Maintenance Workflow
The maintenance workflow runs iterate and maintain automation against each
registered project, committing and pushing any changes they produce. It is
triggered nightly by the in-daemon nightly-maintenance sentinel (see
Sentinels) and can also be invoked manually via
foundry run or foundry emit.
How It Works
Each project goes through its own independent chain. The chain is driven entirely by events — no mutable state is shared between projects.
Per-Project Chain
flowchart TD
A([maintenance_run_started]) --> B[[Validate Project]]
B --> C([project_validation_completed])
C --> D[[Route Project Workflow]]
D -->|iterate=true| E([iteration_requested])
D -->|iterate=false, maintain=true| F([maintenance_requested])
D -->|no actions enabled| G([end])
E --> H[[Resolve Gates]]
H --> I[[Run Preflight Gates]]
I --> J[[Check Charter]]
J --> K[[Assess Project]]
K --> L[[Triage Assessment]]
L --> M[[Create Plan]]
M --> N[[Execute Plan]]
N --> O[[Run Verify Gates]]
O --> P[[Route Gate Result]]
P -->|pass| Q([project_iteration_completed])
P -->|fail, retries left| R[[Retry Execution]]
Q -->|maintain=true| F
F --> S[[Resolve Gates]]
S --> C[[Classify Dependency Updates]]
C -->|dependency_updates_classified, phase before| T[[Execute Maintain]]
T --> U[[Run Verify Gates]]
U --> V[[Route Gate Result]]
V -->|pass| W([project_maintenance_completed])
V -->|fail, retries left| X[[Retry Execution]]
W --> C2[[Classify Dependency Updates]]
C2 --> Y([dependency_updates_classified, phase after])
Classify Dependency Updates decides, in code, which dependency moves
maintenance may make, under the project's update policy. Execute Maintain
gets that list as its brief. The step runs again when maintenance completes, so
the summary can report what was actually applied. See
Dependency Update Policy.
Syncing the Checkout With Its Remote
The nightly chain works directly in each project's registered checkout, not in
a disposable worktree. A checkout that lags origin — common on a second host
whose clones are not in daily use — would otherwise be maintained as stale
code, and its push would be rejected or, worse, land on a remote that has since
moved. So Validate Project syncs the checkout before anything else touches
it, on the registered branch:
- Dirty tree → refuse. If
git status --porcelainis non-empty the project fails withsync_failure: "dirty_tree". Nothing is fetched, merged, or cleaned; the uncommitted work is left for a human. - Fetch.
git fetch origin <branch>. If the fetch fails, ororigin/<branch>cannot be resolved, the project fails withsync_failure: "remote_unavailable". - Fast-forward only.
git merge --ff-only origin/<branch>. The number of commits applied is recorded asfast_forwardedonproject_validation_completed(0when already level, or when the local branch is only ahead). - Diverged → refuse. If both the local branch and the remote have moved,
no fast-forward is possible and the project fails with
sync_failure: "diverged". The checkout is left exactly as found.
A failed sync sets status: "error", so Route Project Workflow stops the
chain (and, inside a maintenance cycle, reports the project run as failed).
Commit and Push commits anything the run left uncommitted, then pushes
every commit the branch has ahead of origin/<branch>. The push does not
depend on whether this step made a commit: agents usually commit their own
work, and those commits must reach the remote too. A run that reports
changes: false still runs the step, so commits stranded by an earlier run are
pushed on the next one. A run that reports success: false never pushes; its
commits stay local (run_failed).
Before pushing, Commit and Push repeats the fetch and fast-forward in case
the remote moved during the run. If it did, the local commits are replayed
with git rebase origin/<branch> and the project's required gates are run
again on the rebased commits. The push happens only when they pass. The
refusals, all recorded as push_failure on project_changes_committed (when
the step committed) and in the step's summary:
push_failure | Meaning |
|---|---|
push_rejected_diverged | The rebase conflicted. It was aborted; the commits stay on the local branch for a human. |
gates_failed_after_rebase | The rebased commits failed a required gate, or the project has no gates to verify them. Nothing was pushed. |
remote_unavailable | The pre-push fetch failed, or origin/<branch> could not be resolved. |
push_failed | git push itself was rejected (for example a non-fast-forward race). |
run_failed | The run reported failure, so its commits were kept local. |
Foundry never force-pushes.
Agents never push
Foundry pushes a run's commits only in Commit and Push, after the verify
gates and the suppression check. The agent must not push first. Three layers
hold that line:
- The prompt. The maintain, retry and remediation prompts say: commit locally if you want to, never push, force-push, tag or change remotes. The iterate and task prompts say Foundry owns Git entirely.
- Prevention. Every maintain, iterate, task and remediation agent session
runs with
GIT_CONFIG_COUNT=1,GIT_CONFIG_KEY_0=remote.origin.pushurlandGIT_CONFIG_VALUE_0=foundry://agent-push-disabledin its environment. Every process the agent starts inherits it, so agit pushtooriginfails. Nothing is written to the repository, andCommit and Pushruns outside that environment, so Foundry's own push works as before. - Detection. Every maintain, iterate, task, vulnerability-remediation
and pipeline-remediation run records
HEADandorigin/<branch>before its first agent session (run_base, carried through retries). After each attempt Foundry fetchesorigin/<branch>and checks whether it moved to commits the checkout holds. The fetch catches a push to an explicit URL, which bypasses the push URL. If the agent pushed, the run fails with "needs review: agent pushed directly to origin/…", naming the pushed commits and the result of the suppression check, which still runs over them. The run is not retried (a retry cannot undo a push), and the maintenance summary lists it as failed with that reason. A remediation that trips a check reports needs_reviewonremediation_completedand is not pushed or released; a task gets ablocked_on_decisionverdict without a review session and does not land.
An agent could still get around the prevention layer on purpose, for example
by pushing to an explicit URL instead of origin. The detection layer reports
that case. (Adding a push URL with git -c does not help it: Git pushes to
every configured push URL, and the disabled one fails the push.)
After the maintain agent finishes, Foundry checks that the checkout is on the
registry's configured branch. If the agent left it on its own branch (or a
detached HEAD) that fast-forwards the configured branch, Foundry moves the
configured branch forward, checks it out and deletes the agent's branch with
git branch -d. If it does not fast-forward, the run fails with a reason that
names the branch, and the checkout is left for a human.
After the whole run, the maintenance summary checks every push-enabled project
again and lists any that still hold unpushed commits in an Unpushed
commits section at the top of audits/runs/<date>/summary.md ("N commit(s)
ahead of origin/main"). Three more sections sit beside it:
- Scanner failures: projects whose dependency audit did not run, with the error. The post-push audit block is marked failed (not ok) when this happens, and the release-audit table says "scanner failed", never "clean".
- Projects skipped: wrong branch: projects whose validation stopped because
the checkout was on another branch (
sync_failure: "wrong_branch"). - Dependency drift: each project's update policy, the updates applied, the updates held back, the majors lane's decisions, and anything not classified. See Dependency Update Policy.
The summary phase runs Plan Major Upgrades first
(maintenance_summary_requested → major_upgrades_planned →
Generate Summary). After the summary is written, the daemon starts each
dispatched major upgrade as its own foundry task, one after another.
The block's result line carries a WARNING: naming each of these that is not
empty. Failed install steps are counted there too, and the Local Installs
table gives the reason for each failure or skip.
When a project's chain fails, or foundryd stops
Each project's run ends in a project_run_completed event, and the cycle
completes when all of them have arrived. If a block fails before a project
reaches that event, nothing else can produce it. When the run has nothing left
to execute, the engine closes the cycle anyway: maintenance_cycle_completed
is recorded with a missing list naming each project that did not finish and
the block that stopped it, and the summary is written as usual.
If foundryd itself stops during a cycle (killed, crashed, host restarted),
nothing records the end. On its next start foundryd looks back seven days for
system cycles with no maintenance_cycle_completed on their trace. For each
one it records the completion, with the unfinished projects as missing, and
runs the summary phase over the events the cycle did log. That summary opens
with a Cycle interrupted section, lists the unfinished projects as failed,
is filed under the date the cycle started (as summary-interrupted-<HHMM>.md
if a summary for that day already exists), and starts no major-upgrade tasks
and no triage.
The one-shot foundry task / campaign path is unaffected — it already builds
its isolated worktree from the fetched remote tip.
Refusing to Start on a Full Disk
Validate Project checks free space on the filesystems holding the project
and ~/.foundry before anything else runs, and a task checks the same (plus
its worktree location) before it creates a worktree. A filesystem is low when
its free space is under FOUNDRY_MIN_FREE_DISK_GB (default 15) and under
FOUNDRY_MIN_FREE_DISK_PERCENT (default 10). A low filesystem fails the run at
once with "insufficient disk: 4.0 GB free (2%) on the filesystem holding …",
instead of leaving a half-written worktree or a build that dies part-way. The
maintenance summary lists low filesystems in a Low disk section at the
top, and the ops digest treats low disk as a P0 anomaly (host_disk_low).
Set either limit to 0 to turn the check off.
Routing Logic
Route Project Workflow reads the actions flags forwarded in the
project_validation_completed payload and makes a single decision:
| Condition | Emits |
|---|---|
status != "ok" | nothing — chain stops |
actions.iterate = true | iteration_requested |
actions.iterate = false, actions.maintain = true | maintenance_requested |
| both false | nothing — no automation enabled |
When iterate = true, the actions.maintain flag is forwarded inside the
iteration_requested payload. After a successful iteration, the gate routing
emits maintenance_requested automatically when that flag is true,
so the maintain sub-workflow starts without an extra routing step.
Triggering a Maintenance Run
To run maintenance for a single project:
foundry emit project_validation_completed my-project \
--payload '{"status":"ok","actions":{"iterate":true,"maintain":true}}'
To trigger the full nightly cycle:
foundry emit maintenance_run_started my-project
Throttle Behaviour
| Throttle | Effect |
|---|---|
full | All blocks execute and emit events |
dry_run | Observers emit; mutators are skipped entirely |
Under dry_run, only iteration_requested or maintenance_requested are
emitted (by the Observer router). No execution blocks run.
Validate Project still checks for a dirty tree under dry_run, but it does
not fetch or merge. It reports the fast-forward that would happen, measured
against the last-fetched origin/<branch>, as fast_forwarded with
dry_run: true on project_validation_completed.
Agent Capabilities
The maintenance workflow uses a single agent invocation in Execute Maintain:
| Phase | Capability | Model | Access | Purpose |
|---|---|---|---|---|
| Execute Maintain | Coding | claude-sonnet-5 | Full | Apply the dependency brief, resolve gate failures |
The prompt carries the dependency brief: the exact updates to apply, and the
updates held back or left for major-upgrade tasks. The agent must not go
beyond the list. Gate definitions are passed as context so the agent knows
what must pass after its changes. If the project has an agent file registered, it is
supplied via --agent.
See the Iteration Workflow for the full model-to-capability mapping and CLI parameters used across all agent invocations.