feat(orchestrator): introduce new orchestrator - #2829
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
| return decodeTranscript({ | ||
| ...metadata, | ||
| entries, | ||
| }); | ||
| }); |
There was a problem hiding this comment.
🟢 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.
…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.
…n v2 provider adapters)
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.
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
|
🚀 Expo continuous deployment is ready!
|
| 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, | ||
| }), | ||
| ); |
There was a problem hiding this comment.
🟡 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).
79031a1 to
4e68dcb
Compare
4e68dcb to
c7539b9
Compare
| function nativeThreadId(provider: ProviderKind, thread: OrchestrationV2ProviderThread): string { | ||
| const id = thread.nativeThreadRef?.nativeId; | ||
| if (id === null || id === undefined || id.trim().length === 0) { | ||
| throw new ProviderAdapterProtocolError({ |
There was a problem hiding this comment.
🟡 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 becausemanagementError()returns aThreadManagementErrorinstance, not anEffect. Theyield*operator inEffect.genexpects an Effect value. This should bereturn yield* Effect.fail(managementError(...)). The correct pattern is demonstrated elsewhere in this file (lines 241-246) whereEffect.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.
…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(() => |
There was a problem hiding this comment.
🟡 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")) { |
There was a problem hiding this comment.
🟢 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({ |
There was a problem hiding this comment.
🟢 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) { |
There was a problem hiding this comment.
🟡 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({ |
There was a problem hiding this comment.
🟡 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 }); |
There was a problem hiding this comment.
🟡 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( |
There was a problem hiding this comment.
🟠 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)) { |
There was a problem hiding this comment.
🟡 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); |
There was a problem hiding this comment.
🟠 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`.
| for (const stored of events) { | ||
| latestByThreadId.set(stored.event.threadId, stored); | ||
| } |
There was a problem hiding this comment.
🟡 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.
| 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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Bugbot Autofix is ON, but a cloud agent failed to start.
Reviewed by Cursor Bugbot for commit 4c55679. Configure here.
| selectedThreadLastVisitedAt, | ||
| selectedThreadUpdatedAt, | ||
| visitThread, | ||
| ]); |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 4c55679. Configure here.
…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( |
There was a problem hiding this comment.
🟡 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 }); |
There was a problem hiding this comment.
🟡 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); |
There was a problem hiding this comment.
🟠 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.
There was a problem hiding this comment.
🟠 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.
| .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.
| if ( | ||
| forkPromptGroups.length === 0 || | ||
| forkPromptGroups.some((group) => group.length === 0) || | ||
| forkPromptGroups.flat().join("\n") !== forkPrompts.join("\n") | ||
| ) { |
There was a problem hiding this comment.
🟡 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))), |
There was a problem hiding this comment.
🟠 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) => |
There was a problem hiding this comment.
🟡 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 |
There was a problem hiding this comment.
🟠 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", { |
There was a problem hiding this comment.
🟠 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.
| commandType: command.type, | ||
| cause: "Command produced no domain events.", | ||
| }), | ||
| ), | ||
| ), | ||
| Effect.catch((cause) |
There was a problem hiding this comment.
🟡 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
| 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; |
There was a problem hiding this comment.
🟢 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; |
There was a problem hiding this comment.
🟡 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); |
There was a problem hiding this comment.
🟢 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.
| 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)), |
There was a problem hiding this comment.
🟡 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( |
There was a problem hiding this comment.
🟡 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, |
There was a problem hiding this comment.
🟢 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); |
There was a problem hiding this comment.
🟢 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 && |
There was a problem hiding this comment.
🟠 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 }, | ||
| }; | ||
| }), | ||
|
|
There was a problem hiding this comment.
🟡 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.
| return copySorted( | ||
| (modelSelection.options ?? []).map( | ||
| (selection): readonly [id: string, value: string | boolean] => [ | ||
| selection.id, | ||
| selection.value, | ||
| ], | ||
| ), |
There was a problem hiding this comment.
🟡 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>())))); |
There was a problem hiding this comment.
🟡 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({ |
There was a problem hiding this comment.
🟡 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)); |
There was a problem hiding this comment.
🟡 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") |
There was a problem hiding this comment.
🟡 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`.
| if ( | ||
| context !== null && | ||
| (yield* Ref.get(suppressPostSettleMonitorPrompt)) && | ||
| (update.sessionUpdate === "agent_message_chunk" || | ||
| update.sessionUpdate === "agent_thought_chunk") | ||
| ) { |
There was a problem hiding this comment.
🟡 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
left a comment
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Keeping Steer as the user-facing label here. The existing wording is clearer for this product and should stay unchanged.
|
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. |
Julius contributed :) |



Summary
Validation
Notes
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
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.orchestrationAdapteronProviderInstanceinstead of the previousadapterfield.delegate_task,task_status,schedule_task, thread CRUD) and worktree (t3_worktree_handoff,t3_worktree_status) viaOrchestratorMcpServiceandWorktreeMcpService.ScheduledTaskService, HTTP/WebSocket atoms, and a full settings UI at/settings/scheduled-tasks.ProviderInstance.adapteris renamed toorchestrationAdapter;OrchestrationWsMethodsreplaced by V2 variants;TurnId/activeTurnIdreplaced byRunId/activeRunIdacross 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 Reportworkflow and the trusted.github/scripts/thread-transfer-reportpublisher/tests that upserted baseline-vs-PR wire-byte tables on pull requests. CI still runs transfer budget tests and uploadsthread-transfer-resultsto the job summary/artifacts; only automated PR commenting is removed. The test job now installsbuild-essentialso ACP process-tree fixtures compile instead of soft-skipping whenccis 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_VERSIONand V2 snapshot shapes (projection.thread.id,DateTimefields). Connection runtime wires bounded thread snapshot loading and history controls. Archive/home/list flows use V2 thread shells andthread.runtimeinstead of V1 session/turn fields; archive eligibility follows provider-active vs queued/waiting rules inthreadCanArchive. 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,orElseSucceedtyping, hardware keyboard handler iteration withouttoReversed.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.