Skip to content

fix(agent-status): keep a pane's agent resume handle unless the host says it is void - #1

Merged
kiwidream merged 7 commits into
mainfrom
kiwidream/resume-handle-durability
Sep 23, 2026
Merged

kiwidream merged 7 commits into
mainfrom
kiwidream/resume-handle-durability

Conversation

@kiwidream

@kiwidream kiwidream commented Sep 22, 2026 •

Copy link
Copy Markdown

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 --resume and 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. reduceAgentStatusLiveUpdate deleted a pane's sleeping record whenever a status update produced no liveRecoveryRecord. 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.

shouldRetireSleepingRecord now retires on positive evidence only:

  • the host states terminalResumeEligible === false,
  • the entry names a different agentType than the record (the pane switched agent), or
  • a provider session that differs from the record's and yields no resumable record.

A retained record keeps its identity (agent, providerSession, launchConfig, origin, capturedAt) while refreshRetainedSleepingRecord keeps its volatile fields (state, prompt, updatedAt, title, interrupted) tracking the pane — otherwise the sidebar, worktree ps and mobile would read a row latched at the state it was captured in.

Two smaller fixes ride along:

  • Host routing. splitWorkspaceSessionByHost routed sleeping records by worktree, and an unresolvable worktree fell into the local partition. Measured on a real profile: five ssh-origin records sat in local while both ssh: partitions held none. It now falls back to the ssh connection the record itself names. null/undefined are not evidence of a host, so they keep today's placement.
  • Dead code. pruneSleepingAgentSessions was 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 SessionStart and survives done, 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 === false retires; a superseding unresumable session retires; an agent switch retires; out-of-order replay preserves identity; origin and capturedAt survive 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 passed

  • pnpm tc — exit 0

  • pnpm run check:code-quality:changed — 0 findings

  • I 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

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and 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 null connection ids as unknown rather than local, per sleeping-record-execution-host-scope.ts. Backwards compatibility: the persisted record schema is unchanged; old profiles whose ssh-origin rows sit in local still load, proven by a split→merge round-trip test.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered
  • pnpm lint, pnpm typecheck, pnpm test pass

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

kiwidream and others added 3 commits September 22, 2026 12:45
…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>
@tenki-reviewer

tenki-reviewer Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Review complete. No issues found.


Retains sleeping-agent resume records (and their launch configs) across silent status updates, routes them by their own ssh connectionId when the worktree catalog has no row, and drops the now-unused pruneSleepingAgentSessions action.

Reviewed commit: c4638a3

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-23T17:09:04.270149Z 5427f80 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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>
@tenki-reviewer

tenki-reviewer Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 1 medium

💬 Inline comments (1)

🧹 Nitpicks (1) — 🟢 1 low
  • 🟢 Retained record accepts a regressed updatedAt (agent-status-sleeping-records.ts:115) — refreshRetainedSleepingRecord writes entry.updatedAt unconditionally (agent-status-sleeping-records.ts:119), so a late or replayed status frame with an older observation time regresses the durable record's freshness clock and can flip its state away from done.

Reviewed commit: f1378e4

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>
@tenki-reviewer

tenki-reviewer Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 2 medium

💬 Inline comments (1)

📍 Outside the diff (1)

  • 🟡 Source-pinning test stale after buildWorkspaceSessionHostSnapshots change (app-startup-routing.test.ts:620–622) — The source-pinning test expects the literal buildWorkspaceSessionHostSnapshots( buildWorkspaceSessionPayload(freshState), freshState ), but this PR inserted window.api.session, as the first argument to that call (src/renderer/src/app-shell/use-app-session-persistence.ts:204), so the pinned substring no longer appears in the sliced checkpoint block.

Reviewed commit: 34a6a7e

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Expand to see previous review

Splits workspace session persistence into per-host partitions with tracked clearing, and adds retained sleeping-agent records with targeted field refresh; two medium issues found.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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>
@tenki-reviewer

tenki-reviewer Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Review complete. No issues found — approved ✅.


Partitions persisted agent-status state by execution host (local/ssh via connectionId), retains sleeping-agent records across live status updates so resume handles survive, and removes the now-unneeded sleeping-session pruning path.

Reviewed commit: 2efa685

tenki-reviewer[bot]
tenki-reviewer Bot previously approved these changes Sep 23, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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>
@tenki-reviewer

tenki-reviewer Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 1 medium

💬 Inline comments (1)

Reviewed commit: 5427f80

@tenki-reviewer tenki-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Refactors workspace-session persistence to partition sleeping agent records by origin host and tightens their retention/retirement rules in the agent-status store; one medium finding on the ssh fallback default.

if (origins?.sshTargetIds && !origins.sshTargetIds.has(targetId)) {
return null
}
return toSshExecutionHostId(targetId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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.

@tenki-reviewer
tenki-reviewer Bot dismissed their stale review September 23, 2026 16:59

A newer Tenki review found issues on this pull request.

@kiwidream
kiwidream force-pushed the kiwidream/resume-handle-durability branch from 5427f80 to 2efa685 Compare September 23, 2026 17:00

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +45 to +48
if (origins?.sshTargetIds && !origins.sshTargetIds.has(targetId)) {
return null
}
return toSshExecutionHostId(targetId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@kiwidream
kiwidream merged commit 298b292 into main Sep 23, 2026
2 checks passed
kiwidream pushed a commit that referenced this pull request Sep 23, 2026
… 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
kiwidream pushed a commit that referenced this pull request Sep 23, 2026
…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
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