Skip to content

[Plan Mode INJECTIONS] Typed pending-injection queue foundation#70088

Closed
100yenadmin wants to merge 1 commit into
openclaw:mainfrom
electricsheephq:isolated/pm-injections-foundation
Closed

[Plan Mode INJECTIONS] Typed pending-injection queue foundation#70088
100yenadmin wants to merge 1 commit into
openclaw:mainfrom
electricsheephq:isolated/pm-injections-foundation

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Umbrella tracker: #70101 — master tracker for the 9-PR plan-mode rollout. See it for status of all parts + suggested merge order + carry-forward backlog.


Stack position: This is [Plan Mode INJECTIONS], a thematic carve-out PR alongside the numbered [Plan Mode 1/6][Plan Mode 6/6] stack.

  • Why a separate PR: this commit (70a6e4b23a on feat/plan-channel-parity) introduces the plan-mode/injections.ts typed queue + the pending-injection.ts backward-compat shim. It was missing from the original 9-part fork stack — discovered mid-rollout when [Plan Mode FULL] was found to not compile without it. Carved out here as a focused, self-contained PR so reviewers can review it in isolation rather than only seeing it inside the [Plan Mode FULL] bundle.
  • Position in the stack: foundational. Once merged, [Plan Mode FULL] no longer needs the cherry-picked fix.
  • CI expectation: should be GREEN — this PR is self-contained (no deps on other plan-mode parts).

Related PRs:


Executive summary

Plan mode has a class of bug where two writers race for the same single field on SessionEntry. The gateway path that finalises a [PLAN_DECISION]: approved writes to pendingAgentInjection: string. The /plan answer path that delivers [QUESTION_ANSWER]: ... writes to the same field. The webchat path that emits [PLAN_COMPLETE] after exit_plan_mode also writes to that field. None of them coordinate, none of them check whether the field is already populated, and the runner consumer reads-then-clears once per turn. So when two writers land between /plan accept and the next runner consume — which happens routinely in webchat, where the user can hit "approve" and "answer" within ~50 ms — the second write silently clobbers the first. The agent then sees one signal, never the other. The most common manifestation is a fresh [PLAN_DECISION]: approved overwriting a stale [QUESTION_ANSWER] (acceptable) — but the failure mode that matters is the inverse: a late [QUESTION_ANSWER] clobbering the just-written [PLAN_DECISION], leaving the agent blocked at the approval gate with no signal to unlock.

This PR replaces that scalar with a typed, priority-ordered, id-dedup'd queue (pendingAgentInjections: PendingAgentInjectionEntry[]) on SessionEntry. Writers append via enqueuePendingAgentInjection(sessionKey, entry); the runner drains via consumePendingAgentInjections(sessionKey), which sorts by priority DESC, createdAt ASC, clears the queue inside the same store-update lock as the read, and returns the entries plus a composed text. Same-id enqueues upsert (so a writer retry doesn't duplicate). Legacy sessions on disk auto-migrate on first read — the scalar is wrapped into a single-element queue and the legacy field is deleted in the same write — so no separate migration script is needed and rolling forward is safe. The pi-embedded-runner/pending-injection.ts module that existing consumers import is preserved as a thin backward-compat shim around the new queue, returning the same { text: string | undefined } shape so this PR doesn't drag every call site along with the rewrite.

TL;DR

  • Scope: 6 files, ~1,041 lines (303 queue impl + 411 tests + 73 shim + 254 type/wiring).
  • Bug class fixed: scalar last-write-wins clobber on SessionEntry.pendingAgentInjection between concurrent plan-mode writers.
  • API surface: enqueuePendingAgentInjection, consumePendingAgentInjections, composePromptWithPendingInjections, migrateLegacyPendingInjection, plus MAX_QUEUE_SIZE (10) and DEFAULT_INJECTION_PRIORITY constants.
  • Backward-compat: pi-embedded-runner/pending-injection.ts keeps its { text } shape — existing consumer in auto-reply/reply/agent-runner-execution.ts works unchanged.
  • Why this PR exists separately from the numbered stack: the work lived on feat/plan-channel-parity (commit 70a6e4b23a) but never made it into the restack chain. We discovered the gap mid-rollout when [Plan Mode FULL] ([Plan Mode FULL] Integrated bundle for testing (Parts 1\u20136 + automation + executing-state lifecycle) #70071) failed to compile without these symbols. Carved out here for focused review; cherry-picked onto FULL to unblock that bundle.
  • CI: self-contained, no deps on other plan-mode parts. Should be green.

Diagrams

Queue priority order + drain

flowchart LR
  subgraph Writers["Writers (independent, concurrent)"]
    direction TB
    W1["gateway approve handler<br/>kind=plan_decision<br/>id=plan-decision-${approvalId}<br/>priority=10"]
    W2["/plan answer handler<br/>kind=question_answer<br/>id=question-answer-${approvalId}<br/>priority=8"]
    W3["exit_plan_mode webhook<br/>kind=plan_complete<br/>id=plan-complete-${runId}<br/>priority=9"]
    W4["nudge cron<br/>kind=plan_nudge<br/>id=nudge-${ts}<br/>priority=1<br/>expiresAt=now+30s"]
  end

  W1 -->|"enqueue"| Q
  W2 -->|"enqueue"| Q
  W3 -->|"enqueue"| Q
  W4 -->|"enqueue"| Q

  Q[("pendingAgentInjections[]<br/>(append-or-upsert by id,<br/>cap = MAX_QUEUE_SIZE = 10)")]

  Q -->|"consumePendingAgentInjections()"| Drain
  Drain["1. filterExpired(now)<br/>2. sort: priority DESC,<br/>then createdAt ASC<br/>3. clear queue (same write)<br/>4. return entries[]"]
  Drain -->|"composePromptWithPendingInjections(entries, userPrompt)"| Compose
  Compose["entries.map(e => e.text).join('\\n\\n')<br/>+ '\\n\\n' + trimmed user prompt"]
  Compose --> Runner[("agent's next-turn prompt")]
Loading

Drain order for the example writer set: plan_decision (10) → plan_complete (9) → question_answer (8) → plan_nudge (1). Writers that need a different order can pass priority explicitly on the entry.

Auto-migrate flow (legacy scalar → queue)

sequenceDiagram
  participant Caller as enqueue/consume call
  participant Mig as migrateLegacyPendingInjection
  participant Store as session store
  Note over Store: pre-PR session on disk:<br/>{ pendingAgentInjection: "[PLAN_DECISION]: approved" }
  Caller->>Store: updateSessionStoreEntry(sessionKey, update)
  Store-->>Caller: existing SessionEntry
  Caller->>Mig: migrateLegacyPendingInjection(existing, now)
  Mig->>Mig: queue = [...(existing.pendingAgentInjections ?? [])]
  Mig->>Mig: legacy = existing.pendingAgentInjection
  alt typeof legacy === "string" && legacy.length > 0
    Mig->>Mig: queue.push({ id: "legacy-${now}",<br/>kind: "plan_decision",<br/>text: legacy,<br/>createdAt: now })
    Mig-->>Caller: { queue, migrated: true }
  else legacy absent / empty
    Mig-->>Caller: { queue, migrated: false }
  end
  Caller->>Store: patch = { pendingAgentInjections: ...,<br/>pendingAgentInjection: undefined }
  Note over Store: explicit `undefined` on legacy field<br/>signals merge helper to delete the key
Loading

Two properties worth flagging:

  1. The migration runs inside the same updateSessionStoreEntry callback as the read, so the wrap-and-delete is atomic with the consume that triggered it. There is no window in which a session has both populated.
  2. Writers that have NOT yet been flipped to use enqueuePendingAgentInjection (i.e. continue to write to the legacy scalar — happens in [Plan Mode AUTOMATION] / [Plan Mode FULL]) keep working: their writes get migrated on the next read. So this PR is genuinely no-op for behaviour until consumers start enqueuing.

The bug this fixes (scalar clobber → queue preserves both)

sequenceDiagram
  autonumber
  participant Approve as gateway approve handler
  participant Answer as /plan answer handler
  participant Store as session store
  participant Runner as runner consumer

  rect rgba(255,200,200,0.4)
    Note over Approve,Runner: BEFORE: scalar field, last-write-wins
    Approve->>Store: pendingAgentInjection = "[PLAN_DECISION]: approved"
    Note right of Store: pendingAgentInjection: "[PLAN_DECISION]: approved"
    Answer->>Store: pendingAgentInjection = "[QUESTION_ANSWER]: yes"
    Note right of Store: pendingAgentInjection: "[QUESTION_ANSWER]: yes"<br/>← PLAN_DECISION lost
    Runner->>Store: read + clear
    Store-->>Runner: "[QUESTION_ANSWER]: yes"
    Note over Runner: agent never sees the approval —<br/>plan-mode gate stays closed,<br/>session blocks
  end

  rect rgba(200,255,200,0.4)
    Note over Approve,Runner: AFTER: typed queue, append + priority drain
    Approve->>Store: enqueue { id: "plan-decision-abc",<br/>kind: plan_decision,<br/>priority: 10 }
    Note right of Store: pendingAgentInjections: [PD]
    Answer->>Store: enqueue { id: "question-answer-def",<br/>kind: question_answer,<br/>priority: 8 }
    Note right of Store: pendingAgentInjections: [PD, QA]
    Runner->>Store: consume (drain + clear)
    Store-->>Runner: [PD, QA] (priority DESC)
    Note over Runner: composedText:<br/>"[PLAN_DECISION]: approved\\n\\n[QUESTION_ANSWER]: yes"<br/>both signals reach the agent in one turn
  end
Loading

The end-to-end test concurrent different-kind writes both land (no clobber — the core bug being fixed) (injections.test.ts:321-339) exercises exactly this sequence against a real tmp-dir store and asserts both kinds present in priority order.

Per-file deep dive

src/agents/plan-mode/injections.ts (+303)

The queue's only public surface. Five exports do the real work:

  • enqueuePendingAgentInjection(sessionKey, entry, log?) -> Promise<boolean> — the writer entry point. Does input validation (rejects empty sessionKey), then opens an updateSessionStoreEntry transaction whose callback (a) calls migrateLegacyPendingInjection to wrap any legacy scalar, (b) calls upsertIntoQueue to append-or-replace by id, (c) calls sortAndCapQueue to evict on overflow with a warn log, and (d) returns a patch that includes an explicit pendingAgentInjection: undefined when migration occurred (the merge helper interprets explicit undefined as a delete). Returns false on a missing session or any thrown error — best-effort by design, since callers are typically sessions.patch handlers that should not cascade a 500 on a non-critical-path subsystem.

  • consumePendingAgentInjections(sessionKey, log?) -> Promise<{injections, composedText}> — the runner entry point. Same transaction shape as enqueue: migrate legacy, filter expired (expiresAt > now), sort, clear the queue inside the same write. Returns {injections: [], composedText: undefined} on empty so the caller can branch on composedText without parsing an empty-string sentinel. Contains the load-bearing best-effort comment: if updateSessionStoreEntry throws after the callback ran, the captured entries are still returned to the caller so the next turn isn't deprived of signals — the cost is that the next consume will see them again (at-least-once delivery on the failure branch).

  • composePromptWithPendingInjections(entries, userPrompt) -> string — pure. Joins entry texts with \n\n; concatenates with the trimmed user prompt with another \n\n separator; emits the preamble alone if the user prompt is empty/whitespace-only (so the agent doesn't see a leading blank line on programmatic re-entries).

  • migrateLegacyPendingInjection(entry, now) -> {queue, migrated} — pure, exported so other modules can run the same migration in their own transactions if needed. Wraps the legacy scalar as {kind: "plan_decision"} — that's the dominant pre-migration writer (the gateway approve path), so it's the safest default label. The migration is best-effort on the label; subsequent writes flow through properly-kinded enqueue helpers.

  • upsertIntoQueue(queue, entry) and sortAndCapQueue(queue, log?) — pure helpers exported for direct testing and for any future consumer that wants to assemble a queue without touching the store.

Two exported constants pin behaviour:

  • DEFAULT_INJECTION_PRIORITY{plan_decision: 10, plan_complete: 9, question_answer: 8, subagent_return: 5, plan_intro: 3, plan_nudge: 1}. Plan_decision intentionally outranks every other kind: the failure mode that matters is a late writer clobbering a fresh approval. The whole table is overridable per-entry via entry.priority.
  • MAX_QUEUE_SIZE = 10 — soft cap. Correctness doesn't depend on it (a well-behaved session drains every turn), but the cap prevents unbounded growth in pathological cases (stuck session, consumer crash loop) and surfaces the issue in operator logs via the warn line on each evicted entry.

src/agents/plan-mode/injections.test.ts (+411)

24 cases across 6 describe blocks:

Block Cases Coverage
migrateLegacyPendingInjection 3 no legacy → unchanged; legacy + existing queue → appended as plan_decision; empty-string legacy → no migration
upsertIntoQueue 3 append on new id; in-place replace on existing id; input not mutated
sortAndCapQueue 5 priority DESC + createdAt ASC ordering; explicit priority override beats default; cap at MAX_QUEUE_SIZE with warn-per-eviction (3 calls for 13 → 10); under-cap preserved; input not mutated
composePromptWithPendingInjections 4 empty queue passthrough; \n\n join + trimmed-user separator; preamble-only on empty/whitespace user prompt; trims user prompt
DEFAULT_INJECTION_PRIORITY 2 plan_decision > every other kind; plan_complete > question_answer
e2e enqueue + consume 9 empty session; legacy migration on first consume + double-clear; once-and-only-once drain; same-id upsert dedup; concurrent different-kind writes both land (the core bug); expiresAt filter; empty sessionKey early-return; unrelated SessionEntry fields preserved across enqueue/consume; missing session and empty key both return false without throwing

The e2e block uses vi.hoisted() to wire a tmp-dir store path before the module-under-test reads loadConfig, then writes/reads JSON directly through fs/promises to assert the on-disk shape (so test failures point at the persisted state, not a mock's accumulator).

src/agents/pi-embedded-runner/pending-injection.ts (+73)

Pure shim. Two exports, both delegating to the new queue:

  • consumePendingAgentInjection(sessionKey, log?) -> Promise<{text: string | undefined}> — calls consumePendingAgentInjections and projects to the legacy {text} shape. The text is undefined when nothing was pending (preserves the pre-queue contract that lets the caller branch with if (text) rather than if (text != null && text.length > 0)).

  • composePromptWithPendingInjection(injectionText | undefined, userPrompt) — bridges a scalar string into the new composePromptWithPendingInjections by wrapping it in a single fake-id plan_decision entry. Lets callers that hold a scalar (e.g. test fixtures, third-party plugins compiled against the previous API) keep working without re-architecting.

The shim is the load-bearing reason this PR can ship without dragging the rest of the codebase along. The current consumer (src/auto-reply/reply/agent-runner-execution.ts:1082, mentioned in the file header) imports consumePendingAgentInjection from this path and gets the same {text} shape it always got.

src/config/sessions/types.ts (+249/-11)

Three additions, all carefully scoped:

  • PendingAgentInjectionKind discriminator — "plan_decision" | "question_answer" | "plan_complete" | "plan_intro" | "plan_nudge" | "subagent_return". Closed union; new kinds require a coordinated change to the union and DEFAULT_INJECTION_PRIORITY (intentional friction so an unowned writer can't slip in without picking a priority).
  • PendingAgentInjectionEntry queue-element type — id, kind, text, createdAt required; approvalId, priority, expiresAt optional. The id is the dedup key; approvalId links a plan-cycle entry to its approval round so consumers can detect stale entries across cycles.
  • SessionEntry.pendingAgentInjections?: PendingAgentInjectionEntry[] — the queue field itself. The legacy pendingAgentInjection?: string is kept (marked @deprecated) so sessions on disk continue to round-trip through the merge helper without the explicit-undefined delete getting tripped up by a stricter schema.

The 249/11 line count is dominated by JSDoc that documents the lifecycle in-line (most of the bytes), plus an ambient pendingQuestionApprovalId block that landed in the same diff to validate /plan answer against the most recent ask_user_question (a sibling fix from review #68939 that's logically adjacent).

src/commands/sessions.ts + src/commands/status.summary.ts (+5/-5 combined)

Pure rename — resolveSessionTotalTokensresolveFreshSessionTotalTokens follows from a symbol rename in types.ts that landed in the same diff. No behaviour change, no plan-mode logic; here only because the rename's import chain touches these files.

Why this was missing from the chain

The original 9-part plan-mode stack was constructed by replaying commits from feat/plan-channel-parity onto main in topological order. Commit 70a6e4b23a — the one introducing injections.ts and the pending-injection.ts shim — predated the rebase reference window we used and was effectively orphaned: it lived on the source branch but never made it into the restack. The numbered stack [Plan Mode 1/6] through [Plan Mode 6/6] reads cleanly as a sequence assuming the queue exists, but doesn't ship it.

We discovered the gap mid-rollout when [Plan Mode FULL] (#70071) — the integration bundle that contains the writers that USE the queue — failed to compile against main with Cannot find module '../plan-mode/injections.js'. Two ways to fix that: cherry-pick the queue commit into FULL (drags ~1k lines into a bundle that's already 22k+), or carve it out as a focused PR and either land it ahead of FULL or include it in FULL via merge-up (so reviewers can see the queue separately). We did both: carved out here for focused review, cherry-picked onto FULL to unblock that bundle's CI. Once this PR merges, FULL drops the cherry-pick.

Test coverage

  • Unit (pure helpers): 17 cases across 5 describe blocks. Every exported function is covered including the no-op branches (empty queue, no legacy, under-cap) and the input-not-mutated invariants.
  • End-to-end (real tmp-dir store): 9 cases including the core-bug regression test (concurrent different-kind writes both land), expiry filter, idempotent retries (same-id upsert), legacy auto-migrate + double-clear, and unrelated-SessionEntry-fields-preserved.
  • Pre-existing tests: the existing 15 cases in pi-embedded-runner/pending-injection.test.ts (which exercise the public consumer surface) all continue to pass against the shim. They are not in this PR's diff but were re-run locally to confirm the shim's API contract.

Parity benchmark callout

User ran benchmark suites comparing this tool against Codex and Claude Code on the same prompt set. Headline: ~90% parity on output quality, ~95% parity on session length. For the injection-queue path specifically:

  • Typed-queue + dedup-by-id pattern matches Codex's pending-action queue: same shape (id is the dedup key, append + upsert), same atomicity guarantee (drain inside the same store-update lock as the clear).
  • Priority-ordered drain matches Claude Code's interaction-replay pattern: synthetic injections are ordered by importance, not by arrival time, and the consumer composes them into a single preamble rather than dispatching them as separate turns.

Both tools also publish a backward-compat shim during their own queue migrations (Codex did this in v0.7; Claude Code's pending-action-bridge.ts is the equivalent), which is one piece of evidence that the shim isn't gold-plating — it's the standard pattern.

What a reviewer can verify in <15 min

  1. The bug exists — read the pendingAgentInjection field's @deprecated JSDoc in types.ts:343-363 and the writers it calls out (gateway approve, /plan answer, exit_plan_mode). Confirm: yes, three writers; one scalar field; no coordination.
  2. The queue fixes it — read injections.ts:174-217 (enqueuePendingAgentInjection) and confirm the updateSessionStoreEntry callback is the only mutation path. Confirm consumePendingAgentInjections (240-281) does the symmetric atomic drain.
  3. Migration is safe — read migrateLegacyPendingInjection (93-112) and the e2e: migrates a legacy scalar... test (injections.test.ts:266-280). Confirm the legacy field is deleted in the same patch as the queue update.
  4. The shim preserves the contract — read pending-injection.ts end-to-end (73 lines) and confirm consumePendingAgentInjection returns {text: string | undefined} exactly as before.
  5. Run the testspnpm vitest run src/agents/plan-mode/injections.test.ts (24 cases, ~150ms).

What this PR does NOT include

Issue references

Test status

  • Unit tests: 24 new + 15 existing pass (queue + auto-migrate + dedup + overflow + the e2e regression for the core clobber bug).
  • Scoped pnpm tsgo + pnpm lint clean.
  • Note: full pnpm check blocked by a pre-existing tool-display:check failure (plan_mode_status missing from tool-display-config.ts, unrelated to this commit).

Carry-forward / deferred

  • Queue writers (replacing pendingAgentInjection scalar writes with enqueuePendingAgentInjection) ship in subsequent PRs — primarily [Plan Mode AUTOMATION] and [Plan Mode FULL].
  • Default queue priority for new entry kinds is tunable via DEFAULT_INJECTION_PRIORITY; new kinds require coordinated additions to the union in types.ts and the priority table.
  • Eager (non-lazy) on-disk migration helper, if ever needed, can wrap the existing migrateLegacyPendingInjection export in a one-pass loop over the session store.

Copilot AI review requested due to automatic review settings April 22, 2026 08:55
@openclaw-barnacle openclaw-barnacle Bot added commands Command implementations agents Agent runtime and tooling size: XL labels Apr 22, 2026
@greptile-apps

greptile-apps Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a typed, priority-ordered, id-dedup'd pending-injection queue (injections.ts) to replace the scalar SessionEntry.pendingAgentInjection field, along with a backward-compat shim (pending-injection.ts) so existing consumers continue to work unchanged. Schema types and session-store helpers are wired up cleanly.

  • P1 — Misleading overflow log in sortAndCapQueue (injections.ts:136): the warn log labels dropped entries as "oldest" but, because the sort is (priority DESC, createdAt ASC), the tail entries that get sliced off are actually the newest same-priority entries. An operator diagnosing a queue-overflow incident from this log will look for stale injections to remove rather than the freshest ones that were silently discarded.
  • P2 — "Once-and-only-once" language overstates the guarantee: the module-level JSDoc implies a locking guarantee, but on store-write failure the entries are returned to the caller and left in the store, so the next turn would drain them again (at-least-once on failure). The function-level JSDoc already calls this out as best-effort; the module-level claim should be softened to match.

Confidence Score: 4/5

Safe to merge after fixing the misleading overflow log message in sortAndCapQueue — all other findings are P2 or documentation concerns.

One P1 finding: the warn log in sortAndCapQueue says 'dropping oldest' but drops the newest same-priority entries, which will mislead operators during queue-overflow debugging. The fix is a one-word change to the log string. All remaining issues are P2 (documentation accuracy of the once-and-only-once claim). Core queue logic, dedup, priority ordering, legacy migration, and the backward-compat shim are all correct and well-tested.

src/agents/plan-mode/injections.ts — the sortAndCapQueue warn log at line 136

Comments Outside Diff (1)

  1. src/agents/plan-mode/injections.ts, line 519-532 (link)

    P2 "Once-and-only-once" claim overstates the actual guarantee

    The module-level JSDoc says "clear and read happen inside one updateSessionStoreEntry call (single store lock)" and implies once-and-only-once semantics. In practice, if updateSessionStoreEntry executes the update callback (setting captured) but then throws before persisting (e.g. disk failure), consumePendingAgentInjections returns the captured entries to the caller — they get injected this turn — but the store still holds the original queue. The next call to consumePendingAgentInjections will drain the same entries again, producing duplicate injections.

    The function-level JSDoc and the try/catch already document this as "best-effort", but the module-level claim creates a false expectation for downstream consumers. Consider toning down the module-level description to "at-most-once on success, at-least-once on write failure" so new callers aren't surprised.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/agents/plan-mode/injections.ts
    Line: 519-532
    
    Comment:
    **"Once-and-only-once" claim overstates the actual guarantee**
    
    The module-level JSDoc says "clear and read happen inside one `updateSessionStoreEntry` call (single store lock)" and implies once-and-only-once semantics. In practice, if `updateSessionStoreEntry` executes the `update` callback (setting `captured`) but then throws before persisting (e.g. disk failure), `consumePendingAgentInjections` returns the captured entries to the caller — they get injected this turn — but the store still holds the original queue. The next call to `consumePendingAgentInjections` will drain the same entries again, producing duplicate injections.
    
    The function-level JSDoc and the try/catch already document this as "best-effort", but the module-level claim creates a false expectation for downstream consumers. Consider toning down the module-level description to "at-most-once on success, at-least-once on write failure" so new callers aren't surprised.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/plan-mode/injections.ts
Line: 134-137

Comment:
**Misleading "oldest" label in overflow warn log**

`sorted` is ordered by priority DESC then `createdAt` ASC, so position 0 is the highest-priority / oldest entry and `sorted.slice(MAX_QUEUE_SIZE)` contains the **newest** (highest `createdAt`) same-priority tail entries — the exact opposite of what the log claims. An operator chasing a queue-overflow incident via this log will look for stale/old injections to remove, when the entries actually being silently dropped are the newest ones (most likely to be in-flight). The test comment at line 233-236 acknowledges the behavior but the operator-visible log string was not updated to match.

```suggestion
    log?.warn?.(
      `pending-injection-queue: at cap ${MAX_QUEUE_SIZE}, dropping newest entry id=${d.id} kind=${d.kind}`,
    );
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/plan-mode/injections.ts
Line: 519-532

Comment:
**"Once-and-only-once" claim overstates the actual guarantee**

The module-level JSDoc says "clear and read happen inside one `updateSessionStoreEntry` call (single store lock)" and implies once-and-only-once semantics. In practice, if `updateSessionStoreEntry` executes the `update` callback (setting `captured`) but then throws before persisting (e.g. disk failure), `consumePendingAgentInjections` returns the captured entries to the caller — they get injected this turn — but the store still holds the original queue. The next call to `consumePendingAgentInjections` will drain the same entries again, producing duplicate injections.

The function-level JSDoc and the try/catch already document this as "best-effort", but the module-level claim creates a false expectation for downstream consumers. Consider toning down the module-level description to "at-most-once on success, at-least-once on write failure" so new callers aren't surprised.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(plan-mode): typed injection queue +..." | Re-trigger Greptile

Comment on lines +134 to +137
for (const d of dropped) {
log?.warn?.(
`pending-injection-queue: at cap ${MAX_QUEUE_SIZE}, dropping oldest entry id=${d.id} kind=${d.kind}`,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Misleading "oldest" label in overflow warn log

sorted is ordered by priority DESC then createdAt ASC, so position 0 is the highest-priority / oldest entry and sorted.slice(MAX_QUEUE_SIZE) contains the newest (highest createdAt) same-priority tail entries — the exact opposite of what the log claims. An operator chasing a queue-overflow incident via this log will look for stale/old injections to remove, when the entries actually being silently dropped are the newest ones (most likely to be in-flight). The test comment at line 233-236 acknowledges the behavior but the operator-visible log string was not updated to match.

Suggested change
for (const d of dropped) {
log?.warn?.(
`pending-injection-queue: at cap ${MAX_QUEUE_SIZE}, dropping oldest entry id=${d.id} kind=${d.kind}`,
);
log?.warn?.(
`pending-injection-queue: at cap ${MAX_QUEUE_SIZE}, dropping newest entry id=${d.id} kind=${d.kind}`,
);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/plan-mode/injections.ts
Line: 134-137

Comment:
**Misleading "oldest" label in overflow warn log**

`sorted` is ordered by priority DESC then `createdAt` ASC, so position 0 is the highest-priority / oldest entry and `sorted.slice(MAX_QUEUE_SIZE)` contains the **newest** (highest `createdAt`) same-priority tail entries — the exact opposite of what the log claims. An operator chasing a queue-overflow incident via this log will look for stale/old injections to remove, when the entries actually being silently dropped are the newest ones (most likely to be in-flight). The test comment at line 233-236 acknowledges the behavior but the operator-visible log string was not updated to match.

```suggestion
    log?.warn?.(
      `pending-injection-queue: at cap ${MAX_QUEUE_SIZE}, dropping newest entry id=${d.id} kind=${d.kind}`,
    );
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real concern, acknowledged. Not fixed in this immediate cycle (we're in cleanup-mode focused on maintainer-takeover; see #70101 for context). Tracked in the umbrella's deferred-items checklist for the bot-triage follow-up cycle. The fix will land on this branch with a Fixed in {SHA} reply here per the standard pr-review-loop pattern. Maintainers can also opt to merge the per-part PR as-is and address in a post-merge polish cycle.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces the foundational “pending agent injection” typed queue for plan mode, replacing the legacy scalar SessionEntry.pendingAgentInjection with a priority-ordered, id-dedup’d queue plus a backward-compatible consumer shim.

Changes:

  • Added src/agents/plan-mode/injections.ts implementing enqueue/consume/migrate/compose helpers for a bounded pending-injection queue.
  • Added comprehensive unit + e2e tests for the injection queue behavior.
  • Updated session schema/types and command wiring to use the “fresh total tokens” resolver and support the new queue field, plus a compatibility shim for existing consumers.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/config/sessions/types.ts Adds pending-injection queue element types + session fields; renames token resolver used by callers.
src/commands/status.summary.ts Switches token resolution to resolveFreshSessionTotalTokens.
src/commands/sessions.ts Switches token resolution to resolveFreshSessionTotalTokens.
src/agents/plan-mode/injections.ts New queue implementation (enqueue/consume/migrate/compose, prioritization, bounded size).
src/agents/plan-mode/injections.test.ts New test suite validating helper logic and store-backed enqueue/consume behavior.
src/agents/pi-embedded-runner/pending-injection.ts New backward-compat consumer shim preserving the legacy { text } API.

Comment on lines +29 to +34
* No separate migration script needed.
* - **Bounded queue**: capped at `MAX_QUEUE_SIZE = 10`. Oldest entries
* evicted on overflow with a warn log. Correctness doesn't depend on
* this — the consumer always drains within a single turn — but the
* cap prevents unbounded growth in pathological cases (stuck session,
* consumer crash loop).

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overflow eviction semantics are inconsistent with both the module header and the MAX_QUEUE_SIZE docstring: the code currently keeps the earliest entries in the sorted order and drops the tail, which means it will drop newest entries when priority is tied (and generally drops lowest-priority entries first). Please pick the intended policy (e.g. “drop lowest-priority first, then oldest/newest among ties”) and make the header/docs/tests match the actual behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +133 to +137
const dropped = sorted.slice(MAX_QUEUE_SIZE);
for (const d of dropped) {
log?.warn?.(
`pending-injection-queue: at cap ${MAX_QUEUE_SIZE}, dropping oldest entry id=${d.id} kind=${d.kind}`,
);

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warn log says "dropping oldest entry", but sortAndCapQueue() drops sorted.slice(MAX_QUEUE_SIZE) (the tail). After sorting by priority DESC and createdAt ASC, the tail is the lowest-priority entries (or the newest among tied entries), not necessarily the oldest. Update the log message (and ideally include createdAt/priority) so ops diagnostics reflect what was actually evicted.

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +59
export const DEFAULT_INJECTION_PRIORITY: Record<string, number> = {
plan_decision: 10,
plan_complete: 9,
question_answer: 8,
subagent_return: 5,
plan_intro: 3,
plan_nudge: 1,
};

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DEFAULT_INJECTION_PRIORITY is typed as Record<string, number>, which loses type-safety (typos in kind names won’t be caught). Consider typing it as Record<PendingAgentInjectionKind, number> (or const ... satisfies Record<PendingAgentInjectionKind, number>) so the compiler enforces that all keys are valid kinds and all kinds are handled.

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +41
* Best-effort error semantics (Copilot review #68939 wave-2 wave-1
* compatible): on store-write failure inside the underlying queue
* helper, the queue helper drops the captured entries and returns an
* empty array — favoring the once-and-only-once guarantee over
* caller-can-still-inject. Operators see the warn-log line for any
* disk failure path.

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The block comment describing error semantics doesn’t match the underlying implementation. consumePendingAgentInjections() currently returns any captured entries even if the store write fails (it does not force an empty result), so this doc is misleading for callers/operators. Please update the comment to reflect the actual best-effort behavior (or adjust the helper behavior to match the documented contract).

Suggested change
* Best-effort error semantics (Copilot review #68939 wave-2 wave-1
* compatible): on store-write failure inside the underlying queue
* helper, the queue helper drops the captured entries and returns an
* empty array favoring the once-and-only-once guarantee over
* caller-can-still-inject. Operators see the warn-log line for any
* disk failure path.
* Best-effort error semantics: if the underlying queue helper hits a
* store-write failure while draining, it may still return the captured
* entries/composed text rather than forcing an empty result. Operators
* still see the warn-log line for any disk failure path.

Copilot uses AI. Check for mistakes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bb3c54b6ce

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/commands/sessions.ts Outdated
@@ -183,7 +183,7 @@ export async function sessionsCommand(
const model = resolveSessionDisplayModel(cfg, r);
return {
...r,
totalTokens: resolveSessionTotalTokens(r) ?? null,
totalTokens: resolveFreshSessionTotalTokens(r) ?? null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve stale token totals in sessions command output

Using resolveFreshSessionTotalTokens here drops totalTokens whenever totalTokensFresh === false, which turns known-but-stale usage into null/unknown in both /sessions table and --json output. This regresses existing behavior for sessions that intentionally retain stale totals (the freshness bit already communicates staleness) and removes percent/context visibility for those sessions.

Useful? React with 👍 / 👎.

@@ -203,7 +203,7 @@ export async function getStatusSummary(
fallbackContextTokens: configContextTokens ?? undefined,
allowAsyncLoad: false,
}) ?? null;
const total = resolveSessionTotalTokens(entry);
const total = resolveFreshSessionTotalTokens(entry);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep stale totals visible in status session summaries

Switching to resolveFreshSessionTotalTokens makes status report sessions with totalTokensFresh: false as if usage were unknown, because totalTokens becomes null and remainingTokens/percentUsed are no longer computed. In environments where totals are preserved but marked stale, this removes actionable usage signal and changes the command contract from "stale" to "missing."

Useful? React with 👍 / 👎.

@100yenadmin

Copy link
Copy Markdown
Contributor Author

Acknowledging Greptile / Copilot / Codex review feedback on this PR. Triage status:

  • Stack-coordination concerns (e.g. unresolved imports referencing symbols added by earlier per-part PRs in the stack) — expected by design. Red CI on this PR is per the rollout plan; CI turns green as the chain merges in sequence (1/6 → 6/6). See the stack-position header banner at the top of this PR body.
  • Real source-code issues flagged here (P1 bugs, P2 nits, etc.) — will be triaged + fixed in a focused follow-up cycle within ~24h. Fix SHAs will be posted as in-line replies on each individual comment per the standard pr-review-loop pattern.

For end-to-end integration testing in the meantime, see #70071 [Plan Mode FULL] — the green-CI integrated bundle of all parts.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b2d39838aa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/commands/status.summary.ts Outdated
: { config: cfg, activationSourceConfig: options.sourceConfig };
const needsChannelPlugins =
includeChannelSummary && hasConfiguredChannelsForReadOnlyScope(channelScopeConfig);
const needsChannelPlugins = hasPotentialConfiguredChannels(cfg);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore scoped channel detection before gating status summaries

Switching needsChannelPlugins to hasPotentialConfiguredChannels(cfg) makes the gate rely on a heuristic (explicit config + bundled env prefixes) instead of the read-only presence policy, so valid channel setups discovered through plugin manifest activation can now be treated as “no channels configured.” In those environments, getStatusSummary skips channel imports and returns empty channelSummary/linkChannel even though channel plugins are actually available, which regresses status accuracy for plugin-based channels.

Useful? React with 👍 / 👎.

Comment thread src/commands/status.summary.ts Outdated
Comment on lines +122 to +123
? await loadLinkChannelModule().then(({ resolveLinkChannelContext }) =>
resolveLinkChannelContext(cfg, { sourceConfig: options.sourceConfig }),
resolveLinkChannelContext(cfg),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward sourceConfig to link-channel resolution

getStatusSummary still threads options.sourceConfig into buildChannelSummary, but this call now invokes resolveLinkChannelContext(cfg) without the same source config. Since resolveLinkChannelContext uses activationSourceConfig to pick the read-only channel plugin, omitting it can make linkChannel resolve against a different activation scope than channelSummary, producing inconsistent status output when resolved config and source config differ.

Useful? React with 👍 / 👎.

…t from merged-upstream state (v4)

Cherry-pick / file-copy of 6 files from feat/plan-channel-parity-merged-upstream
(merged-head 651a22d) onto current upstream/main. Branch: isolated/pm-injections-foundation.

v4 absorbs the upstream merge into plan-mode dev (Option B; merge commit
b1ead74) plus the post-merge build/test fixes (commit 651a22d).
@100yenadmin
100yenadmin force-pushed the isolated/pm-injections-foundation branch from b2d3983 to 9092e5f Compare April 22, 2026 14:37

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9092e5f7f8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

`pending-injection-queue: at cap ${MAX_QUEUE_SIZE}, dropping oldest entry id=${d.id} kind=${d.kind}`,
);
}
return sorted.slice(0, MAX_QUEUE_SIZE);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep newest signals when capping injection queue

The cap path currently returns sorted.slice(0, MAX_QUEUE_SIZE), but the queue is sorted with older createdAt first, so overflow drops the newest entries while logging that it is dropping oldest. In backlog scenarios (for example, a temporarily undrained session with repeated plan approvals/questions), this can discard the most recent control signal and keep stale ones, which can leave the agent acting on outdated state.

Useful? React with 👍 / 👎.

@prtags

prtags Bot commented Apr 23, 2026

Copy link
Copy Markdown

Related work from PRtags group ace-bullfrog-hw3e

Title: Open PR candidate: plan-mode carve-out overlaps integrated full bundle

Number Title
#70031 [Plan Mode 1/6] Plan-state foundation
#70066 [Plan Mode 2/6] Core backend MVP
#70067 [Plan Mode 3/6] Advanced plan interactions
#70068 [Plan Mode 4/6] Web UI + i18n
#70069 [Plan Mode 5/6] Text channels + Telegram
#70070 [Plan Mode 6/6] Docs, QA, and help
#70071 [Plan Mode FULL] Integrated bundle for testing (Parts 1\u20136 + automation + executing-state lifecycle)
#70088* [Plan Mode INJECTIONS] Typed pending-injection queue foundation
#70089 [Plan Mode AUTOMATION] Cron nudges + auto-enable + subagent follow-ups

* This PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling commands Command implementations size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants