@nanobpm/nano-workforce 0.208.4 → 0.209.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.
@@ -62,6 +62,16 @@ function memOpen(stores: Record<string, { rows: any[]; key: string }>, ops?: str
62
62
  }
63
63
  return { changed };
64
64
  }
65
+ if (/^UPDATE "pull_requests" SET "merge_human_approval" = 1 WHERE "pr_key" = \? AND "process_key" = \? AND "converged_at" IS NULL$/.test(sql)) {
66
+ let changed = 0;
67
+ for (const r of stores.pull_requests?.rows ?? []) {
68
+ if (r.pr_key === params[0] && r.process_key === params[1] && r.converged_at == null) {
69
+ r.merge_human_approval = 1;
70
+ changed++;
71
+ }
72
+ }
73
+ return { changed };
74
+ }
65
75
  throw new Error(`unexpected exec sql: ${sql}`);
66
76
  },
67
77
  };
@@ -1259,6 +1269,25 @@ function adoptLoop(status: string, loopState: string, convergedAt: string | null
1259
1269
  return { data, engine, narrowed };
1260
1270
  }
1261
1271
 
1272
+ test("a gated submitPr stamps an OUTSTANDING approval matching the engine seed (PR #950 review)", async () => {
1273
+ await withGithubOff(async () => {
1274
+ const { data, engine, stores, vars } = captureRoot();
1275
+ await submitPr(
1276
+ data,
1277
+ engine,
1278
+ { repo: "owner/repo", number: 25, url: "https://github.com/owner/repo/pull/25", prKey: "owner/repo#25" },
1279
+ [],
1280
+ 20,
1281
+ false,
1282
+ null,
1283
+ true, // humanApproval
1284
+ );
1285
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
1286
+ assertEquals(pr.merge_human_approval, vars()?.humanApproval === true ? 1 : 0, "projection agrees with the engine seed");
1287
+ assertEquals(pr.merge_human_approval, 1, "a gated run projects the outstanding gate");
1288
+ });
1289
+ });
1290
+
1262
1291
  test("a gated submitPr adopting a live loop narrows it to humanApproval (issue #826)", async () => {
1263
1292
  await withGithubOff(async () => {
1264
1293
  const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
@@ -1464,6 +1493,140 @@ test("startMerge seeds the merge-policy triple so /status needs no per-instance
1464
1493
  });
1465
1494
  });
1466
1495
 
1496
+ // ── Issue #947: the durable merge-policy projection the writers stamp ────────────────────────────
1497
+ // `/status` serves the merge policy off the `pull_requests` row (zero engine reads) — these pin the
1498
+ // writer side of that contract: every path that KNOWS the policy must stamp it.
1499
+
1500
+ test("submitPr stamps the merge-policy projection on the row (insert path)", async () => {
1501
+ await withGithubOff(async () => {
1502
+ const { data, engine, stores } = captureRoot();
1503
+ await submitPr(data, engine, {
1504
+ repo: "owner/repo",
1505
+ number: 21,
1506
+ url: "https://github.com/owner/repo/pull/21",
1507
+ prKey: "owner/repo#21",
1508
+ });
1509
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
1510
+ assertEquals(pr.merge_requested, 1, "the default submit requests the merge");
1511
+ assertEquals(pr.merge_human_approval, 0, "an ungated run mirrors the engine seed humanApproval:false (PR #950 review)");
1512
+ });
1513
+ });
1514
+
1515
+ test("submitPr stamps a review-only intent as merge_requested 0 (convergeOnly)", async () => {
1516
+ await withGithubOff(async () => {
1517
+ const { data, engine, stores } = captureRoot();
1518
+ await submitPr(
1519
+ data,
1520
+ engine,
1521
+ { repo: "owner/repo", number: 22, url: "https://github.com/owner/repo/pull/22", prKey: "owner/repo#22" },
1522
+ [],
1523
+ 20,
1524
+ true, // convergeOnly
1525
+ );
1526
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
1527
+ assertEquals(pr.merge_requested, 0, "a review-only run projects requested=false with no engine read");
1528
+ assertEquals(pr.merge_human_approval, 0, "no gate on a review-only run");
1529
+ });
1530
+ });
1531
+
1532
+ test("submitPr reopen restamps the projection to the FRESH run's intent (run-scoped reset)", async () => {
1533
+ await withGithubOff(async () => {
1534
+ const { data, engine, stores } = captureRoot();
1535
+ (stores.pull_requests.rows as unknown[]).push({
1536
+ pr_key: "owner/repo#23",
1537
+ repo: "owner/repo",
1538
+ number: 23,
1539
+ url: "https://github.com/owner/repo/pull/23",
1540
+ status: "converged", // terminal -> reopen path
1541
+ current_round: 2,
1542
+ process_key: "PI-OLD",
1543
+ // The PRIOR run's projection: review-only, approval decided. A fresh default submit must
1544
+ // overwrite both — a carried-over stamp would misreport the new run's policy.
1545
+ merge_requested: 0,
1546
+ merge_human_approval: 0,
1547
+ });
1548
+ await submitPr(data, engine, {
1549
+ repo: "owner/repo",
1550
+ number: 23,
1551
+ url: "https://github.com/owner/repo/pull/23",
1552
+ prKey: "owner/repo#23",
1553
+ });
1554
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
1555
+ assertEquals(pr.merge_requested, 1, "the fresh run's intent wins");
1556
+ assertEquals(pr.merge_human_approval, 0, "the approval state mirrors the fresh ungated seed");
1557
+ });
1558
+ });
1559
+
1560
+ test("a gated submitPr adopting a live loop stamps merge_human_approval on the row (issue #947)", async () => {
1561
+ await withGithubOff(async () => {
1562
+ const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
1563
+ const live = adoptLoop("converging", "ACTIVE");
1564
+ await submitPr(live.data, live.engine, pr, [], 20, false, null, true);
1565
+ const row = (await live.data.table("pull_requests", "pr_key").get(pr.prKey)) as {
1566
+ merge_human_approval?: number | null;
1567
+ } | null;
1568
+ assertEquals(row?.merge_human_approval, 1, "the gate is mirrored onto the /status projection");
1569
+
1570
+ const ungated = adoptLoop("converging", "ACTIVE");
1571
+ await submitPr(ungated.data, ungated.engine, pr);
1572
+ const ungatedRow = (await ungated.data.table("pull_requests", "pr_key").get(pr.prKey)) as {
1573
+ merge_human_approval?: number | null;
1574
+ } | null;
1575
+ assertEquals(ungatedRow?.merge_human_approval ?? null, null, "an ungated adopter leaves the projection undecided");
1576
+ });
1577
+ });
1578
+
1579
+ test("a gated adopter never gates a merge-loop or a row re-pointed mid-handoff (PR #950 review)", async () => {
1580
+ await withGithubOff(async () => {
1581
+ const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
1582
+ // Variant 1: the key already resolves to the merge-loop (startMerge re-pointed it, converged_at unset).
1583
+ const merge = adoptLoop("converging", "ACTIVE");
1584
+ merge.engine.searchProcessInstances = () =>
1585
+ Promise.resolve([{ processInstanceKey: "PI-LOOP", state: "ACTIVE", processDefinitionId: "merge-loop" }]);
1586
+ await submitPr(merge.data, merge.engine, pr, [], 20, false, null, true);
1587
+ const mergeRow = (await merge.data.table("pull_requests", "pr_key").get(pr.prKey)) as any;
1588
+ assertEquals(mergeRow?.merge_human_approval ?? null, null, "merge-loop policy untouched");
1589
+ assertEquals(merge.narrowed.length, 0, "no humanApproval set on the merge-loop");
1590
+ // Variant 2: the handoff re-points the row between the engine read and the stamp.
1591
+ const race = adoptLoop("converging", "ACTIVE");
1592
+ race.engine.setVariables = async () => {
1593
+ const row = (await race.data.table("pull_requests", "pr_key").get(pr.prKey)) as any;
1594
+ row.process_key = "PI-MERGE";
1595
+ row.merge_human_approval = 0;
1596
+ };
1597
+ await submitPr(race.data, race.engine, pr, [], 20, false, null, true);
1598
+ const raceRow = (await race.data.table("pull_requests", "pr_key").get(pr.prKey)) as any;
1599
+ assertEquals(raceRow?.merge_human_approval, 0, "the handoff's merge-stage policy survives");
1600
+ });
1601
+ });
1602
+
1603
+ test("startMerge stamps the merge-stage policy on the row (issue #947)", async () => {
1604
+ await withGithubOff(async () => {
1605
+ const { data, engine, stores } = captureRoot();
1606
+ (stores.pull_requests.rows as unknown[]).push({
1607
+ pr_key: "owner/repo#24",
1608
+ repo: "owner/repo",
1609
+ number: 24,
1610
+ url: "https://github.com/owner/repo/pull/24",
1611
+ status: "converged",
1612
+ current_round: 1,
1613
+ process_key: "PI-CONV",
1614
+ merge_requested: 1,
1615
+ merge_human_approval: 1, // the convergence-stage gate — decided by the merge hand-off
1616
+ });
1617
+ await startMerge(data, engine, {
1618
+ repo: "owner/repo",
1619
+ number: 24,
1620
+ url: "https://github.com/owner/repo/pull/24",
1621
+ prKey: "owner/repo#24",
1622
+ round: 1,
1623
+ });
1624
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
1625
+ assertEquals(pr.merge_requested, 1, "a merge loop only starts for a requested merge");
1626
+ assertEquals(pr.merge_human_approval, 0, "…and an approved one — the gate is behind it");
1627
+ });
1628
+ });
1629
+
1467
1630
  // The repository envelope drives the c8ctl harness's isolated workspace provisioning: it is
1468
1631
  // emitted under the reserved `io.nanobpm.agentTask` namespace with the PR head branch as the
1469
1632
  // checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
@@ -2681,7 +2844,7 @@ test("submitPr handoffFrom: a failed reset restores the prior owner so the hand-
2681
2844
  const PR_KEY = "owner/repo#79";
2682
2845
  const stores: Record<string, { rows: any[]; key: string }> = {
2683
2846
  pull_requests: {
2684
- rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 79, url: "https://github.com/owner/repo/pull/79", title: "t", status: "waiting_merge", current_round: 4, process_key: "PI-MERGE" }],
2847
+ rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 79, url: "https://github.com/owner/repo/pull/79", title: "t", status: "waiting_merge", current_round: 4, process_key: "PI-MERGE", merge_requested: 0, merge_human_approval: 1 }],
2685
2848
  key: "pr_key",
2686
2849
  },
2687
2850
  escalations: { rows: [], key: "id" },
@@ -2722,6 +2885,10 @@ test("submitPr handoffFrom: a failed reset restores the prior owner so the hand-
2722
2885
  assertEquals(row.process_key, "PI-MERGE", "rollback restores the prior owner, never leaves the cancelled key");
2723
2886
  assertEquals(row.status, "waiting_merge", "rollback restores the prior status");
2724
2887
  assertEquals(row.current_round, 4);
2888
+ // PR #950 review: the merge-policy projection is restored WITH ownership, never left as the
2889
+ // failed run's policy under the old owner (which /status would serve as authoritative).
2890
+ assertEquals(row.merge_requested, 0, "rollback restores the prior merge intent");
2891
+ assertEquals(row.merge_human_approval, 1, "rollback restores the prior approval state");
2725
2892
 
2726
2893
  failReset = false;
2727
2894
  const retry = await submitPr(data, engine, parsed, [], undefined, false, null, false, false, true, "PI-MERGE");
package/app/service.ts CHANGED
@@ -64,6 +64,7 @@ import { pollLineage } from "./lineage.ts";
64
64
  import { mergeableRepollInterval, mergeableWaitTimeout } from "./mergeableWait.ts";
65
65
  import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
66
66
  import { mergeLandedWaitTimeout } from "./mergeLandedWait.ts";
67
+ import { withMergePolicyLock } from "./mergePolicyLock.ts";
67
68
  import {
68
69
  freshHeadRunAction,
69
70
  headRunPresenceCount,
@@ -431,6 +432,16 @@ export interface PullRequest {
431
432
  // Operator Dismiss stamp (app/acknowledge.ts). Run-scoped: cleared on reopen so a dismissal of a
432
433
  // PRIOR run never hides an orphaning of the current one.
433
434
  acknowledged_at?: string | null;
435
+ // Durable merge-policy projection (132_pull_requests_merge_policy.sql, issue #947): the run's
436
+ // merge intent (`merge_requested` 0|1) and its decided merge-approval state
437
+ // (`merge_human_approval` NULL = outstanding/undecided, 0|1 once decided), stamped by the writers
438
+ // that already know them — `submitPr` (submit/reopen, and the gated-adopt `setVariables` branch),
439
+ // `completeUserTaskAttributed` (a `merge-approval` approve clears it to 0, app/agentCompletion.ts),
440
+ // and `startMerge` — so `/status` serves the policy from the app's own DB with ZERO engine reads.
441
+ // `merge_requested` NULL marks a row written before this projection existed; only those legacy
442
+ // rows fall back to the #937 batched engine read. Run-scoped: reset by the atomic reopen.
443
+ merge_requested?: number | null;
444
+ merge_human_approval?: number | null;
434
445
  }
435
446
 
436
447
  interface PrDependency {
@@ -742,8 +753,27 @@ async function submitPrCritical(
742
753
  (s) => String(s.processInstanceKey) === loopKey,
743
754
  );
744
755
  const state = match ? String(match.state ?? "").trim().toUpperCase() : null;
745
- if (state !== null && !ENGINE_TERMINAL_STATES.has(state)) {
756
+ // Only the CONVERGENCE loop reads `humanApproval` (PR #950 review): mid-handoff `startMerge`
757
+ // has already re-pointed `process_key` at the merge-loop (and stamped its approval policy), so a
758
+ // key that resolves to any other definition must never be gated.
759
+ const isConvergenceLoop = match?.processDefinitionId == null || match.processDefinitionId === PROCESS_ID;
760
+ if (state !== null && !ENGINE_TERMINAL_STATES.has(state) && isConvergenceLoop) {
761
+ // Serialized with merge-approval completion on this loop (PR #950 review): the engine write
762
+ // and projection stamp form one pair, so an approval can never interleave between them.
763
+ await withMergePolicyLock(loopKey, async () => {
746
764
  await engine.setVariables({ scopeKey: loopKey, variables: { humanApproval: true } });
765
+ // Mirror the gate onto the durable /status projection (issue #947): the loop variable is
766
+ // the process's own copy; the row column is what `/status` reads without an engine
767
+ // round-trip. NULL would read as "outstanding" too, but stamping 1 keeps the projection
768
+ // explicit (decided-and-gated) rather than undecided.
769
+ // Atomic compare-and-set on the SAME owner we gated, still pre-finalization: a concurrent
770
+ // merge handoff (re-pointed key) or reopen between the read and this write leaves its own
771
+ // policy intact instead of being overwritten with a stale gate.
772
+ await data.open().exec(
773
+ `UPDATE "pull_requests" SET "merge_human_approval" = 1 WHERE "pr_key" = ? AND "process_key" = ? AND "converged_at" IS NULL`,
774
+ [parsed.prKey, loopKey],
775
+ );
776
+ });
747
777
  }
748
778
  }
749
779
  // Steward opt-in is monotonic too (issue #889 review): an opted-in caller adopting a live loop —
@@ -838,6 +868,12 @@ async function submitPrCritical(
838
868
  abandon_token: abandonToken,
839
869
  root_request_key: effectiveRoot,
840
870
  auto_resolve_escalations: autoResolveEscalations ? 1 : 0,
871
+ // Merge-policy projection (issue #947): stamp the run's intent so `/status` serves it from
872
+ // the row with no engine read. The approve at the `merge-approval` gate later maps to 0.
873
+ merge_requested: mergeIntent ? 1 : 0,
874
+ // Mirror the engine seed EXACTLY (PR #950 review): an ungated loop is seeded
875
+ // `humanApproval: false` (decided — no gate), a gated one `true` (outstanding).
876
+ merge_human_approval: gated ? 1 : 0,
841
877
  created_at: ts,
842
878
  updated_at: ts,
843
879
  });
@@ -935,7 +971,16 @@ async function submitPrCritical(
935
971
  let reopened = false;
936
972
  // Snapshot BEFORE the reopen write — the row object may be a live reference the update mutates.
937
973
  const prior = existing
938
- ? { status: existing.status, current_round: existing.current_round, process_key: existing.process_key }
974
+ ? {
975
+ status: existing.status,
976
+ current_round: existing.current_round,
977
+ process_key: existing.process_key,
978
+ // Restore the merge-policy projection WITH ownership (PR #950 review): a rollback that put the
979
+ // old `process_key` back but kept the failed run's policy would serve a mismatched policy as
980
+ // authoritative (`stale: false`) in `/status`.
981
+ merge_requested: existing.merge_requested ?? null,
982
+ merge_human_approval: existing.merge_human_approval ?? null,
983
+ }
939
984
  : null;
940
985
  try {
941
986
  if (existing) {
@@ -993,6 +1038,10 @@ async function submitPrCritical(
993
1038
  // Record the opt-in on the row (the durable source the steward's eligibility reads) so a
994
1039
  // fresh run's flag is authoritative even across a reopen (issue #889).
995
1040
  auto_resolve_escalations: autoResolveEscalations ? 1 : 0,
1041
+ // Merge-policy projection (issue #947): run-scoped like every other field in this reset —
1042
+ // the fresh run's intent wins, and the approval state mirrors the fresh loop's `humanApproval` seed.
1043
+ merge_requested: mergeIntent ? 1 : 0,
1044
+ merge_human_approval: gated ? 1 : 0,
996
1045
  // Publish the fresh run's identity in the SAME write as the status flip (may be null if the
997
1046
  // instance could not be created — a phantom row a retry re-enrolls, as on first submit).
998
1047
  process_key: processKey,
@@ -1129,7 +1178,15 @@ export async function startMerge(
1129
1178
  },
1130
1179
  });
1131
1180
  if (processInstanceKey != null) {
1132
- await prs(data).update(pr.prKey, { process_key: String(processInstanceKey), updated_at: now() });
1181
+ await prs(data).update(pr.prKey, {
1182
+ process_key: String(processInstanceKey),
1183
+ // Merge-policy projection (issue #947): a merge loop only starts for a requested, approved
1184
+ // merge (`pr.finalize`'s gate), so the merge-stage policy is known here — stamp it and the
1185
+ // merge stage of `/status` never pays an engine read either.
1186
+ merge_requested: 1,
1187
+ merge_human_approval: 0,
1188
+ updated_at: now(),
1189
+ });
1133
1190
  }
1134
1191
  return { prKey: pr.prKey, mergeProcessKey: processInstanceKey };
1135
1192
  }
@@ -1150,6 +1207,11 @@ export interface MergePolicy {
1150
1207
  * (convergence-loop.bpmn), so the blocker lifts as soon as approval is complete — even while
1151
1208
  * the `pr.finalize` handoff is still queued or incidented. */
1152
1209
  blockedBy: "env" | "human-approval" | null;
1210
+ /** The timing-guard marker (issue #947): `true` when the policy could not be verified and the row
1211
+ * was served from the historical default instead of blocking the serve (a legacy row — written
1212
+ * before the durable projection existed — whose fallback engine read failed). Rows served from
1213
+ * the `pull_requests` projection always read `false`. */
1214
+ stale: boolean;
1153
1215
  }
1154
1216
 
1155
1217
  /** The status `/status` reports for a run whose instance is parked on an ACTIVE engine incident
@@ -1263,18 +1325,35 @@ export async function activePrs(
1263
1325
  // Merge intent (issue #916): read the loop's `autoMerge` / `convergeOnly` / `humanApproval`
1264
1326
  // variables per PR so the effective merge policy is visible without opening the engine.
1265
1327
  //
1266
- // A CONSTANT number of batched engine reads per /status call (issue #936) — never a per-row
1267
- // fan-out. The pre-#936 shape issued 3 `searchVariables` round-trips PER active PR (12 calls at 4
1268
- // rows ≈ 13 s; concurrent callers stacked until the console proxy 502'd). See
1269
- // `readMergePolicyVars` for the completeness contract (single-page seam, root scope only, one
1270
- // per-name root probe only for a page cut or legacy instance). Best-effort: a failed batched read degrades every
1271
- // PR to the historical default (merge requested); a failed per-instance fallback degrades only
1272
- // that PR.
1328
+ // Issue #947: the policy is served from the app's OWN projection — the `merge_requested` /
1329
+ // `merge_human_approval` columns `submitPr`/`startMerge` stamp on the row (the writers already
1330
+ // know the policy) — so the hot read path pays ZERO engine round-trips. The #937 batched engine
1331
+ // read below is kept ONLY for legacy rows (`merge_requested` NULL — written before the projection
1332
+ // existed): it is a CONSTANT 3 name-filtered reads for the whole legacy set, never a per-row
1333
+ // fan-out, and it shrinks to the empty set as rows reopen onto the projection. See
1334
+ // `readMergePolicyVars` for the legacy completeness contract (single-page seam, root scope only,
1335
+ // one per-name root probe only for a page cut or legacy instance). Best-effort: a failed batched
1336
+ // read degrades every legacy row to the historical default (merge requested, flagged `stale`); a
1337
+ // failed per-instance fallback degrades only that row.
1273
1338
  const mergeByPr = new Map<string, MergePolicy>();
1274
- const activeKeys = active.map((p) => p.process_key).filter((k): k is string => k != null && k !== "");
1275
- const varsByInstance = await readMergePolicyVars(engine, activeKeys);
1339
+ const legacyKeys: string[] = [];
1340
+ for (const p of active) {
1341
+ const projected = mergePolicyFromRow(p);
1342
+ if (projected) {
1343
+ mergeByPr.set(p.pr_key, projected);
1344
+ } else if (p.process_key != null && p.process_key !== "") {
1345
+ legacyKeys.push(p.process_key);
1346
+ }
1347
+ }
1348
+ const varsByInstance = await readMergePolicyVars(engine, legacyKeys);
1276
1349
  for (const p of active) {
1277
- mergeByPr.set(p.pr_key, mergePolicyFromVars(varsByInstance?.get(p.process_key ?? "")));
1350
+ if (mergeByPr.has(p.pr_key)) continue;
1351
+ const vars = varsByInstance?.get(p.process_key ?? "");
1352
+ mergeByPr.set(p.pr_key, mergePolicyFromVars(vars));
1353
+ // Self-healing hydration (PR #950 review): a legacy row whose policy the engine just proved is
1354
+ // written back onto the projection, so it pays the engine read at most ONCE — the legacy set
1355
+ // drains on the first serves after upgrade instead of keeping every /status on the slow path.
1356
+ await hydrateLegacyMergePolicy(data, p, vars);
1278
1357
  }
1279
1358
  return active.map((p) => {
1280
1359
  // Issue #931: an ACTIVE engine incident parked on the loop's instance must be VISIBLE — the row
@@ -1352,9 +1431,25 @@ function hasOwnTerminationReason(p: {
1352
1431
 
1353
1432
  /** The default merge policy when no instance variables are readable (mid-handoff row, engine
1354
1433
  * unreachable, or a legacy loop carrying neither variable): the pre-#916 behaviour merged by
1355
- * default, so the projection must NOT read such a PR as review-only. */
1434
+ * default, so the projection must NOT read such a PR as review-only. Flagged `stale` (issue #947):
1435
+ * the policy could not be verified — the row was served from the default rather than blocking. */
1356
1436
  function mergePolicyDefault(): MergePolicy {
1357
- return mergePolicyFrom(true, false);
1437
+ return { ...mergePolicyFrom(true, false), stale: true };
1438
+ }
1439
+
1440
+ /** Derive a PR's effective merge policy from its DURABLE row projection (issue #947) — the
1441
+ * `merge_requested` / `merge_human_approval` columns `submitPr`/`startMerge` stamp. Returns
1442
+ * `undefined` for a legacy row (`merge_requested` NULL — written before the projection existed),
1443
+ * which the caller falls back to the #937 batched engine read. `merge_human_approval` NULL means
1444
+ * the approval is outstanding/undecided (the gate still holds); 0|1 is the decided state the
1445
+ * `merge-approval` task's `humanApproval` variable write mirrors. A projected policy is always
1446
+ * fresh (`stale: false`) — it never blocks the serve on an engine round-trip. */
1447
+ function mergePolicyFromRow(p: {
1448
+ merge_requested?: number | null;
1449
+ merge_human_approval?: number | null;
1450
+ }): MergePolicy | undefined {
1451
+ if (p.merge_requested == null) return undefined;
1452
+ return { ...mergePolicyFrom(p.merge_requested === 1, p.merge_human_approval !== 0), stale: false };
1358
1453
  }
1359
1454
 
1360
1455
  /** Combine a merge intent + human-approval gate with the global switch into the effective policy.
@@ -1362,7 +1457,7 @@ function mergePolicyDefault(): MergePolicy {
1362
1457
  * and the `merge-approval` user task clears it on an explicit "approve", so an approved PR whose
1363
1458
  * finalization is still queued/incidented reads `effective` with no blocker rather than a stale
1364
1459
  * `blockedBy: "human-approval"`. */
1365
- function mergePolicyFrom(requested: boolean, humanApproval: boolean): MergePolicy {
1460
+ function mergePolicyFrom(requested: boolean, humanApproval: boolean): Omit<MergePolicy, "stale"> {
1366
1461
  const enabled = AUTO_MERGE;
1367
1462
  const effective = requested && enabled && !humanApproval;
1368
1463
  const blockedBy = !requested ? null : !enabled ? "env" : humanApproval ? "human-approval" : null;
@@ -1376,6 +1471,9 @@ type MergePolicyVars = {
1376
1471
  autoMerge: boolean | undefined;
1377
1472
  convergeOnly: boolean | undefined;
1378
1473
  humanApproval: boolean | undefined;
1474
+ /** True once every policy name for this instance was either seen or proven absent by an
1475
+ * untruncatable root probe — absence of a name is then real, not a page cut. */
1476
+ proven?: boolean;
1379
1477
  };
1380
1478
 
1381
1479
  function emptyMergePolicyVars(): MergePolicyVars {
@@ -1398,6 +1496,46 @@ function isMergePolicyName(name: string): name is MergePolicyName {
1398
1496
  return MERGE_POLICY_NAMES.some((n) => n === name);
1399
1497
  }
1400
1498
 
1499
+ /** Write a legacy row's engine-proven merge policy back onto its `pull_requests` projection (PR #950
1500
+ * review). Only a COMPLETE read is persisted — a known intent (`autoMerge`, or the legacy inverted
1501
+ * `convergeOnly`) AND a known `humanApproval` — so a guess is never made authoritative. The UPDATE is
1502
+ * guarded on the row still being unstamped AND still owned by the instance read, so a concurrent
1503
+ * submit/reopen stamp always wins. Best-effort: a failed write just leaves the row legacy. */
1504
+ async function hydrateLegacyMergePolicy(
1505
+ data: DataLayer,
1506
+ row: { pr_key: string; process_key?: string | null },
1507
+ vars: MergePolicyVars | undefined,
1508
+ ): Promise<void> {
1509
+ if (!vars || !row.process_key) return;
1510
+ // A legacy loop that predates `humanApproval` legitimately lacks it; once a COMPLETE read proved
1511
+ // that absence it reads as "no outstanding approval", so the row can leave the probe queue
1512
+ // instead of starving later legacy rows (PR #950 review).
1513
+ const humanApproval = vars.humanApproval ?? (vars.proven ? false : undefined);
1514
+ if (humanApproval === undefined) return;
1515
+ const requested = vars.autoMerge ?? (vars.convergeOnly === undefined ? undefined : !vars.convergeOnly);
1516
+ if (requested === undefined) return;
1517
+ try {
1518
+ // `COALESCE` preserves an approval stamp written concurrently (approval / gated adoption leave
1519
+ // `merge_requested` NULL), so a hydration that read the engine BEFORE that decision can never
1520
+ // overwrite it with the older value (PR #950 review).
1521
+ await data.open().exec(
1522
+ `UPDATE "pull_requests" SET "merge_requested" = ?, "merge_human_approval" = COALESCE("merge_human_approval", ?) WHERE "pr_key" = ? AND "process_key" = ? AND "merge_requested" IS NULL`,
1523
+ [requested ? 1 : 0, humanApproval ? 1 : 0, row.pr_key, row.process_key],
1524
+ );
1525
+ } catch {
1526
+ // swallow — hydration is an optimisation; the row simply stays on the legacy read
1527
+ }
1528
+ }
1529
+
1530
+ /** Upper bound on legacy instances that may pay per-instance root probes in ONE /status serve (PR
1531
+ * #950 review). Beyond it, an instance missing from the batched pages degrades to the stale default
1532
+ * for this serve (and is hydrated on a later one), so request-time engine calls stay bounded at
1533
+ * `3 + 3 * MAX_PROBED_LEGACY_INSTANCES` regardless of how many legacy PRs exist. */
1534
+ export const MAX_PROBED_LEGACY_INSTANCES = 4;
1535
+
1536
+ /** Rotating start offset into the legacy probe queue (see {@link readMergePolicyVars}). */
1537
+ let legacyProbeCursor = 0;
1538
+
1401
1539
  /** Batched merge-policy read (issue #936): ONE name-filtered engine-wide read per policy variable
1402
1540
  * (3 concurrent calls, independent of row count), bucketed by owning instance.
1403
1541
  *
@@ -1452,15 +1590,27 @@ async function readMergePolicyVars(
1452
1590
  // `convergeOnly` is skipped once a usable boolean `autoMerge` is known (the canonical intent wins). Every loop
1453
1591
  // seeds all three names at creation (submitPr; startMerge seeds the merge loop's policy too), so
1454
1592
  // the steady state is exactly 3 calls; probes are only paid for page cuts and legacy instances.
1593
+ const needsProbe = (pk: string) =>
1594
+ MERGE_POLICY_NAMES.filter(
1595
+ (name) => !seen.get(pk)?.has(name) && !(name === "convergeOnly" && byInstance.get(pk)?.autoMerge !== undefined),
1596
+ );
1597
+ // Bound the request-time fallback (PR #950 review): only the first MAX_PROBED_LEGACY_INSTANCES
1598
+ // instances (callers pass them most-recently-updated first) are probed; the rest degrade fully to
1599
+ // the stale default for this serve — never a half-read policy.
1600
+ for (const pk of wanted) {
1601
+ const vars = byInstance.get(pk);
1602
+ if (vars && needsProbe(pk).length === 0) vars.proven = true;
1603
+ }
1604
+ const probing = [...wanted].filter((pk) => needsProbe(pk).length > 0);
1605
+ // Rotate the probe window across serves (PR #950 review) so a leading row that can never be
1606
+ // hydrated cannot permanently starve the rows behind it.
1607
+ const offset = probing.length > 0 ? legacyProbeCursor % probing.length : 0;
1608
+ const rotated = [...probing.slice(offset), ...probing.slice(0, offset)];
1609
+ legacyProbeCursor += MAX_PROBED_LEGACY_INSTANCES;
1610
+ for (const pk of rotated.slice(MAX_PROBED_LEGACY_INSTANCES)) byInstance.delete(pk);
1455
1611
  await Promise.all(
1456
- [...wanted].map(async (pk) => {
1457
- const got = seen.get(pk);
1458
- const probe = MERGE_POLICY_NAMES.filter(
1459
- (name) =>
1460
- !got?.has(name) &&
1461
- !(name === "convergeOnly" && byInstance.get(pk)?.autoMerge !== undefined),
1462
- );
1463
- if (probe.length === 0) return;
1612
+ rotated.slice(0, MAX_PROBED_LEGACY_INSTANCES).map(async (pk) => {
1613
+ const probe = needsProbe(pk);
1464
1614
  try {
1465
1615
  // Collect ALL probe results first and record only once every probe succeeded: recording
1466
1616
  // inside each probe would let a slower successful sibling re-create the entry the catch
@@ -1473,6 +1623,9 @@ async function readMergePolicyVars(
1473
1623
  ),
1474
1624
  );
1475
1625
  for (const rows of results) for (const row of rows) record(row);
1626
+ const vars = byInstance.get(pk) ?? emptyMergePolicyVars();
1627
+ vars.proven = true;
1628
+ byInstance.set(pk, vars);
1476
1629
  } catch {
1477
1630
  // Degrade ONLY this instance — and fully: drop any partial entry the batch recorded so
1478
1631
  // the caller projects the default for it, never a half-read policy.
@@ -1490,11 +1643,14 @@ async function readMergePolicyVars(
1490
1643
  * canonical positive `autoMerge` wins; the legacy inverted `convergeOnly` is the fallback for
1491
1644
  * in-flight loops seeded before `autoMerge` existed; an instance carrying neither (or absent from
1492
1645
  * the batch — mid-handoff row, instance gone) degrades to the historical default (merge
1493
- * requested) rather than failing the status read. */
1646
+ * requested, flagged `stale`) rather than failing the status read. */
1494
1647
  function mergePolicyFromVars(vars: MergePolicyVars | undefined): MergePolicy {
1495
1648
  if (!vars) return mergePolicyDefault();
1649
+ if (vars.autoMerge === undefined && vars.convergeOnly === undefined && vars.humanApproval === undefined) {
1650
+ return mergePolicyDefault();
1651
+ }
1496
1652
  const requested = vars.autoMerge ?? (vars.convergeOnly === undefined ? true : !vars.convergeOnly);
1497
- return mergePolicyFrom(requested, vars.humanApproval === true);
1653
+ return { ...mergePolicyFrom(requested, vars.humanApproval === true), stale: false };
1498
1654
  }
1499
1655
 
1500
1656
  /** A delivery-graph run currently in flight, as reported by the status endpoint (issue #889 §1 —
package/build-info.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.208.4",
3
- "gitSha": "097724344a2f3942af5b1796e2e0a7e8363b44f9",
4
- "gitTag": "v0.208.4",
5
- "builtAt": "2026-11-11T03:16:35.107Z"
2
+ "version": "0.209.0",
3
+ "gitSha": "21622b4529383c575de7ffcf77548a704353c4d4",
4
+ "gitTag": "v0.209.0",
5
+ "builtAt": "2026-11-12T05:08:52.210Z"
6
6
  }
@@ -0,0 +1,22 @@
1
+ -- Durable merge-policy projection for /status (issue #947). `/app/api/status` regressed to ~30 s
2
+ -- (502 behind the console proxy) on merlin with only 9 PRs: the #937 batching left the merge-policy
3
+ -- read at 3 engine-wide `searchVariables` pages plus a per-(instance, name) probe for any name cut
4
+ -- from its (single, truncating) page — and on a loaded host each engine read costs ~1 s, so the
5
+ -- serve was O(PRs x reads). The fix for the CLASS is to serve the hot read path from the app's own
6
+ -- projection: the writers that already KNOW the policy stamp it on the row —
7
+ --
8
+ -- * `submitPr` stamps `merge_requested` (the run's merge intent) at submit/reopen and
9
+ -- `merge_human_approval = 1` when a gated adopt narrows a live loop;
10
+ -- * `completeUserTaskAttributed` (app/agentCompletion.ts) stamps `merge_human_approval = 0` when a
11
+ -- human APPROVES at the `merge-approval` task (mirroring the task's `humanApproval`
12
+ -- output mapping), so /status lifts the blocker the moment approval lands;
13
+ -- * `startMerge` stamps `merge_requested = 1, merge_human_approval = 0` (a merge loop only starts
14
+ -- for a requested, approved merge).
15
+ --
16
+ -- `merge_requested` is NULL on every pre-#947 row: those legacy rows keep the #937 batched engine
17
+ -- read (constant, never per-row) until they next reopen. `merge_human_approval` is NULL while an
18
+ -- approval is outstanding and 0/1 once decided — mirroring the loop's `humanApproval` variable,
19
+ -- which the `merge-approval` user task clears to `false` on an explicit approve. Both columns are
20
+ -- run-scoped: `submitPr`'s atomic reopen resets them to the fresh run's intent.
21
+ ALTER TABLE pull_requests ADD COLUMN merge_requested INTEGER;
22
+ ALTER TABLE pull_requests ADD COLUMN merge_human_approval INTEGER;
@@ -0,0 +1,51 @@
1
+ -- Re-create the `pull_requests_read_model` VIEW so it re-exports the new base columns (the VIEW must pass
2
+ -- through EVERY base `pull_requests` column — app/pullRequestReadModel.test.ts DRIFT GUARD). A mechanical
3
+ -- re-emission of 131 with `merge_requested` / `merge_human_approval` (132, issue #947) added; the derived
4
+ -- columns are emitted VERBATIM from app/pullRequestReadModel.ts and are unchanged. No BEGIN/COMMIT (the
5
+ -- runner wraps each file). Numbered after 132.
6
+
7
+ DROP VIEW IF EXISTS pull_requests_read_model;
8
+
9
+ CREATE VIEW pull_requests_read_model AS
10
+ SELECT
11
+ pr.pr_key AS pr_key,
12
+ pr.repo AS repo,
13
+ pr.number AS number,
14
+ pr.url AS url,
15
+ pr.title AS title,
16
+ COALESCE(pr.derived_status, pr.status) AS status,
17
+ pr.current_round AS current_round,
18
+ pr.process_key AS process_key,
19
+ pr.waiting_since AS waiting_since,
20
+ pr.last_review_id AS last_review_id,
21
+ pr.outcome AS outcome,
22
+ pr.created_at AS created_at,
23
+ pr.updated_at AS updated_at,
24
+ pr.converged_at AS converged_at,
25
+ pr.merged_at AS merged_at,
26
+ pr.active_worker AS active_worker,
27
+ pr.lease_until AS lease_until,
28
+ pr.last_nudge_at AS last_nudge_at,
29
+ pr.fresh_head_run_head AS fresh_head_run_head,
30
+ pr.abandon_token AS abandon_token,
31
+ pr.incident_key AS incident_key,
32
+ pr.incident_type AS incident_type,
33
+ pr.incident_since AS incident_since,
34
+ pr.incident_message AS incident_message,
35
+ pr.last_round_head AS last_round_head,
36
+ pr.last_progress_job_key AS last_progress_job_key,
37
+ pr.last_progress_result AS last_progress_result,
38
+ pr.last_progress_agent_watermark AS last_progress_agent_watermark,
39
+ pr.churn_escalated_round AS churn_escalated_round,
40
+ pr.root_request_key AS root_request_key,
41
+ pr.epic_phase_label AS epic_phase_label,
42
+ pr.acknowledged_at AS acknowledged_at,
43
+ pr.auto_resolve_escalations AS auto_resolve_escalations,
44
+ pr.termination_reason AS termination_reason,
45
+ pr.termination_process_key AS termination_process_key,
46
+ pr.retire_pending_key AS retire_pending_key,
47
+ pr.merge_requested AS merge_requested,
48
+ pr.merge_human_approval AS merge_human_approval,
49
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
50
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
51
+ FROM pull_requests__tracking pr;