Skip to content

[orchestrator-v2] fix(orchestrator): Prevent Claude session release from hanging on idle CLI reads#3756

Open
mwolson wants to merge 3 commits into
pingdotgg:t3code/codex-turn-mappingfrom
mwolson:fix/claude-idle-release-hang
Open

[orchestrator-v2] fix(orchestrator): Prevent Claude session release from hanging on idle CLI reads#3756
mwolson wants to merge 3 commits into
pingdotgg:t3code/codex-turn-mappingfrom
mwolson:fix/claude-idle-release-hang

Conversation

@mwolson

@mwolson mwolson commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Stacked on #3752 (which stacks on #3750); the last commit is this PR's delta.

Summary

Releasing an idle Claude provider session could deadlock silently after
removing the in-memory entry: the CLI child process stayed alive
indefinitely, no released event was persisted, the thread projection's
provider session stayed "ready", and nothing was logged. The stale CLI
keeps holding the native session file, which is the likely original
mechanism behind Session ID ... is already in use (#3750 works around that
symptom by forcing --resume; this PR fixes the leak itself).

Discovered while live-verifying #3750/#3752 against a real 30-minute idle
release: the release fired (the entry was deleted), then wedged before
query.close, leaving the CLI orphaned.

Problem and Fix

Problem and Why it Happened Fix
Session scope finalizers run LIFO, so the message-pump fiber (forked into the session scope by openQuery) is interrupted before the closeSession finalizer that calls query.close. Interruption runs Effect's Channel.fromAsyncIterable finalizer Effect.promise(() => iter.return()), and iter is the SDK Query's raw sdkMessages async generator (that is what Query[Symbol.asyncIterator]() returns). return() on a generator suspended at an internal await (reading the next message from an idle CLI) queues behind the in-flight read forever, so the scope close never completes and query.close is never reached. claudeQueryMessages hands the stream an iterable whose [Symbol.asyncIterator]() returns the Query itself. The SDK's Query.return() runs cleanup() first, which closes the transport, terminates the CLI child, and unblocks the pending read, so interruption completes and the remaining finalizers run. Query.next() already delegates to sdkMessages.next(), so streaming behavior is unchanged.

Defensive Fixes

Problem and Why it Happened Fix
Any wedged session-scope finalizer parks releaseEntry forever with no released events and no log line, leaving the projection stuck on "ready". releaseEntry forks the scope close (Effect.forkDetach({ startImmediately: true })) and joins with a 30s timeout. On timeout it logs provider-session-scope-close-timeout, attaches a detached observer that logs late completion or failure, and persists the released events regardless. startImmediately keeps well-behaved synchronous finalizer stacks completing on the same tick, and close failures still propagate after event writes exactly as before.

Known limitations

  • The 30s timeout is a diagnosable backstop, not a substitute for cleanup:
    if a close still wedges (a future adapter regression), the projection
    shows "stopped" while the provider process may linger; the
    provider-session-scope-close-timeout warning is the operational signal.
    Close failures that finish after the timeout are logged by the detached
    observer rather than surfaced as ProviderSessionReleaseError.
  • Pre-existing race, slightly widened only in the wedged case: the release
    step revokes MCP credentials thread-wide, so a replacement provider
    session for the same thread opened during the close window could have its
    fresh credentials revoked when the old release finishes. The window
    already existed (entry removal precedes close and MCP cleanup); per-entry
    credential ownership is a candidate follow-up. With the root-cause fix in
    place the Claude close path no longer wedges, so hitting the widened
    window requires a new bug.

Validation

  • vp check: 0 errors; vp run typecheck: exit 0 (full monorepo)
  • ClaudeAdapterV2.test.ts: 20/20, including a new regression test that
    interrupts the message stream while a read is in flight; with the wrapper
    reverted to the raw generator the test deadlocks and times out at 60s
    (verified during development)
  • ProviderSessionManager.test.ts: 19/19, including a new test where a
    hanging session-scope finalizer no longer blocks release: released events
    persist, and the wedged finalizer is confirmed via the adapter close count
    staying 0
  • ClaudeReplayFixtures.integration.test.ts: 4 pass / 1 skipped;
    OrchestratorReplayFixtures.integration.test.ts -t "claude": 16 pass /
    53 skipped

