Files
sausage/DECISIONS.md

155 lines
7.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.