fix(agent-loop): second review pass — late-steering discard, dead-branch, catalog leaks

Address a second fresh-eye review of the review fixes:
- MAJOR: late steering that lands after runTurn returns terminally
  stopped (e.g. during the post-turn flush) was drained by runLoop and
  dropped without a discard, leaving a dangling outstanding id the
  negative-only invariant can't catch. Emit agent/inbox/discard for it,
  symmetric with the in-turn terminal-stop drop.
- remove the dead cancel() idle-settle branch: whenIdle's fast path
  already resolves for a lone quiet item, so no waiter is ever left for
  it to settle. Document why.
- gen-cordis-api classShape now drops private/protected/#private members
  and strips getter/setter bodies, so Session no longer leaks private
  fields and getter bodies into the model catalog.
- document that AgentMessage intentionally omits meta (durable-only).

Adds a regression test for the late-steering discard.
This commit is contained in:
Turtle
2026-07-23 22:33:12 +08:00
parent 98ee4ce429
commit 1f9a3e1bee
14 changed files with 122 additions and 69 deletions

View File

@@ -117,4 +117,39 @@ describe('inbox FIFO-conservation invariant', () => {
expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false)
expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false)
})
it('stays balanced when late steering lands after a terminal stop (post-turn flush window)', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let enqueues = 0
const discards: number[] = []
ctx.on('agent/inbox/enqueue', (subject) => { if (subject === agent) enqueues += 1 })
ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent) discards.push(messages.length) })
// Terminal-stop the turn, then steer during the post-turn flush window
// (status is still running). That late steer is drained by runLoop and
// dropped because the turn terminally stopped; it must still be discarded so
// its enqueue is matched (the drain sits on a different code path than the
// in-turn terminal-stop drop).
ctx.on('agent/turn-stop', subject => (subject === agent ? { action: 'stop' as const } : undefined))
let steered = false
ctx.on('session/flush', (session) => {
if (session !== agent.session || steered) return
steered = true
agent.steer([{ type: 'text', text: 'late' }], { source: { kind: 'plugin', plugin: 'late' } })
})
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// The prompt plus the late steer both enqueued; both are matched (the prompt
// dequeued, the late steer discarded) so no id is left outstanding.
expect(enqueues).toBe(2)
expect(discards).toEqual([1])
expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false)
expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false)
})
})