From 468fd29e51f160135d6e2b0f30c335d5a8fbb3cb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:22:28 +0800 Subject: [PATCH] fix(host): classify a raced cold-resume ID collision as agent-busy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/host/apiproxy/src/api-proxy.ts | 8 ++-- .../apiproxy/tests/api-proxy-cold.spec.ts | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 96e040d2a2..517688de34 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/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 []. diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 1e5b095778..7eeeba3af6 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/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' }, + }) + } + }) })