Skip to content

feat(orchestrator): introduce new orchestrator - #2829

Open
juliusmarminge wants to merge 239 commits into
mainfrom
t3code/codex-turn-mapping
Open

feat(orchestrator): introduce new orchestrator#2829
juliusmarminge wants to merge 239 commits into
mainfrom
t3code/codex-turn-mapping

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented May 27, 2026

Copy link
Copy Markdown
Member

Summary

  • wire orchestration V2 provider adapter registry/factory flow for Codex and Claude provider instances
  • add Claude replay/query primitives, native fork/rollback fixtures, subagent fixture coverage, and provider replay harness updates
  • update debugger model/provider picker and improve user-facing orchestration errors

Validation

  • bun fmt
  • bun lint
  • bun typecheck
  • bun run test -- src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts -t claudeAgent
  • bun run test -- src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts
  • bun run test -- src/orchestration-v2/testkit/ThreadFork.integration.test.ts -t Claude

Notes

  • Draft PR for review of current branch state. Codex all-provider replay still needs schema alignment with latest app-server behavior before it can be treated as a full-suite signal.

Closes

Verified against the branch with code/commit evidence.

High confidence

Closes #4952
Closes #4873
Closes #4775
Closes #4795
Closes #4710
Closes #4668
Closes #4619
Closes #4584
Closes #4561
Closes #4713
Closes #4198
Closes #4452
Closes #3797
Closes #4232
Closes #3666
Closes #3580
Closes #2785
Closes #2789
Closes #3138
Closes #1404
Closes #231
Closes #216

Medium confidence (under review)

Closes #4568
Closes #4766
Closes #4495
Closes #4456
Closes #4399
Closes #3744
Closes #2921
Closes #3624
Closes #3149
Closes #2336
Closes #538
Closes #2173
Closes #2065

Note

Introduce V2 orchestration engine with multi-provider adapters, MCP toolkits, and scheduled tasks

  • Adds a complete V2 orchestration layer (apps/server/src/orchestration-v2/) with event sourcing, projection store, provider session/turn lifecycle, checkpoint capture/rollback, thread forking/merging, subagent support, and background task recovery.
  • Implements provider adapters for Claude, Codex, Cursor, Grok, OpenCode, and a new ACP Registry driver, all exposing an orchestrationAdapter on ProviderInstance instead of the previous adapter field.
  • Introduces MCP toolkits for orchestration (delegate_task, task_status, schedule_task, thread CRUD) and worktree (t3_worktree_handoff, t3_worktree_status) via OrchestratorMcpService and WorktreeMcpService.
  • Adds scheduled tasks: DB migration, ScheduledTaskService, HTTP/WebSocket atoms, and a full settings UI at /settings/scheduled-tasks.
  • Adds nine SQL migrations (041–049) covering V2 event log, projections, subagents, provider session bindings, launch workflows, application event sourcing, effect outbox cancellation, scheduled tasks, and legacy V1 import state.
  • Extends the web and mobile clients with V2 projection atoms, thread history pagination, queue management UI, thread details panel, relationship graph, and runtime-based status fields replacing legacy session/turn fields.
  • Adds deterministic replay harnesses and fixture suites for all providers to enable integration testing without live providers.
  • Risk: ProviderInstance.adapter is renamed to orchestrationAdapter; OrchestrationWsMethods replaced by V2 variants; TurnId/activeTurnId replaced by RunId/activeRunId across client state — any code not updated in this PR will break at compile time.

Macroscope summarized 7a05917.


Note

Medium Risk
Mobile thread UX and persistence now depend on V2 projection/cache contracts; removing the PR transfer reporter reduces regression visibility for wire-byte budgets though CI artifacts remain.

Overview
CI and automation: Drops the Thread Transfer Report workflow and the trusted .github/scripts/thread-transfer-report publisher/tests that upserted baseline-vs-PR wire-byte tables on pull requests. CI still runs transfer budget tests and uploads thread-transfer-results to the job summary/artifacts; only automated PR commenting is removed. The test job now installs build-essential so ACP process-tree fixtures compile instead of soft-skipping when cc is missing.

Planning/docs: Adds .plans/19-thread-lineage-context-transfer.md (fork/transfer slice status) and .plans/21-orchestration-v2-application-integration.md (Shapes 1–4.5 integration roadmap), indexes plan 21 in .plans/README.md, and links appearance docs from the root README. Marketing copy updates Cursor harness tag to @cursor/sdk.

Mobile (V2 alignment): Shell/thread offline caches use shared ORCHESTRATION_CACHE_SCHEMA_VERSION and V2 snapshot shapes (projection.thread.id, DateTime fields). Connection runtime wires bounded thread snapshot loading and history controls. Archive/home/list flows use V2 thread shells and thread.runtime instead of V1 session/turn fields; archive eligibility follows provider-active vs queued/waiting rules in threadCanArchive. Thread detail consumes V2 projections (visibleTurnItems, checkpoint derivations, RuntimeRequestId), visit watermark commands, queue/relationship UI, expanded activity inspectors (rollback, file links), and dead-provider gating on approval/user-input cards. Smaller fixes: shared brand asset module, desktop user-data dir names in tests, orElseSucceed typing, hardware keyboard handler iteration without toReversed.

Desktop: Minor environment test expectations for userDataDirName / legacy dir name.

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

@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b00b76b5-8def-43f0-923a-726307ad430f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/codex-turn-mapping

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

@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels May 27, 2026
Comment thread apps/server/src/orchestration-v2/EventStore.ts Outdated
Comment on lines +116 to +120
return decodeTranscript({
...metadata,
entries,
});
});

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.

🟢 Low testkit/ReplayTranscriptNdjson.ts:116

The call to decodeTranscript at line 116 invokes Schema.decodeUnknownSync, which throws on validation failure. Since this isn't wrapped in Effect.try, any validation error becomes an uncaught exception (defect) instead of a typed ProviderReplayNdjsonParseError. This breaks the function's declared error contract. Consider wrapping the call in Effect.try to catch the exception and convert it to the declared error type.

-    return decodeTranscript({
-      ...metadata,
-      entries,
-    });
+    return yield* Effect.try({
+      try: () =>
+        decodeTranscript({
+          ...metadata,
+          entries,
+        }),
+      catch: (cause) =>
+        new ProviderReplayNdjsonLineParseError({
+          lineNumber: lines.length,
+          line: "<transcript validation>",
+          cause,
+        }),
+    });
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts around lines 116-120:

The call to `decodeTranscript` at line 116 invokes `Schema.decodeUnknownSync`, which throws on validation failure. Since this isn't wrapped in `Effect.try`, any validation error becomes an uncaught exception (defect) instead of a typed `ProviderReplayNdjsonParseError`. This breaks the function's declared error contract. Consider wrapping the call in `Effect.try` to catch the exception and convert it to the declared error type.

Evidence trail:
apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts lines 50-53: `decodeTranscript = Schema.decodeUnknownSync(ProviderReplayTranscript)` — throws on failure.
Line 116-118: `return decodeTranscript({...metadata, entries})` — called directly inside Effect.gen without Effect.try wrapper.
Lines 55-68 (`parseReplayRecord`): same pattern but correctly wrapped in `Effect.try`.
Line 80: function declares error type `ProviderReplayNdjsonParseError`.
packages/contracts/src/orchestrationV2.ts lines 1561-1568: `ProviderReplayTranscript` schema with `TrimmedNonEmptyString` fields that can fail validation.

Comment thread apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts Outdated
Comment thread packages/client-runtime/src/wsRpcClient.ts Outdated
Comment thread apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/EventSink.ts
duncan4123 pushed a commit to duncan4123/t3code that referenced this pull request Jun 1, 2026
…der adapters (t3-29f.6)

Assessment of upstream PR pingdotgg#2829 (pingdotgg/t3code) from juliusmarminge:
WIP wire orchestration v2 provider adapters with Codex and Claude adapters,
event sourcing, provider session management, and replay testkit.

