@plot-pm/board 0.14.4 → 0.16.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 +166 -158
- package/package.json +3 -1
- package/plot-agent-manifest.sh +137 -0
- package/plot-approve.sh +25 -1
- package/plot-budget.sh +378 -19
- package/plot-config.sh +86 -2
- package/plot-deliver.sh +26 -3
- package/plot-dispatch.sh +307 -43
- package/plot-fleet-scan.sh +438 -229
- package/plot-host.sh +322 -84
- package/plot-plan-meta.sh +68 -13
- package/plot-pr-merged.sh +29 -2
- package/plot-reap.sh +212 -23
- package/plot-resolve-artifact.sh +4 -2
- package/plot-tmp.sh +108 -0
- package/plot-transcript-quiet.sh +29 -1
- package/plot-worker-monitor.sh +66 -5
package/plot-plan-meta.sh
CHANGED
|
@@ -107,8 +107,9 @@
|
|
|
107
107
|
# placeholder
|
|
108
108
|
# story story slug the plan belongs to (`## Status` `Story:` or
|
|
109
109
|
# front matter `story:`); "" if absent or a placeholder
|
|
110
|
-
# assignee github handle from
|
|
111
|
-
#
|
|
110
|
+
# assignee github handle from front matter `assignee:`, else the
|
|
111
|
+
# `## Approval` `Assignee:` line, else the `## Status`
|
|
112
|
+
# `Assignee:` line; "" if absent or a placeholder
|
|
112
113
|
# branches branch names, sorted and unique, read from EITHER spelling:
|
|
113
114
|
# the old `## Branches` section (a LIST ITEM whose first token
|
|
114
115
|
# is the backtick-quoted name, matching the known prefixes) OR
|
|
@@ -199,6 +200,14 @@
|
|
|
199
200
|
# every wave name is a label; ALWAYS present, so a consumer
|
|
200
201
|
# never reads undefined. The plan still parses in full: waves[]
|
|
201
202
|
# is unchanged and no name is shortened or dropped.
|
|
203
|
+
# unread_branch_headings
|
|
204
|
+
# slice headings that carry `Branch:` and whose wave holds no
|
|
205
|
+
# branch, verbatim, in document order — a report, not a
|
|
206
|
+
# refusal. The wave stays in waves[] with branches []; this
|
|
207
|
+
# separates a slice the parser could not read from a narrative
|
|
208
|
+
# heading, which carries no `Branch:`. Covers both slice
|
|
209
|
+
# consumers, so a heading lost to the first-heading latch
|
|
210
|
+
# (#1042) is named too. ALWAYS present; [] when none.
|
|
202
211
|
# issues tracker issue numbers this plan answers, from the `## Status`
|
|
203
212
|
# `Issue:` line or front matter `issue:` (sorted, unique).
|
|
204
213
|
# A DEDICATED field, never a scan of the body for `#NNN`: a
|
|
@@ -285,7 +294,7 @@ if [ ${#files[@]} -eq 0 ] && [ ${#missing[@]} -eq 0 ]; then
|
|
|
285
294
|
fi
|
|
286
295
|
|
|
287
296
|
for f in ${missing[@]+"${missing[@]}"}; do
|
|
288
|
-
printf '{"file":"%s","format":"none","error":"file not found","phase_raw":"","phase":"NONE","phase_alt_raw":"","phase_alt":"NONE","type":"","title":"","sprint":"","story":"","assignee":"","branches":[],"prs":[],"issues":[],"malformed_prs":[],"changelog":[],"long_wave_names":[],"review_raw":"","review":"NONE","impl_raw":"","impl":"NONE","design_raw":"","approved_raw":"","released_raw":"","delivered_raw":"","started_raw":[]}\n' \
|
|
297
|
+
printf '{"file":"%s","format":"none","error":"file not found","phase_raw":"","phase":"NONE","phase_alt_raw":"","phase_alt":"NONE","type":"","title":"","sprint":"","story":"","assignee":"","branches":[],"prs":[],"issues":[],"malformed_prs":[],"changelog":[],"long_wave_names":[],"unread_branch_headings":[],"review_raw":"","review":"NONE","impl_raw":"","impl":"NONE","design_raw":"","approved_raw":"","released_raw":"","delivered_raw":"","started_raw":[]}\n' \
|
|
289
298
|
"$(printf '%s' "$f" | sed 's/\\/\\\\/g; s/"/\\"/g')"
|
|
290
299
|
done
|
|
291
300
|
|
|
@@ -415,7 +424,7 @@ function reset_state() {
|
|
|
415
424
|
fm_delivered = ""; fm_design = ""
|
|
416
425
|
fm_rounds = ""
|
|
417
426
|
canon_state = ""; canon_phase = ""; canon_type = ""
|
|
418
|
-
canon_sprint = ""; canon_story = ""; canon_assignee = ""
|
|
427
|
+
canon_sprint = ""; canon_story = ""; canon_assignee = ""; status_assignee = ""
|
|
419
428
|
canon_review = ""; canon_impl = ""; canon_approved = ""; canon_released = ""
|
|
420
429
|
canon_delivered = ""; canon_design = ""
|
|
421
430
|
canon_rounds = ""
|
|
@@ -432,6 +441,7 @@ function reset_state() {
|
|
|
432
441
|
delete issues; n_issues = 0
|
|
433
442
|
delete wave_names; delete wave_of; delete wave_seq; delete wave_count
|
|
434
443
|
delete deferred_of; delete deferred_why; delete claimed_of; delete ordered_b; n_waves = 0
|
|
444
|
+
delete branch_heading
|
|
435
445
|
delete waits_of; delete waits_set
|
|
436
446
|
delete builds_of; delete builds_set
|
|
437
447
|
delete agent_of; delete agent_set
|
|
@@ -478,6 +488,9 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
|
|
|
478
488
|
sprint = strip_placeholder((fm_sprint != "") ? fm_sprint : canon_sprint)
|
|
479
489
|
story = strip_placeholder((fm_story != "") ? fm_story : canon_story)
|
|
480
490
|
assignee = strip_placeholder((fm_assignee != "") ? fm_assignee : canon_assignee)
|
|
491
|
+
# `## Status` is the section both templates offer; `## Approval` is older and
|
|
492
|
+
# outranks it, so a plan writing both keeps the answer it parsed to before.
|
|
493
|
+
if (fm_assignee == "" && assignee == "") assignee = status_assignee
|
|
481
494
|
review = strip_placeholder((fm_review != "") ? fm_review : canon_review)
|
|
482
495
|
impl = strip_placeholder((fm_impl != "") ? fm_impl : canon_impl)
|
|
483
496
|
design = strip_placeholder((fm_design != "") ? fm_design : canon_design)
|
|
@@ -645,6 +658,20 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
|
|
|
645
658
|
}
|
|
646
659
|
}
|
|
647
660
|
out = out "]"
|
|
661
|
+
# unread_branch_headings[]: a slice heading that carries `Branch:` and whose
|
|
662
|
+
# wave holds no branch, heading text verbatim, in document order. The wave
|
|
663
|
+
# itself stays in waves[] with branches []; this names WHY it is empty, since
|
|
664
|
+
# an empty wave with no `Branch:` is narrative and one with it is a slice the
|
|
665
|
+
# parser could not read. ALWAYS present, [] when every such heading was read.
|
|
666
|
+
out = out ",\"unread_branch_headings\":["
|
|
667
|
+
ubh = 0
|
|
668
|
+
for (w = 1; w <= n_waves; w++) {
|
|
669
|
+
if ((w in branch_heading) && wave_count[w] == 0) {
|
|
670
|
+
out = out (ubh > 0 ? "," : "") "\"" jesc(branch_heading[w]) "\""
|
|
671
|
+
ubh++
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
out = out "]"
|
|
648
675
|
out = out ",\"review_raw\":\"" jesc(review) "\",\"review\":\"" norm_review(review) "\""
|
|
649
676
|
out = out ",\"impl_raw\":\"" jesc(impl) "\",\"impl\":\"" norm_impl(impl) "\""
|
|
650
677
|
out = out ",\"design_raw\":\"" jesc(design) "\""
|
|
@@ -666,6 +693,16 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
|
|
|
666
693
|
out = out "}"
|
|
667
694
|
print out
|
|
668
695
|
}
|
|
696
|
+
# Records the current `### ` heading when it carries `Branch:`. Called by BOTH
|
|
697
|
+
# slice consumers, because the list consumer also opens a wave per heading: a
|
|
698
|
+
# section whose FIRST heading is narrative routes every later heading there, and
|
|
699
|
+
# a `(Branch: …)` heading below it then yields nothing (#1042).
|
|
700
|
+
function note_branch_heading( h) {
|
|
701
|
+
if (index($0, "Branch:") == 0) return
|
|
702
|
+
h = trim(substr($0, 4))
|
|
703
|
+
sub(/[ \t]*<!--.*$/, "", h)
|
|
704
|
+
branch_heading[n_waves] = h
|
|
705
|
+
}
|
|
669
706
|
# The longest wave name that still reads as a label, not prose. A JUDGEMENT, not
|
|
670
707
|
# a measurement: the longest legitimate name in the estate is `Offered first`
|
|
671
708
|
# (13), and the offender this exists to catch is a 53-character sentence, so the
|
|
@@ -823,6 +860,9 @@ section == "status" {
|
|
|
823
860
|
else if (lower ~ /^[ \t]*[-*]?[ \t]*\**issue[:*]/ && canon_issue == "") canon_issue = val_after_colon($0)
|
|
824
861
|
else if (lower ~ /^[ \t]*[-*]?[ \t]*\**review[:*]/ && canon_review == "") canon_review = val_after_colon($0)
|
|
825
862
|
else if (lower ~ /^[ \t]*[-*]?[ \t]*\**impl[:*]/ && canon_impl == "") canon_impl = val_after_colon($0)
|
|
863
|
+
# Its own slot, not `canon_assignee`: the `## Approval` line outranks this one
|
|
864
|
+
# in `emit_record`, whichever section comes first.
|
|
865
|
+
else if (lower ~ /^[ \t]*[-*]?[ \t]*\**assignee[:*]/ && status_assignee == "") status_assignee = strip_placeholder(val_after_colon($0))
|
|
826
866
|
# EACH TAKES THE FIRST LINE THAT CARRIES A VALUE, NOT THE FIRST LINE.
|
|
827
867
|
#
|
|
828
868
|
# A plan may hold both a record and an unfilled placeholder for the same
|
|
@@ -904,20 +944,26 @@ section == "changelog" {
|
|
|
904
944
|
cl_open = 0
|
|
905
945
|
next
|
|
906
946
|
}
|
|
907
|
-
# WHICH SHAPE THIS SECTION HOLDS, decided
|
|
908
|
-
#
|
|
947
|
+
# WHICH SHAPE THIS SECTION HOLDS, decided by ANY heading in it that names a
|
|
948
|
+
# branch — not by the first heading alone.
|
|
909
949
|
#
|
|
910
950
|
# `(Branch:` IS THE MARKER, and it is the only reliable one. A heading carrying
|
|
911
951
|
# it is the new shape by construction — that parenthetical is where the new
|
|
912
|
-
# layout puts the branch.
|
|
952
|
+
# layout puts the branch. Headings without it are the old shape, whose headings
|
|
913
953
|
# are bare names (`### Tracer`) and whose branches ride list items below.
|
|
914
954
|
#
|
|
915
|
-
#
|
|
916
|
-
#
|
|
917
|
-
#
|
|
918
|
-
#
|
|
955
|
+
# THE FIRST HEADING IS NOT THE DECIDER, and a latch on it loses work. Measured
|
|
956
|
+
# 2026-09-28 over 357 plans: 56 sections open with a narrative heading, and in 2
|
|
957
|
+
# of them a branched heading sits below it, so the section routed to the list
|
|
958
|
+
# consumer and 5 declared slices were read as none. `slice_shape` therefore
|
|
959
|
+
# stays `""` until a BRANCHED heading appears, wherever it sits.
|
|
960
|
+
#
|
|
961
|
+
# A SECTION WITH NO BRANCHED `### ` AT ALL is the old shape, and must be: a plan
|
|
962
|
+
# written before subheadings existed is one unnamed wave of list items, which is
|
|
963
|
+
# exactly what the old consumer produces — 54 of those 56 plans. `""` routes
|
|
964
|
+
# there, so the absent latch and the old answer are one behaviour.
|
|
919
965
|
section == "slices" && $0 ~ /^###[ \t]/ && slice_shape == "" {
|
|
920
|
-
|
|
966
|
+
if (index($0, "(Branch:") > 0) slice_shape = "heading"
|
|
921
967
|
}
|
|
922
968
|
|
|
923
969
|
section == "slices" && slice_shape != "heading" {
|
|
@@ -925,6 +971,7 @@ section == "slices" && slice_shape != "heading" {
|
|
|
925
971
|
# unnamed wave, so a pre-wave plan parses as exactly one wave.
|
|
926
972
|
if ($0 ~ /^###[ \t]/) {
|
|
927
973
|
wave_names[++n_waves] = trim(substr($0, 4))
|
|
974
|
+
note_branch_heading()
|
|
928
975
|
next
|
|
929
976
|
}
|
|
930
977
|
# Claim reflection, written by the worker after its ref push succeeds. This is
|
|
@@ -1180,6 +1227,7 @@ section == "slices" && slice_shape == "heading" {
|
|
|
1180
1227
|
sub(/[ \t]*\(Branch:.*$/, "", wname)
|
|
1181
1228
|
wname = trim(wname)
|
|
1182
1229
|
wave_names[++n_waves] = wname
|
|
1230
|
+
note_branch_heading()
|
|
1183
1231
|
|
|
1184
1232
|
# Claim/deferral annotations bind to the line carrying the branch name, which
|
|
1185
1233
|
# is the heading. Read before any match() below, which clobbers RSTART/RLENGTH.
|
|
@@ -1284,10 +1332,17 @@ section == "slices" && slice_shape == "heading" {
|
|
|
1284
1332
|
# heading with no readable branch still opened a wave above — so a `## Waves`
|
|
1285
1333
|
# section is never silently empty, which is the failure this plan refuses: a
|
|
1286
1334
|
# consumer sees a wave it could not extract a branch from, not an absence.
|
|
1335
|
+
#
|
|
1336
|
+
# THE VALUE MAY BE BACKTICKED. `(Branch: \`bug/foo\`)` is unambiguous, and a
|
|
1337
|
+
# backtick between `Branch:` and the prefix made the anchored match fail, so
|
|
1338
|
+
# the heading opened a wave and yielded nothing. The backticks are optional on
|
|
1339
|
+
# both sides and stripped from the name. Measured 2026-09-28 over 357 plans:
|
|
1340
|
+
# 2 records change, 355 byte-identical.
|
|
1287
1341
|
hmeta = $0
|
|
1288
|
-
if (match(hmeta, "Branch:[ \t]
|
|
1342
|
+
if (match(hmeta, "Branch:[ \t]*`?(" PREFIXES ")/[^ \t,)`]+`?")) {
|
|
1289
1343
|
b = substr(hmeta, RSTART, RLENGTH)
|
|
1290
1344
|
sub(/^Branch:[ \t]*/, "", b)
|
|
1345
|
+
gsub(/`/, "", b)
|
|
1291
1346
|
branches[++n_branches] = b
|
|
1292
1347
|
wave_of[n_branches] = n_waves
|
|
1293
1348
|
wave_seq[n_branches] = ++wave_count[n_waves]
|
package/plot-pr-merged.sh
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Plot helper: the ONE answer to "did the host merge ANY PR for this branch?"
|
|
3
3
|
#
|
|
4
|
-
# SOURCED, NOT RUN. `. "$script_dir/plot-pr-merged.sh"` defines `pr_merged
|
|
5
|
-
# `pr_open`; the file does nothing else on load. That is what makes sourcing it
|
|
4
|
+
# SOURCED, NOT RUN. `. "$script_dir/plot-pr-merged.sh"` defines `pr_merged`,
|
|
5
|
+
# `pr_open` and `pr_merged_heads`; the file does nothing else on load. That is what makes sourcing it
|
|
6
6
|
# safe, and it is the same shape — and the same reason — as
|
|
7
7
|
# `plot-worker-state.sh`: the logic could not simply stay in `plot-reap.sh`,
|
|
8
8
|
# because that file parses `$@` and `exit 2`s on an unknown argument at load
|
|
@@ -178,3 +178,30 @@ pr_open() {
|
|
|
178
178
|
answer=$(_plot_landed none "$(_plot_open_lookup "$1")") || return 1
|
|
179
179
|
case "$answer" in *" open-pr") return 0 ;; *) return 1 ;; esac
|
|
180
180
|
}
|
|
181
|
+
|
|
182
|
+
# Which commits did the host merge for this branch? One `headRefOid` per merged
|
|
183
|
+
# PR, one per line.
|
|
184
|
+
#
|
|
185
|
+
# A THIRD QUESTION, asked by `plot-reap.sh` only. After a squash merge the
|
|
186
|
+
# host deletes the branch (`delete_branch_on_merge`), `git fetch --prune`
|
|
187
|
+
# drops the remote-tracking ref, and the branch's own commits are then on no
|
|
188
|
+
# remote ref at all. So "which commits did no remote ever hold?" needs the
|
|
189
|
+
# head the host merged: a commit reachable from it was pushed; a commit beyond
|
|
190
|
+
# it exists only on the desk.
|
|
191
|
+
#
|
|
192
|
+
# Returns 1 when the host cannot be asked. The caller reads that as
|
|
193
|
+
# `unknown`, which keeps the desk: here a failure to observe is the case that
|
|
194
|
+
# loses work. It prints nothing and returns 0 when the host answered and no PR
|
|
195
|
+
# merged.
|
|
196
|
+
pr_merged_heads() {
|
|
197
|
+
local br="$1" out
|
|
198
|
+
command -v gh >/dev/null 2>&1 || return 1
|
|
199
|
+
out=$(gh pr list --head "$br" --state all --limit 100 --json mergedAt,headRefOid 2>/dev/null) \
|
|
200
|
+
|| return 1
|
|
201
|
+
printf '%s' "$out" | node -e '
|
|
202
|
+
let s = "";
|
|
203
|
+
process.stdin.on("data", (d) => { s += d; }).on("end", () => {
|
|
204
|
+
const rows = JSON.parse(s);
|
|
205
|
+
for (const r of rows) if (r.mergedAt && r.headRefOid) console.log(r.headRefOid);
|
|
206
|
+
});' 2>/dev/null || return 1
|
|
207
|
+
}
|
package/plot-reap.sh
CHANGED
|
@@ -146,12 +146,36 @@
|
|
|
146
146
|
# words and lives INSIDE the tree, so it goes when the tree does and is not
|
|
147
147
|
# swept here. This is the dispatcher's record of what it started. Two files,
|
|
148
148
|
# two lifetimes, and CLAUDE.md already distinguishes them.
|
|
149
|
+
#
|
|
150
|
+
# `--sweep-temp` IS A SEPARATE MODE, and it runs INSTEAD of the four kinds. A
|
|
151
|
+
# trap does not run on SIGKILL — the board ends a scan at its timeout,
|
|
152
|
+
# `bounded.sh` escalates to SIGKILL, a person kills a hung script — so some temp
|
|
153
|
+
# paths outlive every trap. It removes two populations, each owned by this user
|
|
154
|
+
# and older than `Temp sweep after` hours (default 24), by the entry's own
|
|
155
|
+
# modification time:
|
|
156
|
+
#
|
|
157
|
+
# - `$TMPDIR/plot-?*` entries directly under `$TMPDIR` — `plot-` and at least
|
|
158
|
+
# one more character, any separator: `mkdtempSync` appends six characters
|
|
159
|
+
# with no dot, so `plot-host-pTFuyG` is the common shape. Never `plot`,
|
|
160
|
+
# `plotter-old` or any `tmp.*`: that is every template-less `mktemp` on the
|
|
161
|
+
# machine, and neither owner nor age separates Plot's from another
|
|
162
|
+
# program's. A `plot-reg.<pid>` exit registry is kept while its pid lives,
|
|
163
|
+
# because a worker loop registers its exit command and runs for days.
|
|
164
|
+
# - `$PLOT_BUDGET_HOME/memo/<pid>` directories (default `~/.plot/state/memo`)
|
|
165
|
+
# whose pid is not alive.
|
|
166
|
+
#
|
|
167
|
+
# It lists each candidate with `find` and removes it by the full path it
|
|
168
|
+
# listed; it never passes a glob to `rm`, and it never reads `/tmp` or
|
|
169
|
+
# `/var/folders` when `$TMPDIR` points elsewhere. The age bound is safe because
|
|
170
|
+
# every Plot temp path belongs to one script call, one scan or one board
|
|
171
|
+
# request, and 24 h is about 1,000 times the scan's 90 s timeout.
|
|
149
172
|
set -u
|
|
150
173
|
|
|
151
|
-
DRY=1; MAX=0
|
|
174
|
+
DRY=1; MAX=0; SWEEP_TEMP=0
|
|
152
175
|
while [ $# -gt 0 ]; do
|
|
153
176
|
case "$1" in
|
|
154
177
|
--yes) DRY=0 ;;
|
|
178
|
+
--sweep-temp) SWEEP_TEMP=1 ;;
|
|
155
179
|
--dry-run) DRY=1 ;;
|
|
156
180
|
--max) MAX="${2:-0}"; shift ;;
|
|
157
181
|
# The header, however long it has become. A hardcoded last line silently
|
|
@@ -163,6 +187,65 @@ while [ $# -gt 0 ]; do
|
|
|
163
187
|
shift
|
|
164
188
|
done
|
|
165
189
|
|
|
190
|
+
# Is a pid alive? `ps -p` answers for another user's process too, where
|
|
191
|
+
# `kill -0` reports EPERM, so a reused pid always keeps its entry.
|
|
192
|
+
pid_alive() { ps -p "$1" >/dev/null 2>&1; }
|
|
193
|
+
|
|
194
|
+
# One entry: report it, and remove it by the exact path unless this is a dry run.
|
|
195
|
+
sweep_one() { # $1=path $2=why
|
|
196
|
+
temp_swept=$((temp_swept + 1))
|
|
197
|
+
if [ "$DRY" = 1 ]; then
|
|
198
|
+
echo "temp: would remove $1 ($2)"
|
|
199
|
+
elif rm -rf -- "$1" 2>/dev/null; then
|
|
200
|
+
echo "temp: removed $1 ($2)"
|
|
201
|
+
temp_removed=$((temp_removed + 1))
|
|
202
|
+
else
|
|
203
|
+
echo "temp: could not remove $1 ($2)"
|
|
204
|
+
fi
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
sweep_temp() {
|
|
208
|
+
local hours root me memo entry name
|
|
209
|
+
hours=$("$(dirname "${BASH_SOURCE[0]}")/plot-config.sh" get "Temp sweep after" 24 2>/dev/null) || hours=24
|
|
210
|
+
case "$hours" in
|
|
211
|
+
''|*[!0-9]*) echo "plot-reap: 'Temp sweep after' must be a whole number of hours, not '$hours'" >&2; return 2 ;;
|
|
212
|
+
esac
|
|
213
|
+
root="${TMPDIR:-/tmp}"; root="${root%/}"
|
|
214
|
+
me=$(id -un)
|
|
215
|
+
temp_swept=0; temp_removed=0; temp_live=0
|
|
216
|
+
while IFS= read -r entry; do
|
|
217
|
+
[ -n "$entry" ] || continue
|
|
218
|
+
[ "$MAX" -gt 0 ] && [ "$temp_swept" -ge "$MAX" ] && break
|
|
219
|
+
name=${entry##*/}
|
|
220
|
+
case "$name" in
|
|
221
|
+
plot-reg.*)
|
|
222
|
+
if pid_alive "${name#plot-reg.}"; then temp_live=$((temp_live + 1)); continue; fi ;;
|
|
223
|
+
esac
|
|
224
|
+
sweep_one "$entry" "older than ${hours}h"
|
|
225
|
+
done <<LIST
|
|
226
|
+
$(find "$root" -mindepth 1 -maxdepth 1 -user "$me" -name 'plot-?*' -mmin +$((hours * 60)) -print 2>/dev/null)
|
|
227
|
+
LIST
|
|
228
|
+
memo="${PLOT_BUDGET_HOME:-${HOME:-}/.plot/state}/memo"
|
|
229
|
+
if [ -d "$memo" ]; then
|
|
230
|
+
while IFS= read -r entry; do
|
|
231
|
+
[ -n "$entry" ] || continue
|
|
232
|
+
[ "$MAX" -gt 0 ] && [ "$temp_swept" -ge "$MAX" ] && break
|
|
233
|
+
name=${entry##*/}
|
|
234
|
+
case "$name" in ''|*[!0-9]*) continue ;; esac
|
|
235
|
+
if pid_alive "$name"; then temp_live=$((temp_live + 1)); continue; fi
|
|
236
|
+
sweep_one "$entry" "memo of dead pid $name, older than ${hours}h"
|
|
237
|
+
done <<LIST
|
|
238
|
+
$(find "$memo" -mindepth 1 -maxdepth 1 -type d -user "$me" -mmin +$((hours * 60)) -print 2>/dev/null)
|
|
239
|
+
LIST
|
|
240
|
+
fi
|
|
241
|
+
echo "temp-summary: swept=$temp_swept removed=$temp_removed kept_live=$temp_live bound_hours=$hours root=$root dry_run=$DRY"
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if [ "$SWEEP_TEMP" = 1 ]; then
|
|
245
|
+
sweep_temp
|
|
246
|
+
exit $?
|
|
247
|
+
fi
|
|
248
|
+
|
|
166
249
|
command -v git >/dev/null 2>&1 || { echo "plot-reap: git not found" >&2; exit 2; }
|
|
167
250
|
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
|
168
251
|
echo "plot-reap: not a git repository" >&2; exit 2; }
|
|
@@ -187,6 +270,109 @@ git fetch origin "$DEFAULT" --quiet 2>/dev/null || true
|
|
|
187
270
|
# newest — and defines `pr_merged` and nothing else on load.
|
|
188
271
|
. "$(dirname "${BASH_SOURCE[0]}")/plot-pr-merged.sh"
|
|
189
272
|
|
|
273
|
+
# Which uncommitted paths count as unlanded work. SOURCED, because the reap
|
|
274
|
+
# reading, the dirty sweep below and `plot-reconcile-scan.sh` section 21 must
|
|
275
|
+
# read one tree the same way; the helper names each excused path.
|
|
276
|
+
. "$(dirname "${BASH_SOURCE[0]}")/plot-desk-dirt.sh"
|
|
277
|
+
|
|
278
|
+
# Is an AGENT still working at a desk? SOURCED from `plot-worker-state.sh`,
|
|
279
|
+
# the ONE classifier `plot-dispatch.sh --stop` also asks. Reading only whether
|
|
280
|
+
# the recorded pid answers `ps` asked about the wrapper shell, which outlives
|
|
281
|
+
# its agent: a desk whose agent had exited read `worker alive` here while
|
|
282
|
+
# `--stop` answered `finished` for the same tree, so it could be neither
|
|
283
|
+
# stopped nor reaped.
|
|
284
|
+
# shellcheck source=plot-worker-state.sh
|
|
285
|
+
. "$(dirname "${BASH_SOURCE[0]}")/plot-worker-state.sh"
|
|
286
|
+
|
|
287
|
+
# The live worker's pid at a desk, or empty. The FIVE process states map to
|
|
288
|
+
# one reading: `running` is live, and `finished`, `failed`, `ended` and `none`
|
|
289
|
+
# are not.
|
|
290
|
+
#
|
|
291
|
+
# `waiting` and `stalled` are DISCARDED BY NAME. They are desk facts — a marker
|
|
292
|
+
# for a person, work on the floor — which `plot_worker_state` reaches only once
|
|
293
|
+
# no agent runs, and they answer what the agent still OWES. That question is
|
|
294
|
+
# the blocked-marker, uncommitted and unpushed readings' below; only the
|
|
295
|
+
# process fact under the two words (no agent is running) crosses into the
|
|
296
|
+
# liveness reading.
|
|
297
|
+
#
|
|
298
|
+
# A word this mapping does not know keeps the desk: it reports the recorded
|
|
299
|
+
# pid, or `unknown`, rather than guessing the process is gone.
|
|
300
|
+
desk_worker_pid() { # $1=worktree → the live worker's pid, or empty
|
|
301
|
+
local row state spid
|
|
302
|
+
row=$(plot_worker_state "$1" "")
|
|
303
|
+
state=$(printf '%s' "$row" | cut -f1)
|
|
304
|
+
spid=$(printf '%s' "$row" | cut -f2)
|
|
305
|
+
case "$state" in
|
|
306
|
+
running) printf '%s' "${spid:-unknown}" ;;
|
|
307
|
+
finished|failed|ended|none) ;;
|
|
308
|
+
waiting|stalled) ;;
|
|
309
|
+
*) printf '%s' "${spid:-unknown}" ;;
|
|
310
|
+
esac
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
# The commits on a desk's HEAD that no remote holds, as short shas, one per
|
|
314
|
+
# line. Returns 1 when they cannot be counted, which the rule reads as
|
|
315
|
+
# `unknown` and refuses on.
|
|
316
|
+
#
|
|
317
|
+
# NOT `@{upstream}..HEAD`. The host deletes a branch when its PR merges, and
|
|
318
|
+
# the reaper only removes desks whose PR merged, so after `git fetch --prune`
|
|
319
|
+
# every desk it serves has lost its upstream — and an absent upstream counts as
|
|
320
|
+
# nothing. The reading is `HEAD --not --remotes` instead: what no remote-tracking
|
|
321
|
+
# ref holds. After a squash merge and a pruned ref, the branch's own commits
|
|
322
|
+
# are on no remote ref either, so the head the host MERGED is excluded too
|
|
323
|
+
# (`pr_merged_heads`): a commit beyond it was never pushed.
|
|
324
|
+
desk_unpushed() { # $1=worktree $2=branch $3=merge reading → short shas
|
|
325
|
+
local wt="$1" br="$2" merge="$3" list heads h
|
|
326
|
+
local -a excl=()
|
|
327
|
+
list=$(git -C "$wt" rev-list --abbrev-commit HEAD --not --remotes 2>/dev/null) || return 1
|
|
328
|
+
[ -n "$list" ] || return 0
|
|
329
|
+
if [ -n "$br" ] && [ "$merge" = merged ]; then
|
|
330
|
+
heads=$(pr_merged_heads "$br") || return 1
|
|
331
|
+
for h in $heads; do
|
|
332
|
+
git -C "$wt" cat-file -e "$h^{commit}" 2>/dev/null && excl+=("$h")
|
|
333
|
+
done
|
|
334
|
+
if [ "${#excl[@]}" -gt 0 ]; then
|
|
335
|
+
list=$(git -C "$wt" rev-list --abbrev-commit HEAD --not --remotes "${excl[@]}" 2>/dev/null) \
|
|
336
|
+
|| return 1
|
|
337
|
+
else
|
|
338
|
+
# NO MERGED HEAD THIS DESK CONTAINS, and the host still said merged. The
|
|
339
|
+
# subtraction above cannot run, and without it `--not --remotes` reports
|
|
340
|
+
# EVERY commit the branch ever had — so the desk would be held forever
|
|
341
|
+
# for having done the work that merged (#1033).
|
|
342
|
+
#
|
|
343
|
+
# Two ways to reach here, both normal. A SQUASH merge rewrites the
|
|
344
|
+
# commits, so the head the host names exists nowhere in this history —
|
|
345
|
+
# the same property that makes `plot-pr-merged.sh` read `mergedAt` and
|
|
346
|
+
# never ancestry, measured here as ancestry clearing 1 of 29 finished
|
|
347
|
+
# trees against the host's 28. And a host answer that carries no head at
|
|
348
|
+
# all leaves nothing to subtract.
|
|
349
|
+
#
|
|
350
|
+
# THE READING IS PATCH-ID: `git cherry` marks a commit `-` when its
|
|
351
|
+
# change is already upstream and `+` when it is not. A `+` commit that no
|
|
352
|
+
# remote holds is work the merge did not take — a commit made after the
|
|
353
|
+
# merge — and it holds the desk. No clock is consulted: a committer date
|
|
354
|
+
# holds a rebased merged desk forever, and an author date reaps an old
|
|
355
|
+
# patch committed today (#1038). The rule stays in shell because the
|
|
356
|
+
# logic is the prefix test and nothing else; a second conditional here is
|
|
357
|
+
# the signal to move it into the domain with a corpus entry.
|
|
358
|
+
#
|
|
359
|
+
# It fails toward keeping: an unreadable base or a failing `git cherry`
|
|
360
|
+
# returns 1, which the rule reads as `unknown` and refuses on.
|
|
361
|
+
local full cherry
|
|
362
|
+
full=$(git -C "$wt" rev-list HEAD --not --remotes 2>/dev/null) || return 1
|
|
363
|
+
cherry=$(git -C "$wt" cherry "origin/$DEFAULT" HEAD 2>/dev/null) || return 1
|
|
364
|
+
list=""
|
|
365
|
+
for h in $(printf '%s\n' "$cherry" | sed -n 's/^+ //p'); do
|
|
366
|
+
case $'\n'"$full"$'\n' in
|
|
367
|
+
*$'\n'"$h"$'\n'*) list+="$(git -C "$wt" rev-parse --short "$h")"$'\n' ;;
|
|
368
|
+
esac
|
|
369
|
+
done
|
|
370
|
+
list=${list%$'\n'}
|
|
371
|
+
fi
|
|
372
|
+
fi
|
|
373
|
+
printf '%s\n' "$list"
|
|
374
|
+
}
|
|
375
|
+
|
|
190
376
|
# Where the registry lives, resolved through `plot-config.sh` — the SAME key and
|
|
191
377
|
# default the board's reader uses (`resolveManifestDir` in `registry.ts` shells
|
|
192
378
|
# out to exactly this). Two implementations of "where is the registry" is how
|
|
@@ -501,25 +687,21 @@ while IFS=$'\037' read -r wt br prunable; do
|
|
|
501
687
|
|
|
502
688
|
# The process table: the live worker's pid, or empty. Read but not judged —
|
|
503
689
|
# an empty pid file is not a live process, and which of those two it is is
|
|
504
|
-
# the rule's to say.
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
p=$(cat "$wt/.plot-worker.pid" 2>/dev/null)
|
|
508
|
-
if [ -n "$p" ] && ps -p "$p" >/dev/null 2>&1; then pid="$p"; fi
|
|
509
|
-
fi
|
|
690
|
+
# the rule's to say. "Live" means an agent runs under the recorded pid, the
|
|
691
|
+
# answer `--stop` reads; a wrapper whose agent exited is not live.
|
|
692
|
+
pid=$(desk_worker_pid "$wt")
|
|
510
693
|
|
|
511
694
|
# The tree: a PLOT-BLOCKED marker, and the first uncommitted path.
|
|
512
695
|
#
|
|
513
|
-
#
|
|
514
|
-
#
|
|
515
|
-
#
|
|
516
|
-
#
|
|
517
|
-
#
|
|
518
|
-
#
|
|
696
|
+
# `desk_dirt` excuses the paths the estate writes itself — the tiny-garden
|
|
697
|
+
# pulse and a root `PLOT-CORRECTION.md` — and reports every OTHER dirty path,
|
|
698
|
+
# which keeps this an exception rather than a hole. It is filtered HERE, in
|
|
699
|
+
# the reading, because those are facts about files this estate writes and
|
|
700
|
+
# not about whether a worktree may go. The filter runs before `head`, so a
|
|
701
|
+
# correction beside a real file names the real file.
|
|
519
702
|
marker=false
|
|
520
703
|
ls "$wt"/PLOT-BLOCKED* >/dev/null 2>&1 && marker=true
|
|
521
|
-
dirty=$(
|
|
522
|
-
| grep -v 'tiny-garden/\.plot/state' | head -1)
|
|
704
|
+
dirty=$(desk_dirt "$wt" | head -1)
|
|
523
705
|
|
|
524
706
|
# The host: whether ANY PR for this branch merged.
|
|
525
707
|
#
|
|
@@ -560,6 +742,12 @@ while IFS=$'\037' read -r wt br prunable; do
|
|
|
560
742
|
merge=merged; why="detached, nothing to land"
|
|
561
743
|
fi
|
|
562
744
|
|
|
745
|
+
# The desk again: commits only this checkout holds. Taken AFTER the merge
|
|
746
|
+
# reading, because the head the host merged is what separates a pushed
|
|
747
|
+
# commit from an unpushed one once the remote ref is gone. Lines, or the
|
|
748
|
+
# word `unknown` when they could not be counted.
|
|
749
|
+
if unpushed=$(desk_unpushed "$wt" "$short" "$merge"); then :; else unpushed=unknown; fi
|
|
750
|
+
|
|
563
751
|
# THE DECISION. One call, and the script holds no `if` about whether a
|
|
564
752
|
# worktree may go — only about what to do with the answer.
|
|
565
753
|
#
|
|
@@ -581,7 +769,7 @@ while IFS=$'\037' read -r wt br prunable; do
|
|
|
581
769
|
# the tree and says why. Silence is never permission, on this path either.
|
|
582
770
|
verdict=$(PLOT_BRANCH="$short" PLOT_DEFAULT="$DEFAULT" PLOT_PID="$pid" \
|
|
583
771
|
PLOT_DIRTY="$dirty" PLOT_MARKER="$marker" PLOT_MERGE="$merge" \
|
|
584
|
-
PLOT_RULE="$RULE" \
|
|
772
|
+
PLOT_UNPUSHED="$unpushed" PLOT_RULE="$RULE" \
|
|
585
773
|
node --input-type=module - <<'NODE_EOF' 2>/dev/null
|
|
586
774
|
// Imported from an ABSOLUTE path derived from this script, never from the
|
|
587
775
|
// cwd. The reaper runs with its cwd wherever the operator invoked it and the
|
|
@@ -603,6 +791,9 @@ const problem = firstReapRefusal({
|
|
|
603
791
|
dirtyPath: process.env.PLOT_DIRTY,
|
|
604
792
|
blockedMarker: process.env.PLOT_MARKER === "true",
|
|
605
793
|
merge: process.env.PLOT_MERGE,
|
|
794
|
+
unpushed: process.env.PLOT_UNPUSHED === "unknown"
|
|
795
|
+
? "unknown"
|
|
796
|
+
: process.env.PLOT_UNPUSHED.split("\n").filter((l) => l !== ""),
|
|
606
797
|
});
|
|
607
798
|
|
|
608
799
|
// `reap` when nothing refused; otherwise the refusal and its reading, which
|
|
@@ -622,6 +813,7 @@ NODE_EOF
|
|
|
622
813
|
live-worker) reason="worker alive (pid $detail)" ;;
|
|
623
814
|
blocked-marker) reason="PLOT-BLOCKED marker — needs a person" ;;
|
|
624
815
|
uncommitted-changes) reason="uncommitted: ${detail:0:40}" ;;
|
|
816
|
+
unpushed-commits) reason="unpushed commits: ${detail:0:40}" ;;
|
|
625
817
|
on-default-branch) reason="on $DEFAULT — dispatched branch not checked out" ;;
|
|
626
818
|
no-merged-pr) reason="unlanded work — no merged PR" ;;
|
|
627
819
|
*) reason="rule could not be asked — keeping" ;;
|
|
@@ -1032,15 +1224,12 @@ while IFS=$'\t' read -r wt br; do
|
|
|
1032
1224
|
[ -n "$MAIN_CHECKOUT" ] && [ "$(canonical "$wt")" = "$(canonical "$MAIN_CHECKOUT")" ] && continue
|
|
1033
1225
|
dshort=${br#refs/heads/}
|
|
1034
1226
|
|
|
1035
|
-
dcount=$(
|
|
1036
|
-
| grep -v 'tiny-garden/\.plot/state' | wc -l | tr -d ' ')
|
|
1227
|
+
dcount=$(desk_dirt "$wt" | wc -l | tr -d ' ')
|
|
1037
1228
|
[ "${dcount:-0}" -gt 0 ] || continue
|
|
1038
1229
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
if [ -n "$p" ] && ps -p "$p" >/dev/null 2>&1; then dpid="$p"; fi
|
|
1043
|
-
fi
|
|
1230
|
+
# The same liveness reading the reap loop takes, so a desk the reaper keeps
|
|
1231
|
+
# for a live worker and one the sweep names as owned are the same desks.
|
|
1232
|
+
dpid=$(desk_worker_pid "$wt")
|
|
1044
1233
|
|
|
1045
1234
|
dmanifest=""
|
|
1046
1235
|
if m=$(manifest_for "$(canonical "$wt")"); then dmanifest=$(basename "$m"); fi
|
package/plot-resolve-artifact.sh
CHANGED
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
set -uo pipefail
|
|
71
71
|
|
|
72
72
|
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
73
|
+
. "$script_dir/plot-tmp.sh"
|
|
73
74
|
|
|
74
75
|
# THE FILES THIS SCRIPT MAY RESOLVE — a SET, and derived rather than listed.
|
|
75
76
|
#
|
|
@@ -231,8 +232,9 @@ if ! mkdir "$lock" 2>/dev/null; then
|
|
|
231
232
|
fi
|
|
232
233
|
# Released on every exit, including a kill. A lock that outlives its process
|
|
233
234
|
# would make one interrupted repair block the branch forever — and the repair is
|
|
234
|
-
# idempotent, so there is nothing to protect after the process is gone.
|
|
235
|
-
|
|
235
|
+
# idempotent, so there is nothing to protect after the process is gone. A TERM
|
|
236
|
+
# or INT stops the repair (143 or 130) after the lock is released.
|
|
237
|
+
plot_on_exit 'rmdir "$lock" 2>/dev/null || true'
|
|
236
238
|
|
|
237
239
|
if [ -d "$wt" ] && git worktree list --porcelain | grep -qx "worktree $wt"; then
|
|
238
240
|
# A REUSED WORKTREE MAY BELONG TO SOMEONE ELSE, and on 2026-08-17 one did: the
|