omp-conductor 0.19.4 → 0.19.6

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.
package/src/doctor.ts CHANGED
@@ -85,6 +85,7 @@ import {
85
85
  import {
86
86
  herdrConductorPluginConfigDir,
87
87
  planHostRuntime,
88
+ RECOVER_SERVICE_NAME,
88
89
  STAGED_SERVICE_NAME,
89
90
  SYSTEMD_UNIT_DIR,
90
91
  tickCwdForProject,
@@ -229,6 +230,8 @@ export interface DoctorDeps {
229
230
  hasSystemd?: () => boolean;
230
231
  /** The installed unit text at an absolute path, or undefined when absent. */
231
232
  readUnit?: (path: string) => string | undefined;
233
+ /** Whether one installed systemd unit is currently failed; undefined when unreadable. */
234
+ unitFailed?: (name: string) => boolean | undefined;
232
235
  /** stat one path; undefined when it does not exist. */
233
236
  stat?: (path: string) => Stats | undefined;
234
237
  /** uid of a username (`id -u`), or undefined when it cannot be resolved. */
@@ -397,6 +400,18 @@ function defaultReadUnit(path: string): string | undefined {
397
400
  }
398
401
  }
399
402
 
403
+ export function systemdStateFailed(state: string): boolean | undefined {
404
+ const normalized = state.trim();
405
+ if (normalized === "") return undefined;
406
+ return normalized === "failed";
407
+ }
408
+
409
+ function defaultUnitFailed(name: string): boolean | undefined {
410
+ const ran = spawnSync("systemctl", ["is-failed", name], { encoding: "utf8" });
411
+ if (ran.error !== undefined) return undefined;
412
+ return systemdStateFailed(ran.stdout ?? "");
413
+ }
414
+
400
415
  function defaultStat(path: string): Stats | undefined {
401
416
  try {
402
417
  return statSync(path);
@@ -660,7 +675,7 @@ function dbBackupProbe(probes: Probes, cfg: ConductorConfig | undefined): Findin
660
675
  return warnFinding(
661
676
  "db-backup",
662
677
  "conductor.db has no snapshot — losing it loses the audit trail with no recovery path",
663
- "take a conductor.db snapshot (the daemon's cadence files one; restore-db needs one to restore from)",
678
+ "run `omp-conductor snapshot-db` (the daemon cadence uses the same SQLite snapshot primitive)",
664
679
  );
665
680
  }
666
681
  const newestMtime = snapshots
@@ -670,7 +685,7 @@ function dbBackupProbe(probes: Probes, cfg: ConductorConfig | undefined): Findin
670
685
  return warnFinding(
671
686
  "db-backup",
672
687
  `conductor.db is newer than every snapshot (${snapshots.length} file(s)) — the newest writes were never snapshot`,
673
- "take a fresh conductor.db snapshot so restore-db has the current state",
688
+ "run `omp-conductor snapshot-db` so restore-db has the current state",
674
689
  );
675
690
  }
676
691
  return passFinding("db-backup", `conductor.db snapshot is fresh (${snapshots.length} file(s))`);
@@ -934,6 +949,22 @@ function recoveryProbe(probes: Probes): Finding {
934
949
  );
935
950
  }
936
951
 
952
+ function recoveryStateProbe(probes: Probes): Finding {
953
+ const id = "systemd-recovery-state";
954
+ if (!probes.hasSystemd()) return passFinding(id, "no systemd directory on this host — nothing to check");
955
+ if (probes.readUnit(join(SYSTEMD_UNIT_DIR, RECOVER_SERVICE_NAME)) === undefined) {
956
+ return passFinding(id, "recovery unit is not installed — systemd-recovery owns that finding");
957
+ }
958
+ const failed = probes.unitFailed(RECOVER_SERVICE_NAME);
959
+ if (failed === false) return passFinding(id, `${RECOVER_SERVICE_NAME} is not failed`);
960
+ const fix =
961
+ `inspect \`systemctl status ${RECOVER_SERVICE_NAME}\` and \`journalctl -u ${RECOVER_SERVICE_NAME}\`; ` +
962
+ `fix the root cause, then run \`systemctl reset-failed ${RECOVER_SERVICE_NAME}\` and rerun doctor`;
963
+ return failed === true
964
+ ? warnFinding(id, `${RECOVER_SERVICE_NAME} is in failed state — its last recovery did not complete`, fix)
965
+ : warnFinding(id, `${RECOVER_SERVICE_NAME} state could not be read`, fix);
966
+ }
967
+
937
968
  function installedUnitUser(probes: Probes): string | undefined {
938
969
  const unit = probes.readUnit(join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME));
939
970
  if (unit === undefined) return undefined;
@@ -1713,7 +1744,7 @@ function parseEnvKey(text: string, key: string): string | undefined {
1713
1744
 
1714
1745
  /**
1715
1746
  * Install-surface parity (#904). omp-conductor installs onto three surfaces —
1716
- * the Bun-global CLI/daemon tree, the omp plugin, and the herdr recovery
1747
+ * the discovered CLI package tree, the omp plugin, and the herdr recovery
1717
1748
  * plugin — and only a live `upgrade` invocation ever compared them. On a
1718
1749
  * fleet whose installs are manual they diverge silently: measured on this
1719
1750
  * host on 2026-08-22, the omp plugin sat on the withdrawn 0.18.1 release for
@@ -2002,6 +2033,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
2002
2033
  // the first one stands in for the rendering seam.
2003
2034
  findings.push(unitProbe(probes, projects[0], cfg));
2004
2035
  findings.push(recoveryProbe(probes));
2036
+ findings.push(recoveryStateProbe(probes));
2005
2037
  findings.push(ownershipProbe(probes));
2006
2038
  // #541 seam checks, host-global: the live herdr config and the plugin's
2007
2039
  // config.env are single files on the host, not per-project facts.
@@ -2106,6 +2138,7 @@ export function defaultProbes(): Probes {
2106
2138
  repoLabels: defaultRepoLabels,
2107
2139
  hasSystemd: () => existsSync(SYSTEMD_UNIT_DIR),
2108
2140
  readUnit: defaultReadUnit,
2141
+ unitFailed: defaultUnitFailed,
2109
2142
  stat: defaultStat,
2110
2143
  uidOf: defaultUidOf,
2111
2144
  dbIntegrity: defaultDbIntegrity,
package/src/fleet.ts CHANGED
@@ -223,6 +223,46 @@ export function armState(projectName?: string): { path: string; armed: boolean }
223
223
  return { path, armed: resolveArmState(path, named).armed };
224
224
  }
225
225
 
226
+ export type WatchMonitoring =
227
+ | { monitored: true }
228
+ | { monitored: false; reason: string };
229
+
230
+ /** Whether a durable watch can be observed and wake an orchestrator tick (#1024). */
231
+ export function watchMonitoringState(
232
+ layers: Pick<FleetLayers, "daemon" | "ticks">,
233
+ ): WatchMonitoring {
234
+ if (!layers.daemon.running) return { monitored: false, reason: "daemon not running" };
235
+ if (layers.ticks === "armed" || layers.ticks === "ungated") return { monitored: true };
236
+ if (layers.ticks === "disarmed") {
237
+ return {
238
+ monitored: false,
239
+ reason: "ticks disarmed — conditions may still be checked, but no tick can be woken",
240
+ };
241
+ }
242
+ return { monitored: false, reason: `ticks ${layers.ticks}` };
243
+ }
244
+
245
+ /** Cheap watch liveness read: no Herdr, Telegram, healthz, or systemd probes. */
246
+ export function watchMonitoringForProject(projectName: string): WatchMonitoring {
247
+ const daemon = livingDaemon();
248
+ if (daemon === undefined) return { monitored: false, reason: "daemon not running" };
249
+ if (daemon.project !== undefined && daemon.project !== projectName) {
250
+ return { monitored: false, reason: `daemon serves ${daemon.project}, not ${projectName}` };
251
+ }
252
+ const tick = resolveTickConfig(projectName);
253
+ const ticks: TicksLayer =
254
+ tick.kind === "absent"
255
+ ? "no-heartbeat-config"
256
+ : tick.kind === "invalid"
257
+ ? "invalid-heartbeat-config"
258
+ : tick.config.armedFile === undefined
259
+ ? "ungated"
260
+ : armState(projectName).armed
261
+ ? "armed"
262
+ : "disarmed";
263
+ return watchMonitoringState({ daemon: { running: true }, ticks });
264
+ }
265
+
226
266
  /**
227
267
  * Clears the arm gate for this project — and `wasArmed` is the gate the
228
268
  * heartbeat reads, not merely one file's presence.
@@ -2174,7 +2214,7 @@ export async function collectFleetStatus(projectName?: string): Promise<FleetSta
2174
2214
  telegram,
2175
2215
  codeGraph,
2176
2216
  brief: briefStatusLine(project),
2177
- decisions: decisionStatusLine(project.name),
2217
+ decisions: decisionStatusLine(project.name, layers),
2178
2218
  failureClasses: failureClassBlock(project.name),
2179
2219
  workerPhases,
2180
2220
  intake: intakeStatusLine(project.name),
@@ -2307,7 +2347,10 @@ function failureClassBlock(projectName: string): string | undefined {
2307
2347
  * watches behind GitHub's checks is not a fleet waiting on its operator, and
2308
2348
  * lumping them in made `decisions 3 open` read as three unanswered questions.
2309
2349
  */
2310
- export function decisionStatusLine(projectName: string): string | undefined {
2350
+ export function decisionStatusLine(
2351
+ projectName: string,
2352
+ layers: Pick<FleetLayers, "daemon" | "ticks">,
2353
+ ): string | undefined {
2311
2354
  const path = dbPath();
2312
2355
  if (!existsSync(path)) return undefined;
2313
2356
  let store: Store | undefined;
@@ -2325,7 +2368,12 @@ export function decisionStatusLine(projectName: string): string | undefined {
2325
2368
  const hours = Math.max(0, Math.round((Date.now() - oldest.askedAt) / 3_600_000));
2326
2369
  line = `decisions ${questions.length} open (oldest ${hours}h)`;
2327
2370
  }
2328
- if (watches.length > 0) line += ` · watches ${watches.length}`;
2371
+ if (watches.length > 0) {
2372
+ const monitoring = watchMonitoringState(layers);
2373
+ line += monitoring.monitored
2374
+ ? ` · watches ${watches.length} monitored`
2375
+ : ` · watches ${watches.length} durable, unmonitored (${monitoring.reason})`;
2376
+ }
2329
2377
  return line;
2330
2378
  } catch {
2331
2379
  return undefined;
@@ -49,7 +49,13 @@ import { spawnSync } from "node:child_process";
49
49
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
50
50
  import { dirname, isAbsolute, join, resolve } from "node:path";
51
51
  import { availabilityPrompt, interruptDisposition } from "./availability.ts";
52
- import { findProject, loadConfig, resolveReleaseGrants, stateDir } from "./config.ts";
52
+ import {
53
+ findProject,
54
+ loadConfig,
55
+ resolveReleaseGrants,
56
+ resolveSharedInstallAuthority,
57
+ stateDir,
58
+ } from "./config.ts";
53
59
  import {
54
60
  bridgeTokenBound,
55
61
  hasBotToken,
@@ -4393,9 +4399,14 @@ export default function orchestratorTickExtension(
4393
4399
  let grants: ResolvedGrants = DENIED_RELEASE_GRANTS;
4394
4400
  let external = true;
4395
4401
  try {
4396
- const project = findProject(loadConfig(), configuredProject);
4402
+ const config = loadConfig();
4403
+ const project = findProject(config, configuredProject);
4404
+ const installAuthority = resolveSharedInstallAuthority(config.projects);
4397
4405
  projectName = project.name;
4398
- grants = resolveReleaseGrants(project);
4406
+ grants = {
4407
+ ...resolveReleaseGrants(project),
4408
+ install: installAuthority.holder ?? "human",
4409
+ };
4399
4410
  external = project.escalation.orchestrator === "external";
4400
4411
  } catch (err) {
4401
4412
  // A missing/unreadable config cannot open a release gate. Log only when
package/src/setup-host.ts CHANGED
@@ -780,11 +780,11 @@ export function renderHerdrUnit(runtime: ServiceRuntime): string {
780
780
  * recovery.
781
781
  */
782
782
  export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string | undefined): string {
783
- // `RECOVER_PROJECT` is how the playbook addresses the tier-2 escalation it
784
- // enqueues. It is a host-global unit installed once for the whole box, so a
785
- // static per-project value is only unambiguous on a single-project host a
786
- // multi-project host (or a no-project, host-global install) leaves it unset
787
- // and reports without a `--project` rather than guessing one (#510/#530).
783
+ // `RECOVER_PROJECT` scopes the recovery action and optional re-arm. It is
784
+ // host-global and installed once, so a static action scope is legitimate
785
+ // only on a single-project host. With several projects it stays unset; the
786
+ // playbook independently derives one deterministic outbox owner from the
787
+ // live config and names every affected project in the report (#1027).
788
788
  return [
789
789
  "[Unit]",
790
790
  "Description=omp-conductor fleet recovery (OnFailure handler)",
@@ -1566,11 +1566,11 @@ export function planHostRuntime(
1566
1566
  const installedPath = join(unitDir, STAGED_SERVICE_NAME);
1567
1567
  const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
1568
1568
  const recoverUnitPath = join(stateDir(), RECOVER_SERVICE_NAME);
1569
- // The recovery unit is host-global: one shared unit, installed once. A
1570
- // static RECOVER_PROJECT is only legitimate when there is exactly one
1571
- // project to be unambiguous about — a multi-project host (or a no-project
1572
- // install) leaves it unset so the escalation reports without attributing a
1573
- // sibling's crash to one project (#510/#530).
1569
+ // The recovery unit is host-global: one shared unit, installed once.
1570
+ // `RECOVER_PROJECT` scopes recovery action/re-arm only and is therefore
1571
+ // omitted on a multi-project host. Report ownership is a separate runtime
1572
+ // decision: the playbook reads the configured project set, chooses its first
1573
+ // project as outbox owner, and lists every project as affected (#1027).
1574
1574
  const recoverProject = project === undefined || multiProject ? undefined : project.name;
1575
1575
  const recoverUnitContent = renderRecoverUnit(runtime, recoverProject);
1576
1576
  const recoverUnit: PlannedWrite<string> = {
@@ -617,7 +617,7 @@ const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]
617
617
  // package floor's "nobody patches the running conductor" is this grant's
618
618
  // deny default.
619
619
  install:
620
- "install — replace this host's installed conductor: the Bun-global CLI, omp plugin " +
620
+ "install — replace this host's installed conductor: the discovered CLI package, omp plugin " +
621
621
  "and Herdr plugin pinned to one published release, executed detached from the fleet",
622
622
  };
623
623
 
@@ -564,8 +564,19 @@ export function formatFleetStatus(
564
564
  })()
565
565
  : `dispatch ${layers.dispatch}`;
566
566
 
567
+ const upgradeRecoveryBlock =
568
+ s.upgradeRecovery === undefined
569
+ ? undefined
570
+ : [
571
+ `upgrade recovery required for failed omp-conductor@${s.upgradeRecovery.failedVersion} (${s.upgradeRecovery.phase})`,
572
+ ` journal ${s.upgradeRecovery.journalPath}`,
573
+ ` owed ${s.upgradeRecovery.verificationOwed}`,
574
+ " release automatic after successful verification; operator acknowledgement: `omp-conductor resume`",
575
+ ].join("\n");
576
+
567
577
  return [
568
578
  dispatchLine,
579
+ ...(upgradeRecoveryBlock === undefined ? [] : [upgradeRecoveryBlock]),
569
580
  tickLine,
570
581
  ...(nextTickLine === undefined ? [] : [nextTickLine]),
571
582
  paneLine,
@@ -1007,6 +1018,16 @@ function formatProjectBody(
1007
1018
  lines.push(...formatSalvagedRuns(s.salvagedRuns));
1008
1019
  lines.push(...formatQuarantinedRuns(s.quarantinedRuns));
1009
1020
  lines.push(...formatOpenReports(s.openReports));
1021
+ const withdrawals = s.handoffWithdrawals ?? [];
1022
+ if (withdrawals.length > 0) {
1023
+ lines.push("handoff withdrawals");
1024
+ for (const withdrawal of withdrawals) {
1025
+ lines.push(
1026
+ ` ${withdrawal.id} ${withdrawal.target} ${new Date(withdrawal.at).toISOString()} ` +
1027
+ `by ${withdrawal.actor} — ${withdrawal.reason}; ${withdrawal.summary}`,
1028
+ );
1029
+ }
1030
+ }
1010
1031
  lines.push(...formatDigestBacklog(s.digestBacklog));
1011
1032
  // The mediated-verb ledger (#972). Absent from this renderer since the block
1012
1033
  // was written (#133) — it was wired into `daemon.ts`'s copy, which was