diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5b5985371c..a76173d198 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -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', diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index 565b21406d..04b3a17e75 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -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 diff --git a/packages/session/session-persistence-jsonl/src/generation.ts b/packages/session/session-persistence-jsonl/src/generation.ts index ca8b01f76e..c1f3e14040 100644 --- a/packages/session/session-persistence-jsonl/src/generation.ts +++ b/packages/session/session-persistence-jsonl/src/generation.ts @@ -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 { + 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, diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 2452523849..404a9a4ab1 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -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)) { diff --git a/packages/session/session-persistence-jsonl/tests/generation.spec.ts b/packages/session/session-persistence-jsonl/tests/generation.spec.ts index 83683fd23b..19d59e2e72 100644 --- a/packages/session/session-persistence-jsonl/tests/generation.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/generation.spec.ts @@ -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)) }) diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index cb32ec9026..816139ff23 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -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 () => { diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index deb59b9247..d999ab8262 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -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 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 8477bda721..3693ae9501 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -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. diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 881a3d1472..5b6c10b239 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -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` 则会把取消信号传给串行化后端读取。 diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index be759299c9..591459a397 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -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 { /** diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index a2386fbf72..b8ebfd9125 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -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. */ diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 9270af9d7f..53ddb244ca 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -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') } } diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 67ffb76873..c2681e596c 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -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') })