Skip to content

feat(orchestrator): introduce new orchestrator - #2829

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

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

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented May 27, 2026

Copy link
Copy Markdown
Member

Summary

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

Validation

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

Notes

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

Closes

Verified against the branch with code/commit evidence.

High confidence

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

Medium confidence (under review)

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

Note

Introduce orchestration V2 system with event sourcing, provider adapters, and thread management

  • Adds a complete event-sourced orchestration V2 runtime: EventStore, ProjectionStore, EffectOutbox, EffectWorker, EventSink, and supporting services (ThreadLaunchService, ThreadManagementService, ProviderSessionManager, CheckpointCapture/RollbackService, RuntimeRequestService, ContextHandoffService, ProviderRuntimeRecoveryService, LegacyV1ThreadImporter)
  • Introduces V2 provider adapter contracts and concrete adapters for Claude, Codex, Cursor, Grok, OpenCode, and ACP Registry, with typed capabilities, session/turn lifecycle, and runtime policy resolution
  • Adds new shared contracts for orchestration V2 domain events, model selections, scheduled tasks, MCP orchestrator/worktree toolkits, project mutations, attachments, and checkpoint diffs
  • Ships client-runtime state for V2 projections, progressive thread history pagination, thread relationships/fork/merge, queue workflows, and item support resolution; web and mobile UI add thread details panels, queued runs control, V2 item inspector, relationship views, and scheduled task settings
  • Adds database migrations 44–52 for V2 event/projection tables, effect outbox, application event source, scheduled tasks, and legacy import state; plus a replay testkit with provider-specific fixtures and integration tests
  • Behavioral Change: EnvironmentApi replaces the orchestration namespace with orchestrationV2; WS methods and RPC scopes now target V2 endpoints; ModelSelection decodes both legacy provider-based and canonical instance-based forms; corrupt orchestration cache entries are deleted on decode failure; CursorSettings removes obsolete binaryPath/apiEndpoint fields; diffPanelStore persisted version bumps to 2 and uses RunId instead of TurnId

Macroscope summarized c01ff20.


Note

High Risk
Mobile cache schema and thread UX now depend on V2 projections and runtime semantics; mis-synced decode or archive/stop guards could corrupt offline cache or interrupt live work. Removing the thread-transfer PR reporter reduces CI visibility into transfer regressions.

Overview
This slice of the orchestration V2 work removes the automated “thread transfer impact” PR comment pipeline (trusted publisher script, tests, and workflow_run workflow) while CI can still upload thread-transfer-results artifacts to the job summary path.

Mobile is brought onto the shared V2 client stack: SQLite cache uses ORCHESTRATION_CACHE_SCHEMA_VERSION and stored shell/thread projection shapes, runtime wiring swaps in bounded thread snapshot loading and history controls, and thread/archive/review/approval flows move from V1 session/TurnId concepts to runtime status, RuntimeRequestId, and checkpoint summaries derived from projections. Thread detail gains visit watermarking, a relationships banner, queue control, richer pending-request handling when the provider is dead, and a new activity inspector; archive guards use threadCanArchive so queued work can be discarded but live provider turns cannot.

Smaller changes: CI installs build-essential so ACP process-tree fixtures compile instead of soft-skipping; Uniwind adds adaptive color tokens; brand mark assets are centralized; README links appearance docs; desktop tests assert user-data dir names; marketing copy updates the Cursor harness label.

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

@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown

Important

Review skipped

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

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7de9b65e-d4c9-4808-b3aa-9a88e491cf34

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

Use the checkbox below for a quick retry:

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Low testkit/ReplayTranscriptNdjson.ts:116

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

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

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

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

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

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

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

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

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

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

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

🚀 Expo continuous deployment is ready!

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium orchestration-v2/ProviderEventIngestor.ts:109

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

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

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium Adapters/AcpAdapterV2.ts:271

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

Also found in 1 other location(s)

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

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

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

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

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

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

