omp-conductor 0.18.1 → 0.19.0
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 +106 -41
- package/REFERENCE.md +866 -31
- package/agents/to-spec.md +6 -2
- package/package.json +1 -1
- package/schema/config.schema.json +32 -1
- package/src/admission.ts +212 -26
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +27 -13
- package/src/briefs/to-spec.md +6 -2
- package/src/cli.ts +127 -2
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +52 -8
- package/src/commands/arm.ts +6 -2
- package/src/commands/context.ts +2 -0
- package/src/commands/intake.ts +4 -19
- package/src/commands/message.ts +26 -2
- package/src/commands/reconcile-units.ts +104 -0
- package/src/commands/release-composition.ts +232 -0
- package/src/commands/resume.ts +2 -27
- package/src/commands/setup.ts +101 -16
- package/src/commands/stats.ts +11 -30
- package/src/commands/tail.ts +31 -1
- package/src/commands/upgrade.ts +20 -3
- package/src/commands/verb.ts +2 -1
- package/src/commands/watch.ts +4 -17
- package/src/config-schema.ts +38 -6
- package/src/config.ts +103 -8
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1368 -529
- package/src/dashboard/app.js +504 -2
- package/src/dashboard/controls.ts +336 -0
- package/src/dashboard/index.html +30 -0
- package/src/dashboard/server.ts +271 -30
- package/src/dashboard/style.css +116 -0
- package/src/dashboard/transcript.ts +173 -0
- package/src/decisions.ts +19 -11
- package/src/doctor.ts +431 -148
- package/src/escalate.ts +22 -11
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +587 -230
- package/src/host.ts +6 -455
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +40 -56
- package/src/orchestrator-tick.ts +564 -121
- package/src/pause.ts +233 -0
- package/src/session-host.ts +6 -41
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +343 -1160
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +252 -51
- package/src/setup.ts +87 -4
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +485 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +50 -2
- package/src/types.ts +759 -10
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +299 -12
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +485 -11
- package/src/wake.ts +48 -0
- package/src/worker.ts +401 -14
package/src/setup-wizard.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
import { claimedTelegramTopics } from "./escalate.ts";
|
|
29
29
|
import { hostRamBytes, recommendedMaxWorkers, workerOvercommit } from "./host.ts";
|
|
30
30
|
import {
|
|
31
|
+
admissionAckPath,
|
|
31
32
|
daemonGeneration,
|
|
32
33
|
isPaused,
|
|
33
34
|
pausedPath,
|
|
@@ -40,8 +41,10 @@ import {
|
|
|
40
41
|
type AdmissionAckRecord,
|
|
41
42
|
type QueuePreview,
|
|
42
43
|
} from "./daemon.ts";
|
|
44
|
+
import { pauseBytesValid } from "./pause.ts";
|
|
43
45
|
import { armedMarkerPath, armTicks, fleetLayers, telegramStateDir } from "./fleet.ts";
|
|
44
46
|
import {
|
|
47
|
+
daemonControlTarget,
|
|
45
48
|
healthCheck,
|
|
46
49
|
healthServesProject,
|
|
47
50
|
livingDaemon,
|
|
@@ -110,11 +113,13 @@ import {
|
|
|
110
113
|
parseOmpSettingsYaml,
|
|
111
114
|
planAgainstLabels,
|
|
112
115
|
planLabels,
|
|
116
|
+
readConfiguredOmpRoles,
|
|
113
117
|
summariseAmend,
|
|
114
118
|
summarisePlan,
|
|
115
119
|
wantedLabels,
|
|
116
120
|
writeOrchestratorBrief,
|
|
117
121
|
type AmendAreaId,
|
|
122
|
+
type ConfiguredOmpRoles,
|
|
118
123
|
type LabelPlan,
|
|
119
124
|
type OperatorJudgment,
|
|
120
125
|
type ScopeCheck,
|
|
@@ -126,6 +131,7 @@ import {
|
|
|
126
131
|
BASE_FRESHNESS,
|
|
127
132
|
BEHIND_BASE_ACTIONS,
|
|
128
133
|
DEFAULT_CAPS,
|
|
134
|
+
DEFAULT_REVIEW_ADJUDICATOR_ROLE,
|
|
129
135
|
DEFAULT_REVIEW_MAX_ROUNDS,
|
|
130
136
|
DEFAULT_REVIEW_STRICTNESS,
|
|
131
137
|
DENIED_RELEASE_GRANTS,
|
|
@@ -133,6 +139,7 @@ import {
|
|
|
133
139
|
RELEASE_REQUIREMENTS,
|
|
134
140
|
INTERRUPT_CATEGORIES,
|
|
135
141
|
RELEASE_SHAPES,
|
|
142
|
+
REVIEW_ADJUDICATOR_RE,
|
|
136
143
|
REVIEW_MAX_ROUNDS_MAX,
|
|
137
144
|
REVIEW_MAX_ROUNDS_MIN,
|
|
138
145
|
REVIEW_STRICTNESS,
|
|
@@ -151,6 +158,7 @@ import {
|
|
|
151
158
|
type ReviewPolicy,
|
|
152
159
|
} from "./types.ts";
|
|
153
160
|
import { withProgress } from "./ui/progress.ts";
|
|
161
|
+
import { modelRolesIn } from "./omp-settings.ts";
|
|
154
162
|
import type { WizardUi } from "./wizard-ui.ts";
|
|
155
163
|
|
|
156
164
|
/**
|
|
@@ -1431,6 +1439,136 @@ async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
|
|
|
1431
1439
|
throw new Cancelled();
|
|
1432
1440
|
}
|
|
1433
1441
|
|
|
1442
|
+
/** The answer-file key the adjudicator question is recorded under. */
|
|
1443
|
+
const REVIEW_ADJUDICATOR_KEY = "review-adjudicator-role";
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* Whether one role is a legitimate adjudicator answer on this host right now:
|
|
1447
|
+
* it must be a single loadable role token (`REVIEW_ADJUDICATOR_RE`) AND a
|
|
1448
|
+
* `modelRoles` key of the daemon's global settings or of the project's omp
|
|
1449
|
+
* overlay — or the deterministic default, which setup must always be able to
|
|
1450
|
+
* write back. Anything else — including a project's current role that was
|
|
1451
|
+
* removed from both surfaces, or a malformed `modelRoles` key the loader
|
|
1452
|
+
* would refuse — is an unavailable role and must not be accepted, whatever
|
|
1453
|
+
* the config says (#875).
|
|
1454
|
+
*/
|
|
1455
|
+
function isAdjudicatorAvailable(live: ConfiguredOmpRoles, overlay: readonly string[], role: string): boolean {
|
|
1456
|
+
if (!REVIEW_ADJUDICATOR_RE.test(role)) return false;
|
|
1457
|
+
return role === DEFAULT_REVIEW_ADJUDICATOR_ROLE || live.global.includes(role) || overlay.includes(role);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* The adjudicator roles the dialog accepts, derived from the live OMP role
|
|
1462
|
+
* configuration — the daemon's global settings, then the project's omp
|
|
1463
|
+
* overlay — with the current answer first and the deterministic default
|
|
1464
|
+
* offered exactly once (#875). The order is the contract: an amend's Enter
|
|
1465
|
+
* re-affirms the current value, and the shipped default stays expressible for
|
|
1466
|
+
* a project whose operator never answered.
|
|
1467
|
+
*
|
|
1468
|
+
* Every role is filtered through the same token grammar the loader enforces:
|
|
1469
|
+
* a `modelRoles` key the config gate would reject must never be offered (or
|
|
1470
|
+
* accepted), because persisting it would write a policy the next loadConfig
|
|
1471
|
+
* refuses and stop the fleet. The current answer leads the list only while it
|
|
1472
|
+
* is still available: a role removed from both the global settings and the
|
|
1473
|
+
* overlay must not be accepted back into the config by a bare Enter — the
|
|
1474
|
+
* caller warns that it cannot be kept, and the accepted set excludes it, so
|
|
1475
|
+
* Entering it refuses exactly like typing any other unavailable role.
|
|
1476
|
+
*/
|
|
1477
|
+
function adjudicatorChoices(live: ConfiguredOmpRoles, overlay: readonly string[], current: string): string[] {
|
|
1478
|
+
const choices: string[] = [];
|
|
1479
|
+
const seen = new Set<string>();
|
|
1480
|
+
const first = isAdjudicatorAvailable(live, overlay, current) ? [current] : [];
|
|
1481
|
+
for (const role of [...first, ...live.global, ...overlay, DEFAULT_REVIEW_ADJUDICATOR_ROLE]) {
|
|
1482
|
+
if (REVIEW_ADJUDICATOR_RE.test(role) && !seen.has(role)) {
|
|
1483
|
+
seen.add(role);
|
|
1484
|
+
choices.push(role);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
return choices;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/** One role row's description: what makes this role available and why. */
|
|
1491
|
+
function adjudicatorDescription(
|
|
1492
|
+
role: string,
|
|
1493
|
+
live: ConfiguredOmpRoles,
|
|
1494
|
+
overlay: readonly string[],
|
|
1495
|
+
): string | undefined {
|
|
1496
|
+
if (live.global.includes(role) || overlay.includes(role)) {
|
|
1497
|
+
return role === DEFAULT_REVIEW_ADJUDICATOR_ROLE
|
|
1498
|
+
? "configured OMP model role — the deterministic default"
|
|
1499
|
+
: "configured OMP model role";
|
|
1500
|
+
}
|
|
1501
|
+
if (role === DEFAULT_REVIEW_ADJUDICATOR_ROLE) {
|
|
1502
|
+
return "the deterministic default every project loads until answered";
|
|
1503
|
+
}
|
|
1504
|
+
return undefined;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/**
|
|
1508
|
+
* Which OMP model role runs a PR's terminal review-ceiling adjudication
|
|
1509
|
+
* (#875). The offered roles are derived from the live OMP role configuration
|
|
1510
|
+
* (the daemon account's global settings and the project's overlay, never a
|
|
1511
|
+
* conductor-owned registry), the prompt opens on the current answer, and any
|
|
1512
|
+
* answer that is not one of those configured roles is refused with the place
|
|
1513
|
+
* to configure it named.
|
|
1514
|
+
*
|
|
1515
|
+
* Driven through `askValid`, not a select, on purpose: a select surface can
|
|
1516
|
+
* only ever return an offered label, so the unconfigured-value refusal would
|
|
1517
|
+
* be unreachable on the real UIs and the answer-file path would fail with a
|
|
1518
|
+
* generic "must be a listed label" before this guidance ran. A validated text
|
|
1519
|
+
* answer gives every surface — terminal, scripted and `--answers` — the same
|
|
1520
|
+
* bounded three tries and the same actionable error naming the settings file.
|
|
1521
|
+
*/
|
|
1522
|
+
async function askReviewAdjudicator(
|
|
1523
|
+
ui: WizardUi,
|
|
1524
|
+
current: string,
|
|
1525
|
+
live: ConfiguredOmpRoles,
|
|
1526
|
+
overlay: readonly string[],
|
|
1527
|
+
): Promise<string> {
|
|
1528
|
+
const choices = adjudicatorChoices(live, overlay, current);
|
|
1529
|
+
if (!live.decoded) {
|
|
1530
|
+
ui.notify(
|
|
1531
|
+
`The daemon's OMP settings at ${live.path} could not be read — only the project's overlay roles and the default are offered.`,
|
|
1532
|
+
"warning",
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
// A project whose configured role was removed from both surfaces must not be
|
|
1536
|
+
// allowed to keep it: the warning names the role and the way back, and the
|
|
1537
|
+
// accepted list above already excludes it — Entering it now refuses exactly
|
|
1538
|
+
// like typing any other unavailable role (#875).
|
|
1539
|
+
if (!isAdjudicatorAvailable(live, overlay, current)) {
|
|
1540
|
+
ui.notify(
|
|
1541
|
+
`"${current}" is this project's current adjudicator but not a currently configured OMP model role — it cannot be kept. ` +
|
|
1542
|
+
`Add "${current}" back as a modelRoles key to ${live.path} or this project's omp settings overlay, ` +
|
|
1543
|
+
`or type one of the available roles below.`,
|
|
1544
|
+
"warning",
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
ui.notify(
|
|
1548
|
+
choices
|
|
1549
|
+
.map((role) => {
|
|
1550
|
+
const described = adjudicatorDescription(role, live, overlay);
|
|
1551
|
+
return `${role}${role === current ? " (current)" : ""}${described === undefined ? "" : ` — ${described}`}`;
|
|
1552
|
+
})
|
|
1553
|
+
.join("\n"),
|
|
1554
|
+
"info",
|
|
1555
|
+
);
|
|
1556
|
+
return askValid(
|
|
1557
|
+
ui,
|
|
1558
|
+
REVIEW_ADJUDICATOR_KEY,
|
|
1559
|
+
"Adjudicator for a PR at the review ceiling — one configured OMP model role, not a model",
|
|
1560
|
+
current,
|
|
1561
|
+
(value) => {
|
|
1562
|
+
if (choices.some((choice) => choice === value)) return undefined;
|
|
1563
|
+
return (
|
|
1564
|
+
`"${value}" is not one of the OMP model roles available here — add it as a modelRoles key to the daemon's OMP settings ` +
|
|
1565
|
+
`(${live.path}) or this project's omp settings overlay, or type an offered role. ` +
|
|
1566
|
+
`A role is a single token like "task" — never a provider or model.`
|
|
1567
|
+
);
|
|
1568
|
+
},
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1434
1572
|
/**
|
|
1435
1573
|
* How green PRs are reviewed (#678), asked with the merge preconditions and
|
|
1436
1574
|
* the arming proof: they are the same kind of declared policy — a typed,
|
|
@@ -1441,7 +1579,10 @@ async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
|
|
|
1441
1579
|
* rather than between three words. The select cursor opens on the current
|
|
1442
1580
|
* answer (the configured level on a re-run, the recommended default on a
|
|
1443
1581
|
* first run); the rounds are a validated integer within the same range the
|
|
1444
|
-
* loader enforces
|
|
1582
|
+
* loader enforces; the adjudicator role is offered from the live OMP role
|
|
1583
|
+
* configuration (#875) — the options and their availability come from the
|
|
1584
|
+
* daemon's OMP settings and the project's overlay, never from a conductor
|
|
1585
|
+
* model registry.
|
|
1445
1586
|
*/
|
|
1446
1587
|
async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPolicy> {
|
|
1447
1588
|
ui.notify(
|
|
@@ -1450,6 +1591,7 @@ async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPol
|
|
|
1450
1591
|
),
|
|
1451
1592
|
"info",
|
|
1452
1593
|
);
|
|
1594
|
+
const live = readConfiguredOmpRoles();
|
|
1453
1595
|
return {
|
|
1454
1596
|
strictness: await askLiteral(
|
|
1455
1597
|
ui,
|
|
@@ -1460,6 +1602,7 @@ async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPol
|
|
|
1460
1602
|
a.review.strictness,
|
|
1461
1603
|
),
|
|
1462
1604
|
maxRounds: await askReviewRounds(ui, a.review.maxRounds),
|
|
1605
|
+
adjudicator: await askReviewAdjudicator(ui, a.review.adjudicator, live, modelRolesIn(a.ompSettings)),
|
|
1463
1606
|
};
|
|
1464
1607
|
}
|
|
1465
1608
|
|
|
@@ -2003,8 +2146,15 @@ interface DerivedPlan {
|
|
|
2003
2146
|
export async function ensureSetupArm(
|
|
2004
2147
|
projectName: string,
|
|
2005
2148
|
arm: typeof armTicks = armTicks,
|
|
2149
|
+
/**
|
|
2150
|
+
* Where the arm proof's pending heartbeat goes while it waits (#861). Setup
|
|
2151
|
+
* holds dispatch under its own fence across this call, so a silent wait here
|
|
2152
|
+
* is the whole incident: the operator sees a stalled wizard and cannot tell
|
|
2153
|
+
* a live handshake from a dead process.
|
|
2154
|
+
*/
|
|
2155
|
+
progress?: (line: string) => void,
|
|
2006
2156
|
): Promise<string> {
|
|
2007
|
-
const armed = await arm(projectName);
|
|
2157
|
+
const armed = await arm(projectName, progress === undefined ? {} : { progress });
|
|
2008
2158
|
return armed.alreadyArmed
|
|
2009
2159
|
? `existing heartbeat arm revalidated for owner ${armed.owner} at ${armed.path}`
|
|
2010
2160
|
: `heartbeat armed for owner ${armed.owner} at ${armed.path}`;
|
|
@@ -2207,7 +2357,12 @@ export interface SetupApplyDeps {
|
|
|
2207
2357
|
drain: DrainDeps;
|
|
2208
2358
|
smoke: (project: string) => Promise<SetupSmokeResult>;
|
|
2209
2359
|
restart: (o: { project?: string }) => Promise<RestartResult>;
|
|
2210
|
-
|
|
2360
|
+
/**
|
|
2361
|
+
* Prove the heartbeat channel for one project. `progress` is where the
|
|
2362
|
+
* pending proof's own heartbeat is written while it waits on the operator
|
|
2363
|
+
* (#861) — the wizard owns the surface, so the reporter comes from the call.
|
|
2364
|
+
*/
|
|
2365
|
+
arm: (project: string, progress?: (line: string) => void) => Promise<string>;
|
|
2211
2366
|
hostInstall: (
|
|
2212
2367
|
project: ProjectConfig,
|
|
2213
2368
|
caps: Caps,
|
|
@@ -2248,7 +2403,15 @@ function defaultSetupDrain(): DrainDeps {
|
|
|
2248
2403
|
projectNames: () => loadConfig().projects.map((p) => p.name),
|
|
2249
2404
|
daemonIdentity: setupDaemonIdentity,
|
|
2250
2405
|
pauseState: (project) => pauseInstance(project),
|
|
2251
|
-
setPaused: (v, project) =>
|
|
2406
|
+
setPaused: (v, project) =>
|
|
2407
|
+
// `owner` is this process (#938): the apply fence holds dispatch across
|
|
2408
|
+
// this transaction only, and the 2026-08-21 incident is exactly what an
|
|
2409
|
+
// abandoned one looks like from outside.
|
|
2410
|
+
setPaused(
|
|
2411
|
+
v,
|
|
2412
|
+
{ source: pauseSourceToken("setup"), reason: "setup apply fence", owner: process.pid },
|
|
2413
|
+
project,
|
|
2414
|
+
),
|
|
2252
2415
|
// The daemon-side admission acknowledgement: the file the running daemon
|
|
2253
2416
|
// itself writes when it observes the fence at an admission boundary, so
|
|
2254
2417
|
// the barrier never mistakes a second synchronous count for the daemon's
|
|
@@ -2318,7 +2481,7 @@ export const DEFAULT_APPLY: SetupApplyDeps = {
|
|
|
2318
2481
|
drain: defaultSetupDrain(),
|
|
2319
2482
|
smoke: runSetupSmoke,
|
|
2320
2483
|
restart: (o) => restartDaemon(o),
|
|
2321
|
-
arm: (project) => ensureSetupArm(project),
|
|
2484
|
+
arm: (project, progress) => ensureSetupArm(project, armTicks, progress),
|
|
2322
2485
|
hostInstall: (project, caps, telegramStateDir, ui) =>
|
|
2323
2486
|
runHostInstall(project, caps, telegramStateDir, ui),
|
|
2324
2487
|
graphInstall: (project, ui, options) => runGraphInstall(project, ui, options),
|
|
@@ -2347,19 +2510,6 @@ type CapturedPause =
|
|
|
2347
2510
|
| { kind: "malformed"; bytes: string; path: string }
|
|
2348
2511
|
| { kind: "unreadable"; path: string };
|
|
2349
2512
|
|
|
2350
|
-
/** Whether one sentinel's bytes parse as a valid pause instance — the exact
|
|
2351
|
-
* grammar {@link pauseInstance} reads in `daemon.ts` (a parseable ISO
|
|
2352
|
-
* timestamp line, then a `source=` provenance line). Readable bytes that
|
|
2353
|
-
* fail this are an unreadable-as-state malformed hold: valid bytes are
|
|
2354
|
-
* restored verbatim, malformed ones are refused at entry and never deleted
|
|
2355
|
-
* (#650). */
|
|
2356
|
-
function isValidPauseBytes(bytes: string): boolean {
|
|
2357
|
-
const [line1, line2] = bytes.split("\n");
|
|
2358
|
-
if (!Number.isFinite(Date.parse(line1?.trim() ?? ""))) return false;
|
|
2359
|
-
if (line2 === undefined) return false;
|
|
2360
|
-
return /^source=(\S+)(?: reason="(.*)")?$/.test(line2.trim());
|
|
2361
|
-
}
|
|
2362
|
-
|
|
2363
2513
|
/** Reads one pause sentinel, keeping absence, readable-valid bytes, malformed
|
|
2364
2514
|
* bytes and unreadability apart — never conflated: collapsing malformed to
|
|
2365
2515
|
* absence would let a refusal delete an operator hold it could not parse. */
|
|
@@ -2367,7 +2517,7 @@ function readSentinel(p: string): CapturedPause {
|
|
|
2367
2517
|
if (!existsSync(p)) return { kind: "absent" };
|
|
2368
2518
|
try {
|
|
2369
2519
|
const bytes = readFileSync(p, "utf8");
|
|
2370
|
-
return
|
|
2520
|
+
return pauseBytesValid(bytes) ? { kind: "bytes", bytes } : { kind: "malformed", bytes, path: p };
|
|
2371
2521
|
} catch {
|
|
2372
2522
|
return { kind: "unreadable", path: p };
|
|
2373
2523
|
}
|
|
@@ -2526,7 +2676,6 @@ function captureInventory(
|
|
|
2526
2676
|
};
|
|
2527
2677
|
captureIfWritten(plan.runtime.service);
|
|
2528
2678
|
captureIfWritten(plan.runtime.herdrUnit);
|
|
2529
|
-
captureIfWritten(plan.runtime.harnessMount);
|
|
2530
2679
|
captureIfWritten(plan.runtime.herdrConfig);
|
|
2531
2680
|
captureIfWritten(plan.runtime.herdrEnv);
|
|
2532
2681
|
captureIfWritten(plan.runtime.recoverUnit);
|
|
@@ -2760,23 +2909,61 @@ const FENCE_ACK_POLL_MS = 250;
|
|
|
2760
2909
|
* barrier froze, or `undefined` when it does: the acknowledgement must name
|
|
2761
2910
|
* the exact pause instance the barrier proved (not some other pause, however
|
|
2762
2911
|
* similar) and the exact daemon generation the barrier began with (not an
|
|
2763
|
-
* older or newer instance's word) (#651 review #3).
|
|
2912
|
+
* older or newer instance's word) (#651 review #3). Every problem names the
|
|
2913
|
+
* observed and expected instances, so an operator can tell "no
|
|
2914
|
+
* acknowledgement yet" from "acknowledged something else" without opening a
|
|
2915
|
+
* debugger (#865).
|
|
2764
2916
|
*/
|
|
2765
2917
|
function fenceAckProblem(ack: AdmissionAckRecord | undefined, begun: RestartBegun): string | undefined {
|
|
2766
|
-
if (ack === undefined)
|
|
2918
|
+
if (ack === undefined) {
|
|
2919
|
+
return (
|
|
2920
|
+
"the running daemon has not acknowledged the setup admission fence " +
|
|
2921
|
+
`(fence ${describePauseInstance(begun.pauseToken)}; expected daemon generation ${begun.daemon.generation}; ` +
|
|
2922
|
+
`no readable acknowledgement at ${admissionAckPath()})`
|
|
2923
|
+
);
|
|
2924
|
+
}
|
|
2767
2925
|
if (
|
|
2768
2926
|
ack.pause.source !== begun.pauseToken.source ||
|
|
2769
2927
|
ack.pause.since !== begun.pauseToken.since ||
|
|
2770
2928
|
ack.pause.reason !== begun.pauseToken.reason
|
|
2771
2929
|
) {
|
|
2772
|
-
return
|
|
2930
|
+
return (
|
|
2931
|
+
"the running daemon acknowledged a different pause than the setup admission fence " +
|
|
2932
|
+
`(acknowledged ${describePauseInstance(ack.pause)}; fence ${describePauseInstance(begun.pauseToken)})`
|
|
2933
|
+
);
|
|
2773
2934
|
}
|
|
2774
2935
|
if (ack.daemon !== begun.daemon.generation) {
|
|
2775
|
-
return
|
|
2936
|
+
return (
|
|
2937
|
+
"the running daemon's acknowledgement belongs to a different daemon generation " +
|
|
2938
|
+
`(acknowledged ${ack.daemon}; expected ${begun.daemon.generation})`
|
|
2939
|
+
);
|
|
2776
2940
|
}
|
|
2777
2941
|
return undefined;
|
|
2778
2942
|
}
|
|
2779
2943
|
|
|
2944
|
+
/** One pause instance as one readable token for refusal diagnostics (#865). */
|
|
2945
|
+
function describePauseInstance(pause: { source: string; reason?: string; since: number }): string {
|
|
2946
|
+
const reason = pause.reason === undefined ? "" : ` reason="${pause.reason}"`;
|
|
2947
|
+
return `source=${pause.source}${reason} since=${new Date(pause.since).toISOString()}`;
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
/**
|
|
2951
|
+
* Where a prompt-pass wake can provably land, or `undefined`: the pidfile
|
|
2952
|
+
* record's port while its process lives, else — a supervised daemon whose
|
|
2953
|
+
* runtime record went missing while the unit stayed active (#651 review #2) —
|
|
2954
|
+
* the run-control endpoint {@link daemonControlTarget} proves by answering
|
|
2955
|
+
* `/healthz`, the same consultation every run-control verb performs (#716).
|
|
2956
|
+
* A port is never guessed: when nothing provable answers, no wake is sent and
|
|
2957
|
+
* the caller's bounded wait covers the daemon's own next pass instead. The
|
|
2958
|
+
* pre-#865 barrier skipped BOTH the wake and the wait whenever the record was
|
|
2959
|
+
* unreadable, so a transient record loss refused three interviews in a row
|
|
2960
|
+
* against a healthy dispatcher that would have acknowledged within one tick.
|
|
2961
|
+
*/
|
|
2962
|
+
async function reachableWakeEndpoint(): Promise<{ port: number } | undefined> {
|
|
2963
|
+
const target = await daemonControlTarget();
|
|
2964
|
+
return target.kind === "record" || target.kind === "unit" ? { port: target.port } : undefined;
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2780
2967
|
/**
|
|
2781
2968
|
* Why the acknowledged barrier no longer holds, or `undefined` when it does:
|
|
2782
2969
|
* a live worker anywhere in scope, the daemon-side admission acknowledgement
|
|
@@ -3251,39 +3438,41 @@ export async function setup(
|
|
|
3251
3438
|
// boundary and written that observation down — a second synchronous worker
|
|
3252
3439
|
// count is not an acknowledgement, because a tick already past its own
|
|
3253
3440
|
// pause gate can claim after it and before setup mutates. A running daemon
|
|
3254
|
-
// that has not yet acknowledged is woken
|
|
3255
|
-
//
|
|
3256
|
-
// acknowledged by the bounded deadline refuses before
|
|
3441
|
+
// that has not yet acknowledged is woken — through any endpoint /healthz
|
|
3442
|
+
// proves, record or record-less (#865) — to prompt an immediate pass; one
|
|
3443
|
+
// that still has not acknowledged by the bounded deadline refuses before
|
|
3444
|
+
// anything is written. The wait itself never depends on the pidfile: a
|
|
3445
|
+
// supervised daemon with a lost record still reaches its own gate within
|
|
3446
|
+
// one tick interval, and skipping the wait for it was the #865 refusal.
|
|
3257
3447
|
let requireAck = true;
|
|
3258
3448
|
if (begun.daemon.running) {
|
|
3259
3449
|
let ack = apply.drain.admissionAck?.(scope.pauseKey);
|
|
3260
|
-
let
|
|
3261
|
-
if (
|
|
3262
|
-
const
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
"
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3450
|
+
let problem = fenceAckProblem(ack, begun);
|
|
3451
|
+
if (problem !== undefined) {
|
|
3452
|
+
const wake = await reachableWakeEndpoint();
|
|
3453
|
+
ui.notify(
|
|
3454
|
+
wake !== undefined
|
|
3455
|
+
? `The running daemon has not yet acknowledged the setup admission fence — waking it on :${wake.port} to prompt a pass.`
|
|
3456
|
+
: "The running daemon has not yet acknowledged the setup admission fence — nothing provable answers /healthz to wake, so waiting for its own dispatch pass.",
|
|
3457
|
+
"info",
|
|
3458
|
+
);
|
|
3459
|
+
if (wake !== undefined) void wakeDaemon(wake.port);
|
|
3460
|
+
// `OMP_CONDUCTOR_TEST_FENCE_ACK_WAIT_MS` is the #399 test seam: a
|
|
3461
|
+
// regression proving this refusal burns the short test deadline
|
|
3462
|
+
// instead of the production 60s.
|
|
3463
|
+
const deadline =
|
|
3464
|
+
Date.now() + Number(process.env["OMP_CONDUCTOR_TEST_FENCE_ACK_WAIT_MS"] ?? FENCE_ACK_WAIT_MS);
|
|
3465
|
+
while (problem !== undefined && Date.now() < deadline) {
|
|
3466
|
+
await apply.drain.sleep(FENCE_ACK_POLL_MS);
|
|
3467
|
+
ack = apply.drain.admissionAck?.(scope.pauseKey);
|
|
3468
|
+
problem = fenceAckProblem(ack, begun);
|
|
3279
3469
|
}
|
|
3280
3470
|
}
|
|
3281
|
-
if (
|
|
3471
|
+
if (problem !== undefined) {
|
|
3282
3472
|
restorePause(priorPause, ownedFence);
|
|
3283
3473
|
ui.notify(
|
|
3284
|
-
|
|
3285
|
-
"
|
|
3286
|
-
"once it has acknowledged the fence.",
|
|
3474
|
+
`Setup stopped before writing anything: ${problem} — quiescence cannot be proven. ` +
|
|
3475
|
+
"Nothing has been changed; stop the daemon, or re-run setup once it has acknowledged the fence.",
|
|
3287
3476
|
"error",
|
|
3288
3477
|
);
|
|
3289
3478
|
return false;
|
|
@@ -3528,7 +3717,7 @@ export async function setup(
|
|
|
3528
3717
|
"info",
|
|
3529
3718
|
);
|
|
3530
3719
|
try {
|
|
3531
|
-
armLine = await apply.arm(plan.project.name);
|
|
3720
|
+
armLine = await apply.arm(plan.project.name, (line) => ui.notify(line, "info"));
|
|
3532
3721
|
} catch (err) {
|
|
3533
3722
|
ui.notify(
|
|
3534
3723
|
[
|
|
@@ -3641,6 +3830,18 @@ export async function setup(
|
|
|
3641
3830
|
`Heartbeat: ${armLine}.`,
|
|
3642
3831
|
"",
|
|
3643
3832
|
"Use the documented toy-issue drill to prove one complete worker path.",
|
|
3833
|
+
"",
|
|
3834
|
+
// The one grooming recommendation setup makes, and it is only a sentence
|
|
3835
|
+
// (#827). Deliberately inert: nothing above or below this line installs,
|
|
3836
|
+
// probes, version-checks or refuses on it, because a conductor guarantee
|
|
3837
|
+
// must never depend on prose that releases on somebody else's cadence
|
|
3838
|
+
// (#506's judgement). Printed only here, on the committed success path,
|
|
3839
|
+
// so a failed or cancelled apply can never imply setup finished.
|
|
3840
|
+
"Optional: the spec-out skills pack at github.com/mattpocock/skills (`npx skills`) adds a",
|
|
3841
|
+
"grooming workflow this fleet has no opinion about — `to-spec` / `to-tickets` for turning a",
|
|
3842
|
+
"rough idea into sized slices, and `grill-me` for the questions worth asking before work",
|
|
3843
|
+
"starts. Nothing was installed or checked just now, and conductor is fully functional",
|
|
3844
|
+
"without it: skip it freely.",
|
|
3644
3845
|
].join("\n"),
|
|
3645
3846
|
"info",
|
|
3646
3847
|
);
|
package/src/setup.ts
CHANGED
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
stateDir,
|
|
51
51
|
} from "./config.ts";
|
|
52
52
|
import { graphProjectPath, graphRepos } from "./graph.ts";
|
|
53
|
-
import { ompSettingsOverlay } from "./omp-settings.ts";
|
|
53
|
+
import { modelRolesIn, ompSettingsOverlay } from "./omp-settings.ts";
|
|
54
54
|
import {
|
|
55
55
|
CONFIG_VERSION,
|
|
56
56
|
DEFAULT_ARM_PROOF,
|
|
@@ -174,6 +174,12 @@ export interface SetupAnswers {
|
|
|
174
174
|
*/
|
|
175
175
|
modelFallbacks?: string[];
|
|
176
176
|
modelFallbackThreshold?: number;
|
|
177
|
+
/**
|
|
178
|
+
* The one stronger tier a spinning cap retries on (#807). Hand-edited like
|
|
179
|
+
* {@link modelFallbacks}, so it exists here only to survive an amend of some
|
|
180
|
+
* other area (#369).
|
|
181
|
+
*/
|
|
182
|
+
workerEscalationModel?: string;
|
|
177
183
|
/**
|
|
178
184
|
* The project's omp settings overlay (#537): an opaque map layered into
|
|
179
185
|
* every worker session via the fleet-owned settings channel. The wizard asks
|
|
@@ -241,6 +247,15 @@ export interface SetupAnswers {
|
|
|
241
247
|
* stale-base interlock from a project that relies on it (#428).
|
|
242
248
|
*/
|
|
243
249
|
criticalBase?: ProjectConfig["criticalBase"];
|
|
250
|
+
/**
|
|
251
|
+
* Hand-edited providers that must bill to a subscription credential, carried
|
|
252
|
+
* through setup unchanged like {@link criticalBase} (#852). The wizard never
|
|
253
|
+
* invents one — which providers a fleet pays for by subscription is the
|
|
254
|
+
* operator's billing arrangement, not something repo reading can answer — and
|
|
255
|
+
* dropping it during an unrelated amend would silently remove the fence that
|
|
256
|
+
* stops a disabled OAuth grant billing to an API key.
|
|
257
|
+
*/
|
|
258
|
+
requireOauthProviders?: ProjectConfig["requireOauthProviders"];
|
|
244
259
|
/**
|
|
245
260
|
* Whether to render `ORCHESTRATOR.md` into the project's workspace root. Not
|
|
246
261
|
* part of the config — the brief is the operator's file, and the conductor
|
|
@@ -1001,6 +1016,12 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
1001
1016
|
// would pin every run of the project onto a dead provider again (#286).
|
|
1002
1017
|
...(a.modelFallbacks === undefined ? {} : { modelFallbacks: [...a.modelFallbacks] }),
|
|
1003
1018
|
...(a.modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold: a.modelFallbackThreshold }),
|
|
1019
|
+
// Hand-edited like the failover chain, and the same reason to keep it: an
|
|
1020
|
+
// unrelated amend that dropped it would silently take the fleet's only
|
|
1021
|
+
// spinning-cap recovery away (#807).
|
|
1022
|
+
...(a.workerEscalationModel === undefined
|
|
1023
|
+
? {}
|
|
1024
|
+
: { workerEscalationModel: a.workerEscalationModel }),
|
|
1004
1025
|
// The omp settings overlay is an opaque map the wizard collects as free-form
|
|
1005
1026
|
// YAML; an unrelated amend must not delete it (#537).
|
|
1006
1027
|
...(a.ompSettings === undefined ? {} : { ompSettings: a.ompSettings }),
|
|
@@ -1026,6 +1047,9 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
1026
1047
|
// Hand-edited safety markers carried unchanged on an unrelated amend
|
|
1027
1048
|
// (#428): dropping them would silently disarm the stale-base interlock.
|
|
1028
1049
|
...(a.criticalBase === undefined ? {} : { criticalBase: [...a.criticalBase] }),
|
|
1050
|
+
...(a.requireOauthProviders === undefined
|
|
1051
|
+
? {}
|
|
1052
|
+
: { requireOauthProviders: [...a.requireOauthProviders] }),
|
|
1029
1053
|
// Written out even when it is the default, so an operator amending the
|
|
1030
1054
|
// volume has a line in the file to point at. Opting into availability makes
|
|
1031
1055
|
// the schedule explicit and daily; omitting it preserves the preset's
|
|
@@ -1232,6 +1256,7 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
|
1232
1256
|
if (p.groomBelow !== undefined) answers.groomBelow = p.groomBelow;
|
|
1233
1257
|
if (p.modelFallbacks !== undefined) answers.modelFallbacks = [...p.modelFallbacks];
|
|
1234
1258
|
if (p.modelFallbackThreshold !== undefined) answers.modelFallbackThreshold = p.modelFallbackThreshold;
|
|
1259
|
+
if (p.workerEscalationModel !== undefined) answers.workerEscalationModel = p.workerEscalationModel;
|
|
1235
1260
|
if (p.ompSettings !== undefined) answers.ompSettings = { ...p.ompSettings };
|
|
1236
1261
|
if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId;
|
|
1237
1262
|
if (p.escalation.telegramTopicId !== undefined) answers.telegramTopicId = p.escalation.telegramTopicId;
|
|
@@ -1239,6 +1264,9 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
|
1239
1264
|
answers.recoveryMerges = p.recoveryMerges.map((entry) => ({ ...entry }));
|
|
1240
1265
|
}
|
|
1241
1266
|
if (p.criticalBase !== undefined) answers.criticalBase = [...p.criticalBase];
|
|
1267
|
+
if (p.requireOauthProviders !== undefined) {
|
|
1268
|
+
answers.requireOauthProviders = [...p.requireOauthProviders];
|
|
1269
|
+
}
|
|
1242
1270
|
if (p.reporting?.digest.at !== undefined) answers.dailyDigestAt = p.reporting.digest.at;
|
|
1243
1271
|
if (p.reporting?.digest.timezone !== undefined) {
|
|
1244
1272
|
answers.reportingTimezone = p.reporting.digest.timezone;
|
|
@@ -1575,6 +1603,53 @@ export function detectTelegram(): TelegramPresence {
|
|
|
1575
1603
|
return result;
|
|
1576
1604
|
}
|
|
1577
1605
|
|
|
1606
|
+
/**
|
|
1607
|
+
* Env override for the daemon account's global omp settings file, exactly the
|
|
1608
|
+
* seam `OMP_TELEGRAM_STATE_DIR` gives telegram discovery: a test (or a second
|
|
1609
|
+
* fleet on the same machine) can redirect the read without touching a real
|
|
1610
|
+
* `~/.omp/agent/config.yml`.
|
|
1611
|
+
*/
|
|
1612
|
+
export const OMP_GLOBAL_SETTINGS_ENV = "OMP_CONDUCTOR_OMP_SETTINGS_PATH";
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* The model roles the daemon account's global omp settings actually configure
|
|
1616
|
+
* — the keys of its `modelRoles` stanza (#875) — the "live OMP role
|
|
1617
|
+
* configuration" an adjudicator answer must be checked against. OMP's own
|
|
1618
|
+
* settings file (`~/.omp/agent/config.yml`) is the source rather than any
|
|
1619
|
+
* conductor-owned registry: conductor stores only role names, so the only
|
|
1620
|
+
* place an unrelated role could be invented is here, and reading this file is
|
|
1621
|
+
* what keeps setup from offering (or accepting) a role OMP never configured.
|
|
1622
|
+
*
|
|
1623
|
+
* A missing file is no roles; an unreadable or malformed one is also no roles
|
|
1624
|
+
* but is reported through `decoded: false` so the wizard can say why instead
|
|
1625
|
+
* of silently offering the default alone.
|
|
1626
|
+
*/
|
|
1627
|
+
export function readConfiguredOmpRoles(): ConfiguredOmpRoles {
|
|
1628
|
+
const override = process.env[OMP_GLOBAL_SETTINGS_ENV]?.trim();
|
|
1629
|
+
const path = override && override.length > 0 ? override : join(homedir(), ".omp", "agent", "config.yml");
|
|
1630
|
+
if (!existsSync(path)) return { path, global: [], decoded: true };
|
|
1631
|
+
try {
|
|
1632
|
+
const parsed = parseYaml(readFileSync(path, "utf8"));
|
|
1633
|
+
return {
|
|
1634
|
+
path,
|
|
1635
|
+
global: [...new Set(modelRolesIn(parsed))].sort(),
|
|
1636
|
+
decoded: true,
|
|
1637
|
+
};
|
|
1638
|
+
} catch {
|
|
1639
|
+
return { path, global: [], decoded: false };
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/** The global omp settings read (`readConfiguredOmpRoles`) reports. */
|
|
1644
|
+
export interface ConfiguredOmpRoles {
|
|
1645
|
+
/** The path read (the override, or the default under the operator's home). */
|
|
1646
|
+
readonly path: string;
|
|
1647
|
+
/** The model-role names the file's `modelRoles` stanza declares. */
|
|
1648
|
+
readonly global: readonly string[];
|
|
1649
|
+
/** False when the file exists but could not be read or parsed. */
|
|
1650
|
+
readonly decoded: boolean;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1578
1653
|
/** True when `.env` carries a non-empty `TELEGRAM_BOT_TOKEN`. The value is
|
|
1579
1654
|
* compared against emptiness and then dropped on the floor. */
|
|
1580
1655
|
function hasTelegramToken(envPath: string): boolean {
|
|
@@ -1729,6 +1804,11 @@ export function summarisePlan(
|
|
|
1729
1804
|
if (a.workerModel !== undefined && a.workerModel.trim().length > 0) {
|
|
1730
1805
|
lines.push(` ${"worker model".padEnd(CAPS_PLAN_KEY_PAD)}${a.workerModel.trim()} (answered)`);
|
|
1731
1806
|
}
|
|
1807
|
+
if (a.workerEscalationModel !== undefined && a.workerEscalationModel.trim().length > 0) {
|
|
1808
|
+
lines.push(
|
|
1809
|
+
` ${"cap escalation".padEnd(CAPS_PLAN_KEY_PAD)}${a.workerEscalationModel.trim()} (hand-edited)`,
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1732
1812
|
|
|
1733
1813
|
lines.push("", "escalation");
|
|
1734
1814
|
if (a.telegramChatId !== undefined && a.telegramChatId.trim().length > 0) {
|
|
@@ -1796,6 +1876,7 @@ export function summarisePlan(
|
|
|
1796
1876
|
|
|
1797
1877
|
lines.push("", "review", ` strictness ${a.review.strictness} — ${REVIEW_STRICTNESS_CHOICES[a.review.strictness]}`);
|
|
1798
1878
|
lines.push(` max rounds/PR ${a.review.maxRounds} — at the ceiling the PR is left open, the findings recorded, and it is escalated once`);
|
|
1879
|
+
lines.push(` adjudicator ${a.review.adjudicator} — the OMP model role that runs that ceiling adjudication`);
|
|
1799
1880
|
|
|
1800
1881
|
const reporting = project.reporting as ReportingPolicy;
|
|
1801
1882
|
const briefPath = orchestratorBriefPath(a);
|
|
@@ -2013,18 +2094,20 @@ export const AMEND_AREAS: {
|
|
|
2013
2094
|
},
|
|
2014
2095
|
policy: {
|
|
2015
2096
|
name: "merge & release preconditions",
|
|
2016
|
-
asks: "the checks, base freshness, draft rule and behind-base action for a merge, then what a release requires and what it ships — and how green PRs are reviewed,
|
|
2097
|
+
asks: "the checks, base freshness, draft rule and behind-base action for a merge, then what a release requires and what it ships — and how green PRs are reviewed, how many rounds a PR may be returned, and which OMP model role adjudicates a PR at that ceiling",
|
|
2017
2098
|
describe: (p) => {
|
|
2018
2099
|
const policy = resolvePolicy(p);
|
|
2019
2100
|
const review = resolveReview(p);
|
|
2020
2101
|
// Counted rather than listed: this row is elided at 96 characters, and the
|
|
2021
|
-
// full table is in the plan summary the consent screen shows next.
|
|
2102
|
+
// full table is in the plan summary the consent screen shows next. The
|
|
2103
|
+
// adjudicator is the third review decision, so it is named with the other
|
|
2104
|
+
// two (#875).
|
|
2022
2105
|
return (
|
|
2023
2106
|
`merge: ${policy.merge.requiredChecks.length === 0 ? "every check" : `${policy.merge.requiredChecks.length} check(s)`}, ` +
|
|
2024
2107
|
`base ${policy.merge.baseFreshness}, drafts ${policy.merge.drafts}, behind → ${policy.merge.whenBehindBase}; ` +
|
|
2025
2108
|
`release: ${policy.release.requires.length} must-land, ${policy.release.artefacts.length} artefact(s), ` +
|
|
2026
2109
|
`${policy.release.environments.length} env(s); ` +
|
|
2027
|
-
`review ${review.strictness} ×${review.maxRounds}`
|
|
2110
|
+
`review ${review.strictness} ×${review.maxRounds} (adjudicator ${review.adjudicator})`
|
|
2028
2111
|
);
|
|
2029
2112
|
},
|
|
2030
2113
|
},
|