omp-conductor 0.4.2 → 0.4.4

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.
@@ -113,18 +113,29 @@ interface GhPrVerification {
113
113
 
114
114
  /** Carries the captured stderr so callers can classify a failure without
115
115
  * re-running the command or parsing the message text of a plain Error. */
116
- class GhError extends Error {
116
+ export class GhError extends Error {
117
117
  readonly argv: string[];
118
118
  readonly code: number;
119
119
  readonly stderr: string;
120
-
121
- constructor(argv: string[], code: number, stderr: string) {
120
+ /**
121
+ * Whatever the command printed before it failed.
122
+ *
123
+ * Carried because a nonzero exit is not always a failure to *read*: `gh pr
124
+ * checks` exits 1 when a check failed and 8 when one is pending, having
125
+ * already written the complete JSON payload asked for. Discarding stdout here
126
+ * made both CI failure classes unreachable on exactly the pull requests they
127
+ * exist to classify — a red PR answered "no checks" (#132 follow-up).
128
+ */
129
+ readonly stdout: string;
130
+
131
+ constructor(argv: string[], code: number, stderr: string, stdout = "") {
122
132
  const detail = stderr.trim() || "(no stderr)";
123
133
  super(`\`gh ${argv.join(" ")}\` exited ${code}: ${detail}`);
124
134
  this.name = "GhError";
125
135
  this.argv = argv;
126
136
  this.code = code;
127
137
  this.stderr = stderr;
138
+ this.stdout = stdout;
128
139
  }
129
140
  }
130
141
 
@@ -155,7 +166,7 @@ async function gh(argv: string[], stdin?: string): Promise<string> {
155
166
 
156
167
  const signal = proc.signalCode;
157
168
  if (code !== 0 || signal) {
158
- throw new GhError(argv, code, signal ? `${stderr}\nterminated by ${signal}` : stderr);
169
+ throw new GhError(argv, code, signal ? `${stderr}\nterminated by ${signal}` : stderr, stdout);
159
170
  }
160
171
  return stdout;
161
172
  }
@@ -381,10 +392,104 @@ export function parentNumberFrom(raw: string): number | undefined {
381
392
  return n;
382
393
  }
383
394
 
395
+ /**
396
+ * Parse `gh pr checks --json name,state,link`.
397
+ *
398
+ * Tolerant on purpose: a payload shape this build does not recognise yields an
399
+ * empty list, which the classifier reads as "could not tell" — the same posture
400
+ * every other reader in this adapter takes.
401
+ */
402
+ export function checkConclusionsFrom(raw: string): { name: string; state: string; link?: string }[] {
403
+ let parsed: unknown;
404
+ try {
405
+ parsed = JSON.parse(raw) as unknown;
406
+ } catch {
407
+ return [];
408
+ }
409
+ if (!Array.isArray(parsed)) return [];
410
+ const checks: { name: string; state: string; link?: string }[] = [];
411
+ for (const entry of parsed) {
412
+ if (entry === null || typeof entry !== "object") continue;
413
+ const row = entry as { readonly [key: string]: unknown };
414
+ const name = row["name"];
415
+ const state = row["state"];
416
+ if (typeof name !== "string" || typeof state !== "string") continue;
417
+ const link = row["link"];
418
+ checks.push({
419
+ name,
420
+ state,
421
+ ...(typeof link === "string" && link.length > 0 ? { link } : {}),
422
+ });
423
+ }
424
+ return checks;
425
+ }
426
+
427
+ /** GitHub's mergeability spelling, fail-open to `unknown` on anything else. */
428
+ export function mergeableFrom(raw: string): "conflicting" | "clean" | "unknown" {
429
+ switch (raw.trim().replaceAll('"', "")) {
430
+ case "CONFLICTING":
431
+ return "conflicting";
432
+ case "MERGEABLE":
433
+ return "clean";
434
+ default:
435
+ // `UNKNOWN` is GitHub still computing the merge, and it is common on a PR
436
+ // pushed seconds ago. Reading it as clean would let a conflict recovery
437
+ // fire on a PR nobody has assessed yet.
438
+ return "unknown";
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Distinct workflow-run ids behind the non-success checks of a PR.
444
+ *
445
+ * Extracted from each check's `link` because that is the only run identifier
446
+ * `gh pr checks` reports. Deduplicated: one workflow run usually backs several
447
+ * checks, and re-running it once is the whole point.
448
+ */
449
+ export function failedRunIds(
450
+ checks: readonly { name: string; state: string; link?: string }[],
451
+ ): string[] {
452
+ const ids: string[] = [];
453
+ for (const check of checks) {
454
+ const state = check.state.trim().toLowerCase();
455
+ if (state === "success" || state === "neutral") continue;
456
+ const match = /\/actions\/runs\/(\d+)\//.exec(check.link ?? "");
457
+ const id = match?.[1];
458
+ if (id !== undefined && !ids.includes(id)) ids.push(id);
459
+ }
460
+ return ids;
461
+ }
462
+
463
+ /** Parse `[{number, state}]` from `gh issue list` or the sub-issues API. */
464
+ export function labeledIssuesFrom(raw: string): { number: number; state: IssueState }[] {
465
+ let parsed: unknown;
466
+ try {
467
+ parsed = JSON.parse(raw) as unknown;
468
+ } catch {
469
+ return [];
470
+ }
471
+ if (!Array.isArray(parsed)) return [];
472
+ const issues: { number: number; state: IssueState }[] = [];
473
+ for (const entry of parsed) {
474
+ if (entry === null || typeof entry !== "object") continue;
475
+ const row = entry as { readonly [key: string]: unknown };
476
+ const number = row["number"];
477
+ const rawState = row["state"];
478
+ if (typeof number !== "number" || !Number.isInteger(number) || typeof rawState !== "string") continue;
479
+ // The REST API answers lowercase, the CLI uppercase. Both are the same fact.
480
+ const state = issueStateFrom(rawState.toUpperCase());
481
+ if (state === undefined) continue;
482
+ issues.push({ number, state });
483
+ }
484
+ return issues;
485
+ }
486
+
384
487
  export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
385
488
  const repo = p.tracker.repo;
386
489
 
387
- return {
490
+ // Named rather than returned inline, so `rerunFailedChecks` can reuse
491
+ // `checkConclusions` instead of re-implementing the same `gh` call.
492
+ const tracker: Tracker = {
388
493
  async listReady(): Promise<ReadyIssue[]> {
389
494
  const raw = await runGh([
390
495
  "issue",
@@ -573,5 +678,83 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
573
678
  return undefined;
574
679
  }
575
680
  },
681
+ async checkConclusions(prUrl: string): Promise<{ name: string; state: string; link?: string }[]> {
682
+ if (!PR_URL.test(prUrl)) return [];
683
+ try {
684
+ return checkConclusionsFrom(await runGh(["pr", "checks", prUrl, "--json", "name,state,link"]));
685
+ } catch (err) {
686
+ // A nonzero exit is the *documented* answer for a PR with a failing or
687
+ // pending check — `gh pr checks` exits 1 and 8 respectively, having
688
+ // already printed the whole payload. Reading only the throw is what made
689
+ // `ci-infra` and `ci-deterministic` unreachable on live data: every red
690
+ // PR answered "no checks", so it classified `unknown` and escalated.
691
+ //
692
+ // So the payload wins when there is one, and only a genuinely unreadable
693
+ // answer falls through to "could not tell".
694
+ const stdout = err instanceof GhError ? err.stdout : "";
695
+ return checkConclusionsFrom(stdout);
696
+ }
697
+ },
698
+
699
+ async mergeable(prUrl: string): Promise<"conflicting" | "clean" | "unknown"> {
700
+ if (!PR_URL.test(prUrl)) return "unknown";
701
+ try {
702
+ return mergeableFrom(await runGh(["pr", "view", prUrl, "--json", "mergeable", "--jq", ".mergeable"]));
703
+ } catch {
704
+ return "unknown";
705
+ }
706
+ },
707
+
708
+ async rerunFailedChecks(prUrl: string): Promise<void> {
709
+ const runIds = failedRunIds(await tracker.checkConclusions(prUrl));
710
+ for (const id of runIds) {
711
+ try {
712
+ await runGh(["run", "rerun", id, "--failed"]);
713
+ } catch {
714
+ // Per run id: a workflow run too old to re-run, or one already
715
+ // re-running, must not stop the others. The sweep re-verifies the PR
716
+ // against its head on a later tick either way.
717
+ }
718
+ }
719
+ },
720
+
721
+ async listLabeled(label: string, limit = 50): Promise<{ number: number; state: IssueState }[]> {
722
+ try {
723
+ return labeledIssuesFrom(
724
+ await runGh([
725
+ "issue",
726
+ "list",
727
+ "--repo",
728
+ repo,
729
+ "--label",
730
+ label,
731
+ "--state",
732
+ "all",
733
+ "--json",
734
+ "number,state",
735
+ "--limit",
736
+ String(limit),
737
+ ]),
738
+ );
739
+ } catch {
740
+ // A reconcile that cannot list must remove no labels: an empty answer is
741
+ // read as "no evidence", never as "nothing carries this label".
742
+ return [];
743
+ }
744
+ },
745
+
746
+ async childrenOf(issue: number): Promise<{ number: number; state: IssueState }[]> {
747
+ try {
748
+ return labeledIssuesFrom(
749
+ await runGh(["api", `repos/${repo}/issues/${issue}/sub_issues`, "--jq", "[.[] | {number, state}]"]),
750
+ );
751
+ } catch {
752
+ // Also the answer on a repo without the sub-issues API, or an issue that
753
+ // was never decomposed. All three mean the same thing to the caller.
754
+ return [];
755
+ }
756
+ },
576
757
  };
758
+
759
+ return tracker;
577
760
  }
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,38 @@ 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 still owes something to, newest first
1303
+ * and bounded (#132): never classified, or classified with a retryable recovery
1304
+ * that has not run. The second half is what makes "retrying next tick" true
1305
+ * when a tracker write fails mid-recovery.
1306
+ *
1307
+ * Bounded because each row costs tracker calls to gather facts for: a fleet
1308
+ * with a long unclassified history works through it over several ticks rather
1309
+ * than spending one tick on all of it.
1310
+ */
1311
+ runsNeedingClassification(project: string, limit?: number): RunRecord[];
1312
+ /** Unrecovered rows per class, for `status`. Empty when nothing is carrying one. */
1313
+ failureClassCounts(project: string): { cls: FailureClass; n: number }[];
1314
+ /** Rows whose recovery ran at or after `since`, newest first — the tick's
1315
+ * "auto-recovered since last tick" line, and its count. */
1316
+ recoveredSince(project: string, since: number): RunRecord[];
1317
+ /**
1318
+ * Record a question put to the operator (#136). Expiry is the store's, not the
1319
+ * caller's: a session that computed its own deadline would be back to
1320
+ * remembering things.
1321
+ */
1322
+ createDecision(draft: DecisionDraft): DecisionRecord;
1323
+ /** Everything still owed an answer, oldest first — what a tick digest reads. */
1324
+ openDecisions(project: string): DecisionRecord[];
1325
+ /** Answer or withdraw one. `false` when the id is unknown or already closed,
1326
+ * so a double-resolve cannot overwrite the first answer. */
1327
+ resolveDecision(id: string, state: "answered" | "withdrawn", resolution: string, at: number): boolean;
1328
+ /** First observation that a row's condition came true. Idempotent. */
1329
+ markDecisionConditionMet(id: string, at: number): boolean;
1330
+ /** Close every open row past its deadline and return them, so the caller can
1331
+ * say what it just closed rather than reporting a count. */
1332
+ expireDueDecisions(project: string, now: number): DecisionRecord[];
1163
1333
  /**
1164
1334
  * Append one decided verb call to the run-scoped action ledger (#126).
1165
1335
  * 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)}`);