Skip to content

feat(orchestrator): queue follow-up messages during active turns, with explicit steer#4245

Open
t3dotgg wants to merge 7 commits into
mainfrom
t3code/queue-steer-feature
Open

feat(orchestrator): queue follow-up messages during active turns, with explicit steer#4245
t3dotgg wants to merge 7 commits into
mainfrom
t3code/queue-steer-feature

Conversation

@t3dotgg

@t3dotgg t3dotgg commented Jul 22, 2026

Copy link
Copy Markdown
Member

Summary

Messages sent while a turn is running are now queued server-side by default and drained automatically when the turn completes naturally. Each queued message renders as a chip above the composer with two actions:

  • Steer — dispatch it immediately, injecting into the active turn (Codex uses the protocol-native turn/steer with an expectedTurnId precondition, falling back to turn/start; other providers steer through their existing sendTurn-while-running paths).
  • Remove — drop it from the queue.

This replaces the previous behavior where a mid-turn send always steered immediately.

image

How it works

  • Contracts: new client commands thread.queue.steer / thread.queue.remove, internal thread.queue.drain, events thread.message-queued / thread.queued-message-removed, and queuedMessages on the thread schema (decoding default keeps old snapshots valid).
  • Decider: thread.turn.start on a busy thread (session starting/running) emits thread.message-queued instead of starting a turn; bootstrap starts are exempt. Steer dispatches unconditionally (degrades to a normal turn start if the thread went idle). Drain is invariant-guarded: it refuses when the thread is busy again, so a user message racing the drain wins and the queue retries on the next completion.
  • Ingestion: auto-drain fires only on natural turn.completed — never after an interrupt or failure — and is dispatched after assistant finalization so the drained message sequences after the assistant text it follows up on.
  • Persistence: projection_queued_messages table (migration 033) so queues survive server restarts.
  • Web: QueuedMessageChips above the composer; the composer's local-dispatch acknowledgment treats a projected queued message as server acknowledgment so it doesn't stick in "Sending".
  • Queued messages are not part of the thread timeline; they enter it via the normal thread.message-sent event when dispatched. Stale sourceProposedPlan references are dropped rather than failing the send. Queue capped at 50 per thread.

Known gaps (follow-ups)

  • Mobile has no queued-message UI yet. Mobile's client-side outbox already holds sends while a thread is busy (resolveThreadOutboxDeliveryAction waits on threadBusy), so mobile sends generally won't hit the server queue — but once its outbox dispatches into a turn that just started, the message queues server-side invisibly. Mobile chips + steer/remove need a follow-up.
  • No keyboard shortcut for "send immediately as steer" from the composer; steering requires the chip button.

Validation

  • vp run typecheck clean across the monorepo
  • vp check clean on all 36 changed files
  • Server orchestration + persistence: 214 tests passing, including 8 new decider queue tests (decider.queue.test.ts) covering queue-on-busy, steer, remove, drain, drain-races, and stale plan references
  • Web + client-runtime + contracts: 1825 tests passing (new coverage for queued-send acknowledgment)
  • Full apps/server suite passes except ProviderRegistry.test.ts > re-probes when settings change the codex binaryPath, which fails only under the full-suite run and passes in isolation and on main — pre-existing flake, unrelated to this diff

🤖 Generated with Claude Code


Note

High Risk
Changes core turn-start semantics, event sourcing projections, and provider steering paths across server, persistence, and clients; incorrect queue/drain or pending-turn handling could drop messages, double-dispatch, or leave threads stuck.

Overview
Follow-ups sent while a turn is starting or running are queued server-side instead of immediately starting another turn or steering. Idle threads still get an immediate thread.message-sent + thread.turn-start-requested; busy threads emit thread.message-queued (50-message cap enforced in the decider).

New commands thread.queue.steer, thread.queue.remove, and internal thread.queue.drain dispatch or drop queued items. After a natural turn completion, runtime ingestion auto-dispatches drain so the FIFO head becomes the next turn. pendingTurnStart on the thread read model blocks duplicate drains and follow-up sends in the gap before the provider adopts a turn; session-set projection clears stale pending rows on settled statuses (including ready after mid-turn steer).

Persistence: migration 034 adds projection_queued_messages, a dedicated projector (ordered before thread-messages on replay so revert pruning keeps queued attachments), and snapshot/query hydration of queuedMessages + pendingTurnStart. Revert and user-removal paths update attachment pruning to include queued rows.

