omp-conductor 0.3.5 → 0.3.7

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.
@@ -32,9 +32,15 @@
32
32
  * `message` replaces both, and is re-read per tick for the same reason.
33
33
  *
34
34
  * The extension is inert unless `<cwd>/.conductor-tick.json` exists, so shipping
35
- * it inside `omp-conductor` costs an ordinary session nothing.
35
+ * it inside `omp-conductor` costs an ordinary session nothing. That file is a
36
+ * property of the *directory*, though, which is why arming is gated on one more
37
+ * question — {@link resolveTickOwnership}: is this session the orchestrator, or
38
+ * merely a session standing in its directory? Without it, a shell opened in the
39
+ * fleet's cwd armed a second heartbeat and, with merge and release delegated in
40
+ * config, believed it held both.
36
41
  */
37
42
 
43
+ import { spawnSync } from "node:child_process";
38
44
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
39
45
  import { isAbsolute, join, resolve } from "node:path";
40
46
  import { findProject, loadConfig } from "./config.ts";
@@ -53,6 +59,16 @@ export const TICK_CUSTOM_TYPE = "omp-conductor.tick";
53
59
  */
54
60
  export const MIN_INTERVAL_SECONDS = 60;
55
61
 
62
+ /**
63
+ * How often to re-ask who owns the fleet tick after herdr failed to answer.
64
+ *
65
+ * Retrying rather than latching is the whole point: a declined identity is
66
+ * permanent, an unanswered one is a blip. Cheap enough to run every minute — one
67
+ * short-lived `herdr agent list` — and it stops the moment the answer is
68
+ * definitive, either way.
69
+ */
70
+ const RETRY_OWNERSHIP_MS = 60_000;
71
+
56
72
  /**
57
73
  * The stall marker — written beside the activation file, in the session cwd —
58
74
  * and the number of consecutive coalesced ticks that earn it.
@@ -118,6 +134,15 @@ interface TickContext {
118
134
  };
119
135
  /** True while steering, follow-up or next-turn messages are still queued. */
120
136
  hasPendingMessages(): boolean;
137
+ /**
138
+ * The session's own transcript path, or `undefined` before anything is written
139
+ * to it. Typed as the SDK types it: `ExtensionContext.sessionManager` is a
140
+ * `ReadonlySessionManager`, whose `getSessionFile(): string | undefined` is at
141
+ * `@oh-my-pi/pi-coding-agent/src/session/session-manager.ts`. Read for one
142
+ * reason — a directory claim that names only a pid tells an operator which
143
+ * process holds the tick but not which session it is.
144
+ */
145
+ sessionManager: { getSessionFile(): string | undefined };
121
146
  /**
122
147
  * Managed timer: throws inside `callback` are contained and surfaced on the
123
148
  * extension error channel, the handle is `unref`'d, and it is cleared on
@@ -168,6 +193,14 @@ export interface TickConfig {
168
193
  armedFile?: string;
169
194
  accessFile?: string;
170
195
  message?: string;
196
+ /**
197
+ * The herdr agent name this fleet's orchestrator pane is registered under, and
198
+ * the whole of {@link resolveTickOwnership}'s identity test under herdr.
199
+ * Omitted means {@link DEFAULT_FLEET_AGENT_NAME}, which is
200
+ * `herdr/bin/recover.sh`'s own `AGENT_NAME=${AGENT_NAME:-fleet}` default — the
201
+ * recovery half and the ticking half key on one identity or neither is safe.
202
+ */
203
+ agentName?: string;
171
204
  }
172
205
 
173
206
  /**
@@ -332,6 +365,16 @@ export function readTickConfig(cwd: string): TickConfigResult {
332
365
  }
333
366
  }
334
367
 
368
+ const agentRaw = raw["agentName"];
369
+ let agentName: string | undefined;
370
+ if (agentRaw !== undefined) {
371
+ if (typeof agentRaw !== "string" || agentRaw.trim().length === 0) {
372
+ problems.push("agentName must be a non-empty string when present");
373
+ } else {
374
+ agentName = agentRaw.trim();
375
+ }
376
+ }
377
+
335
378
  if (problems.length > 0) return { kind: "invalid", path, problem: problems.join("; ") };
336
379
 
337
380
  return {
@@ -342,10 +385,333 @@ export function readTickConfig(cwd: string): TickConfigResult {
342
385
  ...(armedFile === undefined ? {} : { armedFile }),
343
386
  ...(accessFile === undefined ? {} : { accessFile }),
344
387
  ...(message === undefined ? {} : { message }),
388
+ ...(agentName === undefined ? {} : { agentName }),
345
389
  },
346
390
  };
347
391
  }
348
392
 
393
+ /**
394
+ * The claim file the non-herdr path uses to make "who is the orchestrator here"
395
+ * answerable from disk. A sibling of the activation file, and dot-prefixed like
396
+ * the rest of that family.
397
+ */
398
+ export const TICK_OWNER_FILE = ".conductor-tick-owner.json";
399
+
400
+ /**
401
+ * The agent name a fleet is registered under when its config names none —
402
+ * `herdr/bin/recover.sh`'s own default (`AGENT_NAME=${AGENT_NAME:-fleet}`).
403
+ */
404
+ export const DEFAULT_FLEET_AGENT_NAME = "fleet";
405
+
406
+ /** A herdr query that hangs would hang session startup, so it is bounded. */
407
+ const HERDR_QUERY_TIMEOUT_MS = 3000;
408
+
409
+ /**
410
+ * One entry of `herdr agent list`, narrowed to the two fields that decide
411
+ * identity.
412
+ *
413
+ * `name` is the *registered agent name* — what `herdr agent start fleet --pane`
414
+ * sets, what `recover.sh` keys every identity decision on, and what an ad-hoc
415
+ * shell in the same directory does not have. herdr's `agent` field is
416
+ * deliberately not read: it is the *runtime*, it says `"omp"` for the real
417
+ * orchestrator and for a pane somebody opened to look at state, and reading it
418
+ * is what made this problem look unfixable.
419
+ */
420
+ export interface HerdrPaneAgent {
421
+ paneId: string;
422
+ /** Absent on a pane herdr has no registered name for. */
423
+ name?: string;
424
+ }
425
+
426
+ /** Either the list, or why there is none — never an exception: a herdr that does
427
+ * not answer is a fact to log, not a session that fails to start. */
428
+ export type HerdrAgentList =
429
+ | { kind: "ok"; agents: HerdrPaneAgent[] }
430
+ | { kind: "unavailable"; problem: string };
431
+
432
+ /**
433
+ * Whether this session may tick in this directory.
434
+ *
435
+ * `declined` carries the whole sentence to log, because the point of the check is
436
+ * that an operator can tell which session is driving the fleet — a decline that
437
+ * does not name the holder answers the question no better than silence did.
438
+ */
439
+ export type TickOwnership =
440
+ | { kind: "owner"; note?: string }
441
+ /** Proven not to be the fleet's session. Permanent until this session ends. */
442
+ | { kind: "declined"; reason: string }
443
+ /**
444
+ * Could not be determined — herdr did not answer. Deliberately NOT `declined`:
445
+ * both refuse to tick, but only this one is worth retrying, and conflating
446
+ * them means a single 3-second CLI timeout silently disables the real
447
+ * orchestrator until someone restarts the pane. An unproven identity still
448
+ * must not tick; a heartbeat that stopped for a transient blip and never came
449
+ * back is the exact silent stall this package keeps having to fix.
450
+ */
451
+ | { kind: "unresolved"; reason: string };
452
+
453
+ /**
454
+ * `herdr agent list`, over the socket herdr injected into this pane's
455
+ * environment.
456
+ *
457
+ * Shelled out rather than spoken over the socket directly: the wire protocol is
458
+ * not a published interface, while the CLI's "one JSON line on stdout" is — it is
459
+ * what `herdr/bin/recover.sh` already parses, envelope and all. `HERDR_BIN_PATH`
460
+ * is honoured for the same reason that script honours it, so both halves of this
461
+ * repo have one spelling of "how do we reach herdr".
462
+ *
463
+ * Synchronous on purpose: it feeds a decision taken inside `session_start`, and a
464
+ * heartbeat that armed first and checked afterwards would tick from the wrong
465
+ * session for the length of the window between.
466
+ */
467
+ function readHerdrAgents(env: Record<string, string | undefined>): HerdrAgentList {
468
+ const bin = env["HERDR_BIN_PATH"] ?? "herdr";
469
+ let stdout: string;
470
+ try {
471
+ const run = spawnSync(bin, ["agent", "list"], {
472
+ encoding: "utf8",
473
+ timeout: HERDR_QUERY_TIMEOUT_MS,
474
+ stdio: ["ignore", "pipe", "pipe"],
475
+ });
476
+ if (run.error !== undefined) return { kind: "unavailable", problem: run.error.message };
477
+ if (run.status !== 0) {
478
+ // A signal rather than an exit code when herdr was killed mid-answer; both
479
+ // mean the same thing here, and both belong in the log line an operator
480
+ // reads to find out why the heartbeat stopped.
481
+ const how = run.signal === null || run.signal === undefined ? `exited ${String(run.status)}` : `died on ${run.signal}`;
482
+ const detail = (run.stderr ?? "").trim().split("\n")[0] ?? "";
483
+ return { kind: "unavailable", problem: `\`${bin} agent list\` ${how}${detail.length === 0 ? "" : `: ${detail}`}` };
484
+ }
485
+ stdout = run.stdout ?? "";
486
+ } catch (err) {
487
+ return { kind: "unavailable", problem: err instanceof Error ? err.message : String(err) };
488
+ }
489
+
490
+ return parseHerdrAgents(stdout);
491
+ }
492
+
493
+ /**
494
+ * The CLI's single JSON line, as `recover.sh` reads it: the payload is either the
495
+ * envelope's `result` or the object itself, and `agents` is inside it. Anything
496
+ * else is reported rather than guessed at — an agent list that cannot be parsed
497
+ * proves nothing about which pane owns the tick.
498
+ */
499
+ export function parseHerdrAgents(stdout: string): HerdrAgentList {
500
+ let parsed: unknown;
501
+ try {
502
+ parsed = JSON.parse(stdout);
503
+ } catch (err) {
504
+ return { kind: "unavailable", problem: `agent list was not JSON (${err instanceof Error ? err.message : String(err)})` };
505
+ }
506
+ if (parsed === null || typeof parsed !== "object") return { kind: "unavailable", problem: "agent list was not an object" };
507
+
508
+ const envelope = parsed as { readonly [key: string]: unknown };
509
+ const inner = envelope["result"];
510
+ const payload = (inner !== null && typeof inner === "object" ? inner : envelope) as {
511
+ readonly [key: string]: unknown;
512
+ };
513
+ const rows = payload["agents"];
514
+ if (!Array.isArray(rows)) return { kind: "unavailable", problem: "agent list carried no agents array" };
515
+
516
+ const agents: HerdrPaneAgent[] = [];
517
+ for (const row of rows) {
518
+ if (row === null || typeof row !== "object") continue;
519
+ const entry = row as { readonly [key: string]: unknown };
520
+ const paneId = entry["pane_id"];
521
+ if (typeof paneId !== "string" || paneId.length === 0) continue;
522
+ const name = entry["name"];
523
+ // `null` is what herdr reports for an unnamed pane, and it means the same
524
+ // thing as the key being absent: this pane is not a registered agent.
525
+ agents.push({ paneId, ...(typeof name === "string" && name.trim().length > 0 ? { name: name.trim() } : {}) });
526
+ }
527
+ return { kind: "ok", agents };
528
+ }
529
+
530
+ /**
531
+ * The herdr half of the decision, with no subprocess in it.
532
+ *
533
+ * Fleet-ness is the *session*, shared by every pane in it, so `HERDR_SESSION` and
534
+ * the cwd cannot distinguish the orchestrator from a shell opened beside it. The
535
+ * pane's registered name can, and it survives a detection gap: herdr counts a
536
+ * pane as an agent terminal on a saved name alone, so the fleet pane is listed
537
+ * with its name even in the moment before its runtime is re-detected.
538
+ */
539
+ export function paneOwnership(input: { paneId: string; agentName: string; agents: HerdrPaneAgent[] }): TickOwnership {
540
+ const mine = input.agents.find((a) => a.paneId === input.paneId);
541
+ if (mine?.name === input.agentName) return { kind: "owner" };
542
+
543
+ if (mine?.name !== undefined) {
544
+ // A registered agent, just not this fleet's. herdr can name several omp
545
+ // agents in one directory, and requiring merely *a* name would arm each one.
546
+ return {
547
+ kind: "declined",
548
+ reason: `this pane is agent "${mine.name}", not the fleet agent "${input.agentName}" — this session will not tick`,
549
+ };
550
+ }
551
+
552
+ const holder = input.agents.find((a) => a.name === input.agentName);
553
+ if (holder !== undefined) {
554
+ return {
555
+ kind: "declined",
556
+ reason: `pane ${holder.paneId} (agent "${input.agentName}") owns the fleet tick here — this session will not tick`,
557
+ };
558
+ }
559
+
560
+ // No name here and nobody else holding it: an ad-hoc pane in the fleet's
561
+ // directory, which is exactly the session that must stay inert. Fail-closed,
562
+ // and the fix is named — an orchestrator that lost its registration is one
563
+ // `herdr agent start` from ticking again.
564
+ return {
565
+ kind: "declined",
566
+ reason:
567
+ `this pane is not a registered herdr agent, and no pane is running the fleet agent ` +
568
+ `"${input.agentName}" — register it with \`herdr agent start ${input.agentName} --kind omp --pane ${input.paneId}\`; ` +
569
+ `this session will not tick`,
570
+ };
571
+ }
572
+
573
+ /** The claim on disk. `sessionFile` is for the human reading it — the decision
574
+ * itself only ever trusts `pid`. */
575
+ interface TickOwnerRecord {
576
+ pid: number;
577
+ sessionFile?: string;
578
+ claimedAt: string;
579
+ }
580
+
581
+ /** Whether a pid is running. `EPERM` means it exists and is not ours, which is
582
+ * still alive; only `ESRCH` proves it is gone. */
583
+ function pidIsAlive(pid: number): boolean {
584
+ if (!Number.isInteger(pid) || pid <= 0) return false;
585
+ try {
586
+ process.kill(pid, 0);
587
+ return true;
588
+ } catch (err) {
589
+ return err !== null && typeof err === "object" && "code" in err && err.code === "EPERM";
590
+ }
591
+ }
592
+
593
+ /**
594
+ * The no-herdr half: claim the directory, and tick only while this process is the
595
+ * live claimant.
596
+ *
597
+ * Liveness is a pid check and never a timestamp. A crashed orchestrator leaves
598
+ * its claim behind, and a claim that expired on age alone would either wedge the
599
+ * fleet until someone deleted a file, or hand ownership to a second session while
600
+ * the first was merely slow.
601
+ */
602
+ export function claimTickOwner(input: {
603
+ cwd: string;
604
+ pid: number;
605
+ sessionFile?: string;
606
+ now: Date;
607
+ alive?: (pid: number) => boolean;
608
+ }): TickOwnership {
609
+ const alive = input.alive ?? pidIsAlive;
610
+ const path = join(input.cwd, TICK_OWNER_FILE);
611
+
612
+ const held = readOwnerRecord(path);
613
+ if (held !== undefined && held.pid !== input.pid && alive(held.pid)) {
614
+ return {
615
+ kind: "declined",
616
+ reason:
617
+ `pid ${held.pid} (claimed ${held.claimedAt}${held.sessionFile === undefined ? "" : `, session ${held.sessionFile}`}) ` +
618
+ `owns the fleet tick in ${input.cwd} — this session will not tick`,
619
+ };
620
+ }
621
+
622
+ const record: TickOwnerRecord = {
623
+ pid: input.pid,
624
+ ...(input.sessionFile === undefined ? {} : { sessionFile: input.sessionFile }),
625
+ claimedAt: input.now.toISOString(),
626
+ };
627
+ try {
628
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
629
+ } catch (err) {
630
+ // An unwritable claim leaves this session no worse off than it was before the
631
+ // file existed, and refusing to tick over it would silence a fleet that has
632
+ // no rival at all. Said out loud, because the guard is now only advisory.
633
+ return {
634
+ kind: "owner",
635
+ note: `could not write ${path} (${err instanceof Error ? err.message : String(err)}) — ticking anyway, but a second session here would not be detected`,
636
+ };
637
+ }
638
+ return { kind: "owner" };
639
+ }
640
+
641
+ /** A claim that is missing, unreadable or not shaped like one is no claim: the
642
+ * caller then takes ownership, which is also how a corrupt file heals. */
643
+ function readOwnerRecord(path: string): TickOwnerRecord | undefined {
644
+ if (!existsSync(path)) return undefined;
645
+ try {
646
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
647
+ if (parsed === null || typeof parsed !== "object") return undefined;
648
+ const raw = parsed as { readonly [key: string]: unknown };
649
+ const pid = raw["pid"];
650
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return undefined;
651
+ const sessionFile = raw["sessionFile"];
652
+ const claimedAt = raw["claimedAt"];
653
+ return {
654
+ pid,
655
+ ...(typeof sessionFile === "string" && sessionFile.length > 0 ? { sessionFile } : {}),
656
+ claimedAt: typeof claimedAt === "string" && claimedAt.length > 0 ? claimedAt : "unknown",
657
+ };
658
+ } catch {
659
+ return undefined;
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Who owns the tick in this directory: the whole answer, both paths.
665
+ *
666
+ * Activation is the presence of `.conductor-tick.json` in the session cwd, which
667
+ * is a property of the *directory* — so every omp session started there became a
668
+ * ticker, and with `authority.merge`/`authority.release` delegated, a shell
669
+ * somebody opened to read state believed it could merge and release. The
670
+ * directory cannot identify a session, so this asks something that can: under
671
+ * herdr, the pane's registered agent name; otherwise, a claim on the directory
672
+ * that is only honoured while its claimant is alive.
673
+ *
674
+ * Every collaborator is injectable so the two paths can be tested without a
675
+ * running herdr and without spawning processes to kill.
676
+ */
677
+ export function resolveTickOwnership(input: {
678
+ cwd: string;
679
+ agentName: string;
680
+ env: Record<string, string | undefined>;
681
+ pid: number;
682
+ now: Date;
683
+ sessionFile?: string;
684
+ listAgents?: (env: Record<string, string | undefined>) => HerdrAgentList;
685
+ alive?: (pid: number) => boolean;
686
+ }): TickOwnership {
687
+ const paneId = input.env["HERDR_PANE_ID"] ?? "";
688
+ if (input.env["HERDR_ENV"] === "1" && paneId.length > 0) {
689
+ const list = (input.listAgents ?? readHerdrAgents)(input.env);
690
+ if (list.kind === "ok") return paneOwnership({ paneId, agentName: input.agentName, agents: list.agents });
691
+
692
+ // Fail closed, but not forever. Under herdr this session is one pane of
693
+ // possibly several in the fleet's directory, and an unproven identity is the
694
+ // case this whole check exists for — so it does not tick. It is `unresolved`
695
+ // rather than `declined` because herdr not answering says nothing about who
696
+ // this pane is: the caller retries, and the moment herdr answers the real
697
+ // orchestrator arms. Latching here would mean one CLI timeout stops the
698
+ // fleet until a human notices and restarts the pane.
699
+ return {
700
+ kind: "unresolved",
701
+ reason:
702
+ `cannot yet prove this pane is the fleet agent "${input.agentName}" — ${list.problem} — not ticking until it can`,
703
+ };
704
+ }
705
+
706
+ return claimTickOwner({
707
+ cwd: input.cwd,
708
+ pid: input.pid,
709
+ ...(input.sessionFile === undefined ? {} : { sessionFile: input.sessionFile }),
710
+ now: input.now,
711
+ ...(input.alive === undefined ? {} : { alive: input.alive }),
712
+ });
713
+ }
714
+
349
715
  /**
350
716
  * Whether this tick sends, and why — the whole decision, with no clock, no
351
717
  * filesystem and no session in it. The interesting part of a heartbeat is the
@@ -538,8 +904,10 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
538
904
 
539
905
  export default function orchestratorTickExtension(pi: TickApi): void {
540
906
  // Scoped to this registration rather than the module, so a second
541
- // `session_start` cannot install a second heartbeat on the same session.
542
- let armed = false;
907
+ // `session_start` can neither install a second heartbeat on the same session
908
+ // nor repeat the ownership decline — which is logged exactly once, because it
909
+ // is the line that tells an operator which session is driving the fleet.
910
+ let decided = false;
543
911
  // Held per registration for the same reason: the "using the default reporting
544
912
  // scope, because ..." line is logged once for this heartbeat, and the stall
545
913
  // counter is about this session's own queue. A second session in the same
@@ -547,7 +915,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
547
915
  const session: TickSession = { scopeFallbackLogged: false, pendingSkips: 0 };
548
916
 
549
917
  pi.on("session_start", (_event, ctx) => {
550
- if (armed) return;
918
+ if (decided) return;
551
919
 
552
920
  // A subagent inherits the orchestrator's cwd, so it finds the same
553
921
  // activation file and would arm a heartbeat of its own — one extra tick
@@ -580,8 +948,68 @@ export default function orchestratorTickExtension(pi: TickApi): void {
580
948
  }
581
949
 
582
950
  const config = result.config;
951
+
952
+ // Activation is a property of the directory, so every omp session started in
953
+ // the fleet's cwd used to become a ticker — and with merge and release
954
+ // delegated in config, a shell opened beside the orchestrator believed it
955
+ // held both. Asked after the config read because the config names the agent,
956
+ // and before the timer because arming first is the bug.
957
+ const agentName = config.agentName ?? DEFAULT_FLEET_AGENT_NAME;
958
+ const resolve = (): TickOwnership =>
959
+ resolveTickOwnership({
960
+ cwd: ctx.cwd,
961
+ agentName,
962
+ env: process.env,
963
+ pid: process.pid,
964
+ now: new Date(),
965
+ ...(ctx.sessionManager.getSessionFile() === undefined
966
+ ? {}
967
+ : { sessionFile: ctx.sessionManager.getSessionFile() }),
968
+ });
969
+
970
+ const ownership = resolve();
971
+
972
+ // Only a definitive answer is final. "You are not the fleet agent" cannot
973
+ // become untrue while this session lives, so it latches. "herdr did not
974
+ // answer" says nothing about identity, so it must not — otherwise one CLI
975
+ // timeout at session start is indistinguishable from a fleet that was never
976
+ // meant to tick, and the heartbeat is gone until a human notices.
977
+ if (ownership.kind === "unresolved") {
978
+ pi.logger.info(`[omp-conductor] orchestrator tick pending: ${ownership.reason}`, { agentName });
979
+ // Faster than the tick interval so a blip costs a minute rather than a
980
+ // whole cycle, and never slower than one — a fleet on a short interval
981
+ // should not wait longer to recover than it would to tick.
982
+ const retryMs = Math.min(RETRY_OWNERSHIP_MS, config.intervalSeconds * 1000);
983
+ // Disarmed by a flag, not `clearInterval`: `ctx.setInterval` hands back an
984
+ // opaque handle precisely because the harness owns timer lifecycle and
985
+ // clears them on `session_shutdown`. A settled retry is a no-op that costs
986
+ // one boolean per minute until the session ends.
987
+ let settled = false;
988
+ ctx.setInterval(() => {
989
+ if (settled) return;
990
+ const next = resolve();
991
+ if (next.kind === "unresolved") return; // already logged once; stay quiet
992
+ settled = true;
993
+ if (next.kind === "declined") {
994
+ pi.logger.info(`[omp-conductor] orchestrator tick inactive: ${next.reason}`, { agentName });
995
+ return;
996
+ }
997
+ if (next.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${next.note}`);
998
+ ctx.setInterval(() => tick(pi, ctx, config, session), config.intervalSeconds * 1000);
999
+ pi.logger.info(`[omp-conductor] orchestrator tick active: ownership resolved on retry`, { agentName });
1000
+ }, retryMs);
1001
+ decided = true;
1002
+ return;
1003
+ }
1004
+
1005
+ if (ownership.kind === "declined") {
1006
+ decided = true;
1007
+ pi.logger.info(`[omp-conductor] orchestrator tick inactive: ${ownership.reason}`, { agentName });
1008
+ return;
1009
+ }
1010
+ if (ownership.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${ownership.note}`);
583
1011
  ctx.setInterval(() => tick(pi, ctx, config, session), config.intervalSeconds * 1000);
584
- armed = true;
1012
+ decided = true;
585
1013
  // Both gates are named at startup: "why is it not ticking?" is answered by
586
1014
  // looking at the files this line lists, and an unset channel gate on a fleet
587
1015
  // host is visible here rather than only in its absence.