@nanobpm/nano-workforce 0.39.0 → 0.39.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.39.2](https://github.com/nanobpm/nano-workforce/compare/v0.39.1...v0.39.2) (2026-08-11)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **merge:** recover PRs merged out-of-band instead of wedging in "merging" ([#112](https://github.com/nanobpm/nano-workforce/issues/112)) ([037d8aa](https://github.com/nanobpm/nano-workforce/commit/037d8aa2538b85f9c1b43927378f3578530a924e)), closes [Magikcraft/nano-bpm#723](https://github.com/Magikcraft/nano-bpm/issues/723)
7
+
8
+ ## [0.39.1](https://github.com/nanobpm/nano-workforce/compare/v0.39.0...v0.39.1) (2026-08-10)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **openapi:** use 3.0 nullable idiom instead of 3.1 type-arrays ([#111](https://github.com/nanobpm/nano-workforce/issues/111)) ([28f40c8](https://github.com/nanobpm/nano-workforce/commit/28f40c891d48043ef7537158466bafdfb79b9792))
14
+
1
15
  # [0.39.0](https://github.com/nanobpm/nano-workforce/compare/v0.38.0...v0.39.0) (2026-08-10)
2
16
 
3
17
 
package/app/service.ts CHANGED
@@ -668,11 +668,17 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
668
668
  const st = await fetchPrState(repo, number, token);
669
669
  if (st === null) continue; // no transport → skip this PR (others may still advance)
670
670
  if (st.merged) {
671
- // Landed out-of-band (someone merged it) skip straight to done.
671
+ // Landed out-of-band (a maintainer clicked Merge, a mergify queue merged it, etc.). The
672
+ // instance is parked at `wait-mergeable`, which subscribes to `merge-ready` — NOT
673
+ // `merge-landed` (that catch, `wait-landed`, only exists later, after we enqueue). Publishing
674
+ // `merge-landed` here has no subscription to correlate to, so the engine drops it and the PR
675
+ // wedges forever in the transient `merging` status (which no poller branch re-scans).
676
+ // Publish `merge-ready` with a `ready` verdict instead: it routes through `gw-mergeable` to
677
+ // `attempt-merge`, whose idempotent already-merged check completes the loop (`mark-merged`).
672
678
  await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
673
- name: "merge-landed",
679
+ name: "merge-ready",
674
680
  correlationKey: prKey,
675
- variables: {},
681
+ variables: { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
676
682
  });
677
683
  console.log(`[poller] already merged -> ${prKey}`);
678
684
  continue;
package/openapi.yaml CHANGED
@@ -63,36 +63,30 @@ components:
63
63
  url:
64
64
  type: string
65
65
  title:
66
- type:
67
- - string
68
- - "null"
66
+ type: string
67
+ nullable: true
69
68
  status:
70
69
  type: string
71
70
  round:
72
71
  type: integer
73
72
  processKey:
74
- type:
75
- - string
76
- - "null"
73
+ type: string
74
+ nullable: true
77
75
  description: The engine process instance key; also the keyField the pages processExplorer link uses.
78
76
  waitingSince:
79
- type:
80
- - string
81
- - "null"
77
+ type: string
78
+ nullable: true
82
79
  openEscalation:
83
- type:
84
- - string
85
- - "null"
80
+ type: string
81
+ nullable: true
86
82
  updatedAt:
87
83
  type: string
88
84
  activeWorker:
89
- type:
90
- - string
91
- - "null"
85
+ type: string
86
+ nullable: true
92
87
  leaseUntil:
93
- type:
94
- - string
95
- - "null"
88
+ type: string
89
+ nullable: true
96
90
  ActivePrList:
97
91
  type: object
98
92
  required:
@@ -123,27 +117,22 @@ components:
123
117
  name:
124
118
  type: string
125
119
  version:
126
- type:
127
- - string
128
- - "null"
120
+ type: string
121
+ nullable: true
129
122
  urbanVersion:
130
- type:
131
- - string
132
- - "null"
123
+ type: string
124
+ nullable: true
133
125
  gitSha:
134
- type:
135
- - string
136
- - "null"
126
+ type: string
127
+ nullable: true
137
128
  gitBranch:
138
- type:
139
- - string
140
- - "null"
129
+ type: string
130
+ nullable: true
141
131
  runtime:
142
132
  type: string
143
133
  pid:
144
- type:
145
- - integer
146
- - "null"
134
+ type: integer
135
+ nullable: true
147
136
  startedAt:
148
137
  type: string
149
138
  uptimeSeconds:
@@ -156,9 +145,8 @@ components:
156
145
  prKey:
157
146
  type: string
158
147
  processKey:
159
- type:
160
- - string
161
- - "null"
148
+ type: string
149
+ nullable: true
162
150
  description: The started convergence-loop instance key (null if the engine did not return one).
163
151
  alreadyRunning:
164
152
  type: boolean
@@ -172,9 +160,8 @@ components:
172
160
  planKey:
173
161
  type: string
174
162
  processKey:
175
- type:
176
- - string
177
- - "null"
163
+ type: string
164
+ nullable: true
178
165
  alreadyRunning:
179
166
  type: boolean
180
167
  description: True when a non-terminal plan for this issue already exists; no new instance was started.
@@ -257,9 +244,8 @@ components:
257
244
  body:
258
245
  type: string
259
246
  wave:
260
- type:
261
- - integer
262
- - "null"
247
+ type: integer
248
+ nullable: true
263
249
  created_at:
264
250
  type: string
265
251
  BlackboardPage:
@@ -276,9 +262,8 @@ components:
276
262
  items:
277
263
  $ref: "#/components/schemas/BlackboardEntry"
278
264
  cursor:
279
- type:
280
- - integer
281
- - "null"
265
+ type: integer
266
+ nullable: true
282
267
  description: The plan's current head id; pass it back as `since` on the next poll (Tier 2).
283
268
  BlackboardAppendRequest:
284
269
  type: object
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.39.0",
3
+ "version": "0.39.2",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -0,0 +1,96 @@
1
+ // Regression for the out-of-band merge wedge (Magikcraft/nano-bpm#723): when a PR is merged
2
+ // independently of the process (a maintainer clicks Merge, a mergify queue lands it), the poller
3
+ // routes the merge-loop instance back through `attempt-merge`. This worker must detect the
4
+ // already-merged state and complete the loop directly — NOT re-run the land protocol, which would post a
5
+ // spurious `@mergifyio queue` comment (mergify-queue repos) or a redundant merge call. Forces the
6
+ // token transport and stubs `globalThis.fetch` so the single-PR GET reports `merged: true`.
7
+ import { test } from "node:test";
8
+ import { assertEquals } from "#test-assert";
9
+ import handler from "./worker.ts";
10
+
11
+ function fakeApp() {
12
+ const stores: Record<string, Record<string, unknown>[]> = {
13
+ pull_requests: [],
14
+ merges: [],
15
+ };
16
+ return {
17
+ app: {
18
+ data: {
19
+ table(name: string, key: string) {
20
+ const store = (stores[name] ??= []);
21
+ return {
22
+ get: (k: any) => Promise.resolve(store.find((r) => r[key] === k)),
23
+ find: (q: any) =>
24
+ Promise.resolve(
25
+ store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)),
26
+ ),
27
+ insert: (row: any) => {
28
+ store.push(row);
29
+ return Promise.resolve(store.length);
30
+ },
31
+ update: (k: any, patch: any) => {
32
+ const row = store.find((r) => r[key] === k);
33
+ if (row) Object.assign(row, patch);
34
+ return Promise.resolve(row);
35
+ },
36
+ };
37
+ },
38
+ },
39
+ log: () => undefined,
40
+ engine: {},
41
+ } as any,
42
+ stores,
43
+ };
44
+ }
45
+
46
+ function withMergedPr(run: (calls: string[]) => Promise<void>): Promise<void> {
47
+ const oldTransport = process.env["NANO_PR_GITHUB_TRANSPORT"];
48
+ const oldToken = process.env["GITHUB_TOKEN"];
49
+ const oldFetch = globalThis.fetch;
50
+ const calls: string[] = [];
51
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
52
+ process.env["GITHUB_TOKEN"] = "test-token";
53
+ globalThis.fetch = ((input: string | URL | Request) => {
54
+ const url = String(input);
55
+ calls.push(url);
56
+ // Single-PR GET → report the PR as already merged.
57
+ if (/\/pulls\/\d+$/.test(url)) {
58
+ return Promise.resolve(new Response(JSON.stringify({ merged: true, mergeable_state: "clean" })));
59
+ }
60
+ return Promise.resolve(new Response("not found", { status: 404 }));
61
+ }) as typeof fetch;
62
+ return run(calls).finally(() => {
63
+ if (oldTransport == null) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
64
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = oldTransport;
65
+ if (oldToken == null) delete process.env["GITHUB_TOKEN"];
66
+ else process.env["GITHUB_TOKEN"] = oldToken;
67
+ globalThis.fetch = oldFetch;
68
+ });
69
+ }
70
+
71
+ test("pr.merge short-circuits an already-merged PR without re-running the land protocol", async () => {
72
+ await withMergedPr(async (calls) => {
73
+ const { app, stores } = fakeApp();
74
+ const out = (await handler(
75
+ {
76
+ variables: {
77
+ prKey: "Magikcraft/nano-bpm#723",
78
+ repo: "Magikcraft/nano-bpm",
79
+ prNumber: 723,
80
+ },
81
+ } as any,
82
+ app,
83
+ )) as Record<string, unknown>;
84
+
85
+ // Completes the loop directly.
86
+ assertEquals(out, { mergeStatus: "merged" });
87
+
88
+ // Records exactly one audit row, tagged as the idempotent already-merged path.
89
+ assertEquals(stores.merges.length, 1);
90
+ assertEquals(stores.merges[0].outcome, "merged");
91
+ assertEquals(stores.merges[0].method, "already-merged");
92
+
93
+ // Never posts an enqueue comment or issues a merge call (only the read GET happened).
94
+ assertEquals(calls.some((u) => /comments|merge$/.test(u)), false);
95
+ });
96
+ });
@@ -11,7 +11,7 @@
11
11
  import type { AppJobHandler } from "@nanobpm/urban";
12
12
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
13
13
  import { checkBaseTarget } from "../../app/baseGuard.ts";
14
- import { enqueueViaComment, mergePr } from "../../app/github.ts";
14
+ import { enqueueViaComment, fetchPrState, mergePr } from "../../app/github.ts";
15
15
  import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
16
16
  import { ensurePr, MERGE_ADMIN, MERGE_METHOD } from "../../app/service.ts";
17
17
 
@@ -51,6 +51,25 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
51
51
  abandonToken: abandonTokenFromUrl(abandonUrl),
52
52
  });
53
53
 
54
+ // Idempotent already-merged short-circuit. When the poller routes an out-of-band-merged PR back
55
+ // through `attempt-merge` (service.ts publishes `merge-ready` on the `waiting_merge` out-of-band
56
+ // branch), the PR is already landed on GitHub. Re-running the land protocol would post a spurious
57
+ // `@mergifyio queue` comment (mergify-queue repos) or a redundant merge call, so detect the merged
58
+ // state first and complete the loop directly. Runs AFTER ensurePr so the `merges` audit row has its
59
+ // FK parent, and BEFORE the base-guard/protocol logic. Best-effort: a transport hiccup falls through
60
+ // to the normal path rather than blocking a genuine merge.
61
+ const pre = await fetchPrState(repo, prNumber, token).catch(() => null);
62
+ if (pre?.merged) {
63
+ await app.data.table("merges", "id").insert({
64
+ pr_key: prKey,
65
+ outcome: "merged",
66
+ method: "already-merged",
67
+ detail: "PR was already merged on GitHub (landed out-of-band)",
68
+ at: now,
69
+ });
70
+ return { mergeStatus: "merged" };
71
+ }
72
+
54
73
  // Dead-end-base guard (#60): never land a PR into a base branch that has itself already merged
55
74
  // to the default branch — the merge would land into a dead branch and never reach `main`.
56
75
  // GitHub only auto-retargets a PR when its base is *deleted* on merge; a merged-but-undeleted