@feigi/fleet-ctl 3.76.9 → 3.77.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.76.9",
3
+ "version": "3.77.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": {
@@ -134,6 +134,27 @@
134
134
  // `closeout.mjs reopen --reason "<reason>"`, its output printed as it
135
135
  // stands, then the tick. Refused once any `finish` step is ok, and when
136
136
  // no drain marker is set; its exit 2 or 3 is passed through.
137
+ // member-died <member>
138
+ // Settles a member the controller has confirmed dead, then ticks. The
139
+ // outcome comes from the session directory's listing, never from an
140
+ // argument or a file's contents. The first marker that matches decides:
141
+ // <m>.jsonl.tombstone killed
142
+ // <m>.md, no tombstone, fix-pr or finisher-pr failed
143
+ // <m>.md, no tombstone, merge-bot killed
144
+ // <m>.md, no tombstone, impl refused: settle it by hand
145
+ // <m>.jsonl, no marker refused: running or parked
146
+ // no transcript, no marker killed once the tick's
147
+ // first-seen stamp in
148
+ // heartbeat.json is over
149
+ // five minutes old; refused
150
+ // when younger or unstamped
151
+ // Ahead of the markers: a review runner refuses, its death being
152
+ // `review-failed`; a member the ledger already holds settled is noted,
153
+ // and the tick runs; a report on disk refuses, naming the kind that
154
+ // settles it, or its violation when that kind could not settle from
155
+ // it. The session directory is this run's phase-0 receipt's
156
+ // `session`, else `--session <dir>`: the tick's lookup, so another
157
+ // run's receipt names none.
137
158
  //
138
159
  // A write this script cannot make, and does not refuse, is printed as a
139
160
  // `NEED <what> …` line, at the left margin, for the controller to make by
@@ -141,7 +162,10 @@
141
162
  //
142
163
  // Exit: 0 every write made and the tick exited 0. 2 a usage error, refused
143
164
  // before anything is written or ticked: an unknown kind or flag, a `--reason`
144
- // that is empty or beside any kind but `drain` and `undrain`, a member of
165
+ // that is empty or beside any kind but `drain` and `undrain`, a `--session`
166
+ // beside any kind but `member-died`, any `member-died` refusal (the table's,
167
+ // a name that is no ledger member, no session directory, a session directory
168
+ // that cannot be listed, a heartbeat file that cannot be used), a member of
145
169
  // the wrong family, a review path outside the PR's partition or a file that
146
170
  // is no review result, a ledger or phase-0 receipt that cannot be read. A
147
171
  // ledger write's own refusal — `settle`'s 3 (the report's claim and the world
@@ -155,17 +179,18 @@
155
179
  // Zero deps: node builtins and sibling scripts only.
156
180
 
157
181
  import { spawnSync } from "node:child_process";
158
- import { readFileSync, realpathSync } from "node:fs";
182
+ import { readdirSync, readFileSync, realpathSync } from "node:fs";
159
183
  import { basename, dirname, join, normalize, resolve } from "node:path";
160
184
  import { fileURLToPath } from "node:url";
161
185
  import { makeDie, defineFlags, isDigits, writeAll } from "./arg.mjs";
162
186
  import { FleetDirUnresolvable, fleetFile } from "./fleet-dir.mjs";
163
187
  import { gitEnv } from "./git-env.mjs";
164
188
  import { isCLI } from "./is-cli.mjs";
165
- import { BRIEFS, REVIEW, REVIEWED, memberTokens, nextReviewerName, parseDispatchedEntry, parseMember, parseToken, readRetired, rowNums } from "./ledger-grammar.mjs";
166
- import { dispositionsToken, foldLedger } from "./fleet-tick.mjs";
167
- import { readRunReceipt, tickCaps } from "./phase0-receipt.mjs";
189
+ import { BRIEFS, REVIEW, REVIEWED, REVIEWER, memberTokens, nextReviewerName, parseDispatchedEntry, parseMember, parseToken, readRetired, rowNums } from "./ledger-grammar.mjs";
190
+ import { NO_TRANSCRIPT_AFTER_DEFAULT, controllerState, dispositionsToken, foldLedger } from "./fleet-tick.mjs";
191
+ import { phase0Gate, readRunReceipt, tickCaps } from "./phase0-receipt.mjs";
168
192
  import { defaultScratch, readReportIn, reportsDir } from "./report-files.mjs";
193
+ import { readState, statePath } from "./fleet-state.mjs";
169
194
 
170
195
  const NAME = "fleet-event";
171
196
  const die = makeDie(NAME);
@@ -188,8 +213,9 @@ const KINDS = {
188
213
  heartbeat: { args: [] },
189
214
  drain: { args: [], flag: "--reason \"<reason>\"" },
190
215
  undrain: { args: [], flag: "--reason \"<reason>\"" },
216
+ "member-died": { args: ["<member>"] },
191
217
  };
192
- const FLAGS = { "in-flight": "value", reason: "value" };
218
+ const FLAGS = { "in-flight": "value", reason: "value", session: "value" };
193
219
  const USAGE = `usage: ${NAME}.mjs <kind> <args> [--in-flight <n>] — kinds: `
194
220
  + Object.entries(KINDS).map(([k, { args, flag }]) => [k, ...args, ...(flag ? [flag] : [])].join(" ")).join(", ");
195
221
 
@@ -299,15 +325,18 @@ class Event {
299
325
  return readReportIn(reportsDir(this.scratch), member);
300
326
  }
301
327
 
302
- // `settle <member> --from-report` unless the ledger holds it settled.
303
- // Returns the outcome, or null when the settle refused.
304
- settle(member) {
328
+ // `settle <member> --from-report` unless the ledger holds it settled, or
329
+ // `settle <member> <decided>` when a marker decided the outcome — never one
330
+ // a file's contents named. Returns the outcome, or null when the settle
331
+ // refused.
332
+ settle(member, decided = null) {
305
333
  const before = settledOutcome(this.ledger, member);
306
334
  if (before !== null) {
307
335
  this.note(`${member}=${before} already settled`);
308
336
  return before;
309
337
  }
310
- const r = node("ledger.mjs", ["settle", member, "--from-report", "--reports", reportsDir(this.scratch)]);
338
+ const tail = decided === null ? ["--from-report", "--reports", reportsDir(this.scratch)] : [decided];
339
+ const r = node("ledger.mjs", ["settle", member, ...tail]);
311
340
  if (r.status !== 0) {
312
341
  this.refuse(`settle ${member}`, r);
313
342
  return null;
@@ -727,6 +756,81 @@ function undrain(ev, reason) {
727
756
  }
728
757
  }
729
758
 
759
+ // ── member-died ─────────────────────────────────────────────────────────────
760
+
761
+ // fleet-tick's default `--no-transcript-after`: a member with no transcript
762
+ // whose first-seen stamp is older than this never started.
763
+ const NO_TRANSCRIPT_MS = NO_TRANSCRIPT_AFTER_DEFAULT * 60_000;
764
+
765
+ // The kind whose report settles a family — the KINDS entry naming that family
766
+ // — named in the refusal that finds one on disk.
767
+ export const reportKind = (family) => Object.keys(KINDS).find((k) => KINDS[k].family === family);
768
+
769
+ // This run's phase-0 receipt `session`, or null: the receipt phase0Gate()
770
+ // judges this run's against the controller record in heartbeat.json and that
771
+ // record's process, as fleet-tick judges it.
772
+ function runSession({ read, path }) {
773
+ const { controller } = readState(statePath(NAME), NAME);
774
+ return phase0Gate(read, { path, controllerAt: controller?.at, controller: controllerState(controller, NAME) }).session;
775
+ }
776
+
777
+ // The names in the session directory. A missing one reads as empty when the
778
+ // receipt named it, as the tick reads it, and refuses otherwise.
779
+ function sessionListing(session, receiptNamed) {
780
+ try {
781
+ return new Set(readdirSync(session));
782
+ } catch (e) {
783
+ if (e.code === "ENOENT" && receiptNamed) return new Set();
784
+ return die(`member-died: cannot list the session directory ${session} (${e.code ?? e.message}) — nothing written`);
785
+ }
786
+ }
787
+
788
+ // Settles a member the controller has confirmed dead, by the first marker
789
+ // that matches; see the header. The settle is a ledger write, so its
790
+ // refusal passes through to the exit as every other kind's does. Every other
791
+ // refusal is a die() before anything is written or ticked.
792
+ function memberDied(ev, member, receipt, flagSession) {
793
+ const review = REVIEWER.exec(member);
794
+ if (review !== null) die(`member-died ${member}: a review runner settles nothing; its death is review-failed ${review[1]} member:${member} — nothing written`);
795
+ const m = parseMember(member);
796
+ if (m === null) die(`member-died: '${member}' is not a ledger member — nothing written`);
797
+ const before = settledOutcome(ev.ledger, member);
798
+ if (before !== null) {
799
+ ev.note(`${member}=${before} already settled`);
800
+ return;
801
+ }
802
+ const report = ev.report(member);
803
+ const kind = reportKind(m.family);
804
+ if (report.state === "invalid") die(`member-died ${member}: its report ${report.path} is on disk and ${kind} cannot settle from it (${report.violation}): fix the report and run ${kind} ${member}, or settle ${member} by hand — nothing written`);
805
+ if (report.state !== "absent") die(`member-died ${member}: its report ${report.path} is on disk; ${kind} ${member} settles it — nothing written`);
806
+ // The session directory the tick reads: this run's receipt's `session`,
807
+ // else `--session`. A `--session` the receipt's overrides is said, not
808
+ // dropped in silence.
809
+ const receiptSession = runSession(receipt);
810
+ if (receiptSession !== null && flagSession !== null && resolve(flagSession) !== resolve(receiptSession)) {
811
+ writeAll(2, `${NAME}: member-died ${member} reads ${receiptSession}, this run's phase-0 receipt's session, not --session ${flagSession}\n`);
812
+ }
813
+ const session = receiptSession ?? flagSession;
814
+ if (session === null) die(`member-died ${member} needs the session directory: this run's phase-0 receipt names none, so pass --session <dir> — nothing written`);
815
+ const listed = sessionListing(session, receiptSession !== null);
816
+ const md = `${member}.md`;
817
+ const jsonl = `${member}.jsonl`;
818
+ if (listed.has(`${jsonl}.tombstone`)) return ev.settle(member, "killed");
819
+ if (listed.has(md)) {
820
+ if (m.family === "impl") die(`member-died ${member}: ${md} is in ${session} with no tombstone, and an implementer's yield is its own outcome: read agent://${member} and settle it by hand — nothing written`);
821
+ return ev.settle(member, m.family === "merge-bot" ? "killed" : "failed");
822
+ }
823
+ if (listed.has(jsonl)) die(`member-died ${member}: ${member} has a transcript and no marker in ${session}, so it is running or parked; census it, kill it, then rerun — nothing written`);
824
+ const heartbeat = statePath("fleet-event");
825
+ const state = readState(heartbeat, "fleet-event");
826
+ if (state.degraded) die(`member-died ${member}: ${heartbeat} ${state.degraded} — nothing written`);
827
+ const stamp = state.seen[member];
828
+ if (typeof stamp !== "number") die(`member-died ${member}: no transcript and no first-seen stamp in ${heartbeat}, so the tick never saw it live — nothing written`);
829
+ const age = Date.now() - stamp;
830
+ if (age <= NO_TRANSCRIPT_MS) die(`member-died ${member}: first seen ${Math.floor(age / 60_000)} min ago, not yet past the ${NO_TRANSCRIPT_MS / 60_000}-minute no-transcript bound — nothing written`);
831
+ return ev.settle(member, "killed");
832
+ }
833
+
730
834
  // ── main ────────────────────────────────────────────────────────────────────
731
835
 
732
836
  function boundArg(F, name) {
@@ -764,6 +868,7 @@ function main() {
764
868
  } else if (reason !== null) {
765
869
  die(`--reason belongs to drain and undrain, not ${kind}`);
766
870
  }
871
+ if (kind !== "member-died" && F.arg("session") !== null) die(`--session belongs to member-died, not ${kind}`);
767
872
  let pr = null;
768
873
  if (spec.family !== undefined) {
769
874
  const m = parseMember(args[0]);
@@ -776,9 +881,11 @@ function main() {
776
881
 
777
882
  let fleetDir;
778
883
  let scratch;
884
+ let receipt;
779
885
  try {
780
886
  fleetDir = fleetFile(null);
781
- const { read } = readRunReceipt();
887
+ receipt = readRunReceipt();
888
+ const { read } = receipt;
782
889
  const named = read.state === "ok" ? read.receipt.scratch : undefined;
783
890
  scratch = typeof named === "string" && named !== "" ? named : defaultScratch();
784
891
  } catch (e) {
@@ -788,7 +895,6 @@ function main() {
788
895
  const caps = tickCaps(fleetDir);
789
896
  if (caps.error) die(`${caps.error} — nothing written`);
790
897
  const tickArgs = ["--apply", ...(inFlight === null ? caps.args : ["--in-flight", inFlight]), ...(kind === "heartbeat" ? ["--fold-unchanged"] : [])];
791
-
792
898
  const ev = new Event({ fleetDir, scratch, ledger: readLedger() });
793
899
  if (kind === "impl-report") implReport(ev, args[0]);
794
900
  else if (kind === "fix-report") fixReport(ev, args[0]);
@@ -800,6 +906,7 @@ function main() {
800
906
  else if (kind === "conflict") conflict(ev, pr);
801
907
  else if (kind === "drain") drain(ev, reason);
802
908
  else if (kind === "undrain") undrain(ev, reason);
909
+ else if (kind === "member-died") memberDied(ev, args[0], receipt, F.arg("session"));
803
910
 
804
911
  writeAll(1, `${[...ev.lines, `event: ${kind}: ${ev.summary.join("; ") || "nothing to record"}`].join("\n")}\n`);
805
912
  const tick = spawnSync(process.execPath, [join(SCRIPTS, "fleet-tick.mjs"), ...tickArgs], { stdio: ["ignore", "inherit", "inherit"], env: gitEnv() });
@@ -1295,6 +1295,10 @@ export function unrecordedLines({ stems, recorded }) {
1295
1295
  });
1296
1296
  }
1297
1297
 
1298
+ // The `--no-transcript-after` default, in minutes: its one definition, which
1299
+ // fleet-event.mjs's member-died reads as its own no-transcript bound.
1300
+ export const NO_TRANSCRIPT_AFTER_DEFAULT = 5;
1301
+
1298
1302
  // The NO-TRANSCRIPT lines: one per live member token in `live` whose `seen`
1299
1303
  // stamp is more than `after` minutes old and whose `<member>.jsonl` is not
1300
1304
  // among `stems`, as `{kind, name, line, key}` — a ledger token whose `task` call
@@ -1481,7 +1485,7 @@ const OPTIONS = {
1481
1485
  session: { type: "string", default: "" },
1482
1486
  // Minutes a live member token may go with no transcript in the session
1483
1487
  // directory before NO-TRANSCRIPT names it.
1484
- "no-transcript-after": { type: "string", default: "5" },
1488
+ "no-transcript-after": { type: "string", default: String(NO_TRANSCRIPT_AFTER_DEFAULT) },
1485
1489
  // Minutes a Pull's question may stand, its `pull.json` unwritten, before the
1486
1490
  // PULL row naming it says `(stale)`.
1487
1491
  "pull-stale-minutes": { type: "string", default: "120" },
@@ -1867,15 +1871,16 @@ function claimed() {
1867
1871
 
1868
1872
  // What the controller record's process is now, for the phase-0 gate: `none`
1869
1873
  // with no record, else judgeRecord()'s answer. A process table that cannot
1870
- // be read is `unreadable`, said on stderr: a controller nobody could judge
1871
- // is not one whose receipt this tick may take as this run's.
1872
- function controllerState(controller) {
1874
+ // be read is `unreadable`, said on stderr under the caller's `name`: a
1875
+ // controller nobody could judge is not one whose receipt may be taken as
1876
+ // this run's.
1877
+ export function controllerState(controller, name = NAME) {
1873
1878
  if (controller === null) return "none";
1874
1879
  try {
1875
1880
  return judgeRecord(controller);
1876
1881
  } catch (e) {
1877
1882
  if (!(e instanceof ProcUnreadable)) throw e;
1878
- console.error(`${NAME}: WARNING ${e.message} — the recorded controller pid ${controller.pid} cannot be judged, so the phase-0 receipt is held`);
1883
+ console.error(`${name}: WARNING ${e.message} — the recorded controller pid ${controller.pid} cannot be judged, so the phase-0 receipt is held`);
1879
1884
  return "unreadable";
1880
1885
  }
1881
1886
  }
@@ -203,6 +203,7 @@ Run each as `~/.fleet/bin/fleet-run fleet-event.mjs <kind> <args>`, from the mai
203
203
  | Heartbeat | `fleet-event.mjs heartbeat` |
204
204
  | Drain | `fleet-event.mjs drain --reason "<reason>"` |
205
205
  | Undrain | `fleet-event.mjs undrain --reason "<reason>"` |
206
+ | Member confirmed dead or never dispatched | `fleet-event.mjs member-died <member>` |
206
207
 
207
208
  ### Nothing to do: arm the beat
208
209
 
@@ -280,7 +281,7 @@ depth** guard table applied in code. Act on each line as it reads:
280
281
  - `PHASE0-INCOMPLETE <step>…` — every dispatching row is held: run the `phase0.mjs` command the line names; advice lines hold nothing.
281
282
  - `CHECK-LIVENESS <member> (…)` — advice, never an action: take the census the **Member unaccounted** row of **Failure handling** gives before you touch the token.
282
283
  - `UNRECORDED <stem> (…)` — record it with `ledger.mjs dispatch`, or confirm it is a stranger's member.
283
- - `NO-TRANSCRIPT <member> (…)` — make the `task` call, or settle the token `killed`.
284
+ - `NO-TRANSCRIPT <member> (…)` — make the `task` call, or run the **Member confirmed dead or never dispatched** wake.
284
285
  - `SETTLE <member> (report on disk …)` — a live token whose report is on disk: run that member's report wake from the wake table, which runs the `ledger.mjs settle <member> --from-report` the line prints plus the follow-on work its report calls for; `REPORT-MISMATCH`, `REPORT-UNREADABLE` and `REPORT-INVALID` name a report file that cannot be settled from, and the tick settles nothing itself.
285
286
 
286
287
  ## Phase 1 — Pull: judge, then claim or relabel
@@ -1296,11 +1297,7 @@ owes beyond its call:
1296
1297
  nothing the tick did not name.
1297
1298
  - **Finisher reports halted** → the per-cause rule (**Resolving a finisher
1298
1299
  halt**, above).
1299
- - **A finisher failed or was killed** → `ledger.mjs settle finisher-pr-<M>=failed` for a
1300
- finisher that crashed or gave up, `=killed` for one that was killed; no label,
1301
- and the tick prints no dispatch for it. The cockpit flags the PR
1302
- `finisher:failed` / `finisher:killed` at severity 4 and you resolve it by hand
1303
- (**Resolving a finisher that died**, above), then run the tick.
1300
+ - **A finisher failed or was killed** → the **Member confirmed dead or never dispatched** wake (`member-died finisher-pr-<M>`), which settles it `failed` or `killed`; no label, and the tick prints no dispatch for it. The cockpit flags the PR `finisher:failed` / `finisher:killed` at severity 4 and you resolve it by hand (**Resolving a finisher that died**, above), then run the tick.
1304
1301
  - **Implementer bails before implementing** → `fleet-event.mjs impl-report impl-<N>`; the causes and the issue action each takes are under **Relabel causes**.
1305
1302
  - **A review result lands** → see the result file written (by you or by the
1306
1303
  runner — **Reviewers** says which, per harness), then
@@ -2609,8 +2606,8 @@ Match the failure to its row in the table below and run that response; where the
2609
2606
  | Merge bot's CI wait reaches its per-PR time bound with the run's `status` still not `completed` | The bot skips that PR and reports `ci-wait-timeout-#<pr>` with the run id and its status. PR stays queued, label untouched |
2610
2607
  | Member silent or truncated | Send to ping or resume — same unit of work; see the state machine below |
2611
2608
  | Member idle with work outstanding | Read the PR first, *then* ping. Idle ≠ done |
2612
- | Member **killed** (spend limit, API error, crash) | Confirm it is dead first — a frozen transcript does not establish that; see below — then new member, new name, prompt carries inherited state |
2613
- | Member **unaccounted** (a live token the tick flags `CHECK-LIVENESS`, or any live token nothing has reported on) | Registry census first: `read history://` once. A row other than `running` → `read agent://<member>` and the artifacts (PR head, worktree, labels), settle the real outcome with the existing vocabulary, and resume a `parked` member with `write agent://<member>` when its work is worth continuing. A `running` row → the mtime discriminator below, two polls: moved means BLOCKED and alive; frozen at both polls **and** no artifact movement → `write proc://<member>/kill`, `ledger.mjs settle <member> killed`, fresh name. Three independent alive-signals — the first poll, the second poll, the artifacts — must all be silent before a kill; the tick settles and kills nothing |
2609
+ | Member **killed** (spend limit, API error, crash) | Confirm it is dead first — a frozen transcript does not establish that; see below — then run the **Member confirmed dead or never dispatched** wake, then new member, new name, prompt carries inherited state |
2610
+ | Member **unaccounted** (a live token the tick flags `CHECK-LIVENESS`, or any live token nothing has reported on) | Registry census first: `read history://` once. A row other than `running` → `read agent://<member>` and the artifacts (PR head, worktree, labels), settle the real outcome with the existing vocabulary, and resume a `parked` member with `write agent://<member>` when its work is worth continuing. A `running` row → the mtime discriminator below, two polls: moved means BLOCKED and alive; frozen at both polls **and** no artifact movement → `write proc://<member>/kill`, the **Member confirmed dead or never dispatched** wake, fresh name. Three independent alive-signals — the first poll, the second poll, the artifacts — must all be silent before a kill; the tick settles and kills nothing |
2614
2611
 
2615
2612
  ### Recovery
2616
2613
 
@@ -2623,8 +2620,6 @@ spellings. Recovery is a fresh member, fresh name (`impl-<N>-b`,
2623
2620
  `fix-pr-<M>-b`, and for a reviewer the next letter PR M's rows do not carry
2624
2621
  yet) whose prompt states what it inherits.
2625
2622
 
2626
- `hub cancel` leaves a peer hard-aborted and unmessageable, but a `failed` job's peer can stay `idle` and answer normally. No distinct truncated state exists.
2627
-
2628
2623
  Reviewers that went idle on CI recover as a **finisher, not a re-review** once
2629
2624
  commits are pushed. See references/member-lifecycle.md.
2630
2625
 
@@ -151,7 +151,9 @@ The full text of each row `fleet-tick.mjs` prints and what it asks of the contro
151
151
  above the rows — a live ledger token first seen more than
152
152
  `--no-transcript-after` minutes ago (default 5) has no transcript in the
153
153
  session directory: a dispatch whose `task` call was never made. Make the
154
- `task` call, or settle the token `killed`. Advice the same way, folded as
154
+ `task` call, or run the **Member confirmed dead or never dispatched** wake
155
+ (`fleet-event.mjs member-died <member>`), which settles the token `killed`
156
+ once the first-seen stamp is past five minutes. Advice the same way, folded as
155
157
  `…; NO-TRANSCRIPT <member> persists — …`. Both lines read the session
156
158
  directory this run's phase-0 receipt names, else `--session <dir>`; with
157
159
  neither the tick skips them with one stderr line. A heartbeat state file