Jelajahi Sumber

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%.
Turtle 1 bulan lalu
induk
melakukan
d613fdb073

+ 23 - 19
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/*`
 

+ 13 - 13
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) |

+ 2 - 2
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: 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',

+ 38 - 31
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> | 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.

+ 32 - 0
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)

+ 9 - 5
packages/core/agent/src/types.ts

@@ -334,11 +334,15 @@ declare module 'cordis' {
      */
     'agent/inbox/dequeue'(this: Scoped<Agent>, 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
      */