@nanobpm/nano-workforce 0.98.0 → 0.99.0

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,17 @@
1
+ # [0.99.0](https://github.com/nanobpm/nano-workforce/compare/v0.98.1...v0.99.0) (2026-08-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * **review-loop:** re-express the review-ready wait on the ReadinessProbe gate ([#259](https://github.com/nanobpm/nano-workforce/issues/259)) ([#338](https://github.com/nanobpm/nano-workforce/issues/338)) ([4d86509](https://github.com/nanobpm/nano-workforce/commit/4d86509f1f55dd9b31417add323e04da5b574b3d)), closes [#258](https://github.com/nanobpm/nano-workforce/issues/258) [#258](https://github.com/nanobpm/nano-workforce/issues/258)
7
+
8
+ ## [0.98.1](https://github.com/nanobpm/nano-workforce/compare/v0.98.0...v0.98.1) (2026-08-19)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **merge-loop:** transient 'Base branch was modified' merge race → bounded retry, not escalate ([#335](https://github.com/nanobpm/nano-workforce/issues/335)) ([4093d50](https://github.com/nanobpm/nano-workforce/commit/4093d50b0ecd1a226bcfe7a5ecca10ba1db0937a)), closes [#330](https://github.com/nanobpm/nano-workforce/issues/330) [#334](https://github.com/nanobpm/nano-workforce/issues/334)
14
+
1
15
  # [0.98.0](https://github.com/nanobpm/nano-workforce/compare/v0.97.1...v0.98.0) (2026-08-19)
2
16
 
3
17
 
package/SPEC.md CHANGED
@@ -29,7 +29,7 @@ surface are proposed and open for adjustment.
29
29
  submit (form / webhook)
30
30
 
31
31
 
32
- ┌───────────────────┐ review-ready (msg) ┌──────────┐
32
+ ┌───────────────────┐ readiness-ready (msg) ┌──────────┐
33
33
  │ convergence-loop │◀───────────────────────────────│ poller │
34
34
  │ (BPMN) │ └────┬─────┘
35
35
  └─────────┬─────────┘◀───────────────┐ │ polls
@@ -48,8 +48,10 @@ surface are proposed and open for adjustment.
48
48
  agent worker slots and job timeouts are never held hostage to Copilot's reply
49
49
  latency.
50
50
  - **Poller**: an in-app background loop that watches waiting PRs and publishes
51
- the `review-ready` message when a new review lands (no GitHub webhook needed;
52
- works behind NAT).
51
+ the canonical `readiness-ready` wait-gate message (ADR 0001 §2) when a new
52
+ review lands — the "out-of-band poller-correlated shape" the review-ready wait
53
+ is re-expressed on (#259), so there is one "wait for the world" mechanism (no
54
+ GitHub webhook needed; works behind NAT).
53
55
 
54
56
  ## 3. Repository layout
55
57
 
@@ -100,7 +102,7 @@ known at submit time, carried as a process variable and stored on the DB row.
100
102
  │ ├── converged → [Mark converged] → (end: converged)
101
103
  │ │
102
104
  │ ├── addressed → [Record round] → <event-based gateway: review ready or timeout?>
103
- │ │ ├── review-ready (msg catch, key = prKey) → round++ ─────┐
105
+ │ │ ├── readiness-ready (msg catch, key = prKey) → round++ ─┐
104
106
  │ │ └── =reviewWaitTimeout (timer catch) │
105
107
  │ │ → [Escalate: review stalled] (blocked) │
106
108
  │ │ → [Wait: wait-answer userTask] ─────────────────────┤
@@ -127,8 +129,9 @@ Guard: before each Review round, if round > MAX_ROUNDS → force an escalation
127
129
  ```
128
130
 
129
131
  Notes:
130
- - On `addressed`, the loop parks at an **event-based gateway** that races a
131
- `review-ready` message (correlated by the poller when a fresh review lands)
132
+ - On `addressed`, the loop parks at an **event-based gateway** that races the
133
+ canonical `readiness-ready` wait-gate message (ADR 0001 §2; correlated by the
134
+ poller when a fresh review lands)
132
135
  against a `=reviewWaitTimeout` timer (seeded at submit from
133
136
  `NANO_PR_REVIEW_WAIT_TIMEOUT`, default `PT20M`). Whichever fires first
134
137
  withdraws the other — the message arm advances `round`, the timer arm escalates
@@ -136,7 +139,8 @@ Notes:
136
139
  hanging forever. Because `persist-round` already recorded this `round` as
137
140
  `addressed` before the gateway, the timer arm opens the escalation **without
138
141
  re-recording the round** (it passes `recordRound=false`), so a single round is
139
- never logged as both `addressed` and `blocked`. This replaced a bare
142
+ never logged as both `addressed` and `blocked`. This wait is re-expressed on the
143
+ ONE `ReadinessProbe` wait-gate primitive (#258, ADR 0001 §2); it replaced a bare
140
144
  `review-ready` catch that could hang
141
145
  indefinitely: Copilot won't re-review a round with no new commit and routinely
142
146
  dismisses a re-request, so with no timeout a review that never arrives wedged
@@ -210,7 +214,7 @@ Consequences the prompt (`resources/prompts/review-round.md`) encodes:
210
214
  | message | correlationKey | published by | payload |
211
215
  |---|---|---|---|
212
216
  | `pr-submitted` | — (start) | submit route/webhook | `{repo, prNumber, prUrl, prKey}` |
213
- | `review-ready` | `prKey` | **poller** | `{reviewId, reviewState, submittedAt}` |
217
+ | `readiness-ready` | `prKey` | **poller** | `{ready, detail?}` (ADR 0001 §2 wait-gate; the review-ready wait, re-expressed on the ReadinessProbe gate — #259) |
214
218
  | `deps-cleared` | `prKey` | **poller** (merge) | — (all `Depends-on` PRs merged) |
215
219
  | `merge-ready` | `prKey` | **poller** (merge) | `{mergeState}` (`ready` \| `conflict` \| `blocked`); when `blocked`, also `{failingChecks, failingChecksList}` for the `senior:fix-ci` branch |
216
220
  | `merge-landed` | `prKey` | **poller** (merge) | — (queued PR merged, or merged out-of-band) |
@@ -368,8 +372,8 @@ An in-app loop (interval `NANO_PR_POLL_MS`, default 60s):
368
372
  1. `SELECT pr_key, repo, number, waiting_since, last_review_id, last_nudge_at FROM pull_requests WHERE status = 'waiting_review'`.
369
373
  2. For each, GET the PR's reviews from GitHub; find the newest review submitted
370
374
  after `waiting_since` with id > `last_review_id`.
371
- 3. If found → publish `review-ready` (key = `pr_key`, `{reviewId, ...}`) and set
372
- `last_review_id`.
375
+ 3. If found → publish the canonical `readiness-ready` wait-gate message (key =
376
+ `pr_key`, `{ready, detail}`) and set `last_review_id`.
373
377
  4. If **not** found → ensure a review is in flight: unless Copilot is already a
374
378
  pending reviewer, **re-request** it (REST `requested_reviewers`, exact login
375
379
  `copilot-pull-request-reviewer[bot]`) and record `last_nudge_at`. This is
@@ -377,7 +381,7 @@ An in-app loop (interval `NANO_PR_POLL_MS`, default 60s):
377
381
  so a re-request Copilot dismisses is retried without hammering the API. A repo
378
382
  where Copilot isn't an assignable reviewer (HTTP 422) is left to the process's
379
383
  review-wait timer (§4). This closes the stall where Copilot won't spontaneously
380
- re-review and silently dismisses a re-request, so no `review-ready` ever fires.
384
+ re-review and silently dismisses a re-request, so no `readiness-ready` ever fires.
381
385
 
382
386
  Requires a GitHub token (`GITHUB_TOKEN`) or the host `gh` CLI. One cheap API call
383
387
  per waiting PR per interval (plus at most one reviewer-state check + re-request per
@@ -644,7 +648,7 @@ but encode incompatible decisions about a shared contract** — a genuine design
644
648
  `baseRef`) that let large monorepos provision within the clone timeout (#287); the
645
649
  harness is PR-agnostic and provisions from that envelope. The worker stays a pure
646
650
  provisioner.
647
- - **review-ready via GitHub webhook** — same message, swappable faster trigger,
651
+ - **readiness-ready via GitHub webhook** — same `readiness-ready` message, swappable faster trigger,
648
652
  when the app is publicly reachable. Deferred (poller-only for v1).
649
653
  - **Supervised vs external worker** — the agent runs as an external
650
654
  `c8ctl nano work` daemon by default; a supervised in-server mode is possible
package/app/contracts.ts CHANGED
@@ -117,6 +117,13 @@ export const ENV_CONTRACTS = {
117
117
  semantics: "Maximum rebase attempts per PR.",
118
118
  default: "3",
119
119
  },
120
+ NANO_PR_MAX_MERGE_RETRIES: {
121
+ category: "env",
122
+ name: "NANO_PR_MAX_MERGE_RETRIES",
123
+ owner: "app/service.ts",
124
+ semantics: "Maximum transient base/head-moved merge-race retries per PR before escalating.",
125
+ default: "5",
126
+ },
120
127
  NANO_PR_REVIEW_WAIT_TIMEOUT: {
121
128
  category: "env",
122
129
  name: "NANO_PR_REVIEW_WAIT_TIMEOUT",
package/app/github.ts CHANGED
@@ -348,7 +348,7 @@ export async function hasPendingCopilotReviewer(
348
348
  }
349
349
 
350
350
  /** Request a fresh Copilot review on the PR (REST reviewers endpoint, exact `[bot]` login), so
351
- * the process's `review-ready` catch can eventually fire. Returns `"requested"` on success,
351
+ * the process's `readiness-ready` review wait-gate catch can eventually fire. Returns `"requested"` on success,
352
352
  * `"unavailable"` when Copilot is not an assignable reviewer on that repo (HTTP 422 — e.g.
353
353
  * Copilot review not enabled there), or `null` when no transport is usable. Never throws for the
354
354
  * 422 "not assignable" case; only a genuine transport failure propagates. */
@@ -822,14 +822,26 @@ export interface MergeOptions {
822
822
  admin: boolean;
823
823
  }
824
824
  export interface MergeResult {
825
- outcome: "merged" | "queued" | "blocked";
825
+ outcome: "merged" | "queued" | "blocked" | "retry";
826
826
  detail: string;
827
827
  }
828
828
 
829
+ /** GitHub-flagged *retryable* merge races: the base (or head) branch advanced between the
830
+ * mergeability read and the merge mutation, so GitHub aborted the merge with a "… try the merge
831
+ * again" message. These are transient — GitHub itself tells us to just retry — so the merge loop
832
+ * must re-attempt on the settled base, NOT page a human. Matches GitHub's stable message across
833
+ * both the GraphQL `mergePullRequest` error and its HTTP 405 REST variant. Kept narrow — the exact
834
+ * "<Base|Head> branch was modified" phrase — so a genuine block (conflict, failing required check,
835
+ * 403 perms, 422 not-mergeable) is never swallowed as transient. */
836
+ export function isTransientMergeRace(detail: string): boolean {
837
+ return /\b(?:base|head) branch was modified\b/i.test(detail);
838
+ }
839
+
829
840
  /** Attempt to land the PR. Returns `merged` (landed now), `queued` (added to the repo's merge
830
- * queue — the poller then watches for it to land), or `blocked` (GitHub refused a human must
831
- * resolve it, then reply to retry). `null` when no transport is usable. Never throws for a
832
- * refused merge; only a genuine transport failure propagates. */
841
+ * queue — the poller then watches for it to land), `retry` (a transient base/head-moved race
842
+ * GitHub says to re-attempt on the settled base, no human needed), or `blocked` (GitHub refused
843
+ * a human must resolve it, then reply to retry). `null` when no transport is usable. Never throws
844
+ * for a refused merge; only a genuine transport failure propagates. */
833
845
  export async function mergePr(
834
846
  repo: string,
835
847
  number: number | string,
@@ -850,6 +862,9 @@ export async function mergePr(
850
862
  // A merge-queue-required branch surfaces as an error on older gh; treat as queued when the
851
863
  // message says so, otherwise it is a genuine block (conflict, failing gate, perms).
852
864
  if (/added to the merge queue|enqueued/i.test(msg)) return { outcome: "queued", detail: msg };
865
+ // A base/head-moved race is transient (GitHub says to retry) — re-enter the merge loop
866
+ // rather than escalate. Checked before the catch-all block so it is never swallowed as blocked.
867
+ if (isTransientMergeRace(msg)) return { outcome: "retry", detail: msg };
853
868
  return { outcome: "blocked", detail: msg };
854
869
  }
855
870
  }
@@ -877,6 +892,9 @@ export async function mergePr(
877
892
  return { outcome: "queued", detail: "merge accepted; PR not yet landed (awaiting merge queue)" };
878
893
  }
879
894
  const detail = `github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim();
