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/package.json +1 -1
- package/src/cli.ts +2 -0
- package/src/command-help.ts +18 -8
- package/src/command-manifest.ts +22 -5
- package/src/commands/context.ts +1 -0
- package/src/commands/report.ts +63 -0
- package/src/commands/restore-db.ts +1 -1
- package/src/commands/resume.ts +5 -0
- package/src/commands/snapshot-db.ts +67 -0
- package/src/commands/upgrade-install.ts +1 -1
- package/src/commands/upgrade.ts +2 -2
- package/src/commands/verb.ts +1 -0
- package/src/commands/watch.ts +14 -2
- package/src/config.ts +17 -0
- package/src/daemon.ts +54 -8
- package/src/dashboard/controls.ts +3 -0
- package/src/doctor.ts +36 -3
- package/src/fleet.ts +51 -3
- package/src/orchestrator-tick.ts +14 -3
- package/src/setup-host.ts +10 -10
- package/src/setup-wizard.ts +1 -1
- package/src/status-render.ts +21 -0
- package/src/store.ts +296 -22
- package/src/types.ts +32 -3
- package/src/upgrade-journal.ts +37 -1
- package/src/upgrade-verify.ts +272 -37
- package/src/upgrade.ts +324 -23
- package/src/verbs/actions.ts +6 -1
- package/src/verbs/server.ts +54 -20
- package/systemd/omp-conductor-recover.sh +55 -7
- package/systemd/recover-unit-test.sh +99 -6
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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(
|
|
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)
|
|
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;
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
4402
|
+
const config = loadConfig();
|
|
4403
|
+
const project = findProject(config, configuredProject);
|
|
4404
|
+
const installAuthority = resolveSharedInstallAuthority(config.projects);
|
|
4397
4405
|
projectName = project.name;
|
|
4398
|
-
grants =
|
|
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`
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
// and
|
|
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.
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
1573
|
-
//
|
|
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> = {
|
package/src/setup-wizard.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/status-render.ts
CHANGED
|
@@ -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
|