@plainconceptsplatform/workflows 0.21.0 → 0.23.0

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
@@ -1,100 +1,100 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/classify-route/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Classify route
3
- description: Classify the triggering event into exactly one route, and resolve the issue a gated pull request closes.
4
- inputs:
5
- token:
6
- description: GitHub token with pull-requests read access.
7
- required: true
8
- outputs:
9
- route:
10
- description: The single route this event selects, or `none`.
11
- value: ${{ steps.classify.outputs.route }}
12
- issue-number:
13
- description: Issue number the route operates on.
14
- value: ${{ steps.classify.outputs.issue-number }}
15
- pr-number:
16
- description: Pull request number the route operates on.
17
- value: ${{ steps.classify.outputs.pr-number }}
18
- linked-issue:
19
- description: Issue the pull request closes. Empty for routes that do not target one.
20
- value: ${{ steps.linked-issue.outputs.issue }}
21
- ci-conclusion:
22
- description: Conclusion reported by the CI run that triggered a merge-gate route.
23
- value: ${{ steps.classify.outputs.ci-conclusion }}
24
- ci-run-id:
25
- description: Workflow run ID of that CI run.
26
- value: ${{ steps.classify.outputs.ci-run-id }}
27
- merge-gate-attempts:
28
- description: Failed merge-gate attempts already made against this CI verdict.
29
- value: ${{ steps.classify.outputs.merge-gate-attempts }}
30
- implement-attempts:
31
- description: Implement runs already made for this issue that died before producing an answer.
32
- value: ${{ steps.classify.outputs.implement-attempts }}
33
- refine-mode:
34
- description: first or rerefine.
35
- value: ${{ steps.classify.outputs.refine-mode }}
36
- triage-mode:
37
- description: first or retriage.
38
- value: ${{ steps.classify.outputs.triage-mode }}
39
- trigger-kind:
40
- description: scheduled or manual.
41
- value: ${{ steps.classify.outputs.trigger-kind }}
42
- runs:
43
- using: composite
44
- steps:
45
- - name: Classify the event
46
- id: classify
47
- shell: bash
48
- env:
49
- EVENT: ${{ github.event_name }}
50
- ACTION: ${{ github.event.action }}
51
- LABEL: ${{ github.event.label.name }}
52
- ISSUE_LABELS: ${{ toJSON(github.event.issue.labels.*.name) }}
53
- ISSUE_STATE: ${{ github.event.issue.state }}
54
- EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
55
- EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
56
- COMMENT_ON_PR: ${{ github.event.issue.pull_request != '' }}
57
- COMMENT_SENDER_TYPE: ${{ github.event.comment.user.type }}
58
- ACTOR: ${{ github.actor }}
59
- RUN_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
60
- RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
61
- RUN_ID: ${{ github.event.workflow_run.id }}
62
- SCHEDULE: ${{ github.event.schedule }}
63
- OPERATION: ${{ github.event.inputs.operation }}
64
- INPUT_ISSUE_NUMBER: ${{ github.event.inputs.issue-number }}
65
- INPUT_PR_NUMBER: ${{ github.event.inputs.pr-number }}
66
- INPUT_MODE: ${{ github.event.inputs.mode }}
67
- INPUT_CI_CONCLUSION: ${{ github.event.inputs.ci-conclusion }}
68
- INPUT_CI_RUN_ID: ${{ github.event.inputs.ci-run-id }}
69
- INPUT_ATTEMPTS_SO_FAR: ${{ github.event.inputs.attempts_so_far }}
70
- INPUT_TRIGGER_KIND: ${{ github.event.inputs.trigger-kind }}
71
- run: |
72
- set -euo pipefail
73
-
74
- bash "${GITHUB_ACTION_PATH}/classify-route.sh" | tee "$RUNNER_TEMP/route.env"
75
- cat "$RUNNER_TEMP/route.env" >> "$GITHUB_OUTPUT"
76
-
77
- route="$(sed -n 's/^route=//p' "$RUNNER_TEMP/route.env")"
78
- reason="$(sed -n 's/^error=//p' "$RUNNER_TEMP/route.env")"
79
-
80
- if [ "$route" = "none" ] && [ -n "$reason" ]; then
81
- echo "::notice::No route for this event: $reason"
82
- fi
83
-
84
- - name: Resolve the issue the pull request closes
85
- id: linked-issue
86
- if: steps.classify.outputs.route == 'merge-gate' || steps.classify.outputs.route == 'apply-review'
87
- shell: bash
88
- env:
89
- GH_TOKEN: ${{ inputs.token }}
90
- REPO: ${{ github.repository }}
91
- PR_NUMBER: ${{ steps.classify.outputs.pr-number }}
92
- run: |
93
- set -euo pipefail
94
-
95
- issue="$(gh pr view "$PR_NUMBER" --repo "$REPO" \
96
- --json closingIssuesReferences \
97
- --jq '.closingIssuesReferences[0].number // empty')"
98
-
99
- echo "issue=${issue}" >> "$GITHUB_OUTPUT"
100
- echo "Pull request #${PR_NUMBER} closes issue #${issue:-<none>}"
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/classify-route/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Classify route
3
+ description: Classify the triggering event into exactly one route, and resolve the issue a gated pull request closes.
4
+ inputs:
5
+ token:
6
+ description: GitHub token with pull-requests read access.
7
+ required: true
8
+ outputs:
9
+ route:
10
+ description: The single route this event selects, or `none`.
11
+ value: ${{ steps.classify.outputs.route }}
12
+ issue-number:
13
+ description: Issue number the route operates on.
14
+ value: ${{ steps.classify.outputs.issue-number }}
15
+ pr-number:
16
+ description: Pull request number the route operates on.
17
+ value: ${{ steps.classify.outputs.pr-number }}
18
+ linked-issue:
19
+ description: Issue the pull request closes. Empty for routes that do not target one.
20
+ value: ${{ steps.linked-issue.outputs.issue }}
21
+ ci-conclusion:
22
+ description: Conclusion reported by the CI run that triggered a merge-gate route.
23
+ value: ${{ steps.classify.outputs.ci-conclusion }}
24
+ ci-run-id:
25
+ description: Workflow run ID of that CI run.
26
+ value: ${{ steps.classify.outputs.ci-run-id }}
27
+ merge-gate-attempts:
28
+ description: Failed merge-gate attempts already made against this CI verdict.
29
+ value: ${{ steps.classify.outputs.merge-gate-attempts }}
30
+ attempts:
31
+ description: Runs already made for this piece of work that died before producing an answer. Carried by every worker that re-dispatches itself, so the budget is bounded.
32
+ value: ${{ steps.classify.outputs.attempts }}
33
+ refine-mode:
34
+ description: first or rerefine.
35
+ value: ${{ steps.classify.outputs.refine-mode }}
36
+ triage-mode:
37
+ description: first or retriage.
38
+ value: ${{ steps.classify.outputs.triage-mode }}
39
+ trigger-kind:
40
+ description: scheduled or manual.
41
+ value: ${{ steps.classify.outputs.trigger-kind }}
42
+ runs:
43
+ using: composite
44
+ steps:
45
+ - name: Classify the event
46
+ id: classify
47
+ shell: bash
48
+ env:
49
+ EVENT: ${{ github.event_name }}
50
+ ACTION: ${{ github.event.action }}
51
+ LABEL: ${{ github.event.label.name }}
52
+ ISSUE_LABELS: ${{ toJSON(github.event.issue.labels.*.name) }}
53
+ ISSUE_STATE: ${{ github.event.issue.state }}
54
+ EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
55
+ EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
56
+ COMMENT_ON_PR: ${{ github.event.issue.pull_request != '' }}
57
+ COMMENT_SENDER_TYPE: ${{ github.event.comment.user.type }}
58
+ ACTOR: ${{ github.actor }}
59
+ RUN_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
60
+ RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
61
+ RUN_ID: ${{ github.event.workflow_run.id }}
62
+ SCHEDULE: ${{ github.event.schedule }}
63
+ OPERATION: ${{ github.event.inputs.operation }}
64
+ INPUT_ISSUE_NUMBER: ${{ github.event.inputs.issue-number }}
65
+ INPUT_PR_NUMBER: ${{ github.event.inputs.pr-number }}
66
+ INPUT_MODE: ${{ github.event.inputs.mode }}
67
+ INPUT_CI_CONCLUSION: ${{ github.event.inputs.ci-conclusion }}
68
+ INPUT_CI_RUN_ID: ${{ github.event.inputs.ci-run-id }}
69
+ INPUT_ATTEMPTS_SO_FAR: ${{ github.event.inputs.attempts_so_far }}
70
+ INPUT_TRIGGER_KIND: ${{ github.event.inputs.trigger-kind }}
71
+ run: |
72
+ set -euo pipefail
73
+
74
+ bash "${GITHUB_ACTION_PATH}/classify-route.sh" | tee "$RUNNER_TEMP/route.env"
75
+ cat "$RUNNER_TEMP/route.env" >> "$GITHUB_OUTPUT"
76
+
77
+ route="$(sed -n 's/^route=//p' "$RUNNER_TEMP/route.env")"
78
+ reason="$(sed -n 's/^error=//p' "$RUNNER_TEMP/route.env")"
79
+
80
+ if [ "$route" = "none" ] && [ -n "$reason" ]; then
81
+ echo "::notice::No route for this event: $reason"
82
+ fi
83
+
84
+ - name: Resolve the issue the pull request closes
85
+ id: linked-issue
86
+ if: steps.classify.outputs.route == 'merge-gate' || steps.classify.outputs.route == 'apply-review'
87
+ shell: bash
88
+ env:
89
+ GH_TOKEN: ${{ inputs.token }}
90
+ REPO: ${{ github.repository }}
91
+ PR_NUMBER: ${{ steps.classify.outputs.pr-number }}
92
+ run: |
93
+ set -euo pipefail
94
+
95
+ issue="$(gh pr view "$PR_NUMBER" --repo "$REPO" \
96
+ --json closingIssuesReferences \
97
+ --jq '.closingIssuesReferences[0].number // empty')"
98
+
99
+ echo "issue=${issue}" >> "$GITHUB_OUTPUT"
100
+ echo "Pull request #${PR_NUMBER} closes issue #${issue:-<none>}"
@@ -28,7 +28,7 @@ is_issue_number() {
28
28
 
29
29
  classify_route() {
30
30
  local route="none" error=""
31
- local issue_number="" pr_number="" ci_conclusion="" ci_run_id="" merge_gate_attempts="0" implement_attempts="0"
31
+ local issue_number="" pr_number="" ci_conclusion="" ci_run_id="" merge_gate_attempts="0" attempts="0"
32
32
  local refine_mode="" triage_mode="" trigger_kind=""
33
33
 
34
34
  case "${EVENT:-}" in
@@ -190,13 +190,12 @@ classify_route() {
190
190
  if is_issue_number "${INPUT_ISSUE_NUMBER:-}"; then
191
191
  route="${OPERATION}"
192
192
  issue_number="${INPUT_ISSUE_NUMBER}"
193
- if [ "$OPERATION" = "refine" ]; then
194
- refine_mode="${INPUT_MODE:-first}"
195
- else
196
- # The implement worker re-dispatches itself when a run dies before doing any work,
197
- # and carries the count so the budget is bounded.
198
- implement_attempts="${INPUT_ATTEMPTS_SO_FAR:-0}"
199
- fi
193
+ # Both, not one or the other. A worker re-dispatches itself when a run dies before
194
+ # doing any work and carries the count so the budget is bounded; refine also carries
195
+ # the mode it was started in. Written as an if/else, a refine retry arrived as attempt
196
+ # zero every time and could never reach the park.
197
+ [ "$OPERATION" = "refine" ] && refine_mode="${INPUT_MODE:-first}"
198
+ attempts="${INPUT_ATTEMPTS_SO_FAR:-0}"
200
199
  else
201
200
  error="operation '${OPERATION}' needs a positive issue-number, got '${INPUT_ISSUE_NUMBER:-}'"
202
201
  fi
@@ -206,6 +205,7 @@ classify_route() {
206
205
  route="triage"
207
206
  issue_number="${INPUT_ISSUE_NUMBER}"
208
207
  triage_mode="${INPUT_MODE:-first}"
208
+ attempts="${INPUT_ATTEMPTS_SO_FAR:-0}"
209
209
  else
210
210
  error="operation 'triage' needs a positive issue-number, got '${INPUT_ISSUE_NUMBER:-}'"
211
211
  fi
@@ -214,6 +214,7 @@ classify_route() {
214
214
  if is_issue_number "${INPUT_PR_NUMBER:-}"; then
215
215
  route="apply-review"
216
216
  pr_number="${INPUT_PR_NUMBER}"
217
+ attempts="${INPUT_ATTEMPTS_SO_FAR:-0}"
217
218
  else
218
219
  error="operation 'apply-review' needs a positive pr-number, got '${INPUT_PR_NUMBER:-}'"
219
220
  fi
@@ -257,7 +258,7 @@ pr-number=${pr_number}
257
258
  ci-conclusion=${ci_conclusion}
258
259
  ci-run-id=${ci_run_id}
259
260
  merge-gate-attempts=${merge_gate_attempts}
260
- implement-attempts=${implement_attempts}
261
+ attempts=${attempts}
261
262
  refine-mode=${refine_mode}
262
263
  triage-mode=${triage_mode}
263
264
  trigger-kind=${trigger_kind}
@@ -0,0 +1,72 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/decide-agent-retry/action.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ name: Decide agent retry
3
+ description: Say whether an agent job that failed is worth running again, from how long it ran and how many attempts have been made.
4
+ inputs:
5
+ token:
6
+ description: GitHub token with actions read access, used to read this run's own jobs.
7
+ required: true
8
+ attempts-so-far:
9
+ description: Attempts already made against this piece of work. Empty counts as none.
10
+ required: false
11
+ default: '0'
12
+ park-at:
13
+ description: Stop retrying once this many attempts have been made.
14
+ required: true
15
+ under-minutes:
16
+ description: Only a run shorter than this is repeated.
17
+ required: true
18
+ outputs:
19
+ retry:
20
+ description: '"true" when this failure is worth repeating.'
21
+ value: ${{ steps.decide.outputs.retry }}
22
+ next:
23
+ description: The attempt number a retry would be.
24
+ value: ${{ steps.decide.outputs.next }}
25
+ minutes:
26
+ description: How long the agent job ran, or -1 when that could not be read.
27
+ value: ${{ steps.decide.outputs.minutes }}
28
+ runs:
29
+ using: composite
30
+ steps:
31
+ # A run that died in three minutes produced no answer, so repeating it costs three minutes. A
32
+ # run that worked for half an hour and then failed produced an answer that was wrong, and
33
+ # repeating it buys the same wrong answer half an hour later. Duration is what separates them,
34
+ # and it needs no model and no log parsing to read.
35
+ #
36
+ # Written once here because the rule was in the implement worker alone, and refine, triage and
37
+ # apply-review parked instead -- an Odyssey refine died in three minutes on
38
+ # `Model 'glm-5-3' not found`, a gateway fault that clears in seconds, and waited on the
39
+ # janitor's six-hourly sweep. Five copies of sixty lines is how one rule becomes five.
40
+ - name: Decide whether this failure is worth repeating
41
+ id: decide
42
+ shell: bash
43
+ env:
44
+ GH_TOKEN: ${{ inputs.token }}
45
+ REPO: ${{ github.repository }}
46
+ RUN_ID: ${{ github.run_id }}
47
+ ATTEMPTS: ${{ inputs.attempts-so-far }}
48
+ PARK_AT: ${{ inputs.park-at }}
49
+ UNDER_MINUTES: ${{ inputs.under-minutes }}
50
+ run: |
51
+ set -euo pipefail
52
+ # 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" \
54
+ --jq '[.jobs[] | select(.name | endswith("agent"))] | last // empty
55
+ | "\(.started_at // "") \(.completed_at // "")"')"
56
+ minutes=-1
57
+ if [ -n "${started:-}" ] && [ -n "${finished:-}" ]; then
58
+ minutes=$(( ( $(date -u -d "$finished" +%s) - $(date -u -d "$started" +%s) ) / 60 ))
59
+ fi
60
+ attempts="${ATTEMPTS:-0}"
61
+ [ -n "$attempts" ] || attempts=0
62
+ retry=false
63
+ # An unknown duration is treated as a long run: never retry on a guess.
64
+ if [ "$minutes" -ge 0 ] && [ "$minutes" -lt "$UNDER_MINUTES" ] && [ "$attempts" -lt "$PARK_AT" ]; then
65
+ retry=true
66
+ fi
67
+ {
68
+ echo "retry=$retry"
69
+ echo "next=$((attempts + 1))"
70
+ echo "minutes=$minutes"
71
+ } >> "$GITHUB_OUTPUT"
72
+ echo "agent job ran for ${minutes}m; attempts so far ${attempts}; retry=${retry}"
@@ -240,14 +240,14 @@ assert "merge-gate dispatch defaults its attempt count to zero" 0 \
240
240
  "$(route_field merge-gate-attempts EVENT=workflow_dispatch OPERATION=merge-gate INPUT_PR_NUMBER=7)"
241
241
  assert "merge-gate dispatch forwards the attempt count" 3 \
242
242
  "$(route_field merge-gate-attempts EVENT=workflow_dispatch OPERATION=merge-gate INPUT_PR_NUMBER=7 INPUT_ATTEMPTS_SO_FAR=3)"
243
- # The implement worker re-dispatches itself when a run dies before producing an answer, so the
244
- # count has to survive the round trip or the budget never advances and the retry never stops.
245
- assert "implement dispatch defaults its attempt count to zero" 0 \
246
- "$(route_field implement-attempts EVENT=workflow_dispatch OPERATION=implement INPUT_ISSUE_NUMBER=42)"
243
+ # A worker re-dispatches itself when a run dies before producing an answer, so the count has to
244
+ # survive the round trip or the budget never advances and the retry never stops. The field is
245
+ # `attempts` for every route now: it was `implement-attempts`, set in the else-branch of a test
246
+ # on refine, so a refine retry arrived as attempt zero and could never reach the park.
247
+ assert "a dispatch defaults its attempt count to zero" 0 \
248
+ "$(route_field attempts EVENT=workflow_dispatch OPERATION=implement INPUT_ISSUE_NUMBER=42)"
247
249
  assert "implement dispatch forwards the attempt count" 2 \
248
- "$(route_field implement-attempts EVENT=workflow_dispatch OPERATION=implement INPUT_ISSUE_NUMBER=42 INPUT_ATTEMPTS_SO_FAR=2)"
249
- assert "a refine dispatch carries no implement attempts" 0 \
250
- "$(route_field implement-attempts EVENT=workflow_dispatch OPERATION=refine INPUT_ISSUE_NUMBER=42 INPUT_ATTEMPTS_SO_FAR=2)"
250
+ "$(route_field attempts EVENT=workflow_dispatch OPERATION=implement INPUT_ISSUE_NUMBER=42 INPUT_ATTEMPTS_SO_FAR=2)"
251
251
  assert_route "release dispatch needs no numbers" release \
252
252
  EVENT=workflow_dispatch OPERATION=release
253
253
  assert_route "reconcile-bot-pr-runs dispatch needs no numbers" reconcile-bot-pr-runs \
@@ -1522,6 +1522,14 @@ if [ -f "$GATE_VALIDATOR" ] && worker_installed merge-gate; then
1522
1522
  gate_case "remediated with two pushes" invalid "$(gate_items "$(gate_comment remediated),${gate_push},${gate_push}")" failure low
1523
1523
  gate_case "an assessment carrying a push" invalid "$(gate_items "$(gate_comment assessed),${gate_push}")" success low
1524
1524
 
1525
+ # Correctness remediation: the agent found the diff does not satisfy the acceptance
1526
+ # criteria, pushed a fix, and emitted remediated. The validator treats this the same as a
1527
+ # CI-failure or conflict remediation -- one push and the remediated word -- regardless of
1528
+ # what acceptanceCriteriaMet says in the report. The next gate cycle re-evaluates the fix.
1529
+ gate_unmet_remedied='{\"findings\":[{\"verified\":false,\"severity\":\"medium\",\"category\":\"correctness\",\"file\":\"src/app.ts\",\"line\":42,\"finding\":\"criterion X not implemented\"}],\"recoverability\":\"high\",\"acceptanceCriteriaMet\":false,\"confidence\":0.9}'
1530
+ gate_case "remediated for unmet acceptance criteria" \
1531
+ remediated "$(gate_items "$(gate_comment remediated "$gate_unmet_remedied"),${gate_push}")" success low
1532
+
1525
1533
  # Output from a worker version that predates the disposition table. Applying its vocabulary
1526
1534
  # would merge on a word this validator no longer means the same thing by.
1527
1535
  gate_case "the old merge vocabulary is refused" invalid '{"items":[{"type":"add_comment","item_number":7,"body":"<!-- agent-merge-gate -->\\n**Verdict:** merge"}]}' success low
@@ -1940,6 +1948,69 @@ if worker_installed audit; then
1940
1948
  if [ "$AUDIT_EMPTY_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
1941
1949
  fi
1942
1950
 
1951
+ echo "── Retrying a run that died early ────────────────────────────────────────"
1952
+
1953
+ # A run that died before producing anything is worth repeating; one that worked and then failed
1954
+ # produced an answer that was wrong, and repeating it buys the same wrong answer later. Only the
1955
+ # implement worker knew that. An Odyssey refine died in three minutes on `Model 'glm-5-3' not
1956
+ # found` -- a gateway fault that clears in seconds -- parked, and waited on the janitor's
1957
+ # six-hourly sweep.
1958
+ RETRY_OK=1
1959
+ for route in refine implement triage apply-review; do
1960
+ worker_md="${WORKFLOWS_DIR}/agent-${route}.md"
1961
+ [ -f "$worker_md" ] || continue
1962
+
1963
+ # Both halves. A worker with only the park is the state this fixes; one with only the retry
1964
+ # never stops trying.
1965
+ if [ "$(count -cE "^ *if:.*steps\.decide\.outputs\.retry == 'true'" "$worker_md")" -eq 0 ] ||
1966
+ [ "$(count -cE "^ *if:.*steps\.decide\.outputs\.retry != 'true'" "$worker_md")" -eq 0 ]; then
1967
+ RETRY_OK=0
1968
+ echo "FAIL: agent-${route} does not have both a retry and a park path, so an early failure either never repeats or never stops" >&2
1969
+ fi
1970
+
1971
+ # Each worker re-dispatches its own route. `-f operation=implement` pasted into refine produces
1972
+ # a plausible run doing the wrong work, and nothing anywhere goes red.
1973
+ # Only the retry dispatch: it is the line that also carries the attempt count. implement
1974
+ # dispatches reconcile-bot-pr-runs too, after it opens a pull request, and that is not a retry.
1975
+ # Only the retry dispatch: it is the line that also carries the attempt count. implement
1976
+ # dispatches reconcile-bot-pr-runs too, after it opens a pull request, and that is not a retry.
1977
+ #
1978
+ # Both greps go through `count`. A worker with no retry dispatch matches nothing, grep exits 1,
1979
+ # and under `set -o pipefail` that killed the whole suite after printing the failure above it --
1980
+ # the tally never ran, so a mutation that removed a retry branch looked like it had been caught
1981
+ # when in fact nothing after it had been checked.
1982
+ dispatched=$(count -E 'operation=[a-z-]+.*attempts_so_far' "$worker_md" \
1983
+ | { count -oE 'operation=[a-z-]+' || true; } | sed 's/operation=//' | sort -u | tr '\n' ' ')
1984
+ dispatched="${dispatched% }"
1985
+ if [ -n "$dispatched" ] && [ "$dispatched" != "$route" ]; then
1986
+ RETRY_OK=0
1987
+ echo "FAIL: agent-${route} re-dispatches '${dispatched}' rather than its own route; that run would do the wrong work and still look healthy" >&2
1988
+ fi
1989
+
1990
+ # The count has to survive the round trip or the park is never reached.
1991
+ if ! grep -qE '^ attempts_so_far:' "$worker_md"; then
1992
+ RETRY_OK=0
1993
+ echo "FAIL: agent-${route} does not take attempts_so_far, so every retry arrives as the first and the budget never binds" >&2
1994
+ fi
1995
+ if [ "$(count -cF 'attempts_so_far: ${{ needs.classify.outputs.attempts }}' "$ROUTER_YML")" -eq 0 ]; then
1996
+ RETRY_OK=0
1997
+ echo "FAIL: the router does not pass classify's attempts to its workers" >&2
1998
+ fi
1999
+ done
2000
+
2001
+ # The classifier carries the count for every dispatched route. refine needs its mode *and* its
2002
+ # count: written as an if/else, a refine retry arrived as attempt zero every time.
2003
+ for op in refine implement triage; do
2004
+ got=$(route_field attempts EVENT=workflow_dispatch OPERATION="$op" INPUT_ISSUE_NUMBER=42 INPUT_ATTEMPTS_SO_FAR=3)
2005
+ assert "a dispatched ${op} carries its attempt count" 3 "$got"
2006
+ done
2007
+ assert "a dispatched apply-review carries its attempt count" 3 \
2008
+ "$(route_field attempts EVENT=workflow_dispatch OPERATION=apply-review INPUT_PR_NUMBER=9 INPUT_ATTEMPTS_SO_FAR=3)"
2009
+ assert "a dispatched refine still carries its mode alongside the count" rerefine \
2010
+ "$(route_field refine-mode EVENT=workflow_dispatch OPERATION=refine INPUT_ISSUE_NUMBER=42 INPUT_MODE=rerefine INPUT_ATTEMPTS_SO_FAR=3)"
2011
+
2012
+ if [ "$RETRY_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2013
+
1943
2014
  echo "── Ways a run ends with nothing ──────────────────────────────────────────"
1944
2015
 
1945
2016
  # A run can end without a pull request in five ways and they need different answers. One sentence
@@ -62,7 +62,7 @@ jobs:
62
62
  api:
63
63
  name: API (.NET)
64
64
  if: github.event_name != 'schedule'
65
- runs-on: agents-arc
65
+ runs-on: RunnerLandingZone
66
66
  timeout-minutes: 30
67
67
  services:
68
68
  sqlserver:
@@ -123,7 +123,7 @@ jobs:
123
123
  mutation-api:
124
124
  name: Mutation (.NET, diff)
125
125
  if: github.event_name == 'pull_request'
126
- runs-on: agents-arc
126
+ runs-on: RunnerLandingZone
127
127
  timeout-minutes: 25
128
128
  defaults:
129
129
  run:
@@ -169,7 +169,7 @@ jobs:
169
169
  web:
170
170
  name: Web (Next.js)
171
171
  if: github.event_name != 'schedule'
172
- runs-on: agents-arc
172
+ runs-on: RunnerLandingZone
173
173
  timeout-minutes: 15
174
174
  steps:
175
175
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -200,7 +200,7 @@ jobs:
200
200
  mutation-web:
201
201
  name: Mutation (web, diff)
202
202
  if: github.event_name == 'pull_request'
203
- runs-on: agents-arc
203
+ runs-on: RunnerLandingZone
204
204
  timeout-minutes: 25
205
205
  steps:
206
206
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -241,14 +241,14 @@ jobs:
241
241
 
242
242
  secret-scan:
243
243
  name: Secret scan (TruffleHog)
244
- runs-on: agents-arc
244
+ runs-on: RunnerLandingZone
245
245
  steps:
246
246
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
247
247
  - run: docker run --rm -v "${{ github.workspace }}:/repo" "$TRUFFLEHOG_IMAGE" filesystem /repo --only-verified --fail --no-update
248
248
 
249
249
  deps-and-iac-scan:
250
250
  name: Dependencies and IaC (Trivy)
251
- runs-on: agents-arc
251
+ runs-on: RunnerLandingZone
252
252
  steps:
253
253
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
254
254
  - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
@@ -262,14 +262,14 @@ jobs:
262
262
 
263
263
  sast-scan:
264
264
  name: SAST (Semgrep)
265
- runs-on: agents-arc
265
+ runs-on: RunnerLandingZone
266
266
  steps:
267
267
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
268
268
  - run: docker run --rm -v "${{ github.workspace }}:/src" -w /src "$SEMGREP_IMAGE" semgrep scan --error --metrics=off --oss-only --disable-version-check --config p/default --config p/security-audit --config p/secrets --config p/csharp --config p/typescript --config p/react --exclude-rule yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
269
269
 
270
270
  sbom:
271
271
  name: SBOM (CycloneDX)
272
- runs-on: agents-arc
272
+ runs-on: RunnerLandingZone
273
273
  steps:
274
274
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
275
275
  - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
@@ -296,7 +296,7 @@ jobs:
296
296
  github.event.pull_request.head.repo.full_name == github.repository &&
297
297
  github.event.pull_request.draft == false &&
298
298
  github.event.pull_request.user.type == 'Bot'
299
- runs-on: agents-arc
299
+ runs-on: RunnerLandingZone
300
300
  timeout-minutes: 5
301
301
  permissions:
302
302
  contents: read
@@ -38,7 +38,7 @@ jobs:
38
38
  lint:
39
39
  name: Lint (Biome)
40
40
  if: github.event_name != 'schedule'
41
- runs-on: agents-arc
41
+ runs-on: RunnerLandingZone
42
42
  steps:
43
43
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
44
44
  - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
@@ -51,7 +51,7 @@ jobs:
51
51
  web:
52
52
  name: Web (Next.js static export)
53
53
  if: github.event_name != 'schedule'
54
- runs-on: agents-arc
54
+ runs-on: RunnerLandingZone
55
55
  steps:
56
56
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
57
57
  - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
@@ -73,7 +73,7 @@ jobs:
73
73
  mutation-web:
74
74
  name: Mutation (web, diff)
75
75
  if: github.event_name == 'pull_request'
76
- runs-on: agents-arc
76
+ runs-on: RunnerLandingZone
77
77
  timeout-minutes: 25
78
78
  steps:
79
79
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -103,7 +103,7 @@ jobs:
103
103
  name: E2E (Playwright)
104
104
  if: github.event_name != 'schedule'
105
105
  needs: web
106
- runs-on: agents-arc
106
+ runs-on: RunnerLandingZone
107
107
  steps:
108
108
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
109
109
  - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
@@ -128,7 +128,7 @@ jobs:
128
128
  name: Desktop (Electron)
129
129
  if: github.event_name != 'schedule'
130
130
  needs: web
131
- runs-on: agents-arc
131
+ runs-on: RunnerLandingZone
132
132
  steps:
133
133
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
134
134
  - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
@@ -152,7 +152,7 @@ jobs:
152
152
  mobile:
153
153
  name: Mobile (Capacitor)
154
154
  if: github.event_name != 'schedule'
155
- runs-on: agents-arc
155
+ runs-on: RunnerLandingZone
156
156
  steps:
157
157
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
158
158
  - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
@@ -171,14 +171,14 @@ jobs:
171
171
 
172
172
  secret-scan:
173
173
  name: Secret scan (TruffleHog)
174
- runs-on: agents-arc
174
+ runs-on: RunnerLandingZone
175
175
  steps:
176
176
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
177
177
  - run: docker run --rm -v "${{ github.workspace }}:/repo" "$TRUFFLEHOG_IMAGE" filesystem /repo --only-verified --fail --no-update
178
178
 
179
179
  deps-and-iac-scan:
180
180
  name: Dependencies and IaC (Trivy)
181
- runs-on: agents-arc
181
+ runs-on: RunnerLandingZone
182
182
  steps:
183
183
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
184
184
  - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
@@ -192,14 +192,14 @@ jobs:
192
192
 
193
193
  sast-scan:
194
194
  name: SAST (Semgrep)
195
- runs-on: agents-arc
195
+ runs-on: RunnerLandingZone
196
196
  steps:
197
197
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
198
198
  - run: docker run --rm -v "${{ github.workspace }}:/src" -w /src "$SEMGREP_IMAGE" semgrep scan --error --metrics=off --oss-only --disable-version-check --config p/default --config p/security-audit --config p/secrets --config p/typescript --config p/react --exclude-rule yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
199
199
 
200
200
  sbom:
201
201
  name: SBOM (CycloneDX)
202
- runs-on: agents-arc
202
+ runs-on: RunnerLandingZone
203
203
  steps:
204
204
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
205
205
  - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
@@ -226,7 +226,7 @@ jobs:
226
226
  github.event.pull_request.head.repo.full_name == github.repository &&
227
227
  github.event.pull_request.draft == false &&
228
228
  github.event.pull_request.user.type == 'Bot'
229
- runs-on: agents-arc
229
+ runs-on: RunnerLandingZone
230
230
  timeout-minutes: 5
231
231
  permissions:
232
232
  contents: read
@@ -12,7 +12,7 @@ permissions:
12
12
  jobs:
13
13
  publish:
14
14
  name: Publish release
15
- runs-on: [self-hosted, linux, agents]
15
+ runs-on: ubuntu-latest
16
16
  timeout-minutes: 10
17
17
  steps:
18
18
  - name: Create or update GitHub release
@@ -12,6 +12,18 @@ env:
12
12
  STALLED_LABEL: stalled
13
13
  PR_PENDING_LABEL: pr-pending
14
14
  REVIEW_MARKER: "<!-- agent-apply-review -->"
15
+ # A run that died before it produced anything is worth repeating; one that worked and then
16
+ # failed produced an answer that was wrong, and repeating it buys the same wrong answer later.
17
+ # Duration is what separates them. Odyssey #190 died in three minutes on `Model 'glm-5-3' not
18
+ # found`, a gateway fault that clears in seconds, and waited on the janitor's six-hourly sweep
19
+ # because only the implement worker could do this.
20
+ APPLY_REVIEW_ATTEMPT_MARKER: "<!-- agent-apply-review-attempt -->"
21
+ MAX_ATTEMPTS: "5"
22
+ PARK_AT_ATTEMPT: "4"
23
+ # Ten rather than six: the failure that prompted this took three minutes, and a slower one on a
24
+ # worse day would fall outside a six-minute window and park for a fault that clears by itself.
25
+ RETRY_UNDER_MINUTES: "10"
26
+ RETRY_COMMENT: "That is what a provider outage looks like -- the run ended before it could produce an answer -- so this is being tried again from the start. It is a fresh run rather than a continuation: nothing is carried over from the attempt that failed."
15
27
  INCOMPLETE_COMMENT: "Applying the review feedback ended without an outcome. This worker has no retry of its own: it runs again when somebody reviews or comments on the pull request, and the issue is flagged so it is not lost until then."
16
28
  ISSUE_CONTEXT_PATH: /tmp/gh-aw/agent/issue-context.json
17
29
  GH_AW_ALLOWED_BOTS: "platform-devbox[bot],github-actions[bot]"
@@ -38,6 +50,11 @@ imports:
38
50
  on:
39
51
  workflow_call:
40
52
  inputs:
53
+ attempts_so_far:
54
+ description: Runs already made for this work that died before producing an answer. Filled by the worker when it re-dispatches itself, not by people.
55
+ required: false
56
+ type: string
57
+ default: '0'
41
58
  pr-number:
42
59
  description: Pull request number to apply review feedback on.
43
60
  required: true
@@ -285,6 +302,8 @@ jobs:
285
302
  permissions:
286
303
  contents: read
287
304
  issues: write
305
+ # the retry re-enters through the router, which is a workflow_dispatch
306
+ actions: write
288
307
  steps:
289
308
  - name: Checkout workflow actions
290
309
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -296,13 +315,58 @@ jobs:
296
315
  with:
297
316
  client-id: ${{ secrets.BOT_APP_ID }}
298
317
  private-key: ${{ secrets.BOT_PRIVATE_KEY }}
318
+ - name: Decide whether this failure is worth repeating
319
+ id: decide
320
+ uses: ./.github/actions/decide-agent-retry
321
+ with:
322
+ token: ${{ github.token }}
323
+ attempts-so-far: ${{ inputs.attempts_so_far }}
324
+ park-at: ${{ env.PARK_AT_ATTEMPT }}
325
+ under-minutes: ${{ env.RETRY_UNDER_MINUTES }}
326
+ # Recorded before any label moves, so a failure in the steps below leaves a run that can be
327
+ # counted rather than work released with nothing to show for it.
328
+ - name: Report the failed attempt
329
+ if: steps.decide.outputs.retry == 'true'
330
+ uses: ./.github/actions/create-issue-comment
331
+ with:
332
+ token: ${{ steps.app-token.outputs.token }}
333
+ issue-number: ${{ needs.subject.outputs.issue }}
334
+ body: |
335
+ ${{ env.APPLY_REVIEW_ATTEMPT_MARKER }}
336
+ Attempt ${{ steps.decide.outputs.next }} of ${{ env.MAX_ATTEMPTS }} ended after ${{ steps.decide.outputs.minutes }} minutes, before the run could produce an answer.
337
+ ${{ env.RETRY_COMMENT }}
338
+ [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
339
+ - name: Release the reservation for the retry
340
+ if: steps.decide.outputs.retry == 'true'
341
+ uses: ./.github/actions/remove-issue-labels
342
+ with:
343
+ token: ${{ steps.app-token.outputs.token }}
344
+ issue-number: ${{ needs.subject.outputs.issue }}
345
+ labels: ${{ env.WORKING_LABEL }}
346
+ - name: Send the work back through the router
347
+ if: steps.decide.outputs.retry == 'true'
348
+ env:
349
+ GH_TOKEN: ${{ github.token }}
350
+ REPO: ${{ github.repository }}
351
+ REF: ${{ github.event.repository.default_branch }}
352
+ SUBJECT: ${{ inputs.pr-number }}
353
+ NEXT: ${{ steps.decide.outputs.next }}
354
+ run: |
355
+ set -euo pipefail
356
+ # The provider recovers in seconds, so pause before re-entering rather than dispatching
357
+ # back into the same outage. The router's own classify and authorize jobs add more.
358
+ sleep 30
359
+ gh workflow run work-router.yml --repo "$REPO" --ref "$REF" -f operation=apply-review -f pr-number="$SUBJECT" -f attempts_so_far="$NEXT"
360
+ echo "Re-dispatched apply-review for $SUBJECT as attempt $NEXT."
299
361
  - name: Release the issue
362
+ if: steps.decide.outputs.retry != 'true'
300
363
  uses: ./.github/actions/remove-issue-labels
301
364
  with:
302
365
  token: ${{ steps.app-token.outputs.token }}
303
366
  issue-number: ${{ needs.subject.outputs.issue }}
304
367
  labels: ${{ env.WORKING_LABEL }}
305
368
  - name: Flag for human review
369
+ if: steps.decide.outputs.retry != 'true'
306
370
  uses: ./.github/actions/add-issue-labels
307
371
  with:
308
372
  token: ${{ steps.app-token.outputs.token }}
@@ -311,6 +375,7 @@ jobs:
311
375
  ${{ env.REVIEW_LABEL }}
312
376
  ${{ env.STALLED_LABEL }}
313
377
  - name: Report missing review feedback outcome
378
+ if: steps.decide.outputs.retry != 'true'
314
379
  uses: ./.github/actions/create-issue-comment
315
380
  with:
316
381
  token: ${{ steps.app-token.outputs.token }}
@@ -898,9 +898,35 @@ timeout-minutes: 120
898
898
  reassurance about their absence.
899
899
 
900
900
  **5c. Check the acceptance criteria.** The issue context at `${{ env.ISSUE_CONTEXT_PATH }}`
901
- says what this change was supposed to do. Confirm the diff does it. Set
902
- `acceptanceCriteriaMet` to false only when you can name a criterion the diff does not
903
- satisfy.
901
+ says what this change was supposed to do. Confirm the diff does it. The double-check the
902
+ rest of this step asks you to do is the gate's own: a defect an agent did not notice and CI
903
+ did not catch is a more expensive rollback than a wrong auto-merge.
904
+
905
+ Set `acceptanceCriteriaMet` to `false` when you can name a criterion the diff does not
906
+ satisfy. Name every criterion that is missing or wrong, in `reason` and in `findings` if
907
+ the gap is a defect (it often is). An empty `findings` array with
908
+ `acceptanceCriteriaMet: false` is accepted but carries little evidence: prefer one entry
909
+ per gap, with `category: correctness`, the file and line, and a `suggestedFix`.
910
+
911
+ **Correctness remediation.** When `acceptanceCriteriaMet` is `false`, you may fix the
912
+ gap and push the fix the same way you fix a failed CI run — this is the same
913
+ `remediated` verdict the merge-conflict and CI-failure paths use, and it re-runs CI and
914
+ the gate on the new head. To do it:
915
+
916
+ - Produce the patch that closes each unmet criterion. Every criterion you named must be
917
+ addressed by the patch, or the next gate cycle reproduces this verdict.
918
+ - Run the verification commands below (scoped to the files you changed) before the push.
919
+ - Push the fix using `push_to_pull_request_branch` (pr_number: ${{ needs.subject.outputs.pr }},
920
+ branch: the current PR branch), then emit the `add_comment` with
921
+ **Verdict:** remediated. Set `acceptanceCriteriaMet` to `false` in the report: the
922
+ criteria were unmet when you reviewed, and the fix is what addresses them. The next
923
+ cycle validates the fix.
924
+ - Do not rebase, reset, amend or otherwise rewrite history: the push is fast-forward only.
925
+
926
+ If you cannot fix a criterion in one pass — it needs a decision, a question, or a code path
927
+ you cannot trace — do not push. Report `assessed` with `acceptanceCriteriaMet: false` and no
928
+ push. The workflow sends the pull request to a human, which is the correct action when the
929
+ gap is beyond a focused repair.
904
930
 
905
931
  **5d. Answer the recoverability checklist.** How easy would this be to undo if it were
906
932
  wrong? Cite the diff for each answer, and record the ones that fired in
@@ -991,8 +1017,9 @@ timeout-minutes: 120
991
1017
 
992
1018
  7. Say which of two things you did, and nothing more.
993
1019
 
994
- - **`remediated`** — CI failed or the branch conflicted, you fixed it, you verified the fix,
995
- and you are pushing it. Exactly one `push_to_pull_request_branch` goes with this word.
1020
+ - **`remediated`** — something was wrong (CI failed, the branch conflicted, or the diff did
1021
+ not satisfy the acceptance criteria), you fixed it, you verified the fix, and you are
1022
+ pushing it. Exactly one `push_to_pull_request_branch` goes with this word.
996
1023
  - **`assessed`** — you reviewed the change and are reporting what you found. No push.
997
1024
 
998
1025
  These are the only two words the workflow accepts. You do not write `merge`, `review`,
@@ -1041,6 +1068,12 @@ timeout-minutes: 120
1041
1068
 
1042
1069
  `"findings": []` on a clean change is the expected output, not a failure to do the job.
1043
1070
 
1071
+ When `acceptanceCriteriaMet` is `false` and you are pushing a fix, the verdict must be
1072
+ `remediated`, not `assessed`: the validator refuses an `assessed` verdict that carries a
1073
+ push. Set `acceptanceCriteriaMet` to `false` in the report you push with the fix — it
1074
+ describes the code you reviewed, not the fix you just produced. The next gate cycle
1075
+ re-evaluates the updated diff and sets it to `true` (or finds another gap).
1076
+
1044
1077
  The workflow applies comments, labels, merges, and closures with the App token. Reading the
1045
1078
  repository, running verification commands and delegating a finding to be checked are all part
1046
1079
  of the job. What is restricted is what leaves this run: the only safe outputs you may call are
@@ -24,6 +24,18 @@ env:
24
24
  # re-running a decision produces the same decision. Created idempotently where it is applied.
25
25
  STALLED_LABEL: stalled
26
26
  REFINE_MARKER: "<!-- agent-refine -->"
27
+ # A run that died before it produced anything is worth repeating; one that worked and then
28
+ # failed produced an answer that was wrong, and repeating it buys the same wrong answer later.
29
+ # Duration is what separates them. Odyssey #190 died in three minutes on `Model 'glm-5-3' not
30
+ # found`, a gateway fault that clears in seconds, and waited on the janitor's six-hourly sweep
31
+ # because only the implement worker could do this.
32
+ REFINE_ATTEMPT_MARKER: "<!-- agent-refine-attempt -->"
33
+ MAX_ATTEMPTS: "5"
34
+ PARK_AT_ATTEMPT: "4"
35
+ # Ten rather than six: the failure that prompted this took three minutes, and a slower one on a
36
+ # worse day would fall outside a six-minute window and park for a fault that clears by itself.
37
+ RETRY_UNDER_MINUTES: "10"
38
+ RETRY_COMMENT: "That is what a provider outage looks like -- the run ended before it could produce an answer -- so this is being tried again from the start. It is a fresh run rather than a continuation: nothing is carried over from the attempt that failed."
27
39
  DRAFT_MARKER: "<!-- agent-refine-draft -->"
28
40
  INITIAL_MODE: first
29
41
  RESPONSE_MODE: rerefine
@@ -72,6 +84,11 @@ imports:
72
84
  on:
73
85
  workflow_call:
74
86
  inputs:
87
+ attempts_so_far:
88
+ description: Runs already made for this work that died before producing an answer. Filled by the worker when it re-dispatches itself, not by people.
89
+ required: false
90
+ type: string
91
+ default: '0'
75
92
  issue-number:
76
93
  description: Issue number to refine.
77
94
  required: true
@@ -369,6 +386,8 @@ jobs:
369
386
  permissions:
370
387
  contents: read
371
388
  issues: write
389
+ # the retry re-enters through the router, which is a workflow_dispatch
390
+ actions: write
372
391
  steps:
373
392
  - name: Checkout workflow actions
374
393
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -378,13 +397,59 @@ jobs:
378
397
  with:
379
398
  client-id: ${{ secrets.BOT_APP_ID }}
380
399
  private-key: ${{ secrets.BOT_PRIVATE_KEY }}
400
+ - name: Decide whether this failure is worth repeating
401
+ id: decide
402
+ uses: ./.github/actions/decide-agent-retry
403
+ with:
404
+ token: ${{ github.token }}
405
+ attempts-so-far: ${{ inputs.attempts_so_far }}
406
+ park-at: ${{ env.PARK_AT_ATTEMPT }}
407
+ under-minutes: ${{ env.RETRY_UNDER_MINUTES }}
408
+ # Recorded before any label moves, so a failure in the steps below leaves a run that can be
409
+ # counted rather than work released with nothing to show for it.
410
+ - name: Report the failed attempt
411
+ if: steps.decide.outputs.retry == 'true'
412
+ uses: ./.github/actions/create-issue-comment
413
+ with:
414
+ token: ${{ steps.app-token.outputs.token }}
415
+ issue-number: ${{ inputs.issue-number }}
416
+ body: |
417
+ ${{ env.REFINE_ATTEMPT_MARKER }}
418
+ Attempt ${{ steps.decide.outputs.next }} of ${{ env.MAX_ATTEMPTS }} ended after ${{ steps.decide.outputs.minutes }} minutes, before the run could produce an answer.
419
+ ${{ env.RETRY_COMMENT }}
420
+ [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
421
+ - name: Release the reservation for the retry
422
+ if: steps.decide.outputs.retry == 'true'
423
+ uses: ./.github/actions/remove-issue-labels
424
+ with:
425
+ token: ${{ steps.app-token.outputs.token }}
426
+ issue-number: ${{ inputs.issue-number }}
427
+ labels: ${{ env.WORKING_LABEL }}
428
+ - name: Send the work back through the router
429
+ if: steps.decide.outputs.retry == 'true'
430
+ env:
431
+ GH_TOKEN: ${{ github.token }}
432
+ REPO: ${{ github.repository }}
433
+ REF: ${{ github.event.repository.default_branch }}
434
+ SUBJECT: ${{ inputs.issue-number }}
435
+ NEXT: ${{ steps.decide.outputs.next }}
436
+ MODE: ${{ inputs.mode }}
437
+ run: |
438
+ set -euo pipefail
439
+ # The provider recovers in seconds, so pause before re-entering rather than dispatching
440
+ # back into the same outage. The router's own classify and authorize jobs add more.
441
+ sleep 30
442
+ gh workflow run work-router.yml --repo "$REPO" --ref "$REF" -f operation=refine -f issue-number="$SUBJECT" -f mode="$MODE" -f attempts_so_far="$NEXT"
443
+ echo "Re-dispatched refine for $SUBJECT as attempt $NEXT."
381
444
  - name: Release the issue
445
+ if: steps.decide.outputs.retry != 'true'
382
446
  uses: ./.github/actions/remove-issue-labels
383
447
  with:
384
448
  token: ${{ steps.app-token.outputs.token }}
385
449
  issue-number: ${{ inputs.issue-number }}
386
450
  labels: ${{ env.WORKING_LABEL }}
387
451
  - name: Flag for human review
452
+ if: steps.decide.outputs.retry != 'true'
388
453
  uses: ./.github/actions/add-issue-labels
389
454
  with:
390
455
  token: ${{ steps.app-token.outputs.token }}
@@ -393,6 +458,7 @@ jobs:
393
458
  ${{ env.REVIEW_LABEL }}
394
459
  ${{ env.STALLED_LABEL }}
395
460
  - name: Report missing refinement outcome
461
+ if: steps.decide.outputs.retry != 'true'
396
462
  uses: ./.github/actions/create-issue-comment
397
463
  with:
398
464
  token: ${{ steps.app-token.outputs.token }}
@@ -17,6 +17,18 @@ env:
17
17
  STALLED_LABEL: stalled
18
18
  REFINE_LABEL: refine
19
19
  TRIAGE_MARKER: "<!-- agent-triage -->"
20
+ # A run that died before it produced anything is worth repeating; one that worked and then
21
+ # failed produced an answer that was wrong, and repeating it buys the same wrong answer later.
22
+ # Duration is what separates them. Odyssey #190 died in three minutes on `Model 'glm-5-3' not
23
+ # found`, a gateway fault that clears in seconds, and waited on the janitor's six-hourly sweep
24
+ # because only the implement worker could do this.
25
+ TRIAGE_ATTEMPT_MARKER: "<!-- agent-triage-attempt -->"
26
+ MAX_ATTEMPTS: "5"
27
+ PARK_AT_ATTEMPT: "4"
28
+ # Ten rather than six: the failure that prompted this took three minutes, and a slower one on a
29
+ # worse day would fall outside a six-minute window and park for a fault that clears by itself.
30
+ RETRY_UNDER_MINUTES: "10"
31
+ RETRY_COMMENT: "That is what a provider outage looks like -- the run ended before it could produce an answer -- so this is being tried again from the start. It is a fresh run rather than a continuation: nothing is carried over from the attempt that failed."
20
32
  MAX_TRIAGE_ROUNDS: "3"
21
33
  INCOMPLETE_COMMENT: "Automated triage ended without an outcome. The triage label remains for a retry."
22
34
  SAFE_OUTPUT_COMMENT_PREFIX: "Triage assessment"
@@ -61,6 +73,11 @@ imports:
61
73
  on:
62
74
  workflow_call:
63
75
  inputs:
76
+ attempts_so_far:
77
+ description: Runs already made for this work that died before producing an answer. Filled by the worker when it re-dispatches itself, not by people.
78
+ required: false
79
+ type: string
80
+ default: '0'
64
81
  issue-number:
65
82
  description: Issue number to triage.
66
83
  required: true
@@ -295,6 +312,8 @@ jobs:
295
312
  permissions:
296
313
  contents: read
297
314
  issues: write
315
+ # the retry re-enters through the router, which is a workflow_dispatch
316
+ actions: write
298
317
  steps:
299
318
  - name: Checkout workflow actions
300
319
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -304,13 +323,59 @@ jobs:
304
323
  with:
305
324
  client-id: ${{ secrets.BOT_APP_ID }}
306
325
  private-key: ${{ secrets.BOT_PRIVATE_KEY }}
326
+ - name: Decide whether this failure is worth repeating
327
+ id: decide
328
+ uses: ./.github/actions/decide-agent-retry
329
+ with:
330
+ token: ${{ github.token }}
331
+ attempts-so-far: ${{ inputs.attempts_so_far }}
332
+ park-at: ${{ env.PARK_AT_ATTEMPT }}
333
+ under-minutes: ${{ env.RETRY_UNDER_MINUTES }}
334
+ # Recorded before any label moves, so a failure in the steps below leaves a run that can be
335
+ # counted rather than work released with nothing to show for it.
336
+ - name: Report the failed attempt
337
+ if: steps.decide.outputs.retry == 'true'
338
+ uses: ./.github/actions/create-issue-comment
339
+ with:
340
+ token: ${{ steps.app-token.outputs.token }}
341
+ issue-number: ${{ inputs.issue-number }}
342
+ body: |
343
+ ${{ env.TRIAGE_ATTEMPT_MARKER }}
344
+ Attempt ${{ steps.decide.outputs.next }} of ${{ env.MAX_ATTEMPTS }} ended after ${{ steps.decide.outputs.minutes }} minutes, before the run could produce an answer.
345
+ ${{ env.RETRY_COMMENT }}
346
+ [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
347
+ - name: Release the reservation for the retry
348
+ if: steps.decide.outputs.retry == 'true'
349
+ uses: ./.github/actions/remove-issue-labels
350
+ with:
351
+ token: ${{ steps.app-token.outputs.token }}
352
+ issue-number: ${{ inputs.issue-number }}
353
+ labels: ${{ env.WORKING_LABEL }}
354
+ - name: Send the work back through the router
355
+ if: steps.decide.outputs.retry == 'true'
356
+ env:
357
+ GH_TOKEN: ${{ github.token }}
358
+ REPO: ${{ github.repository }}
359
+ REF: ${{ github.event.repository.default_branch }}
360
+ SUBJECT: ${{ inputs.issue-number }}
361
+ NEXT: ${{ steps.decide.outputs.next }}
362
+ MODE: ${{ inputs.mode }}
363
+ run: |
364
+ set -euo pipefail
365
+ # The provider recovers in seconds, so pause before re-entering rather than dispatching
366
+ # back into the same outage. The router's own classify and authorize jobs add more.
367
+ sleep 30
368
+ gh workflow run work-router.yml --repo "$REPO" --ref "$REF" -f operation=triage -f issue-number="$SUBJECT" -f mode="$MODE" -f attempts_so_far="$NEXT"
369
+ echo "Re-dispatched triage for $SUBJECT as attempt $NEXT."
307
370
  - name: Release the issue
371
+ if: steps.decide.outputs.retry != 'true'
308
372
  uses: ./.github/actions/remove-issue-labels
309
373
  with:
310
374
  token: ${{ steps.app-token.outputs.token }}
311
375
  issue-number: ${{ inputs.issue-number }}
312
376
  labels: ${{ env.WORKING_LABEL }}
313
377
  - name: Flag for human review
378
+ if: steps.decide.outputs.retry != 'true'
314
379
  uses: ./.github/actions/add-issue-labels
315
380
  with:
316
381
  token: ${{ steps.app-token.outputs.token }}
@@ -319,6 +384,7 @@ jobs:
319
384
  ${{ env.REVIEW_LABEL }}
320
385
  ${{ env.STALLED_LABEL }}
321
386
  - name: Report missing triage outcome
387
+ if: steps.decide.outputs.retry != 'true'
322
388
  uses: ./.github/actions/create-issue-comment
323
389
  with:
324
390
  token: ${{ steps.app-token.outputs.token }}
@@ -358,7 +358,7 @@ jobs:
358
358
  ci-conclusion: ${{ steps.route.outputs.ci-conclusion }}
359
359
  ci-run-id: ${{ steps.route.outputs.ci-run-id }}
360
360
  merge-gate-attempts: ${{ steps.route.outputs.merge-gate-attempts }}
361
- implement-attempts: ${{ steps.route.outputs.implement-attempts }}
361
+ attempts: ${{ steps.route.outputs.attempts }}
362
362
  refine-mode: ${{ steps.route.outputs.refine-mode }}
363
363
  triage-mode: ${{ steps.route.outputs.triage-mode }}
364
364
  trigger-kind: ${{ steps.route.outputs.trigger-kind }}
@@ -388,6 +388,7 @@ jobs:
388
388
  with:
389
389
  issue-number: ${{ needs.classify.outputs.issue-number }}
390
390
  mode: ${{ needs.classify.outputs.refine-mode }}
391
+ attempts_so_far: ${{ needs.classify.outputs.attempts }}
391
392
  secrets:
392
393
  OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
393
394
  CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
@@ -481,7 +482,7 @@ jobs:
481
482
  permissions: write-all
482
483
  with:
483
484
  issue-number: ${{ needs.classify.outputs.issue-number }}
484
- attempts_so_far: ${{ needs.classify.outputs.implement-attempts }}
485
+ attempts_so_far: ${{ needs.classify.outputs.attempts }}
485
486
  secrets:
486
487
  OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
487
488
  CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
@@ -533,6 +534,7 @@ jobs:
533
534
  with:
534
535
  issue-number: ${{ needs.classify.outputs.issue-number }}
535
536
  mode: ${{ needs.classify.outputs.triage-mode }}
537
+ attempts_so_far: ${{ needs.classify.outputs.attempts }}
536
538
  secrets:
537
539
  OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
538
540
  CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
@@ -552,6 +554,7 @@ jobs:
552
554
  with:
553
555
  pr-number: ${{ needs.classify.outputs.pr-number }}
554
556
  linked-issue: ${{ needs.classify.outputs.linked-issue }}
557
+ attempts_so_far: ${{ needs.classify.outputs.attempts }}
555
558
  secrets:
556
559
  OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
557
560
  CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",
@@ -25,13 +25,6 @@
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
- },
35
28
  "engines": {
36
29
  "node": ">=20.19"
37
30
  },
@@ -42,5 +35,11 @@
42
35
  "@types/node": "^22.0.0",
43
36
  "typescript": "^5.8.0",
44
37
  "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"
45
44
  }
46
- }
45
+ }