Skip to content

Fix subagent session restore takeover - #4543

Closed
lawrencecchen wants to merge 47 commits into
mainfrom
issue-session-restore-parent-thread
Closed

lawrencecchen wants to merge 47 commits into
mainfrom
issue-session-restore-parent-thread

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented May 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Persist parentSessionId for Claude and generic agent hook records so spawned subagents stay tied to their parent.
  • Mark explicit child sessions non-restorable and prevent them from publishing or clearing the parent surface resume binding.
  • Repair stale child resume bindings back to the nearest restorable parent with expected checkpoint/source guards, or clear only the stale child binding.
  • Keep Claude and Codex child notifications visible when subagent notification suppression is disabled while still protecting parent restore.

Tests

  • AWS red proof: subrestore-claude-parent-red-gui-1779495275 failed before the fix because the child record did not persist parentSessionId.
  • AWS red proof: subrestore-claude-prompt-red-gui-1779496531 failed before the prompt-stop fix because the child prompt stayed restorable and emitted a child resume binding.
  • AWS green proof: subrestore-merge-green-1779512889 on cmux-aws-m4pro passed the 19 focused cmux-unit restore and hook regression tests.

@vercel

vercel Bot commented May 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment May 23, 2026 5:58am
cmux-staging Building Building Preview, Comment May 23, 2026 5:58am

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented May 22, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Session lifecycle recording now tracks parent-child relationships via optional parentSessionId, extracted from hook payloads and propagated through recording/upsert APIs. Parent-aware peer lookup and suppression prevent subagent restore takeovers when a restorable parent exists. Snapshot selection logic was refactored to prefer candidate snapshots by updatedAt, and load paths thread environment/fileManager for hook store location resolution.

Changes

Parent Session Tracking and Subagent Suppression

Layer / File(s) Summary
Session record schema: add parentSessionId field
CLI/cmux.swift, Sources/RestorableAgentSession.swift
Add optional parentSessionId: String? field to ClaudeHookSessionRecord and RestorableAgentHookSessionRecord to store parent-child session relationships.
Method signatures: accept and propagate parentSessionId
CLI/cmux.swift
Extend recordPromptSubmit, recordPromptStop, upsert, and update method signatures to accept a parentSessionId parameter (defaulting to nil) and pass it to persistence logic; normalize and persist the parentSessionId on stored records and allow explicit isRestorable assignment.
Extract and normalize parentSessionId from hook payloads
CLI/cmux.swift
Parse parentSessionId from multiple hook payload key variants (direct keys, nested wrappers, source structures, subagent/thread spawn nesting, and bounded recursive traversal) and assign it into constructed session records.
Snapshot candidate preference and load wiring
Sources/RestorableAgentSession.swift
Refactor snapshot candidate selection into preferredSnapshotCandidate(_:over:) that prefers candidate when candidate.updatedAt >= existing.updatedAt, and thread environment/fileManager into load paths to compute hook store file URLs.
Suppress subagent takeovers using parent-aware peer lookup
CLI/cmux.swift
Introduce shouldSuppressSubagentRestoreTakeover(...) and apply parent-aware suppression across nested prompt submit/stop, notification restore handling (early-return telemetry branch), and session-end visibility suppression; set isRestorable to false and persist parentSessionId when suppression applies.
Integration and unit tests for suppression and preference
cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift, cmuxTests/RestorableAgentSessionIndexTests.swift
Add tests verifying parent resume binding is preserved when a subagent starts (child persisted as non-restorable) and that newer launchless top-level sessions can replace older launch-backed bindings; update tests to pass explicit environment to loads and assert snapshot selection behavior.

Sequence Diagram(s)

sequenceDiagram
  participant HookPayload
  participant CLI_cmux as CLI/cmux
  participant HookStore
  participant RestorableIndex as RestorableAgentSessionIndex
  HookPayload->>CLI_cmux: extract sessionId and parentSessionId
  CLI_cmux->>HookStore: recordPromptSubmit/upsert(parentSessionId, isRestorable)
  HookStore->>RestorableIndex: persist snapshot with parentSessionId
  RestorableIndex->>HookStore: preferredSnapshotCandidate? (compare updatedAt)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • manaflow-ai/cmux#4237: Touches session-restore/resume-binding behavior and related persistence of per-surface resume bindings.

