@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/CHANGELOG.md +12 -0
- package/app/loopTermination.test.ts +412 -0
- package/app/mcpExclusions.test.ts +1 -1
- package/app/mergeBlockedThreads.test.ts +62 -2
- package/app/mergeStatus.test.ts +63 -8
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/service.test.ts +18 -1
- package/app/service.ts +435 -63
- package/app/terminalReaderBehaviour.test.ts +15 -4
- package/build-info.json +4 -4
- package/db/migrations/130_pull_requests_termination_reason.sql +17 -0
- package/db/migrations/131_pull_requests_read_model_termination_reason.sql +48 -0
- package/openapi.yaml +53 -0
- package/operations/cancelInstance.test.ts +9 -0
- package/operations/cancelInstance.ts +28 -2
- package/operations/cancelPrRun.ts +16 -0
- package/operations/listActivePrs.test.ts +289 -0
- package/operations/listActivePrs.ts +20 -0
- package/package.json +1 -1
- package/pages/home.page.json +1 -1
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.
|
|
1234
|
-
//
|
|
1235
|
-
//
|
|
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
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
-
/**
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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(
|
|
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
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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
|
-
|
|
1311
|
-
|
|
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
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
|
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.
|
|
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 #
|
|
1918
|
-
* row is restored to `waiting_merge`
|
|
1919
|
-
*
|
|
1920
|
-
*
|
|
1921
|
-
|
|
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
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|