@feigi/fleet-ctl 3.85.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.85.0",
3
+ "version": "3.86.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": {
@@ -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. The file is for run-team's
49
- // phase 0 to check, and nothing reads it yet: this file only writes it. A
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 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`);
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
  }
@@ -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
 
@@ -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 `at` the
86
- // controller record holds now AND its `session` equals this run's session. That
87
- // comparison alone does not separate two runs before the new run's rotation:
88
- // the record still carries the previous run's `at` until `ledger.mjs rotate`
89
- // writes the next, so the previous run's receipt still matches it, and a
90
- // re-run that trusted `run` alone would skip `rotate`. The session rule closes
91
- // a new session. A receipt of any other run (a `run` that is set and does not
92
- // match, or a different session) is held as `phase0.previous.json`, where
93
- // `closeout.mjs finish` also leaves a finished run's receipt, and the
94
- // script starts at `branch`; this run's own receipt is written at
95
- // `phase0.json` after every step. A receipt whose rotation has not run is
96
- // simply overwritten: every step before `rotate` is idempotent and is re-run
97
- // on every invocation until the rotation has run.
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. With no omp process
105
- // above the run the rotation writes no controller record and `run` stays
106
- // null; a receipt whose `rotate` step is `ok` is then resumed on the session
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
- const record = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
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, with no omp process above the run to write
552
- // one, when its rotation is recorded `ok`. Either way it is this run's only
553
- // if the record now holds the `at` it names and the session is the same.
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
- const was = resumed ? previous.steps[name] : undefined;
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
 
@@ -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 where the last
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.
@@ -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