@plot-pm/board 0.16.2 → 0.17.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 +258 -146
- package/package.json +3 -6
- package/plot-agent-manifest.sh +76 -15
- package/plot-agent-monitor.sh +43 -5
- package/plot-approve.sh +224 -73
- package/plot-config.sh +14 -14
- package/plot-deliver.sh +10 -741
- package/plot-dispatch.sh +282 -107
- package/plot-fleet-scan.sh +867 -170
- package/plot-host.sh +310 -76
- package/plot-monitor-subject.sh +61 -10
- package/plot-plan-meta.sh +155 -139
- package/plot-pr-merged.sh +16 -0
- package/plot-reap.sh +61 -28
- package/plot-state-receipt.sh +12 -2
- package/plot-tmp.sh +31 -3
- package/plot-worker-state.sh +458 -24
- package/plot-build-monitor.sh +0 -435
- package/plot-resolve-artifact.sh +0 -423
- package/plot-transcript-quiet.sh +0 -170
- package/plot-worker-monitor.sh +0 -705
package/plot-fleet-scan.sh
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Plot helper: fleet pulse — deterministic extractor for wave/claim state.
|
|
3
|
-
# Usage: plot-fleet-scan.sh [--no-fetch] [--offline] [--next] [<slug>]
|
|
3
|
+
# Usage: plot-fleet-scan.sh [--no-fetch] [--offline] [--next] [--slice-names] [<slug>]
|
|
4
|
+
# --slice-names print every slice a `waits:` name may wait on, one per line,
|
|
5
|
+
# and exit: the estate's non-deferred slices of non-terminal plans,
|
|
6
|
+
# whatever slug is given. No fetch, no host call.
|
|
4
7
|
# --no-fetch skip `git fetch`
|
|
5
8
|
# --offline same (no fetch) — used for cheap, ambient pulses.
|
|
6
9
|
# The fetch also PRUNES remote-tracking refs, so skipping it
|
|
@@ -76,11 +79,12 @@
|
|
|
76
79
|
# Branch states — the word each BRANCH carries, distinct from the wave verdicts
|
|
77
80
|
# above. `open`, `wip`, `merged`, `claimed`, `deferred` and `unknown` are read
|
|
78
81
|
# from git and the host. Two more are read from the plan's `waits:` annotation:
|
|
79
|
-
# waiting the branch names a prerequisite branch that has not merged
|
|
82
|
+
# waiting the branch names a prerequisite branch that has not merged —
|
|
83
|
+
# including a slice some plan names that nobody has started. A
|
|
80
84
|
# wait with an end: it clears when that branch lands, and the
|
|
81
85
|
# fleet payload carries `waits_on` so a reader sees on WHAT.
|
|
82
86
|
# blocked the branch names a prerequisite the host has never seen a PR
|
|
83
|
-
# for
|
|
87
|
+
# for and no plan names as a slice — a typo. A defect in the plan
|
|
84
88
|
# estate, not progress, which is why it is a separate word: the
|
|
85
89
|
# first resolves by waiting, the second by editing the plan.
|
|
86
90
|
# THE SAME WORD AS THE WAVE VERDICT, IN A DIFFERENT VOCABULARY.
|
|
@@ -261,6 +265,7 @@ do_fetch=1
|
|
|
261
265
|
next_only=0
|
|
262
266
|
list_all=0
|
|
263
267
|
why_nothing=0
|
|
268
|
+
slice_names=0
|
|
264
269
|
loose=0
|
|
265
270
|
log_pulse=0
|
|
266
271
|
as_json=0
|
|
@@ -295,6 +300,7 @@ while [ $# -gt 0 ]; do
|
|
|
295
300
|
# about the SAME plans `--next` was silent over — a terminal plan admitted
|
|
296
301
|
# here would answer `not-yet` about work somebody decided was not needed.
|
|
297
302
|
--why-nothing) next_only=1; why_nothing=1 ;;
|
|
303
|
+
--slice-names) slice_names=1; do_fetch=0 ;;
|
|
298
304
|
# `--json` ASSEMBLES BUT DOES NOT RECORD, and the two flags differ here for
|
|
299
305
|
# a reason. `--stream` is what the BOARD spawns (`fleet.ts:2694`) and
|
|
300
306
|
# `--log-pulse` is what `/plot-pulse` passes: both produce a pulse somebody
|
|
@@ -471,8 +477,13 @@ fi
|
|
|
471
477
|
# seam, not a knob: nothing in Plot sets it, and lowering it in real use buys
|
|
472
478
|
# nothing but the silent misses described above.
|
|
473
479
|
MERGE_SCAN_LIMIT=${PLOT_MERGE_SCAN_LIMIT:-2000}
|
|
480
|
+
# `%H %s` RATHER THAN `%s`, because the age rule needs the merge COMMIT and not
|
|
481
|
+
# only its sentence: a subject proves a branch for a plan only when its merge is
|
|
482
|
+
# not contained in the commit that added the plan file, and that is an ancestry
|
|
483
|
+
# question about this hash. One walk still, and the hash costs nothing — the
|
|
484
|
+
# same `git log`, one more format placeholder.
|
|
474
485
|
MERGE_SUBJECTS=$(git log "origin/$MAIN" --merges \
|
|
475
|
-
--max-count="$MERGE_SCAN_LIMIT" --pretty
|
|
486
|
+
--max-count="$MERGE_SCAN_LIMIT" --pretty='%H %s' </dev/null 2>/dev/null || true)
|
|
476
487
|
MERGE_SCAN_TRUNCATED=0
|
|
477
488
|
if [ -n "$MERGE_SUBJECTS" ] \
|
|
478
489
|
&& [ "$(printf '%s\n' "$MERGE_SUBJECTS" | grep -c .)" -ge "$MERGE_SCAN_LIMIT" ]; then
|
|
@@ -489,11 +500,22 @@ fi
|
|
|
489
500
|
# capped walk detected, but not exhaustively.
|
|
490
501
|
# none — the default branch carries no conforming merge commits at all
|
|
491
502
|
# (a squash/rebase repo), so `open` says nothing about merging.
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
503
|
+
# unaskable — the rule could not be asked at all, so NO branch got a subject
|
|
504
|
+
# reading and every refless branch went to the host. A missing or
|
|
505
|
+
# silent bundle is the case; it is not the same answer as `none`,
|
|
506
|
+
# which is a measurement of this estate's history.
|
|
507
|
+
#
|
|
508
|
+
# THE WORD IS THE BUNDLE'S, and this is why no regex decides it here. The regex
|
|
509
|
+
# that stood on this line read GitHub's form only, so a Bitbucket estate — whose
|
|
510
|
+
# every merge carries `Merged in <branch> (pull request #N)` — reported
|
|
511
|
+
# `merge_detect=none` and said its own 1723 proofs did not exist. The forms are
|
|
512
|
+
# data in the host adapter now, and a second copy in shell is exactly what
|
|
513
|
+
# `a-merge-subject-proves-a-landing-the-host-cannot` removes.
|
|
514
|
+
#
|
|
515
|
+
# SET AFTER THE PLANS ARE PARSED, because the bundle is asked then — the plan
|
|
516
|
+
# branches and their adding commits are known only then. Until that point the
|
|
517
|
+
# word is `unaskable`, which is the honest answer for a scan that has not asked.
|
|
518
|
+
MERGE_DETECT=unaskable
|
|
497
519
|
|
|
498
520
|
# ---------------------------------------------------------------------------
|
|
499
521
|
# Squash merges: the case where no local evidence survives at all
|
|
@@ -648,7 +670,32 @@ cache_key() { # $1=branch → a filename that is injective in the branch name
|
|
|
648
670
|
# them. OPEN outranks MERGED outranks CLOSED, matching the walk `pr-state`
|
|
649
671
|
# already performs on Bitbucket, so the join and the per-branch lookup cannot
|
|
650
672
|
# disagree about the same branch.
|
|
651
|
-
|
|
673
|
+
# GENEROUS MEANS ABOVE THE REPOSITORY'S PR COUNT, and 1000 stopped being that.
|
|
674
|
+
# It was set when this repo held 221 PRs. Measured 2026-10-02 it holds 1064, so
|
|
675
|
+
# `--state all --limit 1000` returned exactly 1000 rows — a page AT its limit,
|
|
676
|
+
# which proves only "at least 1000" and can never license completeness. The
|
|
677
|
+
# marker was withheld, and the 26 branches the join could not name each cost one
|
|
678
|
+
# `pr-state` call at 3.8 s: 54-61% of the scan's wall time (#1017).
|
|
679
|
+
#
|
|
680
|
+
# THE NUMBER IS A BUDGET, NOT AN ASSUMPTION, and that distinction is what makes
|
|
681
|
+
# raising it a fix rather than a deferral. A page short of this limit is now read
|
|
682
|
+
# as PROOF of completeness — `plot-host.sh` states it and `prefill_pr_states`
|
|
683
|
+
# reads the sentence — so the limit's only job is to be reachable. A repository
|
|
684
|
+
# that outgrows it gets a capped page, the adapter says `possibly truncated`,
|
|
685
|
+
# the marker is withheld, and the scan degrades to the per-branch asking it did
|
|
686
|
+
# before: slower, and still correct. The failure is loud in the adapter's own
|
|
687
|
+
# stderr and costs cost, never an answer.
|
|
688
|
+
#
|
|
689
|
+
# COST SCALES WITH ROWS RETURNED, NOT ROWS REQUESTED, so the headroom is nearly
|
|
690
|
+
# free. Measured 2026-10-02 through `plot-host.sh pr-list --state all`:
|
|
691
|
+
# `--limit 1000` → 1000 rows in 7.0 s; `--limit 3000` → 1065 rows in 9.2 s;
|
|
692
|
+
# `--limit 5000` → the same 1065 rows in 11.1 s. GitHub pages internally and
|
|
693
|
+
# stops at what exists, so asking for 3000 of 1064 fetches 1064. Two seconds
|
|
694
|
+
# more on one call against ~100 s of per-branch calls removed.
|
|
695
|
+
#
|
|
696
|
+
# `PLOT_PR_LIST_LIMIT` still overrides it, and lowering it is how a repository
|
|
697
|
+
# with a tighter quota trades the join back for per-branch asking.
|
|
698
|
+
PR_LIST_LIMIT="${PLOT_PR_LIST_LIMIT:-3000}"
|
|
652
699
|
|
|
653
700
|
# WHETHER THE HOST ANSWERED, as a fact of its own — the thing this scan
|
|
654
701
|
# computed and threw away until 2026-08-30.
|
|
@@ -823,9 +870,129 @@ pr_list_verdict_rank() {
|
|
|
823
870
|
esac
|
|
824
871
|
}
|
|
825
872
|
|
|
873
|
+
# The listing a previous pulse made, handed in rather than spent again.
|
|
874
|
+
#
|
|
875
|
+
# THE BOARD DECIDES, NOT THIS SCRIPT. `listingSpend` in the domain answers
|
|
876
|
+
# whether the account can afford a listing now, and the board — the only
|
|
877
|
+
# long-lived process here — holds the previous one and hands it back. This scan
|
|
878
|
+
# is spawned fresh per pulse and can span none, which is `PLOT_TERMINAL_CACHE`'s
|
|
879
|
+
# reason (`fleet.ts:3380`) applied to the listing rather than to merge facts.
|
|
880
|
+
#
|
|
881
|
+
# Measured 2026-10-02 on the Bitbucket workspace `quatico`: this scan spent 2949
|
|
882
|
+
# of one account's 3150 calls in an hour, 93.6%, because the open listing sweeps
|
|
883
|
+
# one REST request per tracked branch per state and the pulse is 5 s. Cutting the
|
|
884
|
+
# number of runs does not fix that; cutting the listings a run makes does.
|
|
885
|
+
#
|
|
886
|
+
# A CARRIED LISTING IS A FULL ANSWER OR IT IS NOT USED. It carries the same
|
|
887
|
+
# per-branch lines `prefill_pr_states` would have written plus the markers that
|
|
888
|
+
# license `NONE`, so every reader below is unchanged and none of them can tell a
|
|
889
|
+
# carried listing from a fetched one. That is deliberate: the AGE is the board's
|
|
890
|
+
# to report, because the board is what knows how old its own listing is.
|
|
891
|
+
# An empty value means nothing was carried and the listing is spent as before.
|
|
892
|
+
PLOT_PR_LISTING="${PLOT_PR_LISTING:-}"
|
|
893
|
+
|
|
894
|
+
# Fill the cache from a carried listing. Prints nothing; returns 1 when there was
|
|
895
|
+
# nothing to carry, so the caller spends the listing instead.
|
|
896
|
+
#
|
|
897
|
+
# ABSENT IS NOT EMPTY. A carried listing with no rows would license `NONE` for
|
|
898
|
+
# every branch — the 2026-08-27 failure that refused four fully-merged plans —
|
|
899
|
+
# so a value carrying no arrival marker is refused here and the host is asked.
|
|
900
|
+
carried_listing() {
|
|
901
|
+
[ -n "$PLOT_PR_LISTING" ] || return 1
|
|
902
|
+
local key st chk dft arrived=0 complete=0
|
|
903
|
+
while IFS=" " read -r key st chk dft; do
|
|
904
|
+
case "$key" in
|
|
905
|
+
'') continue ;;
|
|
906
|
+
# The markers travel as lines rather than as files, because the board holds
|
|
907
|
+
# one string and not a directory.
|
|
908
|
+
.list-arrived) arrived=1; continue ;;
|
|
909
|
+
.list-complete) complete=1; continue ;;
|
|
910
|
+
# Every other dotted key is a note for the board and not a branch. Skipped
|
|
911
|
+
# rather than written: `git check-ref-format` rejects a branch name starting
|
|
912
|
+
# with a dot and the key encoding maps only `_` and `/`, so no branch key can
|
|
913
|
+
# begin with one and a dotted key is never a missed answer.
|
|
914
|
+
.*) continue ;;
|
|
915
|
+
esac
|
|
916
|
+
[ -n "$st" ] || continue
|
|
917
|
+
# The `-` sentinel back to empty, exactly as the host payload's plain rows are
|
|
918
|
+
# translated: an empty `checks` reads as `unknown` in `pr_ready` and degrades
|
|
919
|
+
# `--loose` to strict, which is the safe direction.
|
|
920
|
+
[ "$chk" = "-" ] && chk=""
|
|
921
|
+
[ "$dft" = "-" ] && dft=""
|
|
922
|
+
printf '%s\t%s\t%s' "$st" "$chk" "$dft" > "$HOST_STATE_CACHE/$key" 2>/dev/null || true
|
|
923
|
+
done <<EOF
|
|
924
|
+
$PLOT_PR_LISTING
|
|
925
|
+
EOF
|
|
926
|
+
# A listing that arrived with no rows is a real answer — a repository with no
|
|
927
|
+
# PRs — but it is indistinguishable here from a value truncated in transit, and
|
|
928
|
+
# the direction that errs costs a fabricated `NONE`. The MARKER carries the
|
|
929
|
+
# claim, so arrival decides and the row count does not.
|
|
930
|
+
[ "$arrived" = 1 ] || return 1
|
|
931
|
+
printf '1' > "$HOST_STATE_CACHE/.list-arrived" 2>/dev/null || true
|
|
932
|
+
[ "$complete" = 1 ] && { printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true; }
|
|
933
|
+
# `ok`, AND A NEW WORD HERE WOULD STOP THE FLEET. `reachFrom`
|
|
934
|
+
# (`entry/branch-state.ts:113`) maps any word it does not know to `failed`, so
|
|
935
|
+
# an invented `reused` would read as *the host could not be reached*: every
|
|
936
|
+
# branch with no ref would answer `unknown`, `--next` offers only `open`, and
|
|
937
|
+
# nothing would be handed out while the listing was being reused.
|
|
938
|
+
#
|
|
939
|
+
# `ok` IS ALSO THE TRUE ANSWER TO THE QUESTION THIS WORD ASKS. The verdict says
|
|
940
|
+
# how much evidence the scan holds about a branch, not how old the evidence is:
|
|
941
|
+
# a carried listing arrived and was whole, so it licenses exactly what a fetched
|
|
942
|
+
# one licenses. The AGE is the board's to report, because the board is what
|
|
943
|
+
# knows when it listed — this scan is spawned fresh and cannot know.
|
|
944
|
+
HOST_VERDICT=ok
|
|
945
|
+
return 0
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
# Report the listing this run fetched, for the next pulse to carry.
|
|
949
|
+
#
|
|
950
|
+
# ON STDERR AND TAGGED, exactly as the terminal map is reported: stdout is the
|
|
951
|
+
# scan's document and a reader of it must not have to know this exists. The board
|
|
952
|
+
# reads the tag; every other caller discards it with the scan's ordinary prose.
|
|
953
|
+
#
|
|
954
|
+
# REPORTED ONLY WHERE THE LISTING ARRIVED. A failed or throttled call writes no
|
|
955
|
+
# `.list-arrived`, and carrying its empty cache forward would turn one refusal
|
|
956
|
+
# into a listing the next pulse trusts.
|
|
957
|
+
report_listing() {
|
|
958
|
+
[ -n "$HOST_STATE_CACHE" ] || return 0
|
|
959
|
+
[ -f "$HOST_STATE_CACHE/.list-arrived" ] || return 0
|
|
960
|
+
printf 'listing: %s\t%s\t%s\t%s\n' .list-arrived 1 - - >&2
|
|
961
|
+
[ -f "$HOST_STATE_CACHE/.list-complete" ] \
|
|
962
|
+
&& printf 'listing: %s\t%s\t%s\t%s\n' .list-complete 1 - - >&2
|
|
963
|
+
# WHAT THE LISTING COST, MEASURED RATHER THAN MODELLED. The Bitbucket arm sweeps
|
|
964
|
+
# one request per tracked branch per state, and this scan is the only thing that
|
|
965
|
+
# knows how many branches it tracked. A board predicting the number from the
|
|
966
|
+
# plans would under-count: a plan names a subset of the remote refs, and the
|
|
967
|
+
# sweep asks about every one of them.
|
|
968
|
+
printf 'listing: %s\t%s\t%s\t%s\n' .branches \
|
|
969
|
+
"$(printf '%s' "$TRACKED_BRANCHES" | wc -w | tr -d ' ')" - - >&2
|
|
970
|
+
# One line per branch the listing answered for, in the cache's own key encoding
|
|
971
|
+
# and its own `STATE<TAB>checks<TAB>draft` shape, so what is carried back is what
|
|
972
|
+
# this run wrote and the carry path parses no JSON.
|
|
973
|
+
local f key rec _st _chk _dft
|
|
974
|
+
for f in "$HOST_STATE_CACHE"/*; do
|
|
975
|
+
[ -f "$f" ] || continue
|
|
976
|
+
key=${f##*/}
|
|
977
|
+
case "$key" in .*) continue ;; esac
|
|
978
|
+
rec=$(cat "$f" 2>/dev/null) || continue
|
|
979
|
+
IFS=" " read -r _st _chk _dft <<EOF
|
|
980
|
+
$rec
|
|
981
|
+
EOF
|
|
982
|
+
[ -n "$_st" ] || continue
|
|
983
|
+
# Empty fields are refilled with `-` so every field stays occupied: TAB is an
|
|
984
|
+
# IFS whitespace character, and `read` would otherwise slide the branch key
|
|
985
|
+
# along by one. The carry path translates them back, which is the same `-`
|
|
986
|
+
# sentinel the host payload's plain rows already use.
|
|
987
|
+
printf 'listing: %s\t%s\t%s\t%s\n' "$key" "$_st" "${_chk:--}" "${_dft:--}" >&2
|
|
988
|
+
done
|
|
989
|
+
}
|
|
990
|
+
|
|
826
991
|
prefill_pr_states() {
|
|
827
992
|
[ "$HOST_LOOKUP_OK" = 1 ] || return 0
|
|
828
993
|
[ -n "$HOST_STATE_CACHE" ] || return 0
|
|
994
|
+
# ASKED BEFORE THE HOST, and the only reason this function can cost nothing.
|
|
995
|
+
carried_listing && return 0
|
|
829
996
|
local js br st key rc
|
|
830
997
|
# Exit code first: non-zero is a transport failure and its stdout is not an
|
|
831
998
|
# answer. A failed list leaves the cache EMPTY, so every branch falls through
|
|
@@ -1070,19 +1237,44 @@ EOF
|
|
|
1070
1237
|
# were dropped above, so an `open` answer that is short — exit 7, or a sweep
|
|
1071
1238
|
# that did not state its completeness — leaves open PRs with no row at all,
|
|
1072
1239
|
# and completeness would turn those misses into `NONE`.
|
|
1240
|
+
# A STATED CLAIM OUTRANKS A RE-DERIVED ONE, and that is why this reads two
|
|
1241
|
+
# sentences before it counts anything. Either sentence is the adapter saying
|
|
1242
|
+
# the page was whole — `sweep complete` because every tracked branch was asked
|
|
1243
|
+
# and answered, `page complete` because the page came back short of a limit the
|
|
1244
|
+
# host honours. The row count below can only ever GUESS at the second, and on
|
|
1245
|
+
# this repository it guessed wrong: measured 2026-10-02, `--state all --limit
|
|
1246
|
+
# 1000` returned exactly 1000 rows of 1064 PRs, so `_pr_rows < PR_LIST_LIMIT`
|
|
1247
|
+
# was false, the marker was withheld, and 26 branches each cost one `pr-state`
|
|
1248
|
+
# call at 3.8 s — 54-61% of the scan's wall time across slice 1's five runs
|
|
1249
|
+
# (#1017). The adapter knew the page was capped and said so; nothing read it.
|
|
1250
|
+
#
|
|
1251
|
+
# BOTH PAGES MUST CLAIM IT, as the header above requires: the `all` payload's
|
|
1252
|
+
# OPEN rows were dropped, so a short `open` page is what keeps an open PR from
|
|
1253
|
+
# having no row at all, and completeness would turn those misses into `NONE`.
|
|
1254
|
+
# The two claims may arrive by different routes — a sweep for one state and a
|
|
1255
|
+
# short page for the other — and either pair licenses the marker, because each
|
|
1256
|
+
# sentence is the same assertion about its own page.
|
|
1257
|
+
#
|
|
1258
|
+
# THE ROW COUNT STAYS AS THE FALLBACK and is reached only when neither page
|
|
1259
|
+
# stated anything. An adapter that makes no claim is the case it was written
|
|
1260
|
+
# for, and it is still the weaker evidence: a count equal to the limit is
|
|
1261
|
+
# evidence of AT LEAST that many PRs, never of exactly that many. Withholding
|
|
1262
|
+
# the marker costs calls and never correctness, which is why every failure path
|
|
1263
|
+
# here does exactly that.
|
|
1073
1264
|
[ "$_v_open" = ok ] || return 0
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1265
|
+
_list_whole() { # $1=the stderr text of one pr-list call
|
|
1266
|
+
case "$1" in
|
|
1267
|
+
*"pr-list sweep complete"*|*"pr-list state="*" page complete "*) return 0 ;;
|
|
1268
|
+
esac
|
|
1269
|
+
return 1
|
|
1270
|
+
}
|
|
1271
|
+
if _list_whole "$host_err" && _list_whole "$open_err"; then
|
|
1272
|
+
printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true
|
|
1273
|
+
elif [ "$_pr_rows" -gt 0 ] && [ "$_pr_rows" -lt "$PR_LIST_LIMIT" ] \
|
|
1274
|
+
&& [ "$_pr_open_rows" -lt "$PR_LIST_LIMIT" ] 2>/dev/null; then
|
|
1275
|
+
printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true
|
|
1276
|
+
fi
|
|
1277
|
+
report_listing
|
|
1086
1278
|
}
|
|
1087
1279
|
prefill_pr_states
|
|
1088
1280
|
|
|
@@ -1349,10 +1541,7 @@ merged_by_host() { # $1=branch → 0 when the host reports its PR MERGED
|
|
|
1349
1541
|
# backend, a host returning 503 all afternoon — must not manufacture the state
|
|
1350
1542
|
# that tells a reader to stop looking. It falls through to the local signals,
|
|
1351
1543
|
# so a branch with work on the floor reads `stalled`: go and look. That is the
|
|
1352
|
-
# safe direction for an answer nobody could verify.
|
|
1353
|
-
reached_review() { # $1=branch → 0 when an open or merged PR exists
|
|
1354
|
-
case "$(host_pr_state "$1")" in OPEN|MERGED) return 0 ;; *) return 1 ;; esac
|
|
1355
|
-
}
|
|
1544
|
+
# safe direction for an answer nobody could verify. `worker_of` asks it.
|
|
1356
1545
|
|
|
1357
1546
|
# ---------------------------------------------------------------------------
|
|
1358
1547
|
# A SLICE THAT WAITS ON ANOTHER PLAN'S BRANCH
|
|
@@ -1385,9 +1574,9 @@ reached_review() { # $1=branch → 0 when an open or merged PR exists
|
|
|
1385
1574
|
# the branch it was cut from, which is why `plot-pr-merged.sh` reads PRs and not
|
|
1386
1575
|
# refs, and why this reads the same source.
|
|
1387
1576
|
#
|
|
1388
|
-
# WHAT THE HOST SAID, AND NOT WHAT IT MEANS.
|
|
1577
|
+
# WHAT THE HOST SAID, AND NOT WHAT IT MEANS. The prerequisite reading answered
|
|
1389
1578
|
# `waiting` / `blocked` / `""` until the derivation moved: the three answers and
|
|
1390
|
-
#
|
|
1579
|
+
# why only `NONE` on a name outside `SLICE_NAMES` means `blocked` are `waitVerdict` in
|
|
1391
1580
|
# `packages/domain/src/rules/branch-state.ts`, with a test per case. What stays
|
|
1392
1581
|
# here is the READING and the cost argument above it, which is a fact about
|
|
1393
1582
|
# this script's host budget rather than about what a wait means.
|
|
@@ -1396,9 +1585,8 @@ reached_review() { # $1=branch → 0 when an open or merged PR exists
|
|
|
1396
1585
|
# may legitimately omit: its plan may be delivered and its ref gone. The bound
|
|
1397
1586
|
# is the same one PR #216 set — ABSENT branches, not all branches — and the
|
|
1398
1587
|
# cache above keeps it at one call per run.
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
}
|
|
1588
|
+
# `host_pr_state "$name" --ask` → OPEN|MERGED|CLOSED|NONE|-, called once per
|
|
1589
|
+
# prerequisite name in the refill below.
|
|
1402
1590
|
|
|
1403
1591
|
# Modification time of a path, in epoch seconds, following symlinks — or "" when
|
|
1404
1592
|
# it cannot be read.
|
|
@@ -1807,7 +1995,7 @@ while IFS=$'\t' read -r wt_branch wt_path; do
|
|
|
1807
1995
|
# questions this block answers — *is anyone editing* and *when did the work
|
|
1808
1996
|
# last change* — then read one list, which is what stopped them drifting
|
|
1809
1997
|
# apart the last time.
|
|
1810
|
-
if [ -n "$(plot_worker_dirty_filter "$wt_status")" ]; then wt_dirty=true; else wt_dirty=false; fi
|
|
1998
|
+
if [ -n "$(plot_worker_dirty_filter "$wt_status" "$wt_path")" ]; then wt_dirty=true; else wt_dirty=false; fi
|
|
1811
1999
|
elif [ "$wt_locked" = true ]; then
|
|
1812
2000
|
# Status could not answer, but the lock says WHY, and that is an answer
|
|
1813
2001
|
# rather than the absence of one: a write is in progress in this worktree at
|
|
@@ -1843,7 +2031,7 @@ while IFS=$'\t' read -r wt_branch wt_path; do
|
|
|
1843
2031
|
# row after the first path with a tab in it. An integer cannot.
|
|
1844
2032
|
wt_changed=""
|
|
1845
2033
|
if [ -n "${wt_status:-}" ]; then
|
|
1846
|
-
wt_dirty_paths=$(plot_worker_dirty_filter "$wt_status")
|
|
2034
|
+
wt_dirty_paths=$(plot_worker_dirty_filter "$wt_status" "$wt_path")
|
|
1847
2035
|
if [ -n "$wt_dirty_paths" ]; then
|
|
1848
2036
|
wt_mtime_args=()
|
|
1849
2037
|
wt_n=0
|
|
@@ -1954,10 +2142,10 @@ worker_of() { # $1=branch → "state\tpid\texit"
|
|
|
1954
2142
|
#
|
|
1955
2143
|
# `$st` IS NOT THIS FACT. It answers a ref/ancestry question — a branch under
|
|
1956
2144
|
# review reads `wip` — and `merged` there can come from a merge subject with
|
|
1957
|
-
# no PR behind it at all.
|
|
2145
|
+
# no PR behind it at all. The PR state below is the one question that
|
|
1958
2146
|
# outranks the local signals: has this work left the worker's hands?
|
|
1959
2147
|
local pr_fact=""
|
|
1960
|
-
|
|
2148
|
+
case "$(host_pr_state "$br")" in OPEN|MERGED) pr_fact="pr" ;; esac
|
|
1961
2149
|
plot_worker_state "$wt" "$pr_fact"
|
|
1962
2150
|
}
|
|
1963
2151
|
|
|
@@ -2170,19 +2358,42 @@ changed_paths_of() { # $1=branch → changed paths, one per line (may be empty)
|
|
|
2170
2358
|
| head -n "$CHANGED_PATHS_LIMIT"
|
|
2171
2359
|
}
|
|
2172
2360
|
|
|
2173
|
-
# Did this branch land on the default branch?
|
|
2174
|
-
# keeps today's
|
|
2175
|
-
#
|
|
2176
|
-
#
|
|
2177
|
-
#
|
|
2178
|
-
#
|
|
2179
|
-
#
|
|
2180
|
-
#
|
|
2181
|
-
#
|
|
2182
|
-
#
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2361
|
+
# Did this branch land on the default branch, for THIS plan? A lookup in what
|
|
2362
|
+
# the rule already proved — positive evidence only, and absence keeps today's
|
|
2363
|
+
# answer.
|
|
2364
|
+
#
|
|
2365
|
+
# A LOOKUP, NOT A MATCH. The matching is `mergedBySubject`'s, asked once per
|
|
2366
|
+
# scan through `plot-merge-subject.mjs`; this reads the answer. The regex that
|
|
2367
|
+
# stood here read GitHub's form only and was one of three copies of it on the
|
|
2368
|
+
# estate — and being a regex it carried its own fault: a branch name may
|
|
2369
|
+
# legally hold `+`, `(` or `.`, and unescaped each one changes what the pattern
|
|
2370
|
+
# means, so `feature/v.1` matched `feature/vX1` while `bug/a+b` failed to match
|
|
2371
|
+
# its own subject. The rule matches the branch as literal text and cannot have
|
|
2372
|
+
# that fault at all.
|
|
2373
|
+
#
|
|
2374
|
+
# KEYED BY PLAN AND BRANCH, which the regex could not be. A later plan may
|
|
2375
|
+
# reuse a merged branch name — a reopened ticket does it, and one measured
|
|
2376
|
+
# estate holds 87 reused names — so one plan's proof must never settle
|
|
2377
|
+
# another's. `$SUBJECT_PROVEN` holds `<plan>\t<branch>` lines and this tests the
|
|
2378
|
+
# pair.
|
|
2379
|
+
merged_by_subject() { # $1=plan-base $2=branch → 0 when the rule proved the pair
|
|
2380
|
+
case "$SUBJECT_PROVEN" in
|
|
2381
|
+
*$'\n'"$1"$'\t'"$2"$'\n'*) return 0 ;;
|
|
2382
|
+
esac
|
|
2383
|
+
return 1
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
# Was a subject naming this branch REFUSED because its merge predates the plan?
|
|
2387
|
+
#
|
|
2388
|
+
# A SEPARATE QUESTION FROM THE ONE ABOVE, and the footer and the branch JSON
|
|
2389
|
+
# both report it: a slice with no subject and a slice whose subject was refused
|
|
2390
|
+
# for age both read `unknown` under a refused host, and only the second has an
|
|
2391
|
+
# explanation a reader can act on.
|
|
2392
|
+
subject_predates_plan() { # $1=plan-base $2=branch → 0 when a subject was refused
|
|
2393
|
+
case "$SUBJECT_IGNORED" in
|
|
2394
|
+
*$'\n'"$1"$'\t'"$2"$'\n'*) return 0 ;;
|
|
2395
|
+
esac
|
|
2396
|
+
return 1
|
|
2186
2397
|
}
|
|
2187
2398
|
|
|
2188
2399
|
# Is this branch's PR ready to merge — open, not draft, AND checks green?
|
|
@@ -2784,13 +2995,7 @@ add_ref_plan() { # $1=path in ref
|
|
|
2784
2995
|
# `$PLAN_DIR` by default, and their symlinks resolve to files already
|
|
2785
2996
|
# enumerated — counting both would double every plan. `git ls-tree` without
|
|
2786
2997
|
# `-r` lists one level, and the worktree glob `"$PLAN_DIR"*.md` does not
|
|
2787
|
-
# descend either.
|
|
2788
|
-
is_plan_phase() { # $1=normalized phase → 0 when this file is a plan
|
|
2789
|
-
case "$1" in
|
|
2790
|
-
""|NONE) return 1 ;;
|
|
2791
|
-
*) return 0 ;;
|
|
2792
|
-
esac
|
|
2793
|
-
}
|
|
2998
|
+
# descend either. `add_plan_by_phase` below applies the rule.
|
|
2794
2999
|
|
|
2795
3000
|
# ---------------------------------------------------------------------------
|
|
2796
3001
|
# ONE PARSE FOR THE WHOLE ESTATE
|
|
@@ -2847,6 +3052,36 @@ plan_meta_phases=()
|
|
|
2847
3052
|
plan_meta_types=()
|
|
2848
3053
|
plan_meta_waves=()
|
|
2849
3054
|
|
|
3055
|
+
# WHERE THE LAST LOOKUP LANDED, so the next one starts there instead of at 0.
|
|
3056
|
+
#
|
|
3057
|
+
# THE CALLERS ASK IN THE ORDER THE ESTATE WAS PARSED. `parse_plan_estate` is
|
|
3058
|
+
# handed `cand_reads` and the candidate loop at `:3415` then walks the SAME
|
|
3059
|
+
# array in the SAME order, so consecutive asks are consecutive entries and a
|
|
3060
|
+
# search that resumes finds its answer in one step. The scan's other two
|
|
3061
|
+
# callers walk `plans`, which is built from that same enumeration.
|
|
3062
|
+
#
|
|
3063
|
+
# IT IS A CURSOR AND NOT A CACHE. Nothing is remembered about any key; the
|
|
3064
|
+
# lookup still compares strings and still searches the whole array before it
|
|
3065
|
+
# answers "not parsed". Resuming changes WHERE the search starts, never what it
|
|
3066
|
+
# finds — a wrap brings it back to every entry it skipped.
|
|
3067
|
+
#
|
|
3068
|
+
# NO ASSOCIATIVE ARRAY, for the reason the header above gives: `/bin/bash` on
|
|
3069
|
+
# macOS is 3.2. A keyed map is the obvious index and `declare -A` would narrow
|
|
3070
|
+
# where Plot runs. Measured 2026-10-02 on this estate, the bash-3.2 string
|
|
3071
|
+
# idioms are all WORSE than the walk at this size: a `case` over a
|
|
3072
|
+
# newline-delimited index answers membership in 1 ms but `${s#*"$k"$'\t'}`
|
|
3073
|
+
# takes 2.9-22.8 s per call to recover the value, because a leading `*` makes
|
|
3074
|
+
# bash re-test the pattern at every offset of a 52 KB string. The cursor needs
|
|
3075
|
+
# no second structure at all.
|
|
3076
|
+
plan_meta_cursor=0
|
|
3077
|
+
|
|
3078
|
+
# Whether any path was parsed twice. A cursor that starts mid-array would return
|
|
3079
|
+
# the LATER copy of a duplicated path, and the walk this replaces always
|
|
3080
|
+
# returned the first — so a duplicate turns the cursor off and the walk runs
|
|
3081
|
+
# from 0, exactly as it did before. Computed ONCE per parse, three forks, 177 ms
|
|
3082
|
+
# at 416 entries; a per-lookup check would cost the walk it is meant to avoid.
|
|
3083
|
+
plan_meta_dup=0
|
|
3084
|
+
|
|
2850
3085
|
# Parses every plan file given, filling the four arrays above. Called ONCE.
|
|
2851
3086
|
parse_plan_estate() { # $@=files to parse
|
|
2852
3087
|
[ $# -gt 0 ] || return 0
|
|
@@ -2889,15 +3124,23 @@ for line in sys.stdin:
|
|
|
2889
3124
|
# run of tabs collapses to one separator and only the LAST field
|
|
2890
3125
|
# may be optional. "-" stands in for empty everywhere, so no run
|
|
2891
3126
|
# can form.
|
|
2892
|
-
# THE
|
|
2893
|
-
# never re-parsed from the annotation. The key is ABSENT on
|
|
2894
|
-
# declares nothing (`plot-plan-meta.sh` promises "a
|
|
2895
|
-
#
|
|
2896
|
-
#
|
|
3127
|
+
# THE PREREQUISITES, from the `waits_on` key the parser emits —
|
|
3128
|
+
# never re-parsed from the annotation. The key is a LIST, ABSENT on
|
|
3129
|
+
# a branch that declares nothing (`plot-plan-meta.sh` promises "a
|
|
3130
|
+
# list of one or more names or no key at all — never []"), joined
|
|
3131
|
+
# with commas into this one column: `entry/branch-state.ts` reads
|
|
3132
|
+
# field 9 as a comma-separated name list, the shape it already
|
|
3133
|
+
# expects for the parallel PR-state list in field 10. "-" stands in
|
|
3134
|
+
# for absence here for the same tab-collapse reason every other
|
|
3135
|
+
# middle column does. A plan parsed by an OLD parser still emits a
|
|
3136
|
+
# bare string for this key, which ",".join would shred into
|
|
3137
|
+
# letters -- isinstance guards it, so that shape still passes
|
|
3138
|
+
# through as one name.
|
|
3139
|
+
waits_on = b.get("waits_on")
|
|
2897
3140
|
print("\t".join(clean(x) for x in [
|
|
2898
3141
|
"W", f, str(i), ref, str(b.get("deferred")).lower(),
|
|
2899
3142
|
(b.get("deferred_reason") or "-"),
|
|
2900
|
-
(
|
|
3143
|
+
(",".join(waits_on) if isinstance(waits_on, list) else waits_on) or "-",
|
|
2901
3144
|
name or "-", b.get("claimed") or "-"]))
|
|
2902
3145
|
' 2>/dev/null) || records=""
|
|
2903
3146
|
|
|
@@ -2926,27 +3169,106 @@ for line in sys.stdin:
|
|
|
2926
3169
|
;;
|
|
2927
3170
|
esac
|
|
2928
3171
|
done <<< "$records"
|
|
3172
|
+
|
|
3173
|
+
# ONCE PER PARSE, over the whole array — a second `parse_plan_estate` call
|
|
3174
|
+
# EXTENDS the arrays, so a path it adds may duplicate one the first call
|
|
3175
|
+
# stored and the question has to be re-asked of everything.
|
|
3176
|
+
#
|
|
3177
|
+
# A duplicate is not expected: the enumeration lists each file once. It is
|
|
3178
|
+
# possible — a slug reachable both through the active index and through the
|
|
3179
|
+
# plan directory resolves to one file by two paths — and the walk's answer
|
|
3180
|
+
# for it was the FIRST index, which `:3598` and `:4149` then use to read
|
|
3181
|
+
# `plan_meta_waves`. Guessing here would hand a caller another plan's waves.
|
|
3182
|
+
plan_meta_dup=0
|
|
3183
|
+
if [ ${#plan_meta_files[@]} -gt 1 ]; then
|
|
3184
|
+
if printf '%s\n' "${plan_meta_files[@]}" | sort | uniq -d | grep -q .; then
|
|
3185
|
+
plan_meta_dup=1
|
|
3186
|
+
fi
|
|
3187
|
+
fi
|
|
3188
|
+
plan_meta_cursor=0
|
|
2929
3189
|
}
|
|
2930
3190
|
|
|
2931
3191
|
# The index of a parsed file in the arrays above, or "" when it was not parsed
|
|
2932
|
-
# (an unreadable file, or one the helper could not decode).
|
|
2933
|
-
#
|
|
2934
|
-
#
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
3192
|
+
# (an unreadable file, or one the helper could not decode).
|
|
3193
|
+
#
|
|
3194
|
+
# THE SEARCH RESUMES WHERE THE LAST ONE STOPPED. It was a walk from 0 on every
|
|
3195
|
+
# call, and `add_plan_by_phase` asks it once per CANDIDATE file under the plan
|
|
3196
|
+
# directory — 416 here — so the cost was quadratic in the estate. Measured
|
|
3197
|
+
# 2026-10-02 under `--offline` on this estate: 1248 calls walking 260,208
|
|
3198
|
+
# entries between them, 208.5 per call, which is half the array and exactly
|
|
3199
|
+
# what a from-0 walk costs when the answers are spread through it. Slice 1's
|
|
3200
|
+
# `PS4` trace put the line at 294.4 s over ~345,000 gaps, the largest single
|
|
3201
|
+
# shell cost in the scan.
|
|
3202
|
+
#
|
|
3203
|
+
# **1245 of those 1248 calls were HITS**, not misses: in ref mode the estate
|
|
3204
|
+
# parse covers every candidate, so almost every ask finds its file. That is why
|
|
3205
|
+
# the hit path is what had to get cheaper, and why a membership test alone
|
|
3206
|
+
# would not have helped.
|
|
3207
|
+
#
|
|
3208
|
+
# ONE STEP PER ASK IN THE COMMON CASE. The callers ask in parse order, so the
|
|
3209
|
+
# entry after the last answer is usually the next answer. Measured over all 416
|
|
3210
|
+
# candidates in order: 0.31 s against 5.17 s for the from-0 walk, 16.5x.
|
|
3211
|
+
#
|
|
3212
|
+
# IT STILL SEARCHES EVERYTHING BEFORE ANSWERING "NO". The loop runs for the
|
|
3213
|
+
# array's whole length and wraps, so an ask out of order costs what it always
|
|
3214
|
+
# did and an unparsed file is still reported absent. Only the STARTING POINT
|
|
3215
|
+
# changed.
|
|
3216
|
+
#
|
|
3217
|
+
# ABSENT IS NOT FALSE. A file that was not parsed yields "", which every caller
|
|
3218
|
+
# already reads as "not a plan" (`:3547`, `:4096`). It must never be `0` — that
|
|
3219
|
+
# is the FIRST plan's index — and the function keeps exiting 0 on a miss, since
|
|
3220
|
+
# the callers test the string and not the status.
|
|
3221
|
+
#
|
|
3222
|
+
# THE KEY IS THE STORED STRING, compared with `[ = ]`. A `P` row's file passed
|
|
3223
|
+
# through `clean()`, which turns tabs and newlines into spaces, and
|
|
3224
|
+
# `plan_meta_files` holds that cleaned form; a caller's raw path that the clean
|
|
3225
|
+
# would have changed was never findable and stays unfindable. The compare is
|
|
3226
|
+
# literal, so a path holding `[`, `*` or `?` matches itself and never a glob.
|
|
3227
|
+
# THE SEARCH ITSELF, assigning to `plan_meta_index_reply`. Run it in the shell
|
|
3228
|
+
# whose cursor should advance.
|
|
3229
|
+
plan_meta_index_reply=""
|
|
3230
|
+
plan_meta_index_into() { # $1=file → sets plan_meta_index_reply
|
|
3231
|
+
plan_meta_index_reply=""
|
|
3232
|
+
local n=${#plan_meta_files[@]}
|
|
3233
|
+
[ "$n" -gt 0 ] || return 0
|
|
3234
|
+
local i k=0
|
|
3235
|
+
# A DUPLICATED PATH TURNS THE CURSOR OFF. The walk returned the lowest index
|
|
3236
|
+
# that equalled the file; resuming mid-array could return a later copy, so
|
|
3237
|
+
# this falls back to the from-0 walk and keeps the old answer.
|
|
3238
|
+
if [ "$plan_meta_dup" = 1 ]; then
|
|
3239
|
+
for i in "${!plan_meta_files[@]}"; do
|
|
3240
|
+
if [ "${plan_meta_files[$i]}" = "$1" ]; then plan_meta_index_reply="$i"; return 0; fi
|
|
3241
|
+
done
|
|
3242
|
+
return 0
|
|
3243
|
+
fi
|
|
3244
|
+
i=$plan_meta_cursor
|
|
3245
|
+
while [ "$k" -lt "$n" ]; do
|
|
3246
|
+
if [ "${plan_meta_files[$i]}" = "$1" ]; then
|
|
3247
|
+
# The NEXT entry, because the next ask is usually the next candidate.
|
|
3248
|
+
plan_meta_cursor=$(( (i + 1) % n ))
|
|
3249
|
+
plan_meta_index_reply="$i"
|
|
3250
|
+
return 0
|
|
3251
|
+
fi
|
|
3252
|
+
i=$(( (i + 1) % n ))
|
|
3253
|
+
k=$((k + 1))
|
|
2939
3254
|
done
|
|
2940
|
-
printf ''
|
|
2941
3255
|
}
|
|
2942
3256
|
|
|
2943
|
-
# The phase a file declares, or "" when it is not a plan
|
|
2944
|
-
# estate parse above rather than
|
|
2945
|
-
|
|
3257
|
+
# The phase a file declares, or "" when it is not a plan, ASSIGNED to
|
|
3258
|
+
# `plan_phase_reply`. Read from the single estate parse above rather than
|
|
3259
|
+
# spawned per file.
|
|
3260
|
+
#
|
|
3261
|
+
# NO STDOUT FORM. A subshell's write to `plan_meta_cursor` is discarded when it
|
|
3262
|
+
# exits, so a `$(…)` reader would restart the search from 0 on every call. Every
|
|
3263
|
+
# caller of this and of `plan_meta_index_into` runs in the scan's own shell.
|
|
3264
|
+
plan_phase_reply=""
|
|
3265
|
+
plan_phase_into() { # $1=file to parse → sets plan_phase_reply
|
|
3266
|
+
plan_phase_reply=""
|
|
2946
3267
|
local i
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
3268
|
+
plan_meta_index_into "$1"
|
|
3269
|
+
i="$plan_meta_index_reply"
|
|
3270
|
+
[ -n "$i" ] || return 0
|
|
3271
|
+
plan_phase_reply="${plan_meta_phases[$i]}"
|
|
2950
3272
|
}
|
|
2951
3273
|
|
|
2952
3274
|
# A terminal phase belongs to the delivered group: the plan is finished, and it
|
|
@@ -2963,6 +3285,80 @@ is_terminal_phase() { # $1=normalized phase → 0 when finished
|
|
|
2963
3285
|
esac
|
|
2964
3286
|
}
|
|
2965
3287
|
|
|
3288
|
+
# EVERY PLAN FILE OF THE ESTATE, into `cand_ids` (identity) and `cand_reads`
|
|
3289
|
+
# (the file to parse): the plan directory of `origin/$MAIN`, then each prefixed
|
|
3290
|
+
# branch's plans the default branch does not carry. A named slug reads one plan
|
|
3291
|
+
# and still enumerates the estate, because `SLICE_NAMES` is the estate's set.
|
|
3292
|
+
enumerate_estate() {
|
|
3293
|
+
cand_ids=()
|
|
3294
|
+
cand_reads=()
|
|
3295
|
+
if [ "$PLAN_SOURCE" = "ref" ]; then
|
|
3296
|
+
while IFS= read -r plan_path; do
|
|
3297
|
+
[ -n "$plan_path" ] || continue
|
|
3298
|
+
plan_blob=$(ref_plan_file "$plan_path") || continue
|
|
3299
|
+
[ -n "$plan_blob" ] || continue
|
|
3300
|
+
cand_ids+=("$plan_path")
|
|
3301
|
+
cand_reads+=("$plan_blob")
|
|
3302
|
+
done <<< "$(ref_ls "$PLAN_DIR")"
|
|
3303
|
+
|
|
3304
|
+
# THEN EACH PREFIXED BRANCH'S OWN TREE, for the plans `origin/$MAIN` does
|
|
3305
|
+
# not carry. Appended to the SAME candidate arrays, before the one
|
|
3306
|
+
# `parse_plan_estate` call below: a second call would build a second
|
|
3307
|
+
# `plan_meta_files` index and the lookup at the row loop keys on the file
|
|
3308
|
+
# path, so the branch plans would parse and then be unfindable.
|
|
3309
|
+
#
|
|
3310
|
+
# From here the existing pipeline carries the plan unchanged — it names its
|
|
3311
|
+
# branch in `## Slices`, the wave walk finds it, and the branch stops
|
|
3312
|
+
# reaching the report through the plan-less loop in `fleet.ts`.
|
|
3313
|
+
#
|
|
3314
|
+
# THE DEDUP IS THE BOARD'S, COPIED RATHER THAN RE-DERIVED. `on_default` is
|
|
3315
|
+
# every plan path the default branch carries; `seen_branch_plans` is every
|
|
3316
|
+
# path already taken from an earlier branch. Two branches cut from one point
|
|
3317
|
+
# carry the SAME plan file, and without the second test one plan reports as
|
|
3318
|
+
# several — a regression the board measured and fixed, and the reason its
|
|
3319
|
+
# comment exists.
|
|
3320
|
+
#
|
|
3321
|
+
# WHICH BRANCHES: `REMOTE_REFS`, already read once above, filtered by
|
|
3322
|
+
# `PREFIX_RE` — the same population the board calls a prefixed branch. No
|
|
3323
|
+
# second `for-each-ref`. The narrowing to PR-less branches the slice line
|
|
3324
|
+
# offered was WITHDRAWN by the plan's Design section: it was a fallback
|
|
3325
|
+
# against a cost that does not exist. Measured 2026-09-24, one `ls-tree`
|
|
3326
|
+
# over the plan directory is ~0.00 s and the whole addition 0.24 s, 0.4% of
|
|
3327
|
+
# a scan whose wall time is 95% waiting.
|
|
3328
|
+
on_default=$'\n'"$(ref_ls "$PLAN_DIR")"$'\n'
|
|
3329
|
+
seen_branch_plans=$'\n'
|
|
3330
|
+
while IFS=$'\t' read -r branch_name _branch_sha; do
|
|
3331
|
+
[ -n "$branch_name" ] || continue
|
|
3332
|
+
[ "$branch_name" = "HEAD" ] && continue
|
|
3333
|
+
[ "$branch_name" = "$MAIN" ] && continue
|
|
3334
|
+
printf '%s' "$branch_name" | grep -Eq "^($PREFIX_RE)/" || continue
|
|
3335
|
+
while IFS= read -r bp; do
|
|
3336
|
+
[ -n "$bp" ] || continue
|
|
3337
|
+
case "$on_default" in *$'\n'"$bp"$'\n'*) continue ;; esac
|
|
3338
|
+
case "$seen_branch_plans" in *$'\n'"$bp"$'\n'*) continue ;; esac
|
|
3339
|
+
plan_blob=$(branch_plan_file "$branch_name" "$bp") || continue
|
|
3340
|
+
[ -n "$plan_blob" ] || continue
|
|
3341
|
+
# MARKED SEEN ONLY ONCE IT IS TAKEN, matching `board.ts:823`: a blob
|
|
3342
|
+
# that could not be read has not been reported, so a later branch
|
|
3343
|
+
# carrying a readable copy of the same path must still get its turn.
|
|
3344
|
+
seen_branch_plans="${seen_branch_plans}${bp}"$'\n'
|
|
3345
|
+
# THE IDENTITY STAYS THE RELATIVE PATH. The row loop parses
|
|
3346
|
+
# `plan_reads[i]` and never re-reads by the identity in `plans[i]`, so
|
|
3347
|
+
# the `docs/plans/…md` path is a usable id — and the dedup above is what
|
|
3348
|
+
# guarantees it cannot collide with a default-branch plan's.
|
|
3349
|
+
cand_ids+=("$bp")
|
|
3350
|
+
cand_reads+=("$plan_blob")
|
|
3351
|
+
done <<< "$(branch_plan_paths "$branch_name")"
|
|
3352
|
+
done <<< "$REMOTE_REFS"
|
|
3353
|
+
else
|
|
3354
|
+
for plan_path in "$PLAN_DIR"*.md; do
|
|
3355
|
+
[ -e "$plan_path" ] || continue
|
|
3356
|
+
cand_ids+=("$plan_path")
|
|
3357
|
+
cand_reads+=("$plan_path")
|
|
3358
|
+
done
|
|
3359
|
+
fi
|
|
3360
|
+
}
|
|
3361
|
+
|
|
2966
3362
|
if [ -n "$slug" ]; then
|
|
2967
3363
|
# A NAMED SLUG IS NOT A LIST, so it keeps its own resolution: the caller
|
|
2968
3364
|
# already said which plan it means, and the phase rule would only be able to
|
|
@@ -2997,7 +3393,18 @@ if [ -n "$slug" ]; then
|
|
|
2997
3393
|
# because that is where every later question reads its answer from.
|
|
2998
3394
|
if [ ${#plans[@]} -gt 0 ]; then
|
|
2999
3395
|
parse_plan_estate "${plan_reads[0]}"
|
|
3000
|
-
|
|
3396
|
+
plan_phase_into "${plan_reads[0]}"
|
|
3397
|
+
plan_phases+=("$plan_phase_reply")
|
|
3398
|
+
fi
|
|
3399
|
+
# THE WHOLE ESTATE TOO, when a `waits:` name will be looked up in
|
|
3400
|
+
# `SLICE_NAMES` below: a prerequisite that is a slice of ANOTHER plan must
|
|
3401
|
+
# read the same here as on a full scan (#1305). The plan loop reads only
|
|
3402
|
+
# `plans`, so the extra plans change no row. Asked only when needed, because
|
|
3403
|
+
# it is the cost: measured 2026-10-06 on this estate (ref mode, `--offline`),
|
|
3404
|
+
# a slug scan took 1.6 s alone and 5.2 s with the estate enumerated.
|
|
3405
|
+
if [ "$slice_names" = 1 ] || printf '%s' ${plan_meta_waves[@]+"${plan_meta_waves[@]}"} | awk -F'\t' '$5 != "-" { w = 1 } END { exit !w }'; then
|
|
3406
|
+
enumerate_estate
|
|
3407
|
+
[ ${#cand_reads[@]} -gt 0 ] && parse_plan_estate "${cand_reads[@]}"
|
|
3001
3408
|
fi
|
|
3002
3409
|
else
|
|
3003
3410
|
# ---------------------------------------------------------------------------
|
|
@@ -3058,8 +3465,13 @@ else
|
|
|
3058
3465
|
# result, so no path below asks the format contract the same question twice.
|
|
3059
3466
|
add_plan_by_phase() { # $1=identity path, $2=file to parse
|
|
3060
3467
|
local id="$1" src="$2" ph
|
|
3061
|
-
|
|
3062
|
-
|
|
3468
|
+
# NO COMMAND SUBSTITUTION. This runs once per candidate file — 416 on this
|
|
3469
|
+
# estate — and `$(…)` would both fork a subshell per call and discard the
|
|
3470
|
+
# lookup cursor's advance, which is the whole saving.
|
|
3471
|
+
plan_phase_into "$src"
|
|
3472
|
+
ph="$plan_phase_reply"
|
|
3473
|
+
# NOT A PLAN: no phase parsed — see "What makes a file a plan" above.
|
|
3474
|
+
case "$ph" in ""|NONE) return 0 ;; esac
|
|
3063
3475
|
if is_terminal_phase "$ph"; then
|
|
3064
3476
|
[ "$next_only" = 1 ] && return 0
|
|
3065
3477
|
terminal_plans+=("$id")
|
|
@@ -3077,73 +3489,7 @@ else
|
|
|
3077
3489
|
# ref mode that means every blob is materialized first: the phase decides the
|
|
3078
3490
|
# group, so the file must exist before it can be asked, and it must be asked
|
|
3079
3491
|
# together with all the others rather than one at a time.
|
|
3080
|
-
|
|
3081
|
-
cand_reads=()
|
|
3082
|
-
if [ "$PLAN_SOURCE" = "ref" ]; then
|
|
3083
|
-
while IFS= read -r plan_path; do
|
|
3084
|
-
[ -n "$plan_path" ] || continue
|
|
3085
|
-
plan_blob=$(ref_plan_file "$plan_path") || continue
|
|
3086
|
-
[ -n "$plan_blob" ] || continue
|
|
3087
|
-
cand_ids+=("$plan_path")
|
|
3088
|
-
cand_reads+=("$plan_blob")
|
|
3089
|
-
done <<< "$(ref_ls "$PLAN_DIR")"
|
|
3090
|
-
|
|
3091
|
-
# THEN EACH PREFIXED BRANCH'S OWN TREE, for the plans `origin/$MAIN` does
|
|
3092
|
-
# not carry. Appended to the SAME candidate arrays, before the one
|
|
3093
|
-
# `parse_plan_estate` call below: a second call would build a second
|
|
3094
|
-
# `plan_meta_files` index and the lookup at the row loop keys on the file
|
|
3095
|
-
# path, so the branch plans would parse and then be unfindable.
|
|
3096
|
-
#
|
|
3097
|
-
# From here the existing pipeline carries the plan unchanged — it names its
|
|
3098
|
-
# branch in `## Slices`, the wave walk finds it, and the branch stops
|
|
3099
|
-
# reaching the report through the plan-less loop in `fleet.ts`.
|
|
3100
|
-
#
|
|
3101
|
-
# THE DEDUP IS THE BOARD'S, COPIED RATHER THAN RE-DERIVED. `on_default` is
|
|
3102
|
-
# every plan path the default branch carries; `seen_branch_plans` is every
|
|
3103
|
-
# path already taken from an earlier branch. Two branches cut from one point
|
|
3104
|
-
# carry the SAME plan file, and without the second test one plan reports as
|
|
3105
|
-
# several — a regression the board measured and fixed, and the reason its
|
|
3106
|
-
# comment exists.
|
|
3107
|
-
#
|
|
3108
|
-
# WHICH BRANCHES: `REMOTE_REFS`, already read once above, filtered by
|
|
3109
|
-
# `PREFIX_RE` — the same population the board calls a prefixed branch. No
|
|
3110
|
-
# second `for-each-ref`. The narrowing to PR-less branches the slice line
|
|
3111
|
-
# offered was WITHDRAWN by the plan's Design section: it was a fallback
|
|
3112
|
-
# against a cost that does not exist. Measured 2026-09-24, one `ls-tree`
|
|
3113
|
-
# over the plan directory is ~0.00 s and the whole addition 0.24 s, 0.4% of
|
|
3114
|
-
# a scan whose wall time is 95% waiting.
|
|
3115
|
-
on_default=$'\n'"$(ref_ls "$PLAN_DIR")"$'\n'
|
|
3116
|
-
seen_branch_plans=$'\n'
|
|
3117
|
-
while IFS=$'\t' read -r branch_name _branch_sha; do
|
|
3118
|
-
[ -n "$branch_name" ] || continue
|
|
3119
|
-
[ "$branch_name" = "HEAD" ] && continue
|
|
3120
|
-
[ "$branch_name" = "$MAIN" ] && continue
|
|
3121
|
-
printf '%s' "$branch_name" | grep -Eq "^($PREFIX_RE)/" || continue
|
|
3122
|
-
while IFS= read -r bp; do
|
|
3123
|
-
[ -n "$bp" ] || continue
|
|
3124
|
-
case "$on_default" in *$'\n'"$bp"$'\n'*) continue ;; esac
|
|
3125
|
-
case "$seen_branch_plans" in *$'\n'"$bp"$'\n'*) continue ;; esac
|
|
3126
|
-
plan_blob=$(branch_plan_file "$branch_name" "$bp") || continue
|
|
3127
|
-
[ -n "$plan_blob" ] || continue
|
|
3128
|
-
# MARKED SEEN ONLY ONCE IT IS TAKEN, matching `board.ts:823`: a blob
|
|
3129
|
-
# that could not be read has not been reported, so a later branch
|
|
3130
|
-
# carrying a readable copy of the same path must still get its turn.
|
|
3131
|
-
seen_branch_plans="${seen_branch_plans}${bp}"$'\n'
|
|
3132
|
-
# THE IDENTITY STAYS THE RELATIVE PATH. The row loop parses
|
|
3133
|
-
# `plan_reads[i]` and never re-reads by the identity in `plans[i]`, so
|
|
3134
|
-
# the `docs/plans/…md` path is a usable id — and the dedup above is what
|
|
3135
|
-
# guarantees it cannot collide with a default-branch plan's.
|
|
3136
|
-
cand_ids+=("$bp")
|
|
3137
|
-
cand_reads+=("$plan_blob")
|
|
3138
|
-
done <<< "$(branch_plan_paths "$branch_name")"
|
|
3139
|
-
done <<< "$REMOTE_REFS"
|
|
3140
|
-
else
|
|
3141
|
-
for plan_path in "$PLAN_DIR"*.md; do
|
|
3142
|
-
[ -e "$plan_path" ] || continue
|
|
3143
|
-
cand_ids+=("$plan_path")
|
|
3144
|
-
cand_reads+=("$plan_path")
|
|
3145
|
-
done
|
|
3146
|
-
fi
|
|
3492
|
+
enumerate_estate
|
|
3147
3493
|
|
|
3148
3494
|
# ONE INVOCATION FOR THE WHOLE ESTATE. Everything below reads its result.
|
|
3149
3495
|
[ ${#cand_reads[@]} -gt 0 ] && parse_plan_estate "${cand_reads[@]}"
|
|
@@ -3159,6 +3505,19 @@ else
|
|
|
3159
3505
|
done
|
|
3160
3506
|
fi
|
|
3161
3507
|
|
|
3508
|
+
# EVERY SLICE SOMEBODY MAY STILL START, newline-framed for a `case` lookup: the
|
|
3509
|
+
# non-deferred slices (wave field 3) of every parsed plan whose phase is not
|
|
3510
|
+
# terminal. Every mode parses the whole estate, so a slug run, `--next` and the
|
|
3511
|
+
# full scan hold one set, and `plot-dispatch.sh` asks `--slice-names` for it
|
|
3512
|
+
# rather than reading its own plan. A `waits:` name in the set with no pull
|
|
3513
|
+
# request reads `waiting`; any other name with none reads `blocked` (#1305).
|
|
3514
|
+
# `namedSlices` in `rules/branch-state.ts` states the same rule, and
|
|
3515
|
+
# `branch-state.corpus.test.ts` holds the two to one answer.
|
|
3516
|
+
live_waves=()
|
|
3517
|
+
for _si in "${!plan_meta_files[@]}"; do is_terminal_phase "${plan_meta_phases[$_si]}" || live_waves+=("${plan_meta_waves[$_si]}"); done
|
|
3518
|
+
SLICE_NAMES=$'\n'"$(printf '%s' ${live_waves[@]+"${live_waves[@]}"} | awk -F'\t' '$3 != "true" && !seen[$2]++ { print $2 }')"$'\n'
|
|
3519
|
+
[ "$slice_names" = 1 ] && { printf '%s' "$SLICE_NAMES" | sed '/^$/d'; exit 0; }
|
|
3520
|
+
|
|
3162
3521
|
if [ ${#plans[@]} -eq 0 ]; then
|
|
3163
3522
|
# --next/--list-eligible must stay silent and exit 1: "nothing to start" is
|
|
3164
3523
|
# the same answer whether the plans are all claimed or there are no plans at
|
|
@@ -3195,6 +3554,209 @@ if [ ${#plans[@]} -eq 0 ]; then
|
|
|
3195
3554
|
fi
|
|
3196
3555
|
fi
|
|
3197
3556
|
|
|
3557
|
+
# ---------------------------------------------------------------------------
|
|
3558
|
+
# The merge subjects, asked of the rule once the plans are known
|
|
3559
|
+
# ---------------------------------------------------------------------------
|
|
3560
|
+
#
|
|
3561
|
+
# WHY HERE AND NOT WITH THE WALK ABOVE. The rule is asked per plan, with that
|
|
3562
|
+
# plan's own refless branches and the commit that added its file — and neither
|
|
3563
|
+
# is known until the estate is parsed. The walk itself still happens once, up
|
|
3564
|
+
# at `MERGE_SUBJECTS`; this is where its lines are USED.
|
|
3565
|
+
#
|
|
3566
|
+
# THREE READINGS PER SCAN, NONE PER PLAN. The per-plan lookup this replaces
|
|
3567
|
+
# cost 6 to 37 ms per plan, 5.23 s for 140 plans here; the batched walk below
|
|
3568
|
+
# costs 0.10 s for 392 plan files, 0.02 s on the estate that reported #1139.
|
|
3569
|
+
#
|
|
3570
|
+
# 1. one `git log --diff-filter=AR --name-status` for every plan file's
|
|
3571
|
+
# adding commit (here)
|
|
3572
|
+
# 2. the merges walk, already run above with `%H %s`
|
|
3573
|
+
# 3. one `git merge-base --is-ancestor` per MATCHED PAIR, which is a few calls
|
|
3574
|
+
# per scan rather than one per plan
|
|
3575
|
+
#
|
|
3576
|
+
# THE BUNDLE IS ASKED TWICE, with reading 3 between the calls: the age rule
|
|
3577
|
+
# needs an ancestry answer and the domain may not run git, so the first call
|
|
3578
|
+
# names the pairs needing a test and the second applies the rule to the
|
|
3579
|
+
# answers. The decision stays in the bundle; this shell only reads git.
|
|
3580
|
+
SUBJECT_PROVEN=$'\n'
|
|
3581
|
+
SUBJECT_IGNORED=$'\n'
|
|
3582
|
+
SUBJECT_PREDATES_PLAN=0
|
|
3583
|
+
|
|
3584
|
+
# The commit that first added each plan file, as `<path>\t<sha>` lines.
|
|
3585
|
+
#
|
|
3586
|
+
# `--diff-filter=AR` AND THE RENAME FOLLOW. With git's default rename
|
|
3587
|
+
# detection a renamed dated file is listed `R` and never `A`, so an `A`-only
|
|
3588
|
+
# walk leaves the current path out of the answer and the plan gets no subjects
|
|
3589
|
+
# — measured on `origin/main`, 4 of 392 dated plans have no `A` entry for their
|
|
3590
|
+
# current path, all renamed while Draft. Each `R` is followed back to the old
|
|
3591
|
+
# path's `A` in the SAME call, at no extra cost, so a retitled plan keeps the
|
|
3592
|
+
# commit that first added it.
|
|
3593
|
+
#
|
|
3594
|
+
# ON `origin/<main>`, NEVER `HEAD`. This runs in desks and on feature branches,
|
|
3595
|
+
# where `HEAD` holds another history — and the merges walk reads the same ref,
|
|
3596
|
+
# so a mismatch would compare an age from one history with a merge from
|
|
3597
|
+
# another.
|
|
3598
|
+
#
|
|
3599
|
+
# THE DATED FILE, NEVER THE SYMLINK under `active/` or `delivered/`. A delivery
|
|
3600
|
+
# moves the symlink as a git rename, so the symlink's adding commit is the
|
|
3601
|
+
# delivery commit: measured on the estate that reported #1139, one delivered
|
|
3602
|
+
# plan keeps 4 of 4 subjects through its target and 0 of 4 through its symlink,
|
|
3603
|
+
# because one bulk move re-added 13 symlinks at once.
|
|
3604
|
+
plan_adding_commits() { # → <path>\t<sha> per plan file
|
|
3605
|
+
git log "origin/$MAIN" --diff-filter=AR --name-status --format=@%H \
|
|
3606
|
+
-- "$PLAN_DIR" </dev/null 2>/dev/null \
|
|
3607
|
+
| awk '
|
|
3608
|
+
/^@/ { commit = substr($0, 2); next }
|
|
3609
|
+
# An A line gives a path and the commit that added it. The walk runs
|
|
3610
|
+
# newest-first, so the LAST A seen for a path is the oldest — which is the
|
|
3611
|
+
# first add, and the age the rule wants.
|
|
3612
|
+
/^A\t/ { add[$2] = commit; next }
|
|
3613
|
+
# An R line maps an old path (field 2) to a new one (field 3). Recorded as
|
|
3614
|
+
# a chain and resolved after the walk: a rename may be seen before the add
|
|
3615
|
+
# of the path it renames from.
|
|
3616
|
+
/^R/ { from[$3] = $2; next }
|
|
3617
|
+
END {
|
|
3618
|
+
for (path in add) resolved[path] = add[path]
|
|
3619
|
+
# Follow each chain to a path with an A line. Bounded by the chain
|
|
3620
|
+
# length, and a cycle cannot form — a rename always names an earlier
|
|
3621
|
+
# path, and `seen` stops one anyway.
|
|
3622
|
+
for (path in from) {
|
|
3623
|
+
cur = path
|
|
3624
|
+
delete seen
|
|
3625
|
+
while (cur in from && !(cur in seen)) { seen[cur] = 1; cur = from[cur] }
|
|
3626
|
+
if (cur in add) resolved[path] = add[cur]
|
|
3627
|
+
# A chain ending in no A line inside this walk leaves the path
|
|
3628
|
+
# unresolved, so its plan gets no subjects and its branches go to the
|
|
3629
|
+
# host — stated in the plan as the limit it is.
|
|
3630
|
+
}
|
|
3631
|
+
for (path in resolved) printf "%s\t%s\n", path, resolved[path]
|
|
3632
|
+
}'
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
# What the rule was asked, assembled once and reused for both calls.
|
|
3636
|
+
subject_readings() {
|
|
3637
|
+
printf '@backend %s\n' "$HOST_BACKEND"
|
|
3638
|
+
printf '@origin %s\n' "$ORIGIN_URL"
|
|
3639
|
+
printf '@merges\n'
|
|
3640
|
+
[ -n "$MERGE_SUBJECTS" ] && printf '%s\n' "$MERGE_SUBJECTS"
|
|
3641
|
+
printf '%s' "$SUBJECT_PLAN_SECTIONS"
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
ask_merge_subject() { # $1=verb; stdin=readings → the rule's answer
|
|
3645
|
+
node "$script_dir/board/plot-merge-subject.mjs" "$1" 2>/dev/null
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
# AN EMPTY WALK IS A MEASUREMENT, AND IT ANSWERS `none`.
|
|
3649
|
+
#
|
|
3650
|
+
# A squash or rebase estate leaves no merge commit at all, so `$MERGE_SUBJECTS`
|
|
3651
|
+
# is empty — and that is the walk having run and found nothing, which is exactly
|
|
3652
|
+
# what `none` says. `unaskable` must mean only *the rule could not be asked*, or
|
|
3653
|
+
# the footer stops telling a reader the two apart; the asking side of that
|
|
3654
|
+
# distinction is this script's and the found/not-found side is the bundle's.
|
|
3655
|
+
#
|
|
3656
|
+
# Measured by CI on the first run of this slice: the guard below read
|
|
3657
|
+
# `[ -n "$MERGE_SUBJECTS" ]` and a fixture with no merges reported
|
|
3658
|
+
# `merge_detect=unaskable`, which claims the question was never put about an
|
|
3659
|
+
# estate the walk had just examined.
|
|
3660
|
+
if [ -z "$MERGE_SUBJECTS" ]; then
|
|
3661
|
+
MERGE_DETECT=none
|
|
3662
|
+
fi
|
|
3663
|
+
|
|
3664
|
+
# Only where there is something for the rule to match. `--offline` and `--no-pr`
|
|
3665
|
+
# do not gate this: the walks are LOCAL, they cost no host call, and a subject is
|
|
3666
|
+
# the one proof an offline scan can still have.
|
|
3667
|
+
if [ ${#plans[@]} -gt 0 ] && [ -n "$MERGE_SUBJECTS" ]; then
|
|
3668
|
+
# The origin URL, read once. The rule parses it — this script never does, so
|
|
3669
|
+
# the owner is read one way by the scan and the supervisor alike.
|
|
3670
|
+
ORIGIN_URL=$(git config --get remote.origin.url 2>/dev/null || echo "")
|
|
3671
|
+
HOST_BACKEND=$("$script_dir/plot-host.sh" backend 2>/dev/null || echo "")
|
|
3672
|
+
|
|
3673
|
+
SUBJECT_ADDING=$'\n'"$(plan_adding_commits)"$'\n'
|
|
3674
|
+
|
|
3675
|
+
# One section per plan: its dated file, its adding commit, and its refless
|
|
3676
|
+
# branches. A branch WITH a ref is never offered — the ref check stays in
|
|
3677
|
+
# front, and a recreated branch carrying new work must not be settled by the
|
|
3678
|
+
# first attempt's subject.
|
|
3679
|
+
SUBJECT_PLAN_SECTIONS=""
|
|
3680
|
+
for _sp_i in "${!plans[@]}"; do
|
|
3681
|
+
_sp_plan="${plans[$_sp_i]}"
|
|
3682
|
+
_sp_base=$(basename "$(readlink "$_sp_plan" 2>/dev/null || echo "$_sp_plan")")
|
|
3683
|
+
_sp_path="$PLAN_DIR$_sp_base"
|
|
3684
|
+
_sp_added=""
|
|
3685
|
+
case "$SUBJECT_ADDING" in
|
|
3686
|
+
*$'\n'"$_sp_path"$'\t'*)
|
|
3687
|
+
_sp_added=$(printf '%s' "$SUBJECT_ADDING" \
|
|
3688
|
+
| awk -F'\t' -v p="$_sp_path" '$1 == p { print $2; exit }') ;;
|
|
3689
|
+
esac
|
|
3690
|
+
# KEYED ON THE FILE THAT WAS PARSED, which is what `plan_meta_files` holds
|
|
3691
|
+
# — `$plan_reads[i]`, the same key the row loop uses at pass 1a. In ref mode
|
|
3692
|
+
# that is a materialized blob under a temp path and not `$PLAN_DIR` at all,
|
|
3693
|
+
# so a reconstructed path finds nothing.
|
|
3694
|
+
plan_meta_index_into "${plan_reads[$_sp_i]}"
|
|
3695
|
+
_sp_meta_i=$plan_meta_index_reply
|
|
3696
|
+
[ -n "$_sp_meta_i" ] || continue
|
|
3697
|
+
_sp_branches=""
|
|
3698
|
+
while IFS=$'\t' read -r _sp_idx _sp_br _sp_rest; do
|
|
3699
|
+
[ -n "$_sp_br" ] || continue
|
|
3700
|
+
remote_ref_exists "$_sp_br" && continue
|
|
3701
|
+
_sp_branches+="$_sp_br"$'\n'
|
|
3702
|
+
done <<< "${plan_meta_waves[$_sp_meta_i]}"
|
|
3703
|
+
[ -n "$_sp_branches" ] || continue
|
|
3704
|
+
# THE BASENAME IS THE KEY, because that is what the row loop holds as
|
|
3705
|
+
# `$plan_base` and what the lookup tests. The adding-commit walk keys on
|
|
3706
|
+
# the full path, which is what git reports — the two are joined here, once,
|
|
3707
|
+
# rather than at every lookup.
|
|
3708
|
+
SUBJECT_PLAN_SECTIONS+="@plan $_sp_base"$'\n'
|
|
3709
|
+
SUBJECT_PLAN_SECTIONS+="@added ${_sp_added:--}"$'\n'
|
|
3710
|
+
SUBJECT_PLAN_SECTIONS+="$_sp_branches"
|
|
3711
|
+
done
|
|
3712
|
+
|
|
3713
|
+
if [ -n "$SUBJECT_PLAN_SECTIONS" ]; then
|
|
3714
|
+
# CALL 1: which pairs need an ancestry test.
|
|
3715
|
+
_sp_pairs=$(subject_readings | ask_merge_subject pairs || echo "")
|
|
3716
|
+
# THE READING, one per matched pair. The age rule in
|
|
3717
|
+
# plot-merge-subject.mjs is what decides what it means.
|
|
3718
|
+
_sp_answers=""
|
|
3719
|
+
while IFS=$'\t' read -r _sp_pplan _sp_pbr _sp_merge _sp_added2; do
|
|
3720
|
+
[ -n "$_sp_merge" ] || continue
|
|
3721
|
+
# A wrong "contained" sends the branch to the host; a wrong "not
|
|
3722
|
+
# contained" is the reused-name case this narrows.
|
|
3723
|
+
#
|
|
3724
|
+
# plot-ancestry: evidence — handed to `proofOf` in
|
|
3725
|
+
# plot-merge-subject.mjs, which decides and
|
|
3726
|
+
# reads `unknown` as proving nothing.
|
|
3727
|
+
if git merge-base --is-ancestor "$_sp_merge" "$_sp_added2" </dev/null 2>/dev/null; then
|
|
3728
|
+
_sp_answers+="@ancestry $_sp_merge $_sp_added2 yes"$'\n'
|
|
3729
|
+
elif git cat-file -e "$_sp_merge^{commit}" </dev/null 2>/dev/null \
|
|
3730
|
+
&& git cat-file -e "$_sp_added2^{commit}" </dev/null 2>/dev/null; then
|
|
3731
|
+
_sp_answers+="@ancestry $_sp_merge $_sp_added2 no"$'\n'
|
|
3732
|
+
else
|
|
3733
|
+
# A commit this checkout cannot read answers neither way, and the rule
|
|
3734
|
+
# reads `unknown` as proving nothing.
|
|
3735
|
+
_sp_answers+="@ancestry $_sp_merge $_sp_added2 unknown"$'\n'
|
|
3736
|
+
fi
|
|
3737
|
+
done <<< "$_sp_pairs"
|
|
3738
|
+
|
|
3739
|
+
# CALL 2: the proof, given the answers.
|
|
3740
|
+
while IFS=$'\t' read -r _sp_word _sp_a _sp_b _sp_c; do
|
|
3741
|
+
case "$_sp_word" in
|
|
3742
|
+
proven) SUBJECT_PROVEN+="$_sp_a"$'\t'"$_sp_b"$'\n' ;;
|
|
3743
|
+
ignored)
|
|
3744
|
+
SUBJECT_IGNORED+="$_sp_a"$'\t'"$_sp_b"$'\n'
|
|
3745
|
+
SUBJECT_PREDATES_PLAN=$((SUBJECT_PREDATES_PLAN + 1)) ;;
|
|
3746
|
+
detect)
|
|
3747
|
+
# `truncated` is THIS script's reading and outranks `pr-merge`: the
|
|
3748
|
+
# bundle cannot see the cap, and a capped walk detected but did not
|
|
3749
|
+
# examine exhaustively.
|
|
3750
|
+
if [ "$_sp_a" = "pr-merge" ] && [ "$MERGE_SCAN_TRUNCATED" = 1 ]; then
|
|
3751
|
+
MERGE_DETECT=truncated
|
|
3752
|
+
else
|
|
3753
|
+
MERGE_DETECT="$_sp_a"
|
|
3754
|
+
fi ;;
|
|
3755
|
+
esac
|
|
3756
|
+
done <<< "$(printf '%s\n%s' "$(subject_readings)" "$_sp_answers" | ask_merge_subject proven || echo "")"
|
|
3757
|
+
fi
|
|
3758
|
+
fi
|
|
3759
|
+
|
|
3198
3760
|
# A branch is merged when its remote ref is an ancestor of origin/<main>, or —
|
|
3199
3761
|
# once the ref is gone — when the default branch carries a conforming PR-merge
|
|
3200
3762
|
# commit naming it (see "the evidence that survives the ref" above). An absent
|
|
@@ -3316,8 +3878,9 @@ EOF
|
|
|
3316
3878
|
MAIN_TIP=$(remote_ref_oid "$MAIN")
|
|
3317
3879
|
[ -n "$MAIN_TIP" ] || MAIN_TIP="-"
|
|
3318
3880
|
|
|
3319
|
-
branch_readings() { # $1=branch $2=deferred →
|
|
3320
|
-
local br="$1" _bs_deferred="$2" _bs_subject=false
|
|
3881
|
+
branch_readings() { # $1=branch $2=deferred $3=plan-base → readings
|
|
3882
|
+
local br="$1" _bs_deferred="$2" _bs_plan="${3:-}" _bs_subject=false
|
|
3883
|
+
local _bs_ahead=0 _bs_real=0 _bs_tip
|
|
3321
3884
|
local _bs_main="$MAIN_TIP"
|
|
3322
3885
|
# THE REF CHECK STAYS IN FRONT. DO NOT HOIST THE MERGE LOOKUP ABOVE IT.
|
|
3323
3886
|
#
|
|
@@ -3350,7 +3913,17 @@ branch_readings() { # $1=branch $2=deferred → eight tab-separated readings
|
|
|
3350
3913
|
# exists — squash merges, a hand-rewritten subject, a branch genuinely
|
|
3351
3914
|
# never started — today's `open` stands. The evidence may only move a branch
|
|
3352
3915
|
# from `open` to `merged`, and only when it is positive.
|
|
3353
|
-
|
|
3916
|
+
# THE PAIR, not the branch alone. `$_bs_plan` is what stops one plan's
|
|
3917
|
+
# proof settling another plan's reused name — the case round 1 executed,
|
|
3918
|
+
# where an unstarted reused name read `merged`, its slice read complete,
|
|
3919
|
+
# the next slice opened, and the reused slice was never offered.
|
|
3920
|
+
merged_by_subject "$_bs_plan" "$br" && _bs_subject=true
|
|
3921
|
+
# A subject the age rule refused is reported apart from one that never
|
|
3922
|
+
# existed: both read `unknown` under a refused host, and only this one has
|
|
3923
|
+
# an explanation.
|
|
3924
|
+
_bs_predates=false
|
|
3925
|
+
[ "$_bs_subject" = false ] && subject_predates_plan "$_bs_plan" "$br" \
|
|
3926
|
+
&& _bs_predates=true
|
|
3354
3927
|
# No merge commit names it — which is the ordinary case under a squash
|
|
3355
3928
|
# merge, not an exotic one. The local walk is now out of evidence, so the
|
|
3356
3929
|
# host is asked. It may only ever move this branch from `open` to `merged`:
|
|
@@ -3375,7 +3948,20 @@ branch_readings() { # $1=branch $2=deferred → eight tab-separated readings
|
|
|
3375
3948
|
# what travels: the rule tells `CLOSED` from `NONE` from `-`, and a boolean
|
|
3376
3949
|
# cannot. `|| true` because a not-merged answer is an ordinary reading and
|
|
3377
3950
|
# `set -e` must not read it as a failure.
|
|
3378
|
-
|
|
3951
|
+
#
|
|
3952
|
+
# NOT ASKED FOR A BRANCH THE SUBJECT ALREADY PROVED. For the wave gate the
|
|
3953
|
+
# subject IS enough — settling a slice and opening the next is reversible
|
|
3954
|
+
# work — and asking anyway is the spend #1139 reports: a host refusing
|
|
3955
|
+
# every question, asked again on a 5-second pulse about a branch already
|
|
3956
|
+
# proven.
|
|
3957
|
+
#
|
|
3958
|
+
# THE ONE EXCEPTION IS A DELIVERY CANDIDATE, and it is asked in PASS 1e of
|
|
3959
|
+
# the plan loop rather than here. Candidacy needs every branch's DECIDED
|
|
3960
|
+
# state, which exists only after the rule has answered for the whole plan,
|
|
3961
|
+
# and this function runs per branch in a subshell before that.
|
|
3962
|
+
if [ "$_bs_subject" = false ]; then
|
|
3963
|
+
merged_by_host "$br" || true
|
|
3964
|
+
fi
|
|
3379
3965
|
# `open` IS A CLAIM ABOUT A PR: that one was looked for and none was found.
|
|
3380
3966
|
# With no ref, the host is the only remaining source, so when it could not
|
|
3381
3967
|
# be asked that claim was never earned — and the branch measured on
|
|
@@ -3528,7 +4114,7 @@ branch_readings() { # $1=branch $2=deferred → eight tab-separated readings
|
|
|
3528
4114
|
# There is no shell fallback: a second implementation kept "just in case" is
|
|
3529
4115
|
# the duplication this adoption removes, and it would be the copy nobody tests.
|
|
3530
4116
|
# `plot-deliver.sh` fails the same way for the same reason.
|
|
3531
|
-
ask_branch_states() { # stdin=readings → one `state<TAB>needs` line per branch
|
|
4117
|
+
ask_branch_states() { # stdin=readings → one `state<TAB>needs<TAB>own` line per branch
|
|
3532
4118
|
node "$script_dir/board/plot-branch-state.mjs" 2>/dev/null \
|
|
3533
4119
|
|| { echo "error: cannot read branch states — run 'pnpm build:board'." >&2; exit 2; }
|
|
3534
4120
|
}
|
|
@@ -3646,7 +4232,8 @@ for plan in "${plans[@]}"; do
|
|
|
3646
4232
|
# The estate was parsed ONCE, before this loop. A plan absent from that
|
|
3647
4233
|
# result could not be read at all, which is the same answer the per-plan
|
|
3648
4234
|
# parse gave by failing — so it is skipped here exactly as it was then.
|
|
3649
|
-
|
|
4235
|
+
plan_meta_index_into "$plan_read"
|
|
4236
|
+
meta_i=$plan_meta_index_reply
|
|
3650
4237
|
[ -n "$meta_i" ] || continue
|
|
3651
4238
|
|
|
3652
4239
|
# The plan's own phase, carried onto the pulse so a consumer can derive a row
|
|
@@ -3758,13 +4345,20 @@ for plan in "${plans[@]}"; do
|
|
|
3758
4345
|
# not evidence in either direction. The two were one marker until CI ran the
|
|
3759
4346
|
# corpus with no token, where every prerequisite answers `-` and every waiting
|
|
3760
4347
|
# branch read `open`. Reading it
|
|
3761
|
-
# costs a host round trip (`
|
|
4348
|
+
# costs a host round trip (`host_pr_state … --ask`, because a delivered
|
|
3762
4349
|
# prerequisite's ref is gone and only its PR outlives it), and the scan spends
|
|
3763
4350
|
# that only where the answer could change the branch's state. Which states
|
|
3764
4351
|
# those are IS the precedence, so the rule reports it rather than this loop
|
|
3765
4352
|
# deciding it — see pass 1c.
|
|
3766
4353
|
readings=""
|
|
3767
4354
|
order=""
|
|
4355
|
+
# WHICH BRANCHES THE HOST ITSELF CALLED MERGED, recorded here because
|
|
4356
|
+
# `_merged_by_host_state` is a single variable that pass 1a overwrites per
|
|
4357
|
+
# branch — by the time the JSON emitter runs it holds the LAST branch's
|
|
4358
|
+
# answer. The `evidence` field needs to know, per branch, whether the host
|
|
4359
|
+
# confirmed the landing, so the answer is kept while it is still the right
|
|
4360
|
+
# one.
|
|
4361
|
+
host_merged_branches=$'\n'
|
|
3768
4362
|
# THE ELEVENTH FIELD: whether the PR list held every PR (`.list-complete`,
|
|
3769
4363
|
# written by `prefill_pr_states`). The rule reads a `NONE` for a ref behind
|
|
3770
4364
|
# main as `open` only when it is true; a capped list may omit a merged PR.
|
|
@@ -3775,7 +4369,15 @@ for plan in "${plans[@]}"; do
|
|
|
3775
4369
|
# "-" is the absent marker the shim writes, for the tab-collapse reason
|
|
3776
4370
|
# above. Normalized here so everything downstream tests emptiness.
|
|
3777
4371
|
[ "$waits" = "-" ] && waits=""
|
|
3778
|
-
readings+="$(branch_readings "$br" "$deferred") ${waits:--} ? $list_complete"$'\n'
|
|
4372
|
+
readings+="$(branch_readings "$br" "$deferred" "$plan_base") ${waits:--} ? $list_complete ?"$'\n'
|
|
4373
|
+
# Read from the readings line just built rather than from the variable: the
|
|
4374
|
+
# subshell `branch_readings` runs in cannot export it back.
|
|
4375
|
+
# FIELD 6 is the host's own word, in both arms of `branch_readings`:
|
|
4376
|
+
# `$_merged_by_host_state` for a refless branch and `host_pr_state` for one
|
|
4377
|
+
# with a ref. Field 9 is `waits`, appended by this loop.
|
|
4378
|
+
case "$(printf '%s' "$readings" | tail -n1 | cut -f6)" in
|
|
4379
|
+
MERGED) host_merged_branches+="$br"$'\n' ;;
|
|
4380
|
+
esac
|
|
3779
4381
|
order+="$idx $br $deferred $why ${waits:--} $wname $claim"$'\n'
|
|
3780
4382
|
done <<< "$wave_lines"
|
|
3781
4383
|
|
|
@@ -3821,7 +4423,7 @@ for plan in "${plans[@]}"; do
|
|
|
3821
4423
|
while IFS= read -r rd_line; do
|
|
3822
4424
|
[ -n "$rd_line" ] || continue
|
|
3823
4425
|
answer_i=$((answer_i + 1))
|
|
3824
|
-
IFS=$'\t' read -r _st needs \
|
|
4426
|
+
IFS=$'\t' read -r _st needs _ \
|
|
3825
4427
|
<<< "$(printf '%s\n' "$branch_answers" | sed -n "${answer_i}p")"
|
|
3826
4428
|
waits_br=$(printf '%s' "$rd_line" | cut -f9)
|
|
3827
4429
|
if [ "$needs" = "1" ] && [ "$waits_br" != "-" ]; then
|
|
@@ -3830,8 +4432,23 @@ for plan in "${plans[@]}"; do
|
|
|
3830
4432
|
# list may legitimately omit: its plan may be delivered and its ref gone.
|
|
3831
4433
|
# `host_pr_state`'s run cache keeps this at one call per prerequisite per
|
|
3832
4434
|
# run, never one per pass.
|
|
3833
|
-
#
|
|
3834
|
-
|
|
4435
|
+
#
|
|
4436
|
+
# FIELD 9 IS A LIST, comma-joined, so field 10 answers each prerequisite
|
|
4437
|
+
# IN THE SAME ORDER — one `host_pr_state … --ask` call per name, joined the same
|
|
4438
|
+
# way. `entry/branch-state.ts` reads the two columns as parallel lists of
|
|
4439
|
+
# equal length and throws otherwise, so a single answer for several names
|
|
4440
|
+
# would desync them. Field 11 is carried, unreplaced. Field 12 says, in
|
|
4441
|
+
# the same order, whether `SLICE_NAMES` holds each name.
|
|
4442
|
+
#
|
|
4443
|
+
# A NAMED SLICE WITH NO REF IS STILL ASKED. Its ref may be gone because it
|
|
4444
|
+
# merged and `plot-release-refs.sh` reaped it, and only the host's MERGED
|
|
4445
|
+
# clears the wait; skipping the call would hold the branch forever.
|
|
4446
|
+
waits_state="" waits_named=""
|
|
4447
|
+
for _wn in ${waits_br//,/ }; do
|
|
4448
|
+
waits_state+="${waits_state:+,}$(host_pr_state "$_wn" --ask)"
|
|
4449
|
+
case "$SLICE_NAMES" in *$'\n'"$_wn"$'\n'*) waits_named+="${waits_named:+,}true" ;; *) waits_named+="${waits_named:+,}false" ;; esac
|
|
4450
|
+
done
|
|
4451
|
+
refill+="$(printf '%s' "$rd_line" | cut -f1-9) $waits_state $(printf '%s' "$rd_line" | cut -f11) $waits_named"$'\n'
|
|
3835
4452
|
else
|
|
3836
4453
|
refill+="$rd_line"$'\n'
|
|
3837
4454
|
fi
|
|
@@ -3849,15 +4466,56 @@ for plan in "${plans[@]}"; do
|
|
|
3849
4466
|
# record is re-read by two more `read` loops below, and an EMPTY middle
|
|
3850
4467
|
# column collapses its tab into its neighbour's and shifts every later
|
|
3851
4468
|
# field left. `$claim` is the only field allowed to be last and optional.
|
|
4469
|
+
# `$own` is the branch's state before its prerequisite, the third answer column.
|
|
3852
4470
|
states=""
|
|
3853
4471
|
answer_i=0
|
|
3854
4472
|
while IFS=$'\t' read -r idx br deferred why waits wname claim; do
|
|
3855
4473
|
[ -n "$br" ] || continue
|
|
3856
4474
|
answer_i=$((answer_i + 1))
|
|
3857
|
-
st
|
|
3858
|
-
states+="$idx $br $st $deferred $why $waits $wname $claim"$'\n'
|
|
4475
|
+
IFS=$'\t' read -r st _ own <<< "$(printf '%s\n' "$branch_answers" | sed -n "${answer_i}p")"
|
|
4476
|
+
states+="$idx $br $st $deferred $why $waits $wname ${own:-$st} $claim"$'\n'
|
|
3859
4477
|
done <<< "$order"
|
|
3860
4478
|
|
|
4479
|
+
# PASS 1e: A DELIVERY CANDIDATE ASKS THE HOST about its subject-proven branches.
|
|
4480
|
+
#
|
|
4481
|
+
# A candidate is an Approved plan whose every non-deferred branch reads
|
|
4482
|
+
# `merged`. Its next step is a delivery, and `allSlicesConfirmed` refuses a
|
|
4483
|
+
# delivery while any branch carries `evidence: subject`. So for a candidate,
|
|
4484
|
+
# and only under `HOST_VERDICT` `ok` or `partial`, `merged_by_host` is asked
|
|
4485
|
+
# about each branch the subject alone proved. A refused host is not asked:
|
|
4486
|
+
# that spend is the condition #1139 reports, and the branch keeps
|
|
4487
|
+
# `evidence: subject`.
|
|
4488
|
+
#
|
|
4489
|
+
# IN THE MAIN SHELL, not in `branch_readings`. That function runs per branch
|
|
4490
|
+
# in a subshell before any state is decided, so it cannot know whether the
|
|
4491
|
+
# plan is a candidate. Measured 2026-10-02: its `$4` was never passed, 32 of
|
|
4492
|
+
# 39 merged branches on this estate read `evidence: subject`, and auto-deliver
|
|
4493
|
+
# held every plan whose branch refs were deleted at merge.
|
|
4494
|
+
#
|
|
4495
|
+
# The terminal cache bounds the cost: a MERGED answer is kept per tip of
|
|
4496
|
+
# `origin/<main>`, so a candidate is asked once per tip, not once per pulse.
|
|
4497
|
+
if [ "$plan_phase" = approved ]; then
|
|
4498
|
+
case "$HOST_VERDICT" in
|
|
4499
|
+
ok|partial)
|
|
4500
|
+
cand=true
|
|
4501
|
+
cand_proven=""
|
|
4502
|
+
while IFS=$'\t' read -r _ c_br c_st c_deferred _; do
|
|
4503
|
+
[ -n "$c_br" ] || continue
|
|
4504
|
+
if [ "$c_deferred" = true ]; then continue; fi
|
|
4505
|
+
[ "$c_st" = merged ] || { cand=false; break; }
|
|
4506
|
+
case "$host_merged_branches" in *$'\n'"$c_br"$'\n'*) continue ;; esac
|
|
4507
|
+
if merged_by_subject "$plan_base" "$c_br"; then cand_proven+="$c_br"$'\n'; fi
|
|
4508
|
+
done <<< "$states"
|
|
4509
|
+
if [ "$cand" = true ]; then
|
|
4510
|
+
while IFS= read -r c_br; do
|
|
4511
|
+
[ -n "$c_br" ] || continue
|
|
4512
|
+
if merged_by_host "$c_br"; then host_merged_branches+="$c_br"$'\n'; fi
|
|
4513
|
+
done <<< "$cand_proven"
|
|
4514
|
+
fi
|
|
4515
|
+
;;
|
|
4516
|
+
esac
|
|
4517
|
+
fi
|
|
4518
|
+
|
|
3861
4519
|
# Pass 2a: what each wave HOLDS — how many of its non-deferred branches have
|
|
3862
4520
|
# not settled. A reading, and the whole of what this script contributes to the
|
|
3863
4521
|
# verdict: which branches count as settled depends on `--loose` and on a host
|
|
@@ -3874,7 +4532,7 @@ for plan in "${plans[@]}"; do
|
|
|
3874
4532
|
outstanding=0
|
|
3875
4533
|
_loose_degraded_branches=""
|
|
3876
4534
|
wave_states=""
|
|
3877
|
-
while IFS=$'\t' read -r idx br st deferred why waits nm claim; do
|
|
4535
|
+
while IFS=$'\t' read -r idx br st deferred why waits nm _ claim; do
|
|
3878
4536
|
[ "$idx" = "$wid" ] || continue
|
|
3879
4537
|
# EVERY branch, including the deferred ones, and in the order the render
|
|
3880
4538
|
# loop below will walk them — the claimable flags come back positionally,
|
|
@@ -3973,24 +4631,30 @@ for plan in "${plans[@]}"; do
|
|
|
3973
4631
|
# `deferred` to tell a branch that will never move from one that has not
|
|
3974
4632
|
# moved yet.
|
|
3975
4633
|
outlook_branches=""
|
|
3976
|
-
while IFS=$'\t' read -r idx br st deferred why waits nm claim; do
|
|
4634
|
+
while IFS=$'\t' read -r idx br st deferred why waits nm own claim; do
|
|
3977
4635
|
[ "$idx" = "$wid" ] || continue
|
|
3978
4636
|
outlook_branches+="${outlook_branches:+|}$br:$st"
|
|
3979
4637
|
[ "$claim" = "-" ] && claim=""
|
|
3980
4638
|
[ "$why" = "-" ] && why=""
|
|
3981
4639
|
[ "$waits" = "-" ] && waits=""
|
|
4640
|
+
# THE NOTE NAMES EVERY PREREQUISITE, not just one: `$waits` is comma-joined
|
|
4641
|
+
# with no space (the shim's separator), and the sentence reads "a, b" —
|
|
4642
|
+
# with one name both forms are byte-identical. The scan has the VERDICT
|
|
4643
|
+
# and not the per-prerequisite answer here, so `blocked`'s sentence names
|
|
4644
|
+
# every prerequisite rather than which one lacks a PR.
|
|
4645
|
+
waits_prose="${waits//,/, }"
|
|
3982
4646
|
n_branches=$((n_branches + 1))
|
|
3983
4647
|
case "$st" in
|
|
3984
4648
|
# WHAT IT WAITS ON, NAMED. A bare `waiting` tells a reader to come back
|
|
3985
4649
|
# later without saying what would have to happen first, which is the
|
|
3986
4650
|
# whole of what this state adds over `open`.
|
|
3987
4651
|
waiting) n_waiting=$((n_waiting + 1))
|
|
3988
|
-
note="waiting on $
|
|
3989
|
-
# A PREREQUISITE NOBODY DECLARED
|
|
3990
|
-
#
|
|
3991
|
-
#
|
|
4652
|
+
note="waiting on $waits_prose" ;;
|
|
4653
|
+
# A PREREQUISITE NOBODY DECLARED: no plan names it as a slice, and the
|
|
4654
|
+
# host answered that no PR exists for it. A host that could not be
|
|
4655
|
+
# asked, or a named slice nobody has started, reads `waiting` instead.
|
|
3992
4656
|
blocked) n_prereq_missing=$((n_prereq_missing + 1))
|
|
3993
|
-
note="blocked — no PR found for $
|
|
4657
|
+
note="blocked — no PR found for $waits_prose" ;;
|
|
3994
4658
|
# The REASON, where the plan recorded one. A bare `deferred` beside a
|
|
3995
4659
|
# branch with no commits reads as two unrelated facts when the first is
|
|
3996
4660
|
# the reason for the second, and the sentence that says so was already
|
|
@@ -4032,21 +4696,49 @@ for plan in "${plans[@]}"; do
|
|
|
4032
4696
|
# The INTERNAL state ($st), never the prose label ($note): the board
|
|
4033
4697
|
# must not parse a string that exists for humans to read.
|
|
4034
4698
|
json_branches+="${json_branches:+,}{\"branch\":\"$(json_str "$br")\""
|
|
4035
|
-
json_branches+=",\"state\":\"$st\",\"deferred\":$deferred"
|
|
4699
|
+
json_branches+=",\"state\":\"$st\",\"own_state\":\"$own\",\"deferred\":$deferred"
|
|
4700
|
+
# WHAT PROVED THE LANDING, where the proof is weaker than the host's.
|
|
4701
|
+
#
|
|
4702
|
+
# EMITTED ONLY WHERE THE BRANCH READS `merged` AND THE HOST HAS NOT
|
|
4703
|
+
# CONFIRMED IT. `merged_by_host` leaves its own word in
|
|
4704
|
+
# `_merged_by_host_state`, and the scan does not even ask for a proven
|
|
4705
|
+
# branch that is not a delivery candidate — so the absence of a host
|
|
4706
|
+
# `MERGED` is what the field reports. The branch loses the field the
|
|
4707
|
+
# moment the host answers merged.
|
|
4708
|
+
#
|
|
4709
|
+
# A QUALIFIER, NOT A STATE. The state stays `merged`, the wave
|
|
4710
|
+
# arithmetic is unmoved and the next slice still opens; what reads this
|
|
4711
|
+
# is `allSlicesConfirmed`, in front of a delivery, which is the one
|
|
4712
|
+
# decision here that cannot be undone.
|
|
4713
|
+
if [ "$st" = merged ] && merged_by_subject "$plan_base" "$br" \
|
|
4714
|
+
&& [ "${host_merged_branches#*$'\n'"$br"$'\n'}" = "$host_merged_branches" ]; then
|
|
4715
|
+
json_branches+=",\"evidence\":\"subject\""
|
|
4716
|
+
fi
|
|
4717
|
+
# WHY A SUBJECT NAMING THIS BRANCH PROVED NOTHING. A reused name whose
|
|
4718
|
+
# merge predates the plan: the slice reads as it would with no subject
|
|
4719
|
+
# at all, and this is what tells a reader the two cases apart.
|
|
4720
|
+
if subject_predates_plan "$plan_base" "$br"; then
|
|
4721
|
+
json_branches+=",\"subjectIgnored\":\"predates-plan\""
|
|
4722
|
+
fi
|
|
4036
4723
|
# WHY it was deferred, straight from the plan's annotation. "" where the
|
|
4037
4724
|
# branch is not deferred, and "" where it is deferred with nothing
|
|
4038
4725
|
# recorded — the flag says which of those two a reader is looking at.
|
|
4039
4726
|
json_branches+=",\"deferred_reason\":\"$(json_str "$why")\""
|
|
4040
4727
|
# WHAT THIS BRANCH WAITS ON, straight from the plan's `waits:`
|
|
4041
|
-
# annotation.
|
|
4042
|
-
# every branch
|
|
4728
|
+
# annotation. `[]` where the branch declares nothing — `FleetBranchSchema`
|
|
4729
|
+
# carries the key on every branch since #1253, so absence is an empty
|
|
4730
|
+
# list here rather than a missing key, unlike the parser's own
|
|
4731
|
+
# `waits_on` which omits the key entirely.
|
|
4043
4732
|
#
|
|
4044
4733
|
# THE ANNOTATION, NOT THE VERDICT, and it is emitted whatever `state`
|
|
4045
4734
|
# says. A branch whose prerequisite has MERGED reports `waits_on` with
|
|
4046
4735
|
# its ordinary state — the declaration is still a fact about the plan,
|
|
4047
4736
|
# and a reader who sees a cleared dependency learns why the slice is
|
|
4048
4737
|
# now startable. Consumers test `state`, never the presence of this.
|
|
4049
|
-
|
|
4738
|
+
#
|
|
4739
|
+
# `$waits` IS COMMA-JOINED NAMES, never PR states: one name per line
|
|
4740
|
+
# for `json_array`, which escapes each.
|
|
4741
|
+
json_branches+=",\"waits_on\":$(json_array "${waits//,/$'\n'}")"
|
|
4050
4742
|
json_branches+=",\"claimed\":\"$(json_str "$claim")\""
|
|
4051
4743
|
# What this machine knows and the refs do not. Absent everywhere else:
|
|
4052
4744
|
# `local_dirty:false` and `local_worktree:""` are what a branch checked
|
|
@@ -4674,4 +5366,9 @@ if [ "$build_doc" = 1 ] && [ "$record" = 1 ] && [ -n "$reading_doc" ]; then
|
|
|
4674
5366
|
fi
|
|
4675
5367
|
write_bridge
|
|
4676
5368
|
echo "Pulse complete. This report is derived — nothing was changed."
|
|
4677
|
-
|
|
5369
|
+
# `subject_predates_plan` NAMES A SUBJECT THE AGE RULE REFUSED, and it is its
|
|
5370
|
+
# own counter rather than a silence. A slice whose subject was refused reads
|
|
5371
|
+
# `unknown` under a refused host, exactly as a slice with no subject does — and
|
|
5372
|
+
# only this one has an explanation an operator can act on, which is that a
|
|
5373
|
+
# later plan reused a merged branch name.
|
|
5374
|
+
echo "summary: plans=$n_plans waves=$n_waves branches=$n_branches claimed=$n_claimed claimable=$n_eligible eligible=$n_eligible blocked=$n_blocked deferred=$n_deferred waiting=$n_waiting prereq_missing=$n_prereq_missing merge_detect=$MERGE_DETECT subject_predates_plan=$SUBJECT_PREDATES_PLAN host=$HOST_VERDICT main=$MAIN"
|