omp-conductor 0.18.1 → 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 +1 -1
- package/REFERENCE.md +1 -1
- package/agents/to-spec.md +6 -2
- package/package.json +1 -1
- package/schema/config.schema.json +6 -1
- package/src/arm-challenge.ts +250 -57
- package/src/briefs/orchestrator.md +21 -8
- package/src/briefs/to-spec.md +6 -2
- package/src/cli.ts +122 -1
- package/src/command-manifest.ts +16 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/intake.ts +4 -19
- package/src/commands/watch.ts +4 -17
- package/src/config-schema.ts +19 -6
- package/src/config.ts +23 -8
- package/src/daemon.ts +150 -241
- package/src/decisions.ts +19 -11
- package/src/doctor.ts +55 -129
- package/src/escalate.ts +22 -11
- package/src/fleet.ts +93 -218
- package/src/host.ts +7 -332
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +11 -56
- package/src/orchestrator-tick.ts +225 -37
- package/src/session-host.ts +6 -41
- package/src/setup-host.ts +27 -10
- package/src/setup-wizard.ts +141 -1
- package/src/setup.ts +54 -4
- package/src/status-render.ts +138 -1
- package/src/to-spec.ts +23 -2
- package/src/types.ts +62 -6
- package/src/verbs/server.ts +38 -3
- package/src/worker.ts +0 -13
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
|
/**
|
|
@@ -1431,6 +1436,136 @@ async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
|
|
|
1431
1436
|
throw new Cancelled();
|
|
1432
1437
|
}
|
|
1433
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
|
+
|
|
1434
1569
|
/**
|
|
1435
1570
|
* How green PRs are reviewed (#678), asked with the merge preconditions and
|
|
1436
1571
|
* the arming proof: they are the same kind of declared policy — a typed,
|
|
@@ -1441,7 +1576,10 @@ async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
|
|
|
1441
1576
|
* rather than between three words. The select cursor opens on the current
|
|
1442
1577
|
* answer (the configured level on a re-run, the recommended default on a
|
|
1443
1578
|
* first run); the rounds are a validated integer within the same range the
|
|
1444
|
-
* 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.
|
|
1445
1583
|
*/
|
|
1446
1584
|
async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPolicy> {
|
|
1447
1585
|
ui.notify(
|
|
@@ -1450,6 +1588,7 @@ async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPol
|
|
|
1450
1588
|
),
|
|
1451
1589
|
"info",
|
|
1452
1590
|
);
|
|
1591
|
+
const live = readConfiguredOmpRoles();
|
|
1453
1592
|
return {
|
|
1454
1593
|
strictness: await askLiteral(
|
|
1455
1594
|
ui,
|
|
@@ -1460,6 +1599,7 @@ async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPol
|
|
|
1460
1599
|
a.review.strictness,
|
|
1461
1600
|
),
|
|
1462
1601
|
maxRounds: await askReviewRounds(ui, a.review.maxRounds),
|
|
1602
|
+
adjudicator: await askReviewAdjudicator(ui, a.review.adjudicator, live, modelRolesIn(a.ompSettings)),
|
|
1463
1603
|
};
|
|
1464
1604
|
}
|
|
1465
1605
|
|
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,
|
|
@@ -1575,6 +1575,53 @@ export function detectTelegram(): TelegramPresence {
|
|
|
1575
1575
|
return result;
|
|
1576
1576
|
}
|
|
1577
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
|
+
|
|
1578
1625
|
/** True when `.env` carries a non-empty `TELEGRAM_BOT_TOKEN`. The value is
|
|
1579
1626
|
* compared against emptiness and then dropped on the floor. */
|
|
1580
1627
|
function hasTelegramToken(envPath: string): boolean {
|
|
@@ -1796,6 +1843,7 @@ export function summarisePlan(
|
|
|
1796
1843
|
|
|
1797
1844
|
lines.push("", "review", ` strictness ${a.review.strictness} — ${REVIEW_STRICTNESS_CHOICES[a.review.strictness]}`);
|
|
1798
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`);
|
|
1799
1847
|
|
|
1800
1848
|
const reporting = project.reporting as ReportingPolicy;
|
|
1801
1849
|
const briefPath = orchestratorBriefPath(a);
|
|
@@ -2013,18 +2061,20 @@ export const AMEND_AREAS: {
|
|
|
2013
2061
|
},
|
|
2014
2062
|
policy: {
|
|
2015
2063
|
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,
|
|
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",
|
|
2017
2065
|
describe: (p) => {
|
|
2018
2066
|
const policy = resolvePolicy(p);
|
|
2019
2067
|
const review = resolveReview(p);
|
|
2020
2068
|
// 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.
|
|
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).
|
|
2022
2072
|
return (
|
|
2023
2073
|
`merge: ${policy.merge.requiredChecks.length === 0 ? "every check" : `${policy.merge.requiredChecks.length} check(s)`}, ` +
|
|
2024
2074
|
`base ${policy.merge.baseFreshness}, drafts ${policy.merge.drafts}, behind → ${policy.merge.whenBehindBase}; ` +
|
|
2025
2075
|
`release: ${policy.release.requires.length} must-land, ${policy.release.artefacts.length} artefact(s), ` +
|
|
2026
2076
|
`${policy.release.environments.length} env(s); ` +
|
|
2027
|
-
`review ${review.strictness} ×${review.maxRounds}`
|
|
2077
|
+
`review ${review.strictness} ×${review.maxRounds} (adjudicator ${review.adjudicator})`
|
|
2028
2078
|
);
|
|
2029
2079
|
},
|
|
2030
2080
|
},
|
package/src/status-render.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { formatZonedMinute } from "./availability.ts";
|
|
21
|
-
import type { DaemonStop } from "./types.ts";
|
|
21
|
+
import type { DaemonStop, GroomingRecord } from "./types.ts";
|
|
22
22
|
import { settlementFlagSummary } from "./diff-flags.ts";
|
|
23
23
|
import type { CodeGraphHealth } from "./graph-health.ts";
|
|
24
24
|
import { formatDigestBacklog, formatOpenReports } from "./reports.ts";
|
|
@@ -206,6 +206,141 @@ function formatLastStop(lastStop: DaemonStop | undefined): string[] {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
|
|
209
|
+
// durable to-spec grooming lifecycle (#809)
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The durable mechanical-hold reasons admission's reconcile writes as blocked
|
|
214
|
+
* rows (#735) — the lane/dependency holds that clear by themselves. Mirrors
|
|
215
|
+
* `BLOCKED_GROOMING_HOLDS` in `store.ts`; the status reads the stored reason
|
|
216
|
+
* strings so both sides of the contract stay on the store vocabulary.
|
|
217
|
+
*/
|
|
218
|
+
const MECHANICAL_GROOMING_REASONS: Record<string, true> = {
|
|
219
|
+
"file-lane": true,
|
|
220
|
+
"depends-on": true,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The to-spec refusal classes persisted as blocked rows (#772) — a result
|
|
225
|
+
* that failed validation is a mechanical block, never a verdict. Mirrors the
|
|
226
|
+
* failure kinds of `ToSpecFailure` in `to-spec.ts`.
|
|
227
|
+
*/
|
|
228
|
+
const REFUSED_GROOMING_REASONS: Record<string, true> = {
|
|
229
|
+
malformed: true,
|
|
230
|
+
"missing-source": true,
|
|
231
|
+
"stale-source": true,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/** The durable in-flight launch marker (#777) — a batch is running right now.
|
|
235
|
+
* Mirrors `TO_SPEC_IN_FLIGHT_REASON` in `orchestrator-tick.ts`. */
|
|
236
|
+
const GROOMING_IN_FLIGHT_REASON = "in-flight";
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* One project's durable grooming state as status lines, or nothing when there
|
|
240
|
+
* is nothing to report (#809).
|
|
241
|
+
*
|
|
242
|
+
* Renders the state already owned by the `grooming_verdicts` table plus the
|
|
243
|
+
* dispatch snapshot's parked count — each category below is derived from the
|
|
244
|
+
* project-scoped durable rows the caller passes, never from parsing evidence
|
|
245
|
+
* strings and never from a second cache:
|
|
246
|
+
*
|
|
247
|
+
* - `awaiting to-spec` — the routable queue candidates the last dispatch
|
|
248
|
+
* pass saw that no durable row covers: `routed − rows`, clamped at zero so
|
|
249
|
+
* stale rows (candidates since removed from the queue) cannot push it
|
|
250
|
+
* negative. `routed` is the same denominator the tick's grooming trigger
|
|
251
|
+
* reads (`summary.routed >= groomBelow`), and the fleet's queue-digest
|
|
252
|
+
* arithmetic already treats durable rows as current-queue facts
|
|
253
|
+
* (`claimable = routed − knownBlocked`), so this is the operator-facing
|
|
254
|
+
* half of the same subtraction. Parent/epic exclusions and backlog
|
|
255
|
+
* candidates that never entered the queue are tracker-side and not
|
|
256
|
+
* store-knowable; the row lines below carry the durable results either
|
|
257
|
+
* way.
|
|
258
|
+
* - `promotable` / `considered` / `blocked` — completed to-spec results
|
|
259
|
+
* (`blocked` rows whose reason is a groomer verdict or a product-judgement
|
|
260
|
+
* label, e.g. `needs-product-decision`).
|
|
261
|
+
* - `mechanically blocked` — admission's lane/dependency holds.
|
|
262
|
+
* - `refused` — to-spec results that failed validation (`malformed`,
|
|
263
|
+
* `missing-source`, `stale-source`), told apart from the holds so an
|
|
264
|
+
* operator sees whether the runway cannot move or a result cannot be
|
|
265
|
+
* trusted.
|
|
266
|
+
* - `in-flight` — a launched batch is running (#777).
|
|
267
|
+
* - `operator-parked` — the dispatch snapshot's parked count (#507).
|
|
268
|
+
*
|
|
269
|
+
* Rendering is read-only: nothing here reconciles, promotes, retries, or
|
|
270
|
+
* mutates grooming records. Empty categories are omitted — zero-value rows
|
|
271
|
+
* would be noise, and an absent block means a fleet with no grooming state.
|
|
272
|
+
*/
|
|
273
|
+
export interface GroomingStatusInput {
|
|
274
|
+
/** Project-scoped durable rows (`Store.groomingVerdicts`), issue-ascending. */
|
|
275
|
+
records: readonly GroomingRecord[];
|
|
276
|
+
/** Routable queue candidates from the last dispatch pass
|
|
277
|
+
* (`DispatchSummary.routed`). */
|
|
278
|
+
routed: number;
|
|
279
|
+
/** Operator-parked candidates from the last dispatch pass
|
|
280
|
+
* (`DispatchSummary.parked`). */
|
|
281
|
+
parked: number;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** How many issue identifiers one category line may carry — enough to act on,
|
|
285
|
+
* never a queue dump. Mirrors the dispatch hold sampling. */
|
|
286
|
+
const GROOMING_STATUS_SAMPLE = 5;
|
|
287
|
+
|
|
288
|
+
/** One category line: label, count, and a bounded sample of issue identifiers
|
|
289
|
+
* with their durable reasons where the reason differs from the line label
|
|
290
|
+
* (e.g. `#13 already-done` under `considered`, `#19 malformed` under
|
|
291
|
+
* `refused`). Absent at zero. */
|
|
292
|
+
function groomingStatusLine(
|
|
293
|
+
label: string,
|
|
294
|
+
rows: readonly GroomingRecord[],
|
|
295
|
+
): string | undefined {
|
|
296
|
+
if (rows.length === 0) return undefined;
|
|
297
|
+
const sample = rows
|
|
298
|
+
.slice(0, GROOMING_STATUS_SAMPLE)
|
|
299
|
+
.map((row) => (row.reason === label ? `#${row.issue}` : `#${row.issue} ${row.reason}`));
|
|
300
|
+
const more = rows.length > GROOMING_STATUS_SAMPLE ? ", …" : "";
|
|
301
|
+
return ` ${label.padEnd(21)}${rows.length} ${sample.join(", ")}${more}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function formatGroomingStatus(input: GroomingStatusInput): string | undefined {
|
|
305
|
+
const { records, routed, parked } = input;
|
|
306
|
+
const promotable = records.filter((row) => row.verdict === "promotable");
|
|
307
|
+
const considered = records.filter((row) => row.verdict === "considered");
|
|
308
|
+
const blockedResults = records.filter(
|
|
309
|
+
(row) =>
|
|
310
|
+
row.verdict === "blocked" &&
|
|
311
|
+
MECHANICAL_GROOMING_REASONS[row.reason] !== true &&
|
|
312
|
+
REFUSED_GROOMING_REASONS[row.reason] !== true &&
|
|
313
|
+
row.reason !== GROOMING_IN_FLIGHT_REASON,
|
|
314
|
+
);
|
|
315
|
+
const mechanical = records.filter(
|
|
316
|
+
(row) => row.verdict === "blocked" && MECHANICAL_GROOMING_REASONS[row.reason] === true,
|
|
317
|
+
);
|
|
318
|
+
const refused = records.filter(
|
|
319
|
+
(row) => row.verdict === "blocked" && REFUSED_GROOMING_REASONS[row.reason] === true,
|
|
320
|
+
);
|
|
321
|
+
const inFlight = records.filter(
|
|
322
|
+
(row) => row.verdict === "blocked" && row.reason === GROOMING_IN_FLIGHT_REASON,
|
|
323
|
+
);
|
|
324
|
+
const awaiting = Math.max(0, routed - records.length);
|
|
325
|
+
const lines: string[] = [];
|
|
326
|
+
if (awaiting > 0) {
|
|
327
|
+
lines.push(` ${"awaiting to-spec".padEnd(21)}${awaiting} (of ${routed} routable)`);
|
|
328
|
+
}
|
|
329
|
+
for (const line of [
|
|
330
|
+
groomingStatusLine("promotable", promotable),
|
|
331
|
+
groomingStatusLine("considered", considered),
|
|
332
|
+
groomingStatusLine("blocked", blockedResults),
|
|
333
|
+
groomingStatusLine("mechanically blocked", mechanical),
|
|
334
|
+
groomingStatusLine("refused", refused),
|
|
335
|
+
groomingStatusLine("in-flight", inFlight),
|
|
336
|
+
]) {
|
|
337
|
+
if (line !== undefined) lines.push(line);
|
|
338
|
+
}
|
|
339
|
+
if (parked > 0) lines.push(` ${"operator-parked".padEnd(21)}${parked}`);
|
|
340
|
+
if (lines.length === 0) return undefined;
|
|
341
|
+
return ["grooming (to-spec)", ...lines].join("\n");
|
|
342
|
+
}
|
|
343
|
+
|
|
209
344
|
export function formatFleetStatus(
|
|
210
345
|
s: StatusSnapshot,
|
|
211
346
|
layers: FleetLayers,
|
|
@@ -220,6 +355,7 @@ export function formatFleetStatus(
|
|
|
220
355
|
intake: string | undefined = undefined,
|
|
221
356
|
lastStop: DaemonStop | undefined = undefined,
|
|
222
357
|
siblings: { project: string; live: number }[] = [],
|
|
358
|
+
grooming: string | undefined = undefined,
|
|
223
359
|
): string {
|
|
224
360
|
const tickLine =
|
|
225
361
|
layers.ticksDetail === undefined
|
|
@@ -306,6 +442,7 @@ export function formatFleetStatus(
|
|
|
306
442
|
...(decisions === undefined ? [] : [decisions]),
|
|
307
443
|
...(failureClasses === undefined ? [] : [failureClasses]),
|
|
308
444
|
...(intake === undefined ? [] : [intake]),
|
|
445
|
+
...(grooming === undefined ? [] : [grooming]),
|
|
309
446
|
...(graphBlock === undefined ? [] : [graphBlock]),
|
|
310
447
|
daemonBlock,
|
|
311
448
|
...formatLastStop(lastStop),
|
package/src/to-spec.ts
CHANGED
|
@@ -33,6 +33,13 @@
|
|
|
33
33
|
* "strict"` to the task tool. Conductor re-validates whatever the agent
|
|
34
34
|
* returned before persisting, because the harness is permissive by design
|
|
35
35
|
* and the store vets only what this module hands it.
|
|
36
|
+
*
|
|
37
|
+
* One contract, every surface (#883): this Zod schema is the single source of
|
|
38
|
+
* truth. `TO_SPEC_SCHEMA` generated from it stamps the native task launch,
|
|
39
|
+
* `parseToSpecResult` persists against it, and the drift test pins the
|
|
40
|
+
* rendered brief, the shipped agent role, and the floor's return contract to
|
|
41
|
+
* exactly these fields — a field the prose asks for that the schema refuses
|
|
42
|
+
* is a candidate recorded blocked for following instructions.
|
|
36
43
|
*/
|
|
37
44
|
|
|
38
45
|
import { z } from "zod";
|
|
@@ -96,6 +103,12 @@ const ToSpecResultSchema = z
|
|
|
96
103
|
.optional()
|
|
97
104
|
.describe("Required when `routing` is `MULTI`: what each slice goes to."),
|
|
98
105
|
source: ToSpecSourceSchema,
|
|
106
|
+
evidence: z
|
|
107
|
+
.array(z.string().trim().min(1))
|
|
108
|
+
.optional()
|
|
109
|
+
.describe(
|
|
110
|
+
"The files/symbols proving the verdict — required for ALREADY DONE (the code that already does the work, never a title match); welcome on every verdict.",
|
|
111
|
+
),
|
|
99
112
|
laterWorkInvalidates: z
|
|
100
113
|
.boolean()
|
|
101
114
|
.describe("Whether later work (an epic committed after this candidate was filed) invalidated its premise."),
|
|
@@ -126,8 +139,10 @@ const ToSpecResultSchema = z
|
|
|
126
139
|
.min(1)
|
|
127
140
|
.describe("The files/dirs this slice writes — the file lane that serialises concurrent work."),
|
|
128
141
|
dependencies: z
|
|
129
|
-
.array(z.string().trim().min(1))
|
|
130
|
-
.describe(
|
|
142
|
+
.array(z.union([z.string().trim().min(1), z.number().int().min(1)]))
|
|
143
|
+
.describe(
|
|
144
|
+
'Open prerequisites this work is blocked on, as bare issue numbers (875) or strings ("875"); empty when none.',
|
|
145
|
+
),
|
|
131
146
|
proposedBrief: z
|
|
132
147
|
.string()
|
|
133
148
|
.trim()
|
|
@@ -159,6 +174,12 @@ const ToSpecResultSchema = z
|
|
|
159
174
|
ctx.addIssue({ code: "custom", message: `${verdict} must not carry proposedBrief` });
|
|
160
175
|
}
|
|
161
176
|
}
|
|
177
|
+
if (verdict === "ALREADY DONE" && (value.evidence === undefined || value.evidence.length === 0)) {
|
|
178
|
+
ctx.addIssue({
|
|
179
|
+
code: "custom",
|
|
180
|
+
message: "ALREADY DONE requires evidence naming the file/symbol that already does the work",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
162
183
|
if (value.routing === MULTI_ROUTING && value.routingSplit === undefined) {
|
|
163
184
|
ctx.addIssue({ code: "custom", message: 'routing "MULTI" requires routingSplit' });
|
|
164
185
|
}
|
package/src/types.ts
CHANGED
|
@@ -638,26 +638,76 @@ export const REVIEW_MAX_ROUNDS_MAX = 6;
|
|
|
638
638
|
* for real correction cycles, and a hard stop against endless polishing. */
|
|
639
639
|
export const DEFAULT_REVIEW_MAX_ROUNDS = 3;
|
|
640
640
|
|
|
641
|
+
/**
|
|
642
|
+
* The one spelling a review policy may ask the adjudicator for: a token naming
|
|
643
|
+
* an OMP model role (`"task"`, `"worker"`, a project's own `modelRoles` key).
|
|
644
|
+
* A role name is never a provider/model — OMP owns provider selection and
|
|
645
|
+
* named model roles, and conductor stores only the role. Used by the config
|
|
646
|
+
* grammar and by the setup dialog's availability check; they must read the
|
|
647
|
+
* same shape or one of them accepts an answer the other rejects.
|
|
648
|
+
*/
|
|
649
|
+
export const REVIEW_ADJUDICATOR_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Which OMP model role carries a PR's terminal review-ceiling adjudication
|
|
653
|
+
* (#875) when no project policy names one. The deterministic migration default
|
|
654
|
+
* every existing project loads until its operator answers setup: the general
|
|
655
|
+
* OMP session role each install can launch without a provider or model being
|
|
656
|
+
* pinned here.
|
|
657
|
+
*/
|
|
658
|
+
export const DEFAULT_REVIEW_ADJUDICATOR_ROLE = "task";
|
|
659
|
+
|
|
641
660
|
/**
|
|
642
661
|
* One project's review policy (#678): the strictness its orchestrator applies
|
|
643
|
-
* when deciding whether a green PR is returned to its worker,
|
|
644
|
-
* ceiling on how many such rounds one PR lifecycle may consume
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
662
|
+
* when deciding whether a green PR is returned to its worker, the hard
|
|
663
|
+
* ceiling on how many such rounds one PR lifecycle may consume, and which OMP
|
|
664
|
+
* model role carries the final adjudication once that ceiling is reached
|
|
665
|
+
* (#875). The ceiling is enforced by the dispatch side, never left to the
|
|
666
|
+
* session's reading of the brief: at the ceiling `conductor_pr_review`
|
|
667
|
+
* refuses, the orchestrator leaves the PR open, records the unresolved
|
|
668
|
+
* findings and escalates once.
|
|
648
669
|
*/
|
|
649
670
|
export interface ReviewPolicy {
|
|
650
671
|
strictness: ReviewStrictness;
|
|
651
672
|
maxRounds: number;
|
|
673
|
+
/**
|
|
674
|
+
* The OMP model role that runs a PR's terminal review-ceiling adjudication:
|
|
675
|
+
* a named role from OMP's configured model roles (`"task"`, `"worker"`, or a
|
|
676
|
+
* project's own `modelRoles` key), in omp's model-role syntax — never a
|
|
677
|
+
* provider/model, which OMP owns. Absent in config loads as
|
|
678
|
+
* {@link DEFAULT_REVIEW_ADJUDICATOR_ROLE}, the deterministic default every
|
|
679
|
+
* existing install upgrades to.
|
|
680
|
+
*/
|
|
681
|
+
adjudicator: string;
|
|
652
682
|
}
|
|
653
683
|
|
|
654
|
-
/** The documented migration default: what a config written before the
|
|
684
|
+
/** The documented migration default: what a config written before the fields
|
|
655
685
|
* existed loads as, deterministically, until the operator answers setup. */
|
|
656
686
|
export const DEFAULT_REVIEW_POLICY: ReviewPolicy = {
|
|
657
687
|
strictness: DEFAULT_REVIEW_STRICTNESS,
|
|
658
688
|
maxRounds: DEFAULT_REVIEW_MAX_ROUNDS,
|
|
689
|
+
adjudicator: DEFAULT_REVIEW_ADJUDICATOR_ROLE,
|
|
659
690
|
};
|
|
660
691
|
|
|
692
|
+
/**
|
|
693
|
+
* The resolved launch provenance of one review-ceiling adjudication (#875):
|
|
694
|
+
* what the policy named and what the role actually resolved to when the
|
|
695
|
+
* adjudicator session launched. OMP owns named roles and exact provider
|
|
696
|
+
* selection, so this record is where a launch's real model/provider lands —
|
|
697
|
+
* the typed fields later status/ledger code reads to show what genuinely
|
|
698
|
+
* adjudicated, not what a config said it should.
|
|
699
|
+
*/
|
|
700
|
+
export interface ReviewAdjudicationProvenance {
|
|
701
|
+
/** The policy's adjudicator role, verbatim from {@link ReviewPolicy.adjudicator}. */
|
|
702
|
+
readonly role: string;
|
|
703
|
+
/** The model pattern the role resolved to at launch, in omp's model syntax. */
|
|
704
|
+
readonly model: string;
|
|
705
|
+
/** The provider OMP selected, when the harness could name one at launch. */
|
|
706
|
+
readonly provider?: string;
|
|
707
|
+
/** Epoch ms when this launch resolution was recorded. */
|
|
708
|
+
readonly resolvedAt: number;
|
|
709
|
+
}
|
|
710
|
+
|
|
661
711
|
/**
|
|
662
712
|
* One durable review-revision request (#677): the orchestrator returned a
|
|
663
713
|
* green, run-owned pull request to its worker with blocking findings, and
|
|
@@ -2974,6 +3024,12 @@ export const VERB_REFUSALS = [
|
|
|
2974
3024
|
/** The routed repository a terminal run recorded no longer has an entry in
|
|
2975
3025
|
* this project's routing, so the daemon cannot create a PR for it. */
|
|
2976
3026
|
"recovery-repo-unrouted",
|
|
3027
|
+
/** The run's durable `prUrl` is a non-URL value the tracker cannot address
|
|
3028
|
+
* (other than the normalized `pending` settlement sentinel, #866). There is
|
|
3029
|
+
* no PR to read and none to guess at — a corrupt record, not an unreadable
|
|
3030
|
+
* one, so it refuses by name instead of reading as a never-clearing
|
|
3031
|
+
* `head-unresolvable`. */
|
|
3032
|
+
"recorded-pr-corrupt",
|
|
2977
3033
|
/** The run has no pull request to act on. */
|
|
2978
3034
|
"pr-missing",
|
|
2979
3035
|
/**
|
package/src/verbs/server.ts
CHANGED
|
@@ -1751,8 +1751,9 @@ async function prReviewVerb(
|
|
|
1751
1751
|
const findings = String(args["findings"]);
|
|
1752
1752
|
const reason = String(args["reason"]) as ReviewReason;
|
|
1753
1753
|
|
|
1754
|
-
// The shared review-readiness gate (#844): the newest project-owned
|
|
1755
|
-
// that recorded the PR
|
|
1754
|
+
// The shared review-readiness gate (#844, #870): the newest project-owned
|
|
1755
|
+
// run that recorded the PR — a stopped duplicate is transparent to that
|
|
1756
|
+
// selection (#870) — in a revisable state; the same predicate the
|
|
1756
1757
|
// `pr-review-ready` watch reads, so the verb and the condition resolve
|
|
1757
1758
|
// ownership and revisability identically. A PR no run of this project
|
|
1758
1759
|
// opened (or one outside the routed repos) cannot be returned to a worker
|
|
@@ -1958,6 +1959,18 @@ const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
|
|
|
1958
1959
|
*/
|
|
1959
1960
|
const ABBREVIATED_HEAD_SHA = /^[0-9a-f]{7,39}$/i;
|
|
1960
1961
|
|
|
1962
|
+
/**
|
|
1963
|
+
* The durable PR field's worker settlement sentinel (#866). A run row whose
|
|
1964
|
+
* settlement recorded "no PR observed yet" carries the literal `pending`
|
|
1965
|
+
* instead of a URL. It asserts nothing about any GitHub pull request — there
|
|
1966
|
+
* is no PR to read — so recovery normalizes it to absence and re-proves the
|
|
1967
|
+
* no-duplicate shape before opening a PR, exactly like a row that recorded no
|
|
1968
|
+
* URL at all. Querying GitHub with it would only produce the tracker's
|
|
1969
|
+
* fail-closed `undefined`, which recovery would misread as an unreadable-but-
|
|
1970
|
+
* real PR and refuse as retryable forever.
|
|
1971
|
+
*/
|
|
1972
|
+
const PR_PENDING_SENTINEL = "pending";
|
|
1973
|
+
|
|
1961
1974
|
/**
|
|
1962
1975
|
* Open (or adopt) the missing pull request for a settled run whose branch was
|
|
1963
1976
|
* preserved (#806).
|
|
@@ -2302,7 +2315,29 @@ async function prRecoverVerb(
|
|
|
2302
2315
|
);
|
|
2303
2316
|
}
|
|
2304
2317
|
|
|
2305
|
-
|
|
2318
|
+
// The durable PR field is normalized and validated before any recorded-PR
|
|
2319
|
+
// lookup (#866). A real recorded PR is a pull-request URL the tracker can
|
|
2320
|
+
// address — the `prUrlParts` shape, the same guard the tracker's own
|
|
2321
|
+
// `prState` applies. The `pending` settlement sentinel is not a URL: it
|
|
2322
|
+
// records the run believed a PR was in flight but never observed one, so it
|
|
2323
|
+
// is normalized to absence and recovery proceeds like a run with no
|
|
2324
|
+
// recorded PR. Any other non-URL value is corruption — there is nothing for
|
|
2325
|
+
// the tracker to read, and had it been queried the fail-closed `undefined`
|
|
2326
|
+
// would read as an unreadable-but-real PR that can never clear — so it
|
|
2327
|
+
// refuses by name instead of hanging the run on a retry.
|
|
2328
|
+
const recordedPrUrl = target.prUrl;
|
|
2329
|
+
const pendingSentinel = recordedPrUrl === PR_PENDING_SENTINEL;
|
|
2330
|
+
if (recordedPrUrl !== undefined && !pendingSentinel && prUrlParts(recordedPrUrl) === undefined) {
|
|
2331
|
+
return refuse(
|
|
2332
|
+
"recorded-pr-corrupt",
|
|
2333
|
+
`refused: run ${target.id} for #${issue} records prUrl ${JSON.stringify(recordedPrUrl)}, which is not a pull ` +
|
|
2334
|
+
"request URL. A non-URL durable prUrl is a corrupt run record, not an unreadable PR — recovery will not " +
|
|
2335
|
+
"guess a GitHub pull request from it. Clear or correct the run's prUrl, then retry.",
|
|
2336
|
+
issue,
|
|
2337
|
+
);
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
if (!pendingSentinel && target.prUrl !== undefined) {
|
|
2306
2341
|
// The run believes it has a PR. Before creating one, the recorded URL is
|
|
2307
2342
|
// decided: still open → it is the recovery's answer (idempotent); merged →
|
|
2308
2343
|
// the work landed; closed → a human declined it; definitively missing →
|