Skip to content

feat(orchestrator): introduce new orchestrator#2829

Draft
juliusmarminge wants to merge 120 commits into
mainfrom
t3code/codex-turn-mapping
Draft

feat(orchestrator): introduce new orchestrator#2829
juliusmarminge wants to merge 120 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.

Note

Wire orchestration v2 provider adapters, contracts, replay testkit, and WebSocket RPCs

  • Adds a comprehensive orchestration v2 runtime: domain event store, projection store, event sink, command receipt store, ID allocator, checkpoint and context handoff services, provider session manager, run execution service, and runtime policy — all wired into runtimeLayer.ts and exposed via the server layer.
  • Introduces Codex and Claude provider adapter drivers (CodexAdapterV2.ts, ClaudeAdapterV2.ts) with capability matrices, protocol loggers, and provider adapter registry.
  • Adds four WebSocket RPCs (dispatchCommand, getThreadProjection, subscribeShell, subscribeThread) exposed through the server WS layer and fronted by the web RPC client and EnvironmentApi.
  • Adds a new database migration (031_OrchestrationV2.ts) creating orchestration v2 event, command receipt, and projection tables (threads, runs, run attempts, nodes, provider sessions).
  • Introduces a deterministic replay testkit with provider-specific harnesses, NDJSON transcript fixtures, and assertion helpers covering ~20 scenarios (simple, multi-turn, tool calls, interrupts, thread forks, rollbacks, web search, subagents, plan questions, etc.).
  • Adds strongly-typed contracts in orchestrationV2.ts covering commands, domain events, projections, stream items, and RPC schemas, plus new branded entity IDs in baseSchemas.ts.
  • Risk: migration 31 alters the production database schema; the new orchestration v2 layer is provided at server startup and is live alongside the existing v1 orchestration path.
📊 Macroscope summarized 1980b38. 190 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

>

juliusmarminge and others added 30 commits April 17, 2026 17:29
Co-authored-by: codex <codex@users.noreply.github.com>
- Initialize provider as unchecked in a pending state
- Update initial probe message to reflect session-local status
- Type the runtime effect with `Scope`
- Build the ACP session runtime without wrapping it in `Effect.scoped`
- Use strict TurnId and ProviderItemId parsing in Codex session routing
- Decode in-memory stdio chunks in streaming mode to avoid split UTF-8 corruption
- Transfer session-owned scopes into adapter state
- Ensure runtime scopes close on stop and startup failure
- Add regression coverage for scoped lifecycle cleanup
- Close the managed native event logger when the adapter layer tears down
- Make session runtime close idempotent with an atomic closed flag
- Add coverage for flushing thread native logs on shutdown
- Use codex app-server snapshots for auth, models, and skills
- Remove legacy CLI/config discovery paths and related helpers
- Update tests for the new provider status flow
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
- Document the target orchestration graph, IDs, lifecycles, and capability model
- Add Codex app-server probe fixtures and update the probe test harness
- Introduce orchestration v2 service interfaces and error types
- Add replay runtime, fixtures, and integration coverage
- Update shared contracts and probe transcripts

Co-authored-by: codex <codex@users.noreply.github.com>
- Add Codex adapter and replay harness wiring
- Introduce in-memory orchestration projections and provider registry
- Expand orchestration contracts for turn and runtime events
Co-authored-by: codex <codex@users.noreply.github.com>
- Add context transfer IDs, schemas, and projections
- Support cheap fork creation and Codex native fork rollback
- Cover fork idempotency and replay behavior in tests
- Track remaining projection, context transfer, rollback, capability, and subagent work
- Clarify current V2 baseline and debugger-only follow-ups
- Map fork and merge-back turns into stored handoffs and transfer resolutions
- Add shell snapshot projection support plus coverage tests
- Update replay fixtures and web contracts for the new turn flow
Co-authored-by: codex <codex@users.noreply.github.com>
- Move Codex replay recording into `apps/server`
- Add Claude Agent SDK replay fixtures and test harness
- Update orchestration-v2 fixture scenarios and docs
- Move Claude provider runtime logic into its own module
- Share the SDK query runner between live and replay paths
- Add replay driver error wrapping for unexpected failures
Port orchestration V2 provider adapter wiring to the provider-instance driver registry.

Co-authored-by: codex <codex@users.noreply.github.com>
- persist the selected model on run records
- surface run model selection in the debug UI
- update replay fixtures and contracts for the new field
- Record Claude SDK transcripts across multiple prompts and restart/query modes
- Add approval and tool-call replay coverage for new orchestration fixtures
- Update Claude adapter testkit to model open/prompt/permission frames
- Derive Claude SDK query options from runtime policy
- Add read-only replay fixture and policy mapping tests
- Reuse shared approval-policy fixtures across orchestrator tests

Co-authored-by: codex <codex@users.noreply.github.com>
- add active steering and interrupt-restart replay fixtures
- update Claude adapter/orchestrator turn handling for steering
- refresh replay and integration test coverage
- add interrupt and mid-tool replay fixtures for Claude and Codex
- log Claude Agent SDK protocol frames to native event traces
- project Codex commandExecution start events into orchestration updates
- Map Cursor SDK agents and runs to V2 thread and turn lifecycles
- Update MCP capability, tool, and testing guidance for SDK-based injection
juliusmarminge and others added 10 commits July 20, 2026 16:41
Fixes review: #2829 (comment)

Co-authored-by: codex <codex@users.noreply.github.com>
Fixes review: #2829 (comment)

Co-authored-by: codex <codex@users.noreply.github.com>
Fixes review: #2829 (comment)

Co-authored-by: codex <codex@users.noreply.github.com>
Fixes review: #2829 (comment)

Co-authored-by: codex <codex@users.noreply.github.com>
Fixes review: #2829 (comment)

Co-authored-by: codex <codex@users.noreply.github.com>
Fixes review: #2829 (comment)

Co-authored-by: codex <codex@users.noreply.github.com>
Clears the timed-out turn with an identity guard so a late completion cannot clobber a newer active turn.

Addresses #2829 (comment)

Co-authored-by: codex <codex@users.noreply.github.com>
Passes the current target thread MCP configuration through ACP load, resume, and fork requests instead of reusing the credentials captured when the provider process opened.

Addresses #2829 (comment)

Co-authored-by: codex <codex@users.noreply.github.com>
Integrate the current Grok settlement, monitor/subagent lifecycle, interrupt containment, steer visibility, and image support stack into the orchestration-v2 branch.

