Skip to content

fix(ssh): stop respawning an agent onto a PTY the relay just proved alive - #17951

Merged
nwparker merged 3 commits into
mainfrom
fix/duplicate-agent-respawn-gate
Sep 3, 2026
Merged

nwparker merged 3 commits into
mainfrom
fix/duplicate-agent-respawn-gate

Conversation

@nwparker

@nwparker nwparker commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 5 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​177 $\color{#cf222e}{\Huge{\mathbf{−}}}$​12 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​165
Prod 7 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​79 $\color{#cf222e}{\Huge{\mathbf{−}}}$​39 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​40

Corruption-class: two claude --resume processes writing one transcript file.

#11006 — the gate was never host-attested, and #12448's fix could not reach it

#12448 hardened only the main-side gate (isSshPtyAbsentFromRelayError → isPtyAlreadyGoneError → spawnForStablePane). It deliberately kept the error message byte-identical — and the renderer's respawn gate reads the message, not the class.

The chain:

  1. relay-pty-source-publication.ts:117-127 answers restoreRequired — but only after pty-handler.ts:1006-1015 found the managed PTY and confirmed isProcessAlive(managed.pty.pid). That reply is positive host evidence of liveness.
  2. ssh-pty-session-reattach.ts:288-292 converted it to Error("SSH_SESSION_EXPIRED: <relayId>").
  3. spawn-execute.ts:143-172 matched the substring → cleared provider state, deleted ownership, marked the lease expired.
  4. ipc-pty-connect.ts:186 → { sessionExpired: true }.
  5. → startFreshColdRestoreAgentResume → a fresh PTY plus the agent resume command. Two claude --resume on one transcript.

A second gate of the same class: reattachSshPtySession:231 emits that same token for an identity mismatch — which ssh-pty-errors.ts:14-24 itself documents as "the id names a live PTY belonging to another pane". Main refuses to respawn on it; the renderer's .includes('SSH_SESSION_EXPIRED') did not.

Fixed at the gate, not with a downstream dedupe. restoreRequired now retries once (the relay retires the stale delivery as it answers, so attempt 2 opens a fresh one with full replay) and then throws SSH_PTY_SOURCE_RESTORE_REQUIRED, which claims nothing about absence — existing callers already route non-absence to a remount-and-reattach with no shell restart. The renderer's verdict is now isSshSessionGoneError, which excludes the identity-mismatch suffix, replacing a duplicated substring test in two files.

BEFORE  × does not claim the session expired after the retry still needs a restore
        × reattaches the live PTY when the retry opens a fresh delivery
        × stops retrying rather than stacking a delivery on an unconfirmed cancellation
        expected 'SSH_SESSION_EXPIRED: pty-1' not to contain 'SSH_SESSION_EXPIRED'
        Tests  3 failed (3)
AFTER   Tests  3 passed (3)

BEFORE  × refuses an identity mismatch, which names a live PTY owned by another pane
        × refuses an identity mismatch wrapped by the IPC boundary
        Tests  2 failed | 5 passed (7)
AFTER   Tests  7 passed (7)

#9034 — the same bug's resource consequence, fixed by the same change

Not separate. The sessionExpired branch drops the client binding and marks the lease expired but never kills the remote PTY — correctly, since there is no evidence it should. So every reconnect hitting restoreRequired left one more live detached PTY on the host and spawned a replacement, until posix_spawnp failed. Fixing the gate removes the source. No dedupe added.

#12447 — not reproducible, verified by mutation rather than by reading

I disabled the isRetiredReattachedPtySurface guard and dropped mayReviveRetiredSurface: false from the reattach bind, then ran the suites:

× suppresses a 'local'-partition retired surface from a 'current' relay
× suppresses a 'host'-partition retired surface from a 'current' relay
× suppresses a 'local'-partition retired surface from a 'legacy' relay
× suppresses a 'host'-partition retired surface from a 'legacy' relay
× restores and persists exact incarnation proof from reconnect attach

Both partitions and both relay generations are pinned, with negative controls ("reattaches a moved pane despite a … tombstone") so the guard cannot over-fire. persistPtyBinding refuses before any mutation — confirmed, the only code above it is pure reads. Guards restored; nothing landed.

But the claude --resume-on-weeks-old-sessions half of #12447 did reach production, through #11006's sessionExpired branch, and is closed by this commit.

Why #12448's regression net did not catch this

Reverting its classification does fail two tests — so the class is guarded. But the message it deliberately left unchanged is what #11006 travelled on, and stable-pane-relay-absence-respawn.test.ts kept passing under the revert because it injects errors directly and never sees the message path.

Two existing tests pinned the buggy contract and were retargeted, with reasoning in-comment.

Three weaker-than-host-attested respawn gates found and NOT touched

Each deserves its own change; recording them so they are not lost:

  • ipc-pty-connect.ts:187 — SSH_PTY_CONNECTION_MISMATCH_MARKER is thrown purely client-side by ssh-pty-id.ts:51,64 and still mints sessionExpired: true → respawn + resume. No host in the loop at all.
  • orca-runtime-resolve-terminal-pane.ts:73-97 — accepts !pty.connected (loss of contact) plus an expired lease within a 30s grace as authority to createTerminal, i.e. the host spawns a new remote shell. Several writers of that expired state are themselves client-side.
  • direct-ssh-pane-retry-ledger.ts:77-96 — if (!args.tabPtyId) return true: absence from a client-side map arms a generation bump that lands on the fresh-spawn path.

Verification

providers + terminal-pane + ssh + ipc/pty     695 files, 7359 passed | 27 skipped
src/main + relay + shared                     3681 passed
tc:node / tc:web                              clean
check:code-quality:changed                    0 new findings across 10 files

One pre-existing unrelated failure (run-electron-vite-dev.test.ts, mac dev-app symlink copy), confirmed failing identically on a clean stash.

Fixes #11006, #9034
Refs #12447, #12448

@coderabbitai

coderabbitai Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

Next included review available in 5 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: a6fe590c-a5e4-45f7-80c7-5610c4b2c0fc

📥 Commits

Reviewing files that changed from the base of the PR and between 21210aa and 5f259a7.

📒 Files selected for processing (12)
  • src/main/providers/ssh-pty-errors.ts
  • src/main/providers/ssh-pty-live-source-restore-respawn.test.ts
  • src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts
  • src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts
  • src/main/providers/ssh-pty-relay-absence-verdict.test.ts
  • src/main/providers/ssh-pty-session-reattach.ts
  • src/renderer/src/components/terminal-pane/ipc-pty-connect.ts
  • src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts
  • src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts
  • src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts
  • src/renderer/src/components/terminal-pane/pty-connection/ssh-session-connect.ts
  • src/renderer/src/components/terminal-pane/pty-connection/ssh-session-gone-verdict.test.ts

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: d76853c3-5561-4c3d-be90-17924f6dbf9b

📥 Commits

Reviewing files that changed from the base of the PR and between 5dedbd4 and 89b32e0.

📒 Files selected for processing (1)
  • src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts

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


📝 Walkthrough

Walkthrough

The provider now retries live PTY source restoration twice, rolls back pending activation leases between attempts, and reports a dedicated restore-required error after failure. Renderer code now uses isSshSessionGoneError to distinguish expired sessions from identity mismatches, source-restore errors, and transport failures. Tests cover provider retry behavior and renderer verdict classification.

Merge Risk: ⚪ Minimal · up to 89b32

The change prevents live PTYs involved in identity mismatches from triggering an unnecessary cold restore; no actionable merge-blocking risk remains beyond normal checks.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 10 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: preventing agent respawn onto a PTY that the relay confirmed is still alive.
Description check ✅ Passed The description provides detailed context, explains the failure and fix, links the relevant issues, documents testing, and identifies out-of-scope findings. It omits the repository template headings, …
Linked Issues check ✅ Passed The changes address #11006 by distinguishing a live PTY requiring source restoration from an expired session, retrying restoration, preserving live-PTY reattachment, and excluding identity mismatches …
Out of Scope Changes check ✅ Passed The production changes and tests remain focused on SSH PTY source restoration, reattachment error classification, identity mismatch handling, and duplicate agent prevention. The discussion of other po…
Full details: Description check

Explanation

The description provides detailed context, explains the failure and fix, links the relevant issues, documents testing, and identifies out-of-scope findings. It omits the repository template headings, checklist confirmations, and explicit visual-proof or N/A entry, but the substantive content is mostly complete.

Full details: Linked Issues check

Explanation

The changes address #11006 by distinguishing a live PTY requiring source restoration from an expired session, retrying restoration, preserving live-PTY reattachment, and excluding identity mismatches from session-gone handling. They also address #9034 by preventing the erroneous expired-session path that left detached PTYs and spawned replacements.

Full details: Out of Scope Changes check

Explanation

The production changes and tests remain focused on SSH PTY source restoration, reattachment error classification, identity mismatch handling, and duplicate agent prevention. The discussion of other potential gates does not introduce unrelated code changes.

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

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

Run ID: be84e965-a75f-4ad0-b7aa-88d87270d19d

📥 Commits

Reviewing files that changed from the base of the PR and between 5aa02ea and 5dedbd4.

📒 Files selected for processing (11)
  • src/main/providers/ssh-pty-errors.ts
  • src/main/providers/ssh-pty-live-source-restore-respawn.test.ts
  • src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts
  • src/main/providers/ssh-pty-relay-absence-verdict.test.ts
  • src/main/providers/ssh-pty-session-reattach.ts
  • src/renderer/src/components/terminal-pane/ipc-pty-connect.ts
  • src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts
  • src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts
  • src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts
  • src/renderer/src/components/terminal-pane/pty-connection/ssh-session-connect.ts
  • src/renderer/src/components/terminal-pane/pty-connection/ssh-session-gone-verdict.test.ts
💤 Files with no reviewable changes (1)
  • src/renderer/src/components/terminal-pane/pty-connection/ssh-session-connect.ts

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

const message = error instanceof Error ? error.message : String(error)
return (
message.includes(SSH_SESSION_EXPIRED_ERROR) &&
!message.includes(SSH_PTY_IDENTITY_MISMATCH_ERROR)

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Exclude textual identity-mismatch errors.

Line 22 excludes only SSH_PTY_IDENTITY_MISMATCH. Main also accepts /identity mismatch/i as an identity mismatch. An IPC-wrapped error that contains SSH_SESSION_EXPIRED and textual identity mismatch, but not this marker, returns true here. The renderer then marks the live PTY expired and can start a second cold restore.

Match the main-process verdict semantics and add a test for the textual variant.

Proposed fix
-    !message.includes(SSH_PTY_IDENTITY_MISMATCH_ERROR)
+    !message.includes(SSH_PTY_IDENTITY_MISMATCH_ERROR) &&
+    !/identity mismatch/i.test(message)
📝 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
!message.includes(SSH_PTY_IDENTITY_MISMATCH_ERROR)
!message.includes(SSH_PTY_IDENTITY_MISMATCH_ERROR) &&
!/identity mismatch/i.test(message)

@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 critical issues — one minor observation, nothing blocking.

Reviewed changes

  • New SSH_PTY_SOURCE_RESTORE_REQUIRED verdict — restoreRequired no longer mints SSH_SESSION_EXPIRED; reattachSshPtySessionForSpawn retries once (rolling back the source-activation lease when its cancellation is confirmed) and only then throws a token that claims nothing about PTY absence.
  • Renderer gate narrowed to host-attested absence — isSshSessionGoneError replaces the duplicated includes('SSH_SESSION_EXPIRED') tests and excludes the SSH_PTY_IDENTITY_MISMATCH suffix, so an id naming another pane's live PTY no longer triggers a cold-restore respawn.
  • Tests — two new suites (respawn retry, renderer verdict) plus two retargeted tests pin the new contract with exact assertions.

The fix is well-reasoned and the relay-side premise checks out: pty-handler.ts attach() re-verifies isProcessAlive before any restoreRequired can be produced, and relay-pty-source-publication.ts retires the stale delivery as it answers, so attempt 2 reopens a fresh one with full replay. I also confirmed the lease && !(await lease.rollback()) branch is reachable in production — a plain spawn reattach can hit the activate() branch that answers restoreRequired alongside a sourceActivation when a checkpoint recovery is mid-flight — so that guard and its test are not dead code.

ℹ️ The new restore-required error surfaces to the user as an unhumanized token

When the retry still lands on restoreRequired (checkpoint genuinely unavailable), the thrown SSH_PTY_SOURCE_RESTORE_REQUIRED: <relayId> <reason> reaches the renderer's onError/reportError path and is rendered by TerminalErrorToast. The toast's humanizer (UNREATTACHABLE_SESSION_SOURCES) only knows SSH_SESSION_EXPIRED and PTY "..." not found, so this new token is displayed raw with a "file an issue" link instead of the friendly copy the expired-session path gets. Before this change that same failure was an SSH_SESSION_EXPIRED message the humanizer already covered, so this is a small UX regression on an otherwise-strict improvement.

Technical details
# Humanize the source-restore token in the terminal error toast

## Affected sites
- src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx — `UNREATTACHABLE_SESSION_SOURCES` has no entry for `SSH_PTY_SOURCE_RESTORE_REQUIRED`, so `isExplainedTerminalError` returns false and the raw token renders with a "file an issue" link.

## Required outcome
- A user-facing `SSH_PTY_SOURCE_RESTORE_REQUIRED: ...` failure shows copy equivalent to the other "couldn't reattach to this pane's session" verdicts rather than the raw internal token.

## Suggested approach (optional)
- Add `SSH_PTY_SOURCE_RESTORE_REQUIRED:[ \t]*\S*` (or a dedicated pattern) to `UNREATTACHABLE_SESSION_SOURCES` in `TerminalErrorToast.tsx` and extend the corresponding test in `TerminalErrorToast.test.ts`.

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

Neil and others added 3 commits September 2, 2026 20:36
A `pty.attach` reply carrying `sourceRecovery: restoreRequired` is the relay
answering for a PTY it just found in its pool and proved alive with
`isProcessAlive`; only the stale output delivery was retired. Main converted
that into `SSH_SESSION_EXPIRED`, which is the token every caller uses to retire
the pane binding and cold-restore the agent, so a transient reconnect started a
second `claude --resume` over a running one's transcript and left the previous
remote PTY detached — one more per reconnect until the host refused to fork.

Retry the attach once (the relay retires the stale delivery as it answers, so
the next attach opens a fresh one with full replay), then fail with a
restore-required verdict that makes no claim about absence. Callers already
route anything short of absence to the unverifiable pane-recovery path.

Also tighten the renderer's expiry verdict, which was a bare substring test: an
identity mismatch names a LIVE PTY owned by another pane and observes nothing
about this one, and main's own gate already refuses to respawn on it.

Refs #11006, #9034
The refusal now carries SSH_PTY_SOURCE_RESTORE_REQUIRED, so the ratchet
asserts the discriminating token instead of the one it no longer shares.
@nwparker
nwparker force-pushed the fix/duplicate-agent-respawn-gate branch from 89b32e0 to 5f259a7 Compare September 3, 2026 03:38

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

Reviewed changes

  • Retargeted the restoreRequired refusal test — ssh-pty-reattach-absence-discrimination.test.ts now asserts the discriminating SSH_PTY_SOURCE_RESTORE_REQUIRED token (toContain) and its absence of SSH_SESSION_EXPIRED (not.toContain), replacing the assertion that pinned the expiry text the code no longer emits on that path.

The retarget is correct against the production code: reattachSshPtySessionForSpawn throws SSH_PTY_SOURCE_RESTORE_REQUIRED: <relayId> <reason> after exhausting the retry, and the fixture's stable attach reply drives both attempts without a source-activation lease, so the not.toContain(SSH_SESSION_EXPIRED_ERROR) assertion would fail if a future change re-wore the expiry token here. The four main-side suites pass locally (14 tests).

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@nwparker
nwparker merged commit 76836b3 into main Sep 3, 2026
32 checks passed
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…live (stablyai#17951)

* fix(ssh): stop reporting live relay PTYs as expired sessions

A `pty.attach` reply carrying `sourceRecovery: restoreRequired` is the relay
answering for a PTY it just found in its pool and proved alive with
`isProcessAlive`; only the stale output delivery was retired. Main converted
that into `SSH_SESSION_EXPIRED`, which is the token every caller uses to retire
the pane binding and cold-restore the agent, so a transient reconnect started a
second `claude --resume` over a running one's transcript and left the previous
remote PTY detached — one more per reconnect until the host refused to fork.

Retry the attach once (the relay retires the stale delivery as it answers, so
the next attach opens a fresh one with full replay), then fail with a
restore-required verdict that makes no claim about absence. Callers already
route anything short of absence to the unverifiable pane-recovery path.

Also tighten the renderer's expiry verdict, which was a bare substring test: an
identity mismatch names a LIVE PTY owned by another pane and observes nothing
about this one, and main's own gate already refuses to respawn on it.

Refs stablyai#11006, stablyai#9034

* fix(lint): merge the duplicate pty-connect-limits import

* test(ssh): stop pinning the expiry token on a restoreRequired refusal

The refusal now carries SSH_PTY_SOURCE_RESTORE_REQUIRED, so the ratchet
asserts the discriminating token instead of the one it no longer shares.

---------

Co-authored-by: Neil <neil@example.com>
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.

Transient SSH disconnect treats live relay PTYs as dead, causing duplicate agent respawns when a tab is opened

1 participant