@nanobpm/nano-workforce 0.40.0 → 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 CHANGED
@@ -1,3 +1,10 @@
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
+
1
8
  # [0.40.0](https://github.com/nanobpm/nano-workforce/compare/v0.39.3...v0.40.0) (2026-08-11)
2
9
 
3
10
 
@@ -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
- // A round that fell through the `gw-status` default (no `converged`/`addressed` status and no
99
- // question the prompt-less-agent failure behind the empty "(no question provided)" escalations
100
- // on Magikcraft/nano-bpm #597/#599) must NOT throw (which parked an un-remediable JobNoRetries
101
- // incident). It now opens an *answerable* escalation with a fabricated, concrete question and the
102
- // agent's transcript attached, so a human can unblock the loop entirely from the UI.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.40.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",
@@ -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 made changes + pushed this round | `summary` |
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 round escalates with an empty question. So:
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="f_escalate">
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 &gt;= 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="206" width="67" height="14" />
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">