From 10d13e782abf652c29e106a230642da2867d31fa Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 13:55:19 -0700 Subject: [PATCH 1/5] Hold a transferring PTY's derived events until its replay pty:data in the gap is dropped because the replay carries the bytes, but terminal:semanticEvents and terminal:protocolEvents are derived once at the sidecar's parse site and ride no replay, so a prompt mark, cwd change, or notification landing mid-transfer never reached the target. Rust now holds them per id, bounded, and emits them to the new owner right behind the replay that lifts the suppression; a hand-back or exit drops them. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u --- docs/specs/standalone.md | 3 +- scripts/spec-word-budgets.json | 2 +- standalone/src-tauri/src/lib.rs | 47 ++++++++++++++++-- standalone/src-tauri/src/routing.rs | 77 ++++++++++++++++++++++++++++- 4 files changed, 121 insertions(+), 8 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 6149a9701..f4daccce5 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -338,7 +338,8 @@ Source of truth: `route` in `standalone/src-tauri/src/routing.rs`, | Sidecar event | Key | Goes to | |---|---|---| -| `pty:data`, `terminal:semanticEvents`, `terminal:protocolEvents` | `data.id` | its owner; dropped while the id is mid-transfer | +| `pty:data` | `data.id` | its owner; dropped while the id is mid-transfer, its bytes being in the replay | +| `terminal:semanticEvents`, `terminal:protocolEvents` | `data.id` | its owner; **held** while the id is mid-transfer and delivered, in order, behind the replay (`held_events_come_back_in_order_and_bounded`) — no replay carries them | | `pty:exit`, `pty:replay` | `data.id` | its owner, never suppressed | | `pty:list` | `data.forWindow` | the window that asked | | `alert:*` carrying `data.id` | `data.id` | its owner | diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 51a2f17ca..3d780bc14 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -24,7 +24,7 @@ "docs/specs/security-supply-chain.md": 1150, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1050, - "docs/specs/standalone.md": 8600, + "docs/specs/standalone.md": 8650, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 2c376a22a..d808b6fa9 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -86,6 +86,9 @@ struct RoutingState { /// dor requestId -> the window handling it, so a cancel reaches the window /// holding the subscription, watch or completion claim it releases. dor_targets: HashMap, + /// Derived terminal events that arrived while their id was suppressed, + /// delivered to the new owner behind its replay (`routing::Route::Hold`). + held: HashMap>, } #[derive(Default)] @@ -168,6 +171,9 @@ impl WindowState { routing.awaiting_replay.insert(id.clone(), now); } else { routing.awaiting_replay.remove(id); + // A hand-back: what was held for the target belongs to the + // source again, which saw the bytes live and needs no events. + routing.held.remove(id); } } self.suppressed @@ -179,6 +185,7 @@ impl WindowState { let mut routing = guard(&self.routing); routing.owners.remove(id); routing.awaiting_replay.remove(id); + routing.held.remove(id); self.suppressed .store(routing.awaiting_replay.len(), Ordering::Relaxed); } @@ -264,6 +271,8 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { }; let mut released: Vec = Vec::new(); + // Held events an expired suppression releases, flushed to the owner below. + let mut flushed: Vec<(String, Vec)> = Vec::new(); let delivery = { // Before the routing lock, never inside it (§`arrivals`). Nothing is // transferring in the steady state, so this second acquisition is paid @@ -285,6 +294,12 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { state .suppressed .store(routing.awaiting_replay.len(), Ordering::Relaxed); + for id in &released { + let queue = routing::take_held(&mut routing.held, id); + if let (false, Some(label)) = (queue.is_empty(), routing.owners.get(id)) { + flushed.push((label.clone(), queue)); + } + } } } @@ -298,6 +313,12 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { }, ) { Route::Drop => Delivery::Nowhere, + Route::Hold => { + if let Some(id) = data.get("id").and_then(JsonValue::as_str) { + routing::hold_event(&mut routing.held, id, event, data.clone()); + } + Delivery::Nowhere + } Route::Broadcast => Delivery::Broadcast, Route::EmitTo(label) => Delivery::To(label.to_string()), // Resolved here, where the focus order is a sibling of the map the @@ -316,6 +337,12 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { } }; + for (label, queue) in flushed { + for (held_event, held_data) in queue { + let _ = app.emit_to(label.as_str(), held_event.as_str(), &held_data); + } + } + let mut delivered: Option<&str> = None; match &delivery { Delivery::Nowhere => {} @@ -359,11 +386,21 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { } "pty:replay" => { if let Some(id) = id() { - let mut routing = guard(&state.routing); - routing.awaiting_replay.remove(id); - state - .suppressed - .store(routing.awaiting_replay.len(), Ordering::Relaxed); + let queue = { + let mut routing = guard(&state.routing); + routing.awaiting_replay.remove(id); + state + .suppressed + .store(routing.awaiting_replay.len(), Ordering::Relaxed); + routing::take_held(&mut routing.held, id) + }; + // Behind the replay, to the window that just received it: the + // events describe bytes the replay carried. + if let Some(label) = delivered { + for (held_event, held_data) in queue { + let _ = app.emit_to(label, held_event.as_str(), &held_data); + } + } } } "dor:controlRequest" => { diff --git a/standalone/src-tauri/src/routing.rs b/standalone/src-tauri/src/routing.rs index d06d24007..3c3a0a9a0 100644 --- a/standalone/src-tauri/src/routing.rs +++ b/standalone/src-tauri/src/routing.rs @@ -42,6 +42,10 @@ pub enum Route<'a> { /// the replay the new owner is about to receive, or no window owns it at all /// and every window would otherwise ring for a pane none of them shows. Drop, + /// Kept for the id's next owner: the id is mid-transfer and, unlike its + /// bytes, this event is in no replay. The caller queues it and delivers + /// the queue, in order, right after the replay that lifts the suppression. + Hold, /// A `dor` request naming no Surface belongs to whichever window the user /// is looking at. Resolved by the caller, which alone holds the focus order. Focused, @@ -93,7 +97,7 @@ fn owner<'a>(map: &'a HashMap, id: &str) -> Route<'a> { pub fn route<'a>(event: &str, data: &'a JsonValue, view: &RouteView<'a>) -> Route<'a> { match event { // Terminal traffic, keyed by the PTY it came from. - "pty:data" | "terminal:semanticEvents" | "terminal:protocolEvents" => { + "pty:data" => { let Some(id) = str_field(data, "id") else { return Route::Broadcast; }; @@ -102,6 +106,17 @@ pub fn route<'a>(event: &str, data: &'a JsonValue, view: &RouteView<'a>) -> Rout } owner(view.owners, id) } + // Derived once at the sidecar's parse site and carried by no replay, so + // a chunk's events outlive the chunk's drop. + "terminal:semanticEvents" | "terminal:protocolEvents" => { + let Some(id) = str_field(data, "id") else { + return Route::Broadcast; + }; + if view.awaiting_replay.contains_key(id) { + return Route::Hold; + } + owner(view.owners, id) + } // Never suppressed: a replay is exactly what the suppression is waiting // for, and the caller lifts the suppression after this emit. "pty:exit" | "pty:replay" => match str_field(data, "id") { @@ -307,6 +322,33 @@ pub fn sweep_awaiting( stale } +/// One event held for an id mid-transfer, in arrival order. +pub type HeldEvent = (String, JsonValue); + +/// Queue an event for an id whose suppression is up. Bounded per id: a +/// transfer lasts seconds, and an id that outruns the bound is one whose +/// arrival is wedged, which the arrival watchdog hands back anyway. +pub fn hold_event( + held: &mut HashMap>, + id: &str, + event: &str, + data: JsonValue, +) { + let queue = held.entry(id.to_string()).or_default(); + if queue.len() >= HELD_EVENTS_MAX { + queue.remove(0); + } + queue.push((event.to_string(), data)); +} + +/// Cap on events held per id. +pub const HELD_EVENTS_MAX: usize = 256; + +/// Everything held for `id`, in order, and nothing left behind. +pub fn take_held(held: &mut HashMap>, id: &str) -> Vec { + held.remove(id).unwrap_or_default() +} + /// The next `ws-`, above every label given — live windows and saved /// snapshots alike, so a torn-out window can never claim a saved window's file. pub fn seed_next_ws(labels: impl IntoIterator>) -> u64 { @@ -552,6 +594,30 @@ mod tests { ); } + #[test] + fn held_events_come_back_in_order_and_bounded() { + let mut held = HashMap::new(); + hold_event(&mut held, "a", "terminal:semanticEvents", json!({"n":1})); + hold_event(&mut held, "a", "terminal:protocolEvents", json!({"n":2})); + hold_event(&mut held, "b", "terminal:semanticEvents", json!({"n":3})); + assert_eq!( + take_held(&mut held, "a"), + vec![ + ("terminal:semanticEvents".to_string(), json!({"n":1})), + ("terminal:protocolEvents".to_string(), json!({"n":2})), + ] + ); + assert!(take_held(&mut held, "a").is_empty()); + assert_eq!(held.len(), 1); + + for n in 0..(HELD_EVENTS_MAX + 5) { + hold_event(&mut held, "c", "terminal:semanticEvents", json!({"n":n})); + } + let queue = take_held(&mut held, "c"); + assert_eq!(queue.len(), HELD_EVENTS_MAX); + assert_eq!(queue[0].1, json!({"n":5})); + } + #[test] fn a_transferring_pty_is_suppressed_until_its_replay() { let owned = labels(&[("a", "ws-2")]); @@ -564,6 +630,15 @@ mod tests { dor_targets: &no_dor, }; assert_eq!(route("pty:data", &json!({"id":"a"}), &suppressed), Route::Drop); + // A chunk's derived events are in no replay: held, not dropped. + assert_eq!( + route("terminal:semanticEvents", &json!({"id":"a"}), &suppressed), + Route::Hold + ); + assert_eq!( + route("terminal:protocolEvents", &json!({"id":"a"}), &suppressed), + Route::Hold + ); // The replay itself is never suppressed — it is what is being waited for. assert_eq!( route("pty:replay", &json!({"id":"a"}), &suppressed), From ebb6caf8a421510d0b88df8d6e316f8d98809cf4 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 13:56:45 -0700 Subject: [PATCH 2/5] Clean the drift the multi-window review found - The three updater comments still described the reverted every-window grant; they now match capabilities/main-only.json, and the specs name the capability as written, updater:default. - layout.md claimed every Workspace verb has a dor counterpart; reorder, transfer, and tear-out do not yet. - A source test pins that main.js hands pty-core the shared sliceSince: without it recovery capture reads an empty buffer with no error. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u --- docs/specs/auto-update.md | 2 +- docs/specs/layout.md | 2 +- docs/specs/standalone.md | 2 +- standalone/sidecar/main-wiring.test.js | 21 +++++++++++++++++++++ standalone/src/main.tsx | 6 +++--- standalone/src/quit.ts | 10 +++++----- standalone/src/window-label.ts | 3 +-- 7 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 standalone/sidecar/main-wiring.test.js diff --git a/docs/specs/auto-update.md b/docs/specs/auto-update.md index c763d9934..18d449abb 100644 --- a/docs/specs/auto-update.md +++ b/docs/specs/auto-update.md @@ -14,7 +14,7 @@ The standalone app checks for updates on launch and prompts in the Baseboard. ** ### Quit-time install -**The updater owns no quit interception** — install runs only when `hasPendingUpdate()` is true, after the quit orchestrator's teardown and save/drain steps (`docs/specs/standalone.md` §Quit flow) (rationale). **It runs in `main`, the window the quit walk tears down last and the only one holding `updater:*`** (`capabilities/main-only.json`); every other window has handed on by then, so nothing it could still be writing outlives the install. +**The updater owns no quit interception** — install runs only when `hasPendingUpdate()` is true, after the quit orchestrator's teardown and save/drain steps (`docs/specs/standalone.md` §Quit flow) (rationale). **It runs in `main`, the window the quit walk tears down last and the only one holding `updater:default`** (`capabilities/main-only.json`); every other window has handed on by then, so nothing it could still be writing outlives the install. **Only `main` ever checks**, so it is the only window that can hold a download at all — and **closing `main` throws away an approved one**, which lives in that webview's memory. Its close confirmation says so, and is shown for that reason alone even with nothing running (`docs/specs/standalone.md` → "Per-window close"); a session that has closed `main` simply has no update to install until it relaunches (rationale). `installPendingUpdate()` writes the success marker *before* `install()` (§localStorage), and on Windows first awaits bounded sidecar teardown (§Sidecar teardown on Windows). **It never closes the window itself** — exiting the process is `quit_proceed`'s job, after this returns. diff --git a/docs/specs/layout.md b/docs/specs/layout.md index f59c5fbc5..c891103cb 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -165,7 +165,7 @@ The union projection and its indicators are owned by `docs/specs/alert.md` → W Source of truth: `WorkspaceWindow` in `lib/src/components/WorkspaceWindow.tsx`; `registerWallHandle` in `lib/src/components/wall/wall-handles.ts`; `closeAll` in `lib/src/components/Wall.tsx`; `requestWorkspaceClose` in `lib/src/components/wall/workspace-lifecycle.ts`; `createWorkspace` / `closeWorkspace` / `renameWorkspace` / `moveWorkspace` / `setActiveWorkspace` in `lib/src/lib/workspace-store.ts`; `getWorkspaceUiSnapshot` in `lib/src/lib/workspace-ui-store.ts`; `setWorkspaceSurfaces` in `lib/src/lib/workspace-surfaces.ts`. -**Every Workspace verb has a `dor` counterpart** (`docs/specs/dor-cli.md` → "dor workspace"), taking the same route as the strip and the command-mode keys: a command close raises no confirmation, refusing instead, and closes its member Surfaces silently. +**Create, rename, close, and switch have `dor` counterparts** (`docs/specs/dor-cli.md` → "dor workspace"); reorder, transfer, and tear-out are drag-only until `docs/specs/dor-cli.md` → Future "Cross-Window targeting" lands. Each takes the same route as the strip and the command-mode keys: a command close raises no confirmation, refusing instead, and closes its member Surfaces silently. ## Modes diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index f4daccce5..983369d61 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -326,7 +326,7 @@ so `titleBarStyle`, `hiddenTitle`, `dragDropEnabled` and the CSP carry across with no second copy of any of them. **Capabilities are split**: `default.json` covers `main` and the `ws-*` glob, -and `main-only.json` scopes `updater:*` and `core:app:allow-version` to `main`, +and `main-only.json` scopes `updater:default` and `core:app:allow-version` to `main`, which structurally enforces that the install runs in the window the walk tears down last (`docs/specs/auto-update.md`). Custom commands need no capability entry. `standalone/scripts/tauri-conf.test.mjs` pins both. diff --git a/standalone/sidecar/main-wiring.test.js b/standalone/sidecar/main-wiring.test.js new file mode 100644 index 000000000..d848fdafb --- /dev/null +++ b/standalone/sidecar/main-wiring.test.js @@ -0,0 +1,21 @@ +// The sidecar's entry wires pty-core to the bundles it requires. These are +// source checks, because loading main.js needs the built bundles and a live +// stdin; each pins one injection whose absence fails silently at runtime. +const { test } = require('node:test'); +const assert = require('node:assert'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); + +const source = readFileSync(path.join(__dirname, 'main.js'), 'utf8'); + +test('pty-core is created with the shared sliceSince, so recovery capture reads a buffer', () => { + // Without it `outputSince` answers '' and `captureAgentRecovery` records + // nothing, with no error anywhere (pty-core.js -> outputSince). + assert.match(source, /\{\s*captureAgentRecovery,\s*createRecoveryStore,\s*sliceSince\s*\}\s*=\s*require\('\.\/recovery\.cjs'\)/); + assert.match(source, /nodePty,\s*\{\s*replay:\s*true,\s*sliceSince\s*\}\)/); +}); + +test('recovery capture and the record take are answered from pty-core marks', () => { + assert.match(source, /receivedChars:\s*\(id\)\s*=>\s*mgr\.receivedChars\(id\)/); + assert.match(source, /outputSince:\s*\(id,\s*mark\)\s*=>\s*mgr\.outputSince\(id,\s*mark\)/); +}); diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index 72db3d030..9f54d0b2e 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -155,9 +155,9 @@ async function bootstrap() { // store wholesale (`docs/specs/standalone.md` → "Arrival queue"). armWorkspaceMoves?.(); - // Only `main` runs the periodic check, so a session whose `main` was closed - // has none until it relaunches. Installing is every window's, because the - // quit walk's last window is not always `main` (docs/specs/auto-update.md). + // Only `main` runs the periodic check and holds the updater capability, so a + // session whose `main` was closed has no update until it relaunches + // (docs/specs/auto-update.md). if (isMainWindow()) startUpdateCheck(); createRoot(document.getElementById("root")!).render( diff --git a/standalone/src/quit.ts b/standalone/src/quit.ts index 80c77b643..f53e26433 100644 --- a/standalone/src/quit.ts +++ b/standalone/src/quit.ts @@ -123,11 +123,11 @@ async function runQuitTeardown(last: boolean): Promise { `[quit] teardown exceeded ${QUIT_TEARDOWN_CEILING_MS}ms; proceeding to exit`, ); } - // Install strictly after the completed final save, and only in the window - // the walk tears down last — `main` while it is open, else the most recently - // focused one, which is why every window holds `updater:*` - // (docs/specs/auto-update.md). A fresh `quit_progress` gives install its own - // watchdog budget instead of the teardown remainder. + // Install strictly after the completed final save, in the window the walk + // tears down last. Only `main` ever holds a pending download and the + // updater capability (docs/specs/auto-update.md), so this is `main` or a + // no-op. A fresh `quit_progress` gives install its own watchdog budget + // instead of the teardown remainder. if (last && hasPendingUpdate()) { void invoke("quit_progress").catch(() => {}); // install phase begins await installPendingUpdate(); diff --git a/standalone/src/window-label.ts b/standalone/src/window-label.ts index fe0fe578b..2ef15afb7 100644 --- a/standalone/src/window-label.ts +++ b/standalone/src/window-label.ts @@ -31,8 +31,7 @@ export function currentWindowLabel(): string { } /** The window the quit walk tears down last while it is open, and the only one - * that runs the periodic update check. Installing is not gated on it: the walk - * ends with the most recently focused window when `main` has been closed. */ + * that checks for, downloads, and installs updates (`capabilities/main-only.json`). */ export function isMainWindow(): boolean { return label === MAIN_WINDOW_LABEL; } From dc4b8f6d261c21c320b6791e33796837919f711a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 17:39:01 -0700 Subject: [PATCH 3/5] Hold only protocol events across a transfer; rebuild alerts from the replay The hold queued both derived streams on the premise that neither is in any replay. Semantic events are: the replay carries the raw bytes, OSCs included, and the target's `pty:replay` listener re-parses them. The flushed queue then re-applied `commandStart` on top of state the replay had just rebuilt, and `commandStart` is not idempotent, so a transfer that split a `commandLine` from its `commandStart` left the arriving window with a derived title for a command whose real line the replay had recovered. Semantic events now route to `Drop` while suppressed; only `terminal:protocolEvents`, which no replay path rebuilds, are held. What the replay path genuinely did not rebuild was the AlertManager's half, so both adapters' replay listeners now feed it too, and the tests pin a watched command coming back from a replay alone. `clear_suppression` and `mint` cleared `awaiting_replay` and left `held` behind, so a queue could survive until the shell exited and be flushed ahead of the next transfer's own gap. One helper, `lift_suppression`, now takes both halves together, and every site goes through it. The hand-back comment claimed the source saw the gap's bytes live; it did not, since suppression is by id and ownership had already moved. The comment and the Arrival queue spec now say the gap is lost on a hand-back, the one path nothing recovers on this branch. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus --- docs/specs/layout.md | 2 +- docs/specs/standalone.md | 5 +- docs/specs/standalone.rationale.md | 10 +++ scripts/spec-word-budgets.json | 2 +- standalone/src-tauri/src/lib.rs | 89 +++++++++++++++---- standalone/src-tauri/src/routing.rs | 69 +++++++++----- .../src/browser-sidecar-adapter.test.ts | 17 +++- standalone/src/browser-sidecar-adapter.ts | 4 +- standalone/src/tauri-adapter.test.ts | 19 ++++ standalone/src/tauri-adapter.ts | 8 +- 10 files changed, 182 insertions(+), 43 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index c891103cb..e6f919d23 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -165,7 +165,7 @@ The union projection and its indicators are owned by `docs/specs/alert.md` → W Source of truth: `WorkspaceWindow` in `lib/src/components/WorkspaceWindow.tsx`; `registerWallHandle` in `lib/src/components/wall/wall-handles.ts`; `closeAll` in `lib/src/components/Wall.tsx`; `requestWorkspaceClose` in `lib/src/components/wall/workspace-lifecycle.ts`; `createWorkspace` / `closeWorkspace` / `renameWorkspace` / `moveWorkspace` / `setActiveWorkspace` in `lib/src/lib/workspace-store.ts`; `getWorkspaceUiSnapshot` in `lib/src/lib/workspace-ui-store.ts`; `setWorkspaceSurfaces` in `lib/src/lib/workspace-surfaces.ts`. -**Create, rename, close, and switch have `dor` counterparts** (`docs/specs/dor-cli.md` → "dor workspace"); reorder, transfer, and tear-out are drag-only until `docs/specs/dor-cli.md` → Future "Cross-Window targeting" lands. Each takes the same route as the strip and the command-mode keys: a command close raises no confirmation, refusing instead, and closes its member Surfaces silently. +**Create, rename, close, and switch have `dor` counterparts** (`docs/specs/dor-cli.md` → "dor workspace"); reorder is strip-only, and transfer and tear-out stay drag-only until `docs/specs/dor-cli.md` → Future "Cross-Window targeting" lands. Each takes the same route as the strip and the command-mode keys: a command close raises no confirmation, refusing instead, and closes its member Surfaces silently. ## Modes diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 983369d61..bcca76e6b 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -339,7 +339,8 @@ Source of truth: `route` in `standalone/src-tauri/src/routing.rs`, | Sidecar event | Key | Goes to | |---|---|---| | `pty:data` | `data.id` | its owner; dropped while the id is mid-transfer, its bytes being in the replay | -| `terminal:semanticEvents`, `terminal:protocolEvents` | `data.id` | its owner; **held** while the id is mid-transfer and delivered, in order, behind the replay (`held_events_come_back_in_order_and_bounded`) — no replay carries them | +| `terminal:semanticEvents` | `data.id` | its owner; dropped while the id is mid-transfer — the target re-derives them from the raw replay, feeding both pane state and its `AlertManager` (rationale) | +| `terminal:protocolEvents` | `data.id` | its owner; **held** while the id is mid-transfer and delivered, in order, behind the replay, which rebuilds none of them; at most `HELD_EVENTS_MAX` (256) per id, overflow dropping the oldest (`held_events_come_back_in_order_and_bounded`) | | `pty:exit`, `pty:replay` | `data.id` | its owner, never suppressed | | `pty:list` | `data.forWindow` | the window that asked | | `alert:*` carrying `data.id` | `data.id` | its owner | @@ -552,6 +553,8 @@ below reads that record rather than inferring itself from the suppression map. source unsuppressed, drop the record, and emit `workspace-arrival-failed`; the source clears **transferring** and the Workspace is simply still there. With both ends gone the shells are reaped rather than left owned by a dead label. + **The gap is lost on a hand-back**: suppressed from the invoke with no replay + to follow, it is the one path nothing recovers. - **`planArrival` never throws into `bootstrap()`.** A refused sole arrival on the boot path renders a fresh one-pane Workspace, never a blank window. - **`take_arrivals` does not consume.** The record settles at `adopt_done`, so a diff --git a/docs/specs/standalone.rationale.md b/docs/specs/standalone.rationale.md index 66685ab07..0c57ce2a7 100644 --- a/docs/specs/standalone.rationale.md +++ b/docs/specs/standalone.rationale.md @@ -43,6 +43,16 @@ minted in `pty_spawn`, so an unowned id is one whose window went away, and the broadcast reached every sibling's AlertManager — which rang, and offered a TODO, for a pane none of them showed. +The first hold queued both derived streams, on the premise that neither is in +any replay. Semantic events are: the replay is the raw bytes, OSCs included, and +the target's replay listener re-parses them. The flushed queue then re-applied +`commandStart` on top of state the replay had just rebuilt, and `commandStart` +is not idempotent — it mints a fresh id and consumes the pending command line — +so a transfer that split a command's `commandLine` from its `commandStart` left +the arriving window with a derived title for a command whose real line the +replay had already recovered. What the replay path genuinely did not rebuild was +the AlertManager's copy, and that is a listener fix, not a routing one. + ## What a window's `Destroyed` settles Tauri removes a label from `webview_windows()` only when the window is actually diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 3d780bc14..92570c6b3 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -24,7 +24,7 @@ "docs/specs/security-supply-chain.md": 1150, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1050, - "docs/specs/standalone.md": 8650, + "docs/specs/standalone.md": 8700, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index d808b6fa9..7f134ae54 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -86,11 +86,20 @@ struct RoutingState { /// dor requestId -> the window handling it, so a cancel reaches the window /// holding the subscription, watch or completion claim it releases. dor_targets: HashMap, - /// Derived terminal events that arrived while their id was suppressed, - /// delivered to the new owner behind its replay (`routing::Route::Hold`). + /// Protocol events that arrived while their id was suppressed, delivered + /// to the new owner behind its replay (`routing::Route::Hold`). Only ever + /// emptied together with `awaiting_replay` (`lift_suppression`). held: HashMap>, } +impl RoutingState { + /// `routing::lift_suppression` over this state's two halves. The caller + /// republishes `WindowState::suppressed` after it, still under the lock. + fn lift_suppression(&mut self, id: &str) -> Vec { + routing::lift_suppression(&mut self.awaiting_replay, &mut self.held, id) + } +} + #[derive(Default)] struct WindowState { routing: Mutex, @@ -142,10 +151,10 @@ impl WindowState { fn mint(&self, id: &str, label: &str) { let mut routing = guard(&self.routing); routing.owners.insert(id.to_string(), label.to_string()); - if routing.awaiting_replay.remove(id).is_some() { - self.suppressed - .store(routing.awaiting_replay.len(), Ordering::Relaxed); - } + // Whatever was held belonged to the PTY that never arrived, not this one. + routing.lift_suppression(id); + self.suppressed + .store(routing.awaiting_replay.len(), Ordering::Relaxed); } /// Refuse every later `save_session` for `label` (a deliberate close removed @@ -170,10 +179,12 @@ impl WindowState { if suppress { routing.awaiting_replay.insert(id.clone(), now); } else { - routing.awaiting_replay.remove(id); - // A hand-back: what was held for the target belongs to the - // source again, which saw the bytes live and needs no events. - routing.held.remove(id); + // A hand-back. The gap is lost here: the source was suppressed + // like any other non-owner from the invoke on, and no replay + // follows a hand-back, so the bytes and everything derived from + // them are gone from its pane. A later stage recovers the gap + // (docs/specs/standalone.md -> "Arrival queue"). + routing.lift_suppression(id); } } self.suppressed @@ -184,8 +195,7 @@ impl WindowState { fn forget_pty(&self, id: &str) { let mut routing = guard(&self.routing); routing.owners.remove(id); - routing.awaiting_replay.remove(id); - routing.held.remove(id); + routing.lift_suppression(id); self.suppressed .store(routing.awaiting_replay.len(), Ordering::Relaxed); } @@ -197,7 +207,7 @@ impl WindowState { fn clear_suppression(&self, ids: &[String]) { let mut routing = guard(&self.routing); for id in ids { - routing.awaiting_replay.remove(id); + routing.lift_suppression(id); } self.suppressed .store(routing.awaiting_replay.len(), Ordering::Relaxed); @@ -216,7 +226,7 @@ impl WindowState { let mut routing = guard(&self.routing); for id in lost.iter().flat_map(|arrival| &arrival.terminal_ids) { routing.owners.remove(id); - routing.awaiting_replay.remove(id); + routing.lift_suppression(id); } let owned = routing.owned_by(label); for id in &owned { @@ -295,7 +305,8 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { .suppressed .store(routing.awaiting_replay.len(), Ordering::Relaxed); for id in &released { - let queue = routing::take_held(&mut routing.held, id); + // The sweep already took the map entry; this takes the queue. + let queue = routing.lift_suppression(id); if let (false, Some(label)) = (queue.is_empty(), routing.owners.get(id)) { flushed.push((label.clone(), queue)); } @@ -388,11 +399,11 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { if let Some(id) = id() { let queue = { let mut routing = guard(&state.routing); - routing.awaiting_replay.remove(id); + let queue = routing.lift_suppression(id); state .suppressed .store(routing.awaiting_replay.len(), Ordering::Relaxed); - routing::take_held(&mut routing.held, id) + queue }; // Behind the replay, to the window that just received it: the // events describe bytes the replay carried. @@ -4314,12 +4325,56 @@ mod tests { state.reassign(&["pane-a".to_string()], "ws-2", true); assert_eq!(state.suppressed.load(Ordering::Relaxed), 1); + super::routing::hold_event( + &mut guard(&state.routing).held, + "pane-a", + "terminal:protocolEvents", + serde_json::json!({"n": 1}), + ); + state.mint("pane-a", "main"); assert!(guard(&state.routing).awaiting_replay.is_empty()); + // Nothing held for the PTY that never arrived survives under its id. + assert!(guard(&state.routing).held.is_empty()); assert_eq!(state.suppressed.load(Ordering::Relaxed), 0); assert_eq!(state.owned_by("main"), vec!["pane-a".to_string()]); } + /// Every way out of a suppression takes the held queue with it: a queue + /// left behind would be flushed ahead of the *next* transfer's own gap. + #[test] + fn every_lift_of_a_suppression_takes_its_held_queue() { + let state = super::WindowState::default(); + let ids = ["pane-a".to_string()]; + let queue_up = || { + state.reassign(&ids, "ws-2", true); + super::routing::hold_event( + &mut guard(&state.routing).held, + "pane-a", + "terminal:protocolEvents", + serde_json::json!({"n": 1}), + ); + assert_eq!(state.suppressed.load(Ordering::Relaxed), 1); + }; + let lifted = || { + let routing = guard(&state.routing); + routing.awaiting_replay.is_empty() && routing.held.is_empty() + }; + + queue_up(); + state.clear_suppression(&ids); + assert!(lifted()); + assert_eq!(state.suppressed.load(Ordering::Relaxed), 0); + + queue_up(); + state.reassign(&ids, "main", false); + assert!(lifted()); + + queue_up(); + state.forget_pty("pane-a"); + assert!(lifted()); + } + #[test] fn sweep_orphan_session_temps_removes_only_temps() { let dir = TempDir::new("sessions-sweep"); diff --git a/standalone/src-tauri/src/routing.rs b/standalone/src-tauri/src/routing.rs index 3c3a0a9a0..b74c79bc7 100644 --- a/standalone/src-tauri/src/routing.rs +++ b/standalone/src-tauri/src/routing.rs @@ -38,12 +38,13 @@ pub enum Route<'a> { /// (the argument `docs/specs/vscode.md` -> "Peer surfaces across windows" /// makes for its own fan-out). Broadcast, - /// Nothing is delivered: the id is mid-transfer and its bytes are already in - /// the replay the new owner is about to receive, or no window owns it at all - /// and every window would otherwise ring for a pane none of them shows. + /// Nothing is delivered: the id is mid-transfer and what this carries is + /// already in the replay the new owner is about to receive (its bytes, or + /// the semantic events the owner re-derives from them), or no window owns it + /// at all and every window would otherwise ring for a pane none of them shows. Drop, - /// Kept for the id's next owner: the id is mid-transfer and, unlike its - /// bytes, this event is in no replay. The caller queues it and delivers + /// Kept for the id's next owner: the id is mid-transfer and this event is + /// in no replay and re-derived from none. The caller queues it and delivers /// the queue, in order, right after the replay that lifts the suppression. Hold, /// A `dor` request naming no Surface belongs to whichever window the user @@ -106,9 +107,21 @@ pub fn route<'a>(event: &str, data: &'a JsonValue, view: &RouteView<'a>) -> Rout } owner(view.owners, id) } - // Derived once at the sidecar's parse site and carried by no replay, so - // a chunk's events outlive the chunk's drop. - "terminal:semanticEvents" | "terminal:protocolEvents" => { + // The replay is the raw bytes, OSCs included, and the target's replay + // path re-derives these from it — so a held copy would apply on top of + // what the replay rebuilt, and `commandStart` is not idempotent. + "terminal:semanticEvents" => { + let Some(id) = str_field(data, "id") else { + return Route::Broadcast; + }; + if view.awaiting_replay.contains_key(id) { + return Route::Drop; + } + owner(view.owners, id) + } + // Derived once at the sidecar's parse site and rebuilt by no replay + // path, so a chunk's protocol events outlive the chunk's drop. + "terminal:protocolEvents" => { let Some(id) = str_field(data, "id") else { return Route::Broadcast; }; @@ -327,7 +340,8 @@ pub type HeldEvent = (String, JsonValue); /// Queue an event for an id whose suppression is up. Bounded per id: a /// transfer lasts seconds, and an id that outruns the bound is one whose -/// arrival is wedged, which the arrival watchdog hands back anyway. +/// arrival is wedged, which the arrival watchdog hands back anyway. Past the +/// bound the oldest goes, so a long gap delivers its suffix. pub fn hold_event( held: &mut HashMap>, id: &str, @@ -344,8 +358,17 @@ pub fn hold_event( /// Cap on events held per id. pub const HELD_EVENTS_MAX: usize = 256; -/// Everything held for `id`, in order, and nothing left behind. -pub fn take_held(held: &mut HashMap>, id: &str) -> Vec { +/// Lift `id`'s transfer suppression. The suppression and what was held under +/// it go together — one site clearing the map and leaving the queue would +/// deliver a stale gap ahead of the next transfer's own — so this is the one +/// way out of both, and the caller decides whether the queue is delivered +/// (behind the replay) or discarded (a hand-back, a reuse, an exit). +pub fn lift_suppression( + awaiting_replay: &mut HashMap, + held: &mut HashMap>, + id: &str, +) -> Vec { + awaiting_replay.remove(id); held.remove(id).unwrap_or_default() } @@ -596,24 +619,28 @@ mod tests { #[test] fn held_events_come_back_in_order_and_bounded() { + let mut awaiting = awaiting(&["a", "b", "c"]); let mut held = HashMap::new(); - hold_event(&mut held, "a", "terminal:semanticEvents", json!({"n":1})); + hold_event(&mut held, "a", "terminal:protocolEvents", json!({"n":1})); hold_event(&mut held, "a", "terminal:protocolEvents", json!({"n":2})); - hold_event(&mut held, "b", "terminal:semanticEvents", json!({"n":3})); + hold_event(&mut held, "b", "terminal:protocolEvents", json!({"n":3})); assert_eq!( - take_held(&mut held, "a"), + lift_suppression(&mut awaiting, &mut held, "a"), vec![ - ("terminal:semanticEvents".to_string(), json!({"n":1})), + ("terminal:protocolEvents".to_string(), json!({"n":1})), ("terminal:protocolEvents".to_string(), json!({"n":2})), ] ); - assert!(take_held(&mut held, "a").is_empty()); + // Both halves went together, and nothing of "b" went with them. + assert!(!awaiting.contains_key("a")); + assert!(awaiting.contains_key("b")); + assert!(lift_suppression(&mut awaiting, &mut held, "a").is_empty()); assert_eq!(held.len(), 1); for n in 0..(HELD_EVENTS_MAX + 5) { - hold_event(&mut held, "c", "terminal:semanticEvents", json!({"n":n})); + hold_event(&mut held, "c", "terminal:protocolEvents", json!({"n":n})); } - let queue = take_held(&mut held, "c"); + let queue = lift_suppression(&mut awaiting, &mut held, "c"); assert_eq!(queue.len(), HELD_EVENTS_MAX); assert_eq!(queue[0].1, json!({"n":5})); } @@ -630,11 +657,13 @@ mod tests { dor_targets: &no_dor, }; assert_eq!(route("pty:data", &json!({"id":"a"}), &suppressed), Route::Drop); - // A chunk's derived events are in no replay: held, not dropped. + // The target re-derives semantic events from the raw replay, so a held + // copy would apply twice: dropped with the bytes they describe. assert_eq!( route("terminal:semanticEvents", &json!({"id":"a"}), &suppressed), - Route::Hold + Route::Drop ); + // Protocol events are rebuilt by no replay path: held, not dropped. assert_eq!( route("terminal:protocolEvents", &json!({"id":"a"}), &suppressed), Route::Hold diff --git a/standalone/src/browser-sidecar-adapter.test.ts b/standalone/src/browser-sidecar-adapter.test.ts index c29d064cd..ec2b75a0d 100644 --- a/standalone/src/browser-sidecar-adapter.test.ts +++ b/standalone/src/browser-sidecar-adapter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import type { PlatformAdapter, PtyDataDetail } from "dormouse-lib/lib/platform/types"; +import type { AlertStateDetail, PlatformAdapter, PtyDataDetail } from "dormouse-lib/lib/platform/types"; +import { getTerminalPaneState } from "dormouse-lib/lib/terminal-state-store"; // Stub the Tauri modules so `./tauri-adapter` imports and constructs outside a // Tauri webview — same reason as tauri-adapter.test.ts. Nothing here exercises @@ -129,6 +130,20 @@ describe("BrowserSidecarAdapter terminal stream", () => { expect(send.mock.calls.filter(([cmd]) => cmd === "pty_write")).toEqual([]); }); + // Same contract as TauriAdapter: the replay is all a transferred pane's new + // window sees, so it rebuilds the AlertManager's half too. + it("rebuilds alert state from a replay, not only pane state", async () => { + const { adapter, deliver } = await listening(); + const alerts: AlertStateDetail[] = []; + adapter.onAlertState((detail) => void alerts.push(detail)); + adapter.alertSetWatchedCommands(["sleep"]); + + deliver("pty:replay", { id: "replay-b", data: "\x1b]633;E;sleep 5\x07\x1b]633;C\x07" }); + + expect(getTerminalPaneState("replay-b").currentCommand?.rawCommandLine).toBe("sleep 5"); + expect(alerts.some((detail) => detail.id === "replay-b" && detail.watchingEnabled)).toBe(true); + }); + it("pushes the resolved theme so the sidecar can answer a colour query", async () => { const { adapter, send } = await listening(); adapter.requestInit(); diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index e0931208f..6cd583bcb 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -371,7 +371,9 @@ export class BrowserSidecarAdapter implements PlatformAdapter { // why the one-shot parser still needs the theme. const { id, data: text, requestId } = data as PtyReplayDetail; const parsed = new TerminalProtocolParser(themeColorProvider).process(text); - applyTerminalSemanticEvents(id, collectTerminalSemanticEvents(parsed.events)); + const events = collectTerminalSemanticEvents(parsed.events); + this.alertManager.applyTerminalSemanticEvents(id, events); + applyTerminalSemanticEvents(id, events); for (const handler of this.replayHandlers) handler({ id, data: parsed.visibleData, requestId }); } else if (event === BURROW_RESULT_EVENT) { this.burrowClient.onResult(data as BurrowResult); diff --git a/standalone/src/tauri-adapter.test.ts b/standalone/src/tauri-adapter.test.ts index 0c0d9cf1e..ef39108c7 100644 --- a/standalone/src/tauri-adapter.test.ts +++ b/standalone/src/tauri-adapter.test.ts @@ -453,6 +453,25 @@ describe("TauriAdapter terminal stream", () => { expect(alerts.some((detail) => detail.id === "sem-pty")).toBe(true); }); + // A transferred pane's new window sees nothing but the replay: Rust drops the + // gap's semantic events because this path re-derives them, so it must rebuild + // both halves — pane state and the AlertManager's watch. + it("rebuilds alert state from a replay, not only pane state", async () => { + const { adapter, deliver } = await listening(); + const alerts: AlertStateDetail[] = []; + adapter.onAlertState((detail) => void alerts.push(detail)); + // The rule set is the sidecar's; this window hears it as a broadcast. + deliver("alert:watchedCommands", { names: ["sleep"] }); + + deliver("pty:replay", { + id: "replay-pty", + data: "\x1b]633;E;sleep 5\x07\x1b]633;C\x07", + }); + + expect(getTerminalPaneState("replay-pty").currentCommand?.rawCommandLine).toBe("sleep 5"); + expect(alerts.some((detail) => detail.id === "replay-pty" && detail.watchingEnabled)).toBe(true); + }); + it("pushes the resolved theme so the sidecar can answer a colour query", async () => { const { adapter, invoke } = await listening(); adapter.requestInit(); diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 1ecaedf85..4ec1d04db 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -187,9 +187,15 @@ export class TauriAdapter implements PlatformAdapter { // the asker is long gone (docs/specs/terminal-escapes.md). It still // needs the theme: a *declined* colour query is not consumed, so it // reaches xterm.js instead, and answering is the owner's alone. + // Both consumers of the live `terminal:semanticEvents` path, because a + // replay is the whole of what a transferred pane's new window has: Rust + // drops the gap's semantic events rather than holding them + // (docs/specs/standalone.md → "Routing"). const { id, data, requestId } = event.payload; const parsed = new TerminalProtocolParser(themeColorProvider).process(data); - applyTerminalSemanticEvents(id, collectTerminalSemanticEvents(parsed.events)); + const events = collectTerminalSemanticEvents(parsed.events); + this.alertManager.applyTerminalSemanticEvents(id, events); + applyTerminalSemanticEvents(id, events); for (const handler of this.replayHandlers) { handler({ id, data: parsed.visibleData, requestId }); } From d0a80f4dc1caf28634bd73eb7b4f59f21316d441 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 18:25:56 -0700 Subject: [PATCH 4/5] Seed transferred alerts before replay rebuilds live watch state --- docs/specs/standalone.md | 5 +++-- scripts/spec-word-budgets.json | 2 +- standalone/src/workspace-move.test.ts | 11 +++++++++++ standalone/src/workspace-move.ts | 9 ++++----- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 1af725f91..45a7b9c17 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -542,8 +542,9 @@ below reads that record rather than inferring itself from the suppression map. "arrived before armed" class of bug (rationale). Rust answers `pty:requestInit` with **that arrival's ids and no others**; `pty:list` and each `pty:replay` echo the collector's token. The target resumes over them, - hydrates the notes, seeds each persisted TODO into its own `AlertManager`, - and mounts the Workspace at the drop index. + hydrates the notes and mounts the Workspace at the drop index. + **Must seed persisted alerts before requesting replay**, so the older state + cannot erase WATCHING rebuilt by replay (`standalone/src/workspace-move.test.ts`). 4. **Target adopted** invokes `adopt_done(workspaceId)`. Rust retires the record, clears what is left of the suppression, and emits `workspace-departed` for **that Workspace alone** to its own source. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index efac695d2..e0eb7aa95 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -24,7 +24,7 @@ "docs/specs/security-supply-chain.md": 1150, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1050, - "docs/specs/standalone.md": 9050, + "docs/specs/standalone.md": 9100, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, diff --git a/standalone/src/workspace-move.test.ts b/standalone/src/workspace-move.test.ts index 92f169edf..f36722626 100644 --- a/standalone/src/workspace-move.test.ts +++ b/standalone/src/workspace-move.test.ts @@ -338,6 +338,17 @@ describe("the target half", () => { expect(getNotes("pane-a").map((note) => note.content)).toEqual([{ kind: "plain", text: "keep me" }]); }); + it("seeds the persisted alert before asking for the replay that rebuilds WATCHING", async () => { + const order: string[] = []; + const platform = fakePlatform(order); + vi.mocked(platform.alertSeed!).mockImplementation(() => { order.push("seed"); }); + arrivals = [payload()]; + initWorkspaceMoves(platform); + await settle(); + expect(order.indexOf("seed")).toBeGreaterThan(-1); + expect(order.indexOf("seed")).toBeLessThan(order.indexOf("adopt_ready")); + }); + it("resumes each of two simultaneous arrivals over its own PTYs", async () => { // A tear-out with a second tab dropped on it moments later. A window-wide // answer would let each collector finish on the other's shells. diff --git a/standalone/src/workspace-move.ts b/standalone/src/workspace-move.ts index 7516f3b6a..5def36c54 100644 --- a/standalone/src/workspace-move.ts +++ b/standalone/src/workspace-move.ts @@ -195,6 +195,10 @@ async function planArrival( platform: PlatformAdapter, payload: MovePayload, ): Promise { + // Seed the older persisted state before replay re-derives a running watch. + for (const pane of payload.workspace.session.panes) { + if (pane.alert) platform.alertSeed?.(pane.id, pane.alert); + } const ptyIds = new Set(payload.terminalIds); const live = await collectLivePtys(platform, { // The token rides through Rust to the sidecar's `list` and comes back on the @@ -218,11 +222,6 @@ async function planArrival( // The notes travelled in the payload rather than through the archive: a move // is not a closure (`docs/specs/notepad.md` → "Closure"). hydrateNotepadFromVolatile(payload.notepad, payload.allIds); - // The AlertManager is per webview, so a persisted TODO has to be seeded into - // this one — the source's went with its window. - for (const pane of payload.workspace.session.panes) { - if (pane.alert) platform.alertSeed?.(pane.id, pane.alert); - } return wallBootFromResult(result); } From 2d2dc7a5a4055022e586a1dd0ef7aa475767cb46 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 18:26:27 -0700 Subject: [PATCH 5/5] Exercise the persisted-alert branch in the replay ordering regression --- standalone/src/workspace-move.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/standalone/src/workspace-move.test.ts b/standalone/src/workspace-move.test.ts index f36722626..5e4386781 100644 --- a/standalone/src/workspace-move.test.ts +++ b/standalone/src/workspace-move.test.ts @@ -343,6 +343,7 @@ describe("the target half", () => { const platform = fakePlatform(order); vi.mocked(platform.alertSeed!).mockImplementation(() => { order.push("seed"); }); arrivals = [payload()]; + arrivals[0].workspace.session.panes[0].alert = { status: "WATCHING_DISABLED", todo: true, notification: null }; initWorkspaceMoves(platform); await settle(); expect(order.indexOf("seed")).toBeGreaterThan(-1);