895
+ // The REST merge endpoint returns 405 "Base branch was modified. Review and try the merge again."
896
+ // for the same transient race — classify it as retry, not a human-actionable block.
897
+ if (isTransientMergeRace(detail)) return { outcome: "retry", detail };
880
898
  return { outcome: "blocked", detail };
881
899
  }
882
900
 
@@ -103,6 +103,23 @@ test("the question distinguishes all four blocked/SLA triggers rather than a sin
103
103
  assertStringIncludes(el, 'agentVerdict = "blocked"', "must branch CI could-not-fix vs SLA on the agent verdict binding, not the overwritten status");
104
104
  });
105
105
 
106
+ test("retry-budget-exhausted escalation reads as a repeated race, not a generic merge refusal", () => {
107
+ // `f_mr_giveup` (transient merge-retry budget exhausted) routes into merge-esc-attempt with
108
+ // mergeState = "ready" AND mergeStatus = "retry". Without a dedicated branch this reused the
109
+ // generic gate-blocked ("Investigate why GitHub refused the merge") text, which is misleading for
110
+ // a repeated base/head-moved race whose retry budget simply ran out. The question must branch on
111
+ // mergeStatus = "retry" — ahead of the generic `mergeState = "ready"` arm — and name the budget.
112
+ assert(escAttempt, "merge-esc-attempt service task must exist");
113
+ const el = escAttempt![0];
114
+ assertStringIncludes(el, 'mergeStatus = "retry"', "must branch the retry-budget-exhausted escalation on mergeStatus = retry");
115
+ assertStringIncludes(el, "mergeRetryMax", "the retry-exhausted question must surface the retry budget");
116
+ // The retry branch must precede the generic `mergeState = "ready"` branch, or the generic arm
117
+ // (also true here) would shadow it and re-emit the misleading refusal text.
118
+ const retryIdx = el.indexOf('mergeStatus = "retry"');
119
+ const readyIdx = el.indexOf('mergeState = "ready"');
120
+ assert(retryIdx !== -1 && readyIdx !== -1 && retryIdx < readyIdx, "the retry branch must be evaluated before the generic ready branch");
121
+ });
122
+
106
123
  test("a gw-merge-escalated guard honours persist-escalation's escalated:false (mirrors the convergence loop)", () => {
107
124
  // The escalation output no longer flows UNCONDITIONALLY into the durable answer wait: it passes
108
125
  // through a gateway that reads the worker's `escalated` output.
@@ -0,0 +1,90 @@
1
+ // Unit tests for the transient merge-race classification (issue #334).
2
+ //
3
+ // #334: a GitHub-flagged *retryable* merge race — the base (or head) branch advanced between the
4
+ // mergeability read and the merge mutation, so GitHub aborted with "… try the merge again" — was
5
+ // misclassified by `mergePr`'s catch-all as `blocked`, producing a misleading human escalation on
6
+ // a PR that was actually mergeable once the base settled. The fix classifies the stable
7
+ // base/head-moved messages as a new `retry` outcome (the merge loop re-attempts on the settled
8
+ // base), while every genuine block (conflict / failing check / 403 perms / 422 not-mergeable)
9
+ // stays `blocked`. These tests pin that split so a real block is never swallowed as transient.
10
+ import { test } from "node:test";
11
+ import { assertEquals } from "#test-assert";
12
+ import { isTransientMergeRace, mergePr } from "./github.ts";
13
+
14
+ test("isTransientMergeRace: the base/head-moved races are transient", () => {
15
+ // GraphQL mergePullRequest error (observed live on nano-workforce #330).
16
+ assertEquals(
17
+ isTransientMergeRace(
18
+ "GraphQL: Base branch was modified. Review and try the merge again. (mergePullRequest)",
19
+ ),
20
+ true,
21
+ );
22
+ // The head-branch-moved sibling.
23
+ assertEquals(
24
+ isTransientMergeRace("Head branch was modified. Review and try the merge again."),
25
+ true,
26
+ );
27
+ // The HTTP 405 REST variant.
28
+ assertEquals(
29
+ isTransientMergeRace(
30
+ "github 405 Method Not Allowed: Base branch was modified. Review and try the merge again.",
31
+ ),
32
+ true,
33
+ );
34
+ });
35
+
36
+ test("isTransientMergeRace: genuine blocks are NOT transient (never swallowed)", () => {
37
+ assertEquals(isTransientMergeRace("Pull Request is not mergeable"), false); // conflict / failing check
38
+ assertEquals(isTransientMergeRace("github 403 Forbidden: Resource not accessible"), false); // perms
39
+ assertEquals(
40
+ isTransientMergeRace("github 422 Unprocessable Entity: Required status check is expected"),
41
+ false,
42
+ ); // not-mergeable gate
43
+ assertEquals(isTransientMergeRace("Merge conflict; base branch has conflicts"), false); // a base conflict is a real block
44
+ });
45
+
46
+ // Drive `mergePr`'s REST (token) transport: the 405 base-moved race must surface as `retry`, while
47
+ // a genuine 405 refusal stays `blocked`.
48
+ async function withMergePut(
49
+ status: number,
50
+ statusText: string,
51
+ body: string,
52
+ run: () => Promise<void>,
53
+ ): Promise<void> {
54
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
55
+ const prevFetch = globalThis.fetch;
56
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
57
+ globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
58
+ const url = String(input);
59
+ if (/\/pulls\/\d+\/merge$/.test(url) && (init?.method ?? "").toUpperCase() === "PUT") {
60
+ return Promise.resolve(new Response(body, { status, statusText }));
61
+ }
62
+ return Promise.resolve(new Response("not found", { status: 404 }));
63
+ }) as typeof fetch;
64
+ try {
65
+ await run();
66
+ } finally {
67
+ globalThis.fetch = prevFetch;
68
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
69
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
70
+ }
71
+ }
72
+
73
+ test("mergePr: a 405 base-moved race → outcome 'retry'", async () => {
74
+ await withMergePut(
75
+ 405,
76
+ "Method Not Allowed",
77
+ "Base branch was modified. Review and try the merge again.",
78
+ async () => {
79
+ const res = await mergePr("acme/widgets", 42, "test-token", { method: "squash", admin: false });
80
+ assertEquals(res?.outcome, "retry");
81
+ },
82
+ );
83
+ });
84
+
85
+ test("mergePr: a genuine 405 refusal → outcome 'blocked' (not swallowed as transient)", async () => {
86
+ await withMergePut(405, "Method Not Allowed", "Pull Request is not mergeable", async () => {
87
+ const res = await mergePr("acme/widgets", 42, "test-token", { method: "squash", admin: false });
88
+ assertEquals(res?.outcome, "blocked");
89
+ });
90
+ });
@@ -0,0 +1,100 @@
1
+ // Structural regression guard for the merge-loop transient-retry arm (issue #334).
2
+ //
3
+ // #334: a transient, GitHub-flagged-retryable merge race ("Base branch was modified. Review and
4
+ // try the merge again.") was mapped to `blocked` → a decision-required human escalation on a PR
5
+ // that was actually mergeable once the base settled. The fix adds a `retry` merge outcome that
6
+ // re-enters the merge loop on the settled base through a *bounded* budget gate — mirroring the
7
+ // `gw-ci-fix` within-budget / budget-exhausted pattern — WITHOUT any remediation agent, and
8
+ // escalates via the existing `merge-esc-attempt` only when the retry budget is exhausted (so a
9
+ // continuously-moving base still escalates promptly rather than spinning forever).
10
+ //
11
+ // This test asserts the arm's topology on the committed model so it cannot regress silently. It is
12
+ // a pure text assertion over the BPMN (no engine), matching the repo's lightweight model-guard style.
13
+
14
+ import { test } from "node:test";
15
+ import { assert, assertStringIncludes } from "#test-assert";
16
+ import { readFileSync } from "node:fs";
17
+
18
+ const bpmn = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
19
+ const flat = bpmn.replace(/\s+/g, " ");
20
+
21
+ function hasFlow(source: string, target: string): boolean {
22
+ const re = new RegExp(
23
+ `<bpmn:sequenceFlow\\b[^>]*\\bsourceRef="${source}"[^>]*\\btargetRef="${target}"|` +
24
+ `<bpmn:sequenceFlow\\b[^>]*\\btargetRef="${target}"[^>]*\\bsourceRef="${source}"`,
25
+ );
26
+ return re.test(flat);
27
+ }
28
+
29
+ function flowHasId(id: string, source: string, target: string): boolean {
30
+ const m = flat.match(new RegExp(`<bpmn:sequenceFlow\\b[^>]*\\bid="${id}"[^>]*/?>`));
31
+ if (!m) return false;
32
+ const tag = m[0];
33
+ return tag.includes(`sourceRef="${source}"`) && tag.includes(`targetRef="${target}"`);
34
+ }
35
+
36
+ function gatewayDefault(id: string, def: string): boolean {
37
+ const m = flat.match(new RegExp(`<bpmn:exclusiveGateway\\b[^>]*\\bid="${id}"[^>]*>`));
38
+ if (!m) return false;
39
+ return m[0].includes(`default="${def}"`);
40
+ }
41
+
42
+ // Assert the conditionExpression *of a specific sequenceFlow* contains `needle`, so the guard
43
+ // cannot be satisfied by the same substring appearing on an unrelated flow (e.g. the
44
+ // merge-esc-attempt question FEEL also mentions `mergeStatus = "retry"`).
45
+ function flowHasCondition(id: string, needle: string): boolean {
46
+ const m = flat.match(
47
+ new RegExp(`<bpmn:sequenceFlow\\b[^>]*\\bid="${id}"[^>]*>(.*?)</bpmn:sequenceFlow>`),
48
+ );
49
+ if (!m) return false;
50
+ const cond = m[1].match(/<bpmn:conditionExpression\b[^>]*>(.*?)<\/bpmn:conditionExpression>/);
51
+ return cond ? cond[1].includes(needle) : false;
52
+ }
53
+
54
+ test("gw-merge routes the retry outcome to a dedicated budget gate, not to a human", () => {
55
+ // A `retry` merge result must reach the retry-budget gate…
56
+ assert(hasFlow("gw-merge", "gw-merge-retry"), "gw-merge → gw-merge-retry (retry) missing");
57
+ // …guarded by the exact retry condition (mergeStatus = "retry").
58
+ assert(flowHasId("f_m_gRetry", "gw-merge", "gw-merge-retry"), "f_m_gRetry must be gw-merge → gw-merge-retry");
59
+ // Assert the retry condition on f_m_gRetry ITSELF — not merely anywhere in the model — so the
60
+ // guard cannot be satisfied by the identical substring in the merge-esc-attempt question FEEL.
61
+ assert(
62
+ flowHasCondition("f_m_gRetry", 'mergeStatus = "retry"'),
63
+ 'f_m_gRetry conditionExpression must be mergeStatus = "retry"',
64
+ );
65
+ });
66
+
67
+ test("retry arm mirrors the fix-ci arm: budget gate → re-arm, exhausted → escalate", () => {
68
+ // Within budget → re-arm the merge poller (re-attempt on the settled base). No remediation agent.
69
+ assert(hasFlow("gw-merge-retry", "arm-merge"), "gw-merge-retry → arm-merge (within budget) missing");
70
+ assert(flowHasId("f_mr_go", "gw-merge-retry", "arm-merge"), "f_mr_go must be gw-merge-retry → arm-merge");
71
+ assertStringIncludes(flat, "mergeRetryRound &lt;= mergeRetryMax");
72
+
73
+ // Budget exhausted → the EXISTING human escalation (merge-esc-attempt), and it is the gateway default
74
+ // so a continuously-moving base can never spin past the cap.
75
+ assert(
76
+ hasFlow("gw-merge-retry", "merge-esc-attempt"),
77
+ "gw-merge-retry → merge-esc-attempt (budget exhausted) missing",
78
+ );
79
+ assert(gatewayDefault("gw-merge-retry", "f_mr_giveup"), "gw-merge-retry must default to f_mr_giveup");
80
+ assert(
81
+ flowHasId("f_mr_giveup", "gw-merge-retry", "merge-esc-attempt"),
82
+ "f_mr_giveup must default gw-merge-retry → merge-esc-attempt",
83
+ );
84
+ });
85
+
86
+ test("the retry arm advances the attempt counter only on a transient retry outcome", () => {
87
+ // The counter advances ONLY when the merge attempt returned `retry` — mirroring how fix-ci/rebase
88
+ // advance their own rounds only on their own remediation — so unrelated merge attempts (initial,
89
+ // post-rebase, post-fix-ci, post-evict) can't consume the transient-retry budget. N consecutive
90
+ // transient races then trip the `mergeRetryRound <= mergeRetryMax` gate at exactly the cap.
91
+ assertStringIncludes(flat, "=if mergeStatus = &#34;retry&#34; then mergeRetryRound + 1 else mergeRetryRound");
92
+ });
93
+
94
+ test("the retry arm has NO remediation agent (contrast the conflict/rebase arm)", () => {
95
+ // The base-moved race needs only a re-attempt on the settled base — no rebase/CI-fix agent. The
96
+ // within-budget flow goes straight back to arm-merge, never through a `senior:*` task.
97
+ assert(flowHasId("f_mr_go", "gw-merge-retry", "arm-merge"), "retry within-budget must go directly to arm-merge");
98
+ // Sanity: the untouched blocked path still escalates directly (a genuine refusal is unchanged).
99
+ assert(flowHasId("f_m_gBlocked", "gw-merge", "merge-esc-attempt"), "blocked → merge-esc-attempt must remain");
100
+ });
package/app/readiness.ts CHANGED
@@ -150,6 +150,14 @@ export const DEFAULT_ATTEMPT_TIMEOUT_MS = 60_000;
150
150
  /** Default gate timeout when neither the descriptor nor `NANO_READINESS_POLL_TIMEOUT` supplies one. */
