Repository navigation
W5-04b: Not-Aus in der App mit 10-Sekunden-Frist #125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<usize, String>; | ||
| } | ||
|
|
||
| impl Fleet for crate::pty::PtyManager { | ||
| fn kill_all(&self) { | ||
| crate::pty::PtyManager::kill_all(self); | ||
| } | ||
| fn live(&self) -> Result<usize, String> { | ||
| 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| { | ||
|
Comment on lines
+53
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| 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<Duration>, | ||
| } | ||
|
|
||
| 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<Duration>, | ||
| signals: Cell<u32>, | ||
| broken: bool, | ||
| } | ||
|
|
||
| impl Fleet for FakeFleet<'_> { | ||
| fn kill_all(&self) { | ||
| self.signals.set(self.signals.get() + 1); | ||
| } | ||
| fn live(&self) -> Result<usize, String> { | ||
| 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<Duration>, 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); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -72,6 +72,7 @@ mod diagnosis; | |
| mod diff; | ||
| mod digest; | ||
| mod enhance; | ||
| mod estop; | ||
| mod freetier; | ||
| mod gh; | ||
| mod hooks; | ||
|
|
@@ -1132,6 +1133,42 @@ 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<bool, String> { | ||
| 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>, | ||
| gate: State<'_, tokio::sync::Mutex<()>>, | ||
| active: bool, | ||
| ) -> Result<(), String> { | ||
| let _guard = gate.lock().await; | ||
| store.set_emergency_stop(active, "app").await?; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The ten-second timer is created only after Useful? React with 👍 / 👎. |
||
| 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) | ||
| }) | ||
|
Comment on lines
+1165
to
+1167
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
After this polling call returns successfully, the persistent barrier still does not prevent the existing Useful? React with 👍 / 👎. |
||
| .await | ||
| .map_err(|error| format!("emergency stop: {error}"))? | ||
| } | ||
|
|
||
| // -- task queue (Phase 7) ------------------------------------------------- | ||
|
|
||
| #[tauri::command] | ||
|
|
@@ -3440,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())) | ||
|
|
@@ -3481,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::<PtyManager>() | ||
| .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(); | ||
|
|
@@ -3671,6 +3717,8 @@ fn main() { | |
| write_pty, | ||
| resize_pty, | ||
| kill_pty, | ||
| get_emergency_stop, | ||
| set_emergency_stop, | ||
| get_scrollback, | ||
| get_session_restore, | ||
| list_agent_profiles, | ||
|
|
@@ -4390,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)"), | ||
| ]; | ||
|
|
@@ -4449,4 +4501,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" | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| 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 () => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This source-changing commit has no recognized AGENTS.md reference: AGENTS.md:L155-L158 Useful? React with 👍 / 👎. |
||
| ipc.getEmergencyStop.mockResolvedValue(false); | ||
| ipc.setEmergencyStop.mockResolvedValue(undefined); | ||
| render(<EmergencyStop />); | ||
| 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(<EmergencyStop />); | ||
| 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(<EmergencyStop />); | ||
| fireEvent.click(await screen.findByRole("button", { name: "Not-Aus auslösen" })); | ||
| 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(); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The commit adds 318 lines across runtime code and tests with no deletions, exceeding the repository's hard 300-diff-line package maximum; the commit report also acknowledges this overage. Split the change before dispatch rather than landing this oversized package.
AGENTS.md reference: AGENTS.md:L13-L15
Useful? React with 👍 / 👎.