Co-authored-by: codex <codex@users.noreply.github.com>
mwolson and others added 5 commits July 20, 2026 11:47
Settle root turns from provider signals and preserve post-settle continuations.

Track asynchronous subagent and monitor work, allow image prompts, and keep steered messages visible.

Separate soft steering from hard Stop while containing and reaping native process trees.
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>

# Conflicts:
#	apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
#	apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
#	apps/server/src/orchestration-v2/ProviderContinuationRequests.ts
#	apps/server/src/orchestration-v2/ProviderContinuationService.test.ts
#	apps/server/src/orchestration-v2/ProviderContinuationService.ts
Co-authored-by: codex <codex@users.noreply.github.com>
Reuses the focused compatibility approach from closed PR #3923 and covers the remaining hardware-keyboard reverse iteration.

Co-authored-by: codex <codex@users.noreply.github.com>
status: thread.status === "idle" ? "completed" : thread.status,
requestedAt: null,
startedAt: null,
completedAt:

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/models.ts:148

presentThreadShell sets latestRun.completedAt to the thread-level updatedAt, so any later thread event (rename, mode change, archive, pending-runtime-request update) that advances thread.updatedAt overwrites the run's completion timestamp. Clients that rely on completedAt for completion ordering or unseen-completion detection see stale runs as newly completed. completedAt should reflect the run's actual completion time or null if unavailable, not the thread modification time.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/client-runtime/src/state/models.ts around line 148:

`presentThreadShell` sets `latestRun.completedAt` to the thread-level `updatedAt`, so any later thread event (rename, mode change, archive, pending-runtime-request update) that advances `thread.updatedAt` overwrites the run's completion timestamp. Clients that rely on `completedAt` for completion ordering or unseen-completion detection see stale runs as newly completed. `completedAt` should reflect the run's actual completion time or `null` if unavailable, not the thread modification time.

Comment on lines +453 to +454
.delete({ commandId: command.commandId, projectId: command.projectId })
.pipe(Effect.asVoid),

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 cli/project.ts:453

The offline dispatcher drops command.force when handling a project.delete mutation — the force field from the command is never passed to projects.delete(...). This means t3 project remove --force in offline mode silently behaves like a normal remove instead of deleting the project's threads, while still printing the success message Removed project .... Consider forwarding force to the projects.delete call.

Suggested change
.delete({ commandId: command.commandId, projectId: command.projectId })
.pipe(Effect.asVoid),
: projects
.delete({ commandId: command.commandId, projectId: command.projectId, force: command.force })
.pipe(Effect.asVoid),
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/cli/project.ts around lines 453-454:

The offline dispatcher drops `command.force` when handling a `project.delete` mutation — the `force` field from the command is never passed to `projects.delete(...)`. This means `t3 project remove --force` in offline mode silently behaves like a normal remove instead of deleting the project's threads, while still printing the success message `Removed project ...`. Consider forwarding `force` to the `projects.delete` call.

