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/README.md +57 -12
- package/package.json +1 -1
- package/src/board.ts +203 -63
- package/src/briefs/orchestrator.md +45 -1
- package/src/briefs/policy.md +4 -1
- package/src/chain-check.ts +157 -0
- package/src/cli.ts +90 -2
- package/src/config.ts +185 -13
- package/src/daemon.ts +331 -20
- package/src/diff-flags.ts +44 -3
- package/src/digest-schedule.ts +59 -0
- package/src/escalate.ts +17 -0
- package/src/failure-class.ts +72 -0
- package/src/fleet.ts +2 -1
- package/src/gitops.ts +49 -0
- package/src/omp.ts +36 -5
- package/src/orchestrator-tick.ts +73 -3
- package/src/plugin.ts +18 -2
- package/src/reports.ts +5 -6
- package/src/session-host.ts +19 -3
- package/src/setup.ts +39 -8
- package/src/store.ts +73 -2
- package/src/types.ts +111 -4
- package/src/verbs/server.ts +49 -0
- package/src/worker.ts +201 -16
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 {
|
|
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
|
-
...(
|
|
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 (
|
|
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
|
-
*
|
|
2491
|
-
*
|
|
2492
|
-
*
|
|
2493
|
-
*
|
|
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
|
|
2500
|
-
|
|
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}
|
|
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
|
|
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.
|
|
3515
|
-
//
|
|
3516
|
-
// shape "identity from the payload" takes when nobody is watching
|
|
3517
|
-
// tolerable only because
|
|
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
|
-
|
|
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}`);
|