@nanobpm/nano-workforce 0.77.0 → 0.79.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.
@@ -0,0 +1,151 @@
1
+ // nano-workforce — replay-by-reseed / fork of a transcript log (ADR 0056, #251).
2
+ //
3
+ // The H3 read path (#222) can RESUME the same stream from an offset (reattach parity), but it cannot
4
+ // FORK: seed a NEW stream from an existing log so an exited agent's session can be branched or re-run
5
+ // from a chosen point ("what-if" a different continuation). dsh gets this for free because a session IS
6
+ // its append-only log, so forking is just re-seeding a new session from an existing log up to offset N.
7
+ // This module gives the transcript store the same capability WITHOUT touching the store package: it
8
+ // reads the source log and re-records it into a fresh stream through the store's own idempotent,
9
+ // offset-keyed {@link TranscriptStore.record} — so the fork is itself append-only and offset-parity
10
+ // with its source, and replays through the SAME resume-from-offset read path a native stream uses.
11
+ //
12
+ // Invariants preserved (ADR 0056): app-tier only, append-only (we only ever `record`, never mutate),
13
+ // advisory (a fork is a new advisory transcript — it gates no BPMN flow), and offset/resume wire-shape
14
+ // parity (the fork keeps the source offsets, so a reattach behaves identically on the branch).
15
+
16
+ import type { TranscriptChunk, TranscriptLifecycle, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
17
+
18
+ /** Raised when a fork/reseed cannot proceed — the source is missing, or the target already exists. */
19
+ export class TranscriptForkError extends Error {
20
+ readonly source: string;
21
+ readonly target: string;
22
+ constructor(source: string, target: string, message: string) {
23
+ super(message);
24
+ this.name = "TranscriptForkError";
25
+ this.source = source;
26
+ this.target = target;
27
+ }
28
+ }
29
+
30
+ /** Options controlling how a source log is reseeded into a new stream. */
31
+ export interface ForkTranscriptOptions {
32
+ /**
33
+ * Seed only chunks with `offset <= throughOffset` (inclusive) — the point the branch diverges from.
34
+ * Omit to fork the WHOLE source log (every retained chunk). A `throughOffset` below the source's
35
+ * oldest retained offset yields an empty fork (a valid, if trivial, branch point).
36
+ */
37
+ readonly throughOffset?: number;
38
+ /**
39
+ * The forked stream's retention lifecycle. Defaults to `ephemeral` — a fork is a captured branch,
40
+ * retained-whole then swept like any completed session, not a growing live stream.
41
+ */
42
+ readonly lifecycle?: TranscriptLifecycle;
43
+ /**
44
+ * Allow reseeding into a target that already exists. Off by default: forking onto a populated stream
45
+ * would interleave two logs' bytes and defeat offset-parity, so we refuse rather than clobber. When
46
+ * on, seeding is still idempotent (offset-keyed), so re-running the SAME fork is a safe no-op — but
47
+ * the existing target must already hold exactly this seed prefix (same offsets, same chunk bytes,
48
+ * same lifecycle); a target that diverges from the prefix throws rather than silently interleaving.
49
+ */
50
+ readonly allowExisting?: boolean;
51
+ }
52
+
53
+ /** The outcome of a {@link forkTranscript}: the new stream, how many chunks it seeded, and its window. */
54
+ export interface ForkResult {
55
+ /** The forked stream id (the `target` argument). */
56
+ readonly stream: string;
57
+ /** The source stream the fork was seeded from. */
58
+ readonly source: string;
59
+ /** Number of chunks newly persisted into the fork. */
60
+ readonly seeded: number;
61
+ /** The highest source offset included in the fork (undefined when the fork is empty). */
62
+ readonly throughOffset?: number;
63
+ /** The forked stream's metadata after seeding. */
64
+ readonly meta: TranscriptStream;
65
+ }
66
+
67
+ /**
68
+ * Fork a transcript: seed a NEW stream (`target`) from an existing log (`source`) up to a chosen offset,
69
+ * so an exited session can be branched and replayed independently.
70
+ *
71
+ * The fork keeps the SOURCE offsets (offset-parity), so it resumes through the identical
72
+ * resume-from-offset read path a native stream uses. It reads the source's retained window
73
+ * (`store.read`), takes the prefix at or below `throughOffset` (default: the whole log), and re-records
74
+ * it into `target` via the store's idempotent offset-keyed `record` — so the operation is append-only
75
+ * and safe to re-run. The branch is fully independent of its source thereafter: appending to either
76
+ * stream never affects the other.
77
+ *
78
+ * Throws {@link TranscriptForkError} when the source has no transcript, when the target already
79
+ * exists and `allowExisting` is not set, or when `allowExisting` is set but the existing target does
80
+ * not already match the reseed prefix exactly (divergent chunk bytes/offsets or a different lifecycle).
81
+ */
82
+ export function forkTranscript(
83
+ store: TranscriptStore,
84
+ source: string,
85
+ target: string,
86
+ options: ForkTranscriptOptions = {},
87
+ ): ForkResult {
88
+ if (source === target) {
89
+ throw new TranscriptForkError(source, target, "cannot fork a stream onto itself");
90
+ }
91
+ if (store.get(source) === undefined) {
92
+ throw new TranscriptForkError(source, target, `source stream "${source}" has no transcript to fork`);
93
+ }
94
+ const existing = store.get(target);
95
+ if (existing !== undefined && !options.allowExisting) {
96
+ throw new TranscriptForkError(source, target, `target stream "${target}" already exists (pass allowExisting to reseed it)`);
97
+ }
98
+
99
+ const lifecycle: TranscriptLifecycle = options.lifecycle ?? "ephemeral";
100
+ const through = options.throughOffset;
101
+ const chunks: TranscriptChunk[] = store
102
+ .read(source)
103
+ .filter((c) => through === undefined || c.offset <= through);
104
+
105
+ // Reseeding onto an EXISTING target (allowExisting) is only safe when that target already holds
106
+ // exactly the prefix we are about to seed. `record()` is offset-keyed and idempotent, so it silently
107
+ // no-ops any offset already present — if the existing chunk at that offset differs (or the target
108
+ // carries offsets outside this prefix, or a different lifecycle), the reseed would leave a stream
109
+ // that is a MIXTURE of the prior data and the seed, breaking the documented offset-parity invariant.
110
+ // Validate the overlap before writing and refuse rather than clobber/interleave.
111
+ if (existing !== undefined) {
112
+ if (existing.lifecycle !== lifecycle) {
113
+ throw new TranscriptForkError(
114
+ source,
115
+ target,
116
+ `target stream "${target}" already exists with lifecycle "${existing.lifecycle}", cannot reseed as "${lifecycle}"`,
117
+ );
118
+ }
119
+ const seedByOffset = new Map(chunks.map((c) => [c.offset, c.chunk]));
120
+ for (const c of store.read(target)) {
121
+ const expected = seedByOffset.get(c.offset);
122
+ if (expected === undefined || expected !== c.chunk) {
123
+ throw new TranscriptForkError(
124
+ source,
125
+ target,
126
+ `target stream "${target}" already contains data that does not match the reseed prefix at offset ${c.offset}; refusing to interleave`,
127
+ );
128
+ }
129
+ }
130
+ }
131
+
132
+ // Open the fork explicitly so an empty fork (throughOffset predating the log) is still a real,
133
+ // listed stream under its own lifecycle rather than a phantom — mirrors the store's open-then-record.
134
+ store.open(target, lifecycle);
135
+ const seeded = chunks.length > 0 ? store.record(target, chunks, lifecycle) : 0;
136
+
137
+ const meta = store.get(target);
138
+ if (meta === undefined) {
139
+ // Defensive: open() above guarantees a row, so this only fires on a store contract breach.
140
+ throw new TranscriptForkError(source, target, `fork of "${source}" into "${target}" did not persist a stream`);
141
+ }
142
+
143
+ const result: ForkResult = {
144
+ stream: target,
145
+ source,
146
+ seeded,
147
+ meta,
148
+ };
149
+ const last = chunks.at(-1);
150
+ return last !== undefined ? { ...result, throughOffset: last.offset } : result;
151
+ }
@@ -17,11 +17,12 @@
17
17
  import type { TranscriptChunk, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
18
18
  import type { AgenticTranscript, AgenticTranscriptData } from "../../nano-generated/api-io.d.ts";
19
19
  import { type CorrelationRegistry, jobKeyOfStream } from "./correlation.ts";
20
+ import { utf8ByteLength } from "./transcript-events.ts";
20
21
 
21
22
  /** Total captured bytes across a set of retained chunks (UTF-8, the on-the-wire terminal encoding). */
22
23
  export function byteLengthOf(chunks: readonly TranscriptChunk[]): number {
23
24
  let total = 0;
24
- for (const c of chunks) total += Buffer.byteLength(c.chunk, "utf8");
25
+ for (const c of chunks) total += utf8ByteLength(c.chunk);
25
26
  return total;
26
27
  }
27
28
 
@@ -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,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/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.77.0",
3
+ "version": "0.79.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
  }