Skip to content

[orchestrator-v2] fix(orchestrator): Surface Claude background wake turns as continuation runs#3752

Closed
mwolson wants to merge 3 commits into
pingdotgg:t3code/codex-turn-mappingfrom
mwolson:fix/claude-wake-turns
Closed

[orchestrator-v2] fix(orchestrator): Surface Claude background wake turns as continuation runs#3752
mwolson wants to merge 3 commits into
pingdotgg:t3code/codex-turn-mappingfrom
mwolson:fix/claude-wake-turns

Conversation

@mwolson

@mwolson mwolson commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Background tasks started inside a Claude turn (run_in_background Bash)
currently vanish from t3code when they settle: the CLI's post-settle wake
turn is silently dropped, so the displayed thread diverges from the real
native transcript, and the 30 minute idle release kills the CLI (and the
pending task) before it can even wake. This PR ingests wake turns as
first-class continuation runs and keeps the provider session pinned while
background work is pending.

Stacked on #3750 (fix/claude-session-resume); one commit on
fix/claude-wake-turns above it.

Problem and Fix

Problem and Why it Happened Fix
When a background task settles, the Claude CLI re-invokes itself and streams a whole new turn (task notification, assistant response, result) over the still-open SDK query. ClaudeAdapterV2.handleSdkMessage early-returns when activeTurn is null, so every message of the wake turn is dropped. Null-activeTurn messages now buffer per native thread when they are wake evidence (assistant/user/result, or a task notification for a tracked pending task, with or without a summary). A pending-task notification or result offers one continuation request (deduped per wake) to a new ProviderContinuationRequests queue; a worker (ProviderContinuationService) dispatches an internal message.dispatch (createdBy: "agent", creationSource: "provider", queue_after_active), so the wake flows through the normal run pipeline, starting immediately on an idle thread and queueing behind any active run. The continuation turn sends no prompt to the CLI and instead drains the buffered wake messages into itself (results replayed last; a result finalizes the turn).
ProviderSessionManager idle-releases the provider session after 30 minutes. Closing the SDK query kills the CLI child, and any pending background task dies with it (observed: a task notification flushed to the transcript 13ms after query.close, with no response). The adapter exposes an optional hasPendingBackgroundWork runtime capability (pending local_bash tasks, non-empty wake buffer, or outstanding continuation request). releaseIfStillIdle defers the idle_timeout release while it reports true, re-arming the timer, with cumulative deferral capped by a new maxIdlePinMs option (default 4h) so a hung task cannot pin a session forever. Other release reasons are unchanged.

Design notes:

  • The idle-release recheck is guarded atomically: hasPendingBackgroundWork
    yields to the adapter between the idle check and the release, so
    releaseEntry now takes onlyIfIdleGeneration and revalidates
    busyCount/idleGeneration inside its atomic entry removal, making a release
    of a session that turned busy during the check structurally impossible
    (the existing interrupt-on-activity path already covered every
    deterministic interleaving; this closes the preemption-timing residue).
  • Races: a user message that beats the continuation dispatch just makes the
    continuation queue behind the user run (queue_after_active; Claude sets
    supportsQueuedMessages); the user turn leaves the wake buffer intact and
    the continuation drains it afterwards with correct attribution. A
    continuation turn that finds an empty buffer settles immediately as a
    completed run with no items.
  • Background local_bash tasks no longer render as subagent nodes; their
    lifetime is tracked from task_started to terminal task_notification
    in a session-scoped registry (the durable cross-turn version of the
    previous per-turn ignoredTaskIds).
  • CommandPolicy is intentionally unchanged: a client forging
    creationSource: "provider" just produces a continuation turn that
    attaches to an empty buffer and settles instantly.
  • Other adapters are untouched; the new runtime capability and the
    continuation sink are optional with no-op defaults.

