seed: carry restart approval policy into new worlds

Make legacy core.exit share core.restart's supervisor gate and pending
request. Seed the stable agent prompt and boot comments with the approval
and shutdown contract, and check real init output under a deadline.
Cover both aliases' gate, response grace, coalescing and exit status.

Card: SAU-135
This commit is contained in:
SAU-135 implementer
2026-10-26 10:29:12 +00:00
parent 8c46d9b9d0
commit 168795b408
10 changed files with 114 additions and 90 deletions
+4 -3
View File
@@ -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 bypass detached-process cleanup, lock release, or journal draining. The
executable returns dedicated restart code **75** after cleanup. `#loop` executable returns dedicated restart code **75** after cleanup. `#loop`
refreshes and starts again on 75 (and other nonzero exits); 0 stops it. refreshes and starts again on 75 (and other nonzero exits); 0 stops it.
`core.exit` stays available for compatibility, but new callers use the `core.exit` is a compatibility alias with the same supervisor gate,
gated method. **HARD RULE: the mediator and all agents NEVER call response grace, and coalescing; new callers use `core.restart`.
`core.restart` without the person's explicit approval each time.** This **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. approval rule also covers legacy `core.exit`; it must not bypass the gate.
- **YAML for configuration, JSON for data.** A person edits configuration, so - **YAML for configuration, JSON for data.** A person edits configuration, so
+2 -2
View File
@@ -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.revert` | `plugin`, `force`? | like `core.activate` |
| `core.log` | filters (section 7) | journal entries | | `core.log` | filters (section 7) | journal entries |
| `core.restart` | none | `{}`. With `SAUSAGE_LOOP=1`, schedule clean shutdown and exit code 75. | | `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 ### Host restart
**HARD RULE: the mediator and all agents NEVER call `core.restart` without **HARD RULE: the mediator and all agents NEVER call `core.restart` without
the person's explicit approval each time.** The same approval rule applies 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`. `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 The host captures the marker at startup. An absent marker (or any value other
+3 -1
View File
@@ -53,7 +53,9 @@ Restart answers first, then cleanly stops all processes and exits with code
URL instead, for example your fork. URL instead, for example your fork.
**The mediator and all agents NEVER call `core.restart` without the person's **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. See [the protocol](PROTOCOL.md#host-restart) for the shutdown contract.
From a clone (`git clone https://gitea.surma.technology/surma/sausage`), the From a clone (`git clone https://gitea.surma.technology/surma/sausage`), the
+2
View File
@@ -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/`. 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`. 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. 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. 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. 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. The interface check does not cover persistent state. Preserve state compatibility when you change a plugin.
+3
View File
@@ -2,6 +2,9 @@
# Each method is a plugin.method name. params contains its named arguments. # Each method is a plugin.method name. params contains its named arguments.
# The core waits for each result before it starts the next service. # The core waits for each result before it starts the next service.
# After a plugin upgrade, the core restarts that plugin's services. # 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): # Example (replace [] with this list):
# - method: www.start # - method: www.start
# params: # params:
+1 -5
View File
@@ -62,11 +62,7 @@ impl Host {
} }
"log" => Ok(self.0.journal.query(p).await), "log" => Ok(self.0.journal.query(p).await),
"activate" | "revert" => self.activation(caller, method, p).await, "activate" | "revert" => self.activation(caller, method, p).await,
"restart" => self.request_restart(), "restart" | "exit" => self.request_restart(),
"exit" => {
self.0.exit.send_replace(true);
Ok(json!({}))
}
_ => Err(error(-32601, "method not found")), _ => Err(error(-32601, "method not found")),
} }
} }
+1 -1
View File
@@ -147,7 +147,7 @@ impl Host {
) )
.await .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) { pub async fn exit_requested(&self) {
let mut exit = self.0.exit.subscribe(); let mut exit = self.0.exit.subscribe();
let _ = exit.wait_for(|requested| *requested).await; let _ = exit.wait_for(|requested| *requested).await;
+13 -3
View File
@@ -60,7 +60,8 @@ mod tests {
async fn restart_without_supervisor_never_triggers_exit() { async fn restart_without_supervisor_never_triggers_exit() {
timeout(Duration::from_secs(10), async { timeout(Duration::from_secs(10), async {
let (_dir, host) = host(false).await; let (_dir, host) = host(false).await;
let error = host.call("core.restart", json!({})).await.unwrap_err(); for method in ["core.restart", "core.exit"] {
let error = host.call(method, json!({})).await.unwrap_err();
assert_eq!(error["code"], -32600); assert_eq!(error["code"], -32600);
assert!( assert!(
error["message"] error["message"]
@@ -68,6 +69,12 @@ mod tests {
.unwrap() .unwrap()
.contains("SAUSAGE_LOOP not set") .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!(!host.0.restart_pending.load(Ordering::SeqCst));
assert!( assert!(
timeout(Duration::from_millis(300), host.exit_requested()) timeout(Duration::from_millis(300), host.exit_requested())
@@ -81,12 +88,13 @@ mod tests {
} }
#[tokio::test] #[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 { timeout(Duration::from_secs(10), async {
for first in ["core.restart", "core.exit"] {
let (_dir, host) = host(true).await; let (_dir, host) = host(true).await;
// Same dispatch as a plugin's core.call, without killing this runner. // Same dispatch as a plugin's core.call, without killing this runner.
assert_eq!( assert_eq!(
host.call("core.call", json!({"method":"core.restart"})) host.call("core.call", json!({"method":first}))
.await .await
.unwrap(), .unwrap(),
json!({}) json!({})
@@ -106,12 +114,14 @@ mod tests {
host.call("core.restart", json!({})).await.unwrap(), host.call("core.restart", json!({})).await.unwrap(),
json!({}) json!({})
); );
assert_eq!(host.call("core.exit", json!({})).await.unwrap(), json!({}));
assert!( assert!(
timeout(Duration::from_millis(300), host.exit_requested()) timeout(Duration::from_millis(300), host.exit_requested())
.await .await
.is_err() .is_err()
); );
host.stop().await; host.stop().await;
}
}) })
.await .await
.unwrap(); .unwrap();
+48 -13
View File
@@ -19,6 +19,7 @@ fn binary() -> Command {
.arg("--cache-dir") .arg("--cache-dir")
.arg(CACHE) .arg(CACHE)
.env_remove("SAUSAGE_HEADLESS") .env_remove("SAUSAGE_HEADLESS")
.env_remove("SAUSAGE_LOOP")
.kill_on_drop(true); .kill_on_drop(true);
command command
} }
@@ -54,9 +55,15 @@ fn install_probe(world: &Path) {
std::fs::copy(probe(), world.join("plugins/probe/plugin.wasm")).unwrap(); 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)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn init_builds_agent() { 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 temporary = tempfile::tempdir().unwrap();
let world = temporary.path().join("world"); let world = temporary.path().join("world");
// Legacy configuration variables must not affect the templates. // Legacy configuration variables must not affect the templates.
@@ -81,7 +88,7 @@ async fn init_builds_agent() {
assert!(!failed.status.success()); assert!(!failed.status.success());
assert!(String::from_utf8_lossy(&failed.stderr).contains("nonexistent-sausage-rustc")); assert!(String::from_utf8_lossy(&failed.stderr).contains("nonexistent-sausage-rustc"));
assert!(!world.exists()); assert!(!world.exists());
let output = configured().output().await.unwrap(); let output = configured().arg("init").output().await.unwrap();
assert!( assert!(
output.status.success(), output.status.success(),
"stdout: {}\nstderr: {}", "stdout: {}\nstderr: {}",
@@ -91,6 +98,22 @@ async fn init_builds_agent() {
assert!( assert!(
String::from_utf8_lossy(&output.stdout).contains("first agent build takes a few minutes") 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 = let mut config: Value =
serde_yaml_ng::from_slice(&std::fs::read(world.join("config/agent.yaml")).unwrap()) serde_yaml_ng::from_slice(&std::fs::read(world.join("config/agent.yaml")).unwrap())
.unwrap(); .unwrap();
@@ -249,27 +272,39 @@ async fn signals_work_during_boot() {
/// Piped input supports commands, quit, and the restart exit code. /// Piped input supports commands, quit, and the restart exit code.
#[tokio::test] #[tokio::test]
async fn piped_console_and_exit_codes() { 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 (_folder, world) = world();
let mut child = binary() let mut command = binary();
.arg("run") command.arg("run").arg(world);
.arg(world) if supervised {
command.env("SAUSAGE_LOOP", "1");
}
let mut child = command
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.spawn() .spawn()
.unwrap(); .unwrap();
child // Supervised calls must finish via restart, not a racing console EOF.
.stdin let mut input = child.stdin.take().unwrap();
.take() input.write_all(line.as_bytes()).await.unwrap();
.unwrap() if !supervised {
.write_all(line.as_bytes()) drop(input);
.await }
.unwrap();
let output = tokio::time::timeout(Duration::from_secs(15), child.wait_with_output()) let output = tokio::time::timeout(Duration::from_secs(15), child.wait_with_output())
.await .await
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_eq!(output.status.code(), Some(expected)); 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 { if expected == 0 {
assert!(!output.stdout.is_empty()); assert!(!output.stdout.is_empty());
} }
+3 -28
View File
@@ -1014,19 +1014,6 @@ async fn stop_closes_stdin_and_waits_for_exit() {
h.stop().await; 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. /// PROTOCOL 3.3: process ids are uuidv7 and versions are sha256.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn process_ids_are_uuidv7_and_versions_are_sha256() { 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; 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. /// PROTOCOL 6: acquisitions from different processes keep their FIFO positions.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn lock_acquisitions_from_different_processes_are_fifo() { async fn lock_acquisitions_from_different_processes_are_fifo() {
@@ -1989,9 +1963,9 @@ async fn core_restart_cleanly_stops_detached_caller() {
.unwrap(); .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)] #[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(); let w = TestWorld::new();
std::fs::write( std::fs::write(
w.0.join("config/boot.yaml"), 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(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache"))
.arg("run") .arg("run")
.arg(&w.0) .arg(&w.0)
.env("SAUSAGE_LOOP", "1")
.stdin(std::process::Stdio::null()) .stdin(std::process::Stdio::null())
.kill_on_drop(true) .kill_on_drop(true)
.output(), .output(),