omp-conductor 0.15.11 → 0.15.13
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/REFERENCE.md +107 -60
- package/package.json +1 -1
- package/schema/config.schema.json +3 -0
- package/src/briefs/orchestrator.md +64 -11
- package/src/briefs/policy.md +19 -3
- package/src/briefs/worker.md +11 -8
- package/src/cli.ts +41 -21
- package/src/commands/context.ts +102 -1
- package/src/commands/doctor.ts +4 -2
- package/src/commands/intake.ts +26 -5
- package/src/commands/message.ts +80 -32
- package/src/commands/report.ts +38 -2
- package/src/commands/restart.ts +81 -54
- package/src/commands/setup.ts +61 -11
- package/src/commands/stop.ts +45 -22
- package/src/commands/upgrade-rollback.ts +9 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +35 -1
- package/src/daemon.ts +588 -37
- package/src/dashboard/app.js +398 -59
- package/src/dashboard/index.html +27 -0
- package/src/dashboard/server.ts +219 -5
- package/src/dashboard/style.css +169 -1
- package/src/doctor.ts +419 -45
- package/src/escalate.ts +8 -0
- package/src/failure-class.ts +37 -0
- package/src/fleet.ts +49 -2
- package/src/gitops.ts +157 -0
- package/src/lifecycle.ts +113 -2
- package/src/model-fallback.ts +177 -0
- package/src/omp.ts +115 -13
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +108 -5
- package/src/orchestrator.ts +18 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +373 -28
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +665 -70
- package/src/setup-install.ts +275 -28
- package/src/setup-wizard.ts +339 -126
- package/src/setup.ts +25 -0
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +194 -1
- package/src/tracker/github.ts +47 -0
- package/src/types.ts +182 -0
- package/src/upgrade.ts +110 -32
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +27 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +24 -3
- package/systemd/omp-conductor.service.example +7 -3
package/src/failure-class.ts
CHANGED
|
@@ -222,6 +222,27 @@ export function dispatchInfra(
|
|
|
222
222
|
// front of the thrown message, so a bare /^git / would never match a real row.
|
|
223
223
|
const DISPATCH_GIT_ERROR = /^(?:Error: )?git .+ exited \d+/s;
|
|
224
224
|
|
|
225
|
+
/** The prefix the draining/restarting process writes to a run it killed. */
|
|
226
|
+
const ADMIN_RESTART_MARKER = "admin restart:";
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The actor a draining restart recorded on a run it killed, or `undefined`
|
|
230
|
+
* when the row carries no such attribution. Read from `lastError` for the
|
|
231
|
+
* same reason {@link dispatchInfra} is read from it: it is the one surface a
|
|
232
|
+
* run's own row already persists, and the restarting process writes it the
|
|
233
|
+
* way `recordOperatorStop` writes "operator stopped: …". Recognised as an
|
|
234
|
+
* administrative kill, never a charging failure, because nothing the worker
|
|
235
|
+
* did ended it — #512's gap was that a killed session left no attribution.
|
|
236
|
+
*/
|
|
237
|
+
export function adminRestartAttribution(lastError: string | undefined): string | undefined {
|
|
238
|
+
if (lastError === undefined) return undefined;
|
|
239
|
+
const marker = lastError.indexOf(ADMIN_RESTART_MARKER);
|
|
240
|
+
if (marker === -1) return undefined;
|
|
241
|
+
const firstLine = lastError.slice(marker + ADMIN_RESTART_MARKER.length).trim().split("\n")[0];
|
|
242
|
+
if (firstLine === undefined || firstLine.trim() === "") return undefined;
|
|
243
|
+
return firstLine.trim();
|
|
244
|
+
}
|
|
245
|
+
|
|
225
246
|
export function classifyRun(
|
|
226
247
|
run: RunRecord,
|
|
227
248
|
facts: ClassifyFacts,
|
|
@@ -300,6 +321,22 @@ export function classifyRun(
|
|
|
300
321
|
}
|
|
301
322
|
}
|
|
302
323
|
|
|
324
|
+
// A run the draining/restarting process attributed before it restarted the
|
|
325
|
+
// session host: killed under its own ceilings by an administrative action,
|
|
326
|
+
// not by anything the worker did. Costs no budget and requeues, naming the
|
|
327
|
+
// actor (#512). A killed session used to leave no attribution and fell
|
|
328
|
+
// through to `unknown`, charging a failure for an operator's restart.
|
|
329
|
+
if (run.state === "failed" || run.state === "killed") {
|
|
330
|
+
const actor = adminRestartAttribution(run.lastError);
|
|
331
|
+
if (actor !== undefined) {
|
|
332
|
+
return {
|
|
333
|
+
cls: "admin-kill",
|
|
334
|
+
recovery: "requeue",
|
|
335
|
+
evidence: `killed by an administrative restart (${actor}) — an operator's restart, not the run's work`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
303
340
|
if (run.state === "blocked") {
|
|
304
341
|
return {
|
|
305
342
|
cls: "question",
|
package/src/fleet.ts
CHANGED
|
@@ -36,7 +36,7 @@ import { readApprovalSurface, readDaemonProfile } from "./approval-surface.ts";
|
|
|
36
36
|
import { inspectBriefLayout } from "./brief-upgrade.ts";
|
|
37
37
|
import { dbPath, openStore } from "./store.ts";
|
|
38
38
|
import { renderBriefForProject } from "./setup.ts";
|
|
39
|
-
import type { ProjectConfig, Store } from "./types.ts";
|
|
39
|
+
import type { DaemonStop, ProjectConfig, Store } from "./types.ts";
|
|
40
40
|
import { settlementFlagSummary } from "./diff-flags.ts";
|
|
41
41
|
import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
42
42
|
import { formatDigestBacklog, formatOpenReports } from "./reports.ts";
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
statusSnapshot,
|
|
53
53
|
type StatusSnapshot,
|
|
54
54
|
} from "./daemon.ts";
|
|
55
|
+
import { formatOrchestratorDown } from "./orchestrator-down.ts";
|
|
55
56
|
import {
|
|
56
57
|
healthCheck,
|
|
57
58
|
isAlive,
|
|
@@ -1410,6 +1411,31 @@ function formatDaemonUnit(unit: UnitOwnership | undefined, recordPid: number): s
|
|
|
1410
1411
|
}
|
|
1411
1412
|
}
|
|
1412
1413
|
|
|
1414
|
+
/**
|
|
1415
|
+
* The newest daemon stop/restart provenance as status lines (#378).
|
|
1416
|
+
*
|
|
1417
|
+
* Rendered under the daemon block whether the daemon is down or has since
|
|
1418
|
+
* been restarted — the whole point is that the debrief survives the restart.
|
|
1419
|
+
* An unattributed record says so explicitly: the external-signal fallback
|
|
1420
|
+
* records what the receiving daemon knew (that it was unattributed, when,
|
|
1421
|
+
* which projects it served with live counts) rather than guessing a caller.
|
|
1422
|
+
*/
|
|
1423
|
+
function formatLastStop(lastStop: DaemonStop | undefined): string[] {
|
|
1424
|
+
if (lastStop === undefined) return [];
|
|
1425
|
+
const caller = lastStop.unattributed
|
|
1426
|
+
? "unattributed — no mediated request (external signal)"
|
|
1427
|
+
: `pid ${lastStop.callerPid ?? "?"}` +
|
|
1428
|
+
(lastStop.callerUid === undefined ? "" : ` uid ${lastStop.callerUid}`) +
|
|
1429
|
+
(lastStop.role === undefined ? "" : ` (${lastStop.role})`);
|
|
1430
|
+
return [
|
|
1431
|
+
` last stop ${new Date(lastStop.at).toISOString()} ${lastStop.controlPath}`,
|
|
1432
|
+
` caller ${caller}`,
|
|
1433
|
+
` scope ${lastStop.scope}${lastStop.project === undefined ? "" : `: ${lastStop.project}`}`,
|
|
1434
|
+
` affects ${lastStop.affected.map((a) => `${a.project} (${a.live} live)`).join(", ")}`,
|
|
1435
|
+
` reason ${lastStop.reason}`,
|
|
1436
|
+
];
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1413
1439
|
export function formatFleetStatus(
|
|
1414
1440
|
s: StatusSnapshot,
|
|
1415
1441
|
layers: FleetLayers,
|
|
@@ -1422,6 +1448,7 @@ export function formatFleetStatus(
|
|
|
1422
1448
|
failureClasses: string | undefined = undefined,
|
|
1423
1449
|
workerPhases: ReadonlyMap<number, WorkerPausePhase> = new Map(),
|
|
1424
1450
|
intake: string | undefined = undefined,
|
|
1451
|
+
lastStop: DaemonStop | undefined = undefined,
|
|
1425
1452
|
): string {
|
|
1426
1453
|
const tickLine =
|
|
1427
1454
|
layers.ticksDetail === undefined
|
|
@@ -1505,8 +1532,9 @@ export function formatFleetStatus(
|
|
|
1505
1532
|
...(intake === undefined ? [] : [intake]),
|
|
1506
1533
|
...(graphBlock === undefined ? [] : [graphBlock]),
|
|
1507
1534
|
daemonBlock,
|
|
1535
|
+
...formatLastStop(lastStop),
|
|
1508
1536
|
"",
|
|
1509
|
-
formatProjectBody(s, workerPhases),
|
|
1537
|
+
formatProjectBody(s, workerPhases, now),
|
|
1510
1538
|
].join("\n");
|
|
1511
1539
|
}
|
|
1512
1540
|
|
|
@@ -1540,12 +1568,18 @@ function formatDigestScheduleStatus(s: StatusSnapshot): string[] {
|
|
|
1540
1568
|
function formatProjectBody(
|
|
1541
1569
|
s: StatusSnapshot,
|
|
1542
1570
|
workerPhases: ReadonlyMap<number, WorkerPausePhase> = new Map(),
|
|
1571
|
+
now = Date.now(),
|
|
1543
1572
|
): string {
|
|
1544
1573
|
const lines = [
|
|
1545
1574
|
`project ${s.project}${s.paused ? " (PAUSED)" : ""}`,
|
|
1546
1575
|
...(s.pauseReason === undefined ? [] : [`paused ${s.pauseReason}`]),
|
|
1547
1576
|
`config ${s.configPath}`,
|
|
1548
1577
|
`state ${s.stateDir}`,
|
|
1578
|
+
// The orchestrator-down degrade row: first-class in the body, present only
|
|
1579
|
+
// while the incident is open, so recovery drops it (#288).
|
|
1580
|
+
...(s.orchestratorDown === undefined
|
|
1581
|
+
? []
|
|
1582
|
+
: formatOrchestratorDown(s.orchestratorDown, now)),
|
|
1549
1583
|
...formatAvailabilityStatus(s),
|
|
1550
1584
|
...formatDigestScheduleStatus(s),
|
|
1551
1585
|
"",
|
|
@@ -1675,6 +1709,18 @@ export async function renderStatus(projectName?: string): Promise<string> {
|
|
|
1675
1709
|
const cached = codeGraphFromHealthz(healthBody, project.name);
|
|
1676
1710
|
const codeGraph = cached ?? (await probeCodeGraph(project));
|
|
1677
1711
|
const workerPhases = workerPhasesFromHealthz(healthBody, project.name);
|
|
1712
|
+
// The newest host-wide stop/restart provenance (#378). Read here — not in
|
|
1713
|
+
// `statusSnapshot`, which is synchronous and belongs to the daemon module —
|
|
1714
|
+
// and rendered identically from either project: the daemon_stops table is
|
|
1715
|
+
// deliberately not partitioned by project, because the daemon serves every
|
|
1716
|
+
// project and the uninvolved one must see who stopped it too.
|
|
1717
|
+
const store = openStore(dbPath());
|
|
1718
|
+
let lastStop: DaemonStop | undefined;
|
|
1719
|
+
try {
|
|
1720
|
+
lastStop = store.latestDaemonStop();
|
|
1721
|
+
} finally {
|
|
1722
|
+
store.close();
|
|
1723
|
+
}
|
|
1678
1724
|
return formatFleetStatus(
|
|
1679
1725
|
{ ...s, planUsage, github },
|
|
1680
1726
|
layers,
|
|
@@ -1687,6 +1733,7 @@ export async function renderStatus(projectName?: string): Promise<string> {
|
|
|
1687
1733
|
failureClassBlock(project.name),
|
|
1688
1734
|
workerPhases,
|
|
1689
1735
|
intakeStatusLine(project.name),
|
|
1736
|
+
lastStop,
|
|
1690
1737
|
);
|
|
1691
1738
|
}
|
|
1692
1739
|
|
package/src/gitops.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* branch cannot disagree.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
20
21
|
import { join } from "node:path";
|
|
21
22
|
|
|
22
23
|
import { parseChainSource, type ChainEntry } from "./chain-check.ts";
|
|
@@ -321,3 +322,159 @@ export async function readBaseChain(
|
|
|
321
322
|
return { ok: false, stderr: err instanceof Error ? err.message : String(err) };
|
|
322
323
|
}
|
|
323
324
|
}
|
|
325
|
+
|
|
326
|
+
// ------------------------------------------- the shared-host critical-base guard
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The stale-base admission verdict (#428): whether a preserved continuation
|
|
330
|
+
* branch may be reattached at all, once a project marks a base commit as
|
|
331
|
+
* critical. Fail closed — a branch that cannot be *proven* to contain every
|
|
332
|
+
* marker is refused.
|
|
333
|
+
*/
|
|
334
|
+
export type CriticalBaseVerdict =
|
|
335
|
+
/** No preserved branch for this issue in the mirror — a clean first attempt. */
|
|
336
|
+
| { state: "no-branch" }
|
|
337
|
+
/** The reattach source contains every configured marker. */
|
|
338
|
+
| { state: "fresh" }
|
|
339
|
+
/** The reattach source predates a marker and cannot be safely advanced to it,
|
|
340
|
+
* so the dispatcher must not reattach it. `range` names the base commits
|
|
341
|
+
* the branch has not absorbed, for the hold's recovery wording. */
|
|
342
|
+
| { state: "stale"; marker: string; range: string[] }
|
|
343
|
+
/** The marker could not be verified at all (unresolvable, fetch failed). */
|
|
344
|
+
| { state: "unknown"; error: string };
|
|
345
|
+
|
|
346
|
+
/** The stubborn-session view of the probe, injectable for deterministic tests. */
|
|
347
|
+
export type CriticalBaseProbe = (
|
|
348
|
+
repo: RepoTarget,
|
|
349
|
+
markers: readonly string[],
|
|
350
|
+
branch: string,
|
|
351
|
+
) => Promise<CriticalBaseVerdict>;
|
|
352
|
+
|
|
353
|
+
/** The bounded sample of base commits a held branch is missing. */
|
|
354
|
+
const CRITICAL_BASE_RANGE_MAX = 8;
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Answer whether the preserved branch {@link addRunRepo} would reattach
|
|
358
|
+
* contains every configured critical-base marker (#428). A base safety fix
|
|
359
|
+
* protects only branches forked after it landed; a continuation forked before
|
|
360
|
+
* it still carries the dangerous code, and re-running the lifecycle suite it
|
|
361
|
+
* retains on a shared host is what SIGTERMed the production daemon.
|
|
362
|
+
*
|
|
363
|
+
* The reattach source is the mirror's `refs/heads/<branch>`. When it lacks a
|
|
364
|
+
* marker but the live remote branch now carries it and the reattach source is
|
|
365
|
+
* that live head's ancestor, the operator advanced the branch on GitHub (e.g.
|
|
366
|
+
* merged base into the PR branch): the fast-forward is folded into the mirror
|
|
367
|
+
* — safe and work-preserving, the same reconcile `pushRunBranch` performs —
|
|
368
|
+
* and the marker is accepted. Judged against the reattach source, never the
|
|
369
|
+
* live head alone, so a stale local copy cannot smuggle pre-fix code back in.
|
|
370
|
+
*/
|
|
371
|
+
export async function probeCriticalBase(
|
|
372
|
+
project: Pick<ProjectConfig, "mirrorRoot">,
|
|
373
|
+
repo: RepoTarget,
|
|
374
|
+
branch: string,
|
|
375
|
+
markers: readonly string[],
|
|
376
|
+
exec: Exec = spawnCaptured,
|
|
377
|
+
): Promise<CriticalBaseVerdict> {
|
|
378
|
+
const mirror = mirrorPath(project, repo);
|
|
379
|
+
// A mirror is where preserved branches live; without one there is nothing to
|
|
380
|
+
// reattach. Judged "no branch" rather than "unknown" so a fleet that has not
|
|
381
|
+
// yet created a mirror is not deadlocked at admission.
|
|
382
|
+
if (!existsSync(mirror)) return { state: "no-branch" };
|
|
383
|
+
const branchRef = `refs/heads/${branch}`;
|
|
384
|
+
const env = credentialedEnv();
|
|
385
|
+
|
|
386
|
+
const present = await exec(
|
|
387
|
+
["git", "--git-dir", mirror, "show-ref", "--verify", "--quiet", branchRef],
|
|
388
|
+
{ env },
|
|
389
|
+
);
|
|
390
|
+
if (present.code !== 0) return { state: "no-branch" };
|
|
391
|
+
|
|
392
|
+
// The configured marker is a commit that landed on base, so base has to be
|
|
393
|
+
// resolvable to prove anything about the branch against it. Fetch today's
|
|
394
|
+
// base tip into the mirror's tracking refs first, so the judgement is against
|
|
395
|
+
// the current base rather than whatever a run last left behind.
|
|
396
|
+
const baseRef = `refs/remotes/origin/${repo.defaultBranch}`;
|
|
397
|
+
const fetched = await exec(
|
|
398
|
+
["git", "--git-dir", mirror, "fetch", "--no-tags", "origin", `+refs/heads/${repo.defaultBranch}:${baseRef}`],
|
|
399
|
+
{ env },
|
|
400
|
+
);
|
|
401
|
+
if (fetched.code !== 0) {
|
|
402
|
+
return {
|
|
403
|
+
state: "unknown",
|
|
404
|
+
error: scrubUserinfo(fetched.stderr.trim() || fetched.stdout.trim() || `git fetch exited ${String(fetched.code)}`),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
for (const marker of markers) {
|
|
409
|
+
const resolved = await exec(
|
|
410
|
+
["git", "--git-dir", mirror, "rev-parse", "--verify", `${marker}^{commit}`],
|
|
411
|
+
{ env },
|
|
412
|
+
);
|
|
413
|
+
if (resolved.code !== 0) {
|
|
414
|
+
return {
|
|
415
|
+
state: "unknown",
|
|
416
|
+
error: `critical-base marker "${marker}" is not resolvable in the ${repo.name} mirror`,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const markerSha = resolved.stdout.trim();
|
|
420
|
+
if (markerSha === "") {
|
|
421
|
+
return { state: "unknown", error: `critical-base marker "${marker}" resolved to no commit` };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const localHas = await exec(
|
|
425
|
+
["git", "--git-dir", mirror, "merge-base", "--is-ancestor", markerSha, branchRef],
|
|
426
|
+
{ env },
|
|
427
|
+
);
|
|
428
|
+
if (localHas.code === 0) continue;
|
|
429
|
+
|
|
430
|
+
// The reattach source lacks the marker. If the live branch now carries it
|
|
431
|
+
// and the reattach source is its ancestor, the operator advanced the branch
|
|
432
|
+
// on GitHub (e.g. merged base into the PR branch): fold that fast-forward
|
|
433
|
+
// into the reattach source — safe, work-preserving — and accept the marker.
|
|
434
|
+
const tracked = `refs/remotes/origin/${branch}`;
|
|
435
|
+
const liveFetched = await exec(
|
|
436
|
+
["git", "--git-dir", mirror, "fetch", "--no-tags", "origin", `+refs/heads/${branch}:${tracked}`],
|
|
437
|
+
{ env },
|
|
438
|
+
);
|
|
439
|
+
if (liveFetched.code !== 0) {
|
|
440
|
+
return {
|
|
441
|
+
state: "unknown",
|
|
442
|
+
error: scrubUserinfo(liveFetched.stderr.trim() || liveFetched.stdout.trim() || `git fetch ${branch} exited ${String(liveFetched.code)}`),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
const liveHas = await exec(
|
|
446
|
+
["git", "--git-dir", mirror, "merge-base", "--is-ancestor", markerSha, tracked],
|
|
447
|
+
{ env },
|
|
448
|
+
);
|
|
449
|
+
if (liveHas.code === 0) {
|
|
450
|
+
const localIsAncestor = await exec(
|
|
451
|
+
["git", "--git-dir", mirror, "merge-base", "--is-ancestor", branchRef, tracked],
|
|
452
|
+
{ env },
|
|
453
|
+
);
|
|
454
|
+
if (localIsAncestor.code === 0) {
|
|
455
|
+
await exec(["git", "--git-dir", mirror, "fetch", "--no-tags", "origin", `+${branchRef}:${branchRef}`], { env });
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Predates the marker and cannot be safely advanced to it. Name the base
|
|
461
|
+
// commits the branch has not absorbed so the hold's recovery is concrete.
|
|
462
|
+
const range: string[] = [];
|
|
463
|
+
const missing = await exec(
|
|
464
|
+
["git", "--git-dir", mirror, "log", "--oneline", "--format=%h", `${branchRef}..${baseRef}`],
|
|
465
|
+
{ env },
|
|
466
|
+
);
|
|
467
|
+
if (missing.code === 0) {
|
|
468
|
+
range.push(
|
|
469
|
+
...missing.stdout
|
|
470
|
+
.trim()
|
|
471
|
+
.split("\n")
|
|
472
|
+
.filter((line) => line.length > 0)
|
|
473
|
+
.slice(0, CRITICAL_BASE_RANGE_MAX),
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
return { state: "stale", marker: markerSha, range };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return { state: "fresh" };
|
|
480
|
+
}
|
package/src/lifecycle.ts
CHANGED
|
@@ -25,6 +25,10 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
25
25
|
import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
26
26
|
import { homedir } from "node:os";
|
|
27
27
|
import { join } from "node:path";
|
|
28
|
+
// Type-only: erased at runtime, so the "free of every other module" property
|
|
29
|
+
// below survives — this module still opens no store, loads no config and runs
|
|
30
|
+
// no `gh`, and `stop`/`status` keep working when the config is broken.
|
|
31
|
+
import type { DaemonStopDraft } from "./types.ts";
|
|
28
32
|
|
|
29
33
|
/**
|
|
30
34
|
* The systemd unit name operators are expected to install for a supervised
|
|
@@ -480,6 +484,58 @@ export async function startDaemon(
|
|
|
480
484
|
);
|
|
481
485
|
}
|
|
482
486
|
|
|
487
|
+
/**
|
|
488
|
+
* The delivery facts a mediated stop/restart records at the exact chokepoint:
|
|
489
|
+
* the request's own provenance, how the stop is about to be delivered, and the
|
|
490
|
+
* daemon pid it targets (#378). The recorder is injected by the CLI callers
|
|
491
|
+
* (which own the store); this module stays free of it.
|
|
492
|
+
*/
|
|
493
|
+
export interface StopDelivery {
|
|
494
|
+
/** The request facts built by the CLI (commands/stop.ts, commands/restart.ts). */
|
|
495
|
+
provenance: DaemonStopDraft;
|
|
496
|
+
/** Whether the stop is about to go through `systemctl` or a raw signal. */
|
|
497
|
+
via: "systemctl" | "signal";
|
|
498
|
+
/** The daemon pid being stopped, when one was known. */
|
|
499
|
+
pid?: number;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** The injected recorder invoked immediately before a stop/restart is signalled. */
|
|
503
|
+
export type StopDeliveryFn = (stop: StopDelivery) => void;
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The honest provenance for a daemon that received a stop with no mediated
|
|
507
|
+
* request anywhere: unattributed by construction — Linux exposes no sender
|
|
508
|
+
* identity for a signal, so the record must say "unattributed" rather than
|
|
509
|
+
* guess one — plus what the receiving process actually knows: its own pid,
|
|
510
|
+
* the moment (stamped by the store), the runtime directory, and the projects
|
|
511
|
+
* it serves with their live-run counts.
|
|
512
|
+
*
|
|
513
|
+
* Storage belongs at the caller: the daemon's SIGINT/SIGTERM path writes the
|
|
514
|
+
* returned draft through the store before/while draining. This builds the
|
|
515
|
+
* facts, store-free, so a raw `kill`/out-of-band `systemctl stop` — no
|
|
516
|
+
* conductor CLI in the delivery path at all — still leaves a durable row the
|
|
517
|
+
* next `omp-conductor status` can show.
|
|
518
|
+
*/
|
|
519
|
+
export function externalStopProvenance(o: {
|
|
520
|
+
/** The daemon pid that received the signal, when known. */
|
|
521
|
+
daemonPid?: number;
|
|
522
|
+
/** The daemon's runtime directory (host paths only — never secrets). */
|
|
523
|
+
runtimeDir: string;
|
|
524
|
+
/** Every served project with its live-run count at signal time. */
|
|
525
|
+
affected: { project: string; live: number }[];
|
|
526
|
+
reason?: string;
|
|
527
|
+
}): DaemonStopDraft {
|
|
528
|
+
return {
|
|
529
|
+
controlPath: "external signal",
|
|
530
|
+
scope: "global",
|
|
531
|
+
...(o.daemonPid === undefined ? {} : { daemonPid: o.daemonPid }),
|
|
532
|
+
runtimeDir: o.runtimeDir,
|
|
533
|
+
affected: o.affected,
|
|
534
|
+
reason: o.reason ?? "external signal — no mediated stop request was recorded",
|
|
535
|
+
unattributed: true,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
483
539
|
/**
|
|
484
540
|
* How the last stop actually landed. Callers print this so an operator can
|
|
485
541
|
* tell a supervised stop from a bare SIGTERM without reading the journal.
|
|
@@ -510,7 +566,35 @@ export interface RestartResult {
|
|
|
510
566
|
* a stopped unit). The grace period is a deadline, not a clean drain: a tick
|
|
511
567
|
* with a worker in flight can run for that worker's whole wall clock.
|
|
512
568
|
*/
|
|
513
|
-
export async function stopDaemon(
|
|
569
|
+
export async function stopDaemon(
|
|
570
|
+
o: {
|
|
571
|
+
timeoutMs?: number;
|
|
572
|
+
/**
|
|
573
|
+
* The request facts for this stop, when a mediated caller carries them.
|
|
574
|
+
* With `record`, the durable row is written immediately before the signal
|
|
575
|
+
* is sent — the exact ordering "provenance precedes signalling" refers to
|
|
576
|
+
* — and only when a stop actually lands: a request that finds no daemon
|
|
577
|
+
* stops nothing and records nothing. Drain-style callers whose restart may
|
|
578
|
+
* never execute additionally record the request at entry themselves.
|
|
579
|
+
*/
|
|
580
|
+
provenance?: DaemonStopDraft;
|
|
581
|
+
/** Invoked just before the daemon is signalled, with the delivery method. */
|
|
582
|
+
record?: StopDeliveryFn;
|
|
583
|
+
} = {},
|
|
584
|
+
): Promise<StopResult> {
|
|
585
|
+
const recordStop = (via: "systemctl" | "signal", pid: number | undefined): void => {
|
|
586
|
+
if (o.provenance === undefined || o.record === undefined) return;
|
|
587
|
+
o.record({
|
|
588
|
+
provenance: {
|
|
589
|
+
...o.provenance,
|
|
590
|
+
controlPath: `${o.provenance.controlPath} via ${via}`,
|
|
591
|
+
...(pid === undefined ? {} : { daemonPid: pid }),
|
|
592
|
+
},
|
|
593
|
+
via,
|
|
594
|
+
pid,
|
|
595
|
+
});
|
|
596
|
+
};
|
|
597
|
+
|
|
514
598
|
const rec = livingDaemon();
|
|
515
599
|
if (rec === undefined) {
|
|
516
600
|
// `livingDaemon` already cleared a stale file; this covers the unparseable
|
|
@@ -521,6 +605,7 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
|
|
|
521
605
|
throw new Error(ownershipUnknown("stop", decision.reason));
|
|
522
606
|
}
|
|
523
607
|
if (decision.kind === "stop") {
|
|
608
|
+
recordStop("systemctl", decision.mainPid);
|
|
524
609
|
await runSystemdStop(decision.mainPid, o.timeoutMs);
|
|
525
610
|
clearRecord();
|
|
526
611
|
return { kind: "stopped", pid: decision.mainPid, via: "systemctl" };
|
|
@@ -536,6 +621,7 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
|
|
|
536
621
|
throw new Error(ownershipUnknown("stop", decision.reason));
|
|
537
622
|
}
|
|
538
623
|
if (decision.kind === "stop") {
|
|
624
|
+
recordStop("systemctl", rec.pid);
|
|
539
625
|
await runSystemdStop(rec.pid, o.timeoutMs);
|
|
540
626
|
clearRecord();
|
|
541
627
|
return { kind: "stopped", pid: rec.pid, via: "systemctl" };
|
|
@@ -544,6 +630,7 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
|
|
|
544
630
|
// decision.kind === "not-ours": confirmed no unit, inactive unit, no systemd
|
|
545
631
|
// binary, or a unit whose MainPID is somebody else. Only a *confirmed*
|
|
546
632
|
// negative is safe to signal.
|
|
633
|
+
recordStop("signal", rec.pid);
|
|
547
634
|
const gone = await terminate(rec.pid, o.timeoutMs ?? STOP_TIMEOUT_MS);
|
|
548
635
|
if (!gone) {
|
|
549
636
|
// The record stays: something is still holding that pid, and forgetting
|
|
@@ -577,8 +664,29 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
|
|
|
577
664
|
* Returns the record of the process that is now answering `/healthz`.
|
|
578
665
|
*/
|
|
579
666
|
export async function restartDaemon(
|
|
580
|
-
o: {
|
|
667
|
+
o: {
|
|
668
|
+
port?: number;
|
|
669
|
+
project?: string;
|
|
670
|
+
timeoutMs?: number;
|
|
671
|
+
/** Request facts and recorder, same semantics as {@link stopDaemon}: the
|
|
672
|
+
* durable row is written immediately before the restarting signal. */
|
|
673
|
+
provenance?: DaemonStopDraft;
|
|
674
|
+
record?: StopDeliveryFn;
|
|
675
|
+
} = {},
|
|
581
676
|
): Promise<RestartResult> {
|
|
677
|
+
const recordStop = (via: "systemctl" | "signal", pid: number | undefined): void => {
|
|
678
|
+
if (o.provenance === undefined || o.record === undefined) return;
|
|
679
|
+
o.record({
|
|
680
|
+
provenance: {
|
|
681
|
+
...o.provenance,
|
|
682
|
+
controlPath: `${o.provenance.controlPath} via ${via}`,
|
|
683
|
+
...(pid === undefined ? {} : { daemonPid: pid }),
|
|
684
|
+
},
|
|
685
|
+
via,
|
|
686
|
+
pid,
|
|
687
|
+
});
|
|
688
|
+
};
|
|
689
|
+
|
|
582
690
|
const previous = livingDaemon();
|
|
583
691
|
const ownership = probeUnit();
|
|
584
692
|
if (ownership.kind === "unknown") {
|
|
@@ -591,6 +699,7 @@ export async function restartDaemon(
|
|
|
591
699
|
// startDaemon() — that leaves the unit failed while handing the operator
|
|
592
700
|
// a healthy-looking but unmanaged pid (the 12:17Z incident in #376).
|
|
593
701
|
// Any manager refusal is terminal; so is unproven ownership afterwards.
|
|
702
|
+
recordStop("systemctl", previous?.pid);
|
|
594
703
|
const record = await restoreFailedUnit(o.timeoutMs, o.project);
|
|
595
704
|
return { previous, record, via: "systemctl" };
|
|
596
705
|
}
|
|
@@ -602,6 +711,7 @@ export async function restartDaemon(
|
|
|
602
711
|
// Ownership is proven. A refused/timed-out restart must not fall through
|
|
603
712
|
// to stopDaemon's signal path — that is the exact bounce this module exists
|
|
604
713
|
// to prevent (SIGTERM → exit 143 → Restart=on-failure → new MainPID).
|
|
714
|
+
recordStop("systemctl", ownership.pid);
|
|
605
715
|
const ran = systemctl(["restart", SYSTEMD_UNIT]);
|
|
606
716
|
if (!ran.ok) {
|
|
607
717
|
throw new Error(systemctlFailure("restart", ran));
|
|
@@ -615,6 +725,7 @@ export async function restartDaemon(
|
|
|
615
725
|
|
|
616
726
|
// Confirmed unmanaged: no unit, an inactive unit, or a unit whose MainPID
|
|
617
727
|
// is somebody else. The detached CLI daemon is the only path left.
|
|
728
|
+
recordStop("signal", previous?.pid);
|
|
618
729
|
await stopDaemon({ timeoutMs: o.timeoutMs });
|
|
619
730
|
const record = await startDaemon({ port: o.port ?? previous?.port, project: o.project ?? previous?.project });
|
|
620
731
|
return { previous, record, via: "cli" };
|