From 29d1fa9496edc59198f254364829f166919de642 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Wed, 11 Nov 2026 11:27:05 -0600 Subject: [PATCH 1/2] feat(app,cli): the history rewind is a command every surface can name Decision 0082 made the history rewind runnable but left its app half open: the id was handled by the session's apply path, outside the registry, so no surface could enumerate it. A command an agent or a palette cannot name is a command it cannot use. Every enumeration site now reads the session-scoped list too. The command palette filters on `catalog::all_ids()`, so the rewind is offered beside the registry's ids and the palette's counter counts it. Its payload is grounded to the live document: a palette run is a user's undo, one step below where the document sits, not a fuzz depth. The persisted recents restore accepts it for the same reason. The MCP bridge's discovery list extends the registry's names with the session-scoped ones, so `firecraft.listCommands` finds the rewind and `firecraft.apply` can run it. The fuzz drive keeps its call table equal to the registry's ids, so the rewind's payload template sits outside that table: the template exists for the palette and the drive's coverage property is untouched. The reachability measurement therefore does not move. Co-Authored-By: Claude Code --- crates/app-cli/src/mcp.rs | 12 ++- crates/app/src/shell/mod.rs | 54 ++++++++++++-- crates/app/src/shell/palette.rs | 42 ++++++++--- crates/app/tests/history_rewind.rs | 70 ++++++++++++++++++ crates/engine/src/fuzzdrive.rs | 7 ++ docs/NEXT.md | 10 ++- docs/PROGRESS.md | 8 ++ docs/TASKS.md | 5 +- ...ind-is-a-command-every-surface-can-name.md | 74 +++++++++++++++++++ docs/tasks/T10-history-rewind-command.md | 44 +++++++++++ 10 files changed, 306 insertions(+), 20 deletions(-) create mode 100644 docs/decisions/0096-the-rewind-is-a-command-every-surface-can-name.md create mode 100644 docs/tasks/T10-history-rewind-command.md diff --git a/crates/app-cli/src/mcp.rs b/crates/app-cli/src/mcp.rs index 8a355d9c..dc1f0e76 100644 --- a/crates/app-cli/src/mcp.rs +++ b/crates/app-cli/src/mcp.rs @@ -18,8 +18,9 @@ //! CLI `export` verb). //! - `firecraft.undo` / `firecraft.redo` — step the session's undo //! stack; the step label comes back as text. -//! - `firecraft.listCommands` — every registry command id the session -//! binds (`engine::catalog::ids()` authority), one per line. +//! - `firecraft.listCommands` — every command id the session runs +//! (the registry's ids plus the session-scoped ones — the +//! `engine::catalog::all_ids()` authority), one per line. //! //! Resources: `document://current` is the `info` summary as //! `application/json` — agents can read it without a tool round-trip. @@ -333,10 +334,15 @@ fn undo_tool(session: &mut Session, id: serde_json::Value, redo: bool) -> serde_ } } -/// `firecraft.listCommands`: the registry ids the session binds, one +/// `firecraft.listCommands`: every command id the session runs, one /// per line (sorted — discovery wants scan order, not insertion order). +/// +/// The list is the registry's ids plus the session-scoped ones +/// (`history.rewind`): a command `firecraft.apply` can run belongs in +/// the discovery list, or an agent cannot find it. fn list_commands_tool(session: &Session, id: serde_json::Value) -> serde_json::Value { let mut names = session.command_names(); + names.extend(engine::catalog::session_ids()); names.sort_unstable(); tool_result(id, false, &names.join("\n")) } diff --git a/crates/app/src/shell/mod.rs b/crates/app/src/shell/mod.rs index 4de1619d..4dbe7571 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -533,12 +533,14 @@ impl Shell { self.smart_guides = self.layout.smart_guides; self.ui_scale = self.layout.ui_scale; // Recents naming a command this build lacks are dropped — - // same rule as the panel inventory above. + // same rule as the panel inventory above. The list is the + // registry's ids plus the session-scoped ones, because the + // palette offers both. self.palette.recents = self .layout .palette_recents .iter() - .filter(|recent| engine::catalog::ids().contains(&recent.as_str())) + .filter(|recent| engine::catalog::all_ids().contains(&recent.as_str())) .cloned() .collect(); } @@ -4108,7 +4110,7 @@ mod tests { restored.palette.filter.clear(); let hits = restored.palette_matches(); assert_eq!(&hits[..2], ["object.setOpacity", "autoshape.rect"]); - assert_eq!(hits.len(), engine::catalog::ids().len()); + assert_eq!(hits.len(), engine::catalog::all_ids().len()); } /// reachable by an empty filter, carries a non-null payload @@ -4120,12 +4122,16 @@ mod tests { let mut shell = gui_shell(gui_editor()); shell.toggle_palette(); assert!(shell.palette.open, "the palette opens on demand"); - let catalog = engine::catalog::ids(); + let catalog = engine::catalog::all_ids(); assert_eq!( shell.palette_matches(), catalog, "palette lists the catalog" ); + assert!( + catalog.contains(&engine::REWIND_ID), + "the session-scoped rewind is offered alongside the registry" + ); for name in catalog { let payload = shell.payload_for(name); assert!(payload.is_object(), "{name}: payload is not an object"); @@ -4140,6 +4146,44 @@ mod tests { } } + /// The palette runs the session-scoped rewind: the command is + /// offered beside the registry's ids, its payload is grounded to + /// the live history (one step back, not a fuzz depth), and running + /// it takes that step back. + #[test] + fn palette_runs_the_session_scoped_rewind() { + let mut shell = gui_shell(gui_editor()); + assert!( + shell.palette_matches().contains(&engine::REWIND_ID), + "the palette offers the rewind" + ); + let before = shell.editor.session.undo_len(); + let id = shell.editor.current_cell().expect("cell").elements[0]; + shell + .editor + .apply( + "object.rename", + serde_json::json!({ "id": id, "name": "renamed" }), + ) + .expect("rename"); + assert_eq!(shell.editor.session.undo_len(), before + 1); + + // The grounded payload names the depth below where the + // document sits — the step the run takes back. + let payload = shell.payload_for(engine::REWIND_ID); + assert_eq!( + payload["depth"], + serde_json::json!(before), + "the payload is grounded to the live history" + ); + assert!( + shell.run_palette_command(engine::REWIND_ID), + "the rewind runs: {status}", + status = shell.status + ); + assert_eq!(shell.editor.session.undo_len(), before); + } + /// The palette filter narrows by substring (case-insensitive), the /// selection index is reset when the query changes, and grounding /// clamps the fuzzdrive template's out-of-range targets to the @@ -4148,7 +4192,7 @@ mod tests { #[test] fn palette_filters_and_grounds_payloads() { let mut shell = gui_shell(gui_editor()); - let total = engine::catalog::ids().len(); + let total = engine::catalog::all_ids().len(); shell.palette.filter = "rect".to_owned(); for name in shell.palette_matches() { diff --git a/crates/app/src/shell/palette.rs b/crates/app/src/shell/palette.rs index 446ccf6e..ee881faf 100644 --- a/crates/app/src/shell/palette.rs +++ b/crates/app/src/shell/palette.rs @@ -3,13 +3,13 @@ use super::*; -/// The command palette: Ctrl+P opens; typing filters `catalog::ids()` -/// by substring; Enter runs the top hit through `Editor::apply` (the -/// same registry the menus and the journal use). Payload templates -/// come from the engine's fuzzdrive table — the same table whose -/// coverage test (`the_call_table_covers_the_catalog`) guarantees one -/// arm per registered command. Templates are built lazily, for the -/// visible page of hits only. +/// The command palette: Ctrl+P opens; typing filters +/// `catalog::all_ids()` by substring; Enter runs the top hit through +/// `Editor::apply` (the same registry the menus and the journal use). +/// Payload templates come from the engine's fuzzdrive table — the same +/// table whose coverage test (`the_call_table_covers_the_catalog`) +/// guarantees one arm per registered command. Templates are built +/// lazily, for the visible page of hits only. #[derive(Default)] pub(crate) struct CommandPalette { pub(super) open: bool, @@ -55,9 +55,13 @@ impl Shell { /// first. An empty filter shows the recents, then the catalog in /// order. Ties and unscored names keep catalog order (stable /// sort). + /// + /// The catalog is the registry's ids *plus* the session-scoped + /// ones (`history.rewind`): a command the engine can run belongs + /// in the palette even when the registry cannot own it. pub(super) fn palette_matches(&self) -> Vec<&'static str> { let query = self.palette.filter.to_lowercase(); - let catalog = engine::catalog::ids(); + let catalog = engine::catalog::all_ids(); if query.is_empty() { // Recents first (deduplicated, catalog-resident only), // then the remaining catalog in order. @@ -103,10 +107,30 @@ impl Shell { /// The payload template for one command under one seed. pub(super) fn payload_for_seed(&self, name: &str, seed: u64) -> serde_json::Value { + if name == engine::REWIND_ID { + // The rewind's payload names a depth in the undo history. + // A palette run is a *user's* undo — one step back from + // where the document sits now — not a fuzz depth. + return serde_json::json!({ "depth": self.rewind_depth() }); + } let mut rng = engine::fuzzdrive::FuzzRng::new(seed); engine::fuzzdrive::payload_template(name, &mut rng, self.editor.document()) } + /// The depth the palette's rewind lands on: one step below the + /// document's current place in the undo history. + /// + /// The history's rows are the document as opened (depth `0`) plus + /// one row per recorded edit, so the row count is the depth the + /// document sits at plus one. + fn rewind_depth(&self) -> usize { + self.editor + .session + .history_entries() + .len() + .saturating_sub(2) + } + /// Grounds a template to the live editor state. The fuzzdrive /// table aims targets out of range on purpose (a fuzz driver wants /// refused calls); a user does not. Target keys are clamped to the @@ -370,7 +394,7 @@ impl Shell { ui.weak(format!( "{} of {}", names.len(), - engine::catalog::ids().len() + engine::catalog::all_ids().len() )); }); }); diff --git a/crates/app/tests/history_rewind.rs b/crates/app/tests/history_rewind.rs index 31d3a08f..f3696294 100644 --- a/crates/app/tests/history_rewind.rs +++ b/crates/app/tests/history_rewind.rs @@ -4,8 +4,13 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +use std::rc::Rc; + use app::editor::Editor; +use app_cli::mcp; use base64::Engine as _; +use engine::Session; +use engine::platform::{FileSystem, MemoryFs}; /// Imports one 2×2 opaque-red raster and returns its id. fn import_raster(editor: &mut Editor, name: &str) -> doc::ObjectId { @@ -92,3 +97,68 @@ fn a_rewind_past_the_recorded_depth_is_refused() { ); assert_eq!(editor.session.undo_len(), base + 1); } + +/// The MCP surface names the session-scoped rewind and runs it: a +/// command an agent cannot discover is a command the agent cannot +/// use. +#[test] +fn the_mcp_surface_lists_and_runs_the_rewind() { + let base = Editor::untitled().session.document().clone(); + let mut session = Session::new(base); + + let call = |session: &mut Session, name: &str, arguments: serde_json::Value| { + mcp::handle( + session, + Rc::new(MemoryFs::new()) as Rc, + &serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": name, "arguments": arguments }, + }), + ) + .expect("tools/call answers") + }; + + // The discovery list carries the session-scoped id beside the + // registry's. + let reply = call( + &mut session, + "firecraft.listCommands", + serde_json::json!({}), + ); + let text = reply["result"]["content"][0]["text"] + .as_str() + .expect("text"); + assert!( + text.split('\n').any(|line| line == engine::REWIND_ID), + "the rewind is listed: {text}" + ); + + // And it runs: one step of the undo stack is taken back. + let before = session.undo_len(); + session + .apply( + "object.createPath", + serde_json::json!({ + "kind": "rect", "x": 10.0, "y": 20.0, + "width": 80.0, "height": 40.0, + "page": 0, "layer": 0, "cell": 0, "name": "plate", + }), + ) + .expect("create"); + assert_eq!(session.undo_len(), before + 1); + + let reply = call( + &mut session, + "firecraft.apply", + serde_json::json!({ + "command": engine::REWIND_ID, + "args": { "depth": before }, + }), + ); + assert_eq!( + reply["result"]["isError"], + serde_json::json!(false), + "{reply}" + ); + assert_eq!(session.undo_len(), before); +} diff --git a/crates/engine/src/fuzzdrive.rs b/crates/engine/src/fuzzdrive.rs index 463447c5..597fdbd6 100644 --- a/crates/engine/src/fuzzdrive.rs +++ b/crates/engine/src/fuzzdrive.rs @@ -3056,6 +3056,13 @@ pub fn payload_template(name: &str, rng: &mut FuzzRng, document: &Document) -> s "contiguous": rng.chance(3), "mode": rng.pick(&["replace", "add", "subtract", "intersect"]).copied(), }), + // The session-scoped rewind names a depth in the undo + // history. The depth is a *count*, not an index, so the + // template aims one past the top of a fresh session: a + // refused call, exactly like the other out-of-range arms. + crate::session::REWIND_ID => serde_json::json!({ + "depth": rng.below(4), + }), other => { let _ = other; // A catalog command without a builder: an empty payload is diff --git a/docs/NEXT.md b/docs/NEXT.md index 02b1a720..7239e2e4 100644 --- a/docs/NEXT.md +++ b/docs/NEXT.md @@ -421,7 +421,15 @@ evidence work, tracked outside that campaign. `new_parks_the_current_document`, `close_refuses_a_dirty_document`. (Window-menu panel toggles landed in Phase 19.6 batches 3 and 10.) -- **20.6 History panel click-to-rewind** (S): journal replay to seq N. +- **20.6 History panel click-to-rewind** (S): **LANDED 2026-11-11** — the + clickable History rows (the engine half, decision 0082) and the + command surface: `history.rewind` is a session-scoped id, so every + surface that enumerates commands enumerates + `catalog::all_ids()` — the command palette offers it (with its + payload grounded one step below the document's live depth) and + `firecraft.listCommands` advertises it + ([T10](tasks/T10-history-rewind-command.md), decision + [0096](decisions/0096-the-rewind-is-a-command-every-surface-can-name.md)). - **20.7 Slice/hotspot behavior editing + HTML preview** (M). ### Phase 21 — Modern differentiators (P2) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 37623d3b..b60dd90b 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -62,6 +62,14 @@ evidence. - **Clean-room boundary:** local CS6 files and Adobe-derived extracts remain uncommitted; visual assets require attribution. +- **History rewind as a command (2026-11-11, decision 0096):** the + session-scoped `history.rewind` is enumerated by + `catalog::all_ids()`, so every surface that lists commands lists it + too: the command palette offers it (its payload grounded one step + below the document's live depth, not a fuzz depth) and the MCP + bridge's `firecraft.listCommands` advertises it. The registry + catalog and the reachability measurement are unchanged — the id is + not a registry id, so 168/253 (66.40%) stands. ## Build and deployment evidence diff --git a/docs/TASKS.md b/docs/TASKS.md index 58ee9a0a..48407954 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -45,7 +45,7 @@ |---|---|---|---| | P0 | Refresh user-visible CS6 parity matrix | **Done — matrix published** | [T26](tasks/T26-parity-matrix.md) → [`CS6-PARITY.md`](CS6-PARITY.md): every CS6 menu, tool, panel, inspector field set, workflow, import/export surface, canvas interaction, keymap area, and selection state classified with an evidence citation and an owning ticket. The browser-sampling check the ticket named was performed on 2026-11-04 against a fresh optimized bundle and is recorded in T22. | | P0 | Phase 22: CS6 shell fidelity and screenshot audit | In progress | [T22](tasks/T22-screenshot-ui.md) — screenshot acceptance is complete; 22.1 panel inventory and 22.2 tool strip are complete in PR #8; 22.5 chrome remains partial, and 22.4 still has evidence-gated bitmap transform/fill-stroke work. [Phase 22](NEXT.md#phase-22--cs6-shell-fidelity-p0-the-looks-and-acts-like-fireworks-phase) | -| P1 | Phase 20: daily-driver editing features | **In progress — 20.1 landed** | In-canvas text editing landed 2026-11-06 ([T20](tasks/T20-text-editing.md)); remaining: Optimize, animation timeline, mask stack, tabs, history rewind, slice behavior; [details](NEXT.md#phase-20--daily-driver-features-p1) | +| P1 | Phase 20: daily-driver editing features | **In progress — 20.1 landed, 20.6's command surface landed** | In-canvas text editing landed 2026-11-06 ([T20](tasks/T20-text-editing.md)); the history rewind's command surface landed 2026-11-11 ([T10](tasks/T10-history-rewind-command.md)); remaining: Optimize, animation timeline, mask stack, tabs, slice behavior; [details](NEXT.md#phase-20--daily-driver-features-p1) | | P1 | Phase 21: modern differentiators | Queued | Autosave, HiDPI, palette, preferences, accessibility, scripting; [details](NEXT.md#phase-21--modern-differentiators) | | P1 | Reconcile live web deployment | Artifact differs | The public manifest's JS/wasm/index hashes differ from local `dist/web`; identify intended deploy and verify it before publishing. See [T18](tasks/T18-release.md). | | P2 | Next-release asset breadth and platform verification | Open | [T23](tasks/T23-release-assets.md); recipes exist, but non-Linux artifacts need real platform build/launch receipts. | @@ -63,6 +63,7 @@ | 26 | Refresh user-visible CS6 parity matrix | [tasks/T26-parity-matrix.md](tasks/T26-parity-matrix.md) | `IMPLEMENTED` — matrix published | parity lane | every CS6 menu, tool, panel, inspector field set, workflow, import/export surface, canvas interaction, keymap area, and selection state is classified with an evidence citation and an owning ticket in [`CS6-PARITY.md`](CS6-PARITY.md); the browser-sampling check the ticket named was performed on 2026-11-04 against a fresh optimized bundle and is recorded in T22 | | 22 | Phase 22: CS6 shell fidelity and screenshot audit | [tasks/T22-screenshot-ui.md](tasks/T22-screenshot-ui.md) | `IN PROGRESS` | UI lane | screenshot acceptance is complete; 22.1 panel inventory and 22.2 tool strip are complete in PR #8; 22.5 chrome remains partial, and 22.4 still has evidence-gated bitmap transform/fill-stroke work. [Phase 22](NEXT.md#phase-22--cs6-shell-fidelity-p0-the-looks-and-acts-like-fireworks-phase) | | 20 | 20.1 In-canvas text editing (egui TextEdit overlay; kittest focus tests) | [tasks/T20-text-editing.md](tasks/T20-text-editing.md) | `IMPLEMENTED` — landed 2026-11-06 (`shell::begin_text_edit` / `text_edit_overlay`) | editing lane | the remaining Phase 20 items (Optimize, animation timeline, mask stack, tabs, history rewind, slice behavior) stay queued below | +| 10 | 20.6 History rewind as a named command (palette + MCP discovery) | [tasks/T10-history-rewind-command.md](tasks/T10-history-rewind-command.md) | `IMPLEMENTED` — landed 2026-11-11 (decision 0096): the session-scoped `history.rewind` is enumerated by `catalog::all_ids()`, so the palette offers it and `firecraft.listCommands` advertises it; the palette's payload is grounded one step below the live depth | editing lane | the panel's click-to-rewind (decision 0082's engine half) stays as the surface it was; the command surface is now the same id | ## Queued (Phase 20 — daily-driver features) @@ -75,7 +76,7 @@ | 8 | 20.3 Animation timeline (frames strip, timing, onion skinning, `symbol.tween`) | [tasks/T8-animation-timeline.md](tasks/T8-animation-timeline.md) | | — | 20.4 Mask stack UI (add/remove/mode/link/move) | — | | 9 | 20.5 Multi-document tabs + Window-menu panel toggles | — | -| 10 | 20.6 History panel click-to-rewind (journal replay to seq N) | — | +| 10 | 20.6 History panel click-to-rewind (journal replay to seq N) | [tasks/T10-history-rewind-command.md](tasks/T10-history-rewind-command.md) | | 11 | 20.7 Slice/hotspot behavior editing + HTML preview | — (landed on `feat/slice-hotspot-edit`, PR #13 — not yet on `main`) | ## Queued (Phase 21 — modern differentiators) diff --git a/docs/decisions/0096-the-rewind-is-a-command-every-surface-can-name.md b/docs/decisions/0096-the-rewind-is-a-command-every-surface-can-name.md new file mode 100644 index 00000000..eb0ef73a --- /dev/null +++ b/docs/decisions/0096-the-rewind-is-a-command-every-surface-can-name.md @@ -0,0 +1,74 @@ +# 0096 — The rewind is a command every surface can name + +Date: 2026-11-11 · Ticket: [T10](../tasks/T10-history-rewind-command.md) · Status: accepted + +## Context + +Decision 0082 landed the engine half of the history rewind: +`history.rewind` is a **session-scoped** command id — handled by +`Session::apply` before the registry lookup, because a rewind acts on +the undo stack rather than on the document, and a catalog command is +required to have an exact document-level inverse. + +That record named the app half as a separate change and warned about +the trap it leaves behind: + +> A surface enumerating commands must use `catalog::all_ids()` to see +> the session-scoped ones. + +The app surface was still enumerating `catalog::ids()`. The History +panel's rows were clickable (the panel rewinds through +`Session::rewind_to` directly), but the command palette offered only +the registry's ids — so `history.rewind` was a command the engine +could run, the palette could not name, and the MCP bridge's +`firecraft.listCommands` could not advertise. An agent that discovers +its commands by asking could not find the rewind; a user who knew the +id could not type it. + +## Decision + +Every surface that enumerates commands enumerates the commands it can +*run*, not the subset the registry owns. + +- `crates/app/src/shell/palette.rs` filters `engine::catalog::all_ids()` + — the registry's ids plus the session-scoped ones — and the palette's + counter ("N of M") counts the same list. +- The shell's persisted *recents* are restored against the same list, + so a recent `history.rewind` survives a restart (a name the build + cannot run is still dropped). +- `crates/app-cli/src/mcp.rs` — `firecraft.listCommands` lists + `session.command_names()` plus `catalog::session_ids()`. + `firecraft.apply` already routed the id through `Session::apply`, so + the command was runnable but invisible; discovery now matches + capability. +- `crates/engine/src/fuzzdrive.rs` carries a payload arm for + `history.rewind` (`{"depth": n}`), so the template table that the + palette and the fuzz driver share can build the command's payload. + The arm is deliberately **not** in `fuzzdrive::CALLS`: that table is + asserted to equal `catalog::ids()` exactly, and the session-scoped + id is not a registry id. + +The palette's payload for the rewind is **grounded to the live +history**: one step below the depth the document currently sits at. +The fuzz template's depth is a random number in `0..4` — right for a +fuzz driver, wrong for a user, who means "undo the last thing". + +## Consequences + +- The palette's catalogue grows by one command. The reachability + measurement does not move: the gate counts the *registry* ids the + shell binds, and `history.rewind` is not a registry id, so + 168/253 (66.40%) stands and the floor is untouched. +- The palette's "covers every command" property now covers the + session-scoped id too, and the property is stated against + `all_ids()` so it cannot silently drift back to the registry subset. +- `catalog::ids()` remains the registry's own list, and the fuzz + properties that depend on it are unchanged. +- A future session-scoped command (a session save, a session reset) + becomes nameable in the palette and in the MCP discovery list by + being added to `catalog::session_ids()` — the enumeration sites do + not need to change again. + +See also decision 0082 (the engine half this completes) and decision +0076 (the runtime-resolved workspace root that lets the gate measure +whichever worktree it runs in). diff --git a/docs/tasks/T10-history-rewind-command.md b/docs/tasks/T10-history-rewind-command.md new file mode 100644 index 00000000..104ccff8 --- /dev/null +++ b/docs/tasks/T10-history-rewind-command.md @@ -0,0 +1,44 @@ +# T10 — 20.6 History rewind as a named command + +**Objective:** the session-scoped `history.rewind` becomes a command +every surface can *name*: the command palette offers it, the MCP +bridge's discovery list advertises it, and a palette run takes back the +step the user meant rather than a fuzz depth. + +**Inputs:** decision 0082 (the engine half — `history.rewind` as a +session-scoped id, `catalog::session_ids()`, `catalog::all_ids()`); +the shell's command palette and its persisted recents; the MCP bridge's +`firecraft.listCommands` / `firecraft.apply` tools; the engine's +fuzzdrive payload-template table. + +**Authority:** decision 0082's design — a rewind is a session +operation, so it stays out of the registry catalog and is enumerated +through `all_ids()`. CS6's History panel remains the clickable +surface; this ticket adds the *command* surface beside it. + +**Stopping condition:** + +- The palette lists `history.rewind` beside the registry's ids, and its + "N of M" counter counts the same list. +- The palette's payload for the rewind is grounded to the live history + (one step below the current depth), and running it takes that step + back. +- The shell's persisted recents keep a recent `history.rewind` across a + restart. +- `firecraft.listCommands` lists the session-scoped id, and + `firecraft.apply` runs it. +- The fuzzdrive payload table carries an arm for the id *without* + entering it in `CALLS` (that table is asserted to equal + `catalog::ids()` exactly). +- The reachability floor does not move: the gate counts registry ids, + and this id is not one. + +**Verification:** `cargo test -p app -p app-cli -p engine`; the +palette unit test +`palette_runs_the_session_scoped_rewind`; the integration test +`the_mcp_surface_lists_and_runs_the_rewind`; the full gate +(`cargo run -p xtask -- ci`) before commit. + +**Status — LANDED (2026-11-11):** the code, tests, decision 0096 and +the ledger rows are in the tree. The gate receipts are attached to the +pull request. From 18f5de05d005b7e0ee73a51749d993049055ea71 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Wed, 11 Nov 2026 15:46:38 -0600 Subject: [PATCH 2/2] ci: size the pipeline watchdogs from the fleet's measured wall-clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A job's `timeout:` is a watchdog, not a gate: it kills the container and records `timed_out` with no diagnostic about what was slow. The budgets were sized by guess, and the guess is now falsified by the fleet's own record — a job was killed at 20.2 min and at 20.3 min while still working, and the release job's worst successful run finished 1.7 minutes inside its 90-minute budget. The runner holds 4 concurrent containers on a 16-core host and each compiles the workspace, so a co-tenant wave inflates a job roughly 4x. Measured on 2026-11-11 with 9 runs in flight (idle in parentheses): job budget worst success idle fmt-layers 20m 20.1 min 0.3 min clippy-test 90m 76.9 min 10.3 min wasm-web 60m 45.6 min 9.0 min corpus-golden 30m 19.6 min 2.5 min perf-budgets 20m 14.3 min 3.2 min fuzz 60m 52.9 min 10.6 min generated-docs 20m 16.1 min 2.1 min release 90m 88.3 min 38.8 min Each budget below is ~2.5x the worst successful duration, so a loaded job still fits and a genuinely hung job is still killed: fmt-layers 45m, clippy-test 150m, wasm-web 90m, corpus-golden 45m, perf-budgets 45m, fuzz 120m, generated-docs 45m, release 180m No gate is weakened: the budgets decide when a container is killed, not whether a check passes. Refs #126 Co-Authored-By: Claude Code --- .gitforge.yml | 46 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/.gitforge.yml b/.gitforge.yml index 9015388a..ca1c2777 100644 --- a/.gitforge.yml +++ b/.gitforge.yml @@ -40,6 +40,18 @@ # docker save firecraft-ci-rust:1 | ssh docker load # - Step logs are head-capped: cargo's compile spam comes first, so a # failure late in a long step can be truncated. Keep steps small. +# - A job's `timeout:` is a WATCHDOG, not a gate: it kills the +# container and records `timed_out`, with no diagnostic about what +# was slow. The budgets below are therefore sized from the fleet's +# measured wall-clock, not from a round guess. The runner holds 4 +# concurrent containers on a 16-core host and each compiles the +# workspace, so a co-tenant wave inflates a job roughly 4x: +# fmt-layers measured 0.3 min idle and 20.1 min loaded; perf-budgets +# 3.2 min idle and 14.3 min loaded. Each budget below is ~2.5x the +# worst *successful* duration observed on 2026-11-11 with 9 runs in +# flight, so a loaded job still fits its budget and a genuinely hung +# job is still killed. A budget that a passing job can outrun is +# indistinguishable from a failure (issue #126). # # The image recipe is infrastructure/docker/ci-rust.Dockerfile; rebuild # and re-ship it when a pinned tool version or the Rust minor moves. A @@ -60,7 +72,11 @@ jobs: - name: Layer-table gate # Compiles xtask alone; the gate itself is cargo metadata. run: CARGO_TARGET_DIR=/tmp/ci-target cargo run --locked -p xtask -- layers - timeout: 20m + # 45m: the gate compiles xtask, so it carries the workspace's + # build wave. Measured 20.1 min on 2026-11-11 with 9 runs in + # flight (0.3 min idle); the previous 20m budget killed a run that + # was 0.5 min from finishing. + timeout: 45m - name: clippy-test image: firecraft-ci-rust:1 @@ -76,7 +92,8 @@ jobs: run: CARGO_TARGET_DIR=/tmp/ci-target cargo test --workspace --locked --no-fail-fast -- --test-threads=2 # The egui/eframe graph is the bulk of the workspace; the wall # absorbs co-tenant build waves on these shared hosts. - timeout: 90m + # 150m: measured 76.9 min on 2026-11-11 with 9 runs in flight. + timeout: 150m - name: wasm-web image: firecraft-ci-rust:1 @@ -89,7 +106,8 @@ jobs: # the image, the fwpng smoke suite executes for real (no # compile-only downgrade), and trunk builds the web bundle. run: CARGO_TARGET_DIR=/tmp/ci-target cargo run --locked -p xtask -- wasm - timeout: 60m + # 90m: measured 45.6 min on 2026-11-11 with 9 runs in flight. + timeout: 90m - name: corpus-golden image: firecraft-ci-rust:1 @@ -99,7 +117,8 @@ jobs: run: CARGO_TARGET_DIR=/tmp/ci-target cargo run --locked -p xtask -- corpus - name: Golden render pins + determinism run: CARGO_TARGET_DIR=/tmp/ci-target cargo run --locked -p xtask -- golden - timeout: 30m + # 45m: measured 19.6 min on 2026-11-11 with 9 runs in flight. + timeout: 45m - name: perf-budgets image: firecraft-ci-rust:1 @@ -110,7 +129,11 @@ jobs: # fleet. The report line names the host, so a trip is # diagnosable as regression vs environment shift. run: CARGO_TARGET_DIR=/tmp/ci-target cargo run --locked -p xtask -- perf - timeout: 20m + # 45m: measured 14.3 min on 2026-11-11 with 9 runs in flight, but + # two runs were killed at the previous 20m budget while still + # working (one on 2026-11-11 at 20.2 min, one at 20.3 min). The + # budget must exceed what a passing job can take. + timeout: 45m - name: fuzz image: firecraft-ci-rust:1 @@ -131,7 +154,9 @@ jobs: # 30m proved too tight: a cold sandbox target dir spends ~20m # compiling before fuzz-quick starts (dfd7f46 run was killed by # the watchdog 10m into the campaign itself). - timeout: 60m + # 120m: measured 52.9 min on 2026-11-11 with 9 runs in flight, so + # the 60m budget left less than a quarter of an hour of margin. + timeout: 120m - name: generated-docs image: firecraft-ci-rust:1 @@ -173,7 +198,8 @@ jobs: CARGO_TARGET_DIR=/tmp/ci-target cargo run --locked -p xtask -- panel-snapshot cmp /tmp/parity.committed docs/planning/generated/parity.md cmp /tmp/scorecard.committed docs/planning/generated/scorecard.md - timeout: 20m + # 45m: measured 16.1 min on 2026-11-11 with 9 runs in flight. + timeout: 45m - name: release image: firecraft-ci-rust:1 @@ -267,4 +293,8 @@ jobs: # directory, so verify from inside each one. (cd artifacts/web && sha256sum -c MANIFEST.sha256) (cd artifacts/native && sha256sum -c MANIFEST.sha256) - timeout: 90m + # 180m: measured 88.3 min on 2026-11-11 with 9 runs in flight — + # 1.7 minutes inside the previous 90m budget. The release job is + # the longest in the chain and runs last, when the fleet is most + # loaded, so it is the job most likely to be killed by its budget. + timeout: 180m