Skip to content

Workspaces 2/10: standalone persistence and agent recovery - #615

Draft
nedtwigg wants to merge 14 commits into
workspaces-windowfrom
workspaces-persist
Draft

Workspaces 2/10: standalone persistence and agent recovery#615
nedtwigg wants to merge 14 commits into
workspaces-windowfrom
workspaces-persist

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Per-Workspace persistence through the Window aggregator, restore per Workspace off one PTY list, agent recovery lifted into lib/src/host and shared with VS Code, the dev state root, the orphan temp sweep.

Based on workspaces-window.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u

nedtwigg and others added 11 commits September 9, 2026 21:20
One Window has to plan a resume or a cold restore per Workspace off one boot
payload, so the pieces `resumeOrRestore` did in one pass are now separable:
`collectLivePtys` runs the single `requestInit` wait for the whole window, and
`resumeOrRestoreFrom` plans over a slice of that list against an explicitly
supplied record. `restoreSession` takes the same two sources. The wrapper
`resumeOrRestore(platform)` is unchanged, so VS Code, Pocket, and the website
keep their existing behavior.

`claimUnowned` mirrors the unowned-PTY claim in message-router.ts: a live PTY
that no saved Workspace names joins the plan that asks for it, and because such
an id has no saved layout slot the plan degrades to the flat live list exactly
as a single Wall already does.

`PlatformAdapter.alertSeed` is the standalone twin of the seed the VS Code
extension host does while answering a cold boot: standalone's AlertManager lives
in the webview, so the restore path is the only thing that can hand a fresh PTY
the TODO its saved pane carried.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The aggregator now owns the whole Window blob: `seedWindowSession` installs what
the last run left on disk before any Wall mounts, `previousWorkspaceSession`
answers a Workspace's save with its own record (seed until its Wall publishes,
which is what keeps a dead PTY's retained cwd and alert from being dropped on
the first save after a restore), and one 500 ms debounced writer collapses N
Workspaces reacting to one event into a single host write. Installing the writer
also subscribes to the Workspace store, because a reorder, rename, or active
switch changes the blob with no Session changing. `flushWindowSession` is the
quit step between the last Wall flush and the host's drain.

`window-persistence.ts` loses its flag branches for `loadWindowState` /
`saveWindowState`; a pre-Window blob is wrapped as the one Workspace, the only
migration. `dormouse.flags.workspaces` is gone with them.

Both stores that feed the dirty tracker now name the Surface that changed, so a
Workspace only rebuilds its record for its own Surfaces — unkeyed stays a
store-wide reset every Wall takes. Without this every idle Workspace would run a
`getCwd` per pane on every heartbeat while any Workspace was busy.

Both adapters are rewired onto the Window helpers; they still persist nothing
until the flip.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The press-wait-press machine never needed anything host-specific: interrupt, a
monotonic received count, the output since a mark, and the live id set. It now
lives in `lib/src/host/recovery-capture.ts` over that four-method port, with
every rule and constant carried across, and reports each detected invocation the
moment it is found rather than owning a file.

`lib/src/host/recovery-store.ts` is the record half for a Node-resident host:
begin-capture clears once per process and merges after (a second window must not
wipe the first), writes are owner-only and temp-then-rename, and `take` is a
single-use destructive read with a 7-day expiry, a shape guard, and
null-prototype maps. No directory means memory-only with one warning.
`recovery.ts` is the pair, for the sidecar bundle.

VS Code's `captureAgentRecoveryCommands` becomes an adapter over the lifted
machine and keeps only what is its own: where the record lives and how it is
written. Its behavior is unchanged.

The capture is now pinned by `recovery-capture.test.ts` on a virtual clock —
`session-state.test.ts` covers alert persistence only and never exercised the
machine, so this is its first real net.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
`pty-core` gains the three primitives the shared capture machine needs:
`received`, a monotonic per-pane counter a replay trim never decrements (the
buffer's own `chars` is pinned at the cap on exactly the long-running agent pane
recovery exists for, so it is not a usable coordinate), `outputSince` clamped to
what the buffer still holds, and `liveIds`.

`recovery.cjs` joins the three bundles the sidecar requires, and `main.js`
answers `pty:captureRecovery` (begin, capture, report the count) and
`recovery:take` (claim, once). The record lives under `DORMOUSE_RECOVERY_DIR`.
The sidecar owns this rather than Rust because the replay buffers the detection
reads are here, its lifetime is exactly one activation so read-and-unlink has
one home, and the browser-dev harness gets the feature for free — which is why
the harness now passes its own per-run temp dir.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
`capture_agent_recovery` and `take_recovery_commands` forward the two new
sidecar messages. Both are `#[tauri::command(async)]` because they reach the
blocking helper, which `sidecar_commands_are_async` now covers.

The boot-time `clear_session` is replaced by `sweep_orphan_session_temps`: once
standalone persists, deleting the snapshot at every launch is exactly wrong, but
a crash between the temp write and the rename still leaves a file nothing will
ever read or overwrite — and one written before Dormouse stopped storing
transcripts carries a transcript. The sweep derives its suffix through the real
writer so the two cannot drift.

`state_root` gives a debug build `app_data_dir/dev`. Dev and the installed app
resolve to the same `app_data_dir()` (it is keyed by the Tauri identifier), so
without the split a dev launch would restore the installed app's Workspaces and
the two would clobber one snapshot. The notepad archive and the Burrow state
directory stay shared — they are machine-local stores, not this build's copy of
the user's window. The sidecar gets the root as `DORMOUSE_RECOVERY_DIR`.

The quit phase budget goes to 14 s, staying clear of the webview's new 10 s
teardown ceiling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Both adapters persist: `PERSIST_SESSION` is gone, the boot-time blob deletion
with it (Rust's orphan-temp sweep is what retires the transcripts already on
disk). `getWindowState` is the boot reader; `getState` answers nothing, because
the blob is a Window and every shared reader of `getState` wants a bare Session.
`init()` also claims the agent-recovery record for the panes it is about to
restore, so `getRecoveryCommands` is a synchronous answer by the time the cold
restore asks.

`main.tsx` rebuilds the Window: seed the aggregator, install the Workspaces,
install the writer, then plan each Workspace off one `collectLivePtys`. Reload
and relaunch are the same path with a different live list — on a reload the PTYs
partition by saved pane id and every Workspace resumes over its own; on a
relaunch the list is empty and every Workspace cold-restores into fresh shells
at its saved cwds, with nothing replayed. A live PTY no Workspace names goes to
the active one.

The quit teardown captures agent recovery first (the hint exists only between
the interrupt and the kill) and flushes the Window blob after the final
per-Workspace save; the ceiling goes to 10 s. A failed capture cannot abort the
save behind it.

Deviation from the plan: `gracefulKillPtys(ids?)` is not introduced. Nothing in
this stage kills a subset, and neither Rust nor the sidecar can honor an id
list yet, so the argument would be silently ignored. Multi-window adds it with
its own half.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
transport.md's governing rule reverses for standalone: window state is the app's
contract, so quit restores structure and auto-resumes agents, and the rationale
records why — the two objections to the old store were both about content
(transcripts, a WKWebView WAL) and both were already fixed. The persisted-session
types describe the aggregator as it now is: seeded at boot, one debounced writer,
a Workspace compared against its own record, a store change writing too. The
transcript-retirement bullet becomes the orphan-temp sweep.

standalone.md rewrites Persistence around the per-Workspace boot and the dev
state root, gains an Agent recovery subsection for the sidecar-owned record, and
its quit teardown puts the capture first with the new 10 s / 14 s budgets.

layout.md promotes standalone persistence out of the rollout ledger and states
the two rules it owns: publish to the aggregator, and mark dirty only for
Surfaces this Wall owns. vscode.md records that the capture machine is shared and
drops the workspaces-flag Future item. security-local.md, notepad.md, and
glossary.md follow.

Budgets ratcheted: layout 8400, notepad 3800, security-local 2600, standalone
4650, transport 4650, vscode 7400.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Quality pass over Stage B, behavior unchanged.

Shared code:
- Lift `sliceSince` into `lib/src/host/replay-buffer.ts`, the one replay-buffer
  clamp for both hosts; `pty-core.js` takes it as an injected option so its
  `node --test` suite still needs no build artifact.
