From d613fdb073b8235a63ddb8087bcc692daa7942ea Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 13:38:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent-loop):=20third=20review=20pass=20?= =?UTF-8?q?=E2=80=94=20disposal=20discard=20ordering,=20flush=20guard,=20d?= =?UTF-8?q?ocs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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%. --- docs/cordis-catalog/events.md | 42 ++++++----- docs/event-producer-consumer.md | 26 +++---- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/core/agent-loop/src/agent.ts | 69 ++++++++++--------- packages/core/agent-loop/tests/agent.spec.ts | 32 +++++++++ packages/core/agent/src/types.ts | 14 ++-- 6 files changed, 117 insertions(+), 70 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 56c927e0db..05aea0ea7e 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:359`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -96,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:503`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:507`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -121,15 +121,19 @@ Source: [`packages/core/agent/src/types.ts:335`](../../packages/core/agent/src/t ### `agent/inbox/discard` — emit -`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them. Fires once per effective clearing call with every discarded item, after `agent/cancel-requested` and before the abort. +Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` dropping pending steering (in-turn and on the post-turn late-steering drain); and disposal of any still-pending items (before `agent/status('disposed')`). Fires once per drop with every dropped item. ```ts cordis-catalog /** - * `cancel()` (without `keepInbox`) dropped pending inbox items without - * delivering them. Fires once per effective clearing call with every - * discarded item, after `agent/cancel-requested` and before the abort. - * @param agent - the agent whose inbox was cleared. - * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending. + * Pending inbox items were dropped without delivering them, so every + * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR + * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after + * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` + * dropping pending steering (in-turn and on the post-turn late-steering + * drain); and disposal of any still-pending items (before + * `agent/status('disposed')`). Fires once per drop with every dropped item. + * @param agent - the agent whose inbox items were dropped. + * @param messages - the discarded messages in FIFO order (queued then steering); never empty. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ @@ -138,7 +142,7 @@ Source: [`packages/core/agent/src/types.ts:335`](../../packages/core/agent/src/t Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:349`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -184,7 +188,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:453`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:457`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -207,7 +211,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:384`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:388`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -234,7 +238,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:400`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:404`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -259,7 +263,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:414`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:418`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -285,7 +289,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:468`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:472`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -311,7 +315,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:429`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:433`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -333,7 +337,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:372`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -376,7 +380,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:441`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:445`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -398,7 +402,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:479`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:483`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -420,7 +424,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:490`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:494`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f0469118ba..94b204ad43 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,24 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:355`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:359`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:503`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:507`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:335`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:325`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:453`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:384`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:400`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:414`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:468`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:429`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:457`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:388`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:404`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:418`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:472`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:433`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:372`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:441`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:479`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:490`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:445`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:483`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:494`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 150c4880b3..1438096347 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -892,8 +892,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/inbox/discard', mode: 'emit', signature: '\'agent/inbox/discard\'(this: Scoped, agent: Agent, messages: AgentMessage[]): void', - jsDoc: '/**\n * `cancel()` (without `keepInbox`) dropped pending inbox items without\n * delivering them. Fires once per effective clearing call with every\n * discarded item, after `agent/cancel-requested` and before the abort.\n * @param agent - the agent whose inbox was cleared.\n * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: '`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them.', + jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after\n * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop`\n * dropping pending steering (in-turn and on the post-turn late-steering\n * drain); and disposal of any still-pending items (before\n * `agent/status(\'disposed\')`). Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param messages - the discarded messages in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`.', }, { name: 'agent/inbox/enqueue', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 9bc24de5da..b0a20a59e1 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -276,34 +276,40 @@ export class ReactLoopAgent extends Agent { } // No turn open: wrap the injection in a one-shot turn so every event stays // turn-enclosed (the durability/replay boundary is the turn). The payload is - // validated above, so both appends commit together; the finally still owes - // a turn/end (the turn-enclosure invariant) even if a post-commit observer - // throws after turn/start. + // validated above, but `Session.append` can still reject a turn/start + // pre-commit (append re-entrancy from a session/event listener, or an + // internal-dispatch veto), so the finally owes a turn/end only when + // turn/start actually committed. const turn = lastTurnNumber(this.session) + 1 try { this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) this.session.append('user/message', accepted, { surfaceOp: 'append' }) } finally { - // Close the turn if turn/start committed. With the payload validated up - // front both appends commit together, so the turn is always open here; - // the guard remains the turn-enclosure backstop. - /* v8 ignore next -- unopened turn is unreachable after up-front validation; kept as the enclosure backstop. */ + // Close the turn if turn/start made it into the log. A pre-commit veto + // must escape rather than being mistaken for a committed turn/end. if (isTurnOpen(this.session)) { this.session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - // Flush the one-shot turn through the store (the carrier owner), never a - // raw parallel. Keep inject() synchronous: report checkpoint failures live - // instead of rejecting the caller, and track the task so disposal drains it. - const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { - const rendered = errorChain(error) - const err = error instanceof Error ? error : new Error(rendered) - this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`) - agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err) - }) - this.pendingIdleFlushes.add(flush) - // Retire on either settlement path. - const retire = (): void => { this.pendingIdleFlushes.delete(flush) } - void flush.then(retire, retire) + // Checkpoint only an accepted one-shot turn: a turn/start rejected + // pre-commit recorded nothing, so it owes no flush (and a spurious flush + // would emit a phantom-turn agent/error). The payload is validated up + // front, so a committed turn/start is always followed by its user/message. + const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) + // Keep inject() synchronous: report checkpoint failures live instead of + // rejecting the caller, and track the task so disposal still drains it. + if (turnRecorded) { + // Through the store's flush (the carrier owner), never a raw parallel. + const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { + const rendered = errorChain(error) + const err = error instanceof Error ? error : new Error(rendered) + this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`) + agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err) + }) + this.pendingIdleFlushes.add(flush) + // Retire on either settlement path. + const retire = (): void => { this.pendingIdleFlushes.delete(flush) } + void flush.then(retire, retire) + } } } @@ -438,20 +444,21 @@ export class ReactLoopAgent extends Agent { */ private [stopDriver](): Promise | void { if (this._status !== 'disposed') { - // Discard any still-pending inbox items before disposal so every enqueued - // id gets a terminal lifecycle event; a disposed agent never dequeues - // them. Emitted while still published (before the status flip below), and - // only when there is a public lifecycle to observe it. - if (this.published) { - const discarded = this.#inbox.pending() - if (discarded.length > 0) { - const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) - agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) - } - } + // Snapshot any still-pending inbox items, then CLEAR and mark disposed + // BEFORE emitting the discard — mirroring cancel()'s snapshot→clear→emit + // order 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. `send()` emits enqueue unconditionally, so the discard + // is unconditional too (even on an unpublished rollback) to keep every + // enqueued id matched. + const discarded = this.#inbox.pending() this.#inbox.clear() this._status = 'disposed' this.resolveDisposed() + if (discarded.length > 0) { + const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) + agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) + } // Release whenIdle waiters BEFORE the (guarded) event emit — they are // internal state that must settle even if a listener throws below. Each // waiter chains `done`, so it resolves only once the loop actually exits. diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 3b8942d27c..c22919e066 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -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) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index d542c39810..57a71c7772 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -334,11 +334,15 @@ declare module 'cordis' { */ 'agent/inbox/dequeue'(this: Scoped, agent: Agent, message: AgentMessage): void /** - * `cancel()` (without `keepInbox`) dropped pending inbox items without - * delivering them. Fires once per effective clearing call with every - * discarded item, after `agent/cancel-requested` and before the abort. - * @param agent - the agent whose inbox was cleared. - * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending. + * Pending inbox items were dropped without delivering them, so every + * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR + * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after + * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` + * dropping pending steering (in-turn and on the post-turn late-steering + * drain); and disposal of any still-pending items (before + * `agent/status('disposed')`). Fires once per drop with every dropped item. + * @param agent - the agent whose inbox items were dropped. + * @param messages - the discarded messages in FIFO order (queued then steering); never empty. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */