From 1761f9866a74c41928eda5a7fa5f4aad14a62978 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:38:58 +0200 Subject: [PATCH 1/3] feat(app): emergency stop control with a 10 s deadline (W5-04b) Adds estop::enforce (injectable clock): kill every PTY session, re-signal survivors, and report success only once an empty fleet is observed; a fleet that cannot be counted or is alive at the deadline is an error (fail closed). Tauri commands get_emergency_stop/set_emergency_stop write the W5-04a barrier first, then enforce. Settings gets a small Not-Aus control. Test-First: src-tauri/src/estop.rs::tests::a_fleet_alive_at_the_deadline_is_reported_not_stopped Test-First: src-tauri/src/estop.rs::tests::a_fleet_that_cannot_be_counted_is_never_reported_stopped Test-First: src/components/EmergencyStop.test.tsx::keeps the stop up and shows the error when the end was not confirmed Co-Authored-By: Claude Sonnet 5.5 --- src-tauri/src/estop.rs | 165 ++++++++++++++++++++++++++ src-tauri/src/main.rs | 35 ++++++ src/components/EmergencyStop.test.tsx | 41 +++++++ src/components/EmergencyStop.tsx | 62 ++++++++++ src/components/SettingsView.test.tsx | 2 + src/components/SettingsView.tsx | 3 + src/lib/ipc.ts | 10 ++ 7 files changed, 318 insertions(+) create mode 100644 src-tauri/src/estop.rs create mode 100644 src/components/EmergencyStop.test.tsx create mode 100644 src/components/EmergencyStop.tsx diff --git a/src-tauri/src/estop.rs b/src-tauri/src/estop.rs new file mode 100644 index 00000000..03a62863 --- /dev/null +++ b/src-tauri/src/estop.rs @@ -0,0 +1,165 @@ +//! App side of the global emergency stop (W5-04b). The store half (W5-04a) +//! owns the persistent barrier; this half ends the running agent processes +//! within [`DEADLINE`] and says so only when it has observed that no session +//! is left. Fail closed: a fleet that cannot be counted, or still has +//! sessions at the deadline, is reported as not stopped - never as stopped. +use std::time::{Duration, Instant}; + +/// How long the app may take to end every running dispatch. +pub const DEADLINE: Duration = Duration::from_secs(10); + +/// How often the fleet is re-checked (and re-signalled) while waiting. +const POLL: Duration = Duration::from_millis(100); + +/// Time source, injectable so the deadline is tested without waiting. +pub trait Clock { + fn now(&self) -> Instant; + fn sleep(&self, duration: Duration); +} + +pub struct SystemClock; + +impl Clock for SystemClock { + fn now(&self) -> Instant { + Instant::now() + } + fn sleep(&self, duration: Duration) { + std::thread::sleep(duration); + } +} + +/// The running agent processes, as far as the stop can reach them. +pub trait Fleet { + /// Signal every session, including reservations not yet spawned. + fn kill_all(&self); + /// Sessions still alive; an error means "cannot tell". + fn live(&self) -> Result; +} + +impl Fleet for crate::pty::PtyManager { + fn kill_all(&self) { + crate::pty::PtyManager::kill_all(self); + } + fn live(&self) -> Result { + self.live_session_ids().map(|ids| ids.len()) + } +} + +/// Kill the fleet and wait until it is observed empty. Signals again on every +/// poll so a spawn that raced the first signal is caught too. +pub fn enforce(fleet: &dyn Fleet, clock: &dyn Clock, deadline: Duration) -> Result<(), String> { + let end = clock.now() + deadline; + loop { + fleet.kill_all(); + let remaining = fleet.live().map_err(|error| { + format!("emergency stop: cannot confirm that agents ended: {error}") + })?; + if remaining == 0 { + return Ok(()); + } + if clock.now() >= end { + return Err(format!( + "emergency stop: {remaining} agent session(s) still alive after {}s", + deadline.as_secs() + )); + } + clock.sleep(POLL); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + /// Virtual clock: `sleep` advances time and never waits. + struct FakeClock { + start: Instant, + elapsed: Cell, + } + + impl FakeClock { + fn new() -> Self { + Self { + start: Instant::now(), + elapsed: Cell::new(Duration::ZERO), + } + } + } + + impl Clock for FakeClock { + fn now(&self) -> Instant { + self.start + self.elapsed.get() + } + fn sleep(&self, duration: Duration) { + self.elapsed.set(self.elapsed.get() + duration); + } + } + + /// Dies `dies_after` into the fake timeline; `None` never dies. + struct FakeFleet<'a> { + clock: &'a FakeClock, + dies_after: Option, + signals: Cell, + broken: bool, + } + + impl Fleet for FakeFleet<'_> { + fn kill_all(&self) { + self.signals.set(self.signals.get() + 1); + } + fn live(&self) -> Result { + if self.broken { + return Err("registry poisoned".into()); + } + Ok(match self.dies_after { + Some(after) if self.clock.elapsed.get() >= after => 0, + _ => 1, + }) + } + } + + fn fleet(clock: &FakeClock, dies_after: Option, broken: bool) -> FakeFleet<'_> { + FakeFleet { + clock, + dies_after, + signals: Cell::new(0), + broken, + } + } + + #[test] + fn a_fleet_that_ends_inside_the_deadline_is_stopped() { + let clock = FakeClock::new(); + let fleet = fleet(&clock, Some(Duration::from_secs(9)), false); + enforce(&fleet, &clock, DEADLINE).unwrap(); + assert!(clock.elapsed.get() <= DEADLINE); + assert!(fleet.signals.get() > 1, "survivors are signalled again"); + } + + #[test] + fn a_fleet_alive_at_the_deadline_is_reported_not_stopped() { + let clock = FakeClock::new(); + let fleet = fleet(&clock, None, false); + let error = enforce(&fleet, &clock, DEADLINE).unwrap_err(); + assert!(error.contains("still alive after 10s"), "{error}"); + assert!(clock.elapsed.get() >= DEADLINE); + assert!(clock.elapsed.get() <= DEADLINE + POLL, "no overshoot"); + } + + #[test] + fn a_fleet_that_cannot_be_counted_is_never_reported_stopped() { + let clock = FakeClock::new(); + let fleet = fleet(&clock, Some(Duration::ZERO), true); + let error = enforce(&fleet, &clock, DEADLINE).unwrap_err(); + assert!(error.contains("cannot confirm"), "{error}"); + } + + #[test] + fn an_empty_fleet_returns_without_waiting() { + let clock = FakeClock::new(); + let fleet = fleet(&clock, Some(Duration::ZERO), false); + enforce(&fleet, &clock, DEADLINE).unwrap(); + assert_eq!(clock.elapsed.get(), Duration::ZERO); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f1ce94e5..f86e5330 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -72,6 +72,7 @@ mod diagnosis; mod diff; mod digest; mod enhance; +mod estop; mod freetier; mod gh; mod hooks; @@ -1132,6 +1133,38 @@ fn set_updater_state( Ok(()) } +// -- emergency stop (W5-04b) ---------------------------------------------- + +/// The persistent barrier. An unreadable state is an error, which the UI +/// shows as "stopped" - never as permission to dispatch. +#[tauri::command] +async fn get_emergency_stop(store: State<'_, Store>) -> Result { + store.emergency_stop_active().await +} + +/// Raise or clear the global emergency stop. Raising writes the barrier first +/// (new dispatches are refused from that moment, `set_emergency_stop` also +/// revokes claims in flight), then ends every agent session within +/// `estop::DEADLINE`; an `Err` means the end was not observed and the barrier +/// stays up. Clearing only lifts the barrier - nothing is revived. +#[tauri::command] +async fn set_emergency_stop( + store: State<'_, Store>, + manager: State<'_, PtyManager>, + active: bool, +) -> Result<(), String> { + store.set_emergency_stop(active, "app").await?; + if !active { + return Ok(()); + } + let manager = manager.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + estop::enforce(&manager, &estop::SystemClock, estop::DEADLINE) + }) + .await + .map_err(|error| format!("emergency stop: {error}"))? +} + // -- task queue (Phase 7) ------------------------------------------------- #[tauri::command] @@ -3671,6 +3704,8 @@ fn main() { write_pty, resize_pty, kill_pty, + get_emergency_stop, + set_emergency_stop, get_scrollback, get_session_restore, list_agent_profiles, diff --git a/src/components/EmergencyStop.test.tsx b/src/components/EmergencyStop.test.tsx new file mode 100644 index 00000000..c2cab28b --- /dev/null +++ b/src/components/EmergencyStop.test.tsx @@ -0,0 +1,41 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import EmergencyStop from "./EmergencyStop"; + +const ipc = vi.hoisted(() => ({ + getEmergencyStop: vi.fn(), + setEmergencyStop: vi.fn(), +})); +vi.mock("../lib/ipc", () => ({ ...ipc, describeError: (e: unknown) => String(e) })); + +describe("EmergencyStop", () => { + beforeEach(() => { + ipc.getEmergencyStop.mockReset(); + ipc.setEmergencyStop.mockReset(); + }); + + it("raises the stop and shows it active", async () => { + ipc.getEmergencyStop.mockResolvedValue(false); + ipc.setEmergencyStop.mockResolvedValue(undefined); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Not-Aus auslösen" })); + expect(await screen.findByRole("button", { name: "Not-Aus aufheben" })).toBeTruthy(); + expect(ipc.setEmergencyStop).toHaveBeenCalledWith(true); + }); + + it("treats an unreadable state as stopped", async () => { + ipc.getEmergencyStop.mockRejectedValue("db down"); + render(); + expect(await screen.findByRole("button", { name: "Not-Aus aufheben" })).toBeTruthy(); + }); + + it("keeps the stop up and shows the error when the end was not confirmed", async () => { + ipc.getEmergencyStop.mockResolvedValue(false); + ipc.setEmergencyStop.mockRejectedValue("1 agent session(s) still alive after 10s"); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Not-Aus auslösen" })); + await waitFor(() => expect(screen.getByText(/still alive after 10s/)).toBeTruthy()); + expect(screen.getByRole("button", { name: "Not-Aus aufheben" })).toBeTruthy(); + }); +}); diff --git a/src/components/EmergencyStop.tsx b/src/components/EmergencyStop.tsx new file mode 100644 index 00000000..3050e606 --- /dev/null +++ b/src/components/EmergencyStop.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; + +import { describeError, getEmergencyStop, setEmergencyStop } from "../lib/ipc"; + +/** + * Not-Aus: stops every running agent (within 10 s) and blocks new dispatches + * until it is lifted. An unreadable state is shown as "stopped", never as free. + */ +export default function EmergencyStop() { + const [active, setActive] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let alive = true; + getEmergencyStop() + .then((value) => alive && setActive(value === true)) + .catch(() => alive && setActive(true)); + return () => { + alive = false; + }; + }, []); + + async function change(next: boolean) { + setBusy(true); + setError(null); + try { + await setEmergencyStop(next); + setActive(next); + } catch (e) { + setError(describeError(e)); + // The barrier is written before the processes are ended: after a + // failure raising it, it is up even though the end is unconfirmed. + if (next) setActive(true); + } finally { + setBusy(false); + } + } + + return ( +
+ Not-Aus +
+ {active ? ( + + ) : ( + + )} +
+

+ {active + ? "Not-Aus ist aktiv: keine neuen Aufgaben, alle Agenten sind beendet." + : "Beendet binnen 10 Sekunden alle laufenden Agenten und stoppt neue Aufgaben."} +

+ {error ? {error} : null} +
+ ); +} diff --git a/src/components/SettingsView.test.tsx b/src/components/SettingsView.test.tsx index 0d46683e..9a0b650a 100644 --- a/src/components/SettingsView.test.tsx +++ b/src/components/SettingsView.test.tsx @@ -21,6 +21,8 @@ vi.mock("../lib/ipc", () => ({ getProjectSetupCommand: (...args: unknown[]) => getProjectSetupCommand(...args), getRoutingStatus: vi.fn(() => new Promise(() => {})), getStuckAfterMinutes: vi.fn(() => new Promise(() => {})), + getEmergencyStop: vi.fn(() => new Promise(() => {})), + setEmergencyStop: vi.fn(() => Promise.resolve()), getUpdaterState: vi.fn(() => new Promise(() => {})), listAgentProfiles: vi.fn(() => new Promise(() => {})), listLiveSessions: vi.fn(() => new Promise(() => {})), diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index bc7106ae..6f1e6489 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -41,6 +41,7 @@ import { setMasterPromptEnabled, type UiDensity, } from "../lib/settings"; +import EmergencyStop from "./EmergencyStop"; import { handleTablistKey, tabStop } from "../lib/tabs"; import type { AgentCategoryConfig, AgentProfile, Budget, Project } from "../types"; @@ -797,6 +798,8 @@ export default function SettingsView({ > {tab === "allgemein" ? ( <> + +
Darstellungsdichte
diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index b112550e..37d6e29d 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -342,6 +342,16 @@ export async function setUpdaterState(state: UpdaterState): Promise { await invoke("set_updater_state", { state }); } +/** Global emergency stop. A rejection means "unknown", shown as stopped. */ +export async function getEmergencyStop(): Promise { + return invoke("get_emergency_stop"); +} + +/** Raise (ends every agent within 10 s, rejects if not observed) or clear it. */ +export async function setEmergencyStop(active: boolean): Promise { + await invoke("set_emergency_stop", { active }); +} + /** * Owned session ids, including reserved and starting processes. Unavailable or * malformed inventory must reject: it cannot authorize an idle update. From a1f9d7ca20ce89df11b9e7c088a48d5c46797b95 Mon Sep 17 00:00:00 2001 From: Cuarroc Date: Sat, 3 Oct 2026 02:03:21 +0200 Subject: [PATCH 2/3] test: pin emergency stop safety races Test-First: src/components/EmergencyStop.test.tsx::keeps the stop up and shows the error when the end was not confirmed Test-First: src-tauri/src/pty.rs::kill_all_blocks_new_sessions_until_the_stop_is_cleared Test-First: src-tauri/src/main.rs::emergency_stop_changes_are_serialized --- src-tauri/src/main.rs | 16 ++++++++++++++++ src-tauri/src/pty.rs | 7 +++++++ src/components/EmergencyStop.test.tsx | 4 +++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f86e5330..744eaea0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4484,4 +4484,20 @@ mod tests { ); } } + + #[test] + fn emergency_stop_changes_are_serialized() { + const SOURCE: &str = include_str!("main.rs"); + let command = SOURCE + .split("async fn set_emergency_stop") + .nth(1) + .expect("set command") + .split("// -- task queue") + .next() + .expect("command boundary"); + assert!( + command.contains("gate.lock().await"), + "raising and clearing the stop must not overlap" + ); + } } diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index af6a7888..659c84e3 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -2059,6 +2059,13 @@ mod tests { assert!(manager.reserve_session().is_err()); } + #[test] + fn kill_all_blocks_new_sessions_until_the_stop_is_cleared() { + let manager = PtyManager::default(); + manager.kill_all(); + assert!(manager.reserve_session().is_err()); + } + /// Shutdown under poison used to return before touching a single session, /// leaving every agent process running after the app was gone. A /// reservation is the one entry a test can check without a real child: diff --git a/src/components/EmergencyStop.test.tsx b/src/components/EmergencyStop.test.tsx index c2cab28b..888ad5c3 100644 --- a/src/components/EmergencyStop.test.tsx +++ b/src/components/EmergencyStop.test.tsx @@ -35,7 +35,9 @@ describe("EmergencyStop", () => { ipc.setEmergencyStop.mockRejectedValue("1 agent session(s) still alive after 10s"); render(); fireEvent.click(await screen.findByRole("button", { name: "Not-Aus auslösen" })); - await waitFor(() => expect(screen.getByText(/still alive after 10s/)).toBeTruthy()); + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/still alive after 10s/)); expect(screen.getByRole("button", { name: "Not-Aus aufheben" })).toBeTruthy(); + expect(screen.getByRole("status")).toHaveTextContent(/Stillstand ist nicht bestätigt/); + expect(screen.queryByText(/alle Agenten sind beendet/)).toBeNull(); }); }); From 00380256366ed8ae8f4fa854e21becaf729bb2b3 Mon Sep 17 00:00:00 2001 From: Cuarroc Date: Sat, 3 Oct 2026 02:08:18 +0200 Subject: [PATCH 3/3] fix(app): keep emergency stop fail-closed Regression-For: a1f9d7ca20ce89df11b9e7c088a48d5c46797b95 --- src-tauri/src/main.rs | 17 +++++++++++++++++ src-tauri/src/pty.rs | 15 +++++++++++++++ src/components/EmergencyStop.tsx | 9 +++++++-- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 744eaea0..39ac2489 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1151,12 +1151,16 @@ async fn get_emergency_stop(store: State<'_, Store>) -> Result { async fn set_emergency_stop( store: State<'_, Store>, manager: State<'_, PtyManager>, + gate: State<'_, tokio::sync::Mutex<()>>, active: bool, ) -> Result<(), String> { + let _guard = gate.lock().await; store.set_emergency_stop(active, "app").await?; if !active { + manager.set_emergency_stop_active(false); return Ok(()); } + manager.set_emergency_stop_active(true); let manager = manager.inner().clone(); tauri::async_runtime::spawn_blocking(move || { estop::enforce(&manager, &estop::SystemClock, estop::DEADLINE) @@ -3473,6 +3477,7 @@ fn main() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .manage(PtyManager::default()) + .manage(tokio::sync::Mutex::new(())) // The web interface starts on demand; its state only exists so the // frontend can ask for status or stop it later. .manage(Mutex::new(WebInterfaceState::default())) @@ -3514,6 +3519,14 @@ fn main() { // would fail, which is why it is the first thing done with `dir`. learnings::set_data_dir(&dir); let store = init_store(&handle, &dir)?; + let stopped = tauri::async_runtime::block_on(store.emergency_stop_active()) + .unwrap_or_else(|error| { + eprintln!("projecta: emergency stop state unavailable at startup: {error}"); + true + }); + handle + .state::() + .set_emergency_stop_active(stopped); // W2-06: committed supervisor changes reach the window as runtime // notifications; the payload holds ids and reason codes only. let notice_app = handle.clone(); @@ -4425,6 +4438,10 @@ mod tests { ".manage(Mutex::new(WebInterfaceState::default()))", ), ("PtyManager", ".manage(PtyManager::default())"), + ( + "tokio::sync::Mutex<()>", + ".manage(tokio::sync::Mutex::new(()))", + ), ("Store", "app.manage(store)"), ("PanicNotice", "app.manage(panic_notice)"), ]; diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index 659c84e3..78314b0d 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -572,6 +572,7 @@ impl Drop for StartingSession { pub struct PtyManager { sessions: Arc>>, installation_started: Arc, + emergency_stop_active: Arc, next_id: Arc, on_exit: Arc>>, on_output: Arc>>, @@ -582,6 +583,7 @@ impl Default for PtyManager { Self { sessions: Arc::new(Mutex::new(HashMap::new())), installation_started: Arc::new(AtomicBool::new(false)), + emergency_stop_active: Arc::new(AtomicBool::new(false)), next_id: Arc::new(AtomicU64::new(1)), on_exit: Arc::new(Mutex::new(None)), on_output: Arc::new(Mutex::new(None)), @@ -734,10 +736,19 @@ impl PtyManager { if self.installation_started.load(Ordering::Relaxed) { return Err("Installation has started; restart before starting sessions".into()); } + if self.emergency_stop_active.load(Ordering::Acquire) { + return Err( + "Global emergency stop is active; clear it before starting sessions".into(), + ); + } registry.insert(session_id.clone(), SessionEntry::Reserved); Ok(session_id) } + pub fn set_emergency_stop_active(&self, active: bool) { + self.emergency_stop_active.store(active, Ordering::Release); + } + /// Cancel only a not-yet-consumed reservation; never touches a live child. /// /// Same recovery as [`StartingSession::drop`] and the same reason: a @@ -1230,6 +1241,7 @@ impl PtyManager { /// Kill every session, so app shutdown never orphans an agent process. pub fn kill_all(&self) { + self.set_emergency_stop_active(true); // Shutdown recovers a poisoned registry (W1-15b): returning here // left every agent process running after the app was gone. The loop // only flags and collects, so a recovered map is safe to walk. @@ -2064,6 +2076,9 @@ mod tests { let manager = PtyManager::default(); manager.kill_all(); assert!(manager.reserve_session().is_err()); + manager.set_emergency_stop_active(false); + let id = manager.reserve_session().unwrap(); + manager.cancel_reservation(&id); } /// Shutdown under poison used to return before touching a single session, diff --git a/src/components/EmergencyStop.tsx b/src/components/EmergencyStop.tsx index 3050e606..ef9530cd 100644 --- a/src/components/EmergencyStop.tsx +++ b/src/components/EmergencyStop.tsx @@ -8,6 +8,7 @@ import { describeError, getEmergencyStop, setEmergencyStop } from "../lib/ipc"; */ export default function EmergencyStop() { const [active, setActive] = useState(null); + const [confirmed, setConfirmed] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -27,8 +28,10 @@ export default function EmergencyStop() { try { await setEmergencyStop(next); setActive(next); + setConfirmed(next); } catch (e) { setError(describeError(e)); + setConfirmed(false); // The barrier is written before the processes are ended: after a // failure raising it, it is up even though the end is unconfirmed. if (next) setActive(true); @@ -53,10 +56,12 @@ export default function EmergencyStop() {

{active - ? "Not-Aus ist aktiv: keine neuen Aufgaben, alle Agenten sind beendet." + ? confirmed + ? "Not-Aus ist aktiv: keine neuen Aufgaben, alle Agenten sind beendet." + : "Not-Aus ist aktiv: neue Aufgaben sind gesperrt; der Stillstand ist nicht bestätigt." : "Beendet binnen 10 Sekunden alle laufenden Agenten und stoppt neue Aufgaben."}

- {error ? {error} : null} + {error ? {error} : null} ); }