@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.
@@ -65,7 +65,14 @@ run-name: >-
65
65
  && format('Agent: Implement - #{0}', inputs.issue-number)
66
66
  || github.event_name == 'workflow_dispatch'
67
67
  && inputs.operation == 'merge-gate'
68
- && format('Agent: Merge Gate - PR #{0}', inputs.pr-number)
68
+ && github.actor == 'github-actions[bot]'
69
+ && format('Agentics: Merge Train - CI on PR #{0}', inputs.pr-number)
70
+ || github.event_name == 'workflow_dispatch'
71
+ && inputs.operation == 'merge-gate'
72
+ && format('Agent: Merge Gate - PR #{0}{1}', inputs.pr-number, inputs.ci-conclusion == 'failure' && ' (repair)' || '')
73
+ || github.event_name == 'workflow_dispatch'
74
+ && inputs.operation == 'merge-train'
75
+ && format('Agentics: Merge Train{0}', '')
69
76
  || github.event_name == 'workflow_dispatch'
70
77
  && inputs.operation == 'evidence'
71
78
  && format('Agentics: Evidence - PR #{0}', inputs.pr-number)
@@ -144,6 +151,24 @@ env:
144
151
  # merge-gate worker carried its own MAX_ATTEMPTS; the route matrix now asserts the two
145
152
  # agree, because a belt that gives up later than the worker says burns whole runs.
146
153
  MAX_GATE_ATTEMPTS: "6"
154
+ # The merge train takes the oldest open pull request, bot or person, and moves it one step at a
155
+ # time until it merges or a person has to decide. A pull request with this label stays off it.
156
+ MERGE_TRAIN_HOLD_LABEL: "hold"
157
+ # How many times a `blocked` verdict, or a merge that did not happen, is retried once the base
158
+ # branch has moved. After that the pull request waits for a person or a new commit.
159
+ MERGE_TRAIN_MAX_RETRIES: "3"
160
+ # "true" logs the move the train would make and makes none. For trying the train on a
161
+ # repository before letting it act.
162
+ MERGE_TRAIN_DRY_RUN: "false"
163
+ # How many pull requests the merge train may have in repair at once. A repair moves nothing on
164
+ # the default branch, so repairs of different pull requests do not race; only merges are serial.
165
+ # 1 repairs one pull request at a time.
166
+ MERGE_TRAIN_REPAIRS: "1"
167
+ # How many implement runs may work at once. Each queue is FIFO and serial. An issue joins the
168
+ # queue of its split parent when it has one, so the children of one refine never run side by
169
+ # side on the code they share; independent issues spread across the queues. 1 is one queue
170
+ # for everything, as before this existed. Raise it only with a fleet that has the VMs.
171
+ IMPLEMENT_PARALLELISM: "1"
147
172
  # Whether a merged pull request's verification plan is replayed and its screenshots archived on
148
173
  # the pull request (evidence.yml). It needs nothing configured: the app starts with the root
149
174
  # `pnpm run dev`, and a pull request with no plan costs one API call. Set it to "false" for a
@@ -161,25 +186,22 @@ on:
161
186
  types: [submitted]
162
187
 
163
188
  pull_request_target:
164
- # opened / reopened / synchronize feed conflict detection (detect-pr-conflicts) and
165
- # bot-approve. closed feeds detect-pr-conflicts: when a PR merges, the target branch
166
- # moved and previously-mergeable sibling bot PRs may now conflict. A merge also routes to
167
- # evidence, which replays the pull request's verification plan.
189
+ # opened / reopened / synchronize feed bot-approve. closed feeds the merge train: a merge
190
+ # moved the base branch, so the next pull request in line can move. A merge into a feature
191
+ # branch also lands a stack child (land-stack-child), and every merge routes to evidence,
192
+ # which replays the pull request's verification plan.
168
193
  types: [opened, synchronize, reopened, labeled, closed]
169
194
 
170
- # CI completion on a pull request. The classifier admits only conclusion == failure with an
171
- # attached pull request; a green run reaches the gate through the consumer CI's
172
- # dispatch-merge-gate job and the reconcile belt, so routing success here would double-fire
173
- # the gate on every passing PR.
195
+ # CI completion on a pull request moves the merge train, green or red: the train decides
196
+ # whether that pull request is the one in front, and what it needs.
174
197
  #
175
198
  # Delivery (verified 2026-09-06 in Pliny-Bot): GitHub delivers workflow_run only for CI runs
176
- # whose actor is a human. A bot pull request is opened with GITHUB_TOKEN, the CI runs that
177
- # follow have the bot as their actor, and no workflow_run arrives for any of them (four
178
- # human-actor runs produced four router runs; three bot-actor runs produced none). For bot
179
- # pull requests the consumer CI's dispatch-merge-gate job (templates/ci) and the hourly
180
- # reconcile-bot-pr-runs belt are the paths. Security: workflow_run runs from the default
181
- # branch with secrets, but the gate's subject job rejects any PR that is not open and
182
- # bot-authored, so a fork PR's failing CI cannot start privileged work.
199
+ # whose actor is a human. A pull request opened or updated with a bot token has the bot as the
200
+ # actor of every CI run that follows, and no workflow_run arrives for any of them. For those
201
+ # the consumer CI's dispatch job (templates/ci) and the hourly schedule are the paths.
202
+ # Security: workflow_run runs from the default branch with secrets, but the train only ever
203
+ # moves pull requests from this repository opened by a trusted bot or someone with write
204
+ # access, and the gate's subject job checks the same again.
183
205
  workflow_run:
184
206
  # Mirrored from env.CI_WORKFLOW_NAME by the installer. A trigger evaluates no expression.
185
207
  workflows: ["App: CI"]
@@ -205,6 +227,7 @@ on:
205
227
  - triage
206
228
  - apply-review
207
229
  - merge-gate
230
+ - merge-train
208
231
  - evidence
209
232
  - audit
210
233
  - audit-close
@@ -508,7 +531,29 @@ jobs:
508
531
  stack-branch: ${{ steps.stack.outputs.stack-branch }}
509
532
  stack-in-flight: ${{ steps.inflight.outputs.stack-in-flight }}
510
533
  stack-blocked: ${{ steps.blocked.outputs.stack-blocked }}
534
+ implement-queue: ${{ steps.queue.outputs.queue }}
511
535
  steps:
536
+ # Which implement queue this issue waits in. The children of one refine share a queue,
537
+ # keyed on their split parent, because they build on each other's code and two of them at
538
+ # once would each cut from a main the other is about to change. Anything else is keyed on
539
+ # its own number. A body that cannot be read keys on the issue: a wrong queue only costs
540
+ # parallelism, never order within a feature.
541
+ - name: Pick the implement queue
542
+ id: queue
543
+ env:
544
+ GH_TOKEN: ${{ github.token }}
545
+ REPO: ${{ github.repository }}
546
+ ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
547
+ run: |
548
+ set -euo pipefail
549
+ queues="${IMPLEMENT_PARALLELISM:-1}"
550
+ case "$queues" in ''|*[!0-9]*|0) queues=1 ;; esac
551
+ body=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')
552
+ parent=$(printf '%s' "$body" | grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
553
+ key="${parent:-$ISSUE_NUMBER}"
554
+ queue=$(( key % queues ))
555
+ echo "queue=$queue" >> "$GITHUB_OUTPUT"
556
+ echo "#$ISSUE_NUMBER waits in implement queue $queue of $queues (keyed on #$key)."
512
557
  - name: Resolve the feature branch from the stack markers
513
558
  id: stack
514
559
  env:
@@ -565,8 +610,8 @@ jobs:
565
610
  # blocked by #111, modules) was dispatched alongside #111 and would have built units on a
566
611
  # feat/45 with no modules. The in-flight check below cannot catch that: both routers ran
567
612
  # before either dispatch existed, and serialising runs would still start #112 before #111's
568
- # pull request merged. The landing of the blocker releases the child (the stack block in
569
- # detect-pr-conflicts). Held, it keeps `implement` and loses `bot-working`, so it reads as
613
+ # pull request merged. The landing of the blocker releases the child (land-stack-child).
614
+ # Held, it keeps `implement` and loses `bot-working`, so it reads as
570
615
  # waiting rather than stuck.
571
616
  - name: Hold a child whose blockers have not landed
572
617
  id: blocked
@@ -631,8 +676,10 @@ jobs:
631
676
  needs: [classify, authorize, check-implement-pr, check-stack-branch]
632
677
  if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true' && needs.check-stack-branch.outputs.is-stack != 'true'
633
678
  uses: ./.github/workflows/agent-implement.lock.yml
679
+ # One FIFO queue per IMPLEMENT_PARALLELISM slot, chosen by check-stack-branch: the children of
680
+ # one refine always share a queue, independent issues spread across them.
634
681
  concurrency:
635
- group: implement-global
682
+ group: implement-queue-${{ needs.check-stack-branch.outputs.implement-queue || '0' }}
636
683
  cancel-in-progress: false
637
684
  # FIFO, up to 100 waiting. Without this the default `queue: single` cancels whatever
638
685
  # was pending, so labelling six issues at once implements one and drops the rest.
@@ -768,9 +815,9 @@ jobs:
768
815
  # The merge belt is one lock for the whole repository, not one group per pull request.
769
816
  # A batch of six overnight pull requests means every merge moves the default branch
770
817
  # under the other five; gates running at once would each rebase onto a base another
771
- # gate is about to invalidate. Serialising the belt means each gate sees a main that
772
- # only changes between gates, every merge re-arms the siblings through
773
- # detect-pr-conflicts's rescan, and the queue drains one pull request at a time.
818
+ # gate is about to invalidate. The merge train dispatches one gate at a time, oldest pull
819
+ # request first; the lock is what still holds when a person or a consumer CI dispatches one
820
+ # beside it.
774
821
  #
775
822
  # `queue: max` keeps up to 100 gates waiting in order. Without it GitHub keeps one pending run
776
823
  # per group and silently cancels the previous one: in one week Numa cancelled 12 gates and
@@ -779,8 +826,12 @@ jobs:
779
826
  needs: classify
780
827
  if: needs.classify.outputs.route == 'merge-gate'
781
828
  uses: ./.github/workflows/agent-merge-gate.lock.yml
829
+ # A gate that was handed red CI repairs and pushes: it can never merge (the validator blocks a
830
+ # non-success conclusion), so it moves nothing on the default branch and cannot race another
831
+ # gate. Those run in a group of their own per pull request, in parallel, while everything that
832
+ # may merge still waits its turn on the one belt.
782
833
  concurrency:
783
- group: merge-belt
834
+ group: ${{ needs.classify.outputs.ci-conclusion == 'failure' && format('merge-repair-{0}', needs.classify.outputs.pr-number) || 'merge-belt' }}
784
835
  cancel-in-progress: false
785
836
  queue: max
786
837
  permissions: write-all
@@ -798,7 +849,65 @@ jobs:
798
849
  BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
799
850
  AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
800
851
 
801
- # The decision the gate parked for: a person with write access approves a bot pull request the
852
+ # The merge train: one step for the oldest open pull request that can move, bot or person.
853
+ # It merges the base branch into a green pull request that fell behind, hands a red,
854
+ # conflicting or green-and-current one to the gate, and skips one a person has to decide on,
855
+ # so a parked pull request never blocks the line. One gate in flight at a time; everything
856
+ # else waits untouched. It ticks on every merge, every CI completion, after every gate (this
857
+ # job waits for call-merge-gate in the same run), and hourly as the fallback. Deterministic:
858
+ # it reads GitHub and dispatches, and never checks out or runs pull request code.
859
+ #
860
+ # One tick at a time. A pending tick replaced by a newer one loses nothing: each reads the
861
+ # whole repository afresh.
862
+ merge-train:
863
+ needs: [classify, call-merge-gate]
864
+ if: >
865
+ always() &&
866
+ needs.classify.result == 'success' &&
867
+ !github.event.repository.fork &&
868
+ (
869
+ needs.classify.outputs.route == 'merge-train' ||
870
+ needs.classify.outputs.route == 'merge-gate' ||
871
+ needs.classify.outputs.route == 'reconcile-bot-pr-runs' ||
872
+ (github.event_name == 'pull_request_target' &&
873
+ github.event.action == 'closed' &&
874
+ github.event.pull_request.merged == true)
875
+ )
876
+ runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
877
+ timeout-minutes: 15
878
+ concurrency:
879
+ group: merge-train
880
+ cancel-in-progress: false
881
+ permissions:
882
+ contents: read
883
+ pull-requests: read
884
+ steps:
885
+ - name: Checkout workflow actions
886
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
887
+ with:
888
+ persist-credentials: false
889
+ # The App token, not GITHUB_TOKEN: a gate dispatch and a branch update made with
890
+ # GITHUB_TOKEN start no workflow, so the gate would never run and CI never rerun.
891
+ - name: Generate App token
892
+ id: app-token
893
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
894
+ with:
895
+ app-id: ${{ secrets.BOT_APP_ID }}
896
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
897
+ - uses: ./.github/actions/merge-train
898
+ with:
899
+ token: ${{ steps.app-token.outputs.token }}
900
+ ci-workflow-name: ${{ env.CI_WORKFLOW_NAME }}
901
+ trusted-bots: ${{ env.TRUSTED_BOTS }}
902
+ hold-label: ${{ env.MERGE_TRAIN_HOLD_LABEL }}
903
+ max-gate-attempts: ${{ env.MAX_GATE_ATTEMPTS }}
904
+ max-retries: ${{ env.MERGE_TRAIN_MAX_RETRIES }}
905
+ max-repairs: ${{ env.MERGE_TRAIN_REPAIRS }}
906
+ dry-run: ${{ env.MERGE_TRAIN_DRY_RUN }}
907
+ ci-run-id: ${{ needs.classify.outputs.ci-run-id }}
908
+ ci-conclusion: ${{ needs.classify.outputs.ci-conclusion }}
909
+
910
+ # The decision the gate parked for: a person with write access approves a pull request the
802
911
  # gate handed to them (human-review or owner-review), and the bot merges the commit they
803
912
  # approved. Re-running the gate would only reproduce the same parked verdict.
804
913
  merge-approved:
@@ -841,7 +950,15 @@ jobs:
841
950
 
842
951
  read -r state author head_sha < <(gh api "repos/$REPO/pulls/$PR" --jq '[.state, .user.login, .head.sha] | @tsv')
843
952
  [ "$state" = open ] || skip "it is $state"
844
- case " $TRUSTED_BOTS " in *" $author "*) ;; *) skip "it was opened by $author, not the bot" ;; esac
953
+ # The gate parks pull requests by people too. Its author needs write access, like
954
+ # everyone the merge train carries; a bot needs to be one this router trusts.
955
+ case " $TRUSTED_BOTS " in
956
+ *" $author "*) ;;
957
+ *)
958
+ author_permission=$(gh api "repos/$REPO/collaborators/$author/permission" --jq '.permission' 2>/dev/null || echo none)
959
+ case "$author_permission" in admin|maintain|write) ;; *) skip "it was opened by $author, who has $author_permission access" ;; esac
960
+ ;;
961
+ esac
845
962
  [ "$head_sha" = "$REVIEW_COMMIT" ] || skip "$REVIEWER approved ${REVIEW_COMMIT:0:7}, the head is now ${head_sha:0:7}"
846
963
 
847
964
  # Another reviewer's change request still stands.
@@ -969,41 +1086,23 @@ jobs:
969
1086
  gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
970
1087
  done
971
1088
 
972
- # Conflict watcher for bot pull requests. Merge-conflict state is only available from the
973
- # mergeability API, which the pure classifier cannot read, so this is an event-gated
974
- # deterministic dispatcher rather than a classify route (it runs alongside bot-approve on
975
- # the same pull_request_target event). It NEVER checks out or runs pull request code - it
976
- # reads mergeability through the API and dispatches the Merge Gate - so it is safe on
977
- # pull_request_target. Two entry points:
978
- # opened / reopened / synchronize → check the pull request that changed. Gated to this
979
- # repository's own branches so a fork PR never starts this App-token job.
980
- # closed & merged → the target branch moved, so re-check every open
981
- # pull request that may have just become conflicted (the "main changed after another PR
982
- # merged" case). A merge into the default branch is an inherently trusted internal event.
983
- # It dispatches only for open, non-draft pull requests whose head is this
984
- # repository AND whose CI has already concluded (dispatching while CI is still pending would
985
- # make the gate read a 'pending' verdict and prematurely ask for human review; that PR is
986
- # covered by the CI-completion route instead). The gate's subject job re-validates ownership
987
- # before any agent runs.
988
- detect-pr-conflicts:
1089
+ # A stack child landing. A pull request merged into a feature branch rather than the default
1090
+ # branch is one child of a stack: it may unblock siblings that waited on it, and it may be the
1091
+ # last one, which makes the root pull request ready for a person. Event-gated rather than a
1092
+ # route because a merge already routes to evidence. It NEVER checks out or runs pull request
1093
+ # code; it reads issues and pull requests through the API and writes labels and one comment.
1094
+ land-stack-child:
989
1095
  needs: classify
990
1096
  if: >
991
1097
  github.event_name == 'pull_request_target' &&
992
- (
993
- (github.event.action == 'closed' && github.event.pull_request.merged == true) ||
994
- (
995
- (github.event.action == 'opened' ||
996
- github.event.action == 'reopened' ||
997
- github.event.action == 'synchronize') &&
998
- github.event.pull_request.head.repo.full_name == github.repository
999
- )
1000
- )
1098
+ github.event.action == 'closed' &&
1099
+ github.event.pull_request.merged == true &&
1100
+ github.event.pull_request.base.ref != github.event.repository.default_branch
1001
1101
  runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
1002
1102
  timeout-minutes: 10
1003
1103
  permissions:
1004
1104
  contents: read
1005
1105
  pull-requests: read
1006
- actions: write
1007
1106
  steps:
1008
1107
  - name: Checkout for App token action
1009
1108
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -1015,309 +1114,146 @@ jobs:
1015
1114
  with:
1016
1115
  app-id: ${{ secrets.BOT_APP_ID }}
1017
1116
  private-key: ${{ secrets.BOT_PRIVATE_KEY }}
1018
- - name: Dispatch Merge Gate for conflicting bot pull requests
1117
+ - name: Release unblocked children and announce a complete stack
1019
1118
  env:
1020
1119
  GH_TOKEN: ${{ steps.app-token.outputs.token }}
1021
1120
  REPO: ${{ github.repository }}
1022
1121
  DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
1023
- ACTION: ${{ github.event.action }}
1024
1122
  EVENT_PR: ${{ github.event.pull_request.number }}
1025
1123
  run: |
1026
1124
  set -euo pipefail
1027
1125
 
1028
- # Dispatch the Merge Gate for one pull request when it is a red-CI open, non-draft
1029
- # pull request on this repository whose CI has concluded - the
1030
- # conflict case is one arm of that, because a conflicting PR never gets a fresh
1031
- # CI run of its own; a merge conflict with red or concluded CI is the same
1032
- # "needs the gate" signal. A green non-conflicting PR is left for the merge belt
1033
- # to pick up when its checks finish (bot-approve / reconcile).
1034
- # GitHub computes mergeability on demand. The first read of a pull request nobody has
1035
- # opened recently answers UNKNOWN through the API and null through REST, while it works
1036
- # the answer out in the background. Reading once therefore reports "not conflicting"
1037
- # for precisely the stale conflicting pull requests that need the gate. Ask until it
1038
- # settles.
1039
- mergeable_state() {
1040
- local pr="$1" state=UNKNOWN
1041
- for _ in 1 2 3 4 5 6; do
1042
- state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
1043
- [ "$state" = "UNKNOWN" ] || break
1044
- sleep 5
1045
- done
1046
- echo "$state"
1047
- }
1048
-
1049
- # A gate already queued or running for this pull request will read the same CI verdict;
1050
- # a second dispatch would wait in the belt's queue and run the same gate twice. Same
1051
- # live-run test as the reconcile belt's stale-reservation sweep, keyed on the exact
1052
- # title run-name gives a gate dispatch.
1053
- gate_is_live() {
1054
- local pr="$1" status live
1055
- for status in queued in_progress waiting pending; do
1056
- live=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
1057
- --jq "[.workflow_runs[] | select(.display_title == \"Working (Merge Gate): PR #${pr}\")] | length" 2>/dev/null || echo 0)
1058
- [ "${live:-0}" -eq 0 ] || return 0
1059
- done
1060
- return 1
1061
- }
1062
-
1063
- maybe_dispatch() {
1064
- pr="$1"
1065
- [ -n "$pr" ] || return 0
1066
-
1067
- meta=$(gh api "repos/$REPO/pulls/$pr" \
1068
- --jq '{state:.state, draft:.draft, bot:.user.type, head:(.head.repo.full_name // "")}' \
1069
- 2>/dev/null || echo '')
1070
- [ -n "$meta" ] || return 0
1071
- if [ "$(jq -r '.state' <<<"$meta")" != "open" ] \
1072
- || [ "$(jq -r '.draft' <<<"$meta")" != "false" ] \
1073
- || [ "$(jq -r '.head' <<<"$meta")" != "$REPO" ]; then
1074
- return 0
1075
- fi
1076
-
1077
- # Only dispatch when CI has already concluded. A conflict while CI is still
1078
- # running is handled by the CI-completion route; dispatching now would make the
1079
- # gate read a 'pending' conclusion and prematurely ask for human review.
1080
- head_sha=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.sha')
1081
- ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
1082
- --jq '[.workflow_runs[]
1083
- | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
1084
- and (.conclusion == "success" or .conclusion == "failure"))]
1085
- | sort_by(.created_at) | last // empty')
1086
- if [ -z "$ci" ]; then
1087
- # A conflicting pull request has no merge ref for GitHub to build, so a
1088
- # `pull_request` CI workflow can never run on that head: refs/pull/N/merge is a
1089
- # 404 for as long as the conflict lasts. Waiting for a verdict that cannot arrive
1090
- # deadlocks the belt, because resolving the conflict is the gate's own job. Fall
1091
- # back to the branch's last verdict so the gate runs; it stays on the remediation
1092
- # path and cannot merge on a stale conclusion.
1093
- head_ref=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.ref')
1094
- mergeable_now=$(mergeable_state "$pr")
1095
- if [ "$mergeable_now" != "CONFLICTING" ]; then
1096
- echo "PR #$pr has no concluded CI run; deferring to the CI-completion route."
1097
- return 0
1098
- fi
1099
- ci=$(gh api "repos/$REPO/actions/runs?branch=$head_ref&per_page=100" \
1100
- --jq '[.workflow_runs[]
1101
- | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
1102
- and (.conclusion == "success" or .conclusion == "failure"))]
1103
- | sort_by(.created_at) | last // empty')
1104
- if [ -z "$ci" ]; then
1105
- echo "PR #$pr conflicts and has no CI verdict on any head; nothing to gate on yet."
1106
- return 0
1107
- fi
1108
- echo "PR #$pr conflicts, so CI cannot run on $head_sha at all; gating on the branch's last verdict."
1109
- fi
1110
- ci_conclusion=$(jq -r '.conclusion' <<<"$ci")
1111
- ci_finished=$(jq -r '.updated_at' <<<"$ci")
1112
- ci_run_id=$(jq -r '.id' <<<"$ci")
1113
-
1114
- # GitHub computes mergeability asynchronously; poll until it is known (not null).
1115
- mergeable=""
1116
- for _ in 1 2 3 4 5 6; do
1117
- mergeable=$(gh api "repos/$REPO/pulls/$pr" --jq '.mergeable // "null"' 2>/dev/null || echo null)
1118
- [ "$mergeable" = "null" ] || break
1119
- sleep 5
1120
- done
1121
-
1122
- if [ "$mergeable" != "false" ] && [ "$ci_conclusion" != "failure" ]; then
1123
- # Mergeable and green: the reconcile belt and the PR's own checks finish it.
1124
- return 0
1125
- fi
1126
-
1127
- # A terminal verdict newer than this CI run means a gate already decided;
1128
- # failed attempts (the attempt marker) deliberately do not count.
1129
- verdict=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" \
1130
- --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
1131
- linked_issue=$(gh pr view "$pr" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
1132
- if [ -n "$linked_issue" ]; then
1133
- issue_verdict=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
1134
- --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
1135
- if [ -n "$issue_verdict" ] && { [ -z "$verdict" ] || [ "$issue_verdict" \> "$verdict" ]; }; then
1136
- verdict="$issue_verdict"
1137
- fi
1138
- fi
1139
- # A verdict parks the CODE it was given on, not the CI run that prompted it.
1140
- # Comparing against the CI finish time meant any later run on the same commits
1141
- # reopened a park a human already owned. The newest commit on the branch is the
1142
- # right key: push, and the gate decides again; re-run CI, and the park holds.
1143
- head_committed=$(gh pr view "$pr" --repo "$REPO" \
1144
- --json commits --jq '[.commits[].committedDate] | max // empty' 2>/dev/null || true)
1145
- horizon="${head_committed:-$ci_finished}"
1146
- if [ -n "$verdict" ] && [ "$verdict" \> "$horizon" ]; then
1147
- echo "PR #$pr has a gate verdict newer than its head commit; the park holds, skipping."
1148
- return 0
1149
- fi
1150
-
1151
- attempts=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" \
1152
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$horizon"'") ] | length')
1153
- if [ -n "$linked_issue" ]; then
1154
- issue_attempts=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
1155
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$horizon"'") ] | length')
1156
- attempts=$((attempts + issue_attempts))
1157
- fi
1158
- if [ "$attempts" -ge "$MAX_GATE_ATTEMPTS" ]; then
1159
- echo "PR #$pr already consumed $attempts gate attempts on this head, parking for review."
1160
- return 0
1161
- fi
1162
- if gate_is_live "$pr"; then
1163
- echo "PR #$pr: a merge-gate run is already live; not dispatching another."
1164
- return 0
1165
- fi
1166
-
1167
- echo "PR #$pr is $([ "$mergeable" = "false" ] && echo conflicting || echo red-CI) (CI $ci_conclusion); dispatching Merge Gate (attempt $((attempts + 1)) of $MAX_GATE_ATTEMPTS)."
1168
- gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
1169
- --method POST \
1170
- -f ref="$DEFAULT_BRANCH" \
1171
- -f "inputs[operation]=merge-gate" \
1172
- -f "inputs[pr-number]=$pr" \
1173
- -f "inputs[ci-conclusion]=$ci_conclusion" \
1174
- -f "inputs[ci-run-id]=$ci_run_id" \
1175
- -f "inputs[attempts_so_far]=$attempts"
1176
- }
1177
-
1178
- if [ "$ACTION" = "closed" ]; then
1179
- # The target branch just moved. Any previously-mergeable bot PR may now conflict.
1180
- # The just-merged PR is now closed and is skipped by maybe_dispatch's state check.
1181
- gh api "repos/$REPO/pulls?state=open&per_page=100" \
1182
- --jq '.[] | select(.user.type == "Bot" and .draft == false) | .number' |
1183
- while IFS= read -r pr; do
1184
- maybe_dispatch "$pr"
1185
- done
1186
-
1187
- # Stacked delivery: the merged pull request merged into a feature branch, not the
1188
- # default branch, so this is a child landing. If the feature branch has no open
1189
- # children left and its root pull request is green, the stack is complete and a
1190
- # comment on the root pull request tells the human it is ready for the root merge.
1191
- # Repos without stack markers never reach the interesting part: the event PR's base
1192
- # was the default branch, so the guard below fails immediately. Fully idempotent:
1193
- # the completion comment carries its own marker, and a run that finds it already
1194
- # present exits without writing anything.
1195
- event_base=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.base.ref // empty' 2>/dev/null || echo '')
1196
- if [ -n "$event_base" ] && [ "$event_base" != "$DEFAULT_BRANCH" ]; then
1197
- echo "PR #$EVENT_PR merged into a feature branch; checking the stack."
1198
- # The implement worker's pull request closes exactly its own child issue, so
1199
- # the closed issue comes from the linked closing references (the resolved form
1200
- # GitHub computes whatever keyword the agent wrote), with the body text as
1201
- # fallback for a PR created before that guarantee.
1202
- closed_issue=$(gh pr view "$EVENT_PR" --repo "$REPO" --json closingIssuesReferences \
1203
- --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
1204
- [ -n "$closed_issue" ] || closed_issue=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.body // ""' \
1205
- | grep -oiE '(closes?|fixes?|resolves?) #[0-9]+' | head -1 | grep -oE '[0-9]+' || true)
1206
- if [ -z "$closed_issue" ]; then
1207
- echo "PR #$EVENT_PR closes no issue; not a stack child. Nothing to do."
1126
+ # Stacked delivery: the merged pull request merged into a feature branch, not the
1127
+ # default branch, so this is a child landing. If the feature branch has no open
1128
+ # children left and its root pull request is green, the stack is complete and a
1129
+ # comment on the root pull request tells the human it is ready for the root merge.
1130
+ # Repos without stack markers never reach the interesting part: the event PR's base
1131
+ # was the default branch, so the guard below fails immediately. Fully idempotent:
1132
+ # the completion comment carries its own marker, and a run that finds it already
1133
+ # present exits without writing anything.
1134
+ event_base=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.base.ref // empty' 2>/dev/null || echo '')
1135
+ if [ -n "$event_base" ] && [ "$event_base" != "$DEFAULT_BRANCH" ]; then
1136
+ echo "PR #$EVENT_PR merged into a feature branch; checking the stack."
1137
+ # The implement worker's pull request closes exactly its own child issue, so
1138
+ # the closed issue comes from the linked closing references (the resolved form
1139
+ # GitHub computes whatever keyword the agent wrote), with the body text as
1140
+ # fallback for a PR created before that guarantee.
1141
+ closed_issue=$(gh pr view "$EVENT_PR" --repo "$REPO" --json closingIssuesReferences \
1142
+ --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
1143
+ [ -n "$closed_issue" ] || closed_issue=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.body // ""' \
1144
+ | grep -oiE '(closes?|fixes?|resolves?) #[0-9]+' | head -1 | grep -oE '[0-9]+' || true)
1145
+ if [ -z "$closed_issue" ]; then
1146
+ echo "PR #$EVENT_PR closes no issue; not a stack child. Nothing to do."
1147
+ else
1148
+ # Refine stamps the split-parent marker on every child body it hands over, so
1149
+ # the child's own body names the feature issue. The reverse sub-issues
1150
+ # listing has no equivalent per-child lookup: the sub-issues API only walks
1151
+ # downward from the parent.
1152
+ parent=$(printf '%s' "$(gh issue view "$closed_issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
1153
+ | grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
1154
+ if [ -z "$parent" ]; then
1155
+ echo "#$closed_issue is not a stack child issue (no parent found); nothing stack-related to do."
1208
1156
  else
1209
- # Refine stamps the split-parent marker on every child body it hands over, so
1210
- # the child's own body names the feature issue. The reverse sub-issues
1211
- # listing has no equivalent per-child lookup: the sub-issues API only walks
1212
- # downward from the parent.
1213
- parent=$(printf '%s' "$(gh issue view "$closed_issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
1214
- | grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
1215
- if [ -z "$parent" ]; then
1216
- echo "#$closed_issue is not a stack child issue (no parent found); nothing stack-related to do."
1157
+ root_pr=$(printf '%s' "$(gh issue view "$parent" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
1158
+ | grep -oE '<!-- stack-root-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
1159
+ # Case-insensitive on purpose, here and in the child count below: `gh pr view` and
1160
+ # `gh issue view` read GraphQL and answer OPEN, where the REST API answers open. An
1161
+ # exact `open` never matched, so no root was ever open and no child was ever counted
1162
+ # open, and the stack-complete announcement could not fire (Lyceum #44, router run
1163
+ # 38752856518: "Stack #44 has no open root pull request" with #70 open).
1164
+ root_open=false
1165
+ [ -n "$root_pr" ] && root_open=$(gh pr view "$root_pr" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open && echo true || echo false)
1166
+ if [ "$root_open" != true ]; then
1167
+ echo "Stack #$parent has no open root pull request; nothing to announce."
1217
1168
  else
1218
- root_pr=$(printf '%s' "$(gh issue view "$parent" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
1219
- | grep -oE '<!-- stack-root-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
1220
- # Case-insensitive on purpose, here and in the child count below: `gh pr view` and
1221
- # `gh issue view` read GraphQL and answer OPEN, where the REST API answers open. An
1222
- # exact `open` never matched, so no root was ever open and no child was ever counted
1223
- # open, and the stack-complete announcement could not fire (Lyceum #44, router run
1224
- # 38752856518: "Stack #44 has no open root pull request" with #70 open).
1225
- root_open=false
1226
- [ -n "$root_pr" ] && root_open=$(gh pr view "$root_pr" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open && echo true || echo false)
1227
- if [ "$root_open" != true ]; then
1228
- echo "Stack #$parent has no open root pull request; nothing to announce."
1169
+ # The same child listing refine uses to hand work out: the sub-issues
1170
+ # link safe_outputs writes, with the body-marker search as fallback. A
1171
+ # stack parent always has children, so a listing that finds none means
1172
+ # the lookups failed, and announcing completion of a stack we cannot
1173
+ # see would be wrong -- stay silent and let a rerun decide.
1174
+ children=$(gh api "repos/$REPO/issues/$parent/sub_issues" --jq '.[].number' 2>/dev/null || true)
1175
+ if [ -z "$children" ]; then
1176
+ children=$(gh issue list --repo "$REPO" --state open --limit 50 \
1177
+ --search "\"<!-- split-parent: ${parent} -->\" in:body" --json number --jq '.[].number' || true)
1178
+ fi
1179
+ if [ -z "$children" ]; then
1180
+ echo "Cannot list any child of stack #$parent; leaving completion undetected for a rerun."
1229
1181
  else
1230
- # The same child listing refine uses to hand work out: the sub-issues
1231
- # link safe_outputs writes, with the body-marker search as fallback. A
1232
- # stack parent always has children, so a listing that finds none means
1233
- # the lookups failed, and announcing completion of a stack we cannot
1234
- # see would be wrong -- stay silent and let a rerun decide.
1235
- children=$(gh api "repos/$REPO/issues/$parent/sub_issues" --jq '.[].number' 2>/dev/null || true)
1236
- if [ -z "$children" ]; then
1237
- children=$(gh issue list --repo "$REPO" --state open --limit 50 \
1238
- --search "\"<!-- split-parent: ${parent} -->\" in:body" --json number --jq '.[].number' || true)
1239
- fi
1240
- if [ -z "$children" ]; then
1241
- echo "Cannot list any child of stack #$parent; leaving completion undetected for a rerun."
1242
- else
1243
- # Release the children this landing unblocks. check-stack-branch holds a child
1244
- # whose `Blocked by #N` sibling is still open, and nothing else re-dispatches it,
1245
- # so the landing does: re-adding `implement` with the App token is a bot label
1246
- # event, which the router routes straight to implement. The child that just
1247
- # landed counts as done even before the gate closes its issue a moment later.
1248
- for child in $children; do
1249
- [ "$child" = "$closed_issue" ] && continue
1250
- meta=$(gh issue view "$child" --repo "$REPO" --json state,labels,body 2>/dev/null || echo '{}')
1251
- printf '%s' "$meta" | jq -e '(.state // "" | ascii_downcase) == "open"' >/dev/null || continue
1252
- printf '%s' "$meta" | jq -e '[.labels[]?.name] | (index("implement") != null) and (index("bot-working") == null)' >/dev/null || continue
1253
- blockers=$(printf '%s' "$meta" | jq -r '.body // ""' | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
1254
- [ -n "$blockers" ] || continue
1255
- waiting=""
1256
- for b in $blockers; do
1257
- [ "$b" = "$closed_issue" ] && continue
1258
- if gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
1259
- waiting="$waiting #$b"
1260
- fi
1261
- done
1262
- if [ -n "$waiting" ]; then
1263
- echo "#$child still waits on$waiting."
1264
- continue
1182
+ # Release the children this landing unblocks. check-stack-branch holds a child
1183
+ # whose `Blocked by #N` sibling is still open, and nothing else re-dispatches it,
1184
+ # so the landing does: re-adding `implement` with the App token is a bot label
1185
+ # event, which the router routes straight to implement. The child that just
1186
+ # landed counts as done even before the gate closes its issue a moment later.
1187
+ for child in $children; do
1188
+ [ "$child" = "$closed_issue" ] && continue
1189
+ meta=$(gh issue view "$child" --repo "$REPO" --json state,labels,body 2>/dev/null || echo '{}')
1190
+ printf '%s' "$meta" | jq -e '(.state // "" | ascii_downcase) == "open"' >/dev/null || continue
1191
+ printf '%s' "$meta" | jq -e '[.labels[]?.name] | (index("implement") != null) and (index("bot-working") == null)' >/dev/null || continue
1192
+ blockers=$(printf '%s' "$meta" | jq -r '.body // ""' | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
1193
+ [ -n "$blockers" ] || continue
1194
+ waiting=""
1195
+ for b in $blockers; do
1196
+ [ "$b" = "$closed_issue" ] && continue
1197
+ if gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
1198
+ waiting="$waiting #$b"
1265
1199
  fi
1266
- gh issue edit "$child" --repo "$REPO" --remove-label implement >/dev/null 2>&1 || true
1267
- gh issue edit "$child" --repo "$REPO" --add-label implement >/dev/null
1268
- echo "Released #$child: everything it was blocked by has landed."
1269
1200
  done
1270
- open_children=0
1271
- for child in $children; do
1272
- # Landed, though the gate may not have closed its issue yet.
1273
- [ "$child" = "$closed_issue" ] && continue
1274
- if gh issue view "$child" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
1275
- open_children=$((open_children + 1))
1276
- fi
1201
+ if [ -n "$waiting" ]; then
1202
+ echo "#$child still waits on$waiting."
1203
+ continue
1204
+ fi
1205
+ gh issue edit "$child" --repo "$REPO" --remove-label implement >/dev/null 2>&1 || true
1206
+ gh issue edit "$child" --repo "$REPO" --add-label implement >/dev/null
1207
+ echo "Released #$child: everything it was blocked by has landed."
1208
+ done
1209
+ open_children=0
1210
+ for child in $children; do
1211
+ # Landed, though the gate may not have closed its issue yet.
1212
+ [ "$child" = "$closed_issue" ] && continue
1213
+ if gh issue view "$child" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
1214
+ open_children=$((open_children + 1))
1215
+ fi
1216
+ done
1217
+ if [ "$open_children" -gt 0 ]; then
1218
+ echo "$open_children child issue(s) still open on stack #$parent; the stack continues."
1219
+ else
1220
+ # The root pull request must be green and mergeable. Green: a successful
1221
+ # CI run on its head SHA. Mergeable: the same on-demand computation
1222
+ # every gate dispatch already relies on; UNKNOWN settles in the
1223
+ # background, so ask until it does, like mergeable_state() does.
1224
+ root_state=UNKNOWN
1225
+ for _ in 1 2 3 4 5 6; do
1226
+ root_state=$(gh pr view "$root_pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
1227
+ [ "$root_state" = "UNKNOWN" ] || break
1228
+ sleep 5
1277
1229
  done
1278
- if [ "$open_children" -gt 0 ]; then
1279
- echo "$open_children child issue(s) still open on stack #$parent; the stack continues."
1230
+ root_sha=$(gh api "repos/$REPO/pulls/$root_pr" --jq '.head.sha')
1231
+ root_ci=$(gh api "repos/$REPO/actions/runs?head_sha=$root_sha&per_page=100" \
1232
+ --jq '[.workflow_runs[]
1233
+ | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
1234
+ and .conclusion == "success")]
1235
+ | sort_by(.created_at) | last // empty')
1236
+ if [ -z "$root_ci" ] || [ "$root_state" != "MERGEABLE" ]; then
1237
+ echo "Root PR #$root_pr is not green/mergeable yet; the human will merge when it is."
1280
1238
  else
1281
- # The root pull request must be green and mergeable. Green: a successful
1282
- # CI run on its head SHA. Mergeable: the same on-demand computation
1283
- # every gate dispatch already relies on; UNKNOWN settles in the
1284
- # background, so ask until it does, like mergeable_state() does.
1285
- root_state=UNKNOWN
1286
- for _ in 1 2 3 4 5 6; do
1287
- root_state=$(gh pr view "$root_pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
1288
- [ "$root_state" = "UNKNOWN" ] || break
1289
- sleep 5
1290
- done
1291
- root_sha=$(gh api "repos/$REPO/pulls/$root_pr" --jq '.head.sha')
1292
- root_ci=$(gh api "repos/$REPO/actions/runs?head_sha=$root_sha&per_page=100" \
1293
- --jq '[.workflow_runs[]
1294
- | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
1295
- and .conclusion == "success")]
1296
- | sort_by(.created_at) | last // empty')
1297
- if [ -z "$root_ci" ] || [ "$root_state" != "MERGEABLE" ]; then
1298
- echo "Root PR #$root_pr is not green/mergeable yet; the human will merge when it is."
1239
+ completion_marker='<!-- stack-complete -->'
1240
+ # The announcement is a pull request comment, and pull request
1241
+ # comments live at the issue endpoint for the pull request number,
1242
+ # so the marker is looked for where the comment was posted.
1243
+ already=$(gh api "repos/$REPO/issues/$root_pr/comments?per_page=100" \
1244
+ --jq "[.[] | select(.body | contains(\"$completion_marker\"))] | length" 2>/dev/null || echo 0)
1245
+ if [ "${already:-0}" -gt 0 ]; then
1246
+ echo "Stack #$parent already has a completion announcement; staying silent."
1299
1247
  else
1300
- completion_marker='<!-- stack-complete -->'
1301
- # The announcement is a pull request comment, and pull request
1302
- # comments live at the issue endpoint for the pull request number,
1303
- # so the marker is looked for where the comment was posted.
1304
- already=$(gh api "repos/$REPO/issues/$root_pr/comments?per_page=100" \
1305
- --jq "[.[] | select(.body | contains(\"$completion_marker\"))] | length" 2>/dev/null || echo 0)
1306
- if [ "${already:-0}" -gt 0 ]; then
1307
- echo "Stack #$parent already has a completion announcement; staying silent."
1308
- else
1309
- gh pr comment "$root_pr" --repo "$REPO" --body "Stack complete: all children have merged into the feature branch and CI is green. This root pull request is ready for a human to review and merge into $DEFAULT_BRANCH. $completion_marker"
1310
- echo "Stack #$parent complete: announced root PR #$root_pr."
1311
- fi
1248
+ gh pr comment "$root_pr" --repo "$REPO" --body "Stack complete: all children have merged into the feature branch and CI is green. This root pull request is ready for a human to review and merge into $DEFAULT_BRANCH. $completion_marker"
1249
+ echo "Stack #$parent complete: announced root PR #$root_pr."
1312
1250
  fi
1313
1251
  fi
1314
- fi
1252
+ fi
1315
1253
  fi
1316
1254
  fi
1317
1255
  fi
1318
1256
  fi
1319
- else
1320
- maybe_dispatch "$EVENT_PR"
1321
1257
  fi
1322
1258
 
1323
1259
  reconcile-bot-pr-runs:
@@ -1347,55 +1283,37 @@ jobs:
1347
1283
  private-key: ${{ secrets.BOT_PRIVATE_KEY }}
1348
1284
  # Runs only the default-branch workflow and never checks out pull-request code.
1349
1285
  # External users cannot impersonate github-actions[bot].
1350
- - name: Approve checks and dispatch Merge Gate for trusted bot pull requests
1286
+ # Moving pull requests towards a merge is the merge train's, which this schedule also ticks.
1287
+ - name: Approve trusted bot CI and clear stale reservations
1351
1288
  env:
1352
1289
  GH_TOKEN: ${{ steps.app-token.outputs.token }}
1353
1290
  REPO: ${{ github.repository }}
1354
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
1355
1291
  run: |
1356
1292
  set -euo pipefail
1357
1293
 
1358
- # GitHub computes mergeability on demand. The first read of a pull request nobody has
1359
- # opened recently answers UNKNOWN through the API and null through REST, while it works
1360
- # the answer out in the background. Reading once therefore reports "not conflicting"
1361
- # for precisely the stale conflicting pull requests that need the gate. Ask until it
1362
- # settles.
1363
- mergeable_state() {
1364
- local pr="$1" state=UNKNOWN
1365
- for _ in 1 2 3 4 5 6; do
1366
- state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
1367
- [ "$state" = "UNKNOWN" ] || break
1368
- sleep 5
1369
- done
1370
- echo "$state"
1371
- }
1372
-
1373
- # A gate already queued or running for this pull request will read the same CI verdict;
1374
- # a second dispatch would wait in the belt's queue and run the same gate twice.
1375
- gate_is_live() {
1376
- local pr="$1" status live
1377
- for status in queued in_progress waiting pending; do
1378
- live=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
1379
- --jq "[.workflow_runs[] | select(.display_title == \"Working (Merge Gate): PR #${pr}\")] | length" 2>/dev/null || echo 0)
1380
- [ "${live:-0}" -eq 0 ] || return 0
1381
- done
1382
- return 1
1383
- }
1384
-
1385
1294
  # Stale reservation sweep. Labelling ten issues at once labels all ten immediately,
1386
- # because authorize-bot-work is keyed per issue, while implement-global lets exactly
1295
+ # because authorize-bot-work is keyed per issue, while each implement queue lets exactly
1387
1296
  # one run at a time. A queued run that is cancelled therefore leaves its issue
1388
1297
  # carrying bot-working with no run and no pull request: parked, invisible, and not
1389
1298
  # re-triggerable, because classify refuses an issue that is already reserved.
1299
+ #
1300
+ # The live runs are read by status, not as the repository's latest hundred runs. Every
1301
+ # comment and label is a router run, so a busy repository pushes an implement that has
1302
+ # waited two hours in the queue out of the latest hundred, and the sweep then cleared
1303
+ # bot-working from issues whose runs were alive and about to start (Pliny-Bot #693 and
1304
+ # #707, both still queued from 07:32). Runs that are not finished are few, whatever
1305
+ # their age.
1306
+ active_runs=$(for status in queued in_progress pending waiting requested; do
1307
+ gh api "repos/$REPO/actions/runs?status=$status&per_page=100" --jq '.workflow_runs[]' 2>/dev/null || true
1308
+ done | jq -s '{workflow_runs: .}')
1390
1309
  gh issue list --repo "$REPO" --label bot-working --state open --limit 100 \
1391
1310
  --json number --jq '.[].number' |
1392
1311
  while IFS= read -r issue; do
1393
1312
  [ -n "$issue" ] || continue
1394
1313
 
1395
- live=$(gh api "repos/$REPO/actions/runs?per_page=100" \
1396
- --jq "[.workflow_runs[]
1314
+ live=$(jq "[.workflow_runs[]
1397
1315
  | select(.status == \"queued\" or .status == \"in_progress\" or .status == \"pending\" or .status == \"waiting\")
1398
- | select((.display_title + \" \") | test(\"#${issue}[^0-9]\"))] | length")
1316
+ | select((.display_title + \" \") | test(\"#${issue}[^0-9]\"))] | length" <<<"$active_runs")
1399
1317
  [ "${live:-0}" -eq 0 ] || continue
1400
1318
 
1401
1319
  has_pr=$(gh pr list --repo "$REPO" --state open --json body \
@@ -1419,7 +1337,7 @@ jobs:
1419
1337
  )
1420
1338
  | [.number, .head.ref, .head.sha] | @tsv
1421
1339
  ' |
1422
- while IFS=$'\t' read -r pr_number branch head_sha; do
1340
+ while IFS=$'\t' read -r _ branch _; do
1423
1341
  gh api "repos/$REPO/actions/runs?status=action_required&branch=$branch" \
1424
1342
  --jq '.workflow_runs[]
1425
1343
  | select(
@@ -1431,110 +1349,6 @@ jobs:
1431
1349
  echo "Approving trusted bot run $run_id"
1432
1350
  gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
1433
1351
  done
1434
-
1435
- ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
1436
- --jq '[.workflow_runs[]
1437
- | select(
1438
- .name == env.CI_WORKFLOW_NAME
1439
- and .event == "pull_request"
1440
- and .status == "completed"
1441
- and (.conclusion == "success" or .conclusion == "failure")
1442
- )
1443
- ] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
1444
- if [ -z "$ci" ]; then
1445
- # A conflicting pull request has no merge ref for GitHub to build, so a
1446
- # `pull_request` CI workflow can never run on that head: refs/pull/N/merge is a
1447
- # 404 for as long as the conflict lasts. Waiting for a verdict that cannot arrive
1448
- # deadlocks the belt, because resolving the conflict is the gate's own job. Fall
1449
- # back to the branch's last verdict so the gate runs; it stays on the remediation
1450
- # path and cannot merge on a stale conclusion.
1451
- mergeable_now=$(mergeable_state "$pr_number")
1452
- if [ "$mergeable_now" = "CONFLICTING" ]; then
1453
- ci=$(gh api "repos/$REPO/actions/runs?branch=$branch&per_page=100" \
1454
- --jq '[.workflow_runs[]
1455
- | select(
1456
- .name == env.CI_WORKFLOW_NAME
1457
- and .event == "pull_request"
1458
- and .status == "completed"
1459
- and (.conclusion == "success" or .conclusion == "failure")
1460
- )
1461
- ] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
1462
- fi
1463
- if [ -z "$ci" ]; then
1464
- echo "::warning::PR #$pr_number head $head_sha has no completed $CI_WORKFLOW_NAME run; the belt cannot gate it until CI runs."
1465
- continue
1466
- fi
1467
- echo "PR #$pr_number conflicts, so CI cannot run on $head_sha at all; gating on the branch's last verdict."
1468
- fi
1469
-
1470
- IFS=$'\t' read -r ci_run_id ci_conclusion ci_finished <<<"$ci"
1471
-
1472
- # A verdict is a comment carrying BOTH the gate marker and a `**Verdict:**`
1473
- # line; progress comments and failed attempts deliberately carry neither
1474
- # matching pair, so a crashed or OOM-killed gate never masks a PR from
1475
- # later sweeps. A recorded verdict only counts when it is NEWER than the
1476
- # latest CI run; the old existence check made the gate one-shot per PR.
1477
- # Attempts are failed gates (the attempt marker) since this CI verdict.
1478
- latest_verdict=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
1479
- --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
1480
- linked_issue=$(gh pr view "$pr_number" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
1481
- if [ -n "$linked_issue" ]; then
1482
- issue_verdict=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
1483
- --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
1484
- if [ -n "$issue_verdict" ] && { [ -z "$latest_verdict" ] || [ "$issue_verdict" \> "$latest_verdict" ]; }; then
1485
- latest_verdict="$issue_verdict"
1486
- fi
1487
- fi
1488
- # A verdict parks the CODE it was given on, not the CI run that prompted it. This
1489
- # used to compare against the CI finish time, which made the park worthless: any
1490
- # later CI run on the same commits -- a re-run, a nightly, a rebuild triggered by
1491
- # main moving -- was newer than the verdict, so the belt re-dispatched a pull
1492
- # request a human had already been handed, and the attempt counter below reset to
1493
- # zero at the same time and handed it a fresh budget of six full gate runs.
1494
- # Lyceum PR #13 sat parked by a `review` verdict for six days while this happened.
1495
- #
1496
- # The newest commit on the branch is the right key. A gate verdict newer than it
1497
- # applies to the current head, so the park holds however often CI runs. Push a
1498
- # commit -- which is what apply-review and a `remediated` verdict both do -- and
1499
- # the head moves past the verdict and the gate decides again, which is correct:
1500
- # that is new code nobody has ruled on.
1501
- head_committed=$(gh pr view "$pr_number" --repo "$REPO" \
1502
- --json commits --jq '[.commits[].committedDate] | max // empty' 2>/dev/null || true)
1503
- verdict_horizon="${head_committed:-$ci_finished}"
1504
- if [ -n "$latest_verdict" ] && [ "$latest_verdict" \> "$verdict_horizon" ]; then
1505
- echo "PR #$pr_number: gate verdict ($latest_verdict) is newer than its head commit ($verdict_horizon); the park holds, skipping"
1506
- continue
1507
- fi
1508
-
1509
- attempts=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
1510
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$verdict_horizon"'") ] | length')
1511
- if [ -n "$linked_issue" ]; then
1512
- issue_attempts=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
1513
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$verdict_horizon"'") ] | length')
1514
- attempts=$((attempts + issue_attempts))
1515
- fi
1516
- if [ "$attempts" -ge "$MAX_GATE_ATTEMPTS" ]; then
1517
- echo "PR #$pr_number already consumed $attempts gate attempts on this head, parking for review"
1518
- continue
1519
- fi
1520
- if gate_is_live "$pr_number"; then
1521
- echo "PR #$pr_number: a merge-gate run is already live; not dispatching another."
1522
- continue
1523
- fi
1524
-
1525
- echo "Dispatching Merge Gate for PR #$pr_number ($ci_conclusion, run $ci_run_id, attempt $((attempts + 1)) of $MAX_GATE_ATTEMPTS)"
1526
- gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
1527
- --method POST \
1528
- -f ref="$DEFAULT_BRANCH" \
1529
- -f "inputs[operation]=merge-gate" \
1530
- -f "inputs[pr-number]=$pr_number" \
1531
- -f "inputs[ci-conclusion]=$ci_conclusion" \
1532
- -f "inputs[ci-run-id]=$ci_run_id" \
1533
- -f "inputs[attempts_so_far]=$attempts"
1534
-
1535
- # Every eligible pull request in one tick. The belt queues them first in, first out
1536
- # (`queue: max` on call-merge-gate); before it did, each dispatch cancelled the one
1537
- # pending ahead of it, so this loop stopped after one and the rest waited an hour.
1538
1352
  done
1539
1353
 
1540
1354
  audit-close: