omp-conductor 0.15.9 → 0.15.11

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 (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
package/src/upgrade.ts CHANGED
@@ -12,13 +12,28 @@ import {
12
12
  type FleetLayers,
13
13
  } from "./fleet.ts";
14
14
  import { livingDaemon, restartDaemon } from "./lifecycle.ts";
15
- import { configBackupDir, configPath, findProject, loadConfig, resolveCaps, writeConfigRaw } from "./config.ts";
15
+ import { configBackupDir, configPath, findProject, loadConfig, resolveCaps, stateDir, writeConfigRaw } from "./config.ts";
16
16
  import { renderBriefForProject } from "./setup.ts";
17
17
  import {
18
18
  STAGED_SERVICE_NAME,
19
19
  planHostRuntime,
20
20
  writeHostRuntime,
21
21
  } from "./setup-host.ts";
22
+ import {
23
+ appendJournal,
24
+ readUpgradeJournal,
25
+ upgradeJournalPath,
26
+ type UpgradeJournalEntry,
27
+ } from "./upgrade-journal.ts";
28
+ import {
29
+ enqueueUpgradeReport,
30
+ pendingUpgradeRequest,
31
+ reportProject,
32
+ requestProgression,
33
+ runCommand,
34
+ type PendingUpgradeRequest,
35
+ type UpgradeCommandResult,
36
+ } from "./upgrade-verify.ts";
22
37
 
23
38
  const PACKAGE = "omp-conductor";
24
39
  const HERDR_PLUGIN = "herdr-conductor";
@@ -28,15 +43,17 @@ const DRAIN_POLL_MS = 5_000;
28
43
  const RECOVERY_POLL_MS = 2_000;
29
44
  const RECOVERY_ATTEMPTS = 30;
30
45
 
31
- export interface UpgradeCommandResult {
32
- code: number;
33
- stdout: string;
34
- stderr: string;
35
- }
36
-
37
46
  export interface UpgradeOptions {
38
47
  version?: string;
39
48
  project?: string;
49
+ /**
50
+ * Run as the detached fleet installer (#486): journal every surface the
51
+ * transaction touches, skip the in-process verification and leave dispatch
52
+ * paused — the first tick of the post-restart daemon verifies version,
53
+ * /healthz, ticks, pane and doctor, and closes or rolls back on that
54
+ * evidence. Only the transient unit's `upgrade-install` entry sets this.
55
+ */
56
+ detached?: boolean;
40
57
  }
41
58
 
42
59
  export interface UpgradeResult {
@@ -79,25 +96,18 @@ export interface UpgradeDeps {
79
96
  sleep(ms: number): Promise<void>;
80
97
  env: NodeJS.ProcessEnv;
81
98
  log(message: string): void;
99
+ /**
100
+ * The durable journal sink for the detached fleet installer (#486). Wired
101
+ * to the state-dir journal by `upgrade-install`/`upgrade-rollback`; absent
102
+ * on the human `upgrade` path, whose own process is its witness. The engine
103
+ * journals each surface boundary so the post-restart tick can verify against
104
+ * a record it did not write.
105
+ */
106
+ journal?(entry: UpgradeJournalEntry): void;
82
107
  }
83
108
 
84
- async function runCommand(command: string, args: readonly string[]): Promise<UpgradeCommandResult> {
85
- try {
86
- const child = Bun.spawn([command, ...args], {
87
- stdin: "ignore",
88
- stdout: "pipe",
89
- stderr: "pipe",
90
- env: process.env,
91
- });
92
- const stdout = new Response(child.stdout).text();
93
- const stderr = new Response(child.stderr).text();
94
- const code = await child.exited;
95
- return { code, stdout: await stdout, stderr: await stderr };
96
- } catch (err) {
97
- return { code: 127, stdout: "", stderr: err instanceof Error ? err.message : String(err) };
98
- }
99
- }
100
-
109
+ /** One command run to completion with captured output. Exported so the
110
+ * post-restart verifier and the rollback unit run processes the same way. */
101
111
  export const DEFAULT_DEPS: UpgradeDeps = {
102
112
  run: runCommand,
103
113
  snapshot: statusSnapshot,
@@ -576,7 +586,10 @@ async function upgradeBriefs(deps: UpgradeDeps, briefs: readonly ScopedBrief[]):
576
586
  }
577
587
  }
578
588
 
579
- async function rollbackUpgrade(
589
+ /** Restore every surface the upgrade touched, in reverse, from the identities
590
+ * captured before the first install. Exported so the detached rollback unit
591
+ * can restore what the detached installer recorded (#486). */
592
+ export async function rollbackUpgrade(
580
593
  deps: UpgradeDeps,
581
594
  previous: InstalledSurfaces,
582
595
  scope: UpgradeScope,
@@ -736,6 +749,10 @@ export async function upgradeConductor(
736
749
  overrides: Partial<UpgradeDeps> = {},
737
750
  ): Promise<UpgradeResult> {
738
751
  const deps: UpgradeDeps = { ...DEFAULT_DEPS, ...overrides };
752
+ const detached = options.detached === true;
753
+ const journal = (entry: Omit<UpgradeJournalEntry, "at">, at = new Date()): void => {
754
+ deps.journal?.({ ...entry, at: at.toISOString() });
755
+ };
739
756
  if (deps.env["HERDR_ENV"] !== undefined) {
740
757
  throw new Error("run omp-conductor upgrade from a shell outside the target Herdr session");
741
758
  }
@@ -748,6 +765,18 @@ export async function upgradeConductor(
748
765
  const release = parseRegistry(
749
766
  (await mustRun(deps, "npm", ["view", requested, "version", "gitHead", "--json"])).stdout,
750
767
  );
768
+ if (detached) {
769
+ // The durable request: written before anything can pause, so a unit that
770
+ // dies after this line leaves a journal the returning process can act on
771
+ // (roll back, resume, or report) instead of a paused fleet with no record.
772
+ journal({
773
+ kind: "request",
774
+ ok: true,
775
+ version: release.version,
776
+ gitHead: release.gitHead,
777
+ unit: deps.env["OMP_CONDUCTOR_UNIT"],
778
+ });
779
+ }
751
780
  const initial = deps.layers(scope.pauseKey);
752
781
  const surfaces = await inspectSurfaces(deps);
753
782
  const briefs: ScopedBrief[] = scope.selectors.map((selector) => ({
@@ -756,6 +785,15 @@ export async function upgradeConductor(
756
785
  }));
757
786
  const installNeeded = !surfacesCurrent(surfaces, release.version, release.gitHead);
758
787
  if (!installNeeded && briefs.every((b) => b.current)) {
788
+ if (detached) {
789
+ journal({
790
+ kind: "outcome",
791
+ phase: "already-current",
792
+ ok: true,
793
+ version: release.version,
794
+ detail: "all three surfaces and every brief were already pinned to the target release",
795
+ });
796
+ }
759
797
  return {
760
798
  previousVersion: surfaces.cliVersion,
761
799
  ...release,
@@ -796,20 +834,51 @@ export async function upgradeConductor(
796
834
  }
797
835
  if (initial.herdr === "unknown") throw new Error("cannot determine whether herdr-fleet.service is active");
798
836
 
837
+ if (detached) {
838
+ // The durable record the returning process rolls back or verifies against:
839
+ // what was installed, what dispatch state the fleet began in, which pause
840
+ // sentinel this transaction owns, and the config snapshot for the restore.
841
+ journal({
842
+ kind: "snapshot",
843
+ ok: true,
844
+ version: release.version,
845
+ gitHead: release.gitHead,
846
+ unit: deps.env["OMP_CONDUCTOR_UNIT"],
847
+ previous: {
848
+ cliVersion: surfaces.cliVersion,
849
+ ompVersion: surfaces.ompVersion,
850
+ herdrSource: surfaces.herdrSource,
851
+ },
852
+ ...(preUpgradeBackup === undefined ? {} : { configBackup: preUpgradeBackup }),
853
+ pauseKey: scope.pauseKey,
854
+ initialPaused: initial.paused,
855
+ initial: {
856
+ dispatch: initial.dispatch,
857
+ ticks: initial.ticks,
858
+ pane: initial.pane,
859
+ herdr: initial.herdr,
860
+ },
861
+ selectors: scope.selectors.map((s) => s ?? null),
862
+ });
863
+ }
864
+
799
865
  deps.log(`target release: omp-conductor@${release.version} (${release.gitHead})`);
800
866
  if (!initial.paused) {
801
867
  deps.log("safety: pausing new issue claims");
802
868
  deps.setPaused(true, scope.pauseKey);
803
869
  }
870
+ journal({ kind: "phase", phase: "paused", surface: "dispatch", ok: true, version: release.version });
804
871
  deps.log("drain: waiting for live omp worker sessions");
805
872
  try {
806
873
  await waitForDrain(deps, scope);
807
874
  } catch (err) {
875
+ journal({ kind: "phase", phase: "drain", ok: false, version: release.version, detail: err instanceof Error ? err.message : String(err) });
808
876
  const failure = err instanceof Error ? err.message : String(err);
809
877
  throw new Error(
810
878
  `upgrade failed while draining workers: ${failure}; no installation started; dispatch remains paused`,
811
879
  );
812
880
  }
881
+ journal({ kind: "phase", phase: "drain", ok: true, version: release.version });
813
882
  deps.log("drain: complete");
814
883
 
815
884
  let installTouched = false;
@@ -821,8 +890,10 @@ export async function upgradeConductor(
821
890
  installTouched = true;
822
891
  deps.log(`install 1/3: Bun-global omp-conductor CLI → ${release.version}`);
823
892
  await mustRun(deps, "bun", ["add", "-g", `${PACKAGE}@${release.version}`]);
893
+ journal({ kind: "phase", phase: "install", surface: "cli", ok: true, version: release.version, gitHead: release.gitHead });
824
894
  deps.log(`install 2/3: omp plugin omp-conductor → ${release.version}`);
825
895
  await mustRun(deps, "omp", ["plugin", "install", `${PACKAGE}@${release.version}`]);
896
+ journal({ kind: "phase", phase: "install", surface: "omp", ok: true, version: release.version, gitHead: release.gitHead });
826
897
  if (surfaces.herdrSource?.startsWith("local:")) {
827
898
  await mustRun(deps, "herdr", ["plugin", "unlink", HERDR_PLUGIN]);
828
899
  }
@@ -835,6 +906,7 @@ export async function upgradeConductor(
835
906
  release.gitHead,
836
907
  "--yes",
837
908
  ]);
909
+ journal({ kind: "phase", phase: "install", surface: "herdr", ok: true, version: release.version, gitHead: release.gitHead });
838
910
  } else {
839
911
  deps.log("install: CLI, omp plugin, and Herdr plugin already pinned to the target release");
840
912
  }
@@ -842,16 +914,40 @@ export async function upgradeConductor(
842
914
  deps.log("brief: refreshing managed ORCHESTRATOR.md floor while preserving POLICY.md");
843
915
  briefChanged = true;
844
916
  await upgradeBriefs(deps, briefs);
917
+ journal({ kind: "phase", phase: "brief", surface: "brief", ok: true, version: release.version });
845
918
 
846
919
  if (initial.herdr === "active") {
847
920
  herdrReloadStarted = true;
848
921
  deps.log("reload: restarting herdr-fleet.service and recovering the orchestrator pane");
849
922
  await mustRun(deps, "systemctl", ["restart", HERDR_UNIT]);
923
+ journal({ kind: "phase", phase: "restart", surface: "herdr", ok: true, version: release.version });
850
924
  }
851
925
  if (initial.dispatch !== "stopped") {
852
926
  daemonReloadStarted = true;
853
927
  deps.log("reload: restarting the omp-conductor dispatch daemon");
854
928
  await deps.restartDaemon();
929
+ journal({ kind: "phase", phase: "restart", surface: "daemon", ok: true, version: release.version });
930
+ }
931
+
932
+ if (detached) {
933
+ // The installing process does not declare its own success: it journals
934
+ // that the transaction reached the reloads and stops, leaving dispatch
935
+ // paused exactly where it was. The first tick of the post-restart daemon
936
+ // verifies against this journal and is the only witness that closes the
937
+ // transaction (#486).
938
+ journal({
939
+ kind: "phase",
940
+ phase: "awaiting-verification",
941
+ ok: true,
942
+ version: release.version,
943
+ detail: "installed; the first tick after the restart verifies and restores dispatch",
944
+ });
945
+ return {
946
+ previousVersion: surfaces.cliVersion,
947
+ ...release,
948
+ alreadyCurrent: false,
949
+ dispatch: initial.dispatch,
950
+ };
855
951
  }
856
952
 
857
953
  deps.log("verify 1/2: package identities, services, pane, ticks, and paused dispatch");
@@ -885,10 +981,21 @@ export async function upgradeConductor(
885
981
  deps.setPaused(true, scope.pauseKey);
886
982
  }
887
983
  const failure = err instanceof Error ? err.message : String(err);
984
+ journal({
985
+ kind: "phase",
986
+ phase: "failed",
987
+ ok: false,
988
+ version: release.version,
989
+ detail: failure,
990
+ });
888
991
  deps.log(`upgrade failed: ${failure}`);
992
+ const journalLog = (message: string): void => {
993
+ journal({ kind: "phase", phase: "rollback", ok: true, version: release.version, detail: message });
994
+ deps.log(message);
995
+ };
889
996
  try {
890
997
  await rollbackUpgrade(
891
- deps,
998
+ { ...deps, log: journalLog },
892
999
  surfaces,
893
1000
  scope,
894
1001
  installTouched,
@@ -898,8 +1005,35 @@ export async function upgradeConductor(
898
1005
  configBefore,
899
1006
  preUpgradeBackup,
900
1007
  );
1008
+ journal({ kind: "outcome", phase: "rolled-back", ok: false, version: release.version, detail: failure });
1009
+ enqueueUpgradeReport(
1010
+ "tier2",
1011
+ reportProject(scope.selectors),
1012
+ `fleet upgrade to omp-conductor@${release.version} failed and was rolled back`,
1013
+ [
1014
+ `Reason: ${failure}`,
1015
+ `Restored: cli=${surfaces.cliVersion}, omp=${surfaces.ompVersion ?? "missing"}, ` +
1016
+ `herdr=${surfaces.herdrSource ?? "missing"}`,
1017
+ ...(preUpgradeBackup === undefined ? [] : [`Pre-upgrade config snapshot: ${preUpgradeBackup}`]),
1018
+ "Dispatch stays paused until the first tick after the restart records the rollback verified.",
1019
+ ],
1020
+ `upgrade:${release.version}:rolled-back`,
1021
+ );
901
1022
  } catch (rollbackErr) {
902
1023
  const rollback = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
1024
+ journal({ kind: "outcome", phase: "rollback-failed", ok: false, version: release.version, detail: rollback });
1025
+ enqueueUpgradeReport(
1026
+ "tier2",
1027
+ reportProject(scope.selectors),
1028
+ `upgrade to omp-conductor@${release.version} failed and ITS ROLLBACK ALSO FAILED`,
1029
+ [
1030
+ `Failure: ${failure}`,
1031
+ `Rollback failure: ${rollback}`,
1032
+ "The fleet may be at mixed versions; do not resume dispatch.",
1033
+ "Restore manually from the journal at " + upgradeJournalPath(stateDir()) + ".",
1034
+ ],
1035
+ `upgrade:${release.version}:rollback-failed`,
1036
+ );
903
1037
  throw new Error(
904
1038
  `upgrade failed: ${failure}; rollback incomplete: ${rollback}; dispatch remains paused`,
905
1039
  );
@@ -914,3 +1048,138 @@ export async function upgradeConductor(
914
1048
  dispatch: initial.dispatch,
915
1049
  };
916
1050
  }
1051
+
1052
+ // ---------------------------------------------------------------------------
1053
+ // The detached fleet installer (#486)
1054
+ // ---------------------------------------------------------------------------
1055
+
1056
+ /**
1057
+ * Run the upgrade transaction as the detached fleet installer: `upgrade`'s
1058
+ * engine with a journal wired to the state-dir journal and the in-process
1059
+ * verification handed to the first tick after the restart. This is the
1060
+ * `upgrade-install` command the transient unit runs — the one process a
1061
+ * `systemctl restart herdr-fleet.service` and a daemon restart both pass by.
1062
+ */
1063
+ export async function runDetachedUpgrade(options: UpgradeOptions = {}): Promise<UpgradeResult> {
1064
+ return upgradeConductor(
1065
+ { ...options, detached: true },
1066
+ {
1067
+ journal: (entry) => appendJournal(stateDir(), entry),
1068
+ },
1069
+ );
1070
+ }
1071
+
1072
+ // ---------------------------------------------------------------------------
1073
+ // The detached rollback unit (#486)
1074
+ // ---------------------------------------------------------------------------
1075
+
1076
+ export interface RollbackFromJournalResult {
1077
+ /** The version the rollback restored the fleet to. */
1078
+ restoredVersion: string;
1079
+ /** The version the failed upgrade had targeted. */
1080
+ failedVersion: string;
1081
+ }
1082
+
1083
+ /**
1084
+ * Restore every surface the failed upgrade touched, from the journal the
1085
+ * detached installer wrote. This is the `upgrade-rollback` command the
1086
+ * verifier's transient unit runs — the same trust boundary as the install:
1087
+ * the process that rolls back is not the process that failed, and it acts
1088
+ * only on identities the journal recorded before the first install touched
1089
+ * anything.
1090
+ *
1091
+ * Scoped to the newest request's progression: an older, already-closed
1092
+ * upgrade in the same append-only journal neither supplies the snapshot to
1093
+ * restore nor the phases that say which surfaces were touched, and its
1094
+ * terminal outcome cannot refuse this rollback.
1095
+ */
1096
+ export async function rollbackFromJournal(
1097
+ overrides: Partial<UpgradeDeps> = {},
1098
+ root = stateDir(),
1099
+ ): Promise<RollbackFromJournalResult> {
1100
+ const deps: UpgradeDeps = { ...DEFAULT_DEPS, ...overrides };
1101
+ const entries = readUpgradeJournal(root);
1102
+ const progression = requestProgression(entries);
1103
+ const request = pendingUpgradeRequest(entries);
1104
+ if (progression === undefined || request === undefined) {
1105
+ throw new Error("no upgrade request is journaled; nothing to roll back");
1106
+ }
1107
+ const snapshot = progression.find((entry) => entry.kind === "snapshot");
1108
+ if (snapshot === undefined || snapshot.previous === undefined) {
1109
+ throw new Error(
1110
+ `upgrade ${request.version} has no durable pre-install snapshot; refusing to guess what to restore`,
1111
+ );
1112
+ }
1113
+ const newestOutcome = [...progression].reverse().find((entry) => entry.kind === "outcome");
1114
+ if (newestOutcome !== undefined && newestOutcome.phase !== "rollback-requested") {
1115
+ // Already terminal (verified, rolled back in-process, aborted). A second
1116
+ // rollback would fight the newer outcome.
1117
+ throw new Error(
1118
+ `upgrade ${request.version} already closed as "${newestOutcome.phase}"; nothing to roll back`,
1119
+ );
1120
+ }
1121
+
1122
+ const phases = progression.filter((entry) => entry.kind === "phase");
1123
+ const scope: UpgradeScope = {
1124
+ selectors: request.selectors,
1125
+ pauseKey: request.pauseKey,
1126
+ };
1127
+ const previous: InstalledSurfaces = {
1128
+ cliVersion: snapshot.previous.cliVersion,
1129
+ ompVersion: snapshot.previous.ompVersion,
1130
+ herdrSource: snapshot.previous.herdrSource,
1131
+ };
1132
+ let configBefore: string | undefined;
1133
+ if (request.configBackup !== undefined && existsSync(request.configBackup)) {
1134
+ try {
1135
+ configBefore = readFileSync(request.configBackup, "utf8");
1136
+ } catch {
1137
+ configBefore = undefined;
1138
+ }
1139
+ }
1140
+ const journalLog = (message: string): void => {
1141
+ appendJournal(root, {
1142
+ at: new Date().toISOString(),
1143
+ kind: "phase",
1144
+ phase: "rollback",
1145
+ ok: true,
1146
+ version: request.version,
1147
+ detail: message,
1148
+ });
1149
+ process.stdout.write(`${message}\n`);
1150
+ };
1151
+ await rollbackUpgrade(
1152
+ { ...deps, log: journalLog },
1153
+ previous,
1154
+ scope,
1155
+ phases.some((entry) => entry.phase === "install"),
1156
+ phases.some((entry) => entry.phase === "brief"),
1157
+ phases.some((entry) => entry.phase === "restart" && entry.surface === "herdr"),
1158
+ phases.some((entry) => entry.phase === "restart" && entry.surface === "daemon"),
1159
+ configBefore,
1160
+ request.configBackup,
1161
+ );
1162
+
1163
+ const restoredVersion = previous.cliVersion;
1164
+ appendJournal(root, {
1165
+ at: new Date().toISOString(),
1166
+ kind: "outcome",
1167
+ phase: "rolled-back",
1168
+ ok: false,
1169
+ version: request.version,
1170
+ detail: `restored ${previous.cliVersion} from the pre-install snapshot`,
1171
+ });
1172
+ enqueueUpgradeReport(
1173
+ "tier2",
1174
+ reportProject(request.selectors),
1175
+ `fleet restored to omp-conductor@${restoredVersion} after the failed upgrade to ${request.version}`,
1176
+ [
1177
+ "The transient rollback unit restored every surface from the durable snapshot;",
1178
+ "dispatch stays paused until the first tick after these restarts verifies the old version.",
1179
+ `Pre-upgrade config snapshot: ${request.configBackup ?? "none recorded"}`,
1180
+ ],
1181
+ `upgrade:${request.version}:rolled-back`,
1182
+ );
1183
+ return { restoredVersion, failedVersion: request.version };
1184
+ }
1185
+
@@ -15,9 +15,13 @@
15
15
  */
16
16
 
17
17
  import { credentialedEnv, openRunPr, pushRunBranch, repoSlugFor, scrubUserinfo } from "../gitops.ts";
18
+ import { livingDaemon } from "../lifecycle.ts";
19
+ import { stateDir } from "../config.ts";
18
20
  import { ensureMirror } from "../worktree.ts";
21
+ import { launchTransientUnit, readUpgradeJournal } from "../upgrade-journal.ts";
22
+ import { classifyUpgrade } from "../upgrade-verify.ts";
19
23
  import type { ProjectConfig, ReleaseShape, RepoTarget } from "../types.ts";
20
- import type { ActionOutcome, ReleaseExecution, VerbActions } from "./server.ts";
24
+ import type { ActionOutcome, InstallExecution, ReleaseExecution, VerbActions } from "./server.ts";
21
25
 
22
26
  /**
23
27
  * What this daemon can actually cut.
@@ -779,5 +783,73 @@ export function githubVerbActions(
779
783
  }
780
784
  : failed(pushed.result, pushed.argv);
781
785
  },
786
+
787
+ // The fleet-installs-itself request (#486). Two gates, then a spawn:
788
+ //
789
+ // 1. `npm view` must actually expose the version with a full gitHead —
790
+ // "install it" may only name what npm can serve. Refused with npm's
791
+ // own detail so the orchestrator learns why, not that something was
792
+ // wrong.
793
+ // 2. A live daemon must exist to verify the install on its first tick
794
+ // after the restart. A stopped daemon can never come back to do that,
795
+ // so the request is refused and the operator is pointed at the plain
796
+ // `upgrade` command, whose own process is its witness.
797
+ install: async (execution: InstallExecution): Promise<ActionOutcome> => {
798
+ if (livingDaemon() === undefined) {
799
+ return {
800
+ ok: false,
801
+ stderr:
802
+ "the dispatch daemon is not running, so nobody can verify this install after the restart " +
803
+ "it performs. Run `omp-conductor upgrade` from a shell instead.",
804
+ };
805
+ }
806
+ // One install at a time: the journal's newest request is either closed or
807
+ // in flight, and a second request while one is in flight would race it
808
+ // for the same restarts. The refusal names the state so the caller knows
809
+ // to wait for the verifier's outcome instead of retrying blind.
810
+ const pending = classifyUpgrade(readUpgradeJournal(stateDir()));
811
+ if (pending.kind !== "none" && pending.kind !== "closed") {
812
+ return {
813
+ ok: false,
814
+ stderr:
815
+ `an install is already in flight (journal verdict: ${pending.kind}); ` +
816
+ "the fleet verifies or rolls it back before accepting another request.",
817
+ };
818
+ }
819
+ const looked = await run(
820
+ ["npm", "view", `omp-conductor@${execution.version}`, "version", "gitHead", "--json"],
821
+ { env: env() },
822
+ );
823
+ if (!looked.ok) {
824
+ const detail = looked.stderr.trim() || looked.stdout.trim() || "the npm view command failed";
825
+ return {
826
+ ok: false,
827
+ stderr:
828
+ `npm does not expose omp-conductor@${execution.version}: ${detail}. ` +
829
+ "Only a published release with a full gitHead can be installed; the request was not started.",
830
+ };
831
+ }
832
+ const metadata = objectFromJson(looked.stdout);
833
+ const published = metadata === undefined ? undefined : String(metadata["version"] ?? "");
834
+ const gitHead = metadata === undefined ? undefined : String(metadata["gitHead"] ?? "");
835
+ if (published !== execution.version || !/^[0-9a-f]{40}$/i.test(gitHead ?? "")) {
836
+ return {
837
+ ok: false,
838
+ stderr:
839
+ `npm does not expose omp-conductor@${execution.version} with a full gitHead ` +
840
+ `(${published === undefined ? "unreadable metadata" : `saw ${published}`}); the request was not started.`,
841
+ };
842
+ }
843
+ const launched = await launchTransientUnit(
844
+ async (argv) => {
845
+ const ran = await run([...argv] as string[], { env: env() });
846
+ return { code: ran.ok ? 0 : 1, stdout: ran.stdout, stderr: ran.stderr };
847
+ },
848
+ env(),
849
+ { kind: "install", version: execution.version },
850
+ );
851
+ if (!launched.ok) return { ok: false, stderr: launched.stderr };
852
+ return { ok: true, detail: launched.unit };
853
+ },
782
854
  };
783
855
  }
@@ -288,15 +288,40 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
288
288
  `release authority is the orchestrator's, and this is a ${role} session. ` +
289
289
  "A worker session holds no release grant whatever the config says.",
290
290
  },
291
+ conductor_install: {
292
+ name: "conductor_install",
293
+ mutating: true,
294
+ allowedRoles: ["orchestrator"],
295
+ description:
296
+ "Request the fleet upgrade itself to a published omp-conductor release (#486). The install " +
297
+ "shape gates it like a release shape and stays with the human by default; with the grant, " +
298
+ "the daemon validates the version against npm and starts a detached transient unit that " +
299
+ "pauses, drains, installs the CLI/omp plugin/Herdr plugin and reloads — outside this session " +
300
+ "and the daemon. The unit never declares its own success: the first tick after the restart " +
301
+ "verifies version, /healthz, ticks, pane and doctor, and reports through the durable outbox.",
302
+ args: {
303
+ version: {
304
+ type: "string",
305
+ required: true,
306
+ description:
307
+ "The exact published semver to install (e.g. 0.15.11). The request is refused when npm " +
308
+ "does not expose it with a full gitHead.",
309
+ },
310
+ rationale: RATIONALE_ARG,
311
+ },
312
+ roleRefusalText: (role) =>
313
+ `install authority is the orchestrator's, and this is a ${role} session. ` +
314
+ "A worker session holds no install grant whatever the config says.",
315
+ },
291
316
  conductor_pr_status: {
292
317
  name: "conductor_pr_status",
293
318
  mutating: false,
294
319
  allowedRoles: ["worker", "orchestrator"],
295
320
  description:
296
- "Read one pull request's live state and current head: is it open and are its checks green. " +
297
- "Optionally compare that head with one the caller already observed. This is the daemon's own " +
298
- "merge-gate verdict, not a summary of it. A poll, not a watcher — call it again after waiting. " +
299
- "A worker may omit prUrl and gets its own run's.",
321
+ "Read one pull request's live state and current head — open, merged, or closed — and the checks " +
322
+ "available for it (checks are unavailable for a merged or closed PR). Optionally compare that head " +
323
+ "with one the caller already observed. This is the daemon's own merge-gate verdict, not a summary of it. " +
324
+ "A poll, not a watcher — call it again after waiting. A worker may omit prUrl and gets its own run's.",
300
325
  args: {
301
326
  prUrl: {
302
327
  type: "string",