omp-conductor 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +238 -44
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +33 -22
- package/src/briefs/orchestrator.md +89 -25
- package/src/briefs/worker.md +9 -2
- package/src/cli.ts +291 -13
- package/src/config.ts +127 -16
- package/src/daemon.ts +520 -54
- package/src/lifecycle.ts +6 -4
- package/src/orchestrator-tick.ts +209 -45
- package/src/plugin.ts +60 -0
- package/src/setup.ts +98 -4
- package/src/store.ts +41 -4
- package/src/tracker/github.ts +94 -2
- package/src/types.ts +62 -4
- package/src/unblock.ts +113 -0
- package/src/worker.ts +14 -0
- package/src/worktree.ts +119 -0
package/src/daemon.ts
CHANGED
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
* of it, so concurrency, daily volume, spend and per-issue attempts are counted
|
|
8
8
|
* here and enforced before anything is claimed.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join, relative } from "node:path";
|
|
12
13
|
import { configPath, findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
|
|
13
14
|
import { createEscalator } from "./escalate.ts";
|
|
15
|
+
import { livingDaemon } from "./lifecycle.ts";
|
|
16
|
+
import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
|
|
14
17
|
import { startOrchestrator } from "./orchestrator.ts";
|
|
15
18
|
import type { OrchestratorHandle } from "./orchestrator.ts";
|
|
16
19
|
import { branchName, route } from "./routing.ts";
|
|
@@ -27,8 +30,15 @@ import type {
|
|
|
27
30
|
Store,
|
|
28
31
|
Tracker,
|
|
29
32
|
} from "./types.ts";
|
|
30
|
-
import { renderBrief, runWorker } from "./worker.ts";
|
|
31
|
-
import {
|
|
33
|
+
import { type KilledBy, renderBrief, runWorker } from "./worker.ts";
|
|
34
|
+
import {
|
|
35
|
+
addWorktree,
|
|
36
|
+
mirrorPathFor,
|
|
37
|
+
removeWorktree,
|
|
38
|
+
salvageWip,
|
|
39
|
+
type SalvageOutcome,
|
|
40
|
+
worktreePathFor,
|
|
41
|
+
} from "./worktree.ts";
|
|
32
42
|
|
|
33
43
|
/** Long enough that the tracker is not polled raw, short enough that a human
|
|
34
44
|
* who labels an issue sees it picked up within a coffee break. */
|
|
@@ -60,6 +70,8 @@ interface Deps {
|
|
|
60
70
|
tracker: Tracker;
|
|
61
71
|
store: Store;
|
|
62
72
|
escalate(e: Escalation): Promise<void>;
|
|
73
|
+
integrity: IntegrityGate;
|
|
74
|
+
stall: StallGate;
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
// ---------------------------------------------------------------- paths & pause
|
|
@@ -69,6 +81,97 @@ export function dbPath(): string {
|
|
|
69
81
|
return join(stateDir(), "conductor.db");
|
|
70
82
|
}
|
|
71
83
|
|
|
84
|
+
// ------------------------------------------------------- orchestrator liveness
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether the wedged-orchestrator page has already gone out for the stall
|
|
88
|
+
* currently on disk. One page per episode: the marker persists until a tick is
|
|
89
|
+
* consumed, so paging per five minutes would be paging forever.
|
|
90
|
+
*/
|
|
91
|
+
export interface StallGate {
|
|
92
|
+
paged: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface StallVerdict {
|
|
96
|
+
/** The marker's own line, when one is there. */
|
|
97
|
+
since?: string;
|
|
98
|
+
page: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Reads the orchestrator's stall marker and decides whether this tick pages.
|
|
103
|
+
*
|
|
104
|
+
* The marker is written by the tick extension inside the orchestrator session
|
|
105
|
+
* ({@link STALL_MARKER_FILE}) when two of its own prompts go unconsumed — the
|
|
106
|
+
* one signal that separates "the process is alive" from "the loop is reading
|
|
107
|
+
* its queue". Every other guard in this system reads healthy through a wedge:
|
|
108
|
+
* the herdr recovery plugin tests for a live process and an agent label, both
|
|
109
|
+
* of which survive it, and `/healthz` describes this daemon, which is a
|
|
110
|
+
* different process entirely.
|
|
111
|
+
*
|
|
112
|
+
* The daemon is the natural watcher precisely because it is that different
|
|
113
|
+
* process: it already wakes every five minutes, it owns a working escalation
|
|
114
|
+
* path, and nothing about its health depends on the session that is stuck. A
|
|
115
|
+
* wedged loop cannot page for itself, and the herdr plugin only runs on session
|
|
116
|
+
* lifecycle events — a session that stays alive and stops working emits none.
|
|
117
|
+
*
|
|
118
|
+
* Resets when the marker disappears, so a second stall days later pages again.
|
|
119
|
+
*/
|
|
120
|
+
export function checkStall(gate: StallGate, marker: string): StallVerdict {
|
|
121
|
+
if (!existsSync(marker)) {
|
|
122
|
+
gate.paged = false;
|
|
123
|
+
return { page: false };
|
|
124
|
+
}
|
|
125
|
+
const page = !gate.paged;
|
|
126
|
+
let since: string | undefined;
|
|
127
|
+
try {
|
|
128
|
+
const body = readFileSync(marker, "utf8").split("\n")[0]?.trim();
|
|
129
|
+
if (body !== undefined && body !== "") since = body;
|
|
130
|
+
} catch {
|
|
131
|
+
// An unreadable marker still means stalled; the timestamp is a nicety.
|
|
132
|
+
}
|
|
133
|
+
return { ...(since === undefined ? {} : { since }), page };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Pages tier 2 once when the orchestrator session stops draining its queue.
|
|
138
|
+
*
|
|
139
|
+
* Deliberately does not restart anything. A wedge lands mid-turn, this process
|
|
140
|
+
* cannot tell a half-applied edit from an idle loop, and killing the session
|
|
141
|
+
* could destroy work an operator would rather read first — the same refusal to
|
|
142
|
+
* guess that the recovery plugin is built on.
|
|
143
|
+
*/
|
|
144
|
+
async function watchOrchestrator(d: Deps): Promise<void> {
|
|
145
|
+
const marker = join(stateDir(), STALL_MARKER_FILE);
|
|
146
|
+
const verdict = checkStall(d.stall, marker);
|
|
147
|
+
if (!verdict.page) return;
|
|
148
|
+
|
|
149
|
+
log(`ERROR: the orchestrator session is not draining its queue — ${verdict.since ?? "no timestamp"}`);
|
|
150
|
+
const delivered = await safeEscalate(d, {
|
|
151
|
+
tier: 2,
|
|
152
|
+
project: d.project.name,
|
|
153
|
+
issue: NO_ISSUE,
|
|
154
|
+
// Dated like the other tier-2 summaries: the dedup key is the summary, and
|
|
155
|
+
// a second wedge next month must not read as a repeat of this one.
|
|
156
|
+
summary:
|
|
157
|
+
`Orchestrator session wedged on ${new Date().toISOString().slice(0, 10)} — ` +
|
|
158
|
+
`it has stopped reading its queue (${d.project.name})`,
|
|
159
|
+
detail: [
|
|
160
|
+
verdict.since ?? "Marker present with no readable timestamp.",
|
|
161
|
+
`Marker: ${marker}`,
|
|
162
|
+
"",
|
|
163
|
+
"Its process and its herdr agent label are both healthy, which is why nothing else noticed:",
|
|
164
|
+
"the loop is alive and consuming nothing, so ticks and your messages queue behind it unread.",
|
|
165
|
+
"",
|
|
166
|
+
"Attach and look before you act — a wedge lands mid-turn. Then SIGTERM the omp process:",
|
|
167
|
+
"herdr-conductor resumes it by exact identity, and the first consumed tick clears this marker.",
|
|
168
|
+
"",
|
|
169
|
+
"Dispatch is unaffected: workers keep running. What stops is drain, groom, report and merge.",
|
|
170
|
+
].join("\n"),
|
|
171
|
+
});
|
|
172
|
+
markPaged(d.stall, delivered);
|
|
173
|
+
}
|
|
174
|
+
|
|
72
175
|
/**
|
|
73
176
|
* Pause is a file rather than process state on purpose: `omp-conductor pause`
|
|
74
177
|
* and `/conductor pause` run in a different process from the daemon, and a flag
|
|
@@ -89,6 +192,99 @@ export function setPaused(v: boolean): void {
|
|
|
89
192
|
}
|
|
90
193
|
}
|
|
91
194
|
|
|
195
|
+
// ----------------------------------------------------------- package integrity
|
|
196
|
+
|
|
197
|
+
/** Enough differing paths to tell a deploy from a tamper at a glance; the full
|
|
198
|
+
* list is on the host, and the answer is always "go look at the host". */
|
|
199
|
+
const INTEGRITY_SAMPLE = 5;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* What the daemon booted with, and whether it has already paged about losing
|
|
203
|
+
* it. Lives exactly as long as one `runDaemon()` call — which is the whole
|
|
204
|
+
* trick: a restart re-records both.
|
|
205
|
+
*/
|
|
206
|
+
export interface IntegrityGate {
|
|
207
|
+
baseline: Map<string, string>;
|
|
208
|
+
paged: boolean;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface IntegrityVerdict {
|
|
212
|
+
/** Labelled, sorted differences; empty when the package is untouched. */
|
|
213
|
+
diff: string[];
|
|
214
|
+
/** Any difference at all stops the fleet. */
|
|
215
|
+
pause: boolean;
|
|
216
|
+
/** First divergent tick only — a page every five minutes is a page nobody reads. */
|
|
217
|
+
page: boolean;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* sha256 of every source file the running package is made of, keyed by path
|
|
222
|
+
* relative to `root`.
|
|
223
|
+
*
|
|
224
|
+
* `import.meta.dir` is the installed `src/` of the code executing right now, so
|
|
225
|
+
* this is a self-portrait: what was actually deployed, not what some checkout
|
|
226
|
+
* on disk happens to contain. `.ts` and `.md` because both are executable in
|
|
227
|
+
* this package — the briefs under `src/briefs/` are the sessions' instructions,
|
|
228
|
+
* and rewriting one of those buys more than rewriting the dispatcher does.
|
|
229
|
+
* (A checkout also carries `*.test.ts`, which the published package excludes, so
|
|
230
|
+
* a daemon started from one is watching its tests too. That is the honest
|
|
231
|
+
* answer — its code did change — and it costs nothing on a real install.)
|
|
232
|
+
*
|
|
233
|
+
* Walking and hashing the ~30 files of this package measures 0.6 ms warm, once
|
|
234
|
+
* per five-minute tick, so a tick does it inline. No cache and no mtime
|
|
235
|
+
* shortcut on purpose: a cache is a second thing that can be wrong, and mtime
|
|
236
|
+
* is the first field anyone covering their tracks restores.
|
|
237
|
+
*/
|
|
238
|
+
export function packageManifest(root: string = import.meta.dir): Map<string, string> {
|
|
239
|
+
const out = new Map<string, string>();
|
|
240
|
+
const walk = (dir: string): void => {
|
|
241
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
242
|
+
const full = join(dir, e.name);
|
|
243
|
+
if (e.isDirectory()) walk(full);
|
|
244
|
+
else if (e.isFile() && (e.name.endsWith(".ts") || e.name.endsWith(".md")))
|
|
245
|
+
out.set(relative(root, full), createHash("sha256").update(readFileSync(full)).digest("hex"));
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
walk(root);
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Labelled rather than three arrays because every consumer — the log line, the
|
|
254
|
+
* page, the test — wants one readable list of what moved.
|
|
255
|
+
*/
|
|
256
|
+
export function manifestDiff(before: Map<string, string>, after: Map<string, string>): string[] {
|
|
257
|
+
const out: string[] = [];
|
|
258
|
+
for (const [path, hash] of before) {
|
|
259
|
+
const now = after.get(path);
|
|
260
|
+
if (now === undefined) out.push(`removed ${path}`);
|
|
261
|
+
else if (now !== hash) out.push(`changed ${path}`);
|
|
262
|
+
}
|
|
263
|
+
for (const path of after.keys()) if (!before.has(path)) out.push(`added ${path}`);
|
|
264
|
+
return out.sort();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The tick's decision, split from its effects so the once-only page is a thing
|
|
269
|
+
* a test can hold.
|
|
270
|
+
*
|
|
271
|
+
* `pause` stays true on every divergent tick, deliberately: an operator who
|
|
272
|
+
* resumes without restarting gets re-paused, because the boundary is still
|
|
273
|
+
* broken. `page` asks whether this tick should *try* — the caller latches the
|
|
274
|
+
* gate with {@link markPaged} only once a page actually went out, so a Telegram
|
|
275
|
+
* outage during the one tick that noticed does not buy permanent silence.
|
|
276
|
+
*/
|
|
277
|
+
export function checkIntegrity(gate: IntegrityGate, current: Map<string, string>): IntegrityVerdict {
|
|
278
|
+
const diff = manifestDiff(gate.baseline, current);
|
|
279
|
+
if (diff.length === 0) return { diff, pause: false, page: false };
|
|
280
|
+
return { diff, pause: true, page: !gate.paged };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Latch a once-only page, after delivery is confirmed and never before. */
|
|
284
|
+
export function markPaged(gate: { paged: boolean }, delivered: boolean): void {
|
|
285
|
+
if (delivered) gate.paged = true;
|
|
286
|
+
}
|
|
287
|
+
|
|
92
288
|
// ---------------------------------------------------------------------- helpers
|
|
93
289
|
|
|
94
290
|
function log(msg: string): void {
|
|
@@ -157,13 +353,76 @@ async function swapLabel(tracker: Tracker, issue: number, from: string, to: stri
|
|
|
157
353
|
* The escalator throws when no transport is configured or Telegram rejects, and
|
|
158
354
|
* only records the dedup marker on success. A page that cannot be delivered
|
|
159
355
|
* must not take the tick down with it — log it and let the next tick retry.
|
|
356
|
+
*
|
|
357
|
+
* Returns whether it actually went out, because "page once" and "page once
|
|
358
|
+
* *successfully*" are different promises: a caller that latches a once-only
|
|
359
|
+
* gate on the attempt turns one failed delivery into permanent silence about a
|
|
360
|
+
* condition that is still true.
|
|
160
361
|
*/
|
|
161
|
-
async function safeEscalate(d: Deps, e: Escalation): Promise<
|
|
362
|
+
async function safeEscalate(d: Pick<Deps, "escalate">, e: Escalation): Promise<boolean> {
|
|
162
363
|
try {
|
|
163
364
|
await d.escalate(e);
|
|
365
|
+
return true;
|
|
164
366
|
} catch (err) {
|
|
165
367
|
log(`escalation for #${e.issue} could not be delivered: ${errText(err)}`);
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* What a salvage attempt contributes to the escalation: where the work went, or
|
|
374
|
+
* that it went nowhere. Split from the effects below for the same reason
|
|
375
|
+
* `checkIntegrity` is — this wording is the whole thing a human acts on, so it
|
|
376
|
+
* is worth a test holding it, and the sha in it is the only pointer to work
|
|
377
|
+
* that no longer has any other copy.
|
|
378
|
+
*/
|
|
379
|
+
export function salvageLines(outcome: SalvageOutcome, worktree: string): string[] {
|
|
380
|
+
const kept = `Worktree kept for inspection: ${worktree}`;
|
|
381
|
+
|
|
382
|
+
if (outcome.kind === "nothing") return [`${kept} — nothing uncommitted to salvage`];
|
|
383
|
+
|
|
384
|
+
if (outcome.kind === "failed") {
|
|
385
|
+
return [
|
|
386
|
+
`WIP SALVAGE FAILED: ${outcome.error}`,
|
|
387
|
+
`Uncommitted work in ${worktree} is the only copy of it, and the next attempt removes that tree.`,
|
|
388
|
+
];
|
|
166
389
|
}
|
|
390
|
+
|
|
391
|
+
return [
|
|
392
|
+
`WIP committed to ${outcome.branch} @ ${outcome.sha}` +
|
|
393
|
+
(outcome.pushed
|
|
394
|
+
? " and pushed — the work outlives this worktree"
|
|
395
|
+
: ` but NOT pushed (${outcome.pushError ?? "no reason given"}) — it lives only in this host's mirror`),
|
|
396
|
+
kept,
|
|
397
|
+
];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Commits and pushes whatever a dead run left uncommitted, logs the outcome and
|
|
402
|
+
* returns the escalation lines that say where that work now lives.
|
|
403
|
+
*
|
|
404
|
+
* Only ever called on a non-graceful end — a cap kill, a crashed session, a
|
|
405
|
+
* dispatch error. A `blocked` run stopped on purpose, with turns still in hand
|
|
406
|
+
* and a brief that tells it to report rather than push, so nothing is committed
|
|
407
|
+
* behind its back. The rest never got the chance: the kill is external and
|
|
408
|
+
* lands mid-edit, in the tree the next attempt removes `--force`.
|
|
409
|
+
*/
|
|
410
|
+
async function salvage(
|
|
411
|
+
issue: number,
|
|
412
|
+
attempt: number,
|
|
413
|
+
reason: string,
|
|
414
|
+
worktree: string,
|
|
415
|
+
): Promise<string[]> {
|
|
416
|
+
const lines = salvageLines(await salvageWip(worktree, issue, attempt, reason), worktree);
|
|
417
|
+
log(`#${issue} salvage: ${lines.join(" ")}`);
|
|
418
|
+
return lines;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** How a run's end is named — in the salvage commit, and to whoever reads it. */
|
|
422
|
+
function endedBy(killedBy: KilledBy | undefined): string {
|
|
423
|
+
if (killedBy === "turns") return "the turns cap";
|
|
424
|
+
if (killedBy === "wallclock") return "the wall-clock cap";
|
|
425
|
+
return "a failed run";
|
|
167
426
|
}
|
|
168
427
|
|
|
169
428
|
async function buildBrief(
|
|
@@ -201,6 +460,10 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
201
460
|
|
|
202
461
|
let claimed = false;
|
|
203
462
|
let run: RunRecord | undefined;
|
|
463
|
+
// Hoisted out of the try so the catch path can still name the tree: a crash
|
|
464
|
+
// mid-dispatch is one of the non-graceful ends whose uncommitted work has to
|
|
465
|
+
// be salvaged too, and it is the path least likely to have committed first.
|
|
466
|
+
let worktreePath: string | undefined;
|
|
204
467
|
|
|
205
468
|
try {
|
|
206
469
|
// Claim on the tracker FIRST, before any local work. The label — not the
|
|
@@ -233,7 +496,7 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
233
496
|
const mirrorPath = mirrorPathFor(r.repo, project.mirrorRoot);
|
|
234
497
|
await removeWorktree(mirrorPath, worktreePathFor(project.workspaceRoot, issue));
|
|
235
498
|
|
|
236
|
-
|
|
499
|
+
worktreePath = await addWorktree(
|
|
237
500
|
r.repo,
|
|
238
501
|
project.mirrorRoot,
|
|
239
502
|
project.workspaceRoot,
|
|
@@ -257,6 +520,11 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
257
520
|
sessionDir,
|
|
258
521
|
...(project.workerModel === undefined ? {} : { model: project.workerModel }),
|
|
259
522
|
onTurn: (n) => store.updateRun(runId, { turns: n }),
|
|
523
|
+
// Recorded the moment the session opens its transcript, not when the run
|
|
524
|
+
// ends: `omp-conductor tail` resolves an issue to a file through this row,
|
|
525
|
+
// and a path written at completion is a path nobody can follow live. The
|
|
526
|
+
// completion-time update below writes the same value again, harmlessly.
|
|
527
|
+
onSessionFile: (f) => store.updateRun(runId, { sessionFile: f }),
|
|
260
528
|
});
|
|
261
529
|
|
|
262
530
|
// A configured model the harness could not honour means this run was done by
|
|
@@ -287,6 +555,9 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
287
555
|
});
|
|
288
556
|
} else if (result.state === "failed" || result.state === "killed") {
|
|
289
557
|
await swapLabel(tracker, issue, inProgress, project.stateLabels.failed);
|
|
558
|
+
// Before the escalation is composed, so it can say where the work went —
|
|
559
|
+
// and long before the next attempt provisions over this tree.
|
|
560
|
+
const salvaged = await salvage(issue, attempt, endedBy(result.killedBy), worktreePath);
|
|
290
561
|
await safeEscalate(d, {
|
|
291
562
|
tier: 1,
|
|
292
563
|
project: project.name,
|
|
@@ -301,7 +572,7 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
301
572
|
detail: [
|
|
302
573
|
`${r.issue.title}`,
|
|
303
574
|
r.issue.url,
|
|
304
|
-
|
|
575
|
+
...salvaged,
|
|
305
576
|
`Session: ${result.sessionFile ?? "(no transcript)"}`,
|
|
306
577
|
"",
|
|
307
578
|
result.report,
|
|
@@ -335,28 +606,166 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
335
606
|
log(`#${issue} could not be relabelled: ${errText(relabelErr)}`);
|
|
336
607
|
}
|
|
337
608
|
}
|
|
609
|
+
// A crash lands anywhere, including mid-edit in a tree holding the only
|
|
610
|
+
// copy of real work. Nothing else on this path so much as looks at it.
|
|
611
|
+
const salvaged =
|
|
612
|
+
worktreePath === undefined
|
|
613
|
+
? []
|
|
614
|
+
: await salvage(issue, attempt, "a dispatch error", worktreePath);
|
|
615
|
+
|
|
338
616
|
await safeEscalate(d, {
|
|
339
617
|
tier: 1,
|
|
340
618
|
project: project.name,
|
|
341
619
|
issue,
|
|
342
620
|
runId: run?.id,
|
|
343
621
|
summary: `#${issue} could not be dispatched on attempt ${attempt}`,
|
|
344
|
-
detail,
|
|
622
|
+
detail: salvaged.length === 0 ? detail : [detail, "", ...salvaged].join("\n"),
|
|
345
623
|
});
|
|
346
624
|
// The worktree, if one was created, is deliberately left in place: this is
|
|
347
|
-
// a failure path.
|
|
625
|
+
// a failure path, and whatever it still held is now a commit on the branch.
|
|
348
626
|
}
|
|
349
627
|
}
|
|
350
628
|
|
|
629
|
+
// -------------------------------------------------------------------- admission
|
|
630
|
+
|
|
631
|
+
/** A candidate cleared for dispatch, with the attempt number it will run as. */
|
|
632
|
+
export interface Admission {
|
|
633
|
+
r: Routed;
|
|
634
|
+
attempt: number;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Which routed candidates get a worker this tick — in queue order, never more
|
|
639
|
+
* than `slots` of them.
|
|
640
|
+
*
|
|
641
|
+
* Exported so the admission rules can be pinned without spawning a worker.
|
|
642
|
+
* Every one of them exists because of a live incident, and each guards a
|
|
643
|
+
* different way the same issue gets worked twice.
|
|
644
|
+
*
|
|
645
|
+
* Takes the slice of `Deps` it actually reads rather than the whole thing: what
|
|
646
|
+
* admission is allowed to consult is the point of the function, and a `Deps`
|
|
647
|
+
* that grows a field has no business breaking these tests.
|
|
648
|
+
*/
|
|
649
|
+
export async function admitCandidates(
|
|
650
|
+
d: Pick<Deps, "project" | "caps" | "tracker" | "store" | "escalate">,
|
|
651
|
+
routed: Routed[],
|
|
652
|
+
slots: number,
|
|
653
|
+
): Promise<Admission[]> {
|
|
654
|
+
const { project, caps, tracker, store } = d;
|
|
655
|
+
const busy = new Set(store.activeRuns(project.name).map((r) => r.issue));
|
|
656
|
+
|
|
657
|
+
const admitted: Admission[] = [];
|
|
658
|
+
for (const r of routed) {
|
|
659
|
+
if (admitted.length >= slots) break;
|
|
660
|
+
if (busy.has(r.issue.number)) continue;
|
|
661
|
+
|
|
662
|
+
const prior = store.attemptsFor(project.name, r.issue.number);
|
|
663
|
+
if (prior >= caps.maxAttemptsPerIssue) {
|
|
664
|
+
await safeEscalate(d, {
|
|
665
|
+
tier: 1,
|
|
666
|
+
project: project.name,
|
|
667
|
+
issue: r.issue.number,
|
|
668
|
+
summary: `#${r.issue.number} has used all ${caps.maxAttemptsPerIssue} attempts`,
|
|
669
|
+
detail: [
|
|
670
|
+
r.issue.title,
|
|
671
|
+
r.issue.url,
|
|
672
|
+
"Another attempt almost always means the issue itself is underspecified.",
|
|
673
|
+
"Rewrite the acceptance criteria, or take it off the queue.",
|
|
674
|
+
].join("\n"),
|
|
675
|
+
});
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// The busy set is built from run rows, so it can only speak for work this
|
|
680
|
+
// database recorded. Work pushed before this store existed — a migration, a
|
|
681
|
+
// wiped or relocated state dir, a restore onto a new host — looks exactly
|
|
682
|
+
// like fresh work, and a worker sent at it re-implements a finished PR. The
|
|
683
|
+
// tracker is the only party that remembers, so it is asked. The cost is
|
|
684
|
+
// bounded by free slots, not by queue depth: the call sits behind the two
|
|
685
|
+
// cheap local filters and the loop stops once the slots are full.
|
|
686
|
+
let closer: string | undefined;
|
|
687
|
+
try {
|
|
688
|
+
closer = await tracker.openCloserFor(r.issue.number);
|
|
689
|
+
} catch (err) {
|
|
690
|
+
// Fail closed, per candidate. An API error means "unknown whether
|
|
691
|
+
// finished work exists", and admitting on unknown recreates precisely the
|
|
692
|
+
// duplicate-work failure this guard exists to kill: the worst case of
|
|
693
|
+
// holding is a five-minute delay, the worst case of admitting is a burned
|
|
694
|
+
// attempt and a second PR on the same issue. Holding one candidate rather
|
|
695
|
+
// than aborting the loop is what keeps a transient GitHub failure from
|
|
696
|
+
// deadlocking the whole dispatcher; the next tick retries by itself.
|
|
697
|
+
log(`#${r.issue.number} held: open-PR check failed (${errText(err)}) — retrying next tick`);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (closer !== undefined) {
|
|
701
|
+
log(`#${r.issue.number} skipped: open PR ${closer} already closes it`);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
admitted.push({ r, attempt: prior + 1 });
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return admitted;
|
|
709
|
+
}
|
|
710
|
+
|
|
351
711
|
// ----------------------------------------------------------------------- a tick
|
|
352
712
|
|
|
353
713
|
async function tick(d: Deps): Promise<void> {
|
|
714
|
+
// Before the pause check, deliberately. This one is not about dispatch: the
|
|
715
|
+
// orchestrator is a different process, and it can be wedged while this fleet
|
|
716
|
+
// is paused — which is exactly the state the reference fleet was in when the
|
|
717
|
+
// failure happened. A pause silences claiming, not the operator's right to
|
|
718
|
+
// know their supervising session stopped reading its queue.
|
|
719
|
+
await watchOrchestrator(d);
|
|
720
|
+
|
|
354
721
|
// A paused fleet claims nothing. Checked first so pausing takes effect on the
|
|
355
722
|
// next tick without signalling the process.
|
|
356
723
|
if (isPaused()) return;
|
|
357
724
|
|
|
358
725
|
const { project, caps, store } = d;
|
|
359
726
|
|
|
727
|
+
// "Nobody patches the running conductor" is a hard boundary in both briefs —
|
|
728
|
+
// which makes it prompt text, and prompt text is a request. This is the half
|
|
729
|
+
// that does not negotiate: the package that dispatched the last worker must
|
|
730
|
+
// still be the package on disk, or nothing else this tick does is
|
|
731
|
+
// attributable. A legitimate deploy never trips it, because installing a new
|
|
732
|
+
// build and restarting the unit re-records the baseline from the new files;
|
|
733
|
+
// only an edit *underneath* a live daemon diverges from it.
|
|
734
|
+
const integrity = checkIntegrity(d.integrity, packageManifest());
|
|
735
|
+
if (integrity.pause) {
|
|
736
|
+
const shown = integrity.diff.slice(0, INTEGRITY_SAMPLE);
|
|
737
|
+
log(
|
|
738
|
+
`ERROR: the installed conductor changed under this daemon — ${integrity.diff.length} file(s) differ ` +
|
|
739
|
+
`(${shown.join(", ")}${integrity.diff.length > shown.length ? ", …" : ""}) — pausing`,
|
|
740
|
+
);
|
|
741
|
+
setPaused(true);
|
|
742
|
+
if (integrity.page) {
|
|
743
|
+
const delivered = await safeEscalate(d, {
|
|
744
|
+
tier: 2,
|
|
745
|
+
project: project.name,
|
|
746
|
+
issue: NO_ISSUE,
|
|
747
|
+
// Dated for the same reason the spend cap is: the dedup key is the
|
|
748
|
+
// summary, and a second tamper months later must not be swallowed as a
|
|
749
|
+
// repeat of the first.
|
|
750
|
+
summary:
|
|
751
|
+
`Installed conductor changed under a running daemon on ${new Date().toISOString().slice(0, 10)}: ` +
|
|
752
|
+
`${integrity.diff.length} file(s) differ (first: ${integrity.diff[0]}) — ${project.name} is paused`,
|
|
753
|
+
detail: [
|
|
754
|
+
`Package root: ${import.meta.dir}`,
|
|
755
|
+
...shown,
|
|
756
|
+
...(integrity.diff.length > shown.length ? [`… and ${integrity.diff.length - shown.length} more`] : []),
|
|
757
|
+
"",
|
|
758
|
+
"If you deployed a new build, restart the daemon — the restart re-records the baseline.",
|
|
759
|
+
"If you did not, the host edited itself while it was dispatching work: treat every run since",
|
|
760
|
+
"the last known-good restart as unattributable before resuming.",
|
|
761
|
+
"`omp-conductor resume` alone will not hold — the next tick re-pauses while the files differ.",
|
|
762
|
+
].join("\n"),
|
|
763
|
+
});
|
|
764
|
+
markPaged(d.integrity, delivered);
|
|
765
|
+
}
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
|
|
360
769
|
// route() filters the queue through isEligible() itself, so anything already
|
|
361
770
|
// carrying a state label is gone before it gets here.
|
|
362
771
|
const { routed, unroutable } = route(await d.tracker.listReady(), project);
|
|
@@ -402,41 +811,18 @@ async function tick(d: Deps): Promise<void> {
|
|
|
402
811
|
return;
|
|
403
812
|
}
|
|
404
813
|
|
|
405
|
-
|
|
406
|
-
|
|
814
|
+
// Two different questions, deliberately two queries. Capacity counts worker
|
|
815
|
+
// *processes*, so a green PR awaiting a human merge must not consume a slot —
|
|
816
|
+
// two of those would otherwise stop the fleet. That same PR's *issue* must
|
|
817
|
+
// still be occupied, which is what `admitCandidates`' busy set is for.
|
|
818
|
+
const live = store.liveRuns(project.name);
|
|
819
|
+
const slots = caps.maxConcurrentWorkers - live.length;
|
|
407
820
|
if (slots <= 0) {
|
|
408
|
-
log(`at capacity: ${
|
|
821
|
+
log(`at capacity: ${live.length}/${caps.maxConcurrentWorkers} workers`);
|
|
409
822
|
return;
|
|
410
823
|
}
|
|
411
824
|
|
|
412
|
-
|
|
413
|
-
// merge, so this also stops a second attempt landing on a live PR.
|
|
414
|
-
const busy = new Set(active.map((r) => r.issue));
|
|
415
|
-
|
|
416
|
-
const admitted: { r: Routed; attempt: number }[] = [];
|
|
417
|
-
for (const r of routed) {
|
|
418
|
-
if (admitted.length >= slots) break;
|
|
419
|
-
if (busy.has(r.issue.number)) continue;
|
|
420
|
-
|
|
421
|
-
const prior = store.attemptsFor(project.name, r.issue.number);
|
|
422
|
-
if (prior >= caps.maxAttemptsPerIssue) {
|
|
423
|
-
await safeEscalate(d, {
|
|
424
|
-
tier: 1,
|
|
425
|
-
project: project.name,
|
|
426
|
-
issue: r.issue.number,
|
|
427
|
-
summary: `#${r.issue.number} has used all ${caps.maxAttemptsPerIssue} attempts`,
|
|
428
|
-
detail: [
|
|
429
|
-
r.issue.title,
|
|
430
|
-
r.issue.url,
|
|
431
|
-
"Another attempt almost always means the issue itself is underspecified.",
|
|
432
|
-
"Rewrite the acceptance criteria, or take it off the queue.",
|
|
433
|
-
].join("\n"),
|
|
434
|
-
});
|
|
435
|
-
continue;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
admitted.push({ r, attempt: prior + 1 });
|
|
439
|
-
}
|
|
825
|
+
const admitted = await admitCandidates(d, routed, slots);
|
|
440
826
|
|
|
441
827
|
if (admitted.length === 0) return;
|
|
442
828
|
|
|
@@ -453,7 +839,10 @@ export interface StatusSnapshot {
|
|
|
453
839
|
stateDir: string;
|
|
454
840
|
paused: boolean;
|
|
455
841
|
caps: Caps;
|
|
842
|
+
/** Occupied issues: live workers plus green PRs awaiting a human merge. */
|
|
456
843
|
activeRuns: RunRecord[];
|
|
844
|
+
/** Runs backed by a worker process — the number capacity compares against. */
|
|
845
|
+
liveWorkers: number;
|
|
457
846
|
runsToday: number;
|
|
458
847
|
spendTodayUsd: number;
|
|
459
848
|
}
|
|
@@ -474,6 +863,7 @@ export function statusSnapshot(project?: string): StatusSnapshot {
|
|
|
474
863
|
paused: isPaused(),
|
|
475
864
|
caps: resolveCaps(p, cfg.defaults),
|
|
476
865
|
activeRuns: store.activeRuns(p.name),
|
|
866
|
+
liveWorkers: store.liveRuns(p.name).length,
|
|
477
867
|
runsToday: store.runsStartedSince(p.name, since),
|
|
478
868
|
spendTodayUsd: store.spendSince(p.name, since),
|
|
479
869
|
};
|
|
@@ -489,7 +879,7 @@ export function formatStatus(s: StatusSnapshot): string {
|
|
|
489
879
|
`state ${s.stateDir}`,
|
|
490
880
|
"",
|
|
491
881
|
"caps",
|
|
492
|
-
` workers ${s.
|
|
882
|
+
` workers ${s.liveWorkers} / ${s.caps.maxConcurrentWorkers}`,
|
|
493
883
|
` issues today ${s.runsToday}`,
|
|
494
884
|
` spend today $${s.spendTodayUsd.toFixed(2)} / $${s.caps.dailySpendUsd.toFixed(2)}`,
|
|
495
885
|
` worker max turns ${s.caps.workerMaxTurns}`,
|
|
@@ -564,6 +954,38 @@ export function armConductor(): void {
|
|
|
564
954
|
setPaused(false);
|
|
565
955
|
}
|
|
566
956
|
|
|
957
|
+
/**
|
|
958
|
+
* Settles the runs a previous daemon process left in flight.
|
|
959
|
+
*
|
|
960
|
+
* A `claimed` or `running` row is a promise that a worker exists in *some*
|
|
961
|
+
* process. This is called from a freshly started daemon, so when no other
|
|
962
|
+
* daemon is alive every such row is a worker that died with the previous
|
|
963
|
+
* process. Left "active", those rows deadlock admission forever: the slot
|
|
964
|
+
* count reads full while nothing runs, and the fleet looks busy doing nothing
|
|
965
|
+
* (found live, after a host restart killed two workers mid-run).
|
|
966
|
+
*
|
|
967
|
+
* Only the rows change. The issue keeps its in-progress label — that label is
|
|
968
|
+
* the crash guard against double-dispatch, and deciding what a dead worker's
|
|
969
|
+
* remains are worth (an open PR? unpushed commits? a dirty tree?) is the
|
|
970
|
+
* orchestrator's drain-duty judgement, not something to automate here. The
|
|
971
|
+
* rows also keep counting toward `maxAttemptsPerIssue`, so a loop of deaths
|
|
972
|
+
* still escalates instead of retrying forever.
|
|
973
|
+
*
|
|
974
|
+
* `pushed-green` rows are deliberately left alone: they hold no process — they
|
|
975
|
+
* are finished work waiting on a human merge, and they must keep occupying the
|
|
976
|
+
* issue so a second attempt cannot land on a live PR.
|
|
977
|
+
*/
|
|
978
|
+
export function reconcileOrphanedRuns(store: Store, project: string): RunRecord[] {
|
|
979
|
+
// Live runs only: `pushed-green` holds no process, so it cannot be orphaned by
|
|
980
|
+
// a process dying — it is finished work waiting on a human merge.
|
|
981
|
+
const stale = store.liveRuns(project);
|
|
982
|
+
const endedAt = Date.now();
|
|
983
|
+
for (const r of stale) {
|
|
984
|
+
store.updateRun(r.id, { state: "orphaned", endedAt });
|
|
985
|
+
}
|
|
986
|
+
return stale;
|
|
987
|
+
}
|
|
988
|
+
|
|
567
989
|
// ------------------------------------------------------------------- the daemon
|
|
568
990
|
|
|
569
991
|
export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
@@ -573,6 +995,30 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
573
995
|
const store = openStore(dbPath());
|
|
574
996
|
const tracker = makeTracker(project);
|
|
575
997
|
|
|
998
|
+
// Recorded here, before a single tick runs, so that the deploy an operator
|
|
999
|
+
// *means* to do never trips the tripwire: installing a new build and
|
|
1000
|
+
// restarting the unit re-records this from the new files. What it catches is
|
|
1001
|
+
// the other thing — the package changing while the daemon that dispatches
|
|
1002
|
+
// work is holding it open, whether that is a worker that wandered out of its
|
|
1003
|
+
// worktree or a human editing the live install "just to test something".
|
|
1004
|
+
const integrity: IntegrityGate = { baseline: packageManifest(), paged: false };
|
|
1005
|
+
log(`package integrity baseline: ${integrity.baseline.size} files under ${import.meta.dir}`);
|
|
1006
|
+
|
|
1007
|
+
// Before the first tick, settle what the last process left behind — unless
|
|
1008
|
+
// another daemon is alive (a foreground `daemon --once` beside a running
|
|
1009
|
+
// daemon must not orphan that daemon's real, live workers).
|
|
1010
|
+
const alive = livingDaemon();
|
|
1011
|
+
if (alive === undefined || alive.pid === process.pid) {
|
|
1012
|
+
for (const r of reconcileOrphanedRuns(store, project.name)) {
|
|
1013
|
+
log(
|
|
1014
|
+
`#${r.issue} orphaned by a previous daemon (attempt ${r.attempt}, was ${r.state}, worktree ${r.worktree}) — ` +
|
|
1015
|
+
`slot freed; the ${project.stateLabels.inProgress} label stays until the orchestrator triages what the worker left`,
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
} else {
|
|
1019
|
+
log(`skipping orphan reconciliation: daemon pid ${alive.pid} is alive and owns the active runs`);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
576
1022
|
// Standing orders. The orchestrator holds none of this file's context, so
|
|
577
1023
|
// everything it needs to act — which tracker, which labels, what the fleet
|
|
578
1024
|
// does — has to be said once, in words.
|
|
@@ -587,8 +1033,16 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
587
1033
|
"fails twice, its gates stay red, its branch conflicts, or a tripwire fires.",
|
|
588
1034
|
"Your job when that happens: re-brief the issue (comment what the next worker must do",
|
|
589
1035
|
`differently, then put ${project.queueLabel} back on it), file follow-up issues, or promote to`,
|
|
590
|
-
"tier 2 and let the human decide.
|
|
591
|
-
|
|
1036
|
+
"tier 2 and let the human decide.",
|
|
1037
|
+
// Worded from `authority.merge` rather than fixed, so the standing orders
|
|
1038
|
+
// and the Releases section of the rendered brief cannot disagree about who
|
|
1039
|
+
// is holding the merge button. The daemon still merges nothing itself.
|
|
1040
|
+
project.authority.merge === "orchestrator"
|
|
1041
|
+
? "You never edit product code or push a branch — a worker session does that. Merging is yours: one PR at " +
|
|
1042
|
+
"a time, freshness-checked against the base branch, per the Releases section of your ORCHESTRATOR.md."
|
|
1043
|
+
: "You never edit product code, push a branch, or merge a PR — a worker session edits and pushes, and a " +
|
|
1044
|
+
"human merges.",
|
|
1045
|
+
"Handle each escalation below before the next one.",
|
|
592
1046
|
].join("\n");
|
|
593
1047
|
|
|
594
1048
|
// One orchestrator per daemon run, not per tick: it is a persistent session
|
|
@@ -597,17 +1051,25 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
597
1051
|
// directory, deliberately not a checkout — the orchestrator re-briefs workers
|
|
598
1052
|
// and talks to the tracker, it does not edit product code.
|
|
599
1053
|
let orchestrator: OrchestratorHandle | undefined;
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
1054
|
+
if (project.escalation.orchestrator === "external") {
|
|
1055
|
+
// An operator already runs the brain — typically a visible TUI session that
|
|
1056
|
+
// drains `blocked`/`failed` off the tracker as one of its standing duties.
|
|
1057
|
+
// Starting a second one here would re-triage the same issues from a
|
|
1058
|
+
// transcript nobody is watching, and the two would undo each other.
|
|
1059
|
+
log("orchestrator: external — tier-1 escalations post as issue comments for the external session's drain duty");
|
|
1060
|
+
} else {
|
|
1061
|
+
try {
|
|
1062
|
+
orchestrator = await startOrchestrator({ cwd: stateDir(), brief });
|
|
1063
|
+
const transcript = orchestrator.sessionFile();
|
|
1064
|
+
log(`orchestrator session ready${transcript === undefined ? "" : ` · ${transcript}`}`);
|
|
1065
|
+
} catch (err) {
|
|
1066
|
+
// Loudly, but not fatally: tier-1 escalations degrade to issue comments,
|
|
1067
|
+
// which a human still reads. A dispatcher that refuses to run because its
|
|
1068
|
+
// re-briefing channel is down helps nobody.
|
|
1069
|
+
log(
|
|
1070
|
+
`WARNING: orchestrator session failed to start; tier-1 escalations will fall back to issue comments: ${errText(err)}`,
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
611
1073
|
}
|
|
612
1074
|
|
|
613
1075
|
const escalator = createEscalator(project, tracker, store, orchestrator);
|
|
@@ -617,6 +1079,10 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
617
1079
|
tracker,
|
|
618
1080
|
store,
|
|
619
1081
|
escalate: (e) => escalator.escalate(e),
|
|
1082
|
+
integrity,
|
|
1083
|
+
// Fresh per daemon run, like the integrity gate: a restart is entitled to
|
|
1084
|
+
// page again about a stall that is still on disk.
|
|
1085
|
+
stall: { paged: false },
|
|
620
1086
|
};
|
|
621
1087
|
|
|
622
1088
|
if (o.once) {
|