Relevance to target issues:
- pingdotgg#2838 (session resume): HIGH — ProviderSessionManager persists session
  IDs and separates startSession/resumeSession operations
- pingdotgg#2778 (subagent hang): MEDIUM — ProviderEventIngestor provides
  infrastructure to forward permission events, but UI plumbing not yet wired
- pingdotgg#2886 (thread stuck working): HIGH — event-sourced projections replace
  mutable state flags, eliminating sticky "working" states

The PR is a draft (34 commits, not merged). No OpenCode ACP adapter
exists yet in v2 — OpenCode would need its own adapter wired into the
ProviderAdapterRegistry. Recommend watching for merge and adding an
OpenCode adapter post-merge.
duncan4123 pushed a commit to duncan4123/t3code that referenced this pull request Jun 2, 2026
duncan4123 pushed a commit to duncan4123/t3code that referenced this pull request Jun 2, 2026
The upstream PR pingdotgg#2829 added orchestrationV2 methods to the WsRpcClient
interface. The test mock in service.threadSubscriptions.test.ts was
missing the orchestrationV2 property, causing a typecheck failure:
'Property orchestrationV2 is missing in type...'

Added orchestrationV2 mock with dispatchCommand, getThreadProjection,
subscribeShell, and subscribeThread as vi.fn() stubs.
duncan4123 pushed a commit to duncan4123/t3code that referenced this pull request Jun 2, 2026
The upstream PR pingdotgg#2829 targets a newer Effect version than our fork's
pinned effect@4.0.0-beta.73. Fixes:

- Replace Random.nextUUIDv4 with Crypto.randomUUIDv4 (beta.73 API)
- Fix deterministic Service tag keys to match fork convention
  (include file path segments; e.g. Adapters/ClaudeAdapterV2/...)
- Replace Schema.decodeSync with Schema.decodeUnknownEffect inside
  Effect.gen generators (tsgo schemaSyncInEffect rule)
- Replace inline Schema.encodeUnknownSync with module-level wrappers
  to avoid schemaSyncInEffect rule inside generators
@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

🚀 Expo continuous deployment is ready!

  • Project → t3-code
  • Platforms → android, ios
  • Scheme → t3code-preview
  🤖 Android 🍎 iOS
Fingerprint fe5a51f2e189da69dfc4c2cd458e6cfb5fdff2ea ae3bd597809dfd7771d0898f735d172973d4c1c8
Build Details Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: fe5a51f2e189da69dfc4c2cd458e6cfb5fdff2ea
App version: 0.1.0
Git commit: 1d5a64460414f9a2c6ff4a5e4f977932228b7f1b
Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: ae3bd597809dfd7771d0898f735d172973d4c1c8
App version: 0.1.0
Git commit: eea0dcae4150df8341606520c074dc651ae7c00a
Update Details Update Permalink
DetailsBranch: pr-2829
Runtime version: fe5a51f2e189da69dfc4c2cd458e6cfb5fdff2ea
Git commit: 1d5a64460414f9a2c6ff4a5e4f977932228b7f1b
Update Permalink
DetailsBranch: pr-2829
Runtime version: ae3bd597809dfd7771d0898f735d172973d4c1c8
Git commit: 1d5a64460414f9a2c6ff4a5e4f977932228b7f1b
Update QR

