@nanobpm/nano-workforce 0.206.2 → 0.206.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.206.3](https://github.com/nanobpm/nano-workforce/compare/v0.206.2...v0.206.3) (2026-11-09)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **merge:** never escalate a PR that already landed when the queue landing timeout fires ([#935](https://github.com/nanobpm/nano-workforce/issues/935)) ([f731dc6](https://github.com/nanobpm/nano-workforce/commit/f731dc60dc0cb7480b425e88d1d216b95d63a5c1)), closes [#933](https://github.com/nanobpm/nano-workforce/issues/933)
6
+
1
7
  ## [0.206.2](https://github.com/nanobpm/nano-workforce/compare/v0.206.1...v0.206.2) (2026-11-09)
2
8
 
3
9
  ### Bug Fixes
@@ -17,6 +17,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
17
17
  import { tmpdir } from "node:os";
18
18
  import { join } from "node:path";
19
19
  import { assert, assertEquals } from "#test-assert";
20
+ import { memStatusCas } from "../test/memStatusCas.ts";
20
21
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
21
22
  import { pollMerges } from "./service.ts";
22
23
 
@@ -73,6 +74,8 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
73
74
  table: (n: string, pk?: string) => tbl(n, pk),
74
75
  open: () => ({
75
76
  exec: async (sql: string, params: any[] = []) => {
77
+ const cas = memStatusCas(stores, sql, params);
78
+ if (cas) return cas;
76
79
  const m = sql.match(/^\s*DELETE\s+FROM\s+"?(\w+)"?\s+WHERE\s+"?(\w+)"?\s*=\s*\?\s*$/i);
77
80
  if (!m) throw new Error(`memData.open().exec: unsupported statement: ${sql}`);
78
81
  const rows = (stores[m[1]] ??= [] as any[]);
@@ -52,6 +52,7 @@ const ALL_JOB_TYPES = [
52
52
  "pr.arm-merge",
53
53
  "pr.merge",
54
54
  "pr.merge-stall-probe",
55
+ "pr.landed-probe",
55
56
  "pr.mark-merged",
56
57
  "senior:fix-ci",
57
58
  "senior:rebase",
@@ -66,6 +67,7 @@ const DEFAULT_RESPONSES: Record<string, Responder> = {
66
67
  "pr.record-dependency": {},
67
68
  "pr.answer-escalation": {},
68
69
  "pr.persist-escalation": { escalated: true },
70
+ "pr.landed-probe": { landedVerdict: "waiting" },
69
71
  };
70
72
 
71
73
  // Every FEEL expression in the model references these; start them defined (null,
@@ -239,6 +241,30 @@ test("a queued merge that never lands escalates when the landing timeout fires (
239
241
  assertStringIncludes(String(escalation(engine).question ?? ""), "did not land within the merge-queue landing timeout", "the escalation must name the landing-timeout trigger");
240
242
  });
241
243
 
244
+ test("a landing timeout firing on a PR that already MERGED completes merged with no escalation (#933)", async () => {
245
+ // The queue landed the PR, but the timer won the `eg-landed` race before the poller published
246
+ // `merge-landed`. The timeout arm must re-read ground truth and finalize — never escalate a landed PR.
247
+ const engine = await boot({
248
+ responses: { "pr.merge": { mergeStatus: "queued" }, "pr.landed-probe": { landedVerdict: "landed" } },
249
+ });
250
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
251
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
252
+ await engine.advanceTime(LANDED_WAIT_MS + 1);
253
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasNoIncident().hasCompletedElements("landed-probe", "mark-merged");
254
+ assert(!completedElementIds(engine).has("merge-esc-landed"), "a landed PR must never be escalated");
255
+ });
256
+
257
+ test("a landing timeout on a PR evicted from the queue re-arms the merge poller (#933)", async () => {
258
+ const engine = await boot({
259
+ responses: { "pr.merge": { mergeStatus: "queued" }, "pr.landed-probe": { landedVerdict: "evicted" } },
260
+ });
261
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
262
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
263
+ await engine.advanceTime(LANDED_WAIT_MS + 1);
264
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
265
+ assert(!completedElementIds(engine).has("merge-esc-landed"), "an evicted PR re-arms rather than escalating");
266
+ });
267
+
242
268
  test("answering the landing-timeout escalation re-arms the merge poller (#556)", async () => {
243
269
  const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
244
270
  await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
@@ -13,6 +13,7 @@
13
13
  // message THAT wait correlates to; this test asserts the class over every (status × merged/closed).
14
14
  import { test } from "node:test";
15
15
  import { assertEquals } from "#test-assert";
16
+ import { memStatusCas } from "../test/memStatusCas.ts";
16
17
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
17
18
  import { pollMerges } from "./service.ts";
18
19
 
@@ -45,7 +46,16 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
45
46
  },
46
47
  };
47
48
  }
48
- const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
49
+ const data = {
50
+ table: (n: string, pk?: string) => tbl(n, pk),
51
+ open: () => ({
52
+ exec: async (sql: string, params: any[] = []) => {
53
+ const res = memStatusCas(stores, sql, params);
54
+ if (!res) throw new Error(`memData.open().exec: unsupported statement: ${sql}`);
55
+ return res;
56
+ },
57
+ }),
58
+ } as any as DataLayer;
49
59
  return { data, stores };
50
60
  }
51
61
 
@@ -176,3 +186,49 @@ test("still-open waiting_deps PR with an unmerged dep publishes nothing and stay
176
186
  assertEquals(messages.length, 0);
177
187
  assertEquals(stores["pull_requests"][0].status, "waiting_deps");
178
188
  });
189
+
190
+ // PR #935 review: a poll pass reads `queued`, then stalls on GitHub while the process's own landing
191
+ // timeout path completes `mark-merged` (status → `merged`). When the delayed pass resumes, its flip
192
+ // must NOT clobber the terminal `merged` with `merging` (no pass scans `merging`, so it would stick)
193
+ // nor publish a `merge-landed` whose subscription is gone. The flip is a compare-and-set on the
194
+ // status the pass read; a lost race skips the publish entirely.
195
+ // Asserted on `waiting_deps` (the flip is the one shared helper every merge-stage wait uses).
196
+ test("a delayed merge-stage poll never overwrites a concurrently-written terminal status", async () => {
197
+ const { data, stores } = memData();
198
+ const { engine, messages } = recordingEngine();
199
+ stores["pull_requests"] = [prRow("o/r#100", 100, "waiting_deps")];
200
+ stores["pr_dependencies"] = [{ pr_key: "o/r#100", depends_on_key: "o/r#200", created_at: "t" }];
201
+ const inner = githubFetch(new Map<number, Live>([[100, "merged"]]));
202
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
203
+ const prevFetch = globalThis.fetch;
204
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
205
+ // While the poll awaits GitHub, the timeout path's `mark-merged` lands the terminal status.
206
+ globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => {
207
+ // Replace (not mutate) the row: a real DB read hands the pass a snapshot, not a live reference.
208
+ stores["pull_requests"][0] = { ...stores["pull_requests"][0], status: "merged" };
209
+ return inner(url, init);
210
+ }) as typeof fetch;
211
+ try {
212
+ await pollMerges(data, engine, "tok");
213
+ } finally {
214
+ globalThis.fetch = prevFetch;
215
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
216
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
217
+ }
218
+ assertEquals(messages.length, 0, "a lost race must not publish to a vanished subscription");
219
+ assertEquals(stores["pull_requests"][0].status, "merged");
220
+ });
221
+
222
+ // The publish-failure revert is conditional too: it only undoes its OWN `merging`.
223
+ test("a failed publish reverts merging back to the read status", async () => {
224
+ const { data, stores } = memData();
225
+ const engine = {
226
+ async publishMessage() {
227
+ throw new Error("boom");
228
+ },
229
+ } as any as EngineClient;
230
+ stores["pull_requests"] = [prRow("o/r#100", 100, "waiting_deps")];
231
+ stores["pr_dependencies"] = [{ pr_key: "o/r#100", depends_on_key: "o/r#200", created_at: "t" }];
232
+ await withGithub(new Map<number, Live>([[100, "merged"]]), () => pollMerges(data, engine, "tok").catch(() => {}));
233
+ assertEquals(stores["pull_requests"][0].status, "waiting_deps");
234
+ });
@@ -10,6 +10,7 @@
10
10
  // token-transport GitHub stub that serves BOTH the REST PR view and the GraphQL merge-queue probe.
