fix(session): harden migration artifact ownership

This commit is contained in:
Tianyi Cui committed 2026-09-02 03:34:38 +08:00
1 parent d1521ea783
commit 79f27df0dd
13 files changed
+143 -22

No files matched your search

@@ -3868,7 +3868,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CurrentSessionPersistenceListing',
declaration: 'export interface CurrentSessionPersistenceListing {\n readonly status: \'current\';\n readonly header: SessionHeader;\n readonly storedVersion: number;\n readonly targetVersion: number;\n readonly location?: SessionLocation;\n}',
declaration: 'export interface CurrentSessionPersistenceListing {\n readonly status: \'current\';\n readonly storageId?: SessionId;\n readonly header: SessionHeader;\n readonly storedVersion: number;\n readonly targetVersion: number;\n readonly location?: SessionLocation;\n}',
},
{
name: 'DeepSeekLlmApiExtensionMap',
@@ -4380,7 +4380,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'MalformedSessionPersistenceListing',
declaration: 'export interface MalformedSessionPersistenceListing {\n readonly status: \'malformed\';\n readonly targetVersion: number;\n readonly location: SessionLocation;\n readonly reason: string;\n}',
declaration: 'export interface MalformedSessionPersistenceListing {\n readonly status: \'malformed\';\n readonly storageId?: SessionId;\n readonly targetVersion: number;\n readonly location: SessionLocation;\n readonly reason: string;\n}',
},
{
name: 'ManualCompactAgentContext',
@@ -4480,7 +4480,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'MigrationRequiredSessionPersistenceListing',
declaration: 'export interface MigrationRequiredSessionPersistenceListing {\n readonly status: \'migration-required\';\n readonly header: SessionHeader;\n readonly storedVersion: number;\n readonly targetVersion: number;\n readonly location?: SessionLocation;\n}',
declaration: 'export interface MigrationRequiredSessionPersistenceListing {\n readonly status: \'migration-required\';\n readonly storageId?: SessionId;\n readonly header: SessionHeader;\n readonly storedVersion: number;\n readonly targetVersion: number;\n readonly location?: SessionLocation;\n}',
},
{
name: 'ModelCatalog',
@@ -5964,7 +5964,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'UnsupportedSessionPersistenceListing',
declaration: 'export interface UnsupportedSessionPersistenceListing {\n readonly status: \'unsupported\';\n readonly storedVersion?: number;\n readonly targetVersion: number;\n readonly location: SessionLocation;\n readonly reason: string;\n}',
declaration: 'export interface UnsupportedSessionPersistenceListing {\n readonly status: \'unsupported\';\n readonly storageId?: SessionId;\n readonly storedVersion?: number;\n readonly targetVersion: number;\n readonly location: SessionLocation;\n readonly reason: string;\n}',
},
{
name: 'UpdateTeamTaskRequest',
@@ -219,6 +219,30 @@ export function encodeSegment(raw: string): string {
return out
}
/**
* Decode one canonical {@link encodeSegment} result.
*
* @param encoded - candidate storage-directory segment.
* @returns the original string, or `undefined` when the spelling is not canonical.
*/
export function decodeSegment(encoded: string): string | undefined {
if (encoded.length === 0) return undefined
let decoded = ''
for (let index = 0; index < encoded.length;) {
const character = encoded[index] as string
if (character !== '~') {
decoded += character
index += 1
continue
}
const escape = encoded.slice(index + 1, index + 5)
if (!/^[0-9A-F]{4}$/u.test(escape)) return undefined
decoded += String.fromCharCode(Number.parseInt(escape, 16))
index += 5
}
return encodeSegment(decoded) === encoded ? decoded : undefined
}
/**
* Build the readable directory key for a project path.
* Filesystem separators and drive separators become `-`; unsafe code units use
@@ -550,6 +550,18 @@ async function removeTemporary(
}
}
/** Remove a redundant stage after the target has been validated as committed. */
async function removeCommittedTemporary(
path: string,
internals: JsonlGenerationInternals,
): Promise<void> {
try {
await internals.fs.rm(path)
} catch {
// The validated target owns the committed bytes; a redundant link cannot turn success into failure.
}
}
async function validatePhysicalCurrent(
path: string,
compression: JsonlCompression,
@@ -759,6 +771,10 @@ async function ensureCurrent(
!published,
internals,
)
if (staged !== '') {
await removeCommittedTemporary(staged, internals)
staged = ''
}
return {
status: 'migrated',
fromVersion,
@@ -43,7 +43,7 @@ import {
interruptedTurnClosers,
} from '@deepseek-ai/dsh-session'
import {
encodeSegment, eventLines, generationLogFilename, generationLogPath, logPath, logSuffix,
decodeSegment, encodeSegment, eventLines, generationLogFilename, generationLogPath, logPath, logSuffix,
parseGenerationLogFilename, parseHeader, parseHeaderValue, projectDir, scanLog, sessionDir,
SessionLogScanner, toHeaderLine,
type JsonlCompression,
@@ -762,6 +762,10 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
signal?.throwIfAborted()
for (const dir of await this.listSessionDirs(project, signal)) {
signal?.throwIfAborted()
const decodedStorageId = decodeSegment(basename(dir))
const storageIdentity = decodedStorageId === undefined
? {}
: { storageId: makeSessionId(decodedStorageId) }
const selected = await this.resolveGenerationInDirectory(dir, signal)
if (selected === undefined) continue
const path = selected.sourcePath
@@ -776,6 +780,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
if (first === undefined) {
listing = {
status: 'malformed',
...storageIdentity,
targetVersion: sessionFormatCatalog.currentVersion,
location,
reason: 'session artifact has no complete independently readable header',
@@ -799,6 +804,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
await this.assertStoredIdentity(path, selected.sourceVersion, header, undefined, signal)
listing = {
status: 'current',
...storageIdentity,
storedVersion: result.storedVersion,
targetVersion: result.targetVersion,
header,
@@ -809,6 +815,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
await this.assertStoredIdentity(path, selected.sourceVersion, header, undefined, signal)
listing = {
status: 'migration-required',
...storageIdentity,
storedVersion: result.storedVersion,
targetVersion: result.targetVersion,
header,
@@ -817,6 +824,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
} else if (result.status === 'unsupported') {
listing = {
status: 'unsupported',
...storageIdentity,
storedVersion: result.storedVersion,
targetVersion: result.targetVersion,
location,
@@ -826,6 +834,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
const malformed = result as { readonly targetVersion: number; readonly reason: string }
listing = {
status: 'malformed',
...storageIdentity,
targetVersion: malformed.targetVersion,
location,
reason: malformed.reason,
@@ -840,6 +849,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
else reason = String(error)
listing = {
status: 'malformed',
...storageIdentity,
targetVersion: sessionFormatCatalog.currentVersion,
location,
reason,
@@ -859,6 +869,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
const artifact = artifacts[index] as { listing: SessionPersistenceListing; path: string }
artifact.listing = {
status: 'malformed',
...artifact.listing.storageId === undefined ? {} : { storageId: artifact.listing.storageId },
targetVersion: sessionFormatCatalog.currentVersion,
location: { kind: 'jsonl', path: artifact.path },
reason: `duplicate JSONL session id "${id}" appears in multiple project directories`,
@@ -1246,7 +1257,16 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
const cached = this.validatedCurrentGenerations.get(id)
if (cached !== undefined) {
signal?.throwIfAborted()
return cached
try {
await stat(cached.sourcePath)
signal?.throwIfAborted()
return cached
} catch (error: unknown) {
signal?.throwIfAborted()
if (!isENOENT(error)) throw error
this.validatedCurrentGenerations.delete(id)
}
signal?.throwIfAborted()
}
const matches: ResolvedJsonlGeneration[] = []
for (const project of await this.listProjectDirs(signal)) {
@@ -903,7 +903,7 @@ describe('JSONL immutable generation publication', () => {
expect(await readFile(request.sourcePath, 'utf8')).toBe(line(header(0)) + line(event0))
})
it('keeps an exclusively published target when successful stage cleanup fails', async () => {
it('reports success after exclusive publication when redundant stage cleanup fails', async () => {
const root = await tempRoot()
const request = options(root)
const cleanup = new Error('published stage cleanup failed')
@@ -917,7 +917,7 @@ describe('JSONL immutable generation publication', () => {
await rm(path, { force: true })
},
}),
})).rejects.toBe(cleanup)
})).resolves.toMatchObject({ status: 'migrated', path: request.currentPath })
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(1)) + line(event0))
})
@@ -9,7 +9,7 @@ import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-sess
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import type { SessionPersistenceListing } from '@deepseek-ai/dsh-session-persistence'
import {
encodeSegment, eventLines, generationLogFilename, generationLogPath, logPath, parseGenerationLogFilename,
decodeSegment, encodeSegment, eventLines, generationLogFilename, generationLogPath, logPath, parseGenerationLogFilename,
parseHeader, parseHeaderValue, projectDir, projectKey, scanLog, sessionDir,
SessionLogScanner, toHeaderLine,
} from '../src/format.ts'
@@ -317,6 +317,16 @@ describe('JsonlSessionPersistence: format helpers', () => {
expect(() => encodeSegment('')).toThrow(/empty/)
})
it('decodeSegment accepts only canonical storage-directory spellings', () => {
for (const value of ['plain-ID_1.2', '..', 'a/b', 'a~b', String.fromCharCode(0xD800)]) {
expect(decodeSegment(encodeSegment(value))).toBe(value)
}
expect(decodeSegment('')).toBeUndefined()
expect(decodeSegment('~002f')).toBeUndefined()
expect(decodeSegment('~BEEGtail')).toBeUndefined()
expect(decodeSegment('~bad')).toBeUndefined()
})
it('projectKey normalizes project paths into bounded readable names', () => {
expect(projectKey('/Users/qyj/work/deepseek-harness')).toBe('--Users-qyj-work-deepseek-harness--')
expect(projectKey('/a/b-c')).toBe(projectKey('/a-b/c'))
@@ -441,7 +451,7 @@ describe('JsonlSessionPersistence: format helpers', () => {
await mkdir(dirname(path), { recursive: true })
await writeFile(path, `${JSON.stringify({ ...toHeaderLine(m), version: 7 })}\n`)
const [listing] = await ctx.sessionPersistence.list()
expect(listing).toMatchObject({ status: 'unsupported', storedVersion: 7, targetVersion: 1 })
expect(listing).toMatchObject({ status: 'unsupported', storageId: m.id, storedVersion: 7, targetVersion: 1 })
const backend = ctx.sessionPersistence as JsonlSessionPersistence
await expect(backend.loadStored(m.id)).rejects.toThrow(`(raw log: ${path})`)
const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error)
@@ -480,6 +490,17 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
expect(listedIds(await ctx.sessionPersistence.list())).toContain(m.id)
})
it('invalidates a cached current path removed outside the process', async () => {
const m = meta('removed-cached-current', '/work')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
await rm(rawLogPath(root, m.cwd, m.id))
await expect(ctx.sessionPersistence.load(m.id)).rejects.toMatchObject({
name: 'SessionPersistenceNotFoundError',
})
})
it('lists a seeded header without reading an event body', async () => {
const id = SessionId('header-only-seeded')
const path = rawGenerationPath(root, '/work', id, 0)
@@ -2045,7 +2066,11 @@ describe('JsonlSessionPersistence: edge cases', () => {
const listings = await ctx.sessionPersistence.list()
expect(listedIds(listings)).toEqual(['real'])
expect(listings.filter(listing => listing.status === 'malformed')).toHaveLength(3)
expect(listings.filter(listing => listing.status === 'malformed')).toEqual(expect.arrayContaining([
expect.objectContaining({ storageId: 'empty' }),
expect.objectContaining({ storageId: 'notheader' }),
expect.objectContaining({ storageId: 'badjson' }),
]))
})
it('list reads a header line longer than the 8KB read chunk', async () => {
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md
README.md: 8477bda7218d943e9b2a5b10eff54ef593c00876
README.zh.md: 881a3d14723a6012f0e47125b18d502b4805bb7b
README.md: 3693ae95011c11981b3dd80673617db66c8f1d41
README.zh.md: 5b6c10b239db719859f2f5d80360e780a3fcbf2b
@@ -43,7 +43,7 @@ const { meta, inheritedEventCount, events } = await ctx.sessionPersistence.load(
const listings = await ctx.sessionPersistence.list() // header-only artifact descriptors
```
`append` resolves only after the batch is durable, so a resolved write survives an OS crash or power loss. Ordinary `create(meta, inheritedEventCount)` remains lazy; `meta.isSeeded: true` requires the sibling exact cut, while unseeded metadata may omit it and rejects a nonzero value. The first materializing batch for a seeded session must reach the complete inherited prefix, so storage never exposes metadata whose cut exceeds its log. A lifecycle frontend calls `ensureMaterialized` only when an empty session must itself appear in durable listing without inventing an event. `list` and `listSnapshots` read only independent headers and return one current, migration-required, unsupported, or malformed descriptor for the highest canonical generation in each Session directory. `load` returns an immutable balanced log and commits any needed crash recovery. For already-current storage, `inspect` keeps synthetic recovery in memory; a supported historical inspection first publishes a repaired current successor beside the unchanged source. `readFrom` accepts a `SessionLogOffset` and returns a detached `SessionEventSuffix` carrying that `fromSeq`, the unchanged inherited cut, and only stored events at or after the cut. A session's version-qualified artifact target (`locate`) resolves without filesystem I/O.
`append` resolves only after the batch is durable, so a resolved write survives an OS crash or power loss. Ordinary `create(meta, inheritedEventCount)` remains lazy; `meta.isSeeded: true` requires the sibling exact cut, while unseeded metadata may omit it and rejects a nonzero value. The first materializing batch for a seeded session must reach the complete inherited prefix, so storage never exposes metadata whose cut exceeds its log. A lifecycle frontend calls `ensureMaterialized` only when an empty session must itself appear in durable listing without inventing an event. `list` and `listSnapshots` read only independent headers and return one current, migration-required, unsupported, or malformed descriptor for the highest canonical generation in each Session directory. A descriptor's optional `storageId` comes from the backend-owned location rather than the header, so an unsupported or malformed artifact can still reserve its Session id. `load` returns an immutable balanced log and commits any needed crash recovery. For already-current storage, `inspect` keeps synthetic recovery in memory; a supported historical inspection first publishes a repaired current successor beside the unchanged source. `readFrom` accepts a `SessionLogOffset` and returns a detached `SessionEventSuffix` carrying that `fromSeq`, the unchanged inherited cut, and only stored events at or after the cut. A session's version-qualified artifact target (`locate`) resolves without filesystem I/O.
Cancellation on `prepare`, `inspect`, or `borrowSession` stops only that observer's wait. Shared cold preparation or historical migration that another observer can reuse may continue to completion; cancellation never rolls back a generation already entering durable publication. Detached `readFrom` and `readRaw` instead pass their cancellation signal to the serialized backend read.
@@ -43,7 +43,7 @@ const { meta, inheritedEventCount, events } = await ctx.sessionPersistence.load(
const listings = await ctx.sessionPersistence.list() // header-only artifact descriptors
```
`append` 只在批次持久后返回,因此成功返回的写入在操作系统崩溃或断电后依然存在。普通 `create(meta, inheritedEventCount)` 保持惰性;`meta.isSeeded: true` 要求单独的精确 cut,unseeded metadata 可以省略它并拒绝非零值。seeded 会话的首个物化批次必须到达完整继承前缀,因此存储绝不公开 cut 超过日志的 metadata。只有当空会话本身必须出现在持久列表中时,生命周期前端才调用 `ensureMaterialized`,且不会虚构事件。`list` 与 `listSnapshots` 只读取独立 header,并为每个 Session 目录中数值最高的规范 generation 返回一个 current、migration-required、unsupported 或 malformed descriptor。`load` 返回不可变的平衡日志并提交任何需要的崩溃恢复。对于已经是当前格式的存储,`inspect` 只在内存中保留合成恢复;受支持的历史检查会先在不改变源文件的情况下于其旁边发布已修复的当前后继 generation。`readFrom` 接受 `SessionLogOffset`,并返回分离的 `SessionEventSuffix`,其中携带该 `fromSeq`、不变的继承 cut,以及 cut 位置或之后的存储事件。会话的版本限定产物目标(`locate`)不经文件系统 I/O 即可解析。
`append` 只在批次持久后返回,因此成功返回的写入在操作系统崩溃或断电后依然存在。普通 `create(meta, inheritedEventCount)` 保持惰性;`meta.isSeeded: true` 要求单独的精确 cut,unseeded metadata 可以省略它并拒绝非零值。seeded 会话的首个物化批次必须到达完整继承前缀,因此存储绝不公开 cut 超过日志的 metadata。只有当空会话本身必须出现在持久列表中时,生命周期前端才调用 `ensureMaterialized`,且不会虚构事件。`list` 与 `listSnapshots` 只读取独立 header,并为每个 Session 目录中数值最高的规范 generation 返回一个 current、migration-required、unsupported 或 malformed descriptor。descriptor 的可选 `storageId` 来自后端拥有的 location 而非 header,因此 unsupported 或 malformed 产物仍能占用自己的 Session id。`load` 返回不可变的平衡日志并提交任何需要的崩溃恢复。对于已经是当前格式的存储,`inspect` 只在内存中保留合成恢复;受支持的历史检查会先在不改变源文件的情况下于其旁边发布已修复的当前后继 generation。`readFrom` 接受 `SessionLogOffset`,并返回分离的 `SessionEventSuffix`,其中携带该 `fromSeq`、不变的继承 cut,以及 cut 位置或之后的存储事件。会话的版本限定产物目标(`locate`)不经文件系统 I/O 即可解析。
`prepare`、`inspect` 或 `borrowSession` 的取消只停止该观察者等待。可由另一观察者复用的共享冷准备或历史迁移可以继续完成;取消绝不会回滚已经进入持久发布阶段的 generation。分离的 `readFrom` 与 `readRaw` 则会把取消信号传给串行化后端读取。
@@ -61,12 +61,13 @@ export class SessionPersistenceCorruptionError extends Error {
}
/**
* The stored log is intact but this runtime cannot faithfully interpret it:
* the header carries an unsupported format version, or an event's type is
* unknown to this build and the event is not marked ignorable. Distinct from
* {@link SessionPersistenceCorruptionError} — nothing is damaged; the raw log
* remains readable at {@link location} when the backend keeps one artifact
* per session.
* This runtime cannot faithfully interpret the stored log. The header may
* carry an unsupported format version, an event type may be unknown and not
* ignorable, or migration validation may refuse malformed historical input.
* Unlike {@link SessionPersistenceCorruptionError}, this error leaves the
* source artifact unchanged; it does not certify that the artifact is intact.
* The raw bytes remain available at {@link location} when the backend keeps
* one artifact per session.
*/
export class SessionFormatUnsupportedError extends Error {
/**
@@ -23,6 +23,8 @@ export { SessionPersistenceNotFoundError } from './errors.ts'
/** One current-format artifact whose header is already the latest logical type. */
export interface CurrentSessionPersistenceListing {
readonly status: 'current'
/** Session id encoded by the backend location, when recoverable without trusting the header. */
readonly storageId?: SessionId
/** Latest logical header decoded without reading event bodies. */
readonly header: SessionHeader
/** Physical format version read from the artifact header. */
@@ -36,6 +38,8 @@ export interface CurrentSessionPersistenceListing {
/** One supported historical artifact that will migrate on its first body read. */
export interface MigrationRequiredSessionPersistenceListing {
readonly status: 'migration-required'
/** Session id encoded by the backend location, when recoverable without trusting the header. */
readonly storageId?: SessionId
/** Latest logical header translated without reading event bodies. */
readonly header: SessionHeader
/** Physical historical format version read from the artifact header. */
@@ -49,6 +53,8 @@ export interface MigrationRequiredSessionPersistenceListing {
/** One intact artifact whose format has no complete migration path in this build. */
export interface UnsupportedSessionPersistenceListing {
readonly status: 'unsupported'
/** Session id encoded by the backend location, when recoverable without trusting the header. */
readonly storageId?: SessionId
/** Physical version when the minimal header exposes one. */
readonly storedVersion?: number
/** Format version this build writes and restores. */
@@ -62,6 +68,8 @@ export interface UnsupportedSessionPersistenceListing {
/** One artifact whose minimal header is not structurally readable. */
export interface MalformedSessionPersistenceListing {
readonly status: 'malformed'
/** Session id encoded by the backend location, when recoverable without trusting the header. */
readonly storageId?: SessionId
/** Format version this build writes and restores. */
readonly targetVersion: number
/** Stable backend location for operator diagnosis. */
@@ -476,7 +476,7 @@ export class SubagentContinuationManager {
this.assertChildIdAvailable(childId)
if (persisted.some(snapshot => (
snapshot.status === 'current' || snapshot.status === 'migration-required'
) && snapshot.header.id === childId)) {
) ? snapshot.header.id === childId : snapshot.storageId === childId)) {
throw new SubagentError(`subagent "${childId}" already exists`, 'DUPLICATE_CHILD')
}
}
@@ -280,6 +280,33 @@ describe('SubagentRuntime.startContinuable', () => {
listSnapshots.mockRestore()
})
it.each(['unsupported', 'malformed'] as const)(
'rejects a reserved identity occupied by an unreadable %s artifact',
async (status) => {
const { ctx, parent } = await setup([])
const reservedId = SessionId(`00000000-0000-4000-8000-${status === 'unsupported' ? '000000000125' : '000000000126'}`)
const base = {
storageId: reservedId,
targetVersion: SESSION_FORMAT_VERSION,
location: { kind: 'jsonl', path: `/sessions/${reservedId}/session.jsonl` },
reason: `${status} artifact`,
revision: SessionPersistenceRevision(`${status}:1`),
}
const listSnapshots = vi.spyOn(ctx.sessionPersistence, 'listSnapshots').mockResolvedValue([
status === 'unsupported'
? { ...base, status, storedVersion: SESSION_FORMAT_VERSION + 1 }
: { ...base, status },
])
await expect(ctx.subagents.startContinuable({
...startSpec(parent),
childId: reservedId,
})).rejects.toMatchObject({ code: 'DUPLICATE_CHILD' })
expect(ctx.agents.get(reservedId)).toBeUndefined()
listSnapshots.mockRestore()
},
)
it('rejects without ids when the provider has no prepareContinuable capability', async () => {
const { ctx, parent } = await setup([])
const start = vi.fn(async () => { throw new Error('must not dispatch') })