Merge branch 'codex/simp-unify-agent-session-id' into codex/simp-ui-identity-residue

# Conflicts:
#	docs/event-producer-consumer.md
This commit is contained in:
Tianyi Cui
2026-07-14 14:27:18 +08:00
18 changed files with 201 additions and 52 deletions

View File

@@ -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:354`](../packages/core/agent-loop/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:361`](../packages/core/agent-loop/src/index.ts)
## `@deepseek-ai/dsh-bash-local`

View File

@@ -185,7 +185,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers
'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
```
Source: [`packages/core/agent-loop/src/index.ts:349`](../../packages/core/agent-loop/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:356`](../../packages/core/agent-loop/src/index.ts)
## `approval/*`

View File

@@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<Agent
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
```
Source: [`packages/core/agent-loop/src/index.ts:369`](../../packages/core/agent-loop/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:376`](../../packages/core/agent-loop/src/index.ts)
## `ctx.agents` — `AgentRegistry`
@@ -247,7 +247,7 @@ list(): Session[]
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
```
Source: [`packages/core/session/src/index.ts:609`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:612`](../../packages/core/session/src/index.ts)
## `ctx.skills` — `SkillService`

View File

@@ -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:349`](../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:356`](../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`) | [`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`) | - |

View File

@@ -10,7 +10,7 @@ The session log maintains two representations that cost more machinery than thei
The request-header subsystem implements a custom system/tool delta codec and transmission-decision layer even though its contract says deltas are an encoding optimization, not a reconstructability requirement. Retaining the initial/resume full snapshot at each loop-instance boundary, then writing a canonical full `request/header` whenever that instance's assembled header changes, preserves replay while deleting `SystemDelta`, `ToolsDelta`, round-trip fallback, and the durable `request/header-delta` variant. Codec-only vocabulary disappears with the codec, not because its individual arms were invalid.
This proposal deliberately retains append and replacement `sourceEventSeqs`, crash-repair provenance, and all `SessionStartSource` variants: implemented RFCs give those fields an audit/interception role that zero current readers does not overturn.
The implementation retains append and replacement `sourceEventSeqs`, crash-repair provenance, and all `SessionStartSource` variants because those fields have an audit/interception role that zero current readers does not overturn.
## Decision

View File

@@ -1,4 +1,4 @@
import { mkdtemp, rm, readFile } from 'node:fs/promises'
import { mkdtemp, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -9,6 +9,7 @@ import {
type AgentUnderTest,
type LaunchedAcpTestAgent,
} from '@deepseek-ai/dsh-acp-snapshot'
import { cleanupAcpExampleTest } from './cleanup.ts'
/**
* End-to-end: boot examples/acp-agent as a real subprocess speaking ACP over
@@ -31,16 +32,11 @@ let spawned: LaunchedAcpTestAgent | undefined
let workdir: string | undefined
afterEach(async () => {
try {
await spawned?.close('SIGKILL')
} finally {
spawned = undefined
try {
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
} finally {
workdir = undefined
}
}
const ownedSpawned = spawned
const ownedWorkdir = workdir
spawned = undefined
workdir = undefined
await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir)
})
describe('acp-agent over real stdio (no key required)', () => {

View File

@@ -0,0 +1,38 @@
/** Regression coverage for ACP example teardown. */
import { access, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanupAcpExampleTest } from './cleanup.ts'
let fallbackWorkdir: string | undefined
afterEach(async () => {
if (fallbackWorkdir !== undefined) await rm(fallbackWorkdir, { recursive: true, force: true })
fallbackWorkdir = undefined
})
describe('cleanupAcpExampleTest', () => {
it('removes the workspace after process shutdown fails', async () => {
fallbackWorkdir = await mkdtemp(join(tmpdir(), 'acp-cleanup-'))
const closeFailure = new Error('close failed')
const spawned = { close: vi.fn().mockRejectedValue(closeFailure) }
await expect(cleanupAcpExampleTest(spawned, fallbackWorkdir))
.rejects.toMatchObject({ errors: [closeFailure] })
await expect(access(fallbackWorkdir)).rejects.toThrow()
fallbackWorkdir = undefined
})
it('reports process and workspace failures together', async () => {
const closeFailure = new Error('close failed')
const spawned = { close: vi.fn().mockRejectedValue(closeFailure) }
const failure = await cleanupAcpExampleTest(spawned, '\0').catch((error: unknown) => error)
expect(failure).toBeInstanceOf(AggregateError)
expect((failure as AggregateError).errors).toHaveLength(2)
expect((failure as AggregateError).errors[0]).toBe(closeFailure)
})
})

View File

@@ -0,0 +1,23 @@
/** Shared teardown for ACP example tests. */
import { rm } from 'node:fs/promises'
import type { LaunchedAcpTestAgent } from '@deepseek-ai/dsh-acp-snapshot'
/**
* Close the test agent, then remove its workspace, attempting both operations
* and reporting every failure instead of allowing the later one to mask the
* earlier one.
*/
export async function cleanupAcpExampleTest(
spawned: Pick<LaunchedAcpTestAgent, 'close'> | undefined,
workdir: string | undefined,
): Promise<void> {
const results: PromiseSettledResult<unknown>[] = []
if (spawned !== undefined) results.push(...await Promise.allSettled([spawned.close('SIGKILL')]))
if (workdir !== undefined) results.push(...await Promise.allSettled([rm(workdir, { recursive: true, force: true })]))
const failures = results
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map(result => result.reason as unknown)
if (failures.length > 0) throw new AggregateError(failures, 'ACP example cleanup failed')
}

View File

@@ -1,5 +1,5 @@
import { spawnSync } from 'node:child_process'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { mkdtemp, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -13,6 +13,7 @@ import {
type AgentUnderTest,
type LaunchedAcpTestAgent,
} from '@deepseek-ai/dsh-acp-snapshot'
import { cleanupAcpExampleTest } from './cleanup.ts'
/**
* The default ACP composition (`cordis.yml`) end to end.
@@ -81,16 +82,11 @@ let spawned: Spawned | undefined
let workdir: string | undefined
afterEach(async () => {
try {
await spawned?.close('SIGKILL')
} finally {
spawned = undefined
try {
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
} finally {
workdir = undefined
}
}
const ownedSpawned = spawned
const ownedWorkdir = workdir
spawned = undefined
workdir = undefined
await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir)
})
describe('default sandbox composition keyless smoke (real cordis.yml via the Loader)', () => {

View File

@@ -1,4 +1,4 @@
import { mkdtemp, rm, writeFile, access } from 'node:fs/promises'
import { mkdtemp, writeFile, access } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -9,6 +9,7 @@ import {
type AgentUnderTest,
type LaunchedAcpTestAgent,
} from '@deepseek-ai/dsh-acp-snapshot'
import { cleanupAcpExampleTest } from './cleanup.ts'
/**
* With-key e2e: the Claude Code hook bridge running against the REAL acp-agent
@@ -38,16 +39,11 @@ let spawned: LaunchedAcpTestAgent | undefined
let workdir: string | undefined
afterEach(async () => {
try {
await spawned?.close('SIGKILL')
} finally {
spawned = undefined
try {
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
} finally {
workdir = undefined
}
}
const ownedSpawned = spawned
const ownedWorkdir = workdir
spawned = undefined
workdir = undefined
await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir)
})
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook blocks bash (real model)', () => {

View File

@@ -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. 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.
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. An overlapping remount waits for an already-disposed same-id agent to finish detaching both registries before it inspects persistence, so asynchronous teardown cannot strand the configured identity. 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

View File

@@ -53,6 +53,7 @@ function renderThrown(value: unknown): string {
/** Factory-level ownership of every preparing or live transaction. */
class FactoryOwnership {
private accepting = true
private readonly inactive = Promise.withResolvers<void>()
private transactions = new Set<AgentCreationTransaction>()
private startupTasks = new Set<Promise<void>>()
@@ -74,8 +75,14 @@ class FactoryOwnership {
void task.then(forget, forget)
}
/** Resolve `task`, or stop waiting when factory teardown begins. */
async waitWhileActive(task: Promise<void>): Promise<void> {
await Promise.race([task, this.inactive.promise])
}
async dispose(): Promise<void> {
this.accepting = false
this.inactive.resolve()
const reason = new Error('agent loop is not active')
await Promise.all([
...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
@@ -455,6 +462,8 @@ export class AgentLoop extends Service implements AgentFactory {
agentOptions: AgentOptions,
meta: Pick<SessionHeader, 'cwd'>,
): Promise<void> {
await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId)
if (!this.ownership.isActive()) return
const exists = (await persistence.list()).some(header => header.id === sessionId)
if (!this.ownership.isActive()) return
if (exists) {
@@ -464,6 +473,32 @@ export class AgentLoop extends Service implements AgentFactory {
this.create(sessionId, agentOptions, meta)
}
/** Wait for an already-disposed same-id lifecycle to finish registry teardown. */
private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise<void> {
const current = ownerCtx.agents.get(sessionId)
if (current?.status !== 'disposed') return
const released = Promise.withResolvers<void>()
const checkReleased = (): void => {
if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) {
released.resolve()
}
}
const disposeAgentListener = ownerCtx.on('agent/disposed', (agent) => {
if (agent.id === sessionId) checkReleased()
})
const disposeSessionListener = ownerCtx.on('session/disposed', (session) => {
if (session.id === sessionId) checkReleased()
})
try {
checkReleased()
await this.ownership.waitWhileActive(released.promise)
} finally {
disposeAgentListener()
disposeSessionListener()
}
}
/**
* Create an agent and session under one caller-supplied identity, owned by
* the accessing fiber. Constructor-driven config calls mint a fresh combined

View File

@@ -92,6 +92,50 @@ describe('config-driven session id', () => {
await ctx.fiber.dispose()
})
it('waits for a draining exact-id lifecycle during an overlapping reload', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-overlap-'))
dirs.push(root)
const ctx = await makeCoreContext()
await ctx.plugin(SessionPersistenceJsonl, { root })
const sessionId = SessionId('stdio-exact-overlap')
const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
const firstLoop = await ctx.plugin(AgentLoop, config)
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
const first = ctx.agents.get(sessionId) as ReactLoopAgent
const flushGate = Promise.withResolvers<undefined>()
let flushStarted = false
ctx.on('session/flush', (session) => {
if (session !== first.session) return
flushStarted = true
return flushGate.promise
})
first.inject([{ type: 'text', text: 'persist before replacement' }], {
source: { kind: 'plugin', plugin: 'test' },
})
expect(flushStarted).toBe(true)
const firstDisposal = firstLoop.dispose()
await expect.poll(() => first.status).toBe('disposed')
const failures: unknown[] = []
ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) })
const secondLoop = await ctx.plugin(AgentLoop, config)
await new Promise(resolve => setTimeout(resolve, 0))
expect(ctx.agents.get(sessionId)).toBe(first)
expect(failures).toEqual([])
flushGate.resolve(undefined)
await firstDisposal
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
const second = ctx.agents.get(sessionId) as ReactLoopAgent
expect(second).not.toBe(first)
expect(JSON.stringify(second.session.deriveMessages())).toContain('persist before replacement')
expect(failures).toEqual([])
await secondLoop.dispose()
await ctx.fiber.dispose()
})
it('contains an exact-id persistence lookup failure', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-failure-'))
dirs.push(root)

View File

@@ -214,6 +214,9 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, 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-delta') {
throw new Error(`${location} uses unsupported legacy request/header-delta format`)
}
if (type === 'request/header'
&& data !== null && typeof data === 'object' && !Array.isArray(data)
&& (data as Record<string, unknown>)['reason'] === 'fallback') {

View File

@@ -62,11 +62,17 @@ describe('foldRequestHeader', () => {
})
describe('legacy request-header format', () => {
it('rejects a v0 seed containing request/header-delta', () => {
it('rejects request/header-delta in seeds and untyped appends', () => {
const legacy = [{
type: 'request/header-delta', seq: 0, time: 1, data: { config: CONFIG },
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/)
const session = new Session(SessionId('legacy-append-delta'))
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
expect(() => appendLegacy('request/header-delta', { config: CONFIG }))
.toThrow(/unsupported legacy request\/header-delta/)
expect(session.events).toHaveLength(0)
})
it('rejects the removed fallback reason in seeds and untyped appends', () => {

View File

@@ -185,7 +185,7 @@ describe('SessionPersistence service registration', () => {
await fiber.dispose()
})
it('rejects a legacy header delta buffered by a pre-change live producer', async () => {
it('rejects a legacy header delta from a pre-change live producer', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(MemoryPersistence)
@@ -193,10 +193,9 @@ describe('SessionPersistence service registration', () => {
// Model the runtime shape available to JavaScript or a hot-loaded plugin
// compiled against the obsolete event vocabulary.
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
appendLegacy('request/header-delta', { config: { model: 'legacy' } })
await expect(ctx.sessions.flush(session))
.rejects.toThrow(/unsupported legacy request\/header-delta event at seq 0/)
expect(() => appendLegacy('request/header-delta', { config: { model: 'legacy' } }))
.toThrow(/unsupported legacy request\/header-delta format/)
expect(session.events).toHaveLength(0)
await fiber.dispose()
})

View File

@@ -67,6 +67,15 @@ function isTTYPair(input: Readable, output: Writable): boolean {
return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY)
}
/** Render an arbitrary failure without allowing hostile coercion to escape the UI boundary. */
function renderThrown(value: unknown): string {
try {
return String(value)
} catch {
return '<unrenderable thrown value>'
}
}
interface PendingQuestion {
request: AskUserQuestionRequest
questionIndex: number
@@ -230,7 +239,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
queuedInput.length = 0
submittedWork = sawRunning
if (dropped > 0) {
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${String(error)}`)
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${renderThrown(error)}`)
}
maybeExit()
})
@@ -390,7 +399,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
const text = line.trim()
if (!text) return
if (failedStartup !== undefined) {
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${String(failedStartup.error)}`)
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${renderThrown(failedStartup.error)}`)
return
}
const agent = target

View File

@@ -83,6 +83,10 @@ function chunkEvent(chunk: StreamChunk): SessionEvent {
const CONFIG: Config = { welcome: 'hi there', sessionId: 'main' }
function unrenderableFailure(): unknown {
return { [Symbol.toPrimitive](): never { throw new Error('coercion escaped') } }
}
async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
@@ -753,14 +757,14 @@ describe('createStdioChat input', () => {
it('drops later input after the configured startup fails', async () => {
const { ctx, input } = await setup()
const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
const failure = new Error('persisted session is corrupt')
const failure = unrenderableFailure()
ctx.emit('agent-loop/config-start-failed', SessionId('main'), failure)
input.feed('cannot run')
await new Promise(r => setImmediate(r))
expect(error).toHaveBeenCalledWith(
'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): Error: persisted session is corrupt',
'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): <unrenderable thrown value>',
)
})
@@ -844,11 +848,11 @@ describe('createStdioChat EOF exit', () => {
await flushExit()
expect(exit).not.toHaveBeenCalled()
ctx.emit('agent-loop/config-start-failed', SessionId('main'), new Error('missing persisted session'))
ctx.emit('agent-loop/config-start-failed', SessionId('main'), unrenderableFailure())
await flushExit()
expect(error).toHaveBeenCalledWith(
'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): Error: missing persisted session',
'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): <unrenderable thrown value>',
)
expect(exit).toHaveBeenCalledWith(0)
})