`homeboy workflow`
homeboy workflow validate <file|id|->
homeboy workflow run <file|id|-> [--input NAME=VALUE]... [--instance NAME] [--resume-from INSTANCE] [--on RUNNER] [--wait]
homeboy runs show <instance>
homeboy runs watch <instance> [--interval 2s] [--timeout 5m | --forever]
homeboy workflow cancel <instance|cadence-workflow-id>
homeboy workflow approve <instance> <step>
homeboy workflow list
homeboy runs list --kind workflow
homeboy [--placement auto|lab|lab-or-local|local] workflow ab <component|path> --base <ref> --candidate <ref> --run <command> --compare <path-or-glob>... [--instance NAME] [--wait]A workflow pre-defines multi-step and repeating agent work and runs it mechanically: steps run in dependency order, and gates decide which step runs next. The definition format is homeboy/workflow/v1 (JSON).
How an instance runs
- One instance is one daemon job.
workflow runadmits aworkflowjob in the daemon job store. The daemon is its only writer. - The instance never runs work itself. It submits
stepjobs and evaluates gates. Every step job is a supervised child with an admitted environment (seehomeboy daemonand thestepjob kind). - One instance is one pass. A step’s outcome and output are the result of its run in this instance. A definition that wants another pass makes its final step re-admit the definition with the next pass’s inputs, which chains a new instance (see Self-admitting chains).
- Every decision is a pure function.
advance(definition, state, event)returns the next state and its commands: submit a step, cancel a step, complete the instance. The daemon commits the new checkpoint, the new step jobs, and their submission keys in one store transaction, and only then starts the steps. A crash at any point either loses a whole decision or replays it against the same keys. - Keys are deterministic. A step job’s key is
<instance>/<step>/<iteration>/<attempt>; aforeachitem’s is<instance>/<step>/<iteration>/item-<n>/<attempt>. An instance runs iteration 1. A replayed submission converges on the existing job, so no step runs twice. - The instance id is deterministic too. It is
wf-<definition-id>-<sha256(definition digest, inputs, --instance)[..12]>. Re-running the same definition with the same inputs returns the existing instance; new inputs (or a new--instancename) start a new one. - The definition is pinned. Its digest and body are stored in the instance at admission, so editing the file never changes a running instance.
Statuses:
| Thing | Terminal statuses |
|---|---|
| Step | succeeded, failed, timed_out, cancelled, skipped; a cook step reports its Cook’s run state (succeeded, candidate_recoverable, partial_failure, failed, cancelled) |
| Instance | succeeded, failed, cancelled, budget_exhausted |
Resume after fixing a definition
homeboy workflow run <file|id|-> --resume-from <instance> admits the current definition and inputs as a new instance. The source must be terminal and belong to the same workflow on the selected daemon. A succeeded step is reused only when its complete templated step-definition digest matches and all its needs were reused. Changing a step therefore reruns its downstream steps. Failed, skipped, and unfinished steps run normally.
Reused steps retain their recorded outputs, run ids, item records, and original job keys (the references to their artifacts); artifacts are not copied or recreated. runs show includes resumed_from and each step’s reused and launched flags. Pending steps have both flags false. Resume provenance participates in the deterministic instance id; omitting the flag preserves existing identity and pinning semantics. Cadences do not accept --resume-from.
The homeboy/workflow/v1 format
{
"schema": "homeboy/workflow/v1",
"id": "string", // registry id: ~/.config/homeboy/workflows/<id>.json, or run any path; letters, digits, `_`, `-`, `.`
"inputs": { "<name>": { "required": true, "default": null } },
"env": { "public": ["NAME"], "secrets": ["SECRET_REF"] }, // the admitted environment; nothing else is inherited
"budget": { "max_step_runs": 20, "deadline": "12h" }, // hard stop → budget_exhausted
"every": { "interval": "24h", "at": "03:00Z", "overlap": "skip" }, // optional cadence (see below)
"steps": [
{
"id": "string",
"needs": ["step-id"], // run after these succeed; a need that did not succeed skips this step
"when": <gate>, // evaluated after needs settle; false skips the step
"always": false, // run even if a need failed (cleanup)
"foreach": "{{steps.prepare.output/children}}", // fan-out: one step job per item (or a JSON array)
"max_parallel": 4, // at most this many items run at once (default: all)
"join": "all", // or "any", or {"min_succeeded": N}; decided once every item settled
"retry": { "max": 2, "on": ["failed", "timed_out"], "backoff": "10m" },
"timeout": "90m",
// exactly one of:
"cook": { /* keys are `homeboy agent-task cook` flags */ },
"homeboy": ["argv", "..."],
"exec": { "program": "node", "args": ["..."], "cwd": "..." },
"approval": { "prompt": "string" }
}
]
}Use next_pass for a bounded chain of passes. Unknown fields (including the removed iterate) are refused. Replace command gates with an exec step plus an outcome gate.
Fan-out and join
A step with foreach expands, once its needs and when pass, into one step job per item. foreach is a JSON array or a template that resolves to one (a whole-value reference such as {{steps.prepare.output/children}} keeps the array’s JSON). Inside the step’s own work, {{item}} is the item and {{item.<field>}} reads a field of it.
max_parallelbounds how many items run at once; the next item starts as one settles.- Items are independent. One item’s failure neither cancels nor skips its siblings.
retryandtimeoutapply to each item. joindecides the step once every item has settled:all(default) needs every item to succeed,anyone,{"min_succeeded": N}at leastN. A passing join makes the stepsucceeded; otherwise it ispartial_failure(some items succeeded) orfailed(none did), which fails the instance like any failed step.- The step’s output is
{"total", "succeeded", "join", "items": [{"item", "outcome", "run_id", "output", "error"}]}. - Cancelling the instance cancels every running item, the Cook a
cookitem runs included, and starts no further one.
Use {"min_succeeded": 0} when item failures are expected outcomes, such as Cooks or git pr merge-when-green holds in a next_pass loop: the step succeeds once every item settles, preserving each item’s outcome and error (Example D).
agent-task fanout cook-batch compiles to exactly this shape (Example C).
Step kinds
For a reviewed-head merge, use a homeboy step invoking
git pr merge-when-green. It owns bounded check
and mergeability polling, typed holds, repository method selection, and the
atomic head guard; workflows do not need an exec polling/merge wrapper.
execruns a program directly, never through a shell. The program is resolved to an absolute path when the workflow is admitted. Its output is the JSON it writes to the file named by$HOMEBOY_STEP_OUTPUT.homeboyrunshomeboy <argv>with this Homeboy build. Its output is thedataof the command’s JSON envelope.cookrunshomeboy agent-task cook <flags> --wait --cook-id <id>. Every key must be a realagent-task cookflag; this is checked against the live command tree, so there is no second Cook contract. A string becomes--key value,truebecomes--key, and an array repeats the flag.wait,cook-id,run-id,output, andpreviewbelong to the workflow. The step’s outcome is the Cook’s terminal run state, read from its run record;{{steps.<id>.run_id}}is the Cook id.approvalwaits forhomeboy workflow approve <instance> <step>orhomeboy workflow reject <instance> <step> [--reason <text>]. It runs no job. Approval settlessucceeded; rejection settlesrejected(not an instance failure). Both write output withdecision(approvedorrejected),reason(text or null), andactor(the submitting user’s name, orlocal-operator).runs showincludes this output.Rejected dependencies skip their dependents by default. A passing
whenthat explicitly matches the rejected dependency’s/decisiontorejected(usingequalsorin), or itsoutcometorejected, permits that branch to run. For example:json{"id":"revise","needs":["approve_merge"], "when":{"step":"approve_merge","output":"/decision","equals":"rejected"}, "cook":{"prompt":"Rework the candidate: {{steps.approve_merge.output/reason}}"}}Gate the merge branch on
{"step":"approve_merge","output":"/decision","equals":"approved"}.alwaysretains its usual dependency override.When the instance has a notification route, entering this gate sends an
awaiting_approvalannouncement with the rendered prompt, upstream output artifact links, and the approve and reject commands. Delivered or queued announcements are deduplicated per instance, step, and iteration across recovery; status projections never send notifications.Pass the global
--notification-transport TRANSPORT --notification-route ROUTEflags toworkflow runto bind a destination. Explicit flags win overHOMEBOY_NOTIFICATION_TRANSPORT/HOMEBOY_NOTIFICATION_ROUTE. The resolved route is persisted with the instance (or cadence template), propagated to all step processes, and inherited by workflows admitted from those steps. Recovery uses the persisted route rather than the daemon’s ambient destination.
Gates
exec runs a program directly, never through a shell. The program is resolved to an absolute path when the workflow is admitted. Its output is the JSON it writes to the file named by $HOMEBOY_STEP_OUTPUT.
| Gate | Passes when |
|---|---|
{"step":"id","outcome":["succeeded","candidate_recoverable"]} | the step’s outcome is in the set |
{"step":"id","output":"/json/pointer","equals":X} (or in, exists, lt, gt) | the test holds on the step’s output |
{"all":[…]}, {"any":[…]}, {"not":…} | the combination holds |
homeboy runs homeboy <argv> with this Homeboy build. Its output is the data of the command’s JSON envelope.
Templating
cook runs homeboy agent-task cook <flags> --wait --cook-id <id>. Every key must be a real agent-task cook flag; this is checked against the live command tree, so there is no second Cook contract. A string becomes --key value, true becomes --key, and an array repeats the flag. wait, cook-id, run-id, output, and preview belong to the workflow. The step’s outcome is the Cook’s terminal run state, read from its run record; {{steps.<id>.run_id}} is the Cook id.
Validation
approval waits for homeboy workflow approve <instance> <step> or homeboy workflow reject <instance> <step> [--reason <text>]. It runs no job. Approval settles succeeded; rejection settles rejected (not an instance failure). Both write output with decision (approved or rejected), reason (text or null), and actor (the submitting user’s name, or local-operator). runs show includes this output.
- The
needsgraph is acyclic and names real steps. - Every template reference resolves to a declared input or an upstream step (a step reachable through
needs). - A
whengate reads only upstream steps or the step itself. - Every
cookkey is a realagent-task cookflag. - Every environment name is declared once; a credential-named variable is a secret, never a public value.
- Durations (
timeout,retry.backoff,budget.deadline) parse. - No
iterateand nocommandgates (see the format above).
Failure, retry, budgets
- A failed step fails the instance.
failed,timed_out,cancelled, andpartial_failureare failures. No new step starts after one unless it isalways; when everything in flight has settled the instance isfailed. It never parks. retryre-submits the step as attemptn+1when its outcome is inon(defaultfailedandtimed_out), up tomaxretries.backoffdelays the retry through the step job’snot_beforewait.timeoutcancels the step’s job when it has run that long; the outcome istimed_out.budget.max_step_runscounts every step job. Reaching it cancels whatever is running and ends the instancebudget_exhausted. So does passingbudget.deadline.
Environment
Rejected dependencies skip their dependents by default. A passing when that explicitly matches the rejected dependency’s /decision to rejected (using equals or in), or its outcome to rejected, permits that branch to run. For example:
env.publicnames are captured from the environment that runsworkflow run. A declared name that is unset there is refused.env.secretsare agent-task secret references, resolved when each step spawns. The daemon’s own environment is never a source.homeboyandcooksteps also get the runtime environment a detached Cook gets (provider process variables and Homeboy’sHOMEBOY_*configuration).- Every step also gets
HOMEBOY_WORKFLOW_INSTANCE, the id of the instance whose step is running.workflow runreads it and records the admitting instance as the new instance’sparent.
Native next passes (next_pass)
Gate the merge branch on {"step":"approve_merge","output":"/decision","equals":"approved"}. always retains its usual dependency override.
{"id":"revise","needs":["approve_merge"],
"when":{"step":"approve_merge","output":"/decision","equals":"rejected"},
"cook":{"prompt":"Rework the candidate: {{steps.approve_merge.output/reason}}"}}inputs.iterationis a reserved positive JSON integer, starting at 1.max_iterationsincludes the first pass.inputs.previousis null on the first pass; thereafter it containsoutputs(a map keyed by step id) andunchanged_passes. A step can read the whole object with{{inputs.previous}}.stop_unchangedcounts consecutive passes with equal JSON values at the chosen output pointer, including the first observation. A changed value resets the count to 1; a missing output is an error.- Each pass has its own status, step jobs and budget. Parent/children links and
--waitfollow the chain. Child admission is idempotent across recovery. - Definitions and admitted environments/programs are pinned. For file-based admission, a modified or missing definition is refused with a clear error before the next pass; restart to adopt edits. Registry files are checked too; stdin/inline/built-in definitions remain pinned snapshots.
next_passcannot be combined withevery.
Cadence (every)
When the instance has a notification route, entering this gate sends an awaiting_approval announcement with the rendered prompt, upstream output artifact links, and the approve and reject commands. Delivered or queued announcements are deduplicated per instance, step, and iteration across recovery; status projections never send notifications.
- Registration. The registration is the admitted request: the definition, the inputs, the captured environment, and the resolved programs. It is keyed by the workflow id; running
workflow runagain replaces it. An unchanged cadence keeps its slot grid; a changedintervaloratstarts a new grid.--waitand--instancedo not apply.--on RUNNERregisters it on that runner’s daemon. - Slots. Slots are
anchor + k × interval. Withat(a UTC time of day,HH:MMorHH:MMZ) the anchor is that time, so{"interval": "24h", "at": "03:00Z"}fires daily at 03:00 UTC; without it the first slot is the moment of registration.intervalis at least1m. - Admission. The admitting daemon’s orchestration tick (60 seconds by default) admits each due slot. Slots missed while the daemon was stopped coalesce into the latest one.
- One instance per slot. A slot’s instance is named
every-<slot time>, so its instance id is deterministic. A tick that re-fires a slot after a crash converges on the instance it already admitted, even across a daemon restart. - Overlap skip. A slot that comes due while the previous instance of the same cadence is not terminal is skipped and counted.
skipis the onlyoverlappolicy. - Where it lives. Registrations are stored in the observation store beside the daemon job ledger, so they survive daemon restarts. A store schema bump starts fresh and drops them. A daemon that is not running admits nothing: run the always-on cadences on a resident daemon (
HOMEBOY_DAEMON_IDLE_TIMEOUT_SECS=0 homeboy daemon ensure-running, or a runner’s service daemon). - Observe and stop.
workflow listshows every cadence with its next due time, fire and skip counts, last instance, and last admission error.workflow cancel <workflow-id>stops a cadence; its instances are ordinary instances you can cancel separately.
Pass the global --notification-transport TRANSPORT --notification-route ROUTE flags to workflow run to bind a destination. Explicit flags win over HOMEBOY_NOTIFICATION_TRANSPORT / HOMEBOY_NOTIFICATION_ROUTE. The resolved route is persisted with the instance (or cadence template), propagated to all step processes, and inherited by workflows admitted from those steps. Recovery uses the persisted route rather than the daemon’s ambient destination.
Daemon restarts
exec runs a program directly, never through a shell. The program is resolved to an absolute path when the workflow is admitted. Its output is the JSON it writes to the file named by $HOMEBOY_STEP_OUTPUT.
homeboy runs homeboy <argv> with this Homeboy build. Its output is the data of the command’s JSON envelope.
A/B parity (workflow ab)
cook runs homeboy agent-task cook <flags> --wait --cook-id <id>. Every key must be a real agent-task cook flag; this is checked against the live command tree, so there is no second Cook contract. A string becomes --key value, true becomes --key, and an array repeats the flag. wait, cook-id, run-id, output, and preview belong to the workflow. The step’s outcome is the Cook’s terminal run state, read from its run record; {{steps.<id>.run_id}} is the Cook id.
{"id":"revise","needs":["approve_merge"],
"when":{"step":"approve_merge","output":"/decision","equals":"rejected"},
"cook":{"prompt":"Rework the candidate: {{steps.approve_merge.output/reason}}"}}- Inputs. The target is a component id or a repository path.
--baseand--candidateare any refs; both resolve to commit SHAs on the controller first.--runruns withsh -cin each checkout.--compare(repeatable) is a file, directory, or glob relative to the checkout. A directory includes every file under it, and.gitis never compared. - Placement.
--placementfollows the usual rules.auto(the default) andlab-or-localuse a ready Lab runner and run here when none is ready.labrequires a runner.localruns on this controller. On a runner, each commit is synced as its own snapshot-git workspace, and the recipe is admitted on that runner’s daemon (workflow run --on). - Execution.
workflow abadmits the built-inhomeboy.abrecipe. Itssidestep fans out overbaseandcandidatewithmax_parallel: 2, so both sides run at once as supervised step jobs. Each side gets an isolated checkout: a detachedgit worktreelocally, or its own Lab workspace. Before running the command, the side asserts that the checkout’s HEAD equals the resolved SHA, and refuses to run if it does not. A side records its SHA, run id (its step job), exit code, a 4 KiB log tail, and the sha256 of every compared file. - Verdict. The
verdictstep writeshomeboy/workflow-ab-verdict/v1. It isidenticalwhen every compared file matches byte for byte on both sides and the exit codes are equal. Otherwise it isdifferentand carriesdiffering(path plus both sha256 values; a file missing on one side hasnull), a unified diff bounded to 64 KiB (diff_truncatedmarks a cut), the per-file sha256 for both sides, and both run ids. A compare set that matches no files on either side is an error, never a vacuousidentical. The verdict is the step’s output, sohomeboy runs show <instance>keeps it as evidence. - Output. With
--wait, human output is a short Markdown summary you can paste into a PR. The JSON envelope addsab(refs and SHAs) andverdict. The exit code is 0 foridenticaland 1 fordifferent. Without--wait, the command returns the admitted instance with itsstatus_commandandcancel_command. - Cancel.
homeboy workflow cancel <instance>cancels both sides. On a Lab runner, run it throughhomeboy runner exec <runner> -- …, which is thecancel_commandthe front end prints. Each side is a step job that leads its own process group, and the command runs inside that group, so cancelling stops the whole tree with nothing left behind. Thealwayscleanupstep removes local worktrees after a verdict or a failed side. A cancelled instance runs no further steps, so its checkouts stay under the temp directory (homeboy-ab/<instance>) untilgit worktree prune. - Identity. The same refs, command, and compare set name the same instance. Pass
--instance NAMEto run them again.
Commands
workflow validateparses and validates a definition and prints its digest and step graph.workflow runadmits an instance in the local daemon and prints its status; a definition witheveryis registered as a cadence instead. With--waitit follows the instance — and any chain of children it admits — until the last instance is terminal, and exits non-zero unless that instance succeeded.--on RUNNERadmits the same definition on that runner’s daemon throughhomeboy runner exec, so the environment is admitted on the runner and an always-on runner can own the instance.runs showis a pure projection of the job ledger: the instance status, itsparentandchildren, each step’s phase, outcome, attempt, key, and step job, eachforeachitem with its step job, Cook run id, and the live state of a running Cook.runs watch <instance>polls until the instance — and any chain it admits — is terminal. It defaults to a five-minute timeout; use--foreverfor an unbounded watch.workflow cancelcancels the instance; the daemon cancels every step job it is running. Once the cancellation is durable the instance admits no more steps. Given a workflow id that is not an instance, it stops that cadence.workflow approverecords an approval for a step that is awaiting one.workflow reject <instance> <step> [--reason <text>]records a rejection for an awaiting approval, with optional feedback for downstream steps.workflow abcompiles and admits the built-inhomeboy.abA/B parity recipe (see A/B parity). Its hiddenab-stepverb runs the recipe’s steps.workflow listlists the definitions in~/.config/homeboy/workflows/, the built-ins, and every registered cadence.runs list --kind workflowlists instances in the daemon without discovery filters.
Examples
Example B: nightly repeating task (cadence + gate → Cook → review)
approval waits for homeboy workflow approve <instance> <step> or homeboy workflow reject <instance> <step> [--reason <text>]. It runs no job. Approval settles succeeded; rejection settles rejected (not an instance failure). Both write output with decision (approved or rejected), reason (text or null), and actor (the submitting user’s name, or local-operator). runs show includes this output.
{
"schema": "homeboy/workflow/v1",
"id": "bounded-loop",
"inputs": {"cursor": {"default": null}},
"steps": [{"id": "evaluate", "exec": {"program": "evaluate"}}],
"next_pass": {
"max_iterations": 10,
"when": {"step": "evaluate", "output": "/continue", "equals": true},
"inputs": {"cursor": "{{steps.evaluate.output/cursor}}"},
"stop_unchanged": {"step": "evaluate", "output": "/remaining", "passes": 3}
}
}Example C: fan-out batch with a join gate
Rejected dependencies skip their dependents by default. A passing when that explicitly matches the rejected dependency’s /decision to rejected (using equals or in), or its outcome to rejected, permits that branch to run. For example:
homeboy --wait workflow ab blocks-engine --base origin/main --candidate my-refactor
--run 'composer build' --compare build --compare 'out/**/*.html'Example D: a fix loop (measure → Cook → confirm → approve → merge → next pass)
Gate the merge branch on {"step":"approve_merge","output":"/decision","equals":"approved"}. always retains its usual dependency override.
measurereports what is still wrong, one item per tracked issue;confirmchecks each fix PR at its head and lists the ones to merge.
When the instance has a notification route, entering this gate sends an awaiting_approval announcement with the rendered prompt, upstream output artifact links, and the approve and reject commands. Delivered or queued announcements are deduplicated per instance, step, and iteration across recovery; status projections never send notifications.
{
"schema": "homeboy/workflow/v1",
"id": "homeboy-nightly-red-main",
"every": { "interval": "24h", "at": "07:00Z", "overlap": "skip" },
"env": { "secrets": ["github-token"] },
"budget": { "max_step_runs": 6, "deadline": "4h" },
"steps": [
{ "id": "ci", "exec": { "program": "gh", "args": ["run", "list", "-R", "Extra-Chill/homeboy", "-b", "main", "-L", "1", "--json", "conclusion,url"] } },
{ "id": "fix", "needs": ["ci"],
"when": { "step": "ci", "output": "/0/conclusion", "equals": "failure" },
"cook": { "repo": "homeboy", "placement": "lab-or-local",
"prompt": "main is red ({{steps.ci.output/0/url}}). Find the root cause and fix it.",
"verify": ["cargo test --workspace"] } },
{ "id": "review", "needs": ["fix"],
"when": { "step": "fix", "outcome": ["succeeded"] },
"homeboy": ["review", "{{steps.fix.run_id}}"] }
]
}Pass the global --notification-transport TRANSPORT --notification-route ROUTE flags to workflow run to bind a destination. Explicit flags win over HOMEBOY_NOTIFICATION_TRANSPORT / HOMEBOY_NOTIFICATION_ROUTE. The resolved route is persisted with the instance (or cadence template), propagated to all step processes, and inherited by workflows admitted from those steps. Recovery uses the persisted route rather than the daemon’s ambient destination.