- VS Code drops its duplicate record half and uses `createRecoveryStore`, gaining
  the store's owner-only modes.
- `windowPaneIds`, `wallBootFromResult`, `SESSION_SAVE_DEBOUNCE_MS`, and an
  exported `silent` log replace their copies.
- `PlatformAdapter` gains `getWindowState` / `saveWindowState`, so `main.tsx`
  needs no local adapter type and neither adapter casts `unknown`.
- `standalone/src/window-recovery.ts` holds what both standalone adapters do
  identically with the Window slot and the recovery claim.

Removed: the dead `activeWorkspaceSession` / `replaceActiveSession` helpers, the
aggregator's second map and `adoptWorkspaceSession`, the unused
`recoveryCommands` option on `resumeOrRestoreFrom` / `restoreSession`, the
harness's `captureAgentRecovery` (a reload there is a live resume, so pressing
`^C` would interrupt running work), and Rust's `session_temp_suffix` probe.

Cost:
- Batch the cwd probe: `getCwds` on the adapter, `pty:getCwds` in the sidecar
  (one `lsof` for every pid), `pty_get_cwds` in Rust.
- Skip it entirely on the post-kill quit flush, where every probe answers null.
- Start the recovery claim in `init()` without awaiting, and await it in
  `restoreWindow` only when a Workspace can actually cold-restore.
- Scan the recovery buffer incrementally, a bounded overlap per tick.

Correctness-adjacent: the session writer now removes its own temp file on every
error path, `recovery:take` answers through `respondAsync`, and the capture's
in-process interrupts no longer emit a stray `interruptDone`.

Specs: standalone.md, transport.md, vscode.md updated; two budgets ratcheted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Ten confirmed findings on Stage B, each with a test that fails without it:

- `getCwdsForPids` parses `err.stdout` when `lsof` exits non-zero over a dead
  pid, so one gone process no longer costs every pane its cwd for that save.
- Live helper PTYs are routed to the Workspace holding their source instead of
  all landing on the active one, where they were promoted to top-level panes and
  voided that Workspace's saved layout. `routeUnownedPtys` resolves parents
  across the whole live list before any slicing.
- The quit teardown ceiling is derived from the summed step budgets — including
  the round-trip margin Rust adds to the two sidecar steps — instead of a 10s
  literal below that sum, which aborted the final flush and drain.
- `readPersistedWindow` drops a duplicate Workspace id, and `restoreWindowOrFresh`
  degrades a throwing restore to a fresh Window and overwrites the blob, so a bad
  blob cannot leave the app rendering nothing on every launch.
- A Workspace with no record yet gets an empty-but-valid one at creation, so the
  blob's `activeWorkspaceId` always names a Workspace the blob contains.
- The aggregator writes synchronously on `pagehide` and stops debouncing from
  there, instead of arming a timer that never fires.
- A Wall unmounting because its Workspace was closed no longer publishes on the
  way out, which had undone `forgetWorkspaceSession`.
- The recovery store writes through a per-write temp name and removes it in a
  `finally`, so a failed write leaves nothing behind and two hosts sharing a
  storage directory cannot rename over each other.
- Both standalone adapters put `getCwds` behind `coalesceCwds`, folding the
  Walls' concurrent probes into one invoke.

The per-Workspace boot moves out of `main.tsx` into `standalone/src/window-restore.ts`
so it can be tested. `resumeOrRestore` is unchanged for single-Wall hosts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
- `security-local.md`: VS Code's `recovery.json` is owner-only and
  temp-then-rename, not "at the umask" — the nightly audit reads this file.
- `transport.md`: only the two standalone adapters declare `persistsSession`.
- `standalone.md`: document `DORMOUSE_STATE_DIR` / `DORMOUSE_RECOVERY_DIR`, the
  helper routing and fresh-Window degradation at boot, the coalesced cwd probe,
  and a quit ceiling derived from the step budgets rather than a literal.
- `layout.md` / `transport.md`: the per-Workspace boot now lives in
  `standalone/src/window-restore.ts`.

