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/REFERENCE.md +9 -4
- package/package.json +1 -1
- package/src/commands/restart.ts +81 -54
- package/src/commands/stop.ts +45 -22
- package/src/daemon.ts +253 -54
- package/src/doctor.ts +241 -4
- package/src/escalate.ts +8 -0
- package/src/fleet.ts +49 -2
- package/src/lifecycle.ts +113 -2
- package/src/omp.ts +24 -0
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +7 -0
- package/src/orchestrator.ts +14 -0
- package/src/release-policy.ts +163 -18
- package/src/setup-host.ts +386 -17
- package/src/setup-install.ts +40 -2
- package/src/setup-wizard.ts +278 -113
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +181 -0
- package/src/types.ts +111 -0
- package/src/upgrade.ts +26 -6
- 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 +21 -0
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
|
|
1827
|
-
//
|
|
1828
|
-
//
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
if (
|
|
1832
|
-
|
|
1833
|
-
r.repo,
|
|
1834
|
-
|
|
1835
|
-
project.workspaceRoot,
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
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 =
|
|
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
|
|
1888
|
-
//
|
|
1889
|
-
//
|
|
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
|
|
2026
|
+
(provisioned?.reattached ? " (continuation: reattached existing branch)" : ""),
|
|
1908
2027
|
);
|
|
1909
2028
|
|
|
1910
|
-
// The
|
|
1911
|
-
//
|
|
1912
|
-
//
|
|
1913
|
-
//
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
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
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
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) =>
|
|
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
|
-
|
|
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: ${
|
|
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,
|