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.
Files changed (51) hide show
  1. package/REFERENCE.md +107 -60
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +3 -0
  4. package/src/briefs/orchestrator.md +64 -11
  5. package/src/briefs/policy.md +19 -3
  6. package/src/briefs/worker.md +11 -8
  7. package/src/cli.ts +41 -21
  8. package/src/commands/context.ts +102 -1
  9. package/src/commands/doctor.ts +4 -2
  10. package/src/commands/intake.ts +26 -5
  11. package/src/commands/message.ts +80 -32
  12. package/src/commands/report.ts +38 -2
  13. package/src/commands/restart.ts +81 -54
  14. package/src/commands/setup.ts +61 -11
  15. package/src/commands/stop.ts +45 -22
  16. package/src/commands/upgrade-rollback.ts +9 -0
  17. package/src/config-schema.ts +9 -0
  18. package/src/config.ts +35 -1
  19. package/src/daemon.ts +588 -37
  20. package/src/dashboard/app.js +398 -59
  21. package/src/dashboard/index.html +27 -0
  22. package/src/dashboard/server.ts +219 -5
  23. package/src/dashboard/style.css +169 -1
  24. package/src/doctor.ts +419 -45
  25. package/src/escalate.ts +8 -0
  26. package/src/failure-class.ts +37 -0
  27. package/src/fleet.ts +49 -2
  28. package/src/gitops.ts +157 -0
  29. package/src/lifecycle.ts +113 -2
  30. package/src/model-fallback.ts +177 -0
  31. package/src/omp.ts +115 -13
  32. package/src/orchestrator-down.ts +231 -0
  33. package/src/orchestrator-tick.ts +108 -5
  34. package/src/orchestrator.ts +18 -4
  35. package/src/privileged.ts +10 -0
  36. package/src/release-policy.ts +373 -28
  37. package/src/session-host.ts +11 -5
  38. package/src/setup-host.ts +665 -70
  39. package/src/setup-install.ts +275 -28
  40. package/src/setup-wizard.ts +339 -126
  41. package/src/setup.ts +25 -0
  42. package/src/stop-provenance.ts +66 -0
  43. package/src/store.ts +194 -1
  44. package/src/tracker/github.ts +47 -0
  45. package/src/types.ts +182 -0
  46. package/src/upgrade.ts +110 -32
  47. package/src/verbs/protocol.ts +16 -3
  48. package/src/verbs/server.ts +27 -1
  49. package/src/wizard-ui.ts +261 -46
  50. package/src/worker.ts +24 -3
  51. 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 { pushRunBranch, readBaseChain, type RunRepoRef } from "./gitops.ts";
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: { continuation?: boolean; defaultBranch?: string; salvagedSha?: string } = {},
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: ReleaseShape, context: ReleaseBlockContext) => void {
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, so this is safe on a
1601
- // first attempt. addRunRepo does its own ensureMirror; calling it here too
1602
- // would cost a second network fetch per attempt.
1603
- await removeWorktree(mirrorPath, worktreePathFor(project.workspaceRoot, issue));
1604
- if (await settleStopBeforeSession()) return;
1605
- if (await settleDrainBeforeSession()) return;
1606
- const provisioned = await addRunRepo(
1607
- r.repo,
1608
- project.mirrorRoot,
1609
- project.workspaceRoot,
1610
- issue,
1611
- branch,
1612
- );
1613
- worktreePath = provisioned.path;
1614
- runRepo = { repo: r.repo, runRepoPath: worktreePath, branch };
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 = join(runTreeRoot, "sessions", `run-${String(runId)}`);
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.reattached ? " (continuation: reattached existing branch)" : ""),
2025
+ `#${issue} attempt ${attempt}${clause === undefined ? "" : ` ${clause}`} → ${r.repo.name} ${branch}` +
2026
+ (provisioned?.reattached ? " (continuation: reattached existing branch)" : ""),
1663
2027
  );
1664
2028
 
1665
- const brief = await buildBrief(project, r, branch, worktreePath, {
1666
- continuation: provisioned.reattached,
1667
- defaultBranch: r.repo.defaultBranch,
1668
- ...(provisioned.reattached && priorSalvage !== undefined
1669
- ? { salvagedSha: priorSalvage }
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
- ...(project.workerModel === undefined ? {} : { model: project.workerModel }),
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) => store.updateRun(runId, { sessionFile: 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
- if (blocker !== undefined) {
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: ${errText(err)}`,
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,