@plot-pm/board 0.15.0 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plot-pm/board",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Local Kanban board for Plot — a glanceable view of plan phases from docs/plans, with sprint and story filters",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,6 +40,7 @@
40
40
  "plot-release-refs.sh",
41
41
  "plot-resolve-artifact.sh",
42
42
  "plot-state-receipt.sh",
43
+ "plot-tmp.sh",
43
44
  "plot-transcript-quiet.sh",
44
45
  "plot-worker-monitor.sh",
45
46
  "plot-worker-state.sh"
@@ -13,7 +13,11 @@
13
13
  # arrived. The loop is a script, not a library, so sourcing it would run it; the
14
14
  # body moved here unchanged and the loop sources this file instead.
15
15
  #
16
- # Defines one function and does nothing else on load.
16
+ # Defines four functions and does nothing else on load: `clear_manifest_branch`;
17
+ # `plot_session_id`, which `plot-dispatch.sh` calls to launch an agent and
18
+ # `plot-worker-loop.sh` calls when a hop moves the agent to a new branch; and
19
+ # `manifest_resume_id` with `session_handle`, the conversation handle that the
20
+ # loop passes to the prompt and `plot-worker-monitor.sh` probes for a transcript.
17
21
 
18
22
  # Clear `branch` when a slice finishes, so the window before the next one is
19
23
  # observable.
@@ -56,3 +60,78 @@ clear_manifest_branch() { # $1=manifest
56
60
 
57
61
  mv -f "$tmp" "$manifest" 2>/dev/null || { rm -f "$tmp"; return 1; }
58
62
  }
