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.
- package/REFERENCE.md +107 -60
- package/package.json +1 -1
- package/schema/config.schema.json +3 -0
- package/src/briefs/orchestrator.md +64 -11
- package/src/briefs/policy.md +19 -3
- package/src/briefs/worker.md +11 -8
- package/src/cli.ts +41 -21
- package/src/commands/context.ts +102 -1
- package/src/commands/doctor.ts +4 -2
- package/src/commands/intake.ts +26 -5
- package/src/commands/message.ts +80 -32
- package/src/commands/report.ts +38 -2
- package/src/commands/restart.ts +81 -54
- package/src/commands/setup.ts +61 -11
- package/src/commands/stop.ts +45 -22
- package/src/commands/upgrade-rollback.ts +9 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +35 -1
- package/src/daemon.ts +588 -37
- package/src/dashboard/app.js +398 -59
- package/src/dashboard/index.html +27 -0
- package/src/dashboard/server.ts +219 -5
- package/src/dashboard/style.css +169 -1
- package/src/doctor.ts +419 -45
- package/src/escalate.ts +8 -0
- package/src/failure-class.ts +37 -0
- package/src/fleet.ts +49 -2
- package/src/gitops.ts +157 -0
- package/src/lifecycle.ts +113 -2
- package/src/model-fallback.ts +177 -0
- package/src/omp.ts +115 -13
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +108 -5
- package/src/orchestrator.ts +18 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +373 -28
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +665 -70
- package/src/setup-install.ts +275 -28
- package/src/setup-wizard.ts +339 -126
- package/src/setup.ts +25 -0
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +194 -1
- package/src/tracker/github.ts +47 -0
- package/src/types.ts +182 -0
- package/src/upgrade.ts +110 -32
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +27 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +24 -3
- 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 {
|
|
36
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
499
|
-
|
|
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
|
|
520
|
-
*
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
findings.push(
|
|
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:
|
|
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} ` +
|