@plot-pm/board 0.10.0 → 0.11.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 +156 -150
- package/package.json +5 -1
- package/plot-agent-monitor.sh +508 -0
- package/plot-approve.sh +145 -29
- package/plot-budget.sh +439 -0
- package/plot-config.sh +9 -0
- package/plot-deliver.sh +210 -117
- package/plot-dispatch.sh +829 -185
- package/plot-fleet-scan.sh +577 -65
- package/plot-host.sh +1329 -55
- package/plot-plan-meta.sh +234 -40
- package/plot-reap.sh +675 -57
- package/plot-transcript-quiet.sh +142 -0
- package/plot-worker-monitor.sh +644 -0
- package/plot-worker-state.sh +81 -50
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Plot helper: the WorkerMonitor — watches the PROCESS a dispatched agent runs on.
|
|
3
|
+
#
|
|
4
|
+
# RUN, NOT SOURCED, and started by `start_worker()` in `plot-dispatch.sh` as a
|
|
5
|
+
# child of the wrapper. It is never invoked by hand in normal operation: a
|
|
6
|
+
# monitor an operator has to remember to start is one that will be missing on
|
|
7
|
+
# the day it matters.
|
|
8
|
+
#
|
|
9
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
10
|
+
# TWO FINDINGS, AND ONLY TWO
|
|
11
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
12
|
+
#
|
|
13
|
+
# gone the agent pid names no live process
|
|
14
|
+
# idle the pid lives, its TRANSCRIPT has been silent past the window with
|
|
15
|
+
# no child process burning CPU behind it, across two consecutive
|
|
16
|
+
# passes, the tree did not change between them, AND commits already
|
|
17
|
+
# exist on the branch
|
|
18
|
+
#
|
|
19
|
+
# Anything else is `silent`, which is not a finding and is not published. The
|
|
20
|
+
# distinction is the whole point: a monitor that reports every quiet moment
|
|
21
|
+
# teaches an operator to ignore it, and then it is worse than absent.
|
|
22
|
+
#
|
|
23
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
24
|
+
# IT READS THE AGENT, NOT THE MACHINE
|
|
25
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
26
|
+
#
|
|
27
|
+
# Until 2026-09-02 the reading was a 0.4 s CPU sample of the agent's subtree,
|
|
28
|
+
# taken twice ~30 s apart. An agent waiting on a model response burns no subtree
|
|
29
|
+
# CPU, so a false zero was the COMMON reading rather than the rare one, and no
|
|
30
|
+
# sampling interval closes that gap: a slow model response is indistinguishable
|
|
31
|
+
# from a dead one by CPU alone. The rule ended eleven dispatched workers across
|
|
32
|
+
# two days, several holding uncommitted work.
|
|
33
|
+
#
|
|
34
|
+
# A `claude -p` session appends a line to its transcript for every model turn,
|
|
35
|
+
# tool call and tool result. Seconds since the newest line reads whether the
|
|
36
|
+
# AGENT has produced anything — the question the CPU sample was standing in for.
|
|
37
|
+
#
|
|
38
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
39
|
+
# TWO READINGS, BECAUSE NEITHER ANSWERS IT ALONE
|
|
40
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
41
|
+
#
|
|
42
|
+
# A TRANSCRIPT IS EQUALLY QUIET IN TWO CASES that must end differently: an agent
|
|
43
|
+
# waiting on a model, and an agent waiting on its own 20-minute test suite.
|
|
44
|
+
# Wave 1 measured 7547 quiet stretches across 23 sessions on 2026-09-02, and 28
|
|
45
|
+
# of the 37 that passed 30 s were the second kind — the four longest being this
|
|
46
|
+
# repo's own gates, `gh pr checks --watch` at 600.8 s and `pnpm run test:board`
|
|
47
|
+
# at 600.3 s.
|
|
48
|
+
#
|
|
49
|
+
# So the window (`PLOT_MONITOR_QUIET_SECONDS`, 900 s) is a GATE and the CPU is
|
|
50
|
+
# the verdict beside it:
|
|
51
|
+
#
|
|
52
|
+
# transcript inside the window → busy (it just wrote something)
|
|
53
|
+
# past the window, a child burning CPU → busy (its build is running)
|
|
54
|
+
# past the window, no child on a core → quiet (it has stopped)
|
|
55
|
+
# no transcript readable → unknown (see the fallback)
|
|
56
|
+
#
|
|
57
|
+
# THE CPU'S ROLE IS INVERTED FROM THE OLD RULE, and that is what makes it sound
|
|
58
|
+
# here. The rejected rule read a FROZEN clock as a stall; this reads a MOVING
|
|
59
|
+
# clock as life. A moving clock proves something is happening; a frozen one
|
|
60
|
+
# proved nothing, which is precisely why it could not be trusted alone.
|
|
61
|
+
#
|
|
62
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
63
|
+
# WHY `idle` STILL CARRIES THE TREE AND COMMIT CONDITIONS
|
|
64
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
65
|
+
#
|
|
66
|
+
# What separated the three stalls measured on 2026-08-30 is that each had
|
|
67
|
+
# already COMMITTED and then gone quiet:
|
|
68
|
+
#
|
|
69
|
+
# quiet, tree unchanged, commits present → idle
|
|
70
|
+
# quiet, tree unchanged, no commits yet → silent (it may be thinking)
|
|
71
|
+
# quiet, tree CHANGED between samples → silent (something is happening)
|
|
72
|
+
#
|
|
73
|
+
# THE MIDDLE ROW IS WHERE THE FALSE POSITIVES WOULD HAVE BEEN. An agent given a
|
|
74
|
+
# hard first slice is quiet for a long time with nothing to show; calling that a
|
|
75
|
+
# stall is the cry-wolf that costs the finding its readers. The extra two
|
|
76
|
+
# conditions are not caution — they are what makes the word mean something.
|
|
77
|
+
#
|
|
78
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
79
|
+
# WHERE NO TRANSCRIPT CAN BE READ, IT SAYS SO
|
|
80
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
81
|
+
#
|
|
82
|
+
# The reading is UNAVAILABLE, not zero and not failed — the contract
|
|
83
|
+
# `the-registry-supervises-its-agents` settled. The monitor then publishes
|
|
84
|
+
# nothing and `Worker bound` is what ends the worker. The cost is stated rather
|
|
85
|
+
# than hidden: a genuinely stuck agent holds a desk for up to 8 hours, which is
|
|
86
|
+
# smaller than the measured cost of killing working ones.
|
|
87
|
+
#
|
|
88
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
89
|
+
# IT IS NOT CALLED `stalled`, AND THAT IS A CONTRACT
|
|
90
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
91
|
+
#
|
|
92
|
+
# The spec owns `stalled` for an AGENT fact — *"exited 0, unlanded work, no
|
|
93
|
+
# PR"* (DESIGN-agent.md). A stalled agent has work to rescue; an idle worker may
|
|
94
|
+
# just be waiting on the network. An earlier draft reused the name and put a
|
|
95
|
+
# process fact on the agent side, which is the exact confusion CLAUDE.md's
|
|
96
|
+
# Machine/Registry split exists to prevent: this monitor watches a PROCESS, so
|
|
97
|
+
# its vocabulary is Worker-side.
|
|
98
|
+
#
|
|
99
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
100
|
+
# TWO SAMPLES, NEVER ONE
|
|
101
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
102
|
+
#
|
|
103
|
+
# A single idle reading is a process caught between syscalls. The COMPARISON is
|
|
104
|
+
# the finding, so the monitor keeps the previous answer — one piece of state,
|
|
105
|
+
# and derived rather than recorded: lose it (restart the monitor, say) and the
|
|
106
|
+
# next pass rebuilds it, at one interval's delay. Nothing is persisted, because
|
|
107
|
+
# nothing needs to be.
|
|
108
|
+
#
|
|
109
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
110
|
+
# IT MAKES NO HOST CALL AT ALL
|
|
111
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
112
|
+
#
|
|
113
|
+
# Not "few" — none. A monitor on a ~30s cadence that asks the host has become an
|
|
114
|
+
# AgentMonitor with a fast loop, and the rate problem follows it: 127 git
|
|
115
|
+
# processes per scan is what that costs in this repo. Every question here is
|
|
116
|
+
# answered by the process table or by a local git ref. `commits present` is
|
|
117
|
+
# counted against the LOCAL `origin/main` ref, never a fetch — and when that ref
|
|
118
|
+
# is missing the question is unanswerable rather than answered zero, so `idle`
|
|
119
|
+
# does not fire. A failure to observe is not evidence of something to see; the
|
|
120
|
+
# same rule `plot_worker_task_state` reached the hard way after a fallback read
|
|
121
|
+
# every clean branch as `stalled` in a repo with no remote.
|
|
122
|
+
#
|
|
123
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
124
|
+
# WHY IT IS THE WRAPPER'S CHILD
|
|
125
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
126
|
+
#
|
|
127
|
+
# `plot-dispatch.sh` does not spawn the agent directly — it spawns an `sh -c`
|
|
128
|
+
# wrapper that backgrounds the agent, records its pid, `wait`s for it and writes
|
|
129
|
+
# `.plot-worker.exit`. That wrapper ALREADY outlives its agent by construction,
|
|
130
|
+
# because otherwise there would be no exit code to record; the comment at
|
|
131
|
+
# `plot-dispatch.sh` states it outright: *"--stop kills the agent, the wrapper
|
|
132
|
+
# survives to record the code."*
|
|
133
|
+
#
|
|
134
|
+
# A monitor that is its child inherits that survival. A SIBLING would not:
|
|
135
|
+
# two processes started side by side are independently mortal, so the monitor
|
|
136
|
+
# could be killed or crash with nothing noticing — which is the failure being
|
|
137
|
+
# fixed, one level up.
|
|
138
|
+
#
|
|
139
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
140
|
+
# IT INHERITS THE STARTUP WINDOW RATHER THAN WIDENING IT
|
|
141
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
142
|
+
#
|
|
143
|
+
# There is a sub-millisecond gap after the wrapper starts and before it writes
|
|
144
|
+
# `.plot-worker.pid`; a scan landing in it reads `none` — honest. This monitor
|
|
145
|
+
# starts inside the same wrapper (the monitors are backgrounded BEFORE the
|
|
146
|
+
# `printf > "$PLOT_PID_FILE"`), so its first pass can genuinely land in that
|
|
147
|
+
# window. An ABSENT pid file therefore means *not yet*, never `gone`: reporting
|
|
148
|
+
# a dead agent because its birth has not been recorded would make the monitor's
|
|
149
|
+
# loudest finding also its least trustworthy.
|
|
150
|
+
#
|
|
151
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
152
|
+
# PUBLISHING, BEFORE THERE IS A CHANNEL
|
|
153
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
154
|
+
#
|
|
155
|
+
# The channel is a local socket under `.plot/`, and it is
|
|
156
|
+
# `feature/the-channel-carries-the-findings`. Until it exists, a finding is
|
|
157
|
+
# published by being APPENDED to a file the fleet already knows how to ignore.
|
|
158
|
+
#
|
|
159
|
+
# The name matters more than it looks. `plot-worker-state.sh` excludes Plot's
|
|
160
|
+
# own records from both the dirty-tree filter and the marker search with ONE
|
|
161
|
+
# pattern — `PLOT_WORKER_RECORD='\.plot-worker\.'` — after those two exclusions
|
|
162
|
+
# had already drifted apart once. A finding file named `.plot-worker.monitor.*`
|
|
163
|
+
# is covered by that pattern for free; anything else would make every monitored
|
|
164
|
+
# worktree read as holding unlanded work, which is `stalled` for a fleet that
|
|
165
|
+
# is perfectly healthy.
|
|
166
|
+
#
|
|
167
|
+
# THAT EXCLUSION IS ALSO WHY THE TREE FINGERPRINT CAN TRUST `git status`. This
|
|
168
|
+
# monitor writes into the worktree it is watching, once per finding — so a
|
|
169
|
+
# fingerprint over raw `git status` would see the monitor's own file appear and
|
|
170
|
+
# read it as the tree changing, and the monitor would suppress `idle` forever on
|
|
171
|
+
# the strength of its own output. `plot_worker_dirty_filter` drops exactly that
|
|
172
|
+
# prefix, which is why the fingerprint goes through it rather than around it.
|
|
173
|
+
set -uo pipefail
|
|
174
|
+
|
|
175
|
+
usage() {
|
|
176
|
+
cat >&2 <<'EOF'
|
|
177
|
+
Usage: plot-worker-monitor.sh [--once]
|
|
178
|
+
|
|
179
|
+
Started by plot-dispatch.sh inside the worker's wrapper. Reads its subject from
|
|
180
|
+
the environment, exactly as the wrapper's other children do:
|
|
181
|
+
|
|
182
|
+
PLOT_BRANCH the branch this worker is on
|
|
183
|
+
PLOT_WORKTREE the desk it sits at
|
|
184
|
+
PLOT_PID_FILE where the wrapper records the AGENT's pid
|
|
185
|
+
PLOT_MONITOR_FILE where findings are published (default:
|
|
186
|
+
$PLOT_WORKTREE/.plot-worker.monitor.worker.jsonl)
|
|
187
|
+
PLOT_MONITOR_INTERVAL seconds between passes (default 30)
|
|
188
|
+
|
|
189
|
+
--once take one sample and exit, rather than looping. A single pass can
|
|
190
|
+
never publish `idle` — that needs two — so this is how a test drives
|
|
191
|
+
the `gone` arm and the "one sample says nothing" property directly.
|
|
192
|
+
EOF
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
once=0
|
|
196
|
+
while [ $# -gt 0 ]; do
|
|
197
|
+
case "$1" in
|
|
198
|
+
--once) once=1 ;;
|
|
199
|
+
-h|--help) usage; exit 0 ;;
|
|
200
|
+
*) echo "plot-worker-monitor: unknown argument '$1'" >&2; usage; exit 2 ;;
|
|
201
|
+
esac
|
|
202
|
+
shift
|
|
203
|
+
done
|
|
204
|
+
|
|
205
|
+
# THE MONITOR'S NAME IS ITS CONTRACT. It travels into every finding, and the
|
|
206
|
+
# board will key on it — a WorkerMonitor `idle` and an AgentMonitor `owes a
|
|
207
|
+
# review` must be distinguishable in the entry itself, which the plan's
|
|
208
|
+
# attention slice requires and which a shared label would make impossible.
|
|
209
|
+
monitor='WorkerMonitor'
|
|
210
|
+
|
|
211
|
+
branch="${PLOT_BRANCH:-}"
|
|
212
|
+
worktree="${PLOT_WORKTREE:-}"
|
|
213
|
+
interval="${PLOT_MONITOR_INTERVAL:-30}"
|
|
214
|
+
pid_file="${PLOT_PID_FILE:-${worktree:+$worktree/.plot-worker.pid}}"
|
|
215
|
+
|
|
216
|
+
# ONE ANSWER TO "IS MY SUBJECT STILL THERE?", shared with the AgentMonitor
|
|
217
|
+
# rather than written twice. `plot-worker-state.sh` carried five of six states
|
|
218
|
+
# in duplicate until the copies drifted on the sixth; two monitors deciding
|
|
219
|
+
# independently when to stop would drift the same way, and half a fix for a leak
|
|
220
|
+
# looks exactly like a fix.
|
|
221
|
+
#
|
|
222
|
+
# Sourced from THIS script's directory, so a monitor started from a worktree's
|
|
223
|
+
# own copy of the scripts finds that copy's helper — which is how every
|
|
224
|
+
# dispatched worker runs.
|
|
225
|
+
# shellcheck source=./plot-monitor-subject.sh
|
|
226
|
+
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-monitor-subject.sh"
|
|
227
|
+
|
|
228
|
+
# THE DEFAULT PATH IS DERIVED, NOT REQUIRED. The wrapper passes
|
|
229
|
+
# PLOT_MONITOR_FILE explicitly (one env var per path, so no quoting level inside
|
|
230
|
+
# the single-quoted `sh -c` can mangle a path with spaces — the convention the
|
|
231
|
+
# exit and pid files already use). The fallback exists so a hand-run monitor in
|
|
232
|
+
# a worktree still writes somewhere sensible rather than refusing.
|
|
233
|
+
findings="${PLOT_MONITOR_FILE:-${worktree:+$worktree/.plot-worker.monitor.worker.jsonl}}"
|
|
234
|
+
|
|
235
|
+
# THE CPU SAMPLER IS BORROWED, NOT REBUILT. `plot_worker_activity` already sums
|
|
236
|
+
# a pid's whole DESCENDANT subtree across a short interval and answers
|
|
237
|
+
# `working`/`idle`/"" — including the awk that parses `[[HH:]MM:]SS.ss` from the
|
|
238
|
+
# right so an hour of CPU does not wrap at 60, and the one-`ps`-snapshot walk
|
|
239
|
+
# that avoids forking a process per descendant. Writing a second sampler beside
|
|
240
|
+
# it would be two implementations of one measurement, drifting; this repo has
|
|
241
|
+
# already paid for that once, in the classification `plot-worker-state.sh` was
|
|
242
|
+
# extracted to hold.
|
|
243
|
+
#
|
|
244
|
+
# SOURCED WITH A GUARD because a monitor whose helper is missing must still say
|
|
245
|
+
# so rather than die silently in a detached shell nobody is reading.
|
|
246
|
+
plot_state_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-worker-state.sh"
|
|
247
|
+
# shellcheck source=plot-worker-state.sh
|
|
248
|
+
if [ -r "$plot_state_lib" ]; then . "$plot_state_lib"; fi
|
|
249
|
+
|
|
250
|
+
# THE TRANSCRIPT READER — the primary reading, sourced beside the CPU sampler
|
|
251
|
+
# rather than replacing it. What each answers is different in kind: the
|
|
252
|
+
# transcript says whether the AGENT has produced anything, the CPU says whether
|
|
253
|
+
# a CHILD is on a core. `idle` now needs both to agree.
|
|
254
|
+
plot_transcript_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-transcript-quiet.sh"
|
|
255
|
+
# shellcheck source=plot-transcript-quiet.sh
|
|
256
|
+
if [ -r "$plot_transcript_lib" ]; then . "$plot_transcript_lib"; fi
|
|
257
|
+
|
|
258
|
+
# HOW LONG A TRANSCRIPT MUST BE QUIET BEFORE THE QUESTION IS EVEN ASKED.
|
|
259
|
+
#
|
|
260
|
+
# 900 s, and the number comes from wave 1's measurement rather than from taste.
|
|
261
|
+
# `plot-quiet-stretch.sh` read 7547 quiet stretches across 23 sessions in 21
|
|
262
|
+
# worktrees on 2026-09-02:
|
|
263
|
+
#
|
|
264
|
+
# p50 0s p90 2.6s p99 15.6s max 600.8s
|
|
265
|
+
#
|
|
266
|
+
# 900 s is 1.5x that maximum and 57x the p99. Every stretch ever measured on
|
|
267
|
+
# this estate clears it with five minutes to spare.
|
|
268
|
+
#
|
|
269
|
+
# THE MAXIMUM IS NOT A DISTRIBUTION'S TAIL — IT IS A CEILING, and that is why
|
|
270
|
+
# the threshold alone is not the answer. The four longest stretches are this
|
|
271
|
+
# repo's own gates: `gh pr checks --watch` at 600.8s, `pnpm run test:board` at
|
|
272
|
+
# 600.3s, `pnpm run test:reconcile` at 584.9s and 575.5s. They cluster at 600
|
|
273
|
+
# because that is where a watch command and a test runner time out, not because
|
|
274
|
+
# an agent's quiet naturally ends there. A project with a slower suite produces
|
|
275
|
+
# a longer one, and any single number picked from this sample would kill its
|
|
276
|
+
# workers on the day it adopted Plot.
|
|
277
|
+
#
|
|
278
|
+
# SO THE THRESHOLD IS A GATE, NOT THE VERDICT. Past it, the monitor still asks
|
|
279
|
+
# whether a child process is on a core — see `sample_verdict`. The threshold
|
|
280
|
+
# says *this has gone on long enough to be worth asking about*; the CPU reading
|
|
281
|
+
# answers *and there is nothing running*. Together they separate the two cases
|
|
282
|
+
# a transcript cannot tell apart on its own, both of which look identically
|
|
283
|
+
# quiet: an agent waiting on its own 20-minute command, and an agent that has
|
|
284
|
+
# stopped.
|
|
285
|
+
: "${PLOT_MONITOR_QUIET_SECONDS:=900}"
|
|
286
|
+
|
|
287
|
+
json_escape() { # $1 = raw → prints a JSON-safe string body
|
|
288
|
+
printf '%s' "$1" | python3 -c 'import json,sys; sys.stdout.write(json.dumps(sys.stdin.read())[1:-1])' 2>/dev/null \
|
|
289
|
+
|| printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
# A FINDING CARRIES FOUR FIELDS: `finding`, `since`, `evidence`, `measuredAt`.
|
|
293
|
+
#
|
|
294
|
+
# `since` AND `measuredAt` ARE DIFFERENT TIMES, and this is the slice where they
|
|
295
|
+
# start to differ. `measuredAt` is when this reading was taken; `since` is when
|
|
296
|
+
# the finding first held. A finding that has held for twenty minutes and one
|
|
297
|
+
# taken twenty minutes ago are not the same fact, and an operator triaging a
|
|
298
|
+
# board needs the first — so `since` is carried forward across republishes and
|
|
299
|
+
# only reset when the finding changes.
|
|
300
|
+
publish() { # $1=finding $2=evidence $3=since
|
|
301
|
+
local now
|
|
302
|
+
now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
303
|
+
local line
|
|
304
|
+
line=$(printf '{"monitor":"%s","branch":"%s","worktree":"%s","finding":"%s","since":"%s","evidence":"%s","measuredAt":"%s"}' \
|
|
305
|
+
"$monitor" \
|
|
306
|
+
"$(json_escape "$branch")" \
|
|
307
|
+
"$(json_escape "$worktree")" \
|
|
308
|
+
"$(json_escape "$1")" \
|
|
309
|
+
"${3:-$now}" \
|
|
310
|
+
"$(json_escape "$2")" \
|
|
311
|
+
"$now")
|
|
312
|
+
# Both destinations, deliberately. The file is what a test and a future
|
|
313
|
+
# subscriber read; stdout lands in `.plot-worker.log` beside the agent's own
|
|
314
|
+
# output, where an operator tailing a worker sees it without knowing a second
|
|
315
|
+
# file exists.
|
|
316
|
+
[ -n "$findings" ] && printf '%s\n' "$line" >> "$findings" 2>/dev/null
|
|
317
|
+
printf 'plot-monitor %s\n' "$line"
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
# THE PORTS — four named seams, so every branch is reachable from a test
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
#
|
|
324
|
+
# Each of these is one question against the machine, and each is a `monitor_*`
|
|
325
|
+
# function for one reason: a test sources this file with `PLOT_MONITOR_NO_MAIN`
|
|
326
|
+
# and REDEFINES them. That is what makes the interesting branches reachable at
|
|
327
|
+
# all — a pid that dies between two samples, a tree that changes between two
|
|
328
|
+
# readings, and a subtree whose CPU is frozen are all states a real machine will
|
|
329
|
+
# not produce on demand, and a test that waits for one is a test that flakes.
|
|
330
|
+
#
|
|
331
|
+
# The seams are the ports; the sampler below is the logic. Nothing between them
|
|
332
|
+
# touches the machine directly.
|
|
333
|
+
|
|
334
|
+
# Does the agent pid name a live process?
|
|
335
|
+
#
|
|
336
|
+
# THREE ANSWERS, NOT TWO. `0` alive, `1` dead, `2` UNKNOWN — and the third is
|
|
337
|
+
# the startup window. The wrapper backgrounds this monitor BEFORE it writes the
|
|
338
|
+
# pid file, so an absent or empty file means the birth has not been recorded
|
|
339
|
+
# yet. Collapsing that into `dead` would make `gone` fire on every worker's
|
|
340
|
+
# first pass, which is the one moment it is guaranteed to be wrong.
|
|
341
|
+
monitor_pid_alive() { # → 0 alive | 1 dead | 2 unknown (not recorded yet)
|
|
342
|
+
local pid
|
|
343
|
+
[ -n "$pid_file" ] && [ -s "$pid_file" ] || return 2
|
|
344
|
+
pid=$(cat "$pid_file" 2>/dev/null | tr -d '[:space:]')
|
|
345
|
+
[ -n "$pid" ] || return 2
|
|
346
|
+
case "$pid" in *[!0-9]*) return 2 ;; esac
|
|
347
|
+
kill -0 "$pid" 2>/dev/null && return 0
|
|
348
|
+
return 1
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
# The agent pid as recorded, or "" when it has not been recorded.
|
|
352
|
+
monitor_pid() {
|
|
353
|
+
[ -n "$pid_file" ] && [ -s "$pid_file" ] || return 0
|
|
354
|
+
cat "$pid_file" 2>/dev/null | tr -d '[:space:]'
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
# Is the agent's subtree burning CPU? `working` | `idle` | "" (nothing to
|
|
358
|
+
# measure). Delegated wholesale to the borrowed sampler.
|
|
359
|
+
monitor_activity() { # $1=pid → working | idle | ""
|
|
360
|
+
command -v plot_worker_activity >/dev/null 2>&1 || return 0
|
|
361
|
+
plot_worker_activity "$1"
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
# How long has the AGENT at this desk produced nothing? The primary reading.
|
|
365
|
+
#
|
|
366
|
+
# `unavailable` where no transcript can be read, and that word travels all the
|
|
367
|
+
# way to the verdict rather than being collapsed into a number. Settled by
|
|
368
|
+
# `the-registry-supervises-its-agents`: a capability the adopting project does
|
|
369
|
+
# not provide is UNAVAILABLE, never failed and never zero. A missing helper
|
|
370
|
+
# answers the same way — a monitor whose reader is absent must say it cannot
|
|
371
|
+
# see, not that it saw nothing happen.
|
|
372
|
+
monitor_transcript_quiet() { # → seconds | unavailable
|
|
373
|
+
command -v plot_transcript_quiet_seconds >/dev/null 2>&1 || { printf 'unavailable'; return 0; }
|
|
374
|
+
plot_transcript_quiet_seconds "$worktree"
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
# A cheap stand-in for "the tree as it is right now", compared between passes.
|
|
378
|
+
#
|
|
379
|
+
# IT GOES THROUGH `plot_worker_dirty_filter`, which is not an optimisation — it
|
|
380
|
+
# is what stops the monitor from watching itself. This script appends to
|
|
381
|
+
# `.plot-worker.monitor.worker.jsonl` INSIDE the worktree it is watching, so a
|
|
382
|
+
# raw `git status` fingerprint would change every time the monitor published and
|
|
383
|
+
# `idle` could never hold for two passes. The filter drops the `.plot-worker.`
|
|
384
|
+
# prefix (and editor leftovers, and tool scratch) for exactly the reasons
|
|
385
|
+
# recorded where it is defined.
|
|
386
|
+
#
|
|
387
|
+
# THE FILTERED FILE LIST, NOT A CONTENT HASH. What is being asked is *did
|
|
388
|
+
# anything happen here*, and an agent at work adds, removes and renames files
|
|
389
|
+
# far more often than it rewrites one in place at byte-identical length. A
|
|
390
|
+
# content hash over a large tree on a 30s loop would also be the one expensive
|
|
391
|
+
# thing in an otherwise cheap monitor.
|
|
392
|
+
monitor_tree_fingerprint() { # → an opaque string; unchanged means unchanged
|
|
393
|
+
[ -n "$worktree" ] && [ -d "$worktree" ] || { printf 'no-tree'; return 0; }
|
|
394
|
+
local status
|
|
395
|
+
status=$(git -C "$worktree" status --porcelain 2>/dev/null)
|
|
396
|
+
local head
|
|
397
|
+
head=$(git -C "$worktree" rev-parse HEAD 2>/dev/null || printf 'no-head')
|
|
398
|
+
# HEAD is part of the fingerprint too: an agent that COMMITS between two
|
|
399
|
+
# passes has plainly done something, and its status output may well be
|
|
400
|
+
# identical either side of the commit.
|
|
401
|
+
if command -v plot_worker_dirty_filter >/dev/null 2>&1; then
|
|
402
|
+
printf '%s\n%s' "$head" "$(plot_worker_dirty_filter "$status")"
|
|
403
|
+
else
|
|
404
|
+
printf '%s\n%s' "$head" "$status"
|
|
405
|
+
fi
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
# Are there commits on this branch yet?
|
|
409
|
+
#
|
|
410
|
+
# THE THIRD CONDITION ON `idle`, and the one that separates a stall from an
|
|
411
|
+
# agent still thinking about a hard first slice.
|
|
412
|
+
#
|
|
413
|
+
# COUNTED AGAINST THE LOCAL `origin/<default>` REF — never a fetch, because this
|
|
414
|
+
# monitor makes no network call. And when there is no such ref the question is
|
|
415
|
+
# UNANSWERABLE, so this returns 2 and `idle` does not fire: counting against
|
|
416
|
+
# nothing would count the whole history from the root commit and read every
|
|
417
|
+
# branch in a remote-less repo as having committed, which is the failure
|
|
418
|
+
# `plot_worker_task_state` records having made in the other direction.
|
|
419
|
+
monitor_has_commits() { # → 0 yes | 1 no | 2 unanswerable
|
|
420
|
+
[ -n "$worktree" ] && [ -d "$worktree" ] || return 2
|
|
421
|
+
local base n
|
|
422
|
+
base=$(git -C "$worktree" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
|
|
423
|
+
[ -n "$base" ] || { git -C "$worktree" rev-parse --verify --quiet origin/main >/dev/null 2>&1 && base='origin/main'; }
|
|
424
|
+
[ -n "$base" ] || return 2
|
|
425
|
+
# COUNT THE AGENT'S WORK, NOT THE BRANCH'S COMMITS. `plot-dispatch.sh:2074`
|
|
426
|
+
# writes `commit --allow-empty -m "plot: claim <branch>"` BEFORE the agent
|
|
427
|
+
# starts, so `$base..HEAD` is never zero on a dispatched branch and this
|
|
428
|
+
# condition could never refuse an `idle`. Measured 2026-08-30 (#538 red in CI):
|
|
429
|
+
# a worker burning CPU in `yes > /dev/null` was reported idle, because the one
|
|
430
|
+
# condition that could have saved it was satisfied by bookkeeping the agent did
|
|
431
|
+
# not do.
|
|
432
|
+
#
|
|
433
|
+
# The `-- .` pathspec is what does it: `rev-list` with a pathspec keeps only
|
|
434
|
+
# commits that TOUCHED A FILE, and the claim is empty by construction
|
|
435
|
+
# (`--allow-empty`). That is a property rather than a message match — a claim
|
|
436
|
+
# whose wording changes still reads as empty, and an agent committing an empty
|
|
437
|
+
# marker of its own is correctly not counted as work either.
|
|
438
|
+
n=$(git -C "$worktree" rev-list --count "$base..HEAD" -- . 2>/dev/null) || return 2
|
|
439
|
+
case "$n" in ''|*[!0-9]*) return 2 ;; esac
|
|
440
|
+
[ "$n" -gt 0 ] && return 0
|
|
441
|
+
return 1
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
# ---------------------------------------------------------------------------
|
|
445
|
+
# THE SAMPLER — one pass, using only the ports above
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
#
|
|
448
|
+
# THE STATE IS TWO VARIABLES AND IT IS DERIVED. `prev_verdict` is the previous
|
|
449
|
+
# pass's answer and `prev_tree` its fingerprint; `since` is when the CURRENT
|
|
450
|
+
# published finding first held. Nothing is written down: kill the monitor and
|
|
451
|
+
# the next one rebuilds all three, one interval late. That is the plan's "one
|
|
452
|
+
# piece of state, derived rather than recorded", and it is why a monitor
|
|
453
|
+
# restart costs an interval rather than a wrong answer.
|
|
454
|
+
prev_verdict=''
|
|
455
|
+
prev_tree=''
|
|
456
|
+
published=''
|
|
457
|
+
since=''
|
|
458
|
+
|
|
459
|
+
# What this pass sees, before the two-sample rule is applied.
|
|
460
|
+
#
|
|
461
|
+
# THE ORDER IS LOAD-BEARING. `gone` is asked FIRST because a dead pid makes
|
|
462
|
+
# every other question meaningless — you cannot measure the CPU of a subtree
|
|
463
|
+
# that is not there, and `plot_worker_activity` would answer "" for it anyway,
|
|
464
|
+
# which is indistinguishable from a live pid with no children.
|
|
465
|
+
sample_verdict() { # → gone | quiet | busy | unknown
|
|
466
|
+
local alive
|
|
467
|
+
monitor_pid_alive; alive=$?
|
|
468
|
+
[ "$alive" = 1 ] && { printf 'gone'; return; }
|
|
469
|
+
# `unknown` is the startup window: the wrapper has not recorded the pid yet.
|
|
470
|
+
# Not a finding, and NOT `gone`.
|
|
471
|
+
[ "$alive" = 2 ] && { printf 'unknown'; return; }
|
|
472
|
+
|
|
473
|
+
# THE TRANSCRIPT IS ASKED FIRST, and it is asked instead of the CPU rather
|
|
474
|
+
# than beside it. Until 2026-09-02 this read `plot_worker_activity` alone and
|
|
475
|
+
# called a frozen 0.4 s CPU sample `quiet`; that rule ended eleven dispatched
|
|
476
|
+
# workers across two days, several holding uncommitted work. An agent waiting
|
|
477
|
+
# on a model response burns no subtree CPU, so a false zero was the COMMON
|
|
478
|
+
# reading rather than the rare one, and no sampling interval closes that gap.
|
|
479
|
+
#
|
|
480
|
+
# A `claude -p` session appends to its transcript for every turn, tool call
|
|
481
|
+
# and tool result. Seconds since the newest line is a direct reading of
|
|
482
|
+
# whether the AGENT has done anything — which is the question the monitor was
|
|
483
|
+
# always trying to ask.
|
|
484
|
+
local quiet
|
|
485
|
+
quiet=$(monitor_transcript_quiet)
|
|
486
|
+
|
|
487
|
+
# UNAVAILABLE IS NOT A FINDING, and this is where the plan's fallback lands.
|
|
488
|
+
# Where no transcript can be read there is no reading that distinguishes
|
|
489
|
+
# thinking from stuck, so the monitor invents none: it reports `unknown`,
|
|
490
|
+
# publishes nothing, and `Worker bound` is what ends the worker. The cost is
|
|
491
|
+
# stated rather than hidden — a genuinely stuck agent then holds a desk for up
|
|
492
|
+
# to 8 hours, which is smaller than the measured cost of the rule this
|
|
493
|
+
# replaces.
|
|
494
|
+
case "$quiet" in
|
|
495
|
+
''|unavailable) printf 'unknown'; return ;;
|
|
496
|
+
*[!0-9]*) printf 'unknown'; return ;;
|
|
497
|
+
esac
|
|
498
|
+
|
|
499
|
+
# Inside the window, the agent has produced output recently. Nothing else
|
|
500
|
+
# needs asking: no CPU sample can overturn a line written seconds ago.
|
|
501
|
+
if [ "$quiet" -lt "$PLOT_MONITOR_QUIET_SECONDS" ]; then printf 'busy'; return; fi
|
|
502
|
+
|
|
503
|
+
# PAST THE WINDOW, THE SECOND READING DECIDES — and it answers a question the
|
|
504
|
+
# transcript cannot. A transcript is equally quiet whether the agent is
|
|
505
|
+
# waiting on a model or waiting on its own 20-minute test suite. 28 of the 37
|
|
506
|
+
# over-window stretches wave 1 measured were the latter.
|
|
507
|
+
#
|
|
508
|
+
# So the CPU is consulted for what it CAN say: `working` means a child is on a
|
|
509
|
+
# core, and an agent whose build is running has not stopped. That is not the
|
|
510
|
+
# rejected rule returning — the rejected rule read `idle` as a stall, and this
|
|
511
|
+
# reads `working` as life. The asymmetry is the point: a moving clock proves
|
|
512
|
+
# something is happening, while a frozen one proved nothing, which is exactly
|
|
513
|
+
# why it could not be trusted alone.
|
|
514
|
+
case "$(monitor_activity "$(monitor_pid)")" in
|
|
515
|
+
working) printf 'busy' ;;
|
|
516
|
+
# `idle` (frozen subtree clock) and "" (no child holding a clock at all)
|
|
517
|
+
# agree here: fifteen minutes of transcript silence with nothing burning CPU
|
|
518
|
+
# behind it. Unlike the old rule, "" is not refused — a live pid with no
|
|
519
|
+
# child is precisely an agent that has stopped, and it only reaches this
|
|
520
|
+
# line after the window has already elapsed.
|
|
521
|
+
*) printf 'quiet' ;;
|
|
522
|
+
esac
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
# One full pass: sample, apply the two-sample rule, publish only on a change.
|
|
526
|
+
monitor_pass() {
|
|
527
|
+
local verdict tree evidence finding
|
|
528
|
+
tree=$(monitor_tree_fingerprint)
|
|
529
|
+
verdict=$(sample_verdict)
|
|
530
|
+
|
|
531
|
+
finding=''
|
|
532
|
+
evidence=''
|
|
533
|
+
case "$verdict" in
|
|
534
|
+
gone)
|
|
535
|
+
# ONE SAMPLE IS ENOUGH FOR `gone`, and only for `gone`. A dead pid is not
|
|
536
|
+
# a transient reading the way a frozen CPU clock is — a process does not
|
|
537
|
+
# come back. Requiring two passes here would delay the one finding that is
|
|
538
|
+
# already certain by a whole interval, for no gain in confidence.
|
|
539
|
+
finding='gone'
|
|
540
|
+
evidence="the agent pid $(monitor_pid) names no live process; the worker's desk is unattended"
|
|
541
|
+
;;
|
|
542
|
+
quiet)
|
|
543
|
+
# THE TWO-SAMPLE RULE, and the two extra conditions with it. All four must
|
|
544
|
+
# hold together: this pass quiet, the PREVIOUS pass quiet, the tree
|
|
545
|
+
# unchanged between them, and commits already on the branch.
|
|
546
|
+
if [ "$prev_verdict" = 'quiet' ] && [ "$tree" = "$prev_tree" ]; then
|
|
547
|
+
local has rc2
|
|
548
|
+
monitor_has_commits; rc2=$?
|
|
549
|
+
if [ "$rc2" = 0 ]; then
|
|
550
|
+
finding='idle'
|
|
551
|
+
evidence="the agent pid $(monitor_pid) is alive but its transcript has been silent for over ${PLOT_MONITOR_QUIET_SECONDS}s with no child process burning CPU behind it, across two consecutive passes ~${interval}s apart, the tree is unchanged between them, and the branch already carries commits"
|
|
552
|
+
fi
|
|
553
|
+
# rc2 = 1 → no commits yet: the middle row. It may be thinking, and
|
|
554
|
+
# calling that a stall is what teaches an operator to ignore the word.
|
|
555
|
+
# rc2 = 2 → unanswerable: no ref to count against, so no finding. A
|
|
556
|
+
# failure to observe is not evidence of something to see.
|
|
557
|
+
fi
|
|
558
|
+
;;
|
|
559
|
+
# `busy` and `unknown` are not findings. Nothing is published, which is the
|
|
560
|
+
# design: silence means healthy, and the AgentMonitor's slower loop is what
|
|
561
|
+
# catches a worker that finished without saying so.
|
|
562
|
+
esac
|
|
563
|
+
|
|
564
|
+
prev_verdict="$verdict"
|
|
565
|
+
prev_tree="$tree"
|
|
566
|
+
|
|
567
|
+
# PUBLISH ONLY ON A CHANGE — the plan's "it publishes the moment a finding
|
|
568
|
+
# holds and publishes nothing when nothing changed". A monitor that
|
|
569
|
+
# re-published `idle` every 30 seconds would fill the findings file with one
|
|
570
|
+
# fact repeated, and a subscriber could not tell a NEW stall from an old one.
|
|
571
|
+
#
|
|
572
|
+
# The clearing case is a publish too: a finding that held and then stopped
|
|
573
|
+
# holding is news, and a board that never hears it leaves a stale entry up
|
|
574
|
+
# after the worker recovered.
|
|
575
|
+
if [ "$finding" != "$published" ]; then
|
|
576
|
+
if [ -n "$finding" ]; then
|
|
577
|
+
since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
578
|
+
publish "$finding" "$evidence" "$since"
|
|
579
|
+
elif [ -n "$published" ]; then
|
|
580
|
+
since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
581
|
+
publish 'clear' "the ${published} finding no longer holds; the worker is measuring healthy again" "$since"
|
|
582
|
+
fi
|
|
583
|
+
published="$finding"
|
|
584
|
+
fi
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
# SOURCEABLE FOR TESTS. A test that wants to drive `monitor_pass` against
|
|
588
|
+
# redefined ports needs the functions without the loop; everything above this
|
|
589
|
+
# line defines, and nothing below it runs when the guard is set.
|
|
590
|
+
[ -n "${PLOT_MONITOR_NO_MAIN:-}" ] && return 0 2>/dev/null
|
|
591
|
+
|
|
592
|
+
monitor_pass
|
|
593
|
+
[ "$once" = 1 ] && exit 0
|
|
594
|
+
|
|
595
|
+
# THE LOOP IS WHERE THE COMPARISON LIVES. `idle` needs two readings, so a
|
|
596
|
+
# monitor that ran once and exited could never report it — which is why `--once`
|
|
597
|
+
# is a test affordance and not a mode anyone dispatches.
|
|
598
|
+
#
|
|
599
|
+
# SILENCE IS MEANINGFUL HERE, and it is the opposite of what the no-op slice
|
|
600
|
+
# needed. That monitor published every pass so that an attached-but-blind
|
|
601
|
+
# monitor could not be mistaken for a watching one; this one publishes only on a
|
|
602
|
+
# change, because it HAS something to say and saying it repeatedly would bury
|
|
603
|
+
# it. Telling a healthy silence from a dead monitor is the channel's job —
|
|
604
|
+
# `feature/the-channel-carries-the-findings`, whose heartbeat is exactly that
|
|
605
|
+
# distinction.
|
|
606
|
+
#
|
|
607
|
+
# AND IT ENDS WITH ITS AGENT. Until 2026-08-30 it did not, and the estate showed
|
|
608
|
+
# it: 34 of 40 monitors on this machine were `ppid=1`, and the orphans cost half
|
|
609
|
+
# the machine's spawn cost (23.3 ms per 100 forks against 4.8 ms quiet). The
|
|
610
|
+
# wrapper `wait`s on the agent alone — correctly, since waiting on two infinite
|
|
611
|
+
# loops would hang and `.plot-worker.exit` would never be written — so when the
|
|
612
|
+
# wrapper exits, its monitors are re-parented to `init` and loop forever.
|
|
613
|
+
# `docs/research/2026-08-30-what-ends-a-monitor.md` has the measurement and the
|
|
614
|
+
# commands that show it, on both the ordinary path and the `Worker bound` one.
|
|
615
|
+
#
|
|
616
|
+
# PUBLISH FIRST, THEN ASK — the order is the lower bound, and this monitor is
|
|
617
|
+
# exactly where it matters. `gone` is one of its two findings, so a monitor that
|
|
618
|
+
# checked the subject BEFORE its pass would exit on a dead agent without ever
|
|
619
|
+
# reporting the death — the loudest finding it has, lost to the mechanism meant
|
|
620
|
+
# to bound it. `plot_monitor_wait` returns only after `monitor_pass` has run.
|
|
621
|
+
#
|
|
622
|
+
# IT IS A MEASUREMENT, NOT A TIMER, which the plan requires in as many words: a
|
|
623
|
+
# monitor exiting after N seconds regardless would pass every visible assertion
|
|
624
|
+
# and destroy the property the design rests on. This reads the process table —
|
|
625
|
+
# the same source the `gone` finding above reads, asked for a different purpose.
|
|
626
|
+
while plot_monitor_wait "$interval" "$pid_file"; do
|
|
627
|
+
monitor_pass
|
|
628
|
+
done
|
|
629
|
+
|
|
630
|
+
# THE FINAL PASS, and for this monitor it is not a courtesy — it is the `gone`
|
|
631
|
+
# finding itself.
|
|
632
|
+
#
|
|
633
|
+
# `plot_monitor_wait` returns non-zero the moment the agent's pid names no live
|
|
634
|
+
# process, so control arrives here with the subject already dead and NOTHING yet
|
|
635
|
+
# published about it. One more pass runs, `monitor_pass` measures the same dead
|
|
636
|
+
# pid the wait just saw, and `gone` is published on the way out.
|
|
637
|
+
#
|
|
638
|
+
# Without this line the monitor would exit silently on exactly the event it
|
|
639
|
+
# exists to report — the upper bound eating the finding rather than the lower
|
|
640
|
+
# bound. It would still pass "no monitor remains", which is why the suite
|
|
641
|
+
# asserts the last finding's `measuredAt` against the exit file rather than
|
|
642
|
+
# asserting the exit alone.
|
|
643
|
+
monitor_pass
|
|
644
|
+
exit 0
|