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.
- package/REFERENCE.md +107 -60
- package/package.json +1 -1
- package/schema/config.schema.json +3 -0
- package/src/briefs/orchestrator.md +64 -11
- package/src/briefs/policy.md +19 -3
- package/src/briefs/worker.md +11 -8
- package/src/cli.ts +41 -21
- package/src/commands/context.ts +102 -1
- package/src/commands/doctor.ts +4 -2
- package/src/commands/intake.ts +26 -5
- package/src/commands/message.ts +80 -32
- package/src/commands/report.ts +38 -2
- package/src/commands/restart.ts +81 -54
- package/src/commands/setup.ts +61 -11
- package/src/commands/stop.ts +45 -22
- package/src/commands/upgrade-rollback.ts +9 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +35 -1
- package/src/daemon.ts +588 -37
- package/src/dashboard/app.js +398 -59
- package/src/dashboard/index.html +27 -0
- package/src/dashboard/server.ts +219 -5
- package/src/dashboard/style.css +169 -1
- package/src/doctor.ts +419 -45
- package/src/escalate.ts +8 -0
- package/src/failure-class.ts +37 -0
- package/src/fleet.ts +49 -2
- package/src/gitops.ts +157 -0
- package/src/lifecycle.ts +113 -2
- package/src/model-fallback.ts +177 -0
- package/src/omp.ts +115 -13
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +108 -5
- package/src/orchestrator.ts +18 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +373 -28
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +665 -70
- package/src/setup-install.ts +275 -28
- package/src/setup-wizard.ts +339 -126
- package/src/setup.ts +25 -0
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +194 -1
- package/src/tracker/github.ts +47 -0
- package/src/types.ts +182 -0
- package/src/upgrade.ts +110 -32
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +27 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +24 -3
- 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 {
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
-
|
|
691
|
-
|
|
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
|
+
}
|