omp-conductor 0.19.6 → 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 (71) hide show
  1. package/REFERENCE.md +27 -2
  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 -7832
  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/doctor.ts +17 -12
  49. package/src/escalate.ts +39 -21
  50. package/src/failure-class.ts +75 -1
  51. package/src/fleet.ts +1218 -304
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +428 -1681
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +72 -6
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +158 -7
  64. package/src/store.ts +646 -26
  65. package/src/to-spec.ts +194 -21
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +435 -15
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +384 -12
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +456 -1
@@ -0,0 +1,968 @@
1
+ /**
2
+ * One tick: the periodic pass that sequences every other pass in this
3
+ * directory.
4
+ *
5
+ * Nothing here decides anything on its own. `tick` reads the config afresh at
6
+ * its own boundary, builds the one `Deps` the pass will use, and then calls the
7
+ * modules beside it in the order their side effects require — supervision before
8
+ * dispatch so a wedged orchestrator is known about before work is claimed,
9
+ * settlement sweeps after dispatch so a run that just ended is swept on the same
10
+ * pass, grooming last so a queue refilled by promotion is picked up by the next
11
+ * tick rather than mid-pass. That ordering is the only thing this module owns,
12
+ * and keeping it a call sequence rather than a set of registered hooks is what
13
+ * makes it readable in one screen.
14
+ *
15
+ * The three probes that ride along — install surfaces, upgrade verification,
16
+ * the DB snapshot cadence — live here rather than with their subjects because
17
+ * they have no subject beyond "once in a while", and their schedule is the
18
+ * tick's business.
19
+ */
20
+ import { existsSync } from "node:fs";
21
+ import { admitCandidates, startOfToday, type AdmissionHold } from "../admission.ts";
22
+ import { dbBackupDirFor, findProject, loadConfig, resolveCaps, stateDir } from "../config.ts";
23
+ import { evaluateDecisionConditions, probeNpmVersion, probeRateLimitReset } from "../decisions.ts";
24
+ import { dbSnapshotDue, dbSnapshotMarkerKey, localDayKey } from "../digest-schedule.ts";
25
+ import { runDoctor } from "../doctor.ts";
26
+ import { fleetLayers, herdrPaneOmpStarts, resolveHerdrSession } from "../fleet.ts";
27
+ import { projectLabels } from "../label-projection.ts";
28
+ import { healthCheck } from "../lifecycle.ts";
29
+ import { errText, log, safeEscalate } from "../log.ts";
30
+ import { DEFAULT_MINING_WINDOW_MS, mineSignals, type MinedSignal } from "../mining.ts";
31
+ import { reconcileOrchestratorDown } from "../orchestrator-down.ts";
32
+ import { clearPauseIfUnchanged, isPaused, pauseInstance, pauseInstanceAt, pausedPath, setPaused } from "../pause.ts";
33
+ import { enqueueAvailableHeldNotices } from "../reports.ts";
34
+ import { effectiveLabels, isEligible, route } from "../routing.ts";
35
+ import { adoptSalvagedPrs, classifyAndRecover, reconcileGroomingClosures, reconcileStaleLabels, settlePushedGreen } from "../settlement.ts";
36
+ import { ACTIVE_STATES, DB_SNAPSHOT_RETENTION, dbPath, pruneDbSnapshots, snapshotDb } from "../store.ts";
37
+ import { DEFAULT_REPORT_POLICY, type ConductorConfig, type IssueSnapshot, type ReportingPolicy, type Store } from "../types.ts";
38
+ import { appendJournal, launchTransientUnit, readUpgradeJournal } from "../upgrade-journal.ts";
39
+ import { processStartTimeMs, runCommand, verifyPendingUpgrade, type UpgradeVerifyDeps } from "../upgrade-verify.ts";
40
+ import { inspectSurfaces } from "../upgrade.ts";
41
+ import { writeAdmissionAck } from "./ack.ts";
42
+ import { dispatchAdmissions, summarizeDispatch, summarizeHeldPass, type WorkerPool } from "./admission-pass.ts";
43
+ import { NO_ISSUE, PACKAGE_SRC_DIR, UNROUTABLE_TEXT, type Deps } from "./deps.ts";
44
+ import { handleIssue } from "./dispatch.ts";
45
+ import { cancelDrain, consumeDrain } from "./drain.ts";
46
+ import { dispatchToSpecGrooming } from "./groom-pass.ts";
47
+ import { INTEGRITY_SAMPLE, checkIntegrity, markPaged, packageManifest } from "./integrity.ts";
48
+ import { reconcilePanes } from "./panes.ts";
49
+ import { applyAdjudicationDispositions, dispatchReviewAdjudications, dispatchReviewRevisions } from "./review.ts";
50
+ import { cleanupRetainedRuns, watchBaseHealth, watchMergedBase } from "./settle-pass.ts";
51
+ import { wakeOrchestratorForMetConditions, watchOrchestrator } from "./supervision.ts";
52
+
53
+ /**
54
+ * Record what this host has installed, once per dispatch pass (#919).
55
+ *
56
+ * Here rather than at render time, and this is the whole design of the slice:
57
+ * the read costs three subprocesses, and calling it from the tick took the
58
+ * tick's own suite from 8.4s to 83.4s while spawning three children every
59
+ * fifteen minutes to answer a question that changes only when someone installs
60
+ * something. The dispatch pass is already async and already spawns `gh`, so one
61
+ * read per pass is free by comparison, and every cheap surface then reads a row.
62
+ *
63
+ * Advisory throughout: a probe that throws (no `herdr` on PATH, a `$PATH`
64
+ * without `omp`) leaves the previous observation in place and logs. A stale
65
+ * observation is still the truth about the last time anyone could look, and
66
+ * losing a dispatch pass over a version string would be absurd.
67
+ */
68
+ export async function recordInstallSurfaces(d: Deps): Promise<void> {
69
+ if (d.probeInstallSurfaces === undefined) return;
70
+ try {
71
+ const surfaces = await d.probeInstallSurfaces();
72
+ // The telegram peer's versions ride along on the same pass (#961). Read
73
+ // here rather than in `status` because `status` runs every tick and this
74
+ // touches the npm registry: one periodic read, rendered from the store as
75
+ // often as anyone looks. A read that throws leaves the fields absent, which
76
+ // renders as unverified — never as agreement.
77
+ const telegram =
78
+ d.probeTelegramFreshness === undefined ? undefined : await d.probeTelegramFreshness();
79
+ const tgInstalled = telegram?.surfaces.installed;
80
+ const tgDaemon = telegram?.surfaces.daemon;
81
+ const tgPublished = telegram?.surfaces.published;
82
+ d.store.recordInstallSurfaces({
83
+ at: Date.now(),
84
+ cliVersion: surfaces.cliVersion,
85
+ ...(surfaces.ompVersion === undefined ? {} : { ompVersion: surfaces.ompVersion }),
86
+ ...(surfaces.herdrSource === undefined ? {} : { herdrSource: surfaces.herdrSource }),
87
+ ...(tgInstalled?.kind === "version" ? { telegramInstalled: tgInstalled.version } : {}),
88
+ ...(tgDaemon?.kind === "version" ? { telegramDaemon: tgDaemon.version } : {}),
89
+ ...(tgPublished?.kind === "version" ? { telegramPublished: tgPublished.version } : {}),
90
+ });
91
+ } catch (err) {
92
+ log(`install-surface read skipped: ${errText(err)}`);
93
+ }
94
+ }
95
+
96
+ // ==================================================== hourly signal mining
97
+ // (Phase 4.) The fleet already records every fact worth grooming — settlement
98
+ // flags that name a weakened test, failure classes that keep recurring — and
99
+ // then leaves them in `runs`, where only somebody reading stats would ever meet
100
+ // them. Mining turns them into intake items, which is the one queue grooming
101
+ // already reads.
102
+ //
103
+ // Three properties make this safe to run unattended:
104
+ //
105
+ // - **Never auto-queued.** A mined item is `pending` intake, exactly like an
106
+ // idea an operator typed. It reaches a worker only after grooming specs it
107
+ // and the ready gate passes it. Mining proposes; it does not decide.
108
+ // - **Idempotent by construction.** Each signal carries a stable `source`, and
109
+ // Lane A derives the intake id from `project + source` with INSERT OR IGNORE.
110
+ // An unresolved signal therefore does not multiply hourly — the tenth pass
111
+ // over the same weakened test files nothing.
112
+ // - **Bounded to what the store already holds.** `mineSignals` is pure and
113
+ // takes pre-read rows; no network, no new table, no second clock.
114
+
115
+ // The window is `mining.ts`'s own `DEFAULT_MINING_WINDOW_MS`, imported rather
116
+ // than restated: the miner's occurrence thresholds are calibrated against it, so
117
+ // a second copy here would be a second answer to "what counts as recent".
118
+
119
+ /**
120
+ * Run the mining pass at most once per wall-clock hour (Phase 4).
121
+ *
122
+ * The cadence rides the five-minute tick rather than adding a timer: a second
123
+ * scheduler for a pass whose whole job is reading rows it already has would be
124
+ * one more thing to reason about at shutdown. The hour is a bucket key in the
125
+ * `notifications` ledger — the same once-per-key mechanism the daily db snapshot
126
+ * uses — which makes it durable across restarts, where an in-memory `lastRun`
127
+ * would let a flapping daemon mine on every boot.
128
+ *
129
+ * The key is claimed BEFORE the work, so the bound is "one attempt per hour".
130
+ * That is the right way round: a miner that throws would otherwise retry every
131
+ * five minutes and log every time, and losing one hour costs nothing at all —
132
+ * the signals are derived from stored rows, so next hour sees the same ones.
133
+ */
134
+ export function mineIntakeSignals(d: Pick<Deps, "project" | "store">, now = Date.now()): void {
135
+ // Hour bucket, not a rolling 60 minutes: a pass at :59 and one at :01 are two
136
+ // buckets and both run. That is the dbSnapshot cadence's own shape, and it is
137
+ // harmless here precisely because filing is idempotent.
138
+ const key = `signal-mining:${d.project.name}:${new Date(now).toISOString().slice(0, 13)}`;
139
+ if (d.store.wasNotified(key)) return;
140
+ d.store.markNotified(key);
141
+
142
+ const since = now - DEFAULT_MINING_WINDOW_MS;
143
+ let signals: readonly MinedSignal[];
144
+ try {
145
+ signals = mineSignals({
146
+ project: d.project.name,
147
+ now,
148
+ runs: d.store.statsRuns(d.project.name, since),
149
+ windowMs: DEFAULT_MINING_WINDOW_MS,
150
+ });
151
+ } catch (err) {
152
+ log(`signal mining skipped: ${errText(err)}`);
153
+ return;
154
+ }
155
+ if (signals.length === 0) return;
156
+
157
+ const filed: string[] = [];
158
+ let known = 0;
159
+ let dismissed = 0;
160
+ for (const signal of signals) {
161
+ try {
162
+ const item = d.store.recordIntake({
163
+ project: d.project.name,
164
+ text: signal.text,
165
+ at: now,
166
+ source: signal.source,
167
+ });
168
+ // The store returns the row that is NOW in the table, so a `createdAt`
169
+ // older than this call proves the signal was already filed — the ordinary
170
+ // case for anything not yet groomed, and it must read as "already known"
171
+ // rather than as a tenth duplicate.
172
+ if (item.createdAt === now) filed.push(signal.source);
173
+ // A signal the operator dismissed stays dismissed: INSERT OR IGNORE cannot
174
+ // resurrect it, and counting it as merely "known" would hide the fact that
175
+ // the fleet keeps producing a signal somebody has already judged
176
+ // uninteresting — which is itself worth seeing in the journal.
177
+ else if (item.state !== "pending") dismissed += 1;
178
+ else known += 1;
179
+ } catch (err) {
180
+ log(`intake for ${signal.source} not filed: ${errText(err)}`);
181
+ }
182
+ }
183
+ log(
184
+ `signal mining: ${filed.length} filed, ${known} already pending, ${dismissed} previously dismissed ` +
185
+ `(${signals.length} signal(s) over ${Math.round(DEFAULT_MINING_WINDOW_MS / 86_400_000)}d)` +
186
+ (filed.length === 0 ? "" : ` — ${filed.join(", ")}`),
187
+ );
188
+ }
189
+
190
+ /**
191
+ * The first-tick verification for a fleet-initiated upgrade (#486).
192
+ *
193
+ * The detached install unit journals its progress and stops, leaving dispatch
194
+ * paused; whichever daemon comes back after the restart runs this on its
195
+ * first tick — above every gate that a pause or an integrity mismatch could
196
+ * close, because the install's own pause is the thing under verification.
197
+ * A day with no journal costs one file read; only a pending request pays for
198
+ * the version/health/ticks/pane/doctor checks and the outbox rows.
199
+ */
200
+ export function upgradeVerifyDepsFor(d: Pick<Deps, "project" | "store">): UpgradeVerifyDeps {
201
+ return {
202
+ projectName: d.project.name,
203
+ journal: (entry) => appendJournal(stateDir(), entry),
204
+ run: runCommand,
205
+ layers: (project) => fleetLayers(project),
206
+ health: healthCheck,
207
+ doctor: (name) => runDoctor(name),
208
+ surfaces: () => inspectSurfaces({ run: runCommand, log, env: process.env }),
209
+ enqueue: (draft) => {
210
+ try {
211
+ d.store.enqueueReport(draft);
212
+ } catch (err) {
213
+ log(`upgrade verification report could not be queued: ${errText(err)}`);
214
+ }
215
+ },
216
+ pause: pauseInstance,
217
+ resume: (project) => setPaused(false, undefined, project),
218
+ holdGlobal: () => {
219
+ if (pauseInstance() === undefined) {
220
+ setPaused(true, { source: "upgrade-recovery", reason: "upgrade recovery verification required" });
221
+ }
222
+ },
223
+ releaseGlobalRecovery: (expectedSince) => {
224
+ const pause = pauseInstance();
225
+ if (
226
+ pause?.source === "upgrade-recovery" &&
227
+ expectedSince !== undefined &&
228
+ pause.since === expectedSince
229
+ ) {
230
+ setPaused(false);
231
+ }
232
+ },
233
+ launchRollback: async (version) => {
234
+ const launched = await launchTransientUnit(
235
+ (argv) => runCommand(argv[0]!, argv.slice(1)),
236
+ process.env,
237
+ { kind: "rollback", version },
238
+ );
239
+ return launched.ok === true ? { ok: true, unit: launched.unit } : { ok: false, stderr: launched.stderr };
240
+ },
241
+ herdrSession: resolveHerdrSession(process.env),
242
+ // The durable pane probe for the post-restart verifier (#832): every omp
243
+ // process the fleet pane currently claims by herdr, resolved to its start
244
+ // time — the evidence an external (pane-owned) orchestrator actually
245
+ // restarted after the install began, through the same shared probe as the
246
+ // in-process upgrade.
247
+ probePaneOmp: (session) => herdrPaneOmpStarts(runCommand, session, processStartTimeMs),
248
+ log,
249
+ now: () => Date.now(),
250
+ };
251
+ }
252
+
253
+ export async function verifyPendingUpgradeTick(
254
+ d: Deps,
255
+ run = verifyPendingUpgrade,
256
+ ): Promise<void> {
257
+ // Cheap pre-test: no journal, no pending request, nothing to touch.
258
+ if (readUpgradeJournal(stateDir()).length === 0) return;
259
+ const verifyDeps = upgradeVerifyDepsFor(d);
260
+ try {
261
+ const outcome = await run(verifyDeps);
262
+ if (
263
+ outcome.handled !== "none" &&
264
+ outcome.handled !== "already-closed" &&
265
+ outcome.handled !== "recovery-owed"
266
+ ) {
267
+ log(
268
+ `upgrade journal handled: ${outcome.handled}` +
269
+ `${outcome.version === undefined ? "" : ` for omp-conductor@${outcome.version}`}` +
270
+ `${outcome.detail === undefined ? "" : ` — ${outcome.detail}`}`,
271
+ );
272
+ }
273
+ } catch (err) {
274
+ // A torn journal must not take the tick down: the operator still reads it
275
+ // through `status`, and the next tick retries the same pass.
276
+ log(`upgrade verification pass failed: ${errText(err)}`);
277
+ }
278
+ }
279
+
280
+ /**
281
+ * The daemon's conductor.db snapshot cadence (#289) — the ledger's only
282
+ * durable copy, taken on the digest-aligned {@link dbSnapshotDue} window:
283
+ * once per local day, at/after the digest's configured `at` when it has one.
284
+ *
285
+ * Host-global, like the store itself: one snapshot per day for every project,
286
+ * so whichever project's daemon wins the day first publishes it and the
287
+ * others no-op on the same durable marker. The marker is written through the
288
+ * store's existing notification ledger (a one-row idempotence guard, the same
289
+ * primitive escalations use not to act twice) *after* the snapshot is
290
+ * published — a crash between publish and mark re-snapshots the next tick
291
+ * instead of skipping the day, and a restored store that predates today's
292
+ * marker takes a fresh snapshot on the next tick.
293
+ *
294
+ * Returns true when a snapshot was published. A store that does not exist
295
+ * yet is a silent no-op: there is nothing to back up, and `doctor`'s
296
+ * `db-backup` probe already treats that as a pass.
297
+ */
298
+ export function runDbSnapshotCadence(args: {
299
+ digestPolicy: ReportingPolicy["digest"];
300
+ store: Pick<Store, "wasNotified" | "markNotified">;
301
+ source: string;
302
+ backupDir: string;
303
+ now?: number;
304
+ keep?: number;
305
+ log?: (line: string) => void;
306
+ }): boolean {
307
+ const { digestPolicy, store, source, backupDir } = args;
308
+ // No store yet (first boot before any run persisted) → nothing to back up;
309
+ // `doctor`'s `db-backup` probe already treats that as a pass.
310
+ if (!existsSync(source)) return false;
311
+ const now = args.now ?? Date.now();
312
+ const today = localDayKey(now, digestPolicy.timezone);
313
+ const alreadyToday = store.wasNotified(dbSnapshotMarkerKey(today));
314
+ if (!dbSnapshotDue({ digest: digestPolicy }, alreadyToday ? today : undefined, now)) {
315
+ return false;
316
+ }
317
+ const published = snapshotDb(source, backupDir, now);
318
+ (args.log ?? log)(`conductor.db snapshot published: ${published}`);
319
+ store.markNotified(dbSnapshotMarkerKey(today));
320
+ try {
321
+ const removed = pruneDbSnapshots(backupDir, args.keep ?? DB_SNAPSHOT_RETENTION);
322
+ if (removed > 0) {
323
+ (args.log ?? log)(`db snapshot retention pruned ${removed} file(s) beyond the retained ${args.keep ?? DB_SNAPSHOT_RETENTION}`);
324
+ }
325
+ } catch (err) {
326
+ // The snapshot is already published and marked; an over-bound directory
327
+ // costs disk until the next due day retries the prune, never the backup.
328
+ (args.log ?? log)(`db snapshot retention prune failed: ${errText(err)}`);
329
+ }
330
+ return true;
331
+ }
332
+
333
+ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
334
+ // A config edit takes effect on the next tick, not the next daemon restart
335
+ // (#170). Re-resolve the project and its caps at the tick boundary so a tick
336
+ // and every run it admits see one consistent snapshot; a failed read keeps
337
+ // the boot values rather than wedging the tick, and the next tick tries
338
+ // again. The same reloaded config feeds the db-snapshot backup dir: when it
339
+ // is unreadable the cadence falls back to the state-root default, matching
340
+ // `doctor`'s probe of an absent field.
341
+ let reloadedConfig: ConductorConfig | undefined;
342
+ try {
343
+ const cfg = loadConfig();
344
+ reloadedConfig = cfg;
345
+ const fresh = findProject(cfg, d.project.name);
346
+ const freshCaps = resolveCaps(fresh, cfg.defaults);
347
+ if (JSON.stringify(fresh) !== JSON.stringify(d.project) || JSON.stringify(freshCaps) !== JSON.stringify(d.caps)) {
348
+ log(`config reloaded: project ${d.project.name} changed on disk — applying from this tick`);
349
+ }
350
+ d.project = fresh;
351
+ d.caps = freshCaps;
352
+ d.host = cfg.host;
353
+ d.deliveryPolicyValid = true;
354
+ } catch (err) {
355
+ log(`config reload failed (${errText(err)}) — retaining boot values but blocking autonomous delivery`);
356
+ d.deliveryPolicyValid = false;
357
+ }
358
+
359
+ // The fleet-installs-itself transaction (#486): the detached install unit
360
+ // paused dispatch and stopped; the first tick after the restart verifies
361
+ // against its journal and closes or rolls back. Above the pause gate on
362
+ // purpose — the install's own pause is exactly the state being verified.
363
+ try {
364
+ await (d.upgradeVerifier ?? verifyPendingUpgradeTick)(d);
365
+ } catch (err) {
366
+ log(`upgrade journal pass failed: ${errText(err)}`);
367
+ }
368
+
369
+ // Availability-held notices are already durable. Once the freshly reloaded
370
+ // policy opens (or newly allows their category), atomically hand a bounded
371
+ // batch to the report outbox. Pauses do not suppress delivery.
372
+ if (d.deliveryPolicyValid !== false) {
373
+ try {
374
+ const catchUp = enqueueAvailableHeldNotices(d.project, d.store, Date.now());
375
+ if (catchUp !== undefined && !catchUp.deduped) {
376
+ log(`availability catch-up ${catchUp.report.id} queued for ${d.project.name}`);
377
+ }
378
+ } catch (err) {
379
+ // The daily digest may have claimed the same rows from another process
380
+ // between selection and association. Either way the ledger still owns them.
381
+ log(`availability catch-up handoff deferred (${errText(err)}) — retrying next tick`);
382
+ }
383
+ }
384
+
385
+ // Before the pause check, deliberately. This one is not about dispatch: the
386
+ // orchestrator is a different process, and it can be wedged while this fleet
387
+ // is paused — which is exactly the state the reference fleet was in when the
388
+ // failure happened. A pause silences claiming, not the operator's right to
389
+ // know their supervising session stopped reading its queue.
390
+ await watchOrchestrator(d);
391
+ // The down incident is reconciled the same place and for the same reason: a
392
+ // session that has actually died is as much the operator's concern as one
393
+ // that is wedged, and restarting it is the daemon's restart either way. This
394
+ // is what turns a crashed orchestrator into one page ("down since <t>") plus
395
+ // a diverting count, instead of a warning only in daemon.log.
396
+ await reconcileOrchestratorDown({
397
+ project: d.project,
398
+ store: d.store,
399
+ orchestrator: d.orchestrator,
400
+ escalate: (event) => d.escalate(event),
401
+ log,
402
+ });
403
+
404
+ // Beside the two orchestrator watches, and above the pause gate, for the same
405
+ // reason they are: mining reads rows this store already holds and files intake
406
+ // for a human to groom. It claims nothing, so a paused fleet has no cause to
407
+ // stop noticing that a test was weakened three times this week. Its own hour
408
+ // bucket bounds it; this call site just supplies the clock.
409
+ try {
410
+ mineIntakeSignals(d);
411
+ } catch (err) {
412
+ log(`signal mining pass failed: ${errText(err)}`);
413
+ }
414
+
415
+ // Settlement is maintenance, not dispatch. Run it before every gate that can
416
+ // stop claiming — pause, integrity, spend, and capacity — so status converges
417
+ // while the fleet is parked or workers are still active. Resident workers run
418
+ // through the pool without blocking this five-minute tick. The return value
419
+ // is what this pass settled; a held pass records it so the paused state reads
420
+ // as work done, not just a clock that moved (#497).
421
+ let settled = await settlePushedGreen(d);
422
+ try {
423
+ await watchMergedBase(d);
424
+ } catch (err) {
425
+ log(`base-branch check sweep failed: ${errText(err)}`);
426
+ }
427
+ try {
428
+ await watchBaseHealth(d);
429
+ } catch (err) {
430
+ log(`current base-health sweep failed: ${errText(err)}`);
431
+ }
432
+ try {
433
+ await adoptSalvagedPrs(d);
434
+ } catch (err) {
435
+ log(`salvaged PR adoption sweep failed: ${errText(err)}`);
436
+ }
437
+
438
+ // The conductor.db snapshot cadence (#289) is durability, not dispatch:
439
+ // the verb ledger, the decision rows and the run history have no other
440
+ // copy, so they are snapshotted once per day on the digest-aligned window.
441
+ // Above the pause gate on purpose — a parked fleet still accumulates ledger
442
+ // rows and still deserves a backup — and a failure costs the day's snapshot
443
+ // placeholder, never the tick: failed cadence steps are logged and retried
444
+ // by the five-minute loop, exactly like the sweeps above it.
445
+ try {
446
+ runDbSnapshotCadence({
447
+ digestPolicy: (d.project.reporting ?? DEFAULT_REPORT_POLICY).digest,
448
+ store: d.store,
449
+ source: dbPath(),
450
+ backupDir: dbBackupDirFor(reloadedConfig),
451
+ log,
452
+ });
453
+ } catch (err) {
454
+ log(`db snapshot cadence failed: ${errText(err)}`);
455
+ }
456
+
457
+ // Immediately after settlement and before any routing, so a class is on the
458
+ // row before the next dispatch decision reads its budgets (#132). Above the
459
+ // pause gate deliberately: classification and label reconciliation are
460
+ // maintenance, and the four phantom `agent:failed` issues this exists to clear
461
+ // are exactly as misleading on a parked fleet as on a busy one.
462
+ //
463
+ // Each is guarded on its own: a tracker that fails mid-classification must not
464
+ // stop the label reconcile, and neither may stop the tick.
465
+ try {
466
+ settled += await classifyAndRecover({
467
+ project: d.project,
468
+ caps: d.caps,
469
+ tracker: d.tracker,
470
+ store: d.store,
471
+ escalate: (e) => d.escalate(e),
472
+ isPaused,
473
+ setPaused,
474
+ });
475
+ } catch (err) {
476
+ log(`classification sweep failed: ${errText(err)}`);
477
+ }
478
+ try {
479
+ await reconcileStaleLabels(d);
480
+ } catch (err) {
481
+ log(`label reconcile failed: ${errText(err)}`);
482
+ }
483
+ try {
484
+ // Same phase, same reason as the label reconcile above: a durable row the
485
+ // tracker has moved past (#964). A separate call because that one iterates
486
+ // labelled issues, and a promotable issue carries no label.
487
+ await reconcileGroomingClosures(d);
488
+ } catch (err) {
489
+ log(`grooming closure reconcile failed: ${errText(err)}`);
490
+ }
491
+
492
+ // Drain the label projection outbox (#201). The maintenance phases above may
493
+ // have enqueued ops (settlement releases, recovery requeues, reconciles);
494
+ // each due op is applied now — or deferred with backoff for the next tick —
495
+ // before the queue is read, so dispatch sees labels converging on what the
496
+ // store decided. A refusing tracker defers ops instead of taking the tick
497
+ // down.
498
+ try {
499
+ await projectLabels(d.store, d.tracker, d.project);
500
+ } catch (err) {
501
+ log(`label projection failed: ${errText(err)}`);
502
+ }
503
+
504
+ // Ledger maintenance, above the pause gate for the same reason the stall watch
505
+ // is: a paused fleet still owes its operator the questions it asked, and a
506
+ // condition that came true while dispatch was parked is exactly the thing the
507
+ // orchestrator has to see promptly (#329).
508
+ //
509
+ // Expiry is synchronous (one UPDATE); condition evaluation is fire-and-forget
510
+ // because it shells out to `gh` and `npm`, and a registry that hangs must cost
511
+ // one unevaluated condition rather than the tick. A false→true transition
512
+ // writes the same immediate-tick poke recover uses so the heartbeat does not
513
+ // wait a full interval; repeated sweeps while the condition stays true never
514
+ // re-enter `met` (store mark is idempotent).
515
+ for (const expired of d.store.expireDueDecisions(d.project.name, Date.now())) {
516
+ log(`decision ${expired.id} expired unanswered after seven days: ${expired.question}`);
517
+ }
518
+ void evaluateDecisionConditions(
519
+ d.store,
520
+ d.project.name,
521
+ d.tracker,
522
+ { npm: probeNpmVersion, rateLimit: probeRateLimitReset },
523
+ Date.now,
524
+ )
525
+ .then((met) => {
526
+ wakeOrchestratorForMetConditions(d.project.name, met);
527
+ })
528
+ .catch((err: unknown) => {
529
+ log(`decision condition pass failed: ${errText(err)}`);
530
+ });
531
+
532
+ // A spend-cap pause is self-expiring (#780). The spend gate below persists a
533
+ // pause once today's spend reaches the cap, but once that latch is on disk
534
+ // every later pass returns at the pause gate and never reaches the spend
535
+ // check again — so the fleet stayed stopped after the rolling window reset
536
+ // until an operator ran `resume`. The same measurement that closed the gate
537
+ // reopens it: clear a per-project spend-cap pause when the current
538
+ // rolling-window spend is below the cap (or no cap is configured at all —
539
+ // the gate that justified the pause is gone). Two guards keep the clear from
540
+ // ever overriding another stop: only the exact instance that was read is
541
+ // removed (a hold that replaced the sentinel meanwhile is untouched, review
542
+ // #780), and only the per-project sentinel is ever considered — the legacy
543
+ // global sentinel, which may carry an operator/integrity/setup hold, is
544
+ // never removed, so the pause gate below still observes it.
545
+ const spendPausePath = pausedPath(d.project.name);
546
+ const spendPause = pauseInstanceAt(spendPausePath);
547
+ if (spendPause?.source === "spend-cap") {
548
+ const spent = d.store.spendSince(d.project.name, startOfToday());
549
+ if (
550
+ (d.caps.dailySpendUsd === null || spent < d.caps.dailySpendUsd) &&
551
+ clearPauseIfUnchanged(spendPausePath, spendPause)
552
+ ) {
553
+ log(
554
+ `spend-cap pause cleared: $${spent.toFixed(2)} ` +
555
+ (d.caps.dailySpendUsd === null
556
+ ? "(no daily cap configured)"
557
+ : `below the $${d.caps.dailySpendUsd.toFixed(2)} daily cap`) +
558
+ " — dispatch resumes",
559
+ );
560
+ }
561
+ }
562
+
563
+ // A paused fleet claims nothing. Checked first so pausing takes effect on the
564
+ // next tick without signalling the process. But the pass still ran, and the
565
+ // operator has to be able to see it: record it as a held pass — the work the
566
+ // maintenance sweeps settled above this gate, and nothing admitted below it —
567
+ // so `last dispatch` keeps moving while the fleet is deliberately parked. A
568
+ // frozen clock is then a stall report, not a hold (#497).
569
+ if (isPaused(d.project.name)) {
570
+ // The held pass is the daemon-side admission acknowledgement: the daemon
571
+ // has reached its admission boundary under the fence and claims nothing.
572
+ // Written durably so the setup barrier can prove the fence was observed
573
+ // (#651 review #3) — a pause cannot acknowledge itself.
574
+ try {
575
+ writeAdmissionAck(d.project.name);
576
+ } catch (err) {
577
+ log(`admission acknowledgement write failed: ${errText(err)}`);
578
+ }
579
+ d.store.recordDispatch(d.project.name, summarizeHeldPass(settled));
580
+ return;
581
+ }
582
+
583
+ // The project drain is the self-expiring sibling of the pause fence (#484):
584
+ // the same admission boundary — settlement above it, nothing claimed below —
585
+ // but the intent is durable (it survives orchestrator loss) and bounded (the
586
+ // record carries an absolute deadline, so a crash can never strand
587
+ // admission). Three shapes, three behaviours:
588
+ // - fresh drain with live runs → a held pass, exactly like a pause;
589
+ // - fresh drain with nothing left to wait for → the drain is satisfied
590
+ // and clears itself, so a completed drain never needs a second operator
591
+ // action and this pass proceeds normally;
592
+ // - malformed record → fails closed for THIS pass (it might be a fresh
593
+ // fence we cannot read), and the same consume removed it, so it can
594
+ // never become an unbounded permanent drain.
595
+ const drain = consumeDrain(d.project.name);
596
+ if (drain.kind === "active") {
597
+ // Completion is the ACTIVE set, not the live-worker set: pushed-pending
598
+ // and pushed-green PRs still make the `runs-settled` release gate fail, so
599
+ // a drain that cleared while one remained would admit work on top of a
600
+ // batch the releases still see as unfinished (#776 review #2).
601
+ if (d.store.activeRuns(d.project.name).length === 0) {
602
+ cancelDrain(d.project.name);
603
+ log("project drain completed: no active runs remain — drain cleared");
604
+ } else {
605
+ d.store.recordDispatch(d.project.name, summarizeHeldPass(settled));
606
+ return;
607
+ }
608
+ } else if (drain.kind === "error") {
609
+ log(`project drain record invalid (${drain.problem}) — removed; this pass admits nothing`);
610
+ d.store.recordDispatch(d.project.name, summarizeHeldPass(settled));
611
+ return;
612
+ }
613
+
614
+ const { project, caps, store } = d;
615
+
616
+ // "Nobody patches the running conductor" is a hard boundary in both briefs —
617
+ // which makes it prompt text, and prompt text is a request. This is the half
618
+ // that does not negotiate: the package that dispatched the last worker must
619
+ // still be the package on disk, or nothing else this tick does is
620
+ // attributable. A legitimate deploy never trips it, because installing a new
621
+ // build and restarting the unit re-records the baseline from the new files;
622
+ // only an edit *underneath* a live daemon diverges from it.
623
+ //
624
+ // Below the pause gate on purpose, unlike the stall watch above. The property
625
+ // being defended is that no work is dispatched under a package the operator
626
+ // did not install — and a paused fleet dispatches nothing, so nothing needs
627
+ // attributing yet. Tampering during a pause is not missed, only deferred: the
628
+ // baseline is boot's, so the first tick after `resume` compares against it and
629
+ // pauses again before claiming anything. Checking above the gate instead would
630
+ // page on every legitimate build an operator deploys into a parked fleet,
631
+ // which is exactly when they deploy them.
632
+ const integrity = checkIntegrity(d.integrity, packageManifest());
633
+ if (integrity.pause) {
634
+ const shown = integrity.diff.slice(0, INTEGRITY_SAMPLE);
635
+ log(
636
+ `ERROR: the installed conductor changed under this daemon — ${integrity.diff.length} file(s) differ ` +
637
+ `(${shown.join(", ")}${integrity.diff.length > shown.length ? ", …" : ""}) — pausing`,
638
+ );
639
+ setPaused(
640
+ true,
641
+ { source: "integrity", reason: "installed package changed under the daemon" },
642
+ project.name,
643
+ );
644
+ if (integrity.page) {
645
+ const delivered = await safeEscalate(d, {
646
+ tier: 2,
647
+ category: "fleet-stopped",
648
+ project: project.name,
649
+ issue: NO_ISSUE,
650
+ // Dated for the same reason the spend cap is: the dedup key is the
651
+ // summary, and a second tamper months later must not be swallowed as a
652
+ // repeat of the first.
653
+ summary:
654
+ `Installed conductor changed under a running daemon on ${new Date().toISOString().slice(0, 10)}: ` +
655
+ `${integrity.diff.length} file(s) differ (first: ${integrity.diff[0]}) — ${project.name} is paused`,
656
+ detail: [
657
+ `Package root: ${PACKAGE_SRC_DIR}`,
658
+ ...shown,
659
+ ...(integrity.diff.length > shown.length ? [`… and ${integrity.diff.length - shown.length} more`] : []),
660
+ "",
661
+ "If you deployed a new build, restart the daemon — the restart re-records the baseline.",
662
+ "If you did not, the host edited itself while it was dispatching work: treat every run since",
663
+ "the last known-good restart as unattributable before resuming.",
664
+ "`omp-conductor resume` alone will not hold — the next tick re-pauses while the files differ.",
665
+ ].join("\n"),
666
+ });
667
+ markPaged(d.integrity, delivered);
668
+ }
669
+ return;
670
+ }
671
+
672
+ // Review revisions are dispatch, not admission (#677): the orchestrator's
673
+ // verb recorded them durably and the run row already occupies its issue, so
674
+ // they bypass the ready queue and the claim path entirely — this pass wakes
675
+ // them (bounded by the same worker slots) BEFORE the queue's capacity gate
676
+ // reads live runs, so a claimed revision counts against capacity exactly like
677
+ // the worker it is about to spawn.
678
+ try {
679
+ await dispatchReviewRevisions(d, workers);
680
+ } catch (err) {
681
+ log(`review revision dispatch failed: ${errText(err)}`);
682
+ }
683
+
684
+ // Adjudications are dispatch too (#932), and their own pass: an adjudication
685
+ // occupies no worker slot and no issue — it opens a fresh read-only session
686
+ // about a PR whose run has already settled — so it is neither bounded by the
687
+ // worker cap nor allowed to consume a review round. Placed after revisions so
688
+ // a PR with a live revision is never adjudicated in the same tick it is being
689
+ // corrected in; the head re-read inside the pass is the durable guard.
690
+ try {
691
+ await dispatchReviewAdjudications(d, workers);
692
+ } catch (err) {
693
+ log(`adjudication dispatch failed: ${errText(err)}`);
694
+ }
695
+
696
+ // And what the verdicts imply (#876). Its own pass, after dispatch, because a
697
+ // disposition acts on a SETTLED adjudication: running it before dispatch would
698
+ // simply be a tick late, and running it inside dispatch would tie a tracker
699
+ // mutation to the launch that produced the verdict.
700
+ try {
701
+ await applyAdjudicationDispositions(d);
702
+ } catch (err) {
703
+ log(`adjudication dispositions failed: ${errText(err)}`);
704
+ }
705
+
706
+ // route() filters the queue through isEligible() itself, so anything already
707
+ // carrying a state label is gone before it gets here.
708
+ const ready = await d.tracker.listReady();
709
+ await cleanupRetainedRuns(
710
+ d,
711
+ new Set(ready.map((issue) => issue.number)),
712
+ d.cleanup ?? { next: 0 },
713
+ );
714
+ // Label-projection overlay (#201): an issue whose outbox ops have not
715
+ // reached GitHub yet is judged on what its labels *will* be. A pending
716
+ // state-label removal stops a stale GitHub label from blocking redispatch,
717
+ // and a pending queue-label removal drops the issue out of eligibility even
718
+ // though the label is still physically present. isEligible stays pure; the
719
+ // overlay happens here.
720
+ const effective = ready.map((issue) => {
721
+ const pending = store.pendingLabelOpsFor(project.name, issue.number);
722
+ return pending.length === 0 ? issue : { ...issue, labels: effectiveLabels(issue.labels, pending) };
723
+ });
724
+ // Post-unblock stale-list revalidation (#891). `listReady` is a
725
+ // label-FILTERED search, and GitHub's search index is eventually consistent:
726
+ // measured 2026-08-22T09:18Z, an `unblock` cleared `agent:failed` and
727
+ // restored the queue label, `gh issue view` returned the clean label set
728
+ // immediately, and the very next pass still saw the stale labels, held #889
729
+ // as `stale-lifecycle` and left the fleet at 0/5 with four spare slots. The
730
+ // next scheduled pass usually recovers — this is throughput, not correctness
731
+ // — but an operator-driven unblock that cannot refill the fleet has not done
732
+ // what it says.
733
+ //
734
+ // So a lifecycle label read from the LIST is a suspicion, not a verdict: the
735
+ // exact per-issue read decides. Bounded to candidates that would otherwise be
736
+ // dropped for a lifecycle label — never the whole queue — and further to those
737
+ // whose newest run is not active (a genuinely in-flight issue needs no
738
+ // revalidation; its own row is the authority) and that are not operator-parked
739
+ // (a park is a decision, not a stale label).
740
+ //
741
+ // An unreadable exact read keeps the stale labels, so the issue stays out of
742
+ // this pass, and is held as `issue-state-lookup-error` rather than
743
+ // `stale-lifecycle`: "the tracker could not say" is not evidence of a
744
+ // residual label, and it must not summon Duty 1 to reconcile a label nobody
745
+ // has read.
746
+ const stateLabelSet = new Set(Object.values(project.stateLabels));
747
+ const suspect = effective.filter(
748
+ (issue) =>
749
+ !isEligible(issue, project) &&
750
+ issue.labels.some((l) => stateLabelSet.has(l)) &&
751
+ !issue.labels.includes(project.stateLabels.backlog) &&
752
+ !ACTIVE_STATES.includes(store.latestRun(project.name, issue.number)?.state ?? "merged"),
753
+ );
754
+ const revalidated = new Map<number, readonly string[]>();
755
+ const unreadable = new Set<number>();
756
+ for (const issue of suspect) {
757
+ let snapshot: IssueSnapshot | undefined;
758
+ try {
759
+ snapshot = await d.tracker.issueSnapshot(issue.number);
760
+ } catch {
761
+ snapshot = undefined;
762
+ }
763
+ if (snapshot === undefined) {
764
+ unreadable.add(issue.number);
765
+ log(`#${issue.number} lifecycle labels could not be revalidated — holding this pass`);
766
+ continue;
767
+ }
768
+ // A closed issue is nobody's candidate; leave the list labels alone and let
769
+ // the ordinary gates speak.
770
+ if (snapshot.state === "closed") continue;
771
+ if (snapshot.labels.join("\u0000") === issue.labels.join("\u0000")) continue;
772
+ revalidated.set(issue.number, snapshot.labels);
773
+ log(`#${issue.number} lifecycle labels revalidated: the queue list was stale`);
774
+ }
775
+ const verified =
776
+ revalidated.size === 0
777
+ ? effective
778
+ : effective.map((issue) => {
779
+ const live = revalidated.get(issue.number);
780
+ return live === undefined ? issue : { ...issue, labels: [...live] };
781
+ });
782
+ const { routed, unroutable } = route(verified, project);
783
+ // route() drops a candidate for two reasons, only one of which is a claim
784
+ // question. A lifecycle state label (agent:in-progress/blocked/failed)
785
+ // marks a run-owned issue: it is genuinely in flight while its newest run
786
+ // is live or settling, and a terminal newest run — or no run at all — means
787
+ // the label is residual, the footprint of a settled run nobody cleared,
788
+ // which is Duty 1 reconciliation work, not occupied capacity. A missing
789
+ // queue label instead marks an issue the outbox is withdrawing (a pending
790
+ // queue-label removal, e.g. releaseQueueLabel after a merge the PR did not
791
+ // close), which belongs in none of the three populations. So `claimed` is
792
+ // defined from actual ownership over genuinely lifecycle-labelled candidates
793
+ // rather than as the residual of routing (#228, #611).
794
+ // Every population below reads the VERIFIED list, so a candidate the exact
795
+ // read cleared is not simultaneously admitted and counted as held (#891).
796
+ const dropped = verified.filter(
797
+ (issue) => !isEligible(issue, project) && issue.labels.some((l) => stateLabelSet.has(l)),
798
+ );
799
+ // The operator's park label is not a stale lifecycle label: the queue query
800
+ // still returns a parked-and-queued issue, route() drops it as ineligible,
801
+ // and the claim gate holds it as `issue-parked` when the park lands mid-pass
802
+ // (#734). Counting it as stale-lifecycle below would summon the orchestrator
803
+ // to reconcile a deliberate decision. Parked is its own population, derived
804
+ // from the same isEligible read the gate uses, so the number status renders
805
+ // cannot disagree with what admission would hold (#507).
806
+ const parkedCandidates = verified.filter(
807
+ (issue) => !isEligible(issue, project) && issue.labels.includes(project.stateLabels.backlog),
808
+ );
809
+ const parkedNumbers = new Set(parkedCandidates.map((issue) => issue.number));
810
+ let claimed = 0;
811
+ let parked = 0;
812
+ const lifecycleHolds: AdmissionHold[] = [];
813
+ for (const issue of dropped) {
814
+ const newest = store.latestRun(project.name, issue.number);
815
+ if (newest !== undefined && ACTIVE_STATES.includes(newest.state)) {
816
+ claimed += 1;
817
+ } else if (parkedNumbers.has(issue.number)) {
818
+ // A park never kills a live run; a parked issue with no run is inventory
819
+ // the operator is deliberately holding, not reconciliation work.
820
+ parked += 1;
821
+ } else if (unreadable.has(issue.number)) {
822
+ // Fail closed, and say which read failed: a tracker that could not answer
823
+ // is not evidence of a residual label (#891).
824
+ lifecycleHolds.push({
825
+ issue: issue.number,
826
+ reason: "issue-state-lookup-error",
827
+ detail: "lifecycle labels could not be revalidated against the exact issue read",
828
+ });
829
+ } else {
830
+ lifecycleHolds.push({ issue: issue.number, reason: "stale-lifecycle" });
831
+ }
832
+ }
833
+ const routingHolds: AdmissionHold[] = [
834
+ ...unroutable.map(
835
+ (u): AdmissionHold => ({ issue: u.issue.number, reason: `unroutable:${u.reason}` }),
836
+ ),
837
+ ...lifecycleHolds,
838
+ ];
839
+ const recordDispatch = (admitted: number, holds: readonly AdmissionHold[]): void => {
840
+ store.recordDispatch(
841
+ project.name,
842
+ summarizeDispatch(ready.length, routed.length, claimed, admitted, holds, Date.now(), settled, parked),
843
+ );
844
+ };
845
+
846
+ // An issue nobody can route never reaches a worker: guessing the target repo
847
+ // is exactly the kind of improvisation this system exists to prevent. The
848
+ // summary is stable so a queue left unfixed pages once, not every tick.
849
+ for (const u of unroutable) {
850
+ await safeEscalate(d, {
851
+ tier: 1,
852
+ project: project.name,
853
+ issue: u.issue.number,
854
+ summary: `#${u.issue.number} cannot be routed: ${UNROUTABLE_TEXT[u.reason]}`,
855
+ detail: [
856
+ u.issue.title,
857
+ u.issue.url,
858
+ `Repo labels seen: ${u.labels.length > 0 ? u.labels.join(", ") : "(none)"}`,
859
+ `Configured repos: ${Object.keys(project.routing.repos).join(", ") || "(none)"}`,
860
+ `Fix: put exactly one \`${project.routing.labelPrefix}<repo>\` label on the issue.`,
861
+ ].join("\n"),
862
+ });
863
+ }
864
+
865
+ const since = startOfToday();
866
+
867
+ // Spend is the one cap that stops the fleet instead of merely deferring work.
868
+ // A loop that is burning money has to halt itself; waiting for a human to
869
+ // notice tomorrow is how a runaway becomes expensive. `null` means the
870
+ // operator opted out — turns and wall-clock still brake every run (#46).
871
+ const spent = store.spendSince(project.name, since);
872
+ if (caps.dailySpendUsd !== null && spent >= caps.dailySpendUsd) {
873
+ setPaused(
874
+ true,
875
+ { source: "spend-cap", reason: `daily spend reached $${caps.dailySpendUsd}` },
876
+ project.name,
877
+ );
878
+ await safeEscalate(d, {
879
+ tier: 2,
880
+ category: "fleet-stopped",
881
+ project: project.name,
882
+ issue: NO_ISSUE,
883
+ // Dated so the same cap pages again tomorrow, but only once per day.
884
+ summary: `Daily spend cap reached on ${new Date().toISOString().slice(0, 10)} — ${project.name} is paused`,
885
+ detail: [
886
+ `Spent $${spent.toFixed(2)} of the $${caps.dailySpendUsd.toFixed(2)} daily cap.`,
887
+ "Dispatch stays paused until today's spend falls below the cap, then resumes",
888
+ "automatically on the next pass — `omp-conductor resume` only speeds that up.",
889
+ ].join("\n"),
890
+ });
891
+ recordDispatch(0, [
892
+ ...routingHolds,
893
+ ...routed.map((r) => ({ issue: r.issue.number, reason: "daily-spend-cap" as const })),
894
+ ]);
895
+ return;
896
+ }
897
+
898
+ // Grooming, from here on, is the daemon's own duty (#1041). Its place in the
899
+ // tick is the argument for it: BELOW the daily spend cap, because a batch of
900
+ // scouts is real money and a fleet that has stopped spending must stop
901
+ // spending on grooming too; ABOVE the capacity gate, because a full fleet
902
+ // with a draining queue is precisely when the next specs have to be written —
903
+ // the queue those workers will pull from next is empty right now, and waiting
904
+ // for a free slot to notice is how the loop stalls between batches.
905
+ //
906
+ // It reads this pass's own routable count, so the drought and the response
907
+ // happen in one pass instead of one apiece, and it never blocks: the launches
908
+ // ride the worker pool exactly like adjudications.
909
+ try {
910
+ await dispatchToSpecGrooming(d, routed.length, workers);
911
+ } catch (err) {
912
+ log(`grooming pass failed: ${errText(err)}`);
913
+ }
914
+
915
+ // Two different questions, deliberately two queries. Capacity counts worker
916
+ // *processes*, so a green PR awaiting a human merge must not consume a slot —
917
+ // two of those would otherwise stop the fleet. That same PR's *issue* must
918
+ // still be occupied, which is what `admitCandidates`' busy set is for.
919
+ const live = store.liveRuns(project.name);
920
+ const slots = caps.maxConcurrentWorkers - live.length;
921
+ if (slots <= 0) {
922
+ log(`at capacity: ${live.length}/${caps.maxConcurrentWorkers} workers`);
923
+ recordDispatch(0, [
924
+ ...routingHolds,
925
+ ...routed.map((r) => ({ issue: r.issue.number, reason: "capacity" as const })),
926
+ ]);
927
+ return;
928
+ }
929
+
930
+ const pass = await admitCandidates(d, routed, slots);
931
+
932
+ // The stop fence (#374): the run loop only re-reads `stopping` between
933
+ // whole ticks, so a pass already in flight when SIGTERM/SIGINT landed must
934
+ // re-check the drain signal here — after candidate admission, before any
935
+ // claim or launch — or the shutdown path admits exactly the work it is
936
+ // about to wait for and then loses to the stop timeout. The candidates keep
937
+ // their queue labels; the next daemon start re-dispatches them.
938
+ if (d.drain?.draining === true) {
939
+ recordDispatch(0, [
940
+ ...routingHolds,
941
+ ...pass.holds,
942
+ ...pass.admitted.map((a) => ({ issue: a.r.issue.number, reason: "shutting-down" as const })),
943
+ ]);
944
+ return;
945
+ }
946
+ recordDispatch(pass.admitted.length, [...routingHolds, ...pass.holds]);
947
+ await recordInstallSurfaces(d);
948
+ reconcilePanes(d, project.name, log);
949
+
950
+ if (pass.admitted.length === 0) return;
951
+
952
+ log(`dispatching ${pass.admitted.map((a) => `#${a.r.issue.number}`).join(" ")}`);
953
+ await dispatchAdmissions(
954
+ pass.admitted,
955
+ (a) => handleIssue(d, a.r, a.attempt, a.lane, a.model),
956
+ workers,
957
+ );
958
+
959
+ // Post-admission flush: freshly claimed runs enqueued their in-progress
960
+ // label inside `handleIssue`; applying it now means the guard label lands on
961
+ // GitHub within the same tick on the healthy path, not five minutes later
962
+ // (#201).
963
+ try {
964
+ await projectLabels(store, d.tracker, project);
965
+ } catch (err) {
966
+ log(`label projection failed: ${errText(err)}`);
967
+ }
968
+ }