@nanobpm/nano-workforce 0.101.0 → 0.101.1

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 CHANGED
@@ -1,3 +1,10 @@
1
+ ## [0.101.1](https://github.com/nanobpm/nano-workforce/compare/v0.101.0...v0.101.1) (2026-08-19)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **merge-loop:** abandon a closed-not-merged PR instead of escalating ([#342](https://github.com/nanobpm/nano-workforce/issues/342)) ([#343](https://github.com/nanobpm/nano-workforce/issues/343)) ([10ea6be](https://github.com/nanobpm/nano-workforce/commit/10ea6be0e23c9315dfb71a6c94958826a1795a8c)), closes [#350](https://github.com/nanobpm/nano-workforce/issues/350)
7
+
1
8
  # [0.101.0](https://github.com/nanobpm/nano-workforce/compare/v0.100.0...v0.101.0) (2026-08-19)
2
9
 
3
10
 
@@ -3,7 +3,7 @@
3
3
  // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
4
  import { test } from "node:test";
5
5
  import { assertEquals, assertRejects } from "#test-assert";
6
- import { BaseBranchMustExistError, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError, listPrsForHead } from "./github.ts";
6
+ import { BaseBranchMustExistError, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError, listPrsForHead, type PrState } from "./github.ts";
7
7
 
8
8
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
9
9
  // files (named `f{index}`), setting a `Link: rel="next"` header whenever a later page exists.
@@ -428,3 +428,38 @@ test("ensurePromotionPr: creates when none exists, then reuses on a re-run (idem
428
428
  assertEquals(second?.number, 500);
429
429
  assertEquals(state.creates.length, 1);
430
430
  });
431
+ // The shared PR-liveness gate (#342) maps live GitHub state onto one of {open, merged, closed,
432
+ // unknown} so neither durable loop (merge/convergence) can escalate against a non-open PR. A closed
433
+ // PR is terminal (abandon), a merged PR completes, and a null read stays conservative (unknown →
434
+ // proceed as before). `merged` wins over `state` because a merged PR also reports state="closed".
435
+ function prState(over: Partial<PrState>): PrState {
436
+ return {
437
+ merged: false,
438
+ state: "open",
439
+ mergeStateStatus: "CLEAN",
440
+ failingChecks: 0,
441
+ failingCheckNames: [],
442
+ presentCheckNames: [],
443
+ totalChecks: 0,
444
+ isDraft: false,
445
+ headRefOid: null,
446
+ ...over,
447
+ };
448
+ }
449
+
450
+ test("classifyPrLiveness: an open PR proceeds", () => {
451
+ assertEquals(classifyPrLiveness(prState({ state: "open" })), "open");
452
+ });
453
+
454
+ test("classifyPrLiveness: a merged PR completes (merged wins over a closed state)", () => {
455
+ assertEquals(classifyPrLiveness(prState({ merged: true, state: "closed" })), "merged");
456
+ assertEquals(classifyPrLiveness(prState({ merged: true, state: "merged" })), "merged");
457
+ });
458
+
459
+ test("classifyPrLiveness: a closed-not-merged PR is terminal (abandon)", () => {
460
+ assertEquals(classifyPrLiveness(prState({ merged: false, state: "closed" })), "closed");
461
+ });
462
+
463
+ test("classifyPrLiveness: a null read (transport hiccup) is unknown — never abandons blind", () => {
464
+ assertEquals(classifyPrLiveness(null), "unknown");
465
+ });
package/app/github.ts CHANGED
@@ -484,6 +484,11 @@ export function coalesceTitle(...candidates: (string | null | undefined)[]): str
484
484
  * failing gates (empty in token mode) so the CI-fix agent knows what to make green. */
485
485
  export interface PrState {
486
486
  merged: boolean;
487
+ /** GitHub's high-level PR lifecycle state, normalised to `"open" | "closed" | "merged"`. A PR
488
+ * closed *without* merging reports `"closed"` (GitHub also reports a merged PR as `"closed"` on
489
+ * the REST list, but `merged` disambiguates it). Lets a caller gate on PR liveness — see
490
+ * `classifyPrLiveness` — so neither loop escalates against a non-open PR (#342). */
491
+ state: "open" | "closed" | "merged";
487
492
  mergeStateStatus: string;
488
493
  failingChecks: number;
489
494
  failingCheckNames: string[];
@@ -584,8 +589,10 @@ export async function fetchPrState(
584
589
  };
585
590
  const rollup = j.statusCheckRollup ?? [];
586
591
  const names = failingCheckNames(rollup);
592
+ const merged = j.state === "MERGED" || !!j.mergedAt;
587
593
  return {
588
- merged: j.state === "MERGED" || !!j.mergedAt,
594
+ merged,
595
+ state: merged ? "merged" : (j.state ?? "").toUpperCase() === "CLOSED" ? "closed" : "open",
589
596
  mergeStateStatus: (j.mergeStateStatus || "UNKNOWN").toUpperCase(),
590
597
  failingChecks: names.length,
591
598
  failingCheckNames: names,
@@ -604,14 +611,19 @@ export async function fetchPrState(
604
611
  const j = (await r.json()) as {
605
612
  merged?: boolean;
606
613
  merged_at?: string | null;
614
+ state?: string;
607
615
  mergeable_state?: string;
608
616
  draft?: boolean;
609
617
  head?: { sha?: string | null };
610
618
  };
619
+ const restMerged = !!j.merged || !!j.merged_at;
611
620
  return {
612
621
  // The single-PR GET returns a `merged` boolean (unlike the list endpoint); we also honour
613
622
  // `merged_at` so this mirrors the gh branch's `state === "MERGED" || mergedAt` rule.
614
- merged: !!j.merged || !!j.merged_at,
623
+ merged: restMerged,
624
+ // REST reports a merged PR as `state:"closed"` too, so `merged` disambiguates: a `closed` PR
625
+ // here is genuinely closed WITHOUT merging (e.g. superseded) — the #342 abandon case.
626
+ state: restMerged ? "merged" : (j.state ?? "").toLowerCase() === "closed" ? "closed" : "open",
615
627
  mergeStateStatus: normalizeMergeState(j.mergeable_state ?? "unknown"),
616
628
  failingChecks: -1, // REST here doesn't enumerate checks → classifier treats BLOCKED as "wait"
617
629
  failingCheckNames: [], // …and the CI-fix agent gets no per-check list in token mode
@@ -622,6 +634,27 @@ export async function fetchPrState(
622
634
  };
623
635
  }
624
636
 
637
+ /** Map a PR's live GitHub state to one **liveness** verdict shared by both durable loops (merge +
638
+ * convergence), so neither can ever escalate against a non-open PR (#342):
639
+ *
640
+ * • `open` — proceed with the normal protocol.
641
+ * • `merged` — already landed (out-of-band); complete the loop as merged.
642
+ * • `closed` — closed on GitHub WITHOUT merging (e.g. superseded); the PR can never merge, so
643
+ * the loop must **abandon** (terminate) it — NOT escalate a merge no human can
644
+ * complete. This is terminal state, not a human decision.
645
+ * • `unknown` — a transport hiccup left us without live state (`fetchPrState` returned null);
646
+ * stay conservative and fall through to the normal path rather than abandoning a
647
+ * PR we could not read.
648
+ *
649
+ * Deriving all three from one source keeps a single canonical liveness gate instead of each loop
650
+ * re-implementing `pre?.merged`/closed checks against drifting field names. */
651
+ export function classifyPrLiveness(pre: PrState | null): "open" | "merged" | "closed" | "unknown" {
652
+ if (!pre) return "unknown";
653
+ if (pre.merged) return "merged";
654
+ if (pre.state === "closed") return "closed";
655
+ return "open";
656
+ }
657
+
625
658
  /** The changed file paths of a PR (for the D2 conflict-scan, #58). `gh` returns them directly;
626
659
  * the token transport pages `/pulls/{n}/files` (100/page, capped). Returns `null` when no
627
660
  * transport is usable (idle), an empty array for a PR with no files. */
@@ -11,6 +11,7 @@ import { queuedVerdict } from "./service.ts";
11
11
  function st(over: Partial<PrState>): PrState {
12
12
  return {
13
13
  merged: false,
14
+ state: "open",
14
15
  mergeStateStatus: "CLEAN",
15
16
  failingChecks: 0,
16
17
  failingCheckNames: [],
package/app/service.ts CHANGED
@@ -25,6 +25,7 @@ import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
25
25
  import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
26
26
  import {
27
27
  classifyMergeability,
28
+ classifyPrLiveness,
28
29
  coalesceTitle,
29
30
  ensureFreshHeadRun,
30
31
  ensurePromotionPr,
@@ -925,7 +926,8 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
925
926
  try {
926
927
  const st = await fetchPrState(repo, number, token);
927
928
  if (st === null) continue; // no transport → skip this PR (others may still advance)
928
- if (st.merged) {
929
+ const liveness = classifyPrLiveness(st);
930
+ if (liveness === "merged") {
929
931
  // Landed out-of-band (a maintainer clicked Merge, a mergify queue merged it, etc.). The
930
932
  // instance is parked at `wait-mergeable`, which subscribes to `merge-ready` — NOT
931
933
  // `merge-landed` (that catch, `wait-landed`, only exists later, after we enqueue). Publishing
@@ -941,6 +943,21 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
941
943
  console.log(`[poller] already merged -> ${prKey}`);
942
944
  continue;
943
945
  }
946
+ if (liveness === "closed") {
947
+ // Closed on GitHub WITHOUT merging (e.g. superseded by a newer PR — #350). The PR can never
948
+ // land, so it must NOT be classified as blocked/conflict and escalated (that orphans the
949
+ // process on a dead PR, #342). Route it through the same canonical `merge-ready` → `ready` →
950
+ // `attempt-merge` path as the merged case; the merge worker's closed short-circuit records a
951
+ // terminal `abandoned` audit row and drives the loop down its terminate/abandon end event.
952
+ // One canonical abandon implementation lives in the worker — the poller only routes to it.
953
+ await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
954
+ name: "merge-ready",
955
+ correlationKey: prKey,
956
+ variables: { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
957
+ });
958
+ console.log(`[poller] closed without merging -> ${prKey}`);
959
+ continue;
960
+ }
944
961
  const verdict = classifyMergeability(st);
945
962
  if (verdict === "waiting") {
946
963
  // Frugal-CI remedy (#43): when the repo publishes a merge protocol that wants a fresh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.101.0",
3
+ "version": "0.101.1",
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",
@@ -140,6 +140,7 @@
140
140
  <bpmn:outgoing>f_m_gMerged</bpmn:outgoing>
141
141
  <bpmn:outgoing>f_m_gQueued</bpmn:outgoing>
142
142
  <bpmn:outgoing>f_m_gRetry</bpmn:outgoing>
143
+ <bpmn:outgoing>f_m_gAbandoned</bpmn:outgoing>
143
144
  <bpmn:outgoing>f_m_gBlocked</bpmn:outgoing>
144
145
  </bpmn:exclusiveGateway>
145
146
  <bpmn:exclusiveGateway id="gw-merge-retry" name="retry within budget?" default="f_mr_giveup">
@@ -176,6 +177,10 @@
176
177
  <bpmn:endEvent id="MergeEnd" name="Merged">
177
178
  <bpmn:incoming>f_m_done</bpmn:incoming>
178
179
  </bpmn:endEvent>
180
+ <bpmn:endEvent id="MergeAbandoned" name="Abandoned (PR closed)">
181
+ <bpmn:incoming>f_m_gAbandoned</bpmn:incoming>
182
+ <bpmn:terminateEventDefinition id="ted_merge_abandoned" />
183
+ </bpmn:endEvent>
179
184
  <bpmn:serviceTask id="merge-esc-conflict" name="Escalate: not mergeable">
180
185
  <bpmn:extensionElements>
181
186
  <zeebe:taskDefinition type="pr.persist-escalation" />
@@ -392,6 +397,9 @@
392
397
  <bpmn:sequenceFlow id="f_eg_evicted" sourceRef="eg-landed" targetRef="wait-evicted" />
393
398
  <bpmn:sequenceFlow id="f_m_evicted" sourceRef="wait-evicted" targetRef="arm-merge" />
394
399
  <bpmn:sequenceFlow id="f_m_gBlocked" name="blocked" sourceRef="gw-merge" targetRef="merge-esc-attempt" />
400
+ <bpmn:sequenceFlow id="f_m_gAbandoned" name="abandoned (PR closed)" sourceRef="gw-merge" targetRef="MergeAbandoned">
401
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStatus = "abandoned"</bpmn:conditionExpression>
402
+ </bpmn:sequenceFlow>
395
403
  <bpmn:sequenceFlow id="f_m_landed" sourceRef="wait-landed" targetRef="mark-merged" />
396
404
  <bpmn:sequenceFlow id="f_m_done" sourceRef="mark-merged" targetRef="MergeEnd" />
397
405
  <bpmn:sequenceFlow id="f_m_escC" sourceRef="merge-esc-conflict" targetRef="wait-merge-answer" />
@@ -440,13 +448,13 @@
440
448
  <bpmndi:BPMNShape id="BPMNShape_gw-merge" bpmnElement="gw-merge" isMarkerVisible="true">
441
449
  <dc:Bounds x="1063" y="95" width="50" height="50" />
442
450
  <bpmndi:BPMNLabel>
443
- <dc:Bounds x="1022" y="150" width="53" height="28" />
451
+ <dc:Bounds x="1102" y="150" width="53" height="28" />
444
452
  </bpmndi:BPMNLabel>
445
453
  </bpmndi:BPMNShape>
446
454
  <bpmndi:BPMNShape id="BPMNShape_gw-merge-retry" bpmnElement="gw-merge-retry" isMarkerVisible="true">
447
- <dc:Bounds x="1263" y="1215" width="50" height="50" />
455
+ <dc:Bounds x="1263" y="1375" width="50" height="50" />
448
456
  <bpmndi:BPMNLabel>
449
- <dc:Bounds x="1344" y="1250" width="88" height="28" />
457
+ <dc:Bounds x="1344" y="1410" width="88" height="28" />
450
458
  </bpmndi:BPMNLabel>
451
459
  </bpmndi:BPMNShape>
452
460
  <bpmndi:BPMNShape id="BPMNShape_eg-landed" bpmnElement="eg-landed" isMarkerVisible="true">
@@ -476,37 +484,43 @@
476
484
  <dc:Bounds x="1863" y="143" width="50" height="14" />
477
485
  </bpmndi:BPMNLabel>
478
486
  </bpmndi:BPMNShape>
487
+ <bpmndi:BPMNShape id="BPMNShape_MergeAbandoned" bpmnElement="MergeAbandoned">
488
+ <dc:Bounds x="1270" y="582" width="36" height="36" />
489
+ <bpmndi:BPMNLabel>
490
+ <dc:Bounds x="1227" y="623" width="82" height="28" />
491
+ </bpmndi:BPMNLabel>
492
+ </bpmndi:BPMNShape>
479
493
  <bpmndi:BPMNShape id="BPMNShape_merge-esc-conflict" bpmnElement="merge-esc-conflict">
480
- <dc:Bounds x="1238" y="1680" width="100" height="80" />
494
+ <dc:Bounds x="1238" y="1840" width="100" height="80" />
481
495
  </bpmndi:BPMNShape>
482
496
  <bpmndi:BPMNShape id="BPMNShape_merge-esc-attempt" bpmnElement="merge-esc-attempt">
483
- <dc:Bounds x="1438" y="1200" width="100" height="80" />
497
+ <dc:Bounds x="1438" y="1360" width="100" height="80" />
484
498
  </bpmndi:BPMNShape>
485
499
  <bpmndi:BPMNShape id="BPMNShape_gw-merge-escalated" bpmnElement="gw-merge-escalated" isMarkerVisible="true">
486
- <dc:Bounds x="1663" y="1215" width="50" height="50" />
500
+ <dc:Bounds x="1663" y="1375" width="50" height="50" />
487
501
  <bpmndi:BPMNLabel>
488
- <dc:Bounds x="1651" y="1182" width="74" height="28" />
502
+ <dc:Bounds x="1651" y="1342" width="74" height="28" />
489
503
  </bpmndi:BPMNLabel>
490
504
  </bpmndi:BPMNShape>
491
505
  <bpmndi:BPMNShape id="BPMNShape_wait-merge-answer" bpmnElement="wait-merge-answer">
492
- <dc:Bounds x="1838" y="1200" width="100" height="80" />
506
+ <dc:Bounds x="1838" y="1360" width="100" height="80" />
493
507
  </bpmndi:BPMNShape>
494
508
  <bpmndi:BPMNShape id="BPMNShape_record-merge-answer" bpmnElement="record-merge-answer">
495
- <dc:Bounds x="2038" y="1200" width="100" height="80" />
509
+ <dc:Bounds x="2038" y="1360" width="100" height="80" />
496
510
  </bpmndi:BPMNShape>
497
511
  <bpmndi:BPMNShape id="BPMNShape_gw-ci-fix" bpmnElement="gw-ci-fix" isMarkerVisible="true">
498
- <dc:Bounds x="863" y="1695" width="50" height="50" />
512
+ <dc:Bounds x="863" y="1855" width="50" height="50" />
499
513
  <bpmndi:BPMNLabel>
500
- <dc:Bounds x="769" y="1713" width="89" height="14" />
514
+ <dc:Bounds x="769" y="1873" width="89" height="14" />
501
515
  </bpmndi:BPMNLabel>
502
516
  </bpmndi:BPMNShape>
503
517
  <bpmndi:BPMNShape id="BPMNShape_fix-ci" bpmnElement="fix-ci">
504
- <dc:Bounds x="1038" y="560" width="100" height="80" />
518
+ <dc:Bounds x="1038" y="720" width="100" height="80" />
505
519
  </bpmndi:BPMNShape>
506
520
  <bpmndi:BPMNShape id="BPMNShape_gw-ci-result" bpmnElement="gw-ci-result" isMarkerVisible="true">
507
- <dc:Bounds x="1263" y="575" width="50" height="50" />
521
+ <dc:Bounds x="1263" y="735" width="50" height="50" />
508
522
  <bpmndi:BPMNLabel>
509
- <dc:Bounds x="1225" y="630" width="46" height="14" />
523
+ <dc:Bounds x="1225" y="790" width="46" height="14" />
510
524
  </bpmndi:BPMNLabel>
511
525
  </bpmndi:BPMNShape>
512
526
  <bpmndi:BPMNShape id="BPMNShape_gw-rebase" bpmnElement="gw-rebase" isMarkerVisible="true">
@@ -516,27 +530,27 @@
516
530
  </bpmndi:BPMNLabel>
517
531
  </bpmndi:BPMNShape>
518
532
  <bpmndi:BPMNShape id="BPMNShape_rebase" bpmnElement="rebase">
519
- <dc:Bounds x="1038" y="880" width="100" height="80" />
533
+ <dc:Bounds x="1038" y="1040" width="100" height="80" />
520
534
  </bpmndi:BPMNShape>
521
535
  <bpmndi:BPMNShape id="BPMNShape_gw-rebase-result" bpmnElement="gw-rebase-result" isMarkerVisible="true">
522
- <dc:Bounds x="1263" y="895" width="50" height="50" />
536
+ <dc:Bounds x="1263" y="1055" width="50" height="50" />
523
537
  <bpmndi:BPMNLabel>
524
- <dc:Bounds x="1218" y="950" width="60" height="14" />
538
+ <dc:Bounds x="1218" y="1110" width="60" height="14" />
525
539
  </bpmndi:BPMNLabel>
526
540
  </bpmndi:BPMNShape>
527
541
  <bpmndi:BPMNShape id="BPMNShape_record-merge-dep" bpmnElement="record-merge-dep">
528
- <dc:Bounds x="1438" y="720" width="100" height="80" />
542
+ <dc:Bounds x="1438" y="880" width="100" height="80" />
529
543
  </bpmndi:BPMNShape>
530
544
  <bpmndi:BPMNShape id="BPMNShape_be_fixci_sla" bpmnElement="be_fixci_sla">
531
- <dc:Bounds x="1070" y="622" width="36" height="36" />
545
+ <dc:Bounds x="1070" y="782" width="36" height="36" />
532
546
  <bpmndi:BPMNLabel>
533
- <dc:Bounds x="1046" y="703" width="84" height="14" />
547
+ <dc:Bounds x="1046" y="863" width="84" height="14" />
534
548
  </bpmndi:BPMNLabel>
535
549
  </bpmndi:BPMNShape>
536
550
  <bpmndi:BPMNShape id="BPMNShape_be_rebase_sla" bpmnElement="be_rebase_sla">
537
- <dc:Bounds x="1070" y="942" width="36" height="36" />
551
+ <dc:Bounds x="1070" y="1102" width="36" height="36" />
538
552
  <bpmndi:BPMNLabel>
539
- <dc:Bounds x="1046" y="1023" width="84" height="14" />
553
+ <dc:Bounds x="1046" y="1183" width="84" height="14" />
540
554
  </bpmndi:BPMNLabel>
541
555
  </bpmndi:BPMNShape>
542
556
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_start" bpmnElement="f_m_start">
@@ -578,47 +592,47 @@
578
592
  <di:waypoint x="1870" y="120" />
579
593
  </bpmndi:BPMNEdge>
580
594
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_giveup" bpmnElement="f_ci_giveup">
581
- <di:waypoint x="913" y="1720" />
582
- <di:waypoint x="1238" y="1720" />
595
+ <di:waypoint x="913" y="1880" />
596
+ <di:waypoint x="1238" y="1880" />
583
597
  <bpmndi:BPMNLabel>
584
- <dc:Bounds x="1032" y="1687" width="67" height="28" />
598
+ <dc:Bounds x="1032" y="1847" width="67" height="28" />
585
599
  </bpmndi:BPMNLabel>
586
600
  </bpmndi:BPMNEdge>
587
601
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_reconcile" bpmnElement="f_ci_reconcile">
588
- <di:waypoint x="1288" y="625" />
589
- <di:waypoint x="1288" y="680" />
590
- <di:waypoint x="402" y="680" />
602
+ <di:waypoint x="1288" y="785" />
603
+ <di:waypoint x="1288" y="840" />
604
+ <di:waypoint x="402" y="840" />
591
605
  <di:waypoint x="402" y="160" />
592
606
  <bpmndi:BPMNLabel>
593
- <dc:Bounds x="804" y="619" width="82" height="56" />
607
+ <dc:Bounds x="804" y="779" width="82" height="56" />
594
608
  </bpmndi:BPMNLabel>
595
609
  </bpmndi:BPMNEdge>
596
610
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_giveup" bpmnElement="f_reb_giveup">
597
611
  <di:waypoint x="913" y="280" />
598
612
  <di:waypoint x="933" y="280" />
599
613
  <di:waypoint x="933" y="305" />
600
- <di:waypoint x="1333" y="305" />
601
- <di:waypoint x="1333" y="1660" />
602
- <di:waypoint x="1288" y="1660" />
603
- <di:waypoint x="1288" y="1680" />
614
+ <di:waypoint x="1326" y="305" />
615
+ <di:waypoint x="1326" y="1820" />
616
+ <di:waypoint x="1288" y="1820" />
617
+ <di:waypoint x="1288" y="1840" />
604
618
  <bpmndi:BPMNLabel>
605
- <dc:Bounds x="1338" y="1406" width="67" height="28" />
619
+ <dc:Bounds x="1331" y="1566" width="67" height="28" />
606
620
  </bpmndi:BPMNLabel>
607
621
  </bpmndi:BPMNEdge>
608
622
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_reconcile" bpmnElement="f_reb_reconcile">
609
- <di:waypoint x="1288" y="945" />
610
- <di:waypoint x="1288" y="1000" />
611
- <di:waypoint x="402" y="1000" />
623
+ <di:waypoint x="1288" y="1105" />
624
+ <di:waypoint x="1288" y="1160" />
625
+ <di:waypoint x="402" y="1160" />
612
626
  <di:waypoint x="402" y="160" />
613
627
  <bpmndi:BPMNLabel>
614
- <dc:Bounds x="804" y="939" width="82" height="56" />
628
+ <dc:Bounds x="804" y="1099" width="82" height="56" />
615
629
  </bpmndi:BPMNLabel>
616
630
  </bpmndi:BPMNEdge>
617
631
  <bpmndi:BPMNEdge id="BPMNEdge_f_mr_giveup" bpmnElement="f_mr_giveup">
618
- <di:waypoint x="1313" y="1240" />
619
- <di:waypoint x="1438" y="1240" />
632
+ <di:waypoint x="1313" y="1400" />
633
+ <di:waypoint x="1438" y="1400" />
620
634
  <bpmndi:BPMNLabel>
621
- <dc:Bounds x="1352" y="1307" width="67" height="28" />
635
+ <dc:Bounds x="1332" y="1367" width="67" height="28" />
622
636
  </bpmndi:BPMNLabel>
623
637
  </bpmndi:BPMNEdge>
624
638
  <bpmndi:BPMNEdge id="BPMNEdge_f_eg_landed" bpmnElement="f_eg_landed">
@@ -631,44 +645,44 @@
631
645
  <di:waypoint x="1688" y="160" />
632
646
  </bpmndi:BPMNEdge>
633
647
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escC" bpmnElement="f_m_escC">
634
- <di:waypoint x="1338" y="1720" />
635
- <di:waypoint x="1888" y="1720" />
636
- <di:waypoint x="1888" y="1280" />
648
+ <di:waypoint x="1338" y="1880" />
649
+ <di:waypoint x="1888" y="1880" />
650
+ <di:waypoint x="1888" y="1440" />
637
651
  </bpmndi:BPMNEdge>
638
652
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escA" bpmnElement="f_m_escA">
639
- <di:waypoint x="1538" y="1240" />
640
- <di:waypoint x="1663" y="1240" />
653
+ <di:waypoint x="1538" y="1400" />
654
+ <di:waypoint x="1663" y="1400" />
641
655
  </bpmndi:BPMNEdge>
642
656
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escReenter" bpmnElement="f_m_escReenter">
643
- <di:waypoint x="1688" y="1265" />
644
- <di:waypoint x="1688" y="1300" />
645
- <di:waypoint x="402" y="1300" />
657
+ <di:waypoint x="1688" y="1425" />
658
+ <di:waypoint x="1688" y="1460" />
659
+ <di:waypoint x="402" y="1460" />
646
660
  <di:waypoint x="402" y="160" />
647
661
  <bpmndi:BPMNLabel>
648
- <dc:Bounds x="1008" y="1305" width="74" height="42" />
662
+ <dc:Bounds x="1008" y="1465" width="74" height="42" />
649
663
  </bpmndi:BPMNLabel>
650
664
  </bpmndi:BPMNEdge>
651
665
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_answerRecord" bpmnElement="f_m_answerRecord">
652
- <di:waypoint x="1938" y="1240" />
653
- <di:waypoint x="2038" y="1240" />
666
+ <di:waypoint x="1938" y="1400" />
667
+ <di:waypoint x="2038" y="1400" />
654
668
  </bpmndi:BPMNEdge>
655
669
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_answer" bpmnElement="f_m_answer">
656
- <di:waypoint x="2088" y="1280" />
657
- <di:waypoint x="2088" y="1300" />
658
- <di:waypoint x="402" y="1300" />
670
+ <di:waypoint x="2088" y="1440" />
671
+ <di:waypoint x="2088" y="1460" />
672
+ <di:waypoint x="402" y="1460" />
659
673
  <di:waypoint x="402" y="160" />
660
674
  </bpmndi:BPMNEdge>
661
675
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_mCiFix" bpmnElement="f_m_mCiFix">
662
676
  <di:waypoint x="713" y="145" />
663
677
  <di:waypoint x="713" y="318" />
664
- <di:waypoint x="1358" y="318" />
665
- <di:waypoint x="1358" y="1180" />
666
- <di:waypoint x="863" y="1180" />
667
- <di:waypoint x="863" y="1675" />
668
- <di:waypoint x="888" y="1675" />
669
- <di:waypoint x="888" y="1695" />
678
+ <di:waypoint x="1333" y="318" />
679
+ <di:waypoint x="1333" y="1340" />
680
+ <di:waypoint x="863" y="1340" />
681
+ <di:waypoint x="863" y="1835" />
682
+ <di:waypoint x="888" y="1835" />
683
+ <di:waypoint x="888" y="1855" />
670
684
  <bpmndi:BPMNLabel>
671
- <dc:Bounds x="1011" y="1133" width="60" height="42" />
685
+ <dc:Bounds x="1011" y="1293" width="60" height="42" />
672
686
  </bpmndi:BPMNLabel>
673
687
  </bpmndi:BPMNEdge>
674
688
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_mRebase" bpmnElement="f_m_mRebase">
@@ -682,42 +696,42 @@
682
696
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_mBlocked" bpmnElement="f_m_mBlocked">
683
697
  <di:waypoint x="713" y="145" />
684
698
  <di:waypoint x="713" y="318" />
685
- <di:waypoint x="1333" y="318" />
686
- <di:waypoint x="1333" y="1180" />
687
- <di:waypoint x="1218" y="1180" />
688
- <di:waypoint x="1218" y="1700" />
689
- <di:waypoint x="1238" y="1700" />
699
+ <di:waypoint x="1326" y="318" />
700
+ <di:waypoint x="1326" y="1340" />
701
+ <di:waypoint x="1218" y="1340" />
702
+ <di:waypoint x="1218" y="1860" />
703
+ <di:waypoint x="1238" y="1860" />
690
704
  <bpmndi:BPMNLabel>
691
- <dc:Bounds x="1243" y="722" width="85" height="14" />
705
+ <dc:Bounds x="1236" y="862" width="85" height="14" />
692
706
  </bpmndi:BPMNLabel>
693
707
  </bpmndi:BPMNEdge>
694
708
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_go" bpmnElement="f_ci_go">
695
- <di:waypoint x="888" y="1745" />
696
- <di:waypoint x="888" y="1765" />
697
- <di:waypoint x="2158" y="1765" />
698
- <di:waypoint x="2158" y="555" />
699
- <di:waypoint x="1018" y="555" />
700
- <di:waypoint x="1018" y="580" />
701
- <di:waypoint x="1038" y="580" />
709
+ <di:waypoint x="888" y="1905" />
710
+ <di:waypoint x="888" y="1925" />
711
+ <di:waypoint x="2158" y="1925" />
712
+ <di:waypoint x="2158" y="715" />
713
+ <di:waypoint x="1018" y="715" />
714
+ <di:waypoint x="1018" y="740" />
715
+ <di:waypoint x="1038" y="740" />
702
716
  <bpmndi:BPMNLabel>
703
- <dc:Bounds x="2163" y="1146" width="49" height="28" />
717
+ <dc:Bounds x="2163" y="1306" width="49" height="28" />
704
718
  </bpmndi:BPMNLabel>
705
719
  </bpmndi:BPMNEdge>
706
720
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_blocked" bpmnElement="f_ci_blocked">
707
- <di:waypoint x="1313" y="600" />
708
- <di:waypoint x="1418" y="600" />
709
- <di:waypoint x="1418" y="1240" />
710
- <di:waypoint x="1438" y="1240" />
721
+ <di:waypoint x="1313" y="760" />
722
+ <di:waypoint x="1418" y="760" />
723
+ <di:waypoint x="1418" y="1400" />
724
+ <di:waypoint x="1438" y="1400" />
711
725
  <bpmndi:BPMNLabel>
712
- <dc:Bounds x="1423" y="853" width="89" height="14" />
726
+ <dc:Bounds x="1423" y="1013" width="89" height="14" />
713
727
  </bpmndi:BPMNLabel>
714
728
  </bpmndi:BPMNEdge>
715
729
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_wait" bpmnElement="f_ci_wait">
716
- <di:waypoint x="1313" y="600" />
717
- <di:waypoint x="1488" y="600" />
718
- <di:waypoint x="1488" y="720" />
730
+ <di:waypoint x="1313" y="760" />
731
+ <di:waypoint x="1488" y="760" />
732
+ <di:waypoint x="1488" y="880" />
719
733
  <bpmndi:BPMNLabel>
720
- <dc:Bounds x="1416" y="567" width="75" height="28" />
734
+ <dc:Bounds x="1416" y="727" width="75" height="28" />
721
735
  </bpmndi:BPMNLabel>
722
736
  </bpmndi:BPMNEdge>
723
737
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_go" bpmnElement="f_reb_go">
@@ -725,32 +739,32 @@
725
739
  <di:waypoint x="933" y="280" />
726
740
  <di:waypoint x="933" y="305" />
727
741
  <di:waypoint x="1733" y="305" />
728
- <di:waypoint x="1733" y="1660" />
729
- <di:waypoint x="1243" y="1660" />
730
- <di:waypoint x="1243" y="1195" />
731
- <di:waypoint x="1418" y="1195" />
732
- <di:waypoint x="1418" y="875" />
733
- <di:waypoint x="1018" y="875" />
734
- <di:waypoint x="1018" y="900" />
735
- <di:waypoint x="1038" y="900" />
742
+ <di:waypoint x="1733" y="1820" />
743
+ <di:waypoint x="1250" y="1820" />
744
+ <di:waypoint x="1250" y="1355" />
745
+ <di:waypoint x="1358" y="1355" />
746
+ <di:waypoint x="1358" y="1035" />
747
+ <di:waypoint x="1018" y="1035" />
748
+ <di:waypoint x="1018" y="1060" />
749
+ <di:waypoint x="1038" y="1060" />
736
750
  <bpmndi:BPMNLabel>
737
- <dc:Bounds x="1248" y="1414" width="49" height="28" />
751
+ <dc:Bounds x="1255" y="1574" width="49" height="28" />
738
752
  </bpmndi:BPMNLabel>
739
753
  </bpmndi:BPMNEdge>
740
754
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_blocked" bpmnElement="f_reb_blocked">
741
- <di:waypoint x="1313" y="920" />
742
- <di:waypoint x="1488" y="920" />
743
- <di:waypoint x="1488" y="1200" />
755
+ <di:waypoint x="1313" y="1080" />
756
+ <di:waypoint x="1488" y="1080" />
757
+ <di:waypoint x="1488" y="1360" />
744
758
  <bpmndi:BPMNLabel>
745
- <dc:Bounds x="1429" y="887" width="64" height="28" />
759
+ <dc:Bounds x="1429" y="1047" width="64" height="28" />
746
760
  </bpmndi:BPMNLabel>
747
761
  </bpmndi:BPMNEdge>
748
762
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_wait" bpmnElement="f_reb_wait">
749
- <di:waypoint x="1288" y="895" />
750
- <di:waypoint x="1288" y="760" />
751
- <di:waypoint x="1438" y="760" />
763
+ <di:waypoint x="1288" y="1055" />
764
+ <di:waypoint x="1288" y="920" />
765
+ <di:waypoint x="1438" y="920" />
752
766
  <bpmndi:BPMNLabel>
753
- <dc:Bounds x="1208" y="834" width="75" height="28" />
767
+ <dc:Bounds x="1208" y="994" width="75" height="28" />
754
768
  </bpmndi:BPMNLabel>
755
769
  </bpmndi:BPMNEdge>
756
770
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gQueued" bpmnElement="f_m_gQueued">
@@ -758,7 +772,7 @@
758
772
  <di:waypoint x="1088" y="280" />
759
773
  <di:waypoint x="1263" y="280" />
760
774
  <bpmndi:BPMNLabel>
761
- <dc:Bounds x="1093" y="206" width="46" height="14" />
775
+ <dc:Bounds x="1093" y="216" width="46" height="14" />
762
776
  </bpmndi:BPMNLabel>
763
777
  </bpmndi:BPMNEdge>
764
778
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gRetry" bpmnElement="f_m_gRetry">
@@ -767,10 +781,10 @@
767
781
  <di:waypoint x="1926" y="75" />
768
782
  <di:waypoint x="1926" y="318" />
769
783
  <di:waypoint x="1643" y="318" />
770
- <di:waypoint x="1643" y="1285" />
771
- <di:waypoint x="1263" y="1285" />
772
- <di:waypoint x="1288" y="1285" />
773
- <di:waypoint x="1288" y="1265" />
784
+ <di:waypoint x="1643" y="1445" />
785
+ <di:waypoint x="1263" y="1445" />
786
+ <di:waypoint x="1288" y="1445" />
787
+ <di:waypoint x="1288" y="1425" />
774
788
  <bpmndi:BPMNLabel>
775
789
  <dc:Bounds x="1746" y="285" width="78" height="28" />
776
790
  </bpmndi:BPMNLabel>
@@ -784,80 +798,92 @@
784
798
  <di:waypoint x="1088" y="95" />
785
799
  <di:waypoint x="1088" y="75" />
786
800
  <di:waypoint x="1926" y="75" />
787
- <di:waypoint x="1926" y="820" />
788
- <di:waypoint x="1418" y="820" />
789
- <di:waypoint x="1418" y="1220" />
790
- <di:waypoint x="1438" y="1220" />
801
+ <di:waypoint x="1926" y="980" />
802
+ <di:waypoint x="1418" y="980" />
803
+ <di:waypoint x="1418" y="1380" />
804
+ <di:waypoint x="1438" y="1380" />
805
+ <bpmndi:BPMNLabel>
806
+ <dc:Bounds x="1931" y="642" width="53" height="14" />
807
+ </bpmndi:BPMNLabel>
808
+ </bpmndi:BPMNEdge>
809
+ <bpmndi:BPMNEdge id="BPMNEdge_f_m_gAbandoned" bpmnElement="f_m_gAbandoned">
810
+ <di:waypoint x="1088" y="145" />
811
+ <di:waypoint x="1088" y="165" />
812
+ <di:waypoint x="818" y="165" />
813
+ <di:waypoint x="818" y="145" />
814
+ <di:waypoint x="668" y="145" />
815
+ <di:waypoint x="668" y="600" />
816
+ <di:waypoint x="1270" y="600" />
791
817
  <bpmndi:BPMNLabel>
792
- <dc:Bounds x="1931" y="562" width="53" height="14" />
818
+ <dc:Bounds x="722" y="150" width="82" height="28" />
793
819
  </bpmndi:BPMNLabel>
794
820
  </bpmndi:BPMNEdge>
795
821
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_done" bpmnElement="f_ci_done">
796
- <di:waypoint x="1138" y="600" />
797
- <di:waypoint x="1263" y="600" />
822
+ <di:waypoint x="1138" y="760" />
823
+ <di:waypoint x="1263" y="760" />
798
824
  </bpmndi:BPMNEdge>
799
825
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_done" bpmnElement="f_reb_done">
800
- <di:waypoint x="1138" y="920" />
801
- <di:waypoint x="1263" y="920" />
826
+ <di:waypoint x="1138" y="1080" />
827
+ <di:waypoint x="1263" y="1080" />
802
828
  </bpmndi:BPMNEdge>
803
829
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escWait" bpmnElement="f_m_escWait">
804
- <di:waypoint x="1713" y="1240" />
805
- <di:waypoint x="1838" y="1240" />
830
+ <di:waypoint x="1713" y="1400" />
831
+ <di:waypoint x="1838" y="1400" />
806
832
  </bpmndi:BPMNEdge>
807
833
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_sla" bpmnElement="f_reb_sla">
808
- <di:waypoint x="1088" y="978" />
809
- <di:waypoint x="1088" y="998" />
810
- <di:waypoint x="1218" y="998" />
811
- <di:waypoint x="1218" y="1720" />
812
- <di:waypoint x="1238" y="1720" />
834
+ <di:waypoint x="1088" y="1138" />
835
+ <di:waypoint x="1088" y="1158" />
836
+ <di:waypoint x="1218" y="1158" />
837
+ <di:waypoint x="1218" y="1880" />
838
+ <di:waypoint x="1238" y="1880" />
813
839
  <bpmndi:BPMNLabel>
814
- <dc:Bounds x="1118" y="965" width="70" height="28" />
840
+ <dc:Bounds x="1118" y="1125" width="70" height="28" />
815
841
  </bpmndi:BPMNLabel>
816
842
  </bpmndi:BPMNEdge>
817
843
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_sla" bpmnElement="f_ci_sla">
818
- <di:waypoint x="1088" y="658" />
819
- <di:waypoint x="1088" y="678" />
820
- <di:waypoint x="1018" y="678" />
821
- <di:waypoint x="1018" y="540" />
822
- <di:waypoint x="1558" y="540" />
823
- <di:waypoint x="1558" y="820" />
824
- <di:waypoint x="1526" y="820" />
825
- <di:waypoint x="1526" y="1200" />
844
+ <di:waypoint x="1088" y="818" />
845
+ <di:waypoint x="1088" y="838" />
846
+ <di:waypoint x="1018" y="838" />
847
+ <di:waypoint x="1018" y="700" />
848
+ <di:waypoint x="1558" y="700" />
849
+ <di:waypoint x="1558" y="980" />
850
+ <di:waypoint x="1526" y="980" />
851
+ <di:waypoint x="1526" y="1360" />
826
852
  <bpmndi:BPMNLabel>
827
- <dc:Bounds x="1253" y="507" width="70" height="28" />
853
+ <dc:Bounds x="1233" y="667" width="70" height="28" />
828
854
  </bpmndi:BPMNLabel>
829
855
  </bpmndi:BPMNEdge>
830
856
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_fixed" bpmnElement="f_ci_fixed">
831
- <di:waypoint x="1288" y="625" />
832
- <di:waypoint x="1288" y="680" />
833
- <di:waypoint x="402" y="680" />
857
+ <di:waypoint x="1288" y="785" />
858
+ <di:waypoint x="1288" y="840" />
859
+ <di:waypoint x="402" y="840" />
834
860
  <di:waypoint x="402" y="160" />
835
861
  <bpmndi:BPMNLabel>
836
- <dc:Bounds x="826" y="688" width="39" height="14" />
862
+ <dc:Bounds x="826" y="848" width="39" height="14" />
837
863
  </bpmndi:BPMNLabel>
838
864
  </bpmndi:BPMNEdge>
839
865
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_rebased" bpmnElement="f_reb_rebased">
840
- <di:waypoint x="1288" y="945" />
841
- <di:waypoint x="1288" y="1000" />
842
- <di:waypoint x="402" y="1000" />
866
+ <di:waypoint x="1288" y="1105" />
867
+ <di:waypoint x="1288" y="1160" />
868
+ <di:waypoint x="402" y="1160" />
843
869
  <di:waypoint x="402" y="160" />
844
870
  <bpmndi:BPMNLabel>
845
- <dc:Bounds x="819" y="1008" width="53" height="14" />
871
+ <dc:Bounds x="819" y="1168" width="53" height="14" />
846
872
  </bpmndi:BPMNLabel>
847
873
  </bpmndi:BPMNEdge>
848
874
  <bpmndi:BPMNEdge id="BPMNEdge_f_dep_rewait" bpmnElement="f_dep_rewait">
849
- <di:waypoint x="1488" y="800" />
850
- <di:waypoint x="1488" y="820" />
851
- <di:waypoint x="234" y="820" />
875
+ <di:waypoint x="1488" y="960" />
876
+ <di:waypoint x="1488" y="980" />
877
+ <di:waypoint x="234" y="980" />
852
878
  <di:waypoint x="234" y="138" />
853
879
  </bpmndi:BPMNEdge>
854
880
  <bpmndi:BPMNEdge id="BPMNEdge_f_mr_go" bpmnElement="f_mr_go">
855
- <di:waypoint x="1288" y="1265" />
856
- <di:waypoint x="1288" y="1285" />
857
- <di:waypoint x="402" y="1285" />
881
+ <di:waypoint x="1288" y="1425" />
882
+ <di:waypoint x="1288" y="1445" />
883
+ <di:waypoint x="402" y="1445" />
858
884
  <di:waypoint x="402" y="160" />
859
885
  <bpmndi:BPMNLabel>
860
- <dc:Bounds x="808" y="1252" width="49" height="28" />
886
+ <dc:Bounds x="808" y="1412" width="49" height="28" />
861
887
  </bpmndi:BPMNLabel>
862
888
  </bpmndi:BPMNEdge>
863
889
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_evicted" bpmnElement="f_m_evicted">
@@ -245,3 +245,48 @@ test("pr.merge routes a transient base-moved race through the retry branch (no e
245
245
  },
246
246
  );
247
247
  });
248
+
249
+ test("pr.merge abandons a closed-not-merged PR without escalating (terminal abandon, #342)", async () => {
250
+ // #342/#350: a PR CLOSED on GitHub without merging (e.g. superseded by a newer PR) can never
251
+ // land. The worker must NOT fall through to the land protocol — that returns blocked/conflict and
252
+ // escalates a merge no human can complete, orphaning the process on a dead PR. Instead it records
253
+ // a terminal `abandoned` audit row and returns `mergeStatus:"abandoned"` (the model's
254
+ // terminate/abandon end event), opening NO escalation. Symmetric with the already-merged
255
+ // short-circuit; both are decided from the same single live-state read.
256
+ await withGithub(
257
+ (url) => {
258
+ // Single-PR GET → PR is closed (state="closed") and NOT merged.
259
+ if (/\/pulls\/\d+$/.test(url))
260
+ return new Response(JSON.stringify({ merged: false, state: "closed", mergeable_state: "dirty" }));
261
+ return null; // no AGENTS.md / merge-protocol.json → DEFAULT gh-merge protocol
262
+ },
263
+ async (calls) => {
264
+ const { app, stores } = fakeApp();
265
+ const out = (await handler(
266
+ { variables: { prKey: "acme/widgets#13", repo: "acme/widgets", prNumber: 13 } } as any,
267
+ app,
268
+ )) as Record<string, unknown>;
269
+
270
+ // Abandon short-circuit: loop-terminal `mergeStatus` only, NO escalation payload.
271
+ assertEquals(out, { mergeStatus: "abandoned" });
272
+ assertEquals(out.status, undefined);
273
+ assertEquals(out.question, undefined);
274
+
275
+ // Records exactly one terminal audit row tagged as the closed-PR abandon path.
276
+ assertEquals(stores.merges.length, 1);
277
+ assertEquals(stores.merges[0].outcome, "abandoned");
278
+ assertEquals(stores.merges[0].method, "pr-closed");
279
+
280
+ // Flips the PR row to the terminal `abandoned` status. This path drives a terminate end event
281
+ // that runs NO mark-merged/mark-abandoned worker, so the worker must set the terminal status
282
+ // itself — otherwise the row stays in a non-terminal in-flight status (e.g. `merging`) and is
283
+ // tracked/scanned forever even though the merge loop is done (#342 review).
284
+ const prRow = stores.pull_requests.find((r) => r.pr_key === "acme/widgets#13");
285
+ assertEquals(prRow?.status, "abandoned");
286
+
287
+ // Never attempts a merge PUT or posts an enqueue comment on the dead PR (only the read GET).
288
+ assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
289
+ assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
290
+ },
291
+ );
292
+ });
@@ -12,9 +12,9 @@
12
12
  // shapes the escalation payload on a block.
13
13
  import type { AppJobHandler } from "@nanobpm/urban";
14
14
  import { matchTags, tag } from "@nanobpm/urban/effect";
15
- import { abandonTokenFromUrl } from "../../app/abandon.ts";
15
+ import { ABANDONED_STATUS, abandonTokenFromUrl } from "../../app/abandon.ts";
16
16
  import { checkBaseTarget, classifyBaseGuard } from "../../app/baseGuard.ts";
17
- import { enqueueViaComment, fetchPrState, mergePr } from "../../app/github.ts";
17
+ import { classifyPrLiveness, enqueueViaComment, fetchPrState, mergePr } from "../../app/github.ts";
18
18
  import { classifyMergeLanding, DEFAULT_MERGE_PROTOCOL, loadMergeProtocol } from "../../app/mergeProtocol.ts";
19
19
  import { ensurePr, MERGE_ADMIN, MERGE_METHOD } from "../../app/service.ts";
20
20
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
@@ -23,7 +23,7 @@ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
23
23
  type In = WorkerInputs["pr.merge"];
24
24
 
25
25
  interface Out extends Record<string, unknown> {
26
- mergeStatus: "merged" | "queued" | "blocked" | "retry";
26
+ mergeStatus: "merged" | "queued" | "blocked" | "retry" | "abandoned";
27
27
  status?: string;
28
28
  question?: string;
29
29
  }
@@ -54,7 +54,8 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
54
54
  // FK parent, and BEFORE the base-guard/protocol logic. Best-effort: a transport hiccup falls through
55
55
  // to the normal path rather than blocking a genuine merge.
56
56
  const pre = await fetchPrState(repo, prNumber, token).catch(() => null);
57
- if (pre?.merged) {
57
+ const liveness = classifyPrLiveness(pre);
58
+ if (liveness === "merged") {
58
59
  await app.data.table("merges", "id").insert({
59
60
  pr_key: prKey,
60
61
  outcome: "merged",
@@ -65,6 +66,33 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
65
66
  return { mergeStatus: "merged" };
66
67
  }
67
68
 
69
+ // Closed-not-merged short-circuit (#342). A PR CLOSED on GitHub without merging (e.g. superseded
70
+ // by a newer PR) can never land: falling through to the land protocol would return
71
+ // blocked/conflict and escalate a merge no human can complete, orphaning the process on a dead
72
+ // PR (#350). Instead record a terminal `abandoned` audit row and drive the loop down its
73
+ // terminate/abandon end event — NOT `merge-esc-conflict`. This opens NO escalation and parks NO
74
+ // user task: a closed PR is terminal state, not a human decision. Symmetric with the merged
75
+ // short-circuit above and runs on the same live-state read, so one `fetchPrState` classifies both.
76
+ if (liveness === "closed") {
77
+ await app.data.table("merges", "id").insert({
78
+ pr_key: prKey,
79
+ outcome: "abandoned",
80
+ method: "pr-closed",
81
+ detail: "PR was closed on GitHub without merging (e.g. superseded) — abandoning the merge loop",
82
+ at: now,
83
+ });
84
+ // Terminal status write. This branch drives the model's terminate/abandon end event, which runs
85
+ // NO mark-merged worker (the merged path's `pr.mark-merged` is what sets `status:"merged"`). If
86
+ // we don't flip the row here it lingers on its in-flight status (e.g. the transient `merging`),
87
+ // so `activePrs`/delivery keep treating a dead PR as live. Symmetric with mark-merged's write;
88
+ // `ensurePr` above guarantees the row exists to update.
89
+ await app.data.table("pull_requests", "pr_key").update(prKey, {
90
+ status: ABANDONED_STATUS,
91
+ updated_at: now,
92
+ });
93
+ return { mergeStatus: "abandoned" };
94
+ }
95
+
68
96
  // Dead-end-base guard (#60): never land a PR into a base branch that has itself already merged
69
97
  // to the default branch — the merge would land into a dead branch and never reach `main`.
70
98
  // GitHub only auto-retargets a PR when its base is *deleted* on merge; a merged-but-undeleted