Skip to content
Foundry
Esc
↑↓navigate↵open⌘Jpreview
On this page

State

Disk layout, recovery of parked runs, locks, and the limits of continuity.

Persisted state

For the guarantees these records support, start with Durability. Run snapshots use Workflows, session transcripts use Agents, and reports and versioned outputs use Artifacts. Marbles chooses their local stores and connects their identities.

The default state root is ~/.foundry/marbles. Each workspace, the project root above .foundry/marbles, gets a directory named by the first twelve hex digits of the SHA-256 of its canonical path. Listing the state root is the registry of every workspace this machine has run.

~/.foundry/marbles/<workspace-id>/
  workspace.json          id, root, config, firstSeen, lastSeen
  runs/<runId>.json       one run: its record, frames, jobs, queue row, suspensions,
                          the owning pid, and what the run recorded (below)
  output/<runId>.jsonl    append-only output for marbles watch
  schedules/<key>.json    kind, label, lastStart, lastFinish, lastStatus, nextDue
  monitors/<key>.json     the last observation: file digests, or the HTTP body and
                          its hash; plus a pending launch until it has started
  automations/<id>.json   a trigger an agent created: target, cadence, source, owner
  locks/schedule:<key>    pid of the tick running a schedule or monitor
  locks/run-<runId>       pid of the process that owns a recovered run
  locks/edit-<id>         pid of the process changing an agent-created trigger
  locks/<name>.break      transient: the process taking over a dead holder's lock
  heartbeat.json          pid and start time of the foreground `run` loop
  sessions/<id>.json      agent conversations
  sessions/<id>.json.lock pid of the process writing that session
  agent-grants.json       tool grants approved for future runs, by agent id
  agent-grants.json.lock  pid of the process changing grants; never taken over,
                          so remove it by hand if its process is gone
  agent-approvals.json    refused requests waiting in the feed as future permissions
  harness-activities/     native subagent and background activity, for the dashboard
  worktrees/              temporary worktrees cut by `withWorktree`

Agent identity

Grants and agent-created triggers are kept under the agent’s id: its name, else the top-level const it is assigned to, else a digest of its prompt, provider, and model. Renaming an agent gives it a new id, and so does editing the prompt, provider, or model of one with neither a name nor a const. Its earlier grants are then asked for again, and the triggers it created keep firing but leave its list_automations. A trigger also belongs to the session that created it: list_automations shows only the calling session’s own.

Record writes

JSON snapshot records are written to a temporary name and renamed, so a concurrent reader never sees a half-written snapshot. Output journals are append-only. Lock files are created in place (below). Run files are version: 2; the other Marbles-owned records are version: 1. Session files use the session store’s own format. Version 1 run files still load. The persisted config field keeps its existing spelling and records the authoring folder or single-module path. workspace.json is written only by a command that runs something here (run, roll, the dashboard after Enter, or the MCP host); list, status, sessions, and watch leave an empty state root empty.

Run output

output/<runId>.jsonl is the stream read by marbles watch <runId>. A persisted engine creates a journal for each run, including agent and workflow runs launched through MCP. The MCP runId is the engine’s run ID, not an agent-session ID.

The journal starts with the run ID, name, and owner PID. Text records carry agent text, workflow stream.write and exec output, and step lifecycle messages. The final record contains the terminal status and rendered result or error, after execution, publication, cleanup, and the run persistence attempt finish. Raw console.log and process stdout are not part of this journal.

The writer appends newline-delimited JSON. A watcher reads complete records, keeps an incomplete final line until more bytes arrive, and follows new output without modifying state. Completed output remains readable after the owner exits. If the owner disappears before recording an ending, the watcher reports that interruption; it does not restart or adopt the run.

These journals are output history, not replay checkpoints or a persistent job queue. There is no automatic retention policy yet. --dry-run and other in-memory engines write no journal. Runs saved before this feature have no output history to reconstruct. A failed journal write warns the owner; execution can still finish even when its output cannot be saved.

Session writes

Session files are read again for every operation, and each change is a read-change-write under <id>.json.lock, so two processes appending to one session keep both messages. A session file that cannot be read is skipped with a warning and never overwritten; changing that session fails until the file is repaired or removed. Creating a session under an id that already exists returns the existing one.

Run records