63
+
64
+ # A session id, in the shape the runtime uses for its transcript filename.
65
+ #
66
+ # `uuidgen` where it exists (macOS and most Linux), falling back to `/dev/urandom`
67
+ # — never to `$RANDOM` or a timestamp. Two workers launched in the same second by
68
+ # the same fan-out would collide on either, and a collision here silently merges
69
+ # two agents into one manifest.
70
+ #
71
+ # Lowercased because the runtime writes its transcript filename in lowercase and
72
+ # the board joins on exact string equality; `uuidgen` on macOS returns uppercase.
73
+ plot_session_id() {
74
+ local id=""
75
+ if command -v uuidgen >/dev/null 2>&1; then
76
+ id=$(uuidgen 2>/dev/null | tr 'A-Z' 'a-z')
77
+ fi
78
+ if [ -z "$id" ]; then
79
+ # 16 random bytes rendered as a v4-shaped id. The shape matters only for
80
+ # recognisability; nothing parses it.
81
+ id=$(od -An -tx1 -N16 /dev/urandom 2>/dev/null | tr -d ' \n' \
82
+ | sed -E 's/(.{8})(.{4})(.{4})(.{4})(.{12})/\1-\2-\3-\4-\5/')
83
+ fi
84
+ printf '%s' "$id"
85
+ }
86
+
87
+ # The resume handle the manifest carries, or nothing while it carries none.
88
+ #
89
+ # A SECOND FIELD, NOT AN ALIAS FOR `session`. A dispatch writes the same value
90
+ # into both, and they part at the first hop to a new branch: `session` names the
91
+ # agent and its manifest file for the agent's whole life, while `resumeId` names
92
+ # the current slice's conversation and is what the board joins the transcript
93
+ # on. Reading this field rather than `$PLOT_SESSION_ID` is what makes the
94
+ # hop's write (`update_manifest_on_hop` in `plot-worker-loop.sh`) mean anything:
95
+ # a reader asks for the handle, and gets the one the hop last wrote.
96
+ #
97
+ # A PARSE FAILURE AND AN ABSENT MANIFEST ARE ONE ANSWER, the shape
98
+ # `assigned_branch` in `plot-worker-loop.sh` already takes: no handle. A hand-started loop has no
99
+ # manifest, and a manifest nobody can read is not a handle.
100
+ manifest_resume_id() { # $1=manifest → prints the handle, or nothing
101
+ local manifest="$1"
102
+ [ -n "$manifest" ] && [ -f "$manifest" ] || return 1
103
+ local id
104
+ id=$(node -e '
105
+ const fs = require("fs");
106
+ try {
107
+ const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
108
+ process.stdout.write(typeof manifest.resumeId === "string" ? manifest.resumeId : "");
109
+ } catch { process.stdout.write(""); }
110
+ ' "$manifest" 2>/dev/null) || return 1
111
+ [ -n "$id" ] || return 1
112
+ printf '%s' "$id"
113
+ }
114
+
115
+ # THE HANDLE THE PROMPT CARRIES — the manifest's `resumeId`, or the launch id.
116
+ #
117
+ # `resumeId` IS ASKED FIRST BECAUSE IT IS THE FIELD THE HOP WRITES. A dispatch
118
+ # writes the launch id into both `session` and `resumeId`, so on a first slice
119
+ # the two answers are the same string and this reads as a no-op. It stops being
120
+ # one the moment the handle diverges from the join key — which is what the two
121
+ # fields exist to allow, and what a later `--fork-session` would do. The loop
122
+ # and `plot-worker-monitor.sh` both call this, so the prompt and the idle
123
+ # verdict ask about one conversation.
124
+ #
125
+ # `$PLOT_SESSION_ID` IS THE FALLBACK, NOT THE SOURCE. A hand-started loop has no
126
+ # manifest and a pre-`resumeId` manifest carries no handle; both are the launch
127
+ # id, which is what the prompt passed before this function existed. An absent
128
+ # manifest is not an absent session.
129
+ session_handle() { # → the handle, or nothing
130
+ local id
131
+ if id=$(manifest_resume_id "${PLOT_MANIFEST_FILE:-}"); then
132
+ printf '%s' "$id"
133
+ return 0
134
+ fi
135
+ [ -n "${PLOT_SESSION_ID:-}" ] || return 1
136
+ printf '%s' "$PLOT_SESSION_ID"
137
+ }
package/plot-approve.sh CHANGED
@@ -98,6 +98,7 @@
98
98
  set -uo pipefail
99
99
 
100
100
  script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
101
+ . "$script_dir/plot-tmp.sh"
101
102
 
102
103
  # The receipt this script leaves for plot-state-gate.sh, which refuses every
103
104
  # other writer of a `State:` line. Sourced rather than run: the gate and the
@@ -233,7 +234,8 @@ fi
233
234
  # (a rate limit included), 4 for a backend with no answer at all. Only the
234
235
  # first is an absence. The other two stop here with the host's own words and
235
236
  # name no repair to the branch, because nothing about the branch was read.
236
- pr_err_file=$(mktemp "${TMPDIR:-/tmp}/plot-approve-pr.XXXXXX")
237
+ pr_err_file=""
238
+ plot_tmpfile pr_err_file approve-pr
237
239
  pr_rc=0
238
240
  pr_json=$(bash "$script_dir/plot-host.sh" pr-state "$pr_branch" 2>"$pr_err_file") || pr_rc=$?
239
241
  pr_err=$(cat "$pr_err_file" 2>/dev/null); rm -f "$pr_err_file"
package/plot-budget.sh CHANGED
@@ -8,11 +8,25 @@
8
8
  # `plot-worker-state.sh`: the caller parses its own `$@`, so a file that ran an
9
9
  # argument parser at load time could not be sourced.
10
10
  #
11
- # IT APPENDS AND READS, AND IT NEVER PRUNES. Truncation is the one write that is
12
- # not an append, and it belongs to the `BudgetRecord` port's `truncate()` — a
13
- # second pruning path in shell would rewrite the file while the port's reader
14
- # believed it held the lines it had just proven dead. The shell writes the
15
- # record; the domain is what cleans it.
11
+ # IT APPENDS, READS TWO GENERATIONS UNDER A COUNTER, AND ROTATES BY RENAME. The
12
+ # record is three files: `budget.tsv` (current generation), `budget.tsv.1`
13
+ # (previous) and `budget.gen`, a generation counter. A rotation renames the
14
+ # current file over the previous one; it never rewrites either.
15
+ #
16
+ # ROTATION BY RENAME, NOT BY WRITE-ASIDE, AND THE DIFFERENCE IS MEASURED. The
17
+ # port's `truncate()` read the live lines, wrote them aside and renamed the copy
18
+ # over the record: over a 46 MB ledger with four appenders writing 600 lines,
19
+ # **59 of the 600 appends were lost** in one 660 ms window. A line appended
20
+ # between the read and the rename is not in the kept set, and an appender whose
21
+ # `O_APPEND` descriptor opened the old inode writes into the unlinked file. A
22
+ # rename loses nothing, because an append that races it lands in one of the two
23
+ # files and the reader reads both: 11 of 11 trials, 600 of 600 visible. So
24
+ # `truncate()` is gone from the port and from the adapter.
25
+ #
26
+ # THE APPENDER NEVER WAITS. An appender that finds the lock held by a live owner
27
+ # appends and moves on — `O_APPEND` atomicity below `PIPE_BUF` is what makes the
28
+ # record lock-free, and an appender that blocked on a rotation would put a lock
29
+ # on plot's hot path to save a rename nobody is waiting for.
16
30
  #
17
31
  # WHY A SECOND IMPLEMENTATION OF A FORMAT THE DOMAIN ALREADY ENCODES. The
18
32
  # spenders are eleven shell scripts, a board and a person at a terminal, and
@@ -120,6 +134,268 @@ budget_now_ms() {
120
134
  printf '%s000\n' "$(date +%s)"
121
135
  }
122
136
 
137
+ # ── Rotation ─────────────────────────────────────────────────────────────────
138
+ #
139
+ # THE GENERATION IS 24 HOURS AND EVERY WINDOW IS AT MOST ONE HOUR. The
140
+ # generation length is 24 times `BUDGET_FALLBACK_WINDOW_MS`, the upper bound of
141
+ # every spend window, so a line inside a live window is never in the generation a
142
+ # rotation discards. A contract test asserts that this exceeds the domain's
143
+ # `FALLBACK_WINDOW_MS`, so a longer window added later fails loudly rather than
144
+ # dropping live lines.
145
+ BUDGET_GENERATION_MS=86400000
146
+
147
+ # How long a lock may stand before it is stale. A rotation is two renames, so a
148
+ # lock older than this describes an owner that died rather than one still working.
149
+ BUDGET_LOCK_STALE_MS=10000
150
+
151
+ # The previous generation, the counter, and the lock, all beside the record.
152
+ budget_prev_path() { printf '%s\n' "$(budget_path).1"; }
153
+ budget_gen_path() { local p; p="$(budget_path)" || return 1; printf '%s\n' "${p%/*}/budget.gen"; }
154
+ budget_lock_path() { local p; p="$(budget_path)" || return 1; printf '%s\n' "${p%/*}/budget.lock"; }
155
+ budget_break_path() { local p; p="$(budget_path)" || return 1; printf '%s\n' "${p%/*}/budget-lock-broken.tsv"; }
156
+
157
+ # The generation counter. A MISSING COUNTER READS AS 0, so the first release
158
+ # reads an unrotated ledger correctly — absence is the state of every machine
159
+ # that has not rotated yet, and reporting it as broken would make a working
160
+ # record look faulty. Anything that is not a number reads as 0 for the same
161
+ # reason: a torn counter must not stop a read.
162
+ budget_gen_read() {
163
+ local path value
164
+ path="$(budget_gen_path)" || { printf '0\n'; return 0; }
165
+ { IFS= read -r value; } 2>/dev/null <"$path" || value=''
166
+ case "$value" in
167
+ ''|*[!0-9]*) printf '0\n' ;;
168
+ *) printf '%s\n' "$value" ;;
169
+ esac
170
+ }
171
+
172
+ # Publishes a counter value. WRITTEN ASIDE AND RENAMED, so a reader never sees a
173
+ # partial number: a redirect creates the name before the content, and a reader
174
+ # that opened it between the two would read an empty counter as 0 and believe no
175
+ # rotation was in progress.
176
+ budget_gen_write() {
177
+ local value="${1:-0}" path scratch
178
+ path="$(budget_gen_path)" || return 1
179
+ mkdir -p "${path%/*}" 2>/dev/null || return 1
180
+ scratch="$path.$$.$BASHPID.tmp"
181
+ printf '%s\n' "$value" >"$scratch" 2>/dev/null || return 1
182
+ mv -f "$scratch" "$path" 2>/dev/null || { rm -f "$scratch" 2>/dev/null; return 1; }
183
+ return 0
184
+ }
185
+
186
+ # The first line's timestamp of a generation, or nothing where it cannot be read.
187
+ #
188
+ # THE BRACES MATTER. `{ read …; } 2>/dev/null < "$file"` catches the redirection
189
+ # error; a trailing `2>/dev/null` on the `read` alone does not, and the moment
190
+ # after a rename is exactly when that redirection fails.
191
+ budget_first_at() {
192
+ local file="${1:-}" line at
193
+ { IFS= read -r line; } 2>/dev/null <"$file" || return 1
194
+ at="$(printf '%s' "$line" | LC_ALL=C awk -F'\t' '{print $5}')"
195
+ case "$at" in
196
+ ''|*[!0-9]*) return 1 ;;
197
+ esac
198
+ printf '%s\n' "$at"
199
+ }
200
+
201
+ # Is the current generation older than one generation length?
202
+ #
203
+ # budget_rotation_due <now-ms>
204
+ #
205
+ # Exit 0 where a rotation is due. A record with no first line, no readable
206
+ # timestamp or no file at all is NOT due: absence is not age.
207
+ budget_rotation_due() {
208
+ local now="${1:-}" path at
209
+ path="$(budget_path)" || return 1
210
+ at="$(budget_first_at "$path")" || return 1
211
+ [ -n "$now" ] || now="$(budget_now_ms)"
212
+ [ "$(( now - at ))" -ge "$BUDGET_GENERATION_MS" ]
213
+ }
214
+
215
+ # This process's owner line: pid and the moment it took the lock.
216
+ budget_owner_line() { printf '%s\t%s\n' "$$" "${1:-0}"; }
217
+
218
+ # The owner line inside a lock directory, or nothing.
219
+ budget_lock_owner() {
220
+ local dir="${1:-}" line
221
+ { IFS= read -r line; } 2>/dev/null <"$dir/owner" || return 1
222
+ printf '%s\n' "$line"
223
+ }
224
+
225
+ # Is this lock held by a live owner?
226
+ # 0 = held 1 = stale (dead pid, or older than the bound)
227
+ #
228
+ # A PID THE TABLE CANNOT BE ASKED ABOUT KEEPS ITS LOCK, `budget_slot_held`'s
229
+ # rule: breaking a lock on the strength of not knowing would rotate under a live
230
+ # rotator, and the loss that causes is the one rotation exists to avoid.
231
+ budget_lock_held() {
232
+ local dir="${1:-}" now="${2:-}" line pid at
233
+ line="$(budget_lock_owner "$dir")" || return 1
234
+ pid="${line%%$'\t'*}"; at="${line##*$'\t'}"
235
+ case "$pid" in ''|*[!0-9]*) return 1 ;; esac
236
+ kill -0 "$pid" 2>/dev/null || return 1
237
+ case "$at" in ''|*[!0-9]*) return 0 ;; esac
238
+ [ -n "$now" ] || now="$(budget_now_ms)"
239
+ [ "$(( now - at ))" -lt "$BUDGET_LOCK_STALE_MS" ]
240
+ }
241
+
242
+ # Breaks a stale lock, and takes only the lock it inspected.
243
+ #
244
+ # budget_lock_break <now-ms>
245
+ #
246
+ # RENAME FIRST, COMPARE SECOND. The breaker reads the owner line, renames the
247
+ # lock directory to a unique name, then reads the owner line inside the renamed
248
+ # directory and compares. Rename is atomic, so at most one process moves a given
249
+ # directory:
250
+ #
251
+ # - **Match**: it broke the stale lock. It records one line in
252
+ # `budget-lock-broken.tsv` and on stderr, removes the renamed directory, and
253
+ # repairs an odd counter.
254
+ # - **Mismatch**: it moved a FRESH owner's live lock. It records nothing and does
255
+ # not rotate; the fresh owner's pre-`mv` check of its own owner line then fails,
256
+ # so that rotator stops without renaming.
257
+ #
258
+ # Round 3 measured the unguarded version: two breakers both rotated, and 20
259
+ # live-window lines read as 0. Only the breaker whose line matched writes a
260
+ # record, so each break is recorded exactly once.
261
+ #
262
+ # DELETES ONLY A PATH IT HOLDS BY NAME. The renamed directory is unique to this
263
+ # process, and it is removed by that exact name — never a glob.
264
+ budget_lock_break() {
265
+ local now="${1:-}" lock before after moved rc=1
266
+ lock="$(budget_lock_path)" || return 1
267
+ [ -d "$lock" ] || return 1
268
+ [ -n "$now" ] || now="$(budget_now_ms)"
269
+
270
+ before="$(budget_lock_owner "$lock")" || before=''
271
+ if budget_lock_held "$lock" "$now"; then return 1; fi
272
+
273
+ moved="$lock.broken.$$.${RANDOM}${RANDOM}"
274
+ mv "$lock" "$moved" 2>/dev/null || return 1
275
+ after="$(budget_lock_owner "$moved")" || after=''
276
+
277
+ if [ "$before" = "$after" ] && [ -n "$before" ]; then
278
+ local pid at age break_file
279
+ pid="${before%%$'\t'*}"; at="${before##*$'\t'}"
280
+ case "$at" in ''|*[!0-9]*) age='-' ;; *) age="$(( now - at ))" ;; esac
281
+ break_file="$(budget_break_path)" || break_file=''
282
+ if [ -n "$break_file" ]; then
283
+ printf 'b1\t%s\t%s\t%s\n' "$now" "$pid" "$age" >>"$break_file" 2>/dev/null || true
284
+ fi
285
+ echo "plot-budget: broke a stale rotation lock (pid=$pid age=${age}ms)" >&2
286
+ rc=0
287
+ fi
288
+
289
+ rm -rf "$moved" 2>/dev/null || true
290
+
291
+ # AN ODD COUNTER IS REPAIRED ONLY BY THE BREAKER WHOSE LINE MATCHED. A
292
+ # mismatching breaker moved a live lock and knows nothing about the counter;
293
+ # writing it even would clear a rotation still in progress.
294
+ if [ "$rc" -eq 0 ]; then
295
+ local gen
296
+ gen="$(budget_gen_read)"
297
+ if [ $(( gen % 2 )) -eq 1 ]; then budget_gen_write "$(( gen + 1 ))" || true; fi
298
+ fi
299
+ return "$rc"
300
+ }
301
+
302
+ # Checks the lock and breaks it where it is stale. The one entry point for both
303
+ # triggers: an appender whose rotation is due finds the lock held, and any
304
+ # appender or reader that reads an ODD counter.
305
+ #
306
+ # THE SECOND TRIGGER IS WHY THIS IS NOT GATED ON A DUE ROTATION. A rotator killed
307
+ # after its `mv` and before its even write leaves a YOUNG `budget.tsv`, so no
308
+ # rotation is due for 24 h — and without this trigger the lock and the odd
309
+ # counter would stand for that long. Round 2 measured the consequence: 50 later
310
+ # appends, each due to rotate, never rotated.
311
+ budget_lock_recover() {
312
+ local now="${1:-}" lock
313
+ lock="$(budget_lock_path)" || return 1
314
+ [ -d "$lock" ] || return 1
315
+ budget_lock_break "$now"
316
+ }
317
+
318
+ # Rotates the current generation, under the lock.
319
+ #
320
+ # budget_rotate <now-ms>
321
+ #
322
+ # Exit 0 where the rename happened. The sequence is a writer's half of a sequence
323
+ # lock: odd counter, rename, even counter.
324
+ #
325
+ # THE AGE CONDITION AND THE OWNER LINE ARE BOTH RE-READ IMMEDIATELY BEFORE THE
326
+ # `mv`. This one check makes a second rotation within a generation impossible,
327
+ # whoever holds the lock: the file a rotation leaves behind is new, so the age
328
+ # condition is false for it. Round 3 forced a second rotation 0.4 s after the
329
+ # first and 3 of 600 lines went in 5 of 5 trials — that is the discard this check
330
+ # refuses, and an implementation without it passes the one-rotation race.
331
+ #
332
+ # THE OWNER LINE GUARDS AGAINST A BREAKER. A breaker that moved this lock by
333
+ # mistake leaves no owner line to match, so a rotator whose lock was moved stops
334
+ # without renaming rather than rotating beside the process that took it.
335
+ budget_rotate() {
336
+ local now="${1:-}" lock path prev gen owner
337
+ path="$(budget_path)" || return 1
338
+ lock="$(budget_lock_path)" || return 1
339
+ prev="$(budget_prev_path)" || return 1
340
+ [ -n "$now" ] || now="$(budget_now_ms)"
341
+
342
+ mkdir -p "${path%/*}" 2>/dev/null || return 1
343
+ # THE LOCK IS A `mkdir`, NEVER `flock`, which macOS does not ship. `mkdir`
344
+ # fails where the name is taken, which is the whole of the mutual exclusion.
345
+ mkdir "$lock" 2>/dev/null || return 1
346
+
347
+ owner="$(budget_owner_line "$now")"
348
+ printf '%s\n' "$owner" >"$lock/owner" 2>/dev/null || { rm -rf "$lock" 2>/dev/null; return 1; }
349
+
350
+ gen="$(budget_gen_read)"
351
+ # THE NEXT ODD NUMBER. A reader that sees it retries, and a reader that sees it
352
+ # with no live owner runs the stale-lock check.
353
+ if [ $(( gen % 2 )) -eq 1 ]; then gen=$(( gen + 1 )); fi
354
+ budget_gen_write "$(( gen + 1 ))" || { rm -rf "$lock" 2>/dev/null; return 1; }
355
+
356
+ local rc=1
357
+ if budget_rotation_due "$now" && [ "$(budget_lock_owner "$lock" 2>/dev/null || true)" = "$owner" ]; then
358
+ # O(1) AT ANY SIZE, and it loses no append: a writer that races this lands in
359
+ # one of the two files and the reader reads both.
360
+ if mv -f "$path" "$prev" 2>/dev/null; then rc=0; fi
361
+ fi
362
+
363
+ budget_gen_write "$(( gen + 2 ))" || true
364
+ # REMOVED ONLY WHERE THE LINE IS STILL THIS PROCESS'S. A lock a breaker moved
365
+ # belongs to whoever created the directory standing there now.
366
+ if [ "$(budget_lock_owner "$lock" 2>/dev/null || true)" = "$owner" ]; then
367
+ rm -rf "$lock" 2>/dev/null || true
368
+ fi
369
+ return "$rc"
370
+ }
371
+
372
+ # Rotates where one is due, and never makes its caller wait.
373
+ #
374
+ # budget_rotate_if_due <now-ms>
375
+ #
376
+ # Called by `budget_append` before it appends and by `budget_rate_read` before it
377
+ # reads. Both triggers live here: a due rotation whose lock is held runs the
378
+ # stale-lock check, and an odd counter runs it whether or not a rotation is due.
379
+ budget_maybe_rotate() {
380
+ local now="${1:-}" lock gen
381
+ [ -n "$now" ] || now="$(budget_now_ms)"
382
+ lock="$(budget_lock_path)" || return 0
383
+
384
+ # TRIGGER TWO, AND IT IS FIRST because it covers the case no rotation is due
385
+ # for: a rotator killed after its `mv`.
386
+ gen="$(budget_gen_read)"
387
+ if [ $(( gen % 2 )) -eq 1 ]; then budget_lock_recover "$now" || true; fi
388
+
389
+ budget_rotation_due "$now" || return 0
390
+
391
+ if [ -d "$lock" ]; then
392
+ # TRIGGER ONE. A live owner keeps its lock and this appender moves on.
393
+ budget_lock_recover "$now" || return 0
394
+ fi
395
+ budget_rotate "$now" || return 0
396
+ return 0
397
+ }
398
+
123
399
  # Appends one line: what a call spent, and what the response said.
