@plainconceptsplatform/workflows 1.5.0 → 1.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -203,20 +203,23 @@ assert_route "EVIDENCE_ENABLED=false turns the merge route off" none \
203
203
  EVENT=pull_request_target ACTION=closed PR_MERGED=true EVENT_PR_NUMBER=7 EVIDENCE_ENABLED=false
204
204
 
205
205
  echo "── CI completion ─────────────────────────────────────────────────────────"
206
- assert_route "a failed App CI run on a pull request routes to merge-gate" merge-gate \
207
- EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=failure RUN_ID=99
208
- assert "merge-gate carries the failing CI conclusion" failure \
209
- "$(route_field ci-conclusion EVENT=workflow_run RUN_PR_NUMBER=7 \
210
- RUN_CONCLUSION=failure RUN_ID=99)"
211
- assert "merge-gate carries the failing CI run id" 99 \
212
- "$(route_field ci-run-id EVENT=workflow_run RUN_PR_NUMBER=7 \
213
- RUN_CONCLUSION=failure RUN_ID=99)"
214
- assert_route "a green App CI run does not auto-trigger the gate" none \
215
- EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=success RUN_ID=99
216
- assert_route "a cancelled App CI run does not auto-trigger the gate" none \
217
- EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=cancelled RUN_ID=99
218
- assert_route "a failed CI run with no pull request routes nowhere" none \
219
- EVENT=workflow_run RUN_PR_NUMBER= RUN_CONCLUSION=failure RUN_ID=99
206
+ # A CI verdict moves the merge train, green or red; the train picks which pull request the gate
207
+ # takes. Dispatching the gate for the run's own pull request is how a night of them raced each
208
+ # other for the default branch.
209
+ assert_route "a failed App CI run on a pull request moves the merge train" merge-train EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=failure RUN_ID=99
210
+ assert_route "a green App CI run on a pull request moves the merge train" merge-train EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=success RUN_ID=99
211
+ assert "the train hears the CI conclusion" failure "$(route_field ci-conclusion EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=failure RUN_ID=99)"
212
+ assert "the train hears the CI run id" 99 "$(route_field ci-run-id EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=failure RUN_ID=99)"
213
+ assert_route "a cancelled App CI run moves nothing" none EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=cancelled RUN_ID=99
214
+ assert_route "a failed CI run with no pull request routes nowhere" none EVENT=workflow_run RUN_PR_NUMBER= RUN_CONCLUSION=failure RUN_ID=99
215
+ # A consumer CI hands its verdict over with GITHUB_TOKEN. Old CIs dispatch merge-gate; that is a
216
+ # CI completion, so it moves the train instead of jumping the queue. A person or the train itself
217
+ # dispatching merge-gate still reaches the gate.
218
+ assert_route "a consumer CI's merge-gate handover moves the train" merge-train EVENT=workflow_dispatch OPERATION=merge-gate INPUT_PR_NUMBER=7 ACTOR='github-actions[bot]'
219
+ assert "the handover carries its verdict to the train" success "$(route_field ci-conclusion EVENT=workflow_dispatch OPERATION=merge-gate INPUT_PR_NUMBER=7 ACTOR='github-actions[bot]' INPUT_CI_CONCLUSION=success INPUT_CI_RUN_ID=5)"
220
+ assert_route "the train's own gate dispatch reaches the gate" merge-gate EVENT=workflow_dispatch OPERATION=merge-gate INPUT_PR_NUMBER=7 ACTOR='platform-devbox[bot]'
221
+ assert_route "merge-train dispatch needs no pull request" merge-train EVENT=workflow_dispatch OPERATION=merge-train
222
+ assert "a merge-train handover carries its run id" 5 "$(route_field ci-run-id EVENT=workflow_dispatch OPERATION=merge-train INPUT_PR_NUMBER=7 INPUT_CI_CONCLUSION=failure INPUT_CI_RUN_ID=5)"
220
223
 
221
224
  echo "── Schedules ─────────────────────────────────────────────────────────────"
222
225
  while read -r cron; do
