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/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
  },
@@ -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 nothing about one.
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
- stateLabels: { inProgress: string; blocked: string; failed: string };
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
@@ -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
- return allow(`${action === "add" ? "added" : "removed"} ${label} on #${ref.issue}.`, undefined, ref.issue);
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.