@plot-pm/board 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/board-server.mjs +170 -164
- package/package.json +6 -1
- package/plot-agent-monitor.sh +508 -0
- package/plot-approve.sh +145 -29
- package/plot-budget.sh +439 -0
- package/plot-config.sh +9 -0
- package/plot-deliver.sh +210 -117
- package/plot-dispatch.sh +829 -185
- package/plot-fleet-scan.sh +577 -65
- package/plot-host.sh +1329 -55
- package/plot-plan-meta.sh +246 -36
- package/plot-reap.sh +675 -57
- package/plot-transcript-quiet.sh +142 -0
- package/plot-worker-monitor.sh +644 -0
- package/plot-worker-state.sh +81 -50
package/plot-dispatch.sh
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Plot helper:
|
|
2
|
+
# Plot helper: hand one slice + its brief to the registry per eligible branch.
|
|
3
3
|
# Usage: plot-dispatch.sh [--dry-run] [--no-start] [--no-brief] [--offline]
|
|
4
4
|
# [--max N] [--allow-local] <slug>
|
|
5
5
|
# plot-dispatch.sh --migrate [--yes] [--max N]
|
|
@@ -22,29 +22,42 @@
|
|
|
22
22
|
# config — without one there is no destination. --dry-run by
|
|
23
23
|
# default; --yes to actually move.
|
|
24
24
|
# --dry-run print what would happen; create nothing, push nothing
|
|
25
|
+
# --monitors with --dry-run, also name which monitors would be attached to
|
|
26
|
+
# which worktree. Opt-in so the default --dry-run output stays
|
|
27
|
+
# byte-identical, which is what lets it be diffed against a run
|
|
28
|
+
# from before a change to this script.
|
|
25
29
|
# --yes with --migrate, actually move the worktrees (default is dry-run)
|
|
26
|
-
# --no-start
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
30
|
+
# --no-start record that no worker was wanted: the run still hands its slices
|
|
31
|
+
# over, and reports `worker=suppressed` rather than a missing
|
|
32
|
+
# `Worker command`. Dispatch starts no worker either way — the
|
|
33
|
+
# registry spawns agents — so this now says WHY the zero was
|
|
34
|
+
# chosen and no longer changes what the run does.
|
|
35
|
+
# --no-brief hand a slice over even when its branch has no brief. The named
|
|
36
|
+
# escape for the brief gate: a missing brief is not handed over,
|
|
37
|
+
# because the agent's first instruction is to read
|
|
38
|
+
# `.plot/briefs/<branch>.md` and it has nothing to read. A refused
|
|
39
|
+
# slice leaves no desk and no claim and stays in the queue.
|
|
40
|
+
# --no-brief overrides that and says so.
|
|
32
41
|
# --offline skip `git fetch`
|
|
33
42
|
# --max N dispatch at most N branches this run (default: all eligible)
|
|
34
43
|
# --allow-local read the plan's phase from the working tree when
|
|
35
44
|
# origin/<main> cannot be resolved (no remote, fresh clone).
|
|
36
45
|
# The explicit escape for a remote-less repo — never a default,
|
|
37
46
|
# because a working-tree read is what this gate exists to avoid.
|
|
47
|
+
# --allow-waiting dispatch a branch whose `waits:` prerequisite has not
|
|
48
|
+
# merged. The named escape for the prerequisite gate, in the
|
|
49
|
+
# tradition of --allow-local: a gate with no exit is one people
|
|
50
|
+
# route around by never annotating at all. It says so on the
|
|
51
|
+
# line it overrides, so the override is on the record.
|
|
38
52
|
# <slug> the plan to fan out
|
|
39
53
|
# Output: one line per branch, each optionally followed by an indented
|
|
40
54
|
# `in flight:` line naming a branch that already holds files, then the
|
|
41
|
-
# summary block — an optional prose consequence line, then a
|
|
42
55
|
# machine-countable footer.
|
|
43
56
|
# A branch whose worktree exists with UNMERGED work is refused rather
|
|
44
|
-
# than
|
|
57
|
+
# than handed over — counted `skipped`, with the worktree path named,
|
|
45
58
|
# in `--dry-run` identically to a real run. See "THE HELD-BRANCH GATE".
|
|
46
|
-
#
|
|
47
|
-
# summary: dispatched=2 reused=0 skipped=1 started=
|
|
59
|
+
# handed over feature/one → the registry
|
|
60
|
+
# summary: dispatched=2 reused=0 skipped=1 started=0 brief=missing worker=unconfigured brief_asked=0
|
|
48
61
|
#
|
|
49
62
|
# THE CONSEQUENCE IS STATED IN THE SUMMARY, NOT PER BRANCH. start_worker has
|
|
50
63
|
# always said "no 'Worker command' configured" beside the branch it could not
|
|
@@ -77,6 +90,23 @@
|
|
|
77
90
|
# over. It does NOT refuse: --dry-run and --status are legitimate direct calls,
|
|
78
91
|
# and a gate that blocks looking-before-leaping is a gate in the wrong place.
|
|
79
92
|
#
|
|
93
|
+
# `brief_asked=N` counts what the script did about it, and the distinction from
|
|
94
|
+
# `brief=missing` is exact: this script still writes no brief, it ASKS one to be
|
|
95
|
+
# written. `Brief command` names how to run an agent headless for one prompt,
|
|
96
|
+
# and the prompt is `/plot-implement <slug>` — the skill that already owns brief
|
|
97
|
+
# authorship. Absent key, or `none`: nothing is asked, the branch is refused as
|
|
98
|
+
# before, and the per-branch line names `no-brief-command` so the log says which
|
|
99
|
+
# arm ran. A project that never sets the key sees `brief_asked=0` and today's
|
|
100
|
+
# behaviour exactly.
|
|
101
|
+
#
|
|
102
|
+
# `brief_asked=N` COUNTS COMMANDS STARTED, NEVER BRIEFS WRITTEN. The command is
|
|
103
|
+
# detached by design and this script never waits on it, so a command that dies
|
|
104
|
+
# in its first millisecond is counted the same as one that writes and pushes a
|
|
105
|
+
# brief. Measured 2026-09-02: a `Brief command` that could not reach
|
|
106
|
+
# `/plot-implement` wrote a 33-byte log and the summary reported
|
|
107
|
+
# `brief_asked=1`. The per-branch line names the log for exactly this reason —
|
|
108
|
+
# the log is the evidence, the count is only that an attempt was made.
|
|
109
|
+
#
|
|
80
110
|
# THIS IS THE ONE SCRIPT IN THE FLEET THAT WRITES. Everything else
|
|
81
111
|
# (plot-fleet-scan.sh, plot-reconcile-scan.sh) is read-only. Consequently every
|
|
82
112
|
# write here is either idempotent or refused:
|
|
@@ -113,6 +143,14 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
|
113
143
|
# shellcheck source=plot-worker-state.sh
|
|
114
144
|
. "$script_dir/plot-worker-state.sh"
|
|
115
145
|
|
|
146
|
+
# The ONE answer to "did the host merge ANY PR for this branch?" — `pr_merged`,
|
|
147
|
+
# read by `held_worktree` rather than derived from ancestry. Sourced for the
|
|
148
|
+
# same reason `plot-reap.sh` and `plot-release-refs.sh` source it: three callers
|
|
149
|
+
# gate on one fact and must never disagree about it. The helper defines two
|
|
150
|
+
# functions and does nothing else on load.
|
|
151
|
+
# shellcheck source=plot-pr-merged.sh
|
|
152
|
+
. "$script_dir/plot-pr-merged.sh"
|
|
153
|
+
|
|
116
154
|
# ---------------------------------------------------------------------------
|
|
117
155
|
# WHERE THE WORKTREES LIVE, and by what name
|
|
118
156
|
# ---------------------------------------------------------------------------
|
|
@@ -155,6 +193,7 @@ resolve_wt_root() { # $1=repo_root → sets globals wt_root, wt_prefix
|
|
|
155
193
|
}
|
|
156
194
|
|
|
157
195
|
dry_run=0
|
|
196
|
+
show_monitors=0
|
|
158
197
|
no_start=0
|
|
159
198
|
no_brief=0
|
|
160
199
|
mode=dispatch
|
|
@@ -162,12 +201,23 @@ stop_branch=""
|
|
|
162
201
|
restart_branch=""
|
|
163
202
|
offline=""
|
|
164
203
|
allow_local=0
|
|
204
|
+
allow_waiting=0
|
|
165
205
|
max=0
|
|
166
206
|
slug=""
|
|
167
207
|
migrate_yes=0
|
|
168
208
|
while [ $# -gt 0 ]; do
|
|
169
209
|
case "$1" in
|
|
170
210
|
--dry-run) dry_run=1 ;;
|
|
211
|
+
# --monitors NAMES what would be attached, and it is OPT-IN for a reason
|
|
212
|
+
# that is a protection rather than a preference. `plot-dispatch.sh` is the
|
|
213
|
+
# largest script here and a mistake in start_worker starts no workers at
|
|
214
|
+
# all, so this slice pins `--dry-run` output as BYTE-IDENTICAL before and
|
|
215
|
+
# after on the same estate — the dry run exercises every refusal against
|
|
216
|
+
# real worktrees and real pids without starting or removing anything, which
|
|
217
|
+
# is the same protection the reap and dispatch domain work used. A line
|
|
218
|
+
# added to the default output would forfeit exactly that check. So the
|
|
219
|
+
# naming lives behind its own flag: `--dry-run --monitors`.
|
|
220
|
+
--monitors) show_monitors=1 ;;
|
|
171
221
|
--status) mode=status ;;
|
|
172
222
|
--migrate) mode=migrate ;;
|
|
173
223
|
--yes) migrate_yes=1 ;;
|
|
@@ -184,12 +234,13 @@ while [ $# -gt 0 ]; do
|
|
|
184
234
|
--no-brief) no_brief=1 ;;
|
|
185
235
|
--offline|--no-fetch) offline="--offline" ;;
|
|
186
236
|
--allow-local) allow_local=1 ;;
|
|
237
|
+
--allow-waiting) allow_waiting=1 ;;
|
|
187
238
|
--max) max="${2:?--max needs a value}"
|
|
188
239
|
case "$max" in
|
|
189
240
|
''|*[!0-9]*) echo "plot-dispatch: --max needs a number, got '$max'" >&2; exit 1 ;;
|
|
190
241
|
esac
|
|
191
242
|
shift ;;
|
|
192
|
-
-h|--help) sed -n '2,
|
|
243
|
+
-h|--help) sed -n '2,46p' "$0"; exit 0 ;;
|
|
193
244
|
*) slug="$1" ;;
|
|
194
245
|
esac
|
|
195
246
|
shift
|
|
@@ -248,6 +299,22 @@ json_escape() {
|
|
|
248
299
|
#
|
|
249
300
|
# Model and context are still absent on purpose: they belong to the runtime and
|
|
250
301
|
# are read from the transcript, so a manifest that named them would be a guess.
|
|
302
|
+
#
|
|
303
|
+
# `resumeId` AND `session` ARE TWO FIELDS THAT HOLD ONE VALUE AT LAUNCH, and
|
|
304
|
+
# they are written separately on purpose. `session` is the transcript join key
|
|
305
|
+
# and STAYS FIXED across a branch hop, by design — `plot-worker-loop.sh` rewrites
|
|
306
|
+
# `branch` and `worktree` on each hop and leaves `session` alone. The resume
|
|
307
|
+
# handle is a different identity with a different lifetime, and whether it should
|
|
308
|
+
# follow a hop cannot even be ASKED while one field carries both meanings. They
|
|
309
|
+
# will usually agree; nothing may assume they always do.
|
|
310
|
+
#
|
|
311
|
+
# `attempts` IS THE SUPERVISOR'S OWN COUNTER, DISTINCT FROM `relaunches`.
|
|
312
|
+
# `relaunches` counts operator-initiated restarts — a human's record, written by
|
|
313
|
+
# the launch stamp. `attempts` counts a supervisor's own retries and is what a
|
|
314
|
+
# bound would read. Merging them would let a person's three manual restarts
|
|
315
|
+
# exhaust an automatic budget, or the reverse. It is written 0 here and by
|
|
316
|
+
# nothing else in this script: no component in Plot raises it yet, and a launch
|
|
317
|
+
# that guessed at one would be recording a retry nobody made.
|
|
251
318
|
# The `pid` starts EMPTY here and is stamped by the wrapper the instant it learns
|
|
252
319
|
# its own child — see `stamp_manifest_pid`. The dispatcher does not know the
|
|
253
320
|
# agent pid at this line (only the wrapper does, from its `$!`), so it writes the
|
|
@@ -260,10 +327,12 @@ write_agent_manifest() { # $1=path $2=session $3=branch $4=worktree $5=command
|
|
|
260
327
|
{
|
|
261
328
|
printf '{\n'
|
|
262
329
|
printf ' "session": "%s",\n' "$(json_escape "$2")"
|
|
330
|
+
printf ' "resumeId": "%s",\n' "$(json_escape "$2")"
|
|
263
331
|
printf ' "branch": "%s",\n' "$(json_escape "$3")"
|
|
264
332
|
printf ' "worktree": "%s",\n' "$(json_escape "$4")"
|
|
265
333
|
printf ' "command": "%s",\n' "$(json_escape "$5")"
|
|
266
334
|
printf ' "pid": "",\n'
|
|
335
|
+
printf ' "attempts": 0,\n'
|
|
267
336
|
printf ' "startedAt": "%s"\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
268
337
|
printf '}\n'
|
|
269
338
|
} > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
|
|
@@ -302,11 +371,16 @@ write_agent_manifest() { # $1=path $2=session $3=branch $4=worktree $5=command
|
|
|
302
371
|
#
|
|
303
372
|
# READ FROM `origin/<main>`, NOT THE WORKING TREE, for the same reason the phase
|
|
304
373
|
# gate above does: the question is not "does a brief exist in this filesystem?"
|
|
305
|
-
# but "will the
|
|
306
|
-
# `origin/$MAIN
|
|
307
|
-
#
|
|
308
|
-
#
|
|
309
|
-
#
|
|
374
|
+
# but "will the AGENT find one?". The agent cuts or resets its desk from
|
|
375
|
+
# `origin/$MAIN`, so a brief committed nowhere — or committed locally and never
|
|
376
|
+
# pushed — is invisible to it. Checking the working tree passes the gate and
|
|
377
|
+
# hands over a slice whose specification is empty, which is the exact failure
|
|
378
|
+
# this gate exists to prevent.
|
|
379
|
+
#
|
|
380
|
+
# THAT HOLDS ACROSS THE GATE'S MOVE, and is why the move cost this function
|
|
381
|
+
# nothing: the desk was cut from `origin/$MAIN` when dispatch cut it and is cut
|
|
382
|
+
# from `origin/$MAIN` now that the agent does. The ref the brief must be on did
|
|
383
|
+
# not change, only who reads it there.
|
|
310
384
|
#
|
|
311
385
|
# Both directions were measured 2026-08-27. Running the filesystem check from a
|
|
312
386
|
# checkout 8 commits behind main reported three branches' briefs missing while
|
|
@@ -326,6 +400,121 @@ brief_present() { # $1 = branch → 0 if a usable brief exists on origin/<main>
|
|
|
326
400
|
[ "${sz:-0}" -gt 0 ]
|
|
327
401
|
}
|
|
328
402
|
|
|
403
|
+
# WHAT HAPPENS AFTER THE GATE FIRES. The gate above is correct and stays: a
|
|
404
|
+
# missing brief still prepares and still refuses to start. What it never had is
|
|
405
|
+
# a next step — it named the file, and every brief on this estate was then
|
|
406
|
+
# written by hand.
|
|
407
|
+
#
|
|
408
|
+
# `Brief command` is that step, and it is a CONFIG KEY rather than a new script
|
|
409
|
+
# for one reason: `/plot-implement` step 4 already owns brief authorship. A
|
|
410
|
+
# script here would be a SECOND brief writer, and two writers drift. So the key
|
|
411
|
+
# names how to run an agent headless — the shape `Idea command`, `Story command`
|
|
412
|
+
# and `Approve command` already use — and the prompt it is handed asks for
|
|
413
|
+
# `/plot-implement <slug>` and nothing else.
|
|
414
|
+
#
|
|
415
|
+
# ABSENT IS NOT AN ERROR. A project with no `Brief command` behaves exactly as
|
|
416
|
+
# it does today: the gate refuses, and the refusal now names WHY nothing was
|
|
417
|
+
# called — `no-brief-command`, the shape `commission.ts` gives `no-idea-command`.
|
|
418
|
+
# `none` reads the same way as it does for `Worker command`: asked, and answered
|
|
419
|
+
# "we write them by hand".
|
|
420
|
+
brief_command() { # → the usable `Brief command`, or empty
|
|
421
|
+
local cmd
|
|
422
|
+
cmd=$("$script_dir/plot-config.sh" get "Brief command" "")
|
|
423
|
+
case "$cmd" in none|NONE|None) cmd="" ;; esac
|
|
424
|
+
printf '%s' "$cmd"
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
# Run the brief command for one branch, detached, and say what was done.
|
|
428
|
+
#
|
|
429
|
+
# DETACHED AND NOT WAITED ON, for the reason `commission.ts` gives: this is a
|
|
430
|
+
# `claude -p` session of unknown length, and a fan-out that blocks on one would
|
|
431
|
+
# hold every later branch behind it. The dispatch run reports that it asked; the
|
|
432
|
+
# brief lands in a later commit, and the NEXT dispatch of the same branch starts
|
|
433
|
+
# it. That is the whole loop.
|
|
434
|
+
#
|
|
435
|
+
# The prompt travels as ONE argument through `"$@"`, never interpolated into the
|
|
436
|
+
# command string — `Brief command` is a shell FRAGMENT run through `sh -c`, so
|
|
437
|
+
# anything spliced into it would be shell source. The slug is the only value
|
|
438
|
+
# that reaches it and it is a plan slug, but the rule holds regardless of the
|
|
439
|
+
# value: the safety is in the shape, not in the input.
|
|
440
|
+
# WHAT THE AGENT IS ASKED FOR, and the one thing it must not be asked for.
|
|
441
|
+
#
|
|
442
|
+
# It is asked to run `/plot-implement <slug>`, and it is NOT asked to write a
|
|
443
|
+
# brief in its own words. `/plot-implement` step 4 owns brief authorship; a
|
|
444
|
+
# prompt that described the brief here would be a second author, and two
|
|
445
|
+
# authors drift. The branch and the path are named because the skill writes for
|
|
446
|
+
# one branch and the gate reads one path — `brief_path` is the same function
|
|
447
|
+
# `brief_present` reads with, so writer and reader cannot disagree.
|
|
448
|
+
#
|
|
449
|
+
# It is told to COMMIT AND PUSH, because the gate reads `origin/<main>`. A brief
|
|
450
|
+
# written and left in a working tree is invisible to the gate that asked for it,
|
|
451
|
+
# and the next dispatch would ask again — a loop that writes a file every pass
|
|
452
|
+
# and never starts a worker.
|
|
453
|
+
brief_prompt() { # $1 = branch, $2 = slug
|
|
454
|
+
printf '/plot-implement %s — write the hand-off brief for branch `%s` at %s, then commit and push it to %s. The dispatch gate reads that path on origin/%s, so a brief left uncommitted is invisible to it.' \
|
|
455
|
+
"$2" "$1" "$(brief_path "$1")" "$MAIN" "$MAIN"
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
request_brief() { # $1 = branch, $2 = slug → 0 if a command was started
|
|
459
|
+
local branch="$1" bslug="$2" cmd log
|
|
460
|
+
cmd=$(brief_command)
|
|
461
|
+
if [ -z "$cmd" ]; then
|
|
462
|
+
echo " no-brief-command — no \`Brief command\` in Plot Config, so nothing was asked to write it"
|
|
463
|
+
return 1
|
|
464
|
+
fi
|
|
465
|
+
log="$repo_root/.plot/brief-$(printf '%s' "${branch##*/}").log"
|
|
466
|
+
mkdir -p "$(dirname "$log")" 2>/dev/null || true
|
|
467
|
+
# `nohup ... &` inside a subshell, the same detachment `start_worker` uses:
|
|
468
|
+
# this outlives the dispatch run by design, because the fan-out must not block
|
|
469
|
+
# on a `claude -p` session of unknown length. `setsid` is not used — it does
|
|
470
|
+
# not exist on macOS, where most of this fleet runs.
|
|
471
|
+
( cd "$repo_root" \
|
|
472
|
+
&& PLOT_UNATTENDED=1 PLOT_PLAN_SLUG="$bslug" PLOT_BRIEF_BRANCH="$branch" \
|
|
473
|
+
nohup sh -c "$cmd \"\$@\"" plot-brief \
|
|
474
|
+
"$(brief_prompt "$branch" "$bslug")" \
|
|
475
|
+
>"$log" 2>&1 </dev/null & ) 2>/dev/null
|
|
476
|
+
echo " asked the \`Brief command\` to write it — log: $log"
|
|
477
|
+
# SAYS WHAT WAS MEASURED, WHICH IS THE START AND NOT THE RESULT. The command
|
|
478
|
+
# is detached and never waited on, so this returns 0 the moment it is spawned
|
|
479
|
+
# and a command that fails in its first millisecond still counts. Measured
|
|
480
|
+
# 2026-09-02, first real use: the log held 33 bytes, `Unknown command:
|
|
481
|
+
# /plot-implement`, and the summary still reported `brief_asked=1`. Naming the
|
|
482
|
+
# log as the evidence is what keeps the count from reading as a promise.
|
|
483
|
+
echo " started, not awaited — read the log to see whether it wrote anything"
|
|
484
|
+
echo " dispatch $bslug again once it lands; the gate reads $(brief_ref "$branch")"
|
|
485
|
+
return 0
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
# STALENESS REPORTS AND NEVER REFUSES, and the measurement says why.
|
|
489
|
+
#
|
|
490
|
+
# Compared 2026-09-01, all three live briefs were older than their plans and all
|
|
491
|
+
# three were CORRECT — every plan edit between them was bookkeeping (a PR
|
|
492
|
+
# annotation, a measurement note, a re-measure before approval). A timestamp
|
|
493
|
+
# gate would have refused 3 of 3 on the day it shipped, and a gate that refuses
|
|
494
|
+
# everything is one people disable in its first week.
|
|
495
|
+
#
|
|
496
|
+
# It would also have missed the real case. The teardown brief was written AFTER
|
|
497
|
+
# its plan and was still wrong, citing 80 `fs.rmSync` sites where the tree held
|
|
498
|
+
# 76 — the CODE moved, not the plan. Freshness against the plan is the wrong
|
|
499
|
+
# input: a brief's claims are about the repository, and nothing here compares
|
|
500
|
+
# those to the repository. What would actually gate is judgement about which
|
|
501
|
+
# numbers in a paragraph are claims, which grep cannot reach.
|
|
502
|
+
#
|
|
503
|
+
# So this prints a hint and SAYS it is a hint, naming the plan commit it
|
|
504
|
+
# compared against so the reader can look at that commit rather than guess.
|
|
505
|
+
brief_staleness_note() { # $1 = branch → prints a hint, or nothing
|
|
506
|
+
local branch="$1" bc pc bs ps
|
|
507
|
+
[ -n "$gate_sha" ] || return 0 # nothing shared to compare against
|
|
508
|
+
[ -n "${plan_path:-}" ] || return 0
|
|
509
|
+
bc=$(git log -1 --format='%H %ct' "$gate_ref" -- "$(brief_path "$branch")" 2>/dev/null) || return 0
|
|
510
|
+
pc=$(git log -1 --format='%H %ct' "$gate_ref" -- "$plan_path" 2>/dev/null) || return 0
|
|
511
|
+
[ -n "$bc" ] && [ -n "$pc" ] || return 0
|
|
512
|
+
bs=${bc##* }; ps=${pc##* }
|
|
513
|
+
[ "$bs" -lt "$ps" ] 2>/dev/null || return 0
|
|
514
|
+
echo " brief older than the plan — a HINT, not a gate: the plan may have moved, or the edit may have been bookkeeping"
|
|
515
|
+
echo " plan commit ${pc%% *} touched $plan_path after the brief's last change; read it before trusting the brief"
|
|
516
|
+
}
|
|
517
|
+
|
|
329
518
|
start_worker() {
|
|
330
519
|
local branch="$1" wt="$2"
|
|
331
520
|
local cmd
|
|
@@ -473,6 +662,23 @@ start_worker() {
|
|
|
473
662
|
# single-quoted `sh -c` mangles a path with spaces, exactly as the exit file
|
|
474
663
|
# already does.
|
|
475
664
|
#
|
|
665
|
+
# AND THE WRAPPER WRITES IT ITSELF, from `$$` inside the `sh -c`. Until
|
|
666
|
+
# 2026-08-31 the dispatcher wrote `echo $!` beside the spawn, which named an
|
|
667
|
+
# intermediate subshell rather than the wrapper: three of three live workers
|
|
668
|
+
# measured that day recorded a pid one process above the agent's real parent
|
|
669
|
+
# (7357 against 7358, 71953 against 71954, 92947 against 92949).
|
|
670
|
+
#
|
|
671
|
+
# The cause is that `$!` names the last job THIS shell backgrounded, and with
|
|
672
|
+
# an env-var prefix in front of `nohup` bash cannot collapse the AND-list into
|
|
673
|
+
# one child — it forks a subshell, and that subshell is what `$!` reports.
|
|
674
|
+
# (Without the prefix bash `exec`s the command in place and `$!` is correct,
|
|
675
|
+
# which is why the shape matters and a smaller repro does not show it.)
|
|
676
|
+
#
|
|
677
|
+
# So the same rule the agent pid already follows applies here: THE PROCESS
|
|
678
|
+
# THAT KNOWS A PID IS THE ONE THAT WRITES IT. The wrapper knows `$$`; no
|
|
679
|
+
# ancestor can name it without guessing. It is written FIRST, before the
|
|
680
|
+
# monitors and the agent, so the file exists as early as it can.
|
|
681
|
+
#
|
|
476
682
|
# The agent runs backgrounded inside the wrapper so the wrapper can capture its
|
|
477
683
|
# `$!` and `wait` for it. There is a sub-millisecond window after the wrapper
|
|
478
684
|
# starts and before it writes `.plot-worker.pid`; a scan landing in it reads an
|
|
@@ -522,6 +728,62 @@ start_worker() {
|
|
|
522
728
|
# after it, then any stale copies of those lines are dropped and `startedAt` is
|
|
523
729
|
# rewritten to the current run. This is exactly `stampManifest`, line for line,
|
|
524
730
|
# which the parity test pins byte for byte.
|
|
731
|
+
#
|
|
732
|
+
# EVERY WORKER IS BORN MONITORED, AND THAT IS ENFORCED HERE OR NOWHERE.
|
|
733
|
+
#
|
|
734
|
+
# Three monitors start INSIDE the wrapper, as its children, immediately before
|
|
735
|
+
# the agent: one watches the process (`plot-worker-monitor.sh`), one watches
|
|
736
|
+
# the desk (`plot-agent-monitor.sh`), one watches the run
|
|
737
|
+
# (`plot-build-monitor.sh`). Each has a subject the others do not and a
|
|
738
|
+
# cadence it cannot share — seconds on the process table, minutes on the host,
|
|
739
|
+
# seconds again on a run but only while one is live.
|
|
740
|
+
#
|
|
741
|
+
# WHY INSIDE THE WRAPPER RATHER THAN BESIDE IT. The wrapper already outlives
|
|
742
|
+
# its agent by construction — it must, or there would be no exit code to
|
|
743
|
+
# write — so a child of it inherits that survival for free. Two processes
|
|
744
|
+
# started SIDE BY SIDE are independently mortal: the monitor could be killed
|
|
745
|
+
# or crash with nothing noticing, which is the failure being fixed one level
|
|
746
|
+
# up. `--stop` kills the agent; the monitors and the exit record survive it.
|
|
747
|
+
#
|
|
748
|
+
# WHY HERE RATHER THAN ANYWHERE ELSE. `start_worker` is the single path to a
|
|
749
|
+
# worker, which is what makes "every worker is born monitored" a gate rather
|
|
750
|
+
# than a rule: there is no other place to forget. Ask CLAUDE.md's test — *can
|
|
751
|
+
# you answer "did I attach a monitor?" without doing the work?* Here you
|
|
752
|
+
# cannot: no monitor start, no monitored worker, and a mutation test says so.
|
|
753
|
+
#
|
|
754
|
+
# ORDER, AND WHY IT IS THIS WAY ROUND. The monitors are backgrounded FIRST so
|
|
755
|
+
# they exist before their subject does; the agent is backgrounded next and
|
|
756
|
+
# `$!` is captured on the VERY NEXT command, because `$!` names the most
|
|
757
|
+
# recent background job and the pid file must name the AGENT. Starting a
|
|
758
|
+
# monitor between the agent and its `$!` would record a monitor's pid as the
|
|
759
|
+
# worker's — the panel bug the two-pid split already exists to prevent.
|
|
760
|
+
#
|
|
761
|
+
# THEY INHERIT THE STARTUP WINDOW RATHER THAN WIDENING IT. There is a
|
|
762
|
+
# sub-millisecond gap after the wrapper starts and before `.plot-worker.pid`
|
|
763
|
+
# is written, and a scan landing in it reads `none` — honest. The monitors
|
|
764
|
+
# start inside that same window; they must never turn an unwritten pid file
|
|
765
|
+
# into a `gone` finding, which is why the no-op reads no pid at all and the
|
|
766
|
+
# next slice treats an absent pid file as *not yet*.
|
|
767
|
+
#
|
|
768
|
+
# THE PATHS TRAVEL AS ENV VARS, like every other path the wrapper needs. The
|
|
769
|
+
# `sh -c` body is single-quoted and a path with spaces would not survive
|
|
770
|
+
# interpolation into it — the same reason the exit, pid and manifest paths are
|
|
771
|
+
# passed this way. An EMPTY value means "not attached", which is what keeps a
|
|
772
|
+
# missing script from turning into `command not found` in a detached shell
|
|
773
|
+
# nobody is reading.
|
|
774
|
+
#
|
|
775
|
+
# A HAND-MADE WORKTREE GETS NEITHER, and that falls out rather than being
|
|
776
|
+
# enforced: this is the only code that starts a wrapper, and a worktree with
|
|
777
|
+
# no wrapper has nothing for a monitor to be a child of.
|
|
778
|
+
local worker_monitor='' agent_monitor='' build_monitor=''
|
|
779
|
+
[ -x "$script_dir/plot-worker-monitor.sh" ] && worker_monitor="$script_dir/plot-worker-monitor.sh"
|
|
780
|
+
[ -x "$script_dir/plot-agent-monitor.sh" ] && agent_monitor="$script_dir/plot-agent-monitor.sh"
|
|
781
|
+
# THE THIRD MONITOR, born the same way and for the same reason. It watches the
|
|
782
|
+
# RUN — a Build is its own entity in the spec, so a monitor per entity is the
|
|
783
|
+
# pattern rather than an exception to it. Its cadence is the WorkerMonitor's
|
|
784
|
+
# 30 s rather than the AgentMonitor's 300 s, and it can afford that against a
|
|
785
|
+
# HOST because it asks nothing while no run is live.
|
|
786
|
+
[ -x "$script_dir/plot-build-monitor.sh" ] && build_monitor="$script_dir/plot-build-monitor.sh"
|
|
525
787
|
local stamp_now
|
|
526
788
|
stamp_now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
527
789
|
( cd "$wt" && PLOT_BRANCH="$branch" PLOT_WORKTREE="$wt" \
|
|
@@ -529,8 +791,12 @@ start_worker() {
|
|
|
529
791
|
PLOT_SESSION_ID="$session" \
|
|
530
792
|
PLOT_MANIFEST_FILE="$manifest_dir/$session.json" \
|
|
531
793
|
PLOT_STAMP_STARTED="$stamp_now" \
|
|
794
|
+
PLOT_WORKER_MONITOR="$worker_monitor" \
|
|
795
|
+
PLOT_AGENT_MONITOR="$agent_monitor" \
|
|
796
|
+
PLOT_BUILD_MONITOR="$build_monitor" \
|
|
532
797
|
PLOT_EXIT_FILE="$wt/.plot-worker.exit" PLOT_PID_FILE="$wt/.plot-worker.pid" \
|
|
533
|
-
|
|
798
|
+
PLOT_WRAPPER_PID_FILE="$wt/.plot-worker.wrapper.pid" \
|
|
799
|
+
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; ( '"$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" '"'"'
|
|
534
800
|
BEGIN { relaunch = 0; count = 1; stamped = 0 }
|
|
535
801
|
FNR == NR {
|
|
536
802
|
if ($0 ~ /^ "pid": "[^"]*",$/) {
|
|
@@ -545,18 +811,26 @@ start_worker() {
|
|
|
545
811
|
!stamped && $0 ~ /^ "pid": "[^"]*",$/ {
|
|
546
812
|
stamped = 1
|
|
547
813
|
print " \"pid\": \"" pid "\","
|
|
814
|
+
print " \"wrapperPid\": \"" wrapper "\","
|
|
815
|
+
print " \"workerMonitorPid\": \"" wmon "\","
|
|
816
|
+
print " \"agentMonitorPid\": \"" amon "\","
|
|
817
|
+
print " \"buildMonitorPid\": \"" bmon "\","
|
|
548
818
|
if (relaunch) {
|
|
549
819
|
print " \"previousPid\": \"" displaced "\","
|
|
550
820
|
print " \"relaunches\": " count ","
|
|
551
821
|
}
|
|
552
822
|
next
|
|
553
823
|
}
|
|
824
|
+
$0 ~ /^ "wrapperPid": "[^"]*",$/ { next }
|
|
825
|
+
$0 ~ /^ "workerMonitorPid": "[^"]*",$/ { next }
|
|
826
|
+
$0 ~ /^ "agentMonitorPid": "[^"]*",$/ { next }
|
|
827
|
+
$0 ~ /^ "buildMonitorPid": "[^"]*",$/ { next }
|
|
554
828
|
relaunch && $0 ~ /^ "previousPid": "[^"]*",$/ { next }
|
|
555
829
|
relaunch && $0 ~ /^ "relaunches": [0-9]+,$/ { next }
|
|
556
830
|
relaunch && $0 ~ /^ "startedAt": "[^"]*"$/ { print " \"startedAt\": \"" started "\""; next }
|
|
557
831
|
{ print }
|
|
558
832
|
'"'"' "$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"' \
|
|
559
|
-
>"$log" 2>&1 </dev/null &
|
|
833
|
+
>"$log" 2>&1 </dev/null & )
|
|
560
834
|
echo " started worker (log: $log)"
|
|
561
835
|
return 0
|
|
562
836
|
}
|
|
@@ -899,63 +1173,69 @@ if [ "$mode" = "migrate" ]; then
|
|
|
899
1173
|
continue
|
|
900
1174
|
fi
|
|
901
1175
|
|
|
902
|
-
#
|
|
903
|
-
#
|
|
904
|
-
#
|
|
905
|
-
#
|
|
906
|
-
#
|
|
907
|
-
#
|
|
908
|
-
#
|
|
909
|
-
#
|
|
910
|
-
#
|
|
911
|
-
|
|
912
|
-
#
|
|
913
|
-
#
|
|
914
|
-
#
|
|
915
|
-
#
|
|
916
|
-
#
|
|
917
|
-
#
|
|
1176
|
+
# FOUR READINGS, GATHERED HERE AND DECIDED ELSEWHERE. This block holds no
|
|
1177
|
+
# `if` about whether a worktree may move; it collects what was measured and
|
|
1178
|
+
# `plot-movable.mjs` returns the refusal. The four were shell `if`s until
|
|
1179
|
+
# 2026-09-01, and nothing could trigger one in isolation — least of all the
|
|
1180
|
+
# combinations this estate will not produce on demand, a live pid and a
|
|
1181
|
+
# dirty tree at once.
|
|
1182
|
+
#
|
|
1183
|
+
# LIVENESS AND UNLANDED WORK STAY TWO SEPARATE MEASUREMENTS, exactly as they
|
|
1184
|
+
# were: plot_worker_state answers "is a process running or waiting here" and
|
|
1185
|
+
# is keyed on the records a dispatch writes (`.plot-worker.pid`,
|
|
1186
|
+
# `.plot-worker.exit`). A hand-made worktree that never ran one reads `none`
|
|
1187
|
+
# however dirty its tree is — and hand-made worktrees are precisely the
|
|
1188
|
+
# estate this mode exists to tidy. The rule reads them as two fields for
|
|
1189
|
+
# that reason.
|
|
1190
|
+
#
|
|
1191
|
+
# plot_worker_state is the ONE liveness answer, sourced by both this script
|
|
1192
|
+
# and the fleet scan. It carries what a bare `ps` cannot — pid-reuse
|
|
1193
|
+
# detection via the manifest's `startedAt`, and the `waiting` state a
|
|
1194
|
+
# PLOT-BLOCKED* marker produces.
|
|
918
1195
|
wstate_row=$(plot_worker_state "$wt")
|
|
919
1196
|
state=$(printf '%s' "$wstate_row" | cut -f1)
|
|
920
|
-
|
|
921
|
-
running)
|
|
922
|
-
pid=$(printf '%s' "$wstate_row" | cut -f2)
|
|
923
|
-
printf '%-8s %-52s %s\n' "keep" "$(basename "$wt")" "worker alive (pid $pid)"
|
|
924
|
-
n_skipped=$((n_skipped + 1))
|
|
925
|
-
continue ;;
|
|
926
|
-
waiting)
|
|
927
|
-
# The shared classifier reports `waiting` when a blocked marker exists:
|
|
928
|
-
# a worker stopped to ask a person something. Moving it breaks the
|
|
929
|
-
# checkout the answer is owed to.
|
|
930
|
-
printf '%-8s %-52s %s\n' "keep" "$(basename "$wt")" "blocked marker — needs a person"
|
|
931
|
-
n_skipped=$((n_skipped + 1))
|
|
932
|
-
continue ;;
|
|
933
|
-
esac
|
|
1197
|
+
pid=$(printf '%s' "$wstate_row" | cut -f2)
|
|
934
1198
|
|
|
935
|
-
# REFUSAL 3 — UNCOMMITTED WORK, measured independently of any worker record.
|
|
936
1199
|
# `plot_worker_dirty` applies the shared filter (editor leftovers and Plot's
|
|
937
|
-
# own bookkeeping do not count), so this
|
|
1200
|
+
# own bookkeeping do not count), so this reads real work only.
|
|
938
1201
|
dirty=$(plot_worker_dirty "$wt" | head -1 | cut -c1-40)
|
|
939
|
-
if [ -n "$dirty" ]; then
|
|
940
|
-
printf '%-8s %-52s %s\n' "keep" "$(basename "$wt")" "uncommitted: $dirty"
|
|
941
|
-
n_skipped=$((n_skipped + 1))
|
|
942
|
-
continue
|
|
943
|
-
fi
|
|
944
1202
|
|
|
945
|
-
#
|
|
946
|
-
#
|
|
947
|
-
#
|
|
948
|
-
#
|
|
949
|
-
#
|
|
950
|
-
|
|
1203
|
+
# Only the branch's OWN upstream answers "pushed?". An absent upstream
|
|
1204
|
+
# leaves the field EMPTY, which the rule reads as unanswerable rather than
|
|
1205
|
+
# as zero — and an unanswered question is not a refusal, the principle
|
|
1206
|
+
# plot_worker_task_state reached the hard way when counting against
|
|
1207
|
+
# origin/main marked every clean branch stalled in a remote-less repo.
|
|
1208
|
+
ahead=""
|
|
951
1209
|
if [ -n "$br" ]; then
|
|
952
1210
|
ahead=$(git -C "$wt" rev-list --count '@{upstream}..HEAD' 2>/dev/null || echo "")
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1211
|
+
fi
|
|
1212
|
+
|
|
1213
|
+
# THE DECISION. One call per tree, and the answer is a named refusal.
|
|
1214
|
+
#
|
|
1215
|
+
# A rule that cannot be asked REFUSES: a missing `node`, a missing bundle or
|
|
1216
|
+
# a throwing module all leave `mv_verdict` empty, and an empty verdict keeps
|
|
1217
|
+
# the worktree and says the rule could not be asked. Silence is never
|
|
1218
|
+
# permission — and here the permissive direction moves a checkout an agent
|
|
1219
|
+
# may be writing to, which `git worktree move` breaks mid-run.
|
|
1220
|
+
mv_verdict=$(printf '%s\t%s\t%s\t%s' "$state" "$pid" "$dirty" "$ahead" \
|
|
1221
|
+
| node "$script_dir/board/plot-movable.mjs" 2>/dev/null || true)
|
|
1222
|
+
mv_refusal=${mv_verdict%%$'\t'*}
|
|
1223
|
+
mv_detail=${mv_verdict#*$'\t'}
|
|
1224
|
+
|
|
1225
|
+
# RENDERING, not deciding. The rule named the measurement; this names what
|
|
1226
|
+
# it means to someone reading the table, which is the caller's half because
|
|
1227
|
+
# only the caller knows it is printing one.
|
|
1228
|
+
if [ "$mv_refusal" != "move" ]; then
|
|
1229
|
+
case "$mv_refusal" in
|
|
1230
|
+
live-worker) reason="worker alive (pid $mv_detail)" ;;
|
|
1231
|
+
blocked-marker) reason="blocked marker — needs a person" ;;
|
|
1232
|
+
uncommitted-changes) reason="uncommitted: $mv_detail" ;;
|
|
1233
|
+
unpushed-commits) reason="unpushed commits ($mv_detail ahead)" ;;
|
|
1234
|
+
*) reason="rule could not be asked — keeping" ;;
|
|
958
1235
|
esac
|
|
1236
|
+
printf '%-8s %-52s %s\n' "keep" "$(basename "$wt")" "$reason"
|
|
1237
|
+
n_skipped=$((n_skipped + 1))
|
|
1238
|
+
continue
|
|
959
1239
|
fi
|
|
960
1240
|
|
|
961
1241
|
# This worktree is idle — it can be moved.
|
|
@@ -1205,6 +1485,194 @@ esac
|
|
|
1205
1485
|
# MAIN was resolved and origin fetched above, before the phase gate — the gate
|
|
1206
1486
|
# needs the shared ref to read the plan from it.
|
|
1207
1487
|
|
|
1488
|
+
# ---------------------------------------------------------------------------
|
|
1489
|
+
# THE PREREQUISITE GATE: a branch that waits on another plan's branch
|
|
1490
|
+
# ---------------------------------------------------------------------------
|
|
1491
|
+
#
|
|
1492
|
+
# A plan may annotate one of its branches `<!-- waits: <branch> -->`, naming ONE
|
|
1493
|
+
# branch — usually of another plan — that must merge before this one may start.
|
|
1494
|
+
# The parser exposes it as `waves[].branches[].waits_on`; the scan turns it into
|
|
1495
|
+
# the branch states `waiting` and `blocked`, so `--next` already stops OFFERING
|
|
1496
|
+
# such a branch.
|
|
1497
|
+
#
|
|
1498
|
+
# THAT IS ONE HALF, AND THIS IS THE OTHER. An empty offer has nothing to say
|
|
1499
|
+
# about what it filtered out: `plot-dispatch.sh <slug>` answered `dispatched=0`
|
|
1500
|
+
# with no reason attached, which is the same silence `--restart` was built to
|
|
1501
|
+
# break. So this names the branch AND the prerequisite, and it names them in
|
|
1502
|
+
# `--dry-run` identically to a real run.
|
|
1503
|
+
#
|
|
1504
|
+
# IT HAS COST TWO WORKERS. Measured 2026-09-02:
|
|
1505
|
+
# `feature/the-domain-forgets-the-vendor-list` was re-dispatched at 04:50 into a
|
|
1506
|
+
# prerequisite that had not merged, hit its own gate, and wrote a PLOT-BLOCKED
|
|
1507
|
+
# marker. Its report names the cause: *"plot-dispatch.sh gates on the plan's
|
|
1508
|
+
# phase, and this plan is Approved, so the slice read as eligible."*
|
|
1509
|
+
#
|
|
1510
|
+
# THE ANNOTATION IS READ FROM THE SHARED REF, out of the same `gate_meta` the
|
|
1511
|
+
# phase gate parsed. A `waits:` that exists only in this working tree is an
|
|
1512
|
+
# ordering constraint nobody else can see, exactly as a local approval is.
|
|
1513
|
+
#
|
|
1514
|
+
# THE PREREQUISITE IS ASKED OF THE HOST, NEVER OF THE REFS.
|
|
1515
|
+
# `plot-release-refs.sh` deletes the remote refs of a delivered plan's merged
|
|
1516
|
+
# branches, so a prerequisite that SUCCEEDED and was then reaped has no ref —
|
|
1517
|
+
# and a rule reading refs would hold its dependent forever BECAUSE its
|
|
1518
|
+
# dependency succeeded. That is the worst available failure: correct work
|
|
1519
|
+
# producing a permanent block. `pr-state` answers about PULL REQUESTS, and a
|
|
1520
|
+
# merged PR outlives the branch it was cut from.
|
|
1521
|
+
#
|
|
1522
|
+
# `NONE` AND SILENCE ARE DIFFERENT ANSWERS. `NONE` means the host was asked and
|
|
1523
|
+
# has never seen a PR for that branch — a typo, which is `blocked`. A host that
|
|
1524
|
+
# could not be asked is neither permission nor proof of a typo, so it HOLDS the
|
|
1525
|
+
# branch at `waiting`. Both refuse; only one tells the operator to fix the plan.
|
|
1526
|
+
|
|
1527
|
+
# What the host says about the prerequisite's pull requests.
|
|
1528
|
+
#
|
|
1529
|
+
# Four answers, and the last two must never be collapsed — see the header
|
|
1530
|
+
# above. `--offline` promises no network, so it answers `unreachable`: the
|
|
1531
|
+
# question was not put, and a flag that lied would be worse than a slower
|
|
1532
|
+
# answer. The same reasoning `reached_review` applies one screen up.
|
|
1533
|
+
prereq_answer() { # $1=prerequisite branch → merged|unmerged|none|unreachable
|
|
1534
|
+
local js st
|
|
1535
|
+
[ -z "$offline" ] || { echo unreachable; return; }
|
|
1536
|
+
[ "$("$script_dir/plot-host.sh" backend 2>/dev/null)" != "none" ] || { echo unreachable; return; }
|
|
1537
|
+
# Exit code first: a non-zero is a transport failure and its stdout is not an
|
|
1538
|
+
# answer. GitHub returned 503 all afternoon on 2026-08-17, and a reader that
|
|
1539
|
+
# trusted the payload on failure would have started every waiting branch.
|
|
1540
|
+
js=$("$script_dir/plot-host.sh" pr-state "$1" </dev/null 2>/dev/null) || { echo unreachable; return; }
|
|
1541
|
+
st=$(printf '%s' "$js" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
|
|
1542
|
+
case "$st" in
|
|
1543
|
+
MERGED) echo merged ;;
|
|
1544
|
+
NONE) echo none ;;
|
|
1545
|
+
# OPEN and CLOSED both mean the host has SEEN the branch. A closed, unmerged
|
|
1546
|
+
# PR is `unmerged` rather than `none`: nothing is misspelled — somebody
|
|
1547
|
+
# withdrew the work, and that resolves by reopening it, not by editing the
|
|
1548
|
+
# plan.
|
|
1549
|
+
OPEN|CLOSED) echo unmerged ;;
|
|
1550
|
+
# A state word this adapter does not emit, or none at all. Unread is not
|
|
1551
|
+
# answered, and this gate's silence holds rather than permits.
|
|
1552
|
+
*) echo unreachable ;;
|
|
1553
|
+
esac
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
# Every branch the plan annotates `waits:`, with what it waits on — read from
|
|
1557
|
+
# the same blob, in the plan's own order, one line of `branch<TAB>prerequisite`.
|
|
1558
|
+
#
|
|
1559
|
+
# NON-DEFERRED ONLY. `deferred:` is a JUDGEMENT — somebody gave the branch up —
|
|
1560
|
+
# and it outranks a wait for the same reason the scan lets it: a branch nobody
|
|
1561
|
+
# will start does not need to be told what it is waiting for. The two
|
|
1562
|
+
# annotations sit on one line and neither reads the other's value.
|
|
1563
|
+
waits_pairs() { # → branch<TAB>prerequisite, one per annotated branch
|
|
1564
|
+
printf '%s' "$gate_meta" | awk '
|
|
1565
|
+
{
|
|
1566
|
+
n = split($0, parts, /\{"branch":"/)
|
|
1567
|
+
for (i = 2; i <= n; i++) {
|
|
1568
|
+
rec = parts[i]
|
|
1569
|
+
br = rec; sub(/".*$/, "", br)
|
|
1570
|
+
if (rec ~ /"deferred":true/) continue
|
|
1571
|
+
if (match(rec, /"waits_on":"[^"]*"/)) {
|
|
1572
|
+
w = substr(rec, RSTART + 12, RLENGTH - 13)
|
|
1573
|
+
if (w != "") print br "\t" w
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}'
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
# THE PREFLIGHT, run once before the fan-out, and it is where the REFUSAL lives.
|
|
1580
|
+
#
|
|
1581
|
+
# WHY IT CANNOT LIVE IN THE LOOP ALONE. The fan-out loop only ever sees what
|
|
1582
|
+
# `plot-fleet-scan.sh` offered, and the scan ALREADY reports a waiting branch as
|
|
1583
|
+
# `waiting` rather than `open` — so it is filtered out before this script hears
|
|
1584
|
+
# of it, and the run ends `dispatched=0 skipped=0` with nothing said about what
|
|
1585
|
+
# was withheld. That silence is the exact defect: an empty offer has nothing to
|
|
1586
|
+
# say about what it filtered out, and a worker was dispatched twice on 2026-09-02
|
|
1587
|
+
# by an operator reading it as "nothing to do here".
|
|
1588
|
+
#
|
|
1589
|
+
# So the plan is walked DIRECTLY. This script already holds the parsed plan from
|
|
1590
|
+
# the shared ref — the same blob its phase gate read — so naming what the fan-out
|
|
1591
|
+
# will not reach costs one host call per annotated branch, on a population of six
|
|
1592
|
+
# plans in 188.
|
|
1593
|
+
#
|
|
1594
|
+
# IT FILLS `waits_held`, WHICH THE LOOPS THEN CONSULT. Two mechanisms, one
|
|
1595
|
+
# decision: this states the refusal, and the loops refuse to write for a branch
|
|
1596
|
+
# it named — belt to that brace, because a scan that could not reach the host
|
|
1597
|
+
# still offers the branch as `open`.
|
|
1598
|
+
declare -a waits_held=()
|
|
1599
|
+
is_waits_held() {
|
|
1600
|
+
local x
|
|
1601
|
+
for x in ${waits_held[@]+"${waits_held[@]}"}; do [ "$x" = "$1" ] && return 0; done
|
|
1602
|
+
return 1
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
# AND IT FILLS `waits_freed`, WHICH IS WHERE `--allow-waiting` GETS ITS
|
|
1606
|
+
# CANDIDATE FROM.
|
|
1607
|
+
#
|
|
1608
|
+
# The override cannot work by relaxing a test in this script, because the branch
|
|
1609
|
+
# never reaches a test here: `plot-fleet-scan.sh` reports a waiting branch as
|
|
1610
|
+
# `waiting` rather than `open`, so `--list-eligible` and `--next` both withhold
|
|
1611
|
+
# it and the loops are handed an empty set. Measured 2026-09-02 — the flag
|
|
1612
|
+
# printed its override line and the run still reported `dispatched=0 skipped=0`,
|
|
1613
|
+
# counting the branch neither way.
|
|
1614
|
+
#
|
|
1615
|
+
# So the flag ADDS a candidate rather than removing a filter. The preflight
|
|
1616
|
+
# already walked the plan from the shared ref and asked the host about the
|
|
1617
|
+
# prerequisite, so it holds the one fact the scan withheld, and naming it here
|
|
1618
|
+
# costs no further call.
|
|
1619
|
+
#
|
|
1620
|
+
# ONLY A BRANCH THE PREFLIGHT ITSELF HELD, and only under the flag. This adds
|
|
1621
|
+
# nothing the scan refused for any OTHER reason — a claimed branch, a `wip` one,
|
|
1622
|
+
# an incomplete prior wave — because those verdicts are not this flag's to
|
|
1623
|
+
# override and the scan remains the only thing that decides them. The branch
|
|
1624
|
+
# still passes every gate the loops apply after it: `held_worktree`, the claim
|
|
1625
|
+
# race, and the brief.
|
|
1626
|
+
declare -a waits_freed=()
|
|
1627
|
+
is_waits_freed() {
|
|
1628
|
+
local x
|
|
1629
|
+
for x in ${waits_freed[@]+"${waits_freed[@]}"}; do [ "$x" = "$1" ] && return 0; done
|
|
1630
|
+
return 1
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
# Runs the preflight: prints its refusals, fills `waits_held`, and adds what it
|
|
1634
|
+
# withheld to `n_skipped`.
|
|
1635
|
+
#
|
|
1636
|
+
# NOT A COMMAND SUBSTITUTION, and that is not a style choice. `$( … )` is a
|
|
1637
|
+
# SUBSHELL, so an array filled inside one is discarded on return — the loops
|
|
1638
|
+
# below would consult an empty `waits_held` and the refusal would be a message
|
|
1639
|
+
# with no effect behind it. So this writes its two results into globals and the
|
|
1640
|
+
# caller invokes it plainly.
|
|
1641
|
+
#
|
|
1642
|
+
# PRINTED IDENTICALLY BY --dry-run AND THE REAL RUN, the discipline `report_held`
|
|
1643
|
+
# already sets: a dry run that offers what a real run would refuse is worse than
|
|
1644
|
+
# no dry run — it is the same wrong answer with a reassurance attached.
|
|
1645
|
+
run_waits_preflight() { # → prints refusals; fills waits_held, adds to n_skipped
|
|
1646
|
+
local br prereq answer held
|
|
1647
|
+
while IFS=$'\t' read -r br prereq; do
|
|
1648
|
+
[ -n "$br" ] || continue
|
|
1649
|
+
answer=$(prereq_answer "$prereq")
|
|
1650
|
+
case "$answer" in
|
|
1651
|
+
merged) continue ;;
|
|
1652
|
+
none) held=blocked ;;
|
|
1653
|
+
*) held=waiting ;;
|
|
1654
|
+
esac
|
|
1655
|
+
# `--allow-waiting` SAYS SO ON THE LINE IT OVERRIDES. An override nobody can
|
|
1656
|
+
# see in the output is an override nobody can audit.
|
|
1657
|
+
if [ "$allow_waiting" = 1 ]; then
|
|
1658
|
+
echo "$br waits on $prereq ($held) — dispatching anyway (--allow-waiting)"
|
|
1659
|
+
waits_freed+=("$br")
|
|
1660
|
+
continue
|
|
1661
|
+
fi
|
|
1662
|
+
waits_held+=("$br")
|
|
1663
|
+
n_skipped=$((n_skipped + 1))
|
|
1664
|
+
if [ "$held" = "blocked" ]; then
|
|
1665
|
+
echo "skipped $br (blocked — no PR found for $prereq)"
|
|
1666
|
+
echo " the plan says this branch waits on $prereq, and the host has never"
|
|
1667
|
+
echo " seen a pull request for it. Check the branch name in the plan."
|
|
1668
|
+
else
|
|
1669
|
+
echo "skipped $br (waiting on $prereq)"
|
|
1670
|
+
echo " the plan says this branch waits on $prereq, which has not merged."
|
|
1671
|
+
echo " Dispatch it when that lands, or pass --allow-waiting to start anyway."
|
|
1672
|
+
fi
|
|
1673
|
+
done < <(waits_pairs)
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1208
1676
|
# Where the worktrees live and what their names carry — see resolve_wt_root.
|
|
1209
1677
|
# The default is beside the repo with the `plot-wt-` prefix; a `Worktree root:`
|
|
1210
1678
|
# key relocates them (and drops the prefix, which was only earning its keep
|
|
@@ -1215,6 +1683,7 @@ repo_root=$(git rev-parse --show-toplevel)
|
|
|
1215
1683
|
resolve_wt_root "$repo_root"
|
|
1216
1684
|
|
|
1217
1685
|
n_dispatched=0 n_reused=0 n_skipped=0 n_started=0
|
|
1686
|
+
n_brief_asked=0
|
|
1218
1687
|
|
|
1219
1688
|
# Whether this run COULD have started anything, read once and up front.
|
|
1220
1689
|
#
|
|
@@ -1249,31 +1718,20 @@ worker_state_field() {
|
|
|
1249
1718
|
fi
|
|
1250
1719
|
}
|
|
1251
1720
|
|
|
1252
|
-
# The summary
|
|
1721
|
+
# The summary: the machine-countable footer, and nothing above it.
|
|
1722
|
+
#
|
|
1723
|
+
# THE PROSE LINE IS GONE, BY ITS OWN RULE. It existed to explain a zero that
|
|
1724
|
+
# had a cause worth naming — a `Worker command` nobody had configured, read off
|
|
1725
|
+
# a run that had prepared desks and staffed none. Dispatch starts no worker at
|
|
1726
|
+
# all now, so `started=0` is structural: the line would print on every run,
|
|
1727
|
+
# always true and never informative. `--dry-run` was held to exactly this rule
|
|
1728
|
+
# from the start — *"a dry run starts nothing BY CONSTRUCTION, so it explains
|
|
1729
|
+
# nothing"* — and the fan-out has become the same case.
|
|
1253
1730
|
#
|
|
1254
|
-
#
|
|
1255
|
-
#
|
|
1256
|
-
# it is worth nothing on the day it matters. `worktrees prepared` counts
|
|
1257
|
-
# dispatched + reused, because a re-adopted worktree is equally a desk nobody
|
|
1258
|
-
# was sat at.
|
|
1731
|
+
# `worker=` still travels in the footer. It says how this repo is configured,
|
|
1732
|
+
# which remains a fact about the repo even where it no longer explains a count.
|
|
1259
1733
|
print_summary() { # $1=dispatched $2=reused $3=skipped $4=started
|
|
1260
|
-
|
|
1261
|
-
worker=$(worker_state_field)
|
|
1262
|
-
if [ "$prepared" -gt 0 ] && [ "$4" = 0 ]; then
|
|
1263
|
-
case "$worker" in
|
|
1264
|
-
unconfigured)
|
|
1265
|
-
echo "$prepared worktree$([ "$prepared" = 1 ] || echo s) prepared, 0 workers started, no \`Worker command\` configured" ;;
|
|
1266
|
-
declined)
|
|
1267
|
-
# Asked and answered: this repo starts its workers by hand. Stating the
|
|
1268
|
-
# count without calling it a gap — hand-starting is a legitimate
|
|
1269
|
-
# workflow, and repeating "not configured" at someone who decided that
|
|
1270
|
-
# on purpose is the nag the plan rules out.
|
|
1271
|
-
echo "$prepared worktree$([ "$prepared" = 1 ] || echo s) prepared, 0 workers started — this repo starts them by hand" ;;
|
|
1272
|
-
suppressed)
|
|
1273
|
-
echo "$prepared worktree$([ "$prepared" = 1 ] || echo s) prepared, 0 workers started (--no-start)" ;;
|
|
1274
|
-
esac
|
|
1275
|
-
fi
|
|
1276
|
-
echo "summary: dispatched=$1 reused=$2 skipped=$3 started=$4 brief=missing worker=$worker"
|
|
1734
|
+
echo "summary: dispatched=$1 reused=$2 skipped=$3 started=$4 brief=missing worker=$(worker_state_field) brief_asked=${n_brief_asked:-0}"
|
|
1277
1735
|
}
|
|
1278
1736
|
|
|
1279
1737
|
# ---------------------------------------------------------------------------
|
|
@@ -1345,7 +1803,7 @@ update_parallel_agents_cap() { # $1 = new cap
|
|
|
1345
1803
|
if [ -f "$FLEET_CONTROLS_FILE" ]; then
|
|
1346
1804
|
# Preserve the existing autoDispatch setting
|
|
1347
1805
|
local existing
|
|
1348
|
-
existing=$(sed -
|
|
1806
|
+
existing=$(sed -nE 's/.*"autoDispatch"[[:space:]]*:[[:space:]]*(true|false).*/\1/p' "$FLEET_CONTROLS_FILE" | head -1)
|
|
1349
1807
|
[ -n "$existing" ] && auto_dispatch="$existing"
|
|
1350
1808
|
fi
|
|
1351
1809
|
|
|
@@ -1470,14 +1928,39 @@ write_started_record() { # $@ = branches
|
|
|
1470
1928
|
fi
|
|
1471
1929
|
|
|
1472
1930
|
if [ -f "$tmpwt/$rel" ]; then
|
|
1473
|
-
local br
|
|
1931
|
+
local br wrote=0
|
|
1474
1932
|
for br in "$@"; do
|
|
1933
|
+
# A BRANCH ALREADY RECORDED IS NOT RECORDED AGAIN, and this check is what
|
|
1934
|
+
# the CLAIM used to do. Dispatch pushed a claim, a claimed branch was
|
|
1935
|
+
# never offered again, and a second run therefore booked nothing — the
|
|
1936
|
+
# idempotence was a side effect of the lock rather than a property of the
|
|
1937
|
+
# record. The claim went with the fan-out's writes, so the record owns its
|
|
1938
|
+
# own idempotence now: a plan dispatched three times must not read as
|
|
1939
|
+
# started three times, or the count drifts from the refs it describes.
|
|
1940
|
+
#
|
|
1941
|
+
# MATCHED ON THE BRANCH IN BACKTICKS, the shape `append_started_line`
|
|
1942
|
+
# writes and `plot-plan-meta.sh` parses. A bare substring match would let
|
|
1943
|
+
# `feature/api` find itself inside `feature/api-v2`.
|
|
1944
|
+
if grep -qF -- "\`$br\`" "$tmpwt/$rel" 2>/dev/null \
|
|
1945
|
+
&& grep -q -- "Started:.*\`$br\`" "$tmpwt/$rel" 2>/dev/null; then
|
|
1946
|
+
continue
|
|
1947
|
+
fi
|
|
1475
1948
|
append_started_line "$tmpwt/$rel" "$date" "$who" "$br" || {
|
|
1476
1949
|
echo "plot-dispatch: $rel has no '## Status' section — nowhere to record" >&2
|
|
1477
1950
|
rc=1
|
|
1478
1951
|
break
|
|
1479
1952
|
}
|
|
1953
|
+
wrote=1
|
|
1480
1954
|
done
|
|
1955
|
+
# NOTHING NEW TO SAY IS NOT A FAILURE. Every branch this run handed over was
|
|
1956
|
+
# already on the record, so there is no commit to make and no push to
|
|
1957
|
+
# attempt — and a run that pushed an empty commit would leave one per
|
|
1958
|
+
# re-dispatch on the default branch.
|
|
1959
|
+
if [ "$rc" = 0 ] && [ "$wrote" = 0 ]; then
|
|
1960
|
+
git worktree remove --force "$tmpwt" 2>/dev/null || true
|
|
1961
|
+
git branch -D "$bookbr" >/dev/null 2>&1 || true
|
|
1962
|
+
return 0
|
|
1963
|
+
fi
|
|
1481
1964
|
if [ "$rc" = 0 ]; then
|
|
1482
1965
|
git -C "$tmpwt" add -- "$rel" 2>/dev/null
|
|
1483
1966
|
git -C "$tmpwt" -c "user.name=$who" commit -q \
|
|
@@ -1538,6 +2021,23 @@ real_plan_path() { # $1=plan file as found (possibly a symlink, possibly relativ
|
|
|
1538
2021
|
append_started_line() { # $1=file $2=date $3=who $4=branch
|
|
1539
2022
|
local f="$1" line
|
|
1540
2023
|
line="- **Started:** $2, $3, \`$4\`"
|
|
2024
|
+
|
|
2025
|
+
# ALREADY RECORDED IS NOT AN ERROR — it is a second dispatch of the same
|
|
2026
|
+
# slice, and this returns 0 having written nothing.
|
|
2027
|
+
#
|
|
2028
|
+
# THE CLAIM USED TO BE THIS GUARD. A dispatched branch was claimed by a ref
|
|
2029
|
+
# push, so `--next` never offered it twice and a re-run booked nothing. The
|
|
2030
|
+
# hand-over pushes no claim, so the branch stays `open` and every re-run
|
|
2031
|
+
# reaches this line: measured 2026-09-04, two runs left two identical
|
|
2032
|
+
# `Started:` records in one plan.
|
|
2033
|
+
#
|
|
2034
|
+
# The DATE is deliberately not matched. A slice handed over again tomorrow is
|
|
2035
|
+
# the same start, and a per-day record would drift from the refs it describes
|
|
2036
|
+
# exactly as a per-run one does.
|
|
2037
|
+
if grep -qF -- "\`$4\`" "$f" 2>/dev/null \
|
|
2038
|
+
&& grep -q -- "^[ \t]*[-*][ \t]*\*\*Started:\*\*.*\`$4\`" "$f" 2>/dev/null; then
|
|
2039
|
+
return 0
|
|
2040
|
+
fi
|
|
1541
2041
|
awk -v line="$line" '
|
|
1542
2042
|
{ lines[++n] = $0 }
|
|
1543
2043
|
END {
|
|
@@ -1772,7 +2272,38 @@ held_worktree() { # $1=branch → prints the worktree path when held, else nothi
|
|
|
1772
2272
|
# in-flight report a few lines up.
|
|
1773
2273
|
[ -z "$(uncommitted_files "$wt")" ] || { printf '%s' "$wt"; return 0; }
|
|
1774
2274
|
|
|
1775
|
-
#
|
|
2275
|
+
# DID ITS WORK LAND? THE HOST ANSWERS, NOT ANCESTRY.
|
|
2276
|
+
#
|
|
2277
|
+
# This asked `git merge-base --is-ancestor "$br" "origin/$MAIN"` until
|
|
2278
|
+
# 2026-09-04. Measured that day on this estate: ten merged branches still
|
|
2279
|
+
# carried a remote ref and ancestry disagreed with the host on TEN OF TEN.
|
|
2280
|
+
# Squash-merge is not occasionally wrong here — the squashed commit is not the
|
|
2281
|
+
# branch's commit, so the branch stays ahead of main forever and ancestry
|
|
2282
|
+
# answers "not landed" about every squash-merged branch there is.
|
|
2283
|
+
#
|
|
2284
|
+
# The failure direction is throughput, not safety: ancestry called a landed
|
|
2285
|
+
# leftover HELD, so dispatch refused a branch that was free. That is the
|
|
2286
|
+
# cheap half of the plan's measurement and it is still a refusal an operator
|
|
2287
|
+
# has to route around.
|
|
2288
|
+
#
|
|
2289
|
+
# `pr_merged` is the ONE answer, sourced rather than re-derived — the same
|
|
2290
|
+
# gate `plot-reap.sh` and `plot-release-refs.sh` read, for the reason that
|
|
2291
|
+
# file states: two implementations of one question drift, and one of them
|
|
2292
|
+
# drifts permissive.
|
|
2293
|
+
#
|
|
2294
|
+
# ANCESTRY REMAINS AS A SECOND CHANCE, and only toward "landed". A
|
|
2295
|
+
# fast-forward or rebase merge leaves the tip genuinely in main while the host
|
|
2296
|
+
# may hold no PR at all — a branch pushed straight to main, which this repo's
|
|
2297
|
+
# own fixtures do. It can only ever release a worktree the host already
|
|
2298
|
+
# declined to release, so it adds no way to refuse and no way to hide work.
|
|
2299
|
+
#
|
|
2300
|
+
# AN UNREACHABLE HOST ANSWERS "NOT MERGED", which keeps the worktree held.
|
|
2301
|
+
# That is `pr_merged`'s documented direction and the right one here too:
|
|
2302
|
+
# silence is never permission to hand somebody's desk to a second agent.
|
|
2303
|
+
# plot-ancestry: prefilter — second only to `pr_merged` above, and it can
|
|
2304
|
+
# only RELEASE a worktree the host already declined to release. It adds no
|
|
2305
|
+
# refusal, so a squash merge it misreads changes nothing.
|
|
2306
|
+
pr_merged "$br" && return 1
|
|
1776
2307
|
git merge-base --is-ancestor "$br" "origin/$MAIN" </dev/null 2>/dev/null && return 1
|
|
1777
2308
|
printf '%s' "$wt"
|
|
1778
2309
|
}
|
|
@@ -1839,6 +2370,37 @@ work_in_flight() { # $1=branch to exclude (the candidate)
|
|
|
1839
2370
|
IN_FLIGHT_MAX_FILES=6
|
|
1840
2371
|
IN_FLIGHT_MAX_BRANCHES=8
|
|
1841
2372
|
|
|
2373
|
+
# What a real run would attach, named per worktree — behind `--monitors`.
|
|
2374
|
+
#
|
|
2375
|
+
# SILENT UNLESS ASKED, which is what keeps the default `--dry-run` output
|
|
2376
|
+
# byte-identical to a run from before the monitors existed. That diff is this
|
|
2377
|
+
# slice's protection against the one failure that matters here: a mistake in
|
|
2378
|
+
# `start_worker` starts no workers at all, and the dry run exercises every
|
|
2379
|
+
# refusal against real worktrees and real pids without starting anything.
|
|
2380
|
+
#
|
|
2381
|
+
# IT NAMES THE SCRIPT PATH, not just the monitor. The question a reader has at
|
|
2382
|
+
# a dry run is *which code would run against my worktree* — a bare "2 monitors"
|
|
2383
|
+
# would send them into this script to find out, and a path they can `cat` is
|
|
2384
|
+
# the same courtesy the manifest refusal above pays by naming its directory.
|
|
2385
|
+
#
|
|
2386
|
+
# IT REPORTS ABSENCE TOO. A monitor script that is missing or non-executable
|
|
2387
|
+
# means an unmonitored worker, and the empty env var that produces is invisible
|
|
2388
|
+
# at launch by design (a detached `sh -c` nobody reads must not spew `command
|
|
2389
|
+
# not found`). The dry run is the one place that silence can be made audible
|
|
2390
|
+
# before it matters.
|
|
2391
|
+
report_monitors() { # $1=worktree
|
|
2392
|
+
[ "$show_monitors" = 1 ] || return 0
|
|
2393
|
+
local wt="$1" m
|
|
2394
|
+
for m in worker agent; do
|
|
2395
|
+
local script="$script_dir/plot-$m-monitor.sh"
|
|
2396
|
+
if [ -x "$script" ]; then
|
|
2397
|
+
echo " would attach: $script → $wt"
|
|
2398
|
+
else
|
|
2399
|
+
echo " would attach NOTHING for the $m monitor — $script is missing or not executable"
|
|
2400
|
+
fi
|
|
2401
|
+
done
|
|
2402
|
+
}
|
|
2403
|
+
|
|
1842
2404
|
report_in_flight() { # $1=candidate branch
|
|
1843
2405
|
local br files shown extra n=0 total
|
|
1844
2406
|
total=$(work_in_flight "$1" | wc -l | tr -d ' ')
|
|
@@ -1867,6 +2429,13 @@ report_in_flight() { # $1=candidate branch
|
|
|
1867
2429
|
done
|
|
1868
2430
|
}
|
|
1869
2431
|
|
|
2432
|
+
# THE PREREQUISITE PREFLIGHT, before either fan-out path. It names every branch
|
|
2433
|
+
# this plan will not start and why, and fills `waits_held` so neither loop can
|
|
2434
|
+
# write for one. Run here, once, rather than inside the loops: the scan filters a
|
|
2435
|
+
# waiting branch out before a loop ever hears of it, so a refusal that only fires
|
|
2436
|
+
# on an offered branch would never fire at all.
|
|
2437
|
+
run_waits_preflight
|
|
2438
|
+
|
|
1870
2439
|
# A dry run changes nothing, so nothing can go stale — read the whole eligible
|
|
1871
2440
|
# set once. (`--next` would loop forever here: without a claim it keeps
|
|
1872
2441
|
# returning the same branch.)
|
|
@@ -1881,10 +2450,21 @@ if [ "$dry_run" = 1 ]; then
|
|
|
1881
2450
|
n_skipped=$((n_skipped + 1))
|
|
1882
2451
|
continue
|
|
1883
2452
|
fi
|
|
2453
|
+
# ALREADY REFUSED BY THE PREFLIGHT, which named it and counted it. The
|
|
2454
|
+
# scan does not normally offer a waiting branch at all; this arm catches
|
|
2455
|
+
# the one that reached here because the scan could not ask the host.
|
|
2456
|
+
is_waits_held "$br" && continue
|
|
1884
2457
|
echo "would dispatch $br → $(worktree_for "$br")"
|
|
2458
|
+
report_monitors "$(worktree_for "$br")"
|
|
1885
2459
|
report_in_flight "$br"
|
|
1886
2460
|
n_dispatched=$((n_dispatched + 1))
|
|
1887
|
-
|
|
2461
|
+
# The scan's eligible set, plus whatever `--allow-waiting` freed. The scan
|
|
2462
|
+
# reports a waiting branch as `waiting`, so it is absent from the first and
|
|
2463
|
+
# only the preflight can supply it — see `waits_freed`. `sort -u` because a
|
|
2464
|
+
# branch the scan DID offer (its host call failed where the preflight's
|
|
2465
|
+
# succeeded) must be dispatched once, not twice.
|
|
2466
|
+
done < <({ "$script_dir/plot-fleet-scan.sh" $offline --list-eligible "$slug" 2>/dev/null
|
|
2467
|
+
printf '%s\n' ${waits_freed[@]+"${waits_freed[@]}"}; } | grep -v '^$' | sort -u)
|
|
1888
2468
|
# A dry run starts nothing BY CONSTRUCTION, so its `started=0` carries no
|
|
1889
2469
|
# information about the config — reporting "no workers started" here would be
|
|
1890
2470
|
# true and useless, and would train the reader to skip the line on the real
|
|
@@ -1892,42 +2472,82 @@ if [ "$dry_run" = 1 ]; then
|
|
|
1892
2472
|
# `skipped` is REAL here, not a constant. A dry run refuses held branches
|
|
1893
2473
|
# exactly as the real run does, so its count is a fact about this fleet — and
|
|
1894
2474
|
# it was hardcoded to 0 until the gate gave it something to count.
|
|
1895
|
-
|
|
2475
|
+
# `brief_asked=0` is a CONSTANT here and not a prediction. A dry run changes
|
|
2476
|
+
# nothing, and asking the `Brief command` to write a brief spawns an agent
|
|
2477
|
+
# that commits — the loudest write in this script. The field travels so the
|
|
2478
|
+
# footer's shape does not depend on the mode a machine reader happened to
|
|
2479
|
+
# call in.
|
|
2480
|
+
echo "summary: dispatched=$n_dispatched reused=0 skipped=$n_skipped started=0 brief=missing worker=$(worker_state_field) brief_asked=0"
|
|
1896
2481
|
exit 0
|
|
1897
2482
|
fi
|
|
1898
2483
|
|
|
1899
|
-
#
|
|
1900
|
-
#
|
|
1901
|
-
#
|
|
1902
|
-
|
|
2484
|
+
# THE LIST IS READ ONCE, AND THAT FOLLOWS FROM THE CLAIM GOING.
|
|
2485
|
+
#
|
|
2486
|
+
# This was a PULL: `--next` was asked again after every claim, because claiming
|
|
2487
|
+
# a branch changed what the next ask would offer and a list computed up front
|
|
2488
|
+
# would have gone stale mid-fan-out. Dispatch claims nothing now — it hands a
|
|
2489
|
+
# slice to the registry and returns — so nothing this loop does changes the
|
|
2490
|
+
# scan's answer, and re-asking would return the same branch until a gate marked
|
|
2491
|
+
# it exhausted and the loop broke on it. Measured on the first run after the
|
|
2492
|
+
# claim was removed: `feature/one` handed over, `feature/two` never reached.
|
|
2493
|
+
#
|
|
2494
|
+
# ONE SCAN RATHER THAN N. The scan is 18.3 s here, so the pull cost one of those
|
|
2495
|
+
# per branch to re-derive an answer that could not have moved.
|
|
2496
|
+
#
|
|
2497
|
+
# `--allow-waiting`'s CANDIDATES COME LAST, after every branch the scan was
|
|
2498
|
+
# willing to name. `--list-eligible` reports a waiting branch as `waiting` and
|
|
2499
|
+
# never offers it, so the flag's candidates can only come from the preflight; a
|
|
2500
|
+
# held branch is one the operator chose to start early and must not displace one
|
|
2501
|
+
# that was ready. `sort -u` because a branch the scan DID offer — its host call
|
|
2502
|
+
# failed where the preflight's succeeded — must be handed over once, not twice.
|
|
2503
|
+
#
|
|
2504
|
+
# A `while read` LOOP, BECAUSE macOS SHIPS BASH 3.2. The bash 4 builtin that
|
|
2505
|
+
# reads a stream into an array does not exist there, and
|
|
2506
|
+
# `test/reconcile/mergequeue.test.mjs` gates on it — by grepping these scripts,
|
|
2507
|
+
# so naming the builtin in a comment fails the gate too.
|
|
2508
|
+
#
|
|
2509
|
+
# The single scan this block exists for is unaffected: the subshell still runs
|
|
2510
|
+
# once, and its output is still read once.
|
|
2511
|
+
fan_out=()
|
|
2512
|
+
while IFS= read -r _line; do
|
|
2513
|
+
fan_out+=("$_line")
|
|
2514
|
+
done < <({ "$script_dir/plot-fleet-scan.sh" $offline --list-eligible "$slug" 2>/dev/null
|
|
2515
|
+
[ "$allow_waiting" = 1 ] && printf '%s\n' ${waits_freed[@]+"${waits_freed[@]}"}
|
|
2516
|
+
:; } | grep -v '^$' | sort -u)
|
|
2517
|
+
|
|
2518
|
+
for branch in ${fan_out[@]+"${fan_out[@]}"}; do
|
|
1903
2519
|
[ "$max" -gt 0 ] && [ "$n_dispatched" -ge "$max" ] && break
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
#
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
#
|
|
1912
|
-
#
|
|
1913
|
-
#
|
|
2520
|
+
# `exhausted` SURVIVES THE PULL IT WAS WRITTEN FOR. It no longer has to stop
|
|
2521
|
+
# the loop re-offering a branch — a list cannot — but the gates below still
|
|
2522
|
+
# mark what they refused, and `sort -u` cannot merge a preflight candidate
|
|
2523
|
+
# with a scan-offered one where the two spellings differ.
|
|
2524
|
+
is_exhausted "$branch" && continue
|
|
2525
|
+
|
|
2526
|
+
# THE PATH DISPATCH NO LONGER CREATES, still composed for the two readers that
|
|
2527
|
+
# need it: the dry run, which names where a desk WOULD go, and the monitor
|
|
2528
|
+
# report. The agent decides its own desk now, so this is a prediction rather
|
|
2529
|
+
# than a destination — and it stays flattened whole, because `feature/api` and
|
|
2530
|
+
# `bug/api` are different work and must not name one directory.
|
|
1914
2531
|
suffix=$(printf '%s' "$branch" | tr '/' '-')
|
|
1915
2532
|
wt="$wt_root/$wt_prefix$suffix"
|
|
1916
2533
|
|
|
1917
2534
|
if [ "$dry_run" = 1 ]; then
|
|
1918
|
-
echo "would
|
|
2535
|
+
echo "would hand over $branch → the registry"
|
|
2536
|
+
report_monitors "$wt"
|
|
1919
2537
|
report_in_flight "$branch"
|
|
1920
2538
|
n_dispatched=$((n_dispatched + 1))
|
|
1921
2539
|
continue
|
|
1922
2540
|
fi
|
|
1923
2541
|
|
|
1924
|
-
# THE HELD-BRANCH GATE,
|
|
2542
|
+
# THE HELD-BRANCH GATE, and it survives the fan-out losing its writes.
|
|
1925
2543
|
#
|
|
1926
|
-
#
|
|
1927
|
-
# is
|
|
1928
|
-
#
|
|
1929
|
-
#
|
|
1930
|
-
#
|
|
2544
|
+
# It refuses a branch whose own desk holds work that has not landed, and that
|
|
2545
|
+
# is a MEASUREMENT of somebody sitting at it — not a prediction about a file.
|
|
2546
|
+
# Dispatch creates no desk any more, so this no longer protects an adoption
|
|
2547
|
+
# path; it protects the hand-over itself. Handing a slice to the registry
|
|
2548
|
+
# while an agent is mid-edit on that branch is how two agents end up on one,
|
|
2549
|
+
# and the desk is the only place that work is visible: it is unpushed by
|
|
2550
|
+
# definition, so no ref and no PR reports it.
|
|
1931
2551
|
#
|
|
1932
2552
|
# `exhausted` is what makes the refusal terminal: --next has no memory and
|
|
1933
2553
|
# would keep offering this same branch until the loop's own break fired.
|
|
@@ -1938,80 +2558,104 @@ while :; do
|
|
|
1938
2558
|
continue
|
|
1939
2559
|
fi
|
|
1940
2560
|
|
|
1941
|
-
#
|
|
1942
|
-
#
|
|
1943
|
-
#
|
|
2561
|
+
# ALREADY REFUSED BY THE PREFLIGHT, and still asked here. A slice handed over
|
|
2562
|
+
# while its prerequisite is unmerged is an agent started on work that cannot
|
|
2563
|
+
# build, and `feature/the-domain-forgets-the-vendor-list` is the measured
|
|
2564
|
+
# case — claimed, and holding nothing but its claim commit.
|
|
2565
|
+
#
|
|
2566
|
+
# `exhausted` is what makes the refusal terminal — `--next` has no memory and
|
|
2567
|
+
# would keep offering this branch until the loop's own break fired. It should
|
|
2568
|
+
# not be offering it at all (the scan reads `waiting`), so this arm is the
|
|
2569
|
+
# belt to the preflight's brace: a branch offered by a scan that could not
|
|
2570
|
+
# reach the host still stops here, and it is NOT counted again — the preflight
|
|
2571
|
+
# already reported it.
|
|
2572
|
+
if is_waits_held "$branch"; then
|
|
2573
|
+
exhausted+=("$branch")
|
|
2574
|
+
continue
|
|
2575
|
+
fi
|
|
2576
|
+
|
|
2577
|
+
# BEFORE anything is handed over. The candidate is not yet work in flight —
|
|
2578
|
+
# dispatch creates no desk and pushes no claim — so this describes what stood
|
|
2579
|
+
# before this run rather than what this run made.
|
|
1944
2580
|
in_flight=$(report_in_flight "$branch")
|
|
1945
2581
|
|
|
1946
|
-
#
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
2582
|
+
# THE BRIEF GATE, AT THE HAND-OVER RATHER THAN AT THE LAUNCH.
|
|
2583
|
+
#
|
|
2584
|
+
# ITS RULE IS UNCHANGED — a slice with no brief is not handed over — AND ONLY
|
|
2585
|
+
# ITS POSITION MOVED. It used to sit between a prepared desk and a started
|
|
2586
|
+
# worker, so a missing brief left a worktree and a claim nobody was sat at:
|
|
2587
|
+
# correct at the time, because preparing was the only thing dispatch could do
|
|
2588
|
+
# first. Dispatch now prepares nothing, so a refused slice leaves nothing at
|
|
2589
|
+
# all and simply stays in the queue.
|
|
2590
|
+
#
|
|
2591
|
+
# THE REFUSAL STILL NAMES THE REF IT LOOKED AT, not a bare path. A brief
|
|
2592
|
+
# sitting unpushed in the operator's checkout is the likeliest reason to see
|
|
2593
|
+
# this message, and `no brief at .plot/briefs/x.md` would send them to look at
|
|
2594
|
+
# a file that is right there — the ref says where the AGENT will look.
|
|
2595
|
+
#
|
|
2596
|
+
# `--no-brief` KEEPS ITS MEANING: it hands over without one and SAYS SO, so
|
|
2597
|
+
# the override stays on the record rather than being silent.
|
|
2598
|
+
if brief_present "$branch"; then
|
|
2599
|
+
# A brief that is present is never refused for age — see
|
|
2600
|
+
# `brief_staleness_note`. The note is printed before the hand-over so it
|
|
2601
|
+
# sits with the branch it describes, and the hand-over happens either way.
|
|
2602
|
+
brief_staleness_note "$branch"
|
|
2603
|
+
elif [ "$no_brief" = 1 ]; then
|
|
2604
|
+
echo " no brief at $(brief_ref "$branch") — handing it over anyway (--no-brief)"
|
|
1951
2605
|
else
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
#
|
|
1964
|
-
# The claim carries an EMPTY COMMIT, and that is load-bearing. Pushing a
|
|
1965
|
-
# branch that merely points at origin/<main> is a no-op: the remote already
|
|
1966
|
-
# has that commit, so the push succeeds with "Everything up-to-date" and
|
|
1967
|
-
# BOTH dispatchers believe they own the branch. Mutual exclusion requires
|
|
1968
|
-
# the refs to diverge — two independent claim commits are not fast-forwards
|
|
1969
|
-
# of each other, so the second push is rejected as non-fast-forward.
|
|
1970
|
-
#
|
|
1971
|
-
# Never add --force or --force-with-lease here: forcing is precisely what
|
|
1972
|
-
# would let a second dispatcher take a branch someone is working on.
|
|
1973
|
-
git -C "$wt" -c "user.name=${PLOT_CLAIM_WHO:-$(git config user.name || echo plot)}" \
|
|
1974
|
-
commit -q --allow-empty -m "plot: claim $branch" 2>/dev/null
|
|
1975
|
-
if git -C "$wt" push -q -u origin "$branch" 2>/dev/null; then
|
|
1976
|
-
echo "dispatched $branch → $wt"
|
|
1977
|
-
# Reported AFTER the claim, never before: a branch another dispatcher won
|
|
1978
|
-
# is not this run's to describe. The facts themselves were read before the
|
|
1979
|
-
# worktree existed, so the claim cannot have polluted them.
|
|
1980
|
-
[ -n "$in_flight" ] && printf '%s\n' "$in_flight"
|
|
1981
|
-
n_dispatched=$((n_dispatched + 1))
|
|
1982
|
-
# AFTER the claim push, never before. A Started: record for a branch
|
|
1983
|
-
# another dispatcher won would be a lie in the file, and the claim is the
|
|
1984
|
-
# only thing that decides who holds a branch.
|
|
1985
|
-
claimed_now+=("$branch")
|
|
1986
|
-
else
|
|
1987
|
-
echo "skipped $branch (claimed by another session)"
|
|
1988
|
-
git worktree remove --force "$wt" 2>/dev/null || true
|
|
1989
|
-
n_skipped=$((n_skipped + 1))
|
|
1990
|
-
exhausted+=("$branch")
|
|
1991
|
-
continue
|
|
1992
|
-
fi
|
|
2606
|
+
echo " not handed over — no brief at $(brief_ref "$branch")"
|
|
2607
|
+
echo " write one: /plot-implement $slug (then push it, or pass --no-brief to hand it over without one)"
|
|
2608
|
+
# AND NOW SOMETHING IS DONE ABOUT IT. The refusal above stands unchanged;
|
|
2609
|
+
# this line says what happened NEXT. Either arm names itself, so the log
|
|
2610
|
+
# always records which one ran.
|
|
2611
|
+
request_brief "$branch" "$slug" && n_brief_asked=$((n_brief_asked + 1))
|
|
2612
|
+
n_skipped=$((n_skipped + 1))
|
|
2613
|
+
# `exhausted` is what makes the refusal terminal — `--next` has no memory
|
|
2614
|
+
# and would keep offering this branch until the loop's own break fired.
|
|
2615
|
+
exhausted+=("$branch")
|
|
2616
|
+
continue
|
|
1993
2617
|
fi
|
|
1994
2618
|
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2619
|
+
# THE HAND-OVER, AND IT IS THE WHOLE OF WHAT DISPATCH DOES WITH A SLICE.
|
|
2620
|
+
#
|
|
2621
|
+
# `git worktree add` USED TO BE HERE, with a claim push behind it and a worker
|
|
2622
|
+
# start behind that. All three are gone, and each for its own reason:
|
|
2623
|
+
#
|
|
2624
|
+
# THE DESK. `DESIGN-agent.md:65` — *"agent ──owns──► a worktree (its desk,
|
|
2625
|
+
# while it lives)"*. One desk per agent, not one per slice. The agent decides
|
|
2626
|
+
# create-or-reset when it takes the brief, because it is the only party that
|
|
2627
|
+
# can see its own tree; a desk cut here would be cut before anybody knows
|
|
2628
|
+
# which agent will sit at it. Measured 2026-09-02: 2 manifests against 11
|
|
2629
|
+
# worktrees, 5 of them on branches that had already merged.
|
|
2630
|
+
#
|
|
2631
|
+
# THE CLAIM. A pushed claim makes the branch read `claimed` rather than
|
|
2632
|
+
# `open`, and the queue is DERIVED — an eligible slice with a brief and no
|
|
2633
|
+
# claim IS queued. Claiming here would take the slice straight back out of
|
|
2634
|
+
# the queue it was being put into.
|
|
2635
|
+
#
|
|
2636
|
+
# THE WORKER. `DESIGN-agent.md:157` — *"nothing starts a worker"*. The
|
|
2637
|
+
# registry spawns an agent, and spawning it IS starting its process. This
|
|
2638
|
+
# script hands work to the fleet; it does not staff it.
|
|
2639
|
+
#
|
|
2640
|
+
# SO THE HAND-OVER IS A REPORT AND NOT A WRITE. The queue derives from the
|
|
2641
|
+
# plan, the briefs and the refs, all of which are already on the host, so
|
|
2642
|
+
# there is nothing for this line to store — which is what keeps the daemon
|
|
2643
|
+
# stateless across restarts.
|
|
2644
|
+
#
|
|
2645
|
+
# IT REFUSES NOTHING FOR WANT OF A FREE AGENT, and never asks. An earlier
|
|
2646
|
+
# draft of the plan proposed refusing on `0 free` and it is wrong: it makes
|
|
2647
|
+
# dispatch synchronous with fleet capacity, the coupling `DESIGN-machine.md`
|
|
2648
|
+
# §10 spent two revisions rejecting, and `DESIGN-agent.md:173` states it from
|
|
2649
|
+
# the other side — *"a dispatch never asks the machine for capacity"*. **The
|
|
2650
|
+
# queue absorbs the timing.** A queue longer than the pool is the normal case.
|
|
2651
|
+
echo "handed over $branch → the registry"
|
|
2652
|
+
[ -n "$in_flight" ] && printf '%s\n' "$in_flight"
|
|
2653
|
+
n_dispatched=$((n_dispatched + 1))
|
|
2654
|
+
# AFTER the hand-over rather than after a claim. A `Started:` record now
|
|
2655
|
+
# states that the slice was handed to the fleet, which is what this run did;
|
|
2656
|
+
# who takes it is the registry's to decide and its own to record.
|
|
2657
|
+
claimed_now+=("$branch")
|
|
2658
|
+
|
|
2015
2659
|
done
|
|
2016
2660
|
|
|
2017
2661
|
# Book AFTER the fan-out, in one commit, so a booking that fails cannot leave
|