@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-dispatch.sh CHANGED
@@ -4,6 +4,7 @@
4
4
  # [--max N] [--allow-local] <slug>
5
5
  # plot-dispatch.sh --start [N] [--dry-run]
6
6
  # plot-dispatch.sh --migrate [--yes] [--max N]
7
+ # plot-dispatch.sh --release <branch>
7
8
  # --status list fleet worktrees with worker pid, liveness, and last log
8
9
  # line; then exit. Works regardless of plan phase.
9
10
  # --stop <br> stop the worker on <br> (branch required — never "all").
@@ -23,6 +24,18 @@
23
24
  # word), on a live worker, and on a PLOT-BLOCKED marker. The
24
25
  # worktree is inherited exactly as it stands — uncommitted work
25
26
  # is what a stall leaves behind, and this must not destroy it.
27
+ # --release <br>
28
+ # return an ABANDONED slice to the queue: clear the `branch` of
29
+ # every agent manifest naming <br>, then delete the claim ref
30
+ # origin/<br>. --stop ends a worker and KEEPS the claim, because
31
+ # stopping is not abandoning; --release is the act that gives the
32
+ # slice up. Deleting the ref by hand is not the same act: the
33
+ # manifest still names the branch. Branch required. Refuses on a
34
+ # PR (open or merged), a host it cannot ask, a live worker (named
35
+ # by pid), real work (a file-changing commit on origin/<br>, or
36
+ # unpushed commits or uncommitted changes on the local desk), and
37
+ # a PLOT-BLOCKED marker. A refusal writes nothing. The desk is
38
+ # never touched.
26
39
  # --migrate move legacy worktrees into the configured `Worktree root:`. An
27
40
  # idle worktree (no live worker, no unlanded work) is moved; a
28
41
  # busy one is skipped with the reason. Requires a `Worktree root:`
@@ -145,8 +158,10 @@
145
158
  # think they won. Git is the lock only when the refs actually diverge.
146
159
  # - Worktrees are adopted, never duplicated. A dispatcher that dies halfway
147
160
  # through a fan-out is safe to re-run.
148
- # - Nothing is ever deleted. Cleanup belongs to /plot-reconcile, which can
149
- # tell a deliberately abandoned claim from a dead worker.
161
+ # - A fan-out deletes nothing. Cleanup belongs to /plot-reconcile, which can
162
+ # tell a deliberately abandoned claim from a dead worker. The one deletion
163
+ # here is `--release <branch>`, which a person runs after making that call,
164
+ # and which refuses wherever the claim is not abandoned.
150
165
  # - The `Started:` record is booked on the DEFAULT BRANCH, after the claims,
151
166
  # and only for branches this run newly claimed. A re-run books nothing it
152
167
  # merely re-adopted. If the booking cannot be pushed, the fan-out stands
@@ -191,6 +206,12 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
191
206
  # shellcheck source=plot-default-branch.sh
192
207
  . "$script_dir/plot-default-branch.sh"
193
208
 
209
+ # The ONE writer of a manifest's empty `branch` — `clear_manifest_branch`,
210
+ # shared with `plot-worker-loop.sh`. `--release` clears an abandoned slice's
211
+ # assignment with it rather than with a second writer.
212
+ # shellcheck source=plot-agent-manifest.sh
213
+ . "$script_dir/plot-agent-manifest.sh"
214
+
194
215
  # ---------------------------------------------------------------------------
195
216
  # WHERE THE WORKTREES LIVE, and by what name
196
217
  # ---------------------------------------------------------------------------
@@ -239,6 +260,7 @@ no_brief=0
239
260
  mode=dispatch
240
261
  stop_branch=""
241
262
  restart_branch=""
263
+ release_branch=""
242
264
  # EMPTY MEANS "THE DEFAULT", AND THE DEFAULT IS THE RULE'S. `fleetSize` owns the
243
265
  # number and the argument for it; a literal here would be a second copy of a
244
266
  # decision that has one home. It is filled from the rule below.
@@ -275,6 +297,9 @@ while [ $# -gt 0 ]; do
275
297
  # plan for feature/x", which describes neither what was asked nor what
276
298
  # went wrong. The branch is consumed only when it looks like one.
277
299
  --restart) mode=restart; case "${2:-}" in */*) restart_branch="$2"; shift ;; esac ;;
300
+ # The same rule once more: a bare `--release <slug>` must not delete a ref
301
+ # named after a plan slug.
302
+ --release) mode=release; case "${2:-}" in */*) release_branch="$2"; shift ;; esac ;;
278
303
  # `--start [N]` brings FREE agents into existence — registered, waiting, and
279
304
  # holding no slice. The count is OPTIONAL and only a bare number is consumed,
280
305
  # the same rule `--stop` and `--restart` apply to a branch: a value that does
@@ -312,10 +337,11 @@ while [ $# -gt 0 ]; do
312
337
  shift ;;
313
338
  # THE RANGE MOVED WITH THE HEADER IT PRINTS. It ended at `<slug>` and still
314
339
  # does; adding `--agent` above pushed that line from 59 to 69, and
315
- # documenting the plan-declared kind pushed it from 69 to 78. Two records
340
+ # documenting the plan-declared kind pushed it from 69 to 78, and
341
+ # `--release` pushed it from 78 to 91. Two records
316
342
  # of one fact, and nothing compares them — a stale number here silently
317
343
  # truncates the help rather than failing, so it is checked by a test.
318
- -h|--help) sed -n '2,78p' "$0"; exit 0 ;;
344
+ -h|--help) sed -n '2,91p' "$0"; exit 0 ;;
319
345
  *) slug="$1" ;;
320
346
  esac
321
347
  shift
