@plainconceptsplatform/workflows 1.6.0 → 1.6.10
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/decide-agent-retry/action.yml +31 -3
- package/loops/actions/decide-agent-retry/classify-failure.sh +50 -0
- package/loops/actions/housekeeping/action.yml +20 -0
- package/loops/actions/identify-gate-subject/action.yml +11 -19
- package/loops/actions/merge-train/action.yml +5 -0
- package/loops/actions/merge-train/merge-train.sh +102 -31
- package/loops/actions/push-agent-branch/action.yml +14 -1
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +167 -5
- package/loops/docs/diagrams.md +2 -2
- package/loops/scripts/compile-agent-workflows.mjs +20 -3
- package/loops/workflows/agent-implement.md +77 -32
- package/loops/workflows/agent-merge-gate.md +19 -9
- package/loops/workflows/agent-refine.md +9 -0
- package/loops/workflows/work-router.yml +54 -7
- package/package.json +9 -8
package/dist/index.js
CHANGED
|
File without changes
|
|
@@ -25,6 +25,9 @@ outputs:
|
|
|
25
25
|
minutes:
|
|
26
26
|
description: How long the agent job ran, or -1 when that could not be read.
|
|
27
27
|
value: ${{ steps.decide.outputs.minutes }}
|
|
28
|
+
cause:
|
|
29
|
+
description: 'Why a retry was chosen: short-run, runner-shutdown or cut-off. "none" when there is none.'
|
|
30
|
+
value: ${{ steps.decide.outputs.cause }}
|
|
28
31
|
runs:
|
|
29
32
|
using: composite
|
|
30
33
|
steps:
|
|
@@ -33,6 +36,13 @@ runs:
|
|
|
33
36
|
# repeating it buys the same wrong answer half an hour later. Duration is what separates them,
|
|
34
37
|
# and it needs no model and no log parsing to read.
|
|
35
38
|
#
|
|
39
|
+
# Except when the long run was killed rather than wrong. The VM was deleted under a forty
|
|
40
|
+
# minute implement (Pliny-Bot #699), and an AI gateway that answered nothing ended another
|
|
41
|
+
# run one turn after it began (#693's first attempt): neither is a verdict on the work, and
|
|
42
|
+
# both waited six hours for the janitor. classify-failure.sh reads those two from the job's
|
|
43
|
+
# annotations and from the line above the sandbox teardown, and they are repeated whatever
|
|
44
|
+
# the run lasted, within the same attempt budget.
|
|
45
|
+
#
|
|
36
46
|
# Written once here because the rule was in the implement worker alone, and refine, triage and
|
|
37
47
|
# apply-review parked instead -- an Odyssey refine died in three minutes on
|
|
38
48
|
# `Model 'glm-5-2' not found`, a gateway fault that clears in seconds, and waited on the
|
|
@@ -44,15 +54,16 @@ runs:
|
|
|
44
54
|
GH_TOKEN: ${{ inputs.token }}
|
|
45
55
|
REPO: ${{ github.repository }}
|
|
46
56
|
RUN_ID: ${{ github.run_id }}
|
|
57
|
+
ACTION_PATH: ${{ github.action_path }}
|
|
47
58
|
ATTEMPTS: ${{ inputs.attempts-so-far }}
|
|
48
59
|
PARK_AT: ${{ inputs.park-at }}
|
|
49
60
|
UNDER_MINUTES: ${{ inputs.under-minutes }}
|
|
50
61
|
run: |
|
|
51
62
|
set -euo pipefail
|
|
52
63
|
# 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" \
|
|
64
|
+
read -r job_id started finished <<<"$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
|
|
54
65
|
--jq '[.jobs[] | select(.name | endswith("agent"))] | last // empty
|
|
55
|
-
| "\(.started_at // "") \(.completed_at // "")"')"
|
|
66
|
+
| "\(.id // "") \(.started_at // "") \(.completed_at // "")"')"
|
|
56
67
|
minutes=-1
|
|
57
68
|
if [ -n "${started:-}" ] && [ -n "${finished:-}" ]; then
|
|
58
69
|
minutes=$(( ( $(date -u -d "$finished" +%s) - $(date -u -d "$started" +%s) ) / 60 ))
|
|
@@ -60,13 +71,30 @@ runs:
|
|
|
60
71
|
attempts="${ATTEMPTS:-0}"
|
|
61
72
|
[ -n "$attempts" ] || attempts=0
|
|
62
73
|
retry=false
|
|
74
|
+
cause=none
|
|
63
75
|
# An unknown duration is treated as a long run: never retry on a guess.
|
|
64
76
|
if [ "$minutes" -ge 0 ] && [ "$minutes" -lt "$UNDER_MINUTES" ] && [ "$attempts" -lt "$PARK_AT" ]; then
|
|
65
77
|
retry=true
|
|
78
|
+
cause=short-run
|
|
79
|
+
fi
|
|
80
|
+
# A long run is repeated only when it was killed, not when it was wrong. The reads are best
|
|
81
|
+
# effort: a log or an annotation list that cannot be fetched leaves the run parked, exactly
|
|
82
|
+
# as before this check existed.
|
|
83
|
+
if [ "$retry" != true ] && [ "$attempts" -lt "$PARK_AT" ] && [ -n "${job_id:-}" ]; then
|
|
84
|
+
work="$(mktemp -d)"
|
|
85
|
+
gh api "repos/$REPO/check-runs/$job_id/annotations" --paginate --jq '.[].message' >"$work/annotations" 2>/dev/null || true
|
|
86
|
+
gh api "repos/$REPO/actions/jobs/$job_id/logs" >"$work/log" 2>/dev/null || true
|
|
87
|
+
kind="$(bash "$ACTION_PATH/classify-failure.sh" "$work/log" "$work/annotations" || echo none)"
|
|
88
|
+
if [ "$kind" != none ]; then
|
|
89
|
+
retry=true
|
|
90
|
+
cause="$kind"
|
|
91
|
+
fi
|
|
92
|
+
rm -rf "$work"
|
|
66
93
|
fi
|
|
67
94
|
{
|
|
68
95
|
echo "retry=$retry"
|
|
69
96
|
echo "next=$((attempts + 1))"
|
|
70
97
|
echo "minutes=$minutes"
|
|
98
|
+
echo "cause=$cause"
|
|
71
99
|
} >> "$GITHUB_OUTPUT"
|
|
72
|
-
echo "agent job ran for ${minutes}m; attempts so far ${attempts}; retry=${retry}"
|
|
100
|
+
echo "agent job ran for ${minutes}m; attempts so far ${attempts}; retry=${retry}; cause=${cause}"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/decide-agent-retry/classify-failure.sh. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
|
+
# Say why an agent job died, when the reason is one that repeating the run fixes.
|
|
4
|
+
#
|
|
5
|
+
# Duration alone could not tell the difference. A run that dies in three minutes produced nothing;
|
|
6
|
+
# a run that works for forty minutes and is then killed by its machine produced nothing either, and
|
|
7
|
+
# was parked for the janitor's six-hourly sweep as if the agent had been wrong. Two causes are
|
|
8
|
+
# infrastructure, not judgement:
|
|
9
|
+
#
|
|
10
|
+
# runner-shutdown the VM was taken away mid-job: the process is killed with SIGTERM and GitHub
|
|
11
|
+
# annotates the job "Process completed with exit code 143" (the log has the
|
|
12
|
+
# longer "The runner has received a shutdown signal"), whatever the model was
|
|
13
|
+
# doing. 137, a SIGKILL, is left out: an out-of-memory kill repeats itself
|
|
14
|
+
# cut-off the engine was stopped one turn after it started: the last thing the log shows
|
|
15
|
+
# before the sandbox stops is the header opencode prints when it sends a request
|
|
16
|
+
# to the model, with no reply under it. The AI gateway answered nothing, or an
|
|
17
|
+
# error opencode did not surface, and the run ended with the work unsaid
|
|
18
|
+
#
|
|
19
|
+
# A tool's output in the log can quote either phrase, so neither is searched for in the body: the
|
|
20
|
+
# shutdown is read from the annotations, which only GitHub writes, and the cut-off from the one line
|
|
21
|
+
# immediately above the sandbox teardown.
|
|
22
|
+
#
|
|
23
|
+
# Usage: classify-failure.sh LOG_FILE ANNOTATIONS_FILE
|
|
24
|
+
# Prints one word: runner-shutdown, cut-off or none.
|
|
25
|
+
|
|
26
|
+
set -euo pipefail
|
|
27
|
+
|
|
28
|
+
log="${1:-}"
|
|
29
|
+
annotations="${2:-}"
|
|
30
|
+
|
|
31
|
+
if [ -n "$annotations" ] && [ -f "$annotations" ] && grep -qiE 'exit code 143|runner has received a shutdown signal|lost communication with the server|runner.*(was|has been) (deleted|terminated)' "$annotations"; then
|
|
32
|
+
echo runner-shutdown
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
if [ -n "$log" ] && [ -f "$log" ]; then
|
|
37
|
+
# The line the sandbox teardown starts on. grep exits 1 for none, which is an answer here.
|
|
38
|
+
stop=$(grep -n '\[INFO\] Stopping containers' "$log" | head -1 | cut -d: -f1 || true)
|
|
39
|
+
if [ -n "$stop" ] && [ "$stop" -gt 1 ]; then
|
|
40
|
+
# The last line with any text above it, ignoring the ANSI colour codes opencode wraps its
|
|
41
|
+
# output in and the timestamp every runner line carries.
|
|
42
|
+
last=$(sed -n "1,$((stop - 1))p" "$log" | sed -E 's/\x1b\[[0-9;]*m//g' | grep -vE '^(\S+Z )?[[:space:]]*$' | tail -1 || true)
|
|
43
|
+
if printf '%s' "$last" | grep -qE '^(\S+Z )?> [A-Za-z0-9_.-]+ · [A-Za-z0-9_./:-]+[[:space:]]*$'; then
|
|
44
|
+
echo cut-off
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
47
|
+
fi
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
echo none
|
|
@@ -71,13 +71,33 @@ runs:
|
|
|
71
71
|
// timeout. A stuck pending implement or refine run holds a fleet slot that the retry
|
|
72
72
|
// logic below can never free because the run never reaches a terminal state. This
|
|
73
73
|
// cancel runs on a GitHub-hosted runner, so it works even when the fleet is full.
|
|
74
|
+
//
|
|
75
|
+
// Only a run with nothing live ahead of it. The router queues implement runs FIFO
|
|
76
|
+
// (`queue: max`) so that labelling six issues at once implements all six, one after the
|
|
77
|
+
// other, and each of those takes an hour: the sixth waits five hours legitimately. A
|
|
78
|
+
// flat one-hour cutoff cancelled every run behind the first, which left its issue
|
|
79
|
+
// carrying bot-working with no run until the sweep cleared it (Pliny-Bot #706 and #644,
|
|
80
|
+
// both cancelled while #693 ran). A pending run is stuck when no run of its own kind
|
|
81
|
+
// ("Agent: Implement", "Agent: Merge Gate") is running or queued to hold the group.
|
|
74
82
|
const stuckCutoff = Date.now() - 3600 * 1000; // 1 hour ago
|
|
83
|
+
const runKind = (run) => String(run.display_title || '').split(' - ')[0];
|
|
84
|
+
const liveKinds = new Set();
|
|
85
|
+
for (const status of ['in_progress', 'queued']) {
|
|
86
|
+
for (const run of await github.paginate(github.rest.actions.listWorkflowRuns, {
|
|
87
|
+
owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'work-router.yml',
|
|
88
|
+
status, per_page: 50,
|
|
89
|
+
})) liveKinds.add(runKind(run));
|
|
90
|
+
}
|
|
75
91
|
let cancelledRuns = 0;
|
|
76
92
|
for (const run of await github.paginate(github.rest.actions.listWorkflowRuns, {
|
|
77
93
|
owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'work-router.yml',
|
|
78
94
|
status: 'pending', per_page: 50,
|
|
79
95
|
})) {
|
|
80
96
|
if (new Date(run.created_at).getTime() > stuckCutoff) continue;
|
|
97
|
+
if (liveKinds.has(runKind(run))) {
|
|
98
|
+
core.info(`run #${run.id} (${run.display_title}) waits behind a live ${runKind(run)} run; not stuck`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
81
101
|
await act(`cancelling stuck pending run #${run.id} (queued ${new Date(run.created_at).toISOString()})`, async () => {
|
|
82
102
|
await github.rest.actions.cancelWorkflowRun({
|
|
83
103
|
owner: context.repo.owner, repo: context.repo.repo, run_id: run.id,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/identify-gate-subject/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
2
|
name: Identify gate subject
|
|
3
|
-
description: Confirm the pull request is one the gate may act on,
|
|
3
|
+
description: Confirm the pull request is one the gate may act on (same repository, opened by a trusted bot or someone with write access) and resolve the open issue it closes, if any.
|
|
4
4
|
inputs:
|
|
5
5
|
token:
|
|
6
6
|
description: GitHub token with issues and pull-requests read access.
|
|
@@ -24,10 +24,6 @@ inputs:
|
|
|
24
24
|
description: Issue the pull request closes, when the router already resolved it.
|
|
25
25
|
required: false
|
|
26
26
|
default: ''
|
|
27
|
-
require-label:
|
|
28
|
-
description: Label a bot pull request's closing issue must carry. Empty disables the check. A person's pull request needs neither an issue nor the label.
|
|
29
|
-
required: false
|
|
30
|
-
default: ''
|
|
31
27
|
outputs:
|
|
32
28
|
found:
|
|
33
29
|
description: Whether a valid gate subject was found.
|
|
@@ -66,7 +62,6 @@ runs:
|
|
|
66
62
|
CONCLUSION: ${{ inputs.ci-conclusion }}
|
|
67
63
|
CI_RUN_ID: ${{ inputs.ci-run-id }}
|
|
68
64
|
LINKED_ISSUE: ${{ inputs.linked-issue }}
|
|
69
|
-
REQUIRE_LABEL: ${{ inputs.require-label }}
|
|
70
65
|
TRUSTED_BOTS: ${{ inputs.trusted-bots }}
|
|
71
66
|
run: |
|
|
72
67
|
set -euo pipefail
|
|
@@ -115,19 +110,16 @@ runs:
|
|
|
115
110
|
grep -oE '[0-9]+' | head -n 1 || true)"
|
|
116
111
|
fi
|
|
117
112
|
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if ! jq -e --arg name "$REQUIRE_LABEL" 'index($name)' >/dev/null <<<"$labels"; then
|
|
130
|
-
none "issue #$issue does not carry $REQUIRE_LABEL (has: $(jq -r 'join(", ")' <<<"$labels"))"
|
|
113
|
+
# An issue that is already closed is not the work this pull request is for: the feature
|
|
114
|
+
# landed some other way, and the label on a closed issue says nothing. Comments, labels
|
|
115
|
+
# and the acceptance criteria then come from the pull request itself. A pull request is
|
|
116
|
+
# never refused for lacking an issue: the merge train hands the gate every pull request
|
|
117
|
+
# that can move, and a gate that declines one without a trace is handed it again.
|
|
118
|
+
if [ -n "$issue" ]; then
|
|
119
|
+
issue_state="$(gh issue view "$issue" --repo "$REPO" --json state --jq '.state' 2>/dev/null || echo UNKNOWN)"
|
|
120
|
+
if [ "$issue_state" != OPEN ]; then
|
|
121
|
+
echo "Issue #$issue is $issue_state; gating the pull request on its own description."
|
|
122
|
+
issue=""
|
|
131
123
|
fi
|
|
132
124
|
fi
|
|
133
125
|
|
|
@@ -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
|
+
max-repairs:
|
|
32
|
+
description: How many pull requests may be in repair at once. Merges stay one at a time.
|
|
33
|
+
required: false
|
|
34
|
+
default: '1'
|
|
31
35
|
stuck-hours:
|
|
32
36
|
description: How long the train waits behind a CI run that never finishes before it passes that pull request by.
|
|
33
37
|
required: false
|
|
@@ -60,6 +64,7 @@ runs:
|
|
|
60
64
|
MAX_ATTEMPTS: ${{ inputs.max-gate-attempts }}
|
|
61
65
|
MAX_RETRIES: ${{ inputs.max-retries }}
|
|
62
66
|
STUCK_HOURS: ${{ inputs.stuck-hours }}
|
|
67
|
+
MAX_REPAIRS: ${{ inputs.max-repairs }}
|
|
63
68
|
DRY_RUN: ${{ inputs.dry-run }}
|
|
64
69
|
HINT_RUN_ID: ${{ inputs.ci-run-id }}
|
|
65
70
|
HINT_CONCLUSION: ${{ inputs.ci-conclusion }}
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/merge-train/merge-train.sh. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
|
-
# Move the merge train
|
|
3
|
+
# Move the merge train: look at every open pull request, oldest first, and do for each the one
|
|
4
|
+
# thing it needs, with merges one at a time and repairs in parallel.
|
|
4
5
|
#
|
|
5
6
|
# The belt this replaces dispatched the gate for every eligible pull request at once. Each gate
|
|
6
7
|
# then resolved its branch against a default branch the gate ahead of it was about to move, so a
|
|
7
8
|
# night of ten pull requests became ten repairs, ten CI runs and ten more gates after every merge,
|
|
8
|
-
# and a verdict that parked a pull request held it however stale it went.
|
|
9
|
-
# pull request in flight, lowest number first. Everything else waits, untouched, until the one in
|
|
10
|
-
# front of it has merged or been parked for a person.
|
|
9
|
+
# and a verdict that parked a pull request held it however stale it went.
|
|
11
10
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
11
|
+
# What races is a merge, because it moves the default branch under everyone else. A repair (a gate
|
|
12
|
+
# handed red CI) pushes to its own branch and can never merge, so it races nothing. So:
|
|
13
|
+
# merge one at a time, the oldest ready pull request first; nothing behind a pull request that
|
|
14
|
+
# is waiting on its CI, or behind a gate that may merge, merges or is brought up to date
|
|
15
|
+
# repair a red or conflicting pull request goes to the gate at once, up to MAX_REPAIRS together
|
|
16
16
|
# approve its CI is waiting for approval and a trusted bot opened it
|
|
17
|
+
# update it is green but behind the default branch: merge the default branch in, CI reruns
|
|
17
18
|
# A pull request a person has to decide on, or that carries the hold label, is skipped, so it never
|
|
18
19
|
# blocks the line. Ticks come from every merge, every CI completion, every finished gate, and the
|
|
19
20
|
# hourly schedule as the fallback.
|
|
@@ -27,7 +28,12 @@ set -euo pipefail
|
|
|
27
28
|
# HOLD true when the pull request carries the hold label
|
|
28
29
|
# REVIEW_DECISION GitHub's review decision (CHANGES_REQUESTED holds it for its author)
|
|
29
30
|
# PARK none, or held when a standing gate verdict covers this head
|
|
30
|
-
# ATTEMPTS failed gate attempts on this head; MAX_ATTEMPTS is the budget
|
|
31
|
+
# ATTEMPTS failed gate attempts on this head; MAX_ATTEMPTS is the budget. Counts the
|
|
32
|
+
# larger of the attempt comments and the gate runs, so a gate that fails
|
|
33
|
+
# without a comment still spends it
|
|
34
|
+
# SILENT_GATE true when a gate already ran on this head and left no verdict and no attempt
|
|
35
|
+
# comment: it declined, or died before it could say so. Handing it the same
|
|
36
|
+
# pull request again reproduces that, a hot loop with every tick
|
|
31
37
|
# MERGEABLE MERGEABLE, CONFLICTING or UNKNOWN
|
|
32
38
|
# CI_STATUS none, or the latest CI run's status (queued, in_progress, completed, ...)
|
|
33
39
|
# CI_CONCLUSION that run's conclusion when it completed
|
|
@@ -45,6 +51,8 @@ train_move() {
|
|
|
45
51
|
move=skip; reason="a gate verdict on this head is waiting for a person"
|
|
46
52
|
elif [ "${ATTEMPTS:-0}" -ge "${MAX_ATTEMPTS:-6}" ]; then
|
|
47
53
|
move=skip; reason="its gate attempt budget on this head is spent"
|
|
54
|
+
elif [ "${SILENT_GATE:-false}" = true ]; then
|
|
55
|
+
move=skip; reason="its gate already ran on this head and left no verdict or attempt"
|
|
48
56
|
elif [ "${MERGEABLE:-UNKNOWN}" = CONFLICTING ]; then
|
|
49
57
|
# A conflicting pull request has no merge ref, so CI can never run on it: waiting for a
|
|
50
58
|
# verdict would wait forever. Resolving the conflict is the gate's job.
|
|
@@ -126,6 +134,8 @@ TRUSTED_BOTS="${TRUSTED_BOTS:-}"
|
|
|
126
134
|
HOLD_LABEL="${HOLD_LABEL:-hold}"
|
|
127
135
|
MAX_ATTEMPTS="${MAX_ATTEMPTS:-6}"
|
|
128
136
|
MAX_RETRIES="${MAX_RETRIES:-3}"
|
|
137
|
+
MAX_REPAIRS="${MAX_REPAIRS:-1}"
|
|
138
|
+
case "$MAX_REPAIRS" in ''|*[!0-9]*|0) MAX_REPAIRS=1 ;; esac
|
|
129
139
|
STUCK_HOURS="${STUCK_HOURS:-3}"
|
|
130
140
|
DRY_RUN="${DRY_RUN:-false}"
|
|
131
141
|
SELF_RUN_ID="${GITHUB_RUN_ID:-0}"
|
|
@@ -168,18 +178,18 @@ can_ride() {
|
|
|
168
178
|
[ "${WRITE_ACCESS[$login]}" = true ]
|
|
169
179
|
}
|
|
170
180
|
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
|
|
181
|
+
# The gates on the belt, one title per line. A gate handed red CI repairs and pushes and cannot
|
|
182
|
+
# merge, so run-name gives it a " (repair)" suffix and the router queues it per pull request;
|
|
183
|
+
# every other gate may merge, and there is one of those at a time across the repository. The run
|
|
184
|
+
# this tick belongs to is excluded: the tick after a gate runs inside the gate's own router run.
|
|
185
|
+
live_gates() {
|
|
175
186
|
local status
|
|
176
187
|
for status in queued in_progress waiting pending requested; do
|
|
177
|
-
gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100"
|
|
178
|
-
--jq ".workflow_runs[]
|
|
188
|
+
gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" --jq ".workflow_runs[]
|
|
179
189
|
| select(.id != $SELF_RUN_ID)
|
|
180
190
|
| select(.display_title | startswith(\"$GATE_TITLE_PREFIX\"))
|
|
181
|
-
| .display_title" 2>/dev/null
|
|
182
|
-
done |
|
|
191
|
+
| .display_title" 2>/dev/null
|
|
192
|
+
done | sort -u
|
|
183
193
|
}
|
|
184
194
|
|
|
185
195
|
# GitHub works mergeability out on demand: the first read of a pull request nobody has opened
|
|
@@ -220,6 +230,14 @@ update_branch() {
|
|
|
220
230
|
gh api "repos/$REPO/pulls/$pr/update-branch" --method PUT -f expected_head_sha="$head_sha" >/dev/null
|
|
221
231
|
}
|
|
222
232
|
|
|
233
|
+
# How many gate runs were started for a pull request since a moment, whatever became of them. A
|
|
234
|
+
# run is titled by the router's run-name; this is the one count that does not depend on the gate
|
|
235
|
+
# having written anything.
|
|
236
|
+
gate_run_count() {
|
|
237
|
+
local pr="$1" since="$2"
|
|
238
|
+
gh api --paginate "repos/$REPO/actions/workflows/work-router.yml/runs?event=workflow_dispatch&per_page=100" --jq "[.workflow_runs[] | select(.display_title == \"$GATE_TITLE_PREFIX$pr\") | select(.created_at > \"$since\")] | length" 2>/dev/null | awk '{ n += $1 } END { print n + 0 }'
|
|
239
|
+
}
|
|
240
|
+
|
|
223
241
|
attempt_count() {
|
|
224
242
|
local number="$1" since="$2"
|
|
225
243
|
gh api --paginate "repos/$REPO/issues/$number/comments?per_page=100" \
|
|
@@ -241,7 +259,7 @@ dispatch_gate() {
|
|
|
241
259
|
# The next tick waits behind this one and must see the gate it started, or it starts a second.
|
|
242
260
|
for _ in $(seq 1 12); do
|
|
243
261
|
if gh api "repos/$REPO/actions/workflows/work-router.yml/runs?event=workflow_dispatch&created=>=$since&per_page=20" \
|
|
244
|
-
--jq ".workflow_runs[] | select(.display_title
|
|
262
|
+
--jq ".workflow_runs[] | select(.display_title | startswith(\"$GATE_TITLE_PREFIX$pr\")) | .id" 2>/dev/null | grep -q .; then
|
|
245
263
|
return 0
|
|
246
264
|
fi
|
|
247
265
|
sleep 5
|
|
@@ -252,11 +270,21 @@ dispatch_gate() {
|
|
|
252
270
|
summary "### Merge train"
|
|
253
271
|
summary ""
|
|
254
272
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
273
|
+
live_titles="$(live_gates)"
|
|
274
|
+
live_repairs=$(printf '%s
|
|
275
|
+
' "$live_titles" | grep -c ' (repair)$' || true)
|
|
276
|
+
live_merge=$(printf '%s
|
|
277
|
+
' "$live_titles" | grep -v ' (repair)$' | grep -c . || true)
|
|
278
|
+
live_for_pr() { printf '%s
|
|
279
|
+
' "$live_titles" | grep -qE "PR #$1( \(repair\))?$"; }
|
|
280
|
+
echo "On the belt: ${live_merge} merging gate(s), ${live_repairs} repair(s). Up to ${MAX_REPAIRS} repair(s) at once."
|
|
281
|
+
summary "On the belt: ${live_merge} merging gate(s), ${live_repairs} repair(s); up to ${MAX_REPAIRS} repair(s) at once."
|
|
282
|
+
summary ""
|
|
283
|
+
|
|
284
|
+
# Once a pull request ahead is waiting on its CI, or a gate that may merge is on the belt, nothing
|
|
285
|
+
# behind it may merge or be brought up to date: the oldest merges first, and one at a time.
|
|
286
|
+
merge_blocked=false
|
|
287
|
+
[ "$live_merge" -eq 0 ] || merge_blocked=true
|
|
260
288
|
|
|
261
289
|
base_head_date=""
|
|
262
290
|
base_head_date_for() {
|
|
@@ -332,6 +360,7 @@ for line in "${candidates[@]}"; do
|
|
|
332
360
|
RETRIES="$retries" MAX_RETRIES="$MAX_RETRIES" STACK_ROOT="$stack_root" train_park)
|
|
333
361
|
|
|
334
362
|
attempts=0
|
|
363
|
+
silent_gate=false
|
|
335
364
|
if [ "$park" = none ]; then
|
|
336
365
|
horizon="${head_committed:-1970-01-01T00:00:00Z}"
|
|
337
366
|
# A retried verdict starts a fresh budget from the verdict it retries, not from the head.
|
|
@@ -340,6 +369,12 @@ for line in "${candidates[@]}"; do
|
|
|
340
369
|
if [ -n "$linked_issue" ]; then
|
|
341
370
|
attempts=$((attempts + $(attempt_count "$linked_issue" "$horizon")))
|
|
342
371
|
fi
|
|
372
|
+
gate_runs=$(gate_run_count "$pr" "$horizon")
|
|
373
|
+
# Comments say what the gate decided. Runs say it was asked. A run on this head that left no
|
|
374
|
+
# attempt comment and no verdict is a gate that did nothing, and the next tick must not ask
|
|
375
|
+
# again: that is the loop.
|
|
376
|
+
if [ "$gate_runs" -gt 0 ] && [ "$attempts" -eq 0 ]; then silent_gate=true; fi
|
|
377
|
+
[ "$gate_runs" -le "$attempts" ] || attempts="$gate_runs"
|
|
343
378
|
fi
|
|
344
379
|
|
|
345
380
|
ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
|
|
@@ -372,32 +407,51 @@ for line in "${candidates[@]}"; do
|
|
|
372
407
|
is_trusted_bot "$author" && trusted_author=true
|
|
373
408
|
|
|
374
409
|
decision=$(HOLD="$hold" REVIEW_DECISION="$review_decision" PARK="$park" \
|
|
375
|
-
ATTEMPTS="$attempts" MAX_ATTEMPTS="$MAX_ATTEMPTS" MERGEABLE="$mergeable" \
|
|
410
|
+
ATTEMPTS="$attempts" MAX_ATTEMPTS="$MAX_ATTEMPTS" SILENT_GATE="$silent_gate" MERGEABLE="$mergeable" \
|
|
376
411
|
CI_STATUS="$ci_status" CI_CONCLUSION="$ci_conclusion" CI_STUCK="$ci_stuck" \
|
|
377
412
|
BEHIND_BY="${behind_by:-0}" TRUSTED_AUTHOR="$trusted_author" train_move)
|
|
378
413
|
move=$(sed -n 's/^move=//p' <<<"$decision")
|
|
379
414
|
reason=$(sed -n 's/^reason=//p' <<<"$decision")
|
|
380
415
|
|
|
416
|
+
# A gate is already on this pull request, whatever kind: offering it another would run the same
|
|
417
|
+
# work twice. It does not hold the line up for the repair kind; a merging gate already does.
|
|
418
|
+
if [ "$move" != skip ] && live_for_pr "$pr"; then
|
|
419
|
+
move=skip
|
|
420
|
+
reason="a gate is already on it"
|
|
421
|
+
fi
|
|
422
|
+
|
|
381
423
|
echo "#$pr ($author): $move, $reason."
|
|
382
424
|
summary "| #$pr | $author | $move | $reason |"
|
|
383
425
|
|
|
384
426
|
case "$move" in
|
|
385
427
|
skip) continue ;;
|
|
386
|
-
wait)
|
|
428
|
+
wait)
|
|
429
|
+
# Its CI is running: nothing behind it may merge or be brought up to date first, but the
|
|
430
|
+
# pull requests behind it that need repair do not wait for anyone.
|
|
431
|
+
merge_blocked=true
|
|
432
|
+
continue
|
|
433
|
+
;;
|
|
387
434
|
approve)
|
|
388
435
|
act "Approving the CI runs waiting on #$pr." approve_runs "$head_ref"
|
|
389
|
-
|
|
436
|
+
merge_blocked=true
|
|
437
|
+
continue
|
|
390
438
|
;;
|
|
391
439
|
update)
|
|
440
|
+
if [ "$merge_blocked" = true ]; then
|
|
441
|
+
echo " #$pr waits for the pull request ahead of it before it is brought up to date."
|
|
442
|
+
continue
|
|
443
|
+
fi
|
|
392
444
|
# Merge, never rebase: the gate's pushes are fast-forward only, and so are a person's.
|
|
393
445
|
if act "Merging $base_ref into #$pr so CI judges it against what it will land on." \
|
|
394
446
|
update_branch "$pr" "$head_sha"; then
|
|
395
|
-
|
|
447
|
+
merge_blocked=true
|
|
448
|
+
continue
|
|
396
449
|
fi
|
|
397
450
|
echo "::warning::GitHub could not update #$pr from $base_ref; handing it to the gate."
|
|
398
451
|
act "Dispatching the merge gate for #$pr to bring it up to date." \
|
|
399
452
|
dispatch_gate "$pr" success "${ci_run_id:-}" "$attempts"
|
|
400
|
-
|
|
453
|
+
merge_blocked=true
|
|
454
|
+
continue
|
|
401
455
|
;;
|
|
402
456
|
gate)
|
|
403
457
|
conclusion="${ci_conclusion:-}"
|
|
@@ -412,13 +466,30 @@ for line in "${candidates[@]}"; do
|
|
|
412
466
|
conclusion=$(cut -f2 <<<"$last")
|
|
413
467
|
[ -n "$conclusion" ] || conclusion=failure
|
|
414
468
|
fi
|
|
469
|
+
if [ "$conclusion" = failure ]; then
|
|
470
|
+
# A repair: it pushes and can never merge, so it races nothing. Up to MAX_REPAIRS at once.
|
|
471
|
+
if [ "$live_repairs" -ge "$MAX_REPAIRS" ]; then
|
|
472
|
+
echo " #$pr needs a repair; ${live_repairs} already in progress, the limit is ${MAX_REPAIRS}."
|
|
473
|
+
continue
|
|
474
|
+
fi
|
|
475
|
+
act "Dispatching a repair gate for #$pr (CI failed, attempt $((attempts + 1)) of $MAX_ATTEMPTS)." \
|
|
476
|
+
dispatch_gate "$pr" failure "${ci_run_id:-}" "$attempts"
|
|
477
|
+
live_repairs=$((live_repairs + 1))
|
|
478
|
+
continue
|
|
479
|
+
fi
|
|
480
|
+
# CI is green on the verdict: this gate may merge, so it goes only when nothing is ahead of it.
|
|
481
|
+
if [ "$merge_blocked" = true ]; then
|
|
482
|
+
echo " #$pr is ready for the gate; it waits for the pull request ahead of it."
|
|
483
|
+
continue
|
|
484
|
+
fi
|
|
415
485
|
act "Dispatching the merge gate for #$pr (CI $conclusion, attempt $((attempts + 1)) of $MAX_ATTEMPTS)." \
|
|
416
486
|
dispatch_gate "$pr" "$conclusion" "${ci_run_id:-}" "$attempts"
|
|
417
|
-
|
|
487
|
+
merge_blocked=true
|
|
488
|
+
continue
|
|
418
489
|
;;
|
|
419
490
|
esac
|
|
420
491
|
done
|
|
421
492
|
|
|
422
|
-
echo "
|
|
493
|
+
echo "That is every open pull request this tick could move."
|
|
423
494
|
summary ""
|
|
424
|
-
summary "
|
|
495
|
+
summary "Every open pull request was looked at; those that could move have."
|
|
@@ -24,7 +24,20 @@ runs:
|
|
|
24
24
|
env:
|
|
25
25
|
GH_TOKEN: ${{ inputs.token }}
|
|
26
26
|
run: |
|
|
27
|
-
|
|
27
|
+
set -euo pipefail
|
|
28
|
+
# The runners here outlive their jobs, and `git config --global` outlives the job with
|
|
29
|
+
# them. This step used to add one `url.<token>.insteadOf` rule per run and never remove it,
|
|
30
|
+
# so a runner that had pushed before held several rules for the same prefix, each carrying
|
|
31
|
+
# a token revoked since, and git picks among equal-length matches arbitrarily. The same
|
|
32
|
+
# gate pushed for one pull request and was refused for the next: "Invalid username or
|
|
33
|
+
# token", then "Target branch does not exist" because ls-remote exited 128 (Pliny-Bot #716
|
|
34
|
+
# failed twice that way while #717 pushed in between). Drop every such rule wherever it
|
|
35
|
+
# lives, and set this run's rule in the checkout only.
|
|
36
|
+
for scope in --global --local; do
|
|
37
|
+
git config "$scope" --get-regexp '^url\..*x-access-token.*\.insteadof$' 2>/dev/null |
|
|
38
|
+
while read -r key _; do git config "$scope" --unset-all "$key" || true; done || true
|
|
39
|
+
done
|
|
40
|
+
git config --local url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/"
|
|
28
41
|
- name: Read pull request branch
|
|
29
42
|
id: request
|
|
30
43
|
shell: bash
|
|
@@ -493,6 +493,14 @@ if [ "$BELT_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
|
493
493
|
# trailing space, so a number at the end of a body is still followed by a non-digit, and the
|
|
494
494
|
# pattern matches `[^0-9]`. Four quoting layers cannot eat a character class.
|
|
495
495
|
SWEEP_OK=1
|
|
496
|
+
# The live runs come from status-filtered queries. Reading the repository's latest hundred runs of
|
|
497
|
+
# any kind let a long-queued implement fall out of the window, and the sweep cleared bot-working
|
|
498
|
+
# from issues whose runs were alive (Pliny-Bot #693 and #707).
|
|
499
|
+
if grep -q 'gh api "repos/\$REPO/actions/runs?per_page=100"' "$ROUTER_YML" ||
|
|
500
|
+
! grep -qF 'actions/runs?status=$status&per_page=100' "$ROUTER_YML"; then
|
|
501
|
+
SWEEP_OK=0
|
|
502
|
+
echo "FAIL: the stale-reservation sweep must read live runs by status, not the latest hundred runs" >&2
|
|
503
|
+
fi
|
|
496
504
|
if ! grep -qF 'test(\"#${issue}[^0-9]\")' "$ROUTER_YML"; then
|
|
497
505
|
SWEEP_OK=0
|
|
498
506
|
echo "FAIL: the stale-reservation sweep does not match a run title with a character class; a word boundary there is eaten by bash and jq" >&2
|
|
@@ -688,12 +696,15 @@ if worker_installed merge-gate; then
|
|
|
688
696
|
# The merge belt is serial for the whole repository: several overnight pull requests
|
|
689
697
|
# mean every merge moves the default branch under the rest, and gates running at once
|
|
690
698
|
# rebase onto bases other gates are about to invalidate. A per-issue group here would
|
|
691
|
-
# reintroduce that race, so assert the repo-wide lock is the one in use.
|
|
692
|
-
|
|
699
|
+
# reintroduce that race, so assert the repo-wide lock is the one in use. The exception is a gate
|
|
700
|
+
# handed red CI: it repairs and pushes and can never merge, so it queues per pull request.
|
|
701
|
+
gate_group="$(sed -n '/^ call-merge-gate:/,/^ permissions:/p' "$ROUTER_YML" | grep 'group:')"
|
|
702
|
+
if grep -q "'merge-belt'" <<<"$gate_group" &&
|
|
703
|
+
grep -q "ci-conclusion == 'failure' && format('merge-repair-{0}', needs.classify.outputs.pr-number)" <<<"$gate_group"; then
|
|
693
704
|
PASS=$((PASS + 1))
|
|
694
705
|
else
|
|
695
706
|
FAIL=$((FAIL + 1))
|
|
696
|
-
echo "FAIL: call-merge-gate must
|
|
707
|
+
echo "FAIL: call-merge-gate must queue on merge-belt, and only a gate handed red CI may queue per pull request instead" >&2
|
|
697
708
|
fi
|
|
698
709
|
fi
|
|
699
710
|
|
|
@@ -719,7 +730,9 @@ if worker_installed merge-gate; then
|
|
|
719
730
|
# One gate at a time, found by the title run-name gives it. The belt looked for a title that
|
|
720
731
|
# run-name had stopped producing, saw no live gate ever, and dispatched #680 five times in
|
|
721
732
|
# an hour onto a belt that runs one gate at a time.
|
|
722
|
-
gate_title="$(sed -n "s/.*format('\(Agent: Merge Gate - PR #\){0}'.*/\1/p" "$ROUTER_YML" | head -1)"
|
|
733
|
+
gate_title="$(sed -n "s/.*format('\(Agent: Merge Gate - PR #\){0}{1}'.*/\1/p" "$ROUTER_YML" | head -1)"
|
|
734
|
+
grep -q "' (repair)'" "$ROUTER_YML" ||
|
|
735
|
+
{ BELT_OK=0; echo "FAIL: run-name must mark a gate handed red CI as a (repair), the train counts them by it" >&2; }
|
|
723
736
|
if [ -z "$gate_title" ] || ! grep -qF "GATE_TITLE_PREFIX=\"${gate_title}\"" "$TRAIN_SH"; then
|
|
724
737
|
BELT_OK=0
|
|
725
738
|
echo "FAIL: the train looks for live gates titled differently from what run-name names them ('${gate_title:-unread}')" >&2
|
|
@@ -1771,6 +1784,151 @@ if [ -f "$GATE_VALIDATOR" ] && worker_installed merge-gate; then
|
|
|
1771
1784
|
if [ "$VALIDATOR_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
1772
1785
|
fi
|
|
1773
1786
|
|
|
1787
|
+
# The gate takes any pull request the train hands it. A subject check that refuses one without
|
|
1788
|
+
# leaving a verdict or an attempt is a loop: the train cannot tell a gate that declined from a gate
|
|
1789
|
+
# that has not run. Refusals are for what the train never hands over (a fork, a stranger, a pull
|
|
1790
|
+
# request that is not open), and the train filters those itself.
|
|
1791
|
+
if worker_installed merge-gate; then
|
|
1792
|
+
# check-diff exiting 1 is the `fail` the code_rules job reports. Under GitHub's `bash -e` a bare
|
|
1793
|
+
# assignment from it ends the step, so the report is never recorded and the gate burns its
|
|
1794
|
+
# budget on a failure it could have repaired.
|
|
1795
|
+
if grep -qF 'check-diff.mjs --base "$BASE" 2>&1) || code=$?' "$MERGE_GATE_WORKER_MD"; then
|
|
1796
|
+
PASS=$((PASS + 1))
|
|
1797
|
+
else
|
|
1798
|
+
FAIL=$((FAIL + 1))
|
|
1799
|
+
echo "FAIL: code_rules must capture check-diff's exit code with || code=\$?; a bare assignment dies under bash -e" >&2
|
|
1800
|
+
fi
|
|
1801
|
+
# A run killed by its infrastructure is repeated whatever its duration; one the agent got wrong is
|
|
1802
|
+
# not. The two signatures are read from places a tool's output cannot write: GitHub's annotations
|
|
1803
|
+
# for the runner shutdown, and the line above the sandbox teardown for an engine cut off one turn
|
|
1804
|
+
# after it asked the model something (Pliny-Bot #699 and #693's first attempt).
|
|
1805
|
+
CLASSIFY="${HERE}/../decide-agent-retry/classify-failure.sh"
|
|
1806
|
+
if [ -f "$CLASSIFY" ]; then
|
|
1807
|
+
ctmp="$(mktemp -d)"
|
|
1808
|
+
printf '%s
|
|
1809
|
+
' '2026-10-16T22:13:03Z > ci-workflow-agent · glm-5-2' '2026-10-16T22:13:05Z the grep printed a line' '2026-10-16T22:13:36Z > ci-workflow-agent · glm-5-2' '2026-10-16T22:13:37Z [INFO] Stopping containers...' >"$ctmp/cutoff.log"
|
|
1810
|
+
printf '%s
|
|
1811
|
+
' '2026-10-16T22:13:03Z > ci-workflow-agent · glm-5-2' '2026-10-16T22:13:36Z Done. The pull request is ready.' '2026-10-16T22:13:37Z [INFO] Stopping containers...' >"$ctmp/done.log"
|
|
1812
|
+
printf '%s
|
|
1813
|
+
' '2026-10-16T22:13:03Z the log quotes: Process completed with exit code 143 and The runner has received a shutdown signal' '2026-10-16T22:13:04Z Done.' '2026-10-16T22:13:37Z [INFO] Stopping containers...' >"$ctmp/quoted.log"
|
|
1814
|
+
printf '%s
|
|
1815
|
+
' 'Process completed with exit code 143.' 'Property x does not exist' >"$ctmp/sigterm.ann"
|
|
1816
|
+
printf '%s
|
|
1817
|
+
' 'Process completed with exit code 137.' >"$ctmp/sigkill.ann"
|
|
1818
|
+
printf '%s
|
|
1819
|
+
' 'Process completed with exit code 1.' >"$ctmp/plain.ann"
|
|
1820
|
+
assert "an engine stopped one turn after its request is a cut-off" cut-off "$(bash "$CLASSIFY" "$ctmp/cutoff.log" "")"
|
|
1821
|
+
assert "a run that said its piece is not a cut-off" none "$(bash "$CLASSIFY" "$ctmp/done.log" "")"
|
|
1822
|
+
assert "a tool quoting the shutdown text does not count" none "$(bash "$CLASSIFY" "$ctmp/quoted.log" "")"
|
|
1823
|
+
assert "exit 143 in the annotations is a runner shutdown" runner-shutdown "$(bash "$CLASSIFY" "$ctmp/done.log" "$ctmp/sigterm.ann")"
|
|
1824
|
+
assert "exit 137 (an out-of-memory kill) is repeated by nobody" none "$(bash "$CLASSIFY" "$ctmp/done.log" "$ctmp/sigkill.ann")"
|
|
1825
|
+
assert "an ordinary failure is not infrastructure" none "$(bash "$CLASSIFY" "$ctmp/done.log" "$ctmp/plain.ann")"
|
|
1826
|
+
assert "nothing to read classifies as nothing" none "$(bash "$CLASSIFY" "" "")"
|
|
1827
|
+
rm -rf "$ctmp"
|
|
1828
|
+
fi
|
|
1829
|
+
# The implement worker carried its own copy of the retry rule while four other workers used the
|
|
1830
|
+
# shared action, so a rule fixed in one place stayed broken in the other.
|
|
1831
|
+
IMPLEMENT_MD="${WORKFLOWS_DIR}/agent-implement.md"
|
|
1832
|
+
if [ -f "$IMPLEMENT_MD" ]; then
|
|
1833
|
+
uses=$(count -c "uses: ./.github/actions/decide-agent-retry" "$IMPLEMENT_MD")
|
|
1834
|
+
if [ "$uses" -ge 2 ] && ! grep -q 'minutes=$(( ( $(date -u -d "$finished"' "$IMPLEMENT_MD"; then
|
|
1835
|
+
PASS=$((PASS + 1))
|
|
1836
|
+
else
|
|
1837
|
+
FAIL=$((FAIL + 1))
|
|
1838
|
+
echo "FAIL: agent-implement.md must decide a retry through the shared decide-agent-retry action, in both incomplete and conclude" >&2
|
|
1839
|
+
fi
|
|
1840
|
+
fi
|
|
1841
|
+
# Implement runs spread over IMPLEMENT_PARALLELISM queues, but the children of one refine share
|
|
1842
|
+
# a queue keyed on their split parent: they build on each other's code. The queue step's
|
|
1843
|
+
# arithmetic is exercised with the same expression the router runs.
|
|
1844
|
+
if grep -q 'group: implement-queue-${{ needs.check-stack-branch.outputs.implement-queue' "$ROUTER_YML" &&
|
|
1845
|
+
sed -n '/- name: Pick the implement queue/,/- name: Resolve the feature branch/p' "$ROUTER_YML" | grep -q 'key="${parent:-$ISSUE_NUMBER}"'; then
|
|
1846
|
+
PASS=$((PASS + 1))
|
|
1847
|
+
else
|
|
1848
|
+
FAIL=$((FAIL + 1))
|
|
1849
|
+
echo "FAIL: call-implement must queue on check-stack-branch's implement-queue, keyed on the split parent" >&2
|
|
1850
|
+
fi
|
|
1851
|
+
queue_for() { local parent="$1" issue="$2" queues="$3"; case "$queues" in ''|*[!0-9]*|0) queues=1 ;; esac; local key="${parent:-$issue}"; echo $(( key % queues )); }
|
|
1852
|
+
assert "siblings of one refine share a queue" "$(queue_for 690 693 3)" "$(queue_for 690 694 3)"
|
|
1853
|
+
assert "parallelism 1 is one queue for everything" 0 "$(queue_for "" 722 1)"
|
|
1854
|
+
assert "an unset or garbled parallelism is one queue" 0 "$(queue_for "" 725 abc)"
|
|
1855
|
+
assert "independent issues spread across the queues" 1 "$(queue_for "" 724 3)"
|
|
1856
|
+
# The router queues implement runs FIFO, so a run waiting an hour behind the one in progress is
|
|
1857
|
+
# healthy. The janitor cancelling every pending run over an hour old took every issue queued
|
|
1858
|
+
# behind the first out of the queue and left it holding bot-working (Pliny-Bot #706, #644).
|
|
1859
|
+
if [ -f "${HERE}/../housekeeping/action.yml" ] && ! grep -q "liveKinds" "${HERE}/../housekeeping/action.yml"; then
|
|
1860
|
+
FAIL=$((FAIL + 1))
|
|
1861
|
+
echo "FAIL: the janitor cancels pending runs without checking for a live run of their own kind ahead of them" >&2
|
|
1862
|
+
else
|
|
1863
|
+
PASS=$((PASS + 1))
|
|
1864
|
+
fi
|
|
1865
|
+
# The implement worker names every phase skill it must load. Left as "load each phase skill",
|
|
1866
|
+
# glm-5-2 loaded pc-plan-goal, wrote proposal.md and tasks.md itself and edited the code alone:
|
|
1867
|
+
# a hand-written tasks.md has no <!-- agent --> annotations, so pc-plan-apply had no subagents
|
|
1868
|
+
# to spawn and the whole change ran on one model, in sequence (Pliny-Bot #693, #699).
|
|
1869
|
+
IMPL_MD="${WORKFLOWS_DIR}/agent-implement.md"
|
|
1870
|
+
if [ -f "$IMPL_MD" ]; then
|
|
1871
|
+
if grep -q 'You never write' "$IMPL_MD" && grep -q '`pc-plan-propose`, even when the issue is refined' "$IMPL_MD" &&
|
|
1872
|
+
grep -q 'In this phase you do not edit source files' "$IMPL_MD" && grep -q '`pc-repo-verify`, then `pc-plan-archive`' "$IMPL_MD"; then
|
|
1873
|
+
PASS=$((PASS + 1))
|
|
1874
|
+
else
|
|
1875
|
+
FAIL=$((FAIL + 1))
|
|
1876
|
+
echo "FAIL: agent-implement.md must name pc-plan-propose, pc-plan-apply, pc-repo-verify and pc-plan-archive and forbid writing tasks.md by hand" >&2
|
|
1877
|
+
fi
|
|
1878
|
+
fi
|
|
1879
|
+
# Refine writes in English and may retitle an issue written in another language; without
|
|
1880
|
+
# allow_title on update-issue the title in the call is dropped and the issue keeps its language.
|
|
1881
|
+
REFINE_MD="${WORKFLOWS_DIR}/agent-refine.md"
|
|
1882
|
+
if [ -f "$REFINE_MD" ]; then
|
|
1883
|
+
if sed -n '/^ update-issue:/,/^ add-comment:/p' "$REFINE_MD" | grep -q '^ title:' &&
|
|
1884
|
+
grep -q 'Everything you write is in English' "$REFINE_MD"; then
|
|
1885
|
+
PASS=$((PASS + 1))
|
|
1886
|
+
else
|
|
1887
|
+
FAIL=$((FAIL + 1))
|
|
1888
|
+
echo "FAIL: agent-refine.md must write in English and be allowed to retitle an issue (update-issue title:)" >&2
|
|
1889
|
+
fi
|
|
1890
|
+
fi
|
|
1891
|
+
# A token written to the runner's global git config outlives the job on a runner that is reused:
|
|
1892
|
+
# rules for revoked tokens pile up for the same prefix and git picks one at random, so the same
|
|
1893
|
+
# gate pushed for one pull request and was refused for the next (Pliny-Bot #716 and #717).
|
|
1894
|
+
PUSH_ACTION="${HERE}/../push-agent-branch/action.yml"
|
|
1895
|
+
if [ -f "$PUSH_ACTION" ] && grep -qE 'git config( --global)? url\.' "$PUSH_ACTION" && ! grep -qF 'git config --local url.' "$PUSH_ACTION"; then
|
|
1896
|
+
FAIL=$((FAIL + 1))
|
|
1897
|
+
echo "FAIL: push-agent-branch writes its token to the global git config, where stale rules from earlier jobs accumulate" >&2
|
|
1898
|
+
elif [ -f "$PUSH_ACTION" ] && grep -qE 'git config --global url\.' "$PUSH_ACTION"; then
|
|
1899
|
+
FAIL=$((FAIL + 1))
|
|
1900
|
+
echo "FAIL: push-agent-branch adds to the global git config; set the rule with --local" >&2
|
|
1901
|
+
else
|
|
1902
|
+
PASS=$((PASS + 1))
|
|
1903
|
+
fi
|
|
1904
|
+
# A conclude that dies leaves no verdict and no attempt comment unless `incomplete` records it,
|
|
1905
|
+
# and the train then reads the run as a gate that did nothing and never offers the pull request
|
|
1906
|
+
# again.
|
|
1907
|
+
if sed -n '/^ incomplete:/,/^ runs-on:/p' "$MERGE_GATE_WORKER_MD" | grep -q "needs.conclude.result == 'failure'"; then
|
|
1908
|
+
PASS=$((PASS + 1))
|
|
1909
|
+
else
|
|
1910
|
+
FAIL=$((FAIL + 1))
|
|
1911
|
+
echo "FAIL: the gate's incomplete job must record a failed conclude as an attempt, or the train strands the pull request" >&2
|
|
1912
|
+
fi
|
|
1913
|
+
# A stack child is dispatched on its feature branch, and a dispatched run reads repository secrets
|
|
1914
|
+
# by their own names, not through the router's FORGE_API_KEY mapping. Without the fallback every
|
|
1915
|
+
# stack child died at activation on "Validate CODEX_API_KEY or OPENAI_API_KEY secret".
|
|
1916
|
+
COMPILE_SCRIPT="${HERE}/../../scripts/compile-agent-workflows.mjs"
|
|
1917
|
+
if [ -f "$COMPILE_SCRIPT" ] && ! grep -qF "secrets.OPENAI_API_KEY || secrets.FORGE_API_KEY" "$COMPILE_SCRIPT"; then
|
|
1918
|
+
FAIL=$((FAIL + 1))
|
|
1919
|
+
echo "FAIL: the compile script no longer gives the engine key a FORGE_API_KEY fallback; dispatched stack children cannot start" >&2
|
|
1920
|
+
else
|
|
1921
|
+
PASS=$((PASS + 1))
|
|
1922
|
+
fi
|
|
1923
|
+
SUBJECT_YML="${HERE}/../identify-gate-subject/action.yml"
|
|
1924
|
+
if grep -qE 'does not close a numbered issue|does not carry' "$SUBJECT_YML" || grep -q 'require-label' "$SUBJECT_YML" "$MERGE_GATE_WORKER_MD"; then
|
|
1925
|
+
FAIL=$((FAIL + 1))
|
|
1926
|
+
echo "FAIL: the gate's subject job refuses a pull request for lacking an issue or a label; the train hands it back forever" >&2
|
|
1927
|
+
else
|
|
1928
|
+
PASS=$((PASS + 1))
|
|
1929
|
+
fi
|
|
1930
|
+
fi
|
|
1931
|
+
|
|
1774
1932
|
echo "── Merge gate park ───────────────────────────────────────────────────────"
|
|
1775
1933
|
|
|
1776
1934
|
# A gate verdict parks the code it was given on. The belt used to compare the standing verdict
|
|
@@ -1813,6 +1971,10 @@ if worker_installed merge-gate; then
|
|
|
1813
1971
|
assert "requested changes keep it off the train" skip "$(move REVIEW_DECISION=CHANGES_REQUESTED CI_STATUS=completed CI_CONCLUSION=success)"
|
|
1814
1972
|
assert "a parked pull request is passed by, not waited on" skip "$(move PARK=held CI_STATUS=completed CI_CONCLUSION=success)"
|
|
1815
1973
|
assert "a spent attempt budget is passed by" skip "$(move ATTEMPTS=6 MAX_ATTEMPTS=6 CI_STATUS=completed CI_CONCLUSION=failure)"
|
|
1974
|
+
# The loop that ran Pliny-Bot's gate twenty times on one pull request: the gate declined it
|
|
1975
|
+
# (its issue was closed), left no verdict and no attempt, and every tick handed it back.
|
|
1976
|
+
assert "a gate that ran and left no trace is not asked again" skip "$(move SILENT_GATE=true MERGEABLE=CONFLICTING CI_STATUS=none)"
|
|
1977
|
+
assert "a gate that ran and left no trace is not asked again, green or red" skip "$(move SILENT_GATE=true MERGEABLE=MERGEABLE CI_STATUS=completed CI_CONCLUSION=failure)"
|
|
1816
1978
|
assert "a conflict goes to the gate even with no CI" gate "$(move MERGEABLE=CONFLICTING CI_STATUS=none)"
|
|
1817
1979
|
assert "running CI is waited on" wait "$(move MERGEABLE=MERGEABLE CI_STATUS=in_progress)"
|
|
1818
1980
|
assert "CI stuck past the limit is passed by" skip "$(move MERGEABLE=MERGEABLE CI_STATUS=queued CI_STUCK=true)"
|
|
@@ -2568,7 +2730,7 @@ if [ "$INPUT_WIRING_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)
|
|
|
2568
2730
|
BELT_QUEUE_OK=1
|
|
2569
2731
|
belt_caller=$(awk '/^ call-merge-gate:/ { found = 1; print; next } found && /^ [a-z]/ { exit } found { print }' "$ROUTER_YML")
|
|
2570
2732
|
printf '%s
|
|
2571
|
-
' "$belt_caller" | grep -qE
|
|
2733
|
+
' "$belt_caller" | grep -qE "^ *group: .*'merge-belt'" ||
|
|
2572
2734
|
{ BELT_QUEUE_OK=0; echo "FAIL: the merge gate is no longer serialised on the merge-belt concurrency group" >&2; }
|
|
2573
2735
|
printf '%s
|
|
2574
2736
|
' "$belt_caller" | grep -qE '^ *queue: max *$' ||
|
package/loops/docs/diagrams.md
CHANGED
|
@@ -94,7 +94,7 @@ flowchart LR
|
|
|
94
94
|
gate -->|merge| merged(("Merged"))
|
|
95
95
|
merged -->|"next PR"| train
|
|
96
96
|
gate -.->|"CI failed or conflict"| fix("Fix, push, CI again") --> ci
|
|
97
|
-
gate -.->|".github, secrets<br/>or
|
|
97
|
+
gate -.->|".github, secrets<br/>or 10000+ lines"| human(("Human review"))
|
|
98
98
|
review("Someone reviews the PR") --> applyReview("apply-review") --> ci
|
|
99
99
|
audit("audit, weekly") -->|"files one issue"| refine
|
|
100
100
|
|
|
@@ -381,7 +381,7 @@ flowchart TD
|
|
|
381
381
|
gateDisposition -->|"no verified blocker,<br/>radius low or recoverable medium"| gateRootCheck
|
|
382
382
|
gateRootCheck{"PR base is a<br/>stack feature branch<br/>and this PR is the root?"}
|
|
383
383
|
gateRootCheck -->|"child PR: no"| gateMerge
|
|
384
|
-
gateDisposition -.->|"
|
|
384
|
+
gateDisposition -.->|"10000+ lines, .github/<br/>or a secret"| gateOwner
|
|
385
385
|
gateDisposition -.->|"fragile, unmet criteria<br/>or low confidence"| gateHuman
|
|
386
386
|
gateDisposition -.->|"CI red or a verified<br/>high finding"| gateBlocked
|
|
387
387
|
gateMerge(("AUTO-MERGE<br/>squash into the PR's base<br/>(main, or the feature branch:<br/>then the child issue closes,<br/>pr-pending removed)"))
|
|
@@ -331,7 +331,19 @@ for (const file of readdirSync(workflowDirectory)) {
|
|
|
331
331
|
// runner rather than the run so a runner keeps its server warm between its own jobs.
|
|
332
332
|
// No double quotes here: the enclosing run: is a double-quoted YAML scalar, so a quote
|
|
333
333
|
// would have to be escaped. The default has no spaces, so bare is safe shell.
|
|
334
|
-
.replace(/\/tmp\/opencode-data(?!-\$\{\{)/g, () => '/tmp/opencode-data-${{ runner.name }}')
|
|
334
|
+
.replace(/\/tmp\/opencode-data(?!-\$\{\{)/g, () => '/tmp/opencode-data-${{ runner.name }}')
|
|
335
|
+
// The engine key falls back to FORGE_API_KEY, the one secret every repository here has. The
|
|
336
|
+
// router maps it to OPENAI_API_KEY when it CALLS a worker, but a stack child is DISPATCHED on
|
|
337
|
+
// its feature branch, and a dispatched run reads repository secrets by their own names: with
|
|
338
|
+
// no OPENAI_API_KEY or CODEX_API_KEY set, activation died on "Validate CODEX_API_KEY or
|
|
339
|
+
// OPENAI_API_KEY secret" and every stack child in Pliny-Bot failed that way for hours. gh-aw
|
|
340
|
+
// reads the key in four places, the validation step, the engine env and the redaction list
|
|
341
|
+
// included, and all of them must see the fallback or the key would run unredacted. The
|
|
342
|
+
// longest form goes first so the shorter ones do not match inside it.
|
|
343
|
+
.replaceAll('${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY }}',
|
|
344
|
+
'${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY || secrets.FORGE_API_KEY }}')
|
|
345
|
+
.replaceAll('${{ secrets.CODEX_API_KEY }}', '${{ secrets.CODEX_API_KEY || secrets.FORGE_API_KEY }}')
|
|
346
|
+
.replaceAll('${{ secrets.OPENAI_API_KEY }}', '${{ secrets.OPENAI_API_KEY || secrets.FORGE_API_KEY }}');
|
|
335
347
|
|
|
336
348
|
let rewritten = patched;
|
|
337
349
|
// A queued implement executes hours after its run was created, but actions/checkout
|
|
@@ -340,8 +352,8 @@ for (const file of readdirSync(workflowDirectory)) {
|
|
|
340
352
|
// every later push became a rebase onto a moved parent, which gh-aw's signed-commit
|
|
341
353
|
// push cannot do (it dies with "cannot rebase: You have unstaged changes" and files the
|
|
342
354
|
// pull request as an issue instead of a pull request). Checking out the branch tip at
|
|
343
|
-
// execution time makes the commit parent current main; the implement
|
|
344
|
-
//
|
|
355
|
+
// execution time makes the commit parent current main; the implement queue, shared by the
|
|
356
|
+
// children of one refine, keeps sibling merges out of the window while the agent runs.
|
|
345
357
|
//
|
|
346
358
|
// A stack child dispatches this workflow directly (`gh workflow run
|
|
347
359
|
// agent-implement.lock.yml --ref feat/N`), and there the pin must select the feature
|
|
@@ -415,6 +427,11 @@ for (const file of readdirSync(workflowDirectory)) {
|
|
|
415
427
|
for (const check of [
|
|
416
428
|
{ name: "bundle upload glob", ok: /\n( +)- name: Upload agent artifacts\n\1 if: always\(\)\n\1 continue-on-error: true\n\1 uses: actions\/upload-artifact@[^\n]*\n\1 with:\n\1 name: \$\{\{ needs\.activation\.outputs\.artifact_prefix \}\}agent\n\1 path: \|\n(?:\1 [^\n]*\n)*\1 \/tmp\/gh-aw\/aw-\*\.bundle\n/ },
|
|
417
429
|
{ name: "placeholder step intact", ok: /\n( +)- name: Write agent output placeholder if missing\n\1 if: always\(\)\n\1 run: \|\n\1 if \[ ! -f \/tmp\/gh-aw\/agent_output\.json \]; then\n\1 echo '\{"items":\[\]\}' > \/tmp\/gh-aw\/agent_output\.json\n\1 fi\n/ },
|
|
430
|
+
// Every read of the engine key carries the FORGE_API_KEY fallback; see the rewrite above.
|
|
431
|
+
{
|
|
432
|
+
name: "engine key falls back to FORGE_API_KEY",
|
|
433
|
+
ok: lock => !lock.includes("${{ secrets.OPENAI_API_KEY }}") && !lock.includes("${{ secrets.CODEX_API_KEY }}"),
|
|
434
|
+
},
|
|
418
435
|
{ name: "failed-jobs reporter disabled", ok: /- name: Report failed jobs\n[ \t]*id: report_failed_jobs\n[ \t]*if: false[^\n]*\n/ },
|
|
419
436
|
// Only agent-refine declares update_issue; the other workers never carry the tool.
|
|
420
437
|
{
|
|
@@ -185,7 +185,7 @@ jobs:
|
|
|
185
185
|
fi
|
|
186
186
|
|
|
187
187
|
# A queued implement run can start long after it was dispatched. If triage concluded
|
|
188
|
-
# needs-maintainer while this run waited in
|
|
188
|
+
# needs-maintainer while this run waited in its implement queue, the issue now
|
|
189
189
|
# carries review and the verdict must stand: the reserve step clears review blindly,
|
|
190
190
|
# so eligibility has to catch it first (Pliny-Bot #372).
|
|
191
191
|
if jq -e 'index("review")' >/dev/null <<<"$labels"; then
|
|
@@ -245,6 +245,8 @@ jobs:
|
|
|
245
245
|
contents: read
|
|
246
246
|
issues: write
|
|
247
247
|
pull-requests: write
|
|
248
|
+
# reads the agent job's log and annotations, and re-enters through the router on a retry
|
|
249
|
+
actions: write
|
|
248
250
|
steps:
|
|
249
251
|
- name: Checkout workflow actions
|
|
250
252
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
@@ -433,8 +435,48 @@ jobs:
|
|
|
433
435
|
${{ env.IMPLEMENT_MARKER }}
|
|
434
436
|
${{ env.PATCH_WITHOUT_REQUEST_COMMENT }}
|
|
435
437
|
[View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
|
436
|
-
|
|
438
|
+
# A run that ends with nothing is not always the agent's verdict. The AI gateway can answer
|
|
439
|
+
# nothing and end the engine one turn after it began, and the runner's VM can be taken away
|
|
440
|
+
# mid-job; neither says the work was wrong, and parking them for the janitor cost up to six
|
|
441
|
+
# hours each (Pliny-Bot #693's first attempt). The shared rule repeats those, and a run that
|
|
442
|
+
# died young, within the attempt budget.
|
|
443
|
+
- name: Decide whether an empty run is worth repeating
|
|
444
|
+
id: empty_retry
|
|
437
445
|
if: needs.safe_outputs.outputs.created_pr_number == '' && !contains(needs.agent.outputs.output_types, 'create_pull_request') && !contains(needs.agent.outputs.output_types, 'noop') && needs.agent.outputs.has_patch != 'true'
|
|
446
|
+
uses: ./.github/actions/decide-agent-retry
|
|
447
|
+
with:
|
|
448
|
+
token: ${{ github.token }}
|
|
449
|
+
attempts-so-far: ${{ inputs.attempts_so_far || '0' }}
|
|
450
|
+
park-at: ${{ env.PARK_AT_ATTEMPT }}
|
|
451
|
+
under-minutes: ${{ env.RETRY_UNDER_MINUTES }}
|
|
452
|
+
- name: Report the retried empty run
|
|
453
|
+
if: steps.empty_retry.outputs.retry == 'true'
|
|
454
|
+
uses: ./.github/actions/create-issue-comment
|
|
455
|
+
with:
|
|
456
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
457
|
+
issue-number: ${{ inputs.issue-number }}
|
|
458
|
+
body: |
|
|
459
|
+
${{ env.ATTEMPT_MARKER }}
|
|
460
|
+
Attempt ${{ steps.empty_retry.outputs.next }} of ${{ env.MAX_ATTEMPTS }} ended with nothing after ${{ steps.empty_retry.outputs.minutes }} minutes (${{ steps.empty_retry.outputs.cause }}). That is what a provider or runner outage looks like, not a verdict on the work, so this is being retried.
|
|
461
|
+
The issue keeps `implement`.
|
|
462
|
+
[View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
|
463
|
+
- name: Send the issue back through the router
|
|
464
|
+
if: steps.empty_retry.outputs.retry == 'true'
|
|
465
|
+
env:
|
|
466
|
+
GH_TOKEN: ${{ github.token }}
|
|
467
|
+
REPO: ${{ github.repository }}
|
|
468
|
+
REF: ${{ github.event.repository.default_branch }}
|
|
469
|
+
ISSUE_NUMBER: ${{ inputs.issue-number }}
|
|
470
|
+
NEXT: ${{ steps.empty_retry.outputs.next }}
|
|
471
|
+
run: |
|
|
472
|
+
set -euo pipefail
|
|
473
|
+
# The provider recovers in seconds, so pause before re-entering rather than dispatching
|
|
474
|
+
# back into the same outage.
|
|
475
|
+
sleep 30
|
|
476
|
+
gh workflow run work-router.yml --repo "$REPO" --ref "$REF" -f operation=implement -f issue-number="$ISSUE_NUMBER" -f attempts_so_far="$NEXT"
|
|
477
|
+
echo "Re-dispatched implement for #$ISSUE_NUMBER as attempt $NEXT."
|
|
478
|
+
- name: Flag a run that produced nothing
|
|
479
|
+
if: needs.safe_outputs.outputs.created_pr_number == '' && !contains(needs.agent.outputs.output_types, 'create_pull_request') && !contains(needs.agent.outputs.output_types, 'noop') && needs.agent.outputs.has_patch != 'true' && steps.empty_retry.outputs.retry != 'true'
|
|
438
480
|
uses: ./.github/actions/add-issue-labels
|
|
439
481
|
with:
|
|
440
482
|
token: ${{ steps.app-token.outputs.token }}
|
|
@@ -443,7 +485,7 @@ jobs:
|
|
|
443
485
|
${{ env.REVIEW_LABEL }}
|
|
444
486
|
${{ env.STALLED_LABEL }}
|
|
445
487
|
- name: Say the run produced nothing
|
|
446
|
-
if: needs.safe_outputs.outputs.created_pr_number == '' && !contains(needs.agent.outputs.output_types, 'create_pull_request') && !contains(needs.agent.outputs.output_types, 'noop') && needs.agent.outputs.has_patch != 'true'
|
|
488
|
+
if: needs.safe_outputs.outputs.created_pr_number == '' && !contains(needs.agent.outputs.output_types, 'create_pull_request') && !contains(needs.agent.outputs.output_types, 'noop') && needs.agent.outputs.has_patch != 'true' && steps.empty_retry.outputs.retry != 'true'
|
|
447
489
|
uses: ./.github/actions/create-issue-comment
|
|
448
490
|
with:
|
|
449
491
|
token: ${{ steps.app-token.outputs.token }}
|
|
@@ -478,36 +520,17 @@ jobs:
|
|
|
478
520
|
with:
|
|
479
521
|
client-id: ${{ secrets.BOT_APP_ID }}
|
|
480
522
|
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
523
|
+
# The shared rule, which this worker used to carry its own copy of. It repeats a run that died
|
|
524
|
+
# young, and now also one that was killed after a long time: the runner's VM taken away
|
|
525
|
+
# mid-job, or the AI gateway answering nothing and ending the engine one turn after it began.
|
|
481
526
|
- name: Decide whether this failure is worth repeating
|
|
482
527
|
id: decide
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
UNDER_MINUTES: ${{ env.RETRY_UNDER_MINUTES }}
|
|
490
|
-
run: |
|
|
491
|
-
set -euo pipefail
|
|
492
|
-
# The agent job belongs to this same run: a called workflow shares the caller's run id.
|
|
493
|
-
read -r started finished <<<"$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
|
|
494
|
-
--jq '[.jobs[] | select(.name | endswith("agent"))] | last // empty
|
|
495
|
-
| "\(.started_at // "") \(.completed_at // "")"')"
|
|
496
|
-
minutes=-1
|
|
497
|
-
if [ -n "${started:-}" ] && [ -n "${finished:-}" ]; then
|
|
498
|
-
minutes=$(( ( $(date -u -d "$finished" +%s) - $(date -u -d "$started" +%s) ) / 60 ))
|
|
499
|
-
fi
|
|
500
|
-
retry=false
|
|
501
|
-
# An unknown duration is treated as a long run: never retry on a guess.
|
|
502
|
-
if [ "$minutes" -ge 0 ] && [ "$minutes" -lt "$UNDER_MINUTES" ] && [ "$ATTEMPTS" -lt "$PARK_AT" ]; then
|
|
503
|
-
retry=true
|
|
504
|
-
fi
|
|
505
|
-
{
|
|
506
|
-
echo "retry=$retry"
|
|
507
|
-
echo "next=$((ATTEMPTS + 1))"
|
|
508
|
-
echo "minutes=$minutes"
|
|
509
|
-
} >> "$GITHUB_OUTPUT"
|
|
510
|
-
echo "agent job ran for ${minutes}m; attempts so far ${ATTEMPTS}; retry=${retry}"
|
|
528
|
+
uses: ./.github/actions/decide-agent-retry
|
|
529
|
+
with:
|
|
530
|
+
token: ${{ github.token }}
|
|
531
|
+
attempts-so-far: ${{ inputs.attempts_so_far || '0' }}
|
|
532
|
+
park-at: ${{ env.PARK_AT_ATTEMPT }}
|
|
533
|
+
under-minutes: ${{ env.RETRY_UNDER_MINUTES }}
|
|
511
534
|
# The attempt is recorded before any label moves, so a failure in the steps below leaves a
|
|
512
535
|
# run that can be counted rather than an issue released with nothing to show for it.
|
|
513
536
|
- name: Report the failed attempt
|
|
@@ -518,7 +541,7 @@ jobs:
|
|
|
518
541
|
issue-number: ${{ inputs.issue-number }}
|
|
519
542
|
body: |
|
|
520
543
|
${{ env.ATTEMPT_MARKER }}
|
|
521
|
-
Attempt ${{ steps.decide.outputs.next }} of ${{ env.MAX_ATTEMPTS }} ended after ${{ steps.decide.outputs.minutes }} minutes
|
|
544
|
+
Attempt ${{ steps.decide.outputs.next }} of ${{ env.MAX_ATTEMPTS }} ended after ${{ steps.decide.outputs.minutes }} minutes without an answer (${{ steps.decide.outputs.cause }}). That is what a provider or runner outage looks like, not a verdict on the work, so this is being retried.
|
|
522
545
|
The issue keeps `implement`.
|
|
523
546
|
[View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
|
524
547
|
- name: Release the reservation for the retry
|
|
@@ -703,6 +726,28 @@ timeout-minutes: 180
|
|
|
703
726
|
phase skill it names, in order, and do not explore the code yourself where it says a phase
|
|
704
727
|
is skipped.
|
|
705
728
|
|
|
729
|
+
**The pipeline is these skill loads, in this order, each a call to the Skill tool.** A run
|
|
730
|
+
that edits source files without having made them has skipped the pipeline, whatever it
|
|
731
|
+
wrote instead:
|
|
732
|
+
|
|
733
|
+
1. `pc-plan-goal`, then Phase 0 as it describes.
|
|
734
|
+
2. `pc-plan-propose`, even when the issue is refined. It is what turns the issue into
|
|
735
|
+
`openspec/changes/<id>/tasks.md` with every task annotated
|
|
736
|
+
`<!-- agent: <engineer>.<tier>, depends_on: [...], touches: [...] -->`. **You never write
|
|
737
|
+
`proposal.md` or `tasks.md` yourself.** A hand-written `tasks.md` carries no annotations,
|
|
738
|
+
and without them `pc-plan-apply` has no workers to spawn, so the whole change is done by
|
|
739
|
+
you alone, in sequence, on one model. That is the failure this step exists to prevent.
|
|
740
|
+
3. `pc-plan-apply`. The tasks are implemented by the subagents it spawns, in waves, each on
|
|
741
|
+
the model tier its annotation names. **In this phase you do not edit source files
|
|
742
|
+
yourself.** If it stops because a worker cannot be resolved, report that with
|
|
743
|
+
`report_incomplete`, naming the task and the missing worker; do not do the task yourself
|
|
744
|
+
to get past it.
|
|
745
|
+
4. `pc-repo-verify`, then `pc-plan-archive`, as `pc-plan-goal` directs.
|
|
746
|
+
|
|
747
|
+
Before the first edit to a source file, check that
|
|
748
|
+
`openspec/changes/<id>/tasks.md` exists and that its tasks carry the annotation above. If it
|
|
749
|
+
does not, you have not run `pc-plan-propose`: load it now.
|
|
750
|
+
|
|
706
751
|
a. `branch` is the output mode this sandbox needs: the branch is kept, nothing is merged and
|
|
707
752
|
nothing is pushed. Without it the skill merges into the local default branch and deletes
|
|
708
753
|
the feature branch, and step 6 below then opens a pull request from a branch that is
|
|
@@ -37,11 +37,12 @@ env:
|
|
|
37
37
|
# infrastructure used to stop a merge from here; they are the review pass's to look at now, and
|
|
38
38
|
# a migration that rewrites data still parks through a `low` recoverability the agent evidences.
|
|
39
39
|
SENSITIVE_PATHS: '(^|/)([Dd]omain|entities|[Cc]ontracts)/|(^|/)([Aa]uth|[Aa]uthn|[Aa]uthz|[Aa]uthentication|[Aa]uthorization|[Ii]dentity|[Ss]ecurity|[Mm]igrations|[Ii]nfra|terraform|helm|k8s|deploy)(/|[A-Z][A-Za-z]*\.[a-z]+$)'
|
|
40
|
-
# Diff shape. Lines decide high: a change over
|
|
41
|
-
# translations and generated code excluded) is too big to merge unseen.
|
|
42
|
-
#
|
|
40
|
+
# Diff shape. Lines decide high: a change over ten thousand counted lines (tests, docs,
|
|
41
|
+
# translations and generated code excluded) is too big to merge unseen. It was a thousand, which
|
|
42
|
+
# is a normal feature once the tests are left out. A file count alone never decides, so the high
|
|
43
|
+
# file threshold is set out of reach; spread still raises medium.
|
|
43
44
|
BLAST_HIGH_FILES: "100000"
|
|
44
|
-
BLAST_HIGH_LINES: "
|
|
45
|
+
BLAST_HIGH_LINES: "10000"
|
|
45
46
|
BLAST_MEDIUM_FILES: "5"
|
|
46
47
|
BLAST_MEDIUM_LINES: "200"
|
|
47
48
|
# Paths that change no runtime behaviour: tests, docs, specs, translations and generated
|
|
@@ -182,7 +183,6 @@ jobs:
|
|
|
182
183
|
ci-conclusion: ${{ inputs.ci-conclusion }}
|
|
183
184
|
ci-run-id: ${{ inputs.ci-run-id }}
|
|
184
185
|
linked-issue: ${{ inputs.linked-issue }}
|
|
185
|
-
require-label: ${{ env.IMPLEMENT_LABEL }}
|
|
186
186
|
trusted-bots: ${{ env.TRUSTED_BOTS }}
|
|
187
187
|
- name: Block a pull request with requested changes
|
|
188
188
|
id: review
|
|
@@ -320,8 +320,13 @@ jobs:
|
|
|
320
320
|
echo "check-diff is not installed (agent-harness 3.2.0 or later ships it), so comments and tests were not checked." >> "$GITHUB_STEP_SUMMARY"
|
|
321
321
|
exit 0
|
|
322
322
|
fi
|
|
323
|
-
|
|
324
|
-
|
|
323
|
+
# `|| code=$?`, not a bare `code=$?` on the next line. GitHub runs this under `bash -e`,
|
|
324
|
+
# and `set -uo pipefail` above does not undo it, so a check-diff that exited 1 (the
|
|
325
|
+
# `fail` this step exists to report) ended the step there: the job failed, the agent
|
|
326
|
+
# was skipped, and the pull request burned its whole attempt budget on a verdict that
|
|
327
|
+
# was never recorded (Pliny-Bot #686, six attempts in twenty minutes).
|
|
328
|
+
code=0
|
|
329
|
+
report=$(node .opencode/scripts/check-diff.mjs --base "$BASE" 2>&1) || code=$?
|
|
325
330
|
case "$code" in 0) result=pass ;; 1) result=fail ;; *) result=error ;; esac
|
|
326
331
|
delimiter="EOF_$(openssl rand -hex 8)"
|
|
327
332
|
{
|
|
@@ -691,8 +696,12 @@ jobs:
|
|
|
691
696
|
${{ env.OWNER_REVIEW_LABEL }}
|
|
692
697
|
${{ env.BLOCKED_LABEL }}
|
|
693
698
|
${{ env.PR_PENDING_LABEL }}
|
|
699
|
+
# A conclude that dies (a push refused, a token rejected) is a failed attempt too. Without it
|
|
700
|
+
# here the run left no verdict and no attempt comment, which the merge train reads as a gate that
|
|
701
|
+
# did nothing and never offers the pull request again: Pliny-Bot #716 sat stranded that way after
|
|
702
|
+
# its repair was computed and then lost to a rejected git fetch in conclude.
|
|
694
703
|
incomplete:
|
|
695
|
-
needs: [subject, protected_changes, agent, safe_outputs, validate_output]
|
|
704
|
+
needs: [subject, protected_changes, agent, safe_outputs, validate_output, conclude]
|
|
696
705
|
if: >
|
|
697
706
|
always() &&
|
|
698
707
|
needs.subject.outputs.found == 'true' &&
|
|
@@ -700,7 +709,8 @@ jobs:
|
|
|
700
709
|
(
|
|
701
710
|
needs.agent.result != 'success' ||
|
|
702
711
|
needs.safe_outputs.result != 'success' ||
|
|
703
|
-
needs.validate_output.outputs.valid != 'true'
|
|
712
|
+
needs.validate_output.outputs.valid != 'true' ||
|
|
713
|
+
needs.conclude.result == 'failure'
|
|
704
714
|
)
|
|
705
715
|
runs-on: agents-arc
|
|
706
716
|
permissions:
|
|
@@ -837,9 +837,11 @@ safe-outputs:
|
|
|
837
837
|
# was sent with an incorrect parameter shape (20 bytes, missing body) and was accepted
|
|
838
838
|
# with success by the bridge, consuming the 1-per-run quota". The prompt still says to
|
|
839
839
|
# send the body once.
|
|
840
|
+
# `title:` lets refine set an English title when the issue was written in another language.
|
|
840
841
|
update-issue:
|
|
841
842
|
target: "*"
|
|
842
843
|
max: 3
|
|
844
|
+
title:
|
|
843
845
|
add-comment:
|
|
844
846
|
# Split children. An oversized story becomes several implementable ones rather than
|
|
845
847
|
# one issue nobody can land; the cap stops a runaway decomposition.
|
|
@@ -976,6 +978,13 @@ timeout-minutes: 90
|
|
|
976
978
|
The visible line is for people and the marker is read by the workflow, which turns it into the
|
|
977
979
|
`sp-N` label. A body without the marker gets no estimate label at all.
|
|
978
980
|
|
|
981
|
+
**Everything you write is in English, whatever language the issue was written in.** Bodies,
|
|
982
|
+
child issues, comments and questions are English. When the title of the issue you are refining
|
|
983
|
+
is not English, put an English title in the same `update_issue` call as the body, as
|
|
984
|
+
`{"issue_number":N,"title":"...","body":"..."}`: it carries the same meaning, short, with no
|
|
985
|
+
language tag. People write issues in their own language, and everything the workflow produces
|
|
986
|
+
from them (pull requests, commits, the run history) is read by everyone else in English.
|
|
987
|
+
|
|
979
988
|
7. **One safe-output call per turn.** Never batch multiple safe-output calls into a single
|
|
980
989
|
message: `update_issue`, `create_issue` and `add_comment` each go in their own turn, with
|
|
981
990
|
nothing else in the message. The provider caps one response at a fixed size, and a batch of
|
|
@@ -69,7 +69,7 @@ run-name: >-
|
|
|
69
69
|
&& format('Agentics: Merge Train - CI on PR #{0}', inputs.pr-number)
|
|
70
70
|
|| github.event_name == 'workflow_dispatch'
|
|
71
71
|
&& inputs.operation == 'merge-gate'
|
|
72
|
-
&& format('Agent: Merge Gate - PR #{0}', inputs.pr-number)
|
|
72
|
+
&& format('Agent: Merge Gate - PR #{0}{1}', inputs.pr-number, inputs.ci-conclusion == 'failure' && ' (repair)' || '')
|
|
73
73
|
|| github.event_name == 'workflow_dispatch'
|
|
74
74
|
&& inputs.operation == 'merge-train'
|
|
75
75
|
&& format('Agentics: Merge Train{0}', '')
|
|
@@ -160,6 +160,15 @@ env:
|
|
|
160
160
|
# "true" logs the move the train would make and makes none. For trying the train on a
|
|
161
161
|
# repository before letting it act.
|
|
162
162
|
MERGE_TRAIN_DRY_RUN: "false"
|
|
163
|
+
# How many pull requests the merge train may have in repair at once. A repair moves nothing on
|
|
164
|
+
# the default branch, so repairs of different pull requests do not race; only merges are serial.
|
|
165
|
+
# 1 repairs one pull request at a time.
|
|
166
|
+
MERGE_TRAIN_REPAIRS: "1"
|
|
167
|
+
# How many implement runs may work at once. Each queue is FIFO and serial. An issue joins the
|
|
168
|
+
# queue of its split parent when it has one, so the children of one refine never run side by
|
|
169
|
+
# side on the code they share; independent issues spread across the queues. 1 is one queue
|
|
170
|
+
# for everything, as before this existed. Raise it only with a fleet that has the VMs.
|
|
171
|
+
IMPLEMENT_PARALLELISM: "1"
|
|
163
172
|
# Whether a merged pull request's verification plan is replayed and its screenshots archived on
|
|
164
173
|
# the pull request (evidence.yml). It needs nothing configured: the app starts with the root
|
|
165
174
|
# `pnpm run dev`, and a pull request with no plan costs one API call. Set it to "false" for a
|
|
@@ -522,7 +531,29 @@ jobs:
|
|
|
522
531
|
stack-branch: ${{ steps.stack.outputs.stack-branch }}
|
|
523
532
|
stack-in-flight: ${{ steps.inflight.outputs.stack-in-flight }}
|
|
524
533
|
stack-blocked: ${{ steps.blocked.outputs.stack-blocked }}
|
|
534
|
+
implement-queue: ${{ steps.queue.outputs.queue }}
|
|
525
535
|
steps:
|
|
536
|
+
# Which implement queue this issue waits in. The children of one refine share a queue,
|
|
537
|
+
# keyed on their split parent, because they build on each other's code and two of them at
|
|
538
|
+
# once would each cut from a main the other is about to change. Anything else is keyed on
|
|
539
|
+
# its own number. A body that cannot be read keys on the issue: a wrong queue only costs
|
|
540
|
+
# parallelism, never order within a feature.
|
|
541
|
+
- name: Pick the implement queue
|
|
542
|
+
id: queue
|
|
543
|
+
env:
|
|
544
|
+
GH_TOKEN: ${{ github.token }}
|
|
545
|
+
REPO: ${{ github.repository }}
|
|
546
|
+
ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
|
|
547
|
+
run: |
|
|
548
|
+
set -euo pipefail
|
|
549
|
+
queues="${IMPLEMENT_PARALLELISM:-1}"
|
|
550
|
+
case "$queues" in ''|*[!0-9]*|0) queues=1 ;; esac
|
|
551
|
+
body=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')
|
|
552
|
+
parent=$(printf '%s' "$body" | grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
|
|
553
|
+
key="${parent:-$ISSUE_NUMBER}"
|
|
554
|
+
queue=$(( key % queues ))
|
|
555
|
+
echo "queue=$queue" >> "$GITHUB_OUTPUT"
|
|
556
|
+
echo "#$ISSUE_NUMBER waits in implement queue $queue of $queues (keyed on #$key)."
|
|
526
557
|
- name: Resolve the feature branch from the stack markers
|
|
527
558
|
id: stack
|
|
528
559
|
env:
|
|
@@ -645,8 +676,10 @@ jobs:
|
|
|
645
676
|
needs: [classify, authorize, check-implement-pr, check-stack-branch]
|
|
646
677
|
if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true' && needs.check-stack-branch.outputs.is-stack != 'true'
|
|
647
678
|
uses: ./.github/workflows/agent-implement.lock.yml
|
|
679
|
+
# One FIFO queue per IMPLEMENT_PARALLELISM slot, chosen by check-stack-branch: the children of
|
|
680
|
+
# one refine always share a queue, independent issues spread across them.
|
|
648
681
|
concurrency:
|
|
649
|
-
group: implement-
|
|
682
|
+
group: implement-queue-${{ needs.check-stack-branch.outputs.implement-queue || '0' }}
|
|
650
683
|
cancel-in-progress: false
|
|
651
684
|
# FIFO, up to 100 waiting. Without this the default `queue: single` cancels whatever
|
|
652
685
|
# was pending, so labelling six issues at once implements one and drops the rest.
|
|
@@ -793,8 +826,12 @@ jobs:
|
|
|
793
826
|
needs: classify
|
|
794
827
|
if: needs.classify.outputs.route == 'merge-gate'
|
|
795
828
|
uses: ./.github/workflows/agent-merge-gate.lock.yml
|
|
829
|
+
# A gate that was handed red CI repairs and pushes: it can never merge (the validator blocks a
|
|
830
|
+
# non-success conclusion), so it moves nothing on the default branch and cannot race another
|
|
831
|
+
# gate. Those run in a group of their own per pull request, in parallel, while everything that
|
|
832
|
+
# may merge still waits its turn on the one belt.
|
|
796
833
|
concurrency:
|
|
797
|
-
group: merge-belt
|
|
834
|
+
group: ${{ needs.classify.outputs.ci-conclusion == 'failure' && format('merge-repair-{0}', needs.classify.outputs.pr-number) || 'merge-belt' }}
|
|
798
835
|
cancel-in-progress: false
|
|
799
836
|
queue: max
|
|
800
837
|
permissions: write-all
|
|
@@ -865,6 +902,7 @@ jobs:
|
|
|
865
902
|
hold-label: ${{ env.MERGE_TRAIN_HOLD_LABEL }}
|
|
866
903
|
max-gate-attempts: ${{ env.MAX_GATE_ATTEMPTS }}
|
|
867
904
|
max-retries: ${{ env.MERGE_TRAIN_MAX_RETRIES }}
|
|
905
|
+
max-repairs: ${{ env.MERGE_TRAIN_REPAIRS }}
|
|
868
906
|
dry-run: ${{ env.MERGE_TRAIN_DRY_RUN }}
|
|
869
907
|
ci-run-id: ${{ needs.classify.outputs.ci-run-id }}
|
|
870
908
|
ci-conclusion: ${{ needs.classify.outputs.ci-conclusion }}
|
|
@@ -1254,19 +1292,28 @@ jobs:
|
|
|
1254
1292
|
set -euo pipefail
|
|
1255
1293
|
|
|
1256
1294
|
# Stale reservation sweep. Labelling ten issues at once labels all ten immediately,
|
|
1257
|
-
# because authorize-bot-work is keyed per issue, while implement
|
|
1295
|
+
# because authorize-bot-work is keyed per issue, while each implement queue lets exactly
|
|
1258
1296
|
# one run at a time. A queued run that is cancelled therefore leaves its issue
|
|
1259
1297
|
# carrying bot-working with no run and no pull request: parked, invisible, and not
|
|
1260
1298
|
# re-triggerable, because classify refuses an issue that is already reserved.
|
|
1299
|
+
#
|
|
1300
|
+
# The live runs are read by status, not as the repository's latest hundred runs. Every
|
|
1301
|
+
# comment and label is a router run, so a busy repository pushes an implement that has
|
|
1302
|
+
# waited two hours in the queue out of the latest hundred, and the sweep then cleared
|
|
1303
|
+
# bot-working from issues whose runs were alive and about to start (Pliny-Bot #693 and
|
|
1304
|
+
# #707, both still queued from 07:32). Runs that are not finished are few, whatever
|
|
1305
|
+
# their age.
|
|
1306
|
+
active_runs=$(for status in queued in_progress pending waiting requested; do
|
|
1307
|
+
gh api "repos/$REPO/actions/runs?status=$status&per_page=100" --jq '.workflow_runs[]' 2>/dev/null || true
|
|
1308
|
+
done | jq -s '{workflow_runs: .}')
|
|
1261
1309
|
gh issue list --repo "$REPO" --label bot-working --state open --limit 100 \
|
|
1262
1310
|
--json number --jq '.[].number' |
|
|
1263
1311
|
while IFS= read -r issue; do
|
|
1264
1312
|
[ -n "$issue" ] || continue
|
|
1265
1313
|
|
|
1266
|
-
live=$(
|
|
1267
|
-
--jq "[.workflow_runs[]
|
|
1314
|
+
live=$(jq "[.workflow_runs[]
|
|
1268
1315
|
| select(.status == \"queued\" or .status == \"in_progress\" or .status == \"pending\" or .status == \"waiting\")
|
|
1269
|
-
| select((.display_title + \" \") | test(\"#${issue}[^0-9]\"))] | length")
|
|
1316
|
+
| select((.display_title + \" \") | test(\"#${issue}[^0-9]\"))] | length" <<<"$active_runs")
|
|
1270
1317
|
[ "${live:-0}" -eq 0 ] || continue
|
|
1271
1318
|
|
|
1272
1319
|
has_pr=$(gh pr list --repo "$REPO" --state open --json body \
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plainconceptsplatform/workflows",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.10",
|
|
4
4
|
"description": "Install and update Platform GitHub agentic workflows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"github-actions",
|
|
@@ -25,6 +25,13 @@
|
|
|
25
25
|
"loops",
|
|
26
26
|
"README.md"
|
|
27
27
|
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc --project tsconfig.build.json && node ./scripts/copy-loops.mjs",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"typecheck": "tsc --noEmit --project tsconfig.json",
|
|
32
|
+
"release": "pnpm build && pnpm test && npm publish --access public",
|
|
33
|
+
"prepack": "node ./scripts/copy-loops.mjs"
|
|
34
|
+
},
|
|
28
35
|
"engines": {
|
|
29
36
|
"node": ">=20.19"
|
|
30
37
|
},
|
|
@@ -35,11 +42,5 @@
|
|
|
35
42
|
"@types/node": "^22.0.0",
|
|
36
43
|
"typescript": "^5.8.0",
|
|
37
44
|
"vitest": "^3.0.0"
|
|
38
|
-
},
|
|
39
|
-
"scripts": {
|
|
40
|
-
"build": "tsc --project tsconfig.build.json && node ./scripts/copy-loops.mjs",
|
|
41
|
-
"test": "vitest run",
|
|
42
|
-
"typecheck": "tsc --noEmit --project tsconfig.json",
|
|
43
|
-
"release": "pnpm build && pnpm test && npm publish --access public"
|
|
44
45
|
}
|
|
45
|
-
}
|
|
46
|
+
}
|