@plot-pm/board 0.16.2 → 0.17.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 +258 -146
- package/package.json +3 -6
- package/plot-agent-manifest.sh +76 -15
- package/plot-agent-monitor.sh +43 -5
- package/plot-approve.sh +224 -73
- package/plot-config.sh +14 -14
- package/plot-deliver.sh +10 -741
- package/plot-dispatch.sh +282 -107
- package/plot-fleet-scan.sh +867 -170
- package/plot-host.sh +310 -76
- package/plot-monitor-subject.sh +61 -10
- package/plot-plan-meta.sh +155 -139
- package/plot-pr-merged.sh +16 -0
- package/plot-reap.sh +61 -28
- package/plot-state-receipt.sh +12 -2
- package/plot-tmp.sh +31 -3
- package/plot-worker-state.sh +458 -24
- package/plot-build-monitor.sh +0 -435
- package/plot-resolve-artifact.sh +0 -423
- package/plot-transcript-quiet.sh +0 -170
- package/plot-worker-monitor.sh +0 -705
package/plot-resolve-artifact.sh
DELETED
|
@@ -1,423 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Plot helper: repair an ARTIFACT-ONLY merge conflict on one branch.
|
|
3
|
-
# Usage: plot-resolve-artifact.sh [--dry-run] <branch>
|
|
4
|
-
# --dry-run print the sequence and the worktree it would use; change nothing
|
|
5
|
-
# <branch> the branch to repair — must already exist on origin
|
|
6
|
-
# Output: one `step:` line per stage, then a machine-countable footer:
|
|
7
|
-
# summary: branch=<name> outcome=pushed|abandoned|refused reason=<word>
|
|
8
|
-
#
|
|
9
|
-
# THE ONLY AUTOMATIC WRITE THIS SYSTEM GRANTS, and it is granted for three
|
|
10
|
-
# verified reasons rather than for convenience:
|
|
11
|
-
#
|
|
12
|
-
# 1. `-merge` KEEPS THE FILE VALID. `.gitattributes` marks every bundle
|
|
13
|
-
# `-merge`, so git keeps one side whole and writes NO conflict markers.
|
|
14
|
-
# The artifact stays buildable JavaScript *through* a conflict — which is
|
|
15
|
-
# why a script may touch it at all. `scripts/check-bundle-attributes.sh`
|
|
16
|
-
# is the gate that keeps that true of every bundle rather than of one.
|
|
17
|
-
# 2. THE REBUILD IS DETERMINISTIC. Measured: `build.mjs` embeds no timestamp
|
|
18
|
-
# and no randomness, so the output does not depend on which side was kept.
|
|
19
|
-
# 3. CI PROVES IT. The no-diff gate fails the build if the committed artifact
|
|
20
|
-
# does not match a fresh rebuild.
|
|
21
|
-
#
|
|
22
|
-
# Together those make this the one repair whose correctness is checkable
|
|
23
|
-
# WITHOUT JUDGEMENT. That is the whole licence. No other failure has these
|
|
24
|
-
# three properties, and none may be added to this path — widening the entry
|
|
25
|
-
# condition removes the argument that grants the permission, even if the code
|
|
26
|
-
# looks correct.
|
|
27
|
-
#
|
|
28
|
-
# THIS IS A SCRIPT AND NOT AN AGENT, deliberately. Every step below is fixed
|
|
29
|
-
# and nothing between them is a decision, which is *precisely* what licenses the
|
|
30
|
-
# automation. Handing the sequence to an agent would introduce judgement exactly
|
|
31
|
-
# where its absence is the permission. (Measured on 2026-08-17: this repo has no
|
|
32
|
-
# `Worker command` configured either, so plot-dispatch.sh would report
|
|
33
|
-
# `worker=unconfigured` and start nothing — but the shape is the reason, not
|
|
34
|
-
# the measurement.)
|
|
35
|
-
#
|
|
36
|
-
# TESTS RUN BEFORE THE PUSH. The CI no-diff gate is what makes the repair
|
|
37
|
-
# checkable, and CI runs only AFTER a push — so a resolver that pushed and
|
|
38
|
-
# waited would manufacture exactly the state this exists to remove: a red PR in
|
|
39
|
-
# the queue. The sequence therefore ends on `pnpm run test:board` green in the
|
|
40
|
-
# branch's own worktree, and CI becomes confirmation rather than discovery.
|
|
41
|
-
#
|
|
42
|
-
# IF THE SUITE FAILS, NOTHING IS PUSHED. The repair stopped being mechanical the
|
|
43
|
-
# moment its own gate said so; the branch is left exactly as it was, and the
|
|
44
|
-
# board reports it as a conflict a human owns.
|
|
45
|
-
#
|
|
46
|
-
# IT MERGES ONLY IN A WORKTREE THAT IS IDLE. A worktree carrying modifications
|
|
47
|
-
# belongs to whoever made them — measured on 2026-08-17, the resolver ran its
|
|
48
|
-
# merge inside one an agent was actively editing. It refuses `worktree-busy`
|
|
49
|
-
# rather than reaching in, which the plan names the honest minimum: a second
|
|
50
|
-
# worktree on the same branch is not available to it anyway, since git refuses a
|
|
51
|
-
# second checkout of one branch.
|
|
52
|
-
#
|
|
53
|
-
# AN EMPTY CONFLICT SET IS NOT A REFUSAL ABOUT FILES. Three cases, named apart,
|
|
54
|
-
# because two of them were once one:
|
|
55
|
-
#
|
|
56
|
-
# bundles only → the licensed case → repair
|
|
57
|
-
# other files present → needs judgement → not-artifact-only
|
|
58
|
-
# empty, no merge ran → nothing was observed → not-observed
|
|
59
|
-
#
|
|
60
|
-
# The last is not a smaller version of the middle. `not-artifact-only` asserts
|
|
61
|
-
# something about the files that conflicted, and a set of zero has none to
|
|
62
|
-
# assert it about — saying it there sends a reader to look for files nobody ever
|
|
63
|
-
# examined.
|
|
64
|
-
#
|
|
65
|
-
# WHICH SIDE IS TAKEN CANNOT MATTER, and the diff is never read. `--theirs` is
|
|
66
|
-
# named here only because `git checkout` needs a word: the rebuild overwrites
|
|
67
|
-
# whichever side was kept. Never phrase it as "take ours" — under `git merge`
|
|
68
|
-
# *ours* is the branch being merged into, under `git rebase` it is the upstream,
|
|
69
|
-
# and this repo rebases routinely.
|
|
70
|
-
set -uo pipefail
|
|
71
|
-
|
|
72
|
-
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
73
|
-
. "$script_dir/plot-tmp.sh"
|
|
74
|
-
|
|
75
|
-
# THE FILES THIS SCRIPT MAY RESOLVE — a SET, and derived rather than listed.
|
|
76
|
-
#
|
|
77
|
-
# It was one hardcoded filename until 2026-09-06, and that cost a repair the
|
|
78
|
-
# same day: PR #727 conflicted in `plot-registryd.mjs` — a `-merge` bundle with
|
|
79
|
-
# a deterministic rebuild, exactly the licensed case — and this script refused
|
|
80
|
-
# `not-artifact-only` against a list naming only `board-server.mjs`. The refusal
|
|
81
|
-
# was correct behaviour against a stale list. Hours later the same branch
|
|
82
|
-
# conflicted in `board-server.mjs` and was repaired automatically: same class of
|
|
83
|
-
# conflict, opposite outcome, one filename apart.
|
|
84
|
-
#
|
|
85
|
-
# DERIVED FROM `build.mjs`'S OWN DECLARATIONS, by the same pipeline
|
|
86
|
-
# `scripts/check-bundle-attributes.sh` uses, because a hand-written list here
|
|
87
|
-
# would be a fourth place to drift — and drift is the defect this replaces. The
|
|
88
|
-
# build declares each output as `const shippedX = path.join(here, '…')`, which
|
|
89
|
-
# is what an author writes when adding a bundle; nothing else has to be
|
|
90
|
-
# remembered. Nine bundles today; `plot-landed.mjs` arrived while the plan that
|
|
91
|
-
# asked for this was still in draft, and the derivation found it.
|
|
92
|
-
#
|
|
93
|
-
# `plot-monitor.mjs` IS DELIBERATELY ABSENT. It is committed and documented, and
|
|
94
|
-
# no `outfile` names it — nothing rebuilds it. Property 2 above is the whole
|
|
95
|
-
# licence, so a file with no deterministic rebuild cannot be on this list. The
|
|
96
|
-
# derivation reads the build, so it cannot ask for it.
|
|
97
|
-
#
|
|
98
|
-
# Still named in the board's contract as well, because the two run in different
|
|
99
|
-
# languages and neither can import the other's constant. The pairing is asserted
|
|
100
|
-
# by a test rather than trusted — and that test now asserts SET EQUALITY, since
|
|
101
|
-
# a set that agrees on one member and differs on another is exactly the drift
|
|
102
|
-
# this replaces.
|
|
103
|
-
#
|
|
104
|
-
# READ FROM THE REPOSITORY BEING REPAIRED, not from this script's own checkout.
|
|
105
|
-
# The script is vendored into the published package, where `packages/` does not
|
|
106
|
-
# exist — and it rebuilds with `pnpm build:board` inside the target repo, so the
|
|
107
|
-
# build that defines the set is the one that will run. Resolved below, once
|
|
108
|
-
# `repo_root` is known.
|
|
109
|
-
bundle_set() { # $1=repo root → one path per line, sorted
|
|
110
|
-
grep -aoE "shipped[A-Za-z]* = path\.join\([^)]*'[^']*'\)" "$1/packages/board/build.mjs" 2>/dev/null \
|
|
111
|
-
| sed -E "s|.*'\.\./\.\./([^']*)'.*|\1|" \
|
|
112
|
-
| sort -u
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
dry_run=0
|
|
116
|
-
branch=""
|
|
117
|
-
while [ $# -gt 0 ]; do
|
|
118
|
-
case "$1" in
|
|
119
|
-
--dry-run) dry_run=1 ;;
|
|
120
|
-
-*) echo "plot-resolve-artifact: unknown option '$1'" >&2; exit 2 ;;
|
|
121
|
-
*) branch="$1" ;;
|
|
122
|
-
esac
|
|
123
|
-
shift
|
|
124
|
-
done
|
|
125
|
-
|
|
126
|
-
# The footer travels on EVERY exit path, including the refusals. A run that
|
|
127
|
-
# ends without one is indistinguishable from a crash, and a silent automatic
|
|
128
|
-
# write is the failure mode this whole plan exists to remove.
|
|
129
|
-
finish() { # $1=outcome $2=reason
|
|
130
|
-
echo "summary: branch=$branch outcome=$1 reason=$2"
|
|
131
|
-
case "$1" in
|
|
132
|
-
pushed) exit 0 ;;
|
|
133
|
-
*) exit 1 ;;
|
|
134
|
-
esac
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
[ -n "$branch" ] || { echo "usage: plot-resolve-artifact.sh [--dry-run] <branch>" >&2; exit 2; }
|
|
138
|
-
|
|
139
|
-
git rev-parse --git-dir >/dev/null 2>&1 || {
|
|
140
|
-
echo "plot-resolve-artifact: not a git repository" >&2
|
|
141
|
-
finish refused not-a-repo
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
MAIN=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
|
|
145
|
-
[ -n "$MAIN" ] || MAIN="main"
|
|
146
|
-
|
|
147
|
-
repo_root=$(git rev-parse --show-toplevel)
|
|
148
|
-
|
|
149
|
-
# THE SET, resolved against the repository this run will rebuild.
|
|
150
|
-
ARTIFACT_PATHS=$(bundle_set "$repo_root")
|
|
151
|
-
|
|
152
|
-
# AN EMPTY DERIVATION REFUSES, and it must: the guard below asks whether every
|
|
153
|
-
# unmerged path is in this set, and against an empty set that question has no
|
|
154
|
-
# true answer to give — but a guard written the other way round would have said
|
|
155
|
-
# yes to everything. The build changing shape, or a checkout with no
|
|
156
|
-
# `packages/`, is a reason to stop rather than a reason to repair blind.
|
|
157
|
-
if [ -z "$ARTIFACT_PATHS" ]; then
|
|
158
|
-
echo "step: no bundles derived from packages/board/build.mjs — refusing"
|
|
159
|
-
finish refused no-bundle-set
|
|
160
|
-
fi
|
|
161
|
-
|
|
162
|
-
# WHICH WORKTREE HOLDS THIS BRANCH — ASK GIT, do not reconstruct the path from
|
|
163
|
-
# the branch name.
|
|
164
|
-
#
|
|
165
|
-
# MEASURED, and recorded at length in plot-dispatch.sh's held_worktree: a
|
|
166
|
-
# hand-made worktree is named for the branch with its TYPE dropped, so a gate
|
|
167
|
-
# that guessed `plot-wt-<flattened>` missed a worktree with six modified files
|
|
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
|
|
172
|
-
# fresh worktree lands where dispatch would have put it).
|
|
173
|
-
#
|
|
174
|
-
# `git worktree list --porcelain` emits `worktree <path>` then `branch
|
|
175
|
-
# refs/heads/<name>` per entry; the branch line is matched and the path taken
|
|
176
|
-
# from the preceding one. A branch already dispatched is thus repaired in the
|
|
177
|
-
# worktree it is already checked out in rather than in a second copy of itself —
|
|
178
|
-
# git refuses a second checkout of one branch, and the two would fight over the
|
|
179
|
-
# same index if it did not.
|
|
180
|
-
wt=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$branch" '
|
|
181
|
-
/^worktree / { path = substr($0, 10) }
|
|
182
|
-
/^branch / { if (substr($0, 8) == want) { print path; exit } }')
|
|
183
|
-
|
|
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"
|
|
190
|
-
if [ -z "$wt" ]; then
|
|
191
|
-
wt_root=$(plot_desk_root "$(plot_repo_root)") || finish refused no-desk-root
|
|
192
|
-
wt="$wt_root/$(printf '%s' "$branch" | tr '/' '-')"
|
|
193
|
-
fi
|
|
194
|
-
|
|
195
|
-
if [ "$dry_run" = 1 ]; then
|
|
196
|
-
echo "step: would use worktree $wt"
|
|
197
|
-
echo "step: would merge origin/$MAIN, take a side of each conflicted bundle, rebuild, test"
|
|
198
|
-
printf 'step: bundle set (%s): %s\n' \
|
|
199
|
-
"$(printf '%s\n' "$ARTIFACT_PATHS" | grep -c .)" \
|
|
200
|
-
"$(printf '%s' "$ARTIFACT_PATHS" | tr '\n' ' ')"
|
|
201
|
-
echo "step: would push only if pnpm run test:board passes"
|
|
202
|
-
finish refused dry-run
|
|
203
|
-
fi
|
|
204
|
-
|
|
205
|
-
git fetch -q origin "$MAIN" "$branch" 2>/dev/null || true
|
|
206
|
-
|
|
207
|
-
# ONE REPAIR AT A TIME, AND NEVER TWO ON ONE BRANCH.
|
|
208
|
-
#
|
|
209
|
-
# A second run while the first is working would fight over the same worktree:
|
|
210
|
-
# the merge, the rebuild and the five-minute suite all write into it, and two of
|
|
211
|
-
# them interleaved produce an artifact belonging to neither run. The lock is a
|
|
212
|
-
# DIRECTORY rather than a file because `mkdir` is atomic on every filesystem
|
|
213
|
-
# this runs on — two processes racing it, one wins, and the loser learns it lost
|
|
214
|
-
# from the exit code rather than from a check-then-write that both pass.
|
|
215
|
-
#
|
|
216
|
-
# The board guards its own in-flight repairs too, in memory. Both are needed and
|
|
217
|
-
# neither is redundant: the board's registry cannot see a repair started by a
|
|
218
|
-
# second board or by a human at a shell, and this lock cannot stop the board
|
|
219
|
-
# from spawning (it learns only after the spawn). The lock is the authority.
|
|
220
|
-
lock="$repo_root/.plot/state/resolve-$(printf '%s' "$branch" | tr '/' '-').lock"
|
|
221
|
-
mkdir -p "$(dirname "$lock")" 2>/dev/null || true
|
|
222
|
-
if ! mkdir "$lock" 2>/dev/null; then
|
|
223
|
-
echo "step: a repair is already in flight for $branch ($lock)"
|
|
224
|
-
finish refused already-in-flight
|
|
225
|
-
fi
|
|
226
|
-
# Released on every exit, including a kill. A lock that outlives its process
|
|
227
|
-
# would make one interrupted repair block the branch forever — and the repair is
|
|
228
|
-
# idempotent, so there is nothing to protect after the process is gone. A TERM
|
|
229
|
-
# or INT stops the repair (143 or 130) after the lock is released.
|
|
230
|
-
plot_on_exit 'rmdir "$lock" 2>/dev/null || true'
|
|
231
|
-
|
|
232
|
-
if [ -d "$wt" ] && git worktree list --porcelain | grep -qx "worktree $wt"; then
|
|
233
|
-
# A REUSED WORKTREE MAY BELONG TO SOMEONE ELSE, and on 2026-08-17 one did: the
|
|
234
|
-
# resolver ran `git merge` inside a worktree an agent was actively editing —
|
|
235
|
-
# zero unmerged paths, three modified files, work in progress. It refused
|
|
236
|
-
# before writing anything, but that was luck rather than design.
|
|
237
|
-
#
|
|
238
|
-
# Reuse is right when the worktree is IDLE; the name alone does not say so. A
|
|
239
|
-
# worktree with modifications is one whose owner is mid-thought, and merging
|
|
240
|
-
# into it would either fail on "local changes would be overwritten" or, worse,
|
|
241
|
-
# succeed and fold a stranger's uncommitted work into a merge commit this
|
|
242
|
-
# script then pushes.
|
|
243
|
-
#
|
|
244
|
-
# The honest minimum is to refuse, and the plan names it acceptable: creating a
|
|
245
|
-
# scratch worktree is impossible anyway while git holds this branch checked out
|
|
246
|
-
# here, since git refuses a second checkout of one branch.
|
|
247
|
-
#
|
|
248
|
-
# `--porcelain` rather than a parsed `git status`: it is the stable interface,
|
|
249
|
-
# and an untracked file is deliberately NOT counted — a stray log or an
|
|
250
|
-
# editor's scratch file is not work in progress, and `merge` does not touch it.
|
|
251
|
-
echo "step: reusing worktree $wt"
|
|
252
|
-
busy=$(git -C "$wt" status --porcelain --untracked-files=no 2>/dev/null)
|
|
253
|
-
if [ -n "$busy" ]; then
|
|
254
|
-
echo "step: worktree has modifications that are not this repair's — refusing"
|
|
255
|
-
printf 'step: modified: %s\n' "$(printf '%s' "$busy" | sed 's/^...//' | tr '\n' ' ')"
|
|
256
|
-
finish refused worktree-busy
|
|
257
|
-
fi
|
|
258
|
-
else
|
|
259
|
-
plot_exclude_desk_root "$(plot_repo_root)"
|
|
260
|
-
if ! git worktree add -q "$wt" "$branch" 2>/dev/null; then
|
|
261
|
-
if ! git worktree add -q -b "$branch" "$wt" "origin/$branch" 2>/dev/null; then
|
|
262
|
-
echo "plot-resolve-artifact: cannot create a worktree for $branch at $wt" >&2
|
|
263
|
-
finish refused no-worktree
|
|
264
|
-
fi
|
|
265
|
-
fi
|
|
266
|
-
echo "step: worktree $wt"
|
|
267
|
-
fi
|
|
268
|
-
|
|
269
|
-
# THE FIXED SEQUENCE. Five steps, no decision between them.
|
|
270
|
-
|
|
271
|
-
# 1. Merge. The conflict is EXPECTED — that is why we are here — so a non-zero
|
|
272
|
-
# exit is not yet a failure. What decides is which paths came back
|
|
273
|
-
# unmerged, checked next.
|
|
274
|
-
git -C "$wt" merge --no-edit "origin/$MAIN" >/dev/null 2>&1
|
|
275
|
-
merge_status=$?
|
|
276
|
-
|
|
277
|
-
if [ "$merge_status" -eq 0 ]; then
|
|
278
|
-
# Nothing conflicted after all — the prediction was made from refs that have
|
|
279
|
-
# since moved, which is the direction this repo already knows they move in.
|
|
280
|
-
# The merge stands; there is nothing to repair and nothing to prove, so this
|
|
281
|
-
# pushes nothing rather than pushing a merge nobody asked for.
|
|
282
|
-
git -C "$wt" merge --abort >/dev/null 2>&1 || true
|
|
283
|
-
git -C "$wt" reset -q --hard "HEAD" >/dev/null 2>&1 || true
|
|
284
|
-
echo "step: no conflict on merge — nothing to repair"
|
|
285
|
-
finish refused no-conflict
|
|
286
|
-
fi
|
|
287
|
-
|
|
288
|
-
# 2. VERIFY THE SET, HERE, AGAINST THE REAL MERGE.
|
|
289
|
-
#
|
|
290
|
-
# The board classified from `merge-tree`, which predicts IN MEMORY from the refs
|
|
291
|
-
# this machine holds. This is the merge itself, and it is the only place the set
|
|
292
|
-
# is a fact rather than a forecast — a stale ref makes the prediction wrong in
|
|
293
|
-
# the reassuring direction, so the entry condition is re-checked against reality
|
|
294
|
-
# before anything is written.
|
|
295
|
-
#
|
|
296
|
-
# The set is read once and asked TWO questions, in order: was anything observed
|
|
297
|
-
# at all, and — only then — was it exactly the artifact.
|
|
298
|
-
unmerged=$(git -C "$wt" diff --name-only --diff-filter=U)
|
|
299
|
-
n_unmerged=$(printf '%s\n' "$unmerged" | grep -c . || true)
|
|
300
|
-
|
|
301
|
-
# AN EMPTY SET IS NOT A SMALL SET — it is the absence of a reading.
|
|
302
|
-
#
|
|
303
|
-
# The merge exited non-zero, so something went wrong; but a conflict is not the
|
|
304
|
-
# only thing that ends a merge non-zero. A merge that never STARTED — refused
|
|
305
|
-
# because the worktree was dirty, because a merge was already in progress, or
|
|
306
|
-
# because the ref could not be resolved — exits non-zero too and leaves no
|
|
307
|
-
# unmerged paths behind. Zero paths therefore answers a different question than
|
|
308
|
-
# one or three do: those say WHICH files conflicted, zero says NOBODY LOOKED.
|
|
309
|
-
#
|
|
310
|
-
# Measured on 2026-08-17, and the defect this branch exists for: the resolver
|
|
311
|
-
# reused a worktree in which no merge was running, read zero paths, compared
|
|
312
|
-
# zero against one, and reported `not-artifact-only` — a name asserting
|
|
313
|
-
# something about files it had never examined. The refusal was right; its reason
|
|
314
|
-
# was wrong, and the wrong reason sent a reader looking for conflicts that did
|
|
315
|
-
# not exist.
|
|
316
|
-
#
|
|
317
|
-
# So the two refusals are named apart. `not-artifact-only` is a claim about an
|
|
318
|
-
# observed set and may only be said when there was one.
|
|
319
|
-
if [ "$n_unmerged" = "0" ]; then
|
|
320
|
-
git -C "$wt" merge --abort >/dev/null 2>&1 || true
|
|
321
|
-
echo "step: the merge reported failure but left no unmerged paths — nothing was observed"
|
|
322
|
-
finish refused not-observed
|
|
323
|
-
fi
|
|
324
|
-
|
|
325
|
-
# EVERY unmerged path is a bundle: the conflict set is a SUBSET of the bundle
|
|
326
|
-
# set, and nothing else is in it. NOT "a bundle is among the conflicts" — an
|
|
327
|
-
# implementation asking that passes every bundle-only case and silently repairs
|
|
328
|
-
# merges that need judgement as a whole. The claim stayed exact when the list
|
|
329
|
-
# grew from one file to nine; only the thing each path is checked against
|
|
330
|
-
# changed. A merge conflicting in a bundle AND anything else still needs a
|
|
331
|
-
# person, even though one of its files does not.
|
|
332
|
-
#
|
|
333
|
-
# WALKED PER PATH rather than compared as a whole, because the conflict set is
|
|
334
|
-
# an arbitrary subset of nine and there is no single string to compare it to.
|
|
335
|
-
# The direction is what keeps it exact: every element of the observed set must
|
|
336
|
-
# appear in the licensed set, so an unlicensed path can only ever refuse.
|
|
337
|
-
outside=""
|
|
338
|
-
while IFS= read -r conflict; do
|
|
339
|
-
[ -n "$conflict" ] || continue
|
|
340
|
-
if ! printf '%s\n' "$ARTIFACT_PATHS" | grep -qxF -- "$conflict"; then
|
|
341
|
-
outside="${outside}${outside:+ }$conflict"
|
|
342
|
-
fi
|
|
343
|
-
done <<EOF
|
|
344
|
-
$unmerged
|
|
345
|
-
EOF
|
|
346
|
-
|
|
347
|
-
if [ -n "$outside" ]; then
|
|
348
|
-
git -C "$wt" merge --abort >/dev/null 2>&1 || true
|
|
349
|
-
echo "step: conflict set is not bundles only — refusing"
|
|
350
|
-
printf 'step: unmerged: %s\n' "$(printf '%s' "$unmerged" | tr '\n' ' ')"
|
|
351
|
-
printf 'step: outside the bundle set: %s\n' "$outside"
|
|
352
|
-
finish refused not-artifact-only
|
|
353
|
-
fi
|
|
354
|
-
|
|
355
|
-
# 3. Take a side of EACH conflicted bundle. WHICH SIDE CANNOT MATTER — the
|
|
356
|
-
# rebuild overwrites it — and the diff is never read. `--theirs` because the
|
|
357
|
-
# command needs a word.
|
|
358
|
-
#
|
|
359
|
-
# Only the paths that actually conflicted, never the whole set: a bundle git
|
|
360
|
-
# merged cleanly has no side to take, and `checkout --theirs` on an unmerged-
|
|
361
|
-
# stage-free path errors rather than doing nothing useful.
|
|
362
|
-
while IFS= read -r conflict; do
|
|
363
|
-
[ -n "$conflict" ] || continue
|
|
364
|
-
git -C "$wt" checkout --theirs -- "$conflict" 2>/dev/null \
|
|
365
|
-
|| git -C "$wt" checkout --ours -- "$conflict" 2>/dev/null \
|
|
366
|
-
|| true
|
|
367
|
-
git -C "$wt" add -- "$conflict" 2>/dev/null || true
|
|
368
|
-
echo "step: took a side of $conflict (either — the rebuild decides)"
|
|
369
|
-
done <<EOF
|
|
370
|
-
$unmerged
|
|
371
|
-
EOF
|
|
372
|
-
|
|
373
|
-
# 4. Rebuild, in the branch's OWN worktree. This is what makes the kept side
|
|
374
|
-
# irrelevant, and it is the property CI's no-diff gate then re-checks.
|
|
375
|
-
if ! (cd "$wt" && pnpm build:board >/dev/null 2>&1); then
|
|
376
|
-
git -C "$wt" merge --abort >/dev/null 2>&1 || true
|
|
377
|
-
echo "step: rebuild failed — pushing nothing"
|
|
378
|
-
finish abandoned build-failed
|
|
379
|
-
fi
|
|
380
|
-
# EVERY bundle is staged after the rebuild, not just the ones that conflicted.
|
|
381
|
-
# `pnpm build:board` regenerates all nine, and a rebuild triggered by one
|
|
382
|
-
# conflict can legitimately move another — the merge brought in source changes
|
|
383
|
-
# for the whole package. Staging only the conflicted paths would leave those
|
|
384
|
-
# modifications unstaged, and CI's no-diff gate would then fail the push for a
|
|
385
|
-
# file this run had already rebuilt correctly.
|
|
386
|
-
while IFS= read -r bundle; do
|
|
387
|
-
[ -n "$bundle" ] || continue
|
|
388
|
-
git -C "$wt" add -- "$bundle" 2>/dev/null || true
|
|
389
|
-
done <<EOF
|
|
390
|
-
$ARTIFACT_PATHS
|
|
391
|
-
EOF
|
|
392
|
-
printf 'step: rebuilt and staged %s bundle(s)\n' "$(printf '%s\n' "$ARTIFACT_PATHS" | grep -c .)"
|
|
393
|
-
|
|
394
|
-
# The merge commit exists only once the rebuild has produced the artifact it
|
|
395
|
-
# will carry. Committing before the build would leave a commit holding a stale
|
|
396
|
-
# artifact if the build then failed — exactly what CI's no-diff gate catches,
|
|
397
|
-
# arriving as a push instead of as a refusal.
|
|
398
|
-
if ! git -C "$wt" commit -q --no-edit 2>/dev/null; then
|
|
399
|
-
echo "step: nothing to commit after the rebuild"
|
|
400
|
-
finish abandoned nothing-to-commit
|
|
401
|
-
fi
|
|
402
|
-
|
|
403
|
-
# 5. THE GATE. Green in this worktree BEFORE the push, never CI after it.
|
|
404
|
-
# A resolver that pushed and let CI decide passes every correctness check
|
|
405
|
-
# above and manufactures a red PR in the queue — the exact stuck state this
|
|
406
|
-
# plan exists to remove.
|
|
407
|
-
echo "step: running pnpm run test:board"
|
|
408
|
-
if ! (cd "$wt" && pnpm run test:board >/dev/null 2>&1); then
|
|
409
|
-
# NOTHING IS PUSHED, and the merge is undone so the branch is left exactly as
|
|
410
|
-
# it was found. A half-repaired branch would be a third state nobody named.
|
|
411
|
-
git -C "$wt" reset -q --hard "HEAD~1" 2>/dev/null || true
|
|
412
|
-
echo "step: test:board failed — pushing nothing, this is a conflict a human owns"
|
|
413
|
-
finish abandoned tests-failed
|
|
414
|
-
fi
|
|
415
|
-
echo "step: test:board passed"
|
|
416
|
-
|
|
417
|
-
if ! git -C "$wt" push -q origin "HEAD:$branch" 2>/dev/null; then
|
|
418
|
-
echo "step: push rejected — the branch moved under us; leaving the repair local"
|
|
419
|
-
finish abandoned push-failed
|
|
420
|
-
fi
|
|
421
|
-
|
|
422
|
-
echo "step: pushed $branch"
|
|
423
|
-
finish pushed artifact-conflict-resolved
|
package/plot-transcript-quiet.sh
DELETED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# The ONE answer to "how long has this worktree's agent been quiet?" — sourced,
|
|
3
|
-
# not run, by `plot-worker-monitor.sh` and `plot-worker-loop.sh`.
|
|
4
|
-
#
|
|
5
|
-
# It reads the AGENT rather than the machine. A `claude -p` session appends a
|
|
6
|
-
# timestamped line to its transcript for every model turn, tool call and tool
|
|
7
|
-
# result; the seconds since the newest of those lines is how long the agent has
|
|
8
|
-
# produced nothing. A CPU sample answers *is this process on a core right now?*,
|
|
9
|
-
# which is a different question and is zero for most of a working agent's life.
|
|
10
|
-
#
|
|
11
|
-
# ═══════════════════════════════════════════════════════════════════════════
|
|
12
|
-
# THREE ANSWERS, AND THE THIRD IS NOT A FAILURE
|
|
13
|
-
# ═══════════════════════════════════════════════════════════════════════════
|
|
14
|
-
#
|
|
15
|
-
# <seconds> the newest transcript line is that many seconds old
|
|
16
|
-
# unavailable no transcript can be read for this worktree, INCLUDING the
|
|
17
|
-
# case where the arguments name no worktree at all
|
|
18
|
-
#
|
|
19
|
-
# `unavailable` is a first-class answer, settled by
|
|
20
|
-
# `the-registry-supervises-its-agents`: a capability the adopting project does
|
|
21
|
-
# not provide is UNAVAILABLE, never failed and never zero. A caller that read
|
|
22
|
-
# it as "quiet for 0 seconds" would report every unreadable agent healthy; one
|
|
23
|
-
# that read it as an error would refuse to run where Plot's own contract says
|
|
24
|
-
# it should degrade. So it is a word, and callers match on it.
|
|
25
|
-
#
|
|
26
|
-
# ═══════════════════════════════════════════════════════════════════════════
|
|
27
|
-
# THE TRANSCRIPT IS FOUND BY PATH, NOT BY SESSION ID — AND THAT IS A CHOICE
|
|
28
|
-
# ═══════════════════════════════════════════════════════════════════════════
|
|
29
|
-
#
|
|
30
|
-
# `.plot/worker-prompt.sh:29` DOES pass `--session-id` now (2026-09-04), so an
|
|
31
|
-
# exact join is available here and this deliberately does not take it. The
|
|
32
|
-
# reason is the question, not the capability: this asks *is anything happening
|
|
33
|
-
# at this desk*, and an operator's own session at the same worktree is a true
|
|
34
|
-
# answer to it. Joining on the worker's id alone would report a desk quiet
|
|
35
|
-
# while somebody is visibly working at it.
|
|
36
|
-
#
|
|
37
|
-
# THE OPPOSITE JOIN IS RIGHT FOR THE OPPOSITE QUESTION. *What has THIS agent
|
|
38
|
-
# spent* is per session and never per worktree — one project directory measured
|
|
39
|
-
# 2026-09-03 held 45 session files, 30 of them subagents, and a sum across them
|
|
40
|
-
# belongs to no one. `rules/spend.ts` states that side; the two read the same
|
|
41
|
-
# files and must not be made to share a join.
|
|
42
|
-
#
|
|
43
|
-
# A THIRD QUESTION USES BOTH KEYS: *has this worker's conversation written?* It
|
|
44
|
-
# is per worktree AND per conversation handle, and it is asked as a file's
|
|
45
|
-
# existence, never as a time: `plot_transcript_exists` below. The loop asks it
|
|
46
|
-
# to choose `--session-id` or `--resume`; the worker monitor asks it before it
|
|
47
|
-
# calls a quiet desk idle, because until the new conversation writes its first
|
|
48
|
-
# line the desk's newest file belongs to the previous one. It does not change
|
|
49
|
-
# the quiet number, which stays about the desk.
|
|
50
|
-
#
|
|
51
|
-
# So the join here is the one `plot-quiet-stretch.mjs` already made and proved
|
|
52
|
-
# on 23 real sessions: the runtime stores a session under
|
|
53
|
-
# `$HOME/.claude/projects/<slug>` where the slug is the WORKTREE PATH with `/`
|
|
54
|
-
# and `.` replaced by `-`. A dispatched worker has its own worktree, so the
|
|
55
|
-
# path identifies the DESK without any id being passed anywhere.
|
|
56
|
-
#
|
|
57
|
-
# `agent-` PREFIXED FILES ARE SKIPPED, for wave 1's reason: a subagent's
|
|
58
|
-
# transcript is a true statement about the wrong process. A worker whose
|
|
59
|
-
# subagent is chatting while the worker itself has stopped must read as quiet.
|
|
60
|
-
#
|
|
61
|
-
# ═══════════════════════════════════════════════════════════════════════════
|
|
62
|
-
# THE NEWEST LINE ACROSS ALL OF A WORKTREE'S SESSIONS
|
|
63
|
-
# ═══════════════════════════════════════════════════════════════════════════
|
|
64
|
-
#
|
|
65
|
-
# A worktree can hold several sessions: a worker that hopped waves, or an
|
|
66
|
-
# operator who opened a session at the same desk. Any of them producing output
|
|
67
|
-
# means SOMEBODY is working there, and the monitor's question is about the desk.
|
|
68
|
-
# Taking the maximum timestamp is the answer that never ends a live session
|
|
69
|
-
# because a stale sibling exists beside it.
|
|
70
|
-
|
|
71
|
-
# The runtime's project-slug derivation. Duplicated from `plot-quiet-stretch.mjs`
|
|
72
|
-
# — which duplicates it from the board's `projectSlug` — because a monitor on a
|
|
73
|
-
# 30s loop must not depend on a build step. One line, pinned by a test on each
|
|
74
|
-
# side.
|
|
75
|
-
plot_transcript_slug() { # $1=worktree path → slug
|
|
76
|
-
printf '%s' "$1" | tr '/.' '--'
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
# Where the runtime keeps this worktree's transcripts, or "" if nowhere.
|
|
80
|
-
plot_transcript_dir() { # $1=worktree → directory path (may not exist)
|
|
81
|
-
local wt="$1" home="${PLOT_TRANSCRIPT_HOME:-${HOME:-}}"
|
|
82
|
-
[ -n "$wt" ] && [ -n "$home" ] || return 0
|
|
83
|
-
printf '%s/.claude/projects/%s' "$home" "$(plot_transcript_slug "$wt")"
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
# Seconds since this worktree's agent last wrote anything.
|
|
87
|
-
#
|
|
88
|
-
# THE MTIME IS THE READING, not the file's contents. The runtime appends as it
|
|
89
|
-
# works, so the file's modification time IS the timestamp of its newest line —
|
|
90
|
-
# and reading it costs one `stat` rather than parsing a transcript that reaches
|
|
91
|
-
# tens of megabytes on a long session. Wave 1 parsed timestamps because it was
|
|
92
|
-
# measuring a DISTRIBUTION of past gaps; this needs only the newest, on a 30s
|
|
93
|
-
# loop, for as many workers as the machine holds.
|
|
94
|
-
#
|
|
95
|
-
# A CLOCK IS THE RIGHT INSTRUMENT HERE, and that is worth stating because Plot
|
|
96
|
-
# usually refuses one. `plot-estate-changed.sh` hashes content rather than
|
|
97
|
-
# reading mtime, because its question is *did this change?* and a checkout moves
|
|
98
|
-
# mtime without changing anything. This question is *how long since output?* —
|
|
99
|
-
# which is a question about elapsed time, and mtime is the measurement of it.
|
|
100
|
-
plot_transcript_quiet_seconds() { # $1=worktree → seconds | unavailable
|
|
101
|
-
local wt="$1" dir newest now mtime
|
|
102
|
-
dir=$(plot_transcript_dir "$wt")
|
|
103
|
-
[ -n "$dir" ] && [ -d "$dir" ] || { printf 'unavailable'; return 0; }
|
|
104
|
-
|
|
105
|
-
# The newest mtime across every non-`agent-` session file in the directory.
|
|
106
|
-
# `find -print0` and a while-read keep paths with spaces intact; `stat` is
|
|
107
|
-
# asked once per file, and a worktree holds one to eight.
|
|
108
|
-
newest=''
|
|
109
|
-
while IFS= read -r -d '' f; do
|
|
110
|
-
case "$(basename "$f")" in agent-*) continue ;; esac
|
|
111
|
-
mtime=$(plot_transcript_mtime "$f") || continue
|
|
112
|
-
[ -n "$mtime" ] || continue
|
|
113
|
-
if [ -z "$newest" ] || [ "$mtime" -gt "$newest" ] 2>/dev/null; then newest="$mtime"; fi
|
|
114
|
-
done < <(find "$dir" -maxdepth 1 -type f -name '*.jsonl' -print0 2>/dev/null)
|
|
115
|
-
|
|
116
|
-
# A directory that exists but holds no session file is still UNAVAILABLE. The
|
|
117
|
-
# runtime creates the directory when the project is first opened, so an empty
|
|
118
|
-
# one means no session has written here — which is exactly "no transcript can
|
|
119
|
-
# be read", not "quiet for a very long time".
|
|
120
|
-
[ -n "$newest" ] || { printf 'unavailable'; return 0; }
|
|
121
|
-
|
|
122
|
-
now=$(date +%s)
|
|
123
|
-
local quiet=$(( now - newest ))
|
|
124
|
-
# A transcript written in the future — a clock skew across a mounted volume —
|
|
125
|
-
# reads as zero rather than negative. An end condition comparing a negative
|
|
126
|
-
# against a threshold would behave correctly by accident here and not
|
|
127
|
-
# elsewhere; clamping says what is meant.
|
|
128
|
-
[ "$quiet" -lt 0 ] && quiet=0
|
|
129
|
-
printf '%s' "$quiet"
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
# Does the conversation `id` have a transcript at this worktree?
|
|
133
|
-
#
|
|
134
|
-
# EXISTENCE, NOT A TIMESTAMP. The runtime creates `<id>.jsonl` with its first
|
|
135
|
-
# line and appends to it after, under both `--session-id` and `--resume`. So the
|
|
136
|
-
# file's presence says the conversation has written, and no comparison of
|
|
137
|
-
# clocks is made, so a file created in the same second as a manifest write
|
|
138
|
-
# reads as present.
|
|
139
|
-
#
|
|
140
|
-
# NO HANDLE AND NO FILE ARE ONE ANSWER HERE, and a caller that must tell them
|
|
141
|
-
# apart checks the handle first. `session_flag` reads both as *create*; the
|
|
142
|
-
# monitor's port does not, because a monitor with no handle must not read every
|
|
143
|
-
# quiet worker as unspoken.
|
|
144
|
-
plot_transcript_exists() { # $1=worktree $2=id → 0 found | 1 not
|
|
145
|
-
local wt="$1" id="$2" dir
|
|
146
|
-
[ -n "$wt" ] && [ -n "$id" ] || return 1
|
|
147
|
-
dir=$(plot_transcript_dir "$wt" 2>/dev/null) || return 1
|
|
148
|
-
[ -n "$dir" ] || return 1
|
|
149
|
-
[ -f "$dir/$id.jsonl" ]
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
# A file's modification time as a unix epoch. BSD and GNU `stat` disagree on the
|
|
153
|
-
# flag, and a monitor that works on the author's laptop and not in CI is a
|
|
154
|
-
# monitor nobody trusts.
|
|
155
|
-
#
|
|
156
|
-
# THE `||` IS NOT ENOUGH, AND CI MEASURED WHY. On Linux `stat -f` is not an
|
|
157
|
-
# unknown flag — it means FILESYSTEM info, and it SUCCEEDS. So the BSD form
|
|
158
|
-
# never falls through: it printed `Namelen: 255 Type: ext2/ext3` and the
|
|
159
|
-
# caller subtracted that from a clock. Two of this branch's own tests failed on
|
|
160
|
-
# it, 2026-09-02, having passed on macOS.
|
|
161
|
-
#
|
|
162
|
-
# So the answer is validated rather than trusted. Each form must yield digits;
|
|
163
|
-
# anything else is treated as that form not being available here.
|
|
164
|
-
plot_transcript_mtime() { # $1=file → epoch seconds
|
|
165
|
-
local m
|
|
166
|
-
m=$(stat -c '%Y' "$1" 2>/dev/null)
|
|
167
|
-
case "$m" in ''|*[!0-9]*) m=$(stat -f '%m' "$1" 2>/dev/null) ;; esac
|
|
168
|
-
case "$m" in ''|*[!0-9]*) return 1 ;; esac
|
|
169
|
-
printf '%s' "$m"
|
|
170
|
-
}
|