omp-conductor 0.18.0 → 0.18.2

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 (65) hide show
  1. package/README.md +35 -1
  2. package/REFERENCE.md +61 -11
  3. package/agents/to-spec.md +94 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +35 -1
  6. package/src/admission.ts +204 -75
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +268 -7
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +62 -21
  11. package/src/briefs/to-spec.md +88 -0
  12. package/src/briefs/worker.md +2 -1
  13. package/src/cli.ts +124 -1
  14. package/src/command-help.ts +11 -0
  15. package/src/command-manifest.ts +38 -5
  16. package/src/commands/arm.ts +1 -1
  17. package/src/commands/context.ts +1 -0
  18. package/src/commands/drain.ts +176 -0
  19. package/src/commands/extend.ts +6 -10
  20. package/src/commands/intake.ts +4 -19
  21. package/src/commands/status.ts +5 -1
  22. package/src/commands/watch.ts +51 -16
  23. package/src/commands/worker.ts +9 -10
  24. package/src/config-schema.ts +43 -6
  25. package/src/config.ts +65 -9
  26. package/src/daemon.ts +879 -41
  27. package/src/dashboard/app.js +4 -1
  28. package/src/dashboard/server.ts +5 -2
  29. package/src/decisions.ts +243 -17
  30. package/src/diff-flags.ts +75 -1
  31. package/src/doctor.ts +60 -82
  32. package/src/escalate.ts +31 -14
  33. package/src/failure-class.ts +28 -2
  34. package/src/fleet.ts +239 -240
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +35 -1
  37. package/src/graph.ts +66 -1
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +242 -2
  40. package/src/lifecycle.ts +122 -1
  41. package/src/omp-settings.ts +19 -0
  42. package/src/omp.ts +183 -21
  43. package/src/orchestrator-tick.ts +1591 -32
  44. package/src/orchestrator.ts +12 -0
  45. package/src/privileged.ts +1 -4
  46. package/src/release-policy.ts +503 -9
  47. package/src/session-host.ts +65 -6
  48. package/src/settlement.ts +69 -17
  49. package/src/setup-host.ts +1225 -9
  50. package/src/setup-install.ts +28 -0
  51. package/src/setup-wizard.ts +154 -3
  52. package/src/setup.ts +83 -17
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +216 -12
  55. package/src/store.ts +443 -42
  56. package/src/to-spec.ts +408 -0
  57. package/src/tracker/github.ts +104 -14
  58. package/src/types.ts +405 -19
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +765 -56
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +12 -2
  65. package/src/worktree.ts +29 -12
package/src/doctor.ts CHANGED
@@ -46,9 +46,11 @@ import {
46
46
  dbBackupDirFor,
47
47
  findProject,
48
48
  loadConfig,
49
+ resolveArmProof,
49
50
  resolveCaps,
50
51
  stateDir,
51
52
  } from "./config.ts";
53
+ import { observeArmChallenge } from "./arm-challenge.ts";
52
54
  import { ompSettingsOverlay, sessionRootDir } from "./omp-settings.ts";
