omp-conductor 0.15.11 → 0.15.13
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/REFERENCE.md +107 -60
- package/package.json +1 -1
- package/schema/config.schema.json +3 -0
- package/src/briefs/orchestrator.md +64 -11
- package/src/briefs/policy.md +19 -3
- package/src/briefs/worker.md +11 -8
- package/src/cli.ts +41 -21
- package/src/commands/context.ts +102 -1
- package/src/commands/doctor.ts +4 -2
- package/src/commands/intake.ts +26 -5
- package/src/commands/message.ts +80 -32
- package/src/commands/report.ts +38 -2
- package/src/commands/restart.ts +81 -54
- package/src/commands/setup.ts +61 -11
- package/src/commands/stop.ts +45 -22
- package/src/commands/upgrade-rollback.ts +9 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +35 -1
- package/src/daemon.ts +588 -37
- package/src/dashboard/app.js +398 -59
- package/src/dashboard/index.html +27 -0
- package/src/dashboard/server.ts +219 -5
- package/src/dashboard/style.css +169 -1
- package/src/doctor.ts +419 -45
- package/src/escalate.ts +8 -0
- package/src/failure-class.ts +37 -0
- package/src/fleet.ts +49 -2
- package/src/gitops.ts +157 -0
- package/src/lifecycle.ts +113 -2
- package/src/model-fallback.ts +177 -0
- package/src/omp.ts +115 -13
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +108 -5
- package/src/orchestrator.ts +18 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +373 -28
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +665 -70
- package/src/setup-install.ts +275 -28
- package/src/setup-wizard.ts +339 -126
- package/src/setup.ts +25 -0
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +194 -1
- package/src/tracker/github.ts +47 -0
- package/src/types.ts +182 -0
- package/src/upgrade.ts +110 -32
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +27 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +24 -3
- package/systemd/omp-conductor.service.example +7 -3
package/src/daemon.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* here and enforced before anything is claimed.
|
|
9
9
|
*/
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
|
-
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { dirname, join, relative } from "node:path";
|
|
13
13
|
import {
|
|
14
14
|
configPath,
|
|
@@ -39,6 +39,10 @@ import { runCommand, verifyPendingUpgrade, type UpgradeVerifyDeps } from "./upgr
|
|
|
39
39
|
import { fleetLayers } from "./fleet.ts";
|
|
40
40
|
import { startOrchestrator } from "./orchestrator.ts";
|
|
41
41
|
import type { OrchestratorHandle } from "./orchestrator.ts";
|
|
42
|
+
import {
|
|
43
|
+
formatOrchestratorDown,
|
|
44
|
+
reconcileOrchestratorDown,
|
|
45
|
+
} from "./orchestrator-down.ts";
|
|
42
46
|
import {
|
|
43
47
|
createReportOutbox,
|
|
44
48
|
enqueueAvailableHeldNotices,
|
|
@@ -47,12 +51,21 @@ import {
|
|
|
47
51
|
} from "./reports.ts";
|
|
48
52
|
import {
|
|
49
53
|
recordReleaseBlock,
|
|
54
|
+
type GateShape,
|
|
50
55
|
type ReleaseBlockContext,
|
|
51
56
|
} from "./release-policy.ts";
|
|
52
57
|
import { branchName, effectiveLabels, route } from "./routing.ts";
|
|
53
58
|
import type { Routed, UnroutableReason } from "./routing.ts";
|
|
54
59
|
import { evaluateDecisionConditions, probeNpmVersion, probeRateLimitReset } from "./decisions.ts";
|
|
55
60
|
import { classifyRun, providerCreditRefusal, providerTransientFault, type ClassifyFacts } from "./failure-class.ts";
|
|
61
|
+
import {
|
|
62
|
+
DEFAULT_MODEL_FALLBACK_THRESHOLD,
|
|
63
|
+
fallbackClause,
|
|
64
|
+
formatModelsTried,
|
|
65
|
+
modelsTried,
|
|
66
|
+
providerFailureFacts,
|
|
67
|
+
resolveDispatchModel,
|
|
68
|
+
} from "./model-fallback.ts";
|
|
56
69
|
import { projectLabels } from "./label-projection.ts";
|
|
57
70
|
import { dbPath, LIVE_STATES, openStore, utcDay } from "./store.ts";
|
|
58
71
|
import { makeTracker, type RateLimitStatus } from "./tracker/github.ts";
|
|
@@ -64,10 +77,12 @@ import type {
|
|
|
64
77
|
DispatchSummary,
|
|
65
78
|
DigestBacklog,
|
|
66
79
|
Escalation,
|
|
80
|
+
IssueComment,
|
|
67
81
|
IssueSnapshot,
|
|
68
82
|
MergedPrInfo,
|
|
69
83
|
OpenCloser,
|
|
70
84
|
ReleaseShape,
|
|
85
|
+
OrchestratorIncident,
|
|
71
86
|
PrState,
|
|
72
87
|
ProjectConfig,
|
|
73
88
|
ReadyIssue,
|
|
@@ -90,6 +105,7 @@ import {
|
|
|
90
105
|
type WorkerPausePhase,
|
|
91
106
|
type WorkerResult,
|
|
92
107
|
type RunWorkerDeps,
|
|
108
|
+
ORPHAN_RESUME_PROMPT,
|
|
93
109
|
renderBrief,
|
|
94
110
|
runWorker,
|
|
95
111
|
} from "./worker.ts";
|
|
@@ -122,7 +138,14 @@ import {
|
|
|
122
138
|
} from "./verbs/socket.ts";
|
|
123
139
|
import { homedir } from "node:os";
|
|
124
140
|
|
|
125
|
-
import {
|
|
141
|
+
import {
|
|
142
|
+
probeCriticalBase,
|
|
143
|
+
pushRunBranch,
|
|
144
|
+
readBaseChain,
|
|
145
|
+
type CriticalBaseProbe,
|
|
146
|
+
type CriticalBaseVerdict,
|
|
147
|
+
type RunRepoRef,
|
|
148
|
+
} from "./gitops.ts";
|
|
126
149
|
import {
|
|
127
150
|
planUsageLine,
|
|
128
151
|
readPlanUsage,
|
|
@@ -210,6 +233,13 @@ interface Deps {
|
|
|
210
233
|
workerDeps?: RunWorkerDeps;
|
|
211
234
|
integrity: IntegrityGate;
|
|
212
235
|
stall: StallGate;
|
|
236
|
+
/**
|
|
237
|
+
* The embedded orchestrator session handle when one started; absent when it
|
|
238
|
+
* failed to start or the project uses an external orchestrator. Feeds the
|
|
239
|
+
* orchestrator-down reconcile ({@link reconcileOrchestratorDown}) so a
|
|
240
|
+
* crashed session pages once per incident instead of degrading quietly.
|
|
241
|
+
*/
|
|
242
|
+
orchestrator?: OrchestratorHandle;
|
|
213
243
|
cleanup?: RetainedCleanupCursor;
|
|
214
244
|
/**
|
|
215
245
|
* Reads the connecting uid off a verb socket (#126). Resolved once at startup
|
|
@@ -233,6 +263,14 @@ interface Deps {
|
|
|
233
263
|
* without touching the host's packages, journal or outbox.
|
|
234
264
|
*/
|
|
235
265
|
upgradeVerifier?: (d: Deps) => Promise<void>;
|
|
266
|
+
/**
|
|
267
|
+
* Answers whether a preserved continuation branch contains every configured
|
|
268
|
+
* critical-base marker, for the stale-base admission hold (#428). Wired by
|
|
269
|
+
* `runDaemon` to the mirror-backed {@link probeCriticalBase}; a test injects
|
|
270
|
+
* a fake. Absent, the stale-base gate fails closed whenever a project names
|
|
271
|
+
* a marker (a safety interlock must not silently weaken).
|
|
272
|
+
*/
|
|
273
|
+
probeCriticalBase?: CriticalBaseProbe;
|
|
236
274
|
}
|
|
237
275
|
|
|
238
276
|
/**
|
|
@@ -506,6 +544,19 @@ export function pauseInstance(
|
|
|
506
544
|
}
|
|
507
545
|
}
|
|
508
546
|
|
|
547
|
+
/**
|
|
548
|
+
* The pause sentinel's `source=` token, proven from a verb. The sentinel's
|
|
549
|
+
* source line is read back as a single `\S+` token (see {@link pauseInstance}
|
|
550
|
+
* and {@link pauseProvenance}), so a verb that contains a space (`setup host`)
|
|
551
|
+
* is unrepresentable verbatim and must be encoded before it reaches disk —
|
|
552
|
+
* otherwise the fence cannot prove its own pause and refuses forever (#552).
|
|
553
|
+
* Spaces become `-`; the human-readable verb is preserved in the sentinel's
|
|
554
|
+
* `reason=` instead.
|
|
555
|
+
*/
|
|
556
|
+
export function pauseSourceToken(verb: string): string {
|
|
557
|
+
return verb.trim().replace(/\s+/g, "-");
|
|
558
|
+
}
|
|
559
|
+
|
|
509
560
|
export function setPaused(
|
|
510
561
|
v: boolean,
|
|
511
562
|
why?: { source: string; reason?: string },
|
|
@@ -682,6 +733,64 @@ function acceptanceCriteria(issue: ReadyIssue): string {
|
|
|
682
733
|
: "_The issue body is empty. Read the issue and its comments, and escalate if it is genuinely underspecified._";
|
|
683
734
|
}
|
|
684
735
|
|
|
736
|
+
/**
|
|
737
|
+
* How much of a comment thread one brief may hold. Grooming notes are short,
|
|
738
|
+
* and a thread that outgrows this is truncated with an explicit marker rather
|
|
739
|
+
* than a silently dropped tail — the disappearance this fix exists to prevent.
|
|
740
|
+
*/
|
|
741
|
+
const DISCUSSION_CHARS_BUDGET = 8_000;
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* The issue's comments as a brief section: attributed, numbered, oldest
|
|
745
|
+
* first, so a later correction visibly supersedes an earlier note. Rendered
|
|
746
|
+
* beside the body at dispatch, because the orchestrator's grooming is posted
|
|
747
|
+
* as comments and a worker must never depend on a runtime read to see it.
|
|
748
|
+
*
|
|
749
|
+
* `"unread"` is not "empty": when the tracker refused at dispatch the section
|
|
750
|
+
* says so, instead of silently reading as "no discussion" — which is exactly
|
|
751
|
+
* the #517 failure. An empty comment list renders nothing, so a commentless
|
|
752
|
+
* issue's brief stays byte-identical to what this package has always shipped.
|
|
753
|
+
*/
|
|
754
|
+
function renderDiscussion(comments: IssueComment[] | "unread"): string {
|
|
755
|
+
if (comments === "unread") {
|
|
756
|
+
return [
|
|
757
|
+
"## Discussion",
|
|
758
|
+
"",
|
|
759
|
+
"_The issue's comments could not be read at dispatch time. The live read below is_",
|
|
760
|
+
"_the only path to them; if it prints nothing, that is a failed read, not an_",
|
|
761
|
+
"_absence of discussion._",
|
|
762
|
+
"",
|
|
763
|
+
].join("\n");
|
|
764
|
+
}
|
|
765
|
+
if (comments.length === 0) return "";
|
|
766
|
+
const total = comments.length;
|
|
767
|
+
const lines = [
|
|
768
|
+
"## Discussion",
|
|
769
|
+
"",
|
|
770
|
+
`${total} comment${total === 1 ? "" : "s"} on the issue at dispatch, oldest first — a later`,
|
|
771
|
+
"comment supersedes an earlier one.",
|
|
772
|
+
"",
|
|
773
|
+
];
|
|
774
|
+
let chars = lines.join("\n").length;
|
|
775
|
+
let shown = 0;
|
|
776
|
+
for (const comment of comments) {
|
|
777
|
+
const block = `**@${comment.author} — comment ${shown + 1}:**\n\n${comment.body}\n\n`;
|
|
778
|
+
if (shown > 0 && chars + block.length > DISCUSSION_CHARS_BUDGET) break;
|
|
779
|
+
lines.push(`**@${comment.author} — comment ${shown + 1}:**`, "", comment.body, "");
|
|
780
|
+
chars += block.length;
|
|
781
|
+
shown += 1;
|
|
782
|
+
}
|
|
783
|
+
if (shown < total) {
|
|
784
|
+
const omitted = total - shown;
|
|
785
|
+
lines.push(
|
|
786
|
+
`… ${omitted} comment${omitted === 1 ? "" : "s"} omitted — the discussion exceeded ${DISCUSSION_CHARS_BUDGET} characters.`,
|
|
787
|
+
"Read the issue for the tail.",
|
|
788
|
+
"",
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
return lines.join("\n");
|
|
792
|
+
}
|
|
793
|
+
|
|
685
794
|
/**
|
|
686
795
|
* Record a state-label swap for projection (#201). The add enqueues before the
|
|
687
796
|
* remove — the reverse order would leave a window where the issue carries no
|
|
@@ -934,7 +1043,15 @@ export async function buildBrief(
|
|
|
934
1043
|
r: Routed,
|
|
935
1044
|
branch: string,
|
|
936
1045
|
worktree: string,
|
|
937
|
-
opts: {
|
|
1046
|
+
opts: {
|
|
1047
|
+
continuation?: boolean;
|
|
1048
|
+
defaultBranch?: string;
|
|
1049
|
+
salvagedSha?: string;
|
|
1050
|
+
/** The issue's comments, rendered into the Discussion section. `"unread"`
|
|
1051
|
+
* when the tracker refused at dispatch — rare, but it must never read as
|
|
1052
|
+
* "no comments" (the #517 failure mode). Absent means an empty list. */
|
|
1053
|
+
comments?: IssueComment[] | "unread";
|
|
1054
|
+
} = {},
|
|
938
1055
|
): Promise<string> {
|
|
939
1056
|
// Read per dispatch rather than caching: editing the brief then takes effect
|
|
940
1057
|
// on the next issue instead of needing a daemon restart.
|
|
@@ -979,6 +1096,7 @@ export async function buildBrief(
|
|
|
979
1096
|
BRANCH: branch,
|
|
980
1097
|
WORKTREE: worktree,
|
|
981
1098
|
ACCEPTANCE_CRITERIA: acceptanceCriteria(r.issue),
|
|
1099
|
+
ISSUE_COMMENTS: renderDiscussion(opts.comments ?? []),
|
|
982
1100
|
GATES: gatesBlock(r.repo),
|
|
983
1101
|
// Empty for a repo with no `graphProject`, and empty means *nothing*: the
|
|
984
1102
|
// placeholder sits flush against the next list item in the template, so an
|
|
@@ -1045,6 +1163,135 @@ function newestContinuableRun(runs: readonly RunRecord[]): RunRecord | undefined
|
|
|
1045
1163
|
);
|
|
1046
1164
|
}
|
|
1047
1165
|
|
|
1166
|
+
/** The fenced-block info string that marks an exhaustion postmortem comment, so
|
|
1167
|
+
* a grooming scout re-slicing the issue can find and parse the whole block by
|
|
1168
|
+
* grepping for it. */
|
|
1169
|
+
export const POSTMORTEM_MARKER = "conductor-postmortem";
|
|
1170
|
+
|
|
1171
|
+
/** How one issue's attempt chain failed, as "3× ci-deterministic, 1× …" — the
|
|
1172
|
+
* digest shape for naming what the exhaustion was. Groups every row by its
|
|
1173
|
+
* failure class, whether or not it charged the continuation budget, because
|
|
1174
|
+
* the postmortem tells the whole story and not just the budget half (#290). */
|
|
1175
|
+
export function attemptClassBreakdown(runs: readonly RunRecord[]): string {
|
|
1176
|
+
const perClass = new Map<string, number>();
|
|
1177
|
+
for (const r of runs) {
|
|
1178
|
+
const cls = r.failureClass ?? "unclassified";
|
|
1179
|
+
perClass.set(cls, (perClass.get(cls) ?? 0) + 1);
|
|
1180
|
+
}
|
|
1181
|
+
if (perClass.size === 0) return "unclassified";
|
|
1182
|
+
return Array.from(perClass, ([cls, n]) => `${n}× ${cls}`).join(", ");
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** Wall-clock duration of one run as a compact human string ("45m", "1h30m"). */
|
|
1186
|
+
export function humanDuration(ms: number): string {
|
|
1187
|
+
const seconds = Math.max(0, Math.round(ms / 1_000));
|
|
1188
|
+
if (seconds < 60) return `${seconds}s`;
|
|
1189
|
+
const minutes = Math.round(seconds / 60);
|
|
1190
|
+
if (minutes < 60) return `${minutes}m`;
|
|
1191
|
+
const hours = Math.floor(minutes / 60);
|
|
1192
|
+
const rest = minutes % 60;
|
|
1193
|
+
return rest === 0 ? `${hours}h` : `${hours}h${rest}m`;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/** Flatten and bound a run's last error to one greppable table line. */
|
|
1197
|
+
export function oneLineBrief(text: string | undefined): string | undefined {
|
|
1198
|
+
if (text === undefined || text.trim() === "") return undefined;
|
|
1199
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
1200
|
+
return flat.length > 90 ? `${flat.slice(0, 89)}…` : flat;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* The exhaustion postmortem block: one greppable fenced block covering every
|
|
1205
|
+
* attempt in the chain — continuation rows included — with per-attempt turns,
|
|
1206
|
+
* wall clock, failure class and a one-line last error, the explicit salvage
|
|
1207
|
+
* state, the spend total and the transcript paths for local inspection.
|
|
1208
|
+
*
|
|
1209
|
+
* Pure so the tests hold the shape, not the transport: the writer below owns
|
|
1210
|
+
* the once-only guarantee, this owns what "once" looks like.
|
|
1211
|
+
*/
|
|
1212
|
+
export function formatExhaustionPostmortem(args: {
|
|
1213
|
+
issue: number;
|
|
1214
|
+
runs: readonly RunRecord[];
|
|
1215
|
+
reason: string;
|
|
1216
|
+
}): string {
|
|
1217
|
+
const { issue, runs, reason } = args;
|
|
1218
|
+
const artifact = newestContinuableRun(runs);
|
|
1219
|
+
const totalSpend = runs.reduce((sum, r) => sum + r.spendUsd, 0);
|
|
1220
|
+
const spend = `$${totalSpend.toFixed(2)}`;
|
|
1221
|
+
const attemptLines = runs.map((r) => {
|
|
1222
|
+
const wall = r.endedAt === undefined ? "—" : humanDuration(r.endedAt - r.startedAt);
|
|
1223
|
+
const cls = r.failureClass ?? "unclassified";
|
|
1224
|
+
const error = oneLineBrief(r.lastError) ?? "—";
|
|
1225
|
+
return (
|
|
1226
|
+
` attempt ${r.attempt} ${r.state.padEnd(12)} turns ${r.turns}/${r.maxTurns} ` +
|
|
1227
|
+
`${cls.padEnd(24)} ${wall.padStart(4)} last error: ${error}`
|
|
1228
|
+
);
|
|
1229
|
+
});
|
|
1230
|
+
const salvage =
|
|
1231
|
+
artifact === undefined
|
|
1232
|
+
? "Salvaged WIP: absent — no attempt preserved a branch, head SHA or pull request."
|
|
1233
|
+
: `Salvaged WIP: present — branch ${artifact.branch} at ${artifact.headSha ?? artifact.salvageSha}` +
|
|
1234
|
+
`${artifact.prUrl === undefined ? "" : ` (PR ${artifact.prUrl})`}.`;
|
|
1235
|
+
return [
|
|
1236
|
+
`\`\`\`${POSTMORTEM_MARKER}`,
|
|
1237
|
+
`#${issue} exhausted: ${attemptClassBreakdown(runs)}`,
|
|
1238
|
+
reason,
|
|
1239
|
+
"",
|
|
1240
|
+
`Attempts (${runs.length} total, ${spend} spend):`,
|
|
1241
|
+
...attemptLines,
|
|
1242
|
+
salvage,
|
|
1243
|
+
`Spend total: ${spend} across ${runs.length} attempts.`,
|
|
1244
|
+
"Transcripts:",
|
|
1245
|
+
...runs.map((r) => (r.sessionFile === undefined ? " (none)" : ` ${r.sessionFile}`)),
|
|
1246
|
+
"```",
|
|
1247
|
+
].join("\n");
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** Dedupe key prefix for the exhaustion postmortem comment, per issue. */
|
|
1251
|
+
function postmortemDedupeKey(project: string, issue: number): string {
|
|
1252
|
+
return `${project}:postmortem:${issue}`;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* The exhaustion postmortem: written exactly once per issue, at the point the
|
|
1257
|
+
* continuation budget is spent and the issue is settled toward a human.
|
|
1258
|
+
*
|
|
1259
|
+
* The comment and the material event are both gated by the store's notification
|
|
1260
|
+
* ledger — the same idempotence guard the escalator uses — so re-settling an
|
|
1261
|
+
* already-postmortemed issue posts nothing and records nothing. A body-string
|
|
1262
|
+
* match on the issue would be the wrong guard: an issue re-scoped and re-run
|
|
1263
|
+
* would still carry the old block, and the guarantee asked of this is "decided
|
|
1264
|
+
* once", not "deduped against what is already written".
|
|
1265
|
+
*
|
|
1266
|
+
* The digest must name the exhaustion even if the comment write fails, so the
|
|
1267
|
+
* material event is recorded before the write and unconditionally (the ledger
|
|
1268
|
+
* is append-only, and the gate above already ran once). The comment failure is
|
|
1269
|
+
* logged rather than taking the sweep down with it.
|
|
1270
|
+
*/
|
|
1271
|
+
async function postExhaustionPostmortem(d: Deps, run: RunRecord, reason: string): Promise<void> {
|
|
1272
|
+
const { project, tracker, store } = d;
|
|
1273
|
+
const key = postmortemDedupeKey(project.name, run.issue);
|
|
1274
|
+
if (store.wasNotified(key)) return;
|
|
1275
|
+
const runs = store.runsForIssue(project.name, run.issue);
|
|
1276
|
+
const body = formatExhaustionPostmortem({ issue: run.issue, runs, reason });
|
|
1277
|
+
const occurredAt = Date.now();
|
|
1278
|
+
store.recordMaterialEvent({
|
|
1279
|
+
project: project.name,
|
|
1280
|
+
category: "exhaustion",
|
|
1281
|
+
summary: `#${run.issue} exhausted: ${attemptClassBreakdown(runs)}`,
|
|
1282
|
+
evidence: body,
|
|
1283
|
+
occurredAt,
|
|
1284
|
+
recordedAt: occurredAt,
|
|
1285
|
+
});
|
|
1286
|
+
try {
|
|
1287
|
+
await tracker.comment(run.issue, body);
|
|
1288
|
+
store.markNotified(key);
|
|
1289
|
+
log(`#${run.issue} posted exhaustion postmortem (${runs.length} attempts)`);
|
|
1290
|
+
} catch (err) {
|
|
1291
|
+
log(`#${run.issue} postmortem comment could not be posted (${errText(err)})`);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1048
1295
|
export type ExtendTurnLimitResult =
|
|
1049
1296
|
| { kind: "extended"; runId: string; maxTurns: number }
|
|
1050
1297
|
| { kind: "not-increase"; runId: string; maxTurns: number }
|
|
@@ -1329,7 +1576,7 @@ export function workerReleaseBlockRecorder(
|
|
|
1329
1576
|
issue: number,
|
|
1330
1577
|
runId: string,
|
|
1331
1578
|
root = stateDir(),
|
|
1332
|
-
): (shape:
|
|
1579
|
+
): (shape: GateShape, context: ReleaseBlockContext) => void {
|
|
1333
1580
|
return (shape, context) =>
|
|
1334
1581
|
recordReleaseBlock(
|
|
1335
1582
|
project,
|
|
@@ -1386,6 +1633,50 @@ export async function inheritedPrForContinuation(
|
|
|
1386
1633
|
return { prUrl: prior.prUrl, ...(prior.headSha === undefined ? {} : { headSha: prior.headSha }) };
|
|
1387
1634
|
}
|
|
1388
1635
|
|
|
1636
|
+
/**
|
|
1637
|
+
* #536: whether an orphan-clean requeue may resume the interrupted session
|
|
1638
|
+
* instead of dispatching fresh.
|
|
1639
|
+
*
|
|
1640
|
+
* `reconcileOrphanedRuns` keeps the worktree (salvage commit included) and the
|
|
1641
|
+
* transcript is file-backed, so a daemon restart can hand the next attempt
|
|
1642
|
+
* back its own memory: the same worktree, the same session directory, and
|
|
1643
|
+
* `resume: true` at the harness. The old dispatch built a fresh
|
|
1644
|
+
* `run-<uuid>` session and re-read the repo from zero — the exact rediscovery
|
|
1645
|
+
* orphan-clean spent turns on in #535.
|
|
1646
|
+
*
|
|
1647
|
+
* Only `orphan-clean` resumes. A cap-killed or otherwise failed worker was
|
|
1648
|
+
* killed for cause, and orphan-dirty is held precisely because the worktree is
|
|
1649
|
+
* the only copy. The checks here are what the daemon can prove cheaply before
|
|
1650
|
+
* the claim (the transcript is present and non-empty, the worktree is
|
|
1651
|
+
* present); the harness's own `continueRecent` is the backstop, and a corrupt
|
|
1652
|
+
* transcript it silently falls back from is surfaced loudly by the
|
|
1653
|
+
* `sessionFile` lineage compare at the dispatch site rather than left quiet.
|
|
1654
|
+
*/
|
|
1655
|
+
function orphanResumeVerdict(
|
|
1656
|
+
prior: RunRecord | undefined,
|
|
1657
|
+
): { kind: "resume"; prior: RunRecord } | { kind: "fresh"; reason?: string } {
|
|
1658
|
+
if (prior === undefined || prior.state !== "orphaned" || prior.failureClass !== "orphan-clean") {
|
|
1659
|
+
return { kind: "fresh" };
|
|
1660
|
+
}
|
|
1661
|
+
if (prior.sessionFile === undefined) {
|
|
1662
|
+
return { kind: "fresh", reason: "the orphaned attempt recorded no transcript" };
|
|
1663
|
+
}
|
|
1664
|
+
try {
|
|
1665
|
+
if (!existsSync(prior.sessionFile)) {
|
|
1666
|
+
return { kind: "fresh", reason: `transcript ${prior.sessionFile} is gone` };
|
|
1667
|
+
}
|
|
1668
|
+
if (statSync(prior.sessionFile).size === 0) {
|
|
1669
|
+
return { kind: "fresh", reason: `transcript ${prior.sessionFile} is empty` };
|
|
1670
|
+
}
|
|
1671
|
+
} catch (err) {
|
|
1672
|
+
return { kind: "fresh", reason: `transcript ${prior.sessionFile} is unreadable (${errText(err)})` };
|
|
1673
|
+
}
|
|
1674
|
+
if (prior.worktree === "" || !existsSync(prior.worktree)) {
|
|
1675
|
+
return { kind: "fresh", reason: `worktree ${prior.worktree} is gone` };
|
|
1676
|
+
}
|
|
1677
|
+
return { kind: "resume", prior };
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1389
1680
|
export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
1390
1681
|
const { project, caps, tracker, store } = d;
|
|
1391
1682
|
const issue = r.issue.number;
|
|
@@ -1544,6 +1835,54 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
1544
1835
|
// attempt whose work this one inherits.
|
|
1545
1836
|
const prior = store.latestRun(project.name, issue);
|
|
1546
1837
|
const priorSalvage = prior?.salvageSha;
|
|
1838
|
+
// The failover decision is made from the store the same way admission is:
|
|
1839
|
+
// counts of this issue's own rows, so the switch is sticky per chain and a
|
|
1840
|
+
// fresh issue always starts on the primary (#286). Read before the claim
|
|
1841
|
+
// writes this attempt's row, which would otherwise break the streak.
|
|
1842
|
+
const chainFacts = providerFailureFacts(store.runsForIssue(project.name, issue));
|
|
1843
|
+
// Resolved before the claim, not after provisioning: the model is also
|
|
1844
|
+
// part of the #536 resume decision, which has to be made before the
|
|
1845
|
+
// dispatch shape (fresh provision vs kept worktree) is chosen. With no
|
|
1846
|
+
// `modelFallbacks` configured this is the primary model — or none, for an
|
|
1847
|
+
// unconfigured project — and today's dispatch is byte for byte what it
|
|
1848
|
+
// has always been.
|
|
1849
|
+
const chainConfigured = (project.modelFallbacks?.length ?? 0) > 0;
|
|
1850
|
+
const choice = resolveDispatchModel({
|
|
1851
|
+
workerModel: project.workerModel,
|
|
1852
|
+
modelFallbacks: project.modelFallbacks,
|
|
1853
|
+
threshold: project.modelFallbackThreshold ?? DEFAULT_MODEL_FALLBACK_THRESHOLD,
|
|
1854
|
+
streak: chainFacts.streak,
|
|
1855
|
+
});
|
|
1856
|
+
const clause = fallbackClause(choice, chainFacts, project.workerModel);
|
|
1857
|
+
|
|
1858
|
+
// #536: an orphan-clean requeue resumes the interrupted session instead of
|
|
1859
|
+
// re-reading the repo from zero. Decided here, before this attempt's row
|
|
1860
|
+
// exists, because `prior` is still the attempt whose work this one
|
|
1861
|
+
// inherits and the whole dispatch shape follows the verdict.
|
|
1862
|
+
let resuming: RunRecord | undefined;
|
|
1863
|
+
const verdict = orphanResumeVerdict(prior);
|
|
1864
|
+
if (verdict.kind === "resume") {
|
|
1865
|
+
const resumePrior = verdict.prior;
|
|
1866
|
+
// The continuation must stay on the model the interrupted session was
|
|
1867
|
+
// using; a chain that now resolves differently dispatches fresh rather
|
|
1868
|
+
// than quietly continuing on another model, which would smear one
|
|
1869
|
+
// attempt's work across two models (#286 attribution).
|
|
1870
|
+
if (resumePrior.model !== undefined && choice.model !== undefined && resumePrior.model !== choice.model) {
|
|
1871
|
+
log(
|
|
1872
|
+
`#${issue} attempt ${attempt} not resumed: attempt ${resumePrior.attempt} ran on ${resumePrior.model} but dispatch ` +
|
|
1873
|
+
`now resolves ${choice.model} — fresh dispatch`,
|
|
1874
|
+
);
|
|
1875
|
+
} else {
|
|
1876
|
+
resuming = resumePrior;
|
|
1877
|
+
log(
|
|
1878
|
+
`#${issue} attempt ${attempt} continuing session of attempt ${resuming.attempt} → ` +
|
|
1879
|
+
`transcript ${resuming.sessionFile}, worktree kept`,
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
} else if (verdict.reason !== undefined) {
|
|
1883
|
+
log(`#${issue} attempt ${attempt} not resumed: ${verdict.reason} — fresh dispatch`);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1547
1886
|
run = store.createRun({
|
|
1548
1887
|
project: project.name,
|
|
1549
1888
|
issue,
|
|
@@ -1596,22 +1935,30 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
1596
1935
|
|
|
1597
1936
|
// A run's tree is <workspaceRoot>/<issue> and addRunRepo refuses to reuse
|
|
1598
1937
|
// an existing path, so a retry — or a tree kept from a failed attempt — has
|
|
1599
|
-
// to be cleared first. Both helpers are pure path math and removeWorktree
|
|
1600
|
-
// tolerates a mirror or tree that is not there yet
|
|
1601
|
-
//
|
|
1602
|
-
//
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
if (
|
|
1606
|
-
|
|
1607
|
-
r.repo,
|
|
1608
|
-
|
|
1609
|
-
project.workspaceRoot,
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1938
|
+
// to be cleared first. Both helpers are pure path math, and removeWorktree
|
|
1939
|
+
// tolerates a mirror or tree that is not there yet. An attempted resume
|
|
1940
|
+
// skips the whole dance: the orphaned run's tree is the work to continue
|
|
1941
|
+
// (its salvage commit is already on the branch), and re-cloning it from
|
|
1942
|
+
// the mirror would be exactly the rediscovery this feature exists to skip.
|
|
1943
|
+
let provisioned: Awaited<ReturnType<typeof addRunRepo>> | undefined;
|
|
1944
|
+
if (resuming !== undefined) {
|
|
1945
|
+
worktreePath = resuming.worktree;
|
|
1946
|
+
runRepo = { repo: r.repo, runRepoPath: worktreePath, branch };
|
|
1947
|
+
} else {
|
|
1948
|
+
await removeWorktree(mirrorPath, worktreePathFor(project.workspaceRoot, issue));
|
|
1949
|
+
if (await settleStopBeforeSession()) return;
|
|
1950
|
+
if (await settleDrainBeforeSession()) return;
|
|
1951
|
+
const provisionedTree = await addRunRepo(
|
|
1952
|
+
r.repo,
|
|
1953
|
+
project.mirrorRoot,
|
|
1954
|
+
project.workspaceRoot,
|
|
1955
|
+
issue,
|
|
1956
|
+
branch,
|
|
1957
|
+
);
|
|
1958
|
+
provisioned = provisionedTree;
|
|
1959
|
+
worktreePath = provisionedTree.path;
|
|
1960
|
+
runRepo = { repo: r.repo, runRepoPath: worktreePath, branch };
|
|
1961
|
+
}
|
|
1615
1962
|
if (await settleStopBeforeSession()) return;
|
|
1616
1963
|
if (await settleDrainBeforeSession()) return;
|
|
1617
1964
|
|
|
@@ -1620,9 +1967,15 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
1620
1967
|
// here would put a file that never gets written into an escalation.
|
|
1621
1968
|
//
|
|
1622
1969
|
// Per run rather than one shared directory, so one run's transcript cannot
|
|
1623
|
-
// be truncated or replaced by the next
|
|
1970
|
+
// be truncated or replaced by the next — except for a resumed attempt,
|
|
1971
|
+
// which deliberately reuses the interrupted session's directory so the
|
|
1972
|
+
// SDK's `continueRecent(cwd, dir)` picks up that transcript and keeps
|
|
1973
|
+
// writing it.
|
|
1624
1974
|
const runTreeRoot = stateDir();
|
|
1625
|
-
const sessionDir =
|
|
1975
|
+
const sessionDir =
|
|
1976
|
+
resuming === undefined
|
|
1977
|
+
? join(runTreeRoot, "sessions", `run-${String(runId)}`)
|
|
1978
|
+
: dirname(resuming.sessionFile!);
|
|
1626
1979
|
mkdirSync(sessionDir, { recursive: true });
|
|
1627
1980
|
|
|
1628
1981
|
// ---- the run's mutation channel (#126) -------------------------------
|
|
@@ -1657,18 +2010,52 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
1657
2010
|
|
|
1658
2011
|
store.updateRun(runId, { worktree: worktreePath, state: "running" });
|
|
1659
2012
|
|
|
2013
|
+
// Where this attempt goes, and (when the failover fired) the clause that
|
|
2014
|
+
// makes it attributable. `chainConfigured`/`choice`/`clause` were resolved
|
|
2015
|
+
// before the claim — the resume verdict had to be made before the dispatch
|
|
2016
|
+
// shape was chosen (#536) — so only the record write lives here.
|
|
2017
|
+
// Recorded before the launch, so even a run killed mid-flight leaves the
|
|
2018
|
+
// model it chose on its row. Only a chain-configured project writes the
|
|
2019
|
+
// column: absent `modelFallbacks` must preserve today's rows byte for byte.
|
|
2020
|
+
if (chainConfigured && choice.model !== undefined) {
|
|
2021
|
+
store.updateRun(runId, { model: choice.model });
|
|
2022
|
+
}
|
|
2023
|
+
|
|
1660
2024
|
log(
|
|
1661
|
-
`#${issue} attempt ${attempt} → ${r.repo.name} ${branch}` +
|
|
1662
|
-
(provisioned
|
|
2025
|
+
`#${issue} attempt ${attempt}${clause === undefined ? "" : ` ${clause}`} → ${r.repo.name} ${branch}` +
|
|
2026
|
+
(provisioned?.reattached ? " (continuation: reattached existing branch)" : ""),
|
|
1663
2027
|
);
|
|
1664
2028
|
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
2029
|
+
// The continuation notice replaces the brief for a resumed attempt (#536).
|
|
2030
|
+
// The original brief is already in the resumed transcript; re-sending it is
|
|
2031
|
+
// how a resumed worker ends up re-doing the work it just did. Everything
|
|
2032
|
+
// below the brief is fresh-dispatch-only, exactly as today.
|
|
2033
|
+
let brief: string;
|
|
2034
|
+
if (resuming !== undefined) {
|
|
2035
|
+
brief = ORPHAN_RESUME_PROMPT;
|
|
2036
|
+
} else {
|
|
2037
|
+
// The discussion is rendered at dispatch so a worker never depends on a
|
|
2038
|
+
// runtime `gh` read to see the orchestrator's grooming (#517). The read is
|
|
2039
|
+
// best-effort, but its failure is not silent: an unreadable tracker names
|
|
2040
|
+
// itself in the brief's Discussion section instead of reading as "no
|
|
2041
|
+
// comments" — the exact confusion this fix removes.
|
|
2042
|
+
let comments: IssueComment[] | "unread";
|
|
2043
|
+
try {
|
|
2044
|
+
comments = await tracker.listComments(issue);
|
|
2045
|
+
} catch (err) {
|
|
2046
|
+
log(`#${issue} issue comments unreadable at dispatch; the brief will say so: ${errText(err)}`);
|
|
2047
|
+
comments = "unread";
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
brief = await buildBrief(project, r, branch, worktreePath, {
|
|
2051
|
+
continuation: provisioned?.reattached === true,
|
|
2052
|
+
defaultBranch: r.repo.defaultBranch,
|
|
2053
|
+
...(provisioned?.reattached === true && priorSalvage !== undefined
|
|
2054
|
+
? { salvagedSha: priorSalvage }
|
|
2055
|
+
: {}),
|
|
2056
|
+
comments,
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
1672
2059
|
if (await settleStopBeforeSession()) return;
|
|
1673
2060
|
if (await settleDrainBeforeSession()) return;
|
|
1674
2061
|
|
|
@@ -1687,6 +2074,7 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
1687
2074
|
workerControl?.install(control);
|
|
1688
2075
|
},
|
|
1689
2076
|
sessionDir,
|
|
2077
|
+
...(resuming === undefined ? {} : { resume: true }),
|
|
1690
2078
|
// The session's control socket, under the daemon's own state directory —
|
|
1691
2079
|
// a child process of the daemon reaches it directly.
|
|
1692
2080
|
socketPath: join(sessionDir, "ipc.sock"),
|
|
@@ -1702,7 +2090,7 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
1702
2090
|
onChildLog: (line) => {
|
|
1703
2091
|
log(`#${issue} ${line}`);
|
|
1704
2092
|
},
|
|
1705
|
-
...(
|
|
2093
|
+
...(choice.model === undefined ? {} : { model: choice.model }),
|
|
1706
2094
|
releaseGrants: resolveReleaseGrants(project),
|
|
1707
2095
|
onReleaseBlocked: workerReleaseBlockRecorder(project.name, issue, runId),
|
|
1708
2096
|
onTurn: (n) => store.updateRun(runId, { turns: n }),
|
|
@@ -1715,7 +2103,21 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
1715
2103
|
// ends: `omp-conductor tail` resolves an issue to a file through this row,
|
|
1716
2104
|
// and a path written at completion is a path nobody can follow live. The
|
|
1717
2105
|
// completion-time update below writes the same value again, harmlessly.
|
|
1718
|
-
onSessionFile: (f) =>
|
|
2106
|
+
onSessionFile: (f) => {
|
|
2107
|
+
store.updateRun(runId, { sessionFile: f });
|
|
2108
|
+
// #536: the harness's own `continueRecent` can still fall back to a
|
|
2109
|
+
// blank session (corrupt transcript, nothing to continue) — and a
|
|
2110
|
+
// blank session in the "resumed" worktree is indistinguishable from
|
|
2111
|
+
// today's dispatch unless the downgrade is named. Same-file lineage
|
|
2112
|
+
// is the proof the resume happened: the resumed run must keep
|
|
2113
|
+
// writing the orphaned attempt's transcript.
|
|
2114
|
+
if (resuming !== undefined && f !== resuming.sessionFile) {
|
|
2115
|
+
log(
|
|
2116
|
+
`#${issue} attempt ${attempt} resume fell back to a fresh session: opened ${f} ` +
|
|
2117
|
+
`instead of the orphaned attempt's ${resuming.sessionFile}`,
|
|
2118
|
+
);
|
|
2119
|
+
}
|
|
2120
|
+
},
|
|
1719
2121
|
// The last fence (#374): every pre-launch settle check above has
|
|
1720
2122
|
// passed, but the stop can still land while the session socket is
|
|
1721
2123
|
// binding inside `createSession`. This gate is re-checked there,
|
|
@@ -2886,7 +3288,7 @@ function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation
|
|
|
2886
3288
|
* that grows a field has no business breaking these tests.
|
|
2887
3289
|
*/
|
|
2888
3290
|
export async function admitCandidates(
|
|
2889
|
-
d: Pick<Deps, "project" | "caps" | "tracker" | "store" | "escalate" | "usage">,
|
|
3291
|
+
d: Pick<Deps, "project" | "caps" | "tracker" | "store" | "escalate" | "usage" | "probeCriticalBase">,
|
|
2890
3292
|
routed: Routed[],
|
|
2891
3293
|
slots: number,
|
|
2892
3294
|
): Promise<AdmissionPass> {
|
|
@@ -3066,6 +3468,81 @@ export async function admitCandidates(
|
|
|
3066
3468
|
continue;
|
|
3067
3469
|
}
|
|
3068
3470
|
|
|
3471
|
+
// #428 half (a): a preserved continuation that predates a configured
|
|
3472
|
+
// critical-base/safety marker must not be reattached. A base safety fix
|
|
3473
|
+
// protects only branches forked after it landed — a continuation forked
|
|
3474
|
+
// before it still carries the dangerous test/runtime code, and re-running
|
|
3475
|
+
// it on the shared host is what SIGTERMed the production daemon. Fail
|
|
3476
|
+
// closed: only a probe that proves every marker is in the reattach
|
|
3477
|
+
// source's ancestry admits, and a project that names a marker but has no
|
|
3478
|
+
// probe wired (never happens outside tests) holds. Both the hold and the
|
|
3479
|
+
// escalation are durable across restart and orphan recovery because this
|
|
3480
|
+
// gate runs every admission pass; the branch is re-admitted automatically
|
|
3481
|
+
// once the operator updates it to contain the marker, without losing work.
|
|
3482
|
+
const markers = project.criticalBase ?? [];
|
|
3483
|
+
if (markers.length > 0) {
|
|
3484
|
+
const branch = branchName(r.issue);
|
|
3485
|
+
let verdict: CriticalBaseVerdict;
|
|
3486
|
+
if (d.probeCriticalBase === undefined) {
|
|
3487
|
+
verdict = { state: "unknown", error: "no critical-base probe is wired in this deployment" };
|
|
3488
|
+
} else {
|
|
3489
|
+
try {
|
|
3490
|
+
verdict = await d.probeCriticalBase(r.repo, markers, branch);
|
|
3491
|
+
} catch (err) {
|
|
3492
|
+
verdict = { state: "unknown", error: errText(err) };
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
if (verdict.state === "stale") {
|
|
3496
|
+
hold(issue, "stale-base");
|
|
3497
|
+
log(
|
|
3498
|
+
`#${issue} held (stale-base): continuation branch ${branch} predates critical-base marker ${verdict.marker}`,
|
|
3499
|
+
);
|
|
3500
|
+
await safeEscalate(d, {
|
|
3501
|
+
tier: 1,
|
|
3502
|
+
project: project.name,
|
|
3503
|
+
issue,
|
|
3504
|
+
summary: `#${issue} continuation branch predates a critical base safety commit and is held (stale-base)`,
|
|
3505
|
+
detail: [
|
|
3506
|
+
r.issue.title,
|
|
3507
|
+
r.issue.url,
|
|
3508
|
+
`The retained branch ${branch} does not contain critical-base marker ${verdict.marker}.`,
|
|
3509
|
+
...(verdict.range.length > 0
|
|
3510
|
+
? [`Base commits the branch is missing: ${verdict.range.join(", ")}`]
|
|
3511
|
+
: []),
|
|
3512
|
+
"Recovery: merge current base into the branch so it contains the marker, and the next",
|
|
3513
|
+
"admission pass re-admits it automatically without losing the branch's work; or review",
|
|
3514
|
+
"the branch by hand and clear the hold once the fix is present.",
|
|
3515
|
+
].join("\n"),
|
|
3516
|
+
});
|
|
3517
|
+
continue;
|
|
3518
|
+
}
|
|
3519
|
+
if (verdict.state === "unknown") {
|
|
3520
|
+
// Fail closed: a branch that cannot be *proven* to contain the marker
|
|
3521
|
+
// is refused, and the reason names the unverifiable marker so the
|
|
3522
|
+
// operator can fix the fetch or the marker rather than guess.
|
|
3523
|
+
hold(issue, "stale-base");
|
|
3524
|
+
log(
|
|
3525
|
+
`#${issue} held (stale-base): continuation branch ${branch} could not be verified ` +
|
|
3526
|
+
`against critical-base marker(s) ${markers.join(", ")} (${verdict.error})`,
|
|
3527
|
+
);
|
|
3528
|
+
await safeEscalate(d, {
|
|
3529
|
+
tier: 1,
|
|
3530
|
+
project: project.name,
|
|
3531
|
+
issue,
|
|
3532
|
+
summary: `#${issue} continuation branch could not be verified against a critical base safety commit and is held (stale-base)`,
|
|
3533
|
+
detail: [
|
|
3534
|
+
r.issue.title,
|
|
3535
|
+
r.issue.url,
|
|
3536
|
+
`The retained branch ${branch} could not be verified against critical-base marker(s) ${markers.join(", ")}: ${verdict.error}`,
|
|
3537
|
+
"Recovery: merge current base into the branch so it contains the marker, and the next",
|
|
3538
|
+
"admission pass re-admits it automatically without losing the branch's work; or review",
|
|
3539
|
+
"the branch by hand and clear the hold once the fix is present.",
|
|
3540
|
+
].join("\n"),
|
|
3541
|
+
});
|
|
3542
|
+
continue;
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3069
3546
|
// Soft concurrency per epic, per repository: at most one in-flight child of
|
|
3070
3547
|
// a given parent in each repo. Children of one epic in *different* repos
|
|
3071
3548
|
// parallelise freely — `repo-active` / `maxConcurrentWorkersPerRepo` owns
|
|
@@ -3084,7 +3561,13 @@ export async function admitCandidates(
|
|
|
3084
3561
|
if (parent !== undefined) {
|
|
3085
3562
|
const occupied = occupiedParents.get(parent);
|
|
3086
3563
|
const blocker = occupied?.get(r.repo.name) ?? occupied?.get("");
|
|
3087
|
-
|
|
3564
|
+
// The gate serializes siblings under one epic: a held candidate must not
|
|
3565
|
+
// proceed while a *different* child of the parent is occupied. But a
|
|
3566
|
+
// candidate's own worker-free pushed-green row is exactly the work it is
|
|
3567
|
+
// continuing, not a rival — the unblocked continuation of that same
|
|
3568
|
+
// issue must not be rejected by its own occupancy, or the retained
|
|
3569
|
+
// continuation deadlocks forever with the PR open.
|
|
3570
|
+
if (blocker !== undefined && blocker !== issue) {
|
|
3088
3571
|
hold(issue, "sibling-active");
|
|
3089
3572
|
log(`#${issue} skipped: sibling #${blocker} in flight under epic #${parent} in ${r.repo.name}`);
|
|
3090
3573
|
continue;
|
|
@@ -3389,6 +3872,18 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
3389
3872
|
// failure happened. A pause silences claiming, not the operator's right to
|
|
3390
3873
|
// know their supervising session stopped reading its queue.
|
|
3391
3874
|
await watchOrchestrator(d);
|
|
3875
|
+
// The down incident is reconciled the same place and for the same reason: a
|
|
3876
|
+
// session that has actually died is as much the operator's concern as one
|
|
3877
|
+
// that is wedged, and restarting it is the daemon's restart either way. This
|
|
3878
|
+
// is what turns a crashed orchestrator into one page ("down since <t>") plus
|
|
3879
|
+
// a diverting count, instead of a warning only in daemon.log.
|
|
3880
|
+
await reconcileOrchestratorDown({
|
|
3881
|
+
project: d.project,
|
|
3882
|
+
store: d.store,
|
|
3883
|
+
orchestrator: d.orchestrator,
|
|
3884
|
+
escalate: (event) => d.escalate(event),
|
|
3885
|
+
log,
|
|
3886
|
+
});
|
|
3392
3887
|
|
|
3393
3888
|
// Settlement is maintenance, not dispatch. Run it before every gate that can
|
|
3394
3889
|
// stop claiming — pause, integrity, spend, and capacity — so status converges
|
|
@@ -4116,6 +4611,13 @@ export interface StatusSnapshot {
|
|
|
4116
4611
|
* see rather than a silent gap.
|
|
4117
4612
|
*/
|
|
4118
4613
|
labelOps?: { pending: number; oldestAgeMs: number };
|
|
4614
|
+
/**
|
|
4615
|
+
* The orchestrator-down incident, when the embedded orchestrator is down:
|
|
4616
|
+
* mode, since-moment and the tier-1 escalations diverted to issue comments
|
|
4617
|
+
* so far. Absent when the orchestrator is healthy (or external), so recovery
|
|
4618
|
+
* drops the degrade row from `status` (#288).
|
|
4619
|
+
*/
|
|
4620
|
+
orchestratorDown?: OrchestratorIncident;
|
|
4119
4621
|
}
|
|
4120
4622
|
|
|
4121
4623
|
/** Builds a status reading from an already-open store. Long-lived operator
|
|
@@ -4135,6 +4637,8 @@ export function statusSnapshotFromStore(
|
|
|
4135
4637
|
const dispatch = store.latestDispatch(p.name);
|
|
4136
4638
|
const labelOpsPending = store.countPendingLabelOps(p.name);
|
|
4137
4639
|
const oldestLabelOpAt = store.oldestPendingLabelOpAt(p.name);
|
|
4640
|
+
// Read once: `status` renders the degrade row off this while it is down.
|
|
4641
|
+
const orchestratorDown = store.orchestratorIncident(p.name);
|
|
4138
4642
|
// Read once: the provenance read touches the filesystem, and the renderer
|
|
4139
4643
|
// should never pay for it twice per status.
|
|
4140
4644
|
const reason = pauseProvenance(p.name)?.reason;
|
|
@@ -4167,6 +4671,7 @@ export function statusSnapshotFromStore(
|
|
|
4167
4671
|
? {}
|
|
4168
4672
|
: { labelOps: { pending: labelOpsPending, oldestAgeMs: now - oldestLabelOpAt } }),
|
|
4169
4673
|
baseHealth: store.baseHealth(p.name),
|
|
4674
|
+
...(orchestratorDown === undefined ? {} : { orchestratorDown }),
|
|
4170
4675
|
};
|
|
4171
4676
|
}
|
|
4172
4677
|
|
|
@@ -4285,6 +4790,7 @@ export function formatStatus(s: StatusSnapshot): string {
|
|
|
4285
4790
|
`config ${s.configPath}`,
|
|
4286
4791
|
`state ${s.stateDir}`,
|
|
4287
4792
|
"",
|
|
4793
|
+
...(s.orchestratorDown === undefined ? [] : formatOrchestratorDown(s.orchestratorDown)),
|
|
4288
4794
|
"caps",
|
|
4289
4795
|
` workers ${s.liveWorkers} / ${s.caps.maxConcurrentWorkers}`,
|
|
4290
4796
|
` issues today ${s.runsToday}`,
|
|
@@ -4677,6 +5183,11 @@ async function recoverRun(
|
|
|
4677
5183
|
"The issue cannot finish inside the wall-clock cap, so another run would burn a worker slot for the same outcome. Re-scope it, raise maxContinuationsPerIssue for it, or finish the remaining work by hand.",
|
|
4678
5184
|
].join("\n"),
|
|
4679
5185
|
});
|
|
5186
|
+
await postExhaustionPostmortem(
|
|
5187
|
+
d,
|
|
5188
|
+
run,
|
|
5189
|
+
`Attempt ${run.attempt} hit the wall-clock cap with work to continue from, but ${continuation} continuation(s) are already charged against a cap of ${caps.maxContinuationsPerIssue}.`,
|
|
5190
|
+
);
|
|
4680
5191
|
log(`#${run.issue} not continued from ${cls}: continuation budget exhausted`);
|
|
4681
5192
|
}
|
|
4682
5193
|
return;
|
|
@@ -4731,11 +5242,14 @@ async function recoverRun(
|
|
|
4731
5242
|
// Same bound for provider-transient: an issue whose stream keeps stalling
|
|
4732
5243
|
// mid-run is requeued free (no attempt, no continuation charged) — but a
|
|
4733
5244
|
// provider that aborts three times for one issue is down, and a human has
|
|
4734
|
-
// to check its status before hand-requeueing (#220).
|
|
5245
|
+
// to check its status before hand-requeueing (#220). The escalation names
|
|
5246
|
+
// every model the chain tried, so a merged branch built on a different
|
|
5247
|
+
// model is attributable (#286).
|
|
4735
5248
|
if (
|
|
4736
5249
|
cls === "provider-transient" &&
|
|
4737
5250
|
store.classCountFor(project.name, run.issue, "provider-transient") >= PROVIDER_TRANSIENT_MAX_STRIKES
|
|
4738
5251
|
) {
|
|
5252
|
+
const tried = formatModelsTried(modelsTried(store.runsForIssue(project.name, run.issue)));
|
|
4739
5253
|
await safeEscalate(d, {
|
|
4740
5254
|
tier: 1,
|
|
4741
5255
|
project: project.name,
|
|
@@ -4743,6 +5257,9 @@ async function recoverRun(
|
|
|
4743
5257
|
summary: `[provider-transient] #${run.issue}: the provider keeps aborting mid-stream — ${evidence}`,
|
|
4744
5258
|
detail: [
|
|
4745
5259
|
`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).`,
|
|
5260
|
+
...(tried === ""
|
|
5261
|
+
? []
|
|
5262
|
+
: [`Models tried: ${tried}.`]),
|
|
4746
5263
|
"Check provider status before requeueing by hand.",
|
|
4747
5264
|
].join("\n"),
|
|
4748
5265
|
});
|
|
@@ -4816,6 +5333,11 @@ async function recoverRun(
|
|
|
4816
5333
|
"What you can do: raise maxContinuationsPerIssue for this issue, re-scope it, or continue from the preserved work (or start clean if none).",
|
|
4817
5334
|
].join("\n"),
|
|
4818
5335
|
});
|
|
5336
|
+
await postExhaustionPostmortem(
|
|
5337
|
+
d,
|
|
5338
|
+
run,
|
|
5339
|
+
`Attempt ${run.attempt} is not requeued: ${continuations} continuation(s) charged against a cap of ${caps.maxContinuationsPerIssue}.`,
|
|
5340
|
+
);
|
|
4819
5341
|
log(`#${run.issue} not requeued from orphan-clean: continuation budget exhausted`);
|
|
4820
5342
|
return;
|
|
4821
5343
|
}
|
|
@@ -5247,6 +5769,8 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
5247
5769
|
const { brief, releaseGrants } = orchestratorStandingOrders(project);
|
|
5248
5770
|
let orchestrator: OrchestratorHandle | undefined;
|
|
5249
5771
|
let orchestratorVerbs: VerbListener | undefined;
|
|
5772
|
+
/** First start-failure cause, surfaced by the orchestrator-down incident (#288). */
|
|
5773
|
+
let orchestratorStartError: string | undefined;
|
|
5250
5774
|
if (project.escalation.orchestrator === "external") {
|
|
5251
5775
|
projectLog(
|
|
5252
5776
|
"orchestrator: external — tier-1 escalations post as issue comments for the external session's drain duty",
|
|
@@ -5286,9 +5810,10 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
5286
5810
|
const transcript = orchestrator.sessionFile();
|
|
5287
5811
|
projectLog(`orchestrator session ready${transcript === undefined ? "" : ` · ${transcript}`}`);
|
|
5288
5812
|
} catch (err) {
|
|
5813
|
+
orchestratorStartError = errText(err);
|
|
5289
5814
|
projectLog(
|
|
5290
5815
|
"WARNING: orchestrator session failed to start; tier-1 escalations will fall back to issue " +
|
|
5291
|
-
`comments: ${
|
|
5816
|
+
`comments: ${orchestratorStartError}`,
|
|
5292
5817
|
);
|
|
5293
5818
|
await orchestratorVerbs?.close();
|
|
5294
5819
|
orchestratorVerbs = undefined;
|
|
@@ -5305,6 +5830,17 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
5305
5830
|
orchestrator,
|
|
5306
5831
|
Date.now,
|
|
5307
5832
|
deliveryPolicyValid,
|
|
5833
|
+
(e) => {
|
|
5834
|
+
// Every tier-1 escalation that lands on the issue-comment fallback was
|
|
5835
|
+
// diverted from the orchestrator. Count it durably on the open incident
|
|
5836
|
+
// (a no-op when none is open), so the page and status name how much
|
|
5837
|
+
// the outage diverted (#288).
|
|
5838
|
+
store.bumpOrchestratorDiverted(project.name, 1);
|
|
5839
|
+
projectLog(
|
|
5840
|
+
`orchestrator: tier-1 escalation on ${escalationIssueRef(e.issue)} diverted to issue comments ` +
|
|
5841
|
+
`while the orchestrator was down`,
|
|
5842
|
+
);
|
|
5843
|
+
},
|
|
5308
5844
|
);
|
|
5309
5845
|
const outbox = createReportOutbox({
|
|
5310
5846
|
project: currentProject,
|
|
@@ -5335,11 +5871,26 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
5335
5871
|
workerControls,
|
|
5336
5872
|
integrity,
|
|
5337
5873
|
stall: { paged: false },
|
|
5874
|
+
...(orchestrator === undefined ? {} : { orchestrator }),
|
|
5338
5875
|
cleanup: { next: 0 },
|
|
5876
|
+
probeCriticalBase: (repo, markers, branch) =>
|
|
5877
|
+
probeCriticalBase(project, repo, branch, markers),
|
|
5339
5878
|
...(verbPeerReader === undefined ? {} : { verbPeerReader }),
|
|
5340
5879
|
verbActions,
|
|
5341
5880
|
};
|
|
5342
5881
|
runtimeDeps = d;
|
|
5882
|
+
// Startup reconciliation: close an incident carried over from a previous
|
|
5883
|
+
// process when the orchestrator is up (one recovery notice), or open one
|
|
5884
|
+
// when it failed to start (one down page). A daemon restarted while still
|
|
5885
|
+
// down rediscovers the open incident and does not re-page it.
|
|
5886
|
+
await reconcileOrchestratorDown({
|
|
5887
|
+
project,
|
|
5888
|
+
store,
|
|
5889
|
+
orchestrator,
|
|
5890
|
+
escalate: (event) => d.escalate(event),
|
|
5891
|
+
...(orchestratorStartError === undefined ? {} : { startCause: orchestratorStartError }),
|
|
5892
|
+
log: projectLog,
|
|
5893
|
+
});
|
|
5343
5894
|
runtimes.push({
|
|
5344
5895
|
d,
|
|
5345
5896
|
outbox,
|