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.
This commit is contained in:
@@ -2253,9 +2253,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
},
|
},
|
||||||
|
|
||||||
commands: {
|
commands: {
|
||||||
// Both methods address one session's agent (agentFor keeps its
|
// Both methods address one session's agent. agentFor resumes on miss
|
||||||
// resume-on-miss: clients only send a sessionId for a published
|
// and fences every subagent-owned identity with `agent-busy`; the
|
||||||
// session, and resume restores an existing entity).
|
// 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) {
|
async list(request) {
|
||||||
// Missing service = the deployment omitted dsh-commands from its
|
// Missing service = the deployment omitted dsh-commands from its
|
||||||
// composition, not an empty catalog: fail loud instead of serving [].
|
// composition, not an empty catalog: fail loud instead of serving [].
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user