@plot-pm/board 0.8.1 → 0.9.1

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.
@@ -0,0 +1,725 @@
1
+ #!/usr/bin/env bash
2
+ # Plot helper: the ONE answer to "is a worker running in this worktree?"
3
+ #
4
+ # SOURCED, NOT RUN. `. "$script_dir/plot-worker-state.sh"` defines
5
+ # `plot_worker_state`; the file does nothing else on load, which is what makes
6
+ # sourcing it safe and is why this logic could not simply live in
7
+ # plot-dispatch.sh. That file PARSES `$@` and `exit 1`s on a missing slug at
8
+ # load time, so sourcing it from the scan would run the dispatcher's argument
9
+ # parser against the scan's arguments.
10
+ #
11
+ # WHY A THIRD FILE AND NOT A SUBPROCESS. Every other cross-script call in the
12
+ # fleet shells out (`"$script_dir/plot-config.sh" get …`), and that idiom is
13
+ # deliberate elsewhere. It is wrong here: the scan asks this question once per
14
+ # branch inside a loop, and the answer is three fields that the caller then
15
+ # formats two different ways. Shelling out would fork per branch to serialize
16
+ # three values across a pipe so the caller could immediately parse them back —
17
+ # and re-parsing a packed string is the shape this merge exists to remove.
18
+ #
19
+ # THE CALLERS WANT DIFFERENT RENDERINGS OF ONE COMPUTATION.
20
+ # `plot-dispatch.sh --status` prints prose for a person (`failed 1234 (exit 3)`)
21
+ # and `plot-fleet-scan.sh --json` emits tab-separated fields for a machine
22
+ # (`failed\t1234\t3`). Both are real interfaces with tests pinning their bytes,
23
+ # so this function returns the FACTS — state, pid, exit code — and renders
24
+ # nothing. Each caller formats what it owns.
25
+ #
26
+ # Six PROCESS states: running, finished, failed, ended, none, elsewhere.
27
+ # `elsewhere` is answered by the caller BEFORE this function is reached: it
28
+ # means "this machine has no worktree to look in", which is a question about the
29
+ # worktree list rather than about anything inside a worktree. plot-dispatch
30
+ # iterates worktrees it found on disk and so can never produce it.
31
+ #
32
+ # TWO TASK STATES, added 2026-08-18: `waiting` and `stalled`. They answer a
33
+ # different question from the six above, and that is the whole defect they fix.
34
+ #
35
+ # THE EXIT CODE CANNOT ANSWER "IS THE TASK DONE?". Measured across seven
36
+ # worktrees during a four-agent fleet run: EVERY worker exited 0 — the one that
37
+ # opened its PR and reported cleanly, the one that stopped because it would not
38
+ # claim a test run it had not seen, and the one that stopped to ask which retry
39
+ # semantics were wanted. All three landed on `finished`, whose documented
40
+ # meaning is *review it*. Two of the three needed an answer, not a review.
41
+ #
42
+ # So `finished` is refined by the TREE, which is where the difference lives:
43
+ #
44
+ # process alive running leave it alone
45
+ # an open or merged PR finished the work reached review
46
+ # a blocked marker in the tree waiting a person owes it an answer
47
+ # uncommitted or unpushed work stalled work on the floor, no PR
48
+ # otherwise finished nothing left behind
49
+ #
50
+ # `failed`, `ended`, and `none` are NOT refined. A recorded non-zero exit, an
51
+ # unreadable record, and an absent record are each already a specific answer
52
+ # about the process, and none of them is the `finished`-means-everything blur
53
+ # this refinement exists to split.
54
+
55
+ # THE BLOCKED MARKER IS A FILE, not a string any file may contain.
56
+ #
57
+ # Plot instructs a blocked worker to WRITE a file — the `Worker command` in the
58
+ # adopting repo's CLAUDE.md says *"write PLOT-BLOCKED: followed by the question
59
+ # into a file"*. So `plot_worker_blocked` looks for the file, by name.
60
+ #
61
+ # A CONTENTS GREP WAS THE ORIGINAL, and it never worked: it matched the marker
62
+ # token `PLOT-BLOCKED:` (and `TODO(you|human)`) over file CONTENTS, and 28
63
+ # tracked files on `main` contain that token — CLAUDE.md and every brief that
64
+ # documents the feature among them — because a marker that must be documented
65
+ # appears in its own documentation. A token cannot be both the thing you search
66
+ # for and the thing you write about when the search is over everything, so every
67
+ # pristine worktree read `waiting` before any worker ran. A filename cannot be
68
+ # mentioned into existence by prose: a doc may describe the marker file all it
69
+ # likes without becoming one.
70
+ #
71
+ # `TODO(you|human)` IS DROPPED rather than ported. It was kept as an emergent
72
+ # spelling because trees held it, but it is a code-comment convention and
73
+ # matching it over contents is the same defect with a smaller blast radius. A
74
+ # worker signalling from inside a file writes the marker file too.
75
+
76
+ # Plot's OWN records inside a worktree — `.plot-worker.pid`,
77
+ # `.plot-worker.wrapper.pid`, `.plot-worker.exit`, `.plot-worker.log`, and
78
+ # anything else the fleet drops under that prefix (a rotated `.plot-worker.log.1`,
79
+ # say). `.plot-worker.pid` names the AGENT; `.plot-worker.wrapper.pid` names the
80
+ # shell that records its exit — two pids with two names, because one pid with the
81
+ # wrong meaning is the panel bug this prefix now covers a second file to fix.
82
+ #
83
+ # ONE PATTERN, USED BY BOTH EXCLUSIONS BELOW, because they had already drifted
84
+ # apart inside this one file: the marker search excluded the whole prefix while
85
+ # the dirty filter named exactly three files, so a rotated log was skipped by
86
+ # one and counted as work by the other. Two answers about one file, which is the
87
+ # shape this entire plan exists to remove — reproduced here at small scale
88
+ # within an hour of removing it at large scale.
89
+ PLOT_WORKER_RECORD='\.plot-worker\.'
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # THE REGISTRY HOLDS THE PID — the anchor moved from worktree to manifest
93
+ # ---------------------------------------------------------------------------
94
+ #
95
+ # As of 2026-08-24, the pid is read from the session's manifest in
96
+ # `.plot/agents/<session>.json` rather than from `$wt/.plot-worker.pid`. The
97
+ # manifest holds the same fact, better: it carries `session`, `branch`,
98
+ # `worktree` and `pid` in ONE record, and it includes `startedAt` — the launch
99
+ # time that lets us tell a reused pid from the real worker.
100
+ #
101
+ # WHY THIS MATTERS. A pid can be reused by the operating system. In the worktree
102
+ # design the window is small: the file dies with the worktree. In the registry
103
+ # design a manifest can sit for weeks. `startedAt` closes it: a pid whose
104
+ # process began before the manifest's `startedAt` is not that worker, whatever
105
+ # its number. Without it, dead pids are one `fork()` away from reading `running`.
106
+ #
107
+ # THE WORKTREE→MANIFEST LOOKUP. The manifest directory lives at
108
+ # `$PLOT_MANIFEST_DIR` when the caller sets it, or it is derived from the
109
+ # worktree's repo root. Each manifest names a `worktree` field; the lookup
110
+ # finds the manifest whose worktree matches.
111
+
112
+ # The manifest directory, set by callers who know their repo root. When unset,
113
+ # `plot_manifest_for_worktree` derives it from the worktree's own repo.
114
+ : "${PLOT_MANIFEST_DIR:=}"
115
+
116
+ # Find the manifest for a worktree → the full path, or "" (non-zero).
117
+ #
118
+ # Iterates `.plot/agents/*.json` and matches on the `worktree` field. The
119
+ # dispatcher records the RESOLVED worktree path (`realpath`), so the match is
120
+ # tried against both the path as given and its realpath.
121
+ plot_manifest_for_worktree() { # $1=worktree → manifest path, or "" (non-zero)
122
+ local wt="$1" dir real f wt_field
123
+ [ -n "$wt" ] || return 1
124
+
125
+ # Determine the manifest directory.
126
+ if [ -n "$PLOT_MANIFEST_DIR" ]; then
127
+ dir="$PLOT_MANIFEST_DIR"
128
+ else
129
+ # Derive from the worktree's repo. A worktree IS a git working tree, so
130
+ # `git rev-parse --show-toplevel` from inside it returns the MAIN repo —
131
+ # which is where `.plot/agents/` lives.
132
+ dir=$(git -C "$wt" rev-parse --show-toplevel 2>/dev/null)/.plot/agents
133
+ fi
134
+ [ -d "$dir" ] || return 1
135
+
136
+ # Resolve the worktree's realpath for matching.
137
+ real=$(cd "$wt" 2>/dev/null && pwd -P) || real=""
138
+
139
+ # Iterate manifests and match on the worktree field.
140
+ for f in "$dir"/*.json; do
141
+ [ -f "$f" ] || continue
142
+ # Extract the `worktree` field. The manifest is pretty-printed, one field
143
+ # per line, so a grep-and-sed approach avoids parsing JSON in bash.
144
+ wt_field=$(grep -m1 '"worktree":' "$f" 2>/dev/null | sed 's/.*"worktree": *"\([^"]*\)".*/\1/')
145
+ [ -n "$wt_field" ] || continue
146
+ if [ "$wt_field" = "$wt" ] || [ "$wt_field" = "$real" ]; then
147
+ printf '%s' "$f"
148
+ return 0
149
+ fi
150
+ done
151
+ return 1
152
+ }
153
+
154
+ # Read pid and startedAt from a manifest → "pid\tstartedAt", or "" (non-zero).
155
+ #
156
+ # Both fields are extracted; if either is missing the result is empty. A manifest
157
+ # with no pid (an older format or a placeholder) returns nothing, which falls
158
+ # through to the worktree's `.plot-worker.pid` for backward compatibility.
159
+ plot_read_manifest_pid() { # $1=manifest path → "pid\tstartedAt", or "" (non-zero)
160
+ local manifest="$1" pid started
161
+ [ -f "$manifest" ] || return 1
162
+
163
+ # Extract fields. The manifest is pretty-printed, one per line.
164
+ pid=$(grep -m1 '"pid":' "$manifest" 2>/dev/null | sed 's/.*"pid": *"\([^"]*\)".*/\1/')
165
+ started=$(grep -m1 '"startedAt":' "$manifest" 2>/dev/null | sed 's/.*"startedAt": *"\([^"]*\)".*/\1/')
166
+
167
+ [ -n "$pid" ] && [ -n "$started" ] || return 1
168
+ printf '%s\t%s' "$pid" "$started"
169
+ }
170
+
171
+ # Validate a pid against the manifest's startedAt → 0 if valid, non-zero if stale.
172
+ #
173
+ # A pid is stale when the process that holds it started BEFORE the manifest's
174
+ # `startedAt`. The operating system reuses pids, so a recorded pid that now
175
+ # belongs to an older, unrelated process must not read as `running`.
176
+ #
177
+ # THE CHECK IS ON PROCESS START TIME, not existence. `kill -0` only says the pid
178
+ # exists; this says whether it is the SAME process the dispatcher started.
179
+ #
180
+ # Returns non-zero (stale) on any failure — an unparseable time, a process that
181
+ # cannot be inspected, or a platform without `ps -o lstart`. The honest answer
182
+ # for an uncheckable pid is "unknown", which the caller turns into `ended`.
183
+ plot_pid_is_current() { # $1=pid $2=startedAt (ISO-8601) → 0 if current, 1 if stale
184
+ local pid="$1" started="$2" proc_start manifest_epoch proc_epoch
185
+
186
+ # Convert the manifest's startedAt (ISO-8601) to epoch seconds.
187
+ # `date -j -f` is macOS; `date -d` is GNU. Try both.
188
+ if manifest_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$started" +%s 2>/dev/null); then
189
+ :
190
+ elif manifest_epoch=$(date -d "$started" +%s 2>/dev/null); then
191
+ :
192
+ else
193
+ return 1 # Cannot parse; treat as stale to be safe.
194
+ fi
195
+
196
+ # Get the process's start time from `ps -o lstart=`. This is portable across
197
+ # macOS and Linux, though the format differs.
198
+ proc_start=$(ps -o lstart= -p "$pid" 2>/dev/null | tr -d '\n')
199
+ [ -n "$proc_start" ] || return 1 # Process does not exist.
200
+
201
+ # Convert the process start time to epoch seconds.
202
+ # macOS format: "Mon Aug 24 08:31:25 2026"
203
+ # Linux format: varies; `date -d` handles it.
204
+ if proc_epoch=$(date -j -f "%a %b %d %H:%M:%S %Y" "$proc_start" +%s 2>/dev/null); then
205
+ :
206
+ elif proc_epoch=$(date -j -f "%c" "$proc_start" +%s 2>/dev/null); then
207
+ :
208
+ elif proc_epoch=$(date -d "$proc_start" +%s 2>/dev/null); then
209
+ :
210
+ else
211
+ return 1 # Cannot parse; treat as stale.
212
+ fi
213
+
214
+ # A process that started BEFORE the manifest's startedAt is a reused pid.
215
+ # A process that started AT or AFTER is the real worker. We allow 2 seconds
216
+ # of slack for clock skew and rounding — the wrapper writes the pid and then
217
+ # the manifest's awk stamps it; if the second ticks over in between, the
218
+ # process appears to have started 1 second before the manifest says.
219
+ local slack=2
220
+ [ "$proc_epoch" -ge "$((manifest_epoch - slack))" ]
221
+ }
222
+
223
+ # What an editor drops beside real work — `.tmp1`, `.swp`, `.orig`, `.rej`,
224
+ # `.bak`. Measured 2026-08-18: an orphaned `plot-dispatch.sh.tmp1` belonging to
225
+ # no commit and no task read as uncommitted work and got a healthy branch
226
+ # restarted.
227
+ #
228
+ # A NAMED CONSTANT FOR THE SAME REASON `PLOT_WORKER_RECORD` IS ONE. This list
229
+ # was inline in `plot_worker_dirty` while it had one caller. It has two as of
230
+ # the change that measures when a branch last CHANGED — which must not let a
231
+ # `.tmp1` reset its clock, the same file for the same reason — and two inline
232
+ # copies of one list is precisely the drift the constant above was extracted to
233
+ # stop, recorded four lines from here.
234
+ PLOT_EDITOR_LEFTOVER='\.(tmp[0-9]*|swp|orig|rej|bak)$'
235
+
236
+ # WHAT A TOOL LEAVES BEHIND, which is not work either — and the second list for
237
+ # the same reason the first exists.
238
+ #
239
+ # `PLOT_EDITOR_LEFTOVER` names files an editor drops beside real work. These
240
+ # are whole DIRECTORIES a tool creates and nobody commits: a browser driver's
241
+ # scratch, an agent runner's state. Measured on the project directory —
242
+ # the one checkout worked in continuously, and therefore the one that
243
+ # accumulates them — `.playwright-mcp/` and `.plot/agents/` were its only
244
+ # untracked entries, and they made the row read `local_dirty` for hours with
245
+ # nothing being written.
246
+ #
247
+ # Excluded HERE rather than by dropping untracked files wholesale, which is
248
+ # what the first cut did: `test/reconcile/fleet.test.mjs` refuses that in as
249
+ # many words — *an untracked source file IS work and must reset the clock* — and
250
+ # it is right. A new `new-module.ts` is the most interesting thing a worktree
251
+ # can hold; what it is not is a directory nobody will ever commit.
252
+ PLOT_TOOL_SCRATCH='(^|/)\.(playwright-mcp|plot/agents|plot/state|omc/state)(/|$)'
253
+
254
+ # Where the worker's log lives in this worktree, when one is there at all.
255
+ #
256
+ # THIS FILE OWNS THE RECORD'S FILENAMES, and that ownership is enforced rather
257
+ # than merely intended: `workerstate.test.mjs` asserts that plot-fleet-scan.sh
258
+ # never names `.plot-worker.` itself, because a read-only scan that touches the
259
+ # worker record has started classifying workers again — the duplication removed
260
+ # on 2026-08-18, after the two copies had already drifted.
261
+ #
262
+ # So the caller that needs the log's mtime asks for the PATH and reads the time
263
+ # itself. The split is the same one this whole file draws: what Plot's records
264
+ # are called is knowledge that lives here; what a timestamp MEANS is the
265
+ # caller's question. `changed_ago_of` in plot-fleet-scan.sh is the one consumer
266
+ # — the log is the only source that keeps moving while a build runs, so a
267
+ # measurement of "when did anything last change" that could not see it would
268
+ # report every worker mid-suite as maximally quiet.
269
+ #
270
+ # ABSENT IS ABSENT: no worktree, or no log in it, prints nothing and returns
271
+ # non-zero. plot-dispatch writes the log only where it started the worker
272
+ # itself, so a hand-started worker legitimately has none.
273
+ plot_worker_log() { # $1=worktree → path to the worker log, or "" (non-zero)
274
+ local wt="$1"
275
+ [ -n "$wt" ] || return 1
276
+ [ -e "$wt/.plot-worker.log" ] || return 1
277
+ printf '%s' "$wt/.plot-worker.log"
278
+ }
279
+
280
+ # Is a person being waited on inside this worktree?
281
+ #
282
+ # A MARKER FILE IN THE TREE, NOT A STRING IN A FILE. A blocked worker writes a
283
+ # `PLOT-BLOCKED*` file; a doc that mentions the marker does not become one. This
284
+ # is the whole fix: the contents grep this replaced matched 28 documenting files
285
+ # on `main`, so every pristine worktree read `waiting` before any worker ran.
286
+ #
287
+ # READ FROM THE TREE, NOT THE LOG, and the file is still the right place to look
288
+ # for the same reason the grep was. The log records that a question WAS asked;
289
+ # only the tree records that it is still UNANSWERED, and only the tree clears
290
+ # when the answering worker deletes the file. Measured: a restarted worker found
291
+ # its own question already answered in the commit above it and carried on
292
+ # without asking again — the log still held the question, and always will. The
293
+ # marker file being its OWN name rather than a line inside `.plot-worker.log`
294
+ # keeps that distinction automatically: the log is never a `PLOT-BLOCKED*` file.
295
+ #
296
+ # AT THE WORKTREE ROOT, not at any depth. Every observed marker sits at the
297
+ # root, and root is the stricter answer — a worker that means to signal writes
298
+ # where it is told to. The glob is anchored to `"$wt"/` and matches no deeper.
299
+ #
300
+ # A `for`/`-e` LOOP, NOT `ls "$wt"/PLOT-BLOCKED* >/dev/null`. An unmatched glob
301
+ # is shell-dependent, and this file is SOURCED. Under bash — the shell both
302
+ # callers (`plot-dispatch.sh`, `plot-fleet-scan.sh`) declare — an unmatched glob
303
+ # expands to the literal pattern, which the `-e` test then finds absent, so the
304
+ # empty case returns 1 cleanly. That is the case this loop is written for and it
305
+ # is verified on the real call path (a bash script sourcing this file).
306
+ #
307
+ # UNDER zsh THE ANSWER IS STILL CORRECT BUT REACHED THE UGLY WAY: zsh's default
308
+ # `nomatch` makes an unmatched glob a fatal error, so a zsh user who sources
309
+ # this directly gets the right verdict (non-zero) with a `no matches found` line
310
+ # on stderr, from the error rather than from `return 1`. Neither caller is zsh,
311
+ # so this does not bite in production; it is recorded here rather than papered
312
+ # over, because the honest state is "correct under the callers' shell, noisy
313
+ # under a shell no caller uses" — not "identical under both".
314
+ plot_worker_blocked() { # $1=worktree → 0 when a person owes this branch an answer
315
+ local wt="$1" f
316
+ [ -n "$wt" ] && [ -d "$wt" ] || return 1
317
+ for f in "$wt"/PLOT-BLOCKED*; do
318
+ [ -e "$f" ] && return 0
319
+ done
320
+ return 1
321
+ }
322
+
323
+ # WHICH file carries the question — the basename, for a caller that must name it.
324
+ #
325
+ # HERE, BESIDE THE GLOB, and not in the caller. A refusal that says only "this
326
+ # branch is blocked" sends its reader hunting, so `--restart` names the file;
327
+ # but re-globbing `PLOT-BLOCKED*` there would put the marker's spelling in two
328
+ # places, which is the drift the structural test in `workerstate.test.mjs`
329
+ # pins against. The classification and the name of the thing classified stay
330
+ # together: one glob, asked two ways.
331
+ #
332
+ # Prints nothing and returns 1 when no marker exists, so a caller can use the
333
+ # output directly or fall back.
334
+ plot_worker_blocked_file() { # $1=worktree → prints the marker's basename
335
+ local wt="$1" f
336
+ [ -n "$wt" ] && [ -d "$wt" ] || return 1
337
+ for f in "$wt"/PLOT-BLOCKED*; do
338
+ [ -e "$f" ] && { printf '%s' "${f##*/}"; return 0; }
339
+ done
340
+ return 1
341
+ }
342
+
343
+ # How much uncommitted work is on the floor, and in which files.
344
+ #
345
+ # EDITOR LEFTOVERS ARE NOT WORK. Measured 2026-08-18: a guard restarted a branch
346
+ # because an orphaned `plot-dispatch.sh.tmp1` — 10 KB belonging to no commit and
347
+ # no task — read as uncommitted work. The worker was making progress and had
348
+ # just committed.
349
+ #
350
+ # NOR IS PLOT'S OWN BOOKKEEPING. `.plot-worker.pid`, `.plot-worker.exit` and
351
+ # `.plot-worker.log` are files THIS FLEET writes into the worktree, and they are
352
+ # untracked, so every stopped worker's own record counted as work left on the
353
+ # floor. Measured here while testing: a worktree with nothing in it but a clean
354
+ # exit record read `stalled` — which is EVERY worker that finished tidily, the
355
+ # exact population this state must not name. Excluding them is not widening the
356
+ # rule; it is the `.tmp1` case again, for files Plot itself dropped there.
357
+ #
358
+ # THE EXCLUSION STAYS NARROW OTHERWISE, by suffix and by Plot's own filenames.
359
+ # An uncommitted source file is precisely the case this detection exists for, so
360
+ # anything broader — "untracked files do not count", "only tracked changes
361
+ # count" — would delete the signal to remove the noise. Tracked or not, a `.ts`
362
+ # on the floor is work.
363
+ plot_worker_dirty() { # $1=worktree → the dirty files, one per line, leftovers dropped
364
+ local wt="$1"
365
+ [ -n "$wt" ] && [ -d "$wt" ] || return 0
366
+ plot_worker_dirty_filter "$(git -C "$wt" status --porcelain 2>/dev/null)"
367
+ }
368
+
369
+ # The same filter, over status output the CALLER already has.
370
+ #
371
+ # SPLIT OUT BECAUSE THE STATUS CALL IS THE EXPENSIVE HALF and one caller had
372
+ # already paid it. `plot-fleet-scan.sh` runs `git -C <wt> status --porcelain`
373
+ # once per worktree when it builds its worktree table; asking `plot_worker_dirty`
374
+ # for the file list then ran a SECOND status on the same worktree. Caught by
375
+ # `fleet.test.mjs` — "a locked worktree must be asked ONCE" counts the calls,
376
+ # because a scan the board polls every 5 s cannot afford to ask git the same
377
+ # question twice, and a timing assertion could not tell the difference.
378
+ #
379
+ # The FILTER is the part worth sharing; the fetching is not. One definition of
380
+ # what counts as work on the floor, two ways of getting the input to it — which
381
+ # is the same one-computation-two-renderings split this file already draws for
382
+ # `plot_worker_state`.
383
+ plot_worker_dirty_filter() { # $1=`git status --porcelain` output → the real work
384
+ # `--porcelain` is the STABLE format; `git status` prose is localised and
385
+ # reflows. Cut at column 4: the first three bytes are the XY status pair and a
386
+ # space, and a filename can contain spaces of its own.
387
+ printf '%s' "$1" \
388
+ | cut -c4- \
389
+ | grep -vE "(^|/)$PLOT_WORKER_RECORD" \
390
+ | grep -vE "$PLOT_EDITOR_LEFTOVER" \
391
+ | grep -vE "$PLOT_TOOL_SCRATCH" || true
392
+ }
393
+
394
+ # The total CPU time, in centiseconds, of a pid and every process descended from
395
+ # it. Prints the number; prints `0` and returns non-zero when the pid names no
396
+ # live process at all.
397
+ #
398
+ # THE CHILD IS WHERE THE WORK IS, NOT THE SHELL. The pid this fleet records is
399
+ # the loop shell — `plot-worker-loop.sh` — and a shell that `wait`s on its child
400
+ # burns almost no CPU of its own. Measured across the fleet 2026-08-25: 9 of 11
401
+ # loop shells sat at 0.01s CPU over hours while their `claude` child held 1.5+
402
+ # minutes. So the shell's own CPU distinguishes nothing; the DESCENDANT tree is
403
+ # the only place a working worker differs from a dead one. This sums the whole
404
+ # subtree — the loop may fork `claude`, which forks its own tools — so a worker
405
+ # building in a grandchild reads as busy, not idle.
406
+ #
407
+ # ONE `ps` SNAPSHOT, WALKED IN awk. `ps -o pid=,ppid=,time= -ax` is the one
408
+ # portable call that carries the parent link and the CPU clock together (macOS
409
+ # and Linux both). We read it ONCE and walk the ppid graph in memory rather than
410
+ # recursing with a `ps` per node: the alternative forks a process per descendant
411
+ # on a scan the board polls every 5s.
412
+ #
413
+ # `time=` IS `[[HH:]MM:]SS.ss`. Parsed field by field from the right so a worker
414
+ # past an hour of CPU still totals correctly — an absolute-seconds assumption
415
+ # would wrap at 60 and read a busy worker as newly idle.
416
+ plot_worker_cpu_centis() { # $1=pid → total CPU centiseconds of pid+descendants
417
+ local root="$1"
418
+ [ -n "$root" ] || { printf '0'; return 1; }
419
+ case "$root" in *[!0-9]*) printf '0'; return 1 ;; esac
420
+
421
+ # Snapshot the whole process table once. Each line: "<pid> <ppid> <time>".
422
+ # `time` may itself contain a space in no format we read, so pid and ppid are
423
+ # fields 1 and 2 and everything after is the clock.
424
+ ps -o pid=,ppid=,time= -ax 2>/dev/null | awk -v root="$root" '
425
+ # Convert a "[[HH:]MM:]SS.ss" clock to integer centiseconds.
426
+ function to_centis(t, n, parts, i, mult, total, sec, frac) {
427
+ n = split(t, parts, ":")
428
+ # The last field is SS.ss; earlier fields are whole minutes/hours.
429
+ total = 0; mult = 1
430
+ for (i = n; i >= 1; i--) {
431
+ if (i == n) {
432
+ # seconds, possibly fractional
433
+ if (split(parts[i], sf, ".") == 2) { sec = sf[1]; frac = sf[2] }
434
+ else { sec = parts[i]; frac = 0 }
435
+ # Normalise fraction to hundredths (ps prints two digits).
436
+ frac = (frac "00"); frac = substr(frac, 1, 2)
437
+ total += (sec * 100) + (frac + 0)
438
+ } else {
439
+ total += parts[i] * 60 * 100 * mult
440
+ }
441
+ if (i < n) mult *= 60
442
+ }
443
+ return total
444
+ }
445
+ { pid[$1] = $1; ppid[$1] = $2; clk[$1] = $3 }
446
+ END {
447
+ if (!(root in pid)) { print 0; exit 1 }
448
+ # Collect the subtree rooted at `root` by repeated relaxation over the
449
+ # ppid map — a table this size settles in a couple of passes, and there is
450
+ # no deep recursion in a worker tree to make that costly.
451
+ inset[root] = 1
452
+ changed = 1
453
+ while (changed) {
454
+ changed = 0
455
+ for (p in ppid) {
456
+ if (!(p in inset) && (ppid[p] in inset)) { inset[p] = 1; changed = 1 }
457
+ }
458
+ }
459
+ total = 0; any = 0
460
+ for (p in inset) { if (p in clk) { total += to_centis(clk[p]); any = 1 } }
461
+ print total
462
+ exit (any ? 0 : 1)
463
+ }'
464
+ }
465
+
466
+ # Whether a RUNNING worker's child is doing work — `working`, `idle`, or "".
467
+ #
468
+ # A CUE, NOT A STATE. The row already reads `running`; this is the secondary
469
+ # word beside it that says WHICH kind of running. `running` is honest and
470
+ # coarse — measured across the fleet 2026-08-25 it covered a worker mid-thought,
471
+ # a worker between waves, and a worker whose child had crashed hours earlier,
472
+ # and 11 of 13 workers were in the worst of those. This tells the first from the
473
+ # last WITHOUT adding a sixth state: `AgentStateSchema` stays five, and an idle
474
+ # worker with a live child still IS running.
475
+ #
476
+ # THE SIGNAL IS CPU GROWTH OVER AN INTERVAL, never an absolute. A worker deep in
477
+ # a long build and a worker whose child died both show a large accumulated CPU
478
+ # number; only the DELTA separates them — the live one's clock keeps advancing,
479
+ # the dead one's is frozen. So this samples the subtree's total CPU twice across
480
+ # a short sleep and compares.
481
+ #
482
+ # "" WHEN THERE IS NOTHING TO MEASURE. A pid with no descendants that hold a CPU
483
+ # clock (a bare shell, or a worker whose whole tree has already gone) yields no
484
+ # cue rather than a false `idle`: the absence of a child is not the presence of
485
+ # an idle one, and this cue is only ever read beside a `running` verdict, where
486
+ # a live pid is already established. Item 7 of the plan: a worker with no live
487
+ # child is `stalled`/`unknown` by the existing rules, untouched here.
488
+ #
489
+ # THE SAMPLE INTERVAL is short by default so the scan is not held up, and
490
+ # overridable via `PLOT_ACTIVITY_INTERVAL` so a test can prove both arms without
491
+ # waiting. A child doing real work moves its CPU clock within a fraction of a
492
+ # second; the default is generous enough to clear scheduler jitter.
493
+ : "${PLOT_ACTIVITY_INTERVAL:=0.4}"
494
+ plot_worker_activity() { # $1=pid → working | idle | "" (empty = nothing to measure)
495
+ local pid="$1" first second
496
+ [ -n "$pid" ] || return 0
497
+ case "$pid" in *[!0-9]*) return 0 ;; esac
498
+
499
+ # First sample of the whole subtree. If the pid names no process with a CPU
500
+ # clock, there is nothing to say — emit "".
501
+ first=$(plot_worker_cpu_centis "$pid") || return 0
502
+ sleep "$PLOT_ACTIVITY_INTERVAL"
503
+ second=$(plot_worker_cpu_centis "$pid") || return 0
504
+
505
+ # A subtree that burned any CPU across the interval is working; one whose clock
506
+ # did not move is idle. `>` on integer centiseconds — equal means frozen.
507
+ if [ "$second" -gt "$first" ] 2>/dev/null; then
508
+ printf 'working'
509
+ else
510
+ printf 'idle'
511
+ fi
512
+ }
513
+
514
+ # Refine a clean exit into finished / waiting / stalled.
515
+ #
516
+ # THE ORDER IS LOAD-BEARING, and each step earns its place from a measured
517
+ # mistake rather than from tidiness:
518
+ #
519
+ # AN OPEN OR MERGED PR OUTRANKS EVERYTHING BELOW IT. Work that reached review
520
+ # has left the worker's hands, so leftover local edits mean nothing there — a
521
+ # scratch file beside a merged PR is not unfinished work.
522
+ #
523
+ # `waiting` OUTRANKS `stalled`, because a marker is the worker saying *your
524
+ # turn*, and a worker asking a question has almost always left the work it was
525
+ # doing uncommitted beside the question. Checking dirtiness first would report
526
+ # every such branch `stalled`. Measured: a guard restarted one branch TWICE
527
+ # while its worker waited on an answer, and the second restart re-ran work the
528
+ # first had finished. That is a loop, not a rescue.
529
+ #
530
+ # UNCOMMITTED **OR** UNPUSHED. Committing clears dirtiness, so a worker that
531
+ # tidied up and stopped before pushing would otherwise read `finished` with
532
+ # nobody able to see its commits. Both are "work only this machine holds".
533
+ plot_worker_task_state() { # $1=worktree $2=pr-fact → finished|waiting|stalled
534
+ local wt="$1" has_pr="$2"
535
+ [ "$has_pr" = "pr" ] && { printf 'finished'; return; }
536
+ plot_worker_blocked "$wt" && { printf 'waiting'; return; }
537
+ [ -n "$(plot_worker_dirty "$wt")" ] && { printf 'stalled'; return; }
538
+ # UNPUSHED IS A REF QUESTION, asked THROUGH the worktree because that is the
539
+ # checkout whose HEAD is the branch.
540
+ #
541
+ # ONLY `@{upstream}` ANSWERS IT, and when there is no upstream the question is
542
+ # UNANSWERABLE rather than answered zero — or answered anything else. This
543
+ # went in the wrong direction first and was measured doing it: a fallback that
544
+ # counted against `origin/main` reported EVERY clean branch `stalled` in a
545
+ # repo with no remote, because `rev-list --count "..HEAD"` with an empty left
546
+ # side counts the whole history from the root commit. Nine commits of ordinary
547
+ # history read as nine commits of unpushed work.
548
+ #
549
+ # The fallback was also wrong where it worked. A branch legitimately ahead of
550
+ # `origin/main` is the NORMAL state of every branch under review — it is what
551
+ # having commits means — so counting against the trunk marks finished work
552
+ # `stalled` for as long as it exists. Only the branch's OWN upstream separates
553
+ # "pushed" from "not pushed"; the trunk answers a different question entirely.
554
+ #
555
+ # So an absent upstream yields no verdict here and falls through to
556
+ # `finished`, which is the answer the branch gave before this state existed.
557
+ # A failure to observe is not evidence of something to see — the same
558
+ # principle `local_ahead_of` states in plot-fleet-scan.sh, reached the hard
559
+ # way.
560
+ local ahead
561
+ if ahead=$(git -C "$wt" rev-list --count '@{upstream}..HEAD' 2>/dev/null); then
562
+ case "$ahead" in
563
+ ''|0|*[!0-9]*) ;;
564
+ *) printf 'stalled'; return ;;
565
+ esac
566
+ fi
567
+ printf 'finished'
568
+ }
569
+
570
+ # Classify the worker in a worktree.
571
+ #
572
+ # $1 = worktree path
573
+ # $2 = the branch's PR fact, from the CALLER: `pr` when an open or merged PR
574
+ # exists, anything else (including empty) when it does not.
575
+ #
576
+ # WHY THE PR FACT IS A PARAMETER AND NOT A LOOKUP HERE. This function is called
577
+ # once per branch inside the scan's loop, and `plot-fleet-scan.sh --offline`
578
+ # PROMISES no network. A host call in here would either break that promise or
579
+ # fork a `gh` per branch on every 5-second board poll. The callers already know
580
+ # the answer by their own routes and on their own terms — the scan caches one
581
+ # host reply per branch per run behind its `--offline` gate; plot-dispatch
582
+ # `--status` reads worktrees off disk and never touches the host at all.
583
+ #
584
+ # So the fact TRAVELS AS A VALUE, exactly as `elsewhere` does: a question about
585
+ # something outside the worktree, answered before this function is reached.
586
+ # Omitting it is safe and honest — a caller that cannot know says nothing, and a
587
+ # branch with work on the floor then reads `stalled`, which is the answer for a
588
+ # reader who must go look. It is never upgraded to `finished` by a guess.
589
+ #
590
+ # THE PID IS READ FROM THE MANIFEST, not from `$wt/.plot-worker.pid`. The
591
+ # manifest carries `pid` and `startedAt` together, and `startedAt` is what lets
592
+ # us tell a reused pid from the real worker. A pid whose process started before
593
+ # the manifest's `startedAt` is stale — the operating system has reused it —
594
+ # and is NOT reported as running even if `kill -0` succeeds.
595
+ #
596
+ # The worktree pid file is kept as a FALLBACK for two cases:
597
+ # 1. A hand-started worker with no manifest (the `Worker command` was never
598
+ # run through dispatch, so no manifest exists).
599
+ # 2. An older manifest with no `startedAt` — before this change, the manifest
600
+ # carried no launch time. The worktree file is the only record, and the
601
+ # staleness check cannot run, so the old behaviour applies.
602
+ #
603
+ # Prints "state\tpid\tcode" — pid and code empty where they do not apply.
604
+ # Never fails; an unreadable worktree is `none`, which is the honest answer.
605
+ plot_worker_state() { # $1=worktree $2=pr-fact → "state\tpid\tcode"
606
+ local wt="$1" has_pr="${2:-}" pid="" code="" started_at="" manifest_data="" manifest=""
607
+
608
+ # -------------------------------------------------------------------------
609
+ # Read the pid — first from the manifest, then from the worktree file.
610
+ # -------------------------------------------------------------------------
611
+ #
612
+ # THE MANIFEST IS PRIMARY. It carries both `pid` and `startedAt`, so a pid
613
+ # read here can be validated against the process's actual start time.
614
+ if manifest=$(plot_manifest_for_worktree "$wt" 2>/dev/null) && [ -n "$manifest" ]; then
615
+ if manifest_data=$(plot_read_manifest_pid "$manifest") && [ -n "$manifest_data" ]; then
616
+ pid=$(printf '%s' "$manifest_data" | cut -f1)
617
+ started_at=$(printf '%s' "$manifest_data" | cut -f2)
618
+ fi
619
+ fi
620
+
621
+ # FALLBACK to the worktree pid file when the manifest has no usable pid.
622
+ # `started_at` stays empty; the staleness check is skipped, and the old
623
+ # behaviour applies — `kill -0` is trusted.
624
+ if [ -z "$pid" ]; then
625
+ [ -n "$wt" ] && [ -f "$wt/.plot-worker.pid" ] || { printf 'none\t\t'; return; }
626
+ pid=$(cat "$wt/.plot-worker.pid" 2>/dev/null | tr -d ' \n')
627
+ [ -n "$pid" ] || { printf 'none\t\t'; return; }
628
+ fi
629
+
630
+ # -------------------------------------------------------------------------
631
+ # Validate the pid.
632
+ # -------------------------------------------------------------------------
633
+ #
634
+ # `kill -0 0` signals the whole process GROUP and succeeds, so pid 0 would
635
+ # read as running forever. It is never a real worker pid. Non-numeric junk is
636
+ # rejected with it: `kill -0` would error on it anyway, and "running" is the
637
+ # one reading a garbled pid must never produce.
638
+ case "$pid" in 0|*[!0-9]*) printf 'none\t\t'; return ;; esac
639
+
640
+ # -------------------------------------------------------------------------
641
+ # Liveness check, WITH STALENESS DETECTION.
642
+ # -------------------------------------------------------------------------
643
+ #
644
+ # `kill -0` says the pid exists. But pids are reused: the kernel assigns them
645
+ # from a circular pool, and a manifest that sat for days may name a pid now
646
+ # held by an unrelated process. `startedAt` closes the window: a pid is real
647
+ # only if the process holding it started at or after the time the manifest
648
+ # was stamped.
649
+ #
650
+ # Without `startedAt`, the old behaviour applies: `kill -0` alone decides.
651
+ # This keeps the fallback honest — an uncheckable pid is reported as running
652
+ # when it answers `kill -0`, exactly as before.
653
+ if kill -0 "$pid" 2>/dev/null; then
654
+ if [ -n "$started_at" ]; then
655
+ # STALENESS CHECK: is the process the one we started?
656
+ if ! plot_pid_is_current "$pid" "$started_at"; then
657
+ # The pid exists but belongs to an older process — a REUSE. The worker
658
+ # is dead; treat this as `ended` (no exit file can be trusted either).
659
+ printf 'ended\t%s\t' "$pid"
660
+ return
661
+ fi
662
+ fi
663
+ # The process is running AND current (or uncheckable, with no startedAt).
664
+ printf 'running\t%s\t' "$pid"
665
+ return
666
+ fi
667
+
668
+ # -------------------------------------------------------------------------
669
+ # The process is gone. What exit code did it leave?
670
+ # -------------------------------------------------------------------------
671
+ #
672
+ # `kill -0` only separates running from not-running. Whether a stopped worker
673
+ # finished its job or crashed is gone unless the exit code was recorded — and
674
+ # reporting a completed worker as "dead" reads as a crash, which is how a
675
+ # healthy fleet looks broken. The wrapper in start_worker writes the code.
676
+ if [ -f "$wt/.plot-worker.exit" ]; then
677
+ code=$(cat "$wt/.plot-worker.exit" 2>/dev/null | tr -d ' \n')
678
+ case "$code" in
679
+ # EXIT 0 IS THE BLURRED ONE, so it is the only arm refined. The other
680
+ # codes each already say something specific about the process; this one
681
+ # says only "the process ended tidily", which every worker did.
682
+ 0) printf '%s\t%s\t0' "$(plot_worker_task_state "$wt" "$has_pr")" "$pid"; return ;;
683
+ # READ THE EXIT CODE, NOT THE EMPTINESS. An exit file that exists but says
684
+ # nothing usable is `ended`, never `finished`: guessing success from an
685
+ # unreadable record is the same mistake in the other direction, and
686
+ # `finished` is the one answer that tells a reader to stop looking.
687
+ #
688
+ # A NON-NUMERIC CODE IS `ended` HERE, AND THAT RESOLVES A REAL
689
+ # DISAGREEMENT. Before this merge the two copies split on it: the scan
690
+ # answered `ended`, plot-dispatch answered `failed (exit abc)`. Both
691
+ # cannot be kept, so the scan's wins on its own stated principle — an
692
+ # unreadable record licenses no verdict, and "failed with code abc" is as
693
+ # much an invention as "finished" would be. The scan's suite already
694
+ # pinned `ended`; plot-dispatch's pinned only 0, 3, and an absent file, so
695
+ # nothing that was asserted before is asserted differently now.
696
+ ''|*[!0-9]*) printf 'ended\t%s\t' "$pid"; return ;;
697
+ # A PR OUTRANKS A NON-ZERO EXIT — about the TASK, never about the process.
698
+ #
699
+ # The exit code answers "how did the process end?"; the row renders
700
+ # "someone is on it", which is a claim about the WORK. Those come apart
701
+ # exactly when a worker is killed AFTER delivering, and then the failure
702
+ # arm is frozen on a claim that was already false: nothing about the
703
+ # branch can change a recorded exit code, so the row never recovers.
704
+ #
705
+ # Measured 2026-08-24 on `bug/the-agents-tab-filters-on-membership`: a
706
+ # worker SIGTERMed (143) with its work pushed and PR #393 open rendered
707
+ # `worker crashed - someone is on it` indefinitely.
708
+ #
709
+ # THE CODE IS STILL REPORTED. Only the state word changes; a reader can
710
+ # still see the worker was killed. And with no PR fact this stays
711
+ # `failed` — the guess in the other direction, calling a genuine crash
712
+ # finished, is the one this must never make. A PR is the fact that
713
+ # licenses it, because a PR means the work reached a reviewer.
714
+ *) if [ "$has_pr" = pr ]; then
715
+ printf '%s\t%s\t%s' "$(plot_worker_task_state "$wt" "$has_pr")" "$pid" "$code"
716
+ else
717
+ printf 'failed\t%s\t%s' "$pid" "$code"
718
+ fi; return ;;
719
+ esac
720
+ fi
721
+ # No exit file: a worker started before the code was recorded, or one killed
722
+ # outright. Unknown is its own answer — guessing "finished" would be the same
723
+ # mistake in the other direction.
724
+ printf 'ended\t%s\t' "$pid"
725
+ }