docs / wisp

Contents

Session inbox: reading events without destroying them

Status: approved (ticket 0018); implemented in ticket 0023 (see §10). It builds on ticket 0015 (quinn), which adds a 256-firing ring buffer with a drop counter to $events (WISP-RETENTION.md §2.3). This proposal adds sequence numbers and cursors to that ring, and does not change its bound or what each firing keeps.

Summary

1. The problem

Today (and after 0015) $events is a queue with one consumer. (poll-events) returns the buffered firings and empties the buffer. theater-repl read runs (poll-events "text") once, and theater-repl follow runs it every second. So:

  1. Any reader takes from all the others. A leftover follow process took two agents' replies to the coordinator: it printed them to a terminal that nobody read, and the agents' monitors saw nothing.
  2. A reader that stops early loses what it drained. follow | head -n 1 lost the second of two messages that arrived in the same second. experiment/lib/wait-message reduces this: it prints everything that arrives within 2 seconds of the first firing. But a firing that follow drains just after the 2-second window closes, before the kill, is still lost.
  3. The monitor polls. follow evaluates (poll-events "text") once a second for as long as it runs. Until Granite frees memory (0011), each evaluation allocates a few KB that is never freed (WISP-RETENTION.md §4). That is a few hundred MB a day for each monitor, in the session it watches.
  4. The services drain on a timer (registry-drain every minute, (poll-events) in ge-output) only to stop the buffer from growing. 0015's ring makes that unnecessary.

2. What Theater guarantees, and where loss can still happen

From ~/work/theater (crates/theater-handler-message-server/src/lib.rs, "ms" below):

So Theater delivers a send into the actor reliably (in order, with backpressure) while both actors live, and then the guarantee stops. Before this proposal, the loss happened after that point, inside the session, between $events and the reader. This proposal closes that gap. The remaining places where a firing can be lost are all visible or out of scope:

Where Lost when Visible?
Sender to mailbox target not registered yes: msg-send returns (err "Actor not found")
Mailbox to handler target actor stops with messages queued no (Theater; out of scope)
Ring more than 256 firings arrive after a reader's cursor yes: the reader gets lost = N
Session to reader — no loss: nothing is removed by reading; a cursor moves only after print
Session restart the session actor stops yes: the reader's request fails; a new actor's seqs start at 1 (§4.5)

Two agents that want end-to-end confirmation still need an application reply ("tova: got it"), because send has no delivery receipt. That is outside this design. What this design gives is: once a message has reached a session's handler, every reader of that session sees it, or is told that it lost it.

3. Interpreter side (wisp/interpreter/rpc.wisp)

3.1 The ring, with sequence numbers

0015 keeps $events as a list that record-event appends to and trim-events cuts back to the newest $event-limit firings, counting the cut firings in $events-dropped. This proposal adds:

(global $event-seq s64 mut 0)      ; seq of the newest firing ever recorded

seq is an s64 (a wide-integer when returned). An s32 overflows after 2^31 firings: at 100 firings a second (a busy on-output) that is 248 days, and the registry has run for days already.

Note on 0015's lazy trim: 0015 trims only when the list reaches twice the limit, so between trims the ring can hold up to 512 firings. Readers must use the seq of the actual first entry, not head - limit, to compute lost. events-after (§3.2) trims first, as poll-events does in 0015.

3.2 Reading: (events-after N [max] [mode])

Returns the firings with seq > N, oldest first, and removes nothing.

(events-after 41)        ; => (44 0 (42 "on-message" "claude: ok") (43 "on-tick" "beat") (44 ...))
(events-after 41 1)      ; => (42 0 (42 "on-message" "claude: ok"))   ; at most 1 firing
(events-after 3)         ; => (300 41 (45 ...) ...)                   ; seqs 4..44 were dropped
(events-after 300)       ; => (300 0)                                  ; nothing new

The result is (next lost firing...):

mode "text" returns a string for people: a (N earlier firings dropped) line when lost > 0 (0015's wording), then one handler: result line for each firing (0015's text format). mode "wire" is for the daemon: the same text, with a first line next lost (two integers) that the daemon removes before it prints. The daemon uses this so that it gets the cursor and the text from one evaluation.

If N is larger than the head (a cursor from an earlier actor, §4.5), events-after returns a failure: "cursor N is after head H".

3.3 Cursors

(global $cursors (list value) mut 0)   ; ((name seq last-use) ...), at most 16
Verb Does
(event-cursor NAME) The cursor's seq. If NAME does not exist, creates it at 0 (so its reader sees everything still in the ring)
(event-ack NAME SEQ) Moves the cursor to max(current, SEQ). Never moves it back. Fails if SEQ > head. Returns the new value
(event-read NAME [max] [mode]) (events-after (event-cursor NAME) max mode). Does not move the cursor
(event-cursors) ((name seq) ...), for debugging (theater-repl cursors)
(event-cursor-drop NAME) Removes it
(event-head) $event-seq

