Imported from the spike branch, with: - a library crate (Host API) for the CLI and the tests - JSON test scenarios for candidates - commit on close only for new or truncated files - a Nix flake: nix run -- init, nix run -- run
155 lines
7.9 KiB
Markdown
155 lines
7.9 KiB
Markdown
# 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.
|