@plot-pm/board 0.9.1 → 0.11.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 +170 -164
- package/package.json +6 -1
- package/plot-agent-monitor.sh +508 -0
- package/plot-approve.sh +145 -29
- package/plot-budget.sh +439 -0
- package/plot-config.sh +9 -0
- package/plot-deliver.sh +210 -117
- package/plot-dispatch.sh +829 -185
- package/plot-fleet-scan.sh +577 -65
- package/plot-host.sh +1329 -55
- package/plot-plan-meta.sh +246 -36
- package/plot-reap.sh +675 -57
- package/plot-transcript-quiet.sh +142 -0
- package/plot-worker-monitor.sh +644 -0
- package/plot-worker-state.sh +81 -50
package/plot-approve.sh
CHANGED
|
@@ -345,8 +345,11 @@ rel=$(cd "$repo_root" && real_plan_path "$plan_file") || rel=""
|
|
|
345
345
|
# (this repo has several, documenting the format) would otherwise have its
|
|
346
346
|
# illustration rewritten too — a silent corruption of the very files that
|
|
347
347
|
# specify the format.
|
|
348
|
-
|
|
349
|
-
|
|
348
|
+
#
|
|
349
|
+
# READS ONE FILE AND WRITES ANOTHER, rather than editing in place. It edited in
|
|
350
|
+
# place until 2026-09-02, which is what let the phase land without its record —
|
|
351
|
+
# see write_transition() below.
|
|
352
|
+
flip_phase() { # $1=in $2=out → 0 if it changed the file, 1 if there was nothing to flip
|
|
350
353
|
awk '
|
|
351
354
|
BEGIN { section = ""; done = 0 }
|
|
352
355
|
/^## / { section = ($0 ~ /^## Status/) ? "status" : ""; print; next }
|
|
@@ -363,10 +366,7 @@ flip_phase() { # $1=file → 0 if it changed the file, 1 if there was nothing t
|
|
|
363
366
|
}
|
|
364
367
|
{ print }
|
|
365
368
|
END { exit (changed ? 0 : 1) }
|
|
366
|
-
' "$
|
|
367
|
-
local rc=$?
|
|
368
|
-
if [ "$rc" = 0 ]; then mv "$f.plot-tmp" "$f"; else rm -f "$f.plot-tmp"; fi
|
|
369
|
-
return "$rc"
|
|
369
|
+
' "$1" > "$2"
|
|
370
370
|
}
|
|
371
371
|
|
|
372
372
|
# Insert one `- **Approved:** ...` line into the plan's `## Status` section.
|
|
@@ -384,9 +384,11 @@ flip_phase() { # $1=file → 0 if it changed the file, 1 if there was nothing t
|
|
|
384
384
|
# plot-plan-meta.sh reads these records out of that section, so a line below it
|
|
385
385
|
# parses as nothing at all — a record that exists on disk and not in the data is
|
|
386
386
|
# worse than no record, because it looks written.
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
387
|
+
#
|
|
388
|
+
# READS ONE FILE AND WRITES ANOTHER, for the reason flip_phase() gives.
|
|
389
|
+
append_approved_line() { # $1=in $2=out $3=record
|
|
390
|
+
local line
|
|
391
|
+
line="- **Approved:** $3"
|
|
390
392
|
awk -v line="$line" '
|
|
391
393
|
{ lines[++n] = $0 }
|
|
392
394
|
END {
|
|
@@ -408,8 +410,121 @@ append_approved_line() { # $1=file $2=date $3=who $4=channel
|
|
|
408
410
|
if (!slot && i == insert) print line
|
|
409
411
|
}
|
|
410
412
|
}
|
|
411
|
-
' "$
|
|
412
|
-
|
|
413
|
+
' "$1" > "$2"
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
# ---------------------------------------------------------------------------
|
|
417
|
+
# THE TRANSITION — one value, decided in the domain, written whole or not at all
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
#
|
|
420
|
+
# WHAT THIS CLOSES. The phase and the record were steps 3 and 4 until
|
|
421
|
+
# 2026-09-02, and they were independent: `flip_phase` edited the file, and
|
|
422
|
+
# `append_approved_line` then edited it again. The second could fail — a plan
|
|
423
|
+
# with no `## Status` heading is its documented refusal — and the first had
|
|
424
|
+
# already landed. Measured on the delivery side 2026-08-20, where the same shape
|
|
425
|
+
# had the same bug: a plan carrying `Phase: Delivered` with no `Delivered:` line
|
|
426
|
+
# was filtered out of `plot-fleet-scan.sh` ENTIRELY, which reads its delivered
|
|
427
|
+
# window from the record itself. A phase without its record does not make a plan
|
|
428
|
+
# half-transitioned; it makes it invisible.
|
|
429
|
+
#
|
|
430
|
+
# SO THE TWO ARE ONE WRITE. The domain says so in a type: `transitions/plan.ts`
|
|
431
|
+
# gives `Decision` a required `phase` AND a required `record`, so a decision
|
|
432
|
+
# missing either does not compile. `plot-transition.mjs` carries that value out
|
|
433
|
+
# to here, and this pair of functions is the half that could still have broken
|
|
434
|
+
# it — both edits run against scratch copies, and the plan file is replaced only
|
|
435
|
+
# once both have succeeded.
|
|
436
|
+
#
|
|
437
|
+
# THE DOMAIN DECIDES, THIS PERFORMS. The phase word and the record's text come
|
|
438
|
+
# back from the bundle; the awk that knows where a `## Status` line lives stays
|
|
439
|
+
# here, because that is adaptation and it carries bug history worth keeping
|
|
440
|
+
# (append_started_line()'s placeholder repair, 2026-08-17).
|
|
441
|
+
#
|
|
442
|
+
# IDEMPOTENCE IS UNCHANGED, and it is still the source that answers. The domain
|
|
443
|
+
# reads the phase and the record THIS SCRIPT PARSED FROM THE FILE IT IS ABOUT TO
|
|
444
|
+
# WRITE, and answers `already` when both are present — the same question steps 3
|
|
445
|
+
# and 4 asked separately, asked once. No progress file appears here, because a
|
|
446
|
+
# progress file is exactly what would disagree with the repository when somebody
|
|
447
|
+
# intervened by hand between two runs.
|
|
448
|
+
#
|
|
449
|
+
# THE MERGE IS NOT PART OF IT, and it must not become one. Step 2 merges the PR,
|
|
450
|
+
# and a merged PR cannot be rolled back if a file write then fails. Atomicity
|
|
451
|
+
# stops at the host boundary; re-running is the repair, which is why every step
|
|
452
|
+
# still tests the source it would have written.
|
|
453
|
+
transition_mjs="$script_dir/board/plot-transition.mjs"
|
|
454
|
+
|
|
455
|
+
# Ask the domain for the transition, and refuse in its words.
|
|
456
|
+
#
|
|
457
|
+
# Called with the file's OWN parse rather than the caller's: on the `pr` flow
|
|
458
|
+
# those are different files, and the plan on the default branch is the one that
|
|
459
|
+
# counts.
|
|
460
|
+
decide_transition() { # $1=file $2=channel → prints "<Phase>\t<record>\t<write|already>\t<yes|no>"
|
|
461
|
+
local f="$1" channel="$2" m answer rc
|
|
462
|
+
[ -f "$transition_mjs" ] \
|
|
463
|
+
|| { echo "plot-approve: cannot find $transition_mjs — run 'pnpm build:board'." >&2; return 1; }
|
|
464
|
+
m=$(bash "$script_dir/plot-plan-meta.sh" "$f" 2>/dev/null) || m=""
|
|
465
|
+
[ -n "$m" ] || { echo "plot-approve: cannot parse $f — refusing rather than guessing." >&2; return 1; }
|
|
466
|
+
answer=$(printf 'approve\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t\n' \
|
|
467
|
+
"$slug" \
|
|
468
|
+
"$(printf '%s' "$m" | jq -r '.phase // ""')" \
|
|
469
|
+
"$(printf '%s' "$m" | jq -r '.review // ""')" \
|
|
470
|
+
"$(printf '%s' "$m" | jq -r '.approved_raw // ""')" \
|
|
471
|
+
"$(printf '%s' "$m" | jq -r '.delivered_raw // ""')" \
|
|
472
|
+
"$(printf '%s' "$m" | jq -r '.released_raw // ""')" \
|
|
473
|
+
"$today" "$who" "$channel" \
|
|
474
|
+
| node "$transition_mjs" 2>&1)
|
|
475
|
+
rc=$?
|
|
476
|
+
# Exit 1 is the domain's refusal and its sentence, tab-separated after the
|
|
477
|
+
# rule that fired. Exit 2 is this script handing it something unreadable,
|
|
478
|
+
# which no operator can act on — so it reports as the bug it is.
|
|
479
|
+
if [ "$rc" != 0 ]; then
|
|
480
|
+
if [ "$rc" = 1 ]; then
|
|
481
|
+
echo "plot-approve: $(printf '%s' "$answer" | cut -f2-)" >&2
|
|
482
|
+
else
|
|
483
|
+
echo "plot-approve: $answer" >&2
|
|
484
|
+
fi
|
|
485
|
+
return 1
|
|
486
|
+
fi
|
|
487
|
+
printf '%s' "$answer"
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
# Apply the decided transition: the phase and the record, or neither.
|
|
491
|
+
#
|
|
492
|
+
# BOTH EDITS RUN AGAINST SCRATCH FILES and the plan is replaced by one `mv`.
|
|
493
|
+
# An `## Status` section that cannot take the record leaves the file exactly as
|
|
494
|
+
# it was found — INCLUDING ITS PHASE — so re-running is still the repair, and
|
|
495
|
+
# the half-state that made a plan invisible cannot be reached from here.
|
|
496
|
+
#
|
|
497
|
+
# `$3` says whether the file already carries the record. A plan can carry one
|
|
498
|
+
# while its phase lags — written by hand, or an earlier run cut between the two
|
|
499
|
+
# writes — and appending a second would leave the block listing one approval
|
|
500
|
+
# twice. The domain returns the written record unchanged in that case, so the
|
|
501
|
+
# phase still flips and nothing is inserted.
|
|
502
|
+
write_transition() { # $1=file $2=record $3=recorded(yes|no) → sets phase_report record_report
|
|
503
|
+
local f="$1" record="$2" recorded="$3" a="$1.plot-phase" b="$1.plot-record" flipped=0
|
|
504
|
+
|
|
505
|
+
if flip_phase "$f" "$a"; then flipped=1; else flipped=0; fi
|
|
506
|
+
# awk wrote `$a` either way; where nothing flipped it is a faithful copy, so
|
|
507
|
+
# the record still has a file to be inserted into.
|
|
508
|
+
[ -s "$a" ] || { rm -f "$a"; echo "plot-approve: could not read $rel" >&2; return 1; }
|
|
509
|
+
|
|
510
|
+
if [ "$recorded" = "yes" ]; then
|
|
511
|
+
mv "$a" "$f" || { rm -f "$a"; return 1; }
|
|
512
|
+
record_report="already"
|
|
513
|
+
else
|
|
514
|
+
if ! append_approved_line "$a" "$b" "$record"; then
|
|
515
|
+
rm -f "$a" "$b"
|
|
516
|
+
echo "plot-approve: $rel has no '## Status' section — nowhere to record the approval." >&2
|
|
517
|
+
echo " Nothing was written: the phase is not flipped either, because a phase" >&2
|
|
518
|
+
echo " with no record is invisible to the scan. Fix the section and re-run." >&2
|
|
519
|
+
return 1
|
|
520
|
+
fi
|
|
521
|
+
mv "$b" "$f" || { rm -f "$a" "$b"; return 1; }
|
|
522
|
+
rm -f "$a"
|
|
523
|
+
record_report="written"
|
|
524
|
+
fi
|
|
525
|
+
|
|
526
|
+
phase_report=$([ "$flipped" = 1 ] && echo flipped || echo already)
|
|
527
|
+
return 0
|
|
413
528
|
}
|
|
414
529
|
|
|
415
530
|
# Remove the `.plot/hold` entry for EVERY branch the plan names, and nothing
|
|
@@ -496,26 +611,27 @@ apply_local_writes() { # $1=root → sets phase_report record_report holds_repo
|
|
|
496
611
|
local root="$1" f="$1/$rel"
|
|
497
612
|
[ -f "$f" ] || { echo "plot-approve: $rel is not present in $root" >&2; return 1; }
|
|
498
613
|
|
|
499
|
-
# Step 3 —
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
#
|
|
503
|
-
#
|
|
504
|
-
#
|
|
505
|
-
#
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
614
|
+
# Step 3 — THE TRANSITION: the phase and its record, together or not at all.
|
|
615
|
+
#
|
|
616
|
+
# ONE STEP WHERE THERE WERE TWO, which is the whole of this change. The domain
|
|
617
|
+
# decides what the two lines say and whether they are owed at all; this writes
|
|
618
|
+
# them as one replacement of the file. Already-done test: the domain answers
|
|
619
|
+
# `already` when the file it is about to write ALREADY carries both — re-parsed
|
|
620
|
+
# here rather than trusted from the caller's copy, because on the `pr` flow
|
|
621
|
+
# those are different files and the plan on the default branch is the one that
|
|
622
|
+
# counts.
|
|
623
|
+
local channel="plan-PR #$pr_number merged"
|
|
624
|
+
[ "$same_branch" = 1 ] && channel="plan-PR #$pr_number reviewed"
|
|
625
|
+
local decided record action recorded
|
|
626
|
+
decided=$(decide_transition "$f" "$channel") || return 1
|
|
627
|
+
record=$(printf '%s' "$decided" | cut -f2)
|
|
628
|
+
action=$(printf '%s' "$decided" | cut -f3)
|
|
629
|
+
recorded=$(printf '%s' "$decided" | cut -f4)
|
|
630
|
+
if [ "$action" = "already" ]; then
|
|
631
|
+
phase_report="already"
|
|
509
632
|
record_report="already"
|
|
510
633
|
else
|
|
511
|
-
|
|
512
|
-
[ "$same_branch" = 1 ] && channel="plan-PR #$pr_number reviewed"
|
|
513
|
-
if append_approved_line "$f" "$today" "$who" "$channel"; then
|
|
514
|
-
record_report="written"
|
|
515
|
-
else
|
|
516
|
-
echo "plot-approve: $rel has no '## Status' section — nowhere to record the approval" >&2
|
|
517
|
-
return 1
|
|
518
|
-
fi
|
|
634
|
+
write_transition "$f" "$record" "$recorded" || return 1
|
|
519
635
|
fi
|
|
520
636
|
|
|
521
637
|
# Step 5 — clear the holds, keyed by branch.
|
package/plot-budget.sh
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Plot helper: the budget record's SHELL half — what this computer has spent,
|
|
3
|
+
# per (connector, account, bucket).
|
|
4
|
+
#
|
|
5
|
+
# SOURCED, NOT RUN, by `plot-host.sh`. `. "$here/plot-budget.sh"` defines
|
|
6
|
+
# `budget_path`, `budget_append` and `budget_rate`; the file does nothing else
|
|
7
|
+
# on load. Same shape and same reason as `plot-pr-merged.sh` and
|
|
8
|
+
# `plot-worker-state.sh`: the caller parses its own `$@`, so a file that ran an
|
|
9
|
+
# argument parser at load time could not be sourced.
|
|
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.
|
|
16
|
+
#
|
|
17
|
+
# WHY A SECOND IMPLEMENTATION OF A FORMAT THE DOMAIN ALREADY ENCODES. The
|
|
18
|
+
# spenders are eleven shell scripts, a board and a person at a terminal, and
|
|
19
|
+
# only the board is TypeScript. A shell that had to start `node` to record one
|
|
20
|
+
# call would add ~40 ms and a runtime dependency to every host call plot makes —
|
|
21
|
+
# on the hot path, to write one line. So the format is written twice and pinned
|
|
22
|
+
# by a test that decodes shell output with `decodeEntry`: the drift risk is
|
|
23
|
+
# real, and a contract test is the answer to it rather than a slower appender.
|
|
24
|
+
#
|
|
25
|
+
# THE FORMAT IS `packages/domain/src/entities/budget.ts`'s AND NOT THIS FILE'S.
|
|
26
|
+
# Ten tab-separated fields behind a `b1` marker:
|
|
27
|
+
#
|
|
28
|
+
# b1 <connector> <account> <bucket> <at-ms> <spent> <limit> <remaining> <reset-ms> <basis>
|
|
29
|
+
#
|
|
30
|
+
# `-` is the absent marker, and ABSENT IS NOT ZERO: a `remaining` of 0 means
|
|
31
|
+
# the bucket is spent and every call is refused, while `-` means the connector
|
|
32
|
+
# did not say. Writing null as 0 would make silence read as exhaustion.
|
|
33
|
+
#
|
|
34
|
+
# APPEND-ONLY AND LOCK-FREE, AND THE LINE CAP IS WHY. Concurrent `O_APPEND` is
|
|
35
|
+
# atomic only below `PIPE_BUF`, which `getconf PIPE_BUF /` reports as **512**
|
|
36
|
+
# on this fleet's macOS machines — not the 4096 a reader assuming Linux would
|
|
37
|
+
# take. So every line is measured before it is written and an over-long one is
|
|
38
|
+
# REFUSED rather than shortened: a torn line loses the concurrent writer's line
|
|
39
|
+
# too, so dropping one spend is cheaper than corrupting another's.
|
|
40
|
+
#
|
|
41
|
+
# THE BUCKET IS THE CONNECTOR'S OWN WORD, AND ONE CONNECTOR HAS SEVERAL. GitHub
|
|
42
|
+
# meters `core` and `graphql` as independent 5000-request pools, named by
|
|
43
|
+
# `X-RateLimit-Resource` on the response of a call that was going to happen
|
|
44
|
+
# anyway. Measured 2026-09-01 on one account at one moment: `core` 4990 of 5000,
|
|
45
|
+
# `graphql` **0** of 5000. A record keyed to one undifferentiated pool describes
|
|
46
|
+
# neither — it reports room while every `gh pr` call is refused, and refuses
|
|
47
|
+
# calls that would have gone to the pool with 4990 left. Nothing here validates
|
|
48
|
+
# the name: a connector nobody has written an adapter for names a third thing.
|
|
49
|
+
#
|
|
50
|
+
# THE RECORD IS THE COMPUTER'S, NOT THE CHECKOUT'S. Measured 2026-09-01: two
|
|
51
|
+
# GitHub checkouts on this computer share the account `jwloka`, so a
|
|
52
|
+
# per-checkout `.plot/state/` would let each read a full 5000 while the other
|
|
53
|
+
# spent it — the over-spend the record exists to prevent, reproduced by storing
|
|
54
|
+
# it in the wrong place. `$PLOT_BUDGET_HOME` is the ONE override, and it is the
|
|
55
|
+
# same variable `budget-file.ts` reads.
|
|
56
|
+
|
|
57
|
+
# The record's directory, then its file. Nothing here reads a repository root, a
|
|
58
|
+
# git directory or a working directory — that absence is the fix.
|
|
59
|
+
budget_path() {
|
|
60
|
+
local home="${PLOT_BUDGET_HOME:-}"
|
|
61
|
+
if [ -z "$home" ]; then
|
|
62
|
+
[ -n "${HOME:-}" ] || return 1
|
|
63
|
+
home="$HOME/.plot/state"
|
|
64
|
+
fi
|
|
65
|
+
printf '%s\n' "$home/budget.tsv"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# The most bytes one appended line may occupy, newline included. Mirrors
|
|
69
|
+
# `MAX_LINE_BYTES` in `packages/domain/src/entities/budget.ts`, and a test pins
|
|
70
|
+
# the two together.
|
|
71
|
+
BUDGET_MAX_LINE_BYTES=512
|
|
72
|
+
|
|
73
|
+
# Strips what the format cannot carry from a key part. A tab would add a field
|
|
74
|
+
# and a newline would add a line, so both are replaced rather than escaped: the
|
|
75
|
+
# only inputs are a connector, an account and a bucket name, none of which any
|
|
76
|
+
# connector spells with whitespace.
|
|
77
|
+
budget_clean() {
|
|
78
|
+
printf '%s' "$1" | tr '\t\r\n' '___'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# A number, or the absent marker for anything that is not one. A non-numeric
|
|
82
|
+
# reading is ABSENT rather than zero, for the reason above.
|
|
83
|
+
budget_num() {
|
|
84
|
+
case "$1" in
|
|
85
|
+
''|'-') printf '%s' '-' ;;
|
|
86
|
+
*) if [[ "$1" =~ ^-?[0-9]+$ ]]; then printf '%s' "$1"; else printf '%s' '-'; fi ;;
|
|
87
|
+
esac
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Now, in epoch milliseconds.
|
|
91
|
+
#
|
|
92
|
+
# `EPOCHREALTIME` FIRST, BECAUSE IT COSTS NO PROCESS. It is a bash 5 builtin
|
|
93
|
+
# holding `seconds.microseconds`, so the milliseconds are a substring — and this
|
|
94
|
+
# runs beside every host call, where a `fork`+`exec` per line is a cost the
|
|
95
|
+
# record should not impose.
|
|
96
|
+
#
|
|
97
|
+
# THE FALLBACK IS SECONDS, AND IT IS NOT MERELY DEFENSIVE. macOS ships bash 3.2
|
|
98
|
+
# at `/bin/bash`, where `EPOCHREALTIME` does not exist; `date +%s%3N` is GNU-only
|
|
99
|
+
# and prints a literal `3N` on the BSD `date` beside it, which would write a
|
|
100
|
+
# timestamp no reader can decode. So seconds are read portably and multiplied.
|
|
101
|
+
#
|
|
102
|
+
# SECOND RESOLUTION COSTS A SPAN, NOT A COUNT. Several lines inside one second
|
|
103
|
+
# share a timestamp, so a window holding only those reports `spanMs: 0` and
|
|
104
|
+
# `perHour: null` — an absent rate, which is the honest answer to *how fast* when
|
|
105
|
+
# the record cannot yet say. The spend COUNT is exact either way, and a window
|
|
106
|
+
# wide enough to divide a cadence by spans minutes rather than milliseconds.
|
|
107
|
+
budget_now_ms() {
|
|
108
|
+
if [ -n "${EPOCHREALTIME:-}" ]; then
|
|
109
|
+
# `1788341828.708210` → `1788341828708`. The locale decides the separator,
|
|
110
|
+
# so both are matched rather than assuming a dot.
|
|
111
|
+
local whole frac
|
|
112
|
+
whole="${EPOCHREALTIME%%[.,]*}"
|
|
113
|
+
frac="${EPOCHREALTIME#*[.,]}"
|
|
114
|
+
if [ "$frac" != "$EPOCHREALTIME" ]; then
|
|
115
|
+
frac="${frac}000"
|
|
116
|
+
printf '%s%s\n' "$whole" "${frac:0:3}"
|
|
117
|
+
return
|
|
118
|
+
fi
|
|
119
|
+
fi
|
|
120
|
+
printf '%s000\n' "$(date +%s)"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# Appends one line: what a call spent, and what the response said.
|
|
124
|
+
#
|
|
125
|
+
# budget_append <connector> <account> <bucket> <spent> <limit> <remaining> <reset-seconds> <basis>
|
|
126
|
+
#
|
|
127
|
+
# `limit`, `remaining` and `reset` may be empty or `-` where the connector did
|
|
128
|
+
# not report them; `reset` is epoch SECONDS in, epoch MILLISECONDS on disk,
|
|
129
|
+
# matching what `plot-host.sh limit` prints and what `budget.ts` stores.
|
|
130
|
+
#
|
|
131
|
+
# NEVER FAILS ITS CALLER. Recording is bookkeeping beside a host call that has
|
|
132
|
+
# already happened, so a record that cannot be written must not turn a
|
|
133
|
+
# successful call into a failed one. Every failure path returns 0 after writing
|
|
134
|
+
# nothing; a caller that wants to know asks `budget_rate`.
|
|
135
|
+
budget_append() {
|
|
136
|
+
local connector="${1:-}" account="${2:-}" bucket="${3:-}" spent="${4:-1}"
|
|
137
|
+
local limit="${5:-}" remaining="${6:-}" reset="${7:-}" basis="${8:-unknown}"
|
|
138
|
+
local path line reset_ms
|
|
139
|
+
|
|
140
|
+
# A basis this record does not know degrades to `unknown`, the same direction
|
|
141
|
+
# `limitOf` degrades in: a word nobody has seen must never arrive as `actual`,
|
|
142
|
+
# which is the one basis a caller is entitled to trust.
|
|
143
|
+
case "$basis" in
|
|
144
|
+
actual|predicted|unknown) ;;
|
|
145
|
+
*) basis=unknown ;;
|
|
146
|
+
esac
|
|
147
|
+
|
|
148
|
+
# An `unknown` basis carries a null limit whatever was passed. The two would
|
|
149
|
+
# otherwise be able to disagree, and a number tagged *unknown* is the collapse
|
|
150
|
+
# slice 1 exists to refuse.
|
|
151
|
+
if [ "$basis" = unknown ]; then limit=''; fi
|
|
152
|
+
|
|
153
|
+
reset_ms='-'
|
|
154
|
+
if [ -n "$reset" ] && [ "$reset" != '-' ] && [[ "$reset" =~ ^[0-9]+$ ]]; then
|
|
155
|
+
reset_ms="${reset}000"
|
|
156
|
+
fi
|
|
157
|
+
|
|
158
|
+
path="$(budget_path)" || return 0
|
|
159
|
+
line="$(printf 'b1\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s' \
|
|
160
|
+
"$(budget_clean "$connector")" \
|
|
161
|
+
"$(budget_clean "$account")" \
|
|
162
|
+
"$(budget_clean "$bucket")" \
|
|
163
|
+
"$(budget_now_ms)" \
|
|
164
|
+
"$(budget_num "$spent")" \
|
|
165
|
+
"$(budget_num "$limit")" \
|
|
166
|
+
"$(budget_num "$remaining")" \
|
|
167
|
+
"$reset_ms" \
|
|
168
|
+
"$basis")"
|
|
169
|
+
|
|
170
|
+
# MEASURED IN BYTES, NOT CHARACTERS, and the newline counts. `LC_ALL=C wc -c`
|
|
171
|
+
# counts what the kernel writes; a UTF-8 account name costs more than its
|
|
172
|
+
# length, and the cap is the kernel's guarantee.
|
|
173
|
+
local bytes
|
|
174
|
+
bytes="$(printf '%s\n' "$line" | LC_ALL=C wc -c | tr -d ' ')"
|
|
175
|
+
if [ "${bytes:-0}" -gt "$BUDGET_MAX_LINE_BYTES" ]; then
|
|
176
|
+
# REFUSED, NOT TRUNCATED, and it says so. Shortening the line would write a
|
|
177
|
+
# spend against a key nobody can read back, and a silent refusal would make
|
|
178
|
+
# a systematically over-long key look like an idle account.
|
|
179
|
+
echo "plot-budget: refusing a ${bytes}-byte line over the ${BUDGET_MAX_LINE_BYTES}-byte cap — this call went unrecorded (connector=$connector bucket=$bucket)" >&2
|
|
180
|
+
return 0
|
|
181
|
+
fi
|
|
182
|
+
|
|
183
|
+
mkdir -p "$(dirname "$path")" 2>/dev/null || return 0
|
|
184
|
+
# ONE `printf`, ONE `>>`. The redirection opens with `O_APPEND` and the single
|
|
185
|
+
# write is what the atomicity guarantee is about; two writes could interleave
|
|
186
|
+
# however short each was.
|
|
187
|
+
printf '%s\n' "$line" >>"$path" 2>/dev/null || true
|
|
188
|
+
return 0
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# How many calls this budget spent inside its window, and how fast.
|
|
192
|
+
#
|
|
193
|
+
# budget_rate <connector> <account> <bucket> [now-ms]
|
|
194
|
+
#
|
|
195
|
+
# Prints one JSON object:
|
|
196
|
+
# {"spent":N,"spanMs":N,"perHour":N|null,"lines":N,"unreadable":N,
|
|
197
|
+
# "limit":N|null,"remaining":N|null,"resetAt":N|null,
|
|
198
|
+
# "basis":"actual|predicted|unknown"}
|
|
199
|
+
#
|
|
200
|
+
# OVER THE CONNECTOR'S WINDOW, NEVER THE WHOLE FILE, and that is the whole
|
|
201
|
+
# reason the window exists. Measured 2026-09-01, one board at 5 s and eleven
|
|
202
|
+
# scripts at 90 s append ~1,160 lines an hour: a rate divided by an
|
|
203
|
+
# ever-growing span approaches zero, which relaxes the cadence forever — the
|
|
204
|
+
# opposite of what the record is for.
|
|
205
|
+
#
|
|
206
|
+
# THE WINDOW IS READ FROM A RESET THAT HAS PASSED, never computed from one still
|
|
207
|
+
# in the future. A reset an hour out, minus an hour, lands on `now` and would
|
|
208
|
+
# discard every line ever written. Same rule as `windowStart` in
|
|
209
|
+
# `packages/domain/src/rules/budget-record.ts`, and a test pins them together.
|
|
210
|
+
BUDGET_FALLBACK_WINDOW_MS=3600000
|
|
211
|
+
|
|
212
|
+
# AN EMPTY BUCKET MEANS EVERY BUCKET, and that is the account-wide question
|
|
213
|
+
# rather than a missing argument. One connector meters several pools
|
|
214
|
+
# independently — GitHub's `core` and `graphql` — and a caller asking *how fast
|
|
215
|
+
# is this account going?* is asking about all of them: an account spends every
|
|
216
|
+
# bucket it has, and a cadence divided by one pool's rate would ignore the
|
|
217
|
+
# traffic on the other.
|
|
218
|
+
#
|
|
219
|
+
# THE VERDICT IS NOT SUMMED, and the aggregate deliberately does not report one.
|
|
220
|
+
# `remaining` and `basis` describe the NEWEST live line across the buckets,
|
|
221
|
+
# which is a reading about whichever pool was spent last — so a caller deciding
|
|
222
|
+
# whether a bucket is spent must name that bucket. `graphql_budget_spent` does,
|
|
223
|
+
# and this is why.
|
|
224
|
+
budget_rate() {
|
|
225
|
+
local connector="${1:-}" account="${2:-}" bucket="${3:-}" now="${4:-}"
|
|
226
|
+
local path
|
|
227
|
+
[ -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; }
|
|
229
|
+
|
|
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
|
|
237
|
+
|
|
238
|
+
LC_ALL=C awk -v want_c="$connector" -v want_a="$account" -v want_b="$bucket" \
|
|
239
|
+
-v now="$now" -v fallback="$BUDGET_FALLBACK_WINDOW_MS" '
|
|
240
|
+
BEGIN { FS = "\t"; unreadable = 0; n = 0; passed = -1 }
|
|
241
|
+
{
|
|
242
|
+
# A NULL IS THE NORMAL CASE, not an error. The file is appended to by
|
|
243
|
+
# processes that may be killed mid-write, so a torn tail, a blank line and
|
|
244
|
+
# a line from a newer format are all things a reader meets — and every one
|
|
245
|
+
# is skipped rather than thrown on. A reader that failed on one bad line
|
|
246
|
+
# would report the whole account as unreadable, which reads as headroom.
|
|
247
|
+
if ($0 == "") next
|
|
248
|
+
if (NF != 10 || $1 != "b1") { unreadable++; next }
|
|
249
|
+
if ($2 != want_c || $3 != want_a) next
|
|
250
|
+
if (want_b != "" && $4 != want_b) next
|
|
251
|
+
at = $5 + 0
|
|
252
|
+
if ($5 !~ /^-?[0-9]+$/) { unreadable++; next }
|
|
253
|
+
n++
|
|
254
|
+
c_at[n] = at; c_spent[n] = ($6 ~ /^-?[0-9]+$/) ? $6 + 0 : 0
|
|
255
|
+
c_limit[n] = $7; c_rem[n] = $8; c_reset[n] = $9; c_basis[n] = $10
|
|
256
|
+
# The latest reset that has ALREADY happened is where the live window
|
|
257
|
+
# starts: every line older than it describes a bucket that no longer
|
|
258
|
+
# exists. A reset still in the future says only that the window has not
|
|
259
|
+
# closed, and says nothing about when it opened.
|
|
260
|
+
if ($9 ~ /^[0-9]+$/) { r = $9 + 0; if (r <= now && r > passed) passed = r }
|
|
261
|
+
}
|
|
262
|
+
END {
|
|
263
|
+
from = now - fallback
|
|
264
|
+
if (passed >= 0 && passed > from) from = passed
|
|
265
|
+
spent = 0; oldest = -1; newest = -1
|
|
266
|
+
limit = "null"; remaining = "null"; reset = "null"; basis = "unknown"
|
|
267
|
+
for (i = 1; i <= n; i++) {
|
|
268
|
+
if (c_at[i] < from) continue
|
|
269
|
+
spent += c_spent[i]
|
|
270
|
+
if (oldest < 0 || c_at[i] < oldest) oldest = c_at[i]
|
|
271
|
+
# THE NEWEST LIVE LINE THAT CARRIES A READING, not simply the newest
|
|
272
|
+
# line. Most calls cannot report a limit — `gh pr list` is a GraphQL
|
|
273
|
+
# wrapper that exposes no headers — so they record a spend with an
|
|
274
|
+
# `unknown` basis, and one of those arriving after a measurement would
|
|
275
|
+
# erase it. Measured 2026-09-02 against the live host: `limit`
|
|
276
|
+
# harvested `graphql 4391/5000 actual`, one `pr-state` followed, and the
|
|
277
|
+
# bucket then read `remaining: null` — leaving the routing gate
|
|
278
|
+
# permanently unable to see a spent pool, which is the defect this slice
|
|
279
|
+
# exists to remove.
|
|
280
|
+
#
|
|
281
|
+
# A line the RESET has killed is a different case and is already gone:
|
|
282
|
+
# the window filter above dropped it, because it describes a bucket that
|
|
283
|
+
# no longer exists.
|
|
284
|
+
if (c_basis[i] != "unknown" && (newest < 0 || c_at[i] >= newest)) {
|
|
285
|
+
newest = c_at[i]
|
|
286
|
+
limit = (c_limit[i] ~ /^-?[0-9]+$/) ? c_limit[i] : "null"
|
|
287
|
+
remaining = (c_rem[i] ~ /^-?[0-9]+$/) ? c_rem[i] : "null"
|
|
288
|
+
# THE MOMENT THE BUCKET REFILLS, WHICH THE RECORD HAS ALWAYS STORED
|
|
289
|
+
# AND NEVER REPORTED. Field 9 was read for the window boundary above
|
|
290
|
+
# and dropped; a caller reacting to a refusal needs it, and reading it
|
|
291
|
+
# from a second `rate_limit` call would be both metered and wrong —
|
|
292
|
+
# measured 2026-09-01, that endpoint reported 5000 while the headers
|
|
293
|
+
# read 0.
|
|
294
|
+
#
|
|
295
|
+
# THE RESET STILL IN THE FUTURE IS THE ONE A CALLER WAITS FOR, and it
|
|
296
|
+
# is deliberately not the one `windowStart` uses: that boundary needs
|
|
297
|
+
# a reset that has PASSED, because a future one says only that the
|
|
298
|
+
# window has not closed. The two read the same field for opposite
|
|
299
|
+
# halves of the same fact.
|
|
300
|
+
reset = (c_reset[i] ~ /^[0-9]+$/) ? c_reset[i] : "null"
|
|
301
|
+
basis = c_basis[i]
|
|
302
|
+
if (basis != "actual" && basis != "predicted") basis = "unknown"
|
|
303
|
+
# `unknown` IS NOT HEADROOM. A stored number tagged unknown is not a
|
|
304
|
+
# reading, so it is reported as absent rather than as room.
|
|
305
|
+
if (basis == "unknown") { limit = "null"; remaining = "null"; reset = "null" }
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
span = (oldest < 0) ? 0 : now - oldest
|
|
309
|
+
if (span < 0) span = 0
|
|
310
|
+
if (span > 0) {
|
|
311
|
+
rate = sprintf("%.2f", spent * fallback / span)
|
|
312
|
+
} else {
|
|
313
|
+
# NO SPAN, NO RATE. One line, or several written in the same
|
|
314
|
+
# millisecond, divides by zero — and an invented rate would be the
|
|
315
|
+
# cadence input this slice exists to make honest.
|
|
316
|
+
rate = "null"
|
|
317
|
+
}
|
|
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
|
|
320
|
+
}
|
|
321
|
+
' "$path" 2>/dev/null || echo '{"spent":0,"spanMs":0,"perHour":null,"lines":0,"unreadable":0,"limit":null,"remaining":null,"resetAt":null,"basis":"unknown"}'
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
# ── The concurrency bound ────────────────────────────────────────────────────
|
|
325
|
+
#
|
|
326
|
+
# HOW MANY CALLS THIS ACCOUNT HAS OPEN AT ONCE, bounded across PROCESSES. The
|
|
327
|
+
# 2026-08-27 incident was eight WORKERS, each running this script once, so the
|
|
328
|
+
# count cannot live in a shell variable and cannot live in the board: a
|
|
329
|
+
# semaphore inside one process bounds nothing that incident measured.
|
|
330
|
+
#
|
|
331
|
+
# THE RECORD CANNOT HOLD IT. `budget.tsv` is append-only with a 512-byte line
|
|
332
|
+
# cap — the two properties that make it lock-free — and an in-flight count needs
|
|
333
|
+
# a DELETE on release. A process killed between claim and release would leave a
|
|
334
|
+
# line nothing removes, and the account would read as permanently full: the cap
|
|
335
|
+
# degrading into a deadlock, which is worse than the 403 it prevents. So the
|
|
336
|
+
# claims sit BESIDE the record, one file per slot, where releasing is an unlink.
|
|
337
|
+
#
|
|
338
|
+
# ONE DIRECTORY PER ACCOUNT, and it is the SAME one `slots-file.ts` uses —
|
|
339
|
+
# `$PLOT_BUDGET_HOME/slots/<account>/<index>`. The board and the workers compete
|
|
340
|
+
# for one cap or they do not compete at all.
|
|
341
|
+
#
|
|
342
|
+
# THE CLAIM IS PUBLISHED BY `ln`, NEVER BY `>`. A redirect creates the NAME
|
|
343
|
+
# before the CONTENT, so a second process can open the empty file, read no claim
|
|
344
|
+
# in it, and reclaim a slot the first is about to write into — measured in
|
|
345
|
+
# `slots-file.test.ts`, six processes taking five slots from a bound of three.
|
|
346
|
+
# `ln` publishes a file that is already complete and fails where the name is
|
|
347
|
+
# taken, so the name and the claim arrive together.
|
|
348
|
+
|
|
349
|
+
# Where one account's claims live. Same override as the record, deliberately:
|
|
350
|
+
# two halves of one budget must not resolve to two places.
|
|
351
|
+
budget_slots_dir() {
|
|
352
|
+
local home="${PLOT_BUDGET_HOME:-}" account="${1:-}"
|
|
353
|
+
if [ -z "$home" ]; then
|
|
354
|
+
[ -n "${HOME:-}" ] || return 1
|
|
355
|
+
home="$HOME/.plot/state"
|
|
356
|
+
fi
|
|
357
|
+
# The account is a string the record does not validate, so it can hold a
|
|
358
|
+
# slash or a dot segment and a path built from it unchecked is a traversal.
|
|
359
|
+
# Same substitution `slots-file.ts` makes, and for the same reason.
|
|
360
|
+
account="$(printf '%s' "$account" | LC_ALL=C tr -c 'A-Za-z0-9._-' '_' | LC_ALL=C sed 's/\.\{2,\}/_/g')"
|
|
361
|
+
[ -n "$account" ] || account='_'
|
|
362
|
+
printf '%s\n' "$home/slots/$account"
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
# How long a claim may stand before it is reclaimable on age alone. Ten minutes,
|
|
366
|
+
# the same figure `CLAIM_STALE_MS` states and for the same reason: liveness is
|
|
367
|
+
# the measurement and this only catches what liveness cannot decide.
|
|
368
|
+
BUDGET_CLAIM_STALE_MS=600000
|
|
369
|
+
|
|
370
|
+
# Is this claim still held? Prints nothing; the exit code is the answer.
|
|
371
|
+
# 0 = held by a live process 1 = reclaimable
|
|
372
|
+
#
|
|
373
|
+
# A PID THE TABLE CANNOT BE ASKED ABOUT KEEPS ITS SLOT. Nothing silently reads
|
|
374
|
+
# unreachable as permission — reclaiming on a failed reading would raise the
|
|
375
|
+
# number of simultaneous callers on the strength of not knowing.
|
|
376
|
+
budget_slot_held() {
|
|
377
|
+
local file="${1:-}" now="${2:-}" pid at
|
|
378
|
+
[ -f "$file" ] || return 1
|
|
379
|
+
pid="$(LC_ALL=C awk -F'\t' 'NR==1 {print $1}' "$file" 2>/dev/null)"
|
|
380
|
+
at="$(LC_ALL=C awk -F'\t' 'NR==1 {print $3}' "$file" 2>/dev/null)"
|
|
381
|
+
# A torn or empty claim names no process holding the slot.
|
|
382
|
+
case "$pid" in ''|*[!0-9]*) return 1 ;; esac
|
|
383
|
+
kill -0 "$pid" 2>/dev/null || return 1
|
|
384
|
+
case "$at" in ''|*[!0-9]*) return 0 ;; esac
|
|
385
|
+
[ -n "$now" ] || now="$(budget_now_ms)"
|
|
386
|
+
[ "$(( now - at ))" -lt "$BUDGET_CLAIM_STALE_MS" ]
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
# Takes one slot, or reports the account busy.
|
|
390
|
+
# budget_slot_acquire <account> <bound>
|
|
391
|
+
# Prints the slot index and exits 0 where one was taken; exits 1 where every
|
|
392
|
+
# slot is held by a live process; exits 2 where the claims could not be managed.
|
|
393
|
+
#
|
|
394
|
+
# BUSY AND UNREADABLE ARE DIFFERENT EXITS, and a caller must not read either as
|
|
395
|
+
# the other — the rule the whole plan states for `unknown`.
|
|
396
|
+
budget_slot_acquire() {
|
|
397
|
+
local account="${1:-}" bound="${2:-}" dir scratch now index path rc=1
|
|
398
|
+
case "$bound" in ''|*[!0-9]*) return 2 ;; esac
|
|
399
|
+
[ "$bound" -ge 1 ] || bound=1
|
|
400
|
+
dir="$(budget_slots_dir "$account")" || return 2
|
|
401
|
+
mkdir -p "$dir" 2>/dev/null || return 2
|
|
402
|
+
now="$(budget_now_ms)"
|
|
403
|
+
scratch="$dir/.$$.$now.tmp"
|
|
404
|
+
# WRITTEN WHOLE, THEN LINKED. See the note above `budget_slots_dir`.
|
|
405
|
+
printf '%s\t-\t%s\n' "$$" "$now" >"$scratch" 2>/dev/null || return 2
|
|
406
|
+
index=0
|
|
407
|
+
while [ "$index" -lt "$bound" ]; do
|
|
408
|
+
path="$dir/$index"
|
|
409
|
+
if ln "$scratch" "$path" 2>/dev/null; then
|
|
410
|
+
printf '%s\n' "$index"; rc=0; break
|
|
411
|
+
fi
|
|
412
|
+
if ! budget_slot_held "$path" "$now"; then
|
|
413
|
+
rm -f "$path" 2>/dev/null
|
|
414
|
+
if ln "$scratch" "$path" 2>/dev/null; then
|
|
415
|
+
printf '%s\n' "$index"; rc=0; break
|
|
416
|
+
fi
|
|
417
|
+
fi
|
|
418
|
+
index=$(( index + 1 ))
|
|
419
|
+
done
|
|
420
|
+
# THE SCRATCH FILE ALWAYS GOES, taken or not. A successful `ln` leaves two
|
|
421
|
+
# names for one inode and the slot keeps the one that matters.
|
|
422
|
+
rm -f "$scratch" 2>/dev/null
|
|
423
|
+
return "$rc"
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
# Gives a slot back. Removes only THIS process's own claim: a slot reclaimed as
|
|
427
|
+
# stale while its owner still ran belongs to somebody else now, and unlinking it
|
|
428
|
+
# on the way out would let the cap be exceeded by one.
|
|
429
|
+
budget_slot_release() {
|
|
430
|
+
local account="${1:-}" index="${2:-}" dir path pid
|
|
431
|
+
case "$index" in ''|*[!0-9]*) return 0 ;; esac
|
|
432
|
+
dir="$(budget_slots_dir "$account")" || return 0
|
|
433
|
+
path="$dir/$index"
|
|
434
|
+
[ -f "$path" ] || return 0
|
|
435
|
+
pid="$(LC_ALL=C awk -F'\t' 'NR==1 {print $1}' "$path" 2>/dev/null)"
|
|
436
|
+
[ "$pid" = "$$" ] || return 0
|
|
437
|
+
rm -f "$path" 2>/dev/null
|
|
438
|
+
return 0
|
|
439
|
+
}
|
package/plot-config.sh
CHANGED
|
@@ -80,6 +80,15 @@
|
|
|
80
80
|
# here can invoke a skill. Absent (or `none`) = the button
|
|
81
81
|
# refuses and names this key as the fix, rather than
|
|
82
82
|
# accepting the click and doing nothing.
|
|
83
|
+
# Brief command how /plot-dispatch runs an agent headless to WRITE a
|
|
84
|
+
# missing hand-off brief. The prompt is appended as one
|
|
85
|
+
# argument and asks for `/plot-implement <slug>`, whose
|
|
86
|
+
# step 4 owns brief authorship — this key adds no second
|
|
87
|
+
# brief writer. Absent (or `none`) = the capability is
|
|
88
|
+
# unavailable, never an error: the brief gate refuses as
|
|
89
|
+
# it does today and names `no-brief-command` as the
|
|
90
|
+
# reason, so a project that has never set the key behaves
|
|
91
|
+
# exactly as before.
|
|
83
92
|
# Plot 2 posture keys (repo-declared ceremony bounds; all optional):
|
|
84
93
|
# Plan PRs required | never | optional (never = hard gate)
|
|
85
94
|
# Implementation home this repo | <repo/path list> | none
|