Skip to content

fix(claude): guard completed agent resumes - #9130

Open
PlunderStruck wants to merge 3 commits into
pingdotgg:mainfrom
PlunderStruck:fix/claude-completed-agent-resume-guard
Open

PlunderStruck wants to merge 3 commits into
pingdotgg:mainfrom
PlunderStruck:fix/claude-completed-agent-resume-guard

Conversation

@PlunderStruck

@PlunderStruck PlunderStruck commented Sep 1, 2026 •

Copy link
Copy Markdown

Problem

Claude can call SendMessage after 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

  • Track real Claude runtime IDs through the SDK's SubagentStart and SubagentStop hooks.
  • Persist stopped IDs in the existing resume cursor so the guard survives a T3 session restart.
  • Atomically reject cursor updates emitted by an adapter instance that no longer owns the thread.
  • Intercept SendMessage with PreToolUse, which still runs when full-access uses bypassPermissions.
  • Reuse the existing cross-client question flow to offer Start fresh agent, Resume existing agent, or Cancel.
  • Keep messages to running agents unchanged. Choosing resume clears the stopped marker until Claude reports another stop.

Starting fresh denies the resume with instructions for Claude to launch a new bounded agent using a concise handoff. The existing session.configured runtime 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 typecheck
  • vp run --filter @t3tools/contracts typecheck
  • Targeted lint and formatting checks for the changed files
  • SCIP dependency, cycle, extraction, locality, and unused-import review

Built with GPT-5.6 Sol in the Codex desktop app.

Note

Guard Claude SendMessage to stopped subagents and persist resume cursors by owner

  • Claude now records stopped subagents on SubagentStop, removes them on SubagentStart, and intercepts SendMessage PreToolUse 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 tool
  • Stopped-agent state is recovered from the resume cursor on session restore (capped at 128 entries, seven-day hook timeout) and emitted via session.configured events carrying the cursor
  • Adds ProviderSessionRuntimeRepository.updateResumeCursorIfCurrentInstance, a conditional SQL UPDATE that only writes last_seen_at and resume_cursor_json when both thread_id and provider_instance_id match, returning false for stale or rebound sessions
  • ProviderService.processRuntimeEvent now attempts to persist the session.configured resume cursor for the emitting instance before canonical publication; stale updates and persistence failures log a warning instead of failing event processing
  • Risk: SessionConfiguredPayload gains optional resumeCursor field; out-of-tree consumers of the session.configured contract that do strict schema decoding will see a new field. Test fixtures for CodexAdapter, OpenCodeAdapter, ProviderService, and serverRuntimeStartup were updated with the new updateResumeCursorIfCurrentInstance stub

Macroscope 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 SendMessage to a worker that has already stopped does not silently reload a huge child transcript. The adapter tracks stopped agent IDs via SubagentStart / SubagentStop hooks, stores them in the persisted resume cursor, and intercepts SendMessage with a long-timeout PreToolUse hook (so it still runs under full-access bypassPermissions). 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.configured may carry an optional resumeCursor, and ProviderService writes 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.

@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 Sep 1, 2026
Comment thread apps/server/src/provider/Layers/ClaudeAdapter.ts
Comment thread apps/server/src/provider/Layers/ClaudeAdapter.ts Outdated
Comment thread apps/server/src/provider/Layers/ClaudeAdapter.ts
@macroscopeapp

macroscopeapp Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Approvability

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

  • 4 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

Comment thread apps/server/src/provider/Layers/ProviderService.ts Outdated

@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 1 potential issue.

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 9eb7e93. Configure here.

Comment thread apps/server/src/provider/Layers/ProviderService.ts
@PlunderStruck
PlunderStruck force-pushed the fix/claude-completed-agent-resume-guard branch from 461f220 to 5830f3e Compare September 3, 2026 14:29
? `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(

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

@PlunderStruck
PlunderStruck force-pushed the fix/claude-completed-agent-resume-guard branch from 5830f3e to 28d2fef Compare September 3, 2026 14:34
providerInstanceId: source.instanceId,
}),
),
Effect.catchCause((cause) =>

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

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

Suggested change
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.

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 answers = yield* Deferred.await(answersDeferred);

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.

This branch has not been deployed

No deployments
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