@nanobpm/nano-workforce 0.208.2 → 0.208.4

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.
@@ -8,6 +8,7 @@
8
8
  // rebase) and the PRE-PR implementation dispatch (`feature.ts`: `startFeature`; `plan.ts`:
9
9
  // `startPlan` → the epic's `implement-cell`) derive from this single implementation without a
10
10
  // `plan.ts ↔ service.ts` import cycle (AGENTS.md "derivation over duplication — no drift surfaces").
11
+ import { isPlausibleBranchName } from "./baseBranch.ts";
11
12
  import { readEnvOr } from "./contracts.ts";
12
13
  import { isCommitSha } from "./world/index.ts";
13
14
 
@@ -34,6 +35,43 @@ export function isResolvableRepo(repo: unknown): repo is string {
34
35
  return typeof repo === "string" && isPlainOwnerRepo(repo.trim());
35
36
  }
36
37
 
38
+ /** The FLAT, dotted process-variable NAME a per-slice worker emits to attach the deterministic
39
+ * `branch.create` to the implement-cell's agent-job envelope (issue #945). The c8ctl harness's
40
+ * `collectEnvelopeFrom` reassembles the reserved namespace from BOTH the whole-object
41
+ * `io.nanobpm.agentTask` variable (the whole-epic seed, which carries `repository` + `task` but no
42
+ * per-slice `branch`) AND any dotted `io.nanobpm.agentTask.*` variables in scope, deep-merging them —
43
+ * so a dotted-name variable set in the MI-child scope lands on the SAME envelope the seed started,
44
+ * and `normalizeTaskEnvelope` then reads `branch.create` from the merged top level. A
45
+ * `<zeebe:ioMapping>` target with dots canNOT produce this: it builds a NESTED
46
+ * `{io:{nanobpm:{agentTask:…}}}` object the flat-key harness never reads (see {@link
47
+ * flattenAgentTaskEnvelope}). Only a worker result key literally NAMED with this dotted string (the
48
+ * engine propagates worker result variables verbatim, mapped or not) materialises the dotted
49
+ * variable. */
50
+ export const AGENT_TASK_BRANCH_CREATE_VAR = `${AGENT_TASK_NS}.branch.create`;
51
+
52
+ /** The ONE canonical derivation of an implementation slice's deterministic pre-PR branch —
53
+ * `feat/<taskId>` (the agent-guide's `feat/*` convention, resources/prompts/feature.md). This is the
54
+ * single source of truth for BOTH the whole-run feature seed (`feature.ts` `startFeature`, where the
55
+ * branch is known at seed time and rides the envelope's `branch.create` directly) AND the epic
56
+ * plan-fanout's per-slice emission (`workers/slice-branch`, run inside each implement-cell instance, which stamps it onto the implement-cell
57
+ * envelope via {@link AGENT_TASK_BRANCH_CREATE_VAR} because each slice's `task.id` differs per child
58
+ * and can't be named at the whole-epic seed). Issue #945: the plan-fanout implement-cell previously
59
+ * carried NO `branch.create`, so every c8ctl retry cut a random `nano/agent-work/*` fallback branch
60
+ * and the durable WIP checkpoint (keyed on `feat/<task.id>`) was never restored — each retry discarded
61
+ * the prior session's work. Deriving it here once keeps the envelope and the prompt's branch
62
+ * instruction (which tells the agent to use `feat/<task.id>`) from drifting into two sources.
63
+ *
64
+ * Returns `null` for a `taskId` whose derived branch is not a plausible git ref (mirrors the
65
+ * delivery-runner's `feat/<node.id>` guard, #776): an ill-formed `branch.create` would turn the
66
+ * isolation guarantee into a launch/checkout failure, so a blank/implausible id degrades to omission
67
+ * (the pre-#945 agent-cuts-its-own-branch behaviour) rather than emit an unusable value. */
68
+ export function sliceBranchCreate(taskId: string | null | undefined): string | null {
69
+ const id = typeof taskId === "string" ? taskId.trim() : "";
70
+ if (id === "") return null;
71
+ const branch = `feat/${id}`;
72
+ return isPlausibleBranchName(branch) ? branch : null;
73
+ }
74
+
37
75
  /** Flatten a repository-isolation envelope object (as built by {@link repoEnvelopeVars} —
38
76
  * `{ "io.nanobpm.agentTask": { repository: { … } } }`) into the FLAT, dotted `io.nanobpm.agentTask.*`
39
77
  * key → string-value pairs the c8ctl worker harness reads from a service task's `<zeebe:taskHeaders>`
@@ -66,6 +104,90 @@ export function flattenAgentTaskEnvelope(envelope: Record<string, unknown>): Rec
66
104
  return out;
67
105
  }
68
106
 
107
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
108
+ return v != null && typeof v === "object" && !Array.isArray(v);
109
+ }
110
+
111
+ function deepMerge(base: unknown, over: unknown): unknown {
112
+ if (!isPlainObject(base)) return isPlainObject(over) ? deepMerge({}, over) : over;
113
+ const out: Record<string, unknown> = { ...base };
114
+ if (!isPlainObject(over)) return out;
115
+ for (const [k, v] of Object.entries(over)) {
116
+ if (v === undefined) continue;
117
+ out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : v;
118
+ }
119
+ return out;
120
+ }
121
+
122
+ function setPath(obj: Record<string, unknown>, path: string[], value: unknown): void {
123
+ let cur = obj;
124
+ for (let i = 0; i < path.length - 1; i++) {
125
+ const k = path[i];
126
+ const next = cur[k];
127
+ // After the ensure-object step the slot is always a plain object; narrow via the type guard
128
+ // (no `as` — the ban-`as` gate) so the descent stays type-safe.
129
+ if (!isPlainObject(next)) {
130
+ const fresh: Record<string, unknown> = {};
131
+ cur[k] = fresh;
132
+ cur = fresh;
133
+ } else {
134
+ cur = next;
135
+ }
136
+ }
137
+ cur[path[path.length - 1]] = value;
138
+ }
139
+
140
+ /** Mirror of the c8ctl harness's `collectEnvelopeFrom`: reassemble the reserved namespace out of BOTH
141
+ * a whole-object/JSON `io.nanobpm.agentTask` value AND flattened dotted `io.nanobpm.agentTask.*` keys
142
+ * (the channel a per-slice worker's dotted-name result variable uses, issue #945), deep-merging the
143
+ * two. This is the ONE consumer-side mirror shared by the round-trip contract tests
144
+ * (`app/repoEnvelope.test.ts`) and the e2e fan-out proof (`e2e/plan-fanout.e2e.ts`) so producer and
145
+ * consumer can never drift into two readings of where `branch.create` lives. */
146
+ function collectEnvelopeFrom(source: Record<string, unknown>): Record<string, unknown> {
147
+ const out: Record<string, unknown> = {};
148
+ if (!isPlainObject(source)) return out;
149
+ const whole = source[AGENT_TASK_NS];
150
+ if (whole != null) {
151
+ let val: unknown = whole;
152
+ if (typeof whole === "string") {
153
+ try {
154
+ val = JSON.parse(whole);
155
+ } catch {
156
+ val = undefined;
157
+ }
158
+ }
159
+ if (isPlainObject(val)) Object.assign(out, deepMerge(out, val));
160
+ }
161
+ const prefix = `${AGENT_TASK_NS}.`;
162
+ for (const [key, value] of Object.entries(source)) {
163
+ if (!key.startsWith(prefix)) continue;
164
+ const rest = key.slice(prefix.length);
165
+ if (!rest) continue;
166
+ setPath(out, rest.split("."), value);
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /** Mirror of the c8ctl harness's `normalizeTaskEnvelope` branch slice: deep-merge the envelope
172
+ * recovered from `customHeaders` under that recovered from `variables` (variables win), then read
173
+ * `branch` from the TOP LEVEL of the merged envelope (`isPlainObject(raw.branch)`), NEVER from
174
+ * `raw.repository.branch`. Returns the consumer's recovered `branch.create` (`undefined` when the
175
+ * producer put it somewhere the consumer doesn't look, or omitted it). This is the ONE place tests
176
+ * assert "the harness sees this branch" — keeping producer (`repoEnvelopeVars` / the per-slice dotted
177
+ * variable) and consumer pinned together (issues #882/#945). */
178
+ export function recoverBranchCreate(
179
+ customHeaders: Record<string, unknown>,
180
+ variables: Record<string, unknown>,
181
+ ): string | undefined {
182
+ const merged = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables));
183
+ // deepMerge of two plain-object envelope slices is always a plain object; narrow via the type guard
184
+ // (no `as` — the ban-`as` gate). A non-object merge (impossible here) reads as "no branch".
185
+ const raw = isPlainObject(merged) ? merged : {};
186
+ const branch = isPlainObject(raw.branch) ? raw.branch : {};
187
+ const create = branch.create;
188
+ return create == null ? undefined : String(create);
189
+ }
190
+
69
191
  /** Raised by `requireRepoEnvelopeVars` when the repository-isolation envelope is REQUIRED on a fan-out
70
192
  * path but its inputs are unresolved (a blank base/head `ref`, or a `repo` that is not a plain
71
193
  * `owner/repo`). Issue #729: the fan-out dispatch paths must fail loudly here rather than let
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,
@@ -1191,6 +1213,9 @@ export interface ActivePr {
1191
1213
  /** The ACTIVE engine incident parking this PR's instance (issue #931), mirrored by the poller's
1192
1214
  * `pollIncidents` pass; null when none. While set, `status` reads `incident`. */
