Skip to content

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

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

[orchestrator-v2] fix(orchestrator): Surface Claude background wake turns as continuation runs#3752
mwolson wants to merge 2 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.

@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: 59de5253-e970-409b-92d7-355c8d3fb0bf

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 added 2 commits July 6, 2026 22:44
…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. handleSdkMessage
dropped every message of that turn (activeTurn null), and the 30 minute
idle release killed the CLI child before pending tasks could even wake.

Buffer null-activeTurn messages per native thread, request one internal
continuation run per wake (message.dispatch with createdBy agent /
creationSource provider via a new ProviderContinuationRequests queue and
worker), and drain the buffer into the continuation turn without sending
a prompt to the CLI. Pin idle provider sessions while background work is
pending via a new optional hasPendingBackgroundWork runtime capability,
capped by maxIdlePinMs (default 4h).
@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.

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