@nanobpm/nano-workforce 0.39.1 → 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 +7 -0
- package/app/service.ts +9 -3
- 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,10 @@
|
|
|
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
|
+
|
|
1
8
|
## [0.39.1](https://github.com/nanobpm/nano-workforce/compare/v0.39.0...v0.39.1) (2026-08-10)
|
|
2
9
|
|
|
3
10
|
|
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/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
|