@plot-pm/board 0.16.2 → 0.16.3
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 +162 -139
- package/package.json +1 -3
- package/plot-agent-manifest.sh +111 -15
- package/plot-agent-monitor.sh +43 -5
- package/plot-approve.sh +224 -73
- package/plot-build-monitor.sh +49 -4
- package/plot-deliver.sh +10 -741
- package/plot-dispatch.sh +263 -91
- package/plot-fleet-scan.sh +746 -62
- package/plot-host.sh +273 -18
- package/plot-monitor-subject.sh +55 -3
- package/plot-plan-meta.sh +155 -139
- package/plot-pr-merged.sh +16 -0
- package/plot-reap.sh +61 -28
- package/plot-state-receipt.sh +12 -2
- package/plot-tmp.sh +31 -3
- package/plot-transcript-quiet.sh +3 -1
- package/plot-worker-state.sh +611 -24
- package/plot-resolve-artifact.sh +0 -423
- package/plot-worker-monitor.sh +0 -705
package/plot-dispatch.sh
CHANGED
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
# PR (open or merged), a host it cannot ask, a live worker (named
|
|
37
37
|
# by pid), real work (a file-changing commit on origin/<br>, or
|
|
38
38
|
# unpushed commits or uncommitted changes on the local desk), and
|
|
39
|
-
# a PLOT-BLOCKED marker. A refusal writes nothing.
|
|
40
|
-
#
|
|
39
|
+
# a PLOT-BLOCKED marker. A refusal writes nothing. A clean,
|
|
40
|
+
# claim-only desk left on <br> is detached at origin/<main>.
|
|
41
41
|
# --migrate move legacy worktrees into the configured `Worktree root:`. An
|
|
42
42
|
# idle worktree (no live worker, no unlanded work) is moved; a
|
|
43
43
|
# busy one is skipped with the reason. Requires a `Worktree root:`
|
|
@@ -187,6 +187,12 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
|
187
187
|
# shellcheck source=plot-worker-state.sh
|
|
188
188
|
. "$script_dir/plot-worker-state.sh"
|
|
189
189
|
|
|
190
|
+
# `desk_dirt` — the reaper's reading of what counts as work on a desk's floor.
|
|
191
|
+
# `--release` asks it before it detaches a desk, so the reaper and the release
|
|
192
|
+
# read one tree the same way. Sourced; it defines one function on load.
|
|
193
|
+
# shellcheck source=plot-desk-dirt.sh
|
|
194
|
+
. "$script_dir/plot-desk-dirt.sh"
|
|
195
|
+
|
|
190
196
|
# The controller receipt, for `spend_action_receipt` below. Sourced from the
|
|
191
197
|
# ONE file that holds both receipt kinds, for the reason that file states: the
|
|
192
198
|
# gate and the owners must agree on where a receipt lives.
|
|
@@ -253,6 +259,21 @@ resolve_wt_root() { # sets globals wt_root, wt_prefix; exits 3 when unaskable
|
|
|
253
259
|
wt_prefix=""
|
|
254
260
|
}
|
|
255
261
|
|
|
262
|
+
# WHICH DESK HOLDS A BRANCH, ASKED OF GIT — never rebuilt from the branch name,
|
|
263
|
+
# the rule every verb here follows. `--stop`, `--restart`, `--release` and the
|
|
264
|
+
# held-branch gate each carried a byte-identical copy of this awk, so they now
|
|
265
|
+
# share one and cannot drift.
|
|
266
|
+
#
|
|
267
|
+
# `git worktree list --porcelain` emits `worktree <path>` then `branch
|
|
268
|
+
# refs/heads/<name>` per entry, so the branch line is matched and the path
|
|
269
|
+
# remembered from the preceding line. A detached worktree has no branch line
|
|
270
|
+
# and never matches, which is right: it holds no branch to hold.
|
|
271
|
+
worktree_holding_branch() { # $1=branch → the first desk holding it, or nothing
|
|
272
|
+
git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$1" '
|
|
273
|
+
/^worktree / { path = substr($0, 10) }
|
|
274
|
+
/^branch / { if (substr($0, 8) == want) { print path; exit } }'
|
|
275
|
+
}
|
|
276
|
+
|
|
256
277
|
dry_run=0
|
|
257
278
|
show_monitors=0
|
|
258
279
|
no_start=0
|
|
@@ -785,8 +806,12 @@ request_brief() { # $1 = branch, $2 = slug → 0 if a command was started
|
|
|
785
806
|
mkdir -p "$(dirname "$log")" 2>/dev/null || true
|
|
786
807
|
# `nohup ... &` inside a subshell, the same detachment `start_worker` uses:
|
|
787
808
|
# this outlives the dispatch run by design, because the fan-out must not block
|
|
788
|
-
# on a `claude -p` session of unknown length. `
|
|
789
|
-
#
|
|
809
|
+
# on a `claude -p` session of unknown length. `set -m` gives the brief the
|
|
810
|
+
# process group `setsid` would give: the backgrounded list becomes a job, the
|
|
811
|
+
# job leads its own group, and `exec` keeps that pid, so a signal to the
|
|
812
|
+
# dispatcher's group does not reach the brief. `setsid` is not used — it does
|
|
813
|
+
# not exist on macOS, where most of this fleet runs. `set -m` goes in this bash
|
|
814
|
+
# subshell and never in the `sh -c` body: dash refuses it without a terminal.
|
|
790
815
|
# THE SETTINGS FILE THIS PROJECT STARTS ITS AGENTS WITH. The `Brief command` is
|
|
791
816
|
# a `claude -p` session like any other agent and inherits every `SessionStart`
|
|
792
817
|
# hook the operator's plugins declare, so it carries the same variable the
|
|
@@ -798,12 +823,12 @@ request_brief() { # $1 = branch, $2 = slug → 0 if a command was started
|
|
|
798
823
|
# which `${VAR:+…}` reads as nothing — so a broken config cannot stop a brief
|
|
799
824
|
# being written.
|
|
800
825
|
_brief_settings="$(bash "$(dirname "${BASH_SOURCE[0]}")/plot-agent-settings.sh" 2>/dev/null || echo "")"
|
|
801
|
-
( cd "$repo_root" \
|
|
826
|
+
( set -m; cd "$repo_root" \
|
|
802
827
|
&& PLOT_UNATTENDED=1 PLOT_PLAN_SLUG="$bslug" PLOT_BRIEF_BRANCH="$branch" \
|
|
803
828
|
PLOT_AGENT_SETTINGS="$_brief_settings" \
|
|
804
|
-
nohup sh -c "$cmd \"\$@\"" plot-brief \
|
|
829
|
+
exec nohup sh -c "$cmd \"\$@\"" plot-brief \
|
|
805
830
|
"$(brief_prompt "$branch" "$bslug")" \
|
|
806
|
-
>"$log" 2>&1 </dev/null & )
|
|
831
|
+
>"$log" 2>&1 </dev/null & ) >/dev/null 2>&1 </dev/null
|
|
807
832
|
echo " asked the \`Brief command\` to write it — log: $log"
|
|
808
833
|
# SAYS WHAT WAS MEASURED, WHICH IS THE START AND NOT THE RESULT. The command
|
|
809
834
|
# is detached and never waited on, so this returns 0 the moment it is spawned
|
|
@@ -1280,11 +1305,17 @@ start_worker() {
|
|
|
1280
1305
|
# measured that day recorded a pid one process above the agent's real parent
|
|
1281
1306
|
# (7357 against 7358, 71953 against 71954, 92947 against 92949).
|
|
1282
1307
|
#
|
|
1283
|
-
#
|
|
1284
|
-
#
|
|
1285
|
-
#
|
|
1286
|
-
#
|
|
1287
|
-
#
|
|
1308
|
+
# THE LAUNCH NOW ENDS IN `exec`, so the forked bash IS the wrapper: `$$`
|
|
1309
|
+
# inside the `sh -c` and the pid bash forked name one process, and no process
|
|
1310
|
+
# carrying the dispatcher's command line survives the launch. The wrapper
|
|
1311
|
+
# reparents to init — measured 2026-10-01, its PPID is 1.
|
|
1312
|
+
#
|
|
1313
|
+
# `exec` is also what gives the caller its streams back. The outer subshell
|
|
1314
|
+
# inherited the dispatcher's stdout, and the inner redirect applies to the
|
|
1315
|
+
# backgrounded job alone, so a reader on a pipe waited for an EOF the forked
|
|
1316
|
+
# bash held for as long as the agent lived: measured 2026-10-01, `--start 1`
|
|
1317
|
+
# took 20.39 s through `| cat` against 1.12 s with `exec`. The subshell is
|
|
1318
|
+
# redirected too, because `exec` alone leaves the wrapper holding that pipe.
|
|
1288
1319
|
#
|
|
1289
1320
|
# So the same rule the agent pid already follows applies here: THE PROCESS
|
|
1290
1321
|
# THAT KNOWS A PID IS THE ONE THAT WRITES IT. The wrapper knows `$$`; no
|
|
@@ -1343,12 +1374,14 @@ start_worker() {
|
|
|
1343
1374
|
#
|
|
1344
1375
|
# EVERY WORKER IS BORN MONITORED, AND THAT IS ENFORCED HERE OR NOWHERE.
|
|
1345
1376
|
#
|
|
1346
|
-
#
|
|
1347
|
-
# the agent: one watches the
|
|
1348
|
-
#
|
|
1349
|
-
#
|
|
1350
|
-
#
|
|
1351
|
-
#
|
|
1377
|
+
# Two monitors start INSIDE the wrapper, as its children, immediately before
|
|
1378
|
+
# the agent: one watches the desk (`plot-agent-monitor.sh`), one watches the
|
|
1379
|
+
# run (`plot-build-monitor.sh`). Each has a subject the other does not and a
|
|
1380
|
+
# cadence it cannot share — minutes on the host, seconds on a run but only
|
|
1381
|
+
# while one is live. The PROCESS is no longer a third monitor's subject:
|
|
1382
|
+
# since `bug/the-loop-reports-idle` the loop's own watcher judges `idle`
|
|
1383
|
+
# (`plot-worker-state.sh`'s `plot_worker_idle_watch_pass`), and the wrapper
|
|
1384
|
+
# itself reports `gone`/`clear` after `wait "$agent"` returns, below.
|
|
1352
1385
|
#
|
|
1353
1386
|
# WHY INSIDE THE WRAPPER RATHER THAN BESIDE IT. The wrapper already outlives
|
|
1354
1387
|
# its agent by construction — it must, or there would be no exit code to
|
|
@@ -1374,8 +1407,8 @@ start_worker() {
|
|
|
1374
1407
|
# sub-millisecond gap after the wrapper starts and before `.plot-worker.pid`
|
|
1375
1408
|
# is written, and a scan landing in it reads `none` — honest. The monitors
|
|
1376
1409
|
# start inside that same window; they must never turn an unwritten pid file
|
|
1377
|
-
# into a
|
|
1378
|
-
# next slice treats an absent pid file as *not yet*.
|
|
1410
|
+
# into a finding of their own, which is why the no-op reads no pid at all and
|
|
1411
|
+
# the next slice treats an absent pid file as *not yet*.
|
|
1379
1412
|
#
|
|
1380
1413
|
# THE PATHS TRAVEL AS ENV VARS, like every other path the wrapper needs. The
|
|
1381
1414
|
# `sh -c` body is single-quoted and a path with spaces would not survive
|
|
@@ -1448,8 +1481,7 @@ start_worker() {
|
|
|
1448
1481
|
fi
|
|
1449
1482
|
fi
|
|
1450
1483
|
|
|
1451
|
-
local
|
|
1452
|
-
[ -x "$script_dir/plot-worker-monitor.sh" ] && worker_monitor="$script_dir/plot-worker-monitor.sh"
|
|
1484
|
+
local agent_monitor='' build_monitor=''
|
|
1453
1485
|
[ -x "$script_dir/plot-agent-monitor.sh" ] && agent_monitor="$script_dir/plot-agent-monitor.sh"
|
|
1454
1486
|
# THE THIRD MONITOR, born the same way and for the same reason. It watches the
|
|
1455
1487
|
# RUN — a Build is its own entity in the spec, so a monitor per entity is the
|
|
@@ -1469,7 +1501,44 @@ start_worker() {
|
|
|
1469
1501
|
# byte-identical to what it was — which is the 100% case, since zero charters
|
|
1470
1502
|
# exist. `PLOT_AGENT` is forwarded too, so the loop's own `resolve_prompt_file`
|
|
1471
1503
|
# asks about the same agent this launch resolved.
|
|
1472
|
-
|
|
1504
|
+
#
|
|
1505
|
+
# `set -m` FIRST: THE AGENT LEADS ITS OWN PROCESS GROUP. With job control on,
|
|
1506
|
+
# the backgrounded list below is a job, the job gets a group of its own, and
|
|
1507
|
+
# `exec` keeps the job's pid, so the wrapper's pid IS its group id. A SIGKILL
|
|
1508
|
+
# to the starter's group — `killGroup` in `run-script.ts` — then misses the
|
|
1509
|
+
# agent, and `--stop` on one agent ends that wrapper's group and no sibling's.
|
|
1510
|
+
# It goes in this bash subshell and never in the `sh -c` body: dash refuses
|
|
1511
|
+
# `set -m` without a terminal.
|
|
1512
|
+
#
|
|
1513
|
+
# THE WRAPPER IGNORES SIGTERM, AND ONLY AFTER ITS CHILDREN EXIST. `--stop`
|
|
1514
|
+
# signals this whole group, and the wrapper must outlive the agent to write
|
|
1515
|
+
# `.plot-worker.exit` (`test/e2e/monitors-attached.test.mjs`). `trap "" TERM`
|
|
1516
|
+
# comes after `agent=$!`, because an ignored signal is inherited by every child
|
|
1517
|
+
# started after it: set earlier, the monitors and the agent would ignore the
|
|
1518
|
+
# stop too. The wrapper then records the exit code and ends on its own.
|
|
1519
|
+
#
|
|
1520
|
+
# THE WRAPPER ALSO REPORTS `gone`/`clear` ITSELF, right after `wait "$agent"`
|
|
1521
|
+
# returns inside the `sh -c` body below. It already knows the instant the
|
|
1522
|
+
# agent ends, because it `wait`s on it — the same fact `workerAlive` reads in
|
|
1523
|
+
# `rules/supervision.ts`. A non-zero exit (124, 137 for SIGKILL, anything
|
|
1524
|
+
# else) appends `gone`; exit 0 appends `clear`, so a finished agent asks for
|
|
1525
|
+
# no restart and no earlier `idle` stays the newest line. The appended line
|
|
1526
|
+
# matches `publish()`'s own shape exactly (`monitor`, `branch`, `worktree`,
|
|
1527
|
+
# `finding`, `since`, `evidence`, `measuredAt`, `monitor: "WorkerMonitor"`),
|
|
1528
|
+
# so the board's reader cannot tell the difference. This replaces the
|
|
1529
|
+
# WorkerMonitor process `bug/the-loop-reports-idle` removed — which published
|
|
1530
|
+
# `gone` on ANY death of the watched pid, including an honest exit 0, where
|
|
1531
|
+
# this reports `clear` instead.
|
|
1532
|
+
#
|
|
1533
|
+
# THE LINE LANDS IN THE WATCHED DESK, ASKED ONCE, RIGHT AFTER `wait "$agent"`
|
|
1534
|
+
# RETURNS. `PLOT_WORKTREE` is fixed at launch, but a hop rewrites the
|
|
1535
|
+
# manifest's `worktree` field before the agent exits — so the wrapper sources
|
|
1536
|
+
# `plot-monitor-subject.sh` off `$PATH` (set further down, before the agent
|
|
1537
|
+
# starts) and asks `plot_watched_desk` for the desk to write into, exactly
|
|
1538
|
+
# once. Reading it once here, rather than threading `PLOT_WORKTREE` through
|
|
1539
|
+
# the publish, is what keeps a killed hopped agent's `gone` line landing where
|
|
1540
|
+
# the loop's watcher (and the board's reader) actually look.
|
|
1541
|
+
( set -m; cd "$wt" && \
|
|
1473
1542
|
# AN `export`, NOT AN ENV PREFIX, AND THE REASON IS A MEASUREMENT. Bash
|
|
1474
1543
|
# recognises an assignment prefix BEFORE it expands parameters, so a
|
|
1475
1544
|
# `${caps:+PLOT_CAPABILITIES="$caps"}` in the prefix below is not an
|
|
@@ -1493,13 +1562,12 @@ start_worker() {
|
|
|
1493
1562
|
PLOT_EFFORT="$launch_effort" \
|
|
1494
1563
|
PLOT_MANIFEST_FILE="$manifest_dir/$session.json" \
|
|
1495
1564
|
PLOT_STAMP_STARTED="$stamp_now" \
|
|
1496
|
-
PLOT_WORKER_MONITOR="$worker_monitor" \
|
|
1497
1565
|
PLOT_AGENT_MONITOR="$agent_monitor" \
|
|
1498
1566
|
PLOT_BUILD_MONITOR="$build_monitor" \
|
|
1499
1567
|
PLOT_EXIT_FILE="$wt/.plot-worker.exit" PLOT_PID_FILE="$wt/.plot-worker.pid" \
|
|
1500
1568
|
PLOT_WRAPPER_PID_FILE="$wt/.plot-worker.wrapper.pid" \
|
|
1501
1569
|
PLOT_SCRIPT_DIR="$script_dir" \
|
|
1502
|
-
nohup sh -c 'printf "%s" "$$" > "$PLOT_WRAPPER_PID_FILE"; wmon=""; amon=""; bmon=""; if [ -n "$
|
|
1570
|
+
exec nohup sh -c 'printf "%s" "$$" > "$PLOT_WRAPPER_PID_FILE"; wmon=""; amon=""; bmon=""; if [ -n "$PLOT_AGENT_MONITOR" ]; then "$PLOT_AGENT_MONITOR" & amon=$!; fi; if [ -n "$PLOT_BUILD_MONITOR" ]; then "$PLOT_BUILD_MONITOR" & bmon=$!; fi; PATH="$PLOT_SCRIPT_DIR:$PATH"; export PATH; ( '"$cmd"' ) & agent=$!; trap "" TERM; printf "%s" "$agent" > "$PLOT_PID_FILE"; if [ -f "$PLOT_MANIFEST_FILE" ]; then awk -v pid="$agent" -v started="$PLOT_STAMP_STARTED" -v wrapper="$$" -v wmon="$wmon" -v amon="$amon" -v bmon="$bmon" '"'"'
|
|
1503
1571
|
BEGIN { relaunch = 0; count = 1; stamped = 0 }
|
|
1504
1572
|
FNR == NR {
|
|
1505
1573
|
if ($0 ~ /^ "pid": "[^"]*",$/) {
|
|
@@ -1532,8 +1600,8 @@ start_worker() {
|
|
|
1532
1600
|
relaunch && $0 ~ /^ "relaunches": [0-9]+,$/ { next }
|
|
1533
1601
|
relaunch && $0 ~ /^ "startedAt": "[^"]*"$/ { print " \"startedAt\": \"" started "\""; next }
|
|
1534
1602
|
{ print }
|
|
1535
|
-
'"'"' "$PLOT_MANIFEST_FILE" "$PLOT_MANIFEST_FILE" > "$PLOT_MANIFEST_FILE.plot-pid-tmp" 2>/dev/null && mv "$PLOT_MANIFEST_FILE.plot-pid-tmp" "$PLOT_MANIFEST_FILE" 2>/dev/null || rm -f "$PLOT_MANIFEST_FILE.plot-pid-tmp"; fi; wait "$agent"; rc=$?; printf "%s" "$rc" > "$PLOT_EXIT_FILE"' \
|
|
1536
|
-
>"$log" 2>&1 </dev/null & )
|
|
1603
|
+
'"'"' "$PLOT_MANIFEST_FILE" "$PLOT_MANIFEST_FILE" > "$PLOT_MANIFEST_FILE.plot-pid-tmp" 2>/dev/null && mv "$PLOT_MANIFEST_FILE.plot-pid-tmp" "$PLOT_MANIFEST_FILE" 2>/dev/null || rm -f "$PLOT_MANIFEST_FILE.plot-pid-tmp"; fi; wait "$agent"; rc=$?; wd="$PLOT_WORKTREE"; . plot-monitor-subject.sh 2>/dev/null && wd=$(plot_watched_desk "$PLOT_MANIFEST_FILE" "$PLOT_WORKTREE"); wf="$wd/.plot-worker.monitor.worker.jsonl"; if [ -n "$wd" ]; then now=$(date -u +%Y-%m-%dT%H:%M:%SZ); if [ "$rc" -ne 0 ]; then f=gone; e="the agent pid $agent exited $rc; the wrapper that started it is unattended"; else f=clear; e="the agent pid $agent exited 0; the worker is finished"; fi; printf "{\"monitor\":\"WorkerMonitor\",\"branch\":\"%s\",\"worktree\":\"%s\",\"finding\":\"%s\",\"since\":\"%s\",\"evidence\":\"%s\",\"measuredAt\":\"%s\"}\n" "$PLOT_BRANCH" "$wd" "$f" "$now" "$e" "$now" >> "$wf" 2>/dev/null; fi; printf "%s" "$rc" > "$PLOT_EXIT_FILE"' \
|
|
1604
|
+
>"$log" 2>&1 </dev/null & ) >/dev/null 2>&1 </dev/null
|
|
1537
1605
|
echo " started worker (log: $log)"
|
|
1538
1606
|
return 0
|
|
1539
1607
|
}
|
|
@@ -1763,9 +1831,7 @@ if [ "$mode" = "stop" ]; then
|
|
|
1763
1831
|
# A refusal that is confidently wrong is worse than one that is terse, so the
|
|
1764
1832
|
# path-guess survives only as the LAST candidate and the refusal below says
|
|
1765
1833
|
# which places were looked in.
|
|
1766
|
-
wt=$(
|
|
1767
|
-
/^worktree / { path = substr($0, 10) }
|
|
1768
|
-
/^branch / { if (substr($0, 8) == want) { print path; exit } }')
|
|
1834
|
+
wt=$(worktree_holding_branch "$stop_branch")
|
|
1769
1835
|
wt_guess="$wt_root_early/$wt_prefix_early$(printf '%s' "$stop_branch" | tr '/' '-')"
|
|
1770
1836
|
[ -n "$wt" ] && [ -d "$wt" ] || wt="$wt_guess"
|
|
1771
1837
|
if [ ! -d "$wt" ]; then
|
|
@@ -1783,10 +1849,14 @@ if [ "$mode" = "stop" ]; then
|
|
|
1783
1849
|
# THE WHOLE PROCESS GROUP, NOT ONE PID. Measured 2026-09-30 (#1084):
|
|
1784
1850
|
# `--stop` signalled the wrapper, the group leader, and the loop, the
|
|
1785
1851
|
# prompt shell, `claude` and its children survived reparented to pid 1;
|
|
1786
|
-
# `kill -TERM -<pgid>` ended all of them.
|
|
1787
|
-
#
|
|
1788
|
-
#
|
|
1789
|
-
#
|
|
1852
|
+
# `kill -TERM -<pgid>` ended all of them. `start_worker` launches under
|
|
1853
|
+
# `set -m`, so an agent leads its own group and that group holds one
|
|
1854
|
+
# wrapper with its monitors, its loop and its `claude`: the signal ends
|
|
1855
|
+
# this agent and nothing else. An agent started before that change still
|
|
1856
|
+
# shares its starter's group with every sibling the same run started and
|
|
1857
|
+
# with the starter itself. The guard below stays for those agents: the
|
|
1858
|
+
# pid is the target when the group cannot be read, or is this script's
|
|
1859
|
+
# own group, because signalling that group would stop the caller with it.
|
|
1790
1860
|
stop_pgid=$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ')
|
|
1791
1861
|
stop_own_pgid=$(ps -o pgid= -p "$$" 2>/dev/null | tr -d ' ')
|
|
1792
1862
|
stop_target="$pid"
|
|
@@ -1847,9 +1917,7 @@ if [ "$mode" = "restart" ]; then
|
|
|
1847
1917
|
# wrong. It matters more here than anywhere: the population this verb serves
|
|
1848
1918
|
# includes the worktree a person made by hand after the tool had no verb for
|
|
1849
1919
|
# them, and a hand-made worktree rarely follows dispatch's naming.
|
|
1850
|
-
restart_wt=$(
|
|
1851
|
-
/^worktree / { path = substr($0, 10) }
|
|
1852
|
-
/^branch / { if (substr($0, 8) == want) { print path; exit } }')
|
|
1920
|
+
restart_wt=$(worktree_holding_branch "$restart_branch")
|
|
1853
1921
|
if [ -z "$restart_wt" ] || [ ! -d "$restart_wt" ]; then
|
|
1854
1922
|
echo "plot-dispatch: no worktree holds '$restart_branch' — nothing to restart." >&2
|
|
1855
1923
|
echo " --restart hands an EXISTING checkout to a new worker; it creates none." >&2
|
|
@@ -1893,7 +1961,11 @@ if [ "$mode" = "restart" ]; then
|
|
|
1893
1961
|
# resets or stashes: a restart that discards that is worse than the missing
|
|
1894
1962
|
# affordance, because it looks like a supported operation. The new worker's
|
|
1895
1963
|
# brief already tells it to commit and push before verifying.
|
|
1896
|
-
|
|
1964
|
+
# A REBUILT BUNDLE ALONE IS NOT "UNCOMMITTED WORK". `main` rebuilds and
|
|
1965
|
+
# pushes every generated board bundle (`bug/main-builds-its-bundles`, #1249),
|
|
1966
|
+
# so a desk that locally rebuilt one to test says nothing an agent left on
|
|
1967
|
+
# the floor — excused path by path, same reading `desk_dirt` applies.
|
|
1968
|
+
if [ -n "$(git -C "$restart_wt" status --porcelain </dev/null 2>/dev/null | exclude_bundle_paths "$restart_wt")" ]; then
|
|
1897
1969
|
echo " uncommitted work in the tree is kept — the new worker inherits it"
|
|
1898
1970
|
fi
|
|
1899
1971
|
|
|
@@ -2001,35 +2073,49 @@ if [ "$mode" = "release" ]; then
|
|
|
2001
2073
|
# THE DESK, ASKED OF GIT — never rebuilt from the branch name, the rule every
|
|
2002
2074
|
# other verb here follows. A manifest naming this branch may name a desk git
|
|
2003
2075
|
# does not list (removed by hand), so its `worktree` is the fallback.
|
|
2004
|
-
release_wt=$(
|
|
2005
|
-
/^worktree / { path = substr($0, 10) }
|
|
2006
|
-
/^branch / { if (substr($0, 8) == want) { print path; exit } }')
|
|
2076
|
+
release_wt=$(worktree_holding_branch "$br")
|
|
2007
2077
|
registry_dir=$(agent_registry_dir "$repo_root_early")
|
|
2008
2078
|
named_manifests=()
|
|
2009
2079
|
if [ -d "$registry_dir" ]; then
|
|
2010
2080
|
for m in "$registry_dir"/*.json; do
|
|
2011
2081
|
[ -f "$m" ] || continue
|
|
2012
|
-
m_branch=$(
|
|
2013
|
-
try {
|
|
2014
|
-
const m = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
|
2015
|
-
process.stdout.write(typeof m.branch === "string" ? m.branch : "");
|
|
2016
|
-
} catch { process.stdout.write(""); }
|
|
2017
|
-
' "$m" 2>/dev/null)
|
|
2082
|
+
m_branch=$(manifest_string "$m" branch) || m_branch=""
|
|
2018
2083
|
[ "$m_branch" = "$br" ] || continue
|
|
2019
2084
|
named_manifests+=("$m")
|
|
2020
2085
|
if [ -z "$release_wt" ]; then
|
|
2021
|
-
m_wt=$(
|
|
2022
|
-
try {
|
|
2023
|
-
const m = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
|
2024
|
-
process.stdout.write(typeof m.worktree === "string" ? m.worktree : "");
|
|
2025
|
-
} catch { process.stdout.write(""); }
|
|
2026
|
-
' "$m" 2>/dev/null)
|
|
2086
|
+
m_wt=$(manifest_string "$m" worktree) || m_wt=""
|
|
2027
2087
|
[ -n "$m_wt" ] && [ -d "$m_wt" ] && release_wt="$m_wt"
|
|
2028
2088
|
fi
|
|
2029
2089
|
done
|
|
2030
2090
|
fi
|
|
2031
2091
|
[ -n "$release_wt" ] && [ -d "$release_wt" ] || release_wt=""
|
|
2032
2092
|
|
|
2093
|
+
# A LIVE AGENT HOLDS THE BRANCH IN A DESK OTHER THAN `release_wt` — asked
|
|
2094
|
+
# through the domain, and asked BEFORE refusal 2 below for the reason that
|
|
2095
|
+
# refusal cannot cover: an agent JUST HANDED the branch has not checked it
|
|
2096
|
+
# out, so `release_wt` is empty and refusal 2's desk-bound check does not
|
|
2097
|
+
# run. This asks each manifest NAMING the branch whether ITS OWN desk is
|
|
2098
|
+
# alive, whatever branch that desk currently holds — `claimAnswer` answers
|
|
2099
|
+
# `held-by-agent` from `holders` alone, so the ref and the commit log are not
|
|
2100
|
+
# needed to ask this question.
|
|
2101
|
+
#
|
|
2102
|
+
# `release_wt` ITSELF IS EXCLUDED FROM `holders`, so this never duplicates
|
|
2103
|
+
# refusal 2's case: a manifest whose own desk IS `release_wt` is the live
|
|
2104
|
+
# worker refusal 2 already names with its pid, and this must not pre-empt it
|
|
2105
|
+
# with a different message for the same desk.
|
|
2106
|
+
#
|
|
2107
|
+
# NOT NUMBERED WITH THE FOUR BELOW: the plan keeps their order and wording
|
|
2108
|
+
# unchanged, and this is the one new refusal, asked through the domain.
|
|
2109
|
+
holder_lines=$(live_holders_of_branch "$registry_dir" "$br" "" "$release_wt")
|
|
2110
|
+
answer=$(claim_answer "$script_dir/board/plot-claim-answer.mjs" unknown "" \
|
|
2111
|
+
"$(printf '%s\n' "$holder_lines" | cut -f1)") || answer=""
|
|
2112
|
+
if [ "$answer" = "held-by-agent" ]; then
|
|
2113
|
+
echo "plot-dispatch: $br is held by a live agent — refusing." >&2
|
|
2114
|
+
printf '%s\n' "$holder_lines" | while IFS=$'\t' read -r h_session h_wt; do echo " $h_session (desk $h_wt)" >&2; done
|
|
2115
|
+
echo " Nothing was written." >&2
|
|
2116
|
+
exit 1
|
|
2117
|
+
fi
|
|
2118
|
+
|
|
2033
2119
|
# 2. A LIVE WORKER — the measurement `--restart` makes, through the shared
|
|
2034
2120
|
# classifier, never `pgrep` by name. A live pid means somebody is working,
|
|
2035
2121
|
# and the one case where a claim is not abandoned.
|
|
@@ -2133,11 +2219,76 @@ if [ "$mode" = "release" ]; then
|
|
|
2133
2219
|
else
|
|
2134
2220
|
echo " origin/$br does not exist — only the assignment needed releasing"
|
|
2135
2221
|
fi
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2222
|
+
# THE DESK THAT STILL HOLDS THE BRANCH IS DETACHED. Measured 2026-10-02: a
|
|
2223
|
+
# released desk kept `$br` with only its empty claim commit. The next agent
|
|
2224
|
+
# handed the slice asked `checkoutYield` whether that checkout yields; with
|
|
2225
|
+
# the remote ref deleted it has no upstream, `unpushedCommits` reads
|
|
2226
|
+
# `unknown`, and the rule keeps it. Fifteen agents in a row wrote
|
|
2227
|
+
# PLOT-BLOCKED for two released slices between 13:46 and 14:59.
|
|
2228
|
+
#
|
|
2229
|
+
# Detaching at origin/<main> is the state a free agent's desk already has, so
|
|
2230
|
+
# the desk stays usable and the branch is free for the next checkout. The
|
|
2231
|
+
# worktree itself stays: removing it is the reaper's licence, not this one.
|
|
2232
|
+
#
|
|
2233
|
+
# FOUR READINGS, each asked again rather than inferred from the refusals
|
|
2234
|
+
# above, so a change to those refusals cannot silently widen this write:
|
|
2235
|
+
# - the desk's HEAD is `refs/heads/$br` (a manifest's `worktree` may name a
|
|
2236
|
+
# desk on another branch);
|
|
2237
|
+
# - no live worker, through the shared classifier;
|
|
2238
|
+
# - no file-changing commit beyond origin/<main> — the count step 3 takes,
|
|
2239
|
+
# with origin/<main> as the base because the claim ref is now gone;
|
|
2240
|
+
# - a clean tree by `desk_dirt`, the reaper's reading.
|
|
2241
|
+
# Any reading that fails keeps today's behaviour and names the reason.
|
|
2242
|
+
detached_desks=0
|
|
2243
|
+
release_holders=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$br" '
|
|
2244
|
+
/^worktree / { path = substr($0, 10) }
|
|
2245
|
+
/^branch / { if (substr($0, 8) == want) print path }')
|
|
2246
|
+
[ -z "$release_holders" ] && [ -n "$release_wt" ] && release_holders="$release_wt"
|
|
2247
|
+
while IFS= read -r desk; do
|
|
2248
|
+
[ -n "$desk" ] || continue
|
|
2249
|
+
keep_reason=""
|
|
2250
|
+
if [ ! -d "$desk" ]; then
|
|
2251
|
+
keep_reason="the directory does not exist"
|
|
2252
|
+
elif [ "$(git -C "$desk" symbolic-ref -q HEAD </dev/null 2>/dev/null)" != "refs/heads/$br" ]; then
|
|
2253
|
+
keep_reason="its HEAD is not refs/heads/$br"
|
|
2254
|
+
else
|
|
2255
|
+
case "$(plot_worker_state "$desk" "" | cut -f1)" in
|
|
2256
|
+
running) keep_reason="a worker is alive in it" ;;
|
|
2257
|
+
esac
|
|
2258
|
+
fi
|
|
2259
|
+
if [ -z "$keep_reason" ]; then
|
|
2260
|
+
desk_work=$(git -C "$desk" rev-list --count "refs/remotes/origin/$MAIN..HEAD" -- . </dev/null 2>/dev/null) || desk_work=""
|
|
2261
|
+
case "$desk_work" in
|
|
2262
|
+
0) ;;
|
|
2263
|
+
''|*[!0-9]*) keep_reason="its commits beyond origin/$MAIN could not be counted" ;;
|
|
2264
|
+
*) keep_reason="$desk_work commit(s) beyond origin/$MAIN change files" ;;
|
|
2265
|
+
esac
|
|
2266
|
+
fi
|
|
2267
|
+
if [ -z "$keep_reason" ]; then
|
|
2268
|
+
desk_floor=$(desk_dirt "$desk")
|
|
2269
|
+
[ -n "$desk_floor" ] && keep_reason="uncommitted changes: $(printf '%s\n' "$desk_floor" | cut -c4- | tr '\n' ' ')"
|
|
2270
|
+
fi
|
|
2271
|
+
if [ -z "$keep_reason" ]; then
|
|
2272
|
+
if ! git -C "$desk" checkout -q --detach "refs/remotes/origin/$MAIN" </dev/null 2>/dev/null; then
|
|
2273
|
+
keep_reason="git refused to detach it at origin/$MAIN"
|
|
2274
|
+
fi
|
|
2275
|
+
fi
|
|
2276
|
+
if [ -n "$keep_reason" ]; then
|
|
2277
|
+
echo " the desk at $desk still holds $br and is left as it is: $keep_reason"
|
|
2278
|
+
continue
|
|
2279
|
+
fi
|
|
2280
|
+
detached_desks=$((detached_desks + 1))
|
|
2281
|
+
# Only empty claim commits are lost, and the remote ref is already gone.
|
|
2282
|
+
if git branch -q -D "$br" </dev/null 2>/dev/null; then
|
|
2283
|
+
echo " detached the desk at $desk at origin/$MAIN and deleted the local branch $br"
|
|
2284
|
+
else
|
|
2285
|
+
echo " detached the desk at $desk at origin/$MAIN; the local branch $br could not be deleted"
|
|
2286
|
+
fi
|
|
2287
|
+
done <<EOF
|
|
2288
|
+
$release_holders
|
|
2289
|
+
EOF
|
|
2139
2290
|
echo "released $br — the slice returns to the queue"
|
|
2140
|
-
echo "summary: released=1 manifests=$released_manifests ref=$([ "$ref_present" = 1 ] && echo deleted || echo absent)"
|
|
2291
|
+
echo "summary: released=1 manifests=$released_manifests ref=$([ "$ref_present" = 1 ] && echo deleted || echo absent) detached=$detached_desks"
|
|
2141
2292
|
exit 0
|
|
2142
2293
|
fi
|
|
2143
2294
|
|
|
@@ -2926,13 +3077,20 @@ prereq_answer() { # $1=prerequisite branch → merged|unmerged|none|unreachable
|
|
|
2926
3077
|
}
|
|
2927
3078
|
|
|
2928
3079
|
# Every branch the plan annotates `waits:`, with what it waits on — read from
|
|
2929
|
-
# the same blob, in the plan's own order, one line of `branch<TAB>prerequisite
|
|
3080
|
+
# the same blob, in the plan's own order, one line of `branch<TAB>prerequisite`
|
|
3081
|
+
# PER PREREQUISITE: a branch naming two prints two lines, in the plan's order.
|
|
3082
|
+
#
|
|
3083
|
+
# `waits_on` IS A LIST (`["bug/a","bug/b"]`), never the one-name string this
|
|
3084
|
+
# used to match. The old `"waits_on":"[^"]*"` pattern does not match an array,
|
|
3085
|
+
# so an unmigrated reader here would read every slice as waiting on nothing and
|
|
3086
|
+
# dispatch a held slice — the defect this plan exists to remove, reintroduced
|
|
3087
|
+
# by the parser's own fix had this not moved with it (#1153).
|
|
2930
3088
|
#
|
|
2931
3089
|
# NON-DEFERRED ONLY. `deferred:` is a JUDGEMENT — somebody gave the branch up —
|
|
2932
3090
|
# and it outranks a wait for the same reason the scan lets it: a branch nobody
|
|
2933
3091
|
# will start does not need to be told what it is waiting for. The two
|
|
2934
3092
|
# annotations sit on one line and neither reads the other's value.
|
|
2935
|
-
waits_pairs() { # → branch<TAB>prerequisite, one per
|
|
3093
|
+
waits_pairs() { # → branch<TAB>prerequisite, one line per prerequisite
|
|
2936
3094
|
printf '%s' "$gate_meta" | awk '
|
|
2937
3095
|
{
|
|
2938
3096
|
n = split($0, parts, /\{"branch":"/)
|
|
@@ -2940,9 +3098,11 @@ waits_pairs() { # → branch<TAB>prerequisite, one per annotated branch
|
|
|
2940
3098
|
rec = parts[i]
|
|
2941
3099
|
br = rec; sub(/".*$/, "", br)
|
|
2942
3100
|
if (rec ~ /"deferred":true/) continue
|
|
2943
|
-
if (match(rec, /"waits_on"
|
|
2944
|
-
|
|
2945
|
-
|
|
3101
|
+
if (match(rec, /"waits_on":\[[^]]*\]/)) {
|
|
3102
|
+
list = substr(rec, RSTART + 12, RLENGTH - 13)
|
|
3103
|
+
gsub(/"/, "", list)
|
|
3104
|
+
m = split(list, names, ",")
|
|
3105
|
+
for (j = 1; j <= m; j++) if (names[j] != "") print br "\t" names[j]
|
|
2946
3106
|
}
|
|
2947
3107
|
}
|
|
2948
3108
|
}'
|
|
@@ -2995,12 +3155,9 @@ is_waits_held() {
|
|
|
2995
3155
|
# override and the scan remains the only thing that decides them. The branch
|
|
2996
3156
|
# still passes every gate the loops apply after it: `held_worktree`, the claim
|
|
2997
3157
|
# race, and the brief.
|
|
3158
|
+
# The array is read directly as `${waits_freed[@]}`; the predicate wrapper
|
|
3159
|
+
# `is_waits_held` has beside it was never called and is gone.
|
|
2998
3160
|
declare -a waits_freed=()
|
|
2999
|
-
is_waits_freed() {
|
|
3000
|
-
local x
|
|
3001
|
-
for x in ${waits_freed[@]+"${waits_freed[@]}"}; do [ "$x" = "$1" ] && return 0; done
|
|
3002
|
-
return 1
|
|
3003
|
-
}
|
|
3004
3161
|
|
|
3005
3162
|
# Runs the preflight: prints its refusals, fills `waits_held`, and adds what it
|
|
3006
3163
|
# withheld to `n_skipped`.
|
|
@@ -3024,13 +3181,22 @@ run_waits_preflight() { # → prints refusals; fills waits_held, adds to n_skipp
|
|
|
3024
3181
|
none) held=blocked ;;
|
|
3025
3182
|
*) held=waiting ;;
|
|
3026
3183
|
esac
|
|
3027
|
-
# `--allow-waiting` SAYS SO ON THE LINE IT OVERRIDES
|
|
3028
|
-
# see in the output is an override nobody can
|
|
3184
|
+
# `--allow-waiting` SAYS SO ON THE LINE IT OVERRIDES, ONCE PER PREREQUISITE
|
|
3185
|
+
# — an override nobody can see in the output is an override nobody can
|
|
3186
|
+
# audit, and a branch with two unmerged prerequisites names both so the
|
|
3187
|
+
# audit trail is complete.
|
|
3029
3188
|
if [ "$allow_waiting" = 1 ]; then
|
|
3030
3189
|
echo "$br waits on $prereq ($held) — dispatching anyway (--allow-waiting)"
|
|
3031
3190
|
waits_freed+=("$br")
|
|
3032
3191
|
continue
|
|
3033
3192
|
fi
|
|
3193
|
+
# ONE REFUSAL PER BRANCH, NOT PER PREREQUISITE. `waits_pairs` prints one
|
|
3194
|
+
# line per unmerged prerequisite in the plan's order, so a branch with
|
|
3195
|
+
# `[merged, unmerged]` reaches this loop once — but `[unmerged, unmerged]`
|
|
3196
|
+
# would reach it twice, and a branch already held names only the FIRST
|
|
3197
|
+
# prerequisite that held it: `is_waits_held` guards both the second refusal
|
|
3198
|
+
# line and the double-count in `n_skipped`.
|
|
3199
|
+
if is_waits_held "$br"; then continue; fi
|
|
3034
3200
|
waits_held+=("$br")
|
|
3035
3201
|
n_skipped=$((n_skipped + 1))
|
|
3036
3202
|
if [ "$held" = "blocked" ]; then
|
|
@@ -3135,21 +3301,6 @@ read_parallel_agents_cap() {
|
|
|
3135
3301
|
[ -n "$cap" ] && echo "$cap" || echo 3
|
|
3136
3302
|
}
|
|
3137
3303
|
|
|
3138
|
-
# Count workers in live states (running or waiting) across all fleet worktrees.
|
|
3139
|
-
# These are the slots that count against the cap.
|
|
3140
|
-
count_live_workers() {
|
|
3141
|
-
local n=0 wt br st
|
|
3142
|
-
for wt in "$wt_root"/"$wt_prefix"*; do
|
|
3143
|
-
[ -d "$wt" ] || continue
|
|
3144
|
-
br=$(git -C "$wt" branch --show-current 2>/dev/null || echo "?")
|
|
3145
|
-
st=$(worker_state "$wt" "$br")
|
|
3146
|
-
case "$st" in
|
|
3147
|
-
running*|waiting*) n=$((n + 1)) ;;
|
|
3148
|
-
esac
|
|
3149
|
-
done
|
|
3150
|
-
echo "$n"
|
|
3151
|
-
}
|
|
3152
|
-
|
|
3153
3304
|
# Get the branches currently occupying slots (for the warning message).
|
|
3154
3305
|
live_worker_branches() {
|
|
3155
3306
|
local wt br st
|
|
@@ -3163,6 +3314,13 @@ live_worker_branches() {
|
|
|
3163
3314
|
done
|
|
3164
3315
|
}
|
|
3165
3316
|
|
|
3317
|
+
# Count workers in live states (running or waiting) across all fleet worktrees.
|
|
3318
|
+
# These are the slots that count against the cap — the branches above, counted,
|
|
3319
|
+
# so the two readings cannot disagree about what occupies a slot.
|
|
3320
|
+
count_live_workers() {
|
|
3321
|
+
live_worker_branches | grep -c . || true
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3166
3324
|
# Update the parallel-agents cap in the fleet controls file.
|
|
3167
3325
|
# Creates the file (and directory) if needed, preserving autoDispatch if present.
|
|
3168
3326
|
update_parallel_agents_cap() { # $1 = new cap
|
|
@@ -3512,10 +3670,22 @@ committed_files() { # $1=branch → paths, one per line
|
|
|
3512
3670
|
|
|
3513
3671
|
# Files a branch holds only in its worktree — no ref carries these, so they are
|
|
3514
3672
|
# invisible to every ref-based check including this script's own.
|
|
3673
|
+
#
|
|
3674
|
+
# A REBUILT BUNDLE IS EXCLUDED, path by path, same as `desk_dirt`: `main`
|
|
3675
|
+
# rebuilds and pushes every generated board bundle
|
|
3676
|
+
# (`bug/main-builds-its-bundles`, #1249), so it is never a branch's own
|
|
3677
|
+
# uncommitted work. The held-worktree gate below reads this list to decide
|
|
3678
|
+
# whether a worktree counts as held, and a rebuilt-but-unchanged bundle must
|
|
3679
|
+
# not be the reason a free worktree reads occupied.
|
|
3515
3680
|
uncommitted_files() { # $1=worktree → paths, one per line
|
|
3516
|
-
local wt="$1"
|
|
3681
|
+
local wt="$1" bundles
|
|
3517
3682
|
[ -d "$wt" ] || return 0
|
|
3518
|
-
|
|
3683
|
+
bundles=$(bundle_paths "$wt")
|
|
3684
|
+
git -C "$wt" status --porcelain </dev/null 2>/dev/null | awk -v bundles="$bundles" '
|
|
3685
|
+
BEGIN {
|
|
3686
|
+
n = split(bundles, arr, "\n")
|
|
3687
|
+
for (i = 1; i <= n; i++) if (arr[i] != "") excluded[arr[i]] = 1
|
|
3688
|
+
}
|
|
3519
3689
|
{
|
|
3520
3690
|
# Porcelain v1: XY then a space then the path. A rename prints
|
|
3521
3691
|
# "old -> new"; the new path is the one on disk.
|
|
@@ -3523,7 +3693,7 @@ uncommitted_files() { # $1=worktree → paths, one per line
|
|
|
3523
3693
|
i = index(line, " -> ")
|
|
3524
3694
|
if (i > 0) line = substr(line, i + 4)
|
|
3525
3695
|
gsub(/^"|"$/, "", line)
|
|
3526
|
-
if (line != "") print line
|
|
3696
|
+
if (line != "" && !(line in excluded)) print line
|
|
3527
3697
|
}'
|
|
3528
3698
|
}
|
|
3529
3699
|
|
|
@@ -3631,9 +3801,7 @@ held_worktree() { # $1=branch → prints the worktree path when held, else nothi
|
|
|
3631
3801
|
# refs/heads/<name>` per entry, so the branch line is matched and the path
|
|
3632
3802
|
# remembered from the preceding line. A detached worktree has no branch line
|
|
3633
3803
|
# and never matches, which is right: it holds no branch to hold.
|
|
3634
|
-
wt=$(
|
|
3635
|
-
/^worktree / { path = substr($0, 10) }
|
|
3636
|
-
/^branch / { if (substr($0, 8) == want) { print path; exit } }')
|
|
3804
|
+
wt=$(worktree_holding_branch "$br")
|
|
3637
3805
|
[ -n "$wt" ] || return 1
|
|
3638
3806
|
# A registered worktree whose directory is gone (removed by hand, not via
|
|
3639
3807
|
# `git worktree remove`) holds nobody. `status` cannot be read there anyway.
|
|
@@ -3775,7 +3943,11 @@ IN_FLIGHT_MAX_BRANCHES=8
|
|
|
3775
3943
|
report_monitors() { # $1=worktree
|
|
3776
3944
|
[ "$show_monitors" = 1 ] || return 0
|
|
3777
3945
|
local wt="$1" m
|
|
3778
|
-
|
|
3946
|
+
# ONLY `agent` IS A SCRIPT ANY MORE. `bug/the-loop-reports-idle` removed
|
|
3947
|
+
# `plot-worker-monitor.sh`: the PROCESS no longer has a monitor to report —
|
|
3948
|
+
# the loop's own watcher judges `idle` and the wrapper itself reports
|
|
3949
|
+
# `gone`/`clear`, neither of which this dry run can name as a script path.
|
|
3950
|
+
for m in agent; do
|
|
3779
3951
|
local script="$script_dir/plot-$m-monitor.sh"
|
|
3780
3952
|
if [ -x "$script" ]; then
|
|
3781
3953
|
echo " would attach: $script → $wt"
|