omp-conductor 0.19.6 → 0.20.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.
Files changed (71) hide show
  1. package/REFERENCE.md +27 -2
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7832
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/doctor.ts +17 -12
  49. package/src/escalate.ts +39 -21
  50. package/src/failure-class.ts +75 -1
  51. package/src/fleet.ts +1218 -304
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +428 -1681
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +72 -6
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +158 -7
  64. package/src/store.ts +646 -26
  65. package/src/to-spec.ts +194 -21
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +435 -15
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +384 -12
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +456 -1
@@ -574,6 +574,62 @@ function orDash(value, format) {
574
574
  return value === null || value === undefined ? "—" : format(value);
575
575
  }
576
576
 
577
+ /**
578
+ * The attribution block (phase 4): where the spend went, as opposed to what it
579
+ * bought. Rendered in the empty case too — a dry queue is exactly when the
580
+ * daemon's own grooming sessions run, and that spend bought no outcome, which
581
+ * is the number an operator most wants to see.
582
+ */
583
+ function renderAttribution(out, report) {
584
+ const models = report.models ?? [];
585
+ if (models.length > 0) {
586
+ const box = el("div", "chart");
587
+ box.appendChild(el("h4", undefined, "Per model (grouped on the model actually billed)"));
588
+ const rows = el("div", "summary");
589
+ for (const m of models) {
590
+ // Nothing metered at all is stated as unknown: "$0.00 metered" is true and
591
+ // still reads as free, which is the misreading this panel exists to
592
+ // prevent — the same rule the CLI's human form follows.
593
+ const cost =
594
+ m.spendUsd === 0 && m.unmeteredRuns === m.runs
595
+ ? "cost unknown — no run on this model metered"
596
+ : `${usd(m.spendUsd)} metered · ${orDash(m.spendPerMerge, usd)} / merge` +
597
+ (m.unmeteredRuns > 0 ? ` · ${m.unmeteredRuns} unmetered` : "");
598
+ rows.appendChild(statLine(m.model, `${m.runs} run(s) · ${m.merges} merge(s) · ${cost}`));
599
+ }
600
+ box.appendChild(rows);
601
+ out.appendChild(box);
602
+ }
603
+
604
+ const ran = (report.sessions ?? []).filter((s) => s.sessions > 0);
605
+ const unmeteredRoles = report.unmeteredRoles ?? [];
606
+ // Silence rather than a row of zeros: "no grooming ran" and "grooming cost
607
+ // nothing" are different claims, and only the first would be true.
608
+ if (ran.length === 0 && unmeteredRoles.length === 0) return;
609
+ const box = el("div", "chart");
610
+ box.appendChild(el("h4", undefined, "Daemon-owned sessions (not worker runs)"));
611
+ const rows = el("div", "summary");
612
+ for (const s of ran) {
613
+ // An unmetered session's cost is unknown, so it is stated as a count and
614
+ // never folded into the dollar figure as a zero.
615
+ const cost =
616
+ s.spendUsd === 0 && s.unmeteredSessions === s.sessions
617
+ ? "cost unknown — no session metered"
618
+ : `${usd(s.spendUsd)} metered` +
619
+ (s.unmeteredSessions > 0
620
+ ? ` · ${s.unmeteredSessions} unmetered (cost unknown, not $0.00)`
621
+ : "");
622
+ rows.appendChild(statLine(s.role, `${s.sessions} session(s) · ${s.turns} turn(s) · ${cost}`));
623
+ }
624
+ for (const role of unmeteredRoles) {
625
+ rows.appendChild(
626
+ statLine(role, "unmetered — no usage events observable, so the cost is unknown"),
627
+ );
628
+ }
629
+ box.appendChild(rows);
630
+ out.appendChild(box);
631
+ }
632
+
577
633
  function hours(ms) {
578
634
  return `${(ms / 3_600_000).toFixed(1)}h`;
579
635
  }
@@ -594,6 +650,7 @@ async function refreshStats() {
594
650
  ),
595
651
  );
