@plot-pm/board 0.16.1 → 0.16.2

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
@@ -399,79 +399,35 @@ if [ -r "$CONFIG" ]; then
399
399
  fi
400
400
  case "$MANIFEST_DIR" in /*) ;; *) MANIFEST_DIR="$ROOT/$MANIFEST_DIR" ;; esac
401
401
 
402
- # Where the agent logs live, resolved through `plot-config.sh` from the SAME
403
- # `Worktree root` key `resolve_wt_root()` and `agentLogDir` read. Three readers
404
- # of one key, and none of them may invent a second: a reaper sweeping a
405
- # directory the board never writes to reports success over a file that is still
406
- # there, which is the failure this slice exists to stop.
402
+ # Where the DESKS and the agent logs live: the desk root, `deskRoot`'s answer
403
+ # asked through `plot-desk-root.sh` — the rule `plot-dispatch.sh` creates desks
404
+ # under and `agentLogDir` writes logs into. One rule, so a reaper cannot sweep a
405
+ # directory the board never writes to and report success over a file that is
406
+ # still there.
407
407
  #
408
- # THE FALLBACK IS THE PARENT DIRECTORY, NOT AN ERROR — `agentLogDir`'s rule,
409
- # stated the same way here. A repository with no key has no `.worktrees/`, and
410
- # the logs it wrote are beside it; a reaper that refused to look there would
411
- # clean nothing on exactly the repositories that never migrated.
408
+ # Two questions read the one answer. `LOG_DIR` is where a finished branch's log
409
+ # files are swept from; `WT_ROOT` is the directory a tree must sit under to be a
410
+ # candidate desk at all. The sweep's filename filter (`branch_log_files`) is
411
+ # unchanged, so what it may delete is still only `plot-<kind>-*` names. The
412
+ # default root is `<repo>/.worktrees`, which Plot owns, so a sibling checkout in
413
+ # the parent is never under it and stays silent.
412
414
  #
413
- # The case split is `resolve_wt_root()`'s: absolute taken as given, relative
414
- # joined onto the repo root, trailing slash trimmed as pure string work because
415
- # the directory need not exist. A second convention for resolving a configured
416
- # directory is a second way to be wrong.
417
- LOG_DIR="$(cd "$ROOT/.." && pwd)"
418
- if [ -r "$CONFIG" ]; then
419
- d=$(bash "$CONFIG" get "Worktree root" "" 2>/dev/null) || d=""
420
- if [ -n "$d" ]; then
421
- case "$d" in
422
- /*) LOG_DIR="$d" ;;
423
- *) LOG_DIR="$ROOT/$d" ;;
424
- esac
425
- LOG_DIR="${LOG_DIR%/}"
426
- fi
427
- fi
428
-
429
- # Where the DESKS live, which is the same key resolved for a different question.
430
- #
431
- # `LOG_DIR` and this answer two things: `LOG_DIR` is where a finished branch's
432
- # log files are swept from, and this is the directory a tree must sit under to
433
- # be a candidate desk at all. A repository with NO configured root has no
434
- # `.worktrees/` and its desks are named `plot-wt-*` beside the repo — so this
435
- # stays EMPTY there rather than defaulting to the parent, because the parent
436
- # holds every sibling checkout a person ever made and calling those candidate
437
- # desks is the over-broad reading this slice must not introduce.
438
- #
439
- # IT IS RESOLVED AGAINST THE MAIN CHECKOUT, NOT `$ROOT`, and that is the one
440
- # place in this script where the two differ on purpose. `git rev-parse
415
+ # IT IS RESOLVED AGAINST THE MAIN CHECKOUT, NOT `$ROOT`. `git rev-parse
441
416
  # --show-toplevel` answers *this* worktree, so a reaper run from inside a desk
442
- # resolves `.worktrees` to a directory beneath that desk — which does not
417
+ # would resolve `.worktrees` to a directory beneath that desk — which does not
443
418
  # exist, so every tree reads as unplaceable-but-elsewhere and the reading is
444
- # silently empty. Measured 2026-09-10 from this worktree: `$ROOT/.worktrees`
419
+ # silently empty. Measured 2026-09-10 from inside a desk: `$ROOT/.worktrees`
445
420
  # named `.../plot-wt-feature-a-finished-desk-is-a-finding/.worktrees` while
446
421
  # every desk sits under `.../plot/.worktrees`. A dispatched agent runs the
447
422
  # reaper from exactly there, so the wrong answer would be the usual one.
448
423
  #
449
- # `--git-common-dir` is shared by every worktree of one repository, and its
450
- # parent is the primary checkout — which is what `plot-dispatch.sh` composes
451
- # desk paths from when it creates them. Same directory, same key, so a
452
- # creation and this reading cannot disagree.
453
- #
454
- # `LOG_DIR` above keeps `$ROOT` untouched: it is this script's established
455
- # behaviour with its own callers, and changing where a sweep DELETES from is a
456
- # blast radius rather than a reading. Named here rather than fixed silently.
457
- WT_ROOT=""
458
- if [ -r "$CONFIG" ]; then
459
- d=$(bash "$CONFIG" get "Worktree root" "" 2>/dev/null) || d=""
460
- if [ -n "$d" ]; then
461
- case "$d" in
462
- /*) WT_ROOT="$d" ;;
463
- *)
464
- # Falls back to `$ROOT` where git cannot answer, which is the same
465
- # directory on a single-checkout repository and the only one available.
466
- main_checkout=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)") \
467
- || main_checkout="$ROOT"
468
- [ -d "$main_checkout" ] || main_checkout="$ROOT"
469
- WT_ROOT="$main_checkout/$d"
470
- ;;
471
- esac
472
- WT_ROOT="${WT_ROOT%/}"
473
- fi
474
- fi
424
+ # There is NO FALLBACK: a reaper that cannot ask the rule stops rather than
425
+ # compute a second default and delete from it.
426
+ # shellcheck source=plot-desk-root.sh
427
+ . "$(dirname "${BASH_SOURCE[0]}")/plot-desk-root.sh"
428
+ WT_ROOT=$(plot_desk_root "$(plot_repo_root)") || {
429
+ echo "plot-reap: cannot resolve the desk root — nothing was reaped." >&2; exit 2; }
430
+ LOG_DIR="$WT_ROOT"
475
431
 
476
432
  # The files ONE branch's agent run leaves beside its worktree, removed with it.
477
433
  #
@@ -165,11 +165,10 @@ fi
165
165
  # MEASURED, and recorded at length in plot-dispatch.sh's held_worktree: a
166
166
  # hand-made worktree is named for the branch with its TYPE dropped, so a gate
167
167
  # that guessed `plot-wt-<flattened>` missed a worktree with six modified files
168
- # in it. This site had the same shape — it composed `plot-wt-<flattened>` under
169
- # `repo_root/..` — and this change would make the guess worse, not better: the
170
- # new `Worktree root:` key introduces a SECOND naming convention, giving a
171
- # path guess a second way to be wrong. So the read asks git, and only the
172
- # CREATE-a-fresh-one fallback below composes a name (via dispatch's rule, so the
168
+ # in it. Desks carry two naming conventions — `plot-wt-*` beside the repo from
169
+ # older dispatches, and unprefixed names under the desk root — so a path guess
170
+ # has two ways to be wrong. So the read asks git, and only the
171
+ # CREATE-a-fresh-one fallback below composes a name (under the desk root, so the
173
172
  # fresh worktree lands where dispatch would have put it).
174
173
  #
175
174
  # `git worktree list --porcelain` emits `worktree <path>` then `branch
@@ -182,21 +181,15 @@ wt=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/he
182
181
  /^worktree / { path = substr($0, 10) }
183
182
  /^branch / { if (substr($0, 8) == want) { print path; exit } }')
184
183
 
185
- # No existing worktree holds it — compose the path a fresh one will take, by the
186
- # same root+prefix rule plot-dispatch.sh uses. Under a `Worktree root:` key the
187
- # root moves and the `plot-wt-` prefix drops; absent it, today's behaviour.
184
+ # No existing worktree holds it — compose the path a fresh one will take, under
185
+ # the desk root `plot-dispatch.sh` also asks for, so the fresh worktree lands
186
+ # where dispatch would have put it. The root is the MAIN checkout's, and there
187
+ # is no fallback: an unaskable rule refuses the repair.
188
+ # shellcheck source=plot-desk-root.sh
189
+ . "$script_dir/plot-desk-root.sh"
188
190
  if [ -z "$wt" ]; then
189
- wt_root=$("$script_dir/plot-config.sh" get "Worktree root" "")
190
- if [ -z "$wt_root" ]; then
191
- wt_root=$(cd "$repo_root/.." && pwd)
192
- wt="$wt_root/plot-wt-$(printf '%s' "$branch" | tr '/' '-')"
193
- else
194
- case "$wt_root" in
195
- /*) : ;;
196
- *) wt_root="$repo_root/$wt_root" ;;
197
- esac
198
- wt="${wt_root%/}/$(printf '%s' "$branch" | tr '/' '-')"
199
- fi
191
+ wt_root=$(plot_desk_root "$(plot_repo_root)") || finish refused no-desk-root
192
+ wt="$wt_root/$(printf '%s' "$branch" | tr '/' '-')"
200
193
  fi
201
194
 
202
195
  if [ "$dry_run" = 1 ]; then
@@ -263,6 +256,7 @@ if [ -d "$wt" ] && git worktree list --porcelain | grep -qx "worktree $wt"; then
263
256
  finish refused worktree-busy
264
257
  fi
265
258
  else
259
+ plot_exclude_desk_root "$(plot_repo_root)"
266
260
  if ! git worktree add -q "$wt" "$branch" 2>/dev/null; then
267
261
  if ! git worktree add -q -b "$branch" "$wt" "origin/$branch" 2>/dev/null; then
268
262
  echo "plot-resolve-artifact: cannot create a worktree for $branch at $wt" >&2
package/plot-tmp.sh CHANGED
@@ -1,5 +1,5 @@
1
1
  # plot-tmp.sh — every temp path a Plot script creates, and the process's only
2
- # EXIT/INT/TERM traps. SOURCED, not run.
2
+ # EXIT/INT/TERM/PIPE traps. SOURCED, not run.
3
3
  #
4
4
  # . "$SCRIPT_DIR/plot-tmp.sh"
5
5
  # plot_tmpdir work fleet-ref # $work = $TMPDIR/plot-fleet-ref.XXXXXX (a directory)
@@ -27,8 +27,11 @@
27
27
  # first call would be installed inside that call's substitution and remove the
28
28
  # path when the substitution closed. `PLOT_TMP_LOADED` makes a second source in
29
29
  # the same process a no-op, because a re-run setup would truncate the live
30
- # registry. INT and TERM run the cleanup, clear their own trap and re-raise, so
31
- # the script stops with 130 or 143 rather than running on to exit 0.
30
+ # registry. INT, TERM and PIPE run the cleanup, clear their own trap and
31
+ # re-raise, so the script stops with 130, 143 or 141 rather than running on to
32
+ # exit 0. PIPE is among them because a board server stopped with SIGTERM leaves
33
+ # its running scripts writing to closed pipes, and SIGPIPE's default action
34
+ # skips the EXIT trap.
32
35
  #
33
36
  # A script that sources this must not install its own EXIT, INT or TERM trap:
34
37
  # the last `trap` wins, and that replacement is the defect this file fixes
@@ -42,29 +45,51 @@ PLOT_TMP_LOADED=$$
42
45
  # Fixed at first source: a later `TMPDIR` change in the script does not move it.
43
46
  # A file already at this path belongs to a dead process that had the same pid,
44
47
  # and its `c:` commands are not this process's, so it is replaced, never read.
48
+ # The file itself is created at the end of this file, after the traps.
45
49
  PLOT_TMP_REGISTRY="${TMPDIR:-/tmp}/plot-reg.$$"
46
- rm -f -- "$PLOT_TMP_REGISTRY" 2>/dev/null
47
- : > "$PLOT_TMP_REGISTRY" 2>/dev/null || true
48
50
 
49
51
  # One line per entry, in registration order: `p:<path>` or `c:<command>`.
50
52
  _plot_tmp_register() {
51
53
  printf '%s:%s\n' "$1" "$2" >> "$PLOT_TMP_REGISTRY" 2>/dev/null || true
52
54
  }
53
55
 
56
+ # A SIGNAL INSIDE A CREATION WAITS FOR THE REGISTRATION. bash runs a pending
57
+ # trap as soon as `$(mktemp …)` returns, before the next line registers the
58
+ # path, so a cleanup run there would remove the registry and leave the new path
59
+ # unlisted. While `__plot_tmp_busy` is set, the signal handler records the
60
+ # signal in `__plot_tmp_pending` and returns; `_plot_tmp_create` handles it once
61
+ # the path is registered.
62
+ __plot_tmp_busy=''
63
+ __plot_tmp_pending=''
64
+
65
+ # _plot_tmp_create VAR prefix [-d] — create, register, assign to VAR.
66
+ _plot_tmp_create() {
67
+ local __plot_tmp_new __plot_tmp_rc
68
+ __plot_tmp_busy=1
69
+ if [ "${3:-}" = -d ]; then
70
+ __plot_tmp_new=$(mktemp -d "${TMPDIR:-/tmp}/plot-$2.XXXXXX")
71
+ else
72
+ __plot_tmp_new=$(mktemp "${TMPDIR:-/tmp}/plot-$2.XXXXXX")
73
+ fi
74
+ __plot_tmp_rc=$?
75
+ [ "$__plot_tmp_rc" -eq 0 ] && _plot_tmp_register p "$__plot_tmp_new"
76
+ __plot_tmp_busy=''
77
+ if [ -n "$__plot_tmp_pending" ]; then
78
+ # shellcheck disable=SC2086 # "<name> <number>", split on purpose
79
+ _plot_tmp_on_signal $__plot_tmp_pending
80
+ fi
81
+ [ "$__plot_tmp_rc" -eq 0 ] || return 1
82
+ printf -v "$1" '%s' "$__plot_tmp_new"
83
+ }
84
+
54
85
  # plot_tmpdir VAR prefix — create a directory, register it, assign it to VAR.
55
86
  plot_tmpdir() {
56
- local __plot_tmp_new
57
- __plot_tmp_new=$(mktemp -d "${TMPDIR:-/tmp}/plot-$2.XXXXXX") || return 1
58
- _plot_tmp_register p "$__plot_tmp_new"
59
- printf -v "$1" '%s' "$__plot_tmp_new"
87
+ _plot_tmp_create "$1" "$2" -d
60
88
  }
61
89
 
62
90
  # plot_tmpfile VAR prefix — create a file, register it, assign it to VAR.
63
91
  plot_tmpfile() {
64
- local __plot_tmp_new
65
- __plot_tmp_new=$(mktemp "${TMPDIR:-/tmp}/plot-$2.XXXXXX") || return 1
66
- _plot_tmp_register p "$__plot_tmp_new"
67
- printf -v "$1" '%s' "$__plot_tmp_new"
92
+ _plot_tmp_create "$1" "$2"
68
93
  }
69
94
 
70
95
  # plot_on_exit command — run `command` (one line) when the process ends.
@@ -75,9 +100,13 @@ plot_on_exit() {
75
100
  # Runs every registered command and removes every registered path, in
76
101
  # registration order, then removes the registry. Runs once: the registry is
77
102
  # gone afterwards, so a second call finds nothing.
103
+ #
104
+ # PIPE is ignored for the cleanup: the process is ending, and a registered
105
+ # command that writes to a closed stdout or stderr must not stop the removals.
78
106
  _plot_tmp_cleanup() {
79
107
  local __plot_tmp_line
80
- [ -f "$PLOT_TMP_REGISTRY" ] || return 0
108
+ [ -n "${__plot_tmp_owned:-}" ] && [ -f "$PLOT_TMP_REGISTRY" ] || return 0
109
+ trap '' PIPE
81
110
  while IFS= read -r __plot_tmp_line; do
82
111
  case $__plot_tmp_line in
83
112
  c:*) eval "${__plot_tmp_line#c:}" ;;
@@ -97,6 +126,10 @@ _plot_tmp_on_exit() {
97
126
  # $1 is the signal name, $2 its number. `kill` re-raises it with the default
98
127
  # disposition; the `exit` is reached only if the shell defers the delivery.
99
128
  _plot_tmp_on_signal() {
129
+ if [ -n "$__plot_tmp_busy" ]; then
130
+ __plot_tmp_pending="$1 $2"
131
+ return 0
132
+ fi
100
133
  trap - EXIT "$1"
101
134
  _plot_tmp_cleanup
102
135
  kill -"$1" "$$"
@@ -106,3 +139,18 @@ _plot_tmp_on_signal() {
106
139
  trap _plot_tmp_on_exit EXIT
107
140
  trap '_plot_tmp_on_signal INT 2' INT
108
141
  trap '_plot_tmp_on_signal TERM 15' TERM
142
+ trap '_plot_tmp_on_signal PIPE 13' PIPE
143
+
144
+ # THE REGISTRY IS CREATED AFTER THE TRAPS, so no signal lands between the file
145
+ # and the handler that removes it. It is replaced under the creation deferral:
146
+ # until `__plot_tmp_owned` is set, a file at this path is a dead process's and
147
+ # the cleanup leaves it unread.
148
+ __plot_tmp_busy=1
149
+ rm -f -- "$PLOT_TMP_REGISTRY" 2>/dev/null
150
+ : > "$PLOT_TMP_REGISTRY" 2>/dev/null || true
151
+ __plot_tmp_owned=1
152
+ __plot_tmp_busy=''
153
+ if [ -n "$__plot_tmp_pending" ]; then
154
+ # shellcheck disable=SC2086 # "<name> <number>", split on purpose
155
+ _plot_tmp_on_signal $__plot_tmp_pending
156
+ fi
@@ -392,6 +392,28 @@ plot_worker_dirty() { # $1=worktree → the dirty files, one per line, leftovers
392
392
  plot_worker_dirty_filter "$(git -C "$wt" status --porcelain 2>/dev/null)"
393
393
  }
394
394
 
395
+ # Keep a file the estate writes into every desk out of `git status`.
396
+ #
397
+ # THROUGH THE CLONE'S `info/exclude`, never `.gitignore`: a `.gitignore` rule
398
+ # lives in the branch's own content, so a desk cut from an older branch would
399
+ # not see it, and `info/exclude` is per-repository and shared by every worktree.
400
+ # The directory and the file are CREATED when absent. Measured 2026-10-01
401
+ # (#1130): a clone with no `.git/info/exclude` kept `?? .metadata_never_index`
402
+ # in every free desk, and the AgentMonitor reported each waiting agent as
403
+ # `holds unlanded work`, because the writer skipped a file that did not exist.
404
+ #
405
+ # Best-effort: a desk that cannot take the line still works. Always returns 0.
406
+ plot_desk_exclude() { # $1=worktree $2=the exact line to exclude
407
+ local common excl
408
+ common=$(git -C "$1" rev-parse --git-common-dir 2>/dev/null) || return 0
409
+ [ -n "$common" ] || return 0
410
+ case "$common" in /*) ;; *) common="$1/$common" ;; esac
411
+ excl="$common/info/exclude"
412
+ mkdir -p "$common/info" 2>/dev/null || return 0
413
+ grep -qxF "$2" "$excl" 2>/dev/null || printf '%s\n' "$2" >> "$excl" 2>/dev/null || true
414
+ return 0
415
+ }
416
+
395
417
  # The same filter, over status output the CALLER already has.
396
418
  #
397
419
  # SPLIT OUT BECAUSE THE STATUS CALL IS THE EXPENSIVE HALF and one caller had