omp-conductor 0.14.0 → 0.15.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 +336 -169
- package/package.json +8 -5
- package/schema/config.schema.json +609 -0
- package/src/board.ts +19 -32
- package/src/brief-upgrade.ts +1 -1
- package/src/briefs/orchestrator.md +9 -5
- package/src/briefs/policy.md +4 -4
- package/src/briefs/probes/gates.md +51 -0
- package/src/briefs/probes/project-context.md +59 -0
- package/src/briefs/probes/release-procedure.md +81 -0
- package/src/cli.ts +235 -199
- package/src/config-schema.ts +352 -0
- package/src/config.ts +1046 -796
- package/src/confinement.ts +54 -0
- package/src/daemon.ts +442 -374
- package/src/escalate.ts +43 -3
- package/src/fleet.ts +317 -43
- package/src/generate-schema.ts +21 -0
- package/src/graph.ts +3 -3
- package/src/host.ts +16 -0
- package/src/omp.ts +21 -1
- package/src/orchestrator-tick.ts +298 -39
- package/src/privileged.ts +264 -0
- package/src/reports.ts +1 -1
- package/src/session-host.ts +3 -0
- package/src/setup-host.ts +225 -25
- package/src/setup-install.ts +320 -0
- package/src/setup-probe.ts +412 -0
- package/src/{plugin.ts → setup-wizard.ts} +790 -465
- package/src/setup.ts +264 -20
- package/src/types.ts +2 -2
- package/src/upgrade.ts +44 -10
- package/src/verbs/server.ts +32 -9
- package/src/wizard-ui.ts +249 -0
- package/src/worker.ts +6 -1
- package/skills/conductor-onboarding/SKILL.md +0 -748
- package/skills/conductor-update/SKILL.md +0 -51
package/src/daemon.ts
CHANGED
|
@@ -29,13 +29,14 @@ import { createEscalator, escalationIssueRef } from "./escalate.ts";
|
|
|
29
29
|
import { pendingCodeGraph, probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
30
30
|
import { graphHint } from "./graph.ts";
|
|
31
31
|
import { livingDaemon } from "./lifecycle.ts";
|
|
32
|
-
import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
|
|
32
|
+
import { requestImmediateTick, resolveTickConfigCwd, STALL_MARKER_FILE } from "./orchestrator-tick.ts";
|
|
33
33
|
import { startOrchestrator } from "./orchestrator.ts";
|
|
34
34
|
import type { OrchestratorHandle } from "./orchestrator.ts";
|
|
35
35
|
import {
|
|
36
36
|
createReportOutbox,
|
|
37
37
|
enqueueAvailableHeldNotices,
|
|
38
38
|
formatOpenReports,
|
|
39
|
+
type ReportOutbox,
|
|
39
40
|
} from "./reports.ts";
|
|
40
41
|
import {
|
|
41
42
|
recordReleaseBlock,
|
|
@@ -226,10 +227,10 @@ export function verbDeps(d: Pick<Deps, "project" | "store" | "tracker" | "verbAc
|
|
|
226
227
|
tracker: d.tracker,
|
|
227
228
|
actions: d.verbActions ?? githubVerbActions(d.project),
|
|
228
229
|
fleetStop: () =>
|
|
229
|
-
isPaused()
|
|
230
|
-
? "claiming is paused for this fleet (omp-conductor
|
|
230
|
+
isPaused(d.project.name)
|
|
231
|
+
? "claiming is paused for this fleet (omp-conductor hold or stop)"
|
|
231
232
|
: undefined,
|
|
232
|
-
pausedAt,
|
|
233
|
+
pausedAt: () => pausedAt(d.project.name),
|
|
233
234
|
log,
|
|
234
235
|
now: () => Date.now(),
|
|
235
236
|
chain: { readBaseChain },
|
|
@@ -375,80 +376,92 @@ export async function watchOrchestrator(d: Deps, now = Date.now()): Promise<void
|
|
|
375
376
|
}
|
|
376
377
|
|
|
377
378
|
/**
|
|
378
|
-
* Pause is a file rather than process state on purpose: `omp-conductor
|
|
379
|
-
*
|
|
379
|
+
* Pause is a file rather than process state on purpose: `omp-conductor hold`
|
|
380
|
+
* runs in a different process from the daemon, and a flag
|
|
380
381
|
* on disk needs no IPC and survives a restart. A daemon that crashed while
|
|
381
382
|
* paused comes back paused.
|
|
382
383
|
*/
|
|
383
|
-
export function
|
|
384
|
-
return
|
|
384
|
+
export function pausedPath(project?: string): string {
|
|
385
|
+
return join(stateDir(), project === undefined ? "paused" : `paused-${project}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function activePausePaths(project?: string): string[] {
|
|
389
|
+
const paths = project === undefined ? [pausedPath()] : [pausedPath(project), pausedPath()];
|
|
390
|
+
return paths.filter((path) => existsSync(path));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function isPaused(project?: string): boolean {
|
|
394
|
+
return activePausePaths(project).length !== 0;
|
|
385
395
|
}
|
|
386
396
|
|
|
387
397
|
/**
|
|
388
|
-
* The epoch-ms timestamp at which the current pause began
|
|
389
|
-
* sentinel
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
* fail-closed because a run admitted before an *unknown* pause cannot be proven
|
|
393
|
-
* innocent. {@link isPaused} is the authority on *whether*; this answers *since
|
|
394
|
-
* when*.
|
|
398
|
+
* The epoch-ms timestamp at which the current pause began. A project pause also
|
|
399
|
+
* observes the legacy bare sentinel, which pauses every project. If any active
|
|
400
|
+
* sentinel is unreadable or unparseable, the timestamp is unknown so callers
|
|
401
|
+
* continue to fail closed.
|
|
395
402
|
*/
|
|
396
|
-
export function pausedAt(): number | undefined {
|
|
397
|
-
const
|
|
398
|
-
if (
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
403
|
+
export function pausedAt(project?: string): number | undefined {
|
|
404
|
+
const paths = activePausePaths(project);
|
|
405
|
+
if (paths.length === 0) return undefined;
|
|
406
|
+
const times: number[] = [];
|
|
407
|
+
for (const path of paths) {
|
|
408
|
+
try {
|
|
409
|
+
const first = readFileSync(path, "utf8").split("\n")[0]?.trim();
|
|
410
|
+
if (first === undefined || first === "") return undefined;
|
|
411
|
+
const time = Date.parse(first);
|
|
412
|
+
if (Number.isNaN(time)) return undefined;
|
|
413
|
+
times.push(time);
|
|
414
|
+
} catch {
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
408
417
|
}
|
|
418
|
+
return Math.min(...times);
|
|
409
419
|
}
|
|
410
420
|
|
|
411
421
|
/**
|
|
412
|
-
* Who paused the
|
|
413
|
-
*
|
|
414
|
-
* file has no (parseable) line 2. Provenance lives on its own line so line 1
|
|
415
|
-
* stays a pure ISO timestamp that {@link pausedAt} can `Date.parse`; the `armed
|
|
416
|
-
* <ISO> owner=<id>` marker `armTicks` writes is the same one-key-per-line
|
|
417
|
-
* precedent.
|
|
422
|
+
* Who paused the project and why. Per-project provenance wins when both its
|
|
423
|
+
* sentinel and the legacy all-project sentinel are active.
|
|
418
424
|
*/
|
|
419
|
-
export function pauseProvenance(
|
|
420
|
-
|
|
421
|
-
|
|
425
|
+
export function pauseProvenance(
|
|
426
|
+
project?: string,
|
|
427
|
+
): { source: string; reason?: string } | undefined {
|
|
428
|
+
const paths =
|
|
429
|
+
project === undefined
|
|
430
|
+
? [pausedPath()]
|
|
431
|
+
: [pausedPath(project), pausedPath()];
|
|
432
|
+
const path = paths.find((candidate) => existsSync(candidate));
|
|
433
|
+
if (path === undefined) return undefined;
|
|
422
434
|
try {
|
|
423
|
-
const second = readFileSync(
|
|
435
|
+
const second = readFileSync(path, "utf8").split("\n")[1];
|
|
424
436
|
if (second === undefined) return undefined;
|
|
425
437
|
const match = /^source=(\S+)(?: reason="(.*)")?$/.exec(second.trim());
|
|
426
438
|
if (match === null) return undefined;
|
|
427
|
-
const source = match[1]!;
|
|
439
|
+
const source = match[1]!;
|
|
428
440
|
const reason = match[2];
|
|
429
441
|
return { source, ...(reason === undefined ? {} : { reason }) };
|
|
430
442
|
} catch {
|
|
431
|
-
// Unreadable sentinel: no provenance to name. Completion mutations still
|
|
432
|
-
// fail closed because the pause time is unknown.
|
|
433
443
|
return undefined;
|
|
434
444
|
}
|
|
435
445
|
}
|
|
436
446
|
|
|
437
|
-
export function setPaused(
|
|
438
|
-
|
|
447
|
+
export function setPaused(
|
|
448
|
+
v: boolean,
|
|
449
|
+
why?: { source: string; reason?: string },
|
|
450
|
+
project?: string,
|
|
451
|
+
): void {
|
|
452
|
+
const path = pausedPath(project);
|
|
439
453
|
if (v) {
|
|
440
|
-
mkdirSync(dirname(
|
|
454
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
441
455
|
const line1 = `${new Date().toISOString()}\n`;
|
|
442
456
|
if (why === undefined) {
|
|
443
|
-
writeFileSync(
|
|
457
|
+
writeFileSync(path, line1);
|
|
444
458
|
} else {
|
|
445
|
-
// Quotes are stripped before embedding so a reason cannot break out of
|
|
446
|
-
// the `reason="..."` field of line 2.
|
|
447
459
|
const reason = why.reason === undefined ? "" : ` reason="${why.reason.replaceAll('"', "")}"`;
|
|
448
|
-
writeFileSync(
|
|
460
|
+
writeFileSync(path, `${line1}source=${why.source}${reason}\n`);
|
|
449
461
|
}
|
|
450
462
|
} else {
|
|
451
|
-
rmSync(
|
|
463
|
+
rmSync(path, { force: true });
|
|
464
|
+
if (project !== undefined) rmSync(pausedPath(), { force: true });
|
|
452
465
|
}
|
|
453
466
|
}
|
|
454
467
|
|
|
@@ -674,8 +687,8 @@ async function reactToProviderCredit(
|
|
|
674
687
|
sessionFile: string | undefined,
|
|
675
688
|
): Promise<void> {
|
|
676
689
|
const { project } = d;
|
|
677
|
-
const alreadyPaused = isPaused();
|
|
678
|
-
if (!alreadyPaused) setPaused(true, { source: "provider-credit", reason: message });
|
|
690
|
+
const alreadyPaused = isPaused(project.name);
|
|
691
|
+
if (!alreadyPaused) setPaused(true, { source: "provider-credit", reason: message }, project.name);
|
|
679
692
|
log(
|
|
680
693
|
`#${issue} provider refused for credit — dispatch ${alreadyPaused ? "remains paused" : "paused"}: ${message}`,
|
|
681
694
|
);
|
|
@@ -2939,6 +2952,40 @@ export async function dispatchAdmissions(
|
|
|
2939
2952
|
|
|
2940
2953
|
// ----------------------------------------------------------------------- a tick
|
|
2941
2954
|
|
|
2955
|
+
/**
|
|
2956
|
+
* After a false→true condition transition, poke the orchestrator heartbeat so
|
|
2957
|
+
* it does not wait a full interval (#329). Best-effort: missing tick config or
|
|
2958
|
+
* a failed write leaves the row flagged in the ledger for the next heartbeat.
|
|
2959
|
+
*/
|
|
2960
|
+
export function wakeOrchestratorForMetConditions(
|
|
2961
|
+
projectName: string,
|
|
2962
|
+
met: readonly { id: string; condition?: string }[],
|
|
2963
|
+
writeLog: (line: string) => void = log,
|
|
2964
|
+
): void {
|
|
2965
|
+
if (met.length === 0) return;
|
|
2966
|
+
for (const decision of met) {
|
|
2967
|
+
writeLog(
|
|
2968
|
+
`decision ${decision.id} condition met (${decision.condition ?? "?"}) — requesting orchestrator tick`,
|
|
2969
|
+
);
|
|
2970
|
+
}
|
|
2971
|
+
const tickCwd = resolveTickConfigCwd(projectName);
|
|
2972
|
+
if (tickCwd === undefined) {
|
|
2973
|
+
writeLog(
|
|
2974
|
+
`decision condition met for ${met.map((m) => m.id).join(", ")} but no tick config cwd — heartbeat will surface them on its next interval`,
|
|
2975
|
+
);
|
|
2976
|
+
return;
|
|
2977
|
+
}
|
|
2978
|
+
const ids = met.map((m) => m.id).join(",");
|
|
2979
|
+
const reason = `condition-met ${ids}`;
|
|
2980
|
+
if (requestImmediateTick(tickCwd, reason)) {
|
|
2981
|
+
writeLog(`requested immediate tick at ${tickCwd}: ${reason}`);
|
|
2982
|
+
} else {
|
|
2983
|
+
writeLog(
|
|
2984
|
+
`could not write tick request under ${tickCwd}; conditions ${ids} wait for the next heartbeat`,
|
|
2985
|
+
);
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2942
2989
|
export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
2943
2990
|
// A config edit takes effect on the next tick, not the next daemon restart
|
|
2944
2991
|
// (#170). Re-resolve the project and its caps at the tick boundary so a tick
|
|
@@ -3038,11 +3085,14 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
3038
3085
|
// Ledger maintenance, above the pause gate for the same reason the stall watch
|
|
3039
3086
|
// is: a paused fleet still owes its operator the questions it asked, and a
|
|
3040
3087
|
// condition that came true while dispatch was parked is exactly the thing the
|
|
3041
|
-
// orchestrator has to see
|
|
3088
|
+
// orchestrator has to see promptly (#329).
|
|
3042
3089
|
//
|
|
3043
3090
|
// Expiry is synchronous (one UPDATE); condition evaluation is fire-and-forget
|
|
3044
3091
|
// because it shells out to `gh` and `npm`, and a registry that hangs must cost
|
|
3045
|
-
// one unevaluated condition rather than the tick.
|
|
3092
|
+
// one unevaluated condition rather than the tick. A false→true transition
|
|
3093
|
+
// writes the same immediate-tick poke recover uses so the heartbeat does not
|
|
3094
|
+
// wait a full interval; repeated sweeps while the condition stays true never
|
|
3095
|
+
// re-enter `met` (store mark is idempotent).
|
|
3046
3096
|
for (const expired of d.store.expireDueDecisions(d.project.name, Date.now())) {
|
|
3047
3097
|
log(`decision ${expired.id} expired unanswered after seven days: ${expired.question}`);
|
|
3048
3098
|
}
|
|
@@ -3054,9 +3104,7 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
3054
3104
|
Date.now,
|
|
3055
3105
|
)
|
|
3056
3106
|
.then((met) => {
|
|
3057
|
-
|
|
3058
|
-
log(`decision ${decision.id} condition met (${decision.condition ?? "?"}) — surfacing on the next tick`);
|
|
3059
|
-
}
|
|
3107
|
+
wakeOrchestratorForMetConditions(d.project.name, met);
|
|
3060
3108
|
})
|
|
3061
3109
|
.catch((err: unknown) => {
|
|
3062
3110
|
log(`decision condition pass failed: ${errText(err)}`);
|
|
@@ -3064,7 +3112,7 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
3064
3112
|
|
|
3065
3113
|
// A paused fleet claims nothing. Checked first so pausing takes effect on the
|
|
3066
3114
|
// next tick without signalling the process.
|
|
3067
|
-
if (isPaused()) return;
|
|
3115
|
+
if (isPaused(d.project.name)) return;
|
|
3068
3116
|
|
|
3069
3117
|
const { project, caps, store } = d;
|
|
3070
3118
|
|
|
@@ -3091,7 +3139,11 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
3091
3139
|
`ERROR: the installed conductor changed under this daemon — ${integrity.diff.length} file(s) differ ` +
|
|
3092
3140
|
`(${shown.join(", ")}${integrity.diff.length > shown.length ? ", …" : ""}) — pausing`,
|
|
3093
3141
|
);
|
|
3094
|
-
setPaused(
|
|
3142
|
+
setPaused(
|
|
3143
|
+
true,
|
|
3144
|
+
{ source: "integrity", reason: "installed package changed under the daemon" },
|
|
3145
|
+
project.name,
|
|
3146
|
+
);
|
|
3095
3147
|
if (integrity.page) {
|
|
3096
3148
|
const delivered = await safeEscalate(d, {
|
|
3097
3149
|
tier: 2,
|
|
@@ -3181,7 +3233,11 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
3181
3233
|
// operator opted out — turns and wall-clock still brake every run (#46).
|
|
3182
3234
|
const spent = store.spendSince(project.name, since);
|
|
3183
3235
|
if (caps.dailySpendUsd !== null && spent >= caps.dailySpendUsd) {
|
|
3184
|
-
setPaused(
|
|
3236
|
+
setPaused(
|
|
3237
|
+
true,
|
|
3238
|
+
{ source: "spend-cap", reason: `daily spend reached $${caps.dailySpendUsd}` },
|
|
3239
|
+
project.name,
|
|
3240
|
+
);
|
|
3185
3241
|
await safeEscalate(d, {
|
|
3186
3242
|
tier: 2,
|
|
3187
3243
|
category: "fleet-stopped",
|
|
@@ -3191,7 +3247,7 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
3191
3247
|
summary: `Daily spend cap reached on ${new Date().toISOString().slice(0, 10)} — ${project.name} is paused`,
|
|
3192
3248
|
detail: [
|
|
3193
3249
|
`Spent $${spent.toFixed(2)} of the $${caps.dailySpendUsd.toFixed(2)} daily cap.`,
|
|
3194
|
-
"No further work will be claimed until `omp-conductor resume
|
|
3250
|
+
"No further work will be claimed until `omp-conductor resume`.",
|
|
3195
3251
|
].join("\n"),
|
|
3196
3252
|
});
|
|
3197
3253
|
recordDispatch(0, [
|
|
@@ -3242,25 +3298,29 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
3242
3298
|
// --------------------------------------------------------------- read-only views
|
|
3243
3299
|
|
|
3244
3300
|
export interface DaemonHealthSnapshot {
|
|
3245
|
-
ok:
|
|
3301
|
+
ok: boolean;
|
|
3246
3302
|
paused: boolean;
|
|
3247
3303
|
activeRuns: number;
|
|
3248
3304
|
project: string;
|
|
3249
3305
|
/** One-shot issue ceilings waiting for the next claim. */
|
|
3250
3306
|
turnOverrides: TurnOverride[];
|
|
3251
|
-
/** Resident set of this daemon; workers are in-process omp sessions. */
|
|
3252
|
-
rssBytes: number;
|
|
3253
3307
|
dispatch?: DispatchSummary;
|
|
3254
3308
|
codeGraph?: CodeGraphHealth;
|
|
3255
3309
|
/** Live workers in a non-running pause phase; absent/empty = nothing paused. */
|
|
3256
3310
|
workers?: { issue: number; runId: string; phase: WorkerPausePhase }[];
|
|
3257
3311
|
}
|
|
3258
3312
|
|
|
3313
|
+
export interface DaemonHealth {
|
|
3314
|
+
ok: boolean;
|
|
3315
|
+
/** Resident set of this daemon; workers are in-process omp sessions. */
|
|
3316
|
+
rssBytes: number;
|
|
3317
|
+
projects: DaemonHealthSnapshot[];
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3259
3320
|
export function daemonHealthSnapshot(
|
|
3260
3321
|
store: Store,
|
|
3261
3322
|
project: string,
|
|
3262
|
-
paused = isPaused(),
|
|
3263
|
-
rssBytes = process.memoryUsage().rss,
|
|
3323
|
+
paused = isPaused(project),
|
|
3264
3324
|
codeGraph?: CodeGraphHealth,
|
|
3265
3325
|
workerControls?: WorkerControlRegistry,
|
|
3266
3326
|
): DaemonHealthSnapshot {
|
|
@@ -3271,13 +3331,19 @@ export function daemonHealthSnapshot(
|
|
|
3271
3331
|
activeRuns: store.activeRuns(project).length,
|
|
3272
3332
|
turnOverrides: store.listTurnOverrides(project),
|
|
3273
3333
|
project,
|
|
3274
|
-
rssBytes,
|
|
3275
3334
|
...(dispatch === undefined ? {} : { dispatch }),
|
|
3276
3335
|
...(codeGraph?.configured === true ? { codeGraph } : {}),
|
|
3277
3336
|
...(workerControls === undefined ? {} : { workers: workerControls.snapshot(project) }),
|
|
3278
3337
|
};
|
|
3279
3338
|
}
|
|
3280
3339
|
|
|
3340
|
+
export function daemonHealth(
|
|
3341
|
+
projects: DaemonHealthSnapshot[],
|
|
3342
|
+
rssBytes = process.memoryUsage().rss,
|
|
3343
|
+
): DaemonHealth {
|
|
3344
|
+
return { ok: projects.every((project) => project.ok), rssBytes, projects };
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3281
3347
|
const TURN_OVERRIDE_STATES: ReadonlySet<RunState> = new Set([
|
|
3282
3348
|
"failed",
|
|
3283
3349
|
"killed",
|
|
@@ -3483,13 +3549,17 @@ export async function workerControlResponse(
|
|
|
3483
3549
|
);
|
|
3484
3550
|
}
|
|
3485
3551
|
|
|
3486
|
-
export interface
|
|
3552
|
+
export interface DaemonHttpProjectDeps {
|
|
3487
3553
|
project: string;
|
|
3488
3554
|
store: Pick<Store, "latestRun" | "setTurnOverride">;
|
|
3489
3555
|
caps: () => Pick<Caps, "workerMaxTurns" | "workerMaxTurnsCeiling">;
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
export interface DaemonHttpDeps {
|
|
3559
|
+
projects: readonly DaemonHttpProjectDeps[];
|
|
3490
3560
|
turnLimits: TurnLimitRegistry;
|
|
3491
3561
|
workerControls: WorkerControlRegistry;
|
|
3492
|
-
health: () =>
|
|
3562
|
+
health: () => DaemonHealth;
|
|
3493
3563
|
}
|
|
3494
3564
|
|
|
3495
3565
|
/**
|
|
@@ -3504,9 +3574,47 @@ export interface DaemonHttpDeps {
|
|
|
3504
3574
|
export async function daemonHttpResponse(req: Request, d: DaemonHttpDeps): Promise<Response> {
|
|
3505
3575
|
const url = new URL(req.url);
|
|
3506
3576
|
if (req.method === "GET" && url.pathname === "/healthz") return Response.json(d.health());
|
|
3507
|
-
|
|
3577
|
+
|
|
3578
|
+
let selected = d.projects[0];
|
|
3579
|
+
if (
|
|
3580
|
+
req.method === "PUT" &&
|
|
3581
|
+
/^\/runs\/\d+\/(?:turn-limit|pause|resume|stop)$/.test(url.pathname) &&
|
|
3582
|
+
req.headers.get("content-type")?.startsWith("application/json")
|
|
3583
|
+
) {
|
|
3584
|
+
try {
|
|
3585
|
+
const body = await req.clone().json();
|
|
3586
|
+
if (body !== null && typeof body === "object") {
|
|
3587
|
+
const requested = Reflect.get(body, "project");
|
|
3588
|
+
if (typeof requested === "string" && requested.length > 0) {
|
|
3589
|
+
selected = d.projects.find(({ project }) => project === requested);
|
|
3590
|
+
if (selected === undefined) {
|
|
3591
|
+
return Response.json(
|
|
3592
|
+
{ error: `daemon does not serve requested project "${requested}"` },
|
|
3593
|
+
{ status: 409 },
|
|
3594
|
+
);
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
3598
|
+
} catch {
|
|
3599
|
+
// The route handler below owns the public malformed-body response.
|
|
3600
|
+
}
|
|
3601
|
+
}
|
|
3602
|
+
if (selected === undefined) return new Response("not found\n", { status: 404 });
|
|
3603
|
+
|
|
3604
|
+
const turnLimit = await turnLimitResponse(
|
|
3605
|
+
req,
|
|
3606
|
+
selected.project,
|
|
3607
|
+
selected.store,
|
|
3608
|
+
d.turnLimits,
|
|
3609
|
+
selected.caps(),
|
|
3610
|
+
);
|
|
3508
3611
|
if (turnLimit !== undefined) return turnLimit;
|
|
3509
|
-
const workerControl = await workerControlResponse(
|
|
3612
|
+
const workerControl = await workerControlResponse(
|
|
3613
|
+
req,
|
|
3614
|
+
selected.project,
|
|
3615
|
+
selected.store,
|
|
3616
|
+
d.workerControls,
|
|
3617
|
+
);
|
|
3510
3618
|
return workerControl ?? new Response("not found\n", { status: 404 });
|
|
3511
3619
|
}
|
|
3512
3620
|
|
|
@@ -3612,12 +3720,12 @@ export function statusSnapshotFromStore(
|
|
|
3612
3720
|
const oldestLabelOpAt = store.oldestPendingLabelOpAt(p.name);
|
|
3613
3721
|
// Read once: the provenance read touches the filesystem, and the renderer
|
|
3614
3722
|
// should never pay for it twice per status.
|
|
3615
|
-
const reason = pauseProvenance()?.reason;
|
|
3723
|
+
const reason = pauseProvenance(p.name)?.reason;
|
|
3616
3724
|
return {
|
|
3617
3725
|
project: p.name,
|
|
3618
3726
|
configPath: configPath(),
|
|
3619
3727
|
stateDir: stateDir(),
|
|
3620
|
-
paused: isPaused(),
|
|
3728
|
+
paused: isPaused(p.name),
|
|
3621
3729
|
...(reason === undefined ? {} : { pauseReason: reason }),
|
|
3622
3730
|
availability: availabilityState(p.reporting, now),
|
|
3623
3731
|
digestSchedule: digestScheduleState(p.reporting ?? DEFAULT_REPORT_POLICY, lastDigestDay, now),
|
|
@@ -3817,7 +3925,7 @@ export interface QueuePreview {
|
|
|
3817
3925
|
|
|
3818
3926
|
/**
|
|
3819
3927
|
* Exactly what the next tick would pick up, computed without touching a single
|
|
3820
|
-
* label, run row or worktree. This is what makes
|
|
3928
|
+
* label, run row or worktree. This is what makes `omp-conductor setup` honest: the
|
|
3821
3929
|
* dry run is the same routing code the loop uses, not a description of it.
|
|
3822
3930
|
*/
|
|
3823
3931
|
export async function previewProject(
|
|
@@ -3833,7 +3941,7 @@ export async function previewProject(
|
|
|
3833
3941
|
`open issues in ${p.tracker.repo} labelled "${p.queueLabel}", ` +
|
|
3834
3942
|
`minus anything already labelled ${states}, ` +
|
|
3835
3943
|
`routed by one "${p.routing.labelPrefix}<repo>" label`,
|
|
3836
|
-
paused: isPaused(),
|
|
3944
|
+
paused: isPaused(p.name),
|
|
3837
3945
|
ready: routed.map((r) => ({
|
|
3838
3946
|
number: r.issue.number,
|
|
3839
3947
|
title: r.issue.title,
|
|
@@ -3860,9 +3968,9 @@ export async function previewQueue(project?: string): Promise<QueuePreview> {
|
|
|
3860
3968
|
* Setup calls this immediately after consent. Every later setup error therefore
|
|
3861
3969
|
* leaves the fleet paused instead of exposing a partially written runtime.
|
|
3862
3970
|
*/
|
|
3863
|
-
export function prepareConductor(): void {
|
|
3971
|
+
export function prepareConductor(project?: string): void {
|
|
3864
3972
|
openStore(dbPath()).close();
|
|
3865
|
-
setPaused(true, { source: "setup" });
|
|
3973
|
+
setPaused(true, { source: "setup" }, project);
|
|
3866
3974
|
}
|
|
3867
3975
|
|
|
3868
3976
|
/** Bounded per tick: each row costs tracker calls to gather facts for. */
|
|
@@ -4383,297 +4491,264 @@ export async function reconcileOrphanedRuns(
|
|
|
4383
4491
|
|
|
4384
4492
|
// ------------------------------------------------------------------- the daemon
|
|
4385
4493
|
|
|
4494
|
+
interface ProjectRuntime {
|
|
4495
|
+
d: Deps;
|
|
4496
|
+
outbox: ReportOutbox;
|
|
4497
|
+
orchestrator?: OrchestratorHandle;
|
|
4498
|
+
orchestratorVerbs?: VerbListener;
|
|
4499
|
+
codeGraph: CodeGraphHealth;
|
|
4500
|
+
graphProbe?: Promise<void>;
|
|
4501
|
+
reportPass?: Promise<void>;
|
|
4502
|
+
}
|
|
4503
|
+
|
|
4504
|
+
function orchestratorStandingOrders(project: ProjectConfig): {
|
|
4505
|
+
brief: string;
|
|
4506
|
+
releaseGrants: ResolvedGrants;
|
|
4507
|
+
} {
|
|
4508
|
+
const releaseGrants = resolveReleaseGrants(project);
|
|
4509
|
+
const grantedShapes = RELEASE_SHAPES.filter((shape) => releaseGrants[shape] === "orchestrator");
|
|
4510
|
+
const deniedShapes = RELEASE_SHAPES.filter((shape) => releaseGrants[shape] !== "orchestrator");
|
|
4511
|
+
return {
|
|
4512
|
+
releaseGrants,
|
|
4513
|
+
brief: [
|
|
4514
|
+
`You are the omp-conductor orchestrator for project "${project.name}".`,
|
|
4515
|
+
`Tracker: ${project.tracker.repo}. Pass --repo ${project.tracker.repo} to every gh command:`,
|
|
4516
|
+
"this working directory is the conductor's state directory, not a checkout.",
|
|
4517
|
+
`Labels: queue=${project.queueLabel}, running=${project.stateLabels.inProgress}, ` +
|
|
4518
|
+
`blocked=${project.stateLabels.blocked}, failed=${project.stateLabels.failed}.`,
|
|
4519
|
+
"The dispatcher claims queue-labelled issues, runs one worker session per attempt in its own",
|
|
4520
|
+
"worktree under hard turn/wallclock/spend caps, and escalates to you when a worker blocks or",
|
|
4521
|
+
"fails twice, its gates stay red, its branch conflicts, or a tripwire fires.",
|
|
4522
|
+
"Your job when that happens: re-brief the issue (comment what the next worker must do",
|
|
4523
|
+
`differently, then put ${project.queueLabel} back on it), file follow-up issues, or promote to`,
|
|
4524
|
+
"tier 2 and let the human decide.",
|
|
4525
|
+
project.authority.merge === "orchestrator"
|
|
4526
|
+
? "You never edit product code or push a branch — a worker session does that. Merging is yours: one PR at " +
|
|
4527
|
+
"a time, freshness-checked against the base branch, per the Releases section of your POLICY.md."
|
|
4528
|
+
: "You never edit product code, push a branch, or merge a PR — a worker session edits and pushes, and a " +
|
|
4529
|
+
"human merges.",
|
|
4530
|
+
`Release tool gate: ${
|
|
4531
|
+
grantedShapes.length === 0
|
|
4532
|
+
? "every release and deploy shape is mechanically blocked for you"
|
|
4533
|
+
: `you may invoke ${grantedShapes.join(", ")} — and only by the procedure in your POLICY.md`
|
|
4534
|
+
}.` + (deniedShapes.length === 0 ? "" : ` Blocked: ${deniedShapes.join(", ")}.`),
|
|
4535
|
+
"Handle each escalation below before the next one.",
|
|
4536
|
+
].join("\n"),
|
|
4537
|
+
};
|
|
4538
|
+
}
|
|
4539
|
+
|
|
4386
4540
|
export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
4387
4541
|
const cfg = loadConfig();
|
|
4388
|
-
const
|
|
4389
|
-
const caps = resolveCaps(project, cfg.defaults);
|
|
4542
|
+
const projects = o.project === undefined ? cfg.projects : [findProject(cfg, o.project)];
|
|
4390
4543
|
const store = openStore(dbPath());
|
|
4391
|
-
// The tracker's single `gh` funnel is bound to the store so the operator sees
|
|
4392
|
-
// observed truth (#198): every spawn is counted per UTC day, and every
|
|
4393
|
-
// rate-limit refusal is recorded for `status`'s 5m window. Board's ad-hoc
|
|
4394
|
-
// trackers and the polled `fetchRateLimit` probe are deliberately not bound —
|
|
4395
|
-
// this row is the daemon's own traffic.
|
|
4396
|
-
const tracker = makeTracker(project, undefined, {
|
|
4397
|
-
onCall: () => store.bumpGhCalls?.(utcDay(), "daemon"),
|
|
4398
|
-
onRefusal: (at) => store.recordGhRefusal?.(at),
|
|
4399
|
-
// A conditional 304 revalidation is a spawn but not a billed read (#203);
|
|
4400
|
-
// counted separately so `status`'s call row keeps telling the truth once
|
|
4401
|
-
// most spawns are free revalidations.
|
|
4402
|
-
onNotModified: () => store.bumpGhCalls?.(utcDay(), "daemon-304"),
|
|
4403
|
-
});
|
|
4404
|
-
|
|
4405
|
-
// #126's transport, stated at startup rather than guessed at first use. The
|
|
4406
|
-
// banner names what this host can actually enforce — whether the kernel will
|
|
4407
|
-
// vouch for a caller's uid, and whether runs get distinct principals at all —
|
|
4408
|
-
// because "peer credentials asserted" is a claim, and a claim nobody printed
|
|
4409
|
-
// is one nobody can check against the host it is running on.
|
|
4410
4544
|
const verbPeerReader = peerCredentialReader();
|
|
4411
|
-
const
|
|
4412
|
-
let orchestratorVerbs: VerbListener | undefined;
|
|
4413
|
-
log(`verb transport: ${transportBanner(ensureVerbSocketDir(stateDir()), verbPeerReader)}`);
|
|
4414
|
-
|
|
4415
|
-
// Recorded here, before a single tick runs, so that the deploy an operator
|
|
4416
|
-
// *means* to do never trips the tripwire: installing a new build and
|
|
4417
|
-
// restarting the unit re-records this from the new files. What it catches is
|
|
4418
|
-
// the other thing — the package changing while the daemon that dispatches
|
|
4419
|
-
// work is holding it open, whether that is a worker that wandered out of its
|
|
4420
|
-
// worktree or a human editing the live install "just to test something".
|
|
4545
|
+
const verbDir = ensureVerbSocketDir(stateDir());
|
|
4421
4546
|
const integrity: IntegrityGate = { baseline: packageManifest(), paged: false };
|
|
4547
|
+
const usage = sharedUsageSource();
|
|
4548
|
+
const turnLimits = createTurnLimitRegistry((runId, maxTurns) => {
|
|
4549
|
+
store.updateRun(runId, { maxTurns });
|
|
4550
|
+
});
|
|
4551
|
+
const workerControls = createWorkerControlRegistry();
|
|
4552
|
+
const workers = createWorkerPool();
|
|
4553
|
+
const alive = livingDaemon();
|
|
4554
|
+
const runtimes: ProjectRuntime[] = [];
|
|
4555
|
+
|
|
4556
|
+
log(`verb transport: ${transportBanner(verbDir, verbPeerReader)}`);
|
|
4422
4557
|
log(`package integrity baseline: ${integrity.baseline.size} files under ${import.meta.dir}`);
|
|
4423
4558
|
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
const alive = livingDaemon();
|
|
4428
|
-
if (alive === undefined || alive.pid === process.pid) {
|
|
4429
|
-
// The orphan path salvages, and #121 requires that salvage to reach
|
|
4430
|
-
// GitHub. Since the run's commits now live in its own repository, the hop
|
|
4431
|
-
// is the daemon's: resolve the routed repo by name off the run row.
|
|
4432
|
-
const orphanPublisher = (r: RunRecord): RunPublisher => {
|
|
4433
|
-
const repo = project.routing.repos[r.repo];
|
|
4434
|
-
return async (branch) =>
|
|
4435
|
-
repo === undefined
|
|
4436
|
-
? { ok: false, stderr: `run ${String(r.id)} names repo "${r.repo}", which this project no longer routes` }
|
|
4437
|
-
: pushRunBranch(project, { repo, runRepoPath: r.worktree, branch });
|
|
4559
|
+
for (const project of projects) {
|
|
4560
|
+
const projectLog = (message: string): void => {
|
|
4561
|
+
log(projects.length === 1 ? message : `[${project.name}] ${message}`);
|
|
4438
4562
|
};
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
)
|
|
4563
|
+
const caps = resolveCaps(project, cfg.defaults);
|
|
4564
|
+
const tracker = makeTracker(project, undefined, {
|
|
4565
|
+
onCall: () => store.bumpGhCalls?.(utcDay(), "daemon"),
|
|
4566
|
+
onRefusal: (at) => store.recordGhRefusal?.(at),
|
|
4567
|
+
onNotModified: () => store.bumpGhCalls?.(utcDay(), "daemon-304"),
|
|
4568
|
+
});
|
|
4569
|
+
const verbActions = githubVerbActions(project);
|
|
4570
|
+
|
|
4571
|
+
if (alive === undefined || alive.pid === process.pid) {
|
|
4572
|
+
const orphanPublisher = (run: RunRecord): RunPublisher => {
|
|
4573
|
+
const repo = project.routing.repos[run.repo];
|
|
4574
|
+
return async (branch) =>
|
|
4575
|
+
repo === undefined
|
|
4576
|
+
? {
|
|
4577
|
+
ok: false,
|
|
4578
|
+
stderr:
|
|
4579
|
+
`run ${String(run.id)} names repo "${run.repo}", ` +
|
|
4580
|
+
`which project "${project.name}" no longer routes`,
|
|
4581
|
+
}
|
|
4582
|
+
: pushRunBranch(project, { repo, runRepoPath: run.worktree, branch });
|
|
4583
|
+
};
|
|
4584
|
+
for (const run of await reconcileOrphanedRuns(store, project.name, orphanPublisher)) {
|
|
4585
|
+
projectLog(
|
|
4586
|
+
`#${run.issue} orphaned by a previous daemon (attempt ${run.attempt}, was ${run.state}, ` +
|
|
4587
|
+
`worktree ${run.worktree}) — slot freed; the ${project.stateLabels.inProgress} label stays ` +
|
|
4588
|
+
"until the orchestrator triages what the worker left",
|
|
4589
|
+
);
|
|
4590
|
+
}
|
|
4591
|
+
} else if (runtimes.length === 0) {
|
|
4592
|
+
log(`skipping orphan reconciliation: daemon pid ${alive.pid} is alive and owns the active runs`);
|
|
4444
4593
|
}
|
|
4445
|
-
} else {
|
|
4446
|
-
log(`skipping orphan reconciliation: daemon pid ${alive.pid} is alive and owns the active runs`);
|
|
4447
|
-
}
|
|
4448
4594
|
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
const brief = [
|
|
4456
|
-
`You are the omp-conductor orchestrator for project "${project.name}".`,
|
|
4457
|
-
`Tracker: ${project.tracker.repo}. Pass --repo ${project.tracker.repo} to every gh command:`,
|
|
4458
|
-
"this working directory is the conductor's state directory, not a checkout.",
|
|
4459
|
-
`Labels: queue=${project.queueLabel}, running=${project.stateLabels.inProgress}, ` +
|
|
4460
|
-
`blocked=${project.stateLabels.blocked}, failed=${project.stateLabels.failed}.`,
|
|
4461
|
-
"The dispatcher claims queue-labelled issues, runs one worker session per attempt in its own",
|
|
4462
|
-
"worktree under hard turn/wallclock/spend caps, and escalates to you when a worker blocks or",
|
|
4463
|
-
"fails twice, its gates stay red, its branch conflicts, or a tripwire fires.",
|
|
4464
|
-
"Your job when that happens: re-brief the issue (comment what the next worker must do",
|
|
4465
|
-
`differently, then put ${project.queueLabel} back on it), file follow-up issues, or promote to`,
|
|
4466
|
-
"tier 2 and let the human decide.",
|
|
4467
|
-
// Worded from `authority.merge` rather than fixed, so the standing orders
|
|
4468
|
-
// and the Releases section of the rendered brief cannot disagree about who
|
|
4469
|
-
// is holding the merge button. The daemon still merges nothing itself.
|
|
4470
|
-
project.authority.merge === "orchestrator"
|
|
4471
|
-
? "You never edit product code or push a branch — a worker session does that. Merging is yours: one PR at " +
|
|
4472
|
-
"a time, freshness-checked against the base branch, per the Releases section of your POLICY.md."
|
|
4473
|
-
: "You never edit product code, push a branch, or merge a PR — a worker session edits and pushes, and a " +
|
|
4474
|
-
"human merges.",
|
|
4475
|
-
// Named shape by shape rather than as one policy word, so a stale grant is
|
|
4476
|
-
// legible in the transcript instead of only in the config file — the #122
|
|
4477
|
-
// incident began with a grant that no longer matched anyone's intent.
|
|
4478
|
-
`Release tool gate: ${
|
|
4479
|
-
grantedShapes.length === 0
|
|
4480
|
-
? "every release and deploy shape is mechanically blocked for you"
|
|
4481
|
-
: `you may invoke ${grantedShapes.join(", ")} — and only by the procedure in your POLICY.md`
|
|
4482
|
-
}.` + (deniedShapes.length === 0 ? "" : ` Blocked: ${deniedShapes.join(", ")}.`),
|
|
4483
|
-
"Handle each escalation below before the next one.",
|
|
4484
|
-
].join("\n");
|
|
4485
|
-
|
|
4486
|
-
// One orchestrator per daemon run, not per tick: it is a persistent session
|
|
4487
|
-
// whose whole value is remembering what it has already escalated, and a fresh
|
|
4488
|
-
// one every five minutes would remember nothing. Its cwd is the state
|
|
4489
|
-
// directory, deliberately not a checkout — the orchestrator re-briefs workers
|
|
4490
|
-
// and talks to the tracker, it does not edit product code.
|
|
4491
|
-
let orchestrator: OrchestratorHandle | undefined;
|
|
4492
|
-
if (project.escalation.orchestrator === "external") {
|
|
4493
|
-
// An operator already runs the brain — typically a visible TUI session that
|
|
4494
|
-
// drains `blocked`/`failed` off the tracker as one of its standing duties.
|
|
4495
|
-
// Starting a second one here would re-triage the same issues from a
|
|
4496
|
-
// transcript nobody is watching, and the two would undo each other.
|
|
4497
|
-
log("orchestrator: external — tier-1 escalations post as issue comments for the external session's drain duty");
|
|
4498
|
-
} else {
|
|
4499
|
-
try {
|
|
4500
|
-
// The orchestrator is a child process of this daemon running as its own
|
|
4501
|
-
// user. Nothing mechanically stops it reading a run checkout — what holds
|
|
4502
|
-
// it is its brief and the verb ledger (#143).
|
|
4503
|
-
const orchTreeRoot = stateDir();
|
|
4504
|
-
const orchCwd = join(orchTreeRoot, "orchestrator");
|
|
4505
|
-
mkdirSync(orchCwd, { recursive: true });
|
|
4506
|
-
// A third socket, distinct from every run's, in the same daemon-owned
|
|
4507
|
-
// 0711 parent. It makes the orchestrator's authority a property of the
|
|
4508
|
-
// channel rather than of a payload: no argument list can move a worker's
|
|
4509
|
-
// call onto this one (#126).
|
|
4510
|
-
//
|
|
4511
|
-
// It does NOT authenticate the session. Sessions share this daemon's uid,
|
|
4512
|
-
// so one could list this directory and connect here, and the ledger would
|
|
4513
|
-
// record the orchestrator's role because that is the channel's. See
|
|
4514
|
-
// conductor#163 for the pid binding that would close it.
|
|
4515
|
-
orchestratorVerbs = await listenVerbChannel(
|
|
4516
|
-
verbDeps({ project, store, tracker, verbActions }),
|
|
4517
|
-
{
|
|
4518
|
-
kind: "orchestrator",
|
|
4519
|
-
path: verbSocketPath(ensureVerbSocketDir(orchTreeRoot), "orchestrator"),
|
|
4520
|
-
project: project.name,
|
|
4521
|
-
role: "orchestrator",
|
|
4522
|
-
},
|
|
4523
|
-
{ ...(verbPeerReader === undefined ? {} : { peerReader: verbPeerReader }) },
|
|
4524
|
-
);
|
|
4525
|
-
orchestrator = await startOrchestrator({
|
|
4526
|
-
cwd: orchCwd,
|
|
4527
|
-
brief,
|
|
4528
|
-
releaseGrants,
|
|
4529
|
-
socketPath: join(orchCwd, "ipc.sock"),
|
|
4530
|
-
verbSocketPath: orchestratorVerbs.path,
|
|
4531
|
-
// The orchestrator's channel is the one that matters most: it is the
|
|
4532
|
-
// session authorised to merge, so an unbound channel here is a worker's
|
|
4533
|
-
// route to that authority (#163).
|
|
4534
|
-
onSpawn: (pid) => {
|
|
4535
|
-
orchestratorVerbs?.bindPid(pid);
|
|
4536
|
-
},
|
|
4537
|
-
onChildLog: (line) => {
|
|
4538
|
-
log(`orchestrator ${line}`);
|
|
4539
|
-
},
|
|
4540
|
-
onReleaseBlocked: (shape, context) =>
|
|
4541
|
-
recordReleaseBlock(project.name, "orchestrator", shape, context),
|
|
4542
|
-
});
|
|
4543
|
-
const transcript = orchestrator.sessionFile();
|
|
4544
|
-
log(`orchestrator session ready${transcript === undefined ? "" : ` · ${transcript}`}`);
|
|
4545
|
-
} catch (err) {
|
|
4546
|
-
// Loudly, but not fatally: tier-1 escalations degrade to issue comments,
|
|
4547
|
-
// which a human still reads. A dispatcher that refuses to run because its
|
|
4548
|
-
// re-briefing channel is down helps nobody.
|
|
4549
|
-
log(
|
|
4550
|
-
`WARNING: orchestrator session failed to start; tier-1 escalations will fall back to issue comments: ${errText(err)}`,
|
|
4595
|
+
const { brief, releaseGrants } = orchestratorStandingOrders(project);
|
|
4596
|
+
let orchestrator: OrchestratorHandle | undefined;
|
|
4597
|
+
let orchestratorVerbs: VerbListener | undefined;
|
|
4598
|
+
if (project.escalation.orchestrator === "external") {
|
|
4599
|
+
projectLog(
|
|
4600
|
+
"orchestrator: external — tier-1 escalations post as issue comments for the external session's drain duty",
|
|
4551
4601
|
);
|
|
4602
|
+
} else {
|
|
4603
|
+
try {
|
|
4604
|
+
const orchCwd = join(
|
|
4605
|
+
stateDir(),
|
|
4606
|
+
projects.length === 1 ? "orchestrator" : `orchestrator-${project.name}`,
|
|
4607
|
+
);
|
|
4608
|
+
mkdirSync(orchCwd, { recursive: true });
|
|
4609
|
+
orchestratorVerbs = await listenVerbChannel(
|
|
4610
|
+
verbDeps({ project, store, tracker, verbActions }),
|
|
4611
|
+
{
|
|
4612
|
+
kind: "orchestrator",
|
|
4613
|
+
path: verbSocketPath(verbDir, `orchestrator-${project.name}`),
|
|
4614
|
+
project: project.name,
|
|
4615
|
+
role: "orchestrator",
|
|
4616
|
+
},
|
|
4617
|
+
{ ...(verbPeerReader === undefined ? {} : { peerReader: verbPeerReader }) },
|
|
4618
|
+
);
|
|
4619
|
+
orchestrator = await startOrchestrator({
|
|
4620
|
+
cwd: orchCwd,
|
|
4621
|
+
brief,
|
|
4622
|
+
releaseGrants,
|
|
4623
|
+
socketPath: join(orchCwd, "ipc.sock"),
|
|
4624
|
+
verbSocketPath: orchestratorVerbs.path,
|
|
4625
|
+
onSpawn: (pid) => {
|
|
4626
|
+
orchestratorVerbs?.bindPid(pid);
|
|
4627
|
+
},
|
|
4628
|
+
onChildLog: (line) => {
|
|
4629
|
+
projectLog(`orchestrator ${line}`);
|
|
4630
|
+
},
|
|
4631
|
+
onReleaseBlocked: (shape, context) =>
|
|
4632
|
+
recordReleaseBlock(project.name, "orchestrator", shape, context),
|
|
4633
|
+
});
|
|
4634
|
+
const transcript = orchestrator.sessionFile();
|
|
4635
|
+
projectLog(`orchestrator session ready${transcript === undefined ? "" : ` · ${transcript}`}`);
|
|
4636
|
+
} catch (err) {
|
|
4637
|
+
projectLog(
|
|
4638
|
+
"WARNING: orchestrator session failed to start; tier-1 escalations will fall back to issue " +
|
|
4639
|
+
`comments: ${errText(err)}`,
|
|
4640
|
+
);
|
|
4641
|
+
await orchestratorVerbs?.close();
|
|
4642
|
+
orchestratorVerbs = undefined;
|
|
4643
|
+
}
|
|
4552
4644
|
}
|
|
4553
|
-
}
|
|
4554
|
-
|
|
4555
|
-
let runtimeDeps: Deps | undefined;
|
|
4556
|
-
const currentProject = (): ProjectConfig => runtimeDeps?.project ?? project;
|
|
4557
|
-
const deliveryPolicyValid = (): boolean => runtimeDeps?.deliveryPolicyValid === true;
|
|
4558
|
-
const escalator = createEscalator(
|
|
4559
|
-
currentProject,
|
|
4560
|
-
tracker,
|
|
4561
|
-
store,
|
|
4562
|
-
orchestrator,
|
|
4563
|
-
Date.now,
|
|
4564
|
-
deliveryPolicyValid,
|
|
4565
|
-
);
|
|
4566
4645
|
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4646
|
+
let runtimeDeps: Deps | undefined;
|
|
4647
|
+
const currentProject = (): ProjectConfig => runtimeDeps?.project ?? project;
|
|
4648
|
+
const deliveryPolicyValid = (): boolean => runtimeDeps?.deliveryPolicyValid === true;
|
|
4649
|
+
const escalator = createEscalator(
|
|
4650
|
+
currentProject,
|
|
4651
|
+
tracker,
|
|
4652
|
+
store,
|
|
4653
|
+
orchestrator,
|
|
4654
|
+
Date.now,
|
|
4655
|
+
deliveryPolicyValid,
|
|
4656
|
+
);
|
|
4657
|
+
const outbox = createReportOutbox({
|
|
4658
|
+
project: currentProject,
|
|
4659
|
+
store,
|
|
4660
|
+
escalate: (event) => escalator.escalate(event),
|
|
4661
|
+
log: projectLog,
|
|
4662
|
+
deliveryAllowed: deliveryPolicyValid,
|
|
4663
|
+
});
|
|
4664
|
+
if (alive === undefined || alive.pid === process.pid) {
|
|
4665
|
+
for (const report of outbox.recover(Date.now())) {
|
|
4666
|
+
projectLog(
|
|
4667
|
+
`report ${report.id} was left mid-send by a previous daemon (attempt ${report.attempts}) — ` +
|
|
4668
|
+
"retrying; its message will say it may be a repeat",
|
|
4669
|
+
);
|
|
4670
|
+
}
|
|
4591
4671
|
}
|
|
4592
|
-
}
|
|
4593
4672
|
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
}
|
|
4619
|
-
runtimeDeps = d;
|
|
4673
|
+
const d: Deps = {
|
|
4674
|
+
project,
|
|
4675
|
+
caps,
|
|
4676
|
+
tracker,
|
|
4677
|
+
store,
|
|
4678
|
+
deliveryPolicyValid: false,
|
|
4679
|
+
usage,
|
|
4680
|
+
escalate: (event) => escalator.escalate(event),
|
|
4681
|
+
turnLimits,
|
|
4682
|
+
workerControls,
|
|
4683
|
+
integrity,
|
|
4684
|
+
stall: { paged: false },
|
|
4685
|
+
cleanup: { next: 0 },
|
|
4686
|
+
...(verbPeerReader === undefined ? {} : { verbPeerReader }),
|
|
4687
|
+
verbActions,
|
|
4688
|
+
};
|
|
4689
|
+
runtimeDeps = d;
|
|
4690
|
+
runtimes.push({
|
|
4691
|
+
d,
|
|
4692
|
+
outbox,
|
|
4693
|
+
...(orchestrator === undefined ? {} : { orchestrator }),
|
|
4694
|
+
...(orchestratorVerbs === undefined ? {} : { orchestratorVerbs }),
|
|
4695
|
+
codeGraph: pendingCodeGraph(project),
|
|
4696
|
+
});
|
|
4697
|
+
}
|
|
4620
4698
|
|
|
4621
4699
|
if (o.once) {
|
|
4622
4700
|
try {
|
|
4623
|
-
await tick(d);
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
// mechanism it is drilling.
|
|
4627
|
-
await outbox.deliverDue();
|
|
4701
|
+
for (const runtime of runtimes) await tick(runtime.d, workers);
|
|
4702
|
+
await workers.drain();
|
|
4703
|
+
for (const runtime of runtimes) await runtime.outbox.deliverDue();
|
|
4628
4704
|
} finally {
|
|
4629
|
-
|
|
4630
|
-
|
|
4705
|
+
for (const runtime of runtimes.toReversed()) {
|
|
4706
|
+
await runtime.orchestrator?.dispose();
|
|
4707
|
+
await runtime.orchestratorVerbs?.close();
|
|
4708
|
+
}
|
|
4631
4709
|
store.close();
|
|
4632
4710
|
}
|
|
4633
4711
|
return;
|
|
4634
4712
|
}
|
|
4635
4713
|
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
let codeGraph = pendingCodeGraph(project);
|
|
4640
|
-
let graphProbe: Promise<void> | undefined;
|
|
4641
|
-
const refreshCodeGraph = (): void => {
|
|
4642
|
-
if (graphProbe !== undefined) return;
|
|
4643
|
-
graphProbe = probeCodeGraph(project)
|
|
4714
|
+
const refreshCodeGraph = (runtime: ProjectRuntime): void => {
|
|
4715
|
+
if (runtime.graphProbe !== undefined) return;
|
|
4716
|
+
runtime.graphProbe = probeCodeGraph(runtime.d.project)
|
|
4644
4717
|
.then((health) => {
|
|
4645
|
-
codeGraph = health;
|
|
4718
|
+
runtime.codeGraph = health;
|
|
4646
4719
|
})
|
|
4647
4720
|
.catch(() => {
|
|
4648
|
-
log(
|
|
4721
|
+
log(
|
|
4722
|
+
`[${runtime.d.project.name}] code-graph health probe failed unexpectedly; ` +
|
|
4723
|
+
"retaining the previous bounded result",
|
|
4724
|
+
);
|
|
4649
4725
|
})
|
|
4650
4726
|
.finally(() => {
|
|
4651
|
-
graphProbe = undefined;
|
|
4727
|
+
runtime.graphProbe = undefined;
|
|
4652
4728
|
});
|
|
4653
4729
|
};
|
|
4654
|
-
refreshCodeGraph();
|
|
4655
|
-
const graphTimer = setInterval(
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
if (reportPass !== undefined) return;
|
|
4663
|
-
reportPass = outbox
|
|
4730
|
+
for (const runtime of runtimes) refreshCodeGraph(runtime);
|
|
4731
|
+
const graphTimer = setInterval(() => {
|
|
4732
|
+
for (const runtime of runtimes) refreshCodeGraph(runtime);
|
|
4733
|
+
}, GRAPH_HEALTH_INTERVAL_MS);
|
|
4734
|
+
|
|
4735
|
+
const drainReports = (runtime: ProjectRuntime): void => {
|
|
4736
|
+
if (runtime.reportPass !== undefined) return;
|
|
4737
|
+
runtime.reportPass = runtime.outbox
|
|
4664
4738
|
.deliverDue()
|
|
4665
4739
|
.then(() => {})
|
|
4666
4740
|
.catch((err: unknown) => {
|
|
4667
|
-
log(`report delivery pass failed: ${errText(err)}`);
|
|
4741
|
+
log(`[${runtime.d.project.name}] report delivery pass failed: ${errText(err)}`);
|
|
4668
4742
|
})
|
|
4669
4743
|
.finally(() => {
|
|
4670
|
-
reportPass = undefined;
|
|
4744
|
+
runtime.reportPass = undefined;
|
|
4671
4745
|
});
|
|
4672
4746
|
};
|
|
4673
|
-
drainReports();
|
|
4674
|
-
const reportTimer = setInterval(
|
|
4747
|
+
for (const runtime of runtimes) drainReports(runtime);
|
|
4748
|
+
const reportTimer = setInterval(() => {
|
|
4749
|
+
for (const runtime of runtimes) drainReports(runtime);
|
|
4750
|
+
}, REPORT_DELIVERY_INTERVAL_MS);
|
|
4675
4751
|
|
|
4676
|
-
const workers = createWorkerPool();
|
|
4677
4752
|
let stopping = false;
|
|
4678
4753
|
let wake: (() => void) | undefined;
|
|
4679
4754
|
const stop = (): void => {
|
|
@@ -4685,54 +4760,58 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
4685
4760
|
process.on("SIGINT", stop);
|
|
4686
4761
|
process.on("SIGTERM", stop);
|
|
4687
4762
|
|
|
4688
|
-
//
|
|
4689
|
-
//
|
|
4690
|
-
//
|
|
4691
|
-
// carries no credential of any kind, and it cannot tell one caller from
|
|
4692
|
-
// another: `127.0.0.1` is not an identity. The turn-limit and worker
|
|
4693
|
-
// pause/resume controls trust a body-supplied `project`, which is exactly the
|
|
4694
|
-
// shape "identity from the payload" takes when nobody is watching. They stay
|
|
4695
|
-
// tolerable only because every effect is bounded: pauses are reversible,
|
|
4696
|
-
// live extensions touch one controller, and a persisted next-attempt
|
|
4697
|
-
// override can only raise the project base up to its configured ceiling and
|
|
4698
|
-
// is consumed by one claim.
|
|
4699
|
-
//
|
|
4700
|
-
// A merge, a push, a release or a label is none of those things. Do not add
|
|
4701
|
-
// one here, and do not add "just a small one" behind a shared secret either:
|
|
4702
|
-
// a secret readable by the process that would be attacking you is not
|
|
4703
|
-
// authentication. Mutations go over the per-run unix sockets in
|
|
4704
|
-
// `verbs/socket.ts`, where the kernel says who the caller is and the daemon
|
|
4705
|
-
// derives project, run and role from the channel rather than the body (#126).
|
|
4763
|
+
// This unauthenticated loopback surface exposes liveness and bounded live-run
|
|
4764
|
+
// controls only. Repository and tracker mutations stay on credentialled verb
|
|
4765
|
+
// sockets, where project and role come from the channel rather than a payload.
|
|
4706
4766
|
const server = Bun.serve({
|
|
4707
4767
|
hostname: "127.0.0.1",
|
|
4708
4768
|
port: o.port ?? DEFAULT_PORT,
|
|
4709
4769
|
fetch: (req) =>
|
|
4710
4770
|
daemonHttpResponse(req, {
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4771
|
+
projects: runtimes.map((runtime) => ({
|
|
4772
|
+
project: runtime.d.project.name,
|
|
4773
|
+
store,
|
|
4774
|
+
caps: () => runtime.d.caps,
|
|
4775
|
+
})),
|
|
4714
4776
|
turnLimits,
|
|
4715
4777
|
workerControls,
|
|
4716
4778
|
health: () =>
|
|
4717
|
-
|
|
4779
|
+
daemonHealth(
|
|
4780
|
+
runtimes.map((runtime) =>
|
|
4781
|
+
daemonHealthSnapshot(
|
|
4782
|
+
store,
|
|
4783
|
+
runtime.d.project.name,
|
|
4784
|
+
isPaused(runtime.d.project.name),
|
|
4785
|
+
runtime.codeGraph,
|
|
4786
|
+
workerControls,
|
|
4787
|
+
),
|
|
4788
|
+
),
|
|
4789
|
+
),
|
|
4718
4790
|
}),
|
|
4719
4791
|
});
|
|
4720
|
-
log(
|
|
4792
|
+
log(
|
|
4793
|
+
projects.length === 1
|
|
4794
|
+
? `serving /healthz on :${server.port}, project ${projects[0]!.name}`
|
|
4795
|
+
: `serving /healthz on :${server.port}, projects ${projects.map(({ name }) => name).join(", ")}`,
|
|
4796
|
+
);
|
|
4721
4797
|
|
|
4722
4798
|
try {
|
|
4723
4799
|
while (!stopping) {
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4800
|
+
for (const runtime of runtimes) {
|
|
4801
|
+
try {
|
|
4802
|
+
await tick(runtime.d, workers);
|
|
4803
|
+
} catch (err) {
|
|
4804
|
+
log(
|
|
4805
|
+
`${projects.length === 1 ? "" : `[${runtime.d.project.name}] `}` +
|
|
4806
|
+
`tick failed: ${errText(err)}`,
|
|
4807
|
+
);
|
|
4808
|
+
}
|
|
4730
4809
|
}
|
|
4731
4810
|
if (stopping) break;
|
|
4732
4811
|
await new Promise<void>((resolve) => {
|
|
4733
|
-
const
|
|
4812
|
+
const timer = setTimeout(resolve, TICK_INTERVAL_MS);
|
|
4734
4813
|
wake = () => {
|
|
4735
|
-
clearTimeout(
|
|
4814
|
+
clearTimeout(timer);
|
|
4736
4815
|
resolve();
|
|
4737
4816
|
};
|
|
4738
4817
|
});
|
|
@@ -4743,26 +4822,15 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
4743
4822
|
process.off("SIGTERM", stop);
|
|
4744
4823
|
clearInterval(graphTimer);
|
|
4745
4824
|
clearInterval(reportTimer);
|
|
4746
|
-
|
|
4747
|
-
// a `markReportDelivered` still to write, and losing that write is exactly
|
|
4748
|
-
// how a delivered report comes back as an ambiguous one on the next boot.
|
|
4749
|
-
await reportPass;
|
|
4825
|
+
for (const runtime of runtimes) await runtime.reportPass;
|
|
4750
4826
|
await workers.drain();
|
|
4751
4827
|
await server.stop(true);
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
// orchestrator is a channel accepting merges for a session that no longer
|
|
4757
|
-
// exists.
|
|
4758
|
-
await orchestratorVerbs?.close();
|
|
4828
|
+
for (const runtime of runtimes.toReversed()) {
|
|
4829
|
+
await runtime.orchestrator?.dispose();
|
|
4830
|
+
await runtime.orchestratorVerbs?.close();
|
|
4831
|
+
}
|
|
4759
4832
|
store.close();
|
|
4760
4833
|
log("stopped");
|
|
4761
|
-
// A handled SIGTERM still leaves some runtimes with a non-zero default
|
|
4762
|
-
// (historically 128+signal). Under systemd `Restart=on-failure` that looks
|
|
4763
|
-
// like a crash and the unit comes straight back — the exact failure mode
|
|
4764
|
-
// `omp-conductor stop` hit on the reference fleet. Force success so a
|
|
4765
|
-
// graceful drain is not a restart.
|
|
4766
4834
|
process.exitCode = 0;
|
|
4767
4835
|
}
|
|
4768
4836
|
}
|