@plot-pm/board 0.10.0 → 0.12.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 +157 -151
- package/package.json +9 -1
- package/plot-agent-monitor.sh +532 -0
- package/plot-approve.sh +151 -32
- package/plot-budget.sh +439 -0
- package/plot-build-monitor.sh +435 -0
- package/plot-config.sh +17 -1
- package/plot-default-branch.sh +109 -0
- package/plot-deliver.sh +214 -119
- package/plot-dispatch.sh +1327 -188
- package/plot-fleet-scan.sh +1005 -170
- package/plot-host.sh +1438 -61
- package/plot-monitor-subject.sh +194 -0
- package/plot-plan-meta.sh +345 -47
- package/plot-pr-merged.sh +180 -0
- package/plot-reap.sh +719 -61
- package/plot-release-refs.sh +188 -46
- package/plot-resolve-artifact.sh +115 -22
- 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,11 +1,18 @@
|
|
|
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
|
+
# plot-dispatch.sh --start [N] [--dry-run]
|
|
5
6
|
# plot-dispatch.sh --migrate [--yes] [--max N]
|
|
6
7
|
# --status list fleet worktrees with worker pid, liveness, and last log
|
|
7
8
|
# line; then exit. Works regardless of plan phase.
|
|
8
9
|
# --stop <br> stop the worker on <br> (branch required — never "all").
|
|
10
|
+
# --start [N] start N free agents — registered, waiting, holding no slice.
|
|
11
|
+
# Defaults to three. Each gets a desk detached at origin/<main>
|
|
12
|
+
# and a manifest naming no branch, so the registry's next tick can
|
|
13
|
+
# hand each a queued slice with nobody touching a desk. The count
|
|
14
|
+
# is a REQUEST: a machine at its bound answers with fewer and says
|
|
15
|
+
# so, and the shortfall is reported rather than remembered.
|
|
9
16
|
# --restart <br>
|
|
10
17
|
# start a worker on <br>, which already holds a claim — the
|
|
11
18
|
# counterpart to --stop, and the only way to hand a stopped
|
|
@@ -22,29 +29,42 @@
|
|
|
22
29
|
# config — without one there is no destination. --dry-run by
|
|
23
30
|
# default; --yes to actually move.
|
|
24
31
|
# --dry-run print what would happen; create nothing, push nothing
|
|
32
|
+
# --monitors with --dry-run, also name which monitors would be attached to
|
|
33
|
+
# which worktree. Opt-in so the default --dry-run output stays
|
|
34
|
+
# byte-identical, which is what lets it be diffed against a run
|
|
35
|
+
# from before a change to this script.
|
|
25
36
|
# --yes with --migrate, actually move the worktrees (default is dry-run)
|
|
26
|
-
# --no-start
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
37
|
+
# --no-start record that no worker was wanted: the run still hands its slices
|
|
38
|
+
# over, and reports `worker=suppressed` rather than a missing
|
|
39
|
+
# `Worker command`. Dispatch starts no worker either way — the
|
|
40
|
+
# registry spawns agents — so this now says WHY the zero was
|
|
41
|
+
# chosen and no longer changes what the run does.
|
|
42
|
+
# --no-brief hand a slice over even when its branch has no brief. The named
|
|
43
|
+
# escape for the brief gate: a missing brief is not handed over,
|
|
44
|
+
# because the agent's first instruction is to read
|
|
45
|
+
# `.plot/briefs/<branch>.md` and it has nothing to read. A refused
|
|
46
|
+
# slice leaves no desk and no claim and stays in the queue.
|
|
47
|
+
# --no-brief overrides that and says so.
|
|
32
48
|
# --offline skip `git fetch`
|
|
33
49
|
# --max N dispatch at most N branches this run (default: all eligible)
|
|
34
50
|
# --allow-local read the plan's phase from the working tree when
|
|
35
51
|
# origin/<main> cannot be resolved (no remote, fresh clone).
|
|
36
52
|
# The explicit escape for a remote-less repo — never a default,
|
|
37
53
|
# because a working-tree read is what this gate exists to avoid.
|
|
54
|
+
# --allow-waiting dispatch a branch whose `waits:` prerequisite has not
|
|
55
|
+
# merged. The named escape for the prerequisite gate, in the
|
|
56
|
+
# tradition of --allow-local: a gate with no exit is one people
|
|
57
|
+
# route around by never annotating at all. It says so on the
|
|
58
|
+
# line it overrides, so the override is on the record.
|
|
38
59
|
# <slug> the plan to fan out
|
|
39
60
|
# Output: one line per branch, each optionally followed by an indented
|
|
40
61
|
# `in flight:` line naming a branch that already holds files, then the
|
|
41
|
-
# summary block — an optional prose consequence line, then a
|
|
42
62
|
# machine-countable footer.
|
|
43
63
|
# A branch whose worktree exists with UNMERGED work is refused rather
|
|
44
|
-
# than
|
|
64
|
+
# than handed over — counted `skipped`, with the worktree path named,
|
|
45
65
|
# in `--dry-run` identically to a real run. See "THE HELD-BRANCH GATE".
|
|
46
|
-
#
|
|
47
|
-
# summary: dispatched=2 reused=0 skipped=1 started=
|
|
66
|
+
# handed over feature/one → the registry
|
|
67
|
+
# summary: dispatched=2 reused=0 skipped=1 started=0 brief=missing worker=unconfigured brief_asked=0
|
|
48
68
|
#
|
|
49
69
|
# THE CONSEQUENCE IS STATED IN THE SUMMARY, NOT PER BRANCH. start_worker has
|
|
50
70
|
# always said "no 'Worker command' configured" beside the branch it could not
|
|
@@ -77,6 +97,23 @@
|
|
|
77
97
|
# over. It does NOT refuse: --dry-run and --status are legitimate direct calls,
|
|
78
98
|
# and a gate that blocks looking-before-leaping is a gate in the wrong place.
|
|
79
99
|
#
|
|
100
|
+
# `brief_asked=N` counts what the script did about it, and the distinction from
|
|
101
|
+
# `brief=missing` is exact: this script still writes no brief, it ASKS one to be
|
|
102
|
+
# written. `Brief command` names how to run an agent headless for one prompt,
|
|
103
|
+
# and the prompt is `/plot-implement <slug>` — the skill that already owns brief
|
|
104
|
+
# authorship. Absent key, or `none`: nothing is asked, the branch is refused as
|
|
105
|
+
# before, and the per-branch line names `no-brief-command` so the log says which
|
|
106
|
+
# arm ran. A project that never sets the key sees `brief_asked=0` and today's
|
|
107
|
+
# behaviour exactly.
|
|
108
|
+
#
|
|
109
|
+
# `brief_asked=N` COUNTS COMMANDS STARTED, NEVER BRIEFS WRITTEN. The command is
|
|
110
|
+
# detached by design and this script never waits on it, so a command that dies
|
|
111
|
+
# in its first millisecond is counted the same as one that writes and pushes a
|
|
112
|
+
# brief. Measured 2026-09-02: a `Brief command` that could not reach
|
|
113
|
+
# `/plot-implement` wrote a 33-byte log and the summary reported
|
|
114
|
+
# `brief_asked=1`. The per-branch line names the log for exactly this reason —
|
|
115
|
+
# the log is the evidence, the count is only that an attempt was made.
|
|
116
|
+
#
|
|
80
117
|
# THIS IS THE ONE SCRIPT IN THE FLEET THAT WRITES. Everything else
|
|
81
118
|
# (plot-fleet-scan.sh, plot-reconcile-scan.sh) is read-only. Consequently every
|
|
82
119
|
# write here is either idempotent or refused:
|
|
@@ -113,6 +150,22 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
|
113
150
|
# shellcheck source=plot-worker-state.sh
|
|
114
151
|
. "$script_dir/plot-worker-state.sh"
|
|
115
152
|
|
|
153
|
+
# The ONE answer to "did the host merge ANY PR for this branch?" — `pr_merged`,
|
|
154
|
+
# read by `held_worktree` rather than derived from ancestry. Sourced for the
|
|
155
|
+
# same reason `plot-reap.sh` and `plot-release-refs.sh` source it: three callers
|
|
156
|
+
# gate on one fact and must never disagree about it. The helper defines two
|
|
157
|
+
# functions and does nothing else on load.
|
|
158
|
+
# shellcheck source=plot-pr-merged.sh
|
|
159
|
+
. "$script_dir/plot-pr-merged.sh"
|
|
160
|
+
|
|
161
|
+
# The ONE answer to "what is the default branch?" — `default_branch`, which
|
|
162
|
+
# repairs an unresolvable `origin/HEAD` before it answers. This script is where
|
|
163
|
+
# that corruption was MEASURED: twice on 2026-09-04 the symref pointed at
|
|
164
|
+
# `origin/plot-corpus-pin`, and the refusal below fired on every dispatch. The
|
|
165
|
+
# refusal stays; it now has less to refuse over.
|
|
166
|
+
# shellcheck source=plot-default-branch.sh
|
|
167
|
+
. "$script_dir/plot-default-branch.sh"
|
|
168
|
+
|
|
116
169
|
# ---------------------------------------------------------------------------
|
|
117
170
|
# WHERE THE WORKTREES LIVE, and by what name
|
|
118
171
|
# ---------------------------------------------------------------------------
|
|
@@ -155,19 +208,35 @@ resolve_wt_root() { # $1=repo_root → sets globals wt_root, wt_prefix
|
|
|
155
208
|
}
|
|
156
209
|
|
|
157
210
|
dry_run=0
|
|
211
|
+
show_monitors=0
|
|
158
212
|
no_start=0
|
|
159
213
|
no_brief=0
|
|
160
214
|
mode=dispatch
|
|
161
215
|
stop_branch=""
|
|
162
216
|
restart_branch=""
|
|
217
|
+
# EMPTY MEANS "THE DEFAULT", AND THE DEFAULT IS THE RULE'S. `fleetSize` owns the
|
|
218
|
+
# number and the argument for it; a literal here would be a second copy of a
|
|
219
|
+
# decision that has one home. It is filled from the rule below.
|
|
220
|
+
start_count=""
|
|
163
221
|
offline=""
|
|
164
222
|
allow_local=0
|
|
223
|
+
allow_waiting=0
|
|
165
224
|
max=0
|
|
166
225
|
slug=""
|
|
167
226
|
migrate_yes=0
|
|
168
227
|
while [ $# -gt 0 ]; do
|
|
169
228
|
case "$1" in
|
|
170
229
|
--dry-run) dry_run=1 ;;
|
|
230
|
+
# --monitors NAMES what would be attached, and it is OPT-IN for a reason
|
|
231
|
+
# that is a protection rather than a preference. `plot-dispatch.sh` is the
|
|
232
|
+
# largest script here and a mistake in start_worker starts no workers at
|
|
233
|
+
# all, so this slice pins `--dry-run` output as BYTE-IDENTICAL before and
|
|
234
|
+
# after on the same estate — the dry run exercises every refusal against
|
|
235
|
+
# real worktrees and real pids without starting or removing anything, which
|
|
236
|
+
# is the same protection the reap and dispatch domain work used. A line
|
|
237
|
+
# added to the default output would forfeit exactly that check. So the
|
|
238
|
+
# naming lives behind its own flag: `--dry-run --monitors`.
|
|
239
|
+
--monitors) show_monitors=1 ;;
|
|
171
240
|
--status) mode=status ;;
|
|
172
241
|
--migrate) mode=migrate ;;
|
|
173
242
|
--yes) migrate_yes=1 ;;
|
|
@@ -180,16 +249,27 @@ while [ $# -gt 0 ]; do
|
|
|
180
249
|
# plan for feature/x", which describes neither what was asked nor what
|
|
181
250
|
# went wrong. The branch is consumed only when it looks like one.
|
|
182
251
|
--restart) mode=restart; case "${2:-}" in */*) restart_branch="$2"; shift ;; esac ;;
|
|
252
|
+
# `--start [N]` brings FREE agents into existence — registered, waiting, and
|
|
253
|
+
# holding no slice. The count is OPTIONAL and only a bare number is consumed,
|
|
254
|
+
# the same rule `--stop` and `--restart` apply to a branch: a value that does
|
|
255
|
+
# not look like a count is left for the parser rather than swallowed, so
|
|
256
|
+
# `--start --dry-run` means what it reads as.
|
|
257
|
+
--start) mode=start
|
|
258
|
+
case "${2:-}" in
|
|
259
|
+
''|*[!0-9]*) ;;
|
|
260
|
+
*) start_count="$2"; shift ;;
|
|
261
|
+
esac ;;
|
|
183
262
|
--no-start) no_start=1 ;;
|
|
184
263
|
--no-brief) no_brief=1 ;;
|
|
185
264
|
--offline|--no-fetch) offline="--offline" ;;
|
|
186
265
|
--allow-local) allow_local=1 ;;
|
|
266
|
+
--allow-waiting) allow_waiting=1 ;;
|
|
187
267
|
--max) max="${2:?--max needs a value}"
|
|
188
268
|
case "$max" in
|
|
189
269
|
''|*[!0-9]*) echo "plot-dispatch: --max needs a number, got '$max'" >&2; exit 1 ;;
|
|
190
270
|
esac
|
|
191
271
|
shift ;;
|
|
192
|
-
-h|--help) sed -n '2,
|
|
272
|
+
-h|--help) sed -n '2,59p' "$0"; exit 0 ;;
|
|
193
273
|
*) slug="$1" ;;
|
|
194
274
|
esac
|
|
195
275
|
shift
|
|
@@ -248,6 +328,22 @@ json_escape() {
|
|
|
248
328
|
#
|
|
249
329
|
# Model and context are still absent on purpose: they belong to the runtime and
|
|
250
330
|
# are read from the transcript, so a manifest that named them would be a guess.
|
|
331
|
+
#
|
|
332
|
+
# `resumeId` AND `session` ARE TWO FIELDS THAT HOLD ONE VALUE AT LAUNCH, and
|
|
333
|
+
# they are written separately on purpose. `session` is the transcript join key
|
|
334
|
+
# and STAYS FIXED across a branch hop, by design — `plot-worker-loop.sh` rewrites
|
|
335
|
+
# `branch` and `worktree` on each hop and leaves `session` alone. The resume
|
|
336
|
+
# handle is a different identity with a different lifetime, and whether it should
|
|
337
|
+
# follow a hop cannot even be ASKED while one field carries both meanings. They
|
|
338
|
+
# will usually agree; nothing may assume they always do.
|
|
339
|
+
#
|
|
340
|
+
# `attempts` IS THE SUPERVISOR'S OWN COUNTER, DISTINCT FROM `relaunches`.
|
|
341
|
+
# `relaunches` counts operator-initiated restarts — a human's record, written by
|
|
342
|
+
# the launch stamp. `attempts` counts a supervisor's own retries and is what a
|
|
343
|
+
# bound would read. Merging them would let a person's three manual restarts
|
|
344
|
+
# exhaust an automatic budget, or the reverse. It is written 0 here and by
|
|
345
|
+
# nothing else in this script: no component in Plot raises it yet, and a launch
|
|
346
|
+
# that guessed at one would be recording a retry nobody made.
|
|
251
347
|
# The `pid` starts EMPTY here and is stamped by the wrapper the instant it learns
|
|
252
348
|
# its own child — see `stamp_manifest_pid`. The dispatcher does not know the
|
|
253
349
|
# agent pid at this line (only the wrapper does, from its `$!`), so it writes the
|
|
@@ -260,10 +356,12 @@ write_agent_manifest() { # $1=path $2=session $3=branch $4=worktree $5=command
|
|
|
260
356
|
{
|
|
261
357
|
printf '{\n'
|
|
262
358
|
printf ' "session": "%s",\n' "$(json_escape "$2")"
|
|
359
|
+
printf ' "resumeId": "%s",\n' "$(json_escape "$2")"
|
|
263
360
|
printf ' "branch": "%s",\n' "$(json_escape "$3")"
|
|
264
361
|
printf ' "worktree": "%s",\n' "$(json_escape "$4")"
|
|
265
362
|
printf ' "command": "%s",\n' "$(json_escape "$5")"
|
|
266
363
|
printf ' "pid": "",\n'
|
|
364
|
+
printf ' "attempts": 0,\n'
|
|
267
365
|
printf ' "startedAt": "%s"\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
268
366
|
printf '}\n'
|
|
269
367
|
} > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
|
|
@@ -302,11 +400,16 @@ write_agent_manifest() { # $1=path $2=session $3=branch $4=worktree $5=command
|
|
|
302
400
|
#
|
|
303
401
|
# READ FROM `origin/<main>`, NOT THE WORKING TREE, for the same reason the phase
|
|
304
402
|
# gate above does: the question is not "does a brief exist in this filesystem?"
|
|
305
|
-
# but "will the
|
|
306
|
-
# `origin/$MAIN
|
|
307
|
-
#
|
|
308
|
-
#
|
|
309
|
-
#
|
|
403
|
+
# but "will the AGENT find one?". The agent cuts or resets its desk from
|
|
404
|
+
# `origin/$MAIN`, so a brief committed nowhere — or committed locally and never
|
|
405
|
+
# pushed — is invisible to it. Checking the working tree passes the gate and
|
|
406
|
+
# hands over a slice whose specification is empty, which is the exact failure
|
|
407
|
+
# this gate exists to prevent.
|
|
408
|
+
#
|
|
409
|
+
# THAT HOLDS ACROSS THE GATE'S MOVE, and is why the move cost this function
|
|
410
|
+
# nothing: the desk was cut from `origin/$MAIN` when dispatch cut it and is cut
|
|
411
|
+
# from `origin/$MAIN` now that the agent does. The ref the brief must be on did
|
|
412
|
+
# not change, only who reads it there.
|
|
310
413
|
#
|
|
311
414
|
# Both directions were measured 2026-08-27. Running the filesystem check from a
|
|
312
415
|
# checkout 8 commits behind main reported three branches' briefs missing while
|
|
@@ -326,6 +429,310 @@ brief_present() { # $1 = branch → 0 if a usable brief exists on origin/<main>
|
|
|
326
429
|
[ "${sz:-0}" -gt 0 ]
|
|
327
430
|
}
|
|
328
431
|
|
|
432
|
+
# WHAT HAPPENS AFTER THE GATE FIRES. The gate above is correct and stays: a
|
|
433
|
+
# missing brief still prepares and still refuses to start. What it never had is
|
|
434
|
+
# a next step — it named the file, and every brief on this estate was then
|
|
435
|
+
# written by hand.
|
|
436
|
+
#
|
|
437
|
+
# `Brief command` is that step, and it is a CONFIG KEY rather than a new script
|
|
438
|
+
# for one reason: `/plot-implement` step 4 already owns brief authorship. A
|
|
439
|
+
# script here would be a SECOND brief writer, and two writers drift. So the key
|
|
440
|
+
# names how to run an agent headless — the shape `Idea command`, `Story command`
|
|
441
|
+
# and `Approve command` already use — and the prompt it is handed asks for
|
|
442
|
+
# `/plot-implement <slug>` and nothing else.
|
|
443
|
+
#
|
|
444
|
+
# ABSENT IS NOT AN ERROR. A project with no `Brief command` behaves exactly as
|
|
445
|
+
# it does today: the gate refuses, and the refusal now names WHY nothing was
|
|
446
|
+
# called — `no-brief-command`, the shape `commission.ts` gives `no-idea-command`.
|
|
447
|
+
# `none` reads the same way as it does for `Worker command`: asked, and answered
|
|
448
|
+
# "we write them by hand".
|
|
449
|
+
brief_command() { # → the usable `Brief command`, or empty
|
|
450
|
+
local cmd
|
|
451
|
+
cmd=$("$script_dir/plot-config.sh" get "Brief command" "")
|
|
452
|
+
case "$cmd" in none|NONE|None) cmd="" ;; esac
|
|
453
|
+
printf '%s' "$cmd"
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
# Run the brief command for one branch, detached, and say what was done.
|
|
457
|
+
#
|
|
458
|
+
# DETACHED AND NOT WAITED ON, for the reason `commission.ts` gives: this is a
|
|
459
|
+
# `claude -p` session of unknown length, and a fan-out that blocks on one would
|
|
460
|
+
# hold every later branch behind it. The dispatch run reports that it asked; the
|
|
461
|
+
# brief lands in a later commit, and the NEXT dispatch of the same branch starts
|
|
462
|
+
# it. That is the whole loop.
|
|
463
|
+
#
|
|
464
|
+
# The prompt travels as ONE argument through `"$@"`, never interpolated into the
|
|
465
|
+
# command string — `Brief command` is a shell FRAGMENT run through `sh -c`, so
|
|
466
|
+
# anything spliced into it would be shell source. The slug is the only value
|
|
467
|
+
# that reaches it and it is a plan slug, but the rule holds regardless of the
|
|
468
|
+
# value: the safety is in the shape, not in the input.
|
|
469
|
+
# WHAT THE AGENT IS ASKED FOR, and the one thing it must not be asked for.
|
|
470
|
+
#
|
|
471
|
+
# It is asked to run `/plot-implement <slug>`, and it is NOT asked to write a
|
|
472
|
+
# brief in its own words. `/plot-implement` step 4 owns brief authorship; a
|
|
473
|
+
# prompt that described the brief here would be a second author, and two
|
|
474
|
+
# authors drift. The branch and the path are named because the skill writes for
|
|
475
|
+
# one branch and the gate reads one path — `brief_path` is the same function
|
|
476
|
+
# `brief_present` reads with, so writer and reader cannot disagree.
|
|
477
|
+
#
|
|
478
|
+
# It is told to COMMIT AND PUSH, because the gate reads `origin/<main>`. A brief
|
|
479
|
+
# written and left in a working tree is invisible to the gate that asked for it,
|
|
480
|
+
# and the next dispatch would ask again — a loop that writes a file every pass
|
|
481
|
+
# and never starts a worker.
|
|
482
|
+
brief_prompt() { # $1 = branch, $2 = slug
|
|
483
|
+
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.' \
|
|
484
|
+
"$2" "$1" "$(brief_path "$1")" "$MAIN" "$MAIN"
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
# WHETHER THE AGENT THIS COMMAND SPAWNS CAN REACH `plot-implement` AT ALL.
|
|
488
|
+
#
|
|
489
|
+
# `Brief command` runs an agent headless and asks it for `/plot-implement
|
|
490
|
+
# <slug>`. `plot-implement` is a SKILL, and a skill the running agent does not
|
|
491
|
+
# have resolves to nothing. Measured 2026-09-06: Plot is installed as a plugin
|
|
492
|
+
# at 1.2.0 against this repository's 2.13.0, and the installed `skills/` holds
|
|
493
|
+
# eight — `plot-implement` is not among them, nor are `plot-dispatch`,
|
|
494
|
+
# `plot-fleet`, `plot-pulse`, `plot-reconcile`, `plot-board`, `plot-init`,
|
|
495
|
+
# `plot-merge-queue` or `plot-reslice`. Twelve are missing.
|
|
496
|
+
#
|
|
497
|
+
# So `Unknown command: /plot-implement` was LITERALLY TRUE in both 33-byte logs
|
|
498
|
+
# (2026-09-02, 2026-09-04), and the board's *"Run /plot-implement `<slug>` and
|
|
499
|
+
# follow it."* would have failed identically. The prompt's wording was never the
|
|
500
|
+
# defect, and rewriting it fixes nothing.
|
|
501
|
+
#
|
|
502
|
+
# THE INSTALL BEING SCOPED TO ANOTHER PROJECT IS WHY FOUR MONTHS PASSED
|
|
503
|
+
# UNNOTICED. The registry entry carries `scope: project` and `projectPath:
|
|
504
|
+
# /Users/jwloka/Quatico/CDS/cpq-cds-develop` — a DIFFERENT repository, installed
|
|
505
|
+
# 2026-05-08. A plugin installed elsewhere is not absent, it is somewhere else,
|
|
506
|
+
# and nothing on this estate named that.
|
|
507
|
+
#
|
|
508
|
+
# IT ASKS WHETHER THE SKILL DIRECTORY EXISTS, NOT WHETHER THE VERSION MATCHES.
|
|
509
|
+
# A lagging install that still carries the skill works, and a version comparison
|
|
510
|
+
# would refuse it and demand an update nobody needs. One missing directory is
|
|
511
|
+
# what breaks a brief session, so that is what is read.
|
|
512
|
+
#
|
|
513
|
+
# IT FAILS TOWARD ALLOWING. An unreadable or absent `installed_plugins.json`
|
|
514
|
+
# means CANNOT VERIFY, not broken: a project running Plot from a checkout with
|
|
515
|
+
# no plugin install at all is a supported shape, and refusing it would break
|
|
516
|
+
# dispatch for everyone who never installed the plugin. It says the check could
|
|
517
|
+
# not be made, and proceeds.
|
|
518
|
+
#
|
|
519
|
+
# `PLOT_PLUGIN_ROOT` is the override, the same one `plot-board-probe.sh` uses so
|
|
520
|
+
# tests need not depend on `$HOME`.
|
|
521
|
+
plugin_registry() { printf '%s/installed_plugins.json' "${PLOT_PLUGIN_ROOT:-${HOME:-}/.claude/plugins}"; }
|
|
522
|
+
|
|
523
|
+
# Every `installPath` recorded for a plugin whose key starts `plot@`, one per
|
|
524
|
+
# line. The marketplace half of the key is not fixed — `plot@plot-marketplace`
|
|
525
|
+
# here, but a repository may serve Plot from a marketplace under any name — so
|
|
526
|
+
# the prefix is what is matched rather than the whole key.
|
|
527
|
+
#
|
|
528
|
+
# `awk` rather than `node` or `jq`: this script shells to neither anywhere else,
|
|
529
|
+
# and a check that fails toward allowing must not acquire a dependency whose
|
|
530
|
+
# absence it would then have to read as "cannot verify" on every machine that
|
|
531
|
+
# lacks it.
|
|
532
|
+
#
|
|
533
|
+
# IT DOES NOT DEPEND ON THE FILE'S LINE BREAKS. The registry Claude Code writes
|
|
534
|
+
# is pretty-printed, one field per line, and a line-oriented read of it works —
|
|
535
|
+
# but only because of how it happens to be formatted, and this check's wrong
|
|
536
|
+
# answer is silent. Measured 2026-09-07 against a compact registry written by
|
|
537
|
+
# hand: the whole object on two lines, and the reader found no install at all,
|
|
538
|
+
# reported "could not verify" and allowed. Safe, and still wrong about a file
|
|
539
|
+
# that was perfectly readable.
|
|
540
|
+
#
|
|
541
|
+
# So the text is flattened to one token per line FIRST — every `"…"` string and
|
|
542
|
+
# every `[` `]` `{` `}` on its own line — and the scan then walks tokens rather
|
|
543
|
+
# than lines. Formatting stops being an input.
|
|
544
|
+
#
|
|
545
|
+
# The bracket depth is what scopes a key: a plugin's entry runs from the `[`
|
|
546
|
+
# after its key to the `]` that closes it, so an `installPath` is attributed to
|
|
547
|
+
# the key whose array still encloses it. Tracking the key alone would let the
|
|
548
|
+
# LAST `plot@…` key claim every install after it in the file.
|
|
549
|
+
plugin_install_paths() { # → one installPath per line, for keys matching plot@*
|
|
550
|
+
awk '
|
|
551
|
+
{
|
|
552
|
+
line = $0
|
|
553
|
+
while (length(line) > 0) {
|
|
554
|
+
c = substr(line, 1, 1)
|
|
555
|
+
if (c == "\"") {
|
|
556
|
+
# A JSON string, taken whole: scan to the closing quote, honouring
|
|
557
|
+
# backslash escapes so a quote inside a value does not end it early.
|
|
558
|
+
v = ""; i = 2
|
|
559
|
+
while (i <= length(line)) {
|
|
560
|
+
ch = substr(line, i, 1)
|
|
561
|
+
if (ch == "\\") { v = v substr(line, i + 1, 1); i += 2; continue }
|
|
562
|
+
if (ch == "\"") break
|
|
563
|
+
v = v ch; i++
|
|
564
|
+
}
|
|
565
|
+
print "S" v
|
|
566
|
+
line = substr(line, i + 1)
|
|
567
|
+
} else if (c == "[" || c == "]" || c == "{" || c == "}") {
|
|
568
|
+
print "P" c
|
|
569
|
+
line = substr(line, 2)
|
|
570
|
+
} else {
|
|
571
|
+
line = substr(line, 2)
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
' "$1" 2>/dev/null | awk '
|
|
576
|
+
# `pending` is the string most recently seen, which is a KEY when the next
|
|
577
|
+
# token opens a container. `depth` is where this plugin"s array closes.
|
|
578
|
+
/^P\[/ {
|
|
579
|
+
depth++
|
|
580
|
+
if (pending ~ /^plot@/ && inplot == 0) { inplot = 1; plotdepth = depth }
|
|
581
|
+
pending = ""; next
|
|
582
|
+
}
|
|
583
|
+
/^P\]/ {
|
|
584
|
+
if (inplot && depth == plotdepth) inplot = 0
|
|
585
|
+
depth--; pending = ""; next
|
|
586
|
+
}
|
|
587
|
+
/^P/ { pending = ""; next }
|
|
588
|
+
/^S/ {
|
|
589
|
+
v = substr($0, 2)
|
|
590
|
+
if (want) { if (inplot && v != "") print v; want = 0; pending = ""; next }
|
|
591
|
+
if (v == "installPath") { want = 1; next }
|
|
592
|
+
pending = v
|
|
593
|
+
}
|
|
594
|
+
'
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
# Can a spawned agent reach `plot-implement`?
|
|
598
|
+
#
|
|
599
|
+
# 0 — yes: some recorded install carries `skills/plot-implement/`
|
|
600
|
+
# 1 — no: the registry was read, Plot is installed, and none of its installs
|
|
601
|
+
# carry the skill. This is the refusal.
|
|
602
|
+
# 2 — cannot verify: no registry, unreadable, or no `plot@*` entry at all.
|
|
603
|
+
# Allowed, and said.
|
|
604
|
+
#
|
|
605
|
+
# The three-way answer is the whole point. Collapsing "cannot verify" into
|
|
606
|
+
# either of the others is how this check would break a repository running Plot
|
|
607
|
+
# from a checkout, or wave through the exact install that produced two empty
|
|
608
|
+
# logs.
|
|
609
|
+
implement_skill_reach() { # → 0 reachable, 1 unreachable, 2 unverifiable
|
|
610
|
+
local reg paths p
|
|
611
|
+
reg=$(plugin_registry)
|
|
612
|
+
[ -n "$reg" ] && [ -r "$reg" ] || return 2
|
|
613
|
+
paths=$(plugin_install_paths "$reg")
|
|
614
|
+
[ -n "$paths" ] || return 2
|
|
615
|
+
while IFS= read -r p; do
|
|
616
|
+
[ -n "$p" ] || continue
|
|
617
|
+
case "$p" in "~/"*) p="${HOME:-}/${p#\~/}" ;; esac
|
|
618
|
+
[ -d "$p/skills/plot-implement" ] && return 0
|
|
619
|
+
done <<< "$paths"
|
|
620
|
+
return 1
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
# What the refusal SAYS, and it is the shape `plot-fleetctl.sh:306` uses for a
|
|
624
|
+
# wrong `node` major — the reading first, then the repair.
|
|
625
|
+
#
|
|
626
|
+
# IT NAMES A REPAIR AN OPERATOR CAN PERFORM. Plot cannot install its own plugin;
|
|
627
|
+
# updating an install is an action on a machine. So the message reports what was
|
|
628
|
+
# read — which installs, and that none carries the skill — and names the command
|
|
629
|
+
# that fixes it. Nothing here installs or updates anything.
|
|
630
|
+
implement_unreachable_report() { # prints the reading and the repair, indented
|
|
631
|
+
local reg p
|
|
632
|
+
reg=$(plugin_registry)
|
|
633
|
+
echo " no-implement-skill — the agent this would spawn cannot reach \`/plot-implement\`"
|
|
634
|
+
echo " read: $reg"
|
|
635
|
+
while IFS= read -r p; do
|
|
636
|
+
[ -n "$p" ] || continue
|
|
637
|
+
echo " install: $p (no skills/plot-implement/)"
|
|
638
|
+
done <<< "$(plugin_install_paths "$reg")"
|
|
639
|
+
echo " A prompt naming a skill the agent does not have resolves to nothing,"
|
|
640
|
+
echo " whatever its wording — measured twice as a 33-byte log reading"
|
|
641
|
+
echo " \`Unknown command: /plot-implement\`. Refusing before spawning is what"
|
|
642
|
+
echo " puts that in front of you instead of in a log nobody reads."
|
|
643
|
+
echo " Fix it: update the Plot plugin so its skills/ carries plot-implement"
|
|
644
|
+
echo " (/plugin, or reinstall from the marketplace), then dispatch again."
|
|
645
|
+
echo " Or write the brief yourself: /plot-implement $1"
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
# THE THIRD ANSWER, and it allows. A registry that cannot be read says nothing
|
|
649
|
+
# about whether the skill is there — a project running Plot from a checkout with
|
|
650
|
+
# no plugin install at all is a supported shape, and refusing it would break
|
|
651
|
+
# dispatch for everyone who never installed the plugin. So the run says the
|
|
652
|
+
# check could not be made and proceeds, which is what keeps a silent skip from
|
|
653
|
+
# reading as a verified pass.
|
|
654
|
+
implement_unverifiable_note() {
|
|
655
|
+
echo " could not verify that the agent can reach the implement skill —"
|
|
656
|
+
echo " no readable plugin registry at $(plugin_registry); proceeding"
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
request_brief() { # $1 = branch, $2 = slug → 0 if a command was started
|
|
660
|
+
local branch="$1" bslug="$2" cmd log reach
|
|
661
|
+
cmd=$(brief_command)
|
|
662
|
+
if [ -z "$cmd" ]; then
|
|
663
|
+
echo " no-brief-command — no \`Brief command\` in Plot Config, so nothing was asked to write it"
|
|
664
|
+
return 1
|
|
665
|
+
fi
|
|
666
|
+
# REFUSES BEFORE SPAWNING, NEVER AFTER. Failing late cost two logs nobody
|
|
667
|
+
# read and a session writing nine briefs by hand without asking why the arm
|
|
668
|
+
# was silent. The refusal is read by whoever ran the dispatch.
|
|
669
|
+
#
|
|
670
|
+
# A DIFFERENT REFUSAL FROM `no-brief-command`, and it gets its own word rather
|
|
671
|
+
# than overloading that one: there the key is absent and nothing was asked to
|
|
672
|
+
# write the brief; here the key is SET and the skill it names is unreachable.
|
|
673
|
+
# One word for two states is a word an operator cannot act on.
|
|
674
|
+
implement_skill_reach
|
|
675
|
+
reach=$?
|
|
676
|
+
if [ "$reach" = 1 ]; then
|
|
677
|
+
implement_unreachable_report "$bslug"
|
|
678
|
+
return 1
|
|
679
|
+
fi
|
|
680
|
+
if [ "$reach" = 2 ]; then
|
|
681
|
+
implement_unverifiable_note
|
|
682
|
+
fi
|
|
683
|
+
log="$repo_root/.plot/brief-$(printf '%s' "${branch##*/}").log"
|
|
684
|
+
mkdir -p "$(dirname "$log")" 2>/dev/null || true
|
|
685
|
+
# `nohup ... &` inside a subshell, the same detachment `start_worker` uses:
|
|
686
|
+
# this outlives the dispatch run by design, because the fan-out must not block
|
|
687
|
+
# on a `claude -p` session of unknown length. `setsid` is not used — it does
|
|
688
|
+
# not exist on macOS, where most of this fleet runs.
|
|
689
|
+
( cd "$repo_root" \
|
|
690
|
+
&& PLOT_UNATTENDED=1 PLOT_PLAN_SLUG="$bslug" PLOT_BRIEF_BRANCH="$branch" \
|
|
691
|
+
nohup sh -c "$cmd \"\$@\"" plot-brief \
|
|
692
|
+
"$(brief_prompt "$branch" "$bslug")" \
|
|
693
|
+
>"$log" 2>&1 </dev/null & ) 2>/dev/null
|
|
694
|
+
echo " asked the \`Brief command\` to write it — log: $log"
|
|
695
|
+
# SAYS WHAT WAS MEASURED, WHICH IS THE START AND NOT THE RESULT. The command
|
|
696
|
+
# is detached and never waited on, so this returns 0 the moment it is spawned
|
|
697
|
+
# and a command that fails in its first millisecond still counts. Measured
|
|
698
|
+
# 2026-09-02, first real use: the log held 33 bytes, `Unknown command:
|
|
699
|
+
# /plot-implement`, and the summary still reported `brief_asked=1`. Naming the
|
|
700
|
+
# log as the evidence is what keeps the count from reading as a promise.
|
|
701
|
+
echo " started, not awaited — read the log to see whether it wrote anything"
|
|
702
|
+
echo " dispatch $bslug again once it lands; the gate reads $(brief_ref "$branch")"
|
|
703
|
+
return 0
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
# STALENESS REPORTS AND NEVER REFUSES, and the measurement says why.
|
|
707
|
+
#
|
|
708
|
+
# Compared 2026-09-01, all three live briefs were older than their plans and all
|
|
709
|
+
# three were CORRECT — every plan edit between them was bookkeeping (a PR
|
|
710
|
+
# annotation, a measurement note, a re-measure before approval). A timestamp
|
|
711
|
+
# gate would have refused 3 of 3 on the day it shipped, and a gate that refuses
|
|
712
|
+
# everything is one people disable in its first week.
|
|
713
|
+
#
|
|
714
|
+
# It would also have missed the real case. The teardown brief was written AFTER
|
|
715
|
+
# its plan and was still wrong, citing 80 `fs.rmSync` sites where the tree held
|
|
716
|
+
# 76 — the CODE moved, not the plan. Freshness against the plan is the wrong
|
|
717
|
+
# input: a brief's claims are about the repository, and nothing here compares
|
|
718
|
+
# those to the repository. What would actually gate is judgement about which
|
|
719
|
+
# numbers in a paragraph are claims, which grep cannot reach.
|
|
720
|
+
#
|
|
721
|
+
# So this prints a hint and SAYS it is a hint, naming the plan commit it
|
|
722
|
+
# compared against so the reader can look at that commit rather than guess.
|
|
723
|
+
brief_staleness_note() { # $1 = branch → prints a hint, or nothing
|
|
724
|
+
local branch="$1" bc pc bs ps
|
|
725
|
+
[ -n "$gate_sha" ] || return 0 # nothing shared to compare against
|
|
726
|
+
[ -n "${plan_path:-}" ] || return 0
|
|
727
|
+
bc=$(git log -1 --format='%H %ct' "$gate_ref" -- "$(brief_path "$branch")" 2>/dev/null) || return 0
|
|
728
|
+
pc=$(git log -1 --format='%H %ct' "$gate_ref" -- "$plan_path" 2>/dev/null) || return 0
|
|
729
|
+
[ -n "$bc" ] && [ -n "$pc" ] || return 0
|
|
730
|
+
bs=${bc##* }; ps=${pc##* }
|
|
731
|
+
[ "$bs" -lt "$ps" ] 2>/dev/null || return 0
|
|
732
|
+
echo " brief older than the plan — a HINT, not a gate: the plan may have moved, or the edit may have been bookkeeping"
|
|
733
|
+
echo " plan commit ${pc%% *} touched $plan_path after the brief's last change; read it before trusting the brief"
|
|
734
|
+
}
|
|
735
|
+
|
|
329
736
|
start_worker() {
|
|
330
737
|
local branch="$1" wt="$2"
|
|
331
738
|
local cmd
|
|
@@ -473,6 +880,23 @@ start_worker() {
|
|
|
473
880
|
# single-quoted `sh -c` mangles a path with spaces, exactly as the exit file
|
|
474
881
|
# already does.
|
|
475
882
|
#
|
|
883
|
+
# AND THE WRAPPER WRITES IT ITSELF, from `$$` inside the `sh -c`. Until
|
|
884
|
+
# 2026-08-31 the dispatcher wrote `echo $!` beside the spawn, which named an
|
|
885
|
+
# intermediate subshell rather than the wrapper: three of three live workers
|
|
886
|
+
# measured that day recorded a pid one process above the agent's real parent
|
|
887
|
+
# (7357 against 7358, 71953 against 71954, 92947 against 92949).
|
|
888
|
+
#
|
|
889
|
+
# The cause is that `$!` names the last job THIS shell backgrounded, and with
|
|
890
|
+
# an env-var prefix in front of `nohup` bash cannot collapse the AND-list into
|
|
891
|
+
# one child — it forks a subshell, and that subshell is what `$!` reports.
|
|
892
|
+
# (Without the prefix bash `exec`s the command in place and `$!` is correct,
|
|
893
|
+
# which is why the shape matters and a smaller repro does not show it.)
|
|
894
|
+
#
|
|
895
|
+
# So the same rule the agent pid already follows applies here: THE PROCESS
|
|
896
|
+
# THAT KNOWS A PID IS THE ONE THAT WRITES IT. The wrapper knows `$$`; no
|
|
897
|
+
# ancestor can name it without guessing. It is written FIRST, before the
|
|
898
|
+
# monitors and the agent, so the file exists as early as it can.
|
|
899
|
+
#
|
|
476
900
|
# The agent runs backgrounded inside the wrapper so the wrapper can capture its
|
|
477
901
|
# `$!` and `wait` for it. There is a sub-millisecond window after the wrapper
|
|
478
902
|
# starts and before it writes `.plot-worker.pid`; a scan landing in it reads an
|
|
@@ -522,6 +946,62 @@ start_worker() {
|
|
|
522
946
|
# after it, then any stale copies of those lines are dropped and `startedAt` is
|
|
523
947
|
# rewritten to the current run. This is exactly `stampManifest`, line for line,
|
|
524
948
|
# which the parity test pins byte for byte.
|
|
949
|
+
#
|
|
950
|
+
# EVERY WORKER IS BORN MONITORED, AND THAT IS ENFORCED HERE OR NOWHERE.
|
|
951
|
+
#
|
|
952
|
+
# Three monitors start INSIDE the wrapper, as its children, immediately before
|
|
953
|
+
# the agent: one watches the process (`plot-worker-monitor.sh`), one watches
|
|
954
|
+
# the desk (`plot-agent-monitor.sh`), one watches the run
|
|
955
|
+
# (`plot-build-monitor.sh`). Each has a subject the others do not and a
|
|
956
|
+
# cadence it cannot share — seconds on the process table, minutes on the host,
|
|
957
|
+
# seconds again on a run but only while one is live.
|
|
958
|
+
#
|
|
959
|
+
# WHY INSIDE THE WRAPPER RATHER THAN BESIDE IT. The wrapper already outlives
|
|
960
|
+
# its agent by construction — it must, or there would be no exit code to
|
|
961
|
+
# write — so a child of it inherits that survival for free. Two processes
|
|
962
|
+
# started SIDE BY SIDE are independently mortal: the monitor could be killed
|
|
963
|
+
# or crash with nothing noticing, which is the failure being fixed one level
|
|
964
|
+
# up. `--stop` kills the agent; the monitors and the exit record survive it.
|
|
965
|
+
#
|
|
966
|
+
# WHY HERE RATHER THAN ANYWHERE ELSE. `start_worker` is the single path to a
|
|
967
|
+
# worker, which is what makes "every worker is born monitored" a gate rather
|
|
968
|
+
# than a rule: there is no other place to forget. Ask CLAUDE.md's test — *can
|
|
969
|
+
# you answer "did I attach a monitor?" without doing the work?* Here you
|
|
970
|
+
# cannot: no monitor start, no monitored worker, and a mutation test says so.
|
|
971
|
+
#
|
|
972
|
+
# ORDER, AND WHY IT IS THIS WAY ROUND. The monitors are backgrounded FIRST so
|
|
973
|
+
# they exist before their subject does; the agent is backgrounded next and
|
|
974
|
+
# `$!` is captured on the VERY NEXT command, because `$!` names the most
|
|
975
|
+
# recent background job and the pid file must name the AGENT. Starting a
|
|
976
|
+
# monitor between the agent and its `$!` would record a monitor's pid as the
|
|
977
|
+
# worker's — the panel bug the two-pid split already exists to prevent.
|
|
978
|
+
#
|
|
979
|
+
# THEY INHERIT THE STARTUP WINDOW RATHER THAN WIDENING IT. There is a
|
|
980
|
+
# sub-millisecond gap after the wrapper starts and before `.plot-worker.pid`
|
|
981
|
+
# is written, and a scan landing in it reads `none` — honest. The monitors
|
|
982
|
+
# start inside that same window; they must never turn an unwritten pid file
|
|
983
|
+
# into a `gone` finding, which is why the no-op reads no pid at all and the
|
|
984
|
+
# next slice treats an absent pid file as *not yet*.
|
|
985
|
+
#
|
|
986
|
+
# THE PATHS TRAVEL AS ENV VARS, like every other path the wrapper needs. The
|
|
987
|
+
# `sh -c` body is single-quoted and a path with spaces would not survive
|
|
988
|
+
# interpolation into it — the same reason the exit, pid and manifest paths are
|
|
989
|
+
# passed this way. An EMPTY value means "not attached", which is what keeps a
|
|
990
|
+
# missing script from turning into `command not found` in a detached shell
|
|
991
|
+
# nobody is reading.
|
|
992
|
+
#
|
|
993
|
+
# A HAND-MADE WORKTREE GETS NEITHER, and that falls out rather than being
|
|
994
|
+
# enforced: this is the only code that starts a wrapper, and a worktree with
|
|
995
|
+
# no wrapper has nothing for a monitor to be a child of.
|
|
996
|
+
local worker_monitor='' agent_monitor='' build_monitor=''
|
|
997
|
+
[ -x "$script_dir/plot-worker-monitor.sh" ] && worker_monitor="$script_dir/plot-worker-monitor.sh"
|
|
998
|
+
[ -x "$script_dir/plot-agent-monitor.sh" ] && agent_monitor="$script_dir/plot-agent-monitor.sh"
|
|
999
|
+
# THE THIRD MONITOR, born the same way and for the same reason. It watches the
|
|
1000
|
+
# RUN — a Build is its own entity in the spec, so a monitor per entity is the
|
|
1001
|
+
# pattern rather than an exception to it. Its cadence is the WorkerMonitor's
|
|
1002
|
+
# 30 s rather than the AgentMonitor's 300 s, and it can afford that against a
|
|
1003
|
+
# HOST because it asks nothing while no run is live.
|
|
1004
|
+
[ -x "$script_dir/plot-build-monitor.sh" ] && build_monitor="$script_dir/plot-build-monitor.sh"
|
|
525
1005
|
local stamp_now
|
|
526
1006
|
stamp_now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
527
1007
|
( cd "$wt" && PLOT_BRANCH="$branch" PLOT_WORKTREE="$wt" \
|
|
@@ -529,8 +1009,12 @@ start_worker() {
|
|
|
529
1009
|
PLOT_SESSION_ID="$session" \
|
|
530
1010
|
PLOT_MANIFEST_FILE="$manifest_dir/$session.json" \
|
|
531
1011
|
PLOT_STAMP_STARTED="$stamp_now" \
|
|
1012
|
+
PLOT_WORKER_MONITOR="$worker_monitor" \
|
|
1013
|
+
PLOT_AGENT_MONITOR="$agent_monitor" \
|
|
1014
|
+
PLOT_BUILD_MONITOR="$build_monitor" \
|
|
532
1015
|
PLOT_EXIT_FILE="$wt/.plot-worker.exit" PLOT_PID_FILE="$wt/.plot-worker.pid" \
|
|
533
|
-
|
|
1016
|
+
PLOT_WRAPPER_PID_FILE="$wt/.plot-worker.wrapper.pid" \
|
|
1017
|
+
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
1018
|
BEGIN { relaunch = 0; count = 1; stamped = 0 }
|
|
535
1019
|
FNR == NR {
|
|
536
1020
|
if ($0 ~ /^ "pid": "[^"]*",$/) {
|
|
@@ -545,18 +1029,26 @@ start_worker() {
|
|
|
545
1029
|
!stamped && $0 ~ /^ "pid": "[^"]*",$/ {
|
|
546
1030
|
stamped = 1
|
|
547
1031
|
print " \"pid\": \"" pid "\","
|
|
1032
|
+
print " \"wrapperPid\": \"" wrapper "\","
|
|
1033
|
+
print " \"workerMonitorPid\": \"" wmon "\","
|
|
1034
|
+
print " \"agentMonitorPid\": \"" amon "\","
|
|
1035
|
+
print " \"buildMonitorPid\": \"" bmon "\","
|
|
548
1036
|
if (relaunch) {
|
|
549
1037
|
print " \"previousPid\": \"" displaced "\","
|
|
550
1038
|
print " \"relaunches\": " count ","
|
|
551
1039
|
}
|
|
552
1040
|
next
|
|
553
1041
|
}
|
|
1042
|
+
$0 ~ /^ "wrapperPid": "[^"]*",$/ { next }
|
|
1043
|
+
$0 ~ /^ "workerMonitorPid": "[^"]*",$/ { next }
|
|
1044
|
+
$0 ~ /^ "agentMonitorPid": "[^"]*",$/ { next }
|
|
1045
|
+
$0 ~ /^ "buildMonitorPid": "[^"]*",$/ { next }
|
|
554
1046
|
relaunch && $0 ~ /^ "previousPid": "[^"]*",$/ { next }
|
|
555
1047
|
relaunch && $0 ~ /^ "relaunches": [0-9]+,$/ { next }
|
|
556
1048
|
relaunch && $0 ~ /^ "startedAt": "[^"]*"$/ { print " \"startedAt\": \"" started "\""; next }
|
|
557
1049
|
{ print }
|
|
558
1050
|
'"'"' "$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 &
|
|
1051
|
+
>"$log" 2>&1 </dev/null & )
|
|
560
1052
|
echo " started worker (log: $log)"
|
|
561
1053
|
return 0
|
|
562
1054
|
}
|
|
@@ -826,6 +1318,284 @@ if [ "$mode" = "restart" ]; then
|
|
|
826
1318
|
exit 0
|
|
827
1319
|
fi
|
|
828
1320
|
|
|
1321
|
+
if [ "$mode" = "start" ]; then
|
|
1322
|
+
# THE LAST LINK IN THE CHAIN. `plot-dispatch.sh <slug>` queues slices and the
|
|
1323
|
+
# registry matches them to free agents, but until this verb existed nothing
|
|
1324
|
+
# brought a free agent into being. Measured 2026-09-05: a dispatch reported
|
|
1325
|
+
# `handed over feature/... → the registry` and `started=0`, the supervisor
|
|
1326
|
+
# ticked `queued=456 idle=0 agents registered: 0`, and `.plot/agents/` was
|
|
1327
|
+
# empty. The slice was queued, the registry was willing, and there was nobody
|
|
1328
|
+
# to hand it to.
|
|
1329
|
+
#
|
|
1330
|
+
# AN AGENT IS STARTED WITH NO SLICE, and that is the whole shape. It gets a
|
|
1331
|
+
# desk, a manifest naming no branch, and a loop that waits — `isAgentFree`
|
|
1332
|
+
# already reports exactly that state as free (`rules/free.ts:64`: alive, and
|
|
1333
|
+
# `branch === ""`), so the supervisor's next tick can hand each one a queued
|
|
1334
|
+
# slice with nobody touching a desk.
|
|
1335
|
+
#
|
|
1336
|
+
# HERE, BESIDE --stop AND --restart AND BEFORE THE PHASE GATE, for the reason
|
|
1337
|
+
# that block already gives from the other side: starting a free agent is not
|
|
1338
|
+
# about any plan, so there is no plan whose phase could refuse it. A gate on a
|
|
1339
|
+
# slug this verb never takes would refuse every call.
|
|
1340
|
+
repo_root="$repo_root_early"
|
|
1341
|
+
resolve_wt_root "$repo_root"
|
|
1342
|
+
|
|
1343
|
+
# THE DEFAULT BRANCH, by the same three steps the fan-out takes below — the
|
|
1344
|
+
# config key, then origin's own HEAD, then `main`. Resolved here because the
|
|
1345
|
+
# fan-out's `MAIN` is set past the phase gate this path exits before.
|
|
1346
|
+
start_main=$(bash "$script_dir/plot-config.sh" get "Main branch")
|
|
1347
|
+
[ -n "$start_main" ] || start_main=$(default_branch)
|
|
1348
|
+
[ -n "$start_main" ] || start_main="main"
|
|
1349
|
+
|
|
1350
|
+
# HOW MANY WORKERS ARE ALREADY UP, counted from the desks on this disk rather
|
|
1351
|
+
# than from the registry. A manifest records a launch; a live pid records a
|
|
1352
|
+
# worker, and the question `--start` asks is about the machine's load. The
|
|
1353
|
+
# count is what `fleetSize` subtracts, so asking for three twice gives three
|
|
1354
|
+
# agents rather than six.
|
|
1355
|
+
#
|
|
1356
|
+
# A CALLER THAT HAS ALREADY DECIDED THE COUNT SUBTRACTS NOTHING, and
|
|
1357
|
+
# `PLOT_START_ONE` is how it says so. The supervisor's tick ran `fleetSize`
|
|
1358
|
+
# against the WHOLE fleet, decided N, and hands this script one write at a
|
|
1359
|
+
# time; re-counting per call subtracts the agent the PREVIOUS call just
|
|
1360
|
+
# started. Measured 2026-09-05 in a sandbox: a tick that decided `started=3`
|
|
1361
|
+
# produced ONE agent, because calls two and three each saw a fleet already
|
|
1362
|
+
# the size they were asked for.
|
|
1363
|
+
#
|
|
1364
|
+
# SO IT MEANS *START THIS ONE*: the count is one and the subtraction is
|
|
1365
|
+
# skipped. The machine keeps its veto — a starved one still answers with
|
|
1366
|
+
# fewer, which is the reading that must not be skipped, because it is about
|
|
1367
|
+
# the load this very call would add.
|
|
1368
|
+
#
|
|
1369
|
+
# A SEPARATE VARIABLE FROM `PLOT_START_DESK`, and not a side effect of it.
|
|
1370
|
+
# The two answer different questions — *where does this desk go* and *has the
|
|
1371
|
+
# count already been decided* — and the caller that has decided the count is
|
|
1372
|
+
# not always the caller that can name a path. The supervisor is exactly that
|
|
1373
|
+
# caller: the domain names no worktree, so it sets this and leaves the path
|
|
1374
|
+
# to the script's own `Worktree root` convention.
|
|
1375
|
+
start_running=0
|
|
1376
|
+
if [ -n "${PLOT_START_ONE:-}" ]; then
|
|
1377
|
+
start_count=1
|
|
1378
|
+
else
|
|
1379
|
+
while IFS= read -r wt; do
|
|
1380
|
+
[ -n "$wt" ] || continue
|
|
1381
|
+
[ -f "$wt/.plot-worker.pid" ] || continue
|
|
1382
|
+
p=$(cat "$wt/.plot-worker.pid" 2>/dev/null) || continue
|
|
1383
|
+
[ -n "$p" ] && ps -p "$p" >/dev/null 2>&1 && start_running=$((start_running + 1))
|
|
1384
|
+
done <<EOF
|
|
1385
|
+
$(git worktree list --porcelain </dev/null 2>/dev/null | awk '/^worktree /{print $2}')
|
|
1386
|
+
EOF
|
|
1387
|
+
fi
|
|
1388
|
+
|
|
1389
|
+
# THE MACHINE'S OWN READING, one timed fork. `machine-system.ts` samples five
|
|
1390
|
+
# and divides; this takes ONE, because the decision it feeds is coarse — three
|
|
1391
|
+
# bands — and a start that spent 250 ms sampling before deciding whether the
|
|
1392
|
+
# machine is busy would be the story's own complaint reproduced by its fix.
|
|
1393
|
+
#
|
|
1394
|
+
# UNMEASURABLE IS NOT STARVED. A `date` that cannot answer in milliseconds
|
|
1395
|
+
# (BSD `date` without `%N`) leaves the cost empty, the rule reads `unmeasured`,
|
|
1396
|
+
# and an absent veto is not a refusal. The machine vetoes what it can measure.
|
|
1397
|
+
#
|
|
1398
|
+
# THE CLOCK IS BASH'S OWN `EPOCHREALTIME`, not `date` and not `python3`. Both
|
|
1399
|
+
# of those are a FORK, which is precisely the thing being timed — the
|
|
1400
|
+
# measurement would cost two of what it measures and report the sum. Bash 5
|
|
1401
|
+
# expands `EPOCHREALTIME` in-process; where it is empty (bash 4, still the
|
|
1402
|
+
# system shell on macOS) the cost stays unmeasured, which the rule reads as
|
|
1403
|
+
# `unmeasured` rather than as clear.
|
|
1404
|
+
start_cost=""
|
|
1405
|
+
if [ -n "${EPOCHREALTIME:-}" ]; then
|
|
1406
|
+
start_t0=${EPOCHREALTIME/[.,]/}
|
|
1407
|
+
git -C "$repo_root" rev-parse --git-dir >/dev/null 2>&1
|
|
1408
|
+
start_t1=${EPOCHREALTIME/[.,]/}
|
|
1409
|
+
# Microseconds to milliseconds. Integer division, so a fork faster than a
|
|
1410
|
+
# millisecond reports 0 — which is honest about a machine this clear and is
|
|
1411
|
+
# the same band `clear` covers anyway.
|
|
1412
|
+
case "$start_t0$start_t1" in
|
|
1413
|
+
*[!0-9]*) ;;
|
|
1414
|
+
*) start_cost=$(( (start_t1 - start_t0) / 1000 )) ;;
|
|
1415
|
+
esac
|
|
1416
|
+
fi
|
|
1417
|
+
|
|
1418
|
+
# THE COUNT IS THE RULE'S, and the rule is `packages/domain/src/rules/
|
|
1419
|
+
# fleet-size.ts` — imported directly, the same shape `plot-reap.sh` uses for
|
|
1420
|
+
# `reapable.ts`. Node 24 strips the types, so there is no build step between
|
|
1421
|
+
# this script and the decision it asks for, and there is no second copy of the
|
|
1422
|
+
# default, the subtraction or the machine's veto living in shell.
|
|
1423
|
+
#
|
|
1424
|
+
# A RULE THAT CANNOT BE ASKED STARTS NOTHING AND SAYS SO. Missing node, a
|
|
1425
|
+
# failed import, a module that throws all leave the answer empty. The
|
|
1426
|
+
# direction is the reaper's: silence is never permission, and here permission
|
|
1427
|
+
# would spawn detached processes.
|
|
1428
|
+
#
|
|
1429
|
+
# TWO MODULES, BECAUSE THE VERDICT AND THE COUNT ARE TWO RULES. `headroomFor`
|
|
1430
|
+
# owns what a fork cost MEANS and `fleetSize` owns what to do about it; the
|
|
1431
|
+
# count rule takes the verdict as a reading rather than deriving it, so the
|
|
1432
|
+
# thresholds have exactly one home and this block is the join.
|
|
1433
|
+
#
|
|
1434
|
+
# IMPORTED AS `.ts` WITH NO `.js` REWRITING. Node 24 strips types but does not
|
|
1435
|
+
# remap a relative specifier, so `fleet-size.ts` may only `import type` from
|
|
1436
|
+
# its neighbours — which is why the verdict arrives as a value here rather
|
|
1437
|
+
# than being computed inside the rule.
|
|
1438
|
+
start_domain="$(cd "$script_dir/../../.." 2>/dev/null && pwd)/packages/domain/src"
|
|
1439
|
+
start_rule="file://$start_domain/rules/fleet-size.ts"
|
|
1440
|
+
start_answer=$(PLOT_REQUESTED="$start_count" PLOT_RUNNING="$start_running" \
|
|
1441
|
+
PLOT_COST="$start_cost" PLOT_RULE="$start_rule" \
|
|
1442
|
+
PLOT_MACHINE="file://$start_domain/entities/machine.ts" \
|
|
1443
|
+
node --input-type=module - <<'NODE_EOF' 2>/dev/null
|
|
1444
|
+
const { fleetSize, DEFAULT_FLEET_SIZE } = await import(process.env.PLOT_RULE);
|
|
1445
|
+
const { headroomFor } = await import(process.env.PLOT_MACHINE);
|
|
1446
|
+
|
|
1447
|
+
// AN ABSENT COUNT IS THE RULE'S DEFAULT, resolved here rather than in the
|
|
1448
|
+
// shell: the number and the argument for it have one home.
|
|
1449
|
+
const requested =
|
|
1450
|
+
process.env.PLOT_REQUESTED === "" ? DEFAULT_FLEET_SIZE : Number(process.env.PLOT_REQUESTED);
|
|
1451
|
+
|
|
1452
|
+
// An UNMEASURED cost is null, never zero: zero is the fastest fork there is and
|
|
1453
|
+
// would read as the clearest possible machine.
|
|
1454
|
+
const spawnCostMs = process.env.PLOT_COST === "" ? null : Number(process.env.PLOT_COST);
|
|
1455
|
+
|
|
1456
|
+
const answer = fleetSize({
|
|
1457
|
+
requested,
|
|
1458
|
+
running: Number(process.env.PLOT_RUNNING),
|
|
1459
|
+
spawnCostMs,
|
|
1460
|
+
headroom: headroomFor(spawnCostMs),
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
process.stdout.write(`${answer.start}\t${answer.headroom}\t${answer.shortfall}`);
|
|
1464
|
+
NODE_EOF
|
|
1465
|
+
)
|
|
1466
|
+
|
|
1467
|
+
if [ -z "$start_answer" ]; then
|
|
1468
|
+
echo "plot-dispatch: --start could not ask how many agents to start — starting none." >&2
|
|
1469
|
+
echo " The rule is $start_rule" >&2
|
|
1470
|
+
echo " It needs node 24 and a readable checkout of packages/domain." >&2
|
|
1471
|
+
exit 1
|
|
1472
|
+
fi
|
|
1473
|
+
|
|
1474
|
+
start_n=${start_answer%%$'\t'*}
|
|
1475
|
+
start_rest=${start_answer#*$'\t'}
|
|
1476
|
+
start_headroom=${start_rest%%$'\t'*}
|
|
1477
|
+
start_why=${start_rest#*$'\t'}
|
|
1478
|
+
|
|
1479
|
+
echo "starting $start_n agent(s) — machine $start_headroom, $start_running already running"
|
|
1480
|
+
|
|
1481
|
+
# THE WORKER COMMAND IS ASKED ONCE, BEFORE THE LOOP. `start_worker` prints its
|
|
1482
|
+
# own "start it yourself" line per agent when the key is absent, and N
|
|
1483
|
+
# identical copies of it is a wall rather than a message. Asked here, the
|
|
1484
|
+
# refusal is one sentence naming what to configure.
|
|
1485
|
+
#
|
|
1486
|
+
# THE THREE-WAY ANSWER IS THE FAN-OUT'S OWN `worker=` FIELD, in the footer for
|
|
1487
|
+
# the same reason it is there: `agents=0` with no reason beside it is what was
|
|
1488
|
+
# printed and missed five times on 2026-08-17. A caller reading only the
|
|
1489
|
+
# summary — which is now a performer as well as a person — must be able to
|
|
1490
|
+
# tell *the machine bounded it* from *nobody has configured how to start one*.
|
|
1491
|
+
worker_cmd_declined=0
|
|
1492
|
+
start_worker_state=configured
|
|
1493
|
+
case "$("$script_dir/plot-config.sh" get "Worker command" "")" in
|
|
1494
|
+
none|NONE|None) worker_cmd_declined=1; start_worker_state=declined ;;
|
|
1495
|
+
'') start_worker_state=unconfigured ;;
|
|
1496
|
+
esac
|
|
1497
|
+
if [ "$start_worker_state" != configured ]; then
|
|
1498
|
+
echo " no worker will start — 'Worker command' is $start_worker_state in this repo's Plot Config."
|
|
1499
|
+
echo " The desks below are cut and registered; start them by hand, or set the key."
|
|
1500
|
+
fi
|
|
1501
|
+
|
|
1502
|
+
# `slug` STAYS EMPTY, and the loop reads it. A free agent belongs to no plan
|
|
1503
|
+
# — the registry sends the slug WITH the assignment, which is the same reason
|
|
1504
|
+
# `wait_for_work` skips the outlook scan for an agent that holds none.
|
|
1505
|
+
slug=""
|
|
1506
|
+
|
|
1507
|
+
start_made=0
|
|
1508
|
+
start_i=0
|
|
1509
|
+
while [ "$start_i" -lt "$start_n" ]; do
|
|
1510
|
+
start_i=$((start_i + 1))
|
|
1511
|
+
|
|
1512
|
+
# THE DESK IS DETACHED AT `origin/<main>`, AND NEITHER HALF IS INCIDENTAL.
|
|
1513
|
+
#
|
|
1514
|
+
# A free agent still needs a desk: the loop reads `${PLOT_WORKTREE:-$PWD}`
|
|
1515
|
+
# throughout and the transcript directory is derived from that path. It has
|
|
1516
|
+
# no branch to cut one from, so the base is the only thing left — which is
|
|
1517
|
+
# what the loop's own hop already does. `reset_desk` step 1 checks out
|
|
1518
|
+
# `origin/$main_branch` DETACHED before attaching the slice's branch, so a
|
|
1519
|
+
# desk that starts detached at the base is where every reset passes through
|
|
1520
|
+
# anyway, and the first hand-over is a plain `checkout -b` from it.
|
|
1521
|
+
#
|
|
1522
|
+
# DETACHED RATHER THAN ON THE DEFAULT BRANCH, and that is the guard. A tree
|
|
1523
|
+
# sitting on the default branch is one of `plot-reap.sh`'s five refusals
|
|
1524
|
+
# (`on-default-branch`) — so it is never reaped, but it is also never
|
|
1525
|
+
# measured: the refusal exists because that tree's dispatched branch was
|
|
1526
|
+
# never checked out. A detached desk reads `branch: ''`, so the refusal that
|
|
1527
|
+
# keeps it is `no-merged-pr` — unlanded work, the honest reading of a desk
|
|
1528
|
+
# holding nothing yet — and it is kept for a reason that describes it.
|
|
1529
|
+
#
|
|
1530
|
+
# Git refuses to check out one branch in two worktrees, and the main
|
|
1531
|
+
# checkout usually holds the default branch, so an attached desk could not
|
|
1532
|
+
# be cut here at all.
|
|
1533
|
+
# THE DESK PATH IS THE SCRIPT'S UNLESS A CALLER NAMED ONE.
|
|
1534
|
+
#
|
|
1535
|
+
# `PLOT_START_DESK` is how the supervisor's performer passes the desk the
|
|
1536
|
+
# DECISION named. It is an environment variable rather than a flag because
|
|
1537
|
+
# it takes exactly one path and is set by exactly one caller: a flag would
|
|
1538
|
+
# put a machine-only interface in the help text an operator reads, beside
|
|
1539
|
+
# the count they actually type. A person typing `--start 3` never names
|
|
1540
|
+
# three paths.
|
|
1541
|
+
#
|
|
1542
|
+
# It applies to ONE desk, so a run that was handed it starts one agent —
|
|
1543
|
+
# `fleetSize` is what decides the count and the performer applies its writes
|
|
1544
|
+
# one at a time, which is what keeps the decision and the writes in step.
|
|
1545
|
+
if [ -n "${PLOT_START_DESK:-}" ]; then
|
|
1546
|
+
start_wt="$PLOT_START_DESK"
|
|
1547
|
+
else
|
|
1548
|
+
start_wt="$wt_root/${wt_prefix}free-$(plot_session_id | cut -c1-8)"
|
|
1549
|
+
fi
|
|
1550
|
+
if [ -e "$start_wt" ]; then
|
|
1551
|
+
echo " skipped $start_wt — a desk of that name already exists"
|
|
1552
|
+
continue
|
|
1553
|
+
fi
|
|
1554
|
+
if [ "$dry_run" = 1 ]; then
|
|
1555
|
+
echo " would create $start_wt (detached at origin/$start_main) and start a free agent"
|
|
1556
|
+
start_made=$((start_made + 1))
|
|
1557
|
+
continue
|
|
1558
|
+
fi
|
|
1559
|
+
mkdir -p "$wt_root" 2>/dev/null || true
|
|
1560
|
+
if ! git worktree add -q --detach "$start_wt" "origin/$start_main" 2>/dev/null; then
|
|
1561
|
+
# NO REMOTE REF IS NOT A FAILURE OF THIS VERB. A fresh clone or a repo
|
|
1562
|
+
# with no remote has no `origin/<main>`; the local one is the same commit
|
|
1563
|
+
# in every case that matters, and a desk on it is still detached.
|
|
1564
|
+
if ! git worktree add -q --detach "$start_wt" "$start_main" 2>/dev/null; then
|
|
1565
|
+
echo " could not create a desk at $start_wt — skipping" >&2
|
|
1566
|
+
continue
|
|
1567
|
+
fi
|
|
1568
|
+
fi
|
|
1569
|
+
|
|
1570
|
+
# SPOTLIGHT IS TOLD NOT TO INDEX THE DESK, exactly as the loop's hop does.
|
|
1571
|
+
# A desk is a full checkout and the fleet makes and unmakes them all day;
|
|
1572
|
+
# the marker is ignored via `info/exclude` rather than `.gitignore`, because
|
|
1573
|
+
# an untracked file in a desk reads as unlanded work to
|
|
1574
|
+
# `plot-worker-state.sh` and would make every free agent look stalled.
|
|
1575
|
+
_excl="$(git -C "$start_wt" rev-parse --git-common-dir 2>/dev/null)/info/exclude"
|
|
1576
|
+
if [ -f "$_excl" ] && ! grep -qxF '.metadata_never_index' "$_excl" 2>/dev/null; then
|
|
1577
|
+
printf '%s\n' '.metadata_never_index' >> "$_excl" 2>/dev/null || true
|
|
1578
|
+
fi
|
|
1579
|
+
: > "$start_wt/.metadata_never_index" 2>/dev/null || true
|
|
1580
|
+
|
|
1581
|
+
echo " desk $start_wt (detached at origin/$start_main)"
|
|
1582
|
+
# THE EMPTY BRANCH IS THE WHOLE POINT, and `start_worker` already takes it
|
|
1583
|
+
# as a parameter: `write_agent_manifest` writes `"branch": ""`, the loop
|
|
1584
|
+
# reads `PLOT_BRANCH` empty and enters its wait rather than its prompt, and
|
|
1585
|
+
# `isAgentFree` reports the agent free with no change to `rules/free.ts`.
|
|
1586
|
+
if start_worker "" "$start_wt"; then
|
|
1587
|
+
start_made=$((start_made + 1))
|
|
1588
|
+
fi
|
|
1589
|
+
done
|
|
1590
|
+
|
|
1591
|
+
# THE SHORTFALL IS SAID HERE AND STORED NOWHERE. An operator reads it and runs
|
|
1592
|
+
# the command again; a remembered target would be the first piece of state in
|
|
1593
|
+
# a fleet whose statelessness is measured rather than assumed.
|
|
1594
|
+
[ -n "$start_why" ] && echo " $start_why"
|
|
1595
|
+
echo "summary: agents=$start_made requested=${start_count:-default} running=$start_running headroom=$start_headroom worker=$start_worker_state"
|
|
1596
|
+
exit 0
|
|
1597
|
+
fi
|
|
1598
|
+
|
|
829
1599
|
# ---------------------------------------------------------------------------
|
|
830
1600
|
# Migration mode: move legacy worktrees into the configured root
|
|
831
1601
|
# ---------------------------------------------------------------------------
|
|
@@ -899,63 +1669,69 @@ if [ "$mode" = "migrate" ]; then
|
|
|
899
1669
|
continue
|
|
900
1670
|
fi
|
|
901
1671
|
|
|
902
|
-
#
|
|
903
|
-
#
|
|
904
|
-
#
|
|
905
|
-
#
|
|
906
|
-
#
|
|
907
|
-
#
|
|
908
|
-
#
|
|
909
|
-
#
|
|
910
|
-
#
|
|
911
|
-
|
|
912
|
-
#
|
|
913
|
-
#
|
|
914
|
-
#
|
|
915
|
-
#
|
|
916
|
-
#
|
|
917
|
-
#
|
|
1672
|
+
# FOUR READINGS, GATHERED HERE AND DECIDED ELSEWHERE. This block holds no
|
|
1673
|
+
# `if` about whether a worktree may move; it collects what was measured and
|
|
1674
|
+
# `plot-movable.mjs` returns the refusal. The four were shell `if`s until
|
|
1675
|
+
# 2026-09-01, and nothing could trigger one in isolation — least of all the
|
|
1676
|
+
# combinations this estate will not produce on demand, a live pid and a
|
|
1677
|
+
# dirty tree at once.
|
|
1678
|
+
#
|
|
1679
|
+
# LIVENESS AND UNLANDED WORK STAY TWO SEPARATE MEASUREMENTS, exactly as they
|
|
1680
|
+
# were: plot_worker_state answers "is a process running or waiting here" and
|
|
1681
|
+
# is keyed on the records a dispatch writes (`.plot-worker.pid`,
|
|
1682
|
+
# `.plot-worker.exit`). A hand-made worktree that never ran one reads `none`
|
|
1683
|
+
# however dirty its tree is — and hand-made worktrees are precisely the
|
|
1684
|
+
# estate this mode exists to tidy. The rule reads them as two fields for
|
|
1685
|
+
# that reason.
|
|
1686
|
+
#
|
|
1687
|
+
# plot_worker_state is the ONE liveness answer, sourced by both this script
|
|
1688
|
+
# and the fleet scan. It carries what a bare `ps` cannot — pid-reuse
|
|
1689
|
+
# detection via the manifest's `startedAt`, and the `waiting` state a
|
|
1690
|
+
# PLOT-BLOCKED* marker produces.
|
|
918
1691
|
wstate_row=$(plot_worker_state "$wt")
|
|
919
1692
|
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
|
|
1693
|
+
pid=$(printf '%s' "$wstate_row" | cut -f2)
|
|
934
1694
|
|
|
935
|
-
# REFUSAL 3 — UNCOMMITTED WORK, measured independently of any worker record.
|
|
936
1695
|
# `plot_worker_dirty` applies the shared filter (editor leftovers and Plot's
|
|
937
|
-
# own bookkeeping do not count), so this
|
|
1696
|
+
# own bookkeeping do not count), so this reads real work only.
|
|
938
1697
|
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
1698
|
|
|
945
|
-
#
|
|
946
|
-
#
|
|
947
|
-
#
|
|
948
|
-
#
|
|
949
|
-
#
|
|
950
|
-
|
|
1699
|
+
# Only the branch's OWN upstream answers "pushed?". An absent upstream
|
|
1700
|
+
# leaves the field EMPTY, which the rule reads as unanswerable rather than
|
|
1701
|
+
# as zero — and an unanswered question is not a refusal, the principle
|
|
1702
|
+
# plot_worker_task_state reached the hard way when counting against
|
|
1703
|
+
# origin/main marked every clean branch stalled in a remote-less repo.
|
|
1704
|
+
ahead=""
|
|
951
1705
|
if [ -n "$br" ]; then
|
|
952
1706
|
ahead=$(git -C "$wt" rev-list --count '@{upstream}..HEAD' 2>/dev/null || echo "")
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1707
|
+
fi
|
|
1708
|
+
|
|
1709
|
+
# THE DECISION. One call per tree, and the answer is a named refusal.
|
|
1710
|
+
#
|
|
1711
|
+
# A rule that cannot be asked REFUSES: a missing `node`, a missing bundle or
|
|
1712
|
+
# a throwing module all leave `mv_verdict` empty, and an empty verdict keeps
|
|
1713
|
+
# the worktree and says the rule could not be asked. Silence is never
|
|
1714
|
+
# permission — and here the permissive direction moves a checkout an agent
|
|
1715
|
+
# may be writing to, which `git worktree move` breaks mid-run.
|
|
1716
|
+
mv_verdict=$(printf '%s\t%s\t%s\t%s' "$state" "$pid" "$dirty" "$ahead" \
|
|
1717
|
+
| node "$script_dir/board/plot-movable.mjs" 2>/dev/null || true)
|
|
1718
|
+
mv_refusal=${mv_verdict%%$'\t'*}
|
|
1719
|
+
mv_detail=${mv_verdict#*$'\t'}
|
|
1720
|
+
|
|
1721
|
+
# RENDERING, not deciding. The rule named the measurement; this names what
|
|
1722
|
+
# it means to someone reading the table, which is the caller's half because
|
|
1723
|
+
# only the caller knows it is printing one.
|
|
1724
|
+
if [ "$mv_refusal" != "move" ]; then
|
|
1725
|
+
case "$mv_refusal" in
|
|
1726
|
+
live-worker) reason="worker alive (pid $mv_detail)" ;;
|
|
1727
|
+
blocked-marker) reason="blocked marker — needs a person" ;;
|
|
1728
|
+
uncommitted-changes) reason="uncommitted: $mv_detail" ;;
|
|
1729
|
+
unpushed-commits) reason="unpushed commits ($mv_detail ahead)" ;;
|
|
1730
|
+
*) reason="rule could not be asked — keeping" ;;
|
|
958
1731
|
esac
|
|
1732
|
+
printf '%-8s %-52s %s\n' "keep" "$(basename "$wt")" "$reason"
|
|
1733
|
+
n_skipped=$((n_skipped + 1))
|
|
1734
|
+
continue
|
|
959
1735
|
fi
|
|
960
1736
|
|
|
961
1737
|
# This worktree is idle — it can be moved.
|
|
@@ -1032,8 +1808,7 @@ fi
|
|
|
1032
1808
|
# exactly where nothing can catch it. --allow-local is the explicit escape, and
|
|
1033
1809
|
# it is named in the refusal so an operator learns it exists when they need it.
|
|
1034
1810
|
MAIN=$(bash "$script_dir/plot-config.sh" get "Main branch")
|
|
1035
|
-
[ -n "$MAIN" ] || MAIN=$(
|
|
1036
|
-
[ -n "$MAIN" ] || MAIN="main"
|
|
1811
|
+
[ -n "$MAIN" ] || MAIN=$(default_branch)
|
|
1037
1812
|
[ -n "$offline" ] || git fetch -q origin "$MAIN" 2>/dev/null
|
|
1038
1813
|
|
|
1039
1814
|
PLAN_DIR_CFG=$("$script_dir/plot-config.sh" get "Plan directory" "docs/plans/")
|
|
@@ -1205,6 +1980,194 @@ esac
|
|
|
1205
1980
|
# MAIN was resolved and origin fetched above, before the phase gate — the gate
|
|
1206
1981
|
# needs the shared ref to read the plan from it.
|
|
1207
1982
|
|
|
1983
|
+
# ---------------------------------------------------------------------------
|
|
1984
|
+
# THE PREREQUISITE GATE: a branch that waits on another plan's branch
|
|
1985
|
+
# ---------------------------------------------------------------------------
|
|
1986
|
+
#
|
|
1987
|
+
# A plan may annotate one of its branches `<!-- waits: <branch> -->`, naming ONE
|
|
1988
|
+
# branch — usually of another plan — that must merge before this one may start.
|
|
1989
|
+
# The parser exposes it as `waves[].branches[].waits_on`; the scan turns it into
|
|
1990
|
+
# the branch states `waiting` and `blocked`, so `--next` already stops OFFERING
|
|
1991
|
+
# such a branch.
|
|
1992
|
+
#
|
|
1993
|
+
# THAT IS ONE HALF, AND THIS IS THE OTHER. An empty offer has nothing to say
|
|
1994
|
+
# about what it filtered out: `plot-dispatch.sh <slug>` answered `dispatched=0`
|
|
1995
|
+
# with no reason attached, which is the same silence `--restart` was built to
|
|
1996
|
+
# break. So this names the branch AND the prerequisite, and it names them in
|
|
1997
|
+
# `--dry-run` identically to a real run.
|
|
1998
|
+
#
|
|
1999
|
+
# IT HAS COST TWO WORKERS. Measured 2026-09-02:
|
|
2000
|
+
# `feature/the-domain-forgets-the-vendor-list` was re-dispatched at 04:50 into a
|
|
2001
|
+
# prerequisite that had not merged, hit its own gate, and wrote a PLOT-BLOCKED
|
|
2002
|
+
# marker. Its report names the cause: *"plot-dispatch.sh gates on the plan's
|
|
2003
|
+
# phase, and this plan is Approved, so the slice read as eligible."*
|
|
2004
|
+
#
|
|
2005
|
+
# THE ANNOTATION IS READ FROM THE SHARED REF, out of the same `gate_meta` the
|
|
2006
|
+
# phase gate parsed. A `waits:` that exists only in this working tree is an
|
|
2007
|
+
# ordering constraint nobody else can see, exactly as a local approval is.
|
|
2008
|
+
#
|
|
2009
|
+
# THE PREREQUISITE IS ASKED OF THE HOST, NEVER OF THE REFS.
|
|
2010
|
+
# `plot-release-refs.sh` deletes the remote refs of a delivered plan's merged
|
|
2011
|
+
# branches, so a prerequisite that SUCCEEDED and was then reaped has no ref —
|
|
2012
|
+
# and a rule reading refs would hold its dependent forever BECAUSE its
|
|
2013
|
+
# dependency succeeded. That is the worst available failure: correct work
|
|
2014
|
+
# producing a permanent block. `pr-state` answers about PULL REQUESTS, and a
|
|
2015
|
+
# merged PR outlives the branch it was cut from.
|
|
2016
|
+
#
|
|
2017
|
+
# `NONE` AND SILENCE ARE DIFFERENT ANSWERS. `NONE` means the host was asked and
|
|
2018
|
+
# has never seen a PR for that branch — a typo, which is `blocked`. A host that
|
|
2019
|
+
# could not be asked is neither permission nor proof of a typo, so it HOLDS the
|
|
2020
|
+
# branch at `waiting`. Both refuse; only one tells the operator to fix the plan.
|
|
2021
|
+
|
|
2022
|
+
# What the host says about the prerequisite's pull requests.
|
|
2023
|
+
#
|
|
2024
|
+
# Four answers, and the last two must never be collapsed — see the header
|
|
2025
|
+
# above. `--offline` promises no network, so it answers `unreachable`: the
|
|
2026
|
+
# question was not put, and a flag that lied would be worse than a slower
|
|
2027
|
+
# answer. The same reasoning `reached_review` applies one screen up.
|
|
2028
|
+
prereq_answer() { # $1=prerequisite branch → merged|unmerged|none|unreachable
|
|
2029
|
+
local js st
|
|
2030
|
+
[ -z "$offline" ] || { echo unreachable; return; }
|
|
2031
|
+
[ "$("$script_dir/plot-host.sh" backend 2>/dev/null)" != "none" ] || { echo unreachable; return; }
|
|
2032
|
+
# Exit code first: a non-zero is a transport failure and its stdout is not an
|
|
2033
|
+
# answer. GitHub returned 503 all afternoon on 2026-08-17, and a reader that
|
|
2034
|
+
# trusted the payload on failure would have started every waiting branch.
|
|
2035
|
+
js=$("$script_dir/plot-host.sh" pr-state "$1" </dev/null 2>/dev/null) || { echo unreachable; return; }
|
|
2036
|
+
st=$(printf '%s' "$js" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
|
|
2037
|
+
case "$st" in
|
|
2038
|
+
MERGED) echo merged ;;
|
|
2039
|
+
NONE) echo none ;;
|
|
2040
|
+
# OPEN and CLOSED both mean the host has SEEN the branch. A closed, unmerged
|
|
2041
|
+
# PR is `unmerged` rather than `none`: nothing is misspelled — somebody
|
|
2042
|
+
# withdrew the work, and that resolves by reopening it, not by editing the
|
|
2043
|
+
# plan.
|
|
2044
|
+
OPEN|CLOSED) echo unmerged ;;
|
|
2045
|
+
# A state word this adapter does not emit, or none at all. Unread is not
|
|
2046
|
+
# answered, and this gate's silence holds rather than permits.
|
|
2047
|
+
*) echo unreachable ;;
|
|
2048
|
+
esac
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
# Every branch the plan annotates `waits:`, with what it waits on — read from
|
|
2052
|
+
# the same blob, in the plan's own order, one line of `branch<TAB>prerequisite`.
|
|
2053
|
+
#
|
|
2054
|
+
# NON-DEFERRED ONLY. `deferred:` is a JUDGEMENT — somebody gave the branch up —
|
|
2055
|
+
# and it outranks a wait for the same reason the scan lets it: a branch nobody
|
|
2056
|
+
# will start does not need to be told what it is waiting for. The two
|
|
2057
|
+
# annotations sit on one line and neither reads the other's value.
|
|
2058
|
+
waits_pairs() { # → branch<TAB>prerequisite, one per annotated branch
|
|
2059
|
+
printf '%s' "$gate_meta" | awk '
|
|
2060
|
+
{
|
|
2061
|
+
n = split($0, parts, /\{"branch":"/)
|
|
2062
|
+
for (i = 2; i <= n; i++) {
|
|
2063
|
+
rec = parts[i]
|
|
2064
|
+
br = rec; sub(/".*$/, "", br)
|
|
2065
|
+
if (rec ~ /"deferred":true/) continue
|
|
2066
|
+
if (match(rec, /"waits_on":"[^"]*"/)) {
|
|
2067
|
+
w = substr(rec, RSTART + 12, RLENGTH - 13)
|
|
2068
|
+
if (w != "") print br "\t" w
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
}'
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
# THE PREFLIGHT, run once before the fan-out, and it is where the REFUSAL lives.
|
|
2075
|
+
#
|
|
2076
|
+
# WHY IT CANNOT LIVE IN THE LOOP ALONE. The fan-out loop only ever sees what
|
|
2077
|
+
# `plot-fleet-scan.sh` offered, and the scan ALREADY reports a waiting branch as
|
|
2078
|
+
# `waiting` rather than `open` — so it is filtered out before this script hears
|
|
2079
|
+
# of it, and the run ends `dispatched=0 skipped=0` with nothing said about what
|
|
2080
|
+
# was withheld. That silence is the exact defect: an empty offer has nothing to
|
|
2081
|
+
# say about what it filtered out, and a worker was dispatched twice on 2026-09-02
|
|
2082
|
+
# by an operator reading it as "nothing to do here".
|
|
2083
|
+
#
|
|
2084
|
+
# So the plan is walked DIRECTLY. This script already holds the parsed plan from
|
|
2085
|
+
# the shared ref — the same blob its phase gate read — so naming what the fan-out
|
|
2086
|
+
# will not reach costs one host call per annotated branch, on a population of six
|
|
2087
|
+
# plans in 188.
|
|
2088
|
+
#
|
|
2089
|
+
# IT FILLS `waits_held`, WHICH THE LOOPS THEN CONSULT. Two mechanisms, one
|
|
2090
|
+
# decision: this states the refusal, and the loops refuse to write for a branch
|
|
2091
|
+
# it named — belt to that brace, because a scan that could not reach the host
|
|
2092
|
+
# still offers the branch as `open`.
|
|
2093
|
+
declare -a waits_held=()
|
|
2094
|
+
is_waits_held() {
|
|
2095
|
+
local x
|
|
2096
|
+
for x in ${waits_held[@]+"${waits_held[@]}"}; do [ "$x" = "$1" ] && return 0; done
|
|
2097
|
+
return 1
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
# AND IT FILLS `waits_freed`, WHICH IS WHERE `--allow-waiting` GETS ITS
|
|
2101
|
+
# CANDIDATE FROM.
|
|
2102
|
+
#
|
|
2103
|
+
# The override cannot work by relaxing a test in this script, because the branch
|
|
2104
|
+
# never reaches a test here: `plot-fleet-scan.sh` reports a waiting branch as
|
|
2105
|
+
# `waiting` rather than `open`, so `--list-eligible` and `--next` both withhold
|
|
2106
|
+
# it and the loops are handed an empty set. Measured 2026-09-02 — the flag
|
|
2107
|
+
# printed its override line and the run still reported `dispatched=0 skipped=0`,
|
|
2108
|
+
# counting the branch neither way.
|
|
2109
|
+
#
|
|
2110
|
+
# So the flag ADDS a candidate rather than removing a filter. The preflight
|
|
2111
|
+
# already walked the plan from the shared ref and asked the host about the
|
|
2112
|
+
# prerequisite, so it holds the one fact the scan withheld, and naming it here
|
|
2113
|
+
# costs no further call.
|
|
2114
|
+
#
|
|
2115
|
+
# ONLY A BRANCH THE PREFLIGHT ITSELF HELD, and only under the flag. This adds
|
|
2116
|
+
# nothing the scan refused for any OTHER reason — a claimed branch, a `wip` one,
|
|
2117
|
+
# an incomplete prior wave — because those verdicts are not this flag's to
|
|
2118
|
+
# override and the scan remains the only thing that decides them. The branch
|
|
2119
|
+
# still passes every gate the loops apply after it: `held_worktree`, the claim
|
|
2120
|
+
# race, and the brief.
|
|
2121
|
+
declare -a waits_freed=()
|
|
2122
|
+
is_waits_freed() {
|
|
2123
|
+
local x
|
|
2124
|
+
for x in ${waits_freed[@]+"${waits_freed[@]}"}; do [ "$x" = "$1" ] && return 0; done
|
|
2125
|
+
return 1
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
# Runs the preflight: prints its refusals, fills `waits_held`, and adds what it
|
|
2129
|
+
# withheld to `n_skipped`.
|
|
2130
|
+
#
|
|
2131
|
+
# NOT A COMMAND SUBSTITUTION, and that is not a style choice. `$( … )` is a
|
|
2132
|
+
# SUBSHELL, so an array filled inside one is discarded on return — the loops
|
|
2133
|
+
# below would consult an empty `waits_held` and the refusal would be a message
|
|
2134
|
+
# with no effect behind it. So this writes its two results into globals and the
|
|
2135
|
+
# caller invokes it plainly.
|
|
2136
|
+
#
|
|
2137
|
+
# PRINTED IDENTICALLY BY --dry-run AND THE REAL RUN, the discipline `report_held`
|
|
2138
|
+
# already sets: a dry run that offers what a real run would refuse is worse than
|
|
2139
|
+
# no dry run — it is the same wrong answer with a reassurance attached.
|
|
2140
|
+
run_waits_preflight() { # → prints refusals; fills waits_held, adds to n_skipped
|
|
2141
|
+
local br prereq answer held
|
|
2142
|
+
while IFS=$'\t' read -r br prereq; do
|
|
2143
|
+
[ -n "$br" ] || continue
|
|
2144
|
+
answer=$(prereq_answer "$prereq")
|
|
2145
|
+
case "$answer" in
|
|
2146
|
+
merged) continue ;;
|
|
2147
|
+
none) held=blocked ;;
|
|
2148
|
+
*) held=waiting ;;
|
|
2149
|
+
esac
|
|
2150
|
+
# `--allow-waiting` SAYS SO ON THE LINE IT OVERRIDES. An override nobody can
|
|
2151
|
+
# see in the output is an override nobody can audit.
|
|
2152
|
+
if [ "$allow_waiting" = 1 ]; then
|
|
2153
|
+
echo "$br waits on $prereq ($held) — dispatching anyway (--allow-waiting)"
|
|
2154
|
+
waits_freed+=("$br")
|
|
2155
|
+
continue
|
|
2156
|
+
fi
|
|
2157
|
+
waits_held+=("$br")
|
|
2158
|
+
n_skipped=$((n_skipped + 1))
|
|
2159
|
+
if [ "$held" = "blocked" ]; then
|
|
2160
|
+
echo "skipped $br (blocked — no PR found for $prereq)"
|
|
2161
|
+
echo " the plan says this branch waits on $prereq, and the host has never"
|
|
2162
|
+
echo " seen a pull request for it. Check the branch name in the plan."
|
|
2163
|
+
else
|
|
2164
|
+
echo "skipped $br (waiting on $prereq)"
|
|
2165
|
+
echo " the plan says this branch waits on $prereq, which has not merged."
|
|
2166
|
+
echo " Dispatch it when that lands, or pass --allow-waiting to start anyway."
|
|
2167
|
+
fi
|
|
2168
|
+
done < <(waits_pairs)
|
|
2169
|
+
}
|
|
2170
|
+
|
|
1208
2171
|
# Where the worktrees live and what their names carry — see resolve_wt_root.
|
|
1209
2172
|
# The default is beside the repo with the `plot-wt-` prefix; a `Worktree root:`
|
|
1210
2173
|
# key relocates them (and drops the prefix, which was only earning its keep
|
|
@@ -1215,6 +2178,7 @@ repo_root=$(git rev-parse --show-toplevel)
|
|
|
1215
2178
|
resolve_wt_root "$repo_root"
|
|
1216
2179
|
|
|
1217
2180
|
n_dispatched=0 n_reused=0 n_skipped=0 n_started=0
|
|
2181
|
+
n_brief_asked=0
|
|
1218
2182
|
|
|
1219
2183
|
# Whether this run COULD have started anything, read once and up front.
|
|
1220
2184
|
#
|
|
@@ -1249,31 +2213,20 @@ worker_state_field() {
|
|
|
1249
2213
|
fi
|
|
1250
2214
|
}
|
|
1251
2215
|
|
|
1252
|
-
# The summary
|
|
2216
|
+
# The summary: the machine-countable footer, and nothing above it.
|
|
1253
2217
|
#
|
|
1254
|
-
#
|
|
1255
|
-
#
|
|
1256
|
-
#
|
|
1257
|
-
#
|
|
1258
|
-
# was
|
|
2218
|
+
# THE PROSE LINE IS GONE, BY ITS OWN RULE. It existed to explain a zero that
|
|
2219
|
+
# had a cause worth naming — a `Worker command` nobody had configured, read off
|
|
2220
|
+
# a run that had prepared desks and staffed none. Dispatch starts no worker at
|
|
2221
|
+
# all now, so `started=0` is structural: the line would print on every run,
|
|
2222
|
+
# always true and never informative. `--dry-run` was held to exactly this rule
|
|
2223
|
+
# from the start — *"a dry run starts nothing BY CONSTRUCTION, so it explains
|
|
2224
|
+
# nothing"* — and the fan-out has become the same case.
|
|
2225
|
+
#
|
|
2226
|
+
# `worker=` still travels in the footer. It says how this repo is configured,
|
|
2227
|
+
# which remains a fact about the repo even where it no longer explains a count.
|
|
1259
2228
|
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"
|
|
2229
|
+
echo "summary: dispatched=$1 reused=$2 skipped=$3 started=$4 brief=missing worker=$(worker_state_field) brief_asked=${n_brief_asked:-0}"
|
|
1277
2230
|
}
|
|
1278
2231
|
|
|
1279
2232
|
# ---------------------------------------------------------------------------
|
|
@@ -1345,7 +2298,7 @@ update_parallel_agents_cap() { # $1 = new cap
|
|
|
1345
2298
|
if [ -f "$FLEET_CONTROLS_FILE" ]; then
|
|
1346
2299
|
# Preserve the existing autoDispatch setting
|
|
1347
2300
|
local existing
|
|
1348
|
-
existing=$(sed -
|
|
2301
|
+
existing=$(sed -nE 's/.*"autoDispatch"[[:space:]]*:[[:space:]]*(true|false).*/\1/p' "$FLEET_CONTROLS_FILE" | head -1)
|
|
1349
2302
|
[ -n "$existing" ] && auto_dispatch="$existing"
|
|
1350
2303
|
fi
|
|
1351
2304
|
|
|
@@ -1470,14 +2423,39 @@ write_started_record() { # $@ = branches
|
|
|
1470
2423
|
fi
|
|
1471
2424
|
|
|
1472
2425
|
if [ -f "$tmpwt/$rel" ]; then
|
|
1473
|
-
local br
|
|
2426
|
+
local br wrote=0
|
|
1474
2427
|
for br in "$@"; do
|
|
2428
|
+
# A BRANCH ALREADY RECORDED IS NOT RECORDED AGAIN, and this check is what
|
|
2429
|
+
# the CLAIM used to do. Dispatch pushed a claim, a claimed branch was
|
|
2430
|
+
# never offered again, and a second run therefore booked nothing — the
|
|
2431
|
+
# idempotence was a side effect of the lock rather than a property of the
|
|
2432
|
+
# record. The claim went with the fan-out's writes, so the record owns its
|
|
2433
|
+
# own idempotence now: a plan dispatched three times must not read as
|
|
2434
|
+
# started three times, or the count drifts from the refs it describes.
|
|
2435
|
+
#
|
|
2436
|
+
# MATCHED ON THE BRANCH IN BACKTICKS, the shape `append_started_line`
|
|
2437
|
+
# writes and `plot-plan-meta.sh` parses. A bare substring match would let
|
|
2438
|
+
# `feature/api` find itself inside `feature/api-v2`.
|
|
2439
|
+
if grep -qF -- "\`$br\`" "$tmpwt/$rel" 2>/dev/null \
|
|
2440
|
+
&& grep -q -- "Started:.*\`$br\`" "$tmpwt/$rel" 2>/dev/null; then
|
|
2441
|
+
continue
|
|
2442
|
+
fi
|
|
1475
2443
|
append_started_line "$tmpwt/$rel" "$date" "$who" "$br" || {
|
|
1476
2444
|
echo "plot-dispatch: $rel has no '## Status' section — nowhere to record" >&2
|
|
1477
2445
|
rc=1
|
|
1478
2446
|
break
|
|
1479
2447
|
}
|
|
2448
|
+
wrote=1
|
|
1480
2449
|
done
|
|
2450
|
+
# NOTHING NEW TO SAY IS NOT A FAILURE. Every branch this run handed over was
|
|
2451
|
+
# already on the record, so there is no commit to make and no push to
|
|
2452
|
+
# attempt — and a run that pushed an empty commit would leave one per
|
|
2453
|
+
# re-dispatch on the default branch.
|
|
2454
|
+
if [ "$rc" = 0 ] && [ "$wrote" = 0 ]; then
|
|
2455
|
+
git worktree remove --force "$tmpwt" 2>/dev/null || true
|
|
2456
|
+
git branch -D "$bookbr" >/dev/null 2>&1 || true
|
|
2457
|
+
return 0
|
|
2458
|
+
fi
|
|
1481
2459
|
if [ "$rc" = 0 ]; then
|
|
1482
2460
|
git -C "$tmpwt" add -- "$rel" 2>/dev/null
|
|
1483
2461
|
git -C "$tmpwt" -c "user.name=$who" commit -q \
|
|
@@ -1538,6 +2516,23 @@ real_plan_path() { # $1=plan file as found (possibly a symlink, possibly relativ
|
|
|
1538
2516
|
append_started_line() { # $1=file $2=date $3=who $4=branch
|
|
1539
2517
|
local f="$1" line
|
|
1540
2518
|
line="- **Started:** $2, $3, \`$4\`"
|
|
2519
|
+
|
|
2520
|
+
# ALREADY RECORDED IS NOT AN ERROR — it is a second dispatch of the same
|
|
2521
|
+
# slice, and this returns 0 having written nothing.
|
|
2522
|
+
#
|
|
2523
|
+
# THE CLAIM USED TO BE THIS GUARD. A dispatched branch was claimed by a ref
|
|
2524
|
+
# push, so `--next` never offered it twice and a re-run booked nothing. The
|
|
2525
|
+
# hand-over pushes no claim, so the branch stays `open` and every re-run
|
|
2526
|
+
# reaches this line: measured 2026-09-04, two runs left two identical
|
|
2527
|
+
# `Started:` records in one plan.
|
|
2528
|
+
#
|
|
2529
|
+
# The DATE is deliberately not matched. A slice handed over again tomorrow is
|
|
2530
|
+
# the same start, and a per-day record would drift from the refs it describes
|
|
2531
|
+
# exactly as a per-run one does.
|
|
2532
|
+
if grep -qF -- "\`$4\`" "$f" 2>/dev/null \
|
|
2533
|
+
&& grep -q -- "^[ \t]*[-*][ \t]*\*\*Started:\*\*.*\`$4\`" "$f" 2>/dev/null; then
|
|
2534
|
+
return 0
|
|
2535
|
+
fi
|
|
1541
2536
|
awk -v line="$line" '
|
|
1542
2537
|
{ lines[++n] = $0 }
|
|
1543
2538
|
END {
|
|
@@ -1772,7 +2767,38 @@ held_worktree() { # $1=branch → prints the worktree path when held, else nothi
|
|
|
1772
2767
|
# in-flight report a few lines up.
|
|
1773
2768
|
[ -z "$(uncommitted_files "$wt")" ] || { printf '%s' "$wt"; return 0; }
|
|
1774
2769
|
|
|
1775
|
-
#
|
|
2770
|
+
# DID ITS WORK LAND? THE HOST ANSWERS, NOT ANCESTRY.
|
|
2771
|
+
#
|
|
2772
|
+
# This asked `git merge-base --is-ancestor "$br" "origin/$MAIN"` until
|
|
2773
|
+
# 2026-09-04. Measured that day on this estate: ten merged branches still
|
|
2774
|
+
# carried a remote ref and ancestry disagreed with the host on TEN OF TEN.
|
|
2775
|
+
# Squash-merge is not occasionally wrong here — the squashed commit is not the
|
|
2776
|
+
# branch's commit, so the branch stays ahead of main forever and ancestry
|
|
2777
|
+
# answers "not landed" about every squash-merged branch there is.
|
|
2778
|
+
#
|
|
2779
|
+
# The failure direction is throughput, not safety: ancestry called a landed
|
|
2780
|
+
# leftover HELD, so dispatch refused a branch that was free. That is the
|
|
2781
|
+
# cheap half of the plan's measurement and it is still a refusal an operator
|
|
2782
|
+
# has to route around.
|
|
2783
|
+
#
|
|
2784
|
+
# `pr_merged` is the ONE answer, sourced rather than re-derived — the same
|
|
2785
|
+
# gate `plot-reap.sh` and `plot-release-refs.sh` read, for the reason that
|
|
2786
|
+
# file states: two implementations of one question drift, and one of them
|
|
2787
|
+
# drifts permissive.
|
|
2788
|
+
#
|
|
2789
|
+
# ANCESTRY REMAINS AS A SECOND CHANCE, and only toward "landed". A
|
|
2790
|
+
# fast-forward or rebase merge leaves the tip genuinely in main while the host
|
|
2791
|
+
# may hold no PR at all — a branch pushed straight to main, which this repo's
|
|
2792
|
+
# own fixtures do. It can only ever release a worktree the host already
|
|
2793
|
+
# declined to release, so it adds no way to refuse and no way to hide work.
|
|
2794
|
+
#
|
|
2795
|
+
# AN UNREACHABLE HOST ANSWERS "NOT MERGED", which keeps the worktree held.
|
|
2796
|
+
# That is `pr_merged`'s documented direction and the right one here too:
|
|
2797
|
+
# silence is never permission to hand somebody's desk to a second agent.
|
|
2798
|
+
# plot-ancestry: prefilter — second only to `pr_merged` above, and it can
|
|
2799
|
+
# only RELEASE a worktree the host already declined to release. It adds no
|
|
2800
|
+
# refusal, so a squash merge it misreads changes nothing.
|
|
2801
|
+
pr_merged "$br" && return 1
|
|
1776
2802
|
git merge-base --is-ancestor "$br" "origin/$MAIN" </dev/null 2>/dev/null && return 1
|
|
1777
2803
|
printf '%s' "$wt"
|
|
1778
2804
|
}
|
|
@@ -1839,6 +2865,37 @@ work_in_flight() { # $1=branch to exclude (the candidate)
|
|
|
1839
2865
|
IN_FLIGHT_MAX_FILES=6
|
|
1840
2866
|
IN_FLIGHT_MAX_BRANCHES=8
|
|
1841
2867
|
|
|
2868
|
+
# What a real run would attach, named per worktree — behind `--monitors`.
|
|
2869
|
+
#
|
|
2870
|
+
# SILENT UNLESS ASKED, which is what keeps the default `--dry-run` output
|
|
2871
|
+
# byte-identical to a run from before the monitors existed. That diff is this
|
|
2872
|
+
# slice's protection against the one failure that matters here: a mistake in
|
|
2873
|
+
# `start_worker` starts no workers at all, and the dry run exercises every
|
|
2874
|
+
# refusal against real worktrees and real pids without starting anything.
|
|
2875
|
+
#
|
|
2876
|
+
# IT NAMES THE SCRIPT PATH, not just the monitor. The question a reader has at
|
|
2877
|
+
# a dry run is *which code would run against my worktree* — a bare "2 monitors"
|
|
2878
|
+
# would send them into this script to find out, and a path they can `cat` is
|
|
2879
|
+
# the same courtesy the manifest refusal above pays by naming its directory.
|
|
2880
|
+
#
|
|
2881
|
+
# IT REPORTS ABSENCE TOO. A monitor script that is missing or non-executable
|
|
2882
|
+
# means an unmonitored worker, and the empty env var that produces is invisible
|
|
2883
|
+
# at launch by design (a detached `sh -c` nobody reads must not spew `command
|
|
2884
|
+
# not found`). The dry run is the one place that silence can be made audible
|
|
2885
|
+
# before it matters.
|
|
2886
|
+
report_monitors() { # $1=worktree
|
|
2887
|
+
[ "$show_monitors" = 1 ] || return 0
|
|
2888
|
+
local wt="$1" m
|
|
2889
|
+
for m in worker agent; do
|
|
2890
|
+
local script="$script_dir/plot-$m-monitor.sh"
|
|
2891
|
+
if [ -x "$script" ]; then
|
|
2892
|
+
echo " would attach: $script → $wt"
|
|
2893
|
+
else
|
|
2894
|
+
echo " would attach NOTHING for the $m monitor — $script is missing or not executable"
|
|
2895
|
+
fi
|
|
2896
|
+
done
|
|
2897
|
+
}
|
|
2898
|
+
|
|
1842
2899
|
report_in_flight() { # $1=candidate branch
|
|
1843
2900
|
local br files shown extra n=0 total
|
|
1844
2901
|
total=$(work_in_flight "$1" | wc -l | tr -d ' ')
|
|
@@ -1850,7 +2907,7 @@ report_in_flight() { # $1=candidate branch
|
|
|
1850
2907
|
# Said once, on the last line, rather than per branch.
|
|
1851
2908
|
[ "$n" = "$((IN_FLIGHT_MAX_BRANCHES + 1))" ] && \
|
|
1852
2909
|
echo " in flight: …and $((total - IN_FLIGHT_MAX_BRANCHES)) more branches" \
|
|
1853
|
-
"— plot-
|
|
2910
|
+
"— plot-pulse for the full picture"
|
|
1854
2911
|
continue
|
|
1855
2912
|
fi
|
|
1856
2913
|
# Commas to ", " for reading; the machine-countable summary is the footer,
|
|
@@ -1867,6 +2924,13 @@ report_in_flight() { # $1=candidate branch
|
|
|
1867
2924
|
done
|
|
1868
2925
|
}
|
|
1869
2926
|
|
|
2927
|
+
# THE PREREQUISITE PREFLIGHT, before either fan-out path. It names every branch
|
|
2928
|
+
# this plan will not start and why, and fills `waits_held` so neither loop can
|
|
2929
|
+
# write for one. Run here, once, rather than inside the loops: the scan filters a
|
|
2930
|
+
# waiting branch out before a loop ever hears of it, so a refusal that only fires
|
|
2931
|
+
# on an offered branch would never fire at all.
|
|
2932
|
+
run_waits_preflight
|
|
2933
|
+
|
|
1870
2934
|
# A dry run changes nothing, so nothing can go stale — read the whole eligible
|
|
1871
2935
|
# set once. (`--next` would loop forever here: without a claim it keeps
|
|
1872
2936
|
# returning the same branch.)
|
|
@@ -1881,10 +2945,21 @@ if [ "$dry_run" = 1 ]; then
|
|
|
1881
2945
|
n_skipped=$((n_skipped + 1))
|
|
1882
2946
|
continue
|
|
1883
2947
|
fi
|
|
2948
|
+
# ALREADY REFUSED BY THE PREFLIGHT, which named it and counted it. The
|
|
2949
|
+
# scan does not normally offer a waiting branch at all; this arm catches
|
|
2950
|
+
# the one that reached here because the scan could not ask the host.
|
|
2951
|
+
is_waits_held "$br" && continue
|
|
1884
2952
|
echo "would dispatch $br → $(worktree_for "$br")"
|
|
2953
|
+
report_monitors "$(worktree_for "$br")"
|
|
1885
2954
|
report_in_flight "$br"
|
|
1886
2955
|
n_dispatched=$((n_dispatched + 1))
|
|
1887
|
-
|
|
2956
|
+
# The scan's eligible set, plus whatever `--allow-waiting` freed. The scan
|
|
2957
|
+
# reports a waiting branch as `waiting`, so it is absent from the first and
|
|
2958
|
+
# only the preflight can supply it — see `waits_freed`. `sort -u` because a
|
|
2959
|
+
# branch the scan DID offer (its host call failed where the preflight's
|
|
2960
|
+
# succeeded) must be dispatched once, not twice.
|
|
2961
|
+
done < <({ "$script_dir/plot-fleet-scan.sh" $offline --list-eligible "$slug" 2>/dev/null
|
|
2962
|
+
printf '%s\n' ${waits_freed[@]+"${waits_freed[@]}"}; } | grep -v '^$' | sort -u)
|
|
1888
2963
|
# A dry run starts nothing BY CONSTRUCTION, so its `started=0` carries no
|
|
1889
2964
|
# information about the config — reporting "no workers started" here would be
|
|
1890
2965
|
# true and useless, and would train the reader to skip the line on the real
|
|
@@ -1892,42 +2967,82 @@ if [ "$dry_run" = 1 ]; then
|
|
|
1892
2967
|
# `skipped` is REAL here, not a constant. A dry run refuses held branches
|
|
1893
2968
|
# exactly as the real run does, so its count is a fact about this fleet — and
|
|
1894
2969
|
# it was hardcoded to 0 until the gate gave it something to count.
|
|
1895
|
-
|
|
2970
|
+
# `brief_asked=0` is a CONSTANT here and not a prediction. A dry run changes
|
|
2971
|
+
# nothing, and asking the `Brief command` to write a brief spawns an agent
|
|
2972
|
+
# that commits — the loudest write in this script. The field travels so the
|
|
2973
|
+
# footer's shape does not depend on the mode a machine reader happened to
|
|
2974
|
+
# call in.
|
|
2975
|
+
echo "summary: dispatched=$n_dispatched reused=0 skipped=$n_skipped started=0 brief=missing worker=$(worker_state_field) brief_asked=0"
|
|
1896
2976
|
exit 0
|
|
1897
2977
|
fi
|
|
1898
2978
|
|
|
1899
|
-
#
|
|
1900
|
-
#
|
|
1901
|
-
#
|
|
1902
|
-
|
|
2979
|
+
# THE LIST IS READ ONCE, AND THAT FOLLOWS FROM THE CLAIM GOING.
|
|
2980
|
+
#
|
|
2981
|
+
# This was a PULL: `--next` was asked again after every claim, because claiming
|
|
2982
|
+
# a branch changed what the next ask would offer and a list computed up front
|
|
2983
|
+
# would have gone stale mid-fan-out. Dispatch claims nothing now — it hands a
|
|
2984
|
+
# slice to the registry and returns — so nothing this loop does changes the
|
|
2985
|
+
# scan's answer, and re-asking would return the same branch until a gate marked
|
|
2986
|
+
# it exhausted and the loop broke on it. Measured on the first run after the
|
|
2987
|
+
# claim was removed: `feature/one` handed over, `feature/two` never reached.
|
|
2988
|
+
#
|
|
2989
|
+
# ONE SCAN RATHER THAN N. The scan is 18.3 s here, so the pull cost one of those
|
|
2990
|
+
# per branch to re-derive an answer that could not have moved.
|
|
2991
|
+
#
|
|
2992
|
+
# `--allow-waiting`'s CANDIDATES COME LAST, after every branch the scan was
|
|
2993
|
+
# willing to name. `--list-eligible` reports a waiting branch as `waiting` and
|
|
2994
|
+
# never offers it, so the flag's candidates can only come from the preflight; a
|
|
2995
|
+
# held branch is one the operator chose to start early and must not displace one
|
|
2996
|
+
# that was ready. `sort -u` because a branch the scan DID offer — its host call
|
|
2997
|
+
# failed where the preflight's succeeded — must be handed over once, not twice.
|
|
2998
|
+
#
|
|
2999
|
+
# A `while read` LOOP, BECAUSE macOS SHIPS BASH 3.2. The bash 4 builtin that
|
|
3000
|
+
# reads a stream into an array does not exist there, and
|
|
3001
|
+
# `test/reconcile/mergequeue.test.mjs` gates on it — by grepping these scripts,
|
|
3002
|
+
# so naming the builtin in a comment fails the gate too.
|
|
3003
|
+
#
|
|
3004
|
+
# The single scan this block exists for is unaffected: the subshell still runs
|
|
3005
|
+
# once, and its output is still read once.
|
|
3006
|
+
fan_out=()
|
|
3007
|
+
while IFS= read -r _line; do
|
|
3008
|
+
fan_out+=("$_line")
|
|
3009
|
+
done < <({ "$script_dir/plot-fleet-scan.sh" $offline --list-eligible "$slug" 2>/dev/null
|
|
3010
|
+
[ "$allow_waiting" = 1 ] && printf '%s\n' ${waits_freed[@]+"${waits_freed[@]}"}
|
|
3011
|
+
:; } | grep -v '^$' | sort -u)
|
|
3012
|
+
|
|
3013
|
+
for branch in ${fan_out[@]+"${fan_out[@]}"}; do
|
|
1903
3014
|
[ "$max" -gt 0 ] && [ "$n_dispatched" -ge "$max" ] && break
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
#
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
#
|
|
1912
|
-
#
|
|
1913
|
-
#
|
|
3015
|
+
# `exhausted` SURVIVES THE PULL IT WAS WRITTEN FOR. It no longer has to stop
|
|
3016
|
+
# the loop re-offering a branch — a list cannot — but the gates below still
|
|
3017
|
+
# mark what they refused, and `sort -u` cannot merge a preflight candidate
|
|
3018
|
+
# with a scan-offered one where the two spellings differ.
|
|
3019
|
+
is_exhausted "$branch" && continue
|
|
3020
|
+
|
|
3021
|
+
# THE PATH DISPATCH NO LONGER CREATES, still composed for the two readers that
|
|
3022
|
+
# need it: the dry run, which names where a desk WOULD go, and the monitor
|
|
3023
|
+
# report. The agent decides its own desk now, so this is a prediction rather
|
|
3024
|
+
# than a destination — and it stays flattened whole, because `feature/api` and
|
|
3025
|
+
# `bug/api` are different work and must not name one directory.
|
|
1914
3026
|
suffix=$(printf '%s' "$branch" | tr '/' '-')
|
|
1915
3027
|
wt="$wt_root/$wt_prefix$suffix"
|
|
1916
3028
|
|
|
1917
3029
|
if [ "$dry_run" = 1 ]; then
|
|
1918
|
-
echo "would
|
|
3030
|
+
echo "would hand over $branch → the registry"
|
|
3031
|
+
report_monitors "$wt"
|
|
1919
3032
|
report_in_flight "$branch"
|
|
1920
3033
|
n_dispatched=$((n_dispatched + 1))
|
|
1921
3034
|
continue
|
|
1922
3035
|
fi
|
|
1923
3036
|
|
|
1924
|
-
# THE HELD-BRANCH GATE,
|
|
3037
|
+
# THE HELD-BRANCH GATE, and it survives the fan-out losing its writes.
|
|
1925
3038
|
#
|
|
1926
|
-
#
|
|
1927
|
-
# is
|
|
1928
|
-
#
|
|
1929
|
-
#
|
|
1930
|
-
#
|
|
3039
|
+
# It refuses a branch whose own desk holds work that has not landed, and that
|
|
3040
|
+
# is a MEASUREMENT of somebody sitting at it — not a prediction about a file.
|
|
3041
|
+
# Dispatch creates no desk any more, so this no longer protects an adoption
|
|
3042
|
+
# path; it protects the hand-over itself. Handing a slice to the registry
|
|
3043
|
+
# while an agent is mid-edit on that branch is how two agents end up on one,
|
|
3044
|
+
# and the desk is the only place that work is visible: it is unpushed by
|
|
3045
|
+
# definition, so no ref and no PR reports it.
|
|
1931
3046
|
#
|
|
1932
3047
|
# `exhausted` is what makes the refusal terminal: --next has no memory and
|
|
1933
3048
|
# would keep offering this same branch until the loop's own break fired.
|
|
@@ -1938,80 +3053,104 @@ while :; do
|
|
|
1938
3053
|
continue
|
|
1939
3054
|
fi
|
|
1940
3055
|
|
|
1941
|
-
#
|
|
1942
|
-
#
|
|
1943
|
-
#
|
|
3056
|
+
# ALREADY REFUSED BY THE PREFLIGHT, and still asked here. A slice handed over
|
|
3057
|
+
# while its prerequisite is unmerged is an agent started on work that cannot
|
|
3058
|
+
# build, and `feature/the-domain-forgets-the-vendor-list` is the measured
|
|
3059
|
+
# case — claimed, and holding nothing but its claim commit.
|
|
3060
|
+
#
|
|
3061
|
+
# `exhausted` is what makes the refusal terminal — `--next` has no memory and
|
|
3062
|
+
# would keep offering this branch until the loop's own break fired. It should
|
|
3063
|
+
# not be offering it at all (the scan reads `waiting`), so this arm is the
|
|
3064
|
+
# belt to the preflight's brace: a branch offered by a scan that could not
|
|
3065
|
+
# reach the host still stops here, and it is NOT counted again — the preflight
|
|
3066
|
+
# already reported it.
|
|
3067
|
+
if is_waits_held "$branch"; then
|
|
3068
|
+
exhausted+=("$branch")
|
|
3069
|
+
continue
|
|
3070
|
+
fi
|
|
3071
|
+
|
|
3072
|
+
# BEFORE anything is handed over. The candidate is not yet work in flight —
|
|
3073
|
+
# dispatch creates no desk and pushes no claim — so this describes what stood
|
|
3074
|
+
# before this run rather than what this run made.
|
|
1944
3075
|
in_flight=$(report_in_flight "$branch")
|
|
1945
3076
|
|
|
1946
|
-
#
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
3077
|
+
# THE BRIEF GATE, AT THE HAND-OVER RATHER THAN AT THE LAUNCH.
|
|
3078
|
+
#
|
|
3079
|
+
# ITS RULE IS UNCHANGED — a slice with no brief is not handed over — AND ONLY
|
|
3080
|
+
# ITS POSITION MOVED. It used to sit between a prepared desk and a started
|
|
3081
|
+
# worker, so a missing brief left a worktree and a claim nobody was sat at:
|
|
3082
|
+
# correct at the time, because preparing was the only thing dispatch could do
|
|
3083
|
+
# first. Dispatch now prepares nothing, so a refused slice leaves nothing at
|
|
3084
|
+
# all and simply stays in the queue.
|
|
3085
|
+
#
|
|
3086
|
+
# THE REFUSAL STILL NAMES THE REF IT LOOKED AT, not a bare path. A brief
|
|
3087
|
+
# sitting unpushed in the operator's checkout is the likeliest reason to see
|
|
3088
|
+
# this message, and `no brief at .plot/briefs/x.md` would send them to look at
|
|
3089
|
+
# a file that is right there — the ref says where the AGENT will look.
|
|
3090
|
+
#
|
|
3091
|
+
# `--no-brief` KEEPS ITS MEANING: it hands over without one and SAYS SO, so
|
|
3092
|
+
# the override stays on the record rather than being silent.
|
|
3093
|
+
if brief_present "$branch"; then
|
|
3094
|
+
# A brief that is present is never refused for age — see
|
|
3095
|
+
# `brief_staleness_note`. The note is printed before the hand-over so it
|
|
3096
|
+
# sits with the branch it describes, and the hand-over happens either way.
|
|
3097
|
+
brief_staleness_note "$branch"
|
|
3098
|
+
elif [ "$no_brief" = 1 ]; then
|
|
3099
|
+
echo " no brief at $(brief_ref "$branch") — handing it over anyway (--no-brief)"
|
|
1951
3100
|
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
|
|
3101
|
+
echo " not handed over — no brief at $(brief_ref "$branch")"
|
|
3102
|
+
echo " write one: /plot-implement $slug (then push it, or pass --no-brief to hand it over without one)"
|
|
3103
|
+
# AND NOW SOMETHING IS DONE ABOUT IT. The refusal above stands unchanged;
|
|
3104
|
+
# this line says what happened NEXT. Either arm names itself, so the log
|
|
3105
|
+
# always records which one ran.
|
|
3106
|
+
request_brief "$branch" "$slug" && n_brief_asked=$((n_brief_asked + 1))
|
|
3107
|
+
n_skipped=$((n_skipped + 1))
|
|
3108
|
+
# `exhausted` is what makes the refusal terminal — `--next` has no memory
|
|
3109
|
+
# and would keep offering this branch until the loop's own break fired.
|
|
3110
|
+
exhausted+=("$branch")
|
|
3111
|
+
continue
|
|
1993
3112
|
fi
|
|
1994
3113
|
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
3114
|
+
# THE HAND-OVER, AND IT IS THE WHOLE OF WHAT DISPATCH DOES WITH A SLICE.
|
|
3115
|
+
#
|
|
3116
|
+
# `git worktree add` USED TO BE HERE, with a claim push behind it and a worker
|
|
3117
|
+
# start behind that. All three are gone, and each for its own reason:
|
|
3118
|
+
#
|
|
3119
|
+
# THE DESK. `DESIGN-agent.md:65` — *"agent ──owns──► a worktree (its desk,
|
|
3120
|
+
# while it lives)"*. One desk per agent, not one per slice. The agent decides
|
|
3121
|
+
# create-or-reset when it takes the brief, because it is the only party that
|
|
3122
|
+
# can see its own tree; a desk cut here would be cut before anybody knows
|
|
3123
|
+
# which agent will sit at it. Measured 2026-09-02: 2 manifests against 11
|
|
3124
|
+
# worktrees, 5 of them on branches that had already merged.
|
|
3125
|
+
#
|
|
3126
|
+
# THE CLAIM. A pushed claim makes the branch read `claimed` rather than
|
|
3127
|
+
# `open`, and the queue is DERIVED — an eligible slice with a brief and no
|
|
3128
|
+
# claim IS queued. Claiming here would take the slice straight back out of
|
|
3129
|
+
# the queue it was being put into.
|
|
3130
|
+
#
|
|
3131
|
+
# THE WORKER. `DESIGN-agent.md:157` — *"nothing starts a worker"*. The
|
|
3132
|
+
# registry spawns an agent, and spawning it IS starting its process. This
|
|
3133
|
+
# script hands work to the fleet; it does not staff it.
|
|
3134
|
+
#
|
|
3135
|
+
# SO THE HAND-OVER IS A REPORT AND NOT A WRITE. The queue derives from the
|
|
3136
|
+
# plan, the briefs and the refs, all of which are already on the host, so
|
|
3137
|
+
# there is nothing for this line to store — which is what keeps the daemon
|
|
3138
|
+
# stateless across restarts.
|
|
3139
|
+
#
|
|
3140
|
+
# IT REFUSES NOTHING FOR WANT OF A FREE AGENT, and never asks. An earlier
|
|
3141
|
+
# draft of the plan proposed refusing on `0 free` and it is wrong: it makes
|
|
3142
|
+
# dispatch synchronous with fleet capacity, the coupling `DESIGN-machine.md`
|
|
3143
|
+
# §10 spent two revisions rejecting, and `DESIGN-agent.md:173` states it from
|
|
3144
|
+
# the other side — *"a dispatch never asks the machine for capacity"*. **The
|
|
3145
|
+
# queue absorbs the timing.** A queue longer than the pool is the normal case.
|
|
3146
|
+
echo "handed over $branch → the registry"
|
|
3147
|
+
[ -n "$in_flight" ] && printf '%s\n' "$in_flight"
|
|
3148
|
+
n_dispatched=$((n_dispatched + 1))
|
|
3149
|
+
# AFTER the hand-over rather than after a claim. A `Started:` record now
|
|
3150
|
+
# states that the slice was handed to the fleet, which is what this run did;
|
|
3151
|
+
# who takes it is the registry's to decide and its own to record.
|
|
3152
|
+
claimed_now+=("$branch")
|
|
3153
|
+
|
|
2015
3154
|
done
|
|
2016
3155
|
|
|
2017
3156
|
# Book AFTER the fan-out, in one commit, so a booking that fails cannot leave
|