@plot-pm/board 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/board-server.mjs +101 -101
- package/package.json +5 -1
- package/plot-agent-monitor.sh +37 -13
- package/plot-approve.sh +65 -30
- package/plot-build-monitor.sh +435 -0
- package/plot-config.sh +8 -1
- package/plot-default-branch.sh +109 -0
- package/plot-deliver.sh +47 -27
- package/plot-dispatch.sh +546 -10
- package/plot-fleet-scan.sh +498 -175
- package/plot-host.sh +255 -26
- package/plot-monitor-subject.sh +194 -0
- package/plot-plan-meta.sh +111 -7
- package/plot-pr-merged.sh +180 -0
- package/plot-reap.sh +45 -5
- package/plot-release-refs.sh +188 -46
- package/plot-resolve-artifact.sh +115 -22
- package/plot-worker-state.sh +94 -0
package/plot-fleet-scan.sh
CHANGED
|
@@ -177,12 +177,29 @@
|
|
|
177
177
|
# crashed pulse costs nothing — the next pulse re-derives the truth. Nothing
|
|
178
178
|
# here creates a branch, pushes a ref, or starts a worker.
|
|
179
179
|
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
180
|
+
# TWO exceptions to "writes nothing", and NEITHER IS STATE. The test both pass
|
|
181
|
+
# is the same one: delete what they wrote and no behaviour changes, because the
|
|
182
|
+
# next run re-derives everything.
|
|
183
|
+
#
|
|
184
|
+
# 1. --log-pulse appends a pulse line to each reported plan (see below). That is
|
|
185
|
+
# a LOG. The flag defaults OFF precisely so internal callers (plot-implement,
|
|
186
|
+
# plot-dispatch, which invoke --next) can never amend a plan as a side effect
|
|
187
|
+
# of asking what to work on; /plot-fleet, the human-facing command, passes it
|
|
188
|
+
# every run.
|
|
189
|
+
#
|
|
190
|
+
# 2. `.plot/state/last-pulse.json` — the bridge, written by `write_bridge` on
|
|
191
|
+
# the success path of `--stream` (what the board spawns) and `--log-pulse`
|
|
192
|
+
# (what /plot-pulse passes). Those are the two callers that produce a pulse
|
|
193
|
+
# for somebody to READ; plain `--json` is a query and records nothing. That
|
|
194
|
+
# is a CACHE WITH AN EXPIRY: `pulse-bridge.ts` discards it after 15 minutes
|
|
195
|
+
# and on a version mismatch, so it can only ever be a shortcut to an answer
|
|
196
|
+
# this script re-derives anyway. It is machine-local and gitignored.
|
|
197
|
+
#
|
|
198
|
+
# Added 2026-09-06. The board wrote this file and the scan did not, so a
|
|
199
|
+
# repository with no board had nothing to diff against and every pulse read
|
|
200
|
+
# as the first one — while `DESIGN-process.md` §1 requires the fleet to work
|
|
201
|
+
# with no board at all. The component that PRODUCES a pulse is the one that
|
|
202
|
+
# records it.
|
|
186
203
|
#
|
|
187
204
|
# Wave eligibility (the one rule this script encodes):
|
|
188
205
|
# A wave is ELIGIBLE when every non-deferred branch in every PRIOR wave is
|
|
@@ -217,13 +234,30 @@ why_nothing=0
|
|
|
217
234
|
loose=0
|
|
218
235
|
log_pulse=0
|
|
219
236
|
as_json=0
|
|
237
|
+
# Whether the JSON document is ASSEMBLED, which is not the same question as
|
|
238
|
+
# whether it is PRINTED. `--json`/`--stream` need it to print; `--log-pulse`
|
|
239
|
+
# needs it to write the bridge and prints prose. Measured 2026-09-06 on this
|
|
240
|
+
# estate: assembling it costs 14.7 s against 5.9 s for prose alone, because the
|
|
241
|
+
# branch objects carry `merge-tree` conflict sets. So the two meanings are
|
|
242
|
+
# separated rather than folded, and a plain `/plot-fleet-scan.sh` pays neither.
|
|
243
|
+
build_doc=0
|
|
244
|
+
# Whether this run RECORDS the pulse it produced — `.plot/state/last-pulse.json`.
|
|
245
|
+
# A third question again: `--json` assembles a document and records nothing,
|
|
246
|
+
# because it is a query. Only the two callers that produce a pulse for somebody
|
|
247
|
+
# to read set this — `--stream` (the board) and `--log-pulse` (/plot-pulse).
|
|
248
|
+
record=0
|
|
220
249
|
stream=0
|
|
221
250
|
slug=""
|
|
222
251
|
while [ $# -gt 0 ]; do
|
|
223
252
|
case "$1" in
|
|
224
253
|
--no-fetch|--offline) do_fetch=0 ;;
|
|
225
254
|
--loose) loose=1 ;;
|
|
226
|
-
|
|
255
|
+
# `--log-pulse` ALSO ASSEMBLES THE DOCUMENT, because this is the flag that
|
|
256
|
+
# means *this pulse records itself*. It already appends a line to each plan;
|
|
257
|
+
# it now also writes `.plot/state/last-pulse.json`, so `/plot-pulse` in a
|
|
258
|
+
# repository with no board accumulates the history a delta needs. Without
|
|
259
|
+
# that, every pulse on a boardless repo is a first one forever.
|
|
260
|
+
--log-pulse) log_pulse=1; build_doc=1; record=1 ;;
|
|
227
261
|
--next) next_only=1 ;;
|
|
228
262
|
--list-eligible) next_only=1; list_all=1 ;;
|
|
229
263
|
# THE SECOND QUESTION, and it borrows `--next`'s population deliberately.
|
|
@@ -231,8 +265,17 @@ while [ $# -gt 0 ]; do
|
|
|
231
265
|
# about the SAME plans `--next` was silent over — a terminal plan admitted
|
|
232
266
|
# here would answer `not-yet` about work somebody decided was not needed.
|
|
233
267
|
--why-nothing) next_only=1; why_nothing=1 ;;
|
|
234
|
-
|
|
235
|
-
|
|
268
|
+
# `--json` ASSEMBLES BUT DOES NOT RECORD, and the two flags differ here for
|
|
269
|
+
# a reason. `--stream` is what the BOARD spawns (`fleet.ts:2694`) and
|
|
270
|
+
# `--log-pulse` is what `/plot-pulse` passes: both produce a pulse somebody
|
|
271
|
+
# is reading, so both record one. Plain `--json` is a machine-readable
|
|
272
|
+
# QUERY — `--next`'s neighbours ask it to find out what to work on — and a
|
|
273
|
+
# query that left a file behind would make the scan write on a path no
|
|
274
|
+
# caller asked to record. Two reconcile tests assert exactly that and
|
|
275
|
+
# caught this: `conflicts: the scan writes NOTHING` and `fleet: scan is
|
|
276
|
+
# read-only`, both of which drive `--json`.
|
|
277
|
+
--json) as_json=1; build_doc=1 ;;
|
|
278
|
+
--stream) as_json=1; stream=1; build_doc=1; record=1 ;;
|
|
236
279
|
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
|
|
237
280
|
*) slug="$1" ;;
|
|
238
281
|
esac
|
|
@@ -248,11 +291,13 @@ PREFIX_RE=$(cfg "Branch prefixes" "idea/, feature/, bug/, docs/, infra/" \
|
|
|
248
291
|
| tr -d ' ' | tr ',' '\n' | sed 's#/$##' | grep -v '^$' | paste -sd'|' -)
|
|
249
292
|
[ -n "$PREFIX_RE" ] || PREFIX_RE="idea|feature|bug|docs|infra"
|
|
250
293
|
|
|
294
|
+
# `default_branch` repairs an unresolvable origin/HEAD before answering. The
|
|
295
|
+
# scan derives every branch's state from `origin/<main>`, so a symref naming a
|
|
296
|
+
# branch that does not exist makes every one of them unreadable at once.
|
|
297
|
+
# shellcheck source=plot-default-branch.sh
|
|
298
|
+
. "$script_dir/plot-default-branch.sh"
|
|
251
299
|
MAIN=$(cfg "Main branch")
|
|
252
|
-
|
|
253
|
-
MAIN=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
|
|
254
|
-
fi
|
|
255
|
-
[ -n "$MAIN" ] || MAIN="main"
|
|
300
|
+
[ -n "$MAIN" ] || MAIN=$(default_branch)
|
|
256
301
|
|
|
257
302
|
# A FAILED FETCH IS A FACT, not a shrug. The old line was
|
|
258
303
|
# `git fetch ... 2>/dev/null` with its status discarded: a GitHub 503, a
|
|
@@ -1044,8 +1089,16 @@ terminal_learn() { # $1=branch $2=state
|
|
|
1044
1089
|
# branch that is live — in flight, claimed, or with work on the floor — never
|
|
1045
1090
|
# arrives here and therefore cannot be cached however the cache is filled. The
|
|
1046
1091
|
# invariant is structural rather than a check that could be forgotten.
|
|
1092
|
+
#
|
|
1093
|
+
# SETS `_merged_by_host_state` TO THE WORD IT DECIDED ON, and that is what the
|
|
1094
|
+
# readings carry. The boolean is still the answer to this function's own
|
|
1095
|
+
# question; the word is what `branch_readings` reports, because the rule needs
|
|
1096
|
+
# to tell `CLOSED` from `NONE` from `-` and a yes/no cannot. Set before every
|
|
1097
|
+
# return, so a caller reading it never sees the previous branch's answer.
|
|
1098
|
+
_merged_by_host_state='-'
|
|
1047
1099
|
merged_by_host() { # $1=branch → 0 when the host reports its PR MERGED
|
|
1048
1100
|
local st
|
|
1101
|
+
_merged_by_host_state='-'
|
|
1049
1102
|
# Git has already been consulted to get here (no ref) and `terminal_cached`
|
|
1050
1103
|
# asks it again about the plan and the tip. Only the round trip is skipped.
|
|
1051
1104
|
if st=$(terminal_cached "$1"); then
|
|
@@ -1058,11 +1111,13 @@ merged_by_host() { # $1=branch → 0 when the host reports its PR MERGED
|
|
|
1058
1111
|
# It is re-derived, not merely echoed: reaching here means git was asked
|
|
1059
1112
|
# again this pass and still agrees — no ref, same plan, same tip.
|
|
1060
1113
|
terminal_learn "$1" "$st"
|
|
1114
|
+
_merged_by_host_state="$st"
|
|
1061
1115
|
[ "$st" = "MERGED" ]
|
|
1062
1116
|
return
|
|
1063
1117
|
fi
|
|
1064
1118
|
st=$(host_pr_state "$1" --ask)
|
|
1065
1119
|
terminal_learn "$1" "$st"
|
|
1120
|
+
_merged_by_host_state="$st"
|
|
1066
1121
|
[ "$st" = "MERGED" ]
|
|
1067
1122
|
}
|
|
1068
1123
|
|
|
@@ -1114,33 +1169,19 @@ reached_review() { # $1=branch → 0 when an open or merged PR exists
|
|
|
1114
1169
|
# the branch it was cut from, which is why `plot-pr-merged.sh` reads PRs and not
|
|
1115
1170
|
# refs, and why this reads the same source.
|
|
1116
1171
|
#
|
|
1117
|
-
#
|
|
1118
|
-
#
|
|
1119
|
-
#
|
|
1120
|
-
#
|
|
1121
|
-
#
|
|
1122
|
-
#
|
|
1123
|
-
#
|
|
1124
|
-
#
|
|
1125
|
-
#
|
|
1126
|
-
#
|
|
1127
|
-
#
|
|
1128
|
-
#
|
|
1129
|
-
|
|
1130
|
-
# the branch, so nothing is misspelled — somebody withdrew the work, and that
|
|
1131
|
-
# resolves by reopening it, not by editing the plan.
|
|
1132
|
-
waits_state() { # $1=prerequisite branch → "waiting" | "blocked" | ""
|
|
1133
|
-
local st
|
|
1134
|
-
# `--ask` because the prerequisite is precisely the branch the repo-wide list
|
|
1135
|
-
# may legitimately omit: its plan may be delivered and its ref gone. The
|
|
1136
|
-
# bound is the same one PR #216 set — ABSENT branches, not all branches — and
|
|
1137
|
-
# the cache above keeps it at one call per run.
|
|
1138
|
-
st=$(host_pr_state "$1" --ask)
|
|
1139
|
-
case "$st" in
|
|
1140
|
-
MERGED) printf '' ;;
|
|
1141
|
-
NONE) printf 'blocked' ;;
|
|
1142
|
-
*) printf 'waiting' ;;
|
|
1143
|
-
esac
|
|
1172
|
+
# WHAT THE HOST SAID, AND NOT WHAT IT MEANS. This function answered
|
|
1173
|
+
# `waiting` / `blocked` / `""` until the derivation moved: the three answers and
|
|
1174
|
+
# the reason `NONE` is the only one that means `blocked` are `waitVerdict` in
|
|
1175
|
+
# `packages/domain/src/rules/branch-state.ts`, with a test per case. What stays
|
|
1176
|
+
# here is the READING and the cost argument above it, which is a fact about
|
|
1177
|
+
# this script's host budget rather than about what a wait means.
|
|
1178
|
+
#
|
|
1179
|
+
# `--ask` because the prerequisite is precisely the branch the repo-wide list
|
|
1180
|
+
# may legitimately omit: its plan may be delivered and its ref gone. The bound
|
|
1181
|
+
# is the same one PR #216 set — ABSENT branches, not all branches — and the
|
|
1182
|
+
# cache above keeps it at one call per run.
|
|
1183
|
+
waits_pr_state() { # $1=prerequisite branch → OPEN|MERGED|CLOSED|NONE|-
|
|
1184
|
+
host_pr_state "$1" --ask
|
|
1144
1185
|
}
|
|
1145
1186
|
|
|
1146
1187
|
# Modification time of a path, in epoch seconds, following symlinks — or "" when
|
|
@@ -3004,8 +3045,39 @@ EOF
|
|
|
3004
3045
|
echo "$total $n"
|
|
3005
3046
|
}
|
|
3006
3047
|
|
|
3007
|
-
|
|
3008
|
-
|
|
3048
|
+
# WHAT WAS READ OF ONE BRANCH — ten tab-separated fields, and no decision.
|
|
3049
|
+
#
|
|
3050
|
+
# `branch_state()` UNTIL THIS SLICE, and every line of git archaeology below is
|
|
3051
|
+
# its own, unchanged. What went is the `if` chain that merged these readings
|
|
3052
|
+
# into a state word: that lives in `@plot-pm/domain`'s `branchState`, which the
|
|
3053
|
+
# caller asks once per plan through `plot-branch-state.mjs`. The script gathers;
|
|
3054
|
+
# the rule decides.
|
|
3055
|
+
#
|
|
3056
|
+
# THE REF CHECK STAYS IN FRONT, and it still does its job here. It is no longer
|
|
3057
|
+
# a `return`, so the ordering is preserved a different way: `mergeSubjectFound`
|
|
3058
|
+
# is READ ONLY where there is no ref, and reported `false` otherwise. See the
|
|
3059
|
+
# comment on that reading below — it is the same argument the returns carried.
|
|
3060
|
+
#
|
|
3061
|
+
# `-` IS THE ABSENT MARKER, per the field-order rule the caller documents: a
|
|
3062
|
+
# run of tabs collapses into one separator under `read`, so no field is ever
|
|
3063
|
+
# empty. Nothing here is optional, so nothing can shift.
|
|
3064
|
+
#
|
|
3065
|
+
# EIGHT FIELDS, NOT TEN. The two the plan states — the prerequisite's name and
|
|
3066
|
+
# what the host said about it — are appended by the caller, because reading the
|
|
3067
|
+
# second costs a host round trip and the scan spends it only where it could
|
|
3068
|
+
# change the answer. The rule reports which states those are; see the caller.
|
|
3069
|
+
#
|
|
3070
|
+
# THE DEFAULT BRANCH'S TIP IS READ ONCE PER RUN, not once per branch. It does
|
|
3071
|
+
# not move while the scan runs — every fact below is derived from the ref batch
|
|
3072
|
+
# taken at the start — and `remote_ref_oid` forks an `awk`, so asking per branch
|
|
3073
|
+
# would put one process per branch back on the 5 s pulse path. That is the
|
|
3074
|
+
# per-branch tail this script has repeatedly been thinned to remove.
|
|
3075
|
+
MAIN_TIP=$(remote_ref_oid "$MAIN")
|
|
3076
|
+
[ -n "$MAIN_TIP" ] || MAIN_TIP="-"
|
|
3077
|
+
|
|
3078
|
+
branch_readings() { # $1=branch $2=deferred → eight tab-separated readings
|
|
3079
|
+
local br="$1" _bs_deferred="$2" _bs_subject=false _bs_ahead=0 _bs_real=0 _bs_tip
|
|
3080
|
+
local _bs_main="$MAIN_TIP"
|
|
3009
3081
|
# THE REF CHECK STAYS IN FRONT. DO NOT HOIST THE MERGE LOOKUP ABOVE IT.
|
|
3010
3082
|
#
|
|
3011
3083
|
# A branch name can be reused: merge `bug/flaky`, delete it, then recreate it
|
|
@@ -3016,9 +3088,16 @@ branch_state() {
|
|
|
3016
3088
|
#
|
|
3017
3089
|
# The merge lookup is safe only BY PLACEMENT — it lives in the no-ref arm,
|
|
3018
3090
|
# and a recreated branch has a ref, so it never reaches the lookup and takes
|
|
3019
|
-
# the ancestry path below instead. Moving the lookup
|
|
3020
|
-
# cheap early answer and would silently report in-flight work as
|
|
3021
|
-
# opening the next wave on it. A test in fleet.test.mjs pins this
|
|
3091
|
+
# the ancestry path below instead. Moving the lookup out of this `if` reads
|
|
3092
|
+
# like a cheap early answer and would silently report in-flight work as
|
|
3093
|
+
# `merged`, opening the next wave on it. A test in fleet.test.mjs pins this
|
|
3094
|
+
# ordering, and `branch-state.test.ts` pins what the rule makes of it.
|
|
3095
|
+
#
|
|
3096
|
+
# THE PLACEMENT IS NOW A READING RATHER THAN A RETURN, and it holds the same
|
|
3097
|
+
# way: a branch WITH a ref reports `mergeSubjectFound=false` whatever main
|
|
3098
|
+
# says about its name, so the stale subject never reaches the rule at all.
|
|
3099
|
+
# The rule's own comment says it may not be consulted before the ref check;
|
|
3100
|
+
# this is the half of that contract the caller owes.
|
|
3022
3101
|
if ! remote_ref_exists "$br"; then
|
|
3023
3102
|
# No ref carries two meanings and this used to answer `open` for both: a
|
|
3024
3103
|
# branch never started, and a branch merged with its ref deleted at merge.
|
|
@@ -3028,15 +3107,34 @@ branch_state() {
|
|
|
3028
3107
|
# `merged` is already the state that settles a wave, so the arithmetic does
|
|
3029
3108
|
# not change and no new state enters the vocabulary. Where no evidence
|
|
3030
3109
|
# exists — squash merges, a hand-rewritten subject, a branch genuinely
|
|
3031
|
-
# never started — today's `open` stands. The
|
|
3032
|
-
# from `open` to `merged`, and only
|
|
3033
|
-
merged_by_subject "$br" &&
|
|
3110
|
+
# never started — today's `open` stands. The evidence may only move a branch
|
|
3111
|
+
# from `open` to `merged`, and only when it is positive.
|
|
3112
|
+
merged_by_subject "$br" && _bs_subject=true
|
|
3034
3113
|
# No merge commit names it — which is the ordinary case under a squash
|
|
3035
3114
|
# merge, not an exotic one. The local walk is now out of evidence, so the
|
|
3036
3115
|
# host is asked. It may only ever move this branch from `open` to `merged`:
|
|
3037
|
-
# a miss, a CLOSED PR, or a host that cannot answer all
|
|
3038
|
-
#
|
|
3039
|
-
|
|
3116
|
+
# a miss, a CLOSED PR, or a host that cannot answer all leave the reading
|
|
3117
|
+
# as it was, exactly as before this call existed.
|
|
3118
|
+
#
|
|
3119
|
+
# ASKED ONLY HERE, and that bound is the whole of PR #216: this arm is
|
|
3120
|
+
# reached only for a branch with NO REF, so the per-branch host cost is
|
|
3121
|
+
# bounded by ABSENT branches rather than by all of them. Hoisting the call
|
|
3122
|
+
# out of this `if` to "gather uniformly" would put 22 round trips back into
|
|
3123
|
+
# every scan on this repo. The reading is `-` for every branch that has a
|
|
3124
|
+
# ref, and the rule never reaches its PR arm for one that does.
|
|
3125
|
+
#
|
|
3126
|
+
# THE TERMINAL CACHE WRAPS IT, and stays here rather than moving inward.
|
|
3127
|
+
# The cache is about how OFTEN a question is asked; the rule is about what
|
|
3128
|
+
# the answer MEANS. `merged_by_host` consults it, so a terminal branch is
|
|
3129
|
+
# asked once and its answer is reused across pulses — 26 of 54 branches on
|
|
3130
|
+
# this estate, measured 2026-08-19.
|
|
3131
|
+
#
|
|
3132
|
+
# THE WORD, NOT THE BOOLEAN. `merged_by_host` answers its own yes/no and
|
|
3133
|
+
# leaves the state word it decided on in `_merged_by_host_state`, which is
|
|
3134
|
+
# what travels: the rule tells `CLOSED` from `NONE` from `-`, and a boolean
|
|
3135
|
+
# cannot. `|| true` because a not-merged answer is an ordinary reading and
|
|
3136
|
+
# `set -e` must not read it as a failure.
|
|
3137
|
+
merged_by_host "$br" || true
|
|
3040
3138
|
# `open` IS A CLAIM ABOUT A PR: that one was looked for and none was found.
|
|
3041
3139
|
# With no ref, the host is the only remaining source, so when it could not
|
|
3042
3140
|
# be asked that claim was never earned — and the branch measured on
|
|
@@ -3064,11 +3162,14 @@ branch_state() {
|
|
|
3064
3162
|
# `secondary` GATES LIKE THE OTHER TWO, and its faster recovery is no reason
|
|
3065
3163
|
# to exempt it: the question was PUT and went unanswered, so this scan has
|
|
3066
3164
|
# no more evidence than a throttled one does. What the two limits differ in
|
|
3067
|
-
# is what to DO about it, which is the note
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3165
|
+
# is what to DO about it, which is the note above and not this reading.
|
|
3166
|
+
#
|
|
3167
|
+
# THE THREE WORDS TRAVEL AS THEMSELVES. `HOST_VERDICT` is reported rather
|
|
3168
|
+
# than collapsed into a boolean, so the rule keeps `unasked` apart from
|
|
3169
|
+
# `failed` — the distinction the whole readings shape exists for.
|
|
3170
|
+
printf '%s\t-\t%s\t%s\t%s\t%s\t0\t0' \
|
|
3171
|
+
"$_bs_deferred" "$_bs_main" "$_bs_subject" "$HOST_VERDICT" "$_merged_by_host_state"
|
|
3172
|
+
return
|
|
3072
3173
|
fi
|
|
3073
3174
|
# A CLAIM is a branch whose only commits beyond main are claim commits —
|
|
3074
3175
|
# empty markers a dispatcher pushed to take the work. They must be real
|
|
@@ -3080,63 +3181,58 @@ branch_state() {
|
|
|
3080
3181
|
# already computing and discarding, at one extra spawn per branch.
|
|
3081
3182
|
local _bs_counts
|
|
3082
3183
|
_bs_counts=$(real_commits_beyond_main "$br")
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
# says — and `CLOSED` or `NONE` are not evidence that anything landed.
|
|
3136
|
-
if [ "$(host_pr_state "$br")" = MERGED ]; then echo "merged"; return; fi
|
|
3137
|
-
echo "wip"; return
|
|
3138
|
-
fi
|
|
3139
|
-
# Nothing of its own. NOT a claim: that shape is indistinguishable from
|
|
3184
|
+
_bs_ahead=${_bs_counts%% *}
|
|
3185
|
+
_bs_real=${_bs_counts##* }
|
|
3186
|
+
# Real work that main does not yet contain is `wip`, and ONLY `wip` — the
|
|
3187
|
+
# rule says so; this reading is what lets it.
|
|
3188
|
+
#
|
|
3189
|
+
# This arm once asked `merge-base --is-ancestor origin/$br origin/$MAIN`
|
|
3190
|
+
# here — "has the work already landed?" — and returned `merged` when it did.
|
|
3191
|
+
# That question was already answered by the `ahead` count above it and could
|
|
3192
|
+
# never fire: `ahead > 0` means `$br` carries at least one commit unreachable
|
|
3193
|
+
# from `$MAIN`, and a branch with such a commit CANNOT be an ancestor of
|
|
3194
|
+
# `$MAIN`, so `--is-ancestor` was false on every branch that reached it. It
|
|
3195
|
+
# was one git spawn per `wip` branch spent to re-derive a fact already in
|
|
3196
|
+
# hand — the per-branch tail this plan set out to thin — and its `merged`
|
|
3197
|
+
# was dead code that changed no verdict.
|
|
3198
|
+
#
|
|
3199
|
+
# The landed-work case is not lost; it is answered by the TIP COMPARISON. A
|
|
3200
|
+
# branch whose commits are all in `$MAIN` counts `ahead = 0`, and a merge that
|
|
3201
|
+
# deleted the ref takes the no-ref arm above. If a future change makes `ahead`
|
|
3202
|
+
# something other than "commits `$MAIN` lacks", THAT is the invariant that
|
|
3203
|
+
# would break — the ancestry must move back, not be missed.
|
|
3204
|
+
#
|
|
3205
|
+
# A RESURRECTED REF BREAKS THE PREMISE ABOVE, and the host is what closes it.
|
|
3206
|
+
# The reasoning "a merge that deleted the ref never reaches here" holds only
|
|
3207
|
+
# while the ref STAYS deleted. `delete_branch_on_merge` is on, so the host
|
|
3208
|
+
# removes it — and a worktree that still holds the branch can push it back
|
|
3209
|
+
# afterwards, which a fleet does routinely. The ref then exists again while
|
|
3210
|
+
# the work is on `$MAIN` under a DIFFERENT commit, because a squash merge
|
|
3211
|
+
# rewrites it: `ahead > 0`, `real > 0`, and the walk alone would call finished
|
|
3212
|
+
# work `wip`.
|
|
3213
|
+
#
|
|
3214
|
+
# Measured 2026-08-23: `bug/done-holds-finished-plans-only`, PR #356 merged,
|
|
3215
|
+
# read `wip` for three hours. Its wave reported "3 merged, the rest not yet"
|
|
3216
|
+
# over four merged branches and never completed, so the plan sat in
|
|
3217
|
+
# Development with nothing left to do.
|
|
3218
|
+
#
|
|
3219
|
+
# `wip` is the WORST of the wrong answers, which is why this earns a reading
|
|
3220
|
+
# rather than a note: it means *an agent is working here*, so a leftover
|
|
3221
|
+
# worktree reads as an occupied desk and the row asks a reader to wait for
|
|
3222
|
+
# something that finished.
|
|
3223
|
+
#
|
|
3224
|
+
# FREE, and that is what licenses reading it for EVERY branch with a ref. The
|
|
3225
|
+
# state comes from the cache `prefill_pr_states` already filled from ONE
|
|
3226
|
+
# repo-wide `pr-list`, so this adds no host call — no `--ask` here, which is
|
|
3227
|
+
# what keeps the 22 round trips out. Where the list did not arrive the cache
|
|
3228
|
+
# is empty, `host_pr_state` answers `-`, and the local walk decides exactly as
|
|
3229
|
+
# it does today.
|
|
3230
|
+
#
|
|
3231
|
+
# ONLY `MERGED` MAY OVERRIDE the walk, and only toward `merged` — the rule's
|
|
3232
|
+
# business, not this function's. `OPEN` means a PR exists for work still in
|
|
3233
|
+
# flight, and `CLOSED` or `NONE` are not evidence that anything landed.
|
|
3234
|
+
#
|
|
3235
|
+
# Nothing of its own is NOT a claim: that shape is indistinguishable from
|
|
3140
3236
|
# merged work, which is exactly why claims carry a commit.
|
|
3141
3237
|
#
|
|
3142
3238
|
# ZERO AHEAD CARRIES TWO SHAPES, and only one of them is landed work:
|
|
@@ -3147,7 +3243,7 @@ branch_state() {
|
|
|
3147
3243
|
# | reset to main | is an ancestor → merged | holds nothing |
|
|
3148
3244
|
#
|
|
3149
3245
|
# A branch pointing AT the default branch is trivially an ancestor of it, so
|
|
3150
|
-
# every ancestry test passes — right for the case
|
|
3246
|
+
# every ancestry test passes — right for the case that arm was built for (a
|
|
3151
3247
|
# squash merge leaves the branch behind, and its work IS on main), and wrong
|
|
3152
3248
|
# for a branch that was reset, where the same shape means it holds NOTHING.
|
|
3153
3249
|
#
|
|
@@ -3159,33 +3255,41 @@ branch_state() {
|
|
|
3159
3255
|
# this error does not stall the fleet — it advances it onto a seam nobody
|
|
3160
3256
|
# wrote, which is the worse direction.
|
|
3161
3257
|
#
|
|
3162
|
-
# THE DISCRIMINATOR IS THE OTHER DIRECTION
|
|
3258
|
+
# THE DISCRIMINATOR IS THE OTHER DIRECTION, and it is why BOTH TIPS are
|
|
3259
|
+
# reported rather than a verdict about them. A branch with zero commits ahead
|
|
3163
3260
|
# is either equal to the default branch or a strict ancestor of it, so
|
|
3164
3261
|
# "behind = 0" and "tip = main tip" are the same predicate. Compared as OIDs
|
|
3165
3262
|
# because BOTH ARE ALREADY IN HAND from the ref batch — a `rev-list --count`
|
|
3166
3263
|
# would re-derive it at one spawn per branch, the per-branch tail this scan
|
|
3167
3264
|
# has repeatedly been thinned to remove.
|
|
3168
3265
|
#
|
|
3169
|
-
# OFFLINE, AND DELIBERATELY SO. No
|
|
3266
|
+
# OFFLINE, AND DELIBERATELY SO. No `--ask` is added here:
|
|
3170
3267
|
# `a-throttled-host-says-so` measured `plot-pr-merged.sh` answering *not
|
|
3171
3268
|
# merged* for three genuinely merged branches while throttled, and this
|
|
3172
3269
|
# reading must not inherit that failure mode.
|
|
3173
3270
|
#
|
|
3174
3271
|
# The squash path is untouched and must stay so — its mirror defect (a
|
|
3175
3272
|
# squash-merged branch reading `open`) is a separate plan, and a fix for one
|
|
3176
|
-
# can break the other. A squash-merged branch is BEHIND main and
|
|
3177
|
-
#
|
|
3178
|
-
# `ahead > 0` and never arrives here at all.
|
|
3179
|
-
local _bs_tip _bs_main
|
|
3273
|
+
# can break the other. A squash-merged branch is BEHIND main and its tips
|
|
3274
|
+
# differ; a squash-merged branch whose ref was pushed back counts `ahead > 0`.
|
|
3180
3275
|
_bs_tip=$(remote_ref_oid "$br")
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3276
|
+
[ -n "$_bs_tip" ] || _bs_tip="-"
|
|
3277
|
+
printf '%s\t%s\t%s\tfalse\t%s\t%s\t%s\t%s' \
|
|
3278
|
+
"$_bs_deferred" "$_bs_tip" "$_bs_main" "$HOST_VERDICT" "$(host_pr_state "$br")" \
|
|
3279
|
+
"${_bs_ahead:-0}" "${_bs_real:-0}"
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
# THE RULE, ASKED ONCE PER PLAN. `branchState` lives in `@plot-pm/domain` and
|
|
3283
|
+
# this is how the scan reaches it: readings in, one `state<TAB>needs` line per
|
|
3284
|
+
# branch out, in the order they were given.
|
|
3285
|
+
#
|
|
3286
|
+
# A MISSING OR SILENT ARTIFACT REFUSES, exactly as the verdicts call does.
|
|
3287
|
+
# There is no shell fallback: a second implementation kept "just in case" is
|
|
3288
|
+
# the duplication this adoption removes, and it would be the copy nobody tests.
|
|
3289
|
+
# `plot-deliver.sh` fails the same way for the same reason.
|
|
3290
|
+
ask_branch_states() { # stdin=readings → one `state<TAB>needs` line per branch
|
|
3291
|
+
node "$script_dir/board/plot-branch-state.mjs" 2>/dev/null \
|
|
3292
|
+
|| { echo "error: cannot read branch states — run 'pnpm build:board'." >&2; exit 2; }
|
|
3189
3293
|
}
|
|
3190
3294
|
|
|
3191
3295
|
# Prose is suppressed by BOTH alternate output modes. --json accumulates the
|
|
@@ -3402,36 +3506,112 @@ for plan in "${plans[@]}"; do
|
|
|
3402
3506
|
# between `why` and the wave name, which moved the wave name to field 7: the
|
|
3403
3507
|
# `awk` that reads it below was updated with this line and the two must move
|
|
3404
3508
|
# together.
|
|
3405
|
-
|
|
3509
|
+
# PASS 1a: THE READINGS. Every branch of this plan, gathered and not judged.
|
|
3510
|
+
#
|
|
3511
|
+
# `branch_readings` is the git archaeology that used to end in an `if` chain.
|
|
3512
|
+
# It now ends in eight tab-separated readings, and the two the plan states —
|
|
3513
|
+
# the prerequisite's name and what the host said about it — are appended here.
|
|
3514
|
+
#
|
|
3515
|
+
# THE PREREQUISITE'S PR STATE IS NOT READ YET, and `?` says so. `-` is taken:
|
|
3516
|
+
# `host_pr_state` answers it for a host that could not be reached, and the
|
|
3517
|
+
# rule reads that as `unreadable` and answers `waiting`, because silence is
|
|
3518
|
+
# not evidence in either direction. The two were one marker until CI ran the
|
|
3519
|
+
# corpus with no token, where every prerequisite answers `-` and every waiting
|
|
3520
|
+
# branch read `open`. Reading it
|
|
3521
|
+
# costs a host round trip (`waits_pr_state` passes `--ask`, because a delivered
|
|
3522
|
+
# prerequisite's ref is gone and only its PR outlives it), and the scan spends
|
|
3523
|
+
# that only where the answer could change the branch's state. Which states
|
|
3524
|
+
# those are IS the precedence, so the rule reports it rather than this loop
|
|
3525
|
+
# deciding it — see pass 1c.
|
|
3526
|
+
readings=""
|
|
3527
|
+
order=""
|
|
3406
3528
|
while IFS=$'\t' read -r idx br deferred why waits wname claim; do
|
|
3407
3529
|
[ -n "$br" ] || continue
|
|
3408
3530
|
# "-" is the absent marker the shim writes, for the tab-collapse reason
|
|
3409
3531
|
# above. Normalized here so everything downstream tests emptiness.
|
|
3410
3532
|
[ "$waits" = "-" ] && waits=""
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
# WORDS. `deferred` outranks it — somebody gave the branch up, which is a
|
|
3414
|
-
# decision, while waiting is a measurement — and so does any state that
|
|
3415
|
-
# means work exists: `wip`, `claimed` and `merged` all say the branch was
|
|
3416
|
-
# started, and overriding `merged` would stop its wave settling FOREVER,
|
|
3417
|
-
# which is the blocked-on-success failure this feature is built to avoid.
|
|
3418
|
-
#
|
|
3419
|
-
# So the override lands exactly where the defect was: a branch that reads
|
|
3420
|
-
# as unstarted, which is the population `--next` hands out.
|
|
3421
|
-
if [ -n "$waits" ]; then
|
|
3422
|
-
case "$st" in
|
|
3423
|
-
open|unknown)
|
|
3424
|
-
waits_st=$(waits_state "$waits")
|
|
3425
|
-
[ -n "$waits_st" ] && st="$waits_st" ;;
|
|
3426
|
-
esac
|
|
3427
|
-
fi
|
|
3428
|
-
# "-" GOES BACK IN, for the reason it was there in the first place: this
|
|
3429
|
-
# record is re-read by two more `read` loops below, and an EMPTY middle
|
|
3430
|
-
# column collapses its tab into its neighbour's and shifts every later
|
|
3431
|
-
# field left. `$claim` is the only field allowed to be last and optional.
|
|
3432
|
-
states+="$idx $br $st $deferred $why ${waits:--} $wname $claim"$'\n'
|
|
3533
|
+
readings+="$(branch_readings "$br" "$deferred") ${waits:--} ?"$'\n'
|
|
3534
|
+
order+="$idx $br $deferred $why ${waits:--} $wname $claim"$'\n'
|
|
3433
3535
|
done <<< "$wave_lines"
|
|
3434
3536
|
|
|
3537
|
+
# PASS 1b: THE DECISION, and it is not made here.
|
|
3538
|
+
#
|
|
3539
|
+
# `branchState` lives in `@plot-pm/domain` and this script asks it. The eight
|
|
3540
|
+
# words and the precedence that merges them — a plan's `deferred:` over
|
|
3541
|
+
# everything git says, the ref check before the merge lookup, a prerequisite
|
|
3542
|
+
# over `open` and `unknown` and nothing else — are one implementation now,
|
|
3543
|
+
# with a test per case, shared with every component that has to agree about
|
|
3544
|
+
# what a branch is.
|
|
3545
|
+
#
|
|
3546
|
+
# ONE CALL PER PLAN, not per branch, for the reason pass 2b gives: the board
|
|
3547
|
+
# polls this scan every five seconds against ~40 plans, and a process per
|
|
3548
|
+
# branch is the per-branch tail this script has repeatedly been thinned to
|
|
3549
|
+
# remove.
|
|
3550
|
+
#
|
|
3551
|
+
# A MISSING OR SILENT ARTIFACT REFUSES, exactly as the verdicts call does.
|
|
3552
|
+
# There is no shell fallback: a second implementation kept "just in case" is
|
|
3553
|
+
# the duplication this adoption removes, and it would be the copy nobody
|
|
3554
|
+
# tests.
|
|
3555
|
+
branch_answers=$(printf '%s' "$readings" | ask_branch_states)
|
|
3556
|
+
[ "$(printf '%s\n' "$branch_answers" | grep -c .)" = "$(printf '%s' "$readings" | grep -c .)" ] \
|
|
3557
|
+
|| { echo "error: branch states did not answer for every branch of $plan_base." >&2; exit 2; }
|
|
3558
|
+
|
|
3559
|
+
# PASS 1c: THE PREREQUISITES THE RULE ASKED FOR, and only those.
|
|
3560
|
+
#
|
|
3561
|
+
# The second column of each answer is the rule's own
|
|
3562
|
+
# `REPLACEABLE_BY_PREREQUISITE` — `1` where this branch names a prerequisite
|
|
3563
|
+
# whose state has not been read and where reading it could change the answer.
|
|
3564
|
+
# `deferred` outranks it, and so does any state meaning work exists: `wip`,
|
|
3565
|
+
# `claimed` and `merged` all say the branch was started, and overriding
|
|
3566
|
+
# `merged` would stop its wave settling FOREVER. That reasoning now sits in
|
|
3567
|
+
# `branch-state.ts` with a test per case; this loop only spends the calls it
|
|
3568
|
+
# is told to.
|
|
3569
|
+
#
|
|
3570
|
+
# SO THE SECOND ASK IS PAID ONLY WHEN SOMETHING IS FLAGGED, and the bound is
|
|
3571
|
+
# the flagged branches rather than the annotated ones: a `waits:` branch that
|
|
3572
|
+
# already reads `wip`, `claimed`, `merged` or `deferred` costs nothing.
|
|
3573
|
+
refill=""
|
|
3574
|
+
needs_refill=0
|
|
3575
|
+
answer_i=0
|
|
3576
|
+
while IFS= read -r rd_line; do
|
|
3577
|
+
[ -n "$rd_line" ] || continue
|
|
3578
|
+
answer_i=$((answer_i + 1))
|
|
3579
|
+
IFS=$'\t' read -r _st needs \
|
|
3580
|
+
<<< "$(printf '%s\n' "$branch_answers" | sed -n "${answer_i}p")"
|
|
3581
|
+
waits_br=$(printf '%s' "$rd_line" | cut -f9)
|
|
3582
|
+
if [ "$needs" = "1" ] && [ "$waits_br" != "-" ]; then
|
|
3583
|
+
needs_refill=1
|
|
3584
|
+
# `--ask` because the prerequisite is precisely the branch the repo-wide
|
|
3585
|
+
# list may legitimately omit: its plan may be delivered and its ref gone.
|
|
3586
|
+
# `host_pr_state`'s run cache keeps this at one call per prerequisite per
|
|
3587
|
+
# run, never one per pass.
|
|
3588
|
+
refill+="$(printf '%s' "$rd_line" | cut -f1-9) $(waits_pr_state "$waits_br")"$'\n'
|
|
3589
|
+
else
|
|
3590
|
+
refill+="$rd_line"$'\n'
|
|
3591
|
+
fi
|
|
3592
|
+
done <<< "$readings"
|
|
3593
|
+
|
|
3594
|
+
if [ "$needs_refill" = 1 ]; then
|
|
3595
|
+
branch_answers=$(printf '%s' "$refill" | ask_branch_states)
|
|
3596
|
+
[ "$(printf '%s\n' "$branch_answers" | grep -c .)" = "$(printf '%s' "$refill" | grep -c .)" ] \
|
|
3597
|
+
|| { echo "error: branch states did not answer for every branch of $plan_base." >&2; exit 2; }
|
|
3598
|
+
fi
|
|
3599
|
+
|
|
3600
|
+
# PASS 1d: the record every loop below reads, with the decided state in it.
|
|
3601
|
+
#
|
|
3602
|
+
# "-" GOES BACK IN, for the reason it was there in the first place: this
|
|
3603
|
+
# record is re-read by two more `read` loops below, and an EMPTY middle
|
|
3604
|
+
# column collapses its tab into its neighbour's and shifts every later
|
|
3605
|
+
# field left. `$claim` is the only field allowed to be last and optional.
|
|
3606
|
+
states=""
|
|
3607
|
+
answer_i=0
|
|
3608
|
+
while IFS=$'\t' read -r idx br deferred why waits wname claim; do
|
|
3609
|
+
[ -n "$br" ] || continue
|
|
3610
|
+
answer_i=$((answer_i + 1))
|
|
3611
|
+
st=$(printf '%s\n' "$branch_answers" | sed -n "${answer_i}p" | cut -f1)
|
|
3612
|
+
states+="$idx $br $st $deferred $why $waits $wname $claim"$'\n'
|
|
3613
|
+
done <<< "$order"
|
|
3614
|
+
|
|
3435
3615
|
# Pass 2a: what each wave HOLDS — how many of its non-deferred branches have
|
|
3436
3616
|
# not settled. A reading, and the whole of what this script contributes to the
|
|
3437
3617
|
# verdict: which branches count as settled depends on `--loose` and on a host
|
|
@@ -3573,7 +3753,7 @@ for plan in "${plans[@]}"; do
|
|
|
3573
3753
|
claimable+=("$br")
|
|
3574
3754
|
fi
|
|
3575
3755
|
[ "$quiet" = 1 ] || echo " $br — $note"
|
|
3576
|
-
if [ "$
|
|
3756
|
+
if [ "$build_doc" = 1 ]; then
|
|
3577
3757
|
# The INTERNAL state ($st), never the prose label ($note): the board
|
|
3578
3758
|
# must not parse a string that exists for humans to read.
|
|
3579
3759
|
json_branches+="${json_branches:+,}{\"branch\":\"$(json_str "$br")\""
|
|
@@ -3771,7 +3951,7 @@ for plan in "${plans[@]}"; do
|
|
|
3771
3951
|
fi
|
|
3772
3952
|
done <<< "$states"
|
|
3773
3953
|
|
|
3774
|
-
if [ "$
|
|
3954
|
+
if [ "$build_doc" = 1 ]; then
|
|
3775
3955
|
json_waves+="${json_waves:+,}{\"name\":\"$(json_str "$wname")\""
|
|
3776
3956
|
json_waves+=",\"verdict\":\"$verdict\",\"branches\":[$json_branches]}"
|
|
3777
3957
|
fi
|
|
@@ -3785,7 +3965,7 @@ for plan in "${plans[@]}"; do
|
|
|
3785
3965
|
# what the wave before it decided.
|
|
3786
3966
|
[ "$verdict" = "blocked" ] && n_blocked=$((n_blocked + 1))
|
|
3787
3967
|
done
|
|
3788
|
-
if [ "$
|
|
3968
|
+
if [ "$build_doc" = 1 ]; then
|
|
3789
3969
|
# ONE composition, two destinations — the property that makes --stream and
|
|
3790
3970
|
# --json say the same thing rather than agreeing by inspection. A second
|
|
3791
3971
|
# `printf` shaped like this one would be a second implementation of the
|
|
@@ -3883,28 +4063,123 @@ if [ "$log_pulse" = 1 ]; then
|
|
|
3883
4063
|
done
|
|
3884
4064
|
fi
|
|
3885
4065
|
|
|
3886
|
-
#
|
|
3887
|
-
#
|
|
3888
|
-
#
|
|
3889
|
-
#
|
|
3890
|
-
#
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
4066
|
+
# THE PULSE RECORDS ITSELF — `.plot/state/last-pulse.json`, the bridge.
|
|
4067
|
+
#
|
|
4068
|
+
# WHY THE SCAN AND NOT THE BOARD. `fleet.ts:2804` was the ONLY writer until
|
|
4069
|
+
# 2026-09-06, and this script named the file zero times — so `/plot-pulse` in a
|
|
4070
|
+
# repository with no board had nothing to diff against, and every pulse was a
|
|
4071
|
+
# first one forever. `DESIGN-process.md` §1 requires the fleet to work with no
|
|
4072
|
+
# board at all. The scan produces the pulse; the component that produces one
|
|
4073
|
+
# records it. The board's write is not removed and becomes redundant: the board
|
|
4074
|
+
# spawns THIS script, so a scan that writes the bridge writes it on the board's
|
|
4075
|
+
# path too, from inside the same run.
|
|
4076
|
+
#
|
|
4077
|
+
# INSIDE THE SUCCESS PATH, WHICH IS THE PROPERTY THAT HAD TO SURVIVE THE MOVE.
|
|
4078
|
+
# `fleet.ts:2800`: *"A scan that failed must not overwrite the last good answer
|
|
4079
|
+
# — the only thing standing between a `--watch` restart and an empty board."*
|
|
4080
|
+
# So this is called where the scan has finished deriving and is about to say so,
|
|
4081
|
+
# never from a trap and never at exit. A killed scan leaves the previous file
|
|
4082
|
+
# whole.
|
|
4083
|
+
#
|
|
4084
|
+
# THE FORMAT IS `pulse-bridge.ts`'s AND EVERY FIELD HERE IS ITS REQUIREMENT.
|
|
4085
|
+
# `version` must equal `BRIDGE_VERSION` or `:193` returns null and the board
|
|
4086
|
+
# renders an empty page with no error — a silent failure, which is why the
|
|
4087
|
+
# board's own test reads a file this script wrote rather than one a fixture
|
|
4088
|
+
# invented. `at` is epoch MILLISECONDS and is checked against
|
|
4089
|
+
# `BRIDGE_MAX_AGE_MS` (15 min), and a file from the future is rejected outright
|
|
4090
|
+
# rather than clamped, so the clock must be the same one the board reads.
|
|
4091
|
+
#
|
|
4092
|
+
# THE FOUR MAPS ARE EMPTY AND THAT IS HONEST. `ages`, `approvedAt` and
|
|
4093
|
+
# `ideaPlans` are computed by the board on its own timers, and `branchUrlBase`
|
|
4094
|
+
# comes from its settings; the scan knows none of them. `readBridge` rebuilds
|
|
4095
|
+
# each with `toMap`, which yields an empty Map for anything it cannot use, and
|
|
4096
|
+
# `branchUrlBase` falls back to `''`. So a scan-written bridge serves the rows,
|
|
4097
|
+
# the verdicts and the counts, with unknown ages and no branch links — degraded
|
|
4098
|
+
# in the direction the board already handles, and overwritten by the board's own
|
|
4099
|
+
# richer write seconds later on the refresh it always issues.
|
|
4100
|
+
#
|
|
4101
|
+
# TEMP FILE PLUS RENAME, carrying the pid, exactly as `writeBridge` does and for
|
|
4102
|
+
# the same reason: `rename` is atomic within a filesystem, so a board reading
|
|
4103
|
+
# while this writes sees the old file whole or the new file whole. The pid is in
|
|
4104
|
+
# the temp name because two scans on one repo — routine here — must not collide
|
|
4105
|
+
# on one temp file and hand the reader the torn payload the rename exists to
|
|
4106
|
+
# prevent.
|
|
4107
|
+
#
|
|
4108
|
+
# EVERY FAILURE IS SWALLOWED, the rule `writeBridge` states: a read-only
|
|
4109
|
+
# checkout, a full disk, a `.plot` nobody may write to. None of that is a reason
|
|
4110
|
+
# for a pulse to fail, and the cost of the miss is exactly the behaviour before
|
|
4111
|
+
# this existed.
|
|
4112
|
+
#
|
|
4113
|
+
# IT IS A CACHE WITH AN EXPIRY, NEVER A RECORD. Deleting it changes no
|
|
4114
|
+
# behaviour, because the next pulse re-derives everything — which is what keeps
|
|
4115
|
+
# this inside the script's stateless design rather than beside it.
|
|
4116
|
+
# The previous pulse's document, read BEFORE `write_bridge` replaces it.
|
|
4117
|
+
#
|
|
4118
|
+
# EMPTY MEANS NO FILE, and that is a fact about the FILE rather than about its
|
|
4119
|
+
# contents — it is what separates a first run from a history that exists and
|
|
4120
|
+
# cannot be used. A file that is present but truncated, or written in a shape
|
|
4121
|
+
# this build does not know, arrives here as its own bytes and the renderer
|
|
4122
|
+
# decides; a file that was never there arrives as "".
|
|
4123
|
+
#
|
|
4124
|
+
# READ ONCE, EARLY, because `write_bridge` overwrites it on the success path and
|
|
4125
|
+
# the delta compares against what was there when this scan started.
|
|
4126
|
+
PREVIOUS_PULSE=""
|
|
4127
|
+
read_previous_pulse() {
|
|
4128
|
+
local root file
|
|
4129
|
+
root=$(git rev-parse --show-toplevel 2>/dev/null) || return 0
|
|
4130
|
+
[ -n "$root" ] || return 0
|
|
4131
|
+
file="$root/.plot/state/last-pulse.json"
|
|
4132
|
+
[ -f "$file" ] || return 0
|
|
4133
|
+
# A HUGE OR UNREADABLE FILE COSTS THE DELTA, NEVER THE PULSE. Every failure
|
|
4134
|
+
# here leaves PREVIOUS_PULSE empty, which reads as a first run — the same
|
|
4135
|
+
# answer the scan gave before this existed.
|
|
4136
|
+
PREVIOUS_PULSE=$(cat "$file" 2>/dev/null | tr -d '\n') || PREVIOUS_PULSE=""
|
|
4137
|
+
return 0
|
|
4138
|
+
}
|
|
4139
|
+
|
|
4140
|
+
write_bridge() {
|
|
4141
|
+
[ "$record" = 1 ] || return 0
|
|
4142
|
+
[ -n "$reading_doc" ] || return 0
|
|
4143
|
+
# The bridge belongs to the REPOSITORY, not to the directory the scan was run
|
|
4144
|
+
# from. Every other path here is relative to the cwd because the board spawns
|
|
4145
|
+
# this from the root; the board reads the bridge at `repoRoot`, so this asks
|
|
4146
|
+
# git rather than assuming the two agree.
|
|
4147
|
+
local root file tmp
|
|
4148
|
+
root=$(git rev-parse --show-toplevel 2>/dev/null) || return 0
|
|
4149
|
+
[ -n "$root" ] || return 0
|
|
4150
|
+
file="$root/.plot/state/last-pulse.json"
|
|
4151
|
+
mkdir -p "$root/.plot/state" 2>/dev/null || return 0
|
|
4152
|
+
tmp="$file.$$.tmp"
|
|
4153
|
+
# `at` is when the scan COMPLETED, which is what `pulse-bridge.ts:81` asks for
|
|
4154
|
+
# — "NOT when it was written" — and the two are the same instant here.
|
|
4155
|
+
printf '{"version":1,"at":%s,"pulse":%s,"ages":[],"branchUrlBase":"","approvedAt":[],"ideaPlans":[]}' \
|
|
4156
|
+
"$(( $(date +%s) * 1000 ))" "$reading_doc" > "$tmp" 2>/dev/null || {
|
|
4157
|
+
rm -f "$tmp" 2>/dev/null
|
|
4158
|
+
return 0
|
|
4159
|
+
}
|
|
4160
|
+
mv -f "$tmp" "$file" 2>/dev/null || rm -f "$tmp" 2>/dev/null
|
|
4161
|
+
return 0
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
# THE READING, COMPOSED ONCE. Two consumers read it — `--json`/`--stream` print
|
|
4165
|
+
# it, and `write_bridge` records it — and composing it twice is how the printed
|
|
4166
|
+
# document and the recorded one start to disagree. Assembled only when
|
|
4167
|
+
# `build_doc` is on, because the branch objects inside `$json_plans` cost
|
|
4168
|
+
# `merge-tree` per unlanded branch.
|
|
4169
|
+
if [ "$build_doc" = 1 ]; then
|
|
4170
|
+
# BEFORE THE DOCUMENT IS COMPOSED AND LONG BEFORE IT IS WRITTEN. The delta
|
|
4171
|
+
# compares against the pulse that was on disk when this scan started, and
|
|
4172
|
+
# `write_bridge` replaces that file on the success path below.
|
|
4173
|
+
read_previous_pulse
|
|
3899
4174
|
# `read_ref` is the ref this document was derived from; `local_head` is the
|
|
3900
4175
|
# checkout it was derived ON. A consumer needs both to tell "the board is
|
|
3901
4176
|
# current" from "the board is current about an old world".
|
|
3902
4177
|
#
|
|
3903
4178
|
# `head` repeats `local_head` as an alias for one release. The board reads it
|
|
3904
4179
|
# today; it goes away once the board reads the pair.
|
|
3905
|
-
printf '{"main":"%s","read_ref":"%s","local_head":"%s","head":"%s",' \
|
|
4180
|
+
reading_doc=$(printf '{"main":"%s","read_ref":"%s","local_head":"%s","head":"%s",' \
|
|
3906
4181
|
"$(json_str "$MAIN")" "$(json_str "$READ_REF")" "$(json_str "$LOCAL_HEAD")" \
|
|
3907
|
-
"$(json_str "$HEAD_SHORT")"
|
|
4182
|
+
"$(json_str "$HEAD_SHORT")")
|
|
3908
4183
|
# Three more facts about the EVIDENCE, not about the fleet — a consumer that
|
|
3909
4184
|
# renders the numbers below should be able to say how much to trust them.
|
|
3910
4185
|
# They answer the question `read_ref` raises: that field names the ref, and
|
|
@@ -3913,11 +4188,11 @@ if [ "$as_json" = 1 ]; then
|
|
|
3913
4188
|
# `fetch_failed` used to be discarded by `2>/dev/null`, so refs an hour old
|
|
3914
4189
|
# were reported with the confidence of refs a second old. `plan_source` says
|
|
3915
4190
|
# whether the plan list came from the ref or fell back to this checkout.
|
|
3916
|
-
printf '"fetch_failed":%s,"fetch_error":"%s","plan_source":"%s","plans":[%s],' \
|
|
4191
|
+
reading_doc+=$(printf '"fetch_failed":%s,"fetch_error":"%s","plan_source":"%s","plans":[%s],' \
|
|
3917
4192
|
"$([ "$FETCH_FAILED" = 1 ] && echo true || echo false)" \
|
|
3918
|
-
"$(json_str "$FETCH_ERROR")" "$(json_str "$PLAN_SOURCE")" "$json_plans"
|
|
3919
|
-
printf '"summary":{"plans":%d,"waves":%d,"branches":%d,"claimed":%d,' \
|
|
3920
|
-
"$n_plans" "$n_waves" "$n_branches" "$n_claimed"
|
|
4193
|
+
"$(json_str "$FETCH_ERROR")" "$(json_str "$PLAN_SOURCE")" "$json_plans")
|
|
4194
|
+
reading_doc+=$(printf '"summary":{"plans":%d,"waves":%d,"branches":%d,"claimed":%d,' \
|
|
4195
|
+
"$n_plans" "$n_waves" "$n_branches" "$n_claimed")
|
|
3921
4196
|
# `host` is the EVIDENCE field beside merge_detect, and it is the one that
|
|
3922
4197
|
# says whether merge_detect can be believed. Rendered for the machine here
|
|
3923
4198
|
# and in the footer for a human; the board reads this rather than parsing
|
|
@@ -3926,9 +4201,29 @@ if [ "$as_json" = 1 ]; then
|
|
|
3926
4201
|
# WAVES. Two vocabularies share the word `blocked` and the footer must not:
|
|
3927
4202
|
# a consumer adding the three would double-count nothing, because no branch
|
|
3928
4203
|
# is in both and no wave is in either.
|
|
3929
|
-
printf '"eligible":%d,"blocked":%d,"deferred":%d,"waiting":%d,"prereq_missing":%d,"merge_detect":"%s","host":"%s"}}' \
|
|
4204
|
+
reading_doc+=$(printf '"eligible":%d,"blocked":%d,"deferred":%d,"waiting":%d,"prereq_missing":%d,"merge_detect":"%s","host":"%s"}}' \
|
|
3930
4205
|
"$n_eligible" "$n_blocked" "$n_deferred" "$n_waiting" "$n_prereq_missing" \
|
|
3931
|
-
"$MERGE_DETECT" "$HOST_VERDICT"
|
|
4206
|
+
"$MERGE_DETECT" "$HOST_VERDICT")
|
|
4207
|
+
fi
|
|
4208
|
+
|
|
4209
|
+
# --json: the same derivation as the prose above, rendered for machines. It is
|
|
4210
|
+
# an OUTPUT MODE and nothing more — it composes with --offline/--no-fetch/
|
|
4211
|
+
# --loose rather than implying any of them, so the board's data depends on what
|
|
4212
|
+
# it asked for, not on how it asked. --next wins over it (handled above): that
|
|
4213
|
+
# is a different question with a one-line answer.
|
|
4214
|
+
if [ "$as_json" = 1 ]; then
|
|
4215
|
+
# THE SCAN COMPLETED, so the bridge may be replaced. Before the document is
|
|
4216
|
+
# printed rather than after: a consumer that reads the terminal line and then
|
|
4217
|
+
# kills us must still find the file written.
|
|
4218
|
+
write_bridge
|
|
4219
|
+
# --stream wraps the SAME document in one tagged line rather than emitting a
|
|
4220
|
+
# second, smaller one. The terminal object is what proves the scan finished:
|
|
4221
|
+
# a consumer that has seen `plan` lines and no `reading` line has a PARTIAL
|
|
4222
|
+
# answer and must say so — which is the whole distinction this mode adds, and
|
|
4223
|
+
# the reason the end is marked rather than inferred from the pipe closing.
|
|
4224
|
+
# A killed scan closes the pipe too.
|
|
4225
|
+
[ "$stream" = 1 ] && printf '{"kind":"reading","reading":'
|
|
4226
|
+
printf '%s' "$reading_doc"
|
|
3932
4227
|
[ "$stream" = 1 ] && printf '}'
|
|
3933
4228
|
printf '\n'
|
|
3934
4229
|
exit 0
|
|
@@ -4004,5 +4299,33 @@ if [ "$PLAN_SOURCE" != "ref" ]; then
|
|
|
4004
4299
|
echo " note: origin/$MAIN could not be read — plans were listed from this"
|
|
4005
4300
|
echo " checkout instead, so the list is only as current as your last pull."
|
|
4006
4301
|
fi
|
|
4302
|
+
# THE PROSE PATH'S TERMINAL POINT, and the counterpart to the `--json` write
|
|
4303
|
+
# above. `/plot-pulse` runs this path — it passes `--log-pulse` and no `--json`
|
|
4304
|
+
# — so without a write here the boardless repository the plan names would still
|
|
4305
|
+
# accumulate no history. A no-op unless `--log-pulse` turned assembly on.
|
|
4306
|
+
#
|
|
4307
|
+
# Below every `note:` the report emits and above the sentence that says the scan
|
|
4308
|
+
# finished: a scan killed while printing its notes has not completed, and must
|
|
4309
|
+
# not replace the last good answer.
|
|
4310
|
+
# WHAT CHANGED SINCE THE LAST PULSE, above the line that says this one
|
|
4311
|
+
# finished. The rule is `pulseDelta` in the domain and this prints what it
|
|
4312
|
+
# decided — no comparison happens here, and one written here would be the
|
|
4313
|
+
# second implementation of a rule that already has tests.
|
|
4314
|
+
#
|
|
4315
|
+
# ONLY WHERE A PULSE WAS PRODUCED FOR SOMEBODY TO READ. `build_doc` is what says
|
|
4316
|
+
# the reading was assembled at all, and `record` is what says this run is one of
|
|
4317
|
+
# the two that produce a pulse rather than answer a query. A `--next` caller
|
|
4318
|
+
# asking what to work on is told nothing new.
|
|
4319
|
+
#
|
|
4320
|
+
# EVERY FAILURE IS SILENT AND COSTS ONLY THE DELTA. A missing artifact, an
|
|
4321
|
+
# unreadable previous pulse, a node that will not start — the full report below
|
|
4322
|
+
# is exactly what it was before this existed, which is the degradation this
|
|
4323
|
+
# whole line is optional against.
|
|
4324
|
+
if [ "$build_doc" = 1 ] && [ "$record" = 1 ] && [ -n "$reading_doc" ]; then
|
|
4325
|
+
delta_out=$(printf '%s\n%s\n' "$PREVIOUS_PULSE" "$reading_doc" \
|
|
4326
|
+
| node "$script_dir/board/plot-delta.mjs" 2>/dev/null) || delta_out=""
|
|
4327
|
+
[ -n "$delta_out" ] && { printf '%s\n' "$delta_out"; echo; }
|
|
4328
|
+
fi
|
|
4329
|
+
write_bridge
|
|
4007
4330
|
echo "Pulse complete. This report is derived — nothing was changed."
|
|
4008
4331
|
echo "summary: plans=$n_plans waves=$n_waves branches=$n_branches claimed=$n_claimed eligible=$n_eligible blocked=$n_blocked deferred=$n_deferred waiting=$n_waiting prereq_missing=$n_prereq_missing merge_detect=$MERGE_DETECT host=$HOST_VERDICT main=$MAIN"
|