Effects
An Effect is work that a Machine does outside itself. jr2 has four kinds: command runs a program in the Workspace, http calls a URL, push pushes the Workspace's branch, and dispatch starts a run of a Workflow. You write an Effect as an actor slot, beside the Agents of the Machine.
Why Effects exist
A Machine often needs a fact from the world: do the tests pass, did the call succeed, is the branch on the remote. Without an Effect, the Machine can only ask an Agent to do the work. Then the Machine must trust what the Agent says. An Effect gives the Machine its own result, and the Machine routes on it.
An Effect also keeps a credential away from the Agent. A push uses a push credential, and an http call uses an Effect secret. The Agent never reads either one. jr2 also records each Effect before it starts, so that a restart of the Orchestrator does not do the work two times. A plain xstate promise has no such record.
An Effect is an actor slot
Put an Effect under actors in jr2Setup. Invoke it by its key from a state. The state's onDone gets the output of the Effect. This example is a Workflow with a Workspace. An Agent fixes the code, and it can ask for a test run with the pick run_tests. When the Agent picks finished, the Machine pushes the branch:
import { z } from "zod";
import {
agent,
answer,
command,
defineEvent,
fail,
jr2Setup,
push,
workspace,
type CommandOutput,
type Workspaced,
} from "@jr2/orchestrator";
// The two picks of the Agent.
const runTests = defineEvent({
name: "run_tests",
description: "Run the tests. The result is the answer to this call.",
input: z.object({}),
});
const finished = defineEvent({
name: "finished",
description: "Call this when the tests pass and your fix is committed.",
input: z.object({}),
});
const runInput = z.object({ prompt: z.string(), branch: z.string() });
type BodyInput = Workspaced<z.infer<typeof runInput>, "app">;
const passed = (output: CommandOutput): boolean => output.outcome === "exited" && output.code === 0;
const body = jr2Setup({
types: {} as { context: BodyInput; input: BodyInput },
events: [runTests, finished],
actors: {
coder: agent({
model: "anthropic/claude-haiku-5-5",
instructions: "You are a careful engineer. You commit your work on the branch.",
}),
// Two Effects. Each is an actor slot, beside the Agent.
tests: command(["node", "--test"], { timeout: "5m" }),
publish: push(),
fail: fail(),
},
}).createMachine({
id: "test-and-push",
context: ({ input }) => input,
on: { "workspace.lost": ".lost", "agent.fault": ".failed" },
initial: "coding",
states: {
coding: {
invoke: {
src: "coder",
input: ({ context }) => ({ prompt: context.prompt, cwd: context.workspace.repos.app }),
},
on: { finished: "publishing" },
initial: "ready",
states: {
ready: { on: { run_tests: "testing" } },
testing: {
invoke: {
src: "tests",
onDone: {
target: "ready",
// The Agent reads this text as the answer to its run_tests pick.
actions: answer(({ event }) =>
passed(event.output)
? "The tests pass."
: `The tests fail:\n${"tail" in event.output ? event.output.tail : event.output.reason}`,
),
},
},
},
},
},
publishing: {
invoke: {
src: "publish",
onDone: [{ guard: ({ event }) => event.output.outcome === "pushed", target: "done" }, { target: "notPushed" }],
},
},
done: { type: "final" },
// A Failure ends the body, and jr2 deletes the Sandbox with its commits. To keep them, wait at
// a Gate before this state.
notPushed: { invoke: { src: "fail", input: { reason: "the push did not publish the fix" } } },
failed: { invoke: { src: "fail", input: { reason: "the Agent's Turn could not go on" } } },
lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
},
});
export const machine = workspace(body, {
input: runInput,
repos: { app: "https://github.com/you/app.git" },
spec: ({ input }) => ({ branch: input.branch }),
});Agents and Turns tells how to define an Agent.
The Effect has the life of the state that invokes it. It starts when the run enters the state, and it stops when the run leaves the state. Its input goes into the run's snapshot. A typo in src is a compile error. customize() binds an Open part of the Effect by its slot key (see Composition).
jr2 up reads each Effect slot without running it, and prints what it found. For each Workflow, it lists each Effect with its kind, its argv or its method and host, its timeout, and where it runs. For the example above, jr2 up prints these lines:
effects: workflow "test-and-push" — what it runs outside itself (ADR-0067)
tests: command ["node","--test"] — 300s, in the Harness
publish: push — 300s, in the Sandbox EffectorThe first line gives the name of the Workflow. Each next line gives the slot of one Effect.
jr2 up also lists each state that invokes fail, because each one ends the run failed:
failures: workflow "test-and-push" — the states that fail the run (ADR-0073)
test-and-push.notPushed: invokes fail
test-and-push.failed: invokes fail
test-and-push.lost: invokes failThe four kinds
command
command runs one program. Its argv is a list, and each entry is one argument. jr2 starts argv[0] with no shell. An arg("name") entry is one argument that the input of the Effect fills. Thus an Agent's text cannot become shell syntax. To use a shell, write it in the argv: ["sh", "-c", "…"]. Then the result is the result of your script. The Agent can change the files that the script runs, so do not trust its result more than the Agent.
A command uses the Worktree of one Repo Slot: the only slot, or the slot that its slot option names. Its default timeout is 10 minutes. Its output keeps the end of what the program wrote: 4096 characters by default (the tail option). The full output goes to the run's feed.
http
http calls a URL. The definition fixes the method, the URL, the headers and the auth. The scheme and the host of the URL are literal text. An Effect secret can be in the path of the URL, in a header or in the auth, but never in the host. A request function makes the part that changes for each run: path segments, and a JSON or text body. Thus a value from the run can change a path segment or the body, but not where the call goes.
In the second example, an Agent with workspace: "none" writes one comment, and the Machine posts it on a ticket. This Agent has no Sandbox. The jira Effect secret must be in the secrets list of jr2.config.ts:
import { z } from "zod";
import { agent, bearer, defineEvent, fail, fromPick, http, jr2Setup, secret } from "@jr2/orchestrator";
const postComment = defineEvent({
name: "post_comment",
description: "Post one comment on the ticket.",
input: z.object({ body: z.string() }),
});
// The host is fixed here. No input can change it.
const comment = http({
method: "POST",
url: "https://jira.example.com/rest/api/2/issue",
auth: bearer(secret("jira")),
input: z.object({ ticket: z.string(), body: z.string() }),
request: ({ ticket, body }) => ({ path: [ticket, "comment"], json: { body } }),
});
export const machine = jr2Setup({
types: {} as { context: { ticket: string }; input: { ticket: string } },
events: [postComment],
actors: {
triager: agent({
model: "anthropic/claude-haiku-5-5",
instructions: "You triage tickets.",
workspace: "none",
}),
comment,
fail: fail(),
},
}).createMachine({
id: "comment",
input: z.object({ ticket: z.string() }),
context: ({ input }) => input,
on: { "agent.fault": ".failed" },
initial: "triaging",
states: {
triaging: {
invoke: {
src: "triager",
input: ({ context }) => ({ prompt: `Write one comment for ticket ${context.ticket}.` }),
},
initial: "ready",
states: {
ready: { on: { post_comment: "posting" } },
posting: {
invoke: {
src: "comment",
// The Agent gives `body`. The Machine gives `ticket`.
input: fromPick(postComment, ({ context }) => ({ ticket: context.ticket })),
onDone: "#done",
},
},
},
},
done: { id: "done", type: "final" },
failed: { invoke: { src: "fail", input: { reason: "the Agent's Turn could not go on" } } },
},
});The body can also be a file of the Worktree: body: file(path) (see file). The default timeout of an http Effect is 30 seconds.
push
push pushes the Workspace's branch to the Binding of its Repo Slot. It uses the push credential of the git.credentials entry that the URL matches (see git.credentials). The Agent never reads that credential. These options change what a push publishes:
pin: push the commit that the input names, such as a commit that a Gate approved. Withoutpin, jr2 pushes the tip of the Agent's branch when the Effect starts.sign: push signed copies of the commits.squash: push one commit that holds all the changes, with the message that the input gives.force: replace what the remote branch holds.
With pin, the input gives { sha }. With squash, it gives { message }.
After a signed or a squashed push, the commits on the remote are not the Agent's own commits. Then jr2 moves the Agent's branch to the published commits. The Agent's files, index and stash do not change. The default timeout is 5 minutes.
dispatch
dispatch starts a run of a Workflow. The new run is a run of its own: it has its own line in jr2 runs, its own Workspaces and its own CANCEL. Its record names the run that started it: it is dispatched by that run. Use dispatch when a Machine waits for work, for example at a Gate that a webhook answers, and each item of work needs a run of a Workflow that you have. To use a Machine as a part of one run, invoke it in the run instead (see Composition).
- The name of the Workflow is a constant of the definition, as
jr2 runwrites it:"triage", or"<wrapper>/<name>"for a named Chart. An Agent cannot choose it. - The
inputof the invoke is the run input of the new run: the same JSON thatjr2 run --inputsends. Theinputoption of the definition is a zod schema of that run input. It types the invoke, andjr2 upcompares it with the input that the Workflow declares. - By default, the Effect ends when the Orchestrator accepts the new run. With
wait: true, the Effect ends when the new run ends, and its output holds the status and the output of that run. The default timeout is 30 seconds, and 10 minutes withwait. - With no
target, the new run starts in the same Orchestrator. Withtarget: { url: "https://<host>", token: secret("<name>") }, it starts on a different Orchestrator.urlis the origin of that Orchestrator, and the secret holds its Instance token. - The new run starts under the current Assembly, as a run of
jr2 rundoes.
The two runs are independent. A CANCEL of one run does not cancel the other. When the run that dispatched ends, the new run continues.
Where an Effect runs
You do not choose where an Effect runs. The kind, and what the Effect names, decide it. Each Effector is a container that runs jr2's own process, and the Agent cannot run commands in it:
| The Effect | Where it runs |
|---|---|
A command that names no Effect secret | The Harness container of the Sandbox |
A command that names an Effect secret | The Sandbox Effector, against a commit |
An http Effect | The Instance Effector |
An http Effect with a Worktree file | The Sandbox Effector |
A push | The Sandbox Effector |
A dispatch with no target | The Orchestrator, in its own process |
A dispatch with a target | The Instance Effector |
A command that names no Effect secret runs beside the Agent's Working tools. It runs as the Agent's user, with the tools of the Sandbox Image. It has the same Stand-ins as the Agent's shell, and no credential of its own.
A command that names an Effect secret in its env runs in the Sandbox Effector, a container in which the Agent runs nothing. It does not run in the live Worktree. It runs against one commit: the tip of the branch when it starts, or with pin, the commit that its input names. When the Worktree held changes that the command did not see, its output has dirty: true.
A command, a push and an http Effect with a file body need a Workspace. Invoke them from the body of a workspace(). Architecture tells what each Effector is, and Secrets and trust tells how an Effector holds the Effect secrets.
Every outcome is output
An Effect gives each outcome to onDone, a failure too. Route on the outcome field:
| Kind | The outcomes | The output type |
|---|---|---|
command | exited, timeout, killed, unknown | CommandOutput |
http | response, unreachable, refused, unknown | HttpOutput |
push | pushed, rejected, diverged, unknown | PushOutput |
dispatch | started, settled, refused, unreachable, unknown | DispatchOutput |
A test that fails is exited with a code that is not 0. A program that the container does not have is exited with code 127. An HTTP 4xx or 5xx status is a response. A Workflow that refuses the run input of a dispatch is refused, with the status code and the report of the refusal. unknown means that jr2 cannot tell what the Effect did.
The onError route of an Effect is only for a defect in the Machine or in the config. For example, the Effect is not in a Workspace, or an arg() has no value, or an Open part has no binding. An outcome that means that the work did not succeed goes to a state that invokes fail. Then the run ends failed, with a reason (see How a run ends). Do not throw to fail: a throw is a defect, and the run ends error.
At most once
jr2 writes a record of each Effect before the Effect starts, and saves it with the run's snapshot. When the Effect ends, jr2 records its output. After an Orchestrator restart, jr2 reads the record:
- An Effect that ended gives its output. jr2 does not run it again.
- An Effect that did not start, starts.
- An Effect that started follows the rule of its kind.
| Kind | After a restart, when the Effect started |
|---|---|
A command that names no Effect secret | jr2 attaches to it again. When the Harness does not know it, the outcome is unknown. It never runs again. |
A command that names an Effect secret | jr2 follows it. When the Sandbox Effector lost it, jr2 runs it again against the same commit. With no commit recorded, the outcome is unknown. |
A push | jr2 follows it. When the Sandbox Effector lost it, jr2 pushes the same commit again, and no other commit. With no commit recorded, the outcome is unknown. |
An http Effect | jr2 follows it. When no Effector holds it, jr2 sends it again only if it is idempotent. Else the outcome is unknown. |
A dispatch | jr2 sends the start again, with the same key. The Orchestrator answers with the run that the key started, and it starts no second run. |
A command that names no Effect secret runs the code of the Worktree, and that code can spend a Stand-in. Thus jr2 does not run it two times. A push, and a command that names an Effect secret, run against a commit that jr2 records. Thus jr2 can send them again with that commit.
To try again, write it in the Machine: a route from the outcome back into the state. Mark an http Effect idempotent only when a second call is safe: the call has its own idempotency key, or it only reads.
When the state exits
When the run leaves the state, the Effect stops:
- jr2 kills the process group of a
commandthat names no Effect secret. - The Sandbox Effector stops a
commandthat names an Effect secret. It sendsSIGTERM, waits 5 seconds, then sendsSIGKILL. - An
httpEffect and apushhave no stop. jr2 stops waiting for the output, but the Effector can still finish the work. - A
dispatchwithwaitstops its wait. The run that it started continues.
jr2 records the stop, and the Machine gets no output. There is no "finally" in a Machine: cleanup is a state. Put an Effect that must not stop in a state of its own. The only exit of that state is its onDone. That state handles the events of the states above it.
From a pick to an Effect
An Agent can start an Effect with a pick. The pick moves the run into a state that invokes the Effect. Three names help you write that state. Each one is optional.
fromPick(def, fill)is theinputof the state. The params of the pick go into the Effect's input, by name.fillgives only the keys that the Machine adds. Incomment.ts, the Agent givesbody, and the Machine givesticket. Thus the Agent cannot post on a different ticket. TypeScript checks the sum against the input type of the Effect.paramsOf(event, def)reads the same params in a plaininputmapper. Use it when the Machine changes a param before the Effect gets it.answer(fn)sets what the Agent reads when jr2 answers its pick. Intest-and-push.ts, the Agent reads one line, or the end of the test output. Withoutanswer, it reads theCommandOutputof the command, as JSON. The Menu tells more aboutanswer.
While the Effect runs below the Agent's state, the Agent's pick is outstanding. The state is not at Rest until the Effect ends. The Menu tells how a pick waits for its answer.
Rules
- An Effect is a key of
actors.createMachinerefuses an Effect that you write directly in aninvoke. - A
command, apushand anhttpEffect with a file body run only inside aworkspace(). - Under a Workspace with more than one Repo Slot, a
command, apushand a file body name their slot. - The host of an
httpURL is literal. An Effect secret never goes in the host, the query or the fragment of a URL. - Each Effect secret that an Effect names is in the
secretslist ofjr2.config.ts, with a value of 8 characters or more.jr2 uprefuses a name that is not in the list, and a value that is shorter. - A
pushwithsignneeds asignfield on itsgit.credentialsentry. Asquashwithoutsignneeds asignor acommitterfield there. - Each param of a pick that enters an Effect's state has a reader: the Effect's input,
fill, a guard or anassign.createMachinerefuses a param that nothing reads, and afillkey that is also a param. - A packaged Machine can leave an
httpURL, acommandargv, one Effect secret name or thetargetof adispatchOpen. The composer binds it one time withcustomize().jr2 uprefuses an Open part that nobody bound. - A
dispatchwith notargetnames a Workflow that the Assembly has.jr2 uprefuses a name that it does not have, and aninputschema that the input of that Workflow does not take. - The
urlof adispatchtarget is an origin only: a scheme, a host and a port, with no path, no query and no user.
The guides give the refusal text of each rule.
Read next
- Run a command or call a URL with an Effect
- Push and sign a branch
- Start a run from a run
- Workspaces, The Menu, Secrets and trust
- Reference:
command,http,push,dispatch,fromPick,answer - ADR-0067: an Effect is an actor slot, and runs at most once unless marked idempotent
- ADR-0068: an Effector holds an Effect's secrets and signs its wire
- ADR-0071: a run starts a run through a
dispatchEffect