fix(host): settle pre-aborted asks at registration; make audit pairing callId-symmetric

Two races from the #572 review, still live in the ported registry:

An ask whose signal aborted between the service's own check and the
microtask-deferred waterfall dispatch would register its abort listener
AFTER the signal fired — never invoked, entry pending forever, zombie
frame on every mux replay. The answerer now settles 'cancelled'
synchronously before publishing anything.

The audit back-scan let a callId-less ask claim the newest unclaimed
asked record even when that record carried another call's id. Pairing is
now shape-symmetric: callId-bearing asks take exactly their call's
record, callId-less asks take only callId-less records — neither can
steal under parallel asks.
This commit is contained in:
imccyu
2026-07-29 13:03:26 +08:00
parent 79b1ec2eae
commit 99b1a7e895
2 changed files with 41 additions and 1 deletions
@@ -176,6 +176,36 @@ describe('approval pending registry', () => {
abort.abort()
})
it('an ask whose signal aborted before dispatch settles cancelled without publishing', async () => {
// The service checks the signal, then dispatch rides a microtask: an
// abort in that window must not register a dead listener and strand the
// entry (zombie frame on every replay). Drive the waterfall directly
// with a pre-aborted signal to hit the answerer's register-path guard.
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('approval/asked', { id: 'pre-aborted' as ApprovalRequestId, toolName: 'bash' })
const agent = { session } as unknown as Agent
const cancelled = new AbortController()
cancelled.abort()
const outcome = await ctx.waterfall(
'approval/request',
{ agent, toolName: 'bash', signal: cancelled.signal },
() => Promise.resolve('unavailable' as const),
)
expect(outcome).toBe('cancelled')
// Nothing was published: a fresh mux open replays no approval frame.
const abort2 = new AbortController()
const mux2 = openMux(api, abort2)
await new Promise(resolve => setTimeout(resolve, 10))
expect(mux2.envelopes.some(e => e.payload.type === 'approval/requested')).toBe(false)
abort2.abort()
abort.abort()
void mux
})
it('gateway teardown settles pending approvals as cancelled (question-provider parity)', async () => {
// Mount the proxy on its own fiber so disposal exercises the teardown
// effect while an ask is still pending.