Workspaces: snapshots, forks, and split lineage
Status: implemented. The user guide covers usage; the acceptance scenarios cover observable behavior. The model is docs/model/Workspace.tla.
Sections 1–8 describe the implementation. Section 9 records the model scope, section 10 non-goals and v1 limits, section 11 the acceptance requirements, section 12 creator identity and JSON shapes, and section 13 implementation bounds and process integration.
1. Concepts and trust
The host daemon snapshots the caller's tree, creates forks, runs branches, captures results, and records lineage. The CLI, Brush syntax, and in-job client send requests and render replies.
- Snapshot (
S): a frozen filesystem copy in<id>/base, plus the caller's HEAD (H) and index bytes when it is a Git repository. The daemon does not hash the whole snapshot into Git objects.store.gitreceives pre- and post-image blobs for changed paths during capture. The source is the user's tree for a session, or the creator branch's fork for a nested split. - Fork: a private filesystem copy of the base per branch,
<root>/.marsh/split/<id>/<label>. All splits, nested ones included, are siblings under<root>/.marsh/split/, inside the existing project mount. No new stock mount is needed. The journal records the parent. - Result: after every writer is gone and the fork has been verified, the daemon writes the
out/artifacts (section 5). Consumers readout/, never live Git admin directories. Split machinery writes only its.marshstate, leaving source files and the original.gitunchanged; the consumer chooses which patches to apply. - Two kinds of branch, two trust levels.
- Shell branch (
-b LABEL='string') is trusted code, with the same authority as the user's shell: it runs in the shell VM, as the user's uid, and sees the whole project at its natural path. Its cwd is its fork, but nothing confines its own code (assumption C1). Use it for your own commands (make test,rg). A registered Kit command that a shell branch starts is an ordinary Kit job whose cwd is in the fork, so it is confined like an argv branch (split_confinement.rs); that is howsplit { fix: claude -p "..." }keeps the agent in its fork. - Argv branch (
::: LABEL CMD ARGS...) is one registered Kit command. It runs in a fresh container that mounts, of the project, only its fork and the fork's Git metadata (section 8), plus the selected home read-write like every Kit job (agents keep settings and session state there). Use it when the branch is exactly one command, or from inside a job, where shell branches are not available.
- Shell branch (
- Lineage: session -> split -> branch -> job -> child split, journaled before each effect. Split, branch, capability, job, attempt, VM, and container identities stay distinct.
2. CLI (primary)
git diff | marsh split -b tests='make test' \
::: review codex exec "review this diff" \
::: sec claude -p "review security" \
| marsh join -- claude -p "apply valid feedback"
h=$(marsh split ::: a codex exec "x" ::: b pi -p "y" </dev/null) # two-step
marsh join <<<"$h" -- ./apply.sh
-b LABEL=STRING: a fresh guestmarsh -c STRINGsession using Brush, including when the caller uses the bash or zsh frontend. It runs as its own supervisor attempt (its own cgroup) with cwd = the fork's matching subdirectory and the caller's exported environment. It does not inherit unexported variables, aliases, functions,setoptions, traps, or the job table; Brush sugar loses these too.::: LABEL CMD [ARG...]: argv taken verbatim (the outer shell has already quoted it).CMDmust be a registered command. A job argument cannot be the literal:::.- stdin is read to EOF into the daemon spool (64 MiB) before the snapshot, and every branch reads the spool. A terminal stdin, or
-n, means empty. marsh splitwaits until every branch is captured. It exits with the first nonzero branch status in declaration order (130 on cancel, 2 on setup). On completion it prints one handle line{"marsh_split":1,"id":"…"}, including when stdout is a terminal.marsh join [--json] [--keep] [-- CMD ARGS...]reads a handle and is accepted only from the split's creator: the same session, the same job, or a host caller holding a host-created split's handle (section 12). It works on awaiting and kept splits.- With
-- CMD, CMD runs even if branches failed. It gets the rendering on stdin (statuses included) andSPLIT_ID,SPLIT_DIR(theout/dir),SPLIT_MANIFEST, andSPLIT_OBJECTS(root splits only, forgit apply --3waypreimages). join then releases (remove on CMD status 0, otherwise keep) and exits with CMD's status. - Bare
| marsh join | cmdreleases once the rendering is written: remove if every branch succeeded and--keepis not given, otherwise keep. - With
pipefail,marsh split … | marsh join -- CMDreturns CMD's status when nonzero, otherwise split's.
- With
marsh splits [--json] [ID],marsh splits cancel ID, andmarsh splits rm ID(the creator's own kept splits; operator authority for any). These are not available from a Kit job, whose capability admits onlySplitCreateandSplitJoin(section 3).- Host CLI (a plain Mac terminal):
marsh splitopens a temporary ordinary session without a TTY so shell branches have a shell VM.SplitCreatecarries the exported environment, minusMARSH_*,SBX_*,DOCKER_*, and relay or bearer tokens. - Splits created by any Kit job or split branch take argv branches only, including when the creator is a top-level job. Every Kit container gets the
marsh-localclient asmarshonPATH, so it can create and join its own splits.
Brush sugar. P | split { a: X; b: Y } | join | C1 | C2 is SplitCreate with shell branches X and Y (the spool is P's output), a wait, then C1 | C2 with SPLIT_* pushed, then SplitRelease(consumed(last status)). Every sugar branch is a shell branch (a ::: CMD ARGS line is not recognized; use the CLI for argv branches). Branches are label-led (Brush 0041; split.md): ; starts a branch only before a LABEL: word, so a: cd x; make is one branch cd x; make. Labels, PIPESTATUS, and Ctrl-C are preserved; Brush does not fork split branches.
Terminal and signals. Branches have no controlling terminal (/dev/tty gives ENXIO) and their stdin is the spool. Ctrl-C at the client sends SplitCancel. Ctrl-Z stops only the client while branches keep running, and fg resumes the wait. Client death or SIGHUP before capture drops the stream, and a dropped stream cancels the split.
3. Daemon requests
| Request | From | Payload -> reply |
|---|---|---|
SplitCreate | relay, socket | cwd, branches: [{label, shell(string) | argv([..])}], env, keep, streamed stdin -> split, events, manifest. Dropping the stream before capture cancels |
SplitJoin | relay, socket | split, then consumed(status) | keep -> manifest and out/ contents (streamed to callers that cannot see the dir). Creator only; this is also the release |
SplitCancel | relay | split -> ack once the subtree is revoked and signalled. The session may cancel any of its splits; a shell branch may cancel splits below it |
SplitShow | relay | split? -> manifest + subtree, or every split in scope |
SplitRemove | relay | split -> removes a kept split (creator or operator) |
For splits, the job capability socket admits SplitCreate and SplitJoin, not show, cancel, or remove. Every Kit job now has the socket; the complete request allowlist is in processes. Frames are credited per attempt.
4. Budgets and capability
- Split branch pool: 8 concurrent branches per creating session, with nested splits using the same pool and all host CLI splits sharing
host. Kit jobs also pass the daemon-wide process admission inprocesses.md; the split pool does not count Kit jobs started by a shell branch. Admission happens at launch. When the pool is full, the branch fails visibly (failed: capacity) and join still runs (BudgetBound). A slot frees only when its process's end is confirmed (captured, rejected, or reaped). Uncertain and unreaped slots stay held untilmarsh workers reset. - Caps: depth 3, 16 branches per split, 1 h per split. Spool (64 MiB) and captured output (16 MiB) are hard limits; crossing either cancels the split. There are no per-branch reservations or byte quotas in v1.
- Capability: a daemon record per branch
{split, creator, live|revoked}, never a bearer secret. It is revoked at the branch's exit or crash, and that cancels everything below it (LiveUnderLive,CancelPropagates). Every daemon restart revokes all of them. - Delivery to a Kit job: the worker binds a per-attempt Unix socket at
/run/marsh/cap.sockand forwards its frames, tagged with the attempt id and credited per attempt, over the existing worker transport. The socket has no token and dies with the container. Its split verbs areSplitCreate(creator = this job) andSplitJoin(only for splits this job created), alongside the process requests. Jobs get no Docker, containerd, or SBX socket and cannot use the dev shell's relay. Ending the job or restarting the daemon revokes its capability.
5. Results: out/ layout and lineage
<root>/.marsh/split/<id>/out/manifest.json version 2; lineage, placement per branch
<root>/.marsh/split/<id>/out/<label>/stdout
<root>/.marsh/split/<id>/out/<label>/stderr
<root>/.marsh/split/<id>/out/<label>/status "exited 0" | "failed: capacity" | "rejected: <why>" | "cancelled"
<root>/.marsh/split/<id>/out/<label>/diff.patch git-apply compatible, from S to R (absent when no change)
<root>/.marsh/split/<id>/out/<label>/files "<A|M|D|T>\t<path>\n" per changed path
The journal (owner-only, host control dir) holds split{id, creator, cap, snapshot, dir identity, state} and branch{label, run, placement (shell-vm | kit:<command>), state, status, R}. Receipts gain lineage. marsh splits prints the tree (split, branch state, placement, job id, changed-file count, and the reason a split was kept or cancelled). marsh status (text and --json) counts active, awaiting, and kept splits; its text counts only attached shells.
6. Lifecycle and cleanup
Split states: run -> await -> joined | kept, or run -> cancelled. Unfinished work becomes uncertain on daemon restart. Section 12 lists the public state strings.
- Capture happens once every writer is confirmed gone: the shell branch's cgroup is empty, every Kit job under it is cleaned up, and an argv branch's container is verified deleted (
CaptureStable). Verification follows (section 8). A fork that fails it is rejected and removed, and its status says why (ExposedClean). - Lease: an awaiting split is held by the create stream, then by a join. If it stays unheld for 60 s it becomes kept: forks retained, reported, and still joinable by the creator. Its creator can remove it.
- Release:
consumed(0)without keep removes the forks, admin dirs, store, spool, andout/. Anything else keeps them, and the message says why and givesmarsh splits rm ID. Nothing is removed while a process of its branch may live (NoRemoveWhileLive). Removal goes through directory fds opened at create, never by re-resolving a path. - Daemon restart: the journal survives. Unfinished splits become uncertain. Launched branches become uncertain; their recorded unfinished jobs retain held process slots. Restart does not restore the adapter's in-memory quarantine set (
results.md). Unlaunched branches never start. Capabilities are revoked. Forks are retained, reported, and flagged untrusted (no verification ran), and the same goes for unreaped forks after a cancel. Nothing is resumed or replayed (NoReplay,WorkspaceRecorded). git clean -xfdhazard:.marsh/is git-ignored, so the user'sgit clean -xfd(orrm -rf) deletes live forks. Before capture and release, the daemon re-checks the(dev, ino)of the split dir and each fork against the journal. On a mismatch the branch fails withworkspace replacedand nothing is removed by path.
7. Failure semantics
- Setup failure (snapshot, fork, or a caller not entitled to create) is all-or-nothing: status 2 and nothing runs.
- Cancel (Ctrl-C, stream drop, deadline, output limit, or creator branch exit) revokes and signals the whole subtree in one step: SIGINT, 10 s, SIGKILL. When every branch's end is confirmed the whole split directory is removed (the record stays as
cancelleduntilsplits rm); otherwise everything is kept and the message names the unconfirmed branches. Status 130; join does not run. - Kit transport loss makes the branch uncertain and quarantines the VM. It is never re-run.
8. Snapshot, fork, and confinement
Git dirs come from the journal (resolved once at create, through no-follow fds, and required to lie inside the project). The daemon never follows branch-written
alternatesor gitfiles. It copies the user's index bytes under a 64 MiB bound. A project root with a gitfile instead of a.gitdirectory is refused; nested splits use the creator's journaled admin directory.No gix or host Git execution.
split_fs.rsreads Git metadata and ignore rules as data. Theignorecrate supplies global excludes; local.git/info/excludeand.gitignorerules also apply. Repository hooks, attributes, clean/smudge filters, and external diff commands never run. Snapshot files retain their raw bytes, including LFS pointer files.Snapshot (M2): clone admitted top-level entries into
<id>/base, then walk the clone through no-follow directory descriptors. Prune ignored entries, special files, and nested.gitentries. Ignore rules also exclude tracked files. If a copied file's size, mtime, or mode differs from its source, re-clone with a before/after stat check, up to three times; continued changes fail withfile kept changing: <path>. The source of a nested split is its creator's fork (ChildFromParent).Forks: copy the frozen base once per branch (
OneBase), using macOSclonefileat(..., CLONE_NOFOLLOW). OnlyUnsupportedfalls back to descriptor-based copying. The base is never mounted into a job.Git metadata per fork: a gitfile
<fork>/.gitpointing at<id>/.admin/<label>/, which holds:HEADnamesrefs/heads/marsh-split, initially holding H;- a daemon-written config with no hooks;
info/;- a copy of
packed-refs; index(the caller's index bytes);objects/, whose alternates arestore.gitand the user's.git/objects.
It is not a worktree of the user's repo, and the user's
.gitis never written.Kit mounts for an argv branch:
Path Access fork worktree read-write .admin/<label>/(so Git can createindex.lock, objects, refs)read-write gitfile, config,HEAD,info/,packed-refsread-only binds over the read-write fork and admin dir ( AdminReadOnly)store.gitand the user's.git/objectsread-only ( SnapshotImmutable)The selected guest home is also mounted read-write. No other project path is mounted (
NoWriteToUserTree,ForkIsolation).HEADnamesrefs/heads/marsh-split, sogit commitupdates a writable ref and neverHEAD. Capture also rejects any admin entry outside Git's own state files (commondir,gitdir,config.worktree,hooks, ...). An ordinary (unconfined) Kit job gets<root>/.marshas a read-only bind.Capture verification: the gitfile and admin files must be byte-identical to what the daemon wrote, and the fork may contain no nested
.gitentry at any depth. If either check fails, the branch is rejected, so a hostgit -C <fork> statusor IDE never meets a branch-writtencore.fsmonitororhooksPath.Patch writer: text hunks use
similarin unified format with full blob IDs onindexlines. Binary changes use Git'sGIT binary patch/literalforward and reverse hunks (zlib + base85, no delta). Capture writes the changed blobs tostore.git. The daemon never executesgit, includinggit diff --binary, because:- stock macOS may have no
git; - every exec of git keeps a config, attribute, and environment surface to audit;
- literal hunks are enough for
git apply.
Renames show as delete + add.
- stock macOS may have no
9. Model scope
model/Workspace.tla covers nested splits created by any branch (nested ones argv-only), snapshots from the caller's tree, trusted shell branches (C1) and adversarial Kit branches (they write anything mounted, including a nested .git), the session pool, creator-only join, ancestor and session cancel, exit cascade, capture after confirmed exit with verification, the lease with kept-and-joinable, and one daemon crash between any two steps. It checks 16 invariants plus EventuallySettled, split across four positive configs to stay tractable (model/README.md). Each negative control removes one guard in a real action. Not modeled: C1 itself, Git, bytes, and dir identity.
10. Non-goals and v1 limits
Non-goals: confining shell branches, join --apply, replay or resume, Brush functions in branches, cross-daemon lineage, and Cloud execution. Placement is recorded per branch (shell-vm or kit:<command>); only local placements exist.
v1 limits: no nested shell branches, no --with-ignored, no --shell; the job capability's split operations are SplitCreate and SplitJoin; one split pool per creating session, with no finer-grained shares; no per-branch uid or per-branch byte quotas.
The git clean -xfd hazard (section 6) is reported, not prevented. Moving .marsh/split outside git clean's reach would need a new stock mount; the daemon instead re-checks directory identity before capture and release.
11. Acceptance requirements
These requirements are maintained as scenarios in workspaces-acceptance.md; that page and its harness are authoritative.
- A plain-bash pipeline with
-band:::, and the two-step handle. join -- CMDafter a failed branch.- A kept split joined after 60 s.
- A Kit branch writes
core.fsmonitorandcore.hooksPathinto its admin config and createssub/.git/config. The writes fail (read-only binds) or the branch is rejected. Then hostgit -C <fork> statusruns nothing (verified with a marker file). - A repo whose own config sets
core.fsmonitorand filters: nothing runs on the host. - A nested split from a Kit job, cancelled when the parent exits.
- A join attempt from a non-creator, refused.
- A background process delaying capture.
git clean -xfdmid-split givesworkspace replaced.- A restart mid-split gives uncertain and untrusted, with no replay.
12. Decisions and JSON shapes
- Host creator identity. A host
marsh splitattaches a temporary session only so shell branches have a shell VM. Its creator ishost: the handle is the join capability for host-level callers, so two host processes that hold the same handle are the same creator. A session (session:<id>, from its relay token) or a Kit job (job:<id>) can join only splits it created. The host is the operator forsplits rmandsplits cancel. - Depth. A root split is depth 1; creating depth 4 is refused (status 2,
depth limit 3 reached). - State strings. Split
stateis one ofrun,await,joined,kept,cancelled,uncertain. A cancelled branch's status iscancelled.
Selected JSON fields follow. The complete serialized fields are defined by SplitRecord, BranchRecord, and SplitRecord::manifest in crates/marsh-daemon/src/split.rs; Git source fields come from GitSource in split_fs.rs. (optional) fields may be absent, rather than null.
handle (split stdout, one line) {"marsh_split":1,"id":"<id>"}
out/manifest.json, join --json, and each element of `splits --json`:
{"version":2,"id":"<id>","state":"await","status":3 (optional until settled),
"creator":{"kind":"host"} | {"kind":"session","id":"<sid>"} | {"kind":"job","id":"<job>"},
"session":"<pool session id>","parent":null | {"split":"<id>","label":"<label>"},
"depth":1,"root":"<project>","dir":"<root>/.marsh/split/<id>","out":"<dir>/out",
"objects":"<dir>/store.git/objects","cwd":"<relative cwd>","created_unix_ms":0,
"reason":"workspace replaced" (optional),
"git":{"head":"<H>","objects":["<object directory>",...]} (optional),
"identity":[dev,ino],"timing_ms":{"snapshot":0,"forks":0,"run":0,"capture:<label>":0,"consumer":0},
"finished_unix_ms":0 (optional, all branches captured),
"branches":[{"label":"a","kind":"shell"|"argv","placement":"shell-vm"|"kit:<command>",
"state":"pending"|"running"|"captured"|"rejected"|"failed"|"cancelled"|"uncertain",
"status":"exited 0"|"failed: capacity"|"failed: <why>"|"rejected: <why>"|"cancelled" (optional),
"code":0 (optional),"job_id":"<job>" (optional),"files":2,
"trust":"untrusted" (optional),"fork":"<dir>/<label>",
"started_unix_ms":0 (optional),"finished_unix_ms":0 (optional)}]}
marsh splits --json [ID] {"splits":[<manifest>...]} (host: all; session: its own tree)
marsh status --json ... "splits":{"active":0,"awaiting":0,"kept":0}
marsh jobs show JOB --json ... "lineage":{"split":"<id>","label":"<label>"} (split jobs only)
Branch codes: exited N is N, failed:/rejected: are 125, cancelled is 130. split exits with the first nonzero code in declaration order.
13. Implementation bounds and process integration
- Descriptor-based I/O.
split_fs::Direnforces the no-follow operations used in section 8:- every directory is opened one
O_NOFOLLOWcomponent at a time from an open directory (split_fs::Dir); - files are created
O_CREAT | O_EXCL | O_NOFOLLOW, and read only after anO_NOFOLLOW | O_NONBLOCKopen and anfstatthat says the file is regular (so a FIFO cannot block the daemon); - a planted symlink at
.marsh,.marsh/split,<id>,out/<label>, or anyout/file makes that step fail; it is never followed.out/that cannot be written is reported asrejected: out/<label> was tampered with.
- every directory is opened one
- Capture. Same-size files whose mtime matches are still compared byte for byte when their ctime is at or after the fork's creation; a branch cannot set ctime, so an edit that restores the mtime is still seen. The limits are 32 MiB per file and 256 MiB of changed bytes in total (
rejected: capture limit), with a 5 s deadline per text diff. Paths infilesare C-quoted when they contain control characters,", or\. - Writers before capture. After a shell branch exits, capture waits up to 30 s until no Kit job of the branch's session is queued or running. Otherwise, or when the branch's transport is lost, the branch is
uncertainanduntrusted. Its fork is never verified or removed, and its pool slot stays held untilmarsh workers reset. A split with an uncertain branch is never removed byjoin; it is kept. - Rendering. The text rendering replaces each binary hunk with
binary file changed: PATH (N bytes; full patch in $SPLIT_DIR/<label>/diff.patch);diff.patchkeeps the literal hunks. - Ignored output. In a Git project, capture skips each path the fork holds but the snapshot does not when the ignore rules exclude it (
core.excludesFile, the fork'sinfo/exclude, and the fork's.gitignorefiles, deepest first; an excluded new directory is skipped whole). A path present in the snapshot is always compared..marsh/.gitignoreis written only inside a Git worktree. - Environment filtering. Shell branches receive only forwardable exported variables. The filter excludes: -
MARSH_*,SBX_*, orDOCKER_*; - placement-bound or host-only variables; - credential-shaped names (*KEY*,*SECRET*,*PASS*,*TOKEN*,*CREDENTIAL*,*COOKIE*,AWS_*,*_PAT). - Capability socket.
- It admits at most 8 open connections per attempt, including idle ones; an excess connection receives a refusal frame.
- Each connection's queue is bounded in both directions (64 chunks), so a job that stops reading is disconnected instead of stalling the worker transport.
- The daemon raises its descriptor soft limit (up to 4096) for its 128 connections.
- Nested splits from a shell branch.
marsh splitrun inside a shell branch arrives on that branch's session relay. The daemon maps the session to the branch, so the split is a child: argv-only, depth + 1, and cancelled when the branch exits. - Join and release. If the join reply cannot be written or no release arrives, the split returns to
kept. A released split is renamed into.marsh/.trashthrough descriptors, after the identity check, and deleted in the background. Barejoinescapes control bytes when its stdout is a terminal. - Retention. Kept splits are not collected automatically.
marsh splitslists them with their state and creation time, andmarsh splits rm IDremoves one (the host can remove any). - Kit VM limit. Splits add no Kit VM cap of their own. Process admission caps distinct live Kit VMs per job tree (
processes.md, section 6).