@plot-pm/board 0.9.0 → 0.10.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 +143 -143
- package/package.json +12 -2
- package/plot-approve.sh +628 -0
- package/plot-deliver.sh +488 -0
- package/plot-dispatch.sh +2028 -0
- package/plot-fleet-scan.sh +3496 -0
- package/plot-host.sh +1686 -0
- package/plot-plan-meta.sh +20 -4
- package/plot-reap.sh +286 -0
- package/plot-release-refs.sh +234 -0
- package/plot-resolve-artifact.sh +334 -0
- package/plot-worker-state.sh +725 -0
package/plot-dispatch.sh
ADDED
|
@@ -0,0 +1,2028 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Plot helper: fan out one worktree + one worker per eligible branch.
|
|
3
|
+
# Usage: plot-dispatch.sh [--dry-run] [--no-start] [--no-brief] [--offline]
|
|
4
|
+
# [--max N] [--allow-local] <slug>
|
|
5
|
+
# plot-dispatch.sh --migrate [--yes] [--max N]
|
|
6
|
+
# --status list fleet worktrees with worker pid, liveness, and last log
|
|
7
|
+
# line; then exit. Works regardless of plan phase.
|
|
8
|
+
# --stop <br> stop the worker on <br> (branch required — never "all").
|
|
9
|
+
# --restart <br>
|
|
10
|
+
# start a worker on <br>, which already holds a claim — the
|
|
11
|
+
# counterpart to --stop, and the only way to hand a stopped
|
|
12
|
+
# branch to a new worker through Plot. Branch required, never a
|
|
13
|
+
# slug: deciding that a stopped worker should be replaced rather
|
|
14
|
+
# than its work reviewed, reaped or abandoned is a person's call.
|
|
15
|
+
# Refuses on an open or merged PR (asked FIRST, before the state
|
|
16
|
+
# word), on a live worker, and on a PLOT-BLOCKED marker. The
|
|
17
|
+
# worktree is inherited exactly as it stands — uncommitted work
|
|
18
|
+
# is what a stall leaves behind, and this must not destroy it.
|
|
19
|
+
# --migrate move legacy worktrees into the configured `Worktree root:`. An
|
|
20
|
+
# idle worktree (no live worker, no unlanded work) is moved; a
|
|
21
|
+
# busy one is skipped with the reason. Requires a `Worktree root:`
|
|
22
|
+
# config — without one there is no destination. --dry-run by
|
|
23
|
+
# default; --yes to actually move.
|
|
24
|
+
# --dry-run print what would happen; create nothing, push nothing
|
|
25
|
+
# --yes with --migrate, actually move the worktrees (default is dry-run)
|
|
26
|
+
# --no-start create worktrees and claim refs, but start no workers
|
|
27
|
+
# --no-brief start a worker even when its branch has no brief. The named
|
|
28
|
+
# escape for the brief gate: a missing brief PREPARES (worktree +
|
|
29
|
+
# claim) but does not START, because the worker's first
|
|
30
|
+
# instruction is to read `.plot/briefs/<branch>.md` and it has
|
|
31
|
+
# nothing to read. --no-brief overrides that and says so.
|
|
32
|
+
# --offline skip `git fetch`
|
|
33
|
+
# --max N dispatch at most N branches this run (default: all eligible)
|
|
34
|
+
# --allow-local read the plan's phase from the working tree when
|
|
35
|
+
# origin/<main> cannot be resolved (no remote, fresh clone).
|
|
36
|
+
# The explicit escape for a remote-less repo — never a default,
|
|
37
|
+
# because a working-tree read is what this gate exists to avoid.
|
|
38
|
+
# <slug> the plan to fan out
|
|
39
|
+
# Output: one line per branch, each optionally followed by an indented
|
|
40
|
+
# `in flight:` line naming a branch that already holds files, then the
|
|
41
|
+
# summary block — an optional prose consequence line, then a
|
|
42
|
+
# machine-countable footer.
|
|
43
|
+
# A branch whose worktree exists with UNMERGED work is refused rather
|
|
44
|
+
# than dispatched — counted `skipped`, with the worktree path named,
|
|
45
|
+
# in `--dry-run` identically to a real run. See "THE HELD-BRANCH GATE".
|
|
46
|
+
# 3 worktrees prepared, 0 workers started, no `Worker command` configured
|
|
47
|
+
# summary: dispatched=2 reused=0 skipped=1 started=2 brief=missing worker=unconfigured
|
|
48
|
+
#
|
|
49
|
+
# THE CONSEQUENCE IS STATED IN THE SUMMARY, NOT PER BRANCH. start_worker has
|
|
50
|
+
# always said "no 'Worker command' configured" beside the branch it could not
|
|
51
|
+
# start — buried in per-branch output, after the fan-out already happened. On
|
|
52
|
+
# 2026-08-17 that message was printed and missed five times: worktrees sat
|
|
53
|
+
# claimed with nobody working on them, and the last line a caller read said
|
|
54
|
+
# `started=0` with no reason beside it. A caller reading only the summary is
|
|
55
|
+
# the case this exists for, so the fact travels twice: as `worker=` in the
|
|
56
|
+
# footer for machines, and as one prose line above it for people.
|
|
57
|
+
#
|
|
58
|
+
# `worker=` is unconfigured | declined | configured | suppressed:
|
|
59
|
+
# unconfigured — no `Worker command` in Plot Config, and nobody has been
|
|
60
|
+
# asked. This is the state the summary line exists for.
|
|
61
|
+
# declined — `Worker command: none`. Asked, and answered "we start them
|
|
62
|
+
# by hand". A DELIBERATE absence, distinct from a missing key
|
|
63
|
+
# precisely so the skill stops asking: an empty answer is a
|
|
64
|
+
# first-class answer, and a prompt that returns every dispatch
|
|
65
|
+
# is a nag. `none` is never run as a command.
|
|
66
|
+
# configured — a command exists (whether or not every start succeeded)
|
|
67
|
+
# suppressed — `--no-start`, which means exactly what it says and implies
|
|
68
|
+
# nothing else. The inspect-first workflow is deliberate, so
|
|
69
|
+
# its zero is reported as a choice rather than as a defect.
|
|
70
|
+
#
|
|
71
|
+
# `brief=missing` is CONSTANT, and that is the point: this script cannot write a
|
|
72
|
+
# hand-off brief and never will. A brief is interpretation (which alternatives
|
|
73
|
+
# the plan rejected, and what killed them), and no script here invokes a skill —
|
|
74
|
+
# bash cannot reach one at all. /plot-implement owns the brief; the plot-dispatch
|
|
75
|
+
# SKILL invokes it after a fan-out. The field reports the gap so a direct call
|
|
76
|
+
# says what it left undone instead of leaving a claimed worktree looking handed
|
|
77
|
+
# over. It does NOT refuse: --dry-run and --status are legitimate direct calls,
|
|
78
|
+
# and a gate that blocks looking-before-leaping is a gate in the wrong place.
|
|
79
|
+
#
|
|
80
|
+
# THIS IS THE ONE SCRIPT IN THE FLEET THAT WRITES. Everything else
|
|
81
|
+
# (plot-fleet-scan.sh, plot-reconcile-scan.sh) is read-only. Consequently every
|
|
82
|
+
# write here is either idempotent or refused:
|
|
83
|
+
#
|
|
84
|
+
# - Claim by ref push, where the claim carries an empty COMMIT. Two
|
|
85
|
+
# independent claims diverge, so the loser's push is rejected as
|
|
86
|
+
# non-fast-forward — that rejection is the concurrency control. Pushing a
|
|
87
|
+
# branch that merely points at origin/<main> would NOT work: the remote
|
|
88
|
+
# already has that commit, so both pushes succeed and both dispatchers
|
|
89
|
+
# think they won. Git is the lock only when the refs actually diverge.
|
|
90
|
+
# - Worktrees are adopted, never duplicated. A dispatcher that dies halfway
|
|
91
|
+
# through a fan-out is safe to re-run.
|
|
92
|
+
# - Nothing is ever deleted. Cleanup belongs to /plot-reconcile, which can
|
|
93
|
+
# tell a deliberately abandoned claim from a dead worker.
|
|
94
|
+
# - The `Started:` record is booked on the DEFAULT BRANCH, after the claims,
|
|
95
|
+
# and only for branches this run newly claimed. A re-run books nothing it
|
|
96
|
+
# merely re-adopted. If the booking cannot be pushed, the fan-out stands
|
|
97
|
+
# and the script says the record is missing — see book_started.
|
|
98
|
+
#
|
|
99
|
+
# Eligibility is NOT decided here: this script asks plot-fleet-scan.sh, which
|
|
100
|
+
# owns the wave arithmetic. Dispatch only acts on the answer. Keeping the rule
|
|
101
|
+
# in one place is why a blocked wave can never be fanned out by accident.
|
|
102
|
+
#
|
|
103
|
+
# Workers are started DETACHED, one per worktree, so the fleet outlives the
|
|
104
|
+
# dispatching session — close the laptop and they keep going. That is also why
|
|
105
|
+
# the reaper (/plot-reconcile) is load-bearing rather than a nicety: a detached
|
|
106
|
+
# worker dies without telling anyone.
|
|
107
|
+
set -uo pipefail
|
|
108
|
+
|
|
109
|
+
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
110
|
+
|
|
111
|
+
# The shared worker classifier. Sourced by both this script and
|
|
112
|
+
# plot-fleet-scan.sh so a worker has ONE state, not one per reader.
|
|
113
|
+
# shellcheck source=plot-worker-state.sh
|
|
114
|
+
. "$script_dir/plot-worker-state.sh"
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# WHERE THE WORKTREES LIVE, and by what name
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
#
|
|
120
|
+
# Two facts, resolved together because the second is a PROPERTY OF THE FIRST:
|
|
121
|
+
# the root directory the worktrees sit in, and the prefix their directory names
|
|
122
|
+
# carry. `plot-wt-` exists to make Plot's worktrees identifiable AMONG UNRELATED
|
|
123
|
+
# directories — it is a workaround for sharing a parent with other projects.
|
|
124
|
+
# Under a dedicated `Worktree root:` the directory already says what they are,
|
|
125
|
+
# so the prefix answers a question nobody is asking and is dropped. The legacy
|
|
126
|
+
# default keeps it, where it is still doing its job. Two conventions coexist
|
|
127
|
+
# permanently, and that is the intended outcome, not a transition cost.
|
|
128
|
+
#
|
|
129
|
+
# `Worktree root:` absent → repo_root/.. , prefix `plot-wt-` (today's behaviour)
|
|
130
|
+
# relative value → repo_root/<value> , NO prefix
|
|
131
|
+
# absolute value → <value> as given , NO prefix
|
|
132
|
+
#
|
|
133
|
+
# THIS FUNCTION ONLY COMPOSES A ROOT AND A PREFIX. It is the CREATION side. Every
|
|
134
|
+
# read of "which worktree holds this branch" asks `git worktree list` instead —
|
|
135
|
+
# see THE HELD-BRANCH GATE. A second naming convention gives path-guessing a
|
|
136
|
+
# second way to be wrong, so path-guessing is confined to creation alone.
|
|
137
|
+
resolve_wt_root() { # $1=repo_root → sets globals wt_root, wt_prefix
|
|
138
|
+
local rr="$1" configured
|
|
139
|
+
configured=$("$script_dir/plot-config.sh" get "Worktree root" "")
|
|
140
|
+
if [ -z "$configured" ]; then
|
|
141
|
+
# The legacy default: beside the repo, prefixed. No existing checkout moves.
|
|
142
|
+
wt_root=$(cd "$rr/.." && pwd)
|
|
143
|
+
wt_prefix="plot-wt-"
|
|
144
|
+
return
|
|
145
|
+
fi
|
|
146
|
+
case "$configured" in
|
|
147
|
+
/*) wt_root="$configured" ;; # absolute: taken as given
|
|
148
|
+
*) wt_root="$rr/$configured" ;; # relative: against the repo root
|
|
149
|
+
esac
|
|
150
|
+
# Normalise away a trailing slash so composed paths never double it. The
|
|
151
|
+
# directory may not exist yet (created on first dispatch), so this is pure
|
|
152
|
+
# string work, not a `cd`.
|
|
153
|
+
wt_root="${wt_root%/}"
|
|
154
|
+
wt_prefix=""
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
dry_run=0
|
|
158
|
+
no_start=0
|
|
159
|
+
no_brief=0
|
|
160
|
+
mode=dispatch
|
|
161
|
+
stop_branch=""
|
|
162
|
+
restart_branch=""
|
|
163
|
+
offline=""
|
|
164
|
+
allow_local=0
|
|
165
|
+
max=0
|
|
166
|
+
slug=""
|
|
167
|
+
migrate_yes=0
|
|
168
|
+
while [ $# -gt 0 ]; do
|
|
169
|
+
case "$1" in
|
|
170
|
+
--dry-run) dry_run=1 ;;
|
|
171
|
+
--status) mode=status ;;
|
|
172
|
+
--migrate) mode=migrate ;;
|
|
173
|
+
--yes) migrate_yes=1 ;;
|
|
174
|
+
# Only a value containing "/" is taken as the branch: otherwise a bare
|
|
175
|
+
# `--stop <slug>` would silently treat the plan slug as a branch name and
|
|
176
|
+
# stop the wrong thing (or nothing) without saying so.
|
|
177
|
+
--stop) mode=stop; case "${2:-}" in */*) stop_branch="$2"; shift ;; esac ;;
|
|
178
|
+
# Same rule as --stop, for the same reason and one more: a bare
|
|
179
|
+
# `--restart <slug>` would fall through to the plan gate and report "no
|
|
180
|
+
# plan for feature/x", which describes neither what was asked nor what
|
|
181
|
+
# went wrong. The branch is consumed only when it looks like one.
|
|
182
|
+
--restart) mode=restart; case "${2:-}" in */*) restart_branch="$2"; shift ;; esac ;;
|
|
183
|
+
--no-start) no_start=1 ;;
|
|
184
|
+
--no-brief) no_brief=1 ;;
|
|
185
|
+
--offline|--no-fetch) offline="--offline" ;;
|
|
186
|
+
--allow-local) allow_local=1 ;;
|
|
187
|
+
--max) max="${2:?--max needs a value}"
|
|
188
|
+
case "$max" in
|
|
189
|
+
''|*[!0-9]*) echo "plot-dispatch: --max needs a number, got '$max'" >&2; exit 1 ;;
|
|
190
|
+
esac
|
|
191
|
+
shift ;;
|
|
192
|
+
-h|--help) sed -n '2,38p' "$0"; exit 0 ;;
|
|
193
|
+
*) slug="$1" ;;
|
|
194
|
+
esac
|
|
195
|
+
shift
|
|
196
|
+
done
|
|
197
|
+
|
|
198
|
+
git rev-parse --git-dir >/dev/null 2>&1 || { echo "not a git repository" >&2; exit 1; }
|
|
199
|
+
[ -n "$slug" ] || [ "$mode" != dispatch ] || { echo "plot-dispatch: need a plan slug" >&2; exit 1; }
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# Worker launch, and the identity it records
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
#
|
|
205
|
+
# DEFINED HERE, ABOVE THE INSPECTION BLOCK, because `--restart` calls
|
|
206
|
+
# start_worker and that block exits before the fan-out is ever reached. Bash
|
|
207
|
+
# resolves a function when the call RUNS, so a definition further down the file
|
|
208
|
+
# is not yet in scope — the restart path found `start_worker: command not found`
|
|
209
|
+
# until this moved. Nothing here executes at definition time; only the position
|
|
210
|
+
# changed.
|
|
211
|
+
|
|
212
|
+
# A session id, in the shape the runtime uses for its transcript filename.
|
|
213
|
+
#
|
|
214
|
+
# `uuidgen` where it exists (macOS and most Linux), falling back to `/dev/urandom`
|
|
215
|
+
# — never to `$RANDOM` or a timestamp. Two workers launched in the same second by
|
|
216
|
+
# the same fan-out would collide on either, and a collision here silently merges
|
|
217
|
+
# two agents into one manifest.
|
|
218
|
+
#
|
|
219
|
+
# Lowercased because the runtime writes its transcript filename in lowercase and
|
|
220
|
+
# the board joins on exact string equality; `uuidgen` on macOS returns uppercase.
|
|
221
|
+
plot_session_id() {
|
|
222
|
+
local id=""
|
|
223
|
+
if command -v uuidgen >/dev/null 2>&1; then
|
|
224
|
+
id=$(uuidgen 2>/dev/null | tr 'A-Z' 'a-z')
|
|
225
|
+
fi
|
|
226
|
+
if [ -z "$id" ]; then
|
|
227
|
+
# 16 random bytes rendered as a v4-shaped id. The shape matters only for
|
|
228
|
+
# recognisability; nothing parses it.
|
|
229
|
+
id=$(od -An -tx1 -N16 /dev/urandom 2>/dev/null | tr -d ' \n' \
|
|
230
|
+
| sed -E 's/(.{8})(.{4})(.{4})(.{4})(.{12})/\1-\2-\3-\4-\5/')
|
|
231
|
+
fi
|
|
232
|
+
printf '%s' "$id"
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# JSON-escape one string for a manifest value.
|
|
236
|
+
#
|
|
237
|
+
# `printf %s` through a substitution chain rather than `jq`: Plot's helpers must
|
|
238
|
+
# run where only POSIX tools exist, and a Worker command routinely contains
|
|
239
|
+
# double quotes and newlines — this repo's is a 1,400-character prompt full of
|
|
240
|
+
# both. Backslash first, or it re-escapes what the later rules add.
|
|
241
|
+
json_escape() {
|
|
242
|
+
printf '%s' "$1" \
|
|
243
|
+
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' \
|
|
244
|
+
| awk 'BEGIN{ORS=""} {if (NR>1) print "\\n"; print}'
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
# Write one agent manifest: launch-time facts, keyed on the session id.
|
|
248
|
+
#
|
|
249
|
+
# Model and context are still absent on purpose: they belong to the runtime and
|
|
250
|
+
# are read from the transcript, so a manifest that named them would be a guess.
|
|
251
|
+
# The `pid` starts EMPTY here and is stamped by the wrapper the instant it learns
|
|
252
|
+
# its own child — see `stamp_manifest_pid`. The dispatcher does not know the
|
|
253
|
+
# agent pid at this line (only the wrapper does, from its `$!`), so it writes the
|
|
254
|
+
# field as a placeholder the wrapper fills rather than guessing it now.
|
|
255
|
+
#
|
|
256
|
+
# Written to a temp file and moved into place, so a scan reading the directory
|
|
257
|
+
# never sees a half-written manifest. `mv` within one directory is atomic.
|
|
258
|
+
write_agent_manifest() { # $1=path $2=session $3=branch $4=worktree $5=command
|
|
259
|
+
local out="$1" tmp="$1.plot-tmp"
|
|
260
|
+
{
|
|
261
|
+
printf '{\n'
|
|
262
|
+
printf ' "session": "%s",\n' "$(json_escape "$2")"
|
|
263
|
+
printf ' "branch": "%s",\n' "$(json_escape "$3")"
|
|
264
|
+
printf ' "worktree": "%s",\n' "$(json_escape "$4")"
|
|
265
|
+
printf ' "command": "%s",\n' "$(json_escape "$5")"
|
|
266
|
+
printf ' "pid": "",\n'
|
|
267
|
+
printf ' "startedAt": "%s"\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
268
|
+
printf '}\n'
|
|
269
|
+
} > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
|
|
270
|
+
mv "$tmp" "$out" 2>/dev/null || { rm -f "$tmp"; return 1; }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
# THE MANIFEST PID IS STAMPED BY THE WRAPPER, NOT HERE. The agent's pid is
|
|
274
|
+
# knowable only to the wrapper (`$!` of its own backgrounded child), so the stamp
|
|
275
|
+
# is inline in the wrapper's `sh -c` below — a fresh shell that cannot reach a
|
|
276
|
+
# function defined in this bash script, the same isolation that makes the wrapper
|
|
277
|
+
# own `.plot-worker.pid`. The write above leaves `"pid": ""` as a placeholder the
|
|
278
|
+
# wrapper replaces; the mechanics and their safety are documented at that call.
|
|
279
|
+
|
|
280
|
+
# Start one DETACHED worker per worktree. Detached is the whole point: the
|
|
281
|
+
# fleet must outlive the dispatching session. Logs go beside the worktree so a
|
|
282
|
+
# human can read them without knowing anything about how the worker was started.
|
|
283
|
+
#
|
|
284
|
+
# The worker command is configurable because "how do I run an agent headless"
|
|
285
|
+
# is a per-project, per-tool answer that Plot must not hardcode (Principle 5).
|
|
286
|
+
# THE BRIEF GATE. A branch's hand-off brief is its specification: the `Worker
|
|
287
|
+
# command`'s first instruction is "Read `.plot/briefs/<branch-suffix>.md` first
|
|
288
|
+
# — it is the specification". Without it the worker reads nothing and improvises,
|
|
289
|
+
# the one thing the brief exists to prevent (measured 2026-08-20: 2:12 against a
|
|
290
|
+
# 700-line wave with no spec). So a missing brief PREPARES but does not START —
|
|
291
|
+
# the worktree and claim above are correct and stay; only the launch is refused.
|
|
292
|
+
#
|
|
293
|
+
# READABLE AND NON-EMPTY, not merely present. A zero-byte or permission-denied
|
|
294
|
+
# file is not a specification, and `[ -f ]` alone passes for an empty one — the
|
|
295
|
+
# naive check the plan calls out. This is STRICTER than the board's `briefState`
|
|
296
|
+
# row hint (which treats any existing file as present, because a person will look
|
|
297
|
+
# either way): here the cost of guessing wrong is an agent burning minutes on
|
|
298
|
+
# nothing, so an unreadable brief reads as missing.
|
|
299
|
+
#
|
|
300
|
+
# The path is the branch after its last `/` — the same convention
|
|
301
|
+
# `/plot-implement` writes and `briefPathOf` reads on the board side.
|
|
302
|
+
#
|
|
303
|
+
# READ FROM `origin/<main>`, NOT THE WORKING TREE, for the same reason the phase
|
|
304
|
+
# gate above does: the question is not "does a brief exist in this filesystem?"
|
|
305
|
+
# but "will the WORKER find one?". The worker's worktree is created from
|
|
306
|
+
# `origin/$MAIN` (see `git worktree add` below), so a brief committed nowhere —
|
|
307
|
+
# or committed locally and never pushed — is invisible to it. Checking the
|
|
308
|
+
# working tree passes the gate and starts a worker into an empty specification,
|
|
309
|
+
# which is the exact failure this gate exists to prevent.
|
|
310
|
+
#
|
|
311
|
+
# Both directions were measured 2026-08-27. Running the filesystem check from a
|
|
312
|
+
# checkout 8 commits behind main reported three branches' briefs missing while
|
|
313
|
+
# all three existed on `origin/main` at 4-5 KB — a benign refusal. The inverse
|
|
314
|
+
# is not benign, and it is the one a working-tree read permits.
|
|
315
|
+
#
|
|
316
|
+
# NON-EMPTY, not merely present: a zero-byte brief is what a half-finished write
|
|
317
|
+
# leaves behind, and `cat-file -e` alone passes for it. This is STRICTER than the
|
|
318
|
+
# board's `briefState` row hint (which treats any existing file as present,
|
|
319
|
+
# because a person will look either way): here the cost of guessing wrong is an
|
|
320
|
+
# agent burning minutes on nothing.
|
|
321
|
+
brief_path() { printf '.plot/briefs/%s.md' "${1##*/}"; }
|
|
322
|
+
brief_ref() { printf 'origin/%s:%s' "$MAIN" "$(brief_path "$1")"; }
|
|
323
|
+
brief_present() { # $1 = branch → 0 if a usable brief exists on origin/<main>
|
|
324
|
+
local sz
|
|
325
|
+
sz=$(git cat-file -s "$(brief_ref "$1")" 2>/dev/null) || return 1
|
|
326
|
+
[ "${sz:-0}" -gt 0 ]
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
start_worker() {
|
|
330
|
+
local branch="$1" wt="$2"
|
|
331
|
+
local cmd
|
|
332
|
+
cmd=$("$script_dir/plot-config.sh" get "Worker command" "")
|
|
333
|
+
# `none` means "asked, and this repo starts them by hand". Running it would
|
|
334
|
+
# spawn a worker per branch that fails with `none: command not found` — a
|
|
335
|
+
# deliberate answer turned into N crashed workers.
|
|
336
|
+
case "$cmd" in none|NONE|None) cmd="" ;; esac
|
|
337
|
+
if [ -z "$cmd" ]; then
|
|
338
|
+
# Not an error: Plot deliberately hardcodes no agent tooling (Principle 5).
|
|
339
|
+
# Word it as the next step rather than a failure, or a first run reads as
|
|
340
|
+
# "it did nothing".
|
|
341
|
+
#
|
|
342
|
+
# This line carries the ONE thing the summary cannot: which worktree to cd
|
|
343
|
+
# into. The consequence itself — that nothing started, and why — is stated
|
|
344
|
+
# in the summary, because per-branch output is exactly where it was missed
|
|
345
|
+
# five times on 2026-08-17. Saying it in both places would train the reader
|
|
346
|
+
# to skip both.
|
|
347
|
+
if [ "$worker_cmd_declined" = 1 ]; then
|
|
348
|
+
echo " worktree ready — start it yourself:"
|
|
349
|
+
else
|
|
350
|
+
echo " worktree ready — no 'Worker command' configured, so start it yourself:"
|
|
351
|
+
fi
|
|
352
|
+
echo " cd $wt # branch $branch is claimed and waiting"
|
|
353
|
+
return 1
|
|
354
|
+
fi
|
|
355
|
+
local log="$wt/.plot-worker.log"
|
|
356
|
+
rm -f "$wt/.plot-worker.exit"
|
|
357
|
+
|
|
358
|
+
# THE MANIFEST, AND WHY IT IS KEYED ON A SESSION ID RATHER THAN A BRANCH.
|
|
359
|
+
#
|
|
360
|
+
# An agent survives the branch it was launched on: it finishes one and takes
|
|
361
|
+
# another, and everything the board knows about it today lives INSIDE a
|
|
362
|
+
# worktree — `.plot-worker.pid` is a file in it, and the transcript directory
|
|
363
|
+
# is derived from its path. So an agent that moves on loses every identity the
|
|
364
|
+
# board holds, and the states that matter most (`waiting`, and an agent between
|
|
365
|
+
# branches) are exactly the ones no worktree can express.
|
|
366
|
+
#
|
|
367
|
+
# The manifest is the identity that outlives the worktree. It records ONLY
|
|
368
|
+
# launch-time knowledge — what this function has in hand at this line — because
|
|
369
|
+
# a record that infers is a record that can be wrong about the past. Model and
|
|
370
|
+
# context are absent here on purpose: they belong to the runtime and are read
|
|
371
|
+
# from the transcript, which the board joins by the id below.
|
|
372
|
+
#
|
|
373
|
+
# THE DISPATCHER MINTS THE ID. The plan assumed the runtime was already invoked
|
|
374
|
+
# with `--session-id`, but this repo's `Worker command` carries none, so reading
|
|
375
|
+
# one back would mean guessing at the newest file in a directory that holds one
|
|
376
|
+
# to eight of them (measured 2026-08-20) — the guess the manifest exists to
|
|
377
|
+
# remove. Minting keeps it launch-time knowledge, and exporting it as
|
|
378
|
+
# `PLOT_SESSION_ID` lets a `Worker command` forward it so the runtime's
|
|
379
|
+
# transcript lands where the manifest points. A command that ignores the
|
|
380
|
+
# variable still gets a complete manifest; only the transcript join degrades,
|
|
381
|
+
# to the absence the board already treats as the honest answer.
|
|
382
|
+
#
|
|
383
|
+
# WRITTEN BEFORE THE LAUNCH, for the reason the pid file's own comment gives
|
|
384
|
+
# one paragraph down: there is a window between spawn and first write, and a
|
|
385
|
+
# scan landing inside it must not read a started agent as absent. The manifest
|
|
386
|
+
# carries the identity, so its window would be worse than the pid's.
|
|
387
|
+
#
|
|
388
|
+
# WHERE THE MANIFEST GOES IS THE `Agent registry` KEY'S ANSWER, not this
|
|
389
|
+
# dispatcher's cwd. `readAgentRegistry` has honoured that key since #420 and
|
|
390
|
+
# `resolveManifestDir` resolves it; this writer did not, so it wrote
|
|
391
|
+
# `$repo_root/.plot/agents` — and `repo_root` is `git rev-parse
|
|
392
|
+
# --show-toplevel` from wherever the dispatcher was invoked. Auto-dispatch is
|
|
393
|
+
# invoked from the BOARD's checkout (`dispatch.ts` passes `cwd: repoRoot`), so
|
|
394
|
+
# its manifests landed in a directory nothing reads. Measured 2026-08-27: five
|
|
395
|
+
# live workers, five manifests, and the board reporting `2 manifests, 9
|
|
396
|
+
# synthesized` — every agent had one, two were reachable.
|
|
397
|
+
#
|
|
398
|
+
# The case split is `resolve_wt_root`'s, deliberately: absolute taken as given,
|
|
399
|
+
# relative joined onto the repo root, trailing slash trimmed as pure string
|
|
400
|
+
# work because the directory need not exist yet. A second convention for
|
|
401
|
+
# resolving a configured directory is a second way to be wrong.
|
|
402
|
+
local session manifest_dir
|
|
403
|
+
session=$(plot_session_id)
|
|
404
|
+
manifest_dir=$("$script_dir/plot-config.sh" get "Agent registry" ".plot/agents")
|
|
405
|
+
case "$manifest_dir" in
|
|
406
|
+
/*) ;;
|
|
407
|
+
*) manifest_dir="$repo_root/$manifest_dir" ;;
|
|
408
|
+
esac
|
|
409
|
+
manifest_dir="${manifest_dir%/}"
|
|
410
|
+
mkdir -p "$manifest_dir" 2>/dev/null || true
|
|
411
|
+
# `printf` per field with no interpretation: a command containing quotes,
|
|
412
|
+
# newlines or backslashes must survive into valid JSON, and this is the one
|
|
413
|
+
# place a Worker command's full text is recorded.
|
|
414
|
+
write_agent_manifest "$manifest_dir/$session.json" \
|
|
415
|
+
"$session" "$branch" "$wt" "$cmd" || true
|
|
416
|
+
#
|
|
417
|
+
# THE GATE: NO MANIFEST, NO WORKER.
|
|
418
|
+
#
|
|
419
|
+
# Both writes above are `|| true`, so until this check existed a worker whose
|
|
420
|
+
# manifest could not be written started anyway and was invisible to the
|
|
421
|
+
# registry for its whole life. `always write a manifest` is a RULE the code
|
|
422
|
+
# already believed it followed — and did; the file was simply unreachable.
|
|
423
|
+
# The enforceable condition is that the manifest is WHERE THE READER LOOKS,
|
|
424
|
+
# which only a test at the resolved path can establish, so this asserts the
|
|
425
|
+
# post-condition rather than either write's exit status: a future edit may
|
|
426
|
+
# rearrange the writing, and this still holds.
|
|
427
|
+
#
|
|
428
|
+
# REFUSE RATHER THAN LAUNCH. An agent outside the registry cannot be seen,
|
|
429
|
+
# stopped, restarted or reaped through the board, and it holds a claim nobody
|
|
430
|
+
# can release. A worker that cannot be registered is worse than one that never
|
|
431
|
+
# started, because the second state is VISIBLE. The worktree and the claim are
|
|
432
|
+
# left exactly as they are, so the operator retries for free once the cause is
|
|
433
|
+
# fixed — this refuses a launch, it does not undo the setup.
|
|
434
|
+
#
|
|
435
|
+
# BEFORE THE SPAWN, AND THE ORDERING IS THE WHOLE DESIGN. The spawn is ~75
|
|
436
|
+
# lines below, deliberately: there is a spawn-to-first-write window a scan
|
|
437
|
+
# must not misread as an absent agent. So this has a launch to PREVENT rather
|
|
438
|
+
# than a process to kill — no race, no kill path, no orphan risk. An earlier
|
|
439
|
+
# draft of the plan said *assert after launch, then kill*; that would have
|
|
440
|
+
# built a teardown path for a state that cannot arise.
|
|
441
|
+
#
|
|
442
|
+
# It NAMES THE PATH. The defect this closes was a directory nobody could see;
|
|
443
|
+
# a refusal reading only "could not start" would send the operator into the
|
|
444
|
+
# script to learn where it had looked. `return 1` is the same refusal contract
|
|
445
|
+
# the briefless and no-Worker-command arms above use, so the fan-out does not
|
|
446
|
+
# count this branch as started.
|
|
447
|
+
#
|
|
448
|
+
# `/api/continue`'s tolerance of a manifest-less worktree is NOT this gate and
|
|
449
|
+
# must stay tolerant: that is about CONTINUING a worker in a worktree older
|
|
450
|
+
# than manifests. This is CREATION, where the dispatcher has just minted a
|
|
451
|
+
# session id and there is no older-worktree case to tolerate.
|
|
452
|
+
if [ ! -f "$manifest_dir/$session.json" ]; then
|
|
453
|
+
echo " refusing to start $branch — its agent manifest could not be written:"
|
|
454
|
+
echo " $manifest_dir/$session.json"
|
|
455
|
+
echo " An unregistered worker cannot be seen, stopped or reaped, and holds"
|
|
456
|
+
echo " a claim nobody can release. The worktree and claim are untouched —"
|
|
457
|
+
echo " fix the path above (see the 'Agent registry' key) and dispatch again."
|
|
458
|
+
return 1
|
|
459
|
+
fi
|
|
460
|
+
# TWO PIDS, TWO NAMES. `.plot-worker.pid` must name the AGENT — the process
|
|
461
|
+
# doing the work, which is what the panel, `--status` and the scan describe.
|
|
462
|
+
# `$!` from the parent names the `sh -c` WRAPPER, and recording that is the
|
|
463
|
+
# bug this fixes: every field read correctly off the dispatcher's shell rather
|
|
464
|
+
# than off the agent. The wrapper is the one thing that knows its own child,
|
|
465
|
+
# so the wrapper writes the agent's pid; only the wrapper can, and a `pgrep`
|
|
466
|
+
# by command string is the failure this repo already recorded (`wait on your
|
|
467
|
+
# own PID, not a process name`).
|
|
468
|
+
#
|
|
469
|
+
# The wrapper's own pid is KEPT, under `.plot-worker.wrapper.pid`, because the
|
|
470
|
+
# wrapper is what writes `.plot-worker.exit` when the agent exits and that must
|
|
471
|
+
# keep working — `--stop` kills the agent, the wrapper survives to record the
|
|
472
|
+
# code. The paths travel as env vars so no quoting level inside the
|
|
473
|
+
# single-quoted `sh -c` mangles a path with spaces, exactly as the exit file
|
|
474
|
+
# already does.
|
|
475
|
+
#
|
|
476
|
+
# The agent runs backgrounded inside the wrapper so the wrapper can capture its
|
|
477
|
+
# `$!` and `wait` for it. There is a sub-millisecond window after the wrapper
|
|
478
|
+
# starts and before it writes `.plot-worker.pid`; a scan landing in it reads an
|
|
479
|
+
# absent pid file as `none` — honest, never "running" off a stale value.
|
|
480
|
+
#
|
|
481
|
+
# THE WRAPPER ALSO STAMPS THE MANIFEST PID, for the same reason it writes the
|
|
482
|
+
# pid file: it is the one process that knows the agent's own pid. The manifest
|
|
483
|
+
# path travels as `PLOT_MANIFEST_FILE`, beside the exit/pid paths, so no quoting
|
|
484
|
+
# level inside the single-quoted `sh -c` mangles a path with spaces. The stamp
|
|
485
|
+
# is inline `awk` rather than a bash helper, because a helper would live in this
|
|
486
|
+
# bash script and the detached `sh -c` is a fresh shell with no access to it —
|
|
487
|
+
# the same isolation that makes the wrapper own the pid.
|
|
488
|
+
#
|
|
489
|
+
# ONE CONTRACT, TWO IMPLEMENTATIONS. This inline `awk` is the mechanical twin of
|
|
490
|
+
# `manifest-stamp.ts`'s `stampManifest`; `/api/continue` calls that helper, and a
|
|
491
|
+
# parity test (`manifest-stamp-parity.test.ts`) runs THIS awk against the same
|
|
492
|
+
# inputs and asserts a byte-identical result. The two exist because the callers
|
|
493
|
+
# cannot share code — a detached `sh -c` reaches no TypeScript, and a bash helper
|
|
494
|
+
# is out of a fresh shell's reach — but they must not drift, the
|
|
495
|
+
# `plot-worker-state.sh` lesson after five of six states diverged in duplicate.
|
|
496
|
+
#
|
|
497
|
+
# It replaces ANY `pid` line, not only the empty placeholder — a full-line
|
|
498
|
+
# anchored match on bytes we control, so nothing in the command value (one
|
|
499
|
+
# escaped JSON string on its own line) can be mistaken for it. On a FIRST
|
|
500
|
+
# dispatch the placeholder is empty: the pid is filled and nothing else changes,
|
|
501
|
+
# byte-identical to the manifest before relaunch bookkeeping existed. On a
|
|
502
|
+
# RELAUNCH the line already holds a pid: it is overwritten, `startedAt` is
|
|
503
|
+
# rewritten to now, and two lines are inserted after `pid` — `previousPid` (the
|
|
504
|
+
# corpse displaced) and `relaunches` (the restart count, +1 from any it carried).
|
|
505
|
+
# The dispatcher mints a fresh session per launch so its own manifest is always a
|
|
506
|
+
# first stamp; the relaunch arms exist for parity with `/api/continue`, which
|
|
507
|
+
# reuses a worktree's existing manifest. A pid is digits, so no JSON escaping is
|
|
508
|
+
# needed. Rewritten through a temp file and `mv`, atomic like the original write.
|
|
509
|
+
# Any failure leaves the pid untouched — the registry reads an absent one as
|
|
510
|
+
# `unknown`.
|
|
511
|
+
#
|
|
512
|
+
# The awk reads the manifest TWICE — the same file passed as two arguments, so
|
|
513
|
+
# `FNR==NR` is the pre-scan. Pass one learns whether the pid is already filled
|
|
514
|
+
# (a relaunch) and the count any prior `relaunches` line held; pass two rewrites.
|
|
515
|
+
# This mirror of a two-pass read is what lets a SECOND relaunch increment rather
|
|
516
|
+
# than reset: the old count sits AFTER the pid line, so a single pass could not
|
|
517
|
+
# know it when it must emit the new `relaunches` immediately after `pid`.
|
|
518
|
+
#
|
|
519
|
+
# On the pid line: an empty placeholder is filled and nothing else changes (a
|
|
520
|
+
# first stamp, byte-identical to before); a filled pid is overwritten and the
|
|
521
|
+
# two relaunch records — `previousPid` then `relaunches` — are emitted right
|
|
522
|
+
# after it, then any stale copies of those lines are dropped and `startedAt` is
|
|
523
|
+
# rewritten to the current run. This is exactly `stampManifest`, line for line,
|
|
524
|
+
# which the parity test pins byte for byte.
|
|
525
|
+
local stamp_now
|
|
526
|
+
stamp_now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
527
|
+
( cd "$wt" && PLOT_BRANCH="$branch" PLOT_WORKTREE="$wt" \
|
|
528
|
+
PLOT_SLUG="$slug" \
|
|
529
|
+
PLOT_SESSION_ID="$session" \
|
|
530
|
+
PLOT_MANIFEST_FILE="$manifest_dir/$session.json" \
|
|
531
|
+
PLOT_STAMP_STARTED="$stamp_now" \
|
|
532
|
+
PLOT_EXIT_FILE="$wt/.plot-worker.exit" PLOT_PID_FILE="$wt/.plot-worker.pid" \
|
|
533
|
+
nohup sh -c '( '"$cmd"' ) & agent=$!; printf "%s" "$agent" > "$PLOT_PID_FILE"; if [ -f "$PLOT_MANIFEST_FILE" ]; then awk -v pid="$agent" -v started="$PLOT_STAMP_STARTED" '"'"'
|
|
534
|
+
BEGIN { relaunch = 0; count = 1; stamped = 0 }
|
|
535
|
+
FNR == NR {
|
|
536
|
+
if ($0 ~ /^ "pid": "[^"]*",$/) {
|
|
537
|
+
p = $0; sub(/^ "pid": "/, "", p); sub(/",$/, "", p)
|
|
538
|
+
if (p != "") { relaunch = 1; displaced = p }
|
|
539
|
+
}
|
|
540
|
+
if ($0 ~ /^ "relaunches": [0-9]+,$/) {
|
|
541
|
+
n = $0; sub(/^ "relaunches": /, "", n); sub(/,$/, "", n); count = n + 1
|
|
542
|
+
}
|
|
543
|
+
next
|
|
544
|
+
}
|
|
545
|
+
!stamped && $0 ~ /^ "pid": "[^"]*",$/ {
|
|
546
|
+
stamped = 1
|
|
547
|
+
print " \"pid\": \"" pid "\","
|
|
548
|
+
if (relaunch) {
|
|
549
|
+
print " \"previousPid\": \"" displaced "\","
|
|
550
|
+
print " \"relaunches\": " count ","
|
|
551
|
+
}
|
|
552
|
+
next
|
|
553
|
+
}
|
|
554
|
+
relaunch && $0 ~ /^ "previousPid": "[^"]*",$/ { next }
|
|
555
|
+
relaunch && $0 ~ /^ "relaunches": [0-9]+,$/ { next }
|
|
556
|
+
relaunch && $0 ~ /^ "startedAt": "[^"]*"$/ { print " \"startedAt\": \"" started "\""; next }
|
|
557
|
+
{ print }
|
|
558
|
+
'"'"' "$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 & echo $! >"$wt/.plot-worker.wrapper.pid" )
|
|
560
|
+
echo " started worker (log: $log)"
|
|
561
|
+
return 0
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
# ---------------------------------------------------------------------------
|
|
565
|
+
# Inspection and shutdown
|
|
566
|
+
# ---------------------------------------------------------------------------
|
|
567
|
+
#
|
|
568
|
+
# Deliberately BEFORE the phase gate: work that is already running must stay
|
|
569
|
+
# inspectable and stoppable even if the plan was since delivered or rejected.
|
|
570
|
+
# Refusing to show a running worker because of a phase change would strand it.
|
|
571
|
+
repo_root_early=$(git rev-parse --show-toplevel)
|
|
572
|
+
resolve_wt_root "$repo_root_early"
|
|
573
|
+
wt_root_early="$wt_root"
|
|
574
|
+
wt_prefix_early="$wt_prefix"
|
|
575
|
+
|
|
576
|
+
# States: "running <pid>" | "finished <pid>" | "waiting <pid> (answer it)"
|
|
577
|
+
# | "stalled <pid> (work unfinished)" | "failed <pid> (exit N)"
|
|
578
|
+
# | "ended <pid> (status unknown)" | "no worker"
|
|
579
|
+
#
|
|
580
|
+
# THE CLASSIFICATION LIVES IN plot-worker-state.sh, sourced above and shared
|
|
581
|
+
# with plot-fleet-scan.sh. This function is now only the RENDERING half: it
|
|
582
|
+
# turns the shared facts into the prose `--status` has always printed. The scan
|
|
583
|
+
# renders the same facts as tab-separated JSON fields.
|
|
584
|
+
#
|
|
585
|
+
# The two copies of this logic agreed on five of six states and split on the
|
|
586
|
+
# sixth (a non-numeric exit code), which is what a duplicate does while nobody
|
|
587
|
+
# is looking. `no worker` is spelled that way HERE and `none` in the scan —
|
|
588
|
+
# both are the shared `none`, rendered for their own audience.
|
|
589
|
+
# Has this branch's work reached review — an open or merged PR?
|
|
590
|
+
#
|
|
591
|
+
# ASKED HERE TOO, AND THAT IS THE POINT. The PR fact outranks every local signal
|
|
592
|
+
# in the classification, so a consumer that cannot supply it reports `stalled`
|
|
593
|
+
# where the other reports `finished` — the same one-fact-two-verdicts drift
|
|
594
|
+
# wave 1 removed, re-entering through the new parameter. The contract test
|
|
595
|
+
# drives both consumers from one fixture precisely to catch that.
|
|
596
|
+
#
|
|
597
|
+
# AFFORDABLE HERE, unlike in the scan's inner loop. `--status` runs when a
|
|
598
|
+
# person types it and iterates the handful of `plot-wt-*` worktrees on this
|
|
599
|
+
# disk; the scan is polled by the board every 5 s across every branch of every
|
|
600
|
+
# plan, which is why IT caches one reply per branch per run. Same question, two
|
|
601
|
+
# costs, and only one of them needs the machinery.
|
|
602
|
+
#
|
|
603
|
+
# `--offline` IS HONOURED, because it promises no network and a flag that lied
|
|
604
|
+
# would be worse than a slower answer. Offline, or with no backend, the fact is
|
|
605
|
+
# simply not supplied and the local signals answer alone — `stalled` rather
|
|
606
|
+
# than `finished`, which sends a reader to look rather than telling them to stop.
|
|
607
|
+
reached_review() { # $1=branch → 0 when an open or merged PR exists
|
|
608
|
+
[ -z "$offline" ] || return 1
|
|
609
|
+
[ -n "$1" ] && [ "$1" != "?" ] || return 1
|
|
610
|
+
[ "$("$script_dir/plot-host.sh" backend 2>/dev/null)" != "none" ] || return 1
|
|
611
|
+
local js st
|
|
612
|
+
# Exit code first: a non-zero is a transport failure and its stdout is not an
|
|
613
|
+
# answer. GitHub returned 503 all afternoon on 2026-08-17, and a reader that
|
|
614
|
+
# trusted the payload on failure would have called every branch reviewed.
|
|
615
|
+
js=$("$script_dir/plot-host.sh" pr-state "$1" </dev/null 2>/dev/null) || return 1
|
|
616
|
+
st=$(printf '%s' "$js" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
|
|
617
|
+
case "$st" in OPEN|MERGED) return 0 ;; *) return 1 ;; esac
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
worker_state() { # $1=worktree [$2=branch]
|
|
621
|
+
local row state pid code pr_fact=""
|
|
622
|
+
reached_review "${2:-}" && pr_fact="pr"
|
|
623
|
+
row=$(plot_worker_state "$1" "$pr_fact")
|
|
624
|
+
state=$(printf '%s' "$row" | cut -f1)
|
|
625
|
+
pid=$(printf '%s' "$row" | cut -f2)
|
|
626
|
+
code=$(printf '%s' "$row" | cut -f3)
|
|
627
|
+
case "$state" in
|
|
628
|
+
running) echo "running $pid" ;;
|
|
629
|
+
finished) echo "finished $pid" ;;
|
|
630
|
+
# THE TWO TASK STATES, rendered as prose here and as bare words in the
|
|
631
|
+
# scan's JSON — one computation, two renderings, the split this file's
|
|
632
|
+
# `worker_state` exists to keep. Each names the move rather than the
|
|
633
|
+
# condition: a reader of `--status` is deciding what to do next, and
|
|
634
|
+
# "answer it" versus "resume it" is that decision.
|
|
635
|
+
waiting) echo "waiting $pid (answer it)" ;;
|
|
636
|
+
stalled) echo "stalled $pid (work unfinished)" ;;
|
|
637
|
+
failed) echo "failed $pid (exit $code)" ;;
|
|
638
|
+
ended) echo "ended $pid (status unknown)" ;;
|
|
639
|
+
*) echo "no worker" ;;
|
|
640
|
+
esac
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if [ "$mode" = "status" ]; then
|
|
644
|
+
n_live=0 n_done=0 n_waiting=0 n_stalled=0 n_failed=0 n_ended=0 n_none=0
|
|
645
|
+
for wt in "$wt_root_early"/"$wt_prefix_early"*; do
|
|
646
|
+
[ -d "$wt" ] || continue
|
|
647
|
+
br=$(git -C "$wt" branch --show-current 2>/dev/null || echo "?")
|
|
648
|
+
st=$(worker_state "$wt" "$br")
|
|
649
|
+
case "$st" in
|
|
650
|
+
running*) n_live=$((n_live + 1)) ;;
|
|
651
|
+
finished*) n_done=$((n_done + 1)) ;;
|
|
652
|
+
waiting*) n_waiting=$((n_waiting + 1)) ;;
|
|
653
|
+
stalled*) n_stalled=$((n_stalled + 1)) ;;
|
|
654
|
+
failed*) n_failed=$((n_failed + 1)) ;;
|
|
655
|
+
ended*) n_ended=$((n_ended + 1)) ;;
|
|
656
|
+
*) n_none=$((n_none + 1)) ;;
|
|
657
|
+
esac
|
|
658
|
+
echo " $br — $st"
|
|
659
|
+
echo " worktree: $wt"
|
|
660
|
+
if [ -f "$wt/.plot-worker.log" ]; then
|
|
661
|
+
echo " log: $wt/.plot-worker.log"
|
|
662
|
+
echo " last: $(tail -1 "$wt/.plot-worker.log" 2>/dev/null)"
|
|
663
|
+
fi
|
|
664
|
+
done
|
|
665
|
+
[ $((n_live + n_done + n_waiting + n_stalled + n_failed + n_ended + n_none)) -gt 0 ] \
|
|
666
|
+
|| echo " (no fleet worktrees under $wt_root_early)"
|
|
667
|
+
echo "summary: running=$n_live finished=$n_done waiting=$n_waiting stalled=$n_stalled failed=$n_failed ended=$n_ended no_worker=$n_none"
|
|
668
|
+
exit 0
|
|
669
|
+
fi
|
|
670
|
+
|
|
671
|
+
if [ "$mode" = "stop" ]; then
|
|
672
|
+
# An explicit branch is REQUIRED. A --stop that could mean "all" is one
|
|
673
|
+
# fat-finger away from killing a whole fleet.
|
|
674
|
+
if [ -z "$stop_branch" ]; then
|
|
675
|
+
echo "plot-dispatch: --stop needs a branch name, e.g. --stop feature/x" >&2
|
|
676
|
+
echo " Refusing to guess — stopping the wrong worker discards its work." >&2
|
|
677
|
+
exit 1
|
|
678
|
+
fi
|
|
679
|
+
wt="$wt_root_early/$wt_prefix_early$(printf '%s' "$stop_branch" | tr '/' '-')"
|
|
680
|
+
[ -d "$wt" ] || { echo "plot-dispatch: no worktree for '$stop_branch' at $wt" >&2; exit 1; }
|
|
681
|
+
st=$(worker_state "$wt" "$stop_branch")
|
|
682
|
+
case "$st" in
|
|
683
|
+
running*)
|
|
684
|
+
pid=${st#running }
|
|
685
|
+
kill "$pid" 2>/dev/null && echo "stopped $stop_branch (pid $pid)" \
|
|
686
|
+
|| { echo "plot-dispatch: could not stop pid $pid" >&2; exit 1; }
|
|
687
|
+
# The worktree and its claim are left in place: the branch is still taken,
|
|
688
|
+
# and deleting either would be the kind of write this design avoids.
|
|
689
|
+
echo " worktree kept at $wt — the claim stands until you release it"
|
|
690
|
+
;;
|
|
691
|
+
finished*|waiting*|stalled*|failed*|ended*) echo "$stop_branch is not running ($st)" ;;
|
|
692
|
+
*) echo "$stop_branch has no worker" ;;
|
|
693
|
+
esac
|
|
694
|
+
exit 0
|
|
695
|
+
fi
|
|
696
|
+
|
|
697
|
+
if [ "$mode" = "restart" ]; then
|
|
698
|
+
# THE COUNTERPART TO --stop, AND THE WHOLE FEATURE. `--stop` kills a worker;
|
|
699
|
+
# nothing started one on a branch that already holds a claim, because the
|
|
700
|
+
# dispatcher asks the scan for `--next` and `--next` fills claimable[] only
|
|
701
|
+
# where the branch is `open` — meaning NO REF EXISTS. A branch that has ever
|
|
702
|
+
# been claimed is `claimed` or `wip`, so it was never offered and
|
|
703
|
+
# `plot-dispatch.sh <slug>` answered `dispatched=0`: not a refusal with a
|
|
704
|
+
# reason, an empty set, which has nothing to say about what it filtered out.
|
|
705
|
+
#
|
|
706
|
+
# That `open`-only rule is Plot's LOCK and must not widen — three callers
|
|
707
|
+
# consume `--next`, and the board's auto-dispatch would begin restarting
|
|
708
|
+
# stalled work on a five-second timer with nobody deciding anything. So this
|
|
709
|
+
# is a SECOND QUESTION, asked only when a person asks it.
|
|
710
|
+
#
|
|
711
|
+
# HERE, BESIDE --stop AND BEFORE THE PHASE GATE, for the reason that block
|
|
712
|
+
# already gives: a branch that is already claimed and already has a worktree
|
|
713
|
+
# is work in flight, and the plan's phase says nothing about whether a
|
|
714
|
+
# stopped worker on it should be replaced. Refusing on phase would strand
|
|
715
|
+
# exactly the branch this exists to rescue.
|
|
716
|
+
if [ -z "$restart_branch" ]; then
|
|
717
|
+
echo "plot-dispatch: --restart needs a branch name, e.g. --restart feature/x" >&2
|
|
718
|
+
echo " A slug is not enough: which stopped branch to hand to a new worker" >&2
|
|
719
|
+
echo " is your call, not something this should guess." >&2
|
|
720
|
+
exit 1
|
|
721
|
+
fi
|
|
722
|
+
|
|
723
|
+
# ASK GIT WHICH WORKTREE HOLDS THE BRANCH — never rebuild the path from the
|
|
724
|
+
# name. This file's own rule (see resolve_wt_root): path-guessing is confined
|
|
725
|
+
# to CREATION, because a second naming convention gives it a second way to be
|
|
726
|
+
# wrong. It matters more here than anywhere: the population this verb serves
|
|
727
|
+
# includes the worktree a person made by hand after the tool had no verb for
|
|
728
|
+
# them, and a hand-made worktree rarely follows dispatch's naming.
|
|
729
|
+
restart_wt=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$restart_branch" '
|
|
730
|
+
/^worktree / { path = substr($0, 10) }
|
|
731
|
+
/^branch / { if (substr($0, 8) == want) { print path; exit } }')
|
|
732
|
+
if [ -z "$restart_wt" ] || [ ! -d "$restart_wt" ]; then
|
|
733
|
+
echo "plot-dispatch: no worktree holds '$restart_branch' — nothing to restart." >&2
|
|
734
|
+
echo " --restart hands an EXISTING checkout to a new worker; it creates none." >&2
|
|
735
|
+
echo " To start this branch fresh, dispatch its plan." >&2
|
|
736
|
+
exit 1
|
|
737
|
+
fi
|
|
738
|
+
|
|
739
|
+
# THE PR IS ASKED FIRST, BEFORE THE STATE WORD. This ordering is the round-one
|
|
740
|
+
# correction to the plan, and it comes from a measurement: five of five
|
|
741
|
+
# `failed` worktrees in this estate held a PR — four open, one already merged.
|
|
742
|
+
#
|
|
743
|
+
# `plot-worker-state.sh` refines `finished` by the TREE (an open or merged PR
|
|
744
|
+
# turns it into "the work reached review") but deliberately does NOT refine
|
|
745
|
+
# `failed`, `ended` or `none`, because "a recorded non-zero exit is already a
|
|
746
|
+
# specific answer about the process." True about the PROCESS, and silent about
|
|
747
|
+
# the WORK: a worker that opened its PR and then exited non-zero reads
|
|
748
|
+
# `failed` with nothing left to redo. A gate written on the state word alone
|
|
749
|
+
# would have restarted all five and discarded exactly what the `finished`
|
|
750
|
+
# refusal exists to protect.
|
|
751
|
+
#
|
|
752
|
+
# Same lesson plot-reap.sh learned from the other side: it reads `mergedAt`
|
|
753
|
+
# and never `state`, because a merged PR reports CLOSED. There the state word
|
|
754
|
+
# lies about merging; here the exit code lies about completion.
|
|
755
|
+
if reached_review "$restart_branch"; then
|
|
756
|
+
pr_json=$("$script_dir/plot-host.sh" pr-state "$restart_branch" </dev/null 2>/dev/null || true)
|
|
757
|
+
pr_num=$(printf '%s' "$pr_json" | sed -n 's/.*"number":\([0-9]*\).*/\1/p')
|
|
758
|
+
pr_state=$(printf '%s' "$pr_json" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
|
|
759
|
+
echo "plot-dispatch: $restart_branch has a pull request (#${pr_num:-?}, ${pr_state:-OPEN}) — refusing." >&2
|
|
760
|
+
echo " The work reached review, whatever the worker's exit code says. A" >&2
|
|
761
|
+
echo " restart here redoes work someone is already looking at." >&2
|
|
762
|
+
echo " Review it, or reap the worktree once it merges." >&2
|
|
763
|
+
exit 1
|
|
764
|
+
fi
|
|
765
|
+
|
|
766
|
+
# Only now the process. `worker_state` is the ONE answer to "is a worker
|
|
767
|
+
# running here" — asked rather than re-derived, so this cannot drift from
|
|
768
|
+
# `--status` and the scan the way five of six states already did once.
|
|
769
|
+
restart_state=$(worker_state "$restart_wt" "$restart_branch")
|
|
770
|
+
case "$restart_state" in
|
|
771
|
+
running*)
|
|
772
|
+
# THE REFUSAL THAT PREVENTS TWO WORKERS ON ONE BRANCH. There is no
|
|
773
|
+
# --force: a flag overriding this is the flag typed reflexively, and what
|
|
774
|
+
# it would override is another agent's work in progress.
|
|
775
|
+
echo "plot-dispatch: a worker is alive on $restart_branch (pid ${restart_state#running }) — refusing." >&2
|
|
776
|
+
echo " Stop it first if you mean to replace it:" >&2
|
|
777
|
+
echo " plot-dispatch.sh --stop $restart_branch" >&2
|
|
778
|
+
exit 1
|
|
779
|
+
;;
|
|
780
|
+
waiting*)
|
|
781
|
+
# A person owes this branch an answer. A new worker meets the same
|
|
782
|
+
# question and writes the same marker.
|
|
783
|
+
# ASKED, NOT RE-GLOBBED. The marker's spelling lives with the
|
|
784
|
+
# classification in plot-worker-state.sh and only there; a copy of the
|
|
785
|
+
# glob here is the drift `workerstate.test.mjs` pins against.
|
|
786
|
+
marker=$(plot_worker_blocked_file "$restart_wt" || true)
|
|
787
|
+
echo "plot-dispatch: $restart_branch is blocked on a question — refusing." >&2
|
|
788
|
+
echo " the question is in $restart_wt/${marker:-the marker file}" >&2
|
|
789
|
+
echo " Answer it and delete the marker, then restart." >&2
|
|
790
|
+
exit 1
|
|
791
|
+
;;
|
|
792
|
+
esac
|
|
793
|
+
# `stalled`, `failed`, `ended` and `no worker` all restart. The PR question
|
|
794
|
+
# above is what makes `failed` safe to include — and including it is the
|
|
795
|
+
# point: a gate that simply refused `failed` would pass every refusal test
|
|
796
|
+
# here and leave the verb unable to do the one thing it exists for.
|
|
797
|
+
|
|
798
|
+
echo "restarting $restart_branch ($restart_state)"
|
|
799
|
+
echo " worktree: $restart_wt"
|
|
800
|
+
|
|
801
|
+
# THE TREE IS INHERITED EXACTLY AS IT STANDS. A `stalled` worktree holds
|
|
802
|
+
# uncommitted work — that is what `stalled` MEANS, and a measured stall in
|
|
803
|
+
# this repo left 324 finished lines on the floor. Nothing here cleans,
|
|
804
|
+
# resets or stashes: a restart that discards that is worse than the missing
|
|
805
|
+
# affordance, because it looks like a supported operation. The new worker's
|
|
806
|
+
# brief already tells it to commit and push before verifying.
|
|
807
|
+
if [ -n "$(git -C "$restart_wt" status --porcelain </dev/null 2>/dev/null)" ]; then
|
|
808
|
+
echo " uncommitted work in the tree is kept — the new worker inherits it"
|
|
809
|
+
fi
|
|
810
|
+
|
|
811
|
+
# start_worker is the ORDINARY DISPATCH PATH, and reusing it is half this
|
|
812
|
+
# feature. The bypass this plan replaces produced an unregistered agent, so
|
|
813
|
+
# the board showed a branch name in the agent-name slot; a restart that
|
|
814
|
+
# spawned a worker without a manifest would reproduce the exact defect it
|
|
815
|
+
# exists to prevent. One writer, so the two cannot drift.
|
|
816
|
+
#
|
|
817
|
+
# These two globals are set below the phase gate, which this path exits
|
|
818
|
+
# before reaching. `slug` stays empty on purpose: a restart is not a plan
|
|
819
|
+
# fan-out, and the manifest records what this line actually knows.
|
|
820
|
+
repo_root="$repo_root_early"
|
|
821
|
+
worker_cmd_declined=0
|
|
822
|
+
case "$("$script_dir/plot-config.sh" get "Worker command" "")" in
|
|
823
|
+
none|NONE|None) worker_cmd_declined=1 ;;
|
|
824
|
+
esac
|
|
825
|
+
start_worker "$restart_branch" "$restart_wt" || exit 1
|
|
826
|
+
exit 0
|
|
827
|
+
fi
|
|
828
|
+
|
|
829
|
+
# ---------------------------------------------------------------------------
|
|
830
|
+
# Migration mode: move legacy worktrees into the configured root
|
|
831
|
+
# ---------------------------------------------------------------------------
|
|
832
|
+
#
|
|
833
|
+
# THE REFUSALS ARE THE FEATURE. `git worktree move` on a checkout an agent is
|
|
834
|
+
# writing to breaks it mid-run. So this mode moves a worktree only when it has
|
|
835
|
+
# NO LIVE WORKER AND NO UNLANDED WORK, and names every one it skipped with the
|
|
836
|
+
# reason. Modelled on plot-reap.sh, which refuses on five MEASUREMENTS rather
|
|
837
|
+
# than judgements.
|
|
838
|
+
#
|
|
839
|
+
# A MIXED ESTATE IS AN ORDINARY STATE, NOT A TRANSITION TO COMPLETE. Existing
|
|
840
|
+
# worktrees stay where they are and keep working; every read asks git, so a
|
|
841
|
+
# mixed estate is not a special case. `--migrate` must never be required — a
|
|
842
|
+
# repo that adopts `Worktree root:` and never migrates is correctly configured.
|
|
843
|
+
#
|
|
844
|
+
# That is why this is opt-in and idempotent rather than automatic, and why a
|
|
845
|
+
# worktree it refuses is not an error.
|
|
846
|
+
if [ "$mode" = "migrate" ]; then
|
|
847
|
+
# Resolve where worktrees SHOULD go — the configured root.
|
|
848
|
+
configured_root=$("$script_dir/plot-config.sh" get "Worktree root" "")
|
|
849
|
+
if [ -z "$configured_root" ]; then
|
|
850
|
+
echo "plot-dispatch --migrate: no 'Worktree root:' configured — nothing to migrate."
|
|
851
|
+
echo " When Worktree root is absent, worktrees live beside the repo (plot-wt-*)."
|
|
852
|
+
echo " To migrate, first add a 'Worktree root:' key to ## Plot Config."
|
|
853
|
+
exit 0
|
|
854
|
+
fi
|
|
855
|
+
|
|
856
|
+
# Resolve the target root to an absolute path.
|
|
857
|
+
case "$configured_root" in
|
|
858
|
+
/*) target_root="$configured_root" ;;
|
|
859
|
+
*) target_root="$repo_root_early/$configured_root" ;;
|
|
860
|
+
esac
|
|
861
|
+
target_root="${target_root%/}"
|
|
862
|
+
|
|
863
|
+
# Create the target directory if needed.
|
|
864
|
+
if [ "$migrate_yes" = 1 ] && [ ! -d "$target_root" ]; then
|
|
865
|
+
mkdir -p "$target_root" 2>/dev/null || {
|
|
866
|
+
echo "plot-dispatch --migrate: cannot create '$target_root'" >&2
|
|
867
|
+
exit 1
|
|
868
|
+
}
|
|
869
|
+
fi
|
|
870
|
+
|
|
871
|
+
# The legacy location: beside the repo, with `plot-wt-` prefix.
|
|
872
|
+
legacy_root=$(cd "$repo_root_early/.." && pwd)
|
|
873
|
+
legacy_prefix="plot-wt-"
|
|
874
|
+
|
|
875
|
+
# If the configured root is the same as the legacy root, there is nothing to
|
|
876
|
+
# migrate — the worktrees are already in the right place (only the prefix
|
|
877
|
+
# would change, and renaming worktrees for a prefix is not worth the churn).
|
|
878
|
+
if [ "$target_root" = "$legacy_root" ]; then
|
|
879
|
+
echo "plot-dispatch --migrate: target root matches legacy root ($legacy_root)."
|
|
880
|
+
echo " Worktrees are already in the right place — nothing to migrate."
|
|
881
|
+
exit 0
|
|
882
|
+
fi
|
|
883
|
+
|
|
884
|
+
n_moved=0 n_skipped=0 n_would=0
|
|
885
|
+
dry_label="would move"
|
|
886
|
+
[ "$migrate_yes" = 1 ] && dry_label="moved"
|
|
887
|
+
|
|
888
|
+
printf '%-8s %-52s %s\n' "verdict" "worktree" "reason"
|
|
889
|
+
|
|
890
|
+
for wt in "$legacy_root"/"$legacy_prefix"*; do
|
|
891
|
+
[ -d "$wt" ] || continue
|
|
892
|
+
# Extract the branch from the worktree.
|
|
893
|
+
br=$(git -C "$wt" branch --show-current 2>/dev/null || echo "")
|
|
894
|
+
|
|
895
|
+
# If we hit --max, stop processing.
|
|
896
|
+
if [ "$max" -gt 0 ] && [ "$((n_moved + n_would))" -ge "$max" ]; then
|
|
897
|
+
printf '%-8s %-52s %s\n' "keep" "$(basename "$wt")" "--max $max reached"
|
|
898
|
+
n_skipped=$((n_skipped + 1))
|
|
899
|
+
continue
|
|
900
|
+
fi
|
|
901
|
+
|
|
902
|
+
# TWO INDEPENDENT CONDITIONS, because the brief names two: a worktree moves
|
|
903
|
+
# only with NO LIVE WORKER **AND** NO UNLANDED WORK. They are separate
|
|
904
|
+
# measurements, exactly as they are in plot-reap.sh, and folding them into
|
|
905
|
+
# one verdict is a hole: plot_worker_state answers "is a WORKER running or
|
|
906
|
+
# waiting here", and it is keyed on the worker RECORDS (`.plot-worker.pid`,
|
|
907
|
+
# `.plot-worker.exit`). A hand-made worktree that never ran a Plot worker has
|
|
908
|
+
# no records and reads `none` no matter how dirty its tree is — and the
|
|
909
|
+
# hand-made worktrees are precisely the estate this mode exists to tidy. So
|
|
910
|
+
# liveness and unlanded-work are asked as two questions below.
|
|
911
|
+
|
|
912
|
+
# REFUSAL 1 & 2 — a LIVE WORKER, from the ONE shared answer. The brief is
|
|
913
|
+
# explicit: plot_worker_state is the single answer to "is a worker running
|
|
914
|
+
# in this worktree", sourced by both dispatch and the fleet scan. It carries
|
|
915
|
+
# what a bare `ps` cannot — pid-reuse detection via the manifest's
|
|
916
|
+
# `startedAt`, and the `waiting` state a PLOT-BLOCKED* marker produces.
|
|
917
|
+
# Re-implementing either here is the drift the codebase fought to remove.
|
|
918
|
+
wstate_row=$(plot_worker_state "$wt")
|
|
919
|
+
state=$(printf '%s' "$wstate_row" | cut -f1)
|
|
920
|
+
case "$state" in
|
|
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
|
|
934
|
+
|
|
935
|
+
# REFUSAL 3 — UNCOMMITTED WORK, measured independently of any worker record.
|
|
936
|
+
# `plot_worker_dirty` applies the shared filter (editor leftovers and Plot's
|
|
937
|
+
# own bookkeeping do not count), so this fires on real work only.
|
|
938
|
+
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
|
+
|
|
945
|
+
# REFUSAL 4 — UNPUSHED COMMITS. Work that exists only on this machine.
|
|
946
|
+
# Only the branch's OWN upstream answers "pushed?"; an absent upstream leaves
|
|
947
|
+
# the question unanswerable, and an unanswered question is not a refusal —
|
|
948
|
+
# the same principle plot_worker_task_state reached the hard way (counting
|
|
949
|
+
# against origin/main marked every clean branch stalled in a remote-less
|
|
950
|
+
# repo). So no upstream falls through to "movable", not to "keep".
|
|
951
|
+
if [ -n "$br" ]; then
|
|
952
|
+
ahead=$(git -C "$wt" rev-list --count '@{upstream}..HEAD' 2>/dev/null || echo "")
|
|
953
|
+
case "$ahead" in
|
|
954
|
+
''|0|*[!0-9]*) ;; # no upstream, or nothing ahead: not a refusal
|
|
955
|
+
*) printf '%-8s %-52s %s\n' "keep" "$(basename "$wt")" "unpushed commits ($ahead ahead)"
|
|
956
|
+
n_skipped=$((n_skipped + 1))
|
|
957
|
+
continue ;;
|
|
958
|
+
esac
|
|
959
|
+
fi
|
|
960
|
+
|
|
961
|
+
# This worktree is idle — it can be moved.
|
|
962
|
+
# Compute the destination path: the target root plus the branch name
|
|
963
|
+
# (flattened, no prefix).
|
|
964
|
+
if [ -n "$br" ]; then
|
|
965
|
+
dest_name=$(printf '%s' "$br" | tr '/' '-')
|
|
966
|
+
else
|
|
967
|
+
# Fallback: use the existing directory name minus the legacy prefix.
|
|
968
|
+
dest_name=$(basename "$wt")
|
|
969
|
+
dest_name="${dest_name#$legacy_prefix}"
|
|
970
|
+
fi
|
|
971
|
+
dest="$target_root/$dest_name"
|
|
972
|
+
|
|
973
|
+
if [ "$migrate_yes" = 1 ]; then
|
|
974
|
+
# Actually move the worktree.
|
|
975
|
+
if git worktree move "$wt" "$dest" 2>/dev/null; then
|
|
976
|
+
printf '%-8s %-52s %s\n' "moved" "$(basename "$wt")" "→ $dest"
|
|
977
|
+
n_moved=$((n_moved + 1))
|
|
978
|
+
else
|
|
979
|
+
printf '%-8s %-52s %s\n' "FAILED" "$(basename "$wt")" "git worktree move refused"
|
|
980
|
+
n_skipped=$((n_skipped + 1))
|
|
981
|
+
fi
|
|
982
|
+
else
|
|
983
|
+
printf '%-8s %-52s %s\n' "would" "$(basename "$wt")" "→ $dest"
|
|
984
|
+
n_would=$((n_would + 1))
|
|
985
|
+
fi
|
|
986
|
+
done
|
|
987
|
+
|
|
988
|
+
if [ "$migrate_yes" = 1 ]; then
|
|
989
|
+
echo "summary: moved=$n_moved skipped=$n_skipped"
|
|
990
|
+
else
|
|
991
|
+
echo "summary: would_move=$n_would skipped=$n_skipped dry_run=1"
|
|
992
|
+
if [ "$n_would" -gt 0 ]; then
|
|
993
|
+
echo " Run with --yes to actually move the worktrees."
|
|
994
|
+
fi
|
|
995
|
+
fi
|
|
996
|
+
exit 0
|
|
997
|
+
fi
|
|
998
|
+
|
|
999
|
+
# ---------------------------------------------------------------------------
|
|
1000
|
+
# Phase and ceremony gate
|
|
1001
|
+
# ---------------------------------------------------------------------------
|
|
1002
|
+
#
|
|
1003
|
+
# A GATE, not a rule (CLAUDE.md, "Gates Over Rules"): the check lives here, in
|
|
1004
|
+
# the script, because prose in a SKILL.md is something an agent can rationalise
|
|
1005
|
+
# around and a human calling this script directly bypasses entirely. Fanning
|
|
1006
|
+
# out is the one place a user can do real damage — branches and worker
|
|
1007
|
+
# processes for a plan nobody approved.
|
|
1008
|
+
#
|
|
1009
|
+
# FAIL CLOSED, unlike plot-phase-gate.sh. That one is a PreToolUse hook, so a
|
|
1010
|
+
# broken gate would lock every commit in the repo and it must fail open. This
|
|
1011
|
+
# is a command the user invoked: if the plan's phase cannot be read, refusing
|
|
1012
|
+
# costs one confused re-run, while proceeding costs several agents doing
|
|
1013
|
+
# unapproved work. The damage is asymmetric, so the default is too.
|
|
1014
|
+
#
|
|
1015
|
+
# THE PHASE IS READ FROM THE SHARED REF, NOT THE WORKING TREE. The working tree
|
|
1016
|
+
# is the least trustworthy surface in a repo with several agents in it: it
|
|
1017
|
+
# carries whatever branch was last checked out plus whatever is uncommitted,
|
|
1018
|
+
# and neither is a fact anyone else shares. Reading it got this gate wrong in
|
|
1019
|
+
# BOTH directions, both reproduced 2026-08-18:
|
|
1020
|
+
#
|
|
1021
|
+
# - it REFUSED approved work, when a concurrent agent's `git checkout` parked
|
|
1022
|
+
# the shared checkout on a branch carrying an older copy of the plan. The
|
|
1023
|
+
# approval was on origin/<main> the whole time.
|
|
1024
|
+
# - it PERMITTED unapproved work, when an approval was committed to a local
|
|
1025
|
+
# branch and never pushed. Manifesto P2 is "plans are approved before
|
|
1026
|
+
# implementation"; a gate that accepts an approval nobody else can see
|
|
1027
|
+
# enforces "someone typed Approved in this filesystem" instead.
|
|
1028
|
+
#
|
|
1029
|
+
# So the question the gate asks is: has this plan been approved WHERE EVERYONE
|
|
1030
|
+
# CAN SEE IT? `git show origin/<main>:<path>` is that question. There is
|
|
1031
|
+
# deliberately NO fallback to the working tree — that would reintroduce the bug
|
|
1032
|
+
# exactly where nothing can catch it. --allow-local is the explicit escape, and
|
|
1033
|
+
# it is named in the refusal so an operator learns it exists when they need it.
|
|
1034
|
+
MAIN=$(bash "$script_dir/plot-config.sh" get "Main branch")
|
|
1035
|
+
[ -n "$MAIN" ] || MAIN=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
|
|
1036
|
+
[ -n "$MAIN" ] || MAIN="main"
|
|
1037
|
+
[ -n "$offline" ] || git fetch -q origin "$MAIN" 2>/dev/null
|
|
1038
|
+
|
|
1039
|
+
PLAN_DIR_CFG=$("$script_dir/plot-config.sh" get "Plan directory" "docs/plans/")
|
|
1040
|
+
ACTIVE_DIR_CFG=$("$script_dir/plot-config.sh" get "Active index" "docs/plans/active/")
|
|
1041
|
+
|
|
1042
|
+
gate_ref="origin/$MAIN"
|
|
1043
|
+
gate_sha=$(git rev-parse --verify --quiet "$gate_ref^{commit}" 2>/dev/null) || gate_sha=""
|
|
1044
|
+
|
|
1045
|
+
# The plan as it exists on the shared ref. Path resolution runs against the ref
|
|
1046
|
+
# too (`git ls-tree`), not the filesystem: a plan that exists only locally must
|
|
1047
|
+
# not be found here, and a plan whose local copy was deleted must still gate.
|
|
1048
|
+
#
|
|
1049
|
+
# The active index is a directory of SYMLINKS, and git stores a symlink as mode
|
|
1050
|
+
# 120000 whose blob content is the TARGET PATH — so `git show <ref>:active/g.md`
|
|
1051
|
+
# yields the string "../2026-01-01-g.md", not the plan. On a filesystem `[ -e ]`
|
|
1052
|
+
# follows the link and this never comes up; against a ref it must be
|
|
1053
|
+
# dereferenced by hand, or the gate parses a one-line path as a plan and reports
|
|
1054
|
+
# an unreadable phase instead of the real one.
|
|
1055
|
+
deref_on_ref() { # $1=path on $gate_ref → prints the path the blob really lives at
|
|
1056
|
+
local p="$1" target dir hops=0
|
|
1057
|
+
while [ "$(git ls-tree "$gate_ref" -- "$p" 2>/dev/null | awk '{print $1}')" = "120000" ]; do
|
|
1058
|
+
hops=$((hops + 1))
|
|
1059
|
+
[ "$hops" -le 8 ] || return 1 # a symlink cycle must not hang the gate
|
|
1060
|
+
target=$(git show "$gate_ref:$p" 2>/dev/null) || return 1
|
|
1061
|
+
case "$target" in
|
|
1062
|
+
/*) return 1 ;; # absolute: not a path within the ref
|
|
1063
|
+
*) dir=$(dirname "$p")
|
|
1064
|
+
# Normalise ../ and ./ without touching the filesystem.
|
|
1065
|
+
p=$(printf '%s\n' "$dir/$target" | awk -F/ '{
|
|
1066
|
+
n=0
|
|
1067
|
+
for (i=1; i<=NF; i++) {
|
|
1068
|
+
if ($i == "" || $i == ".") continue
|
|
1069
|
+
if ($i == "..") { if (n>0) n--; continue }
|
|
1070
|
+
s[++n]=$i
|
|
1071
|
+
}
|
|
1072
|
+
out=""
|
|
1073
|
+
for (i=1; i<=n; i++) out = (i==1 ? s[i] : out "/" s[i])
|
|
1074
|
+
print out
|
|
1075
|
+
}') ;;
|
|
1076
|
+
esac
|
|
1077
|
+
done
|
|
1078
|
+
printf '%s\n' "$p"
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
plan_path=""
|
|
1082
|
+
if [ -n "$gate_sha" ]; then
|
|
1083
|
+
for cand in "$ACTIVE_DIR_CFG$slug.md" \
|
|
1084
|
+
$(git ls-tree -r --name-only "$gate_ref" -- "$PLAN_DIR_CFG" 2>/dev/null \
|
|
1085
|
+
| grep -E "/[0-9]{4}-[0-9]{2}-[0-9]{2}-${slug}\.md$|/${slug}\.md$"); do
|
|
1086
|
+
git cat-file -e "$gate_ref:$cand" 2>/dev/null || continue
|
|
1087
|
+
plan_path=$(deref_on_ref "$cand") || { plan_path=""; continue; }
|
|
1088
|
+
[ -n "$plan_path" ] && break
|
|
1089
|
+
done
|
|
1090
|
+
fi
|
|
1091
|
+
|
|
1092
|
+
# --allow-local: read the working tree instead, and say so. The escape for a
|
|
1093
|
+
# repo with no remote at all; never reached silently.
|
|
1094
|
+
if [ -z "$gate_sha" ] && [ "$allow_local" = 1 ]; then
|
|
1095
|
+
echo "plot-dispatch: cannot resolve '$gate_ref' — reading the working tree (--allow-local)." >&2
|
|
1096
|
+
for cand in "$ACTIVE_DIR_CFG$slug.md" "$PLAN_DIR_CFG"*"$slug".md; do
|
|
1097
|
+
[ -e "$cand" ] && { plan_path="$cand"; break; }
|
|
1098
|
+
done
|
|
1099
|
+
fi
|
|
1100
|
+
|
|
1101
|
+
if [ -z "$gate_sha" ] && [ "$allow_local" != 1 ]; then
|
|
1102
|
+
echo "plot-dispatch: cannot resolve '$gate_ref' — refusing to dispatch." >&2
|
|
1103
|
+
echo " The phase gate reads the plan as it exists on the shared ref, so an" >&2
|
|
1104
|
+
echo " approval only you can see cannot open it. Run \`git fetch origin $MAIN\`," >&2
|
|
1105
|
+
echo " or pass --allow-local to gate on the working tree instead." >&2
|
|
1106
|
+
exit 1
|
|
1107
|
+
fi
|
|
1108
|
+
|
|
1109
|
+
if [ -z "$plan_path" ]; then
|
|
1110
|
+
if [ "$allow_local" = 1 ]; then
|
|
1111
|
+
echo "plot-dispatch: no plan found for '$slug' — looked in $ACTIVE_DIR_CFG and $PLAN_DIR_CFG" >&2
|
|
1112
|
+
else
|
|
1113
|
+
echo "plot-dispatch: no plan for '$slug' on $gate_ref — looked in $ACTIVE_DIR_CFG and $PLAN_DIR_CFG" >&2
|
|
1114
|
+
echo " A plan that exists only in this working tree has not been shared yet: push it first." >&2
|
|
1115
|
+
fi
|
|
1116
|
+
exit 1
|
|
1117
|
+
fi
|
|
1118
|
+
|
|
1119
|
+
# plot-plan-meta.sh is the format contract and takes a PATH, so the blob is
|
|
1120
|
+
# materialised into a temp file rather than parsed here — the parser stays the
|
|
1121
|
+
# one place that knows what a plan file looks like.
|
|
1122
|
+
#
|
|
1123
|
+
# The template's X's must TRAIL: BSD mktemp (macOS) rejects a template with a
|
|
1124
|
+
# suffix after them, while GNU accepts it. The first version wrote
|
|
1125
|
+
# `plot-gate-XXXXXX.md` and failed on macOS — and because the failure fell back
|
|
1126
|
+
# to the working tree, the gate silently went back to reading the exact surface
|
|
1127
|
+
# this fix exists to stop reading. Hence also: NO working-tree fallback below.
|
|
1128
|
+
# If the shared blob cannot be materialised, the gate refuses.
|
|
1129
|
+
plan_file="$plan_path"
|
|
1130
|
+
gate_blob=""
|
|
1131
|
+
if [ -n "$gate_sha" ]; then
|
|
1132
|
+
gate_dir=$(mktemp -d "${TMPDIR:-/tmp}/plot-gate-XXXXXX") || gate_dir=""
|
|
1133
|
+
if [ -n "$gate_dir" ]; then
|
|
1134
|
+
trap 'rm -rf "$gate_dir"' EXIT
|
|
1135
|
+
gate_blob="$gate_dir/$(basename "$plan_path")"
|
|
1136
|
+
git show "$gate_ref:$plan_path" >"$gate_blob" 2>/dev/null || gate_blob=""
|
|
1137
|
+
fi
|
|
1138
|
+
if [ -z "$gate_blob" ]; then
|
|
1139
|
+
echo "plot-dispatch: could not read '$gate_ref:$plan_path' — refusing to dispatch." >&2
|
|
1140
|
+
echo " The gate does not fall back to the working tree: an approval only you" >&2
|
|
1141
|
+
echo " can see must not open it. Pass --allow-local if that is what you mean." >&2
|
|
1142
|
+
exit 1
|
|
1143
|
+
fi
|
|
1144
|
+
else
|
|
1145
|
+
gate_blob="$plan_path" # --allow-local only; guarded above
|
|
1146
|
+
fi
|
|
1147
|
+
|
|
1148
|
+
# What the gate actually read, for messages: the shared ref by default, the
|
|
1149
|
+
# working tree only under --allow-local. A refusal that names `origin/main@<sha>`
|
|
1150
|
+
# is debuggable in seconds; "still Draft" alone sent an operator looking at a
|
|
1151
|
+
# file that already said Approved.
|
|
1152
|
+
if [ -n "$gate_sha" ]; then
|
|
1153
|
+
gate_source="$gate_ref@${gate_sha:0:8}:$plan_path"
|
|
1154
|
+
else
|
|
1155
|
+
gate_source="$plan_path (working tree, --allow-local)"
|
|
1156
|
+
fi
|
|
1157
|
+
|
|
1158
|
+
gate_meta=$("$script_dir/plot-plan-meta.sh" "$gate_blob" 2>/dev/null) || gate_meta=""
|
|
1159
|
+
gate_phase=$(printf '%s' "$gate_meta" | sed -n 's/.*"phase":"\([^"]*\)".*/\1/p')
|
|
1160
|
+
gate_impl=$(printf '%s' "$gate_meta" | sed -n 's/.*"impl":"\([^"]*\)".*/\1/p')
|
|
1161
|
+
|
|
1162
|
+
case "$gate_phase" in
|
|
1163
|
+
approved) ;;
|
|
1164
|
+
draft)
|
|
1165
|
+
echo "plot-dispatch: plan '$slug' is still Draft on $gate_source — nothing may be dispatched." >&2
|
|
1166
|
+
echo " The gate reads the plan as it exists on the shared ref. If you approved it" >&2
|
|
1167
|
+
echo " locally, push that approval; an approval nobody else can see is not one." >&2
|
|
1168
|
+
echo " Review it, then: /plot-approve $slug" >&2
|
|
1169
|
+
exit 1 ;;
|
|
1170
|
+
delivered|released)
|
|
1171
|
+
echo "plot-dispatch: plan '$slug' is already $gate_phase — its work is done." >&2
|
|
1172
|
+
exit 1 ;;
|
|
1173
|
+
"")
|
|
1174
|
+
echo "plot-dispatch: cannot read the phase of '$slug' ($gate_source)." >&2
|
|
1175
|
+
echo " Refusing rather than guessing — dispatching starts real work." >&2
|
|
1176
|
+
exit 1 ;;
|
|
1177
|
+
*)
|
|
1178
|
+
echo "plot-dispatch: plan '$slug' is in phase '$gate_phase', not Approved." >&2
|
|
1179
|
+
exit 1 ;;
|
|
1180
|
+
esac
|
|
1181
|
+
|
|
1182
|
+
# Fan-out only makes sense where implementation happens on its own branches
|
|
1183
|
+
# here. NONE means a pre-Plot-2 plan that never recorded an answer — allowed,
|
|
1184
|
+
# since those predate the question.
|
|
1185
|
+
case "$gate_impl" in
|
|
1186
|
+
own-branches|NONE|"") ;;
|
|
1187
|
+
same-branch)
|
|
1188
|
+
echo "plot-dispatch: plan '$slug' records 'Impl: same branch' — plan and code" >&2
|
|
1189
|
+
echo " travel on one branch, so there is nothing to fan out." >&2
|
|
1190
|
+
exit 1 ;;
|
|
1191
|
+
other-repo)
|
|
1192
|
+
echo "plot-dispatch: plan '$slug' records 'Impl: other repo' — implementation" >&2
|
|
1193
|
+
echo " happens elsewhere. Dispatch from the implementation repo instead." >&2
|
|
1194
|
+
exit 1 ;;
|
|
1195
|
+
none)
|
|
1196
|
+
echo "plot-dispatch: plan '$slug' records 'Impl: none' — knowledge-only work," >&2
|
|
1197
|
+
echo " nothing to implement." >&2
|
|
1198
|
+
exit 1 ;;
|
|
1199
|
+
*)
|
|
1200
|
+
echo "plot-dispatch: plan '$slug' records an unrecognised 'Impl:' answer" >&2
|
|
1201
|
+
echo " ('$gate_impl'). Refusing rather than guessing." >&2
|
|
1202
|
+
exit 1 ;;
|
|
1203
|
+
esac
|
|
1204
|
+
|
|
1205
|
+
# MAIN was resolved and origin fetched above, before the phase gate — the gate
|
|
1206
|
+
# needs the shared ref to read the plan from it.
|
|
1207
|
+
|
|
1208
|
+
# Where the worktrees live and what their names carry — see resolve_wt_root.
|
|
1209
|
+
# The default is beside the repo with the `plot-wt-` prefix; a `Worktree root:`
|
|
1210
|
+
# key relocates them (and drops the prefix, which was only earning its keep
|
|
1211
|
+
# among unrelated sibling directories). A nested root is made invisible to
|
|
1212
|
+
# `git status` and the marker grep by a `.gitignore` line, not by living
|
|
1213
|
+
# outside the repo.
|
|
1214
|
+
repo_root=$(git rev-parse --show-toplevel)
|
|
1215
|
+
resolve_wt_root "$repo_root"
|
|
1216
|
+
|
|
1217
|
+
n_dispatched=0 n_reused=0 n_skipped=0 n_started=0
|
|
1218
|
+
|
|
1219
|
+
# Whether this run COULD have started anything, read once and up front.
|
|
1220
|
+
#
|
|
1221
|
+
# Read here rather than inside start_worker so the answer exists even on the
|
|
1222
|
+
# paths start_worker never reaches: --dry-run, --no-start, and a run where
|
|
1223
|
+
# every candidate was skipped. Those are precisely the runs whose `started=0`
|
|
1224
|
+
# used to arrive with no explanation attached.
|
|
1225
|
+
#
|
|
1226
|
+
# --no-start wins over the config, and does not mean the config is missing:
|
|
1227
|
+
# a repo that HAS a `Worker command` and was told not to use it is reporting a
|
|
1228
|
+
# choice, not a gap. Conflating them would be the one-label-two-states mistake
|
|
1229
|
+
# this whole plan exists to remove.
|
|
1230
|
+
#
|
|
1231
|
+
# `none` is a DELIBERATE absence, and it is not the same as a missing key —
|
|
1232
|
+
# it is the repo's established sentinel (`Implementation home: none`) and here
|
|
1233
|
+
# it records that the question was asked and answered "I start them myself".
|
|
1234
|
+
# The skill writes it so it stops asking; the script must never try to RUN it,
|
|
1235
|
+
# which is what a bare emptiness check would do.
|
|
1236
|
+
worker_cmd_configured=0
|
|
1237
|
+
worker_cmd_declined=0
|
|
1238
|
+
case "$("$script_dir/plot-config.sh" get "Worker command" "")" in
|
|
1239
|
+
"") ;;
|
|
1240
|
+
none|NONE|None) worker_cmd_declined=1 ;;
|
|
1241
|
+
*) worker_cmd_configured=1 ;;
|
|
1242
|
+
esac
|
|
1243
|
+
|
|
1244
|
+
worker_state_field() {
|
|
1245
|
+
if [ "$no_start" = 1 ]; then echo "suppressed"
|
|
1246
|
+
elif [ "$worker_cmd_configured" = 1 ]; then echo "configured"
|
|
1247
|
+
elif [ "$worker_cmd_declined" = 1 ]; then echo "declined"
|
|
1248
|
+
else echo "unconfigured"
|
|
1249
|
+
fi
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
# The summary block: an optional prose line, then the machine-countable footer.
|
|
1253
|
+
#
|
|
1254
|
+
# The prose line is printed only when there is a consequence to state — a
|
|
1255
|
+
# summary that always explains itself teaches the reader to skip it, and then
|
|
1256
|
+
# it is worth nothing on the day it matters. `worktrees prepared` counts
|
|
1257
|
+
# dispatched + reused, because a re-adopted worktree is equally a desk nobody
|
|
1258
|
+
# was sat at.
|
|
1259
|
+
print_summary() { # $1=dispatched $2=reused $3=skipped $4=started
|
|
1260
|
+
local prepared=$(( $1 + $2 )) worker
|
|
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"
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
# ---------------------------------------------------------------------------
|
|
1280
|
+
# Parallel-agents cap: warn and raise when exceeded
|
|
1281
|
+
# ---------------------------------------------------------------------------
|
|
1282
|
+
#
|
|
1283
|
+
# THE CAP GATES AUTO-DISPATCH AND WARNS A PERSON (a-worker-asks-for-the-next-wave,
|
|
1284
|
+
# "Counted" wave). maybeAutoDispatch REFUSES at the cap; plot-dispatch.sh WARNS
|
|
1285
|
+
# and PROCEEDS. An operator running `/plot-dispatch` has asked for something
|
|
1286
|
+
# specific; refusing them to defend a setting they can change is the wrong
|
|
1287
|
+
# direction. But proceeding past a cap must not leave the cap behind: a stored
|
|
1288
|
+
# `3` beside six running workers is a number the board itself knows to be false.
|
|
1289
|
+
# So exceeding the cap UPDATES it.
|
|
1290
|
+
#
|
|
1291
|
+
# LIVE STATES occupy a slot: `running` and `waiting`. This matches LIVE_STATES
|
|
1292
|
+
# in auto-dispatch.ts — the two must agree, or the cap means different things
|
|
1293
|
+
# to different readers.
|
|
1294
|
+
FLEET_CONTROLS_FILE="$repo_root/.plot/state/fleet-controls.json"
|
|
1295
|
+
|
|
1296
|
+
# Read the current parallel-agents cap from the fleet controls file.
|
|
1297
|
+
# Returns the default (3) if the file does not exist or cannot be parsed.
|
|
1298
|
+
read_parallel_agents_cap() {
|
|
1299
|
+
if [ ! -f "$FLEET_CONTROLS_FILE" ]; then
|
|
1300
|
+
echo 3
|
|
1301
|
+
return
|
|
1302
|
+
fi
|
|
1303
|
+
# A simple extraction: the file is {"autoDispatch":..., "parallelAgents": N}
|
|
1304
|
+
local cap
|
|
1305
|
+
cap=$(sed -n 's/.*"parallelAgents"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p' "$FLEET_CONTROLS_FILE" | head -1)
|
|
1306
|
+
[ -n "$cap" ] && echo "$cap" || echo 3
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
# Count workers in live states (running or waiting) across all fleet worktrees.
|
|
1310
|
+
# These are the slots that count against the cap.
|
|
1311
|
+
count_live_workers() {
|
|
1312
|
+
local n=0 wt br st
|
|
1313
|
+
for wt in "$wt_root"/"$wt_prefix"*; do
|
|
1314
|
+
[ -d "$wt" ] || continue
|
|
1315
|
+
br=$(git -C "$wt" branch --show-current 2>/dev/null || echo "?")
|
|
1316
|
+
st=$(worker_state "$wt" "$br")
|
|
1317
|
+
case "$st" in
|
|
1318
|
+
running*|waiting*) n=$((n + 1)) ;;
|
|
1319
|
+
esac
|
|
1320
|
+
done
|
|
1321
|
+
echo "$n"
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
# Get the branches currently occupying slots (for the warning message).
|
|
1325
|
+
live_worker_branches() {
|
|
1326
|
+
local wt br st
|
|
1327
|
+
for wt in "$wt_root"/"$wt_prefix"*; do
|
|
1328
|
+
[ -d "$wt" ] || continue
|
|
1329
|
+
br=$(git -C "$wt" branch --show-current 2>/dev/null || echo "?")
|
|
1330
|
+
st=$(worker_state "$wt" "$br")
|
|
1331
|
+
case "$st" in
|
|
1332
|
+
running*|waiting*) echo "$br" ;;
|
|
1333
|
+
esac
|
|
1334
|
+
done
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
# Update the parallel-agents cap in the fleet controls file.
|
|
1338
|
+
# Creates the file (and directory) if needed, preserving autoDispatch if present.
|
|
1339
|
+
update_parallel_agents_cap() { # $1 = new cap
|
|
1340
|
+
local new_cap="$1"
|
|
1341
|
+
local auto_dispatch="false"
|
|
1342
|
+
|
|
1343
|
+
mkdir -p "$(dirname "$FLEET_CONTROLS_FILE")" 2>/dev/null || true
|
|
1344
|
+
|
|
1345
|
+
if [ -f "$FLEET_CONTROLS_FILE" ]; then
|
|
1346
|
+
# Preserve the existing autoDispatch setting
|
|
1347
|
+
local existing
|
|
1348
|
+
existing=$(sed -n 's/.*"autoDispatch"[[:space:]]*:[[:space:]]*\(true\|false\).*/\1/p' "$FLEET_CONTROLS_FILE" | head -1)
|
|
1349
|
+
[ -n "$existing" ] && auto_dispatch="$existing"
|
|
1350
|
+
fi
|
|
1351
|
+
|
|
1352
|
+
# Write atomically through a temp file, the same discipline as writeFleetControls
|
|
1353
|
+
local tmp="${FLEET_CONTROLS_FILE}.$$-dispatch.tmp"
|
|
1354
|
+
printf '{"autoDispatch":%s,"parallelAgents":%d}' "$auto_dispatch" "$new_cap" > "$tmp"
|
|
1355
|
+
mv "$tmp" "$FLEET_CONTROLS_FILE"
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
# Check if the dispatch exceeded the cap; if so, warn and raise it.
|
|
1359
|
+
# Called AFTER the dispatch, with the count of newly started workers.
|
|
1360
|
+
check_and_update_cap() { # $1 = n_started this run
|
|
1361
|
+
local n_started="$1"
|
|
1362
|
+
[ "$n_started" -gt 0 ] || return 0
|
|
1363
|
+
|
|
1364
|
+
local cap live_before live_after
|
|
1365
|
+
cap=$(read_parallel_agents_cap)
|
|
1366
|
+
# Count AFTER starting — the workers we just started are now live
|
|
1367
|
+
live_after=$(count_live_workers)
|
|
1368
|
+
|
|
1369
|
+
if [ "$live_after" -gt "$cap" ]; then
|
|
1370
|
+
local branches
|
|
1371
|
+
branches=$(live_worker_branches | paste -sd', ' -)
|
|
1372
|
+
echo "WARNING: dispatch exceeded parallel-agents cap ($cap → $live_after)"
|
|
1373
|
+
echo " Slots now held by: $branches"
|
|
1374
|
+
echo " Raising cap to $live_after so auto-dispatch sees the true count"
|
|
1375
|
+
update_parallel_agents_cap "$live_after"
|
|
1376
|
+
fi
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
# Branches CLAIMED by this run, for the `Started:` record. Only newly claimed
|
|
1380
|
+
# ones: a reused worktree was dispatched by an earlier run, which booked it.
|
|
1381
|
+
declare -a claimed_now=()
|
|
1382
|
+
|
|
1383
|
+
# Branches this run cannot dispatch. --next re-asks each iteration (pull
|
|
1384
|
+
# semantics), and a branch that is never CLAIMED keeps coming back — without
|
|
1385
|
+
# this the loop spins forever on the first undispatchable branch.
|
|
1386
|
+
declare -a exhausted=()
|
|
1387
|
+
is_exhausted() {
|
|
1388
|
+
local x
|
|
1389
|
+
for x in ${exhausted[@]+"${exhausted[@]}"}; do [ "$x" = "$1" ] && return 0; done
|
|
1390
|
+
return 1
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
# Record what was started, ON THE DEFAULT BRANCH.
|
|
1394
|
+
#
|
|
1395
|
+
# WHERE THIS IS WRITTEN IS THE WHOLE DIFFICULTY. The plan file found above
|
|
1396
|
+
# lives in this dispatcher's LOCAL WORKING TREE — `docs/plans/active/<slug>.md`
|
|
1397
|
+
# relative to whatever branch happens to be checked out here. The board reads
|
|
1398
|
+
# the plan from the DEFAULT BRANCH. Appending the record to the local file and
|
|
1399
|
+
# committing it would book the start on the dispatcher's branch, where the
|
|
1400
|
+
# board never looks: the plan would keep reading as Ready while agents edit its
|
|
1401
|
+
# branches. That is not hypothetical — it had to be back-filled by hand twice
|
|
1402
|
+
# on this repo on 2026-08-16, which is why the naive version is called out here
|
|
1403
|
+
# rather than merely avoided.
|
|
1404
|
+
#
|
|
1405
|
+
# So dispatch books the way every other Plot command books: through a
|
|
1406
|
+
# disposable branch off origin/<default>, pushed with plot-push-main.sh.
|
|
1407
|
+
#
|
|
1408
|
+
# A SEPARATE WORKTREE, not `git checkout -b` in this one. The plan's sketch
|
|
1409
|
+
# said checkout, but the dispatcher's working tree belongs to the user and may
|
|
1410
|
+
# carry uncommitted work; switching it out from under them to save a note is
|
|
1411
|
+
# exactly the kind of write this script otherwise refuses. A throwaway worktree
|
|
1412
|
+
# reaches the same commit without touching anyone's checkout, and is removed
|
|
1413
|
+
# whether the push succeeded or not.
|
|
1414
|
+
#
|
|
1415
|
+
# plot-push-main.sh rather than a bare `git push`, so a repo whose protection
|
|
1416
|
+
# is configured but not enforced hears about the bypass instead of it passing
|
|
1417
|
+
# silently — the reason that helper exists at all.
|
|
1418
|
+
book_started() { # $@ = branches dispatched this run
|
|
1419
|
+
[ $# -gt 0 ] || return 0
|
|
1420
|
+
|
|
1421
|
+
if write_started_record "$@"; then
|
|
1422
|
+
return 0
|
|
1423
|
+
fi
|
|
1424
|
+
|
|
1425
|
+
# A FAILED BOOKING NEVER UNWINDS A FAN-OUT. By the time we are here the
|
|
1426
|
+
# worktrees exist and the claims are pushed, and those are the real state;
|
|
1427
|
+
# the record is a report ABOUT that state. Rolling back real work because a
|
|
1428
|
+
# note could not be saved is the larger damage, and aborting mid-fan-out
|
|
1429
|
+
# would leave exactly the inconsistency the record exists to prevent.
|
|
1430
|
+
#
|
|
1431
|
+
# Said ONCE, here, and on STDOUT beside the summary it qualifies. Why the
|
|
1432
|
+
# write failed belongs on stderr and was printed there; that the record is
|
|
1433
|
+
# missing while the work is running is part of this command's report, and a
|
|
1434
|
+
# caller reading only stdout would otherwise see a clean fan-out with no hint
|
|
1435
|
+
# that the plan still reads as Ready.
|
|
1436
|
+
echo " note: Started: could not be recorded on $MAIN — the fan-out stands."
|
|
1437
|
+
echo " Record it by hand, or re-run this dispatch once the push works."
|
|
1438
|
+
return 1
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
# The write itself. Every failure path returns non-zero after saying WHY on
|
|
1442
|
+
# stderr; the caller owns the one user-facing consequence line.
|
|
1443
|
+
write_started_record() { # $@ = branches
|
|
1444
|
+
local who date rel tmpwt bookbr rc=0
|
|
1445
|
+
who="${PLOT_CLAIM_WHO:-$(git config user.name 2>/dev/null || echo plot)}"
|
|
1446
|
+
date=$(date +%Y-%m-%d)
|
|
1447
|
+
|
|
1448
|
+
# The CANONICAL plan file, not the index symlink: the record belongs in the
|
|
1449
|
+
# dated file both indexes point at, or a later `active/` → `delivered/` move
|
|
1450
|
+
# would carry the symlink and leave the record behind.
|
|
1451
|
+
rel=$(cd "$repo_root" && real_plan_path "$plan_file") || rel=""
|
|
1452
|
+
if [ -z "$rel" ]; then
|
|
1453
|
+
echo "plot-dispatch: $plan_file is outside the repository root" >&2
|
|
1454
|
+
return 1
|
|
1455
|
+
fi
|
|
1456
|
+
|
|
1457
|
+
bookbr="plot/start-$slug"
|
|
1458
|
+
tmpwt="$wt_root/.plot-start-$slug.$$"
|
|
1459
|
+
|
|
1460
|
+
# Fetch even under --offline: booking is a push, so the network is already
|
|
1461
|
+
# required. Without a fresh origin/<default> the branch would fork from a
|
|
1462
|
+
# stale tip and the push would be a guaranteed non-fast-forward.
|
|
1463
|
+
git fetch -q origin "$MAIN" 2>/dev/null
|
|
1464
|
+
|
|
1465
|
+
# -B: a leftover branch from an earlier failed booking must not block this
|
|
1466
|
+
# one. It is disposable by construction — created here, pushed, deleted.
|
|
1467
|
+
if ! git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null; then
|
|
1468
|
+
echo "plot-dispatch: could not prepare a booking worktree at $tmpwt" >&2
|
|
1469
|
+
return 1
|
|
1470
|
+
fi
|
|
1471
|
+
|
|
1472
|
+
if [ -f "$tmpwt/$rel" ]; then
|
|
1473
|
+
local br
|
|
1474
|
+
for br in "$@"; do
|
|
1475
|
+
append_started_line "$tmpwt/$rel" "$date" "$who" "$br" || {
|
|
1476
|
+
echo "plot-dispatch: $rel has no '## Status' section — nowhere to record" >&2
|
|
1477
|
+
rc=1
|
|
1478
|
+
break
|
|
1479
|
+
}
|
|
1480
|
+
done
|
|
1481
|
+
if [ "$rc" = 0 ]; then
|
|
1482
|
+
git -C "$tmpwt" add -- "$rel" 2>/dev/null
|
|
1483
|
+
git -C "$tmpwt" -c "user.name=$who" commit -q \
|
|
1484
|
+
-m "plot: record start of $slug" 2>/dev/null || rc=1
|
|
1485
|
+
fi
|
|
1486
|
+
else
|
|
1487
|
+
echo "plot-dispatch: $rel is not on origin/$MAIN" >&2
|
|
1488
|
+
rc=1
|
|
1489
|
+
fi
|
|
1490
|
+
|
|
1491
|
+
# The helper's own words, indented: which rules were stepped over and which
|
|
1492
|
+
# checks did not run is information only the remote has. Its stderr is folded
|
|
1493
|
+
# in so a `rejected` report is visible rather than swallowed by 2>/dev/null
|
|
1494
|
+
# somewhere upstream.
|
|
1495
|
+
if [ "$rc" = 0 ]; then
|
|
1496
|
+
"$script_dir/plot-push-main.sh" "$bookbr" "$MAIN" 2>&1 | sed 's/^/ /'
|
|
1497
|
+
# The pipeline's status is sed's, so ask the helper's directly.
|
|
1498
|
+
rc=${PIPESTATUS[0]}
|
|
1499
|
+
fi
|
|
1500
|
+
|
|
1501
|
+
git worktree remove --force "$tmpwt" 2>/dev/null || true
|
|
1502
|
+
git branch -D "$bookbr" >/dev/null 2>&1 || true
|
|
1503
|
+
return "$rc"
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
# The plan path relative to the repo root, with the index symlink resolved.
|
|
1507
|
+
# Called from within $repo_root.
|
|
1508
|
+
real_plan_path() { # $1=plan file as found (possibly a symlink, possibly relative)
|
|
1509
|
+
local p="$1" d b t
|
|
1510
|
+
d=$(cd "$(dirname "$p")" 2>/dev/null && pwd) || return 1
|
|
1511
|
+
b=$(basename "$p")
|
|
1512
|
+
t=$(readlink "$d/$b" 2>/dev/null || true)
|
|
1513
|
+
if [ -n "$t" ]; then
|
|
1514
|
+
case "$t" in
|
|
1515
|
+
/*) d=$(cd "$(dirname "$t")" 2>/dev/null && pwd) || return 1 ;;
|
|
1516
|
+
*) d=$(cd "$d/$(dirname "$t")" 2>/dev/null && pwd) || return 1 ;;
|
|
1517
|
+
esac
|
|
1518
|
+
b=$(basename "$t")
|
|
1519
|
+
fi
|
|
1520
|
+
case "$d" in
|
|
1521
|
+
"$repo_root") printf '%s' "$b" ;;
|
|
1522
|
+
"$repo_root"/*) printf '%s/%s' "${d#$repo_root/}" "$b" ;;
|
|
1523
|
+
*) return 1 ;;
|
|
1524
|
+
esac
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
# Insert one `- **Started:** ...` line into the plan's `## Status` section, in
|
|
1528
|
+
# /plot-implement's exact shape so nothing downstream learns a second format.
|
|
1529
|
+
#
|
|
1530
|
+
# Placed after the LAST list item of `## Status`, never appended to the end of
|
|
1531
|
+
# the file: plot-plan-meta.sh reads these records out of that section, so a
|
|
1532
|
+
# line below it would parse as nothing at all — a record that exists on disk
|
|
1533
|
+
# and not in the data is worse than no record, because it looks written.
|
|
1534
|
+
#
|
|
1535
|
+
# A plan with no `## Status` heading is therefore a REFUSAL, not a best-effort
|
|
1536
|
+
# append. Exit 1 and let the caller report the record as unwritten; the plan is
|
|
1537
|
+
# malformed and guessing where the field belongs would hide that.
|
|
1538
|
+
append_started_line() { # $1=file $2=date $3=who $4=branch
|
|
1539
|
+
local f="$1" line
|
|
1540
|
+
line="- **Started:** $2, $3, \`$4\`"
|
|
1541
|
+
awk -v line="$line" '
|
|
1542
|
+
{ lines[++n] = $0 }
|
|
1543
|
+
END {
|
|
1544
|
+
# The first `## Status` heading, then where the record belongs under it.
|
|
1545
|
+
for (i = 1; i <= n; i++) {
|
|
1546
|
+
if (lines[i] ~ /^##[ \t]*[Ss]tatus[ \t]*$/) { start = i; break }
|
|
1547
|
+
}
|
|
1548
|
+
if (!start) exit 1
|
|
1549
|
+
|
|
1550
|
+
# The template ships an EMPTY `- **Started:**` placeholder, and the record
|
|
1551
|
+
# belongs there. Appending after the last list item instead put it below
|
|
1552
|
+
# `- **Delivered:**` — the parser still read it, so nothing failed loudly,
|
|
1553
|
+
# but the block listed a start after a delivery and both plans dispatched
|
|
1554
|
+
# on 2026-08-16 had to be tidied by hand.
|
|
1555
|
+
#
|
|
1556
|
+
# Filling the placeholder is preferred; appending after the last list item
|
|
1557
|
+
# remains the fallback for plans that never had one (pre-Plot-2 files).
|
|
1558
|
+
insert = start
|
|
1559
|
+
for (i = start + 1; i <= n; i++) {
|
|
1560
|
+
if (lines[i] ~ /^##[ \t]/) break
|
|
1561
|
+
if (lines[i] ~ /^[ \t]*[-*][ \t]*\*\*Started:\*\*[ \t]*$/) { slot = i; break }
|
|
1562
|
+
if (lines[i] ~ /^[ \t]*[-*][ \t]/) insert = i
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
for (i = 1; i <= n; i++) {
|
|
1566
|
+
if (i == slot) { print line; continue } # replaces the empty placeholder
|
|
1567
|
+
print lines[i]
|
|
1568
|
+
if (!slot && i == insert) print line
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
' "$f" > "$f.plot-tmp" || { rm -f "$f.plot-tmp"; return 1; }
|
|
1572
|
+
mv "$f.plot-tmp" "$f"
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
# ---------------------------------------------------------------------------
|
|
1577
|
+
# What is already in flight
|
|
1578
|
+
# ---------------------------------------------------------------------------
|
|
1579
|
+
#
|
|
1580
|
+
# Before fanning out, say which branches already hold which files. Waves are a
|
|
1581
|
+
# WITHIN-PLAN ordering; a correctly eligible branch can still name a file an
|
|
1582
|
+
# agent has open on a different plan's branch, and nothing in the wave model
|
|
1583
|
+
# represents that. Assembling the answer by hand took five commands and had to
|
|
1584
|
+
# be done twice on 2026-08-16 — this turns it into a line of output.
|
|
1585
|
+
#
|
|
1586
|
+
# IT REPORTS AND REFUSES NOTHING. Every branch is dispatched exactly as before;
|
|
1587
|
+
# the operator reads the report and decides. Two designs that would have judged
|
|
1588
|
+
# instead were tried on paper and killed by measurement:
|
|
1589
|
+
#
|
|
1590
|
+
# - `git merge-tree` compares two EXISTING commits, and dispatch CREATES the
|
|
1591
|
+
# candidate branch. At check time it is identical to the default branch, so
|
|
1592
|
+
# the comparison reports clean for every candidate, forever. A check that
|
|
1593
|
+
# always passes is worse than none: it turns a known gap into a false
|
|
1594
|
+
# assurance. (merge-tree still earns its place where both commits exist —
|
|
1595
|
+
# re-dispatch and plot-merge-queue.)
|
|
1596
|
+
# - A `Touches:` field per branch, intersected with the measured side. The
|
|
1597
|
+
# scope guards in real briefs are `packages/board/**`,
|
|
1598
|
+
# `packages/board/src/app/**` and `plot-fleet-scan.sh` — the first CONTAINS
|
|
1599
|
+
# the second, so two branches that ran in parallel without touching each
|
|
1600
|
+
# other would read as colliding. Three of four briefs use `**` globs, so the
|
|
1601
|
+
# false positive is the normal case. It would also rest on an unverified
|
|
1602
|
+
# self-declaration, and a comparison is only as good as its weaker half.
|
|
1603
|
+
#
|
|
1604
|
+
# So only the MEASURED side is read, and nothing on the candidate side is
|
|
1605
|
+
# consulted at all — not the plan text, not a declaration, nothing.
|
|
1606
|
+
#
|
|
1607
|
+
# LOCAL REFS AND WORKTREES, NOT THE REMOTE. This is where refs-are-truth bends,
|
|
1608
|
+
# for a measured reason: the collision that blocked a dispatch on 2026-08-16
|
|
1609
|
+
# lived in an UNPUSHED commit — committed, clean worktree, remote ref holding
|
|
1610
|
+
# only the claim, invisible to any remote-based check. Uncommitted work is
|
|
1611
|
+
# invisible to refs entirely. Both are readable here and only here, and that is
|
|
1612
|
+
# sound rather than a violation because dispatch is inherently machine-specific:
|
|
1613
|
+
# it creates worktrees on THIS machine. A check that ignored what this machine
|
|
1614
|
+
# knows would be blind precisely where it acts.
|
|
1615
|
+
|
|
1616
|
+
# Files a branch holds in commits of its own, against ITS OWN MERGE-BASE.
|
|
1617
|
+
#
|
|
1618
|
+
# Not against origin/<main>. A branch rebased onto a newer main is not "behind"
|
|
1619
|
+
# it, and diffing against the tip would attribute every commit the branch picked
|
|
1620
|
+
# up from main to the branch itself — on a busy day that is the whole repo, and
|
|
1621
|
+
# the report becomes noise on its first use.
|
|
1622
|
+
committed_files() { # $1=branch → paths, one per line
|
|
1623
|
+
local br="$1" base
|
|
1624
|
+
base=$(git merge-base "$br" "origin/$MAIN" </dev/null 2>/dev/null) || return 0
|
|
1625
|
+
[ -n "$base" ] || return 0
|
|
1626
|
+
git diff --name-only "$base" "$br" </dev/null 2>/dev/null
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
# Files a branch holds only in its worktree — no ref carries these, so they are
|
|
1630
|
+
# invisible to every ref-based check including this script's own.
|
|
1631
|
+
uncommitted_files() { # $1=worktree → paths, one per line
|
|
1632
|
+
local wt="$1"
|
|
1633
|
+
[ -d "$wt" ] || return 0
|
|
1634
|
+
git -C "$wt" status --porcelain </dev/null 2>/dev/null | awk '
|
|
1635
|
+
{
|
|
1636
|
+
# Porcelain v1: XY then a space then the path. A rename prints
|
|
1637
|
+
# "old -> new"; the new path is the one on disk.
|
|
1638
|
+
line = substr($0, 4)
|
|
1639
|
+
i = index(line, " -> ")
|
|
1640
|
+
if (i > 0) line = substr(line, i + 4)
|
|
1641
|
+
gsub(/^"|"$/, "", line)
|
|
1642
|
+
if (line != "") print line
|
|
1643
|
+
}'
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
# The generated board bundle is excluded from every report. Every board branch
|
|
1647
|
+
# rebuilds it, so including it would make every board pair look like a
|
|
1648
|
+
# collision — which is precisely the noise `.gitattributes -merge` exists to
|
|
1649
|
+
# remove. Its conflicts are settled by rebuilding, never by reading.
|
|
1650
|
+
ARTIFACT_PATH="skills/plot/scripts/board/board-server.mjs"
|
|
1651
|
+
|
|
1652
|
+
# The worktree for a branch, by the same name-flattening rule dispatch uses.
|
|
1653
|
+
#
|
|
1654
|
+
# This COMPOSES a path and is used only where composition is right: the
|
|
1655
|
+
# in-flight file report below, which asks "if this branch WERE dispatched, where
|
|
1656
|
+
# would its worktree be", for a branch this run has not itself created. It is the
|
|
1657
|
+
# creation rule read forward, not a "which worktree holds this branch" query —
|
|
1658
|
+
# those ask git (held_worktree). The prefix follows the root, empty under a
|
|
1659
|
+
# dedicated `Worktree root:`.
|
|
1660
|
+
worktree_for() { # $1=branch
|
|
1661
|
+
printf '%s/%s%s' "$wt_root" "$wt_prefix" "$(printf '%s' "$1" | tr '/' '-')"
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
# ---------------------------------------------------------------------------
|
|
1665
|
+
# THE HELD-BRANCH GATE
|
|
1666
|
+
# ---------------------------------------------------------------------------
|
|
1667
|
+
#
|
|
1668
|
+
# Is somebody already holding this branch — a worktree on this disk with work
|
|
1669
|
+
# in it that has not landed?
|
|
1670
|
+
#
|
|
1671
|
+
# THE MEASUREMENT. On 2026-08-20 `--dry-run` reported `claimed=0` across a fleet
|
|
1672
|
+
# with four live agents and offered `feature/the-row-carries-its-verdict` and
|
|
1673
|
+
# `feature/reconcile-calls-the-index-advisory` — both implemented, tested and
|
|
1674
|
+
# green in worktrees beside this repo — as dispatchable. Acting on that output
|
|
1675
|
+
# puts a second agent on finished work.
|
|
1676
|
+
#
|
|
1677
|
+
# WHY THE SCAN CANNOT SEE IT. `plot-fleet-scan.sh` derives every state from
|
|
1678
|
+
# `origin/<branch>`, and both branches had NO REMOTE REF: one local commit
|
|
1679
|
+
# each, never pushed. No remote ref means no claim, and no claim means
|
|
1680
|
+
# `eligible`. The scan is right about what it reads; it is reading the wrong
|
|
1681
|
+
# side of the machine.
|
|
1682
|
+
#
|
|
1683
|
+
# WHY A RULE CANNOT FIX IT. "Always dispatch through plot-dispatch.sh so the
|
|
1684
|
+
# claim ref exists" is answerable without doing it, and it was violated four
|
|
1685
|
+
# times in one evening by an operator who had read it that evening. See
|
|
1686
|
+
# CLAUDE.md § Gates Over Rules.
|
|
1687
|
+
#
|
|
1688
|
+
# WHY THIS SCRIPT CAN. It already enumerates worktrees and local refs for the
|
|
1689
|
+
# in-flight collision report, for the reason documented above that report:
|
|
1690
|
+
# dispatch is inherently machine-specific — it creates worktrees on THIS
|
|
1691
|
+
# machine, so a check blind to this machine is blind precisely where it acts.
|
|
1692
|
+
# The evidence was already being collected; it was simply never asked this
|
|
1693
|
+
# question.
|
|
1694
|
+
#
|
|
1695
|
+
# WHAT COUNTS AS HELD needs both halves, because either alone is wrong:
|
|
1696
|
+
#
|
|
1697
|
+
# * A WORKTREE MUST EXIST — found by ASKING GIT which one holds the branch,
|
|
1698
|
+
# never by rebuilding the path from the branch name. Without one there is no
|
|
1699
|
+
# desk and nobody at it, and a local branch on its own is not a hold: plenty
|
|
1700
|
+
# exist for other reasons.
|
|
1701
|
+
#
|
|
1702
|
+
# * IT MUST HOLD WORK THAT HAS NOT LANDED — in a commit or in the working
|
|
1703
|
+
# tree, and the working tree is checked first because no commit carries it.
|
|
1704
|
+
# Several leftover worktrees on merged branches sit on this disk (6 of 36
|
|
1705
|
+
# when this was written); their work landed and the directory was never
|
|
1706
|
+
# removed. Refusing those would make the gate fire on exactly the branches
|
|
1707
|
+
# that are safe, which is the fastest way to teach an operator to route
|
|
1708
|
+
# around it.
|
|
1709
|
+
#
|
|
1710
|
+
# THE TWO SHAPES ARE NOT ONE QUESTION, and treating them as one is how the first
|
|
1711
|
+
# version of this gate shipped a hole. `--is-ancestor` against `origin/<main>`
|
|
1712
|
+
# answers for the merged leftover AND — identically — for a worktree cut minutes
|
|
1713
|
+
# ago: its branch points at whatever main was then, so it is an ancestor
|
|
1714
|
+
# trivially. Both read `ahead=0, behind=N`, and no walk of the history separates
|
|
1715
|
+
# them. Only the FILES do, which is why `uncommitted_files` is consulted before
|
|
1716
|
+
# the ancestry test rather than instead of it.
|
|
1717
|
+
#
|
|
1718
|
+
# NOT MERGE-BASE, ANCESTRY. A branch rebased onto a newer main is not behind
|
|
1719
|
+
# it, and the merged question is only ever "is this tip already in main".
|
|
1720
|
+
#
|
|
1721
|
+
# LOCAL REF, not `origin/<branch>`. Reading the remote here would reproduce the
|
|
1722
|
+
# scan's blind spot inside the fix.
|
|
1723
|
+
#
|
|
1724
|
+
# `--allow-local` DOES NOT REACH HERE, and must never be wired to. That flag is
|
|
1725
|
+
# the named escape for a repo whose `origin/<main>` cannot be resolved, and it
|
|
1726
|
+
# says something about reading a PHASE — nothing whatever about whether a human
|
|
1727
|
+
# is mid-edit in a worktree. It is absent from this function by design, not by
|
|
1728
|
+
# oversight; a test pins that the refusal survives it.
|
|
1729
|
+
held_worktree() { # $1=branch → prints the worktree path when held, else nothing
|
|
1730
|
+
local br="$1" wt
|
|
1731
|
+
# ASK GIT WHICH WORKTREE HOLDS THE BRANCH. Do not reconstruct the path from
|
|
1732
|
+
# the branch name.
|
|
1733
|
+
#
|
|
1734
|
+
# MEASURED, after a first version did exactly that via `worktree_for`. Every
|
|
1735
|
+
# hand-made worktree on this machine is named `plot-wt-<last-segments>` with
|
|
1736
|
+
# the branch TYPE dropped — `plot-wt-a-branch-row-carries-its-link` for
|
|
1737
|
+
# `bug/a-branch-row-carries-its-link`, where dispatch's own rule would say
|
|
1738
|
+
# `plot-wt-bug-a-branch-row-carries-its-link`. A path-guessing gate therefore
|
|
1739
|
+
# missed a worktree with six modified files in it.
|
|
1740
|
+
#
|
|
1741
|
+
# And it missed it in the WORST POSSIBLE POPULATION: worktrees dispatch did
|
|
1742
|
+
# not create are precisely the ones carrying no claim ref, which is the entire
|
|
1743
|
+
# reason this gate exists. A check that only recognises its own naming
|
|
1744
|
+
# convention can only catch the branches that were already claimed.
|
|
1745
|
+
#
|
|
1746
|
+
# `git worktree list --porcelain` emits `worktree <path>` then `branch
|
|
1747
|
+
# refs/heads/<name>` per entry, so the branch line is matched and the path
|
|
1748
|
+
# remembered from the preceding line. A detached worktree has no branch line
|
|
1749
|
+
# and never matches, which is right: it holds no branch to hold.
|
|
1750
|
+
wt=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$br" '
|
|
1751
|
+
/^worktree / { path = substr($0, 10) }
|
|
1752
|
+
/^branch / { if (substr($0, 8) == want) { print path; exit } }')
|
|
1753
|
+
[ -n "$wt" ] || return 1
|
|
1754
|
+
# A registered worktree whose directory is gone (removed by hand, not via
|
|
1755
|
+
# `git worktree remove`) holds nobody. `status` cannot be read there anyway.
|
|
1756
|
+
[ -d "$wt" ] || return 1
|
|
1757
|
+
|
|
1758
|
+
# UNCOMMITTED WORK IS UNLANDED WORK, and it is asked FIRST because the commit
|
|
1759
|
+
# history cannot see it at all.
|
|
1760
|
+
#
|
|
1761
|
+
# MEASURED ON THIS REPO, after the tip check below was already written and
|
|
1762
|
+
# green. `plot-wt-a-branch-row-carries-its-link` held six modified files for a
|
|
1763
|
+
# live agent and carried NO COMMIT YET: its branch sat at the main tip of the
|
|
1764
|
+
# moment the worktree was cut, so `--is-ancestor` answered "already landed"
|
|
1765
|
+
# and the gate offered the branch. Three sibling worktrees were in the same
|
|
1766
|
+
# shape. That is the plan's own failure — a second agent onto occupied work —
|
|
1767
|
+
# re-entering through the one shape a tip-based check cannot see.
|
|
1768
|
+
#
|
|
1769
|
+
# A freshly cut worktree is `ahead=0, behind=N`: indistinguishable by history
|
|
1770
|
+
# from the merged leftover the gate must NOT refuse. The file state is what
|
|
1771
|
+
# separates them, and `uncommitted_files` was already collecting it for the
|
|
1772
|
+
# in-flight report a few lines up.
|
|
1773
|
+
[ -z "$(uncommitted_files "$wt")" ] || { printf '%s' "$wt"; return 0; }
|
|
1774
|
+
|
|
1775
|
+
# Its tip landed already — a leftover desk, not a held one.
|
|
1776
|
+
git merge-base --is-ancestor "$br" "origin/$MAIN" </dev/null 2>/dev/null && return 1
|
|
1777
|
+
printf '%s' "$wt"
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
# The refusal, printed identically by --dry-run and the real run.
|
|
1781
|
+
#
|
|
1782
|
+
# IDENTICAL BY CONSTRUCTION, via one function called from both loops rather
|
|
1783
|
+
# than two messages that agree today. A dry run that offers what a real run
|
|
1784
|
+
# would refuse is worse than no dry run: it is the same wrong answer with a
|
|
1785
|
+
# reassurance attached.
|
|
1786
|
+
#
|
|
1787
|
+
# It NEVER CLAIMS on the operator's behalf. Writing a claim ref for a worktree
|
|
1788
|
+
# this script did not create puts a record in git nobody asked for, and a stale
|
|
1789
|
+
# ref is worse than an absent one — the reaper cannot tell it from a real claim.
|
|
1790
|
+
# So the gate reports and stops, and the operator decides.
|
|
1791
|
+
report_held() { # $1=branch $2=worktree
|
|
1792
|
+
echo "skipped $1 (held — worktree exists with unlanded work)"
|
|
1793
|
+
echo " worktree: $2"
|
|
1794
|
+
echo " nobody claimed it, so nothing here can tell a live agent from an"
|
|
1795
|
+
echo " abandoned desk. Check it, then remove the worktree or let it finish."
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
# Every local branch that holds files, with what it holds.
|
|
1799
|
+
#
|
|
1800
|
+
# LOCAL branches, because worktrees share one ref database: `git rev-parse`
|
|
1801
|
+
# answers from the main repo for a branch checked out elsewhere, so a sibling
|
|
1802
|
+
# agent's unpushed commits are readable from here without visiting its worktree.
|
|
1803
|
+
#
|
|
1804
|
+
# Emits "branch<TAB>file,file,…" per branch that holds at least one file.
|
|
1805
|
+
# A branch holding nothing emits nothing — a bare claim marker is an empty
|
|
1806
|
+
# commit, and reporting "holds " with no files would be worse than silence.
|
|
1807
|
+
work_in_flight() { # $1=branch to exclude (the candidate)
|
|
1808
|
+
local exclude="${1:-}" br files
|
|
1809
|
+
# bash 3.2 on macOS: no `declare -A`, so this accumulates into a plain string
|
|
1810
|
+
# rather than a map. Sorted output keeps the report stable between runs.
|
|
1811
|
+
git for-each-ref --format='%(refname:short)' refs/heads/ </dev/null 2>/dev/null \
|
|
1812
|
+
| while read -r br; do
|
|
1813
|
+
[ -n "$br" ] || continue
|
|
1814
|
+
[ "$br" = "$exclude" ] && continue
|
|
1815
|
+
[ "$br" = "$MAIN" ] && continue
|
|
1816
|
+
files=$( { committed_files "$br"; uncommitted_files "$(worktree_for "$br")"; } \
|
|
1817
|
+
| grep -v -x -F "$ARTIFACT_PATH" \
|
|
1818
|
+
| sort -u \
|
|
1819
|
+
| tr '\n' ',' | sed 's/,$//' )
|
|
1820
|
+
[ -n "$files" ] || continue
|
|
1821
|
+
printf '%s\t%s\n' "$br" "$files"
|
|
1822
|
+
done
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
# Print the in-flight report for one candidate, indented under its line.
|
|
1826
|
+
#
|
|
1827
|
+
# Silent when nothing is held. A report that always prints something teaches
|
|
1828
|
+
# the reader to skip it, and then it is worth nothing on the day it matters.
|
|
1829
|
+
#
|
|
1830
|
+
# BOUNDED, because measured on this repo it was not. The first run against real
|
|
1831
|
+
# state printed 13 branches under a single candidate, one of them naming 18
|
|
1832
|
+
# paths — the same "ignored by the third time" failure the design warns about,
|
|
1833
|
+
# arriving as volume rather than as false positives. Both caps are plain
|
|
1834
|
+
# truncation with the remainder counted, never a judgment about which branch or
|
|
1835
|
+
# file matters: nothing here can know that, and pretending to would be the
|
|
1836
|
+
# candidate-side prediction this design refuses.
|
|
1837
|
+
#
|
|
1838
|
+
# The full list stays one command away, and the line says which.
|
|
1839
|
+
IN_FLIGHT_MAX_FILES=6
|
|
1840
|
+
IN_FLIGHT_MAX_BRANCHES=8
|
|
1841
|
+
|
|
1842
|
+
report_in_flight() { # $1=candidate branch
|
|
1843
|
+
local br files shown extra n=0 total
|
|
1844
|
+
total=$(work_in_flight "$1" | wc -l | tr -d ' ')
|
|
1845
|
+
[ "${total:-0}" -gt 0 ] || return 0
|
|
1846
|
+
|
|
1847
|
+
work_in_flight "$1" | while IFS=$'\t' read -r br files; do
|
|
1848
|
+
n=$((n + 1))
|
|
1849
|
+
if [ "$n" -gt "$IN_FLIGHT_MAX_BRANCHES" ]; then
|
|
1850
|
+
# Said once, on the last line, rather than per branch.
|
|
1851
|
+
[ "$n" = "$((IN_FLIGHT_MAX_BRANCHES + 1))" ] && \
|
|
1852
|
+
echo " in flight: …and $((total - IN_FLIGHT_MAX_BRANCHES)) more branches" \
|
|
1853
|
+
"— plot-fleet for the full picture"
|
|
1854
|
+
continue
|
|
1855
|
+
fi
|
|
1856
|
+
# Commas to ", " for reading; the machine-countable summary is the footer,
|
|
1857
|
+
# so this line is allowed to be prose.
|
|
1858
|
+
shown=$(printf '%s' "$files" | tr ',' '\n' | head -"$IN_FLIGHT_MAX_FILES" \
|
|
1859
|
+
| tr '\n' ',' | sed -e 's/,$//' -e 's/,/, /g')
|
|
1860
|
+
# `printf '%s'` writes no trailing newline, so `wc -l` counts SEPARATORS
|
|
1861
|
+
# and undercounts the last field by one. Terminating the stream with
|
|
1862
|
+
# printf '\n' is what makes the remainder exact — it reported "+2 more"
|
|
1863
|
+
# for nine files with six shown until a test pinned the arithmetic.
|
|
1864
|
+
extra=$(( $(printf '%s\n' "$files" | tr ',' '\n' | wc -l) - IN_FLIGHT_MAX_FILES ))
|
|
1865
|
+
[ "$extra" -gt 0 ] && shown="$shown (+$extra more)"
|
|
1866
|
+
echo " in flight: $br holds $shown"
|
|
1867
|
+
done
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
# A dry run changes nothing, so nothing can go stale — read the whole eligible
|
|
1871
|
+
# set once. (`--next` would loop forever here: without a claim it keeps
|
|
1872
|
+
# returning the same branch.)
|
|
1873
|
+
if [ "$dry_run" = 1 ]; then
|
|
1874
|
+
while read -r br; do
|
|
1875
|
+
[ -n "$br" ] || continue
|
|
1876
|
+
# The gate, BEFORE the "would dispatch" line — this loop's whole output is
|
|
1877
|
+
# a prediction, and predicting a dispatch the real run refuses is the
|
|
1878
|
+
# failure the gate exists to stop.
|
|
1879
|
+
if held=$(held_worktree "$br"); then
|
|
1880
|
+
report_held "$br" "$held"
|
|
1881
|
+
n_skipped=$((n_skipped + 1))
|
|
1882
|
+
continue
|
|
1883
|
+
fi
|
|
1884
|
+
echo "would dispatch $br → $(worktree_for "$br")"
|
|
1885
|
+
report_in_flight "$br"
|
|
1886
|
+
n_dispatched=$((n_dispatched + 1))
|
|
1887
|
+
done < <("$script_dir/plot-fleet-scan.sh" $offline --list-eligible "$slug" 2>/dev/null)
|
|
1888
|
+
# A dry run starts nothing BY CONSTRUCTION, so its `started=0` carries no
|
|
1889
|
+
# information about the config — reporting "no workers started" here would be
|
|
1890
|
+
# true and useless, and would train the reader to skip the line on the real
|
|
1891
|
+
# run where it matters. Only the machine field travels.
|
|
1892
|
+
# `skipped` is REAL here, not a constant. A dry run refuses held branches
|
|
1893
|
+
# exactly as the real run does, so its count is a fact about this fleet — and
|
|
1894
|
+
# it was hardcoded to 0 until the gate gave it something to count.
|
|
1895
|
+
echo "summary: dispatched=$n_dispatched reused=0 skipped=$n_skipped started=0 brief=missing worker=$(worker_state_field)"
|
|
1896
|
+
exit 0
|
|
1897
|
+
fi
|
|
1898
|
+
|
|
1899
|
+
# Ask the fleet scan for eligible-and-unclaimed branches, one at a time.
|
|
1900
|
+
# Re-asking after each claim is deliberate (pull, not push): the answer changes
|
|
1901
|
+
# as we claim, and a list computed up front would go stale mid-fan-out.
|
|
1902
|
+
while :; do
|
|
1903
|
+
[ "$max" -gt 0 ] && [ "$n_dispatched" -ge "$max" ] && break
|
|
1904
|
+
|
|
1905
|
+
branch=$("$script_dir/plot-fleet-scan.sh" $offline --next "$slug" 2>/dev/null) || break
|
|
1906
|
+
[ -n "$branch" ] || break
|
|
1907
|
+
# --next has no memory; if it offers something we already failed on, the
|
|
1908
|
+
# eligible set is exhausted for this run.
|
|
1909
|
+
is_exhausted "$branch" && break
|
|
1910
|
+
|
|
1911
|
+
# Flatten the whole branch name, not just its last segment: feature/api and
|
|
1912
|
+
# bug/api are different work and must not share a worktree (a shared path
|
|
1913
|
+
# also makes --stop act on whichever claimed it first).
|
|
1914
|
+
suffix=$(printf '%s' "$branch" | tr '/' '-')
|
|
1915
|
+
wt="$wt_root/$wt_prefix$suffix"
|
|
1916
|
+
|
|
1917
|
+
if [ "$dry_run" = 1 ]; then
|
|
1918
|
+
echo "would dispatch $branch → $wt"
|
|
1919
|
+
report_in_flight "$branch"
|
|
1920
|
+
n_dispatched=$((n_dispatched + 1))
|
|
1921
|
+
continue
|
|
1922
|
+
fi
|
|
1923
|
+
|
|
1924
|
+
# THE HELD-BRANCH GATE, ahead of every write this loop makes.
|
|
1925
|
+
#
|
|
1926
|
+
# Ahead of the adoption path below in particular: `reusing existing worktree`
|
|
1927
|
+
# is right for a desk THIS script laid out and a worker has since finished
|
|
1928
|
+
# with, and wrong for one an operator opened by hand and is still using — and
|
|
1929
|
+
# by tip alone those two are the same directory. Unlanded work is what
|
|
1930
|
+
# separates them, so the gate asks first and adoption only sees what is left.
|
|
1931
|
+
#
|
|
1932
|
+
# `exhausted` is what makes the refusal terminal: --next has no memory and
|
|
1933
|
+
# would keep offering this same branch until the loop's own break fired.
|
|
1934
|
+
if held=$(held_worktree "$branch"); then
|
|
1935
|
+
report_held "$branch" "$held"
|
|
1936
|
+
n_skipped=$((n_skipped + 1))
|
|
1937
|
+
exhausted+=("$branch")
|
|
1938
|
+
continue
|
|
1939
|
+
fi
|
|
1940
|
+
|
|
1941
|
+
# BEFORE the worktree exists. Once dispatch has created this candidate's
|
|
1942
|
+
# worktree and claim, the candidate is itself work in flight, and a report
|
|
1943
|
+
# taken afterwards would describe the fan-out rather than what preceded it.
|
|
1944
|
+
in_flight=$(report_in_flight "$branch")
|
|
1945
|
+
|
|
1946
|
+
# Adopt an existing worktree rather than duplicating it.
|
|
1947
|
+
if git worktree list --porcelain | grep -qx "worktree $wt"; then
|
|
1948
|
+
echo "reusing existing worktree for $branch → $wt"
|
|
1949
|
+
[ -n "$in_flight" ] && printf '%s\n' "$in_flight"
|
|
1950
|
+
n_reused=$((n_reused + 1))
|
|
1951
|
+
else
|
|
1952
|
+
git worktree add -q -b "$branch" "$wt" "origin/$MAIN" 2>/dev/null || {
|
|
1953
|
+
# Branch exists locally already: attach the worktree to it instead.
|
|
1954
|
+
git worktree add -q "$wt" "$branch" 2>/dev/null || {
|
|
1955
|
+
echo "skipped $branch (cannot create worktree)"
|
|
1956
|
+
n_skipped=$((n_skipped + 1))
|
|
1957
|
+
exhausted+=("$branch")
|
|
1958
|
+
continue
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
# THE CLAIM. Rejection means another session won the race; leave its
|
|
1962
|
+
# worktree alone and move on to the next branch.
|
|
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
|
|
1993
|
+
fi
|
|
1994
|
+
|
|
1995
|
+
if [ "$no_start" = 0 ]; then
|
|
1996
|
+
# THE BRIEF GATE, between preparing and starting. Prepared work above stands;
|
|
1997
|
+
# only the launch is conditional. A missing brief refuses, naming the file
|
|
1998
|
+
# and the two ways forward — write it, or pass --no-brief. --no-brief starts
|
|
1999
|
+
# anyway and SAYS SO, so the override is on the record rather than silent.
|
|
2000
|
+
#
|
|
2001
|
+
# The refusal names the REF it looked at, not a bare path. A brief sitting
|
|
2002
|
+
# unpushed in the operator's checkout is the likeliest reason to see this
|
|
2003
|
+
# message, and "no brief at .plot/briefs/x.md" would send them to look at a
|
|
2004
|
+
# file that is right there — the ref says where the WORKER will look.
|
|
2005
|
+
if brief_present "$branch"; then
|
|
2006
|
+
start_worker "$branch" "$wt" && n_started=$((n_started + 1))
|
|
2007
|
+
elif [ "$no_brief" = 1 ]; then
|
|
2008
|
+
echo " no brief at $(brief_ref "$branch") — starting anyway (--no-brief)"
|
|
2009
|
+
start_worker "$branch" "$wt" && n_started=$((n_started + 1))
|
|
2010
|
+
else
|
|
2011
|
+
echo " prepared, not started — no brief at $(brief_ref "$branch")"
|
|
2012
|
+
echo " write one: /plot-implement $slug (then push it, or pass --no-brief to start without it)"
|
|
2013
|
+
fi
|
|
2014
|
+
fi
|
|
2015
|
+
done
|
|
2016
|
+
|
|
2017
|
+
# Book AFTER the fan-out, in one commit, so a booking that fails cannot leave
|
|
2018
|
+
# the plan claiming starts the run did not achieve. Its failure is reported and
|
|
2019
|
+
# then ignored: the summary below reports what was dispatched either way.
|
|
2020
|
+
book_started ${claimed_now[@]+"${claimed_now[@]}"} || true
|
|
2021
|
+
|
|
2022
|
+
# Check if we exceeded the cap and raise it if so — see "THE CAP GATES
|
|
2023
|
+
# AUTO-DISPATCH AND WARNS A PERSON" above. Done AFTER workers are started so
|
|
2024
|
+
# the count reflects the true state, and BEFORE the summary so the warning
|
|
2025
|
+
# appears before the footer.
|
|
2026
|
+
check_and_update_cap "$n_started"
|
|
2027
|
+
|
|
2028
|
+
print_summary "$n_dispatched" "$n_reused" "$n_skipped" "$n_started"
|