124
400
  #
125
401
  # budget_append <connector> <account> <bucket> <spent> <limit> <remaining> <reset-seconds> <basis>
@@ -181,9 +457,20 @@ budget_append() {
181
457
  fi
182
458
 
183
459
  mkdir -p "$(dirname "$path")" 2>/dev/null || return 0
460
+
461
+ # ROTATES BEFORE IT APPENDS, AND NEVER WAITS TO. `budget_maybe_rotate` returns 0
462
+ # on every path — a lock held by a live owner, a rotation that lost its race, a
463
+ # counter it could not write — so the append below happens either way. An
464
+ # appender that blocked on a rotation would put a lock on plot's hot path.
465
+ budget_maybe_rotate || true
466
+
184
467
  # ONE `printf`, ONE `>>`. The redirection opens with `O_APPEND` and the single
185
468
  # write is what the atomicity guarantee is about; two writes could interleave
186
469
  # however short each was.
470
+ #
471
+ # AN APPEND THAT RACES THE RENAME IS NOT LOST. It lands in whichever file the
472
+ # descriptor resolved to, and the reader reads both generations: 11 of 11
473
+ # trials, 600 of 600 lines visible.
187
474
  printf '%s\n' "$line" >>"$path" 2>/dev/null || true
188
475
  return 0
189
476
  }
