IW-437: The phase CI gate finishes a phase without manual intervention #475

Merged
mph merged 40 commits from IW-437 into main 2026-08-28 07:55:46 +00:00
Owner

Three defects in the phase CI gate. Together they made iw phase-pr unable to finish a phase on a repository whose pipeline runs longer than the poll window — which is this repository. The gate wrote a ci_failed card although nothing had failed, and a person had to merge by hand.

Each defect was reproduced with a failing test before it was fixed.

Defects fixed

1. phase-pr moved the branch head on an unfinished wait (phase 1, closes #445 #450)

markPending and heartbeat rewrite the card every poll, and ReviewStateUpdater.merge always refreshes last_updated, so the card was dirty on every exit. When the poll window ended before CI decided, finish committed and pushed it. The new head cancelled the run in flight (concurrency.cancel-in-progress) and started a fresh one, so a resumed wait could never converge. --batch reached the same end through CommitPolicy.CommitBeforeGate.

The card is no longer pushed on an unfinished wait; it stays uncommitted until the verdict is terminal. doBatchMerge now commits it immediately before the merge, keeping the clean tree the merge needs.

2. A 409 Conflict was not recognised as a duplicate pull request (phase 2, closes #431)

resolvePullRequest's fallback to the card's PR url was gated on the error prose matching "already exists". ForgejoClient collapsed every unhandled status into Forgejo API error: <code>, so a 409 never matched and the resume failed on this project's own forge.

The create error is now typed as ForgeCreateError, so a duplicate is recognised by what the forge said rather than by how an adapter phrased it.

3. The CI wait budget refused before it read the forge, and could not be reset (phase 3, closes #433)

CiGate.loop evaluated both budgets before any forge call, so a card carrying a spent budget returned TimedOut without a single API request — a terminal-green pipeline reported as a timeout. Separately, the start of the wait was derived by subtracting timeoutMs from ci_deadline, which markPending added straight back; timeoutMs cancelled, so --timeout was inert on exactly the invocation where a larger one is needed.

The forge is now read once first, a terminal verdict wins outright, and the budget is checked only on a Running verdict. ci_deadline is replaced by ci_wait_started, so this invocation's --timeout decides the budget.

Breaking change

ci_deadline is gone with no backward compatibility, by a decision recorded in analysis.md. A card still carrying it is rejected as an unrecognized root key. The field is only present during an active CI wait, so in-flight waits are what this affects; finish them before upgrading, or re-run the gate to write a fresh card.

Testing

  • ./mill core.test — 186/186
  • ./iw ./test — core 186, dashboard 241, dashboard.itest 240, BATS e2e 190/190

Every regression test was written RED first, and each assertion was proved fallible by mutating the fix and confirming the specific test went red. Four assertions that pinned the old contract ("the budget is checked before the forge is read") were deliberately inverted; each comment was rewritten rather than deleted.

Deliberately not fixed

Recorded in LESSONS.md and filed rather than folded in: the startOfWait fallback on a corrupt or future timestamp (#473, byte-for-byte unchanged from baseline), unenforced date-time validation on card timestamps (#474), the duplicate fallback reading a card keyed on the issue rather than the phase (#467), and unsanitized forge-controlled console output through the gh/glab adapters (#469).

PhaseMerge.isStateOnlyDelta is untouched and stays load-bearing: the terminal paths still push a card-only commit.

Release notes

project-management/issues/IW-437/release-notes.md (Czech, user-facing).

Closes #437
Closes #431
Closes #433
Closes #445
Closes #450

🤖 Generated with Claude Code

Three defects in the phase CI gate. Together they made `iw phase-pr` unable to finish a phase on a repository whose pipeline runs longer than the poll window — which is this repository. The gate wrote a `ci_failed` card although nothing had failed, and a person had to merge by hand. Each defect was reproduced with a failing test before it was fixed. ## Defects fixed **1. `phase-pr` moved the branch head on an unfinished wait** (phase 1, closes #445 #450) `markPending` and `heartbeat` rewrite the card every poll, and `ReviewStateUpdater.merge` always refreshes `last_updated`, so the card was dirty on every exit. When the poll window ended before CI decided, `finish` committed **and pushed** it. The new head cancelled the run in flight (`concurrency.cancel-in-progress`) and started a fresh one, so a resumed wait could never converge. `--batch` reached the same end through `CommitPolicy.CommitBeforeGate`. The card is no longer pushed on an unfinished wait; it stays uncommitted until the verdict is terminal. `doBatchMerge` now commits it immediately before the merge, keeping the clean tree the merge needs. **2. A 409 Conflict was not recognised as a duplicate pull request** (phase 2, closes #431) `resolvePullRequest`'s fallback to the card's PR url was gated on the error *prose* matching "already exists". `ForgejoClient` collapsed every unhandled status into `Forgejo API error: <code>`, so a 409 never matched and the resume failed on this project's own forge. The create error is now typed as `ForgeCreateError`, so a duplicate is recognised by what the forge said rather than by how an adapter phrased it. **3. The CI wait budget refused before it read the forge, and could not be reset** (phase 3, closes #433) `CiGate.loop` evaluated both budgets before any forge call, so a card carrying a spent budget returned `TimedOut` without a single API request — a terminal-green pipeline reported as a timeout. Separately, the start of the wait was *derived* by subtracting `timeoutMs` from `ci_deadline`, which `markPending` added straight back; `timeoutMs` cancelled, so `--timeout` was inert on exactly the invocation where a larger one is needed. The forge is now read once first, a terminal verdict wins outright, and the budget is checked only on a `Running` verdict. `ci_deadline` is replaced by `ci_wait_started`, so this invocation's `--timeout` decides the budget. ## Breaking change `ci_deadline` is gone with no backward compatibility, by a decision recorded in `analysis.md`. A card still carrying it is rejected as an unrecognized root key. The field is only present during an active CI wait, so in-flight waits are what this affects; finish them before upgrading, or re-run the gate to write a fresh card. ## Testing - `./mill core.test` — 186/186 - `./iw ./test` — core 186, dashboard 241, dashboard.itest 240, BATS e2e 190/190 Every regression test was written RED first, and each assertion was proved fallible by mutating the fix and confirming the specific test went red. Four assertions that pinned the *old* contract ("the budget is checked before the forge is read") were deliberately inverted; each comment was rewritten rather than deleted. ## Deliberately not fixed Recorded in `LESSONS.md` and filed rather than folded in: the `startOfWait` fallback on a corrupt or future timestamp (#473, byte-for-byte unchanged from baseline), unenforced `date-time` validation on card timestamps (#474), the duplicate fallback reading a card keyed on the issue rather than the phase (#467), and unsanitized forge-controlled console output through the `gh`/`glab` adapters (#469). `PhaseMerge.isStateOnlyDelta` is untouched and stays load-bearing: the terminal paths still push a card-only commit. ## Release notes `project-management/issues/IW-437/release-notes.md` (Czech, user-facing). Closes #437 Closes #431 Closes #433 Closes #445 Closes #450 🤖 Generated with Claude Code
mph added 39 commits 2026-08-28 07:22:08 +00:00
Phase CI gate: phase-pr restarts CI on every resume, cannot resume on
Forgejo, and expires against a green pipeline

Artifacts:
- analysis.md

🤖 Generated with Claude Code
Resolved:
- analysis.md: What should the CI wait budget measure? → take the
  recommendation in full — read the forge before refusing (D), keep
  elapsed wall-clock (A), replace ci_deadline with a stored wait-start,
  and drop the old field (no backward compatibility)
- analysis.md: Does wf-implement Step 3 need to tolerate a dirty card? →
  no. Step 2 stops on an open PR before Step 3 checks the tree, so no
  workflow step can meet the dirty card. No dev-docs change needed.

🤖 Generated with Claude Code
Phase CI gate: phase-pr restarts CI on every resume, cannot resume on
Forgejo, and expires against a green pipeline

Artifacts:
- tasks.md (3 phases, sequenced defect 3 -> 1 -> 2)
- review-state.json (tasks_ready)
- analysis.md (total range corrected to 7.5-13h after the budget
  scope was settled)

🤖 Generated with Claude Code
Issue: IW-437
Phase: 1 - phase-pr moves the branch head on an unfinished wait

This commit marks the checkpoint for phase 1 context generation.
Use dx-fix IW-437 to return to this point.

🤖 Generated with Claude Code
phase-pr moves the branch head on an unfinished wait

Artifacts:
- phase-01-tasks.md

🤖 Generated with Claude Code
phase-pr no longer moves the branch head on an unfinished wait
All checks were successful
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 8s
CI / compile (pull_request) Successful in 1m35s
CI / dashboard-build (pull_request) Successful in 1m20s
CI / test (pull_request) Successful in 11m9s
10d52e8f79
- Remove persist from the CiVerdict.Running case so the card stays uncommitted until the verdict is terminal
- Use CommitPolicy.DeferToCaller unconditionally in gate to close the --batch door
- Commit the card in doBatchMerge immediately before the merge so the checkout still meets a clean tree
- Add FakeTracker.onMergeSquashAndDelete so a test can prove the commit lands before the merge
- Add four regression tests: exit-3 non-batch and --batch make no commit; both --batch merge paths commit first
Reviewed-on: #448
Issue: IW-437
Phase: 2 - A 409 Conflict is not recognised as a duplicate pull request

This commit marks the checkpoint for phase 2 context generation.
Use dx-fix IW-437 to return to this point.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Pwo2RcHqPuziyjj49RMFp
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Pwo2RcHqPuziyjj49RMFp
A 409 Conflict is not recognised as a duplicate pull request

Artifacts:
- phase-02-tasks.md

🤖 Generated with Claude Code
A 409 Conflict is not recognised as a duplicate pull request
All checks were successful
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 7s
CI / compile (pull_request) Successful in 1m25s
CI / dashboard-build (pull_request) Successful in 59s
CI / test (pull_request) Successful in 9m30s
e7d20f56b0
- Type the createPullRequest error channel as ForgeCreateError, so a duplicate
  is recognised by what the forge said and not by how the adapter phrased it
- Classify Forgejo's 409 by status alone - v16.0.1 answers it only for a duplicate
- Move the gh/glab prose match into the adapters that own each CLI's vocabulary
- Stop claiming manual PR creation is the next step on a failure that is not a duplicate
- Sanitise and cap the response body that reaches the terminal

Issue: IW-437
Phase: 2 - A 409 Conflict is not recognised as a duplicate pull request

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Pwo2RcHqPuziyjj49RMFp
chore(IW-437): update review-state for phase 02
All checks were successful
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 7s
CI / compile (pull_request) Successful in 1m23s
CI / dashboard-build (pull_request) Successful in 58s
CI / test (pull_request) Successful in 9m28s
91e79c6747
Reviewed-on: #454
Issue: IW-437
Phase: 3 - The CI wait budget refuses before it reads the forge, and cannot be reset

This commit marks the checkpoint for phase 3 context generation.
Use dx-fix IW-437 to return to this point.

🤖 Generated with Claude Code
--checkpoint replaces the whole phase_checkpoints map, so each phase's
Step 6a update destroyed the previous phase's entry. Phase 1 and phase 2
were read back out of the card's git history and re-appended with
--append-checkpoint. Recorded as LESSONS entry 11.

🤖 Generated with Claude Code
The CI wait budget refuses before it reads the forge, and cannot be reset

Artifacts:
- phase-03-tasks.md

🤖 Generated with Claude Code
The CI wait budget reads the forge before it refuses, and a resume can extend it
All checks were successful
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 8s
CI / compile (pull_request) Successful in 1m26s
CI / dashboard-build (pull_request) Successful in 57s
CI / test (pull_request) Successful in 9m33s
906a72bfbc
- CiGate.loop reads the forge once first and lets a terminal verdict win over a spent budget
- ci_deadline replaced by ci_wait_started so elapsed wall-clock is stored and --timeout works on a resume
- Four deliberate assertions inverted with rewritten comments - analysis.md and tasks.md both miscount them as three
chore(IW-437): update review-state for phase 03
All checks were successful
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 7s
CI / compile (pull_request) Successful in 1m26s
CI / dashboard-build (pull_request) Successful in 58s
CI / test (pull_request) Successful in 9m34s
fefb00d840
Reviewed-on: #458
workflow: Triage lessons for IW-437
Some checks failed
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Has been cancelled
CI / dashboard-build (pull_request) Has been cancelled
CI / compile (pull_request) Has been cancelled
09879b8d4b
Promoted:
- dx-fix checkpoint snippet (entries 1, 11) -> dev-docs#187; CLI half already
  tracked as iw-cli#436, evidence added there
- Code review runs on uncommitted work (entry 3) -> dev-docs#188
- Push failure drops the hook's stderr (entry 4) -> iw-cli#465
- ./iw ./test green on unpushable code (entry 5, in part) -> iw-cli#466
- Duplicate fallback can return an earlier phase's PR url (entry 7) -> iw-cli#467
- Non-deterministic tmux BATS tests (entry 8) -> iw-cli#468
- Unsanitized forge-controlled console output (entry 9) -> iw-cli#469
- phase-commit --items cannot carry a comma (entry 10) -> iw-cli#471
- Re-derive counts and ranges from source (entries 6, 12) -> dev-docs note #192
- Corrupt or future ci_wait_started disables the budget (entry 14) -> iw-cli#473
- Timestamp fields never parsed against the schema (entry 15) -> iw-cli#474

Rejected:
- Stale wf-implement line citation (entry 2): the claim is unaffected
- Review skills should read .scalafix.conf (entry 5, kanon half): lint config is
  a project-level decision, not a shared guide's business
- Contradictory acceptance criteria (entry 13): the two bullets are sequential,
  not contradictory

Retained:
- Stale citations in this issue's own planning documents (entry 6)

Tracker hygiene done during triage: iw-cli#464 and #446 closed as duplicates of
#436; dev-docs#187 cross-linked with note #183.

🤖 Generated with Claude Code
chore(IW-437): update review-state to all_complete
All checks were successful
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 7s
CI / compile (pull_request) Successful in 1m14s
CI / dashboard-build (pull_request) Successful in 59s
CI / test (pull_request) Successful in 9m31s
527e12a99e
mph merged commit 4a102239e9 into main 2026-08-28 07:55:46 +00:00
Sign in to join this conversation.
No description provided.