Tasks and Campaigns
For a worked example, read Make a tool safe to adopt. For preserved work, read Finish a change that stopped.
Foundry has two engineering dispatch primitives:
- A task executes one concrete objective immediately.
- A campaign holds a broader mission and derives one task at a time from current repository state until evidence proves the mission complete.
Campaigns do not contain a pre-cut queue. The next objective is created only when the preceding task has a typed result, so stale downstream inventory and per-item retry loops are unnecessary.
For a shared outcome across campaigns, use an initiative.
The initiative supplies the brief, shared findings, owner decisions and stage
providers. Each member campaign still derives its own next objective.
Once initiative advance enables steering, a settled member reports its result
before the initiative chooses the next action. Passive initiatives retain the
usual independent campaign loop.
Why campaigns reassess scope
Implementation is a source of new knowledge. It can expose hidden dependencies, invalid assumptions, partial solutions, and work that is already complete. Campaign formation reassesses the full remaining mission after each implementation cycle. It uses actual implementation experience, reviewer findings, preserved work, and current repository evidence to derive the next objective.
The reassessment can change scope, priority, or task decomposition within the mission and owner constraints. It can retain an earlier objective when the evidence still supports it. It does not merely select the next item from an initial plan or repeat the reviewer's gap list.
The final reassessment checks the whole mission. A complete task verdict proves its assigned objective, which can be narrower than campaign completion.
Evaluate formation by whether it uses new evidence to choose appropriate work and preserves useful learning. Assessment token volume and the ratio of formation to implementation sessions do not establish waste. When reducing cost, preserve the full reassessment and the evidence it needs. The charter records this as a design principle.
Choosing a Task or Campaign
Use a task when you can state one objective whose acceptance evidence is available now:
foundry task parite-cli \
"Add a --quiet flag and prove it suppresses progress output"
Use a campaign when any of these are true:
- the mission spans several independently reviewable changes;
- later work depends on what the repository reveals after earlier work lands;
- the work may require an explicit owner decision;
- production or other external evidence is part of completion;
- a bounded cycle budget is needed to limit autonomous work.
A campaign is deliberately not a substitute for a backlog. If the work is already a known sequence of unrelated tasks, dispatch those tasks directly. Campaign formation is valuable when each next objective should be derived from mission minus current evidence.
Lifecycle at a Glance
flowchart TD
A["staged campaign"] --> B["advance"]
B --> C{"formation decision"}
C -->|"done"| D["completed"]
C -->|"advance one objective"| E["isolated task"]
C -->|"human judgement or budget"| F["escalated"]
E --> G{"typed task result"}
G -->|"complete or remainder"| B
G -->|"defect"| B
G -->|"blocked on decision"| F
G -->|"provider unavailable"| H["paused"]
H -->|"resume"| B
F -->|"decide or extend budget"| B
A -->|"cancel"| I["cancelled"]
B -->|"cancel"| I
F -->|"cancel"| I
H -->|"cancel"| I
cancelled is terminal and has no edge back: it records that the mission was
abandoned, not achieved. Use paused for a campaign meant to resume.
Each dispatched task consumes one cycle. Formation, retries caused by a transient decision-provider transport failure, and an automatic provider pause do not consume cycles. A landed final task still receives completion evaluation. An exhausted budget with an unlanded non-complete result escalates before gates or formation run.
Durable Ownership
The daemon owns the durable campaign inventory. Online
foundry campaign add/list/show/advance/pause/resume/decide/complete/cancel all
go through typed gRPC and do not read, create, or mutate FOUNDRY_CAMPAIGNS_PATH.
Successful online reads and mutations render the daemon's typed response
directly, so stale client-side campaign files cannot mask the live daemon-owned
state. Pass --offline only for direct-file recovery while the daemon is
stopped. If the daemon is unreachable, the online command fails and leaves any
absent or pre-existing client-side FOUNDRY_CAMPAIGNS_PATH byte-identical.
The read-only inventory surface starts with two gRPC queries:
ListCampaigns— returns summary/status records sorted by campaign name, with an optional exactprojectfilter. Missing or empty stores return an empty list; malformed or unreadable stores return a gRPC error rather than an implicit empty inventory.GetCampaign— retrieves the complete definition of one campaign by exact name, includingintent_refs,context_paths, alldone_evidenceentries with theGate/Reviewtype distinction preserved, and escalation rules. ReturnsNOT_FOUND(not an implicit empty) when the name is absent from the store.
One-Shot Tasks
foundry task parite-cli "Add a --quiet flag and prove it suppresses progress output"
foundry task parite-cli "Fix the parser regression" --agent codex
The task formation:
- Creates a disposable Git worktree under
~/.foundry/worktrees/. - Runs the coding agent and quality gates inside that worktree.
- Performs a read-only skeptical review against the objective and evidence.
- Emits one typed verdict:
complete,remainder,defect,blocked_on_decision, orrunner_error. - Commits all task work before returning a terminal result.
Two verdicts may fast-forward the registered trunk branch. A complete verdict
with passing required gates lands, as does a remainder — the reviewer's term
for a finite list of missing work on a converging implementation — provided at
least one required gate ran and every required gate passed. Converging work
integrates rather than accumulating a long-lived divergent branch, and the green
required gates are what keep trunk from going red; a remainder with no
required gate to vouch for it does not land. Its reviewer gaps travel forward in
the typed result and become the campaign's next objective.
When trunk moves during a task
A task can run for an hour. On a shared repository, other people and sessions push to trunk in that time. The task branch then cannot fast-forward trunk, but that says nothing about the quality of the work. Foundry reconciles it:
- It fetches trunk and rebases the task branch onto it inside the task worktree.
- If the rebase is clean, it runs the project's required gates again on the rebased tree (fix commands do not run).
- If the gates pass, the rebased work lands.
- If trunk moved again while the gates ran, Foundry does steps 1–3 one more time. It makes no more than two rebase attempts in one task.
If the rebase conflicts, a required gate fails on the rebased tree, or trunk
is still moving after the second attempt, Foundry puts the branch back on the
reviewed commit and preserves it as usual. The reviewer's verdict does not
change. A complete that could not land is still complete, with landed: false, success: false, and a typed land_blocked reason in the
task_run_completed payload:
land_blocked | Meaning |
|---|---|
trunk_moved_conflict | Trunk moved during the run and the rebase conflicted |
trunk_moved_gates_failed | Trunk moved during the run; the rebase was clean, but a required gate failed after it |
stale_base_conflict | The run started from a base already behind trunk and the rebase conflicted |
stale_base_gates_failed | The run started behind trunk; the rebase was clean, but a required gate failed |
trunk_moved_repeatedly | Trunk moved again after the second rebase |
checkout_not_ready | The registered checkout was dirty or on the wrong branch |
git_failed | Another git operation needed to land failed |
Foundry tells "moved during the run" from "started behind" by the start the
executor records when it prepares the task worktree (workspace_base: the
worktree's first commit and the trunk commit it observed). A run that recorded
no start, because it began before Foundry recorded one, keeps the
trunk_moved_* reason, and its summary says that the cause is unknown.
The payload also lists in trunk_arrivals the trunk commits that the task
branch did not contain (commit and subject, oldest first). This list is
present when the work landed after a rebase too. When trunk did not move, the
payload has neither field and the result is the same as before.
When a continuation starts behind trunk
A campaign cycle that follows an unlanded cycle, a foundry queue resume, and
a nightly majors continuation start from preserved work: a branch, a commit,
or a bundle:<path>. Trunk can move between the cycle that preserved the work
and the cycle that continues it. Before the agent session starts, Foundry
brings the continuation onto current trunk:
- It fetches the registered trunk (
origin/<branch>). If origin cannot be reached, it uses the local branch, which is where finalize lands. - If the preserved commit already contains trunk, the worktree starts on the preserved commit, as before.
- If not, it rebases the preserved commits onto trunk in the new task worktree. The worker then starts from trunk plus the preserved work. The preservation ref is not rewritten; only the new task branch moves.
- If that rebase conflicts, Foundry refuses the continuation before any agent session and removes the worktree it made.
An agent cannot repair a conflicting continuation. It may not rewrite history,
so finalize would replay the same preserved commits onto trunk and hit the
same conflict after the full session. The refused run ends with
runner_error, landed: false, no preservation_ref, and land_blocked: continuation_conflict. Its summary names the continuation ref, the preserved
commit, how many commits it was behind and each conflicting file. Its work
item settles failed with that summary as the reason. The preserved work
stays at its ref, and the item that preserved it stays open.
A campaign does not escalate a refused continuation as a runner fault.
Formation runs as usual and sees the refusal in the last run result. The next
cycle starts from trunk, because the refused run carries no preservation ref,
so formation cannot ask for the same refused base again. To keep the
preserved work, formation cuts an objective that reapplies it onto trunk (the
preserved commit is readable with git show). Otherwise it cuts the next
objective from trunk, or escalates. The refused cycle counts against the cycle
budget like any other dispatched cycle, but it spends no agent session.
When the refused cycle was the last one the budget allowed, the campaign
escalates. The escalation reason says that the cycle was refused before any
agent session ran, and it includes the refusal summary (the ref, the preserved
commit, how far behind trunk it was and the conflicting files). It also says
what to do next: add a cycle with foundry campaign resume <name> --add-cycles 1 and advance, and the next cycle starts from trunk with the refusal in front
of formation. If you already reconciled that work onto trunk by hand, close its
work item with foundry queue close. Foundry does not give the refused cycle
back: a refund would reuse its cycle number, and the ledger only carries an
open cycle forward when a later cycle has a higher number.
A complete with a land_blocked reason is preserved work to reconcile, not
a defect. Its work item settles preserved and its reason names the block.
The next campaign cycle starts from the preserved branch (brought onto
current trunk first, see below), and the campaign history shows the cycle as
complete, did not land (<reason>). The nightly
majors lane does not dispatch the same upgrade again while that branch
exists.
defect, blocked_on_decision, and runner_error never land. Every result
that does not land is pushed to a named preservation branch; if no remote push
is possible, Foundry writes a Git bundle under ~/.foundry/preserved/. Either
way the next cycle resumes from the preserved work; a bundle also carries
HEAD, so you can fetch or clone it by hand to recover the work yourself. Tasks
do not retry. A campaign decides whether the preserved result should seed
another objective.
When preserved work was settled outside the campaign
The next cycle continues from the last cycle's preserved ref only while that
work is still owed. Before formation, Foundry reads the work-item ledger and
finds the campaign's cycle item whose disposition records that ref (the latest
one submitted, if there are several). If that item is settled cancelled or
landed, the ref no longer carries forward:
cancelled: an owner ranfoundry queue closeon it, for example after cherry-picking the work onto trunk by hand.landed: supersession detection found the work on trunk and settled the item with the reasonsuperseded by <commit>.
The next cycle then starts from the registered trunk. Formation's ACCUMULATED UNMERGED WORK section names the ref, the work item, its state and its reason,
and tells formation not to treat that work as pending. An item that is still
preserved, needs_decision or failed keeps carrying its work forward, as
does a ref that no item records. If the ledger cannot be read, the cycle
continues from the ref, as before, and the daemon logs a warning.
Without this check, a manual advance after an owner reconciled the work by hand replays the old ref. The continuation guard then refuses the cycle because the old commits conflict with trunk, and the owner's cycle is spent with no agent session. To use this, close the item before you advance.
Campaign Definitions
Create a JSON definition file:
{
"name": "parite-phase-2d",
"project": "parite-cli",
"mission": "Prove both retrieval entrypoints preserve raw response identity.",
"intent_refs": ["parite.intent.raw-retrieval-evidence"],
"context_paths": [".alloy/projections/AGENTS.generated.md"],
"done_evidence": [
{
"kind": "gate",
"command": "cargo test -p parite-core retrieval_parity",
"required": true,
"artifacts": ["crates/parite-core/tests/retrieval_parity.rs"]
},
{
"kind": "review",
"statement": "The parity suite compares unmasked response IDs across both real entrypoints."
}
],
"budget": { "max_cycles": 12 },
"escalation": ["A behavior choice requires an owner decision."],
"authorized_by": "Stacey",
"agent_provider": "codex"
}
Definition fields
| Field | Required | Meaning |
|---|---|---|
name | Yes | Stable campaign identifier, unique in the campaign store |
project | Yes | Exact registered project name |
mission | Yes | Durable outcome the formation evaluates |
intent_refs | No | Opaque identifiers connecting the mission to source intent |
context_paths | No | Existing repository-relative neutral artifacts |
done_evidence | Yes | At least one mechanical gate or review statement |
budget.max_cycles | No | Maximum dispatched tasks; defaults to 20 |
escalation | No | Conditions that require the formation to stop for an owner |
authorized_by | Operationally | Owner identity required for decisions, completion, and resume |
agent_provider | No | Campaign-specific provider override |
context_paths must be existing repository-relative files under the registered
checkout. Absolute paths, parent traversal, missing files, and symlink escapes
are rejected before the definition is saved. Foundry reads these neutral
artifacts but never invokes the tool that produced them.
Formation uses the balanced model tier with medium reasoning effort. The independent task reviewer continues to use the deep tier with high effort. Context files are listed by path and size for selective reading. Their normative requirements still apply. Formation receives the mission, owner decisions, reviewer gaps, recent commits, changed paths, and compact gate results. It returns a decision; the daemon dispatches that decision. It must not probe localhost or start nested Foundry workflows.
Passing gate output is omitted. Failed output keeps at most 512 bytes per gate
and 2 KiB across all gates. The shortening keeps the end of every section of
the output (stdout, stderr, and each self-heal run), so noisy stderr cannot
hide the stdout line that names the failure. Full results remain in
CampaignAdvanceCompleted, and a failed gate's logs field names the files
that hold its complete output (see
Gate output and full logs).
An oversized packet fails before an agent starts. Binding declarations are
never silently truncated to fit the packet.
Stage limits and repository scope
Campaign definitions accept these limits. Older definitions use these defaults:
{
"writable_repositories": ["my-project"],
"budget": {
"max_cycles": 4,
"stages": {
"formation_prompt_bytes": 32768
}
}
}
The prompt byte limit must be positive. Campaign formation, execution, and review have no clock deadline, including the project's agent timeout. Legacy stage time fields are ignored when reading existing definitions. The byte limit applies to the rendered Foundry prompt, not provider instructions or files read during a turn.
An empty writable_repositories means the campaign's project alone. Admission
rejects any other declaration. Multi-repository isolation and landing are not
supported. Sibling repositories remain read-only.
Campaign execution currently needs Codex to enforce this filesystem boundary.
An omitted campaign provider selects Codex. Other campaign providers fail before
formation starts. Codex uses workspace-write, clears inherited extra writable
roots, and permits Foundry's command-log directory. Foundry owns Git finalization.
Standalone tasks retain their existing provider and access behavior.
Capture command output
Use the following command for verbose builds and tests:
foundry capture -- cargo test --workspace
This command needs no daemon. It returns the child's exit code and stores both
complete streams in ~/.foundry/tool-logs/. Passing commands print only status
and log paths. Failures print at most 2 KiB from each stream. Use --log-dir
to choose another directory. Logs remain until you remove them. Gate runs keep
their own logs for failed gates under ~/.foundry/evidence/gate-logs/, which
Foundry prunes automatically.
Coding prompts request this command for verbose output. Codex also applies a
2,000-token limit to tool output retained in conversation history. The native
limit does not preserve a separate complete log; use capture for that.
Prove acceptance early
Before broad fixture or documentation changes, the executor must exercise the
hardest acceptance behavior through the real boundary. It must record the
rejecting case and the corrected passing case in .foundry/proof.json:
{
"kind": "behavioral",
"source_change": "Describe the changed source/input and its paths",
"rejecting": {"command": "test command", "exit_code": 1, "log": "full-log-path"},
"corrected": {"command": "test command", "exit_code": 0, "log": "full-log-path"}
}
Write source_change as a single non-empty string. For compatibility, a
non-empty array of non-empty strings is interpreted as their joined text.
rejecting and corrected are top-level objects. Validate the file's JSON,
field types, actual exit codes, and log paths before finishing.
For a non-behavioral objective, use this direct shape:
{
"kind": "direct",
"reason": "Explain why this objective is non-behavioral",
"corrected": {"command": "acceptance command", "exit_code": 0, "log": ".foundry/logs/corrected.log"}
}
Review reads the worktree proof before finalization. Before committing,
Foundry copies the proof and named logs into
~/.foundry/evidence/<trace-id>/<unique-attempt>/ (event id when no trace is
present). The task result's proof_evidence field records that directory for
later audits. original-proof.json retains the exact submitted bytes;
proof.json uses relative paths to copied rejecting.log and corrected.log,
so the archive is readable after worktree removal. Malformed proofs and absent
logs remain failed evidence; archival never invents results. An archival I/O
failure stops finalization before committing or cleanup.
The proof and its logs are not project content. The rules for them are the
rules for every file under .foundry/ in a task worktree, described in
What lands from a task: nothing there is
committed, and finalization copies all of it to
~/.foundry/evidence/<trace-id>/<unique-attempt>/worktree/ before cleanup. The
task result's uncommitted_artifacts field names that directory. It holds the
raw proof and logs at their worktree paths, beside the portable proof archive
in the parent directory. Evidence a campaign must keep in the repository goes
in a normal project path, such as docs/ or the test tree.
New campaign tasks fail with a typed defect if the record is missing, malformed, or refers to absent logs. This check spends no reviewer session. The independent reviewer checks the source, logs, ordering, and whether the probe proves the intended behavior. Schema checks alone cannot establish semantic correctness.
Report campaign efficiency
foundry campaign report my-campaign
foundry campaign report my-campaign --json
The online command reads daemon-owned events through GetCampaignReport.
--offline reads local stores and logs explicitly. The report separates
formation, execution, and review time and tokens. It also shows dispatched and
landed cycles, running sessions, missing usage, unpriced models, stage limits,
and external completion reasons. Digest sessions do not enter these totals.
The report also lists formation sessions and decision events separately. JSON includes session IDs, trace IDs, timestamps, transcript paths, terminal usage, exact Foundry prompt bytes when recorded, and command activity. Captured output bytes are not tokens and do not prove how much output the model saw. Exact command repeats exclude duplicate transcript events.
For Codex, native observations include the first request's input, the latest
cumulative usage, and a native transcript path. These observations never add to
terminal accounting. An interrupted session can have a partial observation while
its final usage remains unmeasured. Missing or unsupported transcripts produce
explicit audit errors, not zero counts. Native transcript lookup uses the daemon's
CODEX_HOME, or ~/.codex, and reads only the matching thread's file.
See the context-mixer2 formation audit for a worked example and the limits of each measurement.
Older session records lack an explicit stage. Their role is inferred from access and model tier, and the report gives the count of inferred sessions. Missing usage means unmeasured spend. A partial list-price estimate is not total cost. The report shows known list USD and billing dimensions missing from the transcript. See Inference costs for rates and limits.
Formation runs before the first task and after each result that needs a new objective or completion evaluation. Thus one campaign with N tasks normally has N+1 formation sessions. Separate campaigns each have an initial call. Forced terminal decisions bypass the agent. Transport retries can add sessions within the formation time budget.
Designing done evidence
Use a gate for a deterministic command that can run against the delivered
repository checkout:
{
"kind": "gate",
"command": "cargo test -p parite-core retrieval_parity",
"required": true,
"artifacts": ["crates/parite-core/tests/retrieval_parity.rs"]
}
Every declared artifact must exist before the command is eligible to pass. This prevents a test runner that silently ignores a missing path from producing false-green evidence.
Use a review for a semantic or externally verified claim:
{
"kind": "review",
"statement": "Production preserves raw identity across both entrypoints."
}
Required gates are re-run by formation against delivered trunk and block done.
A required command that asserts another host's state through ssh, rsync,
systemctl, launchctl, or similar tooling is rejected when the campaign is
added: code in a disposable task worktree cannot make such a gate pass reliably.
Express deployment evidence as a review statement that the owner verifies, or
mark a remote probe as optional.
Campaign gates are not task acceptance criteria. A dispatched task runs the project gates resolved from its own checkout. Formation must state acceptance evidence the task can actually produce inside that worktree; it does not copy campaign gate commands into the objective.
Formation's agent runs in a read-only sandbox. Builds, tests, linters and
anything else that writes target/, caches or lock files cannot run there, and
the prompt tells formation not to try. It takes test and gate evidence from two
places: the done-gate results the daemon ran against delivered trunk before
formation, and the last run result. A run with landed: true reached trunk only
after every required gate in the project's gate file passed in the task
worktree, and Foundry re-runs those gates after any rebase onto trunk. A review
statement such as "with passing unit tests" is judged from that evidence and
from the code. Being unable to run a command in the sandbox is not a reason to
escalate.
Managing a Campaign
foundry campaign add ./parite-phase-2d.json
foundry campaign list
foundry campaign show parite-phase-2d
foundry campaign advance parite-phase-2d
foundry campaign pause parite-phase-2d
foundry campaign decide parite-phase-2d --decision "Use the generated tonic client path."
# On an active or paused campaign, decide records the policy and changes nothing else:
foundry campaign decide parite-phase-2d --decision "Do not remove the v1 endpoint."
foundry campaign resume parite-phase-2d
# When the cycle budget was exhausted, explicitly authorize more work:
foundry campaign resume parite-phase-2d --add-cycles 1
# Or record a decision and authorize more work in one step:
foundry campaign decide parite-phase-2d --decision "Keep the current design." --add-cycles 1
foundry campaign show <name> prints the campaign, then its cycles as the
work-item ledger records them under Cycles in the ledger:,
one line per cycle with the cycle number, the item id, its state, its
settlement or start time and its reason. The cycles come from the ledger
through the ListWorkItems source filter (campaign:<name>), the same list
foundry queue --source campaign:<name> prints, in the daemon's reading order
(running first, then open, then settled newest first). Nothing is inferred
from traces. A campaign whose cycles predate the typed source, or that never
advanced, prints (none recorded).
--json prints the same data as one object: the campaign's fields,
open_escalation, owner_options and cycles (the ledger records
foundry queue --json would print).
What an escalated campaign shows its owner
An escalated campaign stops and waits for you. foundry queue open lists it,
with its reason, beside the open ledger items (see
The work queue), and
campaign show says why and what you can do:
Escalation:
Reason: campaign cycle budget exhausted (6)
Escalated: 2026-10-11T03:12:40+00:00 at 6 cycles (6 landed)
Owner options:
foundry campaign decide mojentic-x --decision "<text>" record a binding owner decision and return to active; advance continues it
foundry campaign resume mojentic-x --add-cycles <n> authorize n more cycles and return to active; advance continues it
foundry campaign complete mojentic-x --reason "<text>" close it: owner-verified evidence shows the mission shipped
foundry campaign cancel mojentic-x --reason "<text>" stop it permanently
When a task stopped with blocked_on_decision, the block also prints the
agent's Question: and each Choice: as the agent wrote them. The options
follow the status table below. resume, decide and complete need an
authorized_by owner, so an unauthorized campaign is offered only cancel.
When the cycle budget is spent, decide and resume are offered with
--add-cycles, the only form the engine accepts. An active or paused campaign
is offered decide without --add-cycles, the only form it accepts, to record
a decision for the next formation. Every other status prints its own options too,
and a completed or cancelled campaign prints none. foundry campaign report
prints the same Escalation: block and options, and its --json carries
open_escalation and owner_options.
The reason lives on the campaign record as open_escalation. Formation writes
it in the same store save that moves the campaign to escalated, from the same
reason the campaign_escalated event carries. It is present only while the
campaign is escalated: an escalated decide, resume, complete, cancel
and pause each clear it, because a reason for a question already answered is stale. The
event log keeps every escalation. While the campaign stays escalated the first
reason stands; a later result that reaches it only restates the status
(campaign is escalated and requires owner resumption) and does not replace
the question you have to answer.
A campaign that escalated before Foundry kept this record has no
open_escalation in its store. Reads recover the reason from the newest
campaign_escalated event naming the campaign, across every monthly event
file, and print Recorded: recovered from the event log. Nothing is written
back: the store changes only when the campaign does. An escalated campaign with
no record and no event prints Reason: (not recorded) beside its options.
An earlier cycle the campaign has moved past does not stay in the open list.
When the next cycle starts, each earlier cycle still preserved,
needs_decision or failed settles cancelled with the reason carried forward by campaign <name> cycle <n> (<item id>). When the campaign completes
or is cancelled, its remaining open cycles settle cancelled with the reason
campaign <name> completed or campaign <name> cancelled. The preserved ref
stays recorded on each item, and no operator_action is written. An escalated
cycle stays open until the campaign advances again or ends: it is the live
question. A formation run that ended failed settles the same way, when the
next cycle starts or the campaign ends. See The work queue.
New definitions start as staged. An authorized staged campaign becomes
active on its first advance. Each advance re-runs mechanical done-evidence,
reviews the repository and context artifacts, then makes exactly one decision:
done— all required gate and review evidence is satisfied.advance— dispatch exactly one next objective from mission minus current state.escalate— stop because the budget, an escalation rule, runner failure, or owner judgment requires attention.
| Status | Meaning | Valid next control |
|---|---|---|
staged | Definition exists; no cycle has started | advance, pause, cancel |
active | Formation or a task may advance the mission | advance, pause, decide, cancel |
paused | Advancement is intentionally stopped | resume, decide, complete, cancel |
escalated | Budget, policy, or human judgement stopped the mission | decide, resume, complete, cancel |
completed | Evidence or owner authorization closed the mission | None |
cancelled | An owner abandoned the mission before its evidence | None |
The status transition table
Every rule governing which control is legal from which status lives in exactly
one place: foundry_sdk::campaign::transition. The daemon's gRPC handlers, the
CLI's --offline recovery path, and the AdvanceCampaign formation block all
delegate to the same Campaign methods (pause, resume,
record_owner_decision, complete, cancel, check_advanceable) rather than
re-deriving the rules independently — --offline differs from the online path
only in transport and event emission, never in legality. The table below is
generated from that module's exhaustive state-machine test and is the
authoritative specification:
| Status | pause | resume | decide | complete | cancel | advance |
|---|---|---|---|---|---|---|
staged | allowed † | rejected: wrong status | rejected: wrong status | allowed | allowed | allowed |
active | allowed † | rejected: wrong status | allowed ‡ | allowed | allowed | allowed |
paused | allowed † | allowed | allowed ‡ | allowed | allowed | rejected: wrong status |
escalated | allowed † | allowed | allowed | allowed | allowed | rejected: wrong status |
completed | allowed † | rejected: wrong status | rejected: wrong status | no-op (already settled) | rejected: already complete | rejected: wrong status |
cancelled | allowed † | rejected: wrong status | rejected: wrong status | rejected: cancelled campaign | no-op (already settled) | rejected: wrong status |
resume, decide and complete additionally require authorized_by to be set,
regardless of status; cancel deliberately does not, so an unauthorized
campaign is never stranded with no reachable terminal state.
† pause is unconditional today, including on a completed or cancelled
campaign — see the TODO(campaign-status) on Campaign::pause in
foundry-sdk. That this can resurrect a terminal status into paused is a
known open question, not a decision this table is asserting is correct.
‡ On an active or paused campaign, decide records the decision and keeps
the status. It refuses --add-cycles there, and refuses a decision whose text
and owner repeat the campaign's latest decision.
A done decision made while a required done-evidence gate is red is rewritten
into an advance. The synthesized objective carries the campaign mission and
each failing gate's own output, not just the command that failed, and forbids
reverting or shrinking landed mission work — or broadening a lint allowance — to
turn the gate green.
Task results auto-request the next advance. A result that did not land carries
its preserved branch into the next task, so the campaign resumes warm. A
blocked_on_decision escalates immediately, and so does a runner_error that
describes a fault in the run. cycles_completed counts dispatched tasks, while
cycles_landed counts only task results whose work actually landed on trunk.
A provider failure is not treated as a campaign failure. If the decision agent cannot be reached, Foundry re-asks it up to three times with a widening backoff before giving up, and the resulting escalation names every attempt — a single transport blip no longer ends a healthy campaign. A malformed decision is not retried: the agent answered, so re-asking would only repeat it.
When the provider itself is unusable — an exhausted account, revoked
authentication, or an open circuit breaker — the campaign moves to paused
rather than escalated, whether that surfaces during formation or as the
executor's runner_error verdict. Nothing about the campaign's own work is
wrong, so no cycle is consumed and the pending run result is preserved. Once the
provider is usable again, foundry campaign resume continues from exactly where
it stopped.
The stop emits a campaign_paused event carrying the reason. It is deliberately
not a terminal event — it neither ends the campaign nor requires resurrection —
but it is emitted because an automatic pause is the one kind nobody is watching:
an operator who runs foundry campaign pause already knows, whereas a campaign
that quietly stops itself is otherwise indistinguishable from one still working.
The reason also appears in the advance block's summary in the run trace.
Formation reasons about two trees, because they can differ. The live repository
snapshot is the delivered trunk state and is what a done decision is judged
against. Separately, when the previous cycle did not land, its preserved branch
becomes the next execution's base ref (unless its work item was settled
outside the campaign, see "When preserved work was settled outside the
campaign" above) — so formation is also shown an
ACCUMULATED UNMERGED WORK section listing the commits and changed files
reachable from that ref but absent from trunk. An advance objective is cut
from mission minus trunk-plus-accumulated. Without this the agent inspects only
trunk and re-cuts objectives the preserved branch already satisfied. When the
final budgeted task lands, Foundry still evaluates the repository for
completion. Only a decision to dispatch another task is converted to a budget
escalation.
Formation is also shown an OBJECTIVE HISTORY section: the objectives this
campaign has already cut, oldest first, each with the typed verdict its
execution returned and whether that work landed. The campaign retains the eight
most recent — the full text of every objective is already durable in the
campaign_advance_completed event stream, so the stored history is formation's
working memory rather than an archive, and stays bounded on a long mission.
The prompt forbids restating an entry from that history. When the objective
being cut substantially repeats an earlier one, exactly two readings are
available. Either the earlier work exists on the preserved branch and the
inspection missed it, in which case the accumulated section applies and the
objective becomes reconcile-and-land. Or the earlier cycle returned remainder
and its gaps are genuinely open, in which case re-dispatching the same request
has already failed once: the agent must name the blocking sub-gap, change the
approach, or escalate for an owner decision.
Observing and Reconstructing a Cycle
foundry campaign advance prints the root event ID, streams the block progress
of its own advance (not other workflows on the project), and renders the
completed trace. An advance never duplicates work: when the campaign already has
an advance or a cycle submitted, queued, held or running, the command dispatches
nothing, prints the existing item, and exits 0:
Campaign 'parite-phase-2d' already has a cycle in flight: work item wi_3f… (running). Nothing dispatched.
next: foundry queue show wi_3f…
Two advances for one campaign issued at the same moment dispatch exactly one workflow. The same run remains available through:
foundry history --project parite-cli
foundry trace <campaign-advance-event-id> --verbose
A campaign run mints a trace ID, and every advance mints a fresh cycle span
within it. Task-side events carry both campaign and campaign_cycle, so
concurrent campaigns in the same project cannot make cycle boundaries ambiguous.
CampaignAdvanceCompleted records the formation inputs that matter for audit:
- the exact prompt shown to the decision agent;
- the selected agent provider;
- the formation decision and reason;
- the objective, when one was dispatched;
- the gate results produced by done-evidence commands; and
success:truewhen the advance decided or stood down,falsewhen a fault (an unreadable store, a failed save, a missing campaign) stopped it.
Forced decisions that do not consult an agent record no prompt or provider. This is intentional evidence that formation was bypassed, not missing telemetry.
An advance that finds its campaign stopped records decision: "stood_down"
instead of a formation decision, with campaign_status (the status that
stopped it) and a reason. It has no objective, dispatches nothing, changes no
status and counts no cycle. It carries the prompt and gate results only when a
formation ran and was then discarded, which accounts for that agent's spend.
The advance's work item settles from this event. A decision or a stand-down
settles landed; success: false, or no campaign_advance_completed at all,
settles failed and lists the item in foundry queue open.
cycles_completed counts dispatched tasks. cycles_landed counts task results
whose changes reached trunk. The bounded objective_history stored with the
campaign is working memory for formation; the append-only event stream is the
complete historical record.
Pausing and Resuming
Pausing prevents automatic or manual advancement. If an already-running task
finishes after the pause, Foundry records its result without changing the paused
state, and the advance that carried the result stands down and settles
landed. The typed result and preservation ref remain pending in the campaign
store; the next manual advance after resume consumes them, so formation sees the
exact reviewer gaps and the task continues from preserved work. If you closed
the preserved work's item while the campaign waited, the task starts from trunk
instead.
Resume is valid for both paused and escalated campaigns. When an escalation
is budget-only (the engine stopped because the cycle limit was reached but no
human judgment question was recorded), resume is the right command — it
returns the campaign to active without requiring an owner-decision record:
foundry campaign resume parite-phase-2d
resume requires authorized_by to be set and will not silently reactivate an
exhausted campaign. When cycles_completed >= max_cycles, pass --add-cycles N
to explicitly authorize more work; the engine rejects resume without an
extension on an exhausted budget:
foundry campaign resume parite-phase-2d --add-cycles 1
Recording an Owner Decision
Completion and escalation are terminal events and are forced into the next ops
digest as an anomaly. Campaign-store mutations are serialized across the CLI and
daemon by a file lock on the campaign store. Every holder keeps it only for a
short read-and-write section. Online add, advance, pause, resume,
decide, complete, and cancel acquire it on Tokio's blocking pool, so
runtime workers stay free to process agent output and answer other RPCs. Once
admitted to the blocking pool, a control operation finishes even if its client
disconnects; inspect campaign state before retrying an interrupted command.
A formation does not hold the lock while its done gates and agent run. It reads the campaign and saves the arriving run result under the lock, releases the lock, forms, then takes the lock again and applies the outcome only if the campaign record is unchanged since it read it. Controls and advances for any campaign therefore return promptly while formations run, and different campaigns form at the same time. One campaign never forms twice at once: a second formation for the same campaign waits for the first and then reads the record the first one left.
A control never loses to a formation that was running when it was issued. When the campaign record changed while it formed, the formed outcome is discarded and the change stands:
- The campaign was paused, cancelled, completed or escalated meanwhile. The
advance stands down: it emits a
stood_downcompletion, dispatches nothing and settleslanded, because the stop is what the operator asked for. A paused campaign keeps its pending run result for the advance afterresume. - The campaign can still advance but its record changed (a budget edit, a
mission edit, an owner decision recorded on the active campaign, an
--offlineedit). The advance forms again from the current record, still holding the campaign's formation lock, and re-runs every check against it, so a budget cut is enforced. The arriving run result is recorded once, whatever the number of formations. An advance whose campaign changes during three formations in a row fails: it applies nothing, its work item settlesfailedwith a summary that says so and shows infoundry queue open, and every change stands. Runfoundry campaign advance <name>once the edits stop.
Controls validate the latest stored state after acquiring the lock; a
transition that became invalid while waiting returns FAILED_PRECONDITION.
cancel --now aborts its target workflow before it takes the lock. Offline
commands retain their synchronous file-lock behaviour.
If a daemon-side save fails during pause, resume, decide, or complete,
the RPC returns INTERNAL, leaves the persisted daemon-owned store
byte-identical, and complete does not emit CampaignCompleted.
What decide changes in each status
decide records a binding owner policy. It appends an owner decision record
with the decision text, the campaign's authorized_by value, and a timestamp.
Every later formation prompt includes every recorded owner decision as binding
context, so the next objective follows the policy instead of re-deriving the
choice you ruled out.
You do not have to wait for an escalation. When you rule on a question while the
campaign is active or paused, record it at once:
foundry campaign decide parite-phase-2d \
--decision "Do not remove the v1 endpoint; clients still depend on it."
On an active or paused campaign, decide changes nothing else. The status stays
as it was, the budget is unchanged, and a stored run result stays pending. A
paused campaign stays paused until you resume it. A spent budget does not
block the decision, because no status changes. If the campaign is forming when
you decide, the formation sees the changed record and forms again from it (see
above), so the next objective already follows the decision.
--add-cycles is refused on an active or paused campaign, and nothing is
recorded. Extending the budget through decide answers an escalation. For a
paused campaign, use foundry campaign resume <name> --add-cycles N.
When a task escalates with a human judgment question, decide answers it:
foundry campaign decide parite-phase-2d \
--decision "Keep the generated tonic client boundary; do not add raw JSON shims."
On an escalated campaign, decide records the decision, then returns the
campaign to active, so the next advance can continue under explicit policy
instead of re-escalating on the same question.
When an escalated campaign has spent its cycle budget, decide needs
--add-cycles N:
foundry campaign decide parite-phase-2d \
--decision "Keep the current design." --add-cycles 1
The decision, the budget extension and the return to active happen in one
transition. Without --add-cycles the command is refused and records nothing.
A decision alone would leave an active campaign whose next advance escalates on
budget again, and resume does not accept an active campaign.
Running the same command again after it succeeded is refused with "campaign
'decide is refused
on a staged, completed or cancelled campaign.
By default, foundry campaign decide is an online mutation: it requires a
reachable foundryd daemon and sends the decision through the DecideCampaign
RPC. If the daemon is unreachable, the command fails and does not touch the
client-side FOUNDRY_CAMPAIGNS_PATH.
If you need to update the file while the daemon is stopped, opt into the direct store path explicitly:
foundry campaign decide parite-phase-2d \
--decision "Keep the generated tonic client boundary; do not add raw JSON shims." \
--offline
External Completion
When production or other owner-reviewed evidence proves the mission shipped without another formation cycle, close the campaign explicitly:
foundry campaign complete parite-phase-2d \
--reason "Production verification confirms every required outcome shipped."
This is an owner-authorized terminal transition. Foundry retains the reason and
timestamp, clears any stale pending result, and emits the same completion event
used by an internally completed campaign. Use --offline only while the daemon
is stopped; the direct-file path cannot emit the terminal event.
Cancelling a Campaign
When a mission is abandoned rather than achieved — superseded by a different approach, overtaken by events, or simply wrong — cancel it:
foundry campaign cancel parite-phase-2d \
--reason "Superseded by the streaming rewrite."
Cancellation is a distinct cancelled status, not a flavour of completed.
Completion in Foundry is an evidence claim, so recording an abandoned campaign
as complete would put a false assertion into the audit trail and the ops digest.
It is terminal and not resumable; if the campaign should come back later, use
pause instead.
Unlike complete, cancellation does not require authorized_by — an
unauthorized campaign cannot be advanced to completion either, so requiring an
owner would leave it stranded with no reachable terminal state. The --reason
is always mandatory and always reaches the campaign_cancelled event; it is
additionally recorded as an owner decision when the campaign has an owner.
By default the cancellation is graceful: the in-flight cycle runs to
completion, its work is committed and preserved exactly as usual, and no
successor cycle is dispatched. The advance that carries the cycle's result
records it, stands down with decision: "stood_down" and settles landed; the
campaign stays cancelled. To stop immediately instead:
# Kill the running agent, keep its work (committed and pushed or bundled)
foundry campaign cancel parite-phase-2d --reason "Wrong approach." --now
# Kill the running agent and throw its uncommitted work away
foundry campaign cancel parite-phase-2d --reason "Wrong approach." --now --discard-work
A whole campaign runs inside a single daemon task, so --now aborts that task:
the running agent process is killed, and the cycle's worktree is left orphaned
because normal finalization never ran. Foundry then disposes of that worktree
according to --discard-work — preserving the work to a branch or bundle by
default, or deleting the worktree and its local branch when asked. A remote
branch pushed by an earlier cycle is retained until its owning item lands; it is the audit trail for
work that did reach a durable ref.
--discard-work requires --now, because a graceful cancellation has already
committed and preserved the cycle's work by the time it stops — there would be
nothing uncommitted left to discard.
A --now cancellation also closes the cycle's entry in the work-item ledger. The
cycle was recorded running when it was dispatched, and killing it means the
TaskRunCompleted that normally settles that entry never arrives — so Foundry
settles it cancelled itself, with your --reason text as the reason and a
disposition naming the cycle's worktree, whether that worktree is gone, and the
ref any preserved work is recoverable from. A work_item_cancelled event records
it, on the aborted cycle's own trace. foundry queue show <id> reads the result
back. A graceful cancellation lets its cycle finish; when that cycle settles
open, it settles cancelled with the reason campaign <name> cancelled: <reason>, because the campaign is already cancelled. See The work
queue.
Settlement happens asynchronously after the cancellation RPC returns. The ledger
is saved before the engine writes and broadcasts work_item_cancelled, so a
queue read can show cancelled before a Watch subscriber receives the event.
Consumers waiting for the event should await its arrival rather than treating a
ledger read as proof that broadcast has finished.
Two limits worth knowing. --now kills the agent process itself, but not the
tool subprocesses that agent spawned; those are reparented and run to their own
completion. And the aborted run produces no trace file, so reconstruct it from
the aborted_event_id recorded on the campaign_cancelled event rather than
from foundry trace.
--offline cancellation is graceful-only and emits no terminal event, matching
offline complete. --offline --now is refused rather than quietly downgraded:
with no daemon there is no workflow to abort, and reporting a kill that never
happened would be worse than failing. The legality of the cancellation
itself — whether this status may become cancelled at all — is not a separate
offline rule; it is the same Campaign::cancel the daemon calls, so a
completed campaign is rejected and an already-cancelled one is a no-op on
both paths identically. See "The status transition table" above.
Online and Offline Control
By default, every campaign control command is daemon-authoritative:
addsends the JSON definition throughAddCampaign; the daemon validates the referenced project and context paths against daemon-owned registry state, persists atomically, and returns the durableCampaignDetailthat the CLI renders directly.listrendersListCampaignsdirectly.showrendersGetCampaigndirectly.advancedispatchesAdvanceCampaign, prints the returned root event ID, watches the workflow, and then renders the trace from that daemon-owned event.pause,resume,decide, andcompleterender the typedCampaignDetailreturned by their respective RPCs.
Without --offline, an unreachable daemon is always an error. The CLI does not
warn and fall back to direct file mutation automatically.
If foundryd is not running, pass --offline to opt into direct-file recovery:
foundry campaign add ./parite-phase-2d.json --offline
foundry campaign list --offline
foundry campaign show parite-phase-2d --offline
foundry campaign pause parite-phase-2d --offline
foundry campaign resume parite-phase-2d --offline
foundry campaign resume parite-phase-2d --add-cycles 1 --offline
foundry campaign decide parite-phase-2d --decision "Keep the daemon boundary." --offline
foundry campaign complete parite-phase-2d --reason "Production verification confirms every required outcome shipped." --offline
The offline path reads or mutates the file directly and cannot emit workflow
events. Restart foundryd afterward so its in-memory state is refreshed from
disk before you resume normal online control.
advance has no offline execution path because formation requires the daemon's
engine, registered blocks, live project state, and event persistence.
Recovery and Preservation
Task execution never retries inside a single formation. When work does not land,
FinalizeTask commits it before returning:
- it first pushes a named task branch to the project's remote;
- if the push is unavailable, it writes a Git bundle under
~/.foundry/preserved/; - the next campaign cycle uses that preservation reference as its base;
- bundle recovery discovers the branch ref directly, and new bundles also
include
HEADfor ordinarygit cloneorgit fetchrecovery.
Formation judges done only against delivered trunk. It uses the accumulated
preserved branch to decide what the next task should do. This prevents a
campaign from declaring success for unintegrated work without forgetting work
that has not landed yet.
Dry Run
Campaign advancement honors event throttle. A dry-run
campaign_advance_requested simulates the next objective without mutating the
campaign store or repository. It executes exactly one simulated task through
review and terminal result, then stops without recursively auto-advancing:
foundry emit campaign_advance_requested \
--project parite-cli \
--throttle dry_run \
--payload '{"campaign":"parite-phase-2d"}' \
--wait