omp-conductor 0.2.0

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.
@@ -0,0 +1,267 @@
1
+ /**
2
+ * The orchestrator: one long-lived session that receives tier-1 escalations as
3
+ * injected prompts.
4
+ *
5
+ * Without it, a tier-1 escalation dead-ends in a GitHub comment nobody reads
6
+ * until morning — which is the same as a blocked worker staying blocked. With
7
+ * it, the escalation lands in a session that can re-brief the worker, file the
8
+ * follow-up issue, or decide the problem really does need a human.
9
+ *
10
+ * Three properties are load-bearing:
11
+ *
12
+ * 1. **Persistent.** The session is file-backed and *resumed*, so restarting
13
+ * the daemon does not erase the orchestrator's memory of what it has
14
+ * already escalated, re-briefed and given up on.
15
+ * 2. **Non-blocking.** `deliver()` resolves when the harness has *accepted* the
16
+ * prompt, never when the model has answered it. The dispatcher tick that
17
+ * produced the escalation must not sit behind a model for ten minutes. The
18
+ * answer is not thrown away, though: the returned {@link DeliveryReceipt}
19
+ * carries it, so a turn that fails ten minutes later is still attributable
20
+ * to the one escalation that caused it — never to the next one.
21
+ * 3. **Serialised.** Two escalations noticed in the same tick become two
22
+ * prompts, in order — never two concurrent `prompt()` calls racing over
23
+ * which of them is the follow-up to a busy session.
24
+ *
25
+ * It does not edit product code. That is a worker's job, and every injection
26
+ * says so out loud, because the session has no other context to infer it from.
27
+ */
28
+
29
+ import { mkdirSync } from "node:fs";
30
+ import { join } from "node:path";
31
+
32
+ import { stateDir } from "./config.ts";
33
+ import { formatEscalation } from "./escalate.ts";
34
+ import { createSession, disposeSession } from "./omp.ts";
35
+ import type { AgentSessionLike } from "./omp.ts";
36
+ import type { Escalation } from "./types.ts";
37
+
38
+ /**
39
+ * The session factory {@link startOrchestrator} uses. Named so the test seam
40
+ * below has a type to satisfy without reaching into the harness.
41
+ */
42
+ export type CreateSessionFn = (opts: {
43
+ cwd: string;
44
+ sessionDir?: string;
45
+ model?: string;
46
+ resume?: boolean;
47
+ }) => Promise<AgentSessionLike>;
48
+
49
+ /**
50
+ * What a caller gets once an injection has been *accepted*.
51
+ *
52
+ * The split exists because acceptance and delivery are minutes apart. Marking
53
+ * an escalation handled on acceptance is what silently drops it when the turn
54
+ * then fails: the dedup key says "notified", no human was told, and nothing
55
+ * ever retries. `settled` is the other half of that promise, kept per
56
+ * injection so a failure is attributed to the escalation it belongs to.
57
+ */
58
+ export interface DeliveryReceipt {
59
+ /** Rejects if the orchestrator's turn for THIS injection failed. */
60
+ settled: Promise<void>;
61
+ }
62
+
63
+ export interface OrchestratorHandle {
64
+ /**
65
+ * Inject an escalation as a prompt. Resolves once accepted, not once
66
+ * answered; rejects only when the injection was never taken at all. Watch
67
+ * the receipt's `settled` for the turn's own outcome.
68
+ */
69
+ deliver(e: Escalation, project: string): Promise<DeliveryReceipt>;
70
+ busy(): boolean;
71
+ sessionFile(): string | undefined;
72
+ dispose(): Promise<void>;
73
+ }
74
+
75
+ export interface OrchestratorOpts {
76
+ cwd: string;
77
+ sessionDir?: string;
78
+ model?: string;
79
+ /**
80
+ * Standing orders — which repo, which labels, what the fleet is. Prepended to
81
+ * the *first* injection rather than sent as its own prompt on startup: a
82
+ * daemon that boots, is briefed and escalates nothing has then paid for a
83
+ * model turn that did no work. The brief is not lost, it just waits for
84
+ * something to attach itself to.
85
+ */
86
+ brief?: string;
87
+ /**
88
+ * Test seam, and only that. Production always wants the real
89
+ * {@link createSession}; the unit suite substitutes a hand-written
90
+ * {@link AgentSessionLike} so it can drive the event stream and read back
91
+ * every `prompt()` call without the harness or the network.
92
+ */
93
+ createSessionImpl?: CreateSessionFn;
94
+ }
95
+
96
+ /**
97
+ * `streamingBehavior` for the next prompt.
98
+ *
99
+ * A busy session rejects a bare `prompt()` — that is work thrown away, and the
100
+ * escalation with it. `"followUp"` queues the injection behind the turn in
101
+ * flight, which is what an escalation arriving mid-thought should do: the
102
+ * orchestrator finishes its current reasoning, then reads the new one. `"steer"`
103
+ * would interrupt it, and interrupting the session that is already handling the
104
+ * previous escalation is how two escalations become one confused answer.
105
+ */
106
+ export function nextStreamingBehavior(busy: boolean): "followUp" | undefined {
107
+ return busy ? "followUp" : undefined;
108
+ }
109
+
110
+ /**
111
+ * The injected prompt.
112
+ *
113
+ * Self-contained by necessity: the orchestrator holds none of the dispatcher's
114
+ * state, so an injection that says only "issue #4211 is blocked" is unactionable.
115
+ * It carries the same body a human would have received ({@link formatEscalation},
116
+ * so the two never disagree) plus an explicit statement of what this session is
117
+ * expected to *do* about it.
118
+ */
119
+ export function formatInjection(e: Escalation, project: string): string {
120
+ const expectation =
121
+ e.tier === 1
122
+ ? [
123
+ "What is expected of you (tier 1 — yours to resolve; no human has been paged):",
124
+ ` 1. Read issue #${e.issue} and the run's transcript before deciding anything.`,
125
+ " 2. Then choose exactly one:",
126
+ ` (a) RE-BRIEF — say concretely what to change in the worker's brief for issue #${e.issue}:`,
127
+ " what to do differently, what to leave alone, which gate to satisfy first. Then re-queue it.",
128
+ " (b) PROMOTE TO TIER 2 — only when this needs a decision, a credential or an approval",
129
+ " a human owns. Say why re-briefing cannot work.",
130
+ " 3. Do not edit product code yourself, and do not push or merge. You re-brief workers,",
131
+ " file and comment on issues, and page the human. A worker session does the editing.",
132
+ ]
133
+ : [
134
+ "What is expected of you (tier 2 — the human is being paged directly):",
135
+ " Leave the issue in a state they can pick up: record what happened and what you already tried.",
136
+ " Do not edit product code yourself, and do not act on the fleet until they answer.",
137
+ ];
138
+
139
+ return [
140
+ formatEscalation(e, project),
141
+ "",
142
+ `You are the omp-conductor orchestrator for project "${project}". The dispatcher`,
143
+ "could not resolve the above on its own and has handed it to you.",
144
+ "",
145
+ ...expectation,
146
+ ].join("\n");
147
+ }
148
+
149
+ export async function startOrchestrator(o: OrchestratorOpts): Promise<OrchestratorHandle> {
150
+ const sessionDir = o.sessionDir ?? join(stateDir(), "orchestrator");
151
+ // Its own directory, separate from the workers': `resume: true` continues the
152
+ // most recent transcript *for this directory*, so sharing one with two dozen
153
+ // worker sessions would resume whichever worker last wrote. Created here
154
+ // because a missing parent is a session-startup failure, not a harness bug.
155
+ mkdirSync(sessionDir, { recursive: true });
156
+
157
+ const create = o.createSessionImpl ?? createSession;
158
+ const session = await create({
159
+ cwd: o.cwd,
160
+ sessionDir,
161
+ ...(o.model === undefined ? {} : { model: o.model }),
162
+ // The whole point of a persistent orchestrator: a daemon restart must not
163
+ // reset what it knows it has already escalated, or the first tick after a
164
+ // deploy re-litigates every parked issue from scratch.
165
+ resume: true,
166
+ });
167
+
168
+ // Busy tracked from the harness's own events, never guessed from our prompt
169
+ // calls: an injection can trigger a turn that outlives the call, and the
170
+ // session also turns on its own (resumed maintenance, async delivery).
171
+ let streaming = false;
172
+ for (const started of ["turn_start", "agent_start"]) {
173
+ session.on(started, () => {
174
+ streaming = true;
175
+ });
176
+ }
177
+ session.on("agent_end", (event) => {
178
+ // The event union lives in the peer dependency, so read the one field this
179
+ // needs off the raw payload. `isTerminal: false` means the harness will
180
+ // resume this session: it is still working, and reading that as idle makes
181
+ // the very next injection a bare `prompt()` against a streaming session —
182
+ // thrown-away work. Absent is terminal, for harnesses that never set it.
183
+ const isTerminal =
184
+ event !== null && typeof event === "object" ? Reflect.get(event, "isTerminal") : undefined;
185
+ if (isTerminal === false) return;
186
+ streaming = false;
187
+ });
188
+
189
+ let disposed = false;
190
+ /** Tail of the delivery queue. Always settled-or-settling, never rejected. */
191
+ let queue: Promise<void> = Promise.resolve();
192
+ /** Consumed by the first injection; see {@link OrchestratorOpts.brief}. */
193
+ let standingOrders = o.brief;
194
+
195
+ /**
196
+ * Issue one prompt. Runs inside the queue, so the streaming flag is read at
197
+ * the moment the prompt is actually handed over rather than when it was
198
+ * queued — the escalation ahead of it in the queue may have started a turn.
199
+ *
200
+ * Throws only when the prompt cannot be *accepted*. Everything after that
201
+ * travels back on the receipt, attached to this escalation and no other.
202
+ */
203
+ const issue = (e: Escalation, project: string): DeliveryReceipt => {
204
+ if (disposed) {
205
+ throw new Error(
206
+ `orchestrator session is disposed; tier ${e.tier} escalation on issue #${e.issue} was not injected`,
207
+ );
208
+ }
209
+
210
+ const injection = formatInjection(e, project);
211
+ const text = standingOrders === undefined ? injection : `${standingOrders}\n\n${injection}`;
212
+ const behavior = nextStreamingBehavior(streaming);
213
+ const opts = behavior === undefined ? {} : { streamingBehavior: behavior };
214
+
215
+ // Accepted, not answered. `prompt()` resolves when the turn ends, which for
216
+ // a re-brief is minutes; the tick that found this escalation has other
217
+ // issues to service. A synchronous throw is the only failure the caller can
218
+ // see before `deliver()` returns — the later one is the receipt's job.
219
+ const settled = session.prompt(text, opts).then(() => {});
220
+ // Only reached once the harness took the prompt, so the brief has landed.
221
+ standingOrders = undefined;
222
+ // A caller that never reads `settled` must not take the daemon down with an
223
+ // unhandled rejection. This handler does not consume the rejection: the
224
+ // caller still sees the real one through the receipt.
225
+ settled.catch(() => {});
226
+ return { settled };
227
+ };
228
+
229
+ return {
230
+ deliver(e: Escalation, project: string): Promise<DeliveryReceipt> {
231
+ // Never throws synchronously, by construction: `issue()` runs inside the
232
+ // chain below, so a transport problem always arrives as a rejected
233
+ // promise the caller can catch and route to its own fallback. The caller
234
+ // decides what an unreachable orchestrator means; this module does not.
235
+ const accepted = queue.then(() => issue(e, project));
236
+ // The queue advances on *acceptance*, never on `settled`: parking the
237
+ // next injection behind a whole model turn is the blocking dispatcher
238
+ // this module exists to avoid. It also outlives a rejected delivery —
239
+ // one unreachable injection must not poison the escalations behind it.
240
+ queue = accepted.then(
241
+ () => {},
242
+ () => {},
243
+ );
244
+ return accepted;
245
+ },
246
+ busy: () => streaming,
247
+ // The path the harness actually opened, read live: the transcript is how a
248
+ // human audits what the orchestrator decided on their behalf.
249
+ sessionFile: () => session.sessionFile,
250
+ async dispose(): Promise<void> {
251
+ if (disposed) return;
252
+ disposed = true;
253
+ // Anything already queued still runs — it either got its prompt in, or it
254
+ // rejects on the `disposed` check above and its caller falls back. Both
255
+ // beat dropping it silently during shutdown.
256
+ await queue;
257
+ // Tolerates a session the harness never gave us a disposer for (a
258
+ // hand-written fake, an older harness build with no `dispose()`): a
259
+ // teardown crash here would take the daemon's shutdown path with it.
260
+ try {
261
+ await disposeSession(session);
262
+ } catch {
263
+ // Teardown noise must not fail a shutdown that is otherwise clean.
264
+ }
265
+ },
266
+ };
267
+ }