@nanobpm/nano-workforce 0.99.0 → 0.99.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.99.1](https://github.com/nanobpm/nano-workforce/compare/v0.99.0...v0.99.1) (2026-08-19)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **convergence-loop:** route every escalation arm through gw-escalated ([#333](https://github.com/nanobpm/nano-workforce/issues/333)) ([#340](https://github.com/nanobpm/nano-workforce/issues/340)) ([0ad7a5c](https://github.com/nanobpm/nano-workforce/commit/0ad7a5c08a659a0ca26dc48def8c024d21a3a53a)), closes [#329](https://github.com/nanobpm/nano-workforce/issues/329)
7
+
1
8
  # [0.99.0](https://github.com/nanobpm/nano-workforce/compare/v0.98.1...v0.99.0) (2026-08-19)
2
9
 
3
10
 
@@ -467,11 +467,15 @@ test("gw-converge-gate default arm finalizes with no condition", () => {
467
467
  assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
468
468
  });
469
469
 
470
- test("the blocked-comments escalation lands on the human wait-answer task with an answerable escalation", () => {
471
- const f = flowElement("f_blockedWait");
472
- assert(f, "f_blockedWait flow missing");
470
+ test("the blocked-comments escalation routes through gw-escalated toward an answerable wait-answer", () => {
471
+ // #333: previously this flowed UNCONDITIONALLY into wait-answer, so a blank convergeBlockReason
472
+ // (the question is mapped from that OPTIONAL variable) opened no escalation yet still parked a
473
+ // dead wait with a null question. It now routes through gw-escalated, which parks wait-answer
474
+ // only on a real escalation and otherwise re-enters the loop.
475
+ const f = flowElement("f_blockedGate");
476
+ assert(f, "f_blockedGate flow missing");
473
477
  assertStringIncludes(f, 'sourceRef="persist-escalation-blockedcomments"');
474
- assertStringIncludes(f, 'targetRef="wait-answer"');
478
+ assertStringIncludes(f, 'targetRef="gw-escalated"');
475
479
  const task = flat.match(
476
480
  /<bpmn:serviceTask\b[^>]*\bid="persist-escalation-blockedcomments"[^>]*>.*?<\/bpmn:serviceTask>/,
477
481
  );
@@ -0,0 +1,106 @@
1
+ // Regression guard for the question-less convergence-loop escalation defect (issue #333).
2
+ //
3
+ // The convergence loop (`resources/processes/convergence-loop.bpmn`) has FIVE `persist-escalation`
4
+ // service tasks, but only ONE — `persist-escalation` (the `f_escalate` agent-verdict arm) — was
5
+ // routed through the `gw-escalated` gateway that honours the worker's `escalated` output. The other
6
+ // four flowed UNCONDITIONALLY into the durable `wait-answer` user task:
7
+ //
8
+ // • persist-escalation-noprogress (no progress made this round)
9
+ // • persist-review-stalled (review-wait timer fired) ← wedged live
10
+ // • persist-escalation-blockedcomments (unaddressed review comments)
11
+ // • persist-escalation-maxrounds (round cap reached)
12
+ //
13
+ // Per ADR 0002 §1 a blank question is a NON-escalation: `pr.persist-escalation` opens no row and
14
+ // returns `escalated:false`. The `persist-escalation-blockedcomments` arm maps its question from the
15
+ // OPTIONAL `convergeBlockReason` process variable, so a blank reason returned `escalated:false` yet
16
+ // STILL parked a dead `wait-answer` — a durable answer-wait with no escalation and a `null` question,
17
+ // surfaced on the merge-driving inbox with nothing for a human to answer (observed live on three
18
+ // instances, issue #333).
19
+ //
20
+ // The fix (mirroring the merge loop's `gw-merge-escalated`, PR #331): route EVERY arm that can reach
21
+ // `wait-answer` through the single `gw-escalated` guard, so a `persist-escalation` returning
22
+ // `escalated:false` RE-ENTERS the loop (`gw-guard`) instead of parking a dead wait. This makes the
23
+ // invariant structural — `wait-answer` is reachable ONLY from a `gw-escalated == true` edge, so a
24
+ // "durable answer-wait with no escalation" is unrepresentable.
25
+ //
26
+ // These are pure text assertions over the committed BPMN (no engine), matching the repo's
27
+ // lightweight model-guard style (see mergeEscalationQuestion.test.ts, mergeRebaseArm.test.ts).
28
+
29
+ import { test } from "node:test";
30
+ import { assert, assertStringIncludes } from "#test-assert";
31
+ import { readFileSync } from "node:fs";
32
+
33
+ const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
34
+ // Collapse whitespace so attribute-order / line-wrapping churn doesn't make the assertions brittle.
35
+ const flat = bpmn.replace(/\s+/g, " ");
36
+
37
+ function flowHasId(id: string, source: string, target: string): boolean {
38
+ const m = flat.match(new RegExp(`<bpmn:sequenceFlow\\b[^>]*\\bid="${id}"[^>]*(?:/>|>)`));
39
+ if (!m) return false;
40
+ const tag = m[0];
41
+ return tag.includes(`sourceRef="${source}"`) && tag.includes(`targetRef="${target}"`);
42
+ }
43
+
44
+ function gatewayDefault(id: string, def: string): boolean {
45
+ const m = flat.match(new RegExp(`<bpmn:exclusiveGateway\\b[^>]*\\bid="${id}"[^>]*>`));
46
+ if (!m) return false;
47
+ return m[0].includes(`default="${def}"`);
48
+ }
49
+
50
+ /** The <userTask> element for wait-answer, including its incoming flows. */
51
+ const waitAnswer = flat.match(/<bpmn:userTask\b[^>]*\bid="wait-answer"[\s\S]*?<\/bpmn:userTask>/);
52
+
53
+ // Every control-flow escalation arm and the gateway edge it must now route through.
54
+ const ARMS: ReadonlyArray<{ task: string; gate: string }> = [
55
+ { task: "persist-escalation-noprogress", gate: "f_noprogressGate" },
56
+ { task: "persist-review-stalled", gate: "f_stalledGate" },
57
+ { task: "persist-escalation-blockedcomments", gate: "f_blockedGate" },
58
+ { task: "persist-escalation-maxrounds", gate: "f_maxGate" },
59
+ ];
60
+
61
+ test("every persist-escalation arm routes through gw-escalated, not straight to wait-answer", () => {
62
+ for (const { task, gate } of ARMS) {
63
+ assert(
64
+ flowHasId(gate, task, "gw-escalated"),
65
+ `${task} must route through gw-escalated (flow ${gate}), not directly to wait-answer (the #333 defect)`,
66
+ );
67
+ }
68
+ // The agent-verdict arm already routed through the gate — keep it.
69
+ assert(flowHasId("f_escGate", "persist-escalation", "gw-escalated"), "persist-escalation must route through gw-escalated");
70
+ });
71
+
72
+ test("gw-escalated honours persist-escalation's escalated output for every arm", () => {
73
+ // escalated:true → park the native user task for a human to answer.
74
+ assert(flowHasId("f_escWait", "gw-escalated", "wait-answer"), "gw-escalated → wait-answer (escalated) missing");
75
+ const escWait = flat.match(/<bpmn:sequenceFlow[^>]*id="f_escWait"[\s\S]*?<\/bpmn:sequenceFlow>/);
76
+ assert(escWait, "f_escWait flow missing");
77
+ assertStringIncludes(escWait![0], "escalated = true", "the wait arm must be guarded by escalated = true");
78
+ // escalated:false (a non-escalation, e.g. a blank convergeBlockReason) → re-enter the loop, not a dead wait.
79
+ assert(gatewayDefault("gw-escalated", "f_escReenter"), "gw-escalated default must re-enter the loop");
80
+ assert(flowHasId("f_escReenter", "gw-escalated", "gw-guard"), "the non-escalation arm must re-enter via gw-guard, not park a wait");
81
+ });
82
+
83
+ test("wait-answer is reachable ONLY from the gw-escalated == true edge (structural invariant)", () => {
84
+ // The single most important guarantee of #333: a durable answer-wait with no escalation is
85
+ // unrepresentable because the ONLY edge into wait-answer is the guarded f_escWait.
86
+ assert(waitAnswer, "wait-answer user task must exist");
87
+ const incoming = [...waitAnswer![0].matchAll(/<bpmn:incoming>([^<]+)<\/bpmn:incoming>/g)].map((m) => m[1]);
88
+ assert(incoming.length === 1, `wait-answer must have exactly one incoming (the guarded f_escWait); found: ${incoming.join(", ")}`);
89
+ assert(incoming[0] === "f_escWait", `wait-answer's only incoming must be f_escWait; found ${incoming[0]}`);
90
+ // The retired dead-wait edges must be gone.
91
+ for (const dead of ["f_noprogressWait", "f_stalledWait", "f_blockedWait", "f_maxWait"]) {
92
+ assert(!flat.includes(`id="${dead}"`), `the dead-wait edge ${dead} must be removed`);
93
+ }
94
+ });
95
+
96
+ test("each control-flow arm still carries a non-blank, human-actionable question", () => {
97
+ // The guard closes the wedge, but a legitimate control-flow escalation must still open a real
98
+ // escalation with a concrete question. Assert each arm supplies status + question via ioMapping.
99
+ for (const { task } of ARMS) {
100
+ const raw = flat.match(new RegExp(`<bpmn:serviceTask\\b[^>]*\\bid="${task}"[\\s\\S]*?</bpmn:serviceTask>`));
101
+ assert(raw, `${task} service task must exist`);
102
+ const el = raw![0];
103
+ assertStringIncludes(el, 'target="status"', `${task} must set an explicit status`);
104
+ assertStringIncludes(el, 'target="question"', `${task} must set a question`);
105
+ }
106
+ });
@@ -180,3 +180,35 @@ test("persist-escalation heals from the prKey when repo/prNumber are absent", as
180
180
  "the running agent's abandon token is preserved from abandonUrl, not re-minted",
181
181
  );
182
182
  });
183
+
184
+ // #333 — the control-flow escalation arms (no-progress / review-stalled / unaddressed-comments /
185
+ // max-rounds) each set an explicit `status="blocked"` + a concrete `question` via `zeebe:input`
186
+ // (recordRound=false for the three that run after `persist-round`). They now route through
187
+ // `gw-escalated`, which branches on the worker's `escalated` output. This pins the contract that
188
+ // gateway depends on: a control-flow arm with a real question OPENS an escalation and returns
189
+ // `escalated:true` + the (trimmed) question, so gw-escalated parks a wait carrying that question —
190
+ // never a dead wait with a null question (the #333 defect).
191
+ test("a control-flow arm with a concrete question opens an escalation gw-escalated can park", async () => {
192
+ const { app, inserts } = fakeApp();
193
+ const question = "No review arrived within the review-wait timeout (PT20M). A human must decide how to proceed.";
194
+ const job = { variables: { prKey: "o/r#5", round: 2, status: "blocked", question, recordRound: false } };
195
+ const out = await handler(job as any, app as any);
196
+ assertEquals((out as any).escalated, true, "a real control-flow escalation reports escalated:true");
197
+ assertEquals((out as any).question, question, "the question is returned for the wait-answer form");
198
+ assertEquals(inserts.escalations.length, 1, "an escalation row is opened");
199
+ assertEquals(inserts.rounds.length, 0, "recordRound=false suppresses a duplicate round row");
200
+ });
201
+
202
+ // #333 — conversely, the `persist-escalation-blockedcomments` arm maps its question from the
203
+ // OPTIONAL `convergeBlockReason`. A blank reason is a NON-escalation: the worker opens nothing and
204
+ // returns `escalated:false`, so gw-escalated's default RE-ENTERS the loop instead of parking a dead
205
+ // `wait-answer` with a null question. This is the exact wedge the guard eliminates.
206
+ test("a control-flow arm with a blank question opens nothing so gw-escalated re-enters", async () => {
207
+ const { app, inserts, updates } = fakeApp();
208
+ const job = { variables: { prKey: "o/r#5", round: 2, status: "blocked", question: " ", recordRound: false } };
209
+ const out = await handler(job as any, app as any);
210
+ assertEquals((out as any).escalated, false, "a blank-reason arm reports escalated:false");
211
+ assertEquals((out as any).escalationId, null, "no escalation id is minted");
212
+ assertEquals(inserts.escalations.length, 0, "no dead escalation is fabricated");
213
+ assertEquals(updates.pull_requests?.length ?? 0, 0, "the PR is never flipped to escalated");
214
+ });
@@ -210,11 +210,14 @@ test("gw-progress default arm re-enters the review wait with no condition", () =
210
210
  assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
211
211
  });
212
212
 
213
- test("the no-progress escalation lands on the human wait-answer task", () => {
214
- const f = flowElement("f_noprogressWait");
215
- assert(f, "f_noprogressWait flow missing");
213
+ test("the no-progress escalation routes through gw-escalated toward the human wait-answer task", () => {
214
+ // #333: the arm no longer flows UNCONDITIONALLY into wait-answer — it routes through the
215
+ // gw-escalated guard, which parks wait-answer only when the worker opened a real escalation
216
+ // (escalated=true) and otherwise re-enters the loop (never a dead wait with a null question).
217
+ const f = flowElement("f_noprogressGate");
218
+ assert(f, "f_noprogressGate flow missing");
216
219
  assertStringIncludes(f, 'sourceRef="persist-escalation-noprogress"');
217
- assertStringIncludes(f, 'targetRef="wait-answer"');
220
+ assertStringIncludes(f, 'targetRef="gw-escalated"');
218
221
  // It opens a real, answerable escalation (blocked status + a concrete question) so it is never a
219
222
  // blank-question non-escalation that would wedge the token on the wait.
220
223
  const task = flat.match(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.99.0",
3
+ "version": "0.99.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",
@@ -161,7 +161,7 @@
161
161
  </zeebe:ioMapping>
162
162
  </bpmn:extensionElements>
163
163
  <bpmn:incoming>f_noProgress</bpmn:incoming>
164
- <bpmn:outgoing>f_noprogressWait</bpmn:outgoing>
164
+ <bpmn:outgoing>f_noprogressGate</bpmn:outgoing>
165
165
  </bpmn:serviceTask>
166
166
  <bpmn:eventBasedGateway id="gw-review-wait" name="review ready or timeout?">
167
167
  <bpmn:incoming>f_progressOk</bpmn:incoming>
@@ -199,7 +199,7 @@
199
199
  </zeebe:ioMapping>
200
200
  </bpmn:extensionElements>
201
201
  <bpmn:incoming>f_reviewStalled</bpmn:incoming>
202
- <bpmn:outgoing>f_stalledWait</bpmn:outgoing>
202
+ <bpmn:outgoing>f_stalledGate</bpmn:outgoing>
203
203
  </bpmn:serviceTask>
204
204
  <bpmn:serviceTask id="persist-escalation" name="Record escalation">
205
205
  <bpmn:extensionElements>
@@ -214,6 +214,10 @@
214
214
  </bpmn:serviceTask>
215
215
  <bpmn:exclusiveGateway id="gw-escalated" name="escalation opened?" default="f_escReenter">
216
216
  <bpmn:incoming>f_escGate</bpmn:incoming>
217
+ <bpmn:incoming>f_noprogressGate</bpmn:incoming>
218
+ <bpmn:incoming>f_stalledGate</bpmn:incoming>
219
+ <bpmn:incoming>f_blockedGate</bpmn:incoming>
220
+ <bpmn:incoming>f_maxGate</bpmn:incoming>
217
221
  <bpmn:outgoing>f_escWait</bpmn:outgoing>
218
222
  <bpmn:outgoing>f_escReenter</bpmn:outgoing>
219
223
  </bpmn:exclusiveGateway>
@@ -230,7 +234,7 @@
230
234
  </zeebe:ioMapping>
231
235
  </bpmn:extensionElements>
232
236
  <bpmn:incoming>f_guardMax</bpmn:incoming>
233
- <bpmn:outgoing>f_maxWait</bpmn:outgoing>
237
+ <bpmn:outgoing>f_maxGate</bpmn:outgoing>
234
238
  </bpmn:serviceTask>
235
239
  <bpmn:userTask id="wait-answer" name="Answer escalation">
236
240
  <bpmn:extensionElements>
@@ -242,10 +246,6 @@
242
246
  </zeebe:ioMapping>
243
247
  </bpmn:extensionElements>
244
248
  <bpmn:incoming>f_escWait</bpmn:incoming>
245
- <bpmn:incoming>f_maxWait</bpmn:incoming>
246
- <bpmn:incoming>f_stalledWait</bpmn:incoming>
247
- <bpmn:incoming>f_noprogressWait</bpmn:incoming>
248
- <bpmn:incoming>f_blockedWait</bpmn:incoming>
249
249
  <bpmn:outgoing>f_answerRecord</bpmn:outgoing>
250
250
  </bpmn:userTask>
251
251
  <bpmn:serviceTask id="record-answer" name="Record answer">
@@ -302,7 +302,7 @@
302
302
  </zeebe:ioMapping>
303
303
  </bpmn:extensionElements>
304
304
  <bpmn:incoming>f_convergeBlocked</bpmn:incoming>
305
- <bpmn:outgoing>f_blockedWait</bpmn:outgoing>
305
+ <bpmn:outgoing>f_blockedGate</bpmn:outgoing>
306
306
  </bpmn:serviceTask>
307
307
  <bpmn:endEvent id="End" name="Converged">
308
308
  <bpmn:incoming>f_finalize</bpmn:incoming>
@@ -317,7 +317,7 @@
317
317
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=convergeBlocked = true</bpmn:conditionExpression>
318
318
  </bpmn:sequenceFlow>
319
319
  <bpmn:sequenceFlow id="f_convergeOk" name="addressed / default" sourceRef="gw-converge-gate" targetRef="persist-converged" />
320
- <bpmn:sequenceFlow id="f_blockedWait" sourceRef="persist-escalation-blockedcomments" targetRef="wait-answer" />
320
+ <bpmn:sequenceFlow id="f_blockedGate" sourceRef="persist-escalation-blockedcomments" targetRef="gw-escalated" />
321
321
  <bpmn:sequenceFlow id="f_addressed" name="addressed / default" sourceRef="gw-status" targetRef="gw-guard" />
322
322
  <bpmn:sequenceFlow id="f_waiting" name="waiting" sourceRef="gw-status" targetRef="gw-guard">
323
323
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "waiting"</bpmn:conditionExpression>
@@ -335,18 +335,18 @@
335
335
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=progressed = false</bpmn:conditionExpression>
336
336
  </bpmn:sequenceFlow>
337
337
  <bpmn:sequenceFlow id="f_progressOk" name="progress / default" sourceRef="gw-progress" targetRef="gw-review-wait" />
338
- <bpmn:sequenceFlow id="f_noprogressWait" sourceRef="persist-escalation-noprogress" targetRef="wait-answer" />
338
+ <bpmn:sequenceFlow id="f_noprogressGate" sourceRef="persist-escalation-noprogress" targetRef="gw-escalated" />
339
339
  <bpmn:sequenceFlow id="f_toReviewWait" sourceRef="gw-review-wait" targetRef="wait-review" />
340
340
  <bpmn:sequenceFlow id="f_toReviewTimeout" sourceRef="gw-review-wait" targetRef="wait-review-timeout" />
341
341
  <bpmn:sequenceFlow id="f_reviewLoop" sourceRef="wait-review" targetRef="review-round" />
342
342
  <bpmn:sequenceFlow id="f_reviewStalled" sourceRef="wait-review-timeout" targetRef="persist-review-stalled" />
343
- <bpmn:sequenceFlow id="f_stalledWait" sourceRef="persist-review-stalled" targetRef="wait-answer" />
343
+ <bpmn:sequenceFlow id="f_stalledGate" sourceRef="persist-review-stalled" targetRef="gw-escalated" />
344
344
  <bpmn:sequenceFlow id="f_escGate" sourceRef="persist-escalation" targetRef="gw-escalated" />
345
345
  <bpmn:sequenceFlow id="f_escWait" sourceRef="gw-escalated" targetRef="wait-answer">
346
346
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=escalated = true</bpmn:conditionExpression>
347
347
  </bpmn:sequenceFlow>
348
348
  <bpmn:sequenceFlow id="f_escReenter" name="no escalation opened" sourceRef="gw-escalated" targetRef="gw-guard" />
349
- <bpmn:sequenceFlow id="f_maxWait" sourceRef="persist-escalation-maxrounds" targetRef="wait-answer" />
349
+ <bpmn:sequenceFlow id="f_maxGate" sourceRef="persist-escalation-maxrounds" targetRef="gw-escalated" />
350
350
  <bpmn:sequenceFlow id="f_answerRecord" sourceRef="wait-answer" targetRef="record-answer" />
351
351
  <bpmn:sequenceFlow id="f_answerLoop" sourceRef="record-answer" targetRef="review-round" />
352
352
  <bpmn:sequenceFlow id="f_finalize" sourceRef="persist-converged" targetRef="End" />
@@ -369,84 +369,84 @@
369
369
  </bpmndi:BPMNLabel>
370
370
  </bpmndi:BPMNShape>
371
371
  <bpmndi:BPMNShape id="BPMNShape_gw-guard" bpmnElement="gw-guard" isMarkerVisible="true">
372
- <dc:Bounds x="941" y="415" width="50" height="50" />
372
+ <dc:Bounds x="591" y="415" width="50" height="50" />
373
373
  <bpmndi:BPMNLabel>
374
- <dc:Bounds x="924" y="470" width="85" height="14" />
374
+ <dc:Bounds x="574" y="470" width="85" height="14" />
375
375
  </bpmndi:BPMNLabel>
376
376
  </bpmndi:BPMNShape>
377
377
  <bpmndi:BPMNShape id="BPMNShape_persist-round" bpmnElement="persist-round">
378
- <dc:Bounds x="1116" y="400" width="100" height="80" />
378
+ <dc:Bounds x="766" y="400" width="100" height="80" />
379
379
  </bpmndi:BPMNShape>
380
380
  <bpmndi:BPMNShape id="BPMNShape_check-progress" bpmnElement="check-progress">
381
- <dc:Bounds x="1316" y="400" width="100" height="80" />
381
+ <dc:Bounds x="966" y="400" width="100" height="80" />
382
382
  </bpmndi:BPMNShape>
383
383
  <bpmndi:BPMNShape id="BPMNShape_gw-progress" bpmnElement="gw-progress" isMarkerVisible="true">
384
- <dc:Bounds x="1516" y="415" width="50" height="50" />
384
+ <dc:Bounds x="1166" y="415" width="50" height="50" />
385
385
  <bpmndi:BPMNLabel>
386
- <dc:Bounds x="1511" y="382" width="60" height="28" />
386
+ <dc:Bounds x="1161" y="382" width="60" height="28" />
387
387
  </bpmndi:BPMNLabel>
388
388
  </bpmndi:BPMNShape>
389
389
  <bpmndi:BPMNShape id="BPMNShape_persist-escalation-noprogress" bpmnElement="persist-escalation-noprogress">
390
- <dc:Bounds x="1666" y="720" width="100" height="80" />
390
+ <dc:Bounds x="1316" y="720" width="100" height="80" />
391
391
  </bpmndi:BPMNShape>
392
392
  <bpmndi:BPMNShape id="BPMNShape_gw-review-wait" bpmnElement="gw-review-wait" isMarkerVisible="true">
393
- <dc:Bounds x="1691" y="415" width="50" height="50" />
393
+ <dc:Bounds x="1341" y="415" width="50" height="50" />
394
394
  <bpmndi:BPMNLabel>
395
- <dc:Bounds x="1672" y="382" width="88" height="28" />
395
+ <dc:Bounds x="1322" y="382" width="88" height="28" />
396
396
  </bpmndi:BPMNLabel>
397
397
  </bpmndi:BPMNShape>
398
398
  <bpmndi:BPMNShape id="BPMNShape_wait-review" bpmnElement="wait-review">
399
- <dc:Bounds x="1866" y="422" width="36" height="36" />
399
+ <dc:Bounds x="1516" y="422" width="36" height="36" />
400
400
  <bpmndi:BPMNLabel>
401
- <dc:Bounds x="1840" y="389" width="88" height="28" />
401
+ <dc:Bounds x="1490" y="389" width="88" height="28" />
402
402
  </bpmndi:BPMNLabel>
403
403
  </bpmndi:BPMNShape>
404
404
  <bpmndi:BPMNShape id="BPMNShape_wait-review-timeout" bpmnElement="wait-review-timeout">
405
- <dc:Bounds x="1866" y="582" width="36" height="36" />
405
+ <dc:Bounds x="1516" y="582" width="36" height="36" />
406
406
  <bpmndi:BPMNLabel>
407
- <dc:Bounds x="1856" y="623" width="56" height="42" />
407
+ <dc:Bounds x="1506" y="623" width="56" height="42" />
408
408
  </bpmndi:BPMNLabel>
409
409
  </bpmndi:BPMNShape>
410
410
  <bpmndi:BPMNShape id="BPMNShape_persist-review-stalled" bpmnElement="persist-review-stalled">
411
- <dc:Bounds x="2002" y="560" width="100" height="80" />
411
+ <dc:Bounds x="1652" y="560" width="100" height="80" />
412
412
  </bpmndi:BPMNShape>
413
413
  <bpmndi:BPMNShape id="BPMNShape_persist-escalation" bpmnElement="persist-escalation">
414
- <dc:Bounds x="566" y="560" width="100" height="80" />
414
+ <dc:Bounds x="566" y="1040" width="100" height="80" />
415
415
  </bpmndi:BPMNShape>
416
416
  <bpmndi:BPMNShape id="BPMNShape_gw-escalated" bpmnElement="gw-escalated" isMarkerVisible="true">
417
- <dc:Bounds x="766" y="575" width="50" height="50" />
417
+ <dc:Bounds x="1852" y="255" width="50" height="50" />
418
418
  <bpmndi:BPMNLabel>
419
- <dc:Bounds x="821" y="586" width="74" height="28" />
419
+ <dc:Bounds x="1760" y="290" width="74" height="28" />
420
420
  </bpmndi:BPMNLabel>
421
421
  </bpmndi:BPMNShape>
422
422
  <bpmndi:BPMNShape id="BPMNShape_persist-escalation-maxrounds" bpmnElement="persist-escalation-maxrounds">
423
- <dc:Bounds x="1116" y="880" width="100" height="80" />
423
+ <dc:Bounds x="766" y="880" width="100" height="80" />
424
424
  </bpmndi:BPMNShape>
425
425
  <bpmndi:BPMNShape id="BPMNShape_wait-answer" bpmnElement="wait-answer">
426
- <dc:Bounds x="2202" y="240" width="100" height="80" />
426
+ <dc:Bounds x="2002" y="240" width="100" height="80" />
427
427
  </bpmndi:BPMNShape>
428
428
  <bpmndi:BPMNShape id="BPMNShape_record-answer" bpmnElement="record-answer">
429
- <dc:Bounds x="2402" y="240" width="100" height="80" />
429
+ <dc:Bounds x="2202" y="240" width="100" height="80" />
430
430
  </bpmndi:BPMNShape>
431
431
  <bpmndi:BPMNShape id="BPMNShape_persist-converged" bpmnElement="persist-converged">
432
- <dc:Bounds x="916" y="80" width="100" height="80" />
432
+ <dc:Bounds x="966" y="80" width="100" height="80" />
433
433
  </bpmndi:BPMNShape>
434
434
  <bpmndi:BPMNShape id="BPMNShape_check-converge" bpmnElement="check-converge">
435
435
  <dc:Bounds x="566" y="80" width="100" height="80" />
436
436
  </bpmndi:BPMNShape>
437
437
  <bpmndi:BPMNShape id="BPMNShape_gw-converge-gate" bpmnElement="gw-converge-gate" isMarkerVisible="true">
438
- <dc:Bounds x="766" y="95" width="50" height="50" />
438
+ <dc:Bounds x="791" y="95" width="50" height="50" />
439
439
  <bpmndi:BPMNLabel>
440
- <dc:Bounds x="754" y="62" width="74" height="28" />
440
+ <dc:Bounds x="779" y="62" width="74" height="28" />
441
441
  </bpmndi:BPMNLabel>
442
442
  </bpmndi:BPMNShape>
443
443
  <bpmndi:BPMNShape id="BPMNShape_persist-escalation-blockedcomments" bpmnElement="persist-escalation-blockedcomments">
444
- <dc:Bounds x="916" y="240" width="100" height="80" />
444
+ <dc:Bounds x="966" y="240" width="100" height="80" />
445
445
  </bpmndi:BPMNShape>
446
446
  <bpmndi:BPMNShape id="BPMNShape_End" bpmnElement="End">
447
- <dc:Bounds x="1148" y="102" width="36" height="36" />
447
+ <dc:Bounds x="1173" y="102" width="36" height="36" />
448
448
  <bpmndi:BPMNLabel>
449
- <dc:Bounds x="1132" y="143" width="69" height="14" />
449
+ <dc:Bounds x="1157" y="143" width="69" height="14" />
450
450
  </bpmndi:BPMNLabel>
451
451
  </bpmndi:BPMNShape>
452
452
  <bpmndi:BPMNEdge id="BPMNEdge_f_start" bpmnElement="f_start">
@@ -466,71 +466,73 @@
466
466
  </bpmndi:BPMNEdge>
467
467
  <bpmndi:BPMNEdge id="BPMNEdge_f_toConvergeGate" bpmnElement="f_toConvergeGate">
468
468
  <di:waypoint x="666" y="120" />
469
- <di:waypoint x="766" y="120" />
469
+ <di:waypoint x="791" y="120" />
470
470
  </bpmndi:BPMNEdge>
471
471
  <bpmndi:BPMNEdge id="BPMNEdge_f_convergeOk" bpmnElement="f_convergeOk">
472
- <di:waypoint x="816" y="120" />
473
- <di:waypoint x="916" y="120" />
472
+ <di:waypoint x="841" y="120" />
473
+ <di:waypoint x="966" y="120" />
474
474
  <bpmndi:BPMNLabel>
475
- <dc:Bounds x="827" y="125" width="78" height="28" />
475
+ <dc:Bounds x="865" y="87" width="78" height="28" />
476
476
  </bpmndi:BPMNLabel>
477
477
  </bpmndi:BPMNEdge>
478
478
  <bpmndi:BPMNEdge id="BPMNEdge_f_finalize" bpmnElement="f_finalize">
479
- <di:waypoint x="1016" y="120" />
480
- <di:waypoint x="1148" y="120" />
479
+ <di:waypoint x="1066" y="120" />
480
+ <di:waypoint x="1173" y="120" />
481
481
  </bpmndi:BPMNEdge>
482
482
  <bpmndi:BPMNEdge id="BPMNEdge_f_guardOk" bpmnElement="f_guardOk">
483
- <di:waypoint x="991" y="440" />
484
- <di:waypoint x="1116" y="440" />
483
+ <di:waypoint x="641" y="440" />
484
+ <di:waypoint x="766" y="440" />
485
485
  <bpmndi:BPMNLabel>
486
- <dc:Bounds x="1014" y="418" width="60" height="14" />
486
+ <dc:Bounds x="664" y="418" width="60" height="14" />
487
487
  </bpmndi:BPMNLabel>
488
488
  </bpmndi:BPMNEdge>
489
489
  <bpmndi:BPMNEdge id="BPMNEdge_f_roundWait" bpmnElement="f_roundWait">
490
- <di:waypoint x="1216" y="440" />
491
- <di:waypoint x="1316" y="440" />
490
+ <di:waypoint x="866" y="440" />
491
+ <di:waypoint x="966" y="440" />
492
492
  </bpmndi:BPMNEdge>
493
493
  <bpmndi:BPMNEdge id="BPMNEdge_f_checkGate" bpmnElement="f_checkGate">
494
- <di:waypoint x="1416" y="440" />
495
- <di:waypoint x="1516" y="440" />
494
+ <di:waypoint x="1066" y="440" />
495
+ <di:waypoint x="1166" y="440" />
496
496
  </bpmndi:BPMNEdge>
497
497
  <bpmndi:BPMNEdge id="BPMNEdge_f_progressOk" bpmnElement="f_progressOk">
498
- <di:waypoint x="1566" y="440" />
499
- <di:waypoint x="1691" y="440" />
498
+ <di:waypoint x="1216" y="440" />
499
+ <di:waypoint x="1341" y="440" />
500
500
  <bpmndi:BPMNLabel>
501
- <dc:Bounds x="1591" y="407" width="71" height="28" />
501
+ <dc:Bounds x="1241" y="407" width="71" height="28" />
502
502
  </bpmndi:BPMNLabel>
503
503
  </bpmndi:BPMNEdge>
504
504
  <bpmndi:BPMNEdge id="BPMNEdge_f_toReviewWait" bpmnElement="f_toReviewWait">
505
- <di:waypoint x="1741" y="440" />
506
- <di:waypoint x="1866" y="440" />
505
+ <di:waypoint x="1391" y="440" />
506
+ <di:waypoint x="1516" y="440" />
507
507
  </bpmndi:BPMNEdge>
508
508
  <bpmndi:BPMNEdge id="BPMNEdge_f_reviewLoop" bpmnElement="f_reviewLoop">
509
- <di:waypoint x="1884" y="458" />
510
- <di:waypoint x="1884" y="500" />
509
+ <di:waypoint x="1534" y="458" />
510
+ <di:waypoint x="1534" y="500" />
511
511
  <di:waypoint x="266" y="500" />
512
512
  <di:waypoint x="266" y="160" />
513
513
  </bpmndi:BPMNEdge>
514
514
  <bpmndi:BPMNEdge id="BPMNEdge_f_escReenter" bpmnElement="f_escReenter">
515
- <di:waypoint x="791" y="575" />
516
- <di:waypoint x="791" y="440" />
517
- <di:waypoint x="941" y="440" />
515
+ <di:waypoint x="1877" y="255" />
516
+ <di:waypoint x="1877" y="220" />
517
+ <di:waypoint x="616" y="220" />
518
+ <di:waypoint x="616" y="415" />
518
519
  <bpmndi:BPMNLabel>
519
- <dc:Bounds x="796" y="507" width="74" height="42" />
520
+ <dc:Bounds x="1410" y="173" width="74" height="42" />
520
521
  </bpmndi:BPMNLabel>
521
522
  </bpmndi:BPMNEdge>
522
523
  <bpmndi:BPMNEdge id="BPMNEdge_f_convergeBlocked" bpmnElement="f_convergeBlocked">
523
- <di:waypoint x="791" y="145" />
524
- <di:waypoint x="791" y="280" />
525
- <di:waypoint x="916" y="280" />
524
+ <di:waypoint x="816" y="145" />
525
+ <di:waypoint x="816" y="220" />
526
+ <di:waypoint x="1016" y="220" />
527
+ <di:waypoint x="1016" y="240" />
526
528
  <bpmndi:BPMNLabel>
527
- <dc:Bounds x="796" y="199" width="81" height="28" />
529
+ <dc:Bounds x="821" y="169" width="81" height="28" />
528
530
  </bpmndi:BPMNLabel>
529
531
  </bpmndi:BPMNEdge>
530
532
  <bpmndi:BPMNEdge id="BPMNEdge_f_addressed" bpmnElement="f_addressed">
531
533
  <di:waypoint x="441" y="145" />
532
534
  <di:waypoint x="441" y="440" />
533
- <di:waypoint x="941" y="440" />
535
+ <di:waypoint x="591" y="440" />
534
536
  <bpmndi:BPMNLabel>
535
537
  <dc:Bounds x="446" y="279" width="78" height="28" />
536
538
  </bpmndi:BPMNLabel>
@@ -538,87 +540,81 @@
538
540
  <bpmndi:BPMNEdge id="BPMNEdge_f_waiting" bpmnElement="f_waiting">
539
541
  <di:waypoint x="441" y="145" />
540
542
  <di:waypoint x="441" y="440" />
541
- <di:waypoint x="941" y="440" />
543
+ <di:waypoint x="591" y="440" />
542
544
  <bpmndi:BPMNLabel>
543
545
  <dc:Bounds x="380" y="286" width="56" height="14" />
544
546
  </bpmndi:BPMNLabel>
545
547
  </bpmndi:BPMNEdge>
546
548
  <bpmndi:BPMNEdge id="BPMNEdge_f_escalate" bpmnElement="f_escalate">
547
549
  <di:waypoint x="441" y="145" />
548
- <di:waypoint x="441" y="600" />
549
- <di:waypoint x="566" y="600" />
550
+ <di:waypoint x="441" y="1080" />
551
+ <di:waypoint x="566" y="1080" />
550
552
  <bpmndi:BPMNLabel>
551
- <dc:Bounds x="446" y="506" width="81" height="28" />
553
+ <dc:Bounds x="446" y="746" width="81" height="28" />
552
554
  </bpmndi:BPMNLabel>
553
555
  </bpmndi:BPMNEdge>
554
556
  <bpmndi:BPMNEdge id="BPMNEdge_f_guardMax" bpmnElement="f_guardMax">
555
- <di:waypoint x="991" y="440" />
556
- <di:waypoint x="1096" y="440" />
557
- <di:waypoint x="1096" y="920" />
558
- <di:waypoint x="1116" y="920" />
557
+ <di:waypoint x="641" y="440" />
558
+ <di:waypoint x="746" y="440" />
559
+ <di:waypoint x="746" y="920" />
560
+ <di:waypoint x="766" y="920" />
559
561
  <bpmndi:BPMNLabel>
560
- <dc:Bounds x="1101" y="673" width="74" height="14" />
562
+ <dc:Bounds x="751" y="673" width="74" height="14" />
561
563
  </bpmndi:BPMNLabel>
562
564
  </bpmndi:BPMNEdge>
563
565
  <bpmndi:BPMNEdge id="BPMNEdge_f_noProgress" bpmnElement="f_noProgress">
564
- <di:waypoint x="1566" y="440" />
565
- <di:waypoint x="1646" y="440" />
566
- <di:waypoint x="1646" y="760" />
567
- <di:waypoint x="1666" y="760" />
566
+ <di:waypoint x="1216" y="440" />
567
+ <di:waypoint x="1296" y="440" />
568
+ <di:waypoint x="1296" y="760" />
569
+ <di:waypoint x="1316" y="760" />
568
570
  <bpmndi:BPMNLabel>
569
- <dc:Bounds x="1563" y="593" width="78" height="14" />
571
+ <dc:Bounds x="1213" y="593" width="78" height="14" />
570
572
  </bpmndi:BPMNLabel>
571
573
  </bpmndi:BPMNEdge>
572
574
  <bpmndi:BPMNEdge id="BPMNEdge_f_toReviewTimeout" bpmnElement="f_toReviewTimeout">
573
- <di:waypoint x="1716" y="465" />
574
- <di:waypoint x="1716" y="600" />
575
- <di:waypoint x="1866" y="600" />
575
+ <di:waypoint x="1366" y="465" />
576
+ <di:waypoint x="1366" y="600" />
577
+ <di:waypoint x="1516" y="600" />
576
578
  </bpmndi:BPMNEdge>
577
- <bpmndi:BPMNEdge id="BPMNEdge_f_escWait" bpmnElement="f_escWait">
578
- <di:waypoint x="791" y="625" />
579
- <di:waypoint x="791" y="980" />
580
- <di:waypoint x="2182" y="980" />
581
- <di:waypoint x="2182" y="300" />
582
- <di:waypoint x="2202" y="300" />
579
+ <bpmndi:BPMNEdge id="BPMNEdge_f_blockedGate" bpmnElement="f_blockedGate">
580
+ <di:waypoint x="1066" y="280" />
581
+ <di:waypoint x="1852" y="280" />
583
582
  </bpmndi:BPMNEdge>
584
- <bpmndi:BPMNEdge id="BPMNEdge_f_blockedWait" bpmnElement="f_blockedWait">
585
- <di:waypoint x="1016" y="280" />
586
- <di:waypoint x="2202" y="280" />
587
- </bpmndi:BPMNEdge>
588
- <bpmndi:BPMNEdge id="BPMNEdge_f_noprogressWait" bpmnElement="f_noprogressWait">
589
- <di:waypoint x="1766" y="780" />
590
- <di:waypoint x="1786" y="780" />
591
- <di:waypoint x="1786" y="980" />
592
- <di:waypoint x="2252" y="980" />
593
- <di:waypoint x="2252" y="320" />
583
+ <bpmndi:BPMNEdge id="BPMNEdge_f_noprogressGate" bpmnElement="f_noprogressGate">
584
+ <di:waypoint x="1416" y="760" />
585
+ <di:waypoint x="1877" y="760" />
586
+ <di:waypoint x="1877" y="305" />
594
587
  </bpmndi:BPMNEdge>
595
588
  <bpmndi:BPMNEdge id="BPMNEdge_f_reviewStalled" bpmnElement="f_reviewStalled">
596
- <di:waypoint x="1902" y="600" />
597
- <di:waypoint x="2002" y="600" />
589
+ <di:waypoint x="1552" y="600" />
590
+ <di:waypoint x="1652" y="600" />
598
591
  </bpmndi:BPMNEdge>
599
- <bpmndi:BPMNEdge id="BPMNEdge_f_stalledWait" bpmnElement="f_stalledWait">
600
- <di:waypoint x="2052" y="560" />
601
- <di:waypoint x="2052" y="280" />
602
- <di:waypoint x="2202" y="280" />
592
+ <bpmndi:BPMNEdge id="BPMNEdge_f_stalledGate" bpmnElement="f_stalledGate">
593
+ <di:waypoint x="1752" y="600" />
594
+ <di:waypoint x="1877" y="600" />
595
+ <di:waypoint x="1877" y="305" />
603
596
  </bpmndi:BPMNEdge>
604
597
  <bpmndi:BPMNEdge id="BPMNEdge_f_escGate" bpmnElement="f_escGate">
605
- <di:waypoint x="666" y="600" />
606
- <di:waypoint x="766" y="600" />
598
+ <di:waypoint x="666" y="1080" />
599
+ <di:waypoint x="1877" y="1080" />
600
+ <di:waypoint x="1877" y="305" />
607
601
  </bpmndi:BPMNEdge>
608
- <bpmndi:BPMNEdge id="BPMNEdge_f_maxWait" bpmnElement="f_maxWait">
609
- <di:waypoint x="1216" y="940" />
610
- <di:waypoint x="1236" y="940" />
611
- <di:waypoint x="1236" y="980" />
612
- <di:waypoint x="2252" y="980" />
613
- <di:waypoint x="2252" y="320" />
602
+ <bpmndi:BPMNEdge id="BPMNEdge_f_escWait" bpmnElement="f_escWait">
603
+ <di:waypoint x="1902" y="280" />
604
+ <di:waypoint x="2002" y="280" />
605
+ </bpmndi:BPMNEdge>
606
+ <bpmndi:BPMNEdge id="BPMNEdge_f_maxGate" bpmnElement="f_maxGate">
607
+ <di:waypoint x="866" y="920" />
608
+ <di:waypoint x="1877" y="920" />
609
+ <di:waypoint x="1877" y="305" />
614
610
  </bpmndi:BPMNEdge>
615
611
  <bpmndi:BPMNEdge id="BPMNEdge_f_answerRecord" bpmnElement="f_answerRecord">
616
- <di:waypoint x="2302" y="280" />
617
- <di:waypoint x="2402" y="280" />
612
+ <di:waypoint x="2102" y="280" />
613
+ <di:waypoint x="2202" y="280" />
618
614
  </bpmndi:BPMNEdge>
619
615
  <bpmndi:BPMNEdge id="BPMNEdge_f_answerLoop" bpmnElement="f_answerLoop">
620
- <di:waypoint x="2452" y="320" />
621
- <di:waypoint x="2452" y="340" />
616
+ <di:waypoint x="2252" y="320" />
617
+ <di:waypoint x="2252" y="340" />
622
618
  <di:waypoint x="266" y="340" />
623
619
  <di:waypoint x="266" y="160" />
624
620
  </bpmndi:BPMNEdge>
@@ -9,10 +9,13 @@
9
9
  // durable review wait instead of routing here (see app/roundResultDefault.ts). This worker keeps
10
10
  // the same rule as defence-in-depth via the canonical taxonomy: if a blank-question job ever
11
11
  // reaches it, it opens NO escalation and reports `escalated:false` rather than FABRICATING a
12
- // question (the retired failure mode) or throwing an un-remediable incident. The convergence-loop
13
- // model branches on that `escalated` output (`gw-escalated`): a `false` return re-enters the loop
14
- // via `gw-guard` instead of flowing into the `wait-answer` catch, so a non-escalation can never
15
- // wedge a token on a durable wait that has no escalation for a human to answer.
12
+ // question (the retired failure mode) or throwing an un-remediable incident. EVERY convergence-loop
13
+ // escalation arm the agent-verdict `persist-escalation` AND the four control-flow arms
14
+ // (`persist-escalation-noprogress`, `persist-review-stalled`, `persist-escalation-blockedcomments`,
15
+ // `persist-escalation-maxrounds`) now routes its `escalated` output through the `gw-escalated`
16
+ // gateway (issue #333): a `false` return re-enters the loop via `gw-guard` instead of flowing into
17
+ // the `wait-answer` catch, so a non-escalation (e.g. a blank `convergeBlockReason`) can never wedge
18
+ // a token on a durable wait that has no escalation for a human to answer.
16
19
  import type { AppJobHandler } from "@nanobpm/urban";
17
20
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
18
21
  import { classifyEscalation } from "../../app/escalationTaxonomy.ts";