Prechádzať zdrojové kódy

fix(host): classify a raced cold-resume ID collision as agent-busy

When a generic `agentFor` cold resume loses the identity to a parent's
concurrent `enter()` — the collision rejection arrives from
`ctx.agents.resume` publication after the pre-resume re-check — the error
fell through to the `internal` mapping. Clients retrying then see a
transient-looking internal failure instead of the stable ownership error
that `ensureSession`'s `.catch` already produces for the exact same
published-winner case.

Mirror that re-classification in `agentFor`'s resume error path: after the
typed errors, re-check the registry and attached store and answer
`agent-busy` when the raced winner is subagent-owned. Adds a regression
test whose resume mock publishes the subagent winner before throwing the
ID-collision error.
Tianyi Cui 2 mesiacov pred
rodič
commit
468fd29e51

+ 5 - 3
packages/host/apiproxy/src/api-proxy.ts

@@ -2253,9 +2253,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
     },
 
     commands: {
-      // Both methods address one session's agent (agentFor keeps its
-      // resume-on-miss: clients only send a sessionId for a published
-      // session, and resume restores an existing entity).
+      // Both methods address one session's agent. agentFor resumes on miss
+      // and fences every subagent-owned identity with `agent-busy`; the
+      // api/commands.ts module contract owns that fence's wording, so this
+      // comment only notes the routing shape: clients send a sessionId for a
+      // published session, and resume restores an existing entity.
       async list(request) {
         // Missing service = the deployment omitted dsh-commands from its
         // composition, not an empty catalog: fail loud instead of serving [].

+ 39 - 0
packages/host/apiproxy/tests/api-proxy-cold.spec.ts

@@ -338,4 +338,43 @@ describe('sessions.prompt synchronous rejection', () => {
       }
     }
   })
+
+  it('classifies a raced cold-resume ID collision as agent-busy', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(UserInteractionService)
+    const sessionId = sid('race-resume')
+    const meta: SessionHeader = header('race-resume', 1000)
+    ctx.provide('sessionPersistence', {
+      list: () => Promise.resolve([meta]),
+      inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }),
+      locate: () => undefined,
+    } as never)
+    // The raced winner: a live parent-owned subagent publishes the identity
+    // while the generic cold resume is in flight, so the resume collides.
+    const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } })
+    const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
+    ctx.agents.register(parent)
+    const childSession = ctx.sessions.create(sessionId, {
+      meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
+    })
+    const child = { id: sessionId, session: childSession, status: 'idle', ctx } as unknown as Agent
+    vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => {
+      // The parent's `enter()` wins the identity between the pre-resume
+      // re-check and publication; the generic resume then collides.
+      ctx.agents.register(child)
+      throw new Error('session id already published')
+    })
+    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+
+    const models = await api.sessions.models(request({ sessionId }))
+    expect(models.result.ok).toBe(false)
+    if (!models.result.ok) {
+      expect(models.result.error).toMatchObject({
+        code: 'agent-busy',
+        details: { reason: 'use subagent delivery for this child session' },
+      })
+    }
+  })
 })