@plot-pm/board 0.14.3 → 0.15.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 +152 -145
- package/package.json +2 -1
- package/plot-agent-manifest.sh +58 -0
- package/plot-approve.sh +23 -1
- package/plot-budget.sh +166 -1
- package/plot-config.sh +34 -1
- package/plot-deliver.sh +24 -3
- package/plot-dispatch.sh +283 -13
- package/plot-fleet-scan.sh +387 -79
- package/plot-host.sh +230 -16
- package/plot-plan-meta.sh +10 -3
- package/plot-pr-merged.sh +29 -2
- package/plot-reap.sh +152 -32
package/plot-reap.sh
CHANGED
|
@@ -187,6 +187,96 @@ git fetch origin "$DEFAULT" --quiet 2>/dev/null || true
|
|
|
187
187
|
# newest — and defines `pr_merged` and nothing else on load.
|
|
188
188
|
. "$(dirname "${BASH_SOURCE[0]}")/plot-pr-merged.sh"
|
|
189
189
|
|
|
190
|
+
# Which uncommitted paths count as unlanded work. SOURCED, because the reap
|
|
191
|
+
# reading, the dirty sweep below and `plot-reconcile-scan.sh` section 21 must
|
|
192
|
+
# read one tree the same way; the helper names each excused path.
|
|
193
|
+
. "$(dirname "${BASH_SOURCE[0]}")/plot-desk-dirt.sh"
|
|
194
|
+
|
|
195
|
+
# Is an AGENT still working at a desk? SOURCED from `plot-worker-state.sh`,
|
|
196
|
+
# the ONE classifier `plot-dispatch.sh --stop` also asks. Reading only whether
|
|
197
|
+
# the recorded pid answers `ps` asked about the wrapper shell, which outlives
|
|
198
|
+
# its agent: a desk whose agent had exited read `worker alive` here while
|
|
199
|
+
# `--stop` answered `finished` for the same tree, so it could be neither
|
|
200
|
+
# stopped nor reaped.
|
|
201
|
+
# shellcheck source=plot-worker-state.sh
|
|
202
|
+
. "$(dirname "${BASH_SOURCE[0]}")/plot-worker-state.sh"
|
|
203
|
+
|
|
204
|
+
# The live worker's pid at a desk, or empty. The FIVE process states map to
|
|
205
|
+
# one reading: `running` is live, and `finished`, `failed`, `ended` and `none`
|
|
206
|
+
# are not.
|
|
207
|
+
#
|
|
208
|
+
# `waiting` and `stalled` are DISCARDED BY NAME. They are desk facts — a marker
|
|
209
|
+
# for a person, work on the floor — which `plot_worker_state` reaches only once
|
|
210
|
+
# no agent runs, and they answer what the agent still OWES. That question is
|
|
211
|
+
# the blocked-marker, uncommitted and unpushed readings' below; only the
|
|
212
|
+
# process fact under the two words (no agent is running) crosses into the
|
|
213
|
+
# liveness reading.
|
|
214
|
+
#
|
|
215
|
+
# A word this mapping does not know keeps the desk: it reports the recorded
|
|
216
|
+
# pid, or `unknown`, rather than guessing the process is gone.
|
|
217
|
+
desk_worker_pid() { # $1=worktree → the live worker's pid, or empty
|
|
218
|
+
local row state spid
|
|
219
|
+
row=$(plot_worker_state "$1" "")
|
|
220
|
+
state=$(printf '%s' "$row" | cut -f1)
|
|
221
|
+
spid=$(printf '%s' "$row" | cut -f2)
|
|
222
|
+
case "$state" in
|
|
223
|
+
running) printf '%s' "${spid:-unknown}" ;;
|
|
224
|
+
finished|failed|ended|none) ;;
|
|
225
|
+
waiting|stalled) ;;
|
|
226
|
+
*) printf '%s' "${spid:-unknown}" ;;
|
|
227
|
+
esac
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# The commits on a desk's HEAD that no remote holds, as short shas, one per
|
|
231
|
+
# line. Returns 1 when they cannot be counted, which the rule reads as
|
|
232
|
+
# `unknown` and refuses on.
|
|
233
|
+
#
|
|
234
|
+
# NOT `@{upstream}..HEAD`. The host deletes a branch when its PR merges, and
|
|
235
|
+
# the reaper only removes desks whose PR merged, so after `git fetch --prune`
|
|
236
|
+
# every desk it serves has lost its upstream — and an absent upstream counts as
|
|
237
|
+
# nothing. The reading is `HEAD --not --remotes` instead: what no remote-tracking
|
|
238
|
+
# ref holds. After a squash merge and a pruned ref, the branch's own commits
|
|
239
|
+
# are on no remote ref either, so the head the host MERGED is excluded too
|
|
240
|
+
# (`pr_merged_heads`): a commit beyond it was never pushed.
|
|
241
|
+
desk_unpushed() { # $1=worktree $2=branch $3=merge reading → short shas
|
|
242
|
+
local wt="$1" br="$2" merge="$3" list heads h
|
|
243
|
+
local -a excl=()
|
|
244
|
+
list=$(git -C "$wt" rev-list --abbrev-commit HEAD --not --remotes 2>/dev/null) || return 1
|
|
245
|
+
[ -n "$list" ] || return 0
|
|
246
|
+
if [ -n "$br" ] && [ "$merge" = merged ]; then
|
|
247
|
+
heads=$(pr_merged_heads "$br") || return 1
|
|
248
|
+
for h in $heads; do
|
|
249
|
+
git -C "$wt" cat-file -e "$h^{commit}" 2>/dev/null && excl+=("$h")
|
|
250
|
+
done
|
|
251
|
+
if [ "${#excl[@]}" -gt 0 ]; then
|
|
252
|
+
list=$(git -C "$wt" rev-list --abbrev-commit HEAD --not --remotes "${excl[@]}" 2>/dev/null) \
|
|
253
|
+
|| return 1
|
|
254
|
+
else
|
|
255
|
+
# NO MERGED HEAD THIS DESK CONTAINS, and the host still said merged. The
|
|
256
|
+
# subtraction above cannot run, and without it `--not --remotes` reports
|
|
257
|
+
# EVERY commit the branch ever had — so the desk would be held forever
|
|
258
|
+
# for having done the work that merged.
|
|
259
|
+
#
|
|
260
|
+
# Two ways to reach here, both normal. A SQUASH merge rewrites the
|
|
261
|
+
# commits, so the head the host names exists nowhere in this history —
|
|
262
|
+
# the same property that makes `plot-pr-merged.sh` read `mergedAt` and
|
|
263
|
+
# never ancestry, measured here as ancestry clearing 1 of 29 finished
|
|
264
|
+
# trees against the host's 28. And a host answer that carries no head at
|
|
265
|
+
# all leaves nothing to subtract.
|
|
266
|
+
#
|
|
267
|
+
# The host's answer decides: it said this branch's work landed, and no
|
|
268
|
+
# reading taken from refs may overrule it. Answer empty.
|
|
269
|
+
#
|
|
270
|
+
# THE GUARD KEEPS ITS WHOLE POPULATION. It protects a desk whose agent
|
|
271
|
+
# finished and whose work has NOT merged, which is where work is lost.
|
|
272
|
+
# The merged desk that holds a commit the PR did not carry is still held,
|
|
273
|
+
# by the subtraction above, whenever the merged head is one this desk has.
|
|
274
|
+
list=""
|
|
275
|
+
fi
|
|
276
|
+
fi
|
|
277
|
+
printf '%s\n' "$list"
|
|
278
|
+
}
|
|
279
|
+
|
|
190
280
|
# Where the registry lives, resolved through `plot-config.sh` — the SAME key and
|
|
191
281
|
# default the board's reader uses (`resolveManifestDir` in `registry.ts` shells
|
|
192
282
|
# out to exactly this). Two implementations of "where is the registry" is how
|
|
@@ -385,11 +475,19 @@ manifest_for() {
|
|
|
385
475
|
reap=0; kept=0; removed=0; cleared=0; vanished=0; unplaced=0
|
|
386
476
|
printf '%-8s %-52s %s\n' "verdict" "branch" "why"
|
|
387
477
|
|
|
388
|
-
while IFS=$'\
|
|
478
|
+
while IFS=$'\037' read -r wt br prunable; do
|
|
389
479
|
[ -n "$wt" ] || continue
|
|
390
480
|
short=${br#refs/heads/}
|
|
391
481
|
[ "$wt" = "$ROOT" ] && continue
|
|
392
482
|
|
|
483
|
+
# WHAT THE REPORT CALLS THIS TREE. `short` is the branch and stays empty for
|
|
484
|
+
# a detached desk, which is the correct reading and a useless label — an
|
|
485
|
+
# operator reading a blank column cannot tell which of thirteen it names.
|
|
486
|
+
# The directory is what identifies such a tree, so the report says so and
|
|
487
|
+
# every refusal still measures `short`.
|
|
488
|
+
label=$short
|
|
489
|
+
[ -z "$label" ] && label="(detached) $(basename "$wt")"
|
|
490
|
+
|
|
393
491
|
# 4a. GIT'S OWN ANSWER THAT THE DIRECTORY IS GONE, and it is a REPORT rather
|
|
394
492
|
# than a sixth refusal. The five below each say *do not remove this* and
|
|
395
493
|
# send an operator to look; this says *there is nothing to remove and the
|
|
@@ -414,7 +512,7 @@ while IFS=$'\t' read -r wt br prunable; do
|
|
|
414
512
|
# prune stays the operator's decision — the same discipline that makes
|
|
415
513
|
# every refusal a measurement rather than an act.
|
|
416
514
|
if [ "$prunable" = "yes" ]; then
|
|
417
|
-
printf '%-8s %-52s %s\n' "vanished" "$
|
|
515
|
+
printf '%-8s %-52s %s\n' "vanished" "$label" "directory gone — 'git worktree prune' clears the entry"
|
|
418
516
|
vanished=$((vanished+1)); continue
|
|
419
517
|
fi
|
|
420
518
|
|
|
@@ -482,7 +580,7 @@ while IFS=$'\t' read -r wt br prunable; do
|
|
|
482
580
|
# population the rule excludes. It counts, because a refusal that counts is
|
|
483
581
|
# the difference between *nothing to clean* and *nothing was looked at*.
|
|
484
582
|
if [ "$unclassified" = true ]; then
|
|
485
|
-
printf '%-8s %-52s %s\n' "unknown" "$
|
|
583
|
+
printf '%-8s %-52s %s\n' "unknown" "$label" \
|
|
486
584
|
"under $(basename "$WT_ROOT")/, no worker pid and no recognised name — needs a person"
|
|
487
585
|
unplaced=$((unplaced+1)); continue
|
|
488
586
|
fi
|
|
@@ -493,25 +591,21 @@ while IFS=$'\t' read -r wt br prunable; do
|
|
|
493
591
|
|
|
494
592
|
# The process table: the live worker's pid, or empty. Read but not judged —
|
|
495
593
|
# an empty pid file is not a live process, and which of those two it is is
|
|
496
|
-
# the rule's to say.
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
p=$(cat "$wt/.plot-worker.pid" 2>/dev/null)
|
|
500
|
-
if [ -n "$p" ] && ps -p "$p" >/dev/null 2>&1; then pid="$p"; fi
|
|
501
|
-
fi
|
|
594
|
+
# the rule's to say. "Live" means an agent runs under the recorded pid, the
|
|
595
|
+
# answer `--stop` reads; a wrapper whose agent exited is not live.
|
|
596
|
+
pid=$(desk_worker_pid "$wt")
|
|
502
597
|
|
|
503
598
|
# The tree: a PLOT-BLOCKED marker, and the first uncommitted path.
|
|
504
599
|
#
|
|
505
|
-
#
|
|
506
|
-
#
|
|
507
|
-
#
|
|
508
|
-
#
|
|
509
|
-
#
|
|
510
|
-
#
|
|
600
|
+
# `desk_dirt` excuses the paths the estate writes itself — the tiny-garden
|
|
601
|
+
# pulse and a root `PLOT-CORRECTION.md` — and reports every OTHER dirty path,
|
|
602
|
+
# which keeps this an exception rather than a hole. It is filtered HERE, in
|
|
603
|
+
# the reading, because those are facts about files this estate writes and
|
|
604
|
+
# not about whether a worktree may go. The filter runs before `head`, so a
|
|
605
|
+
# correction beside a real file names the real file.
|
|
511
606
|
marker=false
|
|
512
607
|
ls "$wt"/PLOT-BLOCKED* >/dev/null 2>&1 && marker=true
|
|
513
|
-
dirty=$(
|
|
514
|
-
| grep -v 'tiny-garden/\.plot/state' | head -1)
|
|
608
|
+
dirty=$(desk_dirt "$wt" | head -1)
|
|
515
609
|
|
|
516
610
|
# The host: whether ANY PR for this branch merged.
|
|
517
611
|
#
|
|
@@ -531,8 +625,33 @@ while IFS=$'\t' read -r wt br prunable; do
|
|
|
531
625
|
merge=merged; why="merged into $DEFAULT"
|
|
532
626
|
elif [ -n "$short" ] && pr_merged "$short"; then
|
|
533
627
|
merge=merged; why="PR merged (squash)"
|
|
628
|
+
elif [ -z "$short" ] \
|
|
629
|
+
&& [ "$(git -C "$wt" rev-list --count "origin/$DEFAULT..HEAD" 2>/dev/null || echo 1)" = "0" ]; then
|
|
630
|
+
# A DETACHED DESK HAS NOTHING TO LAND, and that is a measurement rather
|
|
631
|
+
# than a weakened refusal. `plot-dispatch.sh --start` cuts a free agent's
|
|
632
|
+
# desk detached at `origin/<main>` because a free agent holds no slice, so
|
|
633
|
+
# such a tree never had a branch and can never have a PR. Reading it
|
|
634
|
+
# through `no-merged-pr` would refuse every one of them forever — the same
|
|
635
|
+
# silence this slice removes, in the opposite direction.
|
|
636
|
+
#
|
|
637
|
+
# THE READING IS `HEAD` AGAINST THE DEFAULT BRANCH, never the desk's name.
|
|
638
|
+
# Measured 2026-09-22: three `free-*` desks hold a branch and two carry
|
|
639
|
+
# live workers (pids 27820, 6542), so a prefix-keyed test reaps a running
|
|
640
|
+
# agent. Detachment is the condition; the name is not.
|
|
641
|
+
#
|
|
642
|
+
# A detached desk carrying commits falls through to `not-merged` and is
|
|
643
|
+
# kept, which is correct: somebody committed there and nothing says the
|
|
644
|
+
# work landed. The live-pid, marker and dirty refusals are all asked
|
|
645
|
+
# before this and are unaffected.
|
|
646
|
+
merge=merged; why="detached, nothing to land"
|
|
534
647
|
fi
|
|
535
648
|
|
|
649
|
+
# The desk again: commits only this checkout holds. Taken AFTER the merge
|
|
650
|
+
# reading, because the head the host merged is what separates a pushed
|
|
651
|
+
# commit from an unpushed one once the remote ref is gone. Lines, or the
|
|
652
|
+
# word `unknown` when they could not be counted.
|
|
653
|
+
if unpushed=$(desk_unpushed "$wt" "$short" "$merge"); then :; else unpushed=unknown; fi
|
|
654
|
+
|
|
536
655
|
# THE DECISION. One call, and the script holds no `if` about whether a
|
|
537
656
|
# worktree may go — only about what to do with the answer.
|
|
538
657
|
#
|
|
@@ -554,7 +673,7 @@ while IFS=$'\t' read -r wt br prunable; do
|
|
|
554
673
|
# the tree and says why. Silence is never permission, on this path either.
|
|
555
674
|
verdict=$(PLOT_BRANCH="$short" PLOT_DEFAULT="$DEFAULT" PLOT_PID="$pid" \
|
|
556
675
|
PLOT_DIRTY="$dirty" PLOT_MARKER="$marker" PLOT_MERGE="$merge" \
|
|
557
|
-
PLOT_RULE="$RULE" \
|
|
676
|
+
PLOT_UNPUSHED="$unpushed" PLOT_RULE="$RULE" \
|
|
558
677
|
node --input-type=module - <<'NODE_EOF' 2>/dev/null
|
|
559
678
|
// Imported from an ABSOLUTE path derived from this script, never from the
|
|
560
679
|
// cwd. The reaper runs with its cwd wherever the operator invoked it and the
|
|
@@ -576,6 +695,9 @@ const problem = firstReapRefusal({
|
|
|
576
695
|
dirtyPath: process.env.PLOT_DIRTY,
|
|
577
696
|
blockedMarker: process.env.PLOT_MARKER === "true",
|
|
578
697
|
merge: process.env.PLOT_MERGE,
|
|
698
|
+
unpushed: process.env.PLOT_UNPUSHED === "unknown"
|
|
699
|
+
? "unknown"
|
|
700
|
+
: process.env.PLOT_UNPUSHED.split("\n").filter((l) => l !== ""),
|
|
579
701
|
});
|
|
580
702
|
|
|
581
703
|
// `reap` when nothing refused; otherwise the refusal and its reading, which
|
|
@@ -595,15 +717,16 @@ NODE_EOF
|
|
|
595
717
|
live-worker) reason="worker alive (pid $detail)" ;;
|
|
596
718
|
blocked-marker) reason="PLOT-BLOCKED marker — needs a person" ;;
|
|
597
719
|
uncommitted-changes) reason="uncommitted: ${detail:0:40}" ;;
|
|
720
|
+
unpushed-commits) reason="unpushed commits: ${detail:0:40}" ;;
|
|
598
721
|
on-default-branch) reason="on $DEFAULT — dispatched branch not checked out" ;;
|
|
599
722
|
no-merged-pr) reason="unlanded work — no merged PR" ;;
|
|
600
723
|
*) reason="rule could not be asked — keeping" ;;
|
|
601
724
|
esac
|
|
602
|
-
printf '%-8s %-52s %s\n' "keep" "$
|
|
725
|
+
printf '%-8s %-52s %s\n' "keep" "$label" "$reason"; kept=$((kept+1)); continue
|
|
603
726
|
fi
|
|
604
727
|
|
|
605
728
|
if [ "$MAX" -gt 0 ] && [ "$reap" -ge "$MAX" ]; then
|
|
606
|
-
printf '%-8s %-52s %s\n' "keep" "$
|
|
729
|
+
printf '%-8s %-52s %s\n' "keep" "$label" "--max $MAX reached"; kept=$((kept+1)); continue
|
|
607
730
|
fi
|
|
608
731
|
|
|
609
732
|
# Resolved BEFORE the removal, because `canonical` needs the directory to
|
|
@@ -619,7 +742,7 @@ NODE_EOF
|
|
|
619
742
|
|
|
620
743
|
reap=$((reap+1))
|
|
621
744
|
if [ "$DRY" -eq 1 ]; then
|
|
622
|
-
printf '%-8s %-52s %s\n' "would" "$
|
|
745
|
+
printf '%-8s %-52s %s\n' "would" "$label" "$why${logs:+, log $logs}"
|
|
623
746
|
else
|
|
624
747
|
if git worktree remove --force "$wt" 2>/dev/null; then
|
|
625
748
|
# The worktree is gone; NOW the manifest may go. Inside the success arm
|
|
@@ -647,16 +770,16 @@ NODE_EOF
|
|
|
647
770
|
while IFS= read -r f; do rm -f "$f" 2>/dev/null; done < <(branch_log_files "$short")
|
|
648
771
|
why="$why, log removed"
|
|
649
772
|
fi
|
|
650
|
-
printf '%-8s %-52s %s\n' "reaped" "$
|
|
773
|
+
printf '%-8s %-52s %s\n' "reaped" "$label" "$why"; removed=$((removed+1))
|
|
651
774
|
else
|
|
652
|
-
printf '%-8s %-52s %s\n' "FAILED" "$
|
|
775
|
+
printf '%-8s %-52s %s\n' "FAILED" "$label" "git worktree remove refused"; kept=$((kept+1))
|
|
653
776
|
fi
|
|
654
777
|
fi
|
|
655
778
|
done < <(git worktree list --porcelain \
|
|
656
|
-
| awk '/^worktree /{ if (
|
|
779
|
+
| awk -v OFS="\037" '/^worktree /{ if (p != "") print p, br, pr; p=$2; br=""; pr="no"; next }
|
|
657
780
|
/^branch / { br=$2; next }
|
|
658
781
|
/^prunable/ { pr="yes"; next }
|
|
659
|
-
END { if (
|
|
782
|
+
END { if (p != "") print p, br, pr }')
|
|
660
783
|
|
|
661
784
|
[ "$DRY" -eq 0 ] && git worktree prune 2>/dev/null
|
|
662
785
|
|
|
@@ -1005,15 +1128,12 @@ while IFS=$'\t' read -r wt br; do
|
|
|
1005
1128
|
[ -n "$MAIN_CHECKOUT" ] && [ "$(canonical "$wt")" = "$(canonical "$MAIN_CHECKOUT")" ] && continue
|
|
1006
1129
|
dshort=${br#refs/heads/}
|
|
1007
1130
|
|
|
1008
|
-
dcount=$(
|
|
1009
|
-
| grep -v 'tiny-garden/\.plot/state' | wc -l | tr -d ' ')
|
|
1131
|
+
dcount=$(desk_dirt "$wt" | wc -l | tr -d ' ')
|
|
1010
1132
|
[ "${dcount:-0}" -gt 0 ] || continue
|
|
1011
1133
|
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
if [ -n "$p" ] && ps -p "$p" >/dev/null 2>&1; then dpid="$p"; fi
|
|
1016
|
-
fi
|
|
1134
|
+
# The same liveness reading the reap loop takes, so a desk the reaper keeps
|
|
1135
|
+
# for a live worker and one the sweep names as owned are the same desks.
|
|
1136
|
+
dpid=$(desk_worker_pid "$wt")
|
|
1017
1137
|
|
|
1018
1138
|
dmanifest=""
|
|
1019
1139
|
if m=$(manifest_for "$(canonical "$wt")"); then dmanifest=$(basename "$m"); fi
|