omp-conductor 0.19.2 → 0.19.4
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 +4 -3
- package/REFERENCE.md +1 -1
- package/package.json +1 -1
- package/schema/config.schema.json +13 -1
- package/src/arm-challenge.ts +92 -16
- package/src/board.ts +50 -9
- package/src/cli.ts +2 -0
- package/src/command-manifest.ts +10 -0
- package/src/commands/arm.ts +30 -2
- package/src/commands/companion.ts +103 -0
- package/src/commands/context.ts +1 -0
- package/src/commands/worker.ts +1 -0
- package/src/companion-view.ts +81 -0
- package/src/config-schema.ts +7 -1
- package/src/config.ts +6 -3
- package/src/daemon.ts +288 -15
- package/src/dashboard/controls.ts +1 -1
- package/src/doctor.ts +126 -7
- package/src/escalate.ts +47 -1
- package/src/failure-class.fixture.json +546 -0
- package/src/failure-class.ts +192 -0
- package/src/fleet.ts +286 -32
- package/src/orchestrator-tick.ts +115 -34
- package/src/settlement.ts +181 -0
- package/src/setup.ts +6 -4
- package/src/spend-telemetry.ts +74 -2
- package/src/status-render.ts +25 -6
- package/src/store.ts +39 -3
- package/src/types.ts +39 -2
- package/src/usage.ts +25 -0
package/src/daemon.ts
CHANGED
|
@@ -38,7 +38,12 @@ import { pendingCodeGraph, probeCodeGraph, type CodeGraphHealth } from "./graph-
|
|
|
38
38
|
import { graphHint } from "./graph.ts";
|
|
39
39
|
import { hostConstraintsNotice } from "./host.ts";
|
|
40
40
|
import { acquireOnceLease, healthCheck, livingDaemon, probeUnit, SYSTEMD_UNIT } from "./lifecycle.ts";
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
readTickRequestReason,
|
|
43
|
+
requestImmediateTick,
|
|
44
|
+
resolveTickConfigCwd,
|
|
45
|
+
STALL_MARKER_FILE,
|
|
46
|
+
} from "./orchestrator-tick.ts";
|
|
42
47
|
import { SPEND_SAMPLE_ROWS, SPEND_SAMPLE_RUNS, runDoctor } from "./doctor.ts";
|
|
43
48
|
import { judgeSpendTelemetry, type SpendTelemetryVerdict } from "./spend-telemetry.ts";
|
|
44
49
|
import {
|
|
@@ -62,6 +67,7 @@ import {
|
|
|
62
67
|
herdrPaneOmpStarts,
|
|
63
68
|
openWorkerPane,
|
|
64
69
|
reconcileWorkerPanes,
|
|
70
|
+
PANE_REATTEMPT_MAX,
|
|
65
71
|
releaseOrphanedWorkerPane,
|
|
66
72
|
releaseWorkerPane,
|
|
67
73
|
reportWorkerPaneState,
|
|
@@ -110,11 +116,13 @@ import {
|
|
|
110
116
|
import {
|
|
111
117
|
adoptSalvagedPrs,
|
|
112
118
|
classifyAndRecover,
|
|
119
|
+
harnessLogDir,
|
|
113
120
|
collectSettlementFlags,
|
|
114
121
|
formatQuarantinedRuns,
|
|
115
122
|
formatSalvagedRuns,
|
|
116
123
|
reactToProviderCredit,
|
|
117
124
|
readSessionError,
|
|
125
|
+
readTerminalEvidence,
|
|
118
126
|
reconcileOrphanedRuns,
|
|
119
127
|
reconcileGroomingClosures,
|
|
120
128
|
reconcileStaleLabels,
|
|
@@ -127,6 +135,7 @@ import { materializeOmpSettings } from "./omp-settings.ts";
|
|
|
127
135
|
import { evaluateDecisionConditions, probeNpmVersion, probeRateLimitReset } from "./decisions.ts";
|
|
128
136
|
import {
|
|
129
137
|
infraLogSignature,
|
|
138
|
+
classifyRun,
|
|
130
139
|
infraSignatureVersion,
|
|
131
140
|
providerCreditRefusal,
|
|
132
141
|
providerTransientFault,
|
|
@@ -327,6 +336,8 @@ interface Deps {
|
|
|
327
336
|
workerControls: WorkerControlRegistry;
|
|
328
337
|
/** Session seam for lifecycle integration tests; production uses the real harness. */
|
|
329
338
|
workerDeps?: RunWorkerDeps;
|
|
339
|
+
/** Harness per-process logs used to classify terminal worker routes. */
|
|
340
|
+
harnessLogDir?: string;
|
|
330
341
|
integrity: IntegrityGate;
|
|
331
342
|
stall: StallGate;
|
|
332
343
|
/**
|
|
@@ -1424,11 +1435,20 @@ export interface WorkerControlRegistry {
|
|
|
1424
1435
|
runId: string,
|
|
1425
1436
|
onPhase?: (phase: WorkerPausePhase) => void,
|
|
1426
1437
|
): WorkerControlSlot;
|
|
1427
|
-
|
|
1438
|
+
/** `source` names who asked (board/cli/dashboard) — recorded as pause
|
|
1439
|
+
* provenance so "who paused this and when" is answerable later (#997). */
|
|
1440
|
+
pause(project: string, issue: number, source: string): Promise<WorkerControlResult>;
|
|
1428
1441
|
resume(project: string, issue: number): WorkerControlResult;
|
|
1429
1442
|
stop(project: string, issue: number, reason: string): Promise<WorkerControlResult>;
|
|
1430
|
-
/** Live runs whose phase is not `running` — what /healthz and the board
|
|
1431
|
-
|
|
1443
|
+
/** Live runs whose phase is not `running` — what /healthz and the board
|
|
1444
|
+
* show — each carrying its pause provenance when one was recorded (#997). */
|
|
1445
|
+
snapshot(project: string): {
|
|
1446
|
+
issue: number;
|
|
1447
|
+
runId: string;
|
|
1448
|
+
phase: WorkerPausePhase;
|
|
1449
|
+
source?: string;
|
|
1450
|
+
pausedAtMs?: number;
|
|
1451
|
+
}[];
|
|
1432
1452
|
}
|
|
1433
1453
|
|
|
1434
1454
|
/** Authoritative controls for sessions owned by this daemon process. */
|
|
@@ -1442,6 +1462,8 @@ export function createWorkerControlRegistry(): WorkerControlRegistry {
|
|
|
1442
1462
|
stopError?: string;
|
|
1443
1463
|
finished: PromiseWithResolvers<void>;
|
|
1444
1464
|
onPhase?: (phase: WorkerPausePhase) => void;
|
|
1465
|
+
/** Who asked for the live pause, and when — cleared on resume (#997). */
|
|
1466
|
+
pausedBy?: { source: string; at: number };
|
|
1445
1467
|
}
|
|
1446
1468
|
|
|
1447
1469
|
const active = new Map<string, Entry>();
|
|
@@ -1476,12 +1498,13 @@ export function createWorkerControlRegistry(): WorkerControlRegistry {
|
|
|
1476
1498
|
},
|
|
1477
1499
|
};
|
|
1478
1500
|
},
|
|
1479
|
-
async pause(project, issue) {
|
|
1501
|
+
async pause(project, issue, source) {
|
|
1480
1502
|
const entry = active.get(key(project, issue));
|
|
1481
1503
|
if (entry?.control === undefined) return { kind: "not-active" };
|
|
1482
1504
|
try {
|
|
1483
1505
|
await entry.control.pause();
|
|
1484
1506
|
const phase = entry.control.phase();
|
|
1507
|
+
entry.pausedBy = { source, at: Date.now() };
|
|
1485
1508
|
entry.onPhase?.(phase);
|
|
1486
1509
|
return { kind: "ok", runId: entry.runId, phase };
|
|
1487
1510
|
} catch (err) {
|
|
@@ -1498,6 +1521,7 @@ export function createWorkerControlRegistry(): WorkerControlRegistry {
|
|
|
1498
1521
|
try {
|
|
1499
1522
|
entry.control.resume();
|
|
1500
1523
|
const phase = entry.control.phase();
|
|
1524
|
+
delete entry.pausedBy;
|
|
1501
1525
|
entry.onPhase?.(phase);
|
|
1502
1526
|
return { kind: "ok", runId: entry.runId, phase };
|
|
1503
1527
|
} catch (err) {
|
|
@@ -1533,11 +1557,25 @@ export function createWorkerControlRegistry(): WorkerControlRegistry {
|
|
|
1533
1557
|
return { kind: "stopped", runId: entry.runId, reason: entry.stopReason! };
|
|
1534
1558
|
},
|
|
1535
1559
|
snapshot(project) {
|
|
1536
|
-
const workers: {
|
|
1560
|
+
const workers: {
|
|
1561
|
+
issue: number;
|
|
1562
|
+
runId: string;
|
|
1563
|
+
phase: WorkerPausePhase;
|
|
1564
|
+
source?: string;
|
|
1565
|
+
pausedAtMs?: number;
|
|
1566
|
+
}[] = [];
|
|
1537
1567
|
for (const entry of active.values()) {
|
|
1538
1568
|
if (entry.project !== project || entry.control === undefined) continue;
|
|
1539
1569
|
const phase = entry.control.phase();
|
|
1540
|
-
if (phase
|
|
1570
|
+
if (phase === "running") continue;
|
|
1571
|
+
workers.push({
|
|
1572
|
+
issue: entry.issue,
|
|
1573
|
+
runId: entry.runId,
|
|
1574
|
+
phase,
|
|
1575
|
+
...(entry.pausedBy === undefined
|
|
1576
|
+
? {}
|
|
1577
|
+
: { source: entry.pausedBy.source, pausedAtMs: entry.pausedBy.at }),
|
|
1578
|
+
});
|
|
1541
1579
|
}
|
|
1542
1580
|
return workers;
|
|
1543
1581
|
},
|
|
@@ -2804,6 +2842,7 @@ export async function handleIssue(
|
|
|
2804
2842
|
log(`#${issue} stopped by operator on attempt ${attempt}: ${result.stoppedReason}`);
|
|
2805
2843
|
} else if (state === "blocked") {
|
|
2806
2844
|
swapLabel(store, project.name, issue, inProgress, project.stateLabels.blocked);
|
|
2845
|
+
wakeOrchestratorForBlockedRun(project.name, issue);
|
|
2807
2846
|
await safeEscalate(d, {
|
|
2808
2847
|
tier: 1,
|
|
2809
2848
|
project: project.name,
|
|
@@ -3035,8 +3074,27 @@ export async function handleIssue(
|
|
|
3035
3074
|
* releasing one whose run is not live; the authoritative child is never
|
|
3036
3075
|
* signalled, and no pane is ever closed.
|
|
3037
3076
|
*/
|
|
3077
|
+
/**
|
|
3078
|
+
* Pane re-establishment attempts spent per run, for this daemon process only
|
|
3079
|
+
* (#998).
|
|
3080
|
+
*
|
|
3081
|
+
* Deliberately in memory rather than on the run row: a restart is exactly the
|
|
3082
|
+
* event that makes another attempt worth making (Herdr came back), so a fresh
|
|
3083
|
+
* process legitimately gets a fresh budget, and giving up never marks a run
|
|
3084
|
+
* permanently unrepresentable. Pruned against the live set on every pass, so it
|
|
3085
|
+
* cannot outgrow the fleet.
|
|
3086
|
+
*/
|
|
3087
|
+
const paneAttemptsSpent = new Map<string, number>();
|
|
3088
|
+
|
|
3038
3089
|
function reconcilePanes(d: Deps, project: string, log: (message: string) => void): void {
|
|
3039
3090
|
const live = d.store.liveRuns(project);
|
|
3091
|
+
const liveKeys = new Set(live.map((run) => `${project}\u0000${run.id}`));
|
|
3092
|
+
for (const key of [...paneAttemptsSpent.keys()]) {
|
|
3093
|
+
if (key.startsWith(`${project}\u0000`) && !liveKeys.has(key)) paneAttemptsSpent.delete(key);
|
|
3094
|
+
}
|
|
3095
|
+
const attempts = new Map(
|
|
3096
|
+
live.map((run) => [run.id, paneAttemptsSpent.get(`${project}\u0000${run.id}`) ?? 0]),
|
|
3097
|
+
);
|
|
3040
3098
|
const result = reconcileWorkerPanes(
|
|
3041
3099
|
live.map((run) => ({
|
|
3042
3100
|
runId: run.id,
|
|
@@ -3048,6 +3106,8 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
|
|
|
3048
3106
|
...(run.paneLabel === undefined ? {} : { paneLabel: run.paneLabel }),
|
|
3049
3107
|
...(run.sessionFile === undefined ? {} : { sessionFile: run.sessionFile }),
|
|
3050
3108
|
})),
|
|
3109
|
+
{},
|
|
3110
|
+
attempts,
|
|
3051
3111
|
);
|
|
3052
3112
|
if (!result.ok) {
|
|
3053
3113
|
// An unreadable workspace is not evidence that anything is stale, so nothing
|
|
@@ -3068,9 +3128,33 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
|
|
|
3068
3128
|
});
|
|
3069
3129
|
log(`#${runIssue(live, outcome.runId)} herdr pane re-associated as ${outcome.paneId} after a restart`);
|
|
3070
3130
|
break;
|
|
3071
|
-
case "untracked":
|
|
3131
|
+
case "untracked": {
|
|
3072
3132
|
d.store.updateRun(outcome.runId, { paneId: null, paneLabel: null, paneUnavailable: outcome.reason });
|
|
3073
|
-
|
|
3133
|
+
if (outcome.attempted !== true) {
|
|
3134
|
+
// Cost no attempt (no recorded pid): say so every pass, as before.
|
|
3135
|
+
log(`#${runIssue(live, outcome.runId)} has no herdr pane: ${outcome.reason}`);
|
|
3136
|
+
break;
|
|
3137
|
+
}
|
|
3138
|
+
// #998: a terminal refusing to split will refuse the next one too, and
|
|
3139
|
+
// each retry made the pile worse — spend the whole budget at once.
|
|
3140
|
+
const key = `${project}\u0000${outcome.runId}`;
|
|
3141
|
+
const spent = outcome.reason.includes("pane_split_failed")
|
|
3142
|
+
? PANE_REATTEMPT_MAX
|
|
3143
|
+
: (paneAttemptsSpent.get(key) ?? 0) + 1;
|
|
3144
|
+
paneAttemptsSpent.set(key, spent);
|
|
3145
|
+
// Once per run, at the moment the budget runs out — not once per pass.
|
|
3146
|
+
// Four identical lines a minute for #986 is what made the real cause
|
|
3147
|
+
// (a leaked pane per attempt) hard to see on 2026-08-23.
|
|
3148
|
+
log(
|
|
3149
|
+
spent >= PANE_REATTEMPT_MAX
|
|
3150
|
+
? `#${runIssue(live, outcome.runId)} has no herdr pane after ${spent} attempt(s), not retrying: ${outcome.reason}`
|
|
3151
|
+
: `#${runIssue(live, outcome.runId)} has no herdr pane (attempt ${spent}/${PANE_REATTEMPT_MAX}): ${outcome.reason}`,
|
|
3152
|
+
);
|
|
3153
|
+
break;
|
|
3154
|
+
}
|
|
3155
|
+
case "attempts-exhausted":
|
|
3156
|
+
// Already reported when the budget ran out; the run keeps working with
|
|
3157
|
+
// no pane, and a later restart gets a fresh budget.
|
|
3074
3158
|
break;
|
|
3075
3159
|
case "stale-released":
|
|
3076
3160
|
log(`released stale herdr pane ${outcome.paneId} (run ${outcome.runId || "unidentified"} is not live)`);
|
|
@@ -3880,7 +3964,10 @@ export async function handleReviewRevision(d: Deps, revision: ReviewRevisionReco
|
|
|
3880
3964
|
if (await settleStopBeforeSession()) return;
|
|
3881
3965
|
if (await settleDrainBeforeSession()) return;
|
|
3882
3966
|
|
|
3883
|
-
|
|
3967
|
+
// A review revision is a new worker process on the same run row. Clear the
|
|
3968
|
+
// prior process evidence before launch so a failed wake cannot inherit an
|
|
3969
|
+
// earlier round's harness route.
|
|
3970
|
+
store.updateRun(runId, { worktree: worktreePath, workerPid: null, terminalEvidence: null });
|
|
3884
3971
|
|
|
3885
3972
|
// The row's own counters are cumulative across the revision: the revision
|
|
3886
3973
|
// worker meters its own session from zero, so its deltas are added to the
|
|
@@ -3893,6 +3980,7 @@ export async function handleReviewRevision(d: Deps, revision: ReviewRevisionReco
|
|
|
3893
3980
|
log(`#${issue} review round ${revision.round} → resuming ${branch} (${priorSessionFile})`);
|
|
3894
3981
|
|
|
3895
3982
|
|
|
3983
|
+
let workerStartedAt: number | undefined;
|
|
3896
3984
|
let result: WorkerResult;
|
|
3897
3985
|
try {
|
|
3898
3986
|
const runAllowanceUsd = runSpendAllowanceUsd(caps);
|
|
@@ -3918,7 +4006,9 @@ export async function handleReviewRevision(d: Deps, revision: ReviewRevisionReco
|
|
|
3918
4006
|
socketPath: join(sessionDir, "ipc.sock"),
|
|
3919
4007
|
verbSocketPath: verbListener.path,
|
|
3920
4008
|
onSpawn: (pid) => {
|
|
4009
|
+
workerStartedAt = Date.now();
|
|
3921
4010
|
verbListener?.bindPid(pid);
|
|
4011
|
+
store.updateRun(runId, { workerPid: pid });
|
|
3922
4012
|
},
|
|
3923
4013
|
onChildLog: (line) => {
|
|
3924
4014
|
log(`#${issue} ${line}`);
|
|
@@ -4013,6 +4103,18 @@ export async function handleReviewRevision(d: Deps, revision: ReviewRevisionReco
|
|
|
4013
4103
|
...(verified.reason === undefined ? [] : ["", verified.reason]),
|
|
4014
4104
|
result.report,
|
|
4015
4105
|
].join("\n");
|
|
4106
|
+
const latestRun = store.getRun(runId) ?? run;
|
|
4107
|
+
const terminalEvidence =
|
|
4108
|
+
state === "failed" || state === "killed"
|
|
4109
|
+
? readTerminalEvidence(
|
|
4110
|
+
{
|
|
4111
|
+
sessionFile: result.sessionFile,
|
|
4112
|
+
startedAt: workerStartedAt ?? latestRun.startedAt,
|
|
4113
|
+
...(latestRun.workerPid === undefined ? {} : { workerPid: latestRun.workerPid }),
|
|
4114
|
+
},
|
|
4115
|
+
d.harnessLogDir ?? harnessLogDir(),
|
|
4116
|
+
)
|
|
4117
|
+
: undefined;
|
|
4016
4118
|
|
|
4017
4119
|
const terminalPatch: Partial<RunRecord> = {
|
|
4018
4120
|
endedAt: Date.now(),
|
|
@@ -4033,9 +4135,76 @@ export async function handleReviewRevision(d: Deps, revision: ReviewRevisionReco
|
|
|
4033
4135
|
...(result.headSha === undefined ? {} : { headSha: result.headSha }),
|
|
4034
4136
|
sessionFile: result.sessionFile,
|
|
4035
4137
|
...(result.graphTools === undefined ? {} : { graphTools: result.graphTools }),
|
|
4138
|
+
...(terminalEvidence === undefined ? {} : { terminalEvidence }),
|
|
4036
4139
|
report: finalReport,
|
|
4037
4140
|
...settlement?.patch,
|
|
4038
4141
|
};
|
|
4142
|
+
const terminalClassification =
|
|
4143
|
+
terminalEvidence === undefined
|
|
4144
|
+
? undefined
|
|
4145
|
+
: classifyRun(
|
|
4146
|
+
{
|
|
4147
|
+
...latestRun,
|
|
4148
|
+
...terminalPatch,
|
|
4149
|
+
state,
|
|
4150
|
+
terminalEvidence,
|
|
4151
|
+
},
|
|
4152
|
+
{},
|
|
4153
|
+
caps,
|
|
4154
|
+
);
|
|
4155
|
+
if (terminalClassification?.cls === "model-empty-stop") {
|
|
4156
|
+
const recoveredAt = Date.now();
|
|
4157
|
+
store.updateRun(runId, {
|
|
4158
|
+
...terminalPatch,
|
|
4159
|
+
state: "failed",
|
|
4160
|
+
failureClass: terminalClassification.cls,
|
|
4161
|
+
recoveryAction: terminalClassification.recovery,
|
|
4162
|
+
recoveredAt,
|
|
4163
|
+
});
|
|
4164
|
+
const continuations = store.continuationsFor(project.name, issue);
|
|
4165
|
+
if (hasContinuationBudget(continuations, caps.maxContinuationsPerIssue)) {
|
|
4166
|
+
// Keep the same review row, round, findings, session, branch and PR.
|
|
4167
|
+
// Restore the exact pushed-green origin state while moving this stop
|
|
4168
|
+
// into the explicit continuation counter; the next claim must not
|
|
4169
|
+
// depend on re-verifying a failed-state origin at an unchanged head.
|
|
4170
|
+
const chargedRun = store.getRun(runId) ?? latestRun;
|
|
4171
|
+
store.updateRun(runId, {
|
|
4172
|
+
...terminalPatch,
|
|
4173
|
+
state: "pushed-green",
|
|
4174
|
+
failureClass: null,
|
|
4175
|
+
recoveryAction: null,
|
|
4176
|
+
recoveredAt: null,
|
|
4177
|
+
continuationCharges: (chargedRun.continuationCharges ?? 0) + 1,
|
|
4178
|
+
workerPid: null,
|
|
4179
|
+
lastError: terminalClassification.evidence,
|
|
4180
|
+
});
|
|
4181
|
+
store.requeueReviewRevision(revision.id);
|
|
4182
|
+
log(
|
|
4183
|
+
`#${issue} review round ${revision.round} re-queued after a provider empty-stop ` +
|
|
4184
|
+
`(${continuations}/${caps.maxContinuationsPerIssue} continuations): ${terminalClassification.evidence}`,
|
|
4185
|
+
);
|
|
4186
|
+
return;
|
|
4187
|
+
}
|
|
4188
|
+
|
|
4189
|
+
store.settleReviewRevision(revision.id, "failed", recoveredAt);
|
|
4190
|
+
swapLabel(store, project.name, issue, inProgress, project.stateLabels.failed);
|
|
4191
|
+
const model = result.model ?? latestRun.resolvedModel ?? latestRun.model ?? "configured model";
|
|
4192
|
+
const provider = result.provider ?? latestRun.resolvedProvider ?? "provider";
|
|
4193
|
+
await safeEscalate(d, {
|
|
4194
|
+
tier: 1,
|
|
4195
|
+
project: project.name,
|
|
4196
|
+
issue,
|
|
4197
|
+
runId,
|
|
4198
|
+
summary: `#${issue} review round ${revision.round}: the model kept returning empty responses`,
|
|
4199
|
+
detail:
|
|
4200
|
+
`${provider}/${model} ended ${continuations} review session(s) with empty responses until ` +
|
|
4201
|
+
`the harness retry cap. The same review round was preserved while continuation budget remained; ` +
|
|
4202
|
+
`that budget is now exhausted at ${caps.maxContinuationsPerIssue}. No implementation failure or ` +
|
|
4203
|
+
`additional review round was charged.\n\n${terminalClassification.evidence}`,
|
|
4204
|
+
});
|
|
4205
|
+
log(`#${issue} review round ${revision.round} provider empty-stops exhausted the continuation budget`);
|
|
4206
|
+
return;
|
|
4207
|
+
}
|
|
4039
4208
|
// #903: an infrastructure kill spends no review round.
|
|
4040
4209
|
//
|
|
4041
4210
|
// A dispatch that dies before the resumed session takes a turn — a host
|
|
@@ -4128,6 +4297,7 @@ export async function handleReviewRevision(d: Deps, revision: ReviewRevisionReco
|
|
|
4128
4297
|
log(`#${issue} review round ${revision.round} stopped by operator: ${result.stoppedReason}`);
|
|
4129
4298
|
} else if (state === "blocked") {
|
|
4130
4299
|
swapLabel(store, project.name, issue, inProgress, project.stateLabels.blocked);
|
|
4300
|
+
wakeOrchestratorForBlockedRun(project.name, issue);
|
|
4131
4301
|
await safeEscalate(d, {
|
|
4132
4302
|
tier: 1,
|
|
4133
4303
|
project: project.name,
|
|
@@ -5060,6 +5230,53 @@ export function wakeOrchestratorForMetConditions(
|
|
|
5060
5230
|
}
|
|
5061
5231
|
|
|
5062
5232
|
|
|
5233
|
+
/**
|
|
5234
|
+
* Wake the orchestrator because a worker stopped to ask a question (#990).
|
|
5235
|
+
*
|
|
5236
|
+
* `question` is the third-largest class in this ledger — 58 of 839 runs — and
|
|
5237
|
+
* every one of them is a parked worker holding a slot until someone reads the
|
|
5238
|
+
* question. Nothing woke the orchestrator for it: the only wake was a decision
|
|
5239
|
+
* condition being met, so on a fleet with a 1800s heartbeat a tier-1 question
|
|
5240
|
+
* whose answer is one comment could sit unread for half an hour.
|
|
5241
|
+
*
|
|
5242
|
+
* Deliberately narrow. Only the blocked path wakes: a failed run, a cap kill
|
|
5243
|
+
* and a clean settle are the next scheduled tick's business, and waking on
|
|
5244
|
+
* every terminal state turns the heartbeat into a busy loop — which would be a
|
|
5245
|
+
* worse fleet than the one that waits.
|
|
5246
|
+
*
|
|
5247
|
+
* Debounced by the marker itself rather than by a counter: an unconsumed
|
|
5248
|
+
* request already asks for exactly the wake this call wants, so three workers
|
|
5249
|
+
* blocking in one pass write one request and the *first* question's number
|
|
5250
|
+
* survives as the reason. Best effort throughout — a marker that cannot be
|
|
5251
|
+
* written is logged, and the settlement it belongs to is never affected.
|
|
5252
|
+
*/
|
|
5253
|
+
export function wakeOrchestratorForBlockedRun(
|
|
5254
|
+
projectName: string,
|
|
5255
|
+
issue: number,
|
|
5256
|
+
writeLog: (line: string) => void = log,
|
|
5257
|
+
): void {
|
|
5258
|
+
const tickCwd = resolveTickConfigCwd(projectName);
|
|
5259
|
+
if (tickCwd === undefined) {
|
|
5260
|
+
writeLog(
|
|
5261
|
+
`#${issue} is blocked but no tick config cwd — the question waits for the next heartbeat`,
|
|
5262
|
+
);
|
|
5263
|
+
return;
|
|
5264
|
+
}
|
|
5265
|
+
const pending = readTickRequestReason(tickCwd);
|
|
5266
|
+
if (pending !== undefined) {
|
|
5267
|
+
writeLog(`#${issue} is blocked; a tick request is already pending (${pending})`);
|
|
5268
|
+
return;
|
|
5269
|
+
}
|
|
5270
|
+
const reason = `tier1-question #${issue}`;
|
|
5271
|
+
if (requestImmediateTick(tickCwd, reason)) {
|
|
5272
|
+
writeLog(`requested immediate tick at ${tickCwd}: ${reason}`);
|
|
5273
|
+
} else {
|
|
5274
|
+
writeLog(
|
|
5275
|
+
`could not write tick request under ${tickCwd}; #${issue}'s question waits for the next heartbeat`,
|
|
5276
|
+
);
|
|
5277
|
+
}
|
|
5278
|
+
}
|
|
5279
|
+
|
|
5063
5280
|
/**
|
|
5064
5281
|
* The first-tick verification for a fleet-initiated upgrade (#486).
|
|
5065
5282
|
*
|
|
@@ -5803,8 +6020,9 @@ export interface DaemonHealthSnapshot {
|
|
|
5803
6020
|
turnOverrides: TurnOverride[];
|
|
5804
6021
|
dispatch?: DispatchSummary;
|
|
5805
6022
|
codeGraph?: CodeGraphHealth;
|
|
5806
|
-
/** Live workers in a non-running pause phase; absent/empty = nothing paused.
|
|
5807
|
-
|
|
6023
|
+
/** Live workers in a non-running pause phase; absent/empty = nothing paused.
|
|
6024
|
+
* `source`/`pausedAtMs` carry the pause provenance when one was recorded (#997). */
|
|
6025
|
+
workers?: { issue: number; runId: string; phase: WorkerPausePhase; source?: string; pausedAtMs?: number }[];
|
|
5808
6026
|
/**
|
|
5809
6027
|
* The live orchestrator surface, attested by the running daemon (#832):
|
|
5810
6028
|
* which mode this project's fleet is in, and — when the daemon hosts the
|
|
@@ -5961,8 +6179,12 @@ export async function turnLimitResponse(
|
|
|
5961
6179
|
export async function workerControlResponse(
|
|
5962
6180
|
req: Request,
|
|
5963
6181
|
project: string,
|
|
5964
|
-
store: Pick<Store, "latestRun" | "updateRun">,
|
|
6182
|
+
store: Pick<Store, "latestRun" | "updateRun" | "recordMaterialEvent">,
|
|
5965
6183
|
registry: WorkerControlRegistry,
|
|
6184
|
+
/** Journal line for every accepted pause/resume — the live half of the
|
|
6185
|
+
* audit trail #997 asks for; the durable half is the material event. */
|
|
6186
|
+
log: (line: string) => void = () => {},
|
|
6187
|
+
now: () => number = Date.now,
|
|
5966
6188
|
): Promise<Response | undefined> {
|
|
5967
6189
|
const url = new URL(req.url);
|
|
5968
6190
|
const match = /^\/runs\/(\d+)\/(pause|resume|stop)$/.exec(url.pathname);
|
|
@@ -6002,16 +6224,44 @@ export async function workerControlResponse(
|
|
|
6002
6224
|
return Response.json({ error: "reason must be at most 500 characters" }, { status: 400 });
|
|
6003
6225
|
}
|
|
6004
6226
|
}
|
|
6227
|
+
// Who asked. The board, the CLI and the dashboard each name themselves so a
|
|
6228
|
+
// pause is attributable afterwards (#997); an older caller that sends no
|
|
6229
|
+
// source is recorded as such rather than guessed at.
|
|
6230
|
+
const rawSource = Reflect.get(body, "source");
|
|
6231
|
+
if (rawSource !== undefined && (typeof rawSource !== "string" || rawSource.trim() === "" || rawSource.trim().length > 40)) {
|
|
6232
|
+
return Response.json({ error: "source must be a non-empty string of at most 40 characters when present" }, { status: 400 });
|
|
6233
|
+
}
|
|
6234
|
+
const source = typeof rawSource === "string" ? rawSource.trim() : "unattributed";
|
|
6005
6235
|
|
|
6006
6236
|
|
|
6007
6237
|
const issue = Number(match[1]);
|
|
6008
6238
|
const outcome =
|
|
6009
6239
|
action === "pause"
|
|
6010
|
-
? await registry.pause(project, issue)
|
|
6240
|
+
? await registry.pause(project, issue, source)
|
|
6011
6241
|
: action === "resume"
|
|
6012
6242
|
? registry.resume(project, issue)
|
|
6013
6243
|
: await registry.stop(project, issue, reason!);
|
|
6014
6244
|
if (outcome.kind === "ok") {
|
|
6245
|
+
// The transition really happened — write both halves of the audit trail
|
|
6246
|
+
// before answering (#997): one journal line for the live log, one durable
|
|
6247
|
+
// material event the digest ledger keeps after the journal rotates. #986
|
|
6248
|
+
// was parked 14 minutes by an unlogged keypress and nobody could say why.
|
|
6249
|
+
const verb = action === "pause" ? "paused" : "resumed";
|
|
6250
|
+
log(`#${issue} worker ${verb} via ${source} (run ${outcome.runId})`);
|
|
6251
|
+
try {
|
|
6252
|
+
store.recordMaterialEvent({
|
|
6253
|
+
project,
|
|
6254
|
+
category: "worker-control",
|
|
6255
|
+
summary: `#${issue} worker ${verb} via ${source}`,
|
|
6256
|
+
evidence: `run ${outcome.runId}, phase ${outcome.phase}`,
|
|
6257
|
+
occurredAt: now(),
|
|
6258
|
+
recordedAt: now(),
|
|
6259
|
+
});
|
|
6260
|
+
} catch (err) {
|
|
6261
|
+
// The audit must never turn a successful transition into an error
|
|
6262
|
+
// answer, but a swallowed write would be a silent audit gap — log it.
|
|
6263
|
+
log(`#${issue} worker-control audit write failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
6264
|
+
}
|
|
6015
6265
|
return Response.json({ runId: outcome.runId, phase: outcome.phase });
|
|
6016
6266
|
}
|
|
6017
6267
|
if (outcome.kind === "refused") {
|
|
@@ -6089,8 +6339,10 @@ export async function workerControlResponse(
|
|
|
6089
6339
|
|
|
6090
6340
|
export interface DaemonHttpProjectDeps {
|
|
6091
6341
|
project: string;
|
|
6092
|
-
store: Pick<Store, "latestRun" | "setTurnOverride" | "updateRun">;
|
|
6342
|
+
store: Pick<Store, "latestRun" | "setTurnOverride" | "updateRun" | "recordMaterialEvent">;
|
|
6093
6343
|
caps: () => Pick<Caps, "workerMaxTurns" | "workerMaxTurnsCeiling">;
|
|
6344
|
+
/** Project-scoped journal line — the live half of the pause audit (#997). */
|
|
6345
|
+
log?: (line: string) => void;
|
|
6094
6346
|
}
|
|
6095
6347
|
|
|
6096
6348
|
export interface DaemonHttpDeps {
|
|
@@ -6163,6 +6415,7 @@ export async function daemonHttpResponse(req: Request, d: DaemonHttpDeps): Promi
|
|
|
6163
6415
|
selected.project,
|
|
6164
6416
|
selected.store,
|
|
6165
6417
|
d.workerControls,
|
|
6418
|
+
selected.log,
|
|
6166
6419
|
);
|
|
6167
6420
|
return workerControl ?? new Response("not found\n", { status: 404 });
|
|
6168
6421
|
}
|
|
@@ -6524,6 +6777,22 @@ export function statusSnapshotFromStore(
|
|
|
6524
6777
|
spendTelemetry: judgeSpendTelemetry(
|
|
6525
6778
|
store.recentSpendSamples?.(p.name, SPEND_SAMPLE_ROWS) ?? [],
|
|
6526
6779
|
SPEND_SAMPLE_RUNS,
|
|
6780
|
+
{
|
|
6781
|
+
// Config and an already-read status only — this snapshot probes nothing
|
|
6782
|
+
// at render time, which is the property the figure above depends on
|
|
6783
|
+
// (#970). The declaration alone is enough to know a dollar cap cannot
|
|
6784
|
+
// fire; the window name rides along when a configured plan cap has
|
|
6785
|
+
// already resolved one, and `doctor` (which may probe) reads it live.
|
|
6786
|
+
declaredSubscription: (p.requireOauthProviders ?? []).length > 0,
|
|
6787
|
+
...(planUsage?.window === undefined
|
|
6788
|
+
? {}
|
|
6789
|
+
: {
|
|
6790
|
+
allowanceWindow:
|
|
6791
|
+
planUsage.window.label === undefined
|
|
6792
|
+
? planUsage.window.id
|
|
6793
|
+
: `${planUsage.window.id} (${planUsage.window.label})`,
|
|
6794
|
+
}),
|
|
6795
|
+
},
|
|
6527
6796
|
),
|
|
6528
6797
|
// Read, never probed: the recorded row is the whole point (#919).
|
|
6529
6798
|
...(() => {
|
|
@@ -7461,6 +7730,10 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
7461
7730
|
project: runtime.d.project.name,
|
|
7462
7731
|
store,
|
|
7463
7732
|
caps: () => runtime.d.caps,
|
|
7733
|
+
// The same project prefix the runtime's own journal lines carry, so
|
|
7734
|
+
// a pause audit reads like every other daemon line (#997).
|
|
7735
|
+
log: (line: string) =>
|
|
7736
|
+
log(runtimes.length === 1 ? line : `[${runtime.d.project.name}] ${line}`),
|
|
7464
7737
|
})),
|
|
7465
7738
|
turnLimits,
|
|
7466
7739
|
workerControls,
|
|
@@ -189,7 +189,7 @@ export async function dashboardWorkerControl(
|
|
|
189
189
|
body: unknown,
|
|
190
190
|
d: ControlDeps,
|
|
191
191
|
): Promise<ControlOutcome> {
|
|
192
|
-
const payload: Record<string, unknown> = { project: d.project.name };
|
|
192
|
+
const payload: Record<string, unknown> = { project: d.project.name, source: "dashboard" };
|
|
193
193
|
if (action === "stop") {
|
|
194
194
|
const reason = readReason(body);
|
|
195
195
|
if (reason === undefined) {
|