Workspaces
A Workspace is a Machine that runs a body Machine in a Sandbox. The Sandbox is a pod of its own, with a folder, /work, that the Agents and Effects of the body share. When the Workspace names Repos, /work holds a git Worktree of each Repo on one branch. A Workspace that names no Repo has only the folder (see A Workspace with no Repo). You make a Workspace with workspace(). The Workspace makes the Sandbox when the run enters it, and deletes the Sandbox when the body reaches a final state.
Why Workspaces exist
An Agent that works on code runs programs, writes files and opens ports. Two Agents on one host would share the filesystem, the ports and the processes. A Sandbox gives each unit of work its own pod, with its own filesystem, network and processes.
The Workspace holds the life of that pod, so that your Machine does not. Your body Machine does the work: it codes, reviews and waits for a person. The Workspace makes the Sandbox before the body starts, and removes it after the body ends. A coder and a reviewer in one body share one Workspace: the isolation is for each unit of work, not for each Agent.
A Workspace and its body
This example is a Workflow. A coder changes the app Repo, and reads the handbook Repo beside it. Then a person approves the change at a Gate, and the Machine pushes the branch:
import { z } from "zod";
import { agent, defineEvent, fail, jr2Setup, push, workspace, type Workspaced } from "@jr2/orchestrator";
const finished = defineEvent({
name: "finished",
description: "Call this when your change is committed.",
input: z.object({}),
});
const approve = defineEvent({
name: "approve",
description: "The change is good. Push it, and end the run.",
input: z.object({}),
});
// The run input: what `jr2 run` gives with --input.
const runInput = z.object({ ticket: z.string(), prompt: z.string() });
type BodyInput = Workspaced<z.infer<typeof runInput>, "app" | "handbook">;
const body = jr2Setup({
types: {} as { context: BodyInput; input: BodyInput },
events: [finished, approve],
actors: {
coder: agent({
model: "anthropic/claude-haiku-5-5",
instructions: "You are a careful engineer. You commit your work on the branch.",
}),
// Two Repo Slots, so the push names the slot that it pushes.
publish: push({ slot: "app" }),
fail: fail(),
},
}).createMachine({
id: "docs-fix",
context: ({ input }) => input,
initial: "working",
// The Sandbox is gone, and its unpushed commits with it. This body fails the run.
on: { "agent.fault": ".failed", "workspace.lost": ".lost" },
states: {
working: {
invoke: {
src: "coder",
input: ({ context }) => ({
prompt: `${context.prompt}\nRead the style rules in ${context.workspace.repos.handbook}.`,
// The Agent works in the Worktree of the app slot.
cwd: context.workspace.repos.app,
}),
},
on: { finished: "review" },
},
// The run waits here, and the Sandbox stays, until a person sends approve.
review: {
invoke: {
src: "gate",
input: ({ context }) => ({
meta: { branch: context.workspace.branch, worktree: context.workspace.repos.app },
}),
},
on: { approve: "publishing" },
},
// Push before the body ends: jr2 deletes the Sandbox, and its commits, at a final state.
// A push that did not publish goes back to the Gate, so the Sandbox stays.
publishing: {
invoke: {
src: "publish",
onDone: [{ guard: ({ event }) => event.output.outcome === "pushed", target: "done" }, { target: "review" }],
},
},
done: { type: "final" },
// Not final states: a final state means `done`. Each one fails the run, and the run ends `failed`.
lost: { invoke: { src: "fail", input: { reason: "the Sandbox was lost" } } },
failed: { invoke: { src: "fail", input: { reason: "the Agent's Turn could not go on" } } },
},
});
export const machine = workspace(body, {
input: runInput,
// The Size of the whole Sandbox. jr2 reserves all of it on the node.
resources: { limits: { memory: "4Gi", cpu: "2" } },
repos: {
// Two Repo Slots. Each one becomes a folder under /work.
app: { url: "https://github.com/you/app.git", ref: "main" },
handbook: "https://github.com/you/handbook.git",
},
spec: ({ input }) => ({ branch: `docs/${input.ticket}` }),
});Agents and Turns tells how to define an Agent.
The options of workspace() say what the Sandbox is. jr2 up reads them before a run starts. Only spec and a per-run Repo Slot read the run input:
inputis the schema of the run input: whatjr2 rungives with--input. Put it on theworkspace(), not on the body.reposgives the Repo Slots that the Sandbox clones. Leave it out for a Workspace with no Repo.specis a function from the run input to the branch. Its input also holdsinstanceId(seeHostInjectedInput).resourcesis the Size.image,userandeffectorname the images of the pod's containers.secretsnames the Held secrets that the Sandbox can use (see Secrets and trust).
The body gets the run input, plus workspace: the handles of the Workspace (see WorkspaceHandles). workspace.repos gives the absolute path of each Worktree, and workspace.branch gives the branch. Give a path to an Agent's Turn as its cwd, as the example does. Type the body's input with Workspaced, also when the run input is z.object({}).
The Agents with Working tools in the body run in the Sandbox. An Agent with workspace: "none" runs on the Instance Harness, also when its state is in a body. A command and a push Effect in the body also run in the Sandbox (see Effects).
Repos and Repo Slots
A Repo is a git repository. Its url names it: the host and the path. The scheme, the user and .git do not change which Repo it is. jr2.config.ts has no list of Repos. A Machine names each Repo that it uses in a Repo Slot.
A Repo Slot is a key of the repos option. The key is your Machine's name for the Repo, and it is the folder of the Repo under /work. Each slot is one of three kinds:
- Bound: the Machine gives the url, or a Binding
{ url, ref }.refis the branch or the commit that the Worktree starts from. Withoutref, the Worktree starts from the Repo's default branch. - Open: the Machine gives
open. The Instance that registers the Machine binds the slot withcustomize()(see Composition). - Per-run: the Machine gives a function of the run input,
({ input }) => url. Each run chooses its Repo.
A Machine that names no slot writes repos: open for the whole map. Then the composer names each slot. workspace.repos keeps the slots in the order that repos declares them. jr2 gives no slot a special meaning. Your Machine says which Worktree each Agent works in.
The cluster keeps one read-only cache of each Repo on each node. A Sandbox clones from that cache, and a fetch in the pod goes through it. Architecture tells about the Repo cache. Run jr2 status with no run id to see the Repos of the Instance, for each node.
The folders in the Sandbox
Each Repo Slot gets a folder under /work. The clone of the Repo is /work/<slot>/default/. The Worktree of the branch is beside it, at /work/<slot>/<branch>. In the folder name, each / of the branch becomes -. For example, the branch docs/T-12 of the slot app is at /work/app/docs-T-12. This is the Project layout.
spec returns a WorkspaceSpec. It gives the branch, and two optional fields:
reviewSha: a commit for a reviewer. jr2 checks it out in a second Worktree, at/work/<slot>/<branch>-review. The body gets its paths asworkspace.review.workGroup: the group id that owns the files in/work. The default is 2000.
Your Machine chooses the branch name. jr2 makes the branch in each Repo Slot. Give each run a different branch, so that two runs do not push to one branch. Make it from a field of the run input, or from instanceId when the run input has no field that is different for each run: spec: ({ input }) => ({ branch: `fix/${input.instanceId}` }). Only a root workspace() gets instanceId from jr2. A workspace() that a different Machine invokes gets it only when its parent gives it.
A Workspace with no Repo
Some work has no Repo: get data, write a report from it, edit the report, and send it. For this work, leave repos out. The Sandbox then has only /work, and the steps of the body share it. A step is an Agent's Turn or an Effect of the body. This is like one job of a GitHub Actions workflow, whose steps share one folder. Two Workspaces are two jobs, and they share nothing.
import { z } from "zod";
import {
agent,
arg,
bearer,
command,
defineEvent,
fail,
file,
http,
jr2Setup,
secret,
workspace,
type CommandOutput,
type Workspaced,
} from "@jr2/orchestrator";
const drafted = defineEvent({
name: "drafted",
description: "Call this when report.md is written.",
input: z.object({}),
});
const edited = defineEvent({
name: "edited",
description: "Call this when report.md is ready to send.",
input: z.object({}),
});
const runInput = z.object({ week: z.string() });
// `never`: the Workspace has no Repo Slot, so the handles hold no path and no branch.
type BodyInput = Workspaced<z.infer<typeof runInput>, never>;
const passed = (output: CommandOutput): boolean => output.outcome === "exited" && output.code === 0;
const body = jr2Setup({
types: {} as { context: BodyInput; input: BodyInput },
events: [drafted, edited],
actors: {
// A command with no Effect secret runs in /work, beside the Agents.
fetch: command(["curl", "-fsSLo", "sales.csv", arg("url")]),
writer: agent({
model: "anthropic/claude-haiku-5-5",
instructions: "You write short, clear reports from data.",
}),
editor: agent({
model: "anthropic/claude-haiku-5-5",
instructions: "You edit reports. You make them shorter and correct each number.",
}),
// The Sandbox Effector reads the file from /work and sends it. The Agents never read the secret.
upload: http({
method: "PUT",
url: "https://files.example.com/reports",
auth: bearer(secret("files")),
input: runInput,
body: file<typeof runInput>("report.md", {
segments: ({ week }) => [`${week}.md`],
contentType: "text/markdown",
}),
}),
fail: fail(),
},
}).createMachine({
id: "weekly-report",
context: ({ input }) => input,
// /work goes with the Sandbox. Nothing is sent yet, so this body fails the run.
on: { "agent.fault": ".failed", "workspace.lost": ".lost" },
initial: "fetching",
states: {
fetching: {
invoke: {
src: "fetch",
input: ({ context }) => ({ url: `https://data.example.com/sales/${context.week}.csv` }),
onDone: [{ guard: ({ event }) => passed(event.output), target: "writing" }, { target: "failed" }],
},
},
// Each Agent's Turn frames no `cwd`, so it works in /work, where sales.csv is.
writing: {
invoke: { src: "writer", input: { prompt: "Write report.md: the sales of the week in sales.csv." } },
on: { drafted: "editing" },
},
editing: {
invoke: { src: "editor", input: { prompt: "Edit report.md. Check each number against sales.csv." } },
on: { edited: "uploading" },
},
// Send the report before the body ends: jr2 deletes /work with the Sandbox.
uploading: {
invoke: {
src: "upload",
input: ({ context }) => ({ week: context.week }),
onDone: [
{ guard: ({ event }) => event.output.outcome === "response" && event.output.status < 300, target: "done" },
{ target: "failed" },
],
},
},
done: { type: "final" },
// Not final states: a final state means `done`. Each one fails the run, and the run ends `failed`.
failed: { invoke: { src: "fail", input: { reason: "a step of the report did not succeed" } } },
lost: { invoke: { src: "fail", input: { reason: "the Sandbox was lost" } } },
},
});
// No `repos`: the Workspace clones nothing, and its steps share /work.
export const machine = workspace(body, { input: runInput });In a Workspace with no Repo:
- Type the body's input as
Workspaced<RunInput, never>. The handles are{ repos: {} }, with no branch. - An Agent's Turn that gives no
cwdworks in/work. The Agent'sworkspaceaccess is the same as in each Workspace:"write"gives itwrite,editandbash, and"read"gives itbashonly. - A
commandwith no Effect secret runs in/work. - An
httpEffect sends a file of/workwithfile(path). The path is relative to/work. specis optional, and gives onlyworkGroup.- A
pushneeds a branch, and acommandwith an Effect secret runs on a commit. Thusjr2 uprefuses both.
jr2 deletes /work with the Sandbox. Send what must stay before the body ends, as the example does with upload. A Lost Sandbox has lost all of its work, because no commit was pushed.
The pod
A Sandbox pod has two containers, and two more when the Machine needs them:
| Container | What runs in it |
|---|---|
| The Harness container | The Harness, the Agent's Working tools, and a command that names no Effect secret |
| The Custodian | jr2's own container. It holds the pod's Held secrets, and the Harness's calls out of the pod go through it (see Architecture) |
| The User Container | Your own image, with its own entrypoint. Only when user names one. |
| The Sandbox Effector | A push, an http Effect with a Worktree file, and a command that names an Effect secret. Only when an Effect needs it. |
The Sandbox Image
The Sandbox Image is the image of the Harness container. It holds the tools that an Agent's Working tools can use, and the shell that you get when you run kubectl exec into the pod. The image is yours. Its floor is glibc, git and a writable HOME. Add tools to the Sandbox gives the full list. jr2 adds nothing to the image. It mounts its own runtime into the pod at /opt/jr2, and starts the Harness from there. Thus the ENTRYPOINT and the CMD of the image do not run.
image names it in one of two forms:
- A
file:URL to a docker context that your module ships, such asimport.meta.resolve("./image").jr2 upbuilds it. - A registry ref, for an image that you build and push yourself. jr2 does not build it.
Without image, the Sandbox uses the Instance's images/default, which jr2 init writes. When the Instance has no images/default, it uses jr2's Harness image. Converge tells how jr2 up builds and loads the image.
The User Container
The User Container is an optional third container, for a program that must run without an Agent, such as an sshd. It runs its own entrypoint, with /work mounted. jr2 never builds into it, probes it or sends it commands. user names its image, in the same two forms as image.
The pod has one network. Thus the User Container cannot use the ports of jr2's containers: 8080, 8082 and 15021, and 8081 and 15001 on loopback.
Size
The Size is the most memory and cpu that the whole Sandbox can use. jr2 reserves all of it on the node. Thus a Sandbox never gets less than its Size, and never uses more. jr2 also enforces the cpu limit.
The Size is a fact of the Machine, as its image is. It is never set for one run. jr2 finds it in this way:
- The Machine's Size is the
resourcesof itsworkspace(). Each field thatcustomize(machine, { resources })gives replaces the same field. For example,customize()with memory6Gi, over aworkspace()with memory3Giand cpu2, gives memory6Giand cpu2. - When the Machine states a Size, jr2 uses it. A field that it does not state comes from the kit default: memory
2Gi, cpu1. - When the Machine states no Size, jr2 uses
sandbox.resourcesinjr2.config.ts. A field that it does not state comes from the kit default.
A Size is { limits: { memory, cpu } }, and nothing else (see Size). Inside the pod, the Custodian gets a small fixed part. A User Container gets the part that user gives it, or shares the rest. The Harness container gets the rest. jr2 up refuses a Size that leaves the Harness container less than 256Mi of memory.
Placing
A Sandbox waits until a node has room for its Size. It can also wait for room under a quota of the cluster. This wait is the Workspace's Placing state. jr2 sets no time limit on it, because jr2 cannot tell "full now" from "never fits". When your Machine needs a limit, put an after on the state that holds the Workspace.
The wait shows in the Console and in the CLI. jr2 runs marks a run that has a Workspace in placing. jr2 status <run id> gives the reason, in the words of the scheduler or the quota. <run id> is the id that jr2 run printed, or a prefix of it. The body gets no event.
A Sandbox lands where an ordinary pod lands. To limit the nodes, set sandbox.nodeSelector and sandbox.tolerations in jr2.config.ts. After Placing, the pod has 120 seconds to become ready. A Sandbox that waits for the Repo cache to clone has 25 minutes.
The life of a Workspace
A Workspace goes through these states:
placing: jr2 writes the Sandbox, and waits for a node.provisioning: the pod starts.attaching: the Harness clones each Repo Slot and makes its Worktree. With no Repo Slot, this state has nothing to do.running: the body runs.teardown: the body is in a final state, and jr2 deletes the Sandbox.failing: the body invokedfail, and jr2 deletes the Sandbox. Then the Workspace sends the same Failure up to its parent.
The output of the Workspace is the output of the body.
A final state of the body deletes the Sandbox, with each commit in it. Push the work before the body ends: with a push Effect, or by hand while the run waits. A body that must keep its Sandbox waits in a state that is not final, such as at a Gate. The Sandbox stays while the run waits.
A Failure also deletes the Sandbox, with each commit in it. To keep the work for a person to read, wait at a Gate before the state that fails.
A parent that invokes the same Machine again, with the same invoke id, gives the new Workspace the same Sandbox. That happens on a retry after it catches a Failure, or when it enters the state again. jr2 does not delete that Sandbox. The new Workspace takes it over, with the Worktree and each commit of the attempt before it, as a restore does.
When a run faults after its Sandbox was placed, the Sandbox stays, so that you can look inside it. jr2's operator removes it after an idle timeout of 30 minutes. jr2 immediately deletes a Sandbox that it never placed. CANCEL deletes the Workspaces of the run, unless you send it with --keep (see jr2 send).
Lease and Continuity
Nothing in the cluster stands for a run. Thus the Orchestrator must say that it still wants each Sandbox. The Lease does this: about every 5 minutes, the Workspace writes an annotation on its Sandbox. It does this from the write of the Sandbox to its teardown, placing included. When the Lease stops for 30 minutes, the operator removes the Sandbox. Thus a Sandbox that nobody wants does not stay.
Continuity asks a different question: is the Sandbox still the one that the body attached to? A Sandbox has one pod for its life. The work in /work is only in that pod. When a person or the cluster deletes or evicts the pod, or its node goes, the Sandbox is Lost. jr2 does not make the pod again, because a new pod has none of the work.
The Orchestrator watches its Sandboxes. While the body runs, when it sees a Sandbox that is Lost or gone, it sends workspace.lost to the body, with the reason. The body decides what to do. In the example, the body goes to its lost state, which invokes fail, and the run ends failed. A restart of the Harness container does not lose the Workspace: the pod and /work stay.
Rules
workspace()takes the run input ininput. It refuses a body that declares its owninput.reposnames at least one Repo Slot, or isopen, or is not there. jr2 refusesrepos: {}. A slot key starts with a letter, because it is a folder name.jr2 uprefuses a Workflow with an Open Repo Slot that nobody bound.- A per-run url must match an entry of
git.credentials. jr2 refuses the url before it makes the Sandbox when no entry matches it. Thus a run cannot send the cluster's credentials to a host that it chooses. specreturns abranch. When a run enters the Workspace, it faults ifspecgives nobranch, or the branchdefault, becausedefaultis the folder of the clone. With no Repo Slot, it faults ifspecgives abranchor areviewSha.- With no Repo Slot,
jr2 uprefuses apush, and acommandthat names an Effect secret. - A Size has only
limits.memoryandlimits.cpu. jr2 refusesrequestsand each other key. jr2 uprefuses a Size that leaves the Harness container less than 256Mi of memory, or no cpu.- Under a Workspace with more than one Repo Slot, a Turn states its
cwd, and acommandand apushname their slot.
The guides and the Reference give the refusal text of each rule.
Read next
- Look inside a Sandbox
- Add tools to the Sandbox
- Size a Workspace
- Give the cluster git credentials
- Effects, Composition, Architecture
- Reference:
workspace,RepoSlot,Size,UserContainer,workspace.lost,sandbox - ADR-0012:
workspace()wraps a body Machine and owns only Sandbox lifecycle - ADR-0051: a Repo is a slot on the
workspace()and a cache on the node - ADR-0060: a Workspace states its Size, and jr2 reserves all of it
- ADR-0064: a Workspace waits for capacity
- ADR-0021: Workspace Continuity is watched, and the Lease asserts
- ADR-0005 and ADR-0037: the pod, and the Sandbox Image