@plainconceptsplatform/workflows 0.21.0 → 0.24.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
@@ -0,0 +1,100 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/attach-screenshots/action.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ name: Attach screenshots
3
+ description: Post screenshots captured by agent-browser to the linked issue as a single comment.
4
+ inputs:
5
+ token:
6
+ description: GitHub token with issues:write access.
7
+ required: true
8
+ issue-number:
9
+ description: Issue number to comment on.
10
+ required: true
11
+ pr-number:
12
+ description: Pull request number being verified.
13
+ required: true
14
+ screenshots-dir:
15
+ description: Directory containing screenshots and plan.json.
16
+ required: true
17
+ screenshot-count:
18
+ description: Number of screenshots captured (0 if none).
19
+ required: false
20
+ default: '0'
21
+ app-started:
22
+ description: Whether the app started successfully.
23
+ required: false
24
+ default: 'false'
25
+ enabled:
26
+ description: Whether visual verification was enabled.
27
+ required: false
28
+ default: 'true'
29
+ runs:
30
+ using: composite
31
+ steps:
32
+ - name: Attach screenshots to issue
33
+ shell: bash
34
+ env:
35
+ GH_TOKEN: ${{ inputs.token }}
36
+ REPO: ${{ github.repository }}
37
+ ISSUE: ${{ inputs.issue-number }}
38
+ PR: ${{ inputs.pr-number }}
39
+ SCREENSHOTS_DIR: ${{ inputs.screenshots-dir }}
40
+ SCREENSHOT_COUNT: ${{ inputs.screenshot-count }}
41
+ APP_STARTED: ${{ inputs.app-started }}
42
+ ENABLED: ${{ inputs.enabled }}
43
+ run: |
44
+ set -euo pipefail
45
+
46
+ if [ "$ENABLED" != "true" ]; then
47
+ echo "Visual verification disabled; nothing to attach."
48
+ exit 0
49
+ fi
50
+
51
+ if [ -f "$SCREENSHOTS_DIR/plan.json" ] && [ "$APP_STARTED" = "true" ] && [ "$SCREENSHOT_COUNT" -gt 0 ]; then
52
+ # Build the comment body from the plan's descriptions.
53
+ body=$(jq -r --arg pr "$PR" '
54
+ "<!-- agent-visual-verify -->\n## Visual verification for PR #" + $pr + "\n",
55
+ (.waypoints | to_entries[] |
56
+ "**\(.key + 1). " + (.value.description // .value.expect // "waypoint") + "**\n" +
57
+ "Selector: `" + (.value.selector // "n/a") + "`\n"
58
+ )
59
+ ' "$SCREENSHOTS_DIR/plan.json")
60
+
61
+ # Collect all screenshot files.
62
+ attaches=()
63
+ for png in "$SCREENSHOTS_DIR"/*.png; do
64
+ [ -f "$png" ] || continue
65
+ attaches+=("$png")
66
+ done
67
+
68
+ if [ ${#attaches[@]} -gt 0 ]; then
69
+ gh issue comment "$ISSUE" --repo "$REPO" \
70
+ --body "attach" \
71
+ "${attaches[@]/#/--attach=}" \
72
+ --body-file - <<< "$body" 2>/dev/null || \
73
+ {
74
+ # Fallback: gh CLI does not support --body-file with --attach in all versions.
75
+ # Post the body as the comment and attach each screenshot individually.
76
+ gh issue comment "$ISSUE" --repo "$REPO" --body "$body"
77
+ for png in "${attaches[@]}"; do
78
+ gh issue comment "$ISSUE" --repo "$REPO" --attach "$png" --body ""
79
+ done
80
+ }
81
+ echo "Attached ${#attaches[@]} screenshots to issue #$ISSUE"
82
+ else
83
+ gh issue comment "$ISSUE" --repo "$REPO" --body "$body"
84
+ echo "No screenshots found; posted plan summary only"
85
+ fi
86
+ else
87
+ # App didn't start or no plan was produced. Post a warning.
88
+ reason="visual verification could not produce screenshots"
89
+ if [ "$APP_STARTED" != "true" ]; then
90
+ reason="$reason (app did not start)"
91
+ elif [ ! -f "$SCREENSHOTS_DIR/plan.json" ]; then
92
+ reason="$reason (no verification plan was written)"
93
+ fi
94
+
95
+ gh issue comment "$ISSUE" --repo "$REPO" --body \
96
+ "<!-- agent-visual-verify -->## Visual verification for PR #$PR
97
+
98
+ ⚠ $reason. The merge proceeds without visual evidence."
99
+ echo "Posted warning: $reason"
100
+ fi
@@ -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
@@ -1644,6 +1652,44 @@ if worker_installed merge-gate; then
1644
1652
  if [ "$GATE_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
1645
1653
  fi
1646
1654
 
1655
+ echo "── Visual verify wiring ───────────────────────────────────────────────────"
1656
+
1657
+ # The merge-gate's conclude job dispatches agent-visual-verify before merging when the outcome
1658
+ # is auto-merge and VISUAL_VERIFY_ENABLED is true. Each of these is one line an edit could drop
1659
+ # with nothing going red: a missing dispatch step, a missing env var, or a missing worker file.
1660
+ if worker_installed merge-gate; then
1661
+ VV_OK=1
1662
+
1663
+ vv() {
1664
+ grep -qE "$1" "$2" || { VV_OK=0; echo "FAIL: $3" >&2; }
1665
+ }
1666
+
1667
+ # The dispatch step exists and runs only on auto-merge with the feature flag on.
1668
+ vv 'name: Run visual verification' "$MERGE_GATE_WORKER_MD" 'merge-gate has no visual-verify dispatch step'
1669
+ vv "outcome == 'auto-merge'.*VISUAL_VERIFY_ENABLED == 'true'" "$MERGE_GATE_WORKER_MD" 'visual-verify dispatch has wrong guard'
1670
+ vv 'continue-on-error: true' "$MERGE_GATE_WORKER_MD" 'visual-verify dispatch must not block the merge'
1671
+
1672
+ # The env vars the worker needs.
1673
+ vv 'VISUAL_VERIFY_ENABLED:' "$MERGE_GATE_WORKER_MD" 'merge-gate missing VISUAL_VERIFY_ENABLED env var'
1674
+ vv 'VISUAL_VERIFY_START_COMMAND:' "$MERGE_GATE_WORKER_MD" 'merge-gate missing VISUAL_VERIFY_START_COMMAND env var'
1675
+
1676
+ # The worker file exists.
1677
+ VV_WORKER_MD="${WORKFLOWS_DIR}/agent-visual-verify.md"
1678
+ if [ ! -f "$VV_WORKER_MD" ]; then
1679
+ VV_OK=0
1680
+ echo "FAIL: agent-visual-verify.md does not exist" >&2
1681
+ fi
1682
+
1683
+ # The attach-screenshots action exists.
1684
+ VV_ATTACH="${HERE}/../attach-screenshots/action.yml"
1685
+ if [ ! -f "$VV_ATTACH" ]; then
1686
+ VV_OK=0
1687
+ echo "FAIL: attach-screenshots/action.yml does not exist" >&2
1688
+ fi
1689
+
1690
+ if [ "$VV_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
1691
+ fi
1692
+
1647
1693
  echo "── Expression functions ──────────────────────────────────────────────────"
1648
1694
 
1649
1695
  # GitHub's expression language has eleven functions and no more. There is no `split()`, no
@@ -1940,6 +1986,69 @@ if worker_installed audit; then
1940
1986
  if [ "$AUDIT_EMPTY_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
1941
1987
  fi
1942
1988
 
1989
+ echo "── Retrying a run that died early ────────────────────────────────────────"
1990
+
1991
+ # A run that died before producing anything is worth repeating; one that worked and then failed
1992
+ # produced an answer that was wrong, and repeating it buys the same wrong answer later. Only the
1993
+ # implement worker knew that. An Odyssey refine died in three minutes on `Model 'glm-5-3' not
1994
+ # found` -- a gateway fault that clears in seconds -- parked, and waited on the janitor's
1995
+ # six-hourly sweep.
1996
+ RETRY_OK=1
1997
+ for route in refine implement triage apply-review; do
1998
+ worker_md="${WORKFLOWS_DIR}/agent-${route}.md"
1999
+ [ -f "$worker_md" ] || continue
2000
+
2001
+ # Both halves. A worker with only the park is the state this fixes; one with only the retry
2002
+ # never stops trying.
2003
+ if [ "$(count -cE "^ *if:.*steps\.decide\.outputs\.retry == 'true'" "$worker_md")" -eq 0 ] ||
2004
+ [ "$(count -cE "^ *if:.*steps\.decide\.outputs\.retry != 'true'" "$worker_md")" -eq 0 ]; then
2005
+ RETRY_OK=0
2006
+ 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
2007
+ fi
2008
+
2009
+ # Each worker re-dispatches its own route. `-f operation=implement` pasted into refine produces
2010
+ # a plausible run doing the wrong work, and nothing anywhere goes red.
2011
+ # Only the retry dispatch: it is the line that also carries the attempt count. implement
2012
+ # dispatches reconcile-bot-pr-runs too, after it opens a pull request, and that is not a retry.
2013
+ # Only the retry dispatch: it is the line that also carries the attempt count. implement
2014
+ # dispatches reconcile-bot-pr-runs too, after it opens a pull request, and that is not a retry.
2015
+ #
2016
+ # Both greps go through `count`. A worker with no retry dispatch matches nothing, grep exits 1,
2017
+ # and under `set -o pipefail` that killed the whole suite after printing the failure above it --
2018
+ # the tally never ran, so a mutation that removed a retry branch looked like it had been caught
2019
+ # when in fact nothing after it had been checked.
2020
+ dispatched=$(count -E 'operation=[a-z-]+.*attempts_so_far' "$worker_md" \
2021
+ | { count -oE 'operation=[a-z-]+' || true; } | sed 's/operation=//' | sort -u | tr '\n' ' ')
2022
+ dispatched="${dispatched% }"
2023
+ if [ -n "$dispatched" ] && [ "$dispatched" != "$route" ]; then
2024
+ RETRY_OK=0
2025
+ echo "FAIL: agent-${route} re-dispatches '${dispatched}' rather than its own route; that run would do the wrong work and still look healthy" >&2
2026
+ fi
2027
+
2028
+ # The count has to survive the round trip or the park is never reached.
2029
+ if ! grep -qE '^ attempts_so_far:' "$worker_md"; then
2030
+ RETRY_OK=0
2031
+ echo "FAIL: agent-${route} does not take attempts_so_far, so every retry arrives as the first and the budget never binds" >&2
2032
+ fi
2033
+ if [ "$(count -cF 'attempts_so_far: ${{ needs.classify.outputs.attempts }}' "$ROUTER_YML")" -eq 0 ]; then
2034
+ RETRY_OK=0
2035
+ echo "FAIL: the router does not pass classify's attempts to its workers" >&2
2036
+ fi
2037
+ done
2038
+
2039
+ # The classifier carries the count for every dispatched route. refine needs its mode *and* its
2040
+ # count: written as an if/else, a refine retry arrived as attempt zero every time.
2041
+ for op in refine implement triage; do
2042
+ got=$(route_field attempts EVENT=workflow_dispatch OPERATION="$op" INPUT_ISSUE_NUMBER=42 INPUT_ATTEMPTS_SO_FAR=3)
2043
+ assert "a dispatched ${op} carries its attempt count" 3 "$got"
2044
+ done
2045
+ assert "a dispatched apply-review carries its attempt count" 3 \
2046
+ "$(route_field attempts EVENT=workflow_dispatch OPERATION=apply-review INPUT_PR_NUMBER=9 INPUT_ATTEMPTS_SO_FAR=3)"
2047
+ assert "a dispatched refine still carries its mode alongside the count" rerefine \
2048
+ "$(route_field refine-mode EVENT=workflow_dispatch OPERATION=refine INPUT_ISSUE_NUMBER=42 INPUT_MODE=rerefine INPUT_ATTEMPTS_SO_FAR=3)"
2049
+
2050
+ if [ "$RETRY_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2051
+
1943
2052
  echo "── Ways a run ends with nothing ──────────────────────────────────────────"
1944
2053
 
1945
2054
  # A run can end without a pull request in five ways and they need different answers. One sentence