@@ -221,30 +508,102 @@ BUDGET_FALLBACK_WINDOW_MS=3600000
221
508
  # which is a reading about whichever pool was spent last — so a caller deciding
222
509
  # whether a bucket is spent must name that bucket. `graphql_budget_spent` does,
223
510
  # and this is why.
511
+ # THE ZERO ANSWER, and it is a well-formed reading rather than a failure: a
512
+ # machine that has not spent has spent nothing.
513
+ BUDGET_ZERO_ANSWER='{"spent":0,"spanMs":0,"perHour":null,"lines":0,"unreadable":0,"limit":null,"remaining":null,"resetAt":null,"basis":"unknown","read":0}'
514
+
515
+ # READS TWO GENERATIONS UNDER THE COUNTER, like a sequence lock:
516
+ #
517
+ # 1. Read `budget.gen` as `g1`. An ODD value means a rotation is in progress, or
518
+ # a rotator died — run the stale-lock check, then retry.
519
+ # 2. Read `budget.tsv`, then `budget.tsv.1`, THROUGH ONE PIPE.
520
+ # 3. Read `budget.gen` as `g2`. A value that differs from `g1` means a rotation
521
+ # landed during the read; discard the answer and retry.
522
+ #
523
+ # THE ORDER IN STEP 2 IS DELIBERATE: CURRENT FIRST, THEN PREVIOUS. A rotation
524
+ # between the two reads makes the reader see the old current file twice — it
525
+ # counts that generation twice and never misses it, and the counter check then
526
+ # discards the answer. The reverse order skips the whole live generation: round 2
527
+ # measured `spent` 0 for it.
528
+ #
529
+ # NO FILE NAME IS EVER PASSED TO `awk`. BSD awk 20200816 and gawk both exit 2
530
+ # before `END` on a missing input file, and the `|| echo` fallback below would
531
+ # turn that into spent 0 — the direction that GRANTS headroom. `.1` is missing on
532
+ # every machine until its first rotation and `budget.tsv` is missing after each
533
+ # rotation until the next append, so both are normal states and each reads as
534
+ # empty through `{ cat …; cat …; } 2>/dev/null`.
535
+ #
536
+ # AFTER THREE RETRIES IT ANSWERS FROM THE LAST READ AND SAYS SO. The answer
537
+ # carries `"rotating":true` and can only OVER-count, which makes
538
+ # `graphql_budget_spent` more cautious and never less.
539
+ BUDGET_READ_RETRIES=3
540
+
224
541
  budget_rate_read() {
225
542
  local connector="${1:-}" account="${2:-}" bucket="${3:-}" now="${4:-}"
226
- local path
543
+ local path prev answer g1 g2 attempt=0
227
544
  [ -n "$now" ] || now="$(budget_now_ms)"
228
- path="$(budget_path)" || { echo '{"spent":0,"spanMs":0,"perHour":null,"lines":0,"unreadable":0,"limit":null,"remaining":null,"resetAt":null,"basis":"unknown"}'; return 0; }
545
+ path="$(budget_path)" || { echo "$BUDGET_ZERO_ANSWER"; return 0; }
546
+ prev="$(budget_prev_path)"
229
547
 
230
- # A MISSING FILE IS AN EMPTY RECORD, not a failure — absence is the state of
231
- # every computer that has not spent yet, and reporting it as broken would make
232
- # a fresh checkout look faulty.
233
- if [ ! -f "$path" ]; then
234
- echo '{"spent":0,"spanMs":0,"perHour":null,"lines":0,"unreadable":0,"limit":null,"remaining":null,"resetAt":null,"basis":"unknown"}'
235
- return 0
236
- fi
548
+ while : ; do
549
+ g1="$(budget_gen_read)"
550
+ if [ $(( g1 % 2 )) -eq 1 ]; then
551
+ # A ROTATION IS IN PROGRESS, OR A ROTATOR DIED. The check is the same one
552
+ # an appender runs, and it is what stops an odd counter standing for 24 h.
553
+ budget_lock_recover "$now" || true
554
+ if [ "$attempt" -lt "$BUDGET_READ_RETRIES" ]; then
555
+ attempt=$(( attempt + 1 ))
556
+ continue
557
+ fi
558
+ fi
559
+
560
+ answer="$(budget_rate_pass "$connector" "$account" "$bucket" "$now" "$path" "$prev")"
561
+
562
+ g2="$(budget_gen_read)"
563
+ if [ "$g2" = "$g1" ] && [ $(( g2 % 2 )) -eq 0 ]; then
564
+ printf '%s\n' "$answer"
565
+ return 0
566
+ fi
567
+
568
+ if [ "$attempt" -ge "$BUDGET_READ_RETRIES" ]; then
569
+ # THE LAST READ, MARKED. It can only over-count, because a rotation during
570
+ # the read makes the live generation read twice and never skipped.
571
+ printf '%s,"rotating":true}\n' "${answer%\}}"
572
+ return 0
573
+ fi
574
+ attempt=$(( attempt + 1 ))
575
+ done
576
+ }
577
+
578
+ # One pass over both generations. The `awk` half of the reader; the counter check
579
+ # above decides whether its answer is kept.
580
+ budget_rate_pass() {
581
+ local connector="${1:-}" account="${2:-}" bucket="${3:-}" now="${4:-}"
582
+ local path="${5:-}" prev="${6:-}"
237
583
 
238
- LC_ALL=C awk -v want_c="$connector" -v want_a="$account" -v want_b="$bucket" \
584
+ # `|| true` ON EACH `cat`, AND `pipefail` IS WHY. A missing generation is the
585
+ # normal state — `.1` until the first rotation, `budget.tsv` between a rotation
586
+ # and the next append — and under `set -o pipefail`, which `plot-host.sh` sets,
587
+ # a failing `cat` fails the WHOLE pipeline however well `awk` answered. The
588
+ # `|| echo` fallback below then fires beside a perfectly good answer and the
589
+ # caller reads TWO JSON objects: measured, `plot-host.sh spend-rate` printed
590
+ # the same object twice and `JSON.parse` refused it. The redirection silences
591
+ # the message; only this silences the status.
592
+ { cat -- "$path" || true; cat -- "$prev" || true; } 2>/dev/null | LC_ALL=C awk -v want_c="$connector" -v want_a="$account" -v want_b="$bucket" \
239
593
  -v now="$now" -v fallback="$BUDGET_FALLBACK_WINDOW_MS" '
240
- BEGIN { FS = "\t"; unreadable = 0; n = 0; passed = -1 }
594
+ BEGIN { FS = "\t"; unreadable = 0; n = 0; passed = -1; total = 0 }
241
595
  {
242
596
  # A NULL IS THE NORMAL CASE, not an error. The file is appended to by
243
597
  # processes that may be killed mid-write, so a torn tail, a blank line and
244
598
  # a line from a newer format are all things a reader meets — and every one
245
599
  # is skipped rather than thrown on. A reader that failed on one bad line
246
600
  # would report the whole account as unreadable, which reads as headroom.
601
+ # WHAT THE PASS READ, ACROSS BOTH GENERATIONS, counted before any filter:
602
+ # the `read` field is the bound this reader reports, and the bound is about
603
+ # the FILE rather than about one key. It replaced a timing claim, which
604
+ # measured machine load rather than the ledger.
247
605
  if ($0 == "") next
606
+ total++
248
607
  if (NF != 10 || $1 != "b1") { unreadable++; next }
249
608
  if ($2 != want_c || $3 != want_a) next
250
609
  if (want_b != "" && $4 != want_b) next
@@ -315,10 +674,10 @@ budget_rate_read() {
315
674
  # cadence input this slice exists to make honest.
316
675
  rate = "null"
317
676
  }
318
- printf "{\"spent\":%d,\"spanMs\":%d,\"perHour\":%s,\"lines\":%d,\"unreadable\":%d,\"limit\":%s,\"remaining\":%s,\"resetAt\":%s,\"basis\":\"%s\"}\n", \
319
- spent, span, rate, n, unreadable, limit, remaining, reset, basis
677
+ printf "{\"spent\":%d,\"spanMs\":%d,\"perHour\":%s,\"lines\":%d,\"unreadable\":%d,\"limit\":%s,\"remaining\":%s,\"resetAt\":%s,\"basis\":\"%s\",\"read\":%d}\n", \
678
+ spent, span, rate, n, unreadable, limit, remaining, reset, basis, total
320
679
  }
321
- ' "$path" 2>/dev/null || echo '{"spent":0,"spanMs":0,"perHour":null,"lines":0,"unreadable":0,"limit":null,"remaining":null,"resetAt":null,"basis":"unknown"}'
680
+ ' 2>/dev/null || echo "$BUDGET_ZERO_ANSWER"
322
681
  }
