@plot-pm/board 0.16.1 → 0.16.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/board-server.mjs +167 -139
- package/package.json +2 -3
- package/plot-agent-manifest.sh +111 -15
- package/plot-agent-monitor.sh +43 -5
- package/plot-approve.sh +234 -74
- package/plot-build-monitor.sh +49 -4
- package/plot-config.sh +10 -14
- package/plot-deliver.sh +10 -732
- package/plot-desk-root.sh +127 -0
- package/plot-dispatch.sh +313 -135
- package/plot-fleet-scan.sh +746 -62
- package/plot-host.sh +356 -43
- package/plot-monitor-subject.sh +55 -3
- package/plot-plan-meta.sh +155 -139
- package/plot-pr-merged.sh +16 -0
- package/plot-reap.sh +82 -93
- package/plot-state-receipt.sh +12 -2
- package/plot-tmp.sh +90 -14
- package/plot-transcript-quiet.sh +3 -1
- package/plot-worker-state.sh +633 -24
- package/plot-resolve-artifact.sh +0 -429
- 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.
|
|
@@ -219,43 +225,55 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
|
219
225
|
# WHERE THE WORKTREES LIVE, and by what name
|
|
220
226
|
# ---------------------------------------------------------------------------
|
|
221
227
|
#
|
|
222
|
-
# Two facts
|
|
223
|
-
#
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
#
|
|
228
|
-
#
|
|
229
|
-
#
|
|
228
|
+
# Two facts: the root directory the worktrees sit in, and the prefix their
|
|
229
|
+
# directory names carry. The root is the desk root, `deskRoot`'s answer asked
|
|
230
|
+
# through `plot-desk-root.sh`:
|
|
231
|
+
#
|
|
232
|
+
# `Worktree root:` absent or empty → <main checkout>/.worktrees
|
|
233
|
+
# relative value → <main checkout>/<value>
|
|
234
|
+
# absolute value → <value> as given
|
|
235
|
+
#
|
|
236
|
+
# The prefix is always empty. `plot-wt-` made Plot's worktrees identifiable
|
|
237
|
+
# among unrelated directories in the checkout's parent; the desk root is a
|
|
238
|
+
# directory Plot owns, so the directory already says what they are. Desks a
|
|
239
|
+
# dispatch created under the old default keep their `plot-wt-` names and their
|
|
240
|
+
# place: every read of "which worktree holds this branch" asks `git worktree
|
|
241
|
+
# list`, and `plot-reap.sh` still recognises the legacy path.
|
|
230
242
|
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
243
|
+
# The root is the MAIN checkout's, wherever this runs: inside a desk,
|
|
244
|
+
# `--show-toplevel` answers the desk and `.worktrees` would resolve beneath it.
|
|
245
|
+
# There is NO FALLBACK: when the rule cannot be asked, dispatch stops with the
|
|
246
|
+
# reason rather than compute a second default.
|
|
234
247
|
#
|
|
235
248
|
# THIS FUNCTION ONLY COMPOSES A ROOT AND A PREFIX. It is the CREATION side. Every
|
|
236
249
|
# read of "which worktree holds this branch" asks `git worktree list` instead —
|
|
237
250
|
# see THE HELD-BRANCH GATE. A second naming convention gives path-guessing a
|
|
238
251
|
# second way to be wrong, so path-guessing is confined to creation alone.
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
return
|
|
247
|
-
fi
|
|
248
|
-
case "$configured" in
|
|
249
|
-
/*) wt_root="$configured" ;; # absolute: taken as given
|
|
250
|
-
*) wt_root="$rr/$configured" ;; # relative: against the repo root
|
|
251
|
-
esac
|
|
252
|
-
# Normalise away a trailing slash so composed paths never double it. The
|
|
253
|
-
# directory may not exist yet (created on first dispatch), so this is pure
|
|
254
|
-
# string work, not a `cd`.
|
|
255
|
-
wt_root="${wt_root%/}"
|
|
252
|
+
# shellcheck source=plot-desk-root.sh
|
|
253
|
+
. "$script_dir/plot-desk-root.sh"
|
|
254
|
+
resolve_wt_root() { # sets globals wt_root, wt_prefix; exits 3 when unaskable
|
|
255
|
+
wt_root=$(plot_desk_root "$(plot_repo_root)") || {
|
|
256
|
+
echo "plot-dispatch: cannot resolve where the worktrees go — nothing was dispatched." >&2
|
|
257
|
+
exit 3
|
|
258
|
+
}
|
|
256
259
|
wt_prefix=""
|
|
257
260
|
}
|
|
258
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
|
+
|
|
259
277
|
dry_run=0
|
|
260
278
|
show_monitors=0
|
|
261
279
|
no_start=0
|
|
@@ -788,8 +806,12 @@ request_brief() { # $1 = branch, $2 = slug → 0 if a command was started
|
|
|
788
806
|
mkdir -p "$(dirname "$log")" 2>/dev/null || true
|
|
789
807
|
# `nohup ... &` inside a subshell, the same detachment `start_worker` uses:
|
|
790
808
|
# this outlives the dispatch run by design, because the fan-out must not block
|
|
791
|
-
# on a `claude -p` session of unknown length. `
|
|
792
|
-
#
|
|
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.
|
|
793
815
|
# THE SETTINGS FILE THIS PROJECT STARTS ITS AGENTS WITH. The `Brief command` is
|
|
794
816
|
# a `claude -p` session like any other agent and inherits every `SessionStart`
|
|
795
817
|
# hook the operator's plugins declare, so it carries the same variable the
|
|
@@ -801,12 +823,12 @@ request_brief() { # $1 = branch, $2 = slug → 0 if a command was started
|
|
|
801
823
|
# which `${VAR:+…}` reads as nothing — so a broken config cannot stop a brief
|
|
802
824
|
# being written.
|
|
803
825
|
_brief_settings="$(bash "$(dirname "${BASH_SOURCE[0]}")/plot-agent-settings.sh" 2>/dev/null || echo "")"
|
|
804
|
-
( cd "$repo_root" \
|
|
826
|
+
( set -m; cd "$repo_root" \
|
|
805
827
|
&& PLOT_UNATTENDED=1 PLOT_PLAN_SLUG="$bslug" PLOT_BRIEF_BRANCH="$branch" \
|
|
806
828
|
PLOT_AGENT_SETTINGS="$_brief_settings" \
|
|
807
|
-
nohup sh -c "$cmd \"\$@\"" plot-brief \
|
|
829
|
+
exec nohup sh -c "$cmd \"\$@\"" plot-brief \
|
|
808
830
|
"$(brief_prompt "$branch" "$bslug")" \
|
|
809
|
-
>"$log" 2>&1 </dev/null & )
|
|
831
|
+
>"$log" 2>&1 </dev/null & ) >/dev/null 2>&1 </dev/null
|
|
810
832
|
echo " asked the \`Brief command\` to write it — log: $log"
|
|
811
833
|
# SAYS WHAT WAS MEASURED, WHICH IS THE START AND NOT THE RESULT. The command
|
|
812
834
|
# is detached and never waited on, so this returns 0 the moment it is spawned
|
|
@@ -1283,11 +1305,17 @@ start_worker() {
|
|
|
1283
1305
|
# measured that day recorded a pid one process above the agent's real parent
|
|
1284
1306
|
# (7357 against 7358, 71953 against 71954, 92947 against 92949).
|
|
1285
1307
|
#
|
|
1286
|
-
#
|
|
1287
|
-
#
|
|
1288
|
-
#
|
|
1289
|
-
#
|
|
1290
|
-
#
|
|
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.
|
|
1291
1319
|
#
|
|
1292
1320
|
# So the same rule the agent pid already follows applies here: THE PROCESS
|
|
1293
1321
|
# THAT KNOWS A PID IS THE ONE THAT WRITES IT. The wrapper knows `$$`; no
|
|
@@ -1346,12 +1374,14 @@ start_worker() {
|
|
|
1346
1374
|
#
|
|
1347
1375
|
# EVERY WORKER IS BORN MONITORED, AND THAT IS ENFORCED HERE OR NOWHERE.
|
|
1348
1376
|
#
|
|
1349
|
-
#
|
|
1350
|
-
# the agent: one watches the
|
|
1351
|
-
#
|
|
1352
|
-
#
|
|
1353
|
-
#
|
|
1354
|
-
#
|
|
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.
|
|
1355
1385
|
#
|
|
1356
1386
|
# WHY INSIDE THE WRAPPER RATHER THAN BESIDE IT. The wrapper already outlives
|
|
1357
1387
|
# its agent by construction — it must, or there would be no exit code to
|
|
@@ -1377,8 +1407,8 @@ start_worker() {
|
|
|
1377
1407
|
# sub-millisecond gap after the wrapper starts and before `.plot-worker.pid`
|
|
1378
1408
|
# is written, and a scan landing in it reads `none` — honest. The monitors
|
|
1379
1409
|
# start inside that same window; they must never turn an unwritten pid file
|
|
1380
|
-
# into a
|
|
1381
|
-
# 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*.
|
|
1382
1412
|
#
|
|
1383
1413
|
# THE PATHS TRAVEL AS ENV VARS, like every other path the wrapper needs. The
|
|
1384
1414
|
# `sh -c` body is single-quoted and a path with spaces would not survive
|
|
@@ -1451,8 +1481,7 @@ start_worker() {
|
|
|
1451
1481
|
fi
|
|
1452
1482
|
fi
|
|
1453
1483
|
|
|
1454
|
-
local
|
|
1455
|
-
[ -x "$script_dir/plot-worker-monitor.sh" ] && worker_monitor="$script_dir/plot-worker-monitor.sh"
|
|
1484
|
+
local agent_monitor='' build_monitor=''
|
|
1456
1485
|
[ -x "$script_dir/plot-agent-monitor.sh" ] && agent_monitor="$script_dir/plot-agent-monitor.sh"
|
|
1457
1486
|
# THE THIRD MONITOR, born the same way and for the same reason. It watches the
|
|
1458
1487
|
# RUN — a Build is its own entity in the spec, so a monitor per entity is the
|
|
@@ -1472,7 +1501,44 @@ start_worker() {
|
|
|
1472
1501
|
# byte-identical to what it was — which is the 100% case, since zero charters
|
|
1473
1502
|
# exist. `PLOT_AGENT` is forwarded too, so the loop's own `resolve_prompt_file`
|
|
1474
1503
|
# asks about the same agent this launch resolved.
|
|
1475
|
-
|
|
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" && \
|
|
1476
1542
|
# AN `export`, NOT AN ENV PREFIX, AND THE REASON IS A MEASUREMENT. Bash
|
|
1477
1543
|
# recognises an assignment prefix BEFORE it expands parameters, so a
|
|
1478
1544
|
# `${caps:+PLOT_CAPABILITIES="$caps"}` in the prefix below is not an
|
|
@@ -1496,13 +1562,12 @@ start_worker() {
|
|
|
1496
1562
|
PLOT_EFFORT="$launch_effort" \
|
|
1497
1563
|
PLOT_MANIFEST_FILE="$manifest_dir/$session.json" \
|
|
1498
1564
|
PLOT_STAMP_STARTED="$stamp_now" \
|
|
1499
|
-
PLOT_WORKER_MONITOR="$worker_monitor" \
|
|
1500
1565
|
PLOT_AGENT_MONITOR="$agent_monitor" \
|
|
1501
1566
|
PLOT_BUILD_MONITOR="$build_monitor" \
|
|
1502
1567
|
PLOT_EXIT_FILE="$wt/.plot-worker.exit" PLOT_PID_FILE="$wt/.plot-worker.pid" \
|
|
1503
1568
|
PLOT_WRAPPER_PID_FILE="$wt/.plot-worker.wrapper.pid" \
|
|
1504
1569
|
PLOT_SCRIPT_DIR="$script_dir" \
|
|
1505
|
-
nohup sh -c 'printf "%s" "$$" > "$PLOT_WRAPPER_PID_FILE"; wmon=""; amon=""; bmon=""; if [ -n "$
|
|
1570
|
+
exec nohup sh -c 'printf "%s" "$$" > "$PLOT_WRAPPER_PID_FILE"; wmon=""; amon=""; bmon=""; if [ -n "$PLOT_AGENT_MONITOR" ]; then "$PLOT_AGENT_MONITOR" & amon=$!; fi; if [ -n "$PLOT_BUILD_MONITOR" ]; then "$PLOT_BUILD_MONITOR" & bmon=$!; fi; PATH="$PLOT_SCRIPT_DIR:$PATH"; export PATH; ( '"$cmd"' ) & agent=$!; trap "" TERM; printf "%s" "$agent" > "$PLOT_PID_FILE"; if [ -f "$PLOT_MANIFEST_FILE" ]; then awk -v pid="$agent" -v started="$PLOT_STAMP_STARTED" -v wrapper="$$" -v wmon="$wmon" -v amon="$amon" -v bmon="$bmon" '"'"'
|
|
1506
1571
|
BEGIN { relaunch = 0; count = 1; stamped = 0 }
|
|
1507
1572
|
FNR == NR {
|
|
1508
1573
|
if ($0 ~ /^ "pid": "[^"]*",$/) {
|
|
@@ -1535,8 +1600,8 @@ start_worker() {
|
|
|
1535
1600
|
relaunch && $0 ~ /^ "relaunches": [0-9]+,$/ { next }
|
|
1536
1601
|
relaunch && $0 ~ /^ "startedAt": "[^"]*"$/ { print " \"startedAt\": \"" started "\""; next }
|
|
1537
1602
|
{ print }
|
|
1538
|
-
'"'"' "$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"' \
|
|
1539
|
-
>"$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
|
|
1540
1605
|
echo " started worker (log: $log)"
|
|
1541
1606
|
return 0
|
|
1542
1607
|
}
|
|
@@ -1645,9 +1710,13 @@ charter_file_for() { # $1 = agent name → prints the path it would read
|
|
|
1645
1710
|
# inspectable and stoppable even if the plan was since delivered or rejected.
|
|
1646
1711
|
# Refusing to show a running worker because of a phase change would strand it.
|
|
1647
1712
|
repo_root_early=$(git rev-parse --show-toplevel)
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1713
|
+
# Only the two verbs that enumerate desks ask for the root here; every other
|
|
1714
|
+
# path asks where it composes one, so its own refusals answer first.
|
|
1715
|
+
if [ "$mode" = status ] || [ "$mode" = stop ]; then
|
|
1716
|
+
resolve_wt_root
|
|
1717
|
+
wt_root_early="$wt_root"
|
|
1718
|
+
wt_prefix_early="$wt_prefix"
|
|
1719
|
+
fi
|
|
1651
1720
|
|
|
1652
1721
|
# States: "running <pid>" | "finished <pid>" | "waiting <pid> (answer it)"
|
|
1653
1722
|
# | "stalled <pid> (work unfinished)" | "failed <pid> (exit N)"
|
|
@@ -1762,9 +1831,7 @@ if [ "$mode" = "stop" ]; then
|
|
|
1762
1831
|
# A refusal that is confidently wrong is worse than one that is terse, so the
|
|
1763
1832
|
# path-guess survives only as the LAST candidate and the refusal below says
|
|
1764
1833
|
# which places were looked in.
|
|
1765
|
-
wt=$(
|
|
1766
|
-
/^worktree / { path = substr($0, 10) }
|
|
1767
|
-
/^branch / { if (substr($0, 8) == want) { print path; exit } }')
|
|
1834
|
+
wt=$(worktree_holding_branch "$stop_branch")
|
|
1768
1835
|
wt_guess="$wt_root_early/$wt_prefix_early$(printf '%s' "$stop_branch" | tr '/' '-')"
|
|
1769
1836
|
[ -n "$wt" ] && [ -d "$wt" ] || wt="$wt_guess"
|
|
1770
1837
|
if [ ! -d "$wt" ]; then
|
|
@@ -1782,10 +1849,14 @@ if [ "$mode" = "stop" ]; then
|
|
|
1782
1849
|
# THE WHOLE PROCESS GROUP, NOT ONE PID. Measured 2026-09-30 (#1084):
|
|
1783
1850
|
# `--stop` signalled the wrapper, the group leader, and the loop, the
|
|
1784
1851
|
# prompt shell, `claude` and its children survived reparented to pid 1;
|
|
1785
|
-
# `kill -TERM -<pgid>` ended all of them.
|
|
1786
|
-
#
|
|
1787
|
-
#
|
|
1788
|
-
#
|
|
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.
|
|
1789
1860
|
stop_pgid=$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ')
|
|
1790
1861
|
stop_own_pgid=$(ps -o pgid= -p "$$" 2>/dev/null | tr -d ' ')
|
|
1791
1862
|
stop_target="$pid"
|
|
@@ -1846,9 +1917,7 @@ if [ "$mode" = "restart" ]; then
|
|
|
1846
1917
|
# wrong. It matters more here than anywhere: the population this verb serves
|
|
1847
1918
|
# includes the worktree a person made by hand after the tool had no verb for
|
|
1848
1919
|
# them, and a hand-made worktree rarely follows dispatch's naming.
|
|
1849
|
-
restart_wt=$(
|
|
1850
|
-
/^worktree / { path = substr($0, 10) }
|
|
1851
|
-
/^branch / { if (substr($0, 8) == want) { print path; exit } }')
|
|
1920
|
+
restart_wt=$(worktree_holding_branch "$restart_branch")
|
|
1852
1921
|
if [ -z "$restart_wt" ] || [ ! -d "$restart_wt" ]; then
|
|
1853
1922
|
echo "plot-dispatch: no worktree holds '$restart_branch' — nothing to restart." >&2
|
|
1854
1923
|
echo " --restart hands an EXISTING checkout to a new worker; it creates none." >&2
|
|
@@ -1892,7 +1961,11 @@ if [ "$mode" = "restart" ]; then
|
|
|
1892
1961
|
# resets or stashes: a restart that discards that is worse than the missing
|
|
1893
1962
|
# affordance, because it looks like a supported operation. The new worker's
|
|
1894
1963
|
# brief already tells it to commit and push before verifying.
|
|
1895
|
-
|
|
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
|
|
1896
1969
|
echo " uncommitted work in the tree is kept — the new worker inherits it"
|
|
1897
1970
|
fi
|
|
1898
1971
|
|
|
@@ -2000,35 +2073,49 @@ if [ "$mode" = "release" ]; then
|
|
|
2000
2073
|
# THE DESK, ASKED OF GIT — never rebuilt from the branch name, the rule every
|
|
2001
2074
|
# other verb here follows. A manifest naming this branch may name a desk git
|
|
2002
2075
|
# does not list (removed by hand), so its `worktree` is the fallback.
|
|
2003
|
-
release_wt=$(
|
|
2004
|
-
/^worktree / { path = substr($0, 10) }
|
|
2005
|
-
/^branch / { if (substr($0, 8) == want) { print path; exit } }')
|
|
2076
|
+
release_wt=$(worktree_holding_branch "$br")
|
|
2006
2077
|
registry_dir=$(agent_registry_dir "$repo_root_early")
|
|
2007
2078
|
named_manifests=()
|
|
2008
2079
|
if [ -d "$registry_dir" ]; then
|
|
2009
2080
|
for m in "$registry_dir"/*.json; do
|
|
2010
2081
|
[ -f "$m" ] || continue
|
|
2011
|
-
m_branch=$(
|
|
2012
|
-
try {
|
|
2013
|
-
const m = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
|
2014
|
-
process.stdout.write(typeof m.branch === "string" ? m.branch : "");
|
|
2015
|
-
} catch { process.stdout.write(""); }
|
|
2016
|
-
' "$m" 2>/dev/null)
|
|
2082
|
+
m_branch=$(manifest_string "$m" branch) || m_branch=""
|
|
2017
2083
|
[ "$m_branch" = "$br" ] || continue
|
|
2018
2084
|
named_manifests+=("$m")
|
|
2019
2085
|
if [ -z "$release_wt" ]; then
|
|
2020
|
-
m_wt=$(
|
|
2021
|
-
try {
|
|
2022
|
-
const m = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
|
2023
|
-
process.stdout.write(typeof m.worktree === "string" ? m.worktree : "");
|
|
2024
|
-
} catch { process.stdout.write(""); }
|
|
2025
|
-
' "$m" 2>/dev/null)
|
|
2086
|
+
m_wt=$(manifest_string "$m" worktree) || m_wt=""
|
|
2026
2087
|
[ -n "$m_wt" ] && [ -d "$m_wt" ] && release_wt="$m_wt"
|
|
2027
2088
|
fi
|
|
2028
2089
|
done
|
|
2029
2090
|
fi
|
|
2030
2091
|
[ -n "$release_wt" ] && [ -d "$release_wt" ] || release_wt=""
|
|
2031
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
|
+
|
|
2032
2119
|
# 2. A LIVE WORKER — the measurement `--restart` makes, through the shared
|
|
2033
2120
|
# classifier, never `pgrep` by name. A live pid means somebody is working,
|
|
2034
2121
|
# and the one case where a claim is not abandoned.
|
|
@@ -2132,11 +2219,76 @@ if [ "$mode" = "release" ]; then
|
|
|
2132
2219
|
else
|
|
2133
2220
|
echo " origin/$br does not exist — only the assignment needed releasing"
|
|
2134
2221
|
fi
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
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
|
|
2138
2290
|
echo "released $br — the slice returns to the queue"
|
|
2139
|
-
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"
|
|
2140
2292
|
exit 0
|
|
2141
2293
|
fi
|
|
2142
2294
|
|
|
@@ -2160,7 +2312,6 @@ if [ "$mode" = "start" ]; then
|
|
|
2160
2312
|
# about any plan, so there is no plan whose phase could refuse it. A gate on a
|
|
2161
2313
|
# slug this verb never takes would refuse every call.
|
|
2162
2314
|
repo_root="$repo_root_early"
|
|
2163
|
-
resolve_wt_root "$repo_root"
|
|
2164
2315
|
|
|
2165
2316
|
# THE DEFAULT BRANCH, by the same three steps the fan-out takes below — the
|
|
2166
2317
|
# config key, then origin's own HEAD, then `main`. Resolved here because the
|
|
@@ -2370,6 +2521,9 @@ EOF
|
|
|
2370
2521
|
# `wait_for_work` skips the outlook scan for an agent that holds none.
|
|
2371
2522
|
slug=""
|
|
2372
2523
|
|
|
2524
|
+
# Where the desks go, asked only now: every refusal above answers first, and
|
|
2525
|
+
# a broken runtime is named by the start-command check rather than here.
|
|
2526
|
+
resolve_wt_root
|
|
2373
2527
|
start_made=0
|
|
2374
2528
|
start_i=0
|
|
2375
2529
|
while [ "$start_i" -lt "$start_n" ]; do
|
|
@@ -2423,6 +2577,9 @@ EOF
|
|
|
2423
2577
|
continue
|
|
2424
2578
|
fi
|
|
2425
2579
|
mkdir -p "$wt_root" 2>/dev/null || true
|
|
2580
|
+
# The desk root lies inside the repository by default, so keep it out of
|
|
2581
|
+
# `git status` the moment it exists.
|
|
2582
|
+
plot_exclude_desk_root "$(plot_repo_root)"
|
|
2426
2583
|
if ! git worktree add -q --detach "$start_wt" "origin/$start_main" 2>/dev/null; then
|
|
2427
2584
|
# NO REMOTE REF IS NOT A FAILURE OF THIS VERB. A fresh clone or a repo
|
|
2428
2585
|
# with no remote has no `origin/<main>`; the local one is the same commit
|
|
@@ -2438,10 +2595,7 @@ EOF
|
|
|
2438
2595
|
# the marker is ignored via `info/exclude` rather than `.gitignore`, because
|
|
2439
2596
|
# an untracked file in a desk reads as unlanded work to
|
|
2440
2597
|
# `plot-worker-state.sh` and would make every free agent look stalled.
|
|
2441
|
-
|
|
2442
|
-
if [ -f "$_excl" ] && ! grep -qxF '.metadata_never_index' "$_excl" 2>/dev/null; then
|
|
2443
|
-
printf '%s\n' '.metadata_never_index' >> "$_excl" 2>/dev/null || true
|
|
2444
|
-
fi
|
|
2598
|
+
plot_desk_exclude "$start_wt" '.metadata_never_index'
|
|
2445
2599
|
: > "$start_wt/.metadata_never_index" 2>/dev/null || true
|
|
2446
2600
|
|
|
2447
2601
|
echo " desk $start_wt (detached at origin/$start_main)"
|
|
@@ -2484,8 +2638,9 @@ if [ "$mode" = "migrate" ]; then
|
|
|
2484
2638
|
configured_root=$("$script_dir/plot-config.sh" get "Worktree root" "")
|
|
2485
2639
|
if [ -z "$configured_root" ]; then
|
|
2486
2640
|
echo "plot-dispatch --migrate: no 'Worktree root:' configured — nothing to migrate."
|
|
2487
|
-
echo " When Worktree root is absent, worktrees
|
|
2488
|
-
echo "
|
|
2641
|
+
echo " When Worktree root is absent, new worktrees go to <repo>/.worktrees;"
|
|
2642
|
+
echo " desks an older dispatch made beside the repo (plot-wt-*) stay there."
|
|
2643
|
+
echo " To move those, first add a 'Worktree root:' key to ## Plot Config."
|
|
2489
2644
|
exit 0
|
|
2490
2645
|
fi
|
|
2491
2646
|
|
|
@@ -2922,13 +3077,20 @@ prereq_answer() { # $1=prerequisite branch → merged|unmerged|none|unreachable
|
|
|
2922
3077
|
}
|
|
2923
3078
|
|
|
2924
3079
|
# Every branch the plan annotates `waits:`, with what it waits on — read from
|
|
2925
|
-
# 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).
|
|
2926
3088
|
#
|
|
2927
3089
|
# NON-DEFERRED ONLY. `deferred:` is a JUDGEMENT — somebody gave the branch up —
|
|
2928
3090
|
# and it outranks a wait for the same reason the scan lets it: a branch nobody
|
|
2929
3091
|
# will start does not need to be told what it is waiting for. The two
|
|
2930
3092
|
# annotations sit on one line and neither reads the other's value.
|
|
2931
|
-
waits_pairs() { # → branch<TAB>prerequisite, one per
|
|
3093
|
+
waits_pairs() { # → branch<TAB>prerequisite, one line per prerequisite
|
|
2932
3094
|
printf '%s' "$gate_meta" | awk '
|
|
2933
3095
|
{
|
|
2934
3096
|
n = split($0, parts, /\{"branch":"/)
|
|
@@ -2936,9 +3098,11 @@ waits_pairs() { # → branch<TAB>prerequisite, one per annotated branch
|
|
|
2936
3098
|
rec = parts[i]
|
|
2937
3099
|
br = rec; sub(/".*$/, "", br)
|
|
2938
3100
|
if (rec ~ /"deferred":true/) continue
|
|
2939
|
-
if (match(rec, /"waits_on"
|
|
2940
|
-
|
|
2941
|
-
|
|
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]
|
|
2942
3106
|
}
|
|
2943
3107
|
}
|
|
2944
3108
|
}'
|
|
@@ -2991,12 +3155,9 @@ is_waits_held() {
|
|
|
2991
3155
|
# override and the scan remains the only thing that decides them. The branch
|
|
2992
3156
|
# still passes every gate the loops apply after it: `held_worktree`, the claim
|
|
2993
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.
|
|
2994
3160
|
declare -a waits_freed=()
|
|
2995
|
-
is_waits_freed() {
|
|
2996
|
-
local x
|
|
2997
|
-
for x in ${waits_freed[@]+"${waits_freed[@]}"}; do [ "$x" = "$1" ] && return 0; done
|
|
2998
|
-
return 1
|
|
2999
|
-
}
|
|
3000
3161
|
|
|
3001
3162
|
# Runs the preflight: prints its refusals, fills `waits_held`, and adds what it
|
|
3002
3163
|
# withheld to `n_skipped`.
|
|
@@ -3020,13 +3181,22 @@ run_waits_preflight() { # → prints refusals; fills waits_held, adds to n_skipp
|
|
|
3020
3181
|
none) held=blocked ;;
|
|
3021
3182
|
*) held=waiting ;;
|
|
3022
3183
|
esac
|
|
3023
|
-
# `--allow-waiting` SAYS SO ON THE LINE IT OVERRIDES
|
|
3024
|
-
# see in the output is an override nobody can
|
|
3184
|
+
# `--allow-waiting` SAYS SO ON THE LINE IT OVERRIDES, ONCE PER PREREQUISITE
|
|
3185
|
+
# — an override nobody can see in the output is an override nobody can
|
|
3186
|
+
# audit, and a branch with two unmerged prerequisites names both so the
|
|
3187
|
+
# audit trail is complete.
|
|
3025
3188
|
if [ "$allow_waiting" = 1 ]; then
|
|
3026
3189
|
echo "$br waits on $prereq ($held) — dispatching anyway (--allow-waiting)"
|
|
3027
3190
|
waits_freed+=("$br")
|
|
3028
3191
|
continue
|
|
3029
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
|
|
3030
3200
|
waits_held+=("$br")
|
|
3031
3201
|
n_skipped=$((n_skipped + 1))
|
|
3032
3202
|
if [ "$held" = "blocked" ]; then
|
|
@@ -3042,13 +3212,12 @@ run_waits_preflight() { # → prints refusals; fills waits_held, adds to n_skipp
|
|
|
3042
3212
|
}
|
|
3043
3213
|
|
|
3044
3214
|
# Where the worktrees live and what their names carry — see resolve_wt_root.
|
|
3045
|
-
# The default is
|
|
3046
|
-
# key relocates them
|
|
3047
|
-
#
|
|
3048
|
-
#
|
|
3049
|
-
# outside the repo.
|
|
3215
|
+
# The default is `<main checkout>/.worktrees` with no prefix; a `Worktree root:`
|
|
3216
|
+
# key relocates them. A root inside the repository is kept out of `git status`
|
|
3217
|
+
# by the `info/exclude` line `plot_exclude_desk_root` writes when a desk is
|
|
3218
|
+
# created.
|
|
3050
3219
|
repo_root=$(git rev-parse --show-toplevel)
|
|
3051
|
-
resolve_wt_root
|
|
3220
|
+
resolve_wt_root
|
|
3052
3221
|
|
|
3053
3222
|
n_dispatched=0 n_reused=0 n_skipped=0 n_started=0
|
|
3054
3223
|
n_brief_asked=0
|
|
@@ -3132,21 +3301,6 @@ read_parallel_agents_cap() {
|
|
|
3132
3301
|
[ -n "$cap" ] && echo "$cap" || echo 3
|
|
3133
3302
|
}
|
|
3134
3303
|
|
|
3135
|
-
# Count workers in live states (running or waiting) across all fleet worktrees.
|
|
3136
|
-
# These are the slots that count against the cap.
|
|
3137
|
-
count_live_workers() {
|
|
3138
|
-
local n=0 wt br st
|
|
3139
|
-
for wt in "$wt_root"/"$wt_prefix"*; do
|
|
3140
|
-
[ -d "$wt" ] || continue
|
|
3141
|
-
br=$(git -C "$wt" branch --show-current 2>/dev/null || echo "?")
|
|
3142
|
-
st=$(worker_state "$wt" "$br")
|
|
3143
|
-
case "$st" in
|
|
3144
|
-
running*|waiting*) n=$((n + 1)) ;;
|
|
3145
|
-
esac
|
|
3146
|
-
done
|
|
3147
|
-
echo "$n"
|
|
3148
|
-
}
|
|
3149
|
-
|
|
3150
3304
|
# Get the branches currently occupying slots (for the warning message).
|
|
3151
3305
|
live_worker_branches() {
|
|
3152
3306
|
local wt br st
|
|
@@ -3160,6 +3314,13 @@ live_worker_branches() {
|
|
|
3160
3314
|
done
|
|
3161
3315
|
}
|
|
3162
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
|
+
|
|
3163
3324
|
# Update the parallel-agents cap in the fleet controls file.
|
|
3164
3325
|
# Creates the file (and directory) if needed, preserving autoDispatch if present.
|
|
3165
3326
|
update_parallel_agents_cap() { # $1 = new cap
|
|
@@ -3289,6 +3450,9 @@ write_started_record() { # $@ = branches
|
|
|
3289
3450
|
# stale tip and the push would be a guaranteed non-fast-forward.
|
|
3290
3451
|
git fetch -q origin "$MAIN" 2>/dev/null
|
|
3291
3452
|
|
|
3453
|
+
# `git worktree add` creates the desk root when it is missing; exclude it
|
|
3454
|
+
# first so the booking leaves no untracked `.worktrees/` behind.
|
|
3455
|
+
plot_exclude_desk_root "$(plot_repo_root)"
|
|
3292
3456
|
# -B: a leftover branch from an earlier failed booking must not block this
|
|
3293
3457
|
# one. It is disposable by construction — created here, pushed, deleted.
|
|
3294
3458
|
if ! git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null; then
|
|
@@ -3506,10 +3670,22 @@ committed_files() { # $1=branch → paths, one per line
|
|
|
3506
3670
|
|
|
3507
3671
|
# Files a branch holds only in its worktree — no ref carries these, so they are
|
|
3508
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.
|
|
3509
3680
|
uncommitted_files() { # $1=worktree → paths, one per line
|
|
3510
|
-
local wt="$1"
|
|
3681
|
+
local wt="$1" bundles
|
|
3511
3682
|
[ -d "$wt" ] || return 0
|
|
3512
|
-
|
|
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
|
+
}
|
|
3513
3689
|
{
|
|
3514
3690
|
# Porcelain v1: XY then a space then the path. A rename prints
|
|
3515
3691
|
# "old -> new"; the new path is the one on disk.
|
|
@@ -3517,7 +3693,7 @@ uncommitted_files() { # $1=worktree → paths, one per line
|
|
|
3517
3693
|
i = index(line, " -> ")
|
|
3518
3694
|
if (i > 0) line = substr(line, i + 4)
|
|
3519
3695
|
gsub(/^"|"$/, "", line)
|
|
3520
|
-
if (line != "") print line
|
|
3696
|
+
if (line != "" && !(line in excluded)) print line
|
|
3521
3697
|
}'
|
|
3522
3698
|
}
|
|
3523
3699
|
|
|
@@ -3625,9 +3801,7 @@ held_worktree() { # $1=branch → prints the worktree path when held, else nothi
|
|
|
3625
3801
|
# refs/heads/<name>` per entry, so the branch line is matched and the path
|
|
3626
3802
|
# remembered from the preceding line. A detached worktree has no branch line
|
|
3627
3803
|
# and never matches, which is right: it holds no branch to hold.
|
|
3628
|
-
wt=$(
|
|
3629
|
-
/^worktree / { path = substr($0, 10) }
|
|
3630
|
-
/^branch / { if (substr($0, 8) == want) { print path; exit } }')
|
|
3804
|
+
wt=$(worktree_holding_branch "$br")
|
|
3631
3805
|
[ -n "$wt" ] || return 1
|
|
3632
3806
|
# A registered worktree whose directory is gone (removed by hand, not via
|
|
3633
3807
|
# `git worktree remove`) holds nobody. `status` cannot be read there anyway.
|
|
@@ -3769,7 +3943,11 @@ IN_FLIGHT_MAX_BRANCHES=8
|
|
|
3769
3943
|
report_monitors() { # $1=worktree
|
|
3770
3944
|
[ "$show_monitors" = 1 ] || return 0
|
|
3771
3945
|
local wt="$1" m
|
|
3772
|
-
|
|
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
|
|
3773
3951
|
local script="$script_dir/plot-$m-monitor.sh"
|
|
3774
3952
|
if [ -x "$script" ]; then
|
|
3775
3953
|
echo " would attach: $script → $wt"
|