@plainconceptsplatform/workflows 1.6.23 → 1.6.30
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/loops/actions/decide-agent-retry/action.yml +10 -2
- package/loops/actions/merge-train/action.yml +5 -0
- package/loops/actions/merge-train/merge-train.sh +16 -3
- package/loops/actions/validate-merge-gate-output/action.yml +5 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +66 -0
- package/loops/docs/diagrams.md +5 -4
- package/loops/scripts/compile-agent-workflows.mjs +13 -0
- package/loops/workflows/agent-apply-review.md +2 -2
- package/loops/workflows/agent-implement.md +12 -2
- package/loops/workflows/agent-merge-gate.md +24 -4
- package/loops/workflows/agent-refine.md +2 -2
- package/loops/workflows/agent-triage.md +2 -2
- package/loops/workflows/shared/platform-defaults.md +30 -17
- package/loops/workflows/work-router.yml +6 -1
- package/package.json +1 -1
|
@@ -61,9 +61,9 @@ runs:
|
|
|
61
61
|
run: |
|
|
62
62
|
set -euo pipefail
|
|
63
63
|
# The agent job belongs to this same run: a called workflow shares the caller's run id.
|
|
64
|
-
read -r job_id started finished <<<"$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
|
|
64
|
+
read -r job_id started finished conclusion <<<"$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
|
|
65
65
|
--jq '[.jobs[] | select(.name | endswith("agent"))] | last // empty
|
|
66
|
-
| "\(.id // "") \(.started_at // "") \(.completed_at // "")"')"
|
|
66
|
+
| "\(.id // "") \(.started_at // "") \(.completed_at // "") \(.conclusion // "")"')"
|
|
67
67
|
minutes=-1
|
|
68
68
|
if [ -n "${started:-}" ] && [ -n "${finished:-}" ]; then
|
|
69
69
|
minutes=$(( ( $(date -u -d "$finished" +%s) - $(date -u -d "$started" +%s) ) / 60 ))
|
|
@@ -91,6 +91,14 @@ runs:
|
|
|
91
91
|
fi
|
|
92
92
|
rm -rf "$work"
|
|
93
93
|
fi
|
|
94
|
+
# A cancelled agent job is a person or a newer run taking the work back, not an outage.
|
|
95
|
+
# Pliny-Bot #816: refine was cancelled by hand and its label removed, then this step read
|
|
96
|
+
# the eight-minute run as a provider failure, sent it back through the router and parked
|
|
97
|
+
# the issue as stalled.
|
|
98
|
+
if [ "${conclusion:-}" = cancelled ]; then
|
|
99
|
+
retry=false
|
|
100
|
+
cause=cancelled
|
|
101
|
+
fi
|
|
94
102
|
{
|
|
95
103
|
echo "retry=$retry"
|
|
96
104
|
echo "next=$((attempts + 1))"
|
|
@@ -28,6 +28,10 @@ inputs:
|
|
|
28
28
|
description: How many times a blocked verdict, or a merge that did not happen, is retried after the base branch moves.
|
|
29
29
|
required: false
|
|
30
30
|
default: '3'
|
|
31
|
+
stack-root-automerge:
|
|
32
|
+
description: A stack root whose children have all landed is merged by the gate like any other pull request, unless its blast radius or a finding sends it to a person. false keeps every root for a human.
|
|
33
|
+
required: false
|
|
34
|
+
default: 'true'
|
|
31
35
|
max-repairs:
|
|
32
36
|
description: How many pull requests may be in repair at once. Merges stay one at a time.
|
|
33
37
|
required: false
|
|
@@ -69,6 +73,7 @@ runs:
|
|
|
69
73
|
HOLD_LABEL: ${{ inputs.hold-label }}
|
|
70
74
|
MAX_ATTEMPTS: ${{ inputs.max-gate-attempts }}
|
|
71
75
|
MAX_RETRIES: ${{ inputs.max-retries }}
|
|
76
|
+
STACK_ROOT_AUTOMERGE: ${{ inputs.stack-root-automerge }}
|
|
72
77
|
STUCK_HOURS: ${{ inputs.stuck-hours }}
|
|
73
78
|
MAIN_RED_WAIT_HOURS: ${{ inputs.main-red-wait-hours }}
|
|
74
79
|
MAX_REPAIRS: ${{ inputs.max-repairs }}
|
|
@@ -114,14 +114,19 @@ train_move() {
|
|
|
114
114
|
# BASE_MOVED true when the base branch has a commit newer than the verdict
|
|
115
115
|
# RETRIES blocked or failed-merge verdicts already given on this head
|
|
116
116
|
# MAX_RETRIES how many of those the train retries before leaving it to a person
|
|
117
|
-
# STACK_ROOT true for a stack's root pull request, which a person
|
|
117
|
+
# STACK_ROOT true for a stack's root pull request, which a person merges unless it is ready
|
|
118
|
+
# STACK_ROOT_READY true for a root whose stack was announced complete after the last verdict, with
|
|
119
|
+
# STACK_ROOT_AUTOMERGE on: the train gives it one more gate run, which merges it
|
|
120
|
+
# unless the blast radius or a finding says a person must
|
|
118
121
|
train_park() {
|
|
119
122
|
local park=none
|
|
120
123
|
if [ "${VERDICT_NEWER:-false}" = true ]; then
|
|
121
124
|
case "${VERDICT:-}" in
|
|
122
125
|
remediated | "") park=none ;;
|
|
123
126
|
blocked | auto-merge)
|
|
124
|
-
if [ "${STACK_ROOT:-false}" = true ]; then
|
|
127
|
+
if [ "${STACK_ROOT:-false}" = true ] && [ "${STACK_ROOT_READY:-false}" = true ]; then
|
|
128
|
+
park=none
|
|
129
|
+
elif [ "${STACK_ROOT:-false}" = true ]; then
|
|
125
130
|
park=held
|
|
126
131
|
elif [ "${BASE_MOVED:-false}" = true ] && [ "${RETRIES:-0}" -lt "${MAX_RETRIES:-3}" ]; then
|
|
127
132
|
park=none
|
|
@@ -360,15 +365,23 @@ for line in "${candidates[@]}"; do
|
|
|
360
365
|
fi
|
|
361
366
|
|
|
362
367
|
stack_root=false
|
|
368
|
+
stack_root_ready=false
|
|
363
369
|
if [ "$verdict_newer" = true ] && [ -n "$linked_issue" ]; then
|
|
364
370
|
if gh issue view "$linked_issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null |
|
|
365
371
|
grep -q "<!-- stack-root-pr: $pr -->"; then
|
|
366
372
|
stack_root=true
|
|
373
|
+
if [ "${STACK_ROOT_AUTOMERGE:-false}" = true ]; then
|
|
374
|
+
announced_at=$(gh api --paginate "repos/$REPO/issues/$pr/comments?per_page=100" \
|
|
375
|
+
--jq '.[] | select(.body | contains("<!-- stack-complete -->")) | .created_at' 2>/dev/null | sort | tail -1)
|
|
376
|
+
if [ -n "$announced_at" ] && [ "$announced_at" \> "$verdict_at" ]; then
|
|
377
|
+
stack_root_ready=true
|
|
378
|
+
fi
|
|
379
|
+
fi
|
|
367
380
|
fi
|
|
368
381
|
fi
|
|
369
382
|
|
|
370
383
|
park=$(VERDICT="$verdict" VERDICT_NEWER="$verdict_newer" BASE_MOVED="$base_moved" \
|
|
371
|
-
RETRIES="$retries" MAX_RETRIES="$MAX_RETRIES" STACK_ROOT="$stack_root" train_park)
|
|
384
|
+
RETRIES="$retries" MAX_RETRIES="$MAX_RETRIES" STACK_ROOT="$stack_root" STACK_ROOT_READY="$stack_root_ready" train_park)
|
|
372
385
|
|
|
373
386
|
attempts=0
|
|
374
387
|
silent_gate=false
|
|
@@ -41,6 +41,9 @@ outputs:
|
|
|
41
41
|
pushed:
|
|
42
42
|
description: Whether the agent proposed a push to the pull request branch, applied or not.
|
|
43
43
|
value: ${{ steps.validate.outputs.pushed }}
|
|
44
|
+
empty:
|
|
45
|
+
description: Whether the agent produced no safe output at all, which is a run that quit, not a report that was refused.
|
|
46
|
+
value: ${{ steps.validate.outputs.empty }}
|
|
44
47
|
runs:
|
|
45
48
|
using: composite
|
|
46
49
|
steps:
|
|
@@ -66,6 +69,8 @@ runs:
|
|
|
66
69
|
echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
|
67
70
|
pushed=$(jq -r '[.items[]? | select(.type == "push_to_pull_request_branch")] | length > 0' "$OUTPUT_FILE" 2>/dev/null || echo false)
|
|
68
71
|
echo "pushed=$pushed" >> "$GITHUB_OUTPUT"
|
|
72
|
+
items=$(jq -r '[.items[]?] | length' "$OUTPUT_FILE" 2>/dev/null || echo 0)
|
|
73
|
+
echo "empty=$([ "${items:-0}" = 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
|
69
74
|
|
|
70
75
|
if [ "$outcome" = 'invalid' ]; then
|
|
71
76
|
echo "::warning::Agent output has no usable merge-gate report. It will not be applied."
|
|
@@ -2041,6 +2041,8 @@ if worker_installed merge-gate; then
|
|
|
2041
2041
|
assert "blocked holds once its retries are spent" held "$(park VERDICT=blocked VERDICT_NEWER=true BASE_MOVED=true RETRIES=3 MAX_RETRIES=3)"
|
|
2042
2042
|
assert "a merge that did not happen is retried once the base moves" none "$(park VERDICT=auto-merge VERDICT_NEWER=true BASE_MOVED=true RETRIES=1)"
|
|
2043
2043
|
assert "a stack root is never retried into a merge" held "$(park VERDICT=auto-merge VERDICT_NEWER=true BASE_MOVED=true RETRIES=0 STACK_ROOT=true)"
|
|
2044
|
+
assert "a root whose stack was announced complete gets one more gate run" none "$(park VERDICT=auto-merge VERDICT_NEWER=true BASE_MOVED=false RETRIES=0 STACK_ROOT=true STACK_ROOT_READY=true)"
|
|
2045
|
+
assert "a ready root whose merge failed is held again, not looped" held "$(park VERDICT=owner-review VERDICT_NEWER=true STACK_ROOT=true STACK_ROOT_READY=true)"
|
|
2044
2046
|
|
|
2045
2047
|
assert "hold keeps a pull request off the train" skip "$(move HOLD=true CI_STATUS=completed CI_CONCLUSION=success)"
|
|
2046
2048
|
assert "requested changes keep it off the train" skip "$(move REVIEW_DECISION=CHANGES_REQUESTED CI_STATUS=completed CI_CONCLUSION=success)"
|
|
@@ -2738,6 +2740,70 @@ if worker_installed implement; then
|
|
|
2738
2740
|
echo "FAIL: the implement prompt does not forbid running commands in the background and ending the turn to wait for them" >&2
|
|
2739
2741
|
fi
|
|
2740
2742
|
fi
|
|
2743
|
+
# The bash tool moves any command past its timeout to the background and says so. Pliny-Bot #774 took
|
|
2744
|
+
# that for an end, wrote "I'll wait for the verify command" and stopped after sixty-seven minutes. The
|
|
2745
|
+
# prompt must say that the message is not the end and to poll with tool calls.
|
|
2746
|
+
if worker_installed implement; then
|
|
2747
|
+
if grep -qF 'answers "moved to the background"' "${WORKFLOWS_DIR}/agent-implement.md" && grep -qF "sleep 40; tail -n 15" "${WORKFLOWS_DIR}/agent-implement.md"; then
|
|
2748
|
+
PASS=$((PASS + 1))
|
|
2749
|
+
else
|
|
2750
|
+
FAIL=$((FAIL + 1))
|
|
2751
|
+
echo "FAIL: the implement prompt does not tell the agent to keep polling when bash moves a command to the background" >&2
|
|
2752
|
+
fi
|
|
2753
|
+
fi
|
|
2754
|
+
# Every worker is told that a turn ending in text ends the run. The Pliny-Bot merge gate said "I'll read
|
|
2755
|
+
# the required context files first." and exited cleanly after twenty-three seconds, twice on one head.
|
|
2756
|
+
PLATFORM_DEFAULTS_MD="${WORKFLOWS_DIR}/shared/platform-defaults.md"
|
|
2757
|
+
if [ -f "$PLATFORM_DEFAULTS_MD" ]; then
|
|
2758
|
+
if grep -qF "A turn that ends with text and no tool call ends the whole run" "$PLATFORM_DEFAULTS_MD" && grep -qF "every message you write carries a tool call" "$PLATFORM_DEFAULTS_MD"; then
|
|
2759
|
+
PASS=$((PASS + 1))
|
|
2760
|
+
else
|
|
2761
|
+
FAIL=$((FAIL + 1))
|
|
2762
|
+
echo "FAIL: the shared worker defaults do not say that a text-only turn ends the run" >&2
|
|
2763
|
+
fi
|
|
2764
|
+
fi
|
|
2765
|
+
# A gate agent that produced no output at all quit; it did not write a report the validator refused.
|
|
2766
|
+
# It gets the machine budget, not the two-strike human-review of an unusable report.
|
|
2767
|
+
if worker_installed merge-gate; then
|
|
2768
|
+
if grep -qF 'OUTPUT_EMPTY: ${{ needs.validate_output.outputs.empty }}' "${WORKFLOWS_DIR}/agent-merge-gate.md" && grep -qF '[ "${OUTPUT_EMPTY:-false}" != true ]' "${WORKFLOWS_DIR}/agent-merge-gate.md" && grep -qF 'echo "empty=' "${HERE}/../validate-merge-gate-output/action.yml"; then
|
|
2769
|
+
PASS=$((PASS + 1))
|
|
2770
|
+
else
|
|
2771
|
+
FAIL=$((FAIL + 1))
|
|
2772
|
+
echo "FAIL: a gate agent that produced nothing is sent to a person instead of being retried" >&2
|
|
2773
|
+
fi
|
|
2774
|
+
fi
|
|
2775
|
+
# A cancelled agent job is work taken back, not an outage: no retry, no stalled flag. Pliny-Bot #816 was
|
|
2776
|
+
# cancelled by hand, re-dispatched as "a provider outage" and parked as stalled.
|
|
2777
|
+
DECIDE_YML="${HERE}/../decide-agent-retry/action.yml"
|
|
2778
|
+
if [ -f "$DECIDE_YML" ] && grep -qF 'cause=cancelled' "$DECIDE_YML" && grep -qF "steps.decide.outputs.cause != 'cancelled'" "${WORKFLOWS_DIR}/agent-refine.md" && grep -qF "steps.decide.outputs.cause != 'cancelled'" "${WORKFLOWS_DIR}/agent-implement.md"; then
|
|
2779
|
+
PASS=$((PASS + 1))
|
|
2780
|
+
else
|
|
2781
|
+
FAIL=$((FAIL + 1))
|
|
2782
|
+
echo "FAIL: a cancelled agent run is retried or parked as stalled" >&2
|
|
2783
|
+
fi
|
|
2784
|
+
# No prompt rule stopped the model ending a turn in text; the compiled engine command continues the
|
|
2785
|
+
# session when no safe output exists. Pliny-Bot #771 passed verify and exited with nothing delivered.
|
|
2786
|
+
COMPILE_MJS="${HERE}/../../scripts/compile-agent-workflows.mjs"
|
|
2787
|
+
if [ -f "$COMPILE_MJS" ] && grep -qF 'opencode run --continue' "$COMPILE_MJS" && grep -qF 'gh-aw/safeoutputs/outputs.jsonl' "$COMPILE_MJS"; then
|
|
2788
|
+
PASS=$((PASS + 1))
|
|
2789
|
+
else
|
|
2790
|
+
FAIL=$((FAIL + 1))
|
|
2791
|
+
echo "FAIL: the compiled engine command does not continue a session that delivered nothing" >&2
|
|
2792
|
+
fi
|
|
2793
|
+
# A stack root merges itself once every child has landed, through the same gate as any pull request.
|
|
2794
|
+
# The people it still stops are the gate's own: a blast radius over the limit, a protected path, a
|
|
2795
|
+
# finding. The gate must read the router's completion announcement, and the train must give the root
|
|
2796
|
+
# one more run after it.
|
|
2797
|
+
GATE_MD="${WORKFLOWS_DIR}/agent-merge-gate.md"
|
|
2798
|
+
TRAIN_SH="${HERE}/../merge-train/merge-train.sh"
|
|
2799
|
+
if worker_installed merge-gate && [ -f "$TRAIN_SH" ]; then
|
|
2800
|
+
if grep -qF 'root_complete=$root_complete' "$GATE_MD" && grep -qF "needs.subject.outputs.root_complete == 'true'" "$GATE_MD" && grep -qF 'STACK_ROOT_AUTOMERGE:' "$GATE_MD" && grep -qF 'STACK_ROOT_READY' "$TRAIN_SH"; then
|
|
2801
|
+
PASS=$((PASS + 1))
|
|
2802
|
+
else
|
|
2803
|
+
FAIL=$((FAIL + 1))
|
|
2804
|
+
echo "FAIL: a completed stack root is not merged by the gate and re-run by the train" >&2
|
|
2805
|
+
fi
|
|
2806
|
+
fi
|
|
2741
2807
|
# The merge train wakes on a finished CI run only. Pliny-Bot had four green pull requests wait an hour
|
|
2742
2808
|
# because a repair gate dispatched for red CI kept them marked as having a gate, and nothing called the
|
|
2743
2809
|
# train again once it was cancelled. The hourly sweep is now a heartbeat, and a repair gate whose CI has
|
package/loops/docs/diagrams.md
CHANGED
|
@@ -184,9 +184,10 @@ flowchart TD
|
|
|
184
184
|
|
|
185
185
|
The stack exists only in markers on the issues — `stack-root-branch` and `stack-root-pr` on the
|
|
186
186
|
feature issue, `stack-root-branch` stamped on each child at hand-over — so a repository with none
|
|
187
|
-
of them runs exactly the pipeline above and nothing else. The root is
|
|
188
|
-
|
|
189
|
-
|
|
187
|
+
of them runs exactly the pipeline above and nothing else. The root is held while children land.
|
|
188
|
+
Once every child has landed and the feature branch is green, the gate decides it like any pull
|
|
189
|
+
request and merges it, unless the blast radius, a protected path or a finding sends it to a person
|
|
190
|
+
(`STACK_ROOT_AUTOMERGE: "false"` restores a human merge for every root).
|
|
190
191
|
|
|
191
192
|
---
|
|
192
193
|
|
|
@@ -385,7 +386,7 @@ flowchart TD
|
|
|
385
386
|
gateDisposition -.->|"fragile, unmet criteria<br/>or low confidence"| gateHuman
|
|
386
387
|
gateDisposition -.->|"CI red or a verified<br/>high finding"| gateBlocked
|
|
387
388
|
gateMerge(("AUTO-MERGE<br/>squash into the PR's base<br/>(main, or the feature branch:<br/>then the child issue closes,<br/>pr-pending removed)"))
|
|
388
|
-
gateRootCheck -->|"yes: the root"| gateHeld(("HELD<br/>stack root
|
|
389
|
+
gateRootCheck -->|"yes: the root"| gateHeld(("HELD<br/>stack root waits for its children,<br/>then merges like any pull request<br/>unless the gate sends it to a person"))
|
|
389
390
|
gateHuman(("HUMAN REVIEW<br/>review label,<br/>implement label kept"))
|
|
390
391
|
gateOwner(("OWNER REVIEW<br/>review + owner-review,<br/>CODEOWNERS asked when named"))
|
|
391
392
|
gateHuman --> gatePreview
|
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
const OPENCODE_VERSION = '2.0.22'
|
|
4
4
|
const OPENCODE_PACKAGE = '@opencode/cli'
|
|
5
5
|
|
|
6
|
+
const OPENCODE_NUDGE =
|
|
7
|
+
'; rc=$?; for nudge in 1 2; do [ -s "${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" ] && break; ' +
|
|
8
|
+
'echo "No safe output yet, continuing the session ($nudge of 2)"; ' +
|
|
9
|
+
'OPENCODE_DISABLE_MODELS_FETCH=1 opencode run --continue --print-logs --log-level info ' +
|
|
10
|
+
'"Your last message ended without a tool call, so nothing has been delivered and this run is about to end. ' +
|
|
11
|
+
'Do not explain or plan: continue the task now and finish with the safe-output call the task names."; ' +
|
|
12
|
+
'rc=$?; done; exit $rc'
|
|
13
|
+
|
|
6
14
|
// The install the compiled locks carry, shared so both rewrite sites emit exactly
|
|
7
15
|
// the same command.
|
|
8
16
|
//
|
|
@@ -153,6 +161,11 @@ for (const file of readdirSync(workflowDirectory)) {
|
|
|
153
161
|
// "Invalid value for flag --log-level", aborting before the agent runs.
|
|
154
162
|
// The old upper-case spelling was valid in v1 and is not in v2.
|
|
155
163
|
.replace(/opencode run(?: --print-logs)? --log-level (?:DEBUG|ERROR|INFO|debug|error|info)/g, "OPENCODE_DISABLE_MODELS_FETCH=1 opencode run --print-logs --log-level info")
|
|
164
|
+
// A turn that ends in text ends `opencode run`, whatever the text promised, and no prompt rule
|
|
165
|
+
// stopped glm-5-2 doing it: Pliny-Bot #771 passed verify, wrote "let me discover the exact
|
|
166
|
+
// safeoutputs command" and exited with nothing delivered. When no safe output exists yet, the
|
|
167
|
+
// same session is continued, twice at most, and told to finish.
|
|
168
|
+
.replace(/("\$\(cat \/tmp\/gh-aw\/aw-prompts\/prompt\.txt\)")'/g, (_, prompt) => prompt + OPENCODE_NUDGE + "'")
|
|
156
169
|
.replace(/GH_AW_INFO_MODEL: "[^"]*"/g, 'GH_AW_INFO_MODEL: "per-agent"')
|
|
157
170
|
.replace(/OPENCODE_MODEL: [^\n]+/g, "OPENCODE_MODEL: ''")
|
|
158
171
|
.replace(/GH_AW_INFO_MODEL_COSTS: '[^']*'/g, 'GH_AW_INFO_MODEL_COSTS: \'{"providers":{}}\'')
|
|
@@ -381,7 +381,7 @@ jobs:
|
|
|
381
381
|
issue-number: ${{ needs.subject.outputs.issue }}
|
|
382
382
|
labels: ${{ env.WORKING_LABEL }}
|
|
383
383
|
- name: Flag for human review
|
|
384
|
-
if: steps.decide.outputs.retry != 'true'
|
|
384
|
+
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
|
|
385
385
|
uses: ./.github/actions/add-issue-labels
|
|
386
386
|
with:
|
|
387
387
|
token: ${{ steps.app-token.outputs.token }}
|
|
@@ -390,7 +390,7 @@ jobs:
|
|
|
390
390
|
${{ env.REVIEW_LABEL }}
|
|
391
391
|
${{ env.STALLED_LABEL }}
|
|
392
392
|
- name: Report missing review feedback outcome
|
|
393
|
-
if: steps.decide.outputs.retry != 'true'
|
|
393
|
+
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
|
|
394
394
|
uses: ./.github/actions/create-issue-comment
|
|
395
395
|
with:
|
|
396
396
|
token: ${{ steps.app-token.outputs.token }}
|
|
@@ -593,7 +593,7 @@ jobs:
|
|
|
593
593
|
${{ env.WORKING_LABEL }}
|
|
594
594
|
implement
|
|
595
595
|
- name: Flag for human review
|
|
596
|
-
if: steps.decide.outputs.retry != 'true'
|
|
596
|
+
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
|
|
597
597
|
uses: ./.github/actions/add-issue-labels
|
|
598
598
|
with:
|
|
599
599
|
token: ${{ steps.app-token.outputs.token }}
|
|
@@ -602,7 +602,7 @@ jobs:
|
|
|
602
602
|
${{ env.REVIEW_LABEL }}
|
|
603
603
|
${{ env.STALLED_LABEL }}
|
|
604
604
|
- name: Report missing implementation outcome
|
|
605
|
-
if: steps.decide.outputs.retry != 'true'
|
|
605
|
+
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
|
|
606
606
|
uses: ./.github/actions/create-issue-comment
|
|
607
607
|
with:
|
|
608
608
|
token: ${{ steps.app-token.outputs.token }}
|
|
@@ -844,6 +844,16 @@ timeout-minutes: 180
|
|
|
844
844
|
so every part finishes inside one call. The only valid way to end the run is the safe-output
|
|
845
845
|
call in step 6.
|
|
846
846
|
|
|
847
|
+
**If the bash tool answers "moved to the background", the run is not over and you are not
|
|
848
|
+
waiting: keep going with tool calls.** It does that to any command that passes its timeout, even
|
|
849
|
+
when you passed none, and Pliny-Bot #774 ended that way on `pnpm verify --quick` after
|
|
850
|
+
sixty-seven minutes with no pull request. Poll it: call bash with `sleep 40; tail -n 15` on the
|
|
851
|
+
command's output file, again and again, until the output shows its exit status, and only then
|
|
852
|
+
go on. Each of those calls is a tool call, so the turn never ends. To avoid it from the start,
|
|
853
|
+
run a command that may pass two minutes as
|
|
854
|
+
`(<command>) > /tmp/gh-aw/agent/step.log 2>&1; echo "exit status $?" >> /tmp/gh-aw/agent/step.log` with the bash tool's
|
|
855
|
+
`timeout` at 600000, and read `/tmp/gh-aw/agent/step.log` afterwards.
|
|
856
|
+
|
|
847
857
|
**Two things the unit tests do not catch, and CI will.** When you add a dependency, take the
|
|
848
858
|
latest released version and run the repository's audit for it (`pnpm audit --audit-level high`
|
|
849
859
|
for the web, `dotnet list package --vulnerable` for the backend): Pliny-Bot #752 added a PDF
|
|
@@ -49,6 +49,9 @@ env:
|
|
|
49
49
|
# translations and generated code excluded) is too big to merge unseen. It was a thousand, which
|
|
50
50
|
# is a normal feature once the tests are left out. A file count alone never decides, so the high
|
|
51
51
|
# file threshold is set out of reach; spread still raises medium.
|
|
52
|
+
# "true" lets a stack root merge once every child has landed, through the same blast radius and
|
|
53
|
+
# findings as any pull request. "false" holds every root for a person.
|
|
54
|
+
STACK_ROOT_AUTOMERGE: "true"
|
|
52
55
|
BLAST_HIGH_FILES: "100000"
|
|
53
56
|
BLAST_HIGH_LINES: "10000"
|
|
54
57
|
BLAST_MEDIUM_FILES: "5"
|
|
@@ -177,6 +180,7 @@ jobs:
|
|
|
177
180
|
review_blocked: ${{ steps.review.outputs.review_blocked }}
|
|
178
181
|
pr_base: ${{ steps.stack.outputs.pr_base }}
|
|
179
182
|
stack_root: ${{ steps.stack.outputs.stack_root }}
|
|
183
|
+
root_complete: ${{ steps.stack.outputs.root_complete }}
|
|
180
184
|
steps:
|
|
181
185
|
- name: Checkout workflow actions
|
|
182
186
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
@@ -230,18 +234,28 @@ jobs:
|
|
|
230
234
|
REPO: ${{ github.repository }}
|
|
231
235
|
PR: ${{ steps.subject.outputs.pr }}
|
|
232
236
|
ISSUE: ${{ steps.subject.outputs.issue }}
|
|
237
|
+
STACK_ROOT_AUTOMERGE: ${{ env.STACK_ROOT_AUTOMERGE }}
|
|
233
238
|
run: |
|
|
234
239
|
set -euo pipefail
|
|
235
240
|
pr_base=$(gh pr view "$PR" --repo "$REPO" --json baseRefName --jq '.baseRefName // ""')
|
|
236
241
|
stack_root=false
|
|
242
|
+
root_complete=false
|
|
237
243
|
if [ -n "$ISSUE" ]; then
|
|
238
244
|
body=$(gh issue view "$ISSUE" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo "")
|
|
239
245
|
if echo "$body" | grep -q "<!-- stack-root-pr: $PR -->"; then
|
|
240
246
|
stack_root=true
|
|
247
|
+
# The router announces a stack complete on the root once every child has landed and
|
|
248
|
+
# CI is green. A root without that comment is still collecting children.
|
|
249
|
+
if [ "$STACK_ROOT_AUTOMERGE" = true ] \
|
|
250
|
+
&& gh api --paginate "repos/$REPO/issues/$PR/comments?per_page=100" \
|
|
251
|
+
--jq '.[] | select(.body | contains("<!-- stack-complete -->")) | .id' 2>/dev/null | grep -q .; then
|
|
252
|
+
root_complete=true
|
|
253
|
+
fi
|
|
241
254
|
fi
|
|
242
255
|
fi
|
|
243
256
|
echo "pr_base=$pr_base" >> "$GITHUB_OUTPUT"
|
|
244
257
|
echo "stack_root=$stack_root" >> "$GITHUB_OUTPUT"
|
|
258
|
+
echo "root_complete=$root_complete" >> "$GITHUB_OUTPUT"
|
|
245
259
|
|
|
246
260
|
# Rung 4b. Everything the merge decision needs that a shell can establish, measured once,
|
|
247
261
|
# before any model reads the diff. The job kept its name and its `requires_review` and
|
|
@@ -479,6 +493,7 @@ jobs:
|
|
|
479
493
|
valid: ${{ steps.validate.outputs.valid }}
|
|
480
494
|
outcome: ${{ steps.validate.outputs.outcome }}
|
|
481
495
|
pushed: ${{ steps.validate.outputs.pushed }}
|
|
496
|
+
empty: ${{ steps.validate.outputs.empty }}
|
|
482
497
|
steps:
|
|
483
498
|
- name: Checkout workflow actions
|
|
484
499
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
@@ -589,7 +604,7 @@ jobs:
|
|
|
589
604
|
${{ needs.subject.outputs.issue != '' && format('Findings and verification on the linked issue: #{0}.', needs.subject.outputs.issue) || 'Findings and verification are in the review comment above.' }} [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
|
590
605
|
${{ env.EVIDENCE_BEFORE_REVIEW == 'true' && contains(fromJson('["human-review","owner-review"]'), needs.validate_output.outputs.outcome) && 'Screenshots of this head follow in their own comment if it carries a verification plan. Approving the pull request merges it.' || '' }}
|
|
591
606
|
- name: Merge approved pull request
|
|
592
|
-
if: needs.validate_output.outputs.outcome == 'auto-merge' && needs.subject.outputs.stack_root != 'true'
|
|
607
|
+
if: needs.validate_output.outputs.outcome == 'auto-merge' && (needs.subject.outputs.stack_root != 'true' || needs.subject.outputs.root_complete == 'true')
|
|
593
608
|
env:
|
|
594
609
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
595
610
|
REPO: ${{ github.repository }}
|
|
@@ -603,7 +618,7 @@ jobs:
|
|
|
603
618
|
# what a reader needs: the gate held the root, children merge into the feature branch,
|
|
604
619
|
# and a human merges the root when the stack completes.
|
|
605
620
|
- name: Hold a stack root for a human merge
|
|
606
|
-
if: needs.validate_output.outputs.outcome == 'auto-merge' && needs.subject.outputs.stack_root == 'true'
|
|
621
|
+
if: needs.validate_output.outputs.outcome == 'auto-merge' && needs.subject.outputs.stack_root == 'true' && needs.subject.outputs.root_complete != 'true'
|
|
607
622
|
uses: ./.github/actions/create-issue-comment
|
|
608
623
|
with:
|
|
609
624
|
token: ${{ steps.app-token.outputs.token }}
|
|
@@ -755,6 +770,7 @@ jobs:
|
|
|
755
770
|
AGENT_RESULT: ${{ needs.agent.result }}
|
|
756
771
|
SAFE_RESULT: ${{ needs.safe_outputs.result }}
|
|
757
772
|
OUTPUT_VALID: ${{ needs.validate_output.outputs.valid }}
|
|
773
|
+
OUTPUT_EMPTY: ${{ needs.validate_output.outputs.empty }}
|
|
758
774
|
PARK_AT_ATTEMPT: ${{ env.PARK_AT_ATTEMPT }}
|
|
759
775
|
PARK_AT_UNUSABLE_OUTPUT: ${{ env.PARK_AT_UNUSABLE_OUTPUT }}
|
|
760
776
|
run: |
|
|
@@ -762,8 +778,12 @@ jobs:
|
|
|
762
778
|
attempts=${ATTEMPTS:-0}
|
|
763
779
|
# The agent ran, published, and produced something the validator refused. Repeating
|
|
764
780
|
# that reproduces it; a person reading the comment costs less than three more runs
|
|
765
|
-
# holding the merge belt.
|
|
766
|
-
|
|
781
|
+
# holding the merge belt. An agent that produced nothing at all is not that: it quit,
|
|
782
|
+
# as the Pliny-Bot gate did on #801 after one line of text and twenty-three seconds,
|
|
783
|
+
# twice, and went to a person with no finding. That is a machine failure and gets the
|
|
784
|
+
# machine budget.
|
|
785
|
+
if [ "$AGENT_RESULT" = success ] && [ "$SAFE_RESULT" = success ] && [ "$OUTPUT_VALID" != true ] \
|
|
786
|
+
&& [ "${OUTPUT_EMPTY:-false}" != true ]; then
|
|
767
787
|
threshold="$PARK_AT_UNUSABLE_OUTPUT"
|
|
768
788
|
kind=unusable
|
|
769
789
|
else
|
|
@@ -773,7 +773,7 @@ jobs:
|
|
|
773
773
|
issue-number: ${{ inputs.issue-number }}
|
|
774
774
|
labels: ${{ env.WORKING_LABEL }}
|
|
775
775
|
- name: Flag for human review
|
|
776
|
-
if: steps.decide.outputs.retry != 'true'
|
|
776
|
+
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
|
|
777
777
|
uses: ./.github/actions/add-issue-labels
|
|
778
778
|
with:
|
|
779
779
|
token: ${{ steps.app-token.outputs.token }}
|
|
@@ -782,7 +782,7 @@ jobs:
|
|
|
782
782
|
${{ env.REVIEW_LABEL }}
|
|
783
783
|
${{ env.STALLED_LABEL }}
|
|
784
784
|
- name: Report missing refinement outcome
|
|
785
|
-
if: steps.decide.outputs.retry != 'true'
|
|
785
|
+
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
|
|
786
786
|
uses: ./.github/actions/create-issue-comment
|
|
787
787
|
with:
|
|
788
788
|
token: ${{ steps.app-token.outputs.token }}
|
|
@@ -387,7 +387,7 @@ jobs:
|
|
|
387
387
|
issue-number: ${{ inputs.issue-number }}
|
|
388
388
|
labels: ${{ env.WORKING_LABEL }}
|
|
389
389
|
- name: Flag for human review
|
|
390
|
-
if: steps.decide.outputs.retry != 'true'
|
|
390
|
+
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
|
|
391
391
|
uses: ./.github/actions/add-issue-labels
|
|
392
392
|
with:
|
|
393
393
|
token: ${{ steps.app-token.outputs.token }}
|
|
@@ -396,7 +396,7 @@ jobs:
|
|
|
396
396
|
${{ env.REVIEW_LABEL }}
|
|
397
397
|
${{ env.STALLED_LABEL }}
|
|
398
398
|
- name: Report missing triage outcome
|
|
399
|
-
if: steps.decide.outputs.retry != 'true'
|
|
399
|
+
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
|
|
400
400
|
uses: ./.github/actions/create-issue-comment
|
|
401
401
|
with:
|
|
402
402
|
token: ${{ steps.app-token.outputs.token }}
|
|
@@ -21,20 +21,33 @@ network:
|
|
|
21
21
|
safe-outputs:
|
|
22
22
|
threat-detection: false
|
|
23
23
|
---
|
|
24
|
-
|
|
25
|
-
##
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
24
|
+
|
|
25
|
+
## These runs are not interactive
|
|
26
|
+
|
|
27
|
+
- Nothing resumes you. A turn that ends with text and no tool call ends the whole run, and the work
|
|
28
|
+
is discarded, whatever the text promised. "I'll read the context files first." as a last message
|
|
29
|
+
is a finished run: the Pliny-Bot merge gate did exactly that on #801, ended in twenty-three
|
|
30
|
+
seconds with exit code 0 and no outcome, and was retried twice before the pull request was handed
|
|
31
|
+
to a person for nothing.
|
|
32
|
+
- So every message you write carries a tool call until the last one, which is the safe-output call
|
|
33
|
+
the task names. Say what you are about to do and do it in the same message. Never announce a step
|
|
34
|
+
and stop, never wait for a command, a reviewer or a "next turn".
|
|
35
|
+
- A command the bash tool reports as moved to the background is still running: poll its output file
|
|
36
|
+
with tool calls (`sleep 40; tail -n 15 <file>`) until it shows an exit status, then go on.
|
|
37
|
+
|
|
38
|
+
## Verification, for every worker that builds or pushes
|
|
39
|
+
|
|
40
|
+
- Scope every check to the files you changed. This is the runner, not a preference: a
|
|
41
|
+
whole-repository lint, build or test run exhausts its memory and gets killed mid-run, which
|
|
42
|
+
fails the job with no useful output. Pass the changed paths to the linter (`pnpm exec biome
|
|
43
|
+
check <files>`), build only the projects containing them, and run only the test project that
|
|
44
|
+
covers them. Escalate to the full suite only after the scoped run passes and only when the
|
|
45
|
+
change crosses a project boundary.
|
|
46
|
+
- Run nothing at all for a change that touches only documentation. A cold Release build on a
|
|
47
|
+
shared runner is minutes the run does not get back.
|
|
48
|
+
- Never make a check pass by weakening it: not a deleted test, not a lowered threshold, not a
|
|
49
|
+
skipped step. Fix the cause and run it again.
|
|
50
|
+
- Once the checks pass, run the project's lint fix command over the files you changed
|
|
51
|
+
(`pnpm lint:fix`, `pnpm exec biome check --write <changed-files>`, or its equivalent), or
|
|
52
|
+
correct what lint reports where no fix command exists. A branch that arrives with lint errors
|
|
53
|
+
gets sent back for them.
|
|
@@ -157,6 +157,10 @@ env:
|
|
|
157
157
|
# How many times a `blocked` verdict, or a merge that did not happen, is retried once the base
|
|
158
158
|
# branch has moved. After that the pull request waits for a person or a new commit.
|
|
159
159
|
MERGE_TRAIN_MAX_RETRIES: "3"
|
|
160
|
+
# "true" lets a stack root merge itself once every child has landed and CI is green, through the
|
|
161
|
+
# same gate as any pull request: a change over the blast radius limit, a protected path or an
|
|
162
|
+
# unresolved finding still goes to a person. "false" keeps every root for a human.
|
|
163
|
+
MERGE_TRAIN_STACK_ROOT_AUTOMERGE: "true"
|
|
160
164
|
# "true" logs the move the train would make and makes none. For trying the train on a
|
|
161
165
|
# repository before letting it act.
|
|
162
166
|
MERGE_TRAIN_DRY_RUN: "false"
|
|
@@ -922,6 +926,7 @@ jobs:
|
|
|
922
926
|
hold-label: ${{ env.MERGE_TRAIN_HOLD_LABEL }}
|
|
923
927
|
max-gate-attempts: ${{ env.MAX_GATE_ATTEMPTS }}
|
|
924
928
|
max-retries: ${{ env.MERGE_TRAIN_MAX_RETRIES }}
|
|
929
|
+
stack-root-automerge: ${{ env.MERGE_TRAIN_STACK_ROOT_AUTOMERGE }}
|
|
925
930
|
max-repairs: ${{ env.MERGE_TRAIN_REPAIRS }}
|
|
926
931
|
dry-run: ${{ env.MERGE_TRAIN_DRY_RUN }}
|
|
927
932
|
ci-run-id: ${{ needs.classify.outputs.ci-run-id }}
|
|
@@ -1265,7 +1270,7 @@ jobs:
|
|
|
1265
1270
|
if [ "${already:-0}" -gt 0 ]; then
|
|
1266
1271
|
echo "Stack #$parent already has a completion announcement; staying silent."
|
|
1267
1272
|
else
|
|
1268
|
-
gh pr comment "$root_pr" --repo "$REPO" --body "Stack complete: all children have merged into the feature branch and CI is green. This root pull request is ready
|
|
1273
|
+
gh pr comment "$root_pr" --repo "$REPO" --body "Stack complete: all children have merged into the feature branch and CI is green. This root pull request is ready to merge into $DEFAULT_BRANCH: the merge gate decides it next, and a person is asked only when its blast radius or a finding says so. $completion_marker"
|
|
1269
1274
|
echo "Stack #$parent complete: announced root PR #$root_pr."
|
|
1270
1275
|
fi
|
|
1271
1276
|
fi
|