@nanobpm/nano-workforce 0.39.3 → 0.40.1
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/README.md +5 -1
- package/SPEC.md +6 -1
- package/app/persist-escalation.test.ts +11 -5
- package/app/roundResultDefault.test.ts +94 -0
- package/app/service.test.ts +50 -0
- package/app/service.ts +5 -0
- package/openapi.yaml +5 -0
- package/operations/startAndMessage.test.ts +69 -0
- package/operations/startConvergenceLoop.ts +13 -6
- package/package.json +1 -1
- package/prompts/review-round.md +17 -3
- package/resources/processes/convergence-loop.bpmn +6 -6
- package/workers/finalize/worker.test.ts +104 -0
- package/workers/finalize/worker.ts +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [0.40.1](https://github.com/nanobpm/nano-workforce/compare/v0.40.0...v0.40.1) (2026-08-11)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **convergence:** safe default for resultless review rounds ([#116](https://github.com/nanobpm/nano-workforce/issues/116)) ([de85188](https://github.com/nanobpm/nano-workforce/commit/de85188f57b69496596f4c41cf555c9f6f2090da))
|
|
7
|
+
|
|
8
|
+
# [0.40.0](https://github.com/nanobpm/nano-workforce/compare/v0.39.3...v0.40.0) (2026-08-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **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))
|
|
14
|
+
|
|
1
15
|
## [0.39.3](https://github.com/nanobpm/nano-workforce/compare/v0.39.2...v0.39.3) (2026-08-11)
|
|
2
16
|
|
|
3
17
|
|
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 |
|
|
@@ -95,11 +95,17 @@ test("a padded question is persisted trimmed (no whitespace drift)", async () =>
|
|
|
95
95
|
assertEquals((updates.pull_requests![0] as any).patch.open_escalation_question, "needs a decision", "denormalised question is trimmed too");
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
98
|
+
// Defence-in-depth for the persist-escalation worker: if the gateway ever DOES route a
|
|
99
|
+
// blank-status / blank-question job here (an agent-raised `needs_input`/`blocked` with no
|
|
100
|
+
// question, or the max-rounds / review-stalled arms), the worker must NOT throw — throwing
|
|
101
|
+
// parked an un-remediable JobNoRetries incident (the empty "(no question provided)" escalations
|
|
102
|
+
// on Magikcraft/nano-bpm #597/#599). It opens an *answerable* escalation with a fabricated,
|
|
103
|
+
// concrete question and the agent's transcript attached, so a human can unblock the loop from
|
|
104
|
+
// the UI.
|
|
105
|
+
//
|
|
106
|
+
// NOTE: the `gw-status` gateway no longer routes an empty/unknown status here — that now
|
|
107
|
+
// defaults to `f_addressed` and re-enters the review wait (see roundResultDefault.test.ts).
|
|
108
|
+
// This fabrication path stays as a worker-level backstop for the explicit escalation arms.
|
|
103
109
|
test("blank question fabricates an answerable escalation (no throw, no incident)", async () => {
|
|
104
110
|
for (const question of [undefined, "", " "]) {
|
|
105
111
|
const { app, inserts, updates } = fakeApp();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Structural regression guard for the review-round "safe default" routing.
|
|
2
|
+
//
|
|
3
|
+
// A review round that exits without a machine-readable result (empty/unknown status)
|
|
4
|
+
// used to fall through the `gw-status` exclusive gateway's `default="f_escalate"`
|
|
5
|
+
// arm and escalate to a human — even after a benign rebase/force-push with no
|
|
6
|
+
// reviewer comments. That is premature: the round-cap gate (`gw-guard`) and the
|
|
7
|
+
// review-wait timeout already provide the human-escalation safety nets, and
|
|
8
|
+
// `persist-round` defaults an absent status to `addressed`.
|
|
9
|
+
//
|
|
10
|
+
// The fix inverts the gateway: escalation is now an EXPLICIT arm gated on
|
|
11
|
+
// `needs_input`/`blocked`, and `f_addressed` is the default, so any unknown/empty
|
|
12
|
+
// status re-enters the durable review wait instead of paging a human.
|
|
13
|
+
//
|
|
14
|
+
// This is a pure text assertion over the committed BPMN (no engine), matching the
|
|
15
|
+
// repo's lightweight model-guard style (see mergeRebaseArm.test.ts).
|
|
16
|
+
|
|
17
|
+
import { test } from "node:test";
|
|
18
|
+
import { assert, assertStringIncludes } from "#test-assert";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
|
|
21
|
+
const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
|
|
22
|
+
|
|
23
|
+
// Collapse whitespace so attribute-order / line-wrapping churn doesn't make the assertions brittle.
|
|
24
|
+
const flat = bpmn.replace(/\s+/g, " ");
|
|
25
|
+
|
|
26
|
+
// The `<sequenceFlow id="...">` element (whole element, up to its close), whether
|
|
27
|
+
// self-closing or with children. Returns the matched text or null.
|
|
28
|
+
//
|
|
29
|
+
// The body branch uses a tempered negative lookahead — `(?:(?!<bpmn:sequenceFlow\b).)*?`
|
|
30
|
+
// — so it can never over-match across a following `<bpmn:sequenceFlow>` element; it
|
|
31
|
+
// captures only up to *this* element's own close.
|
|
32
|
+
function flowElement(id: string): string | null {
|
|
33
|
+
const re = new RegExp(
|
|
34
|
+
`<bpmn:sequenceFlow\\b[^>]*?\\bid="${id}"[^>]*?(?:/>|>(?:(?!<bpmn:sequenceFlow\\b).)*?</bpmn:sequenceFlow>)`,
|
|
35
|
+
);
|
|
36
|
+
const m = flat.match(re);
|
|
37
|
+
return m ? m[0] : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Every `<bpmn:sequenceFlow>` element (whole element, self-closing or with children),
|
|
41
|
+
// using the same tempered-lookahead boundary as flowElement() so a captured element can
|
|
42
|
+
// never bleed into the next one.
|
|
43
|
+
function allFlows(): string[] {
|
|
44
|
+
const re = new RegExp(
|
|
45
|
+
"<bpmn:sequenceFlow\\b[^>]*?(?:/>|>(?:(?!<bpmn:sequenceFlow\\b).)*?</bpmn:sequenceFlow>)",
|
|
46
|
+
"g",
|
|
47
|
+
);
|
|
48
|
+
return flat.match(re) ?? [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("gw-status defaults to the addressed arm, not escalation", () => {
|
|
52
|
+
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-status"[^>]*>/);
|
|
53
|
+
assert(gw, "gw-status gateway missing");
|
|
54
|
+
assertStringIncludes(gw[0], 'default="f_addressed"');
|
|
55
|
+
// The old premature-escalation default must be gone.
|
|
56
|
+
assert(
|
|
57
|
+
!/id="gw-status"[^>]*default="f_escalate"/.test(flat),
|
|
58
|
+
"gw-status must no longer default to escalation",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("escalation is an explicit needs_input/blocked arm", () => {
|
|
63
|
+
const esc = flowElement("f_escalate");
|
|
64
|
+
assert(esc, "f_escalate flow missing");
|
|
65
|
+
assertStringIncludes(esc, 'targetRef="persist-escalation"');
|
|
66
|
+
// Escalation now only fires on an explicit human-blocking status.
|
|
67
|
+
assertStringIncludes(esc, 'status = "needs_input" or status = "blocked"');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("the default (addressed) arm carries no condition and re-enters the guard", () => {
|
|
71
|
+
const addressed = flowElement("f_addressed");
|
|
72
|
+
assert(addressed, "f_addressed flow missing");
|
|
73
|
+
assertStringIncludes(addressed, 'targetRef="gw-guard"');
|
|
74
|
+
// A default flow must have NO conditionExpression.
|
|
75
|
+
assert(
|
|
76
|
+
!/conditionExpression/.test(addressed),
|
|
77
|
+
"f_addressed is the default flow and must not carry a conditionExpression",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("regression: an empty/unknown status no longer routes to persist-escalation", () => {
|
|
82
|
+
// Escalation is reachable ONLY via an explicit condition (the needs_input/blocked arm),
|
|
83
|
+
// never as a catch-all default. Enumerate EVERY sequenceFlow into `persist-escalation`
|
|
84
|
+
// and assert each one carries a conditionExpression — so no future edit can slip an
|
|
85
|
+
// unconditional (default) arm into escalation.
|
|
86
|
+
const intoEscalation = allFlows().filter((f) => /targetRef="persist-escalation"/.test(f));
|
|
87
|
+
assert(intoEscalation.length > 0, "no flow targets persist-escalation");
|
|
88
|
+
for (const f of intoEscalation) {
|
|
89
|
+
assertStringIncludes(f, "conditionExpression");
|
|
90
|
+
}
|
|
91
|
+
// The addressed default must land on the guard (which re-solicits the review), not escalation.
|
|
92
|
+
const addressed = flowElement("f_addressed");
|
|
93
|
+
assert(addressed && /targetRef="gw-guard"/.test(addressed), "default arm must re-enter gw-guard");
|
|
94
|
+
});
|
package/app/service.test.ts
CHANGED
|
@@ -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
|
|
7
|
-
// envelope: this is a purpose-built operation, not a generic engine "start process" call,
|
|
8
|
-
// not leak the engine's variable-map concept to callers. The runtime validates the body
|
|
9
|
-
// openapi.yaml; this delegate keeps the PR-parse guard because the reference format
|
|
10
|
-
// or a URL) is app logic, not something the JSON schema can express — an unparseable
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.40.1",
|
|
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",
|
package/prompts/review-round.md
CHANGED
|
@@ -58,7 +58,11 @@ Because several agents may run on the same host at once:
|
|
|
58
58
|
3. **Act.** Make the code changes for all fixes + nitpicks in your workspace (`cwd`)
|
|
59
59
|
in one coherent, signed-off commit (`git commit -s`). Run the repo's
|
|
60
60
|
build/test/lint locally before pushing. Push to the PR's head branch (the branch
|
|
61
|
-
you are already on) — do not open a new branch or PR.
|
|
61
|
+
you are already on) — do not open a new branch or PR. If the branch has drifted
|
|
62
|
+
behind its base and you need to **rebase / resolve a merge conflict** to keep it
|
|
63
|
+
mergeable, that is allowed: do it in place on this branch and **force-push**
|
|
64
|
+
(`--force-with-lease`). Any push this round — including a rebase/force-push with
|
|
65
|
+
no reviewer comments to act on — is an **`addressed`** round (see the return table).
|
|
62
66
|
4. **Reply in-thread** to each comment you addressed or pushed back on, one reply
|
|
63
67
|
per comment, so the trail lives on the PR.
|
|
64
68
|
5. **Resolve the thread** for every comment you handled — every *fix*, *nitpick*,
|
|
@@ -128,7 +132,7 @@ Return **one** of:
|
|
|
128
132
|
| `status` | when | also set |
|
|
129
133
|
|---------------|---------------------------------------------------------------|-----------------|
|
|
130
134
|
| `converged` | nothing actionable left (see above) | `summary` |
|
|
131
|
-
| `addressed` | you
|
|
135
|
+
| `addressed` | you pushed anything this round — code fixes, nitpicks, **or** a rebase/force-push to resolve a conflict | `summary` |
|
|
132
136
|
| `waiting` | nothing to triage yet — you are awaiting a pending review (typically round 1) | `summary` |
|
|
133
137
|
| `needs_input` | you hit a decision only a human can make | `summary`, `question` |
|
|
134
138
|
| `blocked` | you are stuck on something external (auth, failing push, missing secret) | `summary`, `question` |
|
|
@@ -143,7 +147,8 @@ Never guess on a `needs_input` decision — raise it and let a human answer.
|
|
|
143
147
|
|
|
144
148
|
Your result variables only reach the process if you emit them through the harness's
|
|
145
149
|
result channel. Prose in your normal output is **not** parsed — if you only "say"
|
|
146
|
-
your status in the transcript, the
|
|
150
|
+
your status in the transcript, the process can't read it, falls back to a safe
|
|
151
|
+
default, and you waste a round. So emit a machine-readable result one of two ways:
|
|
147
152
|
|
|
148
153
|
1. **Write a JSON object to the file at `$AGENT_RESULT_FILE`** (an env var the
|
|
149
154
|
harness sets for you). The object's keys become process variables. Example for a
|
|
@@ -169,3 +174,12 @@ your status in the transcript, the round escalates with an empty question. So:
|
|
|
169
174
|
Do not put the result file inside the repo checkout or `git add` it — it lives
|
|
170
175
|
outside your workspace. Exit `0` for every status (including `blocked`/`needs_input`);
|
|
171
176
|
a non-zero exit means a genuine crash and the job is retried.
|
|
177
|
+
|
|
178
|
+
**Emitting a machine-readable result is your mandatory final step — never exit
|
|
179
|
+
silently.** Emitting a result (the `$AGENT_RESULT_FILE` write, or the stdout fallback
|
|
180
|
+
above if you truly cannot write the file) is the last thing you do on every path out of
|
|
181
|
+
this round (including after a rebase/force-push, or when nothing needed doing). If you
|
|
182
|
+
are ever unsure which status applies and you are not blocked on a human decision,
|
|
183
|
+
return **`addressed`** (or **`waiting`** if you are still awaiting the first review) —
|
|
184
|
+
never leave without a result. A missing result is treated as a safe `addressed` and
|
|
185
|
+
re-enters the review wait, but relying on that instead of emitting one wastes a round.
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
<bpmn:incoming>f_answerLoop</bpmn:incoming>
|
|
91
91
|
<bpmn:outgoing>f_toStatus</bpmn:outgoing>
|
|
92
92
|
</bpmn:serviceTask>
|
|
93
|
-
<bpmn:exclusiveGateway id="gw-status" name="status?" default="
|
|
93
|
+
<bpmn:exclusiveGateway id="gw-status" name="status?" default="f_addressed">
|
|
94
94
|
<bpmn:incoming>f_toStatus</bpmn:incoming>
|
|
95
95
|
<bpmn:outgoing>f_converged</bpmn:outgoing>
|
|
96
96
|
<bpmn:outgoing>f_addressed</bpmn:outgoing>
|
|
@@ -202,13 +202,13 @@
|
|
|
202
202
|
<bpmn:sequenceFlow id="f_converged" name="converged" sourceRef="gw-status" targetRef="persist-converged">
|
|
203
203
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "converged"</bpmn:conditionExpression>
|
|
204
204
|
</bpmn:sequenceFlow>
|
|
205
|
-
<bpmn:sequenceFlow id="f_addressed" name="addressed" sourceRef="gw-status" targetRef="gw-guard"
|
|
206
|
-
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "addressed"</bpmn:conditionExpression>
|
|
207
|
-
</bpmn:sequenceFlow>
|
|
205
|
+
<bpmn:sequenceFlow id="f_addressed" name="addressed / default" sourceRef="gw-status" targetRef="gw-guard" />
|
|
208
206
|
<bpmn:sequenceFlow id="f_waiting" name="waiting" sourceRef="gw-status" targetRef="gw-guard">
|
|
209
207
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "waiting"</bpmn:conditionExpression>
|
|
210
208
|
</bpmn:sequenceFlow>
|
|
211
|
-
<bpmn:sequenceFlow id="f_escalate" name="needs_input / blocked" sourceRef="gw-status" targetRef="persist-escalation"
|
|
209
|
+
<bpmn:sequenceFlow id="f_escalate" name="needs_input / blocked" sourceRef="gw-status" targetRef="persist-escalation">
|
|
210
|
+
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "needs_input" or status = "blocked"</bpmn:conditionExpression>
|
|
211
|
+
</bpmn:sequenceFlow>
|
|
212
212
|
<bpmn:sequenceFlow id="f_guardMax" name="max rounds" sourceRef="gw-guard" targetRef="persist-escalation-maxrounds">
|
|
213
213
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=round >= maxRounds</bpmn:conditionExpression>
|
|
214
214
|
</bpmn:sequenceFlow>
|
|
@@ -337,7 +337,7 @@
|
|
|
337
337
|
<di:waypoint x="441" y="280" />
|
|
338
338
|
<di:waypoint x="591" y="280" />
|
|
339
339
|
<bpmndi:BPMNLabel>
|
|
340
|
-
<dc:Bounds x="446" y="
|
|
340
|
+
<dc:Bounds x="446" y="199" width="78" height="28" />
|
|
341
341
|
</bpmndi:BPMNLabel>
|
|
342
342
|
</bpmndi:BPMNEdge>
|
|
343
343
|
<bpmndi:BPMNEdge id="BPMNEdge_f_waiting" bpmnElement="f_waiting">
|
|
@@ -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,
|