Comment thread apps/server/src/orchestration-v2/RunExecutionService.ts
Comment thread apps/server/src/ws.ts
Comment thread apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/RunExecutionService.ts
@juliusmarminge juliusmarminge changed the title WIP: wire orchestration v2 provider adapters feat(orchestrator): introduce new orchestrator Jun 14, 2026
Comment thread apps/server/src/orchestration-v2/RunExecutionService.ts
Comment on lines +109 to +128
Effect.gen(function* () {
const threadId = payloadInput.threadId ?? input.threadId;
const eventId = yield* idAllocator.allocate.event({
threadId,
providerSessionId: input.providerSessionId,
});
const occurredAt = yield* DateTime.now;
return yield* Schema.decodeUnknownEffect(OrchestrationV2DomainEvent)(
compactUndefined({
id: eventId,
type: payloadInput.type,
threadId,
runId: payloadInput.runId ?? input.runId,
nodeId: payloadInput.nodeId ?? input.nodeId,
provider: input.event.provider,
rawEventId: input.rawEventId,
occurredAt,
payload: payloadInput.payload,
}),
);

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.

🟡 Medium orchestration-v2/ProviderEventIngestor.ts:109

In makeDomainEvent, the ?? operator on lines 121 and 123 treats explicit null as equivalent to undefined, causing payloadInput.runId ?? input.runId to fall back to input.runId when payloadInput.runId is explicitly null. Since the type is readonly runId?: RunId | null, this means explicit null values from the caller (e.g., input.event.node.runId being null on line 172) are incorrectly overwritten instead of preserved. Consider using === undefined checks like lines 161-162 and 210-211, or use payloadInput.runId === undefined ? input.runId : payloadInput.runId.

          const threadId = payloadInput.threadId ?? input.threadId;
-          const runId = payloadInput.runId ?? input.runId;
-          const nodeId = payloadInput.nodeId ?? input.nodeId;
+          const runId = payloadInput.runId === undefined ? input.runId : payloadInput.runId;
+          const nodeId = payloadInput.nodeId === undefined ? input.nodeId : payloadInput.nodeId;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/ProviderEventIngestor.ts around lines 109-128:

In `makeDomainEvent`, the `??` operator on lines 121 and 123 treats explicit `null` as equivalent to `undefined`, causing `payloadInput.runId ?? input.runId` to fall back to `input.runId` when `payloadInput.runId` is explicitly `null`. Since the type is `readonly runId?: RunId | null`, this means explicit `null` values from the caller (e.g., `input.event.node.runId` being `null` on line 172) are incorrectly overwritten instead of preserved. Consider using `=== undefined` checks like lines 161-162 and 210-211, or use `payloadInput.runId === undefined ? input.runId : payloadInput.runId`.

Evidence trail:
apps/server/src/orchestration-v2/ProviderEventIngestor.ts lines 105-106 (payloadInput type with `RunId | null`), line 121 (`runId: payloadInput.runId ?? input.runId`), line 122 (`nodeId: payloadInput.nodeId ?? input.nodeId`), lines 161-162 and 210-211 (codebase uses `=== undefined` pattern elsewhere). packages/contracts/src/orchestrationV2.ts line 358 (`runId: Schema.NullOr(RunId)` on ExecutionNode - confirms null is a valid value), line 857 (`runId: Schema.optional(RunId)` on EventBase - domain event uses optional/undefined, not null). apps/server/src/orchestration-v2/ProviderEventIngestor.ts lines 80-81 (compactUndefined only strips undefined, not null).

Comment thread apps/server/src/orchestration-v2/Orchestrator.ts
Comment thread apps/web/src/routes/debug.orchestration-v2.tsx Outdated
Comment thread apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@juliusmarminge
juliusmarminge force-pushed the t3code/codex-turn-mapping branch from 79031a1 to 4e68dcb Compare June 14, 2026 23:55
@juliusmarminge
juliusmarminge force-pushed the t3code/codex-turn-mapping branch from 4e68dcb to c7539b9 Compare June 17, 2026 07:30
Comment thread apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/ProviderSessionManager.ts Outdated
Comment thread apps/server/src/orchestration-v2/Orchestrator.ts Outdated
Comment thread apps/server/src/mcp/OrchestratorMcpService.ts Outdated
Comment thread apps/server/src/ws.ts Outdated
function nativeThreadId(provider: ProviderKind, thread: OrchestrationV2ProviderThread): string {
const id = thread.nativeThreadRef?.nativeId;
if (id === null || id === undefined || id.trim().length === 0) {
throw new ProviderAdapterProtocolError({

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.

🟡 Medium Adapters/AcpAdapterV2.ts:271

When nativeThreadId is called inside Effect.gen generators (e.g., lines 899, 1813), the thrown ProviderAdapterProtocolError becomes an untyped defect instead of a typed failure. This bypasses Effect.mapError and other typed error handlers, causing the error to propagate as an unexpected defect. Consider converting nativeThreadId to return Effect<string, ProviderAdapterProtocolError> and yielding it at each call site, or inlining the validation with yield* new ProviderAdapterProtocolError(...) so the failure is properly typed.

Also found in 1 other location(s)

apps/server/src/orchestration-v2/ThreadManagementService.ts:278

The statement return yield* managementError(...) cannot work correctly because managementError() returns a ThreadManagementError instance, not an Effect. The yield* operator in Effect.gen expects an Effect value. This should be return yield* Effect.fail(managementError(...)). The correct pattern is demonstrated elsewhere in this file (lines 241-246) where Effect.fail(managementError(...)) is properly used. This same bug pattern repeats at lines 291, 333, 357, 374, 390, 406, and 427.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts around line 271:

When `nativeThreadId` is called inside `Effect.gen` generators (e.g., lines 899, 1813), the thrown `ProviderAdapterProtocolError` becomes an untyped defect instead of a typed failure. This bypasses `Effect.mapError` and other typed error handlers, causing the error to propagate as an unexpected defect. Consider converting `nativeThreadId` to return `Effect<string, ProviderAdapterProtocolError>` and yielding it at each call site, or inlining the validation with `yield* new ProviderAdapterProtocolError(...)` so the failure is properly typed.

Evidence trail:
1. nativeThreadId function with throw: apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts lines 268-277
2. Call site inside Effect.gen: apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts line 899
3. Call site inside Effect.gen: apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts line 1813
4. Correct yield* pattern for comparison: apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts line 1808
5. ProviderAdapterProtocolError class definition: apps/server/src/orchestration-v2/ProviderAdapter.ts lines 316-327
6. Effect.gen implementation delegating to fromIteratorUnsafe: https://github.com/Effect-TS/effect-smol packages/effect/src/internal/effect.ts lines 1104-1125
7. fromIteratorUnsafe calling iter.next() without try/catch: https://github.com/Effect-TS/effect-smol packages/effect/src/internal/effect.ts lines 1285-1307
8. FiberImpl.runLoop catch block converting thrown errors to exitDie: https://github.com/Effect-TS/effect-smol packages/effect/src/internal/effect.ts lines 646-650
9. die = exitDie producing Effect<never> (untyped): https://github.com/Effect-TS/effect-smol packages/effect/src/internal/effect.ts line 947

Also found in 1 other location(s):
- apps/server/src/orchestration-v2/ThreadManagementService.ts:278 -- The statement `return yield* managementError(...)` cannot work correctly because `managementError()` returns a `ThreadManagementError` instance, not an `Effect`. The `yield*` operator in `Effect.gen` expects an Effect value. This should be `return yield* Effect.fail(managementError(...))`. The correct pattern is demonstrated elsewhere in this file (lines 241-246) where `Effect.fail(managementError(...))` is properly used. This same bug pattern repeats at lines 291, 333, 357, 374, 390, 406, and 427.

juliusmarminge and others added 9 commits August 17, 2026 12:10
…toggles

Restores main's one-inset rule (#5226) that a rebase resolution had
overridden with a conditional right-2 offset, which made the controls jump
sideways whenever the right panel opened. Also restores the live-agent
count badge on the right-panel toggle (#5745) that the round-6 replay
dropped, and applies the same fixed-position rule to the pull requests
page: the toggle now stays mounted at one absolute inset in both states,
with a footprint spacer in the list header so the refresh button never
slides underneath it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The right controls carry mr-px (main's border compensation for anchoring
inside the panel frame), which left the sidebar trigger one pixel closer
to its edge and the sheet-mode tab bar one pixel tighter than the closed
state. Mirror the pixel on the trigger and the sheet layout-controls slot
so all three read the same inset.

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

The trigger's icon falls through to the Button default (size-4) while the
right cluster hard-coded size-3.5, so the two ends of the titlebar read a
pixel apart on every edge. All five layout-control icons now use size-4,
matching the trigger and the pull requests page's refresh icon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Omit transcript bodies from shell rows
- Query archived threads separately and stream compact resume metadata
Re-applies the deltas that mid-stack blob reverts discarded, and merges
main's work into the v2-owned surfaces:

- keybindings: main's STATIC_KEYBINDING_COMMANDS rename plus both new
  commands (rightPanel.toggleMaximized alongside threadPanel.toggle)
- OpenInPicker: main's remote-open/SSH routing and favorite-editor
  shortcut layered onto the branch's panel/toolbar variants; the
  extracted shouldShowOpenInPicker now takes remoteOpenMode
- ChatMarkdown: main's bare-filename resolver (#6297) ported into the
  branch's module-level component factory, plus #4133 title-attribute
  stripping on links and images
- ComposerPrimaryActions: main's #4781 model (stop stays reachable, send
  joins it when Enter-to-send is unavailable) carrying the branch's
  steering send button
- ComposerPendingUserInputPanel: main's collapsible redesign with the v2
  RuntimeRequestId and responseCapability gate
- ChatComposer: main's oversized-prompt submission guard wrapping the
  branch's dispatch-mode send
- preview shell: main's container-aware width clamp ported into the
  branch's usePreviewPanelInlineSize hook
- MessagesTimeline/Sidebar: main's day-aware timestamps, code-font tool
  bodies and provider accent badges on the v2 runtime shell
- index.css: main's @variant dark migration (#6381) replaces the branch's
  standalone .dark block
- contracts: main's send-turn image mime allowlist re-homed to
  chatAttachment.ts, where v2 keeps the other send-turn limits

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports v1's #5246 guard into the v2 dispatcher: a stored receipt only
proves that this exact command already ran for the thread it was recorded
against, so returning it for a command aimed at a different thread reports
success for work that never happened there. The check is extracted as
canReplayCommandReceipt so the rule is unit-testable, and reuse now fails
with OrchestratorCommandIdConflictError like the v1 path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Keep prominent activity rows visible with lifecycle status and provider metadata
- Move feed sizing logic into tested helpers and preserve native measurement for activity groups
Re-applies the deltas that mid-stack blob reverts discarded, and merges
main's round-9 work into the v2-owned surfaces:

- settings: main's Integrations page (#7082) coexists with the branch's
  Scheduled Tasks page in the path union, section labels, icons, and
  search catalog
- contracts: main's preview appearance/zoom/viewport settings imports
  restored beside the branch's modelSelection home for ModelSelection
- mobile: main's built-in themes (#6619) re-applied to the v2 thread
  screens and work log (useThemeColor over hand-rolled color-scheme
  ternaries)
- MessagesTimeline: main's #7157 cleanup adopted (toolCallExpandedBody
  class name unexported, implementation-detail test dropped)
- ChangedFilesTree: main's styled tooltip (#7209) carrying the v2 runId
- pullRequestDetail tests: branch's row-action coverage renamed onto
  main's buildAddSelectionToAgentHandoff (#6597)
- lint: migrated the six branch-owned native title tooltips that main's
  new no-native-title-tooltip rule (#7209) flags to styled Tooltips
  (GitActionsControl, QueuedRunsControl, TimelineSystemDivider,
  MessagesTimeline intent badge and MCP tool logo)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports #7083 into the v2 session path, which replaced the v1
ProviderService where main's gate lives. Instead of withholding the whole
t3-code MCP credential — on this branch it also carries the thread
orchestration and worktree toolkits — the credential is minted without
the "preview" capability when enableAgentBrowserAccess is off, so every
preview tool call rejects while orchestration stays available.

ProviderSessionManager reads the setting at prepare time (deny on an
unreadable settings file, matching main), rotates a reused credential
whose capability set no longer reflects the setting, and the session
config now carries browserToolsAvailable so the Codex adapter keeps its
developer instructions truthful via main's parameterized instruction
builders instead of the removed constants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
}
}
}).pipe(
Effect.tapError(() =>

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.

🟡 Medium orchestration-v2/ProviderSessionManager.ts:1034

A defect or interruption after attachThread leaves the thread marked attached, so later retries skip MCP setup and the thread can remain without a usable MCP session. Effect.tapError only runs for typed errors; use cause-based cleanup so defects and interruption also remove the attachment and revoke newly issued credentials.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/ProviderSessionManager.ts around line 1034:

A defect or interruption after `attachThread` leaves the thread marked attached, so later retries skip MCP setup and the thread can remain without a usable MCP session. `Effect.tapError` only runs for typed errors; use cause-based cleanup so defects and interruption also remove the attachment and revoke newly issued credentials.

: await makeCheckpointWorkspace(`claude-agent-sdk-record-${scenario}`));
const shouldRemoveCwd = process.env.T3_CLAUDE_REPLAY_CWD === undefined;

if (shouldRemoveCwd && (scenario === "tool_call_read_only" || scenario === "subagent")) {

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.

🟢 Low scripts/record-claude-agent-sdk-replay-fixture.ts:368

If the fixture setup at lines 368–392 fails, the temporary workspace is leaked because it is created before the try/finally at line 394. Move the protected region to immediately after workspace creation so failures from writeFileString and other setup operations still remove cwd.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts around line 368:

If the fixture setup at lines 368–392 fails, the temporary workspace is leaked because it is created before the `try`/`finally` at line 394. Move the protected region to immediately after workspace creation so failures from `writeFileString` and other setup operations still remove `cwd`.

return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const baseDir = yield* fs.makeTempDirectory({

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.

🟢 Low testkit/ProviderReplayHarness.ts:73

Each replay layer acquisition creates a new temporary tree that is never deleted, so repeated replay scenarios leave userdata, logs, worktrees, and caches in the system temp directory and steadily consume disk space. Wrap the temporary-directory lifetime in Effect.acquireRelease (or otherwise remove baseDir recursively when the layer is released).

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts around line 73:

Each replay layer acquisition creates a new temporary tree that is never deleted, so repeated replay scenarios leave `userdata`, logs, worktrees, and caches in the system temp directory and steadily consume disk space. Wrap the temporary-directory lifetime in `Effect.acquireRelease` (or otherwise remove `baseDir` recursively when the layer is released).

Effect.flatMap(() =>
Effect.gen(function* () {
const terminal = yield* Ref.get(terminalEvent);
if (terminal === 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.

🟡 Medium orchestration-v2/RunExecutionService.ts:1179

A clean close of eventSubscription.events without a turn.terminal event leaves the run, attempt, and root node permanently in running state. After Stream.runDrain, terminalEvent is null and this branch returns without invoking finalizeRootRun; treat the stream close as a provider failure (or otherwise finalize the run) so disconnects cannot strand the execution.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/RunExecutionService.ts around line 1179:

A clean close of `eventSubscription.events` without a `turn.terminal` event leaves the run, attempt, and root node permanently in `running` state. After `Stream.runDrain`, `terminalEvent` is `null` and this branch returns without invoking `finalizeRootRun`; treat the stream close as a provider failure (or otherwise finalize the run) so disconnects cannot strand the execution.

cause: `Native fork transfer ${nativeForkTransfer.id} has no source provider execution.`,
});
}
return yield* session.forkThread({

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.

🟡 Medium orchestration-v2/ProviderTurnStartService.ts:200

A retry can create duplicate provider threads for the same pending fork: session.forkThread performs the external fork at line 200, but the pending transfer is only consumed by the later writeIfRunCurrent. If that write fails or the run becomes stale, the transfer remains pending and the next attempt issues another non-idempotent fork request. Persist a claim/idempotency marker before forking, or reconcile the created native thread on retry.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/ProviderTurnStartService.ts around line 200:

A retry can create duplicate provider threads for the same pending fork: `session.forkThread` performs the external fork at line 200, but the pending transfer is only consumed by the later `writeIfRunCurrent`. If that write fails or the run becomes stale, the transfer remains pending and the next attempt issues another non-idempotent fork request. Persist a claim/idempotency marker before forking, or reconcile the created native thread on retry.

await runFileSystem(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
yield* fs.remove(cwd, { recursive: true, force: true });

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.

🟡 Medium scripts/record-claude-agent-sdk-replay-fixture.ts:440

The finally block deletes a requested transcript when outputPath is inside the temporary cwd, so --out /tmp/claude-replay-tool_call_read_only/transcript.ndjson logs success and then leaves no output file. Reject output paths under the temporary workspace or write/move the transcript outside cwd before cleanup.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts around line 440:

The `finally` block deletes a requested transcript when `outputPath` is inside the temporary `cwd`, so `--out /tmp/claude-replay-tool_call_read_only/transcript.ndjson` logs success and then leaves no output file. Reject output paths under the temporary workspace or write/move the transcript outside `cwd` before cleanup.

createdAt: now,
updatedAt: now,
};
const childSessionResponse = yield* sdkCall(

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.

🟠 High Adapters/OpenCodeAdapterV2.ts:1148

A failure from session.get aborts the entire OpenCode SSE subscription, causing Stream.runForEach(handleEvent) to stop consuming future events and marking all active turns failed. Because emitSubagent lets this sdkCall escape while projecting one task, a transient or premature child-session lookup takes down the whole provider session; handle the lookup failure locally so only that subagent projection is delayed or failed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts around line 1148:

A failure from `session.get` aborts the entire OpenCode SSE subscription, causing `Stream.runForEach(handleEvent)` to stop consuming future events and marking all active turns failed. Because `emitSubagent` lets this `sdkCall` escape while projecting one task, a transient or premature child-session lookup takes down the whole provider session; handle the lookup failure locally so only that subagent projection is delayed or failed.

// thread. Replaying it for a command aimed at another thread would
// report success for work that never happened.
const dispatchThreadId = commandThreadId(command);
if (!canReplayCommandReceipt(receipt.threadId, dispatchThreadId)) {

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.

🟡 Medium orchestration-v2/Orchestrator.ts:6880

A different command type reused with the same commandId and thread is treated as a successful replay, so the new command is never dispatched and the caller receives the earlier command's events. The replay check only compares thread IDs; reject the receipt when receipt.commandType !== command.type as a command-ID conflict.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Orchestrator.ts around line 6880:

A different command type reused with the same `commandId` and thread is treated as a successful replay, so the new command is never dispatched and the caller receives the earlier command's events. The replay check only compares thread IDs; reject the receipt when `receipt.commandType !== command.type` as a command-ID conflict.

yield* existing.query.close.pipe(Effect.ignore);

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.

🟠 High Adapters/ClaudeAdapterV2.ts:4267

A failed replacement leaves the closed query in queryContext, so the next retry passes the reuse check and returns that unusable query; offering the prompt then fails and the thread cannot recover. Clear queryContext after closing the old same-thread query, before attempting queryRunner.open.

-             yield* existing.query.close.pipe(Effect.ignore);
+             yield* existing.query.close.pipe(Effect.ignore);
+             yield* Ref.update(queryContext, (current) =>
+               current?.query === existing.query ? null : current,
+             );
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts around line 4267:

A failed replacement leaves the closed query in `queryContext`, so the next retry passes the reuse check and returns that unusable query; offering the prompt then fails and the thread cannot recover. Clear `queryContext` after closing the old same-thread query, before attempting `queryRunner.open`.

Comment on lines +127 to +129
for (const stored of events) {
latestByThreadId.set(stored.event.threadId, stored);
}

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.

🟡 Medium orchestration-v2/ShellStream.ts:127

coalesceStoredThreadEvents drops the thread.unarchived event when a later event for the same thread arrives in the same window, so archivedShellStreamItemFromThreadShell returns null and archive subscribers retain the thread until a full refresh. Keep thread.unarchived under a separate coalescing key so both events are emitted.

Suggested change
for (const stored of events) {
latestByThreadId.set(stored.event.threadId, stored);
}
const latestByThreadId = new Map<string, OrchestrationV2StoredEvent>();
for (const stored of events) {
const key =
stored.event.type === "thread.unarchived"
? `unarchive:${stored.event.threadId}`
: stored.event.threadId;
latestByThreadId.set(key, stored);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/ShellStream.ts around lines 127-129:

`coalesceStoredThreadEvents` drops the `thread.unarchived` event when a later event for the same thread arrives in the same window, so `archivedShellStreamItemFromThreadShell` returns `null` and archive subscribers retain the thread until a full refresh. Keep `thread.unarchived` under a separate coalescing key so both events are emitted.

@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 1 potential issue.

Fix All in Cursor

Bugbot Autofix is ON, but a cloud agent failed to start.

Reviewed by Cursor Bugbot for commit 4c55679. Configure here.

selectedThreadLastVisitedAt,
selectedThreadUpdatedAt,
visitThread,
]);

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.

Visit spam during streaming

Medium Severity

Mobile visit tracking mirrors web but omits the trailing throttle web uses for mid-turn updatedAt bumps. While a run streams, each new watermark immediately dispatches thread.visit, producing a burst of RPCs and visit-marker churn across devices.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4c55679. Configure here.

juliusmarminge and others added 2 commits August 17, 2026 16:29
…s style simplification

Main's #6381 deleted the shared .workspace-topbar and scroll-fade rules
from index.css after inlining them at main's own call sites, but this
branch's slim chat chrome still references both classes. The round-8
rebase took the deletion without migrating the branch call sites, so the
header collapsed to zero height — the breadcrumb sat on the window edge,
timeline rows scrolled unfaded through it, and the thread-details popover
anchored to the collapsed header.

Restores both as composable utilities in #6381's own style: a
workspace-topbar utility for the titlebar rows, and the branch's
chat-timeline-scroll-fade mask (soft ramp plus a full-height scrollbar
column). Also drops the duplicated media override and its dead
settings-page-scroll-fade selector.

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

A follow-up sweep against #6381 found the branch still carrying the
pre-simplification forms it replaced, which my earlier fix had papered
over with a compat utility instead of finishing the migration:

- ChatView now uses main's inlined titlebar sizing and the
  data-workspace-titlebar-controls hook on both control clusters. The
  class-based markup was silently missing the themed-toggle bridge
  (html[data-theme-id] [data-workspace-titlebar-controls] …), so custom
  themes lost their titlebar accent in the thread view.
- The scroll-to-end pill becomes main's Button size="xs" variant="glass"
  instead of a hand-rolled button recreating it.
- MessagesTimeline uses main's consolidated topbar-scroll-fade utility;
  the byte-identical chat-timeline-scroll-fade copy and the
  workspace-topbar compat utility are gone.
- The composer-glass dark rules move into nested @variant dark like
  main's (the raw .dark duplicates could drift from the nested copies
  they shadowed), including the branch-only queue strip.
- The pre-#6381 dialog-glass/dialog-backdrop/dropdown-glass class rules
  and their .dark variants are deleted: the #6381 utilities plus
  call-site shadow utilities own every declaration, and the stale
  dropdown rule still had the saturate-less backdrop-filter. The dead
  model-picker-surface dark rule goes with them.

index.css now has zero raw .dark selectors outside the variant
definitions, matching the doctrine in
.macroscope/check-run-agents/ui-consistency.md. Verified against the
emitted production CSS: dark variants compile to :is(.dark,.dark *) with
their @supports color-mix fallbacks intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AND event_id LIKE ${`${IMPORT_EVENT_PREFIX}:message:%`}
`;
const existing = new Set(existingRows.map((row) => row.event_id));
const missing = messages.filter(

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.

🟡 Medium orchestration-v2/LegacyV1ThreadImporter.ts:525

Changed preview messages are permanently left stale in the V2 transcript: if a shell-preview assistant message later completes or gains text, ensureTranscriptBase sees its existing migration:v1:message:* event at line 524, excludes it from missing, and then sets transcript_imported_at, so the final message.updated/turn-item.updated payload is never emitted. Hydration must compare the current message payload (or a source revision), not just event-ID existence, and publish updates for changed previews before marking the transcript imported.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/LegacyV1ThreadImporter.ts around line 525:

Changed preview messages are permanently left stale in the V2 transcript: if a shell-preview assistant message later completes or gains text, `ensureTranscriptBase` sees its existing `migration:v1:message:*` event at line 524, excludes it from `missing`, and then sets `transcript_imported_at`, so the final `message.updated`/`turn-item.updated` payload is never emitted. Hydration must compare the current message payload (or a source revision), not just event-ID existence, and publish updates for changed previews before marking the transcript imported.

input.context.finalized = true;
const completedAt = yield* DateTime.now;
for (const tool of input.context.tools.values()) {
yield* emitToolArtifacts({ active: tool, completed: true });

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.

🟡 Medium Adapters/CursorAdapterV2.ts:1885

Interrupting or failing a turn records every still-running shell, file, or MCP call as successfully completed, often with no result. finalizeTurn passes completed: true for all tools during cleanup, and emitToolArtifacts treats a tool without an error result as completed; propagate the terminal cancellation/failure status instead so unfinished operations are projected as cancelled or failed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts around line 1885:

Interrupting or failing a turn records every still-running shell, file, or MCP call as successfully completed, often with no result. `finalizeTurn` passes `completed: true` for all tools during cleanup, and `emitToolArtifacts` treats a tool without an error result as `completed`; propagate the terminal cancellation/failure status instead so unfinished operations are projected as cancelled or failed.

return existing;
}
if (existing !== null) {
yield* existing.session.close.pipe(Effect.ignore);

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.

🟠 High Adapters/CursorAdapterV2.ts:1983

A concurrent readThreadSnapshot, resumeThread, or ensureThread for a different provider thread closes the SDK session backing the active run, causing that run to be interrupted or fail while activeTurn still points to it. openAgent closes liveAgent whenever the requested agent differs and does not check activeTurn; serialize these runtime operations with active turns or use a separate session for snapshot reads.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts around line 1983:

A concurrent `readThreadSnapshot`, `resumeThread`, or `ensureThread` for a different provider thread closes the SDK session backing the active run, causing that run to be interrupted or fail while `activeTurn` still points to it. `openAgent` closes `liveAgent` whenever the requested agent differs and does not check `activeTurn`; serialize these runtime operations with active turns or use a separate session for snapshot reads.

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.

🟠 High orchestration-v2/ProviderFailure.ts:66

redactProviderFailureText transports wss:// provider URLs with userinfo and query secrets unchanged, so endpoints such as wss://user:password@example.test/connect?signature=secret leak credentials to the client. The URL matcher only includes http and https; include ws and wss so redactUrl removes the userinfo and query.

Suggested change
.replace(/\bhttps?:\/\/[^\s<>"']+/giu, redactUrl)
.replace(/\b(?:https?|wss?):\/\/[^\s<>"']+/giu, redactUrl)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/ProviderFailure.ts around line 66:

`redactProviderFailureText` transports `wss://` provider URLs with userinfo and query secrets unchanged, so endpoints such as `wss://user:password@example.test/connect?signature=secret` leak credentials to the client. The URL matcher only includes `http` and `https`; include `ws` and `wss` so `redactUrl` removes the userinfo and query.

Comment on lines +1876 to +1880
if (
forkPromptGroups.length === 0 ||
forkPromptGroups.some((group) => group.length === 0) ||
forkPromptGroups.flat().join("\n") !== forkPrompts.join("\n")
) {

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.

🟡 Medium Adapters/ClaudeAdapterV2.testkit.ts:1876

Multiline prompts can pass this validation with different prompt boundaries, so the transcript records altered outbound prompts while metadata retains the originals. Joining with "\n" is not injective; compare the flattened prompt arrays element-by-element instead.

       forkPromptGroups.length === 0 ||
-      forkPromptGroups.some((group) => group.length === 0) ||
-      forkPromptGroups.flat().join("\n") !== forkPrompts.join("\n")
+      forkPromptGroups.some((group) => group.length === 0) ||
+      JSON.stringify(forkPromptGroups.flat()) !== JSON.stringify(forkPrompts)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts around lines 1876-1880:

Multiline prompts can pass this validation with different prompt boundaries, so the transcript records altered outbound prompts while metadata retains the originals. Joining with `"\n"` is not injective; compare the flattened prompt arrays element-by-element instead.

providerInstanceId: runtime.instanceId,
}),
).pipe(
Effect.andThen(observeActivity(providerSessionId, markBusy(providerSessionId))),

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.

🟠 High orchestration-v2/ProviderSessionManager.ts:1270

A stale exposed runtime can increment the replacement session's busyCount, leaving that replacement permanently ineligible for idle release. startTurn calls markBusy(providerSessionId) before verifying that this decorated runtime is still the current session entry; after the old entry is released and the ID is reopened, its terminal event pump cannot decrement the replacement's counter. Guard the activity update with a current-entry/runtime identity check.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/ProviderSessionManager.ts around line 1270:

A stale exposed runtime can increment the replacement session's `busyCount`, leaving that replacement permanently ineligible for idle release. `startTurn` calls `markBusy(providerSessionId)` before verifying that this decorated runtime is still the current session entry; after the old entry is released and the ID is reopened, its terminal event pump cannot decrement the replacement's counter. Guard the activity update with a current-entry/runtime identity check.

}),
).pipe(
Effect.andThen(runtime.ensureThread(input)),
Effect.tap((providerThread) =>

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.

🟡 Medium orchestration-v2/ProviderSessionManager.ts:1180

A stale ensureThread, resumeThread, or forkThread completion can mark a provider thread as loaded in a replacement session that reused the same providerSessionId, causing that session to skip its required runtime.resumeThread call. These handlers call markProviderThreadLoaded without verifying that sessions still maps the ID to the same runtime; guard the write with that runtime-identity check.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/ProviderSessionManager.ts around line 1180:

A stale `ensureThread`, `resumeThread`, or `forkThread` completion can mark a provider thread as loaded in a replacement session that reused the same `providerSessionId`, causing that session to skip its required `runtime.resumeThread` call. These handlers call `markProviderThreadLoaded` without verifying that `sessions` still maps the ID to the same `runtime`; guard the write with that runtime-identity check.

if (
isClaudeTaskNotificationOriginResult(message) &&
!isClaudeProviderContinuationTurn(context.input) &&
message.num_turns === 0

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.

🟠 High Adapters/ClaudeAdapterV2.ts:4060

A genuine wake that fails before its first model turn is dropped here, leaving the active provider turn hung indefinitely until the query exits. That wake also reports num_turns === 0, so the filter cannot distinguish it from lifecycle debris; preserve or otherwise correlate the terminal result instead of unconditionally returning for zero-turn results.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts around line 4060:

A genuine wake that fails before its first model turn is dropped here, leaving the active provider turn hung indefinitely until the query exits. That wake also reports `num_turns === 0`, so the filter cannot distinguish it from lifecycle debris; preserve or otherwise correlate the terminal result instead of unconditionally returning for zero-turn results.

if ((yield* Ref.get(activeTurns)).get(context.nativeTurnId) !== context) {
continue;
}
yield* client.request("turn/interrupt", {

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.

🟠 High Adapters/CodexAdapterV2.ts:4677

interruptTurn can block forever and never return when any initial turn/interrupt RPC hangs. The sequential request at line 4677 has no timeout, so execution never reaches the later 10-second completion wait, finalizeRemainingInterruptLineage, terminal cleanup, or the caller's response; issue the initial interrupts with the same bounded/concurrent recovery handling.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts around line 4677:

`interruptTurn` can block forever and never return when any initial `turn/interrupt` RPC hangs. The sequential request at line 4677 has no timeout, so execution never reaches the later 10-second completion wait, `finalizeRemainingInterruptLineage`, terminal cleanup, or the caller's response; issue the initial interrupts with the same bounded/concurrent recovery handling.

Comment on lines +6968 to +6973
commandType: command.type,
cause: "Command produced no domain events.",
}),
),
),
Effect.catch((cause)

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.

🟡 Medium orchestration-v2/Orchestrator.ts:6968

A losing concurrent dispatch can return success even though none of its events were committed. commitCommand may return the receipt reserved by another thread for the same commandId, but this path only checks status and never validates that committed.receipt.threadId matches commandThreadId(command); apply the same command-ID conflict check used during the initial receipt lookup.

 if (committed.receipt.status === "rejected") {
   return yield* new OrchestratorCommandPreviouslyRejectedError({
     commandId: command.commandId,
     commandType: command.type,
     detail: committed.receipt.error ?? "Previously rejected.",
   });
 }
+const committedThreadId = committed.receipt.threadId;
+const dispatchThreadId = commandThreadId(command);
+if (!canReplayCommandReceipt(committedThreadId, dispatchThreadId)) {
+  return yield* new OrchestratorCommandIdConflictError({
+    commandId: command.commandId,
+    commandType: command.type,
+    receiptThreadId: committedThreadId,
+    commandThreadId: dispatchThreadId,
+  });
+}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Orchestrator.ts around lines 6968-6973:

A losing concurrent dispatch can return success even though none of its events were committed. `commitCommand` may return the receipt reserved by another thread for the same `commandId`, but this path only checks `status` and never validates that `committed.receipt.threadId` matches `commandThreadId(command)`; apply the same command-ID conflict check used during the initial receipt lookup.

- Complete retry items when provider activity resumes
- Keep retry progress visible across web and mobile clients
Comment on lines +1 to +25
import {
forkSession,
query,
type SDKAssistantMessage,
type SDKMessage,
} from "@anthropic-ai/claude-agent-sdk";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { ProviderInstanceId, type ProviderReplayEntry } from "@t3tools/contracts";
import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";

import {
makeClaudeQueryOptions,
makeClaudeUserMessage,
type ClaudeAgentSdkQueryOptions,
} from "../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts";
import { randomUuidV4 } from "../src/orchestration-v2/RandomUuid.ts";
import { makeCheckpointWorkspace } from "../src/orchestration-v2/testkit/ReplayFixtureWorkspace.ts";

const SCENARIO = "thread_fork_native_fork_local_rollback";
const DEFAULT_OUTPUT = new URL(
"../src/orchestration-v2/testkit/fixtures/thread_fork_native_fork_local_rollback/claude_transcript.ndjson",
import.meta.url,
).pathname;

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.

🟢 Low scripts/probe-claude-fork-local-rollback-replay.ts:1

DEFAULT_OUTPUT treats the file URL's percent-encoded .pathname as a filesystem path, so a checkout containing spaces writes to a separate %20 directory instead of the fixture. Convert the URL with fileURLToPath(...) before using it as outputPath.

 import {
   forkSession,
   query,
   type SDKAssistantMessage,
   type SDKMessage,
 } from "@anthropic-ai/claude-agent-sdk";
+import { fileURLToPath } from "node:url";
 import * as NodeServices from "@effect/platform-node/NodeServices";
 import { ProviderInstanceId, type ProviderReplayEntry } from "@t3tools/contracts";
 import * as Console from "effect/Console";
 import * as Effect from "effect/Effect";
 import * as FileSystem from "effect/FileSystem";
@@
-const DEFAULT_OUTPUT = new URL(
+const DEFAULT_OUTPUT = fileURLToPath(new URL(
   "../src/orchestration-v2/testkit/fixtures/thread_fork_native_fork_local_rollback/claude_transcript.ndjson",
   import.meta.url,
-).pathname;
+));
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/scripts/probe-claude-fork-local-rollback-replay.ts around lines 1-25:

`DEFAULT_OUTPUT` treats the file URL's percent-encoded `.pathname` as a filesystem path, so a checkout containing spaces writes to a separate `%20` directory instead of the fixture. Convert the URL with `fileURLToPath(...)` before using it as `outputPath`.

return projection.runs.findLast((run) => ACTIVE_RUN_STATUSES.has(run.status)) ?? 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.

🟡 Medium state/threadWorkflows.ts:33

resolveActiveThreadRun can return a stale active run, so queue steering is disabled and provider-session/capability resolution uses the wrong run when projection.runs is not ordinal-sorted. findLast follows array order and returns an older waiting run stored after a newer running run; select the active run with the greatest ordinal instead.

-  return projection.runs.findLast((run) => ACTIVE_RUN_STATUSES.has(run.status)) ?? null;
+  return projection.runs.reduce<Run | null>(
+    (latest, run) =>
+      ACTIVE_RUN_STATUSES.has(run.status) && (latest === null || run.ordinal > latest.ordinal)
+        ? run
+        : latest,
+    null,
+  );
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/client-runtime/src/state/threadWorkflows.ts around line 33:

`resolveActiveThreadRun` can return a stale active run, so queue steering is disabled and provider-session/capability resolution uses the wrong run when `projection.runs` is not ordinal-sorted. `findLast` follows array order and returns an older `waiting` run stored after a newer `running` run; select the active run with the greatest `ordinal` instead.

};
const startedAt = context.toolStartedAt.get(toolCall.toolCallId) ?? now;
context.toolStartedAt.set(toolCall.toolCallId, startedAt);
const completedAt = completedAtForStatus(status, now);

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.

🟢 Low Adapters/AcpAdapterV2.ts:2626

Repeated terminal updates move completedAt forward because emitTool recomputes it from now on every call. Since ACP providers re-report completed or failed tool calls, this produces incorrect tool durations and ordering metadata; preserve the existing terminal timestamp when updating an already-terminal tool.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts around line 2626:

Repeated terminal updates move `completedAt` forward because `emitTool` recomputes it from `now` on every call. Since ACP providers re-report completed or failed tool calls, this produces incorrect tool durations and ordering metadata; preserve the existing terminal timestamp when updating an already-terminal tool.

Comment on lines +35 to +43
mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)),
sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)),
});
export type ChatImageAttachment = typeof ChatImageAttachment.Type;