Note

High Risk
Touches Claude session lifecycle, idle release, and turn/continuation ordering in orchestration-v2; regressions could orphan CLI processes, drop wake output, or release sessions while background work is still pending.

Overview
Fixes silent hangs when releasing idle Claude provider sessions: the message stream now iterates the SDK Query (via claudeQueryMessages) so interruption runs transport cleanup instead of deadlocking on the raw generator’s return(), and ProviderSessionManager times out scope close (30s), still persists released events, and logs when cleanup wedges.

Adds Claude background “wake turn” handling: SDK messages with no active turn are buffered, a single ProviderContinuationRequest is offered, and a worker dispatches a provider-originated run that replays buffered output (no duplicate CLI prompt). User turns that race a wake queue behind active work; hasPendingBackgroundWork defers idle release with a pin cap and re-validates idle generation before release.

Resumes the native CLI session when providerTurnOrdinal > 1 so a fresh manager instance after idle release does not reopen with a fixed session id (“already in use”). Extensive adapter and session-manager tests cover wake flows, hung close, and idle pinning.

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

Note

Fix Claude session release hanging on idle CLI reads by introducing background wake turn buffering

  • Fixes a deadlock where session scope close could hang indefinitely if the SDK message stream was interrupted mid-read; scope close is now time-boxed with a timeout and late completion is logged.
  • Introduces claudeQueryMessages in ClaudeAdapterV2.ts to iterate the Claude Query runtime (not the raw generator), ensuring Query.return() runs transport cleanup before the in-flight read.
  • Adds background wake turn detection in the Claude adapter: wake output is buffered outside active turns, a single ProviderContinuationRequest is issued per wake event, and a provider-authored continuation turn drains buffered messages without re-prompting the CLI.
  • Adds a new ProviderContinuationService.ts worker that consumes continuation requests and dispatches internal messages via ThreadManagementService to trigger continuation runs.
  • Idle session release in ProviderSessionManager.ts is deferred while hasPendingBackgroundWork is true, capped by maxIdlePinMs to prevent indefinite pinning; idle generation guards prevent races with newly busy sessions.
  • Risk: scope close timeout means adapter cleanup may complete after session release is recorded; late-completing finalizers are logged but not retried.

Macroscope summarized 90e67c3.

@coderabbitai

coderabbitai Bot commented Jul 7, 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: e61c9815-e0a0-4a51-92c3-38f59b521658

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 7, 2026
Comment thread apps/server/src/orchestration-v2/ProviderSessionManager.ts
const idAllocator = yield* IdAllocatorV2;
const queryRunner = yield* ClaudeAgentSdkQueryRunner;
const serverConfig = yield* ServerConfig;
const continuationRequests = yield* ProviderContinuationRequests;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High Adapters/ClaudeAdapterV2.ts:3829

ClaudeAdapterV2Driver.create reads ProviderContinuationRequests from the context, but this is a Context.Reference whose default drops all offers. In production, providerAdapterRegistryLayerFromProviderInstances does not provide providerContinuationRequestsLayer, so the driver receives the default sink. Every Claude adapter then stores offer: () => Effect.void, and all background-task wake turns are silently dropped instead of reaching ProviderContinuationService — the continuation mechanism never runs outside tests. Make the production registry layer provide providerContinuationRequestsLayer, or change the driver to require a non-default implementation so the missing provider fails loudly instead of silently dropping work.

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

`ClaudeAdapterV2Driver.create` reads `ProviderContinuationRequests` from the context, but this is a `Context.Reference` whose default drops all offers. In production, `providerAdapterRegistryLayerFromProviderInstances` does not provide `providerContinuationRequestsLayer`, so the driver receives the default sink. Every Claude adapter then stores `offer: () => Effect.void`, and all background-task wake turns are silently dropped instead of reaching `ProviderContinuationService` — the continuation mechanism never runs outside tests. Make the production registry layer provide `providerContinuationRequestsLayer`, or change the driver to require a non-default implementation so the missing provider fails loudly instead of silently dropping work.