The marbles field in a run file contains:

  • ledger: what each step opened, by step path and call position. The session a step’s first agents.session returned, the sandbox its first sandboxes.start returned, and so on. On replay the same call at the same position returns the recorded thing.
  • literals: the input each node was locked with when the run started. A recovered run executes against these, whatever a rebuilt tree or an edited module would give.
  • session: the run’s own session id. Agent sessions opened in the run are children of it.

Artifact storage

Declared artifacts and feed entries live outside the per-workspace directories, in the artifact store shared by every workspace (default ~/.foundry/artifacts, set with --artifacts):

~/.foundry/artifacts/
  records.json            artifacts and their content versions
  records.json.lock       pid holding the write lock during a commit
  blobs/                  file bytes, one immutable file per blob

Writers take the lock, reload records.json, and replace it atomically, so concurrent Marbles processes do not lose each other’s entries.

What survives a restart

Run files are written when a run settles, when a step parks on a question or a pause, and again when it is answered, so the file always says whether the run is waiting or working.

When the engine starts it reads every run file. A settled run always loads, as history. Adopting a parked run requires all of these conditions:

  • The process can answer questions, as the dashboard can.
  • The definition is still registered.
  • The previous process is gone.
  • locks/run-<runId> is free.

Adoption rewrites the file with the new pid and holds the lock until the run settles or the process stops, so two dashboards never both resume one run. The run parks again, its question stays open on the feed, and the answer replays the body against the recorded sessions and artifacts.

A run that was queued or running when its process died is skipped with a warning. Nothing picks a run up mid-body. Results already in a saved snapshot remain on disk, but newer progress may not have been persisted; finishing a child alone does not checkpoint the whole run. status marks such a saved run as orphaned. A new launch does not reuse its step results.

An invalid run file is skipped with a warning and costs only that run. If the files disagree with each other, the engine warns and starts empty.

Scheduling and overlap

Local file coordination uses the Lib file-lock helper. Its PID ownership applies to one host, not a distributed worker pool.

The foreground loop measures an interval schedule’s next tick from the previous tick’s completion, seeded from the recorded lastFinish so a restart keeps the cadence. Missed interval ticks do not queue. Calendar slots are evaluated in machine-local time.

A tick takes locks/schedule:<key> before it runs. If another process holds it, roll <key> prints skipped: held by <holder> and exits successfully. roll <definition-name> takes no lock. Two schedules with different keys have different locks even when they target the same workflow.

Lock ownership and repair

Every lock under locks/, and each session’s <id>.json.lock, is a file created exclusively with mode 0o600 that holds the owner’s pid:

  • A lock whose pid has exited is broken by one process at a time. That process creates <lock>.break, reads the lock again, removes it if it still names the same dead pid, removes .break, and then tries to create the lock; another process may create it first. A .break left by a crashed process stops anyone breaking that lock until it is removed.
  • A lock file that is still empty (its creator has not written its pid yet) is left alone for 30 seconds; after that it counts as abandoned and is broken the same way.
  • A lock file that holds anything else is never removed.
  • A process releases a lock only while it still names that process, so a late release never removes a lock someone else has taken.

A tick or edit that finds locks/<name> held does not wait. It reports the holder: pid N; a process that is taking it now; retry for a fresh empty lock; pid N, which has exited (if no process is taking the lock over, remove <path>.break) when a .break is in the way; or an unknown process (if none is running, remove <path>) for a file that is neither. A session write waits up to 10 seconds for <id>.json.lock, then fails with [json-session-store] session <id> is locked by process N. If no Marbles process is using it, remove <path> (and <path>.break, if present) and retry.

Monitor observations and delivery

Monitors are polled on their cadence. A poll compares what it sees with the last recorded observation; the handler runs only when something changed, and the observation is recorded only after the handler resolves, so a throwing handler sees the same change again next tick. A launch the handler asked for is recorded as pending and handed out on every tick until a start succeeds. A target that is no longer registered fails one tick and is dropped; a started run that later fails or is cancelled is not started again.

Dry runs

Under --dry-run nothing under the state root or the artifact store is written. Sessions and the feed live in memory, monitor observations live in the process, worktrees are cut under the system temporary directory, and parked runs are cancelled when the process quits. Step bodies still execute; see safety and limits.

Was this page helpful?