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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.20.2",
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.",
@@ -8,15 +8,7 @@
8
8
 
9
9
  import type { CommandContext } from "./context.ts";
10
10
  import { runDaemon } from "../daemon.ts";
11
- import { clearRecord, DEFAULT_PORT, livingDaemon, writeRecord } from "../lifecycle.ts";
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
- const running = livingDaemon();
46
- if (running !== undefined && running.pid !== process.pid) {
47
- process.stderr.write(`omp-conductor: another daemon is alive (pid ${running.pid}); stop it first\n`);
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
- // A living record that already names this pid was written by the `start`
52
- // that spawned us, and it knows the log file our stdout is really going
53
- // to. Replacing it with a guess would be a downgrade.
54
- if (running === undefined) {
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();
@@ -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
- * Three tolerances make the matcher honest rather than decorative:
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
- * Only spans inside the verified region count, and only command-shaped ones
981
- * the two filters are what keep `## Verified`-island prose out of the audit.
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 match of region.matchAll(/`([^`\n]+)`/g)) {
988
- const span = (match[1] ?? "").trim();
989
- if (span === "" || seen.has(span) || !claimedCommand(span)) continue;
990
- seen.add(span);
991
- claims.push(span);
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 { pauseInstance } from "./pause.ts";
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,