@nanobpm/nano-workforce 0.208.3 → 0.208.5

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/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.3",
3
- "gitSha": "ff42e2a6fa77fd780949822adf3d4f2b9caefc38",
4
- "gitTag": "v0.208.3",
5
- "builtAt": "2026-11-11T02:35:58.462Z"
2
+ "version": "0.208.5",
3
+ "gitSha": "c3f19d50ef6cf4f731f50ab778b5cd7ebdb3454e",
4
+ "gitTag": "v0.208.5",
5
+ "builtAt": "2026-11-11T09:26:21.393Z"
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;
@@ -159,7 +159,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
159
159
  assert.ok(
160
160
  flows.includes("ensure-base-branch->record-feature-implementing") &&
161
161
  flows.includes("record-feature-implementing->implement") &&
162
- flows.includes("Start->implement-task"),
162
+ flows.includes("slice-branch->implement-task"),
163
163
  `the run reaches the implement agent (via the implement cell) only after the gate (flows: ${flows.join(", ")})`,
164
164
  );
165
165
  // A green probe never escalates.
@@ -192,7 +192,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
192
192
  assert.ok(!flows.includes("gw-readiness->readiness-preflight"), "an ungated feature never enters the preflight");
193
193
  assert.ok(
194
194
  flows.includes("record-feature-implementing->implement") &&
195
- flows.includes("Start->implement-task"),
195
+ flows.includes("slice-branch->implement-task"),
196
196
  "an ungated feature reaches the implement agent (via the implement cell)",
197
197
  );
