@feigi/fleet-ctl 3.83.2 → 3.85.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/agents/fleet-merge-bot.agent.md +3 -1
- package/commands/run-merge-bot.md +3 -0
- package/package.json +3 -2
- package/scripts/controller-alarm.mjs +138 -0
- package/scripts/fleet-state.mjs +10 -5
- package/scripts/ledger-grammar.mjs +25 -15
- package/scripts/ledger.mjs +94 -24
- package/scripts/report-files.mjs +5 -4
- package/scripts/report-schema.mjs +36 -4
- package/skills/run-team/references/ledger.md +3 -1
|
@@ -15,7 +15,9 @@ Your scratch directory is `<scratch>/`; you write only under
|
|
|
15
15
|
`<scratch>/pr<N>/merge-bot-<n>/`.
|
|
16
16
|
|
|
17
17
|
Your report is the one file you write outside it: `<scratch>/reports/merge-bot-<n>.json`,
|
|
18
|
-
the path your brief names,
|
|
18
|
+
the path your brief names, giving every PR queued when you were dispatched
|
|
19
|
+
exactly one verdict — `merged`, `held`, `conflictHold` or `refused` — as
|
|
20
|
+
run-merge-bot.md's **One verdict per queued PR** says. Your final
|
|
19
21
|
message is the one line your brief gives, `merge-bot-<n>: done <path>`.
|
|
20
22
|
|
|
21
23
|
The shell traps below are copied from the controller's skill, `run-team`. In
|
|
@@ -283,6 +283,7 @@ Measured over one three-merge pass: the next queue member went 0 → 2 → 7 →
|
|
|
283
283
|
**A PR whose heavy jobs have only ever `skipped` is getting its first real verification from your rebase.** Reviewers may legitimately have labelled on the checks that did run plus local evidence, saying so explicitly. When your post-rebase run finally executes those suites, treat a red there as a **genuine first result**, not a regression you caused — read the failing job before concluding, and do not hand it back as "the rebase broke it".
|
|
284
284
|
|
|
285
285
|
Report merged / skipped-unlabeled / held-behind-#X / worktree-diverged-#X / head-moved-after-label-#X / rebase-carry-#X (merged or not, with the labelled and accepted SHAs) / main-gain-removed:<path>-#X / label-drop-failed-#X / label-read-failed-#X / branch-delete-failed-#X / branch-kept-#X (X the open PR that keeps the merged branch — a keep, not a failure) / rebase-fallback-#X / instrument-set-changed-#X / ci-wait-timeout-#X (with the run id and its `status`) / `<reason>-#X` for any other gate refusal / blocked after the pass — a dispatched bot once, at exit, after its grace (**Grace, then one report**, below). Every acknowledged main-gain removal the gate passed is named in the report, with its reason.
|
|
286
|
+
In the report file, every PR queued when you were dispatched is listed under exactly one of `merged`, `held`, `conflictHold` or `refused` — those four lists are its verdict, the tokens above are not — and a settle check refuses a report that leaves one out (**One verdict per queued PR**, under **Grace, then one report**).
|
|
286
287
|
|
|
287
288
|
## No-undo audit (before every rebase)
|
|
288
289
|
|
|
@@ -324,6 +325,8 @@ The wait:
|
|
|
324
325
|
|
|
325
326
|
**One report, at exit, and none before it** — every PR this pass touched, in the vocabulary above, a pass that merged nothing included. The controller reaps on that report and records each `held-behind-#<lower>` on the held ticket's row, so a report sent mid-pass is one it acts on too early.
|
|
326
327
|
|
|
328
|
+
**One verdict per queued PR, in the report file.** A dispatched bot's report file (`<scratch>/reports/merge-bot-<n>.json`) gives every PR that carried `ready-to-merge` when you were dispatched exactly one verdict — the last it reached in the pass: `merged`, `held` (a `held-behind-#<lower>`), `conflictHold` (a `conflict-held-#<pr>`, or a `rebase-fallback-#<pr>` you recorded as a conflict hold), or `refused` with the token above as its `reason` (`main-gain-removed:<path>`, `head-moved-after-label`, `ci-wait-timeout`, any other gate refusal). A PR you never reached is still `refused`, with the reason you stopped short of it; a PR refused `behind:<n>` and merged later in the same pass is only `merged`. `branchKept` is not a verdict. Two checks enforce this, not the rule alone: `report-schema.mjs validate merge-bot` answers `invalid` for a PR named twice, in one list or in two, and for a `refused` entry whose `reason` is empty or only whitespace; and `ledger.mjs settle merge-bot-<n> --from-report`, which `fleet-event.mjs merge-bot-report` runs, judges the report against the queue `ledger.mjs dispatch merge-bot` recorded on your `## Dispatched` entry — never a list your report supplies — and refuses it, naming each PR, when a PR from that queue that is still open and carries `ready-to-merge` has no verdict. A PR labelled after your dispatch, or one that left the queue before the settle, never fails it. Each `refused` entry lands in the ledger's `## Ruled` as `merge-bot-<n> refused #<pr>: <reason>`, the reason trimmed, a record only: it moves no label and holds nothing.
|
|
329
|
+
|
|
327
330
|
Your `task` result is the report, delivered when you exit.
|
|
328
331
|
|
|
329
332
|
## Then stay armed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feigi/fleet-ctl",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.85.0",
|
|
4
4
|
"description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
],
|
|
20
20
|
"omp": {
|
|
21
21
|
"extensions": [
|
|
22
|
-
"scripts/member-write-guard.mjs"
|
|
22
|
+
"scripts/member-write-guard.mjs",
|
|
23
|
+
"scripts/controller-alarm.mjs"
|
|
23
24
|
]
|
|
24
25
|
}
|
|
25
26
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The run-team controller's alarm. An omp extension: `plugin/package.json#omp.extensions`
|
|
2
|
+
// names this file beside `member-write-guard.mjs`, and a native install loads it
|
|
3
|
+
// into every omp session on the box — the controller's, every member's, and
|
|
4
|
+
// every unrelated session's — so it is inert unless its gate passes and it
|
|
5
|
+
// never throws.
|
|
6
|
+
//
|
|
7
|
+
// WHAT IT DOES. Nothing outside the omp process can start a turn in an idle
|
|
8
|
+
// session, and a controller that has ended its turn is idle. From inside the
|
|
9
|
+
// process `ctx.setInterval` plus `pi.sendUserMessage` can: every 30 s this
|
|
10
|
+
// checks whether the controller's beat has fallen due and, when it has, sends
|
|
11
|
+
// the one line that tells the controller to run the level-check
|
|
12
|
+
// (`fleet-event.mjs heartbeat`). The line is an ordinary user turn, told apart
|
|
13
|
+
// from a typed message by its `[fleet-alarm]` marker.
|
|
14
|
+
//
|
|
15
|
+
// THE GATE, `alarmDue()` — pure, no I/O, no clock read. All of these hold:
|
|
16
|
+
// - the session is idle;
|
|
17
|
+
// - `ctx.agent.kind` is `main`: a subagent's session never alarms;
|
|
18
|
+
// - `.fleet/heartbeat.json`'s `controller.pid` is this process's pid, so two
|
|
19
|
+
// omp sessions in one checkout alarm only the one the ledger recorded, and
|
|
20
|
+
// a session in an unrelated repository alarms nothing;
|
|
21
|
+
// - a `beat` is recorded and its `stopped` is empty: a stopped beat is a run
|
|
22
|
+
// that ended on purpose, and `""` — what every ordinary beat carries — is
|
|
23
|
+
// as empty as `null`;
|
|
24
|
+
// - `now >= beat.at + beat.interval * 1000`;
|
|
25
|
+
// - this beat has not already been alarmed within the last interval. A
|
|
26
|
+
// controller that never re-marks is alarmed again once per interval, not
|
|
27
|
+
// once per poll.
|
|
28
|
+
// A poll that cannot read or judge the file is "not due", and writes nothing
|
|
29
|
+
// to stderr on any poll: `readState` is handed a sink that drops its warnings,
|
|
30
|
+
// because the default would repeat the same line in the TUI every 30 s.
|
|
31
|
+
//
|
|
32
|
+
// IT NEVER ASKS TO QUEUE. The message goes out only when the session is idle at
|
|
33
|
+
// the check, with no `deliverAs`. A queued follow-up is lost when the
|
|
34
|
+
// maintainer presses Esc, and a steer backgrounds the controller's running bash
|
|
35
|
+
// call. A busy controller is skipped until the next poll, and nothing is held
|
|
36
|
+
// for it. omp's `sendUserMessage` still awaits before it dispatches, and a turn
|
|
37
|
+
// that starts in that gap takes the line as a steer: idle at the check narrows
|
|
38
|
+
// that window and does not close it.
|
|
39
|
+
//
|
|
40
|
+
// IT ONLY READS `beat`. `fleet-heartbeat.mjs` is the one writer of the mark and
|
|
41
|
+
// the one place the back-off is computed; this file never marks, never stops
|
|
42
|
+
// and never writes `.fleet/`. It does not suppress itself while the maintainer
|
|
43
|
+
// is typing: a draft in the editor survives the turn it starts.
|
|
44
|
+
//
|
|
45
|
+
// THE PROOF IT IS ARMED. At `session_start`, for the main session only, and
|
|
46
|
+
// only AFTER the timer is set, it writes `~/.fleet/alarm/<pid>.json` holding
|
|
47
|
+
// `{ pid, lstart, fleet }`: this process's pid, its start time as `proc.mjs`
|
|
48
|
+
// reads it, and the `.fleet/` path it resolved. The file is for run-team's
|
|
49
|
+
// phase 0 to check, and nothing reads it yet: this file only writes it. A
|
|
50
|
+
// session that set no timer writes no file, so the file proves the timer is
|
|
51
|
+
// armed against that repository's `.fleet/` and not only that the extension
|
|
52
|
+
// loaded. A start time that cannot be read writes no file, the safe direction:
|
|
53
|
+
// a missing file can only read as a timer not armed. The `.fleet/` path is
|
|
54
|
+
// resolved once, from `ctx.cwd`; outside a git repository, or where git fails
|
|
55
|
+
// or times out, no timer is set and no file is written. A leftover file from a
|
|
56
|
+
// dead session is harmless — its start time never matches a live process.
|
|
57
|
+
//
|
|
58
|
+
// Zero deps: node builtins and sibling scripts only.
|
|
59
|
+
|
|
60
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
61
|
+
import { homedir } from "node:os";
|
|
62
|
+
import { join } from "node:path";
|
|
63
|
+
import { fleetFile, FleetDirUnresolvable } from "./fleet-dir.mjs";
|
|
64
|
+
import { readState, stateFileIn } from "./fleet-state.mjs";
|
|
65
|
+
import { processSource } from "./proc.mjs";
|
|
66
|
+
|
|
67
|
+
export const POLL_MS = 30_000;
|
|
68
|
+
export const ALARM_LINE = "[fleet-alarm] run ~/.fleet/bin/fleet-run fleet-event.mjs heartbeat";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether the alarm fires now. `beat` is `{ at, interval, stopped }` (`at` in
|
|
72
|
+
* ms, `interval` in seconds) or null; `controller` is `{ pid }` or null;
|
|
73
|
+
* `firedFor` is null or `{ at, firedAt }`, the beat the last fire answered and
|
|
74
|
+
* when.
|
|
75
|
+
*/
|
|
76
|
+
export function alarmDue({ kind, pid, controller, beat, idle, now, firedFor }) {
|
|
77
|
+
if (idle !== true) return false;
|
|
78
|
+
if (kind !== "main") return false;
|
|
79
|
+
if (pid == null || controller?.pid !== pid) return false;
|
|
80
|
+
if (!beat || beat.stopped) return false;
|
|
81
|
+
const intervalMs = beat.interval * 1000;
|
|
82
|
+
if (!(now >= beat.at + intervalMs)) return false;
|
|
83
|
+
if (firedFor && firedFor.at === beat.at && now < firedFor.firedAt + intervalMs) return false;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The extension factory omp calls once per session. */
|
|
88
|
+
export default function controllerAlarm(pi) {
|
|
89
|
+
let latched = false;
|
|
90
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
91
|
+
try {
|
|
92
|
+
if (latched || ctx?.agent?.kind !== "main") return;
|
|
93
|
+
latched = true;
|
|
94
|
+
arm(pi, ctx);
|
|
95
|
+
} catch {
|
|
96
|
+
// Loads into every session on the box: a fault here must cost that
|
|
97
|
+
// session nothing.
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function arm(pi, ctx) {
|
|
103
|
+
let fleetDir;
|
|
104
|
+
try {
|
|
105
|
+
fleetDir = fleetFile(null, { cwd: ctx.cwd });
|
|
106
|
+
} catch (e) {
|
|
107
|
+
if (e instanceof FleetDirUnresolvable) return;
|
|
108
|
+
throw e;
|
|
109
|
+
}
|
|
110
|
+
const beatFile = stateFileIn(fleetDir);
|
|
111
|
+
let firedFor = null;
|
|
112
|
+
|
|
113
|
+
const poll = () => {
|
|
114
|
+
try {
|
|
115
|
+
const { controller, beat } = readState(beatFile, "fleet-alarm", () => {});
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const due = alarmDue({ kind: ctx.agent.kind, pid: process.pid, controller, beat, idle: ctx.isIdle(), now, firedFor });
|
|
118
|
+
if (!due) return;
|
|
119
|
+
// A send that rejects later must not surface as an unhandled rejection in a session that is not ours.
|
|
120
|
+
Promise.resolve(pi.sendUserMessage(ALARM_LINE)).catch(() => {});
|
|
121
|
+
firedFor = { at: beat.at, firedAt: now };
|
|
122
|
+
} catch {
|
|
123
|
+
// Cannot read or judge: not due.
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
ctx.setInterval(poll, POLL_MS);
|
|
127
|
+
|
|
128
|
+
let lstart = null;
|
|
129
|
+
try {
|
|
130
|
+
lstart = processSource().startTime(process.pid);
|
|
131
|
+
} catch {
|
|
132
|
+
// An unreadable process table is an unreadable start time.
|
|
133
|
+
}
|
|
134
|
+
if (typeof lstart !== "string" || lstart === "") return;
|
|
135
|
+
const dir = join(homedir(), ".fleet", "alarm");
|
|
136
|
+
mkdirSync(dir, { recursive: true });
|
|
137
|
+
writeFileSync(join(dir, `${process.pid}.json`), `${JSON.stringify({ pid: process.pid, lstart, fleet: fleetDir })}\n`);
|
|
138
|
+
}
|
package/scripts/fleet-state.mjs
CHANGED
|
@@ -123,7 +123,12 @@ const PRIORS = ["dead", "ancestor", "none"];
|
|
|
123
123
|
// below accepts: it reads as no record, and the next write would drop the
|
|
124
124
|
// key. All of them come back flagged `degraded`, for the one caller that
|
|
125
125
|
// must not read them as a file with nothing in it.
|
|
126
|
-
|
|
126
|
+
//
|
|
127
|
+
// `warn` is where those announcements go, `console.error` by default so every
|
|
128
|
+
// caller that passes nothing is unchanged. A caller that polls on a timer and
|
|
129
|
+
// must not repeat a degraded read on a screen every few seconds passes its own
|
|
130
|
+
// sink instead of copying the validation below.
|
|
131
|
+
export function readState(path, name, warn = console.error) {
|
|
127
132
|
// `beat: null` is the absent MARK, and it is not the same value as a mark
|
|
128
133
|
// whose fields are zero: nothing has been seen beating, so there is nothing
|
|
129
134
|
// to judge and nothing to report. A zeroed mark would date the beat to the
|
|
@@ -149,7 +154,7 @@ export function readState(path, name) {
|
|
|
149
154
|
// degraded read reported as a fresh run, which is the failure class this
|
|
150
155
|
// whole ticket exists to close.
|
|
151
156
|
if (e.code !== "ENOENT") {
|
|
152
|
-
|
|
157
|
+
warn(`${name}: WARNING could not read ${path} (${e.message}) — restarting at the base interval`);
|
|
153
158
|
return makeDegraded("exists but cannot be read");
|
|
154
159
|
}
|
|
155
160
|
return fresh;
|
|
@@ -158,11 +163,11 @@ export function readState(path, name) {
|
|
|
158
163
|
try {
|
|
159
164
|
parsed = JSON.parse(raw);
|
|
160
165
|
} catch (e) {
|
|
161
|
-
|
|
166
|
+
warn(`${name}: WARNING ${path} is not JSON (${e.message}) — restarting at the base interval`);
|
|
162
167
|
return makeDegraded("is not JSON");
|
|
163
168
|
}
|
|
164
169
|
if (!isRecord(parsed)) {
|
|
165
|
-
|
|
170
|
+
warn(`${name}: WARNING ${path} is not an object — restarting at the base interval`);
|
|
166
171
|
return makeDegraded("is not a JSON object");
|
|
167
172
|
}
|
|
168
173
|
// Per-field validation, not all-or-nothing: a file carrying a good `quiet`
|
|
@@ -254,7 +259,7 @@ export function readState(path, name) {
|
|
|
254
259
|
// causes, and flagged.
|
|
255
260
|
const record = ctl(controller);
|
|
256
261
|
if ("controller" in parsed && record === null) {
|
|
257
|
-
|
|
262
|
+
warn(`${name}: WARNING ${path} holds a controller record that cannot be judged — restarting at the base interval`);
|
|
258
263
|
return makeDegraded("holds a controller record that cannot be judged");
|
|
259
264
|
}
|
|
260
265
|
return {
|
|
@@ -117,27 +117,37 @@ export function memberRole(family) {
|
|
|
117
117
|
|
|
118
118
|
// A `## Dispatched` entry is a member token, followed by `brief=<hash>` when
|
|
119
119
|
// `ledger.mjs dispatch` recorded the brief the member was dispatched with:
|
|
120
|
-
// the first 8 hex of the sha256 the brief's sidecar carries.
|
|
121
|
-
//
|
|
122
|
-
|
|
120
|
+
// the first 8 hex of the sha256 the brief's sidecar carries. A merge bot's
|
|
121
|
+
// entry then carries `queued=<pr>,<pr>…` — `queued=none` for an empty queue —
|
|
122
|
+
// the open `ready-to-merge` PRs `dispatch` read when it dispatched the bot,
|
|
123
|
+
// which its report is judged against at `settle --from-report`. `settle`
|
|
124
|
+
// rewrites the token and keeps the words beside it.
|
|
125
|
+
const DISPATCHED_ENTRY = /^([^ ]*) brief=([0-9a-f]{8})(?: queued=(none|[1-9][0-9]*(?:,[1-9][0-9]*)*))?$/;
|
|
123
126
|
|
|
124
127
|
/**
|
|
125
|
-
* `{token, brief}` for one `## Dispatched` entry: the member token
|
|
126
|
-
* brief's hash
|
|
127
|
-
*
|
|
128
|
-
* `
|
|
129
|
-
*
|
|
128
|
+
* `{token, brief, queued}` for one `## Dispatched` entry: the member token,
|
|
129
|
+
* the brief's hash or null, and the PR numbers of its `queued=` word or null
|
|
130
|
+
* for an entry without one. An entry that is not a member token plus a
|
|
131
|
+
* well-formed `brief=` word, optionally followed by a well-formed `queued=`
|
|
132
|
+
* word, is returned whole as `token`, so a reader parsing it with
|
|
133
|
+
* parseToken() refuses it exactly as it refuses any other entry this grammar
|
|
134
|
+
* cannot parse.
|
|
130
135
|
*/
|
|
131
136
|
export function parseDispatchedEntry(entry) {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
return { token:
|
|
137
|
+
const m = DISPATCHED_ENTRY.exec(entry);
|
|
138
|
+
if (m === null || parseToken(m[1]) === null) return { token: entry, brief: null, queued: null };
|
|
139
|
+
const queued = m[3] === undefined ? null : m[3] === "none" ? [] : m[3].split(",").map(Number);
|
|
140
|
+
return { token: m[1], brief: m[2], queued };
|
|
136
141
|
}
|
|
137
142
|
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
143
|
+
/**
|
|
144
|
+
* The `## Dispatched` entry for `token`, with `brief=<brief>` unless brief is
|
|
145
|
+
* null and, after it, `queued=…` unless queued is null.
|
|
146
|
+
*/
|
|
147
|
+
export function dispatchedEntry(token, brief, queued = null) {
|
|
148
|
+
if (brief === null) return token;
|
|
149
|
+
const entry = `${token} brief=${brief}`;
|
|
150
|
+
return queued === null ? entry : `${entry} queued=${queued.join(",") || "none"}`;
|
|
141
151
|
}
|
|
142
152
|
|
|
143
153
|
// `PR#<n>` as a word of a row's text, `PR #<n>` too.
|
package/scripts/ledger.mjs
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
// every liveness count from this file alone, rather than from row text a
|
|
14
14
|
// controller typed by hand: `dispatch` writes a member's live token onto its
|
|
15
15
|
// row and appends it to `## Dispatched` with `brief=<hash>`, the brief it
|
|
16
|
-
// refuses to dispatch without
|
|
16
|
+
// refuses to dispatch without — and, for a merge bot, `queued=<pr>,…`, the
|
|
17
|
+
// open `ready-to-merge` PRs gh lists at dispatch, which its report must give
|
|
18
|
+
// a verdict each; when that brief's sidecar names an absolute
|
|
17
19
|
// scratch, it also clears the member's report file there, so a prior run's
|
|
18
20
|
// report is not read as this run's; `settle` rewrites that token to
|
|
19
21
|
// `<member>=<outcome>` in both places (an implementer's settle to `PR#M` also
|
|
@@ -59,6 +61,7 @@ import { stateFileIn, readState, writeState, assessBeat, stallsAt } from "./flee
|
|
|
59
61
|
import { processSource, ancestry, controllerOf, judgeController, ProcUnreadable } from "./proc.mjs";
|
|
60
62
|
import { attemptLock, releaseLockFile, reapPathOf } from "./pid-lock.mjs";
|
|
61
63
|
import { readCheckRecordIn, readReportIn, reportsDir, reportFamily, defaultScratch, reportPath } from "./report-files.mjs";
|
|
64
|
+
import { mergeBotVerdicts } from "./report-schema.mjs";
|
|
62
65
|
|
|
63
66
|
const NAME = "ledger";
|
|
64
67
|
// The plugin's own agent definitions — the directory tier-check.mjs reads
|
|
@@ -494,7 +497,7 @@ function sectionOf(text, name) {
|
|
|
494
497
|
}
|
|
495
498
|
|
|
496
499
|
function load() {
|
|
497
|
-
if (!existsSync(file)) return { rows: [], filed: [], ruled: [], dispatched: [], briefs: {}, drain: null, retired: [] };
|
|
500
|
+
if (!existsSync(file)) return { rows: [], filed: [], ruled: [], dispatched: [], briefs: {}, queued: {}, drain: null, retired: [] };
|
|
498
501
|
let text;
|
|
499
502
|
try {
|
|
500
503
|
text = readFileSync(file, "utf8");
|
|
@@ -526,15 +529,14 @@ function load() {
|
|
|
526
529
|
}
|
|
527
530
|
|
|
528
531
|
// `## Dispatched` held as its tokens, which is what every reader of `read`
|
|
529
|
-
// parses,
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
+
// parses, `briefs`, each member's `brief=` hash by name, and `queued`, each
|
|
533
|
+
// merge bot's `queued=` PR numbers by name: one token per member name for
|
|
534
|
+
// the session, so a settle that rewrites the token keeps the name its brief
|
|
535
|
+
// and its queue are filed under. save() joins them back.
|
|
532
536
|
function dispatchedOf(entries) {
|
|
533
537
|
const parsed = entries.map(parseDispatchedEntry);
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
briefs: Object.fromEntries(parsed.filter((p) => p.brief !== null).map((p) => [parseToken(p.token).name, p.brief])),
|
|
537
|
-
};
|
|
538
|
+
const byName = (field) => Object.fromEntries(parsed.filter((p) => p[field] !== null).map((p) => [parseToken(p.token).name, p[field]]));
|
|
539
|
+
return { dispatched: parsed.map((p) => p.token), briefs: byName("brief"), queued: byName("queued") };
|
|
538
540
|
}
|
|
539
541
|
|
|
540
542
|
// `## Drain` is written only once there is a marker to hold: an empty section
|
|
@@ -547,7 +549,10 @@ function save(d) {
|
|
|
547
549
|
const list = (entries) => entries.map((r) => `- ${escapeText(r)}`).join("\n");
|
|
548
550
|
const out =
|
|
549
551
|
`# Fleet run ledger\n\n${ROWS}\n\n` + list(d.rows) +
|
|
550
|
-
`\n\n${DISPATCHED}\n\n` + list(d.dispatched.map((t) =>
|
|
552
|
+
`\n\n${DISPATCHED}\n\n` + list(d.dispatched.map((t) => {
|
|
553
|
+
const name = parseToken(t)?.name;
|
|
554
|
+
return dispatchedEntry(t, d.briefs[name] ?? null, d.queued[name] ?? null);
|
|
555
|
+
})) +
|
|
551
556
|
`\n\n${FILED}\n\n` + list(d.filed) +
|
|
552
557
|
`\n\n${RULED}\n\n` + list(d.ruled) +
|
|
553
558
|
(d.drain === null ? "" : `\n\n${DRAIN}\n\n` + list([d.drain])) +
|
|
@@ -694,6 +699,9 @@ const WRITE_COMMANDS = new Set(["row", "token", "filed", "ruled", "dispatch", "s
|
|
|
694
699
|
// `settle --from-report` takes the lock itself, once its checks have run —
|
|
695
700
|
// see runSettleFromReport().
|
|
696
701
|
const FROM_REPORT = cmd === "settle" && rest.includes("--from-report");
|
|
702
|
+
// A merge bot's dispatch reads the queue it records before the lock, for the
|
|
703
|
+
// same reason: a gh call held under the lock stalls every other writer.
|
|
704
|
+
const QUEUED = cmd === "dispatch" && rest.length === 1 && /^merge-bot(-[0-9]+)?$/.test(rest[0]) ? queueSnapshot() : null;
|
|
697
705
|
if (WRITE_COMMANDS.has(cmd) && !FROM_REPORT) await acquireLock();
|
|
698
706
|
// `rotate` never load()s: a ledger load() would refuse is still the previous
|
|
699
707
|
// run's record, and it is archived as-is — and read line by line when it
|
|
@@ -1630,7 +1638,7 @@ function runRotate() {
|
|
|
1630
1638
|
}
|
|
1631
1639
|
// Read before anything moves: a ledger whose live tokens cannot be read is
|
|
1632
1640
|
// refused with nothing moved, never archived with those tokens dropped.
|
|
1633
|
-
let carry = { rows: [], dispatched: [], briefs: {}, retired: [], skipped: [] };
|
|
1641
|
+
let carry = { rows: [], dispatched: [], briefs: {}, queued: {}, retired: [], skipped: [] };
|
|
1634
1642
|
if (prior === "ancestor" && exists) {
|
|
1635
1643
|
let text;
|
|
1636
1644
|
try {
|
|
@@ -1691,7 +1699,7 @@ function runRotate() {
|
|
|
1691
1699
|
// Nothing to carry writes nothing, so the next write starts the skeleton
|
|
1692
1700
|
// exactly as after any other verdict.
|
|
1693
1701
|
if (carrying) {
|
|
1694
|
-
save({
|
|
1702
|
+
save({ ...carry, filed: [], ruled: [], drain: null });
|
|
1695
1703
|
}
|
|
1696
1704
|
if (archive !== null) console.error(` rotated ${file} -> ${archive}`);
|
|
1697
1705
|
for (const { line, why } of carry.skipped) {
|
|
@@ -1751,10 +1759,11 @@ function carryOf(text) {
|
|
|
1751
1759
|
return false;
|
|
1752
1760
|
}
|
|
1753
1761
|
});
|
|
1754
|
-
// An entry's `brief=`
|
|
1755
|
-
// the token it writes, so a carried member's record still names the
|
|
1756
|
-
// it was dispatched with and a
|
|
1757
|
-
|
|
1762
|
+
// An entry's `brief=` and `queued=` travel with it: save() writes them only
|
|
1763
|
+
// beside the token it writes, so a carried member's record still names the
|
|
1764
|
+
// brief it was dispatched with — and a carried merge bot the queue it is
|
|
1765
|
+
// judged against — and a dropped member's does not.
|
|
1766
|
+
const { dispatched: allDispatched, briefs: allBriefs, queued: allQueued } = dispatchedOf(sectionOf(text, DISPATCHED));
|
|
1758
1767
|
const dispatched = allDispatched.filter((e) => {
|
|
1759
1768
|
const t = parseToken(e);
|
|
1760
1769
|
if (t !== null && t.error === null) return true;
|
|
@@ -1777,7 +1786,7 @@ function carryOf(text) {
|
|
|
1777
1786
|
const live = liveOnLedger(rows, dispatched);
|
|
1778
1787
|
const carried = { rows: live.rows.map((i) => rows[i]), dispatched: live.dispatched };
|
|
1779
1788
|
const retired = retiredOnLedger([...rows, ...skippedRows], allDispatched, archivedRetired, carried);
|
|
1780
|
-
return { ...carried, briefs: allBriefs, retired, skipped };
|
|
1789
|
+
return { ...carried, briefs: allBriefs, queued: allQueued, retired, skipped };
|
|
1781
1790
|
}
|
|
1782
1791
|
|
|
1783
1792
|
// ── dispatch / settle ────────────────────────────────────────────────────────
|
|
@@ -1884,6 +1893,37 @@ function dispositionsRefusal(pr, review) {
|
|
|
1884
1893
|
return null;
|
|
1885
1894
|
}
|
|
1886
1895
|
|
|
1896
|
+
// The open `ready-to-merge` PRs, ascending, as gh answers from the ledger's
|
|
1897
|
+
// own directory with runCheck()'s environment: the queue a merge bot is
|
|
1898
|
+
// dispatched against, recorded on its `## Dispatched` entry. Anything but a
|
|
1899
|
+
// JSON array of PR numbers, or an answer filling the limit — gh stops there
|
|
1900
|
+
// at exit 0 — could not read the queue: refused, nothing written.
|
|
1901
|
+
function queueSnapshot() {
|
|
1902
|
+
const limit = 1000;
|
|
1903
|
+
const what = `gh pr list --state open --label ready-to-merge --limit ${limit} --json number`;
|
|
1904
|
+
const env = { ...process.env, GH_REPO: "" };
|
|
1905
|
+
delete env.GIT_DIR;
|
|
1906
|
+
delete env.GIT_WORK_TREE;
|
|
1907
|
+
let dir = dirname(resolve(file));
|
|
1908
|
+
while (!existsSync(dir) && dirname(dir) !== dir) dir = dirname(dir);
|
|
1909
|
+
const r = spawnSync("gh", what.split(" ").slice(1), { encoding: "utf8", cwd: dir, env, timeout: 60_000, stdio: ["ignore", "pipe", "pipe"] });
|
|
1910
|
+
const refuse = (why) => die(`dispatch merge-bot: could not read the queue to record — ${what}${why}; nothing written`);
|
|
1911
|
+
if (r.status !== 0) {
|
|
1912
|
+
const why = cause(r.error && r.error.message, r.stderr);
|
|
1913
|
+
refuse(`${r.error ? "" : r.signal ? ` killed by ${r.signal}` : ` exited ${r.status}`}${why ? `: ${why}` : ""}`);
|
|
1914
|
+
}
|
|
1915
|
+
let list = null;
|
|
1916
|
+
try {
|
|
1917
|
+
list = JSON.parse(r.stdout);
|
|
1918
|
+
} catch {
|
|
1919
|
+
// Refused below.
|
|
1920
|
+
}
|
|
1921
|
+
const prs = Array.isArray(list) ? list.map((p) => p?.number) : null;
|
|
1922
|
+
if (prs === null || !prs.every((n) => Number.isSafeInteger(n) && n > 0)) refuse(` printed no list of PR numbers: ${cause(r.stdout)}`);
|
|
1923
|
+
if (prs.length >= limit) refuse(` answered ${prs.length} PRs, the limit, so the queue may be longer`);
|
|
1924
|
+
return [...new Set(prs)].sort((a, b) => a - b);
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1887
1927
|
function runDispatch() {
|
|
1888
1928
|
const usage = "usage: ledger.mjs dispatch <ticket|pr> <member>, or dispatch merge-bot";
|
|
1889
1929
|
if (rest.length === 0 || rest.length > 2) die(usage);
|
|
@@ -2055,10 +2095,14 @@ function runDispatch() {
|
|
|
2055
2095
|
}
|
|
2056
2096
|
data.dispatched.push(member.name);
|
|
2057
2097
|
data.briefs[member.name] = brief.sha256.slice(0, 8);
|
|
2098
|
+
// A merge bot's queue is recorded here, beside its brief, where nothing in
|
|
2099
|
+
// its report can reach: `settle --from-report` judges the report against it.
|
|
2100
|
+
if (QUEUED !== null) data.queued[member.name] = QUEUED;
|
|
2058
2101
|
save(data);
|
|
2059
2102
|
console.error(` dispatched ${member.name}`);
|
|
2060
2103
|
console.log(JSON.stringify({
|
|
2061
2104
|
member: member.name, agent, ticket: line === null ? null : rowKey(line), line, created, total: data.dispatched.length, brief,
|
|
2105
|
+
queued: QUEUED,
|
|
2062
2106
|
}));
|
|
2063
2107
|
}
|
|
2064
2108
|
|
|
@@ -2292,7 +2336,11 @@ function recordFiled(id, subject) {
|
|
|
2292
2336
|
// the report's labelsReadBack
|
|
2293
2337
|
// finisher-pr-M=halted:<c> cause is in the halted:<cause> vocabulary and
|
|
2294
2338
|
// evidence is not empty
|
|
2295
|
-
// merge-bot-n=done every merged[].pr reads MERGED
|
|
2339
|
+
// merge-bot-n=done every merged[].pr reads MERGED; every PR the
|
|
2340
|
+
// bot's `## Dispatched` entry records as queued=
|
|
2341
|
+
// at dispatch has a verdict in merged, held,
|
|
2342
|
+
// conflictHold or refused, or no longer reads
|
|
2343
|
+
// OPEN with ready-to-merge
|
|
2296
2344
|
//
|
|
2297
2345
|
// THE CHECK-STEP RECORD, for `impl-N=PR#M` and `fix-pr-M=applied:<head>`:
|
|
2298
2346
|
// recipe-check.mjs writes `<reports>/<member>.check.json` on the member's
|
|
@@ -2312,15 +2360,21 @@ function recordFiled(id, subject) {
|
|
|
2312
2360
|
// stderr names each failed check; nothing written. Exit 2: the report is
|
|
2313
2361
|
// absent, unreadable or invalid, a check could not look (a `gh`, `git` or
|
|
2314
2362
|
// verify-sha.sh call that failed, printed what it was not asked for, or never
|
|
2315
|
-
// finished),
|
|
2363
|
+
// finished), a merge bot's `## Dispatched` entry records no queued=, or a
|
|
2364
|
+
// refusal the bare form makes; nothing written either.
|
|
2316
2365
|
//
|
|
2317
2366
|
// After the settle, in the same write: every `unrecorded` entry goes to `##
|
|
2318
|
-
// Filed` the way `filed` records one,
|
|
2367
|
+
// Filed` the way `filed` records one, for a fix-applier every `deferred`
|
|
2319
2368
|
// entry in the `refuted` bucket goes to `## Ruled` as `#M reversed refutation
|
|
2320
|
-
// <index>: <reason
|
|
2321
|
-
//
|
|
2322
|
-
//
|
|
2323
|
-
//
|
|
2369
|
+
// <index>: <reason>`, and for a merge bot every `refused` entry goes to `##
|
|
2370
|
+
// Ruled` as `merge-bot-n refused #<pr>: <reason>`, the reason trimmed — a
|
|
2371
|
+
// record, which moves no label and writes no row token. It is led by the bot,
|
|
2372
|
+
// never by `#<pr>`: compute-board.mjs reads a `## Ruled` line led by `#<n>` as
|
|
2373
|
+
// the controller's ruling on PR n, the last such line winning, so a refusal
|
|
2374
|
+
// led by its PR would replace that ruling on the board. A line already there
|
|
2375
|
+
// is not written again, so a second run of the same settle writes nothing.
|
|
2376
|
+
// The receipt on stdout is `{member, outcome, checks: [{name, ok}], filed,
|
|
2377
|
+
// ruled}`, `filed` and `ruled` the lines this call added.
|
|
2324
2378
|
//
|
|
2325
2379
|
// The checks run before the write lock is taken, against the ledger as read
|
|
2326
2380
|
// without it, because they are network calls and every other writer waits
|
|
@@ -2558,6 +2612,22 @@ async function runSettleFromReport() {
|
|
|
2558
2612
|
} else {
|
|
2559
2613
|
outcome = "done";
|
|
2560
2614
|
for (const { pr } of report.merged) check(`PR #${pr} reads MERGED`, prView(pr, "state").state === "MERGED");
|
|
2615
|
+
// Every PR queued when the bot was dispatched has a verdict, unless it has
|
|
2616
|
+
// left the queue since — merged by someone else, closed or unlabelled. The
|
|
2617
|
+
// queue is the one `dispatch` recorded, never one the report names, so a
|
|
2618
|
+
// PR the report leaves out cannot shrink it; a PR labelled after dispatch
|
|
2619
|
+
// is not in it.
|
|
2620
|
+
const queued = data.queued[member];
|
|
2621
|
+
if (queued === undefined) {
|
|
2622
|
+
die(`${where}: ## Dispatched records no queued= for ${member}, so whether its report gives every PR queued at dispatch a verdict cannot be judged — settle it by hand; nothing written`);
|
|
2623
|
+
}
|
|
2624
|
+
const given = new Set(mergeBotVerdicts(report).map((v) => v.pr));
|
|
2625
|
+
for (const pr of queued.filter((n) => !given.has(n))) {
|
|
2626
|
+
const v = prView(pr, "state,labels");
|
|
2627
|
+
const left = v.state !== "OPEN" || !v.labels.some((l) => l?.name === ready);
|
|
2628
|
+
check(`PR #${pr}, queued at dispatch, has a verdict in merged, held, conflictHold or refused, or has left the queue`, left);
|
|
2629
|
+
}
|
|
2630
|
+
for (const { pr, reason } of report.refused) ruledLines.push(`${member} refused #${pr}: ${reason.trim()}`);
|
|
2561
2631
|
}
|
|
2562
2632
|
|
|
2563
2633
|
const failed = checks.filter((c) => !c.ok);
|
package/scripts/report-files.mjs
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
// (flat: no per-run directory), `<member>` being its ledger name (`impl-412`,
|
|
7
7
|
// `impl-412-b`, `fix-pr-88`, `finisher-pr-88`, `merge-bot-3`). The file is
|
|
8
8
|
// checked against the schema of the member's family, report-schema.mjs's
|
|
9
|
-
// SCHEMAS
|
|
10
|
-
// have no schema, so no file to
|
|
9
|
+
// SCHEMAS, and the rule reportViolation() adds to it. The review runner and
|
|
10
|
+
// every member outside those four families have no schema, so no file to
|
|
11
|
+
// look up.
|
|
11
12
|
//
|
|
12
13
|
// The Check-step record beside a report, `<reports>/<member>.check.json`, is
|
|
13
14
|
// looked up here the same way: recipe-check.mjs writes it, and this module
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
|
|
19
20
|
import { readdirSync, readFileSync } from "node:fs";
|
|
20
21
|
import { join } from "node:path";
|
|
21
|
-
import {
|
|
22
|
+
import { parseReport, reportViolation } from "./report-schema.mjs";
|
|
22
23
|
import { parseMember } from "./ledger-grammar.mjs";
|
|
23
24
|
import { scratchRoot } from "./fleet-dir.mjs";
|
|
24
25
|
|
|
@@ -113,7 +114,7 @@ export function readReportIn(dir, member) {
|
|
|
113
114
|
const read = readAt(path);
|
|
114
115
|
if (read.text === undefined) return read;
|
|
115
116
|
const parsed = parseReport(read.text);
|
|
116
|
-
const violation = parsed.violation ??
|
|
117
|
+
const violation = parsed.violation ?? reportViolation(family, parsed.value);
|
|
117
118
|
return violation === null ? { state: "ok", path, report: parsed.value } : { state: "invalid", path, violation };
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
// 0 print: the schema is on stdout. validate: `valid` on stdout
|
|
19
19
|
// 1 validate: `invalid: <violation>` on stdout, naming the FIRST violation;
|
|
20
20
|
// a file that is not JSON is invalid too, as `$: not JSON (...)`, and so
|
|
21
|
-
// is one that repeats a key, as `$.merged: duplicate key "merged"
|
|
21
|
+
// is one that repeats a key, as `$.merged: duplicate key "merged"`, and
|
|
22
|
+
// a merge-bot report giving one PR two verdicts, as
|
|
23
|
+
// `$.refused[0].pr: PR #<pr> already has a verdict at $.merged[0].pr`
|
|
22
24
|
// 2 usage: no or unknown subcommand, a wrong argument count, an unknown
|
|
23
25
|
// family, any flag; or <file> could not be read; or print could not
|
|
24
26
|
// write the schema to stdout. Refused on stderr, nothing on stdout
|
|
@@ -153,11 +155,41 @@ export const SCHEMAS = {
|
|
|
153
155
|
merged: listOf({ pr: id, sha }),
|
|
154
156
|
held: listOf({ pr: id, behind: id }),
|
|
155
157
|
conflictHold: { type: "array", items: id },
|
|
156
|
-
|
|
158
|
+
// A refusal is a verdict only with a reason: the reason is the record
|
|
159
|
+
// `## Ruled` gets, so one with no text but whitespace is invalid.
|
|
160
|
+
refused: listOf({ pr: id, reason: { type: "string", pattern: "\\S" } }),
|
|
157
161
|
branchKept: listOf({ pr: id, by: id }),
|
|
158
162
|
}),
|
|
159
163
|
};
|
|
160
164
|
|
|
165
|
+
// Every verdict a merge-bot report gives, as `{pr, path}`, `path` the entry's
|
|
166
|
+
// PR field: `merged`, `held`, `conflictHold`, then `refused`, each in report
|
|
167
|
+
// order. `branchKept` gives none: it names a merged PR's branch and the open
|
|
168
|
+
// PR keeping it.
|
|
169
|
+
export function mergeBotVerdicts(report) {
|
|
170
|
+
return [
|
|
171
|
+
...["merged", "held"].flatMap((k) => report[k].map((e, i) => ({ pr: e.pr, path: `$.${k}[${i}].pr` }))),
|
|
172
|
+
...report.conflictHold.map((pr, i) => ({ pr, path: `$.conflictHold[${i}]` })),
|
|
173
|
+
...report.refused.map((e, i) => ({ pr: e.pr, path: `$.refused[${i}].pr` })),
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The first violation of `family`'s schema by `value`, or null; for a
|
|
178
|
+
// merge-bot report then the rule no schema keyword states: each PR gets one
|
|
179
|
+
// verdict — merged, held, conflict-held or refused — the last it reached in
|
|
180
|
+
// the pass, so a PR named twice, in one list or in two, is a violation at
|
|
181
|
+
// its second entry.
|
|
182
|
+
export function reportViolation(family, value) {
|
|
183
|
+
const found = validate(SCHEMAS[family], value);
|
|
184
|
+
if (found !== null || family !== "merge-bot") return found;
|
|
185
|
+
const first = new Map();
|
|
186
|
+
for (const { pr, path } of mergeBotVerdicts(value)) {
|
|
187
|
+
if (first.has(pr)) return `${path}: PR #${pr} already has a verdict at ${first.get(pr)}`;
|
|
188
|
+
first.set(pr, path);
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
161
193
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
162
194
|
|
|
163
195
|
function typeOf(v) {
|
|
@@ -279,7 +311,7 @@ function main() {
|
|
|
279
311
|
}
|
|
280
312
|
if (cmd !== "validate" || path === undefined) die(USAGE);
|
|
281
313
|
|
|
282
|
-
|
|
314
|
+
familyOf(family);
|
|
283
315
|
let text;
|
|
284
316
|
try {
|
|
285
317
|
text = readFileSync(path, "utf8");
|
|
@@ -287,7 +319,7 @@ function main() {
|
|
|
287
319
|
die(`cannot read ${path}: ${e.code ?? e.message}`);
|
|
288
320
|
}
|
|
289
321
|
const parsed = parseReport(text);
|
|
290
|
-
const violation = parsed.violation ??
|
|
322
|
+
const violation = parsed.violation ?? reportViolation(family, parsed.value);
|
|
291
323
|
if (violation !== null) {
|
|
292
324
|
writeAll(1, `invalid: ${violation}\n`);
|
|
293
325
|
process.exit(1);
|
|
@@ -76,11 +76,13 @@ One line per ticket, rewritten in place (`ledger.mjs row <ticket> <text>`; `ledg
|
|
|
76
76
|
|
|
77
77
|
A member that dies between spawn and write is invisible — and members die in batches.
|
|
78
78
|
|
|
79
|
+
`dispatch merge-bot` also records the queue the bot is dispatched against: its `## Dispatched` entry carries `queued=<pr>,<pr>…` (`queued=none` for an empty queue), the open `ready-to-merge` PRs gh lists at that moment. A gh that cannot answer refuses the dispatch, nothing written. `settle merge-bot-<n> --from-report` judges the bot's report against that queue, not against anything the report names: a PR from it that is still open and carries the label, with no verdict in `merged`, `held`, `conflictHold` or `refused`, fails the settle at exit 3, naming the PR, and an entry without `queued=` cannot be judged (exit 2) — settle that bot by hand.
|
|
80
|
+
|
|
79
81
|
## filed and ruled
|
|
80
82
|
|
|
81
83
|
`filed` is checked with `ledger.mjs check <subject>` before every `gh issue create`, so a finding already recorded this run is not filed twice. That is not a guarantee against duplicates at large; the tracker query, described under `check`'s exits and its `verdict` above, is what covers those.
|
|
82
84
|
|
|
83
|
-
`ruled` records PR + decision + one-line reason, so a replacement controller does not re-litigate a settled call.
|
|
85
|
+
`ruled` records PR + decision + one-line reason, so a replacement controller does not re-litigate a settled call. A merge bot's settle adds one line per `refused[]` entry of its report, `merge-bot-<n> refused #<pr>: <reason>`: a record of the refusal and nothing more — no label moves and no row token is written. It is led by the bot, not by `#<pr>`, because the board reads a `#<pr>`-led line as your ruling on that PR, the last one winning, and a refusal must not replace it.
|
|
84
86
|
|
|
85
87
|
## Label-off and conflict holds
|
|
86
88
|
|