151
151
  export const DEFAULT_READINESS_TIMEOUT = "PT30M";
152
152
 
153
+ /** The ONE canonical readiness-signal message the wait-gate correlates on to release the wait
154
+ * (ADR 0001 §2) — the correlation key is per-caller (`=gateKey` for the in-flow probe worker,
155
+ * `=prKey` for the review-ready wait), not a single fixed key. Both the in-flow probe worker
156
+ * (`pr.readiness-probe`) and the out-of-band
157
+ * poller-correlated shape used by the review-ready migration (#259, per #258 pinned decision 3)
158
+ * publish under this ONE name — so there is a single "wait for the world" message, no bespoke twin. */
159
+ export const READINESS_READY_MESSAGE = "readiness-ready";
160
+
153
161
  const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
154
162
  const num = (v: unknown): number | undefined =>
155
163
  typeof v === "number" && Number.isFinite(v) ? v : undefined;
@@ -0,0 +1,92 @@
1
+ // Structural guard for the review-ready wait re-expressed on the canonical ReadinessProbe
2
+ // wait-gate contract (ADR 0001 §2, issue #259).
3
+ //
4
+ // #258 landed the generic durable wait-gate: an event-based gateway racing a canonical
5
+ // `readiness-ready` signal against a bounded timer that escalates. #259 collapses nwf's bespoke
6
+ // review-ready poll path onto that ONE primitive — so there is a single "wait for the world"
7
+ // message, no drift-prone twin. The out-of-band poller-correlated shape (#258 pinned decision 3)
8
+ // keeps the canonical self-scheduling poller (`pollReviews` in app/service.ts) publishing the
9
+ // signal when a fresh review lands, while the convergence-loop parks on the canonical gate.
10
+ //
11
+ // These pure text assertions over the committed BPMN lock the migrated wait: it now catches the
12
+ // canonical `readiness-ready` message (the bespoke `review-ready` message is GONE), and it stays
13
+ // BOUNDED — the event-based gateway still races the signal against the `reviewWaitTimeout` timer,
14
+ // whose arm escalates to a human. Matches the repo's model-guard style (see roundResultDefault.test.ts).
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import { test } from "node:test";
18
+ import { assert, assertStringIncludes } from "#test-assert";
19
+ import { READINESS_READY_MESSAGE } from "./readiness.ts";
20
+
21
+ const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
22
+ const flat = bpmn.replace(/\s+/g, " ");
23
+
24
+ test("the review wait catches the canonical readiness-ready message, not a bespoke twin", () => {
25
+ // The one canonical wait-gate signal (#258), re-used here — no separate `review-ready` message.
26
+ const msg = flat.match(/<bpmn:message\b[^>]*\bname="readiness-ready"[\s\S]*?<\/bpmn:message>/);
27
+ assert(msg, "convergence-loop must declare the canonical readiness-ready message");
28
+ assertStringIncludes(msg![0], 'correlationKey="=prKey"', "correlation shape is preserved (=prKey)");
29
+
30
+ // The bespoke review-ready message/shape must be fully retired (one mechanism, no drift).
31
+ assert(!/name="review-ready"/.test(flat), "the bespoke review-ready message must be gone");
32
+ assert(!/id="Message_reviewReady"/.test(flat), "the bespoke Message_reviewReady must be gone");
33
+ assert(!/id="ReviewReady"/.test(flat), "the bespoke ReviewReady payload shape must be gone");
34
+
35
+ // And the constant the poller publishes under is the very same canonical name.
36
+ assert(new RegExp(`name="${READINESS_READY_MESSAGE}"`).test(flat), "message name matches the canonical constant");
37
+ });
38
+
39
+ test("the review-ready catch subscribes to the canonical readiness-ready message", () => {
40
+ const wait = flat.match(/<bpmn:intermediateCatchEvent\b[^>]*\bid="wait-review"[\s\S]*?<\/bpmn:intermediateCatchEvent>/);
41
+ assert(wait, "the wait-review catch event must exist");
42
+ assertStringIncludes(
43
+ wait![0],
44
+ 'messageRef="Message_readinessReady"',
45
+ "wait-review must catch the canonical readiness-ready message",
46
+ );
47
+ // The round increment (forward progress) is preserved on the same catch.
48
+ assertStringIncludes(wait![0], 'target="round"', "wait-review still advances the round on a fresh signal");
49
+ });
50
+
51
+ test("the migrated wait stays bounded: an event-based gateway races the signal against a timer", () => {
52
+ // The gate shape from #258: an event-based gateway forks to the signal catch and a timer catch.
53
+ const gw = flat.match(/<bpmn:eventBasedGateway\b[^>]*\bid="gw-review-wait"[^>]*>/);
54
+ assert(gw, "the review wait must be an event-based gateway race (bounded gate)");
55
+
56
+ const timeout = flat.match(
57
+ /<bpmn:intermediateCatchEvent\b[^>]*\bid="wait-review-timeout"[\s\S]*?<\/bpmn:intermediateCatchEvent>/,
58
+ );
59
+ assert(timeout, "the bounded timer arm (wait-review-timeout) must exist");
60
+ assertStringIncludes(timeout![0], "<bpmn:timerEventDefinition", "the timer arm bounds the wait");
61
+ assertStringIncludes(timeout![0], "=reviewWaitTimeout", "the timer is seeded with the review-wait timeout");
62
+
63
+ // The gateway forks to BOTH the signal catch and the timer catch (the race).
64
+ assert(
65
+ /sourceRef="gw-review-wait"[^>]*targetRef="wait-review"|targetRef="wait-review"[^>]*sourceRef="gw-review-wait"/.test(
66
+ flat,
67
+ ),
68
+ "the gateway races toward the readiness signal catch",
69
+ );
70
+ assert(
71
+ /sourceRef="gw-review-wait"[^>]*targetRef="wait-review-timeout"|targetRef="wait-review-timeout"[^>]*sourceRef="gw-review-wait"/.test(
72
+ flat,
73
+ ),
74
+ "the gateway races toward the bounded timer catch",
75
+ );
76
+ });
77
+
78
+ test("the timeout arm escalates to a human (the wait cannot hang forever)", () => {
79
+ // timer catch → persist-review-stalled (records the escalation) → wait-answer (native userTask).
80
+ assert(
81
+ /sourceRef="wait-review-timeout"[^>]*targetRef="persist-review-stalled"|targetRef="persist-review-stalled"[^>]*sourceRef="wait-review-timeout"/.test(
82
+ flat,
83
+ ),
84
+ "a timed-out review wait routes to the stalled-review escalation",
85
+ );
86
+ assert(
87
+ /sourceRef="persist-review-stalled"[^>]*targetRef="wait-answer"|targetRef="wait-answer"[^>]*sourceRef="persist-review-stalled"/.test(
88
+ flat,
89
+ ),
90
+ "the stalled-review escalation parks on the human answer userTask",
91
+ );
92
+ });
package/app/reviewWait.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // Two knobs govern the review-wait watchdog:
7
7
  // • the *timeout* — an ISO-8601 duration handed to the process's `wait-review-timeout` timer
8
- // catch (the far side of the event-based-gateway race against `review-ready`). If no fresh
8
+ // catch (the far side of the event-based-gateway race against `readiness-ready`). If no fresh
9
9
  // review arrives within it, the loop escalates to a human instead of hanging forever.
10
10
  // • the *nudge cooldown* — how long the poller waits between automatic Copilot re-requests for
11
11
  // one waiting PR, so a re-request that Copilot dismisses is retried without hammering the API.
package/app/service.ts CHANGED
@@ -54,7 +54,14 @@ import {
54
54
  planTasks,
55
55
  } from "./plan.ts";
56
56
  import { derivePromotionState, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
57
- import { defaultProbeExec, type ProbeExec, probeOnce, type ReadinessProbe, readinessTimeout } from "./readiness.ts";
57
+ import {
58
+ defaultProbeExec,
59
+ type ProbeExec,
60
+ probeOnce,
61
+ READINESS_READY_MESSAGE,
62
+ type ReadinessProbe,
63
+ readinessTimeout,
64
+ } from "./readiness.ts";
58
65
  import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
59
66
  import { trialMergeAudits } from "./trialMerge.ts";
60
67
  import {
@@ -104,6 +111,14 @@ export const MAX_CI_FIX_ROUNDS = clampCiFixBudget(process.env.NANO_PR_MAX_CI_FIX
104
111
  * immediately). Reuses the CI-fix budget clamp (allows 0 = disable, ceiling-capped). */
105
112
  export const MAX_REBASE_ROUNDS = clampCiFixBudget(process.env.NANO_PR_MAX_REBASE_ROUNDS, 3);
106
113
 
114
+ /** How many times the merge stage will re-attempt a merge that GitHub aborted with a *transient*
115
+ * base/head-moved race (see `isTransientMergeRace`) before giving up and escalating to a human.
116
+ * The re-attempt runs on the settled base (no remediation agent); the cap keeps a continuously
117
+ * moving base — or a persistently-failing merge — from spinning forever, escalating within
118
+ * seconds-to-minutes instead. Default 5; set `NANO_PR_MAX_MERGE_RETRIES=0` to disable transient
119
+ * retry (a race escalates immediately). Reuses the CI-fix budget clamp (allows 0 = disable). */
120
+ export const MAX_MERGE_RETRIES = clampCiFixBudget(process.env.NANO_PR_MAX_MERGE_RETRIES, 5);
121
+
107
122
  /** How long a merge-loop AGENT service task (rebase / fix-ci) may sit without completing before its
108
123
  * interrupting timer boundary fires and the PR escalates for human attention. Seeded as the
109
124
  * `agentSlaTimeout` process variable at merge start and evaluated by those tasks' boundary timers.
@@ -577,6 +592,8 @@ export async function startMerge(
577
592
  ciFixMax: MAX_CI_FIX_ROUNDS,
578
593
  rebaseRound: 0,
579
594
  rebaseMax: MAX_REBASE_ROUNDS,
595
+ mergeRetryRound: 0,
596
+ mergeRetryMax: MAX_MERGE_RETRIES,
580
597
  agentSlaTimeout: AGENT_SLA_TIMEOUT,
581
598
  // Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
582
599
  rootRequestKey,
@@ -658,7 +675,17 @@ export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
658
675
 
659
676
  /** One review-ready poll pass (SPEC §10): for every PR waiting on a review, fetch its GitHub
660
677
  * reviews (via the host `gh` CLI or a token — see `app/github.ts`) and, on a fresh one,
661
- * correlate a `review-ready` message to resume the loop. */
678
+ * correlate the canonical `readiness-ready` message to resume the loop.
679
+ *
680
+ * This is the review-ready wait re-expressed on the ONE `ReadinessProbe` wait-gate contract
681
+ * (ADR 0001 §2, issue #259): the convergence-loop parks on the canonical `readiness-ready` gate
682
+ * (event-based gateway racing the signal against the bounded `reviewWaitTimeout` timer), and this
683
+ * canonical self-scheduling poller publishes that signal out-of-band when a fresh review lands —
684
+ * the "out-of-band poller-correlated shape" #258 pinned decision 3 reserved for exactly this
685
+ * migration. There is no bespoke `review-ready` message any more: one mechanism, no drift. The
686
+ * fresh-review detection + Copilot nudge below stay here because review freshness is inherently
687
+ * STATEFUL (keyed off `last_review_id`/`waiting_since`), which the stateless probe matchers cannot
688
+ * subsume — the poller owns the forward-progress guarantee, the gate owns the bounded wait. */
662
689
  async function pollReviews(data: DataLayer, engine: EngineClient, token: string) {
663
690
  const waiting = await prs(data).find({ status: "waiting_review" });
664
691
  for (const pr of waiting) {
@@ -682,9 +709,12 @@ async function pollReviews(data: DataLayer, engine: EngineClient, token: string)
682
709
  }
683
710
  await prs(data).update(prKey, { last_review_id: fresh.id, status: "converging", updated_at: now() });
684
711
  await engine.publishMessage({
685
- name: "review-ready",
712
+ name: READINESS_READY_MESSAGE,
686
713
  correlationKey: prKey,
687
- variables: { reviewId: fresh.id, reviewState: fresh.state, submittedAt: fresh.submitted_at },
714
+ // The canonical readiness-signal payload (`ReadinessReady` shape): the gate only needs to
715
+ // know the probe went green. The fresh review's id/state is bookkept above on the PR row,
716
+ // not carried on the message — the convergence-loop consumes only the round increment.
717
+ variables: { ready: true, detail: `review ${fresh.id} (${fresh.state})` },
688
718
  });
689
719
  console.log(`[poller] review ${fresh.id} (${fresh.state}) -> ${prKey}`);
690
720
  } catch (err) {
package/main.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  // control endpoints plus the webhook operations under `/app/api/hooks/*` (ADR 0059).
10
10
  //
11
11
  // The only thing that isn't declarative is the review-ready poller: it does arbitrary GitHub
12
- // polling and then correlates a `review-ready` message. A cron trigger can only fire an engine
12
+ // polling and then correlates the canonical `readiness-ready` wait-gate message (#259). A cron
13
+ // trigger can only fire an engine
13
14
  // start/message action, not this custom I/O glue, so it stays app-side here — driving the same
14
15
  // engine client the runtime uses, over `app.data`.
15
16
  //
@@ -89,7 +90,7 @@ if (httpServer instanceof Server) {
89
90
  }
90
91
 
91
92
  // Review-ready poller. Self-scheduling (not setInterval) so a slow GitHub call can never
92
- // overlap two passes (which could double-signal `review-ready`); the next pass is scheduled
93
+ // overlap two passes (which could double-signal `readiness-ready`); the next pass is scheduled
93
94
  // only after the previous one settles.
94
95
  let shuttingDown = false;
95
96
  let pollTimer: ReturnType<typeof setTimeout> | null = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.98.0",
3
+ "version": "0.99.0",
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",
@@ -1,10 +1,10 @@
1
1
  <?xml version="1.0" encoding="UTF-8"?>
2
2
  <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" xmlns:nano="https://nanobpm.io/schema/shapes/1.0" id="Definitions_nano_workforce" targetNamespace="http://nanobpm.io/nano-workforce">
3
- <bpmn:message id="Message_reviewReady" name="review-ready">
3
+ <bpmn:message id="Message_readinessReady" name="readiness-ready">
4
4
  <bpmn:extensionElements>
5
5
  <zeebe:subscription correlationKey="=prKey" />
6
6
  <zeebe:properties>
7
- <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReviewReady" />
7
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReadinessReady" />
8
8
  </zeebe:properties>
9
9
  </bpmn:extensionElements>
10
10
  </bpmn:message>
@@ -82,11 +82,6 @@
82
82
  <nano:extend name="convergeOnly" type="boolean" optional="true" />
83
83
  <nano:extend name="abandonUrl" type="string" optional="true" />
84
84
  </nano:shape>
85
- <nano:shape id="ReviewReady" name="review-ready message payload">
86
- <nano:extend name="reviewId" type="integer" />
87
- <nano:extend name="reviewState" type="string" />
88
- <nano:extend name="submittedAt" type="datetime" optional="true" />
89
- </nano:shape>
90
85
  </nano:shapes>
91
86
  </bpmn:extensionElements>
92
87
  <bpmn:startEvent id="Start" name="PR submitted">
@@ -181,7 +176,7 @@
181
176
  </bpmn:extensionElements>
182
177
  <bpmn:incoming>f_toReviewWait</bpmn:incoming>
183
178
  <bpmn:outgoing>f_reviewLoop</bpmn:outgoing>
184
- <bpmn:messageEventDefinition id="med_reviewReady" messageRef="Message_reviewReady" />
179
+ <bpmn:messageEventDefinition id="med_readinessReady" messageRef="Message_readinessReady" />
185
180
  </bpmn:intermediateCatchEvent>
186
181
  <bpmn:intermediateCatchEvent id="wait-review-timeout" name="Wait: review timeout">
187
182
  <bpmn:incoming>f_toReviewTimeout</bpmn:incoming>
@@ -106,6 +106,7 @@
106
106
  <bpmn:incoming>f_reb_rebased</bpmn:incoming>
107
107
  <bpmn:incoming>f_m_evicted</bpmn:incoming>
108
108
  <bpmn:incoming>f_m_escReenter</bpmn:incoming>
109
+ <bpmn:incoming>f_mr_go</bpmn:incoming>
109
110
  <bpmn:outgoing>f_m_arm</bpmn:outgoing>
110
111
  </bpmn:serviceTask>
111
112
  <bpmn:intermediateCatchEvent id="wait-mergeable" name="Wait: mergeable">
@@ -127,6 +128,9 @@
127
128
  <zeebe:property name="io.nanobpm.dataEnvelope.in" value="MergeAttemptIn" />
128
129
  <zeebe:property name="io.nanobpm.dataEnvelope.out" value="MergeAttemptOut" />
129
130
  </zeebe:properties>
131
+ <zeebe:ioMapping>
132
+ <zeebe:output source="=if mergeStatus = &#34;retry&#34; then mergeRetryRound + 1 else mergeRetryRound" target="mergeRetryRound" />
133
+ </zeebe:ioMapping>
130
134
  </bpmn:extensionElements>
131
135
  <bpmn:incoming>f_m_mReady</bpmn:incoming>
132
136
  <bpmn:outgoing>f_m_attempt</bpmn:outgoing>
@@ -135,8 +139,14 @@
135
139
  <bpmn:incoming>f_m_attempt</bpmn:incoming>
136
140
  <bpmn:outgoing>f_m_gMerged</bpmn:outgoing>
137
141
  <bpmn:outgoing>f_m_gQueued</bpmn:outgoing>
142
+ <bpmn:outgoing>f_m_gRetry</bpmn:outgoing>
138
143
  <bpmn:outgoing>f_m_gBlocked</bpmn:outgoing>
139
144
  </bpmn:exclusiveGateway>
145
+ <bpmn:exclusiveGateway id="gw-merge-retry" name="retry within budget?" default="f_mr_giveup">
146
+ <bpmn:incoming>f_m_gRetry</bpmn:incoming>
147
+ <bpmn:outgoing>f_mr_go</bpmn:outgoing>
148
+ <bpmn:outgoing>f_mr_giveup</bpmn:outgoing>
149
+ </bpmn:exclusiveGateway>
140
150
  <bpmn:eventBasedGateway id="eg-landed" name="landed or evicted?">
141
151
  <bpmn:incoming>f_m_gQueued</bpmn:incoming>
142
152
  <bpmn:outgoing>f_eg_landed</bpmn:outgoing>
@@ -193,7 +203,7 @@
193
203
  </zeebe:properties>
194
204
  <zeebe:ioMapping>
195
205
  <zeebe:input source="=status" target="agentVerdict" />
196
- <zeebe:input source="=&#34;This PR cannot be merged and needs a human decision. &#34; + (if mergeState = &#34;conflict&#34; then &#34;The automated rebase agent could not resolve the merge conflicts on this branch. Resolve them manually (or reply with guidance), then reply to retry.&#34; else if mergeState = &#34;ready&#34; then &#34;The merge attempt did not land (merge result: &#34; + mergeStatus + &#34;). Investigate why GitHub refused the merge, then reply to retry.&#34; else if agentVerdict = &#34;blocked&#34; then &#34;The automated CI-fix agent could not make the required checks green. Fix the failing checks on the branch (or reply with guidance), then reply to retry.&#34; else &#34;The automated CI-fix agent exceeded its time budget (SLA) before the required checks went green. Check its progress or intervene, then reply to retry.&#34;)" target="question" />
206
+ <zeebe:input source="=&#34;This PR cannot be merged and needs a human decision. &#34; + (if mergeState = &#34;conflict&#34; then &#34;The automated rebase agent could not resolve the merge conflicts on this branch. Resolve them manually (or reply with guidance), then reply to retry.&#34; else if mergeStatus = &#34;retry&#34; then &#34;The merge kept losing a race with concurrent updates to the base branch and exhausted its automatic retry budget (&#34; + string(mergeRetryMax) + &#34; retries). This is a repeated transient race, not a conflict, failing check, or permissions block. Reply to retry, or investigate why the base branch keeps moving.&#34; else if mergeState = &#34;ready&#34; then &#34;The merge attempt did not land (merge result: &#34; + mergeStatus + &#34;). Investigate why GitHub refused the merge, then reply to retry.&#34; else if agentVerdict = &#34;blocked&#34; then &#34;The automated CI-fix agent could not make the required checks green. Fix the failing checks on the branch (or reply with guidance), then reply to retry.&#34; else &#34;The automated CI-fix agent exceeded its time budget (SLA) before the required checks went green. Check its progress or intervene, then reply to retry.&#34;)" target="question" />
197
207
  <zeebe:input source="=&#34;blocked&#34;" target="status" />
198
208
  </zeebe:ioMapping>
199
209
  </bpmn:extensionElements>
@@ -201,6 +211,7 @@
201
211
  <bpmn:incoming>f_ci_blocked</bpmn:incoming>
202
212
  <bpmn:incoming>f_reb_blocked</bpmn:incoming>
203
213
  <bpmn:incoming>f_ci_sla</bpmn:incoming>
214
+ <bpmn:incoming>f_mr_giveup</bpmn:incoming>
204
215
  <bpmn:outgoing>f_m_escA</bpmn:outgoing>
205
216
  </bpmn:serviceTask>
206
217
  <bpmn:exclusiveGateway id="gw-merge-escalated" name="escalation opened?" default="f_m_escReenter">
@@ -370,6 +381,13 @@
370
381
  <bpmn:sequenceFlow id="f_m_gQueued" name="queued" sourceRef="gw-merge" targetRef="eg-landed">
371
382
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStatus = "queued"</bpmn:conditionExpression>
372
383
  </bpmn:sequenceFlow>
384
+ <bpmn:sequenceFlow id="f_m_gRetry" name="retry (base moved)" sourceRef="gw-merge" targetRef="gw-merge-retry">
385
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStatus = "retry"</bpmn:conditionExpression>
386
+ </bpmn:sequenceFlow>
387
+ <bpmn:sequenceFlow id="f_mr_go" name="within budget" sourceRef="gw-merge-retry" targetRef="arm-merge">
388
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeRetryRound &lt;= mergeRetryMax</bpmn:conditionExpression>
389
+ </bpmn:sequenceFlow>
390
+ <bpmn:sequenceFlow id="f_mr_giveup" name="budget exhausted" sourceRef="gw-merge-retry" targetRef="merge-esc-attempt" />
373
391
  <bpmn:sequenceFlow id="f_eg_landed" sourceRef="eg-landed" targetRef="wait-landed" />
374
392
  <bpmn:sequenceFlow id="f_eg_evicted" sourceRef="eg-landed" targetRef="wait-evicted" />
375
393
  <bpmn:sequenceFlow id="f_m_evicted" sourceRef="wait-evicted" targetRef="arm-merge" />
@@ -425,6 +443,12 @@
425
443
  <dc:Bounds x="1022" y="150" width="53" height="28" />
426
444
  </bpmndi:BPMNLabel>
427
445
  </bpmndi:BPMNShape>
446
+ <bpmndi:BPMNShape id="BPMNShape_gw-merge-retry" bpmnElement="gw-merge-retry" isMarkerVisible="true">
447
+ <dc:Bounds x="1263" y="1215" width="50" height="50" />
448
+ <bpmndi:BPMNLabel>
449
+ <dc:Bounds x="1344" y="1250" width="88" height="28" />
450
+ </bpmndi:BPMNLabel>
451
+ </bpmndi:BPMNShape>
428
452
  <bpmndi:BPMNShape id="BPMNShape_eg-landed" bpmnElement="eg-landed" isMarkerVisible="true">
429
453
  <dc:Bounds x="1263" y="255" width="50" height="50" />
430
454
  <bpmndi:BPMNLabel>
@@ -453,27 +477,27 @@
453
477
  </bpmndi:BPMNLabel>
454
478
  </bpmndi:BPMNShape>
455
479
  <bpmndi:BPMNShape id="BPMNShape_merge-esc-conflict" bpmnElement="merge-esc-conflict">
456
- <dc:Bounds x="1238" y="1520" width="100" height="80" />
480
+ <dc:Bounds x="1238" y="1680" width="100" height="80" />
457
481
  </bpmndi:BPMNShape>
458
482
  <bpmndi:BPMNShape id="BPMNShape_merge-esc-attempt" bpmnElement="merge-esc-attempt">
459
- <dc:Bounds x="1438" y="1040" width="100" height="80" />
483
+ <dc:Bounds x="1438" y="1200" width="100" height="80" />
460
484
  </bpmndi:BPMNShape>
461
485
  <bpmndi:BPMNShape id="BPMNShape_gw-merge-escalated" bpmnElement="gw-merge-escalated" isMarkerVisible="true">
462
- <dc:Bounds x="1663" y="1055" width="50" height="50" />
486
+ <dc:Bounds x="1663" y="1215" width="50" height="50" />
463
487
  <bpmndi:BPMNLabel>
464
- <dc:Bounds x="1651" y="1022" width="74" height="28" />
488
+ <dc:Bounds x="1651" y="1182" width="74" height="28" />
465
489
  </bpmndi:BPMNLabel>
466
490
  </bpmndi:BPMNShape>
467
491
  <bpmndi:BPMNShape id="BPMNShape_wait-merge-answer" bpmnElement="wait-merge-answer">
468
- <dc:Bounds x="1838" y="1040" width="100" height="80" />
492
+ <dc:Bounds x="1838" y="1200" width="100" height="80" />
469
493
  </bpmndi:BPMNShape>
470
494
  <bpmndi:BPMNShape id="BPMNShape_record-merge-answer" bpmnElement="record-merge-answer">
471
- <dc:Bounds x="2038" y="1040" width="100" height="80" />
495
+ <dc:Bounds x="2038" y="1200" width="100" height="80" />
472
496
  </bpmndi:BPMNShape>
473
497
  <bpmndi:BPMNShape id="BPMNShape_gw-ci-fix" bpmnElement="gw-ci-fix" isMarkerVisible="true">
474
- <dc:Bounds x="863" y="1535" width="50" height="50" />
498
+ <dc:Bounds x="863" y="1695" width="50" height="50" />
475
499
  <bpmndi:BPMNLabel>
476
- <dc:Bounds x="769" y="1553" width="89" height="14" />
500
+ <dc:Bounds x="769" y="1713" width="89" height="14" />
477
501
  </bpmndi:BPMNLabel>
478
502
  </bpmndi:BPMNShape>
479
503
  <bpmndi:BPMNShape id="BPMNShape_fix-ci" bpmnElement="fix-ci">
@@ -512,7 +536,7 @@
512
536
  <bpmndi:BPMNShape id="BPMNShape_be_rebase_sla" bpmnElement="be_rebase_sla">
513
537
  <dc:Bounds x="1070" y="942" width="36" height="36" />
514
538
  <bpmndi:BPMNLabel>
515
- <dc:Bounds x="1046" y="1043" width="84" height="14" />
539
+ <dc:Bounds x="1046" y="1023" width="84" height="14" />
516
540
  </bpmndi:BPMNLabel>
517
541
  </bpmndi:BPMNShape>
518
542
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_start" bpmnElement="f_m_start">
@@ -554,10 +578,10 @@
554
578
  <di:waypoint x="1870" y="120" />
555
579
  </bpmndi:BPMNEdge>
556
580
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_giveup" bpmnElement="f_ci_giveup">
557
- <di:waypoint x="913" y="1560" />
558
- <di:waypoint x="1238" y="1560" />
581
+ <di:waypoint x="913" y="1720" />
582
+ <di:waypoint x="1238" y="1720" />
559
583
  <bpmndi:BPMNLabel>
560
- <dc:Bounds x="1032" y="1527" width="67" height="28" />
584
+ <dc:Bounds x="1032" y="1687" width="67" height="28" />
561
585
  </bpmndi:BPMNLabel>
562
586
  </bpmndi:BPMNEdge>
563
587
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_reconcile" bpmnElement="f_ci_reconcile">
@@ -574,11 +598,11 @@
574
598
  <di:waypoint x="933" y="280" />
575
599
  <di:waypoint x="933" y="305" />
576
600
  <di:waypoint x="1333" y="305" />
577
- <di:waypoint x="1333" y="1500" />
578
- <di:waypoint x="1288" y="1500" />
579
- <di:waypoint x="1288" y="1520" />
601
+ <di:waypoint x="1333" y="1660" />
602
+ <di:waypoint x="1288" y="1660" />
603
+ <di:waypoint x="1288" y="1680" />
580
604
  <bpmndi:BPMNLabel>
581
- <dc:Bounds x="1338" y="1246" width="67" height="28" />
605
+ <dc:Bounds x="1338" y="1406" width="67" height="28" />
582
606
  </bpmndi:BPMNLabel>
583
607
  </bpmndi:BPMNEdge>
584
608
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_reconcile" bpmnElement="f_reb_reconcile">
@@ -590,6 +614,13 @@
590
614
  <dc:Bounds x="804" y="939" width="82" height="56" />
591
615
  </bpmndi:BPMNLabel>
592
616
  </bpmndi:BPMNEdge>
617
+ <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" />
620
+ <bpmndi:BPMNLabel>
621
+ <dc:Bounds x="1352" y="1307" width="67" height="28" />
622
+ </bpmndi:BPMNLabel>
623
+ </bpmndi:BPMNEdge>
593
624
  <bpmndi:BPMNEdge id="BPMNEdge_f_eg_landed" bpmnElement="f_eg_landed">
594
625
  <di:waypoint x="1313" y="280" />
595
626
  <di:waypoint x="1470" y="280" />
@@ -600,40 +631,44 @@
600
631
  <di:waypoint x="1688" y="160" />
601
632
  </bpmndi:BPMNEdge>
602
633
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escC" bpmnElement="f_m_escC">
603
- <di:waypoint x="1338" y="1560" />
604
- <di:waypoint x="1888" y="1560" />
605
- <di:waypoint x="1888" y="1120" />
634
+ <di:waypoint x="1338" y="1720" />
635
+ <di:waypoint x="1888" y="1720" />
636
+ <di:waypoint x="1888" y="1280" />
637
+ </bpmndi:BPMNEdge>
638
+ <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" />
606
641
  </bpmndi:BPMNEdge>
607
642
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escReenter" bpmnElement="f_m_escReenter">
608
- <di:waypoint x="1688" y="1105" />
609
- <di:waypoint x="1688" y="1140" />
610
- <di:waypoint x="402" y="1140" />
643
+ <di:waypoint x="1688" y="1265" />
644
+ <di:waypoint x="1688" y="1300" />
645
+ <di:waypoint x="402" y="1300" />
611
646
  <di:waypoint x="402" y="160" />
612
647
  <bpmndi:BPMNLabel>
613
- <dc:Bounds x="1008" y="1093" width="74" height="42" />
648
+ <dc:Bounds x="1008" y="1305" width="74" height="42" />
614
649
  </bpmndi:BPMNLabel>
615
650
  </bpmndi:BPMNEdge>
616
651
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_answerRecord" bpmnElement="f_m_answerRecord">
617
- <di:waypoint x="1938" y="1080" />
618
- <di:waypoint x="2038" y="1080" />
652
+ <di:waypoint x="1938" y="1240" />
653
+ <di:waypoint x="2038" y="1240" />
619
654
  </bpmndi:BPMNEdge>
620
655
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_answer" bpmnElement="f_m_answer">
621
- <di:waypoint x="2088" y="1120" />
622
- <di:waypoint x="2088" y="1140" />
623
- <di:waypoint x="402" y="1140" />
656
+ <di:waypoint x="2088" y="1280" />
657
+ <di:waypoint x="2088" y="1300" />
658
+ <di:waypoint x="402" y="1300" />
624
659
  <di:waypoint x="402" y="160" />
625
660
  </bpmndi:BPMNEdge>
626
661
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_mCiFix" bpmnElement="f_m_mCiFix">
627
662
  <di:waypoint x="713" y="145" />
628
663
  <di:waypoint x="713" y="318" />
629
664
  <di:waypoint x="1358" y="318" />
630
- <di:waypoint x="1358" y="1020" />
631
- <di:waypoint x="863" y="1020" />
632
- <di:waypoint x="863" y="1515" />
633
- <di:waypoint x="888" y="1515" />
634
- <di:waypoint x="888" y="1535" />
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" />
635
670
  <bpmndi:BPMNLabel>
636
- <dc:Bounds x="971" y="1025" width="60" height="42" />
671
+ <dc:Bounds x="1011" y="1133" width="60" height="42" />
637
672
  </bpmndi:BPMNLabel>
638
673
  </bpmndi:BPMNEdge>
639
674
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_mRebase" bpmnElement="f_m_mRebase">
@@ -648,33 +683,33 @@
648
683
  <di:waypoint x="713" y="145" />
649
684
  <di:waypoint x="713" y="318" />
650
685
  <di:waypoint x="1333" y="318" />
651
- <di:waypoint x="1333" y="1020" />
652
- <di:waypoint x="1218" y="1020" />
653
- <di:waypoint x="1218" y="1540" />
654
- <di:waypoint x="1238" y="1540" />
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" />
655
690
  <bpmndi:BPMNLabel>
656
- <dc:Bounds x="1243" y="702" width="85" height="14" />
691
+ <dc:Bounds x="1243" y="722" width="85" height="14" />
657
692
  </bpmndi:BPMNLabel>
658
693
  </bpmndi:BPMNEdge>
659
694
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_go" bpmnElement="f_ci_go">
660
- <di:waypoint x="888" y="1585" />
661
- <di:waypoint x="888" y="1605" />
662
- <di:waypoint x="2158" y="1605" />
695
+ <di:waypoint x="888" y="1745" />
696
+ <di:waypoint x="888" y="1765" />
697
+ <di:waypoint x="2158" y="1765" />
663
698
  <di:waypoint x="2158" y="555" />
664
699
  <di:waypoint x="1018" y="555" />
665
700
  <di:waypoint x="1018" y="580" />
666
701
  <di:waypoint x="1038" y="580" />
667
702
  <bpmndi:BPMNLabel>
668
- <dc:Bounds x="2163" y="1066" width="49" height="28" />
703
+ <dc:Bounds x="2163" y="1146" width="49" height="28" />
669
704
  </bpmndi:BPMNLabel>
670
705
  </bpmndi:BPMNEdge>
671
706
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_blocked" bpmnElement="f_ci_blocked">
672
707
  <di:waypoint x="1313" y="600" />
673
708
  <di:waypoint x="1418" y="600" />
674
- <di:waypoint x="1418" y="1080" />
675
- <di:waypoint x="1438" y="1080" />
709
+ <di:waypoint x="1418" y="1240" />
710
+ <di:waypoint x="1438" y="1240" />
676
711
  <bpmndi:BPMNLabel>
677
- <dc:Bounds x="1423" y="863" width="89" height="14" />
712
+ <dc:Bounds x="1423" y="853" width="89" height="14" />
678
713
  </bpmndi:BPMNLabel>
679
714
  </bpmndi:BPMNEdge>
680
715
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_wait" bpmnElement="f_ci_wait">
@@ -689,21 +724,23 @@
689
724
  <di:waypoint x="913" y="280" />
690
725
  <di:waypoint x="933" y="280" />
691
726
  <di:waypoint x="933" y="305" />
692
- <di:waypoint x="1558" y="305" />
693
- <di:waypoint x="1558" y="1125" />
694
- <di:waypoint x="1418" y="1125" />
727
+ <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" />
695
732
  <di:waypoint x="1418" y="875" />
696
733
  <di:waypoint x="1018" y="875" />
697
734
  <di:waypoint x="1018" y="900" />
698
735
  <di:waypoint x="1038" y="900" />
699
736
  <bpmndi:BPMNLabel>
700
- <dc:Bounds x="1563" y="701" width="49" height="28" />
737
+ <dc:Bounds x="1248" y="1414" width="49" height="28" />
701
738
  </bpmndi:BPMNLabel>
702
739
  </bpmndi:BPMNEdge>
703
740
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_blocked" bpmnElement="f_reb_blocked">
704
741
  <di:waypoint x="1313" y="920" />
705
742
  <di:waypoint x="1488" y="920" />
706
- <di:waypoint x="1488" y="1040" />
743
+ <di:waypoint x="1488" y="1200" />
707
744
  <bpmndi:BPMNLabel>
708
745
  <dc:Bounds x="1429" y="887" width="64" height="28" />
709
746
  </bpmndi:BPMNLabel>
@@ -724,6 +761,20 @@
724
761
  <dc:Bounds x="1093" y="206" width="46" height="14" />
725
762
  </bpmndi:BPMNLabel>
726
763
  </bpmndi:BPMNEdge>
764
+ <bpmndi:BPMNEdge id="BPMNEdge_f_m_gRetry" bpmnElement="f_m_gRetry">
765
+ <di:waypoint x="1088" y="95" />
766
+ <di:waypoint x="1088" y="75" />
767
+ <di:waypoint x="1926" y="75" />
768
+ <di:waypoint x="1926" y="318" />
769
+ <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" />
774
+ <bpmndi:BPMNLabel>
775
+ <dc:Bounds x="1746" y="285" width="78" height="28" />
776
+ </bpmndi:BPMNLabel>
777
+ </bpmndi:BPMNEdge>
727
778
  <bpmndi:BPMNEdge id="BPMNEdge_f_eg_evicted" bpmnElement="f_eg_evicted">
728
779
  <di:waypoint x="1288" y="305" />
729
780
  <di:waypoint x="1288" y="440" />
@@ -735,10 +786,10 @@
735
786
  <di:waypoint x="1926" y="75" />
736
787
  <di:waypoint x="1926" y="820" />
737
788
  <di:waypoint x="1418" y="820" />
738
- <di:waypoint x="1418" y="1060" />
739
- <di:waypoint x="1438" y="1060" />
789
+ <di:waypoint x="1418" y="1220" />
790
+ <di:waypoint x="1438" y="1220" />
740
791
  <bpmndi:BPMNLabel>
741
- <dc:Bounds x="1931" y="441" width="53" height="14" />
792
+ <dc:Bounds x="1931" y="562" width="53" height="14" />
742
793
  </bpmndi:BPMNLabel>
743
794
  </bpmndi:BPMNEdge>
744
795
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_done" bpmnElement="f_ci_done">
@@ -749,20 +800,16 @@
749
800
  <di:waypoint x="1138" y="920" />
750
801
  <di:waypoint x="1263" y="920" />
751
802
  </bpmndi:BPMNEdge>
752
- <bpmndi:BPMNEdge id="BPMNEdge_f_m_escA" bpmnElement="f_m_escA">
753
- <di:waypoint x="1538" y="1080" />
754
- <di:waypoint x="1663" y="1080" />
755
- </bpmndi:BPMNEdge>
756
803
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escWait" bpmnElement="f_m_escWait">
757
- <di:waypoint x="1713" y="1080" />
758
- <di:waypoint x="1838" y="1080" />
804
+ <di:waypoint x="1713" y="1240" />
805
+ <di:waypoint x="1838" y="1240" />
759
806
  </bpmndi:BPMNEdge>
760
807
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_sla" bpmnElement="f_reb_sla">
761
808
  <di:waypoint x="1088" y="978" />
762
809
  <di:waypoint x="1088" y="998" />
763
810
  <di:waypoint x="1218" y="998" />
764
- <di:waypoint x="1218" y="1560" />
765
- <di:waypoint x="1238" y="1560" />
811
+ <di:waypoint x="1218" y="1720" />
812
+ <di:waypoint x="1238" y="1720" />
766
813
  <bpmndi:BPMNLabel>
767
814
  <dc:Bounds x="1118" y="965" width="70" height="28" />
768
815
  </bpmndi:BPMNLabel>
@@ -772,12 +819,12 @@
772
819
  <di:waypoint x="1088" y="678" />
773
820
  <di:waypoint x="1018" y="678" />
774
821
  <di:waypoint x="1018" y="540" />
775
- <di:waypoint x="1618" y="540" />
776
- <di:waypoint x="1618" y="820" />
822
+ <di:waypoint x="1558" y="540" />
823
+ <di:waypoint x="1558" y="820" />
777
824
  <di:waypoint x="1526" y="820" />
778
- <di:waypoint x="1526" y="1040" />
825
+ <di:waypoint x="1526" y="1200" />
779
826
  <bpmndi:BPMNLabel>
780
- <dc:Bounds x="1243" y="507" width="70" height="28" />
827
+ <dc:Bounds x="1253" y="507" width="70" height="28" />
781
828
  </bpmndi:BPMNLabel>
782
829
  </bpmndi:BPMNEdge>
783
830
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_fixed" bpmnElement="f_ci_fixed">
@@ -795,7 +842,7 @@
795
842
  <di:waypoint x="402" y="1000" />
796
843
  <di:waypoint x="402" y="160" />
797
844
  <bpmndi:BPMNLabel>
798
- <dc:Bounds x="799" y="1008" width="53" height="14" />
845
+ <dc:Bounds x="819" y="1008" width="53" height="14" />
799
846
  </bpmndi:BPMNLabel>
800
847
  </bpmndi:BPMNEdge>
801
848
  <bpmndi:BPMNEdge id="BPMNEdge_f_dep_rewait" bpmnElement="f_dep_rewait">
@@ -804,6 +851,15 @@
804
851
  <di:waypoint x="234" y="820" />
805
852
  <di:waypoint x="234" y="138" />
806
853
  </bpmndi:BPMNEdge>
854
+ <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" />
858
+ <di:waypoint x="402" y="160" />
859
+ <bpmndi:BPMNLabel>
860
+ <dc:Bounds x="808" y="1252" width="49" height="28" />
861
+ </bpmndi:BPMNLabel>
862
+ </bpmndi:BPMNEdge>
807
863
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_evicted" bpmnElement="f_m_evicted">
808
864
  <di:waypoint x="1488" y="458" />
809
865
  <di:waypoint x="1488" y="478" />
@@ -207,3 +207,41 @@ test("pr.merge routes a refused merge through the blocked branch (escalation pay
207
207
  },
208
208
  );
209
209
  });
210
+
211
+ test("pr.merge routes a transient base-moved race through the retry branch (no escalation payload)", async () => {
212
+ // #334: GitHub aborts the merge PUT with the 405 "Base branch was modified. Review and try the
213
+ // merge again." race. `mergePr` classifies it as `retry` (not `blocked`), and the retry arm of
214
+ // `matchTags` returns ONLY the loop-terminal `mergeStatus` — no `status`/`question` escalation
215
+ // payload — so the model re-attempts on the settled base instead of paging a human.
216
+ await withGithub(
217
+ (url) => {
218
+ if (/\/pulls\/\d+\/merge$/.test(url))
219
+ return new Response("Base branch was modified. Review and try the merge again.", {
220
+ status: 405,
221
+ statusText: "Method Not Allowed",
222
+ });
223
+ if (/\/pulls\/\d+$/.test(url)) return new Response(JSON.stringify({ merged: false, mergeable_state: "clean" }));
224
+ return null; // no AGENTS.md / merge-protocol.json → DEFAULT gh-merge protocol
225
+ },
226
+ async (calls) => {
227
+ const { app, stores } = fakeApp();
228
+ const out = (await handler(
229
+ { variables: { prKey: "acme/widgets#11", repo: "acme/widgets", prNumber: 11 } } as any,
230
+ app,
231
+ )) as Record<string, unknown>;
232
+
233
+ // Retry arm: transient — surfaces the loop-terminal `mergeStatus` only, NO escalation fields.
234
+ assertEquals(out, { mergeStatus: "retry" });
235
+ assertEquals(out.status, undefined);
236
+ assertEquals(out.question, undefined);
237
+
238
+ // Attempted a real merge PUT and recorded the retry audit row (not a swallowed block).
239
+ assertEquals(
240
+ calls.some((c) => /\/pulls\/\d+\/merge$/.test(c.url) && c.method === "PUT"),
241
+ true,
242
+ );
243
+ assertEquals(stores.merges.length, 1);
244
+ assertEquals(stores.merges[0].outcome, "retry");
245
+ },
246
+ );
247
+ });
@@ -1,6 +1,8 @@
1
1
  // pr.merge — attempt to land the PR (SPEC §11). Returns `mergeStatus`:
2
2
  // • merged — landed now (direct merge) → process marks it merged
3
3
  // • queued — added to the repo's merge queue → process waits for `merge-landed`
4
+ // • retry — transient base/head-moved race → process re-attempts on the settled base
5
+ // via the bounded retry gate (no human, no remediation agent)
4
6
  // • blocked — GitHub refused (conflict / failing gate / perms) → escalate to a human, who
5
7
  // resolves it and replies to retry (the process re-arms and re-polls).
6
8
  // HOW it lands is governed by the target repo's published merge protocol (#43): a `mergify-queue`
@@ -21,7 +23,7 @@ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
21
23
  type In = WorkerInputs["pr.merge"];
22
24
 
23
25
  interface Out extends Record<string, unknown> {
24
- mergeStatus: "merged" | "queued" | "blocked";
26
+ mergeStatus: "merged" | "queued" | "blocked" | "retry";
25
27
  status?: string;
26
28
  question?: string;
27
29
  }
@@ -92,7 +94,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
92
94
  const protocol = await loadMergeProtocol(repo, token).catch(() => null);
93
95
  const method = protocol?.land.method ?? "gh-merge";
94
96
 
95
- let outcome: "merged" | "queued" | "blocked";
97
+ let outcome: "merged" | "queued" | "blocked" | "retry";
96
98
  let detail: string;
97
99
  let auditMethod: string;
98
100
 
@@ -130,7 +132,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
130
132
 
131
133
  // Exhaustive dispatch on the land outcome. Modelled as a tagged value so
132
134
  // `matchTags` forces a handler for every case — adding a new outcome to the
133
- // `"merged" | "queued" | "blocked"` union becomes a compile error here rather
135
+ // `"merged" | "queued" | "blocked" | "retry"` union becomes a compile error here rather
134
136
  // than silently falling through to the "blocked" branch.
135
137
  const docHint = protocol?.doc ? ` See the repo's merge protocol (${protocol.doc}).` : "";
136
138
  return await matchTags(tag(outcome, { detail }), {
@@ -142,6 +144,10 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
142
144
  return { mergeStatus: "queued" };
143
145
  },
144
146
  merged: async () => ({ mergeStatus: "merged" }),
147
+ // retry → a transient base/head-moved race (GitHub told us to re-attempt). Re-enter the merge
148
+ // loop on the settled base via the model's bounded retry gate — NO human escalation, NO
149
+ // remediation agent. The gate caps the attempts so a continuously-moving base still escalates.
150
+ retry: async () => ({ mergeStatus: "retry" }),
145
151
  // blocked → hand the escalation machinery a concrete question.
146
152
  blocked: async (o) => ({
147
153
  mergeStatus: "blocked",
@@ -25,6 +25,7 @@ import {
25
25
  parseProbe,
26
26
  probeBudgetMs,
27
27
  probeOnce,
28
+ READINESS_READY_MESSAGE,
28
29
  type ReadinessProbe,
29
30
  redactTarget,
30
31
  } from "../../app/readiness.ts";
@@ -36,8 +37,11 @@ import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io
36
37
  type In = WorkerInputs["pr.readiness-probe"];
37
38
  type Out = WorkerOutputs["pr.readiness-probe"];
38
39
 
39
- /** The message the gate's event-based gateway correlates on `=gateKey` to release the wait. */
40
- export const READINESS_READY_MESSAGE = "readiness-ready";
40
+ /** The message the gate's event-based gateway correlates on `=gateKey` to release the wait.
41
+ * Re-exported from the canonical source (`app/readiness.ts`) so the worker and every out-of-band
42
+ * publisher (the review-ready poller, #259) share ONE message name — no drift-prone local twin.
43
+ * Re-exports the binding already imported above rather than re-referencing the module. */
44
+ export { READINESS_READY_MESSAGE };
41
45
 
42
46
  const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
43
47