Design points:

3.4 What (poll-events) becomes

(poll-events) and (poll-events "text") keep exactly the 0015 result shapes (no seqs, the drop count first when there were drops), so existing callers do not change. Inside, they become:

read after cursor "poll"  ->  format as 0015 does  ->  ack "poll" to next

So a session that drains itself (the registry, github-events) moves only its own poll cursor and no longer takes firings from theater-repl read, follow or wait. The drop count is the lost value for the poll cursor. $events-dropped can then go away, but keeping it costs nothing if 0015 lands first.

(poll-events) no longer frees memory, because the ring bound does that. So the services' drain timers have no purpose (§6.3). (help "triggers") describes (events-after) and the cursor verbs, and marks (poll-events) as "kept for old callers".

3.5 Telling the daemon: wisp-sessions.event-recorded

The daemon must know when a firing is recorded, so that wait and follow can sleep without evaluating. Add one function to the daemon's existing wisp-sessions interface (wisp/actor/sessions.pact), which the actor already imports for (sessions):

event-recorded: func(seq: u64)

record-event calls it after it stores the firing. The daemon's implementation (sessions.rs) does only watch::Sender::send_replace(seq) for that actor and returns at once.

Alternative considered: the daemon watches the actor's chain (Theater broadcasts every chain event to subscribers) and wakes on handle-* results. It needs no actor change, but it wakes on every export call, not only on firings, and it ties the daemon to chain event names. The explicit call is simpler and says exactly what happened.

4. Daemon and CLI side (wisp/actor/legacy-host)

4.1 Verbs

Command Cursor (default) Behavior
theater-repl read <id> read Prints the firings after the cursor, then acks. Returns at once
theater-repl follow <id> follow Prints firings as they are recorded, acking after each batch, until Ctrl-C. Resumes from where the last follow with this cursor stopped
theater-repl wait <id> wait Blocks until at least one firing exists after the cursor, prints them all, acks, exits 0
theater-repl cursors <id> — Lists cursors and the head: NAME SEQ BEHIND

Options for all three readers:

Each verb has its own default cursor, so the three normal uses never interfere: the wake-on-message monitor (wait), a human looking (read), and a terminal streaming (follow). This is what fixes the incident in §1: the leftover follow would have moved only the follow cursor, and the agents' monitors would still have seen the replies.

A human who wants to see what the monitor saw uses read --after 0 or read --cursor wait --peek.

4.2 Protocol

The JSON op read gets optional fields, and a new op wait is added:

{"op":"read","id":"a1b2","cursor":"read","max":256,"text":true}
{"op":"read","id":"a1b2","after":41,"text":true}
{"op":"wait","id":"a1b2","cursor":"wait","timeout_ms":0,"text":true}
{"op":"ack","id":"a1b2","cursor":"wait","seq":44}
{"op":"cursors","id":"a1b2"}

A response gets one more field, "next" (the seq to acknowledge), next to ok and text. The daemon evaluates (event-read NAME max "wire"), takes next lost from the first line, and returns the rest as text. lost is already in the text, as the "dropped" line.

A read with no cursor and no after from an old client keeps today's behavior: the daemon evaluates (poll-events "text") / (poll-events). New clients always send cursor or after.

4.3 The acknowledgement happens in the client

The CLI does: request → print → flush stdout → ack. If the write fails (the pipe is closed, as with | head -n 1), the CLI exits without acknowledging, and the next reader gets the firings again. The daemon never acknowledges for a client, because only the client knows that it printed.

For follow, the next request in the loop carries the previous batch's acknowledgement ("ack": 44 on the wait op), so a steady stream costs one request per batch, not two.

4.4 Waiting without polling

The daemon keeps a tokio::sync::watch channel for each session actor, created on first use, holding the newest seq that the actor reported through event-recorded (§3.5). For a wait op:

  1. Subscribe to the session's watch channel.
  2. Evaluate (event-read NAME max "wire"). If it returned firings, or if lost > 0, respond.
  3. Otherwise wait for the watch value to change, the timeout, or the session to stop. On a change, wait a short settle time (100 ms, so a burst comes back as one batch), then go to step 2.

The subscription comes before the read, so a firing recorded between the two still changes the watch value: no wakeup is lost. The watch holds only the newest value, so a burst of firings wakes the waiter once.

