@nanobpm/nano-workforce 0.208.1 → 0.208.3

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
@@ -416,6 +416,21 @@ export interface PullRequest {
416
416
  // Opt-in escalation steward (120_escalation_auto_resolutions.sql, issue #889): 1 ⇒ this PR's open
417
417
  // escalations are eligible for the steward to auto-resolve (recorded reviewably). Default 0.
418
418
  auto_resolve_escalations: number;
419
+ // Loop-termination provenance (130_pull_requests_termination_reason.sql, issue #943): the reason the
420
+ // app last cancelled this PR's convergence/merge loop, stamped by {@link terminatePrLoop}. NULL when
421
+ // the app never cancelled the current run.
422
+ termination_reason: string | null;
423
+ // The engine instance `termination_reason` describes (issue #943, PR #944 review): provenance is
424
+ // bound to the CANCELLED instance, so a predecessor's reason can never explain away the orphaning of
425
+ // a successor that later owns the row. Honoured only while it equals `process_key`.
426
+ termination_process_key: string | null;
427
+ // A handed-off predecessor merge loop whose retirement (engine cancel) has not yet succeeded (PR #944
428
+ // review). Durable so {@link retryPendingRetirements} re-attempts it on every poll pass instead of
429
+ // dropping the key after a bounded in-memory retry. NULL when nothing is pending.
430
+ retire_pending_key?: string | null;
431
+ // Operator Dismiss stamp (app/acknowledge.ts). Run-scoped: cleared on reopen so a dismissal of a
432
+ // PRIOR run never hides an orphaning of the current one.
433
+ acknowledged_at?: string | null;
419
434
  }
420
435
 
