@plot-pm/board 0.16.1 → 0.16.3
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 +167 -139
- package/package.json +2 -3
- package/plot-agent-manifest.sh +111 -15
- package/plot-agent-monitor.sh +43 -5
- package/plot-approve.sh +234 -74
- package/plot-build-monitor.sh +49 -4
- package/plot-config.sh +10 -14
- package/plot-deliver.sh +10 -732
- package/plot-desk-root.sh +127 -0
- package/plot-dispatch.sh +313 -135
- package/plot-fleet-scan.sh +746 -62
- package/plot-host.sh +356 -43
- package/plot-monitor-subject.sh +55 -3
- package/plot-plan-meta.sh +155 -139
- package/plot-pr-merged.sh +16 -0
- package/plot-reap.sh +82 -93
- package/plot-state-receipt.sh +12 -2
- package/plot-tmp.sh +90 -14
- package/plot-transcript-quiet.sh +3 -1
- package/plot-worker-state.sh +633 -24
- package/plot-resolve-artifact.sh +0 -429
- package/plot-worker-monitor.sh +0 -705
package/plot-fleet-scan.sh
CHANGED
|
@@ -471,8 +471,13 @@ fi
|
|
|
471
471
|
# seam, not a knob: nothing in Plot sets it, and lowering it in real use buys
|
|
472
472
|
# nothing but the silent misses described above.
|
|
473
473
|
MERGE_SCAN_LIMIT=${PLOT_MERGE_SCAN_LIMIT:-2000}
|
|
474
|
+
# `%H %s` RATHER THAN `%s`, because the age rule needs the merge COMMIT and not
|
|
475
|
+
# only its sentence: a subject proves a branch for a plan only when its merge is
|
|
476
|
+
# not contained in the commit that added the plan file, and that is an ancestry
|
|
477
|
+
# question about this hash. One walk still, and the hash costs nothing — the
|
|
478
|
+
# same `git log`, one more format placeholder.
|
|
474
479
|
MERGE_SUBJECTS=$(git log "origin/$MAIN" --merges \
|
|
475
|
-
--max-count="$MERGE_SCAN_LIMIT" --pretty
|
|
480
|
+
--max-count="$MERGE_SCAN_LIMIT" --pretty='%H %s' </dev/null 2>/dev/null || true)
|
|
476
481
|
MERGE_SCAN_TRUNCATED=0
|
|
477
482
|
if [ -n "$MERGE_SUBJECTS" ] \
|
|
478
483
|
&& [ "$(printf '%s\n' "$MERGE_SUBJECTS" | grep -c .)" -ge "$MERGE_SCAN_LIMIT" ]; then
|
|
@@ -489,11 +494,22 @@ fi
|
|
|
489
494
|
# capped walk detected, but not exhaustively.
|
|
490
495
|
# none — the default branch carries no conforming merge commits at all
|
|
491
496
|
# (a squash/rebase repo), so `open` says nothing about merging.
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
+
# unaskable — the rule could not be asked at all, so NO branch got a subject
|
|
498
|
+
# reading and every refless branch went to the host. A missing or
|
|
499
|
+
# silent bundle is the case; it is not the same answer as `none`,
|
|
500
|
+
# which is a measurement of this estate's history.
|
|
501
|
+
#
|
|
502
|
+
# THE WORD IS THE BUNDLE'S, and this is why no regex decides it here. The regex
|
|
503
|
+
# that stood on this line read GitHub's form only, so a Bitbucket estate — whose
|
|
504
|
+
# every merge carries `Merged in <branch> (pull request #N)` — reported
|
|
505
|
+
# `merge_detect=none` and said its own 1723 proofs did not exist. The forms are
|
|
506
|
+
# data in the host adapter now, and a second copy in shell is exactly what
|
|
507
|
+
# `a-merge-subject-proves-a-landing-the-host-cannot` removes.
|
|
508
|
+
#
|
|
509
|
+
# SET AFTER THE PLANS ARE PARSED, because the bundle is asked then — the plan
|
|
510
|
+
# branches and their adding commits are known only then. Until that point the
|
|
511
|
+
# word is `unaskable`, which is the honest answer for a scan that has not asked.
|
|
512
|
+
MERGE_DETECT=unaskable
|
|
497
513
|
|
|
498
514
|
# ---------------------------------------------------------------------------
|
|
499
515
|
# Squash merges: the case where no local evidence survives at all
|
|
@@ -648,7 +664,32 @@ cache_key() { # $1=branch → a filename that is injective in the branch name
|
|
|
648
664
|
# them. OPEN outranks MERGED outranks CLOSED, matching the walk `pr-state`
|
|
649
665
|
# already performs on Bitbucket, so the join and the per-branch lookup cannot
|
|
650
666
|
# disagree about the same branch.
|
|
651
|
-
|
|
667
|
+
# GENEROUS MEANS ABOVE THE REPOSITORY'S PR COUNT, and 1000 stopped being that.
|
|
668
|
+
# It was set when this repo held 221 PRs. Measured 2026-10-02 it holds 1064, so
|
|
669
|
+
# `--state all --limit 1000` returned exactly 1000 rows — a page AT its limit,
|
|
670
|
+
# which proves only "at least 1000" and can never license completeness. The
|
|
671
|
+
# marker was withheld, and the 26 branches the join could not name each cost one
|
|
672
|
+
# `pr-state` call at 3.8 s: 54-61% of the scan's wall time (#1017).
|
|
673
|
+
#
|
|
674
|
+
# THE NUMBER IS A BUDGET, NOT AN ASSUMPTION, and that distinction is what makes
|
|
675
|
+
# raising it a fix rather than a deferral. A page short of this limit is now read
|
|
676
|
+
# as PROOF of completeness — `plot-host.sh` states it and `prefill_pr_states`
|
|
677
|
+
# reads the sentence — so the limit's only job is to be reachable. A repository
|
|
678
|
+
# that outgrows it gets a capped page, the adapter says `possibly truncated`,
|
|
679
|
+
# the marker is withheld, and the scan degrades to the per-branch asking it did
|
|
680
|
+
# before: slower, and still correct. The failure is loud in the adapter's own
|
|
681
|
+
# stderr and costs cost, never an answer.
|
|
682
|
+
#
|
|
683
|
+
# COST SCALES WITH ROWS RETURNED, NOT ROWS REQUESTED, so the headroom is nearly
|
|
684
|
+
# free. Measured 2026-10-02 through `plot-host.sh pr-list --state all`:
|
|
685
|
+
# `--limit 1000` → 1000 rows in 7.0 s; `--limit 3000` → 1065 rows in 9.2 s;
|
|
686
|
+
# `--limit 5000` → the same 1065 rows in 11.1 s. GitHub pages internally and
|
|
687
|
+
# stops at what exists, so asking for 3000 of 1064 fetches 1064. Two seconds
|
|
688
|
+
# more on one call against ~100 s of per-branch calls removed.
|
|
689
|
+
#
|
|
690
|
+
# `PLOT_PR_LIST_LIMIT` still overrides it, and lowering it is how a repository
|
|
691
|
+
# with a tighter quota trades the join back for per-branch asking.
|
|
692
|
+
PR_LIST_LIMIT="${PLOT_PR_LIST_LIMIT:-3000}"
|
|
652
693
|
|
|
653
694
|
# WHETHER THE HOST ANSWERED, as a fact of its own — the thing this scan
|
|
654
695
|
# computed and threw away until 2026-08-30.
|
|
@@ -823,9 +864,129 @@ pr_list_verdict_rank() {
|
|
|
823
864
|
esac
|
|
824
865
|
}
|
|
825
866
|
|
|
867
|
+
# The listing a previous pulse made, handed in rather than spent again.
|
|
868
|
+
#
|
|
869
|
+
# THE BOARD DECIDES, NOT THIS SCRIPT. `listingSpend` in the domain answers
|
|
870
|
+
# whether the account can afford a listing now, and the board — the only
|
|
871
|
+
# long-lived process here — holds the previous one and hands it back. This scan
|
|
872
|
+
# is spawned fresh per pulse and can span none, which is `PLOT_TERMINAL_CACHE`'s
|
|
873
|
+
# reason (`fleet.ts:3380`) applied to the listing rather than to merge facts.
|
|
874
|
+
#
|
|
875
|
+
# Measured 2026-10-02 on the Bitbucket workspace `quatico`: this scan spent 2949
|
|
876
|
+
# of one account's 3150 calls in an hour, 93.6%, because the open listing sweeps
|
|
877
|
+
# one REST request per tracked branch per state and the pulse is 5 s. Cutting the
|
|
878
|
+
# number of runs does not fix that; cutting the listings a run makes does.
|
|
879
|
+
#
|
|
880
|
+
# A CARRIED LISTING IS A FULL ANSWER OR IT IS NOT USED. It carries the same
|
|
881
|
+
# per-branch lines `prefill_pr_states` would have written plus the markers that
|
|
882
|
+
# license `NONE`, so every reader below is unchanged and none of them can tell a
|
|
883
|
+
# carried listing from a fetched one. That is deliberate: the AGE is the board's
|
|
884
|
+
# to report, because the board is what knows how old its own listing is.
|
|
885
|
+
# An empty value means nothing was carried and the listing is spent as before.
|
|
886
|
+
PLOT_PR_LISTING="${PLOT_PR_LISTING:-}"
|
|
887
|
+
|
|
888
|
+
# Fill the cache from a carried listing. Prints nothing; returns 1 when there was
|
|
889
|
+
# nothing to carry, so the caller spends the listing instead.
|
|
890
|
+
#
|
|
891
|
+
# ABSENT IS NOT EMPTY. A carried listing with no rows would license `NONE` for
|
|
892
|
+
# every branch — the 2026-08-27 failure that refused four fully-merged plans —
|
|
893
|
+
# so a value carrying no arrival marker is refused here and the host is asked.
|
|
894
|
+
carried_listing() {
|
|
895
|
+
[ -n "$PLOT_PR_LISTING" ] || return 1
|
|
896
|
+
local key st chk dft arrived=0 complete=0
|
|
897
|
+
while IFS=" " read -r key st chk dft; do
|
|
898
|
+
case "$key" in
|
|
899
|
+
'') continue ;;
|
|
900
|
+
# The markers travel as lines rather than as files, because the board holds
|
|
901
|
+
# one string and not a directory.
|
|
902
|
+
.list-arrived) arrived=1; continue ;;
|
|
903
|
+
.list-complete) complete=1; continue ;;
|
|
904
|
+
# Every other dotted key is a note for the board and not a branch. Skipped
|
|
905
|
+
# rather than written: `git check-ref-format` rejects a branch name starting
|
|
906
|
+
# with a dot and the key encoding maps only `_` and `/`, so no branch key can
|
|
907
|
+
# begin with one and a dotted key is never a missed answer.
|
|
908
|
+
.*) continue ;;
|
|
909
|
+
esac
|
|
910
|
+
[ -n "$st" ] || continue
|
|
911
|
+
# The `-` sentinel back to empty, exactly as the host payload's plain rows are
|
|
912
|
+
# translated: an empty `checks` reads as `unknown` in `pr_ready` and degrades
|
|
913
|
+
# `--loose` to strict, which is the safe direction.
|
|
914
|
+
[ "$chk" = "-" ] && chk=""
|
|
915
|
+
[ "$dft" = "-" ] && dft=""
|
|
916
|
+
printf '%s\t%s\t%s' "$st" "$chk" "$dft" > "$HOST_STATE_CACHE/$key" 2>/dev/null || true
|
|
917
|
+
done <<EOF
|
|
918
|
+
$PLOT_PR_LISTING
|
|
919
|
+
EOF
|
|
920
|
+
# A listing that arrived with no rows is a real answer — a repository with no
|
|
921
|
+
# PRs — but it is indistinguishable here from a value truncated in transit, and
|
|
922
|
+
# the direction that errs costs a fabricated `NONE`. The MARKER carries the
|
|
923
|
+
# claim, so arrival decides and the row count does not.
|
|
924
|
+
[ "$arrived" = 1 ] || return 1
|
|
925
|
+
printf '1' > "$HOST_STATE_CACHE/.list-arrived" 2>/dev/null || true
|
|
926
|
+
[ "$complete" = 1 ] && { printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true; }
|
|
927
|
+
# `ok`, AND A NEW WORD HERE WOULD STOP THE FLEET. `reachFrom`
|
|
928
|
+
# (`entry/branch-state.ts:113`) maps any word it does not know to `failed`, so
|
|
929
|
+
# an invented `reused` would read as *the host could not be reached*: every
|
|
930
|
+
# branch with no ref would answer `unknown`, `--next` offers only `open`, and
|
|
931
|
+
# nothing would be handed out while the listing was being reused.
|
|
932
|
+
#
|
|
933
|
+
# `ok` IS ALSO THE TRUE ANSWER TO THE QUESTION THIS WORD ASKS. The verdict says
|
|
934
|
+
# how much evidence the scan holds about a branch, not how old the evidence is:
|
|
935
|
+
# a carried listing arrived and was whole, so it licenses exactly what a fetched
|
|
936
|
+
# one licenses. The AGE is the board's to report, because the board is what
|
|
937
|
+
# knows when it listed — this scan is spawned fresh and cannot know.
|
|
938
|
+
HOST_VERDICT=ok
|
|
939
|
+
return 0
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
# Report the listing this run fetched, for the next pulse to carry.
|
|
943
|
+
#
|
|
944
|
+
# ON STDERR AND TAGGED, exactly as the terminal map is reported: stdout is the
|
|
945
|
+
# scan's document and a reader of it must not have to know this exists. The board
|
|
946
|
+
# reads the tag; every other caller discards it with the scan's ordinary prose.
|
|
947
|
+
#
|
|
948
|
+
# REPORTED ONLY WHERE THE LISTING ARRIVED. A failed or throttled call writes no
|
|
949
|
+
# `.list-arrived`, and carrying its empty cache forward would turn one refusal
|
|
950
|
+
# into a listing the next pulse trusts.
|
|
951
|
+
report_listing() {
|
|
952
|
+
[ -n "$HOST_STATE_CACHE" ] || return 0
|
|
953
|
+
[ -f "$HOST_STATE_CACHE/.list-arrived" ] || return 0
|
|
954
|
+
printf 'listing: %s\t%s\t%s\t%s\n' .list-arrived 1 - - >&2
|
|
955
|
+
[ -f "$HOST_STATE_CACHE/.list-complete" ] \
|
|
956
|
+
&& printf 'listing: %s\t%s\t%s\t%s\n' .list-complete 1 - - >&2
|
|
957
|
+
# WHAT THE LISTING COST, MEASURED RATHER THAN MODELLED. The Bitbucket arm sweeps
|
|
958
|
+
# one request per tracked branch per state, and this scan is the only thing that
|
|
959
|
+
# knows how many branches it tracked. A board predicting the number from the
|
|
960
|
+
# plans would under-count: a plan names a subset of the remote refs, and the
|
|
961
|
+
# sweep asks about every one of them.
|
|
962
|
+
printf 'listing: %s\t%s\t%s\t%s\n' .branches \
|
|
963
|
+
"$(printf '%s' "$TRACKED_BRANCHES" | wc -w | tr -d ' ')" - - >&2
|
|
964
|
+
# One line per branch the listing answered for, in the cache's own key encoding
|
|
965
|
+
# and its own `STATE<TAB>checks<TAB>draft` shape, so what is carried back is what
|
|
966
|
+
# this run wrote and the carry path parses no JSON.
|
|
967
|
+
local f key rec _st _chk _dft
|
|
968
|
+
for f in "$HOST_STATE_CACHE"/*; do
|
|
969
|
+
[ -f "$f" ] || continue
|
|
970
|
+
key=${f##*/}
|
|
971
|
+
case "$key" in .*) continue ;; esac
|
|
972
|
+
rec=$(cat "$f" 2>/dev/null) || continue
|
|
973
|
+
IFS=" " read -r _st _chk _dft <<EOF
|
|
974
|
+
$rec
|
|
975
|
+
EOF
|
|
976
|
+
[ -n "$_st" ] || continue
|
|
977
|
+
# Empty fields are refilled with `-` so every field stays occupied: TAB is an
|
|
978
|
+
# IFS whitespace character, and `read` would otherwise slide the branch key
|
|
979
|
+
# along by one. The carry path translates them back, which is the same `-`
|
|
980
|
+
# sentinel the host payload's plain rows already use.
|
|
981
|
+
printf 'listing: %s\t%s\t%s\t%s\n' "$key" "$_st" "${_chk:--}" "${_dft:--}" >&2
|
|
982
|
+
done
|
|
983
|
+
}
|
|
984
|
+
|
|
826
985
|
prefill_pr_states() {
|
|
827
986
|
[ "$HOST_LOOKUP_OK" = 1 ] || return 0
|
|
828
987
|
[ -n "$HOST_STATE_CACHE" ] || return 0
|
|
988
|
+
# ASKED BEFORE THE HOST, and the only reason this function can cost nothing.
|
|
989
|
+
carried_listing && return 0
|
|
829
990
|
local js br st key rc
|
|
830
991
|
# Exit code first: non-zero is a transport failure and its stdout is not an
|
|
831
992
|
# answer. A failed list leaves the cache EMPTY, so every branch falls through
|
|
@@ -1070,19 +1231,44 @@ EOF
|
|
|
1070
1231
|
# were dropped above, so an `open` answer that is short — exit 7, or a sweep
|
|
1071
1232
|
# that did not state its completeness — leaves open PRs with no row at all,
|
|
1072
1233
|
# and completeness would turn those misses into `NONE`.
|
|
1234
|
+
# A STATED CLAIM OUTRANKS A RE-DERIVED ONE, and that is why this reads two
|
|
1235
|
+
# sentences before it counts anything. Either sentence is the adapter saying
|
|
1236
|
+
# the page was whole — `sweep complete` because every tracked branch was asked
|
|
1237
|
+
# and answered, `page complete` because the page came back short of a limit the
|
|
1238
|
+
# host honours. The row count below can only ever GUESS at the second, and on
|
|
1239
|
+
# this repository it guessed wrong: measured 2026-10-02, `--state all --limit
|
|
1240
|
+
# 1000` returned exactly 1000 rows of 1064 PRs, so `_pr_rows < PR_LIST_LIMIT`
|
|
1241
|
+
# was false, the marker was withheld, and 26 branches each cost one `pr-state`
|
|
1242
|
+
# call at 3.8 s — 54-61% of the scan's wall time across slice 1's five runs
|
|
1243
|
+
# (#1017). The adapter knew the page was capped and said so; nothing read it.
|
|
1244
|
+
#
|
|
1245
|
+
# BOTH PAGES MUST CLAIM IT, as the header above requires: the `all` payload's
|
|
1246
|
+
# OPEN rows were dropped, so a short `open` page is what keeps an open PR from
|
|
1247
|
+
# having no row at all, and completeness would turn those misses into `NONE`.
|
|
1248
|
+
# The two claims may arrive by different routes — a sweep for one state and a
|
|
1249
|
+
# short page for the other — and either pair licenses the marker, because each
|
|
1250
|
+
# sentence is the same assertion about its own page.
|
|
1251
|
+
#
|
|
1252
|
+
# THE ROW COUNT STAYS AS THE FALLBACK and is reached only when neither page
|
|
1253
|
+
# stated anything. An adapter that makes no claim is the case it was written
|
|
1254
|
+
# for, and it is still the weaker evidence: a count equal to the limit is
|
|
1255
|
+
# evidence of AT LEAST that many PRs, never of exactly that many. Withholding
|
|
1256
|
+
# the marker costs calls and never correctness, which is why every failure path
|
|
1257
|
+
# here does exactly that.
|
|
1073
1258
|
[ "$_v_open" = ok ] || return 0
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1259
|
+
_list_whole() { # $1=the stderr text of one pr-list call
|
|
1260
|
+
case "$1" in
|
|
1261
|
+
*"pr-list sweep complete"*|*"pr-list state="*" page complete "*) return 0 ;;
|
|
1262
|
+
esac
|
|
1263
|
+
return 1
|
|
1264
|
+
}
|
|
1265
|
+
if _list_whole "$host_err" && _list_whole "$open_err"; then
|
|
1266
|
+
printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true
|
|
1267
|
+
elif [ "$_pr_rows" -gt 0 ] && [ "$_pr_rows" -lt "$PR_LIST_LIMIT" ] \
|
|
1268
|
+
&& [ "$_pr_open_rows" -lt "$PR_LIST_LIMIT" ] 2>/dev/null; then
|
|
1269
|
+
printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true
|
|
1270
|
+
fi
|
|
1271
|
+
report_listing
|
|
1086
1272
|
}
|
|
1087
1273
|
prefill_pr_states
|
|
1088
1274
|
|
|
@@ -1807,7 +1993,7 @@ while IFS=$'\t' read -r wt_branch wt_path; do
|
|
|
1807
1993
|
# questions this block answers — *is anyone editing* and *when did the work
|
|
1808
1994
|
# last change* — then read one list, which is what stopped them drifting
|
|
1809
1995
|
# apart the last time.
|
|
1810
|
-
if [ -n "$(plot_worker_dirty_filter "$wt_status")" ]; then wt_dirty=true; else wt_dirty=false; fi
|
|
1996
|
+
if [ -n "$(plot_worker_dirty_filter "$wt_status" "$wt_path")" ]; then wt_dirty=true; else wt_dirty=false; fi
|
|
1811
1997
|
elif [ "$wt_locked" = true ]; then
|
|
1812
1998
|
# Status could not answer, but the lock says WHY, and that is an answer
|
|
1813
1999
|
# rather than the absence of one: a write is in progress in this worktree at
|
|
@@ -1843,7 +2029,7 @@ while IFS=$'\t' read -r wt_branch wt_path; do
|
|
|
1843
2029
|
# row after the first path with a tab in it. An integer cannot.
|
|
1844
2030
|
wt_changed=""
|
|
1845
2031
|
if [ -n "${wt_status:-}" ]; then
|
|
1846
|
-
wt_dirty_paths=$(plot_worker_dirty_filter "$wt_status")
|
|
2032
|
+
wt_dirty_paths=$(plot_worker_dirty_filter "$wt_status" "$wt_path")
|
|
1847
2033
|
if [ -n "$wt_dirty_paths" ]; then
|
|
1848
2034
|
wt_mtime_args=()
|
|
1849
2035
|
wt_n=0
|
|
@@ -2170,19 +2356,42 @@ changed_paths_of() { # $1=branch → changed paths, one per line (may be empty)
|
|
|
2170
2356
|
| head -n "$CHANGED_PATHS_LIMIT"
|
|
2171
2357
|
}
|
|
2172
2358
|
|
|
2173
|
-
# Did this branch land on the default branch?
|
|
2174
|
-
# keeps today's
|
|
2175
|
-
#
|
|
2176
|
-
#
|
|
2177
|
-
#
|
|
2178
|
-
#
|
|
2179
|
-
#
|
|
2180
|
-
#
|
|
2181
|
-
#
|
|
2182
|
-
#
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2359
|
+
# Did this branch land on the default branch, for THIS plan? A lookup in what
|
|
2360
|
+
# the rule already proved — positive evidence only, and absence keeps today's
|
|
2361
|
+
# answer.
|
|
2362
|
+
#
|
|
2363
|
+
# A LOOKUP, NOT A MATCH. The matching is `mergedBySubject`'s, asked once per
|
|
2364
|
+
# scan through `plot-merge-subject.mjs`; this reads the answer. The regex that
|
|
2365
|
+
# stood here read GitHub's form only and was one of three copies of it on the
|
|
2366
|
+
# estate — and being a regex it carried its own fault: a branch name may
|
|
2367
|
+
# legally hold `+`, `(` or `.`, and unescaped each one changes what the pattern
|
|
2368
|
+
# means, so `feature/v.1` matched `feature/vX1` while `bug/a+b` failed to match
|
|
2369
|
+
# its own subject. The rule matches the branch as literal text and cannot have
|
|
2370
|
+
# that fault at all.
|
|
2371
|
+
#
|
|
2372
|
+
# KEYED BY PLAN AND BRANCH, which the regex could not be. A later plan may
|
|
2373
|
+
# reuse a merged branch name — a reopened ticket does it, and one measured
|
|
2374
|
+
# estate holds 87 reused names — so one plan's proof must never settle
|
|
2375
|
+
# another's. `$SUBJECT_PROVEN` holds `<plan>\t<branch>` lines and this tests the
|
|
2376
|
+
# pair.
|
|
2377
|
+
merged_by_subject() { # $1=plan-base $2=branch → 0 when the rule proved the pair
|
|
2378
|
+
case "$SUBJECT_PROVEN" in
|
|
2379
|
+
*$'\n'"$1"$'\t'"$2"$'\n'*) return 0 ;;
|
|
2380
|
+
esac
|
|
2381
|
+
return 1
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
# Was a subject naming this branch REFUSED because its merge predates the plan?
|
|
2385
|
+
#
|
|
2386
|
+
# A SEPARATE QUESTION FROM THE ONE ABOVE, and the footer and the branch JSON
|
|
2387
|
+
# both report it: a slice with no subject and a slice whose subject was refused
|
|
2388
|
+
# for age both read `unknown` under a refused host, and only the second has an
|
|
2389
|
+
# explanation a reader can act on.
|
|
2390
|
+
subject_predates_plan() { # $1=plan-base $2=branch → 0 when a subject was refused
|
|
2391
|
+
case "$SUBJECT_IGNORED" in
|
|
2392
|
+
*$'\n'"$1"$'\t'"$2"$'\n'*) return 0 ;;
|
|
2393
|
+
esac
|
|
2394
|
+
return 1
|
|
2186
2395
|
}
|
|
2187
2396
|
|
|
2188
2397
|
# Is this branch's PR ready to merge — open, not draft, AND checks green?
|
|
@@ -2847,6 +3056,36 @@ plan_meta_phases=()
|
|
|
2847
3056
|
plan_meta_types=()
|
|
2848
3057
|
plan_meta_waves=()
|
|
2849
3058
|
|
|
3059
|
+
# WHERE THE LAST LOOKUP LANDED, so the next one starts there instead of at 0.
|
|
3060
|
+
#
|
|
3061
|
+
# THE CALLERS ASK IN THE ORDER THE ESTATE WAS PARSED. `parse_plan_estate` is
|
|
3062
|
+
# handed `cand_reads` and the candidate loop at `:3415` then walks the SAME
|
|
3063
|
+
# array in the SAME order, so consecutive asks are consecutive entries and a
|
|
3064
|
+
# search that resumes finds its answer in one step. The scan's other two
|
|
3065
|
+
# callers walk `plans`, which is built from that same enumeration.
|
|
3066
|
+
#
|
|
3067
|
+
# IT IS A CURSOR AND NOT A CACHE. Nothing is remembered about any key; the
|
|
3068
|
+
# lookup still compares strings and still searches the whole array before it
|
|
3069
|
+
# answers "not parsed". Resuming changes WHERE the search starts, never what it
|
|
3070
|
+
# finds — a wrap brings it back to every entry it skipped.
|
|
3071
|
+
#
|
|
3072
|
+
# NO ASSOCIATIVE ARRAY, for the reason the header above gives: `/bin/bash` on
|
|
3073
|
+
# macOS is 3.2. A keyed map is the obvious index and `declare -A` would narrow
|
|
3074
|
+
# where Plot runs. Measured 2026-10-02 on this estate, the bash-3.2 string
|
|
3075
|
+
# idioms are all WORSE than the walk at this size: a `case` over a
|
|
3076
|
+
# newline-delimited index answers membership in 1 ms but `${s#*"$k"$'\t'}`
|
|
3077
|
+
# takes 2.9-22.8 s per call to recover the value, because a leading `*` makes
|
|
3078
|
+
# bash re-test the pattern at every offset of a 52 KB string. The cursor needs
|
|
3079
|
+
# no second structure at all.
|
|
3080
|
+
plan_meta_cursor=0
|
|
3081
|
+
|
|
3082
|
+
# Whether any path was parsed twice. A cursor that starts mid-array would return
|
|
3083
|
+
# the LATER copy of a duplicated path, and the walk this replaces always
|
|
3084
|
+
# returned the first — so a duplicate turns the cursor off and the walk runs
|
|
3085
|
+
# from 0, exactly as it did before. Computed ONCE per parse, three forks, 177 ms
|
|
3086
|
+
# at 416 entries; a per-lookup check would cost the walk it is meant to avoid.
|
|
3087
|
+
plan_meta_dup=0
|
|
3088
|
+
|
|
2850
3089
|
# Parses every plan file given, filling the four arrays above. Called ONCE.
|
|
2851
3090
|
parse_plan_estate() { # $@=files to parse
|
|
2852
3091
|
[ $# -gt 0 ] || return 0
|
|
@@ -2889,15 +3128,23 @@ for line in sys.stdin:
|
|
|
2889
3128
|
# run of tabs collapses to one separator and only the LAST field
|
|
2890
3129
|
# may be optional. "-" stands in for empty everywhere, so no run
|
|
2891
3130
|
# 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
|
-
#
|
|
3131
|
+
# THE PREREQUISITES, from the `waits_on` key the parser emits —
|
|
3132
|
+
# never re-parsed from the annotation. The key is a LIST, ABSENT on
|
|
3133
|
+
# a branch that declares nothing (`plot-plan-meta.sh` promises "a
|
|
3134
|
+
# list of one or more names or no key at all — never []"), joined
|
|
3135
|
+
# with commas into this one column: `entry/branch-state.ts` reads
|
|
3136
|
+
# field 9 as a comma-separated name list, the shape it already
|
|
3137
|
+
# expects for the parallel PR-state list in field 10. "-" stands in
|
|
3138
|
+
# for absence here for the same tab-collapse reason every other
|
|
3139
|
+
# middle column does. A plan parsed by an OLD parser still emits a
|
|
3140
|
+
# bare string for this key, which ",".join would shred into
|
|
3141
|
+
# letters -- isinstance guards it, so that shape still passes
|
|
3142
|
+
# through as one name.
|
|
3143
|
+
waits_on = b.get("waits_on")
|
|
2897
3144
|
print("\t".join(clean(x) for x in [
|
|
2898
3145
|
"W", f, str(i), ref, str(b.get("deferred")).lower(),
|
|
2899
3146
|
(b.get("deferred_reason") or "-"),
|
|
2900
|
-
(
|
|
3147
|
+
(",".join(waits_on) if isinstance(waits_on, list) else waits_on) or "-",
|
|
2901
3148
|
name or "-", b.get("claimed") or "-"]))
|
|
2902
3149
|
' 2>/dev/null) || records=""
|
|
2903
3150
|
|
|
@@ -2926,18 +3173,99 @@ for line in sys.stdin:
|
|
|
2926
3173
|
;;
|
|
2927
3174
|
esac
|
|
2928
3175
|
done <<< "$records"
|
|
3176
|
+
|
|
3177
|
+
# ONCE PER PARSE, over the whole array — a second `parse_plan_estate` call
|
|
3178
|
+
# EXTENDS the arrays, so a path it adds may duplicate one the first call
|
|
3179
|
+
# stored and the question has to be re-asked of everything.
|
|
3180
|
+
#
|
|
3181
|
+
# A duplicate is not expected: the enumeration lists each file once. It is
|
|
3182
|
+
# possible — a slug reachable both through the active index and through the
|
|
3183
|
+
# plan directory resolves to one file by two paths — and the walk's answer
|
|
3184
|
+
# for it was the FIRST index, which `:3598` and `:4149` then use to read
|
|
3185
|
+
# `plan_meta_waves`. Guessing here would hand a caller another plan's waves.
|
|
3186
|
+
plan_meta_dup=0
|
|
3187
|
+
if [ ${#plan_meta_files[@]} -gt 1 ]; then
|
|
3188
|
+
if printf '%s\n' "${plan_meta_files[@]}" | sort | uniq -d | grep -q .; then
|
|
3189
|
+
plan_meta_dup=1
|
|
3190
|
+
fi
|
|
3191
|
+
fi
|
|
3192
|
+
plan_meta_cursor=0
|
|
2929
3193
|
}
|
|
2930
3194
|
|
|
2931
3195
|
# 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
|
-
|
|
3196
|
+
# (an unreadable file, or one the helper could not decode).
|
|
3197
|
+
#
|
|
3198
|
+
# THE SEARCH RESUMES WHERE THE LAST ONE STOPPED. It was a walk from 0 on every
|
|
3199
|
+
# call, and `add_plan_by_phase` asks it once per CANDIDATE file under the plan
|
|
3200
|
+
# directory — 416 here — so the cost was quadratic in the estate. Measured
|
|
3201
|
+
# 2026-10-02 under `--offline` on this estate: 1248 calls walking 260,208
|
|
3202
|
+
# entries between them, 208.5 per call, which is half the array and exactly
|
|
3203
|
+
# what a from-0 walk costs when the answers are spread through it. Slice 1's
|
|
3204
|
+
# `PS4` trace put the line at 294.4 s over ~345,000 gaps, the largest single
|
|
3205
|
+
# shell cost in the scan.
|
|
3206
|
+
#
|
|
3207
|
+
# **1245 of those 1248 calls were HITS**, not misses: in ref mode the estate
|
|
3208
|
+
# parse covers every candidate, so almost every ask finds its file. That is why
|
|
3209
|
+
# the hit path is what had to get cheaper, and why a membership test alone
|
|
3210
|
+
# would not have helped.
|
|
3211
|
+
#
|
|
3212
|
+
# ONE STEP PER ASK IN THE COMMON CASE. The callers ask in parse order, so the
|
|
3213
|
+
# entry after the last answer is usually the next answer. Measured over all 416
|
|
3214
|
+
# candidates in order: 0.31 s against 5.17 s for the from-0 walk, 16.5x.
|
|
3215
|
+
#
|
|
3216
|
+
# IT STILL SEARCHES EVERYTHING BEFORE ANSWERING "NO". The loop runs for the
|
|
3217
|
+
# array's whole length and wraps, so an ask out of order costs what it always
|
|
3218
|
+
# did and an unparsed file is still reported absent. Only the STARTING POINT
|
|
3219
|
+
# changed.
|
|
3220
|
+
#
|
|
3221
|
+
# ABSENT IS NOT FALSE. A file that was not parsed yields "", which every caller
|
|
3222
|
+
# already reads as "not a plan" (`:3547`, `:4096`). It must never be `0` — that
|
|
3223
|
+
# is the FIRST plan's index — and the function keeps exiting 0 on a miss, since
|
|
3224
|
+
# the callers test the string and not the status.
|
|
3225
|
+
#
|
|
3226
|
+
# THE KEY IS THE STORED STRING, compared with `[ = ]`. A `P` row's file passed
|
|
3227
|
+
# through `clean()`, which turns tabs and newlines into spaces, and
|
|
3228
|
+
# `plan_meta_files` holds that cleaned form; a caller's raw path that the clean
|
|
3229
|
+
# would have changed was never findable and stays unfindable. The compare is
|
|
3230
|
+
# literal, so a path holding `[`, `*` or `?` matches itself and never a glob.
|
|
3231
|
+
# THE SEARCH ITSELF, assigning to `plan_meta_index_reply`. Run it in the shell
|
|
3232
|
+
# whose cursor should advance; `plan_meta_index_of` below is the stdout wrapper
|
|
3233
|
+
# for callers that are already inside a `$(…)`.
|
|
3234
|
+
plan_meta_index_reply=""
|
|
3235
|
+
plan_meta_index_into() { # $1=file → sets plan_meta_index_reply
|
|
3236
|
+
plan_meta_index_reply=""
|
|
3237
|
+
local n=${#plan_meta_files[@]}
|
|
3238
|
+
[ "$n" -gt 0 ] || return 0
|
|
3239
|
+
local i k=0
|
|
3240
|
+
# A DUPLICATED PATH TURNS THE CURSOR OFF. The walk returned the lowest index
|
|
3241
|
+
# that equalled the file; resuming mid-array could return a later copy, so
|
|
3242
|
+
# this falls back to the from-0 walk and keeps the old answer.
|
|
3243
|
+
if [ "$plan_meta_dup" = 1 ]; then
|
|
3244
|
+
for i in "${!plan_meta_files[@]}"; do
|
|
3245
|
+
if [ "${plan_meta_files[$i]}" = "$1" ]; then plan_meta_index_reply="$i"; return 0; fi
|
|
3246
|
+
done
|
|
3247
|
+
return 0
|
|
3248
|
+
fi
|
|
3249
|
+
i=$plan_meta_cursor
|
|
3250
|
+
while [ "$k" -lt "$n" ]; do
|
|
3251
|
+
if [ "${plan_meta_files[$i]}" = "$1" ]; then
|
|
3252
|
+
# The NEXT entry, because the next ask is usually the next candidate.
|
|
3253
|
+
plan_meta_cursor=$(( (i + 1) % n ))
|
|
3254
|
+
plan_meta_index_reply="$i"
|
|
3255
|
+
return 0
|
|
3256
|
+
fi
|
|
3257
|
+
i=$(( (i + 1) % n ))
|
|
3258
|
+
k=$((k + 1))
|
|
2939
3259
|
done
|
|
2940
|
-
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
# The stdout form, for the two callers that read it inside a command
|
|
3263
|
+
# substitution. The cursor it advances belongs to that subshell and dies with
|
|
3264
|
+
# it, which costs those callers nothing: both ask once per live plan, and the
|
|
3265
|
+
# weight was never there.
|
|
3266
|
+
plan_meta_index_of() { # $1=file → index on stdout, or ""
|
|
3267
|
+
plan_meta_index_into "$1"
|
|
3268
|
+
printf '%s' "$plan_meta_index_reply"
|
|
2941
3269
|
}
|
|
2942
3270
|
|
|
2943
3271
|
# The phase a file declares, or "" when it is not a plan. Read from the single
|
|
@@ -2949,6 +3277,29 @@ plan_phase_of() { # $1=file to parse → normalized phase on stdout
|
|
|
2949
3277
|
printf '%s' "${plan_meta_phases[$i]}"
|
|
2950
3278
|
}
|
|
2951
3279
|
|
|
3280
|
+
# The same answer, ASSIGNED to `plan_phase_reply` instead of printed.
|
|
3281
|
+
#
|
|
3282
|
+
# THE CURSOR ONLY ADVANCES IN THE PARENT SHELL. `plan_phase_of` is read as
|
|
3283
|
+
# `$(plan_phase_of …)` and `plan_meta_index_of` as `$(plan_meta_index_of …)`,
|
|
3284
|
+
# and a subshell's write to `plan_meta_cursor` is discarded when it exits — so
|
|
3285
|
+
# the resumable search would restart from 0 on every call and the quadratic
|
|
3286
|
+
# shape would survive the fix. This form runs in the caller's own shell, which
|
|
3287
|
+
# is where `add_plan_by_phase` runs and where the 416 asks come from.
|
|
3288
|
+
#
|
|
3289
|
+
# A SECOND FUNCTION RATHER THAN A CHANGED CONTRACT. `plan_phase_of` keeps its
|
|
3290
|
+
# stdout form for the slug path at `:3308`, which asks once and whose answer is
|
|
3291
|
+
# interpolated into an array append. Rewriting that caller to read a global
|
|
3292
|
+
# would trade a one-off subshell for a less obvious assignment.
|
|
3293
|
+
plan_phase_reply=""
|
|
3294
|
+
plan_phase_into() { # $1=file to parse → sets plan_phase_reply
|
|
3295
|
+
plan_phase_reply=""
|
|
3296
|
+
local i
|
|
3297
|
+
plan_meta_index_into "$1"
|
|
3298
|
+
i="$plan_meta_index_reply"
|
|
3299
|
+
[ -n "$i" ] || return 0
|
|
3300
|
+
plan_phase_reply="${plan_meta_phases[$i]}"
|
|
3301
|
+
}
|
|
3302
|
+
|
|
2952
3303
|
# A terminal phase belongs to the delivered group: the plan is finished, and it
|
|
2953
3304
|
# appears only while its `Delivered:` record is inside the rolling window.
|
|
2954
3305
|
#
|
|
@@ -3058,7 +3409,11 @@ else
|
|
|
3058
3409
|
# result, so no path below asks the format contract the same question twice.
|
|
3059
3410
|
add_plan_by_phase() { # $1=identity path, $2=file to parse
|
|
3060
3411
|
local id="$1" src="$2" ph
|
|
3061
|
-
|
|
3412
|
+
# NO COMMAND SUBSTITUTION. This runs once per candidate file — 416 on this
|
|
3413
|
+
# estate — and `$(…)` would both fork a subshell per call and discard the
|
|
3414
|
+
# lookup cursor's advance, which is the whole saving.
|
|
3415
|
+
plan_phase_into "$src"
|
|
3416
|
+
ph="$plan_phase_reply"
|
|
3062
3417
|
is_plan_phase "$ph" || return 0
|
|
3063
3418
|
if is_terminal_phase "$ph"; then
|
|
3064
3419
|
[ "$next_only" = 1 ] && return 0
|
|
@@ -3195,6 +3550,208 @@ if [ ${#plans[@]} -eq 0 ]; then
|
|
|
3195
3550
|
fi
|
|
3196
3551
|
fi
|
|
3197
3552
|
|
|
3553
|
+
# ---------------------------------------------------------------------------
|
|
3554
|
+
# The merge subjects, asked of the rule once the plans are known
|
|
3555
|
+
# ---------------------------------------------------------------------------
|
|
3556
|
+
#
|
|
3557
|
+
# WHY HERE AND NOT WITH THE WALK ABOVE. The rule is asked per plan, with that
|
|
3558
|
+
# plan's own refless branches and the commit that added its file — and neither
|
|
3559
|
+
# is known until the estate is parsed. The walk itself still happens once, up
|
|
3560
|
+
# at `MERGE_SUBJECTS`; this is where its lines are USED.
|
|
3561
|
+
#
|
|
3562
|
+
# THREE READINGS PER SCAN, NONE PER PLAN. The per-plan lookup this replaces
|
|
3563
|
+
# cost 6 to 37 ms per plan, 5.23 s for 140 plans here; the batched walk below
|
|
3564
|
+
# costs 0.10 s for 392 plan files, 0.02 s on the estate that reported #1139.
|
|
3565
|
+
#
|
|
3566
|
+
# 1. one `git log --diff-filter=AR --name-status` for every plan file's
|
|
3567
|
+
# adding commit (here)
|
|
3568
|
+
# 2. the merges walk, already run above with `%H %s`
|
|
3569
|
+
# 3. one `git merge-base --is-ancestor` per MATCHED PAIR, which is a few calls
|
|
3570
|
+
# per scan rather than one per plan
|
|
3571
|
+
#
|
|
3572
|
+
# THE BUNDLE IS ASKED TWICE, with reading 3 between the calls: the age rule
|
|
3573
|
+
# needs an ancestry answer and the domain may not run git, so the first call
|
|
3574
|
+
# names the pairs needing a test and the second applies the rule to the
|
|
3575
|
+
# answers. The decision stays in the bundle; this shell only reads git.
|
|
3576
|
+
SUBJECT_PROVEN=$'\n'
|
|
3577
|
+
SUBJECT_IGNORED=$'\n'
|
|
3578
|
+
SUBJECT_PREDATES_PLAN=0
|
|
3579
|
+
|
|
3580
|
+
# The commit that first added each plan file, as `<path>\t<sha>` lines.
|
|
3581
|
+
#
|
|
3582
|
+
# `--diff-filter=AR` AND THE RENAME FOLLOW. With git's default rename
|
|
3583
|
+
# detection a renamed dated file is listed `R` and never `A`, so an `A`-only
|
|
3584
|
+
# walk leaves the current path out of the answer and the plan gets no subjects
|
|
3585
|
+
# — measured on `origin/main`, 4 of 392 dated plans have no `A` entry for their
|
|
3586
|
+
# current path, all renamed while Draft. Each `R` is followed back to the old
|
|
3587
|
+
# path's `A` in the SAME call, at no extra cost, so a retitled plan keeps the
|
|
3588
|
+
# commit that first added it.
|
|
3589
|
+
#
|
|
3590
|
+
# ON `origin/<main>`, NEVER `HEAD`. This runs in desks and on feature branches,
|
|
3591
|
+
# where `HEAD` holds another history — and the merges walk reads the same ref,
|
|
3592
|
+
# so a mismatch would compare an age from one history with a merge from
|
|
3593
|
+
# another.
|
|
3594
|
+
#
|
|
3595
|
+
# THE DATED FILE, NEVER THE SYMLINK under `active/` or `delivered/`. A delivery
|
|
3596
|
+
# moves the symlink as a git rename, so the symlink's adding commit is the
|
|
3597
|
+
# delivery commit: measured on the estate that reported #1139, one delivered
|
|
3598
|
+
# plan keeps 4 of 4 subjects through its target and 0 of 4 through its symlink,
|
|
3599
|
+
# because one bulk move re-added 13 symlinks at once.
|
|
3600
|
+
plan_adding_commits() { # → <path>\t<sha> per plan file
|
|
3601
|
+
git log "origin/$MAIN" --diff-filter=AR --name-status --format=@%H \
|
|
3602
|
+
-- "$PLAN_DIR" </dev/null 2>/dev/null \
|
|
3603
|
+
| awk '
|
|
3604
|
+
/^@/ { commit = substr($0, 2); next }
|
|
3605
|
+
# An A line gives a path and the commit that added it. The walk runs
|
|
3606
|
+
# newest-first, so the LAST A seen for a path is the oldest — which is the
|
|
3607
|
+
# first add, and the age the rule wants.
|
|
3608
|
+
/^A\t/ { add[$2] = commit; next }
|
|
3609
|
+
# An R line maps an old path (field 2) to a new one (field 3). Recorded as
|
|
3610
|
+
# a chain and resolved after the walk: a rename may be seen before the add
|
|
3611
|
+
# of the path it renames from.
|
|
3612
|
+
/^R/ { from[$3] = $2; next }
|
|
3613
|
+
END {
|
|
3614
|
+
for (path in add) resolved[path] = add[path]
|
|
3615
|
+
# Follow each chain to a path with an A line. Bounded by the chain
|
|
3616
|
+
# length, and a cycle cannot form — a rename always names an earlier
|
|
3617
|
+
# path, and `seen` stops one anyway.
|
|
3618
|
+
for (path in from) {
|
|
3619
|
+
cur = path
|
|
3620
|
+
delete seen
|
|
3621
|
+
while (cur in from && !(cur in seen)) { seen[cur] = 1; cur = from[cur] }
|
|
3622
|
+
if (cur in add) resolved[path] = add[cur]
|
|
3623
|
+
# A chain ending in no A line inside this walk leaves the path
|
|
3624
|
+
# unresolved, so its plan gets no subjects and its branches go to the
|
|
3625
|
+
# host — stated in the plan as the limit it is.
|
|
3626
|
+
}
|
|
3627
|
+
for (path in resolved) printf "%s\t%s\n", path, resolved[path]
|
|
3628
|
+
}'
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
# What the rule was asked, assembled once and reused for both calls.
|
|
3632
|
+
subject_readings() {
|
|
3633
|
+
printf '@backend %s\n' "$HOST_BACKEND"
|
|
3634
|
+
printf '@origin %s\n' "$ORIGIN_URL"
|
|
3635
|
+
printf '@merges\n'
|
|
3636
|
+
[ -n "$MERGE_SUBJECTS" ] && printf '%s\n' "$MERGE_SUBJECTS"
|
|
3637
|
+
printf '%s' "$SUBJECT_PLAN_SECTIONS"
|
|
3638
|
+
}
|
|
3639
|
+
|
|
3640
|
+
ask_merge_subject() { # $1=verb; stdin=readings → the rule's answer
|
|
3641
|
+
node "$script_dir/board/plot-merge-subject.mjs" "$1" 2>/dev/null
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
# AN EMPTY WALK IS A MEASUREMENT, AND IT ANSWERS `none`.
|
|
3645
|
+
#
|
|
3646
|
+
# A squash or rebase estate leaves no merge commit at all, so `$MERGE_SUBJECTS`
|
|
3647
|
+
# is empty — and that is the walk having run and found nothing, which is exactly
|
|
3648
|
+
# what `none` says. `unaskable` must mean only *the rule could not be asked*, or
|
|
3649
|
+
# the footer stops telling a reader the two apart; the asking side of that
|
|
3650
|
+
# distinction is this script's and the found/not-found side is the bundle's.
|
|
3651
|
+
#
|
|
3652
|
+
# Measured by CI on the first run of this slice: the guard below read
|
|
3653
|
+
# `[ -n "$MERGE_SUBJECTS" ]` and a fixture with no merges reported
|
|
3654
|
+
# `merge_detect=unaskable`, which claims the question was never put about an
|
|
3655
|
+
# estate the walk had just examined.
|
|
3656
|
+
if [ -z "$MERGE_SUBJECTS" ]; then
|
|
3657
|
+
MERGE_DETECT=none
|
|
3658
|
+
fi
|
|
3659
|
+
|
|
3660
|
+
# Only where there is something for the rule to match. `--offline` and `--no-pr`
|
|
3661
|
+
# do not gate this: the walks are LOCAL, they cost no host call, and a subject is
|
|
3662
|
+
# the one proof an offline scan can still have.
|
|
3663
|
+
if [ ${#plans[@]} -gt 0 ] && [ -n "$MERGE_SUBJECTS" ]; then
|
|
3664
|
+
# The origin URL, read once. The rule parses it — this script never does, so
|
|
3665
|
+
# the owner is read one way by the scan and the supervisor alike.
|
|
3666
|
+
ORIGIN_URL=$(git config --get remote.origin.url 2>/dev/null || echo "")
|
|
3667
|
+
HOST_BACKEND=$("$script_dir/plot-host.sh" backend 2>/dev/null || echo "")
|
|
3668
|
+
|
|
3669
|
+
SUBJECT_ADDING=$'\n'"$(plan_adding_commits)"$'\n'
|
|
3670
|
+
|
|
3671
|
+
# One section per plan: its dated file, its adding commit, and its refless
|
|
3672
|
+
# branches. A branch WITH a ref is never offered — the ref check stays in
|
|
3673
|
+
# front, and a recreated branch carrying new work must not be settled by the
|
|
3674
|
+
# first attempt's subject.
|
|
3675
|
+
SUBJECT_PLAN_SECTIONS=""
|
|
3676
|
+
for _sp_i in "${!plans[@]}"; do
|
|
3677
|
+
_sp_plan="${plans[$_sp_i]}"
|
|
3678
|
+
_sp_base=$(basename "$(readlink "$_sp_plan" 2>/dev/null || echo "$_sp_plan")")
|
|
3679
|
+
_sp_path="$PLAN_DIR$_sp_base"
|
|
3680
|
+
_sp_added=""
|
|
3681
|
+
case "$SUBJECT_ADDING" in
|
|
3682
|
+
*$'\n'"$_sp_path"$'\t'*)
|
|
3683
|
+
_sp_added=$(printf '%s' "$SUBJECT_ADDING" \
|
|
3684
|
+
| awk -F'\t' -v p="$_sp_path" '$1 == p { print $2; exit }') ;;
|
|
3685
|
+
esac
|
|
3686
|
+
# KEYED ON THE FILE THAT WAS PARSED, which is what `plan_meta_files` holds
|
|
3687
|
+
# — `$plan_reads[i]`, the same key the row loop uses at pass 1a. In ref mode
|
|
3688
|
+
# that is a materialized blob under a temp path and not `$PLAN_DIR` at all,
|
|
3689
|
+
# so a reconstructed path finds nothing.
|
|
3690
|
+
_sp_meta_i=$(plan_meta_index_of "${plan_reads[$_sp_i]}" 2>/dev/null || echo "")
|
|
3691
|
+
[ -n "$_sp_meta_i" ] || continue
|
|
3692
|
+
_sp_branches=""
|
|
3693
|
+
while IFS=$'\t' read -r _sp_idx _sp_br _sp_rest; do
|
|
3694
|
+
[ -n "$_sp_br" ] || continue
|
|
3695
|
+
remote_ref_exists "$_sp_br" && continue
|
|
3696
|
+
_sp_branches+="$_sp_br"$'\n'
|
|
3697
|
+
done <<< "${plan_meta_waves[$_sp_meta_i]}"
|
|
3698
|
+
[ -n "$_sp_branches" ] || continue
|
|
3699
|
+
# THE BASENAME IS THE KEY, because that is what the row loop holds as
|
|
3700
|
+
# `$plan_base` and what the lookup tests. The adding-commit walk keys on
|
|
3701
|
+
# the full path, which is what git reports — the two are joined here, once,
|
|
3702
|
+
# rather than at every lookup.
|
|
3703
|
+
SUBJECT_PLAN_SECTIONS+="@plan $_sp_base"$'\n'
|
|
3704
|
+
SUBJECT_PLAN_SECTIONS+="@added ${_sp_added:--}"$'\n'
|
|
3705
|
+
SUBJECT_PLAN_SECTIONS+="$_sp_branches"
|
|
3706
|
+
done
|
|
3707
|
+
|
|
3708
|
+
if [ -n "$SUBJECT_PLAN_SECTIONS" ]; then
|
|
3709
|
+
# CALL 1: which pairs need an ancestry test.
|
|
3710
|
+
_sp_pairs=$(subject_readings | ask_merge_subject pairs || echo "")
|
|
3711
|
+
# THE READING, one per matched pair. The age rule in
|
|
3712
|
+
# plot-merge-subject.mjs is what decides what it means.
|
|
3713
|
+
_sp_answers=""
|
|
3714
|
+
while IFS=$'\t' read -r _sp_pplan _sp_pbr _sp_merge _sp_added2; do
|
|
3715
|
+
[ -n "$_sp_merge" ] || continue
|
|
3716
|
+
# A wrong "contained" sends the branch to the host; a wrong "not
|
|
3717
|
+
# contained" is the reused-name case this narrows.
|
|
3718
|
+
#
|
|
3719
|
+
# plot-ancestry: evidence — handed to `proofOf` in
|
|
3720
|
+
# plot-merge-subject.mjs, which decides and
|
|
3721
|
+
# reads `unknown` as proving nothing.
|
|
3722
|
+
if git merge-base --is-ancestor "$_sp_merge" "$_sp_added2" </dev/null 2>/dev/null; then
|
|
3723
|
+
_sp_answers+="@ancestry $_sp_merge $_sp_added2 yes"$'\n'
|
|
3724
|
+
elif git cat-file -e "$_sp_merge^{commit}" </dev/null 2>/dev/null \
|
|
3725
|
+
&& git cat-file -e "$_sp_added2^{commit}" </dev/null 2>/dev/null; then
|
|
3726
|
+
_sp_answers+="@ancestry $_sp_merge $_sp_added2 no"$'\n'
|
|
3727
|
+
else
|
|
3728
|
+
# A commit this checkout cannot read answers neither way, and the rule
|
|
3729
|
+
# reads `unknown` as proving nothing.
|
|
3730
|
+
_sp_answers+="@ancestry $_sp_merge $_sp_added2 unknown"$'\n'
|
|
3731
|
+
fi
|
|
3732
|
+
done <<< "$_sp_pairs"
|
|
3733
|
+
|
|
3734
|
+
# CALL 2: the proof, given the answers.
|
|
3735
|
+
while IFS=$'\t' read -r _sp_word _sp_a _sp_b _sp_c; do
|
|
3736
|
+
case "$_sp_word" in
|
|
3737
|
+
proven) SUBJECT_PROVEN+="$_sp_a"$'\t'"$_sp_b"$'\n' ;;
|
|
3738
|
+
ignored)
|
|
3739
|
+
SUBJECT_IGNORED+="$_sp_a"$'\t'"$_sp_b"$'\n'
|
|
3740
|
+
SUBJECT_PREDATES_PLAN=$((SUBJECT_PREDATES_PLAN + 1)) ;;
|
|
3741
|
+
detect)
|
|
3742
|
+
# `truncated` is THIS script's reading and outranks `pr-merge`: the
|
|
3743
|
+
# bundle cannot see the cap, and a capped walk detected but did not
|
|
3744
|
+
# examine exhaustively.
|
|
3745
|
+
if [ "$_sp_a" = "pr-merge" ] && [ "$MERGE_SCAN_TRUNCATED" = 1 ]; then
|
|
3746
|
+
MERGE_DETECT=truncated
|
|
3747
|
+
else
|
|
3748
|
+
MERGE_DETECT="$_sp_a"
|
|
3749
|
+
fi ;;
|
|
3750
|
+
esac
|
|
3751
|
+
done <<< "$(printf '%s\n%s' "$(subject_readings)" "$_sp_answers" | ask_merge_subject proven || echo "")"
|
|
3752
|
+
fi
|
|
3753
|
+
fi
|
|
3754
|
+
|
|
3198
3755
|
# A branch is merged when its remote ref is an ancestor of origin/<main>, or —
|
|
3199
3756
|
# once the ref is gone — when the default branch carries a conforming PR-merge
|
|
3200
3757
|
# commit naming it (see "the evidence that survives the ref" above). An absent
|
|
@@ -3316,8 +3873,9 @@ EOF
|
|
|
3316
3873
|
MAIN_TIP=$(remote_ref_oid "$MAIN")
|
|
3317
3874
|
[ -n "$MAIN_TIP" ] || MAIN_TIP="-"
|
|
3318
3875
|
|
|
3319
|
-
branch_readings() { # $1=branch $2=deferred →
|
|
3320
|
-
local br="$1" _bs_deferred="$2" _bs_subject=false
|
|
3876
|
+
branch_readings() { # $1=branch $2=deferred $3=plan-base → readings
|
|
3877
|
+
local br="$1" _bs_deferred="$2" _bs_plan="${3:-}" _bs_subject=false
|
|
3878
|
+
local _bs_ahead=0 _bs_real=0 _bs_tip
|
|
3321
3879
|
local _bs_main="$MAIN_TIP"
|
|
3322
3880
|
# THE REF CHECK STAYS IN FRONT. DO NOT HOIST THE MERGE LOOKUP ABOVE IT.
|
|
3323
3881
|
#
|
|
@@ -3350,7 +3908,17 @@ branch_readings() { # $1=branch $2=deferred → eight tab-separated readings
|
|
|
3350
3908
|
# exists — squash merges, a hand-rewritten subject, a branch genuinely
|
|
3351
3909
|
# never started — today's `open` stands. The evidence may only move a branch
|
|
3352
3910
|
# from `open` to `merged`, and only when it is positive.
|
|
3353
|
-
|
|
3911
|
+
# THE PAIR, not the branch alone. `$_bs_plan` is what stops one plan's
|
|
3912
|
+
# proof settling another plan's reused name — the case round 1 executed,
|
|
3913
|
+
# where an unstarted reused name read `merged`, its slice read complete,
|
|
3914
|
+
# the next slice opened, and the reused slice was never offered.
|
|
3915
|
+
merged_by_subject "$_bs_plan" "$br" && _bs_subject=true
|
|
3916
|
+
# A subject the age rule refused is reported apart from one that never
|
|
3917
|
+
# existed: both read `unknown` under a refused host, and only this one has
|
|
3918
|
+
# an explanation.
|
|
3919
|
+
_bs_predates=false
|
|
3920
|
+
[ "$_bs_subject" = false ] && subject_predates_plan "$_bs_plan" "$br" \
|
|
3921
|
+
&& _bs_predates=true
|
|
3354
3922
|
# No merge commit names it — which is the ordinary case under a squash
|
|
3355
3923
|
# merge, not an exotic one. The local walk is now out of evidence, so the
|
|
3356
3924
|
# host is asked. It may only ever move this branch from `open` to `merged`:
|
|
@@ -3375,7 +3943,20 @@ branch_readings() { # $1=branch $2=deferred → eight tab-separated readings
|
|
|
3375
3943
|
# what travels: the rule tells `CLOSED` from `NONE` from `-`, and a boolean
|
|
3376
3944
|
# cannot. `|| true` because a not-merged answer is an ordinary reading and
|
|
3377
3945
|
# `set -e` must not read it as a failure.
|
|
3378
|
-
|
|
3946
|
+
#
|
|
3947
|
+
# NOT ASKED FOR A BRANCH THE SUBJECT ALREADY PROVED. For the wave gate the
|
|
3948
|
+
# subject IS enough — settling a slice and opening the next is reversible
|
|
3949
|
+
# work — and asking anyway is the spend #1139 reports: a host refusing
|
|
3950
|
+
# every question, asked again on a 5-second pulse about a branch already
|
|
3951
|
+
# proven.
|
|
3952
|
+
#
|
|
3953
|
+
# THE ONE EXCEPTION IS A DELIVERY CANDIDATE, and it is asked in PASS 1e of
|
|
3954
|
+
# the plan loop rather than here. Candidacy needs every branch's DECIDED
|
|
3955
|
+
# state, which exists only after the rule has answered for the whole plan,
|
|
3956
|
+
# and this function runs per branch in a subshell before that.
|
|
3957
|
+
if [ "$_bs_subject" = false ]; then
|
|
3958
|
+
merged_by_host "$br" || true
|
|
3959
|
+
fi
|
|
3379
3960
|
# `open` IS A CLAIM ABOUT A PR: that one was looked for and none was found.
|
|
3380
3961
|
# With no ref, the host is the only remaining source, so when it could not
|
|
3381
3962
|
# be asked that claim was never earned — and the branch measured on
|
|
@@ -3765,6 +4346,13 @@ for plan in "${plans[@]}"; do
|
|
|
3765
4346
|
# deciding it — see pass 1c.
|
|
3766
4347
|
readings=""
|
|
3767
4348
|
order=""
|
|
4349
|
+
# WHICH BRANCHES THE HOST ITSELF CALLED MERGED, recorded here because
|
|
4350
|
+
# `_merged_by_host_state` is a single variable that pass 1a overwrites per
|
|
4351
|
+
# branch — by the time the JSON emitter runs it holds the LAST branch's
|
|
4352
|
+
# answer. The `evidence` field needs to know, per branch, whether the host
|
|
4353
|
+
# confirmed the landing, so the answer is kept while it is still the right
|
|
4354
|
+
# one.
|
|
4355
|
+
host_merged_branches=$'\n'
|
|
3768
4356
|
# THE ELEVENTH FIELD: whether the PR list held every PR (`.list-complete`,
|
|
3769
4357
|
# written by `prefill_pr_states`). The rule reads a `NONE` for a ref behind
|
|
3770
4358
|
# main as `open` only when it is true; a capped list may omit a merged PR.
|
|
@@ -3775,7 +4363,15 @@ for plan in "${plans[@]}"; do
|
|
|
3775
4363
|
# "-" is the absent marker the shim writes, for the tab-collapse reason
|
|
3776
4364
|
# above. Normalized here so everything downstream tests emptiness.
|
|
3777
4365
|
[ "$waits" = "-" ] && waits=""
|
|
3778
|
-
readings+="$(branch_readings "$br" "$deferred") ${waits:--} ? $list_complete"$'\n'
|
|
4366
|
+
readings+="$(branch_readings "$br" "$deferred" "$plan_base") ${waits:--} ? $list_complete"$'\n'
|
|
4367
|
+
# Read from the readings line just built rather than from the variable: the
|
|
4368
|
+
# subshell `branch_readings` runs in cannot export it back.
|
|
4369
|
+
# FIELD 6 is the host's own word, in both arms of `branch_readings`:
|
|
4370
|
+
# `$_merged_by_host_state` for a refless branch and `host_pr_state` for one
|
|
4371
|
+
# with a ref. Field 9 is `waits`, appended by this loop.
|
|
4372
|
+
case "$(printf '%s' "$readings" | tail -n1 | cut -f6)" in
|
|
4373
|
+
MERGED) host_merged_branches+="$br"$'\n' ;;
|
|
4374
|
+
esac
|
|
3779
4375
|
order+="$idx $br $deferred $why ${waits:--} $wname $claim"$'\n'
|
|
3780
4376
|
done <<< "$wave_lines"
|
|
3781
4377
|
|
|
@@ -3830,8 +4426,17 @@ for plan in "${plans[@]}"; do
|
|
|
3830
4426
|
# list may legitimately omit: its plan may be delivered and its ref gone.
|
|
3831
4427
|
# `host_pr_state`'s run cache keeps this at one call per prerequisite per
|
|
3832
4428
|
# run, never one per pass.
|
|
3833
|
-
#
|
|
3834
|
-
|
|
4429
|
+
#
|
|
4430
|
+
# FIELD 9 IS A LIST, comma-joined, so field 10 answers each prerequisite
|
|
4431
|
+
# IN THE SAME ORDER — one `waits_pr_state` call per name, joined the same
|
|
4432
|
+
# way. `entry/branch-state.ts` reads the two columns as parallel lists of
|
|
4433
|
+
# equal length and throws otherwise, so a single answer for several names
|
|
4434
|
+
# would desync them. Field 11 is carried, unreplaced.
|
|
4435
|
+
waits_state=""
|
|
4436
|
+
for _wn in ${waits_br//,/ }; do
|
|
4437
|
+
waits_state+="${waits_state:+,}$(waits_pr_state "$_wn")"
|
|
4438
|
+
done
|
|
4439
|
+
refill+="$(printf '%s' "$rd_line" | cut -f1-9) $waits_state $(printf '%s' "$rd_line" | cut -f11)"$'\n'
|
|
3835
4440
|
else
|
|
3836
4441
|
refill+="$rd_line"$'\n'
|
|
3837
4442
|
fi
|
|
@@ -3858,6 +4463,46 @@ for plan in "${plans[@]}"; do
|
|
|
3858
4463
|
states+="$idx $br $st $deferred $why $waits $wname $claim"$'\n'
|
|
3859
4464
|
done <<< "$order"
|
|
3860
4465
|
|
|
4466
|
+
# PASS 1e: A DELIVERY CANDIDATE ASKS THE HOST about its subject-proven branches.
|
|
4467
|
+
#
|
|
4468
|
+
# A candidate is an Approved plan whose every non-deferred branch reads
|
|
4469
|
+
# `merged`. Its next step is a delivery, and `allSlicesConfirmed` refuses a
|
|
4470
|
+
# delivery while any branch carries `evidence: subject`. So for a candidate,
|
|
4471
|
+
# and only under `HOST_VERDICT` `ok` or `partial`, `merged_by_host` is asked
|
|
4472
|
+
# about each branch the subject alone proved. A refused host is not asked:
|
|
4473
|
+
# that spend is the condition #1139 reports, and the branch keeps
|
|
4474
|
+
# `evidence: subject`.
|
|
4475
|
+
#
|
|
4476
|
+
# IN THE MAIN SHELL, not in `branch_readings`. That function runs per branch
|
|
4477
|
+
# in a subshell before any state is decided, so it cannot know whether the
|
|
4478
|
+
# plan is a candidate. Measured 2026-10-02: its `$4` was never passed, 32 of
|
|
4479
|
+
# 39 merged branches on this estate read `evidence: subject`, and auto-deliver
|
|
4480
|
+
# held every plan whose branch refs were deleted at merge.
|
|
4481
|
+
#
|
|
4482
|
+
# The terminal cache bounds the cost: a MERGED answer is kept per tip of
|
|
4483
|
+
# `origin/<main>`, so a candidate is asked once per tip, not once per pulse.
|
|
4484
|
+
if [ "$plan_phase" = approved ]; then
|
|
4485
|
+
case "$HOST_VERDICT" in
|
|
4486
|
+
ok|partial)
|
|
4487
|
+
cand=true
|
|
4488
|
+
cand_proven=""
|
|
4489
|
+
while IFS=$'\t' read -r _ c_br c_st c_deferred _; do
|
|
4490
|
+
[ -n "$c_br" ] || continue
|
|
4491
|
+
if [ "$c_deferred" = true ]; then continue; fi
|
|
4492
|
+
[ "$c_st" = merged ] || { cand=false; break; }
|
|
4493
|
+
case "$host_merged_branches" in *$'\n'"$c_br"$'\n'*) continue ;; esac
|
|
4494
|
+
if merged_by_subject "$plan_base" "$c_br"; then cand_proven+="$c_br"$'\n'; fi
|
|
4495
|
+
done <<< "$states"
|
|
4496
|
+
if [ "$cand" = true ]; then
|
|
4497
|
+
while IFS= read -r c_br; do
|
|
4498
|
+
[ -n "$c_br" ] || continue
|
|
4499
|
+
if merged_by_host "$c_br"; then host_merged_branches+="$c_br"$'\n'; fi
|
|
4500
|
+
done <<< "$cand_proven"
|
|
4501
|
+
fi
|
|
4502
|
+
;;
|
|
4503
|
+
esac
|
|
4504
|
+
fi
|
|
4505
|
+
|
|
3861
4506
|
# Pass 2a: what each wave HOLDS — how many of its non-deferred branches have
|
|
3862
4507
|
# not settled. A reading, and the whole of what this script contributes to the
|
|
3863
4508
|
# verdict: which branches count as settled depends on `--loose` and on a host
|
|
@@ -3979,18 +4624,24 @@ for plan in "${plans[@]}"; do
|
|
|
3979
4624
|
[ "$claim" = "-" ] && claim=""
|
|
3980
4625
|
[ "$why" = "-" ] && why=""
|
|
3981
4626
|
[ "$waits" = "-" ] && waits=""
|
|
4627
|
+
# THE NOTE NAMES EVERY PREREQUISITE, not just one: `$waits` is comma-joined
|
|
4628
|
+
# with no space (the shim's separator), and the sentence reads "a, b" —
|
|
4629
|
+
# with one name both forms are byte-identical. The scan has the VERDICT
|
|
4630
|
+
# and not the per-prerequisite answer here, so `blocked`'s sentence names
|
|
4631
|
+
# every prerequisite rather than which one lacks a PR.
|
|
4632
|
+
waits_prose="${waits//,/, }"
|
|
3982
4633
|
n_branches=$((n_branches + 1))
|
|
3983
4634
|
case "$st" in
|
|
3984
4635
|
# WHAT IT WAITS ON, NAMED. A bare `waiting` tells a reader to come back
|
|
3985
4636
|
# later without saying what would have to happen first, which is the
|
|
3986
4637
|
# whole of what this state adds over `open`.
|
|
3987
4638
|
waiting) n_waiting=$((n_waiting + 1))
|
|
3988
|
-
note="waiting on $
|
|
4639
|
+
note="waiting on $waits_prose" ;;
|
|
3989
4640
|
# A PREREQUISITE NOBODY DECLARED. The sentence says the host was asked
|
|
3990
4641
|
# and answered, because that is what separates this from `waiting`: a
|
|
3991
4642
|
# host that could not be asked holds the branch at `waiting` instead.
|
|
3992
4643
|
blocked) n_prereq_missing=$((n_prereq_missing + 1))
|
|
3993
|
-
note="blocked — no PR found for $
|
|
4644
|
+
note="blocked — no PR found for $waits_prose" ;;
|
|
3994
4645
|
# The REASON, where the plan recorded one. A bare `deferred` beside a
|
|
3995
4646
|
# branch with no commits reads as two unrelated facts when the first is
|
|
3996
4647
|
# the reason for the second, and the sentence that says so was already
|
|
@@ -4033,20 +4684,48 @@ for plan in "${plans[@]}"; do
|
|
|
4033
4684
|
# must not parse a string that exists for humans to read.
|
|
4034
4685
|
json_branches+="${json_branches:+,}{\"branch\":\"$(json_str "$br")\""
|
|
4035
4686
|
json_branches+=",\"state\":\"$st\",\"deferred\":$deferred"
|
|
4687
|
+
# WHAT PROVED THE LANDING, where the proof is weaker than the host's.
|
|
4688
|
+
#
|
|
4689
|
+
# EMITTED ONLY WHERE THE BRANCH READS `merged` AND THE HOST HAS NOT
|
|
4690
|
+
# CONFIRMED IT. `merged_by_host` leaves its own word in
|
|
4691
|
+
# `_merged_by_host_state`, and the scan does not even ask for a proven
|
|
4692
|
+
# branch that is not a delivery candidate — so the absence of a host
|
|
4693
|
+
# `MERGED` is what the field reports. The branch loses the field the
|
|
4694
|
+
# moment the host answers merged.
|
|
4695
|
+
#
|
|
4696
|
+
# A QUALIFIER, NOT A STATE. The state stays `merged`, the wave
|
|
4697
|
+
# arithmetic is unmoved and the next slice still opens; what reads this
|
|
4698
|
+
# is `allSlicesConfirmed`, in front of a delivery, which is the one
|
|
4699
|
+
# decision here that cannot be undone.
|
|
4700
|
+
if [ "$st" = merged ] && merged_by_subject "$plan_base" "$br" \
|
|
4701
|
+
&& [ "${host_merged_branches#*$'\n'"$br"$'\n'}" = "$host_merged_branches" ]; then
|
|
4702
|
+
json_branches+=",\"evidence\":\"subject\""
|
|
4703
|
+
fi
|
|
4704
|
+
# WHY A SUBJECT NAMING THIS BRANCH PROVED NOTHING. A reused name whose
|
|
4705
|
+
# merge predates the plan: the slice reads as it would with no subject
|
|
4706
|
+
# at all, and this is what tells a reader the two cases apart.
|
|
4707
|
+
if subject_predates_plan "$plan_base" "$br"; then
|
|
4708
|
+
json_branches+=",\"subjectIgnored\":\"predates-plan\""
|
|
4709
|
+
fi
|
|
4036
4710
|
# WHY it was deferred, straight from the plan's annotation. "" where the
|
|
4037
4711
|
# branch is not deferred, and "" where it is deferred with nothing
|
|
4038
4712
|
# recorded — the flag says which of those two a reader is looking at.
|
|
4039
4713
|
json_branches+=",\"deferred_reason\":\"$(json_str "$why")\""
|
|
4040
4714
|
# WHAT THIS BRANCH WAITS ON, straight from the plan's `waits:`
|
|
4041
|
-
# annotation.
|
|
4042
|
-
# every branch
|
|
4715
|
+
# annotation. `[]` where the branch declares nothing — `FleetBranchSchema`
|
|
4716
|
+
# carries the key on every branch since #1253, so absence is an empty
|
|
4717
|
+
# list here rather than a missing key, unlike the parser's own
|
|
4718
|
+
# `waits_on` which omits the key entirely.
|
|
4043
4719
|
#
|
|
4044
4720
|
# THE ANNOTATION, NOT THE VERDICT, and it is emitted whatever `state`
|
|
4045
4721
|
# says. A branch whose prerequisite has MERGED reports `waits_on` with
|
|
4046
4722
|
# its ordinary state — the declaration is still a fact about the plan,
|
|
4047
4723
|
# and a reader who sees a cleared dependency learns why the slice is
|
|
4048
4724
|
# now startable. Consumers test `state`, never the presence of this.
|
|
4049
|
-
|
|
4725
|
+
#
|
|
4726
|
+
# `$waits` IS COMMA-JOINED NAMES, never PR states: one name per line
|
|
4727
|
+
# for `json_array`, which escapes each.
|
|
4728
|
+
json_branches+=",\"waits_on\":$(json_array "${waits//,/$'\n'}")"
|
|
4050
4729
|
json_branches+=",\"claimed\":\"$(json_str "$claim")\""
|
|
4051
4730
|
# What this machine knows and the refs do not. Absent everywhere else:
|
|
4052
4731
|
# `local_dirty:false` and `local_worktree:""` are what a branch checked
|
|
@@ -4674,4 +5353,9 @@ if [ "$build_doc" = 1 ] && [ "$record" = 1 ] && [ -n "$reading_doc" ]; then
|
|
|
4674
5353
|
fi
|
|
4675
5354
|
write_bridge
|
|
4676
5355
|
echo "Pulse complete. This report is derived — nothing was changed."
|
|
4677
|
-
|
|
5356
|
+
# `subject_predates_plan` NAMES A SUBJECT THE AGE RULE REFUSED, and it is its
|
|
5357
|
+
# own counter rather than a silence. A slice whose subject was refused reads
|
|
5358
|
+
# `unknown` under a refused host, exactly as a slice with no subject does — and
|
|
5359
|
+
# only this one has an explanation an operator can act on, which is that a
|
|
5360
|
+
# later plan reused a merged branch name.
|
|
5361
|
+
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"
|