fix(agent-loop): address review — quiet-item parking, meta, discard balance

Resolve six review findings on the unified-send change:
- quiet (wakeup:false) queued items no longer un-park the driver; the
  inbox distinguishes hasWakingQueued (drives the loop, idle/quiescence)
  from hasQueued (anything to dequeue), so a lone quiet item parks at idle
  and rides the next waking send. whenIdle/cancel settle off the waking
  signal, so cancelling a parked quiet item no longer hangs whenIdle.
- SendOptions.meta on queued/steering sends now reaches the durable
  user/message and steering/message (was dropped except on injection).
- a terminal agent/turn-stop that drops pending steering emits
  agent/inbox/discard so the enqueue-dequeue-or-discard ledger balances.
- the loop-authored continuation reason is snapshotted and frozen like a
  public send.
- gen-cordis-api collects exported classes (body-stripped) so the now-
  abstract-class Agent and its transitive shapes reappear in the API
  catalog.

Adds regression tests for each and re-records the affected snapshot.
This commit is contained in:
Turtle
2026-07-23 21:41:42 +08:00
parent c402cc2422
commit 98ee4ce429
13 changed files with 245 additions and 24 deletions
+21 -2
View File
@@ -201,7 +201,10 @@ export class ReactLoopAgent extends Agent {
id: AgentMessageId, content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions,
): InboxMessage {
const contexts = options?.contexts ?? []
const accepted = snapshotJsonValue({ id, content, source, contexts, wakeup })
const accepted = snapshotJsonValue({
id, content, source, contexts, wakeup,
...options?.meta !== undefined ? { meta: options.meta } : {},
})
if (accepted === undefined) {
throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable')
}
@@ -344,6 +347,11 @@ 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.
@@ -354,6 +362,15 @@ 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()
}
}
cancellation?.request(resolvedCause)
}
@@ -365,7 +382,9 @@ export class ReactLoopAgent extends Agent {
*/
whenIdle(): Promise<void> {
if (this._status === 'disposed') return this.done
if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve()
// A lone quiet (`wakeup:false`) queued item leaves the agent quiescent — the
// driver stays parked — so gate on hasWakingQueued, not hasQueued.
if (this._status !== 'running' && !this.#inbox.hasWakingQueued) return Promise.resolve()
// Agent-owned waiters survive concurrent fiber disposal.
return new Promise<void>((resolve) => {
this.idleWaiters.push(() => {