53
55
  import {
54
56
  DEFAULT_HERDR_UNIT,
@@ -98,7 +100,7 @@ import {
98
100
  type HerdrAgentList,
99
101
  } from "./orchestrator-tick.ts";
100
102
  import type { ConductorConfig, ProjectConfig, RepoTarget, RunState } from "./types.ts";
101
-
103
+ import { DEFAULT_ARM_PROOF, type ArmProof } from "./types.ts";
102
104
  /**
103
105
  * `doctor`'s finding vocabulary. `pass`/`warn`/`fail` — a warning renders the
104
106
  * same but never flips the exit code; exit is nonzero iff any finding fails.
@@ -142,6 +144,10 @@ const INCIDENTS = {
142
144
  spend:
143
145
  "spend telemetry was once absent, so the USD cap never fired — $0.00 spend is not proof of no spend",
144
146
  ghauth: "gh auth expired under a live daemon and every tracker call failed silently",
147
+ workerAcl:
148
+ "the #835 incident: a setup host granted the worker's path ACLs before restarting the fleet, an OMP startup chmod'd " +
149
+ "the agent config dir back to 0700 and rewrote the ACL mask, and the next two admitted workers died on EACCES " +
150
+ "before connecting — the named ACL entry was still there, only its effective permissions were gone",
145
151
  } as const;
146
152
 
147
153
  // ------------------------------------------------------------------ dependencies
@@ -249,8 +255,10 @@ export interface DoctorDeps {
249
255
  /** The topic id an arm challenge would send into for this project —
250
256
  * resolveProjectTopicId's pin-or-live-claim; undefined means flat chat. */
251
257
  armSendTopic?: (project: ProjectConfig) => number | undefined;
252
- /** The session directories arm scans for the challenge reply on this
253
- * project — tick-cwd-derived plus the live claim's own directory. */
258
+ /** The session surface the claim-only plumbing verdict judges for this
259
+ * project — tick-cwd-derived plus the live claim's own directory. The
260
+ * challenge proof reads conductor state instead (#614), so this feeds
261
+ * claim-only verdict checks only. */
254
262
  armScanDirs?: (project: ProjectConfig) => readonly string[];
255
263
  /** Whether a recorded claim or dm-owner pid is live, with omp-telegram's
256
264
  * topics.ts semantics (EPERM is dead). */
@@ -748,6 +756,12 @@ function unresolvableShell(unit: string): boolean {
748
756
  * state resolved at staging time, so a rendered value doctor cannot reproduce
749
757
  * is excluded from both sides rather than reported as a difference — an
750
758
  * unresolvable canonical is "cannot compare", never drift (#511).
759
+ *
760
+ * The service `PATH=` line has no such exemption: since #879 it is canonical
761
+ * on both sides (`defaultServiceRuntime` composes it from the resolved fleet
762
+ * binaries plus the standard system directories, never from the rendering
763
+ * process's environment), so a differing installed value is genuine drift and
764
+ * must fail here with the value named.
751
765
  */
752
766
  export function unitDrift(installed: string, canonical: string): { differences: string[] } {
753
767
  const want = canonical
@@ -902,6 +916,7 @@ function ownershipProbe(probes: Probes): Finding {
902
916
  return failFinding("systemd-ownership", summary, fix);
903
917
  }
904
918
 
919
+
905
920
  /** IANA timezone in reporting config — an invalid zone silently mis-schedules
906
921
  * the availability window and the daily digest (#273). */
907
922
  function timezoneProbe(project: ProjectConfig | undefined): Finding {
@@ -1128,91 +1143,43 @@ function herdrAgentNameProbe(probes: Probes, p: ProjectConfig): Finding {
1128
1143
  }
1129
1144
 
1130
1145
  /**
1131
- * #600 check — the orchestrator's claimed session file vs the directory arm
1132
- * scans.
1146
+ * #614 check — the state of this project's arming acknowledgement transaction.
1133
1147
  *
1134
- * A pane resumed from a session created elsewhere keeps the original transcript
1135
- * path (herdr pins the pane to it), so the claimed file can live outside the
1136
- * session directory the tick cwd implies. arm follows the claim, so the
1137
- * mismatch alone is handled but it is exactly the shape of host that once
1138
- * made arming silently impossible, and an operator reading transcript paths
1139
- * should not have to rediscover it. A claimed file *outside the session tree*
1140
- * is worse: arm refuses to arm there (the reply could never be seen), so
1141
- * doctor fails rather than letting the fleet sit disarmed.
1148
+ * The challenge proof is conductor-owned state now: the host records a pending
1149
+ * challenge and waits for the acknowledgement the orchestrator's inbound
1150
+ * adapter writes. No transcript location is diagnosed any more where (or
1151
+ * whether) a session file lives is not part of arming so what is worth
1152
+ * reporting is a transaction left open: a challenge still pending and whether
1153
+ * it has been acknowledged, or an expired record nothing settled. Every shape
1154
+ * is inert by construction (a reply past expiry, or for a replaced id, is
1155
+ * refused), so this informs; only the stale leftover warns.
1142
1156
  */
1143
- function armSessionDirProbe(probes: Probes, p: ProjectConfig): Finding {
1144
- const result = probes.claimedTopics();
1145
- if (result.kind !== "ok") {
1146
- // The registry could not be read, so the claim's session file is unknown,
1147
- // not known-absent. Arm scans the tick-cwd directory either way, but the
1148
- // finding must stay diagnostic — this is exactly the shape that once made
1149
- // arming silently impossible, and "no claim" would certify it as fine. A
1150
- // missing registry is the same unknown to this probe: the bridge may never
1151
- // have claimed, but nothing proves the claim names no session file.
1157
+ function armAckProbe(probes: Probes, p: ProjectConfig): Finding {
1158
+ const now = probes.now();
1159
+ const sighting = observeArmChallenge(p.name);
1160
+ if (sighting === undefined) {
1161
+ return passFinding("arm-ack", `[${p.name}] no pending arm challenge the acknowledgement handshake is idle`);
1162
+ }
1163
+ const window =
1164
+ sighting.sentAt === undefined || sighting.expiresAt === undefined
1165
+ ? "an unreadable record window"
1166
+ : `sent ${new Date(sighting.sentAt).toISOString()}, expires ${new Date(sighting.expiresAt).toISOString()}`;
1167
+ if (sighting.expiresAt !== undefined && now >= sighting.expiresAt) {
1152
1168
  return warnFinding(
1153
- "arm-session-dir",
1154
- `[${p.name}] ` +
1155
- (result.kind === "missing"
1156
- ? `omp-telegram has no claim registry (threads.json in its state dir) — arm scans the tick-cwd session directory, but doctor cannot tell whether the claim names a session file elsewhere`
1157
- : `omp-telegram's claim registry is unreadable (${result.problem}) — arm scans the tick-cwd session directory, but doctor cannot tell whether the claim names a session file elsewhere`),
1158
- `check omp-telegram's claim registry (threads.json in its state dir) is readable and the bridge is running, then re-run doctor`,
1169
+ "arm-ack",
1170
+ `[${p.name}] an arming challenge from ${window} expired without being settled — it is inert (replies past expiry are refused), but it lingers until this project's next arm replaces it`,
1171
+ `no action needed; the next \`arm\` for this project settles it — re-run doctor afterwards to confirm`,
1159
1172
  );
1160
1173
  }
1161
- if (result.claims.length === 0) {
1174
+ if (sighting.acknowledgedAt !== undefined) {
1162
1175
  return passFinding(
1163
- "arm-session-dir",
1164
- `[${p.name}] no live omp-telegram claim arm scans the tick-cwd session directory`,
1165
- );
1166
- }
1167
- const claims = result.claims;
1168
- const match = resolveProjectClaim(claims, p.name);
1169
- const scanDir = sessionDirForCwd(tickCwdForProject(p));
1170
- if (match.kind === "ambiguous") {
1171
- // Several live claims answer to the project, so the claim's session file
1172
- // cannot be told from a sibling pane's. Arm scans the tick-cwd directory
1173
- // either way, but certifying that fallback as the scan is the miss this
1174
- // probe exists to surface: the resumed transcript may live under another
1175
- // claim while doctor reads the fallback as the whole story (#626).
1176
- return warnFinding(
1177
- "arm-session-dir",
1178
- `[${p.name}] several live claims answer to this project (topics ${match.claimants.map((c) => c.threadId).join(", ")}), so the claim's session file is a coin toss — arm scans the tick-cwd session directory, but the resumed transcript may live elsewhere`,
1179
- `make exactly one live claim answer to this project: rename the other panes' herdr spaces or re-claim them one at a time, then re-run doctor — or start the orchestrator pane's session from the tick cwd ${scanDir}`,
1180
- );
1181
- }
1182
- const claim = match.kind === "match" ? match.claim : undefined;
1183
- if (claim === undefined) {
1184
- return passFinding(
1185
- "arm-session-dir",
1186
- `[${p.name}] no live omp-telegram claim answers to this project — arm scans the tick-cwd session directory`,
1187
- );
1188
- }
1189
- if (claim.sessionFile === undefined) {
1190
- return passFinding(
1191
- "arm-session-dir",
1192
- `[${p.name}] the live claim names no session file — arm scans the tick-cwd session directory`,
1193
- );
1194
- }
1195
- const root = sessionsRoot();
1196
- const claimDir = dirname(claim.sessionFile);
1197
- if (claimDir !== root && !claimDir.startsWith(join(root, sep))) {
1198
- return failFinding(
1199
- "arm-session-dir",
1200
- `[${p.name}] the orchestrator's claimed session file ${claim.sessionFile} is outside the session tree arm scans (${root}) — ` +
1201
- `arm cannot arm while the reply can only land there`,
1202
- `start the orchestrator pane's session under ${root}, or from the tick cwd ${scanDir}`,
1203
- );
1204
- }
1205
- if (claimDir !== scanDir) {
1206
- return warnFinding(
1207
- "arm-session-dir",
1208
- `[${p.name}] the orchestrator's claimed session file ${claim.sessionFile} lives in ${claimDir}, not the tick-cwd session directory ${scanDir} — ` +
1209
- `arm follows the claim, so arming works, but this is the resumed-pane shape #600 names`,
1210
- `optional tidy-up: start the pane's session from the tick cwd so transcript and cwd agree again`,
1176
+ "arm-ack",
1177
+ `[${p.name}] an arm challenge is pending (${window}) and was acknowledged ${new Date(sighting.acknowledgedAt).toISOString()} — the host consumes it on settle`,
1211
1178
  );
1212
1179
  }
1213
1180
  return passFinding(
1214
- "arm-session-dir",
1215
- `[${p.name}] the orchestrator's claimed session file is inside the session directory arm scans`,
1181
+ "arm-ack",
1182
+ `[${p.name}] an arm challenge is pending (${window}), not yet acknowledged live if \`arm\` is running right now, otherwise its window simply runs out`,
1216
1183
  );
1217
1184
  }
1218
1185
 
@@ -1334,9 +1301,20 @@ function telegramPlumbingProbe(probes: Probes, p: ProjectConfig): Finding {
1334
1301
  `[${p.name}] outbound-only paging — no tick accessFile to arm, so no live claim, DM owner or poll ownership is required`,
1335
1302
  );
1336
1303
  }
1304
+ // Transcript-surface identity checks belong to the claim-only proof alone:
1305
+ // since #614 the challenge proof reads conductor acknowledgement state, so
1306
+ // judging it by where a transcript lives could report arming impossible
1307
+ // while `arm` succeeds. An unreadable config keeps the default proof —
1308
+ // fail-safe to the stricter verdict, exactly like armTicks.
1309
+ let proof: ArmProof = DEFAULT_ARM_PROOF;
1310
+ try {
1311
+ proof = resolveArmProof(p);
1312
+ } catch {
1313
+ /* unreadable config — keep today's stricter default */
1314
+ }
1337
1315
  const verdict = telegramPlumbingVerdict(
1338
1316
  probes.armSendTopic(p),
1339
- { dirs: probes.armScanDirs(p) },
1317
+ proof === "claim-only" ? { dirs: probes.armScanDirs(p) } : undefined,
1340
1318
  {
1341
1319
  channel: probes.channelState(p),
1342
1320
  registry: probes.claimedTopics(),
@@ -1618,7 +1596,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1618
1596
  passFinding("herdr-agent-name", projectProblem === undefined ? "no project resolved — nothing to check" : `agent name uncheckable: ${projectProblem}`),
1619
1597
  );
1620
1598
  findings.push(
1621
- passFinding("arm-session-dir", projectProblem === undefined ? "no project resolved — nothing to check" : `arm session dir uncheckable: ${projectProblem}`),
1599
+ passFinding("arm-ack", projectProblem === undefined ? "no project resolved — nothing to check" : `arm acknowledgement uncheckable: ${projectProblem}`),
1622
1600
  );
1623
1601
  findings.push(
1624
1602
  passFinding("topic-pin", projectProblem === undefined ? "no project resolved — nothing to check" : `topic pin uncheckable: ${projectProblem}`),
@@ -1633,7 +1611,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1633
1611
  for (const p of projects) {
1634
1612
  findings.push(await labelProbe(probes, p));
1635
1613
  findings.push(herdrAgentNameProbe(probes, p));
1636
- findings.push(armSessionDirProbe(probes, p));
1614
+ findings.push(armAckProbe(probes, p));
1637
1615
  findings.push(topicPinProbe(probes, p));
1638
1616
  findings.push(telegramPlumbingProbe(probes, p));
1639
1617
  findings.push(ompSettingsProbe(p, sessionRootState));
package/src/escalate.ts CHANGED
@@ -403,11 +403,17 @@ function errText(e: unknown): string {
403
403
  *
404
404
  * Exported for the report outbox (#123), which pages over the same bot and must
405
405
  * resolve the token the same way. Two readers of one `.env` is fine; two
406
- * *implementations* of the parse below is how they drift.
406
+ * *implementations* of the parse below is how they drift. The state dir is the
407
+ * shared resolution both the token read and the interactive ask surface (#722)
408
+ * need, so it is one function rather than a second copy of the override.
407
409
  */
408
- export function readTelegramToken(): string | undefined {
410
+ export function telegramStateDir(): string {
409
411
  const override = process.env.OMP_TELEGRAM_STATE_DIR?.trim();
410
- const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
412
+ return override ? override : join(homedir(), ".omp", "agent", "telegram");
413
+ }
414
+
415
+ export function readTelegramToken(): string | undefined {
416
+ const dir = telegramStateDir();
411
417
  let raw: string;
412
418
  try {
413
419
  raw = readFileSync(join(dir, ".env"), "utf8");
@@ -1034,13 +1040,14 @@ export interface TelegramPlumbingProbe {
1034
1040
  }
1035
1041
 
1036
1042
  /**
1037
- * The session surface the arm waiter actually scans for the challenge reply:
1038
- * {@link armTicks} polls every `.jsonl` transcript *directly inside* these
1039
- * directories the tick-cwd-derived session directory plus, when the
1040
- * project's claim names a session file elsewhere, that file's directory. The
1041
- * recipient of the reply (a claim's, the DM owner's, or the local poll
1042
- * owner's session file) must land in this set, or the reply can never satisfy
1043
- * the challenge even though inbound works.
1043
+ * The transcript-surface input for {@link telegramPlumbingVerdict}'s session
1044
+ * identity checks: the directories the claim-only proof judges recipients
1045
+ * against (a claim's, the DM owner's, or the local poll owner's session file
1046
+ * must land inside, or a flat reply could land where no challenge would have
1047
+ * looked). Since #614 the *challenge* proof reads conductor acknowledgement
1048
+ * state and never opens a transcript, so callers judging challenge-mode
1049
+ * plumbing omit this passing it there could report arming impossible while
1050
+ * `arm` succeeds.
1044
1051
  */
1045
1052
  export type TelegramPlumbingScan = { dirs: readonly string[] };
1046
1053
 
@@ -1068,7 +1075,7 @@ export type TelegramPlumbingScan = { dirs: readonly string[] };
1068
1075
  */
1069
1076
  export function telegramPlumbingVerdict(
1070
1077
  sendTopic: number | undefined,
1071
- scan: TelegramPlumbingScan,
1078
+ scan: TelegramPlumbingScan | undefined,
1072
1079
  probe: TelegramPlumbingProbe,
1073
1080
  ): TelegramPlumbingVerdict {
1074
1081
  const { registry, poll, dmOwner, channel, alive, lockAlive, fresh } = probe;
@@ -1079,14 +1086,21 @@ export function telegramPlumbingVerdict(
1079
1086
  const claim = registry.claims.find((c) => c.threadId === sendTopic);
1080
1087
  if (claim === undefined) return { ok: false, reason: "no-topic-claim" };
1081
1088
  if (claim.pid === undefined || !alive(claim.pid)) return { ok: false, reason: "claim-dead" };
1082
- if (claim.sessionFile !== undefined && !sessionWithinScan(claim.sessionFile, scan.dirs)) {
1089
+ if (
1090
+ scan !== undefined &&
1091
+ claim.sessionFile !== undefined &&
1092
+ !sessionWithinScan(claim.sessionFile, scan.dirs)
1093
+ ) {
1083
1094
  return { ok: false, reason: "claim-session-outside" };
1084
1095
  }
1085
1096
  recipient = "claim";
1086
1097
  } else {
1087
1098
  if (dmOwner.kind === "ok") {
1088
1099
  if (dmOwner.owner.pid === undefined || !alive(dmOwner.owner.pid)) return { ok: false, reason: "dm-owner-dead" };
1089
- if (dmOwner.owner.sessionFile === undefined || !sessionWithinScan(dmOwner.owner.sessionFile, scan.dirs)) {
1100
+ if (
1101
+ scan !== undefined &&
1102
+ (dmOwner.owner.sessionFile === undefined || !sessionWithinScan(dmOwner.owner.sessionFile, scan.dirs))
1103
+ ) {
1090
1104
  return { ok: false, reason: "dm-owner-unrelated" };
1091
1105
  }
1092
1106
  recipient = "dm-owner";
@@ -1096,7 +1110,10 @@ export function telegramPlumbingVerdict(
1096
1110
  // record must name that session. A daemon-only or unrelated owner is no
1097
1111
  // recipient: the reply would land in a transcript arm never scans.
1098
1112
  if (poll.kind !== "ok") return { ok: false, reason: "no-dm-owner" };
1099
- if (poll.owner.sessionFile === undefined || !sessionWithinScan(poll.owner.sessionFile, scan.dirs)) {
1113
+ if (
1114
+ scan !== undefined &&
1115
+ (poll.owner.sessionFile === undefined || !sessionWithinScan(poll.owner.sessionFile, scan.dirs))
1116
+ ) {
1100
1117
  return { ok: false, reason: "no-dm-owner" };
1101
1118
  }
1102
1119
  recipient = "poller";
@@ -17,9 +17,14 @@
17
17
 
18
18
  import type { Caps, FailureClass, RecoveryAction, RunRecord } from "./types.ts";
19
19
 
20
- /** Facts the caller fetched, each only for the rows that need it. */
20
+ /** Facts the caller fetched, each only for the rows that need it.
21
+ *
22
+ * `pr` can also be `"missing"`: the tracker has proven the claimed PR
23
+ * definitively does not exist (#779). It is a fact, the same way `"closed"`
24
+ * is — a REST 404 the adapter corroborated with a same-repository pulls-list
25
+ * read — never a transient "could not tell", which stays `undefined`. */
21
26
  export interface ClassifyFacts {
22
- pr?: "open" | "merged" | "closed";
27
+ pr?: "open" | "merged" | "closed" | "missing";
23
28
  mergeable?: "conflicting" | "clean" | "unknown";
24
29
  checks?: { name: string; state: string; link?: string }[];
25
30
  /** Full session error recovered from the transcript. Kept as a fact so a
@@ -142,6 +147,12 @@ const START_FAILURE_SIGNATURES = [
142
147
  "invalid api key",
143
148
  "authentication failed",
144
149
  "could not load its peer dependency",
150
+ // The pre-launch identity gate (#798/#828): the account is missing, or the
151
+ // harness binding a worker resolves through is not established. The daemon
152
+ // refuses before it spawns anything, so no attempt was spent — and the
153
+ // symptom this replaced (the child dying on the peer import) already read as
154
+ // a start failure, so charging one here would be a regression dressed as a fix.
155
+ "worker identity unavailable",
145
156
  ] as const;
146
157
 
147
158
  /**
@@ -512,6 +523,21 @@ export function classifyRun(
512
523
  if (run.state === "failed" && facts.pr === "open") {
513
524
  const checks = facts.checks ?? [];
514
525
  const unresolved = checks.filter((c) => !SUCCESS_CHECK_STATES[normalise(c.state)] === true);
526
+ if (checks.length > 0 && unresolved.length === 0) {
527
+ // The PR is open and every check is green: the strongest mechanical
528
+ // success evidence there is short of merge. The row's `failed` state is
529
+ // stale — whatever the worker recorded, its work is passing — so this is
530
+ // not a failure of any class. `none` recovery restores it to
531
+ // `pushed-green` in classifyAndRecover instead of escalating an
532
+ // `[unknown]` that charges an attempt (#766). An empty check list is
533
+ // "could not tell", not all-green, and still falls through to `unknown`
534
+ // below.
535
+ return {
536
+ cls: "unknown",
537
+ recovery: "none",
538
+ evidence: `${run.prUrl ?? "the PR"} is open and every check is green`,
539
+ };
540
+ }
515
541
  if (checks.length > 0 && unresolved.length > 0) {
516
542
  // A failed check whose *log* smells like infrastructure — a registry 429,
517
543
  // a runner shutdown, a DNS failure (#177). The check has a verdict, so