`standalone.md` re-baselined with `node scripts/spec-lint.mjs --ratchet`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Carries origin/main (#611, #612) and the hidden-Workspace minimize up the
stack. Only the spec word budgets conflicted; this stage's larger values
stand, ratcheted for the merged text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u
@nedtwigg
nedtwigg added this pull request to stack #624 September 10, 2026 21:44

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Feedback on work in progress — not a merge verdict. Mark the PR ready when you want the full review.

The quit budget is now derived arithmetic spread across three files, and nothing pins it. QUIT_TEARDOWN_CEILING_MS works out to 12 300 ms ((1300+1500) + 1500 + (2000+1500) + 1500 + 2000, +1000 slack), and its own doc comment says it is "exported for the test that pins it above the sum" — but no test imports it, so neither that relationship nor "stays under Rust's QUIT_PHASE_TIMEOUT_MS" is checked anywhere. SIDECAR_ROUND_TRIP_MARGIN_MS = 1500 is likewise a hand-copy of the timeout + 1500 in capture_agent_recovery and pty_graceful_kill_all.

The drift this guards against already happened inside the diff: Rust's comment on QUIT_PHASE_TIMEOUT_MS still claims a 10 s webview ceiling, and quit.test.ts says the same. The real margin is 1 700 ms, not 4 000 — and that comment is exactly what a later editor would read when deciding whether 14 000 is still enough. lib/src/lib/mirrored-constants.test.ts is the existing home for this shape ("a 'must stay in sync' claim names the test that pins it"), and it already pins OPEN_PORT_TIMEOUT_MS across the same TS↔Rust boundary. Two source-reading assertions there — QUIT_TEARDOWN_CEILING_MS < QUIT_PHASE_TIMEOUT_MS, and SIDECAR_ROUND_TRIP_MARGIN_MS against the two timeout + N call sites — would make the whole derivation self-checking, and let both comments drop their literals for good. Inline suggestions below just remove the two stale numbers.

flushWindowSession() is a teardown step with no bound of its own and no term in STEP_BUDGET_TOTAL_MS. The comment above the constants says the ceiling is derived from the sum of the step bounds precisely so it cannot abort the final save, and docs/specs/standalone.md states it as a rule ("derived from the sum of those bounds, never a literal"). Today the step is free — both saveWindowState implementations return void, so await inFlight resolves on a null — but that is a property of the current adapters, not of the step. A writer that ever returns a slow promise eats the 1 000 ms slack and then drainSessionSaves, which is the failure the derivation exists to prevent. Either bound it like its neighbours and add its term, or say in the comment that it is deliberately unbounded because the writer is synchronous.

Comment thread standalone/src-tauri/src/lib.rs Outdated
Comment thread standalone/src/quit.test.ts Outdated
nedtwigg and others added 2 commits September 10, 2026 17:18
The teardown ceiling is derived arithmetic spread across quit.ts and
lib.rs, and nothing checked it: the ceiling had to stay under Rust's
QUIT_PHASE_TIMEOUT_MS, and SIDECAR_ROUND_TRIP_MARGIN_MS was a hand copy
of the `timeout + 1500` in the two sidecar commands. Both comments had
already drifted to a "10 s" ceiling that no longer existed.
mirrored-constants.test.ts now reads the derivation out of quit.ts term
by term and pins the ceiling under the Rust watchdog and the margin to
both Rust call sites; the stale literals are gone from the comments.

flushWindowSession was the one teardown step with no bound and no term
in the ceiling. Both standalone writers are synchronous today, but the
bound is on what the writer may return: a stalled one would have eaten
the ceiling's slack and then the drain — the final save the derivation
exists to protect. It is bounded like its neighbours, with its own term.

Also pins end to end that a restored Workspace's first save reads the
record boot seeded, so a pane whose PTY did not survive the relaunch
keeps its retained cwd and alert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
Carries the strip and lifecycle review fixes up the stack.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 11, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: e1a6f2a
Status: ✅  Deploy successful!
Preview URL: https://79d6e422.mouseterm.pages.dev
Branch Preview URL: https://workspaces-persist.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Feedback on work in progress — not a merge verdict. Mark the PR ready when you want the full review.

The derivation is pinned and the Window write is bounded, so both of last round's points are closed. Two things about what the new pin actually holds.

The margin to Rust's watchdog is down to 700 ms, and the pin asserts only a sign. Adding WINDOW_WRITE_MS moves QUIT_TEARDOWN_CEILING_MS to 13 300 ms against Rust's 14 000, so the gap is 700 ms where it was 1 700 — and keeps the webview ceiling under the Rust per-phase watchdog passes for any positive gap. One more 500 ms step term would leave 200 ms, inside Rust's own QUIT_POLL_STEP_MS, so the watchdog can force the exit before the webview's own guard has reached quit_proceed — the aborted final save the whole derivation exists to prevent, arrived at through a green test. The inline suggestion pins the gap above the poll step instead; it holds today at 700 > 500. The comment this commit rewrote in lib.rs still says the watchdog Comfortably exceeds the ceiling, and at 700 ms that is the sentence a later editor reads when deciding whether 14 000 is still enough.

The pin reads the sum's value, not its completeness. expect(ceiling).toBeGreaterThan(valueOf('STEP_BUDGET_TOTAL_MS')) compares the sum against the same sum plus the slack term, so it can only fail if that slack goes to zero — nothing checks that every bound runQuitTeardown actually passes is a term in STEP_BUDGET_TOTAL_MS, which is the defect the last round found by hand. The same source-reading style reaches it: each step's bound is a literal *_MS identifier in the call (adapter.requestSessionFlush(POST_KILL_FLUSH_MS), withTimeout(..., WINDOW_WRITE_MS, ...)), so pulling those out of runQuitTeardown's body and asserting each is a term would make the next unbudgeted step fail the test rather than wait for a reviewer.

it('keeps the webview ceiling under the Rust per-phase watchdog', () => {
const ceiling = valueOf('QUIT_TEARDOWN_CEILING_MS');
expect(ceiling).toBeGreaterThan(valueOf('STEP_BUDGET_TOTAL_MS'));
expect(ceiling).toBeLessThan(rustMs('QUIT_PHASE_TIMEOUT_MS'));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A bare toBeLessThan still passes at a 200 ms gap, which is inside Rust's own QUIT_POLL_STEP_MS — the watchdog could then fire before the webview's guard has reached quit_proceed. Pinning the gap above the poll step keeps the claim the Rust comment makes, and holds today (700 > 500).

Suggested change
expect(ceiling).toBeLessThan(rustMs('QUIT_PHASE_TIMEOUT_MS'));
// The webview still has to reach `quit_proceed` after its own guard fires,
// so the gap must clear Rust's poll granularity, not merely be positive.
expect(rustMs('QUIT_PHASE_TIMEOUT_MS') - ceiling).toBeGreaterThan(rustMs('QUIT_POLL_STEP_MS'));

// sum of all teardown work. Comfortably exceeds the webview's own 8 s teardown
// ceiling (docs/specs/standalone.md §Quit flow).
const QUIT_PHASE_TIMEOUT_MS: u64 = 12_000;
// sum of all teardown work. Comfortably exceeds the webview's own teardown

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

"Comfortably" described a 4 000 ms gap, then a 1 700 ms one, and now a 700 ms one — it is the one part of this comment nothing checks. Dropping it leaves the pin as the claim.

Suggested change
// sum of all teardown work. Comfortably exceeds the webview's own teardown
// sum of all teardown work. Sits above the webview's own teardown

gracefulKill: slow("gracefulKill", 2000 + 1500),
drain: slow("drain", 2000),
});
mocks.flushWindowSession.mockImplementation(slow("flushWindow", 1000));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

At exactly WINDOW_WRITE_MS the work and withTimeout's guard land on the same tick, and the work only wins because its timer was registered first (the argument is evaluated before withTimeout is entered). The other steps here don't race — the fake adapter doesn't enforce the budget it's passed — so this is the one line where a future reordering flips the assertion. One tick inside the bound is unambiguous and still the worst case the webview can observe with the work winning.

Suggested change
mocks.flushWindowSession.mockImplementation(slow("flushWindow", 1000));
mocks.flushWindowSession.mockImplementation(slow("flushWindow", 999));

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.

2 participants