@plainconceptsplatform/workflows 1.6.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.
package/dist/index.js CHANGED
File without changes
@@ -25,6 +25,9 @@ outputs:
25
25
  minutes:
26
26
  description: How long the agent job ran, or -1 when that could not be read.
27
27
  value: ${{ steps.decide.outputs.minutes }}
28
+ cause:
29
+ description: 'Why a retry was chosen: short-run, runner-shutdown or cut-off. "none" when there is none.'
30
+ value: ${{ steps.decide.outputs.cause }}
28
31
  runs:
29
32
  using: composite
30
33
  steps:
@@ -33,6 +36,13 @@ runs:
33
36
  # repeating it buys the same wrong answer half an hour later. Duration is what separates them,
34
37
  # and it needs no model and no log parsing to read.
35
38
  #
39
+ # Except when the long run was killed rather than wrong. The VM was deleted under a forty
40
+ # minute implement (Pliny-Bot #699), and an AI gateway that answered nothing ended another
41
+ # run one turn after it began (#693's first attempt): neither is a verdict on the work, and
42
+ # both waited six hours for the janitor. classify-failure.sh reads those two from the job's
43
+ # annotations and from the line above the sandbox teardown, and they are repeated whatever
44
+ # the run lasted, within the same attempt budget.
45
+ #
36
46
  # Written once here because the rule was in the implement worker alone, and refine, triage and
37
47
  # apply-review parked instead -- an Odyssey refine died in three minutes on
38
48
  # `Model 'glm-5-2' not found`, a gateway fault that clears in seconds, and waited on the
@@ -44,15 +54,16 @@ runs:
44
54
  GH_TOKEN: ${{ inputs.token }}
45
55
  REPO: ${{ github.repository }}
46
56
  RUN_ID: ${{ github.run_id }}
57
+ ACTION_PATH: ${{ github.action_path }}
47
58
  ATTEMPTS: ${{ inputs.attempts-so-far }}
48
59
  PARK_AT: ${{ inputs.park-at }}
49
60
  UNDER_MINUTES: ${{ inputs.under-minutes }}
50
61
  run: |
51
62
  set -euo pipefail
52
63
  # The agent job belongs to this same run: a called workflow shares the caller's run id.
53
- read -r started finished <<<"$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
64
+ read -r job_id started finished <<<"$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
54
65
  --jq '[.jobs[] | select(.name | endswith("agent"))] | last // empty
55
- | "\(.started_at // "") \(.completed_at // "")"')"
66
+ | "\(.id // "") \(.started_at // "") \(.completed_at // "")"')"
56
67
  minutes=-1
57
68
  if [ -n "${started:-}" ] && [ -n "${finished:-}" ]; then
58
69
  minutes=$(( ( $(date -u -d "$finished" +%s) - $(date -u -d "$started" +%s) ) / 60 ))
@@ -60,13 +71,30 @@ runs:
60
71
  attempts="${ATTEMPTS:-0}"
61
72
  [ -n "$attempts" ] || attempts=0
62
73
  retry=false
74
+ cause=none
63
75
  # An unknown duration is treated as a long run: never retry on a guess.
64
76
  if [ "$minutes" -ge 0 ] && [ "$minutes" -lt "$UNDER_MINUTES" ] && [ "$attempts" -lt "$PARK_AT" ]; then
65
77
  retry=true
78
+ cause=short-run
79
+ fi
80
+ # A long run is repeated only when it was killed, not when it was wrong. The reads are best
81
+ # effort: a log or an annotation list that cannot be fetched leaves the run parked, exactly
82
+ # as before this check existed.
83
+ if [ "$retry" != true ] && [ "$attempts" -lt "$PARK_AT" ] && [ -n "${job_id:-}" ]; then
84
+ work="$(mktemp -d)"
85
+ gh api "repos/$REPO/check-runs/$job_id/annotations" --paginate --jq '.[].message' >"$work/annotations" 2>/dev/null || true
86
+ gh api "repos/$REPO/actions/jobs/$job_id/logs" >"$work/log" 2>/dev/null || true
87
+ kind="$(bash "$ACTION_PATH/classify-failure.sh" "$work/log" "$work/annotations" || echo none)"
88
+ if [ "$kind" != none ]; then
89
+ retry=true
90
+ cause="$kind"
91
+ fi
92
+ rm -rf "$work"
66
93
  fi
67
94
  {
68
95
  echo "retry=$retry"
69
96
  echo "next=$((attempts + 1))"
70
97
  echo "minutes=$minutes"
98
+ echo "cause=$cause"
71
99
  } >> "$GITHUB_OUTPUT"
72
- echo "agent job ran for ${minutes}m; attempts so far ${attempts}; retry=${retry}"
100
+ echo "agent job ran for ${minutes}m; attempts so far ${attempts}; retry=${retry}; cause=${cause}"
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/decide-agent-retry/classify-failure.sh. Update with `workflows update --force`; consumer edits may be overwritten.
3
+ # Say why an agent job died, when the reason is one that repeating the run fixes.
4
+ #
5
+ # Duration alone could not tell the difference. A run that dies in three minutes produced nothing;
6
+ # a run that works for forty minutes and is then killed by its machine produced nothing either, and
7
+ # was parked for the janitor's six-hourly sweep as if the agent had been wrong. Two causes are
8
+ # infrastructure, not judgement:
9
+ #
10
+ # runner-shutdown the VM was taken away mid-job: the process is killed with SIGTERM and GitHub
11
+ # annotates the job "Process completed with exit code 143" (the log has the
12
+ # longer "The runner has received a shutdown signal"), whatever the model was
13
+ # doing. 137, a SIGKILL, is left out: an out-of-memory kill repeats itself
14
+ # cut-off the engine was stopped one turn after it started: the last thing the log shows
15
+ # before the sandbox stops is the header opencode prints when it sends a request
16
+ # to the model, with no reply under it. The AI gateway answered nothing, or an
17
+ # error opencode did not surface, and the run ended with the work unsaid
18
+ #
19
+ # A tool's output in the log can quote either phrase, so neither is searched for in the body: the
20
+ # shutdown is read from the annotations, which only GitHub writes, and the cut-off from the one line
21
+ # immediately above the sandbox teardown.
22
+ #
23
+ # Usage: classify-failure.sh LOG_FILE ANNOTATIONS_FILE
24
+ # Prints one word: runner-shutdown, cut-off or none.
25
+
26
+ set -euo pipefail
27
+
28
+ log="${1:-}"
29
+ annotations="${2:-}"
30
+
31
+ if [ -n "$annotations" ] && [ -f "$annotations" ] && grep -qiE 'exit code 143|runner has received a shutdown signal|lost communication with the server|runner.*(was|has been) (deleted|terminated)' "$annotations"; then
32
+ echo runner-shutdown
33
+ exit 0
34
+ fi
35
+
36
+ if [ -n "$log" ] && [ -f "$log" ]; then
37
+ # The line the sandbox teardown starts on. grep exits 1 for none, which is an answer here.
38
+ stop=$(grep -n '\[INFO\] Stopping containers' "$log" | head -1 | cut -d: -f1 || true)
39
+ if [ -n "$stop" ] && [ "$stop" -gt 1 ]; then
40
+ # The last line with any text above it, ignoring the ANSI colour codes opencode wraps its
41
+ # output in and the timestamp every runner line carries.
42
+ last=$(sed -n "1,$((stop - 1))p" "$log" | sed -E 's/\x1b\[[0-9;]*m//g' | grep -vE '^(\S+Z )?[[:space:]]*$' | tail -1 || true)
43
+ if printf '%s' "$last" | grep -qE '^(\S+Z )?> [A-Za-z0-9_.-]+ · [A-Za-z0-9_./:-]+[[:space:]]*$'; then
44
+ echo cut-off
45
+ exit 0
46
+ fi
47
+ fi
48
+ fi
49
+
50
+ echo none
@@ -71,13 +71,33 @@ runs:
71
71
  // timeout. A stuck pending implement or refine run holds a fleet slot that the retry
72
72
  // logic below can never free because the run never reaches a terminal state. This
73
73
  // cancel runs on a GitHub-hosted runner, so it works even when the fleet is full.
74
+ //
75
+ // Only a run with nothing live ahead of it. The router queues implement runs FIFO
76
+ // (`queue: max`) so that labelling six issues at once implements all six, one after the
77
+ // other, and each of those takes an hour: the sixth waits five hours legitimately. A
78
+ // flat one-hour cutoff cancelled every run behind the first, which left its issue
79
+ // carrying bot-working with no run until the sweep cleared it (Pliny-Bot #706 and #644,
80
+ // both cancelled while #693 ran). A pending run is stuck when no run of its own kind
81
+ // ("Agent: Implement", "Agent: Merge Gate") is running or queued to hold the group.
74
82
  const stuckCutoff = Date.now() - 3600 * 1000; // 1 hour ago
83
+ const runKind = (run) => String(run.display_title || '').split(' - ')[0];
84
+ const liveKinds = new Set();
85
+ for (const status of ['in_progress', 'queued']) {
86
+ for (const run of await github.paginate(github.rest.actions.listWorkflowRuns, {
87
+ owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'work-router.yml',
88
+ status, per_page: 50,
89
+ })) liveKinds.add(runKind(run));
90
+ }
75
91
  let cancelledRuns = 0;
76
92
  for (const run of await github.paginate(github.rest.actions.listWorkflowRuns, {
77
93
  owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'work-router.yml',
78
94
  status: 'pending', per_page: 50,
79
95
  })) {
80
96
  if (new Date(run.created_at).getTime() > stuckCutoff) continue;
97
+ if (liveKinds.has(runKind(run))) {
98
+ core.info(`run #${run.id} (${run.display_title}) waits behind a live ${runKind(run)} run; not stuck`);
99
+ continue;
100
+ }
81
101
  await act(`cancelling stuck pending run #${run.id} (queued ${new Date(run.created_at).toISOString()})`, async () => {
82
102
  await github.rest.actions.cancelWorkflowRun({
83
103
  owner: context.repo.owner, repo: context.repo.repo, run_id: run.id,
@@ -1,6 +1,6 @@
1
1
  # Managed by @plainconceptsplatform/workflows. Source: loops/actions/identify-gate-subject/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
2
  name: Identify gate subject
3
- description: Confirm the pull request is one the gate may act on, resolve the issue it closes, and check a bot pull request's issue carries the required label.
3
+ description: Confirm the pull request is one the gate may act on (same repository, opened by a trusted bot or someone with write access) and resolve the open issue it closes, if any.
4
4
  inputs:
5
5
  token:
6
6
  description: GitHub token with issues and pull-requests read access.
@@ -24,10 +24,6 @@ inputs:
24
24
  description: Issue the pull request closes, when the router already resolved it.
25
25
  required: false
26
26
  default: ''
27
- require-label:
28
- description: Label a bot pull request's closing issue must carry. Empty disables the check. A person's pull request needs neither an issue nor the label.
29
- required: false
30
- default: ''
31
27
  outputs:
32
28
  found:
33
29
  description: Whether a valid gate subject was found.
@@ -66,7 +62,6 @@ runs:
66
62
  CONCLUSION: ${{ inputs.ci-conclusion }}
67
63
  CI_RUN_ID: ${{ inputs.ci-run-id }}
68
64
  LINKED_ISSUE: ${{ inputs.linked-issue }}
69
- REQUIRE_LABEL: ${{ inputs.require-label }}
70
65
  TRUSTED_BOTS: ${{ inputs.trusted-bots }}
71
66
  run: |
72
67
  set -euo pipefail
@@ -115,19 +110,16 @@ runs:
115
110
  grep -oE '[0-9]+' | head -n 1 || true)"
116
111
  fi
117
112
 
118
- # The bot only opens a pull request for an issue it was given, so one that closes none, or
119
- # whose issue lost the label, is not work the gate was handed. A person's pull request is
120
- # gated on its own description when it closes nothing.
121
- if [ "$bot" = true ]; then
122
- [ -n "$issue" ] || none "PR #$PR_NUMBER does not close a numbered issue"
123
- fi
124
-
125
- if [ "$bot" = true ] && [ -n "$REQUIRE_LABEL" ]; then
126
- labels="$(gh issue view "$issue" --repo "$REPO" --json labels \
127
- --jq '[.labels[].name]')"
128
-
129
- if ! jq -e --arg name "$REQUIRE_LABEL" 'index($name)' >/dev/null <<<"$labels"; then
130
- none "issue #$issue does not carry $REQUIRE_LABEL (has: $(jq -r 'join(", ")' <<<"$labels"))"
113
+ # An issue that is already closed is not the work this pull request is for: the feature
114
+ # landed some other way, and the label on a closed issue says nothing. Comments, labels
115
+ # and the acceptance criteria then come from the pull request itself. A pull request is
116
+ # never refused for lacking an issue: the merge train hands the gate every pull request
117
+ # that can move, and a gate that declines one without a trace is handed it again.
118
+ if [ -n "$issue" ]; then
119
+ issue_state="$(gh issue view "$issue" --repo "$REPO" --json state --jq '.state' 2>/dev/null || echo UNKNOWN)"
120
+ if [ "$issue_state" != OPEN ]; then
121
+ echo "Issue #$issue is $issue_state; gating the pull request on its own description."
122
+ issue=""
131
123
  fi
132
124
  fi
133
125
 
@@ -28,6 +28,10 @@ inputs:
28
28
  description: How many times a blocked verdict, or a merge that did not happen, is retried after the base branch moves.
29
29
  required: false
30
30
  default: '3'
31
+ max-repairs:
32
+ description: How many pull requests may be in repair at once. Merges stay one at a time.
33
+ required: false
34
+ default: '1'
31
35
  stuck-hours:
32
36
  description: How long the train waits behind a CI run that never finishes before it passes that pull request by.
33
37
  required: false
@@ -60,6 +64,7 @@ runs:
60
64
  MAX_ATTEMPTS: ${{ inputs.max-gate-attempts }}
61
65
  MAX_RETRIES: ${{ inputs.max-retries }}
62
66
  STUCK_HOURS: ${{ inputs.stuck-hours }}
67
+ MAX_REPAIRS: ${{ inputs.max-repairs }}
63
68
  DRY_RUN: ${{ inputs.dry-run }}
64
69
  HINT_RUN_ID: ${{ inputs.ci-run-id }}
65
70
  HINT_CONCLUSION: ${{ inputs.ci-conclusion }}
@@ -1,19 +1,20 @@
1
1
  #!/usr/bin/env bash
2
2
  # Managed by @plainconceptsplatform/workflows. Source: loops/actions/merge-train/merge-train.sh. Update with `workflows update --force`; consumer edits may be overwritten.
3
- # Move the merge train one step: the oldest open pull request that can move, and only that one.
3
+ # Move the merge train: look at every open pull request, oldest first, and do for each the one
4
+ # thing it needs, with merges one at a time and repairs in parallel.
4
5
  #
5
6
  # The belt this replaces dispatched the gate for every eligible pull request at once. Each gate
6
7
  # then resolved its branch against a default branch the gate ahead of it was about to move, so a
7
8
  # night of ten pull requests became ten repairs, ten CI runs and ten more gates after every merge,
8
- # and a verdict that parked a pull request held it however stale it went. The train keeps one
9
- # pull request in flight, lowest number first. Everything else waits, untouched, until the one in
10
- # front of it has merged or been parked for a person.
9
+ # and a verdict that parked a pull request held it however stale it went.
11
10
  #
12
- # One tick does at most one thing:
13
- # wait the front pull request's CI is still running, or a gate is already on the belt
14
- # update it is green but behind the default branch: merge the default branch in, CI reruns
15
- # gate it is green and current, red, or conflicting: the merge gate repairs or merges it
11
+ # What races is a merge, because it moves the default branch under everyone else. A repair (a gate
12
+ # handed red CI) pushes to its own branch and can never merge, so it races nothing. So:
13
+ # merge one at a time, the oldest ready pull request first; nothing behind a pull request that
14
+ # is waiting on its CI, or behind a gate that may merge, merges or is brought up to date
15
+ # repair a red or conflicting pull request goes to the gate at once, up to MAX_REPAIRS together
16
16
  # approve its CI is waiting for approval and a trusted bot opened it
17
+ # update it is green but behind the default branch: merge the default branch in, CI reruns
17
18
  # A pull request a person has to decide on, or that carries the hold label, is skipped, so it never
18
19
  # blocks the line. Ticks come from every merge, every CI completion, every finished gate, and the
19
20
  # hourly schedule as the fallback.
@@ -27,7 +28,12 @@ set -euo pipefail
27
28
  # HOLD true when the pull request carries the hold label
28
29
  # REVIEW_DECISION GitHub's review decision (CHANGES_REQUESTED holds it for its author)
29
30
  # PARK none, or held when a standing gate verdict covers this head
30
- # ATTEMPTS failed gate attempts on this head; MAX_ATTEMPTS is the budget
31
+ # ATTEMPTS failed gate attempts on this head; MAX_ATTEMPTS is the budget. Counts the
32
+ # larger of the attempt comments and the gate runs, so a gate that fails
33
+ # without a comment still spends it
34
+ # SILENT_GATE true when a gate already ran on this head and left no verdict and no attempt
35
+ # comment: it declined, or died before it could say so. Handing it the same
36
+ # pull request again reproduces that, a hot loop with every tick
31
37
  # MERGEABLE MERGEABLE, CONFLICTING or UNKNOWN
32
38
  # CI_STATUS none, or the latest CI run's status (queued, in_progress, completed, ...)
33
39
  # CI_CONCLUSION that run's conclusion when it completed
@@ -45,6 +51,8 @@ train_move() {
45
51
  move=skip; reason="a gate verdict on this head is waiting for a person"
46
52
  elif [ "${ATTEMPTS:-0}" -ge "${MAX_ATTEMPTS:-6}" ]; then
47
53
  move=skip; reason="its gate attempt budget on this head is spent"
54
+ elif [ "${SILENT_GATE:-false}" = true ]; then
55
+ move=skip; reason="its gate already ran on this head and left no verdict or attempt"
48
56
  elif [ "${MERGEABLE:-UNKNOWN}" = CONFLICTING ]; then
49
57
  # A conflicting pull request has no merge ref, so CI can never run on it: waiting for a
50
58
  # verdict would wait forever. Resolving the conflict is the gate's job.
@@ -126,6 +134,8 @@ TRUSTED_BOTS="${TRUSTED_BOTS:-}"
126
134
  HOLD_LABEL="${HOLD_LABEL:-hold}"
127
135
  MAX_ATTEMPTS="${MAX_ATTEMPTS:-6}"
128
136
  MAX_RETRIES="${MAX_RETRIES:-3}"
137
+ MAX_REPAIRS="${MAX_REPAIRS:-1}"
138
+ case "$MAX_REPAIRS" in ''|*[!0-9]*|0) MAX_REPAIRS=1 ;; esac
129
139
  STUCK_HOURS="${STUCK_HOURS:-3}"
130
140
  DRY_RUN="${DRY_RUN:-false}"
131
141
  SELF_RUN_ID="${GITHUB_RUN_ID:-0}"
@@ -168,18 +178,18 @@ can_ride() {
168
178
  [ "${WRITE_ACCESS[$login]}" = true ]
169
179
  }
170
180
 
171
- # One gate at a time across the repository. A gate dispatched by a tick, by a person or by a
172
- # consumer CI is a router run titled by run-name; the run this tick belongs to is excluded,
173
- # because the tick after a gate runs inside the gate's own router run.
174
- live_gate() {
181
+ # The gates on the belt, one title per line. A gate handed red CI repairs and pushes and cannot
182
+ # merge, so run-name gives it a " (repair)" suffix and the router queues it per pull request;
183
+ # every other gate may merge, and there is one of those at a time across the repository. The run
184
+ # this tick belongs to is excluded: the tick after a gate runs inside the gate's own router run.
185
+ live_gates() {
175
186
  local status
176
187
  for status in queued in_progress waiting pending requested; do
177
- gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
178
- --jq ".workflow_runs[]
188
+ gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" --jq ".workflow_runs[]
179
189
  | select(.id != $SELF_RUN_ID)
180
190
  | select(.display_title | startswith(\"$GATE_TITLE_PREFIX\"))
181
- | .display_title" 2>/dev/null | head -1
182
- done | head -1
191
+ | .display_title" 2>/dev/null
192
+ done | sort -u
183
193
  }
184
194
 
185
195
  # GitHub works mergeability out on demand: the first read of a pull request nobody has opened
@@ -220,6 +230,14 @@ update_branch() {
220
230
  gh api "repos/$REPO/pulls/$pr/update-branch" --method PUT -f expected_head_sha="$head_sha" >/dev/null
221
231
  }
222
232
 
233
+ # How many gate runs were started for a pull request since a moment, whatever became of them. A
234
+ # run is titled by the router's run-name; this is the one count that does not depend on the gate
235
+ # having written anything.
236
+ gate_run_count() {
237
+ local pr="$1" since="$2"
238
+ gh api --paginate "repos/$REPO/actions/workflows/work-router.yml/runs?event=workflow_dispatch&per_page=100" --jq "[.workflow_runs[] | select(.display_title == \"$GATE_TITLE_PREFIX$pr\") | select(.created_at > \"$since\")] | length" 2>/dev/null | awk '{ n += $1 } END { print n + 0 }'
239
+ }
240
+
223
241
  attempt_count() {
224
242
  local number="$1" since="$2"
225
243
  gh api --paginate "repos/$REPO/issues/$number/comments?per_page=100" \
@@ -241,7 +259,7 @@ dispatch_gate() {
241
259
  # The next tick waits behind this one and must see the gate it started, or it starts a second.
242
260
  for _ in $(seq 1 12); do
243
261
  if gh api "repos/$REPO/actions/workflows/work-router.yml/runs?event=workflow_dispatch&created=>=$since&per_page=20" \
244
- --jq ".workflow_runs[] | select(.display_title == \"$GATE_TITLE_PREFIX$pr\") | .id" 2>/dev/null | grep -q .; then
262
+ --jq ".workflow_runs[] | select(.display_title | startswith(\"$GATE_TITLE_PREFIX$pr\")) | .id" 2>/dev/null | grep -q .; then
245
263
  return 0
246
264
  fi
247
265
  sleep 5
@@ -252,11 +270,21 @@ dispatch_gate() {
252
270
  summary "### Merge train"
253
271
  summary ""
254
272
 
255
- if gate=$(live_gate) && [ -n "$gate" ]; then
256
- echo "A gate is already on the belt (${gate}); the train waits for it."
257
- summary "Waiting: \`${gate}\` is on the belt."
258
- exit 0
259
- fi
273
+ live_titles="$(live_gates)"
274
+ live_repairs=$(printf '%s
275
+ ' "$live_titles" | grep -c ' (repair)$' || true)
276
+ live_merge=$(printf '%s
277
+ ' "$live_titles" | grep -v ' (repair)$' | grep -c . || true)
278
+ live_for_pr() { printf '%s
279
+ ' "$live_titles" | grep -qE "PR #$1( \(repair\))?$"; }
280
+ echo "On the belt: ${live_merge} merging gate(s), ${live_repairs} repair(s). Up to ${MAX_REPAIRS} repair(s) at once."
281
+ summary "On the belt: ${live_merge} merging gate(s), ${live_repairs} repair(s); up to ${MAX_REPAIRS} repair(s) at once."
282
+ summary ""
283
+
284
+ # Once a pull request ahead is waiting on its CI, or a gate that may merge is on the belt, nothing
285
+ # behind it may merge or be brought up to date: the oldest merges first, and one at a time.
286
+ merge_blocked=false
287
+ [ "$live_merge" -eq 0 ] || merge_blocked=true
260
288
 
261
289
  base_head_date=""
262
290
  base_head_date_for() {
@@ -332,6 +360,7 @@ for line in "${candidates[@]}"; do
332
360
  RETRIES="$retries" MAX_RETRIES="$MAX_RETRIES" STACK_ROOT="$stack_root" train_park)
333
361
 
334
362
  attempts=0
363
+ silent_gate=false
335
364
  if [ "$park" = none ]; then
336
365
  horizon="${head_committed:-1970-01-01T00:00:00Z}"
337
366
  # A retried verdict starts a fresh budget from the verdict it retries, not from the head.
@@ -340,6 +369,12 @@ for line in "${candidates[@]}"; do
340
369
  if [ -n "$linked_issue" ]; then
341
370
  attempts=$((attempts + $(attempt_count "$linked_issue" "$horizon")))
342
371
  fi
372
+ gate_runs=$(gate_run_count "$pr" "$horizon")
373
+ # Comments say what the gate decided. Runs say it was asked. A run on this head that left no
374
+ # attempt comment and no verdict is a gate that did nothing, and the next tick must not ask
375
+ # again: that is the loop.
376
+ if [ "$gate_runs" -gt 0 ] && [ "$attempts" -eq 0 ]; then silent_gate=true; fi
377
+ [ "$gate_runs" -le "$attempts" ] || attempts="$gate_runs"
343
378
  fi
344
379
 
345
380
  ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
@@ -372,32 +407,51 @@ for line in "${candidates[@]}"; do
372
407
  is_trusted_bot "$author" && trusted_author=true
373
408
 
374
409
  decision=$(HOLD="$hold" REVIEW_DECISION="$review_decision" PARK="$park" \
375
- ATTEMPTS="$attempts" MAX_ATTEMPTS="$MAX_ATTEMPTS" MERGEABLE="$mergeable" \
410
+ ATTEMPTS="$attempts" MAX_ATTEMPTS="$MAX_ATTEMPTS" SILENT_GATE="$silent_gate" MERGEABLE="$mergeable" \
376
411
  CI_STATUS="$ci_status" CI_CONCLUSION="$ci_conclusion" CI_STUCK="$ci_stuck" \
377
412
  BEHIND_BY="${behind_by:-0}" TRUSTED_AUTHOR="$trusted_author" train_move)
378
413
  move=$(sed -n 's/^move=//p' <<<"$decision")
379
414
  reason=$(sed -n 's/^reason=//p' <<<"$decision")
380
415
 
416
+ # A gate is already on this pull request, whatever kind: offering it another would run the same
417
+ # work twice. It does not hold the line up for the repair kind; a merging gate already does.
418
+ if [ "$move" != skip ] && live_for_pr "$pr"; then
419
+ move=skip
420
+ reason="a gate is already on it"
421
+ fi
422
+
381
423
  echo "#$pr ($author): $move, $reason."
382
424
  summary "| #$pr | $author | $move | $reason |"
383
425
 
384
426
  case "$move" in
385
427
  skip) continue ;;
386
- wait) exit 0 ;;
428
+ wait)
429
+ # Its CI is running: nothing behind it may merge or be brought up to date first, but the
430
+ # pull requests behind it that need repair do not wait for anyone.
431
+ merge_blocked=true
432
+ continue
433
+ ;;
387
434
  approve)
388
435
  act "Approving the CI runs waiting on #$pr." approve_runs "$head_ref"
389
- exit 0
436
+ merge_blocked=true
437
+ continue
390
438
  ;;
391
439
  update)
440
+ if [ "$merge_blocked" = true ]; then
441
+ echo " #$pr waits for the pull request ahead of it before it is brought up to date."
442
+ continue
443
+ fi
392
444
  # Merge, never rebase: the gate's pushes are fast-forward only, and so are a person's.
393
445
  if act "Merging $base_ref into #$pr so CI judges it against what it will land on." \
394
446
  update_branch "$pr" "$head_sha"; then
395
- exit 0
447
+ merge_blocked=true
448
+ continue
396
449
  fi
397
450
  echo "::warning::GitHub could not update #$pr from $base_ref; handing it to the gate."
398
451
  act "Dispatching the merge gate for #$pr to bring it up to date." \
399
452
  dispatch_gate "$pr" success "${ci_run_id:-}" "$attempts"
400
- exit 0
453
+ merge_blocked=true
454
+ continue
401
455
  ;;
402
456
  gate)
403
457
  conclusion="${ci_conclusion:-}"
@@ -412,13 +466,30 @@ for line in "${candidates[@]}"; do
412
466
  conclusion=$(cut -f2 <<<"$last")
413
467
  [ -n "$conclusion" ] || conclusion=failure
414
468
  fi
469
+ if [ "$conclusion" = failure ]; then
470
+ # A repair: it pushes and can never merge, so it races nothing. Up to MAX_REPAIRS at once.
471
+ if [ "$live_repairs" -ge "$MAX_REPAIRS" ]; then
472
+ echo " #$pr needs a repair; ${live_repairs} already in progress, the limit is ${MAX_REPAIRS}."
473
+ continue
474
+ fi
475
+ act "Dispatching a repair gate for #$pr (CI failed, attempt $((attempts + 1)) of $MAX_ATTEMPTS)." \
476
+ dispatch_gate "$pr" failure "${ci_run_id:-}" "$attempts"
477
+ live_repairs=$((live_repairs + 1))
478
+ continue
479
+ fi
480
+ # CI is green on the verdict: this gate may merge, so it goes only when nothing is ahead of it.
481
+ if [ "$merge_blocked" = true ]; then
482
+ echo " #$pr is ready for the gate; it waits for the pull request ahead of it."
483
+ continue
484
+ fi
415
485
  act "Dispatching the merge gate for #$pr (CI $conclusion, attempt $((attempts + 1)) of $MAX_ATTEMPTS)." \
416
486
  dispatch_gate "$pr" "$conclusion" "${ci_run_id:-}" "$attempts"
417
- exit 0
487
+ merge_blocked=true
488
+ continue
418
489
  ;;
419
490
  esac
420
491
  done
421
492
 
422
- echo "Nothing on the train can move: every open pull request is parked, held or has no CI verdict."
493
+ echo "That is every open pull request this tick could move."
423
494
  summary ""
424
- summary "Nothing can move: every open pull request is parked, held or has no CI verdict."
495
+ summary "Every open pull request was looked at; those that could move have."
@@ -24,7 +24,20 @@ runs:
24
24
  env:
25
25
  GH_TOKEN: ${{ inputs.token }}
26
26
  run: |
27
- git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/"
27
+ set -euo pipefail
28
+ # The runners here outlive their jobs, and `git config --global` outlives the job with
29
+ # them. This step used to add one `url.<token>.insteadOf` rule per run and never remove it,
30
+ # so a runner that had pushed before held several rules for the same prefix, each carrying
31
+ # a token revoked since, and git picks among equal-length matches arbitrarily. The same
32
+ # gate pushed for one pull request and was refused for the next: "Invalid username or
33
+ # token", then "Target branch does not exist" because ls-remote exited 128 (Pliny-Bot #716
34
+ # failed twice that way while #717 pushed in between). Drop every such rule wherever it
35
+ # lives, and set this run's rule in the checkout only.
36
+ for scope in --global --local; do
37
+ git config "$scope" --get-regexp '^url\..*x-access-token.*\.insteadof$' 2>/dev/null |
38
+ while read -r key _; do git config "$scope" --unset-all "$key" || true; done || true
39
+ done
40
+ git config --local url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/"
28
41
  - name: Read pull request branch
29
42
  id: request
30
43
  shell: bash
@@ -493,6 +493,14 @@ if [ "$BELT_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
493
493
  # trailing space, so a number at the end of a body is still followed by a non-digit, and the
494
494
  # pattern matches `[^0-9]`. Four quoting layers cannot eat a character class.
495
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
496
504
  if ! grep -qF 'test(\"#${issue}[^0-9]\")' "$ROUTER_YML"; then
497
505
  SWEEP_OK=0
498
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
@@ -688,12 +696,15 @@ if worker_installed merge-gate; then
688
696
  # The merge belt is serial for the whole repository: several overnight pull requests
689
697
  # mean every merge moves the default branch under the rest, and gates running at once
690
698
  # rebase onto bases other gates are about to invalidate. A per-issue group here would
691
- # reintroduce that race, so assert the repo-wide lock is the one in use.
692
- 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
693
704
  PASS=$((PASS + 1))
694
705
  else
695
706
  FAIL=$((FAIL + 1))
696
- 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
697
708
  fi
698
709
  fi
699
710
 
@@ -719,7 +730,9 @@ if worker_installed merge-gate; then
719
730
  # One gate at a time, found by the title run-name gives it. The belt looked for a title that
720
731
  # run-name had stopped producing, saw no live gate ever, and dispatched #680 five times in
721
732
  # an hour onto a belt that runs one gate at a time.
722
- gate_title="$(sed -n "s/.*format('\(Agent: Merge Gate - PR #\){0}'.*/\1/p" "$ROUTER_YML" | head -1)"
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; }
723
736
  if [ -z "$gate_title" ] || ! grep -qF "GATE_TITLE_PREFIX=\"${gate_title}\"" "$TRAIN_SH"; then
724
737
  BELT_OK=0
725
738
  echo "FAIL: the train looks for live gates titled differently from what run-name names them ('${gate_title:-unread}')" >&2
@@ -1771,6 +1784,151 @@ if [ -f "$GATE_VALIDATOR" ] && worker_installed merge-gate; then
1771
1784
  if [ "$VALIDATOR_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
1772
1785
  fi
1773
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
+
1774
1932
  echo "── Merge gate park ───────────────────────────────────────────────────────"
1775
1933
 
1776
1934
  # A gate verdict parks the code it was given on. The belt used to compare the standing verdict
@@ -1813,6 +1971,10 @@ if worker_installed merge-gate; then
1813
1971
  assert "requested changes keep it off the train" skip "$(move REVIEW_DECISION=CHANGES_REQUESTED CI_STATUS=completed CI_CONCLUSION=success)"
1814
1972
  assert "a parked pull request is passed by, not waited on" skip "$(move PARK=held CI_STATUS=completed CI_CONCLUSION=success)"
1815
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)"
1816
1978
  assert "a conflict goes to the gate even with no CI" gate "$(move MERGEABLE=CONFLICTING CI_STATUS=none)"
1817
1979
  assert "running CI is waited on" wait "$(move MERGEABLE=MERGEABLE CI_STATUS=in_progress)"
1818
1980
  assert "CI stuck past the limit is passed by" skip "$(move MERGEABLE=MERGEABLE CI_STATUS=queued CI_STUCK=true)"
@@ -2568,7 +2730,7 @@ if [ "$INPUT_WIRING_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)
2568
2730
  BELT_QUEUE_OK=1
2569
2731
  belt_caller=$(awk '/^ call-merge-gate:/ { found = 1; print; next } found && /^ [a-z]/ { exit } found { print }' "$ROUTER_YML")
2570
2732
  printf '%s
2571
- ' "$belt_caller" | grep -qE '^ *group: merge-belt *$' ||
2733
+ ' "$belt_caller" | grep -qE "^ *group: .*'merge-belt'" ||
2572
2734
  { BELT_QUEUE_OK=0; echo "FAIL: the merge gate is no longer serialised on the merge-belt concurrency group" >&2; }
2573
2735
  printf '%s
2574
2736
  ' "$belt_caller" | grep -qE '^ *queue: max *$' ||
@@ -94,7 +94,7 @@ flowchart LR
94
94
  gate -->|merge| merged(("Merged"))
95
95
  merged -->|"next PR"| train
96
96
  gate -.->|"CI failed or conflict"| fix("Fix, push, CI again") --> ci
97
- gate -.->|".github, secrets<br/>or 1000+ lines"| human(("Human review"))
97
+ gate -.->|".github, secrets<br/>or 10000+ lines"| human(("Human review"))
98
98
  review("Someone reviews the PR") --> applyReview("apply-review") --> ci
99
99
  audit("audit, weekly") -->|"files one issue"| refine
100
100
 
@@ -381,7 +381,7 @@ flowchart TD
381
381
  gateDisposition -->|"no verified blocker,<br/>radius low or recoverable medium"| gateRootCheck
382
382
  gateRootCheck{"PR base is a<br/>stack feature branch<br/>and this PR is the root?"}
383
383
  gateRootCheck -->|"child PR: no"| gateMerge
384
- gateDisposition -.->|"1000+ lines, .github/<br/>or a secret"| gateOwner
384
+ gateDisposition -.->|"10000+ lines, .github/<br/>or a secret"| gateOwner
385
385
  gateDisposition -.->|"fragile, unmet criteria<br/>or low confidence"| gateHuman
386
386
  gateDisposition -.->|"CI red or a verified<br/>high finding"| gateBlocked
387
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)"))
@@ -331,7 +331,19 @@ for (const file of readdirSync(workflowDirectory)) {
331
331
  // runner rather than the run so a runner keeps its server warm between its own jobs.
332
332
  // No double quotes here: the enclosing run: is a double-quoted YAML scalar, so a quote
333
333
  // would have to be escaped. The default has no spaces, so bare is safe shell.
334
- .replace(/\/tmp\/opencode-data(?!-\$\{\{)/g, () => '/tmp/opencode-data-${{ runner.name }}');
334
+ .replace(/\/tmp\/opencode-data(?!-\$\{\{)/g, () => '/tmp/opencode-data-${{ runner.name }}')
335
+ // The engine key falls back to FORGE_API_KEY, the one secret every repository here has. The
336
+ // router maps it to OPENAI_API_KEY when it CALLS a worker, but a stack child is DISPATCHED on
337
+ // its feature branch, and a dispatched run reads repository secrets by their own names: with
338
+ // no OPENAI_API_KEY or CODEX_API_KEY set, activation died on "Validate CODEX_API_KEY or
339
+ // OPENAI_API_KEY secret" and every stack child in Pliny-Bot failed that way for hours. gh-aw
340
+ // reads the key in four places, the validation step, the engine env and the redaction list
341
+ // included, and all of them must see the fallback or the key would run unredacted. The
342
+ // longest form goes first so the shorter ones do not match inside it.
343
+ .replaceAll('${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY }}',
344
+ '${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY || secrets.FORGE_API_KEY }}')
345
+ .replaceAll('${{ secrets.CODEX_API_KEY }}', '${{ secrets.CODEX_API_KEY || secrets.FORGE_API_KEY }}')
346
+ .replaceAll('${{ secrets.OPENAI_API_KEY }}', '${{ secrets.OPENAI_API_KEY || secrets.FORGE_API_KEY }}');
335
347
 
336
348
  let rewritten = patched;
337
349
  // A queued implement executes hours after its run was created, but actions/checkout
@@ -340,8 +352,8 @@ for (const file of readdirSync(workflowDirectory)) {
340
352
  // every later push became a rebase onto a moved parent, which gh-aw's signed-commit
341
353
  // push cannot do (it dies with "cannot rebase: You have unstaged changes" and files the
342
354
  // pull request as an issue instead of a pull request). Checking out the branch tip at
343
- // execution time makes the commit parent current main; the implement-global queue
344
- // already keeps sibling merges out of the window while the agent runs.
355
+ // execution time makes the commit parent current main; the implement queue, shared by the
356
+ // children of one refine, keeps sibling merges out of the window while the agent runs.
345
357
  //
346
358
  // A stack child dispatches this workflow directly (`gh workflow run
347
359
  // agent-implement.lock.yml --ref feat/N`), and there the pin must select the feature
@@ -415,6 +427,11 @@ for (const file of readdirSync(workflowDirectory)) {
415
427
  for (const check of [
416
428
  { name: "bundle upload glob", ok: /\n( +)- name: Upload agent artifacts\n\1 if: always\(\)\n\1 continue-on-error: true\n\1 uses: actions\/upload-artifact@[^\n]*\n\1 with:\n\1 name: \$\{\{ needs\.activation\.outputs\.artifact_prefix \}\}agent\n\1 path: \|\n(?:\1 [^\n]*\n)*\1 \/tmp\/gh-aw\/aw-\*\.bundle\n/ },
417
429
  { name: "placeholder step intact", ok: /\n( +)- name: Write agent output placeholder if missing\n\1 if: always\(\)\n\1 run: \|\n\1 if \[ ! -f \/tmp\/gh-aw\/agent_output\.json \]; then\n\1 echo '\{"items":\[\]\}' > \/tmp\/gh-aw\/agent_output\.json\n\1 fi\n/ },
430
+ // Every read of the engine key carries the FORGE_API_KEY fallback; see the rewrite above.
431
+ {
432
+ name: "engine key falls back to FORGE_API_KEY",
433
+ ok: lock => !lock.includes("${{ secrets.OPENAI_API_KEY }}") && !lock.includes("${{ secrets.CODEX_API_KEY }}"),
434
+ },
418
435
  { name: "failed-jobs reporter disabled", ok: /- name: Report failed jobs\n[ \t]*id: report_failed_jobs\n[ \t]*if: false[^\n]*\n/ },
419
436
  // Only agent-refine declares update_issue; the other workers never carry the tool.
420
437
  {
@@ -185,7 +185,7 @@ jobs:
185
185
  fi
186
186
 
187
187
  # A queued implement run can start long after it was dispatched. If triage concluded
188
- # needs-maintainer while this run waited in the implement-global queue, the issue now
188
+ # needs-maintainer while this run waited in its implement queue, the issue now
189
189
  # carries review and the verdict must stand: the reserve step clears review blindly,
190
190
  # so eligibility has to catch it first (Pliny-Bot #372).
191
191
  if jq -e 'index("review")' >/dev/null <<<"$labels"; then
@@ -245,6 +245,8 @@ jobs:
245
245
  contents: read
246
246
  issues: write
247
247
  pull-requests: write
248
+ # reads the agent job's log and annotations, and re-enters through the router on a retry
249
+ actions: write
248
250
  steps:
249
251
  - name: Checkout workflow actions
250
252
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -433,8 +435,48 @@ jobs:
433
435
  ${{ env.IMPLEMENT_MARKER }}
434
436
  ${{ env.PATCH_WITHOUT_REQUEST_COMMENT }}
435
437
  [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
436
- - name: Flag a run that produced nothing
438
+ # A run that ends with nothing is not always the agent's verdict. The AI gateway can answer
439
+ # nothing and end the engine one turn after it began, and the runner's VM can be taken away
440
+ # mid-job; neither says the work was wrong, and parking them for the janitor cost up to six
441
+ # hours each (Pliny-Bot #693's first attempt). The shared rule repeats those, and a run that
442
+ # died young, within the attempt budget.
443
+ - name: Decide whether an empty run is worth repeating
444
+ id: empty_retry
437
445
  if: needs.safe_outputs.outputs.created_pr_number == '' && !contains(needs.agent.outputs.output_types, 'create_pull_request') && !contains(needs.agent.outputs.output_types, 'noop') && needs.agent.outputs.has_patch != 'true'
446
+ uses: ./.github/actions/decide-agent-retry
447
+ with:
448
+ token: ${{ github.token }}
449
+ attempts-so-far: ${{ inputs.attempts_so_far || '0' }}
450
+ park-at: ${{ env.PARK_AT_ATTEMPT }}
451
+ under-minutes: ${{ env.RETRY_UNDER_MINUTES }}
452
+ - name: Report the retried empty run
453
+ if: steps.empty_retry.outputs.retry == 'true'
454
+ uses: ./.github/actions/create-issue-comment
455
+ with:
456
+ token: ${{ steps.app-token.outputs.token }}
457
+ issue-number: ${{ inputs.issue-number }}
458
+ body: |
459
+ ${{ env.ATTEMPT_MARKER }}
460
+ Attempt ${{ steps.empty_retry.outputs.next }} of ${{ env.MAX_ATTEMPTS }} ended with nothing after ${{ steps.empty_retry.outputs.minutes }} minutes (${{ steps.empty_retry.outputs.cause }}). That is what a provider or runner outage looks like, not a verdict on the work, so this is being retried.
461
+ The issue keeps `implement`.
462
+ [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
463
+ - name: Send the issue back through the router
464
+ if: steps.empty_retry.outputs.retry == 'true'
465
+ env:
466
+ GH_TOKEN: ${{ github.token }}
467
+ REPO: ${{ github.repository }}
468
+ REF: ${{ github.event.repository.default_branch }}
469
+ ISSUE_NUMBER: ${{ inputs.issue-number }}
470
+ NEXT: ${{ steps.empty_retry.outputs.next }}
471
+ run: |
472
+ set -euo pipefail
473
+ # The provider recovers in seconds, so pause before re-entering rather than dispatching
474
+ # back into the same outage.
475
+ sleep 30
476
+ gh workflow run work-router.yml --repo "$REPO" --ref "$REF" -f operation=implement -f issue-number="$ISSUE_NUMBER" -f attempts_so_far="$NEXT"
477
+ echo "Re-dispatched implement for #$ISSUE_NUMBER as attempt $NEXT."
478
+ - name: Flag a run that produced nothing
479
+ if: needs.safe_outputs.outputs.created_pr_number == '' && !contains(needs.agent.outputs.output_types, 'create_pull_request') && !contains(needs.agent.outputs.output_types, 'noop') && needs.agent.outputs.has_patch != 'true' && steps.empty_retry.outputs.retry != 'true'
438
480
  uses: ./.github/actions/add-issue-labels
439
481
  with:
440
482
  token: ${{ steps.app-token.outputs.token }}
@@ -443,7 +485,7 @@ jobs:
443
485
  ${{ env.REVIEW_LABEL }}
444
486
  ${{ env.STALLED_LABEL }}
445
487
  - name: Say the run produced nothing
446
- if: needs.safe_outputs.outputs.created_pr_number == '' && !contains(needs.agent.outputs.output_types, 'create_pull_request') && !contains(needs.agent.outputs.output_types, 'noop') && needs.agent.outputs.has_patch != 'true'
488
+ if: needs.safe_outputs.outputs.created_pr_number == '' && !contains(needs.agent.outputs.output_types, 'create_pull_request') && !contains(needs.agent.outputs.output_types, 'noop') && needs.agent.outputs.has_patch != 'true' && steps.empty_retry.outputs.retry != 'true'
447
489
  uses: ./.github/actions/create-issue-comment
448
490
  with:
449
491
  token: ${{ steps.app-token.outputs.token }}
@@ -478,36 +520,17 @@ jobs:
478
520
  with:
479
521
  client-id: ${{ secrets.BOT_APP_ID }}
480
522
  private-key: ${{ secrets.BOT_PRIVATE_KEY }}
523
+ # The shared rule, which this worker used to carry its own copy of. It repeats a run that died
524
+ # young, and now also one that was killed after a long time: the runner's VM taken away
525
+ # mid-job, or the AI gateway answering nothing and ending the engine one turn after it began.
481
526
  - name: Decide whether this failure is worth repeating
482
527
  id: decide
483
- env:
484
- GH_TOKEN: ${{ github.token }}
485
- REPO: ${{ github.repository }}
486
- RUN_ID: ${{ github.run_id }}
487
- ATTEMPTS: ${{ inputs.attempts_so_far || '0' }}
488
- PARK_AT: ${{ env.PARK_AT_ATTEMPT }}
489
- UNDER_MINUTES: ${{ env.RETRY_UNDER_MINUTES }}
490
- run: |
491
- set -euo pipefail
492
- # The agent job belongs to this same run: a called workflow shares the caller's run id.
493
- read -r started finished <<<"$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
494
- --jq '[.jobs[] | select(.name | endswith("agent"))] | last // empty
495
- | "\(.started_at // "") \(.completed_at // "")"')"
496
- minutes=-1
497
- if [ -n "${started:-}" ] && [ -n "${finished:-}" ]; then
498
- minutes=$(( ( $(date -u -d "$finished" +%s) - $(date -u -d "$started" +%s) ) / 60 ))
499
- fi
500
- retry=false
501
- # An unknown duration is treated as a long run: never retry on a guess.
502
- if [ "$minutes" -ge 0 ] && [ "$minutes" -lt "$UNDER_MINUTES" ] && [ "$ATTEMPTS" -lt "$PARK_AT" ]; then
503
- retry=true
504
- fi
505
- {
506
- echo "retry=$retry"
507
- echo "next=$((ATTEMPTS + 1))"
508
- echo "minutes=$minutes"
509
- } >> "$GITHUB_OUTPUT"
510
- echo "agent job ran for ${minutes}m; attempts so far ${ATTEMPTS}; retry=${retry}"
528
+ uses: ./.github/actions/decide-agent-retry
529
+ with:
530
+ token: ${{ github.token }}
531
+ attempts-so-far: ${{ inputs.attempts_so_far || '0' }}
532
+ park-at: ${{ env.PARK_AT_ATTEMPT }}
533
+ under-minutes: ${{ env.RETRY_UNDER_MINUTES }}
511
534
  # The attempt is recorded before any label moves, so a failure in the steps below leaves a
512
535
  # run that can be counted rather than an issue released with nothing to show for it.
513
536
  - name: Report the failed attempt
@@ -518,7 +541,7 @@ jobs:
518
541
  issue-number: ${{ inputs.issue-number }}
519
542
  body: |
520
543
  ${{ env.ATTEMPT_MARKER }}
521
- Attempt ${{ steps.decide.outputs.next }} of ${{ env.MAX_ATTEMPTS }} ended after ${{ steps.decide.outputs.minutes }} minutes, before the run could produce an answer. That is what a provider outage looks like, so this is being retried.
544
+ Attempt ${{ steps.decide.outputs.next }} of ${{ env.MAX_ATTEMPTS }} ended after ${{ steps.decide.outputs.minutes }} minutes without an answer (${{ steps.decide.outputs.cause }}). That is what a provider or runner outage looks like, not a verdict on the work, so this is being retried.
522
545
  The issue keeps `implement`.
523
546
  [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
524
547
  - name: Release the reservation for the retry
@@ -703,6 +726,28 @@ timeout-minutes: 180
703
726
  phase skill it names, in order, and do not explore the code yourself where it says a phase
704
727
  is skipped.
705
728
 
729
+ **The pipeline is these skill loads, in this order, each a call to the Skill tool.** A run
730
+ that edits source files without having made them has skipped the pipeline, whatever it
731
+ wrote instead:
732
+
733
+ 1. `pc-plan-goal`, then Phase 0 as it describes.
734
+ 2. `pc-plan-propose`, even when the issue is refined. It is what turns the issue into
735
+ `openspec/changes/<id>/tasks.md` with every task annotated
736
+ `<!-- agent: <engineer>.<tier>, depends_on: [...], touches: [...] -->`. **You never write
737
+ `proposal.md` or `tasks.md` yourself.** A hand-written `tasks.md` carries no annotations,
738
+ and without them `pc-plan-apply` has no workers to spawn, so the whole change is done by
739
+ you alone, in sequence, on one model. That is the failure this step exists to prevent.
740
+ 3. `pc-plan-apply`. The tasks are implemented by the subagents it spawns, in waves, each on
741
+ the model tier its annotation names. **In this phase you do not edit source files
742
+ yourself.** If it stops because a worker cannot be resolved, report that with
743
+ `report_incomplete`, naming the task and the missing worker; do not do the task yourself
744
+ to get past it.
745
+ 4. `pc-repo-verify`, then `pc-plan-archive`, as `pc-plan-goal` directs.
746
+
747
+ Before the first edit to a source file, check that
748
+ `openspec/changes/<id>/tasks.md` exists and that its tasks carry the annotation above. If it
749
+ does not, you have not run `pc-plan-propose`: load it now.
750
+
706
751
  a. `branch` is the output mode this sandbox needs: the branch is kept, nothing is merged and
707
752
  nothing is pushed. Without it the skill merges into the local default branch and deletes
708
753
  the feature branch, and step 6 below then opens a pull request from a branch that is
@@ -37,11 +37,12 @@ env:
37
37
  # infrastructure used to stop a merge from here; they are the review pass's to look at now, and
38
38
  # a migration that rewrites data still parks through a `low` recoverability the agent evidences.
39
39
  SENSITIVE_PATHS: '(^|/)([Dd]omain|entities|[Cc]ontracts)/|(^|/)([Aa]uth|[Aa]uthn|[Aa]uthz|[Aa]uthentication|[Aa]uthorization|[Ii]dentity|[Ss]ecurity|[Mm]igrations|[Ii]nfra|terraform|helm|k8s|deploy)(/|[A-Z][A-Za-z]*\.[a-z]+$)'
40
- # Diff shape. Lines decide high: a change over a thousand counted lines (tests, docs,
41
- # translations and generated code excluded) is too big to merge unseen. A file count alone
42
- # never does, so the high file threshold is set out of reach; spread still raises medium.
40
+ # Diff shape. Lines decide high: a change over ten thousand counted lines (tests, docs,
41
+ # translations and generated code excluded) is too big to merge unseen. It was a thousand, which
42
+ # is a normal feature once the tests are left out. A file count alone never decides, so the high
43
+ # file threshold is set out of reach; spread still raises medium.
43
44
  BLAST_HIGH_FILES: "100000"
44
- BLAST_HIGH_LINES: "1000"
45
+ BLAST_HIGH_LINES: "10000"
45
46
  BLAST_MEDIUM_FILES: "5"
46
47
  BLAST_MEDIUM_LINES: "200"
47
48
  # Paths that change no runtime behaviour: tests, docs, specs, translations and generated
@@ -182,7 +183,6 @@ jobs:
182
183
  ci-conclusion: ${{ inputs.ci-conclusion }}
183
184
  ci-run-id: ${{ inputs.ci-run-id }}
184
185
  linked-issue: ${{ inputs.linked-issue }}
185
- require-label: ${{ env.IMPLEMENT_LABEL }}
186
186
  trusted-bots: ${{ env.TRUSTED_BOTS }}
187
187
  - name: Block a pull request with requested changes
188
188
  id: review
@@ -320,8 +320,13 @@ jobs:
320
320
  echo "check-diff is not installed (agent-harness 3.2.0 or later ships it), so comments and tests were not checked." >> "$GITHUB_STEP_SUMMARY"
321
321
  exit 0
322
322
  fi
323
- report=$(node .opencode/scripts/check-diff.mjs --base "$BASE" 2>&1)
324
- code=$?
323
+ # `|| code=$?`, not a bare `code=$?` on the next line. GitHub runs this under `bash -e`,
324
+ # and `set -uo pipefail` above does not undo it, so a check-diff that exited 1 (the
325
+ # `fail` this step exists to report) ended the step there: the job failed, the agent
326
+ # was skipped, and the pull request burned its whole attempt budget on a verdict that
327
+ # was never recorded (Pliny-Bot #686, six attempts in twenty minutes).
328
+ code=0
329
+ report=$(node .opencode/scripts/check-diff.mjs --base "$BASE" 2>&1) || code=$?
325
330
  case "$code" in 0) result=pass ;; 1) result=fail ;; *) result=error ;; esac
326
331
  delimiter="EOF_$(openssl rand -hex 8)"
327
332
  {
@@ -691,8 +696,12 @@ jobs:
691
696
  ${{ env.OWNER_REVIEW_LABEL }}
692
697
  ${{ env.BLOCKED_LABEL }}
693
698
  ${{ env.PR_PENDING_LABEL }}
699
+ # A conclude that dies (a push refused, a token rejected) is a failed attempt too. Without it
700
+ # here the run left no verdict and no attempt comment, which the merge train reads as a gate that
701
+ # did nothing and never offers the pull request again: Pliny-Bot #716 sat stranded that way after
702
+ # its repair was computed and then lost to a rejected git fetch in conclude.
694
703
  incomplete:
695
- needs: [subject, protected_changes, agent, safe_outputs, validate_output]
704
+ needs: [subject, protected_changes, agent, safe_outputs, validate_output, conclude]
696
705
  if: >
697
706
  always() &&
698
707
  needs.subject.outputs.found == 'true' &&
@@ -700,7 +709,8 @@ jobs:
700
709
  (
701
710
  needs.agent.result != 'success' ||
702
711
  needs.safe_outputs.result != 'success' ||
703
- needs.validate_output.outputs.valid != 'true'
712
+ needs.validate_output.outputs.valid != 'true' ||
713
+ needs.conclude.result == 'failure'
704
714
  )
705
715
  runs-on: agents-arc
706
716
  permissions:
@@ -837,9 +837,11 @@ safe-outputs:
837
837
  # was sent with an incorrect parameter shape (20 bytes, missing body) and was accepted
838
838
  # with success by the bridge, consuming the 1-per-run quota". The prompt still says to
839
839
  # send the body once.
840
+ # `title:` lets refine set an English title when the issue was written in another language.
840
841
  update-issue:
841
842
  target: "*"
842
843
  max: 3
844
+ title:
843
845
  add-comment:
844
846
  # Split children. An oversized story becomes several implementable ones rather than
845
847
  # one issue nobody can land; the cap stops a runaway decomposition.
@@ -976,6 +978,13 @@ timeout-minutes: 90
976
978
  The visible line is for people and the marker is read by the workflow, which turns it into the
977
979
  `sp-N` label. A body without the marker gets no estimate label at all.
978
980
 
981
+ **Everything you write is in English, whatever language the issue was written in.** Bodies,
982
+ child issues, comments and questions are English. When the title of the issue you are refining
983
+ is not English, put an English title in the same `update_issue` call as the body, as
984
+ `{"issue_number":N,"title":"...","body":"..."}`: it carries the same meaning, short, with no
985
+ language tag. People write issues in their own language, and everything the workflow produces
986
+ from them (pull requests, commits, the run history) is read by everyone else in English.
987
+
979
988
  7. **One safe-output call per turn.** Never batch multiple safe-output calls into a single
980
989
  message: `update_issue`, `create_issue` and `add_comment` each go in their own turn, with
981
990
  nothing else in the message. The provider caps one response at a fixed size, and a batch of
@@ -69,7 +69,7 @@ run-name: >-
69
69
  && format('Agentics: Merge Train - CI on PR #{0}', inputs.pr-number)
70
70
  || github.event_name == 'workflow_dispatch'
71
71
  && inputs.operation == 'merge-gate'
72
- && format('Agent: Merge Gate - PR #{0}', inputs.pr-number)
72
+ && format('Agent: Merge Gate - PR #{0}{1}', inputs.pr-number, inputs.ci-conclusion == 'failure' && ' (repair)' || '')
73
73
  || github.event_name == 'workflow_dispatch'
74
74
  && inputs.operation == 'merge-train'
75
75
  && format('Agentics: Merge Train{0}', '')
@@ -160,6 +160,15 @@ env:
160
160
  # "true" logs the move the train would make and makes none. For trying the train on a
161
161
  # repository before letting it act.
162
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"
163
172
  # Whether a merged pull request's verification plan is replayed and its screenshots archived on
164
173
  # the pull request (evidence.yml). It needs nothing configured: the app starts with the root
165
174
  # `pnpm run dev`, and a pull request with no plan costs one API call. Set it to "false" for a
@@ -522,7 +531,29 @@ jobs:
522
531
  stack-branch: ${{ steps.stack.outputs.stack-branch }}
523
532
  stack-in-flight: ${{ steps.inflight.outputs.stack-in-flight }}
524
533
  stack-blocked: ${{ steps.blocked.outputs.stack-blocked }}
534
+ implement-queue: ${{ steps.queue.outputs.queue }}
525
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)."
526
557
  - name: Resolve the feature branch from the stack markers
527
558
  id: stack
528
559
  env:
@@ -645,8 +676,10 @@ jobs:
645
676
  needs: [classify, authorize, check-implement-pr, check-stack-branch]
646
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'
647
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.
648
681
  concurrency:
649
- group: implement-global
682
+ group: implement-queue-${{ needs.check-stack-branch.outputs.implement-queue || '0' }}
650
683
  cancel-in-progress: false
651
684
  # FIFO, up to 100 waiting. Without this the default `queue: single` cancels whatever
652
685
  # was pending, so labelling six issues at once implements one and drops the rest.
@@ -793,8 +826,12 @@ jobs:
793
826
  needs: classify
794
827
  if: needs.classify.outputs.route == 'merge-gate'
795
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.
796
833
  concurrency:
797
- group: merge-belt
834
+ group: ${{ needs.classify.outputs.ci-conclusion == 'failure' && format('merge-repair-{0}', needs.classify.outputs.pr-number) || 'merge-belt' }}
798
835
  cancel-in-progress: false
799
836
  queue: max
800
837
  permissions: write-all
@@ -865,6 +902,7 @@ jobs:
865
902
  hold-label: ${{ env.MERGE_TRAIN_HOLD_LABEL }}
866
903
  max-gate-attempts: ${{ env.MAX_GATE_ATTEMPTS }}
867
904
  max-retries: ${{ env.MERGE_TRAIN_MAX_RETRIES }}
905
+ max-repairs: ${{ env.MERGE_TRAIN_REPAIRS }}
868
906
  dry-run: ${{ env.MERGE_TRAIN_DRY_RUN }}
869
907
  ci-run-id: ${{ needs.classify.outputs.ci-run-id }}
870
908
  ci-conclusion: ${{ needs.classify.outputs.ci-conclusion }}
@@ -1254,19 +1292,28 @@ jobs:
1254
1292
  set -euo pipefail
1255
1293
 
1256
1294
  # Stale reservation sweep. Labelling ten issues at once labels all ten immediately,
1257
- # 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
1258
1296
  # one run at a time. A queued run that is cancelled therefore leaves its issue
1259
1297
  # carrying bot-working with no run and no pull request: parked, invisible, and not
1260
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: .}')
1261
1309
  gh issue list --repo "$REPO" --label bot-working --state open --limit 100 \
1262
1310
  --json number --jq '.[].number' |
1263
1311
  while IFS= read -r issue; do
1264
1312
  [ -n "$issue" ] || continue
1265
1313
 
1266
- live=$(gh api "repos/$REPO/actions/runs?per_page=100" \
1267
- --jq "[.workflow_runs[]
1314
+ live=$(jq "[.workflow_runs[]
1268
1315
  | select(.status == \"queued\" or .status == \"in_progress\" or .status == \"pending\" or .status == \"waiting\")
1269
- | select((.display_title + \" \") | test(\"#${issue}[^0-9]\"))] | length")
1316
+ | select((.display_title + \" \") | test(\"#${issue}[^0-9]\"))] | length" <<<"$active_runs")
1270
1317
  [ "${live:-0}" -eq 0 ] || continue
1271
1318
 
1272
1319
  has_pr=$(gh pr list --repo "$REPO" --state open --json body \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.6.0",
3
+ "version": "1.6.10",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",
@@ -25,6 +25,13 @@
25
25
  "loops",
26
26
  "README.md"
27
27
  ],
28
+ "scripts": {
29
+ "build": "tsc --project tsconfig.build.json && node ./scripts/copy-loops.mjs",
30
+ "test": "vitest run",
31
+ "typecheck": "tsc --noEmit --project tsconfig.json",
32
+ "release": "pnpm build && pnpm test && npm publish --access public",
33
+ "prepack": "node ./scripts/copy-loops.mjs"
34
+ },
28
35
  "engines": {
29
36
  "node": ">=20.19"
30
37
  },
@@ -35,11 +42,5 @@
35
42
  "@types/node": "^22.0.0",
36
43
  "typescript": "^5.8.0",
37
44
  "vitest": "^3.0.0"
38
- },
39
- "scripts": {
40
- "build": "tsc --project tsconfig.build.json && node ./scripts/copy-loops.mjs",
41
- "test": "vitest run",
42
- "typecheck": "tsc --noEmit --project tsconfig.json",
43
- "release": "pnpm build && pnpm test && npm publish --access public"
44
45
  }
45
- }
46
+ }