@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/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. The desk is
40
- # never touched.
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. `setsid` is not used — it does
789
- # not exist on macOS, where most of this fleet runs.
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 & ) 2>/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
- # The cause is that `$!` names the last job THIS shell backgrounded, and with
1284
- # an env-var prefix in front of `nohup` bash cannot collapse the AND-list into
1285
- # one child — it forks a subshell, and that subshell is what `$!` reports.
1286
- # (Without the prefix bash `exec`s the command in place and `$!` is correct,
1287
- # which is why the shape matters and a smaller repro does not show it.)
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
- # Three monitors start INSIDE the wrapper, as its children, immediately before
1347
- # the agent: one watches the process (`plot-worker-monitor.sh`), one watches
1348
- # the desk (`plot-agent-monitor.sh`), one watches the run
1349
- # (`plot-build-monitor.sh`). Each has a subject the others do not and a
1350
- # cadence it cannot share — seconds on the process table, minutes on the host,
1351
- # seconds again on a run but only while one is live.
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 `gone` finding, which is why the no-op reads no pid at all and the
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 worker_monitor='' agent_monitor='' build_monitor=''
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
- ( cd "$wt" && \
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=""; bmon=""; if [ -n "$PLOT_WORKER_MONITOR" ]; then "$PLOT_WORKER_MONITOR" & wmon=$!; fi; 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=$!; 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" '"'"'
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=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$stop_branch" '
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. The pid is the fallback when the
1787
- # group cannot be read, or is this script's own group: a worker started
1788
- # without job control shares its starter's group, and signalling that
1789
- # group would stop the caller with it.
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=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$restart_branch" '
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
- if [ -n "$(git -C "$restart_wt" status --porcelain </dev/null 2>/dev/null)" ]; then
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=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$br" '
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=$(node -e '
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=$(node -e '
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
- if [ -n "$release_wt" ]; then
2137
- echo " the desk at $release_wt still holds $br and is left as it is"
2138
- fi
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 — a typo, which is `blocked`. A host that
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 annotated branch
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
- w = substr(rec, RSTART + 12, RLENGTH - 13)
2945
- if (w != "") print br "\t" w
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
- none) held=blocked ;;
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. An override nobody can
3028
- # see in the output is an override nobody can audit.
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
- print_summary() { # $1=dispatched $2=reused $3=skipped $4=started
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
- git -C "$wt" status --porcelain </dev/null 2>/dev/null | awk '
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=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$br" '
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
- for m in worker agent; do
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
- print_summary "$n_dispatched" "$n_reused" "$n_skipped" "$n_started"
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