@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 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 (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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.39.1",
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