@@ -490,6 +493,14 @@ if [ "$BELT_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
490
493
  # trailing space, so a number at the end of a body is still followed by a non-digit, and the
491
494
  # pattern matches `[^0-9]`. Four quoting layers cannot eat a character class.
492
495
  SWEEP_OK=1
496
+ # The live runs come from status-filtered queries. Reading the repository's latest hundred runs of
497
+ # any kind let a long-queued implement fall out of the window, and the sweep cleared bot-working
498
+ # from issues whose runs were alive (Pliny-Bot #693 and #707).
499
+ if grep -q 'gh api "repos/\$REPO/actions/runs?per_page=100"' "$ROUTER_YML" ||
500
+ ! grep -qF 'actions/runs?status=$status&per_page=100' "$ROUTER_YML"; then
501
+ SWEEP_OK=0
502
+ echo "FAIL: the stale-reservation sweep must read live runs by status, not the latest hundred runs" >&2
503
+ fi
493
504
  if ! grep -qF 'test(\"#${issue}[^0-9]\")' "$ROUTER_YML"; then
494
505
  SWEEP_OK=0
495
506
  echo "FAIL: the stale-reservation sweep does not match a run title with a character class; a word boundary there is eaten by bash and jq" >&2
@@ -685,69 +696,90 @@ if worker_installed merge-gate; then
685
696
  # The merge belt is serial for the whole repository: several overnight pull requests
686
697
  # mean every merge moves the default branch under the rest, and gates running at once
687
698
  # rebase onto bases other gates are about to invalidate. A per-issue group here would
688
- # reintroduce that race, so assert the repo-wide lock is the one in use.
689
- if grep -A7 'call-merge-gate:' "$ROUTER_YML" | grep -q 'group: merge-belt'; then
699
+ # reintroduce that race, so assert the repo-wide lock is the one in use. The exception is a gate
700
+ # handed red CI: it repairs and pushes and can never merge, so it queues per pull request.
701
+ gate_group="$(sed -n '/^ call-merge-gate:/,/^ permissions:/p' "$ROUTER_YML" | grep 'group:')"
702
+ if grep -q "'merge-belt'" <<<"$gate_group" &&
703
+ grep -q "ci-conclusion == 'failure' && format('merge-repair-{0}', needs.classify.outputs.pr-number)" <<<"$gate_group"; then
690
704
  PASS=$((PASS + 1))
691
705
  else
692
706
  FAIL=$((FAIL + 1))
693
- echo "FAIL: call-merge-gate must hold the repo-wide merge-belt lock" >&2
707
+ echo "FAIL: call-merge-gate must queue on merge-belt, and only a gate handed red CI may queue per pull request instead" >&2
694
708
  fi
695
709
  fi
696
710
 
697
- # A verdict is the gate marker AND a `**Verdict:**` line together. Comments carrying the
698
- # marker alone were progress notes and failed attempts, and the reconcile belt read every
699
- # one of them as final: a crashed or OOM-killed gate parked its pull request for the rest
700
- # of the night. Attempts are counted separately, capped, and reset by any new CI run.
701
- # The belt lives in the router's plumbing jobs, so this holds in every repository.
702
- BELT_OK=1
703
- if ! grep -q 'agent-merge-gate-attempt' "$ROUTER_YML"; then
704
- BELT_OK=0; echo "FAIL: router never counts gate attempts" >&2
705
- fi
706
- if [ "$(count -cF 'contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))' "$ROUTER_YML")" -lt 4 ]; then
707
- BELT_OK=0; echo "FAIL: verdict detection must pair the gate marker with a Verdict line in both dispatch paths" >&2
708
- fi
709
- if [ "$(count -c 'attempts_so_far' "$ROUTER_YML")" -lt 2 ]; then
710
- BELT_OK=0; echo "FAIL: dispatch sites must forward attempts_so_far" >&2
711
- fi
712
- # A second gate for a pull request whose gate is already queued or running reads the same CI
713
- # verdict, and the belt's queue would run it twice.
714
- if [ "$(count -c 'a merge-gate run is already live' "$ROUTER_YML")" -lt 2 ]; then
715
- BELT_OK=0; echo "FAIL: both dispatch paths must skip a pull request whose gate is already live" >&2
716
- fi
717
- # A conflicting pull request has no refs/pull/N/merge for GitHub to build, so a `pull_request`
718
- # CI workflow can never run on that head. Requiring a fresh verdict before dispatching deadlocks
719
- # the belt: only the gate resolves the conflict, and the gate never runs. Both paths fall back to
720
- # the branch's last verdict when, and only when, the pull request is conflicting.
721
- if [ "$(count -c 'conflicts, so CI cannot run on' "$ROUTER_YML")" -lt 2 ]; then
722
- BELT_OK=0
723
- echo "FAIL: both dispatch paths must gate a conflicting pull request that can never get fresh CI" >&2
724
- fi
725
- # That fallback has to read the computed mergeable state. The REST boolean is null until GitHub
726
- # recomputes it, and stays null for a pull request nobody has opened recently, which is exactly
727
- # the stale conflicting pull request the fallback exists for: it never fired once in production.
728
- # The state has to be polled, not read once. GitHub computes mergeability on demand and the
729
- # first read answers UNKNOWN (or null through REST) while it works it out, so a single read
730
- # reports "not conflicting" for exactly the stale pull requests the fallback is for. Observed
731
- # twice in production: the fallback logged "no completed CI run" for a pull request that
732
- # `gh pr view` reported as CONFLICTING from a warm cache seconds later.
733
- if [ "$(count -c 'mergeable_state()' "$ROUTER_YML")" -lt 2 ] ||
734
- [ "$(count -c 'mergeable_now=$(mergeable_state' "$ROUTER_YML")" -lt 2 ]; then
735
- BELT_OK=0
736
- echo "FAIL: both dispatch paths must poll the mergeable state; a single read answers UNKNOWN" >&2
711
+ # The belt is the merge train now: one gate at a time, oldest pull request first. Its I/O lives in
712
+ # the merge-train action and every rule it learned as the belt is asserted there.
713
+ #
714
+ # A verdict is the gate marker AND a `**Verdict:**` line together. Comments carrying the marker
715
+ # alone were progress notes and failed attempts, and the belt read every one of them as final: a
716
+ # crashed or OOM-killed gate parked its pull request for the rest of the night. Attempts are
717
+ # counted separately and capped.
718
+ TRAIN_SH="${HERE}/../merge-train/merge-train.sh"
719
+ if worker_installed merge-gate; then
720
+ BELT_OK=1
721
+ if [ ! -f "$TRAIN_SH" ]; then
722
+ BELT_OK=0; echo "FAIL: the merge gate is installed without the merge-train action" >&2
723
+ else
724
+ grep -q 'agent-merge-gate-attempt' "$TRAIN_SH" ||
725
+ { BELT_OK=0; echo "FAIL: the merge train never counts gate attempts" >&2; }
726
+ grep -qF 'contains("<!-- agent-merge-gate -->")) and (.body | test("Verdict:' "$TRAIN_SH" ||
727
+ { BELT_OK=0; echo "FAIL: the train's verdict detection must pair the gate marker with a Verdict line" >&2; }
728
+ grep -qF 'inputs[attempts_so_far]=' "$TRAIN_SH" ||
729
+ { BELT_OK=0; echo "FAIL: the train must forward attempts_so_far to the gate" >&2; }
730
+ # One gate at a time, found by the title run-name gives it. The belt looked for a title that
731
+ # run-name had stopped producing, saw no live gate ever, and dispatched #680 five times in
732
+ # an hour onto a belt that runs one gate at a time.
733
+ gate_title="$(sed -n "s/.*format('\(Agent: Merge Gate - PR #\){0}{1}'.*/\1/p" "$ROUTER_YML" | head -1)"
734
+ grep -q "' (repair)'" "$ROUTER_YML" ||
735
+ { BELT_OK=0; echo "FAIL: run-name must mark a gate handed red CI as a (repair), the train counts them by it" >&2; }
736
+ if [ -z "$gate_title" ] || ! grep -qF "GATE_TITLE_PREFIX=\"${gate_title}\"" "$TRAIN_SH"; then
737
+ BELT_OK=0
738
+ echo "FAIL: the train looks for live gates titled differently from what run-name names them ('${gate_title:-unread}')" >&2
739
+ fi
740
+ # A conflicting pull request has no refs/pull/N/merge, so CI can never run on its head; the
741
+ # train hands it to the gate on the branch's last verdict rather than waiting forever.
742
+ grep -qF 'actions/runs?branch=$head_ref' "$TRAIN_SH" ||
743
+ { BELT_OK=0; echo "FAIL: the train must gate a conflicting pull request on its branch's last verdict" >&2; }
744
+ # GitHub computes mergeability on demand and the first read answers UNKNOWN.
745
+ grep -q 'mergeable=$(mergeable_state' "$TRAIN_SH" ||
746
+ { BELT_OK=0; echo "FAIL: the train must poll the mergeable state; a single read answers UNKNOWN" >&2; }
747
+ # The park is keyed to the head commit, never the CI time: a CI re-run must not reopen a park
748
+ # a person owns.
749
+ grep -qF '[ "$verdict_at" \> "$head_committed" ]' "$TRAIN_SH" ||
750
+ { BELT_OK=0; echo "FAIL: the train must key a verdict's park to the head commit" >&2; }
751
+ fi
752
+ # The App token: a dispatch or a branch update made with GITHUB_TOKEN starts no workflow.
753
+ train_job="$(sed -n '/^ merge-train:$/,/^ [a-z-]*:$/p' "$ROUTER_YML")"
754
+ if ! grep -q 'token: ${{ steps.app-token.outputs.token }}' <<<"$train_job" ||
755
+ ! grep -q 'group: merge-train' <<<"$train_job" ||
756
+ ! grep -q 'needs: \[classify, call-merge-gate\]' <<<"$train_job"; then
757
+ BELT_OK=0
758
+ echo "FAIL: the merge-train job must use the App token, hold the merge-train lock and tick after the gate" >&2
759
+ fi
760
+ if [ "$BELT_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
737
761
  fi
738
- if [ "$BELT_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
739
762
 
740
- # GitHub delivers workflow_run only for CI runs whose actor is a human, so a bot pull request's
741
- # CI never reaches the router's CI-completion route. The package ships a dispatch-merge-gate job
742
- # in templates/ci that hands the verdict over from inside CI; a consumer CI workflow, where one
743
- # exists beside the router, must carry it or bot pull requests wait for the hourly belt.
763
+ # GitHub delivers workflow_run only for CI runs whose actor is a human, so a bot-updated pull
764
+ # request's CI never reaches the router's CI-completion route. The package ships a dispatch job in
765
+ # templates/ci that hands the verdict over from inside CI; a consumer CI workflow, where one exists
766
+ # beside the router, must carry it or those pull requests wait for the hourly tick.
744
767
  for ci in "${WORKFLOWS_DIR}/ci.yml" "${WORKFLOWS_DIR}/app-ci.yml"; do
745
768
  [ -f "$ci" ] || continue
746
- if grep -q 'operation=merge-gate' "$ci"; then
769
+ if grep -qE 'operation=merge-(gate|train)' "$ci"; then
747
770
  PASS=$((PASS + 1))
748
771
  else
749
772
  FAIL=$((FAIL + 1))
750
- echo "FAIL: $(basename "$ci") has no dispatch-merge-gate job; bot pull requests would wait for the hourly belt" >&2
773
+ echo "FAIL: $(basename "$ci") hands no CI verdict to the merge train; pull requests would wait for the hourly tick" >&2
774
+ fi
775
+ done
776
+ for template in "${HERE}/../../templates/ci/"*.yml; do
777
+ [ -f "$template" ] || continue
778
+ if grep -q 'operation=merge-train' "$template" && ! grep -q "pull_request.user.type == 'Bot'" "$template"; then
779
+ PASS=$((PASS + 1))
780
+ else
781
+ FAIL=$((FAIL + 1))
782
+ echo "FAIL: $(basename "$template") must hand every same-repository pull request's verdict to the merge train" >&2
751
783
  fi
752
784
  done
753
785
 
@@ -1110,7 +1142,7 @@ done < <(sed -n '/^ operation:/,/^ issue-number:/p' "$ROUTER_YML" |
1110
1142
  # contents and issues and failed nightly on a 403 that named the endpoint and nothing else.
1111
1143
  # Paired job-to-scope rather than parsed out of each action: the jobs that touch pull
1112
1144
  # requests are few and known, and naming them here is what makes the omission visible.
1113
- for pr_job in audit-close reconcile-bot-pr-runs detect-pr-conflicts housekeeping; do
1145
+ for pr_job in audit-close reconcile-bot-pr-runs merge-train land-stack-child housekeeping; do
1114
1146
  if ! grep -q "^ ${pr_job}:$" "$ROUTER_YML"; then
1115
1147
  continue
1116
1148
  fi
@@ -1752,19 +1784,165 @@ if [ -f "$GATE_VALIDATOR" ] && worker_installed merge-gate; then
1752
1784
  if [ "$VALIDATOR_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
1753
1785
  fi
1754
1786
 
1787
+ # The gate takes any pull request the train hands it. A subject check that refuses one without
1788
+ # leaving a verdict or an attempt is a loop: the train cannot tell a gate that declined from a gate
1789
+ # that has not run. Refusals are for what the train never hands over (a fork, a stranger, a pull
1790
+ # request that is not open), and the train filters those itself.
1791
+ if worker_installed merge-gate; then
1792
+ # check-diff exiting 1 is the `fail` the code_rules job reports. Under GitHub's `bash -e` a bare
1793
+ # assignment from it ends the step, so the report is never recorded and the gate burns its
1794
+ # budget on a failure it could have repaired.
1795
+ if grep -qF 'check-diff.mjs --base "$BASE" 2>&1) || code=$?' "$MERGE_GATE_WORKER_MD"; then
1796
+ PASS=$((PASS + 1))
1797
+ else
1798
+ FAIL=$((FAIL + 1))
1799
+ echo "FAIL: code_rules must capture check-diff's exit code with || code=\$?; a bare assignment dies under bash -e" >&2
1800
+ fi
1801
+ # A run killed by its infrastructure is repeated whatever its duration; one the agent got wrong is
1802
+ # not. The two signatures are read from places a tool's output cannot write: GitHub's annotations
1803
+ # for the runner shutdown, and the line above the sandbox teardown for an engine cut off one turn
1804
+ # after it asked the model something (Pliny-Bot #699 and #693's first attempt).
1805
+ CLASSIFY="${HERE}/../decide-agent-retry/classify-failure.sh"
1806
+ if [ -f "$CLASSIFY" ]; then
1807
+ ctmp="$(mktemp -d)"
1808
+ printf '%s
1809
+ ' '2026-10-16T22:13:03Z > ci-workflow-agent · glm-5-2' '2026-10-16T22:13:05Z the grep printed a line' '2026-10-16T22:13:36Z > ci-workflow-agent · glm-5-2' '2026-10-16T22:13:37Z [INFO] Stopping containers...' >"$ctmp/cutoff.log"
1810
+ printf '%s
1811
+ ' '2026-10-16T22:13:03Z > ci-workflow-agent · glm-5-2' '2026-10-16T22:13:36Z Done. The pull request is ready.' '2026-10-16T22:13:37Z [INFO] Stopping containers...' >"$ctmp/done.log"
1812
+ printf '%s
1813
+ ' '2026-10-16T22:13:03Z the log quotes: Process completed with exit code 143 and The runner has received a shutdown signal' '2026-10-16T22:13:04Z Done.' '2026-10-16T22:13:37Z [INFO] Stopping containers...' >"$ctmp/quoted.log"
1814
+ printf '%s
1815
+ ' 'Process completed with exit code 143.' 'Property x does not exist' >"$ctmp/sigterm.ann"
1816
+ printf '%s
1817
+ ' 'Process completed with exit code 137.' >"$ctmp/sigkill.ann"
1818
+ printf '%s
1819
+ ' 'Process completed with exit code 1.' >"$ctmp/plain.ann"
1820
+ assert "an engine stopped one turn after its request is a cut-off" cut-off "$(bash "$CLASSIFY" "$ctmp/cutoff.log" "")"
1821
+ assert "a run that said its piece is not a cut-off" none "$(bash "$CLASSIFY" "$ctmp/done.log" "")"
1822
+ assert "a tool quoting the shutdown text does not count" none "$(bash "$CLASSIFY" "$ctmp/quoted.log" "")"
1823
+ assert "exit 143 in the annotations is a runner shutdown" runner-shutdown "$(bash "$CLASSIFY" "$ctmp/done.log" "$ctmp/sigterm.ann")"
1824
+ assert "exit 137 (an out-of-memory kill) is repeated by nobody" none "$(bash "$CLASSIFY" "$ctmp/done.log" "$ctmp/sigkill.ann")"
1825
+ assert "an ordinary failure is not infrastructure" none "$(bash "$CLASSIFY" "$ctmp/done.log" "$ctmp/plain.ann")"
1826
+ assert "nothing to read classifies as nothing" none "$(bash "$CLASSIFY" "" "")"
1827
+ rm -rf "$ctmp"
1828
+ fi
1829
+ # The implement worker carried its own copy of the retry rule while four other workers used the
1830
+ # shared action, so a rule fixed in one place stayed broken in the other.
1831
+ IMPLEMENT_MD="${WORKFLOWS_DIR}/agent-implement.md"
1832
+ if [ -f "$IMPLEMENT_MD" ]; then
1833
+ uses=$(count -c "uses: ./.github/actions/decide-agent-retry" "$IMPLEMENT_MD")
1834
+ if [ "$uses" -ge 2 ] && ! grep -q 'minutes=$(( ( $(date -u -d "$finished"' "$IMPLEMENT_MD"; then
1835
+ PASS=$((PASS + 1))
1836
+ else
1837
+ FAIL=$((FAIL + 1))
1838
+ echo "FAIL: agent-implement.md must decide a retry through the shared decide-agent-retry action, in both incomplete and conclude" >&2
1839
+ fi
1840
+ fi
1841
+ # Implement runs spread over IMPLEMENT_PARALLELISM queues, but the children of one refine share
1842
+ # a queue keyed on their split parent: they build on each other's code. The queue step's
1843
+ # arithmetic is exercised with the same expression the router runs.
1844
+ if grep -q 'group: implement-queue-${{ needs.check-stack-branch.outputs.implement-queue' "$ROUTER_YML" &&
1845
+ sed -n '/- name: Pick the implement queue/,/- name: Resolve the feature branch/p' "$ROUTER_YML" | grep -q 'key="${parent:-$ISSUE_NUMBER}"'; then
1846
+ PASS=$((PASS + 1))
1847
+ else
1848
+ FAIL=$((FAIL + 1))
1849
+ echo "FAIL: call-implement must queue on check-stack-branch's implement-queue, keyed on the split parent" >&2
1850
+ fi
1851
+ queue_for() { local parent="$1" issue="$2" queues="$3"; case "$queues" in ''|*[!0-9]*|0) queues=1 ;; esac; local key="${parent:-$issue}"; echo $(( key % queues )); }
1852
+ assert "siblings of one refine share a queue" "$(queue_for 690 693 3)" "$(queue_for 690 694 3)"
1853
+ assert "parallelism 1 is one queue for everything" 0 "$(queue_for "" 722 1)"
1854
+ assert "an unset or garbled parallelism is one queue" 0 "$(queue_for "" 725 abc)"
1855
+ assert "independent issues spread across the queues" 1 "$(queue_for "" 724 3)"
1856
+ # The router queues implement runs FIFO, so a run waiting an hour behind the one in progress is
1857
+ # healthy. The janitor cancelling every pending run over an hour old took every issue queued
1858
+ # behind the first out of the queue and left it holding bot-working (Pliny-Bot #706, #644).
1859
+ if [ -f "${HERE}/../housekeeping/action.yml" ] && ! grep -q "liveKinds" "${HERE}/../housekeeping/action.yml"; then
1860
+ FAIL=$((FAIL + 1))
1861
+ echo "FAIL: the janitor cancels pending runs without checking for a live run of their own kind ahead of them" >&2
1862
+ else
1863
+ PASS=$((PASS + 1))
1864
+ fi
1865
+ # The implement worker names every phase skill it must load. Left as "load each phase skill",
1866
+ # glm-5-2 loaded pc-plan-goal, wrote proposal.md and tasks.md itself and edited the code alone:
1867
+ # a hand-written tasks.md has no <!-- agent --> annotations, so pc-plan-apply had no subagents
1868
+ # to spawn and the whole change ran on one model, in sequence (Pliny-Bot #693, #699).
1869
+ IMPL_MD="${WORKFLOWS_DIR}/agent-implement.md"
1870
+ if [ -f "$IMPL_MD" ]; then
1871
+ if grep -q 'You never write' "$IMPL_MD" && grep -q '`pc-plan-propose`, even when the issue is refined' "$IMPL_MD" &&
1872
+ grep -q 'In this phase you do not edit source files' "$IMPL_MD" && grep -q '`pc-repo-verify`, then `pc-plan-archive`' "$IMPL_MD"; then
1873
+ PASS=$((PASS + 1))
1874
+ else
1875
+ FAIL=$((FAIL + 1))
1876
+ echo "FAIL: agent-implement.md must name pc-plan-propose, pc-plan-apply, pc-repo-verify and pc-plan-archive and forbid writing tasks.md by hand" >&2
1877
+ fi
1878
+ fi
1879
+ # Refine writes in English and may retitle an issue written in another language; without
1880
+ # allow_title on update-issue the title in the call is dropped and the issue keeps its language.
1881
+ REFINE_MD="${WORKFLOWS_DIR}/agent-refine.md"
1882
+ if [ -f "$REFINE_MD" ]; then
1883
+ if sed -n '/^ update-issue:/,/^ add-comment:/p' "$REFINE_MD" | grep -q '^ title:' &&
1884
+ grep -q 'Everything you write is in English' "$REFINE_MD"; then
1885
+ PASS=$((PASS + 1))
1886
+ else
1887
+ FAIL=$((FAIL + 1))
1888
+ echo "FAIL: agent-refine.md must write in English and be allowed to retitle an issue (update-issue title:)" >&2
1889
+ fi
1890
+ fi
1891
+ # A token written to the runner's global git config outlives the job on a runner that is reused:
1892
+ # rules for revoked tokens pile up for the same prefix and git picks one at random, so the same
1893
+ # gate pushed for one pull request and was refused for the next (Pliny-Bot #716 and #717).
1894
+ PUSH_ACTION="${HERE}/../push-agent-branch/action.yml"
1895
+ if [ -f "$PUSH_ACTION" ] && grep -qE 'git config( --global)? url\.' "$PUSH_ACTION" && ! grep -qF 'git config --local url.' "$PUSH_ACTION"; then
1896
+ FAIL=$((FAIL + 1))
1897
+ echo "FAIL: push-agent-branch writes its token to the global git config, where stale rules from earlier jobs accumulate" >&2
1898
+ elif [ -f "$PUSH_ACTION" ] && grep -qE 'git config --global url\.' "$PUSH_ACTION"; then
1899
+ FAIL=$((FAIL + 1))
1900
+ echo "FAIL: push-agent-branch adds to the global git config; set the rule with --local" >&2
1901
+ else
1902
+ PASS=$((PASS + 1))
1903
+ fi
1904
+ # A conclude that dies leaves no verdict and no attempt comment unless `incomplete` records it,
1905
+ # and the train then reads the run as a gate that did nothing and never offers the pull request
1906
+ # again.
1907
+ if sed -n '/^ incomplete:/,/^ runs-on:/p' "$MERGE_GATE_WORKER_MD" | grep -q "needs.conclude.result == 'failure'"; then
1908
+ PASS=$((PASS + 1))
1909
+ else
1910
+ FAIL=$((FAIL + 1))
1911
+ echo "FAIL: the gate's incomplete job must record a failed conclude as an attempt, or the train strands the pull request" >&2
1912
+ fi
1913
+ # A stack child is dispatched on its feature branch, and a dispatched run reads repository secrets
1914
+ # by their own names, not through the router's FORGE_API_KEY mapping. Without the fallback every
1915
+ # stack child died at activation on "Validate CODEX_API_KEY or OPENAI_API_KEY secret".
1916
+ COMPILE_SCRIPT="${HERE}/../../scripts/compile-agent-workflows.mjs"
1917
+ if [ -f "$COMPILE_SCRIPT" ] && ! grep -qF "secrets.OPENAI_API_KEY || secrets.FORGE_API_KEY" "$COMPILE_SCRIPT"; then
1918
+ FAIL=$((FAIL + 1))
1919
+ echo "FAIL: the compile script no longer gives the engine key a FORGE_API_KEY fallback; dispatched stack children cannot start" >&2
1920
+ else
1921
+ PASS=$((PASS + 1))
1922
+ fi
1923
+ SUBJECT_YML="${HERE}/../identify-gate-subject/action.yml"
1924
+ if grep -qE 'does not close a numbered issue|does not carry' "$SUBJECT_YML" || grep -q 'require-label' "$SUBJECT_YML" "$MERGE_GATE_WORKER_MD"; then
1925
+ FAIL=$((FAIL + 1))
1926
+ echo "FAIL: the gate's subject job refuses a pull request for lacking an issue or a label; the train hands it back forever" >&2
1927
+ else
1928
+ PASS=$((PASS + 1))
1929
+ fi
1930
+ fi
1931
+
1755
1932
  echo "── Merge gate park ───────────────────────────────────────────────────────"
1756
1933
 
1757
- # A gate verdict parks the code it was given on. Both dispatch paths -- detect-pr-conflicts and
1758
- # the reconcile belt -- used to compare the standing verdict against the CI finish time, which
1759
- # made the park worthless: any later run on the same commits was newer than the verdict, so the
1760
- # belt re-dispatched a pull request a human already owned and reset its attempt budget at the
1761
- # same time. Lyceum PR #13 sat parked for six days while that happened. Asserted because both
1762
- # comparisons are one line and neither failing produces a red run.
1934
+ # A gate verdict parks the code it was given on. The belt used to compare the standing verdict
1935
+ # against the CI finish time, which made the park worthless: any later run on the same commits was
1936
+ # newer than the verdict, so it re-dispatched a pull request a human already owned. Lyceum PR #13
1937
+ # sat parked for six days while that happened. The opposite failure is what parked Pliny-Bot for
1938
+ # whole weekends: a park that never expired, so a pull request blocked on Friday night was still
1939
+ # blocked on Monday though the default branch had moved under it a dozen times. The train's two
1940
+ # pure functions decide both, and they are exercised here rather than read.
1763
1941
  if worker_installed merge-gate; then
1764
1942
  GATE_OK=1
1765
1943
 
1766
- # Neither path may key the park to CI timing again.
1767
- stale_horizon=$(count -cE 'verdict" \\> "\$ci_finished"|latest_verdict" \\> "\$ci_finished"' "$ROUTER_YML")
1944
+ # Nothing may key the park to CI timing again.
1945
+ stale_horizon=$(count -cE 'verdict" \> "\$ci_finished"|latest_verdict" \> "\$ci_finished"' "$ROUTER_YML")
1768
1946
  if [ "$stale_horizon" -eq 0 ]; then
1769
1947
  PASS=$((PASS + 1))
1770
1948
  else
@@ -1772,13 +1950,41 @@ if worker_installed merge-gate; then
1772
1950
  echo "FAIL: the merge-gate park is keyed to the CI finish time in ${stale_horizon} place(s); a CI re-run would reopen a park a person owns" >&2
1773
1951
  fi
1774
1952
 
1775
- # Both must fall back to the CI time only when the head commit cannot be read.
1776
- horizons=$(count -cE '\$\{head_committed:-\$ci_finished\}' "$ROUTER_YML")
1777
- if [ "$horizons" -eq 2 ]; then
1778
- PASS=$((PASS + 1))
1779
- else
1780
- GATE_OK=0
1781
- echo "FAIL: ${horizons} of the 2 gate dispatch paths key their park to the head commit" >&2
1953
+ if [ -f "$TRAIN_SH" ]; then
1954
+ # Each call in its own shell: the facts are environment variables, and one case's must not
1955
+ # leak into the next.
1956
+ park() { env "$@" bash -c "source '$TRAIN_SH'; train_park"; }
1957
+ move() { env "$@" bash -c "source '$TRAIN_SH'; train_move" | sed -n 's/^move=//p'; }
1958
+
1959
+ assert "no verdict leaves the pull request to the train" none "$(park VERDICT= VERDICT_NEWER=false)"
1960
+ assert "a verdict older than the head is spent" none "$(park VERDICT=owner-review VERDICT_NEWER=false)"
1961
+ assert "owner-review on this head waits for its owner" held "$(park VERDICT=owner-review VERDICT_NEWER=true BASE_MOVED=true)"
1962
+ assert "human-review on this head waits for a person" held "$(park VERDICT=human-review VERDICT_NEWER=true BASE_MOVED=true)"
1963
+ assert "remediated never parks: the gate wants the next CI" none "$(park VERDICT=remediated VERDICT_NEWER=true)"
1964
+ assert "blocked holds while the base has not moved" held "$(park VERDICT=blocked VERDICT_NEWER=true BASE_MOVED=false RETRIES=1)"
1965
+ assert "blocked is retried once the base moves" none "$(park VERDICT=blocked VERDICT_NEWER=true BASE_MOVED=true RETRIES=1 MAX_RETRIES=3)"
1966
+ assert "blocked holds once its retries are spent" held "$(park VERDICT=blocked VERDICT_NEWER=true BASE_MOVED=true RETRIES=3 MAX_RETRIES=3)"
1967
+ assert "a merge that did not happen is retried once the base moves" none "$(park VERDICT=auto-merge VERDICT_NEWER=true BASE_MOVED=true RETRIES=1)"
1968
+ assert "a stack root is never retried into a merge" held "$(park VERDICT=auto-merge VERDICT_NEWER=true BASE_MOVED=true RETRIES=0 STACK_ROOT=true)"
1969
+
1970
+ assert "hold keeps a pull request off the train" skip "$(move HOLD=true CI_STATUS=completed CI_CONCLUSION=success)"
1971
+ assert "requested changes keep it off the train" skip "$(move REVIEW_DECISION=CHANGES_REQUESTED CI_STATUS=completed CI_CONCLUSION=success)"
1972
+ assert "a parked pull request is passed by, not waited on" skip "$(move PARK=held CI_STATUS=completed CI_CONCLUSION=success)"
1973
+ assert "a spent attempt budget is passed by" skip "$(move ATTEMPTS=6 MAX_ATTEMPTS=6 CI_STATUS=completed CI_CONCLUSION=failure)"
1974
+ # The loop that ran Pliny-Bot's gate twenty times on one pull request: the gate declined it
1975
+ # (its issue was closed), left no verdict and no attempt, and every tick handed it back.
1976
+ assert "a gate that ran and left no trace is not asked again" skip "$(move SILENT_GATE=true MERGEABLE=CONFLICTING CI_STATUS=none)"
1977
+ assert "a gate that ran and left no trace is not asked again, green or red" skip "$(move SILENT_GATE=true MERGEABLE=MERGEABLE CI_STATUS=completed CI_CONCLUSION=failure)"
1978
+ assert "a conflict goes to the gate even with no CI" gate "$(move MERGEABLE=CONFLICTING CI_STATUS=none)"
1979
+ assert "running CI is waited on" wait "$(move MERGEABLE=MERGEABLE CI_STATUS=in_progress)"
1980
+ assert "CI stuck past the limit is passed by" skip "$(move MERGEABLE=MERGEABLE CI_STATUS=queued CI_STUCK=true)"
1981
+ assert "red CI goes to the gate" gate "$(move MERGEABLE=MERGEABLE CI_STATUS=completed CI_CONCLUSION=failure)"
1982
+ assert "green but behind is brought up to date first" update "$(move MERGEABLE=MERGEABLE CI_STATUS=completed CI_CONCLUSION=success BEHIND_BY=2)"
1983
+ assert "green and current goes to the gate" gate "$(move MERGEABLE=MERGEABLE CI_STATUS=completed CI_CONCLUSION=success BEHIND_BY=0)"
1984
+ assert "no CI run is passed by" skip "$(move MERGEABLE=MERGEABLE CI_STATUS=none)"
1985
+ assert "a trusted bot's CI awaiting approval is approved" approve "$(move CI_STATUS=completed CI_CONCLUSION=action_required TRUSTED_AUTHOR=true)"
1986
+ assert "a person's CI awaiting approval is not approved by the train" skip "$(move CI_STATUS=completed CI_CONCLUSION=action_required TRUSTED_AUTHOR=false)"
1987
+ assert "a cancelled run is no verdict" skip "$(move MERGEABLE=MERGEABLE CI_STATUS=completed CI_CONCLUSION=cancelled)"
1782
1988
  fi
1783
1989
 
1784
1990
  # One cap, not four literals, and it has to match what the worker tells the reader.
@@ -2518,13 +2724,13 @@ for worker_md in "${WORKFLOWS_DIR}"/agent-*.md; do
2518
2724
  done
2519
2725
  if [ "$INPUT_WIRING_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2520
2726
 
2521
- # Several paths dispatch gates at once (the reconcile tick, CI's dispatch-merge-gate, the
2522
- # conflict rescan). Without `queue: max` GitHub keeps one pending run in the group and cancels the
2727
+ # The train dispatches one gate at a time, but a person, a merge-gate label and an old consumer CI
2728
+ # can still dispatch one beside it. Without `queue: max` GitHub keeps one pending run in the group and cancels the
2523
2729
  # rest with no comment and no recorded attempt; every dispatch still returns 204.
2524
2730
  BELT_QUEUE_OK=1
2525
2731
  belt_caller=$(awk '/^ call-merge-gate:/ { found = 1; print; next } found && /^ [a-z]/ { exit } found { print }' "$ROUTER_YML")
2526
2732
  printf '%s
2527
- ' "$belt_caller" | grep -qE '^ *group: merge-belt *$' ||
2733
+ ' "$belt_caller" | grep -qE "^ *group: .*'merge-belt'" ||
2528
2734
  { BELT_QUEUE_OK=0; echo "FAIL: the merge gate is no longer serialised on the merge-belt concurrency group" >&2; }
2529
2735
  printf '%s
2530
2736
  ' "$belt_caller" | grep -qE '^ *queue: max *$' ||
@@ -2573,7 +2779,7 @@ if worker_installed merge-gate; then
2573
2779
  echo "FAIL: the incomplete job must read one computed budget decision, not re-derive it" >&2
2574
2780
  fi
2575
2781
  # The park has to be a verdict or the belt keeps dispatching: a comment carrying the gate
2576
- # marker AND a Verdict line is what detect-pr-conflicts and the reconcile belt both park on.
2782
+ # marker AND a Verdict line is what the merge train parks on.
2577
2783
  if ! grep -A 16 "Record an unusable report as a decision" "$MERGE_GATE_WORKER_MD" |
2578
2784
  grep -q '\${{ env.GATE_MARKER }}' ||
2579
2785
  ! grep -A 16 "Record an unusable report as a decision" "$MERGE_GATE_WORKER_MD" |
@@ -57,7 +57,7 @@ flowchart TD
57
57
  wTriage("agent-triage")
58
58
  classify -.->|"cron or dispatch"| plumbing
59
59
  plumbing["deterministic jobs<br/>no model runs in any of these"] --> pJobs
60
- pJobs("bot-approve · audit-close<br/>cleanup-artifacts · validate<br/>reconcile-bot-pr-runs · detect-pr-conflicts<br/>check-stack-branch · dispatch-implement")
60
+ pJobs("bot-approve · audit-close<br/>cleanup-artifacts · validate<br/>reconcile-bot-pr-runs · merge-train · land-stack-child<br/>check-stack-branch · dispatch-implement")
61
61
  idle(("Idle<br/>run ends, ~10s, every job skipped"))
62
62
 
63
63
  classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
@@ -87,10 +87,14 @@ flowchart LR
87
87
  refine -->|"8 or more"| split("Split into children") --> refine
88
88
  refine -.->|questions| author
89
89
  implement("implement<br/>branch, PR, closes the issue") --> ci("CI")
90
- ci --> gate{"merge-gate"}
90
+ person("A person opens a PR") --> ci
91
+ ci --> train{"merge train<br/>oldest PR first,<br/>one at a time"}
92
+ train -.->|"green, behind main"| update("Merge main in") --> ci
93
+ train --> gate{"merge-gate"}
91
94
  gate -->|merge| merged(("Merged"))
92
- gate -.->|"CI failed"| fix("Fix, push, CI again") --> gate
93
- gate -.->|"risk or protected"| human(("Human review"))
95
+ merged -->|"next PR"| train
96
+ gate -.->|"CI failed or conflict"| fix("Fix, push, CI again") --> ci
97
+ gate -.->|".github, secrets<br/>or 10000+ lines"| human(("Human review"))
94
98
  review("Someone reviews the PR") --> applyReview("apply-review") --> ci
95
99
  audit("audit, weekly") -->|"files one issue"| refine
96
100
 
@@ -99,9 +103,9 @@ flowchart LR
99
103
  classDef failure fill:#fff0f0,stroke:#ef2929,stroke-width:2px,color:#8b1a1a
100
104
  classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
101
105
  classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
102
- class open,write,review,audit start
103
- class refine,split,implement,ci,fix,applyReview,author action
104
- class triage,gate decision
106
+ class open,write,review,audit,person start
107
+ class refine,split,implement,ci,fix,applyReview,author,update action
108
+ class triage,gate,train decision
105
109
  class human,closed failure
106
110
  class merged success
107
111
  ```
@@ -110,6 +114,44 @@ Audit creates work rather than consuming it, and files into `refine` rather than
110
114
  `implement`, so a report of several unrelated findings becomes one properly sized issue per finding
111
115
  instead of one pull request that has to fix them all.
112
116
 
117
+ ### The merge train
118
+
119
+ One pull request moves at a time, the lowest number first, whoever opened it. Everything else waits
120
+ untouched, so a merge never invalidates work in flight. A pull request a person has to decide on is
121
+ passed by, so it never blocks the line. The train ticks on every merge, every CI completion, after
122
+ every gate, and hourly as the fallback.
123
+
124
+ ```mermaid
125
+ flowchart TD
126
+ trainTick("A merge, a CI verdict,<br/>a finished gate, or the hour") --> trainLive
127
+ trainLive{"A gate already<br/>on the belt?"}
128
+ trainLive -.->|yes| trainWait
129
+ trainLive -->|no| trainNext
130
+ trainNext{"Oldest open PR from this repo,<br/>bot or writer, not draft,<br/>no hold label"}
131
+ trainNext -.->|"none left"| trainIdle
132
+ trainNext --> trainPark
133
+ trainPark{"Parked on this head?<br/>owner or human review,<br/>blocked with main unmoved,<br/>attempts spent"}
134
+ trainPark -.->|"yes: pass it by"| trainNext
135
+ trainPark -->|no| trainCi
136
+ trainCi{"Its CI"}
137
+ trainCi -.->|running| trainWait
138
+ trainCi -->|"green, behind main"| trainUpdate
139
+ trainCi -->|"red, conflicting,<br/>or green and current"| trainGate
140
+ trainUpdate(("Merge main in<br/>CI reruns on what it<br/>will land on"))
141
+ trainGate(("Dispatch the gate<br/>repair or squash-merge"))
142
+ trainWait(("Wait<br/>the next tick looks again"))
143
+ trainIdle(("Idle<br/>the train is empty"))
144
+
145
+ classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
146
+ classDef decision fill:#fff8e8,stroke:#c75b00,stroke-width:2px,color:#172033
147
+ classDef idle fill:#202c40,stroke:#738198,stroke-width:2px,color:#ffffff
148
+ classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
149
+ class trainTick start
150
+ class trainLive,trainNext,trainPark,trainCi decision
151
+ class trainWait,trainIdle idle
152
+ class trainUpdate,trainGate success
153
+ ```
154
+
113
155
  ### How a feature stacks
114
156
 
115
157
  When refine splits a story that carries real sequencing — a feature whose children build on each
@@ -319,8 +361,8 @@ overnight pull requests mean every merge moves the default branch under the rest
319
361
 
320
362
  ```mermaid
321
363
  flowchart TD
322
- gateStart("Work Router<br/>merge-gate route<br/>CI reported, or the hourly<br/>reconcile belt dispatched it") --> gateSubject
323
- gateSubject{"subject (rung 4)<br/>Our open PR?<br/>Closes an implement issue?"}
364
+ gateStart("Work Router<br/>merge-gate route<br/>the merge train dispatched it,<br/>oldest PR first") --> gateSubject
365
+ gateSubject{"subject (rung 4)<br/>Open PR from this repo,<br/>by the bot or a writer?"}
324
366
  gateSubject -.->|no| gateIdle
325
367
  gateSubject -->|yes| gateBranch
326
368
  gateBranch("Check out the PR branch<br/>the push is fast-forward only,<br/>so never rebase") --> gateFacts
@@ -332,14 +374,14 @@ flowchart TD
332
374
  gatePush(("Pushed<br/>CI runs again, gate re-enters"))
333
375
  gateFix -.->|"cannot fix"| gateHuman
334
376
  gateProtected{"protected_changes (rung 4b)<br/>blast radius from paths, CODEOWNERS<br/>and the size of the code that ships"}
335
- gateProtected -.->|"protected path"| gateOwner
377
+ gateProtected -.->|".github/ path"| gateOwner
336
378
  gateProtected -->|"measured low, medium or high"| gateAssess
337
379
  gateAssess("Agent (rung 5)<br/>find defects, verify them,<br/>rate recoverability") --> gateDisposition
338
380
  gateDisposition{"Disposition<br/>computed in shell from<br/>the facts and the report"}
339
381
  gateDisposition -->|"no verified blocker,<br/>radius low or recoverable medium"| gateRootCheck
340
382
  gateRootCheck{"PR base is a<br/>stack feature branch<br/>and this PR is the root?"}
341
383
  gateRootCheck -->|"child PR: no"| gateMerge
342
- gateDisposition -.->|"radius high, protected<br/>or owner path"| gateOwner
384
+ gateDisposition -.->|"10000+ lines, .github/<br/>or a secret"| gateOwner
343
385
  gateDisposition -.->|"fragile, unmet criteria<br/>or low confidence"| gateHuman
344
386
  gateDisposition -.->|"CI red or a verified<br/>high finding"| gateBlocked
345
387
  gateMerge(("AUTO-MERGE<br/>squash into the PR's base<br/>(main, or the feature branch:<br/>then the child issue closes,<br/>pr-pending removed)"))
@@ -350,8 +392,8 @@ flowchart TD
350
392
  gateOwner --> gatePreview
351
393
  gatePreview("operation=evidence<br/>screenshots of the head<br/>before the person decides") --> gateApproved
352
394
  gateApproved(("Approved by a writer<br/>merge-approved merges<br/>the approved commit"))
353
- gateBlocked(("BLOCKED<br/>review + blocked,<br/>the belt does not retry"))
354
- gateIdle(("Idle<br/>not ours, or not open"))
395
+ gateBlocked(("BLOCKED<br/>review + blocked, the train<br/>retries it when main moves"))
396
+ gateIdle(("Idle<br/>a fork, a stranger,<br/>or not open"))
355
397
  gateFix -.->|"attempt 6 of 6"| gateBlocked
356
398
 
357
399
  classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033