omp-conductor 0.4.2 → 0.4.3

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.
@@ -381,10 +381,104 @@ export function parentNumberFrom(raw: string): number | undefined {
381
381
  return n;
382
382
  }
383
383
 
384
+ /**
385
+ * Parse `gh pr checks --json name,state,link`.
386
+ *
387
+ * Tolerant on purpose: a payload shape this build does not recognise yields an
388
+ * empty list, which the classifier reads as "could not tell" — the same posture
389
+ * every other reader in this adapter takes.
390
+ */
391
+ export function checkConclusionsFrom(raw: string): { name: string; state: string; link?: string }[] {
392
+ let parsed: unknown;
393
+ try {
394
+ parsed = JSON.parse(raw) as unknown;
395
+ } catch {
396
+ return [];
397
+ }
398
+ if (!Array.isArray(parsed)) return [];
399
+ const checks: { name: string; state: string; link?: string }[] = [];
400
+ for (const entry of parsed) {
401
+ if (entry === null || typeof entry !== "object") continue;
402
+ const row = entry as { readonly [key: string]: unknown };
403
+ const name = row["name"];
404
+ const state = row["state"];
405
+ if (typeof name !== "string" || typeof state !== "string") continue;
406
+ const link = row["link"];
407
+ checks.push({
408
+ name,
409
+ state,
410
+ ...(typeof link === "string" && link.length > 0 ? { link } : {}),
411
+ });
412
+ }
413
+ return checks;
414
+ }
415
+
416
+ /** GitHub's mergeability spelling, fail-open to `unknown` on anything else. */
417
+ export function mergeableFrom(raw: string): "conflicting" | "clean" | "unknown" {
418
+ switch (raw.trim().replaceAll('"', "")) {
419
+ case "CONFLICTING":
420
+ return "conflicting";
421
+ case "MERGEABLE":
422
+ return "clean";
423
+ default:
424
+ // `UNKNOWN` is GitHub still computing the merge, and it is common on a PR
425
+ // pushed seconds ago. Reading it as clean would let a conflict recovery
426
+ // fire on a PR nobody has assessed yet.
427
+ return "unknown";
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Distinct workflow-run ids behind the non-success checks of a PR.
433
+ *
434
+ * Extracted from each check's `link` because that is the only run identifier
435
+ * `gh pr checks` reports. Deduplicated: one workflow run usually backs several
436
+ * checks, and re-running it once is the whole point.
437
+ */
438
+ export function failedRunIds(
439
+ checks: readonly { name: string; state: string; link?: string }[],
440
+ ): string[] {
441
+ const ids: string[] = [];
442
+ for (const check of checks) {
443
+ const state = check.state.trim().toLowerCase();
444
+ if (state === "success" || state === "neutral") continue;
445
+ const match = /\/actions\/runs\/(\d+)\//.exec(check.link ?? "");
446
+ const id = match?.[1];
447
+ if (id !== undefined && !ids.includes(id)) ids.push(id);
448
+ }
449
+ return ids;
450
+ }
451
+
452
+ /** Parse `[{number, state}]` from `gh issue list` or the sub-issues API. */
453
+ export function labeledIssuesFrom(raw: string): { number: number; state: IssueState }[] {
454
+ let parsed: unknown;
455
+ try {
456
+ parsed = JSON.parse(raw) as unknown;
457
+ } catch {
458
+ return [];
459
+ }
460
+ if (!Array.isArray(parsed)) return [];
461
+ const issues: { number: number; state: IssueState }[] = [];
462
+ for (const entry of parsed) {
463
+ if (entry === null || typeof entry !== "object") continue;
464
+ const row = entry as { readonly [key: string]: unknown };
465
+ const number = row["number"];
466
+ const rawState = row["state"];
467
+ if (typeof number !== "number" || !Number.isInteger(number) || typeof rawState !== "string") continue;
468
+ // The REST API answers lowercase, the CLI uppercase. Both are the same fact.
469
+ const state = issueStateFrom(rawState.toUpperCase());
470
+ if (state === undefined) continue;
471
+ issues.push({ number, state });
472
+ }
473
+ return issues;
474
+ }
475
+
384
476
  export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
385
477
  const repo = p.tracker.repo;
386
478
 
387
- return {
479
+ // Named rather than returned inline, so `rerunFailedChecks` can reuse
480
+ // `checkConclusions` instead of re-implementing the same `gh` call.
481
+ const tracker: Tracker = {
388
482
  async listReady(): Promise<ReadyIssue[]> {
389
483
  const raw = await runGh([
390
484
  "issue",
@@ -573,5 +667,77 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
573
667
  return undefined;
574
668
  }
575
669
  },
670
+ async checkConclusions(prUrl: string): Promise<{ name: string; state: string; link?: string }[]> {
671
+ if (!PR_URL.test(prUrl)) return [];
672
+ try {
673
+ return checkConclusionsFrom(await runGh(["pr", "checks", prUrl, "--json", "name,state,link"]));
674
+ } catch {
675
+ // `gh pr checks` exits non-zero when checks are failing *and* when it
676
+ // could not read them at all, so an exception here says nothing about the
677
+ // PR. Empty means "could not tell", and the classifier reads it that way.
678
+ return [];
679
+ }
680
+ },
681
+
682
+ async mergeable(prUrl: string): Promise<"conflicting" | "clean" | "unknown"> {
683
+ if (!PR_URL.test(prUrl)) return "unknown";
684
+ try {
685
+ return mergeableFrom(await runGh(["pr", "view", prUrl, "--json", "mergeable", "--jq", ".mergeable"]));
686
+ } catch {
687
+ return "unknown";
688
+ }
689
+ },
690
+
691
+ async rerunFailedChecks(prUrl: string): Promise<void> {
692
+ const runIds = failedRunIds(await tracker.checkConclusions(prUrl));
693
+ for (const id of runIds) {
694
+ try {
695
+ await runGh(["run", "rerun", id, "--failed"]);
696
+ } catch {
697
+ // Per run id: a workflow run too old to re-run, or one already
698
+ // re-running, must not stop the others. The sweep re-verifies the PR
699
+ // against its head on a later tick either way.
700
+ }
701
+ }
702
+ },
703
+
704
+ async listLabeled(label: string, limit = 50): Promise<{ number: number; state: IssueState }[]> {
705
+ try {
706
+ return labeledIssuesFrom(
707
+ await runGh([
708
+ "issue",
709
+ "list",
710
+ "--repo",
711
+ repo,
712
+ "--label",
713
+ label,
714
+ "--state",
715
+ "all",
716
+ "--json",
717
+ "number,state",
718
+ "--limit",
719
+ String(limit),
720
+ ]),
721
+ );
722
+ } catch {
723
+ // A reconcile that cannot list must remove no labels: an empty answer is
724
+ // read as "no evidence", never as "nothing carries this label".
725
+ return [];
726
+ }
727
+ },
728
+
729
+ async childrenOf(issue: number): Promise<{ number: number; state: IssueState }[]> {
730
+ try {
731
+ return labeledIssuesFrom(
732
+ await runGh(["api", `repos/${repo}/issues/${issue}/sub_issues`, "--jq", "[.[] | {number, state}]"]),
733
+ );
734
+ } catch {
735
+ // Also the answer on a repo without the sub-issues API, or an issue that
736
+ // was never decomposed. All three mean the same thing to the caller.
737
+ return [];
738
+ }
739
+ },
576
740
  };
741
+
742
+ return tracker;
577
743
  }
package/src/types.ts CHANGED
@@ -114,10 +114,17 @@ export interface RepoTarget {
114
114
 
115
115
  /**
116
116
  * How much the orchestrator says out loud without being asked. Declared as data
117
- * so the validator, the wizard and the brief all enumerate the same two values:
118
- * a third scope cannot be added while one of them still knows only two.
117
+ * so the validator, the wizard and the brief all enumerate the same three
118
+ * values: a fourth scope cannot be added while one of them still knows only
119
+ * three.
120
+ *
121
+ * `escalations` interrupts only for a tier-2 decision. `material` reports every
122
+ * material event as it happens. `decisions` sits between them: a decision the
123
+ * session needs, or a condition that stops the fleet, interrupts immediately;
124
+ * every other material event accumulates and ships with the next tick report —
125
+ * one message on a schedule instead of a ping per merge (#138).
119
126
  */
120
- export const REPORT_SCOPES = ["escalations", "material"] as const;
127
+ export const REPORT_SCOPES = ["escalations", "decisions", "material"] as const;
121
128
 
122
129
  export type ReportScope = (typeof REPORT_SCOPES)[number];
123
130
 
@@ -608,16 +615,6 @@ export interface ProjectConfig {
608
615
  workspaceRoot: string;
609
616
  /** Cache of bare clones, so N runs share one fetch instead of N. */
610
617
  mirrorRoot: string;
611
- /**
612
- * Extra roots an orchestrator session may *read*, on top of the state
613
- * directory and its own briefs. Absolute (or `~`-expandable) paths only,
614
- * rejected at load rather than resolved against a cwd nobody can name.
615
- *
616
- * It cannot widen the gate past the roots #127 exists to close: the denied
617
- * list — every worker checkout, the mirror cache, this package's own
618
- * install — outranks anything named here.
619
- */
620
- orchestratorReadPaths?: string[];
621
618
  }
622
619
 
623
620
  /**
@@ -836,8 +833,84 @@ export interface Tracker {
836
833
  * better answer on a later tick.
837
834
  */
838
835
  prDiff(url: string): Promise<PrDiff | undefined>;
836
+ /**
837
+ * Every check on a PR with the state the tracker reports for it (#132).
838
+ *
839
+ * Deliberately separate from {@link Tracker.verifyPr}, which answers "may this
840
+ * merge" and collapses every non-success into one verdict. Classification asks
841
+ * a different question — *why* is it not green — and the answer is the
842
+ * difference between re-running a cancelled runner for free and charging an
843
+ * implementation attempt for a real test failure.
844
+ *
845
+ * An empty array means "could not tell", the same posture as the rest of this
846
+ * port: nothing downstream may read it as "no checks failed".
847
+ */
848
+ checkConclusions(prUrl: string): Promise<{ name: string; state: string; link?: string }[]>;
849
+ /** Whether the PR can merge into its base. `unknown` on any doubt, so a
850
+ * mergeability nobody could read never becomes a conflict recovery. */
851
+ mergeable(prUrl: string): Promise<"conflicting" | "clean" | "unknown">;
852
+ /** Re-run the failed jobs behind a PR's non-success checks. Best effort per
853
+ * workflow run: one un-rerunnable run must not stop the others. */
854
+ rerunFailedChecks(prUrl: string): Promise<void>;
855
+ /** Issues carrying `label`, open or closed, bounded. Empty on any failure —
856
+ * a reconcile that cannot list must remove no labels. */
857
+ listLabeled(label: string, limit?: number): Promise<{ number: number; state: IssueState }[]>;
858
+ /** Sub-issues of `issue`, with their states. Empty when there are none *or*
859
+ * when the lookup failed: both mean "no evidence this was decomposed", and
860
+ * the reconcile below only ever acts on positive evidence. */
861
+ childrenOf(issue: number): Promise<{ number: number; state: IssueState }[]>;
839
862
  }
840
863
 
864
+ /**
865
+ * Why a terminal run ended badly (#132).
866
+ *
867
+ * Declared as data here for the same reason `REPORT_SCOPES` is: the classifier,
868
+ * the store's budget counters, `status` and the board all enumerate these, so a
869
+ * new class must fail to compile in each of those places rather than silently
870
+ * resolve to "unclassified" in one of them. The decision table that maps signals
871
+ * onto them lives in `failure-class.ts`.
872
+ *
873
+ * The issue's `question-answerable` / `question-decision` split is deliberately
874
+ * one `question`: telling them apart needs semantic judgement about the worker's
875
+ * question, which is the orchestrator's job rather than a decision table's. The
876
+ * escalation carries the worker's own blocking report, so the orchestrator
877
+ * answers instead of re-investigating.
878
+ */
879
+ export const FAILURE_CLASSES = [
880
+ "turn-cap-progress",
881
+ "turn-cap-spinning",
882
+ "admin-kill",
883
+ "ci-infra",
884
+ "ci-deterministic",
885
+ "merge-conflict",
886
+ "question",
887
+ "orphan-clean",
888
+ "orphan-dirty",
889
+ "settlement-stuck",
890
+ "unknown",
891
+ ] as const;
892
+
893
+ export type FailureClass = (typeof FAILURE_CLASSES)[number];
894
+
895
+ /**
896
+ * What the daemon does about a class.
897
+ *
898
+ * `none` completes the vocabulary rather than naming an outcome: an
899
+ * unclassifiable run escalates and never silently retries, so nothing in the
900
+ * decision table maps to it.
901
+ */
902
+ export const RECOVERY_ACTIONS = [
903
+ "requeue",
904
+ "continue",
905
+ "rerun-checks",
906
+ "settle",
907
+ "escalate",
908
+ "hold",
909
+ "none",
910
+ ] as const;
911
+
912
+ export type RecoveryAction = (typeof RECOVERY_ACTIONS)[number];
913
+
841
914
  /**
842
915
  * Execution state is separate from the tracker's own labels on purpose: labels
843
916
  * are coarse and human-editable, while the loop needs to distinguish "pushed
@@ -902,6 +975,17 @@ export interface RunRecord {
902
975
  * exactly as an unflagged one does, and the flags are evidence for whoever
903
976
  * reviews the PR. Absent means the audit found nothing, or never ran. */
904
977
  settlementFlags?: SettlementFlag[];
978
+ /**
979
+ * Why this run ended badly, and what the daemon did about it (#132). Absent
980
+ * means the sweep has not looked at the row yet — never "nothing was wrong":
981
+ * every budget counter treats an unclassified terminal row exactly as it did
982
+ * before classification existed.
983
+ */
984
+ failureClass?: FailureClass;
985
+ recoveryAction?: RecoveryAction;
986
+ /** When the recovery for that class ran. Absent means still unrecovered, which
987
+ * is what `status` counts and what the board's card suffix reports. */
988
+ recoveredAt?: number;
905
989
  }
906
990
 
907
991
  export type AdmissionHoldReason =
@@ -1073,6 +1157,60 @@ export interface ReportEnqueue {
1073
1157
  deduped: boolean;
1074
1158
  }
1075
1159
 
1160
+ /**
1161
+ * Where one operator decision stands (#136).
1162
+ *
1163
+ * `open` is the only state that owes anybody anything, which is why the tick
1164
+ * digest reads exactly that set. The three terminal states are distinct because
1165
+ * they describe different histories: `answered` carries what the operator
1166
+ * decided, `withdrawn` records that the session stopped needing it (and why),
1167
+ * and `expired` is the ledger closing a question nobody answered inside
1168
+ * {@link DECISION_TTL_MS} rather than letting it accumulate forever.
1169
+ */
1170
+ export const DECISION_STATES = ["open", "answered", "withdrawn", "expired"] as const;
1171
+
1172
+ export type DecisionState = (typeof DECISION_STATES)[number];
1173
+
1174
+ /**
1175
+ * How long an unanswered question stays open — the seven days the floor's
1176
+ * parked-amendment protocol already promised, now enforced instead of
1177
+ * remembered.
1178
+ */
1179
+ export const DECISION_TTL_MS = 7 * 24 * 60 * 60_000;
1180
+
1181
+ /** One question put to the operator, and its answer if it has one. */
1182
+ export interface DecisionRecord {
1183
+ id: string;
1184
+ project: string;
1185
+ /** The question verbatim, as it was sent. Re-asking must not reword it. */
1186
+ question: string;
1187
+ /** What is waiting on the answer — an issue, a release, a PR. Free text,
1188
+ * because the point is that a human reads it in a digest. */
1189
+ blocks?: string;
1190
+ askedAt: number;
1191
+ expiresAt: number;
1192
+ /** Raw machine-checkable precondition, e.g. `pr-merged:<url>`. Stored as
1193
+ * written and parsed on read, so a grammar this build does not know is a row
1194
+ * that still lists rather than a row that fails to load. */
1195
+ condition?: string;
1196
+ /** When that precondition was first observed true. Set once; the digest
1197
+ * promotes the row from "parked" to "act on this now". */
1198
+ conditionMetAt?: number;
1199
+ state: DecisionState;
1200
+ resolvedAt?: number;
1201
+ /** The answer, the withdrawal reason, or the expiry note. */
1202
+ resolution?: string;
1203
+ }
1204
+
1205
+ /** What a caller hands over. The store owns the id, the state and the expiry. */
1206
+ export interface DecisionDraft {
1207
+ project: string;
1208
+ question: string;
1209
+ blocks?: string;
1210
+ condition?: string;
1211
+ at: number;
1212
+ }
1213
+
1076
1214
  /**
1077
1215
  * Bookkeeping only — GitHub labels remain the source of truth. The store
1078
1216
  * exists to answer cap questions cheaply and to survive a restart; if it is
@@ -1160,6 +1298,34 @@ export interface Store {
1160
1298
  recoverSendingReports(project: string, staleAt: number, at: number): ReportRecord[];
1161
1299
  /** Everything an operator still has to care about: pending, sending, failed. */
1162
1300
  openReports(project: string): ReportRecord[];
1301
+ /**
1302
+ * Terminal rows the classification sweep has not looked at yet, newest first
1303
+ * and bounded (#132). Bounded because each row costs tracker calls to gather
1304
+ * facts for: a fleet with a long unclassified history works through it over
1305
+ * several ticks rather than spending one tick on all of it.
1306
+ */
1307
+ runsNeedingClassification(project: string, limit?: number): RunRecord[];
1308
+ /** Unrecovered rows per class, for `status`. Empty when nothing is carrying one. */
1309
+ failureClassCounts(project: string): { cls: FailureClass; n: number }[];
1310
+ /** Rows whose recovery ran at or after `since`, newest first — the tick's
1311
+ * "auto-recovered since last tick" line, and its count. */
1312
+ recoveredSince(project: string, since: number): RunRecord[];
1313
+ /**
1314
+ * Record a question put to the operator (#136). Expiry is the store's, not the
1315
+ * caller's: a session that computed its own deadline would be back to
1316
+ * remembering things.
1317
+ */
1318
+ createDecision(draft: DecisionDraft): DecisionRecord;
1319
+ /** Everything still owed an answer, oldest first — what a tick digest reads. */
1320
+ openDecisions(project: string): DecisionRecord[];
1321
+ /** Answer or withdraw one. `false` when the id is unknown or already closed,
1322
+ * so a double-resolve cannot overwrite the first answer. */
1323
+ resolveDecision(id: string, state: "answered" | "withdrawn", resolution: string, at: number): boolean;
1324
+ /** First observation that a row's condition came true. Idempotent. */
1325
+ markDecisionConditionMet(id: string, at: number): boolean;
1326
+ /** Close every open row past its deadline and return them, so the caller can
1327
+ * say what it just closed rather than reporting a count. */
1328
+ expireDueDecisions(project: string, now: number): DecisionRecord[];
1163
1329
  /**
1164
1330
  * Append one decided verb call to the run-scoped action ledger (#126).
1165
1331
  * Written for refusals as well as approvals, and written *before* the
package/src/upgrade.ts CHANGED
@@ -3,7 +3,7 @@ import { inspectBriefLayout, type BriefLayout } from "./brief-upgrade.ts";
3
3
  import { setPaused, statusSnapshot } from "./daemon.ts";
4
4
  import { fleetLayers, telegramStateDir, type DispatchLayer, type FleetLayers } from "./fleet.ts";
5
5
  import { livingDaemon, restartDaemon } from "./lifecycle.ts";
6
- import { configPath, findProject, loadConfig, resolveCaps, writeConfigFile } from "./config.ts";
6
+ import { configPath, findProject, loadConfig, resolveCaps, writeConfigRaw } from "./config.ts";
7
7
  import { renderBriefForProject } from "./setup.ts";
8
8
  import {
9
9
  STAGED_SERVICE_NAME,
@@ -276,6 +276,21 @@ async function waitForRecovery(
276
276
  throw new Error(`upgrade verification failed: ${problem}`);
277
277
  }
278
278
 
279
+ /**
280
+ * Brings the brief up with the freshly installed package.
281
+ *
282
+ * `brief-upgrade --migrate --apply` is the cross-version ABI: this code runs
283
+ * from the *old* CLI while the new one is already installed, so the verb has to
284
+ * keep working across the boundary. When it does not — a flag renamed, the verb
285
+ * gone — the tolerable outcome depends entirely on the layout:
286
+ *
287
+ * - `overlay` fleets need nothing. The floor is recomposed from the installed
288
+ * package on every tick, so a failed migrate costs nothing and stopping the
289
+ * upgrade over it would be the worse trade.
290
+ * - a legacy layout needs it. Skipping the migration silently would leave the
291
+ * fleet on a single-file brief whose floor no longer tracks the package —
292
+ * the exact drift this verb exists to end — so it fails loudly and rolls back.
293
+ */
279
294
  async function upgradeBrief(
280
295
  deps: UpgradeDeps,
281
296
  kind: BriefLayout["kind"],
@@ -283,9 +298,16 @@ async function upgradeBrief(
283
298
  ): Promise<void> {
284
299
  const selected = project === undefined ? [] : ["--project", project];
285
300
  if (kind === "legacy-handwritten") {
301
+ // Ungated on purpose: a hand-written brief cannot be migrated without it.
286
302
  await mustRun(deps, "omp-conductor", ["brief-upgrade", "--retrofit", "--apply", ...selected]);
287
303
  }
288
- await mustRun(deps, "omp-conductor", ["brief-upgrade", "--migrate", "--apply", ...selected]);
304
+ try {
305
+ await mustRun(deps, "omp-conductor", ["brief-upgrade", "--migrate", "--apply", ...selected]);
306
+ } catch (err) {
307
+ if (kind !== "overlay") throw err;
308
+ const msg = err instanceof Error ? err.message : String(err);
309
+ deps.log(`brief: brief-upgrade unavailable in the target CLI (${msg}) — overlay recomposes each tick, continuing`);
310
+ }
289
311
  }
290
312
 
291
313
  async function rollbackUpgrade(
@@ -314,7 +336,9 @@ async function rollbackUpgrade(
314
336
  const path = configPath();
315
337
  if (readFileSync(path, "utf8") !== configBefore) {
316
338
  deps.log("rollback: conductor config.json");
317
- writeConfigFile(JSON.parse(configBefore));
339
+ // Exact bytes. `writeConfigFile(JSON.parse(...))` returns canonically
340
+ // formatted output, which restores the keys but not the file.
341
+ writeConfigRaw(configBefore);
318
342
  }
319
343
  } catch (err) {
320
344
  failures.push(`could not restore config.json: ${err instanceof Error ? err.message : String(err)}`);