Skip to content

fix(orchestrator): Claude plan-mode plan proposals + real provider error causes#3696

Open
shivamhwp wants to merge 7 commits into
pingdotgg:t3code/codex-turn-mappingfrom
shivamhwp:fix/claude-plan-mode-and-error-surfacing
Open

fix(orchestrator): Claude plan-mode plan proposals + real provider error causes#3696
shivamhwp wants to merge 7 commits into
pingdotgg:t3code/codex-turn-mappingfrom
shivamhwp:fix/claude-plan-mode-and-error-surfacing

Conversation

@shivamhwp

@shivamhwp shivamhwp commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

What Changed

Five focused bug fixes for the Claude adapter / run execution in the v2 orchestrator (this PR's branch):

  1. Surface real causes in Claude provider failuresClaudeAgentSdkQueryRunnerError.message was a fixed string ("Claude Agent SDK query failed."), so every provider-error card showed that and nothing else, and the run-path logs printed cause: [Object]. The error message now appends the failing SDK method and the underlying defect message, and the three warn/error logs on the run path render Cause.pretty output.

  2. Let Claude plan mode propose approvable plans — plan-mode sessions were launched with installPermissionCallback: false, so the SDK never exposed ExitPlanMode and plan mode could only ever end in prose: no plan artifact, no "Plan Ready" state, no approve/implement flow (orchestration_v2_projection_plans stayed empty). The callback is now installed in plan mode, and canUseTool intercepts ExitPlanMode: it emits the proposed plan as v2 node/plan/turn-item artifacts (status: "active", which flips hasActionableProposedPlan) and denies the tool call with guidance to end the turn, so the session stays in plan mode until the user approves from the app. emitsProposedPlan capability flipped to true, regression test added for the plan-mode query policy.

  3. Drop poison provider events instead of killing ingestion — one event failing ingestNormalized (e.g. a late async-subagent notification referencing a finalized run) tore down the whole Stream.tap pipeline, leaving the session's event routing dead and the next run wedged at starting until a server restart. Failed non-terminal events are now logged and dropped; root turn.terminal failures still escalate to the existing fallback finalization. Regression test included.

  4. Resolve async subagent completions after turn finalization — the SDK's system/task_notification for an async Task usually arrives after the parent turn finalized (activeTurn === null), so it was silently dropped: the subagent projection stayed running forever and RunExecutionService kept waiting on a child that could never terminalize. Turn contexts with still-running tasks are now retained (keyed by native task id) and late notifications resolve against the owning context, emitting the subagent/node/turn-item updates under the original run.

  5. Skip a stale resume pin after a dirty-shutdown query failure — resuming pins resumeSessionAt to the recorded conversation head; after a dirty shutdown that head may never have been flushed to the native transcript, so the first query failed and only an unexplained manual retry recovered. A pinned query that dies before producing any SDK message now marks the native thread so the next open resumes unpinned (one-shot).

The ClaudeAgentSdkQueryRunnerError message is derived from structural fields only (per the Effect service conventions); the underlying SDK error reaches the provider-failure card via makeProviderFailure's existing redaction/bounding pipeline instead.

Known follow-up (not in this PR): a stop/cancel affordance for runs stuck in queued/preparing/starting.

Why

Found while testing this PR end-to-end with the Claude provider:

  • Every failure — missing cwd, SDK spawn failure, broken session resume — rendered as the same opaque card (transport_error · "Claude Agent SDK query failed." · code: null), and the logs gave nothing more. makeProviderFailure already extracts Error#message; the information was being discarded at the error-class layer.
  • Plan mode on claudeAgent was a dead-end: the model itself reports it has no ExitPlanMode tool, writes its plan as chat prose, and the plan interaction mode can never hand off to the build flow. With this fix the full loop works: plan request → plan card + "Plan Ready" pill → Implement → build run executes the plan.

Both verified live against a scratch project (screenshots below); full server suite passes (1199 tests).

UI Changes

No component changes — both fixes are server-side, but they change what the existing UI displays.

Provider error card (same failure class — a project whose workspace folder was deleted). Before: the bare one-liner with code: null. After: the card names the failing SDK method and the real cause.

Before After
before: bare provider error card after: provider error card with method and real cause

Plan mode. Before: the plan exists only as chat prose — no plan card, no Plan Ready, nothing to approve — and the model states it has no ExitPlanMode tool. After: a proposed-plan card is emitted, the thread flips to "Plan Ready", and Implement kicks off the build run that executes the plan (card + pill mid-flow state in the collapsed section below).

Before After
before: plan as prose, no ExitPlanMode tool after: plan implemented via the plan flow with changed files
Extra: the proposed-plan card + "Plan Ready" pill + Implement button state (mid-flow) plan card with Plan Ready pill and Implement button

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes (n/a)

🤖 Generated with Claude Code


Note

Medium Risk
Changes core orchestration behavior (event ingestion, Claude session resume, plan/subagent lifecycle) in the v2 run path; mistakes could wedge runs or mis-route subagent/plan state, though failures are mostly isolated with logging and tests.

Overview
Hardens the Claude v2 adapter and run execution path with five targeted fixes.

Plan mode now installs the SDK permission callback in plan mode and handles ExitPlanMode by emitting proposed-plan node/plan/turn-item artifacts (then denying the tool so review stays in-app). Capability emitsProposedPlan is set to true.

Provider failures unwrap ClaudeAgentSdkQueryRunnerError to the underlying Error for makeProviderFailure, and run-path logs use Cause.pretty instead of opaque cause objects.

Async subagents retain turn context for running tasks after parent finalization so late task_notification messages still terminalize subagents on the original run.

Resume skips a stale resumeSessionAt pin when a pinned query fails before any SDK message (one-shot per native thread).

Run ingestion logs and drops non-terminal events that fail normalization instead of killing the stream; root turn.terminal failures still escalate. Regression tests cover plan-mode query policy and poison-event handling.

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

Note

Fix Claude plan-mode plan proposals and provider error cause unwrapping

  • Sets emitsProposedPlan to true in ClaudeProviderCapabilitiesV2 and installs the permission callback in plan mode so the adapter can intercept ExitPlanMode tool calls, emit a proposed_plan artifact, and keep the session in plan mode.
  • Introduces claudeProviderFailureCause to unwrap the underlying Error from ClaudeAgentSdkQueryRunnerError, so provider failure logs now report the real SDK error cause rather than the wrapper.
  • Retains ActiveClaudeTurnContext for async tasks after a turn finalizes so system.task_notification messages received late are still resolved correctly.
  • Drops non-terminal provider events that fail ingestion normalization in RunExecutionService (logging a warning) while still aborting on root turn.terminal failures.
  • If a query opened with resumeSessionAt fails before any SDK message arrives, the next open for that native thread suppresses the resume pin and logs a warning.

Macroscope summarized eeb5f95.

shivamhwp and others added 2 commits July 5, 2026 07:03
ClaudeAgentSdkQueryRunnerError.message now appends the underlying defect
message (and the failing method) instead of a fixed string, so provider
error cards show the actual reason (missing cwd, spawn failure, resume
failure) via makeProviderFailure's Error-message extraction. The three
run-path warn/error logs that printed 'cause: [Object]' now render
Cause.pretty output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plan-mode Claude sessions were launched without the SDK permission
callback, so ExitPlanMode was never available and the plan interaction
mode could only produce prose — no plan artifact, no Plan Ready state,
no approve flow. Install the callback in plan mode and intercept
ExitPlanMode in canUseTool: emit the proposed plan as v2 plan/node/turn
item artifacts (status active, so hasActionableProposedPlan flips) and
deny the tool call with guidance to end the turn, keeping the session
in plan mode until the user approves from the app. Flip the adapter's
emitsProposedPlan capability accordingly.

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

coderabbitai Bot commented Jul 5, 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

Run ID: eec328b2-d47a-4cde-9a42-ef31a87101c4

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

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Jul 5, 2026
shivamhwp and others added 5 commits July 5, 2026 08:37
… only

Effect service conventions require wrapper messages to come from stable
structural attributes, never from cause.message. Keep the runner error
message structural (method only) and instead unwrap the underlying SDK
Error at the provider-failure boundary, where makeProviderFailure's
existing redaction/bounding pipeline renders it — the error card keeps
showing the actionable cause without the error class stringifying its
defect.

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

A single event that fails ingestNormalized (e.g. a late async-subagent
notification referencing an already-finalized run) used to tear down the
whole Stream.tap pipeline, leaving the session's event routing dead and
the next run wedged at 'starting' with no recovery except a server
restart. Failed non-terminal events are now logged and dropped while the
pipeline keeps draining; root turn.terminal failures still escalate so
the outer fallback finalization is preserved.

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

The SDK reports async Task completion via a system/task_notification
message that usually arrives after the parent turn finalized — when
activeTurn is null — or while an unrelated turn is active, so it was
silently dropped: the subagent projection stayed 'running' forever and
RunExecutionService kept the run's ingestion pipeline waiting on a
child that could never terminalize. Retain the turn contexts of tasks
that are still running when a turn finalizes (keyed by native task id)
and route task_notification to the owning context regardless of the
current active turn, emitting the subagent/node/turn-item updates under
the original run.

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

Resuming a Claude session pins resumeSessionAt to the recorded native
conversation head. After a dirty shutdown the killed process may never
have flushed that head to its transcript, so the first query on the
resumed session fails (and only an unexplained manual retry recovered).
When a pinned query dies before producing any SDK message, mark the
native thread so the next open resumes without the pin — one-shot, so
healthy pins keep working and a failing unpinned open re-suppresses.

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

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

if (event.type === "turn.terminal") {

The Effect.catchCause added around the per-event handler swallows non-turn.terminal failures, but trackChildLifecycle(event) runs after the catch block. When a routed provider_turn.updated or subagent.updated event fails ingestion, it is logged and dropped before trackChildLifecycle executes, so the child is never added to activeChildProviderTurns or activeChildSubagents. After the root terminal arrives, shouldStopProviderEventIngestion then returns true and Stream.takeUntilEffect stops the subscription early, skipping later child events that should still be ingested. Consider moving trackChildLifecycle(event) before the Effect.catchCause wrapper so child lifecycle tracking happens regardless of ingestion failures.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/RunExecutionService.ts around line 678:

The `Effect.catchCause` added around the per-event handler swallows non-`turn.terminal` failures, but `trackChildLifecycle(event)` runs *after* the catch block. When a routed `provider_turn.updated` or `subagent.updated` event fails ingestion, it is logged and dropped before `trackChildLifecycle` executes, so the child is never added to `activeChildProviderTurns` or `activeChildSubagents`. After the root terminal arrives, `shouldStopProviderEventIngestion` then returns `true` and `Stream.takeUntilEffect` stops the subscription early, skipping later child events that should still be ingested. Consider moving `trackChildLifecycle(event)` before the `Effect.catchCause` wrapper so child lifecycle tracking happens regardless of ingestion failures.

@shivamhwp shivamhwp marked this pull request as ready for review July 5, 2026 05:26

@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 2 potential issues.

Fix All in Cursor

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

Reviewed by Cursor Bugbot for commit eeb5f95. Configure here.

if (
failedQuery !== null &&
failedQuery.openedWithResumeSessionAt &&
!failedQuery.receivedSdkMessage

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.

Resume pin suppression never runs

High Severity

The new resumeSessionAt recovery logic isn't working as intended. When a pinned resume query fails, queryContext is cleared prematurely, preventing finalizeActiveTurnAfterQueryExit from registering the failure and updating suppressResumeSessionAt. This causes subsequent resume attempts to fail repeatedly, requiring manual intervention.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit eeb5f95. Configure here.

cause: Cause.pretty(eventCause),
},
),
),

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.

Dropped ingest skips child lifecycle

Medium Severity

When ingestNormalized fails, the new Effect.catchCause swallows the error for non-terminal events but the whole tap body aborts before trackChildLifecycle, so a dropped terminal subagent.updated never clears activeChildSubagents and the root ingest stream can stay blocked after turn.terminal.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit eeb5f95. Configure here.

@macroscopeapp

macroscopeapp Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

1 blocking correctness issue found. This PR introduces significant new runtime behavior including Claude plan mode proposals and error recovery mechanisms. Multiple unresolved review comments identify potential bugs in the new logic, including a high-severity issue with the resume pin suppression mechanism.

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

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

Labels

size:L 100-499 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.

1 participant