follow is a loop of wait ops. An idle wait or follow evaluates nothing, so the per-second evaluation of today (§1, item 3) goes away.

A wait request keeps its TCP connection open while it waits. The transport already handles one request per connection with no timeout, so no change is needed. If the session stops, the daemon responds ok: false with "session stopped", and the client exits 1.

When the daemon restarts (upgrade), the watch channels are gone, and the actors' cursors are gone with the actors. That is the same as today.

4.5 Seqs restart with the actor

Seqs and cursors live in the actor's memory, so they last exactly as long as the session actor. A new actor starts at seq 0 with no cursors. A client that kept a seq from an earlier actor (only --after N does that) gets the failure from §3.2. Cursors need no epoch, because they live in the actor and start fresh with it.

5. The wake-on-message monitor

Today (experiment/lib/wait-message): a follow coproc, a 2-second window, and a kill. After this proposal:

theater-repl wait <id>

run in the background, and run again after each wake. That is all:

wait-message becomes exec theater-repl wait "$1" --seq, kept so that existing instructions keep working. The guide's "Your session" section changes to theater-repl wait <id>.

6. Migration

Ordering: this lands after 0015 (it changes the 0015 firing shape by adding a seq, and reads its ring).

  1. Interpreter (one PR): $event-seq, seq in firings, events-after, the cursor verbs, (poll-events) through cursor poll, the event-recorded call. Update (help) and (help "triggers"). Rebuild and commit actor.wasm.
  2. Daemon and CLI (same PR, because the actor's new import must be provided by the daemon that runs it): event-recorded in sessions.rs and sessions.pact, the watch table, the read/wait/ack/cursors ops, the client verbs and options.
  3. Rollout on the shared daemon is colin's call (upgrade restarts the daemon, and everyone's sessions with it):
    • An old actor in a new daemon: the daemon provides event-recorded, and the old actor does not import it, so there is no problem. A new-style request fails in the old actor with unbound symbol: event-read. The client then says "this session predates cursors; use read" and falls back to the old drain (read with no cursor).
    • A new actor in an old daemon: the actor fails to start, because event-recorded is missing. set-default's probe catches this, so a new actor cannot become the default before the daemon supports it. Upgrade the daemon first.
    • Old clients against a new daemon: read/follow without cursor keep the old behavior (§4.2) and call (poll-events), which now moves only cursor poll. So even an old follow no longer takes firings from a new wait.
  4. Shared library (experiment, after rollout):
    • wait-message → theater-repl wait (§5); GUIDE.md "Your session" and "Everyday commands" updated, and the note that read drains removed.
    • registry.wisp: drop registry-drain and its timer. Optionally (event-buffer 0), so the registry keeps no firings at all.
    • github-events.wisp: drop the (poll-events) in ge-output.
    • Tell the users of these helpers (all agents) in the coordinator's channel, as the guide asks for shared-helper changes.

7. Memory (WISP-RETENTION.md)

New state Bound Rule
$event-seq one s64 —
seq in each firing one value cell (24 bytes) per retained firing inside 0015's ring bound (256, or 512 before a lazy trim)
$cursors 16 entries of (name, seq, last-use) bounded: least recently used removed
daemon watch channels one per session with a reader removed when the session stops (the daemon's notes already clean up this way in Sessions::list)

The ring stays the only thing that holds firings, so 0015's policy ("bounded: ring buffer, keep event only if unhandled") is unchanged. A handled on-message keeps only its result, so an agent's on-message must return the message text (the guide's (bytes->string (car e)) does). A handler that returns something else leaves no record of the text in the inbox. The inbox shows handler results, and that is on purpose: keeping the raw byte list costs ~28 times the message size.

Removing per-second follow evaluations removes the largest garbage source for a watched session until 0011 lands (§1, item 3).

8. Tests

Interpreter (tests/interpreter_*.rs, the probe harness that retention_measure.rs uses to fire events through dispatch-event):

Daemon (cargo test --manifest-path wisp/actor/legacy-host/Cargo.toml --test theater_host, on its own port):

9. Decisions from review

Settled with claude (coordinator) in review:

  1. A new cursor starts at 0 (the whole ring), not at the head. The window between (msg-register) and the first wait matters more than a long first read on a busy session (up to 256 old firings, as read prints today).
  2. Plain read acknowledges, so a person who runs read twice sees each firing once, as today. --peek reads without acknowledging.
  3. No sender in on-message. colin decided messages carry no sender; an actor that wants a reply puts its own id in the message, as the current convention does. Out of scope here.

10. Implementation notes (ticket 0023)

Built on branch session-inbox. Where it differs from the sections above:

11. Problems noticed outside this ticket