Spike: console /await, DECISIONS.md, SPIKE.md

This commit is contained in:
2026-10-15 05:06:24 +01:00
parent 0be152dda9
commit 56d02befc8
4 changed files with 293 additions and 1 deletions
+154
View File
@@ -0,0 +1,154 @@
# Decisions
This file lists the design decisions of the spike. **(you)** marks a decision
that Surma made. **(spike)** marks a decision that Scout made alone during the
night and that needs review.
## Architecture
- **(you)** Actors are WASI commands (`wasm32-wasip2`). They use stdin, stdout,
files, and HTTP. No WIT interfaces, no component composition.
- **(you)** Actors exchange newline-delimited JSON through the host, which is
the broker. No built-in request/response. A reply is a message with
`reply_to`. Delivery is at most once.
- **(you)** The broker accepts only `.wasm` files. It knows no source language.
- **(you)** `cargo` is a built-in Rust compiler actor. **(spike)** It is native
(host code), because cargo cannot run inside WASM.
- **(you)** The agent is a normal WASM actor with the fixed ID `agent`. It can
read and upgrade its own source.
- **(spike)** Three native actors: `broker`, `console`, `cargo`.
- **(you)** One Cargo workspace with two crates: `cli` (binary `sausage`) and
`agent`. **(spike)** Example actors are in `examples/`, outside the workspace.
- **(you)** `sausage init` builds the agent and asks for the API shape, the
endpoint, the model, and the token. **(spike)** The token can also come from
`SAUSAGE_API_TOKEN`. The source contains no endpoint and no token.
## Messages
- **(spike)** Stdin line: `{"id", "from", "reply_to"?, "body"}`.
Stdout line: `{"to"?, "id"?, "reply_to"?, "body"}`.
- **(spike)** The broker sets `from`. An actor can choose its own `id`. The
broker then uses `"<sender>/<id>"`, so IDs cannot collide or be faked.
Without an `id`, the broker assigns `m-<n>`.
- **(you)** Without `to`, a message goes to the subscribers of the sender.
**(spike)** `subscribe` / `unsubscribe` messages to the broker. The registry
stores subscriptions, so they survive restarts and upgrades. `init`
subscribes `console` to `agent`.
- **(spike)** Errors from routing come back as
`{"type": "undeliverable", "reason", "to"}` from `broker`.
- **(spike)** Lifecycle: `{"type": "shutdown"}` from `broker`, then EOF on stdin.
After 5 seconds the broker kills the instance.
- **(spike)** Lines on stdout that are not JSON objects with `body` go to the
actor's `stderr.log` with a note.
## Filesystem
- **(you)** `/data` private, `/public` own public files,
`/actor/<id>/public` and `/actor/<id>/source` read-only for everyone.
- **(spike)** The broker also writes `/actor/<id>/status.json` and
`/actor/<id>/stderr.log`. `ls /actor` is the registry.
- **(you)** Commit on close: a write becomes visible only when the writer
closes the file. **(spike)** This also applies to `/data`. Implementation: a
wrapper around the `wasmtime-wasi` filesystem. An open for write goes to a
hidden `.sausage-tmp-*` file next to the target. The descriptor drop renames
it into place. Details:
- A normal exit commits every open file (process exit closes files).
- A trap or kill discards uncommitted writes.
- An append or an edit without truncate copies the old content first.
- Other descriptors see the old content until the close.
- Directory operations take effect immediately.
- The layer hides `.sausage-tmp-*` names from listings, `stat`, `open`,
`rename`, and `unlink`.
- Symlinks are disabled (they would break the path tracking).
- No `fsync`. A power loss can lose committed data.
- **(spike)** Raw sockets are not available (no network permission), but the
`wasi:sockets` interfaces are linked, so programs that import them still
start.
## Broker requests
- **(spike)** `spawn`, `upgrade`, `rollback`, `stop`, `start`, `remove`,
`subscribe`, `unsubscribe`, `list`.
- **(you)** `spawn` and `upgrade` take `wasm`, plus optional `source` and
`built_by` metadata. **(spike)** The broker copies the `.wasm` file and the
source folder into `broker/versions/<id>/<n>/`, with `meta.json` (paths,
builder, requester, time).
- **(spike)** Path resolution: `/actor/...` is global, and `/public/...` and
`/data/...` belong to the sender. `..` is rejected. Native senders (the
console) can give absolute host paths.
- **(you)** Anyone can upgrade anyone. No permissions.
- **(spike)** Actor IDs: `a-z`, `0-9`, `-`, `_`, at most 64 characters.
- **(spike)** `remove` archives every folder of the actor to `archive/`. Nothing
is deleted.
## Upgrades, tests, rollback
- **(spike)** The tests are message transcripts in `source/tests/*.jsonl`:
`{"send": body}` and `{"expect": subset-of-body, "to"?}`. Each file runs in
a fresh candidate on a copy of the actor's `/data` and `/public`. The
candidate has no HTTP access. An `expect` waits up to 10 s. Without tests,
the candidate must stay up for 2 s (smoke test).
- **(spike)** Swap: hold the mailbox, stop the old instance gracefully,
snapshot `/data` + `/public` into the old version's folder, install the new
source in `/actor/<id>/source`, start the new instance, release the mailbox.
- **(spike)** Restart after a crash: backoff 0.5 s × 2ⁿ, at most 30 s. The
counter resets after 60 s of uptime.
- **(spike)** Parked message: when an instance traps, the broker keeps the
last delivered message. It delivers that message again after the next
upgrade, not after a plain restart (that would loop).
- **(spike)** Crash loop: 3 crashes within 30 s after an upgrade → automatic
rollback to the previous version, with its data snapshot. The broker
informs the actor that requested the upgrade.
- **(spike)** `rollback` without `version` goes to the highest older version.
It restores that version's snapshot, and it first snapshots the current
state, so a roll-forward is possible.
- **(spike)** Versions are per actor, not global.
## Limits
- **(spike)** Mailbox: 1,000 messages. When it is full, the sender gets
`undeliverable`.
- **(spike)** The broker waits 30 s when it writes to an actor's stdin. Then it
drops the message and logs it.
- **(spike)** Time slices: the epoch advances every 10 ms, and each guest
yields at each epoch.
- **(spike)** One `sausage run` per world (file lock on `world/.lock`).
- **(spike)** HTTP for all running actors, with no allow-list yet.
## Agent
- **(you)** API shapes: OpenAI Responses and Anthropic Messages, chosen by
`"api"` in `/data/config.json`.
- **(spike)** Config fields: `api`, `base_url`, `model`, `token`, optional
`reasoning_effort` and `max_tokens` (16,000). OpenAI: `POST
{base_url}/v1/responses` with `store: false` and encrypted reasoning passed
back. Anthropic: `POST {base_url}/v1/messages` with `x-api-key`.
- **(spike)** One shared conversation in `/data/history.json`, in a neutral
format, so the API shape can change. The history is limited to 600 entries,
with the cut at a user message. There is no compaction yet.
- **(spike)** A turn starts only for `{"type": "text"}` messages *without*
`reply_to`. Replies never start turns, so two actors cannot keep the agent
in an endless loop. Other messages are collected and shown at the next
turn.
- **(spike)** Tools: `send_message` (with optional wait for the reply),
`wait_for_messages`, `read_file`, `list_dir`, `write_file`, `copy_dir`,
`remove_path`. At most 40 steps per turn. Tool output is cut at 30 KB.
- **(spike)** The final text of a turn goes to the sender, as a reply to the
message that started the turn.
- **(spike)** A shutdown during a turn saves the turn to `/data/turn.json`. The
next start (for example after a self-upgrade) resumes the turn and answers
the original message.
## Console
- **(spike)** `@<actor> <json|text>`, plain text to `agent`, `/deploy`,
`/list`, `/sleep`, `/await`, `/help`, `/quit`. `/sleep` and `/await` exist
for scripted tests.
## Known risks (accepted for now)
- Build scripts and proc macros run as native code in the host environment.
- Any actor can upgrade the agent with code that reads the token.
- Any actor can message, stop, upgrade, or remove any actor.
- A full copy of `/data` and `/public` at every upgrade is slow for large data.
- Mailboxes are in memory. A host crash loses queued messages.
+1
View File
@@ -33,6 +33,7 @@ hello → text to the agent
@agent what actors exist? → text to an actor @agent what actors exist? → text to an actor
/deploy notes examples/notes → build with cargo, then spawn or upgrade /deploy notes examples/notes → build with cargo, then spawn or upgrade
/list → list actors /list → list actors
/await agent 300 → wait for the next message (scripts)
/quit /quit
``` ```
+107
View File
@@ -0,0 +1,107 @@
# Spike results
Date: 2026-10-15. Host: Scout container (8 cores), Rust 1.99, Wasmtime 49.0.3,
`wstd` 0.6.8. All numbers come from `sausage run` with release builds.
## 1. Host, broker, routing, filesystem — works
- `sausage init` builds the agent and registers it. `sausage run` starts the
broker, `cargo`, the console, and every registered actor.
- Routing, replies (`reply_to`), publish to subscribers, and `undeliverable`
errors work.
- **Read-only:** a write to `/actor/<other>/public/...` fails with "Operation
not permitted". A write to `/actor/<self>/public/...` fails too. A write to
`/public/...` works.
- **Commit on close:** while `fs-a` holds `/public/y.txt` open, `fs-b` gets
"No such file". `fs-a` itself also gets it through a second descriptor. The
temporary file does not appear in listings. After the close, `fs-b` reads
the content.
- **Append:** `append` to an existing file copies the old content. Readers
see the old version until the close.
- **Crash with an open file:** the write is discarded, and the old version
stays.
- **Escapes:** `/data/../../../../etc/hostname` and
`/actor/../private/agent/data/config.json` fail.
- **Hard kill of the host:** after `kill`, no `.sausage-tmp-*` files remained,
and the next `sausage run` started every actor again.
## 2. Async stdin + HTTPS in one actor — works
`examples/fetch` (`wstd`) handles `ping` at once while a 2-second HTTPS
request runs. HTTPS and plain HTTP both work through `wasi:http`, and the host
does TLS. Bodies go to `/public/fetch/<n>`, and the reply contains the
global path.
One server (`http://neverssl.com/`) never answered. `curl` also hangs on it,
so the server is the cause. But a request without a timeout keeps its task
alive, and the actor then did not exit on `shutdown`. The broker killed it
after 5 s, as designed. Actors should set HTTP timeouts.
## 3. Upgrade cycle — works
- **No loss during a swap:** `ticker` sent 200 messages to `notes` at 50 ms
intervals. `notes` was upgraded from v1 to v2 during the stream. The swap
happened after tick 68. The v1 snapshot has ticks 1–68, and v2 has ticks
1–200, in order, with no gaps.
- **Parked message:** `{"type":"crash"}` crashed v1. The broker restarted v1
and kept the message. After the upgrade to v2 (which handles `crash`), the
broker delivered it again, and v2 answered `not_crashing`.
- **Crash loop:** an upgrade to `examples/crasher` passed the smoke test (it
crashes after 3 s). It crashed 3 times in about 10 s. The broker rolled back
to v2 with its data and sent `rolled_back` to the console.
- **Explicit rollback** v2 → v1 restored the v1 snapshot.
- **Rejections:** a compiler error comes back as `build_failed` with the
rendered diagnostics. A failing transcript rejects the upgrade, and the old
version keeps running. Invalid IDs and `..` paths are rejected.
## 4. Agent — works with both API shapes
- **`gpt-6-luna`** (OpenAI Responses) through the vendors proxy:
- Asked "which actors exist", it called `broker` `list` and answered in
about 4 s (2 inferences).
- Asked for a `counter` actor, it took these steps without help:
1. It wrote `Cargo.toml`, `src/main.rs`, and `tests/basic.jsonl`.
2. It built the crate with `cargo` (1.9 s).
3. It spawned the actor (the tests passed).
4. It sent increment, increment, get (1, 2, 2).
5. It read `/actor/counter/public/value.txt`.
- **Self-upgrade:** asked to change its own prompt, it took these steps:
1. It copied its source and changed only `prompt.md` (a 3-line diff).
2. It built the change and upgraded `agent`.
3. The upgrade stopped it in the middle of the turn. The new version
resumed the turn from `/data/turn.json` and answered with the new
signature.
- **`claude-haiku-4-5`** (Anthropic Messages): the same conversation
continued, with tool calls.
## 5. Measurements
| What | Result |
| --- | --- |
| Round trip actor → actor → actor | p50 88 µs, p99 172 µs |
| Sequential round trips | ~10,400 per second |
| Warm `cargo` build, small actor | 0.5–1.9 s |
| Warm `cargo` build, agent | 5.5 s |
| First build of the agent (`init`) | 17 s |
| Component compile, small actor | ~70 ms |
| Component compile, agent (870 KB) | ~290 ms |
| Host start, 8 actors compiled in parallel | ~0.8 s each |
| Host RSS with 8 running actors | ~117 MB |
| First release build of `sausage` | ~9 min (Wasmtime) |
Caveat: `~/.cargo/config.toml` in this container sets `kache` as the
`rustc-wrapper`. `kache` caches compiler outputs across projects, so the
first build of the agent (17 s) is probably faster than on a clean machine.
## Next steps I suggest
1. A `matrix` actor (WASM, `wstd`, long-polled `/sync`). The agent could write
it, with you checking the result.
2. Cache compiled components (`Component::serialize`, `.cwasm` files) to
remove the compile time at host start.
3. A network allow-list per actor, for example in a manifest in the source
folder.
4. A sandbox for builds (for example `bwrap`), because build scripts run
natively.
5. Context compaction for the agent's conversation.
6. A better console (line editing, history).
+31 -1
View File
@@ -28,12 +28,16 @@ const HELP: &str = "\
/deploy <actor> <dir> build a Rust folder with cargo, then spawn or upgrade /deploy <actor> <dir> build a Rust folder with cargo, then spawn or upgrade
/list list actors /list list actors
/sleep <seconds> wait (for scripts) /sleep <seconds> wait (for scripts)
/await <actor> <secs> wait for the next message from an actor (for scripts)
/quit stop all actors and exit"; /quit stop all actors and exit";
pub struct Console { pub struct Console {
broker: Arc<Broker>, broker: Arc<Broker>,
waiters: Mutex<HashMap<String, oneshot::Sender<Envelope>>>, waiters: Mutex<HashMap<String, oneshot::Sender<Envelope>>>,
seq: AtomicU64, seq: AtomicU64,
/// Number of messages received per sender, for `/await`.
seen: Mutex<HashMap<String, u64>>,
arrived: tokio::sync::Notify,
} }
fn show(env: &Envelope) { fn show(env: &Envelope) {
@@ -65,12 +69,20 @@ fn show(env: &Envelope) {
impl Console { impl Console {
pub fn new(broker: Arc<Broker>) -> Arc<Self> { pub fn new(broker: Arc<Broker>) -> Arc<Self> {
Arc::new(Self { broker, waiters: Mutex::new(HashMap::new()), seq: AtomicU64::new(1) }) Arc::new(Self {
broker,
waiters: Mutex::new(HashMap::new()),
seq: AtomicU64::new(1),
seen: Mutex::new(HashMap::new()),
arrived: tokio::sync::Notify::new(),
})
} }
pub async fn run_mailbox(self: Arc<Self>, mut mailbox: mpsc::Receiver<Envelope>) { pub async fn run_mailbox(self: Arc<Self>, mut mailbox: mpsc::Receiver<Envelope>) {
while let Some(env) = mailbox.recv().await { while let Some(env) = mailbox.recv().await {
show(&env); show(&env);
*self.seen.lock().unwrap().entry(env.from.clone()).or_default() += 1;
self.arrived.notify_waiters();
if let Some(r) = &env.reply_to if let Some(r) = &env.reply_to
&& let Some(tx) = self.waiters.lock().unwrap().remove(r) && let Some(tx) = self.waiters.lock().unwrap().remove(r)
{ {
@@ -161,6 +173,24 @@ impl Console {
let secs: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(1.0); let secs: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(1.0);
tokio::time::sleep(Duration::from_secs_f64(secs)).await; tokio::time::sleep(Duration::from_secs_f64(secs)).await;
} }
Some("await") => {
let from = parts.next().unwrap_or("agent").to_string();
let secs: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(60.0);
let count = |c: &Self| c.seen.lock().unwrap().get(&from).copied().unwrap_or(0);
let before = count(self);
let deadline = tokio::time::Instant::now() + Duration::from_secs_f64(secs);
loop {
let notified = self.arrived.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if count(self) != before {
break;
}
if tokio::time::timeout_at(deadline, notified).await.is_err() {
bail!("no message from {from} within {secs} s");
}
}
}
Some("deploy") => { Some("deploy") => {
let (Some(actor), Some(dir)) = (parts.next(), parts.next()) else { let (Some(actor), Some(dir)) = (parts.next(), parts.next()) else {
bail!("usage: /deploy <actor> <dir>"); bail!("usage: /deploy <actor> <dir>");