From 49e45ff184dbb1c5a293d83359afd0b8ce333f95 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:32:44 +0800 Subject: [PATCH 1/5] fix: reject legacy fallback headers --- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/session.md | 2 +- ...-12-simplify-session-log-representation.md | 2 +- packages/core/session/README.md | 2 +- packages/core/session/src/index.ts | 11 ++++++ .../core/session/tests/request-header.spec.ts | 14 ++++++++ .../tests/jsonl.spec.ts | 19 ++++++++++ .../tests/sqlite.spec.ts | 19 ++++++++++ .../session-persistence/src/coordinator.ts | 5 +++ .../tests/persistence.spec.ts | 36 +++++++++++++++++++ 10 files changed, 108 insertions(+), 4 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 05ecf9df56..9501a86c6b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -245,7 +245,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:593`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:604`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index e4a508e4dd..b7fa0e126f 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -109,7 +109,7 @@ export interface EpochHeader { } ``` -Canonical form: an empty system prompt, an empty tool list, and an empty session prefix are absent fields, matching how requests are built. `messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product (the request is `messagePrefix + derived history`); it is composed once per loop instance and included in every full snapshot that instance records. Legacy v0 logs containing the removed `request/header-delta` format are rejected at seed and persistence-load boundaries rather than replayed incompletely. +Canonical form: an empty system prompt, an empty tool list, and an empty session prefix are absent fields, matching how requests are built. `messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product (the request is `messagePrefix + derived history`); it is composed once per loop instance and included in every full snapshot that instance records. Legacy v0 logs containing the removed `request/header-delta` event or its full-snapshot `fallback` reason are rejected at seed, append, and persistence-load boundaries rather than replayed incompletely. ## `SessionEvent` — one log entry 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/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 f4a87e5a2f..11451811c3 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. */ @@ -306,6 +315,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`) } @@ -391,6 +401,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() + }) }) From 287041e39e93b368646f0fe8298b65feab692acc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:35:25 +0800 Subject: [PATCH 2/5] fix: preserve malformed snapshot fixtures --- packages/support/acp-snapshot/src/suite.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 From 2027c70a17661fe2b8b5aab1685cd443ac2c3b56 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:38:32 +0800 Subject: [PATCH 3/5] fix: drain failed ACP launches --- packages/support/acp-snapshot/src/launcher.ts | 4 +++- packages/support/acp-snapshot/tests/harness.spec.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) 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/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 () => { From 9f9f83c11f0ba190da488156efb015e3f7d921f4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:40:22 +0800 Subject: [PATCH 4/5] docs: align compaction output contract --- packages/compact/compact/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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`) From b47e4c5276604dd1e5a400f5f60d2c16e195df10 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:45:12 +0800 Subject: [PATCH 5/5] fix: suppress teardown startup failures --- docs/architecture.md | 2 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 4 ++-- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/index.ts | 4 +++- packages/core/agent-loop/tests/config-session-id.spec.ts | 5 ++++- 8 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 41ab5c716b..0eb7f1fe55 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 484ba1c632..173806572a 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:353`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:354`](../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 e44df3c3b2..df3aec8b12 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -179,13 +179,13 @@ Source: [`packages/core/agent/src/types.ts:576`](../../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:348`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:349`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 965a640966..74496739e2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:368`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:369`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 78a30bd4e1..cc155ebb16 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:348`](../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:349`](../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:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../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:593`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 2593da7c47..e27e8b625a 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. ### Exported concrete class diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 3c09ef2e7f..06c108d324 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -340,7 +340,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 @@ -431,6 +432,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 b35dc71739..53782fe03c 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()