@feigi/fleet-ctl 3.84.0 → 3.86.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 +1 -1
- package/scripts/controller-alarm.mjs +8 -6
- package/scripts/ledger-grammar.mjs +25 -15
- package/scripts/ledger.mjs +94 -24
- package/scripts/phase0-receipt.mjs +1 -1
- package/scripts/phase0.mjs +70 -24
- package/scripts/report-files.mjs +5 -4
- package/scripts/report-schema.mjs +36 -4
- package/skills/run-team/SKILL.md +2 -2
- package/skills/run-team/references/ledger.md +3 -1
- package/skills/run-team/references/phase-0.md +5 -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
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
// THE PROOF IT IS ARMED. At `session_start`, for the main session only, and
|
|
46
46
|
// only AFTER the timer is set, it writes `~/.fleet/alarm/<pid>.json` holding
|
|
47
47
|
// `{ pid, lstart, fleet }`: this process's pid, its start time as `proc.mjs`
|
|
48
|
-
// reads it, and the `.fleet/` path it resolved.
|
|
49
|
-
//
|
|
48
|
+
// reads it, and the `.fleet/` path it resolved. Phase 0's `alarm` step reads
|
|
49
|
+
// the file and refuses unless it names this omp and this `.fleet/`. A
|
|
50
50
|
// session that set no timer writes no file, so the file proves the timer is
|
|
51
51
|
// armed against that repository's `.fleet/` and not only that the extension
|
|
52
52
|
// loaded. A start time that cannot be read writes no file, the safe direction:
|
|
@@ -59,13 +59,15 @@
|
|
|
59
59
|
|
|
60
60
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
61
61
|
import { homedir } from "node:os";
|
|
62
|
-
import { join } from "node:path";
|
|
62
|
+
import { dirname, join } from "node:path";
|
|
63
63
|
import { fleetFile, FleetDirUnresolvable } from "./fleet-dir.mjs";
|
|
64
64
|
import { readState, stateFileIn } from "./fleet-state.mjs";
|
|
65
65
|
import { processSource } from "./proc.mjs";
|
|
66
66
|
|
|
67
67
|
export const POLL_MS = 30_000;
|
|
68
68
|
export const ALARM_LINE = "[fleet-alarm] run ~/.fleet/bin/fleet-run fleet-event.mjs heartbeat";
|
|
69
|
+
// Where the extension running as `pid` records that its timer is armed; phase 0 reads it there.
|
|
70
|
+
export const alarmFile = (pid) => join(homedir(), ".fleet", "alarm", `${pid}.json`);
|
|
69
71
|
|
|
70
72
|
/**
|
|
71
73
|
* Whether the alarm fires now. `beat` is `{ at, interval, stopped }` (`at` in
|
|
@@ -132,7 +134,7 @@ function arm(pi, ctx) {
|
|
|
132
134
|
// An unreadable process table is an unreadable start time.
|
|
133
135
|
}
|
|
134
136
|
if (typeof lstart !== "string" || lstart === "") return;
|
|
135
|
-
const
|
|
136
|
-
mkdirSync(
|
|
137
|
-
writeFileSync(
|
|
137
|
+
const file = alarmFile(process.pid);
|
|
138
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
139
|
+
writeFileSync(file, `${JSON.stringify({ pid: process.pid, lstart, fleet: fleetDir })}\n`);
|
|
138
140
|
}
|
|
@@ -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);
|
|
@@ -18,7 +18,7 @@ const isRecord = (x) => x !== null && typeof x === "object" && !Array.isArray(x)
|
|
|
18
18
|
// a step not yet run being `pending`. A receipt missing one was not written
|
|
19
19
|
// by a finished phase 0.
|
|
20
20
|
export const STEP_NAMES = Object.freeze([
|
|
21
|
-
"branch", "fast-forward", "pin", "baseline", "preflight", "ruleset", "tier-roles", "metrics",
|
|
21
|
+
"alarm", "branch", "fast-forward", "pin", "baseline", "preflight", "ruleset", "tier-roles", "metrics",
|
|
22
22
|
"cockpit", "rotate", "reclaim", "recipe", "fold-in", "stranded", "shortlist",
|
|
23
23
|
]);
|
|
24
24
|
|
package/scripts/phase0.mjs
CHANGED
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
// reclaim straight after the rotation, the metrics refresh before the first
|
|
21
21
|
// Pull is routed:
|
|
22
22
|
//
|
|
23
|
+
// alarm `~/.fleet/alarm/<pid>.json` names this omp's pid and start time
|
|
24
|
+
// and this `.fleet/`. Missing, malformed or mismatched, or no omp
|
|
25
|
+
// above the script, refuses: restart omp.
|
|
23
26
|
// branch `git symbolic-ref -q HEAD` is refs/heads/main. A checkout left
|
|
24
27
|
// on a branch whose PR has merged would fast-forward that branch
|
|
25
28
|
// and the baseline would record it as the run's ref.
|
|
@@ -82,29 +85,31 @@
|
|
|
82
85
|
// is the In-flight bound this run used — `--in-flight`, or its default.
|
|
83
86
|
//
|
|
84
87
|
// RESUMABLE. A re-run reads the receipt. It continues it — skipping every `ok`
|
|
85
|
-
// step and re-running the rest — only when its `run` equals the
|
|
86
|
-
// controller record holds now AND its `session` equals this run's
|
|
87
|
-
// comparison alone does not separate two runs before the new
|
|
88
|
-
// the record still carries the previous run's `at` until
|
|
89
|
-
// writes the next, so the previous run's receipt still
|
|
90
|
-
// re-run that trusted `run` alone would skip `rotate`. The
|
|
91
|
-
// a new session.
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// `phase0.json
|
|
96
|
-
//
|
|
97
|
-
//
|
|
88
|
+
// step but `alarm` and re-running the rest — only when its `run` equals the
|
|
89
|
+
// `at` the controller record holds now AND its `session` equals this run's
|
|
90
|
+
// session. That comparison alone does not separate two runs before the new
|
|
91
|
+
// run's rotation: the record still carries the previous run's `at` until
|
|
92
|
+
// `ledger.mjs rotate` writes the next, so the previous run's receipt still
|
|
93
|
+
// matches it, and a re-run that trusted `run` alone would skip `rotate`. The
|
|
94
|
+
// session rule closes a new session. `alarm` is re-run on every invocation,
|
|
95
|
+
// `ok` or not: it proves something about the omp running this invocation, and
|
|
96
|
+
// a resumed receipt may have been written under another. A receipt of any
|
|
97
|
+
// other run (a `run` that is set and does not match, or a different session)
|
|
98
|
+
// is held as `phase0.previous.json`, where `closeout.mjs finish` also leaves a
|
|
99
|
+
// finished run's receipt, and the script starts at `alarm`; this run's own
|
|
100
|
+
// receipt is written at `phase0.json` after every step. A receipt whose
|
|
101
|
+
// rotation has not run is simply overwritten: every step before `rotate` is
|
|
102
|
+
// idempotent and is re-run on every invocation until the rotation has run.
|
|
98
103
|
//
|
|
99
104
|
// THE ROTATION FILES THE PREVIOUS RUN'S RECEIPT. The `rotate` step takes this
|
|
100
105
|
// run's partial receipt off disk, puts the held one back at `phase0.json` and
|
|
101
106
|
// runs `ledger.mjs rotate`, which archives it as `phase0.<UTC stamp>.json`
|
|
102
107
|
// beside `ledger.<UTC stamp>.md`, one stamp for both. A rotation that refused
|
|
103
108
|
// leaves it held for the next attempt; one that archived the ledger and not
|
|
104
|
-
// the receipt files it under a stamp of this script's own.
|
|
105
|
-
//
|
|
106
|
-
// null; a receipt whose `rotate` step is `ok` is then resumed on the
|
|
107
|
-
// alone.
|
|
109
|
+
// the receipt files it under a stamp of this script's own. A rotation that
|
|
110
|
+
// writes no controller record (the ledger finds no omp above itself) leaves
|
|
111
|
+
// `run` null; a receipt whose `rotate` step is `ok` is then resumed on the
|
|
112
|
+
// session alone.
|
|
108
113
|
//
|
|
109
114
|
// A step that fails stops the script; so does a pending one — nothing after
|
|
110
115
|
// the recipe may run before it is proven, and the shortlist waits for the
|
|
@@ -133,16 +138,19 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync,
|
|
|
133
138
|
import { dirname, join, resolve } from "node:path";
|
|
134
139
|
import { fileURLToPath } from "node:url";
|
|
135
140
|
import { makeDie, defineFlags, isDigits } from "./arg.mjs";
|
|
141
|
+
import { alarmFile } from "./controller-alarm.mjs";
|
|
136
142
|
import { FleetDirUnresolvable, fleetFile } from "./fleet-dir.mjs";
|
|
137
143
|
import { readState, stateFileIn } from "./fleet-state.mjs";
|
|
138
144
|
import { gitEnv } from "./git-env.mjs";
|
|
139
145
|
import { isCLI } from "./is-cli.mjs";
|
|
140
146
|
import { RECEIPT, RECEIPT_HELD, archiveStamp, receiptArchiveName, rowNums } from "./ledger-grammar.mjs";
|
|
147
|
+
import { ancestry, controllerOf, processSource, ProcUnreadable } from "./proc.mjs";
|
|
141
148
|
import { IN_FLIGHT_DEFAULT } from "./phase0-receipt.mjs";
|
|
142
149
|
|
|
143
150
|
const NAME = "phase0";
|
|
144
151
|
const SCRIPTS = dirname(fileURLToPath(import.meta.url));
|
|
145
152
|
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
153
|
+
const isRecord = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
|
|
146
154
|
const NETWORK_TIMEOUT_MS = 120_000;
|
|
147
155
|
const PR_LIMIT = 1000;
|
|
148
156
|
// The tail of a child's output is the part that says why it stopped.
|
|
@@ -419,8 +427,46 @@ function shortlist({ cwd }) {
|
|
|
419
427
|
return ok("shortlist built");
|
|
420
428
|
}
|
|
421
429
|
|
|
430
|
+
// The controller alarm: this omp's own record that its timer is armed against
|
|
431
|
+
// this checkout's `.fleet/` (controller-alarm.mjs writes it at session_start).
|
|
432
|
+
// The record is read, not trusted: it must name this omp's pid and start time
|
|
433
|
+
// and this `.fleet/`.
|
|
434
|
+
function alarm({ fleetDir }) {
|
|
435
|
+
const tail = " — restart omp";
|
|
436
|
+
let controller;
|
|
437
|
+
try {
|
|
438
|
+
const source = processSource();
|
|
439
|
+
controller = controllerOf(source, ancestry(source));
|
|
440
|
+
} catch (e) {
|
|
441
|
+
if (!(e instanceof ProcUnreadable)) throw e;
|
|
442
|
+
return failed(`${e.message}${tail}`);
|
|
443
|
+
}
|
|
444
|
+
if (controller === null) return failed(`no omp process above phase 0: the controller alarm cannot be checked${tail}`);
|
|
445
|
+
const path = alarmFile(controller.pid);
|
|
446
|
+
let text;
|
|
447
|
+
try {
|
|
448
|
+
text = readFileSync(path, "utf8");
|
|
449
|
+
} catch (e) {
|
|
450
|
+
if (e.code === "ENOENT") return failed(`controller alarm is not armed: ${path} does not exist${tail}`);
|
|
451
|
+
return failed(`controller alarm file ${path} cannot be read (${e.code ?? e.message})${tail}`);
|
|
452
|
+
}
|
|
453
|
+
let rec;
|
|
454
|
+
try {
|
|
455
|
+
rec = JSON.parse(text);
|
|
456
|
+
} catch (e) {
|
|
457
|
+
return failed(`controller alarm file ${path} is not JSON (${e.message})${tail}`);
|
|
458
|
+
}
|
|
459
|
+
if (!isRecord(rec)) return failed(`controller alarm file ${path} is not an object${tail}`);
|
|
460
|
+
const missing = ["pid", "lstart", "fleet"].filter((k) => !Object.hasOwn(rec, k));
|
|
461
|
+
if (missing.length > 0) return failed(`controller alarm file ${path} lacks ${missing.join(", ")}${tail}`);
|
|
462
|
+
if (rec.pid !== controller.pid) return failed(`controller alarm file ${path} names pid ${rec.pid}, but this omp is pid ${controller.pid}${tail}`);
|
|
463
|
+
if (rec.lstart !== controller.lstart) return failed(`controller alarm file ${path} was written by a process started at "${rec.lstart}", but this omp started at "${controller.lstart}"${tail}`);
|
|
464
|
+
if (rec.fleet !== fleetDir) return failed(`controller alarm file ${path} names fleet "${rec.fleet}", not ${fleetDir}${tail}`);
|
|
465
|
+
return ok(`controller alarm armed: pid ${controller.pid}, ${fleetDir}`);
|
|
466
|
+
}
|
|
467
|
+
|
|
422
468
|
const STEPS = {
|
|
423
|
-
"branch": branch, "fast-forward": fastForward, "pin": pin, "baseline": baseline, "preflight": preflight,
|
|
469
|
+
"alarm": alarm, "branch": branch, "fast-forward": fastForward, "pin": pin, "baseline": baseline, "preflight": preflight,
|
|
424
470
|
"ruleset": ruleset, "tier-roles": tierRoles, "metrics": metrics, "cockpit": cockpit, "rotate": rotate,
|
|
425
471
|
"reclaim": reclaim, "recipe": recipe, "fold-in": foldIn, "stranded": stranded, "shortlist": shortlist,
|
|
426
472
|
};
|
|
@@ -445,8 +491,7 @@ function readReceipt(path) {
|
|
|
445
491
|
} catch (e) {
|
|
446
492
|
throw new Error(`the receipt ${path} is not JSON (${e.message}) — move it aside and re-run`);
|
|
447
493
|
}
|
|
448
|
-
|
|
449
|
-
if (!(record(r) && record(r.steps) && Object.hasOwn(r, "run") && typeof r.session === "string")) {
|
|
494
|
+
if (!(isRecord(r) && isRecord(r.steps) && Object.hasOwn(r, "run") && typeof r.session === "string")) {
|
|
450
495
|
throw new Error(`the receipt ${path} has no steps, run and session — move it aside and re-run`);
|
|
451
496
|
}
|
|
452
497
|
return r;
|
|
@@ -548,9 +593,9 @@ function main() {
|
|
|
548
593
|
return;
|
|
549
594
|
}
|
|
550
595
|
// A receipt belongs to a rotated run when the rotation wrote a controller
|
|
551
|
-
// record it can be keyed on, or,
|
|
552
|
-
//
|
|
553
|
-
//
|
|
596
|
+
// record it can be keyed on, or, when it wrote none, when its rotation is
|
|
597
|
+
// recorded `ok`. Either way it is this run's only if the record now holds
|
|
598
|
+
// the `at` it names and the session is the same.
|
|
554
599
|
const rotated = previous !== null && (previous.run !== null || previous.steps.rotate?.status === "ok");
|
|
555
600
|
const resumed = rotated && previous.run === (record?.at ?? null) && previous.session === session;
|
|
556
601
|
if (rotated && !resumed) {
|
|
@@ -564,7 +609,8 @@ function main() {
|
|
|
564
609
|
|
|
565
610
|
const receipt = { run: resumed ? previous.run : null, session, scratch, inFlight, steps: {} };
|
|
566
611
|
for (const name of STEP_NAMES) {
|
|
567
|
-
|
|
612
|
+
// `alarm` checks the omp running this invocation, so its `ok` is never carried.
|
|
613
|
+
const was = resumed && name !== "alarm" ? previous.steps[name] : undefined;
|
|
568
614
|
receipt.steps[name] = was?.status === "ok" ? was : { status: "pending", at: now, output: "not run" };
|
|
569
615
|
}
|
|
570
616
|
|
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);
|
package/skills/run-team/SKILL.md
CHANGED
|
@@ -50,8 +50,8 @@ phase, or in any later one, asks the maintainer which tickets to take.
|
|
|
50
50
|
when the repository has no workflow named CI. Its steps and receipt: See
|
|
51
51
|
references/phase-0.md. Dispatch nothing before it exits 0. Exit 4 is a
|
|
52
52
|
step that needs you: act on each `NEED` line below, then run it again. Once
|
|
53
|
-
`rotate` has run, a re-run skips every `ok` step and resumes
|
|
54
|
-
one stopped; before it, a re-run runs every step again.
|
|
53
|
+
`rotate` has run, a re-run skips every `ok` step but `alarm` and resumes
|
|
54
|
+
where the last one stopped; before it, a re-run runs every step again.
|
|
55
55
|
|
|
56
56
|
The `tier-roles` step runs `~/.fleet/bin/fleet-run tier-roles.mjs --check
|
|
57
57
|
--in-flight <B>`, `<B>` being the `--in-flight` given, else the default.
|
|
@@ -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
|
|
|
@@ -13,13 +13,17 @@ Exit 3 is a step that failed: its line carries the refusal, so fix the cause
|
|
|
13
13
|
and run it again.
|
|
14
14
|
|
|
15
15
|
`phase0.mjs` runs the start sequence in
|
|
16
|
-
its fixed order — branch, fast-forward, pin, baseline, preflight, ruleset,
|
|
16
|
+
its fixed order — alarm, branch, fast-forward, pin, baseline, preflight, ruleset,
|
|
17
17
|
tier-roles, metrics, cockpit, rotate, reclaim, recipe, fold-in, stranded,
|
|
18
18
|
shortlist — prints one `phase0: <step>: <status>` line per step with the
|
|
19
19
|
`NEED` lines last, and records each step in `.fleet/phase0.json`, which the
|
|
20
20
|
tick reads: it prints `PHASE0-INCOMPLETE` and holds every dispatching row
|
|
21
21
|
until the receipt is whole.
|
|
22
22
|
|
|
23
|
+
## Check the controller alarm
|
|
24
|
+
|
|
25
|
+
**Check the controller alarm first. Phase 0's first step refuses when this omp's timer is not armed.** The `alarm` step reads `~/.fleet/alarm/<pid>.json`, where `<pid>` is the omp process above the script, and checks that the file names that pid, that process's start time, and this checkout's `.fleet/`. A file that is missing, not JSON, or names something else, and no omp process above the script, each fail the step with exit 3 and a message ending `restart omp`. Restart omp, then run phase 0 again. The step runs on every run of phase 0, one its receipt records `ok` included, because it checks the omp running that run; nothing after it runs until it passes.
|
|
26
|
+
|
|
23
27
|
## Fast-forward the checkout
|
|
24
28
|
|
|
25
29
|
**Fast-forward the checkout before you trust these rules — you are reading
|