omp-conductor 0.20.1 → 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/schema/config.schema.json +24 -0
- package/src/commands/companion.ts +52 -16
- package/src/commands/daemon.ts +14 -25
- package/src/commands/drain.ts +12 -5
- package/src/commands/worker.ts +5 -0
- package/src/config-schema.ts +8 -0
- package/src/config.ts +17 -2
- package/src/daemon/drain.ts +33 -0
- package/src/daemon/http.ts +28 -0
- package/src/daemon/review.ts +11 -11
- package/src/daemon/supervision.ts +144 -1
- package/src/daemon/tick.ts +46 -15
- package/src/daemon/views.ts +17 -0
- package/src/daemon.ts +2 -1
- package/src/decisions.ts +20 -9
- package/src/diff-flags.ts +186 -31
- package/src/doctor.ts +320 -9
- package/src/escalate.ts +187 -15
- package/src/failure-class.ts +176 -4
- package/src/fleet.ts +444 -50
- package/src/graph-health.ts +17 -1
- package/src/groom.ts +11 -0
- package/src/knowledge.ts +75 -21
- package/src/lifecycle.ts +267 -5
- package/src/orchestrator-tick.ts +8 -2
- package/src/ready-gate.ts +100 -5
- package/src/reports.ts +4 -1
- package/src/settlement.ts +98 -7
- package/src/status-render.ts +27 -2
- package/src/store.ts +50 -13
- package/src/to-spec.ts +52 -0
- package/src/tracker/github.ts +132 -10
- package/src/types.ts +31 -0
- package/src/upgrade.ts +12 -5
- package/src/verbs/server.ts +16 -12
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,
|
|
@@ -71,6 +73,7 @@ import {
|
|
|
71
73
|
killOf,
|
|
72
74
|
readTelegramChannel,
|
|
73
75
|
readTelegramDmOwner,
|
|
76
|
+
readTelegramMisroutes,
|
|
74
77
|
readTelegramPollState,
|
|
75
78
|
resolveClaimedSessionFile,
|
|
76
79
|
resolveProjectClaim,
|
|
@@ -82,6 +85,7 @@ import {
|
|
|
82
85
|
type TelegramChannelState,
|
|
83
86
|
type TelegramDmOwnerState,
|
|
84
87
|
type TelegramPollState,
|
|
88
|
+
type TelegramMisroute,
|
|
85
89
|
} from "./escalate.ts";
|
|
86
90
|
import {
|
|
87
91
|
herdrConductorPluginConfigDir,
|
|
@@ -103,7 +107,7 @@ import {
|
|
|
103
107
|
readTickConfig,
|
|
104
108
|
type HerdrAgentList,
|
|
105
109
|
} from "./orchestrator-tick.ts";
|
|
106
|
-
import type { Caps, ConductorConfig, ProjectConfig, RepoTarget, RunState } from "./types.ts";
|
|
110
|
+
import type { Caps, ConductorConfig, Escalation, ProjectConfig, RepoTarget, RunState } from "./types.ts";
|
|
107
111
|
import { DEFAULT_ARM_PROOF, type ArmProof } from "./types.ts";
|
|
108
112
|
import {
|
|
109
113
|
DEFAULT_DEPS as UPGRADE_DEPS,
|
|
@@ -273,6 +277,13 @@ export interface DoctorDeps {
|
|
|
273
277
|
/** Whether one pid is a live process. Injected so the abandoned-fence
|
|
274
278
|
* finding is testable without spawning anything. */
|
|
275
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;
|
|
276
287
|
/** Live `herdr --session <s> agent list`, parsed through the tick's own
|
|
277
288
|
* parser (the same "one JSON line on stdout" contract recover.sh reads). */
|
|
278
289
|
herdrAgents?: (session: string) => HerdrAgentList;
|
|
@@ -286,6 +297,11 @@ export interface DoctorDeps {
|
|
|
286
297
|
* `unavailable` when the registry cannot be read or does not parse — the
|
|
287
298
|
* caller must not read that as "no claims" (#626). */
|
|
288
299
|
claimedTopics?: () => ClaimedTopicsResult;
|
|
300
|
+
/** Recorded flat-chat deliveries this project's stale pin already caused
|
|
301
|
+
* (#1094), read from conductor state. The topic-pin row renders them as
|
|
302
|
+
* observed evidence until the pin is repaired — rows match the *current*
|
|
303
|
+
* pin id, so re-pinning is what retires them. */
|
|
304
|
+
topicMisroutes?: () => readonly TelegramMisroute[];
|
|
289
305
|
/** Whether the bridge's access.json runs per-session topic tidy on. */
|
|
290
306
|
topicsTidy?: () => boolean;
|
|
291
307
|
/** The typed bot.lock poll-ownership read, told apart so absent/malformed
|
|
@@ -1390,14 +1406,24 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1390
1406
|
`[${p.name}] no pinned topic id — sends go to the flat chat (live-claim substitution only follows a stale pin)`,
|
|
1391
1407
|
);
|
|
1392
1408
|
}
|
|
1409
|
+
// #1094: sends that already rode the flat chat because of THIS pin turn the
|
|
1410
|
+
// warnings below from hypothetical into observed. Rows are keyed to the pin
|
|
1411
|
+
// id, so re-pinning retires them — nothing deletes them.
|
|
1412
|
+
const misroutes = probes.topicMisroutes().filter(
|
|
1413
|
+
(row) => row.project === p.name && row.staleTopicId === pinned,
|
|
1414
|
+
);
|
|
1415
|
+
const misrouteNote =
|
|
1416
|
+
misroutes.length === 0
|
|
1417
|
+
? ""
|
|
1418
|
+
: ` It has happened: ${misroutes.length} send(s) were delivered to the flat chat because of this pin, last at ${new Date(misroutes[misroutes.length - 1]!.at).toISOString()}`;
|
|
1393
1419
|
const result = probes.claimedTopics();
|
|
1394
1420
|
if (result.kind !== "ok") {
|
|
1395
1421
|
return warnFinding(
|
|
1396
1422
|
"topic-pin",
|
|
1397
1423
|
`[${p.name}] pinned topic ${pinned} — ` +
|
|
1398
1424
|
(result.kind === "missing"
|
|
1399
|
-
? `omp-telegram has no claim registry (threads.json in its state dir), so doctor cannot tell whether the pin is live or a claim answers to this project`
|
|
1400
|
-
: `omp-telegram's claim registry is unreadable (${result.problem}), so doctor cannot tell whether the pin is live or a claim answers to this project`),
|
|
1425
|
+
? `omp-telegram has no claim registry (threads.json in its state dir), so doctor cannot tell whether the pin is live or a claim answers to this project${misrouteNote}`
|
|
1426
|
+
: `omp-telegram's claim registry is unreadable (${result.problem}), so doctor cannot tell whether the pin is live or a claim answers to this project${misrouteNote}`),
|
|
1401
1427
|
`check omp-telegram's claim registry (threads.json in its state dir) is readable and the bridge is running, then re-run doctor — until then sends keep the pinned topic and degrade to the flat chat on a missing thread (#318)`,
|
|
1402
1428
|
);
|
|
1403
1429
|
}
|
|
@@ -1423,7 +1449,7 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1423
1449
|
if (dead.length > 0) {
|
|
1424
1450
|
return warnFinding(
|
|
1425
1451
|
"topic-pin",
|
|
1426
|
-
`[${p.name}] pinned topic ${pinned} — every claim in omp-telegram's registry is dead${deadNote}`,
|
|
1452
|
+
`[${p.name}] pinned topic ${pinned} — every claim in omp-telegram's registry is dead${deadNote}${misrouteNote}`,
|
|
1427
1453
|
`run /cleanup in Telegram so the bridge closes those topics and drops their rows itself, then re-run setup to re-pin escalation.telegramTopicId to a live claim — never delete rows from threads.json by hand, because the row is the only index to the remote topic (#987)`,
|
|
1428
1454
|
);
|
|
1429
1455
|
}
|
|
@@ -1437,7 +1463,7 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1437
1463
|
if (claims.some((claim) => claim.threadId === pinned)) {
|
|
1438
1464
|
return passFinding("topic-pin", `[${p.name}] pinned topic ${pinned} is a live claim${deadNote}`);
|
|
1439
1465
|
}
|
|
1440
|
-
const match = resolveProjectClaim(claims, p.name, probes.pidAlive);
|
|
1466
|
+
const match = resolveProjectClaim(claims, p.name, probes.pidAlive, p.workspaceRoot);
|
|
1441
1467
|
if (match.kind === "match") {
|
|
1442
1468
|
return passFinding(
|
|
1443
1469
|
"topic-pin",
|
|
@@ -1454,7 +1480,7 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1454
1480
|
return warnFinding(
|
|
1455
1481
|
"topic-pin",
|
|
1456
1482
|
`[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims, and several live claims answer to this project ` +
|
|
1457
|
-
`(topics ${match.claimants.map((c) => c.threadId).join(", ")}) — substitution would be a coin toss, so sends keep the stale pin`,
|
|
1483
|
+
`(topics ${match.claimants.map((c) => c.threadId).join(", ")}) — substitution would be a coin toss, so sends keep the stale pin${misrouteNote}`,
|
|
1458
1484
|
`make exactly one live claim answer to this project: rename the other panes' herdr spaces or re-claim them one at a time, then re-run doctor — live-claim substitution follows the unique claim from there`,
|
|
1459
1485
|
);
|
|
1460
1486
|
}
|
|
@@ -1463,14 +1489,14 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1463
1489
|
"topic-pin",
|
|
1464
1490
|
`[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims, no live claim answers ` +
|
|
1465
1491
|
`to this project, and this host runs topicsTidy — the pin is vestigial and can never be live again ` +
|
|
1466
|
-
`(every pane exit closes its topic); sends keep the stale pin`,
|
|
1492
|
+
`(every pane exit closes its topic); sends keep the stale pin${misrouteNote}`,
|
|
1467
1493
|
`turn topicsTidy off, then re-run setup to re-pin escalation.telegramTopicId to a currently claimed topic`,
|
|
1468
1494
|
);
|
|
1469
1495
|
}
|
|
1470
1496
|
return warnFinding(
|
|
1471
1497
|
"topic-pin",
|
|
1472
1498
|
`[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims (${claims.map((c) => c.threadId).join(", ")}), ` +
|
|
1473
|
-
`and no live claim answers to this project — sends keep the stale pin and degrade to the flat chat`,
|
|
1499
|
+
`and no live claim answers to this project — sends keep the stale pin and degrade to the flat chat${misrouteNote}`,
|
|
1474
1500
|
`update escalation.telegramTopicId to a currently claimed topic, or re-run setup to re-pin`,
|
|
1475
1501
|
);
|
|
1476
1502
|
}
|
|
@@ -1654,6 +1680,286 @@ function fenceProbe(probes: Probes, project: string | undefined): Finding {
|
|
|
1654
1680
|
);
|
|
1655
1681
|
}
|
|
1656
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
|
+
|
|
1657
1963
|
function herdrResumeProbe(probes: Probes): Finding {
|
|
1658
1964
|
if (!probes.herdrInstalled()) {
|
|
1659
1965
|
return passFinding("herdr-resume", "herdr not installed — nothing to check");
|
|
@@ -2049,12 +2355,15 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
|
|
|
2049
2355
|
findings.push(timezoneProbe(undefined));
|
|
2050
2356
|
findings.push(await telegramProbe(probes, undefined, checkedAt));
|
|
2051
2357
|
findings.push(fenceProbe(probes, undefined));
|
|
2358
|
+
findings.push(pauseProbe(probes, undefined));
|
|
2052
2359
|
} else {
|
|
2053
2360
|
for (const p of projects) findings.push(timezoneProbe(p));
|
|
2054
2361
|
for (const p of projects) findings.push(await telegramProbe(probes, p, checkedAt));
|
|
2055
2362
|
// Per project, because the sentinel is: one fleet's fence must never be
|
|
2056
2363
|
// reported against another's name.
|
|
2057
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));
|
|
2058
2367
|
}
|
|
2059
2368
|
// The billing class the spend row needs (#984). Declared only when EVERY
|
|
2060
2369
|
// project requires a subscription credential: on a mixed fleet the zeros of
|
|
@@ -2156,6 +2465,7 @@ export function defaultProbes(): Probes {
|
|
|
2156
2465
|
canonicalUnits: defaultCanonicalUnits,
|
|
2157
2466
|
herdrInstalled: () => Bun.which("herdr") !== null,
|
|
2158
2467
|
pauseFence: (project) => pauseInstance(project),
|
|
2468
|
+
upgradeLive: upgradeProcessLive,
|
|
2159
2469
|
workerHarness: defaultWorkerHarness,
|
|
2160
2470
|
pidLive: (pid) => pidAlive(pid),
|
|
2161
2471
|
herdrAgents: defaultHerdrAgents,
|
|
@@ -2163,6 +2473,7 @@ export function defaultProbes(): Probes {
|
|
|
2163
2473
|
herdrConfig: defaultHerdrConfig,
|
|
2164
2474
|
herdrEnv: defaultHerdrEnv,
|
|
2165
2475
|
claimedTopics: () => claimedTelegramTopics(),
|
|
2476
|
+
topicMisroutes: () => readTelegramMisroutes(),
|
|
2166
2477
|
topicsTidy: () => telegramTopicsTidy(),
|
|
2167
2478
|
pollState: () => readTelegramPollState(),
|
|
2168
2479
|
dmOwner: () => readTelegramDmOwner(),
|