omp-conductor 0.20.2 → 0.20.3
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/commands/daemon.ts +14 -25
- package/src/daemon/tick.ts +15 -1
- package/src/diff-flags.ts +93 -9
- package/src/doctor.ts +295 -2
- package/src/fleet.ts +402 -45
- package/src/knowledge.ts +75 -21
- package/src/lifecycle.ts +267 -5
- package/src/ready-gate.ts +100 -5
- package/src/upgrade.ts +12 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
package/src/commands/daemon.ts
CHANGED
|
@@ -8,15 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { CommandContext } from "./context.ts";
|
|
10
10
|
import { runDaemon } from "../daemon.ts";
|
|
11
|
-
import { clearRecord,
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* `DaemonRecord.logFile` for a daemon nobody spawned. The field is required and
|
|
15
|
-
* `status` prints it, so it has to say something true: a foreground daemon
|
|
16
|
-
* opened no log of its own — whoever started it owns its stdout, be that
|
|
17
|
-
* systemd's journal, a terminal, or a pane.
|
|
18
|
-
*/
|
|
19
|
-
const FOREGROUND_LOG = "<inherited stdout — started in the foreground>";
|
|
11
|
+
import { clearRecord, publishForegroundRecord, startRecordRepair } from "../lifecycle.ts";
|
|
20
12
|
|
|
21
13
|
export async function daemonCommand(ctx: CommandContext): Promise<void> {
|
|
22
14
|
// Until now only `lifecycle.startDaemon()` — the spawn path — wrote the
|
|
@@ -42,28 +34,25 @@ if (once) {
|
|
|
42
34
|
return;
|
|
43
35
|
}
|
|
44
36
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
// The boot half of pidfile ownership (#1114): a systemd-started or hand-run
|
|
38
|
+
// foreground daemon publishes its own record in lifecycle — refusing, having
|
|
39
|
+
// done nothing, while another live daemon owns the record.
|
|
40
|
+
const claimed = publishForegroundRecord({ port, project });
|
|
41
|
+
if (claimed.kind === "foreign-live") {
|
|
42
|
+
process.stderr.write(`omp-conductor: another daemon is alive (pid ${claimed.pid}); stop it first\n`);
|
|
48
43
|
process.exit(1);
|
|
49
44
|
}
|
|
50
45
|
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
writeRecord({
|
|
56
|
-
pid: process.pid,
|
|
57
|
-
port: port ?? DEFAULT_PORT,
|
|
58
|
-
startedAt: Date.now(),
|
|
59
|
-
logFile: FOREGROUND_LOG,
|
|
60
|
-
...(project === undefined ? {} : { project }),
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
46
|
+
// The running half (#1114): while this process is the daemon it keeps its own
|
|
47
|
+
// record alive, so an external sweep of the runtime directory cannot leave
|
|
48
|
+
// the single-owner guard disarmed until the next restart.
|
|
49
|
+
const repair = startRecordRepair({ port, project });
|
|
64
50
|
try {
|
|
65
51
|
await runDaemon({ once, port, project });
|
|
66
52
|
} finally {
|
|
53
|
+
// Stop repairing before clearing, so no interval tick can resurrect the
|
|
54
|
+
// file after the removal below.
|
|
55
|
+
repair.stop();
|
|
67
56
|
// The record names a pid that is about to stop existing. Leaving it
|
|
68
57
|
// behind makes the next reader probe a ghost before believing us.
|
|
69
58
|
clearRecord();
|
package/src/daemon/tick.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { admitCandidates, startOfToday, type AdmissionHold } from "../admission.
|
|
|
22
22
|
import { dbBackupDirFor, findProject, loadConfig, resolveCaps, stateDir } from "../config.ts";
|
|
23
23
|
import { evaluateDecisionConditions, probeNpmVersion, probeRateLimitReset } from "../decisions.ts";
|
|
24
24
|
import { dbSnapshotDue, dbSnapshotMarkerKey, localDayKey } from "../digest-schedule.ts";
|
|
25
|
-
import { runDoctor } from "../doctor.ts";
|
|
25
|
+
import { reconcileOrphanedPause, runDoctor } from "../doctor.ts";
|
|
26
26
|
import { fleetLayers, herdrPaneOmpStarts, resolveHerdrSession } from "../fleet.ts";
|
|
27
27
|
import { projectLabels } from "../label-projection.ts";
|
|
28
28
|
import { healthCheck } from "../lifecycle.ts";
|
|
@@ -410,6 +410,20 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
410
410
|
escalate: (event) => d.escalate(event),
|
|
411
411
|
log,
|
|
412
412
|
});
|
|
413
|
+
// The orphaned-pause watch rides the same above-the-pause-gate band (#1113):
|
|
414
|
+
// a sentinel whose writer has exited is exactly the condition a paused
|
|
415
|
+
// fleet cannot surface through its own gates below, and an `upgrade` pause
|
|
416
|
+
// can never be cleared by any future event. The notification ledger dedupes
|
|
417
|
+
// the page to once per sentinel instance; resuming stays a human decision.
|
|
418
|
+
try {
|
|
419
|
+
await reconcileOrphanedPause({
|
|
420
|
+
project: d.project.name,
|
|
421
|
+
escalate: (event) => d.escalate(event),
|
|
422
|
+
log,
|
|
423
|
+
});
|
|
424
|
+
} catch (err) {
|
|
425
|
+
log(`orphaned-pause watch failed: ${errText(err)}`);
|
|
426
|
+
}
|
|
413
427
|
|
|
414
428
|
// Beside the two orchestrator watches, and above the pause gate, for the same
|
|
415
429
|
// reason they are: mining reads rows this store already holds and files intake
|
package/src/diff-flags.ts
CHANGED
|
@@ -898,7 +898,7 @@ function scanHunks(
|
|
|
898
898
|
* an extractor that reads any backtick span in the narrative as a claim would
|
|
899
899
|
* flag every run within a day.
|
|
900
900
|
*
|
|
901
|
-
*
|
|
901
|
+
* Four tolerances make the matcher honest rather than decorative:
|
|
902
902
|
*
|
|
903
903
|
* - Claims are extracted only from a verified section — a `## Verified`-style
|
|
904
904
|
* heading or an inline `Verified:` label — never from the narrative. This is
|
|
@@ -915,6 +915,14 @@ function scanHunks(
|
|
|
915
915
|
* the worker *tried* and the guard stopped it (the honest #566/#570 shape,
|
|
916
916
|
* whose own words are "CI owns them"), which is not the same finding as a
|
|
917
917
|
* claim with no attempt at all.
|
|
918
|
+
* - Claims are positions before they are texts (#1102): a unit of the region
|
|
919
|
+
* claims what it opens with — a bullet or bare line whose lead is the
|
|
920
|
+
* backticked command, or a `**Verified:**` label declaring its whole row —
|
|
921
|
+
* never a span quoted mid-sentence. The soft-wrapped prose that quoted
|
|
922
|
+
* `issue is closed` inside #1097's Verified bullet is commentary whatever
|
|
923
|
+
* words it quotes, and the structural rule says so without a runner
|
|
924
|
+
* vocabulary that would silently stop auditing the first PR proved by
|
|
925
|
+
* anything else.
|
|
918
926
|
*/
|
|
919
927
|
|
|
920
928
|
/** The verified-section markers a claim must live under: a heading whose text
|
|
@@ -975,20 +983,96 @@ function claimedCommand(span: string): boolean {
|
|
|
975
983
|
return EXECUTABLE_TOKEN.test(words[0] ?? "");
|
|
976
984
|
}
|
|
977
985
|
|
|
986
|
+
/** A markdown list marker opening a line: `- item`, `* item`, `1. item`,
|
|
987
|
+
* `2) item`. The required trailing whitespace is what keeps a bold
|
|
988
|
+
* `**Verified:**` row or an italic `*span*` from reading as a bullet. */
|
|
989
|
+
const LIST_MARKER = /^[ \t]*(?:[-*+]|\d+[.)])[ \t]+/;
|
|
990
|
+
|
|
991
|
+
/** The inline verified label a claims row can open with — `**Verified:**`,
|
|
992
|
+
* `Verification:`, `proof.` — mirroring the label branch of
|
|
993
|
+
* {@link VERIFIED_MARKER}, so the very row that opens the region also parses
|
|
994
|
+
* as claims-bearing. */
|
|
995
|
+
const CLAIMS_ROW_LABEL = /^\s*(?:\*\*)?(?:Verif(?:ied|ication)|Proof)(?:\*\*)?\s*[:.]\s*/i;
|
|
996
|
+
|
|
997
|
+
/** Every backticked span in `text`, trimmed. */
|
|
998
|
+
function backtickSpans(text: string): string[] {
|
|
999
|
+
return [...text.matchAll(/`([^`\n]+)`/g)].map((match) => (match[1] ?? "").trim());
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** The one backticked span `text` opens with, when it opens with one. */
|
|
1003
|
+
function leadingBacktickSpan(text: string): string | undefined {
|
|
1004
|
+
const match = /^`([^`\n]+)`/.exec(text);
|
|
1005
|
+
const span = (match?.[1] ?? "").trim();
|
|
1006
|
+
return span === "" ? undefined : span;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* The logical line-units of the verified region. A heading or a list marker
|
|
1011
|
+
* always opens a unit, a blank line ends one, and every other non-blank line
|
|
1012
|
+
* is the markdown lazy continuation of the unit above it. That continuation
|
|
1013
|
+
* rule is the foundation of the #1102 fix: a bullet soft-wrapped across
|
|
1014
|
+
* physical lines stays ONE unit, so its wrapped second line — exactly where
|
|
1015
|
+
* #1097's quoted log line sat — can never pose as a fresh claim-bearing line.
|
|
1016
|
+
* The cost is deliberate and stated: bare claim lines stacked without markers
|
|
1017
|
+
* or blank lines merge into one unit, and only their first extracts. This
|
|
1018
|
+
* fleet writes claims as bullets, so the lost shape is the rarer one.
|
|
1019
|
+
*/
|
|
1020
|
+
function* claimUnits(region: string): Generator<string[]> {
|
|
1021
|
+
let unit: string[] = [];
|
|
1022
|
+
for (const line of region.split("\n")) {
|
|
1023
|
+
if (/^#{1,6}\s/.test(line) || LIST_MARKER.test(line)) {
|
|
1024
|
+
if (unit.length > 0) yield unit;
|
|
1025
|
+
unit = [line];
|
|
1026
|
+
} else if (line.trim() === "") {
|
|
1027
|
+
if (unit.length > 0) yield unit;
|
|
1028
|
+
unit = [];
|
|
1029
|
+
} else {
|
|
1030
|
+
unit.push(line);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (unit.length > 0) yield unit;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* The claim candidates one unit makes — a matter of position, not vocabulary
|
|
1038
|
+
* (#1102). A unit claims what it OPENS with:
|
|
1039
|
+
*
|
|
1040
|
+
* - a verified-label row (`**Verified:** …`, the #749/#731 semicolon-joined
|
|
1041
|
+
* shape) declares every span it carries a claim — the label is what marks
|
|
1042
|
+
* the whole row as proof;
|
|
1043
|
+
* - any other unit contributes only its leading span — `- \`cd omp && bun test …\`
|
|
1044
|
+
* — 42 pass, 0 fail.` or a bare `\`./scripts/deploy.sh\` — deployed.` line —
|
|
1045
|
+
* whose tail is commentary about the result.
|
|
1046
|
+
*
|
|
1047
|
+
* A span embedded after prose is neither: `…assert exactly one log line naming
|
|
1048
|
+
* \`issue is closed\`` quotes the transcript's own output, and quotation is
|
|
1049
|
+
* commentary whatever the quoted words spell.
|
|
1050
|
+
*/
|
|
1051
|
+
function unitClaimSpans(unit: string[]): string[] {
|
|
1052
|
+
const lead = (unit[0] ?? "").replace(LIST_MARKER, "");
|
|
1053
|
+
if (CLAIMS_ROW_LABEL.test(lead)) {
|
|
1054
|
+
return backtickSpans([lead.replace(CLAIMS_ROW_LABEL, ""), ...unit.slice(1)].join("\n"));
|
|
1055
|
+
}
|
|
1056
|
+
const span = leadingBacktickSpan(lead);
|
|
1057
|
+
return span === undefined ? [] : [span];
|
|
1058
|
+
}
|
|
1059
|
+
|
|
978
1060
|
/**
|
|
979
1061
|
* The commands a PR body claims as proof, each deduplicated by its exact span.
|
|
980
|
-
*
|
|
981
|
-
*
|
|
1062
|
+
* Extraction is structural twice over: only spans inside the verified region
|
|
1063
|
+
* count ({@link claimRegion}), and only spans in a claim-bearing position
|
|
1064
|
+
* within it ({@link unitClaimSpans}) — the two filters together are what keep
|
|
1065
|
+
* `## Verified`-island prose out of the audit.
|
|
982
1066
|
*/
|
|
983
1067
|
function claimedProofCommands(body: string): string[] {
|
|
984
|
-
const region = claimRegion(body);
|
|
985
1068
|
const claims: string[] = [];
|
|
986
1069
|
const seen = new Set<string>();
|
|
987
|
-
for (const
|
|
988
|
-
const span
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1070
|
+
for (const unit of claimUnits(claimRegion(body))) {
|
|
1071
|
+
for (const span of unitClaimSpans(unit)) {
|
|
1072
|
+
if (seen.has(span) || !claimedCommand(span)) continue;
|
|
1073
|
+
seen.add(span);
|
|
1074
|
+
claims.push(span);
|
|
1075
|
+
}
|
|
992
1076
|
}
|
|
993
1077
|
return claims;
|
|
994
1078
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -61,7 +61,9 @@ import {
|
|
|
61
61
|
sessionDirForCwd,
|
|
62
62
|
telegramStateDir,
|
|
63
63
|
} from "./fleet.ts";
|
|
64
|
-
import {
|
|
64
|
+
import { errText } from "./log.ts";
|
|
65
|
+
import { formatDownDuration } from "./orchestrator-down.ts";
|
|
66
|
+
import { pauseInstance, pauseInstanceAt, pausedPath } from "./pause.ts";
|
|
65
67
|
import type { TelegramHealth } from "./status-render.ts";
|
|
66
68
|
import {
|
|
67
69
|
claimIsLive,
|
|
@@ -105,7 +107,7 @@ import {
|
|
|
105
107
|
readTickConfig,
|
|
106
108
|
type HerdrAgentList,
|
|
107
109
|
} from "./orchestrator-tick.ts";
|
|
108
|
-
import type { Caps, ConductorConfig, ProjectConfig, RepoTarget, RunState } from "./types.ts";
|
|
110
|
+
import type { Caps, ConductorConfig, Escalation, ProjectConfig, RepoTarget, RunState } from "./types.ts";
|
|
109
111
|
import { DEFAULT_ARM_PROOF, type ArmProof } from "./types.ts";
|
|
110
112
|
import {
|
|
111
113
|
DEFAULT_DEPS as UPGRADE_DEPS,
|
|
@@ -275,6 +277,13 @@ export interface DoctorDeps {
|
|
|
275
277
|
/** Whether one pid is a live process. Injected so the abandoned-fence
|
|
276
278
|
* finding is testable without spawning anything. */
|
|
277
279
|
pidLive?: (pid: number) => boolean;
|
|
280
|
+
/**
|
|
281
|
+
* Whether any conductor process is running an upgrade transaction right
|
|
282
|
+
* now (#1113) — the liveness fact an owner-less `upgrade` sentinel needs,
|
|
283
|
+
* because it records no pid of its own. Injected so the orphaned-upgrade
|
|
284
|
+
* fault is testable without scanning /proc.
|
|
285
|
+
*/
|
|
286
|
+
upgradeLive?: () => boolean;
|
|
278
287
|
/** Live `herdr --session <s> agent list`, parsed through the tick's own
|
|
279
288
|
* parser (the same "one JSON line on stdout" contract recover.sh reads). */
|
|
280
289
|
herdrAgents?: (session: string) => HerdrAgentList;
|
|
@@ -1671,6 +1680,286 @@ function fenceProbe(probes: Probes, project: string | undefined): Finding {
|
|
|
1671
1680
|
);
|
|
1672
1681
|
}
|
|
1673
1682
|
|
|
1683
|
+
/**
|
|
1684
|
+
* Whether any conductor process is running an upgrade transaction (#1113).
|
|
1685
|
+
*
|
|
1686
|
+
* The upgrade sentinel records no `owner=` — unlike a setup fence, whose pid
|
|
1687
|
+
* is precise, the drain spans subprocesses and the detached installer is a
|
|
1688
|
+
* transient unit — so the honest question is whether ANY conductor process is
|
|
1689
|
+
* mid-upgrade at all. Read from `/proc` cmdlines, never inferred from journal
|
|
1690
|
+
* state: a journal outlives its crash, which is exactly the ambiguity this
|
|
1691
|
+
* check exists to cut through. The whole transaction family matches
|
|
1692
|
+
* (`upgrade`, the detached `upgrade-install`, `upgrade-rollback`); unreadable
|
|
1693
|
+
* entries are skipped, and a zombie's empty cmdline cannot match.
|
|
1694
|
+
*/
|
|
1695
|
+
function upgradeProcessLive(): boolean {
|
|
1696
|
+
try {
|
|
1697
|
+
for (const entry of readdirSync("/proc")) {
|
|
1698
|
+
if (!/^\d+$/.test(entry)) continue;
|
|
1699
|
+
let raw: string;
|
|
1700
|
+
try {
|
|
1701
|
+
raw = readFileSync(join("/proc", entry, "cmdline"), "utf8");
|
|
1702
|
+
} catch {
|
|
1703
|
+
continue; // vanished mid-scan, or not ours to read
|
|
1704
|
+
}
|
|
1705
|
+
const argv = raw.split("\0").filter((arg) => arg.length > 0);
|
|
1706
|
+
const conductor = argv.some((arg) => /(^|\/)(omp-conductor|cli\.ts)$/.test(arg));
|
|
1707
|
+
if (conductor && argv.some((arg) => arg === "upgrade" || arg.startsWith("upgrade-"))) return true;
|
|
1708
|
+
}
|
|
1709
|
+
} catch {
|
|
1710
|
+
return false;
|
|
1711
|
+
}
|
|
1712
|
+
return false;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/**
|
|
1716
|
+
* The pause-state judgement shared by the doctor row and the daemon watch
|
|
1717
|
+
* (#1113), so the two surfaces cannot drift apart about which pauses still
|
|
1718
|
+
* have a plausible owner. The rules, in the order they are applied:
|
|
1719
|
+
*
|
|
1720
|
+
* - a recorded `owner=` is the precise fact whenever one exists (#938);
|
|
1721
|
+
* - `hold` is supported at any age — stopping the fleet deliberately is not
|
|
1722
|
+
* a fault, and must never nag;
|
|
1723
|
+
* - `upgrade` owns its drain by existing: a live transaction is fine, an
|
|
1724
|
+
* exited one is the one fault class, because no future event can ever
|
|
1725
|
+
* clear that sentinel;
|
|
1726
|
+
* - `spend-cap` clears itself when spend falls back below the cap;
|
|
1727
|
+
* - every other durable hold is somebody's decision, reported as such.
|
|
1728
|
+
*/
|
|
1729
|
+
export type HeldPause = {
|
|
1730
|
+
kind: "held";
|
|
1731
|
+
verdict:
|
|
1732
|
+
| "operator-hold"
|
|
1733
|
+
| "self-expiring"
|
|
1734
|
+
| "durable"
|
|
1735
|
+
| "live-owner"
|
|
1736
|
+
| "live-upgrade"
|
|
1737
|
+
| "dead-owner"
|
|
1738
|
+
| "orphaned-upgrade";
|
|
1739
|
+
source: string;
|
|
1740
|
+
reason?: string;
|
|
1741
|
+
since: number;
|
|
1742
|
+
owner?: number;
|
|
1743
|
+
};
|
|
1744
|
+
|
|
1745
|
+
export type PauseVerdict = { kind: "unpaused" } | HeldPause;
|
|
1746
|
+
|
|
1747
|
+
export interface PauseLiveness {
|
|
1748
|
+
upgradeLive(): boolean;
|
|
1749
|
+
pidLive(pid: number): boolean;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
export function classifyPause(
|
|
1753
|
+
pause: { source: string; reason?: string; since: number; owner?: number } | undefined,
|
|
1754
|
+
liveness: PauseLiveness,
|
|
1755
|
+
): PauseVerdict {
|
|
1756
|
+
if (pause === undefined) return { kind: "unpaused" };
|
|
1757
|
+
const base: Omit<HeldPause, "verdict"> = {
|
|
1758
|
+
kind: "held",
|
|
1759
|
+
source: pause.source,
|
|
1760
|
+
...(pause.reason === undefined ? {} : { reason: pause.reason }),
|
|
1761
|
+
since: pause.since,
|
|
1762
|
+
...(pause.owner === undefined ? {} : { owner: pause.owner }),
|
|
1763
|
+
};
|
|
1764
|
+
if (pause.owner !== undefined) {
|
|
1765
|
+
return { ...base, verdict: liveness.pidLive(pause.owner) ? "live-owner" : "dead-owner" };
|
|
1766
|
+
}
|
|
1767
|
+
if (pause.source === "hold") return { ...base, verdict: "operator-hold" };
|
|
1768
|
+
if (pause.source === "upgrade") {
|
|
1769
|
+
return { ...base, verdict: liveness.upgradeLive() ? "live-upgrade" : "orphaned-upgrade" };
|
|
1770
|
+
}
|
|
1771
|
+
if (pause.source === "spend-cap") return { ...base, verdict: "self-expiring" };
|
|
1772
|
+
return { ...base, verdict: "durable" };
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
/** Fleet-scoped pages carry issue `0` — there is no tracker issue for this. */
|
|
1776
|
+
const PAUSE_PAGE_ISSUE = 0;
|
|
1777
|
+
|
|
1778
|
+
/**
|
|
1779
|
+
* The orphaned-upgrade page (#1113). Anchored on the sentinel's `since`, like
|
|
1780
|
+
* every once-per-incident page here: the notification ledger dedupes on the
|
|
1781
|
+
* summary, so a still-orphaned sentinel pages exactly once while each newly
|
|
1782
|
+
* recreated sentinel — a fresh `since` — pages again.
|
|
1783
|
+
*/
|
|
1784
|
+
export function orphanedPauseEscalation(project: string, pause: HeldPause): Escalation {
|
|
1785
|
+
const since = new Date(pause.since).toISOString();
|
|
1786
|
+
return {
|
|
1787
|
+
tier: 2,
|
|
1788
|
+
category: "fleet-stopped",
|
|
1789
|
+
project,
|
|
1790
|
+
issue: PAUSE_PAGE_ISSUE,
|
|
1791
|
+
summary: `Orphaned upgrade pause holds dispatch since ${since} — no upgrade process is running (${project})`,
|
|
1792
|
+
detail: [
|
|
1793
|
+
`Dispatch has been paused by the upgrade sentinel since ${since}` +
|
|
1794
|
+
(pause.reason === undefined ? "" : ` — "${pause.reason}"`) +
|
|
1795
|
+
".",
|
|
1796
|
+
"The upgrade transaction that wrote it has exited, so no future event will",
|
|
1797
|
+
"ever clear it: dispatch stays stopped until someone resumes.",
|
|
1798
|
+
"",
|
|
1799
|
+
"Remedy: `omp-conductor resume --all`. Nothing auto-resumes — reporting is",
|
|
1800
|
+
"the whole contract (#1113); resuming a pause nobody authorised stays yours.",
|
|
1801
|
+
].join("\n"),
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
/** One pause sentinel read: who set it, why, when — and any recorded owner. */
|
|
1806
|
+
type PauseInstance = { source: string; reason?: string; since: number; owner?: number };
|
|
1807
|
+
|
|
1808
|
+
export interface OrphanedPauseDeps {
|
|
1809
|
+
/** The project this daemon serves; the effective sentinel for it reads
|
|
1810
|
+
* through the same precedence the doctor row uses (project wins over the
|
|
1811
|
+
* legacy global file). */
|
|
1812
|
+
project: string;
|
|
1813
|
+
escalate(e: Escalation): Promise<void>;
|
|
1814
|
+
log?(msg: string): void;
|
|
1815
|
+
pause?: () => PauseInstance | undefined;
|
|
1816
|
+
/** This project's own `paused-<project>` sentinel, told apart from the
|
|
1817
|
+
* legacy global one so a genuinely per-project orphan pages for its own
|
|
1818
|
+
* project while a shared one pages exactly once (#1113 review round 1). */
|
|
1819
|
+
projectSentinel?: () => PauseInstance | undefined;
|
|
1820
|
+
globalSentinel?: () => PauseInstance | undefined;
|
|
1821
|
+
upgradeLive?: () => boolean;
|
|
1822
|
+
pidLive?: (pid: number) => boolean;
|
|
1823
|
+
/** Every configured project name — from the shared config.json every
|
|
1824
|
+
* daemon on this host reads, which is what makes the canonical owner
|
|
1825
|
+
* below one identity everywhere. Defaults to {@link loadConfig}. */
|
|
1826
|
+
configuredProjects?: () => string[];
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* Best-effort page delivery, mirroring `safeEscalate`: a transport failure is
|
|
1831
|
+
* logged, never thrown, and cannot take the tick down.
|
|
1832
|
+
*/
|
|
1833
|
+
async function deliverOrphanPage(
|
|
1834
|
+
deps: OrphanedPauseDeps,
|
|
1835
|
+
write: (msg: string) => void,
|
|
1836
|
+
held: HeldPause,
|
|
1837
|
+
): Promise<void> {
|
|
1838
|
+
let delivered = true;
|
|
1839
|
+
try {
|
|
1840
|
+
await deps.escalate(orphanedPauseEscalation(deps.project, held));
|
|
1841
|
+
} catch (err) {
|
|
1842
|
+
delivered = false;
|
|
1843
|
+
write(`orphaned-pause escalation could not be delivered: ${errText(err)}`);
|
|
1844
|
+
}
|
|
1845
|
+
write(
|
|
1846
|
+
`ERROR: an orphaned upgrade pause holds dispatch (since ${new Date(held.since).toISOString()})` +
|
|
1847
|
+
(delivered ? " — paged once for this sentinel" : " — page unsent"),
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
/**
|
|
1852
|
+
* The daemon half of #1113: raise the orphaned-upgrade case through the
|
|
1853
|
+
* ordinary escalation path instead of waiting for a tick to read `status` —
|
|
1854
|
+
* a stopped fleet is precisely the condition that cannot assume anything is
|
|
1855
|
+
* watching. Called above the pause gate on every pass; nothing here ever
|
|
1856
|
+
* resumes.
|
|
1857
|
+
*
|
|
1858
|
+
* "Once" needs an ownership boundary, because the ledger that dedupes pages
|
|
1859
|
+
* is per-project store while the upgrade sentinel usually is not: it writes
|
|
1860
|
+
* the legacy GLOBAL file, and every project's effective read falls back to
|
|
1861
|
+
* it. So the two sentinel kinds are told apart before paging:
|
|
1862
|
+
*
|
|
1863
|
+
* - this project's own `paused-<project>` orphan → this project pages as
|
|
1864
|
+
* itself, whatever else is configured;
|
|
1865
|
+
* - the legacy global orphan → exactly one daemon owns the incident: the
|
|
1866
|
+
* one serving the alphabetically-first configured project name — the same
|
|
1867
|
+
* name on every daemon, because they all read the same config.json. A
|
|
1868
|
+
* daemon that cannot read that config assumes it is alone and pages
|
|
1869
|
+
* anyway; staying silent on a failed read would be the exact silence
|
|
1870
|
+
* #1113 exists to end.
|
|
1871
|
+
*
|
|
1872
|
+
* Within either owner, the ledger dedupes on the since-anchored summary, so
|
|
1873
|
+
* a still-orphaned sentinel pages once and a recreated one (fresh `since`)
|
|
1874
|
+
* pages again.
|
|
1875
|
+
*/
|
|
1876
|
+
export async function reconcileOrphanedPause(deps: OrphanedPauseDeps): Promise<void> {
|
|
1877
|
+
const write =
|
|
1878
|
+
deps.log ??
|
|
1879
|
+
((msg: string) => process.stderr.write(`[conductor ${new Date().toISOString()}] ${msg}\n`));
|
|
1880
|
+
const liveness: PauseLiveness = {
|
|
1881
|
+
upgradeLive: deps.upgradeLive ?? upgradeProcessLive,
|
|
1882
|
+
pidLive: deps.pidLive ?? pidAlive,
|
|
1883
|
+
};
|
|
1884
|
+
const verdict = classifyPause((deps.pause ?? (() => pauseInstance(deps.project)))(), liveness);
|
|
1885
|
+
if (verdict.kind !== "held" || verdict.verdict !== "orphaned-upgrade") return;
|
|
1886
|
+
|
|
1887
|
+
// A per-project sentinel of its own: this project owns that page outright.
|
|
1888
|
+
const ownVerdict = classifyPause(
|
|
1889
|
+
(deps.projectSentinel ?? (() => pauseInstanceAt(pausedPath(deps.project))))(),
|
|
1890
|
+
liveness,
|
|
1891
|
+
);
|
|
1892
|
+
if (ownVerdict.kind === "held" && ownVerdict.verdict === "orphaned-upgrade") {
|
|
1893
|
+
await deliverOrphanPage(deps, write, ownVerdict);
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// The shared global sentinel: gate on the canonical owner before paging.
|
|
1898
|
+
let configured: string[] | undefined;
|
|
1899
|
+
try {
|
|
1900
|
+
configured = deps.configuredProjects?.() ?? loadConfig().projects.map((p) => p.name);
|
|
1901
|
+
} catch {
|
|
1902
|
+
configured = undefined;
|
|
1903
|
+
}
|
|
1904
|
+
const names = configured ?? [];
|
|
1905
|
+
const owner = names.length === 0 ? deps.project : [...names].sort()[0];
|
|
1906
|
+
if (deps.project !== owner) {
|
|
1907
|
+
write(`orphaned upgrade pause holds dispatch — ${owner} owns the page for the global sentinel`);
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
const globalVerdict = classifyPause(
|
|
1911
|
+
(deps.globalSentinel ?? (() => pauseInstanceAt(pausedPath())))(),
|
|
1912
|
+
liveness,
|
|
1913
|
+
);
|
|
1914
|
+
if (globalVerdict.kind === "held" && globalVerdict.verdict === "orphaned-upgrade") {
|
|
1915
|
+
await deliverOrphanPage(deps, write, globalVerdict);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* The dispatch-pause row itself (#1113), beside {@link fenceProbe}: the fence
|
|
1921
|
+
* row judges a declared owning process, this one judges the pause — `pass`
|
|
1922
|
+
* when unpaused, and when paused naming the source, the age, and whether
|
|
1923
|
+
* anything still plausibly owns it. One fault class only: the orphaned
|
|
1924
|
+
* upgrade sentinel, which can never clear itself. Nothing here resumes
|
|
1925
|
+
* anything; reporting is the whole contract.
|
|
1926
|
+
*/
|
|
1927
|
+
function pauseProbe(probes: Probes, project: string | undefined): Finding {
|
|
1928
|
+
const id = project === undefined ? "dispatch-pause" : `dispatch-pause:${project}`;
|
|
1929
|
+
const v = classifyPause(probes.pauseFence(project), {
|
|
1930
|
+
upgradeLive: probes.upgradeLive,
|
|
1931
|
+
pidLive: probes.pidLive,
|
|
1932
|
+
});
|
|
1933
|
+
if (v.kind === "unpaused") return passFinding(id, "dispatch is not paused");
|
|
1934
|
+
const why = v.reason === undefined ? "" : ` — "${v.reason}"`;
|
|
1935
|
+
const age = formatDownDuration(Math.max(0, probes.now() - v.since));
|
|
1936
|
+
const base = `dispatch paused by ${v.source}${why}, ${age} ago`;
|
|
1937
|
+
switch (v.verdict) {
|
|
1938
|
+
case "orphaned-upgrade":
|
|
1939
|
+
return failFinding(
|
|
1940
|
+
id,
|
|
1941
|
+
`${base}, and no upgrade process is running — this pause can never clear itself`,
|
|
1942
|
+
"run `omp-conductor resume --all` (nothing here resumes it)",
|
|
1943
|
+
);
|
|
1944
|
+
case "dead-owner":
|
|
1945
|
+
return warnFinding(
|
|
1946
|
+
id,
|
|
1947
|
+
`${base} — its owning process (pid ${v.owner}) is gone`,
|
|
1948
|
+
`confirm nothing is running, then \`omp-conductor resume${project === undefined ? "" : ` --project ${project}`}\``,
|
|
1949
|
+
);
|
|
1950
|
+
case "operator-hold":
|
|
1951
|
+
return passFinding(id, `${base} — a deliberate hold, supported at any age`);
|
|
1952
|
+
case "live-upgrade":
|
|
1953
|
+
return passFinding(id, `${base} — the upgrade transaction is draining, and resumes dispatch when it finishes`);
|
|
1954
|
+
case "live-owner":
|
|
1955
|
+
return passFinding(id, `${base} — owned by live pid ${v.owner}`);
|
|
1956
|
+
case "self-expiring":
|
|
1957
|
+
return passFinding(id, `${base} — clears itself when spend falls below the cap`);
|
|
1958
|
+
case "durable":
|
|
1959
|
+
return passFinding(id, `${base} — no owning process; held until someone resumes it`);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1674
1963
|
function herdrResumeProbe(probes: Probes): Finding {
|
|
1675
1964
|
if (!probes.herdrInstalled()) {
|
|
1676
1965
|
return passFinding("herdr-resume", "herdr not installed — nothing to check");
|
|
@@ -2066,12 +2355,15 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
|
|
|
2066
2355
|
findings.push(timezoneProbe(undefined));
|
|
2067
2356
|
findings.push(await telegramProbe(probes, undefined, checkedAt));
|
|
2068
2357
|
findings.push(fenceProbe(probes, undefined));
|
|
2358
|
+
findings.push(pauseProbe(probes, undefined));
|
|
2069
2359
|
} else {
|
|
2070
2360
|
for (const p of projects) findings.push(timezoneProbe(p));
|
|
2071
2361
|
for (const p of projects) findings.push(await telegramProbe(probes, p, checkedAt));
|
|
2072
2362
|
// Per project, because the sentinel is: one fleet's fence must never be
|
|
2073
2363
|
// reported against another's name.
|
|
2074
2364
|
for (const p of projects) findings.push(fenceProbe(probes, p.name));
|
|
2365
|
+
// The pause state itself (#1113), per project for the same reason.
|
|
2366
|
+
for (const p of projects) findings.push(pauseProbe(probes, p.name));
|
|
2075
2367
|
}
|
|
2076
2368
|
// The billing class the spend row needs (#984). Declared only when EVERY
|
|
2077
2369
|
// project requires a subscription credential: on a mixed fleet the zeros of
|
|
@@ -2173,6 +2465,7 @@ export function defaultProbes(): Probes {
|
|
|
2173
2465
|
canonicalUnits: defaultCanonicalUnits,
|
|
2174
2466
|
herdrInstalled: () => Bun.which("herdr") !== null,
|
|
2175
2467
|
pauseFence: (project) => pauseInstance(project),
|
|
2468
|
+
upgradeLive: upgradeProcessLive,
|
|
2176
2469
|
workerHarness: defaultWorkerHarness,
|
|
2177
2470
|
pidLive: (pid) => pidAlive(pid),
|
|
2178
2471
|
herdrAgents: defaultHerdrAgents,
|