feat(ci-gate): wait for CI to prove a commit before a human is called #418

Merged
mph merged 18 commits from fix/ci-green-gate into main 2026-08-14 09:50:17 +00:00
Owner

What this does

After a PR is created, the command waits for CI to reach a verdict, and only a
proved-green commit is handed to a human or merged.

There is no separate "wait for CI" step: it is wait if there is a CI to wait
for
, and whether a branch carries CI is a fact the code reads from the commit
under test — the workflow files in HEAD — never per-project configuration.

GitHub, GitLab and Forgejo are all first class. GitLab was verified end to end
against a live self-hosted instance (MEDECA): MR 396 read as green, MR 367 as
red naming the e2e job, script_failure, and job 14623.

On red CI: locate, never diagnose

The gate names the failed jobs, downloads their logs, and prints absolute paths,
forge URLs and suggested greps. It quotes no excerpt and guesses no cause. A real
747 KB GitLab trace cleans to 645 KB with its Czech text intact.

The exit-code contract

Code Meaning
0 CI is green (or there is no CI here) — proceed
1 A person or an agent must act
3 The wait is unfinished — call the command again

Exit 3 exists because a CI wait outlives the shell an agent will hold open. The
budget lives on the card (ci_deadline), so a resumed invocation spends what is
left of it rather than starting a fresh thirty minutes.

Commands

  • iw phase-pr gates the phase it just opened.
  • iw phase-merge merges only the commit the gate read (--match-head-commit,
    --sha, head_commit_id), so a push landing between verdict and merge cannot
    slip in ungated.
  • iw ci-gate is new: it gates work that has no phase — a slice, a spike, a
    branch opened by hand. It never commits and never pushes, because a push would
    move the head and restart the run it is waiting for.

Dashboard

New CI states are understood: a red gate asks to fix the failure rather than to
review a pull request, and a ci_pending card that no writer has touched for ten
minutes is a gate that died — the one place resolution raises a claim no writer
made, because the writer is the process that died.

Review

Eight reviewers ran over this branch. They found one critical defect and twelve
warnings; every behaviour finding is fixed. The ones worth knowing:

  • The gate failed open. A git ls-tree failure read as "this branch has no
    CI", sending the PR straight past the wait. It now reports the error.
  • phase-merge never wrote ci_failed. An exhausted gate left the card at
    ci_pending, which batch-implement reads as recoverable, so it looped
    instead of stopping and raised no claim for a person.
  • A failed job fetch was reported as "the forge offers no log for this job"
    a false claim about the forge when the real cause was an expired token.
  • Downloaded traces are scrubbed of credentials and written owner-only, and
    forge-written job names are fenced in the recovery prompt as data the agent
    must not obey.

Deferred deliberately: an opaque CommitSha sweep, URL query encoding, a
download size cap for GitHub and Forgejo, and issue-id path validation — all
pre-existing or repo-wide rather than this branch's scope.

Clean break

No backward-compatibility shim. CommandEnv's merge methods take matchHead,
and listInHead returns the git error instead of an empty list.

Verification

Core suite, the dashboard module under Mill, and the bats suites all green.
The pre-push gate (warnings as errors, scalafix, both suites under Mill, all 33
commands compiled) passes.

Note: MEDECA's main-branch pipeline runs ~57 minutes, so it needs
--timeout 90m. That is documented, not defaulted.

## What this does After a PR is created, the command waits for CI to reach a verdict, and only a proved-green commit is handed to a human or merged. There is no separate "wait for CI" step: it is *wait if there is a CI to wait for*, and whether a branch carries CI is a fact the code reads from the commit under test — the workflow files in `HEAD` — never per-project configuration. GitHub, GitLab and Forgejo are all first class. GitLab was verified end to end against a live self-hosted instance (MEDECA): MR 396 read as green, MR 367 as red naming the `e2e` job, `script_failure`, and job 14623. ## On red CI: locate, never diagnose The gate names the failed jobs, downloads their logs, and prints absolute paths, forge URLs and suggested greps. It quotes no excerpt and guesses no cause. A real 747 KB GitLab trace cleans to 645 KB with its Czech text intact. ## The exit-code contract | Code | Meaning | |------|---------| | 0 | CI is green (or there is no CI here) — proceed | | 1 | A person or an agent must act | | 3 | The wait is unfinished — call the command again | Exit 3 exists because a CI wait outlives the shell an agent will hold open. The budget lives on the card (`ci_deadline`), so a resumed invocation spends what is left of it rather than starting a fresh thirty minutes. ## Commands - `iw phase-pr` gates the phase it just opened. - `iw phase-merge` merges only the commit the gate read (`--match-head-commit`, `--sha`, `head_commit_id`), so a push landing between verdict and merge cannot slip in ungated. - `iw ci-gate` is new: it gates work that has no phase — a slice, a spike, a branch opened by hand. It never commits and never pushes, because a push would move the head and restart the run it is waiting for. ## Dashboard New CI states are understood: a red gate asks to fix the failure rather than to review a pull request, and a `ci_pending` card that no writer has touched for ten minutes is a gate that died — the one place resolution raises a claim no writer made, because the writer is the process that died. ## Review Eight reviewers ran over this branch. They found one critical defect and twelve warnings; every behaviour finding is fixed. The ones worth knowing: - **The gate failed open.** A `git ls-tree` failure read as "this branch has no CI", sending the PR straight past the wait. It now reports the error. - **`phase-merge` never wrote `ci_failed`.** An exhausted gate left the card at `ci_pending`, which `batch-implement` reads as recoverable, so it looped instead of stopping and raised no claim for a person. - **A failed job fetch was reported as "the forge offers no log for this job"** — a false claim about the forge when the real cause was an expired token. - Downloaded traces are scrubbed of credentials and written owner-only, and forge-written job names are fenced in the recovery prompt as data the agent must not obey. Deferred deliberately: an opaque `CommitSha` sweep, URL query encoding, a download size cap for GitHub and Forgejo, and issue-id path validation — all pre-existing or repo-wide rather than this branch's scope. ## Clean break No backward-compatibility shim. `CommandEnv`'s merge methods take `matchHead`, and `listInHead` returns the git error instead of an empty list. ## Verification Core suite, the dashboard module under Mill, and the bats suites all green. The pre-push gate (warnings as errors, scalafix, both suites under Mill, all 33 commands compiled) passes. Note: `MEDECA`'s main-branch pipeline runs ~57 minutes, so it needs `--timeout 90m`. That is documented, not defaulted.
mph added 17 commits 2026-08-14 08:57:09 +00:00
Records the design for gating the human handoff on CI, and the measurements
behind it.

- Forgejo writes commit statuses only at job completion, so phase-merge can
  never observe a running job and merges red or incomplete PRs. Poll the
  Actions runs API instead.
- evaluateChecks treats Unknown as passing, so any unmapped forge status
  counts as green. The status maps become exhaustive and fail safe.
- A blocking wait cannot work: the agent Bash ceiling is 10 minutes and our
  own CI run is ~9m40s. The gate polls in bounded windows and exits 3 to be
  called again.
- GitLab measurements: pipelines carry honest non-terminal statuses, so no
  rework is needed there; head_pipeline is authoritative; job traces reach the
  4 MiB cap and are truncated, so no excerpt may be emitted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CiState/CiRun/CiJob/CiSnapshot/CiVerdict replace nothing yet — the old
CICheckStatus path still runs. This commit adds the pure core the gate
will decide on:

- Exhaustive per-forge status maps. An unmodelled value becomes
  Unrecognized, which is neither green nor terminal, so no unknown word
  from a forge can be read as a pass.
- The commit filter is applied client-side. Forgejo silently ignores
  query parameters it does not recognise, so a server-side filter may
  have done nothing.
- Supersession is by attempt within one run id. Two distinct run ids on
  one commit are two real runs; discarding either can hide a failure.
- Ordering is by creation time. Ordering over opaque ids is lexical,
  which makes run "9" look newer than run "10".
- Red is immediate and sticky: a job that fails while a sibling still
  runs is reported now, with partial set.
- An incomplete snapshot is an error, not a verdict.

CiPresence answers "is there CI here" from the workflow files the commit
carries, via the new GitAdapter.listInHead. A workflow directory tracked
only for a placeholder file holds no CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cleans transport noise out of a downloaded job log and points an agent at
where to start reading, without interpreting the content: the measured
logs offer nothing to slice on, and GitLab stops collecting output at
4 MiB, so an excerpt cut from the end can miss the failure entirely.

Rules, all measured against captured Forgejo and GitLab output that ships
as fixtures:

- A byte order mark at byte 0 is dropped; without this a prefix pattern
  mangles line 1.
- ANSI escapes go; the text they decorated stays.
- GitLab's stream marker is dropped only when a timestamp introduces it.
  Runner 11.4.2 emits neither, so a bare `00O` starting a line is output.
- Only a trailing carriage return is transport. A carriage return inside
  a line is content: curl progress meters overwrite one line and the last
  segment is what ran.
- Section markers survive as plain text, and GitLab gets a query that
  lists them — a log's table of contents is navigation, not noise.
- Cleaning never adds or removes a line.

The suggested greps all work from the top of the file and bound their own
output. A truncated log announces itself so nobody trusts its tail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Forgejo writes a commit status when a job finishes, so the endpoint the
gate reads today cannot see a run in flight: mid-run it reports only the
jobs that already passed, which reads as green. The Actions endpoints
report a run from the moment it is created.

Shapes pinned against code.iterative.works (Forgejo 16.0.1), run 317:

- The runs query sends no `event` parameter. Measured, `?head_sha=<sha>`
  returns the run and adding `&event=pull_request` returns nothing for
  that same run, so sending it would make the gate a permanent no-op.
- Runs arrive in a `workflow_runs` wrapper; the commit is `commit_sha`,
  the creation time is `created`, and the number in the web URL is
  `index_in_repo`, not the id the API takes.
- Jobs arrive as a bare list with the outcome in `status`; `conclusion`
  is unset. A job's log handle is its own `id` — `task_id` belongs to a
  different job (compile is 675/677, and 677 is lint), and transposing
  them returns HTTP 200 with the wrong job's log.
- A page shorter than `total_count` marks the snapshot incomplete rather
  than letting the gate judge a partial set.
- A run whose jobs cannot be read keeps `jobs = None`, so a red run stays
  red instead of reading as job-less.

The old commit-status path is untouched here; it still serves
phase-merge until the port swap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on gitlab.e-bs.cz: a merge request's head pipeline reports
`source: "merge_request_event"`. An allow list of pull_request /
pull_request_target / push discards it, so the gate would have found no
run for any GitLab merge request and refused to hand over every one of
them.

The events that gate are open-ended and forge-specific — GitLab alone
reports push, merge_request_event, merged_result_pipeline, web, api and
trigger, any of which can be a head pipeline. Naming what to ignore is a
closed set: scheduled and release runs. Missing a deny entry costs a wait
on a run that was not ours; missing an allow entry blocks real work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GitHub, measured against api.github.com: runs arrive in a workflow_runs
wrapper with progress in `status` and the outcome in `conclusion`, and
jobs arrive in a `jobs` wrapper already carrying an absolute URL. The
runs and jobs commands deliberately do NOT pass --paginate: measured, gh
emits one JSON object per page on a wrapped endpoint, concatenated, and a
JSON reader takes the first and silently drops the rest. One full page
plus the total_count check turns a set too large to read into a loud
stop instead.

