Workspaces 2/10: standalone persistence and agent recovery - #615
Workspaces 2/10: standalone persistence and agent recovery#615nedtwigg wants to merge 14 commits into
Conversation
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
dormouse-bot
left a comment
There was a problem hiding this comment.
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.
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
Deploying mouseterm with
|
| Latest commit: |
e1a6f2a
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://79d6e422.mouseterm.pages.dev |
| Branch Preview URL: | https://workspaces-persist.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
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')); |
There was a problem hiding this comment.
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).
| 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 |
There was a problem hiding this comment.
"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.
| // 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)); |
There was a problem hiding this comment.
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.
| mocks.flushWindowSession.mockImplementation(slow("flushWindow", 1000)); | |
| mocks.flushWindowSession.mockImplementation(slow("flushWindow", 999)); |
Per-Workspace persistence through the Window aggregator, restore per Workspace off one PTY list, agent recovery lifted into
lib/src/hostand 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