@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/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,14 @@ 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
+ # 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 `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*.
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 worker_monitor='' agent_monitor='' build_monitor=''
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
- ( cd "$wt" && \
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 "$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" '"'"'
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=$(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 } }')
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. 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.
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=$(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 } }')
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
- if [ -n "$(git -C "$restart_wt" status --porcelain </dev/null 2>/dev/null)" ]; then
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=$(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 } }')
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=$(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)
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=$(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)
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
- if [ -n "$release_wt" ]; then
2137
- echo " the desk at $release_wt still holds $br and is left as it is"
2138
- fi
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 annotated branch
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
- w = substr(rec, RSTART + 12, RLENGTH - 13)
2945
- if (w != "") print br "\t" w
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. An override nobody can
3028
- # see in the output is an override nobody can audit.
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
- git -C "$wt" status --porcelain </dev/null 2>/dev/null | awk '
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=$(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 } }')
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
- for m in worker agent; do
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"