fix(agent-loop): third review pass — disposal discard ordering, flush guard, docs

Address a fresh-eye review of the disposal/injection fixes:
- disposal now snapshots, clears, and marks disposed BEFORE emitting
  agent/inbox/discard (mirroring cancel's snapshot→clear→emit), so a
  re-entrant send/cancel from a discard listener throws 'disposed' or
  finds an empty inbox instead of leaking or double-discarding an id.
  The discard is unconditional (even on unpublished setup-rollback) to
  match send's unconditional enqueue, keeping every id balanced.
- restore the turnRecorded guard on the idle-injection flush: a
  turn/start rejected pre-commit (append reentrancy / internal-dispatch
  veto) records nothing and owes no flush; the previous unconditional
  flush emitted a phantom-turn agent/error. The isTurnOpen/turnRecorded
  branches are reachable (reentrant inject from a session/event
  listener) and now covered by a regression test rather than v8-ignored.
- rewrite the agent/inbox/discard event JSDoc to enumerate all three
  emitters (cancel, terminal turn-stop, disposal) — every enqueued id
  gets exactly one terminal dequeue-or-discard.

Per-file coverage stays 100%.
This commit is contained in:
Turtle
2026-07-24 13:38:36 +08:00
parent c7c1b97501
commit d613fdb073
6 changed files with 117 additions and 70 deletions

View File

@@ -216,6 +216,38 @@ describe('Agent', () => {
expect(flushes).toBe(0) // nothing was appended, so no checkpoint
})
it('idle inject() re-entered from a session/event listener is rejected pre-commit and opens no turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// Injecting from inside a session/event listener re-enters Session.append,
// which rejects pre-commit — so turn/start never commits. The finally sees
// no open turn (closes nothing) and no recorded turn (no checkpoint), and
// the reentrant throw is contained by Session's post-commit dispatch.
// Fire on turn/end: at that instant the outer one-shot turn is closed (no
// turn open), so the reentrant inject takes the idle one-shot-turn path and
// its turn/start append re-enters Session and is rejected pre-commit.
let reentered = false
ctx.on('session/event', (_s, event) => {
if (!reentered && event.type === 'turn/end') {
reentered = true
agent.inject([{ type: 'text', text: 'reentrant' }], { source: { kind: 'plugin', plugin: 'p' } })
}
})
agent.inject([{ type: 'text', text: 'outer' }], { source: { kind: 'plugin', plugin: 'p' } })
// The outer injection's own one-shot turn is balanced; the reentrant one
// opened no turn (its turn/start was rejected pre-commit).
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
expect(turnStarts).toHaveLength(1)
const injected = agent.session.events.filter(e => e.type === 'user/message')
expect(injected).toHaveLength(1) // the reentrant user/message never committed
await new Promise(r => setTimeout(r, 10))
expect(flushes).toBe(1) // only the outer accepted turn checkpointed
})
it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)