@mwolson mwolson force-pushed the fix/claude-idle-release-hang branch from f0bd0f5 to e94b5f4 Compare July 7, 2026 01:51

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 lastActivityAtMs = yield* Clock.currentTimeMillis;

releaseIfStillIdle reuses the stale pinnedSinceMs when re-arming the idle timer after deferring for pending background work. Activity paths that go through touchActivity or markIdle (e.g. respondToRuntimeRequest, steerTurn, interruptTurn, or provider events) never reset pinnedSinceMs — only markBusy clears it. So a session that had pending work, then later receives fresh activity without ever becoming busy, will still carry the original pin timestamp. The next idle check sees now - pinnedSinceMs >= maxIdlePinMs and immediately releases the session, even though it had recent activity and still reports pending background work. Resetting pinnedSinceMs to null in touchActivity would clear the stale pin on fresh activity.

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

`releaseIfStillIdle` reuses the stale `pinnedSinceMs` when re-arming the idle timer after deferring for pending background work. Activity paths that go through `touchActivity` or `markIdle` (e.g. `respondToRuntimeRequest`, `steerTurn`, `interruptTurn`, or provider events) never reset `pinnedSinceMs` — only `markBusy` clears it. So a session that had pending work, then later receives fresh activity without ever becoming `busy`, will still carry the original pin timestamp. The next idle check sees `now - pinnedSinceMs >= maxIdlePinMs` and immediately releases the session, even though it had recent activity and still reports pending background work. Resetting `pinnedSinceMs` to `null` in `touchActivity` would clear the stale pin on fresh activity.

},
);

yield* requests.take.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/ProviderContinuationService.ts:56

The worker loop permanently drops a continuation request whenever dispatchContinuation fails. requests.take removes the item from the queue, but the Effect.catchCause handler only logs the failure and never retries or re-enqueues it — so the buffered provider wake event is lost and never ingested as a run. This is reachable via the queue_after_active dispatch path, which rejects dispatch while a thread has pending merge-back transfers. Consider re-enqueuing the request (or retrying with backoff) before logging so the wake event survives a transient rejection.

Also found in 1 other location(s)

apps/server/src/orchestration-v2/ProviderSessionManager.ts:44

Using the new RELEASE_SCOPE_CLOSE_TIMEOUT_MS causes releaseEntry/close() to report success after 30s even when Scope.close(entry.scope, Exit.void) later fails. Once Effect.timeoutOption(...) returns None, the code only logs the eventual Exit in a detached observer and never re-raises it as ProviderSessionReleaseError/ProviderSessionCloseError, so callers cannot detect adapter cleanup failures anymore on any close path that finishes after the timeout.

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

The worker loop permanently drops a continuation request whenever `dispatchContinuation` fails. `requests.take` removes the item from the queue, but the `Effect.catchCause` handler only logs the failure and never retries or re-enqueues it — so the buffered provider wake event is lost and never ingested as a run. This is reachable via the `queue_after_active` dispatch path, which rejects dispatch while a thread has pending merge-back transfers. Consider re-enqueuing the request (or retrying with backoff) before logging so the wake event survives a transient rejection.

