Workspaces 3/10: multiple windows and the transfer protocol - #616
Workspaces 3/10: multiple windows and the transfer protocol#616nedtwigg wants to merge 39 commits into
Conversation
The sidecar serves one process, so a second window would have seen every other window's terminal output. Rust now keeps the PTY-to-window map, minted only in `pty_spawn`, and every stdout line passes through a pure `route()` before it is emitted: terminal traffic to its owner, a `pty:list` to the window that asked, a `dor` request naming an unowned Surface to an error rather than a sibling, everything else broadcast. The same state carries the transfer suppression (with a fail-open sweep), the focus order the drag hit test uses as a z-order stand-in, and a torn-out window's pulled boot payload. The quit machine becomes vote-then-walk over per-window state, and a close is now this window's alone unless it is the last one. `main` is a real label in `tauri.conf.json`, `ws-*` windows share the default capability, and `main-only.json` keeps the updater grants where the walk tears down last. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
`list` and `gracefulKill` take an optional id set, following the same "omitted is not empty" rule `interrupt` already carries, and `list` echoes the window that asked so the host can route the list and every replay behind it back to it alone. A window tearing down can now kill exactly its own PTYs. The Burrow's asks collect until every window has answered (or the budget runs out) instead of settling on the first: each window sees only its own Workspaces, so a directory built from one answer would silently omit the rest. `burrow:windows` pushes the count, and lowering it settles asks a closed window can no longer answer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Closing a window with siblings alive now ends that window alone: it acks, asks about its own running work, archives its own notes, removes its snapshot so the next launch does not reopen it, and kills only the PTYs it owns. Only the last window's close is still a quit. The quit itself is vote-then-walk. Every window is asked, and only once all of them agree does Rust tear them down one at a time, `main` last — so a cancel in the last window can no longer leave earlier ones destroyed. The non-last windows hand the walk on instead of exiting, and the install stays in `main`, the one window holding `updater:*`. The confirmation dialog is shared between the two endings and names the window by its visible Workspace while more than one is open. The notepad gate moves to its own module, since both endings run it and a transfer runs neither. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
`disposeSession` splits into one teardown with a `kill` flag, and the new `releaseSession` takes this webview's half of a Session down while the process keeps running — the whole basis of a transfer. It is an explicit handle verb and never an unmount effect: a Wall unmounts on a reload, a StrictMode double-mount and a Workspace switch, and releasing there would strand every PTY. `releaseWorkspaceForTransfer` builds the payload the target restores from, in the one order that works: serialize with a live cwd probe while the Sessions still exist, take the notes, then detach. Nothing is archived and nothing is killed, because a move is not a closure. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The source releases the Workspace and hands Rust its record, its notes and its PTY ids; Rust reassigns ownership synchronously and suppresses those PTYs until each one's replay has reached the target. The target arms its collector first and only then calls `adopt_ready`, which is what removes the whole "arrived before armed" bug class — nothing is listed or replayed until something is listening for it. `collectLivePtys` gained a trigger and an id filter so an arrival asks for exactly the PTYs that just moved instead of the whole Window. A Workspace created after first render reads its boot plan from a parking store, since creating it mounts the Wall that needs it. A window whose last Workspace leaves closes itself: nothing ended, so nothing is confirmed, archived or killed. A torn-out window pulls its boot payload, because an emit to a window that does not exist yet is lost. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The strip's tear-out seams are wired up: past the strip edge the host throttles a cursor probe and lights a caret in whichever window is under the pointer, and the release either transfers there or tears out into a new window positioned so the tab lands under the cursor. A spike settled the one unverified platform assumption — a pointer captured on a tab keeps delivering `pointermove` and `pointerup` far outside the window in WKWebView, with client coordinates that run past the edges rather than clamping — so the gesture stays the webview's and Rust is only asked where the cursor is. Tauri exposes no z-order, so among stacked windows the most recently focused wins, and the caret is what makes a wrong guess visible before the release. The browser-dev harness gets no window ops: it has no windows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Standalone is a multi-webview host now, so a per-window `localStorage` mirror can no longer be canonical: the WATCHING rule set and the alarm settings are one per machine. The sidecar runs the very classes the VS Code extension host runs — first seed wins, edits are deltas, and a renderer's settings blob is revalidated before it is installed — and broadcasts the canonical snapshot to every window. The settings shape, defaults and validation move to a platform-free `alert-settings-model.ts` so a host can run them without dragging a renderer's platform in behind them; `alert-settings.ts` re-exports it and stays the renderer's own mirror. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Two bugs the real app found, both silent. Tauri delivers an `emit_to` event to any listener registered with the default `Any` target, which is what the JS `listen()` uses — so every window received every other window's terminal output, `pty:list`, Workspace arrivals and teardown order, and the whole routing table was decoration. A boot with two windows had each one adopting the other's PTYs. Every listener now names its own window, and a source scan keeps the bare API to the one wrapper that does it. `WorkspaceWindow` latched a boot plan per Workspace id forever. A Workspace dragged out and back mounted from the plan it first booted with, so a fresh default pane landed over the Sessions that had just arrived and the running work was lost. A departed Workspace now takes its latched plan with it. The window lifecycle also logs its transfers, tear-outs and closes, which is how both of these were found. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
standalone.md gains a Windows section: the label scheme, the routing table and the rule that every webview listener names its own window, boot with N windows and the geometry sibling, the per-window close, the transfer and its suppression window, the tear-out, and the drag between windows. Quit is rewritten as vote-then-walk with `main` last. transport.md's governing rule gains the per-window close and the transfer; its PTY lifecycle gains "Transferring a Workspace" — release-never-dispose, suppress-until-replay, and omitted-is-not-empty. The glossary gains the `release` system verb and the two Workspace-moving user verbs, and says the Tauri label is a Window's persistence identity. notepad.md says a transfer archives nothing and N windows contend through the archive's own lock; alert.md says both app-global stores are host-authoritative wherever one host serves several webviews; auto-update.md says the install runs in the last window torn down; security-local.md's atomic-write rule covers the geometry sibling. layout.md's rollout ledger drops multi-window. The rationale files record the spike that settled pointer capture outside the window, why `adopt_ready` is a hop, why there is no window-state plugin, and the Tauri event-target behaviour that made the routing a no-op. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The standalone frontend's tsconfig has no Node types, so the source scan belongs beside the other config assertions in `scripts/`, not in the vitest suite. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
It is built by `stage:sidecar-proxy` like every other sidecar `.cjs`, and the ignore list is where those belong. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Every sidecar stdout line passed through three mutexes, a `String` alloc for an `id` most events do not use, an `Instant::now()` and a suppression sweep whether or not anything was transferring, and an `append_log` under the routing locks. Fold the three maps a line is routed against into one `RoutingState` behind one lock, borrow every label the table hands back rather than cloning it, gate the sweep on an atomic count of suppressed ids, and take the focus order only for the `dor` request that has no Surface (`Route::Focused`). - A `dor` cancel now follows its own request to the window handling it, rather than waking every window: Rust remembers which window took each `requestId` and forgets it on the response, the cancel, or the window going away. - Geometry rides a cache fed by the `Moved` / `Resized` payloads themselves, so neither the debounced write nor the drag hit test asks the platform for four numbers per event; `window_at_cursor` reads that cache instead of cloning every webview handle per probe. - `pty_graceful_kill` and `capture_agent_recovery` drop an `ids` parameter no caller passed, and the sidecar's dead `gracefulKillAll` wrapper goes with it. - `alert_set_watched` + `alert_publish_settings` become one `alert_command` (the op is in the payload), and `window_close_proceed` + `close_window_self` one `close_window` (Rust's half is identical; the intent is at the JS call sites). - `WindowQuit.tearing_down` was set and never read; `restorable_labels` and `quit_order` share `partition_main`; the `closing` refusal is dropped when its webview is destroyed and can no longer save. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The quit and the per-window close had grown two copies of the same shape — ack, ask about running work, archive, act — with their own phase enums, their own injected `windowName`, and one hand-rolled `Promise.race` that leaked its timer. They now share `createTeardownFlow`; what is left in each is the step past the gates, a vote or a teardown. - The Burrow's N-answer collector is keyed by **which** window answered, not by how many have: Rust stamps the sending window's label on every forwarded command, `burrow:windows` carries labels, and a window answering twice can no longer settle an ask the others have not spoken to. - One boot-plan store, seeded at first render, replaces the latched copy `WorkspaceWindow` kept in a ref and mutated during render — which is what made a returning Workspace's plan need evicting by hand. - `dor list` reports the Window that answered (`window:<label>`), so a caller with several windows open gets a ref it can hand back; a bare Wall still says `window:1`. - The strip drag hands the host what it already knows (the release was inside its own strip) instead of the host re-querying the DOM for it, tells the host when a drag is abandoned so no caret is stranded in another window, and the three places that measured the tab strip share one scan. - The hover probe skips an unmoved pointer, and its dedupe keys on where in the target the cursor is, so the target's caret follows the pointer across its tabs instead of lighting once. - `alert-store-host.ts` drops two `as unknown as AlertManager` casts for the `Pick<>` targets the host classes actually take, and the unscoped-listener guard walks `standalone/src` recursively. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Track the code the two commits before this one changed: the `dor:controlCancel` routing row and the cancel-follows-its-request rule, the geometry cache behind both the debounced write and the drag hit test, the ask collector keyed by answerer rather than by count, the shared teardown flow and the one `close_window` both endings call, the id-less scoping of `pty_graceful_kill` and `capture_agent_recovery`, and `window:<label>` as the Window ref `dor list` reports. Budgets re-ratcheted. Also lifts the boot-plan store's empty-plan constant above its one reader, rather than leaving it declared below the function that returns it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The Rust half of the Stage C review fixes. `route` drops a PTY event no window owns instead of broadcasting it: every PTY is minted with an owner, so an unowned id is one whose window went away, and a broadcast rang every sibling's AlertManager for a pane none of them shows. `Destroyed` reaps whatever the window still owned, which is what the close ack-timeout path never killed. `Destroyed` is also the first moment Tauri has taken a label out of `webview_windows()`, so the quit machine's `forget_window` and the sidecar's window list both move there — the old post-`destroy()` send told the Burrow a dead window would answer, and every ask waited its full budget. A user close arriving mid-walk is prevented rather than ignored, and a quit that runs out of windows exits instead of going headless. Arrivals are queued per window rather than emitted: a window still booting, or torn out moments ago, is a legal drop target and an `emit_to` it is lost. The source is told of the departure only once the target has asked for it. Also: the geometry flush releases its slot in the same step as the drain (a `Moved` landing between the two was dropped) and reads the platform's scale before taking the `rects` lock, which off the main thread parks on the event loop the main thread may be driving while it waits for that lock; the routing lock is released before anything is serialized or emitted; `mint` clears a stale transfer suppression; `finish_window_close` refuses later saves on both close paths; the walk ends with `main` if it is open and otherwise with the most recently focused window, so every window now holds `updater:*`; and a Burrow ask naming a Surface goes to its owner alone, with Rust telling the collector so it does not wait out its budget on windows it never reached. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
muda wires `PredefinedMenuItem::quit` straight to AppKit's `terminate:`, and tao's app delegate implements only `applicationWillTerminate:` — by which point AppKit has decided and nothing may refuse. So the app menu, its Cmd+Q, the Dock's Quit item, an `osascript` quit and a logout all ended the process without the quit flow: no confirmation, no agent-recovery capture, no final save. Verified in tao-0.35.2's `app_delegate.rs` and muda-0.19.1's `platform_impl/macos/mod.rs`. The menu item becomes a custom one whose event calls `request_quit`, and `applicationShouldTerminate:` is spliced onto the live delegate's class at `Ready` — cancelling the terminate and starting the flow, then answering "now" when the flow's own `app.exit(0)` comes back through it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The webview half of the Stage C review fixes. `prepareWorkspaceTransfer` replaces `releaseWorkspaceForTransfer`: the record and the notes are built without touching anything, and the Sessions are detached only once Rust has accepted the move. The target window can close between the drag's last probe and the drop, and releasing first left a Workspace with no Sessions and no window that owned them. A pane's helper Session rides with it now — it is not a member Surface, so nothing else in the payload named it, and one left behind was a leaked shell plus a stray pane on the source's next reload. `collectLivePtys` finishes only on its own answer. Every listener sees every `pty:list`, so one webview with two collections outstanding — a boot and an arriving Workspace, or two arrivals — had each finishing on the other's list and concluding the host held nothing. The token rides the `requestInit` through to the sidecar's `list` and comes back on the list and each replay. An arrival that times out now refuses rather than cold-restoring: those shells are still running, and the restore would start a second set over them. The quit flow and the per-window close arbitrate over the one dialog. A second one used to be refused in silence, its context never settled and its host waiting out a decision that could not come: a quit now takes a confirming close over, a close met by a quit is refused with `window_close_cancel`, a quit cancelled elsewhere drops only a quit's dialog, and the store cancels any context it cannot open. Closing a window holding an approved download says so. The drag ignores a probe that lands after the release, asks again where the pointer came to rest, and clears its caret when the pointer comes back over its own strip. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
standalone.md: the arrival queue as its own section; ownership routing drops and reaps an unowned id; what a window's `Destroyed` settles, and why that is the first moment it can; the two geometry-flush rules; the transfer's two-phase release, its helper Session and its refusal on a timed-out arrival; the teardown arbitration table; the drag's trailing probe, stale answer and back-inside clear; `Trigger interception` becomes a heading and gains the two macOS rows; the walk ends with `main` if it is open, else the focused window; the Burrow's addressed ask. transport.md: a collection finishes only on its own answer, and a timeout is not an empty answer; the standalone `alert_command` envelope; release only once the host has accepted. auto-update.md: the install runs in whichever window is torn down last, so every window holds `updater:*`; only `main` checks; closing `main` discards an approved download. glossary.md and the persistence note stop saying the app has one window; dor-cli.md's reserved ref is `window:<label>`. Rationale keyed to each: the broadcast that rang every sibling, the `webview_windows()` ordering, the two geometry races, the lost arrival, the tao/muda evidence for the terminate hole, the unsettled confirm context, and the capability split that enforced a rule the window lifecycle does not guarantee. Budgets re-ratcheted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Closing a window discards the approved download it is holding, and that is the one ending that does: a quit installs it. The close asks about it even with nothing running, and the dialog says so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
An arrival is now a record keyed by `workspaceId` — `{ from, to, workspace,
notepad, terminalIds }` — held from the source's invoke until the target adopts
it or dies. Everything the transfer needs reads that one record instead of
inferring itself from the suppression map, which is what let the pieces disagree.
`adopt_ready` names its Workspace and answers with exactly that arrival's ids,
never "everything suppressed for this window": two Workspaces landing in one
window at once — a tear-out with a second tab dropped on it moments later — each
finished on the other's `pty:list` and resumed over its neighbour's shells.
`pty_request_init` excludes every id an arrival claims, so a window booting with
a Workspace already queued for it no longer lists those shells as top-level panes
beside the Workspace about to mount them.
The sweep's fail-open now applies only to suppressions no arrival claims. A cold
boot slower than `AWAITING_REPLAY_MAX` used to have a real arrival's shells
unsilenced into a window that had not resumed them yet.
`adopt_done` and `adopt_failed` are the two ways an arrival ends. `adopt_done`
retires the record, clears what is left of its suppression, and emits
`workspace-departed` to that Workspace's source alone — where the old
`announce_departures` told a source about every Workspace it had sent to that
window. `adopt_failed`, and a target `Destroyed` with an arrival still queued,
hand the shells back to the source unsuppressed and emit
`workspace-arrival-failed`; with both ends gone they are reaped rather than left
owned by a dead label.
`take_arrivals` no longer consumes, so a webview that drains at boot and again
when its listener is installed cannot lose a Workspace to a drain that happened
too early. The `departures` Vec is gone into the record, and the tear-out's
`payload.clone()` with it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Granting `updater:*` to every window bought nothing. Only `main` runs the periodic check, so only `main` can hold a downloaded update — a session whose `main` was closed has none to install, whichever window the walk ends on. The grant traded a structural guarantee for a case that cannot arise. So `capabilities/main-only.json` is back, scoping `updater:default` and `core:app:allow-version` to `main`; the walk is `main` last again, with the focused-last variant dropped along with the focus argument `QuitMachine` carried for it; and `tauri-conf.test.mjs` pins the split rather than its absence. The close confirmation's "this throws away the downloaded update" warning stays — that is what a user closing `main` actually needs told. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The source used to release the Workspace the moment Rust accepted the invoke — Sessions detached, notes dropped, tab gone — leaving nothing to put back if the target then refused the arrival or closed before taking it. Now the invoke only marks the Workspace *transferring*: it stays mounted with its Sessions attached, and the two ends of the arrival record decide what happens next. `workspace-departed` is the commit, and it names one Workspace: the prepared transfer is looked up by id and only that one is released, so a source with two Workspaces in flight into the same window no longer loses both when one lands. `workspace-arrival-failed` clears the mark and nothing else — the Workspace is simply still here, its xterms painting again as soon as Rust unsuppresses them. A transferring Workspace is in no snapshot this Window writes: its shells already belong to the target, so a quit in the gap would otherwise persist the same Workspace in two Windows and restore it twice. On the target, `planArrival` no longer throws into `bootstrap()`. Every failure becomes an `adopt_failed`, and a torn-out window whose sole arrival could not be resumed returns null so the caller boots it fresh — a window the user can use rather than a blank one. `bootFromTearOut` also leaves a window that has a snapshot to restore itself, instead of treating a drop that landed mid-boot as a tear-out and installing over its own Window. Arrivals are deduped by id, because `take_arrivals` no longer consumes and the boot drain and the nudge overlap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
A quit arriving while a close had committed only acked, and never voted. The quit machine then sat in `Voting` waiting on a window that had decided nothing — with no dialog anywhere for the user to answer, because the close's own question was long since past. It votes now: the window is ending either way. A committed flow can still retreat, though. `archive-failed` puts the notes it could not store to the user, and declining there cancels the close and leaves the window standing — with a quit already voted for it and its own question never asked. The deferred intent is kept and re-driven from that cancel, so the question is put after all. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
`timedOut` is the whole difference between "the host holds nothing" and "the host never answered", and `resumeOrRestoreFrom` reads the second as the first: it cold-restores, starting a second set of shells over the ones still running. The arrival path refuses rather than restore; the ordinary boot path had no such guard, and a launch slower than 500 ms — a cold sidecar behind an antivirus scan — is exactly when it bites. `collectLivePtys` now takes `retryTimeoutMs` and asks once more on that budget when the first wait times out. The Window restore passes it when the saved Window has terminal panes to lose, `resumeOrRestore` unconditionally: an empty list still resolves as soon as it arrives, so the retry costs nothing when there is nothing to say. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
`throttleTrailing` is exactly the leading-plus-trailing throttle this had hand-rolled out of a timestamp, a timer and a re-arming callback; all that is genuinely local is the in-flight flag, since a probe is an IPC round trip and a throttle window can close on top of one. The three verbatim gesture-end blocks — back inside the strip, released, cancelled — collapse into `endGesture`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
`standalone.md` → "Arrival queue" now carries the protocol as five numbered steps over one keyed record, plus the rules that hang off it: nothing released before adoption, a transferring Workspace in no snapshot and killed by neither end, the hand-back on refusal, the never-throw boot path, the non-consuming drain, the sweep's narrowed fail-open, and the boot list's exclusion. "Transfer" keeps what a move *is* and defers the ordering to it; `Destroyed`, "Per-window close", the routing table and the quit flow gain their one line each. `auto-update.md` goes back to `main-only.json` and a walk that ends on `main`, keeping the close confirmation's discard warning as the thing that session actually needs. `transport.md` picks up `retryTimeoutMs` and the corrected "released only once the target has adopted it". Rationale takes the five-symptoms-one-gap history, the two-phase argument, why the drain stopped consuming, why the capability grant was reverted, and why a timed-out boot list is asked again. `standalone.md` and `transport.md` budgets ratcheted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Carries origin/main (#611, #612) up the stack. releaseSession keeps its kill:false branch and, like disposeSession on main, releases the renderer, so a Session handed to another Window leaves no GL context behind. Restores lib/src/lib/throttle.ts, which main deleted with its only user: the cross-window drag probe is its second one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u
A second Cmd+Q while voting cleared every window's vote, but a window that had voted is committed and answers the repeat with an ack alone. With one sibling still on its dialog, its yes was then never the last vote: the machine sat in Voting with no dialog left, until someone cancelled. Votes are now cleared only from Idle. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u
A claimed arrival had no deadline in Rust: the replay sweep fails open only for ids no arrival claims, and Destroyed hands back only when the target dies. A target alive but wedged left the source marked transferring and its shells silent for good. begin_arrival now arms a watchdog that, past ARRIVAL_MAX, retires exactly the record it was armed for and hands it back like any refusal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u
Deploying mouseterm with
|
| Latest commit: |
b3c12af
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://da1dd212.mouseterm.pages.dev |
| Branch Preview URL: | https://workspaces-multiwindow.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 it ready when you want the full pass.
The arrival record as one keyed transaction, the two-phase release, and vote-then-walk all hold up under tracing, and the test coverage on them is unusually good. What follows is what survived verification. The inline comments carry the six findings with a line to point at; four more are here because they span files or are documentation.
The spec states the invariant that the pty_graceful_kill finding breaks. docs/specs/standalone.md → Quit flow: "A Workspace transferring out is in no window's snapshot and is killed by neither end (§Arrival queue), so a quit mid-transfer restores it exactly once." The §Arrival queue bullet reasons only about the source — "its source kills nothing of it — its shells already belong to the target (pty_graceful_kill is scoped by ownership)" — and that same ownership scoping is what puts them in the target's kill set. Either way that sentence needs to change; "restores it exactly once" is also only true after the target's publishWorkspaceSession, not across the whole gap.
Two more comments assert the updater:* widening that auto-update.rationale.md → "Why updater:* stayed scoped to main" records as reverted. main.tsx: "Installing is every window's, because the quit walk's last window is not always main". window-label.ts: "Installing is not gated on it: the walk ends with the most recently focused window when main has been closed." Both contradict capabilities/main-only.json, and the "most recently focused" half is independently wrong — start_walk calls quit_order(self.windows.keys()) over a HashMap, so the non-main order is arbitrary, and focus_order never reaches the walk at all. WindowState::focused's own doc comment ("most recently focused first, for the quit walk's order") repeats it. The risk is that an editor following these comments re-adds updater:* to default.json and removes the structural guarantee tauri-conf.test.mjs exists to pin.
window_at_cursor calls platform queries while holding GeometryState::rects. The guard(&geometry.rects) temporary lives to the end of the let rects = ...collect(); statement, so window.is_visible() and window.is_minimized() run inside it, ~16×/s during a drag. GeometryState's own doc says "Never call a platform query while holding rects … refresh_rect … takes no window at all so the rule cannot be broken by accident", and refreshing_a_cached_rect_takes_the_scale_rather_than_the_window pins that. It is safe today only because sync Tauri commands run on the main thread — an unwritten premise, and making this command (async) for the per-probe cost would deadlock it against the geometry flush thread. seed_geometry reads scale_factor() inside the same lock. Hoisting both reads above the guard costs nothing; CachedRect is Copy.
resumeOrRestore now waits 3.5 s before first paint where nothing answers. It passes retryTimeoutMs: LIST_RETRY_MS unconditionally, and FakePtyAdapter.requestInit() is a no-op, so the lib dev server pays 500 ms + 3 s on every boot with lib/src/main.tsx rendering behind the promise. restoreWindow gates the same retry on hasTerminalPanes — a collection with nothing to lose does not need it. Two comments went stale with this: lib/src/main.tsx ("this resolves immediately with no IDs") and vscode-ext/test/webview-boot.smoketest.ts ("self-caps at 500ms when no host answers").
Closing main leaves a fresh empty main in front on the next launch. finish_window_close removes main.json and its geometry, but app.windows[0] in tauri.conf.json recreates the window unconditionally at boot, and restore_windows focuses it last so it comes up in front of the restored ws-* ones. §Boot and geometry says "Boot reopens every window sessions/ names, main first (already up from the config)" without covering the case where sessions/ names no main — and auto-update.rationale.md treats a main-less session as a supported state. Worth either handling or writing down.
A quit trigger over an empty label list parked the machine in Voting with nothing able to vote, cancel or be forgotten, so every later ExitRequested was prevented and the app was unquittable short of a force-kill. Reachable by closing two windows while each other's teardown runs, or when the last Workspace is dragged out of a window. request now exits at once, as start_walk and forget_window already did one phase on. CloseMachine::clear removed the entry, so the next request for that label started from seq 1 again, the token a watchdog still sleeping on the cancelled close was holding; on waking it would destroy the window under the user's second dialog. clear now bumps the seq in place. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
… rect lock begin_arrival moves ownership to the target at the source's invoke, while the source is still showing the Workspace. A target tearing down before it adopted therefore SIGTERMed those shells in pty_graceful_kill and Ctrl-C'd their agents in capture_agent_recovery, and the hand-back at Destroyed then returned dead shells to the source. Both commands now filter the window's owned ids through routing::boot_list_ids, the pure helper pty_request_init already uses for the same reason; its doc and the test a_boot_list_never_names_an_arrivals_shells now name the kill and interrupt sets as the other two consumers. The ids are bound before the blocking sidecar round trip so the arrivals guard is not held across it. window_at_cursor called is_visible and is_minimized, and seed_geometry scale_factor, while holding GeometryState::rects, against that struct's own rule; it was safe only because sync commands run on the main thread. The rects are copied out first (CachedRect is Copy) and the scale is read before the insert. WindowState::focused's doc claimed the quit walk reads focus order; it never does — quit_order over a HashMap puts main last and the rest in no order. It now says what focused is for (Route::Focused). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
capabilities/main-only.json scopes updater:* to main, and auto-update.rationale.md records the widening as reverted, but three comments still said every window holds it and that the walk ends on the most recently focused window. Left standing they read as a reason to put the grant back into default.json and drop the guarantee tauri-conf.test.mjs pins. They now say what is true: only main checks, only main can install, and a main-less session has nothing to install. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
…ts flow rerun() re-enters flow.request(intent) synchronously from inside holder.abandon(), and the outer request then carried on: it gated the same intent again, the confirm store refused the second dialog by cancelling it, and that cancel was quit_cancel for the whole app under the Quit dialog the rerun had just opened. With no dialog needed the same path archived and voted twice. The outer request now returns when the flow is no longer idle after abandoning the holder. deferredQuit.against was always the other claim, so the quit flow's own reset never cleared it: a quit cancelled elsewhere survived in the entry and a later archive-failed decline re-opened a Quit dialog for a quit Rust had abandoned, whose vote went into an idle machine. The entry now records the flow that registered it (by), and that flow's reset clears it. Three cases in teardown-arbiter.test.ts pin the pair. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
A refused adopt_done was only logged. The ARRIVAL_MAX watchdog gives it a live cause: once it has expired the record and run hand_back_arrival, the source has cleared its transferring mark and kept the Workspace — but createWorkspace and setActiveWorkspace already ran here, so the same Workspace, notes and pane ids were live in two windows, both persisting it, and the next launch restored it twice over one set of PTYs. adopt_done is now awaited, and a rejection unwinds the mount the way a departure leaves the source: the Wall's transfer verb releases every Session without killing one (the shells are the source's again), the notes go with it, the Workspace is closed, and its record and parked boot plan are forgotten (new forgetWorkspaceBootPlan). The test drives the refusal and checks the store, the snapshot, the plan, the release, and that nothing was killed or reported as adopt_failed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
…lost resumeOrRestore passed retryTimeoutMs unconditionally, so a host whose requestInit answers nothing — the lib dev server on FakePtyAdapter, the VS Code smoke boot — waited 500 ms + 3 s before first paint. The retry exists to keep live shells from being restored over; a record naming no terminal pane has none to protect. It is now gated the way restoreWindow already gates it, the two comments that described the old timing are corrected, and transport.md states the rule. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
…ose seq do Quit flow claimed a transferring Workspace is killed by neither end and restored exactly once. The first half is true only now that the target's kill set excludes arriving ids; the second was never true across the whole gap — a source torn down before its target adopts leaves the Workspace in no snapshot. The sentence now says what this branch does (at most once, and from where), without claiming the durability a later stage adds. Also written down: a trigger that finds no window exits; a cancelled close never reuses its watchdog seq; the target's kill and interrupt sets exclude an arrival's ids; a refused adopt_done unwinds the mount; the re-drive gates once and a quit cancelled elsewhere forgets what it deferred; and a session naming no main relaunches with a fresh main in front, which is the current behavior rather than something handled. Budget ratcheted for the additions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
Carries the review fixes from the window and persist stages up the stack. The teardown-budget pin follows this stage's rename of the graceful kill command, and the wedged-write test drives the vote-then-walk flow. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
dormouse-bot
left a comment
There was a problem hiding this comment.
Feedback on work in progress, not a merge verdict — mark it ready when you want the full pass.
The six fixes land: the empty-label exit, the never-reused close seq, the arrival filter on both teardown commands, the hoisted platform reads, the gate-once re-drive, and the by/against split all trace clean, and a_cleared_close_never_hands_its_seq_to_the_next_request plus the two arbiter tests pin exactly the sequences that were broken.
One finding, inline: adopt_done was made awaited-and-unwound in adoptWorkspace but not in bootFromTearOut, which still calls it through settle. The new §Arrival queue bullet states the invariant without qualification — "A refused adopt_done unwinds the mount." — so the spec now covers a path the code doesn't.
# Conflicts: # docs/specs/layout.md
dormouse-bot
left a comment
There was a problem hiding this comment.
Feedback on work in progress, not a merge verdict — mark it ready when you want the full pass.
The tear-out boot now settles before it installs anything, and its refusal cleanup matches prepareWorkspaceTransfer().commit() surface for surface — notes dropped, helper forgotten ahead of its Session, released and never killed — so the double-persist from the last round is closed on both adopt paths. boots fresh without installing a refused tear-out or retaining its Sessions pins each half of it.
One finding, inline: the second call site makes releaseSession's own doc comment false.
| * **Never reachable from a Wall unmount.** A Wall unmounts on a reload, a | ||
| * StrictMode double-mount, and a Workspace switch, and releasing there would | ||
| * silently strand every PTY the Window still owns. The only caller is the | ||
| * explicit transfer verb on the Wall's handle. |
There was a problem hiding this comment.
bootFromTearOut's refusal path calls this now too, so "the only caller is the explicit transfer verb on the Wall's handle" is no longer true. The invariant above it still holds — that path has no Wall and no unmount — but the enumeration is what pins the invariant, and left standing it reads as "not the Wall handle, so this call is the mistake." The next editor deletes one or the other.
| * **Never reachable from a Wall unmount.** A Wall unmounts on a reload, a | |
| * StrictMode double-mount, and a Workspace switch, and releasing there would | |
| * silently strand every PTY the Window still owns. The only caller is the | |
| * explicit transfer verb on the Wall's handle. | |
| * **Never reachable from a Wall unmount.** A Wall unmounts on a reload, a | |
| * StrictMode double-mount, and a Workspace switch, and releasing there would | |
| * silently strand every PTY the Window still owns. Only a caller that knows the | |
| * ids are another Window's reaches it: the Wall handle's transfer verb, and the | |
| * torn-out boot unwinding an arrival Rust already handed back. |
Several windows over one sidecar: Rust owns PTY→window routing, per-window close, quit voting, macOS terminate interception, the two-phase transfer with arrival queue, and the cross-window drag.
Two fixes folded in: a repeat Cmd+Q during voting no longer clears votes already cast (which wedged the quit), and an arrival the target never adopts is handed back after
ARRIVAL_MAX. Also restoreslib/src/lib/throttle.ts, which main deleted with its only user; the drag probe is the second.Based on
workspaces-persist.🤖 Generated with Claude Code
https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u