Validation

  • vp check: pass
  • vp run typecheck: pass (full monorepo)
  • vp test apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts:
    19/19, including 5 new wake-turn tests (buffer + single deduped
    continuation request with correct routing and detail; drain into a
    continuation turn with no CLI prompt and the wake result surfacing as
    assistant output; racing user turn leaving the buffer for the queued
    continuation; null-summary notification still clearing the pending-task
    registry; spurious continuation settling immediately)
  • Independently reviewed pre-fix by grok (composer-2.5) and codex (gpt-5.5);
    all Medium findings addressed (null-summary notifications, queued-run
    race, user-drain attribution), remainder documented below
  • vp test apps/server/src/orchestration-v2/ProviderSessionManager.test.ts:
    18/18, including 3 new tests (idle release deferred while background work
    is pending, pinned session released once maxIdlePinMs expires, and a
    turn starting during the pending-work check never losing the session)
  • ClaudeReplayFixtures.integration.test.ts and
    OrchestratorReplayFixtures.integration.test.ts (claude fixtures): pass

Known limitations

  • Background tasks do not survive a server restart: the CLI child dies with
    the server and the wake is lost (out of scope; nothing the orchestrator
    can do server-side).
  • Wake traffic arriving while a normal turn is active ingests into that
    active turn, exactly as today (the over-settle family tracked around
    [orchestrator-v2] fix(orchestrator): Harden Grok v2 settlement and steer message visibility #3578); this PR only changes the previously-dropped null-activeTurn
    path.
  • If the pin cap expires with a task still running, the session releases as
    today and that wake is lost (same outcome as before, minus up to 4 hours).
  • A continuation dropped by the worker (archived thread, or a transient
    dispatch failure) is logged but not retried; the wake buffer then sits
    until a later turn drains it or the pin cap releases the session.
  • hasPendingBackgroundWork checks the pending-task registry and wake
    buffer but not the outstanding-continuation flag; the dispatch window is
    covered by the run's own busy pin.
  • The continuation worker is wired in the production layer only and has no
    dedicated unit tests; its behavior is covered indirectly by the adapter
    tests plus the trivial dispatch body.

Note

Medium Risk
Changes orchestration turn lifecycle, session idle release timing, and Claude SDK message handling; incorrect buffering or release could drop wake output or keep sessions pinned longer than intended.

Overview
Claude background wake turns are no longer dropped when the SDK streams messages with no active provider turn. ClaudeAdapterV2 buffers wake evidence per native thread, emits one deduped continuation request, and provider-synthesized runs (createdBy: "agent", creationSource: "provider") drain that buffer without sending a prompt to the CLI. local_bash background tasks are tracked session-wide so they do not render as subagent nodes, and native queries resume when providerTurnOrdinal > 1 so idle-released sessions do not reopen with a conflicting session id.

A new ProviderContinuationRequests queue and ProviderContinuationService worker dispatch internal message.dispatch with queue_after_active, wired through production runtimeLayer and shared adapter infrastructure. ProviderSessionManager defers idle_timeout release while optional hasPendingBackgroundWork is true, with maxIdlePinMs (default 4h) and an onlyIfIdleGeneration guard so release cannot race a session that became busy during the check.

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

Note

Surface Claude background wake turns as continuation runs in orchestrator-v2

  • Adds a ProviderContinuationRequests queue and a background worker in ProviderContinuationService that drains continuation requests and dispatches internal message.dispatch events to enqueue continuation runs with createdBy: 'agent' and creationSource: 'provider'.
  • Updates ClaudeAdapterV2 to buffer SDK wake messages when no active turn exists, emit a single continuation request per wake, drain buffered messages into a provider-synthesized continuation turn, and resume native sessions when a prior provider turn exists.
  • Extends ProviderSessionManager to defer idle session release while hasPendingBackgroundWork is true, bounded by a maxIdlePinMs cap, with a generation guard to prevent stale releases.
  • Wires the continuation queue and worker into the production stack via runtimeLayer.ts and ProviderOrchestrationAdapterInfrastructure.
  • Risk: idle sessions now stay open longer than idleTimeoutMs when background work is pending; the pin cap (DEFAULT_MAX_IDLE_PIN_MS) bounds the maximum deferral window.

Macroscope summarized 10bd126.

@coderabbitai

coderabbitai Bot commented Jul 6, 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: 25b80a47-651c-4ce6-a5b0-adaaeefc3271

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:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Jul 6, 2026
ordinal: projection.messages.length + 1,
});
const commandId = CommandId.make(`provider-continuation:${messageId}`);
yield* threads.dispatch({

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/ProviderContinuationService.ts:42

dispatchContinuation builds the message.dispatch without passing request.providerThreadId or request.driver, so the orchestrator resolves the target provider thread from the thread's current active/model state. If the app thread has switched providers or has no active provider thread when the wake arrives, the continuation run starts on the wrong (or empty) native thread and the buffered wake messages are drained from an unrelated buffer — the wake turn silently vanishes. Consider propagating providerThreadId and driver into the dispatch payload so the run targets the originating native thread.

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

`dispatchContinuation` builds the `message.dispatch` without passing `request.providerThreadId` or `request.driver`, so the orchestrator resolves the target provider thread from the thread's current active/model state. If the app thread has switched providers or has no active provider thread when the wake arrives, the continuation run starts on the wrong (or empty) native thread and the buffered wake messages are drained from an unrelated buffer — the wake turn silently vanishes. Consider propagating `providerThreadId` and `driver` into the dispatch payload so the run targets the originating native thread.

Comment thread apps/server/src/orchestration-v2/ProviderSessionManager.ts
updated.set(key, { ...latestEntry, pinnedSinceMs });
return updated;
});
yield* scheduleIdleReleaseInternal(input.providerSessionId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High orchestration-v2/ProviderSessionManager.ts:588

The pin-deferral path in releaseIfStillIdle deadlocks: when hasPendingBackgroundWork is true and the pin hasn't expired, it calls scheduleIdleReleaseInternal, which calls cancelIdleFiber(entry.idleFiber). That idleFiber is the fiber currently executing releaseIfStillIdle, so Fiber.interrupt interrupts the current fiber and then awaits it — a self-interrupt/self-await that never resolves. The replacement idle timer is never scheduled, so a session with pending background work stays pinned forever after the first idle timeout, bypassing maxIdlePinMs and leaking the provider process until shutdown.

The re-arm should fork the new timer first and update entry.idleFiber before cancelling the old fiber, or the cancellation should skip the current fiber.

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

The pin-deferral path in `releaseIfStillIdle` deadlocks: when `hasPendingBackgroundWork` is true and the pin hasn't expired, it calls `scheduleIdleReleaseInternal`, which calls `cancelIdleFiber(entry.idleFiber)`. That `idleFiber` is the fiber currently executing `releaseIfStillIdle`, so `Fiber.interrupt` interrupts the current fiber and then awaits it — a self-interrupt/self-await that never resolves. The replacement idle timer is never scheduled, so a session with pending background work stays pinned forever after the first idle timeout, bypassing `maxIdlePinMs` and leaking the provider process until shutdown.

The re-arm should fork the new timer first and update `entry.idleFiber` before cancelling the old fiber, or the cancellation should skip the current fiber.

@mwolson mwolson force-pushed the fix/claude-wake-turns branch from 5ebd8b9 to 6054588 Compare July 7, 2026 02:44

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

const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* () {

makeDefaultClaudeAdapterV2 reads ProviderContinuationRequests from the context, but since it is a Context.Reference with a no-op default (offer: () => Effect.void), any caller that provides ClaudeAdapterV2.layer without also supplying the live continuation queue captures the no-op. In that configuration Claude background wake turns are silently dropped, defeating the continuation behavior this PR introduces. Consider adding ProviderContinuationRequests to layer's environment type so the live queue must be provided, or falling back to the live implementation inside the adapter when the default is detected.

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

`makeDefaultClaudeAdapterV2` reads `ProviderContinuationRequests` from the context, but since it is a `Context.Reference` with a no-op default (`offer: () => Effect.void`), any caller that provides `ClaudeAdapterV2.layer` without also supplying the live continuation queue captures the no-op. In that configuration Claude background wake turns are silently dropped, defeating the continuation behavior this PR introduces. Consider adding `ProviderContinuationRequests` to `layer`'s environment type so the live queue must be provided, or falling back to the live implementation inside the adapter when the default is detected.

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

const scheduleIdleReleaseInternal = (providerSessionId: ProviderSessionId) =>

When hasPendingBackgroundWork returns true and the pin cap has not expired, releaseIfStillIdle calls scheduleIdleReleaseInternal, which sleeps for the full idleTimeoutMs before checking again. With idleTimeoutMs=30m and maxIdlePinMs=5m, the session can stay pinned up to ~60 minutes instead of the intended ~35-minute ceiling — the maxIdlePinMs cap is only evaluated once per idle interval rather than as a hard deadline. Consider scheduling the re-check with min(idleTimeoutMs, maxIdlePinMs - elapsed) so the pin cap acts as a true upper bound on deferral time.

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

When `hasPendingBackgroundWork` returns true and the pin cap has not expired, `releaseIfStillIdle` calls `scheduleIdleReleaseInternal`, which sleeps for the full `idleTimeoutMs` before checking again. With `idleTimeoutMs=30m` and `maxIdlePinMs=5m`, the session can stay pinned up to ~60 minutes instead of the intended ~35-minute ceiling — the `maxIdlePinMs` cap is only evaluated once per idle interval rather than as a hard deadline. Consider scheduling the re-check with `min(idleTimeoutMs, maxIdlePinMs - elapsed)` so the pin cap acts as a true upper bound on deferral time.

const hasPendingWork =
entry.runtime.hasPendingBackgroundWork === undefined
? false
: yield* entry.runtime.hasPendingBackgroundWork.pipe(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium orchestration-v2/ProviderSessionManager.ts:568

releaseIfStillIdle swallows all failures from entry.runtime.hasPendingBackgroundWork with Effect.catchCause(() => Effect.succeed(false)), so any adapter defect or unexpected cancellation — not just the benign idle-fiber interrupt race — causes the manager to treat pending background work as absent and proceed to releaseEntry, closing the session and killing the very background task this feature is meant to preserve. Consider narrowing the catch to only the interrupt-race cause (or distinguishing Interrupt from other failures) so genuine probe failures do not trigger premature session release.

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

`releaseIfStillIdle` swallows all failures from `entry.runtime.hasPendingBackgroundWork` with `Effect.catchCause(() => Effect.succeed(false))`, so any adapter defect or unexpected cancellation — not just the benign idle-fiber interrupt race — causes the manager to treat pending background work as absent and proceed to `releaseEntry`, closing the session and killing the very background task this feature is meant to preserve. Consider narrowing the catch to only the interrupt-race cause (or distinguishing `Interrupt` from other failures) so genuine probe failures do not trigger premature session release.

@mwolson mwolson force-pushed the fix/claude-wake-turns branch from 6054588 to 9bc390d Compare July 8, 2026 03:35
providerSessionId: input.providerSessionId,
pinnedForMs: now - pinnedSinceMs,
});
yield* Ref.update(sessions, (latest) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High orchestration-v2/ProviderSessionManager.ts:579

When hasPendingBackgroundWork is slow, the deferred-pin branch in releaseIfStillIdle writes pinnedSinceMs back into the session entry without revalidating idleGeneration. If the session goes busy and returns to idle during that check, markBusy clears pinnedSinceMs for the new turn, but this stale callback overwrites it with the old timestamp. The new idle period then inherits the previous pin age, so maxIdlePinMs is reached prematurely and the session is released while background work is still running. Consider capturing the generation and skipping the pinnedSinceMs write when the entry's idleGeneration no longer matches.

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

When `hasPendingBackgroundWork` is slow, the deferred-pin branch in `releaseIfStillIdle` writes `pinnedSinceMs` back into the session entry without revalidating `idleGeneration`. If the session goes busy and returns to idle during that check, `markBusy` clears `pinnedSinceMs` for the new turn, but this stale callback overwrites it with the old timestamp. The new idle period then inherits the previous pin age, so `maxIdlePinMs` is reached prematurely and the session is released while background work is still running. Consider capturing the generation and skipping the `pinnedSinceMs` write when the entry's `idleGeneration` no longer matches.

@mwolson mwolson marked this pull request as ready for review July 8, 2026 16:52
providerThreadId: route.providerThreadId,
driver: CLAUDE_PROVIDER,
detail,
});

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.

