omp-conductor 0.18.1 → 0.19.0
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/README.md +106 -41
- package/REFERENCE.md +866 -31
- package/agents/to-spec.md +6 -2
- package/package.json +1 -1
- package/schema/config.schema.json +32 -1
- package/src/admission.ts +212 -26
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +27 -13
- package/src/briefs/to-spec.md +6 -2
- package/src/cli.ts +127 -2
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +52 -8
- package/src/commands/arm.ts +6 -2
- package/src/commands/context.ts +2 -0
- package/src/commands/intake.ts +4 -19
- package/src/commands/message.ts +26 -2
- package/src/commands/reconcile-units.ts +104 -0
- package/src/commands/release-composition.ts +232 -0
- package/src/commands/resume.ts +2 -27
- package/src/commands/setup.ts +101 -16
- package/src/commands/stats.ts +11 -30
- package/src/commands/tail.ts +31 -1
- package/src/commands/upgrade.ts +20 -3
- package/src/commands/verb.ts +2 -1
- package/src/commands/watch.ts +4 -17
- package/src/config-schema.ts +38 -6
- package/src/config.ts +103 -8
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1368 -529
- package/src/dashboard/app.js +504 -2
- package/src/dashboard/controls.ts +336 -0
- package/src/dashboard/index.html +30 -0
- package/src/dashboard/server.ts +271 -30
- package/src/dashboard/style.css +116 -0
- package/src/dashboard/transcript.ts +173 -0
- package/src/decisions.ts +19 -11
- package/src/doctor.ts +431 -148
- package/src/escalate.ts +22 -11
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +587 -230
- package/src/host.ts +6 -455
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +40 -56
- package/src/orchestrator-tick.ts +564 -121
- package/src/pause.ts +233 -0
- package/src/session-host.ts +6 -41
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +343 -1160
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +252 -51
- package/src/setup.ts +87 -4
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +485 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +50 -2
- package/src/types.ts +759 -10
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +299 -12
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +485 -11
- package/src/wake.ts +48 -0
- package/src/worker.ts +401 -14
package/src/doctor.ts
CHANGED
|
@@ -46,9 +46,11 @@ import {
|
|
|
46
46
|
dbBackupDirFor,
|
|
47
47
|
findProject,
|
|
48
48
|
loadConfig,
|
|
49
|
+
resolveArmProof,
|
|
49
50
|
resolveCaps,
|
|
50
51
|
stateDir,
|
|
51
52
|
} from "./config.ts";
|
|
53
|
+
import { observeArmChallenge } from "./arm-challenge.ts";
|
|
52
54
|
import { ompSettingsOverlay, sessionRootDir } from "./omp-settings.ts";
|
|
53
55
|
import {
|
|
54
56
|
DEFAULT_HERDR_UNIT,
|
|
@@ -59,6 +61,7 @@ import {
|
|
|
59
61
|
sessionDirForCwd,
|
|
60
62
|
telegramStateDir,
|
|
61
63
|
} from "./fleet.ts";
|
|
64
|
+
import { pauseInstance } from "./pause.ts";
|
|
62
65
|
import type { TelegramHealth } from "./status-render.ts";
|
|
63
66
|
import {
|
|
64
67
|
claimedTelegramTopics,
|
|
@@ -85,10 +88,7 @@ import {
|
|
|
85
88
|
SYSTEMD_UNIT_DIR,
|
|
86
89
|
tickCwdForProject,
|
|
87
90
|
totalConfiguredWorkers,
|
|
88
|
-
workerAclHealth,
|
|
89
|
-
type WorkerAclHealth,
|
|
90
91
|
} from "./setup-host.ts";
|
|
91
|
-
import { WORKER_ACCOUNT } from "./host.ts";
|
|
92
92
|
import { checkTokenScopes, type ScopeCheck } from "./setup.ts";
|
|
93
93
|
import { DB_SNAPSHOT_STEM, dbPath, LIVE_STATES } from "./store.ts";
|
|
94
94
|
import { telegramReportSend, type ReportSend } from "./reports.ts";
|
|
@@ -101,6 +101,27 @@ import {
|
|
|
101
101
|
type HerdrAgentList,
|
|
102
102
|
} from "./orchestrator-tick.ts";
|
|
103
103
|
import type { ConductorConfig, ProjectConfig, RepoTarget, RunState } from "./types.ts";
|
|
104
|
+
import { DEFAULT_ARM_PROOF, type ArmProof } from "./types.ts";
|
|
105
|
+
import {
|
|
106
|
+
DEFAULT_DEPS as UPGRADE_DEPS,
|
|
107
|
+
expectedHerdrSource,
|
|
108
|
+
inspectSurfaces,
|
|
109
|
+
releaseIdentity,
|
|
110
|
+
type InstalledSurfaces,
|
|
111
|
+
} from "./upgrade.ts";
|
|
112
|
+
import {
|
|
113
|
+
TELEGRAM_PACKAGE,
|
|
114
|
+
checkTelegramFreshness,
|
|
115
|
+
type TelegramFreshness,
|
|
116
|
+
} from "./telegram-freshness.ts";
|
|
117
|
+
import { judgeSpendTelemetry, spendTelemetryDetail } from "./spend-telemetry.ts";
|
|
118
|
+
|
|
119
|
+
/** One read of this host's installed surfaces: the three identities, or why
|
|
120
|
+
* they could not be read. A failed read is a finding (`warn`, "unverified"),
|
|
121
|
+
* never a thrown doctor run and never a silent pass (#904). */
|
|
122
|
+
export type SurfaceRead =
|
|
123
|
+
| { ok: true; surfaces: InstalledSurfaces }
|
|
124
|
+
| { ok: false; detail: string };
|
|
104
125
|
|
|
105
126
|
/**
|
|
106
127
|
* `doctor`'s finding vocabulary. `pass`/`warn`/`fail` — a warning renders the
|
|
@@ -132,8 +153,18 @@ export interface DoctorReport {
|
|
|
132
153
|
findings: Finding[];
|
|
133
154
|
}
|
|
134
155
|
|
|
135
|
-
/** How many
|
|
156
|
+
/** How many *working* runs the spend-telemetry judgement wants. */
|
|
136
157
|
export const SPEND_SAMPLE_RUNS = 5;
|
|
158
|
+
/**
|
|
159
|
+
* How many rows to read to find them (#970).
|
|
160
|
+
*
|
|
161
|
+
* Over-sampled deliberately: the judgement discards 0-turn runs, and on this
|
|
162
|
+
* project 26 of 48 zero-spend rows are exactly those. Reading only
|
|
163
|
+
* `SPEND_SAMPLE_RUNS` rows would let a burst of administrative kills push the
|
|
164
|
+
* real evidence out of the window and report "nothing to judge yet" on a fleet
|
|
165
|
+
* that had plenty to judge.
|
|
166
|
+
*/
|
|
167
|
+
export const SPEND_SAMPLE_ROWS = SPEND_SAMPLE_RUNS * 4;
|
|
137
168
|
|
|
138
169
|
/** The past incidents this doctor flags, quoted in the finding so an operator
|
|
139
170
|
* who never lived them knows which failure mode the check exists to prevent. */
|
|
@@ -145,10 +176,6 @@ const INCIDENTS = {
|
|
|
145
176
|
spend:
|
|
146
177
|
"spend telemetry was once absent, so the USD cap never fired — $0.00 spend is not proof of no spend",
|
|
147
178
|
ghauth: "gh auth expired under a live daemon and every tracker call failed silently",
|
|
148
|
-
workerAcl:
|
|
149
|
-
"the #835 incident: a setup host granted the worker's path ACLs before restarting the fleet, an OMP startup chmod'd " +
|
|
150
|
-
"the agent config dir back to 0700 and rewrote the ACL mask, and the next two admitted workers died on EACCES " +
|
|
151
|
-
"before connecting — the named ACL entry was still there, only its effective permissions were gone",
|
|
152
179
|
} as const;
|
|
153
180
|
|
|
154
181
|
// ------------------------------------------------------------------ dependencies
|
|
@@ -173,10 +200,12 @@ export interface CanonicalUnits {
|
|
|
173
200
|
}
|
|
174
201
|
|
|
175
202
|
/** One sampled run row: `state` keeps spend from in-flight rows, which are not
|
|
176
|
-
* a telemetry statement yet
|
|
203
|
+
* a telemetry statement yet, and `turns` separates a run that reported nothing
|
|
204
|
+
* from one that genuinely did nothing (#970). */
|
|
177
205
|
export interface RunSpendRow {
|
|
178
206
|
state: RunState;
|
|
179
207
|
spendUsd: number;
|
|
208
|
+
turns: number;
|
|
180
209
|
}
|
|
181
210
|
|
|
182
211
|
/** Injectable seams. Every field defaults to the production wiring, which
|
|
@@ -223,6 +252,21 @@ export interface DoctorDeps {
|
|
|
223
252
|
canonicalUnits?: (project: ProjectConfig, cfg: ConductorConfig) => CanonicalUnits;
|
|
224
253
|
/** Whether herdr is installed on this host (a `herdr` on PATH). */
|
|
225
254
|
herdrInstalled?: () => boolean;
|
|
255
|
+
/**
|
|
256
|
+
* Whether a worker session could load its harness at all (#910), exercised
|
|
257
|
+
* the way a worker does: the installed peer resolved from this package's own
|
|
258
|
+
* directory under `bun --no-install`, so neither import can fall through to
|
|
259
|
+
* Bun's ambient cache. `ok: false` means no worker on this host can start.
|
|
260
|
+
*/
|
|
261
|
+
workerHarness?: () => Promise<{ ok: true; version: string } | { ok: false; detail: string }>;
|
|
262
|
+
/**
|
|
263
|
+
* The pause sentinel for one project as an instance (#938): who set it, why,
|
|
264
|
+
* when, and — for a fence whose lifetime is one process's — the owning pid.
|
|
265
|
+
*/
|
|
266
|
+
pauseFence?: (project?: string) => { source: string; reason?: string; since: number; owner?: number } | undefined;
|
|
267
|
+
/** Whether one pid is a live process. Injected so the abandoned-fence
|
|
268
|
+
* finding is testable without spawning anything. */
|
|
269
|
+
pidLive?: (pid: number) => boolean;
|
|
226
270
|
/** Live `herdr --session <s> agent list`, parsed through the tick's own
|
|
227
271
|
* parser (the same "one JSON line on stdout" contract recover.sh reads). */
|
|
228
272
|
herdrAgents?: (session: string) => HerdrAgentList;
|
|
@@ -256,8 +300,10 @@ export interface DoctorDeps {
|
|
|
256
300
|
/** The topic id an arm challenge would send into for this project —
|
|
257
301
|
* resolveProjectTopicId's pin-or-live-claim; undefined means flat chat. */
|
|
258
302
|
armSendTopic?: (project: ProjectConfig) => number | undefined;
|
|
259
|
-
/** The session
|
|
260
|
-
* project — tick-cwd-derived plus the live claim's own directory.
|
|
303
|
+
/** The session surface the claim-only plumbing verdict judges for this
|
|
304
|
+
* project — tick-cwd-derived plus the live claim's own directory. The
|
|
305
|
+
* challenge proof reads conductor state instead (#614), so this feeds
|
|
306
|
+
* claim-only verdict checks only. */
|
|
261
307
|
armScanDirs?: (project: ProjectConfig) => readonly string[];
|
|
262
308
|
/** Whether a recorded claim or dm-owner pid is live, with omp-telegram's
|
|
263
309
|
* topics.ts semantics (EPERM is dead). */
|
|
@@ -273,15 +319,16 @@ export interface DoctorDeps {
|
|
|
273
319
|
/** The fleet agent name the tick config of one project names, or undefined
|
|
274
320
|
* when there is no (readable) tick — the expected live herdr pane identity. */
|
|
275
321
|
tickAgentName?: (project: ProjectConfig) => string | undefined;
|
|
322
|
+
/** The three installed identities this host carries, read through
|
|
323
|
+
* `upgrade`'s own seam; a failed read is a named finding, never a throw. */
|
|
324
|
+
installedSurfaces?: () => Promise<SurfaceRead>;
|
|
325
|
+
/** The `omp-telegram` install/daemon/published triple (#961). */
|
|
326
|
+
telegramFreshness?: () => Promise<TelegramFreshness>;
|
|
327
|
+
/** The commit one published version was cut from, for judging the herdr
|
|
328
|
+
* plugin's pin; `undefined` when the registry cannot be read. */
|
|
329
|
+
releaseGitHead?: (version: string) => Promise<string | undefined>;
|
|
276
330
|
/** Clock, so a run is deterministic in tests. */
|
|
277
331
|
now?: () => number;
|
|
278
|
-
/** The linked worker config paths' effective-ACL verdict for the dedicated
|
|
279
|
-
* worker account (#835): `checkable` paths judged, `missing` the ones that
|
|
280
|
-
* do not currently grant the worker's needed effective access. Read-only
|
|
281
|
-
* and injectable so no test ever touches the host's ACLs; the production
|
|
282
|
-
* wiring is the identity plan's own getfacl probe, so `doctor` and
|
|
283
|
-
* `setup host` cannot disagree about an ACL. */
|
|
284
|
-
workerAclHealth?: () => WorkerAclHealth;
|
|
285
332
|
/** The one opt-in side effect: send one self-identified Telegram probe. */
|
|
286
333
|
probeTelegram?: boolean;
|
|
287
334
|
}
|
|
@@ -452,8 +499,8 @@ function defaultRecentRuns(project: string, limit: number): RunSpendRow[] {
|
|
|
452
499
|
try {
|
|
453
500
|
const placeholders = [...LIVE_STATES].map(() => "?").join(", ");
|
|
454
501
|
const rows = database
|
|
455
|
-
.query<{ state: string; spendUsd: number }, [string, ...string[], number]>(
|
|
456
|
-
`SELECT state, spendUsd FROM runs
|
|
502
|
+
.query<{ state: string; spendUsd: number; turns: number }, [string, ...string[], number]>(
|
|
503
|
+
`SELECT state, spendUsd, turns FROM runs
|
|
457
504
|
WHERE project = ? AND state NOT IN (${placeholders})
|
|
458
505
|
ORDER BY startedAt DESC, rowid DESC
|
|
459
506
|
LIMIT ?`,
|
|
@@ -463,7 +510,9 @@ function defaultRecentRuns(project: string, limit: number): RunSpendRow[] {
|
|
|
463
510
|
for (const row of rows) {
|
|
464
511
|
const state = row.state as RunState;
|
|
465
512
|
// A state vocabulary the store never had is not "completed".
|
|
466
|
-
if (!LIVE_STATES.includes(state))
|
|
513
|
+
if (!LIVE_STATES.includes(state)) {
|
|
514
|
+
out.push({ state, spendUsd: row.spendUsd, turns: row.turns });
|
|
515
|
+
}
|
|
467
516
|
}
|
|
468
517
|
return out;
|
|
469
518
|
} catch {
|
|
@@ -762,6 +811,12 @@ function unresolvableShell(unit: string): boolean {
|
|
|
762
811
|
* state resolved at staging time, so a rendered value doctor cannot reproduce
|
|
763
812
|
* is excluded from both sides rather than reported as a difference — an
|
|
764
813
|
* unresolvable canonical is "cannot compare", never drift (#511).
|
|
814
|
+
*
|
|
815
|
+
* The service `PATH=` line has no such exemption: since #879 it is canonical
|
|
816
|
+
* on both sides (`defaultServiceRuntime` composes it from the resolved fleet
|
|
817
|
+
* binaries plus the standard system directories, never from the rendering
|
|
818
|
+
* process's environment), so a differing installed value is genuine drift and
|
|
819
|
+
* must fail here with the value named.
|
|
765
820
|
*/
|
|
766
821
|
export function unitDrift(installed: string, canonical: string): { differences: string[] } {
|
|
767
822
|
const want = canonical
|
|
@@ -916,36 +971,6 @@ function ownershipProbe(probes: Probes): Finding {
|
|
|
916
971
|
return failFinding("systemd-ownership", summary, fix);
|
|
917
972
|
}
|
|
918
973
|
|
|
919
|
-
/**
|
|
920
|
-
* The dedicated worker account's path ACLs (#835): every linked worker config
|
|
921
|
-
* path must grant the worker its *effective* search/read permissions. The
|
|
922
|
-
* installed OMP harness chmods its agent config dir back to 0700 on every
|
|
923
|
-
* open, and a chmod rewrites the ACL mask — the named entry survives as
|
|
924
|
-
* `user:omp-worker:--x #effective:---` while the worker's access silently
|
|
925
|
-
* vanishes, which is exactly the incident this finding exists to catch
|
|
926
|
-
* (`${INCIDENTS.workerAcl}`). The verdict comes from the identity plan's own
|
|
927
|
-
* probe through the read-only {@link DoctorDeps.workerAclHealth} seam, so a
|
|
928
|
-
* pass here is a pass the plan agrees with and a fail names the paths whose
|
|
929
|
-
* mask is stripping the grant.
|
|
930
|
-
*/
|
|
931
|
-
function workerAclProbeFinding(probes: Probes): Finding {
|
|
932
|
-
const health = probes.workerAclHealth();
|
|
933
|
-
if (health.checkable === 0) {
|
|
934
|
-
return passFinding("worker-acl", "no linked worker config paths on this host yet — nothing to check");
|
|
935
|
-
}
|
|
936
|
-
if (health.missing.length === 0) {
|
|
937
|
-
return passFinding(
|
|
938
|
-
"worker-acl",
|
|
939
|
-
`${health.checkable} linked worker config path(s) grant ${WORKER_ACCOUNT} effective search/read access`,
|
|
940
|
-
);
|
|
941
|
-
}
|
|
942
|
-
return failFinding(
|
|
943
|
-
"worker-acl",
|
|
944
|
-
`the ${WORKER_ACCOUNT} account's ACL is not effective on ${health.missing.join(", ")} — a named entry the ACL mask strips is ` +
|
|
945
|
-
"unreachable by the worker, and the next admitted worker dies on EACCES before connecting",
|
|
946
|
-
"re-run `omp-conductor setup host`: the transaction re-applies the ACL grants (mask included) after the final fleet restart",
|
|
947
|
-
);
|
|
948
|
-
}
|
|
949
974
|
|
|
950
975
|
/** IANA timezone in reporting config — an invalid zone silently mis-schedules
|
|
951
976
|
* the availability window and the daily digest (#273). */
|
|
@@ -1071,22 +1096,40 @@ async function telegramProbe(probes: Probes, project: ProjectConfig | undefined,
|
|
|
1071
1096
|
);
|
|
1072
1097
|
}
|
|
1073
1098
|
|
|
1074
|
-
/**
|
|
1075
|
-
*
|
|
1099
|
+
/**
|
|
1100
|
+
* Spend telemetry, through the shared judgement (#970).
|
|
1101
|
+
*
|
|
1102
|
+
* The predicate this replaced fired only when *every* sampled run reported
|
|
1103
|
+
* $0.00, and counted 0-turn kills as evidence. Replayed over this fleet's whole
|
|
1104
|
+
* history it would have fired 14 times while staying silent through 37 windows
|
|
1105
|
+
* that had lost a majority of their telemetry — so partial loss, the common
|
|
1106
|
+
* case, was the invisible one. See `spend-telemetry.ts` for the measurements.
|
|
1107
|
+
*/
|
|
1076
1108
|
function spendProbe(rows: RunSpendRow[], limit: number): Finding {
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
const window = rows.slice(0, limit);
|
|
1081
|
-
if (window.every((r) => r.spendUsd === 0)) {
|
|
1109
|
+
const verdict = judgeSpendTelemetry(rows, limit);
|
|
1110
|
+
const detail = spendTelemetryDetail(verdict);
|
|
1111
|
+
if (detail !== undefined) {
|
|
1082
1112
|
return warnFinding(
|
|
1083
1113
|
"spend-telemetry",
|
|
1084
|
-
|
|
1114
|
+
`${detail} — ${INCIDENTS.spend}`,
|
|
1085
1115
|
"verify harness spend reporting (the per-run spendUsd column / `omp usage --json`); a USD cap on top of zeros never fires",
|
|
1086
1116
|
);
|
|
1087
1117
|
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1118
|
+
if (verdict.kind === "insufficient") {
|
|
1119
|
+
return passFinding(
|
|
1120
|
+
"spend-telemetry",
|
|
1121
|
+
`${verdict.worked} completed run(s) did any work — fewer than ${verdict.needed}, nothing to judge yet`,
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
// Healthy. Naming the metered share rather than only the total: a window with
|
|
1125
|
+
// one missing row is fine and saying so is how a reader learns the baseline
|
|
1126
|
+
// is not zero.
|
|
1127
|
+
const healthy = verdict as Extract<typeof verdict, { kind: "healthy" }>;
|
|
1128
|
+
return passFinding(
|
|
1129
|
+
"spend-telemetry",
|
|
1130
|
+
`spend observed on ${healthy.worked - healthy.missing} of the last ${healthy.worked} working runs ` +
|
|
1131
|
+
`($${healthy.totalUsd.toFixed(2)} total)`,
|
|
1132
|
+
);
|
|
1090
1133
|
}
|
|
1091
1134
|
|
|
1092
1135
|
/** Live `herdr --session <session> agent list` through the project's own
|
|
@@ -1173,91 +1216,43 @@ function herdrAgentNameProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1173
1216
|
}
|
|
1174
1217
|
|
|
1175
1218
|
/**
|
|
1176
|
-
* #
|
|
1177
|
-
* scans.
|
|
1219
|
+
* #614 check — the state of this project's arming acknowledgement transaction.
|
|
1178
1220
|
*
|
|
1179
|
-
*
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1184
|
-
*
|
|
1185
|
-
* is
|
|
1186
|
-
*
|
|
1221
|
+
* The challenge proof is conductor-owned state now: the host records a pending
|
|
1222
|
+
* challenge and waits for the acknowledgement the orchestrator's inbound
|
|
1223
|
+
* adapter writes. No transcript location is diagnosed any more — where (or
|
|
1224
|
+
* whether) a session file lives is not part of arming — so what is worth
|
|
1225
|
+
* reporting is a transaction left open: a challenge still pending and whether
|
|
1226
|
+
* it has been acknowledged, or an expired record nothing settled. Every shape
|
|
1227
|
+
* is inert by construction (a reply past expiry, or for a replaced id, is
|
|
1228
|
+
* refused), so this informs; only the stale leftover warns.
|
|
1187
1229
|
*/
|
|
1188
|
-
function
|
|
1189
|
-
const
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1230
|
+
function armAckProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
1231
|
+
const now = probes.now();
|
|
1232
|
+
const sighting = observeArmChallenge(p.name);
|
|
1233
|
+
if (sighting === undefined) {
|
|
1234
|
+
return passFinding("arm-ack", `[${p.name}] no pending arm challenge — the acknowledgement handshake is idle`);
|
|
1235
|
+
}
|
|
1236
|
+
const window =
|
|
1237
|
+
sighting.sentAt === undefined || sighting.expiresAt === undefined
|
|
1238
|
+
? "an unreadable record window"
|
|
1239
|
+
: `sent ${new Date(sighting.sentAt).toISOString()}, expires ${new Date(sighting.expiresAt).toISOString()}`;
|
|
1240
|
+
if (sighting.expiresAt !== undefined && now >= sighting.expiresAt) {
|
|
1197
1241
|
return warnFinding(
|
|
1198
|
-
"arm-
|
|
1199
|
-
`[${p.name}]
|
|
1200
|
-
|
|
1201
|
-
? `omp-telegram has no claim registry (threads.json in its state dir) — arm scans the tick-cwd session directory, but doctor cannot tell whether the claim names a session file elsewhere`
|
|
1202
|
-
: `omp-telegram's claim registry is unreadable (${result.problem}) — arm scans the tick-cwd session directory, but doctor cannot tell whether the claim names a session file elsewhere`),
|
|
1203
|
-
`check omp-telegram's claim registry (threads.json in its state dir) is readable and the bridge is running, then re-run doctor`,
|
|
1242
|
+
"arm-ack",
|
|
1243
|
+
`[${p.name}] an arming challenge from ${window} expired without being settled — it is inert (replies past expiry are refused), but it lingers until this project's next arm replaces it`,
|
|
1244
|
+
`no action needed; the next \`arm\` for this project settles it — re-run doctor afterwards to confirm`,
|
|
1204
1245
|
);
|
|
1205
1246
|
}
|
|
1206
|
-
if (
|
|
1247
|
+
if (sighting.acknowledgedAt !== undefined) {
|
|
1207
1248
|
return passFinding(
|
|
1208
|
-
"arm-
|
|
1209
|
-
`[${p.name}]
|
|
1210
|
-
);
|
|
1211
|
-
}
|
|
1212
|
-
const claims = result.claims;
|
|
1213
|
-
const match = resolveProjectClaim(claims, p.name);
|
|
1214
|
-
const scanDir = sessionDirForCwd(tickCwdForProject(p));
|
|
1215
|
-
if (match.kind === "ambiguous") {
|
|
1216
|
-
// Several live claims answer to the project, so the claim's session file
|
|
1217
|
-
// cannot be told from a sibling pane's. Arm scans the tick-cwd directory
|
|
1218
|
-
// either way, but certifying that fallback as the scan is the miss this
|
|
1219
|
-
// probe exists to surface: the resumed transcript may live under another
|
|
1220
|
-
// claim while doctor reads the fallback as the whole story (#626).
|
|
1221
|
-
return warnFinding(
|
|
1222
|
-
"arm-session-dir",
|
|
1223
|
-
`[${p.name}] several live claims answer to this project (topics ${match.claimants.map((c) => c.threadId).join(", ")}), so the claim's session file is a coin toss — arm scans the tick-cwd session directory, but the resumed transcript may live elsewhere`,
|
|
1224
|
-
`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 — or start the orchestrator pane's session from the tick cwd ${scanDir}`,
|
|
1225
|
-
);
|
|
1226
|
-
}
|
|
1227
|
-
const claim = match.kind === "match" ? match.claim : undefined;
|
|
1228
|
-
if (claim === undefined) {
|
|
1229
|
-
return passFinding(
|
|
1230
|
-
"arm-session-dir",
|
|
1231
|
-
`[${p.name}] no live omp-telegram claim answers to this project — arm scans the tick-cwd session directory`,
|
|
1232
|
-
);
|
|
1233
|
-
}
|
|
1234
|
-
if (claim.sessionFile === undefined) {
|
|
1235
|
-
return passFinding(
|
|
1236
|
-
"arm-session-dir",
|
|
1237
|
-
`[${p.name}] the live claim names no session file — arm scans the tick-cwd session directory`,
|
|
1238
|
-
);
|
|
1239
|
-
}
|
|
1240
|
-
const root = sessionsRoot();
|
|
1241
|
-
const claimDir = dirname(claim.sessionFile);
|
|
1242
|
-
if (claimDir !== root && !claimDir.startsWith(join(root, sep))) {
|
|
1243
|
-
return failFinding(
|
|
1244
|
-
"arm-session-dir",
|
|
1245
|
-
`[${p.name}] the orchestrator's claimed session file ${claim.sessionFile} is outside the session tree arm scans (${root}) — ` +
|
|
1246
|
-
`arm cannot arm while the reply can only land there`,
|
|
1247
|
-
`start the orchestrator pane's session under ${root}, or from the tick cwd ${scanDir}`,
|
|
1248
|
-
);
|
|
1249
|
-
}
|
|
1250
|
-
if (claimDir !== scanDir) {
|
|
1251
|
-
return warnFinding(
|
|
1252
|
-
"arm-session-dir",
|
|
1253
|
-
`[${p.name}] the orchestrator's claimed session file ${claim.sessionFile} lives in ${claimDir}, not the tick-cwd session directory ${scanDir} — ` +
|
|
1254
|
-
`arm follows the claim, so arming works, but this is the resumed-pane shape #600 names`,
|
|
1255
|
-
`optional tidy-up: start the pane's session from the tick cwd so transcript and cwd agree again`,
|
|
1249
|
+
"arm-ack",
|
|
1250
|
+
`[${p.name}] an arm challenge is pending (${window}) and was acknowledged ${new Date(sighting.acknowledgedAt).toISOString()} — the host consumes it on settle`,
|
|
1256
1251
|
);
|
|
1257
1252
|
}
|
|
1258
1253
|
return passFinding(
|
|
1259
|
-
"arm-
|
|
1260
|
-
`[${p.name}]
|
|
1254
|
+
"arm-ack",
|
|
1255
|
+
`[${p.name}] an arm challenge is pending (${window}), not yet acknowledged — live if \`arm\` is running right now, otherwise its window simply runs out`,
|
|
1261
1256
|
);
|
|
1262
1257
|
}
|
|
1263
1258
|
|
|
@@ -1379,9 +1374,20 @@ function telegramPlumbingProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1379
1374
|
`[${p.name}] outbound-only paging — no tick accessFile to arm, so no live claim, DM owner or poll ownership is required`,
|
|
1380
1375
|
);
|
|
1381
1376
|
}
|
|
1377
|
+
// Transcript-surface identity checks belong to the claim-only proof alone:
|
|
1378
|
+
// since #614 the challenge proof reads conductor acknowledgement state, so
|
|
1379
|
+
// judging it by where a transcript lives could report arming impossible
|
|
1380
|
+
// while `arm` succeeds. An unreadable config keeps the default proof —
|
|
1381
|
+
// fail-safe to the stricter verdict, exactly like armTicks.
|
|
1382
|
+
let proof: ArmProof = DEFAULT_ARM_PROOF;
|
|
1383
|
+
try {
|
|
1384
|
+
proof = resolveArmProof(p);
|
|
1385
|
+
} catch {
|
|
1386
|
+
/* unreadable config — keep today's stricter default */
|
|
1387
|
+
}
|
|
1382
1388
|
const verdict = telegramPlumbingVerdict(
|
|
1383
1389
|
probes.armSendTopic(p),
|
|
1384
|
-
{ dirs: probes.armScanDirs(p) },
|
|
1390
|
+
proof === "claim-only" ? { dirs: probes.armScanDirs(p) } : undefined,
|
|
1385
1391
|
{
|
|
1386
1392
|
channel: probes.channelState(p),
|
|
1387
1393
|
registry: probes.claimedTopics(),
|
|
@@ -1478,6 +1484,45 @@ function telegramPlumbingProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1478
1484
|
* pages instead of recovering. Absent ~ the unrecoverable default; an explicit
|
|
1479
1485
|
* `true` is a deliberate (desktop) choice and only warned.
|
|
1480
1486
|
*/
|
|
1487
|
+
/**
|
|
1488
|
+
* A dispatch fence whose owning process is gone (#938).
|
|
1489
|
+
*
|
|
1490
|
+
* Only a fence that *declared* an owner can be judged: `setPaused` records
|
|
1491
|
+
* `owner=` for a setup transaction, whose lifetime is that process's, and
|
|
1492
|
+
* deliberately not for an operator `hold` or the fail-closed hold a failed
|
|
1493
|
+
* apply leaves behind — those are meant to outlive every process, and calling
|
|
1494
|
+
* one "abandoned" would be worse than saying nothing about it.
|
|
1495
|
+
*
|
|
1496
|
+
* Measured 2026-08-21T13:24Z: an operator abandoned a setup that looked hung;
|
|
1497
|
+
* the policy change had applied, and dispatch sat under
|
|
1498
|
+
* `source=setup reason="setup apply fence"` until somebody thought to run
|
|
1499
|
+
* `resume`. Nothing anywhere said the process that wrote it was gone.
|
|
1500
|
+
*
|
|
1501
|
+
* This never clears anything, and that is deliberate. A pid is reusable, a
|
|
1502
|
+
* paused fleet is the safe state, and resuming dispatch nobody authorised is a
|
|
1503
|
+
* worse outcome than a fence that needs one command. So it names the command.
|
|
1504
|
+
*/
|
|
1505
|
+
function fenceProbe(probes: Probes, project: string | undefined): Finding {
|
|
1506
|
+
const id = project === undefined ? "dispatch-fence" : `dispatch-fence:${project}`;
|
|
1507
|
+
const fence = probes.pauseFence(project);
|
|
1508
|
+
if (fence === undefined) return passFinding(id, "dispatch is not held by a fence");
|
|
1509
|
+
const why = fence.reason === undefined ? "" : ` — "${fence.reason}"`;
|
|
1510
|
+
if (fence.owner === undefined) {
|
|
1511
|
+
// An operator hold, or the durable hold a failed apply leaves on purpose.
|
|
1512
|
+
// Both are somebody's decision rather than a leak, whatever their age.
|
|
1513
|
+
return passFinding(id, `dispatch held by ${fence.source}${why} (no owning process; nothing to expire)`);
|
|
1514
|
+
}
|
|
1515
|
+
if (probes.pidLive(fence.owner)) {
|
|
1516
|
+
return passFinding(id, `dispatch held by a live ${fence.source} (pid ${fence.owner})`);
|
|
1517
|
+
}
|
|
1518
|
+
return warnFinding(
|
|
1519
|
+
id,
|
|
1520
|
+
`dispatch is held by ${fence.source}${why}, whose process (pid ${fence.owner}) is gone: the fence outlived ` +
|
|
1521
|
+
`the transaction that set it, and has stood since ${new Date(fence.since).toISOString()}`,
|
|
1522
|
+
`nothing here clears it — confirm no setup is running, then \`omp-conductor resume${project === undefined ? "" : ` --project ${project}`}\``,
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1481
1526
|
function herdrResumeProbe(probes: Probes): Finding {
|
|
1482
1527
|
if (!probes.herdrInstalled()) {
|
|
1483
1528
|
return passFinding("herdr-resume", "herdr not installed — nothing to check");
|
|
@@ -1572,6 +1617,169 @@ function parseEnvKey(text: string, key: string): string | undefined {
|
|
|
1572
1617
|
return undefined;
|
|
1573
1618
|
}
|
|
1574
1619
|
|
|
1620
|
+
/**
|
|
1621
|
+
* Install-surface parity (#904). omp-conductor installs onto three surfaces —
|
|
1622
|
+
* the Bun-global CLI/daemon tree, the omp plugin, and the herdr recovery
|
|
1623
|
+
* plugin — and only a live `upgrade` invocation ever compared them. On a
|
|
1624
|
+
* fleet whose installs are manual they diverge silently: measured on this
|
|
1625
|
+
* host on 2026-08-22, the omp plugin sat on the withdrawn 0.18.1 release for
|
|
1626
|
+
* about a day beside a 0.18.0 daemon, with no finding, no status line and no
|
|
1627
|
+
* escalation.
|
|
1628
|
+
*
|
|
1629
|
+
* The question is inter-surface agreement on the host, never staleness
|
|
1630
|
+
* against the registry: a fleet may deliberately sit on an older release,
|
|
1631
|
+
* but never on two at once. `expectedGitHead` is the commit the CLI's own
|
|
1632
|
+
* version was published from, so the herdr pin can be judged against the
|
|
1633
|
+
* same release; `undefined` means the registry could not be read and the pin
|
|
1634
|
+
* is reported as unverified rather than as a mismatch.
|
|
1635
|
+
*/
|
|
1636
|
+
/**
|
|
1637
|
+
* The bootstrap deadlock (#910), which is a conjunction rather than a fault:
|
|
1638
|
+
*
|
|
1639
|
+
* - no worker on this host can start, because the *installed* code cannot
|
|
1640
|
+
* load its harness; and
|
|
1641
|
+
* - the fix for that ships through a release, and a release needs a merged
|
|
1642
|
+
* worker pull request — so the broken install is what blocks its own
|
|
1643
|
+
* replacement.
|
|
1644
|
+
*
|
|
1645
|
+
* Kept as its own finding, never folded into the stale-setup ones, because the
|
|
1646
|
+
* two states call for opposite actions. "Host runtime differs — run setup host"
|
|
1647
|
+
* is *correct advice* for a stale install and *useless* here: no amount of
|
|
1648
|
+
* re-running setup replaces the installed package, and diagnosing that cost
|
|
1649
|
+
* real time on 2026-08-20..22. So this names the one command that breaks the
|
|
1650
|
+
* cycle (#908's bootstrap identity), and an ordinary stale host still reads
|
|
1651
|
+
* exactly as it did.
|
|
1652
|
+
*/
|
|
1653
|
+
function bootstrapDeadlockProbe(
|
|
1654
|
+
harness: { ok: true; version: string } | { ok: false; detail: string },
|
|
1655
|
+
): Finding {
|
|
1656
|
+
if (harness.ok) {
|
|
1657
|
+
return passFinding("bootstrap-deadlock", `workers can load the installed harness (${harness.version})`);
|
|
1658
|
+
}
|
|
1659
|
+
return failFinding(
|
|
1660
|
+
"bootstrap-deadlock",
|
|
1661
|
+
`no worker can start: the installed conductor cannot load its harness — ${harness.detail}. ` +
|
|
1662
|
+
"A release cannot publish the fix either, because publishing needs a merged worker pull request, " +
|
|
1663
|
+
"so the installed package blocks its own replacement",
|
|
1664
|
+
"install the fix by exact commit instead of by version: `omp-conductor upgrade --bootstrap <sha> " +
|
|
1665
|
+
"--source <checkout at that sha>` (its checks run against the source first, and it refuses rather " +
|
|
1666
|
+
"than installing a partly verified tree)",
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
/**
|
|
1671
|
+
* Is the plugin a mandated conductor contract runs on actually current? (#961)
|
|
1672
|
+
*
|
|
1673
|
+
* Sibling to {@link surfaceParityProbe}, and deliberately a separate finding:
|
|
1674
|
+
* that one is about the three surfaces of *this* package agreeing with each
|
|
1675
|
+
* other, this one is about the one peer the floor's reply-in-topic instruction
|
|
1676
|
+
* depends on being new enough to honour it.
|
|
1677
|
+
*
|
|
1678
|
+
* A `warn`, never a `fail`. Nothing here is broken on this host's own terms —
|
|
1679
|
+
* the fleet dispatches, merges and reports fine on a stale plugin. What breaks
|
|
1680
|
+
* is one documented instruction, and the remedy is an install the operator owns,
|
|
1681
|
+
* so failing the run would make `doctor` red for something conductor must not
|
|
1682
|
+
* fix itself.
|
|
1683
|
+
*/
|
|
1684
|
+
export function telegramFreshnessProbe(freshness: TelegramFreshness): Finding {
|
|
1685
|
+
const id = "telegram-plugin-freshness";
|
|
1686
|
+
const { installed, daemon, published } = freshness.surfaces;
|
|
1687
|
+
const named =
|
|
1688
|
+
`installed=${installed.kind === "version" ? installed.version : installed.kind}, ` +
|
|
1689
|
+
`daemon=${daemon.kind === "version" ? daemon.version : daemon.kind}, ` +
|
|
1690
|
+
`published=${published.kind === "version" ? published.version : published.kind}`;
|
|
1691
|
+
switch (freshness.state) {
|
|
1692
|
+
case "current":
|
|
1693
|
+
return passFinding(id, `${TELEGRAM_PACKAGE} is current (${named})`);
|
|
1694
|
+
case "installed-behind":
|
|
1695
|
+
return warnFinding(
|
|
1696
|
+
id,
|
|
1697
|
+
`${freshness.detail} (${named})`,
|
|
1698
|
+
`install ${TELEGRAM_PACKAGE}@${published.kind === "version" ? published.version : "latest"} and restart its daemon; a targetless telegram_send needs the newer target ladder to reply in the topic a message arrived in (#882)`,
|
|
1699
|
+
);
|
|
1700
|
+
case "daemon-stale":
|
|
1701
|
+
return warnFinding(
|
|
1702
|
+
id,
|
|
1703
|
+
`${freshness.detail} (${named})`,
|
|
1704
|
+
`restart the ${TELEGRAM_PACKAGE} daemon so it serves the installed version`,
|
|
1705
|
+
);
|
|
1706
|
+
case "not-installed":
|
|
1707
|
+
return warnFinding(
|
|
1708
|
+
id,
|
|
1709
|
+
`${freshness.detail} (${named})`,
|
|
1710
|
+
`install ${TELEGRAM_PACKAGE}, or stop relying on Telegram delivery for this fleet`,
|
|
1711
|
+
);
|
|
1712
|
+
case "unknown":
|
|
1713
|
+
// Not a finding about the plugin: a finding about this check. An
|
|
1714
|
+
// unreachable registry must never read as "behind" *or* as "current".
|
|
1715
|
+
return warnFinding(
|
|
1716
|
+
id,
|
|
1717
|
+
`${TELEGRAM_PACKAGE} freshness unverified: ${freshness.detail ?? "no surface answered"} (${named})`,
|
|
1718
|
+
"no action needed if this host has no outbound npm access; otherwise re-run doctor when it does",
|
|
1719
|
+
);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
export function surfaceParityProbe(
|
|
1724
|
+
read: SurfaceRead,
|
|
1725
|
+
expectedGitHead: string | undefined,
|
|
1726
|
+
herdrExpected: boolean,
|
|
1727
|
+
): Finding {
|
|
1728
|
+
const id = "install-surfaces";
|
|
1729
|
+
if (!read.ok) {
|
|
1730
|
+
return warnFinding(
|
|
1731
|
+
id,
|
|
1732
|
+
`installed surfaces unreadable: ${read.detail} — parity between the CLI, the omp plugin and the herdr plugin is unverified`,
|
|
1733
|
+
"check that `omp-conductor`, `omp` and (when this host runs herdr) `herdr` are on PATH for this user, then re-run doctor",
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
const { cliVersion, ompVersion, herdrSource } = read.surfaces;
|
|
1737
|
+
const named = `cli=${cliVersion}, omp=${ompVersion ?? "absent"}, herdr=${herdrSource ?? "absent"}`;
|
|
1738
|
+
// A proven disagreement first: two different releases live on one host is
|
|
1739
|
+
// the fault this probe exists for, and it outranks anything absent.
|
|
1740
|
+
if (ompVersion !== undefined && ompVersion !== cliVersion) {
|
|
1741
|
+
return failFinding(
|
|
1742
|
+
id,
|
|
1743
|
+
`installed surfaces disagree: the omp plugin is ${ompVersion} while the CLI/daemon tree is ${cliVersion} (${named})`,
|
|
1744
|
+
`run \`omp-conductor upgrade --to ${cliVersion}\` (or to the release this fleet should be on) so all three surfaces carry one identity`,
|
|
1745
|
+
);
|
|
1746
|
+
}
|
|
1747
|
+
if (herdrExpected && herdrSource !== undefined && expectedGitHead !== undefined) {
|
|
1748
|
+
if (herdrSource.startsWith("local:")) {
|
|
1749
|
+
return warnFinding(
|
|
1750
|
+
id,
|
|
1751
|
+
`the herdr plugin is linked to a local checkout, not a released pin (${named})`,
|
|
1752
|
+
`run \`omp-conductor upgrade --to ${cliVersion}\` to replace the link with the released pin, or keep the link deliberately while developing`,
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
const expected = expectedHerdrSource(expectedGitHead);
|
|
1756
|
+
if (herdrSource !== expected) {
|
|
1757
|
+
return failFinding(
|
|
1758
|
+
id,
|
|
1759
|
+
`installed surfaces disagree: the herdr plugin is pinned to ${herdrSource} while ${cliVersion} was published from ${expectedGitHead} (${named})`,
|
|
1760
|
+
`run \`omp-conductor upgrade --to ${cliVersion}\` so the herdr recovery plugin matches the release the rest of the host runs`,
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
// Nothing disagrees. Anything absent is named as absent — a surface this
|
|
1765
|
+
// host does not carry is not a mismatch, and a pin that could not be
|
|
1766
|
+
// verified is not a pass in disguise.
|
|
1767
|
+
const absent: string[] = [];
|
|
1768
|
+
if (ompVersion === undefined) absent.push("omp plugin not installed — a tick cannot arm without it");
|
|
1769
|
+
if (herdrExpected && herdrSource === undefined) absent.push("herdr plugin not installed — pane recovery cannot run");
|
|
1770
|
+
if (herdrExpected && herdrSource !== undefined && expectedGitHead === undefined) {
|
|
1771
|
+
absent.push(`herdr pin ${herdrSource} unverified — the registry did not answer what ${cliVersion} was published from`);
|
|
1772
|
+
}
|
|
1773
|
+
if (absent.length > 0) {
|
|
1774
|
+
return warnFinding(
|
|
1775
|
+
id,
|
|
1776
|
+
`${absent.join("; ")} (${named})`,
|
|
1777
|
+
`run \`omp-conductor upgrade --to ${cliVersion}\` to install every surface this host needs from one release`,
|
|
1778
|
+
);
|
|
1779
|
+
}
|
|
1780
|
+
return passFinding(id, `one identity across every installed surface (${named})`);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1575
1783
|
/**
|
|
1576
1784
|
* Run every probe and assemble the stable report.
|
|
1577
1785
|
*
|
|
@@ -1641,6 +1849,17 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
|
|
|
1641
1849
|
findings.push(dbProbe(probes));
|
|
1642
1850
|
findings.push(dbBackupProbe(probes, cfg));
|
|
1643
1851
|
findings.push(await ghAuthProbe(probes, configuredRepos(cfg)));
|
|
1852
|
+
// Install-surface parity is host-wide, like the unit and the store: one
|
|
1853
|
+
// read of the three identities, judged once (#904).
|
|
1854
|
+
const surfaces = await probes.installedSurfaces();
|
|
1855
|
+
const cliVersion = surfaces.ok ? surfaces.surfaces.cliVersion : undefined;
|
|
1856
|
+
const expectedGitHead = cliVersion === undefined ? undefined : await probes.releaseGitHead(cliVersion);
|
|
1857
|
+
findings.push(surfaceParityProbe(surfaces, expectedGitHead, probes.herdrInstalled()));
|
|
1858
|
+
// Host-wide for the same reason: one plugin install serves every project, so
|
|
1859
|
+
// it is judged once rather than repeated per project (#961).
|
|
1860
|
+
findings.push(telegramFreshnessProbe(await probes.telegramFreshness()));
|
|
1861
|
+
// Host-wide like the surfaces: one install, one harness, one answer (#910).
|
|
1862
|
+
findings.push(bootstrapDeadlockProbe(await probes.workerHarness()));
|
|
1644
1863
|
// The run-session root is one host-wide fact — where every run's session dir
|
|
1645
1864
|
// (and the omp settings overlay inside it) lands — probed once and shared
|
|
1646
1865
|
// across the per-project omp-settings findings.
|
|
@@ -1649,7 +1868,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
|
|
|
1649
1868
|
// The collected run sample across the resolved project set, newest-first;
|
|
1650
1869
|
// spend telemetry is a single host-wide finding, not one per project.
|
|
1651
1870
|
const spendRows: RunSpendRow[] = [];
|
|
1652
|
-
for (const p of projects) spendRows.push(...probes.recentRuns(p.name,
|
|
1871
|
+
for (const p of projects) spendRows.push(...probes.recentRuns(p.name, SPEND_SAMPLE_ROWS));
|
|
1653
1872
|
|
|
1654
1873
|
// Per-project probes run for every resolved project, each named in its
|
|
1655
1874
|
// finding so a multi-project run stays legible. When nothing resolved
|
|
@@ -1663,7 +1882,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
|
|
|
1663
1882
|
passFinding("herdr-agent-name", projectProblem === undefined ? "no project resolved — nothing to check" : `agent name uncheckable: ${projectProblem}`),
|
|
1664
1883
|
);
|
|
1665
1884
|
findings.push(
|
|
1666
|
-
passFinding("arm-
|
|
1885
|
+
passFinding("arm-ack", projectProblem === undefined ? "no project resolved — nothing to check" : `arm acknowledgement uncheckable: ${projectProblem}`),
|
|
1667
1886
|
);
|
|
1668
1887
|
findings.push(
|
|
1669
1888
|
passFinding("topic-pin", projectProblem === undefined ? "no project resolved — nothing to check" : `topic pin uncheckable: ${projectProblem}`),
|
|
@@ -1678,7 +1897,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
|
|
|
1678
1897
|
for (const p of projects) {
|
|
1679
1898
|
findings.push(await labelProbe(probes, p));
|
|
1680
1899
|
findings.push(herdrAgentNameProbe(probes, p));
|
|
1681
|
-
findings.push(
|
|
1900
|
+
findings.push(armAckProbe(probes, p));
|
|
1682
1901
|
findings.push(topicPinProbe(probes, p));
|
|
1683
1902
|
findings.push(telegramPlumbingProbe(probes, p));
|
|
1684
1903
|
findings.push(ompSettingsProbe(p, sessionRootState));
|
|
@@ -1690,10 +1909,6 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
|
|
|
1690
1909
|
findings.push(unitProbe(probes, projects[0], cfg));
|
|
1691
1910
|
findings.push(recoveryProbe(probes));
|
|
1692
1911
|
findings.push(ownershipProbe(probes));
|
|
1693
|
-
// The worker's path ACLs are a host-wide fact like the unit ownership: one
|
|
1694
|
-
// shared daemon account, one shared worker identity, one shared set of
|
|
1695
|
-
// linked config paths. A finding once, never once per project.
|
|
1696
|
-
findings.push(workerAclProbeFinding(probes));
|
|
1697
1912
|
// #541 seam checks, host-global: the live herdr config and the plugin's
|
|
1698
1913
|
// config.env are single files on the host, not per-project facts.
|
|
1699
1914
|
findings.push(herdrResumeProbe(probes));
|
|
@@ -1701,9 +1916,13 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
|
|
|
1701
1916
|
if (projects.length === 0) {
|
|
1702
1917
|
findings.push(timezoneProbe(undefined));
|
|
1703
1918
|
findings.push(await telegramProbe(probes, undefined, checkedAt));
|
|
1919
|
+
findings.push(fenceProbe(probes, undefined));
|
|
1704
1920
|
} else {
|
|
1705
1921
|
for (const p of projects) findings.push(timezoneProbe(p));
|
|
1706
1922
|
for (const p of projects) findings.push(await telegramProbe(probes, p, checkedAt));
|
|
1923
|
+
// Per project, because the sentinel is: one fleet's fence must never be
|
|
1924
|
+
// reported against another's name.
|
|
1925
|
+
for (const p of projects) findings.push(fenceProbe(probes, p.name));
|
|
1707
1926
|
}
|
|
1708
1927
|
findings.push(spendProbe(spendRows, SPEND_SAMPLE_RUNS));
|
|
1709
1928
|
|
|
@@ -1725,6 +1944,41 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
|
|
|
1725
1944
|
};
|
|
1726
1945
|
}
|
|
1727
1946
|
|
|
1947
|
+
/**
|
|
1948
|
+
* Exercise the worker's real import contract out of process (#910).
|
|
1949
|
+
*
|
|
1950
|
+
* `harness-loader.ts` is the module a worker session loads its harness through,
|
|
1951
|
+
* and it is runnable — so this runs exactly that, with `--no-install`, and reads
|
|
1952
|
+
* its verdict rather than re-deriving one. Anything else would be a second
|
|
1953
|
+
* opinion about the launch path, which is how a doctor comes to disagree with
|
|
1954
|
+
* the thing it is checking.
|
|
1955
|
+
*/
|
|
1956
|
+
async function defaultWorkerHarness(): Promise<{ ok: true; version: string } | { ok: false; detail: string }> {
|
|
1957
|
+
const loader = join(import.meta.dir, "harness-loader.ts");
|
|
1958
|
+
try {
|
|
1959
|
+
const child = Bun.spawn(["bun", "--no-install", loader], {
|
|
1960
|
+
stdin: "ignore",
|
|
1961
|
+
stdout: "pipe",
|
|
1962
|
+
stderr: "pipe",
|
|
1963
|
+
env: process.env,
|
|
1964
|
+
});
|
|
1965
|
+
const stdout = new Response(child.stdout).text();
|
|
1966
|
+
const stderr = new Response(child.stderr).text();
|
|
1967
|
+
const code = await child.exited;
|
|
1968
|
+
if (code !== 0) {
|
|
1969
|
+
const detail = ((await stderr).trim() || (await stdout).trim()).split("\n")[0] ?? `exit ${code}`;
|
|
1970
|
+
return { ok: false, detail };
|
|
1971
|
+
}
|
|
1972
|
+
const parsed: unknown = JSON.parse((await stdout).trim());
|
|
1973
|
+
const version = parsed !== null && typeof parsed === "object" ? Reflect.get(parsed, "version") : undefined;
|
|
1974
|
+
return typeof version === "string" && version.length > 0
|
|
1975
|
+
? { ok: true, version }
|
|
1976
|
+
: { ok: false, detail: "the harness loaded but reported no version" };
|
|
1977
|
+
} catch (err) {
|
|
1978
|
+
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1728
1982
|
/** The default wiring — every production transport the rest of the package uses. */
|
|
1729
1983
|
export function defaultProbes(): Probes {
|
|
1730
1984
|
return {
|
|
@@ -1737,9 +1991,6 @@ export function defaultProbes(): Probes {
|
|
|
1737
1991
|
readUnit: defaultReadUnit,
|
|
1738
1992
|
stat: defaultStat,
|
|
1739
1993
|
uidOf: defaultUidOf,
|
|
1740
|
-
// The same effective-ACL read the identity plan plans with — doctor and
|
|
1741
|
-
// setup cannot disagree about a worker grant (#835).
|
|
1742
|
-
workerAclHealth: () => workerAclHealth(join(homedir(), ".omp", "agent")),
|
|
1743
1994
|
dbIntegrity: defaultDbIntegrity,
|
|
1744
1995
|
snapshotDirState: defaultSnapshotDirState,
|
|
1745
1996
|
sessionRootState: defaultSessionRootState,
|
|
@@ -1748,6 +1999,9 @@ export function defaultProbes(): Probes {
|
|
|
1748
1999
|
telegramSend: telegramReportSend,
|
|
1749
2000
|
canonicalUnits: defaultCanonicalUnits,
|
|
1750
2001
|
herdrInstalled: () => Bun.which("herdr") !== null,
|
|
2002
|
+
pauseFence: (project) => pauseInstance(project),
|
|
2003
|
+
workerHarness: defaultWorkerHarness,
|
|
2004
|
+
pidLive: (pid) => pidAlive(pid),
|
|
1751
2005
|
herdrAgents: defaultHerdrAgents,
|
|
1752
2006
|
herdrSession: () => resolveHerdrSessionWithBridge(),
|
|
1753
2007
|
herdrConfig: defaultHerdrConfig,
|
|
@@ -1795,6 +2049,35 @@ export function defaultProbes(): Probes {
|
|
|
1795
2049
|
if (tick.kind !== "ok") return undefined;
|
|
1796
2050
|
return tick.config.agentName ?? DEFAULT_FLEET_AGENT_NAME;
|
|
1797
2051
|
},
|
|
2052
|
+
installedSurfaces: async () => {
|
|
2053
|
+
// `upgrade`'s own reader, so doctor cannot disagree with the transaction
|
|
2054
|
+
// about what is installed. A host with no herdr is asked only about the
|
|
2055
|
+
// two surfaces it has, rather than failing the whole read (#904).
|
|
2056
|
+
try {
|
|
2057
|
+
const surfaces = await inspectSurfaces(UPGRADE_DEPS, { readHerdr: Bun.which("herdr") !== null });
|
|
2058
|
+
return { ok: true, surfaces };
|
|
2059
|
+
} catch (err) {
|
|
2060
|
+
return { ok: false, detail: messageOf(err) };
|
|
2061
|
+
}
|
|
2062
|
+
},
|
|
2063
|
+
telegramFreshness: () =>
|
|
2064
|
+
// The module's own reader, so `doctor` and `status` cannot disagree about
|
|
2065
|
+
// what is installed — the same one-seam rule `installedSurfaces` follows.
|
|
2066
|
+
checkTelegramFreshness({
|
|
2067
|
+
run: async (cmd, args) => {
|
|
2068
|
+
const r = await UPGRADE_DEPS.run(cmd, args);
|
|
2069
|
+
return { code: r.code, stdout: r.stdout };
|
|
2070
|
+
},
|
|
2071
|
+
}),
|
|
2072
|
+
releaseGitHead: async (version) => {
|
|
2073
|
+
try {
|
|
2074
|
+
return (await releaseIdentity(UPGRADE_DEPS, version)).gitHead;
|
|
2075
|
+
} catch {
|
|
2076
|
+
// An unreadable registry makes the herdr pin unverifiable, never
|
|
2077
|
+
// wrong: the probe says so instead of inventing a mismatch.
|
|
2078
|
+
return undefined;
|
|
2079
|
+
}
|
|
2080
|
+
},
|
|
1798
2081
|
now: Date.now,
|
|
1799
2082
|
probeTelegram: false,
|
|
1800
2083
|
};
|