#3339·Archon

flip-ready fails when the pull request is already merged

Author: patrickvanpetegemCreated Sep 16, 2026Updated Sep 16, 2026

Area: archon-deliver workflow pack — node flip-ready

The problem

flip-ready treats "the pull request is already merged or closed" as a failure of the node. It is not one: a merged pull request is the outcome the node exists to produce, already reached by another hand. The node fails, the failure is unrecoverable, and it takes the rest of the run with it.

The branch

.archon/workflows/sdlc/deliver/archon-deliver.yaml:599 (node flip-ready at :537):

bash
if ! FLIP=$(gh pr ready "$PR_NUMBER" --repo "$ORIGIN_REPO" 2>&1); then
  printf '%s\n' "flip-ready: the ready flip failed: $FLIP" >&2
  exit 1
fi

gh pr ready refuses a pull request that is not a draft. When the pull request was merged or closed while the run was still in flight, that refusal is correct at the gh level and wrong at the node level — the node reads "the command I ran returned non-zero" as "the delivery failed."

Observed, on a self-hosted install

deliver__flip-ready: Bash node 'deliver__flip-ready' failed [exit 1]:
  flip-ready: the ready flip failed: X Pull request owner/repo#85 is closed.
  Only draft pull requests can be marked as "ready for review"

The pull request had been merged 5 minutes earlier, by the operator, while the ship run that created it was still in its deliver phase. Nothing was wrong with the work. The run reported a delivery failure for a delivery that had succeeded.

Why it is unrecoverable

Retrying re-runs gh pr ready against the same merged pull request and fails identically. There is no input that makes the node succeed, and no operator action inside the run that clears it — the state it objects to is terminal and outside its control.

The failure is not local

flip-ready's stdout is the delivered URL, and outcome binds it (archon-deliver.yaml:636-641):

yaml
  - id: outcome
    script: outcome
    with:
      pr_url: "$flip-ready.output"
    depends_on: [flip-ready]

The engine refuses the binding rather than passing a value from a failed producer:

Node 'outcome' failed before execution: Node 'outcome' binding 'delivered' reads
'$deliver__flip-ready.output', but node 'deliver__flip-ready' failed (...), so its output cannot be
trusted. A binding never falls back to 'if_skipped' for a failed producer — fix the failure, or
guard 'outcome' with a 'when:' condition that excludes the failed branch.

That refusal is correct and is not the defect. outcome.py documents the guarantee it protects — "a failed flip blocks this node instead of handing it a stale value". The defect is that flip-ready classifies a non-failure as a failure, so a sound cascade fires on it. Fixing this by relaxing the binding rule would trade a false failure for a false success.

The visible cost on the install above: the run's DAG failed, the producing loop's ship stage reported every ship run failed, and the loop that drives the factory exited on it. One merged pull request stopped an unattended automation loop until a human noticed.

Why it is worth solving

A workflow that cannot tolerate its own success being completed by someone else is not safe to run unattended, and unattended is the mode this pack exists for. The trigger is ordinary: an operator merges the pull request from the GitHub UI, a merge queue merges it, or a second run reaches the same pull request. None of those are error conditions, and all of them currently fail the run.

The cost is asymmetric. The false failure produces no useful signal — the artifact it names as delivered exists and is merged — while it destroys the signal that would matter, because the run that genuinely failed has to be told apart from the run that succeeded.

Why now

  • The pack is being actively consolidated (#3127 is cited in this node's own comment at :620-624 as a pending dependency cleanup), so the node is open for correction.
  • flip-ready already reads structured pull-request state two lines below the failure (archon-deliver.yaml:606, gh pr view --json isDraft). The missing read is a state read in the same idiom, not new machinery.
  • .archon/scripts/__tests__/flip-ready-check-read.test.ts already drives this node's bash body with a stubbed gh on PATH. The regression test for this is cheap and the seam exists.

The desired outcome

flip-ready distinguishes "the flip could not be performed because the pull request is already terminal" from "the flip failed", and reports the first as the delivery it is.

Concretely, for a pull request that is already merged when the node runs, the node succeeds and emits the delivered URL on stdout, so outcome reports a completed delivery. The other cases keep their current strictness.

Invariants that must hold

  • The engine's binding rule is not the fix. A failed producer must keep failing its dependents. The change belongs in how flip-ready classifies its own outcome.
  • Classify on structured state, not on gh's prose. Read the pull request's state with gh pr view --json state,isDraft and branch on MERGED / CLOSED / OPEN. Do not match is closed or Only draft pull requests in the message text — per AGENTS.md, classifying vendor output with a pattern is a last resort, and this vendor's wording is not a contract. The structured read is also what makes the case testable.
  • A genuine flip failure must still fail, loudly. Auth failure, network failure, a malformed selector, a pull request that does not exist — all keep exiting non-zero with gh's own words, as today.
  • Do not silently accept a closed-unmerged pull request as a delivery. CLOSED without merge is not a delivered artifact. Whether it fails the node or reports a structured non-delivery is the implementer's call, but it must not be reported as delivered.
  • Both reads the node already performs stay. The check-state preflight and the isDraft read-back are load-bearing; the new state read is additional, not a replacement.

What acceptance looks like

  • A test in .archon/scripts/__tests__/ drives the node's bash body with a stubbed gh where gh pr ready exits non-zero with the closed-pull-request message and gh pr view --json state returns MERGED. The node exits 0 and prints the pull request URL.
  • A second test covers the same refusal with state CLOSED: the node exits non-zero, and its message names the pull request's state rather than "the ready flip failed".
  • A third test keeps today's behaviour: gh pr ready failing for any other reason still exits 1.
  • The existing tests in flip-ready-check-read.test.ts still pass unchanged.
  • bun run validate green, with the test count stated.

Evidence

The failing branch, on dev.

$ grep -n "gh pr ready" .archon/workflows/sdlc/deliver/archon-deliver.yaml
599:      if ! FLIP=$(gh pr ready "$PR_NUMBER" --repo "$ORIGIN_REPO" 2>&1); then

The node already reads structured state, in the same body.

$ sed -n '606p' .archon/workflows/sdlc/deliver/archon-deliver.yaml
      if ! draft=$(gh pr view "$PR_NUMBER" --repo "$ORIGIN_REPO" --json isDraft --jq .isDraft 2>/dev/null); then

outcome.py states the guarantee the cascade protects.

$ sed -n '26,32p' .archon/workflows/sdlc/ship/scripts/outcome.py
    ... That choice is load-bearing twice. The value is proof of the flip,
    not of creation: ... And a composed dependency on `deliver` resolves to its
    returns node, so a failed flip blocks this node instead of handing it a
    stale value, ...

The test seam that already exists.

$ sed -n '4,8p' .archon/scripts/__tests__/flip-ready-check-read.test.ts
 * The node's bash body is the unit under test: fixtures stub `flip-ready`'s
 ...
 * failed check read must refuse before `gh pr ready` is invoked.

The install that hit this runs a pinned earlier revision of this pack; the same body and the same failure are present there, so the fix belongs on dev and reaches it on the next pin.