@nanobpm/nano-workforce 0.193.0 → 0.193.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 CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.193.1](https://github.com/nanobpm/nano-workforce/compare/v0.193.0...v0.193.1) (2026-09-28)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **convergence:** reset round output vars on entry so a no-result round can't re-escalate ([#823](https://github.com/nanobpm/nano-workforce/issues/823)) ([3154918](https://github.com/nanobpm/nano-workforce/commit/3154918f3aeea477733347277ec067c1e0359e84)), closes [nano-coder#35](https://github.com/nanobpm/nano-coder/issues/35) [#806](https://github.com/nanobpm/nano-workforce/issues/806) [#822](https://github.com/nanobpm/nano-workforce/issues/822) [#822](https://github.com/nanobpm/nano-workforce/issues/822)
6
+
1
7
  ## [0.193.0](https://github.com/nanobpm/nano-workforce/compare/v0.192.0...v0.193.0) (2026-09-28)
2
8
 
3
9
  ### Features
package/SPEC.md CHANGED
@@ -300,6 +300,27 @@ Notes:
300
300
  poller does not solicit a spurious review against the still-running round. The
301
301
  round cap and the review-wait timeout remain the outer safety nets.
302
302
 
303
+ - **Stale-output reset on round entry (issue #822).** `pr.capture-head` — the
304
+ single task every entry into `review-round` routes through first (from `Start`,
305
+ the review-loop re-enter, the human-answer resume, the husk auto-retry, and the
306
+ ack-only retry) — additionally CLEARS the round's output variables on entry:
307
+ `status`, `summary`, and `question` (→ `null`) and `escalated` (→ `false`),
308
+ mirroring the merge loop's `arm-merge` (which already clears `status` on entry
309
+ before its agent runs). A `review-round` job taken by a misconfigured worker can
310
+ exit 0 with no result variables, empty output, and no push; because nothing
311
+ overwrote them, the PREVIOUS round's `status`/`summary`/`question` would
312
+ otherwise persist and `gw-status` would re-route on that stale decision —
313
+ re-opening a word-for-word duplicate of an escalation a human had just answered
314
+ (the answer never reaching an agent). Resetting on entry makes a no-result round
315
+ enter on a blank `status`/`question`, so it can no longer inherit the prior
316
+ round's decision: it falls through `gw-status`'s `f_addressed` default into
317
+ `persist-round` → `check-progress`, where the unchanged head is caught by the
318
+ no-progress guard above and classified (husk vs. no-advance) on its own merits.
319
+ These vars are reset on ENTRY here rather than on `review-round`'s own output
320
+ because a same-task output-clear would clobber the agent's real verdict;
321
+ `answer`/`scopePending` (inputs the agent consumes) stay cleared on the
322
+ review-round output.
323
+
303
324
 
304
325
  ## 5. Agent job contract (`senior:pr-review`)
305
326
 
@@ -0,0 +1,92 @@
1
+ // Regression guard for issue #822 — the convergence loop must not REUSE a previous round's
2
+ // status/question/summary/escalated when an agent round returns NO result.
3
+ //
4
+ // The defect (proc 86936 / nano-coder#35): a `review-round` job taken by a misconfigured worker
5
+ // exited 0 in ~11s with NO result vars, no output, no commits, no push. Because nothing overwrote
6
+ // them, the PREVIOUS round's `status = "needs_input"`, `summary` and `question` stayed in scope.
7
+ // `gw-status` then routed on that stale `status`/`question` to `persist-escalation`, opening a
8
+ // word-for-word DUPLICATE of the escalation a human had just answered — the human's answer never
9
+ // reached an agent.
10
+ //
11
+ // The fix (categorical, not a special-case): clear the round's OUTPUT variables ON ENTRY to the
12
+ // round, on `capture-head` — the single task every entry into `review-round` routes through first
13
+ // (from Start, the review-loop re-enter, the human-answer resume, the husk auto-retry, and the
14
+ // ack-only retry). This mirrors the merge loop's `arm-merge`, which clears `status` on entry before
15
+ // its agent runs. With the round entering on a blank `status`/`question`, a no-result round can no
16
+ // longer inherit the prior round's decision: it falls through `gw-status`'s `f_addressed` default
17
+ // into `persist-round` → `check-progress`, where the unchanged PR head is caught as a no-progress
18
+ // round. `decideProgress` then classifies it on positive evidence (`app/roundProgress.ts`): only a
19
+ // corroborated non-terminal completing `review-round` instance is a husk (auto-retried within the
20
+ // budget); a terminal instance OR an absent/unknown read fails safe to `no-advance` and is escalated
21
+ // immediately as "produced nothing" (never re-running, to avoid duplicating agent work that may have
22
+ // run). Either way the round is routed as no-progress and NOT re-asked as an already-answered
23
+ // question.
24
+ //
25
+ // `answer`/`scopePending` (INPUTS the agent CONSUMES) are cleared on the review-round OUTPUT so the
26
+ // NEXT round can't re-consume them; `status`/`summary`/`question`/`escalated` (OUTPUTS the agent
27
+ // PRODUCES) must instead be cleared on ENTRY, because a same-task output-clear would clobber the
28
+ // agent's real verdict. Hence they live on `capture-head`, not on `review-round`.
29
+ //
30
+ // Pure text assertions over the committed BPMN (no engine), matching the repo's lightweight
31
+ // model-guard style (see convergenceEscalationGuard.test.ts, roundResultDefault.test.ts).
32
+
33
+ import { test } from "node:test";
34
+ import { assert, assertStringIncludes } from "#test-assert";
35
+ import { readFileSync } from "node:fs";
36
+
37
+ const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
38
+ // Collapse whitespace so attribute-order / line-wrapping churn doesn't make the assertions brittle.
39
+ const flat = bpmn.replace(/\s+/g, " ");
40
+
41
+ /** The <serviceTask> element for the given id (whole element, up to its close), or null. */
42
+ function serviceTask(id: string): string | null {
43
+ const m = flat.match(new RegExp(`<bpmn:serviceTask\\b[^>]*\\bid="${id}"[\\s\\S]*?</bpmn:serviceTask>`));
44
+ return m ? m[0] : null;
45
+ }
46
+
47
+ // The round-output variables that must be reset on entry so a no-result round can't inherit them.
48
+ // `escalated` is reset to `false` (a boolean flag); the rest to `null` (absent).
49
+ const RESET_ON_ENTRY: ReadonlyArray<{ target: string; source: string }> = [
50
+ { target: "status", source: "=null" },
51
+ { target: "summary", source: "=null" },
52
+ { target: "question", source: "=null" },
53
+ { target: "escalated", source: "=false" },
54
+ ];
55
+
56
+ test("capture-head clears the round's output vars on entry to every review round", () => {
57
+ const el = serviceTask("capture-head");
58
+ assert(el, "capture-head service task must exist");
59
+ for (const { target, source } of RESET_ON_ENTRY) {
60
+ const re = new RegExp(`<zeebe:output\\b[^>]*\\bsource="${source}"[^>]*\\btarget="${target}"[^>]*/>`);
61
+ const alt = new RegExp(`<zeebe:output\\b[^>]*\\btarget="${target}"[^>]*\\bsource="${source}"[^>]*/>`);
62
+ assert(
63
+ re.test(el!) || alt.test(el!),
64
+ `capture-head must clear "${target}" (source ${source}) on entry so a no-result round can't reuse the prior round's ${target} (issue #822)`,
65
+ );
66
+ }
67
+ });
68
+
69
+ test("capture-head still precedes review-round on every loop entry (the reset is truly on entry)", () => {
70
+ // The reset is only "on entry to the round" if capture-head runs immediately before review-round
71
+ // for EVERY way the loop re-enters. capture-head's single outgoing must target review-round, and
72
+ // it must be the join for all the re-entry flows.
73
+ const el = serviceTask("capture-head");
74
+ assert(el, "capture-head service task must exist");
75
+ const incoming = [...el!.matchAll(/<bpmn:incoming>([^<]+)<\/bpmn:incoming>/g)].map((m) => m[1]);
76
+ for (const flow of ["f_start", "f_reviewLoop", "f_answerLoop", "f_huskRetry", "f_ackRetry"]) {
77
+ assert(incoming.includes(flow), `capture-head must be the entry join for ${flow} so the reset covers that re-entry (issue #822)`);
78
+ }
79
+ const capture = flat.match(/<bpmn:sequenceFlow[^>]*\bid="f_capture"[^>]*\/>/);
80
+ assert(capture, "f_capture flow missing");
81
+ assertStringIncludes(capture![0], 'sourceRef="capture-head"');
82
+ assertStringIncludes(capture![0], 'targetRef="review-round"');
83
+ });
84
+
85
+ test("gw-status still defaults a blank status to the addressed (re-enter) arm, not escalation", () => {
86
+ // The reset only helps if a blank status routes to the no-progress/husk path rather than
87
+ // escalation. This pins the interaction: with status/question cleared, gw-status falls to
88
+ // f_addressed (persist-round → check-progress), where an unchanged head is caught as a husk.
89
+ const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-status"[^>]*>/);
90
+ assert(gw, "gw-status gateway missing");
91
+ assertStringIncludes(gw![0], 'default="f_addressed"');
92
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.193.0",
3
+ "version": "0.193.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",
@@ -145,6 +145,12 @@
145
145
  <zeebe:property name="io.nanobpm.dataEnvelope.in" value="PrCaptureHeadIn" />
146
146
  <zeebe:property name="io.nanobpm.dataEnvelope.out" value="PrCaptureHeadOut" />
147
147
  </zeebe:properties>
148
+ <zeebe:ioMapping>
149
+ <zeebe:output source="=null" target="status" />
150
+ <zeebe:output source="=null" target="summary" />
151
+ <zeebe:output source="=null" target="question" />
152
+ <zeebe:output source="=false" target="escalated" />
153
+ </zeebe:ioMapping>
148
154
  </bpmn:extensionElements>
149
155
  <bpmn:incoming>f_start</bpmn:incoming>
150
156
  <bpmn:incoming>f_reviewLoop</bpmn:incoming>
@@ -23,6 +23,16 @@
23
23
  // fresh on every round entry (including husk retries), so it never carries a stale prior-round value.
24
24
  // The read fails OPEN to `null` (a transient GitHub hiccup must never fabricate a no-progress verdict);
25
25
  // progress-check then falls back to the persisted `last_round_head`.
26
+ //
27
+ // Because this task is the single on-entry join before `review-round`, the model also uses it to RESET
28
+ // the round's OUTPUT variables on entry (issue #822): its `ioMapping` clears `status`, `summary`,
29
+ // `question` (→ null) and `escalated` (→ false) in convergence-loop.bpmn. A `review-round` that returns
30
+ // NO result (a misconfigured worker that exits 0 with empty output, no commits, no push) then can't
31
+ // inherit the PREVIOUS round's `status`/`question` and re-open a word-for-word duplicate of an
32
+ // already-answered escalation; instead it falls through `gw-status`'s addressed default into the
33
+ // no-progress (husk) path. These OUTPUT vars are reset on ENTRY (here) rather than on the review-round's
34
+ // own output — a same-task output-clear would clobber the agent's real verdict — mirroring the merge
35
+ // loop's `arm-merge`, which clears `status` on entry before its agent runs.
26
36
  import type { AppJobHandler } from "@nanobpm/urban";
27
37
  import { fetchBranchHead, fetchPrHead } from "../../app/github.ts";
28
38
  import { parsePr } from "../../app/service.ts";