1193
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;
1194
1219
  }
1195
1220
 
1196
1221
  /** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
@@ -1218,7 +1243,7 @@ export async function activePrs(
1218
1243
  // `status`/worker-owned transient columns are still surfaced on each row below (the view re-exports
1219
1244
  // `base.*`), and `pull_requests` has no `onWaitingHuman` edge, so for a still-active PR
1220
1245
  // `derived_status === status`.
1221
- .filter((p) => !TERMINAL_STATUSES.includes(p.derived_status))
1246
+ .filter((p) => !TERMINAL_STATUSES.includes(p.derived_status) || isOrphanedPr(p))
1222
1247
  .sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0));
1223
1248
  // Derive the STRUCTURED open-escalation pointer from the ONE `user_tasks` read model (issue #666) —
1224
1249
  // the same user-task surface `listEscalations` and the Convergence/Tasks page consume — so `/status`
@@ -1256,13 +1281,14 @@ export async function activePrs(
1256
1281
  // kept reading `converging` for hours while nothing ran. Overlay the derived `incident` status
1257
1282
  // (the base `status` column is never touched) and carry the mirrored incident block.
1258
1283
  const incident = statusIncidentFor(p);
1284
+ const orphaned = isOrphanedPr(p);
1259
1285
  return {
1260
1286
  prKey: p.pr_key,
1261
1287
  repo: p.repo,
1262
1288
  number: p.number,
1263
1289
  url: p.url,
1264
1290
  title: p.title ?? null,
1265
- status: incident ? INCIDENT_STATUS : p.status,
1291
+ status: orphaned ? ORPHANED_PR_STATUS : incident ? INCIDENT_STATUS : p.status,
1266
1292
  round: p.current_round,
1267
1293
  processKey: p.process_key ?? null,
1268
1294
  waitingSince: p.waiting_since ?? null,
@@ -1273,10 +1299,57 @@ export async function activePrs(
1273
1299
  autoResolveEscalations: p.auto_resolve_escalations === 1,
1274
1300
  merge: mergeByPr.get(p.pr_key) ?? mergePolicyDefault(),
1275
1301
  incident,
1302
+ terminationReason: p.termination_reason ?? null,
1276
1303
  };
1277
1304
  });
1278
1305
  }
1279
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
+
1280
1353
  /** The default merge policy when no instance variables are readable (mid-handoff row, engine
1281
1354
  * unreachable, or a legacy loop carrying neither variable): the pre-#916 behaviour merged by
1282
1355
  * default, so the projection must NOT read such a PR as review-only. */
@@ -1592,7 +1665,7 @@ export async function retireTerminalReviewWait(
1592
1665
  if (pr.process_key != null) {
1593
1666
  const key = String(pr.process_key);
1594
1667
  try {
1595
- await engine.cancelInstance({ processInstanceKey: key });
1668
+ await terminatePrLoop(data, engine, pr.pr_key, key, `review wait retired: PR ${liveness} out-of-band`, true);
1596
1669
  } catch (err) {
1597
1670
  // Only finalize the row once the instance is confirmed gone/terminal: a transiently-failed
1598
1671
  // cancel would otherwise drop the row from `waiting_review` scans while the live instance's
@@ -2016,36 +2089,75 @@ export async function maybeEnsureFreshHeadRun(
2016
2089
  * defaults `autoResolveEscalations` to false and overwrites the row's flag on reopen, so an opted-in PR
2017
2090
  * would silently lose automated escalation resolution merely because late review threads appeared.
2018
2091
  *
2019
- * RETRYABLE on failure (PR #922 review, thread 3): if the cancel succeeds but `submitPr` throws, the
2020
- * row is restored to `waiting_merge` so the next pass re-attempts the handoff against the
2021
- * already-retired (terminal) merge key — rather than stranding in `waiting_review`, which `pollMerges`
2022
- * no longer scans and whose `readiness-ready` resume correlates to nothing once the merge instance is
2023
- * cancelled. The restored row's `process_key` still names the now-terminal merge instance, so the
2024
- * unfinished handoff is re-detected next pass by the dead-owner guard in `pollMerges` (which keys off
2025
- * the terminal `derived_status`, independently of the GitHub verdict). Best-effort throughout: a failed
2026
- * cancel or restore only logs, leaving the row for the next pass. */
2027
- 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(
2028
2097
  data: DataLayer,
2029
2098
  engine: EngineClient,
2030
2099
  pr: PullRequest,
2031
2100
  logContext: string,
2032
2101
  ) {
2033
2102
  const prKey = pr.pr_key;
2103
+ const mergeKey = pr.process_key != null ? String(pr.process_key) : null;
2034
2104
  try {
2035
2105
  // `waiting_since` bounds the review the fresh loop collects: only a review submitted at or after
2036
2106
  // the handoff counts, so a pre-merge review (submitted before this flip) can never satisfy the new
2037
2107
  // run's wait.
2038
- const handoffTs = now();
2039
- await prs(data).update(prKey, { status: "waiting_review", waiting_since: handoffTs, updated_at: handoffTs });
2040
- const mergeKey = pr.process_key;
2041
- if (mergeKey != null) {
2042
- try {
2043
- await engine.cancelInstance({ processInstanceKey: String(mergeKey) });
2044
- } catch (cancelErr) {
2045
- console.error(`[poller] ${logContext} handoff ${prKey}: retire merge instance ${mergeKey}: ${cancelErr}`);
2046
- }
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;
2047
2142
  }
2048
- 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(
2049
2161
  data,
2050
2162
  engine,
2051
2163
  { repo: pr.repo, number: pr.number, url: pr.url, prKey },
@@ -2055,25 +2167,183 @@ async function handoffWaitingMergeToConvergence(
2055
2167
  null,
2056
2168
  false,
2057
2169
  pr.auto_resolve_escalations === 1,
2170
+ undefined,
2171
+ mergeKey,
2058
2172
  );
2059
- 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}`);
2060
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.
2061
2197
  console.error(`[poller] ${logContext} handoff ${prKey}: ${err}`);
2062
- // Restore the row to `waiting_merge` so the next pass re-attempts the handoff, and NULL the
2063
- // `process_key`: the merge instance was retired above (or never lived), so the row genuinely has
2064
- // NO live owner. The null key is the durable "unfinished handoff" marker the dead-owner guard in
2065
- // `pollMerges` keys on — and it is what distinguishes this retryable shape from a HUMAN cancel of a
2066
- // parked merge wait (which leaves `process_key` naming the terminated instance and must NOT be
2067
- // resurrected). Best-effort: a failed restore still leaves the original error logged.
2068
2198
  try {
2069
- 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
+ });
2070
2211
  } catch (restoreErr) {
2071
2212
  console.error(`[poller] ${logContext} handoff ${prKey}: restore waiting_merge: ${restoreErr}`);
2072
2213
  }
2073
2214
  }
2074
2215
  }
2075
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
+
2076
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
+ }
2077
2347
  // 1) Dependencies merged?
2078
2348
  for (const pr of await prs(data).find({ status: "waiting_deps" })) {
2079
2349
  const prKey = pr.pr_key;
@@ -104,6 +104,10 @@ test("submitPr re-opens a derive-only-terminated PR (base 'converging', derived
104
104
  status: "converging", // base transient FROZEN — reconciler no longer writes the terminal
105
105
  derived_status: "abandoned", // ADR-0065 derive-only terminal
106
106
  current_round: 3,
107
+ // A PRIOR run's operator Dismiss + provenance must not survive the reopen (PR #944 review).
108
+ acknowledged_at: "2024-01-05",
109
+ termination_reason: "operator cancel",
110
+ termination_process_key: "PI-1",
107
111
  }],
108
112
  key: "pr_key",
109
113
  },
@@ -126,24 +130,31 @@ test("submitPr re-opens a derive-only-terminated PR (base 'converging', derived
126
130
  assertEquals(pr.status, "converging");
127
131
  assertEquals(pr.current_round, 1); // re-opened for a fresh run
128
132
  assertEquals(pr.process_key, "PI-9");
133
+ assertEquals(pr.acknowledged_at, null, "a prior run's Dismiss never acknowledges this run's orphaning");
134
+ assertEquals(pr.termination_reason, null);
135
+ assertEquals(pr.termination_process_key, null);
129
136
  });
130
137
  });
131
138
 
132
- // #503 / #497 phantom: `activePrs` filters on `derived_status`, so a derive-only-terminated PR drops
133
- // off the Convergence tab instead of showing "active" indefinitely.
134
- test("activePrs excludes a derive-only-terminated PR and keeps a genuinely active one", async () => {
139
+ // #503 / #497 phantom: `activePrs` classifies on `derived_status`, so a derive-only-terminated PR never
140
+ // reads as its frozen in-flight base status. Issue #943: if NO reason was recorded for the termination
141
+ // (an orphan) it is surfaced as `orphaned` rather than silently dropped; a recorded reason or an
142
+ // operator Dismiss (`acknowledged_at`) excludes it.
143
+ test("activePrs never reports a derive-only-terminated PR as active — orphaned if unexplained, excluded if explained/dismissed", async () => {
135
144
  const stores: Stores = {
136
145
  pull_requests: {
137
146
  rows: [
138
147
  { pr_key: "o/r#1", repo: "o/r", number: 1, url: "u1", status: "converging", derived_status: "abandoned", current_round: 2, updated_at: "2024-01-02" },
139
148
  { pr_key: "o/r#2", repo: "o/r", number: 2, url: "u2", status: "converging", derived_status: "converging", current_round: 1, updated_at: "2024-01-01" },
149
+ { pr_key: "o/r#3", repo: "o/r", number: 3, url: "u3", status: "converging", derived_status: "abandoned", process_key: "PI-3", termination_reason: "operator cancel", termination_process_key: "PI-3", current_round: 1, updated_at: "2024-01-03" },
150
+ { pr_key: "o/r#4", repo: "o/r", number: 4, url: "u4", status: "converging", derived_status: "abandoned", acknowledged_at: "2024-01-05", current_round: 1, updated_at: "2024-01-04" },
140
151
  ],
141
152
  key: "pr_key",
142
153
  },
143
154
  escalations: { rows: [], key: "id" },
144
155
  };
145
156
  const active = await activePrs(memData(stores));
146
- assertEquals(active.map((p) => p.prKey), ["o/r#2"]);
157
+ assertEquals(active.map((p) => [p.prKey, p.status]), [["o/r#1", "orphaned"], ["o/r#2", "converging"]]);
147
158
  });
148
159
 
149
160
  // #503: `pollIncidentsImpl` classifies a dead instance on `derived_status`, so a derive-only-terminated
package/build-info.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.208.2",
3
- "gitSha": "a9778e1c7db7205fe0b3b5dfebc597468c4e66f4",
4
- "gitTag": "v0.208.2",
5
- "builtAt": "2026-11-10T20:28:50.098Z"
2
+ "version": "0.208.4",
3
+ "gitSha": "097724344a2f3942af5b1796e2e0a7e8363b44f9",
4
+ "gitTag": "v0.208.4",
5
+ "builtAt": "2026-11-11T03:16:35.107Z"
6
6
  }
@@ -0,0 +1,17 @@
1
+ -- Loop-termination provenance (issue #943). A merge loop was terminated in-app (the waiting-merge →
2
+ -- convergence hand-off) with no successor and no escalation, and nothing on the row said why — the PR
3
+ -- silently dropped off `/status`. Every APP-INITIATED cancel of a PR's convergence/merge loop now
4
+ -- stamps a one-line reason here (via `terminatePrLoop` in app/service.ts), naming the outcome it
5
+ -- led to (a successor convergence instance, or the PR's terminal GitHub state). NULL when the current
6
+ -- run was never cancelled by the app. `/status` surfaces it, and an `orphaned` overlay flags a PR
7
+ -- whose loop ended with NO recorded reason, no successor and no escalation.
8
+ ALTER TABLE pull_requests ADD COLUMN termination_reason TEXT;
9
+
10
+ -- The instance the reason describes (PR #944 review): provenance is bound to the CANCELLED instance and
11
+ -- only explains a termination while it equals the row's `process_key`, so a predecessor's reason can
12
+ -- never mask the later orphaning of a successor.
13
+ ALTER TABLE pull_requests ADD COLUMN termination_process_key TEXT;
14
+
15
+ -- A handed-off predecessor merge loop whose retirement has not yet succeeded (PR #944 review): durable
16
+ -- so the poller retries it every pass instead of dropping the key. NULL when nothing is pending.
17
+ ALTER TABLE pull_requests ADD COLUMN retire_pending_key TEXT;