596
652
  out.appendChild(statLine("GitHub API calls", String(report.ghCalls)));
653
+ renderAttribution(out, report);
597
654
  return;
598
655
  }
599
656
 
@@ -657,6 +714,7 @@ async function refreshStats() {
657
714
  (v) => String(v),
658
715
  ),
659
716
  );
717
+ renderAttribution(out, report);
660
718
  }
661
719
 
662
720
  statsWindowBar.addEventListener("click", (event) => {
@@ -47,6 +47,7 @@ import { makeTracker } from "../tracker/github.ts";
47
47
  import { dbPath, openStore } from "../store.ts";
48
48
  import { loadConfig, resolveCaps } from "../config.ts";
49
49
  import { acknowledgeUpgradeRecovery } from "../upgrade-verify.ts";
50
+ import { httpAuthHeader, missingHttpTokenMessage } from "../http-token.ts";
50
51
  import type { ProjectConfig } from "../types.ts";
51
52
 
52
53
  /** The source string every dashboard mutation attributes itself with. */
@@ -265,8 +266,24 @@ export function dashboardAnswerDecision(id: string, body: unknown, d: ControlDep
265
266
 
266
267
  // ------------------------------------------------------- production wiring --
267
268
 
268
- /** Forward to the living daemon's own HTTP surface, or report there is none. */
269
- async function defaultProxy(
269
+ /**
270
+ * Forward to the living daemon's own HTTP surface, or report there is none.
271
+ *
272
+ * Every route this forwards to is mutating, so the request carries the
273
+ * daemon's bearer token (Phase 4). Note this is deliberately *not* the
274
+ * dashboard's own token: the browser authenticated to the dashboard, and the
275
+ * dashboard authenticates to the daemon as a second, separately-scoped hop.
276
+ * A missing daemon token is its own answer rather than the 502 "no living
277
+ * daemon" — the daemon may be perfectly alive; what is missing is the
278
+ * credential file, and telling the operator "start the daemon" when it is
279
+ * already running would send them chasing the wrong thing.
280
+ *
281
+ * Exported so the suite can drive the *production* forwarder — the one that
282
+ * actually builds the request — against a stub daemon. Every other test injects
283
+ * `ControlDeps.proxy`, which by construction cannot prove this function sends
284
+ * the credential.
285
+ */
286
+ export async function defaultProxy(
270
287
  project: ProjectConfig,
271
288
  path: string,
272
289
  body: unknown,
@@ -277,13 +294,15 @@ async function defaultProxy(
277
294
  // would apply an operator's action to the wrong fleet.
278
295
  if (record === undefined) return undefined;
279
296
  if (record.project !== undefined && record.project !== project.name) return undefined;
297
+ const auth = httpAuthHeader();
298
+ if (auth === undefined) return { status: 503, body: { error: missingHttpTokenMessage() } };
280
299
  // Cheap liveness first, so an unreachable daemon is a 502 rather than a
281
300
  // mutation attempt that hangs the browser.
282
301
  if (!(await healthCheck(record.port)).ok) return undefined;
283
302
  try {
284
303
  const res = await fetch(`http://127.0.0.1:${record.port}${path}`, {
285
304
  method: "PUT",
286
- headers: { "content-type": "application/json" },
305
+ headers: { "content-type": "application/json", ...auth },
287
306
  body: JSON.stringify(body),
288
307
  signal: AbortSignal.timeout(10_000),
289
308
  });
@@ -331,6 +331,10 @@ async function statsProducer(name: string, since: string): Promise<StatsReport |
331
331
  window,
332
332
  ghCalls: store.ghCallsBetween(window.sinceDay, window.untilDay),
333
333
  runs: store.statsRuns(name, window.sinceEpochMs),
334
+ // Phase 4 attribution: daemon-owned session spend lives in its own table,
335
+ // deliberately outside `runs`, so it is a second read rather than more
336
+ // rows in the first one.
337
+ sessions: store.sessionSpendSince(name, window.sinceEpochMs),
334
338
  });
335
339
  } finally {
336
340
  store.close();
package/src/diff-flags.ts CHANGED
@@ -813,14 +813,35 @@ function claimRegion(body: string): string {
813
813
  return next === null ? rest : rest.slice(0, marker[0].length + next.index);
814
814
  }
815
815
 
816
+ /**
817
+ * An executable token: the shape a claimed command's first word must have to
818
+ * be a program somebody could have run. Either an ordinary command name — an
819
+ * ASCII letter or `_` first, then letters, digits, `_`, `.`, `+`, `-` (`bun`,
820
+ * `bash`, `cd`, `export`, `omp-conductor`, `python3.12`) — or a `./`-relative
821
+ * executable path built from those same characters plus `/`
822
+ * (`./scripts/deploy.sh`).
823
+ *
824
+ * Nothing else is a command, and that exclusion is the whole point (#1039):
825
+ * this predicate used to accept any first word that merely lacked a `/`, so
826
+ * the malformed-JSON fixture `{not json` described in a PR's verified prose
827
+ * became a "claimed" command with no transcript match, and a green PR settled
828
+ * with a false `claimed-proof-missing`. The grammar therefore refuses
829
+ * punctuation-led fragments (`{not`, `{"kind":`, `[1,`), quote-led values
830
+ * (`"some value"`), tokens carrying `:` or `=` (config expressions like
831
+ * `retry.modelFallback: true`, env assignments), digit-led data literals
832
+ * (timestamps like `2026-08-24 09:14Z`) and flag-led spans (`--json`). An
833
+ * absolute path stays excluded as it always was: it carries `/` without the
834
+ * leading `./`.
835
+ */
836
+ const EXECUTABLE_TOKEN = /^(?:[A-Za-z_][A-Za-z0-9_.+-]*|\.\/[A-Za-z0-9_.+\-/]+)$/;
837
+
816
838
  /** One backticked span that reads as a whole command: at least two shell words
817
- * and a first word that is an executable (no path separator, or a `./` one).
839
+ * and a first word that is an {@link EXECUTABLE_TOKEN}.
818
840
  * A span that names a single file or a config key is not a command claim. */
819
841
  function claimedCommand(span: string): boolean {
820
842
  const words = span.split(/\s+/).filter((word) => word.length > 0);
821
843
  if (words.length < 2) return false;
822
- const first = words[0] ?? "";
823
- return first.startsWith("./") || !first.includes("/");
844
+ return EXECUTABLE_TOKEN.test(words[0] ?? "");
824
845
  }
825
846
 
826
847
  /**
package/src/doctor.ts CHANGED
@@ -68,6 +68,7 @@ import {
68
68
  claimedTelegramTopics,
69
69
  lockPidAlive,
70
70
  pidAlive,
71
+ killOf,
71
72
  readTelegramChannel,
72
73
  readTelegramDmOwner,
73
74
  readTelegramPollState,
@@ -311,12 +312,11 @@ export interface DoctorDeps {
311
312
  * claim-only verdict checks only. */
312
313
  armScanDirs?: (project: ProjectConfig) => readonly string[];
313
314
  /** Whether a recorded claim or dm-owner pid is live, with omp-telegram's
314
- * topics.ts semantics (EPERM is dead). */
315
+ * topics.ts semantics (EPERM is dead). The one claim-liveness fact in this
316
+ * package: topic-pin's dead partition, its exact-pin check, project
317
+ * resolution and the telegram-plumbing verdict all derive from it, so two
318
+ * rows cannot answer one claim differently (#987). */
315
319
  pidAlive?: (pid: number) => boolean;
316
- /** Whether one claimed topic is live — the single fact `topic-pin` and
317
- * `telegram-plumbing` both read, so they cannot disagree about a claim
318
- * (#987). Defaults to {@link claimIsLive}. */
319
- claimIsLive?: (claim: { pid?: number }) => boolean;
320
320
  /** The provider allowance window nearest its ceiling, from `omp usage --json`
321
321
  * — what binds a subscription-billed fleet instead of a dollar cap (#984).
322
322
  * `undefined` when the provider reports no comparable window. */
@@ -1401,12 +1401,16 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
1401
1401
  `check omp-telegram's claim registry (threads.json in its state dir) is readable and the bridge is running, then re-run doctor — until then sends keep the pinned topic and degrade to the flat chat on a missing thread (#318)`,
1402
1402
  );
1403
1403
  }
1404
- // One liveness fact, shared with `telegram-plumbing` (#987). Before this the
1405
- // registry's rows were all treated as live here, so a pin aimed at a corpse
1406
- // read PASS while the plumbing row called the same claim dead.
1404
+ // One liveness fact for everything this probe decides the dead partition,
1405
+ // the exact-pin check below and identity resolution all read
1406
+ // `probes.pidAlive` through the same helper, so diverging injections cannot
1407
+ // split the rows' answer again (#987). Before #987 every registry row was
1408
+ // treated as live here, so a pin aimed at a corpse read PASS while the
1409
+ // plumbing row called the same claim dead.
1410
+ const isLive = (claim: { pid?: number }): boolean => claimIsLive(claim, killOf(probes.pidAlive));
1407
1411
  const all = result.claims;
1408
- const claims = all.filter((claim) => probes.claimIsLive(claim));
1409
- const dead = all.filter((claim) => !probes.claimIsLive(claim));
1412
+ const claims = all.filter(isLive);
1413
+ const dead = all.filter((claim) => !isLive(claim));
1410
1414
  // Named, never dropped: the row is the only index to the remote topic, so
1411
1415
  // deleting it strands the topic instead of cleaning it up.
1412
1416
  const deadNote =
@@ -1428,10 +1432,12 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
1428
1432
  `[${p.name}] pinned topic ${pinned} — no live claims to compare (the bridge has claimed no topics yet); the pin stands`,
1429
1433
  );
1430
1434
  }
1435
+ // `claims` is already the live partition, so a dead pinned row cannot win
1436
+ // this check (#987).
1431
1437
  if (claims.some((claim) => claim.threadId === pinned)) {
1432
1438
  return passFinding("topic-pin", `[${p.name}] pinned topic ${pinned} is a live claim${deadNote}`);
1433
1439
  }
1434
- const match = resolveProjectClaim(claims, p.name);
1440
+ const match = resolveProjectClaim(claims, p.name, probes.pidAlive);
1435
1441
  if (match.kind === "match") {
1436
1442
  return passFinding(
1437
1443
  "topic-pin",
@@ -2192,7 +2198,6 @@ export function defaultProbes(): Probes {
2192
2198
  return claimDir === cwdDir ? [cwdDir] : [cwdDir, claimDir];
2193
2199
  },
2194
2200
  pidAlive,
2195
- claimIsLive: (claim) => claimIsLive(claim),
2196
2201
  allowanceWindow: async () => bindingAllowanceWindow(await sharedUsageSource().read()),
2197
2202
  lockPidAlive,
2198
2203
  lockFresh: (mtimeMs) => Date.now() - mtimeMs < TELEGRAM_LOCK_FRESH_MS,
package/src/escalate.ts CHANGED
@@ -467,11 +467,13 @@ export interface ClaimedTopic {
467
467
  * own maintenance, and every topic-addressed send silently degrades to the main
468
468
  * chat: tier-2 pages, reports, digests, arm challenges, direct messages (#407).
469
469
  *
470
- * The operator's pin still wins whenever it is live. Only a pin that is
471
- * *provably* absent from the current claims is replaced, and only by the claim
472
- * this project can be identified with — so a deliberately separate alerts topic
473
- * is never hijacked by the pane's own thread. Unavailable bridge state changes
474
- * nothing, and #318's stale-topic retry remains the last line of defence.
470
+ * The operator's pin still wins whenever it is live. Only a pin that no *live*
471
+ * claim carries is replaced, and only by the claim this project can be
472
+ * identified with — so a deliberately separate alerts topic is never hijacked
473
+ * by the pane's own thread. A dead row wearing the pin id does not keep it in
474
+ * place: the row is the bridge's past, not a destination (#987). Unavailable
475
+ * bridge state changes nothing, and #318's stale-topic retry remains the last
476
+ * line of defence.
475
477
  *
476
478
  * Identity is read from the herdr space first, and only then from the claim's
477
479
  * title. The bridge titles a topic `ownAgentName ?? basename(cwd)`, and both
@@ -483,14 +485,19 @@ export interface ClaimedTopic {
483
485
  * The substitution is logged, naming the project and which identity answered.
484
486
  * Never an id: a log line is a place these leak from.
485
487
  */
486
- export function resolveProjectTopicId(project: ProjectConfig): number | undefined {
488
+ export function resolveProjectTopicId(project: ProjectConfig, alive?: (pid: number) => boolean): number | undefined {
487
489
  const pinned = project.escalation.telegramTopicId;
488
490
  if (pinned === undefined) return undefined;
489
491
  const result = claimedTelegramTopics();
490
492
  if (result.kind !== "ok" || result.claims.length === 0) return pinned;
491
493
  const claims = result.claims;
492
- if (claims.some((claim) => claim.threadId === pinned)) return pinned;
493
- const match = claimForProject(claims, project.name);
494
+ // The pin wins only while a *live* claim carries it; a dead pinned row falls
495
+ // through to the live-claim substitution below instead of winning the
496
+ // exact-pin check (#987).
497
+ if (claims.some((claim) => claim.threadId === pinned && claimIsLive(claim, alive === undefined ? undefined : killOf(alive)))) {
498
+ return pinned;
499
+ }
500
+ const match = claimForProject(claims, project.name, alive);
494
501
  if (match === undefined) return pinned;
495
502
  warn(
496
503
  `escalation.telegramTopicId for ${project.name} is no longer a claimed topic; ` +
@@ -519,25 +526,32 @@ export type ProjectClaim =
519
526
  * How the live claims answer to a project, identity spellings in order:
520
527
  * unique herdr space, then unique title.
521
528
  *
522
- * Ambiguity is not a coin toss: paging the wrong project's topic is worse
523
- * than the flat-chat degrade #318 already handles, and scanning the wrong
524
- * pane's session misses the reply entirely. The title is a fallback for
525
- * bridges that never captured a space, so it runs only when *no* claim
526
- * carries the project's space several claims wearing the space is an
527
- * ambiguity the title cannot resolve, because the titled claim may be a
528
- * sibling pane, not this project (#626).
529
+ * Only live claims take part ({@link claimIsLive}): a dead row neither
530
+ * answers on its own nor turns this project's one live pane into an
531
+ * ambiguity (#987). Ambiguity is not a coin toss: paging the wrong project's
532
+ * topic is worse than the flat-chat degrade #318 already handles, and
533
+ * scanning the wrong pane's session misses the reply entirely. The title is
534
+ * a fallback for bridges that never captured a space, so it runs only when
535
+ * *no* live claim carries the project's space — several live claims wearing
536
+ * the space is an ambiguity the title cannot resolve, because the titled
537
+ * claim may be a sibling pane, not this project (#626).
529
538
  */
530
539
  export function resolveProjectClaim(
531
540
  claims: readonly ClaimedTopic[],
532
541
  projectName: string,
542
+ alive?: (pid: number) => boolean,
533
543
  ): ProjectClaim {
534
- const bySpace = claims.filter((claim) => claim.workspaceLabel === projectName);
544
+ // Dead claims do not vote: a closed pane's row must not turn this project's
545
+ // one live claim into an ambiguity, and must not answer by title on its own
546
+ // (#987). Ambiguity among genuinely live claims is unchanged (#626).
547
+ const live = claims.filter((claim) => claimIsLive(claim, alive === undefined ? undefined : killOf(alive)));
548
+ const bySpace = live.filter((claim) => claim.workspaceLabel === projectName);
535
549
  if (bySpace.length > 0) {
536
550
  return bySpace.length === 1
537
551
  ? { kind: "match", claim: bySpace[0]! }
538
552
  : { kind: "ambiguous", claimants: bySpace };
539
553
  }
540
- const byTitle = claims.filter((claim) => claim.name === projectName);
554
+ const byTitle = live.filter((claim) => claim.name === projectName);
541
555
  if (byTitle.length === 0) return { kind: "none" };
542
556
  return byTitle.length === 1
543
557
  ? { kind: "match", claim: byTitle[0]! }
@@ -556,8 +570,9 @@ export function resolveProjectClaim(
556
570
  export function claimForProject(
557
571
  claims: readonly ClaimedTopic[],
558
572
  projectName: string,
573
+ alive?: (pid: number) => boolean,
559
574
  ): ClaimedTopic | undefined {
560
- const match = resolveProjectClaim(claims, projectName);
575
+ const match = resolveProjectClaim(claims, projectName, alive);
561
576
  return match.kind === "match" ? match.claim : undefined;
562
577
  }
563
578
 
@@ -574,10 +589,13 @@ export function claimForProject(
574
589
  * the claim names no file — the caller then falls back to the cwd-derived
575
590
  * directory, which is the honest answer for a host that predates claims.
576
591
  */
577
- export function resolveClaimedSessionFile(project: ProjectConfig): string | undefined {
592
+ export function resolveClaimedSessionFile(
593
+ project: ProjectConfig,
594
+ alive?: (pid: number) => boolean,
595
+ ): string | undefined {
578
596
  const result = claimedTelegramTopics();
579
597
  if (result.kind !== "ok" || result.claims.length === 0) return undefined;
580
- return claimForProject(result.claims, project.name)?.sessionFile;
598
+ return claimForProject(result.claims, project.name, alive)?.sessionFile;
581
599
  }
582
600
 
583
601
  /**
@@ -1111,7 +1129,7 @@ export type TelegramPlumbingScan = { dirs: readonly string[] };
1111
1129
  * Throwing is how `pidAlive` spells "dead", which is the contract it applies to
1112
1130
  * whatever this returns.
1113
1131
  */
1114
- function killOf(alive: (pid: number) => boolean): (target: number, signal: number) => void {
1132
+ export function killOf(alive: (pid: number) => boolean): (target: number, signal: number) => void {
1115
1133
  return (target) => {
1116
1134
  if (!alive(target)) throw new Error("not alive");
1117
1135
  };
@@ -62,6 +62,16 @@ const INFRA_CHECK_STATES: Record<string, true> = {
62
62
  /** States that mean "this check has a verdict and it is good". */
63
63
  const SUCCESS_CHECK_STATES: Record<string, true> = { success: true, neutral: true };
64
64
 
65
+ /**
66
+ * The runner's own sentence for "the agent under this job went away": written
67
+ * when the runner service is stopped, auto-updates, or is torn down under a
68
+ * live job. Named once because two different accountings quote it — the
69
+ * implementation-attempt waiver below ({@link INFRA_LOG_SIGNATURES}, #177) and
70
+ * the review-correction waiver ({@link runnerInfraFailure}) — and a second
71
+ * spelling of it would waive one while charging the other.
72
+ */
73
+ const RUNNER_SHUTDOWN_SIGNAL = "the runner has received a shutdown signal";
74
+
65
75
  /**
66
76
  * Substrings in a failed check's log that prove the failure was infrastructure,
67
77
  * not the diff (#177). Each is a registry/docker/runner fault a worker cannot
@@ -87,7 +97,7 @@ const INFRA_LOG_SIGNATURES = [
87
97
  // the runner's, and must not be waived (#637, #639).
88
98
  "response status code does not indicate success: 429 (too many requests)",
89
99
  "failed to resolve source metadata for",
90
- "the runner has received a shutdown signal",
100
+ RUNNER_SHUTDOWN_SIGNAL,
91
101
  "could not resolve host",
92
102
  ];
93
103
 
@@ -103,6 +113,70 @@ export function infraLogSignature(log: string): string | undefined {
103
113
  return INFRA_LOG_SIGNATURES.find((signature) => lower.includes(signature));
104
114
  }
105
115
 
116
+ /**
117
+ * Runner-infrastructure wordings: a red check whose own text says the *runner*
118
+ * failed, not the diff (Phase 3, #1043 lane). Matched lowercased as substrings
119
+ * against the failing job's log — the same surface {@link infraLogSignature}
120
+ * reads — and each entry is the real GitHub Actions sentence, verified against
121
+ * the runner's own reports rather than invented:
122
+ *
123
+ * Kept as its own list rather than folded into {@link INFRA_LOG_SIGNATURES}
124
+ * for two reasons. The consumers differ: that list waives an *implementation
125
+ * attempt* for a settled run (#177), this one waives a *correction round* for
126
+ * a live pull request's red check. And {@link infraSignatureVersion} is a
127
+ * persisted cursor fingerprint — growing that list restarts the historical
128
+ * reconciliation, which recognising a runner-lost red has no business doing.
129
+ * The one overlap, {@link RUNNER_SHUTDOWN_SIGNAL}, is shared by reference so
130
+ * the two accountings can never disagree about that wording.
131
+ */
132
+ const RUNNER_INFRA_SIGNATURES = [
133
+ // "The self-hosted runner: <name> lost communication with the server." and
134
+ // its hosted sibling "The hosted runner: <name> lost communication with the
135
+ // server." — the runner process was killed, starved or cut off mid-job
136
+ // (actions/runner#3539, community#84877, community#173431). The name varies,
137
+ // so only the invariant tail is matched.
138
+ "lost communication with the server",
139
+ // The runner service was stopped or auto-updated under the job: an
140
+ // infrastructure cancellation, not a verdict on the diff.
141
+ RUNNER_SHUTDOWN_SIGNAL,
142
+ // "The job was not acquired by Runner of type hosted even after multiple
143
+ // attempts" — the job never reached a runner at all, so nothing in the diff
144
+ // was ever executed (community#186216, community#165287).
145
+ "was not acquired by runner",
146
+ // "The hosted runner encountered an error while running your job. (Error
147
+ // Type: Failure)." — GitHub's own statement that its runner broke
148
+ // (community#126539). The parenthesised error type varies; the sentence does
149
+ // not.
150
+ "the hosted runner encountered an error while running your job",
151
+ ];
152
+
153
+ /**
154
+ * The runner-infrastructure wording a red CI check's text carries, or
155
+ * `undefined` when it carries none.
156
+ *
157
+ * Exists so the review-correction accounting can decline to charge a
158
+ * correction round for a red that the worker's diff did not cause: a lost
159
+ * runner, a runner shut down under the job, or a job no runner ever picked up.
160
+ * The return value is the matched wording, so whatever waives a round can say
161
+ * *which* sentence waived it instead of asserting "infra" unexplained.
162
+ *
163
+ * Fails closed in both directions, and deliberately narrower than it could be:
164
+ *
165
+ * - Unclassifiable, empty or unread text is `undefined` — an unexplained red
166
+ * still counts, and a log-fetch failure hands this function nothing to match,
167
+ * so it never waives.
168
+ * - "The operation was canceled." is *not* here: it is what a failed `needs:`
169
+ * dependency and an operator's own cancel both print, so matching it would
170
+ * waive rounds for ordinary red.
171
+ * - "…has exceeded the maximum execution time of N minutes" is *not* here
172
+ * either: a job that ran until the ceiling is usually a hanging test or an
173
+ * infinite loop, which is exactly the diff's problem to fix.
174
+ */
175
+ export function runnerInfraFailure(log: string): string | undefined {
176
+ const lower = log.toLowerCase();
177
+ return RUNNER_INFRA_SIGNATURES.find((signature) => lower.includes(signature));
178
+ }
179
+
106
180
  /**
107
181
  * The cap kills that produced nothing: the worker reached a ceiling with no PR,
108
182
  * no observed head and no salvage commit.