@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/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=$'\t' read -r wt br prunable; do
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" "$short" "directory gone — 'git worktree prune' clears the entry"
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" "$short" \
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
- pid=""
498
- if [ -f "$wt/.plot-worker.pid" ]; then
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
- # The tiny-garden pulse is excused because every board suite rewrites it — a
506
- # worker that did nothing but run the tests would otherwise never be
507
- # reapable. Any OTHER dirty path is still reported, which keeps this an
508
- # exception rather than a hole. It is filtered HERE, in the reading, because
509
- # it is a fact about this repository's fixtures and not about whether a
510
- # worktree may go.
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=$(git -C "$wt" status --porcelain 2>/dev/null \
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" "$short" "$reason"; kept=$((kept+1)); continue
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" "$short" "--max $MAX reached"; kept=$((kept+1)); continue
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" "$short" "$why${logs:+, log $logs}"
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" "$short" "$why"; removed=$((removed+1))
773
+ printf '%-8s %-52s %s\n' "reaped" "$label" "$why"; removed=$((removed+1))
651
774
  else
652
- printf '%-8s %-52s %s\n' "FAILED" "$short" "git worktree remove refused"; kept=$((kept+1))
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 (br != "") print p"\t"br"\t"pr; p=$2; br=""; pr="no"; next }
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 (br != "") print p"\t"br"\t"pr }')
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=$(git -C "$wt" status --porcelain 2>/dev/null \
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
- dpid=""
1013
- if [ -f "$wt/.plot-worker.pid" ]; then
1014
- p=$(cat "$wt/.plot-worker.pid" 2>/dev/null)
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