DisSysLab is a framework for sense-and-respond systems — a network of agents that ingest streams of inputs, operate on them, and produce streams of outputs. A network of agents is represented in DisSysLab as an office of agents, and the network structure is represented by an org chart. A long-running office must recover from crashes. This document describes how DisSysLab uses a global snapshot algorithm to take a distributed snapshot. (Chandy and Lamport, Distributed Snapshots: Determining Global States of Distributed Systems, ACM TOCS, 1985). The document also describes an algorithm for restarting an office from its most recent checkpoint.
We use three adaptations to the protocol:
-
The OS manager initiates a snapshot by putting the marker only into source input queues. There is a path in the network to each agent from some source. So, markers are propagated from sources to all agents.
-
A four-way recovery handshake —
PrepareRecover→RecoverReady→StartRecover— synchronizes every agent before any of them resumes client work.State Transitions NORMAL ── _Checkpoint(N) ──► RECORDING RECORDING ── all inports closed ──► NORMAL (and send _Reply(N)) NORMAL ── _PrepareRecover(N) ──► RECOVER_WAITING (and load + send _RecoverReady) RECORDING ── _PrepareRecover(N) ──► RECOVER_WAITING (abandon in-flight snapshot; load + reply) RECOVER_WAITING ── _StartRecover(N) ──► NORMAL
Each agent in the network has a queue by which the agent sends messages to the OS manager. The OS manager sends messages to all agents by sending messages to sources, and then these messages get propagated to all agents in the same way that markers are propagated.
The OS manager instructs all agents to prepare to recover. When an agent receives the message it halts its execution and sends a
The handshake closes two race conditions that arise when restarting from a snapshot: agents seeing the recovery trigger at different times, and agents resuming
recv()before all queues have been refilled with channel-state messages. -
A source/sink boundary protocol gives end-to-end correctness across the office's boundary with the world. Each source records
ptr(N)(the count of inputs read) at snapshot andptr_to_now(the count at recovery), then replays the gap from its on-disk log. Each sink records the same pointers and skips theptr_to_now - ptr(N)duplicate emissions during the catchup phase. The world sees no gap on the input side and no duplicate on the output side.
Implementation lives in dissyslab/core.py,
dissyslab/os_agent.py, dissyslab/network.py, and
dissyslab/snapshot.py. The recovery_demo gallery office
demonstrates the protocol end-to-end and is the recommended
starting point for readers who want to see the algorithm
running on a small office before reading the specification
below.
- N — snapshot number, a monotonically increasing integer.
- Edge — a 4-tuple
(Y, α, X, β)whereYis the sending agent,αisY's outport,Xis the receiving agent,βisX's inport. The framework's flattened graph is a set of edges; each edge corresponds to one FIFO queue. - checkpoint(N) — the marker payload, an
_OsMessagesubclass. May travel on the OS agent's control channels (OS→agent broadcast) or on a data edge (agent→agent propagation). - reply(N) — an
_OsMessagesubclass carrying an agent's recorded state and channel states back to the OS agent. Travels on the existing back-channel that today carries_GiveMeCountsreplies. - prepare_recover(N) — an
_OsMessagesubclass sent by the OS manager to every source's input queue at the start of a recovery from snapshot N. Sources propagate it on their outports; downstream agents receive it on data inports. On receipt each agent loads its checkpoint-N state, fills the per-inport recovery buffer, and enters RECOVER_WAITING. Sources and sinks additionally use this message as the moment at which to recordptr_to_nowbefore entering playback (for sources) or skip (for sinks). - recover_ready(N, agent) — agent → OS reply: "I have stopped, loaded my state, filled my recovery buffer, and am waiting for start_recover." Travels on the existing back-channel.
- start_recover(N) — OS broadcast (only after every agent's recover_ready has been received) sent to each source's input queue. Sources propagate it on their outports. Each agent on receipt exits RECOVER_WAITING and resumes normal execution.
- L, L' — conceptual endless lists representing the items a source reads from the world and the items a sink writes to the world, respectively. The algorithm uses these as the boundary abstraction; their on-disk realisation varies by source type.
- Data queues are FIFO. (Python
queue.Queuesatisfies this.) - All control and data queues remain operational for the lifetime
of the office. Queue failures are out of scope for v1.6; if a
queue stops working a reply will not arrive and the OS agent
will wait indefinitely. The user may then
Ctrl-Cto abort. Failure detection and snapshot timeouts are future work. - Execution is single-process for v1.6 (
dsl run). The multi-process mode (dsl run --processes) is supported by the framework today, but extending the snapshot protocol across process boundaries is future work.
N: the next snapshot number. InitiallyN = 0.- For each agent
X: the most recently recorded state ofX. Initially empty. - For each inport
βof each agentX: the most recently recorded queue state of the inbound edge intoβ. Initially empty.
while True:
Delay for T seconds.
For each source S in the (flattened) network:
put checkpoint(N) in S's input queue.
Wait until reply(N) has been received from every agent.
For each reply: record the agent's saved state and the
agent's recorded channel states into the global state.
Persist the global state for snapshot N to disk.
N := N + 1.
The OS manager only puts checkpoint(N) into source input queues. The marker propagates downstream because every agent that receives checkpoint(N) for the first time forwards checkpoint(N) on every one of its outports. Sources have no inports of their own, so they receive checkpoint(N) only via their input queue (which exists purely so the OS manager has a queue to put into; it is the same kind of object as every non-source agent's first inport queue).
The same loop body is used for periodic, manual, and
error-driven snapshots: a periodic snapshot fires when the delay
expires, a manual snapshot fires when an external trigger sets
the delay to zero, an error-driven snapshot fires when an agent
exception is observed before the OS agent sends _Shutdown.
For the purposes of the snapshot algorithm, an agent X has two
conceptually separate layers:
-
The client layer is the application code the agent author wrote:
rms_meter,topic_tagger, the per-app and library roles. It receives data messages on its data inports, processes them, and emits data messages on its outports. It does not know about checkpoint(N), reply(N), or the snapshot algorithm at all. It runs continuously throughout the lifetime of the office and is never paused or signalled by the snapshot. -
The OS layer is framework infrastructure that wraps the client. It intercepts checkpoint(N) messages arriving on data inports (the client never sees them); it injects checkpoint(N) into outport queues directly (independent of client sends); it calls
X.save_state()at the right moment; and it sends reply(N) on the back-channel to the OS agent. The OS layer is the only part ofXthat participates in the snapshot algorithm.
The OS layer does not block, pause, or interfere with the client
layer. While the OS layer is collecting the snapshot, the client
continues to process data messages exactly as it does at any
other time. A data message arriving on an inport β during the
OS layer's recording window for β is processed by the client
and copied (by the OS layer, as a side effect) into the
channel-state recording for β; the client is unaware that the
copy happened. The only client-visible touch from the OS layer
during a snapshot is the call to X.save_state(), which is a
method call rather than a message, and which the client author
wrote to be cheap and fast.
The steps below describe OS-layer behaviour. The client layer continues unchanged.
A source X receives checkpoint(N) on its input queue, put there
directly by the OS manager. A non-source agent X receives
checkpoint(N) on one of its data inports, where it was placed by
an upstream agent that had previously received checkpoint(N) and
forwarded it on every outport. The OS layer of X intercepts the
message in either case; it never reaches the client.
Multi-worker agents (MergeAsynch) may forward checkpoint(N) on
the same outport more than once — one forward per worker thread
that processes a checkpoint(N) on its inport. The downstream
receiver is idempotent on duplicate checkpoint(N) arrivals on an
inport: the first checkpoint(N) triggers the subsequent-marker
branch (closing that inport's recording), and any later
checkpoint(N) on the same inport is discarded as duplicate. This
relaxes "forwarding is exactly-once per outport" to "forwarding
is at-least-once per outport," which simplifies the multi-worker
handler without changing correctness.
- Idempotency. If
Xis already recording for snapshotN(because a checkpoint(N) on a different channel arrived first),Xdoes not restart the recording. The duplicate marker, if it arrived on a data edgeβ, is consumed inside the in-progressinports_checkpointingas the per-edge close forβ; if it arrived on the OS control channel, it is redundant and is discarded. Either way,Xcontinues its in-progress checkpoint behaviour.Xreturns from the checkpoint behaviour only when step 5 completes (reply(N) sent to OS). - Save own state.
Xcalls itssave_state()method and stores the returned object in a local variable. - Forward on outports. For each of
X's outportsα',Xsends checkpoint(N) onα'. This send happens beforeXsends any further data messages on that outport, so the marker is FIFO-ordered ahead of post-snapshot traffic on each outgoing edge. - Checkpoint incoming queues.
Xcalls theinports_checkpointingsubroutine (described below) to record the state of every inbound edge. - Reply to OS. When
inports_checkpointingreturns,Xsends reply(N) to the OS agent on the existing back-channel, carrying both the saved state and the recorded channel states for every inbound edge.
Throughout steps 1–5 the client layer of X is processing data
messages normally; it does not pause, resume, or change behaviour
in any way. The OS layer does steps 1–5 in parallel with the
client. A data message arriving on an inport β after the OS
layer has finalised the channel-state recording for β plays no
part in snapshot N; it is a post-snapshot message and the client
processes it as it would at any other time.
- Sources (agents with no inports).
inports_checkpointingis trivially empty and returns immediately. Sources still forward checkpoint(N) on their outports. - Sinks (agents with no outports). Step 3 is trivially empty. Sinks still record channel state for their inbound edges in step 4.
- Agents with both inports and outports. All four steps apply in the order given.
Let incoming(X) be the set of edges (Y, α, X, β) of the
flattened graph. For each β in the inports of X, the
subroutine records the queue state of the inbound edge to β.
For each edge e = (Y, α, X, β) in incoming(X):
if checkpoint(N) on edge e was X's first checkpoint(N):
the recorded queue state for e is the empty list.
else:
the recorded queue state for e is the list of data
messages X received on edge e from the moment X first
recorded its own state until the moment checkpoint(N)
arrived on edge e.
Return once a recorded queue state exists for every edge in
incoming(X).
The "first checkpoint(N)" referred to here may have arrived from the OS agent on the control channel rather than from a data edge. In that case no inbound edge is the empty-queue special case; recording begins on every inbound edge from the moment the OS broadcast was received, and each edge's recording closes when its own checkpoint(N) arrives via marker propagation from the upstream agent.
The phrase "save state" in the algorithm refers to a call to
X.save_state(), which returns a Python object that is opaque to
the framework. The framework treats the returned object as
write-once-read-once durable data: it is serialised into the
snapshot, and on resume it is passed back to X.load_state() to
restore the agent's state.
- Stateless agents inherit the default
save_state()returning{}and a no-opload_state(). - Stateful agents override both methods.
- The agent author decides what to save. Re-derivable artifacts (loaded ML model weights, decoded audio buffers, file handles) should not be saved; they are reloaded on first use after resume. Position cursors, accumulators, debounce timers, per-event counters, RSS seen-URL sets, and similar are saved.
dsl run <office_dir> --resume <snapshot_id> (or
--resume latest) performs the following steps:
- Read the snapshot manifest. Validate that the office name matches the office that took the snapshot.
- Build the office network exactly as
dsl rundoes today (codegen + Network compilation). - Start the agent threads.
- The OS manager puts prepare_recover(N) in each source's input queue. Each source forwards prepare_recover(N) on every outport, so the message propagates downstream by the same induction as checkpoint(N).
- On receiving prepare_recover(N), each agent loads its
checkpoint-N state via
load_statefrom disk, fills a per-inport recovery buffer from the snapshot's channel-state files for its incoming edges, sends recover_ready(N, agent) on the back-channel, and enters RECOVER_WAITING. In addition: each source recordsptr_to_now(the last log entry written before the crash); each sink recordsptr_to_now(the count of items written to the world before the crash). - The OS manager waits until recover_ready(N) has been received from every agent.
- The OS manager puts start_recover(N) in each source's input
queue. Each source forwards start_recover(N) on every
outport. Each agent on receipt exits RECOVER_WAITING and
resumes normal execution: client-layer
recvreturns buffered channel-state messages first, then falls through to the inport queue. - Each source enters its playback phase, reading entries
L[ptr(N)+1 .. ptr_to_now]from its on-disk log and emitting them into the office. Each sink enters its skip phase, discarding the firstptr_to_now - ptr(N)messages it receives. When playback and skip are both exhausted, the office is at steady state.
The error-driven path (snapshot triggered by an agent exception, followed by recover from the just-taken snapshot in the same process) reuses the same four-way handshake inside the running process: prepare_recover(N) → recover_ready(N) → start_recover(N).
For a single snapshot N, the recorded global state is a
consistent cut of the office's execution: for every data
message m, if the receive of m is in the cut, then the send
of m is also in the cut. The proof is the standard one for
global snapshots; the OS-broadcast initiation does not affect the
property because:
- Each agent records its state on its first checkpoint(N), regardless of whether that first marker arrived from the OS agent or from an upstream agent.
- Each agent forwards checkpoint(N) on every outport before sending any post-snapshot data on that outport. By FIFO order, the marker arrives at the downstream agent ahead of every post-snapshot message.
- Channel state for an edge is recorded as exactly the data messages that crossed that edge between "first marker received" and "marker arrived on this edge" — which is precisely the set of messages that were in transit on that edge at the moment of the cut.
With the source and sink protocols of the previous section in place, the snapshot is correct not only internally but also across the office's boundary with the world. Across a snapshot and a recovery, the world experiences:
- No missing inputs to the office. Items the source had read between checkpoint N and recover are replayed from the source's on-disk log; the office consumes exactly the sequence of items the world produced.
- No duplicate outputs from the office. Items the sink had written between checkpoint N and recover are not re-emitted; the world receives exactly the sequence of items the office was meant to produce.
The world's view of the office is therefore the same as if the crash and recovery had never happened, modulo a possible pause in real-time delivery during the playback-and-skip phase (bounded by the catchup constraint).
The global snapshot algorithm above captures the office's internal state — the agents and the channels between them. Sources and sinks stand at the boundary of the office, with one end inside and one end in the world. To preserve correctness across the boundary, each source and each sink follows a small additional protocol on top of the Agent Algorithm. The protocols are duals of one another. With them in place, the snapshot is end-to-end correct: the world sees no duplicates and no gaps in the sequence of items it produces for the office or receives from the office.
Each source S is modelled as reading items from a conceptual
endless list L. S maintains an integer pointer that counts
how many items from L it has read into the office so far.
- At checkpoint(N).
Srecordsptr(N)— the value of the pointer at the moment of the snapshot — as part of its saved state in reply(N). - Between snapshot N and a recover.
Scontinues normal operation; the pointer advances. - At recover(N). When
Sreceives the OS manager's recover(N) message, it recordsptr_to_now— the value of the pointer at that moment. - Playback. During playback,
Sre-reads itemsL[ptr(N)+1 .. ptr_to_now]from its on-disk log and re-emits them into the office. These are the itemsShad emitted between snapshot N and the recover — the items the snapshot restoration discarded. - Resumption. Once playback is exhausted,
Sresumes normal operation, reading fromL[ptr_to_now + 1]onward.
For S to re-read past items, the items must be available to it
at playback time. This is handled uniformly:
Every source records every emitted message to an append-only on-disk log. On playback the source reads from the log between
ptr(N)+1andptr_to_now.
For a file source the file itself serves as the log (the cursor recovers everything). For other source types — pull-with-history, pull-current, live-push — the source maintains its own append-only log alongside its capture loop. The protocol is uniform across all source types; only the implementation of "re-read past items" differs.
Retention. After checkpoint(N) the source can discard log
entries received before checkpoint(N-1). Steady-state disk
consumption is approximately 2 × (checkpoint interval) × (arrival rate).
Completion detection. The source learns that checkpoint(N) is complete by observing the arrival of checkpoint(N+1). The OS manager initiates N+1 only after N has globally completed, so the arrival of N+1's checkpoint message is implicit proof. No additional protocol message is required.
Each sink K is modelled as writing items to a conceptual
endless list L' — the stream of items the sink has emitted to
the world. K maintains an integer counter of how many items it
has written.
- At checkpoint(N).
Krecordsptr(N)— the value of the counter at the moment of the snapshot — as part of its saved state in reply(N). - Between snapshot N and a recover.
Kcontinues writing normally; the counter advances. - At recover(N). When
Kreceives the OS manager's recover(N) message, it recordsptr_to_now— the value of the counter at that moment. - Skip phase. During recovery the office's internal state
has been restored to checkpoint N, the source is replaying
inputs in
L[ptr(N)+1 .. ptr_to_now], and the office's processing of those replayed inputs produces output messages that flow towardK.Kdoes not emit the firstptr_to_now - ptr(N)messages it receives during the skip phase — they correspond to itemsL'[ptr(N)+1 .. ptr_to_now]which the world has already received. - Resumption. Once the skip phase is exhausted,
Kresumes normal writing, advancing the counter fromptr_to_now + 1onward.
Retention. Sinks need only the integer counters ptr(N) and
ptr(N-1) (one for the most recent completed snapshot, one for
the previous as a safety margin). Disk cost is essentially zero.
The two protocols are duals on the same conceptual primitive: a
boundary stream with two pointers, ptr(N) taken at snapshot
and ptr_to_now taken at recover, with a gap range
[ptr(N)+1 .. ptr_to_now].
- Source: re-emits the gap into the office during playback.
- Sink: skips the gap out of the office during the skip phase.
The world sees a continuous stream with no duplicates and no missing items in either direction.
The sink protocol works by count, not by content comparison.
DSL's processing is non-deterministic in the general case — an
LLM call may produce a different response on replay, a
random-sampling agent uses a different seed, a time-of-day
branch resolves differently. The messages the sink receives
during the skip phase will not be byte-identical to the items
L'[ptr(N)+1 .. ptr_to_now] the world originally received. The
sink discards them by count anyway. The world sees only the
original pre-checkpoint items followed by the post-recover
items; replay divergence inside the office is invisible to the
world.
The same property is true on the source side. The items the source replays from its log are the items it originally captured; they re-enter the office as if no recovery had happened. Downstream processing in the office on replay may diverge from the original execution (because of LLM non-determinism or similar) but the divergence is bounded inside the office and ends at the sinks, where it is discarded.
The source's playback feeds the office faster than real time. For the office to return to real time after recovery, the steady-state processing rate of the slowest downstream agent must exceed the steady-state arrival rate from the source. If not, the office accumulates lag indefinitely.
This is a property of the office, not of the algorithm. The framework cannot detect the condition statically. The framework should log buffer depth during catchup so the office author can observe whether the office is converging back to real time.
Snapshot files are two things: manifest.json (human-readable —
office name, checkpoint number, timestamp, agent list, edges) and a
set of pickle files under agents/ and channels/ holding the
actual saved state and any in-flight messages. The pickle files are
not readable without a Python script, which made "explain a checkpoint
to Pat" impossible to actually build — until now, the honest status
of that feature was "designed in PAPER_NOTES.md, not built."
dsl show-checkpoint <office_dir> <N|latest> closes that gap the same
way dsl explain-trace (see TRACE_AND_LOGICAL_CLOCK.md) closes the
equivalent gap for a debug trace: it reads manifest.json plus every
agent's state pickle and every channel's in-flight-message pickle
(via snapshot.py's existing reader helpers — read_manifest,
load_agent_state, load_channel_state), and merges them into one
JSON document:
{
"office": "recovery_demo",
"N": 5,
"timestamp": "...",
"agents": {
"recovery_demo::Alex": {"user": {"count": 4010}, "sent": {"out_": 4010}, "received": {"in_": 5107}},
...
},
"in_flight_messages": {
"merge_0::in_0": [{"kind": "inside", "running_count": 3340}]
}
}Same division of labor as the trace command: this command's job stops at producing that structured JSON. Turning it into a sentence like "at checkpoint 5, Alex had classified 4010 points as inside the circle, and one message — a count of 3340 — was still in flight to the merge step" is Claude's job, done by reading this command's output, not by a template baked into the CLI.
Agent state is almost always plain data (a dict of numbers, as
above), since that's what every real gallery office's save_state()
actually returns. For the rare case of a more exotic picklable value
(a custom class instance, a datetime, etc.), the command falls back
to a truncated repr() (300-char cutoff, mirroring the trace
feature's message-truncation policy) rather than failing — the value
shows up as readable text instead of structured JSON, but the command
never crashes on it.
Verified 2026-07-22 against recovery_demo's own real checkpoint
files (both the tracked fixtures already in the repo and a fresh
--snapshot-interval run), including a checkpoint caught with a real
in-flight message. Read-only — this command never writes anything,
so it's safe to run against a live office's checkpoints at any time.
Pat-facing, extreme-step-by-step instructions for both this and the
trace feature (using recovery_demo) are in OfficeSpeak's
DEBUG_TRACE_AND_CHECKPOINT_WALKTHROUGH.md.
The following are deliberately deferred and will be addressed in later releases:
- Queue failures. The algorithm assumes all queues remain operational. A failed queue prevents reply(N) from being delivered. Adding a snapshot timeout, failure detection, or recovery from a partial snapshot is future work.
- Multi-process snapshots.
dsl run --processesruns each agent in its own OS process. Extending the marker protocol and the reply path across process boundaries requires additional IPC plumbing. Future work. - Multi-machine snapshots. Out of scope.
- Schema evolution. Resuming a snapshot taken by an earlier version of the office's code is not supported in v1.6. The manifest records enough about the office structure that the resume procedure can detect a mismatch and refuse to resume with a clear error; graceful schema migration is future work.
- Encrypted snapshots. Snapshot files are written with
pickleand are unencrypted in v1.6. Users with sensitive state in their agents should consider their own filesystem protections. Encrypted-at-rest snapshots are future work.