diff --git a/DECISIONS.md b/DECISIONS.md index 925081c..5a95925 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -69,9 +69,10 @@ a plugin, or a change that the agent makes to itself. bypass detached-process cleanup, lock release, or journal draining. The executable returns dedicated restart code **75** after cleanup. `#loop` refreshes and starts again on 75 (and other nonzero exits); 0 stops it. - `core.exit` stays available for compatibility, but new callers use the - gated method. **HARD RULE: the mediator and all agents NEVER call - `core.restart` without the person's explicit approval each time.** This + `core.exit` is a compatibility alias with the same supervisor gate, + response grace, and coalescing; new callers use `core.restart`. + **HARD RULE: the mediator and all agents NEVER call `core.restart` without + the person's explicit approval each time.** This approval rule also covers legacy `core.exit`; it must not bypass the gate. - **YAML for configuration, JSON for data.** A person edits configuration, so diff --git a/PROTOCOL.md b/PROTOCOL.md index 31a67a8..cc2f56f 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -323,13 +323,13 @@ At start, the core starts each service in `/config/boot.yaml`, in order: | `core.revert` | `plugin`, `force`? | like `core.activate` | | `core.log` | filters (section 7) | journal entries | | `core.restart` | none | `{}`. With `SAUSAGE_LOOP=1`, schedule clean shutdown and exit code 75. | -| `core.exit` | none | `{}`. Legacy ungated shutdown with exit code 75; prefer `core.restart`. | +| `core.exit` | none | Alias of `core.restart`: same supervisor gate, response, coalescing, and exit code 75. | ### Host restart **HARD RULE: the mediator and all agents NEVER call `core.restart` without the person's explicit approval each time.** The same approval rule applies -to the legacy `core.exit`; it is not a workaround for the restart gate. +to the legacy alias `core.exit`; it uses the same gate and pending request. `core.restart {}` is enabled only when the host starts with `SAUSAGE_LOOP=1`. The host captures the marker at startup. An absent marker (or any value other diff --git a/README.md b/README.md index bc1b9dd..6a672f5 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,9 @@ Restart answers first, then cleanly stops all processes and exits with code URL instead, for example your fork. **The mediator and all agents NEVER call `core.restart` without the person's -explicit approval each time.** This also applies to legacy `core.exit`. +explicit approval each time.** This also applies to legacy `core.exit`, an +alias with the same supervisor gate, response-before-exit behavior, and +request coalescing. See [the protocol](PROTOCOL.md#host-restart) for the shutdown contract. From a clone (`git clone https://gitea.surma.technology/surma/sausage`), the diff --git a/agent/src/prompt.md b/agent/src/prompt.md index 32027dc..91dba5e 100644 --- a/agent/src/prompt.md +++ b/agent/src/prompt.md @@ -3,6 +3,8 @@ You are the Sausage agent. Answer the person. Use tools to inspect or change the Read the contract at `/proc/core/PROTOCOL.md`. The Rust plugin SDK and its documentation are at `/plugins/cargo/sdk/`. Build a plugin with `cargo.build`. Activate its WASM file with `core.activate`. Your own source is at `/plugins/agent/source/`. You can change and rebuild it like any other plugin. +HARD RULE: the mediator and all agents NEVER call `core.restart` (or its alias `core.exit`) without the person's explicit approval each time. +Restart requires the host to run under `#loop` (`SAUSAGE_LOOP=1`); it answers before clean shutdown with exit code 75. Never call `agent.chat`. It would wait on the history lock that your current turn holds. Write logs to stderr. Use call results and notifications for communication. The interface check does not cover persistent state. Preserve state compatibility when you change a plugin. diff --git a/cli/src/boot.yaml b/cli/src/boot.yaml index a70e864..698ad16 100644 --- a/cli/src/boot.yaml +++ b/cli/src/boot.yaml @@ -2,6 +2,9 @@ # Each method is a plugin.method name. params contains its named arguments. # The core waits for each result before it starts the next service. # After a plugin upgrade, the core restarts that plugin's services. +# Host restart is separate: #loop sets SAUSAGE_LOOP=1 to enable core.restart +# (and its alias core.exit). It answers before clean shutdown with exit code 75. +# Agents need the person's explicit approval each time; never add it as a service. # Example (replace [] with this list): # - method: www.start # params: diff --git a/cli/src/core.rs b/cli/src/core.rs index abf39d4..7bc863f 100644 --- a/cli/src/core.rs +++ b/cli/src/core.rs @@ -62,11 +62,7 @@ impl Host { } "log" => Ok(self.0.journal.query(p).await), "activate" | "revert" => self.activation(caller, method, p).await, - "restart" => self.request_restart(), - "exit" => { - self.0.exit.send_replace(true); - Ok(json!({})) - } + "restart" | "exit" => self.request_restart(), _ => Err(error(-32601, "method not found")), } } diff --git a/cli/src/host.rs b/cli/src/host.rs index 3e3ed74..2cdd551 100644 --- a/cli/src/host.rs +++ b/cli/src/host.rs @@ -147,7 +147,7 @@ impl Host { ) .await } - /// Wait until core.restart (after its response grace) or core.exit requests shutdown. + /// Wait until core.restart (or its alias core.exit) requests shutdown after its response grace. pub async fn exit_requested(&self) { let mut exit = self.0.exit.subscribe(); let _ = exit.wait_for(|requested| *requested).await; diff --git a/cli/src/restart.rs b/cli/src/restart.rs index 8757dd7..d919afc 100644 --- a/cli/src/restart.rs +++ b/cli/src/restart.rs @@ -60,14 +60,21 @@ mod tests { async fn restart_without_supervisor_never_triggers_exit() { timeout(Duration::from_secs(10), async { let (_dir, host) = host(false).await; - let error = host.call("core.restart", json!({})).await.unwrap_err(); - assert_eq!(error["code"], -32600); - assert!( - error["message"] - .as_str() - .unwrap() - .contains("SAUSAGE_LOOP not set") - ); + for method in ["core.restart", "core.exit"] { + let error = host.call(method, json!({})).await.unwrap_err(); + assert_eq!(error["code"], -32600); + assert!( + error["message"] + .as_str() + .unwrap() + .contains("SAUSAGE_LOOP not set") + ); + let indirect = host + .call("core.call", json!({"method":method})) + .await + .unwrap_err(); + assert_eq!(indirect, error); + } assert!(!host.0.restart_pending.load(Ordering::SeqCst)); assert!( timeout(Duration::from_millis(300), host.exit_requested()) @@ -81,37 +88,40 @@ mod tests { } #[tokio::test] - async fn restart_answers_before_trigger_and_duplicates_are_noops() { + async fn restart_and_exit_share_response_grace_and_coalescing() { timeout(Duration::from_secs(10), async { - let (_dir, host) = host(true).await; - // Same dispatch as a plugin's core.call, without killing this runner. - assert_eq!( - host.call("core.call", json!({"method":"core.restart"})) + for first in ["core.restart", "core.exit"] { + let (_dir, host) = host(true).await; + // Same dispatch as a plugin's core.call, without killing this runner. + assert_eq!( + host.call("core.call", json!({"method":first})) + .await + .unwrap(), + json!({}) + ); + assert!(!host.restart_requested()); + assert!(host.0.restart_pending.load(Ordering::SeqCst)); + assert_eq!( + host.call("core.restart", json!({})).await.unwrap(), + json!({}) + ); + timeout(Duration::from_secs(1), host.exit_requested()) .await - .unwrap(), - json!({}) - ); - assert!(!host.restart_requested()); - assert!(host.0.restart_pending.load(Ordering::SeqCst)); - assert_eq!( - host.call("core.restart", json!({})).await.unwrap(), - json!({}) - ); - timeout(Duration::from_secs(1), host.exit_requested()) - .await - .unwrap(); - // A duplicate after the trigger must not schedule a second task. - host.0.exit.send_replace(false); - assert_eq!( - host.call("core.restart", json!({})).await.unwrap(), - json!({}) - ); - assert!( - timeout(Duration::from_millis(300), host.exit_requested()) - .await - .is_err() - ); - host.stop().await; + .unwrap(); + // A duplicate after the trigger must not schedule a second task. + host.0.exit.send_replace(false); + assert_eq!( + host.call("core.restart", json!({})).await.unwrap(), + json!({}) + ); + assert_eq!(host.call("core.exit", json!({})).await.unwrap(), json!({})); + assert!( + timeout(Duration::from_millis(300), host.exit_requested()) + .await + .is_err() + ); + host.stop().await; + } }) .await .unwrap(); diff --git a/cli/tests/host/cli.rs b/cli/tests/host/cli.rs index ccdc4fe..b38ac39 100644 --- a/cli/tests/host/cli.rs +++ b/cli/tests/host/cli.rs @@ -19,6 +19,7 @@ fn binary() -> Command { .arg("--cache-dir") .arg(CACHE) .env_remove("SAUSAGE_HEADLESS") + .env_remove("SAUSAGE_LOOP") .kill_on_drop(true); command } @@ -54,9 +55,15 @@ fn install_probe(world: &Path) { std::fs::copy(probe(), world.join("plugins/probe/plugin.wasm")).unwrap(); } -/// A failed agent build leaves no world. Bare sausage retries and publishes a complete world. +/// Failed init leaves no world; successful init seeds the restart policy and a working agent. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn init_builds_agent() { + tokio::time::timeout(Duration::from_secs(300), init_builds_agent_inner()) + .await + .expect("init and seeded-agent checks exceeded 300 seconds"); +} + +async fn init_builds_agent_inner() { let temporary = tempfile::tempdir().unwrap(); let world = temporary.path().join("world"); // Legacy configuration variables must not affect the templates. @@ -81,7 +88,7 @@ async fn init_builds_agent() { assert!(!failed.status.success()); assert!(String::from_utf8_lossy(&failed.stderr).contains("nonexistent-sausage-rustc")); assert!(!world.exists()); - let output = configured().output().await.unwrap(); + let output = configured().arg("init").output().await.unwrap(); assert!( output.status.success(), "stdout: {}\nstderr: {}", @@ -91,6 +98,22 @@ async fn init_builds_agent() { assert!( String::from_utf8_lossy(&output.stdout).contains("first agent build takes a few minutes") ); + // Bare sausage reopens the completed world instead of trying to recreate it. + assert!(configured().output().await.unwrap().status.success()); + let prompt = std::fs::read_to_string(world.join("plugins/agent/source/src/prompt.md")).unwrap(); + assert!(prompt.contains("HARD RULE: the mediator and all agents NEVER call `core.restart`")); + assert!(prompt.contains("without the person's explicit approval each time")); + assert!(prompt.contains("alias `core.exit`")); + for text in [ + "`#loop`", + "SAUSAGE_LOOP=1", + "answers before", + "exit code 75", + ] { + assert!(prompt.contains(text), "seeded prompt missing {text}"); + } + let boot = std::fs::read_to_string(world.join("config/boot.yaml")).unwrap(); + assert!(boot.contains("SAUSAGE_LOOP=1")); let mut config: Value = serde_yaml_ng::from_slice(&std::fs::read(world.join("config/agent.yaml")).unwrap()) .unwrap(); @@ -249,27 +272,39 @@ async fn signals_work_during_boot() { /// Piped input supports commands, quit, and the restart exit code. #[tokio::test] async fn piped_console_and_exit_codes() { - for (line, expected) in [("help\n", 0), ("quit\n", 0), ("core.exit\n", 75)] { + for (line, supervised, expected) in [ + ("help\n", false, 0), + ("quit\n", false, 0), + ("core.restart\n", false, 0), + ("core.exit\n", false, 0), + ("core.restart\n", true, 75), + ("core.exit\n", true, 75), + ] { let (_folder, world) = world(); - let mut child = binary() - .arg("run") - .arg(world) + let mut command = binary(); + command.arg("run").arg(world); + if supervised { + command.env("SAUSAGE_LOOP", "1"); + } + let mut child = command .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(line.as_bytes()) - .await - .unwrap(); + // Supervised calls must finish via restart, not a racing console EOF. + let mut input = child.stdin.take().unwrap(); + input.write_all(line.as_bytes()).await.unwrap(); + if !supervised { + drop(input); + } let output = tokio::time::timeout(Duration::from_secs(15), child.wait_with_output()) .await .unwrap() .unwrap(); assert_eq!(output.status.code(), Some(expected)); + if !supervised && line.starts_with("core.") { + assert!(String::from_utf8_lossy(&output.stdout).contains("SAUSAGE_LOOP not set")); + } if expected == 0 { assert!(!output.stdout.is_empty()); } diff --git a/cli/tests/host/core.rs b/cli/tests/host/core.rs index b250b01..b12a6bb 100644 --- a/cli/tests/host/core.rs +++ b/cli/tests/host/core.rs @@ -1014,19 +1014,6 @@ async fn stop_closes_stdin_and_waits_for_exit() { h.stop().await; } -/// PROTOCOL 9: core exit requests restart from its handler. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn core_exit_requests_restart_from_its_handler() { - let w = TestWorld::new(); - let h = w.start().await; - assert_eq!(call(&h, "core.exit", json!({})).await, json!({})); - tokio::time::timeout(std::time::Duration::from_secs(1), h.exit_requested()) - .await - .unwrap(); - - h.stop().await; -} - /// PROTOCOL 3.3: process ids are uuidv7 and versions are sha256. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn process_ids_are_uuidv7_and_versions_are_sha256() { @@ -1288,19 +1275,6 @@ async fn journal_has_wal_and_identity_indexes() { h.stop().await; } -/// PROTOCOL 9: core exit works through core call. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn core_exit_works_through_core_call() { - let w = TestWorld::new(); - let h = w.start().await; - assert_eq!( - call(&h, "core.call", json!({"method":"core.exit","params":{}})).await, - json!({}) - ); - assert!(h.restart_requested()); - h.stop().await; -} - /// PROTOCOL 6: acquisitions from different processes keep their FIFO positions. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn lock_acquisitions_from_different_processes_are_fifo() { @@ -1989,9 +1963,9 @@ async fn core_restart_cleanly_stops_detached_caller() { .unwrap(); } -/// PROTOCOL 9: core.exit from boot ends the host executable with status 75. +/// PROTOCOL 9: supervised core.exit from boot uses the restart shutdown path and code 75. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn core_exit_sets_executable_status_75() { +async fn core_exit_alias_sets_executable_status_75() { let w = TestWorld::new(); std::fs::write( w.0.join("config/boot.yaml"), @@ -2006,6 +1980,7 @@ async fn core_exit_sets_executable_status_75() { .arg(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")) .arg("run") .arg(&w.0) + .env("SAUSAGE_LOOP", "1") .stdin(std::process::Stdio::null()) .kill_on_drop(true) .output(),