runId: RunId,
beforeRunId: Schema.NullOr(RunId),
}),
Schema.Struct({

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 src/orchestrationV2.ts:1797

The runtime-request.respond variant makes both decision and answers independently optional, so a command like { type: "runtime-request.respond", commandId, threadId, requestId } with no decision and no answers passes schema validation and resolves the pending runtime request. The request is marked resolved without supplying any answer, and because its status is no longer pending it cannot be retried. The schema also accepts both fields simultaneously. Consider modeling request-kind-specific variants (e.g. runtime-request.approval.respond with a required decision, and runtime-request.user-input.respond with a required answers) or enforcing exactly one of the two fields via a refinement.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/contracts/src/orchestrationV2.ts around line 1797:

The `runtime-request.respond` variant makes both `decision` and `answers` independently optional, so a command like `{ type: "runtime-request.respond", commandId, threadId, requestId }` with no `decision` and no `answers` passes schema validation and resolves the pending runtime request. The request is marked resolved without supplying any answer, and because its status is no longer `pending` it cannot be retried. The schema also accepts both fields simultaneously. Consider modeling request-kind-specific variants (e.g. `runtime-request.approval.respond` with a required `decision`, and `runtime-request.user-input.respond` with a required `answers`) or enforcing exactly one of the two fields via a refinement.

Comment on lines +961 to +962
const apply: ProjectionStoreV2Shape["apply"] = (event) =>
Effect.gen(function* () {

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/ProjectionStore.ts:961

The apply function executes multiple SQL writes per event without wrapping them in sql.withTransaction, so a failure or interruption after the first write leaves partial updates committed. For example, provider-session.attached upserts the session row, inserts a binding row, and then updates the thread payload — if any statement after the first fails, the earlier writes persist and apply returns ProjectionStoreApplyEventError, leaving the projection in a partially applied state that subsequent reads observe. Wrap the event-handling body in sql.withTransaction so all writes for a single event commit atomically.

-    const apply: ProjectionStoreV2Shape["apply"] = (event) =>
-      Effect.gen(function* () {
+    const apply: ProjectionStoreV2Shape["apply"] = (event) =>
+      sql.withTransaction(Effect.gen(function* () {
Also found in 1 other location(s)

apps/server/src/mcp/OrchestratorMcpService.ts:1146

createThreads executes the batch sequentially with no rollback, but fails the entire effect on any later thread.create, message.dispatch, or timeline-recording error. Threads created by earlier iterations remain persisted while the caller receives no result; when no clientRequestId was supplied, retrying generates a new key and creates duplicate threads. Even a message.dispatch failure leaves the current empty thread behind.

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

The `apply` function executes multiple SQL writes per event without wrapping them in `sql.withTransaction`, so a failure or interruption after the first write leaves partial updates committed. For example, `provider-session.attached` upserts the session row, inserts a binding row, and then updates the thread payload — if any statement after the first fails, the earlier writes persist and `apply` returns `ProjectionStoreApplyEventError`, leaving the projection in a partially applied state that subsequent reads observe. Wrap the event-handling body in `sql.withTransaction` so all writes for a single event commit atomically.

Also found in 1 other location(s):
- apps/server/src/mcp/OrchestratorMcpService.ts:1146 -- `createThreads` executes the batch sequentially with no rollback, but fails the entire effect on any later `thread.create`, `message.dispatch`, or timeline-recording error. Threads created by earlier iterations remain persisted while the caller receives no result; when no `clientRequestId` was supplied, retrying generates a new key and creates duplicate threads. Even a `message.dispatch` failure leaves the current empty thread behind.

};
});

yield* checkpoints.restore({ scope, checkpoint });

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/CheckpointRollbackService.ts:150

The execute function calls sessions.open unconditionally before checking whether any runs need rolling back. When runsToRollback is empty (a valid rollback to the latest checkpoint), session is only used in the else-branch — session.rollbackThread is never called — yet any provider session startup or adapter failure still aborts the entire operation, preventing the filesystem restore (checkpoints.restore) from completing even though the provider session isn't needed. The sessions.open call should be moved inside the runsToRollback.length > 0 branch so filesystem-only rollbacks succeed when the provider is unavailable.

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

The `execute` function calls `sessions.open` unconditionally before checking whether any runs need rolling back. When `runsToRollback` is empty (a valid rollback to the latest checkpoint), `session` is only used in the else-branch — `session.rollbackThread` is never called — yet any provider session startup or adapter failure still aborts the entire operation, preventing the filesystem restore (`checkpoints.restore`) from completing even though the provider session isn't needed. The `sessions.open` call should be moved inside the `runsToRollback.length > 0` branch so filesystem-only rollbacks succeed when the provider is unavailable.

`No V2 provider adapter is registered for driver ${requestedDriver}.`,
);
}
const inheritedCandidate = candidates.find(

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/OrchestratorMcpService.ts:661

resolveTarget returns provider_unavailable for a driver-only target even when a healthy, unconstrained instance of that driver is registered. The inherited instance is selected first purely because it matches the requested driver and parent's instanceId, without verifying its constraints — so a disabled, unauthenticated, or otherwise unavailable parent instance wins over an available one. The fallback to availableCandidate is never reached in that case. Consider selecting an inherited instance only when it passes the same providerConstraints check used for the available candidate.

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

`resolveTarget` returns `provider_unavailable` for a driver-only target even when a healthy, unconstrained instance of that driver is registered. The inherited instance is selected first purely because it matches the requested driver and parent's `instanceId`, without verifying its constraints — so a disabled, unauthenticated, or otherwise unavailable parent instance wins over an available one. The fallback to `availableCandidate` is never reached in that case. Consider selecting an inherited instance only when it passes the same `providerConstraints` check used for the available candidate.

const now = yield* DateTime.now;
const emitEvent = emit(events, command);
const interruptRequestItem: OrchestrationV2TurnItem = {
id: idAllocator.derive.runSignalTurnItem({

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:4377

The interrupt request turn item ID is derived only from run.id and the constant signal "interrupt-request", so a second interrupt request for the same run produces the same ID as the first. This overwrites or moves the existing timeline entry instead of recording a distinct request, silently losing the first interrupt's history. Consider including a per-request identifier (e.g. an incrementing ordinal, timestamp, or request id) in the derive.runSignalTurnItem input so each interrupt gets a unique item.

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

The interrupt request turn item ID is derived only from `run.id` and the constant signal `"interrupt-request"`, so a second interrupt request for the same run produces the same ID as the first. This overwrites or moves the existing timeline entry instead of recording a distinct request, silently losing the first interrupt's history. Consider including a per-request identifier (e.g. an incrementing ordinal, timestamp, or request id) in the `derive.runSignalTurnItem` input so each interrupt gets a unique item.

}),
),
),
resumeThread: (threadInput) =>

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:2386

resumeThread fetches and registers the native session but never reapplies openCodePermissionRules(threadInput.runtimePolicy). A thread created under full access and resumed with a restrictive policy keeps the original allow rules, so edits, shell, and network access bypass the new approval/sandbox policy. Consider applying openCodePermissionRules(threadInput.runtimePolicy) after fetching the native session and before registering the thread.

🚀 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 2386:

`resumeThread` fetches and registers the native session but never reapplies `openCodePermissionRules(threadInput.runtimePolicy)`. A thread created under full access and resumed with a restrictive policy keeps the original allow rules, so edits, shell, and network access bypass the new approval/sandbox policy. Consider applying `openCodePermissionRules(threadInput.runtimePolicy)` after fetching the native session and before registering the thread.

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 settings/ScheduledTasksSettings.tsx:331

When workspaceMode is existing_worktree and the user leaves the checkout path blank, submit still sends worktreePath: "" to the server. The contract requires a non-empty path, so the save fails at the RPC layer with a schema validation error instead of being caught by the form's own validation. Consider adding draft.existingWorktreePath.trim() to the guard on line 331 so the missing required field is reported before the request is sent.

-    if (!draft.title.trim() || !draft.prompt.trim() || !draft.projectId || selection === null) {
+    if (
+      !draft.title.trim() ||
+      !draft.prompt.trim() ||
+      !draft.projectId ||
+      selection === null ||
+      (draft.workspaceMode === "existing_worktree" && !draft.existingWorktreePath.trim())
+    ) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/settings/ScheduledTasksSettings.tsx around line 331:

When `workspaceMode` is `existing_worktree` and the user leaves the checkout path blank, `submit` still sends `worktreePath: ""` to the server. The contract requires a non-empty path, so the save fails at the RPC layer with a schema validation error instead of being caught by the form's own validation. Consider adding `draft.existingWorktreePath.trim()` to the guard on line 331 so the missing required field is reported before the request is sent.

>;
readonly items: ReadonlyArray<OrchestrationV2TurnItem>;
}): string {
const itemLines = input.items.flatMap((item) => {

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/ContextHandoffService.ts:136

makeProviderHandoffSummary includes every item in input.items regardless of coveredRunOrdinals, so when the caller passes a projection that already contains the current run's user_message but declares coverage only through run.ordinal - 1, that user message appears in the handoff summary. providerMessageWithContextHandoffs then appends the same user text again in the User message: section, so after a provider-resume failure the replacement provider receives the active instruction duplicated and may act on it twice. Consider filtering items to the declared covered run range inside makeProviderHandoffSummary, or excluding the target run from the items at the call site.

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

`makeProviderHandoffSummary` includes every item in `input.items` regardless of `coveredRunOrdinals`, so when the caller passes a projection that already contains the current run's `user_message` but declares coverage only through `run.ordinal - 1`, that user message appears in the handoff summary. `providerMessageWithContextHandoffs` then appends the same user text again in the `User message:` section, so after a provider-resume failure the replacement provider receives the active instruction duplicated and may act on it twice. Consider filtering `items` to the declared covered run range inside `makeProviderHandoffSummary`, or excluding the target run from the items at the call site.

Co-authored-by: codex <codex@users.noreply.github.com>
const base = { ...projection, updatedAt: event.occurredAt };

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/orchestrationV2Projection.ts:98

The base object updates only the projection-level updatedAt but leaves thread.updatedAt unchanged. After any non-thread event such as run.updated or turn-item.updated, the projection's thread.updatedAt stays stale until a snapshot arrives, diverging from the authoritative server projection which advances thread.updatedAt on every domain event. This gives consumers an incorrect thread modification timestamp. Consider updating thread.updatedAt alongside updatedAt when constructing base, e.g. { ...projection, updatedAt: event.occurredAt, thread: { ...projection.thread, updatedAt: event.occurredAt } }.

-  const base = { ...projection, updatedAt: event.occurredAt };
+  const base = {
+    ...projection,
+    updatedAt: event.occurredAt,
+    thread: { ...projection.thread, updatedAt: event.occurredAt },
+  };
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/client-runtime/src/state/orchestrationV2Projection.ts around line 98:

The `base` object updates only the projection-level `updatedAt` but leaves `thread.updatedAt` unchanged. After any non-thread event such as `run.updated` or `turn-item.updated`, the projection's `thread.updatedAt` stays stale until a snapshot arrives, diverging from the authoritative server projection which advances `thread.updatedAt` on every domain event. This gives consumers an incorrect thread modification timestamp. Consider updating `thread.updatedAt` alongside `updatedAt` when constructing `base`, e.g. `{ ...projection, updatedAt: event.occurredAt, thread: { ...projection.thread, updatedAt: event.occurredAt } }`.

Comment on lines +449 to +451
for (let attempt = 0; attempt < 200; attempt += 1) {
if (!lease.exists()) return;
try {

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 acp/AcpSessionRuntime.ts:449

terminateLinuxCgroupLease calls lease.exists() outside any try/catch, so when the cgroup path throws an I/O or permission error, the effect dies with an untyped defect instead of returning AcpProcessGroupTerminationError. Production finalizers see an unexpected defect and abandon cgroup cleanup, potentially leaving provider descendants running. Wrap these existence checks in try/catch and map failures through failure(...), as already done for kill, populated, and remove.

    for (let attempt = 0; attempt < 200; attempt += 1) {
+      try {
+        if (!lease.exists()) return;
+      } catch (cause) {
+        return yield* failure("check existence of", cause);
+      }
-      if (!lease.exists()) return;
      try {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/provider/acp/AcpSessionRuntime.ts around lines 449-451:

`terminateLinuxCgroupLease` calls `lease.exists()` outside any `try`/`catch`, so when the cgroup path throws an I/O or permission error, the effect dies with an untyped defect instead of returning `AcpProcessGroupTerminationError`. Production finalizers see an unexpected defect and abandon cgroup cleanup, potentially leaving provider descendants running. Wrap these existence checks in `try`/`catch` and map failures through `failure(...)`, as already done for `kill`, `populated`, and `remove`.

Comment on lines +397 to +405
const current = yield* getProjectThread(input);
const run = current.runs.find((candidate) => candidate.id === selectedRun.id);
if (run === undefined) {
return yield* managementError(
"run_not_found",
`Run ${selectedRun.id} no longer belongs to thread ${input.threadId}.`,
);
}
return { threadId: input.threadId, run, timedOut: 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 orchestration-v2/ThreadManagementService.ts:397

In waitForThread, when Effect.timeoutOption returns None, the code reloads the projection and unconditionally returns timedOut: true — even if the run has since reached a terminal status. A completion that races the timeout therefore produces a result containing a terminal run alongside timedOut: true, which is contradictory. Recheck isTerminalRunStatus(run.status) before deciding the timedOut flag.

-      return { threadId: input.threadId, run, timedOut: true };
+      return {
+        threadId: input.threadId,
+        run,
+        timedOut: !isTerminalRunStatus(run.status),
+      };
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/ThreadManagementService.ts around lines 397-405:

In `waitForThread`, when `Effect.timeoutOption` returns `None`, the code reloads the projection and unconditionally returns `timedOut: true` — even if the run has since reached a terminal status. A completion that races the timeout therefore produces a result containing a terminal `run` alongside `timedOut: true`, which is contradictory. Recheck `isTerminalRunStatus(run.status)` before deciding the `timedOut` flag.

messageIndex += 1;
{
const nextStep = input.fixtureInput.steps[stepIndex + 1];
const shouldRunInBackground =

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 fixtures/shared.ts:433

When a message step is immediately followed by a steer step, shouldRunInBackground is false, so the message dispatch is awaited as foreground. The scenario runner blocks until the run finishes, which means await_run_steerable and the steer dispatch never execute until after the run is already complete — the message_steering fixture times out because it can never steer an active run. The condition at line 433 includes restart in the same check but omits steer. Consider adding steer to the shouldRunInBackground condition alongside restart.

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

When a `message` step is immediately followed by a `steer` step, `shouldRunInBackground` is `false`, so the message dispatch is awaited as foreground. The scenario runner blocks until the run finishes, which means `await_run_steerable` and the steer dispatch never execute until after the run is already complete — the `message_steering` fixture times out because it can never steer an active run. The condition at line 433 includes `restart` in the same check but omits `steer`. Consider adding `steer` to the `shouldRunInBackground` condition alongside `restart`.

(session) => session.id !== event.payload.providerSessionId,
),
};
case "provider-thread.updated":

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/orchestrationV2Projection.ts:137

The provider-thread.updated branch only upserts providerThreads and never updates thread.activeProviderThreadId, so after a provider thread is attached or switched through the event stream the client projection keeps the stale active provider-thread pointer. Features that read thread.activeProviderThreadId will operate on the wrong provider thread until a full snapshot is fetched. Consider mirroring the authoritative server projection logic that updates thread.activeProviderThreadId when event.payload.appThreadId matches projection.thread.id.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/client-runtime/src/state/orchestrationV2Projection.ts around line 137:

The `provider-thread.updated` branch only upserts `providerThreads` and never updates `thread.activeProviderThreadId`, so after a provider thread is attached or switched through the event stream the client projection keeps the stale active provider-thread pointer. Features that read `thread.activeProviderThreadId` will operate on the wrong provider thread until a full snapshot is fetched. Consider mirroring the authoritative server projection logic that updates `thread.activeProviderThreadId` when `event.payload.appThreadId` matches `projection.thread.id`.

{row.message.inputIntent && row.message.inputIntent !== "turn_start" ? (
<UserMessageIntentBadge intent={row.message.inputIntent} />
) : null}
{row.projectedItem &&

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 chat/MessagesTimeline.tsx:1009

The failure badge at line 1001 renders in destructive red for every status except completed, pending, and waiting. Because OrchestrationV2TurnItemStatus includes the normal running state, a user message whose projected item is still running is shown in red alongside genuine failures like failed, cancelled, and interrupted. The condition should exclude non-failure statuses such as running (or render running with a non-destructive style).

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/chat/MessagesTimeline.tsx around line 1009:

The failure badge at line 1001 renders in destructive red for *every* status except `completed`, `pending`, and `waiting`. Because `OrchestrationV2TurnItemStatus` includes the normal `running` state, a user message whose projected item is still running is shown in red alongside genuine failures like `failed`, `cancelled`, and `interrupted`. The condition should exclude non-failure statuses such as `running` (or render `running` with a non-destructive style).

return error;
};

const fail = (error: CursorAgentSdkReplayError): never => {

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.testkit.ts:237

fail records a replay failure but never resolves the cursorAdvanced signal, so a run's wait effect suspended at Effect.promise(() => signal.promise) hangs indefinitely instead of observing the recorded failure. When an outbound frame like run.cancel mismatches and triggers fail, the suspended wait never wakes up because the signal is never resolved. Consider resolving cursorAdvanced inside fail so any suspended wait observes the failure and exits.

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

`fail` records a replay failure but never resolves the `cursorAdvanced` signal, so a run's `wait` effect suspended at `Effect.promise(() => signal.promise)` hangs indefinitely instead of observing the recorded failure. When an outbound frame like `run.cancel` mismatches and triggers `fail`, the suspended `wait` never wakes up because the signal is never resolved. Consider resolving `cursorAdvanced` inside `fail` so any suspended `wait` observes the failure and exits.

updatedAt: now,
archivedAt: null,
deletedAt: null,
...input,

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/test-fixtures.ts:50

The ...input spread at the end of makeRawThreadShell overwrites computed defaults with undefined for any field that input explicitly sets to undefined. makeThreadShellFixture forwards fields like overrides.id and overrides.projectId unconditionally, so a default call (no overrides) produces a shell whose id, projectId, title, providerInstanceId, and other forwarded fields are undefined instead of the defaults computed above. Consider filtering out undefined values before spreading, or having callers omit absent overrides.

Suggested change
...input,
...Object.fromEntries(
Object.entries(input).filter(([, v]) => v !== undefined),
),
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/test-fixtures.ts around line 50:

The `...input` spread at the end of `makeRawThreadShell` overwrites computed defaults with `undefined` for any field that `input` explicitly sets to `undefined`. `makeThreadShellFixture` forwards fields like `overrides.id` and `overrides.projectId` unconditionally, so a default call (no overrides) produces a shell whose `id`, `projectId`, `title`, `providerInstanceId`, and other forwarded fields are `undefined` instead of the defaults computed above. Consider filtering out `undefined` values before spreading, or having callers omit absent overrides.

return count;
}

function visibleItemCountForShell(input: {

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/ProjectionStore.ts:863

When a fork's source thread is deleted, visibleItemCountForShell under-reports the fork's visibleItemCount because getShellSnapshot excludes deleted threads from statesByThreadId (WHERE t.deleted_at IS NULL), so the fallback at line 884 returns only the fork's local itemCount. Meanwhile getThreadProjection still loads the deleted source and builds the full inherited timeline. This means the shell count silently drops the inherited prefix items and the fork marker, while the canonical projection for the same fork still shows them. Consider including deleted source threads in the shell query (or fetching the missing source state on demand) so the fork's shell count matches its projection.

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

When a fork's source thread is deleted, `visibleItemCountForShell` under-reports the fork's `visibleItemCount` because `getShellSnapshot` excludes deleted threads from `statesByThreadId` (`WHERE t.deleted_at IS NULL`), so the fallback at line 884 returns only the fork's local `itemCount`. Meanwhile `getThreadProjection` still loads the deleted source and builds the full inherited timeline. This means the shell count silently drops the inherited prefix items and the fork marker, while the canonical projection for the same fork still shows them. Consider including deleted source threads in the shell query (or fetching the missing source state on demand) so the fork's shell count matches its projection.

Effect.andThen(onCommitted),
);

const assertWorkspaceAvailable = Effect.fn("ProjectService.assertWorkspaceAvailable")(function* (

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 project/ProjectService.ts:234

assertWorkspaceAvailable checks workspace uniqueness by reading the projection before dispatching the command. Two concurrent create calls for different project IDs but the same normalized workspace root can both pass the check (neither sees the other's uncommitted project), then both dispatch project.create. The projection-based uniqueness guarantee in this service is bypassed, resulting in duplicate active projects persisted for the same workspace root. Consider enforcing uniqueness at the projection write level (e.g., a unique constraint on workspaceRoot for non-deleted projects) rather than relying on a read-then-write check, or document that this service relies on single-writer command dispatch.

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

`assertWorkspaceAvailable` checks workspace uniqueness by reading the projection before dispatching the command. Two concurrent `create` calls for different project IDs but the same normalized workspace root can both pass the check (neither sees the other's uncommitted project), then both dispatch `project.create`. The projection-based uniqueness guarantee in this service is bypassed, resulting in duplicate active projects persisted for the same workspace root. Consider enforcing uniqueness at the projection write level (e.g., a unique constraint on `workspaceRoot` for non-deleted projects) rather than relying on a read-then-write check, or document that this service relies on single-writer command dispatch.

- Pretty-print known T3 MCP tool names in work rows
- Show the T3 Code logo for recognized MCP tool entries
- Add logic and UI tests for the new tool presentation
function V2EventTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "event" }> }) {
const ctx = use(TimelineRowCtx);
const { item, visibility, sourceThreadId } = row.projectedItem;
if (isV2LifecycleItem(item)) {

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 chat/MessagesTimeline.tsx:1364

V2EventTimelineRow returns early inside isV2LifecycleItem(item), so lifecycle events with visibility of "inherited" or "synthetic" never render the provenance badge or the "From {sourceThreadId}" attribution that non-lifecycle events show below. This means inherited or synthetic fork, handoff, subagent, compaction, and interrupt events are displayed identically to local events, silently losing their provenance information. Consider passing visibility and sourceThreadId into V2LifecycleRow so it can render the same attribution, or rendering the badge before the early return.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/chat/MessagesTimeline.tsx around line 1364:

`V2EventTimelineRow` returns early inside `isV2LifecycleItem(item)`, so lifecycle events with `visibility` of `"inherited"` or `"synthetic"` never render the provenance badge or the "From {sourceThreadId}" attribution that non-lifecycle events show below. This means inherited or synthetic `fork`, `handoff`, `subagent`, compaction, and interrupt events are displayed identically to local events, silently losing their provenance information. Consider passing `visibility` and `sourceThreadId` into `V2LifecycleRow` so it can render the same attribution, or rendering the badge before the early return.

Comment on lines +779 to +785
if (rootIdentity === undefined) {
rootIdentity = byPid.get(input.rootPid);
if (rootIdentity === undefined) {
throw new AcpProcessGroupTerminationError({
detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`,
});
}

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 acp/AcpSessionRuntime.ts:779

capturePosixOwnershipLedger overwrites input.root.captureAttempted on the first capture but never reads it, so the one-shot guard is bypassed when root.value is undefined. If the original ACP process exits and its PID is reused before the teardown snapshot, byPid.get(input.rootPid) records the unrelated replacement as the owned root, after which terminatePosixOwnedProcessTree may signal that process and its descendants. Honor captureAttempted before accepting the PID lookup — when it is already true and root.value is undefined, treat the root as missing rather than capturing a recycled PID.

-  if (rootIdentity === undefined) {
+  if (rootIdentity === undefined && !input.root?.captureAttempted) {
     rootIdentity = byPid.get(input.rootPid);
     if (rootIdentity === undefined) {
       throw new AcpProcessGroupTerminationError({
         detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`,
       });
     }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/provider/acp/AcpSessionRuntime.ts around lines 779-785:

`capturePosixOwnershipLedger` overwrites `input.root.captureAttempted` on the first capture but never reads it, so the one-shot guard is bypassed when `root.value` is undefined. If the original ACP process exits and its PID is reused before the teardown snapshot, `byPid.get(input.rootPid)` records the unrelated replacement as the owned root, after which `terminatePosixOwnedProcessTree` may signal that process and its descendants. Honor `captureAttempted` before accepting the PID lookup — when it is already true and `root.value` is undefined, treat the root as missing rather than capturing a recycled PID.

prompt: [{ type: "text", text: prompt }],
});
const apiKey = yield* resolveCursorApiKey(operation);
const agentOptions = {

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 textGeneration/CursorTextGeneration.ts:90

runCursorJson invokes Agent.prompt in full "agent" mode with sandboxOptions: { enabled: false }, so a text-generation request for commit messages, PR content, or thread titles runs the complete coding-agent harness with unrestricted local tool execution against the real repository checkout. This means prompt content (staged patches, user messages) can cause repository files or other local data to be modified during what should be a read-only metadata operation — a request intended only to generate a commit message could trigger file writes or command execution. Consider using a read-only mode or retaining sandbox/tool restrictions for these metadata-only requests.

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

`runCursorJson` invokes `Agent.prompt` in full `"agent"` mode with `sandboxOptions: { enabled: false }`, so a text-generation request for commit messages, PR content, or thread titles runs the complete coding-agent harness with unrestricted local tool execution against the real repository checkout. This means prompt content (staged patches, user messages) can cause repository files or other local data to be modified during what should be a read-only metadata operation — a request intended only to generate a commit message could trigger file writes or command execution. Consider using a read-only mode or retaining sandbox/tool restrictions for these metadata-only requests.

};
}

export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = Layer.effect(

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/ProjectionStore.ts:956

The shell snapshot's item_count and per-run item counts don't match the visibility logic in activeLocalTurnItems. The SQL item_count query only excludes items whose run status is rolled_back, and itemCountRows counts every item regardless of run status — neither excludes legacy run_interrupt_result items whose attempt is superseded. As a result, getShellSnapshot() returns itemCount and visibleItemCount that are larger than getThreadProjection(...).visibleTurnItems.length, and forked threads inherit these inflated counts via itemCountThroughRun. Consider filtering both counts using the same visibility criteria applied in activeLocalTurnItems, or deriving shell counts from the projection's visibleTurnItems.

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

The shell snapshot's `item_count` and per-run item counts don't match the visibility logic in `activeLocalTurnItems`. The SQL `item_count` query only excludes items whose run status is `rolled_back`, and `itemCountRows` counts every item regardless of run status — neither excludes legacy `run_interrupt_result` items whose attempt is `superseded`. As a result, `getShellSnapshot()` returns `itemCount` and `visibleItemCount` that are larger than `getThreadProjection(...).visibleTurnItems.length`, and forked threads inherit these inflated counts via `itemCountThroughRun`. Consider filtering both counts using the same visibility criteria applied in `activeLocalTurnItems`, or deriving shell counts from the projection's `visibleTurnItems`.

weekdays,
projectId: task.projectId,
threadId: task.threadId ?? "",
workspaceMode: task.workspaceStrategy.type,

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 settings/ScheduledTasksSettings.tsx:230

taskToDraft drops branch from existing worktree strategies and hardcodes startFromOrigin: true on save, so editing and saving a task whose worktree strategy had branch set or startFromOrigin: false silently rewrites the strategy. The existing_worktree branch is similarly affected: startFromOrigin is lost. Preserve these fields in the draft and reuse them in submit when the UI does not expose them.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/settings/ScheduledTasksSettings.tsx around line 230:

`taskToDraft` drops `branch` from existing `worktree` strategies and hardcodes `startFromOrigin: true` on save, so editing and saving a task whose `worktree` strategy had `branch` set or `startFromOrigin: false` silently rewrites the strategy. The `existing_worktree` branch is similarly affected: `startFromOrigin` is lost. Preserve these fields in the draft and reuse them in `submit` when the UI does not expose them.

const survivors = new Map(snapshot().map((entry) => [entry.pid, 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.

🟡 Medium acp/AcpSessionRuntime.ts:973

The final snapshot() call on line 973 executes directly inside Effect.gen without Effect.try, unlike every earlier signalPhase invocation. If controller.snapshot() throws (e.g. /proc enumeration fails), the AcpProcessGroupTerminationError escapes as a defect instead of a typed failure, so callers cannot catch it through the declared Effect<void, AcpProcessGroupTerminationError> error channel. Wrap this snapshot() in Effect.try like the other calls.

-    const survivors = new Map(snapshot().map((entry) => [entry.pid, entry]));
+    const survivors = new Map(
+      (yield* Effect.try({
+        try: () => snapshot(),
+        catch: (cause) => cause as AcpProcessGroupTerminationError,
+      })).map((entry) => [entry.pid, entry]),
+    );
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/provider/acp/AcpSessionRuntime.ts around line 973:

The final `snapshot()` call on line 973 executes directly inside `Effect.gen` without `Effect.try`, unlike every earlier `signalPhase` invocation. If `controller.snapshot()` throws (e.g. `/proc` enumeration fails), the `AcpProcessGroupTerminationError` escapes as a defect instead of a typed failure, so callers cannot catch it through the declared `Effect<void, AcpProcessGroupTerminationError>` error channel. Wrap this `snapshot()` in `Effect.try` like the other calls.

- Extract shared T3 MCP tool label resolution
- Show T3 Code branding for matched tool activity rows
- Add tests for shared resolution and mobile feed output
OrchestrationEffectExecutorV2Shape
>()("t3/orchestration-v2/EffectWorker/OrchestrationEffectExecutorV2") {}

export const executorLayer: Layer.Layer<

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/EffectWorker.ts:63

The provider-turn.restart case retries the entire compound sequence (interruptAndAwaitTerminaldetachstart) from the beginning. When the interrupt succeeds and the old turn terminalizes but a later step fails transiently, every retry re-invokes interruptAndAwaitTerminal on the already-terminal turn, which yields a "not active" protocol error. Unlike provider-turn.interrupt, the isNonRetryableProviderTurnControlFailure guard deliberately does not treat restart errors as non-retryable, so all remaining attempts can fail before detach/start, leaving the replacement turn never started. Consider implementing stage-idempotent handling (e.g., skipping the interrupt when the prior turn/attempt is already terminal) so transient failures after the interrupt don't cause the whole restart to fail.

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

The `provider-turn.restart` case retries the entire compound sequence (`interruptAndAwaitTerminal` → `detach` → `start`) from the beginning. When the interrupt succeeds and the old turn terminalizes but a later step fails transiently, every retry re-invokes `interruptAndAwaitTerminal` on the already-terminal turn, which yields a "not active" protocol error. Unlike `provider-turn.interrupt`, the `isNonRetryableProviderTurnControlFailure` guard deliberately does not treat restart errors as non-retryable, so all remaining attempts can fail before `detach`/`start`, leaving the replacement turn never started. Consider implementing stage-idempotent handling (e.g., skipping the interrupt when the prior turn/attempt is already terminal) so transient failures after the interrupt don't cause the whole restart to fail.

Co-authored-by: codex <codex@users.noreply.github.com>
Comment on lines +187 to +192
if (next.value.type === "result") {
if (assistantMessageUuid === null) {
throw new Error(`Claude query completed ${input.label} without assistant UUID.`);
}
return assistantMessageUuid;
}

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/probe-claude-fork-local-rollback-replay.ts:187

recordTurn treats every SDK result frame as successful completion and returns the last assistant UUID, but result frames also cover failure subtypes like error_max_turns, error_during_execution, and budget errors. When a turn ends in failure, the function still returns the UUID of a prior assistant message, so the probe proceeds to fork/resume and records runtime_exit: success, producing a replay fixture from an unsuccessful turn instead of failing the recording. Consider checking the result frame subtype and throwing when it indicates an error.

    if (next.value.type === "result") {
-      if (assistantMessageUuid === null) {
-        throw new Error(`Claude query completed ${input.label} without assistant UUID.`);
-      }
-      return assistantMessageUuid;
+      if (next.value.subtype !== undefined && next.value.subtype !== "success") {
+        throw new Error(`Claude query ${input.label} ended with result subtype ${next.value.subtype}.`);
+      }
+      if (assistantMessageUuid === null) {
+        throw new Error(`Claude query completed ${input.label} without assistant UUID.`);
+      }
+      return assistantMessageUuid;
Also found in 1 other location(s)

apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts:443

replayMessages returns normal completion when cursor reaches the end of transcript.entries, and assertComplete then also succeeds because every entry was consumed. A truncated transcript that omits its runtime_exit marker therefore replays as a successful provider run instead of being rejected, allowing incomplete fixtures to silently validate incorrect orchestration behavior.

🚀 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 187-192:

`recordTurn` treats every SDK `result` frame as successful completion and returns the last assistant UUID, but result frames also cover failure subtypes like `error_max_turns`, `error_during_execution`, and budget errors. When a turn ends in failure, the function still returns the UUID of a prior assistant message, so the probe proceeds to fork/resume and records `runtime_exit: success`, producing a replay fixture from an unsuccessful turn instead of failing the recording. Consider checking the result frame subtype and throwing when it indicates an error.

Also found in 1 other location(s):
- apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts:443 -- `replayMessages` returns normal completion when `cursor` reaches the end of `transcript.entries`, and `assertComplete` then also succeeds because every entry was consumed. A truncated transcript that omits its `runtime_exit` marker therefore replays as a successful provider run instead of being rejected, allowing incomplete fixtures to silently validate incorrect orchestration behavior.

return boundaryNativeId;
}

const terminalTurnsAfterBoundary = sourceTurns

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:909

resolveClaudeForkUpToMessageId passes nativeTurnRef.nativeId to the Claude fork as upToMessageId without checking that nativeTurnRef.driver is CLAUDE_PROVIDER. When a source turn carries a nativeTurnRef from a different driver, its foreign ID is forwarded to the Claude SDK, producing an invalid fork cursor instead of rejecting the inconsistent turn. Add a nativeTurnRef.driver === CLAUDE_PROVIDER guard before using boundaryNativeId.

🚀 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 909:

`resolveClaudeForkUpToMessageId` passes `nativeTurnRef.nativeId` to the Claude fork as `upToMessageId` without checking that `nativeTurnRef.driver` is `CLAUDE_PROVIDER`. When a source turn carries a `nativeTurnRef` from a different driver, its foreign ID is forwarded to the Claude SDK, producing an invalid fork cursor instead of rejecting the inconsistent turn. Add a `nativeTurnRef.driver === CLAUDE_PROVIDER` guard before using `boundaryNativeId`.

capturedAt,
});
const commandId = CommandId.make(`command:effect:checkpoint.capture:${run.id}`);
yield* eventSink.commitCommand({

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/CheckpointCaptureService.ts:108

execute reads the run status from a projection snapshot and then commits run.updated/node.updated events unconditionally via commitCommand. A concurrent command (e.g. an interrupt) can change the run to a different state between the projection read and the commit, and this stale worker overwrites that newer state back to completed — silently discarding the concurrent update. The EventSinkV2 API provides writeIfRunCurrent/commitCommand with run-current guards for exactly this case; the capture commit should use a conditional commit that checks activeAttemptId and expected status before persisting, or revalidate atomically at write time.

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

`execute` reads the run status from a projection snapshot and then commits `run.updated`/`node.updated` events unconditionally via `commitCommand`. A concurrent command (e.g. an interrupt) can change the run to a different state between the projection read and the commit, and this stale worker overwrites that newer state back to `completed` — silently discarding the concurrent update. The `EventSinkV2` API provides `writeIfRunCurrent`/`commitCommand` with run-current guards for exactly this case; the capture commit should use a conditional commit that checks `activeAttemptId` and expected `status` before persisting, or revalidate atomically at write time.

Comment thread apps/server/src/ws.ts
Port mainline lifecycle and backend fixes onto the v2 orchestration architecture while retaining the full v2 implementation.

Co-authored-by: codex <codex@users.noreply.github.com>
latestVisibleMessage: null,
latestUserMessageAt: latestUserMessageAt(projection),
hasActionableProposedPlan: false,
itemCount: projection.turnItems.length,

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 state/use-thread-selection.ts:98

threadDetailToShell sets itemCount to projection.turnItems.length, which includes rolled-back items. After a rollback, rolled-back items remain in turnItems but are no longer active, so this optimistic shell reports an inflated itemCount until the canonical shell list catches up. The canonical shell uses only active items for the count. Consider computing itemCount from the same active-item set used by the canonical shell instead of the full turnItems array.

Suggested change
itemCount: projection.turnItems.length,
itemCount: projection.visibleTurnItems.length,
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/state/use-thread-selection.ts around line 98:

`threadDetailToShell` sets `itemCount` to `projection.turnItems.length`, which includes rolled-back items. After a rollback, rolled-back items remain in `turnItems` but are no longer active, so this optimistic shell reports an inflated `itemCount` until the canonical shell list catches up. The canonical shell uses only active items for the count. Consider computing `itemCount` from the same active-item set used by the canonical shell instead of the full `turnItems` array.

@@ -452,7 +456,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
[discoveredPorts, navigateToThread, openPreview, threadRef],
);
const isThreadRunning =

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 components/Sidebar.tsx:458

isThreadRunning only checks runtime.status === "running", but the runtime model also treats preparing, starting, queued, and waiting as active. During those states the archive button is shown and enabled (via isConfirmingArchive and the !isThreadRunning branch), allowing users to archive a thread whose run is still in progress. The guard should cover all active-run statuses, not just running.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/Sidebar.tsx around line 458:

`isThreadRunning` only checks `runtime.status === "running"`, but the runtime model also treats `preparing`, `starting`, `queued`, and `waiting` as active. During those states the archive button is shown and enabled (via `isConfirmingArchive` and the `!isThreadRunning` branch), allowing users to archive a thread whose run is still in progress. The guard should cover all active-run statuses, not just `running`.

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

return message.createdAt;

threadDetailToShell sets latestUserMessageAt to the user message's createdAt, but the canonical V2 shell uses the latest user message's updatedAt. When a user message is edited after creation, the optimistic shell reports a stale activity timestamp until the real shell materializes. Consider returning message.updatedAt instead of message.createdAt in latestUserMessageAt to match the canonical shell behavior.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/state/use-thread-selection.ts around line 43:

`threadDetailToShell` sets `latestUserMessageAt` to the user message's `createdAt`, but the canonical V2 shell uses the latest user message's `updatedAt`. When a user message is edited after creation, the optimistic shell reports a stale activity timestamp until the real shell materializes. Consider returning `message.updatedAt` instead of `message.createdAt` in `latestUserMessageAt` to match the canonical shell behavior.

hasActionableProposedPlan: false,
itemCount: projection.turnItems.length,
visibleItemCount: projection.visibleTurnItems.length,
createdAt: thread.createdAt,

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 state/use-thread-selection.ts:100

The fallback shell copies thread.updatedAt instead of projection.updatedAt. Run, message, and turn-item events update the projection timestamp without updating the thread record, so a detail-only thread can expose a stale updatedAt — and presentThreadShell derives run completion timing from it — until the canonical shell materializes. Consider using projection.updatedAt to match the projection's actual freshness.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/state/use-thread-selection.ts around line 100:

The fallback shell copies `thread.updatedAt` instead of `projection.updatedAt`. Run, message, and turn-item events update the projection timestamp without updating the thread record, so a detail-only thread can expose a stale `updatedAt` — and `presentThreadShell` derives run completion timing from it — until the canonical shell materializes. Consider using `projection.updatedAt` to match the projection's actual freshness.

@@ -90,7 +98,15 @@ export const migrationEntries = [
[30, "ProjectionThreadShellArchiveIndexes", Migration0030],
[31, "AuthAuthorizationScopes", Migration0031],
[32, "AuthPairingProofKeyThumbprint", Migration0032],
[33, "ProjectionThreadsSettled", Migration0033],
[33, "OrchestrationV2", Migration0033],

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 persistence/Migrations.ts:101

Migration ID 33 is reused for OrchestrationV2 even though existing databases already recorded ID 33 as ProjectionThreadsSettled. On upgrade, the OrchestrationV2 migration (which creates the orchestration_v2_projection_subagents and related tables) is skipped, so migration 34 fails when it creates an index on a table that was never created, preventing the server from opening existing databases. The new V2 migrations must use IDs 34+ and keep ProjectionThreadsSettled at ID 33.

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

Migration ID `33` is reused for `OrchestrationV2` even though existing databases already recorded ID `33` as `ProjectionThreadsSettled`. On upgrade, the `OrchestrationV2` migration (which creates the `orchestration_v2_projection_subagents` and related tables) is skipped, so migration `34` fails when it creates an index on a table that was never created, preventing the server from opening existing databases. The new V2 migrations must use IDs `34`+ and keep `ProjectionThreadsSettled` at ID `33`.

@@ -501,6 +603,23 @@ export function ProviderInstanceCard({
);
};

const updateGenericEnvironment = (

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 settings/ProviderInstanceCard.tsx:606

updateGenericEnvironment preserves every existing dedicated environment entry and appends the generic rows without filtering out names in environmentFieldNames. If a user renames a generic variable to match a dedicated field name that already has a stored value, the saved environment contains two variables with the same name. readProviderEnvironmentVariable then returns the first match (the old dedicated entry), so the rename appears lost and the server receives ambiguous duplicate names. Consider filtering reserved names out of environment before concatenating with the dedicated entries.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/settings/ProviderInstanceCard.tsx around line 606:

`updateGenericEnvironment` preserves every existing dedicated environment entry and appends the generic rows without filtering out names in `environmentFieldNames`. If a user renames a generic variable to match a dedicated field name that already has a stored value, the saved environment contains two variables with the same name. `readProviderEnvironmentVariable` then returns the first match (the old dedicated entry), so the rename appears lost and the server receives ambiguous duplicate names. Consider filtering reserved names out of `environment` before concatenating with the dedicated entries.

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants