omp-conductor 0.15.9 → 0.15.11

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 (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
package/src/daemon.ts CHANGED
@@ -20,16 +20,23 @@ import {
20
20
  } from "./config.ts";
21
21
  import { availabilityState, type AvailabilityState } from "./availability.ts";
22
22
  import {
23
+ UNREADABLE_TREE_FLAG,
23
24
  analyseSettlement,
25
+ deriveChangedLine,
24
26
  formatSettlementFlags,
25
27
  settlementFlagSummary,
28
+ withDerivedChangedLine,
26
29
  } from "./diff-flags.ts";
27
30
  import { digestScheduleState, type DigestScheduleState } from "./digest-schedule.ts";
28
31
  import { createEscalator, escalationIssueRef } from "./escalate.ts";
29
32
  import { pendingCodeGraph, probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
30
33
  import { graphHint } from "./graph.ts";
31
- import { livingDaemon } from "./lifecycle.ts";
34
+ import { acquireOnceLease, healthCheck, livingDaemon } from "./lifecycle.ts";
32
35
  import { requestImmediateTick, resolveTickConfigCwd, STALL_MARKER_FILE } from "./orchestrator-tick.ts";
36
+ import { runDoctor } from "./doctor.ts";
37
+ import { appendJournal, launchTransientUnit, readUpgradeJournal } from "./upgrade-journal.ts";
38
+ import { runCommand, verifyPendingUpgrade, type UpgradeVerifyDeps } from "./upgrade-verify.ts";
39
+ import { fleetLayers } from "./fleet.ts";
33
40
  import { startOrchestrator } from "./orchestrator.ts";
34
41
  import type { OrchestratorHandle } from "./orchestrator.ts";
35
42
  import {
@@ -219,6 +226,13 @@ interface Deps {
219
226
  * the real one.
220
227
  */
221
228
  verbActions?: VerbActions;
229
+ /**
230
+ * The fleet-installs-itself verification pass (#486), run on the first tick
231
+ * after a detached upgrade. The default is the journal-gated
232
+ * {@link verifyPendingUpgradeTick}; a test injects its own to drive the tick
233
+ * without touching the host's packages, journal or outbox.
234
+ */
235
+ upgradeVerifier?: (d: Deps) => Promise<void>;
222
236
  }
223
237
 
224
238
  /**
@@ -981,6 +995,56 @@ export function hasContinuationBudget(stops: number, maxContinuations: number):
981
995
  return stops <= maxContinuations;
982
996
  }
983
997
 
998
+ /** True while unspent failed-implementation attempts remain. This is the
999
+ * dispatcher's admission gate: once every `maxAttemptsPerIssue` slot is
1000
+ * spent, the issue is held as `failed-attempts` forever, and the `unblock`
1001
+ * verb withholds the queue label on the same predicate (#348). */
1002
+ export function hasFailedAttemptBudget(failures: number, maxAttempts: number): boolean {
1003
+ return failures < maxAttempts;
1004
+ }
1005
+
1006
+ /** The failure classes `countContinuations` deliberately does not charge — the
1007
+ * inverted copy of its exclusions, kept beside the breakdown that consumes it
1008
+ * so the two can only drift together (#439). `orphan-clean` is absent on
1009
+ * purpose: daemon orphans consume the continuation budget, which is exactly
1010
+ * why the requeue side has to respect the ceiling instead of racing it. */
1011
+ const NON_CONTINUATION_CLASSES: Partial<Record<FailureClass, true>> = {
1012
+ "admin-kill": true,
1013
+ "settlement-stuck": true,
1014
+ "env-start-failure": true,
1015
+ "dispatch-infra": true,
1016
+ "provider-credit": true,
1017
+ "provider-transient": true,
1018
+ };
1019
+
1020
+ /** How one issue spent its continuation budget, grouped by failure class —
1021
+ * the exact rows `continuationsFor` charges, so an exhaustion escalation
1022
+ * reports the same budget it says is spent. `unclassified` groups rows that
1023
+ * charged before the class was written (a pre-upgrade NULL). */
1024
+ function continuationBreakdown(runs: readonly RunRecord[]): Map<string, number> {
1025
+ const perClass = new Map<string, number>();
1026
+ for (const r of runs) {
1027
+ const chargedAsKilledOrOrphaned =
1028
+ (r.state === "killed" || r.state === "orphaned" || r.state === "blocked") &&
1029
+ (r.failureClass === undefined || NON_CONTINUATION_CLASSES[r.failureClass] === undefined);
1030
+ const chargedAsReturned = r.state === "failed" && r.failureClass === "returned-for-revision";
1031
+ if (!chargedAsKilledOrOrphaned && !chargedAsReturned) continue;
1032
+ const cls = r.failureClass ?? "unclassified";
1033
+ perClass.set(cls, (perClass.get(cls) ?? 0) + 1);
1034
+ }
1035
+ return perClass;
1036
+ }
1037
+
1038
+ /** The newest attempt's preserved work, if any, so an exhaustion escalation can
1039
+ * say whether continuing is worthwhile: `salvageSha`/`headSha`/`prUrl` are the
1040
+ * three artifacts a run can leave, and all null on a branch nothing reached. */
1041
+ function newestContinuableRun(runs: readonly RunRecord[]): RunRecord | undefined {
1042
+ const newestFirst = [...runs].reverse();
1043
+ return newestFirst.find(
1044
+ (r) => r.salvageSha !== undefined || r.headSha !== undefined || r.prUrl !== undefined,
1045
+ );
1046
+ }
1047
+
984
1048
  export type ExtendTurnLimitResult =
985
1049
  | { kind: "extended"; runId: string; maxTurns: number }
986
1050
  | { kind: "not-increase"; runId: string; maxTurns: number }
@@ -1184,6 +1248,19 @@ export async function verifyPushedGreenClaim(
1184
1248
  };
1185
1249
  }
1186
1250
 
1251
+ /** What the settlement audit of one green run produced: the advisory flags
1252
+ * (test weakening only — the file-list disclosure is derived, not flagged),
1253
+ * whether the diff was cut short, and the `changed:` line composed from the
1254
+ * PR's own diff. */
1255
+ export interface SettlementAuditResult {
1256
+ flags: SettlementFlag[];
1257
+ truncated: boolean;
1258
+ /** The `changed:` file list derived from the PR's diff, present whenever the
1259
+ * diff could be read. Absent means the tree could not be read, and `flags`
1260
+ * then carries exactly {@link UNREADABLE_TREE_FLAG}. */
1261
+ changedLine?: string;
1262
+ }
1263
+
1187
1264
  /**
1188
1265
  * Audit a worker's own account of its work against the pull request it pushed.
1189
1266
  *
@@ -1192,27 +1269,21 @@ export async function verifyPushedGreenClaim(
1192
1269
  * shares none of its authority — that function decides a run's state, this one
1193
1270
  * cannot, by construction. It returns evidence and the caller appends it.
1194
1271
  *
1195
- * Returns undefined when the audit could not run at all: no PR to read, or a
1196
- * tracker that could not produce a diff. Undefined is *not* an empty flag list.
1197
- * An empty list means "read the whole diff, found nothing"; undefined means
1198
- * "did not look", and the report says so rather than presenting silence as a
1199
- * clean bill.
1272
+ * A diff that cannot be read is a finding (`changed-line-missing`), never an
1273
+ * empty flag list: nothing was derived and nothing was checked, and that must
1274
+ * not read as a clean bill.
1200
1275
  */
1201
1276
  export async function collectSettlementFlags(
1202
1277
  tracker: Pick<Tracker, "prDiff">,
1203
- claim: { prUrl?: string; report: string; priorReports?: readonly string[]; issueText: string },
1204
- ): Promise<{ flags: SettlementFlag[]; truncated: boolean } | undefined> {
1205
- if (claim.prUrl === undefined) return undefined;
1278
+ claim: { prUrl?: string; issueText: string },
1279
+ ): Promise<SettlementAuditResult> {
1280
+ if (claim.prUrl === undefined) return { flags: [UNREADABLE_TREE_FLAG], truncated: false };
1206
1281
  const diff = await tracker.prDiff(claim.prUrl);
1207
- if (diff === undefined) return undefined;
1282
+ if (diff === undefined) return { flags: [UNREADABLE_TREE_FLAG], truncated: false };
1208
1283
  return {
1209
- flags: analyseSettlement({
1210
- report: claim.report,
1211
- issueText: claim.issueText,
1212
- diff,
1213
- priorReports: claim.priorReports,
1214
- }),
1284
+ flags: analyseSettlement({ issueText: claim.issueText, diff }),
1215
1285
  truncated: diff.truncated,
1286
+ changedLine: deriveChangedLine(diff),
1216
1287
  };
1217
1288
  }
1218
1289
 
@@ -1273,6 +1344,48 @@ export function workerReleaseBlockRecorder(
1273
1344
  );
1274
1345
  }
1275
1346
 
1347
+ /**
1348
+ * The exact PR a new claim inherits from its terminal predecessor (#434).
1349
+ *
1350
+ * `recentRuns` shows only the newest attempt per issue, and the mediated verbs
1351
+ * resolve a PR's owner through the store — so the moment an orphan-clean
1352
+ * requeue creates its continuation row, the predecessor's green PR (recorded
1353
+ * on the older row) stops resolving and every mediated verb refuses it as "not
1354
+ * a pull request any run opened". Seeding the continuation row with the same
1355
+ * PR at claim time closes that window: ownership never depends on a worker
1356
+ * rediscovering and re-reporting the PR.
1357
+ *
1358
+ * The checks are deliberately the exact counterpart of the store query's scope:
1359
+ * the claim's project/issue/repo/branch must all match the predecessor, the
1360
+ * predecessor must be terminal (a live row is still doing its own work, and a
1361
+ * claim beside one is already a double dispatch), and the tracker must confirm
1362
+ * the PR is still open — a closed or merged predecessor PR is settled by the
1363
+ * sweep against the row that owns it, never carried onto a fresh attempt. An
1364
+ * unreadable tracker fails closed: the new row simply starts without PR
1365
+ * ownership, exactly as before #434, and adoption or the worker's own
1366
+ * pr_create recovers the link.
1367
+ */
1368
+ export async function inheritedPrForContinuation(
1369
+ tracker: Tracker,
1370
+ prior: RunRecord | undefined,
1371
+ claim: { project: string; issue: number; repo: string; branch: string },
1372
+ ): Promise<{ prUrl: string; headSha?: string } | undefined> {
1373
+ if (prior === undefined || prior.prUrl === undefined) return undefined;
1374
+ // Terminal only. A live predecessor owns its PR outright and is not done
1375
+ // with it; inheriting would launder a double dispatch's row.
1376
+ if (prior.state === "claimed" || prior.state === "running") return undefined;
1377
+ if (prior.project !== claim.project || prior.issue !== claim.issue) return undefined;
1378
+ if (prior.repo !== claim.repo || prior.branch !== claim.branch) return undefined;
1379
+ let state: PrState | undefined;
1380
+ try {
1381
+ state = await tracker.prState(prior.prUrl);
1382
+ } catch {
1383
+ return undefined;
1384
+ }
1385
+ if (state !== "open") return undefined;
1386
+ return { prUrl: prior.prUrl, ...(prior.headSha === undefined ? {} : { headSha: prior.headSha }) };
1387
+ }
1388
+
1276
1389
  export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
1277
1390
  const { project, caps, tracker, store } = d;
1278
1391
  const issue = r.issue.number;
@@ -1429,7 +1542,8 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1429
1542
  // (#201).
1430
1543
  // Read before this attempt's own row exists, so `latestRun` still means the
1431
1544
  // attempt whose work this one inherits.
1432
- const priorSalvage = store.latestRun(project.name, issue)?.salvageSha;
1545
+ const prior = store.latestRun(project.name, issue);
1546
+ const priorSalvage = prior?.salvageSha;
1433
1547
  run = store.createRun({
1434
1548
  project: project.name,
1435
1549
  issue,
@@ -1443,6 +1557,27 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1443
1557
  maxTurns: caps.workerMaxTurns,
1444
1558
  startedAt: Date.now(),
1445
1559
  });
1560
+ // #434: carry a terminal predecessor's open PR onto the continuation row.
1561
+ // The claim itself stays synchronous — `handleIssue` claims at the top of
1562
+ // its dispatch burst, the window the shutdown fence and its tests rely on
1563
+ // (#374) — so the tuple-exact, tracker-verified inheritance lands here,
1564
+ // still before any provisioning, network call or verb socket. The new row
1565
+ // is `recentRuns`' newest attempt for the issue the moment it exists, so
1566
+ // without this a requeue hides the green PR the orphaned attempt opened
1567
+ // until a worker happens to rediscover it.
1568
+ const inherited = await inheritedPrForContinuation(tracker, prior, {
1569
+ project: project.name,
1570
+ issue,
1571
+ repo: r.repo.name,
1572
+ branch,
1573
+ });
1574
+ if (inherited !== undefined && prior !== undefined) {
1575
+ store.updateRun(run.id, {
1576
+ prUrl: inherited.prUrl,
1577
+ ...(inherited.headSha === undefined ? {} : { headSha: inherited.headSha }),
1578
+ });
1579
+ log(`#${issue} inherited PR ${inherited.prUrl} from attempt ${prior.attempt} (${prior.state})`);
1580
+ }
1446
1581
  const maxTurns = run.maxTurns;
1447
1582
  const turnOverride = maxTurns > caps.workerMaxTurns ? maxTurns : undefined;
1448
1583
  const runId = run.id;
@@ -1618,39 +1753,37 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1618
1753
  // The other half of not believing a worker about its own run (#128). The
1619
1754
  // claim being audited is `state: pushed-green`, so the audit runs on the
1620
1755
  // worker's claim rather than on what verification made of it: a claim that
1621
- // GitHub then contradicted is exactly the report whose `changed:` line is
1622
- // worth reading twice. Advisory throughout — nothing below reads `flags`
1623
- // when deciding `state`, and the ordering here makes that checkable.
1756
+ // GitHub then contradicted is exactly the report whose details matter
1757
+ // most. Advisory throughout — nothing below reads `flags` when deciding
1758
+ // `state`, and the ordering here makes that checkable.
1624
1759
  const audit =
1625
1760
  result.state === "pushed-green"
1626
1761
  ? await collectSettlementFlags(tracker, {
1627
1762
  prUrl: result.prUrl,
1628
- report: result.report,
1629
- // Earlier attempts' reports pool as disclosure coverage (#199). The
1630
- // current row has not settled yet, so this is exactly the prior set;
1631
- // the strict `< attempt` guard keeps an already-persisted copy of
1632
- // this attempt out just in case. Pre-#199 rows have NULL reports and
1633
- // contribute nothing — today's behaviour.
1634
- priorReports: store
1635
- .attemptReports(project.name, issue)
1636
- .filter((r) => r.attempt < attempt)
1637
- .map((r) => r.report),
1638
1763
  issueText: `${r.issue.title}\n${r.issue.body}`,
1639
1764
  })
1640
1765
  : undefined;
1641
- if (result.state === "pushed-green") {
1642
- if (audit === undefined) log(`#${issue} settlement audit skipped: no readable PR diff`);
1643
- else if (audit.truncated) log(`#${issue} settlement audit read a truncated PR diff`);
1766
+ if (result.state === "pushed-green" && audit?.truncated) {
1767
+ log(`#${issue} settlement audit read a truncated PR diff`);
1644
1768
  }
1645
1769
  const auditLines =
1646
1770
  audit === undefined
1647
1771
  ? []
1648
- : formatSettlementFlags(audit.flags, { truncated: audit.truncated, attempts: attempt });
1772
+ : formatSettlementFlags(audit.flags, { truncated: audit.truncated });
1773
+ // The report's file list is derived from the PR's own diff, not retyped
1774
+ // from the worker's memory of the session: the narrative stays the
1775
+ // worker's, the disclosure becomes the diff's (#488). A diff that could
1776
+ // not be read leaves the worker's text untouched and the audit's
1777
+ // `changed-line-missing` flag says so.
1778
+ const settlementReport =
1779
+ audit?.changedLine === undefined
1780
+ ? result.report
1781
+ : withDerivedChangedLine(result.report, audit.changedLine);
1649
1782
 
1650
1783
  const finalReport = [
1651
1784
  ...(verified.reason === undefined ? [] : [verified.reason, ""]),
1652
1785
  ...(auditLines.length === 0 ? [] : [...auditLines, ""]),
1653
- result.report,
1786
+ settlementReport,
1654
1787
  ].join("\n");
1655
1788
 
1656
1789
  // What becomes of the tree, decided once, before any label or page. A
@@ -1695,12 +1828,18 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1695
1828
  endedAt: Date.now(),
1696
1829
  turns: result.turns,
1697
1830
  spendUsd: result.spendUsd,
1698
- prUrl: result.prUrl,
1699
- headSha: result.headSha,
1831
+ // The worker only reports these when it actually established them; a kill
1832
+ // or a settle whose report named no PR must not wipe what a verb recorded
1833
+ // earlier in the same run (#468). The sink in `updateRun` skips undefined
1834
+ // too, but a settle should not even build such a patch.
1835
+ ...(result.prUrl === undefined ? {} : { prUrl: result.prUrl }),
1836
+ ...(result.headSha === undefined ? {} : { headSha: result.headSha }),
1700
1837
  sessionFile: result.sessionFile,
1701
- // Every terminal state persists the worker's report, not just a green
1702
- // push: a stopped attempt's partial report is still part of its audit trail.
1703
- report: result.report,
1838
+ // Every terminal state persists the worker's report — with the `changed:`
1839
+ // file list derived from the PR's diff where one could be read — not
1840
+ // just a green push: a stopped attempt's partial report is still part of
1841
+ // its audit trail.
1842
+ report: settlementReport,
1704
1843
  ...settlement?.patch,
1705
1844
  ...(audit === undefined || audit.flags.length === 0
1706
1845
  ? {}
@@ -2275,8 +2414,13 @@ export async function watchBaseHealth(
2275
2414
 
2276
2415
  export async function settlePushedGreen(
2277
2416
  d: Pick<Deps, "project" | "tracker" | "store">,
2278
- ): Promise<void> {
2417
+ ): Promise<number> {
2279
2418
  const { project, tracker, store } = d;
2419
+ // Runs the sweep resolved by terminalising the row. Every terminal write
2420
+ // below increments it; the tick attributes it as `settled` on the pass's
2421
+ // dispatch record, so a held pass is visible as work done, not just as a
2422
+ // clock that moved (#497).
2423
+ let settled = 0;
2280
2424
  // Filtered from the active set rather than asked for with a new query: active
2281
2425
  // is live workers plus these, so the list is bounded by the worker cap plus
2282
2426
  // the number of PRs awaiting a merge — a handful, by construction. A fleet
@@ -2343,6 +2487,7 @@ export async function settlePushedGreen(
2343
2487
  patch.recoveryAction = "none";
2344
2488
  }
2345
2489
  store.updateRun(run.id, patch);
2490
+ settled += 1;
2346
2491
  log(`#${run.issue} settled: ${settlement.reason}`);
2347
2492
  continue;
2348
2493
  }
@@ -2357,7 +2502,7 @@ export async function settlePushedGreen(
2357
2502
  }
2358
2503
  if (verification === undefined) continue;
2359
2504
  if (verification.status === "green") {
2360
- store.updateRun(run.id, { state: "pushed-green", lastError: undefined });
2505
+ store.updateRun(run.id, { state: "pushed-green", lastError: null });
2361
2506
  log(`#${run.issue} checks settled: ${verification.reason}`);
2362
2507
  } else if (verification.status === "failed") {
2363
2508
  // Equally terminal, so the release is enqueued before the row writes,
@@ -2366,11 +2511,13 @@ export async function settlePushedGreen(
2366
2511
  // and its live PR is exactly the work the label must keep guarding.
2367
2512
  releaseInProgress(d, run.issue, verification.reason);
2368
2513
  store.updateRun(run.id, { state: "failed", lastError: verification.reason });
2514
+ settled += 1;
2369
2515
  log(`#${run.issue} checks failed: ${verification.reason}`);
2370
2516
  } else {
2371
2517
  store.updateRun(run.id, { lastError: verification.reason });
2372
2518
  }
2373
2519
  }
2520
+ return settled;
2374
2521
  }
2375
2522
 
2376
2523
  const ADOPTABLE_PR_STATES: Partial<Record<RunState, true>> = {
@@ -2612,6 +2759,7 @@ export function summarizeDispatch(
2612
2759
  admitted: number,
2613
2760
  holds: readonly AdmissionHold[],
2614
2761
  completedAt = Date.now(),
2762
+ settled = 0,
2615
2763
  ): DispatchSummary {
2616
2764
  const groups = new Map<AdmissionHoldReason, { count: number; issues: number[] }>();
2617
2765
  for (const hold of holds) {
@@ -2630,6 +2778,27 @@ export function summarizeDispatch(
2630
2778
  holds: [...groups]
2631
2779
  .sort(([a], [b]) => a.localeCompare(b))
2632
2780
  .map(([reason, group]) => ({ reason, ...group })),
2781
+ settled,
2782
+ };
2783
+ }
2784
+
2785
+ /**
2786
+ * The record of a held pass (#497): the loop ran — settling and reconciling
2787
+ * above the pause gate — but routing never happened and nothing was admitted.
2788
+ * `ready`/`routed`/`claimed` stay 0 because they were never computed; the
2789
+ * `paused` flag is what keeps a reader from reading them as an empty queue.
2790
+ */
2791
+ export function summarizeHeldPass(settled: number, completedAt = Date.now()): DispatchSummary {
2792
+ return {
2793
+ completedAt,
2794
+ ready: 0,
2795
+ routed: 0,
2796
+ claimed: 0,
2797
+ admitted: 0,
2798
+ degraded: false,
2799
+ holds: [],
2800
+ settled,
2801
+ paused: true,
2633
2802
  };
2634
2803
  }
2635
2804
 
@@ -2837,7 +3006,7 @@ export async function admitCandidates(
2837
3006
 
2838
3007
  const priorRuns = store.attemptsFor(project.name, issue);
2839
3008
  const failures = store.failuresFor(project.name, issue);
2840
- if (failures >= caps.maxAttemptsPerIssue) {
3009
+ if (!hasFailedAttemptBudget(failures, caps.maxAttemptsPerIssue)) {
2841
3010
  hold(issue, "failed-attempts");
2842
3011
  await safeEscalate(d, {
2843
3012
  tier: 1,
@@ -3103,6 +3272,70 @@ export function wakeOrchestratorForMetConditions(
3103
3272
  }
3104
3273
  }
3105
3274
 
3275
+
3276
+ /**
3277
+ * The first-tick verification for a fleet-initiated upgrade (#486).
3278
+ *
3279
+ * The detached install unit journals its progress and stops, leaving dispatch
3280
+ * paused; whichever daemon comes back after the restart runs this on its
3281
+ * first tick — above every gate that a pause or an integrity mismatch could
3282
+ * close, because the install's own pause is the thing under verification.
3283
+ * A day with no journal costs one file read; only a pending request pays for
3284
+ * the version/health/ticks/pane/doctor checks and the outbox rows.
3285
+ */
3286
+ export function upgradeVerifyDepsFor(d: Pick<Deps, "project" | "store">): UpgradeVerifyDeps {
3287
+ return {
3288
+ projectName: d.project.name,
3289
+ journal: (entry) => appendJournal(stateDir(), entry),
3290
+ run: runCommand,
3291
+ layers: (project) => fleetLayers(project),
3292
+ health: healthCheck,
3293
+ doctor: (name) => runDoctor(name),
3294
+ enqueue: (draft) => {
3295
+ try {
3296
+ d.store.enqueueReport(draft);
3297
+ } catch (err) {
3298
+ log(`upgrade verification report could not be queued: ${errText(err)}`);
3299
+ }
3300
+ },
3301
+ pause: pauseInstance,
3302
+ resume: (project) => setPaused(false, undefined, project),
3303
+ launchRollback: async (version) => {
3304
+ const launched = await launchTransientUnit(
3305
+ (argv) => runCommand(argv[0]!, argv.slice(1)),
3306
+ process.env,
3307
+ { kind: "rollback", version },
3308
+ );
3309
+ return launched.ok === true ? { ok: true, unit: launched.unit } : { ok: false, stderr: launched.stderr };
3310
+ },
3311
+ log,
3312
+ now: () => Date.now(),
3313
+ };
3314
+ }
3315
+
3316
+ async function verifyPendingUpgradeTick(
3317
+ d: Deps,
3318
+ run = verifyPendingUpgrade,
3319
+ ): Promise<void> {
3320
+ // Cheap pre-test: no journal, no pending request, nothing to touch.
3321
+ if (readUpgradeJournal(stateDir()).length === 0) return;
3322
+ const verifyDeps = upgradeVerifyDepsFor(d);
3323
+ try {
3324
+ const outcome = await run(verifyDeps);
3325
+ if (outcome.handled !== "none" && outcome.handled !== "already-closed") {
3326
+ log(
3327
+ `upgrade journal handled: ${outcome.handled}` +
3328
+ `${outcome.version === undefined ? "" : ` for omp-conductor@${outcome.version}`}` +
3329
+ `${outcome.detail === undefined ? "" : ` — ${outcome.detail}`}`,
3330
+ );
3331
+ }
3332
+ } catch (err) {
3333
+ // A torn journal must not take the tick down: the operator still reads it
3334
+ // through `status`, and the next tick retries the same pass.
3335
+ log(`upgrade verification pass failed: ${errText(err)}`);
3336
+ }
3337
+ }
3338
+
3106
3339
  export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
3107
3340
  // A config edit takes effect on the next tick, not the next daemon restart
3108
3341
  // (#170). Re-resolve the project and its caps at the tick boundary so a tick
@@ -3124,6 +3357,16 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
3124
3357
  d.deliveryPolicyValid = false;
3125
3358
  }
3126
3359
 
3360
+ // The fleet-installs-itself transaction (#486): the detached install unit
3361
+ // paused dispatch and stopped; the first tick after the restart verifies
3362
+ // against its journal and closes or rolls back. Above the pause gate on
3363
+ // purpose — the install's own pause is exactly the state being verified.
3364
+ try {
3365
+ await (d.upgradeVerifier ?? verifyPendingUpgradeTick)(d);
3366
+ } catch (err) {
3367
+ log(`upgrade journal pass failed: ${errText(err)}`);
3368
+ }
3369
+
3127
3370
  // Availability-held notices are already durable. Once the freshly reloaded
3128
3371
  // policy opens (or newly allows their category), atomically hand a bounded
3129
3372
  // batch to the report outbox. Pauses do not suppress delivery.
@@ -3150,8 +3393,10 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
3150
3393
  // Settlement is maintenance, not dispatch. Run it before every gate that can
3151
3394
  // stop claiming — pause, integrity, spend, and capacity — so status converges
3152
3395
  // while the fleet is parked or workers are still active. Resident workers run
3153
- // through the pool without blocking this five-minute tick.
3154
- await settlePushedGreen(d);
3396
+ // through the pool without blocking this five-minute tick. The return value
3397
+ // is what this pass settled; a held pass records it so the paused state reads
3398
+ // as work done, not just a clock that moved (#497).
3399
+ let settled = await settlePushedGreen(d);
3155
3400
  try {
3156
3401
  await watchMergedBase(d);
3157
3402
  } catch (err) {
@@ -3177,7 +3422,7 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
3177
3422
  // Each is guarded on its own: a tracker that fails mid-classification must not
3178
3423
  // stop the label reconcile, and neither may stop the tick.
3179
3424
  try {
3180
- await classifyAndRecover(d);
3425
+ settled += await classifyAndRecover(d);
3181
3426
  } catch (err) {
3182
3427
  log(`classification sweep failed: ${errText(err)}`);
3183
3428
  }
@@ -3228,8 +3473,15 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
3228
3473
  });
3229
3474
 
3230
3475
  // A paused fleet claims nothing. Checked first so pausing takes effect on the
3231
- // next tick without signalling the process.
3232
- if (isPaused(d.project.name)) return;
3476
+ // next tick without signalling the process. But the pass still ran, and the
3477
+ // operator has to be able to see it: record it as a held pass — the work the
3478
+ // maintenance sweeps settled above this gate, and nothing admitted below it —
3479
+ // so `last dispatch` keeps moving while the fleet is deliberately parked. A
3480
+ // frozen clock is then a stall report, not a hold (#497).
3481
+ if (isPaused(d.project.name)) {
3482
+ d.store.recordDispatch(d.project.name, summarizeHeldPass(settled));
3483
+ return;
3484
+ }
3233
3485
 
3234
3486
  const { project, caps, store } = d;
3235
3487
 
@@ -3319,7 +3571,7 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
3319
3571
  const recordDispatch = (admitted: number, holds: readonly AdmissionHold[]): void => {
3320
3572
  store.recordDispatch(
3321
3573
  project.name,
3322
- summarizeDispatch(ready.length, routed.length, claimed, admitted, holds),
3574
+ summarizeDispatch(ready.length, routed.length, claimed, admitted, holds, Date.now(), settled),
3323
3575
  );
3324
3576
  };
3325
3577
 
@@ -3577,7 +3829,7 @@ export async function turnLimitResponse(
3577
3829
  export async function workerControlResponse(
3578
3830
  req: Request,
3579
3831
  project: string,
3580
- store: Pick<Store, "latestRun">,
3832
+ store: Pick<Store, "latestRun" | "updateRun">,
3581
3833
  registry: WorkerControlRegistry,
3582
3834
  ): Promise<Response | undefined> {
3583
3835
  const url = new URL(req.url);
@@ -3671,6 +3923,28 @@ export async function workerControlResponse(
3671
3923
  state: latest.state,
3672
3924
  });
3673
3925
  }
3926
+ // A live-state row whose controller is absent from this process is a ghost
3927
+ // (#431): a session controller lives in the daemon that launched it, and once
3928
+ // ownership is exclusive (the once lease makes a drill hold the same pidfile
3929
+ // as the long dispatcher), a row that is live but has no controller in *this*
3930
+ // owner is a row whose owner already died — not work another live daemon is
3931
+ // still writing to. Refuse nothing: recover it as an orphan (worktree kept
3932
+ // for triage, labels untouched, live slot freed) so the issue can be
3933
+ // dispatched again and the row stops counting as a live worker.
3934
+ if (action === "stop") {
3935
+ store.updateRun(latest.id, {
3936
+ state: "orphaned",
3937
+ endedAt: Date.now(),
3938
+ lastError: reason,
3939
+ });
3940
+ return Response.json({
3941
+ outcome: "stopped",
3942
+ runId: latest.id,
3943
+ state: "orphaned",
3944
+ reason: reason!,
3945
+ worktree: latest.worktree,
3946
+ });
3947
+ }
3674
3948
  return Response.json(
3675
3949
  {
3676
3950
  error:
@@ -3683,7 +3957,7 @@ export async function workerControlResponse(
3683
3957
 
3684
3958
  export interface DaemonHttpProjectDeps {
3685
3959
  project: string;
3686
- store: Pick<Store, "latestRun" | "setTurnOverride">;
3960
+ store: Pick<Store, "latestRun" | "setTurnOverride" | "updateRun">;
3687
3961
  caps: () => Pick<Caps, "workerMaxTurns" | "workerMaxTurnsCeiling">;
3688
3962
  }
3689
3963
 
@@ -3692,21 +3966,32 @@ export interface DaemonHttpDeps {
3692
3966
  turnLimits: TurnLimitRegistry;
3693
3967
  workerControls: WorkerControlRegistry;
3694
3968
  health: () => DaemonHealth;
3969
+ /** Request an immediate dispatch pass (`resume`'s wake, #380). */
3970
+ wake: () => void;
3695
3971
  }
3696
3972
 
3697
3973
  /**
3698
3974
  * The whole HTTP surface, in one named function so a test can pin what is *not*
3699
3975
  * on it.
3700
3976
  *
3701
- * Three route families: the health read, turn-limit control, and live-worker
3702
- * pause/resume/stop control. Everything else is 404. The controls mutate only
3703
- * daemon-owned live sessions; tracker and repository mutations stay on the
3704
- * authenticated per-run channel described at the `Bun.serve` call (#126).
3977
+ * Four route families: the health read, the dispatch wake, turn-limit control,
3978
+ * and live-worker pause/resume/stop control. Everything else is 404. The
3979
+ * controls mutate only daemon-owned live sessions; tracker and repository
3980
+ * mutations stay on the authenticated per-run channel described at the
3981
+ * `Bun.serve` call (#126).
3705
3982
  */
3706
3983
  export async function daemonHttpResponse(req: Request, d: DaemonHttpDeps): Promise<Response> {
3707
3984
  const url = new URL(req.url);
3708
3985
  if (req.method === "GET" && url.pathname === "/healthz") return Response.json(d.health());
3709
3986
 
3987
+ if (req.method === "POST" && url.pathname === "/wake") {
3988
+ // A resume wake: daemon-wide and loopback-only, like /healthz. The pass it
3989
+ // produces re-reads every project's pause file, so a project that is still
3990
+ // held stays held — the wake shortens the wait, it never bypasses a gate.
3991
+ d.wake();
3992
+ return new Response(null, { status: 204 });
3993
+ }
3994
+
3710
3995
  let selected = d.projects[0];
3711
3996
  if (
3712
3997
  req.method === "PUT" &&
@@ -3903,21 +4188,31 @@ export function formatDispatchSummary(summary?: DispatchSummary): string {
3903
4188
  if (summary === undefined) return "last dispatch (none recorded)";
3904
4189
  const lines = [
3905
4190
  `last dispatch ${new Date(summary.completedAt).toISOString()}${summary.degraded ? " DEGRADED" : ""}`,
3906
- ` candidates ${summary.ready} ready / ${summary.claimed ?? 0} in flight / ${summary.routed} spare`,
3907
- ` admitted ${summary.admitted}`,
3908
4191
  ];
3909
- if (summary.holds.length === 0) {
3910
- lines.push(" held 0");
4192
+ if (summary.paused === true) {
4193
+ // A held pass never routed the queue: printing zero candidates here would
4194
+ // read as "the queue was empty". Say what the pass was and attribute what
4195
+ // it did instead (#497).
4196
+ lines.push(` pass held — nothing admitted`);
3911
4197
  } else {
3912
- lines.push(" held");
3913
- for (const hold of summary.holds) {
3914
- const sample =
3915
- hold.issues.length === 0
3916
- ? ""
3917
- : ` (#${hold.issues.join(", #")}${hold.count > hold.issues.length ? ", …" : ""})`;
3918
- lines.push(` ${hold.reason} ${hold.count}${sample}`);
4198
+ lines.push(
4199
+ ` candidates ${summary.ready} ready / ${summary.claimed ?? 0} in flight / ${summary.routed} spare`,
4200
+ ` admitted ${summary.admitted}`,
4201
+ );
4202
+ if (summary.holds.length === 0) {
4203
+ lines.push(" held 0");
4204
+ } else {
4205
+ lines.push(" held");
4206
+ for (const hold of summary.holds) {
4207
+ const sample =
4208
+ hold.issues.length === 0
4209
+ ? ""
4210
+ : ` (#${hold.issues.join(", #")}${hold.count > hold.issues.length ? ", …" : ""})`;
4211
+ lines.push(` ${hold.reason} ${hold.count}${sample}`);
4212
+ }
3919
4213
  }
3920
4214
  }
4215
+ if ((summary.settled ?? 0) > 0) lines.push(` settled ${summary.settled}`);
3921
4216
  return lines.join("\n");
3922
4217
  }
3923
4218
 
@@ -4237,8 +4532,12 @@ export function readSessionError(sessionFile: string | undefined): SessionError
4237
4532
  * it is healthy, and writing a class onto it would take it out of this sweep for
4238
4533
  * good.
4239
4534
  */
4240
- export async function classifyAndRecover(d: Deps): Promise<void> {
4241
- const { project, tracker, store } = d;
4535
+ export async function classifyAndRecover(d: Deps): Promise<number> {
4536
+ const { project, caps, tracker, store } = d;
4537
+ // Settle recoveries, counted for the pass's dispatch record — a row whose PR
4538
+ // merged is settled here when the settle sweep could not establish identity
4539
+ // (#497). Every other exit returns 0.
4540
+ let settled = 0;
4242
4541
  for (const run of store.runsNeedingClassification(project.name, CLASSIFY_BATCH)) {
4243
4542
  const facts: ClassifyFacts = {};
4244
4543
  let classifiedRun = run;
@@ -4280,7 +4579,7 @@ export async function classifyAndRecover(d: Deps): Promise<void> {
4280
4579
  continue;
4281
4580
  }
4282
4581
 
4283
- const { cls, recovery, evidence } = classifyRun(classifiedRun, facts);
4582
+ const { cls, recovery, evidence } = classifyRun(classifiedRun, facts, caps);
4284
4583
 
4285
4584
  // A healthy green PR is not a failure of any class. Leaving the row
4286
4585
  // unclassified is what keeps it eligible for the sweep on the tick where its
@@ -4294,8 +4593,10 @@ export async function classifyAndRecover(d: Deps): Promise<void> {
4294
4593
  ? `#${run.issue} retrying ${recovery} for ${cls}: ${evidence}`
4295
4594
  : `#${run.issue} classified ${cls} → ${recovery}: ${evidence}`,
4296
4595
  );
4596
+ if (recovery === "settle") settled += 1;
4297
4597
  await recoverRun(d, classifiedRun, cls, recovery, evidence);
4298
4598
  }
4599
+ return settled;
4299
4600
  }
4300
4601
 
4301
4602
  /** Performs the one action a class names. Never chooses one of its own. */
@@ -4306,7 +4607,7 @@ async function recoverRun(
4306
4607
  recovery: RecoveryAction,
4307
4608
  evidence: string,
4308
4609
  ): Promise<void> {
4309
- const { project, tracker, store } = d;
4610
+ const { project, caps, tracker, store } = d;
4310
4611
  const inProgress = project.stateLabels.inProgress;
4311
4612
 
4312
4613
  if (recovery === "settle") {
@@ -4337,6 +4638,50 @@ async function recoverRun(
4337
4638
  return;
4338
4639
  }
4339
4640
 
4641
+ // `wall-clock-cap-progress`: the completion path only auto-continues
4642
+ // turn-cap kills, so a wall-clock kill with work to show reaches this
4643
+ // sweep still holding the failed label. Swap it for the queue — the branch
4644
+ // is retained, so the next dispatch reattaches it and briefs a resume from
4645
+ // the recorded work. Killed rows gathered no tracker facts, so the
4646
+ // issue-open guard mirrors the requeue path's. The continuation gate is
4647
+ // the same one the turns path applies at kill time
4648
+ // (`shouldContinueAfterTurnsCap`): the row is already charged, and once
4649
+ // the ceiling is spent, handing back the queue label would offer a
4650
+ // candidate admission can never accept — only the failed label comes off,
4651
+ // and the exhaustion reaches a human (#490, #348).
4652
+ if (cls === "wall-clock-cap-progress") {
4653
+ const state = await tracker.issueState(run.issue).catch(() => undefined);
4654
+ if (state !== "open") {
4655
+ log(`#${run.issue} not continued from ${cls}: issue is ${state ?? "unreadable"}`);
4656
+ return;
4657
+ }
4658
+ const continuation = store.continuationsFor(project.name, run.issue);
4659
+ if (hasContinuationBudget(continuation, caps.maxContinuationsPerIssue)) {
4660
+ swapToQueue(d, run.issue, project.stateLabels.failed);
4661
+ store.updateRun(run.id, { recoveredAt: Date.now() });
4662
+ log(`#${run.issue} requeued for a wall-clock continuation: ${evidence}`);
4663
+ } else {
4664
+ store.enqueueLabelOps(project.name, [
4665
+ { issue: run.issue, op: "remove", label: project.stateLabels.failed },
4666
+ ]);
4667
+ store.updateRun(run.id, { recoveredAt: Date.now() });
4668
+ await safeEscalate(d, {
4669
+ tier: 1,
4670
+ project: project.name,
4671
+ issue: run.issue,
4672
+ summary: `#${run.issue} exhausted its continuation budget on wall-clock cap kills`,
4673
+ detail: [
4674
+ `Attempt ${run.attempt} hit the wall-clock cap with work to continue from, but ${continuation} continuation(s) are already charged against a cap of ${caps.maxContinuationsPerIssue}.`,
4675
+ evidence,
4676
+ `Work to continue: branch ${run.branch} at ${run.headSha ?? run.salvageSha}${run.prUrl === undefined ? "" : ` — ${run.prUrl}`}.`,
4677
+ "The issue cannot finish inside the wall-clock cap, so another run would burn a worker slot for the same outcome. Re-scope it, raise maxContinuationsPerIssue for it, or finish the remaining work by hand.",
4678
+ ].join("\n"),
4679
+ });
4680
+ log(`#${run.issue} not continued from ${cls}: continuation budget exhausted`);
4681
+ }
4682
+ return;
4683
+ }
4684
+
4340
4685
  // `merge-conflict`: the branch is retained and its PR is open, so #50's
4341
4686
  // continuation guard admits it and the next tick briefs a rebase.
4342
4687
  //
@@ -4413,6 +4758,68 @@ async function recoverRun(
4413
4758
  log(`#${run.issue} not requeued from ${cls}: issue is ${state ?? "unreadable"}`);
4414
4759
  return;
4415
4760
  }
4761
+ // A clean orphan whose queue label is already absent is a deliberate
4762
+ // withdrawal — the operator took the issue out of the queue (e.g. so a
4763
+ // daemon restart could not re-dispatch work known to be unsafe) — and
4764
+ // recovery must not recreate that intent (#423). Read the live label set;
4765
+ // when the queue label is gone, release the dispatcher-owned in-progress
4766
+ // label and stop, leaving the operator's withdrawal to survive recovery.
4767
+ if (cls === "orphan-clean") {
4768
+ const snapshot = await tracker.issueSnapshot(run.issue).catch(() => undefined);
4769
+ if (snapshot === undefined) {
4770
+ log(`#${run.issue} not requeued from orphan-clean: cannot confirm ${project.queueLabel} (unreadable, retrying)`);
4771
+ return;
4772
+ }
4773
+ if (!snapshot.labels.includes(project.queueLabel)) {
4774
+ store.enqueueLabelOps(project.name, [{ issue: run.issue, op: "remove", label: inProgress }]);
4775
+ store.updateRun(run.id, { recoveredAt: Date.now() });
4776
+ log(`#${run.issue} not requeued from orphan-clean: ${project.queueLabel} removed before recovery (operator withdrawal)`);
4777
+ return;
4778
+ }
4779
+ // #439: an orphan-clean row charges the continuation budget, so once
4780
+ // `hasContinuationBudget` is spent — exactly the predicate `admitCandidates`
4781
+ // holds the issue on — requeueing re-adds a queue label for a candidate the
4782
+ // dispatcher can never admit. Stop handing it back and hold it instead: the
4783
+ // queue label comes off (so admission never re-holds on every dispatch),
4784
+ // the in-progress label is released, and a single diagnosis escalates once.
4785
+ // `orphan-clean` is deliberately NOT a global exclusion from the budget (a
4786
+ // worker that genuinely keeps dying mid-work must still be bounded); this is
4787
+ // the missing ceiling check this path never had (#348's invariant).
4788
+ const continuations = store.continuationsFor(project.name, run.issue);
4789
+ if (!hasContinuationBudget(continuations, caps.maxContinuationsPerIssue)) {
4790
+ const runs = store.runsForIssue(project.name, run.issue);
4791
+ const breakdown = continuationBreakdown(runs);
4792
+ const artifact = newestContinuableRun(runs);
4793
+ const onlyDaemonStops = breakdown.size === 1 && breakdown.get("orphan-clean") === continuations;
4794
+ const classLine = Array.from(breakdown, ([cls, n]) => `${n} ${cls}`).join(", ");
4795
+ const artifactLine =
4796
+ artifact === undefined
4797
+ ? "The attempts left no salvage commit, head SHA or PR — the branch is empty, so start clean from a re-scope rather than continuing from nothing."
4798
+ : `Work to continue: branch ${artifact.branch} at ${artifact.headSha ?? artifact.salvageSha}${artifact.prUrl === undefined ? "" : ` — ${artifact.prUrl}`}.`;
4799
+ store.enqueueLabelOps(project.name, [
4800
+ { issue: run.issue, op: "remove", label: inProgress },
4801
+ { issue: run.issue, op: "remove", label: project.queueLabel },
4802
+ ]);
4803
+ store.updateRun(run.id, { recoveredAt: Date.now() });
4804
+ await safeEscalate(d, {
4805
+ tier: 1,
4806
+ project: project.name,
4807
+ issue: run.issue,
4808
+ summary: `#${run.issue} exhausted its ${caps.maxContinuationsPerIssue}-continuation budget on ${cls}`,
4809
+ detail: [
4810
+ `Attempt ${run.attempt} is not requeued: ${continuations} continuation(s) charged against a cap of ${caps.maxContinuationsPerIssue}.`,
4811
+ `Continuations by failure class: ${classLine}.`,
4812
+ onlyDaemonStops
4813
+ ? "Every continuation was a daemon stop — the work never failed; the budget was spent by daemon deaths, not the issue."
4814
+ : "Continuations span real work — inspect what each attempt left behind before continuing.",
4815
+ artifactLine,
4816
+ "What you can do: raise maxContinuationsPerIssue for this issue, re-scope it, or continue from the preserved work (or start clean if none).",
4817
+ ].join("\n"),
4818
+ });
4819
+ log(`#${run.issue} not requeued from orphan-clean: continuation budget exhausted`);
4820
+ return;
4821
+ }
4822
+ }
4416
4823
  const label = cls === "orphan-clean" ? inProgress : project.stateLabels.failed;
4417
4824
  swapToQueue(d, run.issue, label);
4418
4825
  store.updateRun(run.id, { recoveredAt: Date.now() });
@@ -4431,14 +4838,14 @@ async function recoverRun(
4431
4838
  // Back to pending rather than green: the existing settle sweep re-verifies
4432
4839
  // it against the recorded head on a later tick, so nothing here has to guess
4433
4840
  // whether the re-run passed.
4434
- store.updateRun(run.id, { state: "pushed-pending", lastError: undefined, recoveredAt: Date.now() });
4841
+ store.updateRun(run.id, { state: "pushed-pending", lastError: null, recoveredAt: Date.now() });
4435
4842
  log(`#${run.issue} re-ran infrastructure checks: ${evidence}`);
4436
4843
  return;
4437
4844
  }
4438
4845
 
4439
4846
  if (recovery === "escalate") {
4440
4847
  const detail = [evidence];
4441
- if (cls === "turn-cap-spinning") {
4848
+ if (cls === "turn-cap-spinning" || cls === "wall-clock-cap-spinning") {
4442
4849
  const calls = lastToolCalls(run.sessionFile);
4443
4850
  detail.push(
4444
4851
  calls.length === 0
@@ -4669,7 +5076,112 @@ function orchestratorStandingOrders(project: ProjectConfig): {
4669
5076
  };
4670
5077
  }
4671
5078
 
5079
+ /**
5080
+ * Pacing for the resident dispatch loop, interruptible so an operator action —
5081
+ * `resume` — does not wait out the full interval (#380).
5082
+ *
5083
+ * `requestWake` is sticky until a pass absorbs it: while the long sleep is in
5084
+ * flight it is cut short, and when a pass is mid-flight the loop skips the
5085
+ * *following* sleep. Either way the next pass starts without the interval
5086
+ * wait. The single boolean coalesces any number of near-simultaneous wakes
5087
+ * into one prompt pass, and the strictly sequential loop means a wake can
5088
+ * never start a pass on top of one already running.
5089
+ */
5090
+ export interface DispatchPace {
5091
+ /** Ask for a prompt pass; cuts an in-flight sleep short. Coalesces. */
5092
+ requestWake(): void;
5093
+ /** Absorb a pending request — true when the pass that just finished should
5094
+ * skip its sleep. */
5095
+ takeWake(): boolean;
5096
+ /** The long interval sleep; resolves early when {@link requestWake} fires. */
5097
+ sleep(ms: number): Promise<void>;
5098
+ }
5099
+
5100
+ export function createDispatchPace(): DispatchPace {
5101
+ let pendingWake = false;
5102
+ let interruptSleep: (() => void) | undefined;
5103
+ return {
5104
+ requestWake() {
5105
+ pendingWake = true;
5106
+ interruptSleep?.();
5107
+ },
5108
+ takeWake() {
5109
+ const pending = pendingWake;
5110
+ pendingWake = false;
5111
+ return pending;
5112
+ },
5113
+ async sleep(ms) {
5114
+ // A wake that landed in the gap between the loop's takeWake check and
5115
+ // this call is absorbed here: skip the interval, and consume the flag
5116
+ // so the pass that follows it is the only prompt one.
5117
+ if (pendingWake) {
5118
+ pendingWake = false;
5119
+ return;
5120
+ }
5121
+ await new Promise<void>((resolve) => {
5122
+ const timer = setTimeout(resolve, ms);
5123
+ interruptSleep = () => {
5124
+ // The interruption *is* the absorption: the pass that follows the
5125
+ // sleep is the prompt pass this wake asked for, so the loop sleeps
5126
+ // normally after it instead of running a second immediate pass.
5127
+ pendingWake = false;
5128
+ clearTimeout(timer);
5129
+ resolve();
5130
+ };
5131
+ // A wake can land between the loop's takeWake check and this
5132
+ // constructor; resolve immediately instead of letting it wait out
5133
+ // the full interval.
5134
+ if (pendingWake) interruptSleep();
5135
+ });
5136
+ interruptSleep = undefined;
5137
+ },
5138
+ };
5139
+ }
5140
+
5141
+ export interface DispatchLoopOptions {
5142
+ /** One dispatch pass over every configured project runtime. */
5143
+ tick(): Promise<void>;
5144
+ /** True once a stop is requested; checked between passes. */
5145
+ shouldStop(): boolean;
5146
+ pace: DispatchPace;
5147
+ /** The scheduled interval between passes (`TICK_INTERVAL_MS` in production). */
5148
+ tickIntervalMs: number;
5149
+ }
5150
+
5151
+ /**
5152
+ * The resident daemon's dispatch loop: one pass immediately, then a
5153
+ * `tickIntervalMs` sleep the {@link DispatchPace} can cut short. Exported so
5154
+ * the resume-wake behaviour can be pinned deterministically without a live
5155
+ * daemon (#380).
5156
+ */
5157
+ export async function runDispatchLoop(o: DispatchLoopOptions): Promise<void> {
5158
+ const { tick, shouldStop, pace, tickIntervalMs } = o;
5159
+ while (!shouldStop()) {
5160
+ await tick();
5161
+ if (shouldStop()) break;
5162
+ // A wake that landed while the pass was in flight skips the following
5163
+ // sleep: that pass made its admission decisions against pre-resume state,
5164
+ // and the resumed project must not wait a full interval for the next one.
5165
+ if (pace.takeWake()) continue;
5166
+ await pace.sleep(tickIntervalMs);
5167
+ }
5168
+ }
5169
+
4672
5170
  export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
5171
+ // A `--once` tick is still a dispatcher: it settles rows, projects labels,
5172
+ // admits and launches workers, so it must hold the same exclusive daemon/
5173
+ // state ownership as the long-running dispatcher for its whole lifecycle
5174
+ // (#431). This is the one choke point every once tick passes through (both
5175
+ // `daemon --once` and the `setup host` smoke), so ownership is acquired here
5176
+ // — before any settlement, label, admission, worktree or worker mutation —
5177
+ // and refused (having done none of those) while another live daemon owns the
5178
+ // project or another live `--once` holds the lease.
5179
+ const onceLease = o.once ? acquireOnceLease({ port: o.port, project: o.project }) : undefined;
5180
+ if (onceLease !== undefined && !onceLease.ok) {
5181
+ throw new Error(
5182
+ `another daemon is alive (pid ${onceLease.pid}); stop it first — a --once tick refuses to run beside it`,
5183
+ );
5184
+ }
4673
5185
  const cfg = loadConfig();
4674
5186
  const projects = o.project === undefined ? cfg.projects : [findProject(cfg, o.project)];
4675
5187
  const store = openStore(dbPath());
@@ -4843,6 +5355,10 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
4843
5355
  await workers.drain();
4844
5356
  for (const runtime of runtimes) await runtime.outbox.deliverDue();
4845
5357
  } finally {
5358
+ // Drop exclusivity only while it is still ours; a manager that claimed
5359
+ // the record mid-tick is left alone. A lease acquired but never released
5360
+ // (config/lease failure before this branch) is stale recovery's job.
5361
+ if (onceLease !== undefined && onceLease.ok) onceLease.lease.release();
4846
5362
  for (const runtime of runtimes.toReversed()) {
4847
5363
  await runtime.orchestrator?.dispose();
4848
5364
  await runtime.orchestratorVerbs?.close();
@@ -4891,7 +5407,10 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
4891
5407
  }, REPORT_DELIVERY_INTERVAL_MS);
4892
5408
 
4893
5409
  let stopping = false;
4894
- let wake: (() => void) | undefined;
5410
+ // The wake surface (#380): `resume` POSTs /wake, `stop` interrupts the same
5411
+ // sleep. A wake request is sticky and coalescing — several resumes in quick
5412
+ // succession produce one prompt pass, never an overlapping one.
5413
+ const pace = createDispatchPace();
4895
5414
  const stop = (): void => {
4896
5415
  if (stopping) return;
4897
5416
  stopping = true;
@@ -4901,7 +5420,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
4901
5420
  // wait for (#374).
4902
5421
  drain.draining = true;
4903
5422
  log("shutting down after active workers finish");
4904
- wake?.();
5423
+ pace.requestWake();
4905
5424
  };
4906
5425
  process.on("SIGINT", stop);
4907
5426
  process.on("SIGTERM", stop);
@@ -4921,6 +5440,10 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
4921
5440
  })),
4922
5441
  turnLimits,
4923
5442
  workerControls,
5443
+ wake: () => {
5444
+ log("dispatch wake requested — an immediate pass is starting");
5445
+ pace.requestWake();
5446
+ },
4924
5447
  health: () =>
4925
5448
  daemonHealth(
4926
5449
  runtimes.map((runtime) =>
@@ -4942,27 +5465,23 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
4942
5465
  );
4943
5466
 
4944
5467
  try {
4945
- while (!stopping) {
4946
- for (const runtime of runtimes) {
4947
- try {
4948
- await tick(runtime.d, workers);
4949
- } catch (err) {
4950
- log(
4951
- `${projects.length === 1 ? "" : `[${runtime.d.project.name}] `}` +
4952
- `tick failed: ${errText(err)}`,
4953
- );
5468
+ await runDispatchLoop({
5469
+ tick: async () => {
5470
+ for (const runtime of runtimes) {
5471
+ try {
5472
+ await tick(runtime.d, workers);
5473
+ } catch (err) {
5474
+ log(
5475
+ `${projects.length === 1 ? "" : `[${runtime.d.project.name}] `}` +
5476
+ `tick failed: ${errText(err)}`,
5477
+ );
5478
+ }
4954
5479
  }
4955
- }
4956
- if (stopping) break;
4957
- await new Promise<void>((resolve) => {
4958
- const timer = setTimeout(resolve, TICK_INTERVAL_MS);
4959
- wake = () => {
4960
- clearTimeout(timer);
4961
- resolve();
4962
- };
4963
- });
4964
- wake = undefined;
4965
- }
5480
+ },
5481
+ shouldStop: () => stopping,
5482
+ pace,
5483
+ tickIntervalMs: TICK_INTERVAL_MS,
5484
+ });
4966
5485
  } finally {
4967
5486
  process.off("SIGINT", stop);
4968
5487
  process.off("SIGTERM", stop);