omp-conductor 0.15.5 → 0.15.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.15.5",
3
+ "version": "0.15.6",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
package/src/board.ts CHANGED
@@ -4,6 +4,7 @@ import { emitKeypressEvents } from "node:readline";
4
4
  import { findProject, loadConfig, resolveCaps } from "./config.ts";
5
5
  import { statusSnapshotFromStore, type StatusSnapshot } from "./daemon.ts";
6
6
  import {
7
+ classifyDaemonProjectHealth,
7
8
  codeGraphFromHealthz,
8
9
  fleetLayers,
9
10
  probeTelegramHealth,
@@ -926,39 +927,16 @@ export function renderBoard(
926
927
  async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealthProbe> {
927
928
  const layers = fleetLayers(project.name);
928
929
  const record = livingDaemon();
930
+ // Same gate as status (#379): never health-check a record pinned to another
931
+ // project, and never invent a second membership convention beside
932
+ // classifyDaemonProjectHealth.
929
933
  const wrongRecord = record?.project !== undefined && record.project !== project.name;
930
934
  const [telegram, health] = await Promise.all([
931
935
  probeTelegramHealth(project.name),
932
936
  record === undefined || wrongRecord ? undefined : healthCheck(record.port),
933
937
  ]);
934
- let daemon: DaemonBoardState;
935
- if (record === undefined) daemon = "stopped";
936
- else if (wrongRecord) daemon = "other-project";
937
- else if (health?.ok !== true) daemon = "unreachable";
938
- else {
939
- try {
940
- const payload = JSON.parse(health.body ?? "null") as unknown;
941
- if (payload === null || typeof payload !== "object") {
942
- daemon = "unreachable";
943
- } else if (Reflect.get(payload, "project") === project.name) {
944
- daemon = "ok";
945
- } else {
946
- const projects = Reflect.get(payload, "projects");
947
- daemon =
948
- Array.isArray(projects) &&
949
- projects.some(
950
- (entry) =>
951
- entry !== null &&
952
- typeof entry === "object" &&
953
- Reflect.get(entry, "project") === project.name,
954
- )
955
- ? "ok"
956
- : "other-project";
957
- }
958
- } catch {
959
- daemon = "unreachable";
960
- }
961
- }
938
+ const classified = classifyDaemonProjectHealth(record, health, project.name);
939
+ const daemon: DaemonBoardState = classified.kind;
962
940
  const cachedGraph = daemon === "ok" ? codeGraphFromHealthz(health?.body, project.name) : undefined;
963
941
  return {
964
942
  health: {
package/src/fleet.ts CHANGED
@@ -55,8 +55,10 @@ import {
55
55
  healthCheck,
56
56
  isAlive,
57
57
  livingDaemon,
58
+ probeUnit,
58
59
  stopDaemon,
59
60
  type StopResult,
61
+ type UnitOwnership,
60
62
  SYSTEMD_UNIT,
61
63
  } from "./lifecycle.ts";
62
64
  import { formatRss, rssBytesFromHealthz } from "./host.ts";
@@ -99,7 +101,7 @@ export const ARM_CHALLENGE_TIMEOUT_MS = 300_000;
99
101
  const LEGACY_HERDR_SESSION = "fleet";
100
102
 
101
103
  export const LEGACY_HERDR_SESSION_HINT =
102
- 'herdr session "fleet" found — rename it to "conductor" or set HERDR_SESSION=fleet (remove this bridge next minor)';
104
+ 'herdr session "fleet" found — rename it to "conductor" or run omp-conductor setup host to pin HERDR_SESSION=fleet (remove this bridge next minor)';
103
105
 
104
106
  let legacyHerdrSessionHintPrinted = false;
105
107
 
@@ -1270,10 +1272,132 @@ export function formatCodeGraphHealth(graph: CodeGraphHealth, now = Date.now()):
1270
1272
  }
1271
1273
 
1272
1274
 
1275
+ /**
1276
+ * Project-scoped reading of a living daemon record + `/healthz` body.
1277
+ *
1278
+ * Board and `status` share this so a host running one daemon for several
1279
+ * projects cannot be called healthy for a project it does not serve (#379).
1280
+ * A third interpretation convention is forbidden.
1281
+ */
1282
+ export type DaemonProjectHealth =
1283
+ | { kind: "stopped" }
1284
+ | { kind: "ok" }
1285
+ | { kind: "unreachable" }
1286
+ | { kind: "other-project"; serves?: string };
1287
+
1288
+ /**
1289
+ * Facts `formatFleetStatus` needs about the living daemon. Pure input — the
1290
+ * caller probes; the formatter never shells out (#379).
1291
+ */
1292
+ export type FleetDaemonProbe = {
1293
+ project: DaemonProjectHealth;
1294
+ /** `/healthz` body when the probe is project-ok (rss / overlays). */
1295
+ body?: string;
1296
+ /** systemd ownership of the living record pid; omit when unprobed. */
1297
+ unit?: UnitOwnership;
1298
+ };
1299
+
1300
+ /**
1301
+ * Decide whether a living record + optional `/healthz` answer serve `project`.
1302
+ *
1303
+ * Mirrors the board gate: a record pinned to another project is never probed
1304
+ * further; a multi-project payload is ok only when `project` appears in
1305
+ * `projects[]` (or as the top-level single-project `project` field).
1306
+ */
1307
+ export function classifyDaemonProjectHealth(
1308
+ record: { project?: string } | undefined,
1309
+ health: { ok: boolean; body?: string } | undefined,
1310
+ project: string,
1311
+ ): DaemonProjectHealth {
1312
+ if (record === undefined) return { kind: "stopped" };
1313
+ // Record project is authoritative when set — a single-project leftover from
1314
+ // #376 must not be presented as this project's daemon just because its port
1315
+ // still answers.
1316
+ if (record.project !== undefined && record.project !== project) {
1317
+ return { kind: "other-project", serves: record.project };
1318
+ }
1319
+ if (health?.ok !== true) return { kind: "unreachable" };
1320
+ try {
1321
+ const payload = JSON.parse(health.body ?? "null") as unknown;
1322
+ if (payload === null || typeof payload !== "object") {
1323
+ return { kind: "unreachable" };
1324
+ }
1325
+ if (Reflect.get(payload, "project") === project) return { kind: "ok" };
1326
+ const projects = Reflect.get(payload, "projects");
1327
+ if (
1328
+ Array.isArray(projects) &&
1329
+ projects.some(
1330
+ (entry) =>
1331
+ entry !== null &&
1332
+ typeof entry === "object" &&
1333
+ Reflect.get(entry, "project") === project,
1334
+ )
1335
+ ) {
1336
+ return { kind: "ok" };
1337
+ }
1338
+ return { kind: "other-project", serves: servedProjectName(payload) };
1339
+ } catch {
1340
+ return { kind: "unreachable" };
1341
+ }
1342
+ }
1343
+
1344
+ /** Best-effort name of who a foreign `/healthz` payload is actually serving. */
1345
+ function servedProjectName(payload: object): string | undefined {
1346
+ const top = Reflect.get(payload, "project");
1347
+ if (typeof top === "string" && top.length > 0) return top;
1348
+ const projects = Reflect.get(payload, "projects");
1349
+ if (!Array.isArray(projects)) return undefined;
1350
+ for (const entry of projects) {
1351
+ if (entry === null || typeof entry !== "object") continue;
1352
+ const name = Reflect.get(entry, "project");
1353
+ if (typeof name === "string" && name.length > 0) return name;
1354
+ }
1355
+ return undefined;
1356
+ }
1357
+
1358
+ function formatDaemonHealthz(probe: FleetDaemonProbe | undefined): string {
1359
+ if (probe === undefined) return "unprobed";
1360
+ switch (probe.project.kind) {
1361
+ case "stopped":
1362
+ return "stopped";
1363
+ case "ok":
1364
+ return "ok";
1365
+ case "unreachable":
1366
+ return "unreachable — the process is up but not serving";
1367
+ case "other-project":
1368
+ return probe.project.serves === undefined
1369
+ ? "other-project"
1370
+ : `other-project — serves ${probe.project.serves}`;
1371
+ }
1372
+ }
1373
+
1374
+ /**
1375
+ * Unit line for a living record. The bare unit name alone used to imply
1376
+ * systemd ownership that was never checked (#379); every branch states the
1377
+ * probe result, and `unknown` is never rendered as owned.
1378
+ */
1379
+ function formatDaemonUnit(unit: UnitOwnership | undefined, recordPid: number): string {
1380
+ if (unit === undefined) return ` unit ${SYSTEMD_UNIT} unprobed`;
1381
+ switch (unit.kind) {
1382
+ case "active":
1383
+ if (unit.pid === recordPid) {
1384
+ return ` unit ${SYSTEMD_UNIT} systemd-owned`;
1385
+ }
1386
+ // Live unit, different MainPID — the record pid is not systemd's.
1387
+ return ` unit ${SYSTEMD_UNIT} unmanaged — MainPID ${unit.pid}`;
1388
+ case "failed":
1389
+ return ` unit ${SYSTEMD_UNIT} unmanaged — unit failed`;
1390
+ case "inactive":
1391
+ return ` unit ${SYSTEMD_UNIT} inactive`;
1392
+ case "unknown":
1393
+ return ` unit ${SYSTEMD_UNIT} unknown — ${unit.reason}`;
1394
+ }
1395
+ }
1396
+
1273
1397
  export function formatFleetStatus(
1274
1398
  s: StatusSnapshot,
1275
1399
  layers: FleetLayers,
1276
- daemonHealth?: { ok: boolean; body?: string },
1400
+ daemon: FleetDaemonProbe | undefined = undefined,
1277
1401
  telegram: TelegramHealth = { kind: "unprobed" },
1278
1402
  now = Date.now(),
1279
1403
  codeGraph: CodeGraphHealth = { configured: false },
@@ -1315,20 +1439,17 @@ export function formatFleetStatus(
1315
1439
  if (!layers.daemon.running || layers.daemon.pid === undefined) {
1316
1440
  daemonBlock = "daemon not running";
1317
1441
  } else {
1318
- const hz =
1319
- daemonHealth === undefined
1320
- ? "unprobed"
1321
- : daemonHealth.ok
1322
- ? "ok"
1323
- : "unreachable — the process is up but not serving";
1324
- const rss = rssBytesFromHealthz(daemonHealth?.body);
1442
+ // Only trust rss from a project-ok body — a foreign payload's bytes are
1443
+ // not this project's daemon facts (#379).
1444
+ const rss =
1445
+ daemon?.project.kind === "ok" ? rssBytesFromHealthz(daemon.body) : undefined;
1325
1446
  daemonBlock = [
1326
1447
  "daemon",
1327
1448
  ` pid ${layers.daemon.pid}`,
1328
1449
  ` port ${layers.daemon.port ?? "?"}`,
1329
1450
  ...(rss === undefined ? [] : [` rss ${formatRss(rss)}`]),
1330
- ` healthz ${hz}`,
1331
- ` unit ${SYSTEMD_UNIT}`,
1451
+ ` healthz ${formatDaemonHealthz(daemon)}`,
1452
+ formatDaemonUnit(daemon?.unit, layers.daemon.pid),
1332
1453
  ].join("\n");
1333
1454
  }
1334
1455
 
@@ -1504,8 +1625,11 @@ export async function renderStatus(projectName?: string): Promise<string> {
1504
1625
  const layers = fleetLayers(projectName);
1505
1626
  const project = findProject(loadConfig(), projectName);
1506
1627
  const rec = livingDaemon();
1507
- const [health, telegram, planUsage, github] = await Promise.all([
1508
- rec === undefined ? undefined : healthCheck(rec.port),
1628
+ // Gate the port probe on record membership first — a leftover single-project
1629
+ // daemon from #376 must not be health-checked as if it served us (#379).
1630
+ const wrongRecord = rec?.project !== undefined && rec.project !== project.name;
1631
+ const [rawHealth, telegram, planUsage, github] = await Promise.all([
1632
+ rec === undefined || wrongRecord ? undefined : healthCheck(rec.port),
1509
1633
  probeTelegramHealth(projectName),
1510
1634
  // Read here rather than in `statusSnapshot`, which is synchronous and used
1511
1635
  // by callers that must not shell out. An unmetered project never spawns
@@ -1516,13 +1640,27 @@ export async function renderStatus(projectName?: string): Promise<string> {
1516
1640
  // broken report (#188).
1517
1641
  fetchRateLimit(),
1518
1642
  ]);
1519
- const cached = codeGraphFromHealthz(health?.body, project.name);
1643
+ const projectHealth = classifyDaemonProjectHealth(rec, rawHealth, project.name);
1644
+ // Ownership is probed, never assumed from the unit name constant (#379).
1645
+ const unit = rec === undefined ? undefined : probeUnit();
1646
+ const daemon: FleetDaemonProbe | undefined =
1647
+ rec === undefined
1648
+ ? undefined
1649
+ : {
1650
+ project: projectHealth,
1651
+ ...(projectHealth.kind === "ok" && rawHealth?.body !== undefined
1652
+ ? { body: rawHealth.body }
1653
+ : {}),
1654
+ ...(unit === undefined ? {} : { unit }),
1655
+ };
1656
+ const healthBody = projectHealth.kind === "ok" ? rawHealth?.body : undefined;
1657
+ const cached = codeGraphFromHealthz(healthBody, project.name);
1520
1658
  const codeGraph = cached ?? (await probeCodeGraph(project));
1521
- const workerPhases = workerPhasesFromHealthz(health?.body, project.name);
1659
+ const workerPhases = workerPhasesFromHealthz(healthBody, project.name);
1522
1660
  return formatFleetStatus(
1523
1661
  { ...s, planUsage, github },
1524
1662
  layers,
1525
- health,
1663
+ daemon,
1526
1664
  telegram,
1527
1665
  Date.now(),
1528
1666
  codeGraph,
package/src/setup-host.ts CHANGED
@@ -4,7 +4,7 @@ import { homedir, userInfo } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { configPath, loadConfig, resolveCaps, stateDir } from "./config.ts";
6
6
  import { isPaused, runDaemon, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
7
- import { DEFAULT_FLEET_AGENT_NAME } from "./fleet.ts";
7
+ import { DEFAULT_FLEET_AGENT_NAME, DEFAULT_HERDR_SESSION, resolveHerdrSessionWithBridge } from "./fleet.ts";
8
8
  import {
9
9
  DEFAULT_PORT,
10
10
  healthCheck,
@@ -65,6 +65,12 @@ export interface ServiceRuntime {
65
65
  packageCli: string;
66
66
  conductorHome: string;
67
67
  telegramStateDir: string;
68
+ /**
69
+ * Non-default Herdr session to pin into the unit (#394). Omitted when the
70
+ * host uses {@link DEFAULT_HERDR_SESSION}: an always-present line would show
71
+ * as drift on every existing host and add a redundant default.
72
+ */
73
+ herdrSession?: string;
68
74
  }
69
75
 
70
76
  function systemdQuote(value: string): string {
@@ -237,7 +243,12 @@ function refusal(
237
243
  }
238
244
 
239
245
 
240
- function defaultServiceRuntime(telegramStateDir: string): ServiceRuntime {
246
+ function defaultServiceRuntime(
247
+ telegramStateDir: string,
248
+ // Bridge-resolved session name. Passed in so tests stay hermetic (no herdr
249
+ // spawn) and the renderer never shells out — same threading as telegramStateDir.
250
+ herdrSession: string = resolveHerdrSessionWithBridge(),
251
+ ): ServiceRuntime {
241
252
  const home = homedir();
242
253
  const bun = process.execPath;
243
254
  const globalCli = Bun.which("omp-conductor");
@@ -253,6 +264,9 @@ function defaultServiceRuntime(telegramStateDir: string): ServiceRuntime {
253
264
  packageCli: join(import.meta.dir, "cli.ts"),
254
265
  conductorHome: dirname(configPath()),
255
266
  telegramStateDir,
267
+ // Only pin a non-default session. Default hosts keep a unit with no
268
+ // HERDR_SESSION line so setup/upgrade do not invent drift (#394).
269
+ ...(herdrSession !== DEFAULT_HERDR_SESSION ? { herdrSession } : {}),
256
270
  };
257
271
  }
258
272
 
@@ -283,6 +297,11 @@ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: numbe
283
297
  `Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
284
298
  `Environment=${systemdQuote(`OMP_CONDUCTOR_HOME=${runtime.conductorHome}`)}`,
285
299
  `Environment=${systemdQuote(`OMP_TELEGRAM_STATE_DIR=${runtime.telegramStateDir}`)}`,
300
+ // Escape hatch for hosts still on the pre-#320 "fleet" session (#394). Only
301
+ // when set: an always-on line would drift every default host on every setup.
302
+ ...(runtime.herdrSession === undefined
303
+ ? []
304
+ : [`Environment=${systemdQuote(`HERDR_SESSION=${runtime.herdrSession}`)}`]),
286
305
  `WorkingDirectory=${systemdPath(stateDir())}`,
287
306
  `ExecStart=${command.map(systemdQuote).join(" ")}`,
288
307
  "Restart=on-failure",