@plot-pm/board 0.16.2 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/board-server.mjs +258 -146
- package/package.json +3 -6
- package/plot-agent-manifest.sh +76 -15
- package/plot-agent-monitor.sh +43 -5
- package/plot-approve.sh +224 -73
- package/plot-config.sh +14 -14
- package/plot-deliver.sh +10 -741
- package/plot-dispatch.sh +282 -107
- package/plot-fleet-scan.sh +867 -170
- package/plot-host.sh +310 -76
- package/plot-monitor-subject.sh +61 -10
- 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-worker-state.sh +458 -24
- package/plot-build-monitor.sh +0 -435
- package/plot-resolve-artifact.sh +0 -423
- package/plot-transcript-quiet.sh +0 -170
- 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,12 @@ start_worker() {
|
|
|
1343
1374
|
#
|
|
1344
1375
|
# EVERY WORKER IS BORN MONITORED, AND THAT IS ENFORCED HERE OR NOWHERE.
|
|
1345
1376
|
#
|
|
1346
|
-
#
|
|
1347
|
-
# the agent
|
|
1348
|
-
#
|
|
1349
|
-
# (`plot-
|
|
1350
|
-
#
|
|
1351
|
-
#
|
|
1377
|
+
# The AgentMonitor starts INSIDE the wrapper, as its child, immediately
|
|
1378
|
+
# before the agent, watching the desk. The PROCESS is no longer a monitor's
|
|
1379
|
+
# subject: since `bug/the-loop-reports-idle` the loop's own watcher judges `idle`
|
|
1380
|
+
# (`board/plot-worker-loop.mjs`'s `idleVerdict`, built from the domain's
|
|
1381
|
+
# `idleNow`), and the wrapper itself reports `gone`/`clear` after
|
|
1382
|
+
# `wait "$agent"` returns, below.
|
|
1352
1383
|
#
|
|
1353
1384
|
# WHY INSIDE THE WRAPPER RATHER THAN BESIDE IT. The wrapper already outlives
|
|
1354
1385
|
# its agent by construction — it must, or there would be no exit code to
|
|
@@ -1374,8 +1405,8 @@ start_worker() {
|
|
|
1374
1405
|
# sub-millisecond gap after the wrapper starts and before `.plot-worker.pid`
|
|
1375
1406
|
# is written, and a scan landing in it reads `none` — honest. The monitors
|
|
1376
1407
|
# 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*.
|
|
1408
|
+
# into a finding of their own, which is why the no-op reads no pid at all and
|
|
1409
|
+
# the next slice treats an absent pid file as *not yet*.
|
|
1379
1410
|
#
|
|
1380
1411
|
# THE PATHS TRAVEL AS ENV VARS, like every other path the wrapper needs. The
|
|
1381
1412
|
# `sh -c` body is single-quoted and a path with spaces would not survive
|
|
@@ -1448,15 +1479,8 @@ start_worker() {
|
|
|
1448
1479
|
fi
|
|
1449
1480
|
fi
|
|
1450
1481
|
|
|
1451
|
-
local
|
|
1452
|
-
[ -x "$script_dir/plot-worker-monitor.sh" ] && worker_monitor="$script_dir/plot-worker-monitor.sh"
|
|
1482
|
+
local agent_monitor=''
|
|
1453
1483
|
[ -x "$script_dir/plot-agent-monitor.sh" ] && agent_monitor="$script_dir/plot-agent-monitor.sh"
|
|
1454
|
-
# THE THIRD MONITOR, born the same way and for the same reason. It watches the
|
|
1455
|
-
# RUN — a Build is its own entity in the spec, so a monitor per entity is the
|
|
1456
|
-
# pattern rather than an exception to it. Its cadence is the WorkerMonitor's
|
|
1457
|
-
# 30 s rather than the AgentMonitor's 300 s, and it can afford that against a
|
|
1458
|
-
# HOST because it asks nothing while no run is live.
|
|
1459
|
-
[ -x "$script_dir/plot-build-monitor.sh" ] && build_monitor="$script_dir/plot-build-monitor.sh"
|
|
1460
1484
|
local stamp_now
|
|
1461
1485
|
stamp_now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
1462
1486
|
# THE THREE NAMES THE CHARTER DECLARED, and they travel as env vars for the
|
|
@@ -1469,7 +1493,44 @@ start_worker() {
|
|
|
1469
1493
|
# byte-identical to what it was — which is the 100% case, since zero charters
|
|
1470
1494
|
# exist. `PLOT_AGENT` is forwarded too, so the loop's own `resolve_prompt_file`
|
|
1471
1495
|
# asks about the same agent this launch resolved.
|
|
1472
|
-
|
|
1496
|
+
#
|
|
1497
|
+
# `set -m` FIRST: THE AGENT LEADS ITS OWN PROCESS GROUP. With job control on,
|
|
1498
|
+
# the backgrounded list below is a job, the job gets a group of its own, and
|
|
1499
|
+
# `exec` keeps the job's pid, so the wrapper's pid IS its group id. A SIGKILL
|
|
1500
|
+
# to the starter's group — `killGroup` in `run-script.ts` — then misses the
|
|
1501
|
+
# agent, and `--stop` on one agent ends that wrapper's group and no sibling's.
|
|
1502
|
+
# It goes in this bash subshell and never in the `sh -c` body: dash refuses
|
|
1503
|
+
# `set -m` without a terminal.
|
|
1504
|
+
#
|
|
1505
|
+
# THE WRAPPER IGNORES SIGTERM, AND ONLY AFTER ITS CHILDREN EXIST. `--stop`
|
|
1506
|
+
# signals this whole group, and the wrapper must outlive the agent to write
|
|
1507
|
+
# `.plot-worker.exit` (`test/e2e/monitors-attached.test.mjs`). `trap "" TERM`
|
|
1508
|
+
# comes after `agent=$!`, because an ignored signal is inherited by every child
|
|
1509
|
+
# started after it: set earlier, the monitors and the agent would ignore the
|
|
1510
|
+
# stop too. The wrapper then records the exit code and ends on its own.
|
|
1511
|
+
#
|
|
1512
|
+
# THE WRAPPER ALSO REPORTS `gone`/`clear` ITSELF, right after `wait "$agent"`
|
|
1513
|
+
# returns inside the `sh -c` body below. It already knows the instant the
|
|
1514
|
+
# agent ends, because it `wait`s on it — the same fact `workerAlive` reads in
|
|
1515
|
+
# `rules/supervision.ts`. A non-zero exit (124, 137 for SIGKILL, anything
|
|
1516
|
+
# else) appends `gone`; exit 0 appends `clear`, so a finished agent asks for
|
|
1517
|
+
# no restart and no earlier `idle` stays the newest line. The appended line
|
|
1518
|
+
# matches `publish()`'s own shape exactly (`monitor`, `branch`, `worktree`,
|
|
1519
|
+
# `finding`, `since`, `evidence`, `measuredAt`, `monitor: "WorkerMonitor"`),
|
|
1520
|
+
# so the board's reader cannot tell the difference. This replaces the
|
|
1521
|
+
# WorkerMonitor process `bug/the-loop-reports-idle` removed — which published
|
|
1522
|
+
# `gone` on ANY death of the watched pid, including an honest exit 0, where
|
|
1523
|
+
# this reports `clear` instead.
|
|
1524
|
+
#
|
|
1525
|
+
# THE LINE LANDS IN THE WATCHED DESK, ASKED ONCE, RIGHT AFTER `wait "$agent"`
|
|
1526
|
+
# RETURNS. `PLOT_WORKTREE` is fixed at launch, but a hop rewrites the
|
|
1527
|
+
# manifest's `worktree` field before the agent exits — so the wrapper sources
|
|
1528
|
+
# `plot-monitor-subject.sh` off `$PATH` (set further down, before the agent
|
|
1529
|
+
# starts) and asks `plot_watched_desk` for the desk to write into, exactly
|
|
1530
|
+
# once. Reading it once here, rather than threading `PLOT_WORKTREE` through
|
|
1531
|
+
# the publish, is what keeps a killed hopped agent's `gone` line landing where
|
|
1532
|
+
# the loop's watcher (and the board's reader) actually look.
|
|
1533
|
+
( set -m; cd "$wt" && \
|
|
1473
1534
|
# AN `export`, NOT AN ENV PREFIX, AND THE REASON IS A MEASUREMENT. Bash
|
|
1474
1535
|
# recognises an assignment prefix BEFORE it expands parameters, so a
|
|
1475
1536
|
# `${caps:+PLOT_CAPABILITIES="$caps"}` in the prefix below is not an
|
|
@@ -1493,13 +1554,11 @@ start_worker() {
|
|
|
1493
1554
|
PLOT_EFFORT="$launch_effort" \
|
|
1494
1555
|
PLOT_MANIFEST_FILE="$manifest_dir/$session.json" \
|
|
1495
1556
|
PLOT_STAMP_STARTED="$stamp_now" \
|
|
1496
|
-
PLOT_WORKER_MONITOR="$worker_monitor" \
|
|
1497
1557
|
PLOT_AGENT_MONITOR="$agent_monitor" \
|
|
1498
|
-
PLOT_BUILD_MONITOR="$build_monitor" \
|
|
1499
1558
|
PLOT_EXIT_FILE="$wt/.plot-worker.exit" PLOT_PID_FILE="$wt/.plot-worker.pid" \
|
|
1500
1559
|
PLOT_WRAPPER_PID_FILE="$wt/.plot-worker.wrapper.pid" \
|
|
1501
1560
|
PLOT_SCRIPT_DIR="$script_dir" \
|
|
1502
|
-
nohup sh -c 'printf "%s" "$$" > "$PLOT_WRAPPER_PID_FILE"; wmon=""; amon="";
|
|
1561
|
+
exec nohup sh -c 'printf "%s" "$$" > "$PLOT_WRAPPER_PID_FILE"; wmon=""; amon=""; if [ -n "$PLOT_AGENT_MONITOR" ]; then "$PLOT_AGENT_MONITOR" & amon=$!; 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" '"'"'
|
|
1503
1562
|
BEGIN { relaunch = 0; count = 1; stamped = 0 }
|
|
1504
1563
|
FNR == NR {
|
|
1505
1564
|
if ($0 ~ /^ "pid": "[^"]*",$/) {
|
|
@@ -1517,7 +1576,6 @@ start_worker() {
|
|
|
1517
1576
|
print " \"wrapperPid\": \"" wrapper "\","
|
|
1518
1577
|
print " \"workerMonitorPid\": \"" wmon "\","
|
|
1519
1578
|
print " \"agentMonitorPid\": \"" amon "\","
|
|
1520
|
-
print " \"buildMonitorPid\": \"" bmon "\","
|
|
1521
1579
|
if (relaunch) {
|
|
1522
1580
|
print " \"previousPid\": \"" displaced "\","
|
|
1523
1581
|
print " \"relaunches\": " count ","
|
|
@@ -1527,13 +1585,12 @@ start_worker() {
|
|
|
1527
1585
|
$0 ~ /^ "wrapperPid": "[^"]*",$/ { next }
|
|
1528
1586
|
$0 ~ /^ "workerMonitorPid": "[^"]*",$/ { next }
|
|
1529
1587
|
$0 ~ /^ "agentMonitorPid": "[^"]*",$/ { next }
|
|
1530
|
-
$0 ~ /^ "buildMonitorPid": "[^"]*",$/ { next }
|
|
1531
1588
|
relaunch && $0 ~ /^ "previousPid": "[^"]*",$/ { next }
|
|
1532
1589
|
relaunch && $0 ~ /^ "relaunches": [0-9]+,$/ { next }
|
|
1533
1590
|
relaunch && $0 ~ /^ "startedAt": "[^"]*"$/ { print " \"startedAt\": \"" started "\""; next }
|
|
1534
1591
|
{ 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 & )
|
|
1592
|
+
'"'"' "$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"' \
|
|
1593
|
+
>"$log" 2>&1 </dev/null & ) >/dev/null 2>&1 </dev/null
|
|
1537
1594
|
echo " started worker (log: $log)"
|
|
1538
1595
|
return 0
|
|
1539
1596
|
}
|
|
@@ -1763,9 +1820,7 @@ if [ "$mode" = "stop" ]; then
|
|
|
1763
1820
|
# A refusal that is confidently wrong is worse than one that is terse, so the
|
|
1764
1821
|
# path-guess survives only as the LAST candidate and the refusal below says
|
|
1765
1822
|
# which places were looked in.
|
|
1766
|
-
wt=$(
|
|
1767
|
-
/^worktree / { path = substr($0, 10) }
|
|
1768
|
-
/^branch / { if (substr($0, 8) == want) { print path; exit } }')
|
|
1823
|
+
wt=$(worktree_holding_branch "$stop_branch")
|
|
1769
1824
|
wt_guess="$wt_root_early/$wt_prefix_early$(printf '%s' "$stop_branch" | tr '/' '-')"
|
|
1770
1825
|
[ -n "$wt" ] && [ -d "$wt" ] || wt="$wt_guess"
|
|
1771
1826
|
if [ ! -d "$wt" ]; then
|
|
@@ -1783,10 +1838,14 @@ if [ "$mode" = "stop" ]; then
|
|
|
1783
1838
|
# THE WHOLE PROCESS GROUP, NOT ONE PID. Measured 2026-09-30 (#1084):
|
|
1784
1839
|
# `--stop` signalled the wrapper, the group leader, and the loop, the
|
|
1785
1840
|
# prompt shell, `claude` and its children survived reparented to pid 1;
|
|
1786
|
-
# `kill -TERM -<pgid>` ended all of them.
|
|
1787
|
-
#
|
|
1788
|
-
#
|
|
1789
|
-
#
|
|
1841
|
+
# `kill -TERM -<pgid>` ended all of them. `start_worker` launches under
|
|
1842
|
+
# `set -m`, so an agent leads its own group and that group holds one
|
|
1843
|
+
# wrapper with its monitors, its loop and its `claude`: the signal ends
|
|
1844
|
+
# this agent and nothing else. An agent started before that change still
|
|
1845
|
+
# shares its starter's group with every sibling the same run started and
|
|
1846
|
+
# with the starter itself. The guard below stays for those agents: the
|
|
1847
|
+
# pid is the target when the group cannot be read, or is this script's
|
|
1848
|
+
# own group, because signalling that group would stop the caller with it.
|
|
1790
1849
|
stop_pgid=$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ')
|
|
1791
1850
|
stop_own_pgid=$(ps -o pgid= -p "$$" 2>/dev/null | tr -d ' ')
|
|
1792
1851
|
stop_target="$pid"
|
|
@@ -1847,9 +1906,7 @@ if [ "$mode" = "restart" ]; then
|
|
|
1847
1906
|
# wrong. It matters more here than anywhere: the population this verb serves
|
|
1848
1907
|
# includes the worktree a person made by hand after the tool had no verb for
|
|
1849
1908
|
# 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 } }')
|
|
1909
|
+
restart_wt=$(worktree_holding_branch "$restart_branch")
|
|
1853
1910
|
if [ -z "$restart_wt" ] || [ ! -d "$restart_wt" ]; then
|
|
1854
1911
|
echo "plot-dispatch: no worktree holds '$restart_branch' — nothing to restart." >&2
|
|
1855
1912
|
echo " --restart hands an EXISTING checkout to a new worker; it creates none." >&2
|
|
@@ -1893,7 +1950,11 @@ if [ "$mode" = "restart" ]; then
|
|
|
1893
1950
|
# resets or stashes: a restart that discards that is worse than the missing
|
|
1894
1951
|
# affordance, because it looks like a supported operation. The new worker's
|
|
1895
1952
|
# brief already tells it to commit and push before verifying.
|
|
1896
|
-
|
|
1953
|
+
# A REBUILT BUNDLE ALONE IS NOT "UNCOMMITTED WORK". `main` rebuilds and
|
|
1954
|
+
# pushes every generated board bundle (`bug/main-builds-its-bundles`, #1249),
|
|
1955
|
+
# so a desk that locally rebuilt one to test says nothing an agent left on
|
|
1956
|
+
# the floor — excused path by path, same reading `desk_dirt` applies.
|
|
1957
|
+
if [ -n "$(git -C "$restart_wt" status --porcelain </dev/null 2>/dev/null | exclude_bundle_paths "$restart_wt")" ]; then
|
|
1897
1958
|
echo " uncommitted work in the tree is kept — the new worker inherits it"
|
|
1898
1959
|
fi
|
|
1899
1960
|
|
|
@@ -2001,35 +2062,49 @@ if [ "$mode" = "release" ]; then
|
|
|
2001
2062
|
# THE DESK, ASKED OF GIT — never rebuilt from the branch name, the rule every
|
|
2002
2063
|
# other verb here follows. A manifest naming this branch may name a desk git
|
|
2003
2064
|
# 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 } }')
|
|
2065
|
+
release_wt=$(worktree_holding_branch "$br")
|
|
2007
2066
|
registry_dir=$(agent_registry_dir "$repo_root_early")
|
|
2008
2067
|
named_manifests=()
|
|
2009
2068
|
if [ -d "$registry_dir" ]; then
|
|
2010
2069
|
for m in "$registry_dir"/*.json; do
|
|
2011
2070
|
[ -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)
|
|
2071
|
+
m_branch=$(manifest_string "$m" branch) || m_branch=""
|
|
2018
2072
|
[ "$m_branch" = "$br" ] || continue
|
|
2019
2073
|
named_manifests+=("$m")
|
|
2020
2074
|
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)
|
|
2075
|
+
m_wt=$(manifest_string "$m" worktree) || m_wt=""
|
|
2027
2076
|
[ -n "$m_wt" ] && [ -d "$m_wt" ] && release_wt="$m_wt"
|
|
2028
2077
|
fi
|
|
2029
2078
|
done
|
|
2030
2079
|
fi
|
|
2031
2080
|
[ -n "$release_wt" ] && [ -d "$release_wt" ] || release_wt=""
|
|
2032
2081
|
|
|
2082
|
+
# A LIVE AGENT HOLDS THE BRANCH IN A DESK OTHER THAN `release_wt` — asked
|
|
2083
|
+
# through the domain, and asked BEFORE refusal 2 below for the reason that
|
|
2084
|
+
# refusal cannot cover: an agent JUST HANDED the branch has not checked it
|
|
2085
|
+
# out, so `release_wt` is empty and refusal 2's desk-bound check does not
|
|
2086
|
+
# run. This asks each manifest NAMING the branch whether ITS OWN desk is
|
|
2087
|
+
# alive, whatever branch that desk currently holds — `claimAnswer` answers
|
|
2088
|
+
# `held-by-agent` from `holders` alone, so the ref and the commit log are not
|
|
2089
|
+
# needed to ask this question.
|
|
2090
|
+
#
|
|
2091
|
+
# `release_wt` ITSELF IS EXCLUDED FROM `holders`, so this never duplicates
|
|
2092
|
+
# refusal 2's case: a manifest whose own desk IS `release_wt` is the live
|
|
2093
|
+
# worker refusal 2 already names with its pid, and this must not pre-empt it
|
|
2094
|
+
# with a different message for the same desk.
|
|
2095
|
+
#
|
|
2096
|
+
# NOT NUMBERED WITH THE FOUR BELOW: the plan keeps their order and wording
|
|
2097
|
+
# unchanged, and this is the one new refusal, asked through the domain.
|
|
2098
|
+
holder_lines=$(live_holders_of_branch "$registry_dir" "$br" "" "$release_wt")
|
|
2099
|
+
answer=$(claim_answer "$script_dir/board/plot-claim-answer.mjs" unknown "" \
|
|
2100
|
+
"$(printf '%s\n' "$holder_lines" | cut -f1)") || answer=""
|
|
2101
|
+
if [ "$answer" = "held-by-agent" ]; then
|
|
2102
|
+
echo "plot-dispatch: $br is held by a live agent — refusing." >&2
|
|
2103
|
+
printf '%s\n' "$holder_lines" | while IFS=$'\t' read -r h_session h_wt; do echo " $h_session (desk $h_wt)" >&2; done
|
|
2104
|
+
echo " Nothing was written." >&2
|
|
2105
|
+
exit 1
|
|
2106
|
+
fi
|
|
2107
|
+
|
|
2033
2108
|
# 2. A LIVE WORKER — the measurement `--restart` makes, through the shared
|
|
2034
2109
|
# classifier, never `pgrep` by name. A live pid means somebody is working,
|
|
2035
2110
|
# and the one case where a claim is not abandoned.
|
|
@@ -2133,11 +2208,76 @@ if [ "$mode" = "release" ]; then
|
|
|
2133
2208
|
else
|
|
2134
2209
|
echo " origin/$br does not exist — only the assignment needed releasing"
|
|
2135
2210
|
fi
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2211
|
+
# THE DESK THAT STILL HOLDS THE BRANCH IS DETACHED. Measured 2026-10-02: a
|
|
2212
|
+
# released desk kept `$br` with only its empty claim commit. The next agent
|
|
2213
|
+
# handed the slice asked `checkoutYield` whether that checkout yields; with
|
|
2214
|
+
# the remote ref deleted it has no upstream, `unpushedCommits` reads
|
|
2215
|
+
# `unknown`, and the rule keeps it. Fifteen agents in a row wrote
|
|
2216
|
+
# PLOT-BLOCKED for two released slices between 13:46 and 14:59.
|
|
2217
|
+
#
|
|
2218
|
+
# Detaching at origin/<main> is the state a free agent's desk already has, so
|
|
2219
|
+
# the desk stays usable and the branch is free for the next checkout. The
|
|
2220
|
+
# worktree itself stays: removing it is the reaper's licence, not this one.
|
|
2221
|
+
#
|
|
2222
|
+
# FOUR READINGS, each asked again rather than inferred from the refusals
|
|
2223
|
+
# above, so a change to those refusals cannot silently widen this write:
|
|
2224
|
+
# - the desk's HEAD is `refs/heads/$br` (a manifest's `worktree` may name a
|
|
2225
|
+
# desk on another branch);
|
|
2226
|
+
# - no live worker, through the shared classifier;
|
|
2227
|
+
# - no file-changing commit beyond origin/<main> — the count step 3 takes,
|
|
2228
|
+
# with origin/<main> as the base because the claim ref is now gone;
|
|
2229
|
+
# - a clean tree by `desk_dirt`, the reaper's reading.
|
|
2230
|
+
# Any reading that fails keeps today's behaviour and names the reason.
|
|
2231
|
+
detached_desks=0
|
|
2232
|
+
release_holders=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$br" '
|
|
2233
|
+
/^worktree / { path = substr($0, 10) }
|
|
2234
|
+
/^branch / { if (substr($0, 8) == want) print path }')
|
|
2235
|
+
[ -z "$release_holders" ] && [ -n "$release_wt" ] && release_holders="$release_wt"
|
|
2236
|
+
while IFS= read -r desk; do
|
|
2237
|
+
[ -n "$desk" ] || continue
|
|
2238
|
+
keep_reason=""
|
|
2239
|
+
if [ ! -d "$desk" ]; then
|
|
2240
|
+
keep_reason="the directory does not exist"
|
|
2241
|
+
elif [ "$(git -C "$desk" symbolic-ref -q HEAD </dev/null 2>/dev/null)" != "refs/heads/$br" ]; then
|
|
2242
|
+
keep_reason="its HEAD is not refs/heads/$br"
|
|
2243
|
+
else
|
|
2244
|
+
case "$(plot_worker_state "$desk" "" | cut -f1)" in
|
|
2245
|
+
running) keep_reason="a worker is alive in it" ;;
|
|
2246
|
+
esac
|
|
2247
|
+
fi
|
|
2248
|
+
if [ -z "$keep_reason" ]; then
|
|
2249
|
+
desk_work=$(git -C "$desk" rev-list --count "refs/remotes/origin/$MAIN..HEAD" -- . </dev/null 2>/dev/null) || desk_work=""
|
|
2250
|
+
case "$desk_work" in
|
|
2251
|
+
0) ;;
|
|
2252
|
+
''|*[!0-9]*) keep_reason="its commits beyond origin/$MAIN could not be counted" ;;
|
|
2253
|
+
*) keep_reason="$desk_work commit(s) beyond origin/$MAIN change files" ;;
|
|
2254
|
+
esac
|
|
2255
|
+
fi
|
|
2256
|
+
if [ -z "$keep_reason" ]; then
|
|
2257
|
+
desk_floor=$(desk_dirt "$desk")
|
|
2258
|
+
[ -n "$desk_floor" ] && keep_reason="uncommitted changes: $(printf '%s\n' "$desk_floor" | cut -c4- | tr '\n' ' ')"
|
|
2259
|
+
fi
|
|
2260
|
+
if [ -z "$keep_reason" ]; then
|
|
2261
|
+
if ! git -C "$desk" checkout -q --detach "refs/remotes/origin/$MAIN" </dev/null 2>/dev/null; then
|
|
2262
|
+
keep_reason="git refused to detach it at origin/$MAIN"
|
|
2263
|
+
fi
|
|
2264
|
+
fi
|
|
2265
|
+
if [ -n "$keep_reason" ]; then
|
|
2266
|
+
echo " the desk at $desk still holds $br and is left as it is: $keep_reason"
|
|
2267
|
+
continue
|
|
2268
|
+
fi
|
|
2269
|
+
detached_desks=$((detached_desks + 1))
|
|
2270
|
+
# Only empty claim commits are lost, and the remote ref is already gone.
|
|
2271
|
+
if git branch -q -D "$br" </dev/null 2>/dev/null; then
|
|
2272
|
+
echo " detached the desk at $desk at origin/$MAIN and deleted the local branch $br"
|
|
2273
|
+
else
|
|
2274
|
+
echo " detached the desk at $desk at origin/$MAIN; the local branch $br could not be deleted"
|
|
2275
|
+
fi
|
|
2276
|
+
done <<EOF
|
|
2277
|
+
$release_holders
|
|
2278
|
+
EOF
|
|
2139
2279
|
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)"
|
|
2280
|
+
echo "summary: released=1 manifests=$released_manifests ref=$([ "$ref_present" = 1 ] && echo deleted || echo absent) detached=$detached_desks"
|
|
2141
2281
|
exit 0
|
|
2142
2282
|
fi
|
|
2143
2283
|
|
|
@@ -2892,7 +3032,9 @@ esac
|
|
|
2892
3032
|
# merged PR outlives the branch it was cut from.
|
|
2893
3033
|
#
|
|
2894
3034
|
# `NONE` AND SILENCE ARE DIFFERENT ANSWERS. `NONE` means the host was asked and
|
|
2895
|
-
# has never seen a PR for that branch
|
|
3035
|
+
# has never seen a PR for that branch. For a name in `plot-fleet-scan.sh
|
|
3036
|
+
# --slice-names` — a slice of any live plan, not deferred — that is a slice
|
|
3037
|
+
# nobody started, which is `waiting`; for any other name it is `blocked`. A host that
|
|
2896
3038
|
# could not be asked is neither permission nor proof of a typo, so it HOLDS the
|
|
2897
3039
|
# branch at `waiting`. Both refuse; only one tells the operator to fix the plan.
|
|
2898
3040
|
|
|
@@ -2926,13 +3068,20 @@ prereq_answer() { # $1=prerequisite branch → merged|unmerged|none|unreachable
|
|
|
2926
3068
|
}
|
|
2927
3069
|
|
|
2928
3070
|
# 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
|
|
3071
|
+
# the same blob, in the plan's own order, one line of `branch<TAB>prerequisite`
|
|
3072
|
+
# PER PREREQUISITE: a branch naming two prints two lines, in the plan's order.
|
|
3073
|
+
#
|
|
3074
|
+
# `waits_on` IS A LIST (`["bug/a","bug/b"]`), never the one-name string this
|
|
3075
|
+
# used to match. The old `"waits_on":"[^"]*"` pattern does not match an array,
|
|
3076
|
+
# so an unmigrated reader here would read every slice as waiting on nothing and
|
|
3077
|
+
# dispatch a held slice — the defect this plan exists to remove, reintroduced
|
|
3078
|
+
# by the parser's own fix had this not moved with it (#1153).
|
|
2930
3079
|
#
|
|
2931
3080
|
# NON-DEFERRED ONLY. `deferred:` is a JUDGEMENT — somebody gave the branch up —
|
|
2932
3081
|
# and it outranks a wait for the same reason the scan lets it: a branch nobody
|
|
2933
3082
|
# will start does not need to be told what it is waiting for. The two
|
|
2934
3083
|
# annotations sit on one line and neither reads the other's value.
|
|
2935
|
-
waits_pairs() { # → branch<TAB>prerequisite, one per
|
|
3084
|
+
waits_pairs() { # → branch<TAB>prerequisite, one line per prerequisite
|
|
2936
3085
|
printf '%s' "$gate_meta" | awk '
|
|
2937
3086
|
{
|
|
2938
3087
|
n = split($0, parts, /\{"branch":"/)
|
|
@@ -2940,9 +3089,11 @@ waits_pairs() { # → branch<TAB>prerequisite, one per annotated branch
|
|
|
2940
3089
|
rec = parts[i]
|
|
2941
3090
|
br = rec; sub(/".*$/, "", br)
|
|
2942
3091
|
if (rec ~ /"deferred":true/) continue
|
|
2943
|
-
if (match(rec, /"waits_on"
|
|
2944
|
-
|
|
2945
|
-
|
|
3092
|
+
if (match(rec, /"waits_on":\[[^]]*\]/)) {
|
|
3093
|
+
list = substr(rec, RSTART + 12, RLENGTH - 13)
|
|
3094
|
+
gsub(/"/, "", list)
|
|
3095
|
+
m = split(list, names, ",")
|
|
3096
|
+
for (j = 1; j <= m; j++) if (names[j] != "") print br "\t" names[j]
|
|
2946
3097
|
}
|
|
2947
3098
|
}
|
|
2948
3099
|
}'
|
|
@@ -2995,12 +3146,9 @@ is_waits_held() {
|
|
|
2995
3146
|
# override and the scan remains the only thing that decides them. The branch
|
|
2996
3147
|
# still passes every gate the loops apply after it: `held_worktree`, the claim
|
|
2997
3148
|
# race, and the brief.
|
|
3149
|
+
# The array is read directly as `${waits_freed[@]}`; the predicate wrapper
|
|
3150
|
+
# `is_waits_held` has beside it was never called and is gone.
|
|
2998
3151
|
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
3152
|
|
|
3005
3153
|
# Runs the preflight: prints its refusals, fills `waits_held`, and adds what it
|
|
3006
3154
|
# withheld to `n_skipped`.
|
|
@@ -3015,22 +3163,45 @@ is_waits_freed() {
|
|
|
3015
3163
|
# already sets: a dry run that offers what a real run would refuse is worse than
|
|
3016
3164
|
# no dry run — it is the same wrong answer with a reassurance attached.
|
|
3017
3165
|
run_waits_preflight() { # → prints refusals; fills waits_held, adds to n_skipped
|
|
3018
|
-
local br prereq answer held
|
|
3166
|
+
local br prereq answer held slice_names=""
|
|
3019
3167
|
while IFS=$'\t' read -r br prereq; do
|
|
3020
3168
|
[ -n "$br" ] || continue
|
|
3021
3169
|
answer=$(prereq_answer "$prereq")
|
|
3022
3170
|
case "$answer" in
|
|
3023
3171
|
merged) continue ;;
|
|
3024
|
-
|
|
3172
|
+
# A slice somebody may still start has no PR until its work starts: a
|
|
3173
|
+
# wait, not a typo (#1305). The set is the scan's, of the whole estate,
|
|
3174
|
+
# so a slice of ANOTHER plan reads as it does on the board. Asked once,
|
|
3175
|
+
# and only when the host answered `none`.
|
|
3176
|
+
# ABSENT IS NOT FALSE. A scan that failed or printed nothing leaves the
|
|
3177
|
+
# set unread, and an unread set holds the branch at `waiting` exactly as
|
|
3178
|
+
# an unaskable host does: telling an operator to fix a correct plan is its
|
|
3179
|
+
# own defect. stderr stays visible, and the refusal names the failure.
|
|
3180
|
+
none) [ -n "$slice_names" ] || slice_names=$'\n'"$("$script_dir/plot-fleet-scan.sh" --slice-names)"$'\n' || slice_names=$'\n\n'
|
|
3181
|
+
case "$slice_names" in
|
|
3182
|
+
$'\n\n') held=waiting
|
|
3183
|
+
echo "plot-fleet-scan.sh --slice-names gave no answer: $prereq has no PR, held as waiting rather than called a typo" ;;
|
|
3184
|
+
*$'\n'"$prereq"$'\n'*) held=waiting ;;
|
|
3185
|
+
*) held=blocked ;;
|
|
3186
|
+
esac ;;
|
|
3025
3187
|
*) held=waiting ;;
|
|
3026
3188
|
esac
|
|
3027
|
-
# `--allow-waiting` SAYS SO ON THE LINE IT OVERRIDES
|
|
3028
|
-
# see in the output is an override nobody can
|
|
3189
|
+
# `--allow-waiting` SAYS SO ON THE LINE IT OVERRIDES, ONCE PER PREREQUISITE
|
|
3190
|
+
# — an override nobody can see in the output is an override nobody can
|
|
3191
|
+
# audit, and a branch with two unmerged prerequisites names both so the
|
|
3192
|
+
# audit trail is complete.
|
|
3029
3193
|
if [ "$allow_waiting" = 1 ]; then
|
|
3030
3194
|
echo "$br waits on $prereq ($held) — dispatching anyway (--allow-waiting)"
|
|
3031
3195
|
waits_freed+=("$br")
|
|
3032
3196
|
continue
|
|
3033
3197
|
fi
|
|
3198
|
+
# ONE REFUSAL PER BRANCH, NOT PER PREREQUISITE. `waits_pairs` prints one
|
|
3199
|
+
# line per unmerged prerequisite in the plan's order, so a branch with
|
|
3200
|
+
# `[merged, unmerged]` reaches this loop once — but `[unmerged, unmerged]`
|
|
3201
|
+
# would reach it twice, and a branch already held names only the FIRST
|
|
3202
|
+
# prerequisite that held it: `is_waits_held` guards both the second refusal
|
|
3203
|
+
# line and the double-count in `n_skipped`.
|
|
3204
|
+
if is_waits_held "$br"; then continue; fi
|
|
3034
3205
|
waits_held+=("$br")
|
|
3035
3206
|
n_skipped=$((n_skipped + 1))
|
|
3036
3207
|
if [ "$held" = "blocked" ]; then
|
|
@@ -3101,9 +3272,7 @@ worker_state_field() {
|
|
|
3101
3272
|
#
|
|
3102
3273
|
# `worker=` still travels in the footer. It says how this repo is configured,
|
|
3103
3274
|
# which remains a fact about the repo even where it no longer explains a count.
|
|
3104
|
-
|
|
3105
|
-
echo "summary: dispatched=$1 reused=$2 skipped=$3 started=$4 brief=missing worker=$(worker_state_field) brief_asked=${n_brief_asked:-0}"
|
|
3106
|
-
}
|
|
3275
|
+
# The line is printed once, at the end of the run.
|
|
3107
3276
|
|
|
3108
3277
|
# ---------------------------------------------------------------------------
|
|
3109
3278
|
# Parallel-agents cap: warn and raise when exceeded
|
|
@@ -3135,21 +3304,6 @@ read_parallel_agents_cap() {
|
|
|
3135
3304
|
[ -n "$cap" ] && echo "$cap" || echo 3
|
|
3136
3305
|
}
|
|
3137
3306
|
|
|
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
3307
|
# Get the branches currently occupying slots (for the warning message).
|
|
3154
3308
|
live_worker_branches() {
|
|
3155
3309
|
local wt br st
|
|
@@ -3163,6 +3317,13 @@ live_worker_branches() {
|
|
|
3163
3317
|
done
|
|
3164
3318
|
}
|
|
3165
3319
|
|
|
3320
|
+
# Count workers in live states (running or waiting) across all fleet worktrees.
|
|
3321
|
+
# These are the slots that count against the cap — the branches above, counted,
|
|
3322
|
+
# so the two readings cannot disagree about what occupies a slot.
|
|
3323
|
+
count_live_workers() {
|
|
3324
|
+
live_worker_branches | grep -c . || true
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3166
3327
|
# Update the parallel-agents cap in the fleet controls file.
|
|
3167
3328
|
# Creates the file (and directory) if needed, preserving autoDispatch if present.
|
|
3168
3329
|
update_parallel_agents_cap() { # $1 = new cap
|
|
@@ -3512,10 +3673,22 @@ committed_files() { # $1=branch → paths, one per line
|
|
|
3512
3673
|
|
|
3513
3674
|
# Files a branch holds only in its worktree — no ref carries these, so they are
|
|
3514
3675
|
# invisible to every ref-based check including this script's own.
|
|
3676
|
+
#
|
|
3677
|
+
# A REBUILT BUNDLE IS EXCLUDED, path by path, same as `desk_dirt`: `main`
|
|
3678
|
+
# rebuilds and pushes every generated board bundle
|
|
3679
|
+
# (`bug/main-builds-its-bundles`, #1249), so it is never a branch's own
|
|
3680
|
+
# uncommitted work. The held-worktree gate below reads this list to decide
|
|
3681
|
+
# whether a worktree counts as held, and a rebuilt-but-unchanged bundle must
|
|
3682
|
+
# not be the reason a free worktree reads occupied.
|
|
3515
3683
|
uncommitted_files() { # $1=worktree → paths, one per line
|
|
3516
|
-
local wt="$1"
|
|
3684
|
+
local wt="$1" bundles
|
|
3517
3685
|
[ -d "$wt" ] || return 0
|
|
3518
|
-
|
|
3686
|
+
bundles=$(bundle_paths "$wt")
|
|
3687
|
+
git -C "$wt" status --porcelain </dev/null 2>/dev/null | awk -v bundles="$bundles" '
|
|
3688
|
+
BEGIN {
|
|
3689
|
+
n = split(bundles, arr, "\n")
|
|
3690
|
+
for (i = 1; i <= n; i++) if (arr[i] != "") excluded[arr[i]] = 1
|
|
3691
|
+
}
|
|
3519
3692
|
{
|
|
3520
3693
|
# Porcelain v1: XY then a space then the path. A rename prints
|
|
3521
3694
|
# "old -> new"; the new path is the one on disk.
|
|
@@ -3523,7 +3696,7 @@ uncommitted_files() { # $1=worktree → paths, one per line
|
|
|
3523
3696
|
i = index(line, " -> ")
|
|
3524
3697
|
if (i > 0) line = substr(line, i + 4)
|
|
3525
3698
|
gsub(/^"|"$/, "", line)
|
|
3526
|
-
if (line != "") print line
|
|
3699
|
+
if (line != "" && !(line in excluded)) print line
|
|
3527
3700
|
}'
|
|
3528
3701
|
}
|
|
3529
3702
|
|
|
@@ -3631,9 +3804,7 @@ held_worktree() { # $1=branch → prints the worktree path when held, else nothi
|
|
|
3631
3804
|
# refs/heads/<name>` per entry, so the branch line is matched and the path
|
|
3632
3805
|
# remembered from the preceding line. A detached worktree has no branch line
|
|
3633
3806
|
# 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 } }')
|
|
3807
|
+
wt=$(worktree_holding_branch "$br")
|
|
3637
3808
|
[ -n "$wt" ] || return 1
|
|
3638
3809
|
# A registered worktree whose directory is gone (removed by hand, not via
|
|
3639
3810
|
# `git worktree remove`) holds nobody. `status` cannot be read there anyway.
|
|
@@ -3775,7 +3946,11 @@ IN_FLIGHT_MAX_BRANCHES=8
|
|
|
3775
3946
|
report_monitors() { # $1=worktree
|
|
3776
3947
|
[ "$show_monitors" = 1 ] || return 0
|
|
3777
3948
|
local wt="$1" m
|
|
3778
|
-
|
|
3949
|
+
# ONLY `agent` IS A SCRIPT ANY MORE. `bug/the-loop-reports-idle` removed
|
|
3950
|
+
# `plot-worker-monitor.sh`: the PROCESS no longer has a monitor to report —
|
|
3951
|
+
# the loop's own watcher judges `idle` and the wrapper itself reports
|
|
3952
|
+
# `gone`/`clear`, neither of which this dry run can name as a script path.
|
|
3953
|
+
for m in agent; do
|
|
3779
3954
|
local script="$script_dir/plot-$m-monitor.sh"
|
|
3780
3955
|
if [ -x "$script" ]; then
|
|
3781
3956
|
echo " would attach: $script → $wt"
|
|
@@ -4053,7 +4228,7 @@ book_started ${claimed_now[@]+"${claimed_now[@]}"} || true
|
|
|
4053
4228
|
# appears before the footer.
|
|
4054
4229
|
check_and_update_cap "$n_started"
|
|
4055
4230
|
|
|
4056
|
-
|
|
4231
|
+
echo "summary: dispatched=$n_dispatched reused=$n_reused skipped=$n_skipped started=$n_started brief=missing worker=$(worker_state_field) brief_asked=${n_brief_asked:-0}"
|
|
4057
4232
|
|
|
4058
4233
|
# THE RECEIPT IS SPENT HERE, on the fan-out COMPLETING — never at the gate.
|
|
4059
4234
|
# `plot-controller-gate.sh` clears on a receipt and LEAVES it, so a run that
|