omp-conductor 0.14.0 → 0.15.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 CHANGED
@@ -29,13 +29,14 @@ import { createEscalator, escalationIssueRef } from "./escalate.ts";
29
29
  import { pendingCodeGraph, probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
30
30
  import { graphHint } from "./graph.ts";
31
31
  import { livingDaemon } from "./lifecycle.ts";
32
- import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
32
+ import { requestImmediateTick, resolveTickConfigCwd, STALL_MARKER_FILE } from "./orchestrator-tick.ts";
33
33
  import { startOrchestrator } from "./orchestrator.ts";
34
34
  import type { OrchestratorHandle } from "./orchestrator.ts";
35
35
  import {
36
36
  createReportOutbox,
37
37
  enqueueAvailableHeldNotices,
38
38
  formatOpenReports,
39
+ type ReportOutbox,
39
40
  } from "./reports.ts";
40
41
  import {
41
42
  recordReleaseBlock,
@@ -226,10 +227,10 @@ export function verbDeps(d: Pick<Deps, "project" | "store" | "tracker" | "verbAc
226
227
  tracker: d.tracker,
227
228
  actions: d.verbActions ?? githubVerbActions(d.project),
228
229
  fleetStop: () =>
229
- isPaused()
230
- ? "claiming is paused for this fleet (omp-conductor pause, hold or halt)"
230
+ isPaused(d.project.name)
231
+ ? "claiming is paused for this fleet (omp-conductor hold or stop)"
231
232
  : undefined,
232
- pausedAt,
233
+ pausedAt: () => pausedAt(d.project.name),
233
234
  log,
234
235
  now: () => Date.now(),
235
236
  chain: { readBaseChain },
@@ -375,80 +376,92 @@ export async function watchOrchestrator(d: Deps, now = Date.now()): Promise<void
375
376
  }
376
377
 
377
378
  /**
378
- * Pause is a file rather than process state on purpose: `omp-conductor pause`
379
- * and `/conductor pause` run in a different process from the daemon, and a flag
379
+ * Pause is a file rather than process state on purpose: `omp-conductor hold`
380
+ * runs in a different process from the daemon, and a flag
380
381
  * on disk needs no IPC and survives a restart. A daemon that crashed while
381
382
  * paused comes back paused.
382
383
  */
383
- export function isPaused(): boolean {
384
- return existsSync(join(stateDir(), "paused"));
384
+ export function pausedPath(project?: string): string {
385
+ return join(stateDir(), project === undefined ? "paused" : `paused-${project}`);
386
+ }
387
+
388
+ function activePausePaths(project?: string): string[] {
389
+ const paths = project === undefined ? [pausedPath()] : [pausedPath(project), pausedPath()];
390
+ return paths.filter((path) => existsSync(path));
391
+ }
392
+
393
+ export function isPaused(project?: string): boolean {
394
+ return activePausePaths(project).length !== 0;
385
395
  }
386
396
 
387
397
  /**
388
- * The epoch-ms timestamp at which the current pause began, read from the same
389
- * sentinel file {@link setPaused} writes (`<stateDir()>/paused`). Returns
390
- * `undefined` when the fleet is not paused, or when the file's first line does
391
- * not parse as a date. A legacy/blank sentinel keeps completion mutations
392
- * fail-closed because a run admitted before an *unknown* pause cannot be proven
393
- * innocent. {@link isPaused} is the authority on *whether*; this answers *since
394
- * when*.
398
+ * The epoch-ms timestamp at which the current pause began. A project pause also
399
+ * observes the legacy bare sentinel, which pauses every project. If any active
400
+ * sentinel is unreadable or unparseable, the timestamp is unknown so callers
401
+ * continue to fail closed.
395
402
  */
396
- export function pausedAt(): number | undefined {
397
- const f = join(stateDir(), "paused");
398
- if (!existsSync(f)) return undefined;
399
- try {
400
- const first = readFileSync(f, "utf8").split("\n")[0]?.trim();
401
- if (first === undefined || first === "") return undefined;
402
- const t = Date.parse(first);
403
- return Number.isNaN(t) ? undefined : t;
404
- } catch {
405
- // Unreadable sentinel (permissions, corruption): fail completion mutations
406
- // closed like an unparseable line while the pause time is unprovable.
407
- return undefined;
403
+ export function pausedAt(project?: string): number | undefined {
404
+ const paths = activePausePaths(project);
405
+ if (paths.length === 0) return undefined;
406
+ const times: number[] = [];
407
+ for (const path of paths) {
408
+ try {
409
+ const first = readFileSync(path, "utf8").split("\n")[0]?.trim();
410
+ if (first === undefined || first === "") return undefined;
411
+ const time = Date.parse(first);
412
+ if (Number.isNaN(time)) return undefined;
413
+ times.push(time);
414
+ } catch {
415
+ return undefined;
416
+ }
408
417
  }
418
+ return Math.min(...times);
409
419
  }
410
420
 
411
421
  /**
412
- * Who paused the fleet and why, read from line 2 of the same sentinel
413
- * {@link setPaused} writes — `undefined` when the fleet is not paused or the
414
- * file has no (parseable) line 2. Provenance lives on its own line so line 1
415
- * stays a pure ISO timestamp that {@link pausedAt} can `Date.parse`; the `armed
416
- * <ISO> owner=<id>` marker `armTicks` writes is the same one-key-per-line
417
- * precedent.
422
+ * Who paused the project and why. Per-project provenance wins when both its
423
+ * sentinel and the legacy all-project sentinel are active.
418
424
  */
419
- export function pauseProvenance(): { source: string; reason?: string } | undefined {
420
- const f = join(stateDir(), "paused");
421
- if (!existsSync(f)) return undefined;
425
+ export function pauseProvenance(
426
+ project?: string,
427
+ ): { source: string; reason?: string } | undefined {
428
+ const paths =
429
+ project === undefined
430
+ ? [pausedPath()]
431
+ : [pausedPath(project), pausedPath()];
432
+ const path = paths.find((candidate) => existsSync(candidate));
433
+ if (path === undefined) return undefined;
422
434
  try {
423
- const second = readFileSync(f, "utf8").split("\n")[1];
435
+ const second = readFileSync(path, "utf8").split("\n")[1];
424
436
  if (second === undefined) return undefined;
425
437
  const match = /^source=(\S+)(?: reason="(.*)")?$/.exec(second.trim());
426
438
  if (match === null) return undefined;
427
- const source = match[1]!; // the regex guarantees group 1 on a match
439
+ const source = match[1]!;
428
440
  const reason = match[2];
429
441
  return { source, ...(reason === undefined ? {} : { reason }) };
430
442
  } catch {
431
- // Unreadable sentinel: no provenance to name. Completion mutations still
432
- // fail closed because the pause time is unknown.
433
443
  return undefined;
434
444
  }
435
445
  }
436
446
 
437
- export function setPaused(v: boolean, why?: { source: string; reason?: string }): void {
438
- const f = join(stateDir(), "paused");
447
+ export function setPaused(
448
+ v: boolean,
449
+ why?: { source: string; reason?: string },
450
+ project?: string,
451
+ ): void {
452
+ const path = pausedPath(project);
439
453
  if (v) {
440
- mkdirSync(dirname(f), { recursive: true });
454
+ mkdirSync(dirname(path), { recursive: true });
441
455
  const line1 = `${new Date().toISOString()}\n`;
442
456
  if (why === undefined) {
443
- writeFileSync(f, line1);
457
+ writeFileSync(path, line1);
444
458
  } else {
445
- // Quotes are stripped before embedding so a reason cannot break out of
446
- // the `reason="..."` field of line 2.
447
459
  const reason = why.reason === undefined ? "" : ` reason="${why.reason.replaceAll('"', "")}"`;
448
- writeFileSync(f, `${line1}source=${why.source}${reason}\n`);
460
+ writeFileSync(path, `${line1}source=${why.source}${reason}\n`);
449
461
  }
450
462
  } else {
451
- rmSync(f, { force: true });
463
+ rmSync(path, { force: true });
464
+ if (project !== undefined) rmSync(pausedPath(), { force: true });
452
465
  }
453
466
  }
454
467
 
@@ -674,8 +687,8 @@ async function reactToProviderCredit(
674
687
  sessionFile: string | undefined,
675
688
  ): Promise<void> {
676
689
  const { project } = d;
677
- const alreadyPaused = isPaused();
678
- if (!alreadyPaused) setPaused(true, { source: "provider-credit", reason: message });
690
+ const alreadyPaused = isPaused(project.name);
691
+ if (!alreadyPaused) setPaused(true, { source: "provider-credit", reason: message }, project.name);
679
692
  log(
680
693
  `#${issue} provider refused for credit — dispatch ${alreadyPaused ? "remains paused" : "paused"}: ${message}`,
681
694
  );
@@ -2939,6 +2952,40 @@ export async function dispatchAdmissions(
2939
2952
 
2940
2953
  // ----------------------------------------------------------------------- a tick
2941
2954
 
2955
+ /**
2956
+ * After a false→true condition transition, poke the orchestrator heartbeat so
2957
+ * it does not wait a full interval (#329). Best-effort: missing tick config or
2958
+ * a failed write leaves the row flagged in the ledger for the next heartbeat.
2959
+ */
2960
+ export function wakeOrchestratorForMetConditions(
2961
+ projectName: string,
2962
+ met: readonly { id: string; condition?: string }[],
2963
+ writeLog: (line: string) => void = log,
2964
+ ): void {
2965
+ if (met.length === 0) return;
2966
+ for (const decision of met) {
2967
+ writeLog(
2968
+ `decision ${decision.id} condition met (${decision.condition ?? "?"}) — requesting orchestrator tick`,
2969
+ );
2970
+ }
2971
+ const tickCwd = resolveTickConfigCwd(projectName);
2972
+ if (tickCwd === undefined) {
2973
+ writeLog(
2974
+ `decision condition met for ${met.map((m) => m.id).join(", ")} but no tick config cwd — heartbeat will surface them on its next interval`,
2975
+ );
2976
+ return;
2977
+ }
2978
+ const ids = met.map((m) => m.id).join(",");
2979
+ const reason = `condition-met ${ids}`;
2980
+ if (requestImmediateTick(tickCwd, reason)) {
2981
+ writeLog(`requested immediate tick at ${tickCwd}: ${reason}`);
2982
+ } else {
2983
+ writeLog(
2984
+ `could not write tick request under ${tickCwd}; conditions ${ids} wait for the next heartbeat`,
2985
+ );
2986
+ }
2987
+ }
2988
+
2942
2989
  export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
2943
2990
  // A config edit takes effect on the next tick, not the next daemon restart
2944
2991
  // (#170). Re-resolve the project and its caps at the tick boundary so a tick
@@ -3038,11 +3085,14 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
3038
3085
  // Ledger maintenance, above the pause gate for the same reason the stall watch
3039
3086
  // is: a paused fleet still owes its operator the questions it asked, and a
3040
3087
  // condition that came true while dispatch was parked is exactly the thing the
3041
- // orchestrator has to see on its next tick.
3088
+ // orchestrator has to see promptly (#329).
3042
3089
  //
3043
3090
  // Expiry is synchronous (one UPDATE); condition evaluation is fire-and-forget
3044
3091
  // because it shells out to `gh` and `npm`, and a registry that hangs must cost
3045
- // one unevaluated condition rather than the tick.
3092
+ // one unevaluated condition rather than the tick. A false→true transition
3093
+ // writes the same immediate-tick poke recover uses so the heartbeat does not
3094
+ // wait a full interval; repeated sweeps while the condition stays true never
3095
+ // re-enter `met` (store mark is idempotent).
3046
3096
  for (const expired of d.store.expireDueDecisions(d.project.name, Date.now())) {
3047
3097
  log(`decision ${expired.id} expired unanswered after seven days: ${expired.question}`);
3048
3098
  }
@@ -3054,9 +3104,7 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
3054
3104
  Date.now,
3055
3105
  )
3056
3106
  .then((met) => {
3057
- for (const decision of met) {
3058
- log(`decision ${decision.id} condition met (${decision.condition ?? "?"}) — surfacing on the next tick`);
3059
- }
3107
+ wakeOrchestratorForMetConditions(d.project.name, met);
3060
3108
  })
3061
3109
  .catch((err: unknown) => {
3062
3110
  log(`decision condition pass failed: ${errText(err)}`);
@@ -3064,7 +3112,7 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
3064
3112
 
3065
3113
  // A paused fleet claims nothing. Checked first so pausing takes effect on the
3066
3114
  // next tick without signalling the process.
3067
- if (isPaused()) return;
3115
+ if (isPaused(d.project.name)) return;
3068
3116
 
3069
3117
  const { project, caps, store } = d;
3070
3118
 
@@ -3091,7 +3139,11 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
3091
3139
  `ERROR: the installed conductor changed under this daemon — ${integrity.diff.length} file(s) differ ` +
3092
3140
  `(${shown.join(", ")}${integrity.diff.length > shown.length ? ", …" : ""}) — pausing`,
3093
3141
  );
3094
- setPaused(true, { source: "integrity", reason: "installed package changed under the daemon" });
3142
+ setPaused(
3143
+ true,
3144
+ { source: "integrity", reason: "installed package changed under the daemon" },
3145
+ project.name,
3146
+ );
3095
3147
  if (integrity.page) {
3096
3148
  const delivered = await safeEscalate(d, {
3097
3149
  tier: 2,
@@ -3181,7 +3233,11 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
3181
3233
  // operator opted out — turns and wall-clock still brake every run (#46).
3182
3234
  const spent = store.spendSince(project.name, since);
3183
3235
  if (caps.dailySpendUsd !== null && spent >= caps.dailySpendUsd) {
3184
- setPaused(true, { source: "spend-cap", reason: `daily spend reached $${caps.dailySpendUsd}` });
3236
+ setPaused(
3237
+ true,
3238
+ { source: "spend-cap", reason: `daily spend reached $${caps.dailySpendUsd}` },
3239
+ project.name,
3240
+ );
3185
3241
  await safeEscalate(d, {
3186
3242
  tier: 2,
3187
3243
  category: "fleet-stopped",
@@ -3191,7 +3247,7 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
3191
3247
  summary: `Daily spend cap reached on ${new Date().toISOString().slice(0, 10)} — ${project.name} is paused`,
3192
3248
  detail: [
3193
3249
  `Spent $${spent.toFixed(2)} of the $${caps.dailySpendUsd.toFixed(2)} daily cap.`,
3194
- "No further work will be claimed until `omp-conductor resume` (or /conductor resume).",
3250
+ "No further work will be claimed until `omp-conductor resume`.",
3195
3251
  ].join("\n"),
3196
3252
  });
3197
3253
  recordDispatch(0, [
@@ -3242,25 +3298,29 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
3242
3298
  // --------------------------------------------------------------- read-only views
3243
3299
 
3244
3300
  export interface DaemonHealthSnapshot {
3245
- ok: true;
3301
+ ok: boolean;
3246
3302
  paused: boolean;
3247
3303
  activeRuns: number;
3248
3304
  project: string;
3249
3305
  /** One-shot issue ceilings waiting for the next claim. */
3250
3306
  turnOverrides: TurnOverride[];
3251
- /** Resident set of this daemon; workers are in-process omp sessions. */
3252
- rssBytes: number;
3253
3307
  dispatch?: DispatchSummary;
3254
3308
  codeGraph?: CodeGraphHealth;
3255
3309
  /** Live workers in a non-running pause phase; absent/empty = nothing paused. */
3256
3310
  workers?: { issue: number; runId: string; phase: WorkerPausePhase }[];
3257
3311
  }
3258
3312
 
3313
+ export interface DaemonHealth {
3314
+ ok: boolean;
3315
+ /** Resident set of this daemon; workers are in-process omp sessions. */
3316
+ rssBytes: number;
3317
+ projects: DaemonHealthSnapshot[];
3318
+ }
3319
+
3259
3320
  export function daemonHealthSnapshot(
3260
3321
  store: Store,
3261
3322
  project: string,
3262
- paused = isPaused(),
3263
- rssBytes = process.memoryUsage().rss,
3323
+ paused = isPaused(project),
3264
3324
  codeGraph?: CodeGraphHealth,
3265
3325
  workerControls?: WorkerControlRegistry,
3266
3326
  ): DaemonHealthSnapshot {
@@ -3271,13 +3331,19 @@ export function daemonHealthSnapshot(
3271
3331
  activeRuns: store.activeRuns(project).length,
3272
3332
  turnOverrides: store.listTurnOverrides(project),
3273
3333
  project,
3274
- rssBytes,
3275
3334
  ...(dispatch === undefined ? {} : { dispatch }),
3276
3335
  ...(codeGraph?.configured === true ? { codeGraph } : {}),
3277
3336
  ...(workerControls === undefined ? {} : { workers: workerControls.snapshot(project) }),
3278
3337
  };
3279
3338
  }
3280
3339
 
3340
+ export function daemonHealth(
3341
+ projects: DaemonHealthSnapshot[],
3342
+ rssBytes = process.memoryUsage().rss,
3343
+ ): DaemonHealth {
3344
+ return { ok: projects.every((project) => project.ok), rssBytes, projects };
3345
+ }
3346
+
3281
3347
  const TURN_OVERRIDE_STATES: ReadonlySet<RunState> = new Set([
3282
3348
  "failed",
3283
3349
  "killed",
@@ -3483,13 +3549,17 @@ export async function workerControlResponse(
3483
3549
  );
3484
3550
  }
3485
3551
 
3486
- export interface DaemonHttpDeps {
3552
+ export interface DaemonHttpProjectDeps {
3487
3553
  project: string;
3488
3554
  store: Pick<Store, "latestRun" | "setTurnOverride">;
3489
3555
  caps: () => Pick<Caps, "workerMaxTurns" | "workerMaxTurnsCeiling">;
3556
+ }
3557
+
3558
+ export interface DaemonHttpDeps {
3559
+ projects: readonly DaemonHttpProjectDeps[];
3490
3560
  turnLimits: TurnLimitRegistry;
3491
3561
  workerControls: WorkerControlRegistry;
3492
- health: () => DaemonHealthSnapshot;
3562
+ health: () => DaemonHealth;
3493
3563
  }
3494
3564
 
3495
3565
  /**
@@ -3504,9 +3574,47 @@ export interface DaemonHttpDeps {
3504
3574
  export async function daemonHttpResponse(req: Request, d: DaemonHttpDeps): Promise<Response> {
3505
3575
  const url = new URL(req.url);
3506
3576
  if (req.method === "GET" && url.pathname === "/healthz") return Response.json(d.health());
3507
- const turnLimit = await turnLimitResponse(req, d.project, d.store, d.turnLimits, d.caps());
3577
+
3578
+ let selected = d.projects[0];
3579
+ if (
3580
+ req.method === "PUT" &&
3581
+ /^\/runs\/\d+\/(?:turn-limit|pause|resume|stop)$/.test(url.pathname) &&
3582
+ req.headers.get("content-type")?.startsWith("application/json")
3583
+ ) {
3584
+ try {
3585
+ const body = await req.clone().json();
3586
+ if (body !== null && typeof body === "object") {
3587
+ const requested = Reflect.get(body, "project");
3588
+ if (typeof requested === "string" && requested.length > 0) {
3589
+ selected = d.projects.find(({ project }) => project === requested);
3590
+ if (selected === undefined) {
3591
+ return Response.json(
3592
+ { error: `daemon does not serve requested project "${requested}"` },
3593
+ { status: 409 },
3594
+ );
3595
+ }
3596
+ }
3597
+ }
3598
+ } catch {
3599
+ // The route handler below owns the public malformed-body response.
3600
+ }
3601
+ }
3602
+ if (selected === undefined) return new Response("not found\n", { status: 404 });
3603
+
3604
+ const turnLimit = await turnLimitResponse(
3605
+ req,
3606
+ selected.project,
3607
+ selected.store,
3608
+ d.turnLimits,
3609
+ selected.caps(),
3610
+ );
3508
3611
  if (turnLimit !== undefined) return turnLimit;
3509
- const workerControl = await workerControlResponse(req, d.project, d.store, d.workerControls);
3612
+ const workerControl = await workerControlResponse(
3613
+ req,
3614
+ selected.project,
3615
+ selected.store,
3616
+ d.workerControls,
3617
+ );
3510
3618
  return workerControl ?? new Response("not found\n", { status: 404 });
3511
3619
  }
3512
3620
 
@@ -3612,12 +3720,12 @@ export function statusSnapshotFromStore(
3612
3720
  const oldestLabelOpAt = store.oldestPendingLabelOpAt(p.name);
3613
3721
  // Read once: the provenance read touches the filesystem, and the renderer
3614
3722
  // should never pay for it twice per status.
3615
- const reason = pauseProvenance()?.reason;
3723
+ const reason = pauseProvenance(p.name)?.reason;
3616
3724
  return {
3617
3725
  project: p.name,
3618
3726
  configPath: configPath(),
3619
3727
  stateDir: stateDir(),
3620
- paused: isPaused(),
3728
+ paused: isPaused(p.name),
3621
3729
  ...(reason === undefined ? {} : { pauseReason: reason }),
3622
3730
  availability: availabilityState(p.reporting, now),
3623
3731
  digestSchedule: digestScheduleState(p.reporting ?? DEFAULT_REPORT_POLICY, lastDigestDay, now),
@@ -3817,7 +3925,7 @@ export interface QueuePreview {
3817
3925
 
3818
3926
  /**
3819
3927
  * Exactly what the next tick would pick up, computed without touching a single
3820
- * label, run row or worktree. This is what makes `/conductor setup` honest: the
3928
+ * label, run row or worktree. This is what makes `omp-conductor setup` honest: the
3821
3929
  * dry run is the same routing code the loop uses, not a description of it.
3822
3930
  */
3823
3931
  export async function previewProject(
@@ -3833,7 +3941,7 @@ export async function previewProject(
3833
3941
  `open issues in ${p.tracker.repo} labelled "${p.queueLabel}", ` +
3834
3942
  `minus anything already labelled ${states}, ` +
3835
3943
  `routed by one "${p.routing.labelPrefix}<repo>" label`,
3836
- paused: isPaused(),
3944
+ paused: isPaused(p.name),
3837
3945
  ready: routed.map((r) => ({
3838
3946
  number: r.issue.number,
3839
3947
  title: r.issue.title,
@@ -3860,9 +3968,9 @@ export async function previewQueue(project?: string): Promise<QueuePreview> {
3860
3968
  * Setup calls this immediately after consent. Every later setup error therefore
3861
3969
  * leaves the fleet paused instead of exposing a partially written runtime.
3862
3970
  */
3863
- export function prepareConductor(): void {
3971
+ export function prepareConductor(project?: string): void {
3864
3972
  openStore(dbPath()).close();
3865
- setPaused(true, { source: "setup" });
3973
+ setPaused(true, { source: "setup" }, project);
3866
3974
  }
3867
3975
 
3868
3976
  /** Bounded per tick: each row costs tracker calls to gather facts for. */
@@ -4383,297 +4491,264 @@ export async function reconcileOrphanedRuns(
4383
4491
 
4384
4492
  // ------------------------------------------------------------------- the daemon
4385
4493
 
4494
+ interface ProjectRuntime {
4495
+ d: Deps;
4496
+ outbox: ReportOutbox;
4497
+ orchestrator?: OrchestratorHandle;
4498
+ orchestratorVerbs?: VerbListener;
4499
+ codeGraph: CodeGraphHealth;
4500
+ graphProbe?: Promise<void>;
4501
+ reportPass?: Promise<void>;
4502
+ }
4503
+
4504
+ function orchestratorStandingOrders(project: ProjectConfig): {
4505
+ brief: string;
4506
+ releaseGrants: ResolvedGrants;
4507
+ } {
4508
+ const releaseGrants = resolveReleaseGrants(project);
4509
+ const grantedShapes = RELEASE_SHAPES.filter((shape) => releaseGrants[shape] === "orchestrator");
4510
+ const deniedShapes = RELEASE_SHAPES.filter((shape) => releaseGrants[shape] !== "orchestrator");
4511
+ return {
4512
+ releaseGrants,
4513
+ brief: [
4514
+ `You are the omp-conductor orchestrator for project "${project.name}".`,
4515
+ `Tracker: ${project.tracker.repo}. Pass --repo ${project.tracker.repo} to every gh command:`,
4516
+ "this working directory is the conductor's state directory, not a checkout.",
4517
+ `Labels: queue=${project.queueLabel}, running=${project.stateLabels.inProgress}, ` +
4518
+ `blocked=${project.stateLabels.blocked}, failed=${project.stateLabels.failed}.`,
4519
+ "The dispatcher claims queue-labelled issues, runs one worker session per attempt in its own",
4520
+ "worktree under hard turn/wallclock/spend caps, and escalates to you when a worker blocks or",
4521
+ "fails twice, its gates stay red, its branch conflicts, or a tripwire fires.",
4522
+ "Your job when that happens: re-brief the issue (comment what the next worker must do",
4523
+ `differently, then put ${project.queueLabel} back on it), file follow-up issues, or promote to`,
4524
+ "tier 2 and let the human decide.",
4525
+ project.authority.merge === "orchestrator"
4526
+ ? "You never edit product code or push a branch — a worker session does that. Merging is yours: one PR at " +
4527
+ "a time, freshness-checked against the base branch, per the Releases section of your POLICY.md."
4528
+ : "You never edit product code, push a branch, or merge a PR — a worker session edits and pushes, and a " +
4529
+ "human merges.",
4530
+ `Release tool gate: ${
4531
+ grantedShapes.length === 0
4532
+ ? "every release and deploy shape is mechanically blocked for you"
4533
+ : `you may invoke ${grantedShapes.join(", ")} — and only by the procedure in your POLICY.md`
4534
+ }.` + (deniedShapes.length === 0 ? "" : ` Blocked: ${deniedShapes.join(", ")}.`),
4535
+ "Handle each escalation below before the next one.",
4536
+ ].join("\n"),
4537
+ };
4538
+ }
4539
+
4386
4540
  export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
4387
4541
  const cfg = loadConfig();
4388
- const project = findProject(cfg, o.project);
4389
- const caps = resolveCaps(project, cfg.defaults);
4542
+ const projects = o.project === undefined ? cfg.projects : [findProject(cfg, o.project)];
4390
4543
  const store = openStore(dbPath());
4391
- // The tracker's single `gh` funnel is bound to the store so the operator sees
4392
- // observed truth (#198): every spawn is counted per UTC day, and every
4393
- // rate-limit refusal is recorded for `status`'s 5m window. Board's ad-hoc
4394
- // trackers and the polled `fetchRateLimit` probe are deliberately not bound —
4395
- // this row is the daemon's own traffic.
4396
- const tracker = makeTracker(project, undefined, {
4397
- onCall: () => store.bumpGhCalls?.(utcDay(), "daemon"),
4398
- onRefusal: (at) => store.recordGhRefusal?.(at),
4399
- // A conditional 304 revalidation is a spawn but not a billed read (#203);
4400
- // counted separately so `status`'s call row keeps telling the truth once
4401
- // most spawns are free revalidations.
4402
- onNotModified: () => store.bumpGhCalls?.(utcDay(), "daemon-304"),
4403
- });
4404
-
4405
- // #126's transport, stated at startup rather than guessed at first use. The
4406
- // banner names what this host can actually enforce — whether the kernel will
4407
- // vouch for a caller's uid, and whether runs get distinct principals at all —
4408
- // because "peer credentials asserted" is a claim, and a claim nobody printed
4409
- // is one nobody can check against the host it is running on.
4410
4544
  const verbPeerReader = peerCredentialReader();
4411
- const verbActions = githubVerbActions(project);
4412
- let orchestratorVerbs: VerbListener | undefined;
4413
- log(`verb transport: ${transportBanner(ensureVerbSocketDir(stateDir()), verbPeerReader)}`);
4414
-
4415
- // Recorded here, before a single tick runs, so that the deploy an operator
4416
- // *means* to do never trips the tripwire: installing a new build and
4417
- // restarting the unit re-records this from the new files. What it catches is
4418
- // the other thing — the package changing while the daemon that dispatches
4419
- // work is holding it open, whether that is a worker that wandered out of its
4420
- // worktree or a human editing the live install "just to test something".
4545
+ const verbDir = ensureVerbSocketDir(stateDir());
4421
4546
  const integrity: IntegrityGate = { baseline: packageManifest(), paged: false };
4547
+ const usage = sharedUsageSource();
4548
+ const turnLimits = createTurnLimitRegistry((runId, maxTurns) => {
4549
+ store.updateRun(runId, { maxTurns });
4550
+ });
4551
+ const workerControls = createWorkerControlRegistry();
4552
+ const workers = createWorkerPool();
4553
+ const alive = livingDaemon();
4554
+ const runtimes: ProjectRuntime[] = [];
4555
+
4556
+ log(`verb transport: ${transportBanner(verbDir, verbPeerReader)}`);
4422
4557
  log(`package integrity baseline: ${integrity.baseline.size} files under ${import.meta.dir}`);
4423
4558
 
4424
- // Before the first tick, settle what the last process left behind — unless
4425
- // another daemon is alive (a foreground `daemon --once` beside a running
4426
- // daemon must not orphan that daemon's real, live workers).
4427
- const alive = livingDaemon();
4428
- if (alive === undefined || alive.pid === process.pid) {
4429
- // The orphan path salvages, and #121 requires that salvage to reach
4430
- // GitHub. Since the run's commits now live in its own repository, the hop
4431
- // is the daemon's: resolve the routed repo by name off the run row.
4432
- const orphanPublisher = (r: RunRecord): RunPublisher => {
4433
- const repo = project.routing.repos[r.repo];
4434
- return async (branch) =>
4435
- repo === undefined
4436
- ? { ok: false, stderr: `run ${String(r.id)} names repo "${r.repo}", which this project no longer routes` }
4437
- : pushRunBranch(project, { repo, runRepoPath: r.worktree, branch });
4559
+ for (const project of projects) {
4560
+ const projectLog = (message: string): void => {
4561
+ log(projects.length === 1 ? message : `[${project.name}] ${message}`);
4438
4562
  };
4439
- for (const r of await reconcileOrphanedRuns(store, project.name, orphanPublisher)) {
4440
- log(
4441
- `#${r.issue} orphaned by a previous daemon (attempt ${r.attempt}, was ${r.state}, worktree ${r.worktree}) — ` +
4442
- `slot freed; the ${project.stateLabels.inProgress} label stays until the orchestrator triages what the worker left`,
4443
- );
4563
+ const caps = resolveCaps(project, cfg.defaults);
4564
+ const tracker = makeTracker(project, undefined, {
4565
+ onCall: () => store.bumpGhCalls?.(utcDay(), "daemon"),
4566
+ onRefusal: (at) => store.recordGhRefusal?.(at),
4567
+ onNotModified: () => store.bumpGhCalls?.(utcDay(), "daemon-304"),
4568
+ });
4569
+ const verbActions = githubVerbActions(project);
4570
+
4571
+ if (alive === undefined || alive.pid === process.pid) {
4572
+ const orphanPublisher = (run: RunRecord): RunPublisher => {
4573
+ const repo = project.routing.repos[run.repo];
4574
+ return async (branch) =>
4575
+ repo === undefined
4576
+ ? {
4577
+ ok: false,
4578
+ stderr:
4579
+ `run ${String(run.id)} names repo "${run.repo}", ` +
4580
+ `which project "${project.name}" no longer routes`,
4581
+ }
4582
+ : pushRunBranch(project, { repo, runRepoPath: run.worktree, branch });
4583
+ };
4584
+ for (const run of await reconcileOrphanedRuns(store, project.name, orphanPublisher)) {
4585
+ projectLog(
4586
+ `#${run.issue} orphaned by a previous daemon (attempt ${run.attempt}, was ${run.state}, ` +
4587
+ `worktree ${run.worktree}) — slot freed; the ${project.stateLabels.inProgress} label stays ` +
4588
+ "until the orchestrator triages what the worker left",
4589
+ );
4590
+ }
4591
+ } else if (runtimes.length === 0) {
4592
+ log(`skipping orphan reconciliation: daemon pid ${alive.pid} is alive and owns the active runs`);
4444
4593
  }
4445
- } else {
4446
- log(`skipping orphan reconciliation: daemon pid ${alive.pid} is alive and owns the active runs`);
4447
- }
4448
4594
 
4449
- // Standing orders. The orchestrator holds none of this file's context, so
4450
- // everything it needs to act — which tracker, which labels, what the fleet
4451
- // does has to be said once, in words.
4452
- const releaseGrants = resolveReleaseGrants(project);
4453
- const grantedShapes = RELEASE_SHAPES.filter((shape) => releaseGrants[shape] === "orchestrator");
4454
- const deniedShapes = RELEASE_SHAPES.filter((shape) => releaseGrants[shape] !== "orchestrator");
4455
- const brief = [
4456
- `You are the omp-conductor orchestrator for project "${project.name}".`,
4457
- `Tracker: ${project.tracker.repo}. Pass --repo ${project.tracker.repo} to every gh command:`,
4458
- "this working directory is the conductor's state directory, not a checkout.",
4459
- `Labels: queue=${project.queueLabel}, running=${project.stateLabels.inProgress}, ` +
4460
- `blocked=${project.stateLabels.blocked}, failed=${project.stateLabels.failed}.`,
4461
- "The dispatcher claims queue-labelled issues, runs one worker session per attempt in its own",
4462
- "worktree under hard turn/wallclock/spend caps, and escalates to you when a worker blocks or",
4463
- "fails twice, its gates stay red, its branch conflicts, or a tripwire fires.",
4464
- "Your job when that happens: re-brief the issue (comment what the next worker must do",
4465
- `differently, then put ${project.queueLabel} back on it), file follow-up issues, or promote to`,
4466
- "tier 2 and let the human decide.",
4467
- // Worded from `authority.merge` rather than fixed, so the standing orders
4468
- // and the Releases section of the rendered brief cannot disagree about who
4469
- // is holding the merge button. The daemon still merges nothing itself.
4470
- project.authority.merge === "orchestrator"
4471
- ? "You never edit product code or push a branch — a worker session does that. Merging is yours: one PR at " +
4472
- "a time, freshness-checked against the base branch, per the Releases section of your POLICY.md."
4473
- : "You never edit product code, push a branch, or merge a PR — a worker session edits and pushes, and a " +
4474
- "human merges.",
4475
- // Named shape by shape rather than as one policy word, so a stale grant is
4476
- // legible in the transcript instead of only in the config file — the #122
4477
- // incident began with a grant that no longer matched anyone's intent.
4478
- `Release tool gate: ${
4479
- grantedShapes.length === 0
4480
- ? "every release and deploy shape is mechanically blocked for you"
4481
- : `you may invoke ${grantedShapes.join(", ")} — and only by the procedure in your POLICY.md`
4482
- }.` + (deniedShapes.length === 0 ? "" : ` Blocked: ${deniedShapes.join(", ")}.`),
4483
- "Handle each escalation below before the next one.",
4484
- ].join("\n");
4485
-
4486
- // One orchestrator per daemon run, not per tick: it is a persistent session
4487
- // whose whole value is remembering what it has already escalated, and a fresh
4488
- // one every five minutes would remember nothing. Its cwd is the state
4489
- // directory, deliberately not a checkout — the orchestrator re-briefs workers
4490
- // and talks to the tracker, it does not edit product code.
4491
- let orchestrator: OrchestratorHandle | undefined;
4492
- if (project.escalation.orchestrator === "external") {
4493
- // An operator already runs the brain — typically a visible TUI session that
4494
- // drains `blocked`/`failed` off the tracker as one of its standing duties.
4495
- // Starting a second one here would re-triage the same issues from a
4496
- // transcript nobody is watching, and the two would undo each other.
4497
- log("orchestrator: external — tier-1 escalations post as issue comments for the external session's drain duty");
4498
- } else {
4499
- try {
4500
- // The orchestrator is a child process of this daemon running as its own
4501
- // user. Nothing mechanically stops it reading a run checkout — what holds
4502
- // it is its brief and the verb ledger (#143).
4503
- const orchTreeRoot = stateDir();
4504
- const orchCwd = join(orchTreeRoot, "orchestrator");
4505
- mkdirSync(orchCwd, { recursive: true });
4506
- // A third socket, distinct from every run's, in the same daemon-owned
4507
- // 0711 parent. It makes the orchestrator's authority a property of the
4508
- // channel rather than of a payload: no argument list can move a worker's
4509
- // call onto this one (#126).
4510
- //
4511
- // It does NOT authenticate the session. Sessions share this daemon's uid,
4512
- // so one could list this directory and connect here, and the ledger would
4513
- // record the orchestrator's role because that is the channel's. See
4514
- // conductor#163 for the pid binding that would close it.
4515
- orchestratorVerbs = await listenVerbChannel(
4516
- verbDeps({ project, store, tracker, verbActions }),
4517
- {
4518
- kind: "orchestrator",
4519
- path: verbSocketPath(ensureVerbSocketDir(orchTreeRoot), "orchestrator"),
4520
- project: project.name,
4521
- role: "orchestrator",
4522
- },
4523
- { ...(verbPeerReader === undefined ? {} : { peerReader: verbPeerReader }) },
4524
- );
4525
- orchestrator = await startOrchestrator({
4526
- cwd: orchCwd,
4527
- brief,
4528
- releaseGrants,
4529
- socketPath: join(orchCwd, "ipc.sock"),
4530
- verbSocketPath: orchestratorVerbs.path,
4531
- // The orchestrator's channel is the one that matters most: it is the
4532
- // session authorised to merge, so an unbound channel here is a worker's
4533
- // route to that authority (#163).
4534
- onSpawn: (pid) => {
4535
- orchestratorVerbs?.bindPid(pid);
4536
- },
4537
- onChildLog: (line) => {
4538
- log(`orchestrator ${line}`);
4539
- },
4540
- onReleaseBlocked: (shape, context) =>
4541
- recordReleaseBlock(project.name, "orchestrator", shape, context),
4542
- });
4543
- const transcript = orchestrator.sessionFile();
4544
- log(`orchestrator session ready${transcript === undefined ? "" : ` · ${transcript}`}`);
4545
- } catch (err) {
4546
- // Loudly, but not fatally: tier-1 escalations degrade to issue comments,
4547
- // which a human still reads. A dispatcher that refuses to run because its
4548
- // re-briefing channel is down helps nobody.
4549
- log(
4550
- `WARNING: orchestrator session failed to start; tier-1 escalations will fall back to issue comments: ${errText(err)}`,
4595
+ const { brief, releaseGrants } = orchestratorStandingOrders(project);
4596
+ let orchestrator: OrchestratorHandle | undefined;
4597
+ let orchestratorVerbs: VerbListener | undefined;
4598
+ if (project.escalation.orchestrator === "external") {
4599
+ projectLog(
4600
+ "orchestrator: external tier-1 escalations post as issue comments for the external session's drain duty",
4551
4601
  );
4602
+ } else {
4603
+ try {
4604
+ const orchCwd = join(
4605
+ stateDir(),
4606
+ projects.length === 1 ? "orchestrator" : `orchestrator-${project.name}`,
4607
+ );
4608
+ mkdirSync(orchCwd, { recursive: true });
4609
+ orchestratorVerbs = await listenVerbChannel(
4610
+ verbDeps({ project, store, tracker, verbActions }),
4611
+ {
4612
+ kind: "orchestrator",
4613
+ path: verbSocketPath(verbDir, `orchestrator-${project.name}`),
4614
+ project: project.name,
4615
+ role: "orchestrator",
4616
+ },
4617
+ { ...(verbPeerReader === undefined ? {} : { peerReader: verbPeerReader }) },
4618
+ );
4619
+ orchestrator = await startOrchestrator({
4620
+ cwd: orchCwd,
4621
+ brief,
4622
+ releaseGrants,
4623
+ socketPath: join(orchCwd, "ipc.sock"),
4624
+ verbSocketPath: orchestratorVerbs.path,
4625
+ onSpawn: (pid) => {
4626
+ orchestratorVerbs?.bindPid(pid);
4627
+ },
4628
+ onChildLog: (line) => {
4629
+ projectLog(`orchestrator ${line}`);
4630
+ },
4631
+ onReleaseBlocked: (shape, context) =>
4632
+ recordReleaseBlock(project.name, "orchestrator", shape, context),
4633
+ });
4634
+ const transcript = orchestrator.sessionFile();
4635
+ projectLog(`orchestrator session ready${transcript === undefined ? "" : ` · ${transcript}`}`);
4636
+ } catch (err) {
4637
+ projectLog(
4638
+ "WARNING: orchestrator session failed to start; tier-1 escalations will fall back to issue " +
4639
+ `comments: ${errText(err)}`,
4640
+ );
4641
+ await orchestratorVerbs?.close();
4642
+ orchestratorVerbs = undefined;
4643
+ }
4552
4644
  }
4553
- }
4554
-
4555
- let runtimeDeps: Deps | undefined;
4556
- const currentProject = (): ProjectConfig => runtimeDeps?.project ?? project;
4557
- const deliveryPolicyValid = (): boolean => runtimeDeps?.deliveryPolicyValid === true;
4558
- const escalator = createEscalator(
4559
- currentProject,
4560
- tracker,
4561
- store,
4562
- orchestrator,
4563
- Date.now,
4564
- deliveryPolicyValid,
4565
- );
4566
4645
 
4567
- // Report delivery is the daemon's, not the model's (#123). Built beside the
4568
- // escalator because a report nobody can deliver pages through it, and driven
4569
- // on its own timer rather than inside `tick()`: a paused fleet claims nothing
4570
- // but still owes its operator the report it was handed, and five minutes is a
4571
- // long time to sit on a page.
4572
- const outbox = createReportOutbox({
4573
- project: currentProject,
4574
- store,
4575
- escalate: (e) => escalator.escalate(e),
4576
- log,
4577
- deliveryAllowed: deliveryPolicyValid,
4578
- });
4579
-
4580
- // Every row still `sending` when a daemon boots belonged to a process that is
4581
- // gone, so its outcome will never be learned — retry it and say so in the
4582
- // message. Guarded exactly like the orphan reconciliation above and for the
4583
- // same reason: a row belonging to a *live* daemon is genuinely in flight, and
4584
- // stealing it would page the operator twice for one report.
4585
- if (alive === undefined || alive.pid === process.pid) {
4586
- for (const r of outbox.recover(Date.now())) {
4587
- log(
4588
- `report ${r.id} was left mid-send by a previous daemon (attempt ${r.attempts}) — ` +
4589
- `retrying; its message will say it may be a repeat`,
4590
- );
4646
+ let runtimeDeps: Deps | undefined;
4647
+ const currentProject = (): ProjectConfig => runtimeDeps?.project ?? project;
4648
+ const deliveryPolicyValid = (): boolean => runtimeDeps?.deliveryPolicyValid === true;
4649
+ const escalator = createEscalator(
4650
+ currentProject,
4651
+ tracker,
4652
+ store,
4653
+ orchestrator,
4654
+ Date.now,
4655
+ deliveryPolicyValid,
4656
+ );
4657
+ const outbox = createReportOutbox({
4658
+ project: currentProject,
4659
+ store,
4660
+ escalate: (event) => escalator.escalate(event),
4661
+ log: projectLog,
4662
+ deliveryAllowed: deliveryPolicyValid,
4663
+ });
4664
+ if (alive === undefined || alive.pid === process.pid) {
4665
+ for (const report of outbox.recover(Date.now())) {
4666
+ projectLog(
4667
+ `report ${report.id} was left mid-send by a previous daemon (attempt ${report.attempts}) — ` +
4668
+ "retrying; its message will say it may be a repeat",
4669
+ );
4670
+ }
4591
4671
  }
4592
- }
4593
4672
 
4594
- const turnLimits = createTurnLimitRegistry((runId, maxTurns) => {
4595
- store.updateRun(runId, { maxTurns });
4596
- });
4597
- const workerControls = createWorkerControlRegistry();
4598
- const d: Deps = {
4599
- project,
4600
- caps,
4601
- tracker,
4602
- store,
4603
- // Fail closed until the first tick re-reads and validates the live config.
4604
- deliveryPolicyValid: false,
4605
- // Process-wide, so a `status` served off this daemon's own HTTP surface
4606
- // reuses the tick's reading instead of shelling out again.
4607
- usage: sharedUsageSource(),
4608
- escalate: (e) => escalator.escalate(e),
4609
- turnLimits,
4610
- workerControls,
4611
- integrity,
4612
- // Fresh per daemon run, like the integrity gate: a restart is entitled to
4613
- // page again about a stall that is still on disk.
4614
- stall: { paged: false },
4615
- cleanup: { next: 0 },
4616
- ...(verbPeerReader === undefined ? {} : { verbPeerReader }),
4617
- verbActions,
4618
- };
4619
- runtimeDeps = d;
4673
+ const d: Deps = {
4674
+ project,
4675
+ caps,
4676
+ tracker,
4677
+ store,
4678
+ deliveryPolicyValid: false,
4679
+ usage,
4680
+ escalate: (event) => escalator.escalate(event),
4681
+ turnLimits,
4682
+ workerControls,
4683
+ integrity,
4684
+ stall: { paged: false },
4685
+ cleanup: { next: 0 },
4686
+ ...(verbPeerReader === undefined ? {} : { verbPeerReader }),
4687
+ verbActions,
4688
+ };
4689
+ runtimeDeps = d;
4690
+ runtimes.push({
4691
+ d,
4692
+ outbox,
4693
+ ...(orchestrator === undefined ? {} : { orchestrator }),
4694
+ ...(orchestratorVerbs === undefined ? {} : { orchestratorVerbs }),
4695
+ codeGraph: pendingCodeGraph(project),
4696
+ });
4697
+ }
4620
4698
 
4621
4699
  if (o.once) {
4622
4700
  try {
4623
- await tick(d);
4624
- // A single tick still owes the outbox a pass: a drill that leaves a
4625
- // report undelivered teaches an operator the wrong thing about the
4626
- // mechanism it is drilling.
4627
- await outbox.deliverDue();
4701
+ for (const runtime of runtimes) await tick(runtime.d, workers);
4702
+ await workers.drain();
4703
+ for (const runtime of runtimes) await runtime.outbox.deliverDue();
4628
4704
  } finally {
4629
- await orchestrator?.dispose();
4630
- await orchestratorVerbs?.close();
4705
+ for (const runtime of runtimes.toReversed()) {
4706
+ await runtime.orchestrator?.dispose();
4707
+ await runtime.orchestratorVerbs?.close();
4708
+ }
4631
4709
  store.close();
4632
4710
  }
4633
4711
  return;
4634
4712
  }
4635
4713
 
4636
- // Graphs are optional, so their bounded probes run beside dispatch and feed
4637
- // a cache. /healthz remains an in-memory answer and never blocks liveness on
4638
- // the indexer or systemd.
4639
- let codeGraph = pendingCodeGraph(project);
4640
- let graphProbe: Promise<void> | undefined;
4641
- const refreshCodeGraph = (): void => {
4642
- if (graphProbe !== undefined) return;
4643
- graphProbe = probeCodeGraph(project)
4714
+ const refreshCodeGraph = (runtime: ProjectRuntime): void => {
4715
+ if (runtime.graphProbe !== undefined) return;
4716
+ runtime.graphProbe = probeCodeGraph(runtime.d.project)
4644
4717
  .then((health) => {
4645
- codeGraph = health;
4718
+ runtime.codeGraph = health;
4646
4719
  })
4647
4720
  .catch(() => {
4648
- log("code-graph health probe failed unexpectedly; retaining the previous bounded result");
4721
+ log(
4722
+ `[${runtime.d.project.name}] code-graph health probe failed unexpectedly; ` +
4723
+ "retaining the previous bounded result",
4724
+ );
4649
4725
  })
4650
4726
  .finally(() => {
4651
- graphProbe = undefined;
4727
+ runtime.graphProbe = undefined;
4652
4728
  });
4653
4729
  };
4654
- refreshCodeGraph();
4655
- const graphTimer = setInterval(refreshCodeGraph, GRAPH_HEALTH_INTERVAL_MS);
4656
-
4657
- // Overlap-guarded like the graph probe: a pass that is still waiting on
4658
- // Telegram must not have a second pass started on top of it, or one report
4659
- // would be claimed, reclaimed and sent twice by this process alone.
4660
- let reportPass: Promise<void> | undefined;
4661
- const drainReports = (): void => {
4662
- if (reportPass !== undefined) return;
4663
- reportPass = outbox
4730
+ for (const runtime of runtimes) refreshCodeGraph(runtime);
4731
+ const graphTimer = setInterval(() => {
4732
+ for (const runtime of runtimes) refreshCodeGraph(runtime);
4733
+ }, GRAPH_HEALTH_INTERVAL_MS);
4734
+
4735
+ const drainReports = (runtime: ProjectRuntime): void => {
4736
+ if (runtime.reportPass !== undefined) return;
4737
+ runtime.reportPass = runtime.outbox
4664
4738
  .deliverDue()
4665
4739
  .then(() => {})
4666
4740
  .catch((err: unknown) => {
4667
- log(`report delivery pass failed: ${errText(err)}`);
4741
+ log(`[${runtime.d.project.name}] report delivery pass failed: ${errText(err)}`);
4668
4742
  })
4669
4743
  .finally(() => {
4670
- reportPass = undefined;
4744
+ runtime.reportPass = undefined;
4671
4745
  });
4672
4746
  };
4673
- drainReports();
4674
- const reportTimer = setInterval(drainReports, REPORT_DELIVERY_INTERVAL_MS);
4747
+ for (const runtime of runtimes) drainReports(runtime);
4748
+ const reportTimer = setInterval(() => {
4749
+ for (const runtime of runtimes) drainReports(runtime);
4750
+ }, REPORT_DELIVERY_INTERVAL_MS);
4675
4751
 
4676
- const workers = createWorkerPool();
4677
4752
  let stopping = false;
4678
4753
  let wake: (() => void) | undefined;
4679
4754
  const stop = (): void => {
@@ -4685,54 +4760,58 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
4685
4760
  process.on("SIGINT", stop);
4686
4761
  process.on("SIGTERM", stop);
4687
4762
 
4688
- // ── NO TRACKER OR REPOSITORY MUTATION BELONGS ON THIS PORT ───────────────
4689
- //
4690
- // This is unauthenticated loopback TCP. Every local user can reach it, it
4691
- // carries no credential of any kind, and it cannot tell one caller from
4692
- // another: `127.0.0.1` is not an identity. The turn-limit and worker
4693
- // pause/resume controls trust a body-supplied `project`, which is exactly the
4694
- // shape "identity from the payload" takes when nobody is watching. They stay
4695
- // tolerable only because every effect is bounded: pauses are reversible,
4696
- // live extensions touch one controller, and a persisted next-attempt
4697
- // override can only raise the project base up to its configured ceiling and
4698
- // is consumed by one claim.
4699
- //
4700
- // A merge, a push, a release or a label is none of those things. Do not add
4701
- // one here, and do not add "just a small one" behind a shared secret either:
4702
- // a secret readable by the process that would be attacking you is not
4703
- // authentication. Mutations go over the per-run unix sockets in
4704
- // `verbs/socket.ts`, where the kernel says who the caller is and the daemon
4705
- // derives project, run and role from the channel rather than the body (#126).
4763
+ // This unauthenticated loopback surface exposes liveness and bounded live-run
4764
+ // controls only. Repository and tracker mutations stay on credentialled verb
4765
+ // sockets, where project and role come from the channel rather than a payload.
4706
4766
  const server = Bun.serve({
4707
4767
  hostname: "127.0.0.1",
4708
4768
  port: o.port ?? DEFAULT_PORT,
4709
4769
  fetch: (req) =>
4710
4770
  daemonHttpResponse(req, {
4711
- project: project.name,
4712
- store,
4713
- caps: () => d.caps,
4771
+ projects: runtimes.map((runtime) => ({
4772
+ project: runtime.d.project.name,
4773
+ store,
4774
+ caps: () => runtime.d.caps,
4775
+ })),
4714
4776
  turnLimits,
4715
4777
  workerControls,
4716
4778
  health: () =>
4717
- daemonHealthSnapshot(store, project.name, undefined, undefined, codeGraph, workerControls),
4779
+ daemonHealth(
4780
+ runtimes.map((runtime) =>
4781
+ daemonHealthSnapshot(
4782
+ store,
4783
+ runtime.d.project.name,
4784
+ isPaused(runtime.d.project.name),
4785
+ runtime.codeGraph,
4786
+ workerControls,
4787
+ ),
4788
+ ),
4789
+ ),
4718
4790
  }),
4719
4791
  });
4720
- log(`serving /healthz on :${server.port}, project ${project.name}`);
4792
+ log(
4793
+ projects.length === 1
4794
+ ? `serving /healthz on :${server.port}, project ${projects[0]!.name}`
4795
+ : `serving /healthz on :${server.port}, projects ${projects.map(({ name }) => name).join(", ")}`,
4796
+ );
4721
4797
 
4722
4798
  try {
4723
4799
  while (!stopping) {
4724
- try {
4725
- await tick(d, workers);
4726
- } catch (err) {
4727
- // A tick that blows up outside an issue (the tracker is down, say) must
4728
- // not end the daemon; the next one will retry.
4729
- log(`tick failed: ${errText(err)}`);
4800
+ for (const runtime of runtimes) {
4801
+ try {
4802
+ await tick(runtime.d, workers);
4803
+ } catch (err) {
4804
+ log(
4805
+ `${projects.length === 1 ? "" : `[${runtime.d.project.name}] `}` +
4806
+ `tick failed: ${errText(err)}`,
4807
+ );
4808
+ }
4730
4809
  }
4731
4810
  if (stopping) break;
4732
4811
  await new Promise<void>((resolve) => {
4733
- const t = setTimeout(resolve, TICK_INTERVAL_MS);
4812
+ const timer = setTimeout(resolve, TICK_INTERVAL_MS);
4734
4813
  wake = () => {
4735
- clearTimeout(t);
4814
+ clearTimeout(timer);
4736
4815
  resolve();
4737
4816
  };
4738
4817
  });
@@ -4743,26 +4822,15 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
4743
4822
  process.off("SIGTERM", stop);
4744
4823
  clearInterval(graphTimer);
4745
4824
  clearInterval(reportTimer);
4746
- // Before the store closes, like the orchestrator below: a pass mid-send has
4747
- // a `markReportDelivered` still to write, and losing that write is exactly
4748
- // how a delivered report comes back as an ambiguous one on the next boot.
4749
- await reportPass;
4825
+ for (const runtime of runtimes) await runtime.reportPass;
4750
4826
  await workers.drain();
4751
4827
  await server.stop(true);
4752
- // Before the store closes: a queued injection that rejects on the way out
4753
- // falls back to an issue comment, and that path writes the dedup marker.
4754
- await orchestrator?.dispose();
4755
- // After the session it belongs to is gone. A bound socket outliving its
4756
- // orchestrator is a channel accepting merges for a session that no longer
4757
- // exists.
4758
- await orchestratorVerbs?.close();
4828
+ for (const runtime of runtimes.toReversed()) {
4829
+ await runtime.orchestrator?.dispose();
4830
+ await runtime.orchestratorVerbs?.close();
4831
+ }
4759
4832
  store.close();
4760
4833
  log("stopped");
4761
- // A handled SIGTERM still leaves some runtimes with a non-zero default
4762
- // (historically 128+signal). Under systemd `Restart=on-failure` that looks
4763
- // like a crash and the unit comes straight back — the exact failure mode
4764
- // `omp-conductor stop` hit on the reference fleet. Force success so a
4765
- // graceful drain is not a restart.
4766
4834
  process.exitCode = 0;
4767
4835
  }
4768
4836
  }