198
198
  } finally {
@@ -21,6 +21,7 @@ import { after, before, describe, test } from "node:test";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import type { EngineJob } from "@nanobpm/urban/runtime";
23
23
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
24
+ import { recoverBranchCreate } from "../app/repoEnvelope.ts";
24
25
  import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
25
26
  import { advancePastTimer, settleFully } from "./support/time.ts";
26
27
 
@@ -127,32 +128,127 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
127
128
  const singleTaskPlan: Stub = () => ({ tasks: [{ id: "t1", title: "T1", prompt: "do t1" }] });
128
129
  const approveReview: Stub = () => ({ approved: true, findings: "" });
129
130
 
130
- test("host-git provisioning: each slice's implement-cell agent job carries the repository envelope (#684)", async () => {
131
+ test("host-git provisioning: each slice's implement-cell agent job carries the repository envelope AND the deterministic branch.create (#684/#945)", async () => {
131
132
  // The whole-epic seed emits `io.nanobpm.agentTask.repository` so every slice's `senior:feature`
132
133
  // agent gets an ISOLATED clone instead of clobbering the worker's launch dir. The process variable
133
134
  // rides down through the wave subprocess + `implement-cell-call` callActivity into each agent job.
134
135
  // The epic seed carries the BASE branch as `ref` but NO `branch.create` — each slice's
135
- // `feat/<task.id>` differs per MI child, so the agent cuts its own branch inside the clone.
136
- let agentTask: any = null;
136
+ // `feat/<task.id>` differs per MI child, so it can't be named at seed time. Issue #945: the
137
+ // per-instance `slice-branch` worker (first step of each implement-cell) now stamps the deterministic `branch.create` (`feat/<task.id>`)
138
+ // onto the envelope as a FLAT dotted process variable (`io.nanobpm.agentTask.branch.create`), which
139
+ // the c8ctl harness deep-merges with the whole-object seed — so a retry re-derives the SAME branch
140
+ // and the durable WIP checkpoint restore matches (previously every retry cut a random
141
+ // `nano/agent-work/*` fallback and discarded the prior session's work).
142
+ let agentVars: Record<string, unknown> = {};
137
143
  await withApp(
138
144
  {
139
145
  "senior:plan": singleTaskPlan,
140
146
  "senior:plan-review": approveReview,
141
147
  "senior:feature": (job) => {
142
- agentTask = (job.variables as Record<string, any>)["io.nanobpm.agentTask"] ?? null;
148
+ agentVars = job.variables as Record<string, unknown>;
143
149
  return { status: "blocked", summary: "n/a" };
144
150
  },
145
151
  },
146
152
  async ({ app }) => {
147
153
  await app.settle();
154
+ const agentTask = agentVars["io.nanobpm.agentTask"] as Record<string, any> | undefined;
148
155
  assert.ok(agentTask, "the epic slice's senior:feature job carried the io.nanobpm.agentTask envelope");
149
156
  const agentRepo = agentTask.repository;
150
157
  assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
151
158
  assert.equal(agentRepo.ref, "epic/e2e", "the epic seed checks out the BASE (integration) branch");
152
- // `branch` is a top-level sibling of `repository` (#882); the epic seed omits it entirely.
153
- assert.equal("branch" in agentTask, false, "the epic seed omits branch.create — the agent branches per slice");
154
159
  assert.equal(agentRepo.singleBranch, true, "the blobless/single-branch monorepo shaping rides along");
155
160
  assert.equal(agentRepo.filter, "blob:none");
161
+ // The whole-object seed still omits `branch` (it can't name a per-child branch)…
162
+ assert.equal("branch" in agentTask, false, "the whole-object seed carries no per-slice branch");
163
+ // …but the per-instance slice-branch worker stamps it as a FLAT dotted variable, which the harness
164
+ // merges onto the SAME envelope. Assert the dotted variable is present and names the
165
+ // deterministic `feat/<task.id>` branch (the single task here is `t1`).
166
+ assert.equal(
167
+ agentVars["io.nanobpm.agentTask.branch.create"],
168
+ "feat/t1",
169
+ "slice-branch stamps the deterministic per-slice branch.create as a flat dotted variable (#945)",
170
+ );
171
+ // Round-trip through the harness normalizer shape: the merged envelope must recover
172
+ // `branch.create` from the top level (proving the dotted variable and the whole-object seed
173
+ // merge into one envelope the harness reads).
174
+ assert.equal(
175
+ recoverBranchCreate({}, agentVars),
176
+ "feat/t1",
177
+ "the harness merges the dotted branch.create with the whole-object seed envelope (#945)",
178
+ );
179
+ },
180
+ );
181
+ });
182
+
183
+ test("sibling isolation: two parallel slices each see their OWN branch.create, never a sibling's (#945)", async () => {
184
+ // A per-slice branch emitted into the SHARED plan-fanout MI body lands on the epic root, so a
185
+ // sibling's emission overwrites it and a slice can inherit `feat/<sibling>` — sending its work and
186
+ // checkpoint to the wrong branch. The emitter runs inside each implement-cell instance instead, so
187
+ // every activation's recovered branch must be `feat/<its own task.id>`.
188
+ const seen: Array<{ id: unknown; branch: string | undefined }> = [];
189
+ await withApp(
190
+ {
191
+ "senior:plan": () => ({
192
+ tasks: [
193
+ { id: "t1", title: "T1", prompt: "do t1" },
194
+ { id: "t2", title: "T2", prompt: "do t2" },
195
+ ],
196
+ }),
197
+ "senior:plan-review": approveReview,
198
+ "senior:feature": (job) => {
199
+ const vars = job.variables as Record<string, any>;
200
+ seen.push({ id: vars.task?.id, branch: recoverBranchCreate({}, vars) });
201
+ return { status: "blocked", summary: "n/a" };
202
+ },
203
+ },
204
+ async ({ app }) => {
205
+ await app.settle();
206
+ assert.deepEqual(
207
+ seen.map((s) => s.id).sort(),
208
+ ["t1", "t2"],
209
+ "both sibling slices activated their implement-task",
210
+ );
211
+ for (const s of seen) {
212
+ assert.equal(s.branch, `feat/${String(s.id)}`, `slice ${String(s.id)} must see its OWN branch.create`);
213
+ }
214
+ },
215
+ );
216
+ });
217
+
218
+ test("retry determinism: a re-dispatched implement-task names the SAME branch.create, so the WIP checkpoint restore matches (#945)", async () => {
219
+ // Issue #945's field failure: every c8ctl retry of an implement-cell slice cut a NEW random
220
+ // `nano/agent-work/*` fallback branch, so the durable WIP checkpoint (keyed on the deterministic
221
+ // `feat/<task.id>`) was NEVER restored — each retry started from the epic base and discarded the
222
+ // prior session's work. The fix makes `branch.create` a pure function of `task.id`, so a retry
223
+ // re-derives the SAME branch and the checkpoint restore matches. Drive the implement-task through
224
+ // TWO activations of the SAME slice (an escalation parks it; answering re-dispatches it — the same
225
+ // re-activation an idle-kill retry produces) and assert both carry the identical recovered
226
+ // `branch.create`.
227
+ const seenBranches: Array<string | undefined> = [];
228
+ await withApp(
229
+ {
230
+ "senior:plan": singleTaskPlan,
231
+ "senior:plan-review": approveReview,
232
+ "senior:feature": (job) => {
233
+ seenBranches.push(recoverBranchCreate({}, job.variables as Record<string, unknown>));
234
+ // First activation escalates (parks for a human); the resume (second activation) blocks.
235
+ return seenBranches.length === 1
236
+ ? { status: "escalated", question: "Which API should I use?", summary: "parked for a human" }
237
+ : { status: "blocked", summary: "resumed after answer" };
238
+ },
239
+ },
240
+ async ({ app, processKey }) => {
241
+ // First activation parked on the human-escalation task; answer it to re-dispatch the SAME slice.
242
+ const task = await openTask(app, processKey, "escalation");
243
+ await app.engine.completeUserTask(task.userTaskKey, { resolution: "answer", answer: "use v2" });
244
+ await app.settle();
245
+ assert.equal(seenBranches.length, 2, "the implement-task was activated twice (initial + retry)");
246
+ assert.equal(seenBranches[0], "feat/t1", "the FIRST activation carries the deterministic branch.create");
247
+ assert.equal(
248
+ seenBranches[1],
249
+ seenBranches[0],
250
+ "the RETRY activation re-derives the SAME branch.create — the WIP checkpoint restore matches (#945)",
251
+ );
156
252
  },
157
253
  );
158
254
  });
@@ -532,7 +628,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
532
628
 
533
629
  const flows = takenFlows(app);
534
630
  assert.ok(
535
- flows.includes("wait-caps-resolved->implement-cell-call") && flows.includes("Start->implement-task"),
631
+ flows.includes("wait-caps-resolved->implement-cell-call") && flows.includes("slice-branch->implement-task"),
536
632
  `caps-resolved released the barrier into the implement cell (flows: ${flows.join(", ")})`,
537
633
  );
538
634
  assert.equal(
@@ -614,7 +710,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
614
710
  assert.ok(
615
711
  answered.includes("w_gw_answer->record-task-implementing") &&
616
712
  answered.includes("record-task-implementing->implement-cell-call") &&
617
- answered.includes("Start->implement-task"),
713
+ answered.includes("slice-branch->implement-task"),
618
714
  `answering the caps escalation reset the stale question and released the barrier into the implement cell (flows: ${answered.join(", ")})`,
619
715
  );
620
716
  assert.equal(featureRan, true, "the agent was dispatched once the operator answered the caps escalation");
@@ -668,7 +764,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
668
764
  assert.ok(
669
765
  entered.includes("w_gw_answer->record-task-implementing") &&
670
766
  entered.includes("record-task-implementing->implement-cell-call") &&
671
- entered.includes("Start->implement-task"),
767
+ entered.includes("slice-branch->implement-task"),
672
768
  `answering the caps escalation reset the stale question and released the barrier into the implement cell (flows: ${entered.join(", ")})`,
673
769
  );
674
770
 
package/nano.app.json CHANGED
@@ -180,6 +180,10 @@
180
180
  "taskType": "pr.select-wave",
181
181
  "handler": "workers/select-wave/worker.ts"
182
182
  },
183
+ {
184
+ "taskType": "pr.slice-branch",
185
+ "handler": "workers/slice-branch/worker.ts"
186
+ },
183
187
  {
184
188
  "taskType": "pr.caps-prepare",
185
189
  "handler": "workers/caps-prepare/worker.ts"