omp-conductor 0.19.7 → 0.20.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.
Files changed (69) hide show
  1. package/REFERENCE.md +10 -1
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7923
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/failure-class.ts +75 -1
  49. package/src/fleet.ts +290 -164
  50. package/src/groom.ts +461 -0
  51. package/src/http-token.ts +142 -0
  52. package/src/knowledge.ts +229 -0
  53. package/src/mining.ts +316 -0
  54. package/src/orchestrator-tick.ts +428 -1681
  55. package/src/ready-gate.ts +267 -0
  56. package/src/settlement.ts +72 -6
  57. package/src/setup-host.ts +32 -9
  58. package/src/setup-wizard.ts +55 -7
  59. package/src/setup.ts +229 -3
  60. package/src/stats.ts +257 -2
  61. package/src/status-render.ts +158 -7
  62. package/src/store.ts +604 -26
  63. package/src/to-spec.ts +194 -21
  64. package/src/tracker/github.ts +50 -0
  65. package/src/types.ts +416 -15
  66. package/src/verbs/protocol.ts +28 -0
  67. package/src/verbs/server.ts +330 -39
  68. package/src/wake.ts +19 -2
  69. package/src/worker.ts +456 -1
@@ -0,0 +1,736 @@
1
+ /**
2
+ * The daemon process itself: what `omp-conductor daemon` actually runs.
3
+ *
4
+ * Everything in this directory above it is a pure-ish pass over a `Deps`.
5
+ * This is where the real ones are built — the tracker, the store, the verb
6
+ * socket, the orchestrator session, the pool and the registries — and where the
7
+ * only long-lived state in the dispatcher lives: `Bun.serve`, the timers, the
8
+ * integrity baseline recorded at boot, and the shutdown that has to drain them
9
+ * all in the right order.
10
+ *
11
+ * Keeping it last and separate is what lets every pass be tested without a
12
+ * process. Nothing here is called by a pass; the arrow runs one way, from the
13
+ * runtime into the passes, which is also why `runDaemon` is the only place a
14
+ * dep is wired to its production implementation.
15
+ */
16
+ import { mkdirSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { findProject, loadConfig, resolveCaps, resolveReleaseGrants, resolveSharedInstallAuthority, stateDir } from "../config.ts";
19
+ import { probeCredentialClass } from "../credential-class.ts";
20
+ import { createEscalator, escalationIssueRef } from "../escalate.ts";
21
+ import { releaseOrphanedWorkerPane } from "../fleet.ts";
22
+ import { probeCriticalBase, probeRunLane, pushRunBranch } from "../gitops.ts";
23
+ import { pendingCodeGraph, probeCodeGraph, type CodeGraphHealth } from "../graph-health.ts";
24
+ import { ensureHttpToken } from "../http-token.ts";
25
+ import { acquireOnceLease, livingDaemon } from "../lifecycle.ts";
26
+ import { errText, log } from "../log.ts";
27
+ import { reconcileOrchestratorDown } from "../orchestrator-down.ts";
28
+ import { startOrchestrator, type OrchestratorHandle } from "../orchestrator.ts";
29
+ import { isPaused } from "../pause.ts";
30
+ import { recordReleaseBlock } from "../release-policy.ts";
31
+ import { createReportOutbox, type ReportOutbox } from "../reports.ts";
32
+ import { reconcileOrphanedRuns } from "../settlement.ts";
33
+ import { dbPath, openStore, utcDay } from "../store.ts";
34
+ import { checkTelegramFreshness } from "../telegram-freshness.ts";
35
+ import { GraphqlBreaker, makeTracker } from "../tracker/github.ts";
36
+ import { RELEASE_SHAPES, type ProjectConfig, type ResolvedGrants, type RunRecord } from "../types.ts";
37
+ import { runCommand } from "../upgrade-verify.ts";
38
+ import { inspectSurfaces } from "../upgrade.ts";
39
+ import { sharedUsageSource } from "../usage.ts";
40
+ import { githubVerbActions } from "../verbs/actions.ts";
41
+ import { listenVerbChannel, type VerbListener } from "../verbs/server.ts";
42
+ import { ensureVerbSocketDir, peerCredentialReader, transportBanner, verbSocketPath } from "../verbs/socket.ts";
43
+ import type { RunPublisher } from "../worktree.ts";
44
+ import { createWorkerPool } from "./admission-pass.ts";
45
+ import { DEFAULT_PORT, GRAPH_HEALTH_INTERVAL_MS, PACKAGE_SRC_DIR, REPORT_DELIVERY_INTERVAL_MS, TICK_INTERVAL_MS, verbDeps, type DaemonOpts, type Deps, type DrainSignal, type IntegrityGate } from "./deps.ts";
46
+ import { createTurnLimitRegistry, createWorkerControlRegistry } from "./dispatch.ts";
47
+ import { daemonHttpResponse } from "./http.ts";
48
+ import { packageManifest } from "./integrity.ts";
49
+ import { workspaceOwnership } from "./panes.ts";
50
+ import { reconcileCrashedReviewRevisions } from "./review.ts";
51
+ import { reconcileHistoricalInfra } from "./settle-pass.ts";
52
+ import { tick } from "./tick.ts";
53
+ import { daemonHealth, daemonHealthSnapshot } from "./views.ts";
54
+
55
+ export interface ProjectRuntime {
56
+ d: Deps;
57
+ outbox: ReportOutbox;
58
+ orchestrator?: OrchestratorHandle;
59
+ orchestratorVerbs?: VerbListener;
60
+ codeGraph: CodeGraphHealth;
61
+ graphProbe?: Promise<void>;
62
+ reportPass?: Promise<void>;
63
+ }
64
+
65
+ export function orchestratorStandingOrders(
66
+ project: ProjectConfig,
67
+ projects: readonly ProjectConfig[],
68
+ ): {
69
+ brief: string;
70
+ releaseGrants: ResolvedGrants;
71
+ } {
72
+ const installAuthority = resolveSharedInstallAuthority(projects);
73
+ const releaseGrants = {
74
+ ...resolveReleaseGrants(project),
75
+ install: installAuthority.holder ?? "human",
76
+ };
77
+ const grantedShapes = RELEASE_SHAPES.filter((shape) => releaseGrants[shape] === "orchestrator");
78
+ const deniedShapes = RELEASE_SHAPES.filter((shape) => releaseGrants[shape] !== "orchestrator");
79
+ return {
80
+ releaseGrants,
81
+ brief: [
82
+ `You are the omp-conductor orchestrator for project "${project.name}".`,
83
+ `Tracker: ${project.tracker.repo}. Pass --repo ${project.tracker.repo} to every gh command:`,
84
+ "this working directory is the conductor's state directory, not a checkout.",
85
+ `Labels: queue=${project.queueLabel}, running=${project.stateLabels.inProgress}, ` +
86
+ `blocked=${project.stateLabels.blocked}, failed=${project.stateLabels.failed}.`,
87
+ "The dispatcher claims queue-labelled issues, runs one worker session per attempt in its own",
88
+ "worktree under hard turn/wallclock/spend caps, and escalates to you when a worker blocks or",
89
+ "fails twice, its gates stay red, its branch conflicts, or a tripwire fires.",
90
+ "Your job when that happens: re-brief the issue (comment what the next worker must do",
91
+ `differently, then put ${project.queueLabel} back on it), file follow-up issues, or promote to`,
92
+ "tier 2 and let the human decide.",
93
+ project.authority.merge === "orchestrator"
94
+ ? "You never edit product code or push a branch — a worker session does that. Merging is yours: one PR at " +
95
+ "a time, freshness-checked against the base branch, per the Releases section of your POLICY.md."
96
+ : "You never edit product code, push a branch, or merge a PR — a worker session edits and pushes, and a " +
97
+ "human merges.",
98
+ `Release tool gate: ${
99
+ grantedShapes.length === 0
100
+ ? "every release and deploy shape is mechanically blocked for you"
101
+ : `you may invoke ${grantedShapes.join(", ")} — and only by the procedure in your POLICY.md`
102
+ }.` + (deniedShapes.length === 0 ? "" : ` Blocked: ${deniedShapes.join(", ")}.`),
103
+ ...(installAuthority.holder === undefined
104
+ ? [
105
+ `Host-global install authority conflicts: ${installAuthority.entries
106
+ .map((entry) => `${entry.project}=${entry.holder}`)
107
+ .join(", ")}. Install is blocked.`,
108
+ ]
109
+ : []),
110
+ "Handle each escalation below before the next one.",
111
+ ].join("\n"),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Pacing for the resident dispatch loop, interruptible so an operator action —
117
+ * `resume` — does not wait out the full interval (#380).
118
+ *
119
+ * `requestWake` is sticky until a pass absorbs it: while the long sleep is in
120
+ * flight it is cut short, and when a pass is mid-flight the loop skips the
121
+ * *following* sleep. Either way the next pass starts without the interval
122
+ * wait. The single boolean coalesces any number of near-simultaneous wakes
123
+ * into one prompt pass, and the strictly sequential loop means a wake can
124
+ * never start a pass on top of one already running.
125
+ */
126
+ export interface DispatchPace {
127
+ /** Ask for a prompt pass; cuts an in-flight sleep short. Coalesces. */
128
+ requestWake(): void;
129
+ /** Absorb a pending request — true when the pass that just finished should
130
+ * skip its sleep. */
131
+ takeWake(): boolean;
132
+ /** The long interval sleep; resolves early when {@link requestWake} fires. */
133
+ sleep(ms: number): Promise<void>;
134
+ }
135
+
136
+ export function createDispatchPace(): DispatchPace {
137
+ let pendingWake = false;
138
+ let interruptSleep: (() => void) | undefined;
139
+ return {
140
+ requestWake() {
141
+ pendingWake = true;
142
+ interruptSleep?.();
143
+ },
144
+ takeWake() {
145
+ const pending = pendingWake;
146
+ pendingWake = false;
147
+ return pending;
148
+ },
149
+ async sleep(ms) {
150
+ // A wake that landed in the gap between the loop's takeWake check and
151
+ // this call is absorbed here: skip the interval, and consume the flag
152
+ // so the pass that follows it is the only prompt one.
153
+ if (pendingWake) {
154
+ pendingWake = false;
155
+ return;
156
+ }
157
+ await new Promise<void>((resolve) => {
158
+ const timer = setTimeout(resolve, ms);
159
+ interruptSleep = () => {
160
+ // The interruption *is* the absorption: the pass that follows the
161
+ // sleep is the prompt pass this wake asked for, so the loop sleeps
162
+ // normally after it instead of running a second immediate pass.
163
+ pendingWake = false;
164
+ clearTimeout(timer);
165
+ resolve();
166
+ };
167
+ // A wake can land between the loop's takeWake check and this
168
+ // constructor; resolve immediately instead of letting it wait out
169
+ // the full interval.
170
+ if (pendingWake) interruptSleep();
171
+ });
172
+ interruptSleep = undefined;
173
+ },
174
+ };
175
+ }
176
+
177
+ export interface DispatchLoopOptions {
178
+ /** One dispatch pass over every configured project runtime. */
179
+ tick(): Promise<void>;
180
+ /** True once a stop is requested; checked between passes. */
181
+ shouldStop(): boolean;
182
+ pace: DispatchPace;
183
+ /** The scheduled interval between passes (`TICK_INTERVAL_MS` in production). */
184
+ tickIntervalMs: number;
185
+ }
186
+
187
+ /**
188
+ * The resident daemon's dispatch loop: one pass immediately, then a
189
+ * `tickIntervalMs` sleep the {@link DispatchPace} can cut short. Exported so
190
+ * the resume-wake behaviour can be pinned deterministically without a live
191
+ * daemon (#380).
192
+ */
193
+ export async function runDispatchLoop(o: DispatchLoopOptions): Promise<void> {
194
+ const { tick, shouldStop, pace, tickIntervalMs } = o;
195
+ while (!shouldStop()) {
196
+ await tick();
197
+ if (shouldStop()) break;
198
+ // A wake that landed while the pass was in flight skips the following
199
+ // sleep: that pass made its admission decisions against pre-resume state,
200
+ // and the resumed project must not wait a full interval for the next one.
201
+ if (pace.takeWake()) continue;
202
+ await pace.sleep(tickIntervalMs);
203
+ }
204
+ }
205
+
206
+ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
207
+ // A `--once` tick is still a dispatcher: it settles rows, projects labels,
208
+ // admits and launches workers, so it must hold the same exclusive daemon/
209
+ // state ownership as the long-running dispatcher for its whole lifecycle
210
+ // (#431). This is the one choke point every once tick passes through (both
211
+ // `daemon --once` and the `setup host` smoke), so ownership is acquired here
212
+ // — before any settlement, label, admission, worktree or worker mutation —
213
+ // and refused (having done none of those) while another live daemon owns the
214
+ // project or another live `--once` holds the lease.
215
+ const onceLease = o.once ? acquireOnceLease({ port: o.port, project: o.project }) : undefined;
216
+ if (onceLease !== undefined && !onceLease.ok) {
217
+ throw new Error(
218
+ `another daemon is alive (pid ${onceLease.pid}); stop it first — a --once tick refuses to run beside it`,
219
+ );
220
+ }
221
+ const cfg = loadConfig();
222
+ const projects = o.project === undefined ? cfg.projects : [findProject(cfg, o.project)];
223
+ const store = openStore(dbPath());
224
+ const verbPeerReader = peerCredentialReader();
225
+ const verbDir = ensureVerbSocketDir(stateDir());
226
+ const integrity: IntegrityGate = { baseline: packageManifest(), paged: false };
227
+ const usage = sharedUsageSource();
228
+ const turnLimits = createTurnLimitRegistry((runId, maxTurns) => {
229
+ store.updateRun(runId, { maxTurns });
230
+ });
231
+ const workerControls = createWorkerControlRegistry();
232
+ // Created before the pool so a worker's completion can poke the loop it
233
+ // shares with the interval sleep (#878).
234
+ const pace = createDispatchPace();
235
+ const workers = createWorkerPool(() => {
236
+ // Sticky and coalescing by construction: several workers settling together
237
+ // produce one pass, and a pass already running absorbs the request rather
238
+ // than overlapping. No second owner is created and no gate is skipped.
239
+ pace.requestWake();
240
+ });
241
+ const alive = livingDaemon();
242
+ const runtimes: ProjectRuntime[] = [];
243
+
244
+ // The stop fence (#374): one signal shared by every project's tick.
245
+ // `stopping` gates the run loop between whole ticks; `drain.draining` is
246
+ // the same event visible *inside* a tick already in flight, so a pass that
247
+ // was mid-admission when the stop landed re-checks it before claiming or
248
+ // launching. Created here, before the loop, so `stop` below and every
249
+ // project's `Deps` reference the same object.
250
+ const drain: DrainSignal = { draining: false };
251
+
252
+ log(`verb transport: ${transportBanner(verbDir, verbPeerReader)}`);
253
+ log(`package integrity baseline: ${integrity.baseline.size} files under ${PACKAGE_SRC_DIR}`);
254
+
255
+ for (const project of projects) {
256
+ const projectLog = (message: string): void => {
257
+ log(projects.length === 1 ? message : `[${project.name}] ${message}`);
258
+ };
259
+ const caps = resolveCaps(project, cfg.defaults);
260
+ const host = cfg.host;
261
+ // One transient-server-error breaker per project (#642): admission's
262
+ // GraphQL checks and the orchestrator mutation commands share it, so a 503
263
+ // observed by either side gates both instead of one provider outage being
264
+ // re-asked by every candidate AND every mutation.
265
+ const graphqlBreaker = new GraphqlBreaker();
266
+ const tracker = makeTracker(project, undefined, {
267
+ onCall: () => store.bumpGhCalls?.(utcDay(), "daemon"),
268
+ onRefusal: (at) => store.recordGhRefusal?.(at),
269
+ onNotModified: () => store.bumpGhCalls?.(utcDay(), "daemon-304"),
270
+ }, { graphqlBreaker });
271
+ const verbActions = githubVerbActions(project, undefined, undefined, graphqlBreaker);
272
+
273
+ if (alive === undefined || alive.pid === process.pid) {
274
+ const orphanPublisher = (run: RunRecord): RunPublisher => {
275
+ const repo = project.routing.repos[run.repo];
276
+ return async (branch) =>
277
+ repo === undefined
278
+ ? {
279
+ ok: false,
280
+ stderr:
281
+ `run ${String(run.id)} names repo "${run.repo}", ` +
282
+ `which project "${project.name}" no longer routes`,
283
+ }
284
+ : pushRunBranch(project, { repo, runRepoPath: run.worktree, branch });
285
+ };
286
+ for (const run of await reconcileOrphanedRuns(store, project.name, orphanPublisher)) {
287
+ // The workspace outlives the process, so a restart inherits panes whose
288
+ // workers are gone (#842). A session-host child dies with the daemon that
289
+ // owned its socket, so an orphaned row's pane is authoritatively dead
290
+ // whatever its recorded pid says. Ownership is proven before the close
291
+ // (#1035 review): pane ids are reused after a Herdr restart, so only a
292
+ // pane inside a workspace conductor owns — token-marked or store-recorded,
293
+ // which is what survives Herdr dropping tokens on restore — whose
294
+ // reported identity still agrees with this row is retired. An unowned
295
+ // hit closes nothing and says so.
296
+ const orphanPane = releaseOrphanedWorkerPane(
297
+ { paneId: run.paneId, paneLabel: run.paneLabel, runId: run.id, project: run.project },
298
+ { ownership: workspaceOwnership(store) },
299
+ );
300
+ if (orphanPane.kind === "released") {
301
+ projectLog(
302
+ `#${run.issue} herdr pane ${orphanPane.paneId} retired: its worker died with the previous daemon`,
303
+ );
304
+ } else if (orphanPane.kind === "unowned") {
305
+ projectLog(`#${run.issue} herdr pane ${orphanPane.paneId} left alone: ${orphanPane.reason}`);
306
+ } else if (orphanPane.kind === "failed") {
307
+ projectLog(`#${run.issue} herdr pane ${orphanPane.paneId} release failed: ${orphanPane.reason}`);
308
+ }
309
+ projectLog(
310
+ `#${run.issue} orphaned by a previous daemon (attempt ${run.attempt}, was ${run.state}, ` +
311
+ `worktree ${run.worktree}) — slot freed; the ${project.stateLabels.inProgress} label stays ` +
312
+ "until the orchestrator triages what the worker left",
313
+ );
314
+ }
315
+ } else if (runtimes.length === 0) {
316
+ log(`skipping orphan reconciliation: daemon pid ${alive.pid} is alive and owns the active runs`);
317
+ }
318
+
319
+ const { brief, releaseGrants } = orchestratorStandingOrders(project, cfg.projects);
320
+ let orchestrator: OrchestratorHandle | undefined;
321
+ let orchestratorVerbs: VerbListener | undefined;
322
+ /** First start-failure cause, surfaced by the orchestrator-down incident (#288). */
323
+ let orchestratorStartError: string | undefined;
324
+ if (project.escalation.orchestrator === "external") {
325
+ projectLog(
326
+ "orchestrator: external — tier-1 escalations post as issue comments for the external session's drain duty",
327
+ );
328
+ } else {
329
+ try {
330
+ const orchCwd = join(
331
+ stateDir(),
332
+ projects.length === 1 ? "orchestrator" : `orchestrator-${project.name}`,
333
+ );
334
+ mkdirSync(orchCwd, { recursive: true });
335
+ orchestratorVerbs = await listenVerbChannel(
336
+ verbDeps({ project, store, tracker, verbActions }),
337
+ {
338
+ kind: "orchestrator",
339
+ path: verbSocketPath(verbDir, `orchestrator-${project.name}`),
340
+ project: project.name,
341
+ role: "orchestrator",
342
+ },
343
+ { ...(verbPeerReader === undefined ? {} : { peerReader: verbPeerReader }) },
344
+ );
345
+ orchestrator = await startOrchestrator({
346
+ cwd: orchCwd,
347
+ brief,
348
+ releaseGrants,
349
+ socketPath: join(orchCwd, "ipc.sock"),
350
+ verbSocketPath: orchestratorVerbs.path,
351
+ onSpawn: (pid) => {
352
+ orchestratorVerbs?.bindPid(pid);
353
+ },
354
+ onChildLog: (line) => {
355
+ projectLog(`orchestrator ${line}`);
356
+ },
357
+ onReleaseBlocked: (shape, context) =>
358
+ recordReleaseBlock(project.name, "orchestrator", shape, context),
359
+ });
360
+ const transcript = orchestrator.sessionFile();
361
+ const loaded = orchestrator.extensionVersion();
362
+ projectLog(
363
+ `orchestrator session ready${transcript === undefined ? "" : ` · ${transcript}`}` +
364
+ `${loaded === undefined ? "" : ` · loaded omp-conductor ${loaded}`}`,
365
+ );
366
+ } catch (err) {
367
+ orchestratorStartError = errText(err);
368
+ projectLog(
369
+ "WARNING: orchestrator session failed to start; tier-1 escalations will fall back to issue " +
370
+ `comments: ${orchestratorStartError}`,
371
+ );
372
+ await orchestratorVerbs?.close();
373
+ orchestratorVerbs = undefined;
374
+ }
375
+ }
376
+
377
+ let runtimeDeps: Deps | undefined;
378
+ const currentProject = (): ProjectConfig => runtimeDeps?.project ?? project;
379
+ const deliveryPolicyValid = (): boolean => runtimeDeps?.deliveryPolicyValid === true;
380
+ const escalator = createEscalator(
381
+ currentProject,
382
+ tracker,
383
+ store,
384
+ orchestrator,
385
+ Date.now,
386
+ deliveryPolicyValid,
387
+ (e) => {
388
+ // Every tier-1 escalation that lands on the issue-comment fallback was
389
+ // diverted from the orchestrator. Count it durably on the open incident
390
+ // (a no-op when none is open), so the page and status name how much
391
+ // the outage diverted (#288).
392
+ store.bumpOrchestratorDiverted(project.name, 1);
393
+ projectLog(
394
+ `orchestrator: tier-1 escalation on ${escalationIssueRef(e.issue)} diverted to issue comments ` +
395
+ `while the orchestrator was down`,
396
+ );
397
+ },
398
+ );
399
+ const outbox = createReportOutbox({
400
+ project: currentProject,
401
+ store,
402
+ escalate: (event) => escalator.escalate(event),
403
+ log: projectLog,
404
+ deliveryAllowed: deliveryPolicyValid,
405
+ });
406
+ if (alive === undefined || alive.pid === process.pid) {
407
+ for (const report of outbox.recover(Date.now())) {
408
+ projectLog(
409
+ `report ${report.id} was left mid-send by a previous daemon (attempt ${report.attempts}) — ` +
410
+ "retrying; its message will say it may be a repeat",
411
+ );
412
+ }
413
+ }
414
+
415
+ const d: Deps = {
416
+ project,
417
+ caps,
418
+ host,
419
+ tracker,
420
+ store,
421
+ drain,
422
+ deliveryPolicyValid: false,
423
+ usage,
424
+ escalate: (event) => escalator.escalate(event),
425
+ turnLimits,
426
+ workerControls,
427
+ integrity,
428
+ stall: { paged: false },
429
+ ...(orchestrator === undefined ? {} : { orchestrator }),
430
+ cleanup: { next: 0 },
431
+ probeCriticalBase: (repo, markers, branch) =>
432
+ probeCriticalBase(project, repo, branch, markers),
433
+ // The same seam `doctor` and `upgrade` read (#904/#919), never a second
434
+ // implementation. `readHerdr` stays on: a host that runs herdr is the
435
+ // case where a stale recovery pin matters, and a host without it answers
436
+ // "absent", which no cheap surface treats as a fault.
437
+ probeInstallSurfaces: () => inspectSurfaces({ run: runCommand, log, env: process.env }),
438
+ probeTelegramFreshness: () =>
439
+ // The module's own reader, so the daemon, `doctor` and `status` cannot
440
+ // come to disagree about what is installed (#961).
441
+ checkTelegramFreshness({
442
+ run: async (cmd, args) => {
443
+ const r = await runCommand(cmd, args);
444
+ return { code: r.code, stdout: r.stdout };
445
+ },
446
+ }),
447
+ probeWorktreeLane: (input) => probeRunLane(input),
448
+ // The credential-class fence's reader (#852): the runnable probe module,
449
+ // spawned against the live credential store. Wired once here so the
450
+ // admission gate and the launch fence ask the same question through the
451
+ // same transport — two readers would eventually disagree, and the one that
452
+ // disagreed by passing is the expensive one.
453
+ probeCredentialClass: (provider) => probeCredentialClass(provider),
454
+ // A cross-repo Depends-on prerequisite reads through the same GitHub
455
+ // credential/accounting seams as the project tracker — a fresh tracker
456
+ // scoped to the referenced repo, reusing the daemon's gh hooks so API
457
+ // spend and refusals are counted exactly as the main tracker's are.
458
+ probeIssueIn: (ownerRepo, issue) =>
459
+ makeTracker({ ...project, tracker: { kind: "github", repo: ownerRepo } }, undefined, {
460
+ onCall: () => store.bumpGhCalls?.(utcDay(), "daemon"),
461
+ onRefusal: (at) => store.recordGhRefusal?.(at),
462
+ }).issueSnapshot(issue),
463
+ // The body twin of `probeIssueIn`, for the dependency-graph cycle pass
464
+ // (#421): reads a reachable routed prerequisite's body through the same
465
+ // per-repo tracker/credential/accounting seams.
466
+ probeBodyIn: (ownerRepo, issue) =>
467
+ makeTracker({ ...project, tracker: { kind: "github", repo: ownerRepo } }, undefined, {
468
+ onCall: () => store.bumpGhCalls?.(utcDay(), "daemon"),
469
+ onRefusal: (at) => store.recordGhRefusal?.(at),
470
+ }).issueBody(issue),
471
+ ...(verbPeerReader === undefined ? {} : { verbPeerReader }),
472
+ verbActions,
473
+ };
474
+ runtimeDeps = d;
475
+ // Review-revision restart recovery (#692): a revision the previous daemon
476
+ // claimed and lost — its run is `running`/`orphaned` by the orphan sweep
477
+ // above — is restored to `pushed-green` and re-queued so this process's
478
+ // first dispatch pass resumes the exact session. Runs under the same
479
+ // dead-daemon guard as `reconcileOrphanedRuns`, and after it, so the
480
+ // revision's worktree has already been salvaged to the branch before it is
481
+ // cleared for a fresh reattach. A round that cannot be restored was
482
+ // settled and escalated by the reconcile itself.
483
+ if (alive === undefined || alive.pid === process.pid) {
484
+ try {
485
+ for (const revision of await reconcileCrashedReviewRevisions(d)) {
486
+ projectLog(
487
+ `#${revision.issue} review round ${revision.round} recovered across restart — ` +
488
+ `run ${revision.runId} is pushed-green again and awaits its next dispatch pass`,
489
+ );
490
+ }
491
+ } catch (err) {
492
+ projectLog(`review revision restart recovery failed: ${errText(err)}`);
493
+ }
494
+ }
495
+ // Adjudication restart recovery (#932). An adjudication is `running` only
496
+ // while a session it launched is alive, and that session died with the
497
+ // previous process — nothing resumes it, because an adjudicator has no
498
+ // branch, no worktree and no transcript worth continuing: its whole output
499
+ // is a verdict it never produced.
500
+ //
501
+ // So it settles `failed`, which is honest and terminal, rather than being
502
+ // left `running` forever (a row nothing would ever touch again) or silently
503
+ // re-queued (a second launch for one head, which the one-shot exists to
504
+ // prevent). The one shot for that head is spent, and #876's disposition path
505
+ // reads `failed` exactly as it reads any other non-clear verdict.
506
+ if (alive === undefined || alive.pid === process.pid) {
507
+ for (const adjudication of store.openReviewAdjudications(project.name)) {
508
+ if (adjudication.state !== "running") continue;
509
+ store.settleReviewAdjudication(
510
+ adjudication.id,
511
+ "failed",
512
+ "the daemon restarted while this adjudication was running; no verdict was produced",
513
+ Date.now(),
514
+ );
515
+ projectLog(
516
+ `#${adjudication.issue} adjudication failed across restart — its session died with the previous daemon`,
517
+ );
518
+ }
519
+ }
520
+ // Startup reconciliation: close an incident carried over from a previous
521
+ // process when the orchestrator is up (one recovery notice), or open one
522
+ // when it failed to start (one down page). A daemon restarted while still
523
+ // down rediscovers the open incident and does not re-page it.
524
+ await reconcileOrchestratorDown({
525
+ project,
526
+ store,
527
+ orchestrator,
528
+ escalate: (event) => d.escalate(event),
529
+ ...(orchestratorStartError === undefined ? {} : { startCause: orchestratorStartError }),
530
+ log: projectLog,
531
+ });
532
+ runtimes.push({
533
+ d,
534
+ outbox,
535
+ ...(orchestrator === undefined ? {} : { orchestrator }),
536
+ ...(orchestratorVerbs === undefined ? {} : { orchestratorVerbs }),
537
+ codeGraph: pendingCodeGraph(project),
538
+ });
539
+ }
540
+
541
+ // Historical budget repair (#638): one bounded pass per daemon process,
542
+ // before the first tick, so admission and `unblock` read the repaired counts
543
+ // from the moment the daemon starts. A classifier fix changes future
544
+ // verdicts but not rows already settled under the old one; re-fetching their
545
+ // failed check logs and reclassifying the exact closed infrastructure
546
+ // signature returns those attempts without re-animating the runs. Best-effort
547
+ // per project — one unreachable project must not stop the rest from booting.
548
+ for (const runtime of runtimes) {
549
+ try {
550
+ await reconcileHistoricalInfra(runtime.d);
551
+ } catch (err) {
552
+ log(`historical infra reconciliation failed: ${errText(err)}`);
553
+ }
554
+ }
555
+
556
+ if (o.once) {
557
+ try {
558
+ for (const runtime of runtimes) await tick(runtime.d, workers);
559
+ await workers.drain();
560
+ for (const runtime of runtimes) await runtime.outbox.deliverDue();
561
+ } finally {
562
+ // Drop exclusivity only while it is still ours; a manager that claimed
563
+ // the record mid-tick is left alone. A lease acquired but never released
564
+ // (config/lease failure before this branch) is stale recovery's job.
565
+ if (onceLease !== undefined && onceLease.ok) onceLease.lease.release();
566
+ for (const runtime of runtimes.toReversed()) {
567
+ await runtime.orchestrator?.dispose();
568
+ await runtime.orchestratorVerbs?.close();
569
+ }
570
+ store.close();
571
+ }
572
+ return;
573
+ }
574
+
575
+ const refreshCodeGraph = (runtime: ProjectRuntime): void => {
576
+ if (runtime.graphProbe !== undefined) return;
577
+ runtime.graphProbe = probeCodeGraph(runtime.d.project)
578
+ .then((health) => {
579
+ runtime.codeGraph = health;
580
+ })
581
+ .catch(() => {
582
+ log(
583
+ `[${runtime.d.project.name}] code-graph health probe failed unexpectedly; ` +
584
+ "retaining the previous bounded result",
585
+ );
586
+ })
587
+ .finally(() => {
588
+ runtime.graphProbe = undefined;
589
+ });
590
+ };
591
+ for (const runtime of runtimes) refreshCodeGraph(runtime);
592
+ const graphTimer = setInterval(() => {
593
+ for (const runtime of runtimes) refreshCodeGraph(runtime);
594
+ }, GRAPH_HEALTH_INTERVAL_MS);
595
+
596
+ const drainReports = (runtime: ProjectRuntime): void => {
597
+ if (runtime.reportPass !== undefined) return;
598
+ runtime.reportPass = runtime.outbox
599
+ .deliverDue()
600
+ .then(() => {})
601
+ .catch((err: unknown) => {
602
+ log(`[${runtime.d.project.name}] report delivery pass failed: ${errText(err)}`);
603
+ })
604
+ .finally(() => {
605
+ runtime.reportPass = undefined;
606
+ });
607
+ };
608
+ for (const runtime of runtimes) drainReports(runtime);
609
+ const reportTimer = setInterval(() => {
610
+ for (const runtime of runtimes) drainReports(runtime);
611
+ }, REPORT_DELIVERY_INTERVAL_MS);
612
+
613
+ let stopping = false;
614
+ // The wake surface (#380): `resume` POSTs /wake, `stop` interrupts the same
615
+ // sleep. A wake request is sticky and coalescing — several resumes in quick
616
+ // succession produce one prompt pass, never an overlapping one.
617
+ const stop = (): void => {
618
+ if (stopping) return;
619
+ stopping = true;
620
+ // Close the admission fence before the loop is told, so a tick already in
621
+ // flight — parked at an await when the signal landed — sees `draining`
622
+ // when it resumes and cannot create the work this shutdown is about to
623
+ // wait for (#374).
624
+ drain.draining = true;
625
+ log("shutdown requested — draining dispatch; live worker sessions are not waited for");
626
+ pace.requestWake();
627
+ };
628
+ process.on("SIGINT", stop);
629
+ process.on("SIGTERM", stop);
630
+
631
+ // This loopback surface exposes liveness (open) and bounded live-run controls
632
+ // (authenticated). Repository and tracker mutations stay on credentialled verb
633
+ // sockets, where project and role come from the channel rather than a payload.
634
+ //
635
+ // The mutating half used to be open, on the reasoning that binding 127.0.0.1
636
+ // is itself the boundary. It is not, on a shared host: every local process has
637
+ // the same loopback — including the worker sessions this daemon launches — so
638
+ // anything running here could pause, stop or re-ceiling any run, or wake
639
+ // dispatch in a loop. Phase 4 closes that with a bearer token minted here,
640
+ // before the server exists, so no request can ever be served against a token
641
+ // that had not been decided yet. `GET /healthz` deliberately stays open; the
642
+ // reason is at {@link daemonHttpResponse} and it is load-bearing for
643
+ // `requireDaemonControl`.
644
+ const httpToken = ensureHttpToken();
645
+ const server = Bun.serve({
646
+ hostname: "127.0.0.1",
647
+ port: o.port ?? DEFAULT_PORT,
648
+ fetch: (req) =>
649
+ daemonHttpResponse(req, {
650
+ token: httpToken,
651
+ projects: runtimes.map((runtime) => ({
652
+ project: runtime.d.project.name,
653
+ store,
654
+ caps: () => runtime.d.caps,
655
+ // The same project prefix the runtime's own journal lines carry, so
656
+ // a pause audit reads like every other daemon line (#997).
657
+ log: (line: string) =>
658
+ log(runtimes.length === 1 ? line : `[${runtime.d.project.name}] ${line}`),
659
+ })),
660
+ turnLimits,
661
+ workerControls,
662
+ wake: () => {
663
+ log("dispatch wake requested — an immediate pass is starting");
664
+ pace.requestWake();
665
+ },
666
+ health: () =>
667
+ daemonHealth(
668
+ runtimes.map((runtime) => {
669
+ const orch = runtime.orchestrator;
670
+ const mode = runtime.d.project.escalation.orchestrator;
671
+ return daemonHealthSnapshot(
672
+ store,
673
+ runtime.d.project.name,
674
+ isPaused(runtime.d.project.name),
675
+ runtime.codeGraph,
676
+ workerControls,
677
+ orch === undefined
678
+ ? // External mode is the pane's session: the daemon hosts no
679
+ // child to attest. An embedded orchestrator that failed to
680
+ // start is a live outage of the surface the upgrade must
681
+ // promise reloaded (#832) — say so out loud.
682
+ { mode: mode === "external" ? "external" : "failed" }
683
+ : {
684
+ mode: "embedded",
685
+ ...(orch.extensionVersion() === undefined
686
+ ? {}
687
+ : { loaded: orch.extensionVersion() }),
688
+ ...(orch.sessionFile() === undefined ? {} : { sessionFile: orch.sessionFile() }),
689
+ alive: orch.alive(),
690
+ },
691
+ );
692
+ }),
693
+ ),
694
+ }),
695
+ });
696
+ log(
697
+ projects.length === 1
698
+ ? `serving /healthz on :${server.port}, project ${projects[0]!.name}`
699
+ : `serving /healthz on :${server.port}, projects ${projects.map(({ name }) => name).join(", ")}`,
700
+ );
701
+
702
+ try {
703
+ await runDispatchLoop({
704
+ tick: async () => {
705
+ for (const runtime of runtimes) {
706
+ try {
707
+ await tick(runtime.d, workers);
708
+ } catch (err) {
709
+ log(
710
+ `${projects.length === 1 ? "" : `[${runtime.d.project.name}] `}` +
711
+ `tick failed: ${errText(err)}`,
712
+ );
713
+ }
714
+ }
715
+ },
716
+ shouldStop: () => stopping,
717
+ pace,
718
+ tickIntervalMs: TICK_INTERVAL_MS,
719
+ });
720
+ } finally {
721
+ process.off("SIGINT", stop);
722
+ process.off("SIGTERM", stop);
723
+ clearInterval(graphTimer);
724
+ clearInterval(reportTimer);
725
+ for (const runtime of runtimes) await runtime.reportPass;
726
+ await workers.drain();
727
+ await server.stop(true);
728
+ for (const runtime of runtimes.toReversed()) {
729
+ await runtime.orchestrator?.dispose();
730
+ await runtime.orchestratorVerbs?.close();
731
+ }
732
+ store.close();
733
+ log("stopped");
734
+ process.exitCode = 0;
735
+ }
736
+ }