diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6249b4..dc8258a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,11 @@ just setup-toolchain # adds the wasm32-wasip1 target just sandbox # builds, wires an isolated sandbox, verifies it ``` +The same loop runs against a second machine over ssh — `just remote-qa`, +`just remote-log` — for the regressions only that machine's environment +shows (frames off, a different Claude Code). See +[docs/dev/QA-DRIVE.md](docs/dev/QA-DRIVE.md), "The remote drive". + Then launch it yourself, **in a new terminal, outside zellij** — clave creates its own multiplexer session, so launching from inside one nests them: diff --git a/README.md b/README.md index 96e8bcf..33e142f 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Zellij's keys still belong to Zellij. - Each tab is a terminal. As usual. But with extra info shown in the tab text. - If the terminal is an agent TUI (like Claude Code), the sidebar is populated with rich information about the agent state. - Sidebar state comes from either [Claude Code hooks](https://code.claude.com/docs/en/hooks), or from your `.claude` `jsonl` store that Claude Code already keeps. -- **Your agents survive restarts.** Relaunch and every agent tab you had open comes back, in the order you had them. The top one picks up straight away, the rest pick up when you arrive at them. Plain terminal tabs do not come back. Tabs you had CLOSED stay closed as dormant rows: open one with `Alt+Enter` and it resumes where it left off. +- **Your agents survive restarts.** A relaunch opens one tab for the most recent conversation. Every other conversation is a dormant row; `Alt+Enter` opens it, and it resumes where it left off. Plain terminal tabs do not come back. - **Running tabs sit above closed ones**, so the agents and terminals you're using are quick to cycle through (with `Alt+↑` `Alt+↓`). - **The tab list orders itself by attention.** A modified "frecency" algorithm is used to keep the tabs you're most likely to reuse at the top. diff --git a/crates/clave-bar/src/card.rs b/crates/clave-bar/src/card.rs index 16540ec..1c4d269 100644 --- a/crates/clave-bar/src/card.rs +++ b/crates/clave-bar/src/card.rs @@ -77,6 +77,16 @@ const EXPANDED_COLS: usize = clave_types::RowHeight::Double.target_cols(false); const CARD_COLLAPSED_COLS: usize = clave_types::RowHeight::Card.target_cols(true); const CARD_EXPANDED_COLS: usize = clave_types::RowHeight::Card.target_cols(false); +/// Whether a painted width gets the expanded profile. A frameless pane +/// paints one column short of its declared width (FOOTGUNS: "A frameless +/// pane paints one column short"), so the threshold tolerates exactly the +/// separator column, the same allowance the model's `RowHeight::mode_at` +/// makes. Exact here and tolerant there would leave a frames-off bar parked +/// at the expanded width drawing the collapsed card: a third state. +fn is_expanded(build: usize, expanded_cols: usize) -> bool { + build + clave_types::SEPARATOR_COLS >= expanded_cols +} + /// The card's left chrome: margin, mark, air, rail, air. A four-cell version /// that butted the rail against the mark column was rejected on sight — the /// rule sat on top of the glyphs. The fifth column costs nothing that matters: @@ -332,7 +342,7 @@ pub(crate) fn render_double_card( // and loses the same trailing cells on every row (LEDGER D13), rather than // going ragged or — worse — wrapping into a third line. let build = cols.max(COLLAPSED_COLS); - let branch_w = if build >= EXPANDED_COLS { + let branch_w = if is_expanded(build, EXPANDED_COLS) { BRANCH_MIN } else { 0 @@ -511,7 +521,7 @@ pub(crate) fn render_card( // and loses the same trailing cells on every row rather than going ragged // or, worse, wrapping into a fifth line. let build = cols.max(CARD_COLLAPSED_COLS); - let expanded = build >= CARD_EXPANDED_COLS; + let expanded = is_expanded(build, CARD_EXPANDED_COLS); let c = cells(&row.content, theme); let dormant = row.dormant @@ -1492,6 +1502,78 @@ mod tests { } } + /// A frameless pane paints one column short of its declared width + /// (FOOTGUNS: "A frameless pane paints one column short"). The expanded + /// card must still be the expanded card at 47: the PR cell and the model + /// tail stay, and the rows fill exactly the painted width. Before the + /// fix the threshold was exact, so a frames-off bar sat at full width + /// drawing the collapsed card, which reads as a third state. + #[test] + fn the_card_one_column_under_expanded_is_the_expanded_card() { + let row = A { + tokens: Some(9_949_999), + pr: Some(1234), + branch: "feat/some-long-branch-name", + repo: "a-long-repository-name", + elapsed: "59s", + ..A::default() + } + .row(); + let painted = CARD_EXPANDED_COLS - clave_types::SEPARATOR_COLS; + let got = render_card(&row, painted, false, 0, &Theme::default()); + let l2 = strip_sgr(&got[1]); + let l3 = strip_sgr(&got[2]); + assert!( + l2.contains("1234") || l3.contains("1234"), + "the PR cell must survive one column short: {l2:?} / {l3:?}" + ); + assert!(l2.contains("fable"), "the model tail must survive: {l2:?}"); + for (i, line) in got.iter().enumerate() { + assert_eq!( + display_cells(&strip_sgr(line)), + painted, + "line {} left the painted width", + i + 1 + ); + } + // Two short is still the collapsed card: the tolerance is exactly + // the separator column, not a slope. + let two_short = render_card(&row, painted - 1, false, 0, &Theme::default()); + let l2 = strip_sgr(&two_short[1]); + let l3 = strip_sgr(&two_short[2]); + assert!( + !l2.contains("1234") && !l3.contains("1234"), + "two short must draw the collapsed card: {l2:?} / {l3:?}" + ); + } + + /// The same separator column, on the double card: its branch cell is the + /// thing the expanded width buys, and it must not vanish at 47. + #[test] + fn the_double_card_one_column_under_expanded_keeps_its_branch() { + // With a PR the collapsed profile has no columns left for a branch, + // so its presence is the one-bit read of which profile was drawn. + let row = A { + branch: "feat/some-long-branch-name", + pr: Some(1234), + ..A::default() + } + .row(); + let painted = EXPANDED_COLS - clave_types::SEPARATOR_COLS; + let (l1, l2) = render_double_card(&row, painted, false, false, &Theme::default()); + let joined = format!("{}{}", strip_sgr(&l1), strip_sgr(&l2)); + assert!( + joined.contains("feat/"), + "the branch cell must survive one column short: {joined:?}" + ); + let (l1, l2) = render_double_card(&row, painted - 1, false, false, &Theme::default()); + let joined = format!("{}{}", strip_sgr(&l1), strip_sgr(&l2)); + assert!( + !joined.contains("feat/"), + "two short must draw the collapsed double card: {joined:?}" + ); + } + /// The cell column a needle starts at. Line 3's chrome carries a /// multi-byte mark and rule, so a byte offset is not a column. fn cell_of(line: &str, needle: &str) -> Option { diff --git a/crates/clave-bar/src/main.rs b/crates/clave-bar/src/main.rs index 14e0756..b861118 100644 --- a/crates/clave-bar/src/main.rs +++ b/crates/clave-bar/src/main.rs @@ -114,6 +114,12 @@ struct State { /// the click map falls back to the pre-viewport identity mapping (line N /// selects row N) rather than misbehaving. pane_height: usize, + /// The last `width-deaf` line written, so a bar held at the wrong width + /// logs once per (width, reason) and not once per paint. + last_deaf: Option<(usize, &'static str)>, + /// The width of the last paint, so `render` logs a `painted` line only + /// when zellij changes it — the trace of a swap landing, or being undone. + last_cols: Option, /// A term-facts poll timer is in flight (#206) — one at a time, re-armed /// on expiry only while `term_poll_wanted()` holds. term_poll_armed: bool, @@ -247,12 +253,6 @@ impl State { let bin = self.clave_binary.clone(); for e in effects { match e { - Effect::RunHeldPane { pane_id } => { - // The restored tab's agent starts here — `rerun` is - // zellij's one verb for a held command pane, and a pane - // held from birth has simply never run once. - rerun_command_pane(pane_id); - } Effect::FocusPane { pane_id } => { // S2-proven nav: focus the terminal pane; Zellij pulls // its tab forward. go_to_tab is a known dead end. @@ -464,7 +464,7 @@ impl State { ), ); } - Effect::OpenAgent { uuid, restore_to } => { + Effect::OpenAgent { uuid } => { // Collapse mode rides along for D36's reason: the new tab // must be born in the mode the fleet is in. The width // needs no measuring — the layout `clave open` writes is @@ -473,13 +473,6 @@ impl State { if self.model.collapsed { argv.push("--collapsed"); } - // #261: held, and hand the focus back to this tab. The - // model decided both — see `Effect::OpenAgent`. - let home = restore_to.map(|t| t.to_string()); - if let Some(home) = home.as_deref() { - argv.push("--restore-to"); - argv.push(home); - } run_command(&argv, BTreeMap::new()); } Effect::PersistCollapse { collapsed } if presumed => { @@ -521,15 +514,6 @@ impl State { // out of `identity_effects` also keeps that function's contract what it // has always been — the actions to take, nothing else. let mut fx: Vec = self.model.bind_stall_report().into_iter().collect(); - // The held-tab binds. Kept beside `identity_effects` rather than - // inside it so the two ledgers stay visibly separate — `bind_effects` - // clears `bind_sent` for every uuid without a registered pane, which - // is this leg's whole population (CodeRabbit, #261). It carries its - // OWN election gate and reports for every held tab, not just ours: - // zellij sends the tab frame only to the focused tab, so a bar in an - // unvisited tab cannot resolve its own tab id at all. The elected bar - // can, for all of them — the pane manifest is global. - fx.extend(self.model.restored_bind_effects()); fx.extend(self.model.identity_effects()); if !fx.is_empty() { self.run_effects(fx); @@ -707,15 +691,6 @@ impl State { self.pending_peeks += 1; set_timeout(PEEK_SINK_SECS); // user-tuned: 1.0 felt a touch long } - // The beacon is a join input, like the two frames (#261). - // Waking a held agent needs the beacon AND this instance's - // own tab, and the two arrive by different routes: on a nav - // landing the target bar gets its `TabUpdate` while the - // beacon still names the tab the human left, so the wake - // arm refuses — and without this line nothing re-enters - // when the beacon catches up. Fail-closed and idempotent, - // so settling on the losing order costs nothing. - self.settle_identity(); true // active-row highlight may move } Err(e) => { @@ -802,10 +777,18 @@ impl ZellijPlugin for State { // start-or-reload-plugin`): stamp the build so the zellij log tells // you WHICH wasm produced a trace. Set by the rebuild recipe via // CLAVE_BUILD_TAG; "dev" means an untagged local build. + // The client id rides along (2026-09-22): zellij routes every + // swap-layout ask by the client this instance was loaded under + // (zellij-server 0.45.1 plugins/zellij_exports.rs:120-140, then + // screen.rs:10372 `active_tab_and_connected_client_id!`), and + // ids are the lowest free number (lib.rs:663-672), so a tab minted + // by a CLI client inherits an id the next CLI client will reuse. + let ids = get_plugin_ids(); eprintln!( - "clave-bar: loaded v{} build={}", + "clave-bar: loaded v{} build={} client={}", env!("CARGO_PKG_VERSION"), - option_env!("CLAVE_BUILD_TAG").unwrap_or("dev") + option_env!("CLAVE_BUILD_TAG").unwrap_or("dev"), + ids.client_id ); // #44: resolve the CLI from plugin configuration instead of PATH. A // stale `clave` on PATH previously served a live session's `clave @@ -1009,7 +992,6 @@ impl ZellijPlugin for State { is_focused: p.is_focused, is_floating: p.is_floating, terminal_command: p.terminal_command.clone(), - is_held: p.is_held, exited: p.exited, exit_status: p.exit_status, }); @@ -1147,6 +1129,17 @@ impl ZellijPlugin for State { // the deafness a few ms early is harmless, and no expiry can // strand it. let fx = self.model.width_cooldown_elapsed(); + // The cooldown's own asks were unlogged until 2026-09-22: + // `render` logs only the asks it makes, and this leg makes + // the rest. The QA counter reads both lines. + for e in &fx { + if let Effect::SwapWidth { backwards } = e { + let last = self.last_cols; + eprintln!( + "clave-bar: swap-width backwards={backwards} cols={last:?} source=cooldown" + ); + } + } let width_moved = !fx.is_empty(); self.run_effects(fx); // The term-poll leg (#206): re-probe, re-arm while wanted, @@ -1223,7 +1216,48 @@ impl ZellijPlugin for State { // pane id the request carries (v0.44.3 — FOOTGUNS.md). The gate lives in // `width_effects`, which holds the switch until this bar's own tab is // the focused one. + // Every width change zellij paints (2026-09-22): the only trace of + // a swap that landed on this pane, or landed and was undone. + if self.last_cols != Some(cols) { + let was = self.last_cols; + eprintln!("clave-bar: painted cols={cols} was={was:?}"); + self.last_cols = Some(cols); + } let fx = self.model.width_effects(Some(cols)); + // One log line per width ask, SHIPPED: it is the only observable + // of the flap the devbox had (2026-09-22: sixteen asks in four + // seconds, every one for the width the pane already had). The QA + // drive counts these lines per sandbox instance; a bar at its width + // asks nothing, so any ask during a nav walk is a defect. Cheap: + // a healthy bar asks at most once per toggle. + // Own tab, focused tab and the own tab's tiled pane count ride + // along (2026-09-22): zellij applies a swap to the focused tab, and + // an ask that never lands needs those three to name the seam. + for e in &fx { + if let Effect::SwapWidth { backwards } = e { + let tab = self.model.own_tab(); + let active = self.model.active_tab_id(); + let panes = self.model.own_tab_tiled_pane_count(); + let client = get_plugin_ids().client_id; + eprintln!( + "clave-bar: swap-width backwards={backwards} cols={cols} tab={tab:?} active={active:?} panes={panes:?} client={client}" + ); + } + } + // The silent case (2026-09-22): a paint at the wrong width with no + // ask. One line per (width, reason), so a bar resting wrong for a + // minute costs one line, not one per frame. + let deaf = self.model.width_deaf_reason(cols).map(|r| (cols, r)); + if deaf != self.last_deaf { + if let Some((_, reason)) = deaf { + let tab = self.model.own_tab(); + let active = self.model.active_tab_id(); + eprintln!( + "clave-bar: width-deaf cols={cols} reason={reason} tab={tab:?} active={active:?}" + ); + } + self.last_deaf = deaf; + } self.run_effects(fx); // One line per row, display-ordered. Everything visual — the column // arithmetic, the palette, the fade, the truncation — lives in diff --git a/crates/clave-bar/src/model.rs b/crates/clave-bar/src/model.rs index 7140bc1..15d0bcc 100644 --- a/crates/clave-bar/src/model.rs +++ b/crates/clave-bar/src/model.rs @@ -46,11 +46,6 @@ pub struct PaneMeta { /// nodes) — static, never the shell's current foreground. `None` for /// ordinary shell panes, which is the case `TermFacts` exists for (#206). pub terminal_command: Option, - /// zellij's "waiting for the human" flag. It covers TWO states that must - /// never be conflated: a command pane created with `start_suspended` that - /// has not run yet (what a restored tab is), and one that RAN and exited - /// and is offering to re-run. `exited` is what tells them apart. - pub is_held: bool, /// A finished command pane, and how it finished — the only place an exit /// code exists (an interactive shell never exits while its tab lives), so /// the only source of a terminal row's Done/Failed (#206). @@ -58,29 +53,6 @@ pub struct PaneMeta { pub exit_status: Option, } -/// The agent uuid a pane's command would spawn, if that command is one of -/// OURS. `None` for everything else, which is the point: it is the whole test -/// separating a restored clave tab from any other held command pane, and -/// `run_held_effect` starts what it admits. -/// -/// The baked binary is a bare `clave` in a sandbox and a versioned absolute -/// path in a release install (§2's binary split), so ownership is decided by -/// `clave_types::is_clave_binary`. -/// -/// zellij returns the command as one space-joined string, so the binary is -/// found by locating the `spawn` SUBCOMMAND and reading the token before it, -/// not by taking the first token: an install path containing a space would -/// otherwise make every restored tab under it come back as a terminal row. -fn spawn_uuid(cmd: &str) -> Option<&str> { - let tokens: Vec<&str> = cmd.split_whitespace().collect(); - let sub = tokens.iter().position(|t| *t == "spawn")?; - let bin = tokens.get(sub.checked_sub(1)?)?; - if !clave_types::is_clave_binary(bin) { - return None; - } - tokens.get(sub + 1).copied().filter(|u| !u.is_empty()) -} - /// What the bar has learned about a terminal pane beyond the manifest (#206): /// the cwd and foreground command are OS truths zellij only surrenders on /// request (`get_pane_cwd` / `get_pane_running_command`) or by subscription @@ -262,13 +234,6 @@ pub enum Effect { /// predate a close — the QA-drive nav wedge) and same-target across /// duplicate instances. FocusPane { pane_id: u32 }, - /// rerun_command_pane(pane_id) — start a restored tab's agent, which the - /// launch layout deliberately created HELD so the whole previous live set - /// could come back on screen without every agent's memory coming back with - /// it. Emitted once, by the instance whose own tab the human just landed - /// on: the pane stops being held the moment it runs, so the next frame - /// finds nothing to emit and the effect cannot repeat. - RunHeldPane { pane_id: u32 }, /// switch_tab_to(position + 1) — clicks and the nav fallback for tabs with /// no registered pane. All instances compute the same target from /// replicated state, so duplicates are idempotent. @@ -297,9 +262,8 @@ pub enum Effect { /// run_command(["clave","focus",uuid]) — persist the unread clear. MarkRead { uuid: String }, /// run_command(["clave","bind",uuid,tab_id]) — report the uuid→tab join - /// to the STORE (§6.6 Design B). TWO emitters, both from the elected - /// instance: `bind_effects` for a row whose pane is registered, and - /// `restored_bind_effects` for a held tab whose spawn has not run yet. + /// to the STORE (§6.6 Design B). Emitted by `bind_effects` from the + /// elected instance, for a row whose pane is registered. Bind { uuid: String, tab_id: usize }, /// run_command(["clave","prune-tabs", stale_ids…]) — drop store binds and /// tab_order entries for CLOSED tabs (#6/F3). Carries the OBSERVED-STALE @@ -367,22 +331,10 @@ pub enum Effect { /// (→ the bar's `initial_cwd`) when neither is known. Decided here so /// tests reach it; main.rs only translates `None`. ShellSpawn { cwd: Option }, - /// run_command(["clave","open",uuid]) — §6.3. Two callers, told apart by - /// `restore_to`: - /// - /// - `None` — the Alt+Enter commit (#100 dwell-commit: selection and - /// launch are separate acts). The human asked for this conversation, so - /// the tab runs it and keeps the focus it takes. - /// - `Some(tab_id)` — the staggered restore (#261). The tab comes back - /// with its agent HELD and the focus returns to `tab_id`, the - /// sequencer's own tab, so it never settles on a tab nobody asked for. - /// Both halves ride one field because neither is correct alone. - /// - /// In both cases the model has already marked the uuid in-flight (↻). - OpenAgent { - uuid: String, - restore_to: Option, - }, + /// run_command(["clave","open",uuid]) — §6.3. Fired ONLY by the Alt+Enter + /// commit (#100 dwell-commit: selection and launch are separate acts); + /// the model has already marked the uuid in-flight (↻). + OpenAgent { uuid: String }, /// run_command(["clave","touch",tab_id]) — the once-EVER birth stamp for a /// tab the store's tab order has never seen. Was an inline `run_command` in /// the adapter, which put it out of reach of every test (`main.rs` is @@ -600,18 +552,6 @@ struct BindSent { confirms: u32, } -/// One instance's outstanding `clave bind` for a RESTORED tab. `BindSent` -/// without `confirms`: that field counts the store advances that must hold a -/// bind before `bind_effects` refunds its budget, and this leg needs no refund -/// rule — the store carrying the bind ends the episode outright, which -/// `restored_bind_effects` tests before it reaches the ledger at all. -#[derive(Debug, Clone, PartialEq, Eq)] -struct RestoredBindSent { - tab_id: usize, - at_seq: u64, - tries: u32, -} - /// Bind re-emissions per (uuid, target tab) episode before we stop fighting. /// The heal RC-A needs is ONE; a lost push needs one or two; beyond that we /// are in an eviction ping-pong we cannot win (two agents whose panes both @@ -689,18 +629,6 @@ pub struct BarModel { /// reborn, so whatever write raced ahead belongs to the newborn. witnessed_dead: BTreeSet, panes: Vec, - /// agent uuid → tab id, read off the LAUNCH COMMAND of a pane rather than - /// off the store. A relaunch bakes the previous live set as tabs whose - /// `clave spawn` has not run yet (`setup::launch_layout_kdl`), and nothing - /// unrun can have written a bind — so without this a restored tab renders - /// as a TERMINAL row showing a raw `clave spawn ` line, and its agent - /// renders a SECOND time as dormant. One row, shown twice, neither true. - /// - /// Cached rather than derived per question: `rows()` asks it once per tab - /// and once per agent, and the real store carries ~185 rows. - /// `apply_tabs` and `apply_panes` are the only writers of the two frames - /// it joins, so both rebuild it. - spawn_binds: Vec<(String, usize)>, /// pane id → what the OS said about it (#206). Written by /// `apply_pane_facts` (main.rs probes and event deltas), pruned by /// `apply_panes` when the manifest no longer carries the pane. Only @@ -762,71 +690,6 @@ pub struct BarModel { /// no matter how many frames arrive (C5 rd 4's echo gate re-fired per /// TabUpdate and exhausted the server's fds; this cannot). bind_sent: BTreeMap, - /// The same accounting for the RESTORED leg, kept apart on purpose. - /// - /// `bind_effects` walks every agent and CLEARS `bind_sent` for any uuid - /// whose pane is not registered in `uuid_to_pane`. A restored row is - /// exactly that case — its spawn has not run, so nothing registered a - /// pane — so a shared ledger is wiped on the very next line of - /// `settle_identity` and `BIND_MAX_TRIES` never bites. Measured at 12 - /// subprocesses against a budget of 4 (CodeRabbit, #261). The budget - /// matters more now than it did then: the elected bar reports for EVERY - /// held tab, so one uncapped episode is one subprocess per restored tab - /// per store advance, not one. - restored_bind_sent: BTreeMap, - /// Held panes this instance has already told zellij to run (#261). - /// - /// The start arm re-enters on every store snapshot, and a snapshot is - /// broadcast by any agent's hook in the fleet. Between the rerun going out - /// and zellij's next `PaneUpdate`, the manifest still reports the pane - /// held and not exited, so without this the arm fires again and restarts a - /// claude that has just begun to boot. Dropped when the pane leaves the - /// manifest, so a genuinely re-held pane can be started again. - held_run_sent: BTreeSet, - /// The previous session's live set, straight off the snapshot (#261), in - /// ascending tab id — the order those tabs were created, not a rank. The - /// launch ranks a copy to pick the one row it bakes; everything else in - /// here is a row this bar has to open itself, and it opens them in the - /// order they arrive in here. - last_live: Vec, - /// Which row's tab drives the restore, straight off the snapshot (#261). - /// The launch names it, so every instance agrees without talking. - restore_owner: Option, - /// This instance has SEEN the restore queue empty, once (#261). - /// - /// A latch, and it must be one. `restore_pending` is built from - /// `restore_sent`, which is per-instance and only the owner ever writes — - /// so on every OTHER bar a row that holds no tab reads as "still owed" - /// forever, and a tab the human closes puts one there permanently. Read - /// raw by `run_held_effect`, that means: close one restored tab and no - /// held tab ever wakes again for the rest of the session. The guard is - /// about the storm of focus changes while the fleet arrives, and the storm - /// happens once; the latch is what makes the guard end with it. - restore_settled: bool, - /// Rows the restore has already opened, for the life of this instance. - /// - /// Without it the queue is "named in the set, holds no tab", which a tab - /// the human CLOSES re-enters — so the bar would reopen the tab they just - /// shut, and keep doing it. The restore owes each row exactly one open. - restore_sent: BTreeSet, - /// The beacon has named two different tabs in this instance's lifetime — - /// i.e. the focus has moved at least once since this bar was born (#261). - /// See [`BarModel::beacon`] for what it is worth, and `run_held_effect` - /// for who reads it. - beacon_moved: bool, - /// This bar sent a restore open and owes its own tab a re-anchor (#261). - /// - /// A tab made by `zellij action new-tab` always takes the focus, so the - /// restore hands it straight back. The bar born in the new tab may have - /// announced itself first, stranding the beacon on a tab nobody stands in - /// — dead nav, per FOOTGUNS. - /// - /// Its OWN flag rather than `organic_pending`, which [`BarModel::beacon`] - /// clears on the grounds that an arriving beacon is truth: right for - /// Alt+o, backwards here, where the arriving beacon IS the thing to undo. - /// Paid on the next tab frame — the frame the returning focus delivers — - /// so the re-anchor follows the birth announce instead of racing it. - restore_reanchor_owed: bool, /// Last bind-leg state we reported (#178). Only a CHANGE is worth a line; /// see `Effect::BindStall`. bind_stall: Option, @@ -838,9 +701,10 @@ pub struct BarModel { /// /// A switch is DERIVED: it is owed whenever the width zellij paints this /// pane at is not the width the mode declares - /// (`self.row_height.target_cols` — a fixed column count, per #232's - /// mode, that the layouts carry verbatim, so the comparison is equality - /// against a constant). There is no queue to replay, only an end state + /// (`self.row_height.mode_at` — a fixed column count per #232's mode, + /// carried verbatim by the layouts, judged with one column of tolerance + /// because a frameless pane paints one short; see FOOTGUNS "A frameless + /// pane paints one column short"). There is no queue to replay, only an end state /// to reach; a mode that leaves and returns owes nothing. /// /// `swap_owed`: an ask has been sent and the fast-band expiries it is @@ -1011,7 +875,7 @@ impl BarModel { /// it elects the nav executor; it never reorders (§6.6: focus is not a /// commitment). pub fn beacon(&mut self, tab_id: usize) { - self.set_beacon(tab_id); + self.current_tab = Some(tab_id); self.organic_pending = false; // truth arrived; leftover flags are poison // A new beacon re-anchors the election, so whatever an earlier tab // frame proved about the OLD beacon is spent, debt included (#162): @@ -1028,27 +892,6 @@ impl BarModel { self.cursor = None; } - /// Move the beacon, and record whether that was a MOVE. - /// - /// The one place `current_tab` is written, so the distinction cannot be - /// lost by a caller: `None → x` is a bar learning where the focus is, - /// `x → y` is the focus going somewhere. Only the second means a human - /// walked, and `run_held_effect` is allowed to start an agent on the - /// second alone (#261). - /// - /// The case that forces it: `zellij action new-tab` focuses the tab it - /// makes whatever the layout asks - /// (`zellij-utils-0.44.3/src/input/actions.rs:1611-1625`), so a restored - /// tab is born focused and its newborn bar announces itself — a - /// `None → own`. Read as an arrival, that starts the agent the hold exists - /// to keep asleep, on every row of the fleet. - fn set_beacon(&mut self, tab_id: usize) { - if self.current_tab.is_some_and(|prev| prev != tab_id) { - self.beacon_moved = true; - } - self.current_tab = Some(tab_id); - } - /// The `clave-visited` pipe entry: beacon, plus peek-on-nav — a /// collapsed bar expands while the user navigates. Returns true when a /// peek was armed so main.rs starts the ~1s sink timer. ONLY this pipe @@ -1403,6 +1246,21 @@ impl BarModel { .is_some_and(|t| t.active) } + /// Tiled pane count of the tab this instance sits in, from the last + /// PaneUpdate. Diagnostic only: a swap layout applies to the FOCUSED + /// tab's tiled panes, so a tab whose count the layout cannot map is the + /// first suspect when an ask never lands (2026-09-22, the devbox's baked + /// first tab). `None` while the frames disagree, like `own_tab`. + pub fn own_tab_tiled_pane_count(&self) -> Option { + let pos = self.own_tab_position()?; + Some( + self.panes + .iter() + .filter(|p| p.tab_position == pos && !p.is_floating) + .count(), + ) + } + /// The tab zellij's last frame says is active — any instance's view. pub fn active_tab_id(&self) -> Option { self.tabs.iter().find(|t| t.active).map(|t| t.tab_id) @@ -1440,10 +1298,6 @@ impl BarModel { { fx.push(Effect::Touch { tab_id: active }); } - // Before the bind: a restored tab has nothing to bind yet — its agent - // has not run, so no hook has registered a pane for it. Running it is - // what produces everything the bind leg below needs. - fx.extend(self.run_held_effect()); fx.extend(self.bind_effects(own)); // Prune LAST: its payload is disjoint from the touch's (dead ids vs a // live one) and from any bind's, so ordering is free, and keeping it @@ -1453,190 +1307,11 @@ impl BarModel { fx } - /// Start this tab's restored agent, if it has one waiting (#261). - /// - /// Starting one costs ~350 MB resident and is never returned, so every - /// gate here exists to stop a start nobody asked for. - /// - /// - **The beacon, not the election.** `identity_effects`' election reads - /// our own tab frame, which FOOTGUNS records as poisoned during bursts. - /// This pass re-enters on every store snapshot, on every instance, so a - /// starved bar would start an agent in a tab nobody is looking at. The - /// bind and prune arms survive the weaker gate because they are - /// idempotent; this one and `shell_toggle` are not. - /// - **`!exited`.** zellij's held flag also means "this RAN, press ENTER - /// to run it again" — starting that resurrects an agent the human - /// deliberately quit. - /// - **The command must be ours.** Any `zellij run` pane waiting to re-run - /// reports held too. `spawn_uuid` is the test: our binary, our subcommand. - /// - **The fleet is settled.** Nothing starts while the restore queue is - /// still running — the `restore_settled` gate in the body. - /// - /// Latched on pane id ([`BarModel::held_run_sent`]) because running a pane - /// clears its held flag only in the NEXT manifest, and this pass re-enters - /// on any hook anywhere in the fleet. In that window an unlatched arm - /// restarts a claude that has just begun to boot. Whether zellij ignores - /// the duplicate is unknowable here: `zellij-server` is not vendored, and - /// `zellij-tile-0.44.3/src/shim.rs:1744` says only "Re-run command in - /// pane". - fn run_held_effect(&mut self) -> Option { - if !self.own_tab_focused() { - return None; - } - // The focus must have MOVED to get here, not simply started here. - // `zellij action new-tab` focuses the tab it makes whatever the layout - // asks (`zellij-utils-0.44.3/src/input/actions.rs:1611-1625`), so the - // staggered restore's own tabs are born focused and announce - // themselves — a beacon that names us with nothing before it is that - // birth, not an arrival. Starting on it resumes the whole fleet, which - // is the cost the hold exists to avoid. A real arrival always moves the - // beacon off some other tab first. - if !self.beacon_moved { - return None; - } - // And the restore must be OVER. While the queue runs, every tab it - // builds takes the focus and gives it back, so the beacon is not - // evidence of anything: measured 2026-09-17, four tabs named eight - // times in 800 ms, and two agents woke with nobody near them. - // `beacon_moved` cannot tell that churn from an arrival — as a - // sequence of beacons it IS one. - // - // The cost is one-sided: arrive while the rest are coming back and the - // tab stays asleep until you step away and return. Waking an agent - // nobody asked for is the expensive mistake (~350 MB). - if !self.restore_settled && self.restore_pending() { - return None; - } - let own = self.own_tab_position()?; - self.panes - .iter() - .find(|p| { - p.tab_position == own - && p.is_held - && !p.exited - && !p.is_plugin - && p.terminal_command.as_deref().and_then(spawn_uuid).is_some() - }) - .map(|p| p.pane_id) - .filter(|id| self.held_run_sent.insert(*id)) - .map(|pane_id| Effect::RunHeldPane { pane_id }) - } - - /// Rebuild [`Self::spawn_binds`] from the two delivered frames. - /// - /// Deliberately NOT gated on `is_held`. A command pane keeps its launch - /// command after the process execs, so the same join holds through the - /// beat between a restored tab starting and the store's bind landing — - /// exactly where a flicker would show. `is_held`/`exited` gate the ACTION - /// ([`Self::run_held_effect`]); here nothing starts. - /// - /// The two frames join on tab POSITION, so an incoherent pair can name the - /// wrong tab for a beat — the exposure `is_dormant` already accepts on its - /// pane leg. It costs a flicker, not a write. - fn rebuild_spawn_binds(&mut self) { - let mut claimed: Vec = Vec::new(); - // ASCENDING TAB ID, which is NOT the order zellij hands tabs over — - // that is display position, and a moved tab separates the two. - // `restored_bind_effects` resolves the same contention by lowest tab - // id, so iterating in the given order let the two disagree: the bind - // lands on one tab while the row draws under another, which is the - // blink (CodeRabbit, #261). - let mut by_tab_id: Vec<&TabMeta> = self.tabs.iter().collect(); - by_tab_id.sort_by_key(|t| t.tab_id); - self.spawn_binds = by_tab_id - .into_iter() - .filter_map(|t| { - let uuid = self - .panes - .iter() - .filter(|p| p.tab_position == t.position && !p.is_plugin) - .find_map(|p| p.terminal_command.as_deref().and_then(spawn_uuid))?; - // ONE tab per agent, LOWEST TAB ID wins. Two tabs can carry a - // held pane for one uuid, and the pair then renders the agent - // twice and emits two `clave bind` calls per pass against a - // ledger keyed by uuid alone. Rejecting - // the second claim here is also what bounds the bind budget: - // `restored_bind_sent` refunds its tries when the target tab - // changes, so two tabs that alternate never spend it. - if claimed.iter().any(|u| u == uuid) { - return None; - } - claimed.push(uuid.to_string()); - Some((uuid.to_string(), t.tab_id)) - }) - .collect(); - } - - /// The store's bind, joined against the tabs that exist. - fn store_tab_live(&self, a: &Agent) -> bool { - a.tab_id - .is_some_and(|id| self.tabs.iter().any(|t| t.tab_id == id)) - } - - /// The agent a restored tab names in its pane's launch command, when no - /// snapshot bind claims that agent yet. The store's bind LEADS (§6.6 - /// Design B): an agent already shown live in its own tab must not be - /// claimed a second time here, or one agent would fill two rows. - fn spawn_bound_agent(&self, tab_id: usize) -> Option<&Agent> { - let uuid = self - .spawn_binds - .iter() - .find(|(_, id)| *id == tab_id) - .map(|(u, _)| u.as_str())?; - self.agents.iter().find(|a| { - a.uuid == uuid - && !self.store_tab_live(a) - // Nor when the pane that names it has EXITED. A command pane - // keeps its launch command after the process quits, so this - // join outlives the agent — and `is_dormant` is true by then, - // so both blocks draw the row and one agent is listed twice. - // - // `exited`, not `is_dormant`: between a restored tab's spawn - // starting and its bind landing the row is dormant too, and - // there the claim must HOLD or the tab blinks back to a - // terminal. `exited` is what separates running-but-unbound - // from ran-and-quit. (#261) - && !self.spawn_pane_exited(tab_id, uuid) - }) - } - - /// Has the pane that bakes this agent's spawn in this tab RUN AND QUIT? - /// - /// The tab is then an ordinary terminal holding a finished command, and the - /// way back to the agent is the deliberate restart on its dormant row. - fn spawn_pane_exited(&self, tab_id: usize, uuid: &str) -> bool { - let Some(pos) = self - .tabs - .iter() - .find(|t| t.tab_id == tab_id) - .map(|t| t.position) - else { - return false; - }; - self.panes.iter().any(|p| { - p.tab_position == pos - && !p.is_plugin - && p.exited - && p.terminal_command.as_deref().and_then(spawn_uuid) == Some(uuid) - }) - } - /// The agent bound to this tab, per the SNAPSHOT (§6.6 Design B) — the /// only join every instance agrees on. Local register/manifest joins are - /// used solely to CREATE binds (bind_effects), with one exception: a - /// RESTORED tab, whose agent cannot have written a bind because its - /// command has not run (see [`Self::spawn_binds`]). + /// used solely to CREATE binds (bind_effects). fn agent_in_tab(&self, tab_id: usize) -> Option<&Agent> { - self.agents - .iter() - // A DORMANT agent is drawn in the dormant block, and must not be - // drawn here as well. The store's bind outlives the agent on - // purpose — that is what brings the tab back at the next launch — - // so an agent that ran and quit still points at a tab while - // `is_dormant` calls it dormant, and both blocks draw it. The tab - // it left behind is an ordinary terminal. (#261) - .find(|a| a.tab_id == Some(tab_id) && !self.is_dormant(a)) - .or_else(|| self.spawn_bound_agent(tab_id)) + self.agents.iter().find(|a| a.tab_id == Some(tab_id)) } /// §6.6 Design B bootstrap: agents whose REGISTERED pane sits in @@ -1777,242 +1452,14 @@ impl BarModel { out } - /// Record the row EVERY held tab is holding, before the human reaches it. - /// - /// `Store::last_live` is built from the store's tab binds, and a restored - /// tab writes no bind until its spawn RUNS. So a fleet the human only - /// partly visited recorded only the visited part, and the restored set - /// shrank on every relaunch (#261). This leg makes a cold tab the bound - /// row it already is on screen. - /// - /// **Runs from the ELECTED instance, over every tab — not from each tab - /// for itself.** A bar resolves its own tab id from the tab frame, which - /// zellij delivers only to the FOCUSED tab, so a per-tab version can act - /// only where the human already is and the unvisited tabs stay silent. - /// Measured on a live relaunch, 2026-09-15: four restored tabs, one bind. - /// The elected bar has what the others lack — `PaneUpdate` is a global - /// manifest, `TabUpdate` the whole tab list — and `frames_coherent`, which - /// `elects_confirmed` already requires, is what stops a stale pairing - /// binding a row to another row's tab. - /// - /// Disjoint from [`Self::bind_effects`] in what it EMITS: that leg needs a - /// REGISTERED pane, which only a spawn that has run produces, and this one - /// takes only panes still waiting to run. It is NOT disjoint in what that - /// leg CLEARS — see [`BarModel::restored_bind_sent`]. - pub fn restored_bind_effects(&mut self) -> Vec { - if !self.elects_confirmed() { - return Vec::new(); - } - let seq = self.seq; - let mut out = Vec::new(); - let mut sent: Vec<(String, RestoredBindSent)> = Vec::new(); - for p in &self.panes { - // `is_held` and `!exited` carry the same two meanings they carry - // in `run_held_effect`: waiting to run, and not a command that - // already ran and quit. `spawn_uuid` is the whole test of - // ownership — any `zellij run` pane reports held too. - if p.is_plugin || !p.is_held || p.exited { - continue; - } - let Some(uuid) = p.terminal_command.as_deref().and_then(spawn_uuid) else { - continue; - }; - let Some(tab_id) = self - .tabs - .iter() - .find(|t| t.position == p.tab_position) - .map(|t| t.tab_id) - else { - continue; - }; - // ONE tab per agent, the lowest tab id wins. Two tabs can hold a - // held pane for one uuid, and without this rule the pair writes - // two `clave bind` calls for one agent in one pass, against a - // ledger keyed by uuid alone. The rule is also what - // BOUNDS the budget below: `restored_bind_sent` refunds its tries - // whenever the target tab changes, so two tabs that take turns - // never spend it and the bar runs a subprocess per store advance - // for the life of the session. Picking by tab id and not by frame - // order is the point — the choice must not move between frames. - if self.panes.iter().any(|o| { - !o.is_plugin - && o.is_held - && !o.exited - && o.terminal_command.as_deref().and_then(spawn_uuid) == Some(uuid) - && self - .tabs - .iter() - .find(|t| t.position == o.tab_position) - .is_some_and(|t| t.tab_id < tab_id) - }) { - continue; - } - if self - .agents - .iter() - .any(|a| a.uuid == uuid && a.tab_id == Some(tab_id)) - { - continue; // the store already carries this bind - } - // The same budget and seq rule as the ordinary leg — a quiescent - // store costs no subprocesses however many frames arrive — but its - // OWN ledger. See `BarModel::restored_bind_sent`. - let (may_send, tries) = match self.restored_bind_sent.get(uuid) { - None => (true, 0), - Some(s) if s.tab_id != tab_id => (true, 0), - Some(s) => (seq > s.at_seq && s.tries < BIND_MAX_TRIES, s.tries), - }; - if !may_send { - continue; - } - sent.push(( - uuid.to_string(), - RestoredBindSent { - tab_id, - at_seq: seq, - tries: tries + 1, - }, - )); - out.push(Effect::Bind { - uuid: uuid.to_string(), - tab_id, - }); - } - for (uuid, s) in sent { - self.restored_bind_sent.insert(uuid, s); - } - // Ledger hygiene, as `bind_effects` does it: an agent that has left the - // snapshot can never be matched again, so its entry would otherwise - // persist for the life of the instance. - let known: BTreeSet<&str> = self.agents.iter().map(|a| a.uuid.as_str()).collect(); - self.restored_bind_sent - .retain(|uuid, _| known.contains(uuid.as_str())); - out - } - - /// The next row the staggered restore owes, or `None` when it is finished. - /// - /// The queue is derived, never stored: a row is owed if the previous - /// session held it, this bar has not opened it, and it holds no tab now. - /// That last clause is why the human and the sequencer need no - /// coordination — reaching a row first binds it, and a bound row is not - /// owed. A row named in the set but missing from the store was pruned - /// between sessions, and is stepped over rather than waited for. - fn restore_next(&self) -> Option<&str> { - // Every instance reads the same store and would reach this conclusion - // at the same moment, so N bars would open the same row N times. - // - // The election is the LAUNCH'S NAME, not the focus. `zellij action - // new-tab` always takes the focus (FOOTGUNS), so every tab the restore - // makes is born believing it is the one the human is in and starts the - // queue again from the top. A sequencer cannot be elected by a signal - // its own work destroys. - if !self.owns_the_restore() { - return None; - } - // Wait for the TAB, not for the next store advance. `restore_sent` is - // set the instant an open goes out, which paces the queue at one row - // per SNAPSHOT — and a launch pushes a flurry. Measured 2026-09-17: - // three tabs inside one second killed the zellij server with "Too many - // open files", because the handle burst belongs to zellij BUILDING the - // tab, long after the row is marked sent. - // - // `prune_opening` releases a row on a bind, a stale row, or a row that - // left the store — every open that FINISHES. An open that dies without - // pushing a snapshot holds the mark forever and the queue stops. No - // timeout, on purpose: relaunching is the repair, and it is the repair - // that shrunken-fleet case already needs (known-open, its own issue). - if !self.opening.is_empty() { - return None; - } - self.next_owed() - } - - /// Is THIS instance the one the launch put in charge of the restore? - /// - /// True for the bar in the tab the launch baked, and for no other, however - /// the focus moves while tabs are being built. `own_tab` still has to - /// resolve — a bar that cannot say which tab it is in cannot claim to be - /// this one — but the claim itself comes from the store. - fn owns_the_restore(&self) -> bool { - let Some(owner) = self.restore_owner.as_deref() else { - return false; - }; - let Some(own) = self.own_tab() else { - return false; - }; - self.agents - .iter() - .any(|a| a.uuid == owner && a.tab_id == Some(own)) - } - - /// The head of the queue, whether or not this instant is a good time to - /// send it. Kept apart from `restore_next` because "a row is still owed" - /// and "send a row now" are different questions: the pacing gate makes the - /// second one false for a while without making the first one false at all. - fn next_owed(&self) -> Option<&str> { - self.last_live - .iter() - .find(|uuid| { - !self.restore_sent.contains(*uuid) - && self - .agents - .iter() - // A STALE row can never arrive: `clave open` found its - // cwd gone and refused it, and no later frame can put - // a tab on it. It must not count as owed, or the queue - // never once reads empty — and a deleted worktree in - // the previous live set is an ordinary thing to have. - .any(|a| &a.uuid == *uuid && a.tab_id.is_none() && !a.stale) - }) - .map(|s| s.as_str()) - } - - /// Is the restore still owed a row? - pub fn restore_pending(&self) -> bool { - self.next_owed().is_some() - } - - /// Open the next owed row, and ONLY the next one (#261). - /// - /// Building a tab costs a burst of about fifty file handles, opened in the - /// same instant and drained a second later (measured 2026-09-16: four tabs - /// peaked at 252 against macOS's default ceiling of 256, five crashed the - /// zellij server with "Too many open files"). Returning the whole queue - /// here would rebuild the layout's simultaneous restore one call later and - /// fail identically, so the one-row limit is the fix, not a detail of it. - pub fn restore_effects(&mut self) -> Vec { - let Some(uuid) = self.restore_next().map(str::to_string) else { - return Vec::new(); - }; - // Where the focus goes back to: the OWNER'S own tab, the one the - // launch baked. `restore_next` refused every other instance, and it - // resolves because that same gate went through `own_tab`. Not - // necessarily the tab the human is in — the owner is named by the - // launch, not by the focus, and that is the whole point (#261). - let Some(home) = self.own_tab() else { - return Vec::new(); - }; - // Marked before the open goes out, so the next tick advances instead of - // re-sending a row whose tab has not appeared yet. - self.restore_sent.insert(uuid.clone()); - // The new tab will take the focus and `clave open` will hand it back. - // Whatever the bar born there says about itself in between, the beacon - // belongs on this tab — see `restore_reanchor_owed`. - self.restore_reanchor_owed = true; - self.open_effects(&uuid, Some(home)) - } - - /// The open itself, shared by the pick and the restore. `restore_to` is - /// what separates them — see [`Effect::OpenAgent`]. - fn open_effects(&mut self, uuid: &str, restore_to: Option) -> Vec { + /// The Alt+Enter open: mark the row in flight (↻) and ask the host. + fn open_effects(&mut self, uuid: &str) -> Vec { if self.opening.contains(uuid) { return Vec::new(); } self.opening.insert(uuid.to_string()); vec![Effect::OpenAgent { uuid: uuid.to_string(), - restore_to, }] } @@ -2021,38 +1468,14 @@ impl BarModel { /// divergence only flickers a dormant row briefly (harmless) — but it /// suppresses the duplicate row in the pre-bind beat after a tab spawns. fn is_dormant(&self, a: &Agent) -> bool { - // `tab_id` says where the row LIVES, not that something runs there - // (#261). An exited agent keeps its tab so the next launch restores - // it, so the bind alone can no longer stand for liveness — without - // this the row draws a live agent's dot over a tab holding nothing, - // and the commit path refuses the restart. - // - // Only the TAB leg is suppressed. The two legs below still answer for - // themselves, which matters for a restored tab whose agent had exited - // before the quit: its baked spawn is waiting to run, `spawn_live` - // holds it live, and it must not also appear as a dormant row. - let tab_live = self.store_tab_live(a) && a.status != Status::Exited; + let tab_live = a + .tab_id + .is_some_and(|id| self.tabs.iter().any(|t| t.tab_id == id)); let pane_live = self .uuid_to_pane .get(&a.uuid) .is_some_and(|p| self.tab_position_of_pane(*p).is_some()); - // A restored tab holds this agent's `clave spawn` and HAS NOT RUN IT. - // The agent has a tab on screen, so it is not dormant — without this - // leg it lists a second time under the tab that already shows it. - // - // Asked of the PANE, not of `spawn_binds`. A command pane keeps its - // launch command after the process exits, so the bare name-join also - // matches an agent that ran and quit — and that agent must stay - // dormant, because the deliberate restart is the only way home from an - // exited row. `is_held && !exited` is the same pair `run_held_effect` - // uses, and it is what "waiting to run" means. - let spawn_live = self.panes.iter().any(|p| { - !p.is_plugin - && p.is_held - && !p.exited - && p.terminal_command.as_deref().and_then(spawn_uuid) == Some(a.uuid.as_str()) - }); - !tab_live && !pane_live && !spawn_live + !tab_live && !pane_live } /// Drop in-flight marks that resolved: the store bound the row to a tab or @@ -2143,16 +1566,6 @@ impl BarModel { // The mode below is now authoritative, so a switch may be booked (D37). self.awaiting_hydration = false; self.agents = snap.agents; - // The restore queue (#261). REPLACED like the rest of the snapshot: - // the set is the store's, and the bar's own progress through it lives - // in `restore_sent`, which a snapshot must never reset. - self.last_live = snap.last_live; - self.restore_owner = snap.restore_owner; - // Latched, never cleared: see `restore_settled`. The queue draining is - // a fact about the fleet arriving, not about the current row set. - if !self.restore_pending() { - self.restore_settled = true; - } // Hydrate the pane mapping from the snapshot (#178). `clave-register` // is a broadcast, so it reaches only the instances alive when it fires // — a tab born by a wake never hears about its OWN pane, while the @@ -2279,15 +1692,6 @@ impl BarModel { } } self.prune_opening(); // stale=true clears ↻ → ✗; new binds clear it - // The staggered restore rides the store advance, and needs no timer - // for it (#261). Opening a row binds it, binding writes the store, and - // the store pushes the next snapshot — so the chain paces ITSELF at - // one tab per advance, which is strictly safer than a fixed delay: a - // slow machine waits longer by construction. `restore_effects` takes - // the head of the queue and nothing else, which is what keeps the - // file-handle burst to a single tab's worth (measured: four tabs at - // once peaked at 252 against a ceiling of 256, five crashed zellij). - effects.extend(self.restore_effects()); effects } @@ -2311,7 +1715,6 @@ impl BarModel { self.witnessed_dead.retain(|id| !incoming.contains(id)); } self.tabs = tabs; - self.rebuild_spawn_binds(); let mut effects = Vec::new(); // #23 (2026-07-21): a tab CLOSE (`Alt+w`; `Ctrl+D` closes a plain shell // tab but never an agent pane, FOOTGUNS.md) can STRAND the nav beacon — @@ -2368,10 +1771,6 @@ impl BarModel { // arrives. let birth = !self.birth_announced; let organic = self.organic_pending; - // #261's third trigger. Same shape as `organic`: a bounded, one-shot - // claim spent only when it emits. It answers a beacon this bar's OWN - // restore open caused — see `restore_reanchor_owed`. - let restore_home = self.restore_reanchor_owed; if let Some(active_id) = self.tabs.iter().find(|t| t.active).map(|t| t.tab_id) { if self.current_tab == Some(active_id) { // The beacon ALREADY names the active tab: both claims are @@ -2380,28 +1779,17 @@ impl BarModel { // later burst — the round-11 storm shape. self.birth_announced = true; self.organic_pending = false; - // The restore's claim is the exception, and only while an open - // is in flight: the tab it will steal the beacon with does not - // exist yet, so a frame saying "you still hold the beacon" is - // evidence about nothing. Spending it here left the beacon on - // the last tab the restore built while the human sat on the - // baked one — the first Alt+Up did nothing, the second worked - // (measured live 2026-09-17). - if self.opening.is_empty() { - self.restore_reanchor_owed = false; - } } else if birth { // UNGATED (live-validated): a newborn must announce its own // tab before its first PaneUpdate can satisfy any gate. The // birth announce carries everything an armed organic wanted. self.birth_announced = true; self.organic_pending = false; - self.set_beacon(active_id); + self.current_tab = Some(active_id); effects.push(Effect::AnnounceVisit { tab_id: active_id }); - } else if (organic || stranded || restore_home) && self.elects_presumed() { + } else if (organic || stranded) && self.elects_presumed() { self.organic_pending = false; - self.restore_reanchor_owed = false; - self.set_beacon(active_id); + self.current_tab = Some(active_id); effects.push(Effect::ReanchorVisit { tab_id: active_id }); } } else if stranded && let Some(own) = self.own_tab() { @@ -2415,7 +1803,7 @@ impl BarModel { // reach here — their frozen frame flags their own tab active, so // the `find(active)` arm above takes them. self.organic_pending = false; - self.set_beacon(own); + self.current_tab = Some(own); effects.push(Effect::ReanchorVisit { tab_id: own }); } // The debt `apply_panes` pays (#162). Re-derived rather than copied @@ -2554,7 +1942,6 @@ impl BarModel { /// so the payment is frame-witnessed in the same sense the debt is. pub fn apply_panes(&mut self, panes: Vec) -> Vec { self.panes = panes; - self.rebuild_spawn_binds(); // A closed pane's facts must not survive it: pane ids are minted // monotonically by zellij, but a map that only grows is a leak in a // bar that lives for the session. Plugin panes are excluded from the @@ -2568,12 +1955,6 @@ impl BarModel { .map(|p| p.pane_id) .collect(); self.pane_facts.retain(|id, _| live.contains(id)); - // Same lifetime, same reason: the start latch is keyed on pane id, so - // it must not outlive the pane or a reborn id inherits a stand-down - // and never starts. A pane still in the manifest keeps its latch until - // zellij reports it no longer held, which is the frame that proves the - // run landed. - self.held_run_sent.retain(|id| live.contains(id)); // Stand-downs decay here, not on a clock: the manifest is what // re-arms a probe pass, so a delivered manifest is the one honest // unit of "retry skipped" (FOOTGUNS — the running-latch probe @@ -2605,7 +1986,7 @@ impl BarModel { // Answered by the send, like every other trigger since #162 — an // Alt+o still pending wanted exactly this announce. self.organic_pending = false; - self.set_beacon(own); + self.current_tab = Some(own); return vec![Effect::ReanchorVisit { tab_id: own }]; } Vec::new() @@ -2860,12 +2241,6 @@ impl BarModel { Status::NeedsYou => RowStatus::NeedsYou, Status::Done => RowStatus::Done, Status::Failed => RowStatus::Failed, - // The agent quit but its pane is still on screen, held and - // empty, so `is_dormant` above leaves it in the live block. - // Drawn hollow there: the row keeps its place in the fleet, - // and says plainly that nothing is running behind it. Never - // `Idle`, which on a live row means "ready and waiting". - Status::Exited => RowStatus::Dormant, } }; let provenance = provenance_of(a); @@ -2977,7 +2352,7 @@ impl BarModel { /// This carves NO exception into D16's state-not-cols lock — a hydrated /// bar (every peek, every toggle) still chooses by state alone. pub fn widths_at(&self, cols: usize) -> Widths { - if self.awaiting_hydration && cols == self.row_height.target_cols(true) { + if self.awaiting_hydration && self.row_height.mode_at(cols) == Some(true) { return Widths::COLLAPSED; } self.widths() @@ -3401,7 +2776,7 @@ impl BarModel { } // The pick keeps the focus its tab takes: the human just asked to // go there. - return self.open_effects(&uuid, None); + return self.open_effects(&uuid); } let rows = self.rows(); let line = if let Some(n) = v.get("row").and_then(|n| n.as_u64()) { @@ -3574,9 +2949,11 @@ impl BarModel { /// to its cooldown expiry.** The declared widths are fixed column /// counts ([`clave_types::RowHeight::target_cols`], read through /// `self.row_height`) carried verbatim by the layouts and applied - /// exactly by layout application, so "which geometry - /// am I in" is one equality against a constant — the same shape as the - /// battery cell's one-bit read of the mode, pointed at the supply side. + /// exactly by layout application. "Which geometry am I in" is + /// [`clave_types::RowHeight::mode_at`]: the nearest declared width, + /// allowing the one column a frameless pane loses to the separator + /// (FOOTGUNS "A frameless pane paints one column short"). NOT equality + /// against the constant: that belief was the devbox flap. /// /// Two machines died here, for opposite halves of the same lesson. The /// pre-#197 machine kept a BELIEF about which geometry its tab was in and @@ -3647,12 +3024,7 @@ impl BarModel { // bounded: the review of 2026-09-21 walked every case, and the worst // is the old two-step switch, never a loop. if let (None, Some(cols)) = (self.birth_collapsed, own_cols) { - for mode in [false, true] { - if cols == self.row_height.target_cols(mode) { - self.birth_collapsed = Some(mode); - break; - } - } + self.birth_collapsed = self.row_height.mode_at(cols); } // D37: the mode is not known yet, so any switch would be against a // guess. The pane is already born at the persisted mode's width. @@ -3684,7 +3056,8 @@ impl BarModel { return Vec::new(); } let want = self.showing_collapsed(); - if cols == self.row_height.target_cols(want) { + // By mode, not by exact column: the paint can be one short. + if self.row_height.mode_at(cols) == Some(want) { self.walk_spent = None; return Vec::new(); } @@ -3707,6 +3080,39 @@ impl BarModel { vec![Effect::SwapWidth { backwards }] } + /// Why the width machine made NO ask at this paint although the paint + /// disagrees with the store's mode — the gate of `width_effects` that + /// held it, in that function's order. `None` when the widths agree or + /// when an ask would have gone out. Diagnostic only, read-only, and + /// pure: a bar that sits at the wrong width in silence is otherwise + /// invisible in the log (the devbox's relaunched tabs, 2026-09-22). + pub fn width_deaf_reason(&self, cols: usize) -> Option<&'static str> { + let want = self.showing_collapsed(); + if self.row_height.mode_at(cols) == Some(want) { + return None; + } + if self.awaiting_hydration { + return Some("hydrating"); + } + if !self.own_tab_focused() { + return Some("unfocused"); + } + if self.own_tab_floating_visible() { + return Some("floating-visible"); + } + if self.swap_owed > 0 { + return Some("owed"); + } + let spent = match self.walk_spent { + Some((w, n)) if w == want => n, + _ => 0, + }; + if spent >= WALK_ASK_CAP { + return Some("capped"); + } + None + } + /// How long a claimed fast tick is believed. Two seconds is ten of them: /// long enough that a loaded host delivering one late cannot look stranded, /// short enough that a person does not read a frozen spinner as a hung @@ -3913,8 +3319,6 @@ mod tests { fn snap(seq: u64, agents: Vec) -> AgentSnapshot { AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -3926,70 +3330,10 @@ mod tests { } } - /// The uuids an effect list asks to open, in order. - fn opens(fx: &[Effect]) -> Vec { - fx.iter() - .filter_map(|e| match e { - Effect::OpenAgent { uuid, .. } => Some(uuid.clone()), - _ => None, - }) - .collect() - } - - /// The `restore_to` each open carries, in order — `None` for a pick. - fn open_homes(fx: &[Effect]) -> Vec> { - fx.iter() - .filter_map(|e| match e { - Effect::OpenAgent { restore_to, .. } => Some(*restore_to), - _ => None, - }) - .collect() - } - - /// A snapshot that also carries the previous session's live set — what the - /// launch left for the bar to bring back. - fn snap_live(seq: u64, agents: Vec, last_live: &[&str]) -> AgentSnapshot { - let base = snap(seq, agents); - // The launch names the row it BAKED as the restore's owner, and the - // baked row is the one that already holds a tab. Deriving it the same - // way here keeps the fixture honest about what the host really writes. - // `snap_owned` is for the case this cannot express: a bar that is NOT - // the owner. - let owner = last_live - .iter() - .find(|u| { - base.agents - .iter() - .any(|a| &a.uuid == *u && a.tab_id.is_some()) - }) - .map(|u| u.to_string()); - AgentSnapshot { - last_live: last_live.iter().map(|s| s.to_string()).collect(), - restore_owner: owner, - ..base - } - } - - /// `snap_live` with the owner said out loud — for the tests that need a - /// bar which is NOT in charge of the restore. - fn snap_owned( - seq: u64, - agents: Vec, - last_live: &[&str], - owner: Option<&str>, - ) -> AgentSnapshot { - AgentSnapshot { - restore_owner: owner.map(str::to_string), - ..snap_live(seq, agents, last_live) - } - } - /// Snapshot carrying only a tab order (the §6.6 store tab order): pairs of /// (tab_id, commitment ordinal). fn snap_t(seq: u64, ords: &[(usize, u64)]) -> AgentSnapshot { AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -4029,7 +3373,6 @@ mod tests { is_focused: focused, is_floating: false, terminal_command: None, - is_held: false, exited: false, exit_status: None, } @@ -4404,8 +3747,6 @@ mod tests { let mut a = agent("u-d", Status::Idle, None); a.commit_ord = 999; m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -4458,8 +3799,6 @@ mod tests { }) .collect(); m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -5167,8 +4506,6 @@ mod tests { /// binds alone. fn snap_full(seq: u64, agents: Vec, ords: &[(usize, u64)]) -> AgentSnapshot { AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -5253,896 +4590,45 @@ mod tests { /// one, so OUR pane 101 moves from position 1 to position 0. const FLEET_PANES_AFTER_CLOSE: [(usize, u32, u32); 2] = [(0, 101, 6), (1, 102, 7)]; - /// Same fleet, but OUR tab's terminal pane was created HELD, and our tab - /// is the active one. The shape when the human has just LANDED on a - /// restored tab: its spawn has not run yet, and we are elected. - /// - /// Read the doc this replaced and you can watch the defect being argued - /// into existence — it reasoned that "TabUpdate reaches only the active - /// tab, so a coherent frame pair naming us active IS the focus signal", - /// which is true, and then built a feature for UNVISITED tabs on top of - /// it. An unvisited tab's bar never gets that frame pair at all. Use - /// `fleet_bar_with_held_neighbours` for the restored fleet as it really - /// arrives; this one is only the landed-on case. - fn fleet_bar_with_held_own_pane(cmd: Option<&str>, exited: bool) -> BarModel { - let mut m = born_in_a_held_tab(cmd, exited); - // The human WALKS here: the beacon was on another tab, and this frame - // is it arriving. Without this the fixture models the restore's own - // birth instead — the bar comes up already focused and announces - // itself — which is the one case that must NOT start the agent - // (`set_beacon`). The old fixture did exactly that and read as an - // arrival, so every test of this arm passed on the wrong input. - // - // The real sequence, in two beacons: the restore hands the focus back - // to the tab it came from (10), and later the human walks here (11). - m.beacon(10); - m.beacon(11); - m - } - - /// Nothing wakes while the restore is still running, however the beacon - /// moves. - /// - /// Measured 2026-09-17: the queue's own tabs each take the focus on the - /// way in and give it back a moment later, so the beacon named four tabs - /// eight times in 800 ms. As a sequence of beacons that is indistinguish- - /// able from a human walking around, and two restored agents woke with - /// nobody near them. The queue draining is what makes an arrival mean - /// something again. - #[test] - fn a_restored_tab_stays_asleep_while_the_rest_are_still_coming_back() { - let mut m = - fleet_bar_with_held_own_pane(Some("clave spawn u-restored --name x --cwd /r"), false); - // One row is still owed, and this bar is not the one bringing it back. - let owed = m.apply_snapshot(snap_owned( - 9, - vec![ - agent("u-a", Status::Idle, Some(10)), - agent("u-b", Status::Idle, Some(11)), - agent("u-c", Status::Idle, None), - ], - &["u-a", "u-b", "u-c"], - Some("u-a"), - )); - assert!(m.restore_pending(), "premise: u-c is still owed"); - assert!( - !owed.iter().any(|e| matches!(e, Effect::RunHeldPane { .. })), - "woke an agent while the fleet was still arriving: {owed:?}" - ); + #[test] + fn own_tab_is_none_while_the_pane_and_tab_frames_disagree() { + // RC-A verbatim. The two frames are delivered independently and joined + // on tab POSITION, which zellij renumbers after a close — so in the + // window between one frame landing and the other, the join names a + // DIFFERENT tab. Driven manifest-first (the mirror of the dossier's + // trace) because that is the direction in which the pre-#55 + // computation does not merely fail, it confidently returns a dead tab. + let mut m = fleet_of_three(10); + // The close of tab 10 renumbers everything; the PaneUpdate lands first. + m.apply_panes(panes_at(&FLEET_PANES_AFTER_CLOSE)); + // Our pane is now at position 0. The FROZEN tab frame says position 0 + // is tab 10 — the tab that was just closed — and that it is active. + assert_eq!(m.own_tab(), None, "fail closed while the frames disagree"); + assert!(!m.elects_confirmed()); + // The regression this fix pins: the pre-#55 join elects us, and would + // have bound our agent to tab 10, evicting whoever holds it next. assert!( - !m.identity_effects() - .iter() - .any(|e| matches!(e, Effect::RunHeldPane { .. })), - "and does not wake on the next pass either" + m.elects_presumed(), + "the pre-#55 computation elects on the stale join — this is RC-A" ); + } - // The queue drains. The beacon means what it says again. - m.apply_snapshot(snap_owned( - 10, - vec![ - agent("u-a", Status::Idle, Some(10)), - agent("u-b", Status::Idle, Some(11)), - agent("u-c", Status::Idle, Some(12)), - ], - &["u-a", "u-b", "u-c"], - Some("u-a"), - )); - assert!(!m.restore_pending(), "premise: the fleet is all back"); - let done = m.identity_effects(); - assert!( - done.iter().any(|e| matches!(e, Effect::RunHeldPane { .. })), - "the human is standing here and the restore is over: {done:?}" - ); - } - - /// A row that can never come back must not hold the queue open. - /// - /// Found in review. `clave open` refuses a row whose cwd is gone and marks - /// it stale, and no later frame can put a tab on it. Counted as owed, it - /// makes the queue read "still arriving" from the first snapshot to the - /// last — so the owner never finishes, and on every other bar the wake arm - /// stays shut for the rest of the session. A deleted worktree in the - /// previous live set is an ordinary thing to have. - #[test] - fn a_row_whose_cwd_is_gone_does_not_hold_the_restore_open() { - let mut m = - fleet_bar_with_held_own_pane(Some("clave spawn u-restored --name x --cwd /r"), false); - let mut gone = agent("u-c", Status::Idle, None); - gone.stale = true; - m.apply_snapshot(snap_owned( - 9, - vec![ - agent("u-a", Status::Idle, Some(10)), - agent("u-b", Status::Idle, Some(11)), - gone, - ], - &["u-a", "u-b", "u-c"], - Some("u-a"), - )); - assert!( - !m.restore_pending(), - "a row that cannot arrive was counted as still arriving" - ); - let fx = m.identity_effects(); - assert!( - fx.iter().any(|e| matches!(e, Effect::RunHeldPane { .. })), - "one dead row left the whole fleet unable to wake: {fx:?}" - ); - } - - /// Closing one restored tab must not stop every OTHER held tab waking. - /// - /// Found in review. The wake guard asks `restore_pending`, which is built - /// from `restore_sent` — per-instance, and only the OWNER ever writes it. - /// `run_held_effect` runs on the bar the human is standing in, which is by - /// definition not the owner, so on that bar a row with no tab reads as - /// "still owed" forever. Close one restored tab and the fleet goes deaf. - /// - /// Two models on one snapshot, per the FOOTGUNS rule: the owner and the - /// bar the human is actually on disagree about what is owed, and the guard - /// has to survive that disagreement. - #[test] - fn a_closed_restored_tab_does_not_deafen_the_rest_of_the_fleet() { - let mut m = - fleet_bar_with_held_own_pane(Some("clave spawn u-restored --name x --cwd /r"), false); - // The fleet is all back. This bar has never sent an open — it is not - // the owner — so everything it knows comes off the snapshot. - let all_back = |seq| { - snap_owned( - seq, - vec![ - agent("u-a", Status::Idle, Some(10)), - agent("u-b", Status::Idle, Some(11)), - agent("u-c", Status::Idle, Some(12)), - ], - &["u-a", "u-b", "u-c"], - Some("u-a"), - ) - }; - m.apply_snapshot(all_back(9)); - assert!(!m.restore_pending(), "premise: nothing is owed"); - - // The human closes the tab that held u-c. Its row unbinds. - m.apply_snapshot(snap_owned( - 10, - vec![ - agent("u-a", Status::Idle, Some(10)), - agent("u-b", Status::Idle, Some(11)), - agent("u-c", Status::Idle, None), - ], - &["u-a", "u-b", "u-c"], - Some("u-a"), - )); - assert!( - m.restore_pending(), - "premise: this bar cannot tell a closed tab from an unrestored one" - ); - let fx = m.identity_effects(); - assert!( - fx.iter().any(|e| matches!(e, Effect::RunHeldPane { .. })), - "a closed tab elsewhere left this one unable to wake: {fx:?}" - ); - } - - /// The same bar as the restore makes it: born into a tab that already has - /// the focus, with nothing before it. Used by the fixture above and by the - /// test that this state starts nothing. - fn born_in_a_held_tab(cmd: Option<&str>, exited: bool) -> BarModel { - let mut m = BarModel::default(); - m.set_own_pane(101); - let mut panes = panes_at(&FLEET_PANES); - for p in &mut panes { - if p.pane_id == 6 { - p.is_held = true; - p.exited = exited; - p.terminal_command = cmd.map(str::to_string); - } - } - m.apply_panes(panes); - m.apply_tabs(vec![ - tab(10, 0, "a", false), - tab(11, 1, "b", true), - tab(12, 2, "c", false), - ]); - m - } - - /// A relaunch as it really arrives: OUR tab is the eager one the launch - /// focused, and the NEIGHBOURS are the held restored tabs nobody has - /// reached. This is the fleet the live run of 2026-09-15 produced — four - /// tabs, one started, three waiting — and the shape no test modelled - /// before, which is why four rows produced one bind in the field. - /// - /// Our own pane is an ordinary running one. Every bind this fixture can - /// produce is therefore a bind for somebody ELSE's tab, which the old - /// per-tab leg could not emit even in principle. - fn fleet_bar_with_held_neighbours() -> BarModel { - let mut m = BarModel::default(); - m.set_own_pane(101); - let mut panes = panes_at(&FLEET_PANES); - for p in &mut panes { - // Terminals 5 and 7 sit in tabs 10 and 12; ours (6) keeps running. - if p.pane_id == 5 || p.pane_id == 7 { - p.is_held = true; - p.terminal_command = Some(format!( - "clave spawn u-held-{} --name x --cwd /r", - p.pane_id - )); - } - } - m.apply_panes(panes); - m.apply_tabs(vec![ - tab(10, 0, "a", false), - tab(11, 1, "b", true), - tab(12, 2, "c", false), - ]); - m - } - - /// The relaunch payoff: a restored tab's agent starts when the human - /// arrives, and not a moment sooner. A held pane costs a tab and nothing - /// else; a running one costs ~350 MB that is never given back, so the - /// whole previous fleet can come back on screen for the price of the one - /// row actually being looked at. - #[test] - fn landing_on_a_restored_tab_runs_its_held_spawn() { - let mut m = - fleet_bar_with_held_own_pane(Some("clave spawn u-restored --name x --cwd /r"), false); - assert!( - m.identity_effects() - .contains(&Effect::RunHeldPane { pane_id: 6 }), - "the instance that just became active starts its own held pane" - ); - } - - /// The restore's own tab must start NOTHING, and this is the state it - /// really arrives in: `zellij action new-tab` focuses the tab it makes - /// whatever the layout asks - /// (`zellij-utils-0.44.3/src/input/actions.rs:1611-1625`), so the bar is - /// born already focused and announces itself. Read as an arrival that - /// resumes the agent this tab was brought back asleep to avoid — once per - /// row, so a twelve-row fleet is gigabytes of `claude` nobody asked for. - /// - /// `clave open --restore-to` hands the focus straight back, and this is - /// the guard for the window before it lands. - #[test] - fn a_tab_born_focused_never_starts_its_own_held_spawn() { - let mut m = born_in_a_held_tab(Some("clave spawn u-restored --name x --cwd /r"), false); - assert!( - m.own_tab_focused(), - "premise: the newborn's own announce has put the beacon on itself" - ); - assert!( - !m.identity_effects() - .iter() - .any(|e| matches!(e, Effect::RunHeldPane { .. })), - "a birth is not an arrival — the agent stays asleep" - ); - // And the focus coming back, then the human walking here later, does - // start it: the guard delays the start, it does not cancel it. - m.beacon(10); - m.beacon(11); - assert!( - m.identity_effects() - .contains(&Effect::RunHeldPane { pane_id: 6 }), - "the human arriving still starts it" - ); - } - - /// It starts ITS OWN tab's held pane, never a neighbour's. A relaunch puts - /// a waiting spawn in most tabs at once, so without the tab guard the - /// elected bar would start whichever held pane the manifest lists first — - /// resuming an agent in a tab nobody has opened. That is the ~350 MB - /// resident that is never given back, and it does not self-heal. - /// - /// Every other test of this arm uses a fixture whose only held pane IS - /// ours, so all of them stayed green with the guard deleted (measured, - /// swarm review 2026-09-16). This is the one that does not. - #[test] - fn landing_on_our_tab_never_starts_a_neighbours_held_spawn() { - let mut m = fleet_bar_with_held_neighbours(); - assert!( - !m.identity_effects() - .iter() - .any(|e| matches!(e, Effect::RunHeldPane { .. })), - "our own pane is running; the held panes in tabs 10 and 12 are not ours to start" - ); - } - - /// The start arm re-enters on EVERY store snapshot, and a snapshot is - /// broadcast by any agent's hook anywhere in the fleet. Between the rerun - /// going out and zellij's next `PaneUpdate`, the manifest still reports - /// the pane held and not exited, so the arm would fire again — restarting - /// a claude that has just begun to boot. - /// - /// The arm used to call itself self-limiting, on the grounds that running - /// the pane clears its held flag. That is true only AFTER the next frame - /// arrives, and it rested on a claim that zellij ignores a duplicate - /// rerun, which cannot be checked: `zellij-server` is not vendored, and - /// `zellij-tile-0.44.3/src/shim.rs:1744` documents `rerun_command_pane` - /// as no more than "Re-run command in pane". Latching is cheaper than - /// measuring it. (#261, swarm review 2026-09-16.) - #[test] - fn the_held_spawn_is_started_once_per_pane_not_once_per_snapshot() { - let mut m = - fleet_bar_with_held_own_pane(Some("clave spawn u-restored --name x --cwd /r"), false); - assert!( - m.identity_effects() - .contains(&Effect::RunHeldPane { pane_id: 6 }), - "the first pass starts it" - ); - // No new PaneUpdate: the pane still reads held and not exited. - assert!( - !m.identity_effects() - .iter() - .any(|e| matches!(e, Effect::RunHeldPane { .. })), - "a second snapshot in the same frame must not start it again" - ); - } - - /// The start arm rides the BEACON, not this instance's own opinion of - /// which tab is active. FOOTGUNS: "every hidden instance's stale tab set - /// claims its own tab is active, so anything gated on self-diagnosed 'am - /// I active' is poisoned during event bursts" — and a store snapshot - /// re-enters the identity pass on EVERY instance, not just the looked-at - /// one. The same reasoning already keeps `shell_toggle` behind the beacon: - /// only the arm that STARTS something is dangerous in duplicate. Here the - /// duplicate costs ~350 MB resident that is never given back, and unlike - /// the bind and prune arms beside it, it does not self-heal. - #[test] - fn a_starved_bar_never_starts_its_held_spawn_from_a_stale_active_flag() { - let mut m = - fleet_bar_with_held_own_pane(Some("clave spawn u-restored --name x --cwd /r"), false); - // Our own frame still flags OUR tab active — the frozen-frame shape. - // The replicated beacon says the human is somewhere else. - m.beacon(10); - assert!( - !m.identity_effects() - .iter() - .any(|e| matches!(e, Effect::RunHeldPane { .. })), - "a bar nobody is looking at must not resume an agent" - ); - } - - /// `is_held` is zellij's flag for "waiting for the human", and it covers - /// TWO states: a command that has not run yet, and a command pane that - /// RAN and exited and is offering to re-run. Only the first is ours to - /// start. Without the exit check, walking past the tab of an agent the - /// human deliberately quit would silently resurrect it. - #[test] - fn a_held_pane_that_already_exited_is_never_restarted() { - let mut m = - fleet_bar_with_held_own_pane(Some("clave spawn u-restored --name x --cwd /r"), true); - assert!( - !m.identity_effects() - .iter() - .any(|e| matches!(e, Effect::RunHeldPane { .. })), - "an exited pane is a finished agent, not a restored one" - ); - } - - /// A RELEASE install bakes the versioned copy's absolute path, and it is - /// the only environment that does — a sandbox shims a bare `clave`, so - /// neither drive reaches this shape. Every fixture here used the bare name - /// until the swarm review of #261 found the gap: a regression in this arm - /// would leave every restored tab in a shipped build showing as a terminal - /// row that never starts, with the whole suite green. - /// - /// The spaced path is the second half of the same shape. zellij returns the - /// launch command space-joined, so an install under a directory with a - /// space in it is indistinguishable from a multi-token command unless the - /// subcommand is what anchors the parse. - #[test] - fn a_restored_tab_is_recognised_when_the_release_binary_is_an_absolute_path() { - for cmd in [ - "/Users/x/.local/share/clave/bin/clave-v0.4.0 spawn u-restored --name x --cwd /r", - "/Users/Foo Bar/.local/share/clave/bin/clave-v0.4.0 spawn u-restored --name x", - "clave-v1.10.3 spawn u-restored", - ] { - assert_eq!( - spawn_uuid(cmd), - Some("u-restored"), - "a release install's own spawn must be recognised: {cmd}" - ); - } - } - - /// The near-miss the digit check exists for, at the shape the bar sees it. - /// A stranger's binary must never have its pane started for it. - #[test] - fn a_look_alike_binary_never_passes_as_our_spawn() { - for cmd in [ - "clave-vault spawn u-restored", - "/usr/bin/clave-verify spawn u-restored", - "spawn u-restored", - ] { - assert_eq!(spawn_uuid(cmd), None, "{cmd} is not ours"); - } - } - - /// The command has to be OURS. A held pane is an ordinary thing for a - /// human to have — any `zellij run` command pane waiting to be re-run - /// reports the same flag — and re-running a stranger's command because - /// the human navigated past it would be clave reaching outside its own - /// fleet. - #[test] - fn a_held_pane_running_someone_elses_command_is_left_alone() { - for cmd in [None, Some("cargo test --workspace"), Some("clave ls")] { - let mut m = fleet_bar_with_held_own_pane(cmd, false); - assert!( - !m.identity_effects() - .iter() - .any(|e| matches!(e, Effect::RunHeldPane { .. })), - "{cmd:?} is not a clave spawn and must not be run" - ); - } - } - - /// The relaunch's durability problem, and the reason a cold tab binds at - /// all. `Store::last_live` — the set the NEXT relaunch restores — is - /// derived from the store's tab binds, and a restored tab writes no bind - /// until its `clave spawn` runs. So a fleet the human only partly visited - /// recorded only the visited part, and the set shrank on every relaunch - /// until one row was left. Measured on this branch: three rows restored, - /// one tab visited, one row came back. - /// - /// The landed-on case: our own tab is the held one and we are elected, so - /// the bind lands before the spawn has registered anything. The tab - /// nobody has reached is the sibling case, and it belongs to the elected - /// bar rather than to itself — `the_elected_bar_binds_the_held_tabs_it_does_not_own`. - #[test] - fn a_restored_tab_binds_its_row_before_the_human_reaches_it() { - let mut m = - fleet_bar_with_held_own_pane(Some("clave spawn u-restored --name x --cwd /r"), false); - m.apply_snapshot(snap(1, vec![agent("u-restored", Status::Working, None)])); - assert_eq!( - m.restored_bind_effects(), - vec![Effect::Bind { - uuid: "u-restored".into(), - tab_id: 11, - }], - "the tab holds the row on screen, so the store must know it holds it" - ); - } - - /// A spawn pane that already RAN AND QUIT is not a restored tab. Its - /// command still reads `clave spawn ` — a launch command is static — - /// so the uuid test alone admits it, and the bind would claim a tab for a - /// row that has nothing living in it. `exited` is the only discriminator - /// between "waiting to run" and "finished", and it carries the same - /// meaning here as in `run_held_effect`. (Mutation survivor, 2026-09-16: - /// the three-way skip was uncovered on its `exited` leg.) - #[test] - fn a_held_spawn_pane_that_already_exited_is_not_a_restored_tab() { - let mut m = - fleet_bar_with_held_own_pane(Some("clave spawn u-restored --name x --cwd /r"), true); - m.apply_snapshot(snap(1, vec![agent("u-restored", Status::Working, None)])); - assert_eq!( - m.restored_bind_effects(), - Vec::::new(), - "the command ran and quit, so no tab is waiting to be started" - ); - } - - /// One report per episode, the same rule the ordinary bind leg follows: - /// the guard is the last SEND, never the store echo, because an - /// echo-gated guard storms (C5 rd 4). - #[test] - fn a_restored_bind_is_reported_once_and_stops_when_the_store_confirms_it() { - let mut m = - fleet_bar_with_held_own_pane(Some("clave spawn u-restored --name x --cwd /r"), false); - m.apply_snapshot(snap(1, vec![agent("u-restored", Status::Working, None)])); - assert_eq!(m.restored_bind_effects().len(), 1); - assert_eq!(m.restored_bind_effects(), Vec::::new(), "no repeat"); - m.apply_snapshot(snap( - 2, - vec![agent("u-restored", Status::Working, Some(11))], - )); - assert_eq!( - m.restored_bind_effects(), - Vec::::new(), - "the store already shows the row in our tab" - ); - } - - /// The budget, which is the whole reason a fire-and-forget subprocess - /// emitter is safe to run from an unelected instance. A retry costs a - /// `clave bind` process, so it is spent only when the STORE has moved on - /// since our last report — a quiescent store costs nothing however many - /// frames arrive — and it runs out. - #[test] - fn a_restored_bind_retries_only_as_the_store_advances_and_stops_at_the_cap() { - let mut m = - fleet_bar_with_held_own_pane(Some("clave spawn u-restored --name x --cwd /r"), false); - let mut seq = 1; - m.apply_snapshot(snap(seq, vec![agent("u-restored", Status::Working, None)])); - assert_eq!(m.restored_bind_effects().len(), 1, "the first report"); - for attempt in 2..=BIND_MAX_TRIES { - assert!( - m.restored_bind_effects().is_empty(), - "silent while the store has not moved" - ); - seq += 1; - m.apply_snapshot(snap(seq, vec![agent("u-restored", Status::Working, None)])); - assert_eq!( - m.restored_bind_effects().len(), - 1, - "attempt {attempt}: the store advanced and we are still unbound" - ); - } - seq += 1; - m.apply_snapshot(snap(seq, vec![agent("u-restored", Status::Working, None)])); - assert!( - m.restored_bind_effects().is_empty(), - "the budget runs out rather than retrying forever" - ); - } - - /// The defect the live relaunch found, in the tier that should have. - /// - /// Four restored tabs came back and ONE bind was written, so the next - /// relaunch would have restored one tab. Every test here passed, because - /// each handed a bar the tab frame of a tab it owned — and zellij sends - /// that frame only to the FOCUSED tab, so the bars this leg exists for - /// never had one. The elected bar reports for all of them instead. - #[test] - fn the_elected_bar_binds_the_held_tabs_it_does_not_own() { - let mut m = fleet_bar_with_held_neighbours(); - m.apply_snapshot(snap( - 1, - vec![ - agent("u-held-5", Status::Idle, None), - agent("u-held-7", Status::Idle, None), - ], - )); - assert_eq!( - m.restored_bind_effects(), - vec![ - Effect::Bind { - uuid: "u-held-5".into(), - tab_id: 10, - }, - Effect::Bind { - uuid: "u-held-7".into(), - tab_id: 12, - }, - ], - "every held tab's row is reported, not only the one bar's own tab" - ); - } - - /// Fails closed, like every other tab-scoped write. The join reads tab ids - /// from one frame and pane positions from another, which is the RC-A class - /// — an unelected instance may hold a frozen frame pair (`starved_bar`), - /// and binding a row to another row's tab is the damage that class does. - #[test] - fn an_unelected_bar_reports_no_held_tab_at_all() { - let mut m = fleet_bar_with_held_neighbours(); - m.apply_tabs(vec![ - tab(10, 0, "a", true), // somebody ELSE is active now - tab(11, 1, "b", false), - tab(12, 2, "c", false), - ]); - m.apply_snapshot(snap( - 1, - vec![ - agent("u-held-5", Status::Idle, None), - agent("u-held-7", Status::Idle, None), - ], - )); - assert!( - m.restored_bind_effects().is_empty(), - "a cross-frame join is for the elected instance alone" - ); - } - - /// The budget again, with the leg the SHELL actually runs beside it. - /// - /// `settle_identity` calls `restored_bind_effects` and then - /// `identity_effects` on one pass, and `bind_effects` inside the second - /// one walks EVERY agent and clears the ledger for any uuid whose pane is - /// not registered in `uuid_to_pane`. A restored row is by definition that - /// case — its spawn has not run, so nothing registered a pane — so a - /// shared ledger is wiped between passes and the cap above never bites. - /// The two tests above pass anyway, because they call one leg alone. - /// (CodeRabbit, #261.) - #[test] - fn a_restored_binds_budget_survives_the_ordinary_bind_leg_running_beside_it() { - // The fixture leaves OUR tab active, which the two tests above override - // away. That is exactly the difference: `identity_effects` bails before - // `bind_effects` on an unelected instance, so only an elected one — the - // human standing on the restored tab, before its spawn has registered a - // pane — reaches the clear list at all. - let mut m = - fleet_bar_with_held_own_pane(Some("clave spawn u-restored --name x --cwd /r"), false); - let mut seq = 1; - m.apply_snapshot(snap(seq, vec![agent("u-restored", Status::Working, None)])); - let mut reports = 0; - // Far past the cap: an uncapped leg emits on every store advance. - for _ in 0..(BIND_MAX_TRIES * 3) { - reports += m.restored_bind_effects().len(); - let _ = m.identity_effects(); // the shell's very next line - seq += 1; - m.apply_snapshot(snap(seq, vec![agent("u-restored", Status::Working, None)])); - } - assert_eq!( - reports, BIND_MAX_TRIES as usize, - "the budget caps the subprocesses, whatever else runs on the pass" - ); - } - - /// A DIFFERENT tab is a new episode and gets a full budget. zellij - /// renumbers tabs when one closes, so a restored tab can legitimately - /// become a different id without anything being wrong — and a spent budget - /// carried across that rename would silence a correct bind for the life of - /// the instance. - #[test] - fn a_restored_bind_gets_a_fresh_budget_when_its_tab_is_renumbered() { - let mut m = - fleet_bar_with_held_own_pane(Some("clave spawn u-restored --name x --cwd /r"), false); - let mut seq = 1; - m.apply_snapshot(snap(seq, vec![agent("u-restored", Status::Working, None)])); - for _ in 0..BIND_MAX_TRIES { - let _ = m.restored_bind_effects(); - seq += 1; - m.apply_snapshot(snap(seq, vec![agent("u-restored", Status::Working, None)])); - } - assert!( - m.restored_bind_effects().is_empty(), - "the budget for tab 11 is spent" - ); - // Same pane, same position, new tab ids: the renumbering shape. We - // stay the elected bar across it, because that is the only instance - // this leg runs on at all. - m.apply_tabs(vec![ - tab(20, 0, "a", false), - tab(21, 1, "b", true), - tab(22, 2, "c", false), - ]); - assert_eq!( - m.restored_bind_effects(), - vec![Effect::Bind { - uuid: "u-restored".into(), - tab_id: 21, - }], - "a new target is a new episode, not a spent one" - ); - } - - /// The same two guards the START arm carries, for the same reasons: an - /// exited pane is a finished agent, and a held pane is an ordinary thing - /// for a human to have. Neither is a row of ours to claim. - #[test] - fn a_restored_bind_never_claims_a_pane_that_is_not_our_waiting_spawn() { - for (cmd, exited) in [ - (Some("clave spawn u-restored --name x --cwd /r"), true), - (Some("cargo test --workspace"), false), - (None, false), - ] { - // The fixture already leaves OUR tab active, which is what - // `elects_confirmed` needs. An override that activated another tab - // used to sit here, and it made every case return at the election - // gate instead of at the guards under test: a VALID waiting spawn - // asserted green through it too. (#261, swarm review 2026-09-16.) - let mut m = fleet_bar_with_held_own_pane(cmd, exited); - m.apply_snapshot(snap(1, vec![agent("u-restored", Status::Working, None)])); - assert_eq!( - m.restored_bind_effects(), - Vec::::new(), - "{cmd:?} exited={exited} is not a restored row of ours" - ); - } - // …and the SAME fixture does bind a valid waiting spawn. Without this - // the three cases above could all be passing at the election gate and - // never reach the guards they name. - let mut m = - fleet_bar_with_held_own_pane(Some("clave spawn u-restored --name x --cwd /r"), false); - m.apply_snapshot(snap(1, vec![agent("u-restored", Status::Working, None)])); - assert!( - !m.restored_bind_effects().is_empty(), - "the fixture must be able to produce a bind, or the cases above prove nothing" - ); - } - - /// A restored fleet on screen: one tab per row, each carrying a launch - /// command. `bound` is the tab the STORE knows about — a restored row has - /// none, because its `clave spawn` has not run and only a run writes a - /// bind. - fn restored_fleet(cmds: [Option<&str>; 3], bound: Option) -> BarModel { - let mut m = BarModel::default(); - let mut panes = panes_at(&FLEET_PANES); - let terminals: Vec<&mut PaneMeta> = panes.iter_mut().filter(|p| !p.is_plugin).collect(); - for (p, cmd) in terminals.into_iter().zip(cmds) { - p.is_held = cmd.is_some(); - p.terminal_command = cmd.map(str::to_string); - } - m.apply_panes(panes); - m.apply_tabs(vec![ - tab(10, 0, "one", true), - tab(11, 1, "two", false), - tab(12, 2, "three", false), - ]); - m.apply_snapshot(snap(1, vec![agent("u-restored", Status::Working, bound)])); - m - } - - /// The relaunch's own row problem. A restored tab carries its agent's - /// `clave spawn` and nothing has run it, so no bind exists — and the bar - /// read the tab as a TERMINAL and the agent as DORMANT. One agent, two - /// rows, neither of them the fleet the human left behind. - #[test] - fn a_restored_tab_shows_its_agents_row_and_shows_it_once() { - let m = restored_fleet( - [Some("clave spawn u-restored --name x --cwd /r"), None, None], - None, - ); - assert!( - matches!(content_at(&m, 0), RowContent::Agent { .. }), - "the restored tab is the agent's row, not a terminal showing its command line" - ); - assert!( - !m.rows().iter().any(|(_, r)| r.dormant), - "an agent holding a tab on screen is not also a dormant row" - ); - } - - /// The join is on the LAUNCH COMMAND, not on the held flag: a command - /// pane keeps its launch command after the process execs. Held-only, the - /// row would fall back to a terminal for the beat between the human - /// starting a restored tab and `clave bind` landing — a flicker in the - /// exact moment the feature is being used. - #[test] - fn a_restored_tab_keeps_its_row_the_moment_its_spawn_starts_running() { - let mut m = restored_fleet( - [Some("clave spawn u-restored --name x --cwd /r"), None, None], - None, - ); - let mut panes = panes_at(&FLEET_PANES); - for p in panes.iter_mut().filter(|p| p.pane_id == 5) { - p.terminal_command = Some("clave spawn u-restored --name x --cwd /r".into()); - } - m.apply_panes(panes); // running now: is_held cleared, command unchanged - assert!( - matches!(content_at(&m, 0), RowContent::Agent { .. }), - "the row must not blink back to a terminal while the bind is in flight" - ); - } - - /// Only OUR command names a row. Any other command pane in the session is - /// a terminal tab and stays one, and an agent it does not name stays - /// dormant. - #[test] - fn a_held_pane_that_is_not_our_spawn_leaves_the_rows_alone() { - let m = restored_fleet([Some("cargo test --workspace"), None, None], None); - assert!( - matches!(content_at(&m, 0), RowContent::Terminal { .. }), - "someone else's held command is a terminal row" - ); - assert!( - m.rows().iter().any(|(_, r)| r.dormant), - "an agent no tab names is still dormant" - ); - } - - /// The order a relaunch comes back in is the FLEET's order, not the - /// layout's. A restored tab has no session-scoped ranking data at all — - /// tab ordinals and tab buckets die with the session that minted them — - /// so its rank has to come from the agent record, which survives. It - /// does, through the same `agent_in_tab` join the row content uses: kill - /// that join and every restored row scores zero and falls back to the - /// baked left-to-right tab order, which is only where the previous - /// session happened to leave them on screen. - #[test] - fn restored_rows_rank_by_their_own_frecency_not_by_the_baked_tab_order() { - let mut m = BarModel::default(); - let mut panes = panes_at(&FLEET_PANES); - let terminals: Vec<&mut PaneMeta> = panes.iter_mut().filter(|p| !p.is_plugin).collect(); - for (p, uuid) in terminals.into_iter().zip(["u-a", "u-b", "u-c"]) { - p.is_held = true; - p.terminal_command = Some(format!("clave spawn {uuid} --name x --cwd /r")); - } - m.apply_panes(panes); - m.apply_tabs(vec![ - tab(10, 0, "one", true), - tab(11, 1, "two", false), - tab(12, 2, "three", false), - ]); - // Baked order is a, b, c. Investment says b, c, a. - let invested = |uuid: &str, count: u32| { - let mut a = agent(uuid, Status::Idle, None); - a.buckets.insert(0, count); // `snap` fixes now_hour at 0 - a - }; - m.apply_snapshot(snap( - 1, - vec![invested("u-a", 1), invested("u-b", 5), invested("u-c", 3)], - )); - let keys: Vec = m.rows().into_iter().map(|(k, _)| k).collect(); - assert_eq!( - keys, - vec![RowKey::Tab(11), RowKey::Tab(12), RowKey::Tab(10)], - "the fleet's own ranking, not the order the layout baked" - ); - } - - /// The join is on tab POSITION, and the position has to match: a command - /// pane names the agent of ITS OWN tab and of no other. Without that, one - /// restored tab anywhere in the fleet would hand its agent to every tab. - #[test] - fn a_spawn_command_names_only_the_tab_its_pane_sits_in() { - let m = restored_fleet( - [None, Some("clave spawn u-restored --name x --cwd /r"), None], - None, - ); - assert!( - matches!(content_at(&m, 0), RowContent::Terminal { .. }), - "a tab with no spawn of its own is a terminal row" - ); - let agents = m - .rows() - .into_iter() - .filter(|(_, r)| matches!(r.content, RowContent::Agent { .. })) - .count(); - assert_eq!(agents, 1, "exactly the tab holding the command"); - } - - /// The store's bind LEADS (§6.6 Design B). A stray pane naming an agent - /// that is already live in its own tab must not mint a second row for it: - /// the tab the store bound is the truth, and the other tab is whatever - /// its own content says. - #[test] - fn a_spawn_command_never_claims_an_agent_the_store_already_shows_live() { - let m = restored_fleet( - [None, Some("clave spawn u-restored --name x --cwd /r"), None], - Some(10), - ); - let agents = m - .rows() - .into_iter() - .filter(|(_, r)| matches!(r.content, RowContent::Agent { .. })) - .count(); - assert_eq!(agents, 1, "one agent, one row, and the store picks the tab"); - } - - #[test] - fn own_tab_is_none_while_the_pane_and_tab_frames_disagree() { - // RC-A verbatim. The two frames are delivered independently and joined - // on tab POSITION, which zellij renumbers after a close — so in the - // window between one frame landing and the other, the join names a - // DIFFERENT tab. Driven manifest-first (the mirror of the dossier's - // trace) because that is the direction in which the pre-#55 - // computation does not merely fail, it confidently returns a dead tab. - let mut m = fleet_of_three(10); - // The close of tab 10 renumbers everything; the PaneUpdate lands first. - m.apply_panes(panes_at(&FLEET_PANES_AFTER_CLOSE)); - // Our pane is now at position 0. The FROZEN tab frame says position 0 - // is tab 10 — the tab that was just closed — and that it is active. - assert_eq!(m.own_tab(), None, "fail closed while the frames disagree"); - assert!(!m.elects_confirmed()); - // The regression this fix pins: the pre-#55 join elects us, and would - // have bound our agent to tab 10, evicting whoever holds it next. - assert!( - m.elects_presumed(), - "the pre-#55 computation elects on the stale join — this is RC-A" - ); - } - - #[test] - fn a_newborn_model_is_incoherent_before_either_frame_has_arrived() { - // The pre-first-frame fail-closed arm. Asserted on `frames_coherent` - // directly because two EMPTY frames trivially cover the same (empty) - // position set — the set comparison alone would call that coherent, - // which is the one case the explicit guard exists for. - let mut m = BarModel::default(); - m.set_own_pane(101); - assert!(!m.frames_coherent()); - assert_eq!(m.own_tab(), None); - assert!(!m.elects_confirmed()); - assert!(!m.elects_presumed()); - // One frame alone is not enough either. - m.apply_tabs(vec![tab(11, 0, "b", true)]); - assert!(!m.frames_coherent()); - assert_eq!(m.own_tab(), None); + #[test] + fn a_newborn_model_is_incoherent_before_either_frame_has_arrived() { + // The pre-first-frame fail-closed arm. Asserted on `frames_coherent` + // directly because two EMPTY frames trivially cover the same (empty) + // position set — the set comparison alone would call that coherent, + // which is the one case the explicit guard exists for. + let mut m = BarModel::default(); + m.set_own_pane(101); + assert!(!m.frames_coherent()); + assert_eq!(m.own_tab(), None); + assert!(!m.elects_confirmed()); + assert!(!m.elects_presumed()); + // One frame alone is not enough either. + m.apply_tabs(vec![tab(11, 0, "b", true)]); + assert!(!m.frames_coherent()); + assert_eq!(m.own_tab(), None); } #[test] @@ -7316,7 +5802,6 @@ mod tests { is_focused: false, is_floating: true, terminal_command: None, - is_held: false, exited: false, exit_status: None, }); @@ -7424,7 +5909,6 @@ mod tests { is_focused: false, is_floating: true, terminal_command: None, - is_held: false, exited: false, exit_status: None, }); @@ -7971,11 +6455,49 @@ mod tests { assert_eq!(m.width_effects(Some(COL_W)), Vec::::new()); m.toggle(); // and back assert_eq!( - m.width_effects(Some(COL_W)), + m.width_effects(Some(COL_W)), + vec![Effect::SwapWidth { backwards: false }] + ); + assert_eq!(m.width_effects(Some(EXP_W)), Vec::::new()); + assert_eq!(m.width_cooldown_elapsed(), Vec::::new()); + } + + /// **The separator column.** With `pane_frames false` zellij reserves one + /// column of every tiled pane that is not at the viewport's right edge, + /// borderless or not, for the line between it and its neighbour + /// (`zellij-server/src/panes/tiled_panes/mod.rs`, `pane_content_offset`, + /// the same in 0.44.3 and 0.45.1). The bar is the left pane, so a 48-col + /// pane paints at 47 and a 16-col one at 15. Measured on the devbox + /// 2026-09-22: sixteen asks in four seconds, every one `cols=47` or + /// `cols=15` while wanting the width it already had, and each swap walked + /// the tab through the other geometry. A painted width one short of a + /// declared width IS that width. + #[test] + fn a_painted_width_one_short_of_the_target_is_at_the_target() { + let mut m = focused_bar(); + // Born and painted at 47: the birth guess is expanded, and no ask. + assert_eq!(m.width_effects(Some(EXP_W - 1)), Vec::::new()); + assert_eq!(m.birth_collapsed, Some(false)); + // A toggle from 47 still asks, and 15 ends it. + m.toggle(); + assert_eq!( + m.width_effects(Some(EXP_W - 1)), + vec![Effect::SwapWidth { backwards: true }] + ); + assert_eq!(m.width_effects(Some(COL_W - 1)), Vec::::new()); + assert_eq!(m.width_cooldown_elapsed(), Vec::::new()); + // Before hydration the paint picks the profile: 15 is the collapsed one. + let fresh = BarModel { + awaiting_hydration: true, + ..BarModel::default() + }; + assert_eq!(fresh.widths_at(COL_W - 1), Widths::COLLAPSED); + // Two short is NOT a declared width: nothing else is tolerated. + let mut m = focused_bar(); + assert_eq!( + m.width_effects(Some(EXP_W - 2)), vec![Effect::SwapWidth { backwards: false }] ); - assert_eq!(m.width_effects(Some(EXP_W)), Vec::::new()); - assert_eq!(m.width_cooldown_elapsed(), Vec::::new()); } /// **The step direction** (measured 2026-09-21: the 0.2s collapse lag). @@ -8027,10 +6549,11 @@ mod tests { fn an_unknown_birth_width_steps_forward() { let mut m = focused_bar(); m.toggle(); - // The first paint ever is one column short of either width: no + // The first paint ever is two columns short of either width (one + // short is the separator column, and IS the width): no // birth is recorded, and the ask still goes out, forward. assert_eq!( - m.width_effects(Some(47)), + m.width_effects(Some(EXP_W - 2)), vec![Effect::SwapWidth { backwards: false }] ); assert_eq!(m.birth_collapsed, None); @@ -8363,9 +6886,9 @@ mod tests { #[test] fn a_width_zellij_cannot_produce_is_asked_thrice_then_rested() { let mut m = focused_bar(); - m.toggle(); // wants collapsed; the window can only paint 47 + m.toggle(); // wants collapsed; the window can only paint 46 assert_eq!( - m.width_effects(Some(47)), + m.width_effects(Some(EXP_W - 2)), vec![Effect::SwapWidth { backwards: false }] ); for _ in 1..WALK_ASK_CAP { @@ -8375,7 +6898,11 @@ mod tests { ); } assert_eq!(m.width_cooldown_elapsed(), Vec::::new(), "rested"); - assert_eq!(m.width_effects(Some(47)), Vec::::new(), "still"); + assert_eq!( + m.width_effects(Some(EXP_W - 2)), + Vec::::new(), + "still" + ); } /// The budget is per INTENT, not per width: a walk whose positions keep @@ -8393,7 +6920,7 @@ mod tests { m.width_effects(Some(COL_W)), vec![Effect::SwapWidth { backwards: true }] ); - for paint in [47, COL_W] { + for paint in [EXP_W - 2, COL_W] { assert_eq!(m.width_effects(Some(paint)), Vec::::new()); assert_eq!( m.width_cooldown_elapsed(), @@ -8401,7 +6928,7 @@ mod tests { ); } // Three spent without a landing: the walk rests, moving or not. - assert_eq!(m.width_effects(Some(47)), Vec::::new()); + assert_eq!(m.width_effects(Some(EXP_W - 2)), Vec::::new()); assert_eq!(m.width_cooldown_elapsed(), Vec::::new(), "rested"); assert_eq!(m.width_effects(Some(COL_W)), Vec::::new(), "still"); } @@ -8472,9 +6999,10 @@ mod tests { /// every further press. #[test] fn a_walk_that_never_landed_still_hands_the_next_intent_a_full_budget() { - // Neither target, so no paint below can end an episode by landing — + // Neither target (one short is the separator column and counts as + // the target), so no paint below can end an episode by landing — // the budget can only be re-armed by the intent changing. - const NEITHER: usize = EXP_W - 1; + const NEITHER: usize = EXP_W - 2; let mut m = focused_bar(); m.toggle(); // wants collapsed assert_eq!( @@ -9475,8 +8003,6 @@ mod tests { let mut a = agent("u-dormant", Status::Idle, None); a.last_interacted = 500; m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -9531,8 +8057,6 @@ mod tests { new.commit_ord = 900; new.last_interacted = 100; m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -9762,8 +8286,6 @@ mod tests { let mut m = BarModel::default(); m.apply_tabs(vec![tab(7, 0, "agent-tab", true)]); m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -9780,8 +8302,6 @@ mod tests { m.register("u2".into(), 42); m.apply_panes(vec![pane(0, 42, false, true)]); m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -9794,167 +8314,6 @@ mod tests { assert!(!keys(&m).contains(&RowKey::Dormant("u2".into()))); } - /// An agent that EXITED still holds its tab (#261), and that bind is what - /// brings the tab back on the next launch. It must not also make the row - /// read as alive: the tab is on screen and nothing runs in it. - /// - /// Before this, such a row was not dormant, so it drew the same dim dot as - /// a live idle agent AND the commit path refused it — the human could see - /// no difference and had no way back. Walking past the tab starts nothing - /// either, deliberately (`run_held_effect` guards on `!exited`, so a tab - /// you quit is not resurrected because you passed it). That leaves the - /// deliberate restart as the only way home, so it has to work. - #[test] - fn an_agent_that_exited_is_dormant_although_its_tab_is_still_open() { - let mut m = BarModel::default(); - m.apply_tabs(vec![tab(7, 0, "agent-tab", true)]); - m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, - order: OrderMode::default(), - now_hour: 0, - tab_buckets: Default::default(), - tab_touched: Default::default(), - collapsed: false, - seq: 1, - // Bound to a tab that exists, no registered pane: the shape - // `apply_hook_pane`'s SessionEnd arm leaves behind. - agents: vec![agent("u1", Status::Exited, Some(7))], - tab_order: Default::default(), - }); - assert!( - keys(&m).contains(&RowKey::Dormant("u1".into())), - "the tab is open and nothing runs in it, so the row is dormant" - ); - // A live idle agent in the same tab is NOT dormant — the two states - // this test exists to keep apart. - let mut m = BarModel::default(); - m.apply_tabs(vec![tab(7, 0, "agent-tab", true)]); - m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, - order: OrderMode::default(), - now_hour: 0, - tab_buckets: Default::default(), - tab_touched: Default::default(), - collapsed: false, - seq: 1, - agents: vec![agent("u1", Status::Idle, Some(7))], - tab_order: Default::default(), - }); - assert!( - !keys(&m).contains(&RowKey::Dormant("u1".into())), - "an idle agent is still running; only an exited one is dormant" - ); - } - - /// One agent claims one tab, whatever the pane frame carries. - /// - /// Found in review. Two tabs can hold a `clave spawn` pane for the same - /// uuid — this branch made that state live, before the launch named the - /// restore's owner. Without a uniqueness rule the pair writes two `clave - /// bind` calls for one agent in one pass, against a ledger keyed by uuid - /// alone; and because the ledger refunds its tries whenever the target tab - /// changes, the two tabs take turns and the budget never bites — one - /// subprocess per store advance, for the life of the session. - #[test] - fn two_tabs_that_name_one_agent_bind_it_once() { - let mut m = BarModel::default(); - m.set_own_pane(101); - let mut panes = panes_at(&FLEET_PANES); - for p in &mut panes { - // Panes 5 and 7 sit in tabs 10 and 12, and both name u-twin. - if p.pane_id == 5 || p.pane_id == 7 { - p.is_held = true; - p.terminal_command = Some("clave spawn u-twin --name x --cwd /r".into()); - } - } - m.apply_panes(panes); - m.apply_tabs(vec![ - tab(10, 0, "a", false), - tab(11, 1, "b", true), - tab(12, 2, "c", false), - ]); - m.apply_snapshot(snap(1, vec![agent("u-twin", Status::Working, None)])); - let binds: Vec<_> = m - .restored_bind_effects() - .into_iter() - .filter(|e| matches!(e, Effect::Bind { uuid, .. } if uuid == "u-twin")) - .collect(); - assert_eq!( - binds, - vec![Effect::Bind { - uuid: "u-twin".into(), - tab_id: 10, - }], - "one agent, one bind, and the SAME one every frame" - ); - // And it is drawn under one tab, not two — the same rule, on the - // render side, where the pair would list the agent twice. - assert_eq!( - [10, 11, 12] - .into_iter() - .filter(|t| m.spawn_bound_agent(*t).is_some_and(|a| a.uuid == "u-twin")) - .count(), - 1, - "one agent cannot own two tabs" - ); - } - - /// The pane frame must not keep an exited agent out of the dormant block. - /// - /// Found in review. A command pane keeps its launch command after the - /// process exits (FOOTGUNS; `zellij-utils-0.44.3/src/data.rs:2331`), so the - /// pane of an agent that ran and quit still reads `clave spawn `. - /// Joined on the string alone, that says "a baked spawn is waiting to - /// run", and the row leaves the dormant block — which is the one place the - /// deliberate restart is offered, and the only way home from an exited - /// row. The test above misses it because it delivers no panes. - #[test] - fn an_exited_agents_own_pane_does_not_hold_it_out_of_the_dormant_block() { - let mut m = BarModel::default(); - m.apply_tabs(vec![tab(7, 0, "agent-tab", true)]); - let mut ran = pane(0, 3, false, true); - ran.terminal_command = Some("clave spawn u1 --name x --cwd /r".into()); - // It ran, so it is not held any more, and the process is gone. - ran.exited = true; - m.apply_panes(vec![ran]); - m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, - order: OrderMode::default(), - now_hour: 0, - tab_buckets: Default::default(), - tab_touched: Default::default(), - collapsed: false, - seq: 1, - agents: vec![agent("u1", Status::Exited, Some(7))], - tab_order: Default::default(), - }); - let k = keys(&m); - assert!( - k.contains(&RowKey::Dormant("u1".into())), - "an exited agent has no way home unless it is dormant: {k:?}" - ); - // ONE row for one agent, counted across BOTH blocks. Counting only the - // dormant block is what let this ship: the row went dormant correctly - // AND its tab went on claiming it, so the bar drew the same agent - // twice — top and bottom — with only one of them restartable. Seen on - // screen 2026-09-17, after this test was green. - assert!( - m.agent_in_tab(7).is_none() && m.spawn_bound_agent(7).is_none(), - "the tab of an exited agent is an ordinary terminal, not a second \ - copy of the row" - ); - assert_eq!( - k.iter() - .filter(|r| matches!(r, RowKey::Dormant(u) if u == "u1")) - .count(), - 1, - "and it must appear once, not once per leg: {k:?}" - ); - } - /// Two live tabs and three dormant rows, the dormant ones deliberately /// out-ranking both tabs — the shape #112 exists for, in miniature. On the /// real store it is 4 live and 17 dormant. @@ -9968,8 +8327,6 @@ mod tests { agents.push(a); } m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -10073,8 +8430,6 @@ mod tests { // u-d comes up as a tab of its own; the cursor still names it. m.apply_tabs(vec![tab(1, 0, "live", false), tab(2, 1, "u-d", true)]); m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -10125,8 +8480,6 @@ mod tests { agents.push(a); } m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -10220,10 +8573,7 @@ mod tests { select_dormant(&mut m); assert_eq!( m.nav("{\"commit\":true}", Some(1)), - vec![Effect::OpenAgent { - uuid: "u-d".into(), - restore_to: None, - }] + vec![Effect::OpenAgent { uuid: "u-d".into() }] ); assert!( m.nav("{\"commit\":true}", Some(1)).is_empty(), @@ -10253,10 +8603,7 @@ mod tests { select_dormant(&mut m); assert_eq!( m.nav("{\"commit\":true}", Some(1)), - vec![Effect::OpenAgent { - uuid: "u-d".into(), - restore_to: None, - }] + vec![Effect::OpenAgent { uuid: "u-d".into() }] ); // A push lands while the open is still in flight: an unrelated agent // binds into the live tab. `u-d` is still dormant and still not stale. @@ -10337,10 +8684,7 @@ mod tests { // separate acts on every input path. assert_eq!( m.nav("{\"commit\":true}", Some(1)), - vec![Effect::OpenAgent { - uuid: "u-d".into(), - restore_to: None, - }] + vec![Effect::OpenAgent { uuid: "u-d".into() }] ); } @@ -10595,8 +8939,6 @@ mod tests { let mut a = agent("u-d", Status::Idle, None); a.commit_ord = 999; m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -10673,8 +9015,6 @@ mod tests { a.stale = true; m.opening.insert("u1".into()); m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -10689,8 +9029,6 @@ mod tests { // In-flight (no stale): ↻. let mut m = BarModel::default(); m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -10742,8 +9080,6 @@ mod tests { // "u-d" but it no longer renders dormant. m.apply_tabs(vec![tab(1, 0, "live", false), tab(2, 1, "u-d", true)]); m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -10781,8 +9117,6 @@ mod tests { // dormant block, below the two live rows. a.commit_ord = 999; m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -10855,8 +9189,6 @@ mod tests { a.commit_ord = 999; a.stale = true; m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -10971,8 +9303,6 @@ mod tests { /// behaviour these tests see is the collapse ledger's alone. fn collapse_snap(seq: u64, collapsed: bool) -> AgentSnapshot { AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -11117,7 +9447,7 @@ mod tests { .enumerate() .map(|(i, &id)| (id, timeline[i])) .collect(); - m.apply_snapshot(AgentSnapshot { last_live: Default::default(), restore_owner: None, collapsed: false, seq: 1, agents: vec![], tab_order: tl, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), tab_touched: Default::default() }); + m.apply_snapshot(AgentSnapshot { collapsed: false, seq: 1, agents: vec![], tab_order: tl, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), tab_touched: Default::default() }); m }; let baseline: Vec = @@ -11145,8 +9475,6 @@ mod tests { .enumerate() { m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -11221,7 +9549,7 @@ mod tests { .enumerate() .map(|(i, &id)| (id, tl_vals[i])) .collect(); - m.apply_snapshot(AgentSnapshot { last_live: Default::default(), restore_owner: None, collapsed: false, seq: 1, agents, tab_order: timeline.clone(), order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), tab_touched: Default::default() }); + m.apply_snapshot(AgentSnapshot { collapsed: false, seq: 1, agents, tab_order: timeline.clone(), order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), tab_touched: Default::default() }); // Determinism: identical inputs → identical rows. prop_assert_eq!(m.rows(), m.rows()); @@ -11272,8 +9600,6 @@ mod tests { let mut m = BarModel::default(); m.apply_tabs(vec![tab(0, 0, "a", true), tab(1, 1, "b", false)]); m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -11286,8 +9612,6 @@ mod tests { let rows0 = m.rows(); let timeline0 = m.tab_order.clone(); m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -11311,10 +9635,8 @@ mod tests { tl1 in prop::collection::btree_map(0usize..8, 0u64..500, 0..5), ) { let mut m = BarModel::default(); - m.apply_snapshot(AgentSnapshot { last_live: Default::default(), restore_owner: None, collapsed: false, seq: 1, agents: vec![], tab_order: tl0, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), tab_touched: Default::default() }); + m.apply_snapshot(AgentSnapshot { collapsed: false, seq: 1, agents: vec![], tab_order: tl0, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), tab_touched: Default::default() }); m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -11362,8 +9684,6 @@ mod tests { let timeline: std::collections::BTreeMap = ids.iter().enumerate().map(|(i, &id)| (id, tl_vals[i])).collect(); m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -11471,8 +9791,6 @@ mod tests { .collect(), ); m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -11528,7 +9846,7 @@ mod tests { .collect(); let mut tl = timeline.clone(); tl.remove(&victim_id); - m.apply_snapshot(AgentSnapshot { last_live: Default::default(), restore_owner: None, collapsed: false, seq: 2, agents, tab_order: tl, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), tab_touched: Default::default() }); + m.apply_snapshot(AgentSnapshot { collapsed: false, seq: 2, agents, tab_order: tl, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), tab_touched: Default::default() }); } let closed = RowKey::Dormant(format!("u{victim_id}")); @@ -11605,8 +9923,6 @@ mod tests { Some(flag) => { seq += 1; m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -11643,8 +9959,6 @@ mod tests { // carrying the OPPOSITE flag must change nothing — // not even the pending ledger. m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -11697,8 +10011,6 @@ mod tests { agents.push(a); } m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -11897,7 +10209,6 @@ mod tests { is_focused: false, is_floating: false, terminal_command: None, - is_held: false, exited: false, exit_status: None, }, @@ -11908,7 +10219,6 @@ mod tests { is_focused: false, is_floating: false, terminal_command: None, - is_held: false, exited: false, exit_status: None, }, @@ -11925,8 +10235,6 @@ mod tests { self.seq += 1; self.u1_holds = !self.u1_holds; // the eviction flip self.m.apply_snapshot(AgentSnapshot { - last_live: Default::default(), - restore_owner: None, order: OrderMode::default(), now_hour: 0, tab_buckets: Default::default(), @@ -12021,461 +10329,4 @@ mod tests { } } } - // --- the staggered restore (#261) --------------------------------------- - - /// The launch builds ONE tab and the bar brings the rest back itself, one - /// at a time. Measured 2026-09-16: building a tab costs a burst of about - /// fifty file handles opened in the same instant, so four tabs peaked at - /// 252 against macOS's default ceiling of 256 and five crashed the zellij - /// server outright. One at a time keeps the burst at one tab's worth - /// however big the fleet is. - #[test] - fn the_restore_opens_the_next_row_that_is_not_back_yet() { - let mut m = focused_bar(); - // u-a is the baked tab. u-b and u-c were live last session and hold no - // tab yet, so they are the queue, in the order the launch ranked them. - let fx = m.apply_snapshot(snap_live( - 9, - vec![ - agent("u-a", Status::Idle, Some(11)), - agent("u-b", Status::Idle, None), - agent("u-c", Status::Idle, None), - ], - &["u-a", "u-b", "u-c"], - )); - assert_eq!( - opens(&fx), - vec!["u-b".to_string()], - "the head of the queue that is not already back" - ); - assert!(m.restore_pending(), "u-c is still owed"); - } - - /// The queue waits for the TAB, not for the next store advance. - /// - /// Measured 2026-09-17, the first live relaunch on this branch: three tabs - /// went out inside one second and the zellij server died with "Too many - /// open files" at 194 handles and climbing, 150 of them PIPE. `restore_sent` - /// alone let the head of the queue advance on ANY snapshot, and a launch - /// pushes a flurry of them — so the chain outran the thing it was pacing. - /// The handle burst belongs to zellij BUILDING the tab, which is still - /// going long after the row is marked sent, so the tab appearing is the - /// only honest clock. `opening` carries that claim and `prune_opening` - /// already releases it on a bind, a stale row, or a row that went away. - #[test] - fn the_restore_sends_nothing_while_the_last_tab_is_still_being_built() { - let mut m = focused_bar(); - let rows = |b_tab: Option| { - vec![ - agent("u-a", Status::Idle, Some(11)), - agent("u-b", Status::Idle, b_tab), - agent("u-c", Status::Idle, None), - ] - }; - let fx = m.apply_snapshot(snap_live(9, rows(None), &["u-a", "u-b", "u-c"])); - assert_eq!(opens(&fx), vec!["u-b".to_string()], "premise: u-b went out"); - - // The store advances again — a hook, a status, anything — and u-b's - // tab is STILL not there. Sending u-c now stacks a second tab build on - // the first, which is the crash. - let fx = m.apply_snapshot(snap_live(10, rows(None), &["u-a", "u-b", "u-c"])); - assert!( - opens(&fx).is_empty(), - "sent a second row while u-b had no tab yet: {fx:?}" - ); - assert!(m.restore_pending(), "u-c is still owed, just not yet"); - - // u-b's tab is up. The burst is over and the queue may move. - let fx = m.apply_snapshot(snap_live(11, rows(Some(12)), &["u-a", "u-b", "u-c"])); - assert_eq!( - opens(&fx), - vec!["u-c".to_string()], - "the queue resumes once the tab it was waiting for exists" - ); - } - - /// The restore brings a tab back WITHOUT starting its agent, and gives the - /// focus straight back to the sequencer's own tab. - /// - /// Both halves are one assertion because neither is correct alone. A tab - /// made by `zellij action new-tab` always takes the focus, whatever the - /// layout asks for (`zellij-utils-0.44.3/src/input/actions.rs:1611-1625`), - /// and the bar that owns a focused tab starts its held agent at once - /// (`run_held_effect`) — so a hold with no focus return still resumes every - /// row in the fleet, ~350 MB each, which is the whole cost this design - /// exists to avoid. - #[test] - fn the_restore_opens_held_and_hands_the_focus_back() { - let mut m = focused_bar(); - let fx = m.apply_snapshot(snap_live( - 9, - vec![ - agent("u-a", Status::Idle, Some(11)), - agent("u-b", Status::Idle, None), - ], - &["u-a", "u-b"], - )); - assert_eq!( - open_homes(&fx), - vec![Some(11)], - "the restore's open returns the focus to the sequencer's own tab" - ); - } - - /// The claim survives a frame that arrives BEFORE the tab it made. - /// - /// Measured live 2026-09-17. The owner sends an open, and a tab frame - /// lands while the beacon still names the owner — the newborn has not - /// announced itself yet, so nothing looks wrong. That frame used to spend - /// the re-anchor claim. Then the tab arrived, took the beacon, and there - /// was nothing left owed to take it back: the beacon sat on the last tab - /// the restore built while the human sat on the baked one. The first - /// Alt+Up did nothing (the stranded instance handled it and re-anchored), - /// and the second worked. - /// - /// A frame saying "you still hold the beacon" is not evidence about a tab - /// that does not exist yet. - #[test] - fn the_reanchor_claim_outlives_a_frame_that_beats_the_new_tab() { - let mut m = focused_bar(); - let fx = m.apply_snapshot(snap_live( - 9, - vec![ - agent("u-a", Status::Idle, Some(11)), - agent("u-b", Status::Idle, None), - ], - &["u-a", "u-b"], - )); - assert_eq!( - opens(&fx), - vec!["u-b".to_string()], - "premise: one open sent" - ); - - // A frame arrives first. The tab is still being built, so the beacon - // still names us and this frame proves nothing about it. - m.apply_tabs(vec![ - tab(10, 0, "a", false), - tab(11, 1, "b", true), - tab(12, 2, "c", false), - ]); - - // NOW the tab arrives and takes the beacon, and the focus comes back. - m.beacon(12); - assert!(!m.own_tab_focused(), "premise: the beacon has left us"); - let fx = m.apply_tabs(vec![ - tab(10, 0, "a", false), - tab(11, 1, "b", true), - tab(12, 2, "c", false), - ]); - assert!( - fx.contains(&Effect::ReanchorVisit { tab_id: 11 }), - "the claim was spent on the frame that beat the tab: {fx:?}" - ); - assert!(m.own_tab_focused(), "and nav answers the FIRST press again"); - } - - /// The restored tab steals the beacon, and the sequencer takes it back. - /// - /// The new tab is born focused and its bar announces itself, so the beacon - /// ends up naming a tab nobody is standing in. Left there it costs both - /// halves of the feature: nav reads as dead (FOOTGUNS — a beacon on an - /// unwatched tab answers every press with a no-op). The queue survives it - /// — the owner is named by the launch, not by the beacon — so nav is the - /// whole cost, and it is the half a person notices. - #[test] - fn the_sequencer_takes_the_beacon_back_from_the_tab_it_just_made() { - let mut m = focused_bar(); - let fx = m.apply_snapshot(snap_live( - 9, - vec![ - agent("u-a", Status::Idle, Some(11)), - agent("u-b", Status::Idle, None), - ], - &["u-a", "u-b"], - )); - assert_eq!( - opens(&fx), - vec!["u-b".to_string()], - "premise: one open sent" - ); - // The tab arrives, takes the focus, and its newborn bar announces it. - m.beacon(12); - assert!(!m.own_tab_focused(), "premise: the beacon has left us"); - // `clave open` hands the focus back, so our tab frame arrives again. - let fx = m.apply_tabs(vec![ - tab(10, 0, "a", false), - tab(11, 1, "b", true), - tab(12, 2, "c", false), - ]); - assert!( - fx.contains(&Effect::ReanchorVisit { tab_id: 11 }), - "the sequencer re-anchors the beacon on itself: {fx:?}" - ); - assert!(m.own_tab_focused(), "and the queue can advance again"); - } - - /// One re-anchor per open, spent when it emits — the same bound every - /// other beacon trigger keeps. An unbounded claim is the round-11 pipe - /// storm, and a CLI pipe blocks the zellij router for about a second. - #[test] - fn the_restores_reanchor_is_spent_once() { - let mut m = focused_bar(); - m.apply_snapshot(snap_live( - 9, - vec![ - agent("u-a", Status::Idle, Some(11)), - agent("u-b", Status::Idle, None), - ], - &["u-a", "u-b"], - )); - m.beacon(12); - let first = m.apply_tabs(vec![ - tab(10, 0, "a", false), - tab(11, 1, "b", true), - tab(12, 2, "c", false), - ]); - assert!(first.contains(&Effect::ReanchorVisit { tab_id: 11 })); - // The beacon is ours again, so a later frame that finds it elsewhere - // is somebody else's business, not this open's. - m.beacon(12); - let second = m.apply_tabs(vec![ - tab(10, 0, "a", false), - tab(11, 1, "b", true), - tab(12, 2, "c", false), - ]); - assert!( - !second - .iter() - .any(|e| matches!(e, Effect::ReanchorVisit { .. })), - "the claim was spent on the frame that paid it: {second:?}" - ); - } - - /// The pick is the opposite case, and the same field says so: Alt+Enter - /// asked to GO to that conversation, so its tab runs and keeps the focus. - #[test] - fn a_picked_row_opens_running_and_keeps_the_focus() { - let mut m = live_plus_dormant(); - m.beacon(1); - select_dormant(&mut m); - assert_eq!( - open_homes(&m.nav("{\"commit\":true}", Some(1))), - vec![None], - "a pick is not a restore" - ); - } - - /// One per tick, never the whole fleet — the entire point of the change. - /// A pass that returned every owed row would restore exactly as the layout - /// used to, one call later, and crash identically. - #[test] - fn the_restore_opens_one_row_per_tick_not_the_whole_queue() { - let mut m = focused_bar(); - let fx = m.apply_snapshot(snap_live( - 9, - vec![ - agent("u-a", Status::Idle, Some(11)), - agent("u-b", Status::Idle, None), - agent("u-c", Status::Idle, None), - agent("u-d", Status::Idle, None), - ], - &["u-a", "u-b", "u-c", "u-d"], - )); - assert_eq!(opens(&fx), vec!["u-b".to_string()], "one row, not three"); - // u-b's tab arrives. The queue moves ON rather than repeating itself: - // `restore_sent` is what stops a bound row being sent twice, and it is - // still needed beside the pacing gate, which only says "not yet". - let fx = m.apply_snapshot(snap_live( - 10, - vec![ - agent("u-a", Status::Idle, Some(11)), - agent("u-b", Status::Idle, Some(12)), - agent("u-c", Status::Idle, None), - agent("u-d", Status::Idle, None), - ], - &["u-a", "u-b", "u-c", "u-d"], - )); - assert_eq!( - opens(&fx), - vec!["u-c".to_string()], - "the queue advances instead of re-sending the row it already sent" - ); - } - - /// A row the human reached first is already back, so the queue skips it. - /// This is what makes "land on it and it comes alive" need no coordination - /// with the sequencer: the store is the shared fact, and a bound row is - /// simply not owed. - #[test] - fn a_row_the_human_opened_first_is_not_opened_again() { - let mut m = focused_bar(); - let fx = m.apply_snapshot(snap_live( - 9, - vec![ - agent("u-a", Status::Idle, Some(11)), - agent("u-b", Status::Idle, Some(12)), // the human got here first - agent("u-c", Status::Idle, None), - ], - &["u-a", "u-b", "u-c"], - )); - assert_eq!( - opens(&fx), - vec!["u-c".to_string()], - "u-b holds a tab already; only u-c is owed" - ); - } - - /// A tab the human CLOSES during the restore must stay closed. Without the - /// latch the row loses its tab, rejoins the queue, and the bar reopens the - /// tab the human just shut — the worst failure available here, because it - /// overrides a deliberate act and repeats. - #[test] - fn the_restore_never_reopens_a_tab_the_human_closed() { - let mut m = focused_bar(); - let fx = m.apply_snapshot(snap_live( - 9, - vec![ - agent("u-a", Status::Idle, Some(11)), - agent("u-b", Status::Idle, None), - ], - &["u-a", "u-b"], - )); - assert_eq!(opens(&fx), vec!["u-b".to_string()], "u-b is opened once"); - // It comes back, and the human closes it again. - let _back = m.apply_snapshot(snap_live( - 10, - vec![ - agent("u-a", Status::Idle, Some(11)), - agent("u-b", Status::Idle, Some(12)), - ], - &["u-a", "u-b"], - )); - let reopened = m.apply_snapshot(snap_live( - 11, - vec![ - agent("u-a", Status::Idle, Some(11)), - agent("u-b", Status::Idle, None), - ], - &["u-a", "u-b"], - )); - assert!( - opens(&reopened).is_empty(), - "the restore owes each row exactly one open, for the life of the session" - ); - assert!(!m.restore_pending()); - } - - /// A tab the restore MADE never drives the restore, even while it holds - /// the focus — and it always holds the focus for a moment, because - /// `zellij action new-tab` gives it away and the layout cannot refuse - /// (FOOTGUNS). - /// - /// This is the case that broke live on 2026-09-17. The election used to be - /// "am I the focused tab", so every tab the queue built believed it was in - /// charge and started the queue again from the top: the beacon named four - /// different tabs eight times in 800 ms, five tabs were built in that - /// window, and the run before it killed the zellij server outright. The - /// launch names the owner now, so a newborn has nothing to claim. - #[test] - fn a_tab_the_restore_made_never_drives_the_restore() { - // Tab 12 is the tab the queue has just built, and it is focused. - let mut m = fleet_bar(12, 12); - m.beacon(12); - frame(&mut m, 12); - assert!(m.own_tab_focused(), "premise: the newborn holds the focus"); - let fx = m.apply_snapshot(snap_owned( - 9, - vec![ - agent("u-a", Status::Idle, Some(11)), // the baked tab: the owner - agent("u-b", Status::Idle, Some(12)), // this tab, just built - agent("u-c", Status::Idle, None), // still owed - ], - &["u-a", "u-b", "u-c"], - Some("u-a"), - )); - assert!( - opens(&fx).is_empty(), - "a tab the restore built started the queue over: {fx:?}" - ); - } - - /// The owner keeps driving after the human walks away. - /// - /// The old focus election stalled the queue here, and that was the other - /// half of the same mistake: the restore is the launch's business, not a - /// reaction to where somebody is standing. - #[test] - fn the_owner_drives_the_restore_from_a_tab_nobody_is_watching() { - let mut m = background_bar(); // own tab 11, the human is on tab 10 - let fx = m.apply_snapshot(snap_live( - 9, - vec![ - agent("u-a", Status::Idle, Some(11)), - agent("u-b", Status::Idle, None), - ], - &["u-a", "u-b"], - )); - assert_eq!( - opens(&fx), - vec!["u-b".to_string()], - "the queue stopped because the human looked elsewhere" - ); - } - - /// Two bars, one queue. The whole point of naming the owner is that the - /// instances need no conversation to agree, so this test runs them side by - /// side on the SAME snapshot — the shape every earlier guard on this - /// branch was never tested in, and the shape all three live defects took. - #[test] - fn two_bars_on_one_snapshot_send_one_open_between_them() { - let snap = |seq| { - snap_owned( - seq, - vec![ - agent("u-a", Status::Idle, Some(11)), - agent("u-b", Status::Idle, Some(12)), - agent("u-c", Status::Idle, None), - ], - &["u-a", "u-b", "u-c"], - Some("u-a"), - ) - }; - let mut owner = focused_bar(); // own tab 11 - let mut newborn = fleet_bar(12, 12); // own tab 12, born focused - newborn.beacon(12); - frame(&mut newborn, 12); - - let from_owner = owner.apply_snapshot(snap(9)); - let from_newborn = newborn.apply_snapshot(snap(9)); - let mut sent = opens(&from_owner); - sent.extend(opens(&from_newborn)); - assert_eq!( - sent, - vec!["u-c".to_string()], - "the two bars sent {} opens for one row", - sent.len() - ); - } - - /// A row named in the set but gone from the store — idle-pruned between - /// sessions — is not a row to open. `last_live` is a faithful record, so - /// it can outlive its rows. - #[test] - fn the_restore_skips_a_row_that_no_longer_exists() { - let mut m = focused_bar(); - let fx = m.apply_snapshot(snap_live( - 9, - vec![ - agent("u-a", Status::Idle, Some(11)), - agent("u-c", Status::Idle, None), - ], - &["u-a", "u-gone", "u-c"], - )); - assert_eq!( - opens(&fx), - vec!["u-c".to_string()], - "the vanished row is stepped over, not waited for" - ); - } } diff --git a/crates/clave-types/src/lib.rs b/crates/clave-types/src/lib.rs index 56374c7..2ca6f14 100644 --- a/crates/clave-types/src/lib.rs +++ b/crates/clave-types/src/lib.rs @@ -144,21 +144,6 @@ pub enum Status { NeedsYou, Done, Failed, - /// The agent's session ended, but its TAB is still open (#261). - /// - /// A row in this state owns a tab and runs nothing. Before the restore - /// work it could not arise: `SessionEnd` unbound the row, so the row went - /// dormant and the tab became an ordinary terminal. Keeping the bind is - /// what makes the tab come back on the next launch, and it is also what - /// makes this state reachable — so the state has to be nameable, or it - /// renders as `Idle` and the human cannot tell a dead tab from a live - /// agent waiting on them. - /// - /// An older binary reading this store falls back to `Idle` by the lenient - /// default above, which is exactly the behaviour it had before. Any later - /// hook event overwrites it, so a restarted agent leaves the state on its - /// own. - Exited, } impl Status { @@ -172,11 +157,6 @@ impl Status { Status::Done => ('●', 32), // green: finished & unread Status::Idle => ('●', 90), // dim: read / no session Status::Failed => ('✖', 31), // red cross: turn failed - // Hollow, not filled: the fleet's existing shorthand for "a row - // with no process behind it". Dim like `Idle`, because an exited - // agent is not asking for anything — but hollow, because the - // difference the human must see is that nothing is running. - Status::Exited => ('○', 90), } } } @@ -285,13 +265,10 @@ pub struct LiveRow { /// rewritten, or started. /// /// ONE function across the workspace. The host asks it of a hook command and -/// of the baked layout; the bar asks it of a held pane's launch command before -/// it will start that pane. Those callers parse different shapes — quoted KDL -/// tokens against a space-joined command line — and the matcher was all they -/// had in common, so it was copied, and the copy in the bar was covered by no -/// test at all. A release install is the ONLY environment that bakes the -/// versioned form (a sandbox shims a bare `clave`), so the copy that mattered -/// was the one nothing exercised. +/// of the baked layout, and a matcher copied per caller drifted once (#261): +/// the copy that mattered was covered by no test. A release install is the +/// ONLY environment that bakes the versioned form (a sandbox shims a bare +/// `clave`), so the shared rule is tested here, once. pub fn is_clave_binary(bin: &str) -> bool { matches!( std::path::Path::new(bin).file_name().and_then(|n| n.to_str()), @@ -305,11 +282,8 @@ pub fn is_clave_binary(bin: &str) -> bool { /// One row's ranking key, for every surface that ranks clave rows. /// /// ONE function, for the reason [`sort_live_block`] beneath it is one: the -/// cluster layer found a single home after PR #261 and the key it clusters did -/// not follow, so the same rule still lived in the bar (live rows and dormant -/// rows) and in the host (the set a relaunch bakes). Retune the policy on one -/// side and a relaunch again starts a row that is not the bar's top — the #261 -/// defect, one level down. +/// bar ranks live rows and dormant rows with it, and any host-side ranking +/// must use the same rule or the two surfaces disagree (the #261 defect). /// /// `millis` is the caller's own decayed score, because that part genuinely /// differs: the bar can max-merge a tab-scoped bucket the host cannot see. @@ -329,12 +303,11 @@ pub fn live_key(order: OrderMode, millis: u64, ordinal: u64) -> (u64, u64) { /// cluster by group, clusters rank by (Σ member score, best member key), and /// members keep the row rule within their cluster. /// -/// ONE function, for the same reason [`frecency_millis`] is one. The bar sorts -/// the rows it renders; the host sorts the rows a relaunch bakes, and the -/// FIRST of those is the only agent a relaunch starts. Two copies of the rule -/// disagreed as soon as a repo held several rows — a cluster can outrank a -/// higher-scoring lone row — so a relaunch focused and started an agent that -/// was not the one at the top of the bar (CodeRabbit, PR #261). +/// ONE function, for the same reason [`frecency_millis`] is one. The bar is +/// its only caller today; it lives in the shared crate so a host-side ranking +/// can never grow a second copy. Two copies of the rule disagreed as soon as +/// a repo held several rows — a cluster can outrank a higher-scoring lone row +/// (CodeRabbit, PR #261). /// /// A group-of-one's cluster key is its own key restated, so ungrouped rows /// sort exactly as the flat rule would, and `Recency` mode is unaffected. The @@ -550,35 +523,6 @@ pub struct AgentSnapshot { /// can never leak into the ordinal space and outrank every real ordinal. #[serde(default)] pub tab_order: std::collections::BTreeMap, - /// The set the PREVIOUS session was holding, in ASCENDING TAB ID — the - /// order those tabs were created, which is not a rank (`store.rs`, - /// `clear_session_order`). The launch ranks a copy of it to choose the one - /// row it bakes, and never writes that rank back, so the bar opens the - /// rest in the previous session's tab order, one at a time. - /// - /// It rides the snapshot rather than being passed at load, because a bar - /// born in a tab the RESTORE created must reach the same conclusion as the - /// bar in the baked tab, and the store is the only thing both can read. - /// Rows that hold a tab are already back, so the queue is what remains. - /// `default` keeps pre-field payloads parseable (§5). - #[serde(default)] - pub last_live: Vec, - /// Which row's tab drives the staggered restore (#261). - /// - /// Named by the launch, because the launch is the only party that knows: - /// it picks one row to bake as a tab and hands the rest to the bar. Every - /// instance reads the same name, so exactly one of them sequences the - /// queue however the focus moves. - /// - /// Inferring this from the focus instead cost three live runs. A tab made - /// by `zellij action new-tab` always takes the focus (FOOTGUNS), so each - /// restored tab's bar briefly saw itself as the focused one and started - /// the queue again from the top: five tabs in 800 ms, and once a dead - /// zellij server. The sequencer cannot be elected by a signal the - /// sequencer's own work destroys. `default` (None) means "nothing to - /// sequence" and keeps pre-field payloads parseable (§5). - #[serde(default)] - pub restore_owner: Option, /// Bar collapse mode (issue #5, C8 parity-desync family): per-instance /// memory synced only by the `clave-toggle` broadcast desynced live — a /// tab born after a toggle, a plugin reload, or one missed pipe flips an @@ -742,7 +686,49 @@ pub enum RowHeight { /// mechanism as [`CLAVE_BINARY_KEY`], #44). pub const ROW_HEIGHT_KEY: &str = "row_height"; +/// The one column zellij takes from a frameless pane for the separator +/// line to its right neighbour. See [`RowHeight::mode_at`]. +pub const SEPARATOR_COLS: usize = 1; + +// `mode_at` tolerates `SEPARATOR_COLS` below each declared width, so the +// two widths of one mode must sit further apart than that or the collapsed +// width would read as "expanded, one short" and never land. Pinned here so +// a retune of the pairs cannot make a mode unreachable. +const _: () = { + let mut i = 0; + let modes = [RowHeight::Card, RowHeight::Double, RowHeight::Single]; + while i < modes.len() { + assert!( + modes[i].target_cols(true) + SEPARATOR_COLS < modes[i].target_cols(false), + "a mode's collapsed width must sit more than SEPARATOR_COLS below its expanded one" + ); + i += 1; + } +}; + impl RowHeight { + /// The mode a PAINTED width is in, or `None` when it is at neither + /// declared width. A pane paints one column short of its declared size + /// when `pane_frames` is off: zellij reserves that column on every + /// tiled pane that is not at the viewport's right edge, borderless or + /// not, for the line to its neighbour (`zellij-server/src/panes/ + /// tiled_panes/mod.rs`, `pane_content_offset`, unchanged from 0.44.3 + /// to 0.45.1). The bar is the left pane, so 48 paints as 47 and 16 as + /// 15. Measured on the devbox 2026-09-22: an exact comparison asked for + /// a swap on every paint and walked the tab through the other width. + pub const fn mode_at(self, cols: usize) -> Option { + let mut i = 0; + while i < 2 { + let collapsed = i == 1; + let target = self.target_cols(collapsed); + if cols == target || cols + SEPARATOR_COLS == target { + return Some(collapsed); + } + i += 1; + } + None + } + /// The width the seek machinery asks for in this mode — the card /// budgets ratified in #232, or the legacy pair for `Single`. `const` so /// the bar's test-mod width-target pins (`EXP_W`/`COL_W`) can compute @@ -1018,12 +1004,11 @@ mod tests { /// Every status, as the two tests below must see it: its wire spelling and /// its glyph. /// - /// A MATCH, not a list. Both tests said "every variant" and listed five of - /// six for the whole life of `Exited` (#261) — a list cannot notice what is - /// missing from it, and the one that went missing is the one that rides a - /// persisted store across an upgrade. Adding a status without adding it - /// here is now a compile error. `ALL` still has to grow by hand, so the - /// count assertion below is what catches that half. + /// A MATCH, not a list. Both tests said "every variant" and once listed + /// one variant short for the life of a status (#261) — a list cannot + /// notice what is missing from it. Adding a status without adding it here + /// is a compile error. `ALL` still has to grow by hand, so the count + /// assertion below is what catches that half. fn wire_and_glyph(v: Status) -> (&'static str, (char, u8)) { match v { Status::Idle => ("\"idle\"", ('●', 90)), // dim: read / no session @@ -1031,18 +1016,15 @@ mod tests { Status::NeedsYou => ("\"needs_you\"", ('●', 31)), // red: waiting on you Status::Done => ("\"done\"", ('●', 32)), // green: finished & unread Status::Failed => ("\"failed\"", ('✖', 31)), // red cross - // Hollow and dim: nothing is running, and it asks for nothing. - Status::Exited => ("\"exited\"", ('○', 90)), } } - const ALL: [Status; 6] = [ + const ALL: [Status; 5] = [ Status::Idle, Status::Working, Status::NeedsYou, Status::Done, Status::Failed, - Status::Exited, ]; #[test] @@ -1172,8 +1154,6 @@ mod tests { #[test] fn snapshot_roundtrips() { let snap = AgentSnapshot { - last_live: Default::default(), - restore_owner: None, seq: 7, tab_order: Default::default(), collapsed: false, @@ -1407,8 +1387,6 @@ mod tests { // full-state replace, the one channel that never diverged (C5 rd 5: // fire-and-forget pipe deltas diverged per instance). let snap = AgentSnapshot { - last_live: Default::default(), - restore_owner: None, seq: 1, agents: vec![], tab_order: std::collections::BTreeMap::from([(4usize, 12u64)]), @@ -1488,8 +1466,6 @@ mod tests { // payload without the field must parse as expanded (false) — the // born-expanded default — for old-CLI/new-plugin interop. let snap = AgentSnapshot { - last_live: Default::default(), - restore_owner: None, seq: 2, agents: vec![], tab_order: Default::default(), @@ -1573,6 +1549,36 @@ mod tests { assert_eq!(RowHeight::Single.lines_per_row(), 1); } + /// The tolerance is ONE-SIDED and exactly the separator column: a + /// frameless pane paints one short, never one wide, and never two short. + /// A symmetric or wider allowance would pass every bar test that only + /// probes below the target, so the contract is pinned here. + #[test] + fn mode_at_allows_the_separator_column_below_each_target_only() { + for mode in [RowHeight::Card, RowHeight::Double, RowHeight::Single] { + for collapsed in [false, true] { + let target = mode.target_cols(collapsed); + assert_eq!(mode.mode_at(target), Some(collapsed), "{mode:?} exact"); + assert_eq!( + mode.mode_at(target - SEPARATOR_COLS), + Some(collapsed), + "{mode:?} one short" + ); + assert_eq!( + mode.mode_at(target - SEPARATOR_COLS - 1), + None, + "{mode:?} two short is neither width" + ); + assert_eq!( + mode.mode_at(target + 1), + None, + "{mode:?} one wide is neither width" + ); + } + } + assert_eq!(RowHeight::Card.mode_at(0), None); + } + #[test] fn row_height_parses_its_config_value_failing_closed_to_card() { assert_eq!( diff --git a/crates/clave/src/add.rs b/crates/clave/src/add.rs index 45d4d26..d773013 100644 --- a/crates/clave/src/add.rs +++ b/crates/clave/src/add.rs @@ -12,8 +12,10 @@ use anyhow::{Context, Result}; use crate::hook::push_snapshot; use crate::setup::{data_dir, session_row_height, wasm_path}; use crate::store::{ - AgentRecord, LabelSource, Store, now_unix, snapshot_from, store_paths, with_store_mut, + AgentRecord, LabelSource, Store, StorePaths, now_unix, snapshot_from, store_paths, + with_store_mut, }; +use clave_types::AgentSnapshot; /// Parse `zellij action dump-layout` for live agent uuids (§6.3 liveness). /// Zellij serializes the LIVE pane process, not the baked layout command @@ -110,11 +112,7 @@ pub fn bound_live_uuids(store: &Store) -> Vec { store .agents .values() - // An EXITED agent keeps its tab (#261), and that bind is what restores - // the tab on the next launch — it is not a running session. Offering - // it as live would make the pick a jump onto an empty tab, with no way - // back to the conversation from here. - .filter(|r| r.tab_id.is_some() && r.status != clave_types::Status::Exited) + .filter(|r| r.tab_id.is_some()) .map(|r| r.uuid.clone()) .collect() } @@ -186,28 +184,6 @@ pub fn sanitize_label(s: &str) -> String { .join(" ") } -/// How a one-shot tab's baked `clave spawn` starts. -/// -/// The pair `(focus, runs)` is deliberately NOT expressible: every tab made by -/// `zellij action new-tab` takes the focus whatever the layout says -/// (`zellij-utils-0.44.3/src/input/actions.rs:1611-1625` — the tab node's -/// `focus` property becomes the layout's `focused_tab_index`, and the -/// new-tab path reads the ROOT PANE's `focus`, which a tab node never sets, so -/// the first tab always gets `should_change_focus_to_new_tab = true`). Focus is -/// therefore not an input here; only the command is. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TabStart { - /// The spawn runs at once — the Alt+Enter pick, where the human asked for - /// this conversation and is about to land on it. - Running, - /// The spawn is created HELD (`start_suspended`, which zellij parses as - /// `hold_on_start`): the tab, its name and its baked command all exist, and - /// no `claude` process does. The staggered restore (#261) uses this so a - /// fleet costs a layout instead of ~350 MB per row; the bar starts the - /// command when the human lands on the tab (`run_held_effect`). - Held, -} - /// Everything a one-shot tab is made of, named rather than positional. /// /// `collapsed` is the mode the tab must be born in (LEDGER D36, task 7b′): a @@ -227,7 +203,6 @@ pub struct TabSpec<'a> { pub cwd: &'a str, pub collapsed: bool, pub row_height: clave_types::RowHeight, - pub start: TabStart, } /// The agent-tab KDL node WITH its own bar pane — for one-shot @@ -246,7 +221,6 @@ pub fn tab_node(spec: &TabSpec) -> String { cwd, collapsed, row_height, - start, } = *spec; // split_direction="vertical" is REQUIRED for a LEFT bar: zellij stacks // sibling panes horizontally (rows) by default (Task 9 C1 finding; same @@ -256,19 +230,15 @@ pub fn tab_node(spec: &TabSpec) -> String { // `command` bakes the environment's clave (§2 binary split): the // versioned copy's absolute path in a stable session, bare `clave` in // dev/sandbox — so the resurrected pane re-execs the SAME binary. - // Inside the pane node, beside `args` — a sibling property of the run - // command, not of the tab. Same syntax `tab_node_bare` bakes for the - // launch layout's unfocused tabs. - let hold = match start { - TabStart::Running => "", - TabStart::Held => " start_suspended true\n", - }; + // Every tab made by `zellij action new-tab` takes the focus whatever the + // layout says (`zellij-utils-0.44.3/src/input/actions.rs:1611-1625`), so + // focus is not an input here; only the command is. format!( r#" tab name="{label}" focus=true {{ pane split_direction="vertical" {{ {pane} pane cwd="{cwd}" command="{binary}" {{ args "spawn" "{uuid}" "--name" "{label}" "--cwd" "{cwd}" -{hold} }} + }} }} }} "#, @@ -288,12 +258,9 @@ pub fn tab_node(spec: &TabSpec) -> String { /// slot. Same baked idempotent spawn — only the bar pane differs. /// /// The node is always focused and always runs, because a launch bakes at most -/// ONE tab (#261). It took a `held` variant when the launch baked the whole -/// restored set, and that design is gone: a layout of held tabs made zellij -/// build them all inside one second, and the handle burst killed the server -/// with "Too many open files" (measured 2026-09-17). The fleet now comes back -/// one tab at a time, paced by the bar, through `add::tab_layout` with -/// `TabStart::Held` — which is where `start_suspended` lives now. +/// ONE tab (#261): a layout of many tabs made zellij build them all inside +/// one second, and the handle burst killed the server with "Too many open +/// files" (measured 2026-09-17). /// /// Exactly one node in a layout may be focused; with none, or with several, /// zellij picks, and the row the human lands on stops being ours to choose. @@ -1532,30 +1499,19 @@ pub fn run_add(worktree: bool) -> Result<()> { cwd: &agent_cwd, collapsed, row_height, - start: TabStart::Running, }); let tmp = std::env::temp_dir().join(format!("clave-{uuid}.kdl")); std::fs::write(&tmp, layout)?; - let status = Command::new(&zellij) // discovered above (Fix 2) - .args([ - "--session", - &session, - "action", - "new-tab", - "--layout", - tmp.to_str().context("tmp path")?, - ]) - .status()?; - let _ = std::fs::remove_file(&tmp); - anyhow::ensure!(status.success(), "zellij action new-tab failed"); - - // 7) Record + push (§6.3): the row exists BEFORE the first hook event so - // the hook's untracked fast path doesn't drop this agent's events. + + // 7) Record + open + push (§6.3): the row exists BEFORE the first hook + // event so the hook's untracked fast path doesn't drop this agent's + // events, and before the tab so the spawn's registration finds it + // (see `record_then_open`). // UPDATE-OR-INSERT, not blind insert (plan-review fix): resuming an // agent with an existing row must preserve it — merge_resume_record // keeps everything and resets only status. The authoritative - // existing-row lookup happens HERE, inside the lock (the step-4 copy - // was lock-free and only derived layout inputs). + // existing-row lookup happens inside the lock (the step-4 copy was + // lock-free and only derived layout inputs). // A resumed conversation's own history, read OUTSIDE the store lock (a // transcript can be tens of MB; the lock protects the store, not this // read). Probes the transcript's possible homes (#139: not always the @@ -1572,32 +1528,70 @@ pub fn run_add(worktree: bool) -> Result<()> { crate::store::unix_hour(now_unix()), ) }); - let snap = with_store_mut(&paths, |s| { - // S1: a new row is a user commitment, so it is minted an ordinal from - // this same locked write and enters at the top. Before S1 a new row - // inherited no order at all and could sink below every dormant row. - // See `mint_record` for the ordinal mint, the newborn's inherited - // buckets, and the resume-preserving merge. - mint_record( - s, - FreshRecordInputs { - uuid: &uuid, - cwd: &agent_cwd, - repo_root: resume_root.as_deref().unwrap_or(&repo_root), - branch: &agent_branch, - label: &label, - worktree: worktree_dir.clone(), - default_branch: default_branch.clone(), - own_buckets: own_buckets.clone(), - }, - ); - snapshot_from(s) - })?; + let snap = record_then_open( + &paths, + FreshRecordInputs { + uuid: &uuid, + cwd: &agent_cwd, + repo_root: resume_root.as_deref().unwrap_or(&repo_root), + branch: &agent_branch, + label: &label, + worktree: worktree_dir.clone(), + default_branch: default_branch.clone(), + own_buckets, + }, + || { + let status = Command::new(&zellij) // discovered above (Fix 2) + .args([ + "--session", + &session, + "action", + "new-tab", + "--layout", + tmp.to_str().context("tmp path")?, + ]) + .status()?; + let _ = std::fs::remove_file(&tmp); + anyhow::ensure!(status.success(), "zellij action new-tab failed"); + Ok(()) + }, + )?; push_snapshot(&snap); crate::evlog::log_event("add", &format!("{uuid}: recorded ({choice})")); Ok(()) } +/// Mint (or merge) the row under the store lock, THEN ask zellij for its tab. +/// +/// The record is `mint_record` (S1: a new row is a user commitment, so it is +/// minted an ordinal from this same locked write and enters at the top; the +/// newborn's inherited buckets and the resume-preserving merge live there +/// too). The open is the caller's `zellij action new-tab`, kept out of this +/// function so the sequence host-tests without zellij. +/// +/// THE ORDER IS THE POINT. The newborn pane runs `clave spawn`, which +/// persists its pane id into the store one line before it execs +/// (`spawn.rs` `register_pane`), and `apply_register` answers None for a uuid +/// it cannot find — silently, by design (a spawn can race a pruned row). Until +/// 2026-09-22 the tab was created first and the row recorded after, with a +/// transcript probe of tens of MB between them; on the box (runs 28 and 29) +/// the spawn won that race every time, `pane_id` stayed null for the life of +/// the row, and the tab never bound. A row that exists before its tab cannot +/// lose it. If the open fails the row stays, reopenable from the picker, and +/// the error is the caller's. +pub(crate) fn record_then_open( + paths: &StorePaths, + inputs: FreshRecordInputs<'_>, + open: impl FnOnce() -> Result<()>, +) -> Result { + let snap = with_store_mut(paths, |s| { + mint_record(s, inputs); + snapshot_from(s) + })?; + open()?; + Ok(snap) +} + #[cfg(test)] mod tests { use super::*; @@ -1712,6 +1706,47 @@ mod tests { ) } + /// `clave spawn` in the newborn pane persists its pane id one line before + /// it execs (`register_pane`), and `apply_register` answers None for a + /// uuid it cannot find. So the row must be in the store BEFORE the tab is + /// asked for. Box runs 28 and 29 (2026-09-22): tab first, row second, + /// `pane_id` null for the life of the row, and the drive's rung 1 never + /// bound. The transcript probe between the two widened the window; the + /// order is what closes it. + #[test] + fn the_row_is_in_the_store_before_the_tab_is_asked_for() { + let dir = tempfile::tempdir().unwrap(); + let paths = StorePaths { + dir: dir.path().to_path_buf(), + data: dir.path().join("agents.json"), + lock: dir.path().join("agents.lock"), + }; + let seen = std::cell::Cell::new(false); + record_then_open( + &paths, + FreshRecordInputs { + uuid: "u-new", + cwd: "/x", + repo_root: "/x", + branch: "main", + label: "x · main", + worktree: None, + default_branch: None, + own_buckets: None, + }, + || { + let store = crate::store::read_store(&paths).unwrap(); + seen.set(store.agents.contains_key("u-new")); + Ok(()) + }, + ) + .unwrap(); + assert!( + seen.get(), + "the tab was asked for before the row existed: a spawn that registers now finds no row" + ); + } + /// Spec: newborn initialisation. A fresh row inherits the opener's /// buckets — exact copy, so the tie + position tiebreak lands it /// directly below its opener in frecency mode. @@ -1782,28 +1817,6 @@ mod tests { assert_eq!(bound_live_uuids(&s), vec!["u-live".to_string()]); } - /// A row whose agent EXITED keeps its tab (#261). The picker must still - /// offer it as a RESUME: treating the leftover bind as liveness makes the - /// pick a jump onto a tab with nothing running in it, and there is then no - /// way to bring the conversation back from the picker at all. - #[test] - fn an_exited_agents_leftover_bind_is_not_liveness_for_the_picker() { - let mut s = Store::default(); - let mut gone = rec("u-gone"); - gone.tab_id = Some(7); // the tab is still on screen… - gone.pane_id = None; // …and its claude is gone - gone.status = clave_types::Status::Exited; - s.agents.insert("u-gone".into(), gone); - let mut alive = rec("u-live"); - alive.tab_id = Some(8); - s.agents.insert("u-live".into(), alive); - assert_eq!( - bound_live_uuids(&s), - vec!["u-live".to_string()], - "only the row with something running in its tab is live" - ); - } - #[test] fn live_uuids_finds_baked_spawn_commands() { // Shape of `zellij action dump-layout` output for an agent pane — @@ -1941,7 +1954,7 @@ mod tests { #[test] fn tab_layout_bakes_the_idempotent_spawn() { - let kdl = tab_layout(&spec("clave", "/data/clave-bar.wasm", TabStart::Running)); + let kdl = tab_layout(&spec("clave", "/data/clave-bar.wasm")); // The bar pane, the baked spawn (idempotent resurrection, §6.3/S4), // and the cwd all present: assert!(kdl.contains("location=\"file:/data/clave-bar.wasm\"")); @@ -1953,18 +1966,14 @@ mod tests { // §2 binary split: the pane command is the passed binary. A stable // session bakes the versioned copy's absolute path instead of bare. assert!(kdl.contains("command=\"clave\"")); - let abs = tab_layout(&spec( - "/data/clave/bin/clave-v0.1.0", - "/w", - TabStart::Running, - )); + let abs = tab_layout(&spec("/data/clave/bin/clave-v0.1.0", "/w")); assert!(abs.contains("command=\"/data/clave/bin/clave-v0.1.0\"")); assert!(!abs.contains("command=\"clave\"")); } - /// One tab, two binaries, two start modes — everything else fixed, so a - /// test names only what it is actually about. - fn spec<'a>(binary: &'a str, wasm: &'a str, start: TabStart) -> TabSpec<'a> { + /// One tab, two binaries — everything else fixed, so a test names only + /// what it is actually about. + fn spec<'a>(binary: &'a str, wasm: &'a str) -> TabSpec<'a> { TabSpec { binary, wasm, @@ -1973,41 +1982,9 @@ mod tests { cwd: "/x", collapsed: false, row_height: clave_types::RowHeight::Double, - start, } } - /// #261: the staggered restore brings a tab back WITHOUT starting its - /// agent. The layout must be identical to the running one but for the - /// hold, because everything else about the tab — its bar, its swap - /// geometries, its baked spawn — is what makes it a real clave tab the - /// moment the human lands on it. - #[test] - fn a_held_tab_layout_bakes_the_same_spawn_suspended() { - let running = tab_layout(&spec("clave", "/data/clave-bar.wasm", TabStart::Running)); - let held = tab_layout(&spec("clave", "/data/clave-bar.wasm", TabStart::Held)); - assert!( - !running.contains("start_suspended"), - "the pick path must still run its agent\n{running}" - ); - // `hold_on_start` is what zellij calls this property internally; the - // KDL spelling is the one the launch layout already bakes. - assert_eq!( - held.matches("start_suspended true").count(), - 1, - "exactly the agent pane is held — not the bar\n{held}" - ); - assert!( - held.contains("\"spawn\" \"u-1\""), - "the held tab keeps the idempotent spawn\n{held}" - ); - assert_eq!( - held.replace(" start_suspended true\n", ""), - running, - "held differs from running by the hold and nothing else" - ); - } - #[test] fn sanitize_label_strips_kdl_breakers() { assert_eq!(sanitize_label("fix \"auth\"\nflow"), "fix auth flow"); @@ -2745,7 +2722,6 @@ garbage that should be ignored cwd: &picked.cwd, collapsed: false, row_height: clave_types::RowHeight::Double, - start: TabStart::Running, }); assert!(kdl.contains("cwd=\"/repo/.claude/worktrees/wt\"")); assert!(!kdl.contains("cwd=\"/repo\"")); // NOT the picker/root dir diff --git a/crates/clave/src/dev.rs b/crates/clave/src/dev.rs index 7e3d9d6..65f5f1b 100644 --- a/crates/clave/src/dev.rs +++ b/crates/clave/src/dev.rs @@ -81,14 +81,6 @@ pub struct ScenarioAgent { pub wants: Option<&'static str>, /// Agents still running under this one. A flag, not a count. pub subagents: bool, - /// The tab this row held when the PREVIOUS zellij session died — the one - /// piece of state a killed session leaves behind, and the only input the - /// live-set restore needs. Seeding it is what lets a relaunch scenario be - /// driven in ONE launch instead of two: `clear_session_order` records - /// these rows as `last_live` at session create, exactly as it would after - /// a real kill, and the layout is composed from that. `None` is a dormant - /// row, which is every agent in every other scenario. - pub bound_tab: Option, } impl ScenarioAgent { @@ -115,7 +107,6 @@ impl ScenarioAgent { pr_number: None, wants: None, subagents: false, - bound_tab: None, }; } @@ -561,83 +552,6 @@ pub const SCENARIOS: &[Scenario] = &[ // human's launch line names (see docs/dev/QA-DRIVE.md), not seeded // here — seeding a fake "live" row would just be a second dormant row // wearing a different status. - // The relaunch fixture: four rows that were LIVE when the previous - // session died, plus two that were not. It stages the one state a kill - // leaves behind — rows still holding their dead session's tab binds — so - // a single launch exercises the whole path: `clear_session_order` records - // the live set, `restore_rows` ranks it, the layout bakes a tab per row, - // and only the first one runs. - // - // The tab numbers are deliberately NOT 0,1,2,3: the set must survive both - // the store's uuid-keyed iteration and the gaps a real session accumulates - // as tabs close, and a contiguous run would pass by luck. - // - // RECENCY DISAGREES WITH TAB ORDER, also deliberately. Tab order is - // a, b, c, d; `clear_session_order` backfills ordinals from - // `last_interacted`, so the RANK is d, b, c, a. Staging them in agreement - // would let a relaunch that simply baked the tab strip pass. So the - // expectations are: rows read `restored-d, restored-b, restored-c, - // restored-a`, and the ONE row that starts is `restored-d` — which is - // also the rotated row, so the launch resumes the rotated conversation - // rather than the minted one. - // - // Four, not eleven: a resume costs ~350 MB resident whatever the - // transcript weighs, and the point of the drive is the one-runs-rest-held - // assertion, which four proves as well as eleven and leaves the machine - // room to be worked on while it runs. - Scenario { - name: "relaunch-restore", - agents: &[ - ScenarioAgent { - slug: "restored-a", - ago_secs: 900, - bound_tab: Some(0), - ..ScenarioAgent::DEFAULT - }, - ScenarioAgent { - slug: "restored-b", - ago_secs: 300, - bound_tab: Some(3), - ..ScenarioAgent::DEFAULT - }, - // A worktree row: its OWN cwd is baked, not the repo root, and a - // restored set must keep that right for every row rather than - // only for the one eager row that used to be baked. - ScenarioAgent { - slug: "restored-c", - ago_secs: 600, - worktree: true, - repo: Some("relaunch-shared"), - bound_tab: Some(4), - ..ScenarioAgent::DEFAULT - }, - // Rotated: the restored tab must resume the ROTATED conversation, - // not the frozen minted one (#99). The held pane bakes - // `clave spawn `, and the resume choice happens inside - // `spawn.rs::resume_target` when the human lands on it — which is - // now a LATER moment than it used to be, so it is worth watching. - ScenarioAgent { - slug: "restored-d", - ago_secs: 120, - rotated: true, - bound_tab: Some(9), - ..ScenarioAgent::DEFAULT - }, - // Dormant: in the store, never in the layout. If either of these - // grows a tab, `last_live` is being derived from something other - // than the binds. - ScenarioAgent { - slug: "dormant-e", - ago_secs: 60, // MORE recent than every restored row - ..ScenarioAgent::DEFAULT - }, - ScenarioAgent { - slug: "dormant-f", - ago_secs: 5_400, - ..ScenarioAgent::DEFAULT - }, - ], - }, Scenario { name: "qa-fleet", agents: &[ @@ -1193,22 +1107,7 @@ pub fn run_scenario(name: &str) -> Result<()> { seed_transcript(&cwd, &cwd_str, &scenario_rotated_uuid(n), a.slug)?; } crate::store::with_store_mut(&paths, |s| { - let mut record = - agent_record(name, a, n, &uuid, &cwd_str, &repo.to_string_lossy(), now); - // A bind left over from a session that was killed rather than - // closed down tab by tab. The next launch's `clear_session_order` - // reads exactly these and records them as the live set to restore. - record.tab_id = a.bound_tab; - // A seeded bind is a claim that a session came up and bound rows, - // so the flag that says so has to be seeded with it (#261). Without - // it `clear_session_order` reads the stage as a launch that died - // before binding anything, refuses to record the live set, and the - // relaunch scenario silently stages NO restore at all — one row - // baked, nothing deferred, which is what it looked like on - // 2026-09-17 and cost a maintainer launch to find. - if a.bound_tab.is_some() { - s.bound_since_launch = true; - } + let record = agent_record(name, a, n, &uuid, &cwd_str, &repo.to_string_lossy(), now); s.agents.insert(uuid.clone(), record); s.seq += 1; })?; @@ -1216,26 +1115,6 @@ pub fn run_scenario(name: &str) -> Result<()> { std::fs::remove_dir_all(&cwd)?; // the §6.3 staleness fixture } } - // Say what the next launch will bring back, and REFUSE to hand over a - // relaunch fixture that would bring back nothing (#261). A scenario with - // seeded binds exists only to stage a restore; if the store does not agree - // that a session bound them, the launch quietly bakes one row, defers - // none, and the whole point of the fixture is gone. That happened on - // 2026-09-17 and cost a maintainer launch to notice — from the outside it - // looks exactly like a working session. - let staged = sc.agents.iter().filter(|a| a.bound_tab.is_some()).count(); - if staged > 0 { - let store = crate::store::read_store(&crate::store::store_paths()?)?; - let will_restore = store.agents.values().filter(|r| r.tab_id.is_some()).count(); - anyhow::ensure!( - store.bound_since_launch && will_restore == staged, - "scenario `{name}` seeded {staged} bound rows but the store would restore {will_restore} (bound_since_launch={}). The next launch would stage no restore at all.", - store.bound_since_launch - ); - println!( - "\n the next launch restores {will_restore} rows: one baked, the rest deferred to the bar" - ); - } crate::evlog::log_event("dev", &format!("scenario {name} seeded")); println!( "\nScenario `{name}` ready. Launch (the MAINTAINER's step, in a NON-zellij terminal):\n" @@ -1855,121 +1734,10 @@ mod tests { "c8-stale", "ux-gate1", "tall", - // The relaunch fixture: rows still holding a dead session's - // tab binds, which is the only state the live-set restore - // reads. - "relaunch-restore", "qa-fleet", "showcase" ] ); - // relaunch-restore: the drive's whole premise is the SHAPE of this - // table, and nothing else reads it, so assert the shape here. - let rr = SCENARIOS - .iter() - .find(|s| s.name == "relaunch-restore") - .expect("the relaunch fixture"); - let bound: Vec<(usize, &str)> = rr - .agents - .iter() - .filter_map(|a| a.bound_tab.map(|t| (t, a.slug))) - .collect(); - // Asserted as PROPERTIES, not as a copy of the table: restating the - // literal makes the test fail whenever the fixture is retuned, which - // trains the next reader to re-paste it rather than ask what broke. - assert_eq!(bound.len(), 4, "four rows come back as tabs"); - assert!( - bound.windows(2).all(|w| w[0].0 < w[1].0), - "staged in ascending tab id, which is what a dead session leaves" - ); - // Gaps on purpose: screen order is the ascending tab id, and a - // contiguous 0,1,2,3 would pass on table order alone. - assert!( - bound.windows(2).any(|w| w[1].0 > w[0].0 + 1), - "the bound tabs must not be contiguous" - ); - // And the RANK must disagree with the tab order, or a relaunch that - // simply baked the tab strip would pass this fixture. Ordinals are - // backfilled from `last_interacted` at launch, so rank is recency. - let mut by_rank: Vec<&str> = bound.iter().map(|&(_, slug)| slug).collect(); - let age = |slug: &str| rr.agents.iter().find(|a| a.slug == slug).unwrap().ago_secs; - by_rank.sort_by_key(|slug| age(slug)); - assert_eq!( - by_rank, - vec!["restored-d", "restored-b", "restored-c", "restored-a"], - "the order the fleet comes back in" - ); - assert_ne!( - by_rank, - bound.iter().map(|&(_, slug)| slug).collect::>(), - "rank must not agree with tab order" - ); - // The top of that rank is the one row a relaunch actually starts, and - // it is the ROTATED row: the launch resumes the rotated conversation, - // not the minted one (#99), which used to happen only on navigation. - assert!( - rr.agents - .iter() - .find(|a| a.slug == by_rank[0]) - .is_some_and(|a| a.rotated), - "the row that starts is the rotated one" - ); - let dormant: Vec<&str> = rr - .agents - .iter() - .filter(|a| a.bound_tab.is_none()) - .map(|a| a.slug) - .collect(); - assert_eq!(dormant, vec!["dormant-e", "dormant-f"]); - // dormant-e is MORE recent than every bound row. A restore that reads - // recency instead of the binds gives it a tab, and the drive sees it. - let newest_bound = rr - .agents - .iter() - .filter(|a| a.bound_tab.is_some()) - .map(|a| a.ago_secs) - .min() - .expect("bound rows"); - let e = rr.agents.iter().find(|a| a.slug == "dormant-e").unwrap(); - assert!( - e.ago_secs < newest_bound, - "dormant-e must be the newest row" - ); - // One worktree row and one rotated row, both bound: a restored set - // must bake the worktree's own cwd and resume the rotated - // conversation, for every row rather than only the first. - assert!( - rr.agents - .iter() - .any(|a| a.worktree && a.bound_tab.is_some()) - ); - assert!(rr.agents.iter().any(|a| a.rotated && a.bound_tab.is_some())); - // The worktree row also names a SHARED repo directory, so the - // restored set carries at least one repo ink two rows could wear. - assert!(rr.agents.iter().any(|a| a.worktree && a.repo.is_some())); - // Every row's recency is distinct. Recency decides the dormant order - // and must not decide the restored one, and two rows that tie make - // either verdict unreadable. - let mut ages: Vec = rr.agents.iter().map(|a| a.ago_secs).collect(); - ages.sort_unstable(); - ages.dedup(); - assert_eq!(ages.len(), rr.agents.len(), "recency must stagger"); - // And every one of them is a real age. Zero is "this second", which - // is both untrue of a staged row and unreadable as a verdict. - assert!(rr.agents.iter().all(|a| a.ago_secs > 0)); - // dormant-f is the oldest row in the fixture: the bottom of the list - // is as much a staged expectation as the top of it. - let f = rr.agents.iter().find(|a| a.slug == "dormant-f").unwrap(); - assert_eq!(f.ago_secs, *ages.last().unwrap(), "dormant-f is the oldest"); - // Every other scenario stages dormant rows only — a stray bind would - // change what those reviewed validation paths come up holding. - assert!( - SCENARIOS - .iter() - .filter(|s| s.name != "relaunch-restore") - .all(|s| s.agents.iter().all(|a| a.bound_tab.is_none())), - "only the relaunch fixture binds tabs" - ); // cold-start: 3 agents, staggered recency, none worktree. let cs = &SCENARIOS[0]; assert_eq!(cs.agents.len(), 3); diff --git a/crates/clave/src/hook.rs b/crates/clave/src/hook.rs index 817d22e..97e2514 100644 --- a/crates/clave/src/hook.rs +++ b/crates/clave/src/hook.rs @@ -73,11 +73,7 @@ pub fn status_for_event(event: &str, message: Option<&str>, current: Status) -> "UserPromptSubmit" => Some(Status::Working), "Stop" => Some(Status::Done), "StopFailure" => Some(Status::Failed), - // Not `Idle` (#261): the row keeps its tab now, so "the agent is - // gone" and "the agent is alive with nothing to say" would otherwise - // render the same. `Exited` is what makes the dead tab visible and - // what lets the bar offer a restart on it. - "SessionEnd" => Some(Status::Exited), + "SessionEnd" => Some(Status::Idle), "PermissionRequest" => Some(Status::NeedsYou), "Notification" => { // §4: match the notification MESSAGE TEXT. Substrings chosen from @@ -1815,26 +1811,14 @@ pub fn apply_hook_pane(s: &mut Store, uuid: &str, event: &str, pane: Option return false; // raced a prune — fine }; if event == "SessionEnd" { - // The agent exited. The TAB did not close — and only the pane the row - // OWNS may report even that much. - // - // So `pane_id` goes and `tab_id` STAYS. `pane_id` answers "where does - // this row's process run"; `tab_id` answers "where does this row - // live". Clearing both conflated the two, and `clear_session_order` - // builds the next launch's restore set from `tab_id`: QA run 12 - // (2026-09-16) quit six bound tabs and got five back, missing exactly - // the one whose claude had really started. The event that means a tab - // is gone is `clave prune-tabs`, and it unbinds on its own. - // - // The row is left holding a tab and no pane — the same state a - // RESTORED row sits in, which the bar already renders and re-adopts - // (`spawn_binds`), so this mints no new state. A `/clear` still needs - // no carve-out: the successor session's bind evicts this one - // (`store::apply_bind`). + // Exit reverts the tab to a terminal tab — but only the pane the row + // OWNS may say so. A `/clear` needs no carve-out here: its SessionEnd + // unbinds and the successor session's mint re-claims the tab. if rec.pane_id != Some(pane) { return false; } rec.pane_id = None; + rec.tab_id = None; s.seq += 1; // monotonic pipe contract (§5) return true; } @@ -2132,8 +2116,7 @@ pub fn run_hook(event: &str, stdin_json: &str) -> Result<()> { now_unix(), env_uuid.as_deref() == Some(uuid.as_str()), ); - // The pane half (#226): register on any event, DEregister on - // SessionEnd (the tab bind is not a pane fact, and survives) — + // The pane half (#226): register on any event, revert on SessionEnd — // pane-verified, change-gated, and the bar renders from it. A fresh // mint always lands here too (row pane None → Some), so an adoption // pushes even when the event itself moved nothing. @@ -2870,7 +2853,7 @@ mod tests { ("StopFailure", &p, Status::Failed), ("PermissionRequest", &p, Status::NeedsYou), ("Notification", &perm, Status::NeedsYou), - ("SessionEnd", &p, Status::Exited), + ("SessionEnd", &p, Status::Idle), ] { apply_hook_event(&mut s, "u1", event, payload, None, 9999, true); assert_eq!( @@ -3089,7 +3072,7 @@ mod tests { ); assert_eq!( status_for_event("SessionEnd", None, Status::Done), - Some(Status::Exited) + Some(Status::Idle) ); assert_eq!( status_for_event("PermissionRequest", None, Status::Working), @@ -5338,15 +5321,13 @@ mod tests { } /// #226 live adoption, revert half: exiting claude drops the pane to its - /// shell, so a `SessionEnd` from the pane the row OWNS clears the - /// REGISTER. The BIND survives, because the tab is still on screen and the - /// row still lives in it — a later `claude --resume` in that shell - /// re-adopts into the same tab (the closed loop), and the quit that - /// follows records the tab in the restore set. Pane-match is the test: any - /// other pane's `SessionEnd` (or one with no verified pane) must not strip - /// a live association. + /// shell, so a `SessionEnd` from the pane the row OWNS clears the register + /// and the bind — the tab renders as a terminal tab again, the row goes + /// dormant, and a later `claude --resume` in that shell re-adopts (the + /// closed loop). Pane-match is the test: any other pane's `SessionEnd` + /// (or one with no verified pane) must not strip a live association. #[test] - fn session_end_from_the_owning_pane_clears_the_register_and_keeps_the_tab() { + fn session_end_from_the_owning_pane_reverts_the_tab_to_terminal() { let mut s = Store::default(); let mut r = rec("u1"); r.pane_id = Some(7); @@ -5359,40 +5340,16 @@ mod tests { // No verified pane: also nothing. assert!(!apply_hook_pane(&mut s, "u1", "SessionEnd", None)); assert_eq!(s.agents["u1"].tab_id, Some(3)); - // The owning pane's SessionEnd drops the register, one seq bump. + // The owning pane's SessionEnd reverts — both halves, one seq bump. let before = s.seq; assert!(apply_hook_pane(&mut s, "u1", "SessionEnd", Some(7))); assert_eq!(s.agents["u1"].pane_id, None); - assert_eq!( - s.agents["u1"].tab_id, - Some(3), - "the agent exited; the tab it lives in did not close" - ); + assert_eq!(s.agents["u1"].tab_id, None); assert_eq!(s.seq, before + 1); // Idempotent: a second SessionEnd finds nothing to clear. assert!(!apply_hook_pane(&mut s, "u1", "SessionEnd", Some(7))); } - /// An agent that exits must be DISTINGUISHABLE from one that is merely - /// idle (#261). Both hold a tab and neither is running, so mapping the - /// exit onto `Idle` made the two states one: the bar drew the same dim - /// dot for "waiting on you" and for "gone, start it again". Swarm review, - /// 2026-09-16, rated that the branch's own cost. - #[test] - fn a_session_that_ends_leaves_a_state_of_its_own_not_plain_idle() { - assert_eq!( - status_for_event("SessionEnd", None, Status::Working), - Some(Status::Exited), - "the agent exited; it is not idle, and the row must say so" - ); - // And an agent that comes back overwrites it with no carve-out. - assert_eq!( - status_for_event("UserPromptSubmit", None, Status::Exited), - Some(Status::Working), - "a restarted agent leaves the exited state on its own" - ); - } - /// #226 live adoption, mint half: a session clave has never seen, speaking /// from a verified clave pane, becomes a row — the jsonl is the source of /// truth, so any claude the user runs by hand joins the fleet. The mint is diff --git a/crates/clave/src/main.rs b/crates/clave/src/main.rs index c67d3eb..c49cf6d 100644 --- a/crates/clave/src/main.rs +++ b/crates/clave/src/main.rs @@ -240,10 +240,6 @@ enum Command { /// (LEDGER D36, applied to the open path). #[arg(long)] collapsed: bool, - /// The staggered restore's open (#261): create the tab with its agent - /// HELD, then put the focus back on this tab id. See `run_open`. - #[arg(long, value_name = "TAB_ID")] - restore_to: Option, }, /// Live-validation harness (§6.9): seed sandboxed scenarios, dump status, @@ -723,11 +719,7 @@ fn main() -> Result<()> { Some(Command::Setup) => setup::run_setup(), Some(Command::Doctor { json }) => clave::doctor::run_doctor(json), Some(Command::Backfill) => clave::backfill::run_backfill(), - Some(Command::Open { - uuid, - collapsed, - restore_to, - }) => open::run_open(&uuid, collapsed, restore_to), + Some(Command::Open { uuid, collapsed }) => open::run_open(&uuid, collapsed), Some(Command::Dev { action }) => match action { DevAction::Scenario { name } => dev::run_scenario(&name), DevAction::Launch => dev::run_launch(), @@ -830,14 +822,9 @@ mod tests { fn open_cli_parses_the_birth_mode() { let bare = Cli::try_parse_from(["clave", "open", "u-1"]).expect("must parse"); match bare.command { - Some(Command::Open { - uuid, - collapsed, - restore_to, - }) => { + Some(Command::Open { uuid, collapsed }) => { assert_eq!(uuid, "u-1"); assert!(!collapsed); - assert_eq!(restore_to, None); } _ => panic!("parsed into the wrong command"), } @@ -847,13 +834,6 @@ mod tests { Some(Command::Open { collapsed, .. }) => assert!(collapsed), _ => panic!("parsed into the wrong command"), } - // #261: the restore's open carries the tab the focus goes back to. - let restore = - Cli::try_parse_from(["clave", "open", "u-1", "--restore-to", "7"]).expect("must parse"); - match restore.command { - Some(Command::Open { restore_to, .. }) => assert_eq!(restore_to, Some(7)), - _ => panic!("parsed into the wrong command"), - } } /// #6/F3: the plugin shells `clave prune-tabs …` with the ids it diff --git a/crates/clave/src/open.rs b/crates/clave/src/open.rs index 9f0892e..6e86e20 100644 --- a/crates/clave/src/open.rs +++ b/crates/clave/src/open.rs @@ -38,13 +38,10 @@ pub enum OpenDecision { /// function was written to prevent. `add::live_uuid_union` is the same /// translation for the picker. pub fn open_is_live(store: &crate::store::Store, row: &AgentRecord, dump_layout: &str) -> bool { - // An EXITED agent keeps its tab (#261) so the next launch restores it, - // but nothing runs there. Reading that bind as liveness turns both the - // dwell-open and the bar's restart into a jump onto an empty tab. - (row.tab_id.is_some() && row.status != clave_types::Status::Exited) - // `pane_id` counts too (#226): an adopted session is registered by its - // first hook and bound only when the owning bar's join lands — keying - // on the bind alone would read the row dead inside that window. + // `pane_id` counts too (#226): an adopted session is registered by its + // first hook and bound only when the owning bar's join lands — keying on + // the bind alone would read the row dead inside that window. + row.tab_id.is_some() || row.pane_id.is_some() || crate::add::resolved_scan_uuids(store, dump_layout).contains(&row.uuid) } @@ -95,14 +92,7 @@ pub fn open_decision(_row: &AgentRecord, is_live: bool, cwd_exists: bool) -> Ope /// `collapsed` comes from the BAR (D36): the new tab must be born in the mode /// the fleet is in, or it flashes wide and then snaps. A hand-run `clave open` /// passes nothing and is born expanded. -/// -/// `restore_to` turns this into the staggered restore's open (#261) and carries -/// the tab id the focus belongs to. ONE option rather than a held flag and a -/// focus target, because the two are never separable: a held tab the human is -/// left standing on is started at once by the bar that owns it -/// (`run_held_effect`), so a hold without a focus return is a hold that does -/// not hold. See `add::TabStart` for why the focus cannot simply stay put. -pub fn run_open(uuid: &str, collapsed: bool, restore_to: Option) -> Result<()> { +pub fn run_open(uuid: &str, collapsed: bool) -> Result<()> { let paths = crate::store::store_paths()?; let store = crate::store::read_store(&paths)?; let Some(row) = store.agents.get(uuid) else { @@ -192,10 +182,6 @@ pub fn run_open(uuid: &str, collapsed: bool, restore_to: Option) -> Resul cwd: open_cwd, collapsed, row_height, - start: match restore_to { - Some(_) => crate::add::TabStart::Held, - None => crate::add::TabStart::Running, - }, }); let tmp = std::env::temp_dir().join(format!("clave-open-{uuid}.kdl")); std::fs::write(&tmp, layout)?; @@ -210,33 +196,6 @@ pub fn run_open(uuid: &str, collapsed: bool, restore_to: Option) -> Resul .status()?; let _ = std::fs::remove_file(&tmp); anyhow::ensure!(status.success(), "zellij action new-tab failed"); - // Put the human back where he was standing. Sent from HERE, and - // not as a second effect from the bar, because only this process - // knows the tab exists — `run_command` gives the bar no completion - // to sequence against, so a bar-side return would race the create - // it is meant to follow. - // - // Addressed by STABLE ID (`go-to-tab-by-id`, `zellij-utils-0.44.3/ - // src/cli.rs:1214`), never by position: FOOTGUNS records a - // position-addressed jump wedging nav permanently when the aim - // went past the real tab list, and a restore is exactly when the - // tab list is changing under us. - if let Some(tab_id) = restore_to { - let back = std::process::Command::new(&zellij) - .env("ZELLIJ_SESSION_NAME", &session) - .args(["action", "go-to-tab-by-id", &tab_id.to_string()]) - .status(); - // Loud in the log, not fatal: the tab IS restored, and the - // only loss is where the cursor sits. Bailing here would halt - // the whole queue over a cosmetic failure. - match back { - Ok(s) if s.success() => {} - other => crate::evlog::log_event( - "open", - &format!("{uuid}: focus return to tab {tab_id} failed: {other:?}"), - ), - } - } crate::evlog::log_event("open", &format!("{uuid}: tab created (resume via spawn)")); // A previously-stale row that opens fine heals (§5). if let Some(snap) = crate::store::apply_open_result(&paths, uuid, false)? { @@ -340,32 +299,6 @@ mod tests { assert_eq!(pane_cwd_in(&claude, &row), "/x"); } - /// A row whose agent EXITED keeps its tab (#261), so the bind alone can - /// no longer answer "is this live". Read literally it says the row is - /// already live, and `clave open` becomes a jump to a tab with nothing in - /// it — which is also what the bar's restart runs, so the human would - /// press the key and watch nothing happen. - #[test] - fn a_row_whose_agent_exited_is_not_live_even_though_it_holds_a_tab() { - let mut s = crate::store::Store::default(); - let mut gone = rec("u1"); - gone.tab_id = Some(3); // the tab is still on screen… - gone.pane_id = None; // …and its claude is gone - gone.status = clave_types::Status::Exited; - s.agents.insert("u1".into(), gone.clone()); - let live = open_is_live(&s, &gone, "layout { tab { pane } }"); - assert!( - !live, - "nothing runs in that tab, so opening the row must resume it" - ); - assert_eq!(open_decision(&gone, live, true), OpenDecision::Open); - // A row still bound with a live agent in it is untouched. - let mut alive = rec("u2"); - alive.tab_id = Some(4); - s.agents.insert("u2".into(), alive.clone()); - assert!(open_is_live(&s, &alive, "layout { tab { pane } }")); - } - #[test] fn open_is_live_prefers_the_store_bind_over_command_scan() { // Issue #6: the STORE bind is the authoritative liveness signal — diff --git a/crates/clave/src/setup.rs b/crates/clave/src/setup.rs index 5dec589..61c0c9f 100644 --- a/crates/clave/src/setup.rs +++ b/crates/clave/src/setup.rs @@ -539,26 +539,20 @@ pub fn layout_kdl(binary: &str, wasm: &str, row_height: clave_types::RowHeight) /// §6.8 (C8): the launch layout, composed DYNAMICALLY at session-create /// time. Base = the bar template; `row` is the ONE row to bake a tab for — -/// the top of the previous live set's rank (`restore_rows`), or the single -/// most-recent row when there is nothing to restore, or none at all on a -/// first run. Every other row surfaces as a dormant bar row (§6.6), and the -/// bar opens the deferred ones one tab at a time. +/// the single most-recent row (`eager_row`), or none at all on a first run. +/// Every other row surfaces as a dormant bar row (§6.6), and Alt+Enter opens +/// it. /// -/// One tab, because a layout of held tabs made zellij build the whole fleet -/// inside one second and the handle burst killed the server (#261, measured -/// 2026-09-17). The baked row also runs straight from the layout, which keeps -/// the single-eager-row behaviour as the floor: a relaunch lands in a working -/// agent even if the bar's restore path is broken. `restore_rows` ranks its -/// output for exactly this reason — the row that runs is the top of the -/// fleet, not whichever tab happened to be leftmost. +/// One tab, never a set. A layout of many tabs made zellij build the whole +/// fleet inside one second and the handle burst killed the server (#261, +/// measured 2026-09-17: four tabs peaked at 252 handles against macOS's +/// ceiling of 256, five crashed the server). The live-set restore that +/// sequenced the rest through the bar was removed on 2026-09-22 (FOOTGUNS, +/// "The restore that sequenced tabs through the bar"); the type takes one +/// row so the fleet-in-one-layout shape stays unspellable. pub fn launch_layout_kdl( binary: &str, wasm: &str, - // ONE row, or none — not a slice (#261). The launch bakes exactly the top - // of the rank and the bar brings the rest back one at a time, so a slice - // let this signature express the fleet-in-one-layout shape that killed the - // zellij server. `bakeable_rows` already returns at most one; saying so in - // the type is what stops a later caller re-inventing the crash. row: Option<&crate::store::AgentRecord>, collapsed: bool, row_height: clave_types::RowHeight, @@ -639,9 +633,8 @@ pub const HOOK_EVENTS: [&str; 5] = [ "SessionStart", ]; -/// Is `bin` one of ours? Shared by the hook and statusLine matchers here, and -/// by the bar's held-pane matcher, so the rule lives once -/// (`clave_types::is_clave_binary` carries the reasoning). +/// Is `bin` one of ours? Shared by the hook and statusLine matchers here, so +/// the rule lives once (`clave_types::is_clave_binary` carries the reasoning). fn is_clave_bin(bin: &str) -> bool { clave_types::is_clave_binary(bin) } @@ -1206,137 +1199,9 @@ pub fn eager_row(store: &crate::store::Store) -> Option<&crate::store::AgentReco .max_by_key(|r| r.last_interacted) } -/// The rows a relaunch rebuilds the layout from: the previous session's live -/// set (`Store::last_live`, written by `clear_session_order`), resolved to -/// records and RANKED the way the bar ranks them. -/// -/// `last_live` is a SET, not an order — written in ascending tab id, which is -/// creation order and never the order the human saw (the bar ranks by -/// investment, and Alt+1..9 indexes rendered ROWS, not the tab strip). The -/// rank is therefore computed here from the agent record, which only works -/// because `buckets` and `commit_ord` are agent-scoped and survive the -/// session, while every tab-scoped twin dies with it. -/// -/// Launch focuses the FIRST row, so the first row is the one that starts: the -/// top of the fleet is the row worth paying ~350 MB for. -/// -/// Two kinds of entry are dropped, because the snapshot is a faithful record -/// rather than a filtered one: a uuid naming no row (idle-pruned between -/// sessions), and a row whose cwd has vanished. Baking a tab for either emits -/// a spawn that dies at canonicalize. -/// -/// Empty is the ordinary cold-start answer, not an error: a first run, or a -/// session quit with nothing open. Launch falls back to the single eager row. -pub fn restore_rows(store: &crate::store::Store, now_hour: u32) -> Vec<&crate::store::AgentRecord> { - // `model::live_key` and `model::live_group`, read from the host side. - // Frecency mode scores the agent's own buckets with the fleet's own dial, - // and a row that scores nothing falls back to the commitment ordinal — so - // any scoring row outranks every unscored one, which is why the second - // element is zeroed rather than carried. Recency mode has no dial and no - // repo layer: the ordinal is the whole key. - // - // `repo_root` is the PARENT repo even for a worktree agent (the worktree - // path lives in its own field), so worktree rows cluster with their repo - // exactly as they do in the bar. The tiebreak is the row's index in - // `last_live`, i.e. ascending tab id, so rows the ranking cannot separate - // keep the order the previous session left them in. - let mut rows: Vec> = store - .last_live - .iter() - .filter_map(|uuid| store.agents.get(uuid)) - .filter(|r| std::path::Path::new(&r.cwd).is_dir()) - .enumerate() - .map(|(i, r)| { - let (group, millis) = match store.order { - clave_types::OrderMode::Recency => (None, 0), - clave_types::OrderMode::Frecency { half_life_hours } => ( - Some(r.repo_root.clone()).filter(|s| !s.is_empty()), - clave_types::frecency_millis(&r.buckets, now_hour, half_life_hours), - ), - }; - let key = clave_types::live_key(store.order, millis, r.commit_ord); - clave_types::LiveRow { - group, - key, - tiebreak: i, - row: r, - } - }) - .collect(); - clave_types::sort_live_block(&mut rows); - rows.into_iter().map(|e| e.row).collect() -} - -/// The rows launch actually bakes, and how a bad cwd is treated on each path. -/// -/// Splits the restored set three ways: the ONE row the launch bakes, the rows -/// the sidebar opens afterwards, and the rows that cannot be restored at all. -/// -/// Every baked cwd is guarded (`add::validate_cwd`) — a `"` or a control -/// character emits malformed KDL and the whole session fails to create. The -/// two paths fail differently ON PURPOSE. With one eager row a bad cwd is the -/// only thing the launch was going to bake, so it stays fatal and loud. Across -/// a restored set one bad row must not take the other ten down, so it is -/// dropped, reported, and the rest still come back. -/// -/// An emptied restored set falls through to the eager row. Checking the set -/// for emptiness BEFORE the filter bakes a session with a bar and no agent at -/// all on the day every restored cwd is rejected — strictly worse than the -/// cold start it replaced. -/// -/// Returns the rows and the lines to report rather than reporting them, which -/// keeps the decision pure and testable. It sits beside `restore_rows` and -/// `eager_row` because `launch_session` is excluded from `just mutants` on the -/// grounds that the pieces it orchestrates are each tested directly. -/// -/// The one-row limit is a resource bound, measured 2026-09-16: building a tab -/// costs a burst of about fifty file handles, four tabs peaked at 252 against -/// macOS's ceiling of 256, and five crashed the zellij server with "Too many -/// open files" (reproduced twice). Baking the whole fleet fails on exactly the -/// fleets the restore exists to serve, and a crashed launch loses tabs for -/// good. Sequencing holds the burst at one tab whatever the fleet size. -/// -/// DEFERRED IS NOT DROPPED. A deferred row comes back a few seconds later. A -/// dropped row does not come back at all, and says why. -fn bakeable_rows<'a>( - restored: Vec<&'a crate::store::AgentRecord>, - eager: Option<&'a crate::store::AgentRecord>, - mut check: impl FnMut(&str) -> anyhow::Result<()>, -) -> anyhow::Result<( - Vec<&'a crate::store::AgentRecord>, - Vec<&'a crate::store::AgentRecord>, - Vec, -)> { - let mut dropped = Vec::new(); - let mut kept: Vec<&crate::store::AgentRecord> = restored - .into_iter() - .filter(|r| match check(&r.cwd) { - Ok(()) => true, - Err(e) => { - dropped.push(format!("restore dropped {}: {e}", r.uuid)); - false - } - }) - .collect(); - if !kept.is_empty() { - // `split_off(1)` rather than a truncate-and-clone: the head keeps its - // place and the tail keeps the restore ranking, which is the order the - // sidebar walks down. - let deferred = kept.split_off(1); - return Ok((kept, deferred, dropped)); - } - // Nothing restorable. The eager row is the cold-start path and is already - // one row, so there is nothing to defer behind it. - let eager: Vec<&crate::store::AgentRecord> = eager.into_iter().collect(); - for r in &eager { - check(&r.cwd)?; - } - Ok((eager, Vec::new(), dropped)) -} - /// The record the launch bakes: `row` with its cwd replaced by `pane_cwd` /// (`open::pane_cwd`, the one rule) — but only when that value passes the -/// same KDL guard `bakeable_rows` ran over the row's own cwd. A relocation +/// same KDL guard `launch_session` ran over the row's own cwd. A relocation /// target comes from a transcript's tail and has never been validated; raw /// into `launch.kdl` a `"` or `\\` in it fails the whole session create /// (FOOTGUNS). One clone of one record, so `launch_layout_kdl` stays pure. @@ -1496,77 +1361,15 @@ pub fn launch_session() -> Result<()> { } } } - // Compose the launch layout from the store (§6.8). The previous session's - // live set if there is one — `clear_session_order` above has just recorded - // it — and the single most-recent row when there is not: a first run, or a - // session quit with nothing open, where the useful thing to hand back is - // one agent ready to work rather than a bar and no tabs. + // Compose the launch layout from the store (eager most-recent, §6.8). // Harmless when live (attach ignores --layout for an existing session). let store = crate::store::read_store(&crate::store::store_paths()?)?; - let now_hour = crate::store::unix_hour(crate::store::now_unix()); - let restorable = restore_rows(&store, now_hour); - // Say why a row is not coming back. `restore_rows` silently drops a uuid - // naming no record (idle-pruned between sessions) and a row whose cwd has - // gone (a deleted worktree), and the drop is PERMANENT — the next - // `clear_session_order` rebuilds `last_live` from binds, so a row that was - // never baked is gone from the set for good. Without this the launch log - // prints `baked=[…]` with the row simply absent, which reads as the - // restore losing it rather than the disk having lost it (found in review). - // - // A CREATE only. An attach bakes no tab and defers nothing, and - // `clear_session_order` did not run, so `last_live` still describes the - // session before the live one: logging here would announce a restore that - // is not happening, over a two-session-old set (#261). - if !live { - for uuid in &store.last_live { - if !restorable.iter().any(|r| &r.uuid == uuid) { - let why = match store.agents.get(uuid) { - None => "no row (pruned between sessions)".to_string(), - Some(r) => format!("cwd is gone: {}", r.cwd), - }; - crate::evlog::log_event("launch", &format!("restore dropped {uuid}: {why}")); - } - } - } - let (rows, deferred, dropped) = - bakeable_rows(restorable, eager_row(&store), crate::add::validate_cwd)?; - if !live { - for line in dropped { - crate::evlog::log_event("launch", &line); - } - } - // Recorded so a restore that never finishes is diagnosable from the log - // alone: the launch states what it handed to the sidebar, and the sidebar - // logs each row as it opens it. - if !live && !deferred.is_empty() { - crate::evlog::log_event( - "launch", - &format!( - "deferred to the bar: {}", - deferred - .iter() - .map(|r| r.uuid.as_str()) - .collect::>() - .join(", ") - ), - ); - // Name the tab that will drive the queue, in the store, where every - // bar reads it (#261). The head of `rows` is the row this launch - // bakes, so it is the tab that exists before the restore starts and - // the one `clave open` hands the focus back to. A bar born in a tab - // the restore MADE must not sequence; it cannot tell from the focus, - // because taking the focus is what a new tab does, so it is told. - // Only a CREATE arms it. A second `clave` from another terminal is an - // attach: the layout is ignored, no tab is baked, and nothing is - // deferred — but re-arming the owner here would put a running bar back - // in charge of a queue that has already been served. It is a no-op - // until that bar reloads, and then every tab the human deliberately - // closed is owed again, because closing a tab unbinds its row. - if let Some(home) = rows.first() { - crate::store::with_store_mut(&crate::store::store_paths()?, |s| { - s.restore_owner = Some(home.uuid.clone()); - })?; - } + let most_recent = eager_row(&store); + // Guard the eager row's cwd before it's baked into the launch layout + // (add::validate_cwd) — a `"`/control char would emit malformed KDL and + // the whole session would fail to create. + if let Some(r) = most_recent { + crate::add::validate_cwd(&r.cwd)?; } let wasm = wasm_path()?; // Bake the environment's clave into the eager tab's spawn: the versioned @@ -1584,9 +1387,8 @@ pub fn launch_session() -> Result<()> { // a row whose conversation relocated is baked where it went, not where // the row last stood. One clone of one record, so `launch_layout_kdl` // stays pure over the row it is handed. - let home = rows - .first() - .map(|r| baked_home(r, crate::open::pane_cwd(r), crate::add::validate_cwd)); + let home = + most_recent.map(|r| baked_home(r, crate::open::pane_cwd(r), crate::add::validate_cwd)); let layout_text = launch_layout_kdl( &binary, wasm.to_str().context("wasm path")?, @@ -1603,7 +1405,7 @@ pub fn launch_session() -> Result<()> { "launch", &format!( "session={session} live={live} baked={:?}", - rows.iter().map(|r| r.uuid.as_str()).collect::>() + home.as_ref().map(|r| r.uuid.as_str()) ), ); use std::os::unix::process::CommandExt; @@ -1796,7 +1598,6 @@ mod tests { cwd: "/c", collapsed, row_height, - start: crate::add::TabStart::Running, }), "tab name=", ), @@ -2064,97 +1865,11 @@ mod tests { assert!(!kdl.contains("\"spawn\"")); } - /// A bare record with just the fields a launch layout reads, so the - /// multi-row tests below say what they are about instead of restating - /// twenty-odd irrelevant defaults. - #[cfg(test)] - fn layout_row(uuid: &str, label: &str, cwd: &str) -> crate::store::AgentRecord { - crate::store::AgentRecord { - uuid: uuid.into(), - cwd: cwd.into(), - repo_root: cwd.into(), - branch: "main".into(), - label: label.into(), - status: clave_types::Status::Idle, - last_interacted: 0, - commit_ord: 0, - last_visited: 0, - worktree: None, - label_source: crate::store::LabelSource::FirstPrompt, - tab_id: None, - pane_id: None, - stale: false, - title: None, - summary: String::new(), - default_branch: None, - context_tokens: None, - context_level: None, - live_session: None, - metered_at: 0, - buckets: Default::default(), - model: None, - provider: None, - effort: None, - pr_number: None, - pr_checked: 0, - pr_branch: String::new(), - wants: None, - subagents: false, - } - } - - /// The relaunch layout: every row of the previous live set comes back as - /// a tab, ranked by the fleet's own frecency (`restore_rows`, which is - /// where that rank is decided) — but only the FIRST one runs. The rest are - /// created HELD, which costs a tab and a pane and no `claude` process, so - /// a relaunch restores the whole fleet's shape at near-zero cost and the - /// bar starts each agent when the human navigates to it. - /// - /// The first tab deliberately runs eagerly rather than being started by - /// the bar like the others: it is the tab launch focuses, and running it - /// from the layout keeps today's single-eager-row behaviour as the FLOOR - /// — if the bar's start-on-focus path ever fails, a relaunch still lands - /// the human in a working agent rather than a fleet of dead tabs. - /// The launch bakes ONE tab, and the type will not let it bake two. - /// - /// This test used to build three rows and assert two held tabs. That - /// layout has not been emitted since the restore became staggered (#261): - /// `bakeable_rows` keeps the head of the rank and defers the rest to the - /// bar, because zellij building a fleet of tabs in one breath exhausts the - /// process file-handle ceiling and kills the server. `launch_layout_kdl` - /// now takes one row or none, so the old shape is unspellable — and the - /// held path that IS live is `add::tab_layout` with `TabStart::Held`, - /// covered in `kdl_guardrail.rs`. - #[test] - fn the_launch_bakes_one_running_tab_and_never_a_held_one() { - let row = layout_row("u-1", "alpha · main", "/repo/alpha"); - let kdl = launch_layout_kdl( - "clave", - "/w.wasm", - Some(&row), - false, - clave_types::RowHeight::Card, - ); - assert_eq!( - kdl.matches("tab name=").count(), - 1, - "a launch bakes exactly one tab\n{kdl}" - ); - assert_eq!(kdl.matches("focus=true").count(), 1, "{kdl}"); - assert!( - !kdl.contains("start_suspended"), - "the launch holds nothing; the bar paces the rest\n{kdl}" - ); - assert!(kdl.contains("alpha"), "{kdl}"); - } - #[test] fn launch_layout_with_a_single_row_bakes_one_running_focused_tab() { - // §6.8, now the FALLBACK path: with nothing to restore, launch bakes - // the most-recent row alone — resumed, focused, and NOT held, every - // other row dormant in the bar. This is what a first run and a - // quit-with-nothing-open both land on, so it must stay byte-stable - // even as the restored-set branch grows beside it. + // §6.8: launch bakes the most-recent row alone — resumed, focused, + // every other row dormant in the bar. Every relaunch lands here, so + // it must stay byte-stable. let mut r = crate::store::AgentRecord { uuid: "u-recent".into(), cwd: "/repo/.claude-worktrees/ab".into(), // worktree row: bake ITS cwd @@ -2276,8 +1991,8 @@ mod tests { assert!(eager_row(&none).is_none()); } - /// A store row with nothing on it but a uuid and a cwd — every field the - /// restore reads is set by the caller, so a test says what it means. + /// A store row with nothing on it but a uuid and a cwd — every field a + /// test reads is set by the caller, so the test says what it means. fn bare_record(uuid: &str, cwd: &str) -> crate::store::AgentRecord { use crate::store::{AgentRecord, LabelSource}; AgentRecord { @@ -2314,286 +2029,6 @@ mod tests { } } - /// The launch builds ONE tab, however big the restored fleet is. - /// - /// Measured 2026-09-16: zellij opens a burst of about fifty file handles - /// per tab while it builds a session, all in the same instant, and drains - /// them a second later. Four tabs peaked at 252 handles against macOS's - /// default ceiling of 256; five crashed the server outright with "Too many - /// open files", twice, and a crashed launch loses tabs for good. A fleet - /// of any real size could never start. - /// - /// So the launch bakes the row the human lands on anyway, and the rest are - /// DEFERRED — the sidebar opens them one at a time, seconds apart, which - /// keeps the burst to a single tab's worth however many are coming back. - /// Deferring is not dropping: every deferred row is still restored. - #[test] - fn the_launch_bakes_one_tab_and_defers_the_rest_to_the_sidebar() { - let a = bare_record("u-a", "/good/a"); - let b = bare_record("u-b", "/good/b"); - let c = bare_record("u-c", "/good/c"); - let eager = bare_record("u-eager", "/good/e"); - let (baked, deferred, dropped) = - bakeable_rows(vec![&a, &b, &c], Some(&eager), |_| Ok(())).expect("all good"); - assert_eq!( - baked.iter().map(|r| r.uuid.as_str()).collect::>(), - vec!["u-a"], - "the head of the restore ranking is the tab the human lands on" - ); - assert_eq!( - deferred.iter().map(|r| r.uuid.as_str()).collect::>(), - vec!["u-b", "u-c"], - "the rest are the sidebar's queue, in the same order" - ); - assert!(dropped.is_empty()); - } - - /// Across a restored set, one unbakeable row must not take the rest down. - #[test] - fn a_bad_cwd_in_a_restored_set_drops_that_row_and_keeps_the_others() { - let a = bare_record("u-a", "/good/a"); - let b = bare_record("u-b", "/bad"); - let c = bare_record("u-c", "/good/c"); - let eager = bare_record("u-eager", "/good/e"); - let (rows, deferred, dropped) = bakeable_rows(vec![&a, &b, &c], Some(&eager), |cwd| { - if cwd == "/bad" { - anyhow::bail!("bad cwd") - } else { - Ok(()) - } - }) - .expect("one bad row is not fatal across a set"); - // The survivors keep their order across the split: u-a is baked, u-c - // is still restored, just by the sidebar a few seconds later. - assert_eq!( - rows.iter().map(|r| r.uuid.as_str()).collect::>(), - vec!["u-a"] - ); - assert_eq!( - deferred.iter().map(|r| r.uuid.as_str()).collect::>(), - vec!["u-c"], - "the good row behind the bad one is deferred, NOT lost with it" - ); - assert_eq!(dropped.len(), 1, "the drop is reported, not silent"); - assert!(dropped[0].contains("u-b")); - } - - /// A set whose rows are ALL rejected must still launch something. Testing - /// the set for emptiness before the filter baked a bar and no agent — - /// worse than the cold start the restore replaced. - #[test] - fn a_restored_set_that_is_entirely_unbakeable_falls_back_to_the_eager_row() { - let a = bare_record("u-a", "/bad"); - let eager = bare_record("u-eager", "/good"); - let (rows, _deferred, dropped) = bakeable_rows(vec![&a], Some(&eager), |cwd| { - if cwd == "/bad" { - anyhow::bail!("bad cwd") - } else { - Ok(()) - } - }) - .expect("the eager row is bakeable"); - assert_eq!( - rows.iter().map(|r| r.uuid.as_str()).collect::>(), - vec!["u-eager"], - "an emptied restored set falls through to the cold-start row" - ); - assert_eq!(dropped.len(), 1); - } - - /// With ONE row to bake, a bad cwd is the whole launch: fatal and loud, - /// never a session that silently came up empty. - #[test] - fn a_bad_cwd_on_the_eager_row_is_fatal() { - let eager = bare_record("u-eager", "/bad"); - assert!( - bakeable_rows(vec![], Some(&eager), |_| anyhow::bail!("bad cwd")).is_err(), - "the cold-start path stays fatal" - ); - } - - /// A first run: nothing restored, nothing to be eager about, no error. - #[test] - fn nothing_to_bake_is_not_an_error() { - let (rows, deferred, dropped) = - bakeable_rows(vec![], None, |_| Ok(())).expect("empty is ordinary"); - assert!(rows.is_empty() && deferred.is_empty() && dropped.is_empty()); - } - - /// The read side of the live-set snapshot: `last_live` is a faithful - /// record, so it can name rows that no longer exist (idle-pruned between - /// sessions) or whose directory has since gone (a deleted worktree). Both - /// are dropped here rather than at write time — the store stays the honest - /// record, and only what launch can actually BAKE survives this call. A - /// vanished cwd left in would emit a tab whose spawn dies at canonicalize, - /// the same trap `eager_row` already guards. - #[test] - fn restore_rows_drops_pruned_and_vanished_rows() { - use crate::store::{AgentRecord, LabelSource, Store}; - // `tempfile` rather than a pid-keyed path: it is removed on drop, so a - // panicking test leaks nothing and a reused pid cannot inherit a stale - // directory. Same convention as the store tests. - let tmp = tempfile::tempdir().unwrap(); - let live_dir = tmp.path().to_path_buf(); - let mk = |uuid: &str, cwd: &str| AgentRecord { - uuid: uuid.into(), - cwd: cwd.into(), - repo_root: String::new(), - branch: String::new(), - label: uuid.into(), - status: clave_types::Status::Idle, - last_interacted: 0, - commit_ord: 0, - last_visited: 0, - worktree: None, - label_source: LabelSource::FirstPrompt, - tab_id: None, - pane_id: None, - stale: false, - title: None, - summary: String::new(), - default_branch: None, - context_tokens: None, - context_level: None, - live_session: None, - metered_at: 0, - buckets: Default::default(), - model: None, - provider: None, - effort: None, - pr_number: None, - pr_checked: 0, - pr_branch: String::new(), - wants: None, - subagents: false, - }; - let here = live_dir.to_str().unwrap(); - let mut store = Store::default(); - store.agents.insert("u-b".into(), mk("u-b", here)); - store.agents.insert("u-a".into(), mk("u-a", here)); - store - .agents - .insert("u-gone".into(), mk("u-gone", "/no/such/clave/restore/dir")); - store.last_live = vec![ - "u-pruned".into(), // named in the snapshot, no longer a row - "u-b".into(), - "u-gone".into(), // row exists, its directory does not - "u-a".into(), - ]; - let uuids = |store: &Store| -> Vec { - restore_rows(store, 0) - .iter() - .map(|r| r.uuid.to_string()) - .collect() - }; - // Nothing separates these two rows, so the set's own order stands. - assert_eq!(uuids(&store), vec!["u-b", "u-a"], "viable rows only"); - assert!( - restore_rows(&Store::default(), 0).is_empty(), - "no snapshot ⇒ nothing to restore, and launch falls back to the eager row" - ); - } - - /// The set is written in ascending tab id — the order the tabs were - /// CREATED in, which is not the order the human saw. The bar sorts rows - /// by investment, so a relaunch has to sort them too, or the fleet comes - /// back shuffled and, worse, the row launch starts (the first one) is - /// whichever tab happened to be leftmost rather than the one at the top. - #[test] - fn restore_rows_rank_by_the_same_key_the_bar_uses() { - use crate::store::Store; - // `tempfile` rather than a pid-keyed path: it is removed on drop, so a - // panicking test leaks nothing and a reused pid cannot inherit a stale - // directory. Same convention as the store tests. - let tmp = tempfile::tempdir().unwrap(); - let live_dir = tmp.path().to_path_buf(); - let here = live_dir.to_str().unwrap().to_string(); - let mut store = Store::default(); - // Tab-id order is a, b, c. Investment says b, then c, then a. - for (uuid, count, ord) in [("u-a", 1, 9), ("u-b", 5, 1), ("u-c", 3, 2)] { - let mut r = bare_record(uuid, &here); - r.buckets.insert(0, count); - r.commit_ord = ord; - store.agents.insert(uuid.into(), r); - store.last_live.push(uuid.into()); - } - let uuids = |store: &Store| -> Vec { - restore_rows(store, 0) - .iter() - .map(|r| r.uuid.to_string()) - .collect() - }; - assert_eq!(uuids(&store), vec!["u-b", "u-c", "u-a"], "frecency"); - // `commit_ord` is the fallback, and a fleet with no buckets at all — - // upgrade day — must still come back in a meaningful order rather - // than collapsing to the tab strip. - for r in store.agents.values_mut() { - r.buckets.clear(); - } - assert_eq!(uuids(&store), vec!["u-a", "u-c", "u-b"], "ordinal fallback"); - // Recency mode has no dial: the ordinal is the whole key, so the - // buckets above must not have been ranking it. - store.order = clave_types::OrderMode::Recency; - for (uuid, count) in [("u-a", 1), ("u-b", 5), ("u-c", 3)] { - store.agents.get_mut(uuid).unwrap().buckets.insert(0, count); - } - assert_eq!(uuids(&store), vec!["u-a", "u-c", "u-b"], "recency mode"); - } - - /// The layer ABOVE the row key (CodeRabbit, PR #261). The bar clusters - /// live rows by repo and ranks the CLUSTERS by their summed score, so a - /// repo holding several middling rows outranks another repo's single - /// better one. Ranking each row on its own crossed that: the bar showed - /// `a1` on top and the relaunch focused — and therefore STARTED — `b1`. - /// - /// Both sides now call `clave_types::sort_live_block`, so this asserts the - /// shared rule reaches the host, not a second copy of it. - #[test] - fn restore_rows_rank_repo_clusters_the_way_the_bar_does() { - use crate::store::Store; - // `tempfile` rather than a pid-keyed path: it is removed on drop, so a - // panicking test leaks nothing and a reused pid cannot inherit a stale - // directory. Same convention as the store tests. - let tmp = tempfile::tempdir().unwrap(); - let live_dir = tmp.path().to_path_buf(); - let here = live_dir.to_str().unwrap().to_string(); - let mut store = Store::default(); - // Flat, b1 wins at 8000. Clustered, repo a wins with Σ 9000. - for (uuid, repo, count) in [ - ("u-b1", "/r/b", 8), - ("u-a1", "/r/a", 5), - ("u-a2", "/r/a", 4), - ] { - let mut r = bare_record(uuid, &here); - r.repo_root = repo.into(); - r.buckets.insert(0, count); - store.agents.insert(uuid.into(), r); - store.last_live.push(uuid.into()); - } - let uuids: Vec = restore_rows(&store, 0) - .iter() - .map(|r| r.uuid.to_string()) - .collect(); - assert_eq!( - uuids, - vec!["u-a1", "u-a2", "u-b1"], - "the cluster's sum outranks a better lone row, and the first row is what starts" - ); - // A worktree row clusters with its PARENT repo, because `repo_root` is - // the parent even when `worktree` is set — the same field the bar - // groups on, so a worktree cannot split its repo's cluster in two. - let wt = store.agents.get_mut("u-a2").unwrap(); - wt.worktree = Some(format!("{here}/wt")); - assert_eq!( - restore_rows(&store, 0) - .iter() - .map(|r| r.uuid.to_string()) - .collect::>(), - vec!["u-a1", "u-a2", "u-b1"], - "a worktree row stays inside its repo's cluster" - ); - } - #[test] fn session_exists_vs_live_distinguish_exited() { let out = "clave [Created 2h ago] (EXITED - attach to resurrect)\nother [Created 1m ago]\n"; diff --git a/crates/clave/src/spawn.rs b/crates/clave/src/spawn.rs index 3b59ba4..cba3e34 100644 --- a/crates/clave/src/spawn.rs +++ b/crates/clave/src/spawn.rs @@ -233,20 +233,7 @@ pub fn conversation_evidenced(rec: &AgentRecord) -> bool { || rec.title.is_some() || !rec.summary.is_empty() || rec.context_tokens.is_some() - // Every status here means a TURN happened — except `Exited`, which is - // new on this branch and means only that the process stopped (#261). - // A row that quit before it ever conversed has no transcript to - // shadow, and counting its status as evidence made it permanently - // unspawnable: the row goes dormant, the deliberate restart fires, and - // `spawn` refuses with "this row has already conversed". That is the - // exact dead end the restart exists to open. Seen on screen - // 2026-09-17. The other legs above still catch an exited row that DID - // converse, because a conversation leaves a title, a summary or a - // token count behind. - || !matches!( - rec.status, - clave_types::Status::Idle | clave_types::Status::Exited - ) + || rec.status != clave_types::Status::Idle } /// #139: verify the transcript is where the row says before exec'ing claude, @@ -1011,21 +998,6 @@ mod tests { assert!(conversation_evidenced(&rec( |r| r.status = clave_types::Status::Working ))); - // `Exited` is the one status that is NOT evidence, and it arrived with - // the staggered restore (#261). It says the process stopped, not that a - // turn happened. Counted as evidence, a row that quit before it ever - // conversed had no way home at all: dormant, the deliberate restart - // fires, and this function makes `spawn` refuse over a transcript that - // was never written. Seen on screen 2026-09-17. - assert!(!conversation_evidenced(&rec( - |r| r.status = clave_types::Status::Exited - ))); - // An exited row that DID converse is still protected — by the traces a - // conversation leaves, which is where that belongs. - assert!(conversation_evidenced(&rec(|r| { - r.status = clave_types::Status::Exited; - r.summary = "fix auth".into(); - }))); assert!(conversation_evidenced(&rec( |r| r.live_session = Some("rot".into()) ))); diff --git a/crates/clave/src/store.rs b/crates/clave/src/store.rs index 0d74daa..6fadb9f 100644 --- a/crates/clave/src/store.rs +++ b/crates/clave/src/store.rs @@ -312,59 +312,6 @@ pub struct Store { /// session recreate). `default` keeps pre-field store files loading. #[serde(default)] pub tab_touched: BTreeMap, - /// The rows that held a tab when the PREVIOUS zellij session died — the - /// set a relaunch restores. Written by `clear_session_order`, which is the - /// one pass that both runs at every launch and still sees the old binds a - /// beat before it clears them; read by `setup::restore_rows`. Nothing else - /// writes it. - /// - /// "Held a tab" means the BIND, and a restored tab binds before its spawn - /// runs (the bar's `restored_bind_effects`). Both halves are load-bearing: - /// derived from binds written by a running agent alone, the set recorded - /// only the tabs the human visited and the restored fleet decayed to a - /// single row over a few relaunches. - /// - /// Deliberately UNRANKED and UNCAPPED. A SET, written in ascending tab id - /// only so the file is deterministic; the order the human saw was the - /// bar's ranking, which `restore_rows` recomputes. Every policy about how - /// much comes back hot lives on the read side, so it can be retuned - /// without touching the store's correctness. - /// - /// Agent-scoped, unlike `tab_order`/`tab_buckets`/`tab_touched` beside it: - /// those hold session-scoped tab ids and must die with the session. An - /// empty default means "nothing to restore" — the single-eager-row path. - #[serde(default)] - pub last_live: Vec, - /// Which row's tab drives the staggered restore (#261). - /// - /// Session-scoped, like the binds beside it: written by `setup::launch` - /// once it has picked the row to bake, cleared by `clear_session_order` - /// on the way into the next session. `None` means no restore is owed. - /// See the field of the same name on `AgentSnapshot` for why the launch - /// has to say this out loud rather than let the bars work it out. - #[serde(default)] - pub restore_owner: Option, - /// Did any row bind a tab since the last launch? (#261) - /// - /// Two very different sessions reach the next launch with no binds and no - /// tab order: one that opened tabs and closed them all (the set must go - /// EMPTY), and one that never bound at all — a bar that failed to load, a - /// human who quit in the first seconds (the set must SURVIVE). This flag - /// is the one fact separating them, and only a live session supplies it. - /// Armed false by `clear_session_order`, set true by the first - /// `apply_bind`. Gating on `tab_order` instead does NOT work: the - /// quit-with-nothing-open case clears it on the same pass. - /// - /// The default is TRUE, and only a store the PREVIOUS clave wrote can - /// reach it — every launch from this version on writes the field. So the - /// default answers one question: may the first launch after an upgrade - /// trust the binds it can see? It may. The crash this flag guards against - /// leaves the flag behind set to false; it cannot leave the flag missing. - /// Defaulting to false instead cost one cold start for every person who - /// upgrades — their agents came back from the launch AFTER next, which - /// reads as the feature not working. - #[serde(default = "trust_binds_from_before_the_upgrade")] - pub bound_since_launch: bool, /// Which row geometry the NEXT `clave` launch bakes (#232). Read once by /// `launch_layout_kdl` at session-create time — never rides the pipe /// (unlike `collapsed`/`order`): geometry is launch-baked into fixed pane @@ -464,13 +411,6 @@ pub fn store_paths() -> Result { /// Lock-free read. Safe without the lock because writers replace the file by /// atomic rename — a reader opens either the old whole file or the new whole /// file, never a torn write. Missing file = empty store (first run). -/// See `Store::bound_since_launch`. A store with no such field was written by -/// a clave that had no live-set restore, so its binds describe a real fleet -/// and the launch reading them is the first one able to bring it back. -fn trust_binds_from_before_the_upgrade() -> bool { - true -} - pub fn read_store(paths: &StorePaths) -> Result { match fs::read(&paths.data) { Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), @@ -522,8 +462,6 @@ pub fn snapshot_from(store: &Store) -> AgentSnapshot { AgentSnapshot { seq: store.seq, tab_order: store.tab_order.clone(), - last_live: store.last_live.clone(), - restore_owner: store.restore_owner.clone(), collapsed: store.collapsed, tab_buckets: store.tab_buckets.clone(), order: store.order, @@ -728,12 +666,6 @@ pub fn apply_bind(paths: &StorePaths, uuid: &str, tab_id: usize) -> Result Result> { with_store_mut(paths, |s| { let r = s.agents.get_mut(uuid)?; - // A registration is the agent announcing itself one line before the - // exec into claude, so it is the moment we KNOW the row runs again. - // An `Exited` mark from the last session is wrong from here on, and - // nothing else clears it: `status_for_event` has no SessionStart leg, - // so the mark survives until the first prompt — which can be never. - // A restarted row that keeps it draws dim (`Status::Exited` → '○'), - // and `bound_live_uuids` offers it to the picker as a RESUME instead - // of a jump, which double-attaches the session (#261). - let stale_exit = r.status == Status::Exited; - if r.pane_id == Some(pane_id) && !stale_exit { + if r.pane_id == Some(pane_id) { return None; // re-registration of the same pane: no push } r.pane_id = Some(pane_id); - if stale_exit { - // Idle, not Working: the process is up, the turn is the user's. - r.status = Status::Idle; - } s.seq += 1; // monotonic pipe contract (§5) Some(snapshot_from(s)) }) @@ -986,44 +905,6 @@ pub fn clear_session_order(paths: &StorePaths) -> Result<()> { .values() .any(|r| r.tab_id.is_some() || r.pane_id.is_some()); let mut changed = false; - // Record the set a beat BEFORE clearing it: this pass is the last - // moment the previous session's binds exist, and `last_live` is what - // the next launch rebuilds the layout from. COMPUTED here and written - // below under one condition — see the gate. It used to be written - // unconditionally, and the paragraph on that gate says why that was - // nearly right and still wrong. - let live_set: Vec = { - let mut by_tab: Vec<(usize, &str)> = s - .agents - .values() - .filter_map(|r| r.tab_id.map(|t| (t, r.uuid.as_str()))) - .collect(); - by_tab.sort_unstable(); // ascending tab id: deterministic, NOT a rank - by_tab.into_iter().map(|(_, u)| u.to_string()).collect() - }; - // Gated on a bind having happened (#261). An unconditional write - // looks right — a quit with nothing open must leave an EMPTY set, and - // a conditional write would resurrect the layout from two launches - // ago — but it also lets a launch that DIED before binding anything - // erase the real set on the launch after. `bound_since_launch` is the - // fact that tells the two apart. - if s.bound_since_launch && s.last_live != live_set { - s.last_live = live_set; - changed = true; - } - // Arm for the session this launch is about to start. - if s.bound_since_launch { - s.bound_since_launch = false; - changed = true; - } - // Session-scoped, exactly like the binds below: the owner names a row - // whose TAB sequences one session's restore. The launch that follows - // this pass names the new one, and names nothing when it defers - // nothing, so a stale owner cannot make a bar sequence an empty queue. - if s.restore_owner.is_some() { - s.restore_owner = None; - changed = true; - } if !s.tab_order.is_empty() || bound { s.tab_order.clear(); s.tab_buckets.clear(); @@ -1035,12 +916,12 @@ pub fn clear_session_order(paths: &StorePaths) -> Result<()> { changed = true; } // No agent runs at a launch, so a status that describes a RUNNING - // PROCESS is a claim about something that is gone. A restored tab came - // back wearing the mark of an agent that had stopped, which reads as an - // empty tab beside the live ones (#261, QA run 18). The first hook of - // the new session states the truth; the launch only stops asserting the - // old one. Binds above and status here go on the same pass, because - // they die of the same cause. + // PROCESS is a claim about something that is gone. A row that came + // back wearing the mark of an agent that had stopped read as a + // different state beside the live ones (#261, QA run 18). The first + // hook of the new session states the truth; the launch only stops + // asserting the old one. Binds above and status here go on the same + // pass, because they die of the same cause. // // `Done` and `Failed` survive, and the distinction is the whole point // of this loop (found in review). They describe a finished TURN, not a @@ -1050,10 +931,7 @@ pub fn clear_session_order(paths: &StorePaths) -> Result<()> { // it destroyed an unread result on every relaunch, and dimmed every // seeded demo fleet the moment the maintainer launched it. for r in s.agents.values_mut() { - if matches!( - r.status, - Status::Working | Status::NeedsYou | Status::Exited - ) { + if matches!(r.status, Status::Working | Status::NeedsYou) { r.status = Status::Idle; changed = true; } @@ -1795,86 +1673,6 @@ mod tests { assert_eq!(read_store(&p).unwrap().agents["u1"].pane_id, None); } - #[test] - fn the_first_launch_after_an_upgrade_brings_the_old_fleet_back() { - // The live-set restore reads a list this launch writes from the binds - // the last session left. That write is gated on a flag added WITH the - // feature, so a store the previous clave wrote has no such flag — - // and reading its absence as "do not trust the binds" starts the - // upgrader cold, with their agents returning only from the launch - // AFTER next. The binds in that store are real; nothing else could - // have written them. - let d = tempfile::tempdir().unwrap(); - let p = tmp_paths(d.path()); - with_store_mut(&p, |s| { - let mut r = rec("u1"); - r.tab_id = Some(2); - s.agents.insert("u1".into(), r); - Some(()) - }) - .unwrap(); - // Age the file into what the previous version wrote: the bind stays, - // the flag was never a field. - let mut raw: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&p.data).unwrap()).unwrap(); - assert!( - raw.as_object_mut() - .unwrap() - .remove("bound_since_launch") - .is_some(), - "the field must be there to remove, or this test ages nothing" - ); - std::fs::write(&p.data, serde_json::to_string(&raw).unwrap()).unwrap(); - - clear_session_order(&p).unwrap(); - assert_eq!( - read_store(&p).unwrap().last_live, - vec!["u1".to_string()], - "the upgrader's fleet comes back on the FIRST launch" - ); - } - - #[test] - fn a_restarted_agent_stops_wearing_the_mark_of_the_one_that_quit() { - // Seen on screen 2026-09-17. An agent that quit was restarted, came up - // and ran — and its row kept `Exited`. Nothing else takes that mark - // off: the hook table has no SessionStart leg, so the row lied until - // the next prompt, drawing dim and offering itself to the picker as a - // resume, which double-attaches the session (#261). - let d = tempfile::tempdir().unwrap(); - let p = tmp_paths(d.path()); - with_store_mut(&p, |s| { - let mut r = rec("u1"); - r.status = Status::Exited; - r.pane_id = Some(42); - s.agents.insert("u1".into(), r); - }) - .unwrap(); - // The restart lands on a FRESH pane, the ordinary case. - let snap = apply_register(&p, "u1", 43).unwrap().expect("registered"); - assert_eq!(snap.agents[0].status, Status::Idle, "it runs again"); - // And on the SAME pane id, which the change gate would otherwise drop - // on the floor — the mark has to come off there too. - with_store_mut(&p, |s| { - s.agents.get_mut("u1").unwrap().status = Status::Exited; - Some(()) - }) - .unwrap(); - let snap = apply_register(&p, "u1", 43) - .unwrap() - .expect("same pane, but the status changed"); - assert_eq!(snap.agents[0].status, Status::Idle); - // A row that never quit keeps whatever it was doing: registering is - // not a status write, it is the removal of one wrong mark. - with_store_mut(&p, |s| { - s.agents.get_mut("u1").unwrap().status = Status::Working; - Some(()) - }) - .unwrap(); - apply_register(&p, "u1", 44).unwrap().expect("registered"); - assert_eq!(read_store(&p).unwrap().agents["u1"].status, Status::Working); - } - #[test] fn a_closing_tab_takes_its_pane_mapping_with_it() { // Codex P2 on PR #185. A pane that outlives its tab is worse than no @@ -2177,220 +1975,10 @@ mod tests { ); } - /// The restore's owner is SESSION-scoped, like the binds it sits beside. - /// - /// It names a row whose TAB sequences one session's restore, so carrying - /// it into the next session would put a bar in charge of a queue that no - /// longer exists. The launch names the new one on its way up, and names - /// nothing at all when it defers nothing. (#261) - #[test] - fn the_restores_owner_does_not_outlive_its_session() { - let d = tempfile::tempdir().unwrap(); - let p = tmp_paths(d.path()); - with_store_mut(&p, |s| { - s.agents.insert("u-a".into(), rec("u-a")); - s.restore_owner = Some("u-a".into()); - }) - .unwrap(); - apply_bind(&p, "u-a", 1).unwrap(); - assert_eq!( - read_store(&p).unwrap().restore_owner.as_deref(), - Some("u-a"), - "premise: the session came up with an owner" - ); - clear_session_order(&p).unwrap(); - assert_eq!( - read_store(&p).unwrap().restore_owner, - None, - "a stale owner would make a bar sequence the previous session's queue" - ); - } - - /// The launch pass that clears the session-scoped binds RECORDS them - /// first: `last_live` is the previous session's live SET, written in - /// ascending tab id so the file is deterministic. It is not a rank — the - /// relaunch ranks it on the read side (`setup::restore_rows`). - /// Without this the knowledge dies on the same pass that clears it, and - /// every previously-live row comes back dormant — the relaunch complaint. - #[test] - fn clear_session_order_records_the_live_set_in_tab_order() { - let d = tempfile::tempdir().unwrap(); - let p = tmp_paths(d.path()); - with_store_mut(&p, |s| { - // Inserted out of tab order, and keyed by uuid in a BTreeMap, so a - // pass that recorded iteration order rather than TAB order would - // pass by luck on a two-row fixture. Here uuid order and tab order - // disagree deliberately. - for uuid in ["u-c", "u-a", "u-b"] { - s.agents.insert(uuid.into(), rec(uuid)); - } - // A dormant row holds no tab and is not part of the live set. - s.agents.insert("u-dormant".into(), rec("u-dormant")); - }) - .unwrap(); - // Bound through `apply_bind`, the one writer a live session uses: - // hand-set binds skip `bound_since_launch` and would leave this - // fixture describing a session that never came up (FOOTGUNS: build the - // fixture from what the shell delivers). - for (uuid, tab) in [("u-c", 1usize), ("u-a", 9), ("u-b", 4)] { - apply_bind(&p, uuid, tab).unwrap(); - with_store_mut(&p, |s| { - s.tab_order.insert(tab, 0); - }) - .unwrap(); - } - clear_session_order(&p).unwrap(); - let s = read_store(&p).unwrap(); - assert_eq!( - s.last_live, - vec!["u-c".to_string(), "u-b".to_string(), "u-a".to_string()], - "ascending tab id (1, 4, 9), not uuid order, and dormant rows excluded" - ); - // The binds themselves still go — recording must not preserve them. - assert!(s.agents.values().all(|r| r.tab_id.is_none())); - } - - /// A RESTORED row holds a tab without ever running a process, and it must - /// still count as live — otherwise the set shrinks to the tabs the human - /// happened to visit and the restored fleet decays to one row over a few - /// relaunches (measured on this branch: three rows in, one row back). The - /// bind arrives from the bar's `restored_bind_effects`, so the discriminator - /// here is `tab_id` ALONE: a row with a tab and no registered pane is a - /// tab on screen, which is exactly what the set records. - #[test] - fn clear_session_order_counts_a_restored_row_that_never_ran() { - let d = tempfile::tempdir().unwrap(); - let p = tmp_paths(d.path()); - with_store_mut(&p, |s| { - s.agents.insert("u-visited".into(), rec("u-visited")); - s.agents.insert("u-waiting".into(), rec("u-waiting")); - }) - .unwrap(); - apply_bind(&p, "u-visited", 0).unwrap(); - apply_bind(&p, "u-waiting", 1).unwrap(); // a tab on screen… - with_store_mut(&p, |s| { - s.agents.get_mut("u-visited").unwrap().pane_id = Some(7); // ran - s.agents.get_mut("u-waiting").unwrap().pane_id = None; // never ran - }) - .unwrap(); - clear_session_order(&p).unwrap(); - assert_eq!( - read_store(&p).unwrap().last_live, - vec!["u-visited".to_string(), "u-waiting".to_string()], - "a restored tab the human never reached is still a tab that was open" - ); - } - - /// The tab a human was WORKING in must survive the quit. QA run 12 - /// (2026-09-16) bound six tabs and got five back: the one row whose claude - /// had really started was missing, because its `SessionEnd` unbound the - /// row from a tab that was still on screen, and `clear_session_order` - /// records only rows that still hold a tab. - /// - /// An agent EXITING and a tab CLOSING are different events. Only the - /// second may remove the row from the next launch's set; that one is - /// `apply_prune_tabs`, pinned separately. - #[test] - fn clear_session_order_keeps_a_row_whose_agent_exited_in_its_open_tab() { - let d = tempfile::tempdir().unwrap(); - let p = tmp_paths(d.path()); - with_store_mut(&p, |s| { - s.agents.insert("u-worked-in".into(), rec("u-worked-in")); - s.agents.insert("u-untouched".into(), rec("u-untouched")); - }) - .unwrap(); - apply_bind(&p, "u-worked-in", 0).unwrap(); - apply_bind(&p, "u-untouched", 1).unwrap(); // a tab on screen, spawn never run - with_store_mut(&p, |s| { - s.agents.get_mut("u-worked-in").unwrap().pane_id = Some(7); // owns the pane - // The claude in tab 0 exits. The tab stays open. - crate::hook::apply_hook_pane(s, "u-worked-in", "SessionEnd", Some(7)); - }) - .unwrap(); - clear_session_order(&p).unwrap(); - assert_eq!( - read_store(&p).unwrap().last_live, - vec!["u-worked-in".to_string(), "u-untouched".to_string()], - "an agent exiting does not close its tab, so the row is still restored" - ); - } - - /// Quitting with nothing open must leave an EMPTY set, not the set from - /// the launch before, or the relaunch resurrects a two-launches-ago - /// layout. Empty is also the signal launch reads to take its existing - /// single-eager-row path. - /// - /// The session here OPENED a tab and then closed it. That is what makes - /// the empty reading trustworthy, and it is the whole difference from - /// `clear_session_order_keeps_the_live_set_when_the_last_launch_never_bound` - /// beside it, where the session never bound anything and the set must - /// survive. Both reach this pass with no binds and no tab order; only - /// `bound_since_launch` separates them (#261). - #[test] - fn clear_session_order_empties_the_live_set_when_the_session_quit_with_nothing_open() { - let d = tempfile::tempdir().unwrap(); - let p = tmp_paths(d.path()); - with_store_mut(&p, |s| { - s.last_live = vec!["u-stale".into()]; - s.agents.insert("u-dormant".into(), rec("u-dormant")); - }) - .unwrap(); - apply_bind(&p, "u-dormant", 0).unwrap(); - with_store_mut(&p, |s| { - // The human closes the tab. `apply_prune_tabs` unbinds the row, - // and the session then quits with nothing open. - s.agents.get_mut("u-dormant").unwrap().tab_id = None; - }) - .unwrap(); - clear_session_order(&p).unwrap(); - assert!( - read_store(&p).unwrap().last_live.is_empty(), - "a session that closed its tabs clears the set rather than keeping it" - ); - } - - /// A launch that never bound anything must LEAVE the set alone (#261). - /// `clear_session_order` records the set and then nulls every bind, so a - /// session that comes up and binds nothing — a bar that fails to load, or - /// a human who quits within the first seconds — reaches the next launch - /// with an empty store and would overwrite the real set with nothing. - /// That is the very failure this feature exists to prevent, arriving by - /// another door. Found by swarm review, 2026-09-16. - #[test] - fn clear_session_order_keeps_the_live_set_when_the_last_launch_never_bound() { - let d = tempfile::tempdir().unwrap(); - let p = tmp_paths(d.path()); - with_store_mut(&p, |s| { - s.agents.insert("u-a".into(), rec("u-a")); - s.agents.insert("u-b".into(), rec("u-b")); - }) - .unwrap(); - apply_bind(&p, "u-a", 0).unwrap(); - apply_bind(&p, "u-b", 1).unwrap(); - // The session quits: the set is recorded and every bind is nulled. - clear_session_order(&p).unwrap(); - assert_eq!( - read_store(&p).unwrap().last_live, - vec!["u-a".to_string(), "u-b".to_string()], - "the quit records what was open" - ); - // The next launch dies before any row binds, so the one after it - // finds nothing. The set it must restore is still the same two rows. - clear_session_order(&p).unwrap(); - assert_eq!( - read_store(&p).unwrap().last_live, - vec!["u-a".to_string(), "u-b".to_string()], - "a launch that bound nothing is a re-run over an already-cleared \ - store, not a quit with nothing open" - ); - } - /// A launch begins with no agent running anywhere, so a status that names /// a RUNNING PROCESS is a claim about the session BEFORE. Left alone, a - /// restored tab comes back wearing the spinner of work that stopped at the - /// quit; on this branch it came back wearing `Exited`, which draws the - /// hollow "nothing here" mark on a tab that is about to start its agent — - /// two opposite rows rendered the same (#261, seen on QA run 18). + /// row comes back wearing the spinner of work that stopped at the quit + /// (#261, seen on QA run 18). /// /// The first hook event of the new session states the truth, so the launch /// only has to stop asserting the old one. @@ -2406,7 +1994,6 @@ mod tests { let p = tmp_paths(d.path()); with_store_mut(&p, |s| { for (uuid, st) in [ - ("u-restored", Status::Exited), ("u-mid-turn", Status::Working), ("u-waiting", Status::NeedsYou), ("u-dormant", Status::Failed), @@ -2417,12 +2004,11 @@ mod tests { } }) .unwrap(); - apply_bind(&p, "u-restored", 0).unwrap(); apply_bind(&p, "u-mid-turn", 1).unwrap(); apply_bind(&p, "u-waiting", 2).unwrap(); clear_session_order(&p).unwrap(); let s = read_store(&p).unwrap(); - for uuid in ["u-restored", "u-mid-turn", "u-waiting"] { + for uuid in ["u-mid-turn", "u-waiting"] { assert_eq!( s.agents[uuid].status, Status::Idle, @@ -2434,16 +2020,6 @@ mod tests { Status::Failed, "a finished turn is not a running process — the result is still unread" ); - // The rows themselves are untouched, and the live set is still recorded - // from the binds — resetting the status must not cost the restore. - assert_eq!( - s.last_live, - vec![ - "u-restored".to_string(), - "u-mid-turn".to_string(), - "u-waiting".to_string() - ], - ); } #[test] diff --git a/crates/clave/tests/kdl_guardrail.rs b/crates/clave/tests/kdl_guardrail.rs index ca13e66..070a2c6 100644 --- a/crates/clave/tests/kdl_guardrail.rs +++ b/crates/clave/tests/kdl_guardrail.rs @@ -525,23 +525,6 @@ fn launch_layout_kdl_parses_in_both_branches() { ); } -/// The first command pane anywhere in a tab's tree, in declaration order. -/// -/// Recursive because the tab template is `[clave-bar (fixed) | pane]`, so the -/// agent's command is a CHILD of the tab node and never the tab's own `run`. -/// A non-recursive read returns `None` for every tab and the held assertions -/// below then pass on an empty comparison. -fn run_of(tab: &TiledPaneLayout) -> Option<&Run> { - if tab - .run - .as_ref() - .is_some_and(|r| matches!(r, Run::Command(_))) - { - return tab.run.as_ref(); - } - tab.children.iter().find_map(run_of) -} - /// #181, the whole width mechanism in one assertion. Every layout clave emits /// must declare BOTH geometries as swap layouts, sized as the two FIXED column /// constants (fixed panes refuse every resize, which is what makes the bar @@ -632,7 +615,6 @@ fn every_layout_declares_both_swap_geometries_birth_width_first() { "/tmp", born_collapsed, clave_types::RowHeight::Single, - add::TabStart::Running, )), "one-shot tab layout", born_collapsed, @@ -649,68 +631,10 @@ fn add_tab_layout_parses_through_real_zellij_parser() { let label = add::sanitize_label("fix \"auth\"\nflow · main"); let cwd = "/home/o/code/clave/.claude-worktrees/ab12cd34"; add::validate_cwd(cwd).expect("test cwd must pass validate_cwd"); - let kdl = add::tab_layout(&spec( - &label, - cwd, - false, - clave_types::RowHeight::Double, - add::TabStart::Running, - )); + let kdl = add::tab_layout(&spec(&label, cwd, false, clave_types::RowHeight::Double)); assert_layout_ok(&kdl, "add/open one-shot tab layout"); } -/// #261: the staggered restore's layout is the one the human never sees being -/// written, so a KDL slip in it is only found by a relaunch. `start_suspended` -/// sits INSIDE the pane node beside `args`, which is the one place zellij -/// accepts it — as a tab property it parses and does nothing. -#[test] -fn held_one_shot_tab_layout_parses() { - let kdl = add::tab_layout(&spec( - "row · main", - "/tmp", - false, - clave_types::RowHeight::Double, - add::TabStart::Held, - )); - assert_layout_ok(&kdl, "held one-shot tab layout"); - let layout = Layout::from_str(&kdl, "guardrail:held-one-shot".into(), None, None) - .expect("held layout must parse"); - // The premise the whole hold rests on: zellij read the property and kept - // it. Asserted through the PARSED layout, not the text we just wrote. - let held: Vec = layout - .tabs() - .iter() - .filter_map(|(_, t, _)| run_of(t)) - .map(|run| match run { - Run::Command(c) => c.hold_on_start, - _ => false, - }) - .collect(); - assert_eq!(held, vec![true], "the restored tab's spawn is held\n{kdl}"); - - // The same layout without the hold must NOT parse as held — otherwise the - // assertion above would pass on a property zellij ignores. - let running = add::tab_layout(&spec( - "row · main", - "/tmp", - false, - clave_types::RowHeight::Double, - add::TabStart::Running, - )); - let running = Layout::from_str(&running, "guardrail:running-one-shot".into(), None, None) - .expect("running layout must parse"); - let running_held: Vec = running - .tabs() - .iter() - .filter_map(|(_, t, _)| run_of(t)) - .map(|run| match run { - Run::Command(c) => c.hold_on_start, - _ => false, - }) - .collect(); - assert_eq!(running_held, vec![false], "the pick path still runs"); -} - #[test] fn permissions_kdl_is_well_formed_in_both_branches() { // No public string entry point exists for zellij's PermissionCache parse, @@ -801,7 +725,6 @@ fn backslash_label_is_guarded_through_real_parser() { "/home/o/x", false, clave_types::RowHeight::Double, - add::TabStart::Running, )); assert!( Layout::from_str(&raw, "guardrail:raw-backslash".into(), None, None).is_err(), @@ -814,7 +737,6 @@ fn backslash_label_is_guarded_through_real_parser() { "/home/o/x", false, clave_types::RowHeight::Double, - add::TabStart::Running, )); assert_layout_ok(&kdl, "add/open tab layout (backslash-bearing label)"); // And a backslash-bearing cwd must be REFUSED, not baked. @@ -897,7 +819,6 @@ fn keybind_and_layout_plugin_configurations_match() { "/home/o/x", false, clave_types::RowHeight::Double, - add::TabStart::Running, )); let layout_kdl_text = setup::layout_kdl(BIN_ABS, WASM, clave_types::RowHeight::Double); @@ -1010,7 +931,6 @@ fn spec<'a>( cwd: &'a str, collapsed: bool, row_height: clave_types::RowHeight, - start: add::TabStart, ) -> add::TabSpec<'a> { add::TabSpec { binary: BIN_ABS, @@ -1020,6 +940,5 @@ fn spec<'a>( cwd, collapsed, row_height, - start, } } diff --git a/crates/clave/tests/script_hygiene.rs b/crates/clave/tests/script_hygiene.rs index 6e84f8e..8ffb77b 100644 --- a/crates/clave/tests/script_hygiene.rs +++ b/crates/clave/tests/script_hygiene.rs @@ -21,22 +21,20 @@ const DRIVE: &str = include_str!("../../../scripts/qa-drive.sh"); const CT: &str = include_str!("../../../scripts/ct.sh"); const LIB: &str = include_str!("../../../scripts/qa/lib.sh"); const SELFTEST: &str = include_str!("../../../scripts/qa/lib-selftest.sh"); -const VERDICT: &str = include_str!("../../../scripts/qa/relaunch-verdict.sh"); const SAMPLER: &str = include_str!("../../../scripts/qa/fd-sampler.sh"); /// Every script the drive is made of. The hook rule is about the WHOLE drive, /// not about one file of it: the instrument was split out of qa-drive.sh on /// 2026-09-12, and a rule that only read the file it was written against /// would have stopped covering anything that moved. -const DRIVE_SOURCES: [(&str, &str); 5] = [ +const DRIVE_SOURCES: [(&str, &str); 4] = [ ("scripts/qa-drive.sh", DRIVE), ("scripts/qa/lib.sh", LIB), ("scripts/qa/lib-selftest.sh", SELFTEST), - // The two tools #261 added. They were outside every rule here until review - // pointed it out, which is the failure mode the paragraph above describes + // A tool #261 added. It was outside every rule here until review pointed + // it out, which is the failure mode the paragraph above describes // happening a second time: the list is a list, so growing the drive does // not grow the guard. Anything new under scripts/qa/ belongs here. - ("scripts/qa/relaunch-verdict.sh", VERDICT), ("scripts/qa/fd-sampler.sh", SAMPLER), ]; @@ -445,19 +443,22 @@ fn the_drive_never_starts_or_ends_a_session_itself() { #[test] fn the_relaunch_phase_reads_the_set_through_the_tested_readers() { - // Phase 6c is the only phase that reads what the PREVIOUS session - // recorded, so it is the only one that can see the live set decay (#261) — - // a defect that reached a shipped branch with every gate green because no - // test launches twice. + // Phase 6c is the only phase that reads the store on both sides of a + // session boundary, so it is the only one that can see what a relaunch + // does with the store the quit left. It proves one eager tab and every + // other row dormant (setup.rs `launch_layout_kdl`, decision of + // 2026-09-22). The live-set restore it replaced (#261) reached a shipped + // branch with every gate green because no test launches twice. assert!( DRIVE.contains("P6c-relaunch"), "the relaunch phase must exist — without it nothing but a human \ - launching twice can catch the live set decaying" + launching twice can see what a relaunch bakes" ); // The verdict itself lives in qa/lib.sh and is exercised offline, against - // a store that decayed. The phase costs two maintainer launches, so a - // comparison only that pair of launches can try is one nobody tries — - // and the defect this phase exists for is exactly a leg that no test ran. + // a store with two bound rows, the wrong row bound, and a stale status. + // The phase costs two maintainer launches, so a comparison only that pair + // of launches can try is one nobody tries — and the defect this phase + // exists for is exactly a leg that no test ran. assert!( DRIVE.contains("relaunch_checks"), "phase 6c must reach its verdict through `relaunch_checks`, not a \ @@ -465,10 +466,8 @@ fn the_relaunch_phase_reads_the_set_through_the_tested_readers() { ); for reader in [ "bound_uuids", - "last_live_uuids", - "held_bound_uuids", + "eager_candidate_uuid", "stale_status_uuids", - "close_candidate_tab", "relaunch_checks", ] { assert!( diff --git a/docs/FOOTGUNS.md b/docs/FOOTGUNS.md index 0306ae6..ea985fb 100644 --- a/docs/FOOTGUNS.md +++ b/docs/FOOTGUNS.md @@ -69,6 +69,7 @@ and it is what distinguishes a mechanism from a symptom. - **zellij emits NO events for plugin-initiated resizes** — the only feedback is the plugin's own `render()`. Cross-tab width healing is structurally blind. This killed the entire repair-by-command architecture. (C6 rounds 10, 18) - **`show_self()` is a focus action** — the server maps it to `Action::FocusPluginPaneWithId`, which switches to that pane's tab; ten hidden instances calling it is ten racing focus actions. Use `show_pane_with_id(PaneId::Plugin(own), false, false)` → `UnsuppressOrExpandPane`. (C6 round 14) - **`suppress_pane` damages the tab's swap state, so the IMPLICIT swap relayout can never restore an unsuppressed pane** — `suppress_pane` → `extract_pane` → `set_is_tiled_damaged()`, and `add_tiled_pane` auto-relayouts only when NOT damaged. Other damage-setters: `resize_pane_with_id`, `resize_whole_tab`, close/extract/splits. The swap layout parsed perfectly and never fired. (C6 round 19) **Scope this to the implicit path.** As written it read as a blanket prohibition on swap layouts and shelved the fix for #181 for three weeks: the EXPLICIT `next_swap_layout` is not damage-gated, and since #181 clave issues no resizes and suppresses nothing, so there is no damage to gate on. (LEDGER D22, then D39) +- **A frameless pane paints one column short of its declared width.** With `pane_frames false`, zellij reserves one column of every tiled pane that is not at the viewport's right edge, borderless or not, for the separator line to its neighbour (`zellij-server/src/panes/tiled_panes/mod.rs`, `pane_content_offset` and the last `else` arm of `set_pane_frames`; identical in 0.44.3 and 0.45.1). The bar is the left pane, so a 48-col pane renders at 47 and a 16-col one at 15. The bar compared the painted width to the declared one EXACTLY, so it asked for a swap it did not need on every paint, the swap walked the tab through the other geometry, and the walk budget reset on every focus change, which a six-tab relaunch (the restore, since removed) caused six times in three seconds. That was the devbox flap: measured 2026-09-22 with a diagnostic bar, sixteen asks in four seconds, every one `cols=47` or `cols=15` while wanting the width it already had. It began with v0.5.1, when #262 started passing the user's `pane_frames` into clave's config; the Mac has frames on and never lost the column. Rule: judge a painted width by the nearest declared one, allowing the separator column (`RowHeight::mode_at`), never by equality. Sandboxes on the Mac could not reproduce it because their config had frames on. A diagnostic bar in the live fleet settled in one launch what source reading did not. - **`next_swap_layout()` called from a plugin switches the FOCUSED tab, not the plugin's own tab.** `apply_action!` passes the plugin's pane id, but the `NextSwapLayout`/`PreviousSwapLayout` arms of `route_action` drop it and forward only the client id (`zellij-server/src/plugins/zellij_exports.rs:119-138`, `:3117-3128`; `route.rs:1255-1270`, v0.44.3), and the screen resolves that through `active_tab_and_connected_client_id!` → `get_active_tab_mut(client_id)` (`screen.rs:7547-7573`, macro at `:203-242`). So a background bar that flips mode off a store snapshot relayouts whichever tab the user is looking at. There is **no tab-scoped plugin command** — `NextSwapLayoutByTabId` exists only as a CLI action (`zellij-utils/src/input/actions.rs:640-646`, `:1674-1681` → `screen.rs:9842-9866`). And per the "`render()` is not visibility-gated" entry above, a hidden bar does reach the call. Gate the switch on the bar's own tab being focused, or route it through `zellij action next-swap-layout --tab-id `. - **A swap layout REUSES the running plugin pane — it never respawns it — but only while both arrangements declare the plugin IDENTICALLY.** `relayout_tiled_panes` → `apply_tiled_panes_layout_to_existing_panes` drains the tab's panes and re-places them, matching first on `invoked_with() == run` (`zellij-server/src/tab/mod.rs:1093-1141`; `tab/layout_applier.rs:160-234`, `:1218-1250`); nothing on that path takes new plugin ids, unlike its `override_tiled_panes_layout_for_existing_panes` sibling at `:235`. `Run` for a plugin is `(location, configuration)` only, so pane `size` may differ freely between the two geometries — but any drift in the plugin's config block demotes the match to logical position and can land the bar in the wrong slot. Note also that a swap layout declaring FEWER panes than exist does not remove any: leftovers are re-inserted wherever there is room (`:218-224`). - **A floating pane can never cover the bar — `set_selectable(false)` already fenced it off, so any "clear the sidebar" geometry is arithmetic for a problem that does not exist.** `offset_viewport` resets the viewport to the display area and then, for every **non-selectable** pane flush against an edge and spanning the perpendicular axis, moves that edge past it (`zellij-server/src/tab/layout_applier.rs:1032-1075`). `clave-bar` is full-height at `x=0` and calls `set_selectable(false)` (`crates/clave-bar/src/main.rs:441`), so a tab's viewport BEGINS at the bar's right edge, and every floating percent is a percent of the reduced area. `#110` was raised — and half-designed — believing an explicit `x` past the bar was needed to keep it visible; Ollie disproved it live, resizing a floating pane with `Alt n` `+` until it stopped and never reaching the bar. Note the qualifier: a plugin pane is selectable by default (`zellij-server/src/panes/plugin_pane.rs:136`); it is the plugin's own `set_selectable(false)` call, not `borderless=true`, that does this. @@ -116,10 +117,7 @@ and it is what distinguishes a mechanism from a symptom. - **`Ctrl+D` in an agent pane does NOT close the tab** — panes run `clave spawn` (idempotent resurrect), so zellij holds the pane and re-runs it. `Alt+w` is the close path. (The stale `Ctrl+D` premises in `model.rs`'s re-anchor comment and its test were corrected with #162.) - **The zellij log is shared by EVERY session on the machine and old entries linger** — `$TMPDIR/zellij-/zellij-log/zellij.log` (macOS: under the OS temp dir, **not** `~/Library/Caches`, which holds `permissions.kdl` instead). A date filter is not enough: on a same-day cut it returns the previous version's loads too. Mark the line count before launching and read only what was appended. -- **`is_held` covers TWO states, and one of them is a finished agent** — zellij raises the flag both for a command that has NOT run (`start_suspended`, what a restored tab bakes) and for a command pane that ran, EXITED, and offers "press ENTER to run again". `PaneInfo.exited` is the discriminator. Without that check, walking past the tab of an agent the human deliberately quit resurrects it — a ~350 MB resume nobody asked for (`clave-bar/src/model.rs::run_held_effect`, #261). -- **Any `zellij run` command pane waiting to be re-run reports held too** — held is not a clave-only condition, and a human has ordinary reasons to hold panes. Before acting on one, check the launch command is ours: our binary AND our subcommand (`clave_types::is_clave_binary` + the `spawn` token). Re-running a stranger's command because the human navigated near it is clave reaching outside its own fleet. -- **A held pane keeps its launch command after the process execs** — so the command is a durable join from tab to row, not a signal of whether anything is running. Join rendering on the command; gate any ACTION that starts something on `is_held` and `!exited` as well (#261). -- **The layout key is `start_suspended`, and zellij parses it as `hold_on_start`** — the generated KDL and the parsed `Layout` use different names for the same thing, so a guardrail test asserting the parsed field will not match the string you wrote (`crates/clave/tests/kdl_guardrail.rs`). +- **`is_held` covers TWO states, and one of them is a finished agent** — zellij raises the flag both for a command that has NOT run (`start_suspended`) and for a command pane that ran, EXITED, and offers "press ENTER to run again". `PaneInfo.exited` is the discriminator. Any arm that acts on a held pane without that check resurrects an agent the human deliberately quit — a ~350 MB resume nobody asked for. (Measured on #261, whose held-pane wake was removed 2026-09-22; the flag is still what the manifest carries.) ## PATH and version coherence @@ -203,25 +201,24 @@ a live defect or a live constraint, and S0–S8 all land in this file. - **[FIXED] Two timer kinds in one classifier band do NOT cross-classify symmetrically — check which direction is the expensive one before you let them share.** `Event::Timer` carries elapsed seconds and nothing else, so the bar sorts four timer kinds by duration bands. The card's spinner (0.2s) was put in the fast band alongside the width cooldown (`WIDTH_COOLDOWN_SECS`, 0.15s) on the reasoning that the confusion was harmless both ways. It is not. A width expiry read as a frame costs one spare repaint. A FRAME read as a width expiry **ends the switch deafness early** — the frame's remaining time is uniform in [0, 0.2) against a 0.15s cooldown, so it wins most toggles made while any row is mid-turn — and the width machine then judges a pre-swap echo, the exact paint the cooldown exists to ignore, and spends one of `WALK_ASK_CAP`'s three asks on it. Three is not spare: the walk needs all three to cross the swap cycle's hidden birth position, so the bar can rest at the wrong width until the next toggle. No rare timing required; it is the common case. Fixed in two passes, and the first pass was wrong in an instructive way. **Pass one** kept both timers and stopped trusting any single expiry: `swap_owed` became a count, two when a spinner tick was in flight at the instant of the ask and one otherwise, with the model asking for the second tick itself (`Effect::RearmWidthCooldown`) so an ask that outlives the spinner cannot strand. It rested on "at most one foreign fast timer is in flight" — which was **false**, and for a reason that generalises: the fast leg has to disarm the spinner on ANY fast expiry, because it cannot tell whose expiry it is, so a cooldown expiry disarmed a spinner whose own frame was still pending and the next paint armed a SECOND one. Two pending frames spend both owed ticks inside one 0.15s deafness (0.05 + 0.10), restoring the bug the count was added to prevent. **Pass two** removed the ambiguity instead of classifying it: ONE timer in the fast band (`FAST_TICK_SECS`, 0.2s), armed through one funnel (`arm_fast_tick`) by whoever wants a tick — the spinner, or an ask still owed one. The count stays and is now EXACT, because there is provably one timer to count. **The rule to carry: two arming sites in one classifier band cannot hold a one-timer-at-a-time invariant, because the leg that disarms cannot know whose expiry it just consumed.** Guarded by `WIDTH_COOLDOWN_SECS <= FAST_TICK_SECS` (a `const _: () = assert!`, since lowering the tick silently restores the bug) and by source-text tests over `main.rs` (`clave-bar/src/lib.rs` mod `shell_text`) — the shell does not link on the host, so text is the only guard available to it. (2026-09-10, reopened by review and closed 2026-09-11) - **Terminal facts reach EVERY bar by event, and only the focused bar by probe — so the row that flickers is the one for a pane that has been QUIET since the bar was born.** The terminal row's cwd, last command and running flag are per-instance state with three fillers: `Event::CwdChanged` and `Event::CommandChanged`, both ungated in `main.rs` (every instance ingests them), and `probe_term_facts`, which returns early unless `own_tab_focused()`. Measured against a live 7-tab sandbox (2026-09-12, `scripts/qa-drive.sh` phase 5c): one `cd` and one `sleep` in one shell tab produced a fact delta from **all seven** bars, while only the focused one ever logged a probe. So the diagnosis to NOT reach for is "nothing shares what one instance learned" — the events share it fine. The hole is the pane nothing has happened to: it fires no event, and a bar that never had focus while it was listed never probes it, so that bar has nothing to render and silence writes no log line. Two consequences. **Do not "optimise" the two event arms behind a visibility gate** — it reads as a saving and takes the terminal row with it (phase 5c asserts the fleet-wide count precisely to stop that). And the fix for the hole is the same one `collapsed` and `tab_order` both took: publish the facts through the store, so every instance rebuilds from one writer instead of from its own luck. (#206, #239, the 2026-09-12 sighting) -- **State derived from "what a running agent wrote" silently EXCLUDES a row that holds a tab and has not run** — `Store::last_live`, the set a relaunch restores, is built from tab binds, and a bind is written when a spawn RUNS. A restored tab holds its row from the moment the session starts, so deriving the set from binds alone recorded only the tabs the human happened to visit: relaunch ten rows, work in two, quit, and the next relaunch restores two. The fleet decays toward one row over a few cycles — the very complaint the restore exists to fix, arriving slowly enough to look like normal attrition. Fixed by having a restored tab report its row immediately (`clave-bar/src/model.rs::restored_bind_effects`), so the store's answer to "which rows had a tab" matches the screen. **The general shape: when a set is derived from a side effect of running, check what the not-yet-running case contributes. It contributes nothing, and nothing is indistinguishable from absent.** (#261 swarm review; measured three rows in, one row back.) - -- **The same set also lost the OPPOSITE population — the rows whose agent ran and then EXITED.** `apply_hook_pane`'s `SessionEnd` arm cleared `tab_id` as well as `pane_id`, on the rationale that "exit reverts the tab to a terminal tab". The tab did not close. Only the process ended. `clear_session_order` builds the restore set from `tab_id`, so the tabs a human had really been working in were exactly the ones a relaunch dropped. QA drive run 12 (2026-09-16) quit six bound tabs and got five back; the missing row was the only one whose claude had passed the trust prompt. This hid behind a live verification that read green: that run restored four HELD rows — a tab, no pane, an agent that never started — and the `SessionEnd` unbind is gated on the row OWNING the pane, so held rows are precisely the population the defect cannot touch. **A verification can only prove the case it can reach; name that case before you call it proof.** Fixed by keeping the bind: `pane_id` answers where the process runs, `tab_id` answers where the row lives, and `clave prune-tabs` is the event that means a tab is gone. **The general shape: when one write clears two fields, check that one event really ended both facts.** (#261, `clave/src/hook.rs::apply_hook_pane`.) - -- **A bar in an UNFOCUSED tab cannot learn its own tab id — so no per-tab leg can serve a tab nobody has visited.** `BarModel::own_tab` resolves through the tab frame, and zellij delivers `TabUpdate` only to the FOCUSED tab (`clave-bar/src/main.rs`'s beacon exists for exactly this reason: pipes broadcast, frames do not). The first fix for the decay above had each restored tab report its own row, and it could never fire: the only instance able to act was the tab the human was already looking at, which is the one case that needed no help. Four restored tabs produced ONE bind on a live relaunch, 2026-09-15. **The general shape: before writing a leg that runs "on each instance for itself", check which inputs that instance actually receives when it is not on screen.** It receives the store snapshot and the GLOBAL pane manifest; it does not receive the tab frame. Work that needs tab identity belongs to the elected bar, which holds the whole tab list and can act for every tab at once (`restored_bind_effects`). (#261, found by launching it.) +- **A bar in an UNFOCUSED tab cannot learn its own tab id — so no per-tab leg can serve a tab nobody has visited.** `BarModel::own_tab` resolves through the tab frame, and zellij delivers `TabUpdate` only to the FOCUSED tab (`clave-bar/src/main.rs`'s beacon exists for exactly this reason: pipes broadcast, frames do not). A leg that ran "on each instance for itself" could never fire: the only instance able to act was the tab the human was already looking at, which is the one case that needed no help. Four tabs produced ONE bind on a live relaunch, 2026-09-15. **The general shape: before writing a leg that runs on each instance for itself, check which inputs that instance actually receives when it is not on screen.** It receives the store snapshot and the GLOBAL pane manifest; it does not receive the tab frame. (#261, found by launching it.) - **A test that hands the model a frame the real shell would never deliver proves nothing.** Every test for the leg above passed, because each called `apply_tabs` on a bar in an unfocused tab — fabricating the one input that instance can never get. The fixture's own doc even recited the rule ("zellij delivers TabUpdate only to the active tab") while the fixture contradicted it. **Build the fixture from what the shell delivers to THAT instance in THAT state, not from what the function needs to proceed.** (#261.) -- **An effect that STARTS a process must ride the beacon, not the identity election** — `identity_effects` gates on this instance's own tab frame flagging its own tab active, which is the self-diagnosed signal the entry above under "the bar's model" already condemns; and the identity pass re-enters on every store snapshot, on every instance. Bind, touch and prune survive that weaker gate because they are idempotent and self-healing. A spawn arm does not: a starved bar can resume an agent in a tab nobody is looking at, ~350 MB resident that is never given back. `own_tab_focused()` is the gate (`run_held_effect`, `shell_toggle`). (#261) -- **Widening what a store field MEANS does not tell its readers.** Keeping `tab_id` after `SessionEnd` fixed the restore set, and silently broke four readers that had always been able to treat a bind as "an agent is running here": the bar's dormancy join (so the row drew a live agent's dot and the restart key refused it), `clave open` (a jump onto an empty tab), and the picker (offered the dead row as a jump, never a resume). Nothing failed; the gates stayed green and a twelve-phase live drive passed. **Grep every reader of a field whose meaning you widen, and ask each one what it was really asking.** The fix was to make the new state nameable — `Status::Exited` — so the readers could see it instead of inferring it. Swarm review, 2026-09-16. (#261) -- **A held pane that EXITED and a held pane that never RAN are opposite cases wearing one flag.** `is_held` covers both ("waiting for user input", `zellij-utils-0.44.3/src/data.rs:2317`). `!exited` is what separates "a restored tab, start it when the human arrives" from "a tab the human deliberately quit, leave it alone". Walking past must never resurrect the second. That makes the DELIBERATE restart the only way home from an exited row, so it has to work — which is why dormancy, not the run-on-arrival arm, is where this was fixed. (#261) -- **A per-session status outlives the session that wrote it, and the launch is the only place that can say so.** Nothing runs at a launch, so every `Status` in the store is a claim about a process from the session before: a row mid-turn at the quit comes back spinning, and on #261 a restored tab came back wearing `Exited` — drawn hollow, "nothing here", on a tab that starts its agent the moment you arrive. Two opposite rows, one mark. The store reads perfectly well at every step; the field is simply scoped to a session and outlived it, exactly as tab and pane ids do. `clear_session_order` now resets status on the same pass that clears the binds, because they die of the same cause. **Ask of any store field: which of the three sessions owns it?** If the answer is the zellij session, the launch pass must clear it. (#261, QA run 18) - -- **`zellij action new-tab` ALWAYS focuses the tab it makes — the layout cannot refuse.** `focus=false` on the tab node does nothing: a tab node's `focus` property becomes the layout's `focused_tab_index`, while the new-tab path reads the ROOT PANE LAYOUT's `focus`, which a tab node never sets — so `should_change_focus_to_new_tab` falls through to "first tab wins" and is `true` (`zellij-utils-0.44.3/src/input/actions.rs:1611-1625`, and `kdl_layout_parser.rs:1191` for where the tab property really goes). There is no CLI flag either (`cli.rs:1229-1258`); the plugin API has one only on `break_panes_to_new_tab`. So a background tab is: make it, then `zellij action go-to-tab-by-id ` back — by STABLE id, never position, because a restore is exactly when the tab list is moving. Two consequences for anything born this way: its bar announces its own birth beacon, which leaves the beacon on a tab nobody is standing in (dead nav, and on #261 a stalled restore queue), and that same beacon reads as the human arriving. Both are answered in `restore_effects` / `set_beacon`. (#261) - -- **"One per store advance" is not a rate limit — a launch pushes a flurry of advances.** #261's restore paced itself on `restore_sent`, marked the instant an open went out, so the queue moved on every snapshot. That reads like one tab at a time and is not: the first live relaunch sent three tabs inside one second and killed the zellij server with "Too many open files" (194 handles and climbing, 150 of them PIPE, sampled by `scripts/qa/fd-sampler.sh`). The cost being paced is zellij BUILDING the tab, which runs long after the row is marked sent, so the only honest clock is the tab APPEARING. **When you pace work by a signal, check the signal outlives the work.** The gate is `opening` — set at the open, released by `prune_opening` on a bind, a stale row, or a row that left the store, so a failed open cannot wedge the queue. (#261, 2026-09-17) - -- **Never elect a worker by a signal that worker's own work destroys.** #261 picked the sidebar that drives the restore by asking "am I the focused tab" - and the restore's every step makes a tab, which takes the focus (see the `new-tab` entry above). So each restored tab was born believing it was in charge and started the queue again from the top. Measured 2026-09-17: the beacon named four different tabs eight times in 800 ms, five tabs were built in that window, two restored agents woke with nobody near them, and the run before it killed the zellij server with "Too many open files". Two patches aimed at the symptom (a one-row-at-a-time queue, then a wait-for-the-tab gate) both held inside ONE sidebar and were irrelevant against four. The fix is that the LAUNCH names the owner in the store: the launch is the only party that knows, and a name cannot be perturbed by the thing it names. **Ask of any election: can the elected party's own actions change the vote?** (#261) -- **A beacon storm is indistinguishable from a human walking around, because as a sequence of beacons it IS one.** The same restore moved the focus A->B->C->A->B eight times a second, and `beacon_moved` - the guard that stops a newborn tab reading its own birth as an arrival - passed happily on every one of them. No refinement of "did the focus move" can separate these; the question has to change. Held agents now refuse to start while the restore queue is non-empty, and the queue draining is what makes an arrival mean something again. (#261) -- **A single-instance test cannot see a multi-instance defect, and the bar is multi-instance.** One sidebar runs per tab. Every restore guard on #261 was written against one model and went green over three consecutive live failures, each of which was several sidebars each believing they were the only one. Two-model tests already existed in `model.rs` and were not used here. **If an arm is gated on "am I the one", test it with two models on one snapshot.** (#261) +- **An effect that STARTS a process must ride the beacon, not the identity election** — `identity_effects` gates on this instance's own tab frame flagging its own tab active, which is the self-diagnosed signal the entry above under "the bar's model" already condemns; and the identity pass re-enters on every store snapshot, on every instance. Bind, touch and prune survive that weaker gate because they are idempotent and self-healing. A spawn arm does not: a starved bar can resume an agent in a tab nobody is looking at, ~350 MB resident that is never given back. `own_tab_focused()` is the gate (`shell_toggle`). (#261) +- **Widening what a store field MEANS does not tell its readers.** #261 kept `tab_id` after `SessionEnd`, and silently broke four readers that had always been able to treat a bind as "an agent is running here": the bar's dormancy join (so the row drew a live agent's dot and the restart key refused it), `clave open` (a jump onto an empty tab), and the picker (offered the dead row as a jump, never a resume). Nothing failed; the gates stayed green and a twelve-phase live drive passed. **Grep every reader of a field whose meaning you widen, and ask each one what it was really asking.** The widening went with the restore (2026-09-22): a `SessionEnd` unbinds the tab again, so a bind means an agent is running there. Swarm review, 2026-09-16. (#261) +- **A per-session status outlives the session that wrote it, and the launch is the only place that can say so.** Nothing runs at a launch, so every `Working` or `NeedsYou` in the store is a claim about a process from the session before: a row mid-turn at the quit comes back spinning. The store reads perfectly well at every step; the field is simply scoped to a session and outlived it, exactly as tab and pane ids do. `clear_session_order` resets those marks on the same pass that clears the binds, because they die of the same cause. **Ask of any store field: which of the three sessions owns it?** If the answer is the zellij session, the launch pass must clear it. (#261, QA run 18) + +- **`zellij action new-tab` ALWAYS focuses the tab it makes — the layout cannot refuse.** `focus=false` on the tab node does nothing: a tab node's `focus` property becomes the layout's `focused_tab_index`, while the new-tab path reads the ROOT PANE LAYOUT's `focus`, which a tab node never sets — so `should_change_focus_to_new_tab` falls through to "first tab wins" and is `true` (`zellij-utils-0.44.3/src/input/actions.rs:1611-1625`, and `kdl_layout_parser.rs:1191` for where the tab property really goes). There is no CLI flag either (`cli.rs:1229-1258`); the plugin API has one only on `break_panes_to_new_tab`. So a background tab is: make it, then `zellij action go-to-tab-by-id ` back — by STABLE id, never position, because the tab list is moving exactly then. Two consequences for anything born this way: its bar announces its own birth beacon, which leaves the beacon on a tab nobody is standing in (dead nav), and that same beacon reads as the human arriving. This is the focus steal that sank the restore (see "The restore that sequenced tabs through the bar"). (#261) + +- **A single-instance test cannot see a multi-instance defect, and the bar is multi-instance.** One sidebar runs per tab. Every restore guard on #261 was written against one model and went green over three consecutive live failures, each of which was several sidebars each believing they were the only one. Two-model tests already existed in `model.rs` and were not used there. **If an arm is gated on "am I the one", test it with two models on one snapshot.** (#261) +- **The restore that sequenced tabs through the bar.** The live-set restore (#261, `2d3f068`, 2026-09-15 to 2026-09-22) was: the launch baked one eager tab and wrote the rows that held a tab at the last quit into the store as the live set; the bar in the store-named owner tab opened the rest one at a time as held panes (`start_suspended`, which zellij parses as `hold_on_start`); a held pane woke when the human arrived at it; and after each newborn stole the focus (the `new-tab` entry above) the owner re-anchored the beacon by a one-shot claim. What was measured: a layout of many tabs peaked at 252 file handles for four tabs against macOS's 256, and five crashed the zellij server with "Too many open files" (2026-09-17, `scripts/qa/fd-sampler.sh`); a resumed agent costs ~350 MB resident that is never given back; the set derived from binds decayed toward one row over a few relaunches; the owner election by "am I focused" was destroyed by its own opens (the beacon named four tabs eight times in 800 ms); and the re-anchor claim lost a race in four shapes — spent on a frame that beat the tab, spent on the frame between the bound snapshot and the announce, a rule from `restore_sent` that dragged the beacon off a tab the human walked to, and a drain of every owed row that answered tab 3's steal after tab 4's open — so Alt+c asked three times for two presses (QA runs 24 to 30, 2026-09-22). The box lost every race the Mac won. It was removed (`9670fbf`) because it was an optimistic pre-launch of tabs nobody asked for, sequenced by a bar elected through a focus signal its own work destroyed; the pre-restore design, one eager tab plus Alt+Enter, has no such race. The lessons that outlive it: + - a set derived from a side effect of running excludes the not-yet-running case, and nothing is indistinguishable from absent; + - a signal that paces work must outlive the work ("sent" is not "built"); + - an election the elected party's own actions can change is not an election; + - a beacon storm is, as a sequence of beacons, a human walking, and no refinement of "did the focus move" separates them; + - a per-instance counter cannot answer a question about the fleet, so ask who writes it before you read it; + - a claim armed by an event and spent by a frame loses to a racing announce. + **Do not bring it back as a queue; if a warm fleet is wanted, bake it from a layout the human explicitly asked for, or open rows only on Alt+Enter.** ## Claude transcripts — what the jsonl actually contains @@ -291,6 +288,9 @@ do not assume it: its line types changed under us once already. - **`mktemp -t ` means different things on macOS and on CI** — BSD/macOS takes the argument as a filename PREFIX; GNU mktemp takes it as a template and refuses one containing no `X`s. So a script written and passing on the maintainer's mac dies on the ubuntu runner with `too few X's in template`, and everything downstream of the missing file reads as a content failure rather than as a setup one (`qa/lib-selftest.sh`, 2026-09-12: eleven ledger assertions failed for a temp file that was never created). Always pass a full template — `mktemp "${TMPDIR:-/tmp}/name.XXXXXX"` — and check the result exists before anything asserts on what is in it. - **[FIXED] jq's `//` treats `false` itself as absent, so `.collapsed // empty` is BLIND to the expanded state — a probe built on it can never see a boolean go false.** QA drive run 3 (2026-08-17) failed P5 at its first false-ward press on exactly this: the store had flipped correctly (parity, single writer, all healthy) and `wait_collapsed` reported empty for the whole bounded wait, because `false // empty` evaluates to `empty`. It burned over an hour of forensics because the failure shape — "measured=empty" from a read that works when run by hand — reads exactly like transient store corruption or router congestion, and reproducing it faithfully reproduces the *probe*, not any product behaviour (a hand-rolled probe that copies the script's jq idiom inherits the blindness and "confirms" it). `// fallback` is safe on strings, numbers (including 0), and objects; it is NEVER safe on a boolean field. For booleans use the bare path (prints `true`/`false`/`null`) and compare strings. +- **A `zellij action` typed into an ssh shell on the devbox lands on the `remote` session, not the clave fleet.** The human's ssh shell there IS a zellij session (`remote`), so `ZELLIJ_SESSION_NAME` names it and every bare action aims at it. On 2026-09-22 `zellij action toggle-pane-frames`, run to test the frames seam, toggled `remote` and the clave fleet flapped on. Name the session (`zellij --session clave action …`) or, from an agent, use `scripts/ct.sh` against the SANDBOX only. Reads of the box's fleet: the log file, never a session. +- **On Linux, zellij's sockets are under `$XDG_RUNTIME_DIR/zellij/`, not `/tmp/zellij-/`; only the LOG is under tmp.** `ZELLIJ_SOCK_DIR` (zellij-utils consts.rs 0.44.3:316-327) takes `$ZELLIJ_SOCKET_DIR`, else the project runtime dir (`/run/user//zellij` on Linux, none on macOS), else the tmp dir. The log dir is `ZELLIJ_TMP_LOG_DIR`, always under tmp. So on the devbox the drive read `/tmp/zellij-1000/zellij-log/zellij.log` fine and `ct.sh` refused every action: "no clave-test session socket under /tmp/zellij-1000/contract_version_*". The first remote drive died in phase 1 on it. `ct.sh` follows zellij's rule now. Measure the socket path from the server's argv (`ps -eo args | grep 'zellij --server'`) before assuming it. +- **A worktree's `.git` is a FILE, so rsync of a worktree lands a non-repo.** It points at the main checkout's gitdir on the source machine; on the remote every `git` call fails with "not a git repository", and `scripts/sandbox-setup.sh` starts with `git rev-parse --show-toplevel`. `scripts/remote-qa.sh` pushes instead: the remote is a plain repo with `receive.denyCurrentBranch updateInstead`, and one `git push HEAD:main` is the whole sync. A plain checkout on the remote is also a MAIN checkout to `clave dev instance`, so the remote sandbox is named `clave-test` at `~/.local/state/clave-dev`, not after the worktree it was pushed from. - **Bare `zellij` commands from an agent shell mutate the maintainer's OWN session.** Claude Code runs *inside* the user's zellij session — `$ZELLIJ_SESSION_NAME` reflects that, not some sandbox. `zellij attach` variants once injected clave-layout tabs into the live session. **Session lifecycle is the human's; print the command, do not run it.** (docs/dev/TESTING.md) - **The settings denylist on session teardown is a speed bump, not a boundary.** `.claude/settings.json` denies the exact spellings that would tear down the maintainer's `clave` session (`zellij kill-session clave`, the quoted forms, and the `*-all-sessions` nukes), added at his request after the 2026-08-07 incident. Bash deny rules match the command **text**, so a different spelling — a shell variable, an extra space, a name built at runtime — walks straight past them, and the rules deliberately do NOT cover `clave-test*` because tearing down your own sandbox is legitimate work. **And enumeration is the only option here, which is why the list looks the way it does:** the obvious tightening — deny the whole `zellij kill-session:*` prefix and allow the sandbox back — does not work, because deny beats allow, so it would block tearing down your own sandbox too (CodeRabbit found a missing single-quoted spelling on PR #164; the fix was to complete the quoting matrix, not to widen the prefix). Treat the denylist as the last of three guards, never the first: the real ones are structural — the per-worktree sandbox instance (#161) means you cannot *name* someone else's session by accident, and `scripts/ct.sh` refuses to run at all unless your own is provably live. - **`ZELLIJ_SESSION_NAME=clave-test` is NOT a safety boundary — it is a preference, and it FAILS OPEN.** An agent shell inside the maintainer's session inherits `ZELLIJ=0` and `ZELLIJ_PANE_ID=` as well as the session name. Override the name only, and while `clave-test` exists everything behaves; the moment it stops existing (another agent's `dev reset`, a crash, a kill you did not make) the CLI **falls back to the ambient session instead of erroring**, and every queued drive command lands on the daily fleet. That happened on 2026-08-07: a `start-or-reload-plugin` put a sandbox-built debug bar into the maintainer's live session as a real pane, and ten `clave-toggle` pipes went to his fleet. Nothing was corrupted — stable install, config and store all survived — but the session was visibly disrupted, and he found it before the agent did. @@ -303,8 +303,8 @@ do not assume it: its line types changed under us once already. - **A `dump-screen` cannot tell Claude Code's dimmed placeholder from typed text.** An empty input box shows a suggestion (`❯ ls -la`) in the same position and, to a dump, the same characters as a prompt someone typed. `scripts/ct.sh write 13` on it submits nothing, and a poll then reports a turn that never started (2026-09-02: two Enters, two quiet polls). Probe first: `write-chars 'x'` REPLACES the placeholder if it was one and APPENDS if it was text; `write 127` undoes the probe either way. - **Claude's identity is deliberately NOT sandboxed** — `CLAUDE_CONFIG_DIR` isolation broke auth (ruling 2026-07-18). So scenario seed transcripts land in the real `~/.claude/projects/`, and `~/.claude/settings.json` is one shared hook slot where Claude fires *all* matching hooks — clave keeps exactly one entry per event or they double-fire. - **`~/.claude` is never yours to write.** Note sandbox task-output paths can *symlink* into `~/.claude/projects/`, so a write believed to be sandboxed can land in the real transcript store. -- **In the QA sandbox only the MINTED row's claude ever starts a session — every seeded row sits at "Yes, I trust this folder" forever.** So any phase that wants to see hook-driven behaviour has exactly one witness, and a phase that happens to close, kill or skip that row measures the population the behaviour cannot reach. Phase 6c closed it twice (runs 14 and 15, 2026-09-16) and would have gone green over a restore that was still broken — the same blind spot that made the 2026-09-15 verification read green. Two rules for picking rows failed here before the right one: "lowest bound tab id" takes the minted row because it is created first, and "a tab and no pane" never matches in the FIRST session, because that signature belongs to a RESTORED row and every row the drive opens registers a pane. The drive knows the uuid it minted (`CREATE_UUID`); name it, spare it, and ASSERT it is still in the set — a phase that can pass without its only witness is a phase that proves nothing. (#261) -- **A fact one QA phase measures is not still true ten phases later, and an assertion that carries one across is asserting about a moment that has gone.** Phase 6c named the row whose tab phase 3 closed, and required the restore to leave it out. But phase 4 wakes the first WAKEABLE row of the dormant block, and after phase 3 that is precisely the row phase 3 just made dormant — so the drive re-opened its own closed row four minutes before the quit and then called the restore wrong for bringing it back (run 13, 2026-09-16; `clave.log` holds the drive's own `open` for that uuid). The check had never executed before: run 12 failed an earlier check in the same phase, and `check` stops the run at the first red. **A late phase must ESTABLISH the state it asserts on, not inherit it** — 6c now closes its own tab immediately before the ask, with nothing in between that could wake the row. Read this as the general rule for any drive assertion that names a uuid, a tab id or a count from an earlier phase. +- **In the QA sandbox only the MINTED row's claude ever starts a session — every seeded row sits at "Yes, I trust this folder" forever.** So any phase that wants to see hook-driven behaviour has exactly one witness, and a phase that happens to close, kill or skip that row measures the population the behaviour cannot reach. Phase 6c closed it twice (runs 14 and 15, 2026-09-16) and would have gone green over a restore that was still broken — the same blind spot that made the 2026-09-15 verification read green. Two rules for picking rows failed here before the right one: "lowest bound tab id" takes the minted row because it is created first, and "a tab and no pane" never matches in the FIRST session, because that signature belonged to a RESTORED row (a design removed 2026-09-22) and every row the drive opens registers a pane. The drive knows the uuid it minted (`CREATE_UUID`); name it, spare it, and ASSERT it is still in the set — a phase that can pass without its only witness is a phase that proves nothing. (#261) +- **A fact one QA phase measures is not still true ten phases later, and an assertion that carries one across is asserting about a moment that has gone.** Phase 6c named the row whose tab phase 3 closed, and required the restore to leave it out. But phase 4 wakes the first WAKEABLE row of the dormant block, and after phase 3 that is precisely the row phase 3 just made dormant — so the drive re-opened its own closed row four minutes before the quit and then called the restore wrong for bringing it back (run 13, 2026-09-16; `clave.log` holds the drive's own `open` for that uuid). The check had never executed before: run 12 failed an earlier check in the same phase, and `check` stops the run at the first red. **A late phase must ESTABLISH the state it asserts on, not inherit it** — 6c then closed its own tab immediately before the ask, with nothing in between that could wake the row (that check left with the restore, 2026-09-22; the rule stays). Read this as the general rule for any drive assertion that names a uuid, a tab id or a count from an earlier phase. - **Handoffs and the validation ledger are IMMUTABLE history** — a doc sweep may correct live SOP only, never a dated record of what was run. A blanket "fix every occurrence" instruction once had an implementer falsify a dated quote, inserting a flag that did not exist on that date. - **A branch updated via `gh pr update-branch` diverges from your local copy** — fetch and merge before pushing. Branch protection requires up-to-date-with-base, so merging PR N forces a branch update on PR N+1 and a fresh CI round. Direct push to `main` is blocked (`enforce_admins: true`); note approvals required is **0**, so "ask before merging" is unenforced. - **Global git config rewrites HTTPS→SSH** (`url.git@github.com:.pushInsteadOf`), so a dead SSH agent blocks pushes as well as signing. @@ -320,8 +320,8 @@ do not assume it: its line types changed under us once already. - **The visible bar's sync pane probes retry an unanswerable pane FOREVER via the `running` latch — #189's exited-pane fix closed only one door.** `probe_targets` re-qualifies any pane whose `pane_facts` say `running: true`, so a pane whose `GetPaneCwd`/`GetPaneRunningCommand` round-trips time out (100 ms each, `zellij-server/src/plugins/zellij_exports.rs:4240,4308`) while its command genuinely runs is re-probed on every manifest and every 3 s poll — measured live 2026-08-25: plugin 19 burned timeouts against pane 29 for over four hours. Worse at tab birth: a newborn bar has hydrated nothing, so `agent_in_tab` is `None` for EVERY tab and the probe pass hits every speaker pane in the session — 2 blocking round-trips × N tabs, serialized on the plugin-exec thread that also delivers the newborn's `clave snapshot` result, so the probe storm delays the very hydration that would shrink the target list (measured 2026-08-26 09:44: five full passes in 3.1 s while the bar rendered every row TERM). Any probe gate needs BOTH a hydration gate and a per-pane failure cooldown; the general rule is FOOTGUNS' own "retry until known needs a can-this-ever-succeed test", third sighting. - **The zellij log is USER-GLOBAL, and its source column cannot tell the sandbox bar from the stable one.** One `zellij.log` under `$TMPDIR/zellij-/` serves every session the user runs, and the log's fixed 25-char source field truncates both wasm paths — `~/.local/share/clave/…` (stable) and `~/.local/state/clave-dev-…/…` (sandbox) — before their `share`/`state` divergence point (which sits at char 17 + username length, so any username of 9+ chars collapses them to the identical `/Users//.local…` tag — and even a shorter one leaves a single `h`-vs-`t` to squint at); plugin ids restart per server, so `[id: N]` collides too. A `clave-bar:` line is therefore **unattributable to a session** whenever the maintainer's fleet is live. The first #182 red run failed on exactly this: rung 1 asserted an EOF-twin delta of 0 and measured 10 — every one of them main-session traffic (all 3508 `dropped` lines in the log carried the stable prefix). Twin deltas are forensics to record, never a cross-session assertion to gate on; a content-distinguishing line (e.g. the build tag in `clave-bar: loaded`) is the only attributable kind. (2026-08-13, #182) **The debug render line is the second one, and it is the useful one for a drive:** `CLAVE_BAR_DEBUG=1` prints `clave-bar render: mode=… cols=… rows=…`, and while the sandbox runs a row height the daily fleet does not, `grep 'mode=Card'` attributes every frame to the sandbox. Measured 2026-09-10 proving the card's animation timer — a bare `grep -c 'clave-bar render'` counted BOTH bars and made an idle sandbox look like it was repainting nine times a second; filtered, the same window was 12 frames in one burst. Launch the sandbox with `CLAVE_BAR_DEBUG=1` and filter on the mode, or do not count frames at all. - **[FIXED 2026-09-10] `clave hook` PUSHES, and its push had no `--session` — so it landed wherever zellij's fallback resolution chose, which in an agent shell is the maintainer's live fleet.** The push now names its own session: `bounded_pipe_command` passes `--session` when `ZELLIJ_SESSION_NAME` is set in the hook's env (`own_session`, hook.rs), at every wrapper rung and before the subcommand. **The rest of this entry still stands as the DRIVE rule** — the fix aims the push at whatever session the caller's env names, so a drive shell that has not cleared the maintainer's `ZELLIJ_SESSION_NAME` now aims at his fleet precisely instead of by accident. `scripts/ct.sh --hook [payload-json]` is what sets it correctly and remains the only sanctioned way to drive one. The store write itself is safe (`CLAVE_STATE_DIR` selects it), so a drive that sets only the state dir looks entirely correct and still fires `zellij pipe --name clave-status` at the wrong session. Done 2026-09-09 while driving the four-line card; a snapshot of a 3-row sandbox store was aimed at a live 20-row fleet, and only `apply_snapshot`'s `snap.seq <= self.seq` discard (`model.rs`) stopped it landing — the sandbox's seq was 26 and the live store's was far higher. **That guard is luck, not protection: a fresh sandbox with a high seq, or a live store early in its life, inverts it.** Drive hooks with `ZELLIJ_SESSION_NAME` set to the sandbox and `ZELLIJ`/`ZELLIJ_PANE_ID` cleared, exactly as `ct.sh` does for actions. The tell that it went astray is silence — the sandbox bar simply keeps rendering a stale snapshot, which reads as "the feature is broken" rather than "the push missed". **IT RECURRED ON 2026-09-11, and the second time it hung the maintainer's live session** — so the entry above was right, sufficient, and useless: a rule that every call site must remember is the wrong shape for this hazard. The QA drive's new phase 5b grew its own `hook_fire` helper that set `CLAVE_STATE_DIR` and nothing else, exactly as this entry predicts, and fired five events per run into his fleet. **Three things changed so that remembering is no longer required.** (1) `clave hook` now decides a push's destination from the STORE IT WROTE rather than from the env alone — `hook.rs::aim_push` refuses when `ZELLIJ_SESSION_NAME` names a session that does not own that store, in both directions (a sandbox store pushed at a real session, and a real store pushed at a sandbox bar), and writes one `push-refused` line to the store's own `clave.log` plus a stderr note. The env is the caller's claim; the store is the fact. (2) `scripts/qa-drive.sh` scrubs the inherited identity ONCE, before its first phase, so every child it spawns — routed through `ct.sh` or not — aims at the sandbox; the default is now fail-safe rather than fail-dangerous. (3) `crates/clave/tests/script_hygiene.rs` fails the build if any line in the drive fires a hook outside `ct.sh --hook`, or if the scrub stops preceding the first phase. The drive's own `P6b-isolation-witness` phase then asserts zero refusals across a run, which is the only evidence of isolation obtainable without touching the session you are trying to prove you did not touch. -- **A per-instance counter cannot answer a question about the FLEET.** The bar's restore queue is "rows in `last_live` with no tab, minus the ones I sent". Only the elected owner ever sends, so on every other bar the second half is always empty — and the arm that reads it (`run_held_effect`, "do not wake an agent while the fleet is still arriving") runs on the bar the human is standing in, which is by definition NOT the owner. Two ways it stuck at "still arriving" for the whole session: a tab the human closes unbinds its row, and a row whose cwd is gone is refused by `clave open` and never gets a tab at all. Either one and no held agent wakes again until relaunch. A deleted worktree in the previous live set is an ordinary thing to have. Two fixes, both needed: skip rows that can never arrive (`stale`), and latch "I have seen the queue empty" once rather than re-deriving it per frame. Found in review on #261, never in a test, because the test was single-instance. **Before you read a counter, ask who writes it.** -- **A command pane keeps its launch command after the process exits** (`zellij-utils-0.44.3/src/data.rs:2331` — "the stringified version of the command and its arguments"), so `clave spawn ` in a pane's command proves the tab BELONGS to that agent and proves nothing about whether it is running. Joined on the string alone, an agent that ran and quit reads as "a baked spawn waiting to run", leaves the dormant block, and loses the deliberate restart — which is the only way home from an exited row. `is_held && !exited` is the pair that means waiting to run; use it wherever liveness is the question, and the bare string only where OWNERSHIP is. The test that should have caught this delivered tabs and no panes, so the pane that carries the trap was never in the fixture. (#261) **If an arm reads the pane frame, test it with the pane frame.** -- **`zellij action go-to-tab-by-id` does NOT move the beacon, so it cannot drive any arrival behaviour.** clave's navigation is `clave-nav` (Alt+↑/↓, Alt+N, Alt+Enter): the bar picks the row, moves the focus itself, and broadcasts `clave-visited`. A raw zellij focus change bypasses all of it, and a bar announces itself only ONCE, at birth (`birth_announced`) — so a walk driven with `go-to-tab-by-id` produces no beacon line at all, and the held-pane wake arm never runs. Measured 2026-09-17: three rounds spent reading "the agent did not start" as a product defect when the drive had never pressed the button. The sanctioned form is the drive's own: `scripts/ct.sh pipe --name clave-visited -- ` to anchor the executor, then `scripts/ct.sh pipe --name clave-nav -- '{"dir":"next"}'`. The same applies to any check on the beacon: drive the pipe, not the multiplexer. -- **`zellij action dump-layout` reports `start_suspended true` on a pane that has already run.** It serialises the pane's original run instruction, not its live state, so it cannot answer "did the held agent start". The observable that can is the STORE's `pane_id`: `clave spawn` writes the bind before it execs the agent, so a row that gained a pane is a row whose held pane ran — and it works even in the sandbox, where a seeded row's claude never gets past the trust prompt and so never fires a hook. (#261) +- **A command pane keeps its launch command after the process exits** (`zellij-utils-0.44.3/src/data.rs:2331` — "the stringified version of the command and its arguments"), so `clave spawn ` in a pane's command proves the tab BELONGS to that agent and proves nothing about whether it is running. Joined on the string alone, an agent that ran and quit reads as a running one. `is_held && !exited` is the pair that means waiting to run; the bare string answers only OWNERSHIP. The test that should have caught this delivered tabs and no panes, so the pane that carries the trap was never in the fixture. (#261) **If an arm reads the pane frame, test it with the pane frame.** +- **`zellij action go-to-tab-by-id` does NOT move the beacon, so it cannot drive any arrival behaviour.** clave's navigation is `clave-nav` (Alt+↑/↓, Alt+N, Alt+Enter): the bar picks the row, moves the focus itself, and broadcasts `clave-visited`. A raw zellij focus change bypasses all of it, and a bar announces itself only ONCE, at birth (`birth_announced`) — so a walk driven with `go-to-tab-by-id` produces no beacon line at all, and no arrival arm runs. Measured 2026-09-17: three rounds spent reading "the agent did not start" as a product defect when the drive had never pressed the button. The sanctioned form is the drive's own: `scripts/ct.sh pipe --name clave-visited -- ` to anchor the executor, then `scripts/ct.sh pipe --name clave-nav -- '{"dir":"next"}'`. The same applies to any check on the beacon: drive the pipe, not the multiplexer. +- **The tab was born before its row, and the spawn registered into nothing.** `clave add` created the tab (step 6) and recorded the row (step 7) after a transcript probe that can read tens of MB. The newborn pane runs `clave spawn`, which persists its pane id into the store one line before it execs, and `apply_register` answers None for a uuid it cannot find — silently, by design, because a spawn can race a pruned row. So whichever finished first won: on the Mac the record, on the box (qa-fleet runs 28 and 29, 2026-09-22) the spawn, every time. `pane_id` null for the life of the row, the tab never bound, rung 1 red, and no line anywhere said why; the host log's order (`spawn: Create` before `add: recorded`, same second) was the whole trace. The row now exists before the tab is asked for (`add.rs` `record_then_open`, tested by reading the store from inside the open). **A write that another process will look up must land before that process can start.** A fast machine turns "usually first" into "never first". +- **`zellij action dump-layout` reports `start_suspended true` on a pane that has already run.** It serialises the pane's original run instruction, not its live state, so it cannot answer "did this pane's command start". The observable that can is the STORE's `pane_id`: `clave spawn` writes the bind before it execs the agent, so a row that gained a pane is a row whose spawn ran — and it works even in the sandbox, where a seeded row's claude never gets past the trust prompt and so never fires a hook. (#261) - **The tree mark had two writers and neither ran on the hook path, so a session that ENTERED a worktree mid-conversation followed the move and still wore the branch mark.** `clave add` asks git at mint time; `add::heal_worktrees` asks git at setup and release time. `hook::take_checkout` (2026-09-15) made `cwd` and `branch` follow the transcript, and it clears `worktree` on a move OUT — but nothing SET it on a move IN, so a row minted on `main` whose session then used Claude Code's own worktree tool sat on `worktree-…` with the branch glyph until the next `clave setup`. Seen live 2026-09-18, an hour into the session. The transcript names the path: `{"type":"worktree-state","worktreeSession":{"worktreePath":…}}` is written on enter and re-stamped by turns (29 local transcripts carry it). `hook::worktree_from_tail` reads the newest well-formed one and `take_worktree` SETS it only where the row's cwd is inside the named path — the same containment `take_checkout` drops on, so a stale worktree record cannot re-mark a row that has just left. **And the `"worktreeSession":null` form is NOT "left the worktree" — do not clear on it.** It means "no worktree-tool session", which a session launched or resumed directly inside a worktree directory also writes, on every turn, while its cwd stays inside the tree (measured on `b947acf7`: three consecutive nulls, 39 cwd lines all in `.claude/worktrees/triple-card`). The first cut of this fix cleared on it; the opus reviewer caught that it would strip a correct mark, `heal_worktrees` would re-seed it at the next setup, and the two writers would flap. The only fact that proves a departure is the cwd leaving the tree, and `take_checkout` already clears on that. **The general rule:** a field the card renders needs a writer on the path that fires while the session runs; a repair that runs at setup is for rows that predate the field, not for the session in front of you. diff --git a/docs/UBIQUITOUS_LANGUAGE.md b/docs/UBIQUITOUS_LANGUAGE.md index 4d601e9..900146c 100644 --- a/docs/UBIQUITOUS_LANGUAGE.md +++ b/docs/UBIQUITOUS_LANGUAGE.md @@ -96,30 +96,15 @@ column target, was deleted at #181; the term survives only in the ledger.) **Row** — one entry of the fleet as the bar draws it. Three kinds: - **live row** — an agent session with a zellij tab open. The tab is what makes - it live, not the process: a **restored** row is live before its agent runs. -- **dormant row** — an agent session with nothing running to return to. Usually - no tab open; also an **exited** row, whose tab outlived its agent. + it live, not the process. +- **dormant row** — an agent session with no tab open. Alt+Enter opens it. - **terminal tab** — a zellij tab with no agent session bound to it. -**Held tab** — a tab whose baked `clave spawn` has not run yet. zellij creates -the pane suspended (`start_suspended`, which it parses as `hold_on_start`), so -the tab, its name and its command all exist and no agent process does. A held -tab costs a pane; a running agent costs ~350 MB resident that is never given -back. The bar starts the agent when the human arrives at the tab. - -Do not read zellij's held flag as "waiting to start". It covers two states: a -command that has not run, and a command that ran and EXITED and offers to run -again. Only the first is ours to start. - -**Restored tab** — a held tab that a relaunch baked from the **live set**. It -holds its row from the moment the session starts, and reports that row to the -store immediately, so the set survives the next quit. - -**Live set** — the rows that held a tab when the previous zellij session died -(`Store::last_live`). Written at every launch by the pass that clears the -session-scoped binds, a beat before it clears them. It is a SET, not an order: -the rank is recomputed on the read side, because the order the human saw was -the bar's ranking and not the zellij tab strip. +**Eager tab** — the ONE tab a launch bakes into the layout, for the most-recent +row. A relaunch bakes one eager tab and nothing else: every row that was live +in the previous session is an ordinary dormant row. Nothing is held, nothing is +sequenced (the live-set restore was removed on 2026-09-22; FOOTGUNS, "The +restore that sequenced tabs through the bar"). **Row is the data-side word — a row is what gets rendered, never the shape it is rendered in.** The shape is the **row height**, and there are three: @@ -204,17 +189,13 @@ These three are constantly confused. They are not interchangeable. | Term | Means | |---|---| | **selected** | The row for the currently focused tab. Exactly one. | -| **live** / **dormant** | Has a tab open / does not — the tab decides it, not the process. A restored tab is live before its agent runs. See §3.1. | -| **held tab** | OURS: a baked tab whose `clave spawn` has not run yet. See §3.1. | -| **zellij held flag** | THEIRS: `PaneInfo.is_held`, which is also set for a command that ran and EXITED. A held tab is the subset with `!exited`. Never say "held" alone about a pane. | -| **live set** | The rows that held a tab when the previous session died; what a relaunch brings back. See §3.1. | -| **restore owner** | The one row whose TAB brings the live set back, one tab at a time. The launch names it — it is the row the launch bakes — and writes it to the store, so every sidebar agrees without talking. Say "owner" only about the restore; it is not a word about who uses an agent. A tab the restore MADE is never the owner, whatever the focus says. | +| **live** / **dormant** | Has a tab open / does not — the tab decides it, not the process. See §3.1. | | **unread** | Finished while you were not looking — `done && !visited`. | | **stale** | `clave open` found the row's cwd missing. A row flag, **not** a status. | -| **exited row** | An agent session that ENDED while its tab stayed open. It holds a tab and runs nothing, so it counts in the live set (the tab comes back) but reads DORMANT in the bar (there is nothing to return to). `Status::Exited`, written by the `SessionEnd` hook. Do not say "idle" about it: idle means alive with nothing to say. It lasts one session only — a launch clears every status, because no agent runs at a launch. | -`Status` — the enum — has exactly six variants and they are spelled this way: -**Idle, Working, NeedsYou, Done, Failed, Exited**. +`Status` — the enum — has exactly five variants and they are spelled this way: +**Idle, Working, NeedsYou, Done, Failed**. A `SessionEnd` hook sets Idle and +unbinds the tab: the row goes dormant and the tab is a terminal tab. --- diff --git a/docs/dev/QA-DRIVE.md b/docs/dev/QA-DRIVE.md index 2807c50..93dfdae 100644 --- a/docs/dev/QA-DRIVE.md +++ b/docs/dev/QA-DRIVE.md @@ -8,6 +8,72 @@ owns everything they structurally cannot reach._ ## Run ledger — the useful recent history +- **runs 31 and 32, 2026-09-22 — the stripped build, green 0-7 on the box + and the Mac.** Run 31 went red in 5b on both machines: the drive still + expected `exited` after SessionEnd, a status the removal took out, and + the build reported `idle`. The check is back to its pre-restore form + (`f14d40b`). Run 32: box 242 checks, Mac 243, no failure; 6c bound + exactly one row after the relaunch on both, and the two Alt+c presses + made one ask each. The Mac's first run 32 attempt went red once in 5: + the five rapid presses settled one flip short (store `collapsed=false`, + expected `true`); the rerun passed, and the box passed 5 in every run. + Open: an intermittent lost press in a rapid burst on the Mac (FOOTGUNS, + "Two rapid `clave collapse` writes have no arrival order"). Before the + run, the Mac's 5b fixture clash was cleared: a dead sandbox's fixture + transcripts under `~/.claude/projects` sorted ahead of this worktree's + own; they are moved to `~/.local/state/clave-qa-quarantine`, not deleted. + +- **run 30, 2026-09-22 — box, phase 2 green after the add-order fix; 6c red + again.** Three asks for two presses: the owner bar never emitted the + re-anchor, so the beacon sat on the last tab built and the first tab's bar + answered a press it did not own. Decision: the live-set restore is removed + (commit `9670fbf`); 6c now verifies one eager tab and a dormant fleet. + +- **runs 26 to 29, 2026-09-22 — four reds, three of them the drive's own, + and a host race the box lost every time.** Run 26 (box) went red in 6c on + the second shape of the beacon fix: three asks for two presses, because a + drain of every owed row on emit answered tab 3's steal after tab 4's open + had gone out (FOOTGUNS, "A claim armed by an event"). Run 27 (box) died + in preflight: the launch line printed before the detached stage had run, + and the seed deleted the launch.kdl eight seconds after the launch. The + stage now runs attached and the launch line follows it. Runs 28 and 29 + (box) went red in phase 2 rung 1: `clave add` created the tab first and + recorded the row after, the newborn's `clave spawn` registered its pane + id into a store with no such row, and `pane_id` stayed null for the life + of the row. The host log shows it in order: `spawn: Create` then `add: + recorded`, same second. The Mac won that race in every run and never + showed it. The record now precedes the open (`add.rs` + `record_then_open`). The Mac drive of the same commit passed phase 2 + and went red in 5b on a fixture collision: a sibling sandbox's leftover + transcript shares the scenario uuid, sorts first in the drive's glob, and + had an unclosed launch from run 25 inside the six-hour bound. That one is + the Mac's, not the code's; the box has no sibling sandboxes. + +- **runs 24 and 25, 2026-09-22 — the Alt+c flap on a restored fleet, caught + by a new 6c check, fixed, green on both machines.** Run 24 on the devbox + (232 checks) and run 25 on the Mac (233 checks), 0 failures each. After + the relaunch verdict, 6c now presses the toggle twice in the tab the + restore left focused, with NO beacon anchor, and asserts one width ask per + press from that tab's bar, painted by that bar. Before the fix the beacon + ended every restore on the LAST tab built: the first tab's bar asked + nothing, the last tab's bar asked, and zellij applied each ask to the + first tab. Phase 5 never saw it because it pipes the beacon before it + presses. The trace on both hosts: one `swap-width` line per press from + the standing tab's instance, its `painted` line about 30 ms later, no + other instance asking. + +- **run 23, 2026-09-22 — the FIRST REMOTE DRIVE, on the devbox, twelve + phases green, 224 checks, 0 failures.** `just remote-qa qa-fleet` from a + Mac worktree; the box has `pane_frames false` and zellij 0.45.1, which no + Mac sandbox has. The new width assertion held: zero asks at launch, zero + across both ring-walk legs over six live tabs, one ask per press in the + collapse burst (sixteen for eighteen presses, alternating direction, one + instance). The first attempt died in phase 1 because `ct.sh` looked for + the sockets under `/tmp`; on Linux they are under `$XDG_RUNTIME_DIR` + (FOOTGUNS). The launch must come from a Mac terminal that is NOT a + zellij pane: zellij 0.45 detects the outer session through the terminal + and offers a nesting dialog instead of the fleet. + - **run 22, 2026-09-17 — TWELVE phases green, 237 checks, 0 failures.** The first fully green drive on `worktree-live-set-restore` (#261), and the first in which `restored rows were bound before their agent ran (tab, no pane)` @@ -17,9 +83,9 @@ owns everything they structurally cannot reach._ "Too many open files". Run 21 measured 2 of 4 awake. The fix was to stop inferring which tab drives the restore and have the launch name it. - **Two phase-6c runs timed out** waiting 30 minutes for the second launch, - costing a pair of launches each. `scripts/qa/relaunch-verdict.sh` exists for - exactly that: it runs 6c's verdict on its own against a sandbox still in the - pre-quit state. Capture the bound set BEFORE asking for the quit. + costing a pair of launches each. A standalone verdict script existed for + exactly that: it ran 6c's verdict on its own against a sandbox still in the + pre-quit state (deleted with the restore, 2026-09-22). - **A fixture that stages nothing passes.** `dev scenario relaunch-restore` had been silently staging no restore at all since `bound_since_launch` landed. `clave dev scenario` now prints how many rows the next launch will @@ -127,7 +193,7 @@ loudly and stops the run; later phases assume earlier truth. | 5c | **Terminal facts (OS side)** | a real `sleep` typed into a plain shell tab, behind a shell allowlist, while that tab is focused; then focus moves to an agent tab with the command still running | leg A: at least one sandbox bar learns the change — the OS-facts pipeline (`get_pane_cwd`/`get_pane_running_command` → `apply_pane_facts` → the terminal row) delivers end to end, which nothing tested before. Leg B MEASURES whether a second instance learns it from another tab, the open question under the store-backed fix, and asserts nothing: the facts are per-instance today, so "every bar agrees" is not yet true and a drive must not go red on a known-open defect | the 2026-09-12 flicker (a terminal row with facts under one tab and none under another), #206, #239 | | 6 | Quiescence | idle 60s | evlog and store `seq` flat; zellij log flat after the mark for sandbox-attributable lines only (the shared log is never globally flat with a live maintainer fleet — see Delivery accounting) | P17, B19/B20, drive step 6 | | 6b | **Isolation witness** | nothing (reads this run's own evidence) | zero `push-refused` events across the run — no push was aimed at a bar that does not own this store; the ambient zellij identity is STILL the sandbox's at the END of the run, not just at the start; the inherited session's name appears nowhere as a push target | FOOTGUNS #281, the 2026-09-11 incident | -| 6c | **Relaunch (the second launch)** | quit the sandbox session, then ask the maintainer to `just launch` it again | the restored set comes back the SAME SIZE, and holds the same uuids, after a first session in which only ONE tab was visited; EVERY restored row carries a `tab_id` in the store, and each row except the first carries it BEFORE its agent runs — the first is the eager one, which starts at launch, so the timing half of that assertion applies only to the held rows; a tab closed in session N is absent in session N+1. This is the only phase that reads what the previous session recorded, so it is the only one that can see the live set decay | the relaunch seam (TESTING.md's escape record); #261's decay | +| 6c | **Relaunch (the second launch)** | quit the sandbox session, then ask the maintainer to `just launch` it again | exactly ONE row is bound after the relaunch — the most-recent row, the eager tab the launch bakes — and every other row is dormant: no `tab_id`, no `pane_id`, and no stale `Working` or `NeedsYou` carried over from the first session. Then the beacon leg: two `clave-toggle` presses in the eager tab, with NO anchor pipe first. Each press is ONE width ask, from that tab's bar, painted by that bar | the relaunch seam (TESTING.md's escape record); the one-eager-tab rule, commit `9670fbf` | | 7 | Teardown | nothing | prints the kill pair (the agent may run it once both eyeballs are in) | drive step 9 | **Why 5b could not catch the 2026-09-14 red-glyph defect.** Its event @@ -196,8 +262,8 @@ the stable binary (FOOTGUNS, 2026-08-24). one-command loop. The drive carries **twelve** now: 5c joined on 2026-09-12 and 6c on 2026-09-16. **Run 12, 2026-09-16** drove all twelve: phases 0–6b green, and 6c went red on its first complete run, on a real - defect — the live set lost the one row whose agent was genuinely running - (see the handoff for the mechanism). That is the phase doing its job on + defect — the restore of the day (since removed) lost the one row whose + agent was genuinely running. That is the phase doing its job on the first attempt at a class nothing else could see. **Run 16, 2026-09-16, is the first ALL TWELVE green**, with the `SessionEnd` fix in and phase 6c closing its own tab. Runs 13–15 sit between them and are worth reading as @@ -206,8 +272,8 @@ the stable binary (FOOTGUNS, 2026-08-24). asserts the witness is there, which is what ended that. **Runs 17 and 18, 2026-09-16**, followed the second swarm review. 17 went red at phase 5b on the DRIVE, not the product: the phase ends a session two - lines above the check and then asserted `idle`, which since `Status::Exited` - is the reading for an agent that is still alive. 18 is green in all twelve, + lines above the check and then asserted `idle`, which under the restore's + `Exited` status (since removed) was the reading for an agent that is still alive. 18 is green in all twelve, and is the run that proves both swarm-review blocker fixes live — five rows recorded, five rebound by the bar with nothing driven, focused or typed, four of them bound before their agent ran, and the tab closed in the first @@ -245,41 +311,84 @@ the stable binary (FOOTGUNS, 2026-08-24). phase runs inside one session, which is exactly why the live-set decay (#261) reached a shipped branch with all gates green and a full drive behind it. The phase is cheap — quit, relaunch, count — and it is the only automated look at -state that crosses a session boundary. Do not fold it into phase 1 by staging -a pre-bound fixture: staging the binds is what makes the first launch pass -without ever proving the first session could have RECORDED them. +state that crosses a session boundary. + +What it asserts (2026-09-22, after the live-set restore was removed, commit +`9670fbf`): a relaunch bakes ONE tab, for the most-recent row. So after the +second launch exactly one row is bound, every other row has no `tab_id` and no +`pane_id`, and no row carries a `Working` or `NeedsYou` from the first +session. Then two `clave-toggle` presses in that one tab, each answered by +exactly one width ask from that tab's bar. Both halves read the store and the +log; nothing is driven between the relaunch and the reading — no focus, no +nav, no keystroke — so the reading is the launch's own work. -Three things about how it is built (2026-09-16), each of which a later edit -could undo without any test noticing: +Two things about how it is built, each of which a later edit could undo +without any test noticing: - **It kills nothing and launches nothing.** It prints the pair and waits for liveness to drop and return. Session lifecycle stays the maintainer's, and - `script_hygiene.rs` now fails the build for any line in the drive that - starts or ends a session. -- **The verdict lives in `scripts/qa/lib.sh` (`relaunch_checks`), not in the - phase.** The selftest runs it against a store that decayed and requires it - to go RED. A comparison only two maintainer launches could try is a - comparison nobody tries — which is the shape of the defect the phase exists - for. -- **It closes its own tab, right before the ask.** The "a closed tab stays - closed" half needs a row that was unbound at the quit. Naming the tab phase - 3 closed does not give one: phase 4 wakes the top wakeable dormant row, and - that is the row phase 3 just made dormant, so the drive re-opened its own - closed row and then demanded the restore leave it out (run 13, 2026-09-16). - Nothing runs between this close and the quit, so nothing can wake the row. - The phase checks the prune landed BEFORE it records the set; if it has not, - the run stops there rather than asking for a launch it cannot read. -- **The readings are non-vacuous because a launch CLEARS the binds** before it - bakes the layout, and records the set it cleared into `last_live` on the - same pass (`setup.rs` `clear_session_order`). So every tab id read after the - relaunch was made by the second session, and the store carries its own - expectation. Neither fact is incidental; if either changes, this phase is - measuring nothing. - -**Nothing is driven between the relaunch and the reading** — no focus, no nav, -no keystroke. The whole defect was that a restored row bound only when the -maintainer landed on its tab, so a drive that touched a tab first would hide -exactly what it came to see. + `script_hygiene.rs` fails the build for any line in the drive that starts + or ends a session. +- **The readings are non-vacuous because a launch CLEARS the binds** before + it bakes the layout (`setup.rs` `clear_session_order`). So every tab id read + after the relaunch was made by the second session. If that changes, this + phase is measuring nothing. + +The verdict lives in `scripts/qa/lib.sh` (`relaunch_checks`), not in the +phase, so the selftest can run it against a store with two bound rows and +require it to go RED. + +## The remote drive (a second machine, over ssh) + +Ratified 2026-09-22, after the v0.5.2 rollout: the devbox showed a fleet of +regressions the Mac never did — the width flap needed `pane_frames false`, +which the box has and the Mac does not; the restore defects needed the +box's Claude Code daemon. Every one was found by the human at the box and +diagnosed by an agent reading the box's log over ssh. So that is now the +loop, in one command: + +``` +just remote-qa qa-fleet # push HEAD, stage on the box, wait, drive +just remote-log 60 # the box's zellij log tail +just remote-drive-log 60 # the newest drive log on the box +just remote-kill # the box SANDBOX, by exact name +``` + +`scripts/remote-qa.sh` is the whole mechanism. It pushes this HEAD to a +plain git checkout on the remote (`~/code/clave-qa` by default; the repo +accepts a push onto its checked-out branch), then runs the SAME `just qa` +there, in the remote's own environment — its zellij config, its frames +setting, its Claude Code. There is no second drive and no remote-only +phase: one code path. The remote sandbox is `clave dev instance` on the +remote, and the remote's live fleet is untouched for the same reasons the +local one is. `CLAVE_QA_HOST` and `CLAVE_QA_DIR` pick the machine. + +The human's one job is unchanged: launch. The line is + +``` +ssh -t devbox 'cd ~/code/clave-qa && just launch' +``` + +and it works from ANY terminal, inside zellij or not, because ssh forwards +none of the zellij variables that make `just launch` refuse. Phase 6c's +second launch is the same line after the quit. + +**Reading a remote sandbox is not touching the remote fleet.** The zellij +log is a file (`/tmp/zellij-/zellij-log/zellij.log` on Linux), the +drive log is a file under the remote sandbox state dir, and the store is a +file. `just remote-log` and `just remote-drive-log` read those and nothing +else. A `zellij action` typed into an ssh shell on the box aims at whatever +session that shell sits in — the human's `remote` session — so nothing here +runs one outside `ct.sh`, which the drive already routes through. + +**The frames seam has an assertion now.** The shipped bar logs one line per +width ask (`clave-bar: swap-width backwards=… cols=…`, main.rs `render`). +Phase 1 records the launch's ask count per sandbox instance; phase 4 asserts +ZERO asks across both ring-walk legs, because a walk toggles nothing and a +bar at its painted width asks nothing — the flap asked on every focus +change. The reading is per LINE (`swap_ask_count_since`, lib.sh), not per +instance: a flap is one instance asking sixteen times, and an instance +count reads that as 1. ## When it runs @@ -303,6 +412,6 @@ exactly what it came to see. 4. Runbook/TESTING integration line + retire the duplicated manual steps. 5. Phase 6c (the relaunch). LANDED (2026-09-16, #261). Driven live on run 12 the same day: the settle window held for a six-tab fleet, and the phase - went RED on a real defect — `SessionEnd` unbound the row from a tab that - was still open, so the restore set lost every tab whose agent had really - been running (fixed on this branch; FOOTGUNS records the shape). + went RED on a real defect in the restore of the day. The restore itself + was removed on 2026-09-22 (commit `9670fbf`); 6c now verifies one eager + tab and a dormant fleet. diff --git a/docs/dev/TESTING.md b/docs/dev/TESTING.md index cbe9f78..7330646 100644 --- a/docs/dev/TESTING.md +++ b/docs/dev/TESTING.md @@ -143,7 +143,7 @@ before you ask for a merge. | **External-format parsing** | jsonl tail scanners, hook payloads | + a captured (not invented) fixture, and a dated measurement that the shape still exists in the field | — | | **CLI surface** | new subcommand or flag | + `Cli::try_parse_from` pin + one sandboxed end-to-end run in a **debug** build (clap's `debug_assert` only fires there) | — | | **Cross-process / IPC** | pipes, plugin shellouts, multi-writer store paths | + written argument for ordering/idempotency in the PR dossier; adversarial reviewer must attack it; tier-2 coverage once #47 lands | — | -| **Across a relaunch** | anything one launch writes and the next launch reads — `last_live`, the baked layout, session-scoped state that is cleared at launch | + name every field the change reads and say whether it is session-scoped or agent-scoped; + a unit test for the SECOND launch (what does this record when the first session did nothing with it?); + a drive that quits and relaunches, or a stated hand-check that it did. **One launch cannot test this class** | `needs-live-validation` | +| **Across a relaunch** | anything one launch writes and the next launch reads — the eager row the launch bakes, the session-scoped state (`tab_id`, `pane_id`, `Working`/`NeedsYou`) that the launch clears | + name every field the change reads and say whether it is session-scoped or agent-scoped; + a unit test for the SECOND launch (what does this record when the first session did nothing with it?); + a drive that quits and relaunches, or a stated hand-check that it did. **One launch cannot test this class** | `needs-live-validation` | | **Install / environment** | release mechanics, dev-install, `PATH`, doctor | + fresh-environment reasoning; assume nothing about the maintainer's machine | `needs-live-validation` | | **Visual / UX** | glyphs, colours, widths, fonts | human judgement only | `host-untestable` | @@ -205,6 +205,7 @@ reached `main` or the field. | `Alt+w` close stranded Alt-↑/↓ nav until a mouse click (#23) | live sessions only | the beacon/anchor relationship only exists once real tabs open and close | `Effect::ReanchorVisit`, executor-gated; tier 2 will assert it (#47 first scenarios) | | `CliPipe did not complete within 1s` + empty-payload deliveries (#45) | present since the log's first line, v0.1.0 era; buried the real evidence during the v0.1.1 incident | no tier reads the zellij log; nothing asserts on pipe delivery | nothing yet — it is filed. Observability discipline (below) is the only detector | | `clave-organic` dead on arrival: the empty-payload guard special-cased only `clave-toggle`, so the payload-less organic pipe never reached its named match arm (#128) | live only: the Alt+o beacon never announced, so a departed bar kept `cursor` and `current_tab == own` **indefinitely** — an `Alt+Enter` 18 s after the switch still opened the pre-switch selection; the #100 commit-race fix keyed off this dead arm and was dead with it | the model test called `set_organic_pending` directly (green); `main.rs` is `test = false`, so nothing routes a real payload-less `PipeMessage` — the adapter seam is unmodelled, the classic pattern of this table | the payload-less branch now matches names (toggle AND organic); caught by the sandbox drive loop's re-validation of the very fix that depended on it — the checklist §5 beacon-gap item is the standing detector | +| (The six #261 rows below are history: the live-set restore was removed on 2026-09-22, commit `9670fbf`, FOOTGUNS "The restore that sequenced tabs through the bar". The seam they teach is still real.) | | | | | Restored tabs rendered as TERMINAL rows, each agent shown a second time as dormant (#261) | the first live launch of the relaunch feature | every row-to-tab question read the store's bind, and a restored tab is by definition the state where the spawn exists and has not run, so no bind can exist. Green suite, because no fixture held a tab whose command had not run | `model::spawn_binds` joins on the pane's launch command; fixtures now hold cold tabs. Found by the maintainer looking at a screenshot | | A relaunch started the wrong agent (#261) | same launch | `last_live` is written in ascending tab id, i.e. creation order; the layout baked it in that order and focused the first. The fixture staged recency IN AGREEMENT with tab order, so a bake that ignored the rank passed | `setup::restore_rows` ranks before baking; the fixture now stages recency AGAINST tab order. Found by the maintainer asking whether the order was right | | The restore ranking reproduced only the inner layer of double-layer frecency (#261) | would have started the wrong agent whenever one repo held several middling rows | the rule had two homes. Both were tested, separately, and each was self-consistent | `clave_types::sort_live_block`, one home. Found by CodeRabbit | @@ -233,9 +234,9 @@ Three rules follow, and they are cheap: construction, so a unit fixture must. 3. **Drive every leg the SHELL runs on one pass, not one leg alone.** #261's restored bind had a retry cap and two tests pinning it, and the cap did - nothing: `settle_identity` calls `restored_bind_effects` and then - `identity_effects`, and `bind_effects` inside the second one clears the - shared ledger for exactly the uuids the first one writes. Both tests passed + nothing: `settle_identity` called the restore's bind leg and then + `identity_effects`, and `bind_effects` inside the second one cleared the + shared ledger for exactly the uuids the first one wrote. Both tests passed because each called one function. Measured at 12 subprocesses against a budget of 4. **When a test calls one model function, ask what the shell calls on the next line** — and if the answer touches the same state, the @@ -626,7 +627,7 @@ where it came from: | What could change silently | What that costs in the field | Held by | |---|---|---| | `apply_relocation`'s whole body | a session whose worktree moved keeps the dead path, so the next open goes ✗ stale | `relocation_repoints_the_row_and_only_touches_branch_when_told` | -| `moved_site`'s anchor branch (head cwd keys the file) | every session that entered a worktree mid-conversation refuses to resume at the next launch — the first tab dies naming the transcript, and the staggered restore waits on it | `a_session_that_walked_into_a_new_dir_resumes_where_its_file_is_keyed` | +| `moved_site`'s anchor branch (head cwd keys the file) | every session that entered a worktree mid-conversation refuses to resume at the next launch — the first tab dies naming the transcript | `a_session_that_walked_into_a_new_dir_resumes_where_its_file_is_keyed` | | the launch bake taking `row.cwd` bare, or an unguarded substitute | launch and open bake different dirs for the same row, so a relaunch dies where a click resumes; a relocation target with a `"` in it fails the session create | `a_pane_is_born_where_the_conversation_went_else_at_the_row`, `the_launch_bakes_the_pane_cwd_only_when_it_passes_the_kdl_guard` | | `read_store`'s `NotFound` guard, widened | an unreadable store reads as empty, and `with_store_mut` renames an empty one over it — the fleet is gone | `an_unreadable_store_is_an_error_and_is_never_written_over` | | `apply_prune_tabs`' `seq` bump | every bar discards the prune push and re-fires the subprocess forever, while the closed tab's agent still renders live | `prune_tabs_removes_listed_stale_ids_order_safe_and_change_gated` | diff --git a/docs/status/2026-09-22-1200-devbox-width-flap.md b/docs/status/2026-09-22-1200-devbox-width-flap.md new file mode 100644 index 0000000..8cc2a3e --- /dev/null +++ b/docs/status/2026-09-22-1200-devbox-width-flap.md @@ -0,0 +1,39 @@ +# Task Pickup + +You are picking up a session that cut v0.5.2 (the anchored-transcript +restore, #267, plus the collapse-step fix #268), installed it on the Mac and +the devbox, and then measured the devbox width flap with a diagnostic bar. +The cause is measured and cited. The fix is WRITTEN on branch `fix/bar-separator-column` (off main): `RowHeight::mode_at` in clave-types, three call sites in model.rs, one new test, four tests repointed from 47 to 46, FOOTGUNS entry. Gates were being run at handoff time; not committed. + +## Orientation + +- v0.5.2 is tagged, released and installed on both machines. The Mac fleet is fine. | **Checked** | `gh release view v0.5.2`; `ls ~/.local/share/clave/bin/` +- The devbox runs a DIAGNOSTIC 0.5.2 bar: the release bar plus `eprintln!` lines on every width ask. The release file is beside it as `.orig`. Rollback: `ssh devbox 'cp ~/.local/share/clave/clave-bar-v0.5.2.wasm.orig ~/.local/share/clave/clave-bar-v0.5.2.wasm'` after the fleet is down (`box down`). The sandbox refuses this copy for the agent; the human runs it. | **Open** | `ssh devbox 'ls -la ~/.local/share/clave/clave-bar-v0.5.2.wasm*'` +- The flap, measured 2026-09-22 11:19 on the devbox log: every bar is born at 48 and wants 48, but the host paints it at **47** (and 15 for 16). The model compares painted width to the target EXACTLY, so it asks for a swap it does not need; the swap walks the tab through the collapsed geometry, and the walk budget resets on every focus change, which the restore causes six times in three seconds. | **Checked** | `ssh devbox 'grep "DIAG ask" /tmp/zellij-1000/zellij-log/zellij.log'` (16 asks, all `cols=47` or `cols=15`) +- Why 47: with `pane_frames false`, zellij reserves one column on any tiled pane that is not at the viewport's right edge, for the separator line, and it does so for borderless panes too. Same rule in 0.44.3 and 0.45.1 (`zellij-server/src/panes/tiled_panes/mod.rs`, `pane_content_offset` and the last `else` arm of `set_pane_frames`; copies in `/tmp/zj/tiled-v0.44.3.rs:556-612` and `/tmp/zj/tiled-v0.45.1.rs:48-70,610-700`). The devbox has `pane_frames false`, and since #262 (v0.5.1) that setting passes through into clave's generated config. So the flap began with v0.5.1, not with zellij 0.45.1. The Mac has frames on and never lost the column. | **Checked** | `ssh devbox 'grep pane_frames ~/.local/share/clave/config.kdl'` +- The fix (written, see above): the bar must judge which mode a painted width IS by the nearest declared width, allowing the one separator column, at all three comparison sites in `crates/clave-bar/src/model.rs`: the birth guess (`width_effects`, ~3653), the at-target check (~3690), and `widths_at` (~2980). One helper, one constant for the separator column with the zellij citation, red-first tests: painted 47 wanting expanded asks nothing; painted 15 wanting collapsed asks nothing; birth guess at 47 is expanded; `widths_at(15)` while awaiting hydration is COLLAPSED; a toggle from 47 still asks. | **Open** | `sed -n 3639,3712p crates/clave-bar/src/model.rs` +- The branch `diag/width-flap` in this worktree holds the diagnostic `eprintln!` lines only (8 lines, model.rs and bar main.rs). They must NOT ship. The fix goes on a fresh branch off `origin/main`. | **Checked** | `git diff --stat v0.5.2` +- Second defect seen on the devbox, not clave's: the first restored tab refuses with "Session … is running as a background session". Claude Code 2.1.278's daemon holds that conversation as a background session; clave's spawn runs `--resume` and Claude refuses. clave should attach (`claude attach `) when the live session is held by the daemon. Not started. | **Open** | `ssh devbox 'ps -eo args | grep [b]g-pty-host'` +- Third gap, from the Mac: a session whose restore stalled, where the human opened one tab by hand, overwrites the recorded live set with that one tab. The guard in `store::clear_session_order` covers a session that bound NOTHING only. Not started. | **Open** | `sed -n 985,1015p crates/clave/src/store.rs` +- Fourth, the Mac: a trackpad scroll over the bar panics the plugin inside `register_plugin!` (bar main.rs:142, "NO PAYLOAD"). Unmeasured; likely a mouse event the 0.44.3 tile crate cannot decode from a 0.45.1 host. Not started. | **Open** | screenshot in this conversation only + +- Fifth, the devbox: Alt+Up / Alt+Down stopped changing tabs. Evidence: the binds ARE in the box's generated config (`config.kdl:13-14`, `clave-nav` next/prev); `nav landed` lines appear at 11:03 (worked, 0.5.2 release bar) and ONCE at 12:01:46 (`prev`, instance id 7) and never after, so later presses did not reach any bar: the pipe is not arriving, which points at zellij's input mode or the key reaching zellij over ssh, not at the bar's model. Not started. | **Open** | `ssh devbox 'grep "nav landed" /tmp/zellij-1000/zellij-log/zellij.log | tail'` + +- Nav update from the human (12:10): Alt+Up/Down DO work on the box but inconsistently, and they SKIP the first tab opened, which is the baked tab whose spawn died on the background-session refusal. Hypothesis to measure, not yet measured: a tab whose spawn exits before `clave register` never binds its row, so nav (which walks bound rows) cannot land on it, and the focused bar's own bind in a fresh tab lags a frame or two (`BIND STALLED` lines). The human's read: the restore is the main breakage; the Mac feels fine. | **Open** | `ssh devbox 'grep -E "BIND STALLED|bind leg" /tmp/zellij-1000/zellij-log/zellij.log | tail -20'` + +## The human's standing requirement (2026-09-22) + +"We need to know where all these regressions are showing up so that our automated QA testing can pick them up, and be able to test both locally and over ssh. Even if we spin up a local ssh test environment as part of the automated QA system." Concretely, the drive (`docs/dev/QA-DRIVE.md`) must gain scenarios for: a cold restore of six-plus tabs; the width machine with `pane_frames false` in the sandbox config (the devbox shape); the nav keybinds after a restore; a resume whose live session is held by Claude's daemon; and a way to run the same drive against a session reached over ssh (loopback ssh to this machine is enough to exercise the key-encoding and pty seam). Each regression above names the seam it crossed; put the scenario at that seam. + +## Next Steps + +1. On `fix/bar-separator-column`: `just gates` green, then commit (`fix(bar): a painted width one short of the target is at the target`), `just mutants` over the diff, TESTING.md row. +2. FOOTGUNS entry: the separator column, with the zellij citation and the 2026-09-22 measurement. UBIQUITOUS_LANGUAGE if a term is minted. TESTING.md row. +3. Opus subagent review, then `coderabbit review --committed --base main --agent`, fix findings, PR from the template, the human's go. +4. Cut v0.5.3, install on both machines (devbox: `box down`, restore the `.orig` bar first, then the installer and `~/.cargo/bin/clave setup` over ssh, then `box`). +5. Then the background-session attach, the live-set gap, the scroll panic, in that order. + +## Context for the Work + +- The human launches and kills every session. On the devbox he types `box` (launch) and `box down` (kill); the `remote` session is his ssh shell and stays. The agent may not overwrite files under the devbox's `~/.local/share/clave/`; print the command. +- Bring log lines, not theories. The diagnostic bar is how this was settled. diff --git a/docs/status/2026-09-22-1214-devbox-width-flap.md b/docs/status/2026-09-22-1214-devbox-width-flap.md new file mode 100644 index 0000000..0630701 --- /dev/null +++ b/docs/status/2026-09-22-1214-devbox-width-flap.md @@ -0,0 +1,102 @@ +# Task Pickup + +You are picking up this work session from a prior agent that cut and rolled out clave v0.5.2 (the anchored-transcript restore #267 and the collapse-step fix #268), then chased a fleet of regressions the human saw on the devbox after the cut. One of them, the width flap, is measured, cited, fixed and committed on a branch, not yet reviewed or pushed. Four more are named with evidence and not started. The human has also set a standing requirement: the automated QA drive must cover the seams these regressions crossed, locally and over ssh. + +## Orientation + +- The fix branch is `fix/bar-separator-column`, off `origin/main` (b403e81), three commits, tree clean, `just gates` green (446 host + 345 bar). Reviews not run. Not pushed. | **Checked** | `git log --oneline origin/main..HEAD; git status --short` +- The width flap's cause: with `pane_frames false`, zellij reserves one column of any tiled pane not at the viewport's right edge, borderless or not, for the separator line. The bar (left pane) paints at 47/15 for 48/16. The model compared exactly. Same zellij rule in 0.44.3 and 0.45.1. The devbox has frames off; the Mac has them on. It started with v0.5.1 (#262 passed `pane_frames` into clave's config), not with zellij 0.45.1. | **Inherited** | cost a diagnostic-bar launch on the devbox plus two source fetches. `/tmp/zj/tiled-v0.44.3.rs:556-612`, `/tmp/zj/tiled-v0.45.1.rs:48-70,610-700` (fetched copies; refetch from GitHub if gone). Log: `ssh devbox 'grep "DIAG ask" /tmp/zellij-1000/zellij-log/zellij.log'` +- The devbox still runs the DIAGNOSTIC 0.5.2 bar (release bar + `eprintln!` on every width ask). The release file sits beside it as `.orig`. The agent's sandbox refuses to overwrite files under the box's `~/.local/share/clave/`; the human runs the copy. | **Open** | `ssh devbox 'ls -la ~/.local/share/clave/clave-bar-v0.5.2.wasm*'`; rollback after `box down`: `ssh devbox 'cp ~/.local/share/clave/clave-bar-v0.5.2.wasm.orig ~/.local/share/clave/clave-bar-v0.5.2.wasm'` +- The diagnostic lines live only on branch `diag/width-flap` in this worktree (one WIP commit). They must not ship. | **Checked** | `git diff --stat v0.5.2 diag/width-flap -- crates` +- The human reaches the devbox with `box` (launches the fleet over ssh) and `box down` (kills it). The `remote` zellij session on the box is his ssh shell; never kill it. Both sessions run the same zellij 0.45.1 and only `clave` loads the bar. | **Checked** | `sed -n 644,690p ~/.aliases`; `ssh devbox 'zellij list-sessions'` +- Installing a release on the devbox: the installer puts the binary at `~/.cargo/bin/clave`, but the box's PATH puts the versioned launcher first. The upgrade only lands after `~/.cargo/bin/clave setup` (or one launch by full path), which rewrites config and launcher. Run it only while the fleet is down: zellij live-watches the config. | **Checked** | `ssh devbox 'ls -la ~/.local/share/clave/bin/'` +- `just release` runs from a worktree checkout of the tagged commit; the agent's sandbox allowed it once and refused it once. Tags are shared across worktrees. The gate refuses untracked paths outside `docs/` and `.claude/`; `.claude-worktrees/` is now ignored (#270). | **Checked** | `sed -n 270,300p crates/clave/src/release.rs` +- Claude Code 2.1.278 on the box has a daemon holding a conversation as a background session; `claude --resume ` on it prints "running as a background session" and exits. clave's spawn does not handle this. `claude attach ` gets it back with nothing lost. | **Checked** | `ssh devbox 'ps -eo args | grep [b]g-pty-host'` +- On the box, after the restore, Alt+Up/Down work inconsistently and skip the first (baked) tab, whose spawn died on the refusal above. The store has that row bound (tab 0, pane 14); two restored rows have a tab but no pane recorded. Hypothesis, unmeasured: nav walks rows it counts as live and a row whose pane exited does not count. | **Open** | `ssh devbox 'grep -E "nav landed|BIND STALLED" /tmp/zellij-1000/zellij-log/zellij.log | tail -20'`; store: `~/.local/state/clave/agents.json` on the box +- On the Mac, a session whose restore stalled and where the human opened one tab by hand overwrites the recorded live set with that one tab. The guard in the store covers a session that bound NOTHING only. | **Checked** | `sed -n 985,1015p crates/clave/src/store.rs` +- On the Mac, a trackpad scroll over the bar panics the plugin inside `register_plugin!` (bar main.rs:142, "NO PAYLOAD"). Likely a mouse event from a 0.45.1 host the 0.44.3 tile crate cannot decode. Unmeasured. | **Open** | reproduce in a sandbox, read `$TMPDIR/zellij-501/zellij-log/zellij.log` +- The "Action CliPipe did not complete within 1s timeout" and "dropped … pipe with empty payload" log lines are noise on every version. | **Inherited** | previous handoff, `docs/status/2026-09-21-1700-swap-width-fix.md` + +**Method.** Put a diagnostic bar into the live fleet and read the log when a sandbox cannot reproduce; one launch settled what eight source diffs did not. Fetch the zellij server source from GitHub for the path a symptom names, both versions, and stop when they agree. Repoint an existing test one failing assertion at a time from its actual value. + +**Proof.** `just gates` green in the worktree, and `cargo test -p clave-bar a_painted_width_one_short` passes while it fails with the exact comparison restored. + +## The remote loop (added 14:35, same day) + +- `just remote-qa ` pushes HEAD to `devbox:~/code/clave-qa` (a plain repo, `receive.denyCurrentBranch updateInstead`), stages there, and runs the same drive detached; `remote-qa.sh qa-log`, `just remote-log`, `just remote-drive-log` read it back; `just remote-kill` kills the box SANDBOX (`clave-test`) by name. | **Checked** | `sed -n 1,40p scripts/remote-qa.sh`; docs/dev/QA-DRIVE.md "The remote drive" +- Run 23: the first remote drive, all twelve phases green on the box, 224 checks, this branch's bar, frames off. The width assertion (phase 4, zero asks across the ring walk; `swap_ask_count_since` in lib.sh over the shipped `clave-bar: swap-width` line) passed. | **Checked** | `./scripts/remote-qa.sh drive-log 40` +- The human's launch line, EXPLICITLY from a Mac terminal window that is not a zellij pane: `ssh -t devbox 'cd ~/code/clave-qa && just launch'`. From a clave tab, zellij 0.45 shows a nesting dialog instead. | **Checked** | this session, 14:08 +- The agent's sandbox permits: ssh reads of files, `git push` to the box, remote `cargo`/`just` runs, and killing the box sandbox by name. It refuses `scp` of binaries and any `zellij` command against the box's live sessions. Work through the wrapper. | **Checked** | the denials in this session +- After run 23 the human pressed Alt+c in the box sandbox and reported "it flaps". The log says otherwise: bar id 5 (build b8f1ba4) asked THREE times, `backwards=true cols=47`, 2.5s apart (14:40:50-55), then rested on its budget; `cols` never moved, so the swap NEVER LANDED. In the drive's burst (tab 7, a fresh `clave open` tab) every ask landed: cols alternated 47/15. So this is "a swap the tab refuses", not the old exact-comparison flap. Unmeasured: which tab he stood in (the dump shows the first, baked tab focused), and whether that tab's pane count matches the swap layouts' (zellij applies a swap layout only to a tab whose pane count fits it; a restored tab with an extra pane would refuse silently). The DIAG asks at 14:41:43 and 14:42:22 are the LIVE fleet's diag bar reacting to his nav there (beacons 1 and 8); ids 1-5 collide across the two servers. | **Open** | `ssh devbox 'grep swap-width /tmp/zellij-1000/zellij-log/zellij.log | tail'`; the dump: `ssh devbox 'cd ~/code/clave-qa && ./scripts/ct.sh dump-layout'` +- Retry at 14:45:58-46:03, same shape exactly: id 5, three asks `backwards=true cols=47`, 2.5s apart, no landing. He stood in the FIRST tab (`qa-fleet-quiet`, the baked one, bar expanded at 48 in the screenshot). His words: "collapses randomly, or doesn't respond to alt+c, or hides completely for a fraction of a second". The hide is a swap layout being applied and undone, which the ask line cannot see because the bar only logs when IT asks. Both sessions' bars beacon in lockstep (14:44:09 beacon 2, 14:44:11 beacon 8: nine ids), so a nav in one fleet reaches the other's bars — unexplained, measure before trusting. | **Open** | screenshot in the conversation; `grep swap-width` as above +- Next measurement, one round: extend the `swap-width` log line with `tab= active= panes=`, restage with `relaunch-restore` (one launch gives a restored fleet), have him press Alt+c in the FIRST tab and in a fresh tab, read the log. Then the fix is at whichever seam the numbers name. | **Open** | — +- NOT yet done: the mutation run (the drive against a bar WITHOUT the fix but WITH the swap-width log line, to watch phase 4 go red); the frames-off scenario needs no special fixture on the box, it is the box's own config. The other four regressions from the list below are untouched. | **Open** | — + +## Task Overview + +Make the v0.5.2 rollout hold on both machines, and close the regressions the devbox surfaced. The human's read: "the restore system is the main breakage; locally things feel good." His standing requirement (verbatim): "We need to know about where all these regressions are showing up so that our automated qa testing can pick them up, and be able to test both locally and over ssh. Even if we spin up a local ssh test environment as part of the automated qa system." + +## Reference Docs + +- `docs/status/2026-09-22-1200-devbox-width-flap.md` — the earlier handoff from this session; superseded by this file except its Next Steps 4-5 wording. +- `docs/status/2026-09-21-1700-swap-width-fix.md` — the collapse-step fix and the sandbox measurement loop (two-minute round), the CliPipe noise, the hot-reload trap on the box. +- `docs/status/2026-09-21-1200-anchored-transcript-restore.md` — the restore fix's design and review record. +- `docs/dev/QA-DRIVE.md` — the drive the new scenarios go into. `docs/dev/TESTING.md` — the risk taxonomy. +- `docs/FOOTGUNS.md` — new entry "A frameless pane paints one column short" (on the fix branch). +- `crates/clave-bar/src/model.rs` `width_effects` (~3639-3712) and the tests from `a_toggle_asks_on_the_first_paint…` (~7990) onward — the width machine and its contract. +- `crates/clave/src/setup.rs` 1400-1612 — `launch_session`, the restore bake and deferral; `crates/clave/src/store.rs` 985-1015 — the live-set rebuild. + +## Current State + +Committed on `fix/bar-separator-column`: +- `crates/clave-types/src/lib.rs`: `SEPARATOR_COLS`, `RowHeight::mode_at`. +- `crates/clave-bar/src/model.rs`: three call sites use `mode_at`; new test `a_painted_width_one_short_of_the_target_is_at_the_target`; four tests moved from 47 to `EXP_W - 2` as their impossible width. +- `docs/FOOTGUNS.md`: the entry. `docs/status/2026-09-22-1200-devbox-width-flap.md`: the earlier handoff. +- Review round (later session, same day): the blind Opus lane found the same exact threshold in the card and double-card renderers (`card.rs` `is_expanded`), which would have parked a frames-off bar at full width drawing the collapsed card. Fixed test-first (`the_card_one_column_under_expanded_is_the_expanded_card`, `the_double_card_one_column_under_expanded_keeps_its_branch`). Added a compile-time assert that each mode's two widths sit more than a separator column apart, a types-crate test pinning the tolerance as one-sided, and rewrote the two model.rs doc comments that still called the comparison an equality. CodeRabbit: zero findings on both passes. Declined: dropping `const` from `mode_at` (the assert above now needs it const) and splitting the new model.rs test (it reads as one scenario). + +Merged to main today: #267 (restore), #269 (0.5.2 bump), #270 (gitignore). Tag v0.5.2 pushed, release published, installed on the Mac and the box. + +## What's Working + +- The anchored-transcript restore works on both machines: a six-tab devbox fleet came up in three seconds with two anchored rows resumed from their birth dir. +- The width fix is small and pure: one helper on `RowHeight`, three comparisons. The whole width machine contract is unchanged; the tests from `a_toggle_asks…` onward are the safety net and all pass. +- The diagnostic-bar loop on the devbox: build `clave-bar` with `eprintln!` lines, scp to `/tmp` on the box, the human copies it over the versioned bar after `box down`, `box`, read `/tmp/zellij-1000/zellij-log/zellij.log`. One round is two minutes. +- The review loop that held today: Opus subagent (blind, given file paths and questions), then `coderabbit review --committed --base main --agent`, then Codex on the PR. Every lane found a real defect on #267. +- The Mac fleet on 0.5.2 is good by the human's word. + +## What success looks like + +Both fleets restore cold and stay at their width, nav walks every tab, and a drive in `just qa` reproduces each of today's seams locally and over loopback ssh so the next regression is caught before a cut. + +## Important Discoveries + +- The devbox flap was never a zellij-version issue and never reproduced in sandboxes because every Mac sandbox has frames on. The "Mac flapped with frames on" note in the previous handoff is unexplained and may have been a different, since-fixed cause (#268); the Mac is fine on 0.5.2. +- `pane_frames false` costs the bar one column. The fix tolerates exactly one; two short is still "neither width" and the walk logic is untouched. +- A restore that stalls, followed by one hand-opened tab, is treated as a real session and shrinks the live set to that tab. That is why the Mac needed two relaunches after the 0.5.1 failures. Rows deferred but never bound should stay in the set. +- The launch bakes the first tab from the store's collapse flag at that moment; the devbox store said collapsed at launch and expanded a minute later (a toggle, or a re-assert) — not root-caused, and moot once the width fix lands. + +## Next Steps + +1. Reviews on `fix/bar-separator-column`: an Opus subagent briefed with `crates/clave-types/src/lib.rs` `mode_at`, the three model.rs sites and the question "can a one-column tolerance make the walk settle at the wrong mode?"; then CodeRabbit; fix; ask the human for the go; PR from `.github/PULL_REQUEST_TEMPLATE.md` via `--body-file`, body ending with the session URL. +2. Cut v0.5.3 (bump commit via PR as #269 was, tag, `just release` from the tagged checkout, launch, tag push last). Devbox: `box down`, restore the `.orig` bar, installer, `~/.cargo/bin/clave setup`, `box`. +3. The QA work the human asked for: a devbox-shaped sandbox (frames off in the sandbox config; six-plus tabs; nav after restore; one row whose spawn dies on purpose; one held by the daemon), runnable locally and against loopback ssh. Put each scenario at the seam its regression crossed. +4. Fix, in order: nav skipping a dead-pane tab; restored rows missing their pane registration; spawn attaching to a daemon-held session (`claude attach`); the live-set loss after a stalled restore; the scroll panic on the Mac. + +Last exchange: the human said "Okay, so the Alt+up and Alt+down do somewhat work, but seriously inconsistent behaviour, and it skips over the first tab opened. I have a feeling the restore system is the main breakage. But locally, things feel good." The agent offered two starts, the review lanes on the width fix or the drive first, and the human had not chosen. + +## Context for the Work + +- The human launches and kills every session. On the box: `box` / `box down`. Never touch `remote`. Never run zellij against his live sessions; the log file is readable. +- The agent may not write under the box's `~/.local/share/clave/`; print the command. `clave setup` over ssh has precedent and was run this session. +- Remote surfaces (push, PR, tag, issue) wait for his go. He gave "push it and open the PR" per PR today; do not assume it carries over. +- Never `clave spawn` or `clave hook` by hand; `scripts/ct.sh --hook` only. +- He does not read the code. Give the decision, what it cost, why. Short sentences. Bring log lines, not theories; he pushed back on unmeasured theories twice on this thread. +- Commits end with `Claude-Session: https://claude.ai/code/session_01VxbdvjsGZN9x4hYyGgdXHL`; PR bodies end with that URL bare. + +## Restart Hint + +Tree clean on `fix/bar-separator-column`, gates green; start with the review lanes unless the human says drive first. The devbox still carries the diagnostic bar. + +## Suggested Skills + +`catch-up` to resume; `superpowers:systematic-debugging` for the nav and register defects; `superpowers:test-driven-development` for every fix; `superpowers:requesting-code-review` before the PR. diff --git a/docs/status/2026-09-22-1458-devbox-width-flap.md b/docs/status/2026-09-22-1458-devbox-width-flap.md new file mode 100644 index 0000000..7e84d3a --- /dev/null +++ b/docs/status/2026-09-22-1458-devbox-width-flap.md @@ -0,0 +1,82 @@ +# Task Pickup + +You are picking up this work session from a prior agent that landed the frames-off width fix on `fix/bar-separator-column`, built the REMOTE QA loop (the drive running on the devbox over ssh), drove it green once (run 23), and then measured a new defect on the box: after a restore, Alt+c in the first tab asks for the collapse three times and the tab never swaps. The human is at the box and can launch; everything else is yours. + +## Orientation + +- Branch `fix/bar-separator-column`, 13 commits ahead of `origin/main`, tree clean, `just gates` green (host 446, bar 347, types 34). Nothing pushed; no PR. | **Checked** | `git log --oneline origin/main..HEAD; git status --short` +- The width fix (`RowHeight::mode_at`, one separator column of tolerance) is in the model AND the card renderer (`card.rs` `is_expanded`); the blind review found the renderer copy. Reviews done: Opus blind lane, CodeRabbit ×2 (0 findings). | **Checked** | `docs/status/2026-09-22-1214-devbox-width-flap.md` "Review round" +- The remote loop: `scripts/remote-qa.sh` {sync,stage,qa,drive,qa-log,qa-running,log,drive-log,instance,kill}; `just remote-qa|remote-sandbox|remote-log|remote-drive-log|remote-kill`. The box checkout is `devbox:~/code/clave-qa`, a plain repo fed by `git push HEAD:main` (updateInstead). Its sandbox is `clave-test` at `~/.local/state/clave-dev` (a MAIN checkout to `dev instance`). | **Checked** | `./scripts/remote-qa.sh instance` +- On Linux zellij's sockets are under `$XDG_RUNTIME_DIR/zellij/contract_version_1/`, the log under `/tmp/zellij-1000/zellij-log/zellij.log`. `ct.sh` follows that rule now. | **Checked** | `ssh devbox 'ps -eo args | grep "[z]ellij --server"'` +- The human's launch line must run from a Mac terminal that is NOT a zellij pane: `ssh -t devbox 'cd ~/code/clave-qa && just launch'`. From a clave tab, zellij 0.45 shows a nesting dialog. | **Checked** | this session, 14:08 +- The agent's sandbox allows: ssh file reads, `git push` to the box, remote `cargo`/`just`, killing the box sandbox by name, `ct.sh` against the box SANDBOX. It refuses `scp`, and any `zellij` against the box's live `clave`/`remote` sessions. `pkill -f` over ssh must use a `[q]a-drive` pattern or it kills its own shell. | **Checked** | the denials this session +- The shipped bar logs `clave-bar: swap-width backwards= cols=` once per width ask (main.rs `render`). lib.sh `swap_ask_count_since` counts them per LINE for sandbox instances; phase 1 measures, phase 4 asserts zero. | **Checked** | `bash scripts/qa/lib-selftest.sh` +- Run 23 on the box (frames off): twelve phases green, 224 checks; launch 0 asks; ring walk 0 asks; burst one ask per press, cols alternating 47/15 (swaps LAND in a fresh `clave open` tab). | **Inherited** | cost two launches by the human; `./scripts/remote-qa.sh drive-log 40` +- After run 23's relaunch, Alt+c in the FIRST (baked, restored) tab: bar id 5 asks `backwards=true cols=47` three times, 2.5s apart, twice over (14:40:50, 14:45:58), never lands, rests on `WALK_ASK_CAP`. The human sees "collapses randomly, doesn't respond, hides for a fraction of a second". The dump shows every tab's bar declared `size=48` and session-level swap layouts `clave_expanded`/`clave_collapsed`. | **Open** | which seam refuses the swap. Candidate: the baked tab's pane set does not fit the swap layouts' pane count, so zellij applies nothing (or applies and reverts). Settle with the extended log line below. +- Both servers' bars beacon in lockstep (nine ids on `beacon 2`, `beacon 8` at 14:44:09-11); ids 1-5 collide across the box's `clave` and `clave-test` servers; the `DIAG ask` lines are the LIVE fleet's diagnostic bar. | **Open** | whether a nav in one fleet really reaches the other (`zellij pipe` target), or ids merely collide. Attribute by build tag only. +- The box's live fleet still runs the DIAGNOSTIC 0.5.2 bar (`.orig` beside it); the box's `clave-test` sandbox is still UP. | **Checked** | `ssh devbox 'zellij list-sessions -n'`; rollback per the 1214 file +- A local frames-off sandbox is staged at `clave-test-restore-cwd` (config from `/tmp/clave-frames-off.kdl`), never launched, built BEFORE the swap-width log line. Restage before use. | **Checked** | `./target/release/clave dev instance --field session` + +**Method.** Read the box's zellij log for `swap-width` lines with instance id and `cols` before believing any description of a flap. Push, restage and drive on the box through `scripts/remote-qa.sh` only; never a bare `zellij` against the box. Write the handoff line the moment a measurement lands; the log is user-global and ids collide. + +**Proof.** `just gates` green locally; `./scripts/remote-qa.sh qa qa-fleet 3600` then the human's launch, and `qa-log` ends in twelve PASS lines with phase 4's "the two walks made no width ask" at 0. + +## Task Overview + +Make v0.5.2's restore hold on both machines and close the devbox regressions, with the QA drive covering each seam locally AND over ssh (the human's standing requirement, verbatim in the 1214 file). Now concretely: find why a restored/baked tab on the box refuses the bar's width swap after Alt+c, fix it test-first, drive it green remotely, then cut v0.5.3. + +## Reference Docs + +- `docs/status/2026-09-22-1214-devbox-width-flap.md` — the whole thread: the flap's cause, the review round, the remote loop, the four other regressions (nav skipping a dead tab, restored rows without pane, daemon-held spawn, live-set loss), rollback commands. Read it in full; it is the ledger this file extends. +- `docs/dev/QA-DRIVE.md` "The remote drive" and run 23 in the ledger. +- `docs/FOOTGUNS.md` entries added today: frameless pane paints one short; ssh-shell `zellij action` lands on `remote`; worktree `.git` is a file; Linux socket dir. +- `crates/clave-bar/src/model.rs` `width_effects` (~3655-3712) and `own_tab_focused` (3270); `crates/clave-bar/src/main.rs` `render` (~1206-1240, the swap-width log line) and the `SwapWidth` arm (~419). +- `crates/clave/src/setup.rs` 1400-1612 `launch_session`: how the first tab is baked and restored rows deferred — the tab that refuses the swap is born here. + +## Current State + +All committed. Today's commits on the branch, oldest first: the width fix (6bf32e6), docs, the doc-comment move, the renderer fix + hardening (f2aeaca), status, the remote loop + width assertion (fe5a3fd), detached run (e6dc84a), Linux socket fix (b8f1ba4), run 23 docs (25af2e7), and three status updates (3db9e55, ac04ea7, this). `/tmp/clave-pr-separator.md` holds a drafted PR body (dossier template, review lanes filled) for when the human says push. + +## What's Working + +- The remote loop end to end: push, stage, detached drive, log reads, kill. Copy its shape for any future remote target (`CLAVE_QA_HOST`). +- The width assertion is a real detector: it read 0 on a healthy frames-off fleet and the fixture self-test pins per-line attribution. +- `mode_at` and `is_expanded` are the two places the painted width is judged; a compile-time assert keeps the pairs more than a separator apart. Do not add a third comparison. +- The relaunch phase (6c) passed on the box: five rows back, same uuids, four bound before their agent ran. + +## What success looks like + +Alt+c in every tab of a restored fleet on the box collapses and expands with one swap per press; the drive proves it (a phase that presses Alt+c in the FIRST tab after a relaunch, not only in the commit's fresh tab); v0.5.3 cut and installed on both machines; the four remaining regressions closed the same way. + +## Important Discoveries + +- The old flap (asks for the width the pane already has, on every focus change) is gone on the box with this branch: zero asks at launch and across the walk. What remains is different: a legitimate ask that the TAB does not honour. The drive's burst passed because it stood in tab 7, a fresh `clave open` tab; the human stood in the baked first tab. That gap is the drive's blind spot to close. +- The bar logs only when it asks, so "hides for a fraction of a second" (a swap applied and undone, or a pane rebuilt) is invisible in the log. The next log line must carry `tab= active= panes=` so one round names the seam. +- Tried and abandoned: rsync of the worktree (`.git` is a file); an attached ssh drive (dies with the 10-minute tool cap); `pkill -f` without the bracket trick (kills the ssh shell). + +## Next Steps + +1. Extend the `swap-width` line in main.rs `render` with own tab, active tab and own-tab pane count (model already has `own_tab()`, `active_tab_id()`, `panes`). Gates. +2. `./scripts/remote-qa.sh kill`, then `./scripts/remote-qa.sh qa relaunch-restore 3600` (one launch gives a restored fleet). Hand the launch line. Ask the human to press Alt+c twice in the first tab, then in a fresh tab. Read `just remote-log 60`. +3. Fix at the seam the numbers name, test-first in model.rs or setup.rs; add a drive check that presses the toggle in the FIRST tab after 6c's relaunch and asserts the swap landed (cols moved). +4. Mutation run: revert the two fix commits on a temp branch, keep the log line, push to the box, watch phase 4 go red. One launch. +5. Ask the human for the go: push, PR from `/tmp/clave-pr-separator.md` (refresh the test counts and lanes), then v0.5.3 per the 1214 file's step 2, including the box rollback of the diagnostic bar. +6. Then the four regressions in the 1214 file's order. + +Last exchange: the human sent a screenshot standing in `qa-fleet-quiet` (first tab, bar expanded) and said "tried again, still flapping. The bar collapses randomly, or doesn't respond to alt+c, or hides completely for a fraction of a second." The agent measured the same three unlanded asks and handed off. + +## Context for the Work + +- He launches; hand him the exact line and say it must be a Mac terminal outside zellij. He was annoyed that "any terminal" was wrong: be explicit. +- Never touch the box's `clave` or `remote` sessions, not even a read. The sandbox is `clave-test`; kill it by name only. +- Remote surfaces (push, PR, tag, issue) wait for his go each time. +- Bring log lines, not theories; he pushed back on unmeasured theories three times on this thread. +- Commits end with `Claude-Session: https://claude.ai/code/session_01VxbdvjsGZN9x4hYyGgdXHL`; PR bodies end with that URL bare. + +## Restart Hint + +Tree clean, gates green, box sandbox `clave-test` up and stale; kill it, then step 1. + +## Suggested Skills + +`catch-up` to resume; `superpowers:systematic-debugging` for the refused swap; `superpowers:test-driven-development` for the fix; `superpowers:requesting-code-review` before the PR. diff --git a/docs/status/2026-09-22-1550-restore-beacon.md b/docs/status/2026-09-22-1550-restore-beacon.md new file mode 100644 index 0000000..8b6ef7c --- /dev/null +++ b/docs/status/2026-09-22-1550-restore-beacon.md @@ -0,0 +1,71 @@ +# Task Pickup + +You are picking up from an agent that found, by measurement on the devbox, why Alt+c "flaps" in a restored fleet: after the restore, the replicated focus beacon rests on the LAST tab the restore opened, while the human stands on the FIRST. The bar in the last tab believes it is focused and asks for the swap; zellij applies every ask to the tab that is really focused (the first), whose own bar believes it is unfocused and stays silent. Nothing is fixed yet. The human is at the box and can launch. + +## Orientation + +- Branch `fix/bar-separator-column`, 19 commits ahead of `origin/main`, tree clean after this file, `just gates` green. Nothing pushed, no PR. | **Checked** | `git log --oneline origin/main..HEAD` +- The trace that names the seam (box log, launch 15:46:35, presses 15:47:04-08): bar 4 (tab 3) `swap-width backwards=true cols=47 tab=Some(3) active=Some(3)`; 2 ms later bar 1 (tab 0) `painted cols=16 was=Some(47)`; bar 4's cooldown asks twice more, bar 1 paints 48 then 47; bar 4 rests `capped`. Bar 1 logs `width-deaf cols=47 reason=unfocused tab=Some(0) active=Some(0)` throughout. Dump at +8 s: tab 0 `focus=true`, all four restored panes `start_suspended true`. | **Checked** | `ssh devbox 'grep "clave-bar:" /tmp/zellij-1000/zellij-log/zellij.log | grep 15:47:0'`; `/tmp/launch-dump.txt` on the box +- `own_tab_focused()` is `own_tab() == current_tab`, and `current_tab` is the BEACON (`set_beacon`, the only writer, `model.rs` ~1046), fed by `clave-visited` pipes. It is not zellij's active flag. A bar whose frames say `active=Some(own)` can still read "unfocused". | **Checked** | `crates/clave-bar/src/model.rs:3285`, `:1046` +- Why the beacon is wrong: each deferred restore open runs `zellij action new-tab --layout` (born focused, zellij rule), the newborn bar announces its birth (`None → own`, ungated, `apply_tabs` ~2400), then `open.rs` returns focus with `go-to-tab-by-id` (~225) and announces nothing. The reanchor claim built for this (`restore_reanchor_owed`, #261's third trigger, `apply_tabs` ~2385-2415) is spent early: the focus-return frame reaches tab 0's bar while the beacon still names tab 0 and `opening` is empty between sequential opens, so the claim is dropped before the last newborn's announce arrives. No later frame re-derives it. | **Inferred from code + trace** | the spend branch is `if self.opening.is_empty() { self.restore_reanchor_owed = false }`; confirm with a log line on the spend before fixing +- Refuted this session, by measurement: pane count of the restored tab (2, same as fresh); the plugin's client id (every bar loads under client 1); the tab's swap layouts (a CLI `previous-swap-layout` lands on the refusing tab both ways). Do not revisit. | **Checked** | log lines at 15:09, 15:22, 15:14 +- A hot-reload of all bars (`ct.sh start-or-reload-plugin "file:$d/clave-bar.wasm" -c clave_binary=clave,row_height=card`, ONE `-c` with commas, or zellij starts a stray second bar) reset the beacons and every tab then toggled correctly, one ask per press, landing in ~10 ms. The defect is beacon state, not tab state. | **Checked** | trace 15:33-15:35 +- Shipped instruments on the branch: `swap-width` line carries tab/active/panes/client; the cooldown's asks are logged (`source=cooldown`, they were silent before and the QA counter now counts them too); `painted cols=N was=M` on every width change; `width-deaf cols= reason=` once per (width, reason) via `BarModel::width_deaf_reason`. Noise to tidy: `reason=owed` prints in the same render as the ask. | **Checked** | `crates/clave-bar/src/main.rs` render and Timer arms +- Probes: `scripts/qa/width-probe.sh ` (two toggles, samples one tab's bar width every 150 ms); `scripts/qa/width-probe-cli.sh` (two CLI swaps). Run on the box as `~/code/clave-qa/scripts/qa/width-probe.sh restored-a 2 HH:MM:SS`. | **Checked** | this session +- Box state: sandbox `clave-test` is UP from the 15:46 launch, stale. Kill it by name (`./scripts/remote-qa.sh kill`) before restaging. The box checkout is at this branch's head minus this file. The live `clave` and `remote` sessions are untouched. | **Checked** | `ssh devbox 'zellij list-sessions -n'` + +**Method.** Every claim above came from a log line or a dump; theories cost the human three launches before the instruments caught it. Extend a log line before you extend a theory. Use `remote-qa.sh` and `ct.sh` only; never a bare `zellij` on the box. + +**Proof.** `just gates`; then on the box: kill, `stage relaunch-restore`, the human's launch, Alt+c ×4 in the FIRST tab: one `swap-width` line per press from bar 1 (tab 0), each followed by bar 1's own `painted` line, no `reason=unfocused` from bar 1, no asks from bar 4. Then the same in a fresh tab. Then `qa qa-fleet` green with phase 4 at zero asks. + +## Task Overview + +Make Alt+c hold on a restored fleet on both machines: the beacon must name the tab the human stands on when the restore finishes. Fix test-first in `model.rs` (the model is pure; the trace gives the exact event order to replay in a test), add a drive check that presses the toggle in the FIRST tab after 6c's relaunch and asserts the swap landed on THAT tab, drive green remotely, then v0.5.3. + +## Reference Docs + +- `docs/status/2026-09-22-1458-devbox-width-flap.md` and the 1214 file — the width fix, the remote loop, the four other regressions, rollback of the box's diagnostic bar. +- `crates/clave-bar/src/model.rs`: `apply_tabs` ~2330-2425 (birth announce, reanchor gate, the early spend), `set_beacon` ~1032-1051, `width_effects` ~3657, `width_deaf_reason` after it, `restore_effects` ~2000. +- `crates/clave/src/open.rs` ~214-240 (the silent focus return). +- `docs/FOOTGUNS.md` ~58-66: TabUpdate reaches only the active tab; `is_active_instance` is not a visibility gate; the beacon is the only trustworthy signal. + +## Current State + +All committed. Sandbox on the box up and stale. + +## What's Working + +Every swap the bars ask for lands where zellij's focus is. The fresh-tab path, the reload path, the width tolerance, the remote loop, the probes. + +## What success looks like + +The proof above, on the box and locally, with the drive asserting it. + +## Important Discoveries + +- Candidate fixes, weakest to strongest; decide after reading `apply_tabs` with the trace beside it: (a) `open.rs` pipes `clave-visited` for `restore_to` after the focus return — host-side, one line, but the newborn's birth announce is asynchronous and can still land after it; (b) keep `restore_reanchor_owed` alive until the restore is COMPLETE (the owner knows the deferred list), and re-run the reanchor when a `clave-visited` pipe moves the beacon off a tab whose fresh frame says it is active; (c) a restored HELD newborn does not announce its birth at all, since the host takes focus back at once — simplest, KISS-preferred, but check `run_held_effect` (#261 starts the agent on a beacon MOVE) and the 2026-09-17 note in the spend branch before choosing. +- The cooldown re-asks whenever the paint has not arrived within 0.15-0.2 s. When the ask went to another tab, that is a guaranteed extra walk step per press. Any fix must leave one ask per press in the trace. + +## Next Steps + +1. Read `apply_tabs` ~2385-2415 with the 15:47 trace; add a log line where `restore_reanchor_owed` is spent; relaunch once to confirm the early spend (or replay the event order in a model test and skip the launch). +2. Fix (test-first, `model.rs`), gates, `just mutants` over the change. +3. Drive check: after phase 6c's relaunch, `pipe --name clave-toggle -- 1` in the first tab and assert the first tab's bar logged the ask AND the paint. +4. `remote-qa.sh kill`, `qa qa-fleet 3600`, the human's launch; then the local drive. +5. Ask for the go: push, PR from `/tmp/clave-pr-separator.md` (refresh), v0.5.3 per the 1214 file, box diagnostic-bar rollback. + +Last exchange: the human launched, waited, pressed Alt+c four times in the first tab, and said "done. still flapping." The trace above is that run. + +## Context for the Work + +- Launch line, Mac terminal outside zellij: `ssh -t devbox 'cd ~/code/clave-qa && just launch'`. Say "outside zellij" every time. +- He has launched four times today for this; bring the fix and the drive check in one launch. +- Remote surfaces wait for his go. Commits end with `Claude-Session: https://claude.ai/code/session_01VxbdvjsGZN9x4hYyGgdXHL`. + +## Restart Hint + +Tree clean, gates green. Start at step 1 with the trace open. + +## Suggested Skills + +`catch-up`; `superpowers:test-driven-development` for the fix; `superpowers:requesting-code-review` before the PR. diff --git a/docs/status/2026-09-22-1730-restore-beacon-fixed.md b/docs/status/2026-09-22-1730-restore-beacon-fixed.md new file mode 100644 index 0000000..a14755f --- /dev/null +++ b/docs/status/2026-09-22-1730-restore-beacon-fixed.md @@ -0,0 +1,83 @@ +# Task Pickup + +You are picking up from an agent that fixed the Alt+c flap on a restored fleet in the model, test-first, through two review rounds and one red drive, and is now waiting for the box drive (run 28) to prove the shipped shape. The human launches; you kill the sandbox at 6c and read the verdict. + +## Orientation + +- Branch `fix/bar-separator-column`, ahead of `origin/main`, nothing pushed, no PR. Gates green at `6cbc67e`; `scripts/remote-qa.sh` has an UNCOMMITTED fix (see below). | **Checked** | `git status -sb`, `git log --oneline origin/main..HEAD` +- The fix, final shape: `note_restore_steal` in `model.rs` (the `clave-visited` entry) arms `restore_steal_pending` when the arriving beacon names a tab an owed row holds, or a tab no row holds while an owed open is in flight; that ROW alone leaves `restore_reanchor_owed`. Any other beacon disarms it (the human walked). `apply_tabs` emits the re-anchor on `restore_steal_pending` and clears it on emit. No frame touches it. | **Checked** | `crates/clave-bar/src/model.rs` `note_restore_steal`, fields `restore_reanchor_owed` / `restore_steal_pending`, `apply_tabs` `restore_home` +- Two shapes refuted on the way, both recorded in FOOTGUNS: a rule derived from `restore_sent` alone (review: unbounded lifetime, drags the beacon off a restored tab the human walked to); a drain of every owed row on emit (box run 26: tab 3's steal answered after tab 4's open went out, tab 4's steal unanswered, three asks for two presses). | **Checked** | `docs/FOOTGUNS.md` "A claim armed by an event and spent by the next frame" +- Tests that pin it: `the_reanchor_claim_outlives_a_bound_snapshot_that_beats_the_birth_announce` (devbox order), `a_finished_restore_does_not_drag_the_beacon_off_a_tab_the_human_walked_to` (lifetime), `answering_one_steal_leaves_the_next_open_owed` (box order), `a_walk_to_a_live_tab_during_an_open_is_not_the_newborns_steal`, `a_beacon_on_a_tab_the_restore_did_not_build_is_a_walk`. `cargo mutants -F "note_restore_steal|fn restore_effects"`: 7 caught, 0 missed. | **Checked** | this session +- Drive check: phase 6c, after `relaunch_checks`, presses `clave-toggle` twice in the tab the restore left focused with NO anchor, asserts 2 asks, both `tab=Some()`, askers non-empty, painters == askers. Green on box run 24 and Mac run 25 (on the FIRST, unbounded shape). Red on box run 26 (the drain-all shape). Run 27 died in preflight: the launch raced the stage. | **Checked** | `scripts/qa-drive.sh` "post-relaunch"; `docs/dev/QA-DRIVE.md` run ledger +- Run 27's cause: `remote-qa.sh qa` printed the launch line BEFORE the detached remote stage ran; the human launched at 17:15:22 into a half-staged sandbox and the seed at 17:15:30 deleted launch.kdl. Fixed, uncommitted: the stage now runs attached and the launch line prints after it, the drive alone is detached. | **Checked** | `scripts/remote-qa.sh` `qa)` case; box `~/.local/state/clave-dev/state/clave.log` (launch ts 1790093722, seed ts 1790093730) +- Reviews done: blind Opus adversarial review over the beacon fix (1 blocker taken, the lifetime; the rest taken or declined, all in `/tmp/clave-pr-separator.md`); CodeRabbit over the beacon commits, 0 findings. The final shape (`6cbc67e`) has NOT been re-reviewed. | **Checked** | `/tmp/clave-pr-separator.md` +- Box: sandbox `clave-test` staged for run 28 at `6cbc67e` plus the script fix, drive detached and waiting up to 3600 s. The human launches; `/tmp/watch-box-drive.sh` is the poll script. Kill only via `./scripts/remote-qa.sh kill`. The devbox still carries the DIAGNOSTIC 0.5.2 bar in its live install (rollback in the 1214 file). | **Checked** | `./scripts/remote-qa.sh qa-log 20` +- Mac: no sandbox up. Run 25 was green on the first shape; the final shape has not been driven on the Mac. | **Checked** | the human confirmed the eyeballs, the sandbox was killed + +**Method.** Every claim came from a trace or a test that was red first. Extend a log line before a theory. `remote-qa.sh` and `ct.sh` only; never a bare `zellij` on the box. + +**Proof.** Run 28 on the box: twelve phases green, 6c's four post-relaunch lines green, the trace showing one `swap-width` per press from the standing tab's instance and its `painted` ~30 ms later. Then a Mac drive of the same commit (two launches), or at minimum a Mac launch with Alt+c in the first tab and a fresh tab. + +## Task Overview + +Ship the Alt+c fix: box run 28 green, Mac drive green, commit the script fix, refresh the PR body, ask for the go on push, PR, and v0.5.3. + +## Reference Docs + +- `docs/status/2026-09-22-1550-restore-beacon.md` — the diagnosis and the trace that named the seam (its "nothing is fixed" is dated). +- `docs/status/2026-09-22-1214-devbox-width-flap.md` — the width fix, the release steps, the devbox diagnostic-bar rollback. +- `/tmp/clave-pr-separator.md` — the PR body, filled except for runs 26-28; ephemeral, copy it into the repo if the session ends. +- `docs/dev/QA-DRIVE.md` — run ledger (add runs 26-28), the 6c row, the remote-drive section. + +## Current State + +Uncommitted: `scripts/remote-qa.sh` (the stage/launch-line reorder) and this file. Box drive waiting for the human's launch. + +## What's Working + +The model fix under all five tests and mutants. The 6c check catches the defect (proved by run 26 going red on a wrong shape). The remote loop, now with the stage attached. + +## What success looks like + +Run 28 and the Mac drive green on `6cbc67e`; PR opened with the dossier; v0.5.3 cut; devbox diagnostic bar rolled back. + +## Important Discoveries + +- Opens are paced one per bound snapshot, but a re-anchor for steal N can emit after open N+1 is sent. Any drain must be per row. +- The Mac cannot show box-order races: its opens land slower than the re-anchor. Drive the box for anything about restore ordering. +- A launch line printed before the stage finishes is a launch into a half-staged sandbox. The fix in `remote-qa.sh` is the record. + +## Next Steps + +1. Run 28: the human launches; at "needs a SECOND launch" run `./scripts/remote-qa.sh kill`, ask for the relaunch, read `drive-log` for the summary and the four post-relaunch lines. If red, read the bar trace (`./scripts/remote-qa.sh log 600 | grep clave-bar:`) around the press timestamps before touching code. +2. Commit `scripts/remote-qa.sh` (fix(qa): the remote stage runs attached, the launch line follows it; cite run 27) and this file. Add runs 26-28 to the QA-DRIVE ledger. +3. Mac drive on the same commit: `nohup just qa qa-fleet 3600 > /tmp/clave-local-qa.log 2>&1 &`, the human launches, kill `clave-test-restore-cwd` at 6c, relaunch, read the summary. +4. Refresh the PR body (runs 26-28, the final shape, a note that the final shape had no second adversarial pass), then ask for the go: push, PR, v0.5.3 per the 1214 file, devbox rollback. + +Last exchange: the human said "its up" for run 27, which died in preflight from the stage race; the box was restaged with the fixed script and is waiting for the launch. + +## Context for the Work + +- Launch line, Mac terminal outside zellij: `ssh -t devbox 'cd ~/code/clave-qa && just launch'`. Local: `cd && just launch`. +- The human asked that the agent kill the sandbox each time; that exemption stands for both sandboxes this conversation launched. +- Commits end with `Claude-Session: https://claude.ai/code/session_01VxbdvjsGZN9x4hYyGgdXHL`. + +## Restart Hint + +Check `./scripts/remote-qa.sh qa-log 20` first: if run 28 has started, follow its phase; if it has finished, read the summary. Then step 2. + +## Suggested Skills + +`catch-up`; `superpowers:test-driven-development` if anything goes red in the model; `superpowers:requesting-code-review` before the PR. + +## Amendment, 17:35 — run 28 went red in phase 2, not on the fix + +- Run 28 (box, `6cbc67e` + `b08bf7f`): preflight green, phase 2 rung 1 `tab_id bound` FAIL after 10 s. The minted row (`560a5cd3…`) holds `tab_id null, pane_id null` still. Its tab was created, its bar (id 2) loaded, resolved (`bind leg live again (tab 1)`), and piped `beacon 1`; both bars dropped the `clave-register` pipe as empty, but run 26 shows the SAME drops and CliPipe timeouts at its rung 1 and bound fine. So the register's store write (pane_id) is what did not land, and that is host-side (`clave spawn`/`add`/hook), which no commit on this branch touches. | **Checked** | box `zellij.log` 17:25:05; `clave dev status` on the box +- Two suspects, unmeasured: a flake; or the drive now running detached as `env QA_… ./scripts/qa-drive.sh` (the `qa` verb after `b08bf7f`) instead of under `just qa`. The `drive` verb used that form already but had never been run. | **Inferred** | `scripts/remote-qa.sh` `qa)` +- Mac drive is staged (`/tmp/clave-local-qa.log`, `nohup just qa qa-fleet 3600`) at the same commit; its phase 2 runs 30 s after the launch and answers "regression or box-only". | **Checked** | this session +- Box sandbox `clave-test` is UP from run 28 (broken row, drive finished). Kill with `./scripts/remote-qa.sh kill` before restaging. + +### Next Steps (supersede the list above) + +1. Mac: the human launches; read `/tmp/clave-local-qa.log` for `P2-bind-ladder`. Green → box-only, rerun the box once with the `qa` verb changed to detach `just qa` again (keep the attached stage: `remote "just sandbox …"`, then `nohup setsid env … just qa-drive`-equivalent — or simplest, revert the drive line to `nohup setsid just qa …` and accept that `just qa` restages, which is harmless after an attached stage). Red → the fix regressed the bind; read bar 2's lines and the `Bind` gate (`model.rs` ~1821, `elects_confirmed`), and check whether `note_restore_steal` in `beacon()` changes anything before `set_beacon` for a newborn (it should not: owed is empty off the owner). +2. Then run 6c on both hosts as before, and the rest of the original steps. diff --git a/docs/status/2026-09-22-1900-strip-restore.md b/docs/status/2026-09-22-1900-strip-restore.md new file mode 100644 index 0000000..2905b8b --- /dev/null +++ b/docs/status/2026-09-22-1900-strip-restore.md @@ -0,0 +1,47 @@ +# Strip the live-set restore — handoff + +Branch `fix/bar-separator-column`. Ollie's instruction (2026-09-22): "strip it, +clean everything up, check against the pre-restore diff commit". The +pre-restore commit is `2d3f068^`; the restore PR is `2d3f068`. + +## Done + +- `9670fbf` feat!: remove the live-set restore from host, bar, types, tests. + `just gates` green. `Status::Exited` and "SessionEnd keeps the tab" went + with it (they served the restore only). See the commit body for the full + list of what was removed and what was kept. + +- `957850f` feat(qa): phase 6c rewritten (one eager tab, dormant fleet, + Alt+c two-press check kept). Item 1 below is DONE. + +- docs(restore): UBIQUITOUS_LANGUAGE, TESTING, QA-DRIVE (6c + run 30 + ledger), FOOTGUNS (one folded entry "The restore that sequenced tabs + through the bar"), README bullet. Items 1 and 2 below are DONE. + +## Open, in order + +1. **Scripts.** `scripts/qa-drive.sh` phase 6c (search `P6c`): drop the + close-candidate close, the ≥3 floor and the set comparisons. New verdict + after the relaunch: exactly one bound row (the most-recent), every other + row unbound with no stale Working/NeedsYou status. Keep the two-press + Alt+c block at the end of 6c verbatim. `scripts/qa/lib.sh`: delete + `held_bound_uuids`, `close_candidate_tab`, `last_live_uuids`; rewrite + `relaunch_checks`. `scripts/qa/lib-selftest.sh`: rewrite the relaunch + fixtures, delete the relaunch-verdict tests. Delete + `scripts/qa/relaunch-verdict.sh`. `crates/clave/tests/script_hygiene.rs` + lines ~24-40 and ~449-471 reference all three; update the reader list. + `cargo test -p clave --test script_hygiene --test qa_lib` must pass. +2. **Docs.** `docs/UBIQUITOUS_LANGUAGE.md` (held tab, live set, restore + owner, restored mentions, exited row: remove or mark removed); + `docs/dev/TESTING.md` taxonomy rows for the restore; `docs/dev/QA-DRIVE.md` + 6c row and section plus a ledger entry for run 30 and the removal; + `docs/FOOTGUNS.md`: fold the restore-only entries into ONE entry titled + "The restore that sequenced tabs through the bar" (setup.rs cites that + title) recording what was measured and why it was removed; keep general + entries (new-tab always focuses, the handle burst). README if it mentions + the restore. +3. `just mutants` over the changed files (expected, not a gate). +4. Drive: box `./scripts/remote-qa.sh qa qa-fleet`, Mac `just qa qa-fleet`. + Ollie launches; I kill at the end. Mac 5b has a fixture collision with the + stale `live-set-459d` sandbox dir (Ollie's call). +5. Refresh `/tmp/clave-pr-separator.md`; ask for the go on push, PR, v0.5.3. diff --git a/justfile b/justfile index 8ae79c6..ec8dfb9 100644 --- a/justfile +++ b/justfile @@ -287,8 +287,8 @@ launch: # Stage + wait for the human's launch + drive phases 0-7, in one command. # # It asks for a SECOND launch part way through: phase 6c asks the maintainer to -# quit the sandbox and launch it again, because the live set can only decay -# across a session +# quit the sandbox and launch it again, because what a relaunch bakes (one +# eager tab, every other row dormant) can only be seen across a session # boundary and no other phase crosses one. The drive prints both commands and # waits; `wait` is the budget for EACH ask. # @@ -299,3 +299,32 @@ launch: qa scenario="qa-fleet" wait="1800": ./scripts/sandbox-setup.sh {{scenario}} QA_WAIT_SECS={{wait}} QA_RELAUNCH_WAIT={{wait}} ./scripts/qa-drive.sh {{scenario}} + +# The same loop against a REMOTE machine (scripts/remote-qa.sh): push this +# HEAD to a plain checkout there, run `just qa` in ITS environment — its +# zellij config, its frames setting, its Claude Code — and stream the drive +# here. The launch is still the human's: `ssh -t 'cd && just +# launch'`, from any terminal. Host and dir: CLAVE_QA_HOST / CLAVE_QA_DIR. +# +# Born of the 2026-09-22 devbox regressions: none reproduced on the Mac, +# every one was diagnosed from the box's log over ssh. Now that is the loop. +remote-qa scenario="qa-fleet" wait="1800": + ./scripts/remote-qa.sh qa {{scenario}} {{wait}} + +# Stage only, on the remote, and print the launch line. +remote-sandbox scenario="c8-cold-start": + ./scripts/remote-qa.sh stage {{scenario}} + +# The remote's zellij log tail (n lines) — every reading the drive makes +# comes from this file, and it is readable from here without touching a +# session. +remote-log n="40": + ./scripts/remote-qa.sh log {{n}} + +# The newest remote drive log's tail. +remote-drive-log n="40": + ./scripts/remote-qa.sh drive-log {{n}} + +# Kill the remote SANDBOX session, by the exact name the remote binary derives. +remote-kill: + ./scripts/remote-qa.sh kill diff --git a/scripts/ct.sh b/scripts/ct.sh index c058a6b..9273c81 100755 --- a/scripts/ct.sh +++ b/scripts/ct.sh @@ -81,7 +81,21 @@ fi # fails closed, so it looked exactly like "the sandbox is not running". # (Found reviewing PR #152, 2026-08-10.) TMP="${TMPDIR:-/tmp}" -SOCKET_ROOT="${TMP%/}/zellij-$(id -u)" +# Zellij's socket dir is NOT the tmp dir on Linux. The rule (zellij-utils +# consts.rs `ZELLIJ_SOCK_DIR`, 0.44.3:316-327, same in 0.45.1): the +# `ZELLIJ_SOCKET_DIR` variable if set, else the project runtime dir — which +# the `directories` crate gives as `$XDG_RUNTIME_DIR/zellij` on Linux and as +# nothing on macOS — else `/zellij-`. The devbox's sockets live at +# `/run/user/1000/zellij/contract_version_1/` while its LOG stays +# under `/tmp/zellij-1000/zellij-log/` (the log dir is always the tmp one). +# The first remote drive (2026-09-22) refused every read for exactly this. +if [[ -n "${ZELLIJ_SOCKET_DIR:-}" ]]; then + SOCKET_ROOT="$ZELLIJ_SOCKET_DIR" +elif [[ -n "${XDG_RUNTIME_DIR:-}" ]]; then + SOCKET_ROOT="${XDG_RUNTIME_DIR%/}/zellij" +else + SOCKET_ROOT="${TMP%/}/zellij-$(id -u)" +fi if [[ $# -eq 0 ]]; then echo "usage: $0 [args…] (runs against ${SESSION} only)" >&2 diff --git a/scripts/qa-drive.sh b/scripts/qa-drive.sh index 263ad53..59342f8 100755 --- a/scripts/qa-drive.sh +++ b/scripts/qa-drive.sh @@ -17,9 +17,9 @@ # ALL PHASES DRIVEN LIVE GREEN — run 4, 2026-08-17, full 0-7 pass plus both # human eyeball checkpoints; run 11, 2026-09-11, all TEN phases (0-7 with # 5b card-cells and 6b isolation-witness), first run of the `just qa` loop; -# and run 22, 2026-09-17, all TWELVE phases, 237 checks, first run in which -# 6c's "restored rows were bound before their agent ran" passed — 4 of 4, -# where run 21 measured 2 and the run before that killed the zellij server. The list below was the FIRST LIVE RUN PENDING +# and run 22, 2026-09-17, all TWELVE phases, 237 checks, under the live-set +# restore that was removed on 2026-09-22 (6c now proves one eager tab and +# every other row dormant). The list below was the FIRST LIVE RUN PENDING # ledger; it is kept because each entry records an assumption a live run had # to settle, and how the first runs settled them: runs 1-3 each went red on a # real finding first (the stale-executor nav wedge, the starved-bar prune of @@ -640,6 +640,13 @@ check "orphan 'zellij pipe' processes (pid in both probes, 2s apart)" "$ORPHANS" # =========================================================================== phase "P1-baseline-join" +# The launch's width asks, RECORDED: a tab baked in the other mode asks once +# to correct itself (#89), so a launch is not necessarily silent. The flap's +# shape is many asks per instance for the width the pane already has; phase +# 4 asserts the zero, this line is the launch's own reading beside it. +measure "width asks since the launch mark (sandbox instances, one line per ask)" \ + "$(swap_ask_count_since "$LOGMARK") over $(sandbox_instance_count) instances" + STATUS_JSON="$(dev_status)" measure "dev status (raw)" "$STATUS_JSON" @@ -1715,6 +1722,13 @@ walk_leg() { # see two executors walking in lockstep, the evlog can). P4_OPEN_BEFORE="$(evlog_count open)" P4_SEQ_BEFORE="$(jq -r '.store.seq // empty' <<<"$P4_STATUS" 2>/dev/null)" +# The width seam. Every focus change resets a bar's walk budget, so a bar +# that misjudges its painted width asks for a swap on every tab the walk +# lands in — the devbox flap (2026-09-22, `pane_frames false`: the pane +# paints one column short and an exact comparison asked sixteen times in +# four seconds). No tab is toggled during a walk, so the ask count across +# both legs must be zero on any host, frames on or off. +P4_SWAP_MARK="$(zlog_now)" walk_leg "$P4_FIRST_TAB" "walk 1 (standing in tab ${P4_FIRST_TAB}):" # A walk is selection only: it writes nothing. Recorded rather than asserted — @@ -1734,6 +1748,9 @@ measure "store seq across walk 1 (a walk selects; it should write nothing)" \ # --------------------------------------------------------------------------- walk_leg "$P4_LAST_TAB" "walk 2 (standing in tab ${P4_LAST_TAB}):" +check "the two walks made no width ask (a bar at its painted width asks nothing; the frames-off flap asked on every focus change)" \ + "$(swap_ask_count_since "$P4_SWAP_MARK")" "0" + # The bracket's midpoint: a walk is selection only, so NO open may have run # yet — and pinning zero here is what proves the ==1 after the commit came # from the commit alone, not from a stray walk-time open cancelling against a @@ -2168,12 +2185,8 @@ hook_fire SessionEnd # Main's wording: the mark is asserted DOWN, not "cleared" — SessionEnd forces # it down whatever it was, so the check must not read as a transition. check "SessionEnd leaves the subagent mark down" "$(wait_field subagents false)" "false" -# `exited`, not `idle` (#261). The session above really ended, and a row whose -# agent ended is no longer the same state as a row whose agent is alive with -# nothing to say: it holds a tab and runs nothing. What this check is for is -# unchanged — nothing may be left Working going into phase 6. -check "and the row reads exited, with nothing left Working into phase 6" \ - "$(wait_field status exited)" "exited" +check "and the row goes idle (nothing left Working into phase 6)" \ + "$(wait_field status idle)" "idle" P5B_SEQ_END="$(jq -r '.store.seq' < <(dev_status) 2>/dev/null)" check_numeric "phase-5b end store seq readable" "$P5B_SEQ_END" @@ -2413,16 +2426,18 @@ fi # =========================================================================== # Phase 6c — the relaunch (the second launch) # =========================================================================== -# Every phase above runs inside ONE session. That is exactly why the live set -# could decay all the way to a shipped branch (#261) with four gates green, a -# swarm review behind it, and a full drive: nothing any of them ran ever -# crossed a session boundary, and the decay only exists on the far side of -# one. A maintainer launching twice by hand was the only instrument that -# could see it. +# Every phase above runs inside ONE session. A relaunch is the one thing none +# of them can see: what the store carries across a session boundary, and what +# the next launch does with it. The live-set restore (#261) decayed all the +# way to a shipped branch with four gates green because nothing launched +# twice; a maintainer launching twice by hand was the only instrument. This +# phase is that instrument, automated. # -# This phase is that instrument, automated. It is cheap — read the set, ask -# for a quit and a relaunch, read it again — and it is the only phase that -# reads what the PREVIOUS session recorded. +# What a relaunch does now (setup.rs `launch_layout_kdl`, `eager_row`; +# decision of 2026-09-22, FOOTGUNS "The restore that sequenced tabs through +# the bar"): it bakes ONE tab, for the most-recent row whose cwd still exists. +# Every other row is an ordinary dormant row, and Alt+Enter opens it. Nothing +# is held and nothing is restored. # # It never kills and never launches. Session lifecycle stays the human's # (AGENTS.md), and `script_hygiene.rs` fails the build if any line here starts @@ -2433,105 +2448,31 @@ fi # source rather than assumed: # # 1. A launch CLEARS every tab_id and pane_id before it bakes the layout -# (setup.rs `clear_session_order`, called from `launch_session` when the +# (store.rs `clear_session_order`, called from `launch_session` when the # session is not live). So every bind read after the relaunch was made by # the second session. A stale bind cannot pass this phase for it. -# 2. The same pass records the set it is about to clear into `last_live`, -# which is what the next launch bakes the layout from. So the store -# carries its own expectation, and this phase does not have to trust the -# number it read before the kill — it checks the two against each other. +# 2. The same pass resets Working and NeedsYou to Idle, because a launch +# starts no agent. So a row wearing either after the relaunch is a claim +# the second session made about a process that is not there. # # The phase leans on phase 6 above: quiescence has just proved the store is -# flat, so the set read here is still the set at the moment of the kill. +# flat, so the snapshot read here is still the store at the moment of the quit. phase "P6c-relaunch" -# A closed tab must STAY closed, and this phase closes one ITSELF rather than -# carrying phase 3's closure down here. -# -# The carried version was written first and it is unsound. Phase 4 wakes the -# first WAKEABLE row of the dormant block, and the row phase 3 just closed is -# what sits at the top of that block — so the drive re-opened its own closed -# row four minutes before the quit, then demanded the restore leave it out. -# Measured on run 13 (2026-09-16), the first run that ever reached this check: -# `clave.log` carries the drive's own `open` for that uuid at 13:51:16 against -# a quit at 13:55. The restore was right and the assertion was stale. -# -# Closing HERE removes the gap. Nothing runs between this close and the quit -# except the wait for the maintainer, so nothing can wake the row, and the -# check no longer depends on what any earlier phase chose to do. -# -# WHICH tab is closed matters as much as that one is, and the rule lives in -# `close_candidate_tab` (qa/lib.sh) where the selftest reaches it. -P6C_CLOSE_STATUS="$(dev_status)" -P6C_CLOSE_TAB="$(close_candidate_tab "$P6C_CLOSE_STATUS" "${CREATE_UUID:-}")" -P6C_CLOSED="" -if [[ -n "$P6C_CLOSE_TAB" ]]; then - P6C_CLOSED="$(jq -r --argjson t "$P6C_CLOSE_TAB" \ - '.store.agents | to_entries[] | select(.value.tab_id == $t) | .key' \ - <<<"$P6C_CLOSE_STATUS" 2>/dev/null | head -n1)" -fi -if [[ -n "$P6C_CLOSED" ]]; then - # Whether the minted row was spared is RECORDED, not asserted: the fallback - # is legitimate on a one-row fleet. But it is the difference between this - # phase measuring the defect and measuring the case the defect cannot - # reach, so a reader must be able to see which one a run got. - measure "the tab this phase closes, so the restore has one to refuse" \ - "tab=${P6C_CLOSE_TAB} uuid=${P6C_CLOSED:0:13} minted_row_spared=$( - [[ -n "${CREATE_UUID:-}" && "$P6C_CLOSED" != "${CREATE_UUID:-}" ]] && - echo yes || echo no)" - focus_tab_checked "$P6C_CLOSE_TAB" "the tab to close:" - "$CT" close-tab - P6C_CLOSE_RC=$? - check "the close was accepted" \ - "$([[ $P6C_CLOSE_RC -eq 0 ]] && echo ok || echo failed)" "ok" - # The prune has to LAND before the set is recorded. If it has not, the row - # is still bound at the quit, `last_live` holds it legitimately, and the - # verdict after the relaunch would go red for a reason that is not about - # the restore at all. - P6C_UNBOUND="no" - for _ in $(seq 1 15); do - if [[ "$(jq -r --arg u "$P6C_CLOSED" \ - '(.store.agents[$u].tab_id | tostring) // "null"' \ - < <(dev_status) 2>/dev/null)" == "null" ]]; then - P6C_UNBOUND="yes" - break - fi - sleep 1 - done - check "the closed tab's row unbound before the quit" "$P6C_UNBOUND" "yes" -else - note 'no BOUND tab to close, so the "a closed tab stays closed" half of this phase is vacuous this run' -fi - P6C_BEFORE_STATUS="$(dev_status)" P6C_SET_BEFORE="$(bound_uuids "$P6C_BEFORE_STATUS")" P6C_N_BEFORE="$(uuid_count "$P6C_SET_BEFORE")" P6C_SEQ_BEFORE="$(jq -r '.store.seq' <<<"$P6C_BEFORE_STATUS" 2>/dev/null)" -check_numeric "the live set is readable before the quit" "$P6C_N_BEFORE" -measure "the live set this session is holding" "n=${P6C_N_BEFORE} $(uuid_line "$P6C_SET_BEFORE")" -# Three is the floor for the property, not a convenience: a restore of one row -# is the eager launch path, which runs on an empty store too and proves -# nothing about the set coming back. -check_min "the live set is big enough for a restore to mean anything" "$P6C_N_BEFORE" 3 - -# The row this phase unbound above must not be in the set the quit records. -# That is the half of the assertion catching a restore which is too GENEROUS -# rather than too thin, and it is checked here as well as after the relaunch: -# a set that already holds the row makes the later verdict say nothing. -if [[ -n "$P6C_CLOSED" ]]; then - check "the closed row is out of the set before the quit" \ - "$(grep -c -- "$P6C_CLOSED" <<<"$P6C_SET_BEFORE")" "0" -fi +check_numeric "the bound set is readable before the quit" "$P6C_N_BEFORE" +measure "the bound set this session is holding" "n=${P6C_N_BEFORE} $(uuid_line "$P6C_SET_BEFORE")" -# And the set still holds the row whose agent REALLY RAN. Without this the -# phase can pass on a fleet of rows that never started a session, which is the -# one population the #261 `SessionEnd` unbind cannot reach — so every verdict -# below it would be green on a broken restore. Runs 14 and 15 (2026-09-16) -# both reached the relaunch in exactly that state and nothing said so. -if [[ -n "${CREATE_UUID:-}" ]]; then - check "the row whose agent really ran is in the set to restore" \ - "$(grep -c -- "$CREATE_UUID" <<<"$P6C_SET_BEFORE")" "1" -fi +# The expectation is computed HERE, from the snapshot the quit will leave, +# because that is the store the launch reads (`eager_row`). Computing it after +# the relaunch would read a store the second session has already written to, +# and the verdict would be measuring its own memory. +P6C_EXPECTED="$(eager_candidate_uuid "$P6C_BEFORE_STATUS")" +check_nonempty "the pre-quit snapshot names the row the relaunch will bake" "$P6C_EXPECTED" +measure "the row the relaunch should bake (most recent, cwd exists)" "$P6C_EXPECTED" # Half an hour for each half, measured rather than guessed: the first live run # (2026-09-16) asked for the quit at ten minutes, the maintainer had stepped @@ -2544,8 +2485,8 @@ cat < PHASE 6c needs a SECOND launch from you, and that is the whole point. Quit the sandbox, then launch it again. Change NOTHING in between, and - touch nothing after — the phase reads the fleet the bar restores by - itself, which is the case that broke: + touch nothing after — the phase reads the fleet the launch bakes by + itself: one tab, and every other row dormant. zellij kill-session ${SESSION} zellij delete-session --force ${SESSION} @@ -2586,7 +2527,7 @@ if [[ "$P6C_RAN" == "yes" ]]; then read_liveness done if [[ "$SESSION_LIVE" != "true" ]]; then - skip_phase "$(printf '%s did not come back within %ss. This is a missing launch, not a finding about the restore — but the seam is UNMEASURED, and the sandbox is DOWN, so the eyeball checkpoints below have nothing to look at either.' "$SESSION" "$P6C_WAIT")" + skip_phase "$(printf '%s did not come back within %ss. This is a missing launch, not a finding about the relaunch — but the seam is UNMEASURED, and the sandbox is DOWN, so the eyeball checkpoints below have nothing to look at either.' "$SESSION" "$P6C_WAIT")" P6C_RAN="no" fi fi @@ -2594,33 +2535,73 @@ fi if [[ "$P6C_RAN" == "yes" ]]; then measure "the second session is up" "after ${P6C_UP}s" - # The settle window. Bounded polling rather than a fixed sleep, and it polls - # for the SIZE the store itself recorded — not for the number read before the - # kill, which would make the expectation this phase's own memory instead of - # the product's. A poll that never reaches it still falls through to the - # checks below, so a short restore fails loudly rather than waiting forever. - # The store is read once per turn of this loop and ALWAYS at least once, so - # the verdict below can never run on an unset reading. + # The settle window. Bounded polling rather than a fixed sleep: the baked + # tab registers its bind when its spawn runs, which is after the session is + # up. The loop waits for ONE bound row and no longer, so a launch that bakes + # nothing still falls through to the checks below and fails loudly rather + # than waiting forever. The store is read once per turn and ALWAYS at least + # once, so the verdict can never run on an unset reading. P6C_SETTLE="${QA_RELAUNCH_SETTLE:-30}" P6C_SETTLED=0 while :; do P6C_AFTER_STATUS="$(dev_status)" - P6C_RECORDED="$(last_live_uuids "$P6C_AFTER_STATUS")" - P6C_SET_AFTER="$(bound_uuids "$P6C_AFTER_STATUS")" - [[ -n "$P6C_RECORDED" && "$(uuid_count "$P6C_SET_AFTER")" == "$(uuid_count "$P6C_RECORDED")" ]] && break + [[ "$(uuid_count "$(bound_uuids "$P6C_AFTER_STATUS")")" -ge 1 ]] && break ((P6C_SETTLED >= P6C_SETTLE)) && break sleep 2 P6C_SETTLED=$((P6C_SETTLED + 2)) done - measure "the restored fleet settled" "after ${P6C_SETTLED}s (nothing was driven, focused or typed)" + measure "the relaunched fleet settled" "after ${P6C_SETTLED}s (nothing was driven, focused or typed)" measure "store seq across the relaunch" \ "before=${P6C_SEQ_BEFORE} after=$(jq -r '.store.seq' <<<"$P6C_AFTER_STATUS" 2>/dev/null)" # The verdict lives in qa/lib.sh, where the selftest runs it against a store - # that decayed and requires it to go red. A comparison written the wrong way - # round here would pass on every run, and the next person to learn otherwise - # would be a maintainer launching twice — the loop this phase replaces. - relaunch_checks "$P6C_SET_BEFORE" "$P6C_AFTER_STATUS" "$P6C_CLOSED" + # with two bound rows, the wrong row bound, and a stale status, and requires + # each to go red. A comparison written the wrong way round here would pass + # on every run, and the next person to learn otherwise would be a maintainer + # launching twice — the loop this phase replaces. + relaunch_checks "$P6C_EXPECTED" "$P6C_AFTER_STATUS" + + # The beacon after the relaunch. This is the seam that caught the flap + # under the old restore (devbox, 2026-09-22): every restored tab was born + # focused, so the beacon ended on the LAST tab built while zellij's focus + # rested on the first, and Alt+c in the first tab asked nothing while the + # last tab's bar asked for it. One baked tab has no such race, and this + # block is what proves it: the seam stays, verbatim, so a regression that + # opens a second tab at launch is seen here. + # + # NO `anchor_executor` here, on purpose: phase 5 pipes the beacon before it + # presses, and that hides exactly this defect. The launch itself must leave + # the beacon where the focus is. Two presses, so the phase leaves `collapsed` + # where it found it; each must be ONE ask from the bar in the focused tab, + # painted by that same bar. A cooldown re-ask (`source=cooldown`) or an ask + # naming another tab is the flap. + P6C_STAND="$(focused_tab_id)" + check_nonempty "post-relaunch standing tab (focus read, nothing driven)" "$P6C_STAND" + P6C_TOGGLE_MARK="$(zlog_now)" + P6C_TOGGLE_EXPECT="$(jq -r '.store.collapsed // false' <<<"$P6C_AFTER_STATUS" 2>/dev/null)" + for i in 1 2; do + if [[ "$P6C_TOGGLE_EXPECT" == "true" ]]; then P6C_TOGGLE_EXPECT="false"; else P6C_TOGGLE_EXPECT="true"; fi + toggle_pipe + P6C_RC=$? + check "post-relaunch press ${i}/2 pipe accepted" "$([[ $P6C_RC -eq 0 ]] && echo ok || echo failed)" "ok" + check "post-relaunch press ${i}/2 landed (store collapsed flipped)" "$(wait_collapsed "$P6C_TOGGLE_EXPECT")" "$P6C_TOGGLE_EXPECT" + sleep 2 + done + check "post-relaunch presses made one width ask each (a cooldown re-ask is an ask that landed on another tab)" \ + "$(swap_ask_count_since "$P6C_TOGGLE_MARK")" "2" + # The render-path ask names its tab; a cooldown re-ask does not, so the + # count above is what sees a cooldown and this line is what sees the WRONG + # bar asking. + check "post-relaunch asks named the standing tab ${P6C_STAND} as their own (the beacon rests where the focus is)" \ + "$(sandbox_lines_since "$P6C_TOGGLE_MARK" 'clave-bar: swap-width' | grep -c "tab=Some(${P6C_STAND})" || true)" "2" + # Both sides are id sets. Two empty sets compare equal, so the asking set is + # pinned non-empty first; without that the line could stand alone and pass + # on a fleet that logged nothing. + P6C_ASKERS="$(instances_logging_since "$P6C_TOGGLE_MARK" 'clave-bar: swap-width' | tr '\n' ' ')" + check_nonempty "post-relaunch at least one sandbox instance asked" "$P6C_ASKERS" + check "post-relaunch paints came from the bar that asked (the swap landed on the tab that asked for it)" \ + "$(instances_logging_since "$P6C_TOGGLE_MARK" 'clave-bar: painted' | tr '\n' ' ')" \ + "$P6C_ASKERS" fi # =========================================================================== @@ -2635,9 +2616,9 @@ measure "sandbox left as driven; store, evlog and drive log preserved for forens if [[ "${P6C_RAN:-no}" == "yes" ]]; then cat </dev/null 2>&1) -want "a fleet that came back whole passes" "$?" "0" - -# #261 itself: the store recorded three, and one bound. The old code bound -# only the tab the maintainer was looking at, so this is what a drive would -# have measured the day the defect shipped. -DECAYED="$(relaunch_checks "$RESTORED_BEFORE" \ - "$(relaunch_status '["u-eager"]' '["u-eager","u-held-a","u-held-b"]')" "u-closed" 2>&1)" -want "a decayed fleet fails" "$?" "1" -want "and the verdict names the size it came back at" \ - "$(grep -c 'SAME SIZE: measured=1 expected=3 FAIL' <<<"$DECAYED")" "1" - -# The other direction: a row whose tab was closed in the first session comes -# back in the second. Every set here matches — the row was still bound when -# the session died, which is a prune that did not happen (phase 3's family) — -# so this is the one defect the size and uuid checks cannot see. -STALE="$(relaunch_checks "$(printf 'u-closed\nu-eager\nu-held-a')" \ - "$(relaunch_status '["u-closed","u-eager","u-held-a"]' '["u-closed","u-eager","u-held-a"]')" \ - "u-closed" 2>&1)" -want "a closed tab that came back fails" "$?" "1" -want "and the verdict names the closed row" \ - "$(grep -c 'closed in the first session is absent' <<<"$STALE")" "1" - -# Every restored row already running its agent. The size and uuid checks all -# pass — the fleet IS back — but it was not RESTORED: each row started its own -# agent unasked, which on a real fleet is ~350 MB apiece. The held floor is the -# only check that can see it, and nothing made that floor go red until this -# case (measured: set the floor to 0 and every other case stayed green, swarm -# review 2026-09-16). -ALL_RUNNING="$(relaunch_checks "$RESTORED_BEFORE" \ - "$(relaunch_status '["u-eager","u-held-a","u-held-b"]' \ - '["u-eager","u-held-a","u-held-b"]' all-running)" "u-closed" 2>&1)" -want "a fleet that came back already running fails" "$?" "1" -want "and the verdict names the held floor" \ - "$(grep -c 'restored rows were bound before their agent ran' <<<"$ALL_RUNNING")" "1" - -# A restored row wearing the status of the session before. Every other check -# passes — the fleet came back whole and held — but the bar draws those rows -# from a claim about processes that are gone: spinning over a turn that -# stopped at the quit, or hollow on a tab that is about to start. (#261, seen -# on QA run 18.) -STALE_STATUS="$(relaunch_checks "$RESTORED_BEFORE" \ - "$(relaunch_status '["u-eager","u-held-a","u-held-b"]' \ - '["u-eager","u-held-a","u-held-b"]' stale-status)" "u-closed" 2>&1)" -want "a fleet that came back wearing the last session's status fails" "$?" "1" -want "and the verdict names the rows" \ - "$(grep -c 'carry no status from the session before: measured=u-held-a u-held-b' \ - <<<"$STALE_STATUS")" "1" - -# The stale-status READER on its own, not through the verdict. Naming it here -# is what makes `script_hygiene` able to require it stay in lib.sh: exercised -# only through `relaunch_checks`, an inline replacement would pass every test -# above while bypassing the contract. (CodeRabbit, #261) -STALE_READ="$(stale_status_uuids "$(relaunch_status \ - '["u-eager","u-held-a","u-held-b"]' '["u-eager","u-held-a","u-held-b"]' stale-status)")" -want "stale_status_uuids names the held rows wearing a stale status" \ - "$(tr '\n' ' ' <<<"$STALE_READ")" "u-held-a u-held-b " -# And says nothing about a fleet that came back clean — the eager row runs, so -# its status is a claim about a process that really is there. -want "stale_status_uuids is silent on a clean fleet" \ - "$(stale_status_uuids "$(relaunch_status \ - '["u-eager","u-held-a","u-held-b"]' '["u-eager","u-held-a","u-held-b"]')")" "" - -# Both sides empty compare EQUAL. A dead `dev status` must not read as a -# perfect restore. -(relaunch_checks "" "$(relaunch_status '[]' '[]')" "" >/dev/null 2>&1) -want "two empty sets are refused, not passed" "$?" "1" - -# Which tab phase 6c closes. Sparing the MINTED row is the whole point: it is -# the only row whose claude gets past the trust prompt, so closing it leaves -# the phase measuring the population the #261 defect cannot reach (runs 14 and -# 15, 2026-09-16). The minted row deliberately holds the LOWEST tab id, which -# is what the first attempt at this rule picked. -CLOSE_PICK='{"store":{"agents":{ - "u-minted":{"tab_id":1,"pane_id":7}, - "u-seeded-a":{"tab_id":4,"pane_id":5}, - "u-seeded-b":{"tab_id":2,"pane_id":6}, - "u-dormant":{"tab_id":null,"pane_id":null}}}}' -want "the closed tab is not the minted row's" \ - "$(close_candidate_tab "$CLOSE_PICK" "u-minted")" "2" -# No spare named: the rule degrades to the lowest bound tab rather than -# refusing to pick at all. -want "and picks the lowest bound tab when no row is spared" \ - "$(close_candidate_tab "$CLOSE_PICK")" "1" -# A one-row fleet still gets the closed-tab half. -want "and falls back to the spare when it is the only bound row" \ - "$(close_candidate_tab '{"store":{"agents":{ - "u-minted":{"tab_id":3,"pane_id":9}}}}' "u-minted")" "3" -# Nothing bound: the phase notes the half is vacuous rather than closing a -# tab it did not choose. -want "and picks nothing when no row is bound" \ - "$(close_candidate_tab '{"store":{"agents":{ - "u-a":{"tab_id":null,"pane_id":null}}}}' "u-minted")" "" - -# The VERDICT TOOL, end to end — not `relaunch_checks`, the script the -# maintainer is told to run when phase 6c times out. Found in review: it -# printed a red verdict and exited 0, because it never opened a phase, so -# `fail_phase` indexed an empty array, aborted under `set -u`, and took its -# own `exit 1` with it. Every case above passes `phase` first, which is -# exactly why none of them saw it — the one context the tool really runs in -# was the one context nothing covered. -STUB_DIR="$(mktemp -d "${TMPDIR:-/tmp}/qa-verdict-stub.XXXXXX")" -trap 'rm -f "$FIXTURE"; rm -rf "$STUB_DIR"' EXIT -verdict_exit() { - # $1: the jq array of uuids bound AFTER the relaunch. $2: before the quit. - cat >"$STUB_DIR/clave" <"$STUB_DIR/before" - CLAVE_BIN="$STUB_DIR/clave" "$SCRIPT_DIR/relaunch-verdict.sh" \ - "$STUB_DIR/before" >/dev/null 2>&1 - echo "$?" -} -BEFORE_SET=(u-eager u-held-a u-held-b) -want "a shrunken fleet makes the verdict tool EXIT NON-ZERO" \ - "$(verdict_exit '["u-eager"]' '["u-eager","u-held-a","u-held-b"]')" "1" -want "and a fleet that came back whole exits zero" \ - "$(verdict_exit '["u-eager","u-held-a","u-held-b"]' \ - '["u-eager","u-held-a","u-held-b"]')" "0" + +(relaunch_checks "u-eager" "$(relaunch_status '["u-eager"]')" >/dev/null 2>&1) +want "one baked tab for the most-recent row passes" "$?" "0" + +# Two bound rows: a launch that baked a set. That is the fleet-in-one-layout +# shape that killed the zellij server (#261, measured 2026-09-17), and it is +# the regression the removal of the restore must never let back in. +TWO="$(relaunch_checks "u-eager" "$(relaunch_status '["u-eager","u-a"]')" 2>&1)" +want "two bound rows fail" "$?" "1" +want "and the verdict names the count" \ + "$(grep -c 'exactly ONE row: measured=2 expected=1 FAIL' <<<"$TWO")" "1" + +# One tab, the wrong row. The count is right, so only the uuid check sees it. +WRONG="$(relaunch_checks "u-eager" "$(relaunch_status '["u-a"]')" 2>&1)" +want "the wrong row bound fails" "$?" "1" +want "and the verdict names the row it got" \ + "$(grep -c 'most-recent one: measured=u-a expected=u-eager FAIL' <<<"$WRONG")" "1" + +# Nothing bound: a launch that baked no tab for a fleet it had. +(relaunch_checks "u-eager" "$(relaunch_status '[]')" >/dev/null 2>&1) +want "no bound row fails" "$?" "1" + +# A dormant row wearing Working after the relaunch. The bind checks pass — one +# tab, the right row — but the bar draws that row as an agent mid-turn over a +# process that stopped at the quit (#261, QA run 18). +STALE_STATUS="$(relaunch_checks "u-eager" "$(relaunch_status '["u-eager"]' stale-status)" 2>&1)" +want "a stale running-process status after the relaunch fails" "$?" "1" +want "and the verdict names the row" \ + "$(grep -c 'from the session before: measured=u-a expected=empty FAIL' <<<"$STALE_STATUS")" "1" + +# An empty expectation against an empty store. Both sides empty compare +# EQUAL, so a dead pre-quit read must be refused, not passed. +(relaunch_checks "" "$(relaunch_status '[]')" >/dev/null 2>&1) +want "an empty status is refused, not passed" "$?" "1" +(relaunch_checks "" "" >/dev/null 2>&1) +want "a dead read on both sides is refused, not passed" "$?" "1" printf '\n%s\n' "== qa/lib selftest: $FAILURES failure(s) ==" [[ "$FAILURES" -eq 0 ]] diff --git a/scripts/qa/lib.sh b/scripts/qa/lib.sh index 76dc4dc..66b14f3 100644 --- a/scripts/qa/lib.sh +++ b/scripts/qa/lib.sh @@ -204,6 +204,32 @@ instance_count_logging() { instances_logging "$1" | grep -c . || true } +# LINES matching since , from this sandbox's instances only — +# the per-line count the per-instance forms above deliberately are not. A +# flap is one instance logging the same ask sixteen times (the devbox, +# 2026-09-22), and an instance count reads that as 1. Attributed the same +# way: the line's `[id: N]` must belong to an instance that announced this +# build. +sandbox_lines_since() { + local mark="$1" pattern="$2" ids + ids="$(sandbox_instance_ids | tr '\n' ' ')" + zlog_from "$mark" | grep -F "$pattern" | while IFS= read -r line; do + id="$(printf '%s\n' "$line" | log_ids)" + [[ -n "$id" && " $ids " == *" $id "* ]] && printf '%s\n' "$line" + done +} + +sandbox_line_count_since() { + sandbox_lines_since "$1" "$2" | grep -c . || true +} + +# The width asks (`clave-bar: swap-width …`, one line per ask the shell +# forwards to zellij) this sandbox's bars made since . A bar at its +# declared width asks nothing, so outside a toggle this is a defect count. +swap_ask_count_since() { + sandbox_line_count_since "$1" 'clave-bar: swap-width' +} + # --------------------------------------------------------------------------- # The session readers: the store, the layout, and focus. # --------------------------------------------------------------------------- @@ -218,155 +244,94 @@ instance_count_logging() { dev_status() { "$CLAVE_BIN" dev status 2>/dev/null; } -# The three readings phase 6c compares across a session boundary. Each takes +# The readings phase 6c takes on both sides of a session boundary. Each takes # a `dev_status` document as its argument rather than reading one itself, so -# the two sides of the comparison are the SAME snapshot shape and can be -# tested offline (qa/lib-selftest.sh) — this branch's whole lesson is that a -# relaunch assertion nobody can run without a launched session is one nobody -# runs. Sorted, because every use is a set comparison. Empty on an unreadable -# document, never an error: the phase asserts the sets are non-empty itself. +# the two sides are the SAME snapshot shape and can be tested offline +# (qa/lib-selftest.sh): a relaunch assertion nobody can run without a +# launched session is one nobody runs. Sorted, because every use is a set +# comparison. Empty on an unreadable document, never an error: the verdict +# refuses on the count itself. # A row is BOUND when it holds a tab id. The bind is the tab_id and nothing -# else (store.rs §6.6) — a pane with no tab is a row mid-spawn, not a member -# of the live set. +# else (store.rs §6.6) — a pane with no tab is a row mid-spawn, not a bound +# row. bound_uuids() { jq -r '.store.agents | to_entries[] | select(.value.tab_id != null) | .key' <<<"$1" 2>/dev/null | sort } -# The HELD signature: a tab and no pane. A restored row wears this from the -# moment the bar binds it until its agent is woken, so it is the reading that -# separates a fleet the bar rebound by itself from tabs a human landed on. -held_bound_uuids() { - jq -r '.store.agents | to_entries[] - | select(.value.tab_id != null and .value.pane_id == null) | .key' <<<"$1" 2>/dev/null | sort -} - -# Which tab phase 6c should close on its way to the quit, so the restore has -# one it must refuse. Takes the `dev status` document and the uuid to SPARE. -# Empty when no row is bound. -# -# The spare is the row the drive MINTED (`clave add`, phase 2 rung 1), and it -# must survive into the restored set. It is the only row in this sandbox whose -# claude gets past the "trust this folder" prompt, so it is the only row that -# ever starts a session, fires a hook, or can reach the #261 `SessionEnd` -# unbind at all. Every seeded row sits at that prompt forever. Close the -# minted row and the phase measures the one population the defect cannot -# touch — which is exactly how the 2026-09-15 verification read green over a -# broken restore. -# -# Two earlier rules failed here and are worth not repeating. "Lowest bound tab -# id" takes the minted row, because it is created first (run 14, 2026-09-16). -# "A tab and no pane" never matches in the FIRST session: that signature -# belongs to a RESTORED row, and every row this drive opens registers a pane -# (run 15, same day — the measure line read `was running`). -# -# Falls back to the spare when it is the only bound row, so a one-row fleet -# still gets the closed-tab half rather than silently skipping it. -close_candidate_tab() { - jq -r --arg spare "${2:-}" ' - [.store.agents | to_entries[] | select(.value.tab_id != null)] as $bound - | (([$bound[] | select(.key != $spare) | .value.tab_id] | sort) - + ([$bound[] | .value.tab_id] | sort)) - | .[0] // empty' <<<"$1" 2>/dev/null -} - -# What the PREVIOUS session left. Written at launch, from the binds standing -# when the session died (setup.rs `clear_session_order`), which is also the -# Restored rows that still carry a status from the session BEFORE. Read over -# the HELD signature only (a tab, no pane): those rows have run nothing in this -# session, so any status on them is a claim about a process that is gone. The -# eager row is excluded by construction — its agent really did start — so this -# can never go red on legitimate state. (#261) +# The row a relaunch bakes its ONE tab for: the most-recent row by +# `last_interacted` whose cwd is still a directory (setup.rs `eager_row`). +# Read from the PRE-QUIT snapshot, because the launch computes it from the +# store the quit left. The cwd test is done here, not in jq, because jq +# cannot stat a path; the drive runs on the machine the sandbox runs on, so +# the test sees the same disk the launch does. Empty when no row qualifies, +# which is the bar-only layout. +eager_candidate_uuid() { + local uuid cwd + while IFS=$'\t' read -r uuid cwd; do + [[ -n "$uuid" && -d "$cwd" ]] && { printf '%s\n' "$uuid"; return 0; } + done < <(jq -r '.store.agents | to_entries + | sort_by(-.value.last_interacted)[] + | [.key, .value.cwd] | @tsv' <<<"$1" 2>/dev/null) + return 0 +} + +# Rows wearing a status that describes a RUNNING process. A launch starts no +# agent and clears Working and NeedsYou on the same pass that clears the binds +# (store.rs `clear_session_order`), and the drive types nothing between the +# relaunch and this read — so any row carrying one after the relaunch is a +# claim about a process that is gone (#261, QA run 18). `Done` and `Failed` +# survive by design: they mark a finished turn the human has not read. stale_status_uuids() { jq -r '.store.agents | to_entries[] - | select(.value.tab_id != null and .value.pane_id == null - and .value.status != "idle") | .key' <<<"$1" 2>/dev/null | sort -} - -# pass that clears them — so this is the only surviving record of the fleet, -# and the expectation the rebound set is measured against. -last_live_uuids() { - jq -r '.store.last_live[]?' <<<"$1" 2>/dev/null | sort + | select(.value.status == "working" or .value.status == "needs_you") | .key' <<<"$1" 2>/dev/null | sort } # How many rows a set holds, and the set on one line for a verdict a human # reads. Non-empty lines only: two empty sets compare EQUAL, so a set -# comparison built on a dead read reports a perfect restore. The count is -# what the phase refuses on before it compares anything. +# comparison built on a dead read reports a perfect relaunch. The count is +# what the verdict refuses on before it compares anything. uuid_count() { printf '%s' "${1:-}" | grep -c .; } uuid_line() { printf '%s' "${1:-}" | tr '\n' ' '; } -# The relaunch verdict (phase 6c): does the fleet the second session holds -# match the one the first session left? Takes the set measured before the -# quit, the `dev status` read after the relaunch, and the row whose tab the -# phase closed on its way to the quit. Every reading comes from one snapshot, -# so no two verdicts below can disagree about which moment they are -# describing. -# -# `closed` must be a row that was unbound AT THE QUIT, which is why the phase -# closes its own tab rather than naming one an earlier phase closed: phase 4 -# wakes the top wakeable dormant row, and that is exactly the row phase 3 -# leaves behind (run 13, 2026-09-16). +# The relaunch verdict (phase 6c). A relaunch bakes ONE tab, for the row +# `eager_candidate_uuid` names, and every other row comes back dormant with +# no running-process status (setup.rs `launch_layout_kdl`, decision of +# 2026-09-22). Takes the uuid expected to be bound, computed from the +# pre-quit snapshot, and the `dev status` read after the relaunch. Every +# reading below comes from that one snapshot, so no two checks can disagree +# about which moment they describe. # # Here rather than in the drive because the phase costs two maintainer # launches, and a verdict that can only be tried by spending them is a verdict -# nobody tries. The selftest runs it against a decayed store, where it must go -# red. +# nobody tries. The selftest runs it against a store with two bound rows, the +# wrong row bound, a stale status, and an empty read — each must go red. relaunch_checks() { - local before="$1" status="$2" closed="${3:-}" - local recorded set_after held n_before n_after n_recorded n_held - recorded="$(last_live_uuids "$status")" + local expected="$1" status="$2" + local set_after n_after stale set_after="$(bound_uuids "$status")" - held="$(held_bound_uuids "$status")" - n_before="$(uuid_count "$before")" n_after="$(uuid_count "$set_after")" - n_recorded="$(uuid_count "$recorded")" - n_held="$(uuid_count "$held")" - measure "the set the launch recorded to restore" "n=${n_recorded} $(uuid_line "$recorded")" - measure "the set the second session rebound" "n=${n_after} $(uuid_line "$set_after")" - - # First, that anything was read at all. Two empty sets compare equal, so - # every verdict under this one would pass on a dead store. - check_min "the quit recorded a set to restore" "$n_recorded" 1 - - # The first session RECORDED what it was holding. A restore that bakes the - # right tabs from a set two launches old passes everything below it. - check "the quit recorded the set the first session was holding" \ - "$(uuid_line "$recorded")" "$(uuid_line "$before")" - - # The decay assertion (#261). The defect bound only the tab the maintainer - # was looking at, so this number read 1 against a fleet of four. - check "the live set comes back the SAME SIZE" "$n_after" "$n_before" - - # And it is the same fleet, not the same COUNT of something else. - check "and holds the same uuids" \ - "$(uuid_line "$set_after")" "$(uuid_line "$recorded")" - - # A tab id with no pane id is the restored leg's signature: the bar bound - # the row while the tab still held nothing. Every row but the eager one - # wears it — the eager row spawns at launch — so the floor is one less than - # the set. - check_min "restored rows were bound before their agent ran (tab, no pane)" \ - "$n_held" "$((n_before - 1))" - - # A status is scoped to a zellij session, so a restored row must carry none - # (#261). The branch's `Exited` made the cost visible: a tab about to start - # its agent came back wearing the hollow "nothing here" mark, beside rows in - # exactly the same state drawn as live. `Working` had the same shape before - # it, spinning over a turn that stopped at the quit. - local stale + measure "the row the launch was expected to bake" "$expected" + measure "the set the second session bound" "n=${n_after} $(uuid_line "$set_after")" + + # First, that the expectation exists. An empty expected uuid against an + # empty bound set would compare equal, and a dead pre-quit read would then + # pass as a perfect relaunch. + check_nonempty "the pre-quit snapshot named a row to bake" "$expected" + + # Exactly one tab. Zero is a launch that baked nothing for a fleet it had; + # two or more is a fleet in one layout, the shape that killed the zellij + # server (#261, measured 2026-09-17). + check "the relaunch bound exactly ONE row" "$n_after" "1" + + # And it is the most-recent row, not whichever the launch happened to pick. + check "and that row is the most-recent one" "$(uuid_line "$set_after")" "$expected" + + # Every other row came back dormant, and none wears a status from the + # session before. stale="$(stale_status_uuids "$status")" - check "restored rows carry no status from the session before" \ + check "no row carries a running-process status from the session before" \ "$(uuid_line "$stale")" "" - - # A closed tab stays closed. Not covered by the three above: a row still - # bound when its tab went (a prune that did not happen, phase 3's family) - # is in every set, so all of them match and the fleet still comes back one - # tab too wide. - if [[ -n "$closed" ]]; then - check "the tab closed in the first session is absent from the second" \ - "$(grep -c -- "$closed" <<<"$set_after")" "0" - fi } # Guarded list-panes read. Never the bare env-var form (TESTING.md, "the @@ -430,9 +395,13 @@ live_tab_ids() { # tab_position ordering — a rank join, deliberately, because it does not care # whether zellij counts tab positions from 0 or from 1. # -# FIRST LIVE RUN PENDING (2): the join assumes the dump lists tabs in tab -# position order. Every caller prints the id it read, so a wrong join shows up -# as a focus that never matches anything rather than as a silent pass. +# The join assumes the dump lists tabs in tab position order. SETTLED +# (runs 24 and 25, 2026-09-22): phase 6c compares this id against the +# `tab=Some(N)` the bar computes for itself from its own frames, on both +# hosts, and they agree. Every earlier caller anchored to whatever it read, so +# a wrong join passed self-consistently; 6c is the first cross-check. If 6c's +# `tab=Some(N)` line goes red while its ask count is green, suspect this join +# before the bar. focused_tab_id() { local dump idx panes dump="$(ct_dump_layout)" || return 1 @@ -570,9 +539,10 @@ fail_phase() { # A caller that opened no phase still gets a FAILING exit. Without this the # arithmetic below is `PHASE_RESULTS[-1]`, which aborts the function under # `set -u` with `bad array subscript` — killing the `exit 1` two lines down - # and returning 0. Measured in review: `relaunch-verdict.sh` printed a red - # verdict and exited 0, and it is the documented recovery for a timed-out - # phase 6c. A check that cannot fail the run is not a check. + # and returning 0. Measured in review (2026-09-17): a standalone verdict + # tool that opened no phase printed a red verdict and exited 0. The tool is + # gone; the guard stays, because a check that cannot fail the run is not a + # check. if (( ${#PHASE_RESULTS[@]} == 0 )); then printf '\nFAILED (no phase open)\n' exit 1 diff --git a/scripts/qa/relaunch-verdict.sh b/scripts/qa/relaunch-verdict.sh deleted file mode 100755 index e3ef26e..0000000 --- a/scripts/qa/relaunch-verdict.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -# Phase 6c's verdict, run on its own against a sandbox that is ALREADY in the -# pre-quit state a full drive left it in. -# -# The drive has no phase resume, so a run whose 6c timed out waiting for the -# maintainer would otherwise cost a complete re-drive — two more launches — to -# re-reach a state the sandbox is already holding. This runs the SAME verdict -# function (`relaunch_checks`, qa/lib.sh) over the same readings, so what it -# proves is what the phase proves. It is not a substitute for a drive: it -# assumes phases 0–6b already ran and left the store flat. -# -# Usage, after the maintainer has quit and relaunched the sandbox: -# scripts/qa/relaunch-verdict.sh [closed-uuid] -# where holds the bound uuids read BEFORE the quit, one per -# line — captured while the first session was still up, because the quit is -# what destroys them. -set -uo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -# lib.sh's header names what a caller must set before sourcing. This script -# is a caller like any other, and it skipped the list: `print_summary` reads -# all five and runs under `set -u`, so a red verdict died in the summary -# instead of reporting one (found in review). -SCENARIO="${SCENARIO:-relaunch-verdict}" -DRIVE_LOG="${DRIVE_LOG:-(not a drive — this tool prints to the terminal only)}" -ZLOG="${ZLOG:-${TMPDIR:-/tmp}/zellij-$(id -u)/zellij-log/zellij.log}" -LOGMARK="${LOGMARK:-0}" -BUILD_TAG="${BUILD_TAG:-$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)}" -# shellcheck source=./lib.sh -source "$ROOT/scripts/qa/lib.sh" -CLAVE_BIN="${CLAVE_BIN:-$ROOT/target/release/clave}" -CT="${CT:-$ROOT/scripts/ct.sh}" - -BEFORE_FILE="${1:?pass the file holding the pre-quit bound uuids}" -CLOSED="${2:-}" -BEFORE="$(cat "$BEFORE_FILE")" - -# `check` reports against the OPEN phase and `fail_phase` ends the run through -# it. With no phase open the whole verdict fell through and exited 0, whatever -# it printed — so opening one is what makes this tool's exit code mean -# something (found in review, reproduced: a red check exited 0). -phase "relaunch-verdict" - -AFTER="$(dev_status)" -if [[ -z "$AFTER" ]]; then - echo "FAILED: dev status returned nothing — is the sandbox up?" >&2 - exit 1 -fi - -relaunch_checks "$BEFORE" "$AFTER" "$CLOSED" -print_summary diff --git a/scripts/qa/width-probe-cli.sh b/scripts/qa/width-probe-cli.sh new file mode 100755 index 0000000..7797097 --- /dev/null +++ b/scripts/qa/width-probe-cli.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# width-probe-cli.sh — provoke a bar's own width ask WITHOUT a toggle. +# +# Companion to width-probe.sh (2026-09-22). Focus one sandbox tab, move it +# by CLI swap to the width the store's mode wants (the bar rests), then move +# it by CLI swap to the other width: the focused bar now disagrees with the +# store and must ask. Sample the tab's first tiled pane width every 150 ms +# across that ask, so a swap that lands and is undone shows as a blip. +# +# scripts/qa/width-probe-cli.sh +set -u +frag="$1"; idx="$2"; since="$3" +cd "$(dirname "$0")/../.." || exit 1 +ct=scripts/ct.sh +w() { + $ct dump-layout 2>/dev/null \ + | awk -v frag="$frag" '/tab name=/{f = index($0, frag) > 0} f && /pane size=/{print $2; exit}' +} +sample() { + for _ in $(seq 1 "$1"); do + echo "$(date +%T.%N | cut -c1-12) $(w)" + sleep 0.15 + done +} +echo "focus tab $idx ($frag)"; $ct go-to-tab "$idx"; sleep 1 +echo "width now: $(w)" +echo "cli previous-swap-layout (1)"; $ct previous-swap-layout; sample 10 +echo "cli previous-swap-layout (2)"; $ct previous-swap-layout; sample 30 +echo "== asks since $since" +log="/tmp/zellij-$(id -u)/zellij-log/zellij.log" +[ -f "$log" ] || log="$HOME/Library/Caches/org.Zellij-Contributors.Zellij/zellij-log/zellij.log" +grep "swap-width" "$log" | grep "$(date +%Y-%m-%d)" | awk -v t="$since" '$4 >= t' \ + | sed 's/^DEBUG |[^|]*| //' | cut -c1-150 diff --git a/scripts/qa/width-probe.sh b/scripts/qa/width-probe.sh new file mode 100755 index 0000000..60cb8b6 --- /dev/null +++ b/scripts/qa/width-probe.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# width-probe.sh — sample one sandbox tab's bar width around two toggles. +# +# A diagnostic for a swap ask the bar logs and the tab does not honour +# (2026-09-22, the devbox's restored tabs). The bar logs only when it asks, +# so "the pane flashed narrow and came back" and "nothing happened" read the +# same in the log. This samples the tab's first tiled pane width from +# `dump-layout` every 150 ms across a toggle pair, through `ct.sh`, so it +# can only ever reach the sandbox. +# +# scripts/qa/width-probe.sh +set -u +frag="$1"; idx="$2"; since="$3" +cd "$(dirname "$0")/../.." || exit 1 +ct=scripts/ct.sh +w() { + $ct dump-layout 2>/dev/null \ + | awk -v frag="$frag" '/tab name=/{f = index($0, frag) > 0} f && /pane size=/{print $2; exit}' +} +sample() { + for _ in $(seq 1 "$1"); do + echo "$(date +%T.%N | cut -c1-12) $(w)" + sleep 0.15 + done +} +echo "focus tab $idx ($frag)"; $ct go-to-tab "$idx"; sleep 1 +echo "width now: $(w)" +echo "toggle 1"; $ct pipe --name clave-toggle -- 1; sample 12 +echo "toggle 2"; $ct pipe --name clave-toggle -- 1; sample 25 +echo "== asks since $since" +log="/tmp/zellij-$(id -u)/zellij-log/zellij.log" +[ -f "$log" ] || log="$HOME/Library/Caches/org.Zellij-Contributors.Zellij/zellij-log/zellij.log" +grep "swap-width" "$log" | grep "$(date +%Y-%m-%d)" | awk -v t="$since" '$4 >= t' \ + | sed 's/^DEBUG |[^|]*| //' | cut -c1-150 diff --git a/scripts/remote-qa.sh b/scripts/remote-qa.sh new file mode 100755 index 0000000..14e11af --- /dev/null +++ b/scripts/remote-qa.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# remote-qa.sh — the QA loop against a REMOTE machine, the same shape as the +# local one: stage, wait for the human's launch, drive, read the logs. +# +# Why (2026-09-22): the devbox surfaced a fleet of regressions no Mac sandbox +# could reproduce — the width flap needed `pane_frames false`, which the box +# has and the Mac does not, and the restore defects needed the box's Claude +# Code daemon. Every one of them was found by a human at the box and +# diagnosed by an agent reading the box's log over ssh. This script makes +# that loop the ordinary one: the agent runs the drive ON the box, over ssh, +# and every log it produces is readable from here. +# +# One code path (AGENTS.md): nothing here is a second drive. It pushes this +# checkout to a plain git repo on the remote, and runs the SAME `just qa` +# there, in the remote's own environment — its zellij config, its frames +# setting, its Claude Code. The remote sandbox is `clave dev instance` on the +# remote, keyed off the remote checkout dir like every sandbox; the remote's +# live fleet is untouched for the same reasons the local one is. +# +# The human's one job is unchanged: launch. The launch command is +# `ssh -t 'cd && just launch'`, and it works from ANY terminal, +# inside zellij or not, because ssh does not forward the ZELLIJ variables that +# make `just launch` refuse. +# +# Subcommands: +# sync push HEAD to the remote checkout (creates it if absent) +# qa [scenario] [wait] +# sync, then start `just qa ` on the +# remote, DETACHED; prints the launch command first +# qa-log [n] the last n lines of the detached run's log +# qa-running "running" while the remote stage or drive is alive +# stage [scenario] sync, then `just sandbox ` only +# log [n] the last n lines of the remote zellij log (default 40) +# drive-log [n] the last n lines of the newest remote drive log +# instance the remote sandbox's session name and root +# kill kill the remote SANDBOX session, by its exact name +# +# Env: CLAVE_QA_HOST (default devbox), CLAVE_QA_DIR (default code/clave-qa, +# relative to the remote $HOME). +set -euo pipefail + +HOST="${CLAVE_QA_HOST:-devbox}" +DIR="${CLAVE_QA_DIR:-code/clave-qa}" +# The detached run's log on the remote (the drive's own tee'd log lives under +# the remote sandbox state dir as well; `drive-log` reads that one). +RUN_LOG="/tmp/clave-remote-qa.log" +CMD="${1:-qa}" +[[ $# -gt 0 ]] && shift + +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" + +# A non-interactive ssh shell may not carry the remote's cargo and just on +# PATH; `~/.cargo/env` puts cargo there and just is expected beside it. +remote() { + ssh "$HOST" "cd \"\$HOME/$DIR\" 2>/dev/null; [ -f \"\$HOME/.cargo/env\" ] && . \"\$HOME/.cargo/env\"; $*" +} + +# The remote checkout is a plain repo that accepts a push onto its checked-out +# branch (`receive.denyCurrentBranch updateInstead`), so one `git push` is the +# whole sync — no rsync, no scp, and a worktree's `.git` FILE (which rsync +# would copy as a dangling pointer, FOOTGUNS) never leaves this machine. +# The build tag on the remote is then this HEAD's short SHA, which is what +# the drive's preflight reads off the loaded bar. +sync_remote() { + if ! ssh "$HOST" "git -C \"\$HOME/$DIR\" rev-parse --is-inside-work-tree" >/dev/null 2>&1; then + echo "==> Creating the remote checkout $HOST:~/$DIR" + ssh "$HOST" "git init -q -b main \"\$HOME/$DIR\" && git -C \"\$HOME/$DIR\" config receive.denyCurrentBranch updateInstead" + fi + echo "==> Pushing $(git rev-parse --short HEAD) to $HOST:~/$DIR" + git push -q --force "ssh://$HOST/~/$DIR" HEAD:main + remote 'git log --oneline -1' +} + +launch_line() { + cat < The launch is YOURS. From any terminal (ssh forwards no zellij identity): + + ssh -t $HOST 'cd ~/$DIR && just launch' + +EOF +} + +case "$CMD" in + sync) + sync_remote + ;; + stage) + sync_remote + remote "just sandbox ${1:-c8-cold-start}" + launch_line + ;; + qa) + sync_remote + SCENARIO="${1:-qa-fleet}" + WAIT="${2:-1800}" + # The STAGE runs attached and finishes before the launch line prints. + # It used to run inside the detached job, with the launch line printed + # first: run 27 (2026-09-22) launched into a half-staged sandbox, the + # seed ran eight seconds after the launch, deleted the launch.kdl the + # launch had written, and preflight went red on a healthy build. A + # launch line the human can act on must mean the sandbox is ready. + remote "just sandbox $SCENARIO > $RUN_LOG 2>&1; tail -n 3 $RUN_LOG" + # The DRIVE is detached on the remote. It waits up to WAIT seconds for + # the human and then drives for twenty minutes more, and an agent's + # shell tool caps a foreground call well under that (ten minutes here, + # 2026-09-22); an ssh that dies takes an attached remote job with it. So + # the remote owns the process, appends to $RUN_LOG, and `qa-log` reads it. + remote "nohup setsid env QA_WAIT_SECS=$WAIT QA_RELAUNCH_WAIT=$WAIT ./scripts/qa-drive.sh $SCENARIO >> $RUN_LOG 2>&1 < /dev/null & echo \"drive started on $HOST, log $RUN_LOG\"" + launch_line + ;; + drive) + # The drive alone, against a remote sandbox that is ALREADY live — the + # re-run after a fix to the drive itself, when nothing needs restaging + # and the human's session is up. Refuses on its own if it is not. + sync_remote + SCENARIO="${1:-qa-fleet}" + WAIT="${2:-1800}" + remote "nohup setsid env QA_WAIT_SECS=$WAIT QA_RELAUNCH_WAIT=$WAIT ./scripts/qa-drive.sh $SCENARIO > $RUN_LOG 2>&1 < /dev/null & echo \"drive started on $HOST, log $RUN_LOG\"" + ;; + qa-log) + remote "tail -n ${1:-40} $RUN_LOG 2>/dev/null || echo 'no run log yet'" + ;; + qa-running) + remote "pgrep -f 'qa-drive.sh|sandbox-setup.sh' >/dev/null && echo running || echo finished" + ;; + log) + remote "tail -n ${1:-40} /tmp/zellij-\$(id -u)/zellij-log/zellij.log" + ;; + drive-log) + # `find`, not a glob: the remote login shell may be zsh, whose unmatched + # glob is an error, not an empty list. + remote 'f="$(find "$(./target/release/clave dev instance --field state)/qa" -name "drive-*.log" 2>/dev/null | sort | tail -1)"; if [ -n "$f" ]; then tail -n '"${1:-40}"' "$f"; else echo "no drive log yet"; fi' + ;; + instance) + remote './target/release/clave dev instance --field session; ./target/release/clave dev instance --field root' + ;; + kill) + # By exact name, read from the remote binary — never a pattern, never the + # remote's live fleet (AGENTS.md: kill only the sandbox you asked for). + remote 's="$(./target/release/clave dev instance --field session)"; zellij kill-session "$s"; zellij delete-session --force "$s"; echo "killed $s"' + ;; + *) + echo "usage: $0 {sync|stage [scenario]|qa [scenario] [wait]|qa-log [n]|qa-running|log [n]|drive-log [n]|instance|kill}" >&2 + exit 2 + ;; +esac