omp-conductor 0.19.0 → 0.19.2

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.19.0",
3
+ "version": "0.19.2",
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.",
@@ -29,7 +29,7 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "scripts": {
32
- "check": "tsc --noEmit && bun run src/check-trailing-newlines.ts",
32
+ "check": "tsc --noEmit && bun run src/check-trailing-newlines.ts && bun run src/check-browser-js.ts",
33
33
  "test": "bun test",
34
34
  "schema": "bun run src/generate-schema.ts"
35
35
  },
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Gate: every browser script this package serves must parse.
3
+ *
4
+ * Run via `bun run check` from the package root, beside the trailing-newline
5
+ * gate. `tsc` never sees `src/dashboard/app.js` — it is plain browser JS,
6
+ * shipped as an asset and loaded by a `<script>` tag — and no test executes
7
+ * it, so nothing in the pipeline had an opinion about whether it was even
8
+ * syntactically valid.
9
+ *
10
+ * It was not. 0.19.0 shipped an `app.js` carrying four `__omp_shell("…")`
11
+ * fragments where `!answer.ok` and `!confirmDestructive(` should have been:
12
+ * the authoring session wrote the file through a Python eval path, whose
13
+ * IPython-style `!cmd` shell escape rewrote every line that began with `!`.
14
+ * A single SyntaxError takes the whole script with it, so the dashboard
15
+ * rendered its static `<h1>` and nothing else — no token prompt, no fleet —
16
+ * and looked, from the outside, like an auth or data problem (#981).
17
+ *
18
+ * A parse is the whole check. It is not a linter and has no opinion about
19
+ * style: it asks the one question a served script must answer yes to.
20
+ */
21
+
22
+ import { execFileSync } from "node:child_process";
23
+ import { readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+
26
+ /** Tracked browser scripts, repo-root-relative. */
27
+ export const BROWSER_SCRIPTS: readonly string[] = ["omp/src/dashboard/app.js"];
28
+
29
+ /**
30
+ * The parse error for one script, or null when it parses. Uses Bun's own
31
+ * transpiler: a full parse of the source, without executing a line of it (the
32
+ * script drives a DOM this process does not have).
33
+ */
34
+ export function parseFailure(source: string, path: string): string | null {
35
+ try {
36
+ new Bun.Transpiler({ loader: "js", target: "browser" }).transformSync(source);
37
+ return null;
38
+ } catch (err) {
39
+ const message = err instanceof Error ? err.message : String(err);
40
+ return `${path}: ${message.split("\n")[0]}`;
41
+ }
42
+ }
43
+
44
+ /** One violation line per unparseable script, so a run names every offender. */
45
+ export function findViolations(
46
+ paths: readonly string[],
47
+ read: (path: string) => string,
48
+ ): string[] {
49
+ const violations: string[] = [];
50
+ for (const path of paths) {
51
+ const failure = parseFailure(read(path), path);
52
+ if (failure !== null) violations.push(failure);
53
+ }
54
+ return violations;
55
+ }
56
+
57
+ function repoRoot(): string {
58
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
59
+ }
60
+
61
+ if (import.meta.main) {
62
+ const root = repoRoot();
63
+ const violations = findViolations(BROWSER_SCRIPTS, (path) =>
64
+ readFileSync(join(root, path), "utf8"),
65
+ );
66
+ if (violations.length > 0) {
67
+ for (const violation of violations) console.error(violation);
68
+ console.error(`browser script gate: ${violations.length} script(s) do not parse`);
69
+ process.exit(1);
70
+ }
71
+ console.log("browser script gate: ok");
72
+ }
@@ -140,7 +140,7 @@ async function runControl(path, body, describe) {
140
140
  : JSON.stringify(answer.body);
141
141
  showControlResult(
142
142
  answer.ok ? `${describe}: ok` : `${describe} refused (${answer.status}): ${detail}`,
143
- __omp_shell("answer.ok,")
143
+ !answer.ok,
144
144
  );
145
145
  } catch (err) {
146
146
  showControlResult(`${describe} failed: ${String(err.message ?? err)}`, true);
@@ -188,7 +188,7 @@ controls.addEventListener("click", (event) => {
188
188
  if (reason === null || reason.trim() === "") return;
189
189
  if (
190
190
  action === "hold" &&
191
- __omp_shell("confirmDestructive(")
191
+ !confirmDestructive(
192
192
  "Hold stops new claims AND disarms ticks. Re-arming needs a Telegram challenge answered in the chat. Continue?",
193
193
  )
194
194
  ) {
@@ -447,7 +447,7 @@ function renderRunControls(issue) {
447
447
  const reason = window.prompt(`Reason for stopping #${issue} (recorded in the run's report):`);
448
448
  if (reason === null || reason.trim() === "") return;
449
449
  if (
450
- __omp_shell("confirmDestructive(")
450
+ !confirmDestructive(
451
451
  `Stop #${issue}? This settles the run terminally, salvages its tree and frees the slot. It cannot be resumed.`,
452
452
  )
453
453
  ) {
@@ -474,7 +474,7 @@ function renderRunControls(issue) {
474
474
  );
475
475
  button("Unblock --force", "destructive", () => {
476
476
  if (
477
- __omp_shell("confirmDestructive(")
477
+ !confirmDestructive(
478
478
  `Force-unblock #${issue}? This accepts the loss of uncommitted work in its worktree, which may be the only copy.`,
479
479
  )
480
480
  ) {
package/src/doctor.ts CHANGED
@@ -55,7 +55,7 @@ import { ompSettingsOverlay, sessionRootDir } from "./omp-settings.ts";
55
55
  import {
56
56
  DEFAULT_HERDR_UNIT,
57
57
  probeTelegramHealth,
58
- resolveHerdrSessionWithBridge,
58
+ resolveHerdrSession,
59
59
  resolveTickConfig,
60
60
  sessionsRoot,
61
61
  sessionDirForCwd,
@@ -2003,7 +2003,7 @@ export function defaultProbes(): Probes {
2003
2003
  workerHarness: defaultWorkerHarness,
2004
2004
  pidLive: (pid) => pidAlive(pid),
2005
2005
  herdrAgents: defaultHerdrAgents,
2006
- herdrSession: () => resolveHerdrSessionWithBridge(),
2006
+ herdrSession: () => resolveHerdrSession(),
2007
2007
  herdrConfig: defaultHerdrConfig,
2008
2008
  herdrEnv: defaultHerdrEnv,
2009
2009
  claimedTopics: () => claimedTelegramTopics(),
package/src/fleet.ts CHANGED
@@ -105,10 +105,11 @@ export const PANE_HALT_FILE = ".conductor-pane-halted";
105
105
  export const DEFAULT_HERDR_UNIT = "herdr-fleet.service";
106
106
 
107
107
  /**
108
- * Default Herdr session name for conductor fleets. Renamed from `fleet` in
109
- * #320; `HERDR_SESSION` still wins when set. Legacy hosts that still run a
110
- * session named `fleet` are bridged for one release by
111
- * {@link resolveHerdrSession}.
108
+ * The Herdr session conductor fleets run in. `HERDR_SESSION` wins when set;
109
+ * this is the only other name any surface may resolve. #320 renamed it from
110
+ * `fleet` and shipped a one-release probe that preferred a populated legacy
111
+ * session; #976 removed that probe, because a name resolved two ways is a
112
+ * name two surfaces can disagree about — and they did, in `upgrade`.
112
113
  */
113
114
  export const DEFAULT_HERDR_SESSION = "conductor";
114
115
 
@@ -121,32 +122,13 @@ export const DEFAULT_HERDR_SESSION = "conductor";
121
122
  export const DEFAULT_FLEET_AGENT_NAME = "fleet";
122
123
  export const ARM_CHALLENGE_TIMEOUT_MS = 300_000;
123
124
 
124
-
125
- /** Pre-#320 session name. Bridged for one release when `conductor` is empty. */
126
- const LEGACY_HERDR_SESSION = "fleet";
127
-
128
- export const LEGACY_HERDR_SESSION_HINT =
129
- 'herdr session "fleet" found — rename it to "conductor" or run omp-conductor setup host to pin HERDR_SESSION=fleet (remove this bridge next minor)';
130
-
131
- let legacyHerdrSessionHintPrinted = false;
132
-
133
- function noteLegacyHerdrSession(log?: (message: string) => void): void {
134
- if (legacyHerdrSessionHintPrinted) return;
135
- legacyHerdrSessionHintPrinted = true;
136
- if (log) log(LEGACY_HERDR_SESSION_HINT);
137
- else console.warn(LEGACY_HERDR_SESSION_HINT);
138
- }
139
-
140
- /** Reset the once-per-process rename hint (tests). */
141
- export function resetLegacyHerdrSessionHintForTests(): void {
142
- legacyHerdrSessionHintPrinted = false;
143
- }
144
-
145
125
  /**
146
- * Resolve the Herdr session name. `HERDR_SESSION` is always authoritative.
147
- * When unset, default to {@link DEFAULT_HERDR_SESSION}. The one-release bridge
148
- * that prefers a populated legacy `fleet` session lives in
149
- * {@link resolveHerdrSessionWithBridge} call that at probe sites.
126
+ * Resolve the Herdr session name the single resolution every surface uses.
127
+ * `HERDR_SESSION` is authoritative; otherwise {@link DEFAULT_HERDR_SESSION}.
128
+ * Never probe for an alternative name here: a session that answers is not
129
+ * evidence of which session this fleet is, and the probe that assumed it was
130
+ * is what made `upgrade` install against `fleet` and verify against
131
+ * `conductor` (#976).
150
132
  */
151
133
  export function resolveHerdrSession(env: NodeJS.ProcessEnv = process.env): string {
152
134
  const explicit = env["HERDR_SESSION"];
@@ -154,56 +136,6 @@ export function resolveHerdrSession(env: NodeJS.ProcessEnv = process.env): strin
154
136
  return DEFAULT_HERDR_SESSION;
155
137
  }
156
138
 
157
- function herdrAgentListRaw(
158
- session: string,
159
- bin: string,
160
- env: NodeJS.ProcessEnv,
161
- ): { ok: true; agents: HerdrAgent[] } | { ok: false } {
162
- const res = spawnSync(bin, ["--session", session, "agent", "list"], {
163
- encoding: "utf8",
164
- timeout: 8_000,
165
- env,
166
- });
167
- if (res.error || res.status !== 0) return { ok: false };
168
- try {
169
- return { ok: true, agents: parseHerdrAgentList(res.stdout ?? "") };
170
- } catch {
171
- return { ok: false };
172
- }
173
- }
174
-
175
- /**
176
- * One-release bridge (#320): when `HERDR_SESSION` is unset, a `conductor`
177
- * session with no agents while a `fleet` session answers → use `fleet` and
178
- * print the rename hint once. Remove next minor.
179
- */
180
- export function resolveHerdrSessionWithBridge(opts: {
181
- env?: NodeJS.ProcessEnv;
182
- herdrBin?: string;
183
- log?: (message: string) => void;
184
- } = {}): string {
185
- const env = opts.env ?? process.env;
186
- const explicit = env["HERDR_SESSION"];
187
- if (explicit !== undefined && explicit.length > 0) return explicit;
188
-
189
- const bin = opts.herdrBin ?? "herdr";
190
- const primary = herdrAgentListRaw(DEFAULT_HERDR_SESSION, bin, env);
191
- if (primary.ok && primary.agents.length > 0) return DEFAULT_HERDR_SESSION;
192
-
193
- const legacy = herdrAgentListRaw(LEGACY_HERDR_SESSION, bin, env);
194
- if (legacy.ok && legacy.agents.length > 0) {
195
- noteLegacyHerdrSession(opts.log);
196
- return LEGACY_HERDR_SESSION;
197
- }
198
- // conductor answered (even empty) wins over a dead legacy session.
199
- if (primary.ok) return DEFAULT_HERDR_SESSION;
200
- if (legacy.ok) {
201
- noteLegacyHerdrSession(opts.log);
202
- return LEGACY_HERDR_SESSION;
203
- }
204
- return DEFAULT_HERDR_SESSION;
205
- }
206
-
207
139
  export function telegramStateDir(): string {
208
140
  const override = process.env["OMP_TELEGRAM_STATE_DIR"];
209
141
  if (override !== undefined && override.length > 0) return override;
@@ -1382,9 +1314,7 @@ export async function stopConductorPane(
1382
1314
 
1383
1315
  async function herdrAgentList(deps: PaneStopDeps): Promise<HerdrAgent[]> {
1384
1316
  const bin = deps.herdrBin ?? "herdr";
1385
- const session =
1386
- deps.herdrSession ??
1387
- resolveHerdrSessionWithBridge({ env: process.env, herdrBin: bin });
1317
+ const session = deps.herdrSession ?? resolveHerdrSession(process.env);
1388
1318
  const res = spawnSync(bin, ["--session", session, "agent", "list"], {
1389
1319
  encoding: "utf8",
1390
1320
  timeout: 8_000,
@@ -1485,9 +1415,7 @@ export function parseHerdrProcessInfo(stdout: string, paneId: string): ProcessIn
1485
1415
 
1486
1416
  async function herdrOmpForegroundPids(paneId: string, deps: PaneStopDeps): Promise<number[]> {
1487
1417
  const bin = deps.herdrBin ?? "herdr";
1488
- const session =
1489
- deps.herdrSession ??
1490
- resolveHerdrSessionWithBridge({ env: process.env, herdrBin: bin });
1418
+ const session = deps.herdrSession ?? resolveHerdrSession(process.env);
1491
1419
  const res = spawnSync(bin, ["--session", session, "pane", "process-info", "--pane", paneId], {
1492
1420
  encoding: "utf8",
1493
1421
  timeout: 8_000,
@@ -2539,7 +2467,7 @@ function probeOmpPane(
2539
2467
  }
2540
2468
  const agentName =
2541
2469
  tick.kind === "ok" ? (tick.config.agentName ?? DEFAULT_FLEET_AGENT_NAME) : DEFAULT_FLEET_AGENT_NAME;
2542
- const session = resolveHerdrSessionWithBridge({ env: process.env });
2470
+ const session = resolveHerdrSession(process.env);
2543
2471
  try {
2544
2472
  const res = spawnSync("herdr", ["--session", session, "agent", "list"], {
2545
2473
  encoding: "utf8",
package/src/setup-host.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  DEFAULT_FLEET_AGENT_NAME,
10
10
  DEFAULT_HERDR_SESSION,
11
11
  DEFAULT_HERDR_UNIT,
12
- resolveHerdrSessionWithBridge,
12
+ resolveHerdrSession,
13
13
  } from "./fleet.ts";
14
14
  import {
15
15
  WORKER_HARNESS_DIR,
@@ -612,9 +612,9 @@ export function canonicalBinSearchPath(home: string = homedir()): string {
612
612
 
613
613
  export function defaultServiceRuntime(
614
614
  telegramStateDir: string,
615
- // Bridge-resolved session name. Passed in so tests stay hermetic (no herdr
616
- // spawn) and the renderer never shells out — same threading as telegramStateDir.
617
- herdrSession: string = resolveHerdrSessionWithBridge(),
615
+ // The one resolved session name (#976). Passed in so tests stay hermetic and
616
+ // the renderer never shells out — same threading as telegramStateDir.
617
+ herdrSession: string = resolveHerdrSession(),
618
618
  // The canonical search path (#890), injectable for the same reason: a test
619
619
  // must be able to prove the render does not move when the CALLER's PATH does,
620
620
  // which means controlling what "installed here" means without touching the
package/src/upgrade.ts CHANGED
@@ -4,9 +4,7 @@ import { backupTimestamp, copyToUniqueBackup } from "./backups.ts";
4
4
  import { inspectBriefLayout, type BriefLayout } from "./brief-upgrade.ts";
5
5
  import { pauseInstance, setPaused, statusSnapshot, type AdmissionAckRecord } from "./daemon.ts";
6
6
  import {
7
- DEFAULT_HERDR_SESSION,
8
7
  fleetLayers,
9
- LEGACY_HERDR_SESSION_HINT,
10
8
  resolveHerdrSession,
11
9
  telegramStateDir,
12
10
  } from "./fleet.ts";
@@ -133,6 +131,14 @@ export interface UpgradeDeps {
133
131
  setPaused(value: boolean, project?: string): void;
134
132
  restartDaemon(): Promise<void>;
135
133
  sleep(ms: number): Promise<void>;
134
+ /**
135
+ * The environment every surface of this transaction resolves from — the
136
+ * herdr session included, through {@link resolveHerdrSession}. One source,
137
+ * because the `herdr plugin list` read and the pane probe MUST agree on
138
+ * which session this fleet is: when they did not, `upgrade` installed
139
+ * against one session and failed verification against another, then rolled
140
+ * a good release back (#976).
141
+ */
136
142
  env: NodeJS.ProcessEnv;
137
143
  log(message: string): void;
138
144
  /**
@@ -142,11 +148,6 @@ export interface UpgradeDeps {
142
148
  * session process actually loaded.
143
149
  */
144
150
  health(port: number): Promise<{ ok: boolean; body?: string }>;
145
- /**
146
- * The herdr session the fleet pane lives in, for the external-orchestrator
147
- * pane probe (#832).
148
- */
149
- herdrSession(): string;
150
151
  /**
151
152
  * Every omp process the fleet pane currently claims by herdr, resolved to
152
153
  * its start time — the evidence an external (pane-owned) orchestrator
@@ -208,7 +209,6 @@ export const DEFAULT_DEPS: UpgradeDeps = {
208
209
  await restartDaemon({});
209
210
  },
210
211
  health: async (port) => healthCheck(port),
211
- herdrSession: () => resolveHerdrSession(process.env),
212
212
  probePaneOmp: (session) => herdrPaneOmpStarts(runCommand, session, processStartTimeMs),
213
213
  // The bare host-global plan — the same render the advisory's `setup host`
214
214
  // command would install: no per-project tail, the recovery unit encoding no
@@ -430,32 +430,7 @@ export async function inspectSurfaces(
430
430
  if (version.length === 0) throw new Error("installed omp-conductor CLI has no version");
431
431
  return { cliVersion: version, ompVersion: ompPluginVersion(ompOnly.stdout) };
432
432
  }
433
- // HERDR_SESSION is authoritative. Otherwise prefer DEFAULT_HERDR_SESSION, with
434
- // #320's one-release bridge to a populated legacy "fleet" session.
435
- let session = resolveHerdrSession(deps.env);
436
- if (deps.env["HERDR_SESSION"] === undefined || deps.env["HERDR_SESSION"] === "") {
437
- // Prefer a session that answers plugin list; fall back to conductor.
438
- let conductorOk = false;
439
- let fleetOk = false;
440
- try {
441
- const r = await deps.run("herdr", ["--session", DEFAULT_HERDR_SESSION, "plugin", "list"]);
442
- conductorOk = r.code === 0;
443
- } catch {
444
- conductorOk = false;
445
- }
446
- try {
447
- const r = await deps.run("herdr", ["--session", "fleet", "plugin", "list"]);
448
- fleetOk = r.code === 0;
449
- } catch {
450
- fleetOk = false;
451
- }
452
- if (!conductorOk && fleetOk) {
453
- session = "fleet";
454
- deps.log(LEGACY_HERDR_SESSION_HINT);
455
- } else {
456
- session = DEFAULT_HERDR_SESSION;
457
- }
458
- }
433
+ const session = resolveHerdrSession(deps.env);
459
434
  const [cli, omp, herdr] = await Promise.all([
460
435
  mustRun(deps, "omp-conductor", ["--version"]),
461
436
  mustRun(deps, "omp", ["plugin", "list", "--json"]),
@@ -908,7 +883,7 @@ async function sessionVerifyProblem(
908
883
  // No daemon to attest: only the pane could have reloaded anything, so the
909
884
  // pane probe decides alone, exactly as the external leg of the detached
910
885
  // verifier does.
911
- const pane = await deps.probePaneOmp(deps.herdrSession());
886
+ const pane = await deps.probePaneOmp(resolveHerdrSession(deps.env));
912
887
  if ("problem" in pane) return `the fleet pane could not be probed: ${pane.problem}`;
913
888
  return paneRestartProblem(pane.starts, reloadAfterMs) ?? undefined;
914
889
  }
@@ -934,7 +909,7 @@ async function sessionVerifyProblem(
934
909
  if (names.length === 0) {
935
910
  return `the restarted daemon's /healthz on :${port} named no project — the live session cannot be attested`;
936
911
  }
937
- const pane = await deps.probePaneOmp(deps.herdrSession());
912
+ const pane = await deps.probePaneOmp(resolveHerdrSession(deps.env));
938
913
  for (const project of names) {
939
914
  const problem = sessionReloadProblem({
940
915
  facts: orchestratorFactsFromHealth(health.body, project),
@@ -1388,6 +1363,23 @@ export async function upgradeConductor(
1388
1363
  }
1389
1364
  if (initial.herdr === "unknown") throw new Error("cannot determine whether herdr-fleet.service is active");
1390
1365
 
1366
+ // The one verification input that can be read before anything moves (#976).
1367
+ // Verification probes the fleet pane in `resolveHerdrSession(deps.env)`; a
1368
+ // host whose session answers to another name only discovers that after three
1369
+ // installs and two restarts, and a good release is then rolled back. Read it
1370
+ // first: the same probe, at zero cost, as a refusal.
1371
+ if (!detached && (initial.dispatch !== "stopped" || initial.herdr === "active")) {
1372
+ const session = resolveHerdrSession(deps.env);
1373
+ const pane = await deps.probePaneOmp(session);
1374
+ if ("problem" in pane) {
1375
+ throw new Error(
1376
+ `the fleet pane in herdr session "${session}" cannot be probed, so this upgrade could not be ` +
1377
+ `verified: ${pane.problem}. Set HERDR_SESSION to the session that owns the fleet pane and ` +
1378
+ `re-run, or bring that session up first — nothing has been installed.`,
1379
+ );
1380
+ }
1381
+ }
1382
+
1391
1383
  if (detached) {
1392
1384
  // The durable record the returning process rolls back or verifies against:
1393
1385
  // what was installed, what dispatch state the fleet began in, which pause