@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.
- package/dist/board-server.mjs +120 -111
- package/package.json +11 -2
- package/plot-approve.sh +628 -0
- package/plot-config.sh +14 -0
- package/plot-deliver.sh +488 -0
- package/plot-dispatch.sh +2028 -0
- package/plot-fleet-scan.sh +3496 -0
- package/plot-host.sh +1686 -0
- package/plot-plan-meta.sh +133 -16
- package/plot-reap.sh +286 -0
- package/plot-release-refs.sh +234 -0
- package/plot-resolve-artifact.sh +334 -0
- package/plot-worker-state.sh +725 -0
|
@@ -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
|
+
}
|