omp-conductor 0.15.11 → 0.15.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/REFERENCE.md +107 -60
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +3 -0
  4. package/src/briefs/orchestrator.md +64 -11
  5. package/src/briefs/policy.md +19 -3
  6. package/src/briefs/worker.md +11 -8
  7. package/src/cli.ts +41 -21
  8. package/src/commands/context.ts +102 -1
  9. package/src/commands/doctor.ts +4 -2
  10. package/src/commands/intake.ts +26 -5
  11. package/src/commands/message.ts +80 -32
  12. package/src/commands/report.ts +38 -2
  13. package/src/commands/restart.ts +81 -54
  14. package/src/commands/setup.ts +61 -11
  15. package/src/commands/stop.ts +45 -22
  16. package/src/commands/upgrade-rollback.ts +9 -0
  17. package/src/config-schema.ts +9 -0
  18. package/src/config.ts +35 -1
  19. package/src/daemon.ts +588 -37
  20. package/src/dashboard/app.js +398 -59
  21. package/src/dashboard/index.html +27 -0
  22. package/src/dashboard/server.ts +219 -5
  23. package/src/dashboard/style.css +169 -1
  24. package/src/doctor.ts +419 -45
  25. package/src/escalate.ts +8 -0
  26. package/src/failure-class.ts +37 -0
  27. package/src/fleet.ts +49 -2
  28. package/src/gitops.ts +157 -0
  29. package/src/lifecycle.ts +113 -2
  30. package/src/model-fallback.ts +177 -0
  31. package/src/omp.ts +115 -13
  32. package/src/orchestrator-down.ts +231 -0
  33. package/src/orchestrator-tick.ts +108 -5
  34. package/src/orchestrator.ts +18 -4
  35. package/src/privileged.ts +10 -0
  36. package/src/release-policy.ts +373 -28
  37. package/src/session-host.ts +11 -5
  38. package/src/setup-host.ts +665 -70
  39. package/src/setup-install.ts +275 -28
  40. package/src/setup-wizard.ts +339 -126
  41. package/src/setup.ts +25 -0
  42. package/src/stop-provenance.ts +66 -0
  43. package/src/store.ts +194 -1
  44. package/src/tracker/github.ts +47 -0
  45. package/src/types.ts +182 -0
  46. package/src/upgrade.ts +110 -32
  47. package/src/verbs/protocol.ts +16 -3
  48. package/src/verbs/server.ts +27 -1
  49. package/src/wizard-ui.ts +261 -46
  50. package/src/worker.ts +24 -3
  51. package/systemd/omp-conductor.service.example +7 -3
package/src/doctor.ts CHANGED
@@ -27,18 +27,38 @@
27
27
 
28
28
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
29
29
  import { spawnSync } from "node:child_process";
30
+ import { homedir } from "node:os";
30
31
  import { join } from "node:path";
31
32
  import type { Stats } from "node:fs";
32
33
  import { Database } from "bun:sqlite";
33
34
 
34
35
  import { configBackupDir, configPath, findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
35
- import { DEFAULT_HERDR_UNIT, probeTelegramHealth, telegramStateDir, type TelegramHealth } from "./fleet.ts";
36
- import { planHostRuntime, STAGED_SERVICE_NAME, SYSTEMD_UNIT_DIR, totalConfiguredWorkers } from "./setup-host.ts";
36
+ import {
37
+ DEFAULT_HERDR_UNIT,
38
+ probeTelegramHealth,
39
+ resolveHerdrSessionWithBridge,
40
+ telegramStateDir,
41
+ type TelegramHealth,
42
+ } from "./fleet.ts";
43
+ import {
44
+ herdrConductorPluginConfigDir,
45
+ planHostRuntime,
46
+ STAGED_SERVICE_NAME,
47
+ SYSTEMD_UNIT_DIR,
48
+ tickCwdForProject,
49
+ totalConfiguredWorkers,
50
+ } from "./setup-host.ts";
37
51
  import { checkTokenScopes, type ScopeCheck } from "./setup.ts";
38
52
  import { dbPath, LIVE_STATES } from "./store.ts";
39
53
  import { telegramReportSend, type ReportSend } from "./reports.ts";
40
54
  import { fetchRateLimit, GhError, gh } from "./tracker/github.ts";
41
55
  import { repoSlugFor } from "./gitops.ts";
56
+ import {
57
+ DEFAULT_FLEET_AGENT_NAME,
58
+ parseHerdrAgents,
59
+ readTickConfig,
60
+ type HerdrAgentList,
61
+ } from "./orchestrator-tick.ts";
42
62
  import type { ConductorConfig, ProjectConfig, RepoTarget, RunState } from "./types.ts";
43
63
 
44
64
  /**
@@ -148,6 +168,20 @@ export interface DoctorDeps {
148
168
  telegramSend?: (project: ProjectConfig) => ReportSend;
149
169
  /** The canonical units to compare the installed units against. */
150
170
  canonicalUnits?: (project: ProjectConfig, cfg: ConductorConfig) => CanonicalUnits;
171
+ /** Whether herdr is installed on this host (a `herdr` on PATH). */
172
+ herdrInstalled?: () => boolean;
173
+ /** Live `herdr --session <s> agent list`, parsed through the tick's own
174
+ * parser (the same "one JSON line on stdout" contract recover.sh reads). */
175
+ herdrAgents?: (session: string) => HerdrAgentList;
176
+ /** The fleet session name, for the rename fix line. */
177
+ herdrSession?: () => string;
178
+ /** The live herdr config at `~/.config/herdr/config.toml`, or undefined. */
179
+ herdrConfig?: () => string | undefined;
180
+ /** The live herdr-conductor plugin `config.env`, or undefined. */
181
+ herdrEnv?: () => string | undefined;
182
+ /** The fleet agent name the tick config of one project names, or undefined
183
+ * when there is no (readable) tick — the expected live herdr pane identity. */
184
+ tickAgentName?: (project: ProjectConfig) => string | undefined;
151
185
  /** Clock, so a run is deterministic in tests. */
152
186
  now?: () => number;
153
187
  /** The one opt-in side effect: send one self-identified Telegram probe. */
@@ -462,7 +496,7 @@ async function labelProbe(probes: Probes, project: ProjectConfig): Promise<Findi
462
496
  if (!read.ok) {
463
497
  return failFinding(
464
498
  "labels",
465
- `cannot read the labels of ${project.tracker.repo}${read.detail === undefined ? "" : ` (${read.detail})`}`,
499
+ `[${project.name}] cannot read the labels of ${project.tracker.repo}${read.detail === undefined ? "" : ` (${read.detail})`}`,
466
500
  "check `gh` can list that repo's labels — the daemon reads them the same way",
467
501
  );
468
502
  }
@@ -474,7 +508,7 @@ async function labelProbe(probes: Probes, project: ProjectConfig): Promise<Findi
474
508
  }
475
509
  const missing = wanted.filter((w) => !present.has(w.name));
476
510
  if (missing.length === 0) {
477
- return passFinding("labels", `${wanted.length} configured label(s) on ${project.tracker.repo} match exactly`);
511
+ return passFinding("labels", `[${project.name}] ${wanted.length} configured label(s) on ${project.tracker.repo} match exactly`);
478
512
  }
479
513
  const detail = missing.map((m) => {
480
514
  const near = nearByName.get(norm(m.name));
@@ -486,17 +520,60 @@ async function labelProbe(probes: Probes, project: ProjectConfig): Promise<Findi
486
520
  });
487
521
  return failFinding(
488
522
  "labels",
489
- `${detail.join("; ")} — ${INCIDENTS.labels}`,
523
+ `[${project.name}] ${detail.join("; ")} — ${INCIDENTS.labels}`,
490
524
  allNearMisses
491
525
  ? "fix the label case/format in the tracker repo (or change the config to the actual spelling)"
492
526
  : "create the missing label(s) in the tracker repo (`gh label create`, or re-run `omp-conductor setup`)",
493
527
  );
494
528
  }
495
529
 
496
- /** Line-level drift between an installed unit and the canonical render. */
530
+ /**
531
+ * A unit line whose canonical value is a property of the *rendering process*,
532
+ * not the deployment. The herdr unit's `Environment="SHELL=…"` carries the
533
+ * account's login shell as it resolved in the process that staged it (#463 —
534
+ * the pane-shell pin that keeps panes off dash). Doctor cannot re-derive that
535
+ * value: in a non-login/tick context `userInfo().shell` resolves to nothing and
536
+ * renders `SHELL=unknown`, while the unit that was actually staged carries
537
+ * `SHELL=/bin/bash`. Comparing against a value that answers to "whoever asked"
538
+ * can never pass, so it is excluded from the drift comparison and reported as
539
+ * not-comparable, never as drift (#511).
540
+ */
541
+ const PROCESS_DERIVED_UNIT_LINE = /^Environment="SHELL=/;
542
+
543
+ /**
544
+ * Whether a canonical unit's pane-shell value could not be resolved — the
545
+ * `SHELL=` pin rendered when the invoking process has no login shell (an empty
546
+ * value, or the literal `unknown` userInfo returns in a non-login context).
547
+ * Excluded from the drift comparison, such a value is reported as
548
+ * not-comparable rather than implied to match (#511).
549
+ */
550
+ function unresolvableShell(unit: string): boolean {
551
+ const line = unit
552
+ .split("\n")
553
+ .map((l) => l.trim())
554
+ .find((l) => PROCESS_DERIVED_UNIT_LINE.test(l));
555
+ if (line === undefined) return false;
556
+ const value = /^Environment="SHELL=(.*)"$/.exec(line)?.[1] ?? "";
557
+ return value === "" || value === "unknown";
558
+ }
559
+
560
+ /**
561
+ * Line-level drift between an installed unit and the canonical render, ignoring
562
+ * lines whose canonical value is not re-derivable by doctor (the
563
+ * {@link PROCESS_DERIVED_UNIT_LINE} pane-shell pin). The pane shell is host
564
+ * state resolved at staging time, so a rendered value doctor cannot reproduce
565
+ * is excluded from both sides rather than reported as a difference — an
566
+ * unresolvable canonical is "cannot compare", never drift (#511).
567
+ */
497
568
  export function unitDrift(installed: string, canonical: string): { differences: string[] } {
498
- const want = canonical.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
499
- const have = installed.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
569
+ const want = canonical
570
+ .split("\n")
571
+ .map((l) => l.trim())
572
+ .filter((l) => l.length > 0 && !PROCESS_DERIVED_UNIT_LINE.test(l));
573
+ const have = installed
574
+ .split("\n")
575
+ .map((l) => l.trim())
576
+ .filter((l) => l.length > 0 && !PROCESS_DERIVED_UNIT_LINE.test(l));
500
577
  const wantSet = new Set(want);
501
578
  const haveSet = new Set(have);
502
579
  const differences = [
@@ -516,8 +593,9 @@ function driftLines(name: string, installed: string, canonical: string): string[
516
593
 
517
594
  /**
518
595
  * Installed unit vs the canonical staged rendering: any drift in User=,
519
- * Environment, WorkingDirectory, ExecStart, Restart/SuccessExitStatus, or
520
- * memory lines is the unit-drift class that once killed workers at turn 0.
596
+ * Environment (save the process-derived pane shell, which is not comparable),
597
+ * WorkingDirectory, ExecStart, Restart/SuccessExitStatus, or memory lines is
598
+ * the unit-drift class that once killed workers at turn 0.
521
599
  */
522
600
  function unitProbe(probes: Probes, project: ProjectConfig | undefined, cfg: ConductorConfig | undefined): Finding {
523
601
  if (!probes.hasSystemd()) return passFinding("systemd-unit", "no systemd directory on this host — nothing to check");
@@ -530,16 +608,21 @@ function unitProbe(probes: Probes, project: ProjectConfig | undefined, cfg: Cond
530
608
  return warnFinding("systemd-unit", `${STAGED_SERVICE_NAME} is not installed`, "install it: run `omp-conductor setup host` from the fleet account");
531
609
  }
532
610
  const problems = driftLines(STAGED_SERVICE_NAME, installedDaemon, canonical.daemon);
611
+ const uncomparable: string[] = [];
533
612
  if (canonical.herdr !== undefined) {
534
613
  const installedHerdr = probes.readUnit(join(SYSTEMD_UNIT_DIR, DEFAULT_HERDR_UNIT));
535
614
  if (installedHerdr === undefined) {
536
615
  problems.push(`${DEFAULT_HERDR_UNIT} is not installed (the staged plan provisions it)`);
537
616
  } else {
617
+ if (unresolvableShell(canonical.herdr)) {
618
+ uncomparable.push("the pane-shell SHELL value is not comparable (it resolves from this process, not the deployment)");
619
+ }
538
620
  problems.push(...driftLines(DEFAULT_HERDR_UNIT, installedHerdr, canonical.herdr));
539
621
  }
540
622
  }
541
623
  if (problems.length === 0) {
542
- return passFinding("systemd-unit", "installed units match the staged render");
624
+ const note = uncomparable.length === 0 ? "" : ` ${uncomparable.join("; ")}`;
625
+ return passFinding("systemd-unit", `installed units match the staged render${note}`);
543
626
  }
544
627
  return failFinding(
545
628
  "systemd-unit",
@@ -548,6 +631,46 @@ function unitProbe(probes: Probes, project: ProjectConfig | undefined, cfg: Cond
548
631
  );
549
632
  }
550
633
 
634
+ /** The `OnFailure=` targets an installed unit names, each a systemd unit file. */
635
+ function onFailureTargets(unitText: string): string[] {
636
+ return (unitText.match(/^OnFailure=(.*)$/gm) ?? []).flatMap((line) =>
637
+ line.slice("OnFailure=".length).trim().split(/\s+/).filter(Boolean),
638
+ );
639
+ }
640
+
641
+ /**
642
+ * Every `OnFailure=` target an installed fleet unit names must itself be
643
+ * installed. The fleet units carry the line from day one (#485); the failure
644
+ * this check exists for is the line present while the recovery oneshot it
645
+ * names is not installed — so systemd silently refuses to enqueue the job and
646
+ * a failed unit never recovers (#509).
647
+ */
648
+ function recoveryProbe(probes: Probes): Finding {
649
+ if (!probes.hasSystemd()) return passFinding("systemd-recovery", "no systemd directory on this host — nothing to check");
650
+ const problems: string[] = [];
651
+ const fleetUnits: readonly [string, string][] = [
652
+ [STAGED_SERVICE_NAME, join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME)],
653
+ [DEFAULT_HERDR_UNIT, join(SYSTEMD_UNIT_DIR, DEFAULT_HERDR_UNIT)],
654
+ ];
655
+ for (const [name, path] of fleetUnits) {
656
+ const text = probes.readUnit(path);
657
+ if (text === undefined) continue; // not installed — the unit/ownership probes name it
658
+ for (const target of onFailureTargets(text)) {
659
+ if (probes.readUnit(join(SYSTEMD_UNIT_DIR, target)) === undefined) {
660
+ problems.push(`${name} names OnFailure=${target}, which is not installed`);
661
+ }
662
+ }
663
+ }
664
+ if (problems.length === 0) {
665
+ return passFinding("systemd-recovery", "every OnFailure= target names an installed unit");
666
+ }
667
+ return failFinding(
668
+ "systemd-recovery",
669
+ `${problems.join("; ")} — systemd cannot enqueue the recovery a failed fleet unit needs (#485/#509)`,
670
+ "install the recovery unit and playbook: run `omp-conductor setup host` from the fleet account",
671
+ );
672
+ }
673
+
551
674
  function installedUnitUser(probes: Probes): string | undefined {
552
675
  const unit = probes.readUnit(join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME));
553
676
  if (unit === undefined) return undefined;
@@ -609,13 +732,13 @@ function timezoneProbe(project: ProjectConfig | undefined): Finding {
609
732
  return passFinding(
610
733
  "reporting-timezone",
611
734
  configured.length === 0
612
- ? "no reporting timezone configured"
613
- : `reporting timezone(s) valid: ${configured.map(([, tz]) => `"${tz}"`).join(", ")}`,
735
+ ? `[${project.name}] no reporting timezone configured`
736
+ : `[${project.name}] reporting timezone(s) valid: ${configured.map(([, tz]) => `"${tz}"`).join(", ")}`,
614
737
  );
615
738
  }
616
739
  return failFinding(
617
740
  "reporting-timezone",
618
- `${bad.map(([label, tz]) => `${label}: "${tz}" is not a known IANA timezone`).join("; ")} — an invalid zone silently skips the availability window and the daily digest`,
741
+ `[${project.name}] ${bad.map(([label, tz]) => `${label}: "${tz}" is not a known IANA timezone`).join("; ")} — an invalid zone silently skips the availability window and the daily digest`,
619
742
  "set a valid IANA timezone (e.g. Europe/London) in reporting.availability or reporting.digest",
620
743
  );
621
744
  }
@@ -651,11 +774,11 @@ async function telegramProbe(probes: Probes, project: ProjectConfig | undefined,
651
774
  if (project === undefined) return passFinding("telegram", "no project resolved — nothing to check");
652
775
  const chatId = (project.escalation.telegramChatId ?? "").trim();
653
776
  if (chatId === "") {
654
- return passFinding("telegram", "no escalation.telegramChatId configured — nothing to probe");
777
+ return passFinding("telegram", `[${project.name}] no escalation.telegramChatId configured — nothing to probe`);
655
778
  }
656
779
  const health = await probes.telegramHealth(project.name);
657
780
  const failures: string[] = [];
658
- const notes: string[] = [];
781
+ const notes: string[] = [`[${project.name}]`];
659
782
  if (health.kind === "down") failures.push(`bot health: down (${health.detail ?? "getMe failed"})`);
660
783
  else if (health.kind === "unconfigured") notes.push(`bot health: unconfigured (${health.detail ?? "no token"})`);
661
784
  else if (health.kind === "degraded") notes.push(`bot health: degraded (${health.detail ?? ""})`);
@@ -678,7 +801,7 @@ async function telegramProbe(probes: Probes, project: ProjectConfig | undefined,
678
801
  }
679
802
  return failFinding(
680
803
  "telegram",
681
- failures.join("; "),
804
+ `[${project.name}] ${failures.join("; ")}`,
682
805
  "fix the bot token / omp-telegram install; `doctor --probe-telegram` proves delivery end to end",
683
806
  );
684
807
  }
@@ -701,12 +824,201 @@ function spendProbe(rows: RunSpendRow[], limit: number): Finding {
701
824
  return passFinding("spend-telemetry", `spend observed on the last ${window.length} completed runs ($${total.toFixed(2)} total)`);
702
825
  }
703
826
 
704
- // ------------------------------------------------------------- composition
827
+ /** Live `herdr --session <session> agent list` through the project's own
828
+ * parser — the published "one JSON line on stdout" interface, never the
829
+ * socket. Same bound as the tick's own query so a hung herdr cannot hang
830
+ * doctor. */
831
+ function defaultHerdrAgents(session: string): HerdrAgentList {
832
+ try {
833
+ const run = spawnSync("herdr", ["--session", session, "agent", "list"], {
834
+ encoding: "utf8",
835
+ timeout: 3000,
836
+ stdio: ["ignore", "pipe", "pipe"],
837
+ });
838
+ if (run.error !== undefined) return { kind: "unavailable", problem: run.error.message };
839
+ if (run.status !== 0) {
840
+ return {
841
+ kind: "unavailable",
842
+ problem: `herdr agent list exited ${String(run.status)}: ${(run.stderr ?? "").trim().split("\n")[0] ?? ""}`,
843
+ };
844
+ }
845
+ return parseHerdrAgents(run.stdout ?? "");
846
+ } catch (err) {
847
+ return { kind: "unavailable", problem: err instanceof Error ? err.message : String(err) };
848
+ }
849
+ }
850
+
851
+ /** Live herdr config, defaulting to the fleet account's own path. */
852
+ function defaultHerdrConfig(): string | undefined {
853
+ try {
854
+ return readFileSync(join(homedir(), ".config", "herdr", "config.toml"), "utf8");
855
+ } catch {
856
+ return undefined;
857
+ }
858
+ }
859
+
860
+ /** Live herdr-conductor plugin `config.env`, same path setup provisions. */
861
+ function defaultHerdrEnv(): string | undefined {
862
+ try {
863
+ return readFileSync(join(herdrConductorPluginConfigDir(homedir()), "config.env"), "utf8");
864
+ } catch {
865
+ return undefined;
866
+ }
867
+ }
868
+
869
+ /**
870
+ * #541 check 1 — live herdr agent name equals the tick config's agentName.
871
+ *
872
+ * The paneOwnership decline the fleet actually hit names the exact fix (`herdr
873
+ * agent rename <pane> <name>`); doctor checks the same premise the tick does
874
+ * (is a pane registered under the configured agent name?) and reports drift
875
+ * with the one-line remedy.
876
+ */
877
+ function herdrAgentNameProbe(probes: Probes, p: ProjectConfig): Finding {
878
+ if (!probes.herdrInstalled()) {
879
+ return passFinding("herdr-agent-name", `[${p.name}] herdr not installed — nothing to check`);
880
+ }
881
+ const want = probes.tickAgentName(p);
882
+ if (want === undefined) {
883
+ return passFinding("herdr-agent-name", `[${p.name}] no readable tick config — nothing to check`);
884
+ }
885
+ const session = probes.herdrSession();
886
+ const agents = probes.herdrAgents(session);
887
+ if (agents.kind !== "ok") {
888
+ return failFinding(
889
+ "herdr-agent-name",
890
+ `[${p.name}] could not read the live herdr agent list (${agents.problem}) — cannot verify the fleet agent name`,
891
+ `restore the herdr session (check \`herdr status\`), then re-run doctor`,
892
+ );
893
+ }
894
+ const holders = agents.agents.filter((a) => a.name === want);
895
+ if (holders.length > 0) {
896
+ return passFinding("herdr-agent-name", `[${p.name}] live pane(s) ${holders.map((a) => a.paneId).join(", ")} are agent "${want}"`);
897
+ }
898
+ // Drift: no pane carries the configured identity. Exactly one pane pinned to
899
+ // the shared default is the safe candidate from the decline turnover — name
900
+ // it when unambiguous, else give the generic command.
901
+ const stale = agents.agents.filter((a) => a.name === DEFAULT_FLEET_AGENT_NAME);
902
+ const pane = stale.length === 1 ? stale[0]!.paneId : "<pane>";
903
+ return failFinding(
904
+ "herdr-agent-name",
905
+ `[${p.name}] no live pane is agent "${want}" — every tick will be declined until the fleet pane carries this name (the 2026-08-15 restamp class)`,
906
+ `herdr --session ${session} agent rename ${pane} ${want}`,
907
+ );
908
+ }
909
+
910
+ /**
911
+ * #541 check 2 — `[session] resume_agents_on_restore` on the live herdr config.
912
+ *
913
+ * herdr's own README: the default `true` leaves a restored omp pane with a
914
+ * deferred resume plan and no live terminal, which is the configuration that
915
+ * pages instead of recovering. Absent ~ the unrecoverable default; an explicit
916
+ * `true` is a deliberate (desktop) choice and only warned.
917
+ */
918
+ function herdrResumeProbe(probes: Probes): Finding {
919
+ if (!probes.herdrInstalled()) {
920
+ return passFinding("herdr-resume", "herdr not installed — nothing to check");
921
+ }
922
+ const live = probes.herdrConfig();
923
+ if (live === undefined) {
924
+ return failFinding(
925
+ "herdr-resume",
926
+ "no herdr config at " + join(homedir(), ".config", "herdr", "config.toml") + " — a restored omp pane gets no live terminal (herdr's own README)",
927
+ "run \`omp-conductor setup host\` from the fleet account (writes [session] resume_agents_on_restore = false), or add it by hand",
928
+ );
929
+ }
930
+ let parsed: { session?: { resume_agents_on_restore?: unknown } };
931
+ try {
932
+ parsed = Bun.TOML.parse(live) as { session?: { resume_agents_on_restore?: unknown } };
933
+ } catch (err) {
934
+ return failFinding(
935
+ "herdr-resume",
936
+ `the live herdr config does not parse (${err instanceof Error ? err.message : String(err)}) — herdr itself refuses it`,
937
+ "run `herdr config check` to name the line, fix, then re-run doctor",
938
+ );
939
+ }
940
+ const value = parsed.session?.resume_agents_on_restore;
941
+ if (value === false) {
942
+ return passFinding("herdr-resume", "resume_agents_on_restore is false — restored panes come up as shells, not deferred plans");
943
+ }
944
+ if (value === true) {
945
+ return warnFinding(
946
+ "herdr-resume",
947
+ "resume_agents_on_restore is explicitly true — possibly a deliberate desktop config, but a headless fleet restores panes with no live terminal",
948
+ "set it to false for a headless host ([session] resume_agents_on_restore = false), or run `omp-conductor setup host`",
949
+ );
950
+ }
951
+ return failFinding(
952
+ "herdr-resume",
953
+ "resume_agents_on_restore is absent — herdr defaults it to true, the configuration its own README calls unrecoverable",
954
+ "run `omp-conductor setup host` from the fleet account (writes the key), or add [session] resume_agents_on_restore = false by hand",
955
+ );
956
+ }
957
+
958
+ /**
959
+ * #541 check 3 — herdr-conductor `config.env` FLEET_CWDS vs the configured
960
+ * projects. Absent, a virgin host recovers against legacy `/root/fleet` paths
961
+ * that do not exist: recovery still runs, only the page silently goes nowhere.
962
+ */
963
+ function herdrEnvProbe(probes: Probes, cfg: ConductorConfig | undefined): Finding {
964
+ if (!probes.herdrInstalled()) {
965
+ return passFinding("herdr-config-env", "herdr not installed — nothing to check");
966
+ }
967
+ if (cfg === undefined) {
968
+ return passFinding("herdr-config-env", "config unreadable — nothing to compare");
969
+ }
970
+ const live = probes.herdrEnv();
971
+ if (live === undefined) {
972
+ return failFinding(
973
+ "herdr-config-env",
974
+ "no herdr-conductor config.env — recovery falls back to legacy /root-based single-tenant paths this host does not use, so pages silently skip",
975
+ "run `omp-conductor setup host` from the fleet account (writes config.env)",
976
+ );
977
+ }
978
+ const expected = new Set(cfg.projects.map((p) => tickCwdForProject(p)));
979
+ const actual = parseFleetCwds(live);
980
+ const coherent =
981
+ actual !== undefined && actual.size === expected.size && [...actual].every((cwd) => expected.has(cwd));
982
+ if (coherent) {
983
+ return passFinding("herdr-config-env", `config.env FLEET_CWDS matches the configured fleets (${[...expected].join(":")})`);
984
+ }
985
+ return failFinding(
986
+ "herdr-config-env",
987
+ `config.env FLEET_CWDS (${actual === undefined ? "unset" : [...actual].join(":")}) does not match the configured fleets (${[...expected].join(":")})`,
988
+ "re-run `omp-conductor setup host`, which derives FLEET_CWDS from the configured projects",
989
+ );
990
+ }
991
+
992
+ /** FLEET_CWDS (colon- or space-separated, per recover.sh) or undefined. */
993
+ function parseFleetCwds(text: string): Set<string> | undefined {
994
+ const value = parseEnvKey(text, "FLEET_CWDS");
995
+ if (value !== undefined) {
996
+ return new Set(value.split(/[\s:]+/).filter((w) => w.length > 0));
997
+ }
998
+ // Legacy single-fleet form, the same fallback recover.sh applies.
999
+ const legacy = parseEnvKey(text, "FLEET_CWD");
1000
+ return legacy === undefined ? undefined : new Set([legacy]);
1001
+ }
1002
+
1003
+ /** The first unquoted-or-quoted `KEY=value` on its own line, comments ignored. */
1004
+ function parseEnvKey(text: string, key: string): string | undefined {
1005
+ for (const line of text.split("\n")) {
1006
+ const match = new RegExp(`^[ \\t]*${key}[ \\t]*=[ \\t]*"?([^"\\n]*)"?[ \\t]*$`).exec(line);
1007
+ if (match !== null) return match[1];
1008
+ }
1009
+ return undefined;
1010
+ }
705
1011
 
706
1012
  /**
707
1013
  * Run every probe and assemble the stable report.
708
1014
  *
709
- * @param projectName the `--project NAME` value (undefined on single-project hosts)
1015
+ * Host-wide facts (config backup, store integrity, gh auth, systemd units,
1016
+ * ownership, spend telemetry) are probed once; the per-project facts (labels,
1017
+ * reporting timezone, telegram) are probed for every resolved project, each
1018
+ * named (#530).
1019
+ *
1020
+ * @param projectName the `--project NAME` value; undefined checks every
1021
+ * configured project
710
1022
  * @param opts injected seams; every probe defaults to the production wiring
711
1023
  */
712
1024
  export async function runDoctor(projectName: string | undefined, opts: DoctorDeps = {}): Promise<DoctorReport> {
@@ -722,42 +1034,89 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
722
1034
  configProblem = messageOf(err);
723
1035
  }
724
1036
 
725
- let project: ProjectConfig | undefined;
1037
+ // Resolve the project set: the named project when `--project NAME` is given,
1038
+ // otherwise *(every)* configured project. Doctor is fleet-scoped and covers
1039
+ // the whole host, so a no-name run checks every project's per-project facts
1040
+ // once each, not the first one and not nothing (#530).
1041
+ let projects: ProjectConfig[] = [];
726
1042
  let projectProblem: string | undefined;
727
1043
  if (cfg !== undefined) {
728
- try {
729
- project = findProject(cfg, projectName);
730
- } catch (err) {
731
- projectProblem = messageOf(err);
1044
+ if (projectName !== undefined) {
1045
+ try {
1046
+ projects = [findProject(cfg, projectName)];
1047
+ } catch (err) {
1048
+ projectProblem = messageOf(err);
1049
+ }
1050
+ } else {
1051
+ projects = cfg.projects;
732
1052
  }
733
1053
  }
734
1054
 
735
1055
  const findings: Finding[] = [];
736
1056
  findings.push(configProbe(configProblem));
737
- findings.push(
738
- cfg === undefined
739
- ? passFinding("project", "config unreadable no project to resolve")
740
- : project === undefined
741
- ? failFinding(
742
- "project",
743
- `no project resolved: ${projectProblem ?? "unknown error"}`,
744
- "pass --project NAME (doctor checks one project per run)",
745
- )
746
- : passFinding("project", project.name),
747
- );
1057
+ if (cfg === undefined) {
1058
+ findings.push(passFinding("project", "config unreadable — no project to resolve"));
1059
+ } else if (projectProblem !== undefined) {
1060
+ findings.push(
1061
+ failFinding(
1062
+ "project",
1063
+ `no project resolved: ${projectProblem}`,
1064
+ "pass --project NAME (doctor checks the named project; with no name it checks every configured project)",
1065
+ ),
1066
+ );
1067
+ } else if (projects.length === 1) {
1068
+ findings.push(passFinding("project", projects[0]!.name));
1069
+ } else {
1070
+ findings.push(passFinding("project", `${projects.map((p) => p.name).join(", ")} (${projects.length} projects)`));
1071
+ }
1072
+
1073
+ // Host-wide facts, probed once per run — never once per project (the daemon
1074
+ // unit, the store and the token are shared across projects, so re-probing
1075
+ // them per project and deduping the output would hide a real per-project
1076
+ // difference behind a constant count).
748
1077
  findings.push(backupProbe(probes));
749
1078
  findings.push(dbProbe(probes));
750
1079
  findings.push(await ghAuthProbe(probes, configuredRepos(cfg)));
751
- findings.push(
752
- project === undefined
753
- ? passFinding("labels", projectProblem === undefined ? "no project resolved — nothing to check" : `labels uncheckable: ${projectProblem}`)
754
- : await labelProbe(probes, project),
755
- );
756
- findings.push(unitProbe(probes, project, cfg));
1080
+ // The collected run sample across the resolved project set, newest-first;
1081
+ // spend telemetry is a single host-wide finding, not one per project.
1082
+ const spendRows: RunSpendRow[] = [];
1083
+ for (const p of projects) spendRows.push(...probes.recentRuns(p.name, SPEND_SAMPLE_RUNS));
1084
+
1085
+ // Per-project probes run for every resolved project, each named in its
1086
+ // finding so a multi-project run stays legible. When nothing resolved
1087
+ // (config unreadable, or --project named an unknown project), a no-op pass
1088
+ // keeps the stable report shape rather than silently dropping the row.
1089
+ if (projects.length === 0) {
1090
+ findings.push(
1091
+ passFinding("labels", projectProblem === undefined ? "no project resolved — nothing to check" : `labels uncheckable: ${projectProblem}`),
1092
+ );
1093
+ findings.push(
1094
+ passFinding("herdr-agent-name", projectProblem === undefined ? "no project resolved — nothing to check" : `agent name uncheckable: ${projectProblem}`),
1095
+ );
1096
+ } else {
1097
+ for (const p of projects) {
1098
+ findings.push(await labelProbe(probes, p));
1099
+ findings.push(herdrAgentNameProbe(probes, p));
1100
+ }
1101
+ }
1102
+ // The installed-unit check is host-global: one shared daemon. Any project
1103
+ // renders the same canonical units (the daemon/units carry no project), so
1104
+ // the first one stands in for the rendering seam.
1105
+ findings.push(unitProbe(probes, projects[0], cfg));
1106
+ findings.push(recoveryProbe(probes));
757
1107
  findings.push(ownershipProbe(probes));
758
- findings.push(timezoneProbe(project));
759
- findings.push(await telegramProbe(probes, project, checkedAt));
760
- findings.push(spendProbe(project === undefined ? [] : probes.recentRuns(project.name, SPEND_SAMPLE_RUNS), SPEND_SAMPLE_RUNS));
1108
+ // #541 seam checks, host-global: the live herdr config and the plugin's
1109
+ // config.env are single files on the host, not per-project facts.
1110
+ findings.push(herdrResumeProbe(probes));
1111
+ findings.push(herdrEnvProbe(probes, cfg));
1112
+ if (projects.length === 0) {
1113
+ findings.push(timezoneProbe(undefined));
1114
+ findings.push(await telegramProbe(probes, undefined, checkedAt));
1115
+ } else {
1116
+ for (const p of projects) findings.push(timezoneProbe(p));
1117
+ for (const p of projects) findings.push(await telegramProbe(probes, p, checkedAt));
1118
+ }
1119
+ findings.push(spendProbe(spendRows, SPEND_SAMPLE_RUNS));
761
1120
 
762
1121
  const status: ReportStatus = findings.some((f) => f.status === "fail")
763
1122
  ? "fail"
@@ -765,7 +1124,12 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
765
1124
  ? "warn"
766
1125
  : "pass";
767
1126
  return {
768
- project: project === undefined ? projectName ?? "(none resolved)" : project.name,
1127
+ project:
1128
+ projects.length === 0
1129
+ ? projectName ?? "(none resolved)"
1130
+ : projects.length === 1
1131
+ ? projects[0]!.name
1132
+ : projects.map((p) => p.name).join(", "),
769
1133
  checkedAt: checkedAtIso,
770
1134
  status,
771
1135
  findings,
@@ -789,6 +1153,16 @@ export function defaultProbes(): Probes {
789
1153
  telegramHealth: (projectName) => probeTelegramHealth(projectName),
790
1154
  telegramSend: telegramReportSend,
791
1155
  canonicalUnits: defaultCanonicalUnits,
1156
+ herdrInstalled: () => Bun.which("herdr") !== null,
1157
+ herdrAgents: defaultHerdrAgents,
1158
+ herdrSession: () => resolveHerdrSessionWithBridge(),
1159
+ herdrConfig: defaultHerdrConfig,
1160
+ herdrEnv: defaultHerdrEnv,
1161
+ tickAgentName: (p) => {
1162
+ const tick = readTickConfig(tickCwdForProject(p));
1163
+ if (tick.kind !== "ok") return undefined;
1164
+ return tick.config.agentName ?? DEFAULT_FLEET_AGENT_NAME;
1165
+ },
792
1166
  now: Date.now,
793
1167
  probeTelegram: false,
794
1168
  };
package/src/escalate.ts CHANGED
@@ -144,6 +144,7 @@ export function createEscalator(
144
144
  orchestrator?: OrchestratorHandle,
145
145
  now: () => number = Date.now,
146
146
  deliveryAllowed: () => boolean = () => true,
147
+ onTier1Diverted?: (e: Escalation) => void,
147
148
  ): Escalator {
148
149
  const currentProject = typeof source === "function" ? source : (): ProjectConfig => source;
149
150
  return {
@@ -278,6 +279,13 @@ export function createEscalator(
278
279
  // Tier 1 lands here with no orchestrator, or with one that would not take
279
280
  // the injection; tier 2 lands here when omp-telegram is not installed or
280
281
  // the project never configured a chat id.
282
+ //
283
+ // A tier-1 event reaching this point was diverted from the orchestrator to
284
+ // an issue comment. That is the exact condition the down incident counts:
285
+ // the daemon hooks this to accumulate the orchestrator-down incident's
286
+ // diverted tally (a reader that owns its own dedupe and no-ops for a
287
+ // healthy or external orchestrator).
288
+ if (e.tier === 1) onTier1Diverted?.(e);
281
289
  if (!p.escalation.fallbackToIssueComment) {
282
290
  throw new Error(
283
291
  `no escalation transport configured for project "${p.name}": tier ${e.tier} ` +