Skip to content

fix(claude): let the lease release close the adapter's session so an evicted chat resumes - #22641

Open
brennanb2025 wants to merge 3 commits into
mainfrom
brennanb2025/r2-release-closes-adapter
Open

brennanb2025 wants to merge 3 commits into
mainfrom
brennanb2025/r2-release-closes-adapter

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​556 $\color{#cf222e}{\Huge{\mathbf{−}}}$​108 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​448
Prod 14 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​365 $\color{#cf222e}{\Huge{\mathbf{−}}}$​43 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​322

ELI5

When you hide a Claude chat, Orca stops its Claude process to save resources and hands the chat's "lease" back so it can be restarted later. On many machines Orca can see Claude itself exit but can't prove every helper process it started is gone. The lease was handed back anyway, but the Claude side of Orca still remembered the old process and refused to start a new one until it could prove the old helpers were gone. It never could. So re-opening the chat, or waking it with orchestration mail, failed until you restarted the app. Now handing back the lease is what "closed" means everywhere. The leftover helper check keeps running in the background for a limited time and never blocks a restart.

What Changed

Before: a hidden structured Claude chat evicted with an unverifiable process tree went reserved → released on every later hold, send or mail wake, with provider close unproven, until Orca restarted.

After: the chat resumes in the same run. The mechanism:

  • The lease release is the one close decision. StructuredAgentSessionAdapterRouter.acknowledgeSessionRelease now forwards the host's release to the owning adapter before dropping the route. The acknowledgement carries the fence it released. The router and the adapter both retire only a child at or below that fence, through the same fence rule an acquisition uses, so a late acknowledgement cannot retire a child acquired since. ClaudeStructuredSessionAdapter.acknowledgeSessionRelease removes the session and any retained exit from its index and settles what they owned: dispatch waiters, prompts, translator, reading control and background tasks (claude-structured-session-retirement.ts). Codex has no such method, so its path is unchanged.
  • Resume does not re-close a released child. resolveClaudeAcquisitionLaunch retires an indexed child whose fence is older than the acquisition's fence instead of closing it again. The lease grants a later fence in two ways. One is after a release. The other is over an unreleased lease whose owner probe proved the recorded process dead (evaluateAgentSessionAcquisition, src/shared/agent-session-lease-adjudication.ts). Either way the old fence no longer owns the session, so this also covers release paths that never send an acknowledgement, such as acquisition-failure settlement. Neither grant is evidence about this particular connection, so a child whose root was never seen to exit still goes through the close ladder first.
  • The fence rule is provider-neutral. isAgentSessionChildReleasedThroughFence lives in native-chat/agent-session-wire/structured-agent-session-fence-retirement.ts, so a Codex follow-up can reuse it rather than build a parallel one.
  • Tree verification is bounded cleanup. ClaudeReleasedChildCleanup keys unproven children by connection, not session id, and no acquisition reads it. It re-runs the connection's own close ladder at 5s, 30s and 120s. After that it logs the last { root, tree } verdict and drops the child. closeAll gives each child one final attempt and reports, without throwing. It never says a tree is gone unless it saw it go.
  • An unexpected exit with an unverifiable tree now publishes ended. I confirmed the suspected gap: on main, a Claude CLI that exits on its own without a tree proof never publishes ended, so the host keeps hasProviderChild and a visible chat stays attached to a dead child. The exit now publishes ended on the root's first-hand exit, so the host settles the turn, releases the lease and runs recovery. A descendant seen alive (tree: 'live') only defers it. The withheld exit joins the same bounded cleanup schedule. A retry that proves the tree gone publishes ended. At give-up, ended is published and the report names the descendant as live, never gone. Without this, a killed Claude root with a surviving MCP server would leave the host holding a dead child, and the release clock never retries a failed eviction, so the visible chat stayed wedged until restart. The exit's publication is modelled as a state (ended: 'withheld' | 'published', and a proven exit leaves the map) rather than a boolean. A published exit stays indexed as evidence until the release or a later fence retires it. Until then, acquisition cleanup keeps reporting a root exit and never a proven one. providerHistoryWindow stops reporting a turn in flight for an exit that already published ended.
  • handleExit and settleUnexpectedExit moved into claude-structured-session-exit-lifecycle.ts so the adapter stays under the line cap. Their behaviour is unchanged apart from the two points above.

Why

Why a fence rule and not just the acknowledgement. The fence is the fact every grant path already carries. An acquisition at fence F proves F-1 no longer owns the session, however the lease got there: an eviction, an acquisition-failure settlement, an unexpected-exit settlement, or a probe that proved the owner dead. Deriving "released" from it means no release site has to remember to notify the adapter. The acknowledgement then only makes retirement prompt, and it uses the same rule, bounded by the fence it names.

The window on release paths that send no acknowledgement. Acquisition-failure settlement releases the lease without an acknowledgement. Between that release and the next acquire, the adapter still holds the old entry, so its synchronous readers can serve a dead child's state until the next acquire retires it. Those readers are backgroundTaskState, readCommands, recordsContextUsage and providerHistoryWindow. This gates no user action and ends at the next acquire or quit. It is the residue of deriving "released" at acquire time rather than pushing it from every release site.

Codex keeps the opposite lease semantics for now. Its close returns false when the exit is unproven, so eviction aborts and the lease stays live. A hidden Codex chat with an unprovable close therefore still latches, and so can a Codex coordinator's mail wake. Fixing that changes shared eviction semantics and needs its own ablations. The follow-up should adopt the same shape: the fenced acknowledgement, the provider-neutral fence rule above, and a bounded cleanup.

How a retained exit ends. An exit that published ended with an unproven tree stays indexed only as evidence. It ends in one of three ways, and none of them waits on the tree:

  1. The next acquisition's fence rule retires it.
  2. The release-clock eviction's acknowledgement retires it once the last holder drops.
  3. closeAll at quit gives it one final close attempt and reports.

"Closed" had two owners. The host's lease had already decided the child was closed (#20502: ownership follows the provider root). The adapter's session-id index kept a stricter, tree-proven notion of closed, and made it a precondition for acquisition. This PR removes the second owner instead of adding a guard around it. The index entry now ends with the lease. The descendant obligation still exists, but it has an exit: a bounded schedule and a report. It no longer blocks a user action. An index entry that ends with the close, whatever the close proved, and a restart that never waits on proof of the old tree, is the established pattern for provider session registries.

Alternatives considered:

  • The adapter could retire the entry itself whenever it classifies a close as a root exit. I rejected that. It makes the adapter a second decider again, and a later absent-session answer would read as a proven exit in acquisition-failure settlement.
  • Wiring an acknowledgement into every lease-release site, including acquisition-failure settlement and unexpected-exit settlement. I rejected that in favour of the fence rule, which derives "released" from state the acquire already carries. Sending the acknowledgement from unexpected-exit settlement would also have dropped Codex routes, which this PR must not change.

Deviation, stated plainly: Orca still verifies the descendant tree, which a plain "delete on close" registry does not. It now does so only as bounded background bookkeeping.

Linked Issue

None — part of the structured chat status/orchestration program. This unblocks the orchestration mail wake in #22631, whose wake hold failed with provider close unproven.

Visual Proof

N/A. There is no UI change. The fix is to lease and process lifecycle. Live evidence from the app is below.

Testing

  • I manually tested these changes locally (macOS, isolated dev profile, ORCA_BACKGROUND_LAUNCH=1, CDP only)
  • Automated tests added/updated

Regression tests. The new and changed tests are red on main (main's modified sources with this branch's tests) and green here:

Test main this PR
root-exit eviction: closeSession after release (main's pinned assertion, flipped) rejects provider exited resolves true
resumes in the same run after an orderly close left the tree unverifiable refused RootExitObservedError: provider close unproven, 1 connection acquired, 2 connections
resumes in the same run after the provider exited first refused, 1 connection acquired, 2 connections
unexpected exit with an unverifiable tree publishes ended [] ended (unexpected-exit, fence 7)
no turn in flight once ended is out turnInFlight: true false
acknowledged release forgets the session / the published exit method absent green
a later fence retires a released child without closing it again refused provider close unproven green, closeCount 1
router forwards the release to the owning adapter only 0 calls 1 call

Ablations. Each piece was deleted on the committed branch, with the listed tests going red:

  • Acknowledgement deleted: 3 red (the flipped assertion and both acknowledgement tests). The re-acquire tests stay green because the fence rule also covers them.
  • Fence retirement deleted: 1 red (resume past a released child without an acknowledgement).
  • Both deleted: 6 red, including both host-level re-acquire tests.
  • Live-root clause deleted: 1 red (a live child at an older fence must still be closed first).
  • Unverifiable-exit publication deleted: 2 red.
  • Evidence retention deleted: 3 red. The exit-first re-acquire loses its tree report because eviction then reads the absence as a proven close.
  • providerHistoryWindow change deleted: 1 red.
  • Router forward deleted: 1 red.
  • Cleanup adoption deleted: 5 red.
  • Cleanup give-up report deleted: 6 red.
  • Fenced acknowledgement (follow-up commit):
    • Adapter ignores the fence: 1 red (a stale acknowledgement after a newer acquisition leaves the new child alone).
    • Router ignores the fence: 1 red (a stale release leaves a newer route alone).
    • After the rule was unified, the earlier ablations were re-run: acknowledgement deleted, 3 red; fence retirement deleted, 2 red; live-root clause deleted, 1 red.
  • Withheld exit re-checked by the cleanup (follow-up commit):
    • The withheld exit is not handed to the cleanup: 2 red. It is neither published on proof nor at give-up. This is the wedge.
    • The cleanup does not settle on proof: 2 red.
    • The cleanup does not settle at give-up: 2 red.
    • Give-up drops the evidence: 1 red, because a later cleanup reads it as proven.
    • The architecture review's scratch proof of the latch now fails against this head: the ladder re-runs, 2 closes instead of 1.

Real binary (scratch test, not committed, using the installed claude CLI and the real connection and close ladder). On this Mac an orderly close of a real Claude child returns root exited / tree unverifiable.

  • main: re-acquire is refused with AgentSessionAcquisitionRootExitObservedError: provider close unproven, with and without an acknowledgement.
  • This PR: re-acquire succeeds and spawns a new pid in both the acknowledged arm and the fence-only arm.

Live in the app (this branch, CDP only):

  1. Created a folder project and a structured Claude chat, and sent one turn (OK).
  2. Closed the chat tab. The hold was released, and after the 15s grace the lease went released at fence 2.
  3. Re-held the session through the agentSession.hold RPC, the same path a surface hold or mail wake takes. The lease went live at fence 3 with a second (resumed) link, and a new Claude pid started.
  4. About 110s later the cleanup logged: released child tree was not verified gone { sessionId: 'claude_4a3a…', pid: 61596, verdict: { root: 'exited', tree: 'unverifiable' } }.

This shows the evicted child took the unverifiable path, and the resume did not wait on its tree.

Suites run: src/main/claude, src/main/native-chat, src/main/runtime, src/main/codex, with 13,411 tests passing. The only failures are 5 real-CLI tests (claude-structured-real-cli, claude-tui-resume-real-binary). They fail identically on main on this machine, because their closeAll hits provider close unproven against the real binary. They are skipped in CI, where no real binary is installed. pnpm tc:node passes, full pnpm exec oxlint exits 0, and check:code-quality:changed and audit:anti-slop are clean.

Not verified:

  • Re-opening through the tab UI. Hiding the tab by switching tabs or worktrees did not release the hold within 60s in this build, so I closed the tab instead and re-held through the RPC. By design a hidden tab should release within the 15s grace: the pane's visibility gates the desktop-chat hold, and its cleanup sends agentSession.release. This PR touches no renderer or hold code, so it neither causes nor masks that. It needs its own investigation. The two suspects are the hold hook silently swallowing a failed agentSession.release, and a retaining read-stream hold kept open while undelivered sends exist.
  • The orchestration mail wake end to end.
  • A live main-arm repro. The main arm rests on the investigation ledgers, the real-binary scratch and the unit tests.
  • Windows and Linux. The change is platform-neutral bookkeeping over the existing close ladder.

Review

Please challenge two things:

  • The fence rule, retireClaudeSessionSupersededByFence, which retires only when the indexed fence is older and the root is not live.
  • The decision to keep a published unverifiable exit indexed as evidence until it is retired.

Agent skill upstream boundary

  • Not applicable

Notes

  • SSH / remote / WSL: structured Claude is local-only (supportsClaudeStructuredLocation). The verdict vocabulary stays live / unverifiable / exited. Nothing here treats loss of contact or a newer fence as evidence of death: a root not seen to exit still has to be closed before a resume.
  • Codex: its close still returns false when unproven, so eviction aborts and the lease stays live. Codex defines no acknowledgeSessionRelease, so the router forward is a no-op for it. Codex tests pass unchanged. Its latch remains a separate issue.
  • Mobile / wire: no wire, RPC or persisted-shape change. endedWithTreeUnproven exists only in memory.
  • Performance: at most 3 extra close-ladder runs per released child with an unproven tree, on unref'd timers.

Merge order

This PR is independent of the structured-chat stacks and is based directly on main.

It overlaps with #22364 (open), which edits claude-structured-session-adapter.ts, claude-structured-session-state.ts, claude-structured-session-close.ts and structured-agent-session-claude-root-exit.test.ts. Expect textual conflicts in the adapter (its handleExit/settleUnexpectedExit edits, now in claude-structured-session-exit-lifecycle.ts), in the state types, and in the root-exit test (providerChildPhase: 'ready' on the host session). The semantic overlap is small:

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • N/A for screenshots, with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered
  • Typecheck, full oxlint and affected suites pass locally; CI covers the rest

@coderabbitai

coderabbitai Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: b26b698d-fe74-47cd-a83f-0aba69223b0d

📥 Commits

Reviewing files that changed from the base of the PR and between 0c8b6c9 and f9fad0b.

📒 Files selected for processing (8)
  • src/main/claude/claude-structured-session-adapter.ts
  • src/main/claude/claude-structured-session-exit-lifecycle.ts
  • src/main/claude/claude-structured-session-release.test.ts
  • src/main/claude/claude-structured-session-state.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

Claude session release now retires provider-side session state and adopts released connections for bounded cleanup attempts. Unexpected exits are retained and settled through lifecycle helpers. Acquisition checks for superseded sessions before selecting a session to resume. Release acknowledgments carry fences through the router and host paths, where stale acknowledgments do not remove newer routes.

Priority: ➖ Normal

Merge Risk: 🟡 Moderate · up to f9fad

A resumed Claude session can reuse its provider handle while an earlier child is still live. Resolve that retirement path before merging.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to f9fad

Fence checks protect newer sessions from stale releases, but a resumed chat can overlap with helpers from its previous session when those helpers cannot be verified as stopped. Cleanup retries are bounded and may end without that verification.

Retained concerns

  • Medium · security · inferred: Release now permits a successor Claude session while descendants of the retired connection may remain unverified and potentially active. The bounded cleanup can stop trying without proving they exited, so lease ownership no longer guarantees process-tree exclusivity.
Security review details

Security Blast Radius

  • inferred — The demonstrated overlap is between successive Claude child connections for a released session. The evidence does not establish a new provider route, cross-account reach, or what credentials or tool authority an unverified descendant retains.

Security Findings and Attack Paths

  • inferred — If a former child leaves an active descendant with usable local authority, a resumed session may operate alongside it after release. No attacker-controlled entry path or retained descendant privilege is established by the available evidence, so this is a conditional exposure, not a verified exploit.

Trust Boundaries and Controls

  • observed — The router selects an adapter from the session identity and records the acquisition fence; stale release acknowledgements are rejected at both route and Claude-session retirement boundaries.

Resilience and Maintainability Implications

  • observed — Delayed cleanup checks that its pending entry is still current before settling a retry, while exhaustion records an unverified result. Neither outcome is consulted as an acquisition precondition.

Hardening Proposals

  • proposed — Define whether released descendants retain credentials, filesystem access, or tool authority, and make an unverified cleanup outcome visible to the operator responsible for that authority. This would clarify the security meaning of release without restoring the former resume blocker.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed and covers the required explanation, changes, rationale, testing, visual proof, compatibility considerations, and checklist. However, the required Linked Issue section stat… Add a valid linked issue reference in the Linked Issue section, such as "Fixes #22631" or the specific issue this pull request addresses. Keep the supporting context if needed.
Docstring Coverage ⚠️ Warning Docstring coverage is 30.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 20 functions across 20 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the primary change: lease release now closes the adapter session so an evicted Claude chat can resume.
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.
Full details: Description check

Explanation

The description is detailed and covers the required explanation, changes, rationale, testing, visual proof, compatibility considerations, and checklist. However, the required Linked Issue section states "None," while the template requires an issue link.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

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.

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 02f51ba2-36db-45e6-a314-5edbc38e6320

📥 Commits

Reviewing files that changed from the base of the PR and between 122b8c2 and 29150cb.

📒 Files selected for processing (12)
  • src/main/claude/claude-released-child-cleanup.test.ts
  • src/main/claude/claude-released-child-cleanup.ts
  • src/main/claude/claude-structured-acquisition-launch.ts
  • src/main/claude/claude-structured-session-adapter.ts
  • src/main/claude/claude-structured-session-exit-lifecycle.ts
  • src/main/claude/claude-structured-session-release.test.ts
  • src/main/claude/claude-structured-session-retirement.ts
  • src/main/claude/claude-structured-session-state.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.

Comment on lines +52 to +60
export function retireClaudeSessionSupersededByFence(
input: ClaudeSessionRetirementInput & { fence: number }
): ClaudeSession | undefined {
const indexed = input.sessions.get(input.sessionId) ?? input.exits.get(input.sessionId)?.session
if (!indexed || indexed.fence >= input.fence || indexed.connection.exitVerdict.root === 'live') {
return undefined
}
return retireClaudeReleasedSession(input)
}

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not skip the tree check when a descendant was seen alive.

The guard rejects only exitVerdict.root === 'live'. It still retires an exit with { root: 'exited', tree: 'live' }. That verdict is positive evidence that a descendant is still running. settleClaudeUnexpectedExit withholds ended for this verdict. Before this change, acquisition threw AgentSessionAcquisitionExitUnprovenError for it. Now resolveClaudeAcquisitionLaunch resumes the same provider session while the old descendant can still write to its transcript. A later fence is not proof that the old holder stopped: a lease can expire or be released without the process exiting. Also reject tree === 'live' here.

Proposed fix
-  if (!indexed || indexed.fence >= input.fence || indexed.connection.exitVerdict.root === 'live') {
+  const verdict = indexed?.connection.exitVerdict
+  if (
+    !indexed ||
+    indexed.fence >= input.fence ||
+    verdict?.root === 'live' ||
+    verdict?.tree === 'live'
+  ) {
     return undefined
   }

Based on learnings: an expired-lease takeover "does not guarantee the previous holder actually stopped working."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function retireClaudeSessionSupersededByFence(
input: ClaudeSessionRetirementInput & { fence: number }
): ClaudeSession | undefined {
const indexed = input.sessions.get(input.sessionId) ?? input.exits.get(input.sessionId)?.session
if (!indexed || indexed.fence >= input.fence || indexed.connection.exitVerdict.root === 'live') {
return undefined
}
return retireClaudeReleasedSession(input)
}
export function retireClaudeSessionSupersededByFence(
input: ClaudeSessionRetirementInput & { fence: number }
): ClaudeSession | undefined {
const indexed = input.sessions.get(input.sessionId) ?? input.exits.get(input.sessionId)?.session
const verdict = indexed?.connection.exitVerdict
if (
!indexed ||
indexed.fence >= input.fence ||
verdict?.root === 'live' ||
verdict?.tree === 'live'
) {
return undefined
}
return retireClaudeReleasedSession(input)
}

Source: Learnings

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

Important

retireClaudeSessionSupersededByFence retires on root !== 'live', so it also retires a child whose verdict is { root: 'exited', tree: 'live' } — a state this PR deliberately treats as non-resumable elsewhere. Details inline.

Reviewed changes

  • Lease release closes the adapter session — acknowledgeSessionRelease now forwards through the router to the owning adapter, which drops the session/exit from its index and settles what they owned.
  • Fence-based retirement on resume — resolveClaudeAcquisitionLaunch retires an indexed child at an older fence instead of re-closing it, and deletes a settled retained exit.
  • Bounded descendant cleanup — the new ClaudeReleasedChildCleanup re-runs the close ladder on unref'd timers, then reports and drops; nothing reads it before acquiring.
  • Unexpected unverifiable exit publishes ended — a root exit with tree: 'unverifiable' now emits ended and keeps the exit indexed as evidence; tree: 'live' still withholds.
  • Tests — new cleanup, release, router, and root-exit eviction tests; the exit lifecycle moved into claude-structured-session-exit-lifecycle.ts.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

input: ClaudeSessionRetirementInput & { fence: number }
): ClaudeSession | undefined {
const indexed = input.sessions.get(input.sessionId) ?? input.exits.get(input.sessionId)?.session
if (!indexed || indexed.fence >= input.fence || indexed.connection.exitVerdict.root === 'live') {

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.

This guard retires on root !== 'live', so a verdict of { root: 'exited', tree: 'live' } — root gone, descendant observed alive — is retired too. This PR otherwise treats that state as non-resumable: retainClaudeUnexpectedExit withholds ended for it, and src/main/runtime/claude-structured-session-integration.test.ts:458 keeps the lease reserved/manual-recovery so a live descendant cannot be handed a second writer. The premise that a later fence implies a lease release does not hold in general, so a re-attach can resume on top of the live descendant.

Technical details
# Fence retirement ignores a live descendant

## Affected sites
- `src/main/claude/claude-structured-session-retirement.ts:56` — guard is only `exitVerdict.root === 'live'`; `tree === 'live'` passes.
- `src/main/claude/claude-structured-session-exit-lifecycle.ts:75-84` — `{root:'exited', tree:'live'}` deliberately returns without `settle`, so no `ended` and the exit stays indexed.
- `src/shared/agent-session-lease-adjudication.ts:163-174` — `evaluateAgentSessionAcquisition` grants `nextFence` whenever the recorded root probe is proven dead (`pid-absent` / `identity-mismatch`), with no `released` transition.
- `src/main/runtime/agent-session-lease-transitions.ts:76-97` — the reservation overwrites the lease with `claimStatus: 'reserved'`, `ownerProcess: null`, `deathEvidence: null`.
- `src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts:79-81` — every attach probes the recorded owner (the root) before reserving.

## Repro shape
1. Publish at fence 7; root exits with `tree: 'live'` → session moves to `exits`, no `ended`, lease stays `live` at 7.
2. Client re-attaches at `expectedRuntimeFence: 7`; the root probe is `pid-absent` → reservation grants fence 8.
3. `adapter.acquire({ fence: 8 })` → `retireSuperseded(..., 8)` sees `7 < 8` and `root === 'exited'` → retires the exit.
4. `retainedExit` is now `undefined`, so the `if (!proven) throw claudeAcquisitionCleanupError(...)` refusal is skipped and the resume proceeds.

Pre-PR the same acquire hit that throw and was refused with `agent_session_ownership_unknown`.

## Required outcome
- `retireClaudeSessionSupersededByFence` must not retire a child whose `exitVerdict.tree === 'live'`, so the close-ladder refusal runs as it does for a live root.

## Suggested approach
- Extend the guard to bail when `indexed.connection.exitVerdict.tree === 'live'` (reuse the `treeProven` shape from `claude-released-child-cleanup.ts`), and add a test that acquires at a later fence over a retained `{root:'exited', tree:'live'}` exit and asserts the acquire is refused.

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

ℹ️ No new issues in the follow-up commit. It closes a stale-acknowledgement hole in the release path cleanly; the one substantive concern from the prior review (retireClaudeSessionReleasedThrough still retires a { root: 'exited', tree: 'live' } child) is unchanged by these commits and remains tracked in its open thread.

Reviewed changes

  • The acknowledged release is now fenced — StructuredAgentSessionAdapterRouter records the acquisition fence per route and only forwards acknowledgeSessionRelease(sessionId, releasedFence) (and drops the route) when releasedFence is at or above that fence, so a duplicate or late release cannot retire a route acquired since.
  • Adapter retires by released fence — retireClaudeSessionSupersededByFence became retireClaudeSessionReleasedThrough({ releasedFence }); the resume path calls it with releasedFence: fence - 1, preserving its "strictly older child" semantics while letting the acknowledgement retire at its own fence.
  • Call sites pass the pre-release fence — host eviction, host teardown, and the native handoff forward now carry the fence captured before the lease advanced, so a later acquisition at that fence is never mistaken for the released one.
  • Tests — new router and adapter cases asserting a stale release leaves a newer route/child alone, and the release/teardown/handoff assertions now pin the fence argument they expect.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

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

ℹ️ No new issues in the follow-up commit. It turns a withheld ended into bounded, self-terminating cleanup; the one substantive concern from the prior review — the fence rule still retires a { root: 'exited', tree: 'live' } child — is unchanged by these commits and remains tracked in its open thread.

Reviewed changes

  • A withheld exit is now re-checked by the bounded cleanup — retainClaudeUnexpectedExit hands a { root: 'exited', tree: 'live' } exit to ClaudeReleasedChildCleanup, which publishes ended when a retry proves the tree gone and at give-up otherwise, so a surviving descendant can no longer wedge the chat until restart.
  • ended publication is modelled as a state — endedWithTreeUnproven?: boolean became ended?: 'withheld' | 'published'; settleClaudeUnexpectedExit deletes the exit unless it published, and providerHistoryWindow reads the new state for hasLiveSession.
  • The cleanup reports its outcome once — adopt accepts a one-shot onSettled(treeProven) callback, invoked on proof (true) or give-up (false), clearing itself before calling so it cannot fire twice.
  • The fence rule moved to a provider-neutral module — isAgentSessionChildReleasedThroughFence in structured-agent-session-fence-retirement.ts; the guard's logic is identical to the inline check it replaced.
  • Tests — new cleanup-settlement case and two withheld-exit publication cases (proof and give-up); pnpm test on claude-released-child-cleanup.test.ts and claude-structured-session-release.test.ts passes locally.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…evicted chat resumes

Eviction releases a Claude lease once the provider root has exited, even when its
descendants are unverifiable. The Claude adapter kept that session indexed until the
whole tree was proven gone, and every later acquire re-ran the same close first, so a
hidden chat (or an orchestration mail wake) could not resume until the app restarted.

- The host's lease release is now the adapter's close decision: the router forwards
  acknowledgeSessionRelease to the owning adapter, and the Claude adapter drops and
  settles the session and any retained exit.
- An acquire at a later fence than an indexed child retires it instead of closing it
  again; a child whose root was never seen to exit still goes through the close ladder.
- Unproven descendants move to a bounded, connection-keyed cleanup that no acquire
  consults. It retries the close ladder on a fixed schedule, then reports the last
  verdict instead of claiming the tree gone.
- An unexpected exit whose root exited with an unverifiable tree now publishes
  `ended`, so the host releases and recovers the session. The exit stays indexed as
  evidence until the release retires it, so acquisition cleanup keeps reporting a root
  exit and never a proven one.
…re a newer child

The host's release acknowledgement now carries the fence it released. The router
and the Claude adapter both retire only a child at or below that fence, through the
same fence rule an acquisition uses, so a stale acknowledgement cannot reach a
child acquired since. The rule's comment now names both ways the lease grants a
later fence.
…t wedge the chat

An unexpected exit whose close saw a descendant alive withheld `ended` with nothing
re-checking it, so the host kept a dead child and the visible chat stayed wedged
until restart. The withheld exit now joins the bounded released-child cleanup: a
retry that proves the tree publishes `ended`, and giving up publishes it with the
live verdict reported. The exit's publication is modelled as a state instead of a
boolean, and the fence rule moved to a provider-neutral module so Codex can reuse it.
@brennanb2025
brennanb2025 force-pushed the brennanb2025/r2-release-closes-adapter branch from 0c8b6c9 to f9fad0b Compare September 25, 2026 01:41

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant