@plainconceptsplatform/workflows 0.20.3 → 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 +0 -0
- package/loops/actions/classify-route/action.yml +100 -100
- package/loops/actions/classify-route/classify-route.sh +10 -9
- package/loops/actions/decide-agent-retry/action.yml +72 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +223 -19
- package/loops/templates/ci/app-ci-dotnet-next.yml +9 -9
- package/loops/templates/ci/app-ci-node-monorepo.yml +11 -11
- package/loops/templates/release/github-release.yml +1 -1
- package/loops/workflows/agent-apply-review.md +65 -0
- package/loops/workflows/agent-implement.md +99 -6
- package/loops/workflows/agent-merge-gate.md +42 -7
- package/loops/workflows/agent-refine.md +66 -0
- package/loops/workflows/agent-triage.md +66 -0
- package/loops/workflows/shared/opencode-ci.md +206 -206
- package/loops/workflows/work-router.yml +7 -4
- package/package.json +8 -9
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
|
-
|
|
31
|
-
description:
|
|
32
|
-
value: ${{ steps.classify.outputs.
|
|
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"
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
else
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
244
|
-
#
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
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 \
|
|
@@ -372,6 +372,52 @@ else
|
|
|
372
372
|
fi
|
|
373
373
|
if [ "$MIRROR_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
374
374
|
|
|
375
|
+
# Third-party action pins are the same shape of problem. Every `uses:` in a package-managed
|
|
376
|
+
# file is pinned to a full commit SHA, and one action must resolve to exactly one SHA across
|
|
377
|
+
# the whole package: the router, the workers, the shared mechanics and the composite actions
|
|
378
|
+
# are installed together, so two pins for one action mean two versions of it run in the same
|
|
379
|
+
# repository, and the older one is invisible until it breaks. That is how the merge-belt
|
|
380
|
+
# dispatchers ran create-github-app-token v2.0.6 for a release while every worker ran v3.2.0:
|
|
381
|
+
# nothing failed, nothing flagged it, and the action that receives the App's private key was
|
|
382
|
+
# the stale one. Templates are excluded: they are consumer-owned from installation and
|
|
383
|
+
# deliberately carry their own versions (the CI templates pin download-artifact v6 while the
|
|
384
|
+
# loops pin v8). A floating tag (`@v4`) is a missing pin and fails the same check.
|
|
385
|
+
PIN_OK=1
|
|
386
|
+
declare -A PIN_SEEN
|
|
387
|
+
PKG_ROOT="$(cd "${HERE}/../../.." && pwd)"
|
|
388
|
+
pin_scan() {
|
|
389
|
+
local file="$1" line action ref
|
|
390
|
+
# Resolve to an absolute path so the package-relative name in the failure reads like the
|
|
391
|
+
# source tree, not like this script's location.
|
|
392
|
+
file="$(cd "$(dirname "$file")" && pwd)/$(basename "$file")"
|
|
393
|
+
local shown="${file#"${PKG_ROOT}/"}"
|
|
394
|
+
# Anchored on the line start so a comment quoting a `uses:` cannot match, and tolerant of
|
|
395
|
+
# both spellings (`uses:` as a mapping key and `- uses:` as a list item). Local composite
|
|
396
|
+
# actions (`./.github/actions/...`) and docker refs carry no `@` and never match.
|
|
397
|
+
local pin_re='^[[:space:]]*-?[[:space:]]*uses:[[:space:]]+([A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+)@([A-Za-z0-9._-]+)'
|
|
398
|
+
while IFS= read -r line; do
|
|
399
|
+
[[ "$line" =~ $pin_re ]] || continue
|
|
400
|
+
action="${BASH_REMATCH[1]}"
|
|
401
|
+
ref="${BASH_REMATCH[2]}"
|
|
402
|
+
if [ "${PIN_SEEN[$action]+set}" ] && [ "${PIN_SEEN[$action]}" != "$ref" ]; then
|
|
403
|
+
PIN_OK=0
|
|
404
|
+
echo "FAIL: ${shown} pins ${action}@${ref} but ${PIN_SEEN[$action]} is pinned elsewhere; one action, one pin" >&2
|
|
405
|
+
else
|
|
406
|
+
PIN_SEEN[$action]="$ref"
|
|
407
|
+
fi
|
|
408
|
+
if [ "${#ref}" -ne 40 ]; then
|
|
409
|
+
PIN_OK=0
|
|
410
|
+
echo "FAIL: ${shown} uses ${action}@${ref}; a tag is mutable, pin the full commit SHA" >&2
|
|
411
|
+
fi
|
|
412
|
+
done < "$file"
|
|
413
|
+
}
|
|
414
|
+
for pin_file in "$ROUTER_YML" "${WORKFLOWS_DIR}"/agent-*.md "${WORKFLOWS_DIR}"/shared/*.md \
|
|
415
|
+
"${WORKFLOWS_DIR}"/authorize-bot-work.yml "${HERE}/.."/*/action.yml; do
|
|
416
|
+
[ -f "$pin_file" ] || continue
|
|
417
|
+
pin_scan "$pin_file"
|
|
418
|
+
done
|
|
419
|
+
if [ "$PIN_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
420
|
+
|
|
375
421
|
# Run the belt's own jq, rather than reading it. The filter that picks which open pull requests
|
|
376
422
|
# the hourly reconcile job acts on was written as
|
|
377
423
|
# ((env.TRUSTED_BOTS | split(" ")) | index(.user.login) != null)
|
|
@@ -841,19 +887,16 @@ if worker_installed implement; then
|
|
|
841
887
|
PUSH_FALLBACK_OK=0
|
|
842
888
|
echo "FAIL: implement gates a no-pull-request path on code_push_failure_count, which is 0 when gh-aw files the patch as an issue" >&2
|
|
843
889
|
fi
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
#
|
|
851
|
-
|
|
852
|
-
# have been merged or duplicated.
|
|
853
|
-
if [ "$(count -cF 'env.PUSH_CONFLICT_COMMENT' "$IMPLEMENT_WORKER_MD")" -ne 1 ] ||
|
|
854
|
-
[ "$(count -cF 'env.NO_PULL_REQUEST_COMMENT' "$IMPLEMENT_WORKER_MD")" -ne 1 ]; then
|
|
890
|
+
grep -qF 'env.PUSH_CONFLICT_COMMENT' "$IMPLEMENT_WORKER_MD" || {
|
|
891
|
+
PUSH_FALLBACK_OK=0
|
|
892
|
+
echo "FAIL: implement no longer says env.PUSH_CONFLICT_COMMENT on any path" >&2
|
|
893
|
+
}
|
|
894
|
+
# Collapsing this back into whatever covers the other endings is the regression this guards: it
|
|
895
|
+
# is defined once in the env block and printed on exactly one path. The four messages it used to
|
|
896
|
+
# be paired with are asserted together under "Ways a run ends with nothing".
|
|
897
|
+
if [ "$(count -cF 'env.PUSH_CONFLICT_COMMENT' "$IMPLEMENT_WORKER_MD")" -ne 1 ]; then
|
|
855
898
|
PUSH_FALLBACK_OK=0
|
|
856
|
-
echo "FAIL: implement should print
|
|
899
|
+
echo "FAIL: implement should print the conflicted-push message on exactly one path" >&2
|
|
857
900
|
fi
|
|
858
901
|
# And the two paths must be mutually exclusive, or a conflicted push gets both comments.
|
|
859
902
|
if [ "$(count -cF "process_safe_outputs_items_succeeded != '0'" "$IMPLEMENT_WORKER_MD")" -lt 2 ] ||
|
|
@@ -1202,6 +1245,31 @@ echo "── Merge gate validator ───────────────
|
|
|
1202
1245
|
# reasoning alone, and the worker has not run in production since, so these fixtures are the only
|
|
1203
1246
|
# evidence the change is right. Executing the real script is the same technique that finally
|
|
1204
1247
|
# caught the belt's jq bug, which every reading assertion had walked past.
|
|
1248
|
+
# The gate comments on every bot pull request, so anything it writes there is written dozens of
|
|
1249
|
+
# times a week. A CODEOWNERS of `* @someone` matches every pull request, so the owners row was
|
|
1250
|
+
# naming four people on routine auto-merges and @-mentioning each of them, which notifies them.
|
|
1251
|
+
# The row belongs to the disposition that actually wants an owner, and the names go in a code
|
|
1252
|
+
# span so the comment reports who owns the area without pinging them: on owner-review the gate
|
|
1253
|
+
# requests the review properly, and that is the notification.
|
|
1254
|
+
if worker_installed merge-gate; then
|
|
1255
|
+
OWNERS_OK=1
|
|
1256
|
+
if ! grep -q "outcome == 'owner-review'" "$MERGE_GATE_WORKER_MD"; then
|
|
1257
|
+
OWNERS_OK=0
|
|
1258
|
+
echo "FAIL: the gate comment names required owners unconditionally; gate the row on owner-review" >&2
|
|
1259
|
+
fi
|
|
1260
|
+
# Every rendering of required_owners has to sit inside a code span.
|
|
1261
|
+
while IFS= read -r line; do
|
|
1262
|
+
case "$line" in
|
|
1263
|
+
*'`{0}`'*|*'`${{ needs.protected_changes.outputs.required_owners'*) continue ;;
|
|
1264
|
+
*)
|
|
1265
|
+
OWNERS_OK=0
|
|
1266
|
+
echo "FAIL: required_owners is rendered without a code span, so the gate @-mentions people: ${line# }" >&2
|
|
1267
|
+
;;
|
|
1268
|
+
esac
|
|
1269
|
+
done < <(grep -F 'required_owners' "$MERGE_GATE_WORKER_MD" | grep -vE '^\s*(#|[a-z_]+:)' | grep -F 'Required owners')
|
|
1270
|
+
if [ "$OWNERS_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
1271
|
+
fi
|
|
1272
|
+
|
|
1205
1273
|
# Blast radius is the input the merge decision leans on hardest, and it is the one a reader
|
|
1206
1274
|
# cannot check by eye. These cases are the six real pull requests the redesign was measured
|
|
1207
1275
|
# against, reduced to their shape: the three that used to be parked for a person purely because
|
|
@@ -1454,6 +1522,14 @@ if [ -f "$GATE_VALIDATOR" ] && worker_installed merge-gate; then
|
|
|
1454
1522
|
gate_case "remediated with two pushes" invalid "$(gate_items "$(gate_comment remediated),${gate_push},${gate_push}")" failure low
|
|
1455
1523
|
gate_case "an assessment carrying a push" invalid "$(gate_items "$(gate_comment assessed),${gate_push}")" success low
|
|
1456
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
|
+
|
|
1457
1533
|
# Output from a worker version that predates the disposition table. Applying its vocabulary
|
|
1458
1534
|
# would merge on a word this validator no longer means the same thing by.
|
|
1459
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
|
|
@@ -1872,6 +1948,134 @@ if worker_installed audit; then
|
|
|
1872
1948
|
if [ "$AUDIT_EMPTY_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
1873
1949
|
fi
|
|
1874
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
|
+
|
|
2014
|
+
echo "── Ways a run ends with nothing ──────────────────────────────────────────"
|
|
2015
|
+
|
|
2016
|
+
# A run can end without a pull request in five ways and they need different answers. One sentence
|
|
2017
|
+
# covered all of them, and three issues in two days said "nothing landed": one agent gave up in a
|
|
2018
|
+
# minute, one did more work than the run that succeeded the same hour and never asked for a pull
|
|
2019
|
+
# request, one was told there was nothing to do. Nobody could act on any of them, and the retry
|
|
2020
|
+
# that followed was right for some and wasted on the rest.
|
|
2021
|
+
#
|
|
2022
|
+
# Asserted because collapsing two of these back together is a one-line edit that turns every run
|
|
2023
|
+
# green again while saying less.
|
|
2024
|
+
if worker_installed implement; then
|
|
2025
|
+
ENDINGS_OK=1
|
|
2026
|
+
ENDING_MESSAGES="PUSH_CONFLICT_COMMENT OUTPUT_NOT_APPLIED_COMMENT NOOP_COMMENT PATCH_WITHOUT_REQUEST_COMMENT NO_OUTPUT_COMMENT"
|
|
2027
|
+
|
|
2028
|
+
for message in $ENDING_MESSAGES; do
|
|
2029
|
+
# Defined once in the env block and printed on exactly one path. Two usages means two
|
|
2030
|
+
# branches now say the same thing, which is the collapse this guards.
|
|
2031
|
+
if [ "$(count -cF "env.${message}" "$IMPLEMENT_WORKER_MD")" -ne 1 ]; then
|
|
2032
|
+
ENDINGS_OK=0
|
|
2033
|
+
echo "FAIL: implement should print ${message} on exactly one path" >&2
|
|
2034
|
+
fi
|
|
2035
|
+
done
|
|
2036
|
+
|
|
2037
|
+
# The sentence that used to cover all five. Its text survives as NO_OUTPUT_COMMENT; the name
|
|
2038
|
+
# coming back means the branches were merged again.
|
|
2039
|
+
if [ "$(count -cF 'NO_PULL_REQUEST_COMMENT' "$IMPLEMENT_WORKER_MD")" -ne 0 ]; then
|
|
2040
|
+
ENDINGS_OK=0
|
|
2041
|
+
echo "FAIL: implement still carries NO_PULL_REQUEST_COMMENT, which said the same thing about five different endings" >&2
|
|
2042
|
+
fi
|
|
2043
|
+
|
|
2044
|
+
# What tells them apart. Both are the agent job's own outputs, and gh-aw gates on the first
|
|
2045
|
+
# itself, so losing them means guessing again.
|
|
2046
|
+
for signal in 'needs.agent.outputs.output_types' 'needs.agent.outputs.has_patch'; do
|
|
2047
|
+
if [ "$(count -cE "^ *if:.*${signal}" "$IMPLEMENT_WORKER_MD")" -eq 0 ]; then
|
|
2048
|
+
ENDINGS_OK=0
|
|
2049
|
+
echo "FAIL: implement no longer reads ${signal}, so it cannot tell its no-pull-request endings apart" >&2
|
|
2050
|
+
fi
|
|
2051
|
+
done
|
|
2052
|
+
|
|
2053
|
+
# `!x == 'true'` parses as `(!x) == 'true'` in a GitHub expression, which compares a boolean to
|
|
2054
|
+
# a string and is always false. Written wrong here first, and it would have made the
|
|
2055
|
+
# produced-nothing branch unreachable while every other branch still looked right.
|
|
2056
|
+
if [ "$(count -cF "!needs.agent.outputs.has_patch ==" "$IMPLEMENT_WORKER_MD")" -ne 0 ]; then
|
|
2057
|
+
ENDINGS_OK=0
|
|
2058
|
+
echo "FAIL: implement negates has_patch as \`!x == 'true'\`, which binds as \`(!x) == 'true'\` and never matches; use != 'true'" >&2
|
|
2059
|
+
fi
|
|
2060
|
+
|
|
2061
|
+
# A bot that reported nothing to do made a decision. The janitor retries a stalled issue, and
|
|
2062
|
+
# re-running it produces the same answer, so this one path must not be flagged.
|
|
2063
|
+
noop_block=$(awk "/name: Say there was nothing to do/{found=1} found{print} found && /View this workflow run/{exit}" "$IMPLEMENT_WORKER_MD")
|
|
2064
|
+
if [ -n "$noop_block" ] && printf '%s' "$noop_block" | grep -qF 'STALLED_LABEL'; then
|
|
2065
|
+
ENDINGS_OK=0
|
|
2066
|
+
echo "FAIL: implement flags a deliberate noop as stalled, so the janitor will retry a decision until it runs out of attempts" >&2
|
|
2067
|
+
fi
|
|
2068
|
+
|
|
2069
|
+
# The usage artifact carrying the token counts is uploaded by gh-aw's conclusion job, and that
|
|
2070
|
+
# job already depends on conclude. Naming it is a cycle and the worker stops compiling.
|
|
2071
|
+
if [ "$(count -cE "^ *needs:.*conclusion" "$IMPLEMENT_WORKER_MD")" -ne 0 ]; then
|
|
2072
|
+
ENDINGS_OK=0
|
|
2073
|
+
echo "FAIL: implement depends on the conclusion job, which depends on conclude; that cycle does not compile" >&2
|
|
2074
|
+
fi
|
|
2075
|
+
|
|
2076
|
+
if [ "$ENDINGS_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
2077
|
+
fi
|
|
2078
|
+
|
|
1875
2079
|
echo "── Runner pools ──────────────────────────────────────────────────────────"
|
|
1876
2080
|
|
|
1877
2081
|
# Where every job runs, stated once and asserted, because GitHub gives a wrong pool no error: a
|