@nanobpm/nano-workforce 0.39.3 → 0.40.0

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.40.0](https://github.com/nanobpm/nano-workforce/compare/v0.39.3...v0.40.0) (2026-08-11)
2
+
3
+
4
+ ### Features
5
+
6
+ * **convergence:** per-request convergeOnly override to skip the merge-loop ([#115](https://github.com/nanobpm/nano-workforce/issues/115)) ([8840b23](https://github.com/nanobpm/nano-workforce/commit/8840b23e97ed56aaf43df20d3f9a97b00b7e865d))
7
+
1
8
  ## [0.39.3](https://github.com/nanobpm/nano-workforce/compare/v0.39.2...v0.39.3) (2026-08-11)
2
9
 
3
10
 
package/README.md CHANGED
@@ -210,6 +210,10 @@ branch before escalating (`NANO_PR_MAX_CI_FIX_ROUNDS`, default 3). Conflicts, an
210
210
  exhausted budget, or an agent that can't fix the build escalate to a human; answer in
211
211
  the UI and the process re-arms and retries.
212
212
 
213
+ A single submission can pin **review-only** regardless of the global default by
214
+ passing `convergeOnly: true` on the `start/convergence-loop` request — the PR stops at
215
+ `converged` and is never handed to `merge-loop`, even with `NANO_PR_AUTO_MERGE` on.
216
+
213
217
  ### Fleet mode: hand it an issue (plan → implement → converge)
214
218
 
215
219
  ```
@@ -244,7 +248,7 @@ curl -sS -X POST http://localhost:3000/app/api/actions/start/plan-fanout \
244
248
  | `NANO_PR_POLL_MS` | `60000` | review-ready poll interval |
245
249
  | `NANO_PR_MAX_ROUNDS` | `20` | default cap: escalate after N rounds (per-submit override via the form / the `maxRounds` field on `start/convergence-loop`; clamped 1–100) |
246
250
  | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret for the `POST /app/api/hooks/feature-answer` webhook operation (`X-Hook-Secret`); unset = open |
247
- | `NANO_PR_AUTO_MERGE` | `1` | after convergence, run the merge stage; `0` = stop at `converged` (review-only) |
251
+ | `NANO_PR_AUTO_MERGE` | `1` | after convergence, run the merge stage; `0` = stop at `converged` (review-only). Per-submit override via the `convergeOnly` field on `start/convergence-loop` (`true` forces review-only for that PR) |
248
252
  | `NANO_PR_MERGE_METHOD` | `squash` | merge method: `squash`, `merge`, or `rebase` |
249
253
  | `NANO_PR_MERGE_ADMIN` | `0` | pass `--admin` to override failing non-required checks (use with care) |
250
254
  | `NANO_PR_MAX_CI_FIX_ROUNDS` | `3` | max `senior:fix-ci` attempts to green a `blocked` PR before escalating; `0` disables (escalate immediately), clamped 0–20 |
package/SPEC.md CHANGED
@@ -336,6 +336,11 @@ same `prKey`, sharing the datasource and poller. It merges the PR, honouring
336
336
  merge-queue branches and cross-PR dependencies, and reuses the review stage's
337
337
  escalation machinery for anything it can't resolve autonomously.
338
338
 
339
+ A per-submit `convergeOnly: true` on the `start/convergence-loop` request pins that PR
340
+ to review-only regardless of the global default: `pr.finalize` reads the flag off the
341
+ instance and rests the PR at `converged` without starting `merge-loop`. The flag only
342
+ ever narrows (it never forces the merge stage on when `NANO_PR_AUTO_MERGE` is off).
343
+
339
344
  Flow:
340
345
 
341
346
  ```
@@ -396,7 +401,7 @@ queries skip (`merging`), so a slow pass can't double-signal.
396
401
  | `NANO_PR_POLL_MS` | 60000 | poll interval |
397
402
  | `NANO_PR_MAX_ROUNDS` | 20 | default round cap (per-submit `maxRounds` override, clamped 1–100) |
398
403
  | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret for the `/app/api/hooks/feature-answer` webhook operation (`X-Hook-Secret`) |
399
- | `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only) |
404
+ | `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only; per-submit `convergeOnly: true` override) |
400
405
  | `NANO_PR_MERGE_METHOD` | squash | `squash` \| `merge` \| `rebase` |
401
406
  | `NANO_PR_MERGE_ADMIN` | 0 | pass `--admin` on merge |
402
407
  | `NANO_PR_REVIEW_WAIT_TIMEOUT` | PT20M | ISO-8601 wait before a stalled review escalates (timer arm of the `wait-review` event-based gateway); malformed → default |
@@ -281,3 +281,53 @@ test("submitPr stringifies a numeric processInstanceKey (contract: string | null
281
281
  assertEquals(pr.process_key, "2251799813685249");
282
282
  });
283
283
  });
284
+
285
+ // Per-request review-only override: `submitPr` carries `convergeOnly` onto the convergence
286
+ // instance so `pr.finalize` can stop at `converged` without handing off to the merge-loop. Default
287
+ // false (so the global auto-merge default governs); true when the caller pins review-only.
288
+ function captureConvergeOnly() {
289
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
290
+ pull_requests: { rows: [], key: "pr_key" },
291
+ escalations: { rows: [], key: "id" },
292
+ pr_dependencies: { rows: [], key: "pr_key" },
293
+ };
294
+ const data = {
295
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
296
+ } as any;
297
+ let captured: unknown;
298
+ const engine = {
299
+ createInstance: (req: { variables?: Record<string, unknown> }) => {
300
+ captured = req.variables?.convergeOnly;
301
+ return Promise.resolve({ processInstanceKey: "PI-1" });
302
+ },
303
+ } as any;
304
+ return { data, engine, get: () => captured };
305
+ }
306
+
307
+ test("submitPr threads convergeOnly=true onto the instance as a process variable", async () => {
308
+ await withGithubOff(async () => {
309
+ const { data, engine, get } = captureConvergeOnly();
310
+ await submitPr(
311
+ data,
312
+ engine,
313
+ { repo: "owner/repo", number: 8, url: "https://github.com/owner/repo/pull/8", prKey: "owner/repo#8" },
314
+ [],
315
+ 20,
316
+ true,
317
+ );
318
+ assertEquals(get(), true);
319
+ });
320
+ });
321
+
322
+ test("submitPr defaults convergeOnly to false so the global auto-merge default governs", async () => {
323
+ await withGithubOff(async () => {
324
+ const { data, engine, get } = captureConvergeOnly();
325
+ await submitPr(data, engine, {
326
+ repo: "owner/repo",
327
+ number: 9,
328
+ url: "https://github.com/owner/repo/pull/9",
329
+ prKey: "owner/repo#9",
330
+ });
331
+ assertEquals(get(), false);
332
+ });
333
+ });
package/app/service.ts CHANGED
@@ -285,6 +285,7 @@ export async function submitPr(
285
285
  parsed: ParsedPr,
286
286
  dependsOn: string[] = [],
287
287
  maxRounds: number = MAX_ROUNDS,
288
+ convergeOnly = false,
288
289
  ) {
289
290
  const table = prs(data);
290
291
  const existing = await table.get(parsed.prKey);
@@ -365,6 +366,10 @@ export async function submitPr(
365
366
  round: 1,
366
367
  maxRounds: clampRounds(maxRounds, MAX_ROUNDS),
367
368
  reviewWaitTimeout: REVIEW_WAIT_TIMEOUT,
369
+ // Per-request review-only override: carried on the instance so `pr.finalize` can stop at
370
+ // `converged` for this PR without handing off to the merge-loop, independent of the global
371
+ // NANO_PR_AUTO_MERGE default. Only ever narrows (never forces merge on when auto-merge is off).
372
+ convergeOnly,
368
373
  // Cooperative abandon check (#76): the capability URL + the abort brief appended to the
369
374
  // review-round agent's prompt, so it can stop before pushing if the run is cancelled.
370
375
  abandonUrl: abUrl,
package/openapi.yaml CHANGED
@@ -189,6 +189,11 @@ components:
189
189
  type: integer
190
190
  minimum: 1
191
191
  description: Values above 100 are accepted and clamped to 100 by the delegate.
192
+ convergeOnly:
193
+ type: boolean
194
+ description: When true, run convergence only and stop at `converged` — the PR is never
195
+ handed to the merge-loop even if auto-merge is on globally (`NANO_PR_AUTO_MERGE`). A
196
+ per-request review-only override; defaults to false (the global auto-merge default applies).
192
197
  MessageResult:
193
198
  type: object
194
199
  description: The result of publishing a message / answering an escalation. Shape varies by message
@@ -29,6 +29,75 @@ test("startConvergenceLoop → 400 on an unparseable PR reference", async () =>
29
29
  assertEquals(typeof r.body.error, "string");
30
30
  });
31
31
 
32
+ // The delegate forwards a per-request review-only override to `submitPr`, coercing strictly: only a
33
+ // JSON `true` enables convergence-only (a stray string/other value is NOT truthy-coerced). Drives the
34
+ // real delegate → submitPr against an in-memory app and captures the `convergeOnly` process variable.
35
+ function captureApp() {
36
+ const rows: Record<string, unknown>[] = [];
37
+ let captured: unknown;
38
+ const data = {
39
+ table: (_name: string, key: string) => ({
40
+ get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
41
+ find: (q: Record<string, unknown>) =>
42
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
43
+ insert: (r: Record<string, unknown>) => {
44
+ rows.push(r);
45
+ return Promise.resolve(r);
46
+ },
47
+ update: (k: unknown, patch: Record<string, unknown>) => {
48
+ const row = rows.find((r) => r[key] === k);
49
+ if (row) Object.assign(row, patch);
50
+ return Promise.resolve(row);
51
+ },
52
+ delete: (k: unknown) => {
53
+ const i = rows.findIndex((r) => r[key] === k);
54
+ if (i >= 0) rows.splice(i, 1);
55
+ return Promise.resolve();
56
+ },
57
+ }),
58
+ };
59
+ const engine = {
60
+ createInstance: (req: { variables?: Record<string, unknown> }) => {
61
+ captured = req.variables?.convergeOnly;
62
+ return Promise.resolve({ processInstanceKey: "PI-1" });
63
+ },
64
+ };
65
+ return { app: { data, engine } as any as AppApi, get: () => captured };
66
+ }
67
+
68
+ function withGithubOff(run: () => Promise<void>): Promise<void> {
69
+ const prev = process.env["NANO_PR_GITHUB_TRANSPORT"];
70
+ const prevTok = process.env["GITHUB_TOKEN"];
71
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token"; // no token → meta fetch is skipped
72
+ delete process.env["GITHUB_TOKEN"];
73
+ return run().finally(() => {
74
+ if (prev !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prev;
75
+ else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
76
+ if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
77
+ });
78
+ }
79
+
80
+ test("startConvergenceLoop forwards convergeOnly:true to the loop", async () => {
81
+ await withGithubOff(async () => {
82
+ const { app: capApp, get } = captureApp();
83
+ const res = await startConvergenceLoop(input({ pr: "owner/repo#8", convergeOnly: true }), capApp);
84
+ assertEquals((res as any).status, 202);
85
+ assertEquals(get(), true);
86
+ });
87
+ });
88
+
89
+ test("startConvergenceLoop defaults convergeOnly to false and does not truthy-coerce a non-boolean", async () => {
90
+ await withGithubOff(async () => {
91
+ const omitted = captureApp();
92
+ await startConvergenceLoop(input({ pr: "owner/repo#9" }), omitted.app);
93
+ assertEquals(omitted.get(), false);
94
+
95
+ const stringy = captureApp();
96
+ await startConvergenceLoop(input({ pr: "owner/repo#10", convergeOnly: "true" }), stringy.app);
97
+ assertEquals(stringy.get(), false);
98
+ });
99
+ });
100
+
32
101
  test("startPlanFanout → 400 on an unparseable issue reference", async () => {
33
102
  const res = await startPlanFanout(input({ issue: "" }), app);
34
103
  const r = res as any;
@@ -3,11 +3,12 @@
3
3
  // external webhook relay, a CI job, and Swagger all POST here. Parse the PR reference and
4
4
  // register/refresh the PR aggregate (idempotent on prKey) before starting the loop.
5
5
  //
6
- // The request body is FLAT (`{ pr | url, dependsOn?, maxRounds? }`), not wrapped in a `variables`
7
- // envelope: this is a purpose-built operation, not a generic engine "start process" call, so it does
8
- // not leak the engine's variable-map concept to callers. The runtime validates the body against
9
- // openapi.yaml; this delegate keeps the PR-parse guard because the reference format (owner/repo#123
10
- // or a URL) is app logic, not something the JSON schema can express — an unparseable reference is a 400.
6
+ // The request body is FLAT (`{ pr | url, dependsOn?, maxRounds?, convergeOnly? }`), not wrapped in a
7
+ // `variables` envelope: this is a purpose-built operation, not a generic engine "start process" call,
8
+ // so it does not leak the engine's variable-map concept to callers. The runtime validates the body
9
+ // against openapi.yaml; this delegate keeps the PR-parse guard because the reference format
10
+ // (owner/repo#123 or a URL) is app logic, not something the JSON schema can express — an unparseable
11
+ // reference is a 400.
11
12
 
12
13
  import { clampRounds, MAX_ROUNDS, parsePr, submitPr } from "../app/service.ts";
13
14
  import { defineOperation } from "../nano-generated/operations.ts";
@@ -21,5 +22,11 @@ export default defineOperation("startConvergenceLoop", async ({ body }, app) =>
21
22
  }
22
23
  const dependsOn = Array.isArray(b.dependsOn) ? b.dependsOn.map((d) => String(d)) : [];
23
24
  const maxRounds = clampRounds(b.maxRounds, MAX_ROUNDS);
24
- return { status: 202, body: await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds) };
25
+ // Per-request review-only override: when true the PR stops at `converged` and is never
26
+ // handed to the merge-loop, regardless of the global NANO_PR_AUTO_MERGE default.
27
+ const convergeOnly = b.convergeOnly === true;
28
+ return {
29
+ status: 202,
30
+ body: await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, convergeOnly),
31
+ };
25
32
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.39.3",
3
+ "version": "0.40.0",
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,104 @@
1
+ // Red/green for the per-request review-only override (`convergeOnly`). `pr.finalize` decides,
2
+ // on convergence, whether to hand the PR to the merge-loop (auto-merge) or rest it at `converged`
3
+ // (review-only). The global default is `NANO_PR_AUTO_MERGE` (on), but a single submission can pin
4
+ // review-only by carrying `convergeOnly: true` on the instance. These drive the worker against an
5
+ // in-memory data layer + a capturing engine and assert the hand-off happens iff auto-merge is on
6
+ // AND the request did not force convergence-only.
7
+ import { test } from "node:test";
8
+ import { assertEquals } from "#test-assert";
9
+ import handler from "./worker.ts";
10
+ import { MERGE_PROCESS_ID } from "../../app/service.ts";
11
+
12
+ function fakeApp() {
13
+ const stores: Record<string, Record<string, unknown>[]> = {
14
+ pull_requests: [],
15
+ rounds: [],
16
+ };
17
+ const createdProcesses: string[] = [];
18
+ return {
19
+ createdProcesses,
20
+ stores,
21
+ app: {
22
+ data: {
23
+ table(name: string, key: string) {
24
+ const store = (stores[name] ??= []);
25
+ return {
26
+ // biome-ignore lint/plugin: in-memory test double for the data layer
27
+ get: (k: unknown) => Promise.resolve(store.find((r) => r[key] === k)),
28
+ // biome-ignore lint/plugin: in-memory test double for the data layer
29
+ find: (q: Record<string, unknown>) =>
30
+ Promise.resolve(store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
31
+ // biome-ignore lint/plugin: in-memory test double for the data layer
32
+ insert: (row: Record<string, unknown>) => {
33
+ store.push(row);
34
+ return Promise.resolve(store.length);
35
+ },
36
+ // biome-ignore lint/plugin: in-memory test double for the data layer
37
+ update: (k: unknown, patch: Record<string, unknown>) => {
38
+ const row = store.find((r) => r[key] === k);
39
+ if (row) Object.assign(row, patch);
40
+ return Promise.resolve(row);
41
+ },
42
+ };
43
+ },
44
+ },
45
+ engine: {
46
+ createInstance: (req: { processDefinitionId: string }) => {
47
+ createdProcesses.push(req.processDefinitionId);
48
+ return Promise.resolve({ processInstanceKey: "MERGE-1" });
49
+ },
50
+ },
51
+ log: () => undefined,
52
+ },
53
+ };
54
+ }
55
+
56
+ const BASE_VARS = {
57
+ prKey: "owner/repo#5",
58
+ repo: "owner/repo",
59
+ prNumber: 5,
60
+ prUrl: "https://github.com/owner/repo/pull/5",
61
+ round: 2,
62
+ summary: "looks good",
63
+ };
64
+
65
+ // Auto-retro reads plan tables this fixture doesn't populate; disable it so the terminal
66
+ // `converged` path doesn't spuriously probe for a retro. The hand-off decision under test is
67
+ // independent of retro. Auto-merge is left at its default (on): `AUTO_MERGE` is computed once at
68
+ // `app/service.ts` import time and captured by the imported handler, so toggling
69
+ // `NANO_PR_AUTO_MERGE` here would be a no-op — only `NANO_AUTO_RETRO` is read dynamically.
70
+ function withRetroOff(run: () => Promise<void>): Promise<void> {
71
+ const prevRetro = process.env["NANO_AUTO_RETRO"];
72
+ process.env["NANO_AUTO_RETRO"] = "0";
73
+ return run().finally(() => {
74
+ if (prevRetro == null) delete process.env["NANO_AUTO_RETRO"];
75
+ else process.env["NANO_AUTO_RETRO"] = prevRetro;
76
+ });
77
+ }
78
+
79
+ test("finalize with convergeOnly=true rests the PR at converged and never starts the merge-loop", async () => {
80
+ await withRetroOff(async () => {
81
+ const { app, stores, createdProcesses } = fakeApp();
82
+ // biome-ignore lint/plugin: constructing the framework's job envelope for the handler under test
83
+ await handler({ variables: { ...BASE_VARS, convergeOnly: true } } as never, app as never);
84
+
85
+ // No merge-loop instance started even though auto-merge is on globally …
86
+ assertEquals(createdProcesses.includes(MERGE_PROCESS_ID), false);
87
+ // … and the PR rests at the review-only terminal status.
88
+ const pr = stores.pull_requests[0];
89
+ assertEquals(pr.status, "converged");
90
+ });
91
+ });
92
+
93
+ test("finalize with convergeOnly absent hands off to the merge-loop when auto-merge is on", async () => {
94
+ await withRetroOff(async () => {
95
+ const { app, stores, createdProcesses } = fakeApp();
96
+ // biome-ignore lint/plugin: constructing the framework's job envelope for the handler under test
97
+ await handler({ variables: { ...BASE_VARS } } as never, app as never);
98
+
99
+ // The default (env-governed) path starts the merge-loop and parks the PR in the merge stage.
100
+ assertEquals(createdProcesses.includes(MERGE_PROCESS_ID), true);
101
+ const pr = stores.pull_requests[0];
102
+ assertEquals(pr.status, "waiting_deps");
103
+ });
104
+ });
@@ -15,6 +15,9 @@ interface In extends Record<string, unknown> {
15
15
  prUrl: string;
16
16
  round: number;
17
17
  summary?: string;
18
+ // Per-request review-only override: when true, stop at `converged` and never hand off to the
19
+ // merge-loop even if auto-merge is on globally. Set at submit time, carried on the instance.
20
+ convergeOnly?: boolean;
18
21
  // The per-PR abandon capability URL the agent was handed; its token is preserved on a heal so
19
22
  // the agent's cooperative-abort check keeps resolving (see ensurePr).
20
23
  abandonUrl?: string;
@@ -31,7 +34,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
31
34
  // `summary` is left undefined when absent so the write boundary omits it: the
32
35
  // nullable `rounds.summary` stays NULL and `pull_requests.outcome` is untouched
33
36
  // rather than being coerced to "".
34
- const { prKey, repo, prNumber, prUrl, round, summary, abandonUrl } = job.variables;
37
+ const { prKey, repo, prNumber, prUrl, round, summary, abandonUrl, convergeOnly } = job.variables;
35
38
  const now = new Date().toISOString();
36
39
 
37
40
  // Heal a missing FK parent (engine/app.db desync) before the child `rounds` insert.
@@ -60,8 +63,11 @@ const handler: AppJobHandler<In> = async (job, app) => {
60
63
  // once merge-loop is actually running — otherwise the PR would be parked in a merge-stage status
61
64
  // with no process behind it, and `submitPr` refuses to restart it (only `cancel` recovers). On
62
65
  // failure we leave the PR terminal as `converged` so a human/operator can (re)start merge.
66
+ //
67
+ // A per-request `convergeOnly` override forces the review-only path for this PR regardless of the
68
+ // global auto-merge default — the PR rests at `converged` and is never handed to the merge-loop.
63
69
  let status = "converged";
64
- if (AUTO_MERGE) {
70
+ if (AUTO_MERGE && convergeOnly !== true) {
65
71
  try {
66
72
  const { mergeProcessKey } = await startMerge(app.data, app.engine, {
67
73
  repo,