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:

  1. Dirty tree → refuse. If git status --porcelain is non-empty the project fails with sync_failure: "dirty_tree". Nothing is fetched, merged, or cleaned; the uncommitted work is left for a human.
  2. Fetch. git fetch origin <branch>. If the fetch fails, or origin/<branch> cannot be resolved, the project fails with sync_failure: "remote_unavailable".
  3. Fast-forward only. git merge --ff-only origin/<branch>. The number of commits applied is recorded as fast_forwarded on project_validation_completed (0 when already level, or when the local branch is only ahead).
  4. 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_failureMeaning
push_rejected_divergedThe rebase conflicted. It was aborted; the commits stay on the local branch for a human.
gates_failed_after_rebaseThe rebased commits failed a required gate, or the project has no gates to verify them. Nothing was pushed.
remote_unavailableThe pre-push fetch failed, or origin/<branch> could not be resolved.
push_failedgit push itself was rejected (for example a non-fast-forward race).
run_failedThe 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:

  1. 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.
  2. Prevention. Every maintain, iterate, task and remediation agent session runs with GIT_CONFIG_COUNT=1, GIT_CONFIG_KEY_0=remote.origin.pushurl and GIT_CONFIG_VALUE_0=foundry://agent-push-disabled in its environment. Every process the agent starts inherits it, so a git push to origin fails. Nothing is written to the repository, and Commit and Push runs outside that environment, so Foundry's own push works as before.
  3. Detection. Every maintain, iterate, task, vulnerability-remediation and pipeline-remediation run records HEAD and origin/<branch> before its first agent session (run_base, carried through retries). After each attempt Foundry fetches origin/<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_review on remediation_completed and is not pushed or released; a task gets a blocked_on_decision verdict 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:

ConditionEmits
status != "ok"nothing — chain stops
actions.iterate = trueiteration_requested
actions.iterate = false, actions.maintain = truemaintenance_requested
both falsenothing — 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

ThrottleEffect
fullAll blocks execute and emit events
dry_runObservers 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:

PhaseCapabilityModelAccessPurpose
Execute MaintainCodingclaude-sonnet-5FullApply 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.