@plot-pm/board 0.9.0 → 0.9.1
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 +4 -4
- package/package.json +11 -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-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
|
@@ -0,0 +1,3496 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Plot helper: fleet pulse — deterministic extractor for wave/claim state.
|
|
3
|
+
# Usage: plot-fleet-scan.sh [--no-fetch] [--offline] [--next] [<slug>]
|
|
4
|
+
# --no-fetch skip `git fetch`
|
|
5
|
+
# --offline same (no network) — used for cheap, ambient pulses.
|
|
6
|
+
# The fetch also PRUNES remote-tracking refs, so skipping it
|
|
7
|
+
# keeps whatever stale refs this checkout holds: a branch merged
|
|
8
|
+
# and deleted upstream may read `wip` rather than `merged`, and
|
|
9
|
+
# its wave may read blocked. That is the honest answer for a scan
|
|
10
|
+
# that asked nothing, and the footer says so rather than leaving
|
|
11
|
+
# it to be discovered.
|
|
12
|
+
# --list-eligible print EVERY claimable branch, one per line (exit 1 if none).
|
|
13
|
+
# For callers that need the count rather than one item — a dry
|
|
14
|
+
# run changes nothing, so its answer cannot go stale.
|
|
15
|
+
# --loose a prior wave counts as satisfied when its branches carry PUSHED
|
|
16
|
+
# work, not only merged work. Buys throughput, pays in rebase
|
|
17
|
+
# risk — the plan requires a stated reason for using it. Default
|
|
18
|
+
# is strict (merged only).
|
|
19
|
+
# --log-pulse append one pulse line to each reported plan's ## Notes, clean
|
|
20
|
+
# pulses included — without a record of quiet pulses an idle fleet
|
|
21
|
+
# and a dead fleet look identical. The ONLY thing this script ever
|
|
22
|
+
# writes, and it is a log, not state.
|
|
23
|
+
# --next print ONE claimable branch name and exit 0; print nothing and
|
|
24
|
+
# exit 1 when there is none. Used by /plot-implement to pick work
|
|
25
|
+
# without re-deriving eligibility. "Nothing to start" is a normal
|
|
26
|
+
# state — the exit code, not stderr, is what says so.
|
|
27
|
+
# --stream --json, emitted as it resolves rather than as one document at
|
|
28
|
+
# the end. One `{"kind":"plan","plan":{...}}` line per plan the
|
|
29
|
+
# moment that plan is fully derived, then one
|
|
30
|
+
# `{"kind":"pulse","pulse":{...}}` line carrying the SAME
|
|
31
|
+
# document --json prints. A consumer that has seen plan lines and
|
|
32
|
+
# no pulse line holds a PARTIAL answer — the scan takes 18 s on
|
|
33
|
+
# 84 branches and a board that renders nothing for that long
|
|
34
|
+
# looks broken. The terminal line is what says the scan finished;
|
|
35
|
+
# a closed pipe does not, because a killed scan closes it too.
|
|
36
|
+
# <slug> limit the report to one plan (default: all active plans)
|
|
37
|
+
# Wave verdicts — the word each wave carries, and what a reader may do with it:
|
|
38
|
+
# complete every non-deferred branch of the wave has merged
|
|
39
|
+
# eligible A DISPATCH WOULD TAKE THIS: prior waves landed AND the plan is
|
|
40
|
+
# approved. The word a reader acts on, and the only one that
|
|
41
|
+
# promises the dispatcher agrees.
|
|
42
|
+
# blocked an earlier wave has not landed — resolves by merging work
|
|
43
|
+
# unapproved the plan is not approved, so nothing here may be dispatched —
|
|
44
|
+
# resolves by a person approving the plan, not by any merge.
|
|
45
|
+
# Kept apart from `blocked` because the reader's next action
|
|
46
|
+
# differs, and a terminal-phase plan lands here too (it is not
|
|
47
|
+
# approved); the board routes those to DONE by phase before the
|
|
48
|
+
# verdict is read.
|
|
49
|
+
# Output: per-plan wave report on stdout, terminated by a machine-countable
|
|
50
|
+
# summary line:
|
|
51
|
+
# summary: plans=1 waves=3 branches=5 claimed=1 eligible=2 blocked=1 deferred=1 merge_detect=pr-merge main=main
|
|
52
|
+
# merge_detect names how merged-and-deleted branches were detected:
|
|
53
|
+
# pr-merge (exhaustive), truncated (capped walk), none (no conforming
|
|
54
|
+
# merge commits — a squash/rebase repo, where `open` says nothing about
|
|
55
|
+
# merging).
|
|
56
|
+
# Consumers that only need counts (the /plot-fleet pulse log, the
|
|
57
|
+
# board) read that one line and never re-count the body.
|
|
58
|
+
# --json additionally carries, per PLAN, `phase` — the plan's own
|
|
59
|
+
# lifecycle state, verbatim from plot-plan-meta.sh, and the half of a
|
|
60
|
+
# row's phase git cannot answer. Which column a row reads is composed
|
|
61
|
+
# from it AND the branch state one layer up; this script decides nothing.
|
|
62
|
+
# The plan set also includes plans delivered inside a rolling 24 h
|
|
63
|
+
# window (see "the last day of finished work"), so work does not
|
|
64
|
+
# disappear at the moment it becomes finished.
|
|
65
|
+
# Plans are enumerated from `origin/<main>` (`git ls-tree`/`git show`),
|
|
66
|
+
# NOT from the working tree — so the list describes one atomic commit
|
|
67
|
+
# and does not change while rebases and worker commits rewrite the
|
|
68
|
+
# checkout underneath a running fleet. A consequence worth stating: an
|
|
69
|
+
# UNCOMMITTED plan is invisible, deliberately — the fleet view shows
|
|
70
|
+
# what is shared, and a plan only this machine has cannot be claimed by
|
|
71
|
+
# any worker. --json carries `plan_source` (ref | worktree), which
|
|
72
|
+
# reads `worktree` only when `origin/<main>` cannot be resolved at all.
|
|
73
|
+
# --json also carries `fetch_failed` and `fetch_error`: a failed fetch
|
|
74
|
+
# means these refs are older than the report implies, and that fact is
|
|
75
|
+
# now reported rather than discarded.
|
|
76
|
+
# --json additionally carries, per branch, what THIS MACHINE knows and
|
|
77
|
+
# the refs do not: `local_dirty` (a local worktree has uncommitted
|
|
78
|
+
# changes), `local_locked` (a local worktree holds `.git/index.lock` —
|
|
79
|
+
# a write is in progress THIS INSTANT), `local_worktree` (where it is
|
|
80
|
+
# checked out here) and `local_ahead` (commits on the local branch the
|
|
81
|
+
# remote does not have). All four are absent-shaped — false, false, ""
|
|
82
|
+
# and 0 — wherever this machine holds nothing, so a branch living on
|
|
83
|
+
# another machine answers exactly as it did before.
|
|
84
|
+
# --json additionally carries `changed_at` (the epoch second of the
|
|
85
|
+
# newest evidence of work — what a CHANGE detector must watch, since it
|
|
86
|
+
# moves only when something happens) and `changed_ago_seconds`: how long since this
|
|
87
|
+
# branch last CHANGED, from any source — the newest of its last commit,
|
|
88
|
+
# the newest mtime on the floor (editor leftovers excluded) and the
|
|
89
|
+
# worker log. The other local signals are STATE and cannot separate a
|
|
90
|
+
# branch that finished from one that was abandoned; both read
|
|
91
|
+
# `ahead=0 dirty=false`. This is `null` — never 0 — wherever this
|
|
92
|
+
# machine has no worktree, and branches with none cost nothing to skip.
|
|
93
|
+
# IT IS A MEASUREMENT AND NOT A VERDICT: no threshold, no `stalled`.
|
|
94
|
+
# A worker inside a serial test suite writes nothing for minutes while
|
|
95
|
+
# its child processes work, and this will correctly report it quiet —
|
|
96
|
+
# so "quiet" must not be rendered as "stuck". Worker verdicts belong to
|
|
97
|
+
# `plot-worker-state.sh`; the threshold belongs to the reader.
|
|
98
|
+
# --json additionally carries, per branch, WHICH FILES would collide
|
|
99
|
+
# merging it into the default branch: `conflicts` (paths, from
|
|
100
|
+
# `git merge-tree --write-tree`, which computes entirely in memory),
|
|
101
|
+
# `conflicts_known` (whether the question was asked at all) and
|
|
102
|
+
# `changed_paths` (what the branch touches, capped and reported as
|
|
103
|
+
# evidence). `conflicts_known` is what keeps an empty list from meaning
|
|
104
|
+
# two things — a branch that merges cleanly and one nobody could ask.
|
|
105
|
+
# The SET is reported and never judged: which sets mean what is a
|
|
106
|
+
# decision one layer up.
|
|
107
|
+
# --json also carries, per branch, `worker` — whether anything is
|
|
108
|
+
# actually RUNNING on it: running|finished|failed|ended|none|elsewhere,
|
|
109
|
+
# with `worker_pid` and `worker_exit` beside it. A claim says a
|
|
110
|
+
# dispatcher took the branch; this says whether a worker was ever
|
|
111
|
+
# started. `none` means UNKNOWN (no pid was recorded here), never
|
|
112
|
+
# "nobody"; `elsewhere` means this machine has no worktree and so
|
|
113
|
+
# cannot answer at all. `worker_activity` rides beside them ONLY where
|
|
114
|
+
# `worker` is `running`: `working` when the worker's child is burning
|
|
115
|
+
# CPU, `idle` when its clock is frozen, "" everywhere else. A secondary
|
|
116
|
+
# cue, not a sixth state — `running` is honest and coarse, and this says
|
|
117
|
+
# which kind of running it is without the enum growing a member.
|
|
118
|
+
# Designed for small-model consumption: mechanical enumeration, no judgment.
|
|
119
|
+
#
|
|
120
|
+
# STATELESS AND READ-ONLY. This is the whole design (Manifesto Principle 1):
|
|
121
|
+
# there is no fleet database. Every fact printed here is re-derived from git
|
|
122
|
+
# refs and plan files on each run, so a killed dispatcher, a dead worker, or a
|
|
123
|
+
# crashed pulse costs nothing — the next pulse re-derives the truth. Nothing
|
|
124
|
+
# here creates a branch, pushes a ref, or starts a worker.
|
|
125
|
+
#
|
|
126
|
+
# ONE exception to "writes nothing": --log-pulse appends a pulse line to each
|
|
127
|
+
# reported plan (see below). That is a LOG, not state — deleting the whole log
|
|
128
|
+
# changes no behaviour, because the next run re-derives everything. The flag
|
|
129
|
+
# defaults OFF precisely so internal callers (plot-implement, plot-dispatch,
|
|
130
|
+
# which invoke --next) can never amend a plan as a side effect of asking what
|
|
131
|
+
# to work on; /plot-fleet, the human-facing command, passes it every run.
|
|
132
|
+
#
|
|
133
|
+
# Wave eligibility (the one rule this script encodes):
|
|
134
|
+
# A wave is ELIGIBLE when every non-deferred branch in every PRIOR wave is
|
|
135
|
+
# merged into the main branch. Prior waves outstanding → BLOCKED. All of a
|
|
136
|
+
# wave's own non-deferred branches merged → COMPLETE.
|
|
137
|
+
# Deferred branches never count as outstanding work — that is what the
|
|
138
|
+
# `<!-- deferred: -->` annotation is for.
|
|
139
|
+
#
|
|
140
|
+
# Claim state comes from git, not from the plan file. A branch whose only
|
|
141
|
+
# commits beyond main are `plot: claim ...` markers is a CLAIM: a dispatcher
|
|
142
|
+
# pushed it to take the work. The marker commit is what makes the claim
|
|
143
|
+
# exclusive — a branch merely pointing at main does not diverge from it, so a
|
|
144
|
+
# second push would succeed and both sides would think they held it.
|
|
145
|
+
# The plan's `<!-- claimed: -->` annotation is a reflection for humans and the
|
|
146
|
+
# board; where the two disagree, git wins. The one exception is the reaper in
|
|
147
|
+
# plot-reconcile-scan.sh, which reads the annotation to tell a deliberately
|
|
148
|
+
# abandoned claim from a dead worker.
|
|
149
|
+
set -uo pipefail
|
|
150
|
+
|
|
151
|
+
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
152
|
+
|
|
153
|
+
# The shared worker classifier. Sourced by both this script and
|
|
154
|
+
# plot-dispatch.sh so a worker has ONE state, not one per reader.
|
|
155
|
+
# shellcheck source=plot-worker-state.sh
|
|
156
|
+
. "$script_dir/plot-worker-state.sh"
|
|
157
|
+
cfg() { "$script_dir/plot-config.sh" get "$1" "${2:-}"; }
|
|
158
|
+
|
|
159
|
+
do_fetch=1
|
|
160
|
+
next_only=0
|
|
161
|
+
list_all=0
|
|
162
|
+
loose=0
|
|
163
|
+
log_pulse=0
|
|
164
|
+
as_json=0
|
|
165
|
+
stream=0
|
|
166
|
+
slug=""
|
|
167
|
+
while [ $# -gt 0 ]; do
|
|
168
|
+
case "$1" in
|
|
169
|
+
--no-fetch|--offline) do_fetch=0 ;;
|
|
170
|
+
--loose) loose=1 ;;
|
|
171
|
+
--log-pulse) log_pulse=1 ;;
|
|
172
|
+
--next) next_only=1 ;;
|
|
173
|
+
--list-eligible) next_only=1; list_all=1 ;;
|
|
174
|
+
--json) as_json=1 ;;
|
|
175
|
+
--stream) as_json=1; stream=1 ;;
|
|
176
|
+
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
|
|
177
|
+
*) slug="$1" ;;
|
|
178
|
+
esac
|
|
179
|
+
shift
|
|
180
|
+
done
|
|
181
|
+
|
|
182
|
+
git rev-parse --git-dir >/dev/null 2>&1 || { echo "not a git repository" >&2; exit 1; }
|
|
183
|
+
|
|
184
|
+
PLAN_DIR=$(cfg "Plan directory" "docs/plans/")
|
|
185
|
+
ACTIVE_DIR=$(cfg "Active index" "docs/plans/active/")
|
|
186
|
+
DELIVERED_DIR=$(cfg "Delivered index" "docs/plans/delivered/")
|
|
187
|
+
PREFIX_RE=$(cfg "Branch prefixes" "idea/, feature/, bug/, docs/, infra/" \
|
|
188
|
+
| tr -d ' ' | tr ',' '\n' | sed 's#/$##' | grep -v '^$' | paste -sd'|' -)
|
|
189
|
+
[ -n "$PREFIX_RE" ] || PREFIX_RE="idea|feature|bug|docs|infra"
|
|
190
|
+
|
|
191
|
+
MAIN=$(cfg "Main branch")
|
|
192
|
+
if [ -z "$MAIN" ]; then
|
|
193
|
+
MAIN=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
|
|
194
|
+
fi
|
|
195
|
+
[ -n "$MAIN" ] || MAIN="main"
|
|
196
|
+
|
|
197
|
+
# A FAILED FETCH IS A FACT, not a shrug. The old line was
|
|
198
|
+
# `git fetch ... 2>/dev/null` with its status discarded: a GitHub 503, a
|
|
199
|
+
# concurrent worker holding the ref lock, an offline laptop — every one of
|
|
200
|
+
# them produced a scan indistinguishable from a healthy one, reporting refs
|
|
201
|
+
# that were older than the banner claimed. That is the same failure this whole
|
|
202
|
+
# plan is about, one layer up: the report was more confident than its evidence.
|
|
203
|
+
#
|
|
204
|
+
# The scan STILL RUNS on a failed fetch, and that is deliberate. `origin/$MAIN`
|
|
205
|
+
# from an hour ago is a real answer about a real commit; refusing to report it
|
|
206
|
+
# would trade a slightly stale board for no board at all, exactly when the
|
|
207
|
+
# operator is most likely to be watching something go wrong. What changes is
|
|
208
|
+
# that the staleness is CARRIED — `fetch_failed` in `--json`, a line in the
|
|
209
|
+
# prose report — so a consumer can mark the view instead of trusting it blindly.
|
|
210
|
+
#
|
|
211
|
+
# --offline/--no-fetch is NOT a failure. The operator asked for local refs and
|
|
212
|
+
# got them; there is nothing to report but the fact that no fetch was tried.
|
|
213
|
+
#
|
|
214
|
+
# THE FETCH PRUNES WHAT IT FETCHES. `git fetch` does not remove
|
|
215
|
+
# remote-tracking refs for branches deleted upstream; only `--prune` does. A
|
|
216
|
+
# branch merged with --delete-branch therefore leaves `refs/remotes/origin/<br>`
|
|
217
|
+
# behind on every machine that ever fetched it, and it survives until somebody
|
|
218
|
+
# prunes for unrelated reasons. That leftover is not noise: branch_state()
|
|
219
|
+
# picks its arm on the ref's PRESENCE, so a stale ref routes the branch into
|
|
220
|
+
# the ancestry path — which a squash merge breaks by construction — and the
|
|
221
|
+
# host lookup that would have answered `merged` is never reached. Measured
|
|
222
|
+
# 2026-08-18: a wave could not be dispatched at all until an operator happened
|
|
223
|
+
# to run `git fetch --prune` by hand.
|
|
224
|
+
#
|
|
225
|
+
# THE EXPLICIT REFSPEC IS REQUIRED, and this is the part that is easy to get
|
|
226
|
+
# wrong: `git fetch --prune origin "$MAIN"` prunes NOTHING outside $MAIN.
|
|
227
|
+
# Naming a refspec scopes the prune to that refspec's destination namespace, so
|
|
228
|
+
# the narrow fetch this scan makes would prune only `refs/remotes/origin/$MAIN`
|
|
229
|
+
# — a no-op for exactly the branches this exists to clear. Restating the
|
|
230
|
+
# default heads refspec widens the prune back to the whole mirror while the
|
|
231
|
+
# narrow one keeps the intent legible: fetch $MAIN, and while the connection is
|
|
232
|
+
# open, make the local mirror match the remote.
|
|
233
|
+
#
|
|
234
|
+
# NOTHING HERE DEPENDS ON A STALE REF SURVIVING. The case to fear is a branch
|
|
235
|
+
# deleted upstream while a local worktree still holds work: `local_ahead_of()`
|
|
236
|
+
# reads `refs/remotes/origin/<br>..refs/heads/<br>`, so pruning removes its left
|
|
237
|
+
# side. It already answers 0 on a missing ref by exit code rather than by
|
|
238
|
+
# emptiness ("not observed → not reported"), which is the same answer it gives
|
|
239
|
+
# for every branch living on another machine — so the count degrades to absent,
|
|
240
|
+
# never to a wrong number. `local_dirty`, `local_locked` and `local_worktree`
|
|
241
|
+
# read the worktree, not the mirror, so uncommitted work stays visible either
|
|
242
|
+
# way. Conflict prediction is gated to wip|claimed and a pruned branch is
|
|
243
|
+
# neither. The local `refs/heads/<br>` is untouched: --prune removes only
|
|
244
|
+
# remote-tracking refs, so no local work is destroyed or hidden by this.
|
|
245
|
+
#
|
|
246
|
+
# ONE CONNECTION, NOT TWO. The prune rides the fetch already being made — no
|
|
247
|
+
# extra round trip on a scan the board polls every five seconds.
|
|
248
|
+
FETCH_FAILED=0
|
|
249
|
+
FETCH_ERROR=""
|
|
250
|
+
if [ "$do_fetch" = 1 ]; then
|
|
251
|
+
if ! FETCH_ERROR=$(git fetch -q --prune origin "$MAIN" \
|
|
252
|
+
"+refs/heads/*:refs/remotes/origin/*" 2>&1); then
|
|
253
|
+
FETCH_FAILED=1
|
|
254
|
+
# Collapsed to one line: git's multi-line advice is for a human at a
|
|
255
|
+
# terminal, and this string travels through JSON into a board cell.
|
|
256
|
+
FETCH_ERROR=$(printf '%s' "$FETCH_ERROR" | tr '\n' ' ' | sed 's/ */ /g; s/^ *//; s/ *$//')
|
|
257
|
+
[ -n "$FETCH_ERROR" ] || FETCH_ERROR="git fetch failed with no message"
|
|
258
|
+
else
|
|
259
|
+
FETCH_ERROR=""
|
|
260
|
+
fi
|
|
261
|
+
fi
|
|
262
|
+
|
|
263
|
+
# --loose promises "the prior wave's PRs are green and ready", which needs the
|
|
264
|
+
# git host. An earlier version accepted ANY pushed commit — strictly weaker
|
|
265
|
+
# than promised, and dangerous: red CI or a draft PR would open the next wave,
|
|
266
|
+
# so it built on a seam that was not merely unlanded but possibly broken.
|
|
267
|
+
#
|
|
268
|
+
# Readiness must be VERIFIED, never assumed. Without a host CLI, --loose
|
|
269
|
+
# degrades to strict and says so: an unverifiable claim of readiness is not
|
|
270
|
+
# readiness.
|
|
271
|
+
loose_verifiable=0
|
|
272
|
+
if [ "$loose" = 1 ]; then
|
|
273
|
+
if [ "$do_fetch" = 1 ] && "$script_dir/plot-host.sh" backend >/dev/null 2>&1 \
|
|
274
|
+
&& [ "$("$script_dir/plot-host.sh" backend 2>/dev/null)" != "none" ]; then
|
|
275
|
+
loose_verifiable=1
|
|
276
|
+
fi
|
|
277
|
+
fi
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# Merged-and-deleted branches: the evidence that survives the ref
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
#
|
|
283
|
+
# A branch merged by PR usually has its ref deleted at merge, and nothing local
|
|
284
|
+
# survives it — `git reflog show origin/<br>` fails outright and for-each-ref
|
|
285
|
+
# finds nothing. What DOES survive is the merge commit on the default branch.
|
|
286
|
+
#
|
|
287
|
+
# Asking the host for merged PRs (as plot-reconcile-scan.sh does) is not
|
|
288
|
+
# available here: this scan is git-only on its default path, which is exactly
|
|
289
|
+
# why the board can poll it every 5 s. One board already costs 80 GraphQL
|
|
290
|
+
# calls/hour; a metered scan on a 5-second timer would dwarf that.
|
|
291
|
+
#
|
|
292
|
+
# The candidate set is what is REACHABLE from the default branch, matched by an
|
|
293
|
+
# ANCHORED subject:
|
|
294
|
+
#
|
|
295
|
+
# ^Merge pull request #<n> from <owner>/<branch>$
|
|
296
|
+
#
|
|
297
|
+
# The anchoring is the whole mechanism. A name-only grep reads a BACKWARD merge
|
|
298
|
+
# — `Merge remote-tracking branch 'origin/main' into <branch>` — as evidence
|
|
299
|
+
# that <branch> landed, when it means the opposite: main was pulled INTO the
|
|
300
|
+
# branch. That inversion reports unfinished work as finished and opens the next
|
|
301
|
+
# wave on an unlanded seam, which is strictly worse than the bug this fixes. A
|
|
302
|
+
# backward merge opens with a different sentence, so it cannot match.
|
|
303
|
+
#
|
|
304
|
+
# TWO STRUCTURAL FILTERS WERE MEASURED AND REMOVED. Do not reintroduce either;
|
|
305
|
+
# see docs/plans/2026-08-16-fleet-sees-merged-branches.md for the numbers.
|
|
306
|
+
# * A FIRST-PARENT filter looked convincing at "119 merges → 109 on the
|
|
307
|
+
# chain". Measured against the right baseline — the anchored pattern, not
|
|
308
|
+
# raw merges — it scores 108 to 108: it catches NOTHING extra, because
|
|
309
|
+
# backward merges cannot match the anchored pattern anyway. And it breaks
|
|
310
|
+
# GitFlow: a feature merged into `develop`, where `develop` later merges to
|
|
311
|
+
# the default branch, is not on the first-parent chain and would read
|
|
312
|
+
# `open` while its work is an ancestor.
|
|
313
|
+
# * A SECOND-PARENT counter-check does not discriminate: PR merges and
|
|
314
|
+
# backward merges both have a distinct second-parent tip.
|
|
315
|
+
#
|
|
316
|
+
# Reachability does not over-report either: a PR merged into a long-lived
|
|
317
|
+
# branch that was then abandoned is not reachable from the default branch at
|
|
318
|
+
# all. Reachability is itself an ancestry claim, so it cannot see work that
|
|
319
|
+
# never arrived.
|
|
320
|
+
#
|
|
321
|
+
# The walk is bundled — ONE `git log` per run, not one per branch. branch_state
|
|
322
|
+
# runs per branch and the board polls every 5 s, so the naive shape is
|
|
323
|
+
# O(history × branches) where O(history + branches) is available (measured:
|
|
324
|
+
# 197 ms vs 79 ms on a 2000-merge fixture). Same bundling rule
|
|
325
|
+
# plot-reconcile-scan.sh applies to PR lists, with local data.
|
|
326
|
+
#
|
|
327
|
+
# The cap guards against a pathological history rather than buying time — the
|
|
328
|
+
# walk is local and nearly free (cap 500: 7.7 ms, no cap: 11.8 ms at 2000
|
|
329
|
+
# merges). It is therefore set high, and SATURATION IS REPORTED. A blind cap
|
|
330
|
+
# re-creates this very bug: at 300 against 2000 merges an early merge is not
|
|
331
|
+
# found and reads `open`, hitting precisely the long-hanging plans most likely
|
|
332
|
+
# to suffer it.
|
|
333
|
+
#
|
|
334
|
+
# PLOT_MERGE_SCAN_LIMIT exists so the test suite can force saturation against a
|
|
335
|
+
# small fixture — a cap of 2000 is otherwise unreachable in a test. It is a
|
|
336
|
+
# seam, not a knob: nothing in Plot sets it, and lowering it in real use buys
|
|
337
|
+
# nothing but the silent misses described above.
|
|
338
|
+
MERGE_SCAN_LIMIT=${PLOT_MERGE_SCAN_LIMIT:-2000}
|
|
339
|
+
MERGE_SUBJECTS=$(git log "origin/$MAIN" --merges \
|
|
340
|
+
--max-count="$MERGE_SCAN_LIMIT" --pretty=%s </dev/null 2>/dev/null || true)
|
|
341
|
+
MERGE_SCAN_TRUNCATED=0
|
|
342
|
+
if [ -n "$MERGE_SUBJECTS" ] \
|
|
343
|
+
&& [ "$(printf '%s\n' "$MERGE_SUBJECTS" | grep -c .)" -ge "$MERGE_SCAN_LIMIT" ]; then
|
|
344
|
+
MERGE_SCAN_TRUNCATED=1
|
|
345
|
+
fi
|
|
346
|
+
|
|
347
|
+
# merge_detect names the detection source in the footer, the way
|
|
348
|
+
# plot-reconcile-scan.sh names pr_source. `open` must stop meaning both "never
|
|
349
|
+
# started" and "I could not tell" — that ambiguity is the defect this fix
|
|
350
|
+
# exists to remove, and it would otherwise reappear one level up.
|
|
351
|
+
# pr-merge — conforming merge commits were found and examined exhaustively
|
|
352
|
+
# truncated — the walk hit its cap; a branch merged before that point may
|
|
353
|
+
# still read `open`. Its own value, not folded into pr-merge: a
|
|
354
|
+
# capped walk detected, but not exhaustively.
|
|
355
|
+
# none — the default branch carries no conforming merge commits at all
|
|
356
|
+
# (a squash/rebase repo), so `open` says nothing about merging.
|
|
357
|
+
if printf '%s\n' "$MERGE_SUBJECTS" | grep -qE '^Merge pull request #[0-9]+ from [^/]+/.+$'; then
|
|
358
|
+
MERGE_DETECT=$([ "$MERGE_SCAN_TRUNCATED" = 1 ] && echo truncated || echo pr-merge)
|
|
359
|
+
else
|
|
360
|
+
MERGE_DETECT=none
|
|
361
|
+
fi
|
|
362
|
+
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
# Squash merges: the case where no local evidence survives at all
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
#
|
|
367
|
+
# The merge walk above finds a branch whose PR produced a MERGE COMMIT. A
|
|
368
|
+
# squash merge produces none: measured on the merge of PR #209, the commit on
|
|
369
|
+
# the default branch has ONE parent and a subject naming `(#209)` — the PR
|
|
370
|
+
# number, never the branch. So the exhaustive walk has nothing to match, and a
|
|
371
|
+
# branch squash-merged and deleted reads `open`: the same word used for work
|
|
372
|
+
# nobody has started. Its wave never completes, and the next wave stays blocked
|
|
373
|
+
# forever.
|
|
374
|
+
#
|
|
375
|
+
# The data is not missing — it is simply not local. `pr-state` answers in one
|
|
376
|
+
# call, and a branch with NO REF is exactly where that call is worth making:
|
|
377
|
+
# nothing local is left to read, so the host is the only remaining source, and
|
|
378
|
+
# the cost follows the count of ABSENT branches rather than all of them.
|
|
379
|
+
#
|
|
380
|
+
# THE FAILURE DIRECTION IS THE POINT. `plot-host.sh` already separates a lookup
|
|
381
|
+
# miss (exit 0, state NONE) from a transport failure (non-zero) — the
|
|
382
|
+
# distinction it grew on 2026-08-17, when GitHub returned 503 all afternoon and
|
|
383
|
+
# every branch read as having no PR. Both arms land on today's `open` here, and
|
|
384
|
+
# ONLY an explicit "MERGED" may move a branch off it. An unreachable host must
|
|
385
|
+
# never manufacture a `merged`, because `merged` settles a wave and opens the
|
|
386
|
+
# next one on work that may not have landed.
|
|
387
|
+
#
|
|
388
|
+
# GATED ONCE PER RUN, not per branch, and it honours --offline: that flag
|
|
389
|
+
# promises no network, and a scan that promised no network and then called the
|
|
390
|
+
# host would be lying in the direction of a slow ambient pulse. Without a
|
|
391
|
+
# backend — or offline — the lookup is simply never attempted and every branch
|
|
392
|
+
# answers exactly as it did before.
|
|
393
|
+
HOST_LOOKUP_OK=0
|
|
394
|
+
if [ "$do_fetch" = 1 ] \
|
|
395
|
+
&& [ "$("$script_dir/plot-host.sh" backend 2>/dev/null)" != "none" ]; then
|
|
396
|
+
HOST_LOOKUP_OK=1
|
|
397
|
+
fi
|
|
398
|
+
|
|
399
|
+
# ONE ANSWER PER BRANCH PER RUN, cached on disk rather than in a variable.
|
|
400
|
+
#
|
|
401
|
+
# `branch_state` is called as `$(branch_state "$br")` — a SUBSHELL — so a
|
|
402
|
+
# variable it assigned would be discarded the moment the substitution closed,
|
|
403
|
+
# and every branch would pay the call again. The board polls this scan every
|
|
404
|
+
# 5 s; a plan whose branches were all squash-merged would otherwise spend one
|
|
405
|
+
# host call per branch, per poll, forever.
|
|
406
|
+
#
|
|
407
|
+
# A directory is the cache because it survives the subshell without restructuring
|
|
408
|
+
# the caller, and it is created per run and removed on exit, so no answer ever
|
|
409
|
+
# outlives the scan that fetched it — a stale `merged` read from a previous run
|
|
410
|
+
# is exactly the fabricated verdict the failure direction above forbids.
|
|
411
|
+
#
|
|
412
|
+
# Cleanup is trapped rather than trailing: the script exits early in several
|
|
413
|
+
# places (--next with nothing to start, no active plans), and a temp directory
|
|
414
|
+
# left behind on those paths would accumulate one per poll.
|
|
415
|
+
HOST_STATE_CACHE=""
|
|
416
|
+
if [ "$HOST_LOOKUP_OK" = 1 ]; then
|
|
417
|
+
HOST_STATE_CACHE=$(mktemp -d 2>/dev/null) || HOST_STATE_CACHE=""
|
|
418
|
+
[ -n "$HOST_STATE_CACHE" ] \
|
|
419
|
+
&& trap 'rm -rf "$HOST_STATE_CACHE" 2>/dev/null || true' EXIT INT TERM
|
|
420
|
+
fi
|
|
421
|
+
|
|
422
|
+
# The cache key. Shared by the join and by `host_pr_state`, because the two
|
|
423
|
+
# must agree on it EXACTLY: a prefill written under one spelling and read under
|
|
424
|
+
# another is a cache that silently never hits, which restores the per-branch
|
|
425
|
+
# cost this whole change removes — and does it invisibly, since every answer is
|
|
426
|
+
# still correct.
|
|
427
|
+
#
|
|
428
|
+
# The branch name contains slashes and a flat file per branch needs them gone,
|
|
429
|
+
# but the mapping must be INJECTIVE. `tr '/' '_'` is not: `feature/a_b` and
|
|
430
|
+
# `feature_a/b` are both legal refs and collapse to one key, and a collision
|
|
431
|
+
# here serves one branch's verdict to another — which, when the verdict is
|
|
432
|
+
# `merged`, settles a wave on a branch nobody looked at. Encoding `_` first
|
|
433
|
+
# makes the substitution reversible, so distinct refs stay distinct.
|
|
434
|
+
cache_key() { # $1=branch → a filename that is injective in the branch name
|
|
435
|
+
printf '%s' "$1" | sed 's/_/__/g; s|/|_|g'
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
# ---------------------------------------------------------------------------
|
|
439
|
+
# ONE LIST, JOINED LOCALLY — not one lookup per branch
|
|
440
|
+
# ---------------------------------------------------------------------------
|
|
441
|
+
#
|
|
442
|
+
# THE MEASUREMENT THAT FORCED THIS (issues #228 and #226, both 2026-08-18):
|
|
443
|
+
#
|
|
444
|
+
# bitbucket/ekzweb 14 branches → 39 `bb` calls, scan unfinished at 110 s
|
|
445
|
+
# this repo, GitHub 84 branches × 438 ms → 34 s observed
|
|
446
|
+
# one pr-list (all) → 1107 ms
|
|
447
|
+
#
|
|
448
|
+
# The board's `run()` helper times out at 30 s (`fleet.ts:260`), so the scan had
|
|
449
|
+
# begun exceeding it on GitHub too: the board served a pulse 644 s old while
|
|
450
|
+
# reporting `Command failed`, and the reason was invisible to the operator.
|
|
451
|
+
#
|
|
452
|
+
# The scan needs `branch → state` for a KNOWN SET of branches. That is a JOIN
|
|
453
|
+
# over one response, not N lookups — and the ratio worsens with every branch
|
|
454
|
+
# added, which is what makes it a shape problem rather than a tuning one.
|
|
455
|
+
#
|
|
456
|
+
# WHAT THE JOIN MAY NOT DO, and why it is written the way it is:
|
|
457
|
+
#
|
|
458
|
+
# * AN EMPTY JOIN IS NOT A FAILED JOIN. `plot-host.sh` separates a lookup
|
|
459
|
+
# miss (exit 0, state NONE) from a transport failure (non-zero) — the
|
|
460
|
+
# distinction it grew on 2026-08-17, when GitHub returned 503 all afternoon
|
|
461
|
+
# and every branch read as having no PR. A join over a response that never
|
|
462
|
+
# arrived is that same trap in a new shape, so the list's EXIT CODE is
|
|
463
|
+
# checked before its payload is read, and a failed list prefills NOTHING.
|
|
464
|
+
# Branches then answer `-` (unanswerable) exactly as they did during an
|
|
465
|
+
# outage before this existed — never a confident "no PR".
|
|
466
|
+
#
|
|
467
|
+
# * THE PAGE LIMIT IS NOT OPTIONAL. Measured on this repo 2026-08-18: 221 PRs
|
|
468
|
+
# exist and `pr-list --state all` returns 30 without `--limit`, because that
|
|
469
|
+
# is the host CLI's default. Joining against the newest 30 would silently
|
|
470
|
+
# lose 191 PRs — every older merged branch reading as "no PR", which is the
|
|
471
|
+
# fabricated verdict this scan refuses everywhere else. The limit is asked
|
|
472
|
+
# for explicitly and generously; one call for all 221 measured at 1.8 s.
|
|
473
|
+
#
|
|
474
|
+
# * THE VOCABULARY IS THE HOST ADAPTER'S, UNCHANGED. `pr-list` already emits
|
|
475
|
+
# OPEN/MERGED/CLOSED per PR and already folds Bitbucket's DECLINED into
|
|
476
|
+
# CLOSED, the same three-way vocabulary `pr-state` produces. Nothing is
|
|
477
|
+
# translated here.
|
|
478
|
+
#
|
|
479
|
+
# ONE PR PER BRANCH, and the ordering decides which. A branch can carry several
|
|
480
|
+
# PRs over its life — opened, closed, reopened — and `pr-list` returns all of
|
|
481
|
+
# them. OPEN outranks MERGED outranks CLOSED, matching the walk `pr-state`
|
|
482
|
+
# already performs on Bitbucket, so the join and the per-branch lookup cannot
|
|
483
|
+
# disagree about the same branch.
|
|
484
|
+
PR_LIST_LIMIT="${PLOT_PR_LIST_LIMIT:-1000}"
|
|
485
|
+
prefill_pr_states() {
|
|
486
|
+
[ "$HOST_LOOKUP_OK" = 1 ] || return 0
|
|
487
|
+
[ -n "$HOST_STATE_CACHE" ] || return 0
|
|
488
|
+
local js br st key
|
|
489
|
+
# Exit code first: non-zero is a transport failure and its stdout is not an
|
|
490
|
+
# answer. A failed list leaves the cache EMPTY, so every branch falls through
|
|
491
|
+
# to the unanswerable `-` rather than to a fabricated "no PR".
|
|
492
|
+
#
|
|
493
|
+
# `--rich` adds `checks` and `draft` to the response — the fields `--loose`
|
|
494
|
+
# needs to verify that a prior wave's PR is actually green and ready.
|
|
495
|
+
# Requested unconditionally because the cost is zero on GitHub (same GraphQL
|
|
496
|
+
# call) and bounded on Bitbucket (N extra calls only when CI: jenkins is
|
|
497
|
+
# configured), and the parsing below already skips fields the response does
|
|
498
|
+
# not contain. The BEHAVIOUR change is in `pr_ready`, which now reads the
|
|
499
|
+
# check rollup from the cache rather than making a per-branch host call.
|
|
500
|
+
js=$("$script_dir/plot-host.sh" pr-list --state all --limit "$PR_LIST_LIMIT" --rich \
|
|
501
|
+
</dev/null 2>/dev/null) || return 0
|
|
502
|
+
# `pr-list` emits one compact JSON object per line. PARSED IN ONE PASS, and
|
|
503
|
+
# that is a correctness-of-cost property rather than a style preference:
|
|
504
|
+
# measured 2026-08-18 on this repo's 221 PRs, a `sed` per field per row —
|
|
505
|
+
# 442 forks — took 46 s, which is WORSE than the 34 s of host calls this
|
|
506
|
+
# change exists to remove. Trading N network round trips for N process forks
|
|
507
|
+
# is not a fix. One `sed` over the whole stream emits `branch<TAB>state`, and
|
|
508
|
+
# the loop below forks nothing at all.
|
|
509
|
+
#
|
|
510
|
+
# The fields are matched in their emitted order (`state` precedes `head`) and
|
|
511
|
+
# anchored to `","head":"` so a PR TITLE containing the word `state` cannot be
|
|
512
|
+
# mistaken for the field — titles are free text and this repo has several
|
|
513
|
+
# that name their own fields.
|
|
514
|
+
#
|
|
515
|
+
# `cache_key`'s substitution is inlined here for the same fork reason. It must
|
|
516
|
+
# stay IDENTICAL to that function; a divergence is a cache that silently never
|
|
517
|
+
# hits.
|
|
518
|
+
#
|
|
519
|
+
# STATE FIRST, BRANCH LAST. A branch name may contain almost anything; the
|
|
520
|
+
# state is a single bare word. Emitting `STATE<TAB>branch` lets the key
|
|
521
|
+
# encoding be one anchored substitution on the tail of the line, with no
|
|
522
|
+
# reconstruction of the separator afterwards.
|
|
523
|
+
# THE ROWS ARRIVE ALREADY RANKED, so this loop needs no memory of what it has
|
|
524
|
+
# seen and forks nothing to find out. `sort` below puts the winning row for
|
|
525
|
+
# each branch first; `last=` skips the rest of that branch's rows with a
|
|
526
|
+
# string compare. Reading the rank back from the file with `$(cat …)` would
|
|
527
|
+
# be a fork PER DUPLICATE ROW, which is what this loop was rewritten to avoid.
|
|
528
|
+
#
|
|
529
|
+
# The `--rich` response includes `checks` and `draft` — the fields `pr_ready`
|
|
530
|
+
# needs to verify the rollup. STORED AS `STATE<TAB>checks<TAB>draft`, so the
|
|
531
|
+
# cache file stays a single line of tab-separated fields and the read path
|
|
532
|
+
# can split on tabs without allocating anything. A missing field (non-rich
|
|
533
|
+
# response, which is now only an error case) reads as empty and `pr_ready`
|
|
534
|
+
# treats it as `unknown`, which degrades to strict — the safer direction.
|
|
535
|
+
local last="" chk dft
|
|
536
|
+
# Rows parsed, for the completeness test below. Counted here because this is
|
|
537
|
+
# the one place every row passes through.
|
|
538
|
+
_pr_rows=0
|
|
539
|
+
while IFS=" " read -r st chk dft br; do
|
|
540
|
+
[ -n "$br" ] && [ -n "$st" ] || continue
|
|
541
|
+
_pr_rows=$((_pr_rows + 1))
|
|
542
|
+
[ "$br" = "$last" ] && continue
|
|
543
|
+
last="$br"
|
|
544
|
+
# A PLAIN row (no `--rich` fields) carries `-` in the checks/draft slots so
|
|
545
|
+
# the tab-separated line has no EMPTY field: TAB is an IFS whitespace char,
|
|
546
|
+
# so `read` would collapse `A<TAB><TAB><TAB>B` and slide the branch into
|
|
547
|
+
# `chk`. The `-` sentinel keeps every field occupied; it is translated back
|
|
548
|
+
# to empty here so the cache stores exactly what a rich-less response means:
|
|
549
|
+
# STATE known, rollup unknown.
|
|
550
|
+
[ "$chk" = "-" ] && chk=""
|
|
551
|
+
[ "$dft" = "-" ] && dft=""
|
|
552
|
+
# Cache format: STATE<TAB>checks<TAB>draft — one line per branch.
|
|
553
|
+
# `checks` is one of: green, failing, pending, none, unknown (or empty when
|
|
554
|
+
# the response was not `--rich`); `draft` is true or false (or empty).
|
|
555
|
+
printf '%s\t%s\t%s' "$st" "$chk" "$dft" > "$HOST_STATE_CACHE/$br" 2>/dev/null || true
|
|
556
|
+
done <<EOF
|
|
557
|
+
$(printf '%s\n' "$js" \
|
|
558
|
+
| sed -n -e 's/.*"state":"\([A-Z]*\)","head":"\([^"]*\)","draft":\([a-z]*\),"checks":"\([a-z]*\)".*/\1 \4 \3 \2/p' \
|
|
559
|
+
-e 't' \
|
|
560
|
+
-e 's/.*"state":"\([A-Z]*\)","head":"\([^"]*\)".*/\1 - - \2/p' \
|
|
561
|
+
| sed 's/_/__/g; s|/|_|g; s|^\([A-Z]* [a-z-]* [a-z-]*\)_|\1 |' \
|
|
562
|
+
| sed 's/^\(OPEN [^ ]* [^ ]*\) /1 \1 /; s/^\(MERGED [^ ]* [^ ]*\) /2 \1 /; s/^\([A-Z]\)/3 \1/' \
|
|
563
|
+
| sort -t"$(printf '\t')" -k5,5 -k1,1 | cut -f2,3,4,5)
|
|
564
|
+
EOF
|
|
565
|
+
# The pipeline above, read left to right:
|
|
566
|
+
# 1. pull `STATE<TAB>checks<TAB>draft<TAB>branch` out of each JSON line,
|
|
567
|
+
# anchored on the fields' emitted adjacency so a free-text TITLE cannot
|
|
568
|
+
# impersonate them. TWO patterns tried in order: the RICH line carries
|
|
569
|
+
# `draft` and `checks` (the `--loose` rollup); a PLAIN line — a `pr-list`
|
|
570
|
+
# reply without `--rich`, or a stub that emits only the fields it needs —
|
|
571
|
+
# matches the second pattern and fills the checks/draft slots with `-`.
|
|
572
|
+
# `t` after the first branches past the second only when it matched, so a
|
|
573
|
+
# rich line is never re-parsed as plain. The `-` becomes empty in the
|
|
574
|
+
# loop, where an empty `checks` reads as `unknown` in `pr_ready` and
|
|
575
|
+
# degrades `--loose` to strict — the safe direction — while the STATE the
|
|
576
|
+
# row always carries keeps `host_pr_state`'s join (the N+1 fix) answering
|
|
577
|
+
# for every branch the list names, rich or not;
|
|
578
|
+
# 2. encode the branch into the injective cache key (`cache_key`, inlined);
|
|
579
|
+
# 3. prefix a RANK digit — OPEN 1, MERGED 2, everything else 3;
|
|
580
|
+
# 4. sort by branch (field 5), then rank (field 1), so each branch's winning
|
|
581
|
+
# row comes first;
|
|
582
|
+
# 5. drop the rank column again, leaving `STATE<TAB>checks<TAB>draft<TAB>branch`.
|
|
583
|
+
# The loop then keeps the first row per branch and skips the rest.
|
|
584
|
+
# THE LIST ARRIVED. Recorded as a fact of its own, because "the cache has no
|
|
585
|
+
# entry for this branch" means two different things and only this flag tells
|
|
586
|
+
# them apart: with the list in hand it is real evidence of no PR, without it
|
|
587
|
+
# the question was never answered. `host_pr_state` reads this to decide
|
|
588
|
+
# between NONE and `-`.
|
|
589
|
+
#
|
|
590
|
+
# The marker shares the cache directory with the per-branch files and CANNOT
|
|
591
|
+
# collide with one: `git check-ref-format` rejects a branch whose name starts
|
|
592
|
+
# with a dot, and the key encoding maps only `_` and `/`, so no branch key can
|
|
593
|
+
# begin with one either. The separation is git's rule, not a lucky prefix.
|
|
594
|
+
printf '1' > "$HOST_STATE_CACHE/.list-arrived" 2>/dev/null || true
|
|
595
|
+
# THE LIST WAS WHOLE — a stronger claim than `.list-arrived`, and the only one
|
|
596
|
+
# that licenses deriving `NONE` from a cache miss.
|
|
597
|
+
#
|
|
598
|
+
# `.list-arrived` says the host answered. This says the answer was not
|
|
599
|
+
# truncated, which arrival alone cannot establish: a list returned AT the
|
|
600
|
+
# limit is evidence of *at least* that many PRs, never of exactly that many.
|
|
601
|
+
# Deriving absence from a partial list would report a real PR as having none —
|
|
602
|
+
# strictly worse than the per-branch cost being removed, and the failure #333
|
|
603
|
+
# records on Bitbucket, where `bb pr list` is silently partial past 50 PRs per
|
|
604
|
+
# state.
|
|
605
|
+
#
|
|
606
|
+
# Counted from the rows actually parsed rather than from the raw payload, so a
|
|
607
|
+
# malformed line that the `sed` skipped cannot inflate the count into a false
|
|
608
|
+
# claim of completeness. Fewer rows than the limit means the host had no more
|
|
609
|
+
# to give; equal to it means it may have.
|
|
610
|
+
#
|
|
611
|
+
# AN EMPTY LIST IS NOT A COMPLETE ONE, and the test that caught this is the
|
|
612
|
+
# reason it is written down. A host that exits 0 while printing nothing —
|
|
613
|
+
# a stub, a backend that cannot list, a payload whose shape changed — parses
|
|
614
|
+
# to zero rows, and zero is `< PR_LIST_LIMIT`, so a completeness test on the
|
|
615
|
+
# limit ALONE reads a silent failure as "this repo has no PRs" and derives
|
|
616
|
+
# `NONE` for every branch. That is the outage-renders-a-fleet-unstarted
|
|
617
|
+
# failure this whole guard exists to prevent, reached from the other side.
|
|
618
|
+
#
|
|
619
|
+
# A repository genuinely holding zero PRs loses nothing by being asked: it has
|
|
620
|
+
# no branches with PRs for the join to serve either, so the cost is zero calls
|
|
621
|
+
# in both readings.
|
|
622
|
+
if [ "$_pr_rows" -gt 0 ] && [ "$_pr_rows" -lt "$PR_LIST_LIMIT" ] 2>/dev/null; then
|
|
623
|
+
printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true
|
|
624
|
+
fi
|
|
625
|
+
}
|
|
626
|
+
prefill_pr_states
|
|
627
|
+
|
|
628
|
+
# The cache stores the STATE WORD, not a yes/no, so a future reader can tell
|
|
629
|
+
# "asked, answered CLOSED" from "asked, could not reach the host". `-` is the
|
|
630
|
+
# unanswerable marker, and it is cached too: a host that is down stays down for
|
|
631
|
+
# the length of a scan, and re-asking once per branch would multiply an outage
|
|
632
|
+
# by the branch count.
|
|
633
|
+
#
|
|
634
|
+
# The host's PR state word for a branch, cached once per branch per run.
|
|
635
|
+
#
|
|
636
|
+
# SPLIT OUT OF `merged_by_host` so a SECOND question can reuse the SAME cached
|
|
637
|
+
# reply. `worker_of` needs to know whether an OPEN PR exists — the fact that
|
|
638
|
+
# outranks everything in the worker classification — and `merged` does not
|
|
639
|
+
# answer it: a branch under review reads `wip` by ancestry and MERGED by
|
|
640
|
+
# neither. Asking the host again would double the per-branch cost this cache
|
|
641
|
+
# exists to avoid, on a scan the board polls every 5 s.
|
|
642
|
+
#
|
|
643
|
+
# Returns the STATE WORD, or `-` when the question could not be answered —
|
|
644
|
+
# never a yes/no, so each caller applies its own test and a reader can still
|
|
645
|
+
# tell "asked, answered CLOSED" from "asked, could not reach the host".
|
|
646
|
+
# THE PER-BRANCH LOOKUP IS NOW OPT-IN, and that is the whole saving.
|
|
647
|
+
#
|
|
648
|
+
# `--ask` asks the host about this ONE branch when the join cannot answer. Only
|
|
649
|
+
# the no-ref arm passes it (PR #216), and that is bounded by ABSENT branches
|
|
650
|
+
# rather than by all of them — a branch with no ref may genuinely be missing
|
|
651
|
+
# from a repo-wide list if its PR was never opened, so the list's silence about
|
|
652
|
+
# it is not evidence.
|
|
653
|
+
#
|
|
654
|
+
# Without `--ask` an unjoined branch answers from the list alone: NONE when the
|
|
655
|
+
# list arrived (real evidence — the repo has no PR for it) and `-` when it did
|
|
656
|
+
# not (the question was never answered). Collapsing those two is the 2026-08-17
|
|
657
|
+
# failure in a new shape and is what the `.list-arrived` marker prevents.
|
|
658
|
+
host_pr_state() { # $1=branch [--ask] → OPEN|MERGED|CLOSED|NONE|-
|
|
659
|
+
[ "$HOST_LOOKUP_OK" = 1 ] || { printf '%s' '-'; return; }
|
|
660
|
+
local br="$1" ask="${2:-}" st js cache="" cached
|
|
661
|
+
[ -n "$HOST_STATE_CACHE" ] && cache="$HOST_STATE_CACHE/$(cache_key "$br")"
|
|
662
|
+
if [ -n "$cache" ] && [ -f "$cache" ]; then
|
|
663
|
+
# Cache format is now `STATE<TAB>checks<TAB>draft`. Return only STATE
|
|
664
|
+
# for backward compatibility — callers wanting the full record use
|
|
665
|
+
# `host_pr_readiness`.
|
|
666
|
+
cached=$(cat "$cache" 2>/dev/null)
|
|
667
|
+
printf '%s' "${cached%% *}"
|
|
668
|
+
return
|
|
669
|
+
fi
|
|
670
|
+
# THE ARRIVED LIST ANSWERS FOR THE BRANCHES IT OMITS, and it is tested BEFORE
|
|
671
|
+
# the host call rather than after it. `pr-list` returns every PR in the repo,
|
|
672
|
+
# so once it has arrived COMPLETE, a branch missing from the cache has no PR:
|
|
673
|
+
# absence is the answer, not the absence of one. Asking the host can only
|
|
674
|
+
# confirm what the list already said, at one round trip per branch.
|
|
675
|
+
#
|
|
676
|
+
# MEASURED, 2026-08-23: 28 of 29 host calls in a scan were for branches with
|
|
677
|
+
# no ref and no PR — branches an approved plan NAMES and nobody has started.
|
|
678
|
+
# Each cost a round trip to re-learn `NONE`, every scan, forever. One board
|
|
679
|
+
# spent ~3,600 calls/hour that way and emptied a 5000/hour budget in ~78
|
|
680
|
+
# minutes; four boards were running.
|
|
681
|
+
#
|
|
682
|
+
# COMPLETENESS IS THE LICENCE, and it is checked rather than assumed. A list
|
|
683
|
+
# returned AT the limit may be truncated — a count equal to the limit is
|
|
684
|
+
# evidence of *at least* that many PRs, never of exactly that many — and
|
|
685
|
+
# deriving `NONE` from a partial list would report a real PR as absent, which
|
|
686
|
+
# is worse than the cost this removes. `bb pr list` is silently partial past
|
|
687
|
+
# 50 PRs per state (#333), which is that failure already observed on one host.
|
|
688
|
+
# `.list-complete` is written only when the list is known whole; without it
|
|
689
|
+
# this arm does not fire and the host is asked exactly as before.
|
|
690
|
+
#
|
|
691
|
+
# `PLOT_SCAN_ASK_ALWAYS=1` restores the old behaviour on the NEXT SCAN with no
|
|
692
|
+
# rebuild — the scan is a script the board re-executes every cycle. It may
|
|
693
|
+
# only ever restore asking, never suppress it, so setting it can cost budget
|
|
694
|
+
# but cannot produce a wrong answer.
|
|
695
|
+
if [ "$ask" = "--ask" ] && [ "${PLOT_SCAN_ASK_ALWAYS:-0}" != 1 ] \
|
|
696
|
+
&& [ -n "$HOST_STATE_CACHE" ] && [ -f "$HOST_STATE_CACHE/.list-complete" ]; then
|
|
697
|
+
# Not in a complete list = no PR. Cached so a second caller for the same
|
|
698
|
+
# branch reads the file rather than re-deriving.
|
|
699
|
+
[ -n "$cache" ] && printf '%s' 'NONE' > "$cache" 2>/dev/null
|
|
700
|
+
printf '%s' 'NONE'
|
|
701
|
+
return
|
|
702
|
+
fi
|
|
703
|
+
if [ "$ask" = "--ask" ]; then
|
|
704
|
+
# Exit code first: non-zero is a transport failure and its stdout is not an
|
|
705
|
+
# answer. Only then is the payload read.
|
|
706
|
+
if js=$("$script_dir/plot-host.sh" pr-state "$br" </dev/null 2>/dev/null); then
|
|
707
|
+
st=$(printf '%s' "$js" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
|
|
708
|
+
[ -n "$st" ] || st='-'
|
|
709
|
+
else
|
|
710
|
+
st='-'
|
|
711
|
+
fi
|
|
712
|
+
# Cached even when it is `-`: a host that is down stays down for the length
|
|
713
|
+
# of a scan, and re-asking once per branch would multiply an outage by the
|
|
714
|
+
# branch count.
|
|
715
|
+
[ -n "$cache" ] && printf '%s' "$st" > "$cache" 2>/dev/null
|
|
716
|
+
printf '%s' "$st"
|
|
717
|
+
return
|
|
718
|
+
fi
|
|
719
|
+
# Not asked, not joined. The list's own arrival decides which silence this is.
|
|
720
|
+
if [ -n "$HOST_STATE_CACHE" ] && [ -f "$HOST_STATE_CACHE/.list-arrived" ]; then
|
|
721
|
+
printf '%s' 'NONE'
|
|
722
|
+
else
|
|
723
|
+
printf '%s' '-'
|
|
724
|
+
fi
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
# ---------------------------------------------------------------------------
|
|
728
|
+
# A TERMINAL BRANCH IS ASKED ONCE — the cache that spans pulses
|
|
729
|
+
# ---------------------------------------------------------------------------
|
|
730
|
+
#
|
|
731
|
+
# THE MEASUREMENT, taken on this repo 2026-08-19 after the join (#232) landed:
|
|
732
|
+
# 26 of 54 branches are terminal — merged or deferred — and a terminal fact
|
|
733
|
+
# cannot change. A merged branch stays merged. The board pulses every 5 s, so
|
|
734
|
+
# the scan re-derived those 26 answers at full price, forever.
|
|
735
|
+
#
|
|
736
|
+
# WHAT THE JOIN LEFT FOR THIS TO FIX. Measured in a sandbox before writing it:
|
|
737
|
+
#
|
|
738
|
+
# merged, ref KEPT 3 branches → 1 pr-list 9 → 1 pr-list
|
|
739
|
+
# squash-merged, DELETED 3 branches → 3 pr-state 9 → 9 pr-state
|
|
740
|
+
#
|
|
741
|
+
# After #232 the ONLY per-branch host cost left is the no-ref `--ask` arm that
|
|
742
|
+
# PR #216 put there — and that arm IS the terminal population: a branch whose
|
|
743
|
+
# ref is gone and whose merge already landed. So the cache lands exactly there
|
|
744
|
+
# and nowhere else. A live branch cannot be cached even by accident, because a
|
|
745
|
+
# live branch has a ref and never reaches the call.
|
|
746
|
+
#
|
|
747
|
+
# THE CACHE IS A DERIVATION, NOT A RECORD. That distinction is the whole design
|
|
748
|
+
# and Manifesto Principle 1 rests on it: nothing is remembered that git cannot
|
|
749
|
+
# re-establish. GIT IS CONSULTED ON EVERY PASS; only the host round trip is
|
|
750
|
+
# skipped. The asymmetry is the point — git is local and cheap, the host is
|
|
751
|
+
# remote and metered — and a cache that also skipped git would be a record of
|
|
752
|
+
# the past rather than a derivation of the present.
|
|
753
|
+
#
|
|
754
|
+
# So an entry carries THE EVIDENCE THAT MADE THE BRANCH TERMINAL, and every
|
|
755
|
+
# pass asks git whether that evidence still holds:
|
|
756
|
+
#
|
|
757
|
+
# branch <TAB> state <TAB> plan-oid <TAB> main-oid
|
|
758
|
+
#
|
|
759
|
+
# * THE REF REAPPEARED → not served, and not even reached. A branch name is
|
|
760
|
+
# reusable: merge `bug/flaky`, delete it, push it again for a second
|
|
761
|
+
# attempt. Serving the first attempt's `merged` would settle a wave and
|
|
762
|
+
# open the next one on work that has not landed. Checked live rather than
|
|
763
|
+
# stored, because a ref's absence is the precondition of the call itself.
|
|
764
|
+
# * THE PLAN WAS EDITED → its branches' answers are discarded. A plan is an
|
|
765
|
+
# INPUT to the derivation, not just a list of names: `deferred:`
|
|
766
|
+
# annotations, wave membership and the plan's phase all decide what an
|
|
767
|
+
# answer means. Content-addressed by blob hash, so an edit is caught
|
|
768
|
+
# without trusting a timestamp.
|
|
769
|
+
# * MAIN MOVED → the merge evidence is re-derived. `merged_by_subject` walks
|
|
770
|
+
# main's history, so a new tip is a new question.
|
|
771
|
+
#
|
|
772
|
+
# IT NEVER TOUCHES DISK AND NEVER OUTLIVES A PROCESS. The scan is spawned fresh
|
|
773
|
+
# every pulse, so it cannot hold the map itself; it RECEIVES the cache in the
|
|
774
|
+
# environment and REPORTS what it learned on stderr, leaving stdout
|
|
775
|
+
# byte-identical. The board — the only long-lived process in the system — holds
|
|
776
|
+
# the map in memory and dies with it. A file would be a second source of truth
|
|
777
|
+
# about a repo whose only source of truth is git, and a restart must re-derive
|
|
778
|
+
# everything.
|
|
779
|
+
# Defaulted rather than assumed: `set -u` is on, and an unset variable here
|
|
780
|
+
# would abort the scan for every caller that does not know the cache exists —
|
|
781
|
+
# which is every caller but the board.
|
|
782
|
+
PLOT_TERMINAL_CACHE="${PLOT_TERMINAL_CACHE:-}"
|
|
783
|
+
TERMINAL_LEARNED=""
|
|
784
|
+
# The tip the merge evidence is derived against, resolved once per run.
|
|
785
|
+
TERMINAL_MAIN_OID=$(git rev-parse "refs/remotes/origin/$MAIN" 2>/dev/null || echo "-")
|
|
786
|
+
# The plan whose branches are currently being walked, as a blob hash. Set by the
|
|
787
|
+
# plan loop; empty until then, and an entry with no plan identity is never
|
|
788
|
+
# served — an answer we cannot attribute to a plan revision is not evidence.
|
|
789
|
+
TERMINAL_PLAN_OID=""
|
|
790
|
+
|
|
791
|
+
# Is this branch's cached answer still good? Prints the state word when it is.
|
|
792
|
+
#
|
|
793
|
+
# THE VALIDATION IS THE FEATURE. Every arm here is a question to git, asked on
|
|
794
|
+
# every pass, and any disagreement discards the entry rather than repairing it.
|
|
795
|
+
terminal_cached() { # $1=branch → the cached state word, or nothing
|
|
796
|
+
[ -n "$PLOT_TERMINAL_CACHE" ] || return 1
|
|
797
|
+
[ -n "$TERMINAL_PLAN_OID" ] || return 1
|
|
798
|
+
local br="$1" cbr cst cplan cmain
|
|
799
|
+
while IFS=" " read -r cbr cst cplan cmain; do
|
|
800
|
+
[ "$cbr" = "$br" ] || continue
|
|
801
|
+
# The plan revision the answer was derived under, and the tip it was
|
|
802
|
+
# derived against. Either having moved makes it a fact about a repo that no
|
|
803
|
+
# longer exists.
|
|
804
|
+
[ "$cplan" = "$TERMINAL_PLAN_OID" ] || return 1
|
|
805
|
+
[ "$cmain" = "$TERMINAL_MAIN_OID" ] || return 1
|
|
806
|
+
printf '%s' "$cst"
|
|
807
|
+
return 0
|
|
808
|
+
done <<EOF
|
|
809
|
+
$PLOT_TERMINAL_CACHE
|
|
810
|
+
EOF
|
|
811
|
+
return 1
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
# Record a decided terminal answer for the next pulse to reuse.
|
|
815
|
+
#
|
|
816
|
+
# ONLY A DECIDED ANSWER IS TERMINAL. `-` means the question could not be
|
|
817
|
+
# answered, and caching it would freeze one bad afternoon into every later
|
|
818
|
+
# pulse — the 2026-08-17 outage multiplied by the life of the board rather than
|
|
819
|
+
# by the branch count. `MERGED` and `CLOSED` are settled; `OPEN` and `NONE` are
|
|
820
|
+
# not, because both can still change without anything local moving.
|
|
821
|
+
terminal_learn() { # $1=branch $2=state
|
|
822
|
+
case "$2" in MERGED|CLOSED) ;; *) return 0 ;; esac
|
|
823
|
+
[ -n "$TERMINAL_PLAN_OID" ] || return 0
|
|
824
|
+
TERMINAL_LEARNED+="$1 $2 $TERMINAL_PLAN_OID $TERMINAL_MAIN_OID"$'\n'
|
|
825
|
+
# Reported as it is learned rather than at exit: the scan is killed with
|
|
826
|
+
# SIGKILL on timeout (`fleet.ts`), and a summary written only on a clean exit
|
|
827
|
+
# would teach the board nothing on exactly the slow pulses that need it most.
|
|
828
|
+
printf 'terminal: %s\t%s\t%s\t%s\n' \
|
|
829
|
+
"$1" "$2" "$TERMINAL_PLAN_OID" "$TERMINAL_MAIN_OID" >&2
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
# Does the host say this branch's PR is MERGED? Anything else — OPEN, CLOSED,
|
|
833
|
+
# NONE, an unreachable host, a malformed reply — is NOT a yes. Unchanged in
|
|
834
|
+
# behaviour; only the lookup underneath it is now shared.
|
|
835
|
+
#
|
|
836
|
+
# THE ONE CALLER THAT MAY ASK PER BRANCH (PR #216). It is reached only from the
|
|
837
|
+
# no-ref arm of `branch_state`, for a branch the repo-wide list may legitimately
|
|
838
|
+
# not contain, and its cost is therefore bounded by ABSENT branches rather than
|
|
839
|
+
# by all of them. The join answers everything else. Do not add `--ask` to a
|
|
840
|
+
# caller that runs for every branch — that is the loop this change removed.
|
|
841
|
+
#
|
|
842
|
+
# THE ONE PLACE THE TERMINAL CACHE IS CONSULTED, and it is deliberate that
|
|
843
|
+
# there is only one. This arm is reached only for a branch with NO REF, so a
|
|
844
|
+
# branch that is live — in flight, claimed, or with work on the floor — never
|
|
845
|
+
# arrives here and therefore cannot be cached however the cache is filled. The
|
|
846
|
+
# invariant is structural rather than a check that could be forgotten.
|
|
847
|
+
merged_by_host() { # $1=branch → 0 when the host reports its PR MERGED
|
|
848
|
+
local st
|
|
849
|
+
# Git has already been consulted to get here (no ref) and `terminal_cached`
|
|
850
|
+
# asks it again about the plan and the tip. Only the round trip is skipped.
|
|
851
|
+
if st=$(terminal_cached "$1"); then
|
|
852
|
+
# A SERVED ENTRY RE-REPORTS ITSELF, so what the scan emits is always the
|
|
853
|
+
# WHOLE map the next pulse should hold rather than a delta the board would
|
|
854
|
+
# have to merge. Without this the second pulse serves the entry, reports
|
|
855
|
+
# nothing, and the third starts cold — the cache would work exactly once
|
|
856
|
+
# and the saving would vanish on the pulse after it.
|
|
857
|
+
#
|
|
858
|
+
# It is re-derived, not merely echoed: reaching here means git was asked
|
|
859
|
+
# again this pass and still agrees — no ref, same plan, same tip.
|
|
860
|
+
terminal_learn "$1" "$st"
|
|
861
|
+
[ "$st" = "MERGED" ]
|
|
862
|
+
return
|
|
863
|
+
fi
|
|
864
|
+
st=$(host_pr_state "$1" --ask)
|
|
865
|
+
terminal_learn "$1" "$st"
|
|
866
|
+
[ "$st" = "MERGED" ]
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
# Has this branch's work REACHED REVIEW — an open or merged PR?
|
|
870
|
+
#
|
|
871
|
+
# The fact that outranks every local signal in the worker classification: work
|
|
872
|
+
# under review has left the worker's hands, so leftover edits in its worktree
|
|
873
|
+
# are not unfinished work. OPEN and MERGED both count; CLOSED does not, because
|
|
874
|
+
# a closed PR is work that was rejected or withdrawn and whatever sits in the
|
|
875
|
+
# worktree is back on the floor.
|
|
876
|
+
#
|
|
877
|
+
# UNANSWERABLE IS NOT A YES, and the direction matters. `-` — offline, no
|
|
878
|
+
# backend, a host returning 503 all afternoon — must not manufacture the state
|
|
879
|
+
# that tells a reader to stop looking. It falls through to the local signals,
|
|
880
|
+
# so a branch with work on the floor reads `stalled`: go and look. That is the
|
|
881
|
+
# safe direction for an answer nobody could verify.
|
|
882
|
+
reached_review() { # $1=branch → 0 when an open or merged PR exists
|
|
883
|
+
case "$(host_pr_state "$1")" in OPEN|MERGED) return 0 ;; *) return 1 ;; esac
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
# Modification time of a path, in epoch seconds, following symlinks — or "" when
|
|
887
|
+
# it cannot be read.
|
|
888
|
+
#
|
|
889
|
+
# BSD (`stat -f %m`) and GNU (`stat -c %Y`) spell this differently, and the
|
|
890
|
+
# fallback CANNOT be written as `bsd || gnu`: on GNU coreutils `-f` is a valid
|
|
891
|
+
# flag meaning *file system status*, so it SUCCEEDS with a filesystem report
|
|
892
|
+
# instead of failing over. That reads as a mtime of zero and quietly excludes
|
|
893
|
+
# every delivered plan on Linux, which is exactly what CI caught — the answer
|
|
894
|
+
# was wrong in the safe-looking direction (nothing shown) rather than the loud
|
|
895
|
+
# one. So the OUTPUT is validated rather than the exit code: whichever form
|
|
896
|
+
# yields digits is the one that was understood.
|
|
897
|
+
#
|
|
898
|
+
# THE FORM IS DETECTED ONCE, and that is an efficiency fix rather than a
|
|
899
|
+
# correctness one — the output validation below still has the last word. Two
|
|
900
|
+
# costs made it worth doing here: `for m in "$(bsd)" "$(gnu)"` evaluates BOTH
|
|
901
|
+
# command substitutions before the loop body runs, so the second fork happens
|
|
902
|
+
# even when the first answered; and on Linux the first fork is the one that
|
|
903
|
+
# prints five lines of filesystem report to be thrown away. Measured on this
|
|
904
|
+
# machine: ~60 ms per fork under load. Paid once per delivered plan that was
|
|
905
|
+
# affordable; `changed_ago_seconds` asks per worktree, and the same waste
|
|
906
|
+
# compounds.
|
|
907
|
+
#
|
|
908
|
+
# `-c` IS PROBED FIRST, deliberately, the same order `plot-board-probe.sh`
|
|
909
|
+
# settled on: GNU is the implementation that mis-parses the other's flag, so
|
|
910
|
+
# asking it its own question first means the ambiguous form is never reached on
|
|
911
|
+
# Linux. Verified on both platforms while writing this — macOS rejects `-c`
|
|
912
|
+
# with an empty stdout, alpine's busybox answers `-c` and pollutes stdout for
|
|
913
|
+
# `-f`.
|
|
914
|
+
# DETECTED LAZILY, INSIDE THE FUNCTION, so `file_mtime` stays SELF-CONTAINED.
|
|
915
|
+
# `fleetdelivered.test.mjs` lifts this function out of the script by regex and
|
|
916
|
+
# runs it standalone — deliberately, since sourcing the file would execute the
|
|
917
|
+
# whole scan — so a form probed at top level would leave the extracted copy with
|
|
918
|
+
# an unset variable under `set -u`. That test says so itself: a reshaped
|
|
919
|
+
# function is a signal to look, not to skip. It caught this.
|
|
920
|
+
_STAT_FMT=""
|
|
921
|
+
|
|
922
|
+
file_mtime() { # $1=path → epoch seconds, or ""
|
|
923
|
+
local m
|
|
924
|
+
# Probed on first use and remembered. `-c` FIRST, deliberately, the same order
|
|
925
|
+
# `plot-board-probe.sh` settled on: GNU is the implementation that mis-parses
|
|
926
|
+
# the other's flag, so asking it its own question first means the ambiguous
|
|
927
|
+
# form is never reached on Linux. Verified on both platforms while writing
|
|
928
|
+
# this — macOS rejects `-c` with an empty stdout, alpine's busybox answers
|
|
929
|
+
# `-c` and pollutes stdout for `-f`.
|
|
930
|
+
if [ -z "${_STAT_FMT:-}" ]; then
|
|
931
|
+
if [ -n "$(stat -c %Y "$1" 2>/dev/null)" ]; then
|
|
932
|
+
_STAT_FMT="gnu"
|
|
933
|
+
elif [ -n "$(stat -f %m "$1" 2>/dev/null)" ]; then
|
|
934
|
+
_STAT_FMT="bsd"
|
|
935
|
+
else
|
|
936
|
+
_STAT_FMT="none"
|
|
937
|
+
fi
|
|
938
|
+
fi
|
|
939
|
+
case "$_STAT_FMT" in
|
|
940
|
+
gnu) m=$(stat -c %Y "$1" 2>/dev/null) ;;
|
|
941
|
+
bsd) m=$(stat -f %m "$1" 2>/dev/null) ;;
|
|
942
|
+
# No `stat` at all, or one that speaks neither dialect — or a first path
|
|
943
|
+
# that could not be read, which would misprobe. Fall back to trying both and
|
|
944
|
+
# validating the output, which is what this function did before the
|
|
945
|
+
# detection existed. Slower, still correct, and it re-probes next call.
|
|
946
|
+
*)
|
|
947
|
+
_STAT_FMT=""
|
|
948
|
+
for m in "$(stat -c %Y "$1" 2>/dev/null)" "$(stat -f %m "$1" 2>/dev/null)"; do
|
|
949
|
+
case "$m" in
|
|
950
|
+
''|*[!0-9]*) ;;
|
|
951
|
+
*) printf '%s' "$m"; return 0 ;;
|
|
952
|
+
esac
|
|
953
|
+
done
|
|
954
|
+
return 1 ;;
|
|
955
|
+
esac
|
|
956
|
+
# The OUTPUT is validated, never the exit code — kept from the original for
|
|
957
|
+
# the reason it was written: a form that "succeeds" while answering a
|
|
958
|
+
# different question must not reach the arithmetic that consumes this.
|
|
959
|
+
case "$m" in
|
|
960
|
+
''|*[!0-9]*) return 1 ;;
|
|
961
|
+
*) printf '%s' "$m"; return 0 ;;
|
|
962
|
+
esac
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
# The newest mtime among MANY paths, in ONE `stat` call — epoch seconds, or ""
|
|
966
|
+
# when none could be read.
|
|
967
|
+
#
|
|
968
|
+
# ONE FORK FOR THE WHOLE LIST, and the measurement is why this is not a loop
|
|
969
|
+
# over `file_mtime`. `stat` takes many paths and prints one line each on both
|
|
970
|
+
# dialects (verified on macOS and alpine). Measured here: 50 sequential forks
|
|
971
|
+
# cost 3.1 s, one batched call over three paths cost 0.023 s. A worker mid-build
|
|
972
|
+
# has hundreds of untracked files, so per-file forking would have made the pulse
|
|
973
|
+
# slower than the work it measures — the plan budgeted "one directory stat per
|
|
974
|
+
# worktree" and the design it describes implies one per FILE. Batching is what
|
|
975
|
+
# makes those two the same number again.
|
|
976
|
+
#
|
|
977
|
+
# NO `xargs`, NO ARGUMENT-LIMIT DANCE. A path list long enough to exceed
|
|
978
|
+
# `ARG_MAX` (~1 MB) is not a case this needs to serve: the answer is the MAXIMUM
|
|
979
|
+
# mtime, so a truncated list can only under-report, and a worktree with tens of
|
|
980
|
+
# thousands of dirty files has an answer no operator is reading a clock for. The
|
|
981
|
+
# list is capped by the caller instead, which reports that it capped.
|
|
982
|
+
#
|
|
983
|
+
# UNREADABLE PATHS ARE SKIPPED, not zeroed. `stat` prints nothing for a path it
|
|
984
|
+
# cannot read (a file deleted between `git status` and here — a live race in a
|
|
985
|
+
# worktree being written to), and a zero would be the newest-looking answer's
|
|
986
|
+
# opposite: it drags nothing down, but a fabricated 0 elsewhere in this field
|
|
987
|
+
# would read as 1970. Non-digit lines are dropped for the same reason.
|
|
988
|
+
newest_mtime() { # $@=paths → epoch seconds, or ""
|
|
989
|
+
[ "$#" -gt 0 ] || return 1
|
|
990
|
+
local out
|
|
991
|
+
# Share `file_mtime`'s probe rather than repeating it: one call settles
|
|
992
|
+
# `_STAT_FMT` for the run, and the batched form below can then be chosen
|
|
993
|
+
# without a second detection. Its answer is discarded — only the side effect
|
|
994
|
+
# is wanted, and a path that cannot be read leaves the format unset, which the
|
|
995
|
+
# `*)` arm handles.
|
|
996
|
+
[ -n "${_STAT_FMT:-}" ] || file_mtime "$1" >/dev/null 2>&1
|
|
997
|
+
case "${_STAT_FMT:-}" in
|
|
998
|
+
gnu) out=$(stat -c %Y "$@" 2>/dev/null) ;;
|
|
999
|
+
bsd) out=$(stat -f %m "$@" 2>/dev/null) ;;
|
|
1000
|
+
*) return 1 ;;
|
|
1001
|
+
esac
|
|
1002
|
+
# `sort -n | tail -1` would fork twice more for a maximum awk already has in
|
|
1003
|
+
# hand. The digit test is what keeps a filesystem report — should the
|
|
1004
|
+
# detection ever be wrong — out of the arithmetic downstream.
|
|
1005
|
+
printf '%s\n' "$out" | awk '
|
|
1006
|
+
/^[0-9]+$/ { if ($1 > max) max = $1 }
|
|
1007
|
+
END { if (max != "") print max }
|
|
1008
|
+
'
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
# THE DIRTY LIST IS CAPPED, AND THE CAP NEEDS NO SATURATION FLAG — the one
|
|
1012
|
+
# place in this file where that is true, so it is argued rather than assumed.
|
|
1013
|
+
#
|
|
1014
|
+
# The rule elsewhere here is that caps drop results silently unless they also
|
|
1015
|
+
# report saturation, and it holds because those caps report a SET: a truncated
|
|
1016
|
+
# conflict list reads exactly like a short one, and the reader cannot tell. This
|
|
1017
|
+
# cap feeds a MAXIMUM. Dropping members of a set you take the max over can only
|
|
1018
|
+
# move the answer EARLIER — it can never invent recency — so the failure mode is
|
|
1019
|
+
# "reported quieter than it is", which is the direction that makes a reader look
|
|
1020
|
+
# rather than the one that reassures them wrongly.
|
|
1021
|
+
#
|
|
1022
|
+
# WHICH 500, STATED PLAINLY: the first 500 in git's own order, which is
|
|
1023
|
+
# alphabetical by path — NOT the 500 newest. Sorting by mtime to pick the newest
|
|
1024
|
+
# would need every file's mtime first, which is the entire cost the cap exists
|
|
1025
|
+
# to bound. So on a worktree past the cap the answer is the newest among an
|
|
1026
|
+
# arbitrary subset, and it is reported as the same number as any other run.
|
|
1027
|
+
#
|
|
1028
|
+
# That is acceptable HERE and would not be in a set-shaped field, for the reason
|
|
1029
|
+
# above: it can only under-report. It is written down because a reader who
|
|
1030
|
+
# assumes "newest 500" would trust a capped answer more than it deserves.
|
|
1031
|
+
#
|
|
1032
|
+
# 500 is far above any hand-edited working set and far below `ARG_MAX` (~1 MB of
|
|
1033
|
+
# argv). A tree big enough to hit it is a build directory, not a person's work.
|
|
1034
|
+
CHANGED_DIRTY_CAP=500
|
|
1035
|
+
|
|
1036
|
+
# ---------------------------------------------------------------------------
|
|
1037
|
+
# Local worktrees: what the refs cannot see
|
|
1038
|
+
# ---------------------------------------------------------------------------
|
|
1039
|
+
#
|
|
1040
|
+
# An agent that has edited files and not committed has written nothing git can
|
|
1041
|
+
# see, so a branch someone is actively working reads as abandoned — on the one
|
|
1042
|
+
# machine that could have known better. This scan runs on that machine, and
|
|
1043
|
+
# `git worktree list --porcelain` names every worktree and its branch.
|
|
1044
|
+
#
|
|
1045
|
+
# The signal is strictly ONE-DIRECTIONAL, which is the whole reason it can be
|
|
1046
|
+
# added without weakening refs-as-truth: it may ADD an answer where this machine
|
|
1047
|
+
# knows more, never downgrade one. A machine with no worktree for a branch —
|
|
1048
|
+
# every detached worker, every teammate's laptop, every CI run — reports nothing
|
|
1049
|
+
# here, and the branch answers from refs exactly as it did before.
|
|
1050
|
+
#
|
|
1051
|
+
# TWO EMPTY-MEANS-ONE-THING GUARDS, both cheap:
|
|
1052
|
+
# * SKIP `prunable` ENTRIES. A worktree directory can be deleted without `git
|
|
1053
|
+
# worktree remove`, and the entry survives. `git status` there exits 128 and
|
|
1054
|
+
# prints NOTHING — so a check on emptiness reads "clean" and is right by
|
|
1055
|
+
# ACCIDENT. `git worktree list --porcelain` already marks such entries, so
|
|
1056
|
+
# running `git status` on a directory known to be gone is asking a question
|
|
1057
|
+
# whose answer was printed a line earlier.
|
|
1058
|
+
# * READ THE EXIT CODE, not the emptiness. A non-zero status is a failure to
|
|
1059
|
+
# observe, and a failure to observe is not evidence of cleanliness.
|
|
1060
|
+
#
|
|
1061
|
+
# NO CAP, and the measurement is the reason: 6.6 ms per worktree, so twenty cost
|
|
1062
|
+
# ~133 ms against a scan that already runs 500–1050 ms. A cap would be stock
|
|
1063
|
+
# against a problem the numbers rule out, and caps drop results silently unless
|
|
1064
|
+
# they also report saturation.
|
|
1065
|
+
#
|
|
1066
|
+
# A LOCK IS A THIRD ANSWER, not a failure to observe. `.git/index.lock` means
|
|
1067
|
+
# *an agent is writing HERE, RIGHT NOW* — precisely what the fleet view exists to
|
|
1068
|
+
# show — and it is the most informative state a worktree can be in. Until #167 it
|
|
1069
|
+
# was invisible: the branch answered from refs as though this machine had no
|
|
1070
|
+
# worktree for it, and the row read *claimed, no commits yet* while a commit was
|
|
1071
|
+
# in flight. The branch that looked least active was the one being written to.
|
|
1072
|
+
#
|
|
1073
|
+
# THE LOCK FILE IS OBSERVED DIRECTLY, and that is a correction to the plan rather
|
|
1074
|
+
# than a shortcut around it. The plan expected the lock to announce itself by
|
|
1075
|
+
# FAILING `git status`, so that reading the exit code would be enough. Measured
|
|
1076
|
+
# on 2026-08-17, it does not: `git status --porcelain` exits 0 under a held lock
|
|
1077
|
+
# in every ordinary condition — clean tree, modified file, staged change,
|
|
1078
|
+
# untracked file — because it needs the index lock only when it decides to WRITE
|
|
1079
|
+
# a refreshed index back, which it skips whenever the cached stat info already
|
|
1080
|
+
# answers. The failure this plan was written from was real and is racy: it
|
|
1081
|
+
# reproduces when the index is stale enough to force a refresh-and-write, and not
|
|
1082
|
+
# otherwise. Keying the signal on that exit code would report a lock on some runs
|
|
1083
|
+
# and not others, for the same worktree in the same state — a flaky signal is
|
|
1084
|
+
# worse than none, because it teaches the reader to disbelieve the row.
|
|
1085
|
+
#
|
|
1086
|
+
# So the question is asked of the filesystem, where the answer is unambiguous:
|
|
1087
|
+
# the lock file is either there or it is not.
|
|
1088
|
+
#
|
|
1089
|
+
# LOCKED AND MISSING STAY APART, which is what the direct check buys. They are
|
|
1090
|
+
# now answered by two independent observations rather than by one exit code
|
|
1091
|
+
# carrying two meanings — a vanished directory has no git dir to look in and
|
|
1092
|
+
# reports nothing at all, exactly as before.
|
|
1093
|
+
#
|
|
1094
|
+
# NEVER RETRY, NEVER WAIT. A lock held through a rebase can last seconds and the
|
|
1095
|
+
# next poll is 4 s away — it will find it unlocked. A scan that blocks on one
|
|
1096
|
+
# worktree makes the pulse late for every branch on the board, which is a worse
|
|
1097
|
+
# version of the defect being fixed. Reporting beats blocking.
|
|
1098
|
+
#
|
|
1099
|
+
# Read ONCE per run, not once per branch — same rule the merge walk follows, for
|
|
1100
|
+
# the same reason: branch_state runs per branch and the board polls every 5 s.
|
|
1101
|
+
# bash 3.2 (macOS) has no associative arrays, so the table is a newline-
|
|
1102
|
+
# separated string of `branch<TAB>path<TAB>dirty<TAB>locked` rows, looked up with
|
|
1103
|
+
# awk.
|
|
1104
|
+
worktree_rows() {
|
|
1105
|
+
git worktree list --porcelain </dev/null 2>/dev/null | awk '
|
|
1106
|
+
/^worktree / { path = substr($0, 10); br = ""; prunable = 0; next }
|
|
1107
|
+
/^branch / { br = substr($0, 8); sub(/^refs\/heads\//, "", br); next }
|
|
1108
|
+
/^prunable/ { prunable = 1; next }
|
|
1109
|
+
/^$/ { if (br != "" && !prunable) print br "\t" path; br = ""; path = ""; prunable = 0; next }
|
|
1110
|
+
END { if (br != "" && !prunable) print br "\t" path }
|
|
1111
|
+
'
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
# Whether a worktree is mid-write, asked of the worktree's OWN git dir.
|
|
1115
|
+
#
|
|
1116
|
+
# A linked worktree does not keep its index beside the repository's: `.git` there
|
|
1117
|
+
# is a FILE reading `gitdir: <repo>/.git/worktrees/<name>`, and that is where its
|
|
1118
|
+
# `index.lock` lives. Testing `$wt/.git/index.lock` would answer for the main
|
|
1119
|
+
# checkout only and report every linked worktree unlocked — the population this
|
|
1120
|
+
# whole signal is about, since every dispatched agent works in one.
|
|
1121
|
+
#
|
|
1122
|
+
# NO GIT CALL. `git rev-parse --absolute-git-dir` would answer both shapes in one
|
|
1123
|
+
# line and costs 14 ms — measured, against the 6.6 ms per worktree the sweep
|
|
1124
|
+
# already accepts, so asking it per worktree would roughly TRIPLE the cost of the
|
|
1125
|
+
# local signals to learn something the filesystem already states. `.git` is a
|
|
1126
|
+
# directory in the main checkout and a one-line file in a linked one; reading it
|
|
1127
|
+
# is a stat and at most a 50-byte read.
|
|
1128
|
+
#
|
|
1129
|
+
# ABSENT IS ABSENT, here as everywhere: an unreadable or missing `.git` returns
|
|
1130
|
+
# "not locked" rather than guessing, and the worktree's other answers are
|
|
1131
|
+
# unaffected.
|
|
1132
|
+
worktree_locked() { # $1=worktree path → 0 when a lock is held there
|
|
1133
|
+
local gd=$1/.git line
|
|
1134
|
+
if [ -f "$gd" ]; then
|
|
1135
|
+
# A linked worktree: `.git` is a pointer file. Relative targets are legal, so
|
|
1136
|
+
# resolve against the worktree rather than the caller's cwd.
|
|
1137
|
+
line=$(cut -d' ' -f2- <"$gd" 2>/dev/null) || return 1
|
|
1138
|
+
case "$line" in
|
|
1139
|
+
gitdir:*) line=${line#gitdir:} ;;
|
|
1140
|
+
esac
|
|
1141
|
+
line=${line# }
|
|
1142
|
+
[ -n "$line" ] || return 1
|
|
1143
|
+
case "$line" in
|
|
1144
|
+
/*) gd=$line ;;
|
|
1145
|
+
*) gd=$1/$line ;;
|
|
1146
|
+
esac
|
|
1147
|
+
elif [ ! -d "$gd" ]; then
|
|
1148
|
+
return 1
|
|
1149
|
+
fi
|
|
1150
|
+
[ -e "$gd/index.lock" ]
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
# ---------------------------------------------------------------------------
|
|
1154
|
+
# EVERY REMOTE REF, IN ONE CALL — the spawn count IS the cost
|
|
1155
|
+
# ---------------------------------------------------------------------------
|
|
1156
|
+
#
|
|
1157
|
+
# THE MEASUREMENT, 2026-08-20, with a wrapper counting every `git` invocation:
|
|
1158
|
+
# 459 spawns for one scan of 54 branches, at 56 ms of process launch each —
|
|
1159
|
+
# roughly 24 s before git does any work. No single operation was slow: `fetch`
|
|
1160
|
+
# 0 s, `pr-list` 1 s, all 54 ancestry walks 1 s together. The distribution had
|
|
1161
|
+
# no hotspot (68 rev-list, 68 ls-tree, 67 show, 59 show-ref) because the cost is
|
|
1162
|
+
# the SPAWNING: 8 per branch, 54 branches.
|
|
1163
|
+
#
|
|
1164
|
+
# `git show-ref --verify` was asked once per branch from two places, always the
|
|
1165
|
+
# same question — does `origin/<branch>` exist. `for-each-ref` answers it for
|
|
1166
|
+
# every branch at once, so 59 spawns become one.
|
|
1167
|
+
#
|
|
1168
|
+
# Spawn cost is itself a property of the ESTATE rather than of this script:
|
|
1169
|
+
# every git process reads the ref database and worktree list at startup.
|
|
1170
|
+
# Measured on this repo, 44 worktrees gave 56 ms per spawn and a 105 s scan;
|
|
1171
|
+
# after removing 33 finished ones, 31 ms and 63 s. Both multipliers are real,
|
|
1172
|
+
# which is why housekeeping alone did not reach the 30 s budget.
|
|
1173
|
+
#
|
|
1174
|
+
# WHY IT MATTERS BEYOND THIS REPO: on Bitbucket one host call was measured at
|
|
1175
|
+
# ~10 s against GitHub's 461 ms, so a scan that cannot finish here cannot finish
|
|
1176
|
+
# there at all. The board serves no rows without a completed pulse — a fresh
|
|
1177
|
+
# process has no previous one to fall back on — so an over-budget scan is an
|
|
1178
|
+
# EMPTY Agents tab, not a stale one.
|
|
1179
|
+
#
|
|
1180
|
+
# The tab-delimited-string-plus-`case` shape matches `WORKTREES` below and is
|
|
1181
|
+
# kept for the same reason: no associative arrays, so it must work in POSIX sh.
|
|
1182
|
+
# `%(objectname)` rides along FREE — the same call, one more field. It is what
|
|
1183
|
+
# lets the commit walk name a SHA range instead of `origin/<branch>..`, which a
|
|
1184
|
+
# test at fleet.test.mjs:3138 forbids: a per-branch `git log origin/<branch>` is
|
|
1185
|
+
# how a worker's timestamp would be read from a remote ref, and that cost lands
|
|
1186
|
+
# on the population that must stay free. The walk here is a subject/emptiness
|
|
1187
|
+
# question rather than a timestamp read, but the guard is deliberately broad and
|
|
1188
|
+
# loosening it to fit this change is how a guard rots.
|
|
1189
|
+
REMOTE_REFS=$(git for-each-ref --format='%(refname:strip=3)%09%(objectname)' \
|
|
1190
|
+
"refs/remotes/origin" </dev/null 2>/dev/null)
|
|
1191
|
+
|
|
1192
|
+
# Whether `origin/$1` exists, answered from the batch rather than by spawning.
|
|
1193
|
+
#
|
|
1194
|
+
# EXIT STATUS ONLY, matching what `show-ref -q --verify` returned, so every
|
|
1195
|
+
# caller's `if !` reads identically. An empty `REMOTE_REFS` — a repo with no
|
|
1196
|
+
# remote, or a `for-each-ref` that failed — makes this false for every branch,
|
|
1197
|
+
# exactly as a failing `show-ref` did.
|
|
1198
|
+
remote_ref_exists() { # $1=branch → 0 when origin/$1 is present
|
|
1199
|
+
case "
|
|
1200
|
+
$REMOTE_REFS
|
|
1201
|
+
" in
|
|
1202
|
+
*"
|
|
1203
|
+
$1 "*) return 0 ;;
|
|
1204
|
+
esac
|
|
1205
|
+
return 1
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
# The tip SHA of `origin/$1`, from the same batch. Empty when absent.
|
|
1209
|
+
remote_ref_oid() { # $1=branch → oid, or ""
|
|
1210
|
+
printf '%s\n' "$REMOTE_REFS" | awk -F'\t' -v b="$1" '$1==b {print $2; exit}'
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
# EVERY LOCAL HEAD, IN ONE CALL — the same batch shape as REMOTE_REFS, for the
|
|
1214
|
+
# same reason. `local_ahead_of` (below) asks `refs/remotes/origin/<br>..refs/heads/<br>`
|
|
1215
|
+
# per branch, and on the population that dominates a real scan — branches living
|
|
1216
|
+
# on somebody else's machine, with no local `refs/heads/<br>` at all — that
|
|
1217
|
+
# rev-list exits 128 and answers 0. Measured on this repo in --json mode: 64
|
|
1218
|
+
# `local_ahead_of` calls, 25 of them against branches with no local head,
|
|
1219
|
+
# spawning a process to re-derive a zero already knowable from this one call.
|
|
1220
|
+
#
|
|
1221
|
+
# ONLY the no-local-head case is gated. A local head that HAS NO UPSTREAM
|
|
1222
|
+
# (committed, never pushed) still spawns the rev-list — it is a real question
|
|
1223
|
+
# with a real 128-failure answer, pinned by `a MISSING upstream is detected, not
|
|
1224
|
+
# read as zero`, and gating it here would answer 0 without ever looking, which
|
|
1225
|
+
# is the exact accident that test exists to forbid.
|
|
1226
|
+
LOCAL_HEADS=$(git for-each-ref --format='%(refname:strip=2)' \
|
|
1227
|
+
"refs/heads" </dev/null 2>/dev/null)
|
|
1228
|
+
|
|
1229
|
+
# Whether `refs/heads/$1` exists locally, from the batch. An empty LOCAL_HEADS —
|
|
1230
|
+
# a bare repo, or a failed for-each-ref — makes this false for every branch, so
|
|
1231
|
+
# `local_ahead_of` falls back to spawning and its own 128-handling decides. The
|
|
1232
|
+
# gate can only ever SKIP a spawn it is certain would answer 0; it never invents
|
|
1233
|
+
# one.
|
|
1234
|
+
local_head_exists() { # $1=branch → 0 when refs/heads/$1 is present
|
|
1235
|
+
case "
|
|
1236
|
+
$LOCAL_HEADS
|
|
1237
|
+
" in
|
|
1238
|
+
*"
|
|
1239
|
+
$1
|
|
1240
|
+
"*) return 0 ;;
|
|
1241
|
+
esac
|
|
1242
|
+
return 1
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
WORKTREES=""
|
|
1246
|
+
while IFS=$'\t' read -r wt_branch wt_path; do
|
|
1247
|
+
[ -n "$wt_branch" ] || continue
|
|
1248
|
+
# Asked BEFORE `git status` and independently of it. The lock is a fact about
|
|
1249
|
+
# the repository, not a property of whether the status call happened to
|
|
1250
|
+
# succeed — and it is the one fact here that can change between this scan and
|
|
1251
|
+
# the next poll four seconds later, so it is read as close to the truth as the
|
|
1252
|
+
# filesystem allows.
|
|
1253
|
+
if worktree_locked "$wt_path"; then wt_locked=true; else wt_locked=false; fi
|
|
1254
|
+
# Exit code, never emptiness: `git status` on a vanished directory prints
|
|
1255
|
+
# nothing and fails, and "I could not look" must not read as "clean".
|
|
1256
|
+
#
|
|
1257
|
+
# `--untracked-files=no`, and the omission was a measured defect. `local_dirty`
|
|
1258
|
+
# means SOMEBODY IS EDITING THIS WORK, and an untracked path is not that: it is
|
|
1259
|
+
# most often a tool's leavings. Measured 2026-08-22 on the project directory —
|
|
1260
|
+
# the one checkout that is worked in continuously and therefore accumulates
|
|
1261
|
+
# them — `git status --porcelain` reported two entries, `.playwright-mcp/` and
|
|
1262
|
+
# `.plot/agents/`, while `--untracked-files=no` reported none. The row for the
|
|
1263
|
+
# branch checked out there pulsed the activity mark for hours with nothing
|
|
1264
|
+
# being written, which is how it was found.
|
|
1265
|
+
#
|
|
1266
|
+
# The cost is stated rather than hidden: brand-new work is invisible here
|
|
1267
|
+
# until it is staged. That is the right trade for a signal whose whole job is
|
|
1268
|
+
# *someone is editing*, because the alternative is a checkout that reads dirty
|
|
1269
|
+
# forever from a directory nobody will ever commit — a mark that is always on
|
|
1270
|
+
# is a mark that says nothing.
|
|
1271
|
+
#
|
|
1272
|
+
# It also cleans `wt_changed` below, which derives the freshest mtime from
|
|
1273
|
+
# these same paths: a tool writing into `.playwright-mcp/` was moving the
|
|
1274
|
+
# *last changed* clock of work nobody had touched.
|
|
1275
|
+
if wt_status=$(git -C "$wt_path" status --porcelain </dev/null 2>/dev/null); then
|
|
1276
|
+
# DIRTY IS WHAT THE FILTER LEAVES, not what git prints.
|
|
1277
|
+
#
|
|
1278
|
+
# The first cut passed `--untracked-files=no`, which fixed the reported case
|
|
1279
|
+
# — a checkout reading dirty for hours over `.playwright-mcp/` — and broke
|
|
1280
|
+
# the one `fleet.test.mjs` states in as many words: *an untracked source
|
|
1281
|
+
# file IS work and must reset the clock*. A new `new-module.ts` is the most
|
|
1282
|
+
# interesting thing a worktree can hold.
|
|
1283
|
+
#
|
|
1284
|
+
# So the exclusion is per-PATH and lives in `plot_worker_dirty_filter`,
|
|
1285
|
+
# beside the editor leftovers it already drops for the same reason. Both
|
|
1286
|
+
# questions this block answers — *is anyone editing* and *when did the work
|
|
1287
|
+
# last change* — then read one list, which is what stopped them drifting
|
|
1288
|
+
# apart the last time.
|
|
1289
|
+
if [ -n "$(plot_worker_dirty_filter "$wt_status")" ]; then wt_dirty=true; else wt_dirty=false; fi
|
|
1290
|
+
elif [ "$wt_locked" = true ]; then
|
|
1291
|
+
# Status could not answer, but the lock says WHY, and that is an answer
|
|
1292
|
+
# rather than the absence of one: a write is in progress in this worktree at
|
|
1293
|
+
# this instant. Reported with its path, because the directory demonstrably
|
|
1294
|
+
# exists — something is writing in it.
|
|
1295
|
+
#
|
|
1296
|
+
# `dirty` stays FALSE, and that is not a claim of cleanliness. It was not
|
|
1297
|
+
# observed, and the two facts travel separately: `local_locked` says a write
|
|
1298
|
+
# is happening, `local_dirty` says someone is editing. Folding the lock into
|
|
1299
|
+
# dirtiness would answer a second question with the first one's word, which
|
|
1300
|
+
# is the one-label-two-states defect this scan keeps removing.
|
|
1301
|
+
wt_dirty=false
|
|
1302
|
+
else
|
|
1303
|
+
# A failure to observe with no lock to explain it: the directory is gone, or
|
|
1304
|
+
# unreadable, or not a worktree any more. Not reported at all — neither its
|
|
1305
|
+
# dirtiness (unknown) nor its path (it may not be there).
|
|
1306
|
+
continue
|
|
1307
|
+
fi
|
|
1308
|
+
# The newest mtime of the real work on the floor, computed HERE because this
|
|
1309
|
+
# is where the status output already exists — see `changed_ago_of`, the one
|
|
1310
|
+
# consumer, for what the number is for.
|
|
1311
|
+
#
|
|
1312
|
+
# ONE `git status` PER WORKTREE, AND THIS IS WHERE THAT IS ENFORCED. Asking
|
|
1313
|
+
# `plot_worker_dirty` for the file list later would run a SECOND status on the
|
|
1314
|
+
# same worktree, and `fleet.test.mjs` counts them ("a locked worktree must be
|
|
1315
|
+
# asked ONCE") precisely because a scan the board polls every 5 s cannot pay
|
|
1316
|
+
# twice for one answer. Measured by that test rather than by a stopwatch: a
|
|
1317
|
+
# timing assertion cannot tell a cheap duplicate call from no duplicate at all.
|
|
1318
|
+
#
|
|
1319
|
+
# A SCALAR IN THE TABLE, never the file list. `WORKTREES` is a tab-separated
|
|
1320
|
+
# string (bash 3.2 on macOS has no associative arrays), and status output
|
|
1321
|
+
# carries newlines and arbitrary filenames — a list field would corrupt every
|
|
1322
|
+
# row after the first path with a tab in it. An integer cannot.
|
|
1323
|
+
wt_changed=""
|
|
1324
|
+
if [ -n "${wt_status:-}" ]; then
|
|
1325
|
+
wt_dirty_paths=$(plot_worker_dirty_filter "$wt_status")
|
|
1326
|
+
if [ -n "$wt_dirty_paths" ]; then
|
|
1327
|
+
wt_mtime_args=()
|
|
1328
|
+
wt_n=0
|
|
1329
|
+
while IFS= read -r wt_f; do
|
|
1330
|
+
[ -n "$wt_f" ] || continue
|
|
1331
|
+
# `git status --porcelain` renders a rename as `old -> new`; the path
|
|
1332
|
+
# that EXISTS is the one after the arrow. Left alone, `stat` is handed a
|
|
1333
|
+
# path with no file behind it and silently under-reports.
|
|
1334
|
+
case "$wt_f" in *' -> '*) wt_f=${wt_f#* -> } ;; esac
|
|
1335
|
+
# Quoted when the path holds a character git chooses to escape. The
|
|
1336
|
+
# quotes are git's rendering, not part of the name.
|
|
1337
|
+
case "$wt_f" in '"'*'"') wt_f=${wt_f#\"}; wt_f=${wt_f%\"} ;; esac
|
|
1338
|
+
wt_mtime_args+=("$wt_path/$wt_f")
|
|
1339
|
+
wt_n=$((wt_n + 1))
|
|
1340
|
+
[ "$wt_n" -ge "$CHANGED_DIRTY_CAP" ] && break
|
|
1341
|
+
done <<< "$wt_dirty_paths"
|
|
1342
|
+
[ "${#wt_mtime_args[@]}" -gt 0 ] && wt_changed=$(newest_mtime "${wt_mtime_args[@]}")
|
|
1343
|
+
fi
|
|
1344
|
+
fi
|
|
1345
|
+
WORKTREES+="$wt_branch $wt_path $wt_dirty $wt_locked $wt_changed"$'\n'
|
|
1346
|
+
done <<< "$(worktree_rows)"
|
|
1347
|
+
|
|
1348
|
+
# The local worktree for a branch as `path<TAB>dirty<TAB>locked`, or empty when
|
|
1349
|
+
# this machine has none. Absent is ABSENT — never a false, never a path that
|
|
1350
|
+
# does not exist here.
|
|
1351
|
+
#
|
|
1352
|
+
# THREE FIELDS, not the table's five: the newest-dirty-mtime column beside them
|
|
1353
|
+
# is `changed_ago_of`'s input and no caller of this function has ever wanted it.
|
|
1354
|
+
# Widening the return would land a timestamp in whatever slot the callers'
|
|
1355
|
+
# `cut -f` happens to reach next.
|
|
1356
|
+
local_worktree_of() { # $1=branch → "path\tdirty\tlocked" or ""
|
|
1357
|
+
printf '%s' "$WORKTREES" | awk -F'\t' -v b="$1" '$1==b {print $2 "\t" $3 "\t" $4; exit}'
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
# ---------------------------------------------------------------------------
|
|
1361
|
+
# The worker: whether anything is actually running on a claimed branch
|
|
1362
|
+
# ---------------------------------------------------------------------------
|
|
1363
|
+
#
|
|
1364
|
+
# A claim is a push. It says a dispatcher TOOK the branch, and nothing more —
|
|
1365
|
+
# on 2026-08-17 three rows sat in WORKING with a pulsing dot while nobody was
|
|
1366
|
+
# working on any of them. The claim was real; the worker was never started.
|
|
1367
|
+
#
|
|
1368
|
+
# `worker_state()` in plot-dispatch.sh has distinguished FIVE outcomes since it
|
|
1369
|
+
# was written — running / finished / failed (exit N) / ended (status unknown) /
|
|
1370
|
+
# no worker — and measured against the board, `grep -rn "plot-worker.pid"
|
|
1371
|
+
# packages/board/src` returned NOTHING. The information was already richer than
|
|
1372
|
+
# the row assumed and reached no screen. This reports it; it invents no new
|
|
1373
|
+
# liveness check.
|
|
1374
|
+
#
|
|
1375
|
+
# THE CLASSIFICATION ITSELF NOW LIVES IN plot-worker-state.sh, sourced above and
|
|
1376
|
+
# shared with plot-dispatch.sh. It used to live here TWICE — this file carried
|
|
1377
|
+
# its own copy of the pid read, the `kill -0`, and the exit-code mapping. The
|
|
1378
|
+
# copies agreed on five of six states and had already drifted apart on the
|
|
1379
|
+
# sixth. What remains here is `elsewhere` plus the tab-separated rendering the
|
|
1380
|
+
# JSON consumes.
|
|
1381
|
+
#
|
|
1382
|
+
# SIX VALUES, because the absence of a worktree is a THIRD kind of answer and
|
|
1383
|
+
# not the second one. The pid lives in the worktree (`$wt/.plot-worker.pid`),
|
|
1384
|
+
# so a branch claimed and started on ANOTHER machine has no path to look at:
|
|
1385
|
+
#
|
|
1386
|
+
# claim worktree pid worker the reader's next move
|
|
1387
|
+
# ✓ ✓ ✓ running leave it alone
|
|
1388
|
+
# finished review it
|
|
1389
|
+
# failed restart it — its exit code is carried
|
|
1390
|
+
# ended look in the log; the status was not kept
|
|
1391
|
+
# ✓ ✓ — none claimed, no KNOWN worker
|
|
1392
|
+
# ✓ — n/a elsewhere ask the machine that took it
|
|
1393
|
+
#
|
|
1394
|
+
# `none` and `elsewhere` differ because the ACTIONS differ — *look in this
|
|
1395
|
+
# checkout* versus *ask another machine* — the same split `local_dirty` and
|
|
1396
|
+
# `local_ahead` make between a worktree question and a ref question.
|
|
1397
|
+
#
|
|
1398
|
+
# ABSENT IS NOT FALSE, and `none` is the strongest statement it licenses:
|
|
1399
|
+
# **UNKNOWN, never "nobody"**. plot-dispatch writes the pid only where it
|
|
1400
|
+
# started the worker itself, so a hand-started worker leaves none — and
|
|
1401
|
+
# hand-starting is the normal case for as long as `Worker command` is unset.
|
|
1402
|
+
# Five agents were started that way in one session; reading a missing pid as
|
|
1403
|
+
# "nobody is working" would have reported every one of them dead.
|
|
1404
|
+
#
|
|
1405
|
+
# A PID OF `0` IS NEVER RUNNING. `kill -0 0` signals the whole process GROUP and
|
|
1406
|
+
# succeeds, so a naive liveness check reports it alive forever. It is rejected
|
|
1407
|
+
# here exactly as `worker_state()` rejects it, and the answer travels as a value
|
|
1408
|
+
# rather than being re-derived on the far side where the trap would be sprung
|
|
1409
|
+
# again.
|
|
1410
|
+
#
|
|
1411
|
+
# READ THE EXIT CODE, NOT THE EMPTINESS: an unreadable `.plot-worker.exit` is
|
|
1412
|
+
# `ended` (status unknown), never `finished`. Guessing success from an absent
|
|
1413
|
+
# record is the same mistake in the other direction, and `finished` is the one
|
|
1414
|
+
# answer that tells a reader to stop looking.
|
|
1415
|
+
worker_of() { # $1=branch → "state\tpid\texit"
|
|
1416
|
+
local br="$1" wt
|
|
1417
|
+
wt=$(printf '%s' "$WORKTREES" | awk -F'\t' -v b="$br" '$1==b {print $2; exit}')
|
|
1418
|
+
# No worktree here: this machine cannot answer the question at all. Not the
|
|
1419
|
+
# same as looking and finding nothing. This is the one state the shared
|
|
1420
|
+
# classifier does not produce — it is a question about the worktree LIST,
|
|
1421
|
+
# asked before there is any worktree to look inside.
|
|
1422
|
+
[ -n "$wt" ] || { printf 'elsewhere\t\t'; return; }
|
|
1423
|
+
# Everything below the worktree is the shared classifier's answer, already in
|
|
1424
|
+
# the tab-separated shape this function returns. plot-dispatch renders the
|
|
1425
|
+
# same facts as prose for `--status`.
|
|
1426
|
+
#
|
|
1427
|
+
# THE PR FACT TRAVELS AS AN ARGUMENT, computed HERE where the host is already
|
|
1428
|
+
# being asked and the answer is already cached. The classifier is called once
|
|
1429
|
+
# per branch inside this loop and must not fork a `gh` of its own — and it
|
|
1430
|
+
# must not break `--offline`, which promises no network. Both are properties
|
|
1431
|
+
# of this caller, not of the classification, so the caller supplies the fact
|
|
1432
|
+
# exactly as it supplies `elsewhere` above.
|
|
1433
|
+
#
|
|
1434
|
+
# `$st` IS NOT THIS FACT. It answers a ref/ancestry question — a branch under
|
|
1435
|
+
# review reads `wip` — and `merged` there can come from a merge subject with
|
|
1436
|
+
# no PR behind it at all. `reached_review` asks the one question that
|
|
1437
|
+
# outranks the local signals: has this work left the worker's hands?
|
|
1438
|
+
local pr_fact=""
|
|
1439
|
+
reached_review "$br" && pr_fact="pr"
|
|
1440
|
+
plot_worker_state "$wt" "$pr_fact"
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
# ---------------------------------------------------------------------------
|
|
1444
|
+
# Unpushed commits: work finished on this machine that nobody else can see
|
|
1445
|
+
# ---------------------------------------------------------------------------
|
|
1446
|
+
#
|
|
1447
|
+
# `local_dirty` reports *someone is editing*, and committing clears it. So the
|
|
1448
|
+
# moment a worker finishes tidily the signal covering for it disappears, and the
|
|
1449
|
+
# board reads "claimed, no commits yet" for a branch holding a complete
|
|
1450
|
+
# implementation. Measured on 2026-08-16 on the very branch that fixed the other
|
|
1451
|
+
# half: 3 commits ahead, 0 dirty files, no PR.
|
|
1452
|
+
#
|
|
1453
|
+
# THIS IS A REF QUESTION, NOT A WORKTREE QUESTION, and getting that wrong was
|
|
1454
|
+
# this plan's own first draft. Worktrees share ONE ref database, so
|
|
1455
|
+
# `refs/heads/<br>` answers from here for a branch checked out in a different
|
|
1456
|
+
# worktree — no `git -C`, no worktree needed. Binding it to the worktree list
|
|
1457
|
+
# would have been *consistent* with `local_dirty` and wrong: a local branch with
|
|
1458
|
+
# no worktree — checked out once and moved away from, or fetched from a
|
|
1459
|
+
# colleague — still holds commits nobody else can see, and the worktree-shaped
|
|
1460
|
+
# version skips exactly those. Dirtiness belongs to a working directory;
|
|
1461
|
+
# aheadness belongs to the refs.
|
|
1462
|
+
#
|
|
1463
|
+
# AHEAD ONLY. `A..B` counts one direction and that is the right one: the
|
|
1464
|
+
# question is whether work exists here that nobody else can see. Being *behind*
|
|
1465
|
+
# is not an invisible state — it is sitting in the remote for anyone to read —
|
|
1466
|
+
# and reporting it would answer a second question with no action attached.
|
|
1467
|
+
#
|
|
1468
|
+
# READ THE EXIT CODE, NOT THE EMPTINESS. A missing upstream exits 128 printing
|
|
1469
|
+
# NOTHING — bit-identical to the deleted-worktree signature above — so a check
|
|
1470
|
+
# on emptiness reads "zero ahead" and is right only by accident, for exactly the
|
|
1471
|
+
# reason empty `git status` output must not read as "clean". A failure to
|
|
1472
|
+
# observe is not evidence of nothing to see, and 0 is what the caller renders.
|
|
1473
|
+
#
|
|
1474
|
+
# ABSENT IS NOT FALSE: a branch with no local ref exits 128 too and answers 0,
|
|
1475
|
+
# which is precisely the answer that changes nothing for every branch living on
|
|
1476
|
+
# somebody else's machine.
|
|
1477
|
+
#
|
|
1478
|
+
# NO CAP. Measured at 5.2 ms per call from the main repo (20 iterations,
|
|
1479
|
+
# 0.104 s), against the 6.6 ms per worktree the shipped scan already accepts.
|
|
1480
|
+
# Twenty branches cost ~104 ms on a scan that runs 500–1050 ms, and the count
|
|
1481
|
+
# follows the plans rather than the checkout. A cap would be stock against a
|
|
1482
|
+
# problem the numbers rule out, and caps drop results silently unless they also
|
|
1483
|
+
# report saturation.
|
|
1484
|
+
local_ahead_of() { # $1=branch → count of local commits the remote lacks, or 0
|
|
1485
|
+
local out
|
|
1486
|
+
# NO LOCAL HEAD, NO SPAWN. A branch with no `refs/heads/$1` — the common case,
|
|
1487
|
+
# every branch on another machine — makes the rev-list below exit 128 and
|
|
1488
|
+
# answer 0. That 0 is already known from the LOCAL_HEADS batch, so the process
|
|
1489
|
+
# is not spawned to rediscover it. This skips ONLY the absent-head case; a
|
|
1490
|
+
# present head with no upstream still falls through and fails 128 as before,
|
|
1491
|
+
# because absent-upstream is a different fact the caller's tests pin.
|
|
1492
|
+
local_head_exists "$1" || { printf '0'; return; }
|
|
1493
|
+
# Exit code, never emptiness. `|| return`-style shortcuts would swallow the
|
|
1494
|
+
# distinction this whole comment exists to preserve.
|
|
1495
|
+
if out=$(git rev-list --count \
|
|
1496
|
+
"refs/remotes/origin/$1..refs/heads/$1" </dev/null 2>/dev/null); then
|
|
1497
|
+
case "$out" in
|
|
1498
|
+
''|*[!0-9]*) printf '0' ;;
|
|
1499
|
+
*) printf '%s' "$out" ;;
|
|
1500
|
+
esac
|
|
1501
|
+
else
|
|
1502
|
+
# No local ref, no upstream, or an unreadable ref database. Not observed →
|
|
1503
|
+
# not reported.
|
|
1504
|
+
printf '0'
|
|
1505
|
+
fi
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
# ---------------------------------------------------------------------------
|
|
1509
|
+
# Conflict prediction: which FILES would collide, not merely whether any would
|
|
1510
|
+
# ---------------------------------------------------------------------------
|
|
1511
|
+
#
|
|
1512
|
+
# `plot-merge-queue.sh` has predicted conflicts since it was written, and it
|
|
1513
|
+
# asks a yes/no question — `would_conflict()` throws the output away and reads
|
|
1514
|
+
# the exit code. That is the right question for a merge ORDER and the wrong one
|
|
1515
|
+
# for a stuck branch: on 2026-08-17 two branches (#176, #177) conflicted in
|
|
1516
|
+
# exactly one file, the board artifact, whose resolution is mechanical, while a
|
|
1517
|
+
# third needed a person. A yes/no answer cannot tell those apart.
|
|
1518
|
+
#
|
|
1519
|
+
# So the SET is reported and nothing here judges it. Which conflict sets mean
|
|
1520
|
+
# what is a decision one layer up (Principle 3: scripts collect and report).
|
|
1521
|
+
#
|
|
1522
|
+
# `merge-tree --write-tree` computes the merge ENTIRELY IN MEMORY — no working
|
|
1523
|
+
# tree, no index, no checkout, nothing written. Its output is a tree OID on the
|
|
1524
|
+
# first line, then the conflicted-file info, then a blank line and the
|
|
1525
|
+
# human-readable messages. Non-zero exit means the merge would conflict.
|
|
1526
|
+
#
|
|
1527
|
+
# THE STAGE-TUPLE FORM IS PARSED, NOT `--name-only`. The flag would be tidier
|
|
1528
|
+
# and arrived in git 2.40; the tuples have been there since 2.38, which is the
|
|
1529
|
+
# floor `plot-merge-queue.sh` already checks and states its reasons for. Raising
|
|
1530
|
+
# the floor for a formatting convenience would make this scan refuse to answer
|
|
1531
|
+
# on a git the rest of Plot supports — and a scan that cannot answer reports an
|
|
1532
|
+
# EMPTY set, which reads exactly like "merges cleanly". Same shape, one line of
|
|
1533
|
+
# `awk`, no new requirement.
|
|
1534
|
+
#
|
|
1535
|
+
# ABSENT IS NOT CLEAN, and this is the one place that rule is hard to hold: an
|
|
1536
|
+
# empty list has to mean both "merges cleanly" and "could not look" unless
|
|
1537
|
+
# something else distinguishes them. `conflicts_known` is that something — see
|
|
1538
|
+
# `conflicts_of` — and a consumer that reads the list without it will read every
|
|
1539
|
+
# unanswerable branch as mergeable.
|
|
1540
|
+
#
|
|
1541
|
+
# Only branches with real unlanded work are asked about. A merged branch has
|
|
1542
|
+
# nothing left to merge, an `open` branch has no ref, and a bare claim CHANGES
|
|
1543
|
+
# no file — every one of them would cost a process spawn to be told the obvious,
|
|
1544
|
+
# on every scan. See `conflicts_known_of` for why they report `false` rather
|
|
1545
|
+
# than a convenient `true`.
|
|
1546
|
+
# CAN THE QUESTION BE PUT AT ALL? Two prerequisites, both checked ONCE per run
|
|
1547
|
+
# rather than per branch, and both collapsing to the same honest answer when
|
|
1548
|
+
# they fail: nothing was observed, so nothing is claimed.
|
|
1549
|
+
#
|
|
1550
|
+
# 1. A default branch to merge INTO. Without `origin/$MAIN` there is no
|
|
1551
|
+
# target, and a prediction against nothing is not a prediction.
|
|
1552
|
+
# 2. A git that answers this question. Before 2.38 `merge-tree` EXISTS with
|
|
1553
|
+
# entirely different semantics — a three-way file diff — so it does not
|
|
1554
|
+
# fail cleanly: it succeeds while answering something else, and every
|
|
1555
|
+
# branch would silently read as conflict-free. plot-merge-queue.sh refuses
|
|
1556
|
+
# outright on that git; refusing is not an option here (the scan answers
|
|
1557
|
+
# many other questions), so the capability is reported as unknown instead.
|
|
1558
|
+
CONFLICT_MAIN_OK=0
|
|
1559
|
+
git show-ref -q --verify "refs/remotes/origin/$MAIN" </dev/null 2>/dev/null \
|
|
1560
|
+
&& CONFLICT_MAIN_OK=1
|
|
1561
|
+
|
|
1562
|
+
MERGE_TREE_OK=0
|
|
1563
|
+
if [ "$CONFLICT_MAIN_OK" = 1 ]; then
|
|
1564
|
+
git_ver=$(git --version 2>/dev/null | sed -n 's/^git version \([0-9]*\)\.\([0-9]*\).*/\1 \2/p')
|
|
1565
|
+
gv_major=${git_ver%% *}; gv_minor=${git_ver##* }
|
|
1566
|
+
if [ -n "$git_ver" ] && { [ "$gv_major" -gt 2 ] \
|
|
1567
|
+
|| { [ "$gv_major" -eq 2 ] && [ "$gv_minor" -ge 38 ]; }; }; then
|
|
1568
|
+
MERGE_TREE_OK=1
|
|
1569
|
+
fi
|
|
1570
|
+
fi
|
|
1571
|
+
|
|
1572
|
+
# The files that would collide merging this branch into the default branch, one
|
|
1573
|
+
# per line — or nothing. Two ways of printing nothing, and they are NOT the same
|
|
1574
|
+
# statement, which is why `conflicts_known` travels beside the list:
|
|
1575
|
+
#
|
|
1576
|
+
# merges cleanly → known, empty
|
|
1577
|
+
# cannot be asked → unknown, empty
|
|
1578
|
+
#
|
|
1579
|
+
# Sorted and deduplicated: one path appears once per conflicting stage (base,
|
|
1580
|
+
# ours, theirs), and a set reported three times over is a set nobody can count.
|
|
1581
|
+
conflicts_of() { # $1=branch → conflicting paths, one per line (may be empty)
|
|
1582
|
+
[ "$MERGE_TREE_OK" = 1 ] || return 0
|
|
1583
|
+
# Exit 0 means a clean merge and there is nothing to print. Anything the
|
|
1584
|
+
# command could not do lands here too, which is exactly why the caller must
|
|
1585
|
+
# consult `conflicts_known_of` rather than the emptiness of this output.
|
|
1586
|
+
#
|
|
1587
|
+
# Each info line is `<mode> <oid> <stage>\t<path>`, and the PATH IS EVERYTHING
|
|
1588
|
+
# AFTER THE TAB. Splitting on whitespace and taking the last field would work
|
|
1589
|
+
# on every path in this repo and mangle `docs/my notes.md` into `notes.md` —
|
|
1590
|
+
# a wrong filename, silently, in exactly the report that decides whether a
|
|
1591
|
+
# conflict set is "exactly the artifact".
|
|
1592
|
+
git merge-tree --write-tree "origin/$MAIN" "origin/$1" </dev/null 2>/dev/null \
|
|
1593
|
+
| awk -F'\t' 'NR == 1 { next } # the merged tree OID
|
|
1594
|
+
/^$/ { exit } # the blank line ends the file info
|
|
1595
|
+
NF > 1 { print $2 }' \
|
|
1596
|
+
| sort -u
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
# Whether this branch's conflict set was OBSERVED. False is not "clean" — it is
|
|
1600
|
+
# "not looked at", and the two must never render alike.
|
|
1601
|
+
conflicts_known_of() { # $1=branch $2=state → true|false
|
|
1602
|
+
[ "$MERGE_TREE_OK" = 1 ] || { printf 'false'; return; }
|
|
1603
|
+
# Only branches with real unlanded work are asked about — the same candidate
|
|
1604
|
+
# rule plot-merge-queue.sh applies, and for the same reason. A merged branch
|
|
1605
|
+
# has nothing left to merge, an `open` branch has no ref, and a bare claim is
|
|
1606
|
+
# an EMPTY commit: predicting its merge would spend a process to be told the
|
|
1607
|
+
# obvious, on every claimed branch, every five seconds.
|
|
1608
|
+
#
|
|
1609
|
+
# `false` for all of them is the honest answer rather than a convenient one:
|
|
1610
|
+
# nobody asked, so nothing is known. Reporting `true` about a question never
|
|
1611
|
+
# put is the absent-is-clean mistake pointing the other way — it would license
|
|
1612
|
+
# a consumer to read "no conflicts" off a branch nothing was computed for.
|
|
1613
|
+
case "$2" in
|
|
1614
|
+
wip|claimed) ;;
|
|
1615
|
+
*) printf 'false'; return ;;
|
|
1616
|
+
esac
|
|
1617
|
+
remote_ref_exists "$1" || { printf 'false'; return; }
|
|
1618
|
+
# CHANGES of its own, not merely commits of its own. A claim IS a commit —
|
|
1619
|
+
# the empty `plot: claim <branch>` push, which is what makes claiming
|
|
1620
|
+
# exclusive — so a commit count is non-zero for every claimed branch and would
|
|
1621
|
+
# let this gate through unchanged. What distinguishes a claim from work is
|
|
1622
|
+
# that it touches no file.
|
|
1623
|
+
#
|
|
1624
|
+
# The answer for a claim would be "clean" every time, so the cost is a process
|
|
1625
|
+
# spawn per claimed branch per five-second scan to be told the obvious. `false`
|
|
1626
|
+
# is also the more honest report: nobody asked, so nothing is known — and
|
|
1627
|
+
# `true` about a question never put would license a consumer to read "merges
|
|
1628
|
+
# cleanly" off a branch carrying nothing to merge.
|
|
1629
|
+
[ -n "$(git diff --name-only "origin/$MAIN...origin/$1" </dev/null 2>/dev/null \
|
|
1630
|
+
| head -n 1)" ] || { printf 'false'; return; }
|
|
1631
|
+
printf 'true'
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
# The files this branch changes relative to the default branch, one per line.
|
|
1635
|
+
#
|
|
1636
|
+
# EVIDENCE, never a verdict. It is one of the three lines a CI failure is
|
|
1637
|
+
# reported with — *this branch changes only .md* — and it exists so a reader can
|
|
1638
|
+
# weigh a failing step against what the branch actually touched. Nothing here
|
|
1639
|
+
# maps steps to paths: that mapping is a table nobody maintains, and it goes
|
|
1640
|
+
# silently wrong the first time a workflow is restructured.
|
|
1641
|
+
#
|
|
1642
|
+
# CAPPED, and the cap is REPORTED rather than silently applied — the rule this
|
|
1643
|
+
# scan already follows for its merge walk. A branch touching two hundred files
|
|
1644
|
+
# tells the reader nothing they can hold in their head, and shipping two hundred
|
|
1645
|
+
# strings per row through a 5 s poll is a cost with no matching benefit.
|
|
1646
|
+
CHANGED_PATHS_LIMIT=${PLOT_CHANGED_PATHS_LIMIT:-40}
|
|
1647
|
+
changed_paths_of() { # $1=branch → changed paths, one per line (may be empty)
|
|
1648
|
+
git diff --name-only "origin/$MAIN...origin/$1" </dev/null 2>/dev/null \
|
|
1649
|
+
| head -n "$CHANGED_PATHS_LIMIT"
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
# Did this branch land on the default branch? Positive evidence only — absence
|
|
1653
|
+
# keeps today's answer.
|
|
1654
|
+
#
|
|
1655
|
+
# The branch name is INTERPOLATED INTO AN ERE, so every metacharacter it may
|
|
1656
|
+
# legally contain is escaped first. Git allows `+`, `(`, `)`, `?`, `{`, `}` and
|
|
1657
|
+
# `.` in ref names, and unescaped each one changes what the pattern means —
|
|
1658
|
+
# `feature/v.1` would match `feature/vX1`, and `bug/a+b` would fail to match
|
|
1659
|
+
# its OWN merge subject. Both directions are wrong, and the second is the
|
|
1660
|
+
# quieter one: a branch that silently never matches simply keeps reading
|
|
1661
|
+
# `open`, which is this plan's own bug wearing a different hat.
|
|
1662
|
+
merged_by_subject() { # $1=branch → 0 when a conforming merge names it
|
|
1663
|
+
printf '%s\n' "$MERGE_SUBJECTS" \
|
|
1664
|
+
| grep -qE "^Merge pull request #[0-9]+ from [^/]+/$(printf '%s' "$1" | sed 's/[][\.*^$+?(){}|\/]/\\&/g')\$"
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
# Is this branch's PR ready to merge — open, not draft, AND checks green?
|
|
1668
|
+
# `--loose` promises "the prior wave's PRs are green and ready", so unknown
|
|
1669
|
+
# degrades to NO rather than assuming the best.
|
|
1670
|
+
#
|
|
1671
|
+
# READS THE SHARED CACHE, never the host directly, on any path. `prefill_pr_states`
|
|
1672
|
+
# has already fetched every PR in the repo in one `pr-list --rich` call, so the
|
|
1673
|
+
# cache holds `STATE<TAB>checks<TAB>draft` per branch. `pr_ready` is a lookup in
|
|
1674
|
+
# that cache and issues ZERO per-branch host calls — the N+1 that #228 removed
|
|
1675
|
+
# survived here alone, on the one path the board does not exercise, and this is
|
|
1676
|
+
# where it is finally gone WITHOUT A QUALIFIER. A qualifier ("except when the
|
|
1677
|
+
# list was empty…") is exactly the seam a later change slips the N+1 back through.
|
|
1678
|
+
#
|
|
1679
|
+
# THE PREDICATE IS `checks = green AND draft = false`. An earlier version
|
|
1680
|
+
# checked only `draft`, accepting any non-draft PR regardless of its build.
|
|
1681
|
+
# That violated the documented promise and would open the next wave on red CI —
|
|
1682
|
+
# precisely the failure shape the comment says `pr_ready` exists to prevent.
|
|
1683
|
+
#
|
|
1684
|
+
# EVERYTHING THAT IS NOT `green` REFUSES, and the refusals split by whether the
|
|
1685
|
+
# rollup could be had at all:
|
|
1686
|
+
# failing / pending — the rollup answered, and the answer is "not ready".
|
|
1687
|
+
# An ordinary refusal about the PR.
|
|
1688
|
+
# unknown / none / "" — the rollup could NOT be established: the host could not
|
|
1689
|
+
# say (Bitbucket with no CI backend), no checks ran, or
|
|
1690
|
+
# the list carried no row for this branch. `--loose`
|
|
1691
|
+
# degrades to strict, and the caller ANNOUNCES it, because
|
|
1692
|
+
# a silent degradation is indistinguishable from a bug.
|
|
1693
|
+
# An empty `checks` — a branch the list did not name, or named without a rollup —
|
|
1694
|
+
# is deliberately in the SECOND group: absent is not green, and asking the host
|
|
1695
|
+
# per branch to fill the gap is the very N+1 this removes. Where no rollup
|
|
1696
|
+
# arrived, `--loose` refuses and says so; it does not fall back to the host.
|
|
1697
|
+
#
|
|
1698
|
+
# SETS `_pr_ready_degraded` to the checks value (or "no-cache"/"no-host") when
|
|
1699
|
+
# readiness could not be verified, so the caller can distinguish "not ready
|
|
1700
|
+
# because failing" from "could not be verified" and announce only the latter.
|
|
1701
|
+
_pr_ready_degraded=""
|
|
1702
|
+
pr_ready() {
|
|
1703
|
+
local br="$1" cache="" cached st chk dft
|
|
1704
|
+
_pr_ready_degraded=""
|
|
1705
|
+
[ "$HOST_LOOKUP_OK" = 1 ] || { _pr_ready_degraded="no-host"; return 1; }
|
|
1706
|
+
[ -n "$HOST_STATE_CACHE" ] && cache="$HOST_STATE_CACHE/$(cache_key "$br")"
|
|
1707
|
+
[ -n "$cache" ] && [ -f "$cache" ] || { _pr_ready_degraded="no-cache"; return 1; }
|
|
1708
|
+
|
|
1709
|
+
cached=$(cat "$cache" 2>/dev/null)
|
|
1710
|
+
# Parse `STATE<TAB>checks<TAB>draft`
|
|
1711
|
+
st="${cached%% *}"
|
|
1712
|
+
cached="${cached#* }"
|
|
1713
|
+
chk="${cached%% *}"
|
|
1714
|
+
dft="${cached#* }"
|
|
1715
|
+
|
|
1716
|
+
# Must be OPEN (not MERGED/CLOSED/NONE), green, and not a draft.
|
|
1717
|
+
[ "$st" = "OPEN" ] || return 1
|
|
1718
|
+
if [ "$chk" != "green" ]; then
|
|
1719
|
+
# unknown/none/absent mean the rollup could not be established — announce it.
|
|
1720
|
+
# failing/pending are ordinary refusals about the PR itself and stay silent.
|
|
1721
|
+
case "$chk" in
|
|
1722
|
+
unknown|none|"") _pr_ready_degraded="${chk:-no-cache}" ;;
|
|
1723
|
+
esac
|
|
1724
|
+
return 1
|
|
1725
|
+
fi
|
|
1726
|
+
[ "$dft" = "false" ] || return 1
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
# ---------------------------------------------------------------------------
|
|
1730
|
+
# Recently delivered plans: the last day of finished work
|
|
1731
|
+
# ---------------------------------------------------------------------------
|
|
1732
|
+
#
|
|
1733
|
+
# The pulse read `active/` only, so a plan left the view the INSTANT it was
|
|
1734
|
+
# delivered — taking every branch with it. Measured on this repo: five plans
|
|
1735
|
+
# delivered in one day named eight branches between them, and DONE showed one,
|
|
1736
|
+
# because delivery and merge are minutes apart and only whichever branch
|
|
1737
|
+
# happened to sit in the gap survived. A group that is full by accident is
|
|
1738
|
+
# worse than one that is empty by rule.
|
|
1739
|
+
#
|
|
1740
|
+
# A ROLLING 24 HOURS, not the calendar day. Literally "delivered today" is
|
|
1741
|
+
# easier to explain and wrong at exactly the wrong moment: a plan delivered at
|
|
1742
|
+
# 23:50 vanishes ten minutes later, mid-session, while the branches it names
|
|
1743
|
+
# are still on screen. 24 is also the one freshness bound this repo already
|
|
1744
|
+
# uses (`Claim stale after`), so it is one unit to learn rather than two.
|
|
1745
|
+
#
|
|
1746
|
+
# THE WINDOW FILTERS BEFORE THE PARSE. Measured: ~57 ms per plan through
|
|
1747
|
+
# plot-plan-meta.sh against a scan that already runs 500–1050 ms, so parsing
|
|
1748
|
+
# fourteen delivered plans to discard thirteen would roughly double the pulse —
|
|
1749
|
+
# and that cost grows with the archive, which only ever gets larger, while the
|
|
1750
|
+
# answer stays the size of a day's work. So the cheap signal comes first (the
|
|
1751
|
+
# delivered symlink's own mtime) and only the candidates it admits are parsed.
|
|
1752
|
+
#
|
|
1753
|
+
# The pre-filter may OVER-ADMIT AND PAY A PARSE; it may never exclude. A
|
|
1754
|
+
# checkout can freshen an old file, so the `Delivered:` record keeps the last
|
|
1755
|
+
# word — but nothing mtime rules out could have been delivered inside the
|
|
1756
|
+
# window. On a fresh clone or a CI worktree every file shares one checkout
|
|
1757
|
+
# timestamp and ALL of them are admitted: correct, merely slower, once. Reaching
|
|
1758
|
+
# for `git log` per plan to avoid that would spend a git call to save a parse.
|
|
1759
|
+
DELIVERED_WINDOW_HOURS=$(cfg "Claim stale after" "24")
|
|
1760
|
+
case "$DELIVERED_WINDOW_HOURS" in (*[!0-9]*|'') DELIVERED_WINDOW_HOURS=24 ;; esac
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
# ---------------------------------------------------------------------------
|
|
1764
|
+
# When did this branch last CHANGE? — a measurement, and never a verdict
|
|
1765
|
+
# ---------------------------------------------------------------------------
|
|
1766
|
+
#
|
|
1767
|
+
# `local_ahead` and `local_dirty` are STATE, not CHANGE, and that is the whole
|
|
1768
|
+
# gap this fills. Measured 2026-08-18 across four concurrent workers: the branch
|
|
1769
|
+
# that had just opened the session's hardest PR read `ahead=0 dirty=False`,
|
|
1770
|
+
# bit-identical to a branch claimed a minute earlier and abandoned. Commits were
|
|
1771
|
+
# pushed, so `ahead` was 0; the tree was tidy, so `dirty` was false. Two opposite
|
|
1772
|
+
# situations, one row.
|
|
1773
|
+
#
|
|
1774
|
+
# Runtime cannot separate them either, and that is the measurement this plan was
|
|
1775
|
+
# written from: of four workers, the LONGEST-RUNNING was the most productive
|
|
1776
|
+
# (55 min, 4 commits, PR opened) while a 27-minute one had written nothing for
|
|
1777
|
+
# six. An operator watching the clock would have restarted exactly the wrong one.
|
|
1778
|
+
#
|
|
1779
|
+
# THE MAXIMUM OF THREE SOURCES, because work leaves evidence in three places and
|
|
1780
|
+
# any one alone is silent for long stretches:
|
|
1781
|
+
#
|
|
1782
|
+
# the newest commit — silent for the whole span BETWEEN commits,
|
|
1783
|
+
# which is precisely the window where a worker is
|
|
1784
|
+
# either deep in a test suite or dead
|
|
1785
|
+
# the newest dirty mtime — silent for a worker that commits tidily and
|
|
1786
|
+
# leaves nothing on the floor
|
|
1787
|
+
# the worker log's mtime — the only one that moves while a build runs
|
|
1788
|
+
#
|
|
1789
|
+
# A maximum, never a sum or an average: each source is evidence that work
|
|
1790
|
+
# happened, and the most RECENT evidence is the answer. A source that cannot be
|
|
1791
|
+
# read contributes nothing rather than a zero — a fabricated 0 here reads as
|
|
1792
|
+
# 1970, which is the oldest possible answer and would report every branch as
|
|
1793
|
+
# maximally quiet the moment one input went missing.
|
|
1794
|
+
#
|
|
1795
|
+
# ==> THIS FUNCTION DRAWS NO CONCLUSION, AND THAT IS DELIBERATE. <==
|
|
1796
|
+
#
|
|
1797
|
+
# There is no threshold here, no `stalled`, no "probably stuck". "Stuck" depends
|
|
1798
|
+
# on what the branch is DOING: fifteen minutes of silence is alarming during an
|
|
1799
|
+
# edit and unremarkable during `test:board`, which takes about that long by
|
|
1800
|
+
# itself. The threshold belongs to the reader; the measurement belongs here
|
|
1801
|
+
# (Principle 3 — scripts collect and report).
|
|
1802
|
+
#
|
|
1803
|
+
# A FACT MEASURED AFTER THE PLAN WAS WRITTEN, and the reason the paragraph above
|
|
1804
|
+
# is a warning rather than a preference: a worker deep in a SERIAL test run
|
|
1805
|
+
# writes no file for minutes at a time while its CHILD PROCESSES do the work.
|
|
1806
|
+
# This function will report that worker as quiet, and the number will be honest
|
|
1807
|
+
# — nothing was written. A consumer that renders "quiet for 8 minutes" as
|
|
1808
|
+
# "stuck" will restart a healthy worker mid-suite and redo everything it had
|
|
1809
|
+
# done, which is the exact failure the plan measured and costs more than the
|
|
1810
|
+
# ambiguity it was replacing. The number says how long since a file moved. It
|
|
1811
|
+
# does not say whether anything is wrong.
|
|
1812
|
+
#
|
|
1813
|
+
# ABSENT IS ABSENT — the rule every local signal in this file follows. A branch
|
|
1814
|
+
# with no worktree on this machine reports nothing at all, exactly as `worker`
|
|
1815
|
+
# reports `elsewhere` and for the same reason: this machine cannot see it. The
|
|
1816
|
+
# JSON carries `null`, never 0. A zero would be a fabricated measurement rather
|
|
1817
|
+
# than a missing one, and it would be the WORST fabrication available here — it
|
|
1818
|
+
# reads as "changed this instant", the single most reassuring answer, for the
|
|
1819
|
+
# population nobody can observe.
|
|
1820
|
+
#
|
|
1821
|
+
# BRANCHES WITH NO LOCAL WORKTREE COST NOTHING. The worktree lookup is a string
|
|
1822
|
+
# match against a table already read once per run, and it returns before any
|
|
1823
|
+
# fork. That is load-bearing rather than incidental: the plan's cost argument
|
|
1824
|
+
# assumes the fleet's remote branches are skipped entirely, and a `git log`
|
|
1825
|
+
# spent to learn "elsewhere" would be paid on every branch on every teammate's
|
|
1826
|
+
# machine, on every poll.
|
|
1827
|
+
#
|
|
1828
|
+
# THE PUSHED BRANCH IS DELIBERATELY NOT COVERED — the plan's open point, decided
|
|
1829
|
+
# here. `git log -1 origin/<branch>` would catch a worker on ANOTHER machine
|
|
1830
|
+
# moving a ref, and it is declined on two grounds. The cost lands exactly on the
|
|
1831
|
+
# population that must stay free: a branch with no local worktree is the one
|
|
1832
|
+
# whose remote ref would be the ONLY source, so the call cannot be skipped for
|
|
1833
|
+
# precisely the branches the paragraph above skips. And the field would stop
|
|
1834
|
+
# meaning one thing — every other `local_*` signal answers *what THIS machine
|
|
1835
|
+
# can see*, and a remote ref is what the REFS say, which the scan already
|
|
1836
|
+
# reports as `state` and `claimed`. Deferred, not rejected: if the fleet ever
|
|
1837
|
+
# spans machines in practice, the right shape is a SEPARATE field with its own
|
|
1838
|
+
# absent value, not a second meaning bolted onto this one.
|
|
1839
|
+
#
|
|
1840
|
+
|
|
1841
|
+
changed_ago_of() { # $1=branch → "<seconds since>\t<epoch of>" for the newest evidence of work, or ""
|
|
1842
|
+
# TWO NUMBERS FROM ONE WALK, and the second is the one a change DETECTOR can
|
|
1843
|
+
# use. The age is what a reader wants on a row ("19h"); it is recomputed
|
|
1844
|
+
# against `now` every scan, so it moves once a second whether or not anything
|
|
1845
|
+
# happened. A consumer watching it for CHANGE therefore fires on every pulse
|
|
1846
|
+
# forever — measured on the live board 2026-08-24: 71805 → 71824 across 12
|
|
1847
|
+
# quiet seconds, on 16 rows nobody had touched in 19 hours.
|
|
1848
|
+
#
|
|
1849
|
+
# The INSTANT is stable. It moves only when a commit lands, a file is written
|
|
1850
|
+
# or the worker's log grows, which is exactly what "this row is not what it
|
|
1851
|
+
# was" means. Both are printed because both are wanted and the walk that finds
|
|
1852
|
+
# them is the same walk — asking twice would double the per-worktree cost the
|
|
1853
|
+
# note above exists to defend.
|
|
1854
|
+
local br="$1" row wt now newest="" t paths=()
|
|
1855
|
+
row=$(printf '%s' "$WORKTREES" | awk -F'\t' -v b="$br" '$1==b {print $2 "\t" $5; exit}')
|
|
1856
|
+
# No worktree here: this machine cannot answer, and says so by answering
|
|
1857
|
+
# nothing. Before any fork — see the cost note above.
|
|
1858
|
+
[ -n "$row" ] || return 1
|
|
1859
|
+
wt=$(printf '%s' "$row" | cut -f1)
|
|
1860
|
+
[ -n "$wt" ] && [ -d "$wt" ] || return 1
|
|
1861
|
+
|
|
1862
|
+
# 1. The newest commit. A REF QUESTION answered from this repository, the same
|
|
1863
|
+
# distinction `local_ahead_of` draws: worktrees share one ref database, so
|
|
1864
|
+
# `refs/heads/<br>` answers without a `git -C`. `%ct` is the COMMITTER date,
|
|
1865
|
+
# not `%at` the author date — a rebase or an amend rewrites the committer
|
|
1866
|
+
# date and leaves the author date at the original writing, and it is the
|
|
1867
|
+
# rewrite that is the evidence of work here.
|
|
1868
|
+
t=$(git log -1 --format=%ct "refs/heads/$br" </dev/null 2>/dev/null)
|
|
1869
|
+
case "$t" in ''|*[!0-9]*) ;; *) newest=$t ;; esac
|
|
1870
|
+
|
|
1871
|
+
# 2. The newest mtime of the real work on the floor — READ FROM THE TABLE,
|
|
1872
|
+
# not recomputed. The worktree sweep already ran `git status` once per
|
|
1873
|
+
# worktree and reduced its output to this one number there, applying
|
|
1874
|
+
# `plot_worker_dirty_filter` so the exclusions match `worker_dirty_paths`
|
|
1875
|
+
# exactly: a `.tmp1` that reset this clock while being excluded from that
|
|
1876
|
+
# list would be one file answering two questions two ways.
|
|
1877
|
+
#
|
|
1878
|
+
# Asking `plot_worker_dirty` here instead would run a SECOND `git status`
|
|
1879
|
+
# per worktree, which `fleet.test.mjs` counts and rejects — the board polls
|
|
1880
|
+
# this scan every 5 s.
|
|
1881
|
+
t=$(printf '%s' "$row" | cut -f2)
|
|
1882
|
+
case "$t" in
|
|
1883
|
+
''|*[!0-9]*) ;;
|
|
1884
|
+
*) if [ -z "$newest" ] || [ "$t" -gt "$newest" ]; then newest=$t; fi ;;
|
|
1885
|
+
esac
|
|
1886
|
+
|
|
1887
|
+
# 3. The worker's log — the only source that keeps moving while a build runs.
|
|
1888
|
+
# Present only where plot-dispatch started the worker itself; absent for
|
|
1889
|
+
# every hand-started one, which is the normal case for as long as `Worker
|
|
1890
|
+
# command` is unset. Absent contributes nothing.
|
|
1891
|
+
#
|
|
1892
|
+
# NOT COVERED BY THE TABLE'S NUMBER, deliberately: the sweep's figure comes
|
|
1893
|
+
# from `plot_worker_dirty_filter`, which EXCLUDES Plot's own records
|
|
1894
|
+
# because they are not work on the floor. This asks when anything last
|
|
1895
|
+
# MOVED, and the log is the one file still moving during a build. Two
|
|
1896
|
+
# questions, two right answers about one file — so it is added here.
|
|
1897
|
+
#
|
|
1898
|
+
# THE PATH IS ASKED FOR, NEVER SPELLED HERE. What Plot's own records are
|
|
1899
|
+
# called is `plot-worker-state.sh`'s knowledge, and a read-only scan that
|
|
1900
|
+
# names `.plot-worker.` has started classifying workers again — the
|
|
1901
|
+
# duplication removed on 2026-08-18 after the two copies had drifted.
|
|
1902
|
+
# `workerstate.test.mjs` enforces exactly that, and caught this line.
|
|
1903
|
+
local log
|
|
1904
|
+
if log=$(plot_worker_log "$wt"); then
|
|
1905
|
+
t=$(file_mtime "$log")
|
|
1906
|
+
case "$t" in
|
|
1907
|
+
''|*[!0-9]*) ;;
|
|
1908
|
+
*) if [ -z "$newest" ] || [ "$t" -gt "$newest" ]; then newest=$t; fi ;;
|
|
1909
|
+
esac
|
|
1910
|
+
fi
|
|
1911
|
+
|
|
1912
|
+
# Every source silent: a worktree with no commit, nothing on the floor and no
|
|
1913
|
+
# log. Nothing was OBSERVED, so nothing is claimed — the same answer the
|
|
1914
|
+
# absent worktree gives, for the same reason.
|
|
1915
|
+
[ -n "$newest" ] || return 1
|
|
1916
|
+
|
|
1917
|
+
now=$(date +%s)
|
|
1918
|
+
# Clamped at zero rather than allowed negative. A commit timestamp can sit in
|
|
1919
|
+
# the future — a skewed clock, or a rebase carrying a committer date from a
|
|
1920
|
+
# machine ahead of this one — and a negative age is not a thing a reader can
|
|
1921
|
+
# act on. Zero says "changed just now", which is the honest reading of
|
|
1922
|
+
# evidence dated later than the question.
|
|
1923
|
+
if [ "$newest" -gt "$now" ]; then printf '0\t%s' "$newest"; else printf '%s\t%s' "$((now - newest))" "$newest"; fi
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
# A plan whose delivered symlink was touched inside the window. `find -newermt`
|
|
1927
|
+
# is not portable to every BSD find in the wild, so the cutoff is computed and
|
|
1928
|
+
# compared with `stat` — one stat per file, no parse.
|
|
1929
|
+
delivered_candidates() {
|
|
1930
|
+
local cutoff now link mtime
|
|
1931
|
+
now=$(date +%s)
|
|
1932
|
+
cutoff=$((now - DELIVERED_WINDOW_HOURS * 3600))
|
|
1933
|
+
for link in "$DELIVERED_DIR"*.md; do
|
|
1934
|
+
[ -e "$link" ] || continue
|
|
1935
|
+
# `stat` follows the symlink, which is what we want: the TARGET is the plan,
|
|
1936
|
+
# and a plan edited after delivery must still admit. An unreadable time
|
|
1937
|
+
# ADMITS rather than excludes — the pre-filter may only over-admit, and the
|
|
1938
|
+
# `Delivered:` record has the last word either way.
|
|
1939
|
+
mtime=$(file_mtime "$link") || { printf '%s\n' "$link"; continue; }
|
|
1940
|
+
[ "$mtime" -ge "$cutoff" ] && printf '%s\n' "$link"
|
|
1941
|
+
done
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
# Does this plan's `Delivered:` record fall inside the window? The RECORD
|
|
1945
|
+
# decides — mtime only chose who got asked.
|
|
1946
|
+
#
|
|
1947
|
+
# "No date, no row." A delivered plan with an empty record does not appear at
|
|
1948
|
+
# all: no date means no membership in any window, the same rule the waiting age
|
|
1949
|
+
# already follows. Showing it always would create the one row that can never
|
|
1950
|
+
# age out of DONE, and the missing record is a bookkeeping fault
|
|
1951
|
+
# plot-reconcile-scan.sh exists to report — a view that quietly compensates
|
|
1952
|
+
# for it makes the fault harder to see.
|
|
1953
|
+
#
|
|
1954
|
+
# A BARE DATE IS ANCHORED AT THE END OF ITS DAY, not at midnight, and this is
|
|
1955
|
+
# the one detail that makes "rolling, not the calendar day" true rather than
|
|
1956
|
+
# merely stated. Every `Delivered:` record in this repo is a bare date, which
|
|
1957
|
+
# names no time — so anchoring at 00:00 measures from up to a day BEFORE the
|
|
1958
|
+
# delivery, and the window collapses back into exactly the calendar boundary
|
|
1959
|
+
# the rolling window exists to avoid: a plan delivered at 23:50 would be an
|
|
1960
|
+
# hour from expiry the moment it was written, and gone ten minutes later
|
|
1961
|
+
# mid-session while the branches it names are still on screen.
|
|
1962
|
+
#
|
|
1963
|
+
# Anchoring at 23:59:59 over-admits by at most the length of the delivery day.
|
|
1964
|
+
# That is the same direction the mtime pre-filter is allowed to err in, and for
|
|
1965
|
+
# the same reason: showing a finished plan slightly too long costs a row, while
|
|
1966
|
+
# dropping one mid-session costs the reader the work they were looking at. A
|
|
1967
|
+
# record that DOES carry a time is honoured exactly, so the imprecision belongs
|
|
1968
|
+
# to the record rather than to the rule.
|
|
1969
|
+
# The rule itself now runs inside the ONE estate parse below (`in_window`),
|
|
1970
|
+
# because asking it per plan meant an interpreter per plan. What it decides is
|
|
1971
|
+
# unchanged; only the number of processes that decide it is.
|
|
1972
|
+
|
|
1973
|
+
# ---------------------------------------------------------------------------
|
|
1974
|
+
# Plan enumeration: from the REF, not from the tree
|
|
1975
|
+
# ---------------------------------------------------------------------------
|
|
1976
|
+
#
|
|
1977
|
+
# THE SCAN NAMED A REF AND READ A DIRECTORY. Every fact below is derived from
|
|
1978
|
+
# `origin/$MAIN` and the banner says so, but the plan list was a filesystem
|
|
1979
|
+
# glob over `$ACTIVE_DIR` — and `git fetch` updates refs that a glob cannot
|
|
1980
|
+
# see. Measured in a two-clone sandbox, 2026-08-18:
|
|
1981
|
+
#
|
|
1982
|
+
# origin/main active plans (the REF): 3
|
|
1983
|
+
# working tree active plans: 2
|
|
1984
|
+
# scan --json reports: 2 plans
|
|
1985
|
+
#
|
|
1986
|
+
# The fetch SUCCEEDED. `origin/main` genuinely carried the third plan, pushed
|
|
1987
|
+
# by a second agent minutes earlier. The scan reported two and exited 0, so
|
|
1988
|
+
# nothing anywhere could tell that answer from a correct one. The board's plan
|
|
1989
|
+
# list was only ever as current as the operator's last `git pull`.
|
|
1990
|
+
#
|
|
1991
|
+
# It is worse during the fleet run the board exists to watch: rebases,
|
|
1992
|
+
# checkouts and worker commits rewrite the working tree continuously, so the
|
|
1993
|
+
# glob can return a different set on each 5 s poll while exiting 0 every time.
|
|
1994
|
+
# That is the flicker `bug/a-smaller-pulse-is-not-silently-better` guards
|
|
1995
|
+
# against; this is the cause it guards against the symptom of.
|
|
1996
|
+
#
|
|
1997
|
+
# Reading the ref makes the scan describe ONE ATOMIC COMMIT. Two polls of the
|
|
1998
|
+
# same ref return the same plans no matter what is happening on disk, which is
|
|
1999
|
+
# what makes the count stable and the banner true.
|
|
2000
|
+
#
|
|
2001
|
+
# WORKTREE OBSERVATION STAYS LOCAL, and the split is the whole design:
|
|
2002
|
+
# `local_dirty`, `local_worktree` and the `.git/index.lock` check describe
|
|
2003
|
+
# THIS MACHINE on purpose — they are the one place the scan knows more than
|
|
2004
|
+
# the refs do, and moving them to the ref would delete the signal rather than
|
|
2005
|
+
# fix it. Plan enumeration comes from the ref; worktree observation is local.
|
|
2006
|
+
#
|
|
2007
|
+
# UNCOMMITTED PLANS BECOME INVISIBLE, and that is the intended behaviour rather
|
|
2008
|
+
# than an accepted cost. The plan's Open Points flagged it: `/plot-idea` writes
|
|
2009
|
+
# a plan file before committing it. Three reasons it is right:
|
|
2010
|
+
# * The fleet view answers "what may a worker CLAIM". Workers are detached
|
|
2011
|
+
# agents in other worktrees and on other machines, and not one of them can
|
|
2012
|
+
# claim a plan that exists only in the operator's editor buffer. Showing it
|
|
2013
|
+
# advertises work nobody can take.
|
|
2014
|
+
# * The window is seconds wide. `/plot-idea` commits and pushes in the same
|
|
2015
|
+
# flow (see skills/plot-idea/SKILL.md), so an uncommitted plan is a state
|
|
2016
|
+
# inside one skill run, not a state anyone opens a board to watch.
|
|
2017
|
+
# * A board that mixes shared state with one machine's scratch is the bug
|
|
2018
|
+
# this file keeps fixing. `local_dirty` exists precisely so local facts
|
|
2019
|
+
# travel LABELLED as local; an unlabelled local plan row is the thing that
|
|
2020
|
+
# makes an operator trust a view that only they can see.
|
|
2021
|
+
#
|
|
2022
|
+
# The rule is: committed is shared, and the fleet view shows what is shared.
|
|
2023
|
+
#
|
|
2024
|
+
# WHEN THE REF CANNOT BE READ the scan falls back to the working tree and says
|
|
2025
|
+
# so. A fresh clone with no remote, a repo whose origin is unreachable and
|
|
2026
|
+
# whose refs were never fetched — for these `origin/$MAIN` names nothing, and
|
|
2027
|
+
# an empty plan list would be a confident lie in the one case where the
|
|
2028
|
+
# operator has no way to check it. Falling back is honest; falling back
|
|
2029
|
+
# SILENTLY would recreate this bug, so `plan_source` travels in --json.
|
|
2030
|
+
PLAN_SOURCE="ref"
|
|
2031
|
+
git rev-parse --verify --quiet "origin/$MAIN^{commit}" >/dev/null 2>&1 || PLAN_SOURCE="worktree"
|
|
2032
|
+
|
|
2033
|
+
# Paths in the ref, listed for one directory. Returns the blob paths as they
|
|
2034
|
+
# are spelled in the tree, so `$ACTIVE_DIR` prefixes survive into the loop that
|
|
2035
|
+
# pattern-matches on `$DELIVERED_DIR` below.
|
|
2036
|
+
#
|
|
2037
|
+
# `-z` and a NUL-delimited read: a path is user data, and a plan filename
|
|
2038
|
+
# containing a newline would otherwise split into two nonexistent plans.
|
|
2039
|
+
ref_ls() { # $1=dir → newline-separated paths under it in origin/$MAIN
|
|
2040
|
+
git ls-tree -z --name-only "origin/$MAIN" -- "$1" </dev/null 2>/dev/null \
|
|
2041
|
+
| tr '\0' '\n' | grep '\.md$' || true
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
# The content of a ref path, materialized where plot-plan-meta.sh can parse it.
|
|
2045
|
+
#
|
|
2046
|
+
# THE PARSER TAKES FILES, not stdin: it is an awk pass keyed on FILENAME and it
|
|
2047
|
+
# checks `[ -f "$1" ]`. Rather than reshape the format contract from here — the
|
|
2048
|
+
# one script allowed to know what a plan looks like — the blob is written to a
|
|
2049
|
+
# temp file. The parser is unchanged, so the contract tests still describe it.
|
|
2050
|
+
#
|
|
2051
|
+
# SYMLINKS ARE RESOLVED IN REF-SPACE, and this is the subtlety the whole block
|
|
2052
|
+
# turns on. `$ACTIVE_DIR` holds symlinks into `$PLAN_DIR`, and in a git tree a
|
|
2053
|
+
# symlink is a mode-120000 blob whose CONTENT IS THE TARGET PATH. `readlink`
|
|
2054
|
+
# would answer from the working tree — the exact thing being moved away from —
|
|
2055
|
+
# so the link is followed with a second `git show` against the same ref. One
|
|
2056
|
+
# hop only: plot's indexes are links to files, never chains, and a bounded walk
|
|
2057
|
+
# beats a loop detector for a shape that cannot nest.
|
|
2058
|
+
# CREATED ONCE, EAGERLY, and that is a correction rather than a style choice:
|
|
2059
|
+
# `ref_plan_file` is called as `$(ref_plan_file ...)`, which runs it in a
|
|
2060
|
+
# SUBSHELL. A lazy `[ -z "$REF_TMP" ] && REF_TMP=$(mktemp -d)` inside it
|
|
2061
|
+
# assigns in the child and the parent never sees it — so every call made a
|
|
2062
|
+
# fresh directory, the parent's variable stayed empty, and the EXIT trap
|
|
2063
|
+
# cleaned nothing. Measured while writing this: three plans, three temp dirs,
|
|
2064
|
+
# none removed. The lifetime is owned out here, where the trap can see it.
|
|
2065
|
+
REF_TMP=""
|
|
2066
|
+
if [ "$PLAN_SOURCE" = "ref" ]; then
|
|
2067
|
+
REF_TMP=$(mktemp -d "${TMPDIR:-/tmp}/plot-fleet-ref.XXXXXX") || REF_TMP=""
|
|
2068
|
+
# The scan is read-only and short-lived, and the board polls it every 5 s —
|
|
2069
|
+
# a directory that outlives the run would accumulate one per poll.
|
|
2070
|
+
[ -n "$REF_TMP" ] && trap 'rm -rf "$REF_TMP"' EXIT INT TERM
|
|
2071
|
+
# No temp dir means no way to hand the parser a file, so the ref path cannot
|
|
2072
|
+
# work. Falling back to the checkout is the honest answer, and it announces
|
|
2073
|
+
# itself through `plan_source` exactly like an unreadable ref.
|
|
2074
|
+
[ -n "$REF_TMP" ] || PLAN_SOURCE="worktree"
|
|
2075
|
+
fi
|
|
2076
|
+
|
|
2077
|
+
# EVERY PLAN PATH'S MODE, IN ONE CALL — see `REMOTE_REFS` for the measurement.
|
|
2078
|
+
#
|
|
2079
|
+
# `ref_plan_file` asked `git ls-tree` once per plan for a single field: the file
|
|
2080
|
+
# MODE, which is how a symlink (120000) is told from a regular file. Profiled
|
|
2081
|
+
# 2026-08-20 that was 69 spawns of the scan's 459, and unlike `show-ref` these
|
|
2082
|
+
# are not cheap — reading a tree out of a packfile is real work.
|
|
2083
|
+
#
|
|
2084
|
+
# `ls-tree -r` over the plan directories answers for every path at once: 134
|
|
2085
|
+
# entries in 512 ms here, against 69 separate spawns at 31-56 ms of launch
|
|
2086
|
+
# overhead EACH before any work happens.
|
|
2087
|
+
#
|
|
2088
|
+
# Scoped to the two directories plot keeps plans in rather than the whole tree,
|
|
2089
|
+
# because that is all `ref_plan_file` is ever asked about, and an unbounded
|
|
2090
|
+
# `ls-tree -r` on a large repo would trade one cost for another.
|
|
2091
|
+
PLAN_MODES=$(git ls-tree -r "origin/$MAIN" \
|
|
2092
|
+
-- "$PLAN_DIR" "$ACTIVE_DIR" </dev/null 2>/dev/null)
|
|
2093
|
+
|
|
2094
|
+
# The mode of $1 in the ref, from the batch. Empty when the path is not there,
|
|
2095
|
+
# which is what a failed per-path `ls-tree` also produced.
|
|
2096
|
+
ref_mode_of() { # $1=path → mode digits, or ""
|
|
2097
|
+
printf '%s\n' "$PLAN_MODES" | awk -v p="$1" '
|
|
2098
|
+
{ path = $0; sub(/^[^\t]*\t/, "", path) }
|
|
2099
|
+
path == p { print $1; exit }'
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
# EVERY PLAN BLOB, WRITTEN IN ONE PROCESS.
|
|
2103
|
+
#
|
|
2104
|
+
# `ref_plan_file` handed the parser a temp file per plan, fetched with its own
|
|
2105
|
+
# `git show`. MEASURED 2026-08-20: one such `show` cost 407-621 ms — variable
|
|
2106
|
+
# because several worktrees were hitting one object store — and there were 68 of
|
|
2107
|
+
# them: ~31 s for a single call site, against a 30 s budget. `cat-file --batch`
|
|
2108
|
+
# read NINETEEN blobs in 559 ms, so reading EVERY plan costs about what reading
|
|
2109
|
+
# one did.
|
|
2110
|
+
#
|
|
2111
|
+
# THE FRAMING IS BY BYTE COUNT, NOT BY PATTERN, and that is the whole reason
|
|
2112
|
+
# this is perl rather than awk. `--batch` emits `<oid> blob <size>` then exactly
|
|
2113
|
+
# `<size>` bytes then a newline; a plan containing a line shaped like
|
|
2114
|
+
# `deadbeef blob 42` would desynchronise any split that looks for the header
|
|
2115
|
+
# instead of counting. Two earlier attempts here did exactly that — one wrote
|
|
2116
|
+
# nothing at all, silently, and every plan fell through to the per-plan `show`
|
|
2117
|
+
# with the spawn count unchanged. The count is read from the header and honoured.
|
|
2118
|
+
#
|
|
2119
|
+
# Only REGULAR files are written. A symlink's blob is its target path rather than
|
|
2120
|
+
# a plan, and resolving it needs the link followed first — `ref_plan_file` still
|
|
2121
|
+
# does that, then finds the resolved target already here.
|
|
2122
|
+
#
|
|
2123
|
+
# Failure is silent BY DESIGN: an unwritten file misses the cache in
|
|
2124
|
+
# `ref_plan_file` and takes the per-plan `git show` exactly as before. This is a
|
|
2125
|
+
# cache, so it may only save work, never change an answer.
|
|
2126
|
+
if [ -n "$REF_TMP" ] && [ -n "$PLAN_MODES" ] && command -v perl >/dev/null 2>&1; then
|
|
2127
|
+
printf '%s\n' "$PLAN_MODES" | awk -F'\t' '
|
|
2128
|
+
$0 ~ /^100644 / { name = $2; sub(/.*\//, "", name)
|
|
2129
|
+
split($1, h, " "); print h[3] "\t" name }' \
|
|
2130
|
+
> "$REF_TMP/.manifest" 2>/dev/null
|
|
2131
|
+
if [ -s "$REF_TMP/.manifest" ]; then
|
|
2132
|
+
awk -F'\t' '{print $1}' "$REF_TMP/.manifest" \
|
|
2133
|
+
| git cat-file --batch </dev/stdin 2>/dev/null \
|
|
2134
|
+
| perl -e '
|
|
2135
|
+
my (%name, $dir); $dir = $ARGV[0];
|
|
2136
|
+
open(my $m, "<", "$dir/.manifest") or exit 0;
|
|
2137
|
+
while (<$m>) { chomp; my ($o, $n) = split(/\t/); $name{$o} = $n if $n }
|
|
2138
|
+
close $m; binmode STDIN;
|
|
2139
|
+
while (my $hdr = <STDIN>) {
|
|
2140
|
+
chomp $hdr;
|
|
2141
|
+
my ($oid, $type, $size) = split(/ /, $hdr);
|
|
2142
|
+
last unless defined $size && $size =~ /^[0-9]+$/;
|
|
2143
|
+
my ($buf, $nl); read(STDIN, $buf, $size); read(STDIN, $nl, 1);
|
|
2144
|
+
next unless $type eq "blob" && $name{$oid};
|
|
2145
|
+
open(my $fh, ">", "$dir/$name{$oid}") or next;
|
|
2146
|
+
binmode $fh; print $fh $buf; close $fh;
|
|
2147
|
+
}' "$REF_TMP" 2>/dev/null
|
|
2148
|
+
fi
|
|
2149
|
+
fi
|
|
2150
|
+
|
|
2151
|
+
ref_plan_file() { # $1=path in ref → temp file path, or "" when unreadable
|
|
2152
|
+
local p="$1" mode target content out
|
|
2153
|
+
mode=$(ref_mode_of "$p")
|
|
2154
|
+
# A path the batch does not carry may still exist — the batch is scoped to the
|
|
2155
|
+
# plan directories, and a symlink can point outside them. Asked directly only
|
|
2156
|
+
# then, so the fallback costs one spawn for a case that does not arise here
|
|
2157
|
+
# rather than one per plan for the case that always does.
|
|
2158
|
+
[ -n "$mode" ] || mode=$(git ls-tree "origin/$MAIN" -- "$p" </dev/null 2>/dev/null | awk '{print $1; exit}')
|
|
2159
|
+
if [ "$mode" = "120000" ]; then
|
|
2160
|
+
target=$(git show "origin/$MAIN:$p" </dev/null 2>/dev/null) || return 1
|
|
2161
|
+
[ -n "$target" ] || return 1
|
|
2162
|
+
case "$target" in
|
|
2163
|
+
/*)
|
|
2164
|
+
# AN ABSOLUTE TARGET IS NOT A PATH IN THE TREE. `ln -s "$(pwd)/…"`
|
|
2165
|
+
# stores `/home/runner/work/…` in the blob, and a repository has no
|
|
2166
|
+
# such directory — prefixing it with the link's dirname produces a path
|
|
2167
|
+
# that resolves to nothing, which is how this first showed up: three
|
|
2168
|
+
# board suites went from 104 passing to 93, every failure a plan that
|
|
2169
|
+
# had silently vanished from the pulse.
|
|
2170
|
+
#
|
|
2171
|
+
# Only the BASENAME can be trusted, and only inside `$PLAN_DIR`. That
|
|
2172
|
+
# is not a guess about where the plan is: an absolute link is
|
|
2173
|
+
# machine-specific by construction, so its directory half describes a
|
|
2174
|
+
# filesystem this scan may not be running on, while plot keeps every
|
|
2175
|
+
# plan in one directory by config. Resolving there is the only reading
|
|
2176
|
+
# that can be right on another machine.
|
|
2177
|
+
p="$PLAN_DIR$(basename "$target")" ;;
|
|
2178
|
+
*)
|
|
2179
|
+
# Resolved against the LINK's directory, the same way the filesystem
|
|
2180
|
+
# would resolve a relative link.
|
|
2181
|
+
p=$(printf '%s' "$(dirname "$p")/$target" | sed 's#/\./#/#g')
|
|
2182
|
+
# Collapse `a/b/../c` → `a/c` so the result is a path the tree can name.
|
|
2183
|
+
while printf '%s' "$p" | grep -q '[^/][^/]*/\.\./'; do
|
|
2184
|
+
p=$(printf '%s' "$p" | sed 's#[^/][^/]*/\.\./##')
|
|
2185
|
+
done ;;
|
|
2186
|
+
esac
|
|
2187
|
+
fi
|
|
2188
|
+
# ALREADY MATERIALISED? The batch below wrote every plan blob in the plan
|
|
2189
|
+
# directory into `$REF_TMP` in ONE process. A hit here costs no git at all.
|
|
2190
|
+
#
|
|
2191
|
+
# THE MEASUREMENT that made this the change worth making, 2026-08-20: one
|
|
2192
|
+
# `git show` of a plan blob cost 407-621 ms — variable because four worktrees
|
|
2193
|
+
# were hitting one object store — while `cat-file --batch` read NINETEEN
|
|
2194
|
+
# blobs in 559 ms, in a single process. Reading every plan therefore costs
|
|
2195
|
+
# about what reading one did, and 68 `show` spawns were ~31 s of the scan on
|
|
2196
|
+
# their own: over the whole 30 s budget for that one call site.
|
|
2197
|
+
if [ -n "$REF_TMP" ] && [ -f "$REF_TMP/$(basename "$p")" ]; then
|
|
2198
|
+
printf '%s' "$REF_TMP/$(basename "$p")"
|
|
2199
|
+
return 0
|
|
2200
|
+
fi
|
|
2201
|
+
content=$(git show "origin/$MAIN:$p" </dev/null 2>/dev/null) || return 1
|
|
2202
|
+
[ -n "$content" ] || return 1
|
|
2203
|
+
[ -n "$REF_TMP" ] || return 1
|
|
2204
|
+
# Named after the RESOLVED path so the basename the report prints is the
|
|
2205
|
+
# plan's own filename, matching what the worktree enumeration produced.
|
|
2206
|
+
out="$REF_TMP/$(basename "$p")"
|
|
2207
|
+
printf '%s\n' "$content" > "$out" 2>/dev/null || return 1
|
|
2208
|
+
printf '%s' "$out"
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
# Resolve which plans to report on.
|
|
2212
|
+
#
|
|
2213
|
+
# TWO PARALLEL ARRAYS, because a plan now has two paths that must not be
|
|
2214
|
+
# confused. `plans` keeps the path AS THE REF SPELLS IT — that is the plan's
|
|
2215
|
+
# identity, and it is what the reader sees. `plan_reads` holds the file to
|
|
2216
|
+
# PARSE, which in ref mode is a temp file holding the blob. In worktree mode
|
|
2217
|
+
# the two are equal, so every consumer below reads the same way in both modes.
|
|
2218
|
+
plans=()
|
|
2219
|
+
plan_reads=()
|
|
2220
|
+
# The declared phase of each plan, filled during enumeration and indexed
|
|
2221
|
+
# alongside `plans`. It is read where the delivered window used to test the
|
|
2222
|
+
# path prefix — see "the group is the phase" below.
|
|
2223
|
+
plan_phases=()
|
|
2224
|
+
|
|
2225
|
+
# Add one plan by its ref path, materializing the blob. A path that cannot be
|
|
2226
|
+
# read is SKIPPED rather than guessed at — a dangling index entry is a
|
|
2227
|
+
# bookkeeping fault plot-reconcile-scan.sh reports, and inventing a row for it
|
|
2228
|
+
# here would hide the fault behind a plausible-looking plan.
|
|
2229
|
+
add_ref_plan() { # $1=path in ref
|
|
2230
|
+
local f
|
|
2231
|
+
f=$(ref_plan_file "$1") || return 0
|
|
2232
|
+
[ -n "$f" ] || return 0
|
|
2233
|
+
plans+=("$1")
|
|
2234
|
+
plan_reads+=("$f")
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
# ---------------------------------------------------------------------------
|
|
2238
|
+
# What makes a file a plan
|
|
2239
|
+
# ---------------------------------------------------------------------------
|
|
2240
|
+
#
|
|
2241
|
+
# A `.md` file directly in `$PLAN_DIR` whose PHASE PARSES — `phase` is anything
|
|
2242
|
+
# other than `NONE`. Nothing else qualifies, and the rule is the parser's own
|
|
2243
|
+
# answer rather than a second opinion about it: plot-plan-meta.sh is the format
|
|
2244
|
+
# contract (Manifesto Principle 3), so a scan that grepped for `Phase:` itself
|
|
2245
|
+
# would be a second implementation of the format, free to disagree with the
|
|
2246
|
+
# first.
|
|
2247
|
+
#
|
|
2248
|
+
# THIS HAD TO BE DECIDED rather than inherited. The old enumeration globbed
|
|
2249
|
+
# `$ACTIVE_DIR`, and the symlinks there happen to point only at plans — so
|
|
2250
|
+
# non-plans were excluded BY ACCIDENT, as a side effect of nobody having linked
|
|
2251
|
+
# them. Measured in this repo 2026-08-19: 64 `.md` files in `$PLAN_DIR`, of
|
|
2252
|
+
# which 62 are plans and two are notes that carry no `Phase:` field at all
|
|
2253
|
+
# (`2026-08-18-the-repair-exists-report.md`, `kanban-board-v1-open-questions.md`).
|
|
2254
|
+
# Enumerating the directory without a rule would report both as phase-less
|
|
2255
|
+
# plans with no branches — trading a list that is wrongly short for one that is
|
|
2256
|
+
# wrongly long.
|
|
2257
|
+
#
|
|
2258
|
+
# `UNKNOWN` COUNTS AS A PLAN, and that direction is deliberate. `UNKNOWN` means
|
|
2259
|
+
# the file declared a phase whose value the parser did not recognise — a typo,
|
|
2260
|
+
# or a phase word this version predates. That is a plan with a bad field, and
|
|
2261
|
+
# the whole point of this change is that a plan cannot be simultaneously valid
|
|
2262
|
+
# and invisible; hiding it for a misspelling would rebuild the failure one
|
|
2263
|
+
# level down, where it is harder to see than a missing symlink was. `NONE` is
|
|
2264
|
+
# the different case: no field at all, so nothing claimed to be a plan.
|
|
2265
|
+
#
|
|
2266
|
+
# `$PLAN_DIR` IS READ NON-RECURSIVELY, which is what keeps the index
|
|
2267
|
+
# directories out of the list. `$ACTIVE_DIR` and `$DELIVERED_DIR` live inside
|
|
2268
|
+
# `$PLAN_DIR` by default, and their symlinks resolve to files already
|
|
2269
|
+
# enumerated — counting both would double every plan. `git ls-tree` without
|
|
2270
|
+
# `-r` lists one level, and the worktree glob `"$PLAN_DIR"*.md` does not
|
|
2271
|
+
# descend either.
|
|
2272
|
+
is_plan_phase() { # $1=normalized phase → 0 when this file is a plan
|
|
2273
|
+
case "$1" in
|
|
2274
|
+
""|NONE) return 1 ;;
|
|
2275
|
+
*) return 0 ;;
|
|
2276
|
+
esac
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
# ---------------------------------------------------------------------------
|
|
2280
|
+
# ONE PARSE FOR THE WHOLE ESTATE
|
|
2281
|
+
# ---------------------------------------------------------------------------
|
|
2282
|
+
#
|
|
2283
|
+
# THE COST THIS REMOVES, measured on this repo 2026-08-27 against 154 plans:
|
|
2284
|
+
# 319 `plot-plan-meta.sh` spawns and 463 `python3` spawns for a single
|
|
2285
|
+
# `--offline` scan, which ran at 86.8 % CPU — the scan was COMPUTING, not
|
|
2286
|
+
# waiting, and more than half of that budget was interpreter startup.
|
|
2287
|
+
#
|
|
2288
|
+
# The shape was two spawns per plan, twice over. Enumeration asked the parser
|
|
2289
|
+
# for each file's phase; the plan loop then asked the parser for the SAME file
|
|
2290
|
+
# again and started a fresh `python3` per plan to re-parse that helper's own
|
|
2291
|
+
# output. So the scan parsed each plan, then started an interpreter to re-parse
|
|
2292
|
+
# the parse.
|
|
2293
|
+
#
|
|
2294
|
+
# `plot-plan-meta.sh` TAKES A LIST and always did — its own docstring says
|
|
2295
|
+
# "Accepts many files in one invocation and parses them in a single awk pass",
|
|
2296
|
+
# and `board.ts` already calls it that way. The scan was the caller that did
|
|
2297
|
+
# not. Measured the same day: one plan 0.01 s, ALL plans 0.19 s in a single
|
|
2298
|
+
# invocation — roughly 300× cheaper for the whole estate than for one plan
|
|
2299
|
+
# each.
|
|
2300
|
+
#
|
|
2301
|
+
# So the estate is parsed ONCE, here, and every later question reads the result.
|
|
2302
|
+
# The four Released scan-performance plans fixed the HOST API N+1 (one bulk
|
|
2303
|
+
# `pr-list` instead of one `pr-state` per branch); this is the local-subprocess
|
|
2304
|
+
# N+1, the same shape and a different cost. Nothing below touches the host path.
|
|
2305
|
+
#
|
|
2306
|
+
# WHY THE RESULT IS FLATTENED HERE rather than kept as JSON: the consumers are
|
|
2307
|
+
# bash, and re-reading JSON per plan is precisely the defect being removed. One
|
|
2308
|
+
# python pass emits a tab-separated record stream for every plan at once, and
|
|
2309
|
+
# the loop reads records. There is no second interpreter.
|
|
2310
|
+
#
|
|
2311
|
+
# `record` types, one per line, all tab-separated and all prefixed by the plan
|
|
2312
|
+
# file they describe:
|
|
2313
|
+
# P <file> <phase> <delivered_in_window> one per parsed file
|
|
2314
|
+
# W <file> <wave-idx> <branch> <deferred> <why> <wave-name> <claim>
|
|
2315
|
+
#
|
|
2316
|
+
# NO ASSOCIATIVE ARRAYS. `/bin/bash` on macOS is 3.2 and this script uses no
|
|
2317
|
+
# bash-4 feature anywhere; a `declare -A` here would silently narrow where Plot
|
|
2318
|
+
# runs. The records are read into INDEX-PARALLEL arrays, the idiom the
|
|
2319
|
+
# enumeration below already uses for `plans`/`plan_reads`/`plan_phases`.
|
|
2320
|
+
#
|
|
2321
|
+
# ONE BAD FILE MUST NOT TAKE THE ESTATE DOWN. Batching makes that a new failure
|
|
2322
|
+
# mode: one invocation now covers every plan, so a parser that died on the worst
|
|
2323
|
+
# file would report nothing about the good ones. `plot-plan-meta.sh` contracts to
|
|
2324
|
+
# exit 0 always and report parse problems IN the JSON; this pass holds up its end
|
|
2325
|
+
# by decoding each JSON LINE independently and skipping the ones that do not
|
|
2326
|
+
# decode. A file that cannot be read is absent from the records, which the
|
|
2327
|
+
# callers already treat as "not a plan" — the same answer the per-file parse
|
|
2328
|
+
# gave when it failed.
|
|
2329
|
+
plan_meta_files=()
|
|
2330
|
+
plan_meta_phases=()
|
|
2331
|
+
plan_meta_inwindow=()
|
|
2332
|
+
plan_meta_waves=()
|
|
2333
|
+
|
|
2334
|
+
# Parses every plan file given, filling the four arrays above. Called ONCE.
|
|
2335
|
+
parse_plan_estate() { # $@=files to parse
|
|
2336
|
+
[ $# -gt 0 ] || return 0
|
|
2337
|
+
local records
|
|
2338
|
+
records=$("$script_dir/plot-plan-meta.sh" "$@" --prefixes "$PREFIX_RE" 2>/dev/null \
|
|
2339
|
+
| python3 -c '
|
|
2340
|
+
import json, re, sys, time
|
|
2341
|
+
|
|
2342
|
+
window = float(sys.argv[1]) * 3600
|
|
2343
|
+
now = time.time()
|
|
2344
|
+
|
|
2345
|
+
def in_window(raw):
|
|
2346
|
+
"""The `Delivered:` record against the rolling window — the same rule the
|
|
2347
|
+
per-plan test applied, moved into the one pass. A record whose date does
|
|
2348
|
+
not parse is dropped rather than coerced: Date-style leniency would turn a
|
|
2349
|
+
typo into a confident answer. A record with no time anchors at 23:59:59,
|
|
2350
|
+
so a plan delivered at 23:50 is not an hour from expiry the moment it is
|
|
2351
|
+
written. A FUTURE record is INSIDE (negative age), because hiding a live
|
|
2352
|
+
plan for a mistyped year costs more than showing one."""
|
|
2353
|
+
raw = (raw or "").strip()
|
|
2354
|
+
if not raw:
|
|
2355
|
+
return False
|
|
2356
|
+
m = re.match(r"(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?", raw)
|
|
2357
|
+
if not m:
|
|
2358
|
+
return False
|
|
2359
|
+
y, mo, dy, hh, mi = m.groups()
|
|
2360
|
+
timed = hh is not None
|
|
2361
|
+
try:
|
|
2362
|
+
at = time.mktime((int(y), int(mo), int(dy),
|
|
2363
|
+
int(hh) if timed else 23, int(mi) if timed else 59,
|
|
2364
|
+
0 if timed else 59, 0, 0, -1))
|
|
2365
|
+
except (ValueError, OverflowError):
|
|
2366
|
+
return False
|
|
2367
|
+
return (now - at) <= window
|
|
2368
|
+
|
|
2369
|
+
def clean(s):
|
|
2370
|
+
return str(s).replace("\t", " ").replace("\n", " ")
|
|
2371
|
+
|
|
2372
|
+
for line in sys.stdin:
|
|
2373
|
+
line = line.strip()
|
|
2374
|
+
if not line:
|
|
2375
|
+
continue
|
|
2376
|
+
# PER LINE, so one unparseable plan costs only itself. The helper emits
|
|
2377
|
+
# JSON Lines and promises never to crash; this is the second half of that
|
|
2378
|
+
# promise, held on the consuming side.
|
|
2379
|
+
try:
|
|
2380
|
+
d = json.loads(line)
|
|
2381
|
+
except Exception:
|
|
2382
|
+
continue
|
|
2383
|
+
f = d.get("file")
|
|
2384
|
+
if not f:
|
|
2385
|
+
continue
|
|
2386
|
+
print("\t".join(["P", clean(f), clean(d.get("phase", "")),
|
|
2387
|
+
"1" if in_window(d.get("delivered_raw")) else "0"]))
|
|
2388
|
+
for i, w in enumerate(d.get("waves", []) or []):
|
|
2389
|
+
name = w.get("name")
|
|
2390
|
+
for b in w.get("branches", []) or []:
|
|
2391
|
+
ref = b.get("branch") or ""
|
|
2392
|
+
# Not every prefixed token in a ## Branches section is
|
|
2393
|
+
# implementation work. A cited file path (`docs/note.md`) matches
|
|
2394
|
+
# the docs/ branch prefix, and an idea/ branch carries the plan
|
|
2395
|
+
# itself — counting either as outstanding would keep a finished
|
|
2396
|
+
# wave blocked forever.
|
|
2397
|
+
if ref.startswith("idea/") or "." in ref.rsplit("/", 1)[-1]:
|
|
2398
|
+
continue
|
|
2399
|
+
# THE REASON FOR THE DEFERRAL rides with its flag, and it rides
|
|
2400
|
+
# BEFORE the claim note: tab is an IFS whitespace character, so a
|
|
2401
|
+
# run of tabs collapses to one separator and only the LAST field
|
|
2402
|
+
# may be optional. "-" stands in for empty everywhere, so no run
|
|
2403
|
+
# can form.
|
|
2404
|
+
print("\t".join(clean(x) for x in [
|
|
2405
|
+
"W", f, str(i), ref, str(b.get("deferred")).lower(),
|
|
2406
|
+
(b.get("deferred_reason") or "-"),
|
|
2407
|
+
name or "-", b.get("claimed") or "-"]))
|
|
2408
|
+
' "$DELIVERED_WINDOW_HOURS" 2>/dev/null) || records=""
|
|
2409
|
+
|
|
2410
|
+
local kind file rest
|
|
2411
|
+
while IFS=$'\t' read -r kind file rest; do
|
|
2412
|
+
[ -n "$kind" ] || continue
|
|
2413
|
+
case "$kind" in
|
|
2414
|
+
P)
|
|
2415
|
+
plan_meta_files+=("$file")
|
|
2416
|
+
# `rest` is "<phase>\t<inwindow>"; both are single tokens with no tabs.
|
|
2417
|
+
plan_meta_phases+=("${rest%% *}")
|
|
2418
|
+
plan_meta_inwindow+=("${rest##* }")
|
|
2419
|
+
plan_meta_waves+=("")
|
|
2420
|
+
;;
|
|
2421
|
+
W)
|
|
2422
|
+
# Wave rows follow their plan's P row, so the last-appended entry is
|
|
2423
|
+
# the one this row belongs to — the helper emits one object per file
|
|
2424
|
+
# and this pass prints them in that order.
|
|
2425
|
+
local last=$((${#plan_meta_files[@]} - 1))
|
|
2426
|
+
[ "$last" -ge 0 ] || continue
|
|
2427
|
+
[ "${plan_meta_files[$last]}" = "$file" ] || continue
|
|
2428
|
+
plan_meta_waves[$last]="${plan_meta_waves[$last]}$rest"$'\n'
|
|
2429
|
+
;;
|
|
2430
|
+
esac
|
|
2431
|
+
done <<< "$records"
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
# The index of a parsed file in the arrays above, or "" when it was not parsed
|
|
2435
|
+
# (an unreadable file, or one the helper could not decode). Linear, over an
|
|
2436
|
+
# array the size of the plan directory — the estate is parsed once, so this
|
|
2437
|
+
# replaces a SUBPROCESS per lookup with a string compare per lookup.
|
|
2438
|
+
plan_meta_index_of() { # $1=file → index on stdout, or ""
|
|
2439
|
+
local i
|
|
2440
|
+
for i in "${!plan_meta_files[@]}"; do
|
|
2441
|
+
if [ "${plan_meta_files[$i]}" = "$1" ]; then printf '%s' "$i"; return 0; fi
|
|
2442
|
+
done
|
|
2443
|
+
printf ''
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
# The phase a file declares, or "" when it is not a plan. Read from the single
|
|
2447
|
+
# estate parse above rather than spawned per file.
|
|
2448
|
+
plan_phase_of() { # $1=file to parse → normalized phase on stdout
|
|
2449
|
+
local i
|
|
2450
|
+
i=$(plan_meta_index_of "$1")
|
|
2451
|
+
[ -n "$i" ] || { printf ''; return 0; }
|
|
2452
|
+
printf '%s' "${plan_meta_phases[$i]}"
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
# A terminal phase belongs to the delivered group: the plan is finished, and it
|
|
2456
|
+
# appears only while its `Delivered:` record is inside the rolling window.
|
|
2457
|
+
#
|
|
2458
|
+
# `rejected` and `superseded` are terminal too, and they route here for the
|
|
2459
|
+
# same reason `/plot-deliver` files them under `$DELIVERED_DIR` (issue #33):
|
|
2460
|
+
# they are outcomes, not work. A worker may claim nothing under any of the
|
|
2461
|
+
# four.
|
|
2462
|
+
is_terminal_phase() { # $1=normalized phase → 0 when finished
|
|
2463
|
+
case "$1" in
|
|
2464
|
+
delivered|released|rejected|superseded) return 0 ;;
|
|
2465
|
+
*) return 1 ;;
|
|
2466
|
+
esac
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
if [ -n "$slug" ]; then
|
|
2470
|
+
# A NAMED SLUG IS NOT A LIST, so it keeps its own resolution: the caller
|
|
2471
|
+
# already said which plan it means, and the phase rule would only be able to
|
|
2472
|
+
# refuse the answer. `$ACTIVE_DIR`/`$DELIVERED_DIR` stay in the search path
|
|
2473
|
+
# because a slug is the one place their stable, undated names are the
|
|
2474
|
+
# QUESTION — `plot-fleet-scan.sh plot-sprint-support` names a symlink, not a
|
|
2475
|
+
# dated file, and every caller that passes a slug got it from one.
|
|
2476
|
+
if [ "$PLAN_SOURCE" = "ref" ]; then
|
|
2477
|
+
# Same precedence as the worktree form: the dated plan file first, then the
|
|
2478
|
+
# active index, then delivered. `ref_ls` is filtered rather than globbed
|
|
2479
|
+
# because the ref has no shell to expand `*`.
|
|
2480
|
+
found=0
|
|
2481
|
+
for cand in $(ref_ls "$PLAN_DIR" | grep "$slug\.md$") \
|
|
2482
|
+
$(ref_ls "$ACTIVE_DIR" | grep "/$slug\.md$") \
|
|
2483
|
+
$(ref_ls "$DELIVERED_DIR" | grep "/$slug\.md$"); do
|
|
2484
|
+
add_ref_plan "$cand"
|
|
2485
|
+
[ ${#plans[@]} -gt 0 ] && { found=1; break; }
|
|
2486
|
+
done
|
|
2487
|
+
[ "$found" = 1 ] || true
|
|
2488
|
+
else
|
|
2489
|
+
for cand in "$PLAN_DIR"*"$slug".md "$ACTIVE_DIR$slug.md" "$DELIVERED_DIR$slug.md"; do
|
|
2490
|
+
[ -e "$cand" ] && {
|
|
2491
|
+
plans+=("$(cd "$(dirname "$cand")" && pwd)/$(basename "$cand")")
|
|
2492
|
+
plan_reads+=("${plans[${#plans[@]}-1]}")
|
|
2493
|
+
break
|
|
2494
|
+
}
|
|
2495
|
+
done
|
|
2496
|
+
fi
|
|
2497
|
+
# The named plan's phase, recorded the same way the enumerated ones are so
|
|
2498
|
+
# the loop below reads one array in both paths. A slug names ONE file, so the
|
|
2499
|
+
# batch is a batch of one — it routes through the same estate parse anyway,
|
|
2500
|
+
# because that is where every later question reads its answer from.
|
|
2501
|
+
if [ ${#plans[@]} -gt 0 ]; then
|
|
2502
|
+
parse_plan_estate "${plan_reads[0]}"
|
|
2503
|
+
plan_phases+=("$(plan_phase_of "${plan_reads[0]}")")
|
|
2504
|
+
fi
|
|
2505
|
+
else
|
|
2506
|
+
# ---------------------------------------------------------------------------
|
|
2507
|
+
# THE GROUP IS THE PHASE, not the symlink
|
|
2508
|
+
# ---------------------------------------------------------------------------
|
|
2509
|
+
#
|
|
2510
|
+
# The list came from a glob over `$ACTIVE_DIR` plus one over `$DELIVERED_DIR`,
|
|
2511
|
+
# so a plan was visible because a LINK existed and grouped by WHICH directory
|
|
2512
|
+
# held the link. Both facts are hand-maintained copies of something the plan
|
|
2513
|
+
# already says about itself, and a copy maintained by hand disagrees with its
|
|
2514
|
+
# original the moment somebody forgets.
|
|
2515
|
+
#
|
|
2516
|
+
# Measured 2026-08-18: an agent wrote a plan file directly rather than through
|
|
2517
|
+
# `/plot-idea`. It parsed `canonical`, carried `Phase: Approved`, named three
|
|
2518
|
+
# branches in two waves and sat on `origin/main` — and every unscoped scan
|
|
2519
|
+
# reported 12 plans without it. Two agents were already working its branches.
|
|
2520
|
+
# The failure is silent in the direction that matters: the scan does not say
|
|
2521
|
+
# "one plan is unindexed", it says nothing at all and its footer count is
|
|
2522
|
+
# simply lower than reality. Nothing in the output distinguishes *this plan
|
|
2523
|
+
# does not exist* from *this plan is not indexed*, which is why it was
|
|
2524
|
+
# misdiagnosed three times as a board defect before anyone looked at the index.
|
|
2525
|
+
#
|
|
2526
|
+
# So the plan directory is enumerated and each file is grouped by the phase it
|
|
2527
|
+
# DECLARES. `$ACTIVE_DIR` keeps working and keeps being written — this change
|
|
2528
|
+
# only stops anything DEPENDING on it being right. A stale link is now inert
|
|
2529
|
+
# in both directions: an unlinked Approved plan appears, and a link pointing
|
|
2530
|
+
# at a delivered plan cannot resurrect it, because neither link is consulted.
|
|
2531
|
+
#
|
|
2532
|
+
# COST, measured on this repo 2026-08-19 rather than assumed: 64 plans parse
|
|
2533
|
+
# in 371 ms, ~5.8 ms each, against a full scan this file's own comments record
|
|
2534
|
+
# at 500–1050 ms (18.3 s with the host round trips). The plan's fixture
|
|
2535
|
+
# measurement puts the worst realistic case at ~300 ms extra for 1000 plans,
|
|
2536
|
+
# a scale no Plot repo has reached, behind the board's 5 s cache.
|
|
2537
|
+
#
|
|
2538
|
+
# The delivered mtime PRE-FILTER is gone with the directory it read, and it
|
|
2539
|
+
# was buying less than it appeared to: it keyed off the `$DELIVERED_DIR`
|
|
2540
|
+
# symlink's mtime, and a fresh checkout stamps every symlink at once — 56 of
|
|
2541
|
+
# 56 delivered links admitted here, so the parse it was meant to avoid was
|
|
2542
|
+
# already being paid in full. `delivered_in_window` (the `Delivered:` record)
|
|
2543
|
+
# was always the filter that actually decided, and the pre-filter's own
|
|
2544
|
+
# contract was that it may only ever OVER-admit. Removing it takes that
|
|
2545
|
+
# contract to its limit — strictly more correct, and on this repo not even
|
|
2546
|
+
# more expensive.
|
|
2547
|
+
#
|
|
2548
|
+
# ORDER IS PRESERVED: active plans first, in enumeration order, with the
|
|
2549
|
+
# terminal ones appended. A reader's list must not reshuffle when something is
|
|
2550
|
+
# delivered, and it is the same order the two globs produced before.
|
|
2551
|
+
#
|
|
2552
|
+
# --next and --list-eligible skip the terminal group entirely rather than
|
|
2553
|
+
# filter it later. Their question is "what may a worker claim", and a finished
|
|
2554
|
+
# plan answers nothing to it: even an `open` branch under one is work somebody
|
|
2555
|
+
# decided was not needed. Naming one would send a dispatcher at finished work.
|
|
2556
|
+
terminal_plans=()
|
|
2557
|
+
terminal_reads=()
|
|
2558
|
+
terminal_phases=()
|
|
2559
|
+
|
|
2560
|
+
# One enumeration, two groups. `add_plan_by_phase` parses once and files the
|
|
2561
|
+
# result, so no path below asks the format contract the same question twice.
|
|
2562
|
+
add_plan_by_phase() { # $1=identity path, $2=file to parse
|
|
2563
|
+
local id="$1" src="$2" ph
|
|
2564
|
+
ph=$(plan_phase_of "$src")
|
|
2565
|
+
is_plan_phase "$ph" || return 0
|
|
2566
|
+
if is_terminal_phase "$ph"; then
|
|
2567
|
+
[ "$next_only" = 1 ] && return 0
|
|
2568
|
+
terminal_plans+=("$id")
|
|
2569
|
+
terminal_reads+=("$src")
|
|
2570
|
+
terminal_phases+=("$ph")
|
|
2571
|
+
else
|
|
2572
|
+
plans+=("$id")
|
|
2573
|
+
plan_reads+=("$src")
|
|
2574
|
+
plan_phases+=("$ph")
|
|
2575
|
+
fi
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
# THE CANDIDATE LIST IS BUILT BEFORE ANYTHING IS PARSED, because the estate
|
|
2579
|
+
# is parsed in ONE call and a single call needs its whole argument list. In
|
|
2580
|
+
# ref mode that means every blob is materialized first: the phase decides the
|
|
2581
|
+
# group, so the file must exist before it can be asked, and it must be asked
|
|
2582
|
+
# together with all the others rather than one at a time.
|
|
2583
|
+
cand_ids=()
|
|
2584
|
+
cand_reads=()
|
|
2585
|
+
if [ "$PLAN_SOURCE" = "ref" ]; then
|
|
2586
|
+
while IFS= read -r plan_path; do
|
|
2587
|
+
[ -n "$plan_path" ] || continue
|
|
2588
|
+
plan_blob=$(ref_plan_file "$plan_path") || continue
|
|
2589
|
+
[ -n "$plan_blob" ] || continue
|
|
2590
|
+
cand_ids+=("$plan_path")
|
|
2591
|
+
cand_reads+=("$plan_blob")
|
|
2592
|
+
done <<< "$(ref_ls "$PLAN_DIR")"
|
|
2593
|
+
else
|
|
2594
|
+
for plan_path in "$PLAN_DIR"*.md; do
|
|
2595
|
+
[ -e "$plan_path" ] || continue
|
|
2596
|
+
cand_ids+=("$plan_path")
|
|
2597
|
+
cand_reads+=("$plan_path")
|
|
2598
|
+
done
|
|
2599
|
+
fi
|
|
2600
|
+
|
|
2601
|
+
# ONE INVOCATION FOR THE WHOLE ESTATE. Everything below reads its result.
|
|
2602
|
+
[ ${#cand_reads[@]} -gt 0 ] && parse_plan_estate "${cand_reads[@]}"
|
|
2603
|
+
|
|
2604
|
+
for cand_i in "${!cand_ids[@]}"; do
|
|
2605
|
+
add_plan_by_phase "${cand_ids[$cand_i]}" "${cand_reads[$cand_i]}"
|
|
2606
|
+
done
|
|
2607
|
+
|
|
2608
|
+
for i in "${!terminal_plans[@]}"; do
|
|
2609
|
+
plans+=("${terminal_plans[$i]}")
|
|
2610
|
+
plan_reads+=("${terminal_reads[$i]}")
|
|
2611
|
+
plan_phases+=("${terminal_phases[$i]}")
|
|
2612
|
+
done
|
|
2613
|
+
fi
|
|
2614
|
+
|
|
2615
|
+
if [ ${#plans[@]} -eq 0 ]; then
|
|
2616
|
+
# --next/--list-eligible must stay silent and exit 1: "nothing to start" is
|
|
2617
|
+
# the same answer whether the plans are all claimed or there are no plans at
|
|
2618
|
+
# all. Exiting 0 here would hand a caller an EMPTY branch name as if it were
|
|
2619
|
+
# valid work.
|
|
2620
|
+
[ "$next_only" = 1 ] && exit 1
|
|
2621
|
+
# A MACHINE CONSUMER FALLS THROUGH. An empty estate is a COMPLETE answer, and
|
|
2622
|
+
# this branch used to end the run before the emitter — so `--json` and
|
|
2623
|
+
# `--stream` were ignored entirely here and a consumer got human prose on
|
|
2624
|
+
# stdout. Under `--stream` that meant no terminal `pulse` line, and the
|
|
2625
|
+
# board's contract (":3407": *"a consumer that has seen `plan` lines and no
|
|
2626
|
+
# `pulse` line has a PARTIAL answer and must say so"*) made it report a
|
|
2627
|
+
# complete answer as a scan failure — forever, because the next scan said the
|
|
2628
|
+
# same. Measured 2026-08-28 against a board installed from npm: *"fleet scan
|
|
2629
|
+
# ended without a terminal pulse line"*, `ready:false`, every pulse.
|
|
2630
|
+
#
|
|
2631
|
+
# EVERY NEW USER HAS ZERO PLANS, so this was the first thing an installed
|
|
2632
|
+
# board did. Falling through costs nothing: every loop below is per-plan and
|
|
2633
|
+
# a no-op over none, and the emitter already renders `"plans":[]` with a
|
|
2634
|
+
# zeroed summary — the same document a populated estate produces, which is
|
|
2635
|
+
# the point. One emitter, one shape, no second place to drift.
|
|
2636
|
+
if [ "$as_json" != 1 ]; then
|
|
2637
|
+
# Names the directory that was actually READ. It named `$ACTIVE_DIR` while
|
|
2638
|
+
# the scan globbed it; pointing a reader at the index would now send them to
|
|
2639
|
+
# look for the cause of an empty list in a directory nothing consults.
|
|
2640
|
+
echo "No plans found in ${PLAN_DIR}."
|
|
2641
|
+
echo "summary: plans=0 waves=0 branches=0 claimed=0 eligible=0 blocked=0 deferred=0 main=$MAIN"
|
|
2642
|
+
exit 0
|
|
2643
|
+
fi
|
|
2644
|
+
fi
|
|
2645
|
+
|
|
2646
|
+
# A branch is merged when its remote ref is an ancestor of origin/<main>, or —
|
|
2647
|
+
# once the ref is gone — when the default branch carries a conforming PR-merge
|
|
2648
|
+
# commit naming it (see "the evidence that survives the ref" above). An absent
|
|
2649
|
+
# ref with no such commit means the work has not been taken yet.
|
|
2650
|
+
#
|
|
2651
|
+
# Every git call here redirects stdin from /dev/null: this function runs inside
|
|
2652
|
+
# `while read ... <<< "$states"` loops, and a child process inheriting that
|
|
2653
|
+
# here-string would swallow the loop's remaining lines.
|
|
2654
|
+
# Count commits beyond main that are NOT claim markers. A claim marker must be
|
|
2655
|
+
# BOTH titled `plot: claim ...` AND empty (its tree equals its parent's) — the
|
|
2656
|
+
# subject alone is not evidence. A human commit titled "plot: claim handling
|
|
2657
|
+
# refactor" carrying real files would otherwise read as an empty claim, and
|
|
2658
|
+
# with a deferred: annotation the reaper would offer to DELETE real work.
|
|
2659
|
+
# ONE `git log` PER BRANCH, NOT THREE SPAWNS PER COMMIT.
|
|
2660
|
+
#
|
|
2661
|
+
# THE MEASUREMENT, 2026-08-20: this loop was the largest single spawn source in
|
|
2662
|
+
# the scan. Per commit it ran `git log -1` for the subject and, for a
|
|
2663
|
+
# claim-titled one, two `git rev-parse` for the two trees. `rev-parse` was the
|
|
2664
|
+
# top per-branch call in the profile (14 for a single plan) and it all came from
|
|
2665
|
+
# here.
|
|
2666
|
+
#
|
|
2667
|
+
# `--shortstat` COLLAPSES THE TREE COMPARISON INTO THE SAME WALK. An empty
|
|
2668
|
+
# commit produces NO stat line, a commit with changes produces one — which is
|
|
2669
|
+
# exactly `tree == parent tree`, asked once for the whole range instead of twice
|
|
2670
|
+
# per commit. Verified against a real claim on this repo: the
|
|
2671
|
+
# `plot: claim feature/...` commit prints no stat line, its sibling prints
|
|
2672
|
+
# `1 file changed, 111 insertions(+)`.
|
|
2673
|
+
#
|
|
2674
|
+
# THE TEST IS UNCHANGED, AND MUST STAY SO: a claim marker is titled
|
|
2675
|
+
# `plot: claim ...` AND empty. Both, or it counts as real work — a human commit
|
|
2676
|
+
# titled "plot: claim handling refactor" carrying real files must still count,
|
|
2677
|
+
# because with a `deferred:` annotation the reaper would otherwise offer to
|
|
2678
|
+
# DELETE real work. This changes where the two facts come from, never what they
|
|
2679
|
+
# decide.
|
|
2680
|
+
#
|
|
2681
|
+
# A commit whose stat cannot be read counts as REAL, matching the old code's
|
|
2682
|
+
# behaviour when `rev-parse` failed: an unreadable tree is not evidence of
|
|
2683
|
+
# emptiness.
|
|
2684
|
+
# ONE WALK, BOTH COUNTS — `<total> <real>` on stdout.
|
|
2685
|
+
#
|
|
2686
|
+
# `branch_state` asked `git rev-list --count "origin/$MAIN..origin/$br"` for the
|
|
2687
|
+
# TOTAL and then called this for the REAL count, which walks the SAME range with
|
|
2688
|
+
# `git log`. Two spawns for one question, once per branch: 64 `rev-list` calls
|
|
2689
|
+
# measured on this repo 2026-08-20, the last per-branch block after #262 batched
|
|
2690
|
+
# the plan reads.
|
|
2691
|
+
#
|
|
2692
|
+
# The walk already visits every commit to classify it, so the total is a counter
|
|
2693
|
+
# it was throwing away. `ahead-behind` in `for-each-ref` would answer this
|
|
2694
|
+
# repo-wide in one call and needs git 2.41; 2.39 is what ships with macOS, so
|
|
2695
|
+
# that is not available here.
|
|
2696
|
+
#
|
|
2697
|
+
# BOTH NUMBERS FROM ONE READING keeps them consistent by construction: a total
|
|
2698
|
+
# and a real count taken from two walks could disagree if a ref moved between
|
|
2699
|
+
# them, and the caller compares the two.
|
|
2700
|
+
real_commits_beyond_main() { # $1=branch → "<total> <real>"
|
|
2701
|
+
local br="$1" n=0 total=0 line subj pending=0 _rcb_base _rcb_tip
|
|
2702
|
+
# NAMED AS SHAS, NOT AS REFS — see `REMOTE_REFS` for why. Both come from the
|
|
2703
|
+
# batch already in hand, so this costs no extra process.
|
|
2704
|
+
_rcb_base=$(remote_ref_oid "$MAIN")
|
|
2705
|
+
_rcb_tip=$(remote_ref_oid "$br")
|
|
2706
|
+
[ -n "$_rcb_base" ] && [ -n "$_rcb_tip" ] || { echo "0 0"; return; }
|
|
2707
|
+
# Records are `<sha>\t<subject>`, each optionally followed by a blank line and
|
|
2708
|
+
# a ` N files changed, ...` line. `pending` holds whether the record just read
|
|
2709
|
+
# is claim-titled and still waiting to learn if it was empty.
|
|
2710
|
+
while IFS= read -r line; do
|
|
2711
|
+
case "$line" in
|
|
2712
|
+
'') continue ;;
|
|
2713
|
+
*' file changed,'*|*' files changed,'*)
|
|
2714
|
+
# A stat line: the record before it had changes, so it is real work even
|
|
2715
|
+
# if it was claim-titled.
|
|
2716
|
+
[ "$pending" = 1 ] && { n=$((n + 1)); pending=0; }
|
|
2717
|
+
continue ;;
|
|
2718
|
+
esac
|
|
2719
|
+
# A new record. Anything still pending was claim-titled AND produced no stat
|
|
2720
|
+
# line — an empty claim marker, which does not count.
|
|
2721
|
+
pending=0
|
|
2722
|
+
# EVERY RECORD IS ONE COMMIT, which is what the separate `rev-list --count`
|
|
2723
|
+
# was spawned to learn. Counted here, it costs nothing.
|
|
2724
|
+
total=$((total + 1))
|
|
2725
|
+
subj=${line#*"$(printf '\t')"}
|
|
2726
|
+
case "$subj" in
|
|
2727
|
+
"plot: claim "*) pending=1 ;;
|
|
2728
|
+
*) n=$((n + 1)) ;;
|
|
2729
|
+
esac
|
|
2730
|
+
done <<EOF
|
|
2731
|
+
$(git log --format="%H%x09%s" --shortstat "$_rcb_base..$_rcb_tip" </dev/null 2>/dev/null)
|
|
2732
|
+
EOF
|
|
2733
|
+
echo "$total $n"
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
branch_state() {
|
|
2737
|
+
local br="$1"
|
|
2738
|
+
# THE REF CHECK STAYS IN FRONT. DO NOT HOIST THE MERGE LOOKUP ABOVE IT.
|
|
2739
|
+
#
|
|
2740
|
+
# A branch name can be reused: merge `bug/flaky`, delete it, then recreate it
|
|
2741
|
+
# for a second attempt — a normal thing when work is reopened. The FIRST
|
|
2742
|
+
# attempt's merge subject is still on the default branch, and it is now stale
|
|
2743
|
+
# evidence: it describes work that landed, while the branch of that name
|
|
2744
|
+
# carries new work that has not.
|
|
2745
|
+
#
|
|
2746
|
+
# The merge lookup is safe only BY PLACEMENT — it lives in the no-ref arm,
|
|
2747
|
+
# and a recreated branch has a ref, so it never reaches the lookup and takes
|
|
2748
|
+
# the ancestry path below instead. Moving the lookup to the top reads like a
|
|
2749
|
+
# cheap early answer and would silently report in-flight work as `merged`,
|
|
2750
|
+
# opening the next wave on it. A test in fleet.test.mjs pins this ordering.
|
|
2751
|
+
if ! remote_ref_exists "$br"; then
|
|
2752
|
+
# No ref carries two meanings and this used to answer `open` for both: a
|
|
2753
|
+
# branch never started, and a branch merged with its ref deleted at merge.
|
|
2754
|
+
# The wave arithmetic reads `open` as OUTSTANDING, so a finished wave never
|
|
2755
|
+
# completed and --next named finished work as the next thing to start.
|
|
2756
|
+
#
|
|
2757
|
+
# `merged` is already the state that settles a wave, so the arithmetic does
|
|
2758
|
+
# not change and no new state enters the vocabulary. Where no evidence
|
|
2759
|
+
# exists — squash merges, a hand-rewritten subject, a branch genuinely
|
|
2760
|
+
# never started — today's `open` stands. The fix may only move a branch
|
|
2761
|
+
# from `open` to `merged`, and only on positive evidence.
|
|
2762
|
+
merged_by_subject "$br" && { echo "merged"; return; }
|
|
2763
|
+
# No merge commit names it — which is the ordinary case under a squash
|
|
2764
|
+
# merge, not an exotic one. The local walk is now out of evidence, so the
|
|
2765
|
+
# host is asked. It may only ever move this branch from `open` to `merged`:
|
|
2766
|
+
# a miss, a CLOSED PR, or a host that cannot answer all fall through to the
|
|
2767
|
+
# `open` below, exactly as before this call existed.
|
|
2768
|
+
merged_by_host "$br" && { echo "merged"; return; }
|
|
2769
|
+
echo "open"; return
|
|
2770
|
+
fi
|
|
2771
|
+
# A CLAIM is a branch whose only commits beyond main are claim commits —
|
|
2772
|
+
# empty markers a dispatcher pushed to take the work. They must be real
|
|
2773
|
+
# commits, not a bare pointer at main: two branches pointing at the same
|
|
2774
|
+
# commit do not diverge, so the second push would succeed and both sides
|
|
2775
|
+
# would think they held the claim (see plot-dispatch.sh, "THE CLAIM").
|
|
2776
|
+
# ONE WALK ANSWERS BOTH. See `real_commits_beyond_main` for the measurement:
|
|
2777
|
+
# the separate `rev-list --count` here asked for a number that walk was
|
|
2778
|
+
# already computing and discarding, at one extra spawn per branch.
|
|
2779
|
+
local _bs_counts
|
|
2780
|
+
_bs_counts=$(real_commits_beyond_main "$br")
|
|
2781
|
+
ahead=${_bs_counts%% *}
|
|
2782
|
+
real=${_bs_counts##* }
|
|
2783
|
+
if [ "${ahead:-0}" -gt 0 ]; then
|
|
2784
|
+
[ "${real:-0}" = "0" ] && { echo "claimed"; return; }
|
|
2785
|
+
# Real work that main does not yet contain: `wip`, and ONLY `wip`.
|
|
2786
|
+
#
|
|
2787
|
+
# This arm once asked `merge-base --is-ancestor origin/$br origin/$MAIN`
|
|
2788
|
+
# here — "has the work already landed?" — and returned `merged` when it did.
|
|
2789
|
+
# That question was already answered by the `ahead` count above it and could
|
|
2790
|
+
# never fire: `ahead > 0` means `$br` carries at least one commit unreachable
|
|
2791
|
+
# from `$MAIN`, and a branch with such a commit CANNOT be an ancestor of
|
|
2792
|
+
# `$MAIN`, so `--is-ancestor` was false on every branch that reached it. It
|
|
2793
|
+
# was one git spawn per `wip` branch spent to re-derive a fact already in
|
|
2794
|
+
# hand — the per-branch tail this plan set out to thin — and its `merged`
|
|
2795
|
+
# was dead code that changed no verdict.
|
|
2796
|
+
#
|
|
2797
|
+
# The landed-work case is not lost; it is answered ONE LEVEL UP. A branch
|
|
2798
|
+
# whose commits are all in `$MAIN` counts `ahead = 0` and falls through to
|
|
2799
|
+
# the `merged` below, and a merge that deleted the ref never reaches here at
|
|
2800
|
+
# all (the no-ref arm returns first). If a future change makes `ahead`
|
|
2801
|
+
# something other than "commits `$MAIN` lacks", THIS is the invariant that
|
|
2802
|
+
# would break — the ancestry must move back, not be missed.
|
|
2803
|
+
#
|
|
2804
|
+
# A RESURRECTED REF BREAKS THE PREMISE ABOVE, and the join already knows.
|
|
2805
|
+
# The reasoning "a merge that deleted the ref never reaches here" holds only
|
|
2806
|
+
# while the ref STAYS deleted. `delete_branch_on_merge` is on, so the host
|
|
2807
|
+
# removes it — and a worktree that still holds the branch can push it back
|
|
2808
|
+
# afterwards, which a fleet does routinely. The ref then exists again while
|
|
2809
|
+
# the work is on `$MAIN` under a DIFFERENT commit, because a squash merge
|
|
2810
|
+
# rewrites it: `ahead > 0` (the pre-squash commits are unreachable from
|
|
2811
|
+
# `$MAIN`), `real > 0` (they are real work), and this arm calls finished
|
|
2812
|
+
# work `wip`.
|
|
2813
|
+
#
|
|
2814
|
+
# Measured 2026-08-23: `bug/done-holds-finished-plans-only`, PR #356 merged,
|
|
2815
|
+
# read `wip` for three hours. Its wave reported "3 merged, the rest not yet"
|
|
2816
|
+
# over four merged branches and never completed, so the plan sat in
|
|
2817
|
+
# Development with nothing left to do.
|
|
2818
|
+
#
|
|
2819
|
+
# `wip` is the WORST of the wrong answers, which is why this earns a check
|
|
2820
|
+
# rather than a note: it means *an agent is working here*, so a leftover
|
|
2821
|
+
# worktree reads as an occupied desk and the row asks a reader to wait for
|
|
2822
|
+
# something that finished.
|
|
2823
|
+
#
|
|
2824
|
+
# FREE, and that is what licenses it HERE. The state comes from the cache
|
|
2825
|
+
# `prefill_pr_states` already filled from ONE repo-wide `pr-list`, so this
|
|
2826
|
+
# adds no host call — asking per branch on this arm would put 22 calls back
|
|
2827
|
+
# into every scan on this repo and undo the change that removed them. Where
|
|
2828
|
+
# the list did not arrive the cache is empty, `host_pr_state` answers `-`,
|
|
2829
|
+
# and the local walk decides exactly as it does today.
|
|
2830
|
+
#
|
|
2831
|
+
# ONLY `MERGED` MAY OVERRIDE the walk, and only toward `merged`. `OPEN`
|
|
2832
|
+
# means a PR exists for work still in flight — which is what `wip` already
|
|
2833
|
+
# says — and `CLOSED` or `NONE` are not evidence that anything landed.
|
|
2834
|
+
if [ "$(host_pr_state "$br")" = MERGED ]; then echo "merged"; return; fi
|
|
2835
|
+
echo "wip"; return
|
|
2836
|
+
fi
|
|
2837
|
+
# Nothing of its own. NOT a claim: that shape is indistinguishable from
|
|
2838
|
+
# merged work, which is exactly why claims carry a commit.
|
|
2839
|
+
echo "merged"
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
# Prose is suppressed by BOTH alternate output modes. --json accumulates the
|
|
2843
|
+
# same derivation into a document instead of printing it; the arithmetic below
|
|
2844
|
+
# is untouched, which is what keeps the human report byte-identical.
|
|
2845
|
+
quiet=0
|
|
2846
|
+
[ "$next_only" = 1 ] && quiet=1
|
|
2847
|
+
[ "$as_json" = 1 ] && quiet=1
|
|
2848
|
+
# The ref this scan READ, and the ref the operator is STANDING ON. They are
|
|
2849
|
+
# different questions, and conflating them is the bug this block exists to
|
|
2850
|
+
# stop: every fact below is derived from `origin/$MAIN`, but the banner was
|
|
2851
|
+
# built from local `HEAD`. On `main` right after a fetch the two agree, which
|
|
2852
|
+
# is exactly why it survived — the common case made it look correct.
|
|
2853
|
+
#
|
|
2854
|
+
# When `origin/$MAIN` cannot be resolved (no remote, fresh clone) the ref is
|
|
2855
|
+
# reported as unknown. It does NOT fall back to `HEAD`: that would reintroduce
|
|
2856
|
+
# this bug in the one case where nothing can catch it, and a banner that says
|
|
2857
|
+
# "unknown" gets investigated in seconds where a real-looking SHA gets believed.
|
|
2858
|
+
READ_REF=$(git rev-parse --short "origin/$MAIN" 2>/dev/null) || READ_REF=""
|
|
2859
|
+
[ -n "$READ_REF" ] || READ_REF="unknown"
|
|
2860
|
+
LOCAL_HEAD=$(git rev-parse --short HEAD 2>/dev/null) || LOCAL_HEAD=""
|
|
2861
|
+
[ -n "$LOCAL_HEAD" ] || LOCAL_HEAD="unknown"
|
|
2862
|
+
# Kept as an alias for one release: the board reads `head` today (Agents tab),
|
|
2863
|
+
# and renaming a field out from under a live consumer is a break nobody asked
|
|
2864
|
+
# for. Removed once the board reads `read_ref`.
|
|
2865
|
+
HEAD_SHORT="$LOCAL_HEAD"
|
|
2866
|
+
json_plans=""
|
|
2867
|
+
|
|
2868
|
+
# Emit a JSON string with the six characters JSON forbids escaped. Branch names
|
|
2869
|
+
# and claim notes are user data: a plan may legitimately carry a quote or a
|
|
2870
|
+
# backslash, and an unescaped one would produce a document nothing can parse.
|
|
2871
|
+
json_str() {
|
|
2872
|
+
printf '%s' "$1" | LC_ALL=C sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' \
|
|
2873
|
+
-e 's/\t/\\t/g' -e 's/\r/\\r/g' -e 's/\x08/\\b/g' -e 's/\x0c/\\f/g'
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
# A newline-separated list as a JSON array of strings — `[]` for nothing.
|
|
2877
|
+
#
|
|
2878
|
+
# Each element goes through `json_str`, for the reason `json_str` itself exists:
|
|
2879
|
+
# a path is user data, and one legitimate quote in a filename would otherwise
|
|
2880
|
+
# produce a document nothing can parse. Blank lines are dropped, because a
|
|
2881
|
+
# trailing newline is punctuation rather than an empty path.
|
|
2882
|
+
json_array() {
|
|
2883
|
+
local out="" line
|
|
2884
|
+
while IFS= read -r line; do
|
|
2885
|
+
[ -n "$line" ] || continue
|
|
2886
|
+
out+="${out:+,}\"$(json_str "$line")\""
|
|
2887
|
+
done <<< "$1"
|
|
2888
|
+
printf '[%s]' "$out"
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
if [ "$next_only" != 1 ] && [ "$as_json" != 1 ]; then
|
|
2892
|
+
banner="plot-fleet pulse — $READ_REF on origin/$MAIN"
|
|
2893
|
+
# The local checkout and the ref this report was derived from disagree. That
|
|
2894
|
+
# is worth one clause: the operator is looking at a tree this report does not
|
|
2895
|
+
# describe. `behind` is how many commits of `origin/$MAIN` the checkout has
|
|
2896
|
+
# not got — empty when the two share no ancestry to count across.
|
|
2897
|
+
if [ "$READ_REF" != "unknown" ] && [ "$LOCAL_HEAD" != "$READ_REF" ]; then
|
|
2898
|
+
behind=$(git rev-list --count "HEAD..origin/$MAIN" 2>/dev/null) || behind=""
|
|
2899
|
+
# POINTS AT THE REPORT, NOT THE TREE. The measured failure was operators
|
|
2900
|
+
# believing a stale report, so the clause has to say what this report
|
|
2901
|
+
# describes — "your checkout is behind" is advice about the tree, and an
|
|
2902
|
+
# operator who reads it still has no reason to doubt the numbers below.
|
|
2903
|
+
#
|
|
2904
|
+
# The count is included only when git could compute it. `behind` counts
|
|
2905
|
+
# commits on origin/$MAIN the checkout lacks; on a diverged feature branch
|
|
2906
|
+
# that is true but partial, so the SHA leads and the count trails as a
|
|
2907
|
+
# parenthetical rather than being the claim.
|
|
2908
|
+
if [ -n "$behind" ] && [ "$behind" != 0 ]; then
|
|
2909
|
+
banner="$banner (not your checkout $LOCAL_HEAD, $behind behind)"
|
|
2910
|
+
else
|
|
2911
|
+
banner="$banner (not your checkout $LOCAL_HEAD)"
|
|
2912
|
+
fi
|
|
2913
|
+
fi
|
|
2914
|
+
if [ "$loose" = 1 ]; then
|
|
2915
|
+
if [ "$loose_verifiable" = 1 ]; then banner="$banner (loose eligibility)"
|
|
2916
|
+
else banner="$banner (--loose cannot verify PR readiness without a git host — using strict)"
|
|
2917
|
+
fi
|
|
2918
|
+
fi
|
|
2919
|
+
echo "$banner"; echo
|
|
2920
|
+
fi
|
|
2921
|
+
|
|
2922
|
+
n_plans=0 n_waves=0 n_branches=0 n_claimed=0 n_eligible=0 n_blocked=0 n_deferred=0
|
|
2923
|
+
claimable=()
|
|
2924
|
+
plan_files=()
|
|
2925
|
+
|
|
2926
|
+
plan_idx=-1
|
|
2927
|
+
for plan in "${plans[@]}"; do
|
|
2928
|
+
# `plan` is the plan's IDENTITY (the path as the ref or the tree spells it);
|
|
2929
|
+
# `plan_read` is the file to PARSE. They differ only in ref mode, where the
|
|
2930
|
+
# second is a materialized blob — see "Plan enumeration" above.
|
|
2931
|
+
plan_idx=$((plan_idx + 1))
|
|
2932
|
+
plan_read="${plan_reads[$plan_idx]}"
|
|
2933
|
+
# Per-plan reset. State that survives into the next iteration is how the
|
|
2934
|
+
# plan parser once leaked a `## Branches` flag across files — same shape of
|
|
2935
|
+
# bug, so the accumulator is cleared where the plan loop begins.
|
|
2936
|
+
json_waves=""
|
|
2937
|
+
# The estate was parsed ONCE, before this loop. A plan absent from that
|
|
2938
|
+
# result could not be read at all, which is the same answer the per-plan
|
|
2939
|
+
# parse gave by failing — so it is skipped here exactly as it was then.
|
|
2940
|
+
meta_i=$(plan_meta_index_of "$plan_read")
|
|
2941
|
+
[ -n "$meta_i" ] || continue
|
|
2942
|
+
|
|
2943
|
+
# The plan's own phase, carried onto the pulse so a consumer can derive a row
|
|
2944
|
+
# phase from the PAIR — plan state AND branch git state. It is reported, never
|
|
2945
|
+
# interpreted: this script collects and reports, and which column a row reads
|
|
2946
|
+
# is a judgment that belongs one layer up (Manifesto Principle 3).
|
|
2947
|
+
plan_phase="${plan_meta_phases[$meta_i]}"
|
|
2948
|
+
|
|
2949
|
+
# The delivered window, applied to the plans the PHASE put in the terminal
|
|
2950
|
+
# group. Enumeration grouped them; the `Delivered:` RECORD decides which of
|
|
2951
|
+
# them still appears.
|
|
2952
|
+
#
|
|
2953
|
+
# THE TEST IS THE PHASE, not the path. It read `case "$plan" in "$DELIVERED_DIR"*)`
|
|
2954
|
+
# — the directory the link sat in — and that made "which group is this plan
|
|
2955
|
+
# in" a fact about a symlink while "what phase is it" was a fact about the
|
|
2956
|
+
# file. The old comment here noted that an active plan carrying
|
|
2957
|
+
# `Phase: Delivered` was drift the window must not hide; under the phase rule
|
|
2958
|
+
# that drift cannot be constructed, because there is no second place for the
|
|
2959
|
+
# answer to live. One source, so nothing to disagree.
|
|
2960
|
+
#
|
|
2961
|
+
# Two exits, and both matter:
|
|
2962
|
+
# * the record's date has aged out of the window — ordinary expiry;
|
|
2963
|
+
# * there is NO record — "no date, no row". `reconcile-scan-accuracy.md` is
|
|
2964
|
+
# the live example; showing it would create the one row that can never
|
|
2965
|
+
# age out of DONE.
|
|
2966
|
+
# Both leave before a single git call is spent on the plan's branches.
|
|
2967
|
+
if is_terminal_phase "$plan_phase"; then
|
|
2968
|
+
[ "${plan_meta_inwindow[$meta_i]}" = "1" ] || continue
|
|
2969
|
+
fi
|
|
2970
|
+
|
|
2971
|
+
n_plans=$((n_plans + 1))
|
|
2972
|
+
plan_target=$(readlink "$plan" 2>/dev/null && echo "" || true)
|
|
2973
|
+
|
|
2974
|
+
# The plan's DISPLAY NAME: the dated filename, never an index alias.
|
|
2975
|
+
#
|
|
2976
|
+
# Unscoped enumeration now names `$PLAN_DIR` files directly, so the basename
|
|
2977
|
+
# is already the dated name and the `readlink` is a no-op that falls through
|
|
2978
|
+
# to `$plan`. It stays because a NAMED SLUG can still resolve to a symlink in
|
|
2979
|
+
# `$ACTIVE_DIR`/`$DELIVERED_DIR` (see "a named slug is not a list"), and that
|
|
2980
|
+
# path must keep printing the plan's own filename rather than the alias the
|
|
2981
|
+
# caller typed.
|
|
2982
|
+
#
|
|
2983
|
+
# In ref mode the resolution already happened in ref-space and the temp file
|
|
2984
|
+
# was named after its result, so its basename IS the answer — `readlink`
|
|
2985
|
+
# would ask the working tree, which the ref enumeration stopped doing.
|
|
2986
|
+
if [ "$PLAN_SOURCE" = "ref" ]; then
|
|
2987
|
+
plan_base=$(basename "$plan_read")
|
|
2988
|
+
else
|
|
2989
|
+
plan_base=$(basename "$(readlink "$plan" 2>/dev/null || echo "$plan")")
|
|
2990
|
+
fi
|
|
2991
|
+
|
|
2992
|
+
# --log-pulse WRITES, so it needs a path in the working tree — a pulse line
|
|
2993
|
+
# appended to a materialized blob would be discarded with the temp dir, and a
|
|
2994
|
+
# ref is not writable in the first place. Enumeration moved to the ref; the
|
|
2995
|
+
# one write this script performs stays where a write can persist and be
|
|
2996
|
+
# committed. A plan present in the ref but not on this machine is simply not
|
|
2997
|
+
# logged: the alternative is writing a file the operator never checked out.
|
|
2998
|
+
if [ "$PLAN_SOURCE" = "ref" ]; then
|
|
2999
|
+
[ -e "$PLAN_DIR$plan_base" ] && plan_files+=("$PLAN_DIR$plan_base")
|
|
3000
|
+
else
|
|
3001
|
+
plan_files+=("$plan")
|
|
3002
|
+
fi
|
|
3003
|
+
|
|
3004
|
+
# THE PLAN'S IDENTITY FOR THE TERMINAL CACHE — its CONTENT, hashed, not its
|
|
3005
|
+
# name or its mtime. An edited plan yields a different oid and its branches'
|
|
3006
|
+
# cached answers stop validating, which is the invalidation the plan requires:
|
|
3007
|
+
# a plan is an input to the derivation, so an answer derived under one
|
|
3008
|
+
# revision is not evidence about the next.
|
|
3009
|
+
#
|
|
3010
|
+
# `hash-object` reads a file without touching the object database. It is one
|
|
3011
|
+
# fork per PLAN — not per branch — so it does not reintroduce the per-branch
|
|
3012
|
+
# cost this whole change removes.
|
|
3013
|
+
TERMINAL_PLAN_OID=$(git hash-object "$plan_read" 2>/dev/null || echo "")
|
|
3014
|
+
|
|
3015
|
+
# The wave walk is driven by plot-plan-meta.sh's own output, flattened for
|
|
3016
|
+
# this plan by the ONE estate parse above. It was a fresh `python3` per plan
|
|
3017
|
+
# here — 463 interpreter starts on this repo, each re-parsing a JSON document
|
|
3018
|
+
# the scan had just finished parsing. The flattening rule is unchanged; only
|
|
3019
|
+
# the number of interpreters that apply it is.
|
|
3020
|
+
[ "$quiet" = 1 ] || echo "== $plan_base =="
|
|
3021
|
+
|
|
3022
|
+
wave_lines="${plan_meta_waves[$meta_i]}"
|
|
3023
|
+
# The records carry a trailing newline per row; the reader below expects the
|
|
3024
|
+
# same shape the per-plan shim produced, which had none on the last line.
|
|
3025
|
+
wave_lines="${wave_lines%$'\n'}"
|
|
3026
|
+
|
|
3027
|
+
[ -n "$wave_lines" ] || { [ "$quiet" = 1 ] || { echo " (no branches)"; echo; }; continue; }
|
|
3028
|
+
|
|
3029
|
+
# Pass 1: per-branch git state.
|
|
3030
|
+
#
|
|
3031
|
+
# Field order matters: tab is an IFS whitespace character, so bash collapses
|
|
3032
|
+
# a run of tabs into ONE separator. A branch with no claim note would shift
|
|
3033
|
+
# every later field left by one. Everything that must survive `read` is
|
|
3034
|
+
# therefore placed BEFORE the optional claim note, which stays last.
|
|
3035
|
+
# Emitted fields are never empty ("-" stands in), so no tab run can collapse.
|
|
3036
|
+
#
|
|
3037
|
+
# `why` — the deferral reason — is free prose in a MIDDLE column, which is
|
|
3038
|
+
# only safe because of that same rule: it is "-" when absent, never "". The
|
|
3039
|
+
# tabs inside it are replaced with spaces by the shim above, for the same
|
|
3040
|
+
# reason. It cannot go last; `claim` already is.
|
|
3041
|
+
states=""
|
|
3042
|
+
while IFS=$'\t' read -r idx br deferred why wname claim; do
|
|
3043
|
+
[ -n "$br" ] || continue
|
|
3044
|
+
if [ "$deferred" = "true" ]; then st="deferred"; else st=$(branch_state "$br"); fi
|
|
3045
|
+
states+="$idx $br $st $deferred $why $wname $claim"$'\n'
|
|
3046
|
+
done <<< "$wave_lines"
|
|
3047
|
+
|
|
3048
|
+
# Pass 2: wave verdicts. A wave is complete when none of its non-deferred
|
|
3049
|
+
# branches is outstanding; eligible when all PRIOR waves are complete.
|
|
3050
|
+
wave_ids=$(printf '%s' "$states" | cut -f1 | sort -un)
|
|
3051
|
+
prior_ok=1
|
|
3052
|
+
for wid in $wave_ids; do
|
|
3053
|
+
wname=$(printf '%s' "$states" | awk -F'\t' -v w="$wid" '$1==w {print $6; exit}')
|
|
3054
|
+
[ "$wname" = "-" ] && wname=""
|
|
3055
|
+
outstanding=0
|
|
3056
|
+
_loose_degraded_branches=""
|
|
3057
|
+
while IFS=$'\t' read -r idx br st deferred why nm claim; do
|
|
3058
|
+
[ "$idx" = "$wid" ] || continue
|
|
3059
|
+
[ "$st" = "deferred" ] && continue
|
|
3060
|
+
# strict (default): only a merged branch is settled.
|
|
3061
|
+
# loose: pushed work counts too — buys throughput, pays in rebase risk.
|
|
3062
|
+
case "$st" in
|
|
3063
|
+
merged) ;;
|
|
3064
|
+
wip)
|
|
3065
|
+
# Loose only counts pushed work as settled when its PR is verifiably
|
|
3066
|
+
# ready. Unverifiable → treat as outstanding (i.e. behave as strict).
|
|
3067
|
+
if [ "$loose_verifiable" = 1 ] && pr_ready "$br"; then :; else
|
|
3068
|
+
outstanding=$((outstanding + 1))
|
|
3069
|
+
# Track branches where --loose degraded to strict because the
|
|
3070
|
+
# rollup could not be verified — these deserve a warning.
|
|
3071
|
+
if [ -n "$_pr_ready_degraded" ]; then
|
|
3072
|
+
_loose_degraded_branches="${_loose_degraded_branches}${_loose_degraded_branches:+ }$br($_pr_ready_degraded)"
|
|
3073
|
+
fi
|
|
3074
|
+
fi ;;
|
|
3075
|
+
*) outstanding=$((outstanding + 1)) ;;
|
|
3076
|
+
esac
|
|
3077
|
+
done <<< "$states"
|
|
3078
|
+
|
|
3079
|
+
# `eligible` IS A CLAIM ABOUT STARTABILITY, not about wave ordering alone.
|
|
3080
|
+
#
|
|
3081
|
+
# Measured 2026-08-27: every one-wave plan in `not-started` on the live
|
|
3082
|
+
# board read `eligible`, and `plot-dispatch.sh` refused all six of them —
|
|
3083
|
+
# *"plan '<slug>' is still Draft on <ref> — nothing may be dispatched."*
|
|
3084
|
+
# Six of six. Both components were correct and they were answering
|
|
3085
|
+
# different questions: this computed *no earlier wave blocks this one*,
|
|
3086
|
+
# and the reader took it to mean *I can start this*. Those coincide only
|
|
3087
|
+
# for an approved plan.
|
|
3088
|
+
#
|
|
3089
|
+
# THE PHASE IS ALREADY IN HAND. `$plan_phase` was parsed above for the
|
|
3090
|
+
# terminal grouping, so consulting it here costs no read and no host call
|
|
3091
|
+
# — the fix adds a test, not a lookup.
|
|
3092
|
+
#
|
|
3093
|
+
# AN ALLOWLIST OF ONE GOOD PHASE, mirroring `plot-dispatch.sh`'s own gate
|
|
3094
|
+
# (`case "$gate_phase" in approved) ;;`) rather than testing for `draft`.
|
|
3095
|
+
# A denylist is the blocklist-collapse shape this codebase keeps removing:
|
|
3096
|
+
# `design` is documented as a phase whose work cannot yet be handed over,
|
|
3097
|
+
# and `UNKNOWN`/`NONE` are unreadable answers. Under a `draft`-only test
|
|
3098
|
+
# each of those would inherit the good word. The scan and the dispatcher
|
|
3099
|
+
# now refuse the same set, which is the disagreement this removes.
|
|
3100
|
+
#
|
|
3101
|
+
# ORDERING IS STILL COMPUTED FIRST, and `complete` still outranks it: a
|
|
3102
|
+
# wave whose branches have all merged IS complete whatever its plan says,
|
|
3103
|
+
# because that is a statement about work that already landed, not an
|
|
3104
|
+
# invitation to start any. Only the word a reader ACTS on is withheld.
|
|
3105
|
+
#
|
|
3106
|
+
# NOT `blocked`, deliberately. That word means *an earlier wave has not
|
|
3107
|
+
# landed* — an ordering fact that resolves by merging work. This resolves
|
|
3108
|
+
# by a person approving the plan. Folding both into one word would rebuild
|
|
3109
|
+
# the ambiguity one level down, and `blocked by <wave> — 1 branch` is a
|
|
3110
|
+
# sentence a row in this state cannot truthfully complete.
|
|
3111
|
+
if [ "$outstanding" -eq 0 ]; then verdict="complete"
|
|
3112
|
+
elif [ "$plan_phase" != "approved" ]; then verdict="unapproved"
|
|
3113
|
+
elif [ "$prior_ok" -eq 1 ]; then verdict="eligible"
|
|
3114
|
+
else verdict="blocked"; fi
|
|
3115
|
+
|
|
3116
|
+
[ "$quiet" = 1 ] || echo " ${wname:-(unnamed)} — $verdict"
|
|
3117
|
+
# A degradation that says nothing is indistinguishable from a bug.
|
|
3118
|
+
# When --loose falls back to strict because the rollup cannot be had,
|
|
3119
|
+
# say so — an operator who passed the flag and sees strict behaviour
|
|
3120
|
+
# has no way to tell "the rollup said not-green" from "the rollup
|
|
3121
|
+
# could not be had". Both are correct refusals; only one is about
|
|
3122
|
+
# their PR.
|
|
3123
|
+
if [ "$quiet" != 1 ] && [ "$loose" = 1 ] && [ -n "$_loose_degraded_branches" ]; then
|
|
3124
|
+
echo " (--loose degraded to strict: checks unavailable for ${_loose_degraded_branches})"
|
|
3125
|
+
fi
|
|
3126
|
+
json_branches=""
|
|
3127
|
+
while IFS=$'\t' read -r idx br st deferred why nm claim; do
|
|
3128
|
+
[ "$idx" = "$wid" ] || continue
|
|
3129
|
+
[ "$claim" = "-" ] && claim=""
|
|
3130
|
+
[ "$why" = "-" ] && why=""
|
|
3131
|
+
n_branches=$((n_branches + 1))
|
|
3132
|
+
case "$st" in
|
|
3133
|
+
# The REASON, where the plan recorded one. A bare `deferred` beside a
|
|
3134
|
+
# branch with no commits reads as two unrelated facts when the first is
|
|
3135
|
+
# the reason for the second, and the sentence that says so was already
|
|
3136
|
+
# written in the plan file.
|
|
3137
|
+
deferred) n_deferred=$((n_deferred + 1)); note="deferred${why:+ — $why}" ;;
|
|
3138
|
+
claimed) n_claimed=$((n_claimed + 1)); note="claimed${claim:+ ($claim)}" ;;
|
|
3139
|
+
merged) note="merged" ;;
|
|
3140
|
+
wip) note="in progress" ;;
|
|
3141
|
+
*) note="open" ;;
|
|
3142
|
+
esac
|
|
3143
|
+
if [ "$verdict" = "eligible" ] && [ "$st" = "open" ]; then
|
|
3144
|
+
n_eligible=$((n_eligible + 1))
|
|
3145
|
+
claimable+=("$br")
|
|
3146
|
+
fi
|
|
3147
|
+
[ "$quiet" = 1 ] || echo " $br — $note"
|
|
3148
|
+
if [ "$as_json" = 1 ]; then
|
|
3149
|
+
# The INTERNAL state ($st), never the prose label ($note): the board
|
|
3150
|
+
# must not parse a string that exists for humans to read.
|
|
3151
|
+
json_branches+="${json_branches:+,}{\"branch\":\"$(json_str "$br")\""
|
|
3152
|
+
json_branches+=",\"state\":\"$st\",\"deferred\":$deferred"
|
|
3153
|
+
# WHY it was deferred, straight from the plan's annotation. "" where the
|
|
3154
|
+
# branch is not deferred, and "" where it is deferred with nothing
|
|
3155
|
+
# recorded — the flag says which of those two a reader is looking at.
|
|
3156
|
+
json_branches+=",\"deferred_reason\":\"$(json_str "$why")\""
|
|
3157
|
+
json_branches+=",\"claimed\":\"$(json_str "$claim")\""
|
|
3158
|
+
# What this machine knows and the refs do not. Absent everywhere else:
|
|
3159
|
+
# `local_dirty:false` and `local_worktree:""` are what a branch checked
|
|
3160
|
+
# out on somebody else's laptop reports, which is the same answer it
|
|
3161
|
+
# gave before this field existed. Only the JSON carries it — the prose
|
|
3162
|
+
# report is a human interface and the row it feeds lives in the board.
|
|
3163
|
+
wt_row=$(local_worktree_of "$br")
|
|
3164
|
+
wt_dirty=$(printf '%s' "$wt_row" | cut -f2)
|
|
3165
|
+
wt_here=$(printf '%s' "$wt_row" | cut -f1)
|
|
3166
|
+
wt_lock=$(printf '%s' "$wt_row" | cut -f3)
|
|
3167
|
+
json_branches+=",\"local_dirty\":${wt_dirty:-false}"
|
|
3168
|
+
# A write in progress THIS INSTANT — a separate fact from dirtiness, and
|
|
3169
|
+
# false wherever this machine could not observe one, which is the answer
|
|
3170
|
+
# every branch elsewhere gives.
|
|
3171
|
+
json_branches+=",\"local_locked\":${wt_lock:-false}"
|
|
3172
|
+
json_branches+=",\"local_worktree\":\"$(json_str "$wt_here")\""
|
|
3173
|
+
# WHETHER SOMEBODY HOLDS THIS BRANCH — the derivation `local_worktree`
|
|
3174
|
+
# is one input to, not a rename of it. A branch is held when a worktree
|
|
3175
|
+
# here has it checked out AND its tip has not merged: the two facts this
|
|
3176
|
+
# loop already holds, `wt_here` and `$st`. The AND is the whole point.
|
|
3177
|
+
# `local_worktree` alone would also fire on a CLEAN worktree left on a
|
|
3178
|
+
# branch whose work has landed — a leftover directory, not a held branch
|
|
3179
|
+
# — and lifting that to WORKING is the merged-leftover misread the plan
|
|
3180
|
+
# forbids. Excluding `merged` here is what keeps location and holding
|
|
3181
|
+
# apart. `held` may only be true where a worktree is present, so it is
|
|
3182
|
+
# false on every branch on every other machine, exactly like its
|
|
3183
|
+
# neighbours: additive, never a downgrade of the refs answer. It is
|
|
3184
|
+
# emitted for consumers to READ instead of re-deriving `!merged`; it is
|
|
3185
|
+
# never fed back into the wave arithmetic below, which settles waves on
|
|
3186
|
+
# `merged` alone and is untouched by holding.
|
|
3187
|
+
if [ -n "$wt_here" ] && [ "$st" != "merged" ]; then
|
|
3188
|
+
json_branches+=",\"held\":true"
|
|
3189
|
+
else
|
|
3190
|
+
json_branches+=",\"held\":false"
|
|
3191
|
+
fi
|
|
3192
|
+
# WHETHER A REF HOLDS THIS BRANCH — the git fact `plot-dispatch.sh`
|
|
3193
|
+
# tests when it claims, published so consumers stop inferring it.
|
|
3194
|
+
#
|
|
3195
|
+
# NOT A RENAME OF ITS TWO NEIGHBOURS, and the three answer different
|
|
3196
|
+
# questions. `claimed` above is the PLAN FILE's human-written annotation
|
|
3197
|
+
# — the contract calls it "a REFLECTION of a claim, not the claim
|
|
3198
|
+
# itself — where the two disagree, git wins", and this is the git side
|
|
3199
|
+
# of exactly that disagreement. `held` is about a WORKTREE on THIS
|
|
3200
|
+
# machine. This one is about a REF on the remote, so it is the only
|
|
3201
|
+
# claim signal that reads the same from every machine: a branch claimed
|
|
3202
|
+
# by a detached worker on another host reports `held: false` here and
|
|
3203
|
+
# `ref_held: true`, which is the population the misread was measured on.
|
|
3204
|
+
#
|
|
3205
|
+
# FREE. `remote_ref_exists` answers from the `REMOTE_REFS` batch this
|
|
3206
|
+
# scan already read to derive `merged` and `wip` — no git spawn, and no
|
|
3207
|
+
# host call, which `Done when` item 5 pins with the existing no-network
|
|
3208
|
+
# tests.
|
|
3209
|
+
#
|
|
3210
|
+
# WHY THE REF AND NOT THE STATE. A consumer can almost infer this from
|
|
3211
|
+
# `state == "wip"`, and one does today; the inference is incomplete in
|
|
3212
|
+
# both directions. `wip` can be OVERRIDDEN to `merged` by a MERGED PR
|
|
3213
|
+
# while the ref still exists (the resurrected-ref arm in `branch_state`),
|
|
3214
|
+
# and a `claimed` branch — a ref carrying nothing but claim commits —
|
|
3215
|
+
# is a ref no `wip` test sees. Both are refs `plot-dispatch.sh` would
|
|
3216
|
+
# refuse to push over. Asking the refs directly cannot drift from them.
|
|
3217
|
+
#
|
|
3218
|
+
# A STATEMENT ABOUT THE REF ALONE. It does not say the work is
|
|
3219
|
+
# unfinished, that a worker lives, or that the branch should be left
|
|
3220
|
+
# alone — a merged branch whose ref survives reports true, because a ref
|
|
3221
|
+
# does hold it. Whether that matters is the CONSUMER's judgement, and
|
|
3222
|
+
# keeping it out of here is what stops this becoming a second, drifting
|
|
3223
|
+
# copy of the state vocabulary. It is never fed into the wave
|
|
3224
|
+
# arithmetic below, which settles waves on `merged` alone.
|
|
3225
|
+
if remote_ref_exists "$br"; then
|
|
3226
|
+
json_branches+=",\"ref_held\":true"
|
|
3227
|
+
else
|
|
3228
|
+
json_branches+=",\"ref_held\":false"
|
|
3229
|
+
fi
|
|
3230
|
+
# From the REFS, not from the worktree table above — a local branch with
|
|
3231
|
+
# no worktree still holds commits nobody can see. 0 wherever this
|
|
3232
|
+
# machine has no local ref, which is what every branch elsewhere reports.
|
|
3233
|
+
json_branches+=",\"local_ahead\":$(local_ahead_of "$br")"
|
|
3234
|
+
# WHEN THIS BRANCH LAST CHANGED — the one signal above that is a
|
|
3235
|
+
# DERIVATIVE rather than a state. `local_dirty` and `local_ahead` both
|
|
3236
|
+
# read identically for a finished branch and an abandoned one; this
|
|
3237
|
+
# tells them apart. See `changed_ago_of` for what it measures and, more
|
|
3238
|
+
# importantly, for what it deliberately does not conclude.
|
|
3239
|
+
#
|
|
3240
|
+
# `null`, NOT 0, WHERE THIS MACHINE CANNOT SEE. The other local signals
|
|
3241
|
+
# have an absent value that is also a real value — `false` and `0` are
|
|
3242
|
+
# what an unobserved branch honestly reports. Seconds have no such
|
|
3243
|
+
# value: 0 means "changed this instant", the most reassuring answer on
|
|
3244
|
+
# the board, and handing it to every branch on somebody else's machine
|
|
3245
|
+
# would be a fabrication pointing the wrong way. `null` is the only
|
|
3246
|
+
# shape that cannot be mistaken for a measurement.
|
|
3247
|
+
changed_pair=$(changed_ago_of "$br") || changed_pair=""
|
|
3248
|
+
changed_ago=$(printf '%s' "$changed_pair" | cut -f1)
|
|
3249
|
+
# The INSTANT, beside the age. A detector must watch this one: the age
|
|
3250
|
+
# ticks with the clock and would flash a quiet row on every pulse.
|
|
3251
|
+
changed_at=$(printf '%s' "$changed_pair" | cut -f2)
|
|
3252
|
+
json_branches+=",\"changed_ago_seconds\":${changed_ago:-null}"
|
|
3253
|
+
json_branches+=",\"changed_at\":${changed_at:-null}"
|
|
3254
|
+
# Whether anything is actually RUNNING on the branch — see `worker_of`.
|
|
3255
|
+
# The pid and the exit code travel as values rather than as something to
|
|
3256
|
+
# re-derive: a pid of 0 has already been rejected here, and re-deriving
|
|
3257
|
+
# liveness on the far side would spring that trap again.
|
|
3258
|
+
worker_row=$(worker_of "$br")
|
|
3259
|
+
json_branches+=",\"worker\":\"$(printf '%s' "$worker_row" | cut -f1)\""
|
|
3260
|
+
json_branches+=",\"worker_pid\":\"$(json_str "$(printf '%s' "$worker_row" | cut -f2)")\""
|
|
3261
|
+
json_branches+=",\"worker_exit\":\"$(json_str "$(printf '%s' "$worker_row" | cut -f3)")\""
|
|
3262
|
+
# WHETHER A RUNNING WORKER'S CHILD IS WORKING — the secondary cue, a
|
|
3263
|
+
# SIBLING FIELD for the same reason `worker_dirty_paths` is one: the
|
|
3264
|
+
# shared classifier's row is a load-bearing three-field tuple, and this
|
|
3265
|
+
# is a fourth fact measured beside it, not woven into it.
|
|
3266
|
+
#
|
|
3267
|
+
# ONLY ON `running`, because only there does it answer anything. The cue
|
|
3268
|
+
# is *which kind of running is this* — a child mid-work vs. a child that
|
|
3269
|
+
# crashed while the loop waits on it — and every other worker state has
|
|
3270
|
+
# its own word already. Beside `finished` or `stalled` a CPU sample says
|
|
3271
|
+
# nothing a reader needs, so it is "" there, the one absent-value shape
|
|
3272
|
+
# every sibling field uses.
|
|
3273
|
+
#
|
|
3274
|
+
# MEASURED FROM THE PID THE ROW ALREADY CARRIES, not re-derived: field 2
|
|
3275
|
+
# of the same row. `plot_worker_activity` samples that pid's whole
|
|
3276
|
+
# descendant tree twice — the shell is near-zero CPU in every case, so
|
|
3277
|
+
# the CHILD's clock is the discriminator.
|
|
3278
|
+
if [ "$(printf '%s' "$worker_row" | cut -f1)" = "running" ]; then
|
|
3279
|
+
json_branches+=",\"worker_activity\":\"$(json_str "$(plot_worker_activity "$(printf '%s' "$worker_row" | cut -f2)")")\""
|
|
3280
|
+
else
|
|
3281
|
+
json_branches+=",\"worker_activity\":\"\""
|
|
3282
|
+
fi
|
|
3283
|
+
# WHAT IS ON THE FLOOR, beside the verdict that named it.
|
|
3284
|
+
#
|
|
3285
|
+
# A COUNT WOULD HAVE BEEN CHEAPER AND IS NOT ENOUGH. `stalled` exists so
|
|
3286
|
+
# a person can decide whether to resume a branch, and "3 uncommitted
|
|
3287
|
+
# files" does not support that decision — three scratch notes and three
|
|
3288
|
+
# half-finished modules read identically. The names make the row
|
|
3289
|
+
# actionable without a second command, which is the only reason to
|
|
3290
|
+
# report it rather than merely count it.
|
|
3291
|
+
#
|
|
3292
|
+
# A SIBLING FIELD, NOT A FOURTH COLUMN on the worker row. Every answer
|
|
3293
|
+
# from the shared classifier carries exactly three tab-separated fields,
|
|
3294
|
+
# and that is load-bearing: POSIX `cut` prints a line UNCHANGED when it
|
|
3295
|
+
# holds no delimiter, so a row of a different width would land a
|
|
3296
|
+
# filename in the exit-code slot with nothing erroring. One computation
|
|
3297
|
+
# (`plot_worker_dirty`), two renderings — the split this whole file
|
|
3298
|
+
# keeps.
|
|
3299
|
+
#
|
|
3300
|
+
# ONLY ON `stalled`, because only there does it answer anything. Beside
|
|
3301
|
+
# `finished` the same list is the leftovers a merged branch happens to
|
|
3302
|
+
# hold, and printing it would invite exactly the reading `stalled` was
|
|
3303
|
+
# added to prevent.
|
|
3304
|
+
if [ "$(printf '%s' "$worker_row" | cut -f1)" = "stalled" ]; then
|
|
3305
|
+
json_branches+=",\"worker_dirty_paths\":$(json_array "$(plot_worker_dirty "$(local_worktree_of "$br" | cut -f1)")")"
|
|
3306
|
+
else
|
|
3307
|
+
# Empty rather than omitted: one absent-value shape for every
|
|
3308
|
+
# consumer, the rule the local signals above already follow.
|
|
3309
|
+
json_branches+=",\"worker_dirty_paths\":[]"
|
|
3310
|
+
fi
|
|
3311
|
+
# WHICH FILES would collide, and whether the question was asked at all.
|
|
3312
|
+
# The two travel together on purpose: an empty list means "merges
|
|
3313
|
+
# cleanly" ONLY beside `conflicts_known: true`, and reading the list
|
|
3314
|
+
# alone turns every unanswerable branch into a mergeable one.
|
|
3315
|
+
#
|
|
3316
|
+
# The SET, never a verdict on it. Whether a given set is the one
|
|
3317
|
+
# mechanically resolvable case is a decision one layer up — this script
|
|
3318
|
+
# collects and reports.
|
|
3319
|
+
cf_known=$(conflicts_known_of "$br" "$st")
|
|
3320
|
+
json_branches+=",\"conflicts_known\":$cf_known"
|
|
3321
|
+
if [ "$cf_known" = "true" ]; then
|
|
3322
|
+
json_branches+=",\"conflicts\":$(json_array "$(conflicts_of "$br")")"
|
|
3323
|
+
# What the branch touches — one of the three lines a CI failure is
|
|
3324
|
+
# reported with. Evidence for a reader, not an input to any rule here.
|
|
3325
|
+
json_branches+=",\"changed_paths\":$(json_array "$(changed_paths_of "$br")")"
|
|
3326
|
+
else
|
|
3327
|
+
# Not looked at, so nothing is claimed. Empty rather than omitted:
|
|
3328
|
+
# one absent-value shape for every consumer, the rule the local
|
|
3329
|
+
# signals above already follow.
|
|
3330
|
+
json_branches+=",\"conflicts\":[],\"changed_paths\":[]"
|
|
3331
|
+
fi
|
|
3332
|
+
json_branches+="}"
|
|
3333
|
+
fi
|
|
3334
|
+
done <<< "$states"
|
|
3335
|
+
|
|
3336
|
+
if [ "$as_json" = 1 ]; then
|
|
3337
|
+
json_waves+="${json_waves:+,}{\"name\":\"$(json_str "$wname")\""
|
|
3338
|
+
json_waves+=",\"verdict\":\"$verdict\",\"branches\":[$json_branches]}"
|
|
3339
|
+
fi
|
|
3340
|
+
|
|
3341
|
+
n_waves=$((n_waves + 1))
|
|
3342
|
+
[ "$verdict" = "complete" ] || prior_ok=0
|
|
3343
|
+
[ "$verdict" = "blocked" ] && n_blocked=$((n_blocked + 1))
|
|
3344
|
+
done
|
|
3345
|
+
if [ "$as_json" = 1 ]; then
|
|
3346
|
+
# ONE composition, two destinations — the property that makes --stream and
|
|
3347
|
+
# --json say the same thing rather than agreeing by inspection. A second
|
|
3348
|
+
# `printf` shaped like this one would be a second implementation of the
|
|
3349
|
+
# plan object, and the first field added to one and not the other is a
|
|
3350
|
+
# streamed board that quietly renders less than a batch one.
|
|
3351
|
+
#
|
|
3352
|
+
# `plan_base` was resolved once where the plan was admitted — in ref mode
|
|
3353
|
+
# from the ref, in worktree mode by readlink. Recomputing it here would
|
|
3354
|
+
# reintroduce a working-tree read on the JSON path only.
|
|
3355
|
+
json_plan="{\"file\":\"$(json_str "$plan_base")\""
|
|
3356
|
+
# The plan's own phase, reported verbatim. The board composes it with each
|
|
3357
|
+
# branch's git state into a row phase; nothing here decides which column
|
|
3358
|
+
# anything reads.
|
|
3359
|
+
json_plan+=",\"phase\":\"$(json_str "$plan_phase")\""
|
|
3360
|
+
json_plan+=",\"waves\":[$json_waves]}"
|
|
3361
|
+
json_plans+="${json_plans:+,}$json_plan"
|
|
3362
|
+
# THE STREAM'S POINT: this plan is fully derived, so a consumer can render
|
|
3363
|
+
# it now rather than when the eighty-fourth branch resolves. Emitted as one
|
|
3364
|
+
# line so a reader can split on newlines without parsing incrementally, and
|
|
3365
|
+
# tagged so the terminal `pulse` line cannot be mistaken for another plan.
|
|
3366
|
+
#
|
|
3367
|
+
# Flushed by `printf` on a line of its own: a consumer reading this stream
|
|
3368
|
+
# is reading it BECAUSE the whole document takes 18 s, so buffering the
|
|
3369
|
+
# lines until exit would give back exactly what the mode exists to remove.
|
|
3370
|
+
if [ "$stream" = 1 ]; then
|
|
3371
|
+
printf '{"kind":"plan","plan":%s}\n' "$json_plan"
|
|
3372
|
+
fi
|
|
3373
|
+
fi
|
|
3374
|
+
[ "$quiet" = 1 ] || echo
|
|
3375
|
+
done
|
|
3376
|
+
|
|
3377
|
+
# --next: name ONE branch a worker may claim, or stay silent with exit 1.
|
|
3378
|
+
# "Nothing to start" is a normal state, not a failure — the exit code is what
|
|
3379
|
+
# distinguishes it from a name, so callers can branch on it without parsing.
|
|
3380
|
+
if [ "$next_only" = 1 ]; then
|
|
3381
|
+
[ ${#claimable[@]} -gt 0 ] || exit 1
|
|
3382
|
+
if [ "$list_all" = 1 ]; then
|
|
3383
|
+
printf '%s\n' "${claimable[@]}"
|
|
3384
|
+
else
|
|
3385
|
+
printf '%s\n' "${claimable[0]}"
|
|
3386
|
+
fi
|
|
3387
|
+
exit 0
|
|
3388
|
+
fi
|
|
3389
|
+
|
|
3390
|
+
# --log-pulse: append ONE line per plan, clean pulses included. Without a
|
|
3391
|
+
# record of quiet pulses an idle fleet and a dead fleet are indistinguishable.
|
|
3392
|
+
# This is a LOG, not state: deleting it changes no behaviour, because the next
|
|
3393
|
+
# pulse re-derives everything from git.
|
|
3394
|
+
if [ "$log_pulse" = 1 ]; then
|
|
3395
|
+
stamp=$(date -u +%Y-%m-%dT%H:%MZ)
|
|
3396
|
+
line="<!-- pulse: $stamp — waves=$n_waves eligible=$n_eligible claimed=$n_claimed blocked=$n_blocked deferred=$n_deferred -->"
|
|
3397
|
+
for pf in ${plan_files[@]+"${plan_files[@]}"}; do
|
|
3398
|
+
real=$(cd "$(dirname "$pf")" && readlink "$(basename "$pf")" 2>/dev/null || true)
|
|
3399
|
+
target=$([ -n "$real" ] && echo "$(dirname "$pf")/$real" || echo "$pf")
|
|
3400
|
+
[ -f "$target" ] || continue
|
|
3401
|
+
if grep -q '^## Notes' "$target" 2>/dev/null; then
|
|
3402
|
+
awk -v ln="$line" '
|
|
3403
|
+
/^## Notes/ && !done { print; print ""; print ln; done=1; next }
|
|
3404
|
+
{ print }
|
|
3405
|
+
' "$target" > "$target.tmp" && mv "$target.tmp" "$target"
|
|
3406
|
+
else
|
|
3407
|
+
printf '\n## Notes\n\n%s\n' "$line" >> "$target"
|
|
3408
|
+
fi
|
|
3409
|
+
done
|
|
3410
|
+
fi
|
|
3411
|
+
|
|
3412
|
+
# --json: the same derivation as the prose above, rendered for machines. It is
|
|
3413
|
+
# an OUTPUT MODE and nothing more — it composes with --offline/--no-fetch/
|
|
3414
|
+
# --loose rather than implying any of them, so the board's data depends on what
|
|
3415
|
+
# it asked for, not on how it asked. --next wins over it (handled above): that
|
|
3416
|
+
# is a different question with a one-line answer.
|
|
3417
|
+
if [ "$as_json" = 1 ]; then
|
|
3418
|
+
# --stream wraps the SAME document in one tagged line rather than emitting a
|
|
3419
|
+
# second, smaller one. The terminal object is what proves the scan finished:
|
|
3420
|
+
# a consumer that has seen `plan` lines and no `pulse` line has a PARTIAL
|
|
3421
|
+
# answer and must say so — which is the whole distinction this mode adds, and
|
|
3422
|
+
# the reason the end is marked rather than inferred from the pipe closing.
|
|
3423
|
+
# A killed scan closes the pipe too.
|
|
3424
|
+
[ "$stream" = 1 ] && printf '{"kind":"pulse","pulse":'
|
|
3425
|
+
# `read_ref` is the ref this document was derived from; `local_head` is the
|
|
3426
|
+
# checkout it was derived ON. A consumer needs both to tell "the board is
|
|
3427
|
+
# current" from "the board is current about an old world".
|
|
3428
|
+
#
|
|
3429
|
+
# `head` repeats `local_head` as an alias for one release. The board reads it
|
|
3430
|
+
# today; it goes away once the board reads the pair.
|
|
3431
|
+
printf '{"main":"%s","read_ref":"%s","local_head":"%s","head":"%s",' \
|
|
3432
|
+
"$(json_str "$MAIN")" "$(json_str "$READ_REF")" "$(json_str "$LOCAL_HEAD")" \
|
|
3433
|
+
"$(json_str "$HEAD_SHORT")"
|
|
3434
|
+
# Three more facts about the EVIDENCE, not about the fleet — a consumer that
|
|
3435
|
+
# renders the numbers below should be able to say how much to trust them.
|
|
3436
|
+
# They answer the question `read_ref` raises: that field names the ref, and
|
|
3437
|
+
# these say whether reading it succeeded and whether the plans came from it.
|
|
3438
|
+
#
|
|
3439
|
+
# `fetch_failed` used to be discarded by `2>/dev/null`, so refs an hour old
|
|
3440
|
+
# were reported with the confidence of refs a second old. `plan_source` says
|
|
3441
|
+
# whether the plan list came from the ref or fell back to this checkout.
|
|
3442
|
+
printf '"fetch_failed":%s,"fetch_error":"%s","plan_source":"%s","plans":[%s],' \
|
|
3443
|
+
"$([ "$FETCH_FAILED" = 1 ] && echo true || echo false)" \
|
|
3444
|
+
"$(json_str "$FETCH_ERROR")" "$(json_str "$PLAN_SOURCE")" "$json_plans"
|
|
3445
|
+
printf '"summary":{"plans":%d,"waves":%d,"branches":%d,"claimed":%d,' \
|
|
3446
|
+
"$n_plans" "$n_waves" "$n_branches" "$n_claimed"
|
|
3447
|
+
printf '"eligible":%d,"blocked":%d,"deferred":%d,"merge_detect":"%s"}}' \
|
|
3448
|
+
"$n_eligible" "$n_blocked" "$n_deferred" "$MERGE_DETECT"
|
|
3449
|
+
[ "$stream" = 1 ] && printf '}'
|
|
3450
|
+
printf '\n'
|
|
3451
|
+
exit 0
|
|
3452
|
+
fi
|
|
3453
|
+
|
|
3454
|
+
# A saturated merge walk is STATED, never silent. A branch merged before the
|
|
3455
|
+
# cap reads `open`, which is acceptable only while the scan says it stopped
|
|
3456
|
+
# looking — a silent cap would make the report lie in the one direction this
|
|
3457
|
+
# check was written to stop.
|
|
3458
|
+
if [ "$MERGE_SCAN_TRUNCATED" = 1 ]; then
|
|
3459
|
+
echo " note: merge scan hit its limit of $MERGE_SCAN_LIMIT — older merges were not"
|
|
3460
|
+
echo " examined; a branch merged before that point may still read as open."
|
|
3461
|
+
fi
|
|
3462
|
+
# A STALE PULSE SAYS SO. The fetch used to fail silently, which made a scan of
|
|
3463
|
+
# hour-old refs read exactly like a scan of current ones — the same
|
|
3464
|
+
# over-confidence, one layer up, that this plan fixes in the plan list.
|
|
3465
|
+
if [ "$FETCH_FAILED" = 1 ]; then
|
|
3466
|
+
echo " note: git fetch failed — these refs are as current as your last"
|
|
3467
|
+
echo " successful fetch, not as current as origin/$MAIN."
|
|
3468
|
+
echo " $FETCH_ERROR"
|
|
3469
|
+
# A failed fetch also failed to PRUNE, and that has a sharper consequence
|
|
3470
|
+
# than staleness alone: an unpruned ref sends branch_state() down the
|
|
3471
|
+
# ancestry arm, where a squash merge reads `wip`. Said plainly, because the
|
|
3472
|
+
# symptom — a finished wave that will not complete — looks nothing like
|
|
3473
|
+
# "your fetch failed".
|
|
3474
|
+
echo " Stale remote-tracking refs were not pruned either, so a branch"
|
|
3475
|
+
echo " merged and deleted upstream may still read wip."
|
|
3476
|
+
fi
|
|
3477
|
+
# AN OFFLINE SCAN CANNOT PRUNE, and the answer that costs is not obvious.
|
|
3478
|
+
# --offline skips the fetch, so refs for branches deleted upstream survive, and
|
|
3479
|
+
# a surviving ref is what makes a squash-merged branch read `wip` and its wave
|
|
3480
|
+
# read blocked. Reporting the flag alone would leave the operator to derive
|
|
3481
|
+
# that; this states the consequence instead. Only under the prose report — the
|
|
3482
|
+
# machine renderings carry `fetch_failed` and the caller passed --offline
|
|
3483
|
+
# itself, so neither is being told something it does not know.
|
|
3484
|
+
if [ "$do_fetch" = 0 ]; then
|
|
3485
|
+
echo " note: --offline skipped the fetch, so stale remote-tracking refs were"
|
|
3486
|
+
echo " not pruned; a branch merged and deleted upstream may read wip"
|
|
3487
|
+
echo " and hold its wave blocked. Re-run without --offline to settle it."
|
|
3488
|
+
fi
|
|
3489
|
+
# The fallback announces itself. Silent degradation here would recreate the
|
|
3490
|
+
# very bug being fixed: a working-tree plan list reported as if it were the ref.
|
|
3491
|
+
if [ "$PLAN_SOURCE" != "ref" ]; then
|
|
3492
|
+
echo " note: origin/$MAIN could not be read — plans were listed from this"
|
|
3493
|
+
echo " checkout instead, so the list is only as current as your last pull."
|
|
3494
|
+
fi
|
|
3495
|
+
echo "Pulse complete. This report is derived — nothing was changed."
|
|
3496
|
+
echo "summary: plans=$n_plans waves=$n_waves branches=$n_branches claimed=$n_claimed eligible=$n_eligible blocked=$n_blocked deferred=$n_deferred merge_detect=$MERGE_DETECT main=$MAIN"
|