@plot-pm/board 0.14.4 → 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 +164 -158
- package/package.json +2 -1
- package/plot-agent-manifest.sh +58 -0
- package/plot-approve.sh +23 -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 +95 -12
- package/plot-plan-meta.sh +10 -3
- package/plot-pr-merged.sh +29 -2
- package/plot-reap.sh +115 -22
package/plot-host.sh
CHANGED
|
@@ -19,7 +19,18 @@
|
|
|
19
19
|
# than a transient to retry. Adding a host is
|
|
20
20
|
# an edit to `HOST_DRIVES` and the arms below,
|
|
21
21
|
# and to nothing in `packages/domain`.
|
|
22
|
-
# default-branch print the repo's default branch name
|
|
22
|
+
# default-branch print the repo's default branch name. THE HOST
|
|
23
|
+
# IS ASKED, on both backends, and the local
|
|
24
|
+
# `origin/HEAD` is the fallback where it cannot
|
|
25
|
+
# answer — that ref is a clone-time cache and a
|
|
26
|
+
# default branch moved since then never updates
|
|
27
|
+
# it. Prints "" where neither can answer.
|
|
28
|
+
# account print the signed-in host user, read from the
|
|
29
|
+
# CLI's config file (no API request).
|
|
30
|
+
# `PLOT_BUDGET_ACCOUNT` overrides it. Exit 3
|
|
31
|
+
# where GitHub's `hosts.yml` names no user;
|
|
32
|
+
# exit 4 on Bitbucket, which stores no username
|
|
33
|
+
# for free. Never prints `unknown`.
|
|
23
34
|
# pr-state <number|branch> [--repo <owner/repo>] one JSON object:
|
|
24
35
|
# {"number":N,"state":"OPEN|MERGED|CLOSED|NONE",
|
|
25
36
|
# "draft":true|false,"url":"..."}
|
|
@@ -2996,14 +3007,77 @@ case "$op" in
|
|
|
2996
3007
|
;;
|
|
2997
3008
|
|
|
2998
3009
|
default-branch)
|
|
3010
|
+
# THE HOST IS ASKED FIRST, ON BOTH BACKENDS, and `origin/HEAD` is the
|
|
3011
|
+
# fallback rather than the answer. `origin/HEAD` is a LOCAL CACHE WRITTEN AT
|
|
3012
|
+
# CLONE TIME: a default branch changed afterwards never updates it and
|
|
3013
|
+
# nothing in git notices. Reported 2026-09-24 — a clone whose GitHub default
|
|
3014
|
+
# had moved to `develop` kept `origin/HEAD → main`, the board read plans from
|
|
3015
|
+
# `origin/main`, and two of three plans went missing.
|
|
3016
|
+
#
|
|
3017
|
+
# THE BITBUCKET ARM ASKED THE CACHE FIRST UNTIL 2026-09-25, so on Bitbucket
|
|
3018
|
+
# this op answered from the very cache it exists to bypass, the two readings
|
|
3019
|
+
# could never disagree, and the defect was invisible there. Every caller —
|
|
3020
|
+
# `plot-open-pr.sh:73`, `plot-approve.sh:205`, `plot-deliver.sh:218`,
|
|
3021
|
+
# `plot-reap.sh:176`, `plot-release-refs.sh:162`, `/plot-idea:252` — wants
|
|
3022
|
+
# the branch the host calls default, which is what this now answers.
|
|
3023
|
+
#
|
|
3024
|
+
# AND THE FALLBACK WAS DEAD CODE. `git symbolic-ref … | sed … || bb …` takes
|
|
3025
|
+
# the PIPELINE's exit status, which is `sed`'s, and `sed` exits 0 on empty
|
|
3026
|
+
# input — measured 2026-09-25 against a ref that does not exist. So `bb repo
|
|
3027
|
+
# view` had never once been reached, and a Bitbucket clone with no
|
|
3028
|
+
# `origin/HEAD` printed an empty line and exited 0. Each reading is now
|
|
3029
|
+
# tested for a NON-EMPTY value of its own, never for a pipeline's status.
|
|
3030
|
+
_db=""
|
|
2999
3031
|
if [ "$be" = "github" ]; then
|
|
3000
|
-
gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name'
|
|
3032
|
+
_db=$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)
|
|
3001
3033
|
else
|
|
3002
|
-
#
|
|
3003
|
-
#
|
|
3004
|
-
|
|
3005
|
-
|
|
3034
|
+
# `--json` IS A CAPABILITY, NOT A GIVEN. Two products share the name `bb`
|
|
3035
|
+
# and craftamap's rejects the flag (see `bb_require_json`) — against it
|
|
3036
|
+
# this call emits `unknown flag: --json`, `jq` reads non-JSON, and the arm
|
|
3037
|
+
# would answer nothing while exiting 0. This call was unguarded while it
|
|
3038
|
+
# was unreachable; asking the host makes it live.
|
|
3039
|
+
bb_require_json
|
|
3040
|
+
_db=$(bb repo view --json 2>/dev/null | jq -r '.mainbranch.name // empty' 2>/dev/null || true)
|
|
3006
3041
|
fi
|
|
3042
|
+
# `null` is what `jq` prints for an absent field and it is not a branch name.
|
|
3043
|
+
[ "$_db" = "null" ] && _db=""
|
|
3044
|
+
# THE CACHE IS THE FALLBACK: a host that cannot be asked is not a reason to
|
|
3045
|
+
# answer nothing, since every caller above needs a usable branch name and
|
|
3046
|
+
# worked from this reading alone before today.
|
|
3047
|
+
[ -n "$_db" ] || _db=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')
|
|
3048
|
+
printf '%s\n' "$_db"
|
|
3049
|
+
;;
|
|
3050
|
+
|
|
3051
|
+
account)
|
|
3052
|
+
# WHO IS READING, from the same free reading `budget_account` makes. It
|
|
3053
|
+
# costs a file read and no request, which matters because the board asks on
|
|
3054
|
+
# its timer. `PLOT_BUDGET_ACCOUNT` overrides it on every backend.
|
|
3055
|
+
#
|
|
3056
|
+
# THE BITBUCKET ARM IS NOT EXPOSED. `budget_account bitbucket` answers the
|
|
3057
|
+
# remote's owner segment, which is the WORKSPACE: on a team workspace every
|
|
3058
|
+
# contributor gets the same word, and it matches no PR author. Measured
|
|
3059
|
+
# 2026-09-25, neither `bb` stores a username for free. Quatico's keeps only
|
|
3060
|
+
# the Atlassian email, in the Keychain. craftamap's keeps an email in
|
|
3061
|
+
# `configuration.toml`. A Bitbucket PR author carries a nickname, an
|
|
3062
|
+
# account id and a uuid, and never an email. So this backend has no answer,
|
|
3063
|
+
# and exit 4 says so.
|
|
3064
|
+
#
|
|
3065
|
+
# `unknown` IS NEVER PRINTED. It groups budget lines correctly and names
|
|
3066
|
+
# nobody, so an unreadable `hosts.yml` exits 3 with the reason on stderr.
|
|
3067
|
+
_acct=''
|
|
3068
|
+
if [ -n "${PLOT_BUDGET_ACCOUNT:-}" ]; then
|
|
3069
|
+
_acct="$PLOT_BUDGET_ACCOUNT"
|
|
3070
|
+
elif [ "$be" = "github" ]; then
|
|
3071
|
+
_acct="$(budget_account github)"
|
|
3072
|
+
else
|
|
3073
|
+
echo "plot-host.sh: account: $be names no signed-in user without an API request" >&2
|
|
3074
|
+
exit 4
|
|
3075
|
+
fi
|
|
3076
|
+
if [ -z "$_acct" ] || [ "$_acct" = "unknown" ]; then
|
|
3077
|
+
echo "plot-host.sh: account: no user in ${GH_CONFIG_DIR:-$HOME/.config/gh}/hosts.yml" >&2
|
|
3078
|
+
exit 3
|
|
3079
|
+
fi
|
|
3080
|
+
printf '%s\n' "$_acct"
|
|
3007
3081
|
;;
|
|
3008
3082
|
|
|
3009
3083
|
pr-state)
|
|
@@ -3550,7 +3624,7 @@ case "$op" in
|
|
|
3550
3624
|
# $jentry == null → the branch has no Jenkins job; `none`.
|
|
3551
3625
|
# otherwise → the joined colour's `checks`, job named on fail.
|
|
3552
3626
|
_gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
|
|
3553
|
-
--json number,title,state,headRefName,isDraft,mergeable,mergeStateStatus,reviewDecision,url,updatedAt)" || exit $?
|
|
3627
|
+
--json number,title,state,headRefName,isDraft,mergeable,mergeStateStatus,reviewDecision,url,updatedAt,author)" || exit $?
|
|
3554
3628
|
pr_list_report_truncation github "$limit" "$state" \
|
|
3555
3629
|
"$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
|
|
3556
3630
|
printf '%s' "$_gh_raw" \
|
|
@@ -3571,6 +3645,7 @@ case "$op" in
|
|
|
3571
3645
|
review:(.reviewDecision // ""),
|
|
3572
3646
|
url:.url,
|
|
3573
3647
|
updatedAt:(.updatedAt // ""),
|
|
3648
|
+
author:(.author.login // ""),
|
|
3574
3649
|
failing_checks:(
|
|
3575
3650
|
if $jentry != null and $jentry.checks == "failing"
|
|
3576
3651
|
then [$jentry.job]
|
|
@@ -3580,7 +3655,7 @@ case "$op" in
|
|
|
3580
3655
|
else
|
|
3581
3656
|
# GitHub without Jenkins (or Jenkins not configured): use GitHub rollup
|
|
3582
3657
|
_gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
|
|
3583
|
-
--json number,title,state,headRefName,isDraft,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,url,updatedAt)" || exit $?
|
|
3658
|
+
--json number,title,state,headRefName,isDraft,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,url,updatedAt,author)" || exit $?
|
|
3584
3659
|
pr_list_report_truncation github "$limit" "$state" \
|
|
3585
3660
|
"$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
|
|
3586
3661
|
printf '%s' "$_gh_raw" \
|
|
@@ -3603,6 +3678,7 @@ case "$op" in
|
|
|
3603
3678
|
review:(.reviewDecision // ""),
|
|
3604
3679
|
url:.url,
|
|
3605
3680
|
updatedAt:(.updatedAt // ""),
|
|
3681
|
+
author:(.author.login // ""),
|
|
3606
3682
|
failing_checks:[
|
|
3607
3683
|
.statusCheckRollup[]? | select((if (.conclusion // "") != "" then .conclusion else (.status // .state) end) as $c
|
|
3608
3684
|
| $c=="FAILURE" or $c=="ERROR" or $c=="CANCELLED"
|
|
@@ -3612,13 +3688,19 @@ case "$op" in
|
|
|
3612
3688
|
fi
|
|
3613
3689
|
else
|
|
3614
3690
|
_gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
|
|
3615
|
-
--json number,title,state,headRefName)" || exit $?
|
|
3691
|
+
--json number,title,state,headRefName,author)" || exit $?
|
|
3616
3692
|
pr_list_report_truncation github "$limit" "$state" \
|
|
3617
3693
|
"$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
|
|
3618
3694
|
printf '%s' "$_gh_raw" \
|
|
3619
|
-
| jq -c '.[] | {number:.number,title:.title,state:.state,head:.headRefName}'
|
|
3695
|
+
| jq -c '.[] | {number:.number,title:.title,state:.state,head:.headRefName,author:(.author.login // "")}'
|
|
3620
3696
|
fi
|
|
3621
3697
|
else
|
|
3698
|
+
# `author` IS THE `nickname`, the one handle field a Bitbucket user
|
|
3699
|
+
# object carries. Measured 2026-09-25 against bb 1.9.0: `author` holds
|
|
3700
|
+
# `display_name`, `nickname`, `account_id` and `uuid` and no username,
|
|
3701
|
+
# and on a Quatico workspace `nickname` equals the display name. GitHub's
|
|
3702
|
+
# arm reads `.author.login`. Both answer `""` where the host omits it.
|
|
3703
|
+
#
|
|
3622
3704
|
# Bitbucket carries no check rollup through `bb pr list`, and no
|
|
3623
3705
|
# mergeability verdict either. Rather than guess, --rich reports
|
|
3624
3706
|
# checks:"unknown" and mergeable:"unknown" — a consumer must render those
|
|
@@ -3705,6 +3787,7 @@ case "$op" in
|
|
|
3705
3787
|
review:"",
|
|
3706
3788
|
url:(.links.html.href // ""),
|
|
3707
3789
|
updatedAt:(.updated_on // ""),
|
|
3790
|
+
author:(.author.nickname // ""),
|
|
3708
3791
|
failing_checks:(
|
|
3709
3792
|
if $jentry != null and $jentry.checks == "failing"
|
|
3710
3793
|
then [$jentry.job]
|
|
@@ -3715,13 +3798,13 @@ case "$op" in
|
|
|
3715
3798
|
# Bitbucket without Jenkins: checks remain unknown
|
|
3716
3799
|
PR_LIST_JQ_ARGS=()
|
|
3717
3800
|
pr_list_states bitbucket "$limit" "$bb_states" \
|
|
3718
|
-
'.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name,draft:(.draft // false),checks:"unknown",mergeable:"unknown",review:"",url:(.links.html.href // ""),updatedAt:(.updated_on // ""),failing_checks:[]}' \
|
|
3801
|
+
'.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name,draft:(.draft // false),checks:"unknown",mergeable:"unknown",review:"",url:(.links.html.href // ""),updatedAt:(.updated_on // ""),author:(.author.nickname // ""),failing_checks:[]}' \
|
|
3719
3802
|
"${bb_cmd[@]}" || exit $?
|
|
3720
3803
|
fi
|
|
3721
3804
|
else
|
|
3722
3805
|
PR_LIST_JQ_ARGS=()
|
|
3723
3806
|
pr_list_states bitbucket "$limit" "$bb_states" \
|
|
3724
|
-
'.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name}' \
|
|
3807
|
+
'.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name,author:(.author.nickname // "")}' \
|
|
3725
3808
|
"${bb_cmd[@]}" || exit $?
|
|
3726
3809
|
fi
|
|
3727
3810
|
fi
|
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
|
|
@@ -415,7 +416,7 @@ function reset_state() {
|
|
|
415
416
|
fm_delivered = ""; fm_design = ""
|
|
416
417
|
fm_rounds = ""
|
|
417
418
|
canon_state = ""; canon_phase = ""; canon_type = ""
|
|
418
|
-
canon_sprint = ""; canon_story = ""; canon_assignee = ""
|
|
419
|
+
canon_sprint = ""; canon_story = ""; canon_assignee = ""; status_assignee = ""
|
|
419
420
|
canon_review = ""; canon_impl = ""; canon_approved = ""; canon_released = ""
|
|
420
421
|
canon_delivered = ""; canon_design = ""
|
|
421
422
|
canon_rounds = ""
|
|
@@ -478,6 +479,9 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
|
|
|
478
479
|
sprint = strip_placeholder((fm_sprint != "") ? fm_sprint : canon_sprint)
|
|
479
480
|
story = strip_placeholder((fm_story != "") ? fm_story : canon_story)
|
|
480
481
|
assignee = strip_placeholder((fm_assignee != "") ? fm_assignee : canon_assignee)
|
|
482
|
+
# `## Status` is the section both templates offer; `## Approval` is older and
|
|
483
|
+
# outranks it, so a plan writing both keeps the answer it parsed to before.
|
|
484
|
+
if (fm_assignee == "" && assignee == "") assignee = status_assignee
|
|
481
485
|
review = strip_placeholder((fm_review != "") ? fm_review : canon_review)
|
|
482
486
|
impl = strip_placeholder((fm_impl != "") ? fm_impl : canon_impl)
|
|
483
487
|
design = strip_placeholder((fm_design != "") ? fm_design : canon_design)
|
|
@@ -823,6 +827,9 @@ section == "status" {
|
|
|
823
827
|
else if (lower ~ /^[ \t]*[-*]?[ \t]*\**issue[:*]/ && canon_issue == "") canon_issue = val_after_colon($0)
|
|
824
828
|
else if (lower ~ /^[ \t]*[-*]?[ \t]*\**review[:*]/ && canon_review == "") canon_review = val_after_colon($0)
|
|
825
829
|
else if (lower ~ /^[ \t]*[-*]?[ \t]*\**impl[:*]/ && canon_impl == "") canon_impl = val_after_colon($0)
|
|
830
|
+
# Its own slot, not `canon_assignee`: the `## Approval` line outranks this one
|
|
831
|
+
# in `emit_record`, whichever section comes first.
|
|
832
|
+
else if (lower ~ /^[ \t]*[-*]?[ \t]*\**assignee[:*]/ && status_assignee == "") status_assignee = strip_placeholder(val_after_colon($0))
|
|
826
833
|
# EACH TAKES THE FIRST LINE THAT CARRIES A VALUE, NOT THE FIRST LINE.
|
|
827
834
|
#
|
|
828
835
|
# A plan may hold both a record and an unfilled placeholder for the same
|
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
|
@@ -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
|
|
@@ -501,25 +591,21 @@ while IFS=$'\037' read -r wt br prunable; do
|
|
|
501
591
|
|
|
502
592
|
# The process table: the live worker's pid, or empty. Read but not judged —
|
|
503
593
|
# 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
|
|
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")
|
|
510
597
|
|
|
511
598
|
# The tree: a PLOT-BLOCKED marker, and the first uncommitted path.
|
|
512
599
|
#
|
|
513
|
-
#
|
|
514
|
-
#
|
|
515
|
-
#
|
|
516
|
-
#
|
|
517
|
-
#
|
|
518
|
-
#
|
|
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.
|
|
519
606
|
marker=false
|
|
520
607
|
ls "$wt"/PLOT-BLOCKED* >/dev/null 2>&1 && marker=true
|
|
521
|
-
dirty=$(
|
|
522
|
-
| grep -v 'tiny-garden/\.plot/state' | head -1)
|
|
608
|
+
dirty=$(desk_dirt "$wt" | head -1)
|
|
523
609
|
|
|
524
610
|
# The host: whether ANY PR for this branch merged.
|
|
525
611
|
#
|
|
@@ -560,6 +646,12 @@ while IFS=$'\037' read -r wt br prunable; do
|
|
|
560
646
|
merge=merged; why="detached, nothing to land"
|
|
561
647
|
fi
|
|
562
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
|
+
|
|
563
655
|
# THE DECISION. One call, and the script holds no `if` about whether a
|
|
564
656
|
# worktree may go — only about what to do with the answer.
|
|
565
657
|
#
|
|
@@ -581,7 +673,7 @@ while IFS=$'\037' read -r wt br prunable; do
|
|
|
581
673
|
# the tree and says why. Silence is never permission, on this path either.
|
|
582
674
|
verdict=$(PLOT_BRANCH="$short" PLOT_DEFAULT="$DEFAULT" PLOT_PID="$pid" \
|
|
583
675
|
PLOT_DIRTY="$dirty" PLOT_MARKER="$marker" PLOT_MERGE="$merge" \
|
|
584
|
-
PLOT_RULE="$RULE" \
|
|
676
|
+
PLOT_UNPUSHED="$unpushed" PLOT_RULE="$RULE" \
|
|
585
677
|
node --input-type=module - <<'NODE_EOF' 2>/dev/null
|
|
586
678
|
// Imported from an ABSOLUTE path derived from this script, never from the
|
|
587
679
|
// cwd. The reaper runs with its cwd wherever the operator invoked it and the
|
|
@@ -603,6 +695,9 @@ const problem = firstReapRefusal({
|
|
|
603
695
|
dirtyPath: process.env.PLOT_DIRTY,
|
|
604
696
|
blockedMarker: process.env.PLOT_MARKER === "true",
|
|
605
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 !== ""),
|
|
606
701
|
});
|
|
607
702
|
|
|
608
703
|
// `reap` when nothing refused; otherwise the refusal and its reading, which
|
|
@@ -622,6 +717,7 @@ NODE_EOF
|
|
|
622
717
|
live-worker) reason="worker alive (pid $detail)" ;;
|
|
623
718
|
blocked-marker) reason="PLOT-BLOCKED marker — needs a person" ;;
|
|
624
719
|
uncommitted-changes) reason="uncommitted: ${detail:0:40}" ;;
|
|
720
|
+
unpushed-commits) reason="unpushed commits: ${detail:0:40}" ;;
|
|
625
721
|
on-default-branch) reason="on $DEFAULT — dispatched branch not checked out" ;;
|
|
626
722
|
no-merged-pr) reason="unlanded work — no merged PR" ;;
|
|
627
723
|
*) reason="rule could not be asked — keeping" ;;
|
|
@@ -1032,15 +1128,12 @@ while IFS=$'\t' read -r wt br; do
|
|
|
1032
1128
|
[ -n "$MAIN_CHECKOUT" ] && [ "$(canonical "$wt")" = "$(canonical "$MAIN_CHECKOUT")" ] && continue
|
|
1033
1129
|
dshort=${br#refs/heads/}
|
|
1034
1130
|
|
|
1035
|
-
dcount=$(
|
|
1036
|
-
| grep -v 'tiny-garden/\.plot/state' | wc -l | tr -d ' ')
|
|
1131
|
+
dcount=$(desk_dirt "$wt" | wc -l | tr -d ' ')
|
|
1037
1132
|
[ "${dcount:-0}" -gt 0 ] || continue
|
|
1038
1133
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
if [ -n "$p" ] && ps -p "$p" >/dev/null 2>&1; then dpid="$p"; fi
|
|
1043
|
-
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")
|
|
1044
1137
|
|
|
1045
1138
|
dmanifest=""
|
|
1046
1139
|
if m=$(manifest_for "$(canonical "$wt")"); then dmanifest=$(basename "$m"); fi
|