Files
sausage/DECISIONS.md

7.9 KiB
Raw Permalink Blame History

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.