omp-conductor 0.17.1 → 0.18.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/REFERENCE.md +11 -7
- package/package.json +1 -1
- package/schema/config.schema.json +24 -1
- package/src/admission.ts +104 -1
- package/src/ask.ts +39 -3
- package/src/backups.ts +2 -2
- package/src/briefs/orchestrator.md +1 -0
- package/src/briefs/worker.md +36 -19
- package/src/command-help.ts +8 -1
- package/src/command-manifest.ts +5 -2
- package/src/commands/watch.ts +62 -3
- package/src/config-schema.ts +33 -0
- package/src/config.ts +60 -1
- package/src/daemon.ts +253 -1479
- package/src/decisions.ts +51 -6
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +350 -0
- package/src/digest-schedule.ts +37 -0
- package/src/failure-class.ts +15 -2
- package/src/fleet.ts +20 -2
- package/src/graph-health.ts +20 -7
- package/src/graph.ts +313 -68
- package/src/lifecycle.ts +36 -5
- package/src/omp.ts +42 -0
- package/src/orchestrator-tick.ts +104 -12
- package/src/routing.ts +11 -3
- package/src/session-host.ts +16 -0
- package/src/settlement.ts +1728 -0
- package/src/setup-install.ts +91 -30
- package/src/setup-wizard.ts +75 -0
- package/src/setup.ts +93 -3
- package/src/status-render.ts +31 -9
- package/src/store.ts +78 -5
- package/src/tracker/github.ts +46 -0
- package/src/types.ts +127 -3
- package/src/verbs/server.ts +58 -2
- package/src/worker.ts +214 -7
- package/src/worktree.ts +115 -8
package/src/store.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { Database } from "bun:sqlite";
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
|
-
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs";
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
16
|
|
|
17
17
|
import { backupTimestamp, publishTempFile } from "./backups.ts";
|
|
@@ -146,6 +146,7 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
|
146
146
|
autoRetryCount: true,
|
|
147
147
|
autoCompactionCount: true,
|
|
148
148
|
sessionFile: true,
|
|
149
|
+
resumedFromRunId: true,
|
|
149
150
|
prUrl: true,
|
|
150
151
|
headSha: true,
|
|
151
152
|
mergeSha: true,
|
|
@@ -155,6 +156,7 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
|
155
156
|
salvageSha: true,
|
|
156
157
|
salvageError: true,
|
|
157
158
|
salvageAckAt: true,
|
|
159
|
+
quarantineDetail: true,
|
|
158
160
|
startedAt: true,
|
|
159
161
|
endedAt: true,
|
|
160
162
|
lastError: true,
|
|
@@ -191,6 +193,7 @@ interface RunRow {
|
|
|
191
193
|
autoRetryCount: number | null;
|
|
192
194
|
autoCompactionCount: number | null;
|
|
193
195
|
sessionFile: string | null;
|
|
196
|
+
resumedFromRunId: string | null;
|
|
194
197
|
prUrl: string | null;
|
|
195
198
|
headSha: string | null;
|
|
196
199
|
mergeSha: string | null;
|
|
@@ -200,6 +203,7 @@ interface RunRow {
|
|
|
200
203
|
salvageSha: string | null;
|
|
201
204
|
salvageError: string | null;
|
|
202
205
|
salvageAckAt: number | null;
|
|
206
|
+
quarantineDetail: string | null;
|
|
203
207
|
startedAt: number;
|
|
204
208
|
endedAt: number | null;
|
|
205
209
|
lastError: string | null;
|
|
@@ -517,6 +521,7 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
517
521
|
autoRetryCount INTEGER,
|
|
518
522
|
autoCompactionCount INTEGER,
|
|
519
523
|
sessionFile TEXT,
|
|
524
|
+
resumedFromRunId TEXT,
|
|
520
525
|
prUrl TEXT,
|
|
521
526
|
headSha TEXT,
|
|
522
527
|
mergeSha TEXT,
|
|
@@ -991,6 +996,7 @@ function toRecord(row: RunRow): RunRecord {
|
|
|
991
996
|
startedAt: row.startedAt,
|
|
992
997
|
};
|
|
993
998
|
if (row.sessionFile !== null) record.sessionFile = row.sessionFile;
|
|
999
|
+
if (row.resumedFromRunId !== null) record.resumedFromRunId = row.resumedFromRunId;
|
|
994
1000
|
if (row.prUrl !== null) record.prUrl = row.prUrl;
|
|
995
1001
|
if (row.headSha !== null) record.headSha = row.headSha;
|
|
996
1002
|
if (row.mergeSha !== null) record.mergeSha = row.mergeSha;
|
|
@@ -1000,6 +1006,7 @@ function toRecord(row: RunRow): RunRecord {
|
|
|
1000
1006
|
if (row.salvageSha !== null) record.salvageSha = row.salvageSha;
|
|
1001
1007
|
if (row.salvageError !== null) record.salvageError = row.salvageError;
|
|
1002
1008
|
if (row.salvageAckAt !== null) record.salvageAckAt = row.salvageAckAt;
|
|
1009
|
+
if (row.quarantineDetail !== null) record.quarantineDetail = row.quarantineDetail;
|
|
1003
1010
|
if (row.endedAt !== null) record.endedAt = row.endedAt;
|
|
1004
1011
|
if (row.lastError !== null) record.lastError = row.lastError;
|
|
1005
1012
|
if (row.settlementFlags !== null) {
|
|
@@ -1318,9 +1325,13 @@ export function vacuumInto(source: string, target: string): void {
|
|
|
1318
1325
|
* A fresh, restorable copy of the store at `source`, published under `destDir`
|
|
1319
1326
|
* with the same temp-then-atomic-publish convention as the config backups.
|
|
1320
1327
|
*
|
|
1328
|
+
* The stem carries `at` (default now) so the filename's timestamp is the
|
|
1329
|
+
* snapshot's logical instant — the cadence passes its tick time, keeping the
|
|
1330
|
+
* name, the day marker and the retention ordering in one timeline.
|
|
1331
|
+
*
|
|
1321
1332
|
* Returns the published snapshot path.
|
|
1322
1333
|
*/
|
|
1323
|
-
export function snapshotDb(source: string, destDir: string): string {
|
|
1334
|
+
export function snapshotDb(source: string, destDir: string, at: number = Date.now()): string {
|
|
1324
1335
|
if (source === ":memory:") throw new Error("cannot snapshot an in-memory store");
|
|
1325
1336
|
if (!existsSync(source)) {
|
|
1326
1337
|
throw new Error(`cannot snapshot ${source}: the store does not exist`);
|
|
@@ -1334,7 +1345,34 @@ export function snapshotDb(source: string, destDir: string): string {
|
|
|
1334
1345
|
rmSync(temporary, { force: true });
|
|
1335
1346
|
throw err;
|
|
1336
1347
|
}
|
|
1337
|
-
return publishTempFile(temporary, destDir, `${DB_SNAPSHOT_STEM}-${backupTimestamp()}`);
|
|
1348
|
+
return publishTempFile(temporary, destDir, `${DB_SNAPSHOT_STEM}-${backupTimestamp(at)}`);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* How many conductor.db snapshots the daemon's daily cadence keeps — a week
|
|
1353
|
+
* of ledger history at one snapshot per day. Deliberately a fixed count, not
|
|
1354
|
+
* an age or space policy: the store's ledger is append-only and the issue
|
|
1355
|
+
* forbids inventing a fleet-wide retention policy, so pruning is
|
|
1356
|
+
* configured-count only (#289).
|
|
1357
|
+
*/
|
|
1358
|
+
export const DB_SNAPSHOT_RETENTION = 7;
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Prune a snapshot directory to the newest `keep` conductor.db snapshots.
|
|
1362
|
+
*
|
|
1363
|
+
* Snapshot stems embed the publish timestamp (`YYYY-MM-DDTHH-MM-SS…`), so
|
|
1364
|
+
* lexicographic order is chronological; only files named with the shared
|
|
1365
|
+
* `DB_SNAPSHOT_STEM` prefix are considered, never unrelated backups next to
|
|
1366
|
+
* them. Idempotent: a directory already at or under the bound removes
|
|
1367
|
+
* nothing. Returns the number of files removed.
|
|
1368
|
+
*/
|
|
1369
|
+
export function pruneDbSnapshots(dir: string, keep: number): number {
|
|
1370
|
+
const names = readdirSync(dir)
|
|
1371
|
+
.filter((name) => name.startsWith(DB_SNAPSHOT_STEM))
|
|
1372
|
+
.sort();
|
|
1373
|
+
const stale = names.length > keep ? names.slice(0, names.length - keep) : [];
|
|
1374
|
+
for (const name of stale) unlinkSync(join(dir, name));
|
|
1375
|
+
return stale.length;
|
|
1338
1376
|
}
|
|
1339
1377
|
|
|
1340
1378
|
/** The UTC calendar day (`YYYY-MM-DD`) a moment falls on — the partition key
|
|
@@ -1421,6 +1459,14 @@ export function openStore(dbPath: string): Store {
|
|
|
1421
1459
|
db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
|
|
1422
1460
|
}
|
|
1423
1461
|
}
|
|
1462
|
+
// Rows written before #737 were never asked whether their tree was
|
|
1463
|
+
// quarantined, and a NULL means exactly that: the pre-quarantine passes
|
|
1464
|
+
// reaped the same trees they always had, so nothing before this release
|
|
1465
|
+
// needs backfilling — the column starts null and only a post-release pass
|
|
1466
|
+
// that actually refuses a broken store writes it.
|
|
1467
|
+
if (!columns.some((column) => column.name === "quarantineDetail")) {
|
|
1468
|
+
db.exec("ALTER TABLE runs ADD COLUMN quarantineDetail TEXT");
|
|
1469
|
+
}
|
|
1424
1470
|
// Rows written before the settlement audit existed (#128) were never audited,
|
|
1425
1471
|
// so a NULL here means "no audit ran" and reads the same as "found nothing".
|
|
1426
1472
|
// That conflation is deliberate and harmless: the flags are advisory, and no
|
|
@@ -1455,6 +1501,13 @@ export function openStore(dbPath: string): Store {
|
|
|
1455
1501
|
if (!columns.some((column) => column.name === "model")) {
|
|
1456
1502
|
db.exec("ALTER TABLE runs ADD COLUMN model TEXT");
|
|
1457
1503
|
}
|
|
1504
|
+
// Resume provenance (#567): rows written before #564 landed were never
|
|
1505
|
+
// resumed, so NULL is the honest reading of "no resume happened" — never
|
|
1506
|
+
// "resumed from unknown". No backfill: the identity existed only in the
|
|
1507
|
+
// dispatch decision, and deriving it now would guess at history.
|
|
1508
|
+
if (!columns.some((column) => column.name === "resumedFromRunId")) {
|
|
1509
|
+
db.exec("ALTER TABLE runs ADD COLUMN resumedFromRunId TEXT");
|
|
1510
|
+
}
|
|
1458
1511
|
// The count of in-session provider 429s a run recorded was first written by
|
|
1459
1512
|
// the worker (#573). Rows predating the column are NULL, which is the honest
|
|
1460
1513
|
// reading: the count was not recorded, so the classifier must not treat the
|
|
@@ -1614,10 +1667,10 @@ export function openStore(dbPath: string): Store {
|
|
|
1614
1667
|
const insertRun = db.query<unknown, SqlValue[]>(
|
|
1615
1668
|
`INSERT INTO runs (
|
|
1616
1669
|
id, project, issue, repo, branch, worktree, state, attempt, turns,
|
|
1617
|
-
maxTurns, spendUsd, sessionFile, prUrl, headSha, mergeSha, baseRef,
|
|
1670
|
+
maxTurns, spendUsd, sessionFile, resumedFromRunId, prUrl, headSha, mergeSha, baseRef,
|
|
1618
1671
|
baseCheck, baseCheckAt, salvageSha, salvageError, salvageAckAt, startedAt,
|
|
1619
1672
|
endedAt, lastError, settlementFlags, report
|
|
1620
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1673
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1621
1674
|
);
|
|
1622
1675
|
const selectRun = db.query<RunRow, [string]>(`SELECT * FROM runs WHERE id = ?`);
|
|
1623
1676
|
const selectActive = db.query<RunRow, SqlValue[]>(
|
|
@@ -1870,6 +1923,21 @@ export function openStore(dbPath: string): Store {
|
|
|
1870
1923
|
AND (salvageSha IS NOT NULL OR salvageError IS NOT NULL)
|
|
1871
1924
|
ORDER BY issue ASC`,
|
|
1872
1925
|
);
|
|
1926
|
+
// Quarantined retained trees (#737), newest attempt per issue — the same
|
|
1927
|
+
// shape as `selectSalvaged` because the same change can land twice: a row
|
|
1928
|
+
// long since reaped must not keep its issue looking quarantined, and an
|
|
1929
|
+
// older quarantined attempt must not claim the issue while a newer,
|
|
1930
|
+
// healthy one replaced it.
|
|
1931
|
+
const selectQuarantined = db.query<RunRow, [string]>(
|
|
1932
|
+
`SELECT * FROM runs
|
|
1933
|
+
WHERE rowid IN (
|
|
1934
|
+
SELECT MAX(rowid) FROM runs
|
|
1935
|
+
WHERE project = ?
|
|
1936
|
+
GROUP BY issue
|
|
1937
|
+
)
|
|
1938
|
+
AND quarantineDetail IS NOT NULL
|
|
1939
|
+
ORDER BY issue ASC`,
|
|
1940
|
+
);
|
|
1873
1941
|
// Newest attempt for one issue. `startedAt` is millisecond-resolution and two
|
|
1874
1942
|
// attempts could in principle share one, so rowid breaks the tie by insertion
|
|
1875
1943
|
// order — a `tail` that attached to the older of two same-millisecond attempts
|
|
@@ -2708,6 +2776,7 @@ export function openStore(dbPath: string): Store {
|
|
|
2708
2776
|
record.maxTurns,
|
|
2709
2777
|
record.spendUsd,
|
|
2710
2778
|
toSql(record.sessionFile),
|
|
2779
|
+
toSql(record.resumedFromRunId),
|
|
2711
2780
|
toSql(record.prUrl),
|
|
2712
2781
|
toSql(record.headSha),
|
|
2713
2782
|
toSql(record.mergeSha),
|
|
@@ -2885,6 +2954,10 @@ export function openStore(dbPath: string): Store {
|
|
|
2885
2954
|
return selectSalvaged.all(project).map(toRecord);
|
|
2886
2955
|
},
|
|
2887
2956
|
|
|
2957
|
+
quarantinedRuns(project: string): RunRecord[] {
|
|
2958
|
+
return selectQuarantined.all(project).map(toRecord);
|
|
2959
|
+
},
|
|
2960
|
+
|
|
2888
2961
|
attemptsFor(project: string, issue: number): number {
|
|
2889
2962
|
return countAttempts.get(project, issue)?.n ?? 0;
|
|
2890
2963
|
},
|
package/src/tracker/github.ts
CHANGED
|
@@ -1603,6 +1603,18 @@ export function makeTracker(
|
|
|
1603
1603
|
);
|
|
1604
1604
|
},
|
|
1605
1605
|
|
|
1606
|
+
async getIssue(issue: number): Promise<ReadyIssue | undefined> {
|
|
1607
|
+
try {
|
|
1608
|
+
// The single-issue REST answer is that one object alone; it is wrapped
|
|
1609
|
+
// so the shared parser stays the single mapping of a GitHub row onto
|
|
1610
|
+
// ReadyIssue. A missing issue or an unreadable API answers undefined,
|
|
1611
|
+
// never a guessed or partial row.
|
|
1612
|
+
return readyIssuesFromRest(`[${await runGh(["api", `repos/${repo}/issues/${issue}`])}]`)[0];
|
|
1613
|
+
} catch {
|
|
1614
|
+
return undefined;
|
|
1615
|
+
}
|
|
1616
|
+
},
|
|
1617
|
+
|
|
1606
1618
|
async addLabel(issue: number, label: string): Promise<void> {
|
|
1607
1619
|
try {
|
|
1608
1620
|
// REST POST is natively idempotent: re-adding a label the issue already
|
|
@@ -1691,6 +1703,21 @@ export function makeTracker(
|
|
|
1691
1703
|
|
|
1692
1704
|
issueSnapshot: readIssueSnapshot,
|
|
1693
1705
|
|
|
1706
|
+
async issueBody(issue: number): Promise<string | undefined> {
|
|
1707
|
+
try {
|
|
1708
|
+
// Same single-issue REST read as `issueSnapshot`, projected onto the
|
|
1709
|
+
// body alone for the dependency-graph cycle pass (#421).
|
|
1710
|
+
return await runGh([
|
|
1711
|
+
"api",
|
|
1712
|
+
`repos/${repo}/issues/${issue}`,
|
|
1713
|
+
"--jq",
|
|
1714
|
+
".body // \"\"",
|
|
1715
|
+
]);
|
|
1716
|
+
} catch {
|
|
1717
|
+
return undefined;
|
|
1718
|
+
}
|
|
1719
|
+
},
|
|
1720
|
+
|
|
1694
1721
|
async prState(url: string): Promise<PrState | undefined> {
|
|
1695
1722
|
// No `--repo`: the full URL names the repository, and the daemon runs
|
|
1696
1723
|
// from its own state directory rather than a checkout — so the REST path
|
|
@@ -1925,6 +1952,25 @@ export function makeTracker(
|
|
|
1925
1952
|
}
|
|
1926
1953
|
},
|
|
1927
1954
|
|
|
1955
|
+
async prBody(url: string): Promise<string | undefined> {
|
|
1956
|
+
// Same guarded REST read as `issueBody`, projected onto the PR: the
|
|
1957
|
+
// settlement audit's claimed-proof check is advisory, so an unreadable
|
|
1958
|
+
// body costs one unaudited flag family and never a run's state.
|
|
1959
|
+
if (!PR_URL.test(url)) return undefined;
|
|
1960
|
+
const parts = prUrlParts(url);
|
|
1961
|
+
if (parts === undefined) return undefined;
|
|
1962
|
+
try {
|
|
1963
|
+
return await runGh([
|
|
1964
|
+
"api",
|
|
1965
|
+
`repos/${parts.owner}/${parts.repo}/pulls/${parts.number}`,
|
|
1966
|
+
"--jq",
|
|
1967
|
+
".body // \"\"",
|
|
1968
|
+
]);
|
|
1969
|
+
} catch {
|
|
1970
|
+
return undefined;
|
|
1971
|
+
}
|
|
1972
|
+
},
|
|
1973
|
+
|
|
1928
1974
|
async prDiff(url: string): Promise<PrDiff | undefined> {
|
|
1929
1975
|
if (!PR_URL.test(url)) return undefined;
|
|
1930
1976
|
try {
|
package/src/types.ts
CHANGED
|
@@ -95,7 +95,8 @@ export interface RepoTarget {
|
|
|
95
95
|
/**
|
|
96
96
|
* Absolute path of the **conductor-owned, index-only clone** of this repo
|
|
97
97
|
* whose code-graph index workers query. Optional: absent means this repo has
|
|
98
|
-
* no graph, and the worker brief says
|
|
98
|
+
* no graph, and the worker brief says so explicitly rather than staying
|
|
99
|
+
* silent — a worker that knows there is no graph stops looking for one.
|
|
99
100
|
*
|
|
100
101
|
* Two things it deliberately is not, and both were paid for:
|
|
101
102
|
*
|
|
@@ -609,6 +610,54 @@ export const REVIEW_REVISION_OUTCOMES = ["revised", "pending", "failed", "skippe
|
|
|
609
610
|
|
|
610
611
|
export type ReviewRevisionOutcome = (typeof REVIEW_REVISION_OUTCOMES)[number];
|
|
611
612
|
|
|
613
|
+
/**
|
|
614
|
+
* How strictly this project's green PRs are reviewed before they merge
|
|
615
|
+
* (#678). One of three typed levels; each level's bar is documented once in
|
|
616
|
+
* {@link REVIEW_STRICTNESS_CHOICES} (setup.ts), which is the same single text
|
|
617
|
+
* the rendered orchestrator brief carries, so the level a config declares and
|
|
618
|
+
* the threshold a session actually enforces cannot disagree.
|
|
619
|
+
*
|
|
620
|
+
* `low` blocks only correctness, security, data-loss or explicit
|
|
621
|
+
* acceptance-criteria failures. `medium` (the recommended default for a new
|
|
622
|
+
* project) adds material maintainability or reliability defects likely to
|
|
623
|
+
* become incidents within six months. `high` adds concrete quality defects —
|
|
624
|
+
* never subjective style churn or unbounded refactoring.
|
|
625
|
+
*/
|
|
626
|
+
export const REVIEW_STRICTNESS = ["low", "medium", "high"] as const;
|
|
627
|
+
|
|
628
|
+
export type ReviewStrictness = (typeof REVIEW_STRICTNESS)[number];
|
|
629
|
+
|
|
630
|
+
/** The recommended strictness for a project that has never answered. */
|
|
631
|
+
export const DEFAULT_REVIEW_STRICTNESS: ReviewStrictness = "medium";
|
|
632
|
+
|
|
633
|
+
/** The small validated range a project's own review-round ceiling may sit in. */
|
|
634
|
+
export const REVIEW_MAX_ROUNDS_MIN = 1;
|
|
635
|
+
export const REVIEW_MAX_ROUNDS_MAX = 6;
|
|
636
|
+
|
|
637
|
+
/** The typed default ceiling: three corrected heads per PR lifecycle — enough
|
|
638
|
+
* for real correction cycles, and a hard stop against endless polishing. */
|
|
639
|
+
export const DEFAULT_REVIEW_MAX_ROUNDS = 3;
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* One project's review policy (#678): the strictness its orchestrator applies
|
|
643
|
+
* when deciding whether a green PR is returned to its worker, and the hard
|
|
644
|
+
* ceiling on how many such rounds one PR lifecycle may consume. The ceiling is
|
|
645
|
+
* enforced by the dispatch side, never left to the session's reading of the
|
|
646
|
+
* brief: at the ceiling `conductor_pr_review` refuses, the orchestrator leaves
|
|
647
|
+
* the PR open, records the unresolved findings and escalates once.
|
|
648
|
+
*/
|
|
649
|
+
export interface ReviewPolicy {
|
|
650
|
+
strictness: ReviewStrictness;
|
|
651
|
+
maxRounds: number;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** The documented migration default: what a config written before the key
|
|
655
|
+
* existed loads as, deterministically, until the operator answers setup. */
|
|
656
|
+
export const DEFAULT_REVIEW_POLICY: ReviewPolicy = {
|
|
657
|
+
strictness: DEFAULT_REVIEW_STRICTNESS,
|
|
658
|
+
maxRounds: DEFAULT_REVIEW_MAX_ROUNDS,
|
|
659
|
+
};
|
|
660
|
+
|
|
612
661
|
/**
|
|
613
662
|
* One durable review-revision request (#677): the orchestrator returned a
|
|
614
663
|
* green, run-owned pull request to its worker with blocking findings, and
|
|
@@ -716,8 +765,11 @@ export interface ProjectConfig {
|
|
|
716
765
|
* orchestrator-tick.ts. */
|
|
717
766
|
groomBelow?: number;
|
|
718
767
|
/** Labels the dispatcher writes back so the tracker alone shows live state
|
|
719
|
-
* to a human who never opens the daemon's logs.
|
|
720
|
-
|
|
768
|
+
* to a human who never opens the daemon's logs. `backlog` is the
|
|
769
|
+
* operator's own park gesture (#507) — not dispatcher-written, but read by
|
|
770
|
+
* eligibility so a parked issue can never be claimed while its queue label
|
|
771
|
+
* is still physically present. */
|
|
772
|
+
stateLabels: { inProgress: string; blocked: string; failed: string; backlog: string };
|
|
721
773
|
/** Maps a `${labelPrefix}${name}` label on an issue to the checkout it
|
|
722
774
|
* belongs in, so routing is declared by humans, not guessed. */
|
|
723
775
|
routing: { labelPrefix: string; repos: Record<string, RepoTarget> };
|
|
@@ -811,6 +863,16 @@ export interface ProjectConfig {
|
|
|
811
863
|
* reaching for `.merge` on a project some test hand-built.
|
|
812
864
|
*/
|
|
813
865
|
policy?: ProjectPolicy;
|
|
866
|
+
/**
|
|
867
|
+
* How green PRs are reviewed and returned (#678): the strictness level and
|
|
868
|
+
* the hard ceiling on review rounds per PR lifecycle. Optional only for
|
|
869
|
+
* configs written before the key existed — the loader always materialises a
|
|
870
|
+
* complete value, so read it through `resolveReview` rather than reaching
|
|
871
|
+
* for `.review` on a project some test hand-built. A config that never
|
|
872
|
+
* answered migrates to {@link DEFAULT_REVIEW_POLICY} deterministically, and
|
|
873
|
+
* the schema's bounds agree with the runtime ones.
|
|
874
|
+
*/
|
|
875
|
+
review?: ReviewPolicy;
|
|
814
876
|
/**
|
|
815
877
|
* Exact operator-authored exceptions for recovery PRs that no run recorded.
|
|
816
878
|
* Optional and hand-edited: setup preserves entries but never invents them.
|
|
@@ -963,6 +1025,12 @@ export const SETTLEMENT_FLAG_KINDS = [
|
|
|
963
1025
|
"assertions-removed",
|
|
964
1026
|
/** A named timeout in a test file went up. */
|
|
965
1027
|
"test-timeout-raised",
|
|
1028
|
+
/** A command the PR body claims as proof, with no matching command in the
|
|
1029
|
+
* run's transcript — the claim has no attempt behind it at all. */
|
|
1030
|
+
"claimed-proof-missing",
|
|
1031
|
+
/** A claimed proof command was attempted, but the shared-host guard refused
|
|
1032
|
+
* it — the honest blocked shape, told apart from a claim with no attempt. */
|
|
1033
|
+
"claimed-proof-blocked",
|
|
966
1034
|
/** A terminal run recovered an OPEN PR by exact issue, repo and branch identity. */
|
|
967
1035
|
"pr-adopted",
|
|
968
1036
|
/** A workflow on the merge commit's base branch failed after settlement. */
|
|
@@ -1063,6 +1131,14 @@ export interface Tracker {
|
|
|
1063
1131
|
* read exists to prevent (#517).
|
|
1064
1132
|
*/
|
|
1065
1133
|
listComments(issue: number): Promise<IssueComment[]>;
|
|
1134
|
+
/**
|
|
1135
|
+
* One issue's body, or undefined when the tracker could not produce it.
|
|
1136
|
+
* The promotion echo (#724) renders the parsed file lane exactly where the
|
|
1137
|
+
* author asserts the claim; the queue label is being added to an issue the
|
|
1138
|
+
* `listReady` filter would have excluded by definition, so the verb needs
|
|
1139
|
+
* this read rather than a list the issue is not on.
|
|
1140
|
+
*/
|
|
1141
|
+
getIssue(issue: number): Promise<ReadyIssue | undefined>;
|
|
1066
1142
|
addLabel(issue: number, label: string): Promise<void>;
|
|
1067
1143
|
removeLabel(issue: number, label: string): Promise<void>;
|
|
1068
1144
|
comment(issue: number, body: string): Promise<void>;
|
|
@@ -1109,6 +1185,12 @@ export interface Tracker {
|
|
|
1109
1185
|
* when either fact cannot be read safely.
|
|
1110
1186
|
*/
|
|
1111
1187
|
issueSnapshot(issue: number): Promise<IssueSnapshot | undefined>;
|
|
1188
|
+
/**
|
|
1189
|
+
* An issue's raw body, or undefined when it cannot be read. Used only by the
|
|
1190
|
+
* dependency-graph cycle pass (#421) to follow a reachable prerequisite's
|
|
1191
|
+
* own `Depends-on:` declarations; never a "no body" claim.
|
|
1192
|
+
*/
|
|
1193
|
+
issueBody(issue: number): Promise<string | undefined>;
|
|
1112
1194
|
/**
|
|
1113
1195
|
* The state of one specific pull request, or undefined when this adapter
|
|
1114
1196
|
* could not tell — a network failure, a deleted PR, a URL it cannot parse.
|
|
@@ -1175,6 +1257,13 @@ export interface Tracker {
|
|
|
1175
1257
|
* better answer on a later tick.
|
|
1176
1258
|
*/
|
|
1177
1259
|
prDiff(url: string): Promise<PrDiff | undefined>;
|
|
1260
|
+
/**
|
|
1261
|
+
* The pull request's body, or undefined when this adapter could not produce
|
|
1262
|
+
* one — the same advisory contract as {@link Tracker.prDiff}. Its only
|
|
1263
|
+
* consumer is the settlement audit's claimed-proof check (#582): a claim
|
|
1264
|
+
* extracted from a body this port could not read is no claim at all.
|
|
1265
|
+
*/
|
|
1266
|
+
prBody(url: string): Promise<string | undefined>;
|
|
1178
1267
|
/**
|
|
1179
1268
|
* Every check on a PR with the state the tracker reports for it (#132).
|
|
1180
1269
|
*
|
|
@@ -1397,6 +1486,12 @@ export interface RunRecord {
|
|
|
1397
1486
|
autoCompactionCount?: number;
|
|
1398
1487
|
/** omp session transcript, so a human can read what the worker actually did. */
|
|
1399
1488
|
sessionFile?: string;
|
|
1489
|
+
/** The run id of the orphan-clean attempt whose session this row resumes
|
|
1490
|
+
* (#536, #567). Set only when the dispatch-time resume verdict fired;
|
|
1491
|
+
* absent means a fresh dispatch, never "resumed from unknown". Readable
|
|
1492
|
+
* without opening the transcript, so "how often did resume fire and did it
|
|
1493
|
+
* save turns" is a query, not a file walk. */
|
|
1494
|
+
resumedFromRunId?: string;
|
|
1400
1495
|
prUrl?: string;
|
|
1401
1496
|
/** Pull request head the worker observed after its deterministic CI watcher exited. */
|
|
1402
1497
|
headSha?: string;
|
|
@@ -1427,6 +1522,14 @@ export interface RunRecord {
|
|
|
1427
1522
|
/** When an operator accepted the loss or recovered the tree by hand
|
|
1428
1523
|
* (`unblock --force`). Clears the hold without erasing what happened. */
|
|
1429
1524
|
salvageAckAt?: number;
|
|
1525
|
+
/** Why the retained tree is under quarantine (#737): the cleanup pass could
|
|
1526
|
+
* not make its object store sound (`repairAlternates`), so fetching into it
|
|
1527
|
+
* was refused and the tree's commits cannot be verified against any remote.
|
|
1528
|
+
* Present means the tree is quarantined right now; the pass clears it the
|
|
1529
|
+
* moment it reaps the tree or re-verifies the store on a later pass.
|
|
1530
|
+
* Absent means never quarantined, or no longer — never "quarantine not
|
|
1531
|
+
* checked", because every retained-tree pass checks. */
|
|
1532
|
+
quarantineDetail?: string;
|
|
1430
1533
|
startedAt: number;
|
|
1431
1534
|
endedAt?: number;
|
|
1432
1535
|
/** Last failure text, surfaced verbatim in escalations. */
|
|
@@ -1479,6 +1582,10 @@ export type AdmissionHoldReason =
|
|
|
1479
1582
|
| "issue-closed"
|
|
1480
1583
|
| "issue-state-lookup-error"
|
|
1481
1584
|
| "issue-dequeued"
|
|
1585
|
+
/** An operator-parked issue: the park label beat the queue label at claim
|
|
1586
|
+
* time (#734). Unlike `issue-dequeued` it is an operator gesture, not a
|
|
1587
|
+
* queue edit — the queue label may still be physically present. */
|
|
1588
|
+
| "issue-parked"
|
|
1482
1589
|
| "parent-lookup-error"
|
|
1483
1590
|
| "sibling-active"
|
|
1484
1591
|
| "repo-active"
|
|
@@ -1489,9 +1596,14 @@ export type AdmissionHoldReason =
|
|
|
1489
1596
|
| "plan-usage-cap"
|
|
1490
1597
|
| "shutting-down"
|
|
1491
1598
|
| "stale-base"
|
|
1599
|
+
/** A ready issue whose lifecycle state label is residual: its newest run is
|
|
1600
|
+
* terminal (or absent), so nobody owns it — Duty 1 reconciliation work, not
|
|
1601
|
+
* occupied capacity (#611). */
|
|
1602
|
+
| "stale-lifecycle"
|
|
1492
1603
|
| "critical-base-verify-error"
|
|
1493
1604
|
| "file-lane"
|
|
1494
1605
|
| "depends-on"
|
|
1606
|
+
| "dependency-cycle"
|
|
1495
1607
|
| "unroutable:no-repo-label"
|
|
1496
1608
|
| "unroutable:multiple-repo-labels"
|
|
1497
1609
|
| "unroutable:unknown-repo";
|
|
@@ -1962,6 +2074,11 @@ export interface Store {
|
|
|
1962
2074
|
* can name every WIP tip a re-claim would build on and every tree that is
|
|
1963
2075
|
* still the only copy. */
|
|
1964
2076
|
salvagedRuns(project: string): RunRecord[];
|
|
2077
|
+
/** Newest attempt per issue whose retained tree is under quarantine: the
|
|
2078
|
+
* object store could not be made sound, so the tree's commits cannot be
|
|
2079
|
+
* verified against any remote and the daemon refused to fetch into it
|
|
2080
|
+
* (#737). Newest-attempt-only for the same reason the board is. */
|
|
2081
|
+
quarantinedRuns(project: string): RunRecord[];
|
|
1965
2082
|
/** Total run segments, used only for the monotonically increasing run number. */
|
|
1966
2083
|
attemptsFor(project: string, issue: number): number;
|
|
1967
2084
|
/** Terminal implementation failures that consume `maxAttemptsPerIssue`. */
|
|
@@ -2559,6 +2676,13 @@ export const VERB_REFUSALS = [
|
|
|
2559
2676
|
* running on it, or the run settled otherwise. The detail names the state.
|
|
2560
2677
|
*/
|
|
2561
2678
|
"review-in-flight",
|
|
2679
|
+
/**
|
|
2680
|
+
* The PR has already been through the configured review-round ceiling
|
|
2681
|
+
* (#678): the policy's hard bound against endless polishing. Refused rather
|
|
2682
|
+
* than recorded — the orchestrator leaves the PR open, records the
|
|
2683
|
+
* unresolved findings and escalates once instead of spending a worker round.
|
|
2684
|
+
*/
|
|
2685
|
+
"review-round-ceiling",
|
|
2562
2686
|
/**
|
|
2563
2687
|
* The routed repository's base branch is frozen because a watched merge (or
|
|
2564
2688
|
* live base observation) turned it red — a base-red-freeze. Further merges to
|
package/src/verbs/server.ts
CHANGED
|
@@ -41,12 +41,14 @@
|
|
|
41
41
|
import { randomUUID } from "node:crypto";
|
|
42
42
|
import { createServer, type Server, type Socket } from "node:net";
|
|
43
43
|
|
|
44
|
-
import { resolvePolicy, resolveReleaseGrants } from "../config.ts";
|
|
44
|
+
import { resolvePolicy, resolveReleaseGrants, resolveReview } from "../config.ts";
|
|
45
|
+
import { effectiveLane, laneEcho } from "../admission.ts";
|
|
45
46
|
import { chainEntriesFromDiff, chainViolations } from "../chain-check.ts";
|
|
46
47
|
import { repoSlugFor, type readBaseChain as readBaseChainType } from "../gitops.ts";
|
|
47
48
|
import { releaseRefusal } from "../release-policy.ts";
|
|
48
49
|
import { LIVE_STATES } from "../store.ts";
|
|
49
50
|
import type {
|
|
51
|
+
IssueComment,
|
|
50
52
|
OpenCloser,
|
|
51
53
|
PrState,
|
|
52
54
|
PrVerification,
|
|
@@ -1104,7 +1106,43 @@ async function labelVerb(
|
|
|
1104
1106
|
const why = err instanceof Error ? err.message : String(err);
|
|
1105
1107
|
return refuse("action-failed", `refused: the tracker rejected the label change:\n${why}`, ref.issue);
|
|
1106
1108
|
}
|
|
1107
|
-
|
|
1109
|
+
|
|
1110
|
+
const outcome = `${action === "add" ? "added" : "removed"} ${label} on #${ref.issue}.`;
|
|
1111
|
+
// Adding the queue label is the promotion: the author is asserting the file
|
|
1112
|
+
// lane right now, so the verb echoes the one admission will enforce — the
|
|
1113
|
+
// parsed file list, or the explicit fail-open note (#724). Best-effort: an
|
|
1114
|
+
// unreadable issue or thread must never block the promotion itself (the
|
|
1115
|
+
// label is the point), so a failed read falls back to the plain message.
|
|
1116
|
+
let echo: string | undefined;
|
|
1117
|
+
if (action === "add" && label === project.queueLabel) {
|
|
1118
|
+
echo = await laneEchoForIssue(deps, ref.issue);
|
|
1119
|
+
}
|
|
1120
|
+
return allow(
|
|
1121
|
+
echo === undefined ? outcome : `${outcome} File lane: ${echo}.`,
|
|
1122
|
+
undefined,
|
|
1123
|
+
ref.issue,
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* The effective file lane admission will enforce for one issue, as the
|
|
1129
|
+
* one-line echo (#724): the body plus the whole comment thread, the same
|
|
1130
|
+
* inputs `effectiveLane` reads at admission. `undefined` when either read
|
|
1131
|
+
* fails or the tracker cannot produce the issue — the promotion is never
|
|
1132
|
+
* blocked by its own feedback.
|
|
1133
|
+
*/
|
|
1134
|
+
async function laneEchoForIssue(deps: VerbDeps, issue: number): Promise<string | undefined> {
|
|
1135
|
+
let body: string;
|
|
1136
|
+
let comments: IssueComment[];
|
|
1137
|
+
try {
|
|
1138
|
+
const row = await deps.tracker.getIssue(issue);
|
|
1139
|
+
if (row === undefined) return undefined;
|
|
1140
|
+
body = row.body;
|
|
1141
|
+
comments = await deps.tracker.listComments(issue);
|
|
1142
|
+
} catch {
|
|
1143
|
+
return undefined;
|
|
1144
|
+
}
|
|
1145
|
+
return laneEcho(effectiveLane(body, comments));
|
|
1108
1146
|
}
|
|
1109
1147
|
|
|
1110
1148
|
async function releaseVerb(
|
|
@@ -1678,6 +1716,24 @@ async function prReviewVerb(
|
|
|
1678
1716
|
);
|
|
1679
1717
|
}
|
|
1680
1718
|
|
|
1719
|
+
// The hard bound (#678): the round ceiling is the dispatcher's, not the
|
|
1720
|
+
// orchestrator's to re-read from prose. A PR may be returned at most
|
|
1721
|
+
// `maxRounds` times per lifecycle; at the ceiling this refuses, and the
|
|
1722
|
+
// refusal states the two non-actions that replace a further round — leave
|
|
1723
|
+
// the PR open, record the unresolved findings and escalate once. The same
|
|
1724
|
+
// count `latestReviewRound` drives the renderer's `review-revision N`, so
|
|
1725
|
+
// the bound and the visible round can never disagree.
|
|
1726
|
+
const review = resolveReview(project);
|
|
1727
|
+
if (deps.store.latestReviewRound(project.name, target.id) >= review.maxRounds) {
|
|
1728
|
+
return refuse(
|
|
1729
|
+
"review-round-ceiling",
|
|
1730
|
+
`refused: ${prUrl} has already been through ${review.maxRounds} review round(s) ` +
|
|
1731
|
+
`(project "${project.name}" is ${review.strictness} strictness, ceiling ${review.maxRounds}). ` +
|
|
1732
|
+
"Leave the PR open, record the unresolved findings, and escalate once — a further revision round is not available.",
|
|
1733
|
+
issue,
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1681
1737
|
// Durable, and refused atomically against a concurrent duplicate: the row is
|
|
1682
1738
|
// persisted before any wake, and a second request for the same run bumps
|
|
1683
1739
|
// into the pending row rather than racing it.
|