@nanobpm/nano-workforce 0.210.0 → 0.210.2

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/retro.test.ts CHANGED
@@ -140,6 +140,17 @@ test("isPlanComplete: true when every task is settled (terminal PR or skipped/bl
140
140
  assertEquals(await isPlanComplete(data, PLAN), true);
141
141
  });
142
142
 
143
+ // Issue #959: a PR that MERGED but whose loop was terminated afterwards derived `abandoned` off the
144
+ // `onTerminated` edge. Retro must still treat it as the landed terminal it is. Model the terminated
145
+ // divergence by seeding the base row at its frozen in-flight transient WITH `merged_at` set: the
146
+ // merge-aware tracking view folds it to `merged`, so the plan reads complete (not stuck-in-flight).
147
+ test("isPlanComplete: a merged-then-terminated PR (frozen base + merged_at) is terminal/landed (#959)", async () => {
148
+ const { data, stores } = memData();
149
+ seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
150
+ stores["pull_requests"] = [{ pr_key: "acme/widgets#10", status: "converging", merged_at: "2026-11-01T00:00:00Z" }];
151
+ assertEquals(await isPlanComplete(data, PLAN), true);
152
+ });
153
+
143
154
  test("isPlanComplete: an empty plan has nothing to retrospect", async () => {
144
155
  const { data } = memData();
145
156
  assertEquals(await isPlanComplete(data, PLAN), false);
package/app/service.ts CHANGED
@@ -1464,7 +1464,10 @@ export function isOrphanedPr(p: {
1464
1464
  // Provenance only explains the CURRENT owner's termination: a reason bound to a different
1465
1465
  // (predecessor) instance never masks an orphaned successor.
1466
1466
  !hasOwnTerminationReason(p) &&
1467
- p.merged_at == null
1467
+ p.merged_at == null &&
1468
+ // #959: a landing recorded only in the merge audit derives `merged` (base still `merging`,
1469
+ // `merged_at` NULL) — that PR landed, it is not orphaned.
1470
+ p.derived_status !== "merged"
1468
1471
  );
1469
1472
  }
1470
1473
 
@@ -2053,7 +2056,7 @@ async function classifyWaveTarget(
2053
2056
  token: string,
2054
2057
  ): Promise<"cleared" | "closed" | "pending"> {
2055
2058
  const tracked = await prsTracking(data).get(prKey);
2056
- if (tracked && tracked.status === "merged") return "cleared"; // worker-owned terminal, passes through the derive edge unchanged
2059
+ if (tracked && (tracked.status === "merged" || tracked.derived_status === "merged")) return "cleared"; // worker-owned terminal, passes through the derive edge unchanged
2057
2060
  if (tracked && tracked.derived_status === ABANDONED_STATUS) return "cleared"; // ADR-0065 derive-only terminal → non-blocking, no re-reconcile needed
2058
2061
  const parsed = parsePr(prKey);
2059
2062
  if (!parsed) return "cleared"; // unparseable ref can't be checked → never wedge the barrier
@@ -2139,7 +2142,12 @@ export async function mergeLaneDecisionForPr(data: DataLayer, prKey: string): Pr
2139
2142
  // cancelled/terminated (derive-only terminal → `derived_status === "abandoned"`; the base row is
2140
2143
  // still frozen at its transient). Reading only base `status` here missed a cancelled member and
2141
2144
  // stalled/misrouted the lane.
2142
- if (lanePr && (lanePr.status === "merged" || lanePr.derived_status === "abandoned")) {
2145
+ // #959: an audit-only landing (merge recorded, `pr.mark-merged` never ran) derives `merged` while
2146
+ // the base row is still `merging` — it is just as complete as a base `merged` row.
2147
+ if (
2148
+ lanePr &&
2149
+ (lanePr.status === "merged" || lanePr.derived_status === "merged" || lanePr.derived_status === "abandoned")
2150
+ ) {
2143
2151
  completedPrKeys.add(lanePr.pr_key);
2144
2152
  }
2145
2153
  }
@@ -319,3 +319,47 @@ test("startPlan re-admits a derive-only-abandoned epic (base 'dispatched', deriv
319
319
  assertEquals(stores.plan_tasks.rows.length, 0);
320
320
  });
321
321
  });
322
+
323
+ // #959: an audit-only landing (merge recorded in `merges`, `pr.mark-merged` never ran) derives
324
+ // `merged` while the base row is still `merging`. It must count as lane-complete and never surface as
325
+ // `orphaned` in /status.
326
+ test("#959 mergeLaneDecisionForPr treats an audit-only landed (derived merged) predecessor as complete", async () => {
327
+ const PLAN_KEY = "o/r#100";
328
+ const stores: Stores = {
329
+ plan_tasks: {
330
+ rows: [
331
+ { id: 1, plan_key: PLAN_KEY, task_id: "a", pr_key: "o/r#1" },
332
+ { id: 2, plan_key: PLAN_KEY, task_id: "b", pr_key: "o/r#2" },
333
+ ],
334
+ key: "id",
335
+ },
336
+ plan_merge_exclusions: {
337
+ rows: [{ id: 1, plan_key: PLAN_KEY, task_a: "a", task_b: "b", files: JSON.stringify(["shared.ts"]), source: "file-overlap" }],
338
+ key: "id",
339
+ },
340
+ plan_task_deps: { rows: [], key: "plan_key" },
341
+ pull_requests: {
342
+ rows: [
343
+ { pr_key: "o/r#1", repo: "o/r", number: 1, status: "merging", derived_status: "merged", merged_at: null },
344
+ { pr_key: "o/r#2", repo: "o/r", number: 2, status: "converging", derived_status: "converging" },
345
+ ],
346
+ key: "pr_key",
347
+ },
348
+ };
349
+ const decision = await mergeLaneDecisionForPr(memData(stores), "o/r#2");
350
+ assertEquals(decision?.isHeld, false);
351
+ });
352
+
353
+ test("#959 activePrs does not surface an audit-only landed (derived merged) PR as orphaned", async () => {
354
+ const stores: Stores = {
355
+ pull_requests: {
356
+ rows: [
357
+ { pr_key: "o/r#1", repo: "o/r", number: 1, url: "u1", status: "merging", derived_status: "merged", merged_at: null, current_round: 1, updated_at: "2024-01-01" },
358
+ ],
359
+ key: "pr_key",
360
+ },
361
+ escalations: { rows: [], key: "id" },
362
+ };
363
+ const active = await activePrs(memData(stores));
364
+ assertEquals(active.some((p) => p.status === "orphaned"), false);
365
+ });
package/build-info.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.210.0",
3
- "gitSha": "c1a72ce90744ccd23aa8160d20f0add2779db837",
4
- "gitTag": "v0.210.0",
5
- "builtAt": "2026-11-12T10:53:21.705Z"
2
+ "version": "0.210.2",
3
+ "gitSha": "56f0ff4c4a3a0d8a3cf68745b1f7b9bf539987da",
4
+ "gitTag": "v0.210.2",
5
+ "builtAt": "2026-11-14T15:30:46.025Z"
6
6
  }
@@ -0,0 +1,40 @@
1
+ -- Backfill for issue #959: merged PRs whose run was terminated afterwards showed as "abandoned".
2
+ --
3
+ -- ROOT CAUSE. `pull_requests__tracking.derived_status` mapped ANY terminated run to `abandoned`
4
+ -- without consulting the PR's merge state. Of the 24 top-level runs terminated in November, 23
5
+ -- belong to PRs that MERGED on GitHub — an operator cancelled a redundant post-merge loop, an
6
+ -- out-of-band merge (Mergify / a manual click) was reconciled, or a restart/upgrade cleaned up. Every
7
+ -- one read "abandoned", which made the status untrustworthy and skewed retro/conformance stats
8
+ -- (`LANDED_PR_STATUSES` excludes `abandoned`, so merged PRs didn't count as landed).
9
+ --
10
+ -- THE FIX is the merge-aware derivation (app/prTrackingReadModel.ts): a landed PR now reads `merged`
11
+ -- regardless of how its instance ended, keyed off the base row's `merged_at`. That derivation is
12
+ -- merge-aware ONLY when `merged_at` is set — but several out-of-band/reconciled merges terminated the
13
+ -- loop BEFORE `pr.mark-merged` (or the review-wait retire) could stamp `merged_at`, leaving the base
14
+ -- row frozen at its in-flight transient (`converging`/`waiting_merge`/…) with `merged_at` NULL. For
15
+ -- those rows the derivation still has no merge signal, so they would keep reading `abandoned`.
16
+ --
17
+ -- This migration re-derives the existing rows so the 23 show as `merged`: any `pull_requests` row
18
+ -- that has a `merged` outcome in its `merges` audit trail but whose base `status` is not already
19
+ -- `merged` (and that has no `merged_at` yet) is stamped `status='merged'` + `merged_at`. The audit
20
+ -- trail (`merges.outcome='merged'`) is the canonical record that the PR landed — written by
21
+ -- `pr.mark-merged`, the merge worker's already-merged short-circuit, and the out-of-band retire path.
22
+ -- With the base row settled to `merged`, the derivation reads `merged` whether or not the instance
23
+ -- was later terminated (the terminated edge is gated on `merged_at IS NULL`).
24
+ --
25
+ -- Idempotent and additive-only: it touches only rows with a recorded `merged` audit outcome that are
26
+ -- not already settled `merged`, and never overwrites an existing `merged_at`. `merged_at` is stamped
27
+ -- from the earliest `merged` audit row (`MIN(m.at)`), falling back to the current timestamp when that
28
+ -- is somehow NULL. Safe to re-run.
29
+ UPDATE pull_requests
30
+ SET status = 'merged',
31
+ merged_at = COALESCE(
32
+ merged_at,
33
+ (SELECT MIN(m.at) FROM merges m WHERE m.pr_key = pull_requests.pr_key AND m.outcome = 'merged'),
34
+ datetime('now')
35
+ ),
36
+ updated_at = datetime('now')
37
+ WHERE status <> 'merged'
38
+ AND EXISTS (
39
+ SELECT 1 FROM merges m WHERE m.pr_key = pull_requests.pr_key AND m.outcome = 'merged'
40
+ );
package/main.ts CHANGED
@@ -23,10 +23,16 @@ import { type AgenticChannelHandle, mountAgenticChannel } from "./app/agentic/ch
23
23
  import { makeElementInstanceResolver } from "./app/agentic/element-instance.ts";
24
24
  import { mountConsoleRedirect, resolveConsoleOrigin } from "./app/consoleRedirect.ts";
25
25
  import { announceEngine, resolveEngineAddress } from "./app/enginePreflight.ts";
26
+ import { registerPullRequestTrackingReadModel } from "./app/prTrackingReadModel.ts";
26
27
  import { runEngineReconcile } from "./app/reconcile.ts";
27
28
  import { MAX_ROUNDS, pollOnce } from "./app/service.ts";
28
29
  import { envVar } from "./app/version.ts";
29
30
 
31
+ // Issue #959: register the app-owned, merge-aware `pull_requests__tracking` read model BEFORE the
32
+ // runtime mounts workers/instance-tracking, so the framework provisions THIS derivation (a landed PR
33
+ // reads `merged`, never `abandoned`, however its loop ended) instead of the merge-blind default.
34
+ registerPullRequestTrackingReadModel();
35
+
30
36
  const PORT = Number(process.env.PR_REVIEW_PORT ?? 3000);
31
37
  const POLL_MS = Number(process.env.NANO_PR_POLL_MS ?? 60_000);
32
38
  const GITHUB_TOKEN = process.env.GITHUB_TOKEN ?? "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.210.0",
3
+ "version": "0.210.2",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -17,6 +17,21 @@ import { baseStatusFieldFor, baseTableForTrackingView, trackingTargetFor } from
17
17
  // biome-ignore lint/suspicious/noExplicitAny: test-only fake `table` resolver over dynamic row shapes.
18
18
  type TableFn = (name: string, pk?: string) => any;
19
19
 
20
+ /** Project a base row onto its tracking-VIEW shape: the base columns plus `derived_status`. Mirrors
21
+ * the merge-aware derivation (app/prTrackingReadModel.ts, issue #959): an explicitly-seeded
22
+ * `derived_status` (a test modelling the ADR-0065 terminated divergence) wins ONLY when the row has
23
+ * NOT landed — a `merged_at` stamp folds the effective status to `merged` regardless, exactly as the
24
+ * real VIEW's merged-beats-terminated `WHEN` does. Absent an explicit override, the VIEW's
25
+ * `ELSE base.<statusField>` fall-through applies. */
26
+ // biome-ignore lint/suspicious/noExplicitAny: test-only projection over dynamic row shapes.
27
+ function projectRow(row: any, derivedColumn: string, statusField: string) {
28
+ if (row == null) return row;
29
+ const seeded = row[derivedColumn];
30
+ // merged beats terminated: a landed PR (merged_at set) is `merged` even when its run was terminated.
31
+ if (row.merged_at != null) return { ...row, [derivedColumn]: "merged" };
32
+ return { ...row, [derivedColumn]: seeded ?? row[statusField] };
33
+ }
34
+
20
35
  /** Decorate a fake `table(name, pk)` resolver so it also serves every `<table>__tracking` derived
21
36
  * VIEW off the corresponding base store, projecting `derived_status := base.<statusField>` onto each
22
37
  * row the read methods (`findOne`/`get`/`find`/`all`) return. A non-tracking name is passed through
@@ -29,18 +44,7 @@ export function withTrackingViews<F extends TableFn>(base: F): F {
29
44
  const derivedColumn = trackingTargetFor(baseName).statusColumn;
30
45
  const statusField = baseStatusFieldFor(baseName);
31
46
  // biome-ignore lint/suspicious/noExplicitAny: test-only projection over dynamic row shapes.
32
- const project = (row: any) =>
33
- row == null
34
- ? row
35
- : // Honor an explicitly-seeded `derived_status` so a test can model the ADR-0065 divergence a
36
- // real terminated instance produces — the base `<statusField>` frozen at its last transient
37
- // while the derive edge reports the terminal (`abandoned`/`failed`/`reviewed`). When a row
38
- // seeds no derived column the VIEW's `ELSE base.<statusField>` fall-through applies, so it
39
- // stays byte-for-byte the pass-through the previous behaviour modelled.
40
- {
41
- ...row,
42
- [derivedColumn]: row[derivedColumn] ?? row[statusField],
43
- };
47
+ const project = (row: any) => projectRow(row, derivedColumn, statusField);
44
48
  // biome-ignore lint/suspicious/noExplicitAny: test-only Proxy over a dynamic DataLayer table.
45
49
  return new Proxy(inner, {
46
50
  get(target: any, prop: string) {
@@ -12,7 +12,7 @@ const base: PrState = {
12
12
  totalChecks: 0,
13
13
  presentCheckNames: [],
14
14
  pendingCheckNames: [],
15
- checkConclusions: {},
15
+ checkGateConclusions: {},
16
16
  isDraft: false,
17
17
  headRefOid: null,
18
18
  mergeQueueEntry: null,
@@ -18,7 +18,7 @@ function state(over: Partial<PrState>): PrState {
18
18
  failingCheckNames: [],
19
19
  presentCheckNames: ["build", "Mergify Merge Queue"],
20
20
  pendingCheckNames: [],
21
- checkConclusions: { build: "SUCCESS", "Mergify Merge Queue": "NEUTRAL" },
21
+ checkGateConclusions: { build: ["SUCCESS"], "Mergify Merge Queue": ["NEUTRAL"] },
22
22
  totalChecks: 2,
23
23
  isDraft: false,
24
24
  headRefOid: "abc",
@@ -80,7 +80,7 @@ test("merge-stall-probe: BLOCKED gh-merge PR with green checks still waits (#951
80
80
  // must NOT classify that head's stale check failures as `blocked` (which starts fix-ci / escalates).
81
81
  // It defers to `waiting` (bounded re-poll) until the head moves or the remedy's window lapses.
82
82
  test("merge-stall-probe: a blocked verdict on a pending-update old head defers to waiting (#951)", async () => {
83
- const blocked = state({ failingChecks: 1, failingCheckNames: ["build"], checkConclusions: { build: "FAILURE" } });
83
+ const blocked = state({ failingChecks: 1, failingCheckNames: ["build"], checkGateConclusions: { build: ["FAILURE"] } });
84
84
  // Sanity: without a pending update this IS blocked (would route to fix-ci).
85
85
  assertEquals((await run(blocked, MERGIFY, false)).mergeState, "blocked");
86
86
  // With the update still pending on this head, the probe holds remediation.
@@ -1,9 +1,106 @@
1
1
  // pr.reconverge (issue #939): findings on a head-current review hand the PR back to the ONE canonical
2
2
  // convergence entry (`submitPr`), declaring the merge-loop instance as the hand-off source.
3
3
  import { test } from "node:test";
4
- import { assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
4
+ import { assert, assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
5
5
  import { noopLog } from "../../test/log.ts";
6
- import { makeHandler, type FetchPrHead, type Submit } from "./worker.ts";
6
+ import { makeHandler, type FetchPrHead, type ReadLiveness, type Submit } from "./worker.ts";
7
+
8
+ // Deterministic liveness stub for hand-off tests: never reach the real GitHub transport.
9
+ const openLive: ReadLiveness = async () => "open";
10
+ const nullHead = (async () => null) as unknown as FetchPrHead;
11
+
12
+ // Issue #959: a reconverge hand-off can fire AFTER the PR landed out-of-band (a gate override /
13
+ // Mergify / a manual merge). Re-entering `submitPr` on a landed PR would restart a redundant
14
+ // convergence loop against a deleted head branch (the nano-bpm#1579 clone incident). The worker must
15
+ // instead stamp the row `merged` (so the merge-aware derivation reports `merged`, not `abandoned`,
16
+ // when the instance then ends), record the already-merged audit row, and end the loop cleanly — NOT
17
+ // reconverge.
18
+ test("an already-merged PR ends the loop cleanly instead of reconverging (#959)", async () => {
19
+ const merges: Record<string, unknown>[] = [];
20
+ const updates: Record<string, unknown>[] = [];
21
+ const app = {
22
+ data: {
23
+ table: (name: string) => ({
24
+ get: async () => (name === "pull_requests" ? { auto_resolve_escalations: 0 } : undefined),
25
+ find: async () => [],
26
+ findOne: async () => undefined,
27
+ insert: async (r: Record<string, unknown>) => (merges.push(r), 1),
28
+ update: async (_k: string, patch: Record<string, unknown>) => (updates.push(patch), undefined),
29
+ }),
30
+ source: () => ({ on: () => {}, off: () => {}, emit: () => {} }),
31
+ },
32
+ engine: { createInstance: async () => ({ processInstanceKey: "retro-1" }) },
33
+ log: noopLog(),
34
+ } as any;
35
+ let submitted = false;
36
+ const submit = (async () => {
37
+ submitted = true;
38
+ return { prKey: "acme/w#7", processKey: "999" };
39
+ }) as unknown as Submit;
40
+ const head = (async () => ({ headRef: "feat/x", headSha: "b".repeat(40), baseRef: "main", headRepo: "acme/w" })) as unknown as FetchPrHead;
41
+ const live: ReadLiveness = async () => "merged";
42
+ const out = await makeHandler(submit, head, live)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
43
+ // Ends cleanly — NO reconverge submit, no mergeStatus routing (the loop completes at MergeReconverged).
44
+ assertEquals(submitted, false);
45
+ assertEquals(out, {});
46
+ // The row is stamped landed (merged_at set) so the derivation reports `merged` on termination.
47
+ assert(updates.some((u) => u.status === "merged" && typeof u.merged_at === "string"), "row must be stamped merged");
48
+ // The audit trail carries the already-merged landing (alongside the reconverge-findings record).
49
+ const landing = merges.filter((m) => m.outcome === "merged");
50
+ assertEquals(landing.length, 1);
51
+ assertEquals(landing[0].method, "already-merged");
52
+ });
53
+
54
+ // The guard must NOT fire on a still-open PR: the normal reconverge hand-off proceeds.
55
+ test("a still-open PR proceeds with the normal reconverge hand-off", async () => {
56
+ const app = {
57
+ data: {
58
+ table: (name: string) => ({
59
+ get: async () => (name === "pull_requests" ? { auto_resolve_escalations: 0 } : undefined),
60
+ insert: async () => 1,
61
+ update: async () => undefined,
62
+ }),
63
+ },
64
+ engine: {},
65
+ log: noopLog(),
66
+ } as any;
67
+ let submitted = false;
68
+ const submit = (async () => {
69
+ submitted = true;
70
+ return { prKey: "acme/w#7", processKey: "999" };
71
+ }) as unknown as Submit;
72
+ const head = (async () => ({ headRef: "feat/x", headSha: "b".repeat(40), baseRef: "main", headRepo: "acme/w" })) as unknown as FetchPrHead;
73
+ const live: ReadLiveness = async () => "open";
74
+ const out = await makeHandler(submit, head, live)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
75
+ assertEquals(submitted, true);
76
+ assertEquals(out, {});
77
+ });
78
+
79
+ // An unreadable liveness read (transport hiccup) must not block a genuine reconverge — fail open.
80
+ test("an unreadable liveness read falls through to the normal hand-off (fail-open)", async () => {
81
+ const app = {
82
+ data: {
83
+ table: (name: string) => ({
84
+ get: async () => (name === "pull_requests" ? { auto_resolve_escalations: 0 } : undefined),
85
+ insert: async () => 1,
86
+ update: async () => undefined,
87
+ }),
88
+ },
89
+ engine: {},
90
+ log: noopLog(),
91
+ } as any;
92
+ let submitted = false;
93
+ const submit = (async () => {
94
+ submitted = true;
95
+ return { prKey: "acme/w#7", processKey: "999" };
96
+ }) as unknown as Submit;
97
+ const head = (async () => ({ headRef: "feat/x", headSha: "b".repeat(40), baseRef: "main", headRepo: "acme/w" })) as unknown as FetchPrHead;
98
+ const live: ReadLiveness = async () => {
99
+ throw new Error("github unreachable");
100
+ };
101
+ await makeHandler(submit, head, live)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
102
+ assertEquals(submitted, true);
103
+ });
7
104
 
8
105
  test("re-submits through submitPr with auto-merge on and handoffFrom = this merge-loop instance", async () => {
9
106
  const merges: Record<string, unknown>[] = [];
@@ -22,7 +119,7 @@ test("re-submits through submitPr with auto-merge on and handoffFrom = this merg
22
119
  args = a;
23
120
  return { prKey: "acme/w#7" };
24
121
  }) as unknown as Submit;
25
- await makeHandler(submit)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
122
+ await makeHandler(submit, nullHead, openLive)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
26
123
  assertEquals((args[2] as { prKey: string }).prKey, "acme/w#7");
27
124
  assertEquals(args[7], false, "no re-gate on human approval");
28
125
  assertEquals(args[8], true, "steward opt-in carried from the row");
@@ -49,7 +146,7 @@ test("throws on a null processKey so the hand-off stays retryable (#941)", async
49
146
  } as any;
50
147
  const submit = (async () => ({ prKey: "acme/w#7", processKey: null })) as unknown as Submit;
51
148
  const err = await assertRejects(
52
- () => makeHandler(submit)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app),
149
+ () => makeHandler(submit, nullHead, openLive)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app),
53
150
  Error,
54
151
  "null processKey",
55
152
  );
@@ -84,7 +181,7 @@ test("hands off at the CURRENT PR head, not the stale convergence checkpoint (#9
84
181
  baseRef: "main",
85
182
  headRepo: "acme/w",
86
183
  })) as unknown as FetchPrHead;
87
- await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
184
+ await makeHandler(submit, head, openLive)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
88
185
  assertEquals(args[11], "b".repeat(40), "the restore override is the live PR head SHA");
89
186
  });
90
187
 
@@ -105,7 +202,7 @@ test("a head read that resolves no SHA degrades to the checkpoint restore (no ov
105
202
  return { prKey: "acme/w#7", processKey: "999" };
106
203
  }) as unknown as Submit;
107
204
  const head = (async () => null) as unknown as FetchPrHead;
108
- await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
205
+ await makeHandler(submit, head, openLive)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
109
206
  assertEquals(args[11], null, "no live head SHA → no override (checkpoint restore, the pre-fix behaviour)");
110
207
  });
111
208
 
@@ -134,7 +231,7 @@ test("preserves the run's custom instructions across the merge→reconvergence h
134
231
  return { prKey: "acme/w#7", processKey: "999" };
135
232
  }) as unknown as Submit;
136
233
  const head = (async () => null) as unknown as FetchPrHead;
137
- await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
234
+ await makeHandler(submit, head, openLive)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
138
235
  assertEquals(args[12], " commit as josh.wulf@camunda.com ", "the durable instruction text rides the handoff into the fresh convergence run");
139
236
  });
140
237
 
@@ -155,6 +252,38 @@ test("a PR row with no stored instructions hands off with null (no steering)", a
155
252
  return { prKey: "acme/w#7", processKey: "999" };
156
253
  }) as unknown as Submit;
157
254
  const head = (async () => null) as unknown as FetchPrHead;
158
- await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
255
+ await makeHandler(submit, head, openLive)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
159
256
  assertEquals(args[12], null, "no stored text → null (the fresh run's prompts stay byte-identical to no-instructions)");
160
257
  });
258
+
259
+ // A PR closed WITHOUT merging before the hand-off must be settled abandoned via the canonical
260
+ // `abandonClosedPr` (row + plan tasks + audit) — never resubmitted into a replacement run.
261
+ test("a closed-unmerged PR is abandoned and no replacement run is submitted", async () => {
262
+ const merges: Record<string, unknown>[] = [];
263
+ const prUpdates: Record<string, unknown>[] = [];
264
+ const app = {
265
+ data: {
266
+ table: (name: string) => ({
267
+ get: async () => (name === "pull_requests" ? { pr_key: "acme/w#7", auto_resolve_escalations: 0 } : undefined),
268
+ find: async () => [],
269
+ findOne: async () => null,
270
+ insert: async (r: Record<string, unknown>) => (name === "merges" && merges.push(r), 1),
271
+ update: async (_k: string, patch: Record<string, unknown>) => (name === "pull_requests" && prUpdates.push(patch), undefined),
272
+ upsert: async () => undefined,
273
+ }),
274
+ },
275
+ engine: {},
276
+ log: noopLog(),
277
+ } as any;
278
+ let submitted = false;
279
+ const submit = (async () => {
280
+ submitted = true;
281
+ return { prKey: "acme/w#7", processKey: "999" };
282
+ }) as unknown as Submit;
283
+ const live: ReadLiveness = async () => "closed";
284
+ const out = await makeHandler(submit, nullHead, live)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
285
+ assertEquals(submitted, false);
286
+ assertEquals(out, {});
287
+ assert(merges.some((m) => m.outcome === "abandoned" && m.method === "pr-closed"), "closed audit row written");
288
+ assert(prUpdates.some((u) => u.status === "abandoned"), "row settled abandoned");
289
+ });
@@ -7,10 +7,16 @@
7
7
  // (`submitPr`), declaring this merge-loop instance as the hand-off source so the live row it owns is
8
8
  // not misread as "already running". The merge loop then ends; the fresh convergence run starts a new
9
9
  // merge loop on convergence, exactly as a first pass does. No parallel review loop is introduced.
10
+ //
11
+ // EXCEPTION (issue #959): if the PR has ALREADY MERGED out-of-band by the time the hand-off fires
12
+ // (a gate override / Mergify / a manual merge landed it), there is nothing to reconverge — the worker
13
+ // stamps the row `merged`, records the already-merged audit, kicks the retrospective, and ends the
14
+ // loop cleanly instead of restarting convergence against a deleted head branch.
10
15
  import type { AppJobHandler } from "@nanobpm/urban";
11
- import { fetchPrHead } from "../../app/github.ts";
16
+ import { classifyPrLiveness, fetchPrHead, fetchPrState } from "../../app/github.ts";
12
17
  import { parsePr } from "../../app/prParse.ts";
13
- import { MAX_ROUNDS, submitPr } from "../../app/service.ts";
18
+ import { maybeStartRetro } from "../../app/retro.ts";
19
+ import { abandonClosedPr, MAX_ROUNDS, submitPr } from "../../app/service.ts";
14
20
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
15
21
 
16
22
  // Input typed off the model data envelope (`ReconvergeIn` in merge-loop.bpmn) — ADR 0040.
@@ -18,8 +24,18 @@ type In = WorkerInputs["pr.reconverge"];
18
24
 
19
25
  export type Submit = typeof submitPr;
20
26
  export type FetchPrHead = typeof fetchPrHead;
27
+ /** The PR-liveness reader used for the already-merged guard. Injectable so tests drive the
28
+ * merged/closed/open matrix without a transport; the default binds the canonical `fetchPrState`. */
29
+ export type ReadLiveness = (repo: string, prNumber: number) => Promise<"open" | "merged" | "closed" | "unknown">;
30
+
31
+ const defaultReadLiveness: ReadLiveness = async (repo, prNumber) =>
32
+ classifyPrLiveness(await fetchPrState(repo, prNumber, process.env.GITHUB_TOKEN ?? ""));
21
33
 
22
- export function makeHandler(submit: Submit, fetchHead: FetchPrHead = fetchPrHead): AppJobHandler<In> {
34
+ export function makeHandler(
35
+ submit: Submit,
36
+ fetchHead: FetchPrHead = fetchPrHead,
37
+ readLiveness: ReadLiveness = defaultReadLiveness,
38
+ ): AppJobHandler<In> {
23
39
  return async (job, app) => {
24
40
  const { prKey } = job.variables;
25
41
  const parsed = parsePr(prKey);
@@ -54,6 +70,40 @@ export function makeHandler(submit: Submit, fetchHead: FetchPrHead = fetchPrHead
54
70
  } catch (err) {
55
71
  app.log.warn("reconverge: head read failed — falling back to the checkpoint restore", { prKey, err: String(err) });
56
72
  }
73
+ // Already-merged guard (issue #959): a reconverge hand-off can fire AFTER the PR landed out-of-band
74
+ // (a gate override / Mergify / a manual merge) — the very race that left nano-bpm#1579's loop dying
75
+ // trying to clone a deleted head branch. Re-entering `submitPr` on a landed PR would restart a
76
+ // redundant convergence loop against a deleted head. Detect the terminal merge first and end the
77
+ // merge loop cleanly instead: stamp the row `merged` (so the merge-aware derivation reports
78
+ // `merged`, not `abandoned`, when this instance then ends), record the already-merged audit row, and
79
+ // kick the retrospective exactly as `pr.mark-merged` would. The merge loop then completes normally
80
+ // at `MergeReconverged` — a COMPLETED instance is NOT the `onTerminated` edge, so the row stays
81
+ // `merged`. Best-effort: an unreadable liveness (`unknown` / a throw) falls through to the normal
82
+ // hand-off rather than blocking a genuine reconverge.
83
+ const liveness = await readLiveness(parsed.repo, parsed.number).catch(() => "unknown" as const);
84
+ if (liveness === "merged") {
85
+ const ts = new Date().toISOString();
86
+ await app.data.table("pull_requests", "pr_key").update(prKey, { status: "merged", merged_at: ts, updated_at: ts });
87
+ await app.data.table("merges", "id").insert({
88
+ pr_key: prKey,
89
+ outcome: "merged",
90
+ method: "already-merged",
91
+ detail: "PR was already merged on GitHub (landed out-of-band) — ending the merge loop instead of reconverging",
92
+ at: ts,
93
+ });
94
+ // The merged-completion side effect `pr.mark-merged` runs: an externally merged PR may be its
95
+ // plan's last to settle, so its automatic retrospective must still start. Best-effort, never throws.
96
+ await maybeStartRetro(app.data, app.engine, prKey);
97
+ return {};
98
+ }
99
+ // A PR verified CLOSED without merging (it closed between the merge worker's findings and this
100
+ // hand-off) has nothing to reconverge either: settle it terminal through the ONE canonical
101
+ // `abandonClosedPr` (row + plan tasks + idempotent audit) and end the loop — never start a
102
+ // replacement convergence run on a closed PR.
103
+ if (liveness === "closed") {
104
+ await abandonClosedPr(app.data, prKey, "PR was closed without merging before the reconverge hand-off");
105
+ return {};
106
+ }
57
107
  // The PR was already merging (merge intent on, any human approval already given), so the fresh
58
108
  // run keeps auto-merge and does not re-gate on approval.
59
109
  const result = await submit(