omp-conductor 0.3.5 → 0.3.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/README.md +112 -6
- package/package.json +1 -1
- package/src/briefs/orchestrator.md +18 -2
- package/src/orchestrator-tick.ts +433 -5
- package/src/plugin.ts +285 -98
- package/src/setup.ts +283 -2
package/src/orchestrator-tick.ts
CHANGED
|
@@ -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`
|
|
542
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|