@nanobpm/nano-workforce 0.76.0 → 0.78.0

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.78.0](https://github.com/nanobpm/nano-workforce/compare/v0.77.0...v0.78.0) (2026-08-17)
2
+
3
+
4
+ ### Features
5
+
6
+ * converge the merge-loop escalation onto one native user-task pathway ([#256](https://github.com/nanobpm/nano-workforce/issues/256)) ([#257](https://github.com/nanobpm/nano-workforce/issues/257)) ([16551ee](https://github.com/nanobpm/nano-workforce/commit/16551ee59015081dcf20294ffdd05a078564b775))
7
+
8
+ # [0.77.0](https://github.com/nanobpm/nano-workforce/compare/v0.76.0...v0.77.0) (2026-08-17)
9
+
10
+
11
+ ### Features
12
+
13
+ * bind the app to all interfaces so a remote worker fleet can reach it ([#224](https://github.com/nanobpm/nano-workforce/issues/224)) ([#255](https://github.com/nanobpm/nano-workforce/issues/255)) ([5968717](https://github.com/nanobpm/nano-workforce/commit/5968717416e13ff739b797822eb0d44ff51cc9cb)), closes [12778/#828](https://github.com/nanobpm/nano-workforce/issues/828) [12801/#252](https://github.com/nanobpm/nano-workforce/issues/252) [#228](https://github.com/nanobpm/nano-workforce/issues/228)
14
+
1
15
  # [0.76.0](https://github.com/nanobpm/nano-workforce/compare/v0.75.0...v0.76.0) (2026-08-17)
2
16
 
3
17
 
package/SPEC.md CHANGED
@@ -103,19 +103,22 @@ known at submit time, carried as a process variable and stored on the DB row.
103
103
  │ │ ├── review-ready (msg catch, key = prKey) → round++ ─────┐
104
104
  │ │ └── =reviewWaitTimeout (timer catch) │
105
105
  │ │ → [Escalate: review stalled] (blocked) │
106
- │ │ → [Wait: escalation-answered] ──────────────────────┤
106
+ │ │ → [Wait: wait-answer userTask] ─────────────────────┤
107
107
  │ │ │
108
108
  │ └── needs_input [Record escalation] │ │
109
109
  │ or blocked → (kind = question | blocker) │ │
110
- │ → [Wait: escalation-answered] (msg catch) │ │
110
+ │ → [Wait: wait-answer userTask] │ │
111
+ │ → [record-answer: pr.answer-escalation] │ │
111
112
  │ → set answer ──────────────────────────────┤ │
112
113
  │ │ │
113
114
  └────────────────────────────────────────────────────────────────────┴───────────────┘
114
115
 
115
116
  Both `needs_input` (the agent has a question) and `blocked` (the agent is stuck
116
117
  on something external — auth, a failing push, a missing secret) route to the
117
- **same escalation path**: record it, sleep at `escalation-answered`, then retry
118
- the same round with the human's `answer`. They differ only by escalation `kind`,
118
+ **same escalation path**: record it, park on the native `wait-answer` user task
119
+ (answered through the canonical `completeUserTask` door and surfaced in the Tasks
120
+ inbox), reconcile the answer via the `record-answer` (`pr.answer-escalation`)
121
+ step, then retry the same round with the human's `answer`. They differ only by escalation `kind`,
119
122
  which the UI uses to label the card. Neither ends the run — a human always gets
120
123
  a chance to unblock and resume.
121
124
 
@@ -200,16 +203,15 @@ Consequences the prompt (`resources/prompts/review-round.md`) encodes:
200
203
  |---|---|---|---|
201
204
  | `pr-submitted` | — (start) | submit route/webhook | `{repo, prNumber, prUrl, prKey}` |
202
205
  | `review-ready` | `prKey` | **poller** | `{reviewId, reviewState, submittedAt}` |
203
- | `escalation-answered` | `prKey` | merge-loop answer (`answerEscalation`) | `{answer, escalationId}` |
204
206
  | `deps-cleared` | `prKey` | **poller** (merge) | — (all `Depends-on` PRs merged) |
205
207
  | `merge-ready` | `prKey` | **poller** (merge) | `{mergeState}` (`ready` \| `conflict` \| `blocked`); when `blocked`, also `{failingChecks, failingChecksList}` for the `senior:fix-ci` branch |
206
208
  | `merge-landed` | `prKey` | **poller** (merge) | — (queued PR merged, or merged out-of-band) |
207
209
 
208
- Note `escalation-answered` is now used only by the `merge-loop` process (the
209
- `convergence-loop` review escalation is a native `userTask` answered through the
210
- task inbox, not this message). The merge-loop `.bpmn` gives it a distinct message
211
- **id** (and distinct envelope shape ids) to avoid duplicate-id collisions when the
212
- manifest deploys both files.
210
+ The `escalation-answered` message was retired (#256): the merge-loop escalation is
211
+ now a native `wait-merge-answer` `userTask`, exactly like the `convergence-loop`
212
+ review escalation (`wait-answer`). Both are answered through the ONE canonical
213
+ `completeUserTask` door and surface in the Tasks inbox there is no longer a
214
+ merge-only message pathway.
213
215
 
214
216
  ## 7. Domain model (SQLite — `db/migrations/001_init.sql`) — PROPOSED
215
217
 
@@ -287,8 +289,8 @@ The full, authoritative contract is `openapi.yaml` (Swagger UI at
287
289
  | `GET` | `/app/api/version` | app + engine version |
288
290
  | `POST` | `/app/api/actions/start/convergence-loop` | parse the PR ref → create the aggregate + start the process (the ONE submit door — page + external callers) |
289
291
  | `POST` | `/app/api/actions/start/plan-fanout` | parse the issue ref → start a plan fan-out run (the ONE plan door) |
290
- | `POST` | `/app/api/actions/message` (`escalation-answered`) | answer an open merge-loop escalation publish `escalation-answered` (the four #156 escalation kinds are native user tasks answered via the task inbox) |
291
- | `POST` | `/app/api/actions/complete-user-task` | complete an open native user-task escalation from the Tasks page (plan-review / trial-merge / PR `wait-answer`) → `completeEscalationAsHuman` (the same resume path the task inbox uses) |
292
+ | `POST` | `/app/api/actions/message` | publish a BPMN message (optionally correlated) into the engine (generic; every escalation kind is now answered via `/actions/complete-user-task`) |
293
+ | `POST` | `/app/api/actions/complete-user-task` | complete an open native user-task escalation from the Tasks page (plan-review / trial-merge / PR `wait-answer` / PR merge `wait-merge-answer`) → `completeEscalationAsHuman` (the same resume path the task inbox uses) |
292
294
  | `GET`/`POST` | `/app/api/hooks/blackboard` | per-plan coordination blackboard (capability-token side-channel) |
293
295
  | `GET` | `/app/api/hooks/abandon` | cooperative abandon check (per-PR capability token) |
294
296
 
@@ -401,7 +403,7 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
401
403
  └─ could not fix ─► escalate ─┐ │ │
402
404
  ▼ ▼ ▼
403
405
  wait: answered ─► (re-arm) ◄──────── (all escalations)
404
- (escalation-answered)
406
+ (wait-merge-answer userTask)
405
407
  ```
406
408
 
407
409
  - **CI auto-fix** — a `blocked` verdict means a **required check failed**
@@ -438,7 +440,8 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
438
440
  recorded in the `merges` audit table.
439
441
  - **Escalation** — a conflict or a failing gate raises the same
440
442
  `pr.persist-escalation` worker as the review stage (status `escalated`), answered
441
- via the merge-loop `escalation-answered` message; answering re-arms and retries.
443
+ via the native `wait-merge-answer` `userTask` (the same `completeUserTask` door as
444
+ the review escalation, #256); answering re-arms and retries.
442
445
  - **Terminal** — `merged` (with `merged_at`), or `converged` when
443
446
  `NANO_PR_AUTO_MERGE=0` (review-only), or `abandoned` on cancel.
444
447
 
@@ -68,6 +68,7 @@ export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
68
68
  "plan-review-decision",
69
69
  "trial-merge-decision",
70
70
  "wait-answer", // PR review-loop escalation (convergence-loop.bpmn, U3)
71
+ "wait-merge-answer", // PR merge-loop escalation (merge-loop.bpmn) — same native user-task path (#256)
71
72
  ]);
72
73
 
73
74
  /** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
@@ -78,6 +79,7 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
78
79
  "plan-review-decision": "plan-review-decision",
79
80
  "trial-merge-decision": "trial-merge-decision",
80
81
  "wait-answer": "pr-escalation",
82
+ "wait-merge-answer": "pr-escalation",
81
83
  };
82
84
 
83
85
  interface FormContract {
package/app/agentGuide.ts CHANGED
@@ -33,7 +33,8 @@ const RAW_GUIDE: string = (() => {
33
33
  "- `GET /version` — which code is live.",
34
34
  "- `POST /actions/start/convergence-loop` — submit a PR (`{ pr, convergeOnly?, maxRounds?, dependsOn? }`).",
35
35
  "- `POST /actions/start/plan-fanout` — submit an epic (`{ issue, baseBranch }` or `{ url, baseBranch }`; base is required — a missing `epic/*` base is auto-created, and `confirmDefaultBase`/`allowSharedBase` gate the default-branch and shared-base cases — see ADR 0003).",
36
- "- `POST /actions/message` — answer an escalation (`escalation-answered`, correlate by PR key).",
36
+ "- `POST /actions/complete-user-task` — answer an escalation (`{ userTaskKey, variables }`); the parked user task's key comes from `GET /status`/the Tasks inbox, and the typed variables match its `.form` (e.g. a PR escalation's `{ answer }`).",
37
+ "- `POST /actions/message` — publish a BPMN message (optionally correlated) into the engine.",
37
38
  "",
38
39
  "Engine (Camunda-8 v2 REST) base for debugging: `__ENGINE__`.",
39
40
  "Source repository: `nanobpm/nano-workforce`.",
@@ -8,7 +8,7 @@
8
8
  // completion and must transition the latest open row to `answered`, recording the submitted answer.
9
9
  //
10
10
  // It must ALSO move the `pull_requests` row off `status="escalated"` back to `"converging"`, exactly
11
- // as the merge-loop's `answerEscalation` message path does. Otherwise the PR stays `escalated` (with
11
+ // (the single reconcile step both the review and merge loops run). Otherwise the PR stays `escalated` (with
12
12
  // a now-null `openEscalation`) until the re-entered round's `persist-round` runs — an inconsistent
13
13
  // `/status` window and a divergence from the merge loop the two paths are meant to share.
14
14
  import { test } from "node:test";
@@ -60,7 +60,7 @@ test("retires the latest open escalation to answered with the submitted answer",
60
60
  assertEquals(typeof updates[0].patch.answered_at, "string", "answered_at is stamped");
61
61
  assertEquals(prUpdates.length, 1, "the PR row is moved off `escalated`");
62
62
  assertEquals(prUpdates[0].key, "o/r#1", "the PR keyed by prKey is updated");
63
- assertEquals(prUpdates[0].patch.status, "converging", "mirrors answerEscalation: back to converging");
63
+ assertEquals(prUpdates[0].patch.status, "converging", "answered escalation returns the PR to converging");
64
64
  assertEquals(typeof prUpdates[0].patch.updated_at, "string", "updated_at is stamped");
65
65
  });
66
66
 
@@ -0,0 +1,22 @@
1
+ // Guard for the app's network posture (nano.app.json). nano-workforce drives a distributed LAN
2
+ // worker fleet: remote `senior:*` agents must reach the capability hooks (`/app/api/hooks/abandon`,
3
+ // `/app/api/hooks/blackboard`) over the app's hostname. Urban's runtime bind default is loopback
4
+ // (secure by default), which silently makes those hooks unreachable off-box — the failure mode
5
+ // behind the wedged review agents (jwulf/c8ctl-plugin-nano#76, issue #224): an agent parked on a
6
+ // wait-answer escalation it could not resolve because the abandon endpoint refused hostname
7
+ // connections. The manifest must therefore declare `network.bind: "all"` so nwf is reachable by
8
+ // its fleet out of the box. The LOCAL agentic-channel token is loopback-guarded independently
9
+ // (#228) so binding wide does not expose it.
10
+ import { test } from "node:test";
11
+ import { assert, assertEquals } from "#test-assert";
12
+ import { readFileSync } from "node:fs";
13
+
14
+ test("nano.app.json binds to all interfaces so a remote worker fleet can reach the capability hooks", () => {
15
+ const manifest = JSON.parse(readFileSync(new URL("../nano.app.json", import.meta.url), "utf8"));
16
+ assert(manifest.network, "nano.app.json must declare a network block (fleet reachability)");
17
+ assertEquals(
18
+ manifest.network.bind,
19
+ "all",
20
+ "network.bind must be \"all\": loopback silently makes the capability hooks unreachable off-box for the distributed fleet",
21
+ );
22
+ });
@@ -0,0 +1,77 @@
1
+ // Structural + cross-layer regression guard for converging the merge-loop escalation onto the ONE
2
+ // native user-task answer pathway (#256).
3
+ //
4
+ // Before #256 the merge loop parked on a durable `escalation-answered` message catch answered by a
5
+ // bespoke `answerEscalation()` publish — a SECOND answer pathway invisible to the Tasks inbox, so a
6
+ // merge escalation could not be answered from the nwf UI at all. It now parks on a native
7
+ // `wait-merge-answer` userTask (backed by `pr-escalation.form`) followed by the SAME
8
+ // `pr.answer-escalation` reconcile step the review loop's `wait-answer` runs, so both loops answer
9
+ // through the one canonical `completeUserTask` door and surface in the one Tasks inbox.
10
+ //
11
+ // These are pure text assertions over the committed BPMN (no engine), matching the repo's
12
+ // lightweight model-guard style (see mergeRebaseArm.test.ts), plus a drift guard tying the model's
13
+ // user-task element id to the completer's accepted escalation set so the two can't silently diverge.
14
+
15
+ import { test } from "node:test";
16
+ import { assert, assertStringIncludes } from "#test-assert";
17
+ import { readFileSync } from "node:fs";
18
+ import { ESCALATION_TASK_ELEMENTS, validateEscalationVariables } from "./agentCompletion.ts";
19
+
20
+ const bpmn = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
21
+ const flat = bpmn.replace(/\s+/g, " ");
22
+
23
+ function hasFlow(source: string, target: string): boolean {
24
+ const re = new RegExp(
25
+ `<bpmn:sequenceFlow\\b[^>]*\\bsourceRef="${source}"[^>]*\\btargetRef="${target}"|` +
26
+ `<bpmn:sequenceFlow\\b[^>]*\\btargetRef="${target}"[^>]*\\bsourceRef="${source}"`,
27
+ );
28
+ return re.test(flat);
29
+ }
30
+
31
+ test("the merge escalation parks on a native wait-merge-answer userTask backed by pr-escalation.form", () => {
32
+ const task = flat.match(/<bpmn:userTask\b[^>]*\bid="wait-merge-answer"[\s\S]*?<\/bpmn:userTask>/);
33
+ assert(task, "wait-merge-answer must be a <bpmn:userTask>");
34
+ assertStringIncludes(task![0], 'formId="pr-escalation"', "it must render the shared pr-escalation form");
35
+ assertStringIncludes(task![0], "<zeebe:userTask", "it must be a native (Zeebe) user task");
36
+ });
37
+
38
+ test("the answered task reconciles the escalations row, then re-arms the merge poller", () => {
39
+ // wait-merge-answer → record-merge-answer (pr.answer-escalation) → arm-merge, mirroring the review
40
+ // loop's wait-answer → record-answer. Without the reconcile step the escalations row would stay
41
+ // `open` forever after the task completes (a phantom on /status).
42
+ const record = flat.match(/<bpmn:serviceTask\b[^>]*\bid="record-merge-answer"[\s\S]*?<\/bpmn:serviceTask>/);
43
+ assert(record, "record-merge-answer service task must exist");
44
+ assertStringIncludes(record![0], 'type="pr.answer-escalation"', "it must run the shared reconcile worker");
45
+ assert(hasFlow("wait-merge-answer", "record-merge-answer"), "wait-merge-answer → record-merge-answer missing");
46
+ assert(hasFlow("record-merge-answer", "arm-merge"), "record-merge-answer → arm-merge (re-arm) missing");
47
+ });
48
+
49
+ test("the legacy escalation-answered message pathway is gone", () => {
50
+ assert(!flat.includes("escalation-answered"), "the escalation-answered message must be removed");
51
+ assert(!flat.includes("Message_mergeEscAnswered"), "the merge escalation message declaration must be removed");
52
+ // The answer wait must no longer be a message catch — it is now a user task.
53
+ assert(
54
+ !/<bpmn:intermediateCatchEvent\b[^>]*\bid="wait-merge-answer"/.test(flat),
55
+ "wait-merge-answer must no longer be an intermediateCatchEvent",
56
+ );
57
+ });
58
+
59
+ test("drift guard: the model's merge user-task element is one the canonical completer accepts", () => {
60
+ // The completer refuses any user task outside ESCALATION_TASK_ELEMENTS, so a model that parks on
61
+ // `wait-merge-answer` while the code doesn't accept it would deploy but never be answerable — the
62
+ // exact silent-drift failure mode this guard closes.
63
+ assert(
64
+ ESCALATION_TASK_ELEMENTS.has("wait-merge-answer"),
65
+ "ESCALATION_TASK_ELEMENTS must accept wait-merge-answer",
66
+ );
67
+ // And it must map to the pr-escalation form contract (answer required) — a missing answer is
68
+ // rejected, proving the element resolves to the same form the model renders.
69
+ assert(
70
+ validateEscalationVariables("wait-merge-answer", {}) !== null,
71
+ "wait-merge-answer must enforce the pr-escalation form contract (answer required)",
72
+ );
73
+ assert(
74
+ validateEscalationVariables("wait-merge-answer", { answer: "rebased and retried" }) === null,
75
+ "a valid answer must satisfy the wait-merge-answer form contract",
76
+ );
77
+ });
@@ -114,6 +114,29 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
114
114
  assertEquals(byKey["ut-pr"].question, "conflicting reviews");
115
115
  });
116
116
 
117
+ test("pollUserTasks: projects a merge-loop wait-merge-answer escalation into user_tasks as \"PR merge\"", async () => {
118
+ // During the merge phase a PR's process_key points at its merge-loop instance; the merge escalation
119
+ // parks on a native `wait-merge-answer` userTask (#256) and writes the SAME `escalations` row the
120
+ // review loop does, so the inbox surfaces it exactly like a review escalation — just labelled by
121
+ // stage. This guards the poller accepting the merge element alongside `wait-answer`.
122
+ const { data, stores } = memData({
123
+ pull_requests: [
124
+ { pr_key: "o/r#31", status: "escalated", process_key: "mp-31", url: "https://github.com/o/r/pull/31" },
125
+ ],
126
+ escalations: [{ id: 1, pr_key: "o/r#31", status: "open", question: "not mergeable — resolve the conflict" }],
127
+ });
128
+ const engine = fakeEngine({ "mp-31": [{ userTaskKey: "ut-merge", elementId: "wait-merge-answer" }] });
129
+
130
+ await pollUserTasks(data, engine);
131
+
132
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
133
+ assertEquals(Object.keys(byKey), ["ut-merge"]);
134
+ assertEquals(byKey["ut-merge"].element_id, "wait-merge-answer");
135
+ assertEquals(byKey["ut-merge"].kind_label, "PR merge");
136
+ assertEquals(byKey["ut-merge"].subject_type, "pr");
137
+ assertEquals(byKey["ut-merge"].question, "not mergeable — resolve the conflict");
138
+ });
139
+
117
140
  test("pollUserTasks: removes a row once its task is no longer open (completed / out-of-band)", async () => {
118
141
  const { data, stores } = memData({
119
142
  user_tasks: [
package/app/service.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  latestTrialMergeQuestion,
40
40
  PLAN_REVIEW_ELEMENT,
41
41
  PR_WAIT_ANSWER_ELEMENT,
42
+ PR_WAIT_MERGE_ANSWER_ELEMENT,
42
43
  prEscalations,
43
44
  reconcileUserTasks,
44
45
  TRIAL_MERGE_ELEMENT,
@@ -578,35 +579,6 @@ export async function startMerge(
578
579
  return { prKey: pr.prKey, mergeProcessKey: processInstanceKey };
579
580
  }
580
581
 
581
- /** Answer an open escalation → record it and resume the process. */
582
- export async function answerEscalation(
583
- data: DataLayer,
584
- engine: EngineClient,
585
- prKey: string,
586
- answer: string,
587
- ) {
588
- const open = (await escs(data).find({ pr_key: prKey, status: "open" })).sort((a, b) => b.id - a.id);
589
- if (open.length === 0) return { ok: false, reason: "no open escalation" };
590
- const ts = now();
591
- await escs(data).update(open[0].id, { answer, status: "answered", answered_at: ts });
592
- // `pr.persist-escalation` always INSERTs a new open row, so a retry can leave duplicate open rows
593
- // for this PR. Retire any older ones to `stale` so none is left `open` to phantom-surface on
594
- // /status (mirrors `submitPr`'s resubmit cleanup and the review loop's `pr.answer-escalation`).
595
- for (const dup of open.slice(1)) {
596
- await escs(data).update(dup.id, { status: "stale" });
597
- }
598
- await prs(data).update(prKey, {
599
- status: "converging",
600
- updated_at: ts,
601
- });
602
- await engine.publishMessage({
603
- name: "escalation-answered",
604
- correlationKey: prKey,
605
- variables: { answer, escalationId: open[0].id },
606
- });
607
- return { ok: true, escalationId: open[0].id };
608
- }
609
-
610
582
  /** A PR currently in flight, as reported by the status endpoint. */
611
583
  export interface ActivePr {
612
584
  prKey: string;
@@ -632,11 +604,11 @@ export interface ActivePr {
632
604
  * without reading the datasource directly. The open-escalation question is derived from the
633
605
  * canonical `escalations` audit row — the single source of truth (no denormalised PR-row
634
606
  * pointer). A PR reads `status="escalated"` only while a token is parked awaiting a human answer,
635
- * and the row it raised carries `status="open"` until that answer is recorded — by the review
636
- * loop's `pr.answer-escalation` step on `wait-answer` completion, or the merge loop's
637
- * `answerEscalation` message path. Deriving from the row (not a per-loop wait mechanism) surfaces
638
- * BOTH loops' escalations: the merge loop parks on a message catch with no user task, so a
639
- * user-task probe would silently hide it. Once answered the row leaves `open`, so `openEscalation`
607
+ * and the row it raised carries `status="open"` until that answer is recorded — by the
608
+ * `pr.answer-escalation` step on the `wait-answer` (review loop) or `wait-merge-answer` (merge loop)
609
+ * user-task completion. Both loops now park on a native user task answered through the one canonical
610
+ * `completeUserTask` door (#256), so deriving from the row (not a per-loop wait mechanism) surfaces
611
+ * BOTH loops' escalations uniformly. Once answered the row leaves `open`, so `openEscalation`
640
612
  * derives back to null. */
641
613
  export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
642
614
  const all = await prs(data).all();
@@ -1594,13 +1566,13 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1594
1566
  continue;
1595
1567
  }
1596
1568
  for (const t of tasks) {
1597
- if (t.elementId !== PR_WAIT_ANSWER_ELEMENT) continue;
1569
+ if (t.elementId !== PR_WAIT_ANSWER_ELEMENT && t.elementId !== PR_WAIT_MERGE_ANSWER_ELEMENT) continue;
1598
1570
  const question = latestOpenEscalationQuestion(await prEscalations(data).find({ pr_key: pr.pr_key, status: "open" }));
1599
1571
  push(
1600
1572
  buildUserTaskRow(
1601
1573
  {
1602
1574
  userTaskKey: t.userTaskKey,
1603
- elementId: PR_WAIT_ANSWER_ELEMENT,
1575
+ elementId: t.elementId,
1604
1576
  subjectType: "pr",
1605
1577
  subjectKey: pr.pr_key,
1606
1578
  subjectUrl: pr.url,
package/app/userTasks.ts CHANGED
@@ -36,6 +36,12 @@ export const TRIAL_MERGE_ELEMENT = "trial-merge-decision";
36
36
  * review loop and is handed to the next round. */
37
37
  export const PR_WAIT_ANSWER_ELEMENT = "wait-answer";
38
38
 
39
+ /** The PR merge-loop escalation user task (merge-loop.bpmn) — a human answer that resumes the merge
40
+ * loop (re-arms the merge poller) when the PR can't be landed (not mergeable / merge blocked). The
41
+ * same native user-task path as the review loop's `wait-answer` (#256), answered through the one
42
+ * canonical `completeUserTask` door and surfaced in this same Tasks inbox. */
43
+ export const PR_WAIT_MERGE_ANSWER_ELEMENT = "wait-merge-answer";
44
+
39
45
  /** One row per currently-open native user-task escalation, denormalised for the Tasks page. Keyed on
40
46
  * the completable `user_task_key` (a task is open at most once). Present iff the engine reports the
41
47
  * task open; `pollUserTasks` deletes it once the task is gone. */
@@ -63,6 +69,7 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
63
69
  [PLAN_REVIEW_ELEMENT]: "Plan review",
64
70
  [TRIAL_MERGE_ELEMENT]: "Trial merge",
65
71
  [PR_WAIT_ANSWER_ELEMENT]: "PR review",
72
+ [PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
66
73
  };
67
74
 
68
75
  /** The denormalised context the poller has resolved for an open escalation user task. */
@@ -204,20 +204,23 @@ curl -sS -X POST __BASE__/../../tasks/api/complete -H 'content-type: application
204
204
  -d '{ "userTaskKey": "<key>", "variables": { "action": "rebase", "notes": "Re-run after the fix." } }'
205
205
  ```
206
206
 
207
- **Answer a merge-loop escalation** (the one out-of-scope kind that still uses the
208
- durable message catch, not a user task). Use the message name `escalation-answered`,
209
- correlated by the PR key:
207
+ **Answer a PR escalation** (both the review-loop `wait-answer` and the merge-loop
208
+ `wait-merge-answer` both are now native user tasks answered the same way, #256).
209
+ Use the PR key's parked user task and submit the `pr-escalation` form's `{ answer }`:
210
210
 
211
211
  ```bash
212
- curl -sS -X POST __BASE__/actions/message \
212
+ curl -sS -X POST __BASE__/actions/complete-user-task \
213
213
  -H 'content-type: application/json' \
214
214
  -d '{
215
- "name": "escalation-answered",
216
- "correlationKey": "owner/repo#123",
215
+ "userTaskKey": "<key>",
217
216
  "variables": { "answer": "Yes — cap the retries at 5 and proceed." }
218
217
  }'
219
218
  ```
220
219
 
220
+ The `userTaskKey` comes from `GET /status` or the Tasks inbox. This is the ONE
221
+ canonical answer door for every escalation kind; the merge loop no longer uses a
222
+ durable `escalation-answered` message catch.
223
+
221
224
  If `NANO_PR_WEBHOOK_SECRET` is set on the deployment, add `-H "x-hook-secret: <secret>"`.
222
225
 
223
226
  The answer is delivered to the agent as its next-round context (e.g. the `answer`,
package/nano.app.json CHANGED
@@ -4,6 +4,7 @@
4
4
  "id": "nano-workforce",
5
5
  "name": "Nano Workforce",
6
6
  "codename": "nano-workforce",
7
+ "network": { "bind": "all" },
7
8
  "data": {
8
9
  "default": "app",
9
10
  "sources": {
package/openapi.yaml CHANGED
@@ -1182,11 +1182,9 @@ paths:
1182
1182
  /actions/message:
1183
1183
  post:
1184
1184
  operationId: postMessage
1185
- summary: "Publish a message / answer an escalation. For the merge-loop `escalation-answered`
1186
- name, runs the merge-loop escalation answer flow; otherwise a plain publishMessage. The
1187
- epic #156 escalation
1188
- kinds (task, plan-review, trial-merge, PR review-loop) are native user tasks answered via
1189
- the task inbox (POST /tasks/api/complete), not this route."
1185
+ summary: "Publish a BPMN message (optionally correlated) into the engine. Every escalation kind
1186
+ (task, plan-review, trial-merge, PR review-loop, PR merge-loop) is a native user task answered
1187
+ via the Tasks inbox / POST /actions/complete-user-task, not this route."
1190
1188
  requestBody:
1191
1189
  required: true
1192
1190
  content:
@@ -42,7 +42,7 @@ test("the guide covers every capability the endpoint promises", async () => {
42
42
  assert(md.includes("start/convergence-loop"), "covers submitting a PR for convergence");
43
43
  assert(md.includes("convergeOnly"), "documents review-only vs. merge");
44
44
  assert(md.includes("start/plan-fanout"), "covers submitting an epic");
45
- assert(md.includes("escalation-answered"), "covers answering escalations");
45
+ assert(md.includes("complete-user-task"), "covers answering escalations via the native user-task door");
46
46
  // …debug the system.
47
47
  assert(md.includes("/jobs/search") && md.includes("/incidents/search"), "covers engine REST debugging");
48
48
  assert(md.includes("processKey") || md.includes("process_key"), "relates instances to PRs");
@@ -1,19 +1,14 @@
1
1
  // POST /app/api/actions/message → operationId `postMessage` (ADR 0058, base /app/api).
2
- // Replaces the hand-rolled action that overrode the generic publishMessage action. For the
3
- // merge-loop `escalation-answered` message we run the merge-loop escalation answer flow; any other
4
- // message falls back to a plain publishMessage.
2
+ // Replaces the hand-rolled action that overrode the generic publishMessage action: publish an
3
+ // arbitrary BPMN message (optionally correlated) into the engine.
5
4
  //
6
- // The four #156 escalation kinds (task, plan-review, trial-merge, PR review-loop) are now native
7
- // `userTask`s answered directly through the task inbox (`POST /tasks/api/complete`), so this
8
- // delegate no longer carries their bespoke `feature-escalation-answered` / `plan-escalation-answered`
9
- // discriminators. The merge-loop escalation is still a durable message catch (out of scope for
10
- // #156), so its `escalation-answered` branch is kept.
11
- //
12
- // The runtime validates the body against openapi.yaml (`name` is required, so a missing name is a 400
13
- // for free); this delegate keeps the message-name dispatch — the discriminator + downstream behavior
14
- // is app logic, not something the JSON schema can express.
5
+ // Every escalation kind is now a native `userTask` answered through the ONE canonical human/agent
6
+ // completer (`completeUserTask` `completeUserTaskAttributed`) and surfaced in the Tasks inbox
7
+ // including the merge-loop escalation, which converged from a durable `escalation-answered` message
8
+ // catch onto a native user task (#256). So this delegate no longer carries any bespoke
9
+ // escalation-answer discriminator; it is a thin, generic message publish. The runtime validates the
10
+ // body against openapi.yaml (`name` is required, so a missing name is a 400 for free).
15
11
 
16
- import { answerEscalation } from "../app/service.ts";
17
12
  import { defineOperation } from "../nano-generated/operations.ts";
18
13
 
19
14
  export default defineOperation("postMessage", async ({ body }, app) => {
@@ -24,17 +19,6 @@ export default defineOperation("postMessage", async ({ body }, app) => {
24
19
  return { status: 400, body: { error: "name is required" } };
25
20
  }
26
21
 
27
- if (name === "escalation-answered") {
28
- const prKey = String(b.correlationKey ?? "");
29
- const answer = String(b.variables?.answer ?? "").trim();
30
- if (!prKey) return { status: 400, body: { error: "correlationKey is required" } };
31
- if (!answer) return { status: 400, body: { error: "answer is required" } };
32
- const r = await answerEscalation(app.data, app.engine, prKey, answer);
33
- if (r.ok) app.log.info("merge-loop escalation answered", { name, prKey });
34
- else app.log.warn("postMessage: no open merge-loop escalation to answer", { name, prKey });
35
- return { status: r.ok ? 200 : 404, body: r };
36
- }
37
-
38
22
  await app.engine.publishMessage({
39
23
  name,
40
24
  correlationKey: b.correlationKey != null ? String(b.correlationKey) : undefined,
@@ -216,9 +216,20 @@ test("postMessage → 400 when name is blank", async () => {
216
216
  assertEquals(r.body.error, "name is required");
217
217
  });
218
218
 
219
- test("postMessage 400 when escalation-answered lacks a correlationKey", async () => {
220
- const res = await postMessage(input({ name: "escalation-answered", variables: { answer: "yes" } }), app);
219
+ test("postMessage publishes any named message generically (no bespoke escalation branch)", async () => {
220
+ // Every escalation kind is now a native user task answered via /actions/complete-user-task (#256),
221
+ // so postMessage no longer special-cases `escalation-answered`; it is a thin generic publish. The
222
+ // former `escalation-answered`-without-correlationKey 400 branch is gone — such a message now just
223
+ // publishes (uncorrelated) like any other.
224
+ let published: { name: string; correlationKey?: string; variables?: unknown } | undefined;
225
+ const pubApp = {
226
+ log: noopLog(),
227
+ engine: { publishMessage: (m: any) => ((published = m), Promise.resolve()) },
228
+ } as any as AppApi;
229
+ const res = await postMessage(input({ name: "merge-ready", correlationKey: "o/r#1", variables: { x: 1 } }), pubApp);
221
230
  const r = res as any;
222
- assertEquals(r.status, 400);
223
- assertEquals(r.body.error, "correlationKey is required");
231
+ assertEquals(r.status, 200);
232
+ assertEquals(r.body.ok, true);
233
+ assertEquals(published?.name, "merge-ready");
234
+ assertEquals(published?.correlationKey, "o/r#1");
224
235
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.76.0",
3
+ "version": "0.78.0",
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",
@@ -235,7 +235,7 @@
235
235
  "type": "dataGrid",
236
236
  "id": "pr-reviews",
237
237
  "props": {
238
- "title": "PR review escalations",
238
+ "title": "PR escalations",
239
239
  "data": {
240
240
  "kind": "datasource",
241
241
  "source": "app",
@@ -244,7 +244,8 @@
244
244
  {
245
245
  "field": "element_id",
246
246
  "in": [
247
- "wait-answer"
247
+ "wait-answer",
248
+ "wait-merge-answer"
248
249
  ]
249
250
  }
250
251
  ],
@@ -258,6 +259,10 @@
258
259
  "field": "subject_key",
259
260
  "header": "PR"
260
261
  },
262
+ {
263
+ "field": "kind_label",
264
+ "header": "Stage"
265
+ },
261
266
  {
262
267
  "field": "subject_url",
263
268
  "header": "PR link"
@@ -283,7 +288,25 @@
283
288
  "field": "question",
284
289
  "label": "Escalation question"
285
290
  }
286
- ]
291
+ ],
292
+ "form": {
293
+ "showWhenField": "user_task_key",
294
+ "title": "Answer escalation",
295
+ "promptField": "question",
296
+ "inputKey": "answer",
297
+ "inputLabel": "Your answer",
298
+ "submitLabel": "Answer & resume",
299
+ "action": {
300
+ "path": "/app/api/actions/complete-user-task",
301
+ "successLabel": "Answered — the loop will resume",
302
+ "body": {
303
+ "userTaskKey": "{{row.user_task_key}}",
304
+ "variables": {
305
+ "answer": "{{form.answer}}"
306
+ }
307
+ }
308
+ }
309
+ }
287
310
  },
288
311
  "refreshMs": 5000
289
312
  }
@@ -23,14 +23,6 @@
23
23
  <zeebe:subscription correlationKey="=prKey" />
24
24
  </bpmn:extensionElements>
25
25
  </bpmn:message>
26
- <bpmn:message id="Message_mergeEscAnswered" name="escalation-answered">
27
- <bpmn:extensionElements>
28
- <zeebe:subscription correlationKey="=prKey" />
29
- <zeebe:properties>
30
- <zeebe:property name="io.nanobpm.dataEnvelope.in" value="MergeEscalationAnswered" />
31
- </zeebe:properties>
32
- </bpmn:extensionElements>
33
- </bpmn:message>
34
26
  <bpmn:process id="merge-loop" name="PR Merge" isExecutable="true">
35
27
  <bpmn:extensionElements>
36
28
  <nano:shapes>
@@ -87,10 +79,6 @@
87
79
  <nano:extend name="prKey" type="string" />
88
80
  <nano:extend name="dependsOn" type="string" optional="true" />
89
81
  </nano:shape>
90
- <nano:shape id="MergeEscalationAnswered" name="escalation-answered message payload">
91
- <nano:extend name="answer" type="string" />
92
- <nano:extend name="escalationId" type="integer" />
93
- </nano:shape>
94
82
  </nano:shapes>
95
83
  </bpmn:extensionElements>
96
84
  <bpmn:startEvent id="MergeStart" name="PR converged">
@@ -206,12 +194,33 @@
206
194
  <bpmn:incoming>f_ci_sla</bpmn:incoming>
207
195
  <bpmn:outgoing>f_m_escA</bpmn:outgoing>
208
196
  </bpmn:serviceTask>
209
- <bpmn:intermediateCatchEvent id="wait-merge-answer" name="Wait: escalation answered">
197
+ <bpmn:userTask id="wait-merge-answer" name="Answer escalation">
198
+ <bpmn:extensionElements>
199
+ <zeebe:formDefinition formId="pr-escalation" />
200
+ <zeebe:userTask />
201
+ <zeebe:ioMapping>
202
+ <zeebe:input source="=question" target="question" />
203
+ <zeebe:input source="=prKey" target="prKey" />
204
+ </zeebe:ioMapping>
205
+ </bpmn:extensionElements>
210
206
  <bpmn:incoming>f_m_escC</bpmn:incoming>
211
207
  <bpmn:incoming>f_m_escA</bpmn:incoming>
208
+ <bpmn:outgoing>f_m_answerRecord</bpmn:outgoing>
209
+ </bpmn:userTask>
210
+ <bpmn:serviceTask id="record-merge-answer" name="Record answer">
211
+ <bpmn:extensionElements>
212
+ <zeebe:taskDefinition type="pr.answer-escalation" />
213
+ <zeebe:properties>
214
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="PrAnswerEscalationIn" />
215
+ </zeebe:properties>
216
+ <zeebe:ioMapping>
217
+ <zeebe:input source="=prKey" target="prKey" />
218
+ <zeebe:input source="=answer" target="answer" />
219
+ </zeebe:ioMapping>
220
+ </bpmn:extensionElements>
221
+ <bpmn:incoming>f_m_answerRecord</bpmn:incoming>
212
222
  <bpmn:outgoing>f_m_answer</bpmn:outgoing>
213
- <bpmn:messageEventDefinition id="med_mergeEscAnswered" messageRef="Message_mergeEscAnswered" />
214
- </bpmn:intermediateCatchEvent>
223
+ </bpmn:serviceTask>
215
224
  <bpmn:exclusiveGateway id="gw-ci-fix" name="auto-fix CI?" default="f_ci_giveup">
216
225
  <bpmn:incoming>f_m_mCiFix</bpmn:incoming>
217
226
  <bpmn:outgoing>f_ci_go</bpmn:outgoing>
@@ -355,7 +364,8 @@
355
364
  <bpmn:sequenceFlow id="f_m_done" sourceRef="mark-merged" targetRef="MergeEnd" />
356
365
  <bpmn:sequenceFlow id="f_m_escC" sourceRef="merge-esc-conflict" targetRef="wait-merge-answer" />
357
366
  <bpmn:sequenceFlow id="f_m_escA" sourceRef="merge-esc-attempt" targetRef="wait-merge-answer" />
358
- <bpmn:sequenceFlow id="f_m_answer" sourceRef="wait-merge-answer" targetRef="arm-merge" />
367
+ <bpmn:sequenceFlow id="f_m_answerRecord" sourceRef="wait-merge-answer" targetRef="record-merge-answer" />
368
+ <bpmn:sequenceFlow id="f_m_answer" sourceRef="record-merge-answer" targetRef="arm-merge" />
359
369
  <bpmn:sequenceFlow id="f_reb_sla" name="agent SLA elapsed" sourceRef="be_rebase_sla" targetRef="merge-esc-conflict" />
360
370
  <bpmn:sequenceFlow id="f_ci_sla" name="agent SLA elapsed" sourceRef="be_fixci_sla" targetRef="merge-esc-attempt" />
361
371
  </bpmn:process>
@@ -419,9 +429,9 @@
419
429
  <dc:Bounds x="1638" y="80" width="100" height="80" />
420
430
  </bpmndi:BPMNShape>
421
431
  <bpmndi:BPMNShape id="BPMNShape_MergeEnd" bpmnElement="MergeEnd">
422
- <dc:Bounds x="1838" y="102" width="36" height="36" />
432
+ <dc:Bounds x="1870" y="102" width="36" height="36" />
423
433
  <bpmndi:BPMNLabel>
424
- <dc:Bounds x="1831" y="143" width="50" height="14" />
434
+ <dc:Bounds x="1863" y="143" width="50" height="14" />
425
435
  </bpmndi:BPMNLabel>
426
436
  </bpmndi:BPMNShape>
427
437
  <bpmndi:BPMNShape id="BPMNShape_merge-esc-conflict" bpmnElement="merge-esc-conflict">
@@ -431,10 +441,10 @@
431
441
  <dc:Bounds x="1438" y="1040" width="100" height="80" />
432
442
  </bpmndi:BPMNShape>
433
443
  <bpmndi:BPMNShape id="BPMNShape_wait-merge-answer" bpmnElement="wait-merge-answer">
434
- <dc:Bounds x="1670" y="1062" width="36" height="36" />
435
- <bpmndi:BPMNLabel>
436
- <dc:Bounds x="1731" y="1103" width="74" height="42" />
437
- </bpmndi:BPMNLabel>
444
+ <dc:Bounds x="1638" y="1040" width="100" height="80" />
445
+ </bpmndi:BPMNShape>
446
+ <bpmndi:BPMNShape id="BPMNShape_record-merge-answer" bpmnElement="record-merge-answer">
447
+ <dc:Bounds x="1838" y="1040" width="100" height="80" />
438
448
  </bpmndi:BPMNShape>
439
449
  <bpmndi:BPMNShape id="BPMNShape_gw-ci-fix" bpmnElement="gw-ci-fix" isMarkerVisible="true">
440
450
  <dc:Bounds x="863" y="1535" width="50" height="50" />
@@ -517,7 +527,7 @@
517
527
  </bpmndi:BPMNEdge>
518
528
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_done" bpmnElement="f_m_done">
519
529
  <di:waypoint x="1738" y="120" />
520
- <di:waypoint x="1838" y="120" />
530
+ <di:waypoint x="1870" y="120" />
521
531
  </bpmndi:BPMNEdge>
522
532
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_giveup" bpmnElement="f_ci_giveup">
523
533
  <di:waypoint x="913" y="1560" />
@@ -568,11 +578,15 @@
568
578
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escC" bpmnElement="f_m_escC">
569
579
  <di:waypoint x="1338" y="1560" />
570
580
  <di:waypoint x="1688" y="1560" />
571
- <di:waypoint x="1688" y="1098" />
581
+ <di:waypoint x="1688" y="1120" />
582
+ </bpmndi:BPMNEdge>
583
+ <bpmndi:BPMNEdge id="BPMNEdge_f_m_answerRecord" bpmnElement="f_m_answerRecord">
584
+ <di:waypoint x="1738" y="1080" />
585
+ <di:waypoint x="1838" y="1080" />
572
586
  </bpmndi:BPMNEdge>
573
587
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_answer" bpmnElement="f_m_answer">
574
- <di:waypoint x="1688" y="1098" />
575
- <di:waypoint x="1688" y="1140" />
588
+ <di:waypoint x="1888" y="1120" />
589
+ <di:waypoint x="1888" y="1140" />
576
590
  <di:waypoint x="402" y="1140" />
577
591
  <di:waypoint x="402" y="160" />
578
592
  </bpmndi:BPMNEdge>
@@ -612,13 +626,13 @@
612
626
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_go" bpmnElement="f_ci_go">
613
627
  <di:waypoint x="888" y="1585" />
614
628
  <di:waypoint x="888" y="1605" />
615
- <di:waypoint x="1726" y="1605" />
616
- <di:waypoint x="1726" y="555" />
629
+ <di:waypoint x="1958" y="1605" />
630
+ <di:waypoint x="1958" y="555" />
617
631
  <di:waypoint x="1018" y="555" />
618
632
  <di:waypoint x="1018" y="580" />
619
633
  <di:waypoint x="1038" y="580" />
620
634
  <bpmndi:BPMNLabel>
621
- <dc:Bounds x="1731" y="1046" width="49" height="28" />
635
+ <dc:Bounds x="1963" y="1066" width="49" height="28" />
622
636
  </bpmndi:BPMNLabel>
623
637
  </bpmndi:BPMNEdge>
624
638
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_blocked" bpmnElement="f_ci_blocked">
@@ -685,13 +699,13 @@
685
699
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gBlocked" bpmnElement="f_m_gBlocked">
686
700
  <di:waypoint x="1088" y="95" />
687
701
  <di:waypoint x="1088" y="75" />
688
- <di:waypoint x="1894" y="75" />
689
- <di:waypoint x="1894" y="820" />
702
+ <di:waypoint x="1926" y="75" />
703
+ <di:waypoint x="1926" y="820" />
690
704
  <di:waypoint x="1418" y="820" />
691
705
  <di:waypoint x="1418" y="1060" />
692
706
  <di:waypoint x="1438" y="1060" />
693
707
  <bpmndi:BPMNLabel>
694
- <dc:Bounds x="1899" y="441" width="53" height="14" />
708
+ <dc:Bounds x="1931" y="441" width="53" height="14" />
695
709
  </bpmndi:BPMNLabel>
696
710
  </bpmndi:BPMNEdge>
697
711
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_done" bpmnElement="f_ci_done">
@@ -704,7 +718,7 @@
704
718
  </bpmndi:BPMNEdge>
705
719
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escA" bpmnElement="f_m_escA">
706
720
  <di:waypoint x="1538" y="1080" />
707
- <di:waypoint x="1670" y="1080" />
721
+ <di:waypoint x="1638" y="1080" />
708
722
  </bpmndi:BPMNEdge>
709
723
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_sla" bpmnElement="f_reb_sla">
710
724
  <di:waypoint x="1088" y="978" />
@@ -1,6 +1,7 @@
1
- // pr.answer-escalation — retires the review-loop escalation the operator just answered.
2
- //
3
- // The review-loop escalation is a native `wait-answer` userTask (backed by `pr-escalation.form`).
1
+ // pr.answer-escalation — retires the escalation the operator just answered, for BOTH the review loop
2
+ // (`wait-answer`, convergence-loop.bpmn) and the merge loop (`wait-merge-answer`, merge-loop.bpmn).
3
+ // Both loops now park on a native `wait-*` userTask (backed by `pr-escalation.form`) and run this same
4
+ // reconcile step on completion (#256) — there is one answer path, not two.
4
5
  // Completing that task resumes the token, but the engine folds the completed instance's variables
5
6
  // away — so without this step the durable `escalations` audit row raised by `pr.persist-escalation`
6
7
  // would stay `status="open"` forever, its `answer`/`answered_at` never recorded. That both loses the
@@ -8,9 +9,8 @@
8
9
  // single source of truth, ADR "derivation over duplication") would keep surfacing a phantom open
9
10
  // escalation on `/status` after it was answered.
10
11
  //
11
- // This mirrors the merge-loop's message-catch answer path (`answerEscalation` in app/service.ts):
12
- // both answer the newest still-open `escalations` row, mark any duplicate open rows `stale` (a
13
- // retry of `pr.persist-escalation` can leave more than one open), AND move the `pull_requests` row
12
+ // It answers the newest still-open `escalations` row, marks any duplicate open rows `stale` (a
13
+ // retry of `pr.persist-escalation` can leave more than one open), AND moves the `pull_requests` row
14
14
  // off `status="escalated"` back to `"converging"`, so an answered escalation is never left dangling
15
15
  // and `/status` never shows an escalated PR with a null question or a phantom open row. The token
16
16
  // resume itself is owned by the engine (userTask completion), so this worker only reconciles the
@@ -28,7 +28,7 @@ interface Escalation extends Record<string, unknown> {
28
28
  }
29
29
 
30
30
  // The PR-row fields this worker reconciles when an escalation is answered. Only `status`/`updated_at`
31
- // are written (mirroring `answerEscalation`); the rest of the row is untouched.
31
+ // are written; the rest of the row is untouched.
32
32
  interface PullRequest extends Record<string, unknown> {
33
33
  pr_key: string;
34
34
  status: string;
@@ -51,7 +51,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
51
51
  // open row, so a retry/duplicate activation can leave more than one open — answering only the
52
52
  // newest would leave an older duplicate `open`, a phantom `activePrs` keeps deriving while the PR
53
53
  // is still `escalated`. Answer the newest (it carries the operator's reply) and mark any remaining
54
- // open rows `stale`, mirroring `answerEscalation` and `submitPr`'s resubmit cleanup.
54
+ // open rows `stale`, mirroring `submitPr`'s resubmit cleanup.
55
55
  const open = (await escs.find({ pr_key: prKey, status: "open" })).sort((a, b) => b.id - a.id);
56
56
  if (open.length > 0) {
57
57
  const ts = new Date().toISOString();
@@ -63,7 +63,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
63
63
  for (const dup of open.slice(1)) {
64
64
  await escs.update(dup.id, { status: "stale" });
65
65
  }
66
- // Mirror the merge loop's `answerEscalation`: move the PR off `status="escalated"` back to
66
+ // Move the PR off `status="escalated"` back to
67
67
  // `"converging"` now that the question is answered. Without this the row stays `escalated` (with
68
68
  // a now-null derived `openEscalation`) until the re-entered round's `persist-round` runs — a
69
69
  // `/status` inconsistency and a divergence from the merge path both loops are meant to share.
@@ -1,67 +0,0 @@
1
- // Red/green regression for answerEscalation (merge-loop message path) — Copilot review of PR #180.
2
- //
3
- // `pr.persist-escalation` always INSERTs a fresh `escalations` row with `status="open"`, so a
4
- // retry/duplicate activation can leave more than one open row for the same PR. answerEscalation
5
- // only closed the NEWEST open row, leaving older duplicates `open` — a phantom that keeps
6
- // `activePrs` deriving an open escalation while the PR is still `escalated`. Every open row for the
7
- // PR must leave `open` in this completion (newest answered, the rest marked `stale`), exactly as
8
- // `submitPr`'s resubmit cleanup already does. Mirrors the review loop's `pr.answer-escalation`.
9
- import { test } from "node:test";
10
- import { assertEquals } from "#test-assert";
11
- import type { DataLayer, EngineClient } from "@nanobpm/urban";
12
- import { answerEscalation } from "./service.ts";
13
-
14
- function memData(escalations: any[], prs: any[]): DataLayer {
15
- const table = (name: string, key: string) => ({
16
- async find(where: Record<string, unknown>) {
17
- const src = name === "escalations" ? escalations : prs;
18
- return src.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
19
- },
20
- async update(k: unknown, patch: Record<string, unknown>) {
21
- const src = name === "escalations" ? escalations : prs;
22
- const r = src.find((x) => x[key] === k);
23
- if (r) Object.assign(r, patch);
24
- return r;
25
- },
26
- });
27
- return { table } as any as DataLayer;
28
- }
29
-
30
- function fakeEngine(published: any[]): EngineClient {
31
- return {
32
- async publishMessage(m: any) {
33
- published.push(m);
34
- },
35
- } as any as EngineClient;
36
- }
37
-
38
- test("answerEscalation answers the newest open row and marks duplicate open rows stale", async () => {
39
- const escalations = [
40
- { id: 3, pr_key: "o/r#1", status: "open", answer: null, answered_at: null },
41
- { id: 7, pr_key: "o/r#1", status: "open", answer: null, answered_at: null },
42
- { id: 9, pr_key: "o/r#2", status: "open", answer: null, answered_at: null },
43
- ];
44
- const prs = [{ pr_key: "o/r#1", status: "escalated", updated_at: "t0" }];
45
- const published: any[] = [];
46
- const res = await answerEscalation(memData(escalations, prs), fakeEngine(published), "o/r#1", "Cap at 5.");
47
-
48
- assertEquals(res, { ok: true, escalationId: 7 }, "the newest open row is the one answered");
49
- const answered = escalations.find((e) => e.id === 7);
50
- const stale = escalations.find((e) => e.id === 3);
51
- const other = escalations.find((e) => e.id === 9);
52
- assertEquals(answered?.status, "answered");
53
- assertEquals(answered?.answer, "Cap at 5.");
54
- assertEquals(stale?.status, "stale", "the older duplicate open row is retired to stale");
55
- assertEquals(other?.status, "open", "the other PR's open row is untouched");
56
- assertEquals(prs[0].status, "converging", "the PR row is moved off escalated");
57
- assertEquals(published.length, 1, "the resume message is published once");
58
- assertEquals(published[0].variables.escalationId, 7);
59
- });
60
-
61
- test("answerEscalation is a no-op when no row is open", async () => {
62
- const escalations = [{ id: 7, pr_key: "o/r#1", status: "answered", answer: "x", answered_at: "t" }];
63
- const published: any[] = [];
64
- const res = await answerEscalation(memData(escalations, []), fakeEngine(published), "o/r#1", "again");
65
- assertEquals(res, { ok: false, reason: "no open escalation" });
66
- assertEquals(published.length, 0);
67
- });