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/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,
@@ -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(),