Skip to content

The event log ​

Noeta never stores a Task's current state. It appends everything that happens to the Task's own EventLog and rebuilds state from that log on demand:

state = fold(the Task's events)

The log is the master copy; the state object is a throwaway projection. Crash recovery, replay and audit are all the same operation — a fold.

Engine recordswhat happenedSnapshotoptional shortcutEventLogone append-only stream per TaskContentStorelarge bodies, by hashfold()replay in orderTask statemessages · goal & todoscontext · counterssmall event ≤ 4 KBbig bodythe event keeps a ContentRefstart hereevents after itlook up by hashnext step

What it guarantees ​

  • Byte-equal replay. The same log folds to byte-identical state in any process on any machine. Fold reads only the log and the content store — no clock, no randomness, no network, no model calls.
  • Nothing is edited in place. Corrections, rewinds and compaction are new events. The original records stay on the stream.
  • One writer per Task. Every append presents the worker's lease; a worker whose lease was reclaimed is rejected at the append.
  • Snapshots are only a speed-up. Delete every snapshot and behaviour is unchanged, just slower.

What the log holds ​

A typical stream, one line per record:

seqtyperecords
1TaskCreatedgoal, agent name, parent task
2MessagesAppendedthe user's message
3ContextPlanComposeda reference to exactly what the model was shown
4LLMRequestFinishedthe model's reply and token usage
5ToolCallStartede.g. Read(file_path="README.md")
…the loop continues
41TaskSuspendedthe Task is waiting
42TaskWokenwhat it waited for arrived
58TaskCompletedthe final answer

Each record is an EventEnvelope carrying seq (assigned by the log at append time), type, actor, origin (engine / llm / observer / tool / system) and a small payload. Payloads are capped at 4 KB (EVENT_PAYLOAD_MAX_BYTES); anything larger — a full model response, a big tool output, a snapshot body — goes to the content-addressed ContentStore, and the event carries a ContentRef to it.

Four slices, changed only by fold ​

Task state is split into four typed slices. Only fold changes them, so nothing can change state without leaving an event behind:

SliceChanges come fromHolds
RuntimeStatewhat the Engine recordsrolling messages, per-turn usage
TaskStatePolicy, only through a TaskStatePatch on its decisiongoal, phase, todos, active content
ContextStatethe composed context the Engine recordscontext plan ref, compaction summary, content anchors
GovernanceStateaccumulated from the whole streamcost, iteration and token counters, subtask results

The policy decides what to change, but the Engine records it as a TaskStatePatched event and fold applies it. Deciding and recording are two separate rights held by two separate components.

Recovering from a crash ​

Event loglate write rejected (InvalidLease)Worker A holds the lease,runs a stepA dies mid-stepLease expires → back on the queuerequeue_staleWorker B leases+ folds the logMark the half-done attempt deadStepAttemptAbandonedsafe to repeatneeds a humanRe-run the stepno approval neededPark —wait for a human3 abandons in a row → park
  1. Worker A is killed mid-step. Its heartbeat stops and its lease expires.
  2. The stale sweep puts the Task back on the ready queue.
  3. Worker B leases it and folds the log — which every step does anyway.
  4. B seals the interrupted attempt with a StepAttemptAbandoned event. If the guards would allow every tool call in that attempt without approval, the step is re-run automatically — and the re-run may repeat a call that had already run. If any call would need approval or be denied, the attempt spawned a subtask, or it names an unknown tool, the Task is parked for a human to resume. Three seals in a row always park, so a crash loop cannot retry forever.

There is no recovery code path beyond this, because there was never anything to "save". The edges of the guarantee are in Known limitations.

Snapshots ​

Replaying a long log from the top gets slow, so fold also restores from the newest baseline event and replays only the tail after it:

BaselineWritten when
TaskSnapshotbefore every suspend and terminal event, and every 20 consecutive tool-call turns
TaskRewoundthe conversation was rewound to an earlier turn
StepAttemptAbandonedan interrupted attempt was sealed
TaskForkeda new Task branched off this one

The test suite folds with ignore_snapshots=True and checks both paths produce the same bytes. A snapshot that lacks fields today's fold needs is discarded in favour of a full replay: slower, never wrong. The same rules keep a Task suspended six months ago foldable under today's code.

What this means for you ​

  • Read a Task's full history with Client.events and its messages with Client.messages; that is the same data recovery uses.
  • Any process that can read the store can resume any Task, so scaling out is a storage choice, not a code change.
  • Hooks that need to change what happens belong in a policy or a guard, never in an observer — see The engine.

Design records: event-sourced truth · single-writer invariant · step-attempt recovery

Next ​

Released under the Apache License 2.0.