323
682
 
324
683
  # THE SAME ANSWER IS SCANNED FOR ONCE PER PROCESS, and that is the whole of this
package/plot-config.sh CHANGED
@@ -39,7 +39,11 @@
39
39
  # Non-numeric or empty falls back to the default. It bounds
40
40
  # a HUNG agent — one whose CLI crashed without exiting — so
41
41
  # one dead worker cannot hold a slot for hours.
42
- # Agent registry the directory the dispatcher writes agent manifests to,
42
+ # Temp sweep after hours before `plot-reap.sh --sweep-temp` removes an
43
+ # owned `$TMPDIR/plot-*` entry or a dead pid's budget
44
+ # memo that a SIGKILL left behind. Default 24; a value
45
+ # that is not a whole number is refused by the sweep.
46
+ # Agent registry the directory the dispatcher writes agent manifests to,
43
47
  # read by the board's registry. Default `.plot/agents`
44
48
  # (repo-relative, gitignored, hence per-worktree). A board
45
49
  # served from a worktree the dispatcher never wrote to
@@ -48,6 +52,53 @@
48
52
  # the board finds the registry wherever it was started.
49
53
  # Absent = the default, so a single-checkout project is
50
54
  # unaffected.
55
+ # Board artifact the board-server.mjs this repository runs, read by
56
+ # plot-board-probe.sh. Declared, it is resolved FIRST and
57
+ # reported as `artifact_source: checkout`; absent, the
58
+ # order stays plugin, npm, checkout — the adopting
59
+ # project's case, unchanged. It exists because a
60
+ # repository that BUILDS the artifact must run the one it
61
+ # built: without it `pnpm build:board` writes a file the
62
+ # board never reads whenever a plugin is installed, and
63
+ # the symptom looks like the fix not working.
64
+ # A relative value resolves against the MAIN CHECKOUT (the
65
+ # parent of `--git-common-dir`), never `--show-toplevel`,
66
+ # so a dispatch desk resolves the same file rather than
67
+ # its own copy; an absolute one is taken as given.
68
+ # A declared file that is missing reports `none` and does
69
+ # NOT fall back to the plugin — the key names which
70
+ # artifact runs, so a silent substitution is the wrong
71
+ # answer it removes. Absent = today's order.
72
+ # Agent settings a JSON settings file every `claude -p` the fleet starts
73
+ # is given, through `--settings`. It names the plugins this
74
+ # project's agents start WITHOUT. Every dispatched agent
75
+ # inherits every `SessionStart` hook the operator's plugins
76
+ # declare, and the fleet starts a session on every worker
77
+ # start, restart, retry and hop plus every agent-runner
78
+ # command the board runs: measured 2026-09-30, one plugin's
79
+ # lockless sync ran three times at once, the 1-minute load
80
+ # reached 195, and the supervisor did not tick for 12
81
+ # minutes.
82
+ # Resolved by `plot-agent-settings.sh`, which prints an
83
+ # absolute path (exit 0), nothing for an absent or empty key
84
+ # (exit 0), or nothing with the reason on stderr (exit 3)
85
+ # for a missing, unparseable or gate-disabling file. A
86
+ # relative value resolves against the MAIN CHECKOUT (the
87
+ # parent of `--git-common-dir`), never `--show-toplevel`,
88
+ # for `Board artifact`'s reason: a desk must resolve the
89
+ # same file, and one cut from an older main may not hold it.
90
+ # The path travels to every consumer as
91
+ # `PLOT_AGENT_SETTINGS`, and each command key interpolates
92
+ # `${PLOT_AGENT_SETTINGS:+--settings "$PLOT_AGENT_SETTINGS"}`
93
+ # itself — Plot rewrites no configured command.
94
+ # A file setting any `plot@…` plugin false, `disableAllHooks`
95
+ # true, or ANY `env` key is REFUSED: those switch Plot's own
96
+ # four gates off, and a settings `PATH` hiding the gates'
97
+ # tools makes them fail open. It is a check against an
98
+ # accidental switch-off, not a boundary — the file is
99
+ # project-owned and reviewed like this one.
100
+ # Absent or empty = no change, so an adopting project that
101
+ # sets nothing behaves exactly as today.
51
102
  # Worktree root where /plot-dispatch creates fleet worktrees. A relative
