Skip to content

fix(session): scope agent resume to the host that captured the session - #21288

Merged
nwparker merged 3 commits into
mainfrom
fix/resume-execution-host-scope
Sep 18, 2026
Merged

nwparker merged 3 commits into
mainfrom
fix/resume-execution-host-scope

Conversation

@nwparker

@nwparker nwparker commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​582 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​582
Prod 5 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​151 $\color{#cf222e}{\Huge{\mathbf{−}}}$​7 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​144

ELI5

A coding-agent session id names a transcript in one machine's state directory. Orca was replaying one against a machine that never wrote it, so the agent answered No conversation found with session ID. This teaches the resume path to check which machine a record came from, and to decline rather than guess.

The bug, reported by a user

SSH target, remote worktree, multiple panes. The user updated to a newer hourly, and on the next launch the agent auto-resumed with:

claude '--dangerously-skip-permissions' '--resume' '87987465-66f6-4967-bf3f-0659565cbcc5'
No conversation found with session ID: 87987465-66f6-4967-bf3f-0659565cbcc5

Two things were wrong at once: it resumed when it should not have, and the identifier was meaningless on that host.

Does main reproduce it?

Yes. The new Docker SSH lane makes main at 09622f0c289 emit, on the remote machine:

ARGV [1789677448] pid=1676 ppid=1667 --dangerously-skip-permissions --resume e2e-stale-resume-87987465

That argv is read out of the container's own filesystem, so it is not an inference about intent — it is the command that actually landed on a host that never wrote that transcript.

Root cause

Three things let a record drift across machines, and nothing compared them:

  • worktreeId is repoId::path with no host component. src/shared/worktree/host-qualified-identity.ts:20-25 states outright that one repo registered on two hosts publishes the same id twice.
  • sleepingAgentSessionsByPaneKey is classified 'sleepingAgentKeyed' (src/shared/workspace-session-host-field-ownership.ts:50) — the only class in that map that bypasses host-contention arbitration. Every partition's records merge at boot, last slice wins, provenance dropped.
  • Both issuers resolve their launch target from the current catalog, never from the record.

There are two issuers, and gating only the first changes nothing

This is the part that is not reconstructable from the diff. The activation sweep does not launch quit/live records whose pane still exists — it deliberately hands them to the pane's own cold restore:

// resume-sleeping-agent-session.ts
if (isPaneOwned) {
  continue
}

with the contract stated on the type itself (src/shared/agent-session-resume.ts:65-69): "only the pane's own cold-restore path may consume them."

So the first version of this fix gated resumeSleepingAgentSessionsForWorktree alone — and the e2e still failed, with the resume still reaching the remote. buildColdRestoreAgentResumeStartup is the issuer that actually fires. A reviewer looking at a one-issuer fix would reasonably assume it was sufficient; it is not.

Gating the builder covers four call sites at once, because all of them consume one value computed once at deferred-session-reattach-connect.ts:27: the three rungs of the reattach failure ladder (:110, :170, :182) plus wakeHibernatedAgentPane, which uses the builder as its default argument (deferred-cold-restore-and-snapshot.ts:69).

Why #19882 did not already prevent this

Its release arm — a finished agent whose shell is still running releases its session and does not auto-resume — is gated on stablePaneHasLivePty. After an app restart there is no live PTY, so by design it cannot apply. #19882 is not bypassed or regressed here; it simply does not cover the restart case.

The fix

New src/renderer/src/lib/sleeping-record-execution-host-scope.ts, consulted by both issuers.

It declines; it never guesses. The record is preserved and stays resumable by hand. A refused resume is recoverable; a forked transcript is not.

It fails open on anything it cannot positively rule out — an unstamped record (undefined, which #9030 leaves on SSH orphans), an empty stamp, or a runtime: host (a paired client relabels its host's own SSH workspaces into that namespace, so it is no evidence either way).

The catalog consult is load-bearing, not belt-and-braces

The cold-restore gate checks both the pane's transport and the catalog. The transport alone was racy: two e2e runs on an identical bundle disagreed, because session.executionHostId is still unresolved on an early reattach frame — and that frame is exactly when a wrong resume escapes. With both sources the lane is deterministic over 3 repetitions. Please do not simplify this to one source.

On "fail open"

workspace-session-terminal-buffers.ts:33-38 resolves the same unhydrated-catalog window in the opposite direction, and both are correct: there the destructive act is declining to capture (the scrollback is the only copy), here it is attempting the resume. The second commit records that asymmetry at both ends so a future reader does not pattern-match one onto the other.

Testing

Check Result
New e2e, 3 repetitions x both directions 6 passed, 0 failed, zero FAIL/✘ lines
E2E mutation — cold-restore gate removed, sweep gate kept 1 failed, ledger shows --dangerously-skip-permissions --resume <id>
Unit tests, renderer/src/lib/ + terminal-pane/ 1025 files, 9922 passed, 1 failed (pre-existing, see below)
New unit file 23 passed
pnpm tc exit 0, 0 errors
ORCA_CODE_QUALITY_BASE=origin/main check-changed-code-quality.mjs passed, 0 findings across 6 files

Mutation-tested: 8 mutations applied to the predicate, zero survivors across all unit assertions, plus the e2e mutation above. Every assertion was shown to fail when the behaviour it claims to catch is reintroduced.

The positive control (still resumes a session captured on the SSH host that owns the workspace) is what stops the negative test passing vacuously — without it, the suite would pass on an app that resumes nothing at all, which is precisely the failure mode a refuse-everything gate would ship. The negative test also asserts the ledger still contains Orca's ordinary --version probe, so "the agent never ran at all" cannot masquerade as a pass.

The one unit failure is pre-existing on main. terminal-pane-hook-order-parity.test.ts fails 211 vs 209 on a clean tree; I reverted my changes and confirmed it independently. It hashes only TerminalPane.tsx and use-terminal-pane-*.ts, none of which this PR touches.

Run it yourself:

ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_WEB_CLIENT=1 \
  pnpm exec playwright test tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts \
  --config tests/playwright.config.ts --project electron-headless --workers=1

The spec is registered in config/scripts/run-ssh-docker-e2e.mjs, without which pr-e2e-gate-contract.test.mjs fails (42 passed with it).

Known-open, stated rather than buried

  • This is not proven to be the mechanism in the user's specific incident. The defect class is real, reachable and reproduced; I could not confirm their record carried a foreign stamp. If it named their own host and the transcript was simply absent there, this change does not help that instance. The artifact that settles it is connectionId on their record in the ssh:<target> partition.
  • A rebuilt host behind the same target id passes the gate. Detecting that needs a host-side transcript existence check, which the sweep's synchronous signature cannot do.
  • Records with connectionId: undefined remain fail-open by design.
  • A foreign record still participates in the sweep's dedupe bookkeeping (resume-sleeping-agent-session.ts:297-311). It requires identical provider ids across hosts, so it is left alone.
  • The tab-granular residual in findUnhydratedHostMirrorForPane is not implicated — this reproduces on a single-pane tab — and is untouched.

Relationship to other work

No data was destroyed

The resume failed before starting a session, and --dangerously-skip-permissions only skips the permission prompt — it writes nothing. If the id was minted on another host, that transcript still exists there under that id. This is an identity bug, not data loss.

A provider session id names a transcript in one machine's agent state
directory. Nothing in the resume path compared that machine against the
one the resume executes on, so a record captured on host A reached a
`--resume` run on host B, which answers `No conversation found with
session ID`.

Three things make the drift reachable: `worktreeId` is `repoId::path`
with no host component, sleeping records are `'sleepingAgentKeyed'` so
boot-time host-contention parking never arbitrates them and every
partition merges into one map without retaining provenance, and both
issuers resolve their launch target from the current catalog.

Both issuers are gated. The activation sweep hands `quit`/`live` records
whose pane still exists to the pane's own cold restore, so gating the
sweep alone changed nothing in the SSH lane.

Declines rather than guesses: the record is preserved and remains
resumable by hand. A refused resume is recoverable, a forked transcript
is not. The predicate fails open on anything it cannot positively rule
out -- an unstamped record, an empty stamp, or a `runtime:` host, which a
paired client uses to relabel its host's own SSH workspaces.

The cold-restore gate consults both the pane's transport and the
catalog. The transport alone was racy: it is unresolved on an early
reattach frame, and that frame is exactly when a wrong resume escaped.
@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

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: 80ba6d18-a94b-4a89-b43f-0e42c3e86d0f

📥 Commits

Reviewing files that changed from the base of the PR and between fa7030a and 766369e.

📒 Files selected for processing (5)
  • src/renderer/src/lib/resume-sleeping-agent-session-execution-host-scope.test.ts
  • src/renderer/src/lib/sleeping-record-execution-host-scope.ts
  • src/renderer/src/lib/worktree-runtime-owner.test.ts
  • src/renderer/src/lib/worktree-runtime-owner.ts
  • tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts

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


📝 Walkthrough

Walkthrough

The change adds execution-host scope checks for sleeping-agent session resumption. Foreign records are skipped or left as plain shells instead of issuing a resume. Legacy and native records remain resumable. Unit tests cover host identifiers and resume batches. A new SSH Docker end-to-end suite verifies foreign and owning host behavior across restart, and the runner executes the suite.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: 🟡 Moderate · up to 76636

Valid sessions can fail to restore, and a local sleeping session can be removed without being resumed when a newer foreign record shares its claim. These resume regressions should be resolved before merge.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the bug, implementation, rationale, and testing in detail, but it omits several required template sections: What Changed, Why, Linked Issue, Visual Proof or an explicit N/A ex… Add the missing required sections and complete them. Include the linked issue using Fixes #..., provide visual proof or state N/A with a reason, complete the testing and checklist items, and address AI disclosure, review, upstream-bound…
Docstring Coverage ⚠️ Warning Docstring coverage is 52.94% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 17 functions across 8 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 and concisely describes the main change: limiting agent session resume to the host that captured the session.
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 explains the bug, implementation, rationale, and testing in detail, but it omits several required template sections: What Changed, Why, Linked Issue, Visual Proof or an explicit N/A explanation, AI Disclosure, Review, Agent skill upstream boundary, Notes, and Checklist.

Resolution

Add the missing required sections and complete them. Include the linked issue using Fixes #..., provide visual proof or state N/A with a reason, complete the testing and checklist items, and address AI disclosure, review, upstream-boundary, and compatibility notes.

  • 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: 4


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: c46070fb-d824-494c-b5fe-c6371a59c301

📥 Commits

Reviewing files that changed from the base of the PR and between 4a86b2d and fa7030a.

📒 Files selected for processing (6)
  • config/scripts/run-ssh-docker-e2e.mjs
  • src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts
  • src/renderer/src/lib/resume-sleeping-agent-session-execution-host-scope.test.ts
  • src/renderer/src/lib/resume-sleeping-agent-session.ts
  • src/renderer/src/lib/sleeping-record-execution-host-scope.ts
  • tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts

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

useLiveEntry ? entry.connectionId : sleepingRecord?.connectionId,
session.executionHostId
) ||
(sleepingRecord && sleepingRecordNamesAnotherExecutionHost(sleepingRecord, state))

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 | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1,120p' src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts
rg -n 'buildColdRestoreAgentResumeStartup|useLiveEntry|sleepingRecord' src/renderer/src/components/terminal-pane

Repository: stablyai/orca

Length of output: 13542


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- startup remainder and type ---'
sed -n '110,145p' src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts
sed -n '1,45p' src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts
printf '%s\n' '--- getSleepingRecordForPane declarations/usages ---'
rg -n -C 4 'getSleepingRecordForPane' src/renderer/src
printf '%s\n' '--- host-scope helpers ---'
sed -n '1,180p' src/renderer/src/lib/sleeping-record-execution-host-scope.ts
printf '%s\n' '--- relevant record/status declarations ---'
rg -n -C 5 'interface SleepingAgentSessionRecord|type SleepingAgentSessionRecord|agentStatusByPaneKey|interface AgentStatus|type AgentStatus' src shared

Repository: stablyai/orca

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- exact method files ---'
rg -l --glob '*.ts' --glob '*.tsx' --glob '!*.test.ts' --glob '!*.test.tsx' 'getSleepingRecordForPane' src/renderer/src/components/terminal-pane
printf '%s\n' '--- exact method matches ---'
rg -n -C 8 --glob '*.ts' --glob '*.tsx' --glob '!*.test.ts' --glob '!*.test.tsx' 'getSleepingRecordForPane' src/renderer/src/components/terminal-pane
printf '%s\n' '--- host helper ---'
rg -n -C 10 --glob '*.ts' --glob '!*.test.ts' --glob '!*.test.tsx' 'function (sleepingRecordNamesAnotherExecutionHost|agentResumeOriginNamesAnotherExecutionHost)|const (sleepingRecordNamesAnotherExecutionHost|agentResumeOriginNamesAnotherExecutionHost)' src/renderer/src
printf '%s\n' '--- record/status type files ---'
rg -l --glob '*.ts' --glob '*.tsx' --glob '!*.test.ts' --glob '!*.test.tsx' 'SleepingAgentSessionRecord|AgentStatusEntry' src/renderer/src shared | head -30

Repository: stablyai/orca

Length of output: 20830


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- sleeping-record-access.ts ---'
sed -n '1,95p' src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts
printf '%s\n' '--- session identity declarations ---'
rg -n -C 5 'cacheKey:|cacheKey =' src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts src/renderer/src/components/terminal-pane/pty-connection/*.ts
printf '%s\n' '--- status and record declarations ---'
rg -n --glob '*.ts' --glob '!*.test.ts' 'SleepingAgentSessionRecord|AgentStatusEntry' src shared 2>/dev/null | head -100
printf '%s\n' '--- relevant record writes ---'
rg -n -C 5 --glob '*.ts' --glob '!*.test.ts' 'sleepingAgentSessionsByPaneKey|agentStatusByPaneKey' src/renderer/src/lib src/renderer/src/store/slices | head -240

Repository: stablyai/orca

Length of output: 39566


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- live-resume anchor logic ---'
sed -n '1,130p' src/renderer/src/lib/live-resume-anchor-record.ts
printf '%s\n' '--- shared type declarations ---'
rg -n -C 8 'export (interface|type) (SleepingAgentSessionRecord|AgentStatusEntry)|interface SleepingAgentSessionRecord|type SleepingAgentSessionRecord' shared
printf '%s\n' '--- cold restore candidates ---'
fd -i 'cold-restore|resume-startup' src tests | head -80
printf '%s\n' '--- identity checks near cold restore ---'
rg -n -C 6 --glob '*.ts' --glob '*.tsx' --glob '!*.test.ts' --glob '!*.test.tsx' 'isLiveResumeAnchorForCompletedAgent|agentProviderSessionsEqual\(.*providerSession|entry\.providerSession.*sleeping|sleepingRecord\.providerSession.*entry' src/renderer/src shared | head -200

Repository: stablyai/orca

Length of output: 2251


Do not let an unrelated sleeping record veto the live session.

When useLiveEntry is true, the startup uses entry.agentType and entry.providerSession. getSleepingRecordForPane independently returns a record by pane key without matching those fields. The unconditional sleepingRecordNamesAnotherExecutionHost check can therefore return null for a valid live-entry resume when the sleeping record belongs to another agent or provider session.

Apply the sleeping-record host check only when the record matches the selected agent and provider session, as the launch-config selection already does.

Comment on lines +271 to +272
if (sleepingRecordNamesAnotherExecutionHost(record, currentState)) {
continue

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

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1,65p' src/renderer/src/lib/sleeping-agent-pane-ownership.ts
sed -n '140,185p' src/renderer/src/store/slices/agent-status-provider-session.test.ts

Repository: stablyai/orca

Length of output: 4117


Exclude foreign records from claim deduplication.

getProviderSessionClaimKey uses worktreeId, agent, provider-session fields, and selected transcript paths. It does not include connectionId or execution-host identity. Therefore, native and foreign records can share a claim key.

The claim maps are built before sleepingRecordNamesAnotherExecutionHost skips foreign records. If the foreign record is newer, the native record fails the later newest-record check and is cleared without being resumed. Build the claim maps from non-foreign records, while retaining foreign records in worktreeRecords so the loop leaves them uncleared.

Comment thread src/renderer/src/lib/sleeping-record-execution-host-scope.ts Outdated
Comment thread tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts Outdated

@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 test-robustness note inline.

Reviewed changes — the full 6-file diff at fa7030a (2 commits).

  • Host-scope predicate (src/renderer/src/lib/sleeping-record-execution-host-scope.ts): new agentResumeOriginNamesAnotherExecutionHost compares a record's raw SSH target id against the resolved execution host, failing open on undefined/empty/runtime:*/unparseable/unresolved host. Validated against every producer: SSH relay stamps the raw target id, the WSL relay's wsl:<distro> is normalized to null in agent-status-event-applicator.ts, and local/structured/runtime PTYs are null — no legitimate same-host resume is refused.
  • Sweep gate (src/renderer/src/lib/resume-sleeping-agent-session.ts:271): a foreign record now continues instead of launching, and instead of being cleared, so it stays resumable by hand.
  • Cold-restore gate (src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts:54-62): the second --resume issuer consults both the pane transport and the catalog, covering the four call sites that share the cached builder result.
  • Tests: a 213-line unit file pinning the predicate, the fail-open cases, and the sweep with a positive control; a Docker SSH e2e that replays the incident across a relay-kill restart and asserts on the argv ledger read from the container; plus the spec's registration in run-ssh-docker-e2e.mjs.

The e2e is not theatre: the negative case asserts the ledger still holds Orca's --version probe, and the positive control requires the resume to land, so "resumes nothing" cannot masquerade as a pass.

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

Comment thread tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts
The worktree form of the resume gate resolved the current host through
getExecutionHostIdForWorktree, which answers 'local' for a worktree the
catalog has no row for. Read as a host, that made every SSH-stamped record
look foreign until its repo row landed, contradicting the module's own
contract that it reports only a positively-known disagreement. Add
getKnownExecutionHostIdForWorktree, which returns null in that silence
(no repo row for a git worktree, no folder-workspace row for a folder
workspace), and route the gate through it; the pair form already fails
open on a null host. The routing resolver keeps its default unchanged.

The CI red on the control case was a separate spec race: the ledger wait
returned as soon as the ledger was non-empty, and it already held the
first launch's `--version` probe, so the control read two probes and gave
up before the cold-restore had typed `--resume` (the failure screenshot
shows the command running in the pane). The spec now reads only the lines
the relaunch appended, anchors on the relaunch's PTY binding and its own
probe, and then waits for `--resume` for the control case or a bounded
grace for the refusal case.

@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 — the incremental delta at 766369e (1 commit since the prior pullfrog review at fa7030a).

  • Catalog silence is distinguished from 'local' — new getKnownExecutionHostIdForWorktree returns null when no row names an owner (git worktree without a repo row, folder workspace without a folder-workspace row), and getExecutionHostIdForWorktree now delegates to it plus the focused-runtime/local fallback. The resume gate therefore fails open on the unhydrated-catalog window instead of reading the routing resolver's 'local' default as a positive foreign-host verdict and refusing every SSH record until its repo row lands.
  • E2E read is tied to the resume decision — ledgerLinesSince scopes the read to the post-quit ledger delta and settleRemoteArgvLedger polls a predicate until satisfied or budget-exhausted, after waitForActivePanePtyId confirms the cold-restore decision has already been made.
  • Tests — added fail-open cases for the missing-catalog state (both SSH and local/runtime records) plus a positive refusal once a repo row names the worktree local, and pinned getKnownExecutionHostIdForWorktree against the routing resolver's 'local' substitution.

The getExecutionHostIdForWorktree refactor is behavior-preserving for every caller: the folder branch's 'local' implies no focused runtime (so the ?? fallback also yields 'local'), and the no-repo-row branch already fell through to that same default. Verified locally: 58 unit tests across the two changed test files pass, pnpm tc:web exits 0, and the changed-lines code-quality gate reports 0 findings across 8 files. The prior inline note on the e2e wait helper is addressed and the thread resolved.

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

@nwparker
nwparker merged commit 8d2f168 into main Sep 18, 2026
34 checks passed
ivanvykopal pushed a commit to ivanvykopal/orca that referenced this pull request Sep 20, 2026
stablyai#21288)

* fix(session): scope agent resume to the host that captured the session

A provider session id names a transcript in one machine's agent state
directory. Nothing in the resume path compared that machine against the
one the resume executes on, so a record captured on host A reached a
`--resume` run on host B, which answers `No conversation found with
session ID`.

Three things make the drift reachable: `worktreeId` is `repoId::path`
with no host component, sleeping records are `'sleepingAgentKeyed'` so
boot-time host-contention parking never arbitrates them and every
partition merges into one map without retaining provenance, and both
issuers resolve their launch target from the current catalog.

Both issuers are gated. The activation sweep hands `quit`/`live` records
whose pane still exists to the pane's own cold restore, so gating the
sweep alone changed nothing in the SSH lane.

Declines rather than guesses: the record is preserved and remains
resumable by hand. A refused resume is recoverable, a forked transcript
is not. The predicate fails open on anything it cannot positively rule
out -- an unstamped record, an empty stamp, or a `runtime:` host, which a
paired client uses to relabel its host's own SSH workspaces.

The cold-restore gate consults both the pane's transport and the
catalog. The transport alone was racy: it is unresolved on an early
reattach frame, and that frame is exactly when a wrong resume escaped.

* docs(session): name the inverted fail-open direction at the resume gate

* fix(session): keep an unresolved catalog out of the resume host verdict

The worktree form of the resume gate resolved the current host through
getExecutionHostIdForWorktree, which answers 'local' for a worktree the
catalog has no row for. Read as a host, that made every SSH-stamped record
look foreign until its repo row landed, contradicting the module's own
contract that it reports only a positively-known disagreement. Add
getKnownExecutionHostIdForWorktree, which returns null in that silence
(no repo row for a git worktree, no folder-workspace row for a folder
workspace), and route the gate through it; the pair form already fails
open on a null host. The routing resolver keeps its default unchanged.

The CI red on the control case was a separate spec race: the ledger wait
returned as soon as the ledger was non-empty, and it already held the
first launch's `--version` probe, so the control read two probes and gave
up before the cold-restore had typed `--resume` (the failure screenshot
shows the command running in the pane). The spec now reads only the lines
the relaunch appended, anchors on the relaunch's PTY binding and its own
probe, and then waits for `--resume` for the control case or a bounded
grace for the refusal case.
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