omp-conductor 0.9.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.ts CHANGED
@@ -35,7 +35,7 @@ import { recordReleaseBlock } from "./release-policy.ts";
35
35
  import { branchName, effectiveLabels, route } from "./routing.ts";
36
36
  import type { Routed, UnroutableReason } from "./routing.ts";
37
37
  import { evaluateDecisionConditions, probeNpmVersion, probeRateLimitReset } from "./decisions.ts";
38
- import { classifyRun, type ClassifyFacts } from "./failure-class.ts";
38
+ import { classifyRun, providerCreditRefusal, providerTransientFault, type ClassifyFacts } from "./failure-class.ts";
39
39
  import { projectLabels } from "./label-projection.ts";
40
40
  import { dbPath, openStore, utcDay } from "./store.ts";
41
41
  import { makeTracker, type RateLimitStatus } from "./tracker/github.ts";
@@ -61,7 +61,14 @@ import type {
61
61
  Tracker,
62
62
  VerbLedgerEntry,
63
63
  } from "./types.ts";
64
- import { type KilledBy, type WorkerResult, renderBrief, runWorker } from "./worker.ts";
64
+ import {
65
+ type KilledBy,
66
+ type WorkerPauseControl,
67
+ type WorkerPausePhase,
68
+ type WorkerResult,
69
+ renderBrief,
70
+ runWorker,
71
+ } from "./worker.ts";
65
72
  import {
66
73
  addRunRepo,
67
74
  cleanupRetainedWorktree,
@@ -86,7 +93,7 @@ import {
86
93
  } from "./verbs/socket.ts";
87
94
  import { homedir } from "node:os";
88
95
 
89
- import { pushRunBranch, type RunRepoRef } from "./gitops.ts";
96
+ import { pushRunBranch, readBaseChain, type RunRepoRef } from "./gitops.ts";
90
97
  import {
91
98
  planUsageLine,
92
99
  readPlanUsage,
@@ -108,6 +115,11 @@ const REPORT_DELIVERY_INTERVAL_MS = 30_000;
108
115
  * means the mirror itself is broken, not unlucky, and the sweep escalates
109
116
  * instead of burning a turn-0 run per tick forever (#168, #177). */
110
117
  const DISPATCH_INFRA_MAX_STRIKES = 3;
118
+ /** A provider-transient requeue (stream stalled mid-run) is retried, but only a
119
+ * bounded number of times: three aborted streams for one issue means the
120
+ * provider itself is degraded, not unlucky, and the sweep escalates to a
121
+ * human instead of requeueing into a down provider forever (#220). */
122
+ const PROVIDER_TRANSIENT_MAX_STRIKES = 3;
111
123
  const DEFAULT_PORT = 8787;
112
124
  const BRIEF_TEMPLATE_PATH = join(import.meta.dir, "briefs", "worker.md");
113
125
 
@@ -144,6 +156,7 @@ interface Deps {
144
156
  usage: UsageSource;
145
157
  escalate(e: Escalation): Promise<void>;
146
158
  turnLimits: TurnLimitRegistry;
159
+ workerControls: WorkerControlRegistry;
147
160
  integrity: IntegrityGate;
148
161
  stall: StallGate;
149
162
  cleanup?: RetainedCleanupCursor;
@@ -190,6 +203,7 @@ export function verbDeps(d: Pick<Deps, "project" | "store" | "tracker" | "verbAc
190
203
  pausedAt,
191
204
  log,
192
205
  now: () => Date.now(),
206
+ chain: { readBaseChain },
193
207
  };
194
208
  }
195
209
 
@@ -264,6 +278,7 @@ async function watchOrchestrator(d: Deps): Promise<void> {
264
278
  log(`ERROR: the orchestrator session is not draining its queue — ${verdict.since ?? "no timestamp"}`);
265
279
  const delivered = await safeEscalate(d, {
266
280
  tier: 2,
281
+ category: "confirmed-failure",
267
282
  project: d.project.name,
268
283
  issue: NO_ISSUE,
269
284
  // Keyed on the marker's own timestamp, not the date. The dedup ledger keys
@@ -823,6 +838,91 @@ export function createTurnLimitRegistry(
823
838
  };
824
839
  }
825
840
 
841
+ export type WorkerControlResult =
842
+ | { kind: "ok"; runId: string; phase: WorkerPausePhase }
843
+ | { kind: "refused"; runId: string; error: string }
844
+ | { kind: "not-active" };
845
+
846
+ export interface WorkerControlSlot {
847
+ install(control: WorkerPauseControl): void;
848
+ close(): void;
849
+ }
850
+
851
+ export interface WorkerControlRegistry {
852
+ open(project: string, issue: number, runId: string): WorkerControlSlot;
853
+ pause(project: string, issue: number): Promise<WorkerControlResult>;
854
+ resume(project: string, issue: number): WorkerControlResult;
855
+ /** Live runs whose phase is not `running` — what /healthz and the board show. */
856
+ snapshot(project: string): { issue: number; runId: string; phase: WorkerPausePhase }[];
857
+ }
858
+
859
+ /** Authoritative controls for sessions owned by this daemon process. */
860
+ export function createWorkerControlRegistry(): WorkerControlRegistry {
861
+ const active = new Map<
862
+ string,
863
+ { project: string; issue: number; runId: string; control?: WorkerPauseControl }
864
+ >();
865
+ const key = (project: string, issue: number): string => `${project}\0${issue}`;
866
+ return {
867
+ open(project, issue, runId) {
868
+ const k = key(project, issue);
869
+ if (active.has(k)) throw new Error(`#${issue} already has a live worker controller`);
870
+ const entry = { project, issue, runId } as {
871
+ project: string;
872
+ issue: number;
873
+ runId: string;
874
+ control?: WorkerPauseControl;
875
+ };
876
+ active.set(k, entry);
877
+ return {
878
+ install: (control) => {
879
+ if (active.get(k) === entry) entry.control = control;
880
+ },
881
+ close: () => {
882
+ if (active.get(k) === entry) active.delete(k);
883
+ },
884
+ };
885
+ },
886
+ async pause(project, issue) {
887
+ const entry = active.get(key(project, issue));
888
+ if (entry?.control === undefined) return { kind: "not-active" };
889
+ try {
890
+ await entry.control.pause();
891
+ return { kind: "ok", runId: entry.runId, phase: entry.control.phase() };
892
+ } catch (err) {
893
+ return {
894
+ kind: "refused",
895
+ runId: entry.runId,
896
+ error: err instanceof Error ? err.message : String(err),
897
+ };
898
+ }
899
+ },
900
+ resume(project, issue) {
901
+ const entry = active.get(key(project, issue));
902
+ if (entry?.control === undefined) return { kind: "not-active" };
903
+ try {
904
+ entry.control.resume();
905
+ return { kind: "ok", runId: entry.runId, phase: entry.control.phase() };
906
+ } catch (err) {
907
+ return {
908
+ kind: "refused",
909
+ runId: entry.runId,
910
+ error: err instanceof Error ? err.message : String(err),
911
+ };
912
+ }
913
+ },
914
+ snapshot(project) {
915
+ const workers: { issue: number; runId: string; phase: WorkerPausePhase }[] = [];
916
+ for (const entry of active.values()) {
917
+ if (entry.project !== project || entry.control === undefined) continue;
918
+ const phase = entry.control.phase();
919
+ if (phase !== "running") workers.push({ issue: entry.issue, runId: entry.runId, phase });
920
+ }
921
+ return workers;
922
+ },
923
+ };
924
+ }
925
+
826
926
  export async function verifyPushedGreenClaim(
827
927
  tracker: Pick<Tracker, "verifyPr">,
828
928
  claim: Pick<WorkerResult, "prUrl" | "headSha">,
@@ -926,6 +1026,7 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
926
1026
  const mirrorPath = mirrorPathFor(r.repo, project.mirrorRoot);
927
1027
  let worktreePath: string | undefined;
928
1028
  let turnLimit: TurnLimitController | undefined;
1029
+ let workerControl: WorkerControlSlot | undefined;
929
1030
  // The run's own repository. Hoisted for the same reason `worktreePath` is —
930
1031
  // the catch and finally paths have to publish the branch.
931
1032
  let runRepo: RunRepoRef | undefined;
@@ -981,6 +1082,7 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
981
1082
  store.enqueueLabelOps(project.name, [{ issue, op: "add", label: inProgress }]);
982
1083
  claimed = true;
983
1084
  turnLimit = d.turnLimits.open(project.name, issue, runId, caps.workerMaxTurns);
1085
+ workerControl = d.workerControls.open(project.name, issue, runId);
984
1086
 
985
1087
  // A run's tree is <workspaceRoot>/<issue> and addRunRepo refuses to reuse
986
1088
  // an existing path, so a retry — or a tree kept from a failed attempt — has
@@ -1057,6 +1159,7 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
1057
1159
  cwd: worktreePath,
1058
1160
  caps,
1059
1161
  maxTurns: () => turnLimit?.maxTurns() ?? caps.workerMaxTurns,
1162
+ onPauseControl: (control) => workerControl?.install(control),
1060
1163
  sessionDir,
1061
1164
  // The session's control socket, under the daemon's own state directory —
1062
1165
  // a child process of the daemon reaches it directly.
@@ -1081,6 +1184,8 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
1081
1184
  onKilled: () => {
1082
1185
  turnLimit?.close();
1083
1186
  turnLimit = undefined;
1187
+ workerControl?.close();
1188
+ workerControl = undefined;
1084
1189
  },
1085
1190
  // Recorded the moment the session opens its transcript, not when the run
1086
1191
  // ends: `omp-conductor tail` resolves an issue to a file through this row,
@@ -1093,6 +1198,8 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
1093
1198
  // PR verification or terminal row writes can leave stale `running` state.
1094
1199
  turnLimit?.close();
1095
1200
  turnLimit = undefined;
1201
+ workerControl?.close();
1202
+ workerControl = undefined;
1096
1203
  }
1097
1204
 
1098
1205
  // A configured model the harness could not honour means this run was done by
@@ -1108,6 +1215,13 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
1108
1215
  : { state: result.state };
1109
1216
  const state = verified.state;
1110
1217
 
1218
+ // Read before the row is written so `lastError` carries the provider's own
1219
+ // message — it names the fix and the URL, which no classification can.
1220
+ const sessionErr = state === "failed" || state === "killed" ? readSessionError(result.sessionFile) : undefined;
1221
+ const providerCredit = sessionErr === undefined ? undefined : providerCreditRefusal(sessionErr);
1222
+ const providerTransient =
1223
+ providerCredit !== undefined || sessionErr === undefined ? undefined : providerTransientFault(sessionErr);
1224
+
1111
1225
  // The other half of not believing a worker about its own run (#128). The
1112
1226
  // claim being audited is `state: pushed-green`, so the audit runs on the
1113
1227
  // worker's claim rather than on what verification made of it: a claim that
@@ -1192,7 +1306,11 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
1192
1306
  // push: the report of a killed attempt is exactly the one a later
1193
1307
  // continuation must pool its disclosures from (#199).
1194
1308
  report: result.report,
1195
- ...(verified.reason === undefined ? {} : { lastError: verified.reason }),
1309
+ ...(providerCredit !== undefined || providerTransient !== undefined
1310
+ ? { lastError: providerCredit ?? providerTransient }
1311
+ : verified.reason === undefined
1312
+ ? {}
1313
+ : { lastError: verified.reason }),
1196
1314
  ...settlement?.patch,
1197
1315
  ...(audit === undefined || audit.flags.length === 0
1198
1316
  ? {}
@@ -1224,7 +1342,33 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
1224
1342
  maxContinuations: caps.maxContinuationsPerIssue,
1225
1343
  });
1226
1344
 
1227
- if (continueTurns) {
1345
+ if (providerCredit !== undefined) {
1346
+ // Pause here rather than at classification: the sweep runs on the tick,
1347
+ // and three issues each burned an attempt in the fifteen minutes between
1348
+ // the first 402 and a human noticing (#220).
1349
+ setPaused(true, { source: "provider-credit", reason: providerCredit });
1350
+ log(`#${issue} provider refused for credit — dispatch paused: ${providerCredit}`);
1351
+ swapLabel(store, project.name, issue, inProgress, project.stateLabels.failed);
1352
+ // Fleet-scoped and run-independent on purpose. The notification ledger
1353
+ // dedupes on `project:issue:tier:summary`, so `NO_ISSUE` plus a summary
1354
+ // carrying no run or attempt is what makes this page once for the fleet
1355
+ // instead of once per affected run.
1356
+ await safeEscalate(d, {
1357
+ tier: 2,
1358
+ category: "fleet-stopped",
1359
+ project: project.name,
1360
+ issue: NO_ISSUE,
1361
+ summary: `Model provider refused for credit — ${project.name} is paused`,
1362
+ detail: [
1363
+ providerCredit,
1364
+ "",
1365
+ "No implementation attempt was charged: this is a billing state, not a",
1366
+ "failed implementation. Each affected issue keeps its queue label and",
1367
+ "re-dispatches on `omp-conductor resume` once the provider has credit.",
1368
+ `Session: ${result.sessionFile ?? "(no transcript)"}`,
1369
+ ].join("\n"),
1370
+ });
1371
+ } else if (continueTurns) {
1228
1372
  // Requeue as one ordered pair: the in-progress removal before the
1229
1373
  // queue add, exactly the order the projector will apply them in (#201).
1230
1374
  store.enqueueLabelOps(project.name, [
@@ -1325,6 +1469,8 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
1325
1469
  // inner settlement guard exists. Latch it before any terminal write or await.
1326
1470
  turnLimit?.close();
1327
1471
  turnLimit = undefined;
1472
+ workerControl?.close();
1473
+ workerControl = undefined;
1328
1474
  const detail = errText(err);
1329
1475
  log(`#${issue} errored: ${detail}`);
1330
1476
  // A crash lands anywhere, including mid-edit in a tree holding the only
@@ -1370,6 +1516,8 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
1370
1516
  // a failure path, and whatever it still held is now a commit on the branch.
1371
1517
  } finally {
1372
1518
  turnLimit?.close();
1519
+ workerControl?.close();
1520
+ workerControl = undefined;
1373
1521
  // The run is over, so its channel is too. Closed here rather than beside
1374
1522
  // the session so the crash path closes it as well: a listener left bound
1375
1523
  // after its run settled is a socket whose `run-not-live` check is the only
@@ -1691,6 +1839,7 @@ const DEGRADED_HOLDS: ReadonlySet<AdmissionHoldReason> = new Set([
1691
1839
  export function summarizeDispatch(
1692
1840
  ready: number,
1693
1841
  routed: number,
1842
+ claimed: number,
1694
1843
  admitted: number,
1695
1844
  holds: readonly AdmissionHold[],
1696
1845
  completedAt = Date.now(),
@@ -1706,6 +1855,7 @@ export function summarizeDispatch(
1706
1855
  completedAt,
1707
1856
  ready,
1708
1857
  routed,
1858
+ claimed,
1709
1859
  admitted,
1710
1860
  degraded: holds.some((hold) => DEGRADED_HOLDS.has(hold.reason)),
1711
1861
  holds: [...groups]
@@ -1753,6 +1903,7 @@ function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation
1753
1903
  return {
1754
1904
  ...base,
1755
1905
  tier: 2,
1906
+ category: "fleet-stopped",
1756
1907
  // Dated: a meter that breaks again next month is a new incident, not a
1757
1908
  // repeat of this one.
1758
1909
  summary: `Plan usage source unreadable — ${project} is not claiming new work (${new Date().toISOString().slice(0, 10)})`,
@@ -1771,6 +1922,7 @@ function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation
1771
1922
  return {
1772
1923
  ...base,
1773
1924
  tier: 2,
1925
+ category: "fleet-stopped",
1774
1926
  summary: `Plan usage cap names an unusable window "${plan.cap?.windowId ?? "?"}" — ${project} is not claiming new work`,
1775
1927
  detail: [
1776
1928
  plan.detail,
@@ -2245,6 +2397,7 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
2245
2397
  if (integrity.page) {
2246
2398
  const delivered = await safeEscalate(d, {
2247
2399
  tier: 2,
2400
+ category: "fleet-stopped",
2248
2401
  project: project.name,
2249
2402
  issue: NO_ISSUE,
2250
2403
  // Dated for the same reason the spend cap is: the dedup key is the
@@ -2288,6 +2441,10 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
2288
2441
  return pending.length === 0 ? issue : { ...issue, labels: effectiveLabels(issue.labels, pending) };
2289
2442
  });
2290
2443
  const { routed, unroutable } = route(effective, project);
2444
+ // Ready issues that route() dropped without a reason carry a state label —
2445
+ // claimed by a live or settling run — so the claimed count is subtraction
2446
+ // rather than a routed-state signal (#228).
2447
+ const claimed = effective.length - routed.length - unroutable.length;
2291
2448
  const routingHolds: AdmissionHold[] = unroutable.map((u) => ({
2292
2449
  issue: u.issue.number,
2293
2450
  reason: `unroutable:${u.reason}`,
@@ -2295,7 +2452,7 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
2295
2452
  const recordDispatch = (admitted: number, holds: readonly AdmissionHold[]): void => {
2296
2453
  store.recordDispatch(
2297
2454
  project.name,
2298
- summarizeDispatch(ready.length, routed.length, admitted, holds),
2455
+ summarizeDispatch(ready.length, routed.length, claimed, admitted, holds),
2299
2456
  );
2300
2457
  };
2301
2458
 
@@ -2329,6 +2486,7 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
2329
2486
  setPaused(true, { source: "spend-cap", reason: `daily spend reached $${caps.dailySpendUsd}` });
2330
2487
  await safeEscalate(d, {
2331
2488
  tier: 2,
2489
+ category: "fleet-stopped",
2332
2490
  project: project.name,
2333
2491
  issue: NO_ISSUE,
2334
2492
  // Dated so the same cap pages again tomorrow, but only once per day.
@@ -2394,6 +2552,8 @@ export interface DaemonHealthSnapshot {
2394
2552
  rssBytes: number;
2395
2553
  dispatch?: DispatchSummary;
2396
2554
  codeGraph?: CodeGraphHealth;
2555
+ /** Live workers in a non-running pause phase; absent/empty = nothing paused. */
2556
+ workers?: { issue: number; runId: string; phase: WorkerPausePhase }[];
2397
2557
  }
2398
2558
 
2399
2559
  export function daemonHealthSnapshot(
@@ -2402,6 +2562,7 @@ export function daemonHealthSnapshot(
2402
2562
  paused = isPaused(),
2403
2563
  rssBytes = process.memoryUsage().rss,
2404
2564
  codeGraph?: CodeGraphHealth,
2565
+ workerControls?: WorkerControlRegistry,
2405
2566
  ): DaemonHealthSnapshot {
2406
2567
  const dispatch = store.latestDispatch(project);
2407
2568
  return {
@@ -2412,6 +2573,7 @@ export function daemonHealthSnapshot(
2412
2573
  rssBytes,
2413
2574
  ...(dispatch === undefined ? {} : { dispatch }),
2414
2575
  ...(codeGraph?.configured === true ? { codeGraph } : {}),
2576
+ ...(workerControls === undefined ? {} : { workers: workerControls.snapshot(project) }),
2415
2577
  };
2416
2578
  }
2417
2579
 
@@ -2476,10 +2638,70 @@ export async function turnLimitResponse(
2476
2638
  );
2477
2639
  }
2478
2640
 
2641
+ export async function workerControlResponse(
2642
+ req: Request,
2643
+ project: string,
2644
+ store: Pick<Store, "latestRun">,
2645
+ registry: WorkerControlRegistry,
2646
+ ): Promise<Response | undefined> {
2647
+ const url = new URL(req.url);
2648
+ const match = /^\/runs\/(\d+)\/(pause|resume)$/.exec(url.pathname);
2649
+ if (req.method !== "PUT" || match === null) return undefined;
2650
+ if (!req.headers.get("content-type")?.startsWith("application/json")) {
2651
+ return Response.json({ error: "content-type must be application/json" }, { status: 415 });
2652
+ }
2653
+
2654
+ let body: unknown;
2655
+ try {
2656
+ body = await req.json();
2657
+ } catch {
2658
+ return Response.json({ error: "request body must be valid JSON" }, { status: 400 });
2659
+ }
2660
+ if (body === null || typeof body !== "object") {
2661
+ return Response.json({ error: "request body must be a JSON object" }, { status: 400 });
2662
+ }
2663
+ const requestedProject = Reflect.get(body, "project");
2664
+ if (typeof requestedProject !== "string" || requestedProject.length === 0) {
2665
+ return Response.json({ error: "project must be a non-empty string" }, { status: 400 });
2666
+ }
2667
+ if (requestedProject !== project) {
2668
+ return Response.json(
2669
+ { error: `daemon serves project "${project}", not requested project "${requestedProject}"` },
2670
+ { status: 409 },
2671
+ );
2672
+ }
2673
+
2674
+ const issue = Number(match[1]);
2675
+ const outcome =
2676
+ match[2] === "pause"
2677
+ ? await registry.pause(project, issue)
2678
+ : registry.resume(project, issue);
2679
+ if (outcome.kind === "ok") {
2680
+ return Response.json({ runId: outcome.runId, phase: outcome.phase });
2681
+ }
2682
+ if (outcome.kind === "refused") {
2683
+ return Response.json({ error: `#${issue}: ${outcome.error}` }, { status: 409 });
2684
+ }
2685
+
2686
+ const latest = store.latestRun(project, issue);
2687
+ if (latest === undefined) {
2688
+ return Response.json({ error: `no run recorded for #${issue}` }, { status: 404 });
2689
+ }
2690
+ return Response.json(
2691
+ {
2692
+ error:
2693
+ `#${issue} has no live worker controller; its session already settled ` +
2694
+ `or belongs to another daemon (stored state: ${latest.state})`,
2695
+ },
2696
+ { status: 409 },
2697
+ );
2698
+ }
2699
+
2479
2700
  export interface DaemonHttpDeps {
2480
2701
  project: string;
2481
2702
  store: Pick<Store, "latestRun">;
2482
2703
  turnLimits: TurnLimitRegistry;
2704
+ workerControls: WorkerControlRegistry;
2483
2705
  health: () => DaemonHealthSnapshot;
2484
2706
  }
2485
2707
 
@@ -2487,17 +2709,18 @@ export interface DaemonHttpDeps {
2487
2709
  * The whole HTTP surface, in one named function so a test can pin what is *not*
2488
2710
  * on it.
2489
2711
  *
2490
- * Two routes: a health read, and the turn-limit control. Everything else is
2491
- * 404, and that is the contract see the prohibition at the `Bun.serve` call
2492
- * for why no mutation may be added here. Extracted from the serve callback
2493
- * precisely so "the daemon's HTTP port exposes no mutation route" is something
2494
- * a test asserts rather than something a reviewer has to notice (#126).
2712
+ * Three route families: the health read, turn-limit control, and worker
2713
+ * pause/resume control. Everything else is 404. The controls mutate only
2714
+ * daemon-owned live sessions; tracker and repository mutations stay on the
2715
+ * authenticated per-run channel described at the `Bun.serve` call (#126).
2495
2716
  */
2496
2717
  export async function daemonHttpResponse(req: Request, d: DaemonHttpDeps): Promise<Response> {
2497
2718
  const url = new URL(req.url);
2498
2719
  if (req.method === "GET" && url.pathname === "/healthz") return Response.json(d.health());
2499
- const control = await turnLimitResponse(req, d.project, d.store, d.turnLimits);
2500
- return control ?? new Response("not found\n", { status: 404 });
2720
+ const turnLimit = await turnLimitResponse(req, d.project, d.store, d.turnLimits);
2721
+ if (turnLimit !== undefined) return turnLimit;
2722
+ const workerControl = await workerControlResponse(req, d.project, d.store, d.workerControls);
2723
+ return workerControl ?? new Response("not found\n", { status: 404 });
2501
2724
  }
2502
2725
 
2503
2726
  export interface StatusSnapshot {
@@ -2505,6 +2728,12 @@ export interface StatusSnapshot {
2505
2728
  configPath: string;
2506
2729
  stateDir: string;
2507
2730
  paused: boolean;
2731
+ /**
2732
+ * Why the fleet is paused, when whatever paused it said. Rendered beside
2733
+ * `(PAUSED)` so a pause an operator did not issue names itself instead of
2734
+ * reading like a mistake (#220).
2735
+ */
2736
+ pauseReason?: string;
2508
2737
  caps: Caps;
2509
2738
  /**
2510
2739
  * The effective per-shape release grants. On the snapshot rather than re-read
@@ -2579,11 +2808,15 @@ export function statusSnapshotFromStore(
2579
2808
  const dispatch = store.latestDispatch(p.name);
2580
2809
  const labelOpsPending = store.countPendingLabelOps(p.name);
2581
2810
  const oldestLabelOpAt = store.oldestPendingLabelOpAt(p.name);
2811
+ // Read once: the provenance read touches the filesystem, and the renderer
2812
+ // should never pay for it twice per status.
2813
+ const reason = pauseProvenance()?.reason;
2582
2814
  return {
2583
2815
  project: p.name,
2584
2816
  configPath: configPath(),
2585
2817
  stateDir: stateDir(),
2586
2818
  paused: isPaused(),
2819
+ ...(reason === undefined ? {} : { pauseReason: reason }),
2587
2820
  caps,
2588
2821
  releaseGrants: resolveReleaseGrants(p),
2589
2822
  activeRuns: store.activeRuns(p.name),
@@ -2623,7 +2856,7 @@ export function formatDispatchSummary(summary?: DispatchSummary): string {
2623
2856
  if (summary === undefined) return "last dispatch (none recorded)";
2624
2857
  const lines = [
2625
2858
  `last dispatch ${new Date(summary.completedAt).toISOString()}${summary.degraded ? " DEGRADED" : ""}`,
2626
- ` candidates ${summary.ready} ready / ${summary.routed} routed`,
2859
+ ` candidates ${summary.ready} ready / ${summary.claimed ?? 0} in flight / ${summary.routed} spare`,
2627
2860
  ` admitted ${summary.admitted}`,
2628
2861
  ];
2629
2862
  if (summary.holds.length === 0) {
@@ -2857,6 +3090,57 @@ export function lastToolCalls(sessionFile: string | undefined, limit = SPIN_EVID
2857
3090
  return names.slice(-limit);
2858
3091
  }
2859
3092
 
3093
+ /** A provider refusal a session recorded before dying, or undefined for none. */
3094
+ export interface SessionError {
3095
+ status?: number;
3096
+ message: string;
3097
+ }
3098
+
3099
+ /**
3100
+ * The last error a transcript recorded, or undefined when it recorded none.
3101
+ *
3102
+ * The harness writes `{"stopReason":"error","errorStatus":402,"errorId":402,
3103
+ * "errorMessage":"402 This request requires more credits, ..."}`. The daemon
3104
+ * read none of it, so three runs died `unknown` with an empty `lastError` and
3105
+ * charged an attempt each for a billing state (#220).
3106
+ *
3107
+ * Scanned newest-first: a session that recovered from an early error and then
3108
+ * died of something else must report the something else, and a session that
3109
+ * recovered from its only error and finished cleanly reports the error anyway
3110
+ * because there is no terminal verdict to outrank it (#220).
3111
+ */
3112
+ export function readSessionError(sessionFile: string | undefined): SessionError | undefined {
3113
+ if (sessionFile === undefined) return undefined;
3114
+ let text: string;
3115
+ try {
3116
+ text = readFileSync(sessionFile, "utf8");
3117
+ } catch {
3118
+ return undefined;
3119
+ }
3120
+ const lines = text.split("\n");
3121
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
3122
+ const line = lines[i];
3123
+ if (line === undefined || line.length === 0) continue;
3124
+ let row: unknown;
3125
+ try {
3126
+ row = JSON.parse(line) as unknown;
3127
+ } catch {
3128
+ continue;
3129
+ }
3130
+ if (row === null || typeof row !== "object") continue;
3131
+ const rec = row as { readonly [key: string]: unknown };
3132
+ if (rec["stopReason"] !== "error") continue;
3133
+ const message = rec["errorMessage"];
3134
+ if (typeof message !== "string" || message.trim() === "") continue;
3135
+ const status = rec["errorStatus"];
3136
+ return {
3137
+ ...(typeof status === "number" && Number.isFinite(status) ? { status } : {}),
3138
+ message: message.trim(),
3139
+ };
3140
+ }
3141
+ return undefined;
3142
+ }
3143
+
2860
3144
  /**
2861
3145
  * Classify every unclassified terminal run, persist the verdict, and perform the
2862
3146
  * one recovery its class names (#132).
@@ -3004,6 +3288,28 @@ async function recoverRun(
3004
3288
  log(`#${run.issue} escalated from persistent ${cls}: ${evidence}`);
3005
3289
  return;
3006
3290
  }
3291
+ // Same bound for provider-transient: an issue whose stream keeps stalling
3292
+ // mid-run is requeued free (no attempt, no continuation charged) — but a
3293
+ // provider that aborts three times for one issue is down, and a human has
3294
+ // to check its status before hand-requeueing (#220).
3295
+ if (
3296
+ cls === "provider-transient" &&
3297
+ store.classCountFor(project.name, run.issue, "provider-transient") >= PROVIDER_TRANSIENT_MAX_STRIKES
3298
+ ) {
3299
+ await safeEscalate(d, {
3300
+ tier: 1,
3301
+ project: project.name,
3302
+ issue: run.issue,
3303
+ summary: `[provider-transient] #${run.issue}: the provider keeps aborting mid-stream — ${evidence}`,
3304
+ detail: [
3305
+ `The provider aborted the stream for #${run.issue} ${PROVIDER_TRANSIENT_MAX_STRIKES} times without the run ever producing a verdict (0 tokens billed each time).`,
3306
+ "Check provider status before requeueing by hand.",
3307
+ ].join("\n"),
3308
+ });
3309
+ store.updateRun(run.id, { recoveredAt: Date.now() });
3310
+ log(`#${run.issue} escalated from persistent ${cls}: ${evidence}`);
3311
+ return;
3312
+ }
3007
3313
  // Only when the tracker still shows this issue as ours to hand back. An
3008
3314
  // issue that is closed, or has no state label, was resolved by another route
3009
3315
  // and requeueing it would dispatch work nobody asked for.
@@ -3421,6 +3727,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
3421
3727
  const turnLimits = createTurnLimitRegistry((runId, maxTurns) => {
3422
3728
  store.updateRun(runId, { maxTurns });
3423
3729
  });
3730
+ const workerControls = createWorkerControlRegistry();
3424
3731
  const d: Deps = {
3425
3732
  project,
3426
3733
  caps,
@@ -3431,6 +3738,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
3431
3738
  usage: sharedUsageSource(),
3432
3739
  escalate: (e) => escalator.escalate(e),
3433
3740
  turnLimits,
3741
+ workerControls,
3434
3742
  integrity,
3435
3743
  // Fresh per daemon run, like the integrity gate: a restart is entitled to
3436
3744
  // page again about a stall that is still on disk.
@@ -3507,14 +3815,15 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
3507
3815
  process.on("SIGINT", stop);
3508
3816
  process.on("SIGTERM", stop);
3509
3817
 
3510
- // ── NO MUTATION ROUTE BELONGS ON THIS PORT ────────────────────────────────
3818
+ // ── NO TRACKER OR REPOSITORY MUTATION BELONGS ON THIS PORT ───────────────
3511
3819
  //
3512
3820
  // This is unauthenticated loopback TCP. Every local user can reach it, it
3513
3821
  // carries no credential of any kind, and it cannot tell one caller from
3514
- // another: `127.0.0.1` is not an identity. `turnLimitResponse` already shows
3515
- // what that costs — it trusts a body-supplied `project`, which is exactly the
3516
- // shape "identity from the payload" takes when nobody is watching, and it is
3517
- // tolerable only because raising a turn ceiling is bounded and reversible.
3822
+ // another: `127.0.0.1` is not an identity. The turn-limit and worker
3823
+ // pause/resume controls trust a body-supplied `project`, which is exactly the
3824
+ // shape "identity from the payload" takes when nobody is watching. Those
3825
+ // controls are tolerable only because they are bounded, live-run-local, and
3826
+ // reversible.
3518
3827
  //
3519
3828
  // A merge, a push, a release or a label is none of those things. Do not add
3520
3829
  // one here, and do not add "just a small one" behind a shared secret either:
@@ -3530,7 +3839,9 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
3530
3839
  project: project.name,
3531
3840
  store,
3532
3841
  turnLimits,
3533
- health: () => daemonHealthSnapshot(store, project.name, undefined, undefined, codeGraph),
3842
+ workerControls,
3843
+ health: () =>
3844
+ daemonHealthSnapshot(store, project.name, undefined, undefined, codeGraph, workerControls),
3534
3845
  }),
3535
3846
  });
3536
3847
  log(`serving /healthz on :${server.port}, project ${project.name}`);