Poem

🐰 I sniffed the hooks and traced the line,
I braided child to parent ID,
When little agents hop to steal a throne,
The stored parent keeps its own,
Restores stay true and order’s tidy.


Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore

❌ Failed checks (2 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift File And Package Boundaries ❌ Error CLI/cmux.swift adds new parsing and suppression logic (+158 lines) to already-oversized file (28k, budget 20k), mixing session isolation into CLI entry point without package boundary. Extract parentSessionId parsing and subagent suppression logic into a new SwiftPM package target before merging, or reduce cmux.swift below budget by extracting this feature behind a package boundary.
Cmux Swift Logging ❌ Error CLI/cmux.swift adds unguarded print("{}") statements in production code as part of new subagent notification suppression logic, violating swift-logging.md rules against print() in app/runtime code. Replace print("{}") calls with Logger or use #if DEBUG guards if outputs are debug-only.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (14 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix subagent session restore takeover' clearly summarizes the main objective: preventing subagent sessions from inappropriately taking over parent session restores. It directly relates to the core change in the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed Production changes only add parentSessionId field to private Codable/Sendable record types; no MainActor implicit isolation, mutable Sendable types, or background context issues introduced.
Cmux Swift Blocking Runtime ✅ Passed PR introduces no blocking runtime synchronization patterns; new code uses only string extraction, bounded recursive traversal (maxDepth 4), boolean logic, and field updates.
Cmux No Hacky Sleeps ✅ Passed Check only applies to TypeScript, JavaScript, shell, and non-Swift build/runtime scripts. PR modifies only Swift files (cmux.swift, RestorableAgentSession.swift, test files), so not applicable.
Cmux Swift Concurrency ✅ Passed No new legacy async patterns introduced: all added functions are synchronous, no Dispatch queues for ordinary async work, no Combine usage, no completion handlers, no fire-and-forget Tasks.
Cmux Swift @Concurrent ✅ Passed Three async functions properly use Task.detached to offload file/JSON-parsing work from UI/main actor. No @concurrent violations; Swift 5.0 project satisfies all rules.
Cmux User-Facing Error Privacy ✅ Passed Session IDs and parentSessionId are not exposed in user-visible output, errors, alerts, or telemetry. Public environment variable names are appropriately referenced in error guidance.
Cmux Full Internationalization ✅ Passed PR does not violate i18n requirements: no new user-facing text added without localization; telemetry breadcrumbs are debug-only logs (exempt per rules); all structured data outputs remain unchanged.
Cmux Swiftui State Layout ✅ Passed PR contains no SwiftUI code; all changes are in backend session/hook logic (CLI/cmux.swift, RestorableAgentSession.swift) and XCTest files with zero SwiftUI state patterns.
Cmux Architecture Rethink ✅ Passed Clear correctness fix: parentSessionId tracks parent-subagent relationships, subagents marked non-restorable. No timing repairs or duplicate state owners introduced.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR contains no new user-visible NSWindow/NSPanel/NSWindowController/SwiftUI Window code. Changes are to session management logic. NSWindow found is in existing test fixture code, which is allowed.
Description check ✅ Passed The PR description covers what changed (parentSessionId persistence, child session suppression) and why (prevent subagent takeover), with AWS testing proofs referenced.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-session-restore-parent-thread

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 and usage tips.

@greptile-apps

greptile-apps Bot commented May 22, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR prevents Claude and generic agent subagent sessions from taking over panel restore by persisting parentSessionId, marking child sessions isRestorable: false, and suppressing child surface.resume.set/clear side effects — while keeping child notifications visible when suppression is off. It also adds guarded resume-binding updates via expected_checkpoint_id/expected_source in TerminalController, repair logic that rebinds a stale child checkpoint to the nearest restorable ancestor, and updates RestorableAgentSessionIndex to filter marked subagent records.

  • Session-start gap in hookRecordIsRestorable: The Claude session-start handler persists parentSessionId but does not write isRestorable: false; that flag only arrives via suppressClaudeSubagentRestore (called from stop/prompt-submit). In the pre-stop window, hookRecordIsRestorable's new early-out (record.isRestorable == false && parentSessionId != nil) never triggers, so a Claude subagent whose transcript already exists on disk will still be indexed as restorable and can steal the parent's panel slot.
  • Sticky-true protection narrowed: The update() guard now allows isRestorable: false to overwrite a previously set isRestorable: true whenever parentSessionId is present. Because extractClaudeHookParentSessionId performs broad nested-key searches, a false-positive parent tag combined with a later isRestorable: false event could permanently demote a legitimate non-subagent session out of restore.

Confidence Score: 3/5

The core restore-index guard for Claude subagents has a gap that allows a pre-stop subagent record to appear restorable if its transcript exists on disk, and the sticky-true protection was narrowed in a way that opens a demotion path for non-subagent sessions with a false-positive parent tag; both affect the fundamental restore invariant this PR is trying to enforce.

Two distinct correctness gaps exist in the changed paths that handle the central invariant of the PR. The hookRecordIsRestorable guard for Claude only fires when isRestorable == false, but session-start never writes that flag — only stop/prompt-submit do — so the filtering is silent for the pre-stop window. The update() guard change removes sticky-true protection for any session that acquires a parentSessionId tag, and the tag-extraction logic searches broad nested keys, creating a non-trivial false-positive surface. Either defect can cause the parent restore binding to be lost or overwritten in production.

Sources/RestorableAgentSession.swift (hookRecordIsRestorable Claude path) and CLI/cmux.swift (update() isRestorable guard and session-start upsert) need the closest review.

Important Files Changed

Filename Overview
CLI/cmux.swift Core hook handler changes: adds claudeSubagentParentSessionId, suppressClaudeSubagentRestore, repairClaudeSubagentResumeBinding helpers and threads them through session-start, stop, prompt-submit, notification, and pre-tool-use Claude handlers; similar subagentParentSessionId / repairSuppressedSubagentResumeBinding logic added to generic agent handlers. session-start upsert writes parentSessionId but omits isRestorable: false for Claude subagents, leaving the restore-index guard ineffective during the pre-stop window.
Sources/RestorableAgentSession.swift Adds parentSessionId to RestorableAgentHookSessionRecord; hookRecordIsRestorable gains an early-out for Claude records with isRestorable==false + parentSessionId, but records written only by session-start (isRestorable==nil, parentSessionId set) bypass the guard and fall through to transcript checks. Also refactors snapshot-preference logic to preferredSnapshotCandidate with >= tie-breaking.
Sources/TerminalController.swift Adds expected_checkpoint_id / expected_source guards to the surface.resume.set handler so that conditional repair publishes are safe even if the binding changed before they land.
cmuxTests/RestorableAgentSessionIndexTests.swift New tests cover self-referential parentSessionId, Claude transcript-backed subagent suppression (isRestorable=false + parentSessionId), Codex launchless subagent suppression, and launchless top-level session tie-breaking; missing a case for isRestorable==nil + parentSessionId set (pre-stop crash window).
cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift Significantly expanded integration tests covering subagent suppression and repair across session-start, stop, prompt-submit, notification, and session-end paths for both Claude and generic hook agents.

Sequence Diagram

sequenceDiagram
    participant Hook as Claude/Agent Hook
    participant Store as SessionStore
    participant Repair as RepairBinding
    participant TC as TerminalController
    participant Index as RestoreIndex

    Hook->>Store: "session-start (parentSessionId, isRestorable=nil)"
    Hook->>Repair: repairClaudeSubagentResumeBinding
    Repair->>TC: "surface.resume.set(parent, expected_checkpoint_id=child)"
    TC-->>Repair: ok (guard passed)

    Note over Store,Index: Pre-stop window: isRestorable=nil, parentSessionId set
    Index->>Store: hookRecordIsRestorable?
    Store-->>Index: "isRestorable==false? No, transcript check returns true"

    Hook->>Store: "stop (suppressClaudeSubagentRestore, isRestorable=false, parentSessionId)"
    Hook->>TC: "surface.resume.set(parent, expected_checkpoint_id=child)"
    TC-->>Hook: ok (guard passed)

    Index->>Store: hookRecordIsRestorable?
    Store-->>Index: "isRestorable==false + parentSessionId, returns false"

    Hook->>Store: "session-end (mapped.isRestorable==false, upsert)"
    Hook->>Repair: repairSuppressedSubagentResumeBinding
    Repair->>TC: "surface.resume.set(parent, expected_checkpoint_id=child)"
    TC-->>Repair: ok
Loading

Comments Outside Diff (1)

  1. Sources/RestorableAgentSession.swift, line 840-853 (link)

    P1 Claude subagent restorable when isRestorable is nil but parentSessionId is set

    The early-out only fires when record.isRestorable == false. But the Claude session-start handler writes parentSessionId to the store without setting isRestorable: false (the isRestorable parameter is omitted from that upsert call and defaults to nil). isRestorable: false is only written later by suppressClaudeSubagentRestore, which is called from stop and prompt-submit. In the window between session-start and the first stop/prompt-submit — including any app restart or crash that happens during that window — record.isRestorable is nil and parentSessionId is set. The guard condition record.isRestorable == false evaluates to false, the function falls through to the transcript check, and if transcriptPath was passed in the session-start payload pointing to an existing file (or claudeTranscriptExists finds one), hookRecordIsRestorable returns true. The Claude subagent then enters the restore index and can take over the parent's panel slot despite having a registered parent. A broader check — parentSessionId is non-nil AND isRestorable != true → return false — would close this gap, or alternatively the session-start upsert should write isRestorable: false for detected subagents.

Reviews (25): Last reviewed commit: "test: align subagent visible mutation ex..." | Re-trigger Greptile

Comment thread CLI/cmux.swift Outdated
Comment on lines +897 to +903
if let normalizedParent,
let parent = peers.first(where: { $0.sessionId == normalizedParent }) {
return parent
}
return peers
.filter { !requireLaunchCommand || recordHasLaunchCommand($0) }
.max(by: { $0.updatedAt < $1.updatedAt })

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.

P1 Fallback returns unrelated peer when parent is not found

When normalizedParent is set (i.e., parentSessionId was supplied by the caller) but the parent record is not among peers (e.g., the parent session was already consumed/removed from the store), the function falls through to the generic .max(by:) fallback and returns any restorable peer on the same workspace+surface. shouldSuppressSubagentRestoreTakeover then treats the non-nil result as "there is a peer that should take precedence" and marks the child isRestorable: false. If an old unrelated session happens to remain in the store for that workspace+surface, the actively running child session is silently locked out of restore — permanently, since isRestorable: false is never reset. The fallback should return nil when normalizedParent is provided but the parent is not found, so only an explicitly matched parent triggers suppression in the first-check path.

Comment thread CLI/cmux.swift Outdated
Comment on lines +901 to +903
return peers
.filter { !requireLaunchCommand || recordHasLaunchCommand($0) }
.max(by: { $0.updatedAt < $1.updatedAt })

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.

P2 Redundant filter inside sameSurfaceRestorablePeer

The outer peers collection is already filtered by requireLaunchCommand (the guard inside the closure returns recordHasLaunchCommand(record) when requireLaunchCommand is true), so the secondary .filter { !requireLaunchCommand || recordHasLaunchCommand($0) } is dead code for that branch. It adds noise and may mislead future readers into thinking the initial filter was insufficient.

Suggested change
return peers
.filter { !requireLaunchCommand || recordHasLaunchCommand($0) }
.max(by: { $0.updatedAt < $1.updatedAt })
return peers
.max(by: { $0.updatedAt < $1.updatedAt })

Comment thread CLI/cmux.swift Outdated
Comment on lines +19296 to +19301
private func firstStringRecursively(
in value: Any,
keys: Set<String>,
maxDepth: Int
) -> String? {
guard maxDepth >= 0 else { return nil }

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.

P2 firstStringRecursively traverses one extra level beyond maxDepth

The guard maxDepth >= 0 passes when maxDepth is 0, so the function visits the current node at depth 0 and then recurses with maxDepth - 1 == -1 (which immediately fails). A call with maxDepth: 4 actually traverses 5 levels (4, 3, 2, 1, 0), not 4. Use maxDepth > 0 as the recursion guard so the parameter semantics match its name.

Suggested change
private func firstStringRecursively(
in value: Any,
keys: Set<String>,
maxDepth: Int
) -> String? {
guard maxDepth >= 0 else { return nil }
private func firstStringRecursively(
in value: Any,
keys: Set<String>,
maxDepth: Int
) -> String? {
guard maxDepth > 0 else { return nil }

Comment thread CLI/cmux.swift Outdated
Comment on lines +24128 to +24151
func shouldSuppressSubagentRestoreTakeover(
workspaceId: String,
surfaceId: String,
launchCommand: AgentHookLaunchCommandRecord?
) -> Bool {
guard !sessionId.isEmpty else { return false }
if let parentSessionId = input.parentSessionId,
(try? store.sameSurfaceRestorablePeer(
workspaceId: workspaceId,
surfaceId: surfaceId,
excludingSessionId: sessionId,
parentSessionId: parentSessionId
)) != nil {
return true
}
guard launchCommand == nil else {
return false
}
return (try? store.sameSurfaceRestorablePeer(
workspaceId: workspaceId,
surfaceId: surfaceId,
excludingSessionId: sessionId,
requireLaunchCommand: true
)) != nil

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.

P2 Suppression result depends on store state at hook-fire time — ordering sensitive

shouldSuppressSubagentRestoreTakeover reads the hook session store to decide whether the current session is a subagent. The result is correct only when the parent's session record is already written to the store before the child's hook fires. If the parent's session-start hook is delayed and the child fires first, sameSurfaceRestorablePeer finds no parent record, and the child may or may not be suppressed depending on unrelated store content. The invariant "child sessions are always subordinate to their parent" is not enforced by a signal from the owning subsystem but by a time-of-read snapshot. Treating parentSessionId being non-nil in the hook payload as sufficient to mark non-restorable would eliminate this ordering dependency entirely.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CLI/cmux.swift`:
- Around line 901-903: The return chain redundantly re-filters peers with
recordHasLaunchCommand even though peers were already filtered when
requireLaunchCommand was true; remove the conditional filter from the return
(the `.filter { !requireLaunchCommand || recordHasLaunchCommand($0) }`) and
simply call `.max(by: { $0.updatedAt < $1.updatedAt })` on the already-prepared
peers so the `max(by:)` uses the correct set without double-filtering; reference
symbols: peers, requireLaunchCommand, recordHasLaunchCommand, and max(by:).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 83ce2817-0ba3-4801-be9b-991aefc28aee

📥 Commits

Reviewing files that changed from the base of the PR and between bcd630e and a90b1d0.

📒 Files selected for processing (4)
  • CLI/cmux.swift
  • Sources/RestorableAgentSession.swift
  • cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
  • cmuxTests/RestorableAgentSessionIndexTests.swift

Comment thread CLI/cmux.swift Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

3 issues found across 4 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread cmuxTests/RestorableAgentSessionIndexTests.swift
Comment thread cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift Outdated
Comment thread CLI/cmux.swift Outdated
Comment thread CLI/cmux.swift
Comment thread CLI/cmux.swift Outdated
Comment thread CLI/cmux.swift Outdated
Comment on lines 24244 to 24260
let savedSubagentSuppression = mapped?.isRestorable == false
let suppressRestorableRecord = nestedAgentSuppressVisibleMutations || suppressRestoreTakeover
|| savedSubagentSuppression
let suppressVisibleMutations = suppressRestorableRecord
if !sessionId.isEmpty {
try? store.upsert(
sessionId: sessionId,
parentSessionId: input.parentSessionId,
workspaceId: workspaceId,
surfaceId: surfaceId,
cwd: hookCwd ?? mapped?.cwd,
transcriptPath: input.transcriptPath ?? mapped?.transcriptPath,
pid: pid,
launchCommand: launchCommand,
isRestorable: suppressRestorableRecord ? false : nil,
runtimeStatus: suppressVisibleMutations ? nil : .running,
updateRuntimeStatus: !suppressVisibleMutations

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.

P1 nestedAgentSuppressVisibleMutations now permanently locks isRestorable: false via savedSubagentSuppression

When shouldSuppressNestedAgentVisibleMutations fires at session-start (e.g., PID-based heuristic detects a parent agent process), isRestorable: false is written to the store. On every subsequent hook call, savedSubagentSuppression = mapped?.isRestorable == false evaluates true and re-asserts suppression — even if shouldSuppressNestedAgentVisibleMutations would now return false (e.g., the parent process has since exited). Before this PR, suppression was re-evaluated on each hook call; now a single positive evaluation makes suppression permanent.

The concrete regression: a session that starts while a parent agent process is alive (PID heuristic fires → isRestorable: false stored), but the parent exits before the session's stop hook fires, will never publish a resume binding. The user's session becomes permanently non-restorable with no escape hatch short of manually editing the session store file.

The suppressRestoreTakeover path (payload-based) is correctly sticky because a parentSessionId in the payload is a structural signal. The nestedAgentSuppressVisibleMutations path is a heuristic, and its prior per-call re-evaluation was load-bearing for recovery.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Verified against current head. This path is fixed by 723091c: transient nested-agent suppression no longer writes isRestorable=false, so savedSubagentSuppression is not made sticky by the PID/process-tree heuristic. The new focused regression is testTransientSessionEndSuppressionDoesNotClearRuntimeStatus.

— Claude Code

Comment thread CLI/cmux.swift Outdated
Comment thread CLI/cmux.swift Outdated
Comment on lines 772 to 774
record.launchCommand = launchCommand
}
if let isRestorable {

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.

P1 Sticky-true guard removed — late Claude SessionStart can permanently un-restore a completed session

The old update() body contained an explicit invariant guard that protected isRestorable=true (set by transcript-backed events at session completion) from being overwritten by isRestorable=false (set by late non-promoting SessionStarts). The Claude session handler (~line 18051) passes isRestorable: false for "Non-clear SessionStart that can arrive late from startup/resume/compact after /clear". If the same session ID was already stored as isRestorable: true after a completed turn (lines 18165/18259), the new assignment record.isRestorable = isRestorable overwrites true with false. Because savedSubagentSuppression = mapped?.isRestorable == false re-confirms suppression on every subsequent hook event, the session becomes permanently non-restorable with no escape hatch. The subagent suppression path this PR introduces is gated solely on parentSessionId being present in the hook payload — the Claude handler never sets that field — so the sticky guard could be restored without breaking the new subagent logic.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread CLI/cmux.swift
Comment thread Sources/RestorableAgentSession.swift
Comment thread CLI/cmux.swift

@cursor cursor Bot left a comment

Copy link
Copy Markdown

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 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 1b4ea0f. Configure here.

Comment thread CLI/cmux.swift
surfaceId: surfaceId,
sessionId: sessionId
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Duplicated ancestor traversal repair logic across two handlers

Low Severity

repairClaudeSubagentResumeBinding and repairSuppressedSubagentResumeBinding contain nearly identical ancestor-chain traversal logic (walking up to 8 parent hops, checking surface match and restorability, publishing with expected-checkpoint guard, and falling back to clear). The only differences are the store variable name, how sessionId/parentSessionId are obtained, and the kind/displayName used for publishing. A shared helper accepting those parameters would eliminate this duplication and reduce the risk of the two copies diverging.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1b4ea0f. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Verified against the current code. I am leaving the two repair paths separate for this PR because they sit in different hook owners with different store/display inputs, and collapsing them would be a behavior-neutral refactor inside a restore regression fix. The AWS focused test set covers both paths.

— Claude Code

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Superseded by #8321, which is updated to current main and handles both unindexed Codex worker IDs and indexed subagents that must resolve to their writable parent.

This branch was successfully deployed

1 active deployment
Preview – cmux — 881d516c Deployed May 23, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant