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.
- package/LICENSE +21 -0
- package/README.md +732 -0
- package/package.json +40 -0
- package/skills/conductor-onboarding/SKILL.md +626 -0
- package/src/briefs/orchestrator.md +213 -0
- package/src/briefs/worker.md +146 -0
- package/src/cli.ts +179 -0
- package/src/config.ts +446 -0
- package/src/daemon.ts +689 -0
- package/src/escalate.ts +265 -0
- package/src/lifecycle.ts +367 -0
- package/src/omp.ts +273 -0
- package/src/orchestrator-tick.ts +432 -0
- package/src/orchestrator.ts +267 -0
- package/src/plugin.ts +605 -0
- package/src/routing.ts +160 -0
- package/src/setup.ts +644 -0
- package/src/store.ts +263 -0
- package/src/tracker/github.ts +160 -0
- package/src/types.ts +250 -0
- package/src/worker.ts +292 -0
- package/src/worktree.ts +303 -0
|
@@ -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
|
+
}
|