export const UploadChatImageAttachment = Schema.Struct({
type: Schema.Literal("image"),
name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)),

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.

🟡 Medium src/chatAttachment.ts:35

UploadChatImageAttachment and ChatImageAttachment accept unsupported MIME types such as image/svg+xml, so direct RPC payloads are persisted and can reach provider adapters despite only GIF/JPEG/PNG/WebP being supported. The schemas only check the image/ prefix; add isProviderSendTurnSupportedImageMimeType to both validations so unsupported attachments are rejected at the contract boundary.

-  mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)),
+  mimeType: TrimmedNonEmptyString.check(
+    Schema.isMaxLength(100),
+    Schema.isPattern(/^image\//i),
+    Schema.filter(isProviderSendTurnSupportedImageMimeType),
+  ),
   sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)),
 });
 export type ChatImageAttachment = typeof ChatImageAttachment.Type;
 
 export const UploadChatImageAttachment = Schema.Struct({
   type: Schema.Literal("image"),
   name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
-  mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)),
+  mimeType: TrimmedNonEmptyString.check(
+    Schema.isMaxLength(100),
+    Schema.isPattern(/^image\//i),
+    Schema.filter(isProviderSendTurnSupportedImageMimeType),
+  ),
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/contracts/src/chatAttachment.ts around lines 35-43:

`UploadChatImageAttachment` and `ChatImageAttachment` accept unsupported MIME types such as `image/svg+xml`, so direct RPC payloads are persisted and can reach provider adapters despite only GIF/JPEG/PNG/WebP being supported. The schemas only check the `image/` prefix; add `isProviderSendTurnSupportedImageMimeType` to both validations so unsupported attachments are rejected at the contract boundary.

if (replayGate !== undefined) {
yield* Effect.addFinalizer(() => Effect.sync(() => replayGate.releaseAll()));
}
const driver = yield* CodexReplay.makeReplayDriver(

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.

🟡 Medium Adapters/CodexAdapterV2.testkit.ts:219

A Codex replay run succeeds with unconsumed transcript entries, so tests can pass without exercising the full recorded interaction. The harness creates the shared driver at makeReplayDriver but never registers a teardown finalizer to call driver.assertComplete() (or verify driver.state.cursor === transcript.entries.length); layerReplayWithDriver does not perform that check. Add the completeness assertion to the harness finalizer alongside replayGate.releaseAll().

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts around line 219:

A Codex replay run succeeds with unconsumed transcript entries, so tests can pass without exercising the full recorded interaction. The harness creates the shared `driver` at `makeReplayDriver` but never registers a teardown finalizer to call `driver.assertComplete()` (or verify `driver.state.cursor === transcript.entries.length`); `layerReplayWithDriver` does not perform that check. Add the completeness assertion to the harness finalizer alongside `replayGate.releaseAll()`.

runtimeRequestId: null,
checkpointScopeId: null,
startedAt: context.plan.startedAt,
completedAt: completed ? now : 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.

🟢 Low Adapters/AcpAdapterV2.ts:2853

A completed plan's completedAt is overwritten with now on every subsequent terminal plan update, so replayed notifications move the plan node/item completion time forward and corrupt timeline metadata. Persist the first completion timestamp in context.plan (or equivalent state) and reuse it for later completed updates.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts around line 2853:

A completed plan's `completedAt` is overwritten with `now` on every subsequent terminal `plan` update, so replayed notifications move the plan node/item completion time forward and corrupt timeline metadata. Persist the first completion timestamp in `context.plan` (or equivalent state) and reuse it for later completed updates.

const retainedRecent = collectRecentThreadTitleContext(messages, recentContextBudget);

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.

🟢 Low orchestration-v2/ThreadTitleRegenerationService.ts:130

The regenerated title prompt includes the pinned first user message twice, so part of the recent-context budget is wasted repeating that message instead of retaining later replies. retainedRecent is collected from the full messages array, allowing the suffix of the pinned message to be selected again; exclude firstUserMessage from this second collection.

-  const retainedRecent = collectRecentThreadTitleContext(messages, recentContextBudget);
+  const retainedRecent = collectRecentThreadTitleContext(
+    messages.filter((message) => message !== firstUserMessage),
+    recentContextBudget,
+  );
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts around line 130:

The regenerated title prompt includes the pinned first user message twice, so part of the recent-context budget is wasted repeating that message instead of retaining later replies. `retainedRecent` is collected from the full `messages` array, allowing the suffix of the pinned message to be selected again; exclude `firstUserMessage` from this second collection.

isMcpCredentialReserved(threadId, mcpCredentialId) ||
Array.from(current.values()).some(
(other) =>
other !== 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.

🟠 High orchestration-v2/ProviderSessionManager.ts:734

Releasing an old session leaves its previous MCP credential valid when another session is attached to the same thread with a different credential. The attachedThreadIds check makes heldElsewhere skip revocation based on thread ownership rather than credential identity; only a matching credential ID or active reservation should prevent revocation.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/ProviderSessionManager.ts around line 734:

Releasing an old session leaves its previous MCP credential valid when another session is attached to the same thread with a different credential. The `attachedThreadIds` check makes `heldElsewhere` skip revocation based on thread ownership rather than credential identity; only a matching credential ID or active reservation should prevent revocation.

providerPayload: { protocol: ACP_PROTOCOL, sessionId },
};
}),

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.