Cancel pending SDK requests before aborting the native session. Preserve per-admission cancellation state and treat stopped initial prompts as interruption instead of provider failure.

Finding: R14 prompt cancellation

Model: GPT-5.6 Sol via Codex
Keep the original cursor owner through ancestor traversal and preserve the history budget across empty intermediate forks. Verify exact paged history against the complete nested projection.

Finding: P01 nested lineage

Model: GPT-5.6 Sol via Codex
Retain pending admission after transient status failures and use one generation-owned retry worker. Ignore stale timers and duplicate evidence so older prompts cannot finish newer steering.

Finding: R14 status reconciliation

Model: GPT-5.6 Sol via Codex
Keep hidden local and inherited rows from consuming history pages. Preserve stop-request dependencies, source-run cutoffs, and imported history while loading related metadata from the selected cohort and using indexed watermark lookups.

Finding: P01 bounded history visibility

Model: GPT-5.6 Sol via Codex
The provider process for this request is no longer available. Interrupt or restart the run
to continue.
</Text>
) : null}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dead-provider approvals stay tappable

Medium Severity

PendingApprovalCard computes disabled from responseCapability !== "live" but the action buttons still key only off respondingApprovalId. When the provider process is gone, Allow/Decline stay tappable and can dispatch a doomed respond call, unlike PendingUserInputCard which actually gates on canRespond.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 373612e. Configure here.

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

{fileStagingLimit !== null && pendingUserInputs.length === 0 ? (

Queued-message editing exposes the generic attachment picker, but the edit save request serializes only composerImages and never includes composerFiles. As a result, adding a file such as a PDF is silently dropped (or leaves a file-only edit with nothing to save). Disable generic file attachments in this mode or include and upload composerFiles in the queued-edit save request.

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

Queued-message editing exposes the generic attachment picker, but the edit save request serializes only `composerImages` and never includes `composerFiles`. As a result, adding a file such as a PDF is silently dropped (or leaves a file-only edit with nothing to save). Disable generic file attachments in this mode or include and upload `composerFiles` in the queued-edit save request.

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

On mobile, editing a queued message during another running turn leaves the collapsed primary Save action disabled, so the edit cannot be saved without expanding the composer. collapsedComposerPrimaryActionDisabled still treats every phase === "running" state as blocked; apply the same !isEditingQueuedMessage exception used by the footer action.

-    phase === "running" ||
+    (phase === "running" && !isEditingQueuedMessage) ||
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/chat/ChatComposer.tsx around line 1522:

On mobile, editing a queued message during another running turn leaves the collapsed primary Save action disabled, so the edit cannot be saved without expanding the composer. `collapsedComposerPrimaryActionDisabled` still treats every `phase === "running"` state as blocked; apply the same `!isEditingQueuedMessage` exception used by the footer action.

</ComposerBanner.Dock>
) : null}
</>
)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stash menu hides blocking composer UI

Medium Severity

Opening the in-flow stash attachment unmounts the rest of the composer chrome, including pending user-input questions, plan follow-up, urgent banners, and the interrupt/activity row. Approvals stay visible via isComposerApprovalState, but pending questions do not, even though ⌘S and the inline stash badge still open stash while those questions are up. Previously the stash overlay left that UI in place.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cef1028. Configure here.

<ComposerBannerStack key={activeThreadId} className="relative z-0" items={bannerStackItems} />
{!activityStackItem &&
(inlineTasksBadge || (standaloneActivityStatus && !showShoulderTabs)) ? (
{isStashMenuOpen &&

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.

Stash state survives thread and collapse

Medium Severity

isStashMenuOpen now replaces the banner stack, but it is not cleared on activeThreadId change, mobile collapse, or when an approval/pending-input drawer appears. Tasks already reset on thread change. A leftover open flag hides the new thread’s banners, or brings stash back the moment an approval resolves or the mobile composer re-expands.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cef1028. Configure here.

const promote = useAtomCommand(threadEnvironment.promoteQueuedRun);
const cancel = useAtomCommand(threadEnvironment.cancelQueuedRun);
const [expanded, setExpanded] = useState(true);
const queueListId = useId();

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.

Queue collapse persists across threads

Low Severity

expanded is local useState(true) and QueuedRunsControl is not remounted or reset when threadId changes. Collapsing the queue on one thread leaves the next thread’s list hidden behind the header, even though that thread’s queued runs are different.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cef1028. Configure here.

@juliusmarminge

Copy link
Copy Markdown
Member Author

Fixed in 84ef39d. Stash is a separate tab again. The queue and activity share a column that stops 4px before Stash. Opening Stash shows its own drawer and preserves the queue's expanded or collapsed state when closed. Waiting threads keep the Stash tab too.

Before After
Stash merged into the queue card Separate Stash tab beside the queue and activity column

Stash open:

Stash drawer with four entries

Checked the queue, waiting state, expanded tasks, and Stash at desktop and narrow widths. Targeted lint, web typecheck, and all six existing queue/stash tests passed. No new tests or stylesheets.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

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

Reviewed by Cursor Bugbot for commit 84ef39d. Configure here.

data-chat-composer-collapsed-controls="true"
<ComposerBanner.Dock>
<ComposerBanner.Column className={showStashMenu ? "hidden" : undefined}>
{props.queuedRunsControl}

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.

Stash menu hides queued runs

Medium Severity

Opening the stash menu now applies hidden to the whole banner column, including queuedRunsControl. That control previously rendered above the stash/banner swap, so queued messages stayed visible and actionable. Users can no longer see or manage the queue while browsing stash.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 84ef39d. Configure here.

@juliusmarminge

Copy link
Copy Markdown
Member Author

Queued message rows now use the shared banner list, row, icon, content, count, action, and dismiss components in 6b73b65. Their separate padding, divider lines, and button size overrides are gone.

Queue rows before Queue rows after
Queue rows with separate spacing Queue rows using the shared banner layout

The edit control was incorrectly treated as a peeking notice, which split the queue card and overlapped its rows. In bc5ac65, it stays inside the queue panel with Cancel visible, including when the queue is collapsed or its last message is being edited. Working stays attached and Stash stays separate.

Editing before Editing after
Editing notice splitting the banner Editing control inside the queue panel

Verified mouse and keyboard reordering, editing and saving, cancellation, steering, removal, and narrow layouts in the mock environment. Web typecheck and the four existing queue tests passed. No new tests or stylesheets.

@juliusmarminge

Copy link
Copy Markdown
Member Author

Queued-message editing is fixed in a794ced. These screenshots replace the earlier edit-row examples.

  • Editing highlights the original queue row instead of hiding it or adding a row. The count and header remain visible, including with one queued message.
  • Row numbers are removed. There is one Cancel action, and the save button uses a checkmark.
  • Edit, Steer, and Remove use the app tooltip component instead of native title tooltips.
  • The narrow-screen toolbar keeps the same height when switching between Stop and Save.

Verified in the shared preview: typing and Cancel leave the saved text unchanged; Save updates the message in place. Queue and composer dimensions stay unchanged when entering and leaving edit mode at desktop and narrow widths. Targeted lint, web typecheck, and 21 existing focused tests pass. No new tests were added.

This covers the shared web/desktop composer and narrow web layouts. Provider adapters, wire contracts, connection modes, and the native mobile client are unchanged. The composer documentation now describes saving with the checkmark.

Before:

Before: editing lowers the count and adds a separate row with duplicate cancel controls

After:

After: original queued message stays visible while its draft is edited, with one Cancel and a checkmark to save

Short edit and cancel demo

Single-message, narrow-screen, and tooltip examples

Editing the only queued message retains its header and count

Single-message editing at a narrow viewport

App-styled edit tooltip

@@ -245,8 +247,12 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
: isPreparingWorktree
? "Preparing worktree"
: isSendBusy
? "Sending"
: "Send message"
? isEditingQueuedMessage

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 chat/ComposerPrimaryActions.tsx:250

After clicking Save on a queued-message edit, the button remains enabled and continues showing the checkmark while editQueuedRun is still pending, so text typed during that request is cleared when the save succeeds. The saving indicator is gated by isSendBusy, but this path does not call beginLocalDispatch; track the queued-edit save request separately and disable the editor/actions until it completes.

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

After clicking Save on a queued-message edit, the button remains enabled and continues showing the checkmark while `editQueuedRun` is still pending, so text typed during that request is cleared when the save succeeds. The saving indicator is gated by `isSendBusy`, but this path does not call `beginLocalDispatch`; track the queued-edit save request separately and disable the editor/actions until it completes.

@juliusmarminge

Copy link
Copy Markdown
Member Author

Updated the composer actions in a41ba60.

  • A running turn shows Interrupt only when the composer is empty.
  • With text or attachments, the button shows a steer arrow. Click or Enter steers.
  • Holding Cmd/Ctrl changes it to a queue icon. Modifier-click and Cmd/Ctrl+Enter queue.
  • Editing a queued message keeps the checkmark, even while holding a modifier.
  • Each state uses an app-styled tooltip, including disabled Submit and busy states.

Verified click and keyboard dispatch, edit save/cancel, modifier release, and the narrow web layout in the mock preview. Targeted lint, the web typecheck, and 30 existing tests passed. No new tests or CSS files.

Steer Queue with Cmd/Ctrl held
Steer button and keyboard shortcut tooltip Queue icon while holding the modifier
Interrupt, queued update, idle, and narrow layout
Empty running composer Updating a queued message
Interrupt tooltip on the empty running composer Checkmark with Update queued message tooltip

Disabled idle Submit button still shows its tooltip

Narrow web composer shows only Steer when it has content

@@ -4146,7 +4277,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
isPreparingWorktree={isPreparingWorktree}
hasSendableContent={composerSendState.hasSendableContent}

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/ChatComposer.tsx:4278

ComposerPrimaryActions disables the queued-message save button when hasSendableContent is false, but that value only reflects the edit draft and omits editingQueuedAttachments. Therefore, deleting all text while retaining an existing attachment makes Update queued message unavailable even though onSend accepts attachment-only edits. Include the retained queued attachments in the value passed here.

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

`ComposerPrimaryActions` disables the queued-message save button when `hasSendableContent` is false, but that value only reflects the edit draft and omits `editingQueuedAttachments`. Therefore, deleting all text while retaining an existing attachment makes `Update queued message` unavailable even though `onSend` accepts attachment-only edits. Include the retained queued attachments in the value passed here.

@juliusmarminge

juliusmarminge commented Aug 30, 2026

Copy link
Copy Markdown
Member Author

Fixed the tooltip wrapping on Mod in 330c44f.

Changing the order of the shortcut text triggered the tooltip's content resize. The text now stays Enter to steer, Mod+Enter to queue while the icon and action still switch to Queue.

Verified repeated Mod press/release cycles at desktop and narrow widths. The tooltip stayed 26px high throughout, compared with 42px when the old text wrapped. Targeted lint and the web typecheck passed.

Before fix, Mod held After fix, Mod held
Wrapped tooltip Stable single-line tooltip

Watch two Mod press/release cycles.

@astarktc

Copy link
Copy Markdown

Bug: delegated-task completion wakes are silently starved after two deliveries per cohort — parent waits forever on the third wave

TL;DR. delegate_task (async) promises the parent a wake on every child terminal, and its tool description tells the agent to "end the turn instead of polling." But finalizeDelegatedCompletionDelivery refuses to reserve a follow-up delivery once a cohort has settled two deliveries (settledDeliveryCount < 2). Any child whose completion lands in a third wave stays completionDelivery: "pending" forever: no wake, no queued message, nothing on restart. The orchestrating agent — following the tool contract — sits idle waiting for a completion that already happened. With 4–5+ parallel children whose completions straddle wake turns, this is close to guaranteed.

Observed on the #7211 stack (V2 snapshot of 2026-08-28); everything cited below is apps/server/src/orchestration-v2/ code from this PR's branch (none of it exists on main), and none of it is provider-specific.

The two contracts that collide

OrchestratorMcpService.ts, delegate-task creation:

// Async delegations wake the parent on every child terminal; wait
// delegations deliver through the blocking tool call, so a wake is
// only needed if the parent settled first (timeout, disconnect).
completionWake: input.mode === "wait" ? "settled_only" : "always",

Orchestrator.ts, planDelegatedCompletionDelivery:

const settledDeliveryCount = cohort?.settledDeliveryCount ?? 0;
if (settledDeliveryCount >= 2) {
  // A cohort permits one initial delivery and one successor. Keep the
  // result pending and inspectable instead of recursively re-arming the
  // parent for every child that finishes after that bounded handoff.
  ...state: "pending"..., offer: false

…and the same settledDeliveryCount < 2 guard in finalizeDelegatedCompletionDelivery's canReserveFollowUp, which is the sweep that runs when a wake turn settles. settledDeliveryCount never resets, so this isn't a throttle — it's lifetime starvation per cohort. Nothing ever revisits a "pending" task after the cap engages: handleTerminalRun only sweeps delivery runs, the startup recovery path re-runs the same capped sweep, and finalizeAppOwnedSubagent early-returns on existingResultTransfer. The tool description ("An async child's completion wakes this thread… end the turn instead of polling") makes the failure mode maximally quiet: the agent does exactly what it was told and hangs.

Forensic timeline (real thread, from orchestration_v2_projection_*)

Parent thread delegated 5 async children in one turn (run 7), so all 5 share run 7's cohort. Timestamps from the projections DB:

time (UTC) event cohort state
01:01:52 run 7 delegates t10, t11, t13, t15, t17
01:05:49 t10 completes → delivery 1, wake run 8 starts settled=0, delivery gen 1
01:05:56–01:07:01 t17, t13, t15 complete during run 8 → "pending"
01:07:11 run 8 settles → sweep reserves delivery 2 = {t15, t17, t13} → wake run 9 starts 01:07:12 settled=1, delivery gen 2
01:07:20 t11 completes, 8s into run 9 → "pending"
01:09:12 run 9 settles → sweep: settledDeliveryCount → 2, pendingTaskIds = [t11], but canReserveFollowUp requires < 2refused settled=2, delivery null, t11 pending
01:09–01:29 parent idle; t11's result queryable but no wake exists or ever will
01:29:48 human notices the stall and nudges; that run reads task_status → t11 finally acknowledged

Final cohort row on run 7: settledDeliveryCount=2, nextGeneration=3, disposition=open, delivery=null — with a terminal, never-delivered task. Children delegated during later wake turns (t12 on run 8, t14 on run 9, t16 on run 10…) each started fresh cohorts with reset counters, so their wakes worked — which is exactly why moderate parallelism masks the bug until one dispatch wave is large enough to stagger across ≥3 waves.

(The queued-delivery merging worked beautifully, for the record — t13/t15/t17 amending the queued wake into one bundled message is the right behavior. The problem is only the lifetime cap.)

Suggested fixes (any of these)

  1. Make the cap a concurrency bound, not a lifetime bound. The recursion the comment fears is "one delivery in flight + one reserved successor" — that's already bounded without a lifetime counter. Reset settledDeliveryCount (or simply drop it) whenever a sweep finds pendingTaskIds.length > 0 after the delivery drains; the wake-per-terminal contract then holds and the parent still never sees more than one queued wake at a time.
  2. Sweep pendings on any parent-run settle. Extend handleTerminalRun to arm a delivery for terminal app-owned tasks left "pending"/unarmed whenever the parent thread goes non-live. The existing generation + alreadyDispatched dedupe in ProviderContinuationService.currentDelegatedCompletionDelivery already guards against the duplicate-wake concern.
  3. At minimum, don't be silent. If the cap stays, surface refused deliveries (system line in the thread: "N delegated results pending — read with task_status") and soften the tool description's "end the turn instead of polling," which is currently a trap.

Related smaller hole in the same family: for mode="wait" children (settled_only), planDelegatedCompletionDelivery returns with the task untouched (not even "pending") when the parent has a live run — that branch precedes the pending-marking branches — and the post-timeout wake-policy upgrade knowingly skips re-planning when the parent settled in between ("a missed wake is cheaper than a duplicate one"). Same silent-starvation class, just harder to hit because the blocking wait usually owns delivery.

Happy to provide the raw projection rows or test this against a patched build — we run this stack daily.

Copy link
Copy Markdown

@juliusmarminge one addition I'd really like here: scheduled messages.

I think this should be separate from normal queued messages. Queued messages mean “send after the current run finishes”; a scheduled message means “send this message at a specific date/time.”

The important architectural requirement is that the schedule must be persisted and executed by the T3 server/environment that actually runs the agent, not by whichever web/mobile/desktop client created it. If I schedule a message from my laptop or phone against an agent running on another machine, I should be able to close/disconnect that client and the message should still fire at the selected time.

In other words, the client should only create/edit/cancel the schedule. The host environment owns the durable record and dispatch timer.

A simple UX could be a “Send later” action from the composer that creates a one-shot scheduled message for the current thread. It should be visible/cancellable/editable before it fires.

I noticed this PR already adds server-persisted scheduled tasks, but those schedules appear recurring (interval / fixed_time). This feels like the same server-side scheduling infrastructure could support a one-shot send_at/timestamp schedule without conflating it with the existing queued-message semantics.

@juliusmarminge

Copy link
Copy Markdown
Member Author

@juliusmarminge one addition I'd really like here: scheduled messages.

I think this should be separate from normal queued messages. Queued messages mean “send after the current run finishes”; a scheduled message means “send this message at a specific date/time.”

The important architectural requirement is that the schedule must be persisted and executed by the T3 server/environment that actually runs the agent, not by whichever web/mobile/desktop client created it. If I schedule a message from my laptop or phone against an agent running on another machine, I should be able to close/disconnect that client and the message should still fire at the selected time.

In other words, the client should only create/edit/cancel the schedule. The host environment owns the durable record and dispatch timer.

A simple UX could be a “Send later” action from the composer that creates a one-shot scheduled message for the current thread. It should be visible/cancellable/editable before it fires.

I noticed this PR already adds server-persisted scheduled tasks, but those schedules appear recurring (interval / fixed_time). This feels like the same server-side scheduling infrastructure could support a one-shot send_at/timestamp schedule without conflating it with the existing queued-message semantics.

CleanShot 2026-08-29 at 19 21 01@2x

@juliusmarminge

Copy link
Copy Markdown
Member Author

Implemented T3 action summaries in c01ff20. The same groups now describe orchestration work instead of reducing it to “used tools”:

  • Ran 2 commands and sent messages to 3 threads
  • Sent 5 messages to 2 threads, with repeated message IDs deduplicated
  • Created 4 threads and delegated 2 tasks, including batch creation

Mixed groups show two specific categories and a count of the remaining actions. Commands, file changes, and orchestration changes take priority over reads and status checks. Failed calls do not inflate successful sends or creations; failure counts appear first so narrow layouts cannot truncate them away. A reported child failure is separate from failure of the status-check tool itself.

Grouping boundaries, expansion, individual call details, and icons are unchanged. Native mobile layout is unchanged. Preview/worktree tools and unknown tools retain the generic fallback.

Verified 53 focused tests across the timeline, T3 counting, and tool presentation, plus scoped lint and web/shared/client-runtime typechecks. Checked the scripted preview at desktop and narrow widths, including expanding back to the original calls and their input/output.

Before After
Generic tool summaries T3 action summaries
Narrow layout — failure count stays visible

Narrow T3 activity summaries

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