GitLab, measured against gitlab.e-bs.cz: a merge request names its own
`head_pipeline`, so no "latest pipeline wins" guess is needed on an MR
with three pipelines from successive pushes. Jobs are enumerated only
when the pipeline is not green — GitLab already folds allow_failure,
manual and skipped into the pipeline status, and re-deriving that from
job statuses would be the same computation, done worse. Jobs are needed
to name a failure and fetch its log, nothing else.

`failure_reason` is kept for GitLab: script_failure means the code broke,
runner_system_failure means the runner did, and nothing else separates
them without guessing.

A pipeline that tested a commit other than the merge request's head —
the merged-results case — reports what it tested, and the gate's own
commit filter decides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
phase-merge asked the forge for commit statuses and read an empty list as
"no CI configured, proceed". On Forgejo a status row appears only when a
job finishes, so a run in flight looked exactly like a repository with no
CI at all: the command merged while its own tests were still going.

TrackerOps now reads what the forge actually reports about a run.
fetchCheckStatuses is replaced by fetchCiSnapshot, fetchCiJobLog and
fetchPrHeadSha, and CiGate.verdict decides. An empty run set is no longer
a pass: with workflow files on the branch it waits, then stops. A set the
adapter could not read whole is an error, not a green.

The head commit is re-read on every poll, so a recovery push moves the
gate onto the pushed commit instead of judging a commit that no longer
exists on the branch.

CICheckResult stays as the recovery hook's payload; phase-merge maps to
it at the presentation boundary. Replacing it changes the plugin API,
which is a separate step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wait lived inside phase-merge and blocked for up to 30 minutes. An
agent's shell holds a command open for at most 10, so the wait was
killed mid-flight, leaving a card that said ci_pending with no attention
and no commit: "the human was called too early" turned into "the human
was never called".

CiGate.poll now owns the loop. It blocks for one poll window (8m by
default), and when CI is still in flight at the end of it, it returns
Running and the command exits 3 — call me again. The card keeps
activity=working and carries ci_deadline, so the total budget survives
across invocations and a dead waiter can be told from a live one.

Also in the loop, all of it testable now that it is one function:
a heartbeat write per iteration; three tolerated read failures before
the gate reports an error; and a head commit re-read every iteration,
where a move restarts the run-appearance budget, because a commit that
just landed has had no time to produce a run.

CommitPolicy says who commits the ci_pending write. phase-merge commits
it (gh pr merge --delete-branch runs a checkout that aborts on a dirty
review-state.json); phase-pr and ci-gate will not, since they make one
commit on every exit path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Exit code 3 alone reads as a failure to anything that only checks for
zero. The command that produced it now says, in its own output, that
nothing failed, what the code means, which pull request it was waiting
on, how much of the budget is left, and the exact command that
continues the wait.

The agent needs no instruction in the workflow that called it: the
process ends and the reason is on the screen. Built as a pure function
so phase-pr and ci-gate print the same four lines under their own names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A red gate now downloads the failing jobs' logs, writes them under
.iw/ci-logs, and prints the absolute path, the size, and the query to
start from. It quotes none of the content: measured runner output offers
nothing to slice on, and a wrong excerpt is worse than none.

The report is one pure function, so the console and the recovery prompt
say the same thing. RecoveryContext carries the downloaded logs in place
of check names, which is a break the kanon hook has to follow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
phase-pr handed a pull request to a human the moment it was created,
which is where a red CI first becomes somebody else's problem. It now
runs the same gate phase-merge does: ci_pending while it waits, exit 3
when the poll window runs out, ci_failed and exit 1 when CI refuses.

A green verdict names the commit it proves, and phase-pr records it as
ci_gated_sha; what merges next matches against it. No recovery hook runs
here — the agent that wrote the code owns the fix, and running two
recovery owners over one failure was the draft's four-attempt bug.

A repository with no workflow files still costs no forge call at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
phase-merge waited for CI a second time on every phase. The commit it
waited for was usually phase-pr's own review-state push, so each phase
paid two full CI cycles, and with --max-retries 0 a flake on a card-only
commit handed the agent a red.