52
103
  # value resolves against the repo root, an absolute one is
53
104
  # taken as given. Absent = the default `repo_root/..` with
package/plot-deliver.sh CHANGED
@@ -51,6 +51,7 @@
51
51
  set -uo pipefail
52
52
 
53
53
  script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
54
+ . "$script_dir/plot-tmp.sh"
54
55
 
55
56
  # The receipt this script leaves for plot-state-gate.sh, which refuses every
56
57
  # other writer of a `State:` line. Sourced rather than run: the gate and the
@@ -660,7 +661,8 @@ git -C "$tmpwt" add -- "${DELIVERED_DIR#/}" >/dev/null 2>&1 || true
660
661
  # THE BOOKED PLAN, KEPT FOR THE TRACKER. The booking worktree is removed on
661
662
  # every exit below, and the working tree may still read `Approved`; the issue
662
663
  # status is decided from the file that reached the default branch.
663
- booked_plan=$(mktemp "${TMPDIR:-/tmp}/plot-deliver-plan.XXXXXX")
664
+ booked_plan=""
665
+ plot_tmpfile booked_plan deliver-plan
664
666
  cp "$tmpwt/$rel" "$booked_plan" 2>/dev/null || : > "$booked_plan"
665
667
 
666
668
  if git -C "$tmpwt" diff --cached --quiet 2>/dev/null; then