@plot-pm/board 0.15.0 → 0.16.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 +109 -107
- package/package.json +2 -1
- package/plot-agent-manifest.sh +80 -1
- package/plot-approve.sh +3 -1
- package/plot-budget.sh +378 -19
- package/plot-config.sh +61 -9
- package/plot-deliver.sh +3 -1
- package/plot-dispatch.sh +149 -46
- package/plot-fleet-scan.sh +61 -150
- package/plot-host.sh +227 -72
- package/plot-plan-meta.sh +58 -10
- package/plot-reap.sh +104 -8
- package/plot-resolve-artifact.sh +4 -2
- package/plot-tmp.sh +108 -0
- package/plot-transcript-quiet.sh +29 -1
- package/plot-worker-monitor.sh +66 -5
package/plot-plan-meta.sh
CHANGED
|
@@ -200,6 +200,14 @@
|
|
|
200
200
|
# every wave name is a label; ALWAYS present, so a consumer
|
|
201
201
|
# never reads undefined. The plan still parses in full: waves[]
|
|
202
202
|
# is unchanged and no name is shortened or dropped.
|
|
203
|
+
# unread_branch_headings
|
|
204
|
+
# slice headings that carry `Branch:` and whose wave holds no
|
|
205
|
+
# branch, verbatim, in document order — a report, not a
|
|
206
|
+
# refusal. The wave stays in waves[] with branches []; this
|
|
207
|
+
# separates a slice the parser could not read from a narrative
|
|
208
|
+
# heading, which carries no `Branch:`. Covers both slice
|
|
209
|
+
# consumers, so a heading lost to the first-heading latch
|
|
210
|
+
# (#1042) is named too. ALWAYS present; [] when none.
|
|
203
211
|
# issues tracker issue numbers this plan answers, from the `## Status`
|
|
204
212
|
# `Issue:` line or front matter `issue:` (sorted, unique).
|
|
205
213
|
# A DEDICATED field, never a scan of the body for `#NNN`: a
|
|
@@ -286,7 +294,7 @@ if [ ${#files[@]} -eq 0 ] && [ ${#missing[@]} -eq 0 ]; then
|
|
|
286
294
|
fi
|
|
287
295
|
|
|
288
296
|
for f in ${missing[@]+"${missing[@]}"}; do
|
|
289
|
-
printf '{"file":"%s","format":"none","error":"file not found","phase_raw":"","phase":"NONE","phase_alt_raw":"","phase_alt":"NONE","type":"","title":"","sprint":"","story":"","assignee":"","branches":[],"prs":[],"issues":[],"malformed_prs":[],"changelog":[],"long_wave_names":[],"review_raw":"","review":"NONE","impl_raw":"","impl":"NONE","design_raw":"","approved_raw":"","released_raw":"","delivered_raw":"","started_raw":[]}\n' \
|
|
297
|
+
printf '{"file":"%s","format":"none","error":"file not found","phase_raw":"","phase":"NONE","phase_alt_raw":"","phase_alt":"NONE","type":"","title":"","sprint":"","story":"","assignee":"","branches":[],"prs":[],"issues":[],"malformed_prs":[],"changelog":[],"long_wave_names":[],"unread_branch_headings":[],"review_raw":"","review":"NONE","impl_raw":"","impl":"NONE","design_raw":"","approved_raw":"","released_raw":"","delivered_raw":"","started_raw":[]}\n' \
|
|
290
298
|
"$(printf '%s' "$f" | sed 's/\\/\\\\/g; s/"/\\"/g')"
|
|
291
299
|
done
|
|
292
300
|
|
|
@@ -433,6 +441,7 @@ function reset_state() {
|
|
|
433
441
|
delete issues; n_issues = 0
|
|
434
442
|
delete wave_names; delete wave_of; delete wave_seq; delete wave_count
|
|
435
443
|
delete deferred_of; delete deferred_why; delete claimed_of; delete ordered_b; n_waves = 0
|
|
444
|
+
delete branch_heading
|
|
436
445
|
delete waits_of; delete waits_set
|
|
437
446
|
delete builds_of; delete builds_set
|
|
438
447
|
delete agent_of; delete agent_set
|
|
@@ -649,6 +658,20 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
|
|
|
649
658
|
}
|
|
650
659
|
}
|
|
651
660
|
out = out "]"
|
|
661
|
+
# unread_branch_headings[]: a slice heading that carries `Branch:` and whose
|
|
662
|
+
# wave holds no branch, heading text verbatim, in document order. The wave
|
|
663
|
+
# itself stays in waves[] with branches []; this names WHY it is empty, since
|
|
664
|
+
# an empty wave with no `Branch:` is narrative and one with it is a slice the
|
|
665
|
+
# parser could not read. ALWAYS present, [] when every such heading was read.
|
|
666
|
+
out = out ",\"unread_branch_headings\":["
|
|
667
|
+
ubh = 0
|
|
668
|
+
for (w = 1; w <= n_waves; w++) {
|
|
669
|
+
if ((w in branch_heading) && wave_count[w] == 0) {
|
|
670
|
+
out = out (ubh > 0 ? "," : "") "\"" jesc(branch_heading[w]) "\""
|
|
671
|
+
ubh++
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
out = out "]"
|
|
652
675
|
out = out ",\"review_raw\":\"" jesc(review) "\",\"review\":\"" norm_review(review) "\""
|
|
653
676
|
out = out ",\"impl_raw\":\"" jesc(impl) "\",\"impl\":\"" norm_impl(impl) "\""
|
|
654
677
|
out = out ",\"design_raw\":\"" jesc(design) "\""
|
|
@@ -670,6 +693,16 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
|
|
|
670
693
|
out = out "}"
|
|
671
694
|
print out
|
|
672
695
|
}
|
|
696
|
+
# Records the current `### ` heading when it carries `Branch:`. Called by BOTH
|
|
697
|
+
# slice consumers, because the list consumer also opens a wave per heading: a
|
|
698
|
+
# section whose FIRST heading is narrative routes every later heading there, and
|
|
699
|
+
# a `(Branch: …)` heading below it then yields nothing (#1042).
|
|
700
|
+
function note_branch_heading( h) {
|
|
701
|
+
if (index($0, "Branch:") == 0) return
|
|
702
|
+
h = trim(substr($0, 4))
|
|
703
|
+
sub(/[ \t]*<!--.*$/, "", h)
|
|
704
|
+
branch_heading[n_waves] = h
|
|
705
|
+
}
|
|
673
706
|
# The longest wave name that still reads as a label, not prose. A JUDGEMENT, not
|
|
674
707
|
# a measurement: the longest legitimate name in the estate is `Offered first`
|
|
675
708
|
# (13), and the offender this exists to catch is a 53-character sentence, so the
|
|
@@ -911,20 +944,26 @@ section == "changelog" {
|
|
|
911
944
|
cl_open = 0
|
|
912
945
|
next
|
|
913
946
|
}
|
|
914
|
-
# WHICH SHAPE THIS SECTION HOLDS, decided
|
|
915
|
-
#
|
|
947
|
+
# WHICH SHAPE THIS SECTION HOLDS, decided by ANY heading in it that names a
|
|
948
|
+
# branch — not by the first heading alone.
|
|
916
949
|
#
|
|
917
950
|
# `(Branch:` IS THE MARKER, and it is the only reliable one. A heading carrying
|
|
918
951
|
# it is the new shape by construction — that parenthetical is where the new
|
|
919
|
-
# layout puts the branch.
|
|
952
|
+
# layout puts the branch. Headings without it are the old shape, whose headings
|
|
920
953
|
# are bare names (`### Tracer`) and whose branches ride list items below.
|
|
921
954
|
#
|
|
922
|
-
#
|
|
923
|
-
#
|
|
924
|
-
#
|
|
925
|
-
#
|
|
955
|
+
# THE FIRST HEADING IS NOT THE DECIDER, and a latch on it loses work. Measured
|
|
956
|
+
# 2026-09-28 over 357 plans: 56 sections open with a narrative heading, and in 2
|
|
957
|
+
# of them a branched heading sits below it, so the section routed to the list
|
|
958
|
+
# consumer and 5 declared slices were read as none. `slice_shape` therefore
|
|
959
|
+
# stays `""` until a BRANCHED heading appears, wherever it sits.
|
|
960
|
+
#
|
|
961
|
+
# A SECTION WITH NO BRANCHED `### ` AT ALL is the old shape, and must be: a plan
|
|
962
|
+
# written before subheadings existed is one unnamed wave of list items, which is
|
|
963
|
+
# exactly what the old consumer produces — 54 of those 56 plans. `""` routes
|
|
964
|
+
# there, so the absent latch and the old answer are one behaviour.
|
|
926
965
|
section == "slices" && $0 ~ /^###[ \t]/ && slice_shape == "" {
|
|
927
|
-
|
|
966
|
+
if (index($0, "(Branch:") > 0) slice_shape = "heading"
|
|
928
967
|
}
|
|
929
968
|
|
|
930
969
|
section == "slices" && slice_shape != "heading" {
|
|
@@ -932,6 +971,7 @@ section == "slices" && slice_shape != "heading" {
|
|
|
932
971
|
# unnamed wave, so a pre-wave plan parses as exactly one wave.
|
|
933
972
|
if ($0 ~ /^###[ \t]/) {
|
|
934
973
|
wave_names[++n_waves] = trim(substr($0, 4))
|
|
974
|
+
note_branch_heading()
|
|
935
975
|
next
|
|
936
976
|
}
|
|
937
977
|
# Claim reflection, written by the worker after its ref push succeeds. This is
|
|
@@ -1187,6 +1227,7 @@ section == "slices" && slice_shape == "heading" {
|
|
|
1187
1227
|
sub(/[ \t]*\(Branch:.*$/, "", wname)
|
|
1188
1228
|
wname = trim(wname)
|
|
1189
1229
|
wave_names[++n_waves] = wname
|
|
1230
|
+
note_branch_heading()
|
|
1190
1231
|
|
|
1191
1232
|
# Claim/deferral annotations bind to the line carrying the branch name, which
|
|
1192
1233
|
# is the heading. Read before any match() below, which clobbers RSTART/RLENGTH.
|
|
@@ -1291,10 +1332,17 @@ section == "slices" && slice_shape == "heading" {
|
|
|
1291
1332
|
# heading with no readable branch still opened a wave above — so a `## Waves`
|
|
1292
1333
|
# section is never silently empty, which is the failure this plan refuses: a
|
|
1293
1334
|
# consumer sees a wave it could not extract a branch from, not an absence.
|
|
1335
|
+
#
|
|
1336
|
+
# THE VALUE MAY BE BACKTICKED. `(Branch: \`bug/foo\`)` is unambiguous, and a
|
|
1337
|
+
# backtick between `Branch:` and the prefix made the anchored match fail, so
|
|
1338
|
+
# the heading opened a wave and yielded nothing. The backticks are optional on
|
|
1339
|
+
# both sides and stripped from the name. Measured 2026-09-28 over 357 plans:
|
|
1340
|
+
# 2 records change, 355 byte-identical.
|
|
1294
1341
|
hmeta = $0
|
|
1295
|
-
if (match(hmeta, "Branch:[ \t]
|
|
1342
|
+
if (match(hmeta, "Branch:[ \t]*`?(" PREFIXES ")/[^ \t,)`]+`?")) {
|
|
1296
1343
|
b = substr(hmeta, RSTART, RLENGTH)
|
|
1297
1344
|
sub(/^Branch:[ \t]*/, "", b)
|
|
1345
|
+
gsub(/`/, "", b)
|
|
1298
1346
|
branches[++n_branches] = b
|
|
1299
1347
|
wave_of[n_branches] = n_waves
|
|
1300
1348
|
wave_seq[n_branches] = ++wave_count[n_waves]
|
package/plot-reap.sh
CHANGED
|
@@ -146,12 +146,36 @@
|
|
|
146
146
|
# words and lives INSIDE the tree, so it goes when the tree does and is not
|
|
147
147
|
# swept here. This is the dispatcher's record of what it started. Two files,
|
|
148
148
|
# two lifetimes, and CLAUDE.md already distinguishes them.
|
|
149
|
+
#
|
|
150
|
+
# `--sweep-temp` IS A SEPARATE MODE, and it runs INSTEAD of the four kinds. A
|
|
151
|
+
# trap does not run on SIGKILL — the board ends a scan at its timeout,
|
|
152
|
+
# `bounded.sh` escalates to SIGKILL, a person kills a hung script — so some temp
|
|
153
|
+
# paths outlive every trap. It removes two populations, each owned by this user
|
|
154
|
+
# and older than `Temp sweep after` hours (default 24), by the entry's own
|
|
155
|
+
# modification time:
|
|
156
|
+
#
|
|
157
|
+
# - `$TMPDIR/plot-?*` entries directly under `$TMPDIR` — `plot-` and at least
|
|
158
|
+
# one more character, any separator: `mkdtempSync` appends six characters
|
|
159
|
+
# with no dot, so `plot-host-pTFuyG` is the common shape. Never `plot`,
|
|
160
|
+
# `plotter-old` or any `tmp.*`: that is every template-less `mktemp` on the
|
|
161
|
+
# machine, and neither owner nor age separates Plot's from another
|
|
162
|
+
# program's. A `plot-reg.<pid>` exit registry is kept while its pid lives,
|
|
163
|
+
# because a worker loop registers its exit command and runs for days.
|
|
164
|
+
# - `$PLOT_BUDGET_HOME/memo/<pid>` directories (default `~/.plot/state/memo`)
|
|
165
|
+
# whose pid is not alive.
|
|
166
|
+
#
|
|
167
|
+
# It lists each candidate with `find` and removes it by the full path it
|
|
168
|
+
# listed; it never passes a glob to `rm`, and it never reads `/tmp` or
|
|
169
|
+
# `/var/folders` when `$TMPDIR` points elsewhere. The age bound is safe because
|
|
170
|
+
# every Plot temp path belongs to one script call, one scan or one board
|
|
171
|
+
# request, and 24 h is about 1,000 times the scan's 90 s timeout.
|
|
149
172
|
set -u
|
|
150
173
|
|
|
151
|
-
DRY=1; MAX=0
|
|
174
|
+
DRY=1; MAX=0; SWEEP_TEMP=0
|
|
152
175
|
while [ $# -gt 0 ]; do
|
|
153
176
|
case "$1" in
|
|
154
177
|
--yes) DRY=0 ;;
|
|
178
|
+
--sweep-temp) SWEEP_TEMP=1 ;;
|
|
155
179
|
--dry-run) DRY=1 ;;
|
|
156
180
|
--max) MAX="${2:-0}"; shift ;;
|
|
157
181
|
# The header, however long it has become. A hardcoded last line silently
|
|
@@ -163,6 +187,65 @@ while [ $# -gt 0 ]; do
|
|
|
163
187
|
shift
|
|
164
188
|
done
|
|
165
189
|
|
|
190
|
+
# Is a pid alive? `ps -p` answers for another user's process too, where
|
|
191
|
+
# `kill -0` reports EPERM, so a reused pid always keeps its entry.
|
|
192
|
+
pid_alive() { ps -p "$1" >/dev/null 2>&1; }
|
|
193
|
+
|
|
194
|
+
# One entry: report it, and remove it by the exact path unless this is a dry run.
|
|
195
|
+
sweep_one() { # $1=path $2=why
|
|
196
|
+
temp_swept=$((temp_swept + 1))
|
|
197
|
+
if [ "$DRY" = 1 ]; then
|
|
198
|
+
echo "temp: would remove $1 ($2)"
|
|
199
|
+
elif rm -rf -- "$1" 2>/dev/null; then
|
|
200
|
+
echo "temp: removed $1 ($2)"
|
|
201
|
+
temp_removed=$((temp_removed + 1))
|
|
202
|
+
else
|
|
203
|
+
echo "temp: could not remove $1 ($2)"
|
|
204
|
+
fi
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
sweep_temp() {
|
|
208
|
+
local hours root me memo entry name
|
|
209
|
+
hours=$("$(dirname "${BASH_SOURCE[0]}")/plot-config.sh" get "Temp sweep after" 24 2>/dev/null) || hours=24
|
|
210
|
+
case "$hours" in
|
|
211
|
+
''|*[!0-9]*) echo "plot-reap: 'Temp sweep after' must be a whole number of hours, not '$hours'" >&2; return 2 ;;
|
|
212
|
+
esac
|
|
213
|
+
root="${TMPDIR:-/tmp}"; root="${root%/}"
|
|
214
|
+
me=$(id -un)
|
|
215
|
+
temp_swept=0; temp_removed=0; temp_live=0
|
|
216
|
+
while IFS= read -r entry; do
|
|
217
|
+
[ -n "$entry" ] || continue
|
|
218
|
+
[ "$MAX" -gt 0 ] && [ "$temp_swept" -ge "$MAX" ] && break
|
|
219
|
+
name=${entry##*/}
|
|
220
|
+
case "$name" in
|
|
221
|
+
plot-reg.*)
|
|
222
|
+
if pid_alive "${name#plot-reg.}"; then temp_live=$((temp_live + 1)); continue; fi ;;
|
|
223
|
+
esac
|
|
224
|
+
sweep_one "$entry" "older than ${hours}h"
|
|
225
|
+
done <<LIST
|
|
226
|
+
$(find "$root" -mindepth 1 -maxdepth 1 -user "$me" -name 'plot-?*' -mmin +$((hours * 60)) -print 2>/dev/null)
|
|
227
|
+
LIST
|
|
228
|
+
memo="${PLOT_BUDGET_HOME:-${HOME:-}/.plot/state}/memo"
|
|
229
|
+
if [ -d "$memo" ]; then
|
|
230
|
+
while IFS= read -r entry; do
|
|
231
|
+
[ -n "$entry" ] || continue
|
|
232
|
+
[ "$MAX" -gt 0 ] && [ "$temp_swept" -ge "$MAX" ] && break
|
|
233
|
+
name=${entry##*/}
|
|
234
|
+
case "$name" in ''|*[!0-9]*) continue ;; esac
|
|
235
|
+
if pid_alive "$name"; then temp_live=$((temp_live + 1)); continue; fi
|
|
236
|
+
sweep_one "$entry" "memo of dead pid $name, older than ${hours}h"
|
|
237
|
+
done <<LIST
|
|
238
|
+
$(find "$memo" -mindepth 1 -maxdepth 1 -type d -user "$me" -mmin +$((hours * 60)) -print 2>/dev/null)
|
|
239
|
+
LIST
|
|
240
|
+
fi
|
|
241
|
+
echo "temp-summary: swept=$temp_swept removed=$temp_removed kept_live=$temp_live bound_hours=$hours root=$root dry_run=$DRY"
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if [ "$SWEEP_TEMP" = 1 ]; then
|
|
245
|
+
sweep_temp
|
|
246
|
+
exit $?
|
|
247
|
+
fi
|
|
248
|
+
|
|
166
249
|
command -v git >/dev/null 2>&1 || { echo "plot-reap: git not found" >&2; exit 2; }
|
|
167
250
|
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
|
168
251
|
echo "plot-reap: not a git repository" >&2; exit 2; }
|
|
@@ -255,7 +338,7 @@ desk_unpushed() { # $1=worktree $2=branch $3=merge reading → short shas
|
|
|
255
338
|
# NO MERGED HEAD THIS DESK CONTAINS, and the host still said merged. The
|
|
256
339
|
# subtraction above cannot run, and without it `--not --remotes` reports
|
|
257
340
|
# EVERY commit the branch ever had — so the desk would be held forever
|
|
258
|
-
# for having done the work that merged.
|
|
341
|
+
# for having done the work that merged (#1033).
|
|
259
342
|
#
|
|
260
343
|
# Two ways to reach here, both normal. A SQUASH merge rewrites the
|
|
261
344
|
# commits, so the head the host names exists nowhere in this history —
|
|
@@ -264,14 +347,27 @@ desk_unpushed() { # $1=worktree $2=branch $3=merge reading → short shas
|
|
|
264
347
|
# trees against the host's 28. And a host answer that carries no head at
|
|
265
348
|
# all leaves nothing to subtract.
|
|
266
349
|
#
|
|
267
|
-
#
|
|
268
|
-
#
|
|
350
|
+
# THE READING IS PATCH-ID: `git cherry` marks a commit `-` when its
|
|
351
|
+
# change is already upstream and `+` when it is not. A `+` commit that no
|
|
352
|
+
# remote holds is work the merge did not take — a commit made after the
|
|
353
|
+
# merge — and it holds the desk. No clock is consulted: a committer date
|
|
354
|
+
# holds a rebased merged desk forever, and an author date reaps an old
|
|
355
|
+
# patch committed today (#1038). The rule stays in shell because the
|
|
356
|
+
# logic is the prefix test and nothing else; a second conditional here is
|
|
357
|
+
# the signal to move it into the domain with a corpus entry.
|
|
269
358
|
#
|
|
270
|
-
#
|
|
271
|
-
#
|
|
272
|
-
|
|
273
|
-
|
|
359
|
+
# It fails toward keeping: an unreadable base or a failing `git cherry`
|
|
360
|
+
# returns 1, which the rule reads as `unknown` and refuses on.
|
|
361
|
+
local full cherry
|
|
362
|
+
full=$(git -C "$wt" rev-list HEAD --not --remotes 2>/dev/null) || return 1
|
|
363
|
+
cherry=$(git -C "$wt" cherry "origin/$DEFAULT" HEAD 2>/dev/null) || return 1
|
|
274
364
|
list=""
|
|
365
|
+
for h in $(printf '%s\n' "$cherry" | sed -n 's/^+ //p'); do
|
|
366
|
+
case $'\n'"$full"$'\n' in
|
|
367
|
+
*$'\n'"$h"$'\n'*) list+="$(git -C "$wt" rev-parse --short "$h")"$'\n' ;;
|
|
368
|
+
esac
|
|
369
|
+
done
|
|
370
|
+
list=${list%$'\n'}
|
|
275
371
|
fi
|
|
276
372
|
fi
|
|
277
373
|
printf '%s\n' "$list"
|
package/plot-resolve-artifact.sh
CHANGED
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
set -uo pipefail
|
|
71
71
|
|
|
72
72
|
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
73
|
+
. "$script_dir/plot-tmp.sh"
|
|
73
74
|
|
|
74
75
|
# THE FILES THIS SCRIPT MAY RESOLVE — a SET, and derived rather than listed.
|
|
75
76
|
#
|
|
@@ -231,8 +232,9 @@ if ! mkdir "$lock" 2>/dev/null; then
|
|
|
231
232
|
fi
|
|
232
233
|
# Released on every exit, including a kill. A lock that outlives its process
|
|
233
234
|
# would make one interrupted repair block the branch forever — and the repair is
|
|
234
|
-
# idempotent, so there is nothing to protect after the process is gone.
|
|
235
|
-
|
|
235
|
+
# idempotent, so there is nothing to protect after the process is gone. A TERM
|
|
236
|
+
# or INT stops the repair (143 or 130) after the lock is released.
|
|
237
|
+
plot_on_exit 'rmdir "$lock" 2>/dev/null || true'
|
|
236
238
|
|
|
237
239
|
if [ -d "$wt" ] && git worktree list --porcelain | grep -qx "worktree $wt"; then
|
|
238
240
|
# A REUSED WORKTREE MAY BELONG TO SOMEONE ELSE, and on 2026-08-17 one did: the
|
package/plot-tmp.sh
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# plot-tmp.sh — every temp path a Plot script creates, and the process's only
|
|
2
|
+
# EXIT/INT/TERM traps. SOURCED, not run.
|
|
3
|
+
#
|
|
4
|
+
# . "$SCRIPT_DIR/plot-tmp.sh"
|
|
5
|
+
# plot_tmpdir work fleet-ref # $work = $TMPDIR/plot-fleet-ref.XXXXXX (a directory)
|
|
6
|
+
# plot_tmpfile err host-err # $err = $TMPDIR/plot-host-err.XXXXXX (a file)
|
|
7
|
+
# plot_on_exit budget_memo_clear # a command the exit handler runs
|
|
8
|
+
#
|
|
9
|
+
# THE TEMPLATE IS THE REASON THIS FILE EXISTS. On macOS, `mktemp` with no
|
|
10
|
+
# template and `mktemp -t` both write to `_CS_DARWIN_USER_TEMP_DIR` and ignore
|
|
11
|
+
# `TMPDIR` (`man mktemp`). Only an explicit `"${TMPDIR:-/tmp}/plot-<prefix>.XXXXXX"`
|
|
12
|
+
# lands under `TMPDIR`, and BSD `mktemp` requires the X's to trail. Every path
|
|
13
|
+
# carries the `plot-` prefix, which is the name `plot-reap.sh --sweep-temp`
|
|
14
|
+
# removes after a SIGKILL skipped the traps.
|
|
15
|
+
#
|
|
16
|
+
# ASSIGNMENT BY NAME, NEVER `d=$(plot_tmpdir x)`. The functions assign through
|
|
17
|
+
# `printf -v` and print nothing. `scripts/check-temp-paths.sh` refuses the
|
|
18
|
+
# substitution form.
|
|
19
|
+
#
|
|
20
|
+
# THE REGISTRY IS A FILE KEYED BY `$$`, NOT A SHELL ARRAY. `$$` is the owning
|
|
21
|
+
# script's pid in every subshell, so a registration made inside `$(…)` or
|
|
22
|
+
# `( … ) &` reaches the owner's trap; an array would be a subshell's copy and
|
|
23
|
+
# the path would leak. Measured 2026-09-30: `plot-host.sh` creates two spool
|
|
24
|
+
# files inside a command substitution.
|
|
25
|
+
#
|
|
26
|
+
# THE TRAPS ARE INSTALLED HERE, ONCE, AT SOURCE TIME. A trap installed on the
|
|
27
|
+
# first call would be installed inside that call's substitution and remove the
|
|
28
|
+
# path when the substitution closed. `PLOT_TMP_LOADED` makes a second source in
|
|
29
|
+
# the same process a no-op, because a re-run setup would truncate the live
|
|
30
|
+
# registry. INT and TERM run the cleanup, clear their own trap and re-raise, so
|
|
31
|
+
# the script stops with 130 or 143 rather than running on to exit 0.
|
|
32
|
+
#
|
|
33
|
+
# A script that sources this must not install its own EXIT, INT or TERM trap:
|
|
34
|
+
# the last `trap` wins, and that replacement is the defect this file fixes
|
|
35
|
+
# (`plot-fleet-scan.sh` left one ~955-file cache per run). Use `plot_on_exit`.
|
|
36
|
+
|
|
37
|
+
if [ "${PLOT_TMP_LOADED:-}" = "$$" ]; then
|
|
38
|
+
return 0 2>/dev/null || exit 0
|
|
39
|
+
fi
|
|
40
|
+
PLOT_TMP_LOADED=$$
|
|
41
|
+
|
|
42
|
+
# Fixed at first source: a later `TMPDIR` change in the script does not move it.
|
|
43
|
+
# A file already at this path belongs to a dead process that had the same pid,
|
|
44
|
+
# and its `c:` commands are not this process's, so it is replaced, never read.
|
|
45
|
+
PLOT_TMP_REGISTRY="${TMPDIR:-/tmp}/plot-reg.$$"
|
|
46
|
+
rm -f -- "$PLOT_TMP_REGISTRY" 2>/dev/null
|
|
47
|
+
: > "$PLOT_TMP_REGISTRY" 2>/dev/null || true
|
|
48
|
+
|
|
49
|
+
# One line per entry, in registration order: `p:<path>` or `c:<command>`.
|
|
50
|
+
_plot_tmp_register() {
|
|
51
|
+
printf '%s:%s\n' "$1" "$2" >> "$PLOT_TMP_REGISTRY" 2>/dev/null || true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# plot_tmpdir VAR prefix — create a directory, register it, assign it to VAR.
|
|
55
|
+
plot_tmpdir() {
|
|
56
|
+
local __plot_tmp_new
|
|
57
|
+
__plot_tmp_new=$(mktemp -d "${TMPDIR:-/tmp}/plot-$2.XXXXXX") || return 1
|
|
58
|
+
_plot_tmp_register p "$__plot_tmp_new"
|
|
59
|
+
printf -v "$1" '%s' "$__plot_tmp_new"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# plot_tmpfile VAR prefix — create a file, register it, assign it to VAR.
|
|
63
|
+
plot_tmpfile() {
|
|
64
|
+
local __plot_tmp_new
|
|
65
|
+
__plot_tmp_new=$(mktemp "${TMPDIR:-/tmp}/plot-$2.XXXXXX") || return 1
|
|
66
|
+
_plot_tmp_register p "$__plot_tmp_new"
|
|
67
|
+
printf -v "$1" '%s' "$__plot_tmp_new"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# plot_on_exit command — run `command` (one line) when the process ends.
|
|
71
|
+
plot_on_exit() {
|
|
72
|
+
_plot_tmp_register c "$*"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Runs every registered command and removes every registered path, in
|
|
76
|
+
# registration order, then removes the registry. Runs once: the registry is
|
|
77
|
+
# gone afterwards, so a second call finds nothing.
|
|
78
|
+
_plot_tmp_cleanup() {
|
|
79
|
+
local __plot_tmp_line
|
|
80
|
+
[ -f "$PLOT_TMP_REGISTRY" ] || return 0
|
|
81
|
+
while IFS= read -r __plot_tmp_line; do
|
|
82
|
+
case $__plot_tmp_line in
|
|
83
|
+
c:*) eval "${__plot_tmp_line#c:}" ;;
|
|
84
|
+
p:*) rm -rf -- "${__plot_tmp_line#p:}" 2>/dev/null ;;
|
|
85
|
+
esac
|
|
86
|
+
done < "$PLOT_TMP_REGISTRY"
|
|
87
|
+
rm -f -- "$PLOT_TMP_REGISTRY" 2>/dev/null
|
|
88
|
+
return 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_plot_tmp_on_exit() {
|
|
92
|
+
local __plot_tmp_rc=$?
|
|
93
|
+
_plot_tmp_cleanup
|
|
94
|
+
exit "$__plot_tmp_rc"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# $1 is the signal name, $2 its number. `kill` re-raises it with the default
|
|
98
|
+
# disposition; the `exit` is reached only if the shell defers the delivery.
|
|
99
|
+
_plot_tmp_on_signal() {
|
|
100
|
+
trap - EXIT "$1"
|
|
101
|
+
_plot_tmp_cleanup
|
|
102
|
+
kill -"$1" "$$"
|
|
103
|
+
exit $((128 + $2))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
trap _plot_tmp_on_exit EXIT
|
|
107
|
+
trap '_plot_tmp_on_signal INT 2' INT
|
|
108
|
+
trap '_plot_tmp_on_signal TERM 15' TERM
|
package/plot-transcript-quiet.sh
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# The ONE answer to "how long has this worktree's agent been quiet?" — sourced,
|
|
3
|
-
# not run, by `plot-worker-monitor.sh`.
|
|
3
|
+
# not run, by `plot-worker-monitor.sh` and `plot-worker-loop.sh`.
|
|
4
4
|
#
|
|
5
5
|
# It reads the AGENT rather than the machine. A `claude -p` session appends a
|
|
6
6
|
# timestamped line to its transcript for every model turn, tool call and tool
|
|
@@ -40,6 +40,14 @@
|
|
|
40
40
|
# belongs to no one. `rules/spend.ts` states that side; the two read the same
|
|
41
41
|
# files and must not be made to share a join.
|
|
42
42
|
#
|
|
43
|
+
# A THIRD QUESTION USES BOTH KEYS: *has this worker's conversation written?* It
|
|
44
|
+
# is per worktree AND per conversation handle, and it is asked as a file's
|
|
45
|
+
# existence, never as a time: `plot_transcript_exists` below. The loop asks it
|
|
46
|
+
# to choose `--session-id` or `--resume`; the worker monitor asks it before it
|
|
47
|
+
# calls a quiet desk idle, because until the new conversation writes its first
|
|
48
|
+
# line the desk's newest file belongs to the previous one. It does not change
|
|
49
|
+
# the quiet number, which stays about the desk.
|
|
50
|
+
#
|
|
43
51
|
# So the join here is the one `plot-quiet-stretch.mjs` already made and proved
|
|
44
52
|
# on 23 real sessions: the runtime stores a session under
|
|
45
53
|
# `$HOME/.claude/projects/<slug>` where the slug is the WORKTREE PATH with `/`
|
|
@@ -121,6 +129,26 @@ plot_transcript_quiet_seconds() { # $1=worktree → seconds | unavailable
|
|
|
121
129
|
printf '%s' "$quiet"
|
|
122
130
|
}
|
|
123
131
|
|
|
132
|
+
# Does the conversation `id` have a transcript at this worktree?
|
|
133
|
+
#
|
|
134
|
+
# EXISTENCE, NOT A TIMESTAMP. The runtime creates `<id>.jsonl` with its first
|
|
135
|
+
# line and appends to it after, under both `--session-id` and `--resume`. So the
|
|
136
|
+
# file's presence says the conversation has written, and no comparison of
|
|
137
|
+
# clocks is made, so a file created in the same second as a manifest write
|
|
138
|
+
# reads as present.
|
|
139
|
+
#
|
|
140
|
+
# NO HANDLE AND NO FILE ARE ONE ANSWER HERE, and a caller that must tell them
|
|
141
|
+
# apart checks the handle first. `session_flag` reads both as *create*; the
|
|
142
|
+
# monitor's port does not, because a monitor with no handle must not read every
|
|
143
|
+
# quiet worker as unspoken.
|
|
144
|
+
plot_transcript_exists() { # $1=worktree $2=id → 0 found | 1 not
|
|
145
|
+
local wt="$1" id="$2" dir
|
|
146
|
+
[ -n "$wt" ] && [ -n "$id" ] || return 1
|
|
147
|
+
dir=$(plot_transcript_dir "$wt" 2>/dev/null) || return 1
|
|
148
|
+
[ -n "$dir" ] || return 1
|
|
149
|
+
[ -f "$dir/$id.jsonl" ]
|
|
150
|
+
}
|
|
151
|
+
|
|
124
152
|
# A file's modification time as a unix epoch. BSD and GNU `stat` disagree on the
|
|
125
153
|
# flag, and a monitor that works on the author's laptop and not in CI is a
|
|
126
154
|
# monitor nobody trusts.
|
package/plot-worker-monitor.sh
CHANGED
|
@@ -185,6 +185,11 @@ the environment, exactly as the wrapper's other children do:
|
|
|
185
185
|
PLOT_MONITOR_FILE where findings are published (default:
|
|
186
186
|
$PLOT_WORKTREE/.plot-worker.monitor.worker.jsonl)
|
|
187
187
|
PLOT_MONITOR_INTERVAL seconds between passes (default 30)
|
|
188
|
+
PLOT_SESSION_ID the launch session id; the handle when the manifest
|
|
189
|
+
carries no `resumeId`
|
|
190
|
+
PLOT_MANIFEST_FILE the agent's manifest, whose `resumeId` names the current
|
|
191
|
+
conversation. With neither set, `idle` is judged on the
|
|
192
|
+
desk alone and one line on stderr says so.
|
|
188
193
|
|
|
189
194
|
--once take one sample and exit, rather than looping. A single pass can
|
|
190
195
|
never publish `idle` — that needs two — so this is how a test drives
|
|
@@ -255,6 +260,15 @@ plot_transcript_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-transcri
|
|
|
255
260
|
# shellcheck source=plot-transcript-quiet.sh
|
|
256
261
|
if [ -r "$plot_transcript_lib" ]; then . "$plot_transcript_lib"; fi
|
|
257
262
|
|
|
263
|
+
# THE CONVERSATION HANDLE — `session_handle`, the one the loop hands the prompt.
|
|
264
|
+
# The manifest's `resumeId`, else `PLOT_SESSION_ID`, both read from the
|
|
265
|
+
# environment the wrapper passes down. Never `plot_manifest_for_worktree`: it
|
|
266
|
+
# resolves `--show-toplevel` to the desk and ignores `Agent registry`, so it
|
|
267
|
+
# names a directory that does not exist (#1086).
|
|
268
|
+
plot_manifest_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-agent-manifest.sh"
|
|
269
|
+
# shellcheck source=plot-agent-manifest.sh
|
|
270
|
+
if [ -r "$plot_manifest_lib" ]; then . "$plot_manifest_lib"; fi
|
|
271
|
+
|
|
258
272
|
# HOW LONG A TRANSCRIPT MUST BE QUIET BEFORE THE QUESTION IS EVEN ASKED.
|
|
259
273
|
#
|
|
260
274
|
# 900 s, and the number comes from wave 1's measurement rather than from taste.
|
|
@@ -318,7 +332,7 @@ publish() { # $1=finding $2=evidence $3=since
|
|
|
318
332
|
}
|
|
319
333
|
|
|
320
334
|
# ---------------------------------------------------------------------------
|
|
321
|
-
# THE PORTS —
|
|
335
|
+
# THE PORTS — seven named seams, so every branch is reachable from a test
|
|
322
336
|
# ---------------------------------------------------------------------------
|
|
323
337
|
#
|
|
324
338
|
# Each of these is one question against the machine, and each is a `monitor_*`
|
|
@@ -374,6 +388,29 @@ monitor_transcript_quiet() { # → seconds | unavailable
|
|
|
374
388
|
plot_transcript_quiet_seconds "$worktree"
|
|
375
389
|
}
|
|
376
390
|
|
|
391
|
+
# Has THIS worker's conversation written yet?
|
|
392
|
+
#
|
|
393
|
+
# THE DESK-WIDE NUMBER CANNOT SAY. After a hop to a new branch the loop mints a
|
|
394
|
+
# fresh handle, and the new conversation has no transcript file until its first
|
|
395
|
+
# line. Until then the desk's newest file is the PREVIOUS slice's, and its
|
|
396
|
+
# silence is not this worker's. So the monitor asks the loop's own probe with
|
|
397
|
+
# the loop's own handle: one probe, two readers, one answer.
|
|
398
|
+
#
|
|
399
|
+
# THREE ANSWERS, AND THE THIRD IS NOT THE SECOND. `0` the handle's file exists,
|
|
400
|
+
# `1` it does not, `2` there is no handle to ask about. `plot_transcript_exists`
|
|
401
|
+
# reads *no handle* as *no file*, which suits `session_flag`; here it would
|
|
402
|
+
# make a hand-started monitor read every quiet worker as unspoken and disable
|
|
403
|
+
# `idle` silently. So the handle is checked here, before the probe.
|
|
404
|
+
monitor_conversation_spoken() { # → 0 spoken | 1 unspoken | 2 no handle
|
|
405
|
+
command -v session_handle >/dev/null 2>&1 || return 2
|
|
406
|
+
command -v plot_transcript_exists >/dev/null 2>&1 || return 2
|
|
407
|
+
local handle
|
|
408
|
+
handle=$(session_handle) || return 2
|
|
409
|
+
[ -n "$handle" ] || return 2
|
|
410
|
+
plot_transcript_exists "$worktree" "$handle" && return 0
|
|
411
|
+
return 1
|
|
412
|
+
}
|
|
413
|
+
|
|
377
414
|
# A cheap stand-in for "the tree as it is right now", compared between passes.
|
|
378
415
|
#
|
|
379
416
|
# IT GOES THROUGH `plot_worker_dirty_filter`, which is not an optimisation — it
|
|
@@ -462,7 +499,7 @@ since=''
|
|
|
462
499
|
# every other question meaningless — you cannot measure the CPU of a subtree
|
|
463
500
|
# that is not there, and `plot_worker_activity` would answer "" for it anyway,
|
|
464
501
|
# which is indistinguishable from a live pid with no children.
|
|
465
|
-
sample_verdict() { # → gone | quiet | busy | unknown
|
|
502
|
+
sample_verdict() { # → gone | quiet | busy | unknown | unspoken
|
|
466
503
|
local alive
|
|
467
504
|
monitor_pid_alive; alive=$?
|
|
468
505
|
[ "$alive" = 1 ] && { printf 'gone'; return; }
|
|
@@ -500,6 +537,18 @@ sample_verdict() { # → gone | quiet | busy | unknown
|
|
|
500
537
|
# needs asking: no CPU sample can overturn a line written seconds ago.
|
|
501
538
|
if [ "$quiet" -lt "$PLOT_MONITOR_QUIET_SECONDS" ]; then printf 'busy'; return; fi
|
|
502
539
|
|
|
540
|
+
# PAST THE WINDOW, AND ONLY HERE, ASK WHETHER THIS CONVERSATION HAS WRITTEN.
|
|
541
|
+
# The number is the desk's; a new conversation with no file yet has produced
|
|
542
|
+
# none of its silence. `unspoken` is a reading that was made, and it is not
|
|
543
|
+
# `unknown`, which is no reading. Only `1` answers it: with no handle (`2`)
|
|
544
|
+
# the verdict is judged on the desk alone, as before this port existed.
|
|
545
|
+
#
|
|
546
|
+
# LAZY ON PURPOSE. `session_handle` starts one `node` (about 35 ms), so a
|
|
547
|
+
# worker inside the window never pays it.
|
|
548
|
+
local spoken
|
|
549
|
+
monitor_conversation_spoken; spoken=$?
|
|
550
|
+
[ "$spoken" = 1 ] && { printf 'unspoken'; return; }
|
|
551
|
+
|
|
503
552
|
# PAST THE WINDOW, THE SECOND READING DECIDES — and it answers a question the
|
|
504
553
|
# transcript cannot. A transcript is equally quiet whether the agent is
|
|
505
554
|
# waiting on a model or waiting on its own 20-minute test suite. 28 of the 37
|
|
@@ -556,9 +605,13 @@ monitor_pass() {
|
|
|
556
605
|
# failure to observe is not evidence of something to see.
|
|
557
606
|
fi
|
|
558
607
|
;;
|
|
559
|
-
# `busy` and `
|
|
560
|
-
# design: silence means healthy, and the AgentMonitor's slower
|
|
561
|
-
# catches a worker that finished without saying so.
|
|
608
|
+
# `busy`, `unknown` and `unspoken` are not findings. Nothing is published,
|
|
609
|
+
# which is the design: silence means healthy, and the AgentMonitor's slower
|
|
610
|
+
# loop is what catches a worker that finished without saying so. `unspoken`
|
|
611
|
+
# is recorded as `prev_verdict`, so `idle` needs two `quiet` passes after
|
|
612
|
+
# the conversation's first line. No grace period bounds it: a prompt that
|
|
613
|
+
# stays alive and never writes a line ends at `Worker bound`, the cost
|
|
614
|
+
# `unknown` already carries.
|
|
562
615
|
esac
|
|
563
616
|
|
|
564
617
|
prev_verdict="$verdict"
|
|
@@ -589,6 +642,14 @@ monitor_pass() {
|
|
|
589
642
|
# line defines, and nothing below it runs when the guard is set.
|
|
590
643
|
[ -n "${PLOT_MONITOR_NO_MAIN:-}" ] && return 0 2>/dev/null
|
|
591
644
|
|
|
645
|
+
# ONE LINE AT START WHEN THERE IS NO HANDLE. Every wrapper-started monitor has
|
|
646
|
+
# one, because `plot-dispatch.sh` sets `PLOT_SESSION_ID` on every launch; only a
|
|
647
|
+
# monitor started by hand has none. It then behaves as it did before the
|
|
648
|
+
# conversation probe, and says so rather than degrading silently.
|
|
649
|
+
if [ -z "${PLOT_SESSION_ID:-}" ] && [ -z "${PLOT_MANIFEST_FILE:-}" ]; then
|
|
650
|
+
echo 'plot-worker-monitor: no session handle (PLOT_SESSION_ID and PLOT_MANIFEST_FILE unset) — idle is judged on the desk alone' >&2
|
|
651
|
+
fi
|
|
652
|
+
|
|
592
653
|
monitor_pass
|
|
593
654
|
[ "$once" = 1 ] && exit 0
|
|
594
655
|
|