@nanobpm/nano-workforce 0.129.0 → 0.129.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,11 @@
1
1
  name: PR title lint
2
2
 
3
3
  # Squash-merge uses the PR title as the commit subject on `main`, and
4
- # semantic-release (Angular preset) only cuts a release for `feat:`/`fix:`/`perf:`.
5
- # A non-conventional title therefore lands on `main` and is silently skipped by
6
- # the release job so gate the title against the Conventional Commits grammar.
4
+ # semantic-release (conventionalcommits preset) cuts a release for
5
+ # `feat:`/`fix:`/`perf:`/`refactor:`/`build:`/`revert:`/`docs:` (see
6
+ # `.releaserc.json` releaseRules). A non-conventional title therefore lands on
7
+ # `main` and is silently skipped by the release job — so gate the title against
8
+ # the Conventional Commits grammar.
7
9
  on:
8
10
  pull_request:
9
11
  types: [opened, edited, synchronize, reopened]
package/.releaserc.json CHANGED
@@ -1,8 +1,42 @@
1
1
  {
2
2
  "branches": ["main"],
3
3
  "plugins": [
4
- "@semantic-release/commit-analyzer",
5
- "@semantic-release/release-notes-generator",
4
+ [
5
+ "@semantic-release/commit-analyzer",
6
+ {
7
+ "preset": "conventionalcommits",
8
+ "releaseRules": [
9
+ { "type": "feat", "release": "minor" },
10
+ { "type": "fix", "release": "patch" },
11
+ { "type": "perf", "release": "patch" },
12
+ { "type": "refactor", "release": "patch" },
13
+ { "type": "build", "release": "patch" },
14
+ { "type": "revert", "release": "patch" },
15
+ { "type": "docs", "release": "patch" }
16
+ ]
17
+ }
18
+ ],
19
+ [
20
+ "@semantic-release/release-notes-generator",
21
+ {
22
+ "preset": "conventionalcommits",
23
+ "presetConfig": {
24
+ "types": [
25
+ { "type": "feat", "section": "Features" },
26
+ { "type": "fix", "section": "Bug Fixes" },
27
+ { "type": "perf", "section": "Performance Improvements" },
28
+ { "type": "refactor", "section": "Code Refactoring" },
29
+ { "type": "build", "section": "Build System" },
30
+ { "type": "revert", "section": "Reverts" },
31
+ { "type": "docs", "section": "Documentation" },
32
+ { "type": "test", "hidden": true },
33
+ { "type": "ci", "hidden": true },
34
+ { "type": "chore", "hidden": true },
35
+ { "type": "style", "hidden": true }
36
+ ]
37
+ }
38
+ }
39
+ ],
6
40
  "@semantic-release/changelog",
7
41
  "@semantic-release/npm",
8
42
  "@semantic-release/github",
package/AGENTS.md CHANGED
@@ -353,16 +353,20 @@ agents:
353
353
  - **DCO sign-off is enforced.** Every commit needs a `Signed-off-by` trailer —
354
354
  use `git commit -s` (or `git rebase --signoff`). A missing sign-off fails the
355
355
  DCO check.
356
- - **Conventional Commits.** `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`,
357
- `test:`, imperative mood. Review-comment fix-ups are `chore:`, not `fix:`.
356
+ - **Conventional Commits.** `feat:`, `fix:`, `perf:`, `refactor:`, `build:`,
357
+ `revert:`, `docs:`, `chore:`, `ci:`, `test:`, `style:`, imperative mood — see the
358
+ next bullet for which of these trigger a release. Review-comment fix-ups are
359
+ `chore:`, not `fix:`.
358
360
  - **PR titles must be Conventional too — they become the release trigger.** PRs
359
361
  land on `main` via **squash merge**, so the **PR title is the commit subject**
360
- semantic-release analyses. Only `feat:` (minor) and `fix:`/`perf:` (patch) cut a
361
- release; any other type or a non-conventional title like `Redesign …` or
362
- `Foundation: …` — lands on `main` and is **silently skipped** by the release job
362
+ semantic-release analyses. `feat:` cuts a **minor**; `fix:`, `perf:`,
363
+ `refactor:`, `build:`, `revert:`, and `docs:` cut a **patch** (see
364
+ `.releaserc.json` `releaseRules`); a `BREAKING CHANGE:` footer cuts a **major**.
365
+ Only `chore:`, `ci:`, `test:`, and `style:` are **no-release** — a PR titled
366
+ with one of those lands on `main` and is **silently skipped** by the release job
363
367
  (no version, no changelog, no deploy). A user-facing feature **must** be titled
364
368
  `feat:`. The `PR title lint` workflow (`.github/workflows/pr-title-lint.yml`)
365
- enforces this; if a non-conventional title ever slips through, push one empty
369
+ enforces this; if a non-releasing title ever slips through, push one empty
366
370
  releasable commit (`git commit --allow-empty -s -m "feat(scope): …"`) to release
367
371
  the accumulated changes.
368
372
  - **Feature work in a worktree** off `origin/main`, one branch per change; open a
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.129.1](https://github.com/nanobpm/nano-workforce/compare/v0.129.0...v0.129.1) (2026-08-23)
2
+
3
+ ### Code Refactoring
4
+
5
+ * **merge-loop:** sub-process topology + behavioural guards ([#466](https://github.com/nanobpm/nano-workforce/issues/466)) ([#490](https://github.com/nanobpm/nano-workforce/issues/490)) ([ac6f130](https://github.com/nanobpm/nano-workforce/commit/ac6f130d19e545c4d3da23d738c30c1cb6f2b981)), closes [Magikcraft/nano-bpm#971](https://github.com/Magikcraft/nano-bpm/issues/971)
6
+
1
7
  # [0.129.0](https://github.com/nanobpm/nano-workforce/compare/v0.128.0...v0.129.0) (2026-08-23)
2
8
 
3
9
 
@@ -1,71 +1,37 @@
1
- // Structural + cross-layer regression guard for converging the merge-loop escalation onto the ONE
2
- // native user-task answer pathway (#256).
1
+ // Cross-layer drift guard: the merge-loop escalation user task the model parks on must be one the
2
+ // canonical completer (`agentCompletion.ts`) actually accepts and validates (#256, #466).
3
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.
4
+ // The merge escalation converges on ONE native `wait-merge-answer` userTask (backed by the shared
5
+ // `pr-escalation` form) so it is answerable from the one Tasks inbox. The *behavioural* invariants —
6
+ // that the loop parks on that task, that answering it reconciles the escalations row and re-arms the
7
+ // poller — are exercised end-to-end by the WASM engine in `mergeLoopBehaviour.test.ts`. But that
8
+ // engine harness completes the task through the engine, NOT through nwf's application-level
9
+ // completer, so it cannot catch the specific silent-drift failure this guard closes: the model
10
+ // deploys and parks on `wait-merge-answer`, yet `agentCompletion.ts` refuses to drive it because the
11
+ // id fell out of `ESCALATION_TASK_ELEMENTS` (or its form contract drifted) a task no worker will
12
+ // ever answer. This guard ties the model's user-task id to the completer's accepted set + form
13
+ // contract so the two layers cannot diverge unnoticed.
14
14
 
15
15
  import { test } from "node:test";
16
- import { assert, assertStringIncludes } from "#test-assert";
16
+ import { assert } from "#test-assert";
17
17
  import { readFileSync } from "node:fs";
18
18
  import { ESCALATION_TASK_ELEMENTS, validateEscalationVariables } from "./agentCompletion.ts";
19
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
- });
20
+ const flat = readFileSync("resources/processes/merge-loop.bpmn", "utf8").replace(/\s+/g, " ");
37
21
 
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.
22
+ test("drift guard: the model's merge user-task element is one the canonical completer accepts", () => {
23
+ // (1) The model actually parks on `wait-merge-answer` as a native user task...
53
24
  assert(
54
- !/<bpmn:intermediateCatchEvent\b[^>]*\bid="wait-merge-answer"/.test(flat),
55
- "wait-merge-answer must no longer be an intermediateCatchEvent",
25
+ /<bpmn:userTask\b[^>]*\bid="wait-merge-answer"/.test(flat),
26
+ "the model must park the merge escalation on a userTask id='wait-merge-answer'",
56
27
  );
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.
28
+ // (2) ...and the completer accepts that exact id (else it deploys but is never answerable)...
63
29
  assert(
64
30
  ESCALATION_TASK_ELEMENTS.has("wait-merge-answer"),
65
31
  "ESCALATION_TASK_ELEMENTS must accept wait-merge-answer",
66
32
  );
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.
33
+ // (3) ...resolving to the pr-escalation form contract (a missing answer is rejected; a present
34
+ // one accepted), proving the element maps to the same form the model renders.
69
35
  assert(
70
36
  validateEscalationVariables("wait-merge-answer", {}) !== null,
71
37
  "wait-merge-answer must enforce the pr-escalation form contract (answer required)",
@@ -0,0 +1,446 @@
1
+ // Behavioural coverage for the sub-process merge-loop (issue #466).
2
+ //
3
+ // The merge-loop was refactored from a flat state machine into sub-processes
4
+ // (`SP_cifix`, `SP_rebase`) whose outcomes are propagated to the top level via
5
+ // end-event `ciOutcome`/`rebaseOutcome` output mappings and re-discriminated by
6
+ // `gw-ci-outcome`/`gw-rebase-outcome`. The previous merge guards were *structural
7
+ // text assertions* over the flat topology; they broke by construction under any
8
+ // re-shaping and re-encoded the model's shape rather than its behaviour.
9
+ //
10
+ // Per direction (issue #466) these are replaced with **behavioural** tests that
11
+ // deploy the committed model into the real WASM engine (`@nanobpm/urban-testkit`)
12
+ // and drive tokens through it, asserting the observable invariant — activated
13
+ // jobs, taken outcomes, terminal state, escalations, budget counters — so they
14
+ // protect what the loop *does*, not how it is drawn. The invariants preserved
15
+ // here are exactly those the retired guards protected:
16
+ // - mergeRetryArm (#334): transient-retry arm re-arms within budget, escalates
17
+ // when exhausted, advances the attempt counter only on a retry, no agent.
18
+ // - mergeCiReattempt (#134): fix-ci `reattempt`/no-verdict re-arms (never pages
19
+ // a human); blocked reconciles once from ground truth before escalating.
20
+ // - mergeRebaseArm: conflict → bounded rebase agent → re-arm / escalate /
21
+ // reconcile / wait-on-PR.
22
+ // - mergeEscalationQuestion (#329/#454): the four blocked/SLA triggers and the
23
+ // draft verdict each produce a distinct, human-actionable question; a
24
+ // persist-escalation `escalated:false` re-enters the poller instead of
25
+ // parking a dead user task.
26
+ // - mergeEscalationUserTask (#256): escalation parks on the native
27
+ // `wait-merge-answer` user task and the answer reconciles then re-arms.
28
+ // Plus the terminate semantics the refactor had to preserve: `MergeAbandoned`
29
+ // terminates the whole instance (it stays at the root, not inside a sub-process).
30
+ import { after, test } from "node:test";
31
+ import { assert, assertStringIncludes } from "#test-assert";
32
+ import { readFileSync } from "node:fs";
33
+ import {
34
+ assertThatInstance,
35
+ assertThatUserTask,
36
+ byProcessId,
37
+ createWasmEngineClient,
38
+ type WasmEngineClient,
39
+ } from "@nanobpm/urban-testkit";
40
+
41
+ const MODEL = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
42
+
43
+ const AGENT_SLA_MS = 30 * 60 * 1000; // matches the PT30M we start instances with
44
+
45
+ type Output = Record<string, unknown>;
46
+ type Responder = Output | Output[] | ((job: { variables: Record<string, unknown> }) => Output);
47
+
48
+ const ALL_JOB_TYPES = [
49
+ "pr.arm-merge",
50
+ "pr.merge",
51
+ "pr.mark-merged",
52
+ "senior:fix-ci",
53
+ "senior:rebase",
54
+ "pr.persist-escalation",
55
+ "pr.answer-escalation",
56
+ "pr.record-dependency",
57
+ ] as const;
58
+
59
+ const DEFAULT_RESPONSES: Record<string, Responder> = {
60
+ "pr.arm-merge": {},
61
+ "pr.mark-merged": {},
62
+ "pr.record-dependency": {},
63
+ "pr.answer-escalation": {},
64
+ "pr.persist-escalation": { escalated: true },
65
+ };
66
+
67
+ // Every FEEL expression in the model references these; start them defined (null,
68
+ // or a typed zero where the model compares/arithmetics the value, e.g.
69
+ // `failingChecks > 0`) so a missing-variable access can never raise a spurious
70
+ // incident in a test.
71
+ const DEFAULT_VARS: Record<string, unknown> = {
72
+ prKey: "pr-1",
73
+ repo: "acme/app",
74
+ prNumber: 1,
75
+ prUrl: "https://example.test/pr/1",
76
+ ciFixMax: 3,
77
+ rebaseMax: 3,
78
+ mergeRetryMax: 3,
79
+ ciFixRound: 0,
80
+ rebaseRound: 0,
81
+ mergeRetryRound: 0,
82
+ agentSlaTimeout: "PT30M",
83
+ abandonBrief: null,
84
+ failingChecksList: null,
85
+ status: null,
86
+ mergeState: null,
87
+ mergeStatus: null,
88
+ ciBlockedReconciled: null,
89
+ failingChecks: 0,
90
+ };
91
+
92
+ /**
93
+ * Deploy the committed merge-loop and start one instance, wired to a per-job-type
94
+ * responder. A responder may be a fixed output, a queue consumed per activation,
95
+ * or a function of the job. A job type mapped to `null` registers **no** worker,
96
+ * so its token parks on the task — used to let an agent SLA boundary fire.
97
+ */
98
+ async function startMergeLoop(opts: {
99
+ responses?: Record<string, Responder | null>;
100
+ vars?: Record<string, unknown>;
101
+ } = {}): Promise<WasmEngineClient> {
102
+ const engine = await createWasmEngineClient();
103
+ await engine.deployResources([{ name: "merge-loop.bpmn", content: MODEL, contentType: "text/xml" }]);
104
+ const responses: Record<string, Responder | null> = { ...DEFAULT_RESPONSES, ...(opts.responses ?? {}) };
105
+ for (const jobType of ALL_JOB_TYPES) {
106
+ const responder = jobType in responses ? responses[jobType] : undefined;
107
+ if (responder === null) continue; // park the token (e.g. to let the SLA timer fire)
108
+ const queue = Array.isArray(responder) ? [...responder] : null;
109
+ await engine.registerWorker(jobType, (job) => {
110
+ // The escalation `question`/`status` are job-LOCAL input mappings on
111
+ // `merge-esc-*` (fed to `pr.persist-escalation`), so they never surface as
112
+ // instance variables — capture them off the job the worker sees instead.
113
+ if (jobType === "pr.persist-escalation") {
114
+ lastEscalationByEngine.set(engine, (job as { variables?: Record<string, unknown> }).variables ?? {});
115
+ }
116
+ if (queue) return queue.length > 1 ? queue.shift()! : queue[0] ?? {};
117
+ if (typeof responder === "function") return responder(job as { variables: Record<string, unknown> });
118
+ return (responder as Output | undefined) ?? {};
119
+ });
120
+ }
121
+ await engine.createInstance({
122
+ processDefinitionId: "merge-loop",
123
+ awaitCompletion: false,
124
+ variables: { ...DEFAULT_VARS, ...(opts.vars ?? {}) },
125
+ });
126
+ return engine;
127
+ }
128
+
129
+ // Positive checks use the engine-testkit `assertThat*` DSL below. The DSL has no
130
+ // *negative* element matcher ("element X did NOT complete") and no *substring*
131
+ // variable matcher (`hasVariable` is deep-equal), so the two readers below cover
132
+ // exactly those gaps. They read via the **same canonical snapshot accessors the
133
+ // DSL uses internally** (`instance.js`): completions from the snapshot-global
134
+ // `elementStats` (`{ elementId, completed }`) and live vars from
135
+ // `instances[].variables`. Sound because every test runs one isolated instance
136
+ // per engine — the single-instance precondition the DSL's own aggregate read
137
+ // relies on.
138
+
139
+ /** Element ids completed by the single instance — mirrors the DSL's `completedElementIds`. */
140
+ function completedElementIds(engine: WasmEngineClient): Set<string> {
141
+ const snap = engine.snapshot() as { elementStats?: { elementId: string; completed: number }[] };
142
+ return new Set((snap.elementStats ?? []).filter((s) => s.completed > 0).map((s) => s.elementId));
143
+ }
144
+
145
+ /** The single instance's live variables — mirrors the DSL's `variablesOf`. */
146
+ function instanceVars(engine: WasmEngineClient): Record<string, unknown> {
147
+ const snap = engine.snapshot() as { instances?: { variables?: Record<string, unknown> }[] };
148
+ return snap.instances?.[0]?.variables ?? {};
149
+ }
150
+
151
+ /**
152
+ * The variables the `pr.persist-escalation` worker was last activated with, captured
153
+ * in `startMergeLoop`. The escalation `question`/`status` are job-local `zeebe:input`
154
+ * mappings on `merge-esc-*`, so they are only observable on the persist-escalation job
155
+ * — not as instance variables — this is the canonical read for the escalation payload.
156
+ */
157
+ const lastEscalationByEngine = new WeakMap<WasmEngineClient, Record<string, unknown>>();
158
+ function escalation(engine: WasmEngineClient): Record<string, unknown> {
159
+ return lastEscalationByEngine.get(engine) ?? {};
160
+ }
161
+
162
+ const engines: WasmEngineClient[] = [];
163
+
164
+ /**
165
+ * urban-testkit's `assertThat*` DSL reads through a booted-app port: instance
166
+ * matchers use `app.snapshot()`, user-task matchers use
167
+ * `app.engine.{search,open}UserTasks`. These tests drive the WASM engine
168
+ * directly (no full app boot), so expose the client as its own read-model app:
169
+ * `snapshot()` is native, and `.engine` self-references so the task reads land
170
+ * on the same client.
171
+ */
172
+ function asReadModelApp(engine: WasmEngineClient): WasmEngineClient {
173
+ (engine as unknown as { engine: WasmEngineClient }).engine = engine;
174
+ return engine;
175
+ }
176
+
177
+ async function boot(opts?: Parameters<typeof startMergeLoop>[0]): Promise<WasmEngineClient> {
178
+ const engine = asReadModelApp(await startMergeLoop(opts));
179
+ engines.push(engine);
180
+ return engine;
181
+ }
182
+
183
+ /** The key of the single open `wait-merge-answer` task, via the typed read model. */
184
+ async function mergeAnswerTaskKey(engine: WasmEngineClient): Promise<string> {
185
+ const tasks = await engine.searchUserTasks({});
186
+ const row = tasks.find((t) => t.elementId === "wait-merge-answer");
187
+ assert(row, "expected an open wait-merge-answer user task");
188
+ return row.userTaskKey;
189
+ }
190
+ after(async () => {
191
+ await Promise.all(engines.map((e) => e.close()));
192
+ });
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Happy paths
196
+ // ---------------------------------------------------------------------------
197
+
198
+ test("a ready PR merges and the instance completes via mark-merged", async () => {
199
+ const engine = await boot({ responses: { "pr.merge": { mergeStatus: "merged" } } });
200
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
201
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
202
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasNoIncident().hasCompletedElements("mark-merged");
203
+ });
204
+
205
+ test("a queued merge parks on the event gateway; the landed message marks it merged", async () => {
206
+ const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
207
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
208
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
209
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElements("wait-landed", "wait-evicted");
210
+ await engine.publishMessage({ name: "merge-landed", correlationKey: "pr-1" });
211
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasCompletedElements("mark-merged");
212
+ });
213
+
214
+ test("an evicted queued merge re-arms the poller rather than completing", async () => {
215
+ const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
216
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
217
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
218
+ await engine.publishMessage({ name: "merge-evicted", correlationKey: "pr-1" });
219
+ // back at the poller's wait, not merged
220
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
221
+ assert(!completedElementIds(engine).has("mark-merged"), "an evicted merge must not mark-merged");
222
+ });
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Transient-retry arm (mergeRetryArm, #334)
226
+ // ---------------------------------------------------------------------------
227
+
228
+ test("a transient retry re-arms the poller within budget and advances the retry counter only on retry", async () => {
229
+ const engine = await boot({ responses: { "pr.merge": [{ mergeStatus: "retry" }, { mergeStatus: "merged" }] } });
230
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
231
+ // 1st attempt: retry (base moved) → within budget → re-arm → 2nd attempt: merged
232
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
233
+ // the retry re-armed and re-polled; feed a second mergeable so the 2nd attempt runs
234
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
235
+ assert(!completedElementIds(engine).has("merge-esc-attempt"), "a within-budget retry must NOT escalate");
236
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
237
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted();
238
+ });
239
+
240
+ test("a retry that exhausts the budget escalates as a repeated race, not a generic refusal", async () => {
241
+ const engine = await boot({
242
+ responses: { "pr.merge": { mergeStatus: "retry" } },
243
+ vars: { mergeRetryMax: 0 }, // first retry → mergeRetryRound 1 > 0 → exhausted
244
+ });
245
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
246
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
247
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
248
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-attempt");
249
+ assertStringIncludes(String(escalation(engine).question ?? ""), "retry budget", "the retry escalation must read as a repeated race");
250
+ });
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // CI-fix sub-process (SP_cifix) — mergeCiReattempt (#134), #329
254
+ // ---------------------------------------------------------------------------
255
+
256
+ async function driveToCiFix(engine: WasmEngineClient): Promise<void> {
257
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
258
+ await engine.publishMessage({
259
+ name: "merge-ready",
260
+ correlationKey: "pr-1",
261
+ variables: { mergeState: "blocked", failingChecks: 1, failingChecksList: "build" },
262
+ });
263
+ }
264
+
265
+ test("a fixed CI verdict runs the agent, advances the fix counter, and re-arms the poller", async () => {
266
+ const engine = await boot({ responses: { "senior:fix-ci": { status: "fixed" } } });
267
+ await driveToCiFix(engine);
268
+ assertThatInstance(engine, byProcessId("merge-loop"))
269
+ .isActive()
270
+ .hasActiveElement("wait-mergeable")
271
+ .hasCompletedElements("fix-ci")
272
+ .hasVariable("ciFixRound", 1); // the fix counter advanced across the sub-process boundary
273
+ const done = completedElementIds(engine);
274
+ assert(!done.has("merge-esc-attempt") && !done.has("merge-esc-conflict"), "a fixed verdict must never escalate");
275
+ });
276
+
277
+ test("a reattempt CI verdict re-arms the poller and never pages a human (#134)", async () => {
278
+ const engine = await boot({ responses: { "senior:fix-ci": { status: "reattempt" } } });
279
+ await driveToCiFix(engine);
280
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
281
+ const done = completedElementIds(engine);
282
+ assert(!done.has("merge-esc-attempt") && !done.has("merge-esc-conflict"), "a reattempt must not escalate");
283
+ });
284
+
285
+ test("a no-verdict CI result reconciles from ground truth (re-arm), not escalation (#134)", async () => {
286
+ const engine = await boot({ responses: { "senior:fix-ci": { summary: "unclear" } } }); // no `status`
287
+ await driveToCiFix(engine);
288
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
289
+ assert(!completedElementIds(engine).has("merge-esc-attempt"), "a missing status must not escalate");
290
+ });
291
+
292
+ test("a blocked CI verdict with nothing pushed reconciles once before escalating", async () => {
293
+ const engine = await boot({ responses: { "senior:fix-ci": { status: "blocked", pushed: false } } });
294
+ await driveToCiFix(engine);
295
+ // ci-reconcile re-arms and re-checks mergeable; it does not escalate on the first block
296
+ assertThatInstance(engine, byProcessId("merge-loop"))
297
+ .isActive()
298
+ .hasActiveElement("wait-mergeable")
299
+ .hasCompletedElements("ci-reconcile")
300
+ .hasVariable("ciBlockedReconciled", true); // the one-shot reconcile is marked spent
301
+ assert(!completedElementIds(engine).has("merge-esc-attempt"), "the first block episode must reconcile, not page a human");
302
+ });
303
+
304
+ test("a blocked CI verdict that already pushed escalates with a could-not-fix question", async () => {
305
+ const engine = await boot({ responses: { "senior:fix-ci": { status: "blocked", pushed: true } } });
306
+ await driveToCiFix(engine);
307
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
308
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-attempt");
309
+ assertStringIncludes(String(escalation(engine).question ?? ""), "CI-fix agent could not", "the question must name the could-not-fix trigger");
310
+ });
311
+
312
+ test("a CI-fix that discovers a dependency records it and waits on the other PR", async () => {
313
+ const engine = await boot({ responses: { "senior:fix-ci": { status: "waiting-on-pr", dependsOn: "acme/app#2" } } });
314
+ await driveToCiFix(engine);
315
+ assertThatInstance(engine, byProcessId("merge-loop"))
316
+ .isActive()
317
+ .hasActiveElement("wait-deps")
318
+ .hasCompletedElements("record-merge-dep"); // a waiting-on-pr verdict records the dependency
319
+ });
320
+
321
+ test("CI-fix budget exhaustion escalates as not-mergeable without running the agent", async () => {
322
+ const engine = await boot({ responses: { "senior:fix-ci": { status: "fixed" } }, vars: { ciFixMax: 0 } });
323
+ await driveToCiFix(engine);
324
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
325
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-conflict");
326
+ assert(!completedElementIds(engine).has("fix-ci"), "budget exhaustion must not run the fix-ci agent");
327
+ });
328
+
329
+ test("the fix-ci agent SLA interrupts the sub-process and escalates", async () => {
330
+ const engine = await boot({ responses: { "senior:fix-ci": null } }); // park on the agent so the SLA fires
331
+ await driveToCiFix(engine);
332
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("fix-ci");
333
+ await engine.advanceTime(AGENT_SLA_MS + 1);
334
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
335
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-attempt");
336
+ assertStringIncludes(String(escalation(engine).question ?? ""), "time budget (SLA)", "the SLA escalation must name the SLA trigger");
337
+ });
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Rebase sub-process (SP_rebase) — mergeRebaseArm
341
+ // ---------------------------------------------------------------------------
342
+
343
+ async function driveToRebase(engine: WasmEngineClient): Promise<void> {
344
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
345
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "conflict" } });
346
+ }
347
+
348
+ test("a conflict runs the bounded rebase agent and a rebased result re-arms the poller", async () => {
349
+ const engine = await boot({ responses: { "senior:rebase": { status: "rebased" } } });
350
+ await driveToRebase(engine);
351
+ assertThatInstance(engine, byProcessId("merge-loop"))
352
+ .isActive()
353
+ .hasActiveElement("wait-mergeable")
354
+ .hasCompletedElements("rebase") // a conflict runs the rebase agent, not page a human
355
+ .hasVariable("rebaseRound", 1); // the rebase counter advanced across the sub-process boundary
356
+ });
357
+
358
+ test("a rebase that cannot resolve escalates with a conflict question", async () => {
359
+ const engine = await boot({ responses: { "senior:rebase": { status: "blocked" } } });
360
+ await driveToRebase(engine);
361
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
362
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-attempt");
363
+ assertStringIncludes(String(escalation(engine).question ?? ""), "rebase agent could not resolve", "the question must name the conflict trigger");
364
+ });
365
+
366
+ test("a no-verdict rebase result reconciles from ground truth, not escalation (#134)", async () => {
367
+ const engine = await boot({ responses: { "senior:rebase": { summary: "unclear" } } }); // no `status`
368
+ await driveToRebase(engine);
369
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
370
+ assert(!completedElementIds(engine).has("merge-esc-attempt"), "a missing rebase status must not escalate");
371
+ });
372
+
373
+ test("rebase budget exhaustion escalates as not-mergeable without running the agent", async () => {
374
+ const engine = await boot({ responses: { "senior:rebase": { status: "rebased" } }, vars: { rebaseMax: 0 } });
375
+ await driveToRebase(engine);
376
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
377
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-conflict");
378
+ assert(!completedElementIds(engine).has("rebase"), "budget exhaustion must not run the rebase agent");
379
+ });
380
+
381
+ test("the rebase agent SLA interrupts the sub-process and escalates as not-mergeable", async () => {
382
+ const engine = await boot({ responses: { "senior:rebase": null } });
383
+ await driveToRebase(engine);
384
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("rebase");
385
+ await engine.advanceTime(AGENT_SLA_MS + 1);
386
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
387
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-conflict");
388
+ });
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Escalation user task (mergeEscalationUserTask #256, mergeEscalationQuestion #329/#454)
392
+ // ---------------------------------------------------------------------------
393
+
394
+ test("an escalation parks on the native user task; answering it reconciles then re-arms the poller", async () => {
395
+ const engine = await boot({ responses: { "senior:rebase": { status: "blocked" } } });
396
+ await driveToRebase(engine);
397
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
398
+ await engine.completeUserTask(await mergeAnswerTaskKey(engine), { answer: "rebased manually, retry" });
399
+ assertThatInstance(engine, byProcessId("merge-loop"))
400
+ .isActive()
401
+ .hasActiveElement("wait-mergeable")
402
+ .hasCompletedElements("record-merge-answer"); // answering runs the pr.answer-escalation reconcile
403
+ });
404
+
405
+ test("a persist-escalation that opens nothing (escalated:false) re-enters the poller, not a dead user task", async () => {
406
+ const engine = await boot({
407
+ responses: { "senior:rebase": { status: "blocked" }, "pr.persist-escalation": { escalated: false } },
408
+ });
409
+ await driveToRebase(engine);
410
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
411
+ const openTasks = await engine.searchUserTasks({});
412
+ assert(
413
+ !openTasks.some((t) => t.elementId === "wait-merge-answer"),
414
+ "escalated:false must not park a user task",
415
+ );
416
+ });
417
+
418
+ test("a not-landable gate verdict gives a draft PR an actionable 'mark it ready' question (#454)", async () => {
419
+ const engine = await boot();
420
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
421
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "draft" } });
422
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
423
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-conflict");
424
+ assertStringIncludes(String(escalation(engine).question ?? ""), "draft", "a draft PR must get a mark-it-ready question");
425
+ });
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // Terminate semantics — the refactor kept MergeAbandoned at the root
429
+ // ---------------------------------------------------------------------------
430
+
431
+ test("an abandoned merge ends the whole instance via the root terminate, without merging", async () => {
432
+ const engine = await boot({ responses: { "pr.merge": { mergeStatus: "abandoned" } } });
433
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
434
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
435
+ // `MergeAbandoned` is a terminate end event at the ROOT scope: it ends all tokens, so the whole
436
+ // instance finishes (BPMN: a terminate end event *completes* the instance — `Completed`, not a
437
+ // cancellation `Terminated`). Had it lived inside `SP_cifix`/`SP_rebase`, the terminate would end
438
+ // only that sub-process scope and the outer poller would re-arm, leaving the instance ACTIVE —
439
+ // so `hasCompleted()` (nothing left running) is exactly what proves the terminate stayed at root.
440
+ const done = completedElementIds(engine);
441
+ assert(done.has("MergeAbandoned"), "the abandon terminate end event must fire");
442
+ assert(!done.has("mark-merged"), "an abandoned PR must not mark-merged");
443
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasCompletedElements("MergeAbandoned");
444
+ const open = await engine.searchUserTasks({});
445
+ assert(open.length === 0, "the root terminate must leave nothing running (no re-armed poller, no parked escalation)");
446
+ });