@nanobpm/nano-workforce 0.77.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 +7 -0
- package/SPEC.md +17 -14
- package/app/agentCompletion.ts +2 -0
- package/app/agentGuide.ts +2 -1
- package/app/answer-escalation.test.ts +2 -2
- package/app/mergeEscalationUserTask.test.ts +77 -0
- package/app/pollUserTasks.test.ts +23 -0
- package/app/service.ts +8 -36
- package/app/userTasks.ts +7 -0
- package/docs/agent-guide.md +9 -6
- package/openapi.yaml +3 -5
- package/operations/getAgentInstructions.test.ts +1 -1
- package/operations/postMessage.ts +8 -24
- package/operations/startAndMessage.test.ts +15 -4
- package/package.json +1 -1
- package/pages/tasks.page.json +26 -3
- package/resources/processes/merge-loop.bpmn +47 -33
- package/workers/answer-escalation/worker.ts +9 -9
- package/app/answerEscalation.test.ts +0 -67
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
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
|
+
|
|
1
8
|
# [0.77.0](https://github.com/nanobpm/nano-workforce/compare/v0.76.0...v0.77.0) (2026-08-17)
|
|
2
9
|
|
|
3
10
|
|
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:
|
|
106
|
+
│ │ → [Wait: wait-answer userTask] ─────────────────────┤
|
|
107
107
|
│ │ │
|
|
108
108
|
│ └── needs_input [Record escalation] │ │
|
|
109
109
|
│ or blocked → (kind = question | blocker) │ │
|
|
110
|
-
│ → [Wait:
|
|
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,
|
|
118
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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`
|
|
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
|
-
|
|
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-
|
|
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
|
|
package/app/agentCompletion.ts
CHANGED
|
@@ -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/
|
|
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
|
-
//
|
|
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", "
|
|
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
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
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:
|
|
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. */
|
package/docs/agent-guide.md
CHANGED
|
@@ -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
|
|
208
|
-
|
|
209
|
-
|
|
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/
|
|
212
|
+
curl -sS -X POST __BASE__/actions/complete-user-task \
|
|
213
213
|
-H 'content-type: application/json' \
|
|
214
214
|
-d '{
|
|
215
|
-
"
|
|
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
|
|
1186
|
-
|
|
1187
|
-
|
|
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("
|
|
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
|
|
3
|
-
//
|
|
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
|
-
//
|
|
7
|
-
// `
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
220
|
-
|
|
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,
|
|
223
|
-
assertEquals(r.body.
|
|
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.
|
|
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",
|
package/pages/tasks.page.json
CHANGED
|
@@ -235,7 +235,7 @@
|
|
|
235
235
|
"type": "dataGrid",
|
|
236
236
|
"id": "pr-reviews",
|
|
237
237
|
"props": {
|
|
238
|
-
"title": "PR
|
|
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:
|
|
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
|
-
|
|
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="
|
|
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="
|
|
432
|
+
<dc:Bounds x="1870" y="102" width="36" height="36" />
|
|
423
433
|
<bpmndi:BPMNLabel>
|
|
424
|
-
<dc:Bounds x="
|
|
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="
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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="
|
|
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="
|
|
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="
|
|
575
|
-
<di:waypoint x="
|
|
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="
|
|
616
|
-
<di:waypoint x="
|
|
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="
|
|
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="
|
|
689
|
-
<di:waypoint x="
|
|
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="
|
|
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="
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
12
|
-
//
|
|
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
|
|
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 `
|
|
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
|
-
//
|
|
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
|
-
});
|