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

@@ -347,11 +347,6 @@ export class ReactLoopAgent extends Agent {
agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause)
}
if (!keepInbox) {
// Whether the parked driver was already scheduled to run: a waking item
// woke `waitForQueued`, so the loop WILL resume and settle idle waiters
// itself through the pre-run-cancel path (possibly after a replacement
// prompt). Only a lone quiet item leaves the loop truly parked.
const willResume = this.#inbox.hasWakingQueued
// Snapshot before clearing so the discard notification carries the exact
// dropped items; a replacement synchronously enqueued by an
// `agent/cancel-requested` observer belongs to the next turn, not here.
@@ -362,15 +357,12 @@ export class ReactLoopAgent extends Agent {
const items = discarded.map(({ message, steering }) => agentMessage(message, steering))
agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items)
}
// Clearing a parked quiet (`wakeup:false`) item reaches quiescence with no
// status transition and without waking the parked driver, so settle any
// `whenIdle` waiter here. When a waking item was present the loop resumes
// and settles itself; while `running` (including the post-turn flush
// window) the driver still owns the eventual idle transition. So settle
// only for a parked, non-running agent whose sole cleared work was quiet.
if (cancellation === undefined && !willResume && this._status !== 'running') {
this.settleIdleWaiters()
}
// No idle-waiter settle here: a `whenIdle` waiter exists only while the
// agent is `running` or a waking item is queued, and neither is left
// quiescent by clearing the inbox — a lone quiet item takes `whenIdle`'s
// fast path (no waiter), a waking item keeps the woken driver running,
// and a running agent owns its own idle transition (including the
// post-turn flush window).
}
cancellation?.request(resolvedCause)
}

View File

@@ -264,9 +264,17 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
handle.clearTurnCancellation(cancellation)
}
// Late steering becomes queued input unless terminal policy stopped the turn.
for (const message of handle.inbox.drainSteering()) {
if (!terminalStopped) handle.inbox.enqueue(message)
// Late steering (arriving after runTurn returns, e.g. during the post-turn
// flush) becomes queued input — unless terminal policy stopped the turn, in
// which case it is dropped and must publish a discard so its enqueue is
// still matched (the invariant only catches a NEGATIVE count, not a leak).
const lateSteering = handle.inbox.drainSteering()
if (terminalStopped) {
if (lateSteering.length > 0) {
events.emit('agent/inbox/discard', lateSteering.map(message => agentMessage(message, true)))
}
} else {
for (const message of lateSteering) handle.inbox.enqueue(message)
}
// Park at idle unless a waking item still wants the model to run; a lone