Trim the harness to what it needs to keep going

Host:
- test first: a new version is tested where it lies; only an installed
  version goes to versions/, and instances run from there
- data/ and public/ are shared by all versions; no snapshots; rollback
  restores code only
- no re-delivery, status.json, list, remove/archive, smoke test, data/
  hiding, or stdin timeout; rollback needs a version
- supervisor.rs split out; one place for installing a version
- init only writes the agent folder; run installs it like any actor
- console: @actor, text to the agent, /help, /quit

Agent:
- every text goes out as a broadcast; 5-line prompt; 5 tools that describe
  themselves; turn.json only marks an active turn

Docs: PROTOCOL.md, README.md, DECISIONS.md, AGENTS.md rewritten.
Examples: only notes and notes-v2.
This commit is contained in:
2026-10-15 13:35:45 +01:00
parent cee862275c
commit 11144b947e
35 changed files with 909 additions and 2038 deletions
-9
View File
@@ -1,9 +0,0 @@
[package]
name = "bench"
version = "0.1.0"
edition = "2024"
[dependencies]
sausage-actor = { version = "0.1", path = "../../sdk" }
[workspace]
-42
View File
@@ -1,42 +0,0 @@
//! Ping-pong with a target and report the round-trip times.
use sausage_actor::{json, messages, reply, send};
use std::time::Instant;
fn main() {
let mut inbox = messages();
while let Some(message) = inbox.next() {
if message.kind() != "bench" {
continue;
}
let target = message.body["target"].as_str().unwrap_or("echo");
let n = message.body["n"].as_u64().unwrap_or(1000);
if n == 0 {
let _ = reply(&message, json!({"type": "error", "reason": "n must be greater than 0"}));
continue;
}
let mut times = Vec::new();
let started = Instant::now();
for i in 0..n {
let t = Instant::now();
let id = send(target, json!({"type": "ping", "i": i}));
loop {
let Some(reply) = inbox.next() else { return };
if reply.reply_to.as_deref() == Some(id.as_str()) {
break;
}
}
times.push(t.elapsed().as_micros() as u64);
}
let total = started.elapsed().as_secs_f64();
times.sort();
let p = |q: f64| times[((times.len() as f64 - 1.0) * q) as usize];
let _ = reply(
&message,
json!({
"type": "bench_result", "n": n, "total_s": total,
"msgs_per_s": (n as f64 / total).round(),
"p50_us": p(0.5), "p90_us": p(0.9), "p99_us": p(0.99), "max_us": p(1.0)
}),
);
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"description": "reject zero benchmark samples",
"steps": [
{
"send": {"type": "bench", "n": 0},
"expect": {"type": "error", "reason": "n must be greater than 0"}
}
]
}
-7
View File
@@ -1,7 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "crasher"
version = "0.1.0"
-8
View File
@@ -1,8 +0,0 @@
[package]
name = "crasher"
version = "0.1.0"
edition = "2024"
[dependencies]
[workspace]
-6
View File
@@ -1,6 +0,0 @@
//! A bad version: it starts, waits 3 seconds (long enough for the smoke
//! test), then panics. Used to test the automatic rollback.
fn main() {
std::thread::sleep(std::time::Duration::from_secs(3));
panic!("this version is broken");
}
-9
View File
@@ -1,9 +0,0 @@
[package]
name = "echo"
version = "0.1.0"
edition = "2024"
[dependencies]
sausage-actor = { version = "0.1", path = "../../sdk" }
[workspace]
-7
View File
@@ -1,7 +0,0 @@
use sausage_actor::{json, messages, reply};
fn main() {
for message in messages() {
let _ = reply(&message, json!({"type": "echo", "got": message.body}));
}
}
-10
View File
@@ -1,10 +0,0 @@
[package]
name = "fetch"
version = "0.1.0"
edition = "2024"
[dependencies]
sausage-actor = { version = "0.1", path = "../../sdk", features = ["async"] }
wstd = "0.6"
[workspace]
-85
View File
@@ -1,85 +0,0 @@
//! Fetch URLs over HTTP while answering pings.
use sausage_actor::{Inbox, Message, json, me, reply};
use wstd::http::{Body, Client, Request};
fn next_fetch_id() -> Result<u64, String> {
let previous = match std::fs::read_to_string("/data/fetch-sequence") {
Ok(value) => value.parse::<u64>().map_err(|e| e.to_string())?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => existing_fetch_id()?,
Err(e) => return Err(e.to_string()),
};
let next = previous
.checked_add(1)
.ok_or_else(|| "fetch ID space exhausted".to_string())?;
std::fs::write("/data/fetch-sequence", next.to_string()).map_err(|e| e.to_string())?;
Ok(next)
}
fn existing_fetch_id() -> Result<u64, String> {
let entries = match std::fs::read_dir("/public/fetch") {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(e) => return Err(e.to_string()),
};
Ok(entries
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().to_str()?.parse().ok())
.max()
.unwrap_or(0))
}
async fn fetch(n: u64, message: Message) {
let url = message.body["url"].as_str().unwrap_or_default();
let started = wstd::time::Instant::now();
let result = async {
let request = Request::get(url)
.body(Body::empty())
.map_err(|e| e.to_string())?;
let response = Client::new()
.send(request)
.await
.map_err(|e| format!("{e:?}"))?;
let status = response.status().as_u16();
let bytes = response
.into_body()
.contents()
.await
.map_err(|e| format!("{e:?}"))?
.to_vec();
std::fs::create_dir_all("/public/fetch").map_err(|e| e.to_string())?;
let path = format!("/public/fetch/{n}");
std::fs::write(path, &bytes).map_err(|e| e.to_string())?;
Ok::<_, String>((status, bytes.len()))
}
.await;
let ms = started.elapsed().as_millis();
let body = match result {
Ok((status, size)) => {
let id = me();
let actor = if id.is_empty() { "fetch" } else { &id };
json!({"type": "fetched", "status": status, "bytes": size, "path": format!("/actor/{actor}/public/fetch/{n}"), "ms": ms})
}
Err(reason) => json!({"type": "error", "reason": reason, "ms": ms}),
};
let _ = reply(&message, body);
}
fn main() {
wstd::runtime::block_on(async {
let inbox = Inbox::start();
while let Some(message) = inbox.recv().await {
match message.kind() {
"fetch" => match next_fetch_id() {
Ok(n) => wstd::runtime::spawn(fetch(n, message)).detach(),
Err(reason) => {
let _ = reply(&message, json!({"type": "error", "reason": reason}));
}
}
"ping" => {
let _ = reply(&message, json!({"type": "pong"}));
}
_ => {}
}
}
});
}
-9
View File
@@ -1,9 +0,0 @@
[package]
name = "fstest"
version = "0.1.0"
edition = "2024"
[dependencies]
sausage-actor = { version = "0.1", path = "../../sdk" }
[workspace]
-38
View File
@@ -1,38 +0,0 @@
//! Filesystem probe. Keeps files open across messages.
use sausage_actor::{Value, json, messages, reply};
use std::{collections::HashMap, io::Write};
fn main() {
let mut open = HashMap::<String, std::fs::File>::new();
for msg in messages() {
let b = &msg.body;
let path = b["path"].as_str().unwrap_or_default();
let r = |result: std::io::Result<Value>| result.unwrap_or_else(|e| json!({"type": "error", "error": e.to_string()}));
let response = match msg.kind() {
"write" => r(std::fs::write(path, b["text"].as_str().unwrap_or_default()).map(|_| json!({"type": "ok"}))),
"open" => r((|| {
let mut file = std::fs::File::create(path)?;
file.write_all(b["text"].as_str().unwrap_or_default().as_bytes())?;
open.insert(path.into(), file);
Ok(json!({"type": "ok", "open": true}))
})()),
"append" => r((|| {
let mut file = std::fs::OpenOptions::new().append(true).open(path)?;
file.write_all(b["text"].as_str().unwrap_or_default().as_bytes())?;
open.insert(path.into(), file);
Ok(json!({"type": "ok", "open": true}))
})()),
"close" => json!({"type": "ok", "closed": open.remove(path).is_some()}),
"read" => r(std::fs::read_to_string(path).map(|text| json!({"type": "content", "text": text}))),
"list" => r(std::fs::read_dir(path).map(|rd| {
let mut names: Vec<String> = rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect();
names.sort();
json!({"type": "entries", "names": names})
})),
"remove" => r(std::fs::remove_file(path).map(|_| json!({"type": "ok"}))),
"crash" => panic!("asked to crash"),
_ => json!({"type": "error", "error": "unknown message"}),
};
let _ = reply(&msg, response);
}
}
+10
View File
@@ -18,6 +18,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
name = "notes"
version = "0.1.0"
dependencies = [
"sausage-actor",
"serde_json",
]
@@ -39,6 +40,14 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "sausage-actor"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "serde"
version = "1.0.229"
@@ -46,6 +55,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
+115
View File
@@ -0,0 +1,115 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "notes"
version = "0.1.0"
dependencies = [
"sausage-actor",
"serde_json",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "sausage-actor"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
-9
View File
@@ -1,9 +0,0 @@
[package]
name = "ticker"
version = "0.1.0"
edition = "2024"
[dependencies]
sausage-actor = { version = "0.1", path = "../../sdk" }
[workspace]
-13
View File
@@ -1,13 +0,0 @@
//! Send 200 notes to `notes`, 50 ms apart, then stay alive.
use sausage_actor::{json, send};
use std::time::Duration;
fn main() {
for n in 1..=200 {
let _ = send("notes", json!({"type": "add", "text": format!("tick {n}")}));
std::thread::sleep(Duration::from_millis(50));
}
loop {
std::thread::sleep(Duration::from_secs(3600));
}
}