Repository navigation
fix(agent-status): keep a pane's agent resume handle unless the host says it is void - #1
Conversation
…t is void A terminal pane that held an agent came back as a bare shell after a host restart. The resume path itself was fine; the pane's resume handle -- its sleepingAgentSessionsByPaneKey record -- was already gone by the time the pane remounted. On a fleet of 31 SSH worktrees whose agents had all reported through hooks, only 4 still had one. The live reducer deleted the record whenever an accepted status row produced no live recovery record. That is null for several reasons and almost none of them say the pane's resume identity is void: an OSC-parsed row carries no provider session, a relay cached-pane-status replay carries none, and a worktree id may not have resolved yet. Any one of those destroyed a perfectly good handle. Retirement now needs positive evidence -- the host reporting the pane ineligible, the pane switching agent, or a superseding session that yields no resumable record. Everything else keeps the record's identity (agent, provider session, launch config, origin, capture time) while its volatile fields (state, prompt, updated time, title, last message, interrupted) keep tracking the pane, so a retained record cannot latch at the state it was captured in. A retained handle also keeps its launch-config registry entry, or the pane would wake with default flags instead of the flags it actually ran with. Also removes pruneSleepingAgentSessions, which was declared, implemented, and never called: it erases every record whose worktree is absent from a caller's set, so wiring it up before the remote catalogue hydrated would have wiped every remote handle at once. The capacity cap keeps evicting records for the panes it drops; that deletion is correct and separate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…names Partitioning a session routes each sleeping-agent record by looking its worktreeId up in the host catalog. An SSH worktree the catalog has no row for answers 'local', so five real ssh-origin records sat in the local partition while both ssh: partitions held none — a host partition that cannot answer for its own panes. When the worktree route lands on 'local' — the one verdict that may be silence rather than a host — fall back to the ssh connection the record itself names. A null or missing connectionId is not evidence of a host (it is local *or* a paired runtime), so those keep today's placement instead of guessing. mergeWorkspaceSessionsFromHosts is unchanged and still reads every partition, so profiles that already wrote these rows into 'local' load exactly as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The retirement comment cited stablyai#22270, which is an unrelated worktree-setup bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Review complete. No issues found. Retains sleeping-agent resume records (and their launch configs) across silent status updates, routes them by their own ssh Reviewed commit: c4638a3 |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c4638a33d0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Main applies a partition patch field by field, so once the last record left an ssh partition the split sent that host nothing and the stale record reloaded on the next boot. The writer now sends the empty field to every partition it previously filled, retiring the clear only after it lands. Also condenses the retirement/refresh comments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Review complete. 🟡 1 medium 💬 Inline comments (1)
🧹 Nitpicks (1) — 🟢 1 low
Reviewed commit: f1378e4 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f1378e4655
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Expand to see previous review
Splits workspace session persistence into per-execution-host partitions (local vs ssh/runtime) with a debounced field-clearing path so removed sleeping-agent rows no longer leak across hosts, and adds retirement/refresh handling for retained sleeping agent records.
The clear tracker learned which host held a sleeping-record field only from patches this renderer had already sent, so a record restored from disk and retired before the first patch carrying the field left nothing to clear: the empty-map patch updated 'local' alone and the stale remote row merged back on the next boot. Seed ownership from the boot read, ssh partitions included. The full-snapshot path bypassed the tracker entirely. `api.set` replaces a partition, so a host the snapshot routes rows to is cleared by the write itself, but a host it omits keeps its rows; the snapshot now carries an empty field for a host the tracker says it filled, added before the shadow attach so parked rows still ride along. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Review complete. 🟡 2 medium 💬 Inline comments (1)
📍 Outside the diff (1)
Reviewed commit: 34a6a7e |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 34a6a7e248
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Round-three review feedback on the resume-handle durability PR.
P1-a and P1-b pulled in opposite directions on the same mechanism, and the
mechanism cannot satisfy both. A partition patch replaces a field's whole
record, so the only clear this renderer can emit is `{ field: {} }` — and its
view of a non-local partition is incomplete by construction: the boot merge
PARKS rows it declines to adopt (base already holds terminal tabs for the
workspace, contested ids, catalog-attributed residue), and main's own
migrations rewrite the same field. A test proved the loss directly: with an
ssh partition holding a resume handle the merge parked, the renderer's session
read `{}` and the write path still sent `{ sleepingAgentSessionsByPaneKey: {} }`
to that partition, deleting a handle nobody here ever saw.
That is the direction docs/reference/ssh-execution-boundary.md forbids — leak,
never kill. So the clearing mechanism is removed and the stale-row leak it
tried to close is documented instead: a resurrected row costs one entry the
pane's own evidence check discards, where a wrong clear is unrecoverable.
Closing it properly needs a key-scoped delete in the persistence API, not a
renderer-side guess. The partition routing fix this PR is actually about — a
sleeping record routed by the ssh connection it names when the catalog has no
row for its worktree — is unchanged.
P1-c: `interrupted` was refreshed from every payload by coercing an omitted
field to `false`, deleting an interrupted record's marker. The row then read as
passive hibernation evidence, which the resume path clears instead of resuming.
Omission is not evidence: the marker now clears only on an explicit negative,
and is inherited only from a record naming the same session (EP-STATE). Fixed
in both writers — the retained-record refresh and the live-recovery rebuild.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Review complete. No issues found — approved ✅. Partitions persisted agent-status state by execution host (local/ssh via Reviewed commit: 2efa685 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2efa685498
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ing it The connectionId fallback converted any nonempty value to `ssh:<id>`. But a paired-runtime status captured during a catalog gap has its connectionId stamped with the RUNTIME environment id (web-session-tabs-sync/agent-status-patch.ts), and quit capture copies that onto the sleeping record. Such a record was written to an `ssh:<environmentId>` partition no runtime reader ever opens, so the pane's only resume handle was unavailable after a restart — the exact failure this path exists to prevent, for runtime hosts instead of ssh ones. The split now proves which id space a nonempty connectionId came from before it names a host: a known runtime environment id routes to `runtime:<id>`, a known ssh target id keeps routing to `ssh:<id>`, and an id neither space claims keeps today's local placement rather than guessing. Both sets are read through the catalogs the persistence state already carries, with the ssh side gated on `sshTargetsHydrated` — an unloaded target list is no evidence about an id. This is the third case beside the two sleeping-record-execution-host-scope.ts already names: `undefined` is unstamped, `null` is local-or-runtime, and a nonempty value is an origin whose kind still has to be proven. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Review complete. 🟡 1 medium 💬 Inline comments (1)
Reviewed commit: 5427f80 |
| if (origins?.sshTargetIds && !origins.sshTargetIds.has(targetId)) { | ||
| return null | ||
| } | ||
| return toSshExecutionHostId(targetId) |
There was a problem hiding this comment.
🟡 bug · medium
Unprovable connectionId still defaults to ssh partition
sleepingRecordOriginHostId gates the ssh classification on sshTargetIds hydration but has no runtime counterpart: when runtimeEnvironmentIds is empty or simply missing the id, control falls through to return toSshExecutionHostId(targetId) (src/renderer/src/lib/sleeping-record-origin-host.ts:48). A record stamped with a valid runtime environment id during a catalog gap, or whose environment was removed, is written to the ssh:<environmentId> partition — the exact unreachable partition the module's own doc comment says loses the pane's resume handle. Backup id sources in collectRuntimeEnvironmentIds narrow but do not close the window.
📋 Prompt for AI Agents
In src/renderer/src/lib/sleeping-record-origin-host.ts around lines 42-48, add a runtime analog of the ssh hydration gate: extend SleepingRecordOriginHosts with a nullable runtime set or a runtimeCatalogHydrated flag sourced from the store's runtime-catalog settled state, populate it in buildHostSessionRouting (src/renderer/src/lib/workspace-session-host-persistence.ts:259-264), and in sleepingRecordOriginHostId return null (not the ssh fallback) when the id matches neither id space AND the runtime catalog has not hydrated. Why: a runtime-stamped sleeping record captured during a catalog gap is otherwise filed under ssh:<environmentId>, a partition no runtime reader opens, stranding the pane's resume handle.
A newer Tenki review found issues on this pull request.
5427f80 to
2efa685
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5427f80b86
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (origins?.sshTargetIds && !origins.sshTargetIds.has(targetId)) { | ||
| return null | ||
| } | ||
| return toSshExecutionHostId(targetId) |
There was a problem hiding this comment.
Preserve the outer runtime for nested-SSH records
When a paired-runtime workspace uses a nested SSH target and its worktree catalog has not hydrated, buildMirroredAgentStatusPatch preserves the host row's nonempty connectionId, so the sleeping record contains the nested target ID rather than the outer environment ID. This classifier cannot find that ID in runtimeEnvironmentIds; on clients without a hydrated local SSH catalog it therefore files the record under ssh:<nested-target>, and with a hydrated catalog that lacks the host's nested target it leaves it local, instead of returning it to runtime:<environment>. The resulting quit/checkpoint write can miss the partition the paired-runtime workspace reloads from. Fresh evidence beyond the earlier runtime-ID finding is that the mirror only substitutes the environment ID when the host's connectionId is undefined, so nested-SSH stamps remain unhandled; carry the outer runtime owner as a separate discriminator rather than inferring it from this ID alone.
AGENTS.md reference: AGENTS.md:L89-L91
Useful? React with 👍 / 👎.
… manifest (OTA phase C follow-up) (stablyai#22376) * perf(mobile): keep four bundle chunk reads in flight across the whole manifest The fetch ran one worker per asset and paged inside an asset sequentially, so the largest script's 71 chunks were 71 serial round trips while the other readers idled. One window of four chunk reads now covers every (asset, offset) on the host's chunk grid, largest asset first. A read_limited refusal narrows the window and retries the read; eof is still read from the reply. Synthetic manifest (one 71-chunk asset, five small): 72 round trips -> 19. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the bundle-fetch family under pipelined reads Baseline moves to a1ee317, the pipelined fetch. 781 goldens change only their `baseline` header line. Six bodies move: mobile-web-bundle-fetch-paged, mobile-web-bundle-build-changed, and the four matrix-mobileweb.bundle-fetch-* goldens. The two bundle-fetch scenarios now bind requests in pipelined order, largest asset first, with every chunk sent before any reply: index.html@0 (#1), index.html@16 (#2), assets/app.js@0 (#3). - fetch-paged: the request set is identical, only reordered. The chunk sender names/ordinals and the scenarioSha256 moved; the replies and the fetched bytes did not. - build-changed: the same reorder, plus one request that is new because pipelining puts it in flight before the refusal lands (index.html@16). The refusal and the checkpoint are unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): the bundle chunk comment no longer says a reply picks the next offset Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the recording corpus after the bundle comment fix Baseline moves from a1ee317 to e94bde3, the comment-only commit on a fenced path. All 787 goldens and the scenarios file change only their `baseline` line. No golden body moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): pool four workers over planned bundle chunks, report progress per chunk Design-review fix round, sketch C: four workers take reads from one planned chunk queue, largest asset first. They replace the central pump and the read_limited narrowing. A host frees its read slot before it replies, so a lone fetch capped at four cannot trip the limit. A refusal now fails the fetch, as it did on base, and stops the other reads. - Each asset's buffer is allocated when the plan is built. That removes the nullable buffer and its guard. The per-asset byte count is gone, and the hash is the oracle (S1, S2). - The caller's signal is checked before each read and once after the pool drains, so an abort during the final window rejects with fetch-stopped (S3). The stopped check now covers only the caller's abort. The internal stop only makes late replies skip checks, hashing and progress (S4). - Progress is reported per accepted chunk. completedAssets still counts on completion (S6). - Renames: MAX_CONCURRENT_CHUNK_READS, and `reply` for the RPC reply (N1). - The slot check states exact-slot acceptance once, then classifies the refusal (N3). - Stale test titles and comments are renamed (N4). The synthetic manifest still takes 19 round trips. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say why bundle reads settle at on-settle under pipelined chunks Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the bundle-fetch family under per-chunk progress Baseline moves to 34fda6f. 782 goldens and the scenarios file change only their `baseline` line. Five bodies move: mobile-web-bundle-fetch-paged and the four matrix-mobileweb.bundle-fetch-* goldens. The only change is bundle-progress effects. One report now lands after the first accepted chunk of index.html (0 assets, 16 bytes), and the later progress ordinals shift by one. Requests, replies and fetched bytes are identical. mobile-web-bundle-build-changed keeps its body, because its one accepted chunk is the whole of assets/app.js. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drain the fake host after the fetch settles so the sibling-stop bounds can fail The wave host stopped releasing replies once the fetch settled. Reads that should have been stopped were never answered, so the read_limited bound (7) and the chunk-failure bound (5) held even with no sibling stop at all. It now drains until nothing waits. With the worker's stopped.abort() removed, both bounds fail at 76 requests. assertChunkDescribesAsset's parameter is renamed to `reply`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the recording corpus after the sibling-stop test fix Baseline moves from 34fda6f to 97b13ec. All 787 goldens and the scenarios file change only their `baseline` line. No golden body moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
…otiated mobileWeb.bundle.range (OTA phase C follow-up) (stablyai#22381) * feat(mobile-web): serve gzipped 384 KiB bundle ranges behind a capability Adds mobileWeb.bundle.range with its own strict params and result, so shipped chunk readers see no reply change. The host gzips each range at level 6 and sends identity when gzip does not shrink it, sharing the chunk method's read-slot budget and per-asset verification. status.get advertises mobileWeb.bundle.range.v1 beside mobileWeb.bundle.v1, and the method is allowlisted for paired phones. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): read the bundle range capability and range replies Adds the range reply reader and operation, and picks range or chunk from the status.get capabilities the connection already proved, so an older desktop keeps being paged in chunks with no probe round trip. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * perf(mobile): keep four bundle chunk reads in flight across the whole manifest The fetch ran one worker per asset and paged inside an asset sequentially, so the largest script's 71 chunks were 71 serial round trips while the other readers idled. One window of four chunk reads now covers every (asset, offset) on the host's chunk grid, largest asset first. A read_limited refusal narrows the window and retries the read; eof is still read from the reply. Synthetic manifest (one 71-chunk asset, five small): 72 round trips -> 19. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): add fflate 0.8.2 for gzip bundle ranges Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): decode gzip bundle ranges into a bounded buffer Inflates each range into a buffer one byte past its window, so a gzip bomb costs at most that allocation and an overlong body is visible. A corrupt, truncated or unknown-encoding body refuses as range-undecodable; a body of the wrong decoded length refuses as range-length-mismatch. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): pass the bundle read method from the session to the fetch The download reads the capabilities of the gates the reducer decided under and hands the fetch range or chunk. The fetch does not act on it yet; the range read lands on the pipelined window. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the bundle-fetch family under pipelined reads Baseline moves to a1ee317, the pipelined fetch. 781 goldens change only their `baseline` header line. Six bodies move: mobile-web-bundle-fetch-paged, mobile-web-bundle-build-changed, and the four matrix-mobileweb.bundle-fetch-* goldens. The two bundle-fetch scenarios now bind requests in pipelined order, largest asset first, with every chunk sent before any reply: index.html@0 (#1), index.html@16 (#2), assets/app.js@0 (#3). - fetch-paged: the request set is identical, only reordered. The chunk sender names/ordinals and the scenarioSha256 moved; the replies and the fetched bytes did not. - build-changed: the same reorder, plus one request that is new because pipelining puts it in flight before the refusal lands (index.html@16). The refusal and the checkpoint are unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): the bundle chunk comment no longer says a reply picks the next offset Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): read gzipped bundle ranges on the pipelined window A host that advertised mobileWeb.bundle.range.v1 is paged in 384 KiB ranges on the range grid, through the same four-read window and queue as chunks; any other host keeps the chunk grid. Each range is decoded to its exact window length before the fill checks and the asset hash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the recording corpus after the bundle comment fix Baseline moves from a1ee317 to e94bde3, the comment-only commit on a fenced path. All 787 goldens and the scenarios file change only their `baseline` line. No golden body moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the corpus at the range-read pin Repins baseline to the range-read commit and re-records every golden. Only the baseline and lockfileSha256 headers move: the lockfile gained fflate, and the bundle-fetch adapter pages the chunk path, whose requests and replies are unchanged, so no golden body moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the on-settle reason that holds for pipelined bundle reads Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the recording corpus after the on-settle comment fix Baseline moves from 252c592 to fe41226, the comment-only commit on a fenced path. Re-recorded: all 787 goldens and the scenarios file change only their baseline line. No golden body moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): keep the lockfile's patch block in main's form Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the recording corpus after the lockfile patch-block restore Baseline moves from fe41226 to 84d6fca, which restores main's patchedDependencies form in mobile/pnpm-lock.yaml. Re-recorded: all 787 goldens change only baseline and lockfileSha256, and the scenarios file only baseline. No golden body moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): pool four workers over planned bundle chunks, report progress per chunk Design-review fix round, sketch C: four workers take reads from one planned chunk queue, largest asset first. They replace the central pump and the read_limited narrowing. A host frees its read slot before it replies, so a lone fetch capped at four cannot trip the limit. A refusal now fails the fetch, as it did on base, and stops the other reads. - Each asset's buffer is allocated when the plan is built. That removes the nullable buffer and its guard. The per-asset byte count is gone, and the hash is the oracle (S1, S2). - The caller's signal is checked before each read and once after the pool drains, so an abort during the final window rejects with fetch-stopped (S3). The stopped check now covers only the caller's abort. The internal stop only makes late replies skip checks, hashing and progress (S4). - Progress is reported per accepted chunk. completedAssets still counts on completion (S6). - Renames: MAX_CONCURRENT_CHUNK_READS, and `reply` for the RPC reply (N1). - The slot check states exact-slot acceptance once, then classifies the refusal (N3). - Stale test titles and comments are renamed (N4). The synthetic manifest still takes 19 round trips. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say why bundle reads settle at on-settle under pipelined chunks Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web): announce bundle ranges on the manifest reply The manifest reply now names the range grid in an optional rangeBytes, beside chunkBytes, and the status capability is gone. The range method takes exactly the chunk params on that grid instead of a caller length. Both methods share one verified read that returns the six-field header, and the range handler checks the connection again before deflating. Range schemas move into the bundle RPC contract; SHA256_PATTERN is shared from the manifest contract. Shared refusals are tested once over both methods. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the range capability read and its session threading The phone will read rangeBytes off the loose manifest reply instead, so the read-method module goes and the session effects and hook return to the pipeline branch's version. Range imports move to the bundle RPC contract, the reply reader reuses the shared SHA256_PATTERN, and a new test pins that node's level-6 gzip from the host encoder inflates with fflate to the same bytes. The fetch and window-read modules still import the deleted names until part 2. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the bundle-fetch family under per-chunk progress Baseline moves to 34fda6f. 782 goldens and the scenarios file change only their `baseline` line. Five bodies move: mobile-web-bundle-fetch-paged and the four matrix-mobileweb.bundle-fetch-* goldens. The only change is bundle-progress effects. One report now lands after the first accepted chunk of index.html (0 assets, 16 bytes), and the later progress ordinals shift by one. Requests, replies and fetched bytes are identical. mobile-web-bundle-build-changed keeps its body, because its one accepted chunk is the whole of assets/app.js. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): page bundle ranges through one window reader chosen by the manifest The fetch keeps the pipeline's four-worker pool and builds one window reader from the manifest reply: ranges on rangeBytes when the host names it, chunks on chunkBytes otherwise. The reader returns the six-field header and a lazy bytes() so the stop and misroute checks run before any decode. A range that inflates to the wrong length now falls to the slot checks, with the one-byte-over buffer as the memory bound, so range-length-mismatch is gone. A rangeBytes this build cannot page reads as absent. Fetch names say window, not chunk. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drain the fake host after the fetch settles so the sibling-stop bounds can fail The wave host stopped releasing replies once the fetch settled. Reads that should have been stopped were never answered, so the read_limited bound (7) and the chunk-failure bound (5) held even with no sibling stop at all. It now drains until nothing waits. With the worker's stopped.abort() removed, both bounds fail at 76 requests. assertChunkDescribesAsset's parameter is renamed to `reply`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the recording corpus at the window-reader commit Baseline moves to a25a355. Re-recorded: every golden and the scenarios file move only baseline, and the five bundle-fetch goldens also move lockfileSha256 to this branch's lockfile. Every golden body is byte-identical to the pipeline branch's. The recording adapter's scripted host names no rangeBytes, so the bundle family still records the chunk path. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the recording corpus after the sibling-stop test fix Baseline moves from 34fda6f to 97b13ec. All 787 goldens and the scenarios file change only their `baseline` line. No golden body moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the recording corpus at the second pipeline merge Baseline moves from a25a355 to 4d2cab3, the merge of the pipeline's sibling-stop test fix. Re-recorded: every golden and the scenarios file move only baseline. Against the pipeline branch, only baseline and lockfileSha256 differ; every golden body is identical. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the bomb inflation without depending on call order With the fetch's sibling stop removed, a read left over from the previous test inflated into the bomb test's record first, and indexOf(601) picked it. The test now asserts some inflation stopped at 601 and none exceeded its buffer, whatever else ran. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the recording corpus at the bomb-test fix Baseline moves from 4d2cab3 to 73fde15, the test-only commit on a fenced path. All 787 goldens and the scenarios file change only their baseline line. No golden body moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web): tighten the bundle window contract and pin the range sibling stop The range bomb test reads only its own host's inflations, keyed by the gzip bodies that host sent, and plans twenty reads so a missing sibling stop is visible: with stopped.abort() removed it sends all twenty. The range params are an alias of the chunk params, and the chunk data bound is the exact base64 length of a full chunk. The phone's chunk and range replies share one header shape. The window reader closes over the client and bytes() takes the slot length the fetch computes. The host's positional read is readMobileWebBundleAssetWindow. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the recording corpus at the window-contract commit Baseline moves from 73fde15 to 7346e00. All 787 goldens and the scenarios file change only their baseline line. No golden body moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the recording corpus at the main merge Baseline moves from 7346e00 to 9c0fe1a, the merge of main at 98a6a53. Recorded with --record: all 787 goldens and the scenarios file change only their baseline line. No golden body moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web): drop a test cast and shape-named field maps The bomb test's inflation log is typed by its hoisted factory's return instead of an assertion, and the zod field maps shared by the bundle window schemas are windowParamsFields and windowHeaderFields. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the recording corpus at the lint fix Baseline moves from 9c0fe1a to f4f0915. Recorded with --record: all 787 goldens and the scenarios file change only their baseline line. No golden body moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): page the range fetch fixture on a small advertised grid The fake host names a 4 KiB range grid and a 1 KiB chunk grid on its manifest reply, which the phone pages as it would the real ones, so the fixtures shrink to a few KiB with the same shapes and each asset is hashed once. The file runs in about 360 ms instead of 3.5 s, which a loaded CI runner pushed past the 5 s test timeout. The desktop range suite still pins that the real host names MOBILE_WEB_BUNDLE_RANGE_BYTES. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the recording corpus at the range fixture fix Baseline moves from f4f0915 to d4d6aad. Recorded with --record: all 787 goldens and the scenarios file change only their baseline line. No golden body moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
ELI5
When your remote dev machine restarts, a terminal that had Claude (or Codex, or any agent) running in it comes back as a plain shell. The conversation is still on disk — Orca has just forgotten which conversation belonged to that terminal. This PR stops Orca forgetting.
What Changed
Before: after a host restart, a pane that ran an agent reopens as a bare shell and you retype
claude --resumeand pick a conversation from a list.After: the pane keeps the handle it needs to resume itself. (The resume path that consumes the handle already exists; this PR is only about the handle surviving.)
The mechanism.
reduceAgentStatusLiveUpdatedeleted a pane's sleeping record whenever a status update produced noliveRecoveryRecord. That is null for several reasons, and most of them are silence rather than evidence: an OSC-parsed row, a replayed cached pane status, or a worktree id that has not hydrated yet all arrive with no provider session. Any one of them destroyed the pane's only resume handle.shouldRetireSleepingRecordnow retires on positive evidence only:terminalResumeEligible === false,agentTypethan the record (the pane switched agent), orA retained record keeps its identity (
agent,providerSession,launchConfig,origin,capturedAt) whilerefreshRetainedSleepingRecordkeeps its volatile fields (state,prompt,updatedAt, title,interrupted) tracking the pane — otherwise the sidebar,worktree psand mobile would read a row latched at the state it was captured in.Two smaller fixes ride along:
splitWorkspaceSessionByHostrouted sleeping records by worktree, and an unresolvable worktree fell into thelocalpartition. Measured on a real profile: five ssh-origin records sat inlocalwhile bothssh:partitions held none. It now falls back to the ssh connection the record itself names.null/undefinedare not evidence of a host, so they keep today's placement.pruneSleepingAgentSessionswas declared in the slice contract and implemented but never called. It deletes every record whose worktree is absent from a caller-supplied set — wired up before the remote catalogue hydrates, it would erase every remote handle at once. Removed.Why
Diagnosed from live state, not from reading. On one fleet, 31 ssh worktrees had all reported agent status through hooks, and only 4 still had a resume handle. A live experiment confirmed the handle is written within 2s of
SessionStartand survivesdone, agent exit and PTY death — so capture was healthy and something later was destroying handles.The alternative was to re-derive the handle at resume time. That is strictly worse: the pane's own record is the only evidence that ties this pane to that conversation, and re-deriving it means guessing. A wrong resume forks a transcript unrecoverably.
Linked Issue
No existing issue covers this; happy to file one if the team prefers a tracking issue.
Fixes #
Visual Proof
N/A— no UI change. This PR only changes when an internal record is discarded; the user-visible effect (a pane resuming instead of showing a bare shell) comes from the existing resume path.Testing
8 new tests in
agent-status-sleeping-record-retirement.test.ts: a status update with no provider session retains;terminalResumeEligible === falseretires; a superseding unresumable session retires; an agent switch retires; out-of-order replay preserves identity;originandcapturedAtsurvive a refresh; the launch-config registry entry survives; capacity-cap eviction still drops the record.Verified on Linux (remote dev host), re-run by the integrator rather than taken from the author's report:
pnpm test src/renderer/src/store/slices— 3211 passed (337 files)pnpm test src/renderer/src/lib/workspace-session-host-split.test.ts— 28 passedpnpm tc— exit 0pnpm run check:code-quality:changed— 0 findingsI manually tested these changes locally
Automated tests added/updated, or explained why not below
AI Disclosure
Authored with Claude (Opus 5). Diagnosis, review and integration by a human-directed session; every worker result was independently re-run before merge.
Review
Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
Cross-platform: no platform-specific code paths touched. SSH/remote: this is the case the PR exists for — the routing fix is ssh-specific and treats
nullconnection ids as unknown rather than local, persleeping-record-execution-host-scope.ts. Backwards compatibility: the persisted record schema is unchanged; old profiles whose ssh-origin rows sit inlocalstill load, proven by a split→merge round-trip test.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm testpassNeed help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.