@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 +14 -0
- package/app/service.ts +9 -3
- package/openapi.yaml +30 -45
- package/package.json +1 -1
- package/workers/merge/worker.test.ts +96 -0
- package/workers/merge/worker.ts +20 -1
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 (
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
- "null"
|
|
77
|
+
type: string
|
|
78
|
+
nullable: true
|
|
82
79
|
openEscalation:
|
|
83
|
-
type:
|
|
84
|
-
|
|
85
|
-
- "null"
|
|
80
|
+
type: string
|
|
81
|
+
nullable: true
|
|
86
82
|
updatedAt:
|
|
87
83
|
type: string
|
|
88
84
|
activeWorker:
|
|
89
|
-
type:
|
|
90
|
-
|
|
91
|
-
- "null"
|
|
85
|
+
type: string
|
|
86
|
+
nullable: true
|
|
92
87
|
leaseUntil:
|
|
93
|
-
type:
|
|
94
|
-
|
|
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
|
-
|
|
128
|
-
- "null"
|
|
120
|
+
type: string
|
|
121
|
+
nullable: true
|
|
129
122
|
urbanVersion:
|
|
130
|
-
type:
|
|
131
|
-
|
|
132
|
-
- "null"
|
|
123
|
+
type: string
|
|
124
|
+
nullable: true
|
|
133
125
|
gitSha:
|
|
134
|
-
type:
|
|
135
|
-
|
|
136
|
-
- "null"
|
|
126
|
+
type: string
|
|
127
|
+
nullable: true
|
|
137
128
|
gitBranch:
|
|
138
|
-
type:
|
|
139
|
-
|
|
140
|
-
- "null"
|
|
129
|
+
type: string
|
|
130
|
+
nullable: true
|
|
141
131
|
runtime:
|
|
142
132
|
type: string
|
|
143
133
|
pid:
|
|
144
|
-
type:
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
});
|
package/workers/merge/worker.ts
CHANGED
|
@@ -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
|