omp-conductor 0.15.13 → 0.16.1

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 (50) hide show
  1. package/REFERENCE.md +72 -2
  2. package/package.json +2 -1
  3. package/schema/config.schema.json +7 -0
  4. package/src/admission.ts +849 -0
  5. package/src/ask.ts +47 -0
  6. package/src/backups.ts +19 -7
  7. package/src/board.ts +1 -2
  8. package/src/briefs/orchestrator.md +62 -4
  9. package/src/cli.ts +26 -0
  10. package/src/commands/context.ts +3 -0
  11. package/src/commands/decision.ts +10 -1
  12. package/src/commands/doctor.ts +2 -0
  13. package/src/commands/message.ts +8 -1
  14. package/src/commands/restart.ts +15 -3
  15. package/src/commands/restore-db.ts +146 -0
  16. package/src/commands/stop.ts +24 -15
  17. package/src/commands/tail.ts +204 -44
  18. package/src/commands/unfreeze.ts +56 -0
  19. package/src/commands/watch.ts +77 -0
  20. package/src/config-schema.ts +13 -0
  21. package/src/config.ts +54 -0
  22. package/src/daemon.ts +255 -530
  23. package/src/dashboard/server.ts +2 -1
  24. package/src/decisions.ts +32 -7
  25. package/src/depends-on.ts +122 -0
  26. package/src/doctor.ts +297 -5
  27. package/src/escalate.ts +191 -19
  28. package/src/failure-class.ts +47 -0
  29. package/src/fleet.ts +168 -452
  30. package/src/gitops.ts +86 -1
  31. package/src/log.ts +40 -0
  32. package/src/model-fallback.ts +3 -2
  33. package/src/omp-settings.ts +114 -0
  34. package/src/omp.ts +39 -0
  35. package/src/orchestrator-tick.ts +7 -1
  36. package/src/reports.ts +124 -12
  37. package/src/session-host.ts +6 -0
  38. package/src/setup-wizard.ts +36 -0
  39. package/src/setup.ts +58 -1
  40. package/src/status-render.ts +445 -0
  41. package/src/stop-provenance.ts +53 -0
  42. package/src/store.ts +352 -11
  43. package/src/transcript.ts +1 -1
  44. package/src/types.ts +187 -4
  45. package/src/unblock.ts +1 -1
  46. package/src/upgrade-verify.ts +1 -1
  47. package/src/upgrade.ts +1 -2
  48. package/src/verbs/server.ts +25 -0
  49. package/src/worker.ts +358 -10
  50. package/src/worktree.ts +13 -1
package/src/daemon.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  createReportOutbox,
48
48
  enqueueAvailableHeldNotices,
49
49
  formatOpenReports,
50
+ reliabilitySettlementLine,
50
51
  type ReportOutbox,
51
52
  } from "./reports.ts";
52
53
  import {
@@ -56,6 +57,10 @@ import {
56
57
  } from "./release-policy.ts";
57
58
  import { branchName, effectiveLabels, route } from "./routing.ts";
58
59
  import type { Routed, UnroutableReason } from "./routing.ts";
60
+ import { admitCandidates, hasContinuationBudget, hasFailedAttemptBudget } from "./admission.ts";
61
+ import type { Admission, AdmissionHold } from "./admission.ts";
62
+ import { log, errText, safeEscalate } from "./log.ts";
63
+ import { materializeOmpSettings } from "./omp-settings.ts";
59
64
  import { evaluateDecisionConditions, probeNpmVersion, probeRateLimitReset } from "./decisions.ts";
60
65
  import { classifyRun, providerCreditRefusal, providerTransientFault, type ClassifyFacts } from "./failure-class.ts";
61
66
  import {
@@ -71,6 +76,7 @@ import { dbPath, LIVE_STATES, openStore, utcDay } from "./store.ts";
71
76
  import { makeTracker, type RateLimitStatus } from "./tracker/github.ts";
72
77
  import { DEFAULT_REPORT_POLICY, RELEASE_SHAPES } from "./types.ts";
73
78
  import type {
79
+ BaseFreeze,
74
80
  BaseHealth,
75
81
  AdmissionHoldReason,
76
82
  Caps,
@@ -140,10 +146,12 @@ import { homedir } from "node:os";
140
146
 
141
147
  import {
142
148
  probeCriticalBase,
149
+ probeRunLane,
143
150
  pushRunBranch,
144
151
  readBaseChain,
145
152
  type CriticalBaseProbe,
146
153
  type CriticalBaseVerdict,
154
+ type RunLaneProbe,
147
155
  type RunRepoRef,
148
156
  } from "./gitops.ts";
149
157
  import {
@@ -172,6 +180,13 @@ const DISPATCH_INFRA_MAX_STRIKES = 3;
172
180
  * provider itself is degraded, not unlucky, and the sweep escalates to a
173
181
  * human instead of requeueing into a down provider forever (#220). */
174
182
  const PROVIDER_TRANSIENT_MAX_STRIKES = 3;
183
+ /** A provider-capacity requeue (sustained in-session rate limiting) is retried,
184
+ * but only a bounded number of times: three throttled runs for one issue mean
185
+ * the provider is at capacity, not unlucky, and the sweep escalates to a human
186
+ * instead of requeueing into a throttled provider forever (#573). The issue's
187
+ * own chain moves onto its next model per strike (via {@link FAILOVER_CLASSES}),
188
+ * so a bounded chain is exhaustible; this caps the unbounded no-chain case. */
189
+ const PROVIDER_CAPACITY_MAX_STRIKES = 3;
175
190
  /** Terminal rows inspected for a salvaged PR per tick. GitHub provenance reads
176
191
  * are maintenance, but a backlog must not turn one tick into an API burst. */
177
192
  const SALVAGED_PR_ADOPTION_BATCH = 10;
@@ -271,6 +286,23 @@ interface Deps {
271
286
  * a marker (a safety interlock must not silently weaken).
272
287
  */
273
288
  probeCriticalBase?: CriticalBaseProbe;
289
+ /**
290
+ * Reads one active run's file lane for the admission file-lane interlock
291
+ * (#555): the union of its uncommitted worktree changes and its branch-vs-base
292
+ * diff. Wired by `runDaemon` to the mirror/worktree-backed
293
+ * {@link probeRunLane}; a test injects a fake. Absent, the interlock is inert
294
+ * (no lane is ever known occupied), which is the issue's "fail open": the
295
+ * gate adds holds, it never refuses a well-formed issue for lack of this
296
+ * probe the way `criticalBase` does.
297
+ */
298
+ probeWorktreeLane?: RunLaneProbe;
299
+ /**
300
+ * Reads one issue's tracker state in a repository the admission tracker is
301
+ * not bound to — the cross-repo Depends-on interlock (#420). Wired by
302
+ * `runDaemon` to a repo-scoped tracker; a test injects a fake. Absent,
303
+ * admission fails a routed cross-repo prerequisite closed.
304
+ */
305
+ probeIssueIn?: (repo: string, issue: number) => Promise<IssueSnapshot | undefined>;
274
306
  }
275
307
 
276
308
  /**
@@ -681,14 +713,6 @@ export function markPaged(
681
713
 
682
714
  // ---------------------------------------------------------------------- helpers
683
715
 
684
- function log(msg: string): void {
685
- process.stderr.write(`[conductor ${new Date().toISOString()}] ${msg}\n`);
686
- }
687
-
688
- function errText(e: unknown): string {
689
- return e instanceof Error ? (e.stack ?? e.message) : String(e);
690
- }
691
-
692
716
  /**
693
717
  * Local midnight, matching how a human reads "today".
694
718
  *
@@ -841,15 +865,6 @@ export function recordOperatorStop(
841
865
  * gate on the attempt turns one failed delivery into permanent silence about a
842
866
  * condition that is still true.
843
867
  */
844
- async function safeEscalate(d: Pick<Deps, "escalate">, e: Escalation): Promise<boolean> {
845
- try {
846
- await d.escalate(e);
847
- return true;
848
- } catch (err) {
849
- log(`escalation for ${escalationIssueRef(e.issue)} could not be delivered: ${errText(err)}`);
850
- return false;
851
- }
852
- }
853
868
 
854
869
  async function reactToProviderCredit(
855
870
  d: Deps,
@@ -1108,18 +1123,6 @@ export async function buildBrief(
1108
1123
 
1109
1124
  // ------------------------------------------------------------------- one issue
1110
1125
 
1111
- /** `stops` are the operational ends that each require one resume. */
1112
- export function hasContinuationBudget(stops: number, maxContinuations: number): boolean {
1113
- return stops <= maxContinuations;
1114
- }
1115
-
1116
- /** True while unspent failed-implementation attempts remain. This is the
1117
- * dispatcher's admission gate: once every `maxAttemptsPerIssue` slot is
1118
- * spent, the issue is held as `failed-attempts` forever, and the `unblock`
1119
- * verb withholds the queue label on the same predicate (#348). */
1120
- export function hasFailedAttemptBudget(failures: number, maxAttempts: number): boolean {
1121
- return failures < maxAttempts;
1122
- }
1123
1126
 
1124
1127
  /** The failure classes `countContinuations` deliberately does not charge — the
1125
1128
  * inverted copy of its exclusions, kept beside the breakdown that consumes it
@@ -1133,6 +1136,7 @@ const NON_CONTINUATION_CLASSES: Partial<Record<FailureClass, true>> = {
1133
1136
  "dispatch-infra": true,
1134
1137
  "provider-credit": true,
1135
1138
  "provider-transient": true,
1139
+ "provider-capacity": true,
1136
1140
  };
1137
1141
 
1138
1142
  /** How one issue spent its continuation budget, grouped by failure class —
@@ -1978,6 +1982,16 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1978
1982
  : dirname(resuming.sessionFile!);
1979
1983
  mkdirSync(sessionDir, { recursive: true });
1980
1984
 
1985
+ // The fleet-owned omp settings overlay (#537): the project's `ompSettings`
1986
+ // map (plus the retry keys derived from `modelFallbacks`, #539's staging
1987
+ // half) materialised to YAML under the run's session directory — never
1988
+ // inside the worktree, whose diff is the PR a worker ships. Rewritten on
1989
+ // every attempt, so a config edit takes effect on the next dispatch and a
1990
+ // resumed attempt reuses the kept session dir with the *current* config.
1991
+ // Absent `ompSettings` and `modelFallbacks`, no file is written, nothing
1992
+ // is passed, and dispatch is byte-for-byte today's.
1993
+ const ompSettingsFile = materializeOmpSettings(project, sessionDir);
1994
+
1981
1995
  // ---- the run's mutation channel (#126) -------------------------------
1982
1996
  // A shared, daemon-owned 0711 parent with one 0600 socket per run, never a
1983
1997
  // per-run *directory*: a directory owned by the run principal would hand
@@ -2091,6 +2105,12 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
2091
2105
  log(`#${issue} ${line}`);
2092
2106
  },
2093
2107
  ...(choice.model === undefined ? {} : { model: choice.model }),
2108
+ // The fleet-owned omp settings overlay (#537): the staged YAML the
2109
+ // session loads through `Settings.init({ configFiles: [<path>] })` —
2110
+ // the project's `ompSettings` map plus the within-run failover keys
2111
+ // #581 staged directly (the project's own chain, not an empty default).
2112
+ // Absent both, nothing is staged, today's dispatch byte for byte.
2113
+ ...(ompSettingsFile === undefined ? {} : { ompSettingsFile }),
2094
2114
  releaseGrants: resolveReleaseGrants(project),
2095
2115
  onReleaseBlocked: workerReleaseBlockRecorder(project.name, issue, runId),
2096
2116
  onTurn: (n) => store.updateRun(runId, { turns: n }),
@@ -2177,10 +2197,27 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
2177
2197
  // worker's, the disclosure becomes the diff's (#488). A diff that could
2178
2198
  // not be read leaves the worker's text untouched and the audit's
2179
2199
  // `changed-line-missing` flag says so.
2180
- const settlementReport =
2200
+ // The within-run reliability sentence, appended where a human reads it
2201
+ // (#584): which model the run finished on and whether it swapped
2202
+ // mid-flight. Empty (undefined) for a clean run, so a run that never
2203
+ // retried, swapped or compacted keeps today's settlement report byte for
2204
+ // byte — the "additive" claim asserted rather than assumed.
2205
+ const reliabilityLine = reliabilitySettlementLine({
2206
+ resolvedModel: result.model,
2207
+ resolvedProvider: result.provider,
2208
+ retryFallbacks: result.retryFallbacks,
2209
+ retryFallbackSucceeded: result.retryFallbackSucceeded,
2210
+ modelRecoveries: result.modelRecoveries,
2211
+ autoRetryCount: result.autoRetryCount,
2212
+ autoCompactionCount: result.autoCompactionCount,
2213
+ });
2214
+
2215
+ const settlementReport = [
2181
2216
  audit?.changedLine === undefined
2182
2217
  ? result.report
2183
- : withDerivedChangedLine(result.report, audit.changedLine);
2218
+ : withDerivedChangedLine(result.report, audit.changedLine),
2219
+ ...(reliabilityLine === undefined ? [] : ["", reliabilityLine]),
2220
+ ].join("\n");
2184
2221
 
2185
2222
  const finalReport = [
2186
2223
  ...(verified.reason === undefined ? [] : [verified.reason, ""]),
@@ -2230,6 +2267,24 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
2230
2267
  endedAt: Date.now(),
2231
2268
  turns: result.turns,
2232
2269
  spendUsd: result.spendUsd,
2270
+ // The count of in-session provider 429s the worker metered live, so a
2271
+ // run the provider throttled into the ground carries its own diagnosis
2272
+ // instead of landing `unknown` — the classifier reads it straight off
2273
+ // this column (#573).
2274
+ provider429Count: result.provider429Count,
2275
+ // The within-run harness reliability surface #581 collected, persisted
2276
+ // now that settlement owns the row (#584). The resolved model/provider
2277
+ // only travel when some assistant message carried them (the run recorded
2278
+ // no model, or the worker never established one); the count fields always
2279
+ // travel, 0 for a clean run, so an absent column can never be read as a
2280
+ // quiet fleet. Written for every terminal state, clean or not.
2281
+ ...(result.model === undefined ? {} : { resolvedModel: result.model }),
2282
+ ...(result.provider === undefined ? {} : { resolvedProvider: result.provider }),
2283
+ retryFallbacks: result.retryFallbacks,
2284
+ retryFallbackSucceeded: result.retryFallbackSucceeded,
2285
+ modelRecoveries: result.modelRecoveries,
2286
+ autoRetryCount: result.autoRetryCount,
2287
+ autoCompactionCount: result.autoCompactionCount,
2233
2288
  // The worker only reports these when it actually established them; a kill
2234
2289
  // or a settle whose report named no PR must not wipe what a verb recorded
2235
2290
  // earlier in the same run (#468). The sink in `updateRun` skips undefined
@@ -2272,6 +2327,22 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
2272
2327
 
2273
2328
  const salvaged = settlement?.lines ?? [];
2274
2329
 
2330
+ // The run's reliability news, surfaced where the tick digest reads it
2331
+ // (#584): the digest is model-authored but consumes the store's material
2332
+ // ledger, so a run that swapped mid-flight or rode out a throttled
2333
+ // provider lands one event the digest can name. Clean runs record nothing
2334
+ // here, so a quiet fleet's digest is unchanged.
2335
+ if (reliabilityLine !== undefined) {
2336
+ store.recordMaterialEvent({
2337
+ project: project.name,
2338
+ category: "reliability",
2339
+ summary: `#${issue} ${reliabilityLine}`,
2340
+ evidence: `${r.issue.title}\n${r.issue.url}\n\n${reliabilityLine}`,
2341
+ occurredAt: Date.now(),
2342
+ recordedAt: Date.now(),
2343
+ });
2344
+ }
2345
+
2275
2346
  if (state === "stopped") {
2276
2347
  log(`#${issue} stopped by operator on attempt ${attempt}: ${result.stoppedReason}`);
2277
2348
  } else if (state === "blocked") {
@@ -2693,6 +2764,31 @@ export async function watchMergedBase(d: Pick<Deps, "project" | "tracker" | "sto
2693
2764
  `${failed.name} failed at ${run.mergeSha} — ${failed.url}` +
2694
2765
  (preexisting ? " (already red before this merge)" : "");
2695
2766
  const flag: SettlementFlag = { kind: "base-branch-red", file: "(base branch)", detail };
2767
+ // Freeze merges to this repo while the base it merged into is red (#283).
2768
+ // The freeze is repo-scoped and sets independently of escalation delivery:
2769
+ // a merge that broke the base must not be followed by another merge onto
2770
+ // the same red base, even if paging the operator fails.
2771
+ if (
2772
+ d.store.setBaseFreeze(d.project.name, {
2773
+ repo: run.repo,
2774
+ culpritSha: run.mergeSha,
2775
+ detail,
2776
+ setAt: now,
2777
+ })
2778
+ ) {
2779
+ d.store.recordMaterialEvent({
2780
+ project: d.project.name,
2781
+ category: "base-red-freeze",
2782
+ summary: `merges to ${run.repo} frozen — base ${run.baseRef} red at ${run.mergeSha.slice(0, 8)}`,
2783
+ evidence:
2784
+ `${detail} This freeze names ${run.mergeSha.slice(0, 8)} as the suspected culprit merge. ` +
2785
+ "Merges to this repo are refused until the base is green again; reverting the culprit is the " +
2786
+ "likely remedy. The freeze lifts automatically on a green re-observation, or the operator can " +
2787
+ "override it with `omp-conductor unfreeze <repo>`.",
2788
+ occurredAt: now,
2789
+ recordedAt: now,
2790
+ });
2791
+ }
2696
2792
  const delivered = await safeEscalate(d, {
2697
2793
  tier: 1,
2698
2794
  project: d.project.name,
@@ -2751,7 +2847,16 @@ export async function watchBaseHealth(
2751
2847
  }
2752
2848
 
2753
2849
  const previous = previousByRepo.get(repo);
2850
+ const freeze = d.store.baseFreeze(d.project.name, repo);
2851
+ const frozen = freeze !== undefined && freeze.clearedAt === undefined;
2852
+ // The same-head/age shortcut exists to avoid re-querying GitHub when a
2853
+ // terminal verdict has not moved. It must NOT skip a frozen repo: a freeze
2854
+ // keyed on one red observation has to keep re-evaluating the same head so a
2855
+ // green rerun clears it without operator action (tonight's evidence — a
2856
+ // red/unknown read two minutes after the same SHA's CI succeeded — is why
2857
+ // it cannot be trusted as terminal).
2754
2858
  if (
2859
+ !frozen &&
2755
2860
  previous?.branch === branch &&
2756
2861
  previous.headSha === head &&
2757
2862
  (previous.verdict === "green" || previous.verdict === "red")
@@ -2811,6 +2916,43 @@ export async function watchBaseHealth(
2811
2916
  };
2812
2917
  d.store.upsertBaseHealth(d.project.name, health);
2813
2918
  previousByRepo.set(repo, health);
2919
+
2920
+ // The freeze follows the live base verdict: red arms (or re-arms) the
2921
+ // repo-scoped freeze, green lifts it. pending/unknown leave it untouched —
2922
+ // a stale or in-flight reading must neither create a freeze nor clear one.
2923
+ if (verdict === "green") {
2924
+ if (d.store.clearBaseFreeze(d.project.name, repo, "daemon", "base-green", now)) {
2925
+ d.store.recordMaterialEvent({
2926
+ project: d.project.name,
2927
+ category: "base-recovered",
2928
+ summary: `merges to ${repo} unfrozen — base ${branch} observed green at ${head.slice(0, 8)}`,
2929
+ evidence: `The base-red freeze on ${repo} lifted automatically: ${branch} is green at ${head.slice(0, 8)}. Merges resume.`,
2930
+ occurredAt: now,
2931
+ recordedAt: now,
2932
+ });
2933
+ }
2934
+ } else if (verdict === "red") {
2935
+ if (
2936
+ d.store.setBaseFreeze(d.project.name, {
2937
+ repo,
2938
+ culpritSha: head,
2939
+ detail,
2940
+ setAt: now,
2941
+ })
2942
+ ) {
2943
+ d.store.recordMaterialEvent({
2944
+ project: d.project.name,
2945
+ category: "base-red-freeze",
2946
+ summary: `merges to ${repo} frozen — base ${branch} red at ${head.slice(0, 8)}`,
2947
+ evidence:
2948
+ `${detail ?? `workflow failed at ${head.slice(0, 8)}`} This freeze names ` +
2949
+ `${head.slice(0, 8)} as the suspected culprit. Reverting it is the likely remedy; the freeze ` +
2950
+ "lifts automatically on green or with `omp-conductor unfreeze <repo>`.",
2951
+ occurredAt: now,
2952
+ recordedAt: now,
2953
+ });
2954
+ }
2955
+ }
2814
2956
  }
2815
2957
  }
2816
2958
 
@@ -3130,22 +3272,6 @@ export async function cleanupRetainedRuns(
3130
3272
 
3131
3273
  // -------------------------------------------------------------------- admission
3132
3274
 
3133
- /** A candidate cleared for dispatch, with the attempt number it will run as. */
3134
- export interface Admission {
3135
- r: Routed;
3136
- attempt: number;
3137
- }
3138
-
3139
- export interface AdmissionHold {
3140
- issue: number;
3141
- reason: AdmissionHoldReason;
3142
- }
3143
-
3144
- export interface AdmissionPass {
3145
- admitted: Admission[];
3146
- holds: AdmissionHold[];
3147
- }
3148
-
3149
3275
  const HOLD_SAMPLE_SIZE = 5;
3150
3276
  const DEGRADED_HOLDS: ReadonlySet<AdmissionHoldReason> = new Set([
3151
3277
  "parent-lookup-error",
@@ -3163,11 +3289,16 @@ export function summarizeDispatch(
3163
3289
  completedAt = Date.now(),
3164
3290
  settled = 0,
3165
3291
  ): DispatchSummary {
3166
- const groups = new Map<AdmissionHoldReason, { count: number; issues: number[] }>();
3292
+ const groups = new Map<AdmissionHoldReason, { count: number; issues: number[]; details: string[] }>();
3167
3293
  for (const hold of holds) {
3168
- const group = groups.get(hold.reason) ?? { count: 0, issues: [] };
3294
+ const group = groups.get(hold.reason) ?? { count: 0, issues: [], details: [] };
3169
3295
  group.count += 1;
3170
- if (group.issues.length < HOLD_SAMPLE_SIZE) group.issues.push(hold.issue);
3296
+ if (group.issues.length < HOLD_SAMPLE_SIZE) {
3297
+ // `issues` and `details` share the sample: the detail is only kept when
3298
+ // the issue it explains is, so the arrays stay index-aligned.
3299
+ group.issues.push(hold.issue);
3300
+ if (hold.detail !== undefined) group.details.push(hold.detail);
3301
+ }
3171
3302
  groups.set(hold.reason, group);
3172
3303
  }
3173
3304
  return {
@@ -3179,7 +3310,12 @@ export function summarizeDispatch(
3179
3310
  degraded: holds.some((hold) => DEGRADED_HOLDS.has(hold.reason)),
3180
3311
  holds: [...groups]
3181
3312
  .sort(([a], [b]) => a.localeCompare(b))
3182
- .map(([reason, group]) => ({ reason, ...group })),
3313
+ .map(([reason, group]) => ({
3314
+ reason,
3315
+ count: group.count,
3316
+ issues: group.issues,
3317
+ ...(group.details.length === 0 ? {} : { details: group.details }),
3318
+ })),
3183
3319
  settled,
3184
3320
  };
3185
3321
  }
@@ -3204,484 +3340,6 @@ export function summarizeHeldPass(settled: number, completedAt = Date.now()): Di
3204
3340
  };
3205
3341
  }
3206
3342
 
3207
- /**
3208
- * What a held plan-usage gate says to a human, if anything.
3209
- *
3210
- * Three different problems hide behind one hold, and they want different
3211
- * tiers. Reaching the threshold is the guard *working*: tier 1, because the
3212
- * fleet resumes on its own at the provider's reset and nobody needs to get
3213
- * out of bed. Everything else — a window nothing reports, a window that
3214
- * resolves to two allowances, a meter that has been unreadable for half an
3215
- * hour — is dispatch stopped with no self-recovery, which is tier 2.
3216
- *
3217
- * Each summary carries the fact that will change when the situation does (the
3218
- * reset instant, the configured id, the date), because the escalation ledger
3219
- * dedupes on the summary: a stable one pages once and then goes quiet, which
3220
- * is right for a repeated tick and wrong for the next window.
3221
- */
3222
- function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation | undefined {
3223
- const base = { project, issue: NO_ISSUE };
3224
- if (plan.state === "at-cap") {
3225
- const window = plan.window?.id ?? plan.cap?.windowId ?? "the configured window";
3226
- const resets =
3227
- plan.resetsAt === undefined
3228
- ? new Date().toISOString().slice(0, 10)
3229
- : new Date(plan.resetsAt).toISOString();
3230
- return {
3231
- ...base,
3232
- tier: 1,
3233
- summary: `Plan allowance cap reached on ${window} — ${project} is not claiming new work (window ${resets})`,
3234
- detail: [
3235
- plan.detail,
3236
- "Running workers finish normally; only new claims are held.",
3237
- "Dispatch resumes by itself once the provider reports the window reset or usage below the threshold —",
3238
- "no `resume` needed. Raise `caps.planUsage.maxUsedFraction` only if you mean to spend the rest.",
3239
- ].join("\n"),
3240
- };
3241
- }
3242
- if (plan.state === "blind") {
3243
- return {
3244
- ...base,
3245
- tier: 2,
3246
- category: "fleet-stopped",
3247
- // Dated: a meter that breaks again next month is a new incident, not a
3248
- // repeat of this one.
3249
- summary: `Plan usage source unreadable — ${project} is not claiming new work (${new Date().toISOString().slice(0, 10)})`,
3250
- detail: [
3251
- plan.detail,
3252
- "The guard admitted work while the failure looked transient and has now stopped.",
3253
- "Check `omp usage --json` on the fleet host, or set `caps.planUsage` to null if this fleet is unmetered.",
3254
- ].join("\n"),
3255
- };
3256
- }
3257
- if (
3258
- plan.state === "window-missing" ||
3259
- plan.state === "window-ambiguous" ||
3260
- plan.state === "window-uncomparable"
3261
- ) {
3262
- return {
3263
- ...base,
3264
- tier: 2,
3265
- category: "fleet-stopped",
3266
- summary: `Plan usage cap names an unusable window "${plan.cap?.windowId ?? "?"}" — ${project} is not claiming new work`,
3267
- detail: [
3268
- plan.detail,
3269
- "Run `omp usage --json` and copy an allowance `id` into `caps.planUsage.windowId`,",
3270
- "or set `caps.planUsage` to null if this fleet is unmetered.",
3271
- ].join("\n"),
3272
- };
3273
- }
3274
- return undefined;
3275
- }
3276
-
3277
- /**
3278
- * Which routed candidates get a worker this tick — in queue order, never more
3279
- * than `slots` of them. Every non-admission receives a stable reason code.
3280
- *
3281
- * Exported so the admission rules can be pinned without spawning a worker.
3282
- * Every one of them exists because of a live incident, and each guards a
3283
- * different way the same issue gets worked twice — including epic siblings
3284
- * racing onto the same files (#48).
3285
- *
3286
- * Takes the slice of `Deps` it actually reads rather than the whole thing: what
3287
- * admission is allowed to consult is the point of the function, and a `Deps`
3288
- * that grows a field has no business breaking these tests.
3289
- */
3290
- export async function admitCandidates(
3291
- d: Pick<Deps, "project" | "caps" | "tracker" | "store" | "escalate" | "usage" | "probeCriticalBase">,
3292
- routed: Routed[],
3293
- slots: number,
3294
- ): Promise<AdmissionPass> {
3295
- const { project, caps, tracker, store } = d;
3296
- const activeRuns = store.activeRuns(project.name);
3297
- const busyIssues = activeRuns.map((r) => r.issue);
3298
- const busy = new Set(busyIssues);
3299
- // issue -> its active run rows, for the pushed-green admission bypass (#175):
3300
- // only a worker-free pushed-green row may be bypassed, and only when *every*
3301
- // active run for the issue is worker-free. A live (claimed/running) row still
3302
- // holds unconditionally.
3303
- const activeByIssue = new Map<number, RunRecord[]>();
3304
- for (const run of activeRuns) {
3305
- const list = activeByIssue.get(run.issue);
3306
- if (list === undefined) activeByIssue.set(run.issue, [run]);
3307
- else list.push(run);
3308
- }
3309
- // Live worker count per repo, seeded from live runs and incremented as this
3310
- // same pass admits — so two same-repo candidates can never both clear the
3311
- // per-repo cap in one tick (#186).
3312
- const liveByRepo = new Map<string, number>();
3313
- for (const run of store.liveRuns(project.name)) {
3314
- liveByRepo.set(run.repo, (liveByRepo.get(run.repo) ?? 0) + 1);
3315
- }
3316
- const holds: AdmissionHold[] = [];
3317
- const hold = (issue: number, reason: AdmissionHoldReason): void => {
3318
- holds.push({ issue, reason });
3319
- };
3320
-
3321
- // The plan allowance is a fleet-wide question, so it is asked once per pass
3322
- // and answers for every candidate — unlike every gate below it, which is
3323
- // per-issue. It sits here rather than beside the spend cap in `tick` for one
3324
- // reason: the spend cap *pauses the daemon* and waits for a human, and a
3325
- // weekly plan window resets by itself. A guard that demanded `resume` after
3326
- // every rollover would cost more operator attention than the guard saves
3327
- // (#110). Already-running workers are untouched and settle normally.
3328
- //
3329
- // Placed after the cheap local busy-set read and before the first tracker
3330
- // call, so a held fleet spends no GitHub API budget discovering it is held.
3331
- const plan = await readPlanUsage(caps.planUsage, d.usage);
3332
- if (plan.blocking) {
3333
- for (const r of routed) hold(r.issue.number, "plan-usage-cap");
3334
- log(`plan usage gate holding ${String(routed.length)} candidate(s): ${plan.detail}`);
3335
- const escalation = planUsageEscalation(project.name, plan);
3336
- if (escalation !== undefined) await safeEscalate(d, escalation);
3337
- return { admitted: [], holds };
3338
- }
3339
-
3340
- // parent -> repo name -> blocking issue. Seeded from active runs (including
3341
- // pushed-green), then extended by candidates admitted earlier in this same
3342
- // pass so two siblings of one epic never both clear the gate in one tick.
3343
- // A busy issue whose run row cannot be resolved occupies the sentinel repo
3344
- // "" — treated as matching every repo, failing toward holding (#197).
3345
- const occupiedParents = new Map<number, Map<string, number>>();
3346
- const parentCache = new Map<number, number | undefined>();
3347
-
3348
- const resolveParent = async (issue: number): Promise<number | undefined> => {
3349
- if (parentCache.has(issue)) return parentCache.get(issue);
3350
- const parent = await tracker.parentOf(issue);
3351
- parentCache.set(issue, parent);
3352
- return parent;
3353
- };
3354
-
3355
- // Bounded by concurrent workers, not queue depth. A failed lookup here cannot
3356
- // mark an epic occupied; candidates still fail closed on their own parentOf.
3357
- for (const issue of busyIssues) {
3358
- try {
3359
- const parent = await resolveParent(issue);
3360
- if (parent === undefined) continue;
3361
- // The runs table records which repo each attempt worked in, and sibling
3362
- // holds are now per-repo, so a busy child only occupies its epic under
3363
- // that repo's name (same spelling as `createRun` writes from
3364
- // `r.repo.name`). A busy issue with no resolvable run row occupies the
3365
- // sentinel "" instead — matching every repo, failing toward holding.
3366
- const repo = store.latestRun(project.name, issue)?.repo ?? "";
3367
- const siblings = occupiedParents.get(parent);
3368
- if (siblings === undefined) {
3369
- occupiedParents.set(parent, new Map([[repo, issue]]));
3370
- } else if (!siblings.has(repo) && !siblings.has("")) {
3371
- siblings.set(repo, issue);
3372
- }
3373
- } catch (err) {
3374
- log(`#${issue} parent lookup failed while seeding epic occupancy (${errText(err)})`);
3375
- }
3376
- }
3377
-
3378
- const admitted: Admission[] = [];
3379
- for (const r of routed) {
3380
- const issue = r.issue.number;
3381
- if (admitted.length >= slots) {
3382
- hold(issue, "capacity");
3383
- continue;
3384
- }
3385
- if (busy.has(issue)) {
3386
- // A pushed-green row is worker-free by definition (it is not in
3387
- // LIVE_STATES): its PR is live but no process is writing to its branch.
3388
- // So an issue whose active runs are ALL pushed-green is not actually
3389
- // occupied — the corrective attempt the operator unblocked may be
3390
- // admitted as a continuation of that PR, and the open-PR gate below
3391
- // decides the identity. Any live row still holds (#175).
3392
- const allWorkerFree = (activeByIssue.get(issue) ?? []).every((r) => r.state === "pushed-green");
3393
- if (!allWorkerFree) {
3394
- hold(issue, "issue-active");
3395
- continue;
3396
- }
3397
- }
3398
-
3399
- // Per-repo concurrency: the mirror, branch-protection staleness and shared
3400
- // CI egress are all per-repo collision domains, so extra slots should land
3401
- // on other repos rather than stacking workers into the same one (#186).
3402
- const liveInRepo = liveByRepo.get(r.repo.name) ?? 0;
3403
- if (liveInRepo >= caps.maxConcurrentWorkersPerRepo) {
3404
- hold(issue, "repo-active");
3405
- log(`#${issue} skipped: ${liveInRepo} live worker(s) already in ${r.repo.name} (cap ${caps.maxConcurrentWorkersPerRepo})`);
3406
- continue;
3407
- }
3408
-
3409
- const priorRuns = store.attemptsFor(project.name, issue);
3410
- const failures = store.failuresFor(project.name, issue);
3411
- if (!hasFailedAttemptBudget(failures, caps.maxAttemptsPerIssue)) {
3412
- hold(issue, "failed-attempts");
3413
- await safeEscalate(d, {
3414
- tier: 1,
3415
- project: project.name,
3416
- issue,
3417
- summary: `#${issue} has used all ${caps.maxAttemptsPerIssue} failed attempts`,
3418
- detail: [
3419
- r.issue.title,
3420
- r.issue.url,
3421
- "Another implementation attempt almost always means the issue itself is underspecified.",
3422
- "Rewrite the acceptance criteria, or take it off the queue.",
3423
- ].join("\n"),
3424
- });
3425
- continue;
3426
- }
3427
-
3428
- const continuations = store.continuationsFor(project.name, issue);
3429
- if (!hasContinuationBudget(continuations, caps.maxContinuationsPerIssue)) {
3430
- hold(issue, "continuations");
3431
- await safeEscalate(d, {
3432
- tier: 1,
3433
- project: project.name,
3434
- issue,
3435
- summary: `#${issue} exceeded its ${caps.maxContinuationsPerIssue}-continuation budget`,
3436
- detail: [
3437
- r.issue.title,
3438
- r.issue.url,
3439
- "Repeated cap kills, daemon orphans, or answered blocks need an operator to inspect progress.",
3440
- ].join("\n"),
3441
- });
3442
- continue;
3443
- }
3444
-
3445
- // Fail closed on work that exists only in a run repo. `addRunRepo` clears
3446
- // the tree at <workspaceRoot>/<issue> before it provisions, so admitting
3447
- // this issue is what finally destroys the copy the salvage could not save
3448
- // (#118). Nothing here can recover it — git already refused once — so the
3449
- // only safe move is to refuse the claim and keep saying why until an
3450
- // operator has looked and run `unblock --force`.
3451
- const newest = store.latestRun(project.name, issue);
3452
- if (newest?.salvageError !== undefined && newest.salvageAckAt === undefined) {
3453
- hold(issue, "unsalvaged-wip");
3454
- await safeEscalate(d, {
3455
- tier: 1,
3456
- project: project.name,
3457
- issue,
3458
- summary: `#${issue} is holding unsalvaged work and will not be re-claimed`,
3459
- detail: [
3460
- r.issue.title,
3461
- r.issue.url,
3462
- `Attempt ${newest.attempt} could not commit its uncommitted changes: ${newest.salvageError}`,
3463
- `The only copy is the worktree ${newest.worktree === "" ? "(path not recorded)" : newest.worktree}.`,
3464
- "Dispatch is held because claiming this issue removes that tree.",
3465
- "Recover it by hand, then `omp-conductor unblock <n> --force` to release the hold.",
3466
- ].join("\n"),
3467
- });
3468
- continue;
3469
- }
3470
-
3471
- // #428 half (a): a preserved continuation that predates a configured
3472
- // critical-base/safety marker must not be reattached. A base safety fix
3473
- // protects only branches forked after it landed — a continuation forked
3474
- // before it still carries the dangerous test/runtime code, and re-running
3475
- // it on the shared host is what SIGTERMed the production daemon. Fail
3476
- // closed: only a probe that proves every marker is in the reattach
3477
- // source's ancestry admits, and a project that names a marker but has no
3478
- // probe wired (never happens outside tests) holds. Both the hold and the
3479
- // escalation are durable across restart and orphan recovery because this
3480
- // gate runs every admission pass; the branch is re-admitted automatically
3481
- // once the operator updates it to contain the marker, without losing work.
3482
- const markers = project.criticalBase ?? [];
3483
- if (markers.length > 0) {
3484
- const branch = branchName(r.issue);
3485
- let verdict: CriticalBaseVerdict;
3486
- if (d.probeCriticalBase === undefined) {
3487
- verdict = { state: "unknown", error: "no critical-base probe is wired in this deployment" };
3488
- } else {
3489
- try {
3490
- verdict = await d.probeCriticalBase(r.repo, markers, branch);
3491
- } catch (err) {
3492
- verdict = { state: "unknown", error: errText(err) };
3493
- }
3494
- }
3495
- if (verdict.state === "stale") {
3496
- hold(issue, "stale-base");
3497
- log(
3498
- `#${issue} held (stale-base): continuation branch ${branch} predates critical-base marker ${verdict.marker}`,
3499
- );
3500
- await safeEscalate(d, {
3501
- tier: 1,
3502
- project: project.name,
3503
- issue,
3504
- summary: `#${issue} continuation branch predates a critical base safety commit and is held (stale-base)`,
3505
- detail: [
3506
- r.issue.title,
3507
- r.issue.url,
3508
- `The retained branch ${branch} does not contain critical-base marker ${verdict.marker}.`,
3509
- ...(verdict.range.length > 0
3510
- ? [`Base commits the branch is missing: ${verdict.range.join(", ")}`]
3511
- : []),
3512
- "Recovery: merge current base into the branch so it contains the marker, and the next",
3513
- "admission pass re-admits it automatically without losing the branch's work; or review",
3514
- "the branch by hand and clear the hold once the fix is present.",
3515
- ].join("\n"),
3516
- });
3517
- continue;
3518
- }
3519
- if (verdict.state === "unknown") {
3520
- // Fail closed: a branch that cannot be *proven* to contain the marker
3521
- // is refused, and the reason names the unverifiable marker so the
3522
- // operator can fix the fetch or the marker rather than guess.
3523
- hold(issue, "stale-base");
3524
- log(
3525
- `#${issue} held (stale-base): continuation branch ${branch} could not be verified ` +
3526
- `against critical-base marker(s) ${markers.join(", ")} (${verdict.error})`,
3527
- );
3528
- await safeEscalate(d, {
3529
- tier: 1,
3530
- project: project.name,
3531
- issue,
3532
- summary: `#${issue} continuation branch could not be verified against a critical base safety commit and is held (stale-base)`,
3533
- detail: [
3534
- r.issue.title,
3535
- r.issue.url,
3536
- `The retained branch ${branch} could not be verified against critical-base marker(s) ${markers.join(", ")}: ${verdict.error}`,
3537
- "Recovery: merge current base into the branch so it contains the marker, and the next",
3538
- "admission pass re-admits it automatically without losing the branch's work; or review",
3539
- "the branch by hand and clear the hold once the fix is present.",
3540
- ].join("\n"),
3541
- });
3542
- continue;
3543
- }
3544
- }
3545
-
3546
- // Soft concurrency per epic, per repository: at most one in-flight child of
3547
- // a given parent in each repo. Children of one epic in *different* repos
3548
- // parallelise freely — `repo-active` / `maxConcurrentWorkersPerRepo` owns
3549
- // the same-repo collision domain (#197). The "" sentinel matches every
3550
- // repo. No parent means today's concurrent admission. Cheap local filters
3551
- // already ran; this sits before the open-PR API call so a held sibling
3552
- // frees the slot for unrelated work without spending a closers query.
3553
- let parent: number | undefined;
3554
- try {
3555
- parent = await resolveParent(issue);
3556
- } catch (err) {
3557
- hold(issue, "parent-lookup-error");
3558
- log(`#${issue} held: parent check failed (${errText(err)}) — retrying next tick`);
3559
- continue;
3560
- }
3561
- if (parent !== undefined) {
3562
- const occupied = occupiedParents.get(parent);
3563
- const blocker = occupied?.get(r.repo.name) ?? occupied?.get("");
3564
- // The gate serializes siblings under one epic: a held candidate must not
3565
- // proceed while a *different* child of the parent is occupied. But a
3566
- // candidate's own worker-free pushed-green row is exactly the work it is
3567
- // continuing, not a rival — the unblocked continuation of that same
3568
- // issue must not be rejected by its own occupancy, or the retained
3569
- // continuation deadlocks forever with the PR open.
3570
- if (blocker !== undefined && blocker !== issue) {
3571
- hold(issue, "sibling-active");
3572
- log(`#${issue} skipped: sibling #${blocker} in flight under epic #${parent} in ${r.repo.name}`);
3573
- continue;
3574
- }
3575
- }
3576
-
3577
- // The busy set is built from run rows, so it can only speak for work this
3578
- // database recorded. Work pushed before this store existed — a migration, a
3579
- // wiped or relocated state dir, a restore onto a new host — looks exactly
3580
- // like fresh work, and a worker sent at it re-implements a finished PR. The
3581
- // tracker is the only party that remembers, so it is asked. The cost is
3582
- // bounded by free slots, not by queue depth: the call sits behind the two
3583
- // cheap local filters and candidates beyond capacity skip it.
3584
- let closer: OpenCloser | undefined;
3585
- try {
3586
- closer = await tracker.openCloserFor(issue);
3587
- } catch (err) {
3588
- // Fail closed, per candidate. An API error means "unknown whether
3589
- // finished work exists", and admitting on unknown recreates precisely the
3590
- // duplicate-work failure this guard exists to kill: the worst case of
3591
- // holding is a five-minute delay, the worst case of admitting is a burned
3592
- // attempt and a second PR on the same issue. Holding one candidate rather
3593
- // than aborting the loop keeps a transient GitHub failure from deadlocking
3594
- // the whole dispatcher; the next tick retries by itself.
3595
- hold(issue, "open-pr-lookup-error");
3596
- log(`#${issue} held: open-PR check failed (${errText(err)}) — retrying next tick`);
3597
- continue;
3598
- }
3599
- if (closer !== undefined) {
3600
- const latest = store.latestRun(project.name, issue);
3601
- // Terminality is the first half of the test and is not negotiable: while a
3602
- // run is live its worker is still pushing to that branch, and a second
3603
- // worker sent at the same PR is exactly the duplicate-work failure this
3604
- // guard exists to kill. Only a run that has stopped can be continued.
3605
- const retained =
3606
- latest?.state === "blocked" ||
3607
- latest?.state === "failed" ||
3608
- latest?.state === "killed" ||
3609
- latest?.state === "orphaned" ||
3610
- latest?.state === "pushed-green"
3611
- ? latest
3612
- : undefined;
3613
- // The second half asks "is this open PR our retained work", and accepts
3614
- // two identities for it, because the branch is the durable artefact of a
3615
- // retained run and the PR is not. A cap kill can end a run before any PR
3616
- // exists: veltro#324 attempt 1 was killed at the turns cap on
3617
- // 2026-08-09T00:47Z before its worker opened one, so the row kept `branch`
3618
- // and `prUrl` stayed NULL. chad#438 was opened from that exact branch
3619
- // afterwards, and URL equality — the only test 0.3.20 had — can never match
3620
- // a URL the terminal run never recorded, so every tick held #324 as
3621
- // `open-pr` until an operator closed recoverable work to free the branch
3622
- // (#50). An ordinary issue whose open PR is unrelated still fails both
3623
- // identities and stays ineligible, and an empty `headRefName` (a reply that
3624
- // did not carry the field) is never a match: unknown is not identity.
3625
- let resume: string | undefined;
3626
- if (retained !== undefined) {
3627
- if (retained.prUrl === closer.url) {
3628
- resume = `from ${retained.state} run (matched recorded PR URL)`;
3629
- } else if (closer.headRefName !== "" && retained.branch === closer.headRefName) {
3630
- resume = `from ${retained.state} run (matched retained branch ${closer.headRefName})`;
3631
- }
3632
- }
3633
- if (resume === undefined) {
3634
- hold(issue, "open-pr");
3635
- log(`#${issue} skipped: open PR ${closer.url} already closes it`);
3636
- continue;
3637
- }
3638
- log(`#${issue} continuing retained PR ${closer.url} ${resume}`);
3639
- }
3640
-
3641
- // The queue comes from GitHub's eventually-consistent search index. Re-read
3642
- // state and labels directly at the last possible moment so a just-closed or
3643
- // explicitly dequeued issue cannot turn a stale candidate into another
3644
- // attempt (#247).
3645
- let snapshot: IssueSnapshot | undefined;
3646
- try {
3647
- snapshot = await tracker.issueSnapshot(issue);
3648
- } catch {
3649
- snapshot = undefined;
3650
- }
3651
- if (snapshot === undefined) {
3652
- hold(issue, "issue-state-lookup-error");
3653
- log(`#${issue} held: issue snapshot check failed — retrying next tick`);
3654
- continue;
3655
- }
3656
- if (snapshot.state === "closed") {
3657
- hold(issue, "issue-closed");
3658
- log(`#${issue} skipped: issue is closed (search index lag)`);
3659
- continue;
3660
- }
3661
- if (!snapshot.labels.includes(project.queueLabel)) {
3662
- hold(issue, "issue-dequeued");
3663
- log(`#${issue} skipped: queue label ${project.queueLabel} was removed (search index lag)`);
3664
- continue;
3665
- }
3666
-
3667
- admitted.push({ r, attempt: priorRuns + 1 });
3668
- liveByRepo.set(r.repo.name, (liveByRepo.get(r.repo.name) ?? 0) + 1);
3669
- if (parent !== undefined) {
3670
- // Extend the epic's occupancy under this repo (slot empty by construction
3671
- // here — the gate above would have held the candidate otherwise) so a
3672
- // same-repo sibling later in this pass does not clear the gate (#197).
3673
- let siblings = occupiedParents.get(parent);
3674
- if (siblings === undefined) {
3675
- siblings = new Map();
3676
- occupiedParents.set(parent, siblings);
3677
- }
3678
- if (!siblings.has(r.repo.name)) siblings.set(r.repo.name, issue);
3679
- }
3680
- }
3681
-
3682
- return { admitted, holds };
3683
- }
3684
-
3685
3343
  export interface WorkerPool {
3686
3344
  launch(work: Promise<void>): void;
3687
3345
  activeCount(): number;
@@ -4576,6 +4234,8 @@ export interface StatusSnapshot {
4576
4234
  */
4577
4235
  /** Current live-head push-workflow verdict per recently merged repository. */
4578
4236
  baseHealth: BaseHealth[];
4237
+ /** Per-repo base-red merge freezes, active first (#283). */
4238
+ freezes: BaseFreeze[];
4579
4239
  verbLedger: VerbLedgerEntry[];
4580
4240
  /** Runs backed by a worker process — the number capacity compares against. */
4581
4241
  liveWorkers: number;
@@ -4671,6 +4331,7 @@ export function statusSnapshotFromStore(
4671
4331
  ? {}
4672
4332
  : { labelOps: { pending: labelOpsPending, oldestAgeMs: now - oldestLabelOpAt } }),
4673
4333
  baseHealth: store.baseHealth(p.name),
4334
+ freezes: store.freezes(p.name),
4674
4335
  ...(orchestratorDown === undefined ? {} : { orchestratorDown }),
4675
4336
  };
4676
4337
  }
@@ -4714,6 +4375,11 @@ export function formatDispatchSummary(summary?: DispatchSummary): string {
4714
4375
  ? ""
4715
4376
  : ` (#${hold.issues.join(", #")}${hold.count > hold.issues.length ? ", …" : ""})`;
4716
4377
  lines.push(` ${hold.reason} ${hold.count}${sample}`);
4378
+ // A `file-lane` hold groups several issues, each blocked by a different
4379
+ // file and holder; the grouped line says how many, this says which.
4380
+ if ((hold.details?.length ?? 0) > 0) {
4381
+ lines.push(` ${hold.details!.join(" | ")}`);
4382
+ }
4717
4383
  }
4718
4384
  }
4719
4385
  }
@@ -4783,6 +4449,25 @@ export function formatBaseHealth(rows: readonly BaseHealth[]): string[] {
4783
4449
  });
4784
4450
  }
4785
4451
 
4452
+ /**
4453
+ * The active base-red freezes as status lines — merges refused until the base
4454
+ * is green again or the operator overrides. Active freezes only: a cleared
4455
+ * freeze is history the ledger and digest already told an operator about.
4456
+ */
4457
+ export function formatFreezes(freezes: readonly BaseFreeze[]): string[] {
4458
+ const active = freezes.filter((f) => f.clearedAt === undefined);
4459
+ if (active.length === 0) return [];
4460
+ return [
4461
+ "frozen repos (merges refused until base green)",
4462
+ ...active.map(
4463
+ (f) =>
4464
+ ` ${f.repo} base red at ${f.culpritSha.slice(0, 8)}` +
4465
+ (f.detail === undefined ? "" : ` ${f.detail}`) +
4466
+ ` — override: omp-conductor unfreeze ${f.repo}`,
4467
+ ),
4468
+ ];
4469
+ }
4470
+
4786
4471
 
4787
4472
  export function formatStatus(s: StatusSnapshot): string {
4788
4473
  const lines = [
@@ -4832,6 +4517,7 @@ export function formatStatus(s: StatusSnapshot): string {
4832
4517
  }
4833
4518
  }
4834
4519
  lines.push(...formatBaseHealth(s.baseHealth));
4520
+ lines.push(...formatFreezes(s.freezes));
4835
4521
  lines.push(...formatSalvagedRuns(s.salvagedRuns));
4836
4522
  lines.push(...formatOpenReports(s.openReports));
4837
4523
  lines.push(...formatVerbLedger(s.verbLedger));
@@ -5267,6 +4953,35 @@ async function recoverRun(
5267
4953
  log(`#${run.issue} escalated from persistent ${cls}: ${evidence}`);
5268
4954
  return;
5269
4955
  }
4956
+ // Same bound for provider-capacity: a run the provider throttled into the
4957
+ // ground is requeued free (no attempt charged) — but a provider that
4958
+ // throttles the same issue three times is at capacity, and a human has to
4959
+ // check its status before hand-requeueing (#573). On a chain-configured
4960
+ // project each requeue already moved the next attempt to the next chain
4961
+ // model, so this escalation is what catches the no-chain case and the
4962
+ // exhausted chain; it names every model the chain tried.
4963
+ if (
4964
+ cls === "provider-capacity" &&
4965
+ store.classCountFor(project.name, run.issue, "provider-capacity") >= PROVIDER_CAPACITY_MAX_STRIKES
4966
+ ) {
4967
+ const tried = formatModelsTried(modelsTried(store.runsForIssue(project.name, run.issue)));
4968
+ await safeEscalate(d, {
4969
+ tier: 1,
4970
+ project: project.name,
4971
+ issue: run.issue,
4972
+ summary: `[provider-capacity] #${run.issue}: the model provider is throttling this run into the ground — ${evidence}`,
4973
+ detail: [
4974
+ `The provider answered #${run.issue} with sustained in-session rate limits ${PROVIDER_CAPACITY_MAX_STRIKES} times in a row; the harness retried each and was exhausted.`,
4975
+ ...(tried === ""
4976
+ ? []
4977
+ : [`Models tried: ${tried}.`]),
4978
+ "Check the provider's rate-limit status (and its throughput-oriented routes) before requeueing by hand.",
4979
+ ].join("\n"),
4980
+ });
4981
+ store.updateRun(run.id, { recoveredAt: Date.now() });
4982
+ log(`#${run.issue} escalated from persistent ${cls}: ${evidence}`);
4983
+ return;
4984
+ }
5270
4985
  // Only when the tracker still shows this issue as ours to hand back. An
5271
4986
  // issue that is closed, or has no state label, was resolved by another route
5272
4987
  // and requeueing it would dispatch work nobody asked for.
@@ -5875,6 +5590,16 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
5875
5590
  cleanup: { next: 0 },
5876
5591
  probeCriticalBase: (repo, markers, branch) =>
5877
5592
  probeCriticalBase(project, repo, branch, markers),
5593
+ probeWorktreeLane: (input) => probeRunLane(input),
5594
+ // A cross-repo Depends-on prerequisite reads through the same GitHub
5595
+ // credential/accounting seams as the project tracker — a fresh tracker
5596
+ // scoped to the referenced repo, reusing the daemon's gh hooks so API
5597
+ // spend and refusals are counted exactly as the main tracker's are.
5598
+ probeIssueIn: (ownerRepo, issue) =>
5599
+ makeTracker({ ...project, tracker: { kind: "github", repo: ownerRepo } }, undefined, {
5600
+ onCall: () => store.bumpGhCalls?.(utcDay(), "daemon"),
5601
+ onRefusal: (at) => store.recordGhRefusal?.(at),
5602
+ }).issueSnapshot(issue),
5878
5603
  ...(verbPeerReader === undefined ? {} : { verbPeerReader }),
5879
5604
  verbActions,
5880
5605
  };
@@ -5970,7 +5695,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
5970
5695
  // when it resumes and cannot create the work this shutdown is about to
5971
5696
  // wait for (#374).
5972
5697
  drain.draining = true;
5973
- log("shutting down after active workers finish");
5698
+ log("shutdown requested draining dispatch; live worker sessions are not waited for");
5974
5699
  pace.requestWake();
5975
5700
  };
5976
5701
  process.on("SIGINT", stop);