421
436
  interface PrDependency {
@@ -963,6 +978,13 @@ async function submitPrCritical(
963
978
  // watermark could drop the new run's early rounds from the churn window (or, after the new
964
979
  // run passes that number, silently expire). Reset it so the new run's churn clock starts clean.
965
980
  churn_escalated_round: null,
981
+ // Run-scoped termination provenance (issue #943): a prior run's cancel reason must not mask a
982
+ // later orphaning of THIS run in `/status`.
983
+ termination_reason: null,
984
+ termination_process_key: null,
985
+ // The operator Dismiss is run-scoped too (PR #944 review): a dismissal of the PRIOR run must not
986
+ // count as acknowledging a later unexplained termination of THIS run.
987
+ acknowledged_at: null,
966
988
  outcome: null,
967
989
  converged_at: null,
968
990
  merged_at: null,
@@ -1091,6 +1113,12 @@ export async function startMerge(
1091
1113
  reviewRequest: null,
1092
1114
  // Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
1093
1115
  rootRequestKey,
1116
+ // Merge policy (issue #936): a merge loop only starts for a requested, approved merge. Seeding
1117
+ // the policy triple keeps it in /status's batched reads (no per-instance probes). Inert here —
1118
+ // the merge-loop process reads none of them.
1119
+ autoMerge: true,
1120
+ convergeOnly: false,
1121
+ humanApproval: false,
1094
1122
  abandonUrl: abUrl,
1095
1123
  abandonBrief: renderAbandonBrief(abUrl),
1096
1124
  // Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
@@ -1185,6 +1213,9 @@ export interface ActivePr {
1185
1213
  /** The ACTIVE engine incident parking this PR's instance (issue #931), mirrored by the poller's
1186
1214
  * `pollIncidents` pass; null when none. While set, `status` reads `incident`. */
1187
1215
  incident: StatusIncident | null;
1216
+ /** Why the app last cancelled this PR's loop (issue #943), naming the successor/outcome; null when
1217
+ * never. An `orphaned` status means the loop ended with no reason, successor, or escalation. */
1218
+ terminationReason: string | null;
1188
1219
  }
1189
1220
 
1190
1221
  /** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
@@ -1212,7 +1243,7 @@ export async function activePrs(
1212
1243
  // `status`/worker-owned transient columns are still surfaced on each row below (the view re-exports
1213
1244
  // `base.*`), and `pull_requests` has no `onWaitingHuman` edge, so for a still-active PR
1214
1245
  // `derived_status === status`.
1215
- .filter((p) => !TERMINAL_STATUSES.includes(p.derived_status))
1246
+ .filter((p) => !TERMINAL_STATUSES.includes(p.derived_status) || isOrphanedPr(p))
1216
1247
  .sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0));
1217
1248
  // Derive the STRUCTURED open-escalation pointer from the ONE `user_tasks` read model (issue #666) —
1218
1249
  // the same user-task surface `listEscalations` and the Convergence/Tasks page consume — so `/status`
@@ -1230,27 +1261,34 @@ export async function activePrs(
1230
1261
  openEscByPr.set(t.subject_key, toOpenEscalation(t));
1231
1262
  }
1232
1263
  // Merge intent (issue #916): read the loop's `autoMerge` / `convergeOnly` / `humanApproval`
1233
- // variables per PR so the effective merge policy is visible without opening the engine. One
1234
- // best-effort read per PR; a failure (engine unreachable, instance gone) degrades to the
1235
- // historical default (merge requested) rather than failing the whole status read.
1264
+ // variables per PR so the effective merge policy is visible without opening the engine.
1265
+ //
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.
1236
1273
  const mergeByPr = new Map<string, MergePolicy>();
1237
- await Promise.all(
1238
- active.map(async (p) => {
1239
- mergeByPr.set(p.pr_key, await mergePolicyFor(p.process_key ?? null, engine));
1240
- }),
1241
- );
1274
+ const activeKeys = active.map((p) => p.process_key).filter((k): k is string => k != null && k !== "");
1275
+ const varsByInstance = await readMergePolicyVars(engine, activeKeys);
1276
+ for (const p of active) {
1277
+ mergeByPr.set(p.pr_key, mergePolicyFromVars(varsByInstance?.get(p.process_key ?? "")));
1278
+ }
1242
1279
  return active.map((p) => {
1243
1280
  // Issue #931: an ACTIVE engine incident parked on the loop's instance must be VISIBLE — the row
1244
1281
  // kept reading `converging` for hours while nothing ran. Overlay the derived `incident` status
1245
1282
  // (the base `status` column is never touched) and carry the mirrored incident block.
1246
1283
  const incident = statusIncidentFor(p);
1284
+ const orphaned = isOrphanedPr(p);
1247
1285
  return {
1248
1286
  prKey: p.pr_key,
1249
1287
  repo: p.repo,
1250
1288
  number: p.number,
1251
1289
  url: p.url,
1252
1290
  title: p.title ?? null,
1253
- status: incident ? INCIDENT_STATUS : p.status,
1291
+ status: orphaned ? ORPHANED_PR_STATUS : incident ? INCIDENT_STATUS : p.status,
1254
1292
  round: p.current_round,
1255
1293
  processKey: p.process_key ?? null,
1256
1294
  waitingSince: p.waiting_since ?? null,
@@ -1261,10 +1299,57 @@ export async function activePrs(
1261
1299
  autoResolveEscalations: p.auto_resolve_escalations === 1,
1262
1300
  merge: mergeByPr.get(p.pr_key) ?? mergePolicyDefault(),
1263
1301
  incident,
1302
+ terminationReason: p.termination_reason ?? null,
1264
1303
  };
1265
1304
  });
1266
1305
  }
1267
1306
 
1307
+ /** `/status` overlay for an ORPHANED PR (issue #943): its loop instance ended (derived tracking edge
1308
+ * folded to `abandoned`) while the base row still sits at an in-flight stage — no worker or human
1309
+ * settled it — and no termination reason was recorded (every app-initiated cancel records one via
1310
+ * {@link terminatePrLoop}, and the operator cancel door records one too), no merge landed, and no
1311
+ * successor took over. Cleared by an operator Dismiss (`acknowledged_at`). That is exactly the
1312
+ * silently-dropped nanobpm/nano-bpm#1452 shape; it must be visible, not filtered out. */
1313
+ export const ORPHANED_PR_STATUS = "orphaned";
1314
+
1315
+ /** Whether a tracked PR row is orphaned (see {@link ORPHANED_PR_STATUS}). Exported for tests. */
1316
+ export function isOrphanedPr(p: {
1317
+ status: string;
1318
+ derived_status: string;
1319
+ process_key?: string | null;
1320
+ termination_reason?: string | null;
1321
+ termination_process_key?: string | null;
1322
+ merged_at: string | null;
1323
+ acknowledged_at?: string | null;
1324
+ }): boolean {
1325
+ return (
1326
+ TERMINAL_STATUSES.includes(p.derived_status) &&
1327
+ // An operator Dismiss (`acknowledged_at`) clears the overlay once a human has seen it.
1328
+ !p.acknowledged_at &&
1329
+ // The BASE row is still at an in-flight stage of the loop (the manifest's active set): nothing
1330
+ // settled it. This base-vs-derived divergence IS the orphan signal.
1331
+ activeStatusesFor("pull_requests").includes(p.status) &&
1332
+ // Provenance only explains the CURRENT owner's termination: a reason bound to a different
1333
+ // (predecessor) instance never masks an orphaned successor.
1334
+ !hasOwnTerminationReason(p) &&
1335
+ p.merged_at == null
1336
+ );
1337
+ }
1338
+
1339
+ /** Whether the row carries a termination reason recorded for the instance that currently owns it. */
1340
+ function hasOwnTerminationReason(p: {
1341
+ process_key?: string | null;
1342
+ termination_reason?: string | null;
1343
+ termination_process_key?: string | null;
1344
+ }): boolean {
1345
+ return (
1346
+ !!p.termination_reason &&
1347
+ p.termination_process_key != null &&
1348
+ p.process_key != null &&
1349
+ String(p.termination_process_key) === String(p.process_key)
1350
+ );
1351
+ }
1352
+
1268
1353
  /** The default merge policy when no instance variables are readable (mid-handoff row, engine
1269
1354
  * unreachable, or a legacy loop carrying neither variable): the pre-#916 behaviour merged by
1270
1355
  * default, so the projection must NOT read such a PR as review-only. */
@@ -1284,44 +1369,134 @@ function mergePolicyFrom(requested: boolean, humanApproval: boolean): MergePolic
1284
1369
  return { requested, enabled, effective, blockedBy };
1285
1370
  }
1286
1371
 
1287
- /** Read one boolean process variable off a live instance; undefined when absent/unreadable. */
1288
- async function readBoolVar(
1289
- engine: Pick<EngineClient, "searchVariables">,
1290
- processKey: string,
1291
- name: string,
1292
- ): Promise<boolean | undefined> {
1293
- const [row] = await engine.searchVariables({ processInstanceKey: processKey, name });
1294
- if (!row) return undefined;
1372
+ /** The merge-policy variable triple for one instance: the values of `autoMerge`, `convergeOnly`,
1373
+ * and `humanApproval`, each `undefined` when the instance does not carry it (or it is not a
1374
+ * boolean). */
1375
+ type MergePolicyVars = {
1376
+ autoMerge: boolean | undefined;
1377
+ convergeOnly: boolean | undefined;
1378
+ humanApproval: boolean | undefined;
1379
+ };
1380
+
1381
+ function emptyMergePolicyVars(): MergePolicyVars {
1382
+ return { autoMerge: undefined, convergeOnly: undefined, humanApproval: undefined };
1383
+ }
1384
+
1385
+ function parseBoolValue(value: string): boolean | undefined {
1295
1386
  try {
1296
- const v: unknown = JSON.parse(row.value);
1387
+ const v: unknown = JSON.parse(value);
1297
1388
  return typeof v === "boolean" ? v : undefined;
1298
1389
  } catch {
1299
1390
  return undefined;
1300
1391
  }
1301
1392
  }
1302
1393
 
1303
- /** Derive a PR's effective merge policy from its loop's process variables (issue #916). The
1304
- * canonical positive `autoMerge` wins; the legacy inverted `convergeOnly` is the fallback for
1305
- * in-flight loops seeded before `autoMerge` existed. Best-effort: any read failure degrades to the
1306
- * historical default (merge requested) rather than failing the status read. */
1307
- async function mergePolicyFor(
1308
- processKey: string | null,
1394
+ const MERGE_POLICY_NAMES = ["autoMerge", "convergeOnly", "humanApproval"] as const;
1395
+ type MergePolicyName = (typeof MERGE_POLICY_NAMES)[number];
1396
+
1397
+ function isMergePolicyName(name: string): name is MergePolicyName {
1398
+ return MERGE_POLICY_NAMES.some((n) => n === name);
1399
+ }
1400
+
1401
+ /** Batched merge-policy read (issue #936): ONE name-filtered engine-wide read per policy variable
1402
+ * (3 concurrent calls, independent of row count), bucketed by owning instance.
1403
+ *
1404
+ * Completeness: the engine seam returns a single page with no cursor, so an engine-wide read can
1405
+ * silently omit rows, and the three name pages truncate independently. Completeness is therefore
1406
+ * tracked per (instance, name): every name an active instance is missing from is re-probed with a
1407
+ * name-filtered root read (<=1 row, cannot be truncated). Every loop seeds all three names at
1408
+ * creation (submitPr for the review loop, startMerge for the merge loop), so the steady state is a
1409
+ * constant 3 calls; probes are paid only for genuine page cuts or legacy instances.
1410
+ *
1411
+ * Scope: only the PROCESS-ROOT variable (`scopeKey === processInstanceKey`) is the loop's policy; a
1412
+ * same-named child-scope variable is ignored so it can never overwrite the root value.
1413
+ *
1414
+ * Failure isolation: a failed BATCH read rejects the whole projection (its page is partial, so
1415
+ * absence can no longer be distinguished from a page cut) and the caller degrades every row to
1416
+ * the default. A failed TARGETED read degrades only its own instance (its partial entry is dropped,
1417
+ * so it projects the default, never a half-read policy) — the policies already
1418
+ * fetched for every other PR are preserved (a rejected fallback must not discard a sibling's
1419
+ * `autoMerge:false` or `humanApproval:true` and silently un-block its merge).
1420
+ *
1421
+ * Skips the engine entirely when no active row carries a process key. Returns `undefined` when no
1422
+ * engine was handed in or a batched read fails — the caller degrades every row to the default. */
1423
+ async function readMergePolicyVars(
1309
1424
  engine: Pick<EngineClient, "searchVariables"> | undefined,
1310
- ): Promise<MergePolicy> {
1311
- if (processKey == null || !engine) return mergePolicyDefault();
1425
+ activeKeys: readonly string[],
1426
+ ): Promise<Map<string, MergePolicyVars> | undefined> {
1427
+ if (!engine || activeKeys.length === 0) return undefined;
1428
+ const wanted = new Set(activeKeys);
1312
1429
  try {
1313
- const [autoMerge, convergeOnly, humanApproval] = await Promise.all([
1314
- readBoolVar(engine, processKey, "autoMerge"),
1315
- readBoolVar(engine, processKey, "convergeOnly"),
1316
- readBoolVar(engine, processKey, "humanApproval"),
1317
- ]);
1318
- const requested = autoMerge ?? (convergeOnly === undefined ? true : !convergeOnly);
1319
- return mergePolicyFrom(requested, humanApproval === true);
1430
+ const byInstance = new Map<string, MergePolicyVars>();
1431
+ const seen = new Map<string, Set<MergePolicyName>>();
1432
+ const record = (row: { name: string; processInstanceKey: string; scopeKey: string; value: string }) => {
1433
+ const pk = String(row.processInstanceKey);
1434
+ if (!wanted.has(pk) || String(row.scopeKey) !== pk) return;
1435
+ if (!isMergePolicyName(row.name)) return;
1436
+ const vars = byInstance.get(pk) ?? emptyMergePolicyVars();
1437
+ vars[row.name] = parseBoolValue(row.value);
1438
+ byInstance.set(pk, vars);
1439
+ const names = seen.get(pk) ?? new Set<MergePolicyName>();
1440
+ names.add(row.name);
1441
+ seen.set(pk, names);
1442
+ };
1443
+ await Promise.all(
1444
+ MERGE_POLICY_NAMES.map(async (name) => {
1445
+ for (const row of await engine.searchVariables({ name })) if (row.name === name) record(row);
1446
+ }),
1447
+ );
1448
+ // Completeness is tracked per (instance, NAME), not per instance: the three name pages truncate
1449
+ // independently, so an instance present in one page (e.g. `autoMerge`) may still have been cut
1450
+ // from another (e.g. `humanApproval`). Each name an instance is missing from is re-probed with a
1451
+ // name-filtered ROOT read — a <=1-row result that cannot be truncated, so it proves absence.
1452
+ // `convergeOnly` is skipped once a usable boolean `autoMerge` is known (the canonical intent wins). Every loop
1453
+ // seeds all three names at creation (submitPr; startMerge seeds the merge loop's policy too), so
1454
+ // the steady state is exactly 3 calls; probes are only paid for page cuts and legacy instances.
1455
+ 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;
1464
+ try {
1465
+ // Collect ALL probe results first and record only once every probe succeeded: recording
1466
+ // inside each probe would let a slower successful sibling re-create the entry the catch
1467
+ // below deleted after an earlier rejection, exposing a half-read policy.
1468
+ const results = await Promise.all(
1469
+ probe.map(async (name) =>
1470
+ (await engine.searchVariables({ processInstanceKey: pk, scopeKey: pk, name })).filter(
1471
+ (row) => row.name === name,
1472
+ ),
1473
+ ),
1474
+ );
1475
+ for (const rows of results) for (const row of rows) record(row);
1476
+ } catch {
1477
+ // Degrade ONLY this instance — and fully: drop any partial entry the batch recorded so
1478
+ // the caller projects the default for it, never a half-read policy.
1479
+ byInstance.delete(pk);
1480
+ }
1481
+ }),
1482
+ );
1483
+ return byInstance;
1320
1484
  } catch {
1321
- return mergePolicyDefault();
1485
+ return undefined;
1322
1486
  }
1323
1487
  }
1324
1488
 
1489
+ /** Derive a PR's effective merge policy from an already-read variable triple (issue #916). The
1490
+ * canonical positive `autoMerge` wins; the legacy inverted `convergeOnly` is the fallback for
1491
+ * in-flight loops seeded before `autoMerge` existed; an instance carrying neither (or absent from
1492
+ * the batch — mid-handoff row, instance gone) degrades to the historical default (merge
1493
+ * requested) rather than failing the status read. */
1494
+ function mergePolicyFromVars(vars: MergePolicyVars | undefined): MergePolicy {
1495
+ if (!vars) return mergePolicyDefault();
1496
+ const requested = vars.autoMerge ?? (vars.convergeOnly === undefined ? true : !vars.convergeOnly);
1497
+ return mergePolicyFrom(requested, vars.humanApproval === true);
1498
+ }
1499
+
1325
1500
  /** A delivery-graph run currently in flight, as reported by the status endpoint (issue #889 §1 —
1326
1501
  * "Show it in `/status` for each PR or delivery"). Mirrors {@link ActivePr} for the delivery side. */
1327
1502
  export interface ActiveDelivery {
@@ -1490,7 +1665,7 @@ export async function retireTerminalReviewWait(
1490
1665
  if (pr.process_key != null) {
1491
1666
  const key = String(pr.process_key);
1492
1667
  try {
1493
- await engine.cancelInstance({ processInstanceKey: key });
1668
+ await terminatePrLoop(data, engine, pr.pr_key, key, `review wait retired: PR ${liveness} out-of-band`, true);
1494
1669
  } catch (err) {
1495
1670
  // Only finalize the row once the instance is confirmed gone/terminal: a transiently-failed
1496
1671
  // cancel would otherwise drop the row from `waiting_review` scans while the live instance's
@@ -1914,36 +2089,75 @@ export async function maybeEnsureFreshHeadRun(
1914
2089
  * defaults `autoResolveEscalations` to false and overwrites the row's flag on reopen, so an opted-in PR
1915
2090
  * would silently lose automated escalation resolution merely because late review threads appeared.
1916
2091
  *
1917
- * RETRYABLE on failure (PR #922 review, thread 3): if the cancel succeeds but `submitPr` throws, the
1918
- * row is restored to `waiting_merge` so the next pass re-attempts the handoff against the
1919
- * already-retired (terminal) merge key — rather than stranding in `waiting_review`, which `pollMerges`
1920
- * no longer scans and whose `readiness-ready` resume correlates to nothing once the merge instance is
1921
- * cancelled. The restored row's `process_key` still names the now-terminal merge instance, so the
1922
- * unfinished handoff is re-detected next pass by the dead-owner guard in `pollMerges` (which keys off
1923
- * the terminal `derived_status`, independently of the GitHub verdict). Best-effort throughout: a failed
1924
- * cancel or restore only logs, leaving the row for the next pass. */
1925
- async function handoffWaitingMergeToConvergence(
2092
+ * RETRYABLE on failure, with creation and retirement recovered SEPARATELY (PR #944 review): if no
2093
+ * successor is created, the row is restored to `waiting_merge` naming the still-LIVE merge owner, so
2094
+ * the next pass simply retries. Once a successor is published the row is never demoted; retiring the
2095
+ * captured merge key is retried by {@link retireHandedOffMergeLoop}. Exported for tests. */
2096
+ export async function handoffWaitingMergeToConvergence(
1926
2097
  data: DataLayer,
1927
2098
  engine: EngineClient,
1928
2099
  pr: PullRequest,
1929
2100
  logContext: string,
1930
2101
  ) {
1931
2102
  const prKey = pr.pr_key;
2103
+ const mergeKey = pr.process_key != null ? String(pr.process_key) : null;
1932
2104
  try {
1933
2105
  // `waiting_since` bounds the review the fresh loop collects: only a review submitted at or after
1934
2106
  // the handoff counts, so a pre-merge review (submitted before this flip) can never satisfy the new
1935
2107
  // run's wait.
1936
- const handoffTs = now();
1937
- await prs(data).update(prKey, { status: "waiting_review", waiting_since: handoffTs, updated_at: handoffTs });
1938
- const mergeKey = pr.process_key;
1939
- if (mergeKey != null) {
1940
- try {
1941
- await engine.cancelInstance({ processInstanceKey: String(mergeKey) });
1942
- } catch (cancelErr) {
1943
- console.error(`[poller] ${logContext} handoff ${prKey}: retire merge instance ${mergeKey}: ${cancelErr}`);
1944
- }
2108
+ // FENCE the staging write on the captured owner (PR #944 review): if a cancel/resubmit replaced the
2109
+ // owner while this pass awaited GitHub, the replacement owns the row (possibly parked on a stage such
2110
+ // as `waiting_deps`) — re-staging it to `waiting_review` would strand it outside its stage's scan.
2111
+ // The STAGE side of the fence is an allowlist (PR #944 round-6 review), not "unchanged since this
2112
+ // pass read the row": a restart mid-handoff can strand the row at a handoff-transient stage
2113
+ // (`waiting_review`, or keyless at `converging` after a phantom null-key publish) outside the
2114
+ // `waiting_merge` recovery scan, and an unchanged-stage fence would skip it forever. The fence
2115
+ // admits (a) the LIVE merge stages the merge poller scans (`waiting_merge`, `waiting_lane`) and
2116
+ // (b) a KEYLESS row at a handoff-transient stage — there is no live owner to protect, and the
2117
+ // rollback below restores the live merge stage so the next pass re-drives the handoff. A
2118
+ // successor-owned row (`converging` naming the fresh convergence instance) still fails the OWNER
2119
+ // check and is never demoted; a predecessor-owned `waiting_review` row is mid-handoff and is left
2120
+ // for `retryPendingRetirements`, not re-staged.
2121
+ const fresh = await prs(data).get(prKey);
2122
+ const freshOwner = fresh?.process_key != null ? String(fresh.process_key) : null;
2123
+ const HANDOFFABLE_STAGES = new Set(["waiting_merge", "waiting_lane"]);
2124
+ const HANDOFF_TRANSIENT_STAGES = new Set(["waiting_review", "converging"]);
2125
+ const stageOk =
2126
+ HANDOFFABLE_STAGES.has(String(fresh?.status)) ||
2127
+ (freshOwner == null && HANDOFF_TRANSIENT_STAGES.has(String(fresh?.status)));
2128
+ if (!fresh || freshOwner !== mergeKey || !stageOk) {
2129
+ console.log(`[poller] ${logContext} handoff ${prKey}: owner/stage changed (${freshOwner}/${fresh?.status}); skipping`);
2130
+ return;
2131
+ }
2132
+ // ONE pending predecessor retirement at a time (PR #944 review): while an earlier predecessor's
2133
+ // retirement is unconfirmed, never start another handoff — a second key would overwrite the first
2134
+ // and leave that live loop untracked. `retryPendingRetirements` clears it first. A KEYLESS owner
2135
+ // row is exempt (PR #944 round-6 review): there is no live owner to protect — the pending key names
2136
+ // the predecessor the ORIGINAL handoff stranded, and this recovery handoff re-stages against THAT
2137
+ // SAME key, so nothing is overwritten.
2138
+ const pendingKey = fresh.retire_pending_key != null ? String(fresh.retire_pending_key) : "";
2139
+ if (pendingKey !== "" && mergeKey != null && pendingKey !== mergeKey) {
2140
+ console.log(`[poller] ${logContext} handoff ${prKey}: predecessor ${pendingKey} retirement pending; deferring`);
2141
+ return;
1945
2142
  }
1946
- await submitPr(
2143
+ const handoffTs = now();
2144
+ // Persist the retirement INTENT in the same write that stages the handoff (PR #944 review), so a
2145
+ // restart after successor publication can never lose the predecessor's key. The retry pass only
2146
+ // acts once the row's owner is no longer this key (i.e. the successor is published); a rollback
2147
+ // clears it.
2148
+ await prs(data).update(prKey, {
2149
+ status: "waiting_review",
2150
+ waiting_since: handoffTs,
2151
+ updated_at: handoffTs,
2152
+ ...(mergeKey != null ? { retire_pending_key: mergeKey } : {}),
2153
+ });
2154
+ // SUCCESSOR FIRST, then retire (issue #943). The old order — cancel the merge instance, THEN
2155
+ // `submitPr` — orphaned nanobpm/nano-bpm#1452: the derived tracking status folds the cancel in
2156
+ // asynchronously, so `submitPr` still read the row's `process_key` as a LIVE owner, returned
2157
+ // `alreadyRunning`, and started NOTHING — the merge loop was gone, no convergence loop took over,
2158
+ // no human was told. Now the live merge instance hands back as the exact owner (`handoffFrom`, the
2159
+ // #939 channel), so the successor exists before the predecessor is retired.
2160
+ const res = await submitPr(
1947
2161
  data,
1948
2162
  engine,
1949
2163
  { repo: pr.repo, number: pr.number, url: pr.url, prKey },
@@ -1953,25 +2167,183 @@ async function handoffWaitingMergeToConvergence(
1953
2167
  null,
1954
2168
  false,
1955
2169
  pr.auto_resolve_escalations === 1,
2170
+ undefined,
2171
+ mergeKey,
1956
2172
  );
1957
- console.log(`[poller] ${logContext} -> handed back into convergence -> ${prKey}`);
2173
+ const successor = "processKey" in res ? res.processKey : null;
2174
+ if (successor == null) {
2175
+ // Deduped against a REPLACEMENT owner (a cancel/resubmit landed while this pass awaited GitHub):
2176
+ // the live replacement owns the row now — never overwrite its key with the stale merge key.
2177
+ const current = await prs(data).get(prKey);
2178
+ if (current?.process_key != null && String(current.process_key) !== mergeKey) {
2179
+ console.log(`[poller] ${logContext} handoff ${prKey}: replaced by ${current.process_key}; leaving it as owner`);
2180
+ return;
2181
+ }
2182
+ // No successor was started — never retire the merge loop into nothing.
2183
+ throw new Error("convergence hand-off started no successor instance");
2184
+ }
2185
+ // The successor is PUBLISHED: from here on the row is convergence-owned and must never be demoted
2186
+ // back to `waiting_merge` (PR #944 review) — a retirement failure is recovered separately, against
2187
+ // the CAPTURED merge key, without touching the successor's row.
2188
+ if (mergeKey != null) await retireHandedOffMergeLoop(data, engine, prKey, mergeKey, successor, logContext);
2189
+ console.log(`[poller] ${logContext} -> handed back into convergence (${successor}) -> ${prKey}`);
1958
2190
  } catch (err) {
2191
+ // CREATION failed (submitPr threw or started nothing): roll back to the prior owner. The merge
2192
+ // instance was NOT retired, so it stays LIVE and keeps owning the PR — restore BOTH the stage and
2193
+ // its `process_key` (a null-successor reopen may already have overwritten the key with NULL), so
2194
+ // the next pass re-attempts the handoff against the right instance. A row that had no owner key
2195
+ // (an earlier unfinished handoff) stays keyless: the NULL key is the durable "unfinished handoff"
2196
+ // marker the dead-owner guard in `pollMerges` keys on. Best-effort: a failed restore is only logged.
1959
2197
  console.error(`[poller] ${logContext} handoff ${prKey}: ${err}`);
1960
- // Restore the row to `waiting_merge` so the next pass re-attempts the handoff, and NULL the
1961
- // `process_key`: the merge instance was retired above (or never lived), so the row genuinely has
1962
- // NO live owner. The null key is the durable "unfinished handoff" marker the dead-owner guard in
1963
- // `pollMerges` keys on — and it is what distinguishes this retryable shape from a HUMAN cancel of a
1964
- // parked merge wait (which leaves `process_key` naming the terminated instance and must NOT be
1965
- // resurrected). Best-effort: a failed restore still leaves the original error logged.
1966
2198
  try {
1967
- await prs(data).update(prKey, { status: "waiting_merge", process_key: null, updated_at: now() });
2199
+ // FENCE the rollback on the captured owner (PR #944 review): if a replacement took the row while
2200
+ // we awaited, it owns the PR — restoring the stale merge key would point tracking at a retired loop.
2201
+ const current = await prs(data).get(prKey);
2202
+ if (current?.process_key != null && String(current.process_key) !== mergeKey) {
2203
+ console.log(`[poller] ${logContext} handoff ${prKey}: owner changed to ${current.process_key}; no rollback`);
2204
+ return;
2205
+ }
2206
+ await prs(data).update(prKey, {
2207
+ status: "waiting_merge",
2208
+ ...(mergeKey != null ? { process_key: mergeKey, retire_pending_key: null } : {}),
2209
+ updated_at: now(),
2210
+ });
1968
2211
  } catch (restoreErr) {
1969
2212
  console.error(`[poller] ${logContext} handoff ${prKey}: restore waiting_merge: ${restoreErr}`);
1970
2213
  }
1971
2214
  }
1972
2215
  }
1973
2216
 
2217
+ /** Retire the predecessor merge loop after its convergence successor is published (issue #943, PR #944
2218
+ * review). Bounded retry against the CAPTURED merge key; a persistent failure is logged loudly but never
2219
+ * rolls the successor-owned row back. Exported for tests. */
2220
+ export async function retireHandedOffMergeLoop(
2221
+ data: DataLayer,
2222
+ engine: Pick<EngineClient, "cancelInstance">,
2223
+ prKey: string,
2224
+ mergeKey: string,
2225
+ successor: string,
2226
+ logContext: string,
2227
+ attempts = 2,
2228
+ ): Promise<boolean> {
2229
+ const reason = `merge loop handed back to convergence (${logContext}) -> successor ${successor}`;
2230
+ for (let i = 1; i <= attempts; i++) {
2231
+ try {
2232
+ await terminatePrLoop(data, engine, prKey, mergeKey, reason, true);
2233
+ await setRetirePending(data, prKey, mergeKey, false);
2234
+ return true;
2235
+ } catch (err) {
2236
+ console.error(`[poller] ${logContext} handoff ${prKey}: retire merge ${mergeKey} attempt ${i}/${attempts}: ${err}`);
2237
+ }
2238
+ }
2239
+ // Persist the pending retirement so later passes keep retrying it (never drop the predecessor key —
2240
+ // a live merge loop could fire its timeout and overwrite the successor-owned row).
2241
+ await setRetirePending(data, prKey, mergeKey, true);
2242
+ return false;
2243
+ }
2244
+
2245
+ /** Durably mark/clear a pending predecessor retirement. Clearing is fenced on the key so a newer
2246
+ * pending retirement is never wiped by an older one's success. Best-effort (logged). */
2247
+ async function setRetirePending(data: DataLayer, prKey: string, mergeKey: string, pending: boolean): Promise<void> {
2248
+ try {
2249
+ const row = await prs(data).get(prKey);
2250
+ if (!row) return;
2251
+ if (pending) await prs(data).update(prKey, { retire_pending_key: mergeKey, updated_at: now() });
2252
+ else if (row.retire_pending_key != null && String(row.retire_pending_key) === mergeKey)
2253
+ await prs(data).update(prKey, { retire_pending_key: null, updated_at: now() });
2254
+ } catch (err) {
2255
+ console.error(`[poller] ${prKey}: persist retire-pending ${mergeKey}: ${err}`);
2256
+ }
2257
+ }
2258
+
2259
+ /** Poll pass (PR #944 review): re-attempt every persisted pending predecessor retirement. Never touches
2260
+ * the row's stage or owner — only the predecessor instance and the pending marker. Exported for tests. */
2261
+ export async function retryPendingRetirements(data: DataLayer, engine: Pick<EngineClient, "cancelInstance">) {
2262
+ for (const pr of await prs(data).all()) {
2263
+ const key = pr.retire_pending_key;
2264
+ if (key == null || key === "") continue;
2265
+ // A pending key that is still the row's OWNER means the handoff never published a successor (e.g. a
2266
+ // restart mid-handoff): the merge loop is live and owning — never retire it into nothing.
2267
+ if (pr.process_key != null && String(pr.process_key) === String(key)) continue;
2268
+ // A KEYLESS owner row is the same unpublished-handoff shape (PR #944 round-5 review): `submitPr`
2269
+ // can publish a null `process_key`, and if the app stops before the rollback lands (or the rollback
2270
+ // write fails) the pending key survives with NO successor. Retiring here would cancel the still-live
2271
+ // merge loop with successor "none" — the ownerless termination this PR exists to prevent. Leave the
2272
+ // pending marker in place: the dead-owner guard in `pollMerges` re-drives the handoff from the
2273
+ // NULL-key row, and the next pass after a successor is published retires the predecessor against it.
2274
+ if (pr.process_key == null) {
2275
+ console.log(`[poller] retire-retry ${pr.pr_key}: no successor published (process_key null); deferring retirement of ${key}`);
2276
+ continue;
2277
+ }
2278
+ await retireHandedOffMergeLoop(data, engine, pr.pr_key, String(key), String(pr.process_key), "retire-retry", 1);
2279
+ }
2280
+ }
2281
+
2282
+ /** Record WHY a PR loop instance was terminated (issue #943), bound to THAT instance. The ONE writer of
2283
+ * termination provenance, shared by {@link terminatePrLoop} (app-initiated) and every operator cancel
2284
+ * door (`/actions/cancel`, `/actions/cancel-pr`). The reason is stamped only when `processKey` is the
2285
+ * row's CURRENT owner: once a successor owns the row (the convergence hand-off publishes it before
2286
+ * retiring the predecessor), the predecessor's reason has nothing to explain on that row and must not
2287
+ * mask a later orphaning of the successor (PR #944 review). Exported for the cancel doors + tests. */
2288
+ export async function recordTerminationProvenance(
2289
+ data: DataLayer,
2290
+ prKey: string,
2291
+ processKey: string,
2292
+ reason: string,
2293
+ ): Promise<boolean> {
2294
+ const row = await prs(data).get(prKey);
2295
+ if (!row || row.process_key == null || String(row.process_key) !== String(processKey)) return false;
2296
+ await prs(data).update(prKey, {
2297
+ termination_reason: reason,
2298
+ termination_process_key: String(processKey),
2299
+ updated_at: now(),
2300
+ });
2301
+ return true;
2302
+ }
2303
+
2304
+ /** Record operator-cancel provenance on every PR row a cancelled instance owns (issue #943). */
2305
+ export async function recordOperatorCancelProvenance(data: DataLayer, processKey: string, door: string): Promise<void> {
2306
+ for (const row of await prs(data).find({ process_key: processKey })) {
2307
+ await recordTerminationProvenance(data, row.pr_key, processKey, `operator cancel (${door})`);
2308
+ }
2309
+ }
2310
+
2311
+ /** THE canonical door for an app-initiated cancel of a PR's convergence/merge loop (issue #943).
2312
+ * Every in-app termination must say WHY and what took over: the reason (which names the successor
2313
+ * instance or the terminal GitHub outcome) is stamped on the row's `termination_reason` BEFORE the
2314
+ * engine cancel, so even a crash between the two leaves provenance. `strict` rethrows a failed cancel
2315
+ * (callers that must confirm the instance is gone); otherwise a failed cancel is logged. The
2316
+ * `cancel-sites` guard test (app/loopTermination.test.ts) fails if any new `cancelInstance` call site
2317
+ * targeting a PR loop bypasses this door. Exported for tests. */
2318
+ export async function terminatePrLoop(
2319
+ data: DataLayer,
2320
+ engine: Pick<EngineClient, "cancelInstance">,
2321
+ prKey: string,
2322
+ processKey: string,
2323
+ reason: string,
2324
+ strict: boolean,
2325
+ ): Promise<void> {
2326
+ try {
2327
+ await recordTerminationProvenance(data, prKey, processKey, reason);
2328
+ } catch (err) {
2329
+ // Provenance is diagnostic: a failed write must never keep a loop alive that must be retired.
2330
+ console.error(`[loop] ${prKey}: record termination reason for ${processKey} failed: ${err}`);
2331
+ }
2332
+ try {
2333
+ await engine.cancelInstance({ processInstanceKey: processKey });
2334
+ } catch (err) {
2335
+ if (strict) throw err;
2336
+ console.error(`[loop] ${prKey}: cancel ${processKey} (${reason}) failed: ${err}`);
2337
+ }
2338
+ }
2339
+
1974
2340
  export async function pollMerges(data: DataLayer, engine: EngineClient, token: string) {
2341
+ // 0) Pending predecessor retirements from earlier hand-offs (PR #944 review).
2342
+ try {
2343
+ await retryPendingRetirements(data, engine);
2344
+ } catch (err) {
2345
+ console.error(`[poller] retire-retry: ${err}`);
2346
+ }
1975
2347
  // 1) Dependencies merged?
1976
2348
  for (const pr of await prs(data).find({ status: "waiting_deps" })) {
1977
2349
  const prKey = pr.pr_key;