@plainconceptsplatform/workflows 1.6.0 → 1.6.11

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.
@@ -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,10 +1971,19 @@ 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)"
1819
1981
  assert "red CI goes to the gate" gate "$(move MERGEABLE=MERGEABLE CI_STATUS=completed CI_CONCLUSION=failure)"
1982
+ # A red base branch cost every open Pliny-Bot pull request its repairs for seven hours: the
1983
+ # failures were main's, and the gate spent attempts on code that was not at fault.
1984
+ assert "a failure that is main's too is waited out, not repaired" wait "$(move MERGEABLE=MERGEABLE CI_STATUS=completed CI_CONCLUSION=failure SAME_AS_MAIN=true)"
1985
+ assert "a failure that is not main's still goes to the gate" gate "$(move MERGEABLE=MERGEABLE CI_STATUS=completed CI_CONCLUSION=failure SAME_AS_MAIN=false)"
1986
+ assert "a conflict is still the gate's even when main is red" gate "$(move MERGEABLE=CONFLICTING CI_STATUS=completed CI_CONCLUSION=failure SAME_AS_MAIN=true)"
1820
1987
  assert "green but behind is brought up to date first" update "$(move MERGEABLE=MERGEABLE CI_STATUS=completed CI_CONCLUSION=success BEHIND_BY=2)"
1821
1988
  assert "green and current goes to the gate" gate "$(move MERGEABLE=MERGEABLE CI_STATUS=completed CI_CONCLUSION=success BEHIND_BY=0)"
1822
1989
  assert "no CI run is passed by" skip "$(move MERGEABLE=MERGEABLE CI_STATUS=none)"
@@ -2568,7 +2735,7 @@ if [ "$INPUT_WIRING_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)
2568
2735
  BELT_QUEUE_OK=1
2569
2736
  belt_caller=$(awk '/^ call-merge-gate:/ { found = 1; print; next } found && /^ [a-z]/ { exit } found { print }' "$ROUTER_YML")
2570
2737
  printf '%s
2571
- ' "$belt_caller" | grep -qE '^ *group: merge-belt *$' ||
2738
+ ' "$belt_caller" | grep -qE "^ *group: .*'merge-belt'" ||
2572
2739
  { BELT_QUEUE_OK=0; echo "FAIL: the merge gate is no longer serialised on the merge-belt concurrency group" >&2; }
2573
2740
  printf '%s
2574
2741
  ' "$belt_caller" | grep -qE '^ *queue: max *$' ||
@@ -2596,6 +2763,11 @@ if worker_installed merge-gate; then
2596
2763
  GATE_RUN_ID_OK=0
2597
2764
  echo "FAIL: identify-gate-subject resolves the CI run only when the conclusion is unknown; a router that passes both gets an empty run ID" >&2
2598
2765
  fi
2766
+ # A verdict from a run that judged an older head is not a verdict on this one (Pliny-Bot #729).
2767
+ grep -Fq '"$run_sha" != "$current_sha"' "$GATE_SUBJECT_ACTION" ||
2768
+ { GATE_RUN_ID_OK=0; echo "FAIL: identify-gate-subject trusts the carried CI run even when it judged an older head" >&2; }
2769
+ grep -Fq 'select(.status == "completed")' "$GATE_SUBJECT_ACTION" ||
2770
+ { GATE_RUN_ID_OK=0; echo "FAIL: identify-gate-subject re-reads a stale verdict without requiring a finished run on the head" >&2; }
2599
2771
  fi
2600
2772
  # Repeating a report the validator refused reproduces it, and every repeat is a fresh agent run
2601
2773
  # holding the repo-wide merge-belt slot. The worker gives that failure a smaller budget than a
@@ -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
@@ -571,6 +594,7 @@ jobs:
571
594
  body: |
572
595
  ${{ env.IMPLEMENT_MARKER }}
573
596
  ${{ env.INCOMPLETE_COMMENT }}
597
+ What ended: the agent job was **${{ needs.agent.result }}** after ${{ steps.decide.outputs.minutes }} minutes (-1 when that could not be read), and publishing its output was **${{ needs.safe_outputs.result }}**.
574
598
  [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
575
599
  agent:
576
600
  needs: [eligibility]
@@ -703,6 +727,28 @@ timeout-minutes: 180
703
727
  phase skill it names, in order, and do not explore the code yourself where it says a phase
704
728
  is skipped.
705
729
 
730
+ **The pipeline is these skill loads, in this order, each a call to the Skill tool.** A run
731
+ that edits source files without having made them has skipped the pipeline, whatever it
732
+ wrote instead:
733
+
734
+ 1. `pc-plan-goal`, then Phase 0 as it describes.
735
+ 2. `pc-plan-propose`, even when the issue is refined. It is what turns the issue into
736
+ `openspec/changes/<id>/tasks.md` with every task annotated
737
+ `<!-- agent: <engineer>.<tier>, depends_on: [...], touches: [...] -->`. **You never write
738
+ `proposal.md` or `tasks.md` yourself.** A hand-written `tasks.md` carries no annotations,
739
+ and without them `pc-plan-apply` has no workers to spawn, so the whole change is done by
740
+ you alone, in sequence, on one model. That is the failure this step exists to prevent.
741
+ 3. `pc-plan-apply`. The tasks are implemented by the subagents it spawns, in waves, each on
742
+ the model tier its annotation names. **In this phase you do not edit source files
743
+ yourself.** If it stops because a worker cannot be resolved, report that with
744
+ `report_incomplete`, naming the task and the missing worker; do not do the task yourself
745
+ to get past it.
746
+ 4. `pc-repo-verify`, then `pc-plan-archive`, as `pc-plan-goal` directs.
747
+
748
+ Before the first edit to a source file, check that
749
+ `openspec/changes/<id>/tasks.md` exists and that its tasks carry the annotation above. If it
750
+ does not, you have not run `pc-plan-propose`: load it now.
751
+
706
752
  a. `branch` is the output mode this sandbox needs: the branch is kept, nothing is merged and
707
753
  nothing is pushed. Without it the skill merges into the local default branch and deletes
708
754
  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