omp-conductor 0.15.12 → 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/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,
@@ -78,6 +82,7 @@ import type {
78
82
  MergedPrInfo,
79
83
  OpenCloser,
80
84
  ReleaseShape,
85
+ OrchestratorIncident,
81
86
  PrState,
82
87
  ProjectConfig,
83
88
  ReadyIssue,
@@ -100,6 +105,7 @@ import {
100
105
  type WorkerPausePhase,
101
106
  type WorkerResult,
102
107
  type RunWorkerDeps,
108
+ ORPHAN_RESUME_PROMPT,
103
109
  renderBrief,
104
110
  runWorker,
105
111
  } from "./worker.ts";
@@ -227,6 +233,13 @@ interface Deps {
227
233
  workerDeps?: RunWorkerDeps;
228
234
  integrity: IntegrityGate;
229
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;
230
243
  cleanup?: RetainedCleanupCursor;
231
244
  /**
232
245
  * Reads the connecting uid off a verb socket (#126). Resolved once at startup
@@ -531,6 +544,19 @@ export function pauseInstance(
531
544
  }
532
545
  }
533
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
+
534
560
  export function setPaused(
535
561
  v: boolean,
536
562
  why?: { source: string; reason?: string },
@@ -1607,6 +1633,50 @@ export async function inheritedPrForContinuation(
1607
1633
  return { prUrl: prior.prUrl, ...(prior.headSha === undefined ? {} : { headSha: prior.headSha }) };
1608
1634
  }
1609
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
+
1610
1680
  export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
1611
1681
  const { project, caps, tracker, store } = d;
1612
1682
  const issue = r.issue.number;
@@ -1770,6 +1840,49 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1770
1840
  // fresh issue always starts on the primary (#286). Read before the claim
1771
1841
  // writes this attempt's row, which would otherwise break the streak.
1772
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
+
1773
1886
  run = store.createRun({
1774
1887
  project: project.name,
1775
1888
  issue,
@@ -1822,22 +1935,30 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1822
1935
 
1823
1936
  // A run's tree is <workspaceRoot>/<issue> and addRunRepo refuses to reuse
1824
1937
  // an existing path, so a retry — or a tree kept from a failed attempt — has
1825
- // to be cleared first. Both helpers are pure path math and removeWorktree
1826
- // tolerates a mirror or tree that is not there yet, so this is safe on a
1827
- // first attempt. addRunRepo does its own ensureMirror; calling it here too
1828
- // would cost a second network fetch per attempt.
1829
- await removeWorktree(mirrorPath, worktreePathFor(project.workspaceRoot, issue));
1830
- if (await settleStopBeforeSession()) return;
1831
- if (await settleDrainBeforeSession()) return;
1832
- const provisioned = await addRunRepo(
1833
- r.repo,
1834
- project.mirrorRoot,
1835
- project.workspaceRoot,
1836
- issue,
1837
- branch,
1838
- );
1839
- worktreePath = provisioned.path;
1840
- 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
+ }
1841
1962
  if (await settleStopBeforeSession()) return;
1842
1963
  if (await settleDrainBeforeSession()) return;
1843
1964
 
@@ -1846,9 +1967,15 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1846
1967
  // here would put a file that never gets written into an escalation.
1847
1968
  //
1848
1969
  // Per run rather than one shared directory, so one run's transcript cannot
1849
- // 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.
1850
1974
  const runTreeRoot = stateDir();
1851
- 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!);
1852
1979
  mkdirSync(sessionDir, { recursive: true });
1853
1980
 
1854
1981
  // ---- the run's mutation channel (#126) -------------------------------
@@ -1884,50 +2011,51 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1884
2011
  store.updateRun(runId, { worktree: worktreePath, state: "running" });
1885
2012
 
1886
2013
  // Where this attempt goes, and (when the failover fired) the clause that
1887
- // makes it attributable: with no `modelFallbacks` configured this is the
1888
- // primary modelor none, for an unconfigured project and today's
1889
- // dispatch is byte for byte what it has always been.
1890
- const chainConfigured = (project.modelFallbacks?.length ?? 0) > 0;
1891
- const choice = resolveDispatchModel({
1892
- workerModel: project.workerModel,
1893
- modelFallbacks: project.modelFallbacks,
1894
- threshold: project.modelFallbackThreshold ?? DEFAULT_MODEL_FALLBACK_THRESHOLD,
1895
- streak: chainFacts.streak,
1896
- });
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.
1897
2017
  // Recorded before the launch, so even a run killed mid-flight leaves the
1898
2018
  // model it chose on its row. Only a chain-configured project writes the
1899
2019
  // column: absent `modelFallbacks` must preserve today's rows byte for byte.
1900
2020
  if (chainConfigured && choice.model !== undefined) {
1901
2021
  store.updateRun(runId, { model: choice.model });
1902
2022
  }
1903
- const clause = fallbackClause(choice, chainFacts, project.workerModel);
1904
2023
 
1905
2024
  log(
1906
2025
  `#${issue} attempt ${attempt}${clause === undefined ? "" : ` ${clause}`} → ${r.repo.name} ${branch}` +
1907
- (provisioned.reattached ? " (continuation: reattached existing branch)" : ""),
2026
+ (provisioned?.reattached ? " (continuation: reattached existing branch)" : ""),
1908
2027
  );
1909
2028
 
1910
- // The discussion is rendered at dispatch so a worker never depends on a
1911
- // runtime `gh` read to see the orchestrator's grooming (#517). The read is
1912
- // best-effort, but its failure is not silent: an unreadable tracker names
1913
- // itself in the brief's Discussion section instead of reading as "no
1914
- // comments" — the exact confusion this fix removes.
1915
- let comments: IssueComment[] | "unread";
1916
- try {
1917
- comments = await tracker.listComments(issue);
1918
- } catch (err) {
1919
- log(`#${issue} issue comments unreadable at dispatch; the brief will say so: ${errText(err)}`);
1920
- comments = "unread";
1921
- }
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
+ }
1922
2049
 
1923
- const brief = await buildBrief(project, r, branch, worktreePath, {
1924
- continuation: provisioned.reattached,
1925
- defaultBranch: r.repo.defaultBranch,
1926
- ...(provisioned.reattached && priorSalvage !== undefined
1927
- ? { salvagedSha: priorSalvage }
1928
- : {}),
1929
- comments,
1930
- });
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
+ }
1931
2059
  if (await settleStopBeforeSession()) return;
1932
2060
  if (await settleDrainBeforeSession()) return;
1933
2061
 
@@ -1946,6 +2074,7 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1946
2074
  workerControl?.install(control);
1947
2075
  },
1948
2076
  sessionDir,
2077
+ ...(resuming === undefined ? {} : { resume: true }),
1949
2078
  // The session's control socket, under the daemon's own state directory —
1950
2079
  // a child process of the daemon reaches it directly.
1951
2080
  socketPath: join(sessionDir, "ipc.sock"),
@@ -1974,7 +2103,21 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1974
2103
  // ends: `omp-conductor tail` resolves an issue to a file through this row,
1975
2104
  // and a path written at completion is a path nobody can follow live. The
1976
2105
  // completion-time update below writes the same value again, harmlessly.
1977
- 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
+ },
1978
2121
  // The last fence (#374): every pre-launch settle check above has
1979
2122
  // passed, but the stop can still land while the session socket is
1980
2123
  // binding inside `createSession`. This gate is re-checked there,
@@ -3418,7 +3561,13 @@ export async function admitCandidates(
3418
3561
  if (parent !== undefined) {
3419
3562
  const occupied = occupiedParents.get(parent);
3420
3563
  const blocker = occupied?.get(r.repo.name) ?? occupied?.get("");
3421
- 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) {
3422
3571
  hold(issue, "sibling-active");
3423
3572
  log(`#${issue} skipped: sibling #${blocker} in flight under epic #${parent} in ${r.repo.name}`);
3424
3573
  continue;
@@ -3723,6 +3872,18 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
3723
3872
  // failure happened. A pause silences claiming, not the operator's right to
3724
3873
  // know their supervising session stopped reading its queue.
3725
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
+ });
3726
3887
 
3727
3888
  // Settlement is maintenance, not dispatch. Run it before every gate that can
3728
3889
  // stop claiming — pause, integrity, spend, and capacity — so status converges
@@ -4450,6 +4611,13 @@ export interface StatusSnapshot {
4450
4611
  * see rather than a silent gap.
4451
4612
  */
4452
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;
4453
4621
  }
4454
4622
 
4455
4623
  /** Builds a status reading from an already-open store. Long-lived operator
@@ -4469,6 +4637,8 @@ export function statusSnapshotFromStore(
4469
4637
  const dispatch = store.latestDispatch(p.name);
4470
4638
  const labelOpsPending = store.countPendingLabelOps(p.name);
4471
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);
4472
4642
  // Read once: the provenance read touches the filesystem, and the renderer
4473
4643
  // should never pay for it twice per status.
4474
4644
  const reason = pauseProvenance(p.name)?.reason;
@@ -4501,6 +4671,7 @@ export function statusSnapshotFromStore(
4501
4671
  ? {}
4502
4672
  : { labelOps: { pending: labelOpsPending, oldestAgeMs: now - oldestLabelOpAt } }),
4503
4673
  baseHealth: store.baseHealth(p.name),
4674
+ ...(orchestratorDown === undefined ? {} : { orchestratorDown }),
4504
4675
  };
4505
4676
  }
4506
4677
 
@@ -4619,6 +4790,7 @@ export function formatStatus(s: StatusSnapshot): string {
4619
4790
  `config ${s.configPath}`,
4620
4791
  `state ${s.stateDir}`,
4621
4792
  "",
4793
+ ...(s.orchestratorDown === undefined ? [] : formatOrchestratorDown(s.orchestratorDown)),
4622
4794
  "caps",
4623
4795
  ` workers ${s.liveWorkers} / ${s.caps.maxConcurrentWorkers}`,
4624
4796
  ` issues today ${s.runsToday}`,
@@ -5597,6 +5769,8 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
5597
5769
  const { brief, releaseGrants } = orchestratorStandingOrders(project);
5598
5770
  let orchestrator: OrchestratorHandle | undefined;
5599
5771
  let orchestratorVerbs: VerbListener | undefined;
5772
+ /** First start-failure cause, surfaced by the orchestrator-down incident (#288). */
5773
+ let orchestratorStartError: string | undefined;
5600
5774
  if (project.escalation.orchestrator === "external") {
5601
5775
  projectLog(
5602
5776
  "orchestrator: external — tier-1 escalations post as issue comments for the external session's drain duty",
@@ -5636,9 +5810,10 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
5636
5810
  const transcript = orchestrator.sessionFile();
5637
5811
  projectLog(`orchestrator session ready${transcript === undefined ? "" : ` · ${transcript}`}`);
5638
5812
  } catch (err) {
5813
+ orchestratorStartError = errText(err);
5639
5814
  projectLog(
5640
5815
  "WARNING: orchestrator session failed to start; tier-1 escalations will fall back to issue " +
5641
- `comments: ${errText(err)}`,
5816
+ `comments: ${orchestratorStartError}`,
5642
5817
  );
5643
5818
  await orchestratorVerbs?.close();
5644
5819
  orchestratorVerbs = undefined;
@@ -5655,6 +5830,17 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
5655
5830
  orchestrator,
5656
5831
  Date.now,
5657
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
+ },
5658
5844
  );
5659
5845
  const outbox = createReportOutbox({
5660
5846
  project: currentProject,
@@ -5685,6 +5871,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
5685
5871
  workerControls,
5686
5872
  integrity,
5687
5873
  stall: { paged: false },
5874
+ ...(orchestrator === undefined ? {} : { orchestrator }),
5688
5875
  cleanup: { next: 0 },
5689
5876
  probeCriticalBase: (repo, markers, branch) =>
5690
5877
  probeCriticalBase(project, repo, branch, markers),
@@ -5692,6 +5879,18 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
5692
5879
  verbActions,
5693
5880
  };
5694
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
+ });
5695
5894
  runtimes.push({
5696
5895
  d,
5697
5896
  outbox,