It now skips the wait when nothing but review-state.json changed since
the commit phase-pr proved, and takes one snapshot instead: a head that
is red now still stops the merge. A card-only commit can break CI only
by flake, and that trade is taken deliberately.

Both merges name the head the gate read. gh, glab and the Forgejo API
each refuse a merge whose head has moved since, which closes the window
between the verdict and the merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dashboard control plane landed while the gate was being built, and it
resolves the one act a worktree has pending on a human from the status
its workflow published. The three statuses the gates write were not among
them: a red phase read as "Review pull request #42", which hands a person
work the gate had just refused.

A ci_failed card now names the act it actually wants — fix the CI — and
the two waiting statuses carry labels for the case where a gate stops
waiting without saying so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(ci-gate): gate work that has no phase, and keep the budget across resumes
All checks were successful
CI / contract (pull_request) Has been skipped
CI / compile (pull_request) Successful in 1m24s
CI / format (pull_request) Successful in 13s
CI / lint (pull_request) Successful in 16s
CI / dashboard-build (pull_request) Successful in 59s
CI / test (pull_request) Successful in 9m34s
e935bd21b2
`iw ci-gate` waits for a pull request's CI wherever the work is tracked — a
slice, a spike, a branch someone opened by hand. It finds the card the way
`review-state` does and reads the same 0/1/3 contract. It never commits and
never pushes: a push from inside a gate moves the head and restarts the run it
is waiting for.

The wait budget now survives a resume. `ci_deadline` was written and never
read, so every re-entry started a fresh thirty minutes. Every terminal write
clears it, which makes a deadline that is present proof of a wait in progress,
and the start of the wait a pure function of the card.

A wait that goes quiet asks for a person. The gate rewrites the card on every
poll, so a ci_pending card untouched for ten minutes is a gate that died.

`ForgeResolution` and `ReviewStatePath` replace the copies that `phase-pr`,
`phase-merge` and `review-state` each carried.

What the code review found, and this fixes:

- CI presence failed open. `git ls-tree` returning non-zero read as "no CI
  here", sending the pull request straight past the gate on a broken worktree
  or an unborn HEAD. A tree that cannot be read is now an error, not an answer.
- `phase-merge` never wrote `ci_failed`. It left the card at the `ci_pending`
  its own gate had set, which `batch-implement` reads as recoverable — so an
  exhausted gate looped instead of stopping, and nobody was called.
- A log that could not be fetched claimed "the forge offers no log for this
  job". All three adapters dropped the real error; `CiRun.jobsError` carries it
  to the report, so an expired token is not described as a forge limitation.
- The recovery hook's exit code was discarded, making "the fixer crashed"
  identical to "the fixer ran and CI is still red".
- Downloaded traces are scrubbed of authorization headers and forge tokens
  before they are written, and land in an owner-only directory. Forge-written
  job names are fenced in the recovery prompt as data the agent must not obey.
- `PendingAct.resolve` read the clock inside a function declaring itself pure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge branch 'main' into fix/ci-green-gate
All checks were successful
CI / lint (pull_request) Successful in 10s
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 8s
CI / compile (pull_request) Successful in 1m25s
CI / dashboard-build (pull_request) Successful in 1m9s
CI / test (pull_request) Successful in 9m42s
1f00cc9171
Main broadened what counts as a claim: a card that says it is waiting and
names a status now claims the human, not only one that raised the flag.

The abandoned-wait rule stays ahead of that check rather than folding into
it. A gate that died left activity=working and its claim cleared, so
`claims` is false for exactly the card the rule exists to catch; second
place would make it unreachable.

Main's new PendingAct tests go through the same fixed-moment helper as the
rest of that suite, since `resolve` no longer defaults its clock.
mph merged commit 061b051cbc into main 2026-08-14 09:50:17 +00:00
mph deleted branch fix/ci-green-gate 2026-08-14 09:50:18 +00:00
Sign in to join this conversation.
No description provided.