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/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
+ }