Skip to content

ADR-0012 Architecture decision record

workspace() wraps a body Machine and owns only Sandbox lifecycle ​

jr2 provides Workspaces as a helper so workflows never provision Sandboxes themselves: workspace(body, { input, repos, spec }) is a statically-imported factory (per ADR-0011's import doctrine) returning a Machine that provisions a Sandbox and its worktrees, runs the author's body Machine inside it, and tears the Sandbox down when the body finishes. This makes GLOSSARY.md's "Workspace = child Machine bound to a unit of work" concrete.

placing:       create Sandbox CR → await the pod scheduled, no deadline (ADR-0064)
provisioning:  await phase: Ready (watched, ADR-0063) → Harness /attach repos/worktrees (ADR-0004)
running:       invoke body; input = parent input + { workspace: { repos, branch } }
teardown:      destroy the CR
done:          final; workspace output = body output

Why a wrapper Machine and not actors in the workflow ​

Teardown forces the shape. In xstate v5 a stop is synchronous — a stopped machine cannot run async work or transition through cleanup states — so reliable multi-step teardown must be internal states the machine reaches by its own transitions. The thing that provisions must therefore also observe the body's completion, which means it wraps the body. The persisted teardown state is also durable intent: a crash mid-teardown restores into it and re-runs the idempotent destroy, which host-side lifecycle could only replicate by rebuilding the same ledger the state machine already is. When the guarantee still fails (kill -9, hard cancel), the operator's heartbeat-lease GC is the backstop (ADR-0001) — the reason ADR-0001 made the CR the durable desired state. Author-assembled provisioning/cleanup states (the obvious alternative) put the guarantee in every workflow's hands to forget, on every error path.

Boundary: the spec speaks workspace vocabulary only ​

The spec — { branch, workGroup?, reviewSha? } — is mapped from the parent's context by the author, but its shape is workspace-domain: the branch to cut, and what the attach needs to cut it. The branch itself is the author's: its name and its granularity (per feature, per task, from a ticket, from run input) are the Machine's policy, not jr2's; the workspace only cuts it. Which Repos to attach is not per-run at all: they are slots on the wrapper's options (repos: { target: open, docs: { url, ref } }, ADR-0051), static so jr2 up can see them, each bound by the Machine, by a composer's customize, or by a mapper over the door. Workflow configuration (review rounds, budgets, ticket data) passes through to the body untouched; the workspace never sees it. In return the body gets what it needs to operate inside the Sandbox: { repos: Record<slot, path>, branch }, the handles keyed by the slots the wrapper declared, in declaration order. No handle names a primary checkout: which tree an Agent works in is a fact about that Agent's Turn, which the body states (ADR-0051). The Harness endpoint and the Sandbox name are mechanism-facing, not body-facing — agentRun resolves them ambiently from the enclosing wrapper (a registrar actor co-invoked in running, ADR-0016), so the body cannot mis-thread them. Keeping this boundary is what lets one helper serve any workflow rather than coupling to coding-shaped ones.

The wrapper's input beside it is the door the spec maps FROM — the wrapper's own declared run-input schema, which also types the mapping (ADR-0033). It sits on the wrapper and not on the body precisely because of the injection above: the body's input is the door plus workspace, which no caller sends. That same composition is what the door checks the body against, one way only — the body may not demand more than the wrapper hands it; demanding less is safe — so the injection is a compile-time contract, not just a comment. The host's own injection into a root machine's input (instanceId) counts as handed over for that check, because whether it actually reaches this body depends on where the wrapper sits and no type can see that (ADR-0033).

An Effect runs in its Workspace: the Harness container or the Sandbox Effector ​

An Effect in the body (ADR-0067) resolves the same ambient handles an Agent does. A command that names no secret runs in the Harness container of the enclosing Workspace's Sandbox, as the Agent's user. A push, an http whose body is a Worktree file and a command that names a secret run in that Workspace's Sandbox Effector, the pod's effector container (ADR-0005, ADR-0068), which the provision adds only when an Effect below the workspace() is placed there; its image is the Sandbox Image unless the workspace() names effector: { image }. Each one's Worktree follows the Turn's rule (ADR-0057): the one Repo Slot's, or the slot it names with slot; a Workspace with several and none named refuses it. A push goes to that slot's Binding, and after every push whose published commit is not the Agent's own — signed copies, or one squashed commit, signed or not — the Workspace's Harness moves the Agent's branch to the published commits (the sync, ADR-0067). An Effect of these kinds outside any workspace() is refused at invoke, as an error, never as output. A command that names a secret runs against a pinned commit in a scratch of the Effector's own, and its child is confined with Landlock from the Effector's key, the secrets it does not name, the push credentials and the Worktree (ADR-0067, ADR-0068).

There is no retain policy: parking is retention ​

Teardown fires only when the body reaches a final state or fails. A Failure from the body (ADR-0073) goes to a failing state, which runs the same destroy and then sends the Failure up unchanged; a defect goes up with no onError, and its Sandbox stays as evidence. A body that needs its Sandbox kept alive — escalation, a human inspecting the worktree, a human-review gate — simply parks in a non-final state (typically holding a gate, ADR-0011). We considered and rejected an onSettled → retain | destroy policy knob: parking expresses retention with no new API, and keeps the run visibly "waiting" in jr2 runs. A parked run's Sandbox stays alive as long as the run does — each live workspace renews its own lease, and the operator reaps only Sandboxes whose lease has lapsed (abandonment, not parking — ADR-0001).

Commits leave via the workflow, never the workspace ​

Getting commits out of the pod-local clone (push, PR) is body flow, decided by the workflow — a coding workflow pushes the branch / opens a PR before its human gate, so review is PR-based and the forge owns merging. The workspace attaches and destroys; it never touches remotes. A body that finishes without publishing loses its commits by design — the same contract as a temp directory.

Consequences ​

  • ADR-0004's single-active-Agent-per-Workspace invariant becomes structural: one sequential body per workspace, no lock machinery.
  • The workspace labels the CR with its run (jr2.dev/run, jr2.dev/workflow), which is what jr2 ls groups by (ADR-0009).
  • Lost workspace: the wrapper emits, the body decides. While the body runs, a lease actor beside it renews the Sandbox's keepalive and follows the Sandbox watch for whether the workspace it attached to is still there (ADR-0021, ADR-0063) — continuously, and on snapshot restore as the first tick of that same loop rather than a separate restore-time path. Intact → carry on (same endpoint — deterministic per Sandbox name; in-flight turns re-attach from the host ledger, ADR-0016). Lost — reaped, deleted, or a replacement pod after an eviction or node loss — → the pod-local clone and any unpushed commits are gone, so silently re-provisioning would resume into an inconsistent world (tickets closed, commits vanished); instead the wrapper delivers a workspace.lost event into the body — same channel as agent.fault — and the body's policy decides (route it to escalation, fail the run, or choose to re-provision and restart). The body must route it: workspace() refuses a body with a non-final state that has no route on itself or an ancestor, and an event that no transition takes faults the run (ADR-0074).

The docs of Kit version 0.5.1. Each page is also at its .md URL.