fix(claude): guard completed agent resumes - #9130
PlunderStruck wants to merge 3 commits into
Conversation
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR adds a cross-layer Claude runtime workflow that automatically intercepts stopped-agent messages, prompts the user, and persists new resume state; it is a substantive behavior change rather than a small isolated fix. An unresolved high-severity race in the new user-input flow can leave the provider hook waiting indefinitely. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9eb7e93. Configure here.
461f220 to
5830f3e
Compare
| ? `the finished “${agentType}” sub-agent` | ||
| : "a finished sub-agent"; | ||
| const question = `Claude wants to send another message to ${agentLabel}. Claude Code resumes that agent with its previous conversation and may have to process all of that history again. How should Claude continue?`; | ||
| const result = yield* handleAskUserQuestion( |
There was a problem hiding this comment.
🟠 High Layers/ClaudeAdapter.ts:4272
handleCompletedSubagentMessageHook can leave a completed-agent SendMessage blocked indefinitely after the client responds: respondToUserInput sees Unknown pending user-input request and the hook's deferred is never resolved. handleAskUserQuestion emits user-input.requested before inserting the request into pendingUserInputs, so register the request before publishing the event.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ClaudeAdapter.ts around line 4272:
`handleCompletedSubagentMessageHook` can leave a completed-agent `SendMessage` blocked indefinitely after the client responds: `respondToUserInput` sees `Unknown pending user-input request` and the hook's deferred is never resolved. `handleAskUserQuestion` emits `user-input.requested` before inserting the request into `pendingUserInputs`, so register the request before publishing the event.
5830f3e to
28d2fef
Compare
| providerInstanceId: source.instanceId, | ||
| }), | ||
| ), | ||
| Effect.catchCause((cause) => |
There was a problem hiding this comment.
🟡 Medium Layers/ProviderService.ts:373
Effect.catchCause converts cancellation of updateResumeCursorIfCurrentInstance into a successful warning, so a cancelled event consumer continues to publishRuntimeEvent during shutdown. Preserve interruption causes and recover only expected persistence failures.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderService.ts around line 373:
`Effect.catchCause` converts cancellation of `updateResumeCursorIfCurrentInstance` into a successful warning, so a cancelled event consumer continues to `publishRuntimeEvent` during shutdown. Preserve interruption causes and recover only expected persistence failures.
| last_seen_at = ${lastSeenAt}, | ||
| resume_cursor_json = ${resumeCursor} | ||
| WHERE thread_id = ${threadId} | ||
| AND provider_instance_id = ${providerInstanceId} |
There was a problem hiding this comment.
🟡 Medium persistence/ProviderSessionRuntime.ts:243
Cursor updates for rehydrated legacy sessions always return false, so their stopped-agent cursor state is never persisted. Legacy rows keep provider_instance_id = NULL, and SQL NULL = <default instance id> does not match the WHERE clause; allow the legacy NULL case to match the supplied instance ID.
| AND provider_instance_id = ${providerInstanceId} | |
| AND (provider_instance_id = ${providerInstanceId} OR provider_instance_id IS NULL) |
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/persistence/ProviderSessionRuntime.ts around line 243:
Cursor updates for rehydrated legacy sessions always return `false`, so their stopped-agent cursor state is never persisted. Legacy rows keep `provider_instance_id = NULL`, and SQL `NULL = <default instance id>` does not match the `WHERE` clause; allow the legacy `NULL` case to match the supplied instance ID.
There was a problem hiding this comment.
🟡 Medium
A normal answer leaves onAbort registered on the shared turn signal, so every completed stopped-agent prompt retains its closure until the signal aborts and listener/memory usage grows over time. Remove the listener after Deferred.await(answersDeferred) completes; once: true only cleans it up on abort.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ClaudeAdapter.ts around line 4106:
A normal answer leaves `onAbort` registered on the shared turn signal, so every completed stopped-agent prompt retains its closure until the signal aborts and listener/memory usage grows over time. Remove the listener after `Deferred.await(answersDeferred)` completes; `once: true` only cleans it up on abort.

Problem
Claude can call
SendMessageafter a sub-agent has stopped. Claude Code then restores that worker's prior conversation, making a small follow-up capable of reprocessing a very large child history.This is a narrow, measured failure mode rather than a claim that every T3 sub-agent costs more. In one substantial T3 transcript, nine one-shot agents produced 570 model calls, 1.63M cache-create tokens, and no large cache rewrites. Five repeatedly resumed agents produced 2,692 calls, 13.48M cache-create tokens, and all 19 large rewrites. Individual post-resume rewrites reached roughly 622K–751K tokens while producing only a few output tokens.
Fix
SubagentStartandSubagentStophooks.SendMessagewithPreToolUse, which still runs when full-access usesbypassPermissions.Starting fresh denies the resume with instructions for Claude to launch a new bounded agent using a concise handoff. The existing
session.configuredruntime contract gains one optional opaque cursor field; no UI components or new event types are added.This addresses the completed-agent pattern observed while investigating #7338. It does not attempt to solve every usage report in that issue. A separate Claude Code cache-invalidation mechanism is tracked in anthropics/claude-code#78720.
Verification
vp test run apps/server/src/provider/Layers/ClaudeAdapter.test.ts apps/server/src/provider/Layers/ProviderService.test.ts apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts(119 tests)vp test run apps/server/src/provider/Layers/CodexAdapter.test.ts apps/server/src/provider/Layers/OpenCodeAdapter.test.ts apps/server/src/serverRuntimeStartup.reconcile.test.ts(120 tests)vp run --filter t3 typecheckvp run --filter @t3tools/contracts typecheckBuilt with GPT-5.6 Sol in the Codex desktop app.
Note
Guard Claude
SendMessageto stopped subagents and persist resume cursors by ownerSubagentStop, removes them onSubagentStart, and interceptsSendMessagePreToolUse when the target matches a stopped agent, prompting the user to resume the existing agent or start fresh; fresh-agent and cancel choices deny the toolsession.configuredevents carrying the cursorProviderSessionRuntimeRepository.updateResumeCursorIfCurrentInstance, a conditional SQL UPDATE that only writeslast_seen_atandresume_cursor_jsonwhen boththread_idandprovider_instance_idmatch, returningfalsefor stale or rebound sessionsProviderService.processRuntimeEventnow attempts to persist thesession.configuredresume cursor for the emitting instance before canonical publication; stale updates and persistence failures log a warning instead of failing event processingSessionConfiguredPayloadgains optionalresumeCursorfield; out-of-tree consumers of thesession.configuredcontract that do strict schema decoding will see a new field. Test fixtures forCodexAdapter,OpenCodeAdapter,ProviderService, andserverRuntimeStartupwere updated with the newupdateResumeCursorIfCurrentInstancestubMacroscope summarized 28d2fef.
Note
Medium Risk
Touches Claude permission hooks, resume cursor persistence, and cross-client prompts; incorrect hook or cursor logic could block legitimate sub-agent messaging or leak stale cursor state, but changes are scoped and heavily tested.
Overview
Adds a Claude sub-agent resume guard so
SendMessageto a worker that has already stopped does not silently reload a huge child transcript. The adapter tracks stopped agent IDs viaSubagentStart/SubagentStophooks, stores them in the persisted resume cursor, and interceptsSendMessagewith a long-timeoutPreToolUsehook (so it still runs under full-accessbypassPermissions). The existing user-input flow asks to start fresh, resume existing, or cancel; fresh/cancel deny the tool with guidance for Claude, resume allows it and clears the stopped marker until the next stop.Resume cursor persistence is tightened:
session.configuredmay carry an optionalresumeCursor, andProviderServicewrites it through a new current-instance-only SQL update so a stale Claude instance cannot overwrite a row after the thread is rebound. Stale writes are debug-logged; persist failures are warnings and do not block event publication.Contracts, directory/repository APIs, tests, and Claude provider docs are updated accordingly; no new UI or event types.
Reviewed by Cursor Bugbot for commit 28d2fef. Bugbot is set up for automated code reviews on this repo. Configure here.