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
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dispatcher loop.
|
|
3
|
+
*
|
|
4
|
+
* One tick turns the tracker's ready queue into running omp sessions. Every
|
|
5
|
+
* limit that decides whether work starts is read from the store, never asked of
|
|
6
|
+
* the model: a worker told to respect a budget will eventually talk itself out
|
|
7
|
+
* of it, so concurrency, daily volume, spend and per-issue attempts are counted
|
|
8
|
+
* here and enforced before anything is claimed.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { configPath, findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
|
|
13
|
+
import { createEscalator } from "./escalate.ts";
|
|
14
|
+
import { startOrchestrator } from "./orchestrator.ts";
|
|
15
|
+
import type { OrchestratorHandle } from "./orchestrator.ts";
|
|
16
|
+
import { branchName, route } from "./routing.ts";
|
|
17
|
+
import type { Routed, UnroutableReason } from "./routing.ts";
|
|
18
|
+
import { openStore } from "./store.ts";
|
|
19
|
+
import { makeTracker } from "./tracker/github.ts";
|
|
20
|
+
import type {
|
|
21
|
+
Caps,
|
|
22
|
+
Escalation,
|
|
23
|
+
ProjectConfig,
|
|
24
|
+
ReadyIssue,
|
|
25
|
+
RepoTarget,
|
|
26
|
+
RunRecord,
|
|
27
|
+
Store,
|
|
28
|
+
Tracker,
|
|
29
|
+
} from "./types.ts";
|
|
30
|
+
import { renderBrief, runWorker } from "./worker.ts";
|
|
31
|
+
import { addWorktree, mirrorPathFor, removeWorktree, worktreePathFor } from "./worktree.ts";
|
|
32
|
+
|
|
33
|
+
/** Long enough that the tracker is not polled raw, short enough that a human
|
|
34
|
+
* who labels an issue sees it picked up within a coffee break. */
|
|
35
|
+
const TICK_INTERVAL_MS = 5 * 60_000;
|
|
36
|
+
const DEFAULT_PORT = 8787;
|
|
37
|
+
const BRIEF_TEMPLATE_PATH = join(import.meta.dir, "briefs", "worker.md");
|
|
38
|
+
|
|
39
|
+
/** Fleet-wide escalations still need an issue number in the payload; 0 is the
|
|
40
|
+
* sentinel that reads as "no issue" in every renderer. */
|
|
41
|
+
const NO_ISSUE = 0;
|
|
42
|
+
|
|
43
|
+
const UNROUTABLE_TEXT: Record<UnroutableReason, string> = {
|
|
44
|
+
"no-repo-label": "it carries no repo label",
|
|
45
|
+
"multiple-repo-labels": "it carries more than one repo label",
|
|
46
|
+
"unknown-repo": "its repo label maps to no configured repo",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export interface DaemonOpts {
|
|
50
|
+
once?: boolean;
|
|
51
|
+
port?: number;
|
|
52
|
+
project?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Everything one tick touches, resolved once at startup so a tick never
|
|
56
|
+
* re-reads config mid-flight and changes its own limits underneath itself. */
|
|
57
|
+
interface Deps {
|
|
58
|
+
project: ProjectConfig;
|
|
59
|
+
caps: Caps;
|
|
60
|
+
tracker: Tracker;
|
|
61
|
+
store: Store;
|
|
62
|
+
escalate(e: Escalation): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------- paths & pause
|
|
66
|
+
|
|
67
|
+
/** Single database for every project; the store partitions by project name. */
|
|
68
|
+
export function dbPath(): string {
|
|
69
|
+
return join(stateDir(), "conductor.db");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pause is a file rather than process state on purpose: `omp-conductor pause`
|
|
74
|
+
* and `/conductor pause` run in a different process from the daemon, and a flag
|
|
75
|
+
* on disk needs no IPC and survives a restart. A daemon that crashed while
|
|
76
|
+
* paused comes back paused.
|
|
77
|
+
*/
|
|
78
|
+
export function isPaused(): boolean {
|
|
79
|
+
return existsSync(join(stateDir(), "paused"));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function setPaused(v: boolean): void {
|
|
83
|
+
const f = join(stateDir(), "paused");
|
|
84
|
+
if (v) {
|
|
85
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
86
|
+
writeFileSync(f, `${new Date().toISOString()}\n`);
|
|
87
|
+
} else {
|
|
88
|
+
rmSync(f, { force: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------- helpers
|
|
93
|
+
|
|
94
|
+
function log(msg: string): void {
|
|
95
|
+
process.stderr.write(`[conductor ${new Date().toISOString()}] ${msg}\n`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function errText(e: unknown): string {
|
|
99
|
+
return e instanceof Error ? (e.stack ?? e.message) : String(e);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Local midnight, matching how a human reads "today".
|
|
104
|
+
*
|
|
105
|
+
* ponytail: a rolling 24h window would be fairer to a run that started at
|
|
106
|
+
* 23:50, but midnight is what someone checking a morning spend report expects.
|
|
107
|
+
* Upgrade path is a `capWindow: "day" | "rolling24h"` config key.
|
|
108
|
+
*/
|
|
109
|
+
function startOfToday(): number {
|
|
110
|
+
const d = new Date();
|
|
111
|
+
d.setHours(0, 0, 0, 0);
|
|
112
|
+
return d.getTime();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `owner/repo` for `gh`, derived from the clone URL.
|
|
117
|
+
*
|
|
118
|
+
* ponytail: RepoTarget has no explicit slug, so it is parsed off the URL and
|
|
119
|
+
* falls back to the routing name. Upgrade path is an optional `slug` field once
|
|
120
|
+
* a non-GitHub remote actually shows up.
|
|
121
|
+
*/
|
|
122
|
+
function repoSlug(repo: RepoTarget): string {
|
|
123
|
+
const m = /(?:[:/])([^/:]+\/[^/]+?)(?:\.git)?$/.exec(repo.cloneUrl);
|
|
124
|
+
return m?.[1] ?? repo.name;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function gatesBlock(repo: RepoTarget): string {
|
|
128
|
+
if (repo.gates.length === 0) {
|
|
129
|
+
return "_No pre-push gates are configured for this repo. Say so in your report rather than inventing one._";
|
|
130
|
+
}
|
|
131
|
+
return repo.gates.map((g) => `- \`${g.cmd}\` — run from \`${g.cwd}\``).join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function acceptanceCriteria(issue: ReadyIssue): string {
|
|
135
|
+
const body = issue.body.trim();
|
|
136
|
+
// ponytail: the whole issue body stands in for a criteria section. Upgrade
|
|
137
|
+
// path is parsing the "## Acceptance criteria" heading once issue templates
|
|
138
|
+
// are consistent enough to trust; a fuzzy extraction today would silently
|
|
139
|
+
// drop context the worker needs, and the brief already tells it to read the
|
|
140
|
+
// issue itself.
|
|
141
|
+
return body.length > 0
|
|
142
|
+
? body
|
|
143
|
+
: "_The issue body is empty. Read the issue and its comments, and escalate if it is genuinely underspecified._";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Add the new label before dropping the old one. The reverse order leaves a
|
|
148
|
+
* window where the issue carries no state label at all, which is exactly the
|
|
149
|
+
* shape `isEligible` treats as fresh work.
|
|
150
|
+
*/
|
|
151
|
+
async function swapLabel(tracker: Tracker, issue: number, from: string, to: string): Promise<void> {
|
|
152
|
+
await tracker.addLabel(issue, to);
|
|
153
|
+
await tracker.removeLabel(issue, from);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The escalator throws when no transport is configured or Telegram rejects, and
|
|
158
|
+
* only records the dedup marker on success. A page that cannot be delivered
|
|
159
|
+
* must not take the tick down with it — log it and let the next tick retry.
|
|
160
|
+
*/
|
|
161
|
+
async function safeEscalate(d: Deps, e: Escalation): Promise<void> {
|
|
162
|
+
try {
|
|
163
|
+
await d.escalate(e);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
log(`escalation for #${e.issue} could not be delivered: ${errText(err)}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function buildBrief(
|
|
170
|
+
project: ProjectConfig,
|
|
171
|
+
r: Routed,
|
|
172
|
+
branch: string,
|
|
173
|
+
worktree: string,
|
|
174
|
+
): Promise<string> {
|
|
175
|
+
// Read per dispatch rather than caching: editing the brief then takes effect
|
|
176
|
+
// on the next issue instead of needing a daemon restart.
|
|
177
|
+
const template = await Bun.file(BRIEF_TEMPLATE_PATH).text();
|
|
178
|
+
return renderBrief(template, {
|
|
179
|
+
ISSUE_NUMBER: String(r.issue.number),
|
|
180
|
+
ISSUE_TITLE: r.issue.title,
|
|
181
|
+
TRACKER_REPO: project.tracker.repo,
|
|
182
|
+
REPO: repoSlug(r.repo),
|
|
183
|
+
BRANCH: branch,
|
|
184
|
+
WORKTREE: worktree,
|
|
185
|
+
ACCEPTANCE_CRITERIA: acceptanceCriteria(r.issue),
|
|
186
|
+
GATES: gatesBlock(r.repo),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ------------------------------------------------------------------- one issue
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* One attempt at one issue, from claim to terminal state. Everything is inside
|
|
194
|
+
* a single try/catch so that a bad issue costs its own run and nothing else.
|
|
195
|
+
*/
|
|
196
|
+
async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
197
|
+
const { project, caps, tracker, store } = d;
|
|
198
|
+
const issue = r.issue.number;
|
|
199
|
+
const branch = branchName(r.issue);
|
|
200
|
+
const inProgress = project.stateLabels.inProgress;
|
|
201
|
+
|
|
202
|
+
let claimed = false;
|
|
203
|
+
let run: RunRecord | undefined;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
// Claim on the tracker FIRST, before any local work. The label — not the
|
|
207
|
+
// store — is the crash-safe guard against double dispatch: if this process
|
|
208
|
+
// dies mid-run, the next daemon sees the label, `isEligible` filters the
|
|
209
|
+
// issue out, and a human decides what to do with the orphan.
|
|
210
|
+
await tracker.addLabel(issue, inProgress);
|
|
211
|
+
claimed = true;
|
|
212
|
+
|
|
213
|
+
run = store.createRun({
|
|
214
|
+
project: project.name,
|
|
215
|
+
issue,
|
|
216
|
+
repo: r.repo.name,
|
|
217
|
+
branch,
|
|
218
|
+
worktree: "",
|
|
219
|
+
state: "claimed",
|
|
220
|
+
attempt,
|
|
221
|
+
turns: 0,
|
|
222
|
+
spendUsd: 0,
|
|
223
|
+
startedAt: Date.now(),
|
|
224
|
+
});
|
|
225
|
+
const runId = run.id;
|
|
226
|
+
|
|
227
|
+
// A run's tree is <workspaceRoot>/<issue> and addWorktree refuses to reuse
|
|
228
|
+
// an existing path, so a retry — or a tree kept from a failed attempt — has
|
|
229
|
+
// to be cleared first. Both helpers are pure path math and removeWorktree
|
|
230
|
+
// tolerates a mirror or tree that is not there yet, so this is safe on a
|
|
231
|
+
// first attempt. addWorktree does its own ensureMirror; calling it here too
|
|
232
|
+
// would cost a second network fetch per attempt.
|
|
233
|
+
const mirrorPath = mirrorPathFor(r.repo, project.mirrorRoot);
|
|
234
|
+
await removeWorktree(mirrorPath, worktreePathFor(project.workspaceRoot, issue));
|
|
235
|
+
|
|
236
|
+
const worktreePath = await addWorktree(
|
|
237
|
+
r.repo,
|
|
238
|
+
project.mirrorRoot,
|
|
239
|
+
project.workspaceRoot,
|
|
240
|
+
issue,
|
|
241
|
+
branch,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// The SDK names the transcript itself, so the daemon supplies the parent
|
|
245
|
+
// directory and learns the real path back from the result. Inventing one
|
|
246
|
+
// here would put a file that never gets written into an escalation.
|
|
247
|
+
const sessionDir = join(stateDir(), "sessions");
|
|
248
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
249
|
+
store.updateRun(runId, { worktree: worktreePath, state: "running" });
|
|
250
|
+
|
|
251
|
+
log(`#${issue} attempt ${attempt} → ${r.repo.name} ${branch}`);
|
|
252
|
+
|
|
253
|
+
const result = await runWorker({
|
|
254
|
+
brief: await buildBrief(project, r, branch, worktreePath),
|
|
255
|
+
cwd: worktreePath,
|
|
256
|
+
caps,
|
|
257
|
+
sessionDir,
|
|
258
|
+
...(project.workerModel === undefined ? {} : { model: project.workerModel }),
|
|
259
|
+
onTurn: (n) => store.updateRun(runId, { turns: n }),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// A configured model the harness could not honour means this run was done by
|
|
263
|
+
// a different model than the operator chose. Logged per run, because it is
|
|
264
|
+
// the only place that fact is still attached to the issue it affected.
|
|
265
|
+
if (result.modelFallbackMessage !== undefined) {
|
|
266
|
+
log(`#${issue} model fallback: ${result.modelFallbackMessage}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
store.updateRun(runId, {
|
|
270
|
+
state: result.state,
|
|
271
|
+
endedAt: Date.now(),
|
|
272
|
+
turns: result.turns,
|
|
273
|
+
spendUsd: result.spendUsd,
|
|
274
|
+
prUrl: result.prUrl,
|
|
275
|
+
sessionFile: result.sessionFile,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (result.state === "blocked") {
|
|
279
|
+
await swapLabel(tracker, issue, inProgress, project.stateLabels.blocked);
|
|
280
|
+
await safeEscalate(d, {
|
|
281
|
+
tier: 1,
|
|
282
|
+
project: project.name,
|
|
283
|
+
issue,
|
|
284
|
+
runId,
|
|
285
|
+
summary: `#${issue} is blocked on attempt ${attempt} and needs a decision`,
|
|
286
|
+
detail: [`${r.issue.title}`, r.issue.url, "", result.report].join("\n"),
|
|
287
|
+
});
|
|
288
|
+
} else if (result.state === "failed" || result.state === "killed") {
|
|
289
|
+
await swapLabel(tracker, issue, inProgress, project.stateLabels.failed);
|
|
290
|
+
await safeEscalate(d, {
|
|
291
|
+
tier: 1,
|
|
292
|
+
project: project.name,
|
|
293
|
+
issue,
|
|
294
|
+
runId,
|
|
295
|
+
// The dedup key includes the summary, so the attempt number is what
|
|
296
|
+
// lets a genuine second failure page again while a tick that keeps
|
|
297
|
+
// seeing the same dead issue stays quiet.
|
|
298
|
+
summary: result.killedBy
|
|
299
|
+
? `#${issue} was killed on attempt ${attempt} by the ${result.killedBy} cap`
|
|
300
|
+
: `#${issue} failed on attempt ${attempt}`,
|
|
301
|
+
detail: [
|
|
302
|
+
`${r.issue.title}`,
|
|
303
|
+
r.issue.url,
|
|
304
|
+
`Worktree kept for inspection: ${worktreePath}`,
|
|
305
|
+
`Session: ${result.sessionFile ?? "(no transcript)"}`,
|
|
306
|
+
"",
|
|
307
|
+
result.report,
|
|
308
|
+
].join("\n"),
|
|
309
|
+
});
|
|
310
|
+
} else {
|
|
311
|
+
// pushed-green: the PR belongs to a human now. The in-progress label
|
|
312
|
+
// stays on until the merge closes the issue, which is also what keeps
|
|
313
|
+
// the next tick from re-claiming it.
|
|
314
|
+
log(`#${issue} ${result.state}${result.prUrl ? ` ${result.prUrl}` : ""}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// A failed or killed tree is evidence — keep it. Anything else is just
|
|
318
|
+
// disk, and the mirror means re-provisioning is cheap. (The kept tree is
|
|
319
|
+
// wiped by the next attempt, not left to accumulate forever.)
|
|
320
|
+
if (result.state !== "failed" && result.state !== "killed") {
|
|
321
|
+
await removeWorktree(mirrorPath, worktreePath);
|
|
322
|
+
}
|
|
323
|
+
} catch (err) {
|
|
324
|
+
const detail = errText(err);
|
|
325
|
+
log(`#${issue} errored: ${detail}`);
|
|
326
|
+
if (run) {
|
|
327
|
+
store.updateRun(run.id, { state: "failed", endedAt: Date.now(), lastError: detail });
|
|
328
|
+
}
|
|
329
|
+
if (claimed) {
|
|
330
|
+
// Leaving the issue stuck as in-progress would hide it from both the
|
|
331
|
+
// queue and the human, so relabel even on the error path.
|
|
332
|
+
try {
|
|
333
|
+
await swapLabel(tracker, issue, inProgress, project.stateLabels.failed);
|
|
334
|
+
} catch (relabelErr) {
|
|
335
|
+
log(`#${issue} could not be relabelled: ${errText(relabelErr)}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
await safeEscalate(d, {
|
|
339
|
+
tier: 1,
|
|
340
|
+
project: project.name,
|
|
341
|
+
issue,
|
|
342
|
+
runId: run?.id,
|
|
343
|
+
summary: `#${issue} could not be dispatched on attempt ${attempt}`,
|
|
344
|
+
detail,
|
|
345
|
+
});
|
|
346
|
+
// The worktree, if one was created, is deliberately left in place: this is
|
|
347
|
+
// a failure path.
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ----------------------------------------------------------------------- a tick
|
|
352
|
+
|
|
353
|
+
async function tick(d: Deps): Promise<void> {
|
|
354
|
+
// A paused fleet claims nothing. Checked first so pausing takes effect on the
|
|
355
|
+
// next tick without signalling the process.
|
|
356
|
+
if (isPaused()) return;
|
|
357
|
+
|
|
358
|
+
const { project, caps, store } = d;
|
|
359
|
+
|
|
360
|
+
// route() filters the queue through isEligible() itself, so anything already
|
|
361
|
+
// carrying a state label is gone before it gets here.
|
|
362
|
+
const { routed, unroutable } = route(await d.tracker.listReady(), project);
|
|
363
|
+
|
|
364
|
+
// An issue nobody can route never reaches a worker: guessing the target repo
|
|
365
|
+
// is exactly the kind of improvisation this system exists to prevent. The
|
|
366
|
+
// summary is stable so a queue left unfixed pages once, not every tick.
|
|
367
|
+
for (const u of unroutable) {
|
|
368
|
+
await safeEscalate(d, {
|
|
369
|
+
tier: 1,
|
|
370
|
+
project: project.name,
|
|
371
|
+
issue: u.issue.number,
|
|
372
|
+
summary: `#${u.issue.number} cannot be routed: ${UNROUTABLE_TEXT[u.reason]}`,
|
|
373
|
+
detail: [
|
|
374
|
+
u.issue.title,
|
|
375
|
+
u.issue.url,
|
|
376
|
+
`Repo labels seen: ${u.labels.length > 0 ? u.labels.join(", ") : "(none)"}`,
|
|
377
|
+
`Configured repos: ${Object.keys(project.routing.repos).join(", ") || "(none)"}`,
|
|
378
|
+
`Fix: put exactly one \`${project.routing.labelPrefix}<repo>\` label on the issue.`,
|
|
379
|
+
].join("\n"),
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const since = startOfToday();
|
|
384
|
+
|
|
385
|
+
// Spend is the one cap that stops the fleet instead of merely deferring work.
|
|
386
|
+
// A loop that is burning money has to halt itself; waiting for a human to
|
|
387
|
+
// notice tomorrow is how a runaway becomes expensive.
|
|
388
|
+
const spent = store.spendSince(project.name, since);
|
|
389
|
+
if (spent >= caps.dailySpendUsd) {
|
|
390
|
+
setPaused(true);
|
|
391
|
+
await safeEscalate(d, {
|
|
392
|
+
tier: 2,
|
|
393
|
+
project: project.name,
|
|
394
|
+
issue: NO_ISSUE,
|
|
395
|
+
// Dated so the same cap pages again tomorrow, but only once per day.
|
|
396
|
+
summary: `Daily spend cap reached on ${new Date().toISOString().slice(0, 10)} — ${project.name} is paused`,
|
|
397
|
+
detail: [
|
|
398
|
+
`Spent $${spent.toFixed(2)} of the $${caps.dailySpendUsd.toFixed(2)} daily cap.`,
|
|
399
|
+
"No further work will be claimed until `omp-conductor resume` (or /conductor resume).",
|
|
400
|
+
].join("\n"),
|
|
401
|
+
});
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const active = store.activeRuns(project.name);
|
|
406
|
+
const slots = caps.maxConcurrentWorkers - active.length;
|
|
407
|
+
if (slots <= 0) {
|
|
408
|
+
log(`at capacity: ${active.length}/${caps.maxConcurrentWorkers} workers`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// activeRuns includes pushed-green work that is still waiting on a human
|
|
413
|
+
// merge, so this also stops a second attempt landing on a live PR.
|
|
414
|
+
const busy = new Set(active.map((r) => r.issue));
|
|
415
|
+
|
|
416
|
+
const admitted: { r: Routed; attempt: number }[] = [];
|
|
417
|
+
for (const r of routed) {
|
|
418
|
+
if (admitted.length >= slots) break;
|
|
419
|
+
if (busy.has(r.issue.number)) continue;
|
|
420
|
+
|
|
421
|
+
const prior = store.attemptsFor(project.name, r.issue.number);
|
|
422
|
+
if (prior >= caps.maxAttemptsPerIssue) {
|
|
423
|
+
await safeEscalate(d, {
|
|
424
|
+
tier: 1,
|
|
425
|
+
project: project.name,
|
|
426
|
+
issue: r.issue.number,
|
|
427
|
+
summary: `#${r.issue.number} has used all ${caps.maxAttemptsPerIssue} attempts`,
|
|
428
|
+
detail: [
|
|
429
|
+
r.issue.title,
|
|
430
|
+
r.issue.url,
|
|
431
|
+
"Another attempt almost always means the issue itself is underspecified.",
|
|
432
|
+
"Rewrite the acceptance criteria, or take it off the queue.",
|
|
433
|
+
].join("\n"),
|
|
434
|
+
});
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
admitted.push({ r, attempt: prior + 1 });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (admitted.length === 0) return;
|
|
442
|
+
|
|
443
|
+
log(`dispatching ${admitted.map((a) => `#${a.r.issue.number}`).join(" ")}`);
|
|
444
|
+
// handleIssue never rejects; allSettled is the belt to that braces.
|
|
445
|
+
await Promise.allSettled(admitted.map((a) => handleIssue(d, a.r, a.attempt)));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// --------------------------------------------------------------- read-only views
|
|
449
|
+
|
|
450
|
+
export interface StatusSnapshot {
|
|
451
|
+
project: string;
|
|
452
|
+
configPath: string;
|
|
453
|
+
stateDir: string;
|
|
454
|
+
paused: boolean;
|
|
455
|
+
caps: Caps;
|
|
456
|
+
activeRuns: RunRecord[];
|
|
457
|
+
runsToday: number;
|
|
458
|
+
spendTodayUsd: number;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Opens and closes its own store handle so the CLI and the plugin can read
|
|
462
|
+
* status while a daemon in another process is writing (the store runs in WAL
|
|
463
|
+
* mode for exactly this). */
|
|
464
|
+
export function statusSnapshot(project?: string): StatusSnapshot {
|
|
465
|
+
const cfg = loadConfig();
|
|
466
|
+
const p = findProject(cfg, project);
|
|
467
|
+
const store = openStore(dbPath());
|
|
468
|
+
try {
|
|
469
|
+
const since = startOfToday();
|
|
470
|
+
return {
|
|
471
|
+
project: p.name,
|
|
472
|
+
configPath: configPath(),
|
|
473
|
+
stateDir: stateDir(),
|
|
474
|
+
paused: isPaused(),
|
|
475
|
+
caps: resolveCaps(p, cfg.defaults),
|
|
476
|
+
activeRuns: store.activeRuns(p.name),
|
|
477
|
+
runsToday: store.runsStartedSince(p.name, since),
|
|
478
|
+
spendTodayUsd: store.spendSince(p.name, since),
|
|
479
|
+
};
|
|
480
|
+
} finally {
|
|
481
|
+
store.close();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function formatStatus(s: StatusSnapshot): string {
|
|
486
|
+
const lines = [
|
|
487
|
+
`project ${s.project}${s.paused ? " (PAUSED)" : ""}`,
|
|
488
|
+
`config ${s.configPath}`,
|
|
489
|
+
`state ${s.stateDir}`,
|
|
490
|
+
"",
|
|
491
|
+
"caps",
|
|
492
|
+
` workers ${s.activeRuns.length} / ${s.caps.maxConcurrentWorkers}`,
|
|
493
|
+
` issues today ${s.runsToday}`,
|
|
494
|
+
` spend today $${s.spendTodayUsd.toFixed(2)} / $${s.caps.dailySpendUsd.toFixed(2)}`,
|
|
495
|
+
` worker max turns ${s.caps.workerMaxTurns}`,
|
|
496
|
+
` worker wall clock ${Math.round(s.caps.workerWallClockMs / 60_000)}m`,
|
|
497
|
+
` attempts per issue ${s.caps.maxAttemptsPerIssue}`,
|
|
498
|
+
"",
|
|
499
|
+
];
|
|
500
|
+
if (s.activeRuns.length === 0) {
|
|
501
|
+
lines.push("active runs (none)");
|
|
502
|
+
} else {
|
|
503
|
+
lines.push("active runs");
|
|
504
|
+
for (const r of s.activeRuns) {
|
|
505
|
+
lines.push(
|
|
506
|
+
` #${r.issue} ${r.repo} ${r.state} attempt ${r.attempt} ` +
|
|
507
|
+
`${r.turns} turns $${r.spendUsd.toFixed(2)} ${r.branch}` +
|
|
508
|
+
(r.prUrl ? ` ${r.prUrl}` : ""),
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return lines.join("\n");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export interface QueuePreview {
|
|
516
|
+
project: string;
|
|
517
|
+
configPath: string;
|
|
518
|
+
queueDescription: string;
|
|
519
|
+
paused: boolean;
|
|
520
|
+
ready: { number: number; title: string; repo: string; branch: string }[];
|
|
521
|
+
unroutable: { number: number; title: string; reason: string; labels: string[] }[];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Exactly what the next tick would pick up, computed without touching a single
|
|
526
|
+
* label, run row or worktree. This is what makes `/conductor setup` honest: the
|
|
527
|
+
* dry run is the same routing code the loop uses, not a description of it.
|
|
528
|
+
*/
|
|
529
|
+
export async function previewQueue(project?: string): Promise<QueuePreview> {
|
|
530
|
+
const cfg = loadConfig();
|
|
531
|
+
const p = findProject(cfg, project);
|
|
532
|
+
const { routed, unroutable } = route(await makeTracker(p).listReady(), p);
|
|
533
|
+
const states = Object.values(p.stateLabels).join(", ");
|
|
534
|
+
return {
|
|
535
|
+
project: p.name,
|
|
536
|
+
configPath: configPath(),
|
|
537
|
+
queueDescription:
|
|
538
|
+
`open issues in ${p.tracker.repo} labelled "${p.queueLabel}", ` +
|
|
539
|
+
`minus anything already labelled ${states}, ` +
|
|
540
|
+
`routed by one "${p.routing.labelPrefix}<repo>" label`,
|
|
541
|
+
paused: isPaused(),
|
|
542
|
+
ready: routed.map((r) => ({
|
|
543
|
+
number: r.issue.number,
|
|
544
|
+
title: r.issue.title,
|
|
545
|
+
repo: r.repo.name,
|
|
546
|
+
branch: branchName(r.issue),
|
|
547
|
+
})),
|
|
548
|
+
unroutable: unroutable.map((u) => ({
|
|
549
|
+
number: u.issue.number,
|
|
550
|
+
title: u.issue.title,
|
|
551
|
+
reason: UNROUTABLE_TEXT[u.reason],
|
|
552
|
+
labels: u.labels,
|
|
553
|
+
})),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The one mutation `/conductor setup` performs, and only after the operator has
|
|
559
|
+
* seen the dry run: create the state directory and schema, then clear the pause
|
|
560
|
+
* flag so the daemon is allowed to claim work.
|
|
561
|
+
*/
|
|
562
|
+
export function armConductor(): void {
|
|
563
|
+
openStore(dbPath()).close();
|
|
564
|
+
setPaused(false);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ------------------------------------------------------------------- the daemon
|
|
568
|
+
|
|
569
|
+
export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
570
|
+
const cfg = loadConfig();
|
|
571
|
+
const project = findProject(cfg, o.project);
|
|
572
|
+
const caps = resolveCaps(project, cfg.defaults);
|
|
573
|
+
const store = openStore(dbPath());
|
|
574
|
+
const tracker = makeTracker(project);
|
|
575
|
+
|
|
576
|
+
// Standing orders. The orchestrator holds none of this file's context, so
|
|
577
|
+
// everything it needs to act — which tracker, which labels, what the fleet
|
|
578
|
+
// does — has to be said once, in words.
|
|
579
|
+
const brief = [
|
|
580
|
+
`You are the omp-conductor orchestrator for project "${project.name}".`,
|
|
581
|
+
`Tracker: ${project.tracker.repo}. Pass --repo ${project.tracker.repo} to every gh command:`,
|
|
582
|
+
"this working directory is the conductor's state directory, not a checkout.",
|
|
583
|
+
`Labels: queue=${project.queueLabel}, running=${project.stateLabels.inProgress}, ` +
|
|
584
|
+
`blocked=${project.stateLabels.blocked}, failed=${project.stateLabels.failed}.`,
|
|
585
|
+
"The dispatcher claims queue-labelled issues, runs one worker session per attempt in its own",
|
|
586
|
+
"worktree under hard turn/wallclock/spend caps, and escalates to you when a worker blocks or",
|
|
587
|
+
"fails twice, its gates stay red, its branch conflicts, or a tripwire fires.",
|
|
588
|
+
"Your job when that happens: re-brief the issue (comment what the next worker must do",
|
|
589
|
+
`differently, then put ${project.queueLabel} back on it), file follow-up issues, or promote to`,
|
|
590
|
+
"tier 2 and let the human decide. You never edit product code, push a branch, or merge a PR —",
|
|
591
|
+
"a worker session does all of that. Handle each escalation below before the next one.",
|
|
592
|
+
].join("\n");
|
|
593
|
+
|
|
594
|
+
// One orchestrator per daemon run, not per tick: it is a persistent session
|
|
595
|
+
// whose whole value is remembering what it has already escalated, and a fresh
|
|
596
|
+
// one every five minutes would remember nothing. Its cwd is the state
|
|
597
|
+
// directory, deliberately not a checkout — the orchestrator re-briefs workers
|
|
598
|
+
// and talks to the tracker, it does not edit product code.
|
|
599
|
+
let orchestrator: OrchestratorHandle | undefined;
|
|
600
|
+
try {
|
|
601
|
+
orchestrator = await startOrchestrator({ cwd: stateDir(), brief });
|
|
602
|
+
const transcript = orchestrator.sessionFile();
|
|
603
|
+
log(`orchestrator session ready${transcript === undefined ? "" : ` · ${transcript}`}`);
|
|
604
|
+
} catch (err) {
|
|
605
|
+
// Loudly, but not fatally: tier-1 escalations degrade to issue comments,
|
|
606
|
+
// which a human still reads. A dispatcher that refuses to run because its
|
|
607
|
+
// re-briefing channel is down helps nobody.
|
|
608
|
+
log(
|
|
609
|
+
`WARNING: orchestrator session failed to start; tier-1 escalations will fall back to issue comments: ${errText(err)}`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const escalator = createEscalator(project, tracker, store, orchestrator);
|
|
614
|
+
const d: Deps = {
|
|
615
|
+
project,
|
|
616
|
+
caps,
|
|
617
|
+
tracker,
|
|
618
|
+
store,
|
|
619
|
+
escalate: (e) => escalator.escalate(e),
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
if (o.once) {
|
|
623
|
+
try {
|
|
624
|
+
await tick(d);
|
|
625
|
+
} finally {
|
|
626
|
+
await orchestrator?.dispose();
|
|
627
|
+
store.close();
|
|
628
|
+
}
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
let stopping = false;
|
|
633
|
+
let wake: (() => void) | undefined;
|
|
634
|
+
const stop = (): void => {
|
|
635
|
+
if (stopping) return;
|
|
636
|
+
stopping = true;
|
|
637
|
+
log("shutting down after the current tick");
|
|
638
|
+
wake?.();
|
|
639
|
+
};
|
|
640
|
+
process.on("SIGINT", stop);
|
|
641
|
+
process.on("SIGTERM", stop);
|
|
642
|
+
|
|
643
|
+
const server = Bun.serve({
|
|
644
|
+
port: o.port ?? DEFAULT_PORT,
|
|
645
|
+
fetch(req) {
|
|
646
|
+
const url = new URL(req.url);
|
|
647
|
+
if (req.method === "GET" && url.pathname === "/healthz") {
|
|
648
|
+
return Response.json({
|
|
649
|
+
ok: true,
|
|
650
|
+
paused: isPaused(),
|
|
651
|
+
activeRuns: store.activeRuns(project.name).length,
|
|
652
|
+
project: project.name,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
return new Response("not found\n", { status: 404 });
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
log(`serving /healthz on :${server.port}, project ${project.name}`);
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
while (!stopping) {
|
|
662
|
+
try {
|
|
663
|
+
await tick(d);
|
|
664
|
+
} catch (err) {
|
|
665
|
+
// A tick that blows up outside an issue (the tracker is down, say) must
|
|
666
|
+
// not end the daemon; the next one will retry.
|
|
667
|
+
log(`tick failed: ${errText(err)}`);
|
|
668
|
+
}
|
|
669
|
+
if (stopping) break;
|
|
670
|
+
await new Promise<void>((resolve) => {
|
|
671
|
+
const t = setTimeout(resolve, TICK_INTERVAL_MS);
|
|
672
|
+
wake = () => {
|
|
673
|
+
clearTimeout(t);
|
|
674
|
+
resolve();
|
|
675
|
+
};
|
|
676
|
+
});
|
|
677
|
+
wake = undefined;
|
|
678
|
+
}
|
|
679
|
+
} finally {
|
|
680
|
+
process.off("SIGINT", stop);
|
|
681
|
+
process.off("SIGTERM", stop);
|
|
682
|
+
await server.stop(true);
|
|
683
|
+
// Before the store closes: a queued injection that rejects on the way out
|
|
684
|
+
// falls back to an issue comment, and that path writes the dedup marker.
|
|
685
|
+
await orchestrator?.dispose();
|
|
686
|
+
store.close();
|
|
687
|
+
log("stopped");
|
|
688
|
+
}
|
|
689
|
+
}
|