🟡 Medium Adapters/AcpAdapterV2.ts:5975

readThreadSnapshot returns the source session's cached messages for a newly forked thread instead of the fork's snapshot. forkThread sets activeSessionId to the forked ID without invalidating snapshot, so readThreadSnapshot skips loadSession when the IDs match. Invalidate the active session ID after forking so the next snapshot read reloads the fork.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts around line 5975:

`readThreadSnapshot` returns the source session's cached messages for a newly forked thread instead of the fork's snapshot. `forkThread` sets `activeSessionId` to the forked ID without invalidating `snapshot`, so `readThreadSnapshot` skips `loadSession` when the IDs match. Invalidate the active session ID after forking so the next snapshot read reloads the fork.

Comment on lines +84 to +90
return copySorted(
(modelSelection.options ?? []).map(
(selection): readonly [id: string, value: string | boolean] => [
selection.id,
selection.value,
],
),

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.

🟡 Medium src/model.ts:84

modelSelectionsEqual returns true for reversed duplicate selections such as [{id: "effort", value: "low"}, {id: "effort", value: "high"}] and its reverse, even though option lookup uses the first matching ID and resolves different effective values. Callers can therefore skip the required provider/session transition and retain the wrong configured option. Preserve the first value for each ID before sorting, or reject duplicate IDs.

-  return copySorted(
-    (modelSelection.options ?? []).map(
-      (selection): readonly [id: string, value: string | boolean] => [
-        selection.id,
-        selection.value,
-      ],
-    ),
+  const firstById = new Map<string, string | boolean>();
+  for (const selection of modelSelection.options ?? []) {
+    if (!firstById.has(selection.id)) {
+      firstById.set(selection.id, selection.value);
+    }
+  }
+  return copySorted(
+    [...firstById.entries()],
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/shared/src/model.ts around lines 84-90:

`modelSelectionsEqual` returns `true` for reversed duplicate selections such as `[{id: "effort", value: "low"}, {id: "effort", value: "high"}]` and its reverse, even though option lookup uses the first matching ID and resolves different effective values. Callers can therefore skip the required provider/session transition and retain the wrong configured option. Preserve the first value for each ID before sorting, or reject duplicate IDs.

return decode.pipe(Effect.catch(() => discard.pipe(Effect.as(Option.none<A>()))));

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.

🟡 Medium platform/orchestrationCache.ts:35

When discard fails, decodeOrDiscardOrchestrationCache propagates that error instead of returning Option.none(), so IndexedDB cleanup failures can block live synchronization after an invalid cache is found. Catch failures from the discard effect as well and return Option.none().

-  return decode.pipe(Effect.catch(() => discard.pipe(Effect.as(Option.none<A>()))));
+  return decode.pipe(
+    Effect.catch(() =>
+      discard.pipe(
+        Effect.as(Option.none<A>()),
+        Effect.catch(() => Effect.succeed(Option.none<A>())),
+      ),
+    ),
+  );
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/client-runtime/src/platform/orchestrationCache.ts around line 35:

When `discard` fails, `decodeOrDiscardOrchestrationCache` propagates that error instead of returning `Option.none()`, so IndexedDB cleanup failures can block live synchronization after an invalid cache is found. Catch failures from the discard effect as well and return `Option.none()`.

label: `query.open:fork${labelSuffix}`,
frame: makeClaudeQueryOpenFrame({ options: targetOptions }),
});
const targetRuntime = query({

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.

🟡 Medium Adapters/ClaudeAdapterV2.testkit.ts:1942

When recording a fork or continuation query throws after targetRuntime or continuationRuntime starts, those runtimes and their prompt queues remain open, so the Claude process can keep running and hang replay fixture generation. The outer catch only closes sourceRuntime; track each child runtime and close its queue and runtime in a per-query finally (or equivalent cleanup path).

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts around line 1942:

When recording a fork or continuation query throws after `targetRuntime` or `continuationRuntime` starts, those runtimes and their prompt queues remain open, so the Claude process can keep running and hang replay fixture generation. The outer `catch` only closes `sourceRuntime`; track each child runtime and close its queue and runtime in a per-query `finally` (or equivalent cleanup path).

),
);

const continuation = yield* recheckAndBind.pipe(Effect.andThen(queueContinuation));

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.

🟡 Medium mcp/WorktreeMcpService.ts:381

The queued continuation can start the next agent turn before runForThread has launched the setup script, so the agent may execute in an uninitialized worktree. queueContinuation is evaluated at line 381 immediately after the metadata update, while setup runs afterward; move continuation scheduling after runForThread or otherwise prevent promotion until setup is launched.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/mcp/WorktreeMcpService.ts around line 381:

The queued continuation can start the next agent turn before `runForThread` has launched the setup script, so the agent may execute in an uninitialized worktree. `queueContinuation` is evaluated at line 381 immediately after the metadata update, while setup runs afterward; move continuation scheduling after `runForThread` or otherwise prevent promotion until setup is launched.

([id, property], index): OrchestrationV2UserInputQuestion => {
const record = unknownRecord(property);
const enumValues = Array.isArray(record?.enum)
? record.enum.filter((value): value is string => typeof value === "string")

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.

🟡 Medium Adapters/AcpAdapterV2.ts:4474

Numeric and boolean enum choices are discarded, so a schema such as { type: "number", enum: [1, 2] } produces options: [] and presents a free-form question; the user can then submit a value outside the allowed enum. Preserve supported scalar enum values when constructing options.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts around line 4474:

Numeric and boolean enum choices are discarded, so a schema such as `{ type: "number", enum: [1, 2] }` produces `options: []` and presents a free-form question; the user can then submit a value outside the allowed enum. Preserve supported scalar enum values when constructing `options`.

Comment on lines +3255 to +3260
if (
context !== null &&
(yield* Ref.get(suppressPostSettleMonitorPrompt)) &&
(update.sessionUpdate === "agent_message_chunk" ||
update.sessionUpdate === "agent_thought_chunk")
) {

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.

🟡 Medium Adapters/AcpAdapterV2.ts:3255

Straggler monitor chatter can open a spurious synthetic continuation after activeTurn becomes null. The suppression check is gated by context !== null, so post-settle agent_message_chunk and agent_thought_chunk traffic set by applyLateBackgroundMutation bypasses it and reaches bufferPostSettleWake; apply the flag regardless of whether an active context exists.

           if (
-            context !== null &&
             (yield* Ref.get(suppressPostSettleMonitorPrompt)) &&
             (update.sessionUpdate === "agent_message_chunk" ||
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts around lines 3255-3260:

Straggler monitor chatter can open a spurious synthetic continuation after `activeTurn` becomes `null`. The suppression check is gated by `context !== null`, so post-settle `agent_message_chunk` and `agent_thought_chunk` traffic set by `applyLateBackgroundMutation` bypasses it and reaches `bufferPostSettleWake`; apply the flag regardless of whether an active context exists.

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

Two small Queue/Steer UX gaps look worth folding into V2 while this is still the canonical implementation. Neither requires changing the server-authoritative queue model.

export function resolveComposerDispatchMode(input: {
readonly phase: SessionPhase;
readonly queueModifier: boolean;
readonly activeTurnDefault?: ActiveTurnComposerAction;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This looks like the intended policy seam for the Queue/Steer preference. Could we wire activeTurnDefault to a persisted client setting, e.g. activeRunFollowUpMode: "steer" | "queue", with a decoding default of "steer" so existing behavior is unchanged? Then ChatView can pass that preference into this resolver.

That would complete the configurable-default part of #231 without changing the server queue semantics. I’d keep queue visibility driven by whether queued runs actually exist, not by this preference, so switching the default back to Steer never hides already queued work.

}}
>
<CornerUpRightIcon className="size-3" />
Steer

@ElliotDrel ElliotDrel Aug 20, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Keeping Steer as the user-facing label here. The existing wording is clearer for this product and should stay unchanged.

@dirtydishes dirtydishes mentioned this pull request Aug 20, 2026
@colonelpanic8

Copy link
Copy Markdown

Is this pr actually just a stress test of github's pull request page? love to see +181,657 -85,511. There's really no way any of this could have been done more incrementally?

@wrick17

wrick17 commented Aug 21, 2026

Copy link
Copy Markdown

Is this pr actually just a stress test of github's pull request page? love to see +181,657 -85,511. There's really no way any of this could have been done more incrementally?

Stacked PRs would be a good fit. Maybe we could have stress tested stacked PRs as well.

@TheBit

TheBit commented Aug 21, 2026

Copy link
Copy Markdown

Is this pr actually just a stress test of github's pull request page? love to see +181,657 -85,511. There's really no way any of this could have been done more incrementally?

Julius contributed :)
Знімок екрана 2026-08-21 о 13 38 11

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