Browse Source

fix: bind stdio to its exact fresh identity

Tianyi Cui 3 months ago
parent
commit
6b782b0189

+ 3 - 1
docs/config-catalog.md

@@ -119,6 +119,8 @@ export interface Config {
   agents: (AgentOptions & {
     /** Stable config label used in logs and as the fresh combined-id prefix. */
     id: string
+    /** Optional exact identity for a fresh session; absent lets the loop mint one from the label. */
+    sessionId?: SessionId
     /** Optional workspace for a fresh session. */
     cwd?: string
     /** Persisted session to resume instead of creating a fresh session. */
@@ -645,7 +647,7 @@ export interface Config {
 
 Depends on: [`agentCore`](../packages/core/agent-core/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
 
-Source: [`packages/ui/stdio-agent/src/index.ts:64`](../packages/ui/stdio-agent/src/index.ts)
+Source: [`packages/ui/stdio-agent/src/index.ts:65`](../packages/ui/stdio-agent/src/index.ts)
 
 ## `@deepseek-ai/dsh-subagent-acp`
 

+ 1 - 1
docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md

@@ -16,7 +16,7 @@ Session itself repeated the same fact as `Session.id` and `Session.header.id`. C
 
 An agent's registry id equals its session id. `CreateAgentOptions` accepts one `sessionId` used for both final registry entries; resume registers the agent under `resumeSessionId`; in-process subagent creation uses the child session id; and `Session.id` derives from `header.id`. A remote ACP run has no local agent/session pair: it keeps one parent-minted lifecycle id while the child server's wire-local session id remains private to ACP calls. The existing creation transaction, final-entry collision checks, and exact-entry detach semantics remain; maps and fields whose sole job was translating between local ids are gone.
 
-The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. A fresh start mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide; `resumeSessionId` instead supplies the exact combined identity to load and register. Logs may use the stable label while all live and durable lookups use the one `SessionId`.
+The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. A fresh start normally mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide; a coupled app may pre-mint and pass the exact fresh `sessionId`, while `resumeSessionId` supplies the exact combined identity to load and register. The two exact-id inputs are mutually exclusive. Stdio uses this narrow escape hatch so its config-created agent and UI share one opaque identity instead of guessing from a prefix. Logs may use the stable label while all live and durable lookups use the one `SessionId`.
 
 `agent/created` and `agent/disposed` remain. They are paired publication lifecycle events, not identity aliases; any later consumer-free removal needs its own proposal after a fresh search.
 

+ 3 - 2
packages/core/agent-loop/README.md

@@ -14,7 +14,7 @@ The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createA
 
 Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`.
 
-- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and mints `${label}-session-<uuid>` before calling this boundary; `resumeSessionId` instead loads and registers the exact persisted id. This keeps fresh restarts collision-free without retaining a second live routing identity.
+- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-<uuid>` before calling this boundary; an app may instead supply an exact fresh `sessionId` when another coupled component must bind to it. `resumeSessionId` loads and registers the exact persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity.
 
 `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
 
@@ -33,6 +33,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo
 interface Config {
   agents: Array<{
     id: string                 // required stable label; prefixes fresh combined ids
+    sessionId?: string         // optional exact identity for a fresh session
     model?: string
     resumeSessionId?: string   // load this persisted session instead of creating one
     cwd?: string               // optional workspace cwd for the fresh session
@@ -40,7 +41,7 @@ interface Config {
 }
 ```
 
-Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. 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` and optional `sessionId` apply only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. 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
 

+ 8 - 3
packages/core/agent-loop/src/index.ts

@@ -326,6 +326,8 @@ export interface Config {
   agents: (AgentOptions & {
     /** Stable config label used in logs and as the fresh combined-id prefix. */
     id: string
+    /** Optional exact identity for a fresh session; absent lets the loop mint one from the label. */
+    sessionId?: SessionId
     /** Optional workspace for a fresh session. */
     cwd?: string
     /** Persisted session to resume instead of creating a fresh session. */
@@ -341,6 +343,7 @@ export class AgentLoop extends Service implements AgentFactory {
   static Config = z.object({
     agents: z.array(z.object({
       id: z.string().required(),
+      sessionId: z.string(),
       model: z.string(),
       cwd: z.string(),
       resumeSessionId: z.string(),
@@ -360,12 +363,14 @@ export class AgentLoop extends Service implements AgentFactory {
     ctx.systemPrompt.variable('model', context => context.agent?.options.model)
     ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
 
-    for (const { id, cwd, resumeSessionId, ...options } of config.agents) {
+    for (const { id, sessionId, cwd, resumeSessionId, ...options } of config.agents) {
       if (resumeSessionId === undefined || resumeSessionId === '') {
-        const sessionId = SessionId(`${id}-session-${randomUUID()}`)
-        this.create(sessionId, options, cwd === undefined ? {} : { cwd })
+        this.create(sessionId ?? SessionId(`${id}-session-${randomUUID()}`), options, cwd === undefined ? {} : { cwd })
         continue
       }
+      if (sessionId !== undefined) {
+        throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`)
+      }
       ctx.effect(() => {
         const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
           void this.resumeWith(ctx, childCtx.sessionPersistence, {

+ 30 - 0
packages/core/agent-loop/tests/config-session-id.spec.ts

@@ -24,7 +24,37 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   })
 }
 
+async function makeCoreContext(): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(LlmService)
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(AgentRegistry)
+  return ctx
+}
+
 describe('config-driven session id', () => {
+  it('accepts one exact fresh id and rejects it alongside a resume id', async () => {
+    const exact = await makeCoreContext()
+    await exact.plugin(AgentLoop, {
+      agents: [{ id: 'main', sessionId: SessionId('stdio-exact'), model: 'mock' }],
+    })
+    expect(exact.agents.get(SessionId('stdio-exact'))?.session.id).toBe('stdio-exact')
+    await exact.fiber.dispose()
+
+    const conflicting = await makeCoreContext()
+    await expect(conflicting.plugin(AgentLoop, {
+      agents: [{
+        id: 'main',
+        sessionId: SessionId('fresh'),
+        resumeSessionId: SessionId('persisted'),
+        model: 'mock',
+      }],
+    })).rejects.toThrow('sessionId and resumeSessionId are mutually exclusive')
+    await conflicting.fiber.dispose()
+  })
+
   it('identity-nests the deferred resume fiber under its labeled owner effect', async () => {
     const ctx = new Context()
     await ctx.plugin(LlmService)

+ 1 - 1
packages/ui/stdio-agent/README.md

@@ -32,7 +32,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
 | `welcome` | `ready.` | the stdin-chat banner |
 | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
 
-Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-<uuid>` agent/session id, so durable restarts cannot collide. The UI's `main` text is a display label, not a second routing id; the UI binds to that fresh-id namespace, or to the exact `resumeSessionId` for a resumed run, and never selects unrelated registry roots. Resumed sessions keep the cwd stored in the persisted session header.
+Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-<uuid>` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; the UI's `main` text is only a display label and never selects another registry root by prefix or insertion order. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header.
 
 ## The bin
 

+ 5 - 2
packages/ui/stdio-agent/src/index.ts

@@ -39,6 +39,7 @@
  */
 
 import type { Context } from 'cordis'
+import { randomUUID } from 'node:crypto'
 import ConsoleExporter from '@cordisjs/plugin-logger-console'
 import z from 'schemastery'
 import { SessionId } from '@deepseek-ai/dsh-session'
@@ -108,6 +109,8 @@ export const Config: z<Config> = z.object({
  * a leaf concern (see the module doc), so it is not mounted here.
  */
 export function apply(ctx: Context, config: Config): void {
+  const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
+  const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
   ctx.plugin(ConsoleExporter)
   ctx.plugin(agentCore, {
     ...config.persona !== undefined ? { persona: config.persona } : {},
@@ -117,7 +120,7 @@ export function apply(ctx: Context, config: Config): void {
       id: 'main',
       model: config.model,
       cwd: process.cwd(),
-      ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
+      ...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId },
     }],
     ...config.skills !== undefined ? { skills: config.skills } : {},
   })
@@ -126,6 +129,6 @@ export function apply(ctx: Context, config: Config): void {
   ctx.plugin(toolAskUser)
   ctx.plugin(uiStdio, {
     welcome: config.welcome ?? 'ready.',
-    ...config.resumeSessionId !== undefined ? { resumeSessionId: config.resumeSessionId } : {},
+    sessionId,
   })
 }

+ 12 - 20
packages/ui/stdio-agent/src/stdio-chat.ts

@@ -36,13 +36,13 @@ export const inject = ['agents', 'userInteraction']
 export interface Config {
   /** Banner printed once on start, before the first `> ` prompt. */
   welcome?: string
-  /** Exact persisted session id the app configured for resume; absent selects the app's fresh `main-session-*` identity. */
-  resumeSessionId?: string
+  /** Exact shared agent/session identity this app instance created or resumed. */
+  sessionId?: string
 }
 
 export const Config: z<Config> = z.object({
   welcome: z.string().default('ready.'),
-  resumeSessionId: z.string(),
+  sessionId: z.string(),
 })
 
 /**
@@ -98,26 +98,18 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
   const welcome = config.welcome ?? 'ready.'
   const { input, output, exit } = runtime
 
-  // Bind only to this app's configured top-level agent. Fresh runs own the
-  // `main-session-*` namespace; resumed runs own the exact persisted id. The
-  // registry's runtime-root relation excludes subagents without confusing it
-  // with durable parentSession lineage. Keeping the matching candidates also
-  // covers HMR's publish-new-before-dispose-old ordering without ever falling
-  // through to an unrelated root owned by another app or test fixture.
-  const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
-  const matchesConfiguredIdentity = (agent: Agent): boolean => resumeSessionId === undefined
-    ? agent.id.startsWith('main-session-')
-    : agent.id === resumeSessionId
-  const configuredRoots = new Set(ctx.agents.roots().filter(matchesConfiguredIdentity))
-  let target: Agent | undefined = [...configuredRoots].at(-1)
+  // Bind only to the exact identity this app passed to its config-created
+  // agent. Session ids are opaque: neither a prefix nor registry order can
+  // identify ownership. The root check rejects a child that somehow preempts
+  // the configured id; later recreation under the same id supports loop HMR.
+  const matchesConfiguredIdentity = (agent: Agent): boolean =>
+    agent.id === config.sessionId && ctx.agents.roots().includes(agent)
+  let target: Agent | undefined = ctx.agents.roots().find(agent => agent.id === config.sessionId)
   ctx.on('agent/created', (agent) => {
-    if (!matchesConfiguredIdentity(agent) || !ctx.agents.roots().includes(agent)) return
-    configuredRoots.add(agent)
-    target ??= agent
+    if (matchesConfiguredIdentity(agent)) target = agent
   })
   ctx.on('agent/disposed', (agent) => {
-    configuredRoots.delete(agent)
-    if (target === agent) target = [...configuredRoots].at(-1)
+    if (target === agent) target = undefined
   })
 
   // Transcript rendering off the durable `session/event` feed — the assistant

+ 13 - 0
packages/ui/stdio-agent/tests/stdio-agent.spec.ts

@@ -93,6 +93,19 @@ describe('dsh-stdio-agent app', () => {
     await ctx.fiber.dispose()
   })
 
+  it('normalizes an empty resume id to a fresh exact app identity', async () => {
+    const ctx = await mount({
+      model: 'mock',
+      resumeSessionId: '',
+      persistenceRoot: '/tmp/dsh-stdio-agent-spec-empty-resume',
+      skills: await isolatedSkillsConfig(),
+    })
+    const agent = ctx.get('agents')?.list()[0]
+    expect(agent?.id).toMatch(/^main-session-[0-9a-f-]{36}$/)
+    expect(agent?.id).toBe(agent?.session.id)
+    await ctx.fiber.dispose()
+  })
+
   it('defaults persistenceRoot and welcome when omitted', async () => {
     // Direct apply (NOT via ctx.plugin, which validates+defaults the config
     // first) so the runtime `?? './.sessions'` / `?? 'ready.'` fallbacks on

+ 11 - 31
packages/ui/stdio-agent/tests/stdio-chat.spec.ts

@@ -74,7 +74,7 @@ function chunkEvent(chunk: StreamChunk): SessionEvent {
   return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } }
 }
 
-const CONFIG: Config = { welcome: 'hi there', resumeSessionId: 'main' }
+const CONFIG: Config = { welcome: 'hi there', sessionId: 'main' }
 
 async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
   const ctx = new Context()
@@ -199,7 +199,7 @@ describe('createStdioChat rendering', () => {
   })
 
   it('accepts a lineage-bearing configured agent created after the UI installs', async () => {
-    const { ctx, input } = await setup({ welcome: 'hi there', resumeSessionId: 'resumed' })
+    const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'resumed' })
     const unrelated = makeAgent('unrelated')
     ctx.agents.register(unrelated)
     const resumed = makeAgent('resumed')
@@ -247,33 +247,21 @@ describe('createStdioChat rendering', () => {
     expect(out.text()).toContain('[main turn 1] ')
   })
 
-  it('retargets a surviving root when HMR publishes it before disposing the old root', async () => {
-    const { ctx, input } = await setup({ welcome: 'hi there' })
-    const oldRoot = makeAgent('main-session-old')
-    const child = makeAgent('child')
-    ;(child.session.header as { parentSession?: string }).parentSession = oldRoot.id
-    const replacement = makeAgent('main-session-replacement')
-    const lateChild = makeAgent('late-child')
+  it('retargets only the exact identity after loop HMR recreation', async () => {
+    const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'main-session-fixed' })
+    const oldRoot = makeAgent('main-session-fixed')
+    const prefixCollision = makeAgent('main-session-unrelated')
     const disposeOld = ctx.agents.register(oldRoot)
-    const disposeChild = ctx.agents.enter(child, oldRoot)
-    ctx.agents.announce(child)
+    ctx.agents.register(prefixCollision)
+    disposeOld()
+    const replacement = makeAgent('main-session-fixed')
     ctx.agents.register(replacement)
-    const disposeLateChild = ctx.agents.enter(lateChild, replacement)
-    ctx.agents.announce(lateChild)
 
-    // The replacement's created edge arrived while oldRoot was still targeted.
-    // A replacement-owned child then arrived even later. Once oldRoot is
-    // removed, runtime ownership still identifies replacement as the only
-    // surviving root instead of selecting either newer child by insertion order.
-    disposeOld()
     input.feed('after hmr')
     await new Promise(resolve => setImmediate(resolve))
 
-    expect(child.sent).toEqual([])
-    expect(lateChild.sent).toEqual([])
+    expect(prefixCollision.sent).toEqual([])
     expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]])
-    disposeLateChild()
-    disposeChild()
   })
 
   it('does not retarget stdin to an unrelated root after the configured agent is disposed', async () => {
@@ -740,7 +728,7 @@ describe('createStdioChat input', () => {
   })
 
   it('drives the exact app-configured resumed session', async () => {
-    const { ctx, input } = await setup({ welcome: 'w', resumeSessionId: 'worker' })
+    const { ctx, input } = await setup({ welcome: 'w', sessionId: 'worker' })
     const agent = makeAgent('worker')
     ctx.agents.register(agent)
     input.feed('hi')
@@ -748,14 +736,6 @@ describe('createStdioChat input', () => {
     expect(agent.sent).toHaveLength(1)
   })
 
-  it('treats an empty resume session id as a fresh configured identity', async () => {
-    const { ctx, input } = await setup({ welcome: 'w', resumeSessionId: '' })
-    const agent = makeAgent('main-session-fresh')
-    ctx.agents.register(agent)
-    input.feed('hi')
-    await new Promise(r => setImmediate(r))
-    expect(agent.sent).toHaveLength(1)
-  })
 })
 
 describe('createStdioChat EOF exit', () => {