Also found in 1 other location(s):
- apps/server/src/orchestration-v2/ProviderSessionManager.ts:44 -- Using the new `RELEASE_SCOPE_CLOSE_TIMEOUT_MS` causes `releaseEntry`/`close()` to report success after 30s even when `Scope.close(entry.scope, Exit.void)` later fails. Once `Effect.timeoutOption(...)` returns `None`, the code only logs the eventual `Exit` in a detached observer and never re-raises it as `ProviderSessionReleaseError`/`ProviderSessionCloseError`, so callers cannot detect adapter cleanup failures anymore on any close path that finishes after the timeout.

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-idle-release-hang branch from e94b5f4 to 6c7e27e Compare July 7, 2026 02:44
@mwolson mwolson force-pushed the fix/claude-idle-release-hang branch from 6c7e27e to 90e67c3 Compare July 7, 2026 02:48
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 discards the providerThreadId and driver from the ProviderContinuationRequest, dispatching a generic message.dispatch against the thread's current routing. If the thread's active provider changes before the worker drains the request (e.g. after a provider switch or handoff), the synthetic wake turn starts on the wrong provider thread while the buffered provider-native wake messages remain attached to the original provider thread and are never ingested. Consider passing request.providerThreadId and request.driver through to threads.dispatch so the continuation targets the correct provider.

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

`dispatchContinuation` discards the `providerThreadId` and `driver` from the `ProviderContinuationRequest`, dispatching a generic `message.dispatch` against the thread's current routing. If the thread's active provider changes before the worker drains the request (e.g. after a provider switch or handoff), the synthetic wake turn starts on the wrong provider thread while the buffered provider-native wake messages remain attached to the original provider thread and are never ingested. Consider passing `request.providerThreadId` and `request.driver` through to `threads.dispatch` so the continuation targets the correct 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.

🟡 Medium

const scheduleIdleReleaseInternal = (providerSessionId: ProviderSessionId) =>

When a session has pending background work, releaseIfStillIdle calls scheduleIdleReleaseInternal, which immediately calls cancelIdleFiber(entry.idleFiber). Because entry.idleFiber still points to the currently running idle-release fiber, the fiber interrupts itself and then joins its own interruption, deadlocking forever. Any session that defers idle release for pending background work will wedge instead of rescheduling. Consider passing cancelIdleFiber: false to the reschedule path (or skipping the cancel when the current fiber is the idle fiber) so the self-interrupt does not happen.

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

When a session has pending background work, `releaseIfStillIdle` calls `scheduleIdleReleaseInternal`, which immediately calls `cancelIdleFiber(entry.idleFiber)`. Because `entry.idleFiber` still points to the currently running idle-release fiber, the fiber interrupts itself and then joins its own interruption, deadlocking forever. Any session that defers idle release for pending background work will wedge instead of rescheduling. Consider passing `cancelIdleFiber: false` to the reschedule path (or skipping the cancel when the current fiber is the idle fiber) so the self-interrupt does not happen.

@mwolson mwolson marked this pull request as ready for review July 7, 2026 12:21

@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.

Fix All in Cursor

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

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 90e67c3. Configure here.

providerSessionId: input.providerSessionId,
reason: "idle_timeout",
cancelIdleFiber: false,
onlyIfIdleGeneration: input.generation,

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.

Idle release stale pending check

High Severity

releaseIfStillIdle decides not to defer when hasPendingBackgroundWork is false, then calls releaseEntry without re-checking pending work. Wake output can land in the adapter buffer after that read but before the session is torn down, so idle release can drop a live Claude session while wake messages and a queued continuation are still in memory.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 90e67c3. 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 errors imply none

Medium Severity

When hasPendingBackgroundWork fails, releaseIfStillIdle treats the session as having no pending work via Effect.catchCause(() => Effect.succeed(false)), so idle release can proceed while wake buffers or background tasks may still be active.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 90e67c3. Configure here.

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.

Failed continuation blocks retry

Medium Severity

When a wake is detected, the Claude adapter records the native thread in requestedContinuations before enqueueing a continuation request. If ProviderContinuationService later skips or fails dispatch (for example archived thread, orchestration error, or logged dispatch-failed), nothing clears that flag or sends another request. Further wake output stays buffered, hasPendingBackgroundWork remains true, and the wake may never run until idle pin expiry or session release.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 90e67c3. Configure here.

@macroscopeapp

macroscopeapp Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

5 blocking correctness issues found. This PR introduces significant new capability (background task wake handling, session pinning, continuation service) beyond a simple fix. Multiple unresolved review comments identify high-severity issues including production misconfiguration causing silent drops and potential deadlocks in the idle release path.

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