[Plan Mode INJECTIONS] Typed pending-injection queue foundation#70088
[Plan Mode INJECTIONS] Typed pending-injection queue foundation#70088100yenadmin wants to merge 1 commit into
Conversation
Greptile SummaryAdds a typed, priority-ordered, id-dedup'd pending-injection queue (
Confidence Score: 4/5Safe 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
|
| for (const d of dropped) { | ||
| log?.warn?.( | ||
| `pending-injection-queue: at cap ${MAX_QUEUE_SIZE}, dropping oldest entry id=${d.id} kind=${d.kind}`, | ||
| ); |
There was a problem hiding this 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.
| 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.There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.tsimplementing 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. |
| * 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). |
There was a problem hiding this comment.
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.
| 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}`, | ||
| ); |
There was a problem hiding this comment.
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.
| 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, | ||
| }; |
There was a problem hiding this comment.
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.
| * 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. |
There was a problem hiding this comment.
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).
| * 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. |
There was a problem hiding this comment.
💡 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".
| @@ -183,7 +183,7 @@ export async function sessionsCommand( | |||
| const model = resolveSessionDisplayModel(cfg, r); | |||
| return { | |||
| ...r, | |||
| totalTokens: resolveSessionTotalTokens(r) ?? null, | |||
| totalTokens: resolveFreshSessionTotalTokens(r) ?? null, | |||
There was a problem hiding this comment.
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); | |||
There was a problem hiding this comment.
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 👍 / 👎.
|
Acknowledging Greptile / Copilot / Codex review feedback on this PR. Triage status:
For end-to-end integration testing in the meantime, see #70071 [Plan Mode FULL] — the green-CI integrated bundle of all parts. |
bb3c54b to
b2d3983
Compare
There was a problem hiding this comment.
💡 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".
| : { config: cfg, activationSourceConfig: options.sourceConfig }; | ||
| const needsChannelPlugins = | ||
| includeChannelSummary && hasConfiguredChannelsForReadOnlyScope(channelScopeConfig); | ||
| const needsChannelPlugins = hasPotentialConfiguredChannels(cfg); |
There was a problem hiding this comment.
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 👍 / 👎.
| ? await loadLinkChannelModule().then(({ resolveLinkChannelContext }) => | ||
| resolveLinkChannelContext(cfg, { sourceConfig: options.sourceConfig }), | ||
| resolveLinkChannelContext(cfg), |
There was a problem hiding this comment.
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).
b2d3983 to
9092e5f
Compare
There was a problem hiding this comment.
💡 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); |
There was a problem hiding this comment.
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 👍 / 👎.
|
Related work from PRtags group Title: Open PR candidate: plan-mode carve-out overlaps integrated full bundle
|
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.
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]: approvedwrites topendingAgentInjection: string. The/plan answerpath that delivers[QUESTION_ANSWER]: ...writes to the same field. The webchat path that emits[PLAN_COMPLETE]afterexit_plan_modealso 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 acceptand 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]: approvedoverwriting 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[]) onSessionEntry. Writers append viaenqueuePendingAgentInjection(sessionKey, entry); the runner drains viaconsumePendingAgentInjections(sessionKey), which sorts bypriority 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. Thepi-embedded-runner/pending-injection.tsmodule 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
SessionEntry.pendingAgentInjectionbetween concurrent plan-mode writers.enqueuePendingAgentInjection,consumePendingAgentInjections,composePromptWithPendingInjections,migrateLegacyPendingInjection, plusMAX_QUEUE_SIZE(10) andDEFAULT_INJECTION_PRIORITYconstants.pi-embedded-runner/pending-injection.tskeeps its{ text }shape — existing consumer inauto-reply/reply/agent-runner-execution.tsworks unchanged.feat/plan-channel-parity(commit70a6e4b23a) 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.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")]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 passpriorityexplicitly 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 keyTwo properties worth flagging:
updateSessionStoreEntrycallback 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.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 endThe 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 emptysessionKey), then opens anupdateSessionStoreEntrytransaction whose callback (a) callsmigrateLegacyPendingInjectionto wrap any legacy scalar, (b) callsupsertIntoQueueto append-or-replace byid, (c) callssortAndCapQueueto evict on overflow with a warn log, and (d) returns a patch that includes an explicitpendingAgentInjection: undefinedwhen migration occurred (the merge helper interprets explicitundefinedas a delete). Returnsfalseon a missing session or any thrown error — best-effort by design, since callers are typicallysessions.patchhandlers 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 oncomposedTextwithout parsing an empty-string sentinel. Contains the load-bearing best-effort comment: ifupdateSessionStoreEntrythrows 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\nseparator; 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)andsortAndCapQueue(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 viaentry.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:
migrateLegacyPendingInjectionplan_decision; empty-string legacy → no migrationupsertIntoQueuesortAndCapQueueMAX_QUEUE_SIZEwith warn-per-eviction (3 calls for 13 → 10); under-cap preserved; input not mutatedcomposePromptWithPendingInjections\n\njoin + trimmed-user separator; preamble-only on empty/whitespace user prompt; trims user promptDEFAULT_INJECTION_PRIORITYplan_decision> every other kind;plan_complete>question_answerexpiresAtfilter; empty sessionKey early-return; unrelatedSessionEntryfields preserved across enqueue/consume; missing session and empty key both returnfalsewithout throwingThe e2e block uses
vi.hoisted()to wire a tmp-dir store path before the module-under-test readsloadConfig, then writes/reads JSON directly throughfs/promisesto 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}>— callsconsumePendingAgentInjectionsand projects to the legacy{text}shape. Thetextisundefinedwhen nothing was pending (preserves the pre-queue contract that lets the caller branch withif (text)rather thanif (text != null && text.length > 0)).composePromptWithPendingInjection(injectionText | undefined, userPrompt)— bridges a scalar string into the newcomposePromptWithPendingInjectionsby wrapping it in a single fake-idplan_decisionentry. 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) importsconsumePendingAgentInjectionfrom this path and gets the same{text}shape it always got.src/config/sessions/types.ts(+249/-11)Three additions, all carefully scoped:
PendingAgentInjectionKinddiscriminator —"plan_decision" | "question_answer" | "plan_complete" | "plan_intro" | "plan_nudge" | "subagent_return". Closed union; new kinds require a coordinated change to the union andDEFAULT_INJECTION_PRIORITY(intentional friction so an unowned writer can't slip in without picking a priority).PendingAgentInjectionEntryqueue-element type —id,kind,text,createdAtrequired;approvalId,priority,expiresAtoptional. Theidis the dedup key;approvalIdlinks a plan-cycle entry to its approval round so consumers can detect stale entries across cycles.SessionEntry.pendingAgentInjections?: PendingAgentInjectionEntry[]— the queue field itself. The legacypendingAgentInjection?: stringis kept (marked@deprecated) so sessions on disk continue to round-trip through the merge helper without the explicit-undefineddelete 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
pendingQuestionApprovalIdblock that landed in the same diff to validate/plan answeragainst the most recentask_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 —
resolveSessionTotalTokens→resolveFreshSessionTotalTokensfollows from a symbol rename intypes.tsthat 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-parityontomainin topological order. Commit70a6e4b23a— the one introducinginjections.tsand thepending-injection.tsshim — 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
mainwithCannot 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
concurrent different-kind writes both land), expiry filter, idempotent retries (same-id upsert), legacy auto-migrate + double-clear, and unrelated-SessionEntry-fields-preserved.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:
idis the dedup key, append + upsert), same atomicity guarantee (drain inside the same store-update lock as the clear).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.tsis 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
pendingAgentInjectionfield's@deprecatedJSDoc intypes.ts:343-363and the writers it calls out (gateway approve,/plan answer,exit_plan_mode). Confirm: yes, three writers; one scalar field; no coordination.injections.ts:174-217(enqueuePendingAgentInjection) and confirm theupdateSessionStoreEntrycallback is the only mutation path. ConfirmconsumePendingAgentInjections(240-281) does the symmetric atomic drain.migrateLegacyPendingInjection(93-112) and thee2e: migrates a legacy scalar...test (injections.test.ts:266-280). Confirm the legacy field is deleted in the same patch as the queue update.pending-injection.tsend-to-end (73 lines) and confirmconsumePendingAgentInjectionreturns{text: string | undefined}exactly as before.pnpm vitest run src/agents/plan-mode/injections.test.ts(24 cases, ~150ms).What this PR does NOT include
enqueuePendingAgentInjectionlands in[Plan Mode AUTOMATION]([Plan Mode AUTOMATION] Cron nudges + auto-enable + subagent follow-ups #70089) and[Plan Mode FULL]([Plan Mode FULL] Integrated bundle for testing (Parts 1\u20136 + automation + executing-state lifecycle) #70071). Until those land, the legacy scalar continues to flow through the auto-migrate path on first read.[Plan Mode AUTOMATION]([Plan Mode AUTOMATION] Cron nudges + auto-enable + subagent follow-ups #70089).SessionEntry.planModeand the approval lifecycle fields) —[Plan Mode 1/6].migrateLegacyPendingInjection— not in scope here.Issue references
Test status
pnpm tsgo+pnpm lintclean.pnpm checkblocked by a pre-existingtool-display:checkfailure (plan_mode_statusmissing fromtool-display-config.ts, unrelated to this commit).Carry-forward / deferred
pendingAgentInjectionscalar writes withenqueuePendingAgentInjection) ship in subsequent PRs — primarily[Plan Mode AUTOMATION]and[Plan Mode FULL].DEFAULT_INJECTION_PRIORITY; new kinds require coordinated additions to the union intypes.tsand the priority table.migrateLegacyPendingInjectionexport in a one-pass loop over the session store.