@@ -428,6 +454,23 @@ json_escape() {
428
454
  #
429
455
  # Written to a temp file and moved into place, so a scan reading the directory
430
456
  # never sees a half-written manifest. `mv` within one directory is atomic.
457
+ # The directory the `Agent registry` key names, resolved against a repo root.
458
+ #
459
+ # The case split is `resolve_wt_root`'s: absolute taken as given, relative
460
+ # joined onto the repo root, trailing slash trimmed as pure string work because
461
+ # the directory need not exist yet. One resolver, read by `start_worker` when it
462
+ # writes a manifest and by `--release` when it clears one, so the two cannot
463
+ # look in different places.
464
+ agent_registry_dir() { # $1=repo_root → prints the directory
465
+ local dir
466
+ dir=$("$script_dir/plot-config.sh" get "Agent registry" ".plot/agents")
467
+ case "$dir" in
468
+ /*) ;;
469
+ *) dir="$1/$dir" ;;
470
+ esac
471
+ printf '%s' "${dir%/}"
472
+ }
473
+
431
474
  write_agent_manifest() { # $1=path $2=session $3=branch $4=worktree $5=command
432
475
  local out="$1" tmp="$1.plot-tmp"
433
476
  {
@@ -1119,12 +1162,7 @@ start_worker() {
1119
1162
  # resolving a configured directory is a second way to be wrong.
1120
1163
  local session manifest_dir
1121
1164
  session=$(plot_session_id)
1122
- manifest_dir=$("$script_dir/plot-config.sh" get "Agent registry" ".plot/agents")
1123
- case "$manifest_dir" in
1124
- /*) ;;
1125
- *) manifest_dir="$repo_root/$manifest_dir" ;;
1126
- esac
1127
- manifest_dir="${manifest_dir%/}"
1165
+ manifest_dir=$(agent_registry_dir "$repo_root")
1128
1166
  mkdir -p "$manifest_dir" 2>/dev/null || true
1129
1167
  # `printf` per field with no interpretation: a command containing quotes,
1130
1168
  # newlines or backslashes must survive into valid JSON, and this is the one
@@ -1171,8 +1209,9 @@ start_worker() {
1171
1209
  echo " refusing to start $branch — its agent manifest could not be written:"
1172
1210
  echo " $manifest_dir/$session.json"
1173
1211
  echo " An unregistered worker cannot be seen, stopped or reaped, and holds"
1174
- echo " a claim nobody can release. The worktree and claim are untouched —"
1175
- echo " fix the path above (see the 'Agent registry' key) and dispatch again."
1212
+ echo " a claim only 'plot-dispatch.sh --release $branch' can give up. The worktree"
1213
+ echo " and claim are untouched — fix the path above (see the 'Agent registry'"
1214
+ echo " key) and dispatch again."
1176
1215
  return 1
1177
1216
  fi
1178
1217
  # TWO PIDS, TWO NAMES. `.plot-worker.pid` must name the AGENT — the process
@@ -1699,7 +1738,8 @@ if [ "$mode" = "stop" ]; then
1699
1738
  exit 1; }
1700
1739
  # The worktree and its claim are left in place: the branch is still taken,
1701
1740
  # and deleting either would be the kind of write this design avoids.
1702
- echo " worktree kept at $wt — the claim stands until you release it"
1741
+ echo " worktree kept at $wt — the claim stands until you release it:"
1742
+ echo " plot-dispatch.sh --release $stop_branch"
1703
1743
  ;;
1704
1744
  finished*|waiting*|stalled*|failed*|ended*) echo "$stop_branch is not running ($st)" ;;
1705
1745
  *) echo "$stop_branch has no worker" ;;
@@ -1807,6 +1847,232 @@ if [ "$mode" = "restart" ]; then
1807
1847
  exit 0
1808
1848
  fi
1809
1849
 
1850
+ if [ "$mode" = "release" ]; then
1851
+ # THE COUNTERPART TO --stop THAT NOBODY WROTE. `--stop` ends a worker and
1852
+ # keeps the claim, because stopping is not abandoning. This is the act that
1853
+ # gives an abandoned slice back to the queue.
1854
+ #
1855
+ # THE ASSIGNMENT HAS TWO RECORDS, and this clears both. The remote claim ref
1856
+ # is what the scan and the registry's queue read as *somebody took this*; the
1857
+ # agent manifest's `branch` field is what the registry wrote when it handed
1858
+ # the slice over. Deleting only the ref — the repair `plot-reap.sh` and
1859
+ # `plot-reconcile-scan.sh` leave to a person — leaves the manifest naming a
1860
+ # slice the queue now offers to somebody else. Measured 2026-09-26:
1861
+ # `feature/the-board-filters-to-my-work` was handed out twice after exactly
1862
+ # that hand repair, and the second agent abandoned a desk with unpushed work.
1863
+ #
1864
+ # BEFORE THE PHASE GATE, beside --stop and --restart: a claimed branch is
1865
+ # work in flight whatever the plan's phase now says.
1866
+ if [ -z "$release_branch" ]; then
1867
+ echo "plot-dispatch: --release needs a branch name, e.g. --release feature/x" >&2
1868
+ echo " A slug is not enough: which claim is abandoned is your call, and" >&2
1869
+ echo " a release deletes a ref that cannot be re-created." >&2
1870
+ exit 1
1871
+ fi
1872
+ br="$release_branch"
1873
+ MAIN=$(bash "$script_dir/plot-config.sh" get "Main branch")
1874
+ [ -n "$MAIN" ] || MAIN=$(default_branch)
1875
+ if [ -z "$MAIN" ]; then
1876
+ echo "plot-dispatch: cannot resolve the default branch — refusing to release $br." >&2
1877
+ echo " Nothing was written." >&2
1878
+ exit 1
1879
+ fi
1880
+
1881
+ # THE REMOTE IS READ FRESH. A stale remote-tracking ref would hide commits a
1882
+ # worker pushed since the last fetch, and those are the real work this must
1883
+ # refuse on. A fetch that fails is a remote this cannot read.
1884
+ if ! git fetch -q --prune origin </dev/null 2>/dev/null; then
1885
+ echo "plot-dispatch: cannot fetch origin — refusing to release $br." >&2
1886
+ echo " Without a fresh reading this cannot tell a claim from pushed work." >&2
1887
+ echo " Nothing was written." >&2
1888
+ exit 1
1889
+ fi
1890
+ ref_present=0
1891
+ git rev-parse -q --verify "refs/remotes/origin/$br" >/dev/null 2>&1 && ref_present=1
1892
+
1893
+ # 1. A PULL REQUEST, ASKED FIRST — `handover_refusal`'s order and for its
1894
+ # reason: a worker's exit state says nothing about whether its work reached
1895
+ # review. A merged branch's ref belongs to `plot-release-refs.sh`; an open
1896
+ # one is work under review.
1897
+ #
1898
+ # UNREACHABLE IS NOT "NO PR". `reached_review` reads a failed call as no PR,
1899
+ # which is right for a rendering and wrong here: a deleted ref cannot be
1900
+ # re-created, so a host that cannot be asked refuses. `--offline` promises no
1901
+ # host call, so it refuses too.
1902
+ if [ -n "$offline" ]; then
1903
+ echo "plot-dispatch: --release asks the host whether $br has a PR, and --offline forbids that — refusing." >&2
1904
+ echo " Nothing was written." >&2
1905
+ exit 1
1906
+ fi
1907
+ if ! pr_json=$("$script_dir/plot-host.sh" pr-state "$br" </dev/null 2>/dev/null); then
1908
+ echo "plot-dispatch: the host could not be asked whether $br has a PR — refusing." >&2
1909
+ echo " A release deletes a ref that cannot be re-created, so silence is not" >&2
1910
+ echo " permission. Check the host (plot-host.sh pr-state $br) and retry." >&2
1911
+ echo " Nothing was written." >&2
1912
+ exit 1
1913
+ fi
1914
+ pr_state=$(printf '%s' "$pr_json" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
1915
+ pr_num=$(printf '%s' "$pr_json" | sed -n 's/.*"number":\([0-9]*\).*/\1/p')
1916
+ # ANY MERGED PR, NOT ONLY THE NEWEST — `pr-merged` reads `mergedAt` over every
1917
+ # PR for the branch, because a newer unmerged PR can mask a merged one.
1918
+ merged_answer=$("$script_dir/plot-host.sh" pr-merged "$br" </dev/null 2>/dev/null) || merged_answer=unknown
1919
+ case "$pr_state:$merged_answer" in
1920
+ OPEN:*|MERGED:*|*:merged)
1921
+ echo "plot-dispatch: $br has a pull request (#${pr_num:-?}, ${pr_state:-MERGED}) — refusing." >&2
1922
+ echo " An open PR is work under review; a merged branch's ref is released by" >&2
1923
+ echo " plot-release-refs.sh when its plan is delivered. Nothing was written." >&2
1924
+ exit 1
1925
+ ;;
1926
+ *:unknown|*:)
1927
+ echo "plot-dispatch: the host could not say whether $br ever merged — refusing." >&2
1928
+ echo " Check it (plot-host.sh pr-merged $br) and retry. Nothing was written." >&2
1929
+ exit 1
1930
+ ;;
1931
+ esac
1932
+
1933
+ # THE DESK, ASKED OF GIT — never rebuilt from the branch name, the rule every
1934
+ # other verb here follows. A manifest naming this branch may name a desk git
1935
+ # does not list (removed by hand), so its `worktree` is the fallback.
1936
+ release_wt=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$br" '
1937
+ /^worktree / { path = substr($0, 10) }
1938
+ /^branch / { if (substr($0, 8) == want) { print path; exit } }')
1939
+ registry_dir=$(agent_registry_dir "$repo_root_early")
1940
+ named_manifests=()
1941
+ if [ -d "$registry_dir" ]; then
1942
+ for m in "$registry_dir"/*.json; do
1943
+ [ -f "$m" ] || continue
1944
+ m_branch=$(node -e '
1945
+ try {
1946
+ const m = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
1947
+ process.stdout.write(typeof m.branch === "string" ? m.branch : "");
1948
+ } catch { process.stdout.write(""); }
1949
+ ' "$m" 2>/dev/null)
1950
+ [ "$m_branch" = "$br" ] || continue
1951
+ named_manifests+=("$m")
1952
+ if [ -z "$release_wt" ]; then
1953
+ m_wt=$(node -e '
1954
+ try {
1955
+ const m = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
1956
+ process.stdout.write(typeof m.worktree === "string" ? m.worktree : "");
1957
+ } catch { process.stdout.write(""); }
1958
+ ' "$m" 2>/dev/null)
1959
+ [ -n "$m_wt" ] && [ -d "$m_wt" ] && release_wt="$m_wt"
1960
+ fi
1961
+ done
1962
+ fi
1963
+ [ -n "$release_wt" ] && [ -d "$release_wt" ] || release_wt=""
1964
+
1965
+ # 2. A LIVE WORKER — the measurement `--restart` makes, through the shared
1966
+ # classifier, never `pgrep` by name. A live pid means somebody is working,
1967
+ # and the one case where a claim is not abandoned.
1968
+ if [ -n "$release_wt" ]; then
1969
+ release_state=$(plot_worker_state "$release_wt" "" | cut -f1,2)
1970
+ case "$release_state" in
1971
+ running*)
1972
+ echo "plot-dispatch: a worker is alive on $br (pid $(printf '%s' "$release_state" | cut -f2)) — refusing." >&2
1973
+ echo " A live worker has not abandoned its claim. Stop it first if you mean to:" >&2
1974
+ echo " plot-dispatch.sh --stop $br" >&2
1975
+ echo " Nothing was written." >&2
1976
+ exit 1
1977
+ ;;
1978
+ esac
1979
+ fi
1980
+
1981
+ # 3. REAL WORK. A claim is an EMPTY commit on the default branch's tip, so
1982
+ # it changes no file; `-- .` limits the count to commits that change one,
1983
+ # which is what separates a claim-only ref from pushed work. The local desk
1984
+ # is asked too, because the measured victim held its work in UNPUSHED
1985
+ # commits and uncommitted files that no remote reading can see. A count of
1986
+ # commits, not an ancestry verdict: a count above zero only ever REFUSES.
1987
+ if [ "$ref_present" = 1 ]; then
1988
+ remote_work=$(git rev-list --count "refs/remotes/origin/$MAIN..refs/remotes/origin/$br" -- . </dev/null 2>/dev/null) || remote_work=""
1989
+ if [ -z "$remote_work" ]; then
1990
+ echo "plot-dispatch: cannot count the commits on origin/$br — refusing." >&2
1991
+ echo " Nothing was written." >&2
1992
+ exit 1
1993
+ fi
1994
+ if [ "$remote_work" -gt 0 ]; then
1995
+ echo "plot-dispatch: origin/$br carries $remote_work commit(s) that change files — refusing." >&2
1996
+ echo " That is work, not an abandoned claim. Review it, or open its PR:" >&2
1997
+ echo " plot-open-pr.sh $br" >&2
1998
+ echo " Nothing was written." >&2
1999
+ exit 1
2000
+ fi
2001
+ fi
2002
+ if [ -n "$release_wt" ]; then
2003
+ desk_dirty=$(plot_worker_dirty "$release_wt")
2004
+ if [ "$ref_present" = 1 ]; then desk_base="refs/remotes/origin/$br"; else desk_base="refs/remotes/origin/$MAIN"; fi
2005
+ # Local commits the remote lacks exist nowhere else, so any refuses.
2006
+ desk_unpushed=$(git -C "$release_wt" rev-list --count "$desk_base..HEAD" -- . </dev/null 2>/dev/null) || desk_unpushed=0
2007
+ if [ -n "$desk_dirty" ] || [ "${desk_unpushed:-0}" -gt 0 ]; then
2008
+ echo "plot-dispatch: the desk at $release_wt holds work for $br — refusing." >&2
2009
+ [ "${desk_unpushed:-0}" -gt 0 ] && echo " $desk_unpushed unpushed commit(s) that change files" >&2
2010
+ [ -n "$desk_dirty" ] && echo " uncommitted changes: $(printf '%s' "$desk_dirty" | tr '\n' ' ')" >&2
2011
+ echo " Releasing would hand the slice to an agent that cannot see this work." >&2
2012
+ echo " Push it, or hand the branch to a new worker: plot-dispatch.sh --restart $br" >&2
2013
+ echo " Nothing was written." >&2
2014
+ exit 1
2015
+ fi
2016
+
2017
+ # 4. A PLOT-BLOCKED MARKER: the agent is waiting on a person, and a slice
2018
+ # waiting on an answer is not abandoned. Asked through
2019
+ # `plot_worker_blocked_file`, where the marker's spelling lives.
2020
+ if marker=$(plot_worker_blocked_file "$release_wt") && [ -n "$marker" ]; then
2021
+ echo "plot-dispatch: $br is blocked on a question — refusing." >&2
2022
+ echo " the question is in $release_wt/$marker" >&2
2023
+ echo " Answer it and delete the marker; the agent's slice is not abandoned." >&2
2024
+ echo " Nothing was written." >&2
2025
+ exit 1
2026
+ fi
2027
+ fi
2028
+
2029
+ if [ "$ref_present" = 0 ] && [ "${#named_manifests[@]}" -eq 0 ]; then
2030
+ echo "$br holds no claim: origin/$br does not exist and no manifest names it."
2031
+ echo " Nothing to release."
2032
+ exit 0
2033
+ fi
2034
+
2035
+ # THE MANIFESTS FIRST, THEN THE REF. If the ref deletion fails, the manifest
2036
+ # is already free and the ref still locks, so the scan still reads the slice
2037
+ # as claimed and nothing hands it out twice. The reverse order would leave the
2038
+ # measured failure on disk: no ref, and a manifest still naming the branch.
2039
+ released_manifests=0
2040
+ if [ "${#named_manifests[@]}" -eq 0 ]; then
2041
+ echo " no manifest in $registry_dir names $br"
2042
+ fi
2043
+ for m in ${named_manifests[@]+"${named_manifests[@]}"}; do
2044
+ if ! clear_manifest_branch "$m"; then
2045
+ echo "plot-dispatch: could not clear the assignment in $m — refusing to delete the ref." >&2
2046
+ echo " The claim on origin/$br stands; fix the file and run --release again." >&2
2047
+ exit 1
2048
+ fi
2049
+ released_manifests=$((released_manifests + 1))
2050
+ echo " cleared the assignment in $m"
2051
+ done
2052
+
2053
+ if [ "$ref_present" = 1 ]; then
2054
+ if ! git push -q origin --delete "$br" </dev/null 2>/dev/null; then
2055
+ # VERIFIED, NOT TRUSTED: the host has returned 503 on a push that landed.
2056
+ git fetch -q --prune origin </dev/null 2>/dev/null || true
2057
+ if git rev-parse -q --verify "refs/remotes/origin/$br" >/dev/null 2>&1; then
2058
+ echo "plot-dispatch: could not delete origin/$br — the claim still locks the slice." >&2
2059
+ echo " The manifests are already free, so nothing hands it out twice. Retry:" >&2
2060
+ echo " plot-dispatch.sh --release $br" >&2
2061
+ exit 1
2062
+ fi
2063
+ fi
2064
+ echo " deleted the claim ref origin/$br"
2065
+ else
2066
+ echo " origin/$br does not exist — only the assignment needed releasing"
2067
+ fi
2068
+ if [ -n "$release_wt" ]; then
2069
+ echo " the desk at $release_wt still holds $br and is left as it is"
2070
+ fi
2071
+ echo "released $br — the slice returns to the queue"
2072
+ echo "summary: released=1 manifests=$released_manifests ref=$([ "$ref_present" = 1 ] && echo deleted || echo absent)"
2073
+ exit 0
2074
+ fi
2075
+
1810
2076
  if [ "$mode" = "start" ]; then
1811
2077
  # THE LAST LINK IN THE CHAIN. `plot-dispatch.sh <slug>` queues slices and the
1812
2078
  # registry matches them to free agents, but until this verb existed nothing
@@ -3063,6 +3329,10 @@ append_started_line() { # $1=file $2=date $3=who $4=branch
3063
3329
  insert = start
3064
3330
  for (i = start + 1; i <= n; i++) {
3065
3331
  if (lines[i] ~ /^##[ \t]/) break
3332
+ # An HTML comment ends the writable region. Checked BEFORE the
3333
+ # placeholder arms so a commented-out `- **Started:**` template line
3334
+ # is never mistaken for the slot to fill.
3335
+ if (lines[i] ~ /<!--/) break
3066
3336
  if (lines[i] ~ /^[ \t]*[-*][ \t]*\*\*Started:\*\*[ \t]*$/) { slot = i; break }
3067
3337
  if (lines[i] ~ /^[ \t]*[-*][ \t]/) insert = i
3068
3338
  }