Web: QueuedMessageChips above the composer (steer/remove); composer acknowledgment correlates sends by messageId when timeline or queue projects the message. Codex sendTurn tries protocol turn/steer with expectedTurnId before falling back to turn/start. Contracts, client-runtime reducer/commands, and WS detail events extended for the new queue events.

Reviewed by Cursor Bugbot for commit 6be48eb. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Queue follow-up messages during active turns and surface them as steerable chips in the chat UI

  • When a thread.turn.start command arrives while a turn is active or pending, the decider now emits thread.message-queued instead of starting a turn immediately, capped at 50 queued messages per thread.
  • After a turn completes successfully, the ingestion layer auto-dispatches a thread.queue.drain to send the oldest queued message, enabling sequential follow-up delivery.
  • Three new commands are introduced: thread.queue.steer (dispatch a specific queued message into the running turn via a protocol-native steer), thread.queue.remove (drop a queued message), and thread.queue.drain (internal drain after turn completion).
  • The chat UI renders queued messages as chips above the composer with "Steer" and "Remove" actions via a new QueuedMessageChips component.
  • A new projection_queued_messages DB table (migration 034) persists queued messages server-side; the projector and snapshot query propagate queuedMessages and pendingTurnStart through the read model.
  • Local dispatch acknowledgment in the web client is now correlated by a specific messageId appearing in either the timeline or the queue, replacing the global tail heuristic.
  • Risk: the steer path in CodexSessionRuntime falls back to turn/start only on a specific receive-response rejection; other errors propagate without fallback.

Macroscope summarized 6be48eb.

Summary by CodeRabbit

  • New Features
    • Added queued follow-up messages for active threads.
    • Messages sent while a turn is running are queued and processed in order when the turn completes.
    • Added controls to steer a queued message immediately or remove it.
    • Queued messages now appear in thread details and the chat interface.
    • Added support for provider-native steering, with fallback behavior when unavailable.
  • Bug Fixes
    • Improved local message acknowledgement and optimistic timeline cleanup for queued messages.
    • Preserved queued messages consistently across thread updates and synchronization.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.76% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed Title clearly summarizes queueing follow-up messages during active turns and the new steer behavior.
Description check ✅ Passed Description is mostly complete and covers the change, rationale, UI impact, and validation, despite not using the exact template headings.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/queue-steer-feature

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jul 22, 2026
Comment thread apps/server/src/orchestration/projector.ts Outdated
@macroscopeapp

macroscopeapp Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces a substantial new feature (message queueing with steer capability) including a new database table, persistence layer, orchestration commands/events, complex decider logic, provider protocol integration, and new UI components. The scope and complexity warrant human review, and unresolved comments identify potential data integrity and stability issues.

You can customize Macroscope's approvability policy. Learn more.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/components/ChatView.tsx (1)

3682-3717: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Revoke previews acknowledged only by the queue.

Queued messages are absent from displayServerMessages, so their blob URLs remain in attachmentPreviewHandoffByMessageId. Removing such a queued image never promotes the handoff and leaks the object URLs until unmount.

Proposed fix
-    const serverIds = new Set([
-      ...activeThread.messages.map((message) => message.id),
-      ...activeThread.queuedMessages.map((message) => message.messageId),
-    ]);
+    const persistedMessageIds = new Set(activeThread.messages.map((message) => message.id));
+    const serverIds = new Set([
+      ...persistedMessageIds,
+      ...activeThread.queuedMessages.map((message) => message.messageId),
+    ]);
@@
-      if (previewUrls.length > 0) {
+      if (previewUrls.length > 0 && persistedMessageIds.has(removedMessage.id)) {
         handoffAttachmentPreviews(removedMessage.id, previewUrls);
         continue;
       }
       revokeUserMessagePreviewUrls(removedMessage);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/ChatView.tsx` around lines 3682 - 3717, Update the
optimistic-message cleanup effect around activeThread.queuedMessages so previews
for queued-only acknowledgements are also removed from
attachmentPreviewHandoffByMessageId. Ensure the queue acknowledgment path
promotes or revokes the handed-off blob URLs when the queued image is removed,
while preserving existing cleanup for messages present in activeThread.messages.
🧹 Nitpick comments (2)
packages/client-runtime/src/state/threadReducer.ts (1)

278-316: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover queued-message reducer lifecycle.

Add reducer cases for queueing, replacing an existing messageId, and removing it. The changed fixture only validates the expanded shape, not these new state transitions. Verify with vp test run packages/client-runtime/src/state/threadReducer.test.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client-runtime/src/state/threadReducer.ts` around lines 278 - 316,
The threadReducer lifecycle coverage is incomplete for queued messages. Update
the reducer handling around the “thread.message-queued” and
“thread.queued-message-removed” cases to support adding a queued message,
replacing an existing entry with the same messageId, and removing it while
preserving updatedAt and the full queued-message shape; add or update tests in
threadReducer.test.ts and run the specified test command.

Source: Coding guidelines

apps/server/src/persistence/Layers/ProjectionQueuedMessages.ts (1)

19-24: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Single-source the queued-message DB row schema instead of duplicating it. Both files define an identical ProjectionQueuedMessageDbRowSchema transform; drift between them would go unnoticed at compile time since each is a separate local const.

  • apps/server/src/persistence/Layers/ProjectionQueuedMessages.ts#L19-L24: export this schema (or move it into Services/ProjectionQueuedMessages.ts) as the canonical definition.
  • apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts#L80-L85: import the canonical schema instead of redefining it locally.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/persistence/Layers/ProjectionQueuedMessages.ts` around lines
19 - 24, Make ProjectionQueuedMessageDbRowSchema in
apps/server/src/persistence/Layers/ProjectionQueuedMessages.ts the exported
canonical schema. In
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts, import and
reuse that exported schema instead of defining a local duplicate; preserve the
existing transform fields and behavior at both sites.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts`:
- Around line 1450-1462: Update getCommandReadModel’s queuedMessageRows loop to
advance updatedAt with each row’s queuedAt timestamp, using the same maxIso
logic as getSnapshot. Keep the existing queuedMessagesByThread grouping and skip
behavior unchanged.

In `@apps/server/src/provider/Layers/CodexSessionRuntime.ts`:
- Around line 1290-1326: Restrict the error handling in the turn/steer request
within the activeSession branch to the specific not-steerable/precondition
failure, allowing transient RPC or protocol errors to propagate instead of
triggering turn/start. Preserve the existing fallback behavior for that matched
case and keep successful steer responses unchanged.

---

Outside diff comments:
In `@apps/web/src/components/ChatView.tsx`:
- Around line 3682-3717: Update the optimistic-message cleanup effect around
activeThread.queuedMessages so previews for queued-only acknowledgements are
also removed from attachmentPreviewHandoffByMessageId. Ensure the queue
acknowledgment path promotes or revokes the handed-off blob URLs when the queued
image is removed, while preserving existing cleanup for messages present in
activeThread.messages.

---

Nitpick comments:
In `@apps/server/src/persistence/Layers/ProjectionQueuedMessages.ts`:
- Around line 19-24: Make ProjectionQueuedMessageDbRowSchema in
apps/server/src/persistence/Layers/ProjectionQueuedMessages.ts the exported
canonical schema. In
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts, import and
reuse that exported schema instead of defining a local duplicate; preserve the
existing transform fields and behavior at both sites.

In `@packages/client-runtime/src/state/threadReducer.ts`:
- Around line 278-316: The threadReducer lifecycle coverage is incomplete for
queued messages. Update the reducer handling around the “thread.message-queued”
and “thread.queued-message-removed” cases to support adding a queued message,
replacing an existing entry with the same messageId, and removing it while
preserving updatedAt and the full queued-message shape; add or update tests in
threadReducer.test.ts and run the specified test command.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f7596e4b-fb86-4489-8567-5937c39efcea

📥 Commits

Reviewing files that changed from the base of the PR and between 62cf461 and 299d961.

📒 Files selected for processing (36)
  • apps/mobile/src/lib/threadActivity.test.ts
  • apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
  • apps/server/src/orchestration/Layers/ProjectionPipeline.ts
  • apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
  • apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
  • apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
  • apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
  • apps/server/src/orchestration/Schemas.ts
  • apps/server/src/orchestration/commandInvariants.test.ts
  • apps/server/src/orchestration/decider.queue.test.ts
  • apps/server/src/orchestration/decider.ts
  • apps/server/src/orchestration/projector.test.ts
  • apps/server/src/orchestration/projector.ts
  • apps/server/src/persistence/Layers/ProjectionQueuedMessages.ts
  • apps/server/src/persistence/Migrations.ts
  • apps/server/src/persistence/Migrations/033_ProjectionQueuedMessages.ts
  • apps/server/src/persistence/Services/ProjectionQueuedMessages.ts
  • apps/server/src/provider/Layers/CodexSessionRuntime.ts
  • apps/server/src/provider/Layers/ProviderSessionReaper.test.ts
  • apps/server/src/server.test.ts
  • apps/server/src/ws.ts
  • apps/web/src/components/ChatView.logic.test.ts
  • apps/web/src/components/ChatView.logic.ts
  • apps/web/src/components/ChatView.tsx
  • apps/web/src/components/CommandPalette.logic.test.ts
  • apps/web/src/components/Sidebar.logic.test.ts
  • apps/web/src/components/chat/QueuedMessageChips.tsx
  • apps/web/src/lib/threadSort.test.ts
  • apps/web/src/worktreeCleanup.test.ts
  • packages/client-runtime/src/operations/commands.ts
  • packages/client-runtime/src/state/entities.test.ts
  • packages/client-runtime/src/state/threadCommands.ts
  • packages/client-runtime/src/state/threadReducer.test.ts
  • packages/client-runtime/src/state/threadReducer.ts
  • packages/client-runtime/src/state/threads-sync.test.ts
  • packages/contracts/src/orchestration.ts

Comment on lines +1450 to +1462
const queuedMessagesByThread = new Map<string, Array<OrchestrationQueuedMessage>>();
const sessionByThread = new Map<string, OrchestrationSession>();

for (let index = 0; index < queuedMessageRows.length; index += 1) {
const row = queuedMessageRows[index];
if (!row) {
continue;
}
const threadQueuedMessages = queuedMessagesByThread.get(row.threadId) ?? [];
threadQueuedMessages.push(mapQueuedMessageRow(row));
queuedMessagesByThread.set(row.threadId, threadQueuedMessages);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

getCommandReadModel doesn't advance updatedAt from queued-message timestamps.

getSnapshot's queued-message loop does updatedAt = maxIso(updatedAt, row.queuedAt) (line 1154), but the equivalent loop here (1453-1461) only builds queuedMessagesByThread without touching updatedAt. None of the earlier updatedAt-tracking loops (1382-1439) cover queuedMessageRows either, so a thread whose only new activity is a queued message won't bump the command read model's updatedAt.

🐛 Proposed fix
               for (let index = 0; index < queuedMessageRows.length; index += 1) {
                 const row = queuedMessageRows[index];
                 if (!row) {
                   continue;
                 }
+                updatedAt = maxIso(updatedAt, row.queuedAt);
                 const threadQueuedMessages = queuedMessagesByThread.get(row.threadId) ?? [];
                 threadQueuedMessages.push(mapQueuedMessageRow(row));
                 queuedMessagesByThread.set(row.threadId, threadQueuedMessages);
               }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const queuedMessagesByThread = new Map<string, Array<OrchestrationQueuedMessage>>();
const sessionByThread = new Map<string, OrchestrationSession>();
for (let index = 0; index < queuedMessageRows.length; index += 1) {
const row = queuedMessageRows[index];
if (!row) {
continue;
}
const threadQueuedMessages = queuedMessagesByThread.get(row.threadId) ?? [];
threadQueuedMessages.push(mapQueuedMessageRow(row));
queuedMessagesByThread.set(row.threadId, threadQueuedMessages);
}
const queuedMessagesByThread = new Map<string, Array<OrchestrationQueuedMessage>>();
const sessionByThread = new Map<string, OrchestrationSession>();
for (let index = 0; index < queuedMessageRows.length; index += 1) {
const row = queuedMessageRows[index];
if (!row) {
continue;
}
updatedAt = maxIso(updatedAt, row.queuedAt);
const threadQueuedMessages = queuedMessagesByThread.get(row.threadId) ?? [];
threadQueuedMessages.push(mapQueuedMessageRow(row));
queuedMessagesByThread.set(row.threadId, threadQueuedMessages);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts` around lines
1450 - 1462, Update getCommandReadModel’s queuedMessageRows loop to advance
updatedAt with each row’s queuedAt timestamp, using the same maxIso logic as
getSnapshot. Keep the existing queuedMessagesByThread grouping and skip behavior
unchanged.

Comment on lines +1290 to +1326

// Steer the active turn via the protocol-native `turn/steer` —
// appending input mid-turn instead of opening a second provider
// turn while the first is still settling. The `expectedTurnId`
// precondition fails when the turn just ended or is not steerable
// (e.g. review/compact); fall back to a fresh `turn/start`.
const activeSession = yield* Ref.get(sessionRef);
if (activeSession.status === "running" && activeSession.activeTurnId !== undefined) {
const steeredTurnId = yield* client
.request("turn/steer", {
threadId: providerThreadId,
expectedTurnId: activeSession.activeTurnId,
input: params.input,
})
.pipe(
Effect.map((steerResponse) => TurnId.make(steerResponse.turnId)),
Effect.catch((cause) =>
Effect.as(
Effect.logDebug("Codex turn/steer rejected; falling back to turn/start.", {
cause,
}),
null,
),
),
);
if (steeredTurnId !== null) {
const steeredProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef));
return {
threadId: options.threadId,
turnId: steeredTurnId,
...(steeredProviderThreadId
? { resumeCursor: { threadId: steeredProviderThreadId } }
: {}),
} satisfies ProviderTurnStartResult;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the Codex client's typed error surface for turn/steer to see whether
# a specific "not steerable" tag/reason is available to narrow this catch.
rg -n 'ActiveTurnNotSteerable|turn/steer' apps/server/src/provider -g '*.ts'
ast-grep run --pattern 'turn/steer' --lang typescript apps/server/src/provider

Repository: pingdotgg/t3code

Length of output: 543


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the local implementation and any typed error definitions related to turn/steer.
rg -n --hidden --glob '!node_modules' --glob '!dist' --glob '!build' \
  'ActiveTurnNotSteerable|turn/steer|not steerable|steer rejected|turn/start' \
  apps/server/src vendor .repos 2>/dev/null || true

echo '--- CodexSessionRuntime.ts (around the review range) ---'
sed -n '1270,1335p' apps/server/src/provider/Layers/CodexSessionRuntime.ts

echo '--- Files in apps/server/src/provider likely related to schema/error types ---'
fd -a -t f '.*(schema|Schema|error|Error|protocol|client).*\.ts$' apps/server/src/provider 2>/dev/null || true

Repository: pingdotgg/t3code

Length of output: 5664


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- apps/server/src/provider/Errors.ts ---'
cat -n apps/server/src/provider/Errors.ts

echo '--- turn/steer-related request/error mapping in CodexSessionRuntime.ts ---'
rg -n 'mapError|RequestError|Request.*Error|turn/steer|turn/start|not steerable|ActiveTurn' apps/server/src/provider/Layers/CodexSessionRuntime.ts apps/server/src/provider/Layers/CodexAdapter.ts apps/server/src/provider/Layers/ClaudeAdapter.ts apps/server/src/provider/Errors.ts

echo '--- nearby CodexSessionRuntime request helper definitions ---'
sed -n '1,220p' apps/server/src/provider/Errors.ts

Repository: pingdotgg/t3code

Length of output: 20493


Scope the turn/steer fallback to the not-steerable case. Effect.catch treats every turn/steer failure as a benign precondition miss and immediately starts a new turn. That can mask transient RPC/protocol errors and launch a duplicate turn while the original one is still active. Let other failures propagate, or match only the specific not-steerable error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/provider/Layers/CodexSessionRuntime.ts` around lines 1290 -
1326, Restrict the error handling in the turn/steer request within the
activeSession branch to the specific not-steerable/precondition failure,
allowing transient RPC or protocol errors to propagate instead of triggering
turn/start. Preserve the existing fallback behavior for that matched case and
keep successful steer responses unchanged.

@t3dotgg
t3dotgg force-pushed the t3code/queue-steer-feature branch from 299d961 to f222834 Compare July 22, 2026 11:56
Comment thread apps/web/src/components/ChatView.logic.ts
Comment thread apps/server/src/persistence/Layers/ProjectionQueuedMessages.ts Outdated
Comment thread apps/web/src/components/ChatView.logic.ts Outdated
Comment thread apps/server/src/orchestration/projector.ts
@t3dotgg

t3dotgg commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Addressed all review feedback (rebased onto latest main; migration renumbered 033 → 034 after #4243 took slot 033):

Bugbot — queue cap desync (high): Fixed by moving the 50-message cap into the decider: a thread.turn.start that would exceed the cap is rejected with an invariant error, so a thread.message-queued event past the cap is never emitted and the in-memory read model, SQL projection, and event replay can no longer diverge. The projector's local slice() clamp is removed. New decider test covers the overflow rejection.

CodeRabbit — unscoped turn/steer fallback (major): The fallback to turn/start now catches only CodexAppServerRequestError (the request-level precondition rejection); transport and protocol failures propagate instead of racing a duplicate provider turn.

CodeRabbit — blob preview leak (minor): Optimistic sends acknowledged only by the queue now revoke their blob preview URLs instead of handing them off (queued chips render text only, so the handoff would never be promoted).

CodeRabbit — getCommandReadModel updatedAt (minor): Queued-message timestamps now advance updatedAt, matching getSnapshot.

CodeRabbit — reducer test coverage (trivial): Added threadReducer lifecycle tests for queue, replace-by-messageId, and remove.

An independent gpt-5.6-sol (Codex CLI) review of the full branch diff found two additional issues, both fixed:

  • P1 — steer during session startup: thread.queue.steer while the session was starting would fall through to turn/start in the Codex runtime (no active turn id yet) and race the pending startup with a second provider turn — after the message had already been dequeued. The decider now rejects steer while starting; the message stays queued and drains or steers normally once running. Covered by a new decider test.
  • P2 — queued attachments deleted by revert pruning: checkpoint-revert attachment pruning computed retained paths only from timeline messages, so reverting a thread deleted image files still referenced by queued messages. Queued-message attachments are now included in the retained set.

Validation: monorepo typecheck clean, vp check clean on all 37 changed files, 1192 tests across server orchestration/persistence, client-runtime, and web components passing.

🤖 Generated with Claude Code

Comment thread apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Comment thread apps/server/src/orchestration/decider.ts
t3dotgg and others added 4 commits July 22, 2026 14:15
…h explicit steer

Messages sent while a turn is running are now held server-side in a
per-thread queue and drained automatically on natural turn completion.
Queued messages render as chips above the composer with Steer (dispatch
now, steering the active turn) and Remove actions. Codex steers use the
protocol-native turn/steer with a turn/start fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A mid-turn send now projects as a queued message rather than a steered
timeline message, so the composer's local-dispatch acknowledgment also
watches the queued-message tail. Remove the unused listAll repository
method.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ew handling

- Move the 50-message queue cap from the projector into the decider so
  the event stream, in-memory read model, and SQL projection can never
  diverge (Bugbot); overflow rejects with an invariant error surfaced to
  the composer. Cap covered by a new decider test.
- Scope the Codex turn/steer fallback to CodexAppServerRequestError so
  transport/protocol failures propagate instead of racing a duplicate
  turn/start (CodeRabbit).
- Only hand off optimistic blob previews for messages entering the
  timeline; queued-only acknowledgments revoke them, fixing an object-URL
  leak (CodeRabbit).
- Advance getCommandReadModel updatedAt from queued-message timestamps
  (CodeRabbit).
- Cover queued-message lifecycle in the threadReducer tests (CodeRabbit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ents on revert

- Reject thread.queue.steer while the session is starting: there is no
  provider turn to steer into yet, and dispatching would race the pending
  turn/start with a second provider turn. The message stays queued and
  the composer surfaces the invariant error.
- Include queued-message attachments in the retained set during
  checkpoint-revert pruning so reverting a thread no longer deletes
  image files still referenced by queued messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@t3dotgg
t3dotgg force-pushed the t3code/queue-steer-feature branch from c36ded6 to 148c355 Compare July 22, 2026 21:43
…ion consistency

- Correlate composer 'Sending' acknowledgment with the dispatched
  messageId (timeline or queue projection) instead of queue-tail
  heuristics that another client's queue activity could satisfy
  (Bugbot + Macroscope). Dispatches without a known messageId keep the
  legacy latest-user-message heuristic.
- Drain queued messages in rowid (insertion) order instead of
  queued_at + message_id, which mis-ordered same-timestamp messages
  after a restart (Macroscope). Requeue replaces the row so an edited
  messageId moves to the tail, matching the in-memory projector.
- Clear queuedMessages in the in-memory projector on thread.deleted,
  mirroring the SQL projection (Bugbot).
- Bootstrap the queuedMessages projector before threadMessages so
  revert pruning sees queued attachments during replay (Macroscope).
- Allow steer while a session reports 'starting' with a live
  activeTurnId (provider reconnect mid-turn); only the pending-start
  shape (starting, no active turn) is rejected (Bugbot).
- Settle mock provider sessions in ProviderCommandReactor tests whose
  follow-up sends now queue-by-default against a still-running session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@t3dotgg
t3dotgg force-pushed the t3code/queue-steer-feature branch from 148c355 to 6cf940c Compare July 22, 2026 21:46
@t3dotgg

t3dotgg commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Rebased onto latest main and addressed the second round of review findings:

Bugbot / Macroscope — composer acknowledges unrelated queue changes (medium): Rewrote the acknowledgment to be correlated instead of heuristic. LocalDispatchSnapshot now records the dispatched messageId (all three send paths pass it), and during a running turn the composer clears "Sending" only when that exact message appears in the projected timeline or queue. Another client queueing a message, or a different chip being steered/removed, no longer clears this client's send state. Dispatches without a known messageId (plan-implementation flow) keep the legacy latest-user-message heuristic. New logic tests cover both the match and the unrelated-change cases.

Macroscope — same-timestamp queue mis-ordering after restart (medium): Queue drain order is now SQLite rowid (insertion) order instead of queued_at, message_id, so same-millisecond enqueues can't swap after a restart. The upsert is now INSERT OR REPLACE so a re-queued messageId moves to the queue tail, matching the in-memory projector's replace semantics. Migration 034's index drops the now-unused queued_at column (034 hasn't shipped, so edited in place).

Bugbot — deleted thread keeps in-memory queue (low): The in-memory projector now clears queuedMessages on thread.deleted, mirroring the SQL projection.

Macroscope — bootstrap projector ordering deletes queued attachments (high): The queuedMessages projector now bootstraps before threadMessages, so the revert handler's retained-attachment set sees queued messages during replay.

Bugbot — steer blocked during mid-turn reconnect (medium): The steer guard now rejects only the pending-start shape (starting with no activeTurnId). A session reporting starting while still tracking an active turn (e.g. Codex session/connecting mid-turn) is steerable again.

Also updated ProviderCommandReactor tests whose follow-up sends now queue by default: the harness gained a settleSession helper that returns the projected session to ready (preserving provider identity) before the second send, mirroring what turn completion does in production.

Validation: monorepo typecheck clean, vp check clean on all 39 changed files, and the full affected suites pass — 3649 tests across apps/server, apps/web, client-runtime, and contracts.

🤖 Generated with Claude Code

Comment thread apps/web/src/components/ChatView.logic.ts
Comment thread apps/server/src/orchestration/Layers/ProjectionPipeline.ts Outdated
Comment thread apps/server/src/orchestration/decider.ts
Comment thread apps/web/src/components/ChatView.logic.ts
Comment thread apps/server/src/orchestration/decider.ts Outdated
…ases, attachment cleanup

- Add pendingTurnStart to the thread read model, set by
  thread.turn-start-requested and cleared when the session adopts the
  turn (running with activeTurnId) or fails/stops. The decider's
  queue-by-default, drain, and steer guards now cover the window between
  dispatching a turn start and the provider reporting session status —
  a send racing in behind a drain queues instead of opening a second
  concurrent turn (Bugbot + Macroscope). Hydrated from the existing SQL
  pending-turn-start rows on restart; the settle guard keeps its
  timestamp heuristic (renamed hasRecentUnadoptedUserMessage) since
  client-supplied clocks must not drive dispatch decisions.
- Acknowledge a projected expectedMessageId in every composer phase,
  not just running: sends during 'connecting' also queue server-side
  (Bugbot + Macroscope).
- Prune attachment files when a queued message is removed by the user;
  dispatch removals keep them since the same attachments re-enter the
  timeline via the paired message-sent (Macroscope).
- Restrict the Codex turn/steer fallback to receive-response request
  errors (server rejected the steer). Response-decode failures on an
  accepted steer propagate instead of double-sending the input via
  turn/start (Macroscope).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@t3dotgg

t3dotgg commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Round 3 findings addressed:

Bugbot — idle send bypasses pending queue / Macroscope — drain race window (medium): This was the real design gap in the round. Fixed with an event-driven signal instead of timestamps: the thread read model now carries pendingTurnStart, set by thread.turn-start-requested and cleared when the session adopts the turn (running with activeTurnId) or reaches a terminal status (a failed start sets session error, which clears it). The decider's queue-by-default check, the drain guard, and the steer guard all consult it, so a send racing in behind a just-dispatched turn start (or drain) queues behind existing chips instead of opening a second concurrent turn. It hydrates from the existing SQL pending-turn-start rows on restart. I deliberately did not use the message-timestamp heuristic the settle guard uses — message createdAt is client-supplied, and a skewed client clock would wrongly queue sends on an idle thread where nothing ever drains them (the settle guard keeps its heuristic, renamed hasRecentUnadoptedUserMessage, where the failure mode is only a delayed settle). New decider test covers queue-behind-pending-start and drain-rejected-while-pending.

Bugbot + Macroscope — connecting-phase sends never acknowledge (medium): The projected expectedMessageId check now runs in every composer phase, not just running — the server also queues sends while a session is starting, which the client renders as connecting.

Macroscope — queued-message removal leaks attachment files (medium): User removals now record a prune of the thread's attachment set (retained timeline + remaining queue) so the removed message's files are deleted. Dispatch removals intentionally keep everything — the same attachments re-enter the timeline via the paired thread.message-sent.

Macroscope — steer fallback on decode failures duplicates input (in the drain-race comment): The Codex turn/steer fallback now matches only operation: "receive-response" request errors — the server actively rejected the steer, so it was not applied. Decode failures on an accepted steer's response (and transport/protocol errors) propagate instead of re-sending the same input via turn/start.

Validation: monorepo typecheck clean, vp check clean on the branch's changed files, and the affected suites pass — 3,649 tests across apps/server, apps/web, client-runtime, and contracts (single failure is the known pre-existing ProviderRegistry binary-probe flake, which passes in isolation and on main).

🤖 Generated with Claude Code

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2218b91. Configure here.

Comment thread apps/server/src/orchestration/projector.ts
Comment thread apps/server/src/orchestration/decider.ts
Comment thread apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
…ard, hydrate detail snapshot

- Clear pendingTurnStart on every settled session status (including
  ready), in both the in-memory projector and the SQL projection. A
  mid-turn steer re-arms the flag with the session already running, so
  only terminal-status clearing left it stuck after natural completion,
  permanently rejecting queue drains. Ready-with-genuinely-pending
  starts never project as ready — ingestion maps that shape to starting.
- Reject steer while a turn start awaits adoption whenever the session
  tracks no active turn, including running with a null activeTurnId.
- Hydrate pendingTurnStart in getThreadDetailById so thread-detail
  snapshots agree with the command read model after a refresh.
- Decider test covers the steer-then-complete-then-drain lifecycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@t3dotgg

t3dotgg commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

Round 4 addressed — all three were real consequences of round 3's pendingTurnStart, and the first was a genuine high-severity catch:

Bugbot — stuck pending blocks drain (high): Confirmed. A mid-turn steer emits thread.turn-start-requested while the session is already running, re-arming pendingTurnStart — and since the session never passes through a fresh adoption transition, only the old terminal-status clearing (error/stopped/interrupted) would ever release it. Natural completion (ready) left it stuck, so every subsequent auto-drain was rejected and swallowed: queued follow-ups would sit forever. Both the in-memory projector and the SQL projection now clear the pending start on every settled status including ready. That's safe for genuinely-pending starts because ingestion never projects those as ready — it maps ready-with-pending-start to starting. New decider test walks the full lifecycle: queue two → steer one mid-turn → complete → verify the flag released → drain dispatches the second.

Bugbot — steer races pending adoption (high): The guard now rejects steer whenever a turn start awaits adoption and the session tracks no active turn — including running with a null activeTurnId (provider waiting shape), which the previous status-based condition let through.

Bugbot — detail snapshot drops pending start (medium): getThreadDetailById now hydrates pendingTurnStart from the SQL pending row, so thread-detail refreshes agree with the command read model instead of silently defaulting to null.

Validation: typecheck clean, vp check clean on the diff (2 remaining warnings are pre-existing in CommandPalette.tsx from main), 3,650 tests passing across server/web/client-runtime/contracts — sole failure remains the known ProviderRegistry probe flake (passes in isolation and fails identically on clean main).

🤖 Generated with Claude Code

@xptea

xptea commented Jul 23, 2026

Copy link
Copy Markdown

Need

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

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants