omp-conductor 0.15.11 → 0.15.13

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.
Files changed (51) hide show
  1. package/REFERENCE.md +107 -60
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +3 -0
  4. package/src/briefs/orchestrator.md +64 -11
  5. package/src/briefs/policy.md +19 -3
  6. package/src/briefs/worker.md +11 -8
  7. package/src/cli.ts +41 -21
  8. package/src/commands/context.ts +102 -1
  9. package/src/commands/doctor.ts +4 -2
  10. package/src/commands/intake.ts +26 -5
  11. package/src/commands/message.ts +80 -32
  12. package/src/commands/report.ts +38 -2
  13. package/src/commands/restart.ts +81 -54
  14. package/src/commands/setup.ts +61 -11
  15. package/src/commands/stop.ts +45 -22
  16. package/src/commands/upgrade-rollback.ts +9 -0
  17. package/src/config-schema.ts +9 -0
  18. package/src/config.ts +35 -1
  19. package/src/daemon.ts +588 -37
  20. package/src/dashboard/app.js +398 -59
  21. package/src/dashboard/index.html +27 -0
  22. package/src/dashboard/server.ts +219 -5
  23. package/src/dashboard/style.css +169 -1
  24. package/src/doctor.ts +419 -45
  25. package/src/escalate.ts +8 -0
  26. package/src/failure-class.ts +37 -0
  27. package/src/fleet.ts +49 -2
  28. package/src/gitops.ts +157 -0
  29. package/src/lifecycle.ts +113 -2
  30. package/src/model-fallback.ts +177 -0
  31. package/src/omp.ts +115 -13
  32. package/src/orchestrator-down.ts +231 -0
  33. package/src/orchestrator-tick.ts +108 -5
  34. package/src/orchestrator.ts +18 -4
  35. package/src/privileged.ts +10 -0
  36. package/src/release-policy.ts +373 -28
  37. package/src/session-host.ts +11 -5
  38. package/src/setup-host.ts +665 -70
  39. package/src/setup-install.ts +275 -28
  40. package/src/setup-wizard.ts +339 -126
  41. package/src/setup.ts +25 -0
  42. package/src/stop-provenance.ts +66 -0
  43. package/src/store.ts +194 -1
  44. package/src/tracker/github.ts +47 -0
  45. package/src/types.ts +182 -0
  46. package/src/upgrade.ts +110 -32
  47. package/src/verbs/protocol.ts +16 -3
  48. package/src/verbs/server.ts +27 -1
  49. package/src/wizard-ui.ts +261 -46
  50. package/src/worker.ts +24 -3
  51. package/systemd/omp-conductor.service.example +7 -3
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The model-failover chain (#286): which model the next attempt of one run
3
+ * chain dispatches on, and how the choice is worded so a merged branch built
4
+ * on a different model stays attributable months later.
5
+ *
6
+ * Pure and synchronous: the daemon reads the run rows from the store, hands
7
+ * them here, and does what the returned choice says. Nothing in this module
8
+ * reaches a store or a provider — the same split as `failure-class.ts`, kept
9
+ * here (and NOT there) because `failure-class.ts` owns the *verdict* while
10
+ * this owns the *response to a run of provider verdicts*.
11
+ *
12
+ * The trigger is deliberately counted from the store, never from the
13
+ * harness's `modelFallbackMessage`: that message means the harness already
14
+ * downgraded internally and is evidence about a run, not a decision; keying
15
+ * on it would fire the feature when nothing needed switching and stay silent
16
+ * when a provider returns 402 cleanly.
17
+ */
18
+
19
+ import type { FailureClass, RunRecord } from "./types.ts";
20
+
21
+ /**
22
+ * Failure classes that move a chain onto its next model — and ONLY these: a
23
+ * cap kill means the slice was too big, and switching models in response
24
+ * would hide a decomposition problem behind a provider one (#286, #490).
25
+ */
26
+ export const FAILOVER_CLASSES: readonly FailureClass[] = [
27
+ "provider-transient",
28
+ "provider-credit",
29
+ ];
30
+
31
+ /** The default for a project's `modelFallbackThreshold` when it is absent or
32
+ * unusable. Two consecutive provider-class failures, then the next attempt
33
+ * moves to the next model. */
34
+ export const DEFAULT_MODEL_FALLBACK_THRESHOLD = 2;
35
+
36
+ /** True for the classes {@link FAILOVER_CLASSES} names, so a non-provider
37
+ * failure can never push a chain sideways. */
38
+ export function isProviderFailureClass(cls: string | undefined): cls is FailureClass {
39
+ return cls !== undefined && (FAILOVER_CLASSES as readonly string[]).includes(cls);
40
+ }
41
+
42
+ /** Consecutive provider-class failures at the head of one run chain. */
43
+ export interface ProviderFailureFacts {
44
+ /** How many terminal runs at the head of the chain failed as a provider. */
45
+ streak: number;
46
+ /** Those runs' classes, newest first — the set is either one class or a mix
47
+ * of `provider-transient` and `provider-credit`. */
48
+ classes: FailureClass[];
49
+ /**
50
+ * The model the most recent failure dispatched on, when its row recorded
51
+ * one. Undefined when the rows predate the column or the project has no
52
+ * chain — the log then falls back to the configured primary, or to "harness
53
+ * default" for a project with no `workerModel`.
54
+ */
55
+ previousModel: string | undefined;
56
+ }
57
+
58
+ /**
59
+ * Counts the consecutive provider-class failures at the head of a run chain.
60
+ * `runs` is in store order (oldest first, per `Store.runsForIssue`); the walk
61
+ * starts at the newest row and stops at the first row that is not a
62
+ * provider-class failure — a success, a non-provider failure, an unclassified
63
+ * row, a live row — so "sticky per chain, never global" falls out: a fresh
64
+ * issue has no rows and starts on the primary, and one issue's bad luck can
65
+ * never move another issue's chain.
66
+ */
67
+ export function providerFailureFacts(runs: readonly RunRecord[]): ProviderFailureFacts {
68
+ const facts: ProviderFailureFacts = { streak: 0, classes: [], previousModel: undefined };
69
+ for (let i = runs.length - 1; i >= 0; i--) {
70
+ const run = runs[i]!;
71
+ if (!isProviderFailureClass(run.failureClass)) break;
72
+ facts.streak += 1;
73
+ facts.classes.push(run.failureClass);
74
+ if (facts.previousModel === undefined && run.model !== undefined) {
75
+ facts.previousModel = run.model;
76
+ }
77
+ }
78
+ return facts;
79
+ }
80
+
81
+ /** The model the next attempt should dispatch on, and whether that is a
82
+ * failover at all. */
83
+ export interface ModelChoice {
84
+ /** Model to dispatch on; `undefined` leaves the harness default, exactly as
85
+ * an unconfigured project dispatches today. */
86
+ model: string | undefined;
87
+ /** True when the choice came from the fallback chain — the failover has
88
+ * fired and the tick report must say so. */
89
+ fallback: boolean;
90
+ }
91
+
92
+ /**
93
+ * Where one run chain's next attempt dispatches, given the chain configuration
94
+ * and the store-derived failure streak.
95
+ *
96
+ * With no chain (empty or absent `modelFallbacks`) the primary model is
97
+ * returned unchanged and `fallback` stays false — today's dispatch byte for
98
+ * byte. Once the streak reaches the threshold the chain advances one slot per
99
+ * extra failure, clamped to the last model: the chain is exhausted there, and
100
+ * the existing escalation path (the provider-transient strike cap) settles it,
101
+ * naming every model tried.
102
+ */
103
+ export function resolveDispatchModel(args: {
104
+ workerModel: string | undefined;
105
+ modelFallbacks: readonly string[] | undefined;
106
+ threshold: number;
107
+ streak: number;
108
+ }): ModelChoice {
109
+ const { workerModel, modelFallbacks, threshold, streak } = args;
110
+ if (modelFallbacks === undefined || modelFallbacks.length === 0) {
111
+ return { model: workerModel, fallback: false };
112
+ }
113
+ if (streak < threshold) return { model: workerModel, fallback: false };
114
+ const index = Math.min(streak - threshold, modelFallbacks.length - 1);
115
+ return { model: modelFallbacks[index] ?? workerModel, fallback: true };
116
+ }
117
+
118
+ /**
119
+ * The clause the tick report annexes to a failover dispatch:
120
+ * `on <fallback> after N provider-transient failures on <previous>`, the shape
121
+ * the issue asked for (`#123 attempt 3 on <fallback> after 2
122
+ * provider-transient failures on <primary>`). `undefined` when no failover
123
+ * fired, so the ordinary dispatch log line is untouched.
124
+ */
125
+ export function fallbackClause(
126
+ choice: ModelChoice,
127
+ facts: ProviderFailureFacts,
128
+ workerModel: string | undefined,
129
+ ): string | undefined {
130
+ if (!choice.fallback) return undefined;
131
+ const classWord = new Set(facts.classes).size === 1 ? facts.classes[0]! : "provider";
132
+ const countWord = facts.streak === 1 ? "failure" : "failures";
133
+ const previous = facts.previousModel ?? workerModel ?? "harness default";
134
+ return `on ${choice.model} after ${facts.streak} ${classWord} ${countWord} on ${previous}`;
135
+ }
136
+
137
+ /** One recorded model dispatch in a chain: which model, on which attempt. */
138
+ export interface ModelTried {
139
+ model: string;
140
+ attempt: number;
141
+ }
142
+
143
+ /** Every recorded model of a chain, in chain order — what the exhaustion
144
+ * escalation names when it says "every model tried". Rows without a recorded
145
+ * model (no chain configured, or written before the column existed) are
146
+ * absent: an unnamed primary is the harness default, which the escalation
147
+ * wording covers without pretending to know its name. */
148
+ export function modelsTried(runs: readonly RunRecord[]): ModelTried[] {
149
+ const tried: ModelTried[] = [];
150
+ for (const run of runs) {
151
+ if (run.model === undefined) continue;
152
+ tried.push({ model: run.model, attempt: run.attempt });
153
+ }
154
+ return tried;
155
+ }
156
+
157
+ /**
158
+ * `"acme/a (attempts 1-2), acme/b (attempt 3)"` — consecutive attempts
159
+ * grouped under their model, in chain order.
160
+ */
161
+ export function formatModelsTried(tried: readonly ModelTried[]): string {
162
+ const groups: { model: string; attempts: number[] }[] = [];
163
+ for (const { model, attempt } of tried) {
164
+ const last = groups[groups.length - 1];
165
+ if (last !== undefined && last.model === model) last.attempts.push(attempt);
166
+ else groups.push({ model, attempts: [attempt] });
167
+ }
168
+ return groups
169
+ .map(({ model, attempts }) => {
170
+ const span =
171
+ attempts.length === 1
172
+ ? `attempt ${attempts[0]}`
173
+ : `attempts ${attempts[0]}-${attempts[attempts.length - 1]}`;
174
+ return `${model} (${span})`;
175
+ })
176
+ .join(", ");
177
+ }
package/src/omp.ts CHANGED
@@ -19,14 +19,15 @@ import { tmpdir } from "node:os";
19
19
  import { dirname, join } from "node:path";
20
20
 
21
21
  import { readOnlySession, worktreeConfinement } from "./confinement.ts";
22
- import { releasePolicyTripwire, type ReleaseBlockContext } from "./release-policy.ts";
22
+ import { releasePolicyTripwire, type GateShape, type ReleaseBlockContext } from "./release-policy.ts";
23
23
  import type {
24
24
  HostToParent,
25
25
  ParentToHost,
26
26
  SessionHostSpec,
27
27
  } from "./session-host.ts";
28
28
  import { decodeFrames, encodeFrame } from "./session-host.ts";
29
- import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
29
+ import type { ResolvedGrants, SessionRole } from "./types.ts";
30
+ import { SESSION_ROLE_ENV } from "./types.ts";
30
31
  import { conductorVerbs } from "./verbs/client.ts";
31
32
 
32
33
  const OMP_PACKAGE = "@oh-my-pi/pi-coding-agent";
@@ -43,6 +44,14 @@ export interface AgentSessionLike {
43
44
  * `"*"` for every event. The payload is the harness's own event union, which
44
45
  * this package cannot name without the peer dependency, so it arrives as
45
46
  * `unknown` and each caller narrows the two or three fields it reads.
47
+ *
48
+ * One event is not the harness's at all: `"session_exit"` fires when the
49
+ * underlying session *process* has terminated, carrying
50
+ * `{ type: "session_exit", code?: number | null }`. It is the single real
51
+ * terminal signal — a crashed or killed session is never confused with one
52
+ * that merely stopped streaming (`"agent_end"`). A supervisor may receive it
53
+ * after its own `dispose()`, and must decide what an exit means from its own
54
+ * state rather than assuming every exit is a crash.
46
55
  */
47
56
  on(event: string, cb: (e: unknown) => void): void;
48
57
  abort(): void;
@@ -158,7 +167,7 @@ export async function createLocalSession(opts: {
158
167
  /** Install the release/deploy tool-call gate with these per-shape grants. */
159
168
  releaseGrants?: ResolvedGrants;
160
169
  /** Durable audit callback invoked only when that gate rejects a call. */
161
- onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
170
+ onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
162
171
  /**
163
172
  * The conductor verb socket this session's mutation tools call (#126).
164
173
  *
@@ -414,7 +423,7 @@ export interface CreateSessionOptions {
414
423
  resume?: boolean;
415
424
  role: SessionRole;
416
425
  releaseGrants?: ResolvedGrants;
417
- onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
426
+ onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
418
427
 
419
428
  /**
420
429
  * Where the control socket is bound. The daemon puts it beside the run's own
@@ -559,6 +568,11 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
559
568
  const argv = [process.execPath, opts.hostModule ?? SESSION_HOST, JSON.stringify(spec)];
560
569
  const child = Bun.spawn(argv, {
561
570
  cwd: opts.cwd,
571
+ // Stamp the session's own role on the child, so a process the agent runs —
572
+ // `omp-conductor report` from its sandbox — can tell a worker session from
573
+ // the operator's shell. Direct CLI runs outside a spawned session inherit
574
+ // nothing and stay the orchestrator surface.
575
+ env: opts.role === undefined ? undefined : { ...process.env, [SESSION_ROLE_ENV]: opts.role },
562
576
  stdin: "ignore",
563
577
  // The harness writes progress to stdout; both streams are the daemon's log,
564
578
  // never the protocol. The protocol has its own socket precisely so a chatty
@@ -617,6 +631,67 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
617
631
  return stderrTail.trim();
618
632
  };
619
633
 
634
+ /**
635
+ * The child's own start failure, if it got one out over the socket before it
636
+ * died — the socket counterpart of the pipe drain.
637
+ *
638
+ * A child that cannot start can connect and write `start-error` in the same
639
+ * event-loop turn its exit is dispatched in: the connect already completed
640
+ * in the kernel, the accept is still queued, and closing the listener in
641
+ * that window drops the frame with the reason. So every exit route waits
642
+ * this out BEFORE {@link cleanup} closes the server, then prefers the frame
643
+ * over a bare exit code. Bounded like the pipes: a dead child that never
644
+ * connected has nothing on the wire, and 2s only ever delays an
645
+ * already-failed startup.
646
+ *
647
+ * Memoized: the pre-connect catch and the exit handler both arrive here, and
648
+ * both must read the SAME socket — two independent readers would race on the
649
+ * first `destroy()` and one of them would lose the frame.
650
+ */
651
+ let startErrorReading: Promise<string | undefined> | undefined;
652
+ const startErrorFromSocket = (): Promise<string | undefined> => {
653
+ startErrorReading ??= (async (): Promise<string | undefined> => {
654
+ const socket = await Promise.race([
655
+ attached.catch(() => undefined),
656
+ new Promise<undefined>((resolve) => {
657
+ setTimeout(() => resolve(undefined), DRAIN_GRACE_MS).unref?.();
658
+ }),
659
+ ]);
660
+ if (socket === undefined) return undefined;
661
+ try {
662
+ return await new Promise<string | undefined>((resolve) => {
663
+ let buffer = "";
664
+ let settled = false;
665
+ const finish = (message: string | undefined): void => {
666
+ if (settled) return;
667
+ settled = true;
668
+ clearTimeout(timer);
669
+ resolve(message);
670
+ };
671
+ const timer = setTimeout(() => finish(undefined), DRAIN_GRACE_MS);
672
+ timer.unref?.();
673
+ socket.on("data", (chunk: Buffer) => {
674
+ buffer += chunk.toString("utf8");
675
+ const { frames, rest } = decodeFrames(buffer);
676
+ buffer = rest;
677
+ for (const frame of frames) {
678
+ const message = frame as { t?: unknown; message?: unknown };
679
+ if (message.t === "start-error" && typeof message.message === "string") {
680
+ finish(message.message);
681
+ return;
682
+ }
683
+ }
684
+ });
685
+ socket.once("error", () => finish(undefined));
686
+ socket.once("close", () => finish(undefined));
687
+ });
688
+ } finally {
689
+ socket.destroy();
690
+ }
691
+ })();
692
+ return startErrorReading;
693
+ };
694
+
620
695
  const cleanup = (): void => {
621
696
  server.close();
622
697
  rmSync(socketPath, { force: true });
@@ -647,17 +722,39 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
647
722
  pending.clear();
648
723
  };
649
724
 
725
+ // The one terminal event this proxy owns outright: the session process is
726
+ // gone, whether by crash, kill, or a clean dispose of its own. Delivered to
727
+ // subscribers *before* any failure is surfaced, so a supervisor learning of
728
+ // the death can also read its exit code off the same event. `disposing` is
729
+ // the proxy's own intent (its disposer asked the child to stop), not the
730
+ // subscriber's, so the event is emitted in both branches and each observer
731
+ // decides what an exit means from its own state.
732
+ const emitSessionExit = (code: number | null): void => {
733
+ const type = "session_exit";
734
+ const event = { type, code };
735
+ for (const cb of handlers.get(type) ?? []) cb(event);
736
+ for (const cb of handlers.get("*") ?? []) cb(event);
737
+ };
738
+
650
739
  void child.exited.then(async (code) => {
651
740
  onExit();
741
+ const exitCode = typeof code === "number" ? code : null;
742
+ emitSessionExit(exitCode);
743
+ // A child that cannot start writes `start-error` over the socket, not the
744
+ // pipes — and its exit can be dispatched before the accept of a connection
745
+ // that already completed in the kernel. Let the socket settle before the
746
+ // listener closes, so the child's own words survive the ordering.
747
+ if (disposing) {
748
+ cleanup();
749
+ return;
750
+ }
751
+ const startError = await startErrorFromSocket();
652
752
  cleanup();
653
- // A child that exits during teardown exited because we asked it to. Only an
654
- // exit while the session is supposed to be live is a failure — and it has
655
- // to reach whoever is awaiting a prompt, or the dispatcher waits forever
656
- // for a turn from a process that is gone.
657
- if (disposing) return;
658
753
  // The tail first, so the message carries the child's own words.
659
754
  await settledTail();
660
- fail(`omp-conductor session child exited ${String(code)} before the session ended`);
755
+ fail(
756
+ startError ?? `omp-conductor session child exited ${String(code)} before the session ended`,
757
+ );
661
758
  });
662
759
 
663
760
  let socket: Socket;
@@ -681,15 +778,20 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
681
778
  ]);
682
779
  } catch (err) {
683
780
  child.kill("SIGKILL");
781
+ // The child's exit can win the race above against the accept of a
782
+ // connection that completed in the kernel — then its `start-error` frame
783
+ // is still queued, and closing the listener now would throw it away.
784
+ // Read the socket first (bounded, like the pipes), and prefer its words
785
+ // over a bare exit code.
786
+ const startError = await startErrorFromSocket();
684
787
  cleanup();
685
788
  // Killed first, so the pipes are already closing, and only then read. This is
686
789
  // the route a child that dies before connecting takes — a missing peer
687
790
  // dependency, say — and reading `stderrTail` synchronously here raced the
688
791
  // drain loops and reported a bare exit code instead of the reason.
689
792
  const tail = await settledTail();
690
- throw new Error(
691
- `${err instanceof Error ? err.message : String(err)}${tail === "" ? "" : `\nchild output:\n${tail}`}`,
692
- );
793
+ const reason = startError ?? (err instanceof Error ? err.message : String(err));
794
+ throw new Error(`${reason}${tail === "" ? "" : `\nchild output:\n${tail}`}`);
693
795
  }
694
796
 
695
797
  let buffer = "";
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The orchestrator-down incident: how the daemon learns — and tells its
3
+ * operator — that the embedded orchestrator session is gone.
4
+ *
5
+ * A failed orchestrator used to degrade quietly: the daemon logged a warning
6
+ * and kept running, but the tier-1 escalations that then fell back to issue
7
+ * comments were exactly the "nobody reads it until morning" path the
8
+ * orchestrator exists to avoid. Nothing paged, and `status` never said the
9
+ * operator channel was degraded (the pending issue is #288).
10
+ *
11
+ * This module makes three facts durable and observable:
12
+ *
13
+ * 1. **The incident.** Start failure (no handle) and unexpected session death
14
+ * (a handle whose `alive()` is false) each open one durable
15
+ * `orchestrator-incident` row, re-derived across restarts so a daemon
16
+ * restarted while still down rediscovers it. The row survives and is
17
+ * removed only on recovery.
18
+ * 2. **One page per incident.** Open, and every subsequent down tick, page a
19
+ * tier-2 urgent escalation whose key is anchored on the incident's `since`
20
+ * timestamp — the notification ledger pages once, never per tick, and a
21
+ * *new* incident (a flapping orchestrator recovering then dying again) gets
22
+ * a new anchor and pages again.
23
+ * 3. **The degrade row.** While down, `status` names the mode, the since-
24
+ * moment and how many tier-1 escalations were diverted to issue comments.
25
+ * Recovery closes the row and pages one closing notice on the same anchor,
26
+ * with downtime and the diverted count.
27
+ *
28
+ * The session liveness driving this is event-driven (the proxy's `session_exit`
29
+ * terminal event), never a timer: a wedged-but-alive session stays "alive"
30
+ * here and belongs to the `.conductor-stalled` watchdog, which this module
31
+ * deliberately does not touch.
32
+ */
33
+
34
+ import type { OrchestratorHandle } from "./orchestrator.ts";
35
+ import type {
36
+ Escalation,
37
+ OrchestratorDownMode,
38
+ OrchestratorIncident,
39
+ ProjectConfig,
40
+ Store,
41
+ } from "./types.ts";
42
+
43
+ /** Fleet-scoped pages carry issue `0` — there is no tracker issue for this. */
44
+ const NO_ISSUE = 0;
45
+
46
+ export interface OrchestratorDownDeps {
47
+ project: ProjectConfig;
48
+ store: Store;
49
+ /** The embedded session handle; `undefined` when it failed to start. */
50
+ orchestrator?: OrchestratorHandle;
51
+ /**
52
+ * The daemon's escalation transport. Errors are caught here (the call is
53
+ * best-effort and ledger-deduped), so a transport that is itself down never
54
+ * takes the tick down with it.
55
+ */
56
+ escalate(e: Escalation): Promise<void>;
57
+ /**
58
+ * Start-failure cause, only meaningful when a new incident is opened with no
59
+ * handle. Carried so the page and `status` tell the operator *why*, not just
60
+ * that the orchestrator is gone.
61
+ */
62
+ startCause?: string;
63
+ now?(): number;
64
+ log?(msg: string): void;
65
+ }
66
+
67
+ function iso(at: number): string {
68
+ return new Date(at).toISOString();
69
+ }
70
+
71
+ /** Compact, human-scale duration for downtime and ages. */
72
+ export function formatDownDuration(ms: number): string {
73
+ if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1_000))}s`;
74
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
75
+ if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
76
+ return `${Math.round(ms / 86_400_000)}d`;
77
+ }
78
+
79
+ function warn(msg: string): void {
80
+ process.stderr.write(`[conductor ${new Date().toISOString()}] ${msg}\n`);
81
+ }
82
+
83
+ function errText(e: unknown): string {
84
+ return e instanceof Error ? e.message : String(e);
85
+ }
86
+
87
+ /**
88
+ * Best-effort page, mirroring the daemon's `safeEscalate`: a transport failure
89
+ * is logged, never thrown. The recipient (the daemon's escalator) owns the
90
+ * notification-ledger dedupe, which is what makes "page once per incident" a
91
+ * property of the store rather than of this loop.
92
+ */
93
+ async function deliver(deps: OrchestratorDownDeps, e: Escalation): Promise<boolean> {
94
+ try {
95
+ await deps.escalate(e);
96
+ return true;
97
+ } catch (err) {
98
+ (deps.log ?? warn)(`orchestrator-down escalation could not be delivered: ${errText(err)}`);
99
+ return false;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The down page. Keyed on the incident's `since` so a still-open incident never
105
+ * pages again (the ledger dedupes on the summary), while each *new* incident —
106
+ * a fresh `since` — pages once.
107
+ */
108
+ export function downEscalation(project: string, incident: OrchestratorIncident): Escalation {
109
+ return {
110
+ tier: 2,
111
+ category: "confirmed-failure",
112
+ urgent: true,
113
+ project,
114
+ issue: NO_ISSUE,
115
+ summary:
116
+ `Orchestrator down since ${iso(incident.since)} — tier-1 escalations diverting to issue comments (${project})`,
117
+ detail: [
118
+ `Mode: ${incident.mode}.`,
119
+ ...(incident.cause === undefined ? [] : [`Cause: ${incident.cause}.`]),
120
+ "",
121
+ "Tier-1 escalations still land — as issue comments, the path this orchestrator",
122
+ "exists to avoid. Dispatch, settlement and release are unaffected; the operator",
123
+ "channel is degraded. Recovery sends one closing notice on this same incident.",
124
+ ].join("\n"),
125
+ };
126
+ }
127
+
128
+ /** The closing notice, referencing the same incident anchor. */
129
+ export function recoveryEscalation(
130
+ project: string,
131
+ closed: OrchestratorIncident,
132
+ now: number,
133
+ ): Escalation {
134
+ const downtime = formatDownDuration(Math.max(0, now - closed.since));
135
+ return {
136
+ tier: 2,
137
+ category: "confirmed-failure",
138
+ urgent: true,
139
+ project,
140
+ issue: NO_ISSUE,
141
+ summary:
142
+ `Orchestrator recovered for ${project} ` +
143
+ `(down since ${iso(closed.since)}) — tier-1 escalations resume as injected prompts`,
144
+ detail: [
145
+ `Mode: ${closed.mode}. Downtime: ${downtime}.`,
146
+ `Tier-1 escalations diverted to issue comments while down: ${closed.diverted}.`,
147
+ "",
148
+ "The orchestrator is back and ticking. The .conductor-stalled wedge watchdog is",
149
+ "unchanged and still covers a session that stops draining its queue.",
150
+ ].join("\n"),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Reconcile the embedded orchestrator's liveness against the durable incident.
156
+ * Idempotent, so it is safe to call at startup (after the escalator exists) and
157
+ * on every tick: opening, paging, divert-counting and recovering all converge
158
+ * on the store.
159
+ *
160
+ * External orchestrators return early — their down state is a human-owned pane,
161
+ * not conductor's to page, and the stall watchdog still covers their wedge.
162
+ */
163
+ export async function reconcileOrchestratorDown(deps: OrchestratorDownDeps): Promise<void> {
164
+ const { project, store } = deps;
165
+ const now = deps.now ?? Date.now;
166
+ const log = deps.log ?? warn;
167
+ if (project.escalation.orchestrator === "external") return;
168
+
169
+ const handle = deps.orchestrator;
170
+ const up = handle !== undefined && handle.alive();
171
+ const existing = store.orchestratorIncident(project.name);
172
+
173
+ if (up) {
174
+ if (existing === undefined) return;
175
+ // A session is (re)started and alive: close the incident from before and
176
+ // page one closing notice on the same anchor.
177
+ const closed = store.closeOrchestratorIncident(project.name, now());
178
+ if (closed === undefined) return; // raced with another daemon
179
+ const delivered = await deliver(deps, recoveryEscalation(project.name, closed, now()));
180
+ log(
181
+ `orchestrator recovered — closed incident from ${iso(closed.since)} ` +
182
+ `(${closed.diverted} diverted)${delivered ? "" : " (page unsent)"}`,
183
+ );
184
+ return;
185
+ }
186
+
187
+ // Down. Open once (the store's INSERT OR IGNORE is the dedupe), then page —
188
+ // the page itself is ledger-deduped so later ticks re-derive the world
189
+ // without paging again.
190
+ if (existing === undefined) {
191
+ const mode: OrchestratorDownMode = handle === undefined ? "start-failed" : "crashed";
192
+ if (
193
+ !store.openOrchestratorIncident({
194
+ project: project.name,
195
+ mode,
196
+ ...(mode === "start-failed"
197
+ ? deps.startCause === undefined
198
+ ? {}
199
+ : { cause: deps.startCause }
200
+ : { cause: "session died after a healthy start" }),
201
+ since: now(),
202
+ })
203
+ ) {
204
+ return; // raced with another daemon opening it
205
+ }
206
+ }
207
+ const incident = store.orchestratorIncident(project.name);
208
+ if (incident === undefined) return;
209
+ await deliver(deps, downEscalation(project.name, incident));
210
+ log(
211
+ `ERROR: orchestrator ${incident.mode} down since ${iso(incident.since)} — ` +
212
+ `tier-1 escalations diverting to issue comments`,
213
+ );
214
+ }
215
+
216
+ /**
217
+ * The first-class degrade row for `status`: mode, since-when, and how many
218
+ * tier-1 escalations have been diverted to issue comments. Absent entirely
219
+ * (empty array) when the orchestrator is healthy, so recovery drops the row.
220
+ */
221
+ export function formatOrchestratorDown(row: OrchestratorIncident, now = Date.now()): string[] {
222
+ return [
223
+ `orchestrator DEGRADED ${row.mode} since ${iso(row.since)} ` +
224
+ `(${formatDownDuration(Math.max(0, now - row.since))} ago)`,
225
+ ` diverting tier-1 escalations to issue comments` +
226
+ (row.diverted === 0
227
+ ? " (none yet)"
228
+ : ` — ${row.diverted} escalated issue${row.diverted === 1 ? "" : "s"} diverted`),
229
+ ...(row.cause === undefined ? [] : [` cause ${row.cause}`]),
230
+ ];
231
+ }