diff --git a/docs/architecture.md b/docs/architecture.md index 947cf3f909..0e83cba072 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,7 +56,7 @@ Default loop processing remains exposed through plugin-visible services and even A **session** is an append-only event log. A **turn** drains queued input until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. -Startup resolves one identity. No id mints `-session-`; `sessionId` resumes stored or creates; `resumeSessionId` requires history. Failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject buffered work. +Startup resolves identity. No id mints `-session-`; `sessionId` resumes or creates; `resumeSessionId` requires history. Active failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject work; teardown stays silent. ### Turn Flow diff --git a/docs/config-catalog.md b/docs/config-catalog.md index bc083d28c6..a2a7df03c3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -131,7 +131,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:352`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 2f4bacd255..9a2c0624dc 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -179,13 +179,13 @@ Source: [`packages/core/agent/src/types.ts:575`](../../packages/core/agent/src/t ### `agent-loop/config-start-failed` — emit -A declarative agent entry failed before it could publish a live agent. Consumers that buffer work for the configured identity use this transient signal to reject that work instead of waiting forever. +A declarative agent entry failed before it could publish a live agent. Consumers that buffer work for the configured identity use this transient signal to reject that work instead of waiting forever. Normal factory teardown suppresses failures from the cancelled startup attempt. ```ts cordis-catalog 'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void ``` -Source: [`packages/core/agent-loop/src/index.ts:347`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:348`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 274be94393..7a2a1cc294 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -21,7 +21,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise` — one log entry diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f03b5ddb73..a4908294d8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:347`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:348`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:318`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:592`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | diff --git a/docs/rfc/implemented/simplification/2026-07-12-simplify-session-log-representation.md b/docs/rfc/implemented/simplification/2026-07-12-simplify-session-log-representation.md index 4f4bf8d569..a1dfb5eafb 100644 --- a/docs/rfc/implemented/simplification/2026-07-12-simplify-session-log-representation.md +++ b/docs/rfc/implemented/simplification/2026-07-12-simplify-session-log-representation.md @@ -18,7 +18,7 @@ This proposal deliberately retains append and replacement `sourceEventSeqs`, cra Request headers use canonical full snapshots only. Initial and resume anchors remain full snapshots even when unchanged; an in-instance change appends another full `request/header` with reason `change`. The delta event, codec types, diff/apply helpers, and codec-only `fallback` reason are removed. Request reconstruction selects the latest snapshot. -`SESSION_FORMAT_VERSION` remains pinned at `0`, so seed and persistence-load validation explicitly reject an old v0 log containing `request/header-delta`. There is no compatibility fold or migration. JSONL and SQLite tests pin this fail-loud boundary, and the ACP snapshot harness represents legitimate mid-session changes as full pinned headers and full readable prompts. +`SESSION_FORMAT_VERSION` remains pinned at `0`, so seed, append, and persistence-load validation explicitly reject old v0 `request/header-delta` events and full snapshots carrying the removed `fallback` reason. There is no compatibility fold or migration. JSONL and SQLite tests pin this fail-loud boundary, and the ACP snapshot harness represents legitimate mid-session changes as full pinned headers and full readable prompts. ## Alternatives considered diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index fafcad76b8..adad9cfdfa 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -10,7 +10,7 @@ This package is the interface tier of the compaction capability, split so each c | `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | | `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` | -Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). +Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session`, and its durable `compact/summary` event uses the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). ## Service API (`ctx.compact`) diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index b1c5c60453..affb155fe6 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -41,7 +41,7 @@ interface Config { } ``` -Agents listed in config are auto-created at startup. `cwd` seeds a fresh config-created session; a materialized exact `sessionId` remount and an explicit `resumeSessionId` keep the persisted session header. A declarative lookup, resume, setup, or publication failure is contained, logged, and emitted as `agent-loop/config-start-failed(sessionId, error)` because no live `Agent` exists for an `agent/*` signal. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. +Agents listed in config are auto-created at startup. `cwd` seeds a fresh config-created session; a materialized exact `sessionId` remount and an explicit `resumeSessionId` keep the persisted session header. While the factory is active, a declarative lookup, resume, setup, or publication failure is contained, logged, and emitted as `agent-loop/config-start-failed(sessionId, error)` because no live `Agent` exists for an `agent/*` signal; cancellation caused by factory teardown is silent. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. ### Internal concrete driver diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 1faabb9aa6..2984826701 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -339,7 +339,8 @@ declare module 'cordis' { /** * A declarative agent entry failed before it could publish a live agent. * Consumers that buffer work for the configured identity use this - * transient signal to reject that work instead of waiting forever. + * transient signal to reject that work instead of waiting forever. Normal + * factory teardown suppresses failures from the cancelled startup attempt. * @param sessionId - exact shared agent/session identity that failed startup. * @param error - persistence, setup, or publication failure. * @mode emit @@ -430,6 +431,7 @@ export class AgentLoop extends Service implements AgentFactory { sessionId: SessionId, error: unknown, ): void { + if (!this.ownership.isActive()) return this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${renderThrown(error)}`) const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error] for (const callback of this.ctx.events.dispatch('emit', args)) { diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 0cf600d6fe..c705288fb1 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -172,6 +172,8 @@ describe('config-driven session id', () => { const listing = Promise.withResolvers>>() vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(listing.promise) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const failures: unknown[] = [] + ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) const loop = await ctx.plugin(AgentLoop, { agents: [{ id: 'main', sessionId: SessionId('stdio-exact-dispose'), model: 'mock' }], @@ -181,9 +183,10 @@ describe('config-driven session id', () => { await Promise.resolve() expect(disposed).toBe(false) - listing.resolve([]) + listing.reject(new Error('startup cancelled by teardown')) await disposal expect(ctx.agents.get(SessionId('stdio-exact-dispose'))).toBeUndefined() + expect(failures).toEqual([]) expect(warn).not.toHaveBeenCalled() warn.mockRestore() await ctx.fiber.dispose() diff --git a/packages/core/session/README.md b/packages/core/session/README.md index ae168ad3eb..9f196c6d3d 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -53,7 +53,7 @@ Durable values need one accepted representation, not a check followed by a secon ### Request-header reconstruction (`request-header.ts`) -The `request/header` event records a full canonical `EpochHeader` snapshot with reason `initial`, `resume`, or `change`, making the request envelope logged session state and every conversation request a pure function of the log. `foldRequestHeader(events)` selects the latest snapshot from a log or prefix; `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields), and `headerEquals` compares canonical headers. `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it. Legacy v0 seeds containing the removed `request/header-delta` format are rejected rather than partially replayed. +The `request/header` event records a full canonical `EpochHeader` snapshot with reason `initial`, `resume`, or `change`, making the request envelope logged session state and every conversation request a pure function of the log. `foldRequestHeader(events)` selects the latest snapshot from a log or prefix; `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields), and `headerEquals` compares canonical headers. `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it. Legacy v0 seeds containing the removed `request/header-delta` event or its full-snapshot `fallback` reason are rejected rather than partially replayed. ### Session event vocabulary (`types.ts`) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index a11aee5809..b851e5f252 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -212,6 +212,15 @@ function assertSessionEventEnvelope(value: Record, index: numbe } } +/** Reject request-header vocabulary removed with the legacy delta codec. */ +function assertSupportedRequestHeader(type: string, data: unknown, location: string): void { + if (type === 'request/header' + && data !== null && typeof data === 'object' && !Array.isArray(data) + && (data as Record)['reason'] === 'fallback') { + throw new Error(`${location} uses unsupported legacy request/header reason "fallback"`) + } +} + type SessionCallback = (...args: unknown[]) => unknown /** Resolve one listener snapshot, including Cordis's internal dispatch checks. */ @@ -311,6 +320,7 @@ export class Session { throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`) } assertSessionEventEnvelope(snapshot, index) + assertSupportedRequestHeader(snapshot.type, snapshot.data, `seed event at index ${index}`) if (snapshot.seq !== index) { throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`) } @@ -396,6 +406,7 @@ export class Session { if (dataSnapshot === undefined) { throw new Error(`session event "${type}" carries non-JSON-serializable data`) } + assertSupportedRequestHeader(type, dataSnapshot, `session event "${type}"`) const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata) if (surfaceMetadataSnapshot === undefined) { throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`) diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index fa2acdda28..8bc2a4bf6c 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -68,4 +68,18 @@ describe('legacy request-header format', () => { }] as unknown as SessionEvent[] expect(() => new Session(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/) }) + + it('rejects the removed fallback reason in seeds and untyped appends', () => { + const legacy = [{ + type: 'request/header', seq: 0, time: 1, data: { header: { config: CONFIG }, reason: 'fallback' }, + }] as unknown as SessionEvent[] + expect(() => new Session(SessionId('legacy-seed-reason'), legacy)) + .toThrow('unsupported legacy request/header reason "fallback"') + + const session = new Session(SessionId('legacy-append-reason')) + const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent + expect(() => appendLegacy('request/header', { header: { config: CONFIG }, reason: 'fallback' })) + .toThrow('unsupported legacy request/header reason "fallback"') + expect(session.events).toHaveLength(0) + }) }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index b9bc0f8d9a..7c5febb88f 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -162,6 +162,25 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/) }) + it('rejects a stored v0 full header carrying the legacy fallback reason', async () => { + const m = meta('legacy-header-fallback', '/legacy') + const path = logPath(root, m.cwd, m.id) + await mkdir(sessionDir(root, m.cwd), { recursive: true }) + await writeFile(path, [ + JSON.stringify(toHeaderLine(m)), + JSON.stringify({ + type: 'request/header', + seq: 0, + time: 1, + data: { header: { config: { model: 'legacy' } }, reason: 'fallback' }, + }), + '', + ].join('\n')) + + await expect(ctx.sessionPersistence.load(m.id)) + .rejects.toThrow(/unsupported legacy request\/header reason "fallback" at seq 0/) + }) + it('persists a forked child seed through the existing session write path', async () => { const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } }) appendClosedTurn(source) diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 12857d2cd8..15d2a44492 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -161,6 +161,25 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await mounted.dispose() }) + it('rejects a stored v0 full header carrying the legacy fallback reason', async () => { + const path = await freshDbPath() + const m = meta('legacy-header-fallback', '/legacy') + const db = openDatabase(path, 'wal') + db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length) VALUES (?, ?, ?, ?, NULL, NULL)') + .run(m.id, m.version, m.createdAt, m.cwd ?? null) + db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)') + .run(m.id, 0, 'request/header', 1, JSON.stringify({ + header: { config: { model: 'legacy' } }, + reason: 'fallback', + })) + db.close() + + const mounted = await backend(path) + await expect(mounted.ctx.sessionPersistence.load(m.id)) + .rejects.toThrow(/unsupported legacy request\/header reason "fallback" at seq 0/) + await mounted.dispose() + }) + it('an interrupted turn (rows after the last turn/end) is PRESERVED and closed during load', async () => { const path = await freshDbPath() const m = meta('crash') diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index b80e4c8d23..e35a6e2e41 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -158,6 +158,11 @@ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): if (legacy !== undefined) { throw new Error(`session "${id}" contains unsupported legacy request/header-delta event at seq ${legacy.seq}`) } + const fallback = events.find(event => event.type === 'request/header' + && (event.data as { reason?: string }).reason === 'fallback') + if (fallback !== undefined) { + throw new Error(`session "${id}" contains unsupported legacy request/header reason "fallback" at seq ${fallback.seq}`) + } } /** diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index a20d850d83..5d0ba2c607 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -22,6 +22,16 @@ function legacyHeaderDelta(seq = 0): SessionEvent { } as unknown as SessionEvent } +/** An obsolete full-header reason fixture from the removed delta codec. */ +function legacyFallbackHeader(seq = 0): SessionEvent { + return { + type: 'request/header', + seq, + time: 1, + data: { header: { config: { model: 'legacy' } }, reason: 'fallback' }, + } as unknown as SessionEvent +} + /** Optional plugin config: an EXTERNAL store shared across backend instances. */ interface MemoryConfig { store?: MemoryStore } @@ -190,6 +200,19 @@ describe('SessionPersistence service registration', () => { await fiber.dispose() }) + it('rejects a legacy fallback header buffered by a pre-change live producer', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(MemoryPersistence) + const session = ctx.sessions.create(SessionId('legacy-fallback-live'), { meta: { cwd: '/legacy' } }) + const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent + + expect(() => appendLegacy('request/header', legacyFallbackHeader().data)) + .toThrow('unsupported legacy request/header reason "fallback"') + expect(session.events).toHaveLength(0) + await fiber.dispose() + }) + it('rejects a legacy stored prefix during live HMR adoption', async () => { const id = SessionId('legacy-hmr') const m = meta(id, '/legacy') @@ -207,4 +230,17 @@ describe('SessionPersistence service registration', () => { .rejects.toThrow(/unsupported legacy request\/header-delta event at seq 0/) await Promise.allSettled([fiber.dispose()]) }) + + it('rejects a stored legacy fallback header during load', async () => { + const id = SessionId('legacy-fallback-load') + const m = meta(id, '/legacy') + const store: MemoryStore = new Map([[id, { meta: m, events: [legacyFallbackHeader()] }]]) + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(MemoryPersistence, { store }) + + await expect(ctx.sessionPersistence.load(id)) + .rejects.toThrow('unsupported legacy request/header reason "fallback" at seq 0') + await fiber.dispose() + }) }) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 3f9a21d596..30ca0b42b8 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -178,13 +178,14 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // `closed` follows parser exhaustion. Capture both eagerly so a caller that // invokes close after process exit still joins the complete drain boundary. const stdioClosed = new Promise(resolve => child.once('close', () => { resolve() })) - const drained = Promise.all([stdioClosed, client.closed]).then(async () => { + const drained = Promise.allSettled([stdioClosed, client.closed]).then(async ([, clientResult]) => { // The ACP SDK's readable loop dispatches client callbacks without awaiting // them. Once `closed` settles no new callbacks can start, but callbacks // already in flight still belong to this launch's teardown boundary. while (inFlightClientCallbacks.size > 0) { await Promise.allSettled([...inFlightClientCallbacks]) } + if (clientResult.status === 'rejected') throw clientResult.reason }) // A caller may await a pending update without calling close(). Make natural // stream exhaustion terminal for those waiters too, but only after the @@ -206,6 +207,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe try { await spawned } catch (error: unknown) { + await drained.catch(() => undefined) closeUpdateStream() throw error } diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 4d87093c8a..906521c42d 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -486,8 +486,10 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const entries = await readdir(dir, { withFileTypes: true }) await Promise.all(entries .filter(entry => entry.isFile() - && entry.name.startsWith('session.') - && entry.name.endsWith('.jsonl') + // Only valid numbered children are record-owned stale output. + // Malformed session-like names stay for the inventory guard to + // reject instead of being silently deleted during mutation. + && /^session\.[1-9]\d*\.jsonl$/.test(entry.name) && !outputNames.has(entry.name)) .map(entry => rm(join(dir, entry.name)))) fixtureFiles = outputFixtureFiles diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index ff7c304121..6adb0d4268 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -62,8 +62,17 @@ describe('runScenario', () => { it('surfaces an asynchronous child spawn failure through startup and close', async () => { const { dir } = await scenario({}) const launched = launchAcpTestAgent({ agent: AGENT, cwd: join(dir, 'missing') }) + let stdioClosed = false + let clientClosed = false + launched.child.once('close', () => { stdioClosed = true }) + void launched.client.closed.then( + () => { clientClosed = true }, + () => { clientClosed = true }, + ) await expect(launched.spawned).rejects.toMatchObject({ code: 'ENOENT' }) await expect(launched.close()).rejects.toMatchObject({ code: 'ENOENT' }) + expect(stdioClosed).toBe(true) + expect(clientClosed).toBe(true) }) it('centralizes ACP boot, captures, updates, fail-closed permissions, and shutdown', { timeout: 20_000 }, async () => {