omp-conductor 0.18.0 → 0.18.2
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 +35 -1
- package/REFERENCE.md +61 -11
- package/agents/to-spec.md +94 -0
- package/package.json +2 -1
- package/schema/config.schema.json +35 -1
- package/src/admission.ts +204 -75
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +62 -21
- package/src/briefs/to-spec.md +88 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +124 -1
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +38 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/intake.ts +4 -19
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +51 -16
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +43 -6
- package/src/config.ts +65 -9
- package/src/daemon.ts +879 -41
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +243 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +60 -82
- package/src/escalate.ts +31 -14
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +239 -240
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +35 -1
- package/src/graph.ts +66 -1
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +242 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +183 -21
- package/src/orchestrator-tick.ts +1591 -32
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +65 -6
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1225 -9
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +154 -3
- package/src/setup.ts +83 -17
- package/src/shell.ts +15 -0
- package/src/status-render.ts +216 -12
- package/src/store.ts +443 -42
- package/src/to-spec.ts +408 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +405 -19
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +765 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +12 -2
- package/src/worktree.ts +29 -12
package/src/setup-install.ts
CHANGED
|
@@ -52,6 +52,8 @@ import {
|
|
|
52
52
|
agentRenameVerdict,
|
|
53
53
|
checkEscalation,
|
|
54
54
|
DEFAULT_AGENT_RENAME_DEPS,
|
|
55
|
+
defaultIdentityProbes,
|
|
56
|
+
defaultServiceRuntime,
|
|
55
57
|
planHostRuntime,
|
|
56
58
|
totalConfiguredWorkers,
|
|
57
59
|
writeHostRuntime,
|
|
@@ -61,6 +63,7 @@ import {
|
|
|
61
63
|
type AgentRenameDeps,
|
|
62
64
|
type EscalationDeps,
|
|
63
65
|
type ServiceRuntime,
|
|
66
|
+
type WorkerIdentityProbes,
|
|
64
67
|
} from "./setup-host.ts";
|
|
65
68
|
import type { WizardUi } from "./wizard-ui.ts";
|
|
66
69
|
import type { Caps, ProjectConfig, Store } from "./types.ts";
|
|
@@ -81,6 +84,14 @@ export interface InstallDeps {
|
|
|
81
84
|
escalation?: EscalationDeps;
|
|
82
85
|
/** `"linux"` gates the systemd half. Injectable so the refusal is testable. */
|
|
83
86
|
platform?: () => string;
|
|
87
|
+
/**
|
|
88
|
+
* The worker identity plan's probes (#798). `undefined` means the real
|
|
89
|
+
* host's read-only facts — production: every `setup host` installs and
|
|
90
|
+
* grants the dedicated worker account. `null` disables the identity plan
|
|
91
|
+
* entirely (the historical step list, for tests pinning it), and an object
|
|
92
|
+
* is used as given (tests pinning a specific host state).
|
|
93
|
+
*/
|
|
94
|
+
workerIdentityProbes?: WorkerIdentityProbes | null;
|
|
84
95
|
unitDir?: string;
|
|
85
96
|
/**
|
|
86
97
|
* Where the recovery playbook installs. Injectable so the idempotency gate
|
|
@@ -188,6 +199,13 @@ export async function runHostInstall(
|
|
|
188
199
|
// happens only after consent (#510), so the prompt's "Nothing has been run
|
|
189
200
|
// yet" is true of the staged tree when it is printed, and a declined run
|
|
190
201
|
// leaves every staged file byte-identical.
|
|
202
|
+
// The worker identity plan rides the same consent (#798): production probes
|
|
203
|
+
// the real host (account present? grants current?); a test may pass its own
|
|
204
|
+
// probes, or `null` to plan the historical step list only.
|
|
205
|
+
const identityProbes: WorkerIdentityProbes | null =
|
|
206
|
+
deps.workerIdentityProbes === null
|
|
207
|
+
? null
|
|
208
|
+
: deps.workerIdentityProbes ?? defaultIdentityProbes(deps.runtime ?? defaultServiceRuntime(telegramStateDir));
|
|
191
209
|
const plan = planHostRuntime(
|
|
192
210
|
project,
|
|
193
211
|
caps,
|
|
@@ -197,6 +215,7 @@ export async function runHostInstall(
|
|
|
197
215
|
unitDir,
|
|
198
216
|
deps.recoverScriptInstallPath ?? RECOVER_SCRIPT_INSTALL_PATH,
|
|
199
217
|
deps.multiProject ?? hostMultiProject(),
|
|
218
|
+
identityProbes,
|
|
200
219
|
);
|
|
201
220
|
// No pane-shell key is planned (unusable login shell, unparseable config):
|
|
202
221
|
// the operator hears why before the consent prompt, not after an install
|
|
@@ -298,6 +317,15 @@ export async function runHostInstall(
|
|
|
298
317
|
]),
|
|
299
318
|
]),
|
|
300
319
|
"The unit runs as the account that staged it; nothing here changes that.",
|
|
320
|
+
...(plan.workerIdentity === undefined
|
|
321
|
+
? []
|
|
322
|
+
: [
|
|
323
|
+
`Establishes the dedicated worker identity ${plan.workerIdentity.account}: every worker session runs under it, ` +
|
|
324
|
+
"never under the daemon's account, and it is granted search/read access to exactly the fleet paths a worker needs.",
|
|
325
|
+
...(plan.workerIdentity.current
|
|
326
|
+
? ["The worker identity is already in place — nothing to change."]
|
|
327
|
+
: plan.workerIdentity.pending),
|
|
328
|
+
]),
|
|
301
329
|
"",
|
|
302
330
|
liveWorkers === 0
|
|
303
331
|
? "No workers are live — nothing to drain before the restart."
|
package/src/setup-wizard.ts
CHANGED
|
@@ -110,11 +110,13 @@ import {
|
|
|
110
110
|
parseOmpSettingsYaml,
|
|
111
111
|
planAgainstLabels,
|
|
112
112
|
planLabels,
|
|
113
|
+
readConfiguredOmpRoles,
|
|
113
114
|
summariseAmend,
|
|
114
115
|
summarisePlan,
|
|
115
116
|
wantedLabels,
|
|
116
117
|
writeOrchestratorBrief,
|
|
117
118
|
type AmendAreaId,
|
|
119
|
+
type ConfiguredOmpRoles,
|
|
118
120
|
type LabelPlan,
|
|
119
121
|
type OperatorJudgment,
|
|
120
122
|
type ScopeCheck,
|
|
@@ -126,6 +128,7 @@ import {
|
|
|
126
128
|
BASE_FRESHNESS,
|
|
127
129
|
BEHIND_BASE_ACTIONS,
|
|
128
130
|
DEFAULT_CAPS,
|
|
131
|
+
DEFAULT_REVIEW_ADJUDICATOR_ROLE,
|
|
129
132
|
DEFAULT_REVIEW_MAX_ROUNDS,
|
|
130
133
|
DEFAULT_REVIEW_STRICTNESS,
|
|
131
134
|
DENIED_RELEASE_GRANTS,
|
|
@@ -133,6 +136,7 @@ import {
|
|
|
133
136
|
RELEASE_REQUIREMENTS,
|
|
134
137
|
INTERRUPT_CATEGORIES,
|
|
135
138
|
RELEASE_SHAPES,
|
|
139
|
+
REVIEW_ADJUDICATOR_RE,
|
|
136
140
|
REVIEW_MAX_ROUNDS_MAX,
|
|
137
141
|
REVIEW_MAX_ROUNDS_MIN,
|
|
138
142
|
REVIEW_STRICTNESS,
|
|
@@ -151,6 +155,7 @@ import {
|
|
|
151
155
|
type ReviewPolicy,
|
|
152
156
|
} from "./types.ts";
|
|
153
157
|
import { withProgress } from "./ui/progress.ts";
|
|
158
|
+
import { modelRolesIn } from "./omp-settings.ts";
|
|
154
159
|
import type { WizardUi } from "./wizard-ui.ts";
|
|
155
160
|
|
|
156
161
|
/**
|
|
@@ -979,6 +984,7 @@ function seedFromDiscovery(seed: SetupAnswers, found: DiscoveredFacts): SetupAns
|
|
|
979
984
|
inProgress: labels.get(seed.stateLabels.inProgress.toLowerCase()) ?? seed.stateLabels.inProgress,
|
|
980
985
|
blocked: labels.get(seed.stateLabels.blocked.toLowerCase()) ?? seed.stateLabels.blocked,
|
|
981
986
|
failed: labels.get(seed.stateLabels.failed.toLowerCase()) ?? seed.stateLabels.failed,
|
|
987
|
+
backlog: labels.get(seed.stateLabels.backlog.toLowerCase()) ?? seed.stateLabels.backlog,
|
|
982
988
|
},
|
|
983
989
|
targetRepos,
|
|
984
990
|
policy: {
|
|
@@ -1094,19 +1100,28 @@ const askTrackerAndRepos: AreaAsker = async (ui, a, probes) => {
|
|
|
1094
1100
|
|
|
1095
1101
|
// One confirm instead of three prompts: the namespaced defaults are right for
|
|
1096
1102
|
// almost everyone, and three dialogs of Enter-to-accept is how a wizard earns
|
|
1097
|
-
// its reputation.
|
|
1103
|
+
// its reputation. The park label is in the same confirm, with its ownership
|
|
1104
|
+
// stated — it is not conductor-written, but it has to exist for the operator
|
|
1105
|
+
// to have the gesture (#507).
|
|
1098
1106
|
const stateLabels: SetupAnswers["stateLabels"] = { ...a.stateLabels };
|
|
1099
1107
|
const customiseStates = await askYesNo(
|
|
1100
1108
|
ui,
|
|
1101
1109
|
"customise-state-labels",
|
|
1102
1110
|
"State labels",
|
|
1103
1111
|
`The conductor writes back "${stateLabels.inProgress}", "${stateLabels.blocked}" and ` +
|
|
1104
|
-
`"${stateLabels.failed}" so the tracker alone shows live state.
|
|
1112
|
+
`"${stateLabels.failed}" so the tracker alone shows live state; "${stateLabels.backlog}" is ` +
|
|
1113
|
+
`yours — the conductor never writes it, but an issue carrying it is never claimed. Rename them?`,
|
|
1105
1114
|
);
|
|
1106
1115
|
if (customiseStates) {
|
|
1107
1116
|
stateLabels.inProgress = await ask(ui, "state-label-in-progress", "Label for a run in progress", stateLabels.inProgress);
|
|
1108
1117
|
stateLabels.blocked = await ask(ui, "state-label-blocked", "Label for a run parked on a human", stateLabels.blocked);
|
|
1109
1118
|
stateLabels.failed = await ask(ui, "state-label-failed", "Label for a run that gave up", stateLabels.failed);
|
|
1119
|
+
stateLabels.backlog = await ask(
|
|
1120
|
+
ui,
|
|
1121
|
+
"state-label-backlog",
|
|
1122
|
+
"Label the operator applies to park an issue (the conductor never writes it)",
|
|
1123
|
+
stateLabels.backlog,
|
|
1124
|
+
);
|
|
1110
1125
|
}
|
|
1111
1126
|
|
|
1112
1127
|
const routingLabelPrefix = await ask(
|
|
@@ -1421,6 +1436,136 @@ async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
|
|
|
1421
1436
|
throw new Cancelled();
|
|
1422
1437
|
}
|
|
1423
1438
|
|
|
1439
|
+
/** The answer-file key the adjudicator question is recorded under. */
|
|
1440
|
+
const REVIEW_ADJUDICATOR_KEY = "review-adjudicator-role";
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Whether one role is a legitimate adjudicator answer on this host right now:
|
|
1444
|
+
* it must be a single loadable role token (`REVIEW_ADJUDICATOR_RE`) AND a
|
|
1445
|
+
* `modelRoles` key of the daemon's global settings or of the project's omp
|
|
1446
|
+
* overlay — or the deterministic default, which setup must always be able to
|
|
1447
|
+
* write back. Anything else — including a project's current role that was
|
|
1448
|
+
* removed from both surfaces, or a malformed `modelRoles` key the loader
|
|
1449
|
+
* would refuse — is an unavailable role and must not be accepted, whatever
|
|
1450
|
+
* the config says (#875).
|
|
1451
|
+
*/
|
|
1452
|
+
function isAdjudicatorAvailable(live: ConfiguredOmpRoles, overlay: readonly string[], role: string): boolean {
|
|
1453
|
+
if (!REVIEW_ADJUDICATOR_RE.test(role)) return false;
|
|
1454
|
+
return role === DEFAULT_REVIEW_ADJUDICATOR_ROLE || live.global.includes(role) || overlay.includes(role);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* The adjudicator roles the dialog accepts, derived from the live OMP role
|
|
1459
|
+
* configuration — the daemon's global settings, then the project's omp
|
|
1460
|
+
* overlay — with the current answer first and the deterministic default
|
|
1461
|
+
* offered exactly once (#875). The order is the contract: an amend's Enter
|
|
1462
|
+
* re-affirms the current value, and the shipped default stays expressible for
|
|
1463
|
+
* a project whose operator never answered.
|
|
1464
|
+
*
|
|
1465
|
+
* Every role is filtered through the same token grammar the loader enforces:
|
|
1466
|
+
* a `modelRoles` key the config gate would reject must never be offered (or
|
|
1467
|
+
* accepted), because persisting it would write a policy the next loadConfig
|
|
1468
|
+
* refuses and stop the fleet. The current answer leads the list only while it
|
|
1469
|
+
* is still available: a role removed from both the global settings and the
|
|
1470
|
+
* overlay must not be accepted back into the config by a bare Enter — the
|
|
1471
|
+
* caller warns that it cannot be kept, and the accepted set excludes it, so
|
|
1472
|
+
* Entering it refuses exactly like typing any other unavailable role.
|
|
1473
|
+
*/
|
|
1474
|
+
function adjudicatorChoices(live: ConfiguredOmpRoles, overlay: readonly string[], current: string): string[] {
|
|
1475
|
+
const choices: string[] = [];
|
|
1476
|
+
const seen = new Set<string>();
|
|
1477
|
+
const first = isAdjudicatorAvailable(live, overlay, current) ? [current] : [];
|
|
1478
|
+
for (const role of [...first, ...live.global, ...overlay, DEFAULT_REVIEW_ADJUDICATOR_ROLE]) {
|
|
1479
|
+
if (REVIEW_ADJUDICATOR_RE.test(role) && !seen.has(role)) {
|
|
1480
|
+
seen.add(role);
|
|
1481
|
+
choices.push(role);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return choices;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/** One role row's description: what makes this role available and why. */
|
|
1488
|
+
function adjudicatorDescription(
|
|
1489
|
+
role: string,
|
|
1490
|
+
live: ConfiguredOmpRoles,
|
|
1491
|
+
overlay: readonly string[],
|
|
1492
|
+
): string | undefined {
|
|
1493
|
+
if (live.global.includes(role) || overlay.includes(role)) {
|
|
1494
|
+
return role === DEFAULT_REVIEW_ADJUDICATOR_ROLE
|
|
1495
|
+
? "configured OMP model role — the deterministic default"
|
|
1496
|
+
: "configured OMP model role";
|
|
1497
|
+
}
|
|
1498
|
+
if (role === DEFAULT_REVIEW_ADJUDICATOR_ROLE) {
|
|
1499
|
+
return "the deterministic default every project loads until answered";
|
|
1500
|
+
}
|
|
1501
|
+
return undefined;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* Which OMP model role runs a PR's terminal review-ceiling adjudication
|
|
1506
|
+
* (#875). The offered roles are derived from the live OMP role configuration
|
|
1507
|
+
* (the daemon account's global settings and the project's overlay, never a
|
|
1508
|
+
* conductor-owned registry), the prompt opens on the current answer, and any
|
|
1509
|
+
* answer that is not one of those configured roles is refused with the place
|
|
1510
|
+
* to configure it named.
|
|
1511
|
+
*
|
|
1512
|
+
* Driven through `askValid`, not a select, on purpose: a select surface can
|
|
1513
|
+
* only ever return an offered label, so the unconfigured-value refusal would
|
|
1514
|
+
* be unreachable on the real UIs and the answer-file path would fail with a
|
|
1515
|
+
* generic "must be a listed label" before this guidance ran. A validated text
|
|
1516
|
+
* answer gives every surface — terminal, scripted and `--answers` — the same
|
|
1517
|
+
* bounded three tries and the same actionable error naming the settings file.
|
|
1518
|
+
*/
|
|
1519
|
+
async function askReviewAdjudicator(
|
|
1520
|
+
ui: WizardUi,
|
|
1521
|
+
current: string,
|
|
1522
|
+
live: ConfiguredOmpRoles,
|
|
1523
|
+
overlay: readonly string[],
|
|
1524
|
+
): Promise<string> {
|
|
1525
|
+
const choices = adjudicatorChoices(live, overlay, current);
|
|
1526
|
+
if (!live.decoded) {
|
|
1527
|
+
ui.notify(
|
|
1528
|
+
`The daemon's OMP settings at ${live.path} could not be read — only the project's overlay roles and the default are offered.`,
|
|
1529
|
+
"warning",
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
// A project whose configured role was removed from both surfaces must not be
|
|
1533
|
+
// allowed to keep it: the warning names the role and the way back, and the
|
|
1534
|
+
// accepted list above already excludes it — Entering it now refuses exactly
|
|
1535
|
+
// like typing any other unavailable role (#875).
|
|
1536
|
+
if (!isAdjudicatorAvailable(live, overlay, current)) {
|
|
1537
|
+
ui.notify(
|
|
1538
|
+
`"${current}" is this project's current adjudicator but not a currently configured OMP model role — it cannot be kept. ` +
|
|
1539
|
+
`Add "${current}" back as a modelRoles key to ${live.path} or this project's omp settings overlay, ` +
|
|
1540
|
+
`or type one of the available roles below.`,
|
|
1541
|
+
"warning",
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
ui.notify(
|
|
1545
|
+
choices
|
|
1546
|
+
.map((role) => {
|
|
1547
|
+
const described = adjudicatorDescription(role, live, overlay);
|
|
1548
|
+
return `${role}${role === current ? " (current)" : ""}${described === undefined ? "" : ` — ${described}`}`;
|
|
1549
|
+
})
|
|
1550
|
+
.join("\n"),
|
|
1551
|
+
"info",
|
|
1552
|
+
);
|
|
1553
|
+
return askValid(
|
|
1554
|
+
ui,
|
|
1555
|
+
REVIEW_ADJUDICATOR_KEY,
|
|
1556
|
+
"Adjudicator for a PR at the review ceiling — one configured OMP model role, not a model",
|
|
1557
|
+
current,
|
|
1558
|
+
(value) => {
|
|
1559
|
+
if (choices.some((choice) => choice === value)) return undefined;
|
|
1560
|
+
return (
|
|
1561
|
+
`"${value}" is not one of the OMP model roles available here — add it as a modelRoles key to the daemon's OMP settings ` +
|
|
1562
|
+
`(${live.path}) or this project's omp settings overlay, or type an offered role. ` +
|
|
1563
|
+
`A role is a single token like "task" — never a provider or model.`
|
|
1564
|
+
);
|
|
1565
|
+
},
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1424
1569
|
/**
|
|
1425
1570
|
* How green PRs are reviewed (#678), asked with the merge preconditions and
|
|
1426
1571
|
* the arming proof: they are the same kind of declared policy — a typed,
|
|
@@ -1431,7 +1576,10 @@ async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
|
|
|
1431
1576
|
* rather than between three words. The select cursor opens on the current
|
|
1432
1577
|
* answer (the configured level on a re-run, the recommended default on a
|
|
1433
1578
|
* first run); the rounds are a validated integer within the same range the
|
|
1434
|
-
* loader enforces
|
|
1579
|
+
* loader enforces; the adjudicator role is offered from the live OMP role
|
|
1580
|
+
* configuration (#875) — the options and their availability come from the
|
|
1581
|
+
* daemon's OMP settings and the project's overlay, never from a conductor
|
|
1582
|
+
* model registry.
|
|
1435
1583
|
*/
|
|
1436
1584
|
async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPolicy> {
|
|
1437
1585
|
ui.notify(
|
|
@@ -1440,6 +1588,7 @@ async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPol
|
|
|
1440
1588
|
),
|
|
1441
1589
|
"info",
|
|
1442
1590
|
);
|
|
1591
|
+
const live = readConfiguredOmpRoles();
|
|
1443
1592
|
return {
|
|
1444
1593
|
strictness: await askLiteral(
|
|
1445
1594
|
ui,
|
|
@@ -1450,6 +1599,7 @@ async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPol
|
|
|
1450
1599
|
a.review.strictness,
|
|
1451
1600
|
),
|
|
1452
1601
|
maxRounds: await askReviewRounds(ui, a.review.maxRounds),
|
|
1602
|
+
adjudicator: await askReviewAdjudicator(ui, a.review.adjudicator, live, modelRolesIn(a.ompSettings)),
|
|
1453
1603
|
};
|
|
1454
1604
|
}
|
|
1455
1605
|
|
|
@@ -2516,6 +2666,7 @@ function captureInventory(
|
|
|
2516
2666
|
};
|
|
2517
2667
|
captureIfWritten(plan.runtime.service);
|
|
2518
2668
|
captureIfWritten(plan.runtime.herdrUnit);
|
|
2669
|
+
captureIfWritten(plan.runtime.harnessMount);
|
|
2519
2670
|
captureIfWritten(plan.runtime.herdrConfig);
|
|
2520
2671
|
captureIfWritten(plan.runtime.herdrEnv);
|
|
2521
2672
|
captureIfWritten(plan.runtime.recoverUnit);
|
package/src/setup.ts
CHANGED
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
import {
|
|
40
40
|
clonePolicy,
|
|
41
41
|
configPath,
|
|
42
|
-
DEFAULT_STATE_LABELS,
|
|
43
42
|
defaultMirrorRoot,
|
|
44
43
|
defaultWorkspaceRoot,
|
|
45
44
|
resolveArmProof,
|
|
@@ -51,7 +50,7 @@ import {
|
|
|
51
50
|
stateDir,
|
|
52
51
|
} from "./config.ts";
|
|
53
52
|
import { graphProjectPath, graphRepos } from "./graph.ts";
|
|
54
|
-
import { ompSettingsOverlay } from "./omp-settings.ts";
|
|
53
|
+
import { modelRolesIn, ompSettingsOverlay } from "./omp-settings.ts";
|
|
55
54
|
import {
|
|
56
55
|
CONFIG_VERSION,
|
|
57
56
|
DEFAULT_ARM_PROOF,
|
|
@@ -144,7 +143,7 @@ export interface SetupAnswers {
|
|
|
144
143
|
/** Tracker repo as `owner/repo` — the only spelling `gh` takes without a host. */
|
|
145
144
|
trackerRepo: string;
|
|
146
145
|
queueLabel: string;
|
|
147
|
-
stateLabels: { inProgress: string; blocked: string; failed: string };
|
|
146
|
+
stateLabels: { inProgress: string; blocked: string; failed: string; backlog: string };
|
|
148
147
|
routingLabelPrefix: string;
|
|
149
148
|
targetRepos: {
|
|
150
149
|
name: string;
|
|
@@ -318,7 +317,12 @@ export interface LabelPlan {
|
|
|
318
317
|
* cannot drift apart: one spelling of "ready-for-agent" in the package. */
|
|
319
318
|
export const SETUP_DEFAULTS = {
|
|
320
319
|
queueLabel: "ready-for-agent",
|
|
321
|
-
stateLabels: {
|
|
320
|
+
stateLabels: {
|
|
321
|
+
inProgress: "agent:in-progress",
|
|
322
|
+
blocked: "agent:blocked",
|
|
323
|
+
failed: "agent:failed",
|
|
324
|
+
backlog: "backlog",
|
|
325
|
+
},
|
|
322
326
|
routingLabelPrefix: "repo:",
|
|
323
327
|
defaultBranch: "main",
|
|
324
328
|
/** Both authorities start with the human; the wizard asks to move each one. */
|
|
@@ -578,13 +582,15 @@ const POLICY_TEMPLATE_PATH = join(import.meta.dir, "briefs", "policy.md");
|
|
|
578
582
|
const REQUIRED_SCOPES = ["repo", "project"] as const;
|
|
579
583
|
|
|
580
584
|
/** GitHub's own palette, so the tracker reads at a glance: green means queued,
|
|
581
|
-
* blue means moving, amber means waiting on you, red means it gave up,
|
|
582
|
-
*
|
|
585
|
+
* blue means moving, amber means waiting on you, red means it gave up, grey
|
|
586
|
+
* means the operator parked it, and purple means routing — the operator's
|
|
587
|
+
* input, the label the loop never writes. */
|
|
583
588
|
const LABEL_COLOURS = {
|
|
584
589
|
queue: "0e8a16",
|
|
585
590
|
inProgress: "1d76db",
|
|
586
591
|
blocked: "fbca04",
|
|
587
592
|
failed: "b60205",
|
|
593
|
+
backlog: "8b949e",
|
|
588
594
|
routing: "6f42c1",
|
|
589
595
|
} as const;
|
|
590
596
|
|
|
@@ -668,14 +674,15 @@ export async function checkTokenScopes(): Promise<ScopeCheck> {
|
|
|
668
674
|
|
|
669
675
|
/**
|
|
670
676
|
* Every label setup wants on the tracker, before existence is known: the queue
|
|
671
|
-
* and state labels the loop reads and writes,
|
|
672
|
-
*
|
|
673
|
-
*
|
|
677
|
+
* and state labels the loop reads and writes, the operator's park label (which
|
|
678
|
+
* the loop must never write), plus one routing label per routed repo. Pure —
|
|
679
|
+
* the prefix and the repo keys are already answered at this point in the
|
|
680
|
+
* interview — which is what lets a test pin the whole set without `gh`.
|
|
674
681
|
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
682
|
+
* The park label is provisioned like the rest but owned the other way round:
|
|
683
|
+
* it exists so the operator has a gesture, and the conductor never applies or
|
|
684
|
+
* removes it (#507). Creating it queues nothing either — applying one, with
|
|
685
|
+
* the queue label, stays the operator's sign-off.
|
|
679
686
|
*/
|
|
680
687
|
export function wantedLabels(a: SetupAnswers): Omit<LabelPlan, "exists">[] {
|
|
681
688
|
return [
|
|
@@ -699,6 +706,11 @@ export function wantedLabels(a: SetupAnswers): Omit<LabelPlan, "exists">[] {
|
|
|
699
706
|
colour: LABEL_COLOURS.failed,
|
|
700
707
|
description: "The conductor gave up on this issue after its retry budget",
|
|
701
708
|
},
|
|
709
|
+
{
|
|
710
|
+
name: a.stateLabels.backlog,
|
|
711
|
+
colour: LABEL_COLOURS.backlog,
|
|
712
|
+
description: "Parked by the operator — never claimed, only the operator may set or clear it",
|
|
713
|
+
},
|
|
702
714
|
...a.targetRepos.map((r) => ({
|
|
703
715
|
name: `${a.routingLabelPrefix}${r.name}`,
|
|
704
716
|
colour: LABEL_COLOURS.routing,
|
|
@@ -973,7 +985,11 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
973
985
|
name: a.projectName,
|
|
974
986
|
tracker: { kind: "github", repo: a.trackerRepo },
|
|
975
987
|
queueLabel: a.queueLabel,
|
|
976
|
-
|
|
988
|
+
// The park label is now an answer like the other three: the wizard offers
|
|
989
|
+
// it and every answers constructor carries it, so writing the answers as
|
|
990
|
+
// they stand is the single source — slice 1's hardcoded default bridge is
|
|
991
|
+
// gone (#507 slice 2).
|
|
992
|
+
stateLabels: { ...a.stateLabels },
|
|
977
993
|
routing: { labelPrefix: a.routingLabelPrefix, repos },
|
|
978
994
|
caps,
|
|
979
995
|
...(a.workerModel !== undefined && a.workerModel.trim().length > 0
|
|
@@ -1559,6 +1575,53 @@ export function detectTelegram(): TelegramPresence {
|
|
|
1559
1575
|
return result;
|
|
1560
1576
|
}
|
|
1561
1577
|
|
|
1578
|
+
/**
|
|
1579
|
+
* Env override for the daemon account's global omp settings file, exactly the
|
|
1580
|
+
* seam `OMP_TELEGRAM_STATE_DIR` gives telegram discovery: a test (or a second
|
|
1581
|
+
* fleet on the same machine) can redirect the read without touching a real
|
|
1582
|
+
* `~/.omp/agent/config.yml`.
|
|
1583
|
+
*/
|
|
1584
|
+
export const OMP_GLOBAL_SETTINGS_ENV = "OMP_CONDUCTOR_OMP_SETTINGS_PATH";
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* The model roles the daemon account's global omp settings actually configure
|
|
1588
|
+
* — the keys of its `modelRoles` stanza (#875) — the "live OMP role
|
|
1589
|
+
* configuration" an adjudicator answer must be checked against. OMP's own
|
|
1590
|
+
* settings file (`~/.omp/agent/config.yml`) is the source rather than any
|
|
1591
|
+
* conductor-owned registry: conductor stores only role names, so the only
|
|
1592
|
+
* place an unrelated role could be invented is here, and reading this file is
|
|
1593
|
+
* what keeps setup from offering (or accepting) a role OMP never configured.
|
|
1594
|
+
*
|
|
1595
|
+
* A missing file is no roles; an unreadable or malformed one is also no roles
|
|
1596
|
+
* but is reported through `decoded: false` so the wizard can say why instead
|
|
1597
|
+
* of silently offering the default alone.
|
|
1598
|
+
*/
|
|
1599
|
+
export function readConfiguredOmpRoles(): ConfiguredOmpRoles {
|
|
1600
|
+
const override = process.env[OMP_GLOBAL_SETTINGS_ENV]?.trim();
|
|
1601
|
+
const path = override && override.length > 0 ? override : join(homedir(), ".omp", "agent", "config.yml");
|
|
1602
|
+
if (!existsSync(path)) return { path, global: [], decoded: true };
|
|
1603
|
+
try {
|
|
1604
|
+
const parsed = parseYaml(readFileSync(path, "utf8"));
|
|
1605
|
+
return {
|
|
1606
|
+
path,
|
|
1607
|
+
global: [...new Set(modelRolesIn(parsed))].sort(),
|
|
1608
|
+
decoded: true,
|
|
1609
|
+
};
|
|
1610
|
+
} catch {
|
|
1611
|
+
return { path, global: [], decoded: false };
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/** The global omp settings read (`readConfiguredOmpRoles`) reports. */
|
|
1616
|
+
export interface ConfiguredOmpRoles {
|
|
1617
|
+
/** The path read (the override, or the default under the operator's home). */
|
|
1618
|
+
readonly path: string;
|
|
1619
|
+
/** The model-role names the file's `modelRoles` stanza declares. */
|
|
1620
|
+
readonly global: readonly string[];
|
|
1621
|
+
/** False when the file exists but could not be read or parsed. */
|
|
1622
|
+
readonly decoded: boolean;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1562
1625
|
/** True when `.env` carries a non-empty `TELEGRAM_BOT_TOKEN`. The value is
|
|
1563
1626
|
* compared against emptiness and then dropped on the floor. */
|
|
1564
1627
|
function hasTelegramToken(envPath: string): boolean {
|
|
@@ -1780,6 +1843,7 @@ export function summarisePlan(
|
|
|
1780
1843
|
|
|
1781
1844
|
lines.push("", "review", ` strictness ${a.review.strictness} — ${REVIEW_STRICTNESS_CHOICES[a.review.strictness]}`);
|
|
1782
1845
|
lines.push(` max rounds/PR ${a.review.maxRounds} — at the ceiling the PR is left open, the findings recorded, and it is escalated once`);
|
|
1846
|
+
lines.push(` adjudicator ${a.review.adjudicator} — the OMP model role that runs that ceiling adjudication`);
|
|
1783
1847
|
|
|
1784
1848
|
const reporting = project.reporting as ReportingPolicy;
|
|
1785
1849
|
const briefPath = orchestratorBriefPath(a);
|
|
@@ -1997,18 +2061,20 @@ export const AMEND_AREAS: {
|
|
|
1997
2061
|
},
|
|
1998
2062
|
policy: {
|
|
1999
2063
|
name: "merge & release preconditions",
|
|
2000
|
-
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,
|
|
2064
|
+
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",
|
|
2001
2065
|
describe: (p) => {
|
|
2002
2066
|
const policy = resolvePolicy(p);
|
|
2003
2067
|
const review = resolveReview(p);
|
|
2004
2068
|
// Counted rather than listed: this row is elided at 96 characters, and the
|
|
2005
|
-
// full table is in the plan summary the consent screen shows next.
|
|
2069
|
+
// full table is in the plan summary the consent screen shows next. The
|
|
2070
|
+
// adjudicator is the third review decision, so it is named with the other
|
|
2071
|
+
// two (#875).
|
|
2006
2072
|
return (
|
|
2007
2073
|
`merge: ${policy.merge.requiredChecks.length === 0 ? "every check" : `${policy.merge.requiredChecks.length} check(s)`}, ` +
|
|
2008
2074
|
`base ${policy.merge.baseFreshness}, drafts ${policy.merge.drafts}, behind → ${policy.merge.whenBehindBase}; ` +
|
|
2009
2075
|
`release: ${policy.release.requires.length} must-land, ${policy.release.artefacts.length} artefact(s), ` +
|
|
2010
2076
|
`${policy.release.environments.length} env(s); ` +
|
|
2011
|
-
`review ${review.strictness} ×${review.maxRounds}`
|
|
2077
|
+
`review ${review.strictness} ×${review.maxRounds} (adjudicator ${review.adjudicator})`
|
|
2012
2078
|
);
|
|
2013
2079
|
},
|
|
2014
2080
|
},
|
package/src/shell.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One CLI argument rendered exactly as an operator would type it, for surfaces
|
|
3
|
+
* that print executable commands.
|
|
4
|
+
*
|
|
5
|
+
* A command a surface prints is a command the reader copies, so an argument
|
|
6
|
+
* that could mean something else is a defect: a project named `odd $fleet`
|
|
7
|
+
* must not render `--project odd $fleet` (two arguments, and a `$` that the
|
|
8
|
+
* shell would read). Safe characters pass through unquoted; anything else is
|
|
9
|
+
* wrapped in single quotes, folding an embedded quote with the standard
|
|
10
|
+
* `'\''` — the same convention the privileged-step renderer applies to its
|
|
11
|
+
* printed `sudo …` lines (#810).
|
|
12
|
+
*/
|
|
13
|
+
export function shellQuote(value: string): string {
|
|
14
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
15
|
+
}
|