@plot-pm/board 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/board-server.mjs +157 -151
- package/package.json +9 -1
- package/plot-agent-monitor.sh +532 -0
- package/plot-approve.sh +151 -32
- package/plot-budget.sh +439 -0
- package/plot-build-monitor.sh +435 -0
- package/plot-config.sh +17 -1
- package/plot-default-branch.sh +109 -0
- package/plot-deliver.sh +214 -119
- package/plot-dispatch.sh +1327 -188
- package/plot-fleet-scan.sh +1005 -170
- package/plot-host.sh +1438 -61
- package/plot-monitor-subject.sh +194 -0
- package/plot-plan-meta.sh +345 -47
- package/plot-pr-merged.sh +180 -0
- package/plot-reap.sh +719 -61
- package/plot-release-refs.sh +188 -46
- package/plot-resolve-artifact.sh +115 -22
- package/plot-transcript-quiet.sh +142 -0
- package/plot-worker-monitor.sh +644 -0
- package/plot-worker-state.sh +81 -50
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Plot helper: the BuildMonitor — watches the RUN, and what it concluded about a sha.
|
|
3
|
+
#
|
|
4
|
+
# RUN, NOT SOURCED, and started by `start_worker()` in `plot-dispatch.sh` as a
|
|
5
|
+
# child of the wrapper, beside the WorkerMonitor and the AgentMonitor.
|
|
6
|
+
#
|
|
7
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
8
|
+
# THREE MONITORS, BECAUSE THERE ARE THREE SUBJECTS
|
|
9
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
10
|
+
#
|
|
11
|
+
# Two monitors became three, and the reason is the one that split the first two:
|
|
12
|
+
# a different subject on a different cadence.
|
|
13
|
+
#
|
|
14
|
+
# | monitor | subject | samples | asks |
|
|
15
|
+
# |-------------------|-------------|------------------------------|----------------------------|
|
|
16
|
+
# | **WorkerMonitor** | the process | ~30 s | is it doing anything? |
|
|
17
|
+
# | **AgentMonitor** | the desk | ~5 min | what does this agent owe? |
|
|
18
|
+
# | **BuildMonitor** | the run | ~30 s **while a run is live**| did the build change? |
|
|
19
|
+
#
|
|
20
|
+
# A Build is already an entity in the spec, with its own identity and its own
|
|
21
|
+
# state: DESIGN-build.md — *"is the thing that RUNS … one RESULT of one run"*,
|
|
22
|
+
# identified by its URL, holding a state, a start time and a duration. **A
|
|
23
|
+
# monitor per entity is the pattern, not an exception to it.**
|
|
24
|
+
#
|
|
25
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
26
|
+
# FOUR FINDINGS, AND `head moved` IS THE ONE THAT EARNS THIS MONITOR
|
|
27
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
28
|
+
#
|
|
29
|
+
# | finding | measurement |
|
|
30
|
+
# |--------------------------|----------------------------------------------------|
|
|
31
|
+
# | **build failed** | a run for this branch's head reached a failing conclusion |
|
|
32
|
+
# | **build passed** | it reached success |
|
|
33
|
+
# | **build needs approval** | it is `action_required` |
|
|
34
|
+
# | **head moved** | a newer sha exists, so the run in flight answers about the past |
|
|
35
|
+
#
|
|
36
|
+
# **`head moved` is why this cannot live in the AgentMonitor.** A build's
|
|
37
|
+
# subject is a SHA, not a branch. A green result for code nobody will merge is
|
|
38
|
+
# worse than no result — it invites a merge of the wrong thing. Measured
|
|
39
|
+
# 2026-08-30: two merge waiters reported on superseded runs and had to be
|
|
40
|
+
# stopped and re-armed.
|
|
41
|
+
#
|
|
42
|
+
# **`action_required` is a real state here, not an edge case.** Bot branches hit
|
|
43
|
+
# it — the release PR's runs need manual approval before they start. A monitor
|
|
44
|
+
# that folded it into "not passed yet" would report a build as pending forever
|
|
45
|
+
# while it waits for a click nobody knows is needed.
|
|
46
|
+
#
|
|
47
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
48
|
+
# IT POLLS NOTHING WHEN NO RUN IS LIVE
|
|
49
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
50
|
+
#
|
|
51
|
+
# That is what makes a 30-second cadence against a host affordable, and it is
|
|
52
|
+
# the property that separates this monitor's budget from the AgentMonitor's.
|
|
53
|
+
# The AgentMonitor's five-minute budget exists because it asks ON EVERY PASS;
|
|
54
|
+
# this one asks only while there is something to ask about.
|
|
55
|
+
#
|
|
56
|
+
# STRUCTURALLY, NOT INCIDENTALLY: `monitor_head_sha` is a LOCAL git read and it
|
|
57
|
+
# gates the host call. `sample_finding` returns before `monitor_run_for_sha` is
|
|
58
|
+
# ever reached whenever there is no head to ask about, and once a sha has
|
|
59
|
+
# reached a terminal conclusion it is never asked about again. A monitor that
|
|
60
|
+
# kept questioning an idle host is the rate problem this whole design avoids,
|
|
61
|
+
# so the silence is asserted by the tests rather than assumed.
|
|
62
|
+
#
|
|
63
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
64
|
+
# THE FINDINGS ARE TRANSITIONS, NOT CONDITIONS
|
|
65
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
66
|
+
#
|
|
67
|
+
# The other monitors report states that PERSIST — `owes a review` holds until a
|
|
68
|
+
# PR exists, and republishing it would be repeating one fact. A build's answer
|
|
69
|
+
# CHANGES ONCE AND STAYS: a run that failed has failed, and it will still have
|
|
70
|
+
# failed in thirty seconds.
|
|
71
|
+
#
|
|
72
|
+
# So the state this monitor carries is keyed by SHA as well as by finding.
|
|
73
|
+
# Publishing `build passed` for one sha does not suppress `build passed` for the
|
|
74
|
+
# next one — that would silence the answer an operator is actually waiting for,
|
|
75
|
+
# on the very push they pushed to get it. And once a sha's build is terminal,
|
|
76
|
+
# the sha is not asked about again: the answer cannot change, so continuing to
|
|
77
|
+
# poll would be spending a host round trip to re-learn a fact already published.
|
|
78
|
+
#
|
|
79
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
80
|
+
# IT OBSERVES; IT DOES NOT ACT
|
|
81
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
82
|
+
#
|
|
83
|
+
# It does not rerun a workflow, approve a run that is `action_required`, merge a
|
|
84
|
+
# PR that went green, or push a fix for one that went red. Every one of those is
|
|
85
|
+
# a judgement with a blast radius. Approving a run in particular is a human's
|
|
86
|
+
# call by construction — `action_required` EXISTS because a person is meant to
|
|
87
|
+
# look — and a monitor that clicked it would defeat the gate it is reporting.
|
|
88
|
+
#
|
|
89
|
+
# PUBLISHING IS ITS ONLY OUTPUT, as next door: no state file, no cache, nothing
|
|
90
|
+
# written into the repository it watches, not even a record of what it last
|
|
91
|
+
# published. The variables that make "publish on change" work live in memory and
|
|
92
|
+
# die with the process, so a restarted monitor re-derives them one interval late
|
|
93
|
+
# rather than reading a stale one.
|
|
94
|
+
set -uo pipefail
|
|
95
|
+
|
|
96
|
+
usage() {
|
|
97
|
+
cat >&2 <<'EOF'
|
|
98
|
+
Usage: plot-build-monitor.sh [--once]
|
|
99
|
+
|
|
100
|
+
Started by plot-dispatch.sh inside the worker's wrapper. Reads its subject from
|
|
101
|
+
the environment, exactly as the wrapper's other children do:
|
|
102
|
+
|
|
103
|
+
PLOT_BRANCH the branch whose builds this monitor will report
|
|
104
|
+
PLOT_WORKTREE the desk it reads the head sha from
|
|
105
|
+
PLOT_MONITOR_FILE where findings are published (default:
|
|
106
|
+
$PLOT_WORKTREE/.plot-worker.monitor.build.jsonl)
|
|
107
|
+
PLOT_MONITOR_INTERVAL seconds between passes (default 30)
|
|
108
|
+
|
|
109
|
+
--once take one sample and exit, rather than looping. A test
|
|
110
|
+
affordance: nothing dispatches this mode.
|
|
111
|
+
EOF
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
once=0
|
|
115
|
+
while [ $# -gt 0 ]; do
|
|
116
|
+
case "$1" in
|
|
117
|
+
--once) once=1 ;;
|
|
118
|
+
-h|--help) usage; exit 0 ;;
|
|
119
|
+
*) echo "plot-build-monitor: unknown argument '$1'" >&2; usage; exit 2 ;;
|
|
120
|
+
esac
|
|
121
|
+
shift
|
|
122
|
+
done
|
|
123
|
+
|
|
124
|
+
monitor='BuildMonitor'
|
|
125
|
+
|
|
126
|
+
branch="${PLOT_BRANCH:-}"
|
|
127
|
+
worktree="${PLOT_WORKTREE:-}"
|
|
128
|
+
# THIRTY SECONDS IS AFFORDABLE ONLY BECAUSE OF THE SILENCE RULE. This cadence
|
|
129
|
+
# matches the WorkerMonitor's rather than the AgentMonitor's, and it asks a HOST
|
|
130
|
+
# — which would be the rate problem the AgentMonitor's 300 s exists to avoid,
|
|
131
|
+
# were it asking on every pass. It is not: no live run, no question. The budget
|
|
132
|
+
# is bounded by how long a build takes, not by how long a worker lives.
|
|
133
|
+
interval="${PLOT_MONITOR_INTERVAL:-30}"
|
|
134
|
+
|
|
135
|
+
findings="${PLOT_MONITOR_FILE:-${worktree:+$worktree/.plot-worker.monitor.build.jsonl}}"
|
|
136
|
+
|
|
137
|
+
# THE SUBJECT, read the same way the other two monitors read it.
|
|
138
|
+
pid_file="${PLOT_PID_FILE:-${worktree:+$worktree/.plot-worker.pid}}"
|
|
139
|
+
|
|
140
|
+
# ONE ANSWER TO "IS MY SUBJECT STILL THERE?", shared with both siblings, for the
|
|
141
|
+
# reason `plot-monitor-subject.sh` documents: three monitors deciding
|
|
142
|
+
# independently when to stop would drift, and the failure would be silent.
|
|
143
|
+
# shellcheck source=./plot-monitor-subject.sh
|
|
144
|
+
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-monitor-subject.sh"
|
|
145
|
+
|
|
146
|
+
# THE HOST ADAPTER, sourced for its path rather than its functions: the one
|
|
147
|
+
# operation this monitor asks is `plot-host.sh run-for-sha`, and `plot-host.sh`
|
|
148
|
+
# is the ONE place that talks to the host CLI. A monitor calling `gh` directly
|
|
149
|
+
# would be a second adapter, and the backend split (github/bitbucket) would have
|
|
150
|
+
# to be decided twice.
|
|
151
|
+
host_script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-host.sh"
|
|
152
|
+
|
|
153
|
+
json_escape() { # $1 = raw → prints a JSON-safe string body
|
|
154
|
+
printf '%s' "$1" | python3 -c 'import json,sys; sys.stdout.write(json.dumps(sys.stdin.read())[1:-1])' 2>/dev/null \
|
|
155
|
+
|| printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# A FINDING CARRIES THE SAME FOUR FIELDS the other two monitors publish —
|
|
159
|
+
# `finding`, `since`, `evidence`, `measuredAt` — for the same reason: one
|
|
160
|
+
# subscriber will read all three files and must not need a third parser to do
|
|
161
|
+
# it.
|
|
162
|
+
#
|
|
163
|
+
# `since` HERE IS WHEN THE BUILD REACHED THIS ANSWER, as far as this monitor
|
|
164
|
+
# can tell — the pass that first saw it. On a 30 s cadence the gap to
|
|
165
|
+
# `measuredAt` is small by construction, which is the opposite of the
|
|
166
|
+
# AgentMonitor's case and follows from the same field meaning the same thing.
|
|
167
|
+
publish() { # $1=finding $2=evidence $3=since
|
|
168
|
+
local now
|
|
169
|
+
now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
170
|
+
local line
|
|
171
|
+
line=$(printf '{"monitor":"%s","branch":"%s","worktree":"%s","finding":"%s","since":"%s","evidence":"%s","measuredAt":"%s"}' \
|
|
172
|
+
"$monitor" \
|
|
173
|
+
"$(json_escape "$branch")" \
|
|
174
|
+
"$(json_escape "$worktree")" \
|
|
175
|
+
"$(json_escape "$1")" \
|
|
176
|
+
"${3:-$now}" \
|
|
177
|
+
"$(json_escape "$2")" \
|
|
178
|
+
"$now")
|
|
179
|
+
# Both destinations, for the reason the siblings give: the file is what a
|
|
180
|
+
# subscriber reads, and stdout lands in `.plot-worker.log` beside the agent's
|
|
181
|
+
# own output where an operator tailing a worker sees it.
|
|
182
|
+
[ -n "$findings" ] && printf '%s\n' "$line" >> "$findings" 2>/dev/null
|
|
183
|
+
printf 'plot-monitor %s\n' "$line"
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# THE PORTS — two named seams, so every branch is reachable from a test
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
#
|
|
190
|
+
# Same convention as the four next door and the five beyond it: a test sources
|
|
191
|
+
# this file with `PLOT_MONITOR_NO_MAIN=1` and REDEFINES them. Here there are
|
|
192
|
+
# only two, and one of them is the host round trip — which is exactly the seam a
|
|
193
|
+
# test can least afford to drive for real. You cannot ask GitHub for a run that
|
|
194
|
+
# is `action_required` on demand, you cannot make a run vanish to order, and you
|
|
195
|
+
# certainly cannot arrange two runs for two shas at the instant a test needs
|
|
196
|
+
# them. Every one of those is a branch this monitor must get right.
|
|
197
|
+
|
|
198
|
+
# What sha is this branch's head, right now?
|
|
199
|
+
#
|
|
200
|
+
# → prints the sha, or nothing when it cannot be read
|
|
201
|
+
#
|
|
202
|
+
# LOCAL, AND IT GATES THE HOST CALL. This is the cheap reading that makes the
|
|
203
|
+
# silence rule structural: no head, no question. It reads the WORKTREE's HEAD
|
|
204
|
+
# rather than a remote ref, because the head a build should be about is the one
|
|
205
|
+
# the agent has actually produced — and because a fetch per pass would be a
|
|
206
|
+
# second network call on a 30-second loop.
|
|
207
|
+
monitor_head_sha() { # → prints a sha, or nothing
|
|
208
|
+
[ -n "$worktree" ] && [ -d "$worktree" ] || return 0
|
|
209
|
+
git -C "$worktree" rev-parse --verify --quiet HEAD 2>/dev/null || true
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# What does the host say about the run for ONE sha?
|
|
213
|
+
#
|
|
214
|
+
# → prints the run JSON, or nothing when there is no run for it
|
|
215
|
+
# → returns 2 when the host could not be asked at all
|
|
216
|
+
#
|
|
217
|
+
# THE DISTINCTION BETWEEN "NO RUN" AND "COULD NOT ASK" IS THE POINT, and it is
|
|
218
|
+
# the same discipline `monitor_pr_state` keeps next door. An unreachable host is
|
|
219
|
+
# NOT evidence that a build is absent. A monitor that read a `gh` failure as "no
|
|
220
|
+
# run" would go silent about every build on the estate the moment a token
|
|
221
|
+
# expired — and silence is this monitor's healthy signal, so the failure would
|
|
222
|
+
# be invisible by construction.
|
|
223
|
+
#
|
|
224
|
+
# An EMPTY result with a reachable host is a real and common answer: the run has
|
|
225
|
+
# not been created yet. A monitor polling a fresh push sees exactly this until
|
|
226
|
+
# CI wakes up, and it is not a finding.
|
|
227
|
+
monitor_run_for_sha() { # $1 = sha → prints run JSON | rc 2 = unaskable
|
|
228
|
+
[ -n "$branch" ] || return 2
|
|
229
|
+
[ -x "$host_script" ] || return 2
|
|
230
|
+
local out
|
|
231
|
+
out=$("$host_script" run-for-sha "$branch" "$1" 2>/dev/null) || return 2
|
|
232
|
+
printf '%s' "$out"
|
|
233
|
+
return 0
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# READING ONE FIELD OUT OF THE RUN
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
#
|
|
240
|
+
# `jq` where it exists and `sed` where it does not, the same fallback shape
|
|
241
|
+
# `json_escape` uses above. A monitor that died because `jq` was missing would
|
|
242
|
+
# be a monitor that reported nothing on exactly the machines least likely to
|
|
243
|
+
# have anyone watching.
|
|
244
|
+
run_field() { # $1 = json, $2 = key → prints the value, or nothing for null
|
|
245
|
+
local v
|
|
246
|
+
if command -v jq >/dev/null 2>&1; then
|
|
247
|
+
v=$(printf '%s' "$1" | jq -r --arg k "$2" '.[$k] // empty' 2>/dev/null)
|
|
248
|
+
else
|
|
249
|
+
v=$(printf '%s' "$1" | sed -n 's/.*"'"$2"'":"\([^"]*\)".*/\1/p')
|
|
250
|
+
fi
|
|
251
|
+
printf '%s' "$v"
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# THE SAMPLER — one pass, using only the ports above
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
#
|
|
258
|
+
# THE STATE IS TWO VARIABLES AND IT IS DERIVED, as next door — but `published`
|
|
259
|
+
# is keyed by SHA here, because the findings are transitions rather than
|
|
260
|
+
# conditions. `published_sha` records which commit the standing answer is about,
|
|
261
|
+
# so the same word about a different commit is still news.
|
|
262
|
+
published=''
|
|
263
|
+
published_sha=''
|
|
264
|
+
since=''
|
|
265
|
+
# The shas whose builds have reached a terminal answer. Once a run has failed,
|
|
266
|
+
# passed, or been superseded, asking again spends a host round trip to re-learn
|
|
267
|
+
# a fact already published — so it is not asked. THIS is the second half of "it
|
|
268
|
+
# polls nothing when no run is live": the first half is having no head at all,
|
|
269
|
+
# and this is having no OPEN question about the head there is.
|
|
270
|
+
settled_shas=''
|
|
271
|
+
|
|
272
|
+
sha_is_settled() { # $1 = sha → 0 settled | 1 not
|
|
273
|
+
case " $settled_shas " in *" $1 "*) return 0 ;; esac
|
|
274
|
+
return 1
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# ONE FINDING PER PASS, AND `head moved` COMES FIRST
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
#
|
|
281
|
+
# Unlike the AgentMonitor's four, these are near-exclusive by construction — a
|
|
282
|
+
# run has one status. The one genuine overlap is the one that matters: a run
|
|
283
|
+
# that concluded `success` for a sha the branch has since moved past is BOTH
|
|
284
|
+
# "passed" and "superseded", and reporting it as passed is precisely the failure
|
|
285
|
+
# this monitor exists to prevent.
|
|
286
|
+
#
|
|
287
|
+
# So `head moved` is decided FIRST and about the run's own sha, not about the
|
|
288
|
+
# branch: if the answer in hand describes a commit that is no longer the head,
|
|
289
|
+
# the answer is about the past whatever it says.
|
|
290
|
+
sample_finding() { # → prints "finding\tevidence", or nothing
|
|
291
|
+
local head
|
|
292
|
+
head=$(monitor_head_sha)
|
|
293
|
+
|
|
294
|
+
# NO HEAD, NO QUESTION. The cheap local reading refuses before anything
|
|
295
|
+
# reaches the host — the structural form of "it polls nothing when no run is
|
|
296
|
+
# live". A worktree that is gone, or a branch with no commit yet, asks
|
|
297
|
+
# nothing at all.
|
|
298
|
+
[ -n "$head" ] || return 0
|
|
299
|
+
|
|
300
|
+
# ALREADY ANSWERED. The head's build reached a terminal conclusion on an
|
|
301
|
+
# earlier pass and a build's answer does not change back. Asking again would
|
|
302
|
+
# be the idle polling this design refuses.
|
|
303
|
+
sha_is_settled "$head" && return 0
|
|
304
|
+
|
|
305
|
+
local run rc
|
|
306
|
+
run=$(monitor_run_for_sha "$head"); rc=$?
|
|
307
|
+
|
|
308
|
+
# UNASKABLE — no finding. The host could not be asked, so nothing about the
|
|
309
|
+
# build is known. Reporting anything here would be inventing an answer out of
|
|
310
|
+
# a failure to observe.
|
|
311
|
+
[ "$rc" = 2 ] && return 0
|
|
312
|
+
|
|
313
|
+
# NO RUN YET — no finding, and not an error. The commit exists and CI has not
|
|
314
|
+
# created a run for it. This is the ordinary state of a freshly pushed sha,
|
|
315
|
+
# and it is what the monitor sees on every pass until the run appears.
|
|
316
|
+
[ -n "$run" ] || return 0
|
|
317
|
+
|
|
318
|
+
local run_sha status conclusion url
|
|
319
|
+
run_sha=$(run_field "$run" sha)
|
|
320
|
+
status=$(run_field "$run" status)
|
|
321
|
+
conclusion=$(run_field "$run" conclusion)
|
|
322
|
+
url=$(run_field "$run" url)
|
|
323
|
+
|
|
324
|
+
# 1. HEAD MOVED — the run in hand is about a commit that is no longer the
|
|
325
|
+
# head. Decided BEFORE the conclusion is read, because a green run for
|
|
326
|
+
# superseded code is the specific wrong answer this monitor was built to
|
|
327
|
+
# avoid: it invites a merge of the wrong thing. Measured 2026-08-30 — two
|
|
328
|
+
# merge waiters reported on superseded runs and had to be stopped and
|
|
329
|
+
# re-armed.
|
|
330
|
+
#
|
|
331
|
+
# This fires when the host answered about a DIFFERENT sha than the one asked
|
|
332
|
+
# about, which is the shape a race actually takes: the head moved between the
|
|
333
|
+
# local read and the host's reply.
|
|
334
|
+
if [ -n "$run_sha" ] && [ "$run_sha" != "$head" ]; then
|
|
335
|
+
printf 'head moved\tthe run at %s is for %s, but the branch head is now %s; its answer is about the past\n' \
|
|
336
|
+
"${url:-an unknown url}" "$run_sha" "$head"
|
|
337
|
+
return 0
|
|
338
|
+
fi
|
|
339
|
+
|
|
340
|
+
# 2. BUILD NEEDS APPROVAL — a real state, not an edge case. Bot branches hit
|
|
341
|
+
# it: the release PR's runs need a manual click before they start. GitHub
|
|
342
|
+
# reports it as a `status` of `waiting`/`action_required` and as a
|
|
343
|
+
# `conclusion` of `action_required`, depending on where the run is, so both
|
|
344
|
+
# are read. Folding it into "not passed yet" would report the build pending
|
|
345
|
+
# forever while it waits for a click nobody knows is needed.
|
|
346
|
+
case "$status:$conclusion" in
|
|
347
|
+
*action_required*|waiting:*)
|
|
348
|
+
printf 'build needs approval\tthe run at %s for %s is waiting for a manual approval before it can start\n' \
|
|
349
|
+
"${url:-an unknown url}" "$head"
|
|
350
|
+
return 0
|
|
351
|
+
;;
|
|
352
|
+
esac
|
|
353
|
+
|
|
354
|
+
# 3 & 4. THE TERMINAL CONCLUSIONS. An empty conclusion means the run is still
|
|
355
|
+
# going — queued or in progress — and a monitor whose subject is a transition
|
|
356
|
+
# says nothing about a state that has not changed yet.
|
|
357
|
+
[ -n "$conclusion" ] || return 0
|
|
358
|
+
|
|
359
|
+
case "$conclusion" in
|
|
360
|
+
success)
|
|
361
|
+
printf 'build passed\tthe run at %s for %s concluded success\n' "${url:-an unknown url}" "$head"
|
|
362
|
+
return 0
|
|
363
|
+
;;
|
|
364
|
+
# EVERY OTHER TERMINAL CONCLUSION IS A FAILURE TO A READER WAITING ON GREEN.
|
|
365
|
+
# `failure`, `timed_out`, `cancelled` and `startup_failure` differ in cause
|
|
366
|
+
# and not in consequence: none of them is a build somebody may merge on. The
|
|
367
|
+
# cause is not thrown away — it rides in the evidence, where a reader
|
|
368
|
+
# deciding whether to rerun can see it.
|
|
369
|
+
*)
|
|
370
|
+
printf 'build failed\tthe run at %s for %s concluded %s\n' "${url:-an unknown url}" "$head" "$conclusion"
|
|
371
|
+
return 0
|
|
372
|
+
;;
|
|
373
|
+
esac
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
# One full pass: sample, publish only on a change of ANSWER-ABOUT-A-COMMIT.
|
|
377
|
+
monitor_pass() {
|
|
378
|
+
local row finding evidence head
|
|
379
|
+
head=$(monitor_head_sha)
|
|
380
|
+
row=$(sample_finding)
|
|
381
|
+
finding="${row%% *}"
|
|
382
|
+
evidence=''
|
|
383
|
+
case "$row" in *" "*) evidence="${row#* }" ;; esac
|
|
384
|
+
[ -z "$row" ] && finding=''
|
|
385
|
+
|
|
386
|
+
# PUBLISH ON A CHANGE OF EITHER THE FINDING OR THE COMMIT IT IS ABOUT. The
|
|
387
|
+
# second half is what makes these transitions rather than conditions: `build
|
|
388
|
+
# passed` for a new sha is news even though the word is the same as last
|
|
389
|
+
# time, and suppressing it would silence exactly the answer an operator
|
|
390
|
+
# pushed in order to get.
|
|
391
|
+
if [ "$finding" != "$published" ] || { [ -n "$finding" ] && [ "$head" != "$published_sha" ]; }; then
|
|
392
|
+
if [ -n "$finding" ]; then
|
|
393
|
+
since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
394
|
+
publish "$finding" "$evidence" "$since"
|
|
395
|
+
# SETTLED, so it is never asked about again. Every finding this monitor
|
|
396
|
+
# publishes is terminal for its sha: a failure stays failed, a pass stays
|
|
397
|
+
# passed, and a superseded run does not become current. Recording it here
|
|
398
|
+
# rather than in `sample_finding` keeps the decision beside the publish it
|
|
399
|
+
# follows from.
|
|
400
|
+
[ -n "$head" ] && settled_shas="$settled_shas $head"
|
|
401
|
+
fi
|
|
402
|
+
# NO CLEARING PUBLISH, and that is the difference from the AgentMonitor. A
|
|
403
|
+
# debt is cleared when it is paid — that is news. A build's answer is never
|
|
404
|
+
# withdrawn: `build failed` does not stop being true about that sha, and the
|
|
405
|
+
# next answer is a new finding about a new commit, which the sha key already
|
|
406
|
+
# carries. Publishing `clear` here would tell a subscriber a failure had
|
|
407
|
+
# been resolved when all that happened is the branch moved on.
|
|
408
|
+
published="$finding"
|
|
409
|
+
published_sha="$head"
|
|
410
|
+
fi
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
# SOURCEABLE FOR TESTS, the same guard the siblings carry. A test that wants to
|
|
414
|
+
# drive `monitor_pass` against redefined ports needs the functions without the
|
|
415
|
+
# loop; everything above this line defines, and nothing below it runs when the
|
|
416
|
+
# guard is set.
|
|
417
|
+
[ -n "${PLOT_MONITOR_NO_MAIN:-}" ] && return 0 2>/dev/null
|
|
418
|
+
|
|
419
|
+
monitor_pass
|
|
420
|
+
[ "$once" = 1 ] && exit 0
|
|
421
|
+
|
|
422
|
+
# IT ENDS WITH ITS AGENT, by the mechanism `plot-monitor-subject.sh` documents
|
|
423
|
+
# and for the reason `docs/research/2026-08-30-what-ends-a-monitor.md` measured.
|
|
424
|
+
#
|
|
425
|
+
# PUBLISH FIRST, THEN LEAVE. The final pass runs with the agent already gone,
|
|
426
|
+
# and it matters here for a reason of its own: an agent that pushes and exits
|
|
427
|
+
# leaves a run still going, and the answer arrives after there is nobody left to
|
|
428
|
+
# see it. The last pass is the one chance to catch a build that concluded during
|
|
429
|
+
# the shutdown.
|
|
430
|
+
while plot_monitor_wait "$interval" "$pid_file"; do
|
|
431
|
+
monitor_pass
|
|
432
|
+
done
|
|
433
|
+
|
|
434
|
+
monitor_pass
|
|
435
|
+
exit 0
|
package/plot-config.sh
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
# Known keys (see the plot skill's Setup section):
|
|
27
27
|
# Project board | Branch prefixes | Plan directory | Active index |
|
|
28
28
|
# Delivered index | Sprint directory | Story directory | Story index |
|
|
29
|
-
# Plan template | Main branch | Board command
|
|
29
|
+
# Plan template | Worker prompt template | Main branch | Board command
|
|
30
30
|
# Worktree root where /plot-dispatch puts its worktrees. Read by
|
|
31
31
|
# plot-dispatch.sh; default is the repo's PARENT, which
|
|
32
32
|
# scatters `plot-wt-*` beside the checkout. An absolute
|
|
@@ -80,6 +80,15 @@
|
|
|
80
80
|
# here can invoke a skill. Absent (or `none`) = the button
|
|
81
81
|
# refuses and names this key as the fix, rather than
|
|
82
82
|
# accepting the click and doing nothing.
|
|
83
|
+
# Brief command how /plot-dispatch runs an agent headless to WRITE a
|
|
84
|
+
# missing hand-off brief. The prompt is appended as one
|
|
85
|
+
# argument and asks for `/plot-implement <slug>`, whose
|
|
86
|
+
# step 4 owns brief authorship — this key adds no second
|
|
87
|
+
# brief writer. Absent (or `none`) = the capability is
|
|
88
|
+
# unavailable, never an error: the brief gate refuses as
|
|
89
|
+
# it does today and names `no-brief-command` as the
|
|
90
|
+
# reason, so a project that has never set the key behaves
|
|
91
|
+
# exactly as before.
|
|
83
92
|
# Plot 2 posture keys (repo-declared ceremony bounds; all optional):
|
|
84
93
|
# Plan PRs required | never | optional (never = hard gate)
|
|
85
94
|
# Implementation home this repo | <repo/path list> | none
|
|
@@ -97,6 +106,13 @@
|
|
|
97
106
|
#
|
|
98
107
|
# `Plan template` is a repo-root-relative path to the plan template /plot-idea
|
|
99
108
|
# instantiates; when absent, /plot-idea falls back to the shipped template.
|
|
109
|
+
#
|
|
110
|
+
# `Worker prompt template` is the same shape for the worker prompt
|
|
111
|
+
# plot-install-prompt.sh writes into `.plot/worker-prompt.sh` at adoption: a
|
|
112
|
+
# repo-root-relative path (an absolute one is taken as given), falling back to
|
|
113
|
+
# the shipped `skills/plot/templates/worker-prompt.sh`. It names a STARTING
|
|
114
|
+
# POINT, never the file the loop runs — the loop always sources
|
|
115
|
+
# `.plot/worker-prompt.sh`, and nothing re-reads the template after adoption.
|
|
100
116
|
|
|
101
117
|
set -uo pipefail
|
|
102
118
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Plot helper: the ONE answer to "what is the default branch, and is origin/HEAD
|
|
3
|
+
# still pointing at something that exists?"
|
|
4
|
+
#
|
|
5
|
+
# SOURCED, NOT RUN. `. "$script_dir/plot-default-branch.sh"` defines
|
|
6
|
+
# `default_branch` and `repair_origin_head`; the file does nothing else on load.
|
|
7
|
+
# The same shape — and the same reason — as `plot-pr-merged.sh` and
|
|
8
|
+
# `plot-worker-state.sh`.
|
|
9
|
+
#
|
|
10
|
+
# WHY IT EXISTS. Twice on 2026-09-04, hours apart, `refs/remotes/origin/HEAD`
|
|
11
|
+
# pointed at `origin/plot-corpus-pin`, a branch that does not exist on the
|
|
12
|
+
# remote. `plot-dispatch.sh` could not resolve the default branch and refused
|
|
13
|
+
# every dispatch:
|
|
14
|
+
#
|
|
15
|
+
# plot-dispatch: cannot resolve 'origin/plot-corpus-pin' — refusing to dispatch.
|
|
16
|
+
#
|
|
17
|
+
# `git remote set-head origin --auto` fixed it both times, in under a second.
|
|
18
|
+
#
|
|
19
|
+
# THE FAILURE IS INVISIBLE TO EVERY READER, AND THAT IS THE POINT.
|
|
20
|
+
# `git symbolic-ref --short refs/remotes/origin/HEAD` on a corrupt symref exits
|
|
21
|
+
# **0** and prints `origin/plot-corpus-pin` — a plausible branch name. Ten
|
|
22
|
+
# scripts read it that way, and none of them can tell that answer from a good
|
|
23
|
+
# one. Only the `rev-parse` downstream fails, in a component that has already
|
|
24
|
+
# built `origin/<main>` out of it and can only report that the ref will not
|
|
25
|
+
# resolve. So the check belongs where the symref is READ, not where it is used.
|
|
26
|
+
#
|
|
27
|
+
# IT NAMES WHAT IT REPAIRED. A recurring corruption that is silently fixed is
|
|
28
|
+
# one nobody investigates: the line says what the symref pointed at and what it
|
|
29
|
+
# now points at, so a second occurrence is visible in a log rather than
|
|
30
|
+
# invisible in a working system.
|
|
31
|
+
#
|
|
32
|
+
# IT DOES NOT REPAIR A SYMREF THAT RESOLVES. Only an unresolvable one is broken.
|
|
33
|
+
# A clone whose `origin/HEAD` deliberately names a non-default branch is
|
|
34
|
+
# somebody's choice, and `--auto` would silently overrule it.
|
|
35
|
+
#
|
|
36
|
+
# WHAT LEAVES THE PIN BEHIND IS NOT REPAIRED HERE, and the leave-alone rule is
|
|
37
|
+
# what keeps this safe alongside it. `packages/domain/corpus/refs.corpus.test.ts`
|
|
38
|
+
# repoints `origin/HEAD` at a `plot-corpus-pin` ref it creates in `beforeAll`
|
|
39
|
+
# and restores in `afterAll` — deliberately, so two readings of the estate see
|
|
40
|
+
# one world. While that suite runs the pin RESOLVES, so this repairs nothing and
|
|
41
|
+
# the suite is unaffected. What was measured on 2026-09-04 is the state after a
|
|
42
|
+
# run that never reached its `afterAll`: the symref left behind, the ref it
|
|
43
|
+
# names gone. Fixing that belongs to the suite; this repairs the symptom and
|
|
44
|
+
# reports it loudly enough that the cause stays findable.
|
|
45
|
+
|
|
46
|
+
# Whether `refs/remotes/origin/HEAD` names a ref that exists.
|
|
47
|
+
#
|
|
48
|
+
# Reads the symref itself rather than `default_branch`'s answer, because the
|
|
49
|
+
# fallbacks below would mask exactly the state this tests.
|
|
50
|
+
#
|
|
51
|
+
# Usage: origin_head_resolves [<repo-dir>]
|
|
52
|
+
# Returns: 0 when it resolves — including when there is no symref at all, which
|
|
53
|
+
# is a FRESH CLONE rather than a corruption and has nothing to repair.
|
|
54
|
+
origin_head_resolves() {
|
|
55
|
+
local dir="${1:-.}" target
|
|
56
|
+
target=$(git -C "$dir" symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null) || return 0
|
|
57
|
+
[ -n "$target" ] || return 0
|
|
58
|
+
git -C "$dir" rev-parse --verify --quiet "${target}^{commit}" >/dev/null 2>&1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Repairs an unresolvable `refs/remotes/origin/HEAD`, and says what it did.
|
|
62
|
+
#
|
|
63
|
+
# The repair is `git remote set-head origin --auto`, which asks the remote. It
|
|
64
|
+
# is cheap — measured under a second — and it is the same command that fixed
|
|
65
|
+
# both occurrences by hand.
|
|
66
|
+
#
|
|
67
|
+
# Usage: repair_origin_head [<repo-dir>]
|
|
68
|
+
# Output: one line on stderr naming BOTH refs, when a repair happened.
|
|
69
|
+
# Returns: 0 whether or not it repaired, and 0 when the repair itself fails —
|
|
70
|
+
# this is a self-heal on a path that has its own refusal downstream,
|
|
71
|
+
# so it must never become a second way to stop.
|
|
72
|
+
repair_origin_head() {
|
|
73
|
+
local dir="${1:-.}" was now
|
|
74
|
+
origin_head_resolves "$dir" && return 0
|
|
75
|
+
|
|
76
|
+
was=$(git -C "$dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)
|
|
77
|
+
if ! git -C "$dir" remote set-head origin --auto >/dev/null 2>&1; then
|
|
78
|
+
echo "plot: origin/HEAD points at '$was', which does not resolve, and 'git remote set-head origin --auto' failed — the remote could not be asked." >&2
|
|
79
|
+
return 0
|
|
80
|
+
fi
|
|
81
|
+
now=$(git -C "$dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)
|
|
82
|
+
echo "plot: repaired origin/HEAD — it pointed at '$was', which does not exist on the remote; it now points at '${now:-<unset>}'." >&2
|
|
83
|
+
return 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# The default branch, repairing an unresolvable `origin/HEAD` on the way.
|
|
87
|
+
#
|
|
88
|
+
# The fallbacks are the ones the callers already carried, in the same order: the
|
|
89
|
+
# symref, then `main`. What is new is that the symref is TESTED before it is
|
|
90
|
+
# believed.
|
|
91
|
+
#
|
|
92
|
+
# IT NEVER FALLS BACK TO THE CHECKOUT'S OWN BRANCH, and that is a refusal rather
|
|
93
|
+
# than an omission. `refs-git.ts:138` does exactly that, correctly — it answers
|
|
94
|
+
# a question about THIS checkout. A shell caller is asking which branch everyone
|
|
95
|
+
# shares, and answering with whatever branch this tree happens to sit on is the
|
|
96
|
+
# defect `dispatch.test.mjs` is named for: *"a shared approval is not hidden by
|
|
97
|
+
# a parked checkout"*, measured when a concurrent agent's `git checkout` blocked
|
|
98
|
+
# two correctly-approved plans in one session. `main` is a guess about the
|
|
99
|
+
# repository; the current branch is a guess about the operator's last command.
|
|
100
|
+
#
|
|
101
|
+
# Usage: default_branch [<repo-dir>]
|
|
102
|
+
# Output: the branch name on stdout, without the `origin/` prefix.
|
|
103
|
+
default_branch() {
|
|
104
|
+
local dir="${1:-.}" name
|
|
105
|
+
repair_origin_head "$dir"
|
|
106
|
+
name=$(git -C "$dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
|
|
107
|
+
[ -n "$name" ] || name="main"
|
|
108
|
+
printf '%s\n' "$name"
|
|
109
|
+
}
|