11
11
  import { test } from "node:test";
12
12
  import { assertEquals } from "#test-assert";
13
+ import { memStatusCas } from "../test/memStatusCas.ts";
13
14
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
14
15
  import { fetchMergeQueueMembership } from "./github.ts";
15
16
  import { pollMerges } from "./service.ts";
@@ -42,7 +43,16 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
42
43
  },
43
44
  };
44
45
  }
45
- const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
46
+ const data = {
47
+ table: (n: string, pk?: string) => tbl(n, pk),
48
+ open: () => ({
49
+ exec: async (sql: string, params: any[] = []) => {
50
+ const res = memStatusCas(stores, sql, params);
51
+ if (!res) throw new Error(`memData.open().exec: unsupported statement: ${sql}`);
52
+ return res;
53
+ },
54
+ }),
55
+ } as any as DataLayer;
46
56
  return { data, stores };
47
57
  }
48
58
 
package/app/service.ts CHANGED
@@ -1572,24 +1572,44 @@ async function classifyWaveTarget(
1572
1572
  * otherwise wedge the PR there forever — the next pass would never pick it back up. Reverting on
1573
1573
  * failure keeps the PR on a pollable status so the next pass retries. Single source of truth for
1574
1574
  * the flip-then-publish handoff shared by all merge-stage waits below. */
1575
- async function flipToMergingThenPublish(
1575
+ export async function flipToMergingThenPublish(
1576
1576
  data: DataLayer,
1577
1577
  engine: EngineClient,
1578
1578
  prKey: string,
1579
1579
  prevStatus: string,
1580
1580
  message: Parameters<EngineClient["publishMessage"]>[0],
1581
- ) {
1582
- await prs(data).update(prKey, { status: "merging", updated_at: now() });
1581
+ ): Promise<boolean> {
1582
+ // Compare-and-set (#935 review): the flip only wins if the row is STILL on the status this pass
1583
+ // read. A pass that read `queued`, then stalled on GitHub while the process's own timeout path
1584
+ // completed `mark-merged`, must not clobber the terminal `merged` with `merging` (no pass scans
1585
+ // `merging`, and the completed loop has no worker left to restore it) nor publish a message whose
1586
+ // subscription is gone. A lost race skips the publish entirely.
1587
+ const db = data.open();
1588
+ const flipped = await db.exec(
1589
+ "UPDATE pull_requests SET status = ?, updated_at = ? WHERE pr_key = ? AND status = ?",
1590
+ ["merging", now(), prKey, prevStatus],
1591
+ );
1592
+ if (flipped.changed === 0) {
1593
+ console.log(`[poller] ${prKey} left ${prevStatus} concurrently — skipping ${message.name}`);
1594
+ return false;
1595
+ }
1583
1596
  try {
1584
1597
  await engine.publishMessage(message);
1585
1598
  } catch (err) {
1586
1599
  try {
1587
- await prs(data).update(prKey, { status: prevStatus, updated_at: now() });
1600
+ // Conditional revert too: only undo OUR `merging`, never a status another actor wrote since.
1601
+ await db.exec("UPDATE pull_requests SET status = ?, updated_at = ? WHERE pr_key = ? AND status = ?", [
1602
+ prevStatus,
1603
+ now(),
1604
+ prKey,
1605
+ "merging",
1606
+ ]);
1588
1607
  } catch (revertErr) {
1589
1608
  console.error(`[poller] revert ${prKey} -> ${prevStatus} failed: ${revertErr}`);
1590
1609
  }
1591
1610
  throw err;
1592
1611
  }
1612
+ return true;
1593
1613
  }
1594
1614
 
1595
1615
  export async function mergeLaneDecisionForPr(data: DataLayer, prKey: string): Promise<PrLaneDecision | null> {
package/build-info.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.206.2",
3
- "gitSha": "71984d5d607151b2c3690a12d6b05537b24c3ece",
4
- "gitTag": "v0.206.2",
5
- "builtAt": "2026-11-09T11:18:01.340Z"
2
+ "version": "0.206.3",
3
+ "gitSha": "f731dc60dc0cb7480b425e88d1d216b95d63a5c1",
4
+ "gitTag": "v0.206.3",
5
+ "builtAt": "2026-11-09T21:25:41.376Z"
6
6
  }
package/nano.app.json CHANGED
@@ -148,6 +148,10 @@
148
148
  "taskType": "pr.mark-merged",
149
149
  "handler": "workers/mark-merged/worker.ts"
150
150
  },
151
+ {
152
+ "taskType": "pr.landed-probe",
153
+ "handler": "workers/landed-probe/worker.ts"
154
+ },
151
155
  {
152
156
  "taskType": "pr.merge-stall-probe",
153
157
  "handler": "workers/merge-stall-probe/worker.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.206.2",
3
+ "version": "0.206.3",
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",
@@ -56,6 +56,14 @@
56
56
  <nano:extend name="reviewThreads" type="integer" optional="true" />
57
57
  <nano:extend name="pendingChecks" type="integer" optional="true" />
58
58
  </nano:shape>
59
+ <nano:shape id="LandedProbeIn" name="Landed probe — input">
60
+ <nano:extend name="prKey" type="string" />
61
+ <nano:extend name="repo" type="string" />
62
+ <nano:extend name="prNumber" type="integer" />
63
+ </nano:shape>
64
+ <nano:shape id="LandedProbeOut" name="Landed probe — result">
65
+ <nano:extend name="landedVerdict" type="string" />
66
+ </nano:shape>
59
67
  <nano:shape id="MergeReady" name="merge-ready message payload">
60
68
  <nano:extend name="mergeState" type="string" />
61
69
  <nano:extend name="failingChecks" type="integer" optional="true" />
@@ -125,6 +133,7 @@
125
133
  <bpmn:incoming>f_ci_rearm</bpmn:incoming>
126
134
  <bpmn:incoming>f_reb_rearm</bpmn:incoming>
127
135
  <bpmn:incoming>f_m_evicted</bpmn:incoming>
136
+ <bpmn:incoming>f_lp_evicted</bpmn:incoming>
128
137
  <bpmn:incoming>f_m_escReenter</bpmn:incoming>
129
138
  <bpmn:incoming>f_mr_go</bpmn:incoming>
130
139
  <bpmn:outgoing>f_m_arm</bpmn:outgoing>
@@ -234,11 +243,28 @@
234
243
  </bpmn:intermediateCatchEvent>
235
244
  <bpmn:intermediateCatchEvent id="wait-landed-timeout" name="Wait: merge-queue landing timeout">
236
245
  <bpmn:incoming>f_eg_landedTimeout</bpmn:incoming>
237
- <bpmn:outgoing>f_m_landedStalled</bpmn:outgoing>
246
+ <bpmn:outgoing>f_m_landedProbe</bpmn:outgoing>
238
247
  <bpmn:timerEventDefinition id="ted_landedTimeout">
239
248
  <bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=landedWaitTimeout</bpmn:timeDuration>
240
249
  </bpmn:timerEventDefinition>
241
250
  </bpmn:intermediateCatchEvent>
251
+ <bpmn:serviceTask id="landed-probe" name="Probe: did the queued PR land?">
252
+ <bpmn:extensionElements>
253
+ <zeebe:taskDefinition type="pr.landed-probe" />
254
+ <zeebe:properties>
255
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="LandedProbeIn" />
256
+ <zeebe:property name="io.nanobpm.dataEnvelope.out" value="LandedProbeOut" />
257
+ </zeebe:properties>
258
+ </bpmn:extensionElements>
259
+ <bpmn:incoming>f_m_landedProbe</bpmn:incoming>
260
+ <bpmn:outgoing>f_lp_gate</bpmn:outgoing>
261
+ </bpmn:serviceTask>
262
+ <bpmn:exclusiveGateway id="gw-landed-probe" name="landed?" default="f_m_landedStalled">
263
+ <bpmn:incoming>f_lp_gate</bpmn:incoming>
264
+ <bpmn:outgoing>f_lp_landed</bpmn:outgoing>
265
+ <bpmn:outgoing>f_lp_evicted</bpmn:outgoing>
266
+ <bpmn:outgoing>f_m_landedStalled</bpmn:outgoing>
267
+ </bpmn:exclusiveGateway>
242
268
  <bpmn:serviceTask id="mark-merged" name="Mark merged">
243
269
  <bpmn:extensionElements>
244
270
  <zeebe:taskDefinition type="pr.mark-merged" />
@@ -248,6 +274,7 @@
248
274
  </bpmn:extensionElements>
249
275
  <bpmn:incoming>f_m_gMerged</bpmn:incoming>
250
276
  <bpmn:incoming>f_m_landed</bpmn:incoming>
277
+ <bpmn:incoming>f_lp_landed</bpmn:incoming>
251
278
  <bpmn:outgoing>f_m_done</bpmn:outgoing>
252
279
  </bpmn:serviceTask>
253
280
  <bpmn:endEvent id="MergeEnd" name="Merged">
@@ -792,7 +819,15 @@
792
819
  <bpmn:sequenceFlow id="f_eg_landed" sourceRef="eg-landed" targetRef="wait-landed" />
793
820
  <bpmn:sequenceFlow id="f_eg_evicted" sourceRef="eg-landed" targetRef="wait-evicted" />
794
821
  <bpmn:sequenceFlow id="f_eg_landedTimeout" sourceRef="eg-landed" targetRef="wait-landed-timeout" />
795
- <bpmn:sequenceFlow id="f_m_landedStalled" sourceRef="wait-landed-timeout" targetRef="merge-esc-landed" />
822
+ <bpmn:sequenceFlow id="f_m_landedProbe" sourceRef="wait-landed-timeout" targetRef="landed-probe" />
823
+ <bpmn:sequenceFlow id="f_lp_gate" sourceRef="landed-probe" targetRef="gw-landed-probe" />
824
+ <bpmn:sequenceFlow id="f_lp_landed" sourceRef="gw-landed-probe" targetRef="mark-merged">
825
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=landedVerdict = "landed"</bpmn:conditionExpression>
826
+ </bpmn:sequenceFlow>
827
+ <bpmn:sequenceFlow id="f_lp_evicted" sourceRef="gw-landed-probe" targetRef="arm-merge">
828
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=landedVerdict = "evicted"</bpmn:conditionExpression>
829
+ </bpmn:sequenceFlow>
830
+ <bpmn:sequenceFlow id="f_m_landedStalled" sourceRef="gw-landed-probe" targetRef="merge-esc-landed" />
796
831
  <bpmn:sequenceFlow id="f_m_escLanded" sourceRef="merge-esc-landed" targetRef="gw-merge-escalated" />
797
832
  <bpmn:sequenceFlow id="f_m_evicted" sourceRef="wait-evicted" targetRef="arm-merge" />
798
833
  <bpmn:sequenceFlow id="f_m_gBlocked" name="blocked" sourceRef="gw-merge" targetRef="merge-esc-attempt" />
@@ -879,7 +914,7 @@
879
914
  <bpmndi:BPMNShape id="BPMNShape_gw-merge" bpmnElement="gw-merge" isMarkerVisible="true">
880
915
  <dc:Bounds x="1363" y="255" width="50" height="50" />
881
916
  <bpmndi:BPMNLabel>
882
- <dc:Bounds x="1362" y="350" width="53" height="28" />
917
+ <dc:Bounds x="1302" y="290" width="53" height="28" />
883
918
  </bpmndi:BPMNLabel>
884
919
  </bpmndi:BPMNShape>
885
920
  <bpmndi:BPMNShape id="BPMNShape_gw-merge-retry" bpmnElement="gw-merge-retry" isMarkerVisible="true">
@@ -909,16 +944,25 @@
909
944
  <bpmndi:BPMNShape id="BPMNShape_wait-landed-timeout" bpmnElement="wait-landed-timeout">
910
945
  <dc:Bounds x="1720" y="102" width="36" height="36" />
911
946
  <bpmndi:BPMNLabel>
912
- <dc:Bounds x="1596" y="143" width="84" height="56" />
947
+ <dc:Bounds x="1716" y="143" width="84" height="56" />
948
+ </bpmndi:BPMNLabel>
949
+ </bpmndi:BPMNShape>
950
+ <bpmndi:BPMNShape id="BPMNShape_landed-probe" bpmnElement="landed-probe">
951
+ <dc:Bounds x="1888" y="80" width="100" height="80" />
952
+ </bpmndi:BPMNShape>
953
+ <bpmndi:BPMNShape id="BPMNShape_gw-landed-probe" bpmnElement="gw-landed-probe" isMarkerVisible="true">
954
+ <dc:Bounds x="2088" y="95" width="50" height="50" />
955
+ <bpmndi:BPMNLabel>
956
+ <dc:Bounds x="2087" y="76" width="53" height="14" />
913
957
  </bpmndi:BPMNLabel>
914
958
  </bpmndi:BPMNShape>
915
959
  <bpmndi:BPMNShape id="BPMNShape_mark-merged" bpmnElement="mark-merged">
916
- <dc:Bounds x="1888" y="240" width="100" height="80" />
960
+ <dc:Bounds x="2238" y="240" width="100" height="80" />
917
961
  </bpmndi:BPMNShape>
918
962
  <bpmndi:BPMNShape id="BPMNShape_MergeEnd" bpmnElement="MergeEnd">
919
- <dc:Bounds x="2095" y="262" width="36" height="36" />
963
+ <dc:Bounds x="2445" y="262" width="36" height="36" />
920
964
  <bpmndi:BPMNLabel>
921
- <dc:Bounds x="2088" y="303" width="50" height="14" />
965
+ <dc:Bounds x="2438" y="303" width="50" height="14" />
922
966
  </bpmndi:BPMNLabel>
923
967
  </bpmndi:BPMNShape>
924
968
  <bpmndi:BPMNShape id="BPMNShape_MergeAbandoned" bpmnElement="MergeAbandoned">
@@ -937,19 +981,19 @@
937
981
  <dc:Bounds x="1138" y="1680" width="100" height="80" />
938
982
  </bpmndi:BPMNShape>
939
983
  <bpmndi:BPMNShape id="BPMNShape_gw-merge-escalated" bpmnElement="gw-merge-escalated" isMarkerVisible="true">
940
- <dc:Bounds x="2088" y="95" width="50" height="50" />
984
+ <dc:Bounds x="2438" y="95" width="50" height="50" />
941
985
  <bpmndi:BPMNLabel>
942
- <dc:Bounds x="2056" y="170" width="74" height="28" />
986
+ <dc:Bounds x="2406" y="150" width="74" height="28" />
943
987
  </bpmndi:BPMNLabel>
944
988
  </bpmndi:BPMNShape>
945
989
  <bpmndi:BPMNShape id="BPMNShape_merge-esc-landed" bpmnElement="merge-esc-landed">
946
- <dc:Bounds x="1888" y="80" width="100" height="80" />
990
+ <dc:Bounds x="2238" y="80" width="100" height="80" />
947
991
  </bpmndi:BPMNShape>
948
992
  <bpmndi:BPMNShape id="BPMNShape_wait-merge-answer" bpmnElement="wait-merge-answer">
949
- <dc:Bounds x="2238" y="80" width="100" height="80" />
993
+ <dc:Bounds x="2588" y="80" width="100" height="80" />
950
994
  </bpmndi:BPMNShape>
951
995
  <bpmndi:BPMNShape id="BPMNShape_record-merge-answer" bpmnElement="record-merge-answer">
952
- <dc:Bounds x="2438" y="80" width="100" height="80" />
996
+ <dc:Bounds x="2788" y="80" width="100" height="80" />
953
997
  </bpmndi:BPMNShape>
954
998
  <bpmndi:BPMNShape id="BPMNShape_gw-ci-fix" bpmnElement="gw-ci-fix" isMarkerVisible="true">
955
999
  <dc:Bounds x="1163" y="1055" width="50" height="50" />
@@ -1021,14 +1065,14 @@
1021
1065
  </bpmndi:BPMNEdge>
1022
1066
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gMerged" bpmnElement="f_m_gMerged">
1023
1067
  <di:waypoint x="1413" y="280" />
1024
- <di:waypoint x="1888" y="280" />
1068
+ <di:waypoint x="2238" y="280" />
1025
1069
  <bpmndi:BPMNLabel>
1026
- <dc:Bounds x="1721" y="258" width="49" height="14" />
1070
+ <dc:Bounds x="1866" y="258" width="49" height="14" />
1027
1071
  </bpmndi:BPMNLabel>
1028
1072
  </bpmndi:BPMNEdge>
1029
1073
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_done" bpmnElement="f_m_done">
1030
- <di:waypoint x="1988" y="280" />
1031
- <di:waypoint x="2095" y="280" />
1074
+ <di:waypoint x="2338" y="280" />
1075
+ <di:waypoint x="2445" y="280" />
1032
1076
  </bpmndi:BPMNEdge>
1033
1077
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_rearm" bpmnElement="f_ci_rearm">
1034
1078
  <di:waypoint x="1563" y="1265" />
@@ -1075,54 +1119,62 @@
1075
1119
  <di:waypoint x="1463" y="1060" />
1076
1120
  <di:waypoint x="1688" y="1060" />
1077
1121
  <bpmndi:BPMNLabel>
1078
- <dc:Bounds x="1391" y="749" width="67" height="28" />
1122
+ <dc:Bounds x="1468" y="829" width="67" height="28" />
1079
1123
  </bpmndi:BPMNLabel>
1080
1124
  </bpmndi:BPMNEdge>
1081
1125
  <bpmndi:BPMNEdge id="BPMNEdge_f_mr_giveup" bpmnElement="f_mr_giveup">
1082
1126
  <di:waypoint x="1588" y="760" />
1083
1127
  <di:waypoint x="1688" y="760" />
1084
1128
  <bpmndi:BPMNLabel>
1085
- <dc:Bounds x="1605" y="765" width="67" height="28" />
1129
+ <dc:Bounds x="1595" y="765" width="67" height="28" />
1086
1130
  </bpmndi:BPMNLabel>
1087
1131
  </bpmndi:BPMNEdge>
1088
1132
  <bpmndi:BPMNEdge id="BPMNEdge_f_eg_landed" bpmnElement="f_eg_landed">
1089
1133
  <di:waypoint x="1588" y="440" />
1090
1134
  <di:waypoint x="1720" y="440" />
1091
1135
  </bpmndi:BPMNEdge>
1136
+ <bpmndi:BPMNEdge id="BPMNEdge_f_m_landedStalled" bpmnElement="f_m_landedStalled">
1137
+ <di:waypoint x="2138" y="120" />
1138
+ <di:waypoint x="2238" y="120" />
1139
+ </bpmndi:BPMNEdge>
1140
+ <bpmndi:BPMNEdge id="BPMNEdge_f_m_escLanded" bpmnElement="f_m_escLanded">
1141
+ <di:waypoint x="2338" y="120" />
1142
+ <di:waypoint x="2438" y="120" />
1143
+ </bpmndi:BPMNEdge>
1092
1144
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_landed" bpmnElement="f_m_landed">
1093
1145
  <di:waypoint x="1756" y="440" />
1094
- <di:waypoint x="1938" y="440" />
1095
- <di:waypoint x="1938" y="320" />
1146
+ <di:waypoint x="2288" y="440" />
1147
+ <di:waypoint x="2288" y="320" />
1096
1148
  </bpmndi:BPMNEdge>
1097
1149
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escC" bpmnElement="f_m_escC">
1098
1150
  <di:waypoint x="1788" y="1080" />
1099
- <di:waypoint x="2288" y="1080" />
1100
- <di:waypoint x="2288" y="160" />
1151
+ <di:waypoint x="2638" y="1080" />
1152
+ <di:waypoint x="2638" y="160" />
1101
1153
  </bpmndi:BPMNEdge>
1102
1154
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escA" bpmnElement="f_m_escA">
1103
1155
  <di:waypoint x="1788" y="760" />
1104
- <di:waypoint x="2151" y="760" />
1105
- <di:waypoint x="2151" y="145" />
1106
- <di:waypoint x="2158" y="145" />
1107
- <di:waypoint x="2158" y="120" />
1108
- <di:waypoint x="2138" y="120" />
1156
+ <di:waypoint x="2501" y="760" />
1157
+ <di:waypoint x="2501" y="145" />
1158
+ <di:waypoint x="2508" y="145" />
1159
+ <di:waypoint x="2508" y="120" />
1160
+ <di:waypoint x="2488" y="120" />
1109
1161
  </bpmndi:BPMNEdge>
1110
1162
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escReenter" bpmnElement="f_m_escReenter">
1111
- <di:waypoint x="2113" y="95" />
1112
- <di:waypoint x="2113" y="60" />
1163
+ <di:waypoint x="2463" y="95" />
1164
+ <di:waypoint x="2463" y="60" />
1113
1165
  <di:waypoint x="402" y="60" />
1114
1166
  <di:waypoint x="402" y="240" />
1115
1167
  <bpmndi:BPMNLabel>
1116
- <dc:Bounds x="1221" y="13" width="74" height="42" />
1168
+ <dc:Bounds x="1396" y="13" width="74" height="42" />
1117
1169
  </bpmndi:BPMNLabel>
1118
1170
  </bpmndi:BPMNEdge>
1119
1171
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_answerRecord" bpmnElement="f_m_answerRecord">
1120
- <di:waypoint x="2338" y="120" />
1121
- <di:waypoint x="2438" y="120" />
1172
+ <di:waypoint x="2688" y="120" />
1173
+ <di:waypoint x="2788" y="120" />
1122
1174
  </bpmndi:BPMNEdge>
1123
1175
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_answer" bpmnElement="f_m_answer">
1124
- <di:waypoint x="2488" y="160" />
1125
- <di:waypoint x="2488" y="340" />
1176
+ <di:waypoint x="2838" y="160" />
1177
+ <di:waypoint x="2838" y="340" />
1126
1178
  <di:waypoint x="402" y="340" />
1127
1179
  <di:waypoint x="402" y="320" />
1128
1180
  </bpmndi:BPMNEdge>
@@ -1241,15 +1293,15 @@
1241
1293
  <di:waypoint x="1213" y="440" />
1242
1294
  <di:waypoint x="1233" y="440" />
1243
1295
  <di:waypoint x="1233" y="465" />
1244
- <di:waypoint x="2158" y="465" />
1245
- <di:waypoint x="2158" y="75" />
1246
- <di:waypoint x="2558" y="75" />
1247
- <di:waypoint x="2558" y="1125" />
1296
+ <di:waypoint x="2508" y="465" />
1297
+ <di:waypoint x="2508" y="75" />
1298
+ <di:waypoint x="2908" y="75" />
1299
+ <di:waypoint x="2908" y="1125" />
1248
1300
  <di:waypoint x="1318" y="1125" />
1249
1301
  <di:waypoint x="1318" y="1380" />
1250
1302
  <di:waypoint x="1338" y="1380" />
1251
1303
  <bpmndi:BPMNLabel>
1252
- <dc:Bounds x="2334" y="42" width="49" height="28" />
1304
+ <dc:Bounds x="2684" y="42" width="49" height="28" />
1253
1305
  </bpmndi:BPMNLabel>
1254
1306
  </bpmndi:BPMNEdge>
1255
1307
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gQueued" bpmnElement="f_m_gQueued">
@@ -1263,13 +1315,13 @@
1263
1315
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gRetry" bpmnElement="f_m_gRetry">
1264
1316
  <di:waypoint x="1388" y="255" />
1265
1317
  <di:waypoint x="1388" y="235" />
1266
- <di:waypoint x="2218" y="235" />
1267
- <di:waypoint x="2218" y="805" />
1318
+ <di:waypoint x="2568" y="235" />
1319
+ <di:waypoint x="2568" y="805" />
1268
1320
  <di:waypoint x="1538" y="805" />
1269
1321
  <di:waypoint x="1563" y="805" />
1270
1322
  <di:waypoint x="1563" y="785" />
1271
1323
  <bpmndi:BPMNLabel>
1272
- <dc:Bounds x="1852" y="772" width="78" height="28" />
1324
+ <dc:Bounds x="2485" y="766" width="78" height="28" />
1273
1325
  </bpmndi:BPMNLabel>
1274
1326
  </bpmndi:BPMNEdge>
1275
1327
  <bpmndi:BPMNEdge id="BPMNEdge_f_eg_evicted" bpmnElement="f_eg_evicted">
@@ -1286,30 +1338,26 @@
1286
1338
  <di:waypoint x="1700" y="120" />
1287
1339
  <di:waypoint x="1720" y="120" />
1288
1340
  </bpmndi:BPMNEdge>
1341
+ <bpmndi:BPMNEdge id="BPMNEdge_f_lp_landed" bpmnElement="f_lp_landed">
1342
+ <di:waypoint x="2113" y="145" />
1343
+ <di:waypoint x="2113" y="280" />
1344
+ <di:waypoint x="2238" y="280" />
1345
+ </bpmndi:BPMNEdge>
1289
1346
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gBlocked" bpmnElement="f_m_gBlocked">
1290
- <di:waypoint x="1388" y="255" />
1291
- <di:waypoint x="1388" y="82" />
1292
- <di:waypoint x="1776" y="82" />
1293
- <di:waypoint x="1776" y="165" />
1294
- <di:waypoint x="2218" y="165" />
1295
- <di:waypoint x="2218" y="638" />
1296
- <di:waypoint x="1668" y="638" />
1297
- <di:waypoint x="1668" y="740" />
1298
- <di:waypoint x="1688" y="740" />
1347
+ <di:waypoint x="1413" y="280" />
1348
+ <di:waypoint x="1668" y="280" />
1349
+ <di:waypoint x="1668" y="760" />
1350
+ <di:waypoint x="1688" y="760" />
1299
1351
  <bpmndi:BPMNLabel>
1300
- <dc:Bounds x="1932" y="616" width="53" height="14" />
1352
+ <dc:Bounds x="1610" y="412" width="53" height="14" />
1301
1353
  </bpmndi:BPMNLabel>
1302
1354
  </bpmndi:BPMNEdge>
1303
1355
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gAbandoned" bpmnElement="f_m_gAbandoned">
1304
- <di:waypoint x="1388" y="255" />
1305
- <di:waypoint x="1388" y="82" />
1306
- <di:waypoint x="1776" y="82" />
1307
- <di:waypoint x="1776" y="165" />
1308
- <di:waypoint x="2218" y="165" />
1309
- <di:waypoint x="2218" y="920" />
1310
- <di:waypoint x="1581" y="920" />
1356
+ <di:waypoint x="1388" y="305" />
1357
+ <di:waypoint x="1388" y="920" />
1358
+ <di:waypoint x="1545" y="920" />
1311
1359
  <bpmndi:BPMNLabel>
1312
- <dc:Bounds x="2131" y="849" width="82" height="28" />
1360
+ <dc:Bounds x="1301" y="599" width="82" height="28" />
1313
1361
  </bpmndi:BPMNLabel>
1314
1362
  </bpmndi:BPMNEdge>
1315
1363
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_waitDep" bpmnElement="f_ci_waitDep">
@@ -1343,25 +1391,25 @@
1343
1391
  <di:waypoint x="1438" y="1400" />
1344
1392
  <di:waypoint x="1538" y="1400" />
1345
1393
  </bpmndi:BPMNEdge>
1346
- <bpmndi:BPMNEdge id="BPMNEdge_f_m_landedStalled" bpmnElement="f_m_landedStalled">
1394
+ <bpmndi:BPMNEdge id="BPMNEdge_f_m_landedProbe" bpmnElement="f_m_landedProbe">
1347
1395
  <di:waypoint x="1756" y="120" />
1348
1396
  <di:waypoint x="1888" y="120" />
1349
1397
  </bpmndi:BPMNEdge>
1350
- <bpmndi:BPMNEdge id="BPMNEdge_f_m_escLanded" bpmnElement="f_m_escLanded">
1398
+ <bpmndi:BPMNEdge id="BPMNEdge_f_lp_gate" bpmnElement="f_lp_gate">
1351
1399
  <di:waypoint x="1988" y="120" />
1352
1400
  <di:waypoint x="2088" y="120" />
1353
1401
  </bpmndi:BPMNEdge>
1354
1402
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escT" bpmnElement="f_m_escT">
1355
1403
  <di:waypoint x="1238" y="1720" />
1356
- <di:waypoint x="2008" y="1720" />
1357
- <di:waypoint x="2008" y="145" />
1358
- <di:waypoint x="2068" y="145" />
1359
- <di:waypoint x="2068" y="120" />
1360
- <di:waypoint x="2088" y="120" />
1404
+ <di:waypoint x="2358" y="1720" />
1405
+ <di:waypoint x="2358" y="145" />
1406
+ <di:waypoint x="2418" y="145" />
1407
+ <di:waypoint x="2418" y="120" />
1408
+ <di:waypoint x="2438" y="120" />
1361
1409
  </bpmndi:BPMNEdge>
1362
1410
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escWait" bpmnElement="f_m_escWait">
1363
- <di:waypoint x="2138" y="120" />
1364
- <di:waypoint x="2238" y="120" />
1411
+ <di:waypoint x="2488" y="120" />
1412
+ <di:waypoint x="2588" y="120" />
1365
1413
  </bpmndi:BPMNEdge>
1366
1414
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_recheck" bpmnElement="f_ci_recheck">
1367
1415
  <di:waypoint x="1563" y="1265" />
@@ -1396,6 +1444,12 @@
1396
1444
  <dc:Bounds x="946" y="772" width="49" height="28" />
1397
1445
  </bpmndi:BPMNLabel>
1398
1446
  </bpmndi:BPMNEdge>
1447
+ <bpmndi:BPMNEdge id="BPMNEdge_f_lp_evicted" bpmnElement="f_lp_evicted">
1448
+ <di:waypoint x="2113" y="145" />
1449
+ <di:waypoint x="2113" y="340" />
1450
+ <di:waypoint x="402" y="340" />
1451
+ <di:waypoint x="402" y="320" />
1452
+ </bpmndi:BPMNEdge>
1399
1453
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_evicted" bpmnElement="f_m_evicted">
1400
1454
  <di:waypoint x="1738" y="618" />
1401
1455
  <di:waypoint x="1738" y="638" />
@@ -0,0 +1,23 @@
1
+ // Test-only: apply the poller's compare-and-set status flip (`flipToMergingThenPublish`, app/service.ts)
2
+ // — `UPDATE <t> SET status = ?, updated_at = ? WHERE pr_key = ? AND status = ?` — against an
3
+ // in-memory store, so hand-rolled `DataLayer` fakes model the real conditional write (and its
4
+ // lost-race `changed: 0`) instead of no-op'ing it. Returns `null` for any other statement so the
5
+ // caller can fall through to its own handling (or throw loudly).
6
+ const CAS =
7
+ /^\s*UPDATE\s+"?(\w+)"?\s+SET\s+"?(\w+)"?\s*=\s*\?\s*,\s*"?(\w+)"?\s*=\s*\?\s+WHERE\s+"?(\w+)"?\s*=\s*\?\s+AND\s+"?(\w+)"?\s*=\s*\?\s*$/i;
8
+
9
+ // biome-ignore lint/suspicious/noExplicitAny: test-only fake over dynamic row shapes.
10
+ export function memStatusCas(stores: Record<string, any[]>, sql: string, params: unknown[]): { changed: number } | null {
11
+ const m = sql.match(CAS);
12
+ if (!m) return null;
13
+ const [, table, setA, setB, keyCol, condCol] = m;
14
+ let changed = 0;
15
+ for (const r of (stores[table] ??= [])) {
16
+ if (r[keyCol] === params[2] && r[condCol] === params[3]) {
17
+ r[setA] = params[0];
18
+ r[setB] = params[1];
19
+ changed++;
20
+ }
21
+ }
22
+ return { changed };
23
+ }
@@ -0,0 +1,41 @@
1
+ import { test } from "node:test";
2
+ import { assertEquals } from "#test-assert";
3
+ import type { PrState } from "../../app/github.ts";
4
+ import { probeLanded } from "./worker.ts";
5
+
6
+ const base: PrState = {
7
+ merged: false,
8
+ state: "open",
9
+ mergeStateStatus: "BLOCKED",
10
+ failingChecks: 0,
11
+ failingCheckNames: [],
12
+ totalChecks: 0,
13
+ presentCheckNames: [],
14
+ pendingCheckNames: [],
15
+ checkConclusions: {},
16
+ isDraft: false,
17
+ headRefOid: null,
18
+ mergeQueueEntry: null,
19
+ unresolvedThreads: -1,
20
+ };
21
+ const fake = (st: PrState | null, fail = false) => async () => {
22
+ if (fail) throw new Error("boom");
23
+ return st;
24
+ };
25
+
26
+ test("landed-probe: a MERGED PR reads landed (#933)", async () => {
27
+ assertEquals(await probeLanded("a/b", 1, "", fake({ ...base, merged: true, state: "merged" })), "landed");
28
+ });
29
+ test("landed-probe: still queued reads waiting", async () => {
30
+ assertEquals(await probeLanded("a/b", 1, "", fake(base)), "waiting");
31
+ });
32
+ test("landed-probe: dropped from the native queue reads evicted", async () => {
33
+ assertEquals(await probeLanded("a/b", 1, "", fake({ ...base, mergeQueueEntry: false })), "evicted");
34
+ });
35
+ test("landed-probe: closed unmerged reads evicted", async () => {
36
+ assertEquals(await probeLanded("a/b", 1, "", fake({ ...base, state: "closed" })), "evicted");
37
+ });
38
+ test("landed-probe: unreadable state is conservative (waiting)", async () => {
39
+ assertEquals(await probeLanded("a/b", 1, "", fake(null)), "waiting");
40
+ assertEquals(await probeLanded("a/b", 1, "", fake(null, true)), "waiting");
41
+ });
@@ -0,0 +1,43 @@
1
+ // pr.landed-probe — the merge-queue landing timer fired (issue #933). Before escalating "did not
2
+ // land", re-read ground truth: the queue may have landed the PR after the poller's last `queued`
3
+ // pass but before (or exactly as) the `landedWaitTimeout` timer won the `eg-landed` race — e.g. a
4
+ // slow Mergify batch that merges right around the timeout, or a poller pass that errored/stalled
5
+ // on this PR. Escalating a landed PR forces a pointless human "retry", so the timeout arm routes
6
+ // through this probe and `gw-landed-probe` instead of straight to `merge-esc-landed`:
7
+ // • landed → mark-merged (never escalate a landed PR)
8
+ // • evicted → arm-merge (re-run the mergeable gate, same as a `merge-evicted` message)
9
+ // • waiting → merge-esc-landed (genuinely never landed — the #556 backstop)
10
+ // The verdict is derived by the poller's own canonical classifier (`queuedVerdict`) so the probe
11
+ // and the poll pass can never drift. An unreadable PR reads as `waiting` (escalate — conservative).
12
+ import type { AppJobHandler } from "@nanobpm/urban";
13
+ import { fetchPrState } from "../../app/github.ts";
14
+ import { queuedVerdict } from "../../app/service.ts";
15
+ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
16
+
17
+ type In = WorkerInputs["pr.landed-probe"];
18
+
19
+ interface Out extends Record<string, unknown> {
20
+ landedVerdict: "landed" | "evicted" | "waiting";
21
+ }
22
+
23
+ export async function probeLanded(
24
+ repo: string,
25
+ prNumber: number | string,
26
+ token: string,
27
+ fetch: typeof fetchPrState = fetchPrState,
28
+ ): Promise<Out["landedVerdict"]> {
29
+ const st = await fetch(repo, prNumber, token, { withMergeQueue: true }).catch(() => null);
30
+ if (st === null) return "waiting";
31
+ // A PR closed WITHOUT merging can never land; re-arm so the merge poller abandons it.
32
+ if (!st.merged && st.state === "closed") return "evicted";
33
+ return queuedVerdict(st);
34
+ }
35
+
36
+ const handler: AppJobHandler<In, Out> = async (job, app) => {
37
+ const { repo, prNumber, prKey } = job.variables;
38
+ const landedVerdict = await probeLanded(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
39
+ app.log.info("landed-probe: landing timeout fired — re-read PR state", { prKey, landedVerdict });
40
+ return { landedVerdict };
41
+ };
42
+
43
+ export default handler;