@nanobpm/nano-workforce 0.98.1 → 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,17 @@
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
+
8
+ # [0.99.0](https://github.com/nanobpm/nano-workforce/compare/v0.98.1...v0.99.0) (2026-08-19)
9
+
10
+
11
+ ### Features
12
+
13
+ * **review-loop:** re-express the review-ready wait on the ReadinessProbe gate ([#259](https://github.com/nanobpm/nano-workforce/issues/259)) ([#338](https://github.com/nanobpm/nano-workforce/issues/338)) ([4d86509](https://github.com/nanobpm/nano-workforce/commit/4d86509f1f55dd9b31417add323e04da5b574b3d)), closes [#258](https://github.com/nanobpm/nano-workforce/issues/258) [#258](https://github.com/nanobpm/nano-workforce/issues/258)
14
+
1
15
  ## [0.98.1](https://github.com/nanobpm/nano-workforce/compare/v0.98.0...v0.98.1) (2026-08-19)
2
16
 
3
17
 
package/SPEC.md CHANGED
@@ -29,7 +29,7 @@ surface are proposed and open for adjustment.
29
29
  submit (form / webhook)
30
30
 
31
31
 
32
- ┌───────────────────┐ review-ready (msg) ┌──────────┐
32
+ ┌───────────────────┐ readiness-ready (msg) ┌──────────┐
33
33
  │ convergence-loop │◀───────────────────────────────│ poller │
34
34
  │ (BPMN) │ └────┬─────┘
35
35
  └─────────┬─────────┘◀───────────────┐ │ polls
@@ -48,8 +48,10 @@ surface are proposed and open for adjustment.
48
48
  agent worker slots and job timeouts are never held hostage to Copilot's reply
49
49
  latency.
50
50
  - **Poller**: an in-app background loop that watches waiting PRs and publishes
51
- the `review-ready` message when a new review lands (no GitHub webhook needed;
52
- works behind NAT).
51
+ the canonical `readiness-ready` wait-gate message (ADR 0001 §2) when a new
52
+ review lands — the "out-of-band poller-correlated shape" the review-ready wait
53
+ is re-expressed on (#259), so there is one "wait for the world" mechanism (no
54
+ GitHub webhook needed; works behind NAT).
53
55
 
54
56
  ## 3. Repository layout
55
57
 
@@ -100,7 +102,7 @@ known at submit time, carried as a process variable and stored on the DB row.
100
102
  │ ├── converged → [Mark converged] → (end: converged)
101
103
  │ │
102
104
  │ ├── addressed → [Record round] → <event-based gateway: review ready or timeout?>
103
- │ │ ├── review-ready (msg catch, key = prKey) → round++ ─────┐
105
+ │ │ ├── readiness-ready (msg catch, key = prKey) → round++ ─┐
104
106
  │ │ └── =reviewWaitTimeout (timer catch) │
105
107
  │ │ → [Escalate: review stalled] (blocked) │
106
108
  │ │ → [Wait: wait-answer userTask] ─────────────────────┤
@@ -127,8 +129,9 @@ Guard: before each Review round, if round > MAX_ROUNDS → force an escalation
127
129
  ```
128
130
 
129
131
  Notes:
130
- - On `addressed`, the loop parks at an **event-based gateway** that races a
131
- `review-ready` message (correlated by the poller when a fresh review lands)
132
+ - On `addressed`, the loop parks at an **event-based gateway** that races the
133
+ canonical `readiness-ready` wait-gate message (ADR 0001 §2; correlated by the
134
+ poller when a fresh review lands)
132
135
  against a `=reviewWaitTimeout` timer (seeded at submit from
133
136
  `NANO_PR_REVIEW_WAIT_TIMEOUT`, default `PT20M`). Whichever fires first
134
137
  withdraws the other — the message arm advances `round`, the timer arm escalates
@@ -136,7 +139,8 @@ Notes:
136
139
  hanging forever. Because `persist-round` already recorded this `round` as
137
140
  `addressed` before the gateway, the timer arm opens the escalation **without
138
141
  re-recording the round** (it passes `recordRound=false`), so a single round is
139
- never logged as both `addressed` and `blocked`. This replaced a bare
142
+ never logged as both `addressed` and `blocked`. This wait is re-expressed on the
143
+ ONE `ReadinessProbe` wait-gate primitive (#258, ADR 0001 §2); it replaced a bare
140
144
  `review-ready` catch that could hang
141
145
  indefinitely: Copilot won't re-review a round with no new commit and routinely
142
146
  dismisses a re-request, so with no timeout a review that never arrives wedged
@@ -210,7 +214,7 @@ Consequences the prompt (`resources/prompts/review-round.md`) encodes:
210
214
  | message | correlationKey | published by | payload |
211
215
  |---|---|---|---|
212
216
  | `pr-submitted` | — (start) | submit route/webhook | `{repo, prNumber, prUrl, prKey}` |
213
- | `review-ready` | `prKey` | **poller** | `{reviewId, reviewState, submittedAt}` |
217
+ | `readiness-ready` | `prKey` | **poller** | `{ready, detail?}` (ADR 0001 §2 wait-gate; the review-ready wait, re-expressed on the ReadinessProbe gate — #259) |
214
218
  | `deps-cleared` | `prKey` | **poller** (merge) | — (all `Depends-on` PRs merged) |
215
219
  | `merge-ready` | `prKey` | **poller** (merge) | `{mergeState}` (`ready` \| `conflict` \| `blocked`); when `blocked`, also `{failingChecks, failingChecksList}` for the `senior:fix-ci` branch |
216
220
  | `merge-landed` | `prKey` | **poller** (merge) | — (queued PR merged, or merged out-of-band) |
@@ -368,8 +372,8 @@ An in-app loop (interval `NANO_PR_POLL_MS`, default 60s):
368
372
  1. `SELECT pr_key, repo, number, waiting_since, last_review_id, last_nudge_at FROM pull_requests WHERE status = 'waiting_review'`.
369
373
  2. For each, GET the PR's reviews from GitHub; find the newest review submitted
370
374
  after `waiting_since` with id > `last_review_id`.
371
- 3. If found → publish `review-ready` (key = `pr_key`, `{reviewId, ...}`) and set
372
- `last_review_id`.
375
+ 3. If found → publish the canonical `readiness-ready` wait-gate message (key =
376
+ `pr_key`, `{ready, detail}`) and set `last_review_id`.
373
377
  4. If **not** found → ensure a review is in flight: unless Copilot is already a
374
378
  pending reviewer, **re-request** it (REST `requested_reviewers`, exact login
375
379
  `copilot-pull-request-reviewer[bot]`) and record `last_nudge_at`. This is
@@ -377,7 +381,7 @@ An in-app loop (interval `NANO_PR_POLL_MS`, default 60s):
377
381
  so a re-request Copilot dismisses is retried without hammering the API. A repo
378
382
  where Copilot isn't an assignable reviewer (HTTP 422) is left to the process's
379
383
  review-wait timer (§4). This closes the stall where Copilot won't spontaneously
380
- re-review and silently dismisses a re-request, so no `review-ready` ever fires.
384
+ re-review and silently dismisses a re-request, so no `readiness-ready` ever fires.
381
385
 
382
386
  Requires a GitHub token (`GITHUB_TOKEN`) or the host `gh` CLI. One cheap API call
383
387
  per waiting PR per interval (plus at most one reviewer-state check + re-request per
@@ -644,7 +648,7 @@ but encode incompatible decisions about a shared contract** — a genuine design
644
648
  `baseRef`) that let large monorepos provision within the clone timeout (#287); the
645
649
  harness is PR-agnostic and provisions from that envelope. The worker stays a pure
646
650
  provisioner.
647
- - **review-ready via GitHub webhook** — same message, swappable faster trigger,
651
+ - **readiness-ready via GitHub webhook** — same `readiness-ready` message, swappable faster trigger,
648
652
  when the app is publicly reachable. Deferred (poller-only for v1).
649
653
  - **Supervised vs external worker** — the agent runs as an external
650
654
  `c8ctl nano work` daemon by default; a supervised in-server mode is possible
@@ -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
+ });
package/app/github.ts CHANGED
@@ -348,7 +348,7 @@ export async function hasPendingCopilotReviewer(
348
348
  }
349
349
 
350
350
  /** Request a fresh Copilot review on the PR (REST reviewers endpoint, exact `[bot]` login), so
351
- * the process's `review-ready` catch can eventually fire. Returns `"requested"` on success,
351
+ * the process's `readiness-ready` review wait-gate catch can eventually fire. Returns `"requested"` on success,
352
352
  * `"unavailable"` when Copilot is not an assignable reviewer on that repo (HTTP 422 — e.g.
353
353
  * Copilot review not enabled there), or `null` when no transport is usable. Never throws for the
354
354
  * 422 "not assignable" case; only a genuine transport failure propagates. */
@@ -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
+ });
package/app/readiness.ts CHANGED
@@ -150,6 +150,14 @@ export const DEFAULT_ATTEMPT_TIMEOUT_MS = 60_000;
150
150
  /** Default gate timeout when neither the descriptor nor `NANO_READINESS_POLL_TIMEOUT` supplies one. */
151
151
  export const DEFAULT_READINESS_TIMEOUT = "PT30M";
152
152
 
153
+ /** The ONE canonical readiness-signal message the wait-gate correlates on to release the wait
154
+ * (ADR 0001 §2) — the correlation key is per-caller (`=gateKey` for the in-flow probe worker,
155
+ * `=prKey` for the review-ready wait), not a single fixed key. Both the in-flow probe worker
156
+ * (`pr.readiness-probe`) and the out-of-band
157
+ * poller-correlated shape used by the review-ready migration (#259, per #258 pinned decision 3)
158
+ * publish under this ONE name — so there is a single "wait for the world" message, no bespoke twin. */
159
+ export const READINESS_READY_MESSAGE = "readiness-ready";
160
+
153
161
  const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
154
162
  const num = (v: unknown): number | undefined =>
155
163
  typeof v === "number" && Number.isFinite(v) ? v : undefined;
@@ -0,0 +1,92 @@
1
+ // Structural guard for the review-ready wait re-expressed on the canonical ReadinessProbe
2
+ // wait-gate contract (ADR 0001 §2, issue #259).
3
+ //
4
+ // #258 landed the generic durable wait-gate: an event-based gateway racing a canonical
5
+ // `readiness-ready` signal against a bounded timer that escalates. #259 collapses nwf's bespoke
6
+ // review-ready poll path onto that ONE primitive — so there is a single "wait for the world"
7
+ // message, no drift-prone twin. The out-of-band poller-correlated shape (#258 pinned decision 3)
8
+ // keeps the canonical self-scheduling poller (`pollReviews` in app/service.ts) publishing the
9
+ // signal when a fresh review lands, while the convergence-loop parks on the canonical gate.
10
+ //
11
+ // These pure text assertions over the committed BPMN lock the migrated wait: it now catches the
12
+ // canonical `readiness-ready` message (the bespoke `review-ready` message is GONE), and it stays
13
+ // BOUNDED — the event-based gateway still races the signal against the `reviewWaitTimeout` timer,
14
+ // whose arm escalates to a human. Matches the repo's model-guard style (see roundResultDefault.test.ts).
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import { test } from "node:test";
18
+ import { assert, assertStringIncludes } from "#test-assert";
19
+ import { READINESS_READY_MESSAGE } from "./readiness.ts";
20
+
21
+ const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
22
+ const flat = bpmn.replace(/\s+/g, " ");
23
+
24
+ test("the review wait catches the canonical readiness-ready message, not a bespoke twin", () => {
25
+ // The one canonical wait-gate signal (#258), re-used here — no separate `review-ready` message.
26
+ const msg = flat.match(/<bpmn:message\b[^>]*\bname="readiness-ready"[\s\S]*?<\/bpmn:message>/);
27
+ assert(msg, "convergence-loop must declare the canonical readiness-ready message");
28
+ assertStringIncludes(msg![0], 'correlationKey="=prKey"', "correlation shape is preserved (=prKey)");
29
+
30
+ // The bespoke review-ready message/shape must be fully retired (one mechanism, no drift).
31
+ assert(!/name="review-ready"/.test(flat), "the bespoke review-ready message must be gone");
32
+ assert(!/id="Message_reviewReady"/.test(flat), "the bespoke Message_reviewReady must be gone");
33
+ assert(!/id="ReviewReady"/.test(flat), "the bespoke ReviewReady payload shape must be gone");
34
+
35
+ // And the constant the poller publishes under is the very same canonical name.
36
+ assert(new RegExp(`name="${READINESS_READY_MESSAGE}"`).test(flat), "message name matches the canonical constant");
37
+ });
38
+
39
+ test("the review-ready catch subscribes to the canonical readiness-ready message", () => {
40
+ const wait = flat.match(/<bpmn:intermediateCatchEvent\b[^>]*\bid="wait-review"[\s\S]*?<\/bpmn:intermediateCatchEvent>/);
41
+ assert(wait, "the wait-review catch event must exist");
42
+ assertStringIncludes(
43
+ wait![0],
44
+ 'messageRef="Message_readinessReady"',
45
+ "wait-review must catch the canonical readiness-ready message",
46
+ );
47
+ // The round increment (forward progress) is preserved on the same catch.
48
+ assertStringIncludes(wait![0], 'target="round"', "wait-review still advances the round on a fresh signal");
49
+ });
50
+
51
+ test("the migrated wait stays bounded: an event-based gateway races the signal against a timer", () => {
52
+ // The gate shape from #258: an event-based gateway forks to the signal catch and a timer catch.
53
+ const gw = flat.match(/<bpmn:eventBasedGateway\b[^>]*\bid="gw-review-wait"[^>]*>/);
54
+ assert(gw, "the review wait must be an event-based gateway race (bounded gate)");
55
+
56
+ const timeout = flat.match(
57
+ /<bpmn:intermediateCatchEvent\b[^>]*\bid="wait-review-timeout"[\s\S]*?<\/bpmn:intermediateCatchEvent>/,
58
+ );
59
+ assert(timeout, "the bounded timer arm (wait-review-timeout) must exist");
60
+ assertStringIncludes(timeout![0], "<bpmn:timerEventDefinition", "the timer arm bounds the wait");
61
+ assertStringIncludes(timeout![0], "=reviewWaitTimeout", "the timer is seeded with the review-wait timeout");
62
+
63
+ // The gateway forks to BOTH the signal catch and the timer catch (the race).
64
+ assert(
65
+ /sourceRef="gw-review-wait"[^>]*targetRef="wait-review"|targetRef="wait-review"[^>]*sourceRef="gw-review-wait"/.test(
66
+ flat,
67
+ ),
68
+ "the gateway races toward the readiness signal catch",
69
+ );
70
+ assert(
71
+ /sourceRef="gw-review-wait"[^>]*targetRef="wait-review-timeout"|targetRef="wait-review-timeout"[^>]*sourceRef="gw-review-wait"/.test(
72
+ flat,
73
+ ),
74
+ "the gateway races toward the bounded timer catch",
75
+ );
76
+ });
77
+
78
+ test("the timeout arm escalates to a human (the wait cannot hang forever)", () => {
79
+ // timer catch → persist-review-stalled (records the escalation) → wait-answer (native userTask).
80
+ assert(
81
+ /sourceRef="wait-review-timeout"[^>]*targetRef="persist-review-stalled"|targetRef="persist-review-stalled"[^>]*sourceRef="wait-review-timeout"/.test(
82
+ flat,
83
+ ),
84
+ "a timed-out review wait routes to the stalled-review escalation",
85
+ );
86
+ assert(
87
+ /sourceRef="persist-review-stalled"[^>]*targetRef="wait-answer"|targetRef="wait-answer"[^>]*sourceRef="persist-review-stalled"/.test(
88
+ flat,
89
+ ),
90
+ "the stalled-review escalation parks on the human answer userTask",
91
+ );
92
+ });
package/app/reviewWait.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // Two knobs govern the review-wait watchdog:
7
7
  // • the *timeout* — an ISO-8601 duration handed to the process's `wait-review-timeout` timer
8
- // catch (the far side of the event-based-gateway race against `review-ready`). If no fresh
8
+ // catch (the far side of the event-based-gateway race against `readiness-ready`). If no fresh
9
9
  // review arrives within it, the loop escalates to a human instead of hanging forever.
10
10
  // • the *nudge cooldown* — how long the poller waits between automatic Copilot re-requests for
11
11
  // one waiting PR, so a re-request that Copilot dismisses is retried without hammering the API.
@@ -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/app/service.ts CHANGED
@@ -54,7 +54,14 @@ import {
54
54
  planTasks,
55
55
  } from "./plan.ts";
56
56
  import { derivePromotionState, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
57
- import { defaultProbeExec, type ProbeExec, probeOnce, type ReadinessProbe, readinessTimeout } from "./readiness.ts";
57
+ import {
58
+ defaultProbeExec,
59
+ type ProbeExec,
60
+ probeOnce,
61
+ READINESS_READY_MESSAGE,
62
+ type ReadinessProbe,
63
+ readinessTimeout,
64
+ } from "./readiness.ts";
58
65
  import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
59
66
  import { trialMergeAudits } from "./trialMerge.ts";
60
67
  import {
@@ -668,7 +675,17 @@ export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
668
675
 
669
676
  /** One review-ready poll pass (SPEC §10): for every PR waiting on a review, fetch its GitHub
670
677
  * reviews (via the host `gh` CLI or a token — see `app/github.ts`) and, on a fresh one,
671
- * correlate a `review-ready` message to resume the loop. */
678
+ * correlate the canonical `readiness-ready` message to resume the loop.
679
+ *
680
+ * This is the review-ready wait re-expressed on the ONE `ReadinessProbe` wait-gate contract
681
+ * (ADR 0001 §2, issue #259): the convergence-loop parks on the canonical `readiness-ready` gate
682
+ * (event-based gateway racing the signal against the bounded `reviewWaitTimeout` timer), and this
683
+ * canonical self-scheduling poller publishes that signal out-of-band when a fresh review lands —
684
+ * the "out-of-band poller-correlated shape" #258 pinned decision 3 reserved for exactly this
685
+ * migration. There is no bespoke `review-ready` message any more: one mechanism, no drift. The
686
+ * fresh-review detection + Copilot nudge below stay here because review freshness is inherently
687
+ * STATEFUL (keyed off `last_review_id`/`waiting_since`), which the stateless probe matchers cannot
688
+ * subsume — the poller owns the forward-progress guarantee, the gate owns the bounded wait. */
672
689
  async function pollReviews(data: DataLayer, engine: EngineClient, token: string) {
673
690
  const waiting = await prs(data).find({ status: "waiting_review" });
674
691
  for (const pr of waiting) {
@@ -692,9 +709,12 @@ async function pollReviews(data: DataLayer, engine: EngineClient, token: string)
692
709
  }
693
710
  await prs(data).update(prKey, { last_review_id: fresh.id, status: "converging", updated_at: now() });
694
711
  await engine.publishMessage({
695
- name: "review-ready",
712
+ name: READINESS_READY_MESSAGE,
696
713
  correlationKey: prKey,
697
- variables: { reviewId: fresh.id, reviewState: fresh.state, submittedAt: fresh.submitted_at },
714
+ // The canonical readiness-signal payload (`ReadinessReady` shape): the gate only needs to
715
+ // know the probe went green. The fresh review's id/state is bookkept above on the PR row,
716
+ // not carried on the message — the convergence-loop consumes only the round increment.
717
+ variables: { ready: true, detail: `review ${fresh.id} (${fresh.state})` },
698
718
  });
699
719
  console.log(`[poller] review ${fresh.id} (${fresh.state}) -> ${prKey}`);
700
720
  } catch (err) {
package/main.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  // control endpoints plus the webhook operations under `/app/api/hooks/*` (ADR 0059).
10
10
  //
11
11
  // The only thing that isn't declarative is the review-ready poller: it does arbitrary GitHub
12
- // polling and then correlates a `review-ready` message. A cron trigger can only fire an engine
12
+ // polling and then correlates the canonical `readiness-ready` wait-gate message (#259). A cron
13
+ // trigger can only fire an engine
13
14
  // start/message action, not this custom I/O glue, so it stays app-side here — driving the same
14
15
  // engine client the runtime uses, over `app.data`.
15
16
  //
@@ -89,7 +90,7 @@ if (httpServer instanceof Server) {
89
90
  }
90
91
 
91
92
  // Review-ready poller. Self-scheduling (not setInterval) so a slow GitHub call can never
92
- // overlap two passes (which could double-signal `review-ready`); the next pass is scheduled
93
+ // overlap two passes (which could double-signal `readiness-ready`); the next pass is scheduled
93
94
  // only after the previous one settles.
94
95
  let shuttingDown = false;
95
96
  let pollTimer: ReturnType<typeof setTimeout> | null = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.98.1",
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",
@@ -1,10 +1,10 @@
1
1
  <?xml version="1.0" encoding="UTF-8"?>
2
2
  <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" xmlns:nano="https://nanobpm.io/schema/shapes/1.0" id="Definitions_nano_workforce" targetNamespace="http://nanobpm.io/nano-workforce">
3
- <bpmn:message id="Message_reviewReady" name="review-ready">
3
+ <bpmn:message id="Message_readinessReady" name="readiness-ready">
4
4
  <bpmn:extensionElements>
5
5
  <zeebe:subscription correlationKey="=prKey" />
6
6
  <zeebe:properties>
7
- <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReviewReady" />
7
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReadinessReady" />
8
8
  </zeebe:properties>
9
9
  </bpmn:extensionElements>
10
10
  </bpmn:message>
@@ -82,11 +82,6 @@
82
82
  <nano:extend name="convergeOnly" type="boolean" optional="true" />
83
83
  <nano:extend name="abandonUrl" type="string" optional="true" />
84
84
  </nano:shape>
85
- <nano:shape id="ReviewReady" name="review-ready message payload">
86
- <nano:extend name="reviewId" type="integer" />
87
- <nano:extend name="reviewState" type="string" />
88
- <nano:extend name="submittedAt" type="datetime" optional="true" />
89
- </nano:shape>
90
85
  </nano:shapes>
91
86
  </bpmn:extensionElements>
92
87
  <bpmn:startEvent id="Start" name="PR submitted">
@@ -166,7 +161,7 @@
166
161
  </zeebe:ioMapping>
167
162
  </bpmn:extensionElements>
168
163
  <bpmn:incoming>f_noProgress</bpmn:incoming>
169
- <bpmn:outgoing>f_noprogressWait</bpmn:outgoing>
164
+ <bpmn:outgoing>f_noprogressGate</bpmn:outgoing>
170
165
  </bpmn:serviceTask>
171
166
  <bpmn:eventBasedGateway id="gw-review-wait" name="review ready or timeout?">
172
167
  <bpmn:incoming>f_progressOk</bpmn:incoming>
@@ -181,7 +176,7 @@
181
176
  </bpmn:extensionElements>
182
177
  <bpmn:incoming>f_toReviewWait</bpmn:incoming>
183
178
  <bpmn:outgoing>f_reviewLoop</bpmn:outgoing>
184
- <bpmn:messageEventDefinition id="med_reviewReady" messageRef="Message_reviewReady" />
179
+ <bpmn:messageEventDefinition id="med_readinessReady" messageRef="Message_readinessReady" />
185
180
  </bpmn:intermediateCatchEvent>
186
181
  <bpmn:intermediateCatchEvent id="wait-review-timeout" name="Wait: review timeout">
187
182
  <bpmn:incoming>f_toReviewTimeout</bpmn:incoming>
@@ -204,7 +199,7 @@
204
199
  </zeebe:ioMapping>
205
200
  </bpmn:extensionElements>
206
201
  <bpmn:incoming>f_reviewStalled</bpmn:incoming>
207
- <bpmn:outgoing>f_stalledWait</bpmn:outgoing>
202
+ <bpmn:outgoing>f_stalledGate</bpmn:outgoing>
208
203
  </bpmn:serviceTask>
209
204
  <bpmn:serviceTask id="persist-escalation" name="Record escalation">
210
205
  <bpmn:extensionElements>
@@ -219,6 +214,10 @@
219
214
  </bpmn:serviceTask>
220
215
  <bpmn:exclusiveGateway id="gw-escalated" name="escalation opened?" default="f_escReenter">
221
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>
222
221
  <bpmn:outgoing>f_escWait</bpmn:outgoing>
223
222
  <bpmn:outgoing>f_escReenter</bpmn:outgoing>
224
223
  </bpmn:exclusiveGateway>
@@ -235,7 +234,7 @@
235
234
  </zeebe:ioMapping>
236
235
  </bpmn:extensionElements>
237
236
  <bpmn:incoming>f_guardMax</bpmn:incoming>
238
- <bpmn:outgoing>f_maxWait</bpmn:outgoing>
237
+ <bpmn:outgoing>f_maxGate</bpmn:outgoing>
239
238
  </bpmn:serviceTask>
240
239
  <bpmn:userTask id="wait-answer" name="Answer escalation">
241
240
  <bpmn:extensionElements>
@@ -247,10 +246,6 @@
247
246
  </zeebe:ioMapping>
248
247
  </bpmn:extensionElements>
249
248
  <bpmn:incoming>f_escWait</bpmn:incoming>
250
- <bpmn:incoming>f_maxWait</bpmn:incoming>
251
- <bpmn:incoming>f_stalledWait</bpmn:incoming>
252
- <bpmn:incoming>f_noprogressWait</bpmn:incoming>
253
- <bpmn:incoming>f_blockedWait</bpmn:incoming>
254
249
  <bpmn:outgoing>f_answerRecord</bpmn:outgoing>
255
250
  </bpmn:userTask>
256
251
  <bpmn:serviceTask id="record-answer" name="Record answer">
@@ -307,7 +302,7 @@
307
302
  </zeebe:ioMapping>
308
303
  </bpmn:extensionElements>
309
304
  <bpmn:incoming>f_convergeBlocked</bpmn:incoming>
310
- <bpmn:outgoing>f_blockedWait</bpmn:outgoing>
305
+ <bpmn:outgoing>f_blockedGate</bpmn:outgoing>
311
306
  </bpmn:serviceTask>
312
307
  <bpmn:endEvent id="End" name="Converged">
313
308
  <bpmn:incoming>f_finalize</bpmn:incoming>
@@ -322,7 +317,7 @@
322
317
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=convergeBlocked = true</bpmn:conditionExpression>
323
318
  </bpmn:sequenceFlow>
324
319
  <bpmn:sequenceFlow id="f_convergeOk" name="addressed / default" sourceRef="gw-converge-gate" targetRef="persist-converged" />
325
- <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" />
326
321
  <bpmn:sequenceFlow id="f_addressed" name="addressed / default" sourceRef="gw-status" targetRef="gw-guard" />
327
322
  <bpmn:sequenceFlow id="f_waiting" name="waiting" sourceRef="gw-status" targetRef="gw-guard">
328
323
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "waiting"</bpmn:conditionExpression>
@@ -340,18 +335,18 @@
340
335
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=progressed = false</bpmn:conditionExpression>
341
336
  </bpmn:sequenceFlow>
342
337
  <bpmn:sequenceFlow id="f_progressOk" name="progress / default" sourceRef="gw-progress" targetRef="gw-review-wait" />
343
- <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" />
344
339
  <bpmn:sequenceFlow id="f_toReviewWait" sourceRef="gw-review-wait" targetRef="wait-review" />
345
340
  <bpmn:sequenceFlow id="f_toReviewTimeout" sourceRef="gw-review-wait" targetRef="wait-review-timeout" />
346
341
  <bpmn:sequenceFlow id="f_reviewLoop" sourceRef="wait-review" targetRef="review-round" />
347
342
  <bpmn:sequenceFlow id="f_reviewStalled" sourceRef="wait-review-timeout" targetRef="persist-review-stalled" />
348
- <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" />
349
344
  <bpmn:sequenceFlow id="f_escGate" sourceRef="persist-escalation" targetRef="gw-escalated" />
350
345
  <bpmn:sequenceFlow id="f_escWait" sourceRef="gw-escalated" targetRef="wait-answer">
351
346
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=escalated = true</bpmn:conditionExpression>
352
347
  </bpmn:sequenceFlow>
353
348
  <bpmn:sequenceFlow id="f_escReenter" name="no escalation opened" sourceRef="gw-escalated" targetRef="gw-guard" />
354
- <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" />
355
350
  <bpmn:sequenceFlow id="f_answerRecord" sourceRef="wait-answer" targetRef="record-answer" />
356
351
  <bpmn:sequenceFlow id="f_answerLoop" sourceRef="record-answer" targetRef="review-round" />
357
352
  <bpmn:sequenceFlow id="f_finalize" sourceRef="persist-converged" targetRef="End" />
@@ -374,84 +369,84 @@
374
369
  </bpmndi:BPMNLabel>
375
370
  </bpmndi:BPMNShape>
376
371
  <bpmndi:BPMNShape id="BPMNShape_gw-guard" bpmnElement="gw-guard" isMarkerVisible="true">
377
- <dc:Bounds x="941" y="415" width="50" height="50" />
372
+ <dc:Bounds x="591" y="415" width="50" height="50" />
378
373
  <bpmndi:BPMNLabel>
379
- <dc:Bounds x="924" y="470" width="85" height="14" />
374
+ <dc:Bounds x="574" y="470" width="85" height="14" />
380
375
  </bpmndi:BPMNLabel>
381
376
  </bpmndi:BPMNShape>
382
377
  <bpmndi:BPMNShape id="BPMNShape_persist-round" bpmnElement="persist-round">
383
- <dc:Bounds x="1116" y="400" width="100" height="80" />
378
+ <dc:Bounds x="766" y="400" width="100" height="80" />
384
379
  </bpmndi:BPMNShape>
385
380
  <bpmndi:BPMNShape id="BPMNShape_check-progress" bpmnElement="check-progress">
386
- <dc:Bounds x="1316" y="400" width="100" height="80" />
381
+ <dc:Bounds x="966" y="400" width="100" height="80" />
387
382
  </bpmndi:BPMNShape>
388
383
  <bpmndi:BPMNShape id="BPMNShape_gw-progress" bpmnElement="gw-progress" isMarkerVisible="true">
389
- <dc:Bounds x="1516" y="415" width="50" height="50" />
384
+ <dc:Bounds x="1166" y="415" width="50" height="50" />
390
385
  <bpmndi:BPMNLabel>
391
- <dc:Bounds x="1511" y="382" width="60" height="28" />
386
+ <dc:Bounds x="1161" y="382" width="60" height="28" />
392
387
  </bpmndi:BPMNLabel>
393
388
  </bpmndi:BPMNShape>
394
389
  <bpmndi:BPMNShape id="BPMNShape_persist-escalation-noprogress" bpmnElement="persist-escalation-noprogress">
395
- <dc:Bounds x="1666" y="720" width="100" height="80" />
390
+ <dc:Bounds x="1316" y="720" width="100" height="80" />
396
391
  </bpmndi:BPMNShape>
397
392
  <bpmndi:BPMNShape id="BPMNShape_gw-review-wait" bpmnElement="gw-review-wait" isMarkerVisible="true">
398
- <dc:Bounds x="1691" y="415" width="50" height="50" />
393
+ <dc:Bounds x="1341" y="415" width="50" height="50" />
399
394
  <bpmndi:BPMNLabel>
400
- <dc:Bounds x="1672" y="382" width="88" height="28" />
395
+ <dc:Bounds x="1322" y="382" width="88" height="28" />
401
396
  </bpmndi:BPMNLabel>
402
397
  </bpmndi:BPMNShape>
403
398
  <bpmndi:BPMNShape id="BPMNShape_wait-review" bpmnElement="wait-review">
404
- <dc:Bounds x="1866" y="422" width="36" height="36" />
399
+ <dc:Bounds x="1516" y="422" width="36" height="36" />
405
400
  <bpmndi:BPMNLabel>
406
- <dc:Bounds x="1840" y="389" width="88" height="28" />
401
+ <dc:Bounds x="1490" y="389" width="88" height="28" />
407
402
  </bpmndi:BPMNLabel>
408
403
  </bpmndi:BPMNShape>
409
404
  <bpmndi:BPMNShape id="BPMNShape_wait-review-timeout" bpmnElement="wait-review-timeout">
410
- <dc:Bounds x="1866" y="582" width="36" height="36" />
405
+ <dc:Bounds x="1516" y="582" width="36" height="36" />
411
406
  <bpmndi:BPMNLabel>
412
- <dc:Bounds x="1856" y="623" width="56" height="42" />
407
+ <dc:Bounds x="1506" y="623" width="56" height="42" />
413
408
  </bpmndi:BPMNLabel>
414
409
  </bpmndi:BPMNShape>
415
410
  <bpmndi:BPMNShape id="BPMNShape_persist-review-stalled" bpmnElement="persist-review-stalled">
416
- <dc:Bounds x="2002" y="560" width="100" height="80" />
411
+ <dc:Bounds x="1652" y="560" width="100" height="80" />
417
412
  </bpmndi:BPMNShape>
418
413
  <bpmndi:BPMNShape id="BPMNShape_persist-escalation" bpmnElement="persist-escalation">
419
- <dc:Bounds x="566" y="560" width="100" height="80" />
414
+ <dc:Bounds x="566" y="1040" width="100" height="80" />
420
415
  </bpmndi:BPMNShape>
421
416
  <bpmndi:BPMNShape id="BPMNShape_gw-escalated" bpmnElement="gw-escalated" isMarkerVisible="true">
422
- <dc:Bounds x="766" y="575" width="50" height="50" />
417
+ <dc:Bounds x="1852" y="255" width="50" height="50" />
423
418
  <bpmndi:BPMNLabel>
424
- <dc:Bounds x="821" y="586" width="74" height="28" />
419
+ <dc:Bounds x="1760" y="290" width="74" height="28" />
425
420
  </bpmndi:BPMNLabel>
426
421
  </bpmndi:BPMNShape>
427
422
  <bpmndi:BPMNShape id="BPMNShape_persist-escalation-maxrounds" bpmnElement="persist-escalation-maxrounds">
428
- <dc:Bounds x="1116" y="880" width="100" height="80" />
423
+ <dc:Bounds x="766" y="880" width="100" height="80" />
429
424
  </bpmndi:BPMNShape>
430
425
  <bpmndi:BPMNShape id="BPMNShape_wait-answer" bpmnElement="wait-answer">
431
- <dc:Bounds x="2202" y="240" width="100" height="80" />
426
+ <dc:Bounds x="2002" y="240" width="100" height="80" />
432
427
  </bpmndi:BPMNShape>
433
428
  <bpmndi:BPMNShape id="BPMNShape_record-answer" bpmnElement="record-answer">
434
- <dc:Bounds x="2402" y="240" width="100" height="80" />
429
+ <dc:Bounds x="2202" y="240" width="100" height="80" />
435
430
  </bpmndi:BPMNShape>
436
431
  <bpmndi:BPMNShape id="BPMNShape_persist-converged" bpmnElement="persist-converged">
437
- <dc:Bounds x="916" y="80" width="100" height="80" />
432
+ <dc:Bounds x="966" y="80" width="100" height="80" />
438
433
  </bpmndi:BPMNShape>
439
434
  <bpmndi:BPMNShape id="BPMNShape_check-converge" bpmnElement="check-converge">
440
435
  <dc:Bounds x="566" y="80" width="100" height="80" />
441
436
  </bpmndi:BPMNShape>
442
437
  <bpmndi:BPMNShape id="BPMNShape_gw-converge-gate" bpmnElement="gw-converge-gate" isMarkerVisible="true">
443
- <dc:Bounds x="766" y="95" width="50" height="50" />
438
+ <dc:Bounds x="791" y="95" width="50" height="50" />
444
439
  <bpmndi:BPMNLabel>
445
- <dc:Bounds x="754" y="62" width="74" height="28" />
440
+ <dc:Bounds x="779" y="62" width="74" height="28" />
446
441
  </bpmndi:BPMNLabel>
447
442
  </bpmndi:BPMNShape>
448
443
  <bpmndi:BPMNShape id="BPMNShape_persist-escalation-blockedcomments" bpmnElement="persist-escalation-blockedcomments">
449
- <dc:Bounds x="916" y="240" width="100" height="80" />
444
+ <dc:Bounds x="966" y="240" width="100" height="80" />
450
445
  </bpmndi:BPMNShape>
451
446
  <bpmndi:BPMNShape id="BPMNShape_End" bpmnElement="End">
452
- <dc:Bounds x="1148" y="102" width="36" height="36" />
447
+ <dc:Bounds x="1173" y="102" width="36" height="36" />
453
448
  <bpmndi:BPMNLabel>
454
- <dc:Bounds x="1132" y="143" width="69" height="14" />
449
+ <dc:Bounds x="1157" y="143" width="69" height="14" />
455
450
  </bpmndi:BPMNLabel>
456
451
  </bpmndi:BPMNShape>
457
452
  <bpmndi:BPMNEdge id="BPMNEdge_f_start" bpmnElement="f_start">
@@ -471,71 +466,73 @@
471
466
  </bpmndi:BPMNEdge>
472
467
  <bpmndi:BPMNEdge id="BPMNEdge_f_toConvergeGate" bpmnElement="f_toConvergeGate">
473
468
  <di:waypoint x="666" y="120" />
474
- <di:waypoint x="766" y="120" />
469
+ <di:waypoint x="791" y="120" />
475
470
  </bpmndi:BPMNEdge>
476
471
  <bpmndi:BPMNEdge id="BPMNEdge_f_convergeOk" bpmnElement="f_convergeOk">
477
- <di:waypoint x="816" y="120" />
478
- <di:waypoint x="916" y="120" />
472
+ <di:waypoint x="841" y="120" />
473
+ <di:waypoint x="966" y="120" />
479
474
  <bpmndi:BPMNLabel>
480
- <dc:Bounds x="827" y="125" width="78" height="28" />
475
+ <dc:Bounds x="865" y="87" width="78" height="28" />
481
476
  </bpmndi:BPMNLabel>
482
477
  </bpmndi:BPMNEdge>
483
478
  <bpmndi:BPMNEdge id="BPMNEdge_f_finalize" bpmnElement="f_finalize">
484
- <di:waypoint x="1016" y="120" />
485
- <di:waypoint x="1148" y="120" />
479
+ <di:waypoint x="1066" y="120" />
480
+ <di:waypoint x="1173" y="120" />
486
481
  </bpmndi:BPMNEdge>
487
482
  <bpmndi:BPMNEdge id="BPMNEdge_f_guardOk" bpmnElement="f_guardOk">
488
- <di:waypoint x="991" y="440" />
489
- <di:waypoint x="1116" y="440" />
483
+ <di:waypoint x="641" y="440" />
484
+ <di:waypoint x="766" y="440" />
490
485
  <bpmndi:BPMNLabel>
491
- <dc:Bounds x="1014" y="418" width="60" height="14" />
486
+ <dc:Bounds x="664" y="418" width="60" height="14" />
492
487
  </bpmndi:BPMNLabel>
493
488
  </bpmndi:BPMNEdge>
494
489
  <bpmndi:BPMNEdge id="BPMNEdge_f_roundWait" bpmnElement="f_roundWait">
495
- <di:waypoint x="1216" y="440" />
496
- <di:waypoint x="1316" y="440" />
490
+ <di:waypoint x="866" y="440" />
491
+ <di:waypoint x="966" y="440" />
497
492
  </bpmndi:BPMNEdge>
498
493
  <bpmndi:BPMNEdge id="BPMNEdge_f_checkGate" bpmnElement="f_checkGate">
499
- <di:waypoint x="1416" y="440" />
500
- <di:waypoint x="1516" y="440" />
494
+ <di:waypoint x="1066" y="440" />
495
+ <di:waypoint x="1166" y="440" />
501
496
  </bpmndi:BPMNEdge>
502
497
  <bpmndi:BPMNEdge id="BPMNEdge_f_progressOk" bpmnElement="f_progressOk">
503
- <di:waypoint x="1566" y="440" />
504
- <di:waypoint x="1691" y="440" />
498
+ <di:waypoint x="1216" y="440" />
499
+ <di:waypoint x="1341" y="440" />
505
500
  <bpmndi:BPMNLabel>
506
- <dc:Bounds x="1591" y="407" width="71" height="28" />
501
+ <dc:Bounds x="1241" y="407" width="71" height="28" />
507
502
  </bpmndi:BPMNLabel>
508
503
  </bpmndi:BPMNEdge>
509
504
  <bpmndi:BPMNEdge id="BPMNEdge_f_toReviewWait" bpmnElement="f_toReviewWait">
510
- <di:waypoint x="1741" y="440" />
511
- <di:waypoint x="1866" y="440" />
505
+ <di:waypoint x="1391" y="440" />
506
+ <di:waypoint x="1516" y="440" />
512
507
  </bpmndi:BPMNEdge>
513
508
  <bpmndi:BPMNEdge id="BPMNEdge_f_reviewLoop" bpmnElement="f_reviewLoop">
514
- <di:waypoint x="1884" y="458" />
515
- <di:waypoint x="1884" y="500" />
509
+ <di:waypoint x="1534" y="458" />
510
+ <di:waypoint x="1534" y="500" />
516
511
  <di:waypoint x="266" y="500" />
517
512
  <di:waypoint x="266" y="160" />
518
513
  </bpmndi:BPMNEdge>
519
514
  <bpmndi:BPMNEdge id="BPMNEdge_f_escReenter" bpmnElement="f_escReenter">
520
- <di:waypoint x="791" y="575" />
521
- <di:waypoint x="791" y="440" />
522
- <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" />
523
519
  <bpmndi:BPMNLabel>
524
- <dc:Bounds x="796" y="507" width="74" height="42" />
520
+ <dc:Bounds x="1410" y="173" width="74" height="42" />
525
521
  </bpmndi:BPMNLabel>
526
522
  </bpmndi:BPMNEdge>
527
523
  <bpmndi:BPMNEdge id="BPMNEdge_f_convergeBlocked" bpmnElement="f_convergeBlocked">
528
- <di:waypoint x="791" y="145" />
529
- <di:waypoint x="791" y="280" />
530
- <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" />
531
528
  <bpmndi:BPMNLabel>
532
- <dc:Bounds x="796" y="199" width="81" height="28" />
529
+ <dc:Bounds x="821" y="169" width="81" height="28" />
533
530
  </bpmndi:BPMNLabel>
534
531
  </bpmndi:BPMNEdge>
535
532
  <bpmndi:BPMNEdge id="BPMNEdge_f_addressed" bpmnElement="f_addressed">
536
533
  <di:waypoint x="441" y="145" />
537
534
  <di:waypoint x="441" y="440" />
538
- <di:waypoint x="941" y="440" />
535
+ <di:waypoint x="591" y="440" />
539
536
  <bpmndi:BPMNLabel>
540
537
  <dc:Bounds x="446" y="279" width="78" height="28" />
541
538
  </bpmndi:BPMNLabel>
@@ -543,87 +540,81 @@
543
540
  <bpmndi:BPMNEdge id="BPMNEdge_f_waiting" bpmnElement="f_waiting">
544
541
  <di:waypoint x="441" y="145" />
545
542
  <di:waypoint x="441" y="440" />
546
- <di:waypoint x="941" y="440" />
543
+ <di:waypoint x="591" y="440" />
547
544
  <bpmndi:BPMNLabel>
548
545
  <dc:Bounds x="380" y="286" width="56" height="14" />
549
546
  </bpmndi:BPMNLabel>
550
547
  </bpmndi:BPMNEdge>
551
548
  <bpmndi:BPMNEdge id="BPMNEdge_f_escalate" bpmnElement="f_escalate">
552
549
  <di:waypoint x="441" y="145" />
553
- <di:waypoint x="441" y="600" />
554
- <di:waypoint x="566" y="600" />
550
+ <di:waypoint x="441" y="1080" />
551
+ <di:waypoint x="566" y="1080" />
555
552
  <bpmndi:BPMNLabel>
556
- <dc:Bounds x="446" y="506" width="81" height="28" />
553
+ <dc:Bounds x="446" y="746" width="81" height="28" />
557
554
  </bpmndi:BPMNLabel>
558
555
  </bpmndi:BPMNEdge>
559
556
  <bpmndi:BPMNEdge id="BPMNEdge_f_guardMax" bpmnElement="f_guardMax">
560
- <di:waypoint x="991" y="440" />
561
- <di:waypoint x="1096" y="440" />
562
- <di:waypoint x="1096" y="920" />
563
- <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" />
564
561
  <bpmndi:BPMNLabel>
565
- <dc:Bounds x="1101" y="673" width="74" height="14" />
562
+ <dc:Bounds x="751" y="673" width="74" height="14" />
566
563
  </bpmndi:BPMNLabel>
567
564
  </bpmndi:BPMNEdge>
568
565
  <bpmndi:BPMNEdge id="BPMNEdge_f_noProgress" bpmnElement="f_noProgress">
569
- <di:waypoint x="1566" y="440" />
570
- <di:waypoint x="1646" y="440" />
571
- <di:waypoint x="1646" y="760" />
572
- <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" />
573
570
  <bpmndi:BPMNLabel>
574
- <dc:Bounds x="1563" y="593" width="78" height="14" />
571
+ <dc:Bounds x="1213" y="593" width="78" height="14" />
575
572
  </bpmndi:BPMNLabel>
576
573
  </bpmndi:BPMNEdge>
577
574
  <bpmndi:BPMNEdge id="BPMNEdge_f_toReviewTimeout" bpmnElement="f_toReviewTimeout">
578
- <di:waypoint x="1716" y="465" />
579
- <di:waypoint x="1716" y="600" />
580
- <di:waypoint x="1866" y="600" />
581
- </bpmndi:BPMNEdge>
582
- <bpmndi:BPMNEdge id="BPMNEdge_f_escWait" bpmnElement="f_escWait">
583
- <di:waypoint x="791" y="625" />
584
- <di:waypoint x="791" y="980" />
585
- <di:waypoint x="2182" y="980" />
586
- <di:waypoint x="2182" y="300" />
587
- <di:waypoint x="2202" y="300" />
575
+ <di:waypoint x="1366" y="465" />
576
+ <di:waypoint x="1366" y="600" />
577
+ <di:waypoint x="1516" y="600" />
588
578
  </bpmndi:BPMNEdge>
589
- <bpmndi:BPMNEdge id="BPMNEdge_f_blockedWait" bpmnElement="f_blockedWait">
590
- <di:waypoint x="1016" y="280" />
591
- <di:waypoint x="2202" y="280" />
579
+ <bpmndi:BPMNEdge id="BPMNEdge_f_blockedGate" bpmnElement="f_blockedGate">
580
+ <di:waypoint x="1066" y="280" />
581
+ <di:waypoint x="1852" y="280" />
592
582
  </bpmndi:BPMNEdge>
593
- <bpmndi:BPMNEdge id="BPMNEdge_f_noprogressWait" bpmnElement="f_noprogressWait">
594
- <di:waypoint x="1766" y="780" />
595
- <di:waypoint x="1786" y="780" />
596
- <di:waypoint x="1786" y="980" />
597
- <di:waypoint x="2252" y="980" />
598
- <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" />
599
587
  </bpmndi:BPMNEdge>
600
588
  <bpmndi:BPMNEdge id="BPMNEdge_f_reviewStalled" bpmnElement="f_reviewStalled">
601
- <di:waypoint x="1902" y="600" />
602
- <di:waypoint x="2002" y="600" />
589
+ <di:waypoint x="1552" y="600" />
590
+ <di:waypoint x="1652" y="600" />
603
591
  </bpmndi:BPMNEdge>
604
- <bpmndi:BPMNEdge id="BPMNEdge_f_stalledWait" bpmnElement="f_stalledWait">
605
- <di:waypoint x="2052" y="560" />
606
- <di:waypoint x="2052" y="280" />
607
- <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" />
608
596
  </bpmndi:BPMNEdge>
609
597
  <bpmndi:BPMNEdge id="BPMNEdge_f_escGate" bpmnElement="f_escGate">
610
- <di:waypoint x="666" y="600" />
611
- <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" />
612
601
  </bpmndi:BPMNEdge>
613
- <bpmndi:BPMNEdge id="BPMNEdge_f_maxWait" bpmnElement="f_maxWait">
614
- <di:waypoint x="1216" y="940" />
615
- <di:waypoint x="1236" y="940" />
616
- <di:waypoint x="1236" y="980" />
617
- <di:waypoint x="2252" y="980" />
618
- <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" />
619
610
  </bpmndi:BPMNEdge>
620
611
  <bpmndi:BPMNEdge id="BPMNEdge_f_answerRecord" bpmnElement="f_answerRecord">
621
- <di:waypoint x="2302" y="280" />
622
- <di:waypoint x="2402" y="280" />
612
+ <di:waypoint x="2102" y="280" />
613
+ <di:waypoint x="2202" y="280" />
623
614
  </bpmndi:BPMNEdge>
624
615
  <bpmndi:BPMNEdge id="BPMNEdge_f_answerLoop" bpmnElement="f_answerLoop">
625
- <di:waypoint x="2452" y="320" />
626
- <di:waypoint x="2452" y="340" />
616
+ <di:waypoint x="2252" y="320" />
617
+ <di:waypoint x="2252" y="340" />
627
618
  <di:waypoint x="266" y="340" />
628
619
  <di:waypoint x="266" y="160" />
629
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";
@@ -25,6 +25,7 @@ import {
25
25
  parseProbe,
26
26
  probeBudgetMs,
27
27
  probeOnce,
28
+ READINESS_READY_MESSAGE,
28
29
  type ReadinessProbe,
29
30
  redactTarget,
30
31
  } from "../../app/readiness.ts";
@@ -36,8 +37,11 @@ import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io
36
37
  type In = WorkerInputs["pr.readiness-probe"];
37
38
  type Out = WorkerOutputs["pr.readiness-probe"];
38
39
 
39
- /** The message the gate's event-based gateway correlates on `=gateKey` to release the wait. */
40
- export const READINESS_READY_MESSAGE = "readiness-ready";
40
+ /** The message the gate's event-based gateway correlates on `=gateKey` to release the wait.
41
+ * Re-exported from the canonical source (`app/readiness.ts`) so the worker and every out-of-band
42
+ * publisher (the review-ready poller, #259) share ONE message name — no drift-prone local twin.
43
+ * Re-exports the binding already imported above rather than re-referencing the module. */
44
+ export { READINESS_READY_MESSAGE };
41
45
 
42
46
  const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
43
47