@opensoftware/foreman
v0.14.0
Published
foreman: runs the factory's workflow of agents for each work item on Temporal, with gates, steering and cancel.
Readme
covers: [modules/foreman/src/, modules/foreman/scripts/]
foreman
Run the factory's workflow for each work item: the stages of factory.yaml one after the other, each with its agent
through the harness port, with human gates, steering and cancel, on Temporal. A run survives a restart of the
foreman, and Temporal's UI shows its history. Decisions: ADR 0067, on
ADR 0004, ADR 0068 for
sandboxes, ADR 0069 for checks, and
ADR 0070 for the inference gateway and take over, and
ADR 0078 for the effort level of each agent, and
ADR 0106 for the limits of the waits, and
ADR 0107 for a stage that only waits, and
ADR 0111 for the result of a stage in the next
stage's prompt, and ADR 0112 for an
agent's MCP servers and skills, and ADR 0126
for an agent's repositories.
Quick start
This quick start is scenario SCN-FORE-001 and is tested on every change. You need the
Temporal CLI for a local Temporal server and Git. Save a factory of two stages:
version: 1
factory: hello
providers:
harness:
acp: "^0"
agents:
plan:
harness: acp
model: routes/default
effort: medium
prompt: ./agents/plan.md
implement:
harness: acp
model: routes/default
effort: medium
prompt: ./agents/implement.md
workflow:
- stage: plan
- stage: implementWrite a plan for the work item into plan.md.Implement plan.md.Commit it, start Temporal, and serve the foreman with Claude Code behind the acp harness adapter:
git init -q && git add -A && git commit -qm "the hello factory"
temporal server start-dev --headless &
export FOREMAN_API_TOKEN=dev-token
export FOREMAN_HARNESSES='{"acp": "npx -y @opensoftware/harness-acp run --allow read,edit -- npx -y @agentclientprotocol/claude-agent-acp"}'
npx @opensoftware/foreman serveSave a work item, as intake publishes it:
{
"id": "wi_5d1c1f0a9e7b3c2d4e6f",
"type": "idea",
"title": "Export reports as CSV",
"requester": { "kind": "person", "id": "slack:U0PERSON", "displayName": "Varun" },
"origin": { "source": "api" },
"dedupeKey": "idea:export-csv"
}Start a run and read it:
curl -H "Authorization: Bearer dev-token" -H "content-type: application/json" \
--data @work.json http://localhost:8080/v1/runs
curl -H "Authorization: Bearer dev-token" http://localhost:8080/v1/runs/run_…The first call answers 201 with the run run_… in the state running. Once both agents ended their turns, the run is
complete and both stages are succeeded. Temporal's UI at http://localhost:8233 shows the workflow run_….
Versions
A run names the versions it runs (ADR 0110):
- Its
factoryVersionis the commit of the factory's folder. The folder must be clean:foreman serve, a run's start and a retry refuse a tracked file of the folder that differs from that commit, and a file the foreman reads (factory.yaml, the prompts, the inference config) that Git does not track, withfactory.yaml: commit the changed files <path>, <path>; a run's factory version is its commit. The server exits with code 2;POST /v1/runsand a retry answer 400. Other untracked files and ignored files do not count. - An adapter that
FOREMAN_HARNESSES,FOREMAN_SANDBOXES,FOREMAN_FORGESorFOREMAN_INFERENCESdoes not name runsnpx -y @opensoftware/<port>-<name>@<version>(runfollows for a harness): the exact version thatfactory plan --adapterspinned inadapters.<port>.<name>offactory.locknext tofactory.yaml, while that pin's range is the range underproviders.<port>.<name>, else that range (ADR 0125). A map entry is a JSON list of the program and its arguments, for a path that holds a space, or a string that the foreman splits at each space. - The run's
adapterslists each adapter underproviderswith its pinned version or its range asversion, and either the command line the foreman builds ascommandor the map that names it asconfigured.
Sandboxes
The quick start runs both agents on your machine. Run an agent you do not trust with that machine in a sandbox: give
it sandbox in factory.yaml. This factory runs both stages on Docker, each in a fresh sandbox with 2 CPUs and 4 GiB,
that may reach only npm and Anthropic's API:
version: 1
factory: hello
providers:
harness:
acp: "^0"
sandbox:
docker: "^0"
agents:
plan:
harness: acp
model: routes/default
effort: medium
prompt: ./agents/plan.md
secrets: [ref:file/anthropic-api-key]
sandbox:
template: node:24
cpu: 2
memory: 4Gi
egress: [registry.npmjs.org, api.anthropic.com]
implement:
harness: acp
model: routes/default
effort: medium
prompt: ./agents/implement.md
secrets: [ref:file/anthropic-api-key]
sandbox:
template: node:24
cpu: 2
memory: 4Gi
egress: [registry.npmjs.org, api.anthropic.com]
workflow:
- stage: plan
- stage: implementPut your Anthropic API key in the file secrets/anthropic-api-key and serve the foreman as in the quick start, with
export OSF_SECRETS_DIR=$PWD/secrets for the docker sandbox adapter. Each
stage then goes like this:
- For each of the agent's secrets from a store, like
ref:openbao/forge-token, the foreman asks the provider's secrets adapter whether it has a value:exists <name>, which reads the secret's metadata only (secrets port, ADR 0139). A secret without a value fails the stage before it starts anything, with the reasonthe secret "ref:openbao/forge-token" has no value; store it and run again; an adapter that fails, such as one whose store does not answer, fails it with the adapter's error. The run is not tried again: store the value and run it again.FOREMAN_SECRETSmaps a provider to its secrets adapter's command line; a provider it does not name runsnpx -y @opensoftware/secrets-<name>@<range>at the range underproviders.secrets. The adapter gets the foreman's variables, among themOSF_OPENBAO_ADDR,OSF_OPENBAO_TOKEN(the read token) andOSF_OPENBAO_MOUNT, which the sandbox adapter gets too. Secrets offileandenvare the sandbox adapter's own: itscreaterefuses a missing one. - The foreman creates a sandbox with the adapter that the factory names under
providers.sandbox(dockerwhen it names none) from the agent'ssandbox. The spec holds each secret as a reference under a variable,ref:file/anthropic-api-keyasANTHROPIC_API_KEY, andOTEL_RESOURCE_ATTRIBUTESwith the run. The sandbox gets a placeholder; its egress proxy puts the key in on the way toapi.anthropic.com(sandbox port). The foreman never reads a secret. - It copies the run's folder into the sandbox, below the sandbox's working folder, as
<run ID>. - It runs the harness adapter's command line from
FOREMAN_HARNESSESin the sandbox with the adapter'sexec, and speaks ACP to it. The template must hold that program;node:24holdsnpx. - When the turn ended, it copies the folder back into the run's folder, so the next stage finds the files.
- It destroys the sandbox, also when the stage failed or was cancelled.
FOREMAN_SANDBOXES maps an adapter's name to its command line; an adapter it does not name runs
npx -y @opensoftware/sandbox-<name>@<range> at the factory's range (Versions). A factory names at most one sandbox adapter. A factory that names none runs
its sandboxes with the adapter of FOREMAN_SANDBOX (docker), so one factory runs in Docker on one machine and in
Kubernetes in a cluster (ADR 0096). An agent without sandbox runs on
the foreman's machine in the run's folder. A sandboxed agent's egress also allows the host of each of its remote MCP
servers; a server that runs with npx needs registry.npmjs.org in the agent's own egress.
Checks
A stage can wait for CI: give it checks, the names of the checks that must succeed on the run's pull request before
it starts (ADR 0069). This factory merges the pull request only when
its check verify succeeded:
version: 1
factory: hello
providers:
harness:
acp: "^0"
forge:
github: "^0"
agents:
plan:
harness: acp
model: routes/default
effort: medium
prompt: ./agents/plan.md
implement:
harness: acp
model: routes/default
effort: medium
prompt: ./agents/implement-pr.md
ship:
harness: acp
model: routes/default
effort: medium
prompt: ./agents/ship.md
workflow:
- stage: plan
- stage: implement
- stage: ship
checks: [verify]Implement plan.md on the branch the prompt names, or on a new branch, push it and open a pull request. End with
the pull request's link.Merge the pull request.Serve it as in the quick start, with the forge adapter's token: export FOREMAN_FORGE_TOKEN=…. Then:
- The run's pull request (ADR 0109). When the
work item's origin is an issue or a pull request of a forge,
<forge>/<owner>/<name>/issues/<n>, that is the work item's repository, the run's branch isosf/<run ID>, and every stage's prompt has the lineBranch: osf/<run ID>. After each stage the foreman runs the forge adapter'sprs <owner>/<name> --head osf/<run ID> --state all: the pull request with exactly that head is the run's. Else it takes the agent's last linePull request: <link>, but only of the work item's repository; a link the agent only mentions is not taken. A stage that refuses a link, or leaves the run without a pull request, says why in itsnote, for exampleno pull request of tester/web has the head osf/run_…, and the agent stated none. For any other origin the run's pull request is the last pull request link that an agent wrote, in its messages or in its tool calls' output:https://<host>/<owner>/<name>/pull/<n>(GitHub) or…/pulls/<n>(Forgejo). The run shows it aspullRequest, every later stage's prompt has the linePull request: <link>, andrun.completed@v1lists it inpullRequests. - Before the stage
shipstarts, and before its gate if it has one, the foreman runs the forge adapter'spr <owner>/<name> <n>and reads the named checks. The stage lists them underchecks, each withnameandstate. When the stage begins to wait for them, a foreman withFOREMAN_NATS_URLpublishesrun.blocked@v1onosf.run.blocked.v1once, with the stage,waitsForchecks, the pull request and the names of the checks; its ID is the run's ID with.blocked.<stage>(ADR 0127). - Every check
success: the stage starts. A checkfailureorerror: the stage isfailedwithout running its agent, with thereasonthe check verify ended with failure, and the run isfailed. A checkpending, or one the pull request does not have yet: the run and the stage areblocked, and the foreman reads the checks again everyFOREMAN_CHECK_POLL_MS. The stage shows since when it waits aswaitingSince. A cancel ends the wait. - Checks that did not all succeed after
FOREMAN_CHECK_TIMEOUT_MS(2 hours) fail the stage and the run, with areasonthat names each check that did not end:the checks did not succeed after 7200000 ms: verify is pending; proof is missing on the pull request's head commit. A check is missing when the forge lists no check of that name on the head commit at all, as for a wrong name (ADR 0106). - Without a pull request link, the stage is
failedwith the reasonno earlier stage linked a pull request.
The forge adapter is the one the factory names under providers.forge; a factory with checks names exactly one.
FOREMAN_FORGES maps an adapter's name to its command line; an adapter it does not name runs
npx -y @opensoftware/forge-<name>@<range> at the factory's range (Versions). It gets FORGE_URL and FORGE_TOKEN from FOREMAN_FORGE_URL and
FOREMAN_FORGE_TOKEN.
A stage with evidence: required also waits for the check proof: the commit status that
proof sets when it attaches the evidence of a pull request
(ADR 0088). Run proof in the CI of the repository, or the stage stays
blocked. Such a stage needs a forge adapter under providers.forge, as checks do.
A stage with wait: true runs no agent (ADR 0107): it waits
for its checks, for proof when it has evidence: required, and for its gate, and then succeeds, with no harness
session, no sandbox and no model call. The run shows it with wait: true, its own name as agent and no sessionId. Such a stage names no
agent, and it names checks, evidence: required or a gate; foreman serve stops at the start otherwise, with
factory.yaml: /workflow/<n>/agent: a stage that only waits names no agent or
factory.yaml: /workflow/<n>/wait: a stage that only waits names checks, the evidence "required" or a gate. A later
stage that must start only on checks that succeeded on the pull request's head commit, like ship, names the same
checks again, so the foreman reads them once more before it starts.
Inference gateway
Give the factory's agents one model endpoint with a budget per run: name an
inference adapter under providers.inference and its config under inference.
This factory runs the LiteLLM gateway on Docker and lets a run spend at
most 5 USD:
version: 1
factory: hello
providers:
harness:
acp: "^0"
inference:
litellm: "^0"
inference:
config: ./inference/litellm.yaml
budget: 5
agents:
plan:
harness: acp
model: routes/default
effort: medium
prompt: ./agents/plan.md
implement:
harness: acp
model: routes/default
effort: medium
prompt: ./agents/implement.md
workflow:
- stage: plan
- stage: implementmodel_list:
- model_name: routes/default
litellm_params:
model: anthropic/claude-sonnet-5-5
api_key: os.environ/OSF_SECRET_ANTHROPIC_API_KEYServe it as in the quick start, with export OSF_SECRET_ANTHROPIC_API_KEY=… for the adapter. Then:
foreman serveruns the adapter'sstartwith the config, a path relative tofactory.yaml, in the background, andstopwhen it stops (see 4). The server takes work at once; a stage waits for the gateway before it gets its key. Astartthat fails is a warning with the adapter's message, and the server tries again afterFOREMAN_INFERENCE_RETRY_MS. Until a start succeeds, the checkinferenceof/v1/healthfails with that message (ADR 0099).startgetsOSF_INFERENCE_OWNERwith the foreman's process ID, so a later start removes the gateway of a foreman that was killed (ADR 0090).- Before a run's first stage, the foreman asks the adapter for the run's key:
key <gateway> <run ID> --budget 5. The gateway refuses the run's calls once it spent its budget. The key is never in Temporal; the adapter gives the same key for the same run, so a stage after a restart of the foreman goes on with the run's key and its spend (ADR 0108). - When the run ended, completed, failed or cancelled, the foreman runs
revoke <gateway> <run ID>, and the key no longer works. A revoke that fails is a warning (the key of the run <ID>: …); the run ends as it would, and the key lives until the gateway stops. - The foreman keeps the gateway's ID and URL in
OSF_DATA/inference/gateway.json, and a file per run with a key inOSF_DATA/inference/runs/. At its start it takes the gateway of that file when the adapter'sliststill prints it. At its stop it runsstoponly when no run has a key; otherwise the gateway runs on for the next foreman. - Each stage's harness adapter gets the gateway in the variables that the harness adapters read:
OPENAI_BASE_URL(the URLstartprinted) andANTHROPIC_BASE_URL(the same without/v1),OPENAI_API_KEYandANTHROPIC_API_KEY(the run's key), andOPENAI_MODELandANTHROPIC_MODEL(the agent'smodel, a routing alias of the config). A sandboxed stage gets them in its sandbox's environment.
FOREMAN_INFERENCES maps an adapter's name to its command line; an adapter it does not name runs
npx -y @opensoftware/inference-<name>@<range> at the factory's range (Versions). A factory names at most one inference adapter. Without one, the foreman sets
none of these variables, and each harness adapter calls the model API it is configured for.
Effort level
Every agent in factory.yaml names its effort: low, medium, high, xhigh or max. Each stage's harness
adapter gets it as OSF_EFFORT, with or without an inference gateway, and a sandboxed stage gets it in its sandbox's
environment. The harness adapter maps it to its product's setting (see the
harness port).
Take over
A person can take a running stage over from its agent and hand the run back later (ADR 0070):
curl -H "Authorization: Bearer dev-token" -H "content-type: application/json" \
--data '{"type": "take-over"}' http://localhost:8080/v1/runs/run_…/signals- The running stage's agent ends its turn within
FOREMAN_POLL_MS. A sandboxed stage copies its files out and destroys its sandbox, as after every turn. - The stage is
blockedwithtakenOverAt, and the run isblockedwithfolder: the run's folder on the foreman's machine, which holds the stage's files. The stage'ssessionId, the run'spullRequestand the work item are the rest of the context. - The person works in that folder, then posts
{"type": "hand-back"}. The stage issucceeded, and the next stage starts from the files in the folder, or the run iscompleteafter the last stage.
A take-over needs a running stage, and a hand-back a stage that a person took over; otherwise the answer is 409. A cancel ends a run that a person took over.
Run again
A run that failed or was cancelled runs again as a new run
(ADR 0105):
curl -H "Authorization: Bearer dev-token" -H "content-type: application/json" \
--data '{"by": {"kind": "person", "id": "github:ada"}}' http://localhost:8080/v1/runs/run_…/retry- The new run has an ID of its own,
attempt2 (3, …),retryOfwith the run before andretriedBywith the person. The run before keeps its view. - It starts at the first stage that did not succeed, or at
from, a stage at or before it:{"by": …, "from": "plan"}. The stages before are copied from the run before, as they ended. When the names of the factory's stages, in order, changed since the run before, the new run starts at the factory's first stage with every stagepending; afromother than that stage is 409. - It moves the folder
OSF_DATA/runs/<run ID>of the run before to its own, and keeps the run's pull request. A failed check is read again, a cancelled stage runs a fresh turn, and a rejected gate asks gatekeeper again. - The prompt of its first stage has one more line that says how the run before ended, for example
Run before: run_… failed in the stage ship: the check verify ended with failure. The later stages do not get it. - It has a trace of its own, whose run span has
osf.run.retry_of, and its own key of the gateway with the factory's budget.
Only the newest run of a work item runs again. A run that is running, blocked or complete is refused with 409
and its state, a run that already ran again with 409 that names the later run. Two retries at once start one run;
the other gets 409. The new run's run.started@v1 and run.completed@v1 have retryOf, and intake's
work.retry-requested@v1 runs a run again as this route does (Events).
Commands
| Command | What it does |
| --- | --- |
| foreman serve | Serve the run API on PORT and run the workflow of FOREMAN_FACTORY on the Temporal server at FOREMAN_TEMPORAL_ADDRESS; with FOREMAN_NATS_URL, start a run for each work.received@v1 and run a run again for each work.retry-requested@v1. |
| foreman resend | Send the events that failed 5 times, the dead letters of the consumer foreman on the NATS server at FOREMAN_NATS_URL, again, and print the subject of each (ADR 0116). |
API
| Route | What it does |
| --- | --- |
| POST /v1/runs | Start the run of a work item (the work item format). 201 with the new run, 200 with the newest run the same work item started before, 400 with a problem that names the field. |
| GET /v1/runs | The runs of the server, running runs first, then the runs that ended, the one that ended last first: { "runs": [...] }, each as GET /v1/runs/{runId} shows it. ?state= with running, blocked, complete, failed or cancelled lists only the runs in that state; another state is 400. One page at a time (ADR 0115): ?limit= runs at most, 1 to 500, 50 by default; the answer's next is the ?cursor= of the next page while more runs follow. With ?state= a page may hold fewer runs than limit. Another limit or a cursor that is no page of the list is 400. Each run keeps its view in its Temporal memo, so a page is one call to Temporal's visibility. |
| GET /v1/runs/{runId} | The run: state, factory, factoryVersion, pullRequest, folder, startedAt, endedAt and its stages; for a run that runs a run before it again, attempt, retryOf and retriedBy; once a signal named its person, actions. 404 for a run the server does not know. |
| GET /v1/health | Whether the server reaches Temporal and, with FOREMAN_NATS_URL, NATS without a dead letter (ADR 0116): 200 with status: ok, else 503 with the failing check and what to fix (ADR 0086). Needs no token. |
| GET /v1/doctor/pings/{id} | The doctor.ping@v1 with this ID that the consumer received since the server started: { "id", "receivedAt" }; 404 for another ID. console doctor --deep publishes one and asks for it (ADR 0120). The server keeps the newest 100. |
| POST /v1/runs/{runId}/signals | { "type": "steer", "message": "…" } sends the message to the running stage's agent; { "type": "cancel" } cancels the run; { "type": "take-over" } and { "type": "hand-back" } hand the running stage to a person and back (Take over). With "by": { "kind": "person", "id": "…" } the run records the signal, its person and its time in its view's actions, oldest first (ADR 0118). 202 with the run, 409 when the run ended or the signal does not fit its state. |
| POST /v1/runs/{runId}/retry | { "by": { "kind": "person", "id": "…" }, "from": "<stage>" } runs a run that failed or was cancelled again (Run again); from is optional. 201 with the new run, 400 without by, with a from the run does not have or a from after the first stage that did not succeed, 404 for an unknown run, 409 for a run in another state, a run that already ran again, or a from other than the first stage when the factory's stages changed. |
The OpenAPI of the routes is in contracts/openapi/openapi.json; the
TypeScript type paths of @opensoftware/contracts types a client. Every route except /v1/health needs
Authorization: Bearer <FOREMAN_API_TOKEN>, else 401. Errors are application/problem+json with title, status and detail.
A run's ID is run_ and 20 hex digits of the SHA-256 of the work item's ID; the n-th run of a work item, n ≥ 2,
takes the SHA-256 of <work item ID>#<n> (Run again). A run is
running, blocked (at a gate, waiting for checks, or taken over by a person), complete, failed or cancelled. A stage is pending,
blocked, running, succeeded, failed, rejected (by its gate) or cancelled; it has its harness sessionId once
it started, its gateId once its gate was asked, its checks once they were read, its takenOverAt once a person
took it over, and a reason when it failed for a reason the foreman knows.
How a run goes
- The factory.
foreman servechecksFOREMAN_FACTORYagainst the factory definition when it starts, and that each stage's agent is declared; a wrong file exits 2 with the path in the file. Each run reads the file again when it starts and keeps that version until it ends. ItsfactoryVersionis the commit of the repository that holds the file. Withgit+<url>#<ref>, the library@opensoftware/factory-sourcefetches the ref intoOSF_DATA/factory(ADR 0124); a private repository over HTTPS takes the token ofFOREMAN_FACTORY_TOKEN, which Git gets as Basic credentials ofx-access-tokenfor that URL only, in its environment, never in a file or on a command line (ADR 0142). Without it, Git's own credentials apply. - Stages. The stages run in the order of
workflow, each in the run's folderOSF_DATA/runs/<run ID>, or in a sandbox when the stage's agent declares one (Sandboxes). The foreman starts the stage's harness adapter fromFOREMAN_HARNESSES, speaks ACP to it, opens a session with the agent's MCP servers and sends one prompt: the agent's prompt file, then the work item's ID, title and description, andOrigin: <url>when the work item has an origin's link, like the issue it came from,Branch: osf/<run ID>when the run's pull request comes from the forge (Checks),Pull request: <link>once the run has one, andRun before: …in the first stage of a run that runs a run before it again (Run again), and last the section# Result of the stage <stage>with what the agent of the stage before wrote, without its tool calls: its last 4000 characters, after the line[… the first <n> characters are cut]when it wrote more (ADR 0111). It allows the tool calls the adapter asks about; limit them with the adapter's--allow. Before the session, the foreman writes the files of each of the agent'sskillsinto the run's folder below.claude/skills/<name>and.agents/skills/<name>, replacing a folder of that name (ADR 0112). A skill of another Git repository,git+<url>#<ref>:<path>, comes from the commit thatfactory.lockpins for it: the library fetches that commit intoOSF_DATA/skillsonce, never the ref, and its files must have the pin's hash. A skill without a pin, a fetch that fails and another hash each stop the server at the start and keep a run from starting (ADR 0146); the foreman gives Git no token for it. It then clones each of the agent'srepositorieswithgit clone --depth 1belowrepositories/<name>,<name>the last segment of its URL without.git, unless an earlier stage left that folder: a clone URL as it is,<owner>/<name>fromhttps://github.com. The foreman gives Git no token; a private repository needs credentials that Git on the foreman's host has, and a clone that fails fails the run (ADR 0126). The session's MCP servers are the agent'smcp:<package>@<version>runs asnpx -y <package>@<version>, andregistry:<name>@<range>asfactory planpinned it infactory.lock, a package run withnpxor a remote URL over HTTP; a registry server without a pin stops the run. A turn that ends withend_turnsucceeds the stage; any other end fails or cancels it, ends the run, and leaves the later stagespending. The adapter getsOTEL_RESOURCE_ATTRIBUTESwithosf.run.id,osf.work_item.id,osf.stageandosf.factory.version, and the factory's inference gateway with the run's key and the agent's model (Inference gateway). - Checks. Before a stage with
checksstarts, the foreman waits until those checks succeeded on the run's pull request; a failing one fails the run (Checks). - Gates. Before a stage with a
gatestarts, the foreman asks gatekeeper atFOREMAN_GATEKEEPER_URL(gatekeeper): the work item's ID, the stage, the summary<title>: start the stage <stage>, the origin's link and the run's pull request aslinks(Origin,Pull request), and the run's ID and the gate'spolicyandapproversinchange, so each run's request is a new one. The run isblockeduntil agate.decided@v1for that gate arrives on NATS, and the stage shows since when it waits aswaitingSince.approvedstarts the stage;rejectedorexpiredfails the run. Without a decision afterFOREMAN_GATE_TIMEOUT_MS(7 days) the gate expires at the foreman, also when its policy names noexpire_after: the stage isrejectedwith the reasonthe gate <id> expired: no decision after 604800000 ms, and the run isfailed. gatekeeper's record of the gate stays as it is; a decision that comes later is a warning and changes no run (ADR 0106). - Signals. A steering message reaches the running stage's agent within
FOREMAN_POLL_MSas a prompt in its session, which the harness port treats as steering. A cancel ends the agent's turn, or the wait at a gate, and the run iscancelled. A person takes a running stage over and hands the run back with the signalstake-overandhand-back(Take over). - Restarts. The runs live in Temporal's history. A foreman that stops and starts again goes on with them; a stage that ran when it stopped runs again, up to 3 times. On SIGTERM, foreman gives running activities 1 second, then cancels the turns of running stages and exits; Temporal runs those stages again when a foreman starts (ADR 0101).
Events
With FOREMAN_NATS_URL, the foreman starts a run for each work.received@v1
on osf.work.received.v1, and hands each gate.decided@v1
on osf.gate.decided.v1 to the newest run of its work item. For each
work.retry-requested@v1 on
osf.work.retry-requested.v1 it runs the run in runId again, by the requester, as POST /v1/runs/<run ID>/retry
does (Run again). It keeps the ID and time of each
doctor.ping@v1 on osf.doctor.ping.v1 for
GET /v1/doctor/pings/{id}, so console doctor --deep sees that an event reaches it (ADR 0120); a consumer of an earlier
release gets the subject when the server starts. A run publishes
run.started@v1 on osf.run.started.v1 when it starts and
run.completed@v1 on osf.run.completed.v1 when it
ends, with the outcome succeeded, failed or cancelled and, when the stage it ended at failed with one, its
reason (ADR 0127). Their IDs are the run's ID with .started and
.completed; a run that runs a run before it again names that run in retryOf. The events are kept in the JetStream stream OSF on osf.>, which the
foreman adds when it is missing, and the foreman reads them through its durable consumer foreman (ADR
0085). A work item or a gate decision published while the foreman is down
reaches it when it starts again. A message is acknowledged after it is handled; one that fails comes again after 2
seconds, at most 5 times. A work item that is not valid, a decision for a run that does not exist, or a retry request
for a run that is unknown, not failed or cancelled, or already ran again, is a warning and does not come again:
a second request for the same run starts no third run.
A message that fails the 5th time is a dead letter (ADR
0116): the foreman keeps its body on
osf.dead.foreman.<subject without osf.>, like osf.dead.foreman.work.received.v1, its warning names that subject,
and the check nats of /v1/health fails while one waits. After fixing the cause, foreman resend sends each one
again on osf.resend.foreman.…, which only the consumer foreman reads, and removes it. With OSF_STREAM_MAX_AGE,
like 7d, the stream drops an event, a dead letter too, after that time; the profile and the chart set 7d.
Traces
With OTEL_EXPORTER_OTLP_ENDPOINT, each run is one OTLP trace (ADR 0087).
When a stage ends, the foreman sends the span stage <stage>, and when the run ends the span run <factory>, to
<url>/v1/traces. The stage spans are children of the run's span. Every span has the resource osf.run.id,
osf.work_item.id and osf.factory.version; a stage span also has osf.stage, osf.agent, osf.harness.name,
gen_ai.request.model, osf.effort, gen_ai.conversation.id (the harness session) and osf.outcome. The trace ID
is the first 32 hex digits of the SHA-256 of the run's ID, so a restarted foreman sends to the same trace. A run that
runs a run before it again has a trace of its own, and its run span has osf.run.retry_of with the run before. The run's
view names it as traceId, so the console links the trace.
Each stage's harness gets TRACEPARENT with the trace and the stage's span, also in a sandbox, so the harness's own
spans join the run's trace. A receiver that refuses a span is a warning; the stage goes on.
When a stage's turn ends, the foreman runs the harness adapter's transcript <session ID> (its command line up to
run, with OUTPUT set to OSF_DATA/transcripts/<session ID>) and puts the run record's gen_ai.usage.* tokens and
osf.cost.usd on the stage's span (ADR 0121). In a
sandbox, the foreman runs transcript there through the sandbox adapter's exec before it destroys the sandbox and
copies the folder it wrote out to the same OSF_DATA/transcripts/<session ID>. A transcript that fails is a warning. The run's view
names each started stage's span as spanId, so the console links each stage to its span.
Releases
release-please opens a release pull request from the Conventional Commits under modules/foreman/. Merging it creates
the tag foreman-v<version>. The workflow .github/workflows/publish-foreman.yml then builds the package and
publishes it to npm as @opensoftware/foreman. The first release is 0.1.0; until 1.0.0, a breaking change raises
the minor version. The one-time setup is the same as for contracts
(contracts/README.md).
Configuration
| Variable | Type | Default | Meaning |
| --- | --- | --- | --- |
| PORT | string | "8080" | serve: the port the HTTP server listens on. |
| OSF_DATA | string | ".foreman" | serve: the folder of the runs' working folders, runs/, and of the harness adapters' session records. |
| OSF_NOW | string | | The current time, like 2026-01-01T00:00:00Z, for the times of runs and stages. Default: the clock. |
| FOREMAN_API_TOKEN | string | | serve: the token that callers of /v1 send as Authorization: Bearer . Required. |
| FOREMAN_FACTORY | string | "factory.yaml" | serve: the factory definition. A path: its folder must be in a Git repository and clean, with every tracked file and every file the foreman reads committed: a run's factory version is its commit (ADR 0110). Or git+#: the foreman fetches the ref, default the remote's HEAD, into OSF_DATA/factory, reads factory.yaml at the root of that commit, and the commit is the factory version (ADR 0124). |
| FOREMAN_FACTORY_TOKEN | string | | serve: a token for a FOREMAN_FACTORY of git+https://… in a private repository, sent to that URL only as Git's Basic credentials of x-access-token. Never written to a file or a command line (ADR 0142). Unset: Git's own credentials. |
| FOREMAN_TEMPORAL_ADDRESS | string | "localhost:7233" | serve: the gRPC address of the Temporal server. |
| FOREMAN_TEMPORAL_NAMESPACE | string | "default" | serve: the Temporal namespace of the runs. |
| FOREMAN_TASK_QUEUE | string | "foreman" | serve: the Temporal task queue of the runs' workflows and activities. |
| FOREMAN_HARNESSES | string | "{}" | serve: a JSON object from harness name to the command line of its adapter. Each command line is a JSON list of the program and its arguments, or a string split at each space. A harness it does not name runs npx -y @opensoftware/harness-@ run, at the range under providers.harness (ADR 0110). |
| FOREMAN_SANDBOXES | string | "{}" | serve: a JSON object from sandbox adapter name to the command line of the adapter. Each command line is a JSON list of the program and its arguments, or a string split at each space. An adapter it does not name runs npx -y @opensoftware/sandbox-@, at the range under providers.sandbox (ADR 0110). The factory names its adapter under providers.sandbox, or else FOREMAN_SANDBOX picks it. |
| FOREMAN_SANDBOX | string | "docker" | serve: the sandbox adapter of a factory that names none under providers.sandbox, like docker in the Compose profile and kubernetes in the Helm chart. |
| FOREMAN_FORGES | string | "{}" | serve: a JSON object from forge adapter name to the command line of the adapter. Each command line is a JSON list of the program and its arguments, or a string split at each space. An adapter it does not name runs npx -y @opensoftware/forge-@, at the range under providers.forge (ADR 0110). The factory names its adapter under providers.forge. |
| FOREMAN_INFERENCES | string | "{}" | serve: a JSON object from inference adapter name to the command line of the adapter. Each command line is a JSON list of the program and its arguments, or a string split at each space. An adapter it does not name runs npx -y @opensoftware/inference-@, at the range under providers.inference (ADR 0110). The factory names its adapter under providers.inference. |
| FOREMAN_SECRETS | string | "{}" | serve: a JSON object from secrets provider name to the command line of its secrets adapter. Each command line is a JSON list of the program and its arguments, or a string split at each space. An adapter it does not name runs npx -y @opensoftware/secrets-@, at the range under providers.secrets. Before a sandboxed stage, the foreman runs its exists for each of the agent's secrets of that provider; file and env are the sandbox adapter's own (ADR 0139). |
| OSF_OPENBAO_ADDR | string | | serve: OpenBao's address, handed on to the sandbox adapter and the secrets adapter for ref:openbao/ secrets; the foreman reads none of the OSF_OPENBAO_ variables itself. |
| OSF_OPENBAO_TOKEN | string | | serve: the token that may only read the mount, the egress proxy's (ADR 0137), handed on to the sandbox adapter and to the secrets adapter, whose exists asks with it whether a secret has a value. |
| OSF_OPENBAO_MOUNT | string | | serve: the KV version 2 mount of the factory's secrets, handed on to the sandbox adapter and the secrets adapter. Unset, both use secret. |
| PATH | string | | serve: the folders where the foreman finds git, npx for an adapter without a command line, and the programs of the adapter maps. |
| FOREMAN_FORGE_URL | string | | serve: the forge adapter's FORGE_URL, for a factory whose stages have checks. Default: the adapter's own default. |
| FOREMAN_FORGE_TOKEN | string | | serve: the forge adapter's FORGE_TOKEN: a token that may read the pull requests and their checks. Never written to a file. |
| FOREMAN_CHECK_POLL_MS | string | "30000" | serve: how long a stage that waits for pending checks waits before it reads them again, in milliseconds. |
| FOREMAN_CHECK_TIMEOUT_MS | string | "7200000" | serve: how long a stage waits for its checks to succeed, in milliseconds; then it fails with a reason that names each check that did not end (ADR 0106). |
| FOREMAN_GATE_TIMEOUT_MS | string | "604800000" | serve: how long a stage waits for the decision of its gate, in milliseconds; then the gate expires and the stage is rejected (ADR 0106). |
| FOREMAN_INFERENCE_RETRY_MS | string | "1000" | serve: how long the server waits before it starts the factory's inference gateway again after a failed start, in milliseconds. Until a start succeeds, the health check inference fails. |
| FOREMAN_POLL_MS | string | "1000" | serve: how often a running stage looks for steering messages and a cancel, in milliseconds. |
| FOREMAN_GATEKEEPER_URL | string | | serve: the URL of gatekeeper serve, which a stage with a gate asks for a decision. Required for a factory with gates. |
| FOREMAN_GATEKEEPER_TOKEN | string | | serve: gatekeeper's API token, GATEKEEPER_API_TOKEN. |
| FOREMAN_NATS_URL | string | | serve: the NATS server for work.received@v1, work.retry-requested@v1, gate.decided@v1 and doctor.ping@v1 in, and run.started@v1, run.blocked@v1 and run.completed@v1 out. Unset: no events. |
| OSF_STREAM_MAX_AGE | string | | serve, resend: the age after which the stream OSF drops an event, like 7d, 12h, 30m or 90s (ADR 0116). The server adds the stream with it, or sets it on a stream that another module added. Unset: a new stream keeps its events without a limit of age, and an existing one keeps its own. The profile and the chart set 7d. |
| OTEL_EXPORTER_OTLP_ENDPOINT | string | | serve: the base URL of an OTLP/HTTP receiver, like http://phoenix:6006. Set, each run is one trace with a span per stage and one for the run, sent to /v1/traces, and each stage's harness gets the trace in TRACEPARENT (ADR 0087). |
| OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | string | | serve: the full URL of the OTLP/HTTP traces endpoint; takes precedence over OTEL_EXPORTER_OTLP_ENDPOINT. |
| OTEL_EXPORTER_OTLP_HEADERS | string | | serve: headers for the OTLP receiver as key=value pairs, comma-separated, like a token from a secret store. |
Scenarios
| ID | Status | Scenario | | --- | --- | --- | | SCN-FORE-001 | implemented | A program starts a run of the README's factory and it completes | | SCN-FORE-002 | implemented | Each stage's agent gets its prompt and the work item | | SCN-FORE-003 | implemented | The same work item again is the same run | | SCN-FORE-004 | implemented | A stage whose turn ends with "refusal" fails the run | | SCN-FORE-005 | implemented | A run the server does not know is 404 | | SCN-FORE-006 | implemented | A call without the API token is refused | | SCN-FORE-007 | implemented | A work item without a title is refused | | SCN-FORE-008 | implemented | A run carries the commit of the factory definition | | SCN-FORE-009 | implemented | A run keeps its factory version when the factory changes during the run | | SCN-FORE-010 | implemented | A stage that names an unknown agent stops the server at the start | | SCN-FORE-011 | implemented | The server needs an API token | | SCN-FORE-012 | implemented | A stage with a gate asks gatekeeper and the run waits | | SCN-FORE-013 | implemented | An approval of the gate starts the stage | | SCN-FORE-014 | implemented | A rejection of the gate fails the run | | SCN-FORE-015 | implemented | A decision for another gate does not start the stage | | SCN-FORE-016 | implemented | A steering message reaches the running stage's agent | | SCN-FORE-017 | implemented | A cancel ends the running stage's turn and the run | | SCN-FORE-018 | implemented | A cancel ends a run that waits at a gate | | SCN-FORE-019 | implemented | A signal to a run that ended is refused | | SCN-FORE-020 | implemented | An unknown signal is refused | | SCN-FORE-021 | implemented | A run goes on from its history after the foreman restarts | | SCN-FORE-022 | implemented | A work item from intake starts a run | | SCN-FORE-023 | implemented | A run that ends publishes its outcome and its factory version | | SCN-FORE-024 | implemented | The foreman allows a tool call that the stage's agent asks about | | SCN-FORE-025 | implemented | A stage whose harness does not start fails the run | | SCN-FORE-026 | implemented | A factory outside a Git repository stops the server at the start | | SCN-FORE-027 | implemented | An event without a valid work item starts no run | | SCN-FORE-028 | implemented | A stage's harness session carries the run | | SCN-FORE-029 | implemented | A run that fails publishes the outcome "failed" and the stage that failed | | SCN-FORE-030 | implemented | A cancelled run publishes the outcome "cancelled" | | SCN-FORE-031 | implemented | Two steering messages reach the agent once each, in order | | SCN-FORE-032 | implemented | A stage whose agent declares a sandbox runs in a sandbox created from it | | SCN-FORE-033 | implemented | The sandbox spec holds the agent's secret as a reference under its variable | | SCN-FORE-034 | implemented | A stage's sandbox carries the run in its environment | | SCN-FORE-035 | implemented | A file a stage writes in its sandbox reaches the run's folder and the next stage's sandbox | | SCN-FORE-036 | implemented | Each stage's sandbox is destroyed when the stage ends | | SCN-FORE-037 | implemented | A cancel destroys the sandbox of the running stage | | SCN-FORE-038 | implemented | A sandbox the adapter cannot create fails the run | | SCN-FORE-039 | implemented | The sandbox adapter is the one the factory names under "providers.sandbox" | | SCN-FORE-040 | implemented | A factory that names two sandbox adapters stops the server at the start | | SCN-FORE-041 | implemented | A stage whose agent declares no sandbox runs on the foreman's machine | | SCN-FORE-042 | implemented | A failing check stops the stage that waits for it | | SCN-FORE-043 | implemented | A stage starts when its checks succeeded | | SCN-FORE-044 | implemented | A pending check holds the stage until it succeeds | | SCN-FORE-045 | implemented | A check the pull request does not have yet holds the stage | | SCN-FORE-046 | implemented | A stage with checks fails when no earlier stage linked a pull request | | SCN-FORE-047 | implemented | A run shows its pull request and publishes it when it ends | | SCN-FORE-048 | implemented | A cancel ends a run that waits for a check | | SCN-FORE-049 | implemented | A factory with checks but without a forge adapter stops the server at the start | | SCN-FORE-050 | implemented | A stage's agent calls the factory's gateway with the run's key | | SCN-FORE-051 | implemented | Each stage's agent calls the gateway with its own model | | SCN-FORE-052 | implemented | The foreman starts one gateway from the factory's inference config | | SCN-FORE-053 | implemented | Each run gets its own key with the factory's budget | | SCN-FORE-054 | implemented | A sandboxed stage gets the gateway in its sandbox's environment | | SCN-FORE-055 | implemented | The foreman stops the gateway when it stops serving | | SCN-FORE-056 | deprecated | A gateway that does not start stops the server at the start | | SCN-FORE-057 | implemented | A factory with an inference adapter but without its config stops the server at the start | | SCN-FORE-058 | implemented | A person takes over the running stage | | SCN-FORE-059 | implemented | The next stage starts from the files of the person who handed the run back | | SCN-FORE-060 | implemented | A take-over destroys the stage's sandbox and keeps its files | | SCN-FORE-061 | implemented | A take-over of a run without a running stage is refused | | SCN-FORE-062 | implemented | A hand-back of a run nobody took over is refused | | SCN-FORE-063 | implemented | A cancel ends a run a person took over | | SCN-FORE-064 | implemented | A person lists the runs of the server | | SCN-FORE-065 | implemented | A person filters the runs by state | | SCN-FORE-066 | implemented | A state that no run can have is refused | | SCN-FORE-067 | implemented | A run that waits at its gate is listed as blocked | | SCN-FORE-068 | implemented | A stage ends when its harness keeps running after the turn | | SCN-FORE-069 | implemented | Each stage's agent gets its agent's effort as "OSF_EFFORT" | | SCN-FORE-070 | implemented | A sandboxed stage gets its agent's effort in its sandbox's environment | | SCN-FORE-071 | implemented | A stage's prompt names the origin of the work item | | SCN-FORE-072 | implemented | A gate request links the run's pull request | | SCN-FORE-073 | implemented | A work item that intake publishes while the foreman is down starts a run when the foreman starts again | | SCN-FORE-074 | implemented | A gate decision that gatekeeper publishes while the foreman is down reaches the run when the foreman starts again | | SCN-FORE-075 | implemented | A server that reaches Temporal and NATS is healthy, without a token | | SCN-FORE-076 | implemented | A run sends a span per stage and one for the run, in one trace | | SCN-FORE-077 | implemented | Each stage's harness gets the trace and the stage's span in "TRACEPARENT" | | SCN-FORE-078 | implemented | A stage that must produce evidence also waits for the check "proof" | | SCN-FORE-079 | implemented | A stage that must produce evidence fails when the check "proof" failed | | SCN-FORE-080 | implemented | A factory with a stage that must produce evidence but without a forge adapter stops the server at the start | | SCN-FORE-081 | implemented | A run's view names its trace ID | | SCN-FORE-082 | implemented | A work item that the foreman cannot start for a moment comes again and starts its run | | SCN-FORE-083 | implemented | A work item that breaks its contract and a gate decision for no run are one warning each and come no second time | | SCN-FORE-084 | implemented | A factory that names no sandbox adapter runs its sandboxes with the adapter of FOREMAN_SANDBOX | | SCN-FORE-085 | implemented | A factory that names no sandbox adapter runs its sandboxes with docker when FOREMAN_SANDBOX is not set | | SCN-FORE-086 | implemented | Until the factory's gateway starts, the server serves and its health names the failure | | SCN-FORE-087 | implemented | A stage that runs a turn when the foreman stops runs its turn again when the foreman starts again | | SCN-FORE-088 | implemented | A retry of a failed run starts a new run at the stage that failed | | SCN-FORE-089 | implemented | The new run works in the folder and on the pull request of the run before | | SCN-FORE-090 | implemented | A retry of a cancelled run starts the cancelled stage again | | SCN-FORE-091 | implemented | A retry of a run whose gate was rejected asks gatekeeper again | | SCN-FORE-092 | implemented | A retry from an earlier stage runs that stage again | | SCN-FORE-093 | implemented | A retry from a stage after the stage that failed is refused | | SCN-FORE-094 | implemented | A retry without the person who asks is refused | | SCN-FORE-095 | implemented | A retry of a run that is still going is refused | | SCN-FORE-096 | implemented | A retry of a run that succeeded is refused | | SCN-FORE-097 | implemented | A retry of a run the server does not know is 404 | | SCN-FORE-098 | implemented | Two retries of the same run at the same time start one run | | SCN-FORE-099 | implemented | A retry of a run that already ran again is refused and names the later run | | SCN-FORE-100 | implemented | The runs list shows the run before and the new run | | SCN-FORE-101 | implemented | The same work item again answers the newest run | | SCN-FORE-102 | implemented | The new run publishes its events with its own IDs and the run before | | SCN-FORE-103 | implemented | A gate decision reaches the newest run of the work item | | SCN-FORE-104 | implemented | A retry request from intake runs the work item again | | SCN-FORE-105 | implemented | A second retry request for the same run starts no third run | | SCN-FORE-106 | implemented | The new run gets its own key with the factory's budget | | SCN-FORE-107 | implemented | The new run has a trace of its own that names the run before | | SCN-FORE-108 | implemented | The first stage of the new run is told why the run before ended | | SCN-FORE-109 | implemented | A retry after the factory's stages changed starts at the first stage | | SCN-FORE-110 | implemented | A retry from a later stage after the factory's stages changed is refused | | SCN-FORE-111 | implemented | A retry from a stage the run does not have is refused | | SCN-FORE-112 | implemented | A check that stays pending past the foreman's limit fails the stage | | SCN-FORE-113 | implemented | A check that the pull request's head commit does not have is named as missing when the limit ends the wait | | SCN-FORE-114 | implemented | A stage that waits for a check shows since when it waits | | SCN-FORE-115 | implemented | A gate without a decision after the foreman's limit ends as expired | | SCN-FORE-116 | implemented | A decision after the foreman's limit ended the gate changes nothing | | SCN-FORE-117 | implemented | A stage that only waits succeeds when its checks succeeded, without a session | | SCN-FORE-118 | implemented | A factory with a stage that only waits and names an agent stops the server at the start | | SCN-FORE-119 | implemented | A factory with a stage that only waits for nothing stops the server at the start | | SCN-FORE-120 | implemented | A stage that only waits at a gate succeeds after the approval, without a session | | SCN-FORE-121 | implemented | A run keeps its key and its spent budget when the foreman restarts during a stage | | SCN-FORE-122 | implemented | The foreman revokes the run's key when the run completed | | SCN-FORE-123 | implemented | The foreman revokes the run's key when the run was cancelled | | SCN-FORE-124 | implemented | A revoke that fails is a warning and the run completes all the same | | SCN-FORE-125 | implemented | The foreman stops the gateway when it stops serving after every run's key was revoked | | SCN-FORE-126 | implemented | The run's pull request is the forge's pull request of the run's branch, not the last link the agent wrote | | SCN-FORE-127 | implemented | The pull request the agent states is the run's when the forge has none of the run's branch | | SCN-FORE-128 | implemented | A stated pull request of another repository than the work item's is refused | | SCN-FORE-129 | implemented | A stage says so when the forge has no pull request of the run's branch and the agent states none | | SCN-FORE-130 | implemented | A stage whose forge adapter cannot list the pull requests succeeds and names the adapter's error | | SCN-FORE-131 | implemented | A factory with changes that are not committed stops the server at the start | | SCN-FORE-132 | implemented | A run does not start while the factory has changes that are not committed | | SCN-FORE-133 | implemented | The run's view names each adapter's version and the fallback's command line | | SCN-FORE-134 | implemented | An adapter's command line is a JSON list of arguments, and a path may hold a space | | SCN-FORE-135 | implemented | A prompt that Git does not track counts as a change, and the error lists the paths sorted | | SCN-FORE-136 | implemented | Every adapter without a command line runs its package at the factory's range | | SCN-FORE-137 | implemented | A forge adapter without a command line runs its package at the factory's range | | SCN-FORE-138 | implemented | An inference config that Git does not track counts as a change, in a factory below the repository's root | | SCN-FORE-139 | implemented | A sandbox adapter that the factory does not name runs its package without a range | | SCN-FORE-140 | implemented | The next stage's prompt holds the result of the stage before it | | SCN-FORE-141 | implemented | A result of more than 4000 characters reaches the next stage cut to its last 4000 | | SCN-FORE-142 | implemented | A stage's agent gets its MCP servers in its session and its skills in its folder | | SCN-FORE-143 | implemented | A sandboxed stage's egress allows the host of its agent's remote MCP server | | SCN-FORE-144 | implemented | A person reads the runs page by page | | SCN-FORE-145 | implemented | A page size out of range is refused | | SCN-FORE-146 | implemented | A cursor that is no page of the run list is refused | | SCN-FORE-147 | implemented | A work item that failed 5 times waits as a dead letter, fails "/v1/health" while it waits, and "foreman resend" starts its run | | SCN-FORE-148 | implemented | The foreman adds the stream "OSF" with the max age of "OSF_STREAM_MAX_AGE" | | SCN-FORE-149 | implemented | The run records each signal with the person who sent it | | SCN-FORE-155 | implemented | Each stage's span carries its session's tokens and cost, and the run's view names the span | | SCN-FORE-156 | implemented | A factory in a Git remote runs at the commit of its default branch, and the server's folder needs no clean checkout | | SCN-FORE-157 | implemented | A ref that the Git remote does not have stops the server at the start | | SCN-FORE-158 | implemented | The fallback runs the exact version factory.lock pins for the adapter's range | | SCN-FORE-159 | implemented | A stage's agent finds the repositories it declares cloned in its folder | | SCN-FORE-160 | implemented | A stage that waits for its checks publishes "run.blocked@v1" once, and the run that fails at a check publishes its reason | | SCN-FORE-161 | implemented | A stage whose checks succeeded at the first read publishes no "run.blocked@v1" | | SCN-FORE-162 | implemented | The foreman shows a doctor's ping that its consumer received | | SCN-FORE-163 | implemented | A sandboxed stage's span carries the tokens and cost of the session in its sandbox | | SCN-FORE-164 | implemented | A stage whose agent declares a secret from OpenBao without a value fails before its first turn and names the secret | | SCN-FORE-165 | implemented | A stage whose agent's secret from OpenBao has a value runs in its sandbox | | SCN-FORE-166 | implemented | A stage whose secrets store does not answer fails before its first turn with the reason | | SCN-FORE-167 | implemented | The foreman fetches a factory in a Git remote with the token of FOREMAN_FACTORY_TOKEN as Git's Basic credentials | | SCN-FORE-168 | implemented | A stage's agent finds a skill of another repository at the commit factory.lock pins, not at the newer commit of its branch | | SCN-FORE-169 | implemented | A skill of another repository that factory.lock does not pin for its entry stops the server at the start | | SCN-FORE-170 | implemented | A pinned skill whose files do not have the hash of its pin stops the server at the start |