Dedup blocks wake after dispatch fails

Medium Severity

When a continuation request fails to dispatch (e.g., for an archived thread), the requestedContinuations flag isn't cleared, and wakeBuffers remain undrained. This prevents future wake events on that native thread from triggering new continuations, effectively stranding buffered wake output.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9bc390d. Configure here.

// dropped as before instead of triggering a spurious continuation.
const isPendingTaskNotification =
isNotification && (yield* Ref.get(pendingBackgroundTaskIds)).has(message.task_id);
const isWakeEvidence =

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.

Pending task id survives result-only drain

Medium Severity

pendingBackgroundTaskIds is populated on task_started for local_bash work but is only removed when a task_notification is handled in an active turn (including replay). A continuation that drains a wake buffer containing only a result (no buffered notification) can complete successfully while the task id remains in the set, so hasPendingBackgroundWork may stay true and idle release stays deferred after the wake was already ingested.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9bc390d. Configure here.

@macroscopeapp

macroscopeapp Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

6 blocking correctness issues found. This PR introduces new capability for Claude background wake turns with new services, state management, and idle release deferral logic. Multiple unresolved review comments identify potential deadlocks and race conditions in the session manager and adapter code.

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

mwolson added 2 commits July 9, 2026 00:45
Introduce a provider-agnostic continuation request queue, worker, and optional
hasPendingBackgroundWork idle pin so adapters can surface post-settle native
wake traffic as first-class runs. No adapter offers continuations yet; behavior
is unchanged until Claude or Grok specialty commits wire attach mode.
…on runs

When a background task (run_in_background Bash) settles, the Claude CLI streams
a whole new turn over the still-open SDK query. Buffer null-activeTurn wake
messages, request one internal continuation run per wake, and drain the buffer
in attach mode without re-prompting the CLI. Report pending background work so
idle release stays pinned until the wake is ingested.
@mwolson mwolson force-pushed the fix/claude-wake-turns branch from 9bc390d to 10bd126 Compare July 9, 2026 14:41

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

There are 5 total unresolved issues (including 2 from previous reviews).

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 10bd126. Configure here.

const message = input.message;
const context = yield* Ref.get(activeTurn);
if (context === null) {
yield* bufferWakeMessage({ nativeThreadId: liveQuery.nativeThreadId, message });

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.

Wake task_started messages ignored

Medium Severity

With no active provider turn, every SDK frame goes through bufferWakeMessage, which only keeps assistant, user, result, or tracked task_notification frames. task_started for another local_bash job during that wake window is dropped, so the session never registers the task and later notifications are not treated as wake evidence.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 10bd126. Configure here.

? false
: yield* entry.runtime.hasPendingBackgroundWork.pipe(
Effect.catchCause(() => Effect.succeed(false)),
);

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.

Pending work check hides failures

Medium Severity

During idle release, hasPendingBackgroundWork is wrapped in Effect.catchCause that returns false on any failure. A defect or error in the adapter check is treated as “no pending work,” so the manager can idle-release the provider session while wake buffers or background tasks are still outstanding.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 10bd126. Configure here.

dispatchMode: { type: "queue_after_active" },
createdBy: "agent",
creationSource: "provider",
});

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.

Continuation ignores wake provider thread

Medium Severity

ProviderContinuationService records providerThreadId on the wake request but dispatches only threadId. Queued continuation runs bind to the thread’s active provider thread, not the route stored when the wake was buffered, so a provider switch before dispatch can start a continuation on a different session with an empty wake buffer.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 10bd126. Configure here.

@mwolson

mwolson commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #3860, which consolidates #3750, #3752, and #3756 into a single PR (shared continuation plumbing commit + one Claude specialty commit, mirroring #3578). Content is byte-identical to the tip of #3756.

@mwolson mwolson closed this Jul 10, 2026
@mwolson mwolson deleted the fix/claude-wake-turns branch July 10, 2026 17:17
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:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant