@nanobpm/nano-workforce 0.201.4 → 0.201.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.201.5](https://github.com/nanobpm/nano-workforce/compare/v0.201.4...v0.201.5) (2026-10-11)
2
+
3
+ ### Bug Fixes
4
+
5
+ * protect engine-owned process vars from agent-completion clobber (Closes [#887](https://github.com/nanobpm/nano-workforce/issues/887)) ([#888](https://github.com/nanobpm/nano-workforce/issues/888)) ([4245287](https://github.com/nanobpm/nano-workforce/commit/4245287e3d7553881f29bdda50cbd6d2b60b6268))
6
+
1
7
  ## [0.201.4](https://github.com/nanobpm/nano-workforce/compare/v0.201.3...v0.201.4) (2026-10-11)
2
8
 
3
9
  ### Bug Fixes
package/SPEC.md CHANGED
@@ -428,6 +428,79 @@ Notes:
428
428
  `answer`/`scopePending` (inputs the agent consumes) stay cleared on the
429
429
  review-round output.
430
430
 
431
+ - **Engine-owned process variables are restored around every external-agent task
432
+ (issue #887).** The engine merges **every** variable an external agent returns
433
+ into process scope **unfiltered** — the `io.nanobpm.dataEnvelope.out` shape is
434
+ codegen/typing-only and applies **no** runtime filtering or coercion. An agent
435
+ that echoes an engine-owned variable with a colliding name or drifted type (a
436
+ `senior:pr-review` job that returned `round: "4"` — a string) therefore
437
+ overwrites the real process value, and the next FEEL guard that reads it
438
+ (`round >= maxRounds` at `gw-guard`) raises `comparison not defined for string
439
+ and number` and wedges the loop on an unrecoverable incident. The categorical
440
+ guard is a **snapshot/restore** on each external-agent task (`review-round`,
441
+ `adversarial-review`, `classify-scope`): before the agent runs, each
442
+ engine-owned variable is copied into a task-**local** `guard<Name>` input
443
+ (`<zeebe:input source="=round" target="guardRound"/>` …), and the task's output
444
+ mapping **restores** the process variable from that snapshot
445
+ (`<zeebe:output source="=guardRound" target="round"/>`). Because the snapshot
446
+ holds the pre-merge value, the restore overwrites whatever the completion
447
+ merged in, so the agent's real outputs (`status`/`summary`/`question`,
448
+ `adversarialFindings`, the scope verdict) flow through but it can never alter
449
+ the protected set. The protected set is **every engine-owned variable the loop
450
+ relies on that the agent does not own as an output**: the control counters and
451
+ budgets (`round`, `maxRounds`, `advPass`, `advMax`, `ackRetryRound`,
452
+ `ackRetryMax`, `huskRetries`, `reviewWaitTimeout`), the `prKey` correlation
453
+ key, **and** the PR identity inputs (`repo`, `prNumber`, `prUrl`) — the last
454
+ three because the unfiltered merge lets an input-echo replace the identity that
455
+ `progress-check`, `converge-gate`, and `finalize` go on to read (a wrong
456
+ `prNumber` would make `finalize` start the merge loop for the wrong PR) — **and**
457
+ the gate controls `humanApproval` and `convergeOnly`. These last two are
458
+ decision flags the loop acts on after the agent returns: `humanApproval = true`
459
+ routes a converged PR through the `gw-approval` human gate instead of
460
+ auto-merging, and `convergeOnly = true` ends a review-only run without ever
461
+ handing the PR to the merge loop. Left unguarded, a converged completion that
462
+ echoed `humanApproval: false` would **bypass** the approval gate, and one that
463
+ echoed `convergeOnly: false` would hand a review-only PR to the merge loop.
464
+ `convergeOnly` is a plain seeded constant, so the usual `=guardConvergeOnly`
465
+ restore applies. `humanApproval` is the one engine-owned flag that is **also
466
+ legitimately mutated mid-flight** — a gated caller that adopts a live loop
467
+ raises it `false → true` via `engine.setVariables` (`submitPr` in
468
+ `app/service.ts`); it is **monotonic** (only ever rises). A plain
469
+ `=guardHumanApproval` restore would clobber that concurrent host-set true, so
470
+ the restore is the monotonic join
471
+ `=(guardHumanApproval = true) or (humanApproval = true)`: it recovers the
472
+ snapshot's `true`, honours a concurrent adopter's `true`, and ignores an agent
473
+ echo of `false`. The direction is **fail-safe** — the only state it can force
474
+ is *more* human review, never a merge the loop would otherwise have gated. The
475
+ protected set also covers the two **progress bookkeeping** values the loop
476
+ reads back after the agent returns: `reviewStale` (the converge-gate's
477
+ stale-body signal — `f_guardMax` escalates only when `round >= maxRounds and
478
+ reviewStale != true`, so a forged `reviewStale: true` would suppress the
479
+ max-round escalation) and `roundEntryHead` (the within-round baseline
480
+ `pr.capture-head` captures and `pr.progress-check` consumes — a forged head
481
+ would falsify the no-progress decision). Both are plain process-owned reads, so
482
+ the usual `=guardReviewStale` / `=guardRoundEntryHead` restore pins them. The
483
+ protected set further covers the two **seeded cooperative-abandon** reads:
484
+ `abandonUrl` (the capability `pr.persist-round`/`pr.persist-escalation`/
485
+ `pr.finalize` record for a human — a forged echo would redirect the abandon
486
+ token) and `abandonBrief` (which feeds the NEXT agent task's `appendPrompt`
487
+ input `=(if abandonBrief = null then "" else abandonBrief)` — a non-string echo
488
+ would carry a wrong/non-string brief into that later prompt). Both are plain
489
+ process-owned reads, pinned by the usual `=guardAbandonUrl` / `=guardAbandonBrief`
490
+ restore. The reserved **`io.nanobpm.agentTask`** repository envelope is
491
+ *deliberately not* in this list: it is a flat dotted framework key with no
492
+ FEEL-path access, so snapshotting it would need backtick-escaped FEEL
493
+ (`` =`io.nanobpm.agentTask` ``) that has no precedent on this engine and would,
494
+ if evaluated as the default dotted path, resolve to `null` and **null the
495
+ fleet-critical envelope on restore** — a strictly worse failure than the
496
+ hypothetical clobber (an agent would have to emit a result-JSON key literally
497
+ named `io.nanobpm.agentTask`). A counter the task itself advances is incremented
498
+ **from the snapshot** (`adversarial-review` computes `advPass` as
499
+ `(if guardAdvPass = null then 0
500
+ else guardAdvPass) + 1`), never from the mergeable process variable. Any new
501
+ external-agent task added to this loop must apply the same guard to every
502
+ engine-owned variable in scope.
503
+
431
504
 
432
505
  ## 5. Agent job contract (`senior:pr-review`)
433
506
 
@@ -817,6 +890,37 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
817
890
  `pr.persist-escalation` worker as the review stage (status `escalated`), answered
818
891
  via the native `wait-merge-answer` `userTask` (the same `completeUserTask` door as
819
892
  the review escalation, #256); answering re-arms and retries.
893
+ - **Engine-owned variable guard (issue #887)** — the merge-loop applies the same
894
+ snapshot/restore invariant as the convergence loop (§4) around its external-agent
895
+ tasks (`fix-ci`, `rebase`). Each guards the loop counters and budgets
896
+ (`ciFixRound`, `ciFixMax`, `rebaseRound`, `rebaseMax`, `mergeRetryRound`,
897
+ `mergeRetryMax`, `mergeStallRounds`, `mergeStallMax`), the `prKey` correlation
898
+ key, and the PR identity inputs (`repo`, `prNumber`, `prUrl`) — the last three
899
+ because `pr.merge` reads them directly for GitHub operations after the agent
900
+ re-arms the poller, so an input-echo that replaced them would merge the wrong
901
+ PR. It further guards the **lifecycle marker and timeout config** the loop
902
+ re-arms on every pass: `ciBlockedReconciled` (arm-merge seeds it `null`;
903
+ `ci-reconcile` sets it `true`; `gw-ci-blocked` runs the one-shot ground-truth
904
+ reconcile exactly once per episode while it is `!= true`) and the seeded timer
905
+ durations (`agentSlaTimeout`, `landedWaitTimeout`, `mergeableWaitTimeout`,
906
+ `mergeableRepollInterval`). Left unguarded, a `{status:"blocked", pushed:false,
907
+ ciBlockedReconciled:true}` completion would **skip** the mandatory first
908
+ reconcile and page a human immediately, and a clobbered timeout would silently
909
+ reshape a wait a later loop pass re-arms. The guard also pins the two values
910
+ `pr.merge` (attempt-merge) consumes directly after a successful fix/rebase
911
+ re-arms the poller: `round` (the converged round `ensurePr` records on the
912
+ merge audit row) and `abandonUrl` (the cooperative-abandon capability URL whose
913
+ token the merge worker extracts). A completion echoing either would corrupt the
914
+ recorded round or replace the abandon token, so `=guardRound` /
915
+ `=guardAbandonUrl` restores pin both. It likewise guards `abandonBrief`, which
916
+ feeds the fix-ci/rebase `appendPrompt` input (`=(if abandonBrief = null then ""
917
+ else abandonBrief)`) on the next re-arm — a non-string echo would carry a
918
+ non-string brief into that later prompt. (As in the convergence loop (§4), the
919
+ reserved dotted `io.nanobpm.agentTask` repository envelope is *not* guarded: it
920
+ has no FEEL-path access and a backtick-escaped restore, untested on this engine,
921
+ would risk nulling the envelope used for later agent provisioning.) The counter
922
+ the task advances is incremented from the snapshot (`ciFixRound`/`rebaseRound`
923
+ become `guard<Name> + 1`).
820
924
  - **Terminal** — `merged` (with `merged_at`), or `converged` when
821
925
  `NANO_PR_AUTO_MERGE=0` (review-only), or `abandoned` on cancel.
822
926
 
@@ -0,0 +1,464 @@
1
+ // Regression guard for issue #887 — an external-agent job's completion variables must not clobber
2
+ // engine-owned process state.
3
+ //
4
+ // The defect (merlin PI 351101, incident 363350 at `gw-guard`): a `senior:pr-review` job returned
5
+ // `round: "4"` (a STRING) alongside its result. The engine merges EVERY variable an external agent
6
+ // returns into process scope UNFILTERED (the `io.nanobpm.dataEnvelope.out` shape is typing-only — no
7
+ // runtime filtering/coercion), so the agent's string overwrote the engine-owned integer counter
8
+ // `round`. The next `gw-guard` evaluation (`round >= maxRounds`) then raised
9
+ // "comparison not defined for string and number"
10
+ // and wedged the loop on an unrecoverable incident.
11
+ //
12
+ // Failure CLASS: any external-agent task can corrupt engine-owned state (round, maxRounds, the
13
+ // adversarial/ack/husk counters, prKey…) by name collision or type drift. The categorical fix snapshots
14
+ // each engine-owned control variable into a task-LOCAL input before the agent runs and RESTORES it from
15
+ // that snapshot on the task's output mapping, so an agent completion can never alter it (the snapshot is
16
+ // the pre-merge value; an output `source="=round"` would read the already-clobbered merge — proven by the
17
+ // `app/agentCompletion*`/testkit notes). The agent's real outputs (status/summary/question for a review
18
+ // round, findings for the adversarial pass, the scope verdict) still flow through untouched.
19
+ //
20
+ // These deploy the committed convergence-loop into the real WASM engine and drive a token to `gw-guard`
21
+ // with an agent that echoes `round: "4"`; the fix means NO incident is raised and `round` stays the
22
+ // engine-managed integer.
23
+ import { after, test } from "node:test";
24
+ import { assert, assertEquals } from "#test-assert";
25
+ import { readFileSync } from "node:fs";
26
+ import { createWasmEngineClient, type WasmEngineClient } from "@nanobpm/urban-testkit";
27
+
28
+ const MODEL = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
29
+
30
+ type Output = Record<string, unknown>;
31
+ type Responder = Output | ((job: { variables: Record<string, unknown> }) => Output);
32
+
33
+ const ALL_JOB_TYPES = [
34
+ "senior:pr-review",
35
+ "senior:adversarial-review",
36
+ "pr.capture-head",
37
+ "pr.persist-round",
38
+ "pr.progress-check",
39
+ "pr.converge-gate",
40
+ "senior:scope-classify",
41
+ "pr.finalize",
42
+ "pr.persist-escalation",
43
+ "pr.answer-escalation",
44
+ ] as const;
45
+
46
+ const DEFAULT_RESPONSES: Record<string, Responder> = {
47
+ "senior:pr-review": { status: "addressed", summary: "fixed" },
48
+ "senior:adversarial-review": { adversarialFindings: "", adversarialSummary: "clean" },
49
+ "pr.capture-head": { roundEntryHead: "sha-entry" },
50
+ "pr.persist-round": {},
51
+ "pr.progress-check": { progressed: true },
52
+ "pr.converge-gate": { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false },
53
+ "senior:scope-classify": { scopeBlocked: false, scopeBlockReason: "" },
54
+ "pr.finalize": {},
55
+ "pr.persist-escalation": { escalated: true },
56
+ "pr.answer-escalation": {},
57
+ };
58
+
59
+ const DEFAULT_VARS: Record<string, unknown> = {
60
+ prKey: "o/r#1",
61
+ repo: "o/r",
62
+ prNumber: 1,
63
+ prUrl: "https://example.test/pr/1",
64
+ round: 1,
65
+ maxRounds: 20,
66
+ reviewWaitTimeout: "PT30M",
67
+ ackRetryRound: 0,
68
+ ackRetryMax: 2,
69
+ abandonUrl: "https://hooks.test/abandon?token=seed",
70
+ abandonBrief: "\n\n---\nAbort if cancelled: curl …",
71
+ status: null,
72
+ question: null,
73
+ summary: null,
74
+ answer: null,
75
+ scopePending: false,
76
+ scopeAnswer: null,
77
+ convergeBlocked: null,
78
+ convergeBlockReason: null,
79
+ convergeAckOnly: null,
80
+ reviewStale: null,
81
+ roundEntryHead: null,
82
+ huskRetries: 0,
83
+ progressed: true,
84
+ escalated: null,
85
+ humanApproval: false,
86
+ convergeOnly: false,
87
+ };
88
+
89
+ const engines: WasmEngineClient[] = [];
90
+ after(async () => {
91
+ await Promise.all(engines.map((e) => e.close()));
92
+ });
93
+
94
+ async function boot(opts: { responses?: Record<string, Responder>; vars?: Record<string, unknown> } = {}) {
95
+ const engine = await createWasmEngineClient();
96
+ engines.push(engine);
97
+ await engine.deployResources([{ name: "convergence-loop.bpmn", content: MODEL, contentType: "text/xml" }]);
98
+ const responses = { ...DEFAULT_RESPONSES, ...(opts.responses ?? {}) };
99
+ for (const jobType of ALL_JOB_TYPES) {
100
+ const responder = responses[jobType];
101
+ await engine.registerWorker(jobType, (job) =>
102
+ typeof responder === "function" ? responder(job as { variables: Record<string, unknown> }) : (responder ?? {}),
103
+ );
104
+ }
105
+ const created = await engine.createInstance({
106
+ processDefinitionId: "convergence-loop",
107
+ awaitCompletion: false,
108
+ variables: { ...DEFAULT_VARS, ...(opts.vars ?? {}) },
109
+ });
110
+ return { engine, processInstanceKey: created.processInstanceKey };
111
+ }
112
+
113
+ function instanceVars(engine: WasmEngineClient): Record<string, unknown> {
114
+ const snap = engine.snapshot() as { instances?: { variables?: Record<string, unknown> }[] };
115
+ return snap.instances?.[0]?.variables ?? {};
116
+ }
117
+
118
+ /** Read one variable's deserialized value via the engine read channel — works on a COMPLETED instance. */
119
+ async function variableValue(engine: WasmEngineClient, processInstanceKey: string, name: string): Promise<unknown> {
120
+ const rows = await engine.searchVariables({ processInstanceKey, name });
121
+ const row = rows.find((v) => v.name === name);
122
+ assert(row, `expected variable ${name} to be present`);
123
+ return JSON.parse(row.value);
124
+ }
125
+
126
+ test("a review-round agent echoing round:\"4\" does not clobber the engine-owned counter or wedge gw-guard", async () => {
127
+ const { engine } = await boot({
128
+ responses: { "senior:pr-review": { status: "addressed", summary: "fixed", round: "4" } },
129
+ });
130
+ // gw-guard evaluates `round >= maxRounds`. If the agent's string "4" clobbered `round`, the engine
131
+ // raises "comparison not defined for string and number" and parks the token on an open incident.
132
+ const incidents = await engine.searchIncidents();
133
+ assertEquals(incidents.length, 0, `no incident must be raised; got: ${JSON.stringify(incidents)}`);
134
+ // The engine-owned counter must remain the integer it was — untouched by the agent's echo.
135
+ assertEquals(instanceVars(engine).round, 1, "engine-owned `round` must stay the integer 1, unclobbered");
136
+ });
137
+
138
+ test("a review-round agent echoing numeric engine counters cannot alter them", async () => {
139
+ // A well-formed numeric echo must still be ignored — the agent owns NONE of these control variables.
140
+ const { engine } = await boot({
141
+ responses: {
142
+ "senior:pr-review": { status: "addressed", summary: "fixed", round: 99, maxRounds: 1, prKey: "evil/repo#999" },
143
+ },
144
+ });
145
+ const incidents = await engine.searchIncidents();
146
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
147
+ const vars = instanceVars(engine);
148
+ assertEquals(vars.round, 1, "round must be unchanged");
149
+ assertEquals(vars.maxRounds, 20, "maxRounds must be unchanged");
150
+ assertEquals(vars.prKey, "o/r#1", "prKey correlation key must be unchanged");
151
+ });
152
+
153
+ test("a review-round agent echoing PR identity inputs cannot alter them", async () => {
154
+ // `repo`/`prNumber`/`prUrl` are the agent's declared *inputs* (PrReviewRoundIn), not its outputs —
155
+ // but the engine merges a completion UNFILTERED, so an input-echo would replace the valid process
156
+ // identity that `progress-check`, `converge-gate`, and `finalize` go on to read. The snapshot/restore
157
+ // guard must pin them.
158
+ const { engine } = await boot({
159
+ responses: {
160
+ "senior:pr-review": {
161
+ status: "addressed",
162
+ summary: "fixed",
163
+ repo: "evil/repo",
164
+ prNumber: 999,
165
+ prUrl: "https://evil.test/pr/999",
166
+ },
167
+ },
168
+ });
169
+ const incidents = await engine.searchIncidents();
170
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
171
+ const vars = instanceVars(engine);
172
+ assertEquals(vars.repo, "o/r", "repo must be unchanged");
173
+ assertEquals(vars.prNumber, 1, "prNumber must be unchanged");
174
+ assertEquals(vars.prUrl, "https://example.test/pr/1", "prUrl must be unchanged");
175
+ });
176
+
177
+ // The adversarial reviewer (`senior:adversarial-review`) and scope classifier
178
+ // (`senior:scope-classify`) are hardened by the SAME snapshot/restore mappings as `review-round`;
179
+ // these drive each of those tasks with an engine-owned-field echo so removing either task's guard
180
+ // turns this suite red (the `review-round` regressions above would stay green).
181
+
182
+ test("an adversarial-review agent echoing engine counters cannot alter them", async () => {
183
+ // advMax: 1 makes the adversarial pass due on round 1. The agent echoes `advPass`/`advMax` (and a
184
+ // counter) as the WRONG type; the guard must keep the engine's own increment (0 → 1) and budget.
185
+ const { engine } = await boot({
186
+ vars: { advMax: 1 },
187
+ responses: {
188
+ "senior:adversarial-review": { adversarialFindings: "", adversarialSummary: "clean", advPass: "7", advMax: "0", round: "9" },
189
+ },
190
+ });
191
+ const incidents = await engine.searchIncidents();
192
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
193
+ const vars = instanceVars(engine);
194
+ assertEquals(vars.advPass, 1, "advPass must be the engine increment (0 → 1), not the agent's \"7\"");
195
+ assertEquals(vars.advMax, 1, "advMax budget ceiling must be unchanged");
196
+ assertEquals(vars.round, 1, "round must be unchanged");
197
+ });
198
+
199
+ test("an adversarial-review agent echoing PR identity inputs cannot alter them", async () => {
200
+ const { engine } = await boot({
201
+ vars: { advMax: 1 },
202
+ responses: {
203
+ "senior:adversarial-review": {
204
+ adversarialFindings: "",
205
+ adversarialSummary: "clean",
206
+ repo: "evil/repo",
207
+ prNumber: 999,
208
+ prUrl: "https://evil.test/pr/999",
209
+ },
210
+ },
211
+ });
212
+ const incidents = await engine.searchIncidents();
213
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
214
+ const vars = instanceVars(engine);
215
+ assertEquals(vars.repo, "o/r", "repo must be unchanged");
216
+ assertEquals(vars.prNumber, 1, "prNumber must be unchanged");
217
+ assertEquals(vars.prUrl, "https://example.test/pr/1", "prUrl must be unchanged");
218
+ });
219
+
220
+ test("a scope-classify agent echoing engine-owned fields cannot alter them", async () => {
221
+ // Drive to the scope classifier via a `converged` review. The classifier echoes prKey + the PR
222
+ // identity inputs; the guard must keep them. The instance runs to completion, so read the
223
+ // variables back through the read channel (a completed instance's `snapshot().variables` is folded).
224
+ const { engine, processInstanceKey } = await boot({
225
+ responses: {
226
+ "senior:pr-review": { status: "converged", summary: "done" },
227
+ "senior:scope-classify": {
228
+ scopeBlocked: false,
229
+ scopeBlockReason: "",
230
+ prKey: "evil/repo#999",
231
+ repo: "evil/repo",
232
+ prNumber: 999,
233
+ prUrl: "https://evil.test/pr/999",
234
+ },
235
+ },
236
+ });
237
+ const incidents = await engine.searchIncidents();
238
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
239
+ assertEquals(await variableValue(engine, processInstanceKey, "prKey"), "o/r#1", "prKey correlation key must be unchanged");
240
+ assertEquals(await variableValue(engine, processInstanceKey, "repo"), "o/r", "repo must be unchanged");
241
+ assertEquals(await variableValue(engine, processInstanceKey, "prNumber"), 1, "prNumber must be unchanged");
242
+ assertEquals(await variableValue(engine, processInstanceKey, "prUrl"), "https://example.test/pr/1", "prUrl must be unchanged");
243
+ });
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Issue #887 (round 3): the seeded merge-control gates `humanApproval` and
247
+ // `convergeOnly` are ALSO engine-owned and must survive an agent completion.
248
+ // A converged review that echoes `humanApproval:false` would bypass `gw-approval`
249
+ // (skip the required human merge approval), and `convergeOnly:false` would hand a
250
+ // review-only PR to the merge loop. The snapshot/restore guard pins both.
251
+ //
252
+ // `humanApproval` is MONOTONIC (false→true only — the host flips it true via
253
+ // `engine.setVariables` when a gated caller adopts a live loop, app/service.ts).
254
+ // So its restore is `(guardHumanApproval = true) or (humanApproval = true)`: it
255
+ // recovers a snapshot-true against an agent's false AND preserves a concurrent
256
+ // host-set true, but never lets an agent force it back to false.
257
+ // ---------------------------------------------------------------------------
258
+
259
+ test("a review-round agent echoing humanApproval:false cannot bypass the approval gate", async () => {
260
+ // Seeded gated (humanApproval:true). The agent echoes false; the monotonic restore must keep it true.
261
+ const { engine } = await boot({
262
+ vars: { humanApproval: true },
263
+ responses: { "senior:pr-review": { status: "addressed", summary: "fixed", humanApproval: false } },
264
+ });
265
+ const incidents = await engine.searchIncidents();
266
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
267
+ assertEquals(instanceVars(engine).humanApproval, true, "seeded humanApproval:true must survive the agent's false echo");
268
+ });
269
+
270
+ test("a review-round agent echoing convergeOnly:false cannot re-target a review-only run at the merge loop", async () => {
271
+ // Seeded review-only (convergeOnly:true). The agent echoes false; the restore must keep it true.
272
+ const { engine } = await boot({
273
+ vars: { convergeOnly: true },
274
+ responses: { "senior:pr-review": { status: "addressed", summary: "fixed", convergeOnly: false } },
275
+ });
276
+ const incidents = await engine.searchIncidents();
277
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
278
+ assertEquals(instanceVars(engine).convergeOnly, true, "seeded convergeOnly:true must survive the agent's false echo");
279
+ });
280
+
281
+ test("an adversarial-review agent echoing humanApproval/convergeOnly cannot alter the seeded gates", async () => {
282
+ const { engine } = await boot({
283
+ vars: { advMax: 1, humanApproval: true, convergeOnly: true },
284
+ responses: {
285
+ "senior:adversarial-review": {
286
+ adversarialFindings: "",
287
+ adversarialSummary: "clean",
288
+ humanApproval: false,
289
+ convergeOnly: false,
290
+ },
291
+ },
292
+ });
293
+ const incidents = await engine.searchIncidents();
294
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
295
+ const vars = instanceVars(engine);
296
+ assertEquals(vars.humanApproval, true, "humanApproval gate must be unchanged");
297
+ assertEquals(vars.convergeOnly, true, "convergeOnly flag must be unchanged");
298
+ });
299
+
300
+ test("a scope-classify agent echoing humanApproval/convergeOnly cannot alter the seeded gates", async () => {
301
+ // Drive to the scope classifier via a `converged` review with the gates seeded true; the classifier
302
+ // echoes them false. With humanApproval:true the instance then parks at the merge-approval user task
303
+ // (still ACTIVE), so read the gates back through the variables channel after classify-scope ran.
304
+ const { engine, processInstanceKey } = await boot({
305
+ vars: { humanApproval: true, convergeOnly: true },
306
+ responses: {
307
+ "senior:pr-review": { status: "converged", summary: "done" },
308
+ "senior:scope-classify": {
309
+ scopeBlocked: false,
310
+ scopeBlockReason: "",
311
+ humanApproval: false,
312
+ convergeOnly: false,
313
+ },
314
+ },
315
+ });
316
+ const incidents = await engine.searchIncidents();
317
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
318
+ assertEquals(await variableValue(engine, processInstanceKey, "humanApproval"), true, "humanApproval gate must be unchanged");
319
+ assertEquals(await variableValue(engine, processInstanceKey, "convergeOnly"), true, "convergeOnly flag must be unchanged");
320
+ });
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // Issue #887 (round 4 — Copilot "previously missed"): `reviewStale` and
324
+ // `roundEntryHead` are ALSO process-owned and readable by downstream decisions,
325
+ // so an agent completion must not overwrite them:
326
+ // * `f_guardMax` escalates only when `round >= maxRounds and reviewStale != true`
327
+ // — a forged `reviewStale:true` suppresses the max-round escalation.
328
+ // * `check-progress` (pr.progress-check) consumes `roundEntryHead` as the
329
+ // within-round baseline — a forged head falsifies the progress decision.
330
+ // The snapshot/restore guard pins both on all three agent tasks.
331
+ // ---------------------------------------------------------------------------
332
+
333
+ test("a review-round agent echoing reviewStale:true cannot suppress the max-round escalation", async () => {
334
+ // Seeded at the max-round boundary with reviewStale false. The agent echoes reviewStale:true;
335
+ // without the guard, gw-guard's `reviewStale != true` arm would go false and suppress escalation.
336
+ const { engine } = await boot({
337
+ vars: { round: 20, maxRounds: 20, reviewStale: false },
338
+ responses: { "senior:pr-review": { status: "addressed", summary: "fixed", reviewStale: true } },
339
+ });
340
+ const incidents = await engine.searchIncidents();
341
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
342
+ assertEquals(instanceVars(engine).reviewStale, false, "seeded reviewStale:false must survive the agent's true echo");
343
+ });
344
+
345
+ test("a review-round agent echoing a forged roundEntryHead cannot falsify the progress baseline", async () => {
346
+ // capture-head seeds roundEntryHead="sha-entry"; the agent echoes a forged head. The restore must keep it.
347
+ const { engine } = await boot({
348
+ responses: { "senior:pr-review": { status: "addressed", summary: "fixed", roundEntryHead: "sha-forged" } },
349
+ });
350
+ const incidents = await engine.searchIncidents();
351
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
352
+ assertEquals(instanceVars(engine).roundEntryHead, "sha-entry", "captured roundEntryHead must survive the agent's forged echo");
353
+ });
354
+
355
+ test("an adversarial-review agent echoing reviewStale/roundEntryHead cannot alter them", async () => {
356
+ const { engine } = await boot({
357
+ vars: { advMax: 1, reviewStale: false },
358
+ responses: {
359
+ "senior:adversarial-review": {
360
+ adversarialFindings: "",
361
+ adversarialSummary: "clean",
362
+ reviewStale: true,
363
+ roundEntryHead: "sha-forged",
364
+ },
365
+ },
366
+ });
367
+ const incidents = await engine.searchIncidents();
368
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
369
+ const vars = instanceVars(engine);
370
+ assertEquals(vars.reviewStale, false, "reviewStale must be unchanged");
371
+ assertEquals(vars.roundEntryHead, "sha-entry", "roundEntryHead must be unchanged");
372
+ });
373
+
374
+ test("a scope-classify agent echoing reviewStale/roundEntryHead cannot alter them", async () => {
375
+ // Drive to the scope classifier via a `converged` review; the classifier echoes both. With
376
+ // humanApproval:false the instance runs to completion, so read the vars back via the channel.
377
+ const { engine, processInstanceKey } = await boot({
378
+ vars: { reviewStale: false },
379
+ responses: {
380
+ "senior:pr-review": { status: "converged", summary: "done" },
381
+ "senior:scope-classify": {
382
+ scopeBlocked: false,
383
+ scopeBlockReason: "",
384
+ reviewStale: true,
385
+ roundEntryHead: "sha-forged",
386
+ },
387
+ },
388
+ });
389
+ const incidents = await engine.searchIncidents();
390
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
391
+ assertEquals(await variableValue(engine, processInstanceKey, "reviewStale"), false, "reviewStale must be unchanged");
392
+ assertEquals(await variableValue(engine, processInstanceKey, "roundEntryHead"), "sha-entry", "roundEntryHead must be unchanged");
393
+ });
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Issue #887 (round 5 — Copilot "previously missed"): `abandonUrl` and
397
+ // `abandonBrief` are ALSO seeded engine-owned state readable by downstream
398
+ // persistence and prompt mappings:
399
+ // * `abandonUrl` is the cooperative-abandon capability recorded by
400
+ // `pr.persist-round`/`pr.persist-escalation`/`pr.finalize` (EscalationIn.abandonUrl,
401
+ // PrPersistRoundIn.abandonUrl, PrFinalizeIn.abandonUrl) — a forged echo
402
+ // redirects the abandon token persisted for a human.
403
+ // * `abandonBrief` feeds the `appendPrompt` input of the NEXT agent task
404
+ // (`=(if (abandonBrief = null) then "" else abandonBrief)`) — a non-string
405
+ // echo makes that later prompt mapping carry a non-string / wrong brief.
406
+ // The snapshot/restore guard pins both on all three agent tasks. (The reserved
407
+ // dotted `io.nanobpm.agentTask` envelope was DECLINED — it has no FEEL-path
408
+ // access and a backtick-escaped restore, untested on this engine, would null the
409
+ // fleet-critical repository envelope; see the PR discussion.)
410
+
411
+ test("a review-round agent echoing abandonUrl/abandonBrief cannot clobber the seeded capability or brief", async () => {
412
+ const { engine } = await boot({
413
+ responses: {
414
+ "senior:pr-review": {
415
+ status: "addressed",
416
+ summary: "fixed",
417
+ abandonUrl: "https://evil.test/abandon?token=forged",
418
+ abandonBrief: 42,
419
+ },
420
+ },
421
+ });
422
+ const incidents = await engine.searchIncidents();
423
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
424
+ const vars = instanceVars(engine);
425
+ assertEquals(vars.abandonUrl, "https://hooks.test/abandon?token=seed", "seeded abandonUrl must survive the agent's forged echo");
426
+ assertEquals(vars.abandonBrief, "\n\n---\nAbort if cancelled: curl …", "seeded abandonBrief must survive the agent's non-string echo");
427
+ });
428
+
429
+ test("an adversarial-review agent echoing abandonUrl/abandonBrief cannot alter them", async () => {
430
+ const { engine } = await boot({
431
+ vars: { advMax: 1 },
432
+ responses: {
433
+ "senior:adversarial-review": {
434
+ adversarialFindings: "",
435
+ adversarialSummary: "clean",
436
+ abandonUrl: "https://evil.test/abandon?token=forged",
437
+ abandonBrief: 42,
438
+ },
439
+ },
440
+ });
441
+ const incidents = await engine.searchIncidents();
442
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
443
+ const vars = instanceVars(engine);
444
+ assertEquals(vars.abandonUrl, "https://hooks.test/abandon?token=seed", "abandonUrl must be unchanged");
445
+ assertEquals(vars.abandonBrief, "\n\n---\nAbort if cancelled: curl …", "abandonBrief must be unchanged");
446
+ });
447
+
448
+ test("a scope-classify agent echoing abandonUrl/abandonBrief cannot alter them", async () => {
449
+ const { engine, processInstanceKey } = await boot({
450
+ responses: {
451
+ "senior:pr-review": { status: "converged", summary: "done" },
452
+ "senior:scope-classify": {
453
+ scopeBlocked: false,
454
+ scopeBlockReason: "",
455
+ abandonUrl: "https://evil.test/abandon?token=forged",
456
+ abandonBrief: 42,
457
+ },
458
+ },
459
+ });
460
+ const incidents = await engine.searchIncidents();
461
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
462
+ assertEquals(await variableValue(engine, processInstanceKey, "abandonUrl"), "https://hooks.test/abandon?token=seed", "abandonUrl must be unchanged");
463
+ assertEquals(await variableValue(engine, processInstanceKey, "abandonBrief"), "\n\n---\nAbort if cancelled: curl …", "abandonBrief must be unchanged");
464
+ });
@@ -89,13 +89,15 @@ const DEFAULT_VARS: Record<string, unknown> = {
89
89
  mergeableRepollInterval: "PT2M",
90
90
  mergeStallRounds: 0,
91
91
  mergeStallMax: 3,
92
- abandonBrief: null,
92
+ abandonBrief: "\n\n---\nAbort if cancelled: curl …",
93
93
  failingChecksList: null,
94
94
  status: null,
95
95
  mergeState: null,
96
96
  mergeStatus: null,
97
97
  ciBlockedReconciled: null,
98
98
  failingChecks: 0,
99
+ round: 2,
100
+ abandonUrl: "http://host/app/api/hooks/abandon?token=tok-seeded",
99
101
  };
100
102
 
101
103
  /**
@@ -303,6 +305,86 @@ test("a CONFLICTING PR whose poller never signalled reaches the rebase arm via t
303
305
  .hasVariable("rebaseRound", 1);
304
306
  });
305
307
 
308
+ test("a rebase agent echoing engine-owned timeout state cannot clobber it (#887)", async () => {
309
+ // Same guard class as fix-ci: the rebase agent must not reshape the seeded timers that later loop
310
+ // passes re-arm, nor forge `ciBlockedReconciled`. Drive to the rebase arm via the timeout path with
311
+ // the agent echoing a forged marker and tiny timeouts; the restore must pin the timers.
312
+ const engine = await boot({
313
+ responses: {
314
+ "pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "" },
315
+ "senior:rebase": {
316
+ status: "rebased",
317
+ ciBlockedReconciled: true,
318
+ agentSlaTimeout: "PT1S",
319
+ landedWaitTimeout: "PT1S",
320
+ mergeableWaitTimeout: "PT1S",
321
+ mergeableRepollInterval: "PT1S",
322
+ },
323
+ },
324
+ });
325
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
326
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
327
+ assertThatInstance(engine, byProcessId("merge-loop"))
328
+ .isActive()
329
+ .hasCompletedElements("rebase")
330
+ .hasVariable("agentSlaTimeout", "PT30M")
331
+ .hasVariable("landedWaitTimeout", "PT30M")
332
+ .hasVariable("mergeableWaitTimeout", "PT30M")
333
+ .hasVariable("mergeableRepollInterval", "PT2M");
334
+ // The forged lifecycle marker was pinned out too (the rebase path never reconciles).
335
+ assert(instanceVars(engine).ciBlockedReconciled !== true, "a rebase agent must not forge ciBlockedReconciled:true");
336
+ });
337
+
338
+ test("a rebase agent echoing round/abandonUrl cannot corrupt the converged round or the abandon token (#887)", async () => {
339
+ // Same guard class as fix-ci: `round`/`abandonUrl` are seeded once and consumed by `pr.merge`
340
+ // after a successful rebase re-arms the poller. The restore must pin both against an agent echo.
341
+ const engine = await boot({
342
+ responses: {
343
+ "pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "" },
344
+ "senior:rebase": { status: "rebased", round: 99, abandonUrl: "http://evil.test/abandon?token=forged" },
345
+ },
346
+ });
347
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
348
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
349
+ assertThatInstance(engine, byProcessId("merge-loop"))
350
+ .isActive()
351
+ .hasCompletedElements("rebase")
352
+ .hasVariable("round", 2)
353
+ .hasVariable("abandonUrl", "http://host/app/api/hooks/abandon?token=tok-seeded");
354
+ });
355
+
356
+ test("a rebase agent echoing abandonBrief cannot corrupt the seeded abort brief (#887 round 5)", async () => {
357
+ // `abandonBrief` feeds the fix-ci/rebase `appendPrompt` input on the NEXT re-arm; a non-string
358
+ // echo would make that later prompt mapping carry a non-string brief. The restore must pin it.
359
+ const engine = await boot({
360
+ responses: {
361
+ "pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "" },
362
+ "senior:rebase": { status: "rebased", abandonBrief: 42 },
363
+ },
364
+ });
365
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
366
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
367
+ assertThatInstance(engine, byProcessId("merge-loop"))
368
+ .isActive()
369
+ .hasCompletedElements("rebase")
370
+ .hasVariable("abandonBrief", "\n\n---\nAbort if cancelled: curl …");
371
+ });
372
+
373
+ test("a fix-ci agent echoing abandonBrief cannot corrupt the seeded abort brief (#887 round 5)", async () => {
374
+ const engine = await boot({
375
+ responses: {
376
+ "pr.merge-stall-probe": { mergeState: "blocked", failingChecks: 1, failingChecksList: "build" },
377
+ "senior:fix-ci": { status: "fixed", abandonBrief: 42 },
378
+ },
379
+ });
380
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
381
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
382
+ assertThatInstance(engine, byProcessId("merge-loop"))
383
+ .isActive()
384
+ .hasCompletedElements("merge-stall-probe", "fix-ci")
385
+ .hasVariable("abandonBrief", "\n\n---\nAbort if cancelled: curl …");
386
+ });
387
+
306
388
  test("a blocked PR probed on timeout reaches the CI-fix arm via the timeout path (#636)", async () => {
307
389
  const engine = await boot({
308
390
  responses: {
@@ -529,6 +611,67 @@ test("a blocked CI verdict with nothing pushed reconciles once before escalating
529
611
  assert(!completedElementIds(engine).has("merge-esc-attempt"), "the first block episode must reconcile, not page a human");
530
612
  });
531
613
 
614
+ test("a fix-ci agent echoing ciBlockedReconciled:true cannot skip the one-shot ground-truth reconcile (#887)", async () => {
615
+ // `ciBlockedReconciled` is an engine-owned lifecycle marker (arm-merge seeds it null; ci-reconcile
616
+ // sets it true). gw-ci-blocked reconciles exactly once per episode while it is != true. If the agent's
617
+ // completion could echo `ciBlockedReconciled:true`, a `{status:"blocked", pushed:false}` verdict would
618
+ // skip the mandatory first reconcile and page a human immediately. The snapshot/restore guard pins it,
619
+ // so the engine still reconciles once.
620
+ const engine = await boot({
621
+ responses: { "senior:fix-ci": { status: "blocked", pushed: false, ciBlockedReconciled: true } },
622
+ });
623
+ await driveToCiFix(engine);
624
+ assertThatInstance(engine, byProcessId("merge-loop"))
625
+ .isActive()
626
+ .hasActiveElement("wait-mergeable")
627
+ .hasCompletedElements("ci-reconcile") // the reconcile STILL runs — the agent's echo was pinned out
628
+ .hasVariable("ciBlockedReconciled", true);
629
+ assert(!completedElementIds(engine).has("merge-esc-attempt"), "an agent-forged reconciled flag must not skip the first reconcile");
630
+ });
631
+
632
+ test("a fix-ci agent echoing seeded timeouts cannot clobber the re-armed merge/SLA timers (#887)", async () => {
633
+ // The seeded timeout/interval config (agentSlaTimeout, landedWaitTimeout, mergeableWaitTimeout,
634
+ // mergeableRepollInterval) drives timers re-armed on every loop pass. A `reattempt` re-arms the
635
+ // merge wait, so a clobbered timeout would silently reshape the next wait. The guard restores them.
636
+ const engine = await boot({
637
+ responses: {
638
+ "senior:fix-ci": {
639
+ status: "reattempt",
640
+ agentSlaTimeout: "PT1S",
641
+ landedWaitTimeout: "PT1S",
642
+ mergeableWaitTimeout: "PT1S",
643
+ mergeableRepollInterval: "PT1S",
644
+ },
645
+ },
646
+ });
647
+ await driveToCiFix(engine);
648
+ assertThatInstance(engine, byProcessId("merge-loop"))
649
+ .isActive()
650
+ .hasActiveElement("wait-mergeable")
651
+ .hasVariable("agentSlaTimeout", "PT30M")
652
+ .hasVariable("landedWaitTimeout", "PT30M")
653
+ .hasVariable("mergeableWaitTimeout", "PT30M")
654
+ .hasVariable("mergeableRepollInterval", "PT2M");
655
+ });
656
+
657
+ test("a fix-ci agent echoing round/abandonUrl cannot corrupt the converged round or the abandon token (#887)", async () => {
658
+ // `round` (the converged round recorded by ensurePr) and `abandonUrl` (the cooperative-abandon
659
+ // capability URL) are seeded once and consumed directly by `pr.merge` (attempt-merge). A fix-ci
660
+ // completion echoing either would corrupt the recorded round or replace the abandon token. The
661
+ // snapshot/restore guard pins both.
662
+ const engine = await boot({
663
+ responses: {
664
+ "senior:fix-ci": { status: "reattempt", round: 99, abandonUrl: "http://evil.test/abandon?token=forged" },
665
+ },
666
+ });
667
+ await driveToCiFix(engine);
668
+ assertThatInstance(engine, byProcessId("merge-loop"))
669
+ .isActive()
670
+ .hasActiveElement("wait-mergeable")
671
+ .hasVariable("round", 2)
672
+ .hasVariable("abandonUrl", "http://host/app/api/hooks/abandon?token=tok-seeded");
673
+ });
674
+
532
675
  test("a blocked CI verdict that already pushed escalates with a could-not-fix question", async () => {
533
676
  const engine = await boot({ responses: { "senior:fix-ci": { status: "blocked", pushed: true } } });
534
677
  await driveToCiFix(engine);
@@ -602,6 +745,70 @@ test("a no-verdict rebase result reconciles from ground truth, not escalation (#
602
745
  assert(!completedElementIds(engine).has("merge-esc-attempt"), "a missing rebase status must not escalate");
603
746
  });
604
747
 
748
+ // ---------------------------------------------------------------------------
749
+ // #887 — an external-agent completion must not clobber engine-owned counters
750
+ // ---------------------------------------------------------------------------
751
+ // The engine merges EVERY variable an external agent returns into process scope unfiltered, so a
752
+ // `senior:fix-ci`/`senior:rebase` agent echoing a loop counter (`ciFixRound`, `rebaseRound`, …) with
753
+ // the wrong type/value would corrupt it — and because the counter feeds the task's own `+ 1`
754
+ // increment and the `ciFixRound < ciFixMax` / `rebaseRound < rebaseMax` guards, a string echo wedges
755
+ // the sub-process on a "comparison/arithmetic not defined for string and number" incident. The fix
756
+ // snapshots each engine-owned counter into a task-local input before the agent runs and restores it
757
+ // on the task output (driving the increment from the snapshot), so the agent can never alter it.
758
+
759
+ test("a fix-ci agent echoing ciFixRound:\"9\" cannot clobber the counter or wedge the increment (#887)", async () => {
760
+ const engine = await boot({ responses: { "senior:fix-ci": { status: "fixed", ciFixRound: "9", ciFixMax: "1" } } });
761
+ await driveToCiFix(engine);
762
+ assertEquals((await engine.searchIncidents()).length, 0, "the agent echo must not raise an incident");
763
+ assertThatInstance(engine, byProcessId("merge-loop"))
764
+ .isActive()
765
+ .hasCompletedElements("fix-ci")
766
+ .hasVariable("ciFixRound", 1) // the engine increment (0 → 1), NOT the agent's "9"
767
+ .hasVariable("ciFixMax", 3); // the budget ceiling is engine-owned, unclobbered
768
+ });
769
+
770
+ test("a rebase agent echoing rebaseRound:\"9\" cannot clobber the counter or wedge the increment (#887)", async () => {
771
+ const engine = await boot({ responses: { "senior:rebase": { status: "rebased", rebaseRound: "9", rebaseMax: "1" } } });
772
+ await driveToRebase(engine);
773
+ assertEquals((await engine.searchIncidents()).length, 0, "the agent echo must not raise an incident");
774
+ assertThatInstance(engine, byProcessId("merge-loop"))
775
+ .isActive()
776
+ .hasCompletedElements("rebase")
777
+ .hasVariable("rebaseRound", 1) // the engine increment (0 → 1), NOT the agent's "9"
778
+ .hasVariable("rebaseMax", 3); // the budget ceiling is engine-owned, unclobbered
779
+ });
780
+
781
+ test("a fix-ci agent echoing PR identity inputs cannot clobber them (#887)", async () => {
782
+ // `repo`/`prNumber`/`prUrl` are the agent's declared *inputs* (FixCiIn), not its outputs — but the
783
+ // engine merges a completion UNFILTERED, so an input-echo would replace the process identity that
784
+ // `pr.merge` reads for GitHub operations after re-arming. The snapshot/restore guard must pin them.
785
+ const engine = await boot({
786
+ responses: { "senior:fix-ci": { status: "fixed", repo: "evil/repo", prNumber: 999, prUrl: "https://evil.test/pr/999" } },
787
+ });
788
+ await driveToCiFix(engine);
789
+ assertEquals((await engine.searchIncidents()).length, 0, "the agent echo must not raise an incident");
790
+ assertThatInstance(engine, byProcessId("merge-loop"))
791
+ .isActive()
792
+ .hasCompletedElements("fix-ci")
793
+ .hasVariable("repo", "acme/app")
794
+ .hasVariable("prNumber", 1)
795
+ .hasVariable("prUrl", "https://example.test/pr/1");
796
+ });
797
+
798
+ test("a rebase agent echoing PR identity inputs cannot clobber them (#887)", async () => {
799
+ const engine = await boot({
800
+ responses: { "senior:rebase": { status: "rebased", repo: "evil/repo", prNumber: 999, prUrl: "https://evil.test/pr/999" } },
801
+ });
802
+ await driveToRebase(engine);
803
+ assertEquals((await engine.searchIncidents()).length, 0, "the agent echo must not raise an incident");
804
+ assertThatInstance(engine, byProcessId("merge-loop"))
805
+ .isActive()
806
+ .hasCompletedElements("rebase")
807
+ .hasVariable("repo", "acme/app")
808
+ .hasVariable("prNumber", 1)
809
+ .hasVariable("prUrl", "https://example.test/pr/1");
810
+ });
811
+
605
812
  test("rebase budget exhaustion escalates as not-mergeable without running the agent", async () => {
606
813
  const engine = await boot({ responses: { "senior:rebase": { status: "rebased" } }, vars: { rebaseMax: 0 } });
607
814
  await driveToRebase(engine);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.201.4",
3
+ "version": "0.201.5",
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",
@@ -147,9 +147,45 @@
147
147
  </zeebe:properties>
148
148
  <zeebe:ioMapping>
149
149
  <zeebe:input source="=(if (abandonBrief = null) then &#34;&#34; else abandonBrief)" target="appendPrompt" />
150
+ <zeebe:input source="=round" target="guardRound" />
151
+ <zeebe:input source="=maxRounds" target="guardMaxRounds" />
152
+ <zeebe:input source="=advPass" target="guardAdvPass" />
153
+ <zeebe:input source="=advMax" target="guardAdvMax" />
154
+ <zeebe:input source="=ackRetryRound" target="guardAckRetryRound" />
155
+ <zeebe:input source="=ackRetryMax" target="guardAckRetryMax" />
156
+ <zeebe:input source="=huskRetries" target="guardHuskRetries" />
157
+ <zeebe:input source="=prKey" target="guardPrKey" />
158
+ <zeebe:input source="=repo" target="guardRepo" />
159
+ <zeebe:input source="=prNumber" target="guardPrNumber" />
160
+ <zeebe:input source="=prUrl" target="guardPrUrl" />
161
+ <zeebe:input source="=reviewWaitTimeout" target="guardReviewWaitTimeout" />
162
+ <zeebe:input source="=humanApproval" target="guardHumanApproval" />
163
+ <zeebe:input source="=convergeOnly" target="guardConvergeOnly" />
164
+ <zeebe:input source="=reviewStale" target="guardReviewStale" />
165
+ <zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
166
+ <zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
167
+ <zeebe:input source="=abandonBrief" target="guardAbandonBrief" />
150
168
  <zeebe:output source="=null" target="answer" />
151
169
  <zeebe:output source="=false" target="scopePending" />
152
170
  <zeebe:output source="=null" target="adversarialFindings" />
171
+ <zeebe:output source="=guardRound" target="round" />
172
+ <zeebe:output source="=guardMaxRounds" target="maxRounds" />
173
+ <zeebe:output source="=guardAdvPass" target="advPass" />
174
+ <zeebe:output source="=guardAdvMax" target="advMax" />
175
+ <zeebe:output source="=guardAckRetryRound" target="ackRetryRound" />
176
+ <zeebe:output source="=guardAckRetryMax" target="ackRetryMax" />
177
+ <zeebe:output source="=guardHuskRetries" target="huskRetries" />
178
+ <zeebe:output source="=guardPrKey" target="prKey" />
179
+ <zeebe:output source="=guardRepo" target="repo" />
180
+ <zeebe:output source="=guardPrNumber" target="prNumber" />
181
+ <zeebe:output source="=guardPrUrl" target="prUrl" />
182
+ <zeebe:output source="=guardReviewWaitTimeout" target="reviewWaitTimeout" />
183
+ <zeebe:output source="=(guardHumanApproval = true) or (humanApproval = true)" target="humanApproval" />
184
+ <zeebe:output source="=guardConvergeOnly" target="convergeOnly" />
185
+ <zeebe:output source="=guardReviewStale" target="reviewStale" />
186
+ <zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
187
+ <zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
188
+ <zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
153
189
  </zeebe:ioMapping>
154
190
  </bpmn:extensionElements>
155
191
  <bpmn:incoming>f_capture</bpmn:incoming>
@@ -205,11 +241,46 @@
205
241
  </zeebe:properties>
206
242
  <zeebe:ioMapping>
207
243
  <zeebe:input source="=(if (abandonBrief = null) then &#34;&#34; else abandonBrief)" target="appendPrompt" />
244
+ <zeebe:input source="=round" target="guardRound" />
245
+ <zeebe:input source="=maxRounds" target="guardMaxRounds" />
246
+ <zeebe:input source="=advPass" target="guardAdvPass" />
247
+ <zeebe:input source="=advMax" target="guardAdvMax" />
248
+ <zeebe:input source="=ackRetryRound" target="guardAckRetryRound" />
249
+ <zeebe:input source="=ackRetryMax" target="guardAckRetryMax" />
250
+ <zeebe:input source="=huskRetries" target="guardHuskRetries" />
251
+ <zeebe:input source="=prKey" target="guardPrKey" />
252
+ <zeebe:input source="=repo" target="guardRepo" />
253
+ <zeebe:input source="=prNumber" target="guardPrNumber" />
254
+ <zeebe:input source="=prUrl" target="guardPrUrl" />
255
+ <zeebe:input source="=reviewWaitTimeout" target="guardReviewWaitTimeout" />
256
+ <zeebe:input source="=humanApproval" target="guardHumanApproval" />
257
+ <zeebe:input source="=convergeOnly" target="guardConvergeOnly" />
258
+ <zeebe:input source="=reviewStale" target="guardReviewStale" />
259
+ <zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
260
+ <zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
261
+ <zeebe:input source="=abandonBrief" target="guardAbandonBrief" />
208
262
  <zeebe:output source="=adversarialFindings" target="adversarialFindings" />
209
- <zeebe:output source="=(if advPass = null then 0 else advPass) + 1" target="advPass" />
263
+ <zeebe:output source="=(if guardAdvPass = null then 0 else guardAdvPass) + 1" target="advPass" />
210
264
  <zeebe:output source="=(if adversarialFindings != null and trim(adversarialFindings) != &#34;&#34; then null else status)" target="status" />
211
265
  <zeebe:output source="=(if adversarialFindings != null and trim(adversarialFindings) != &#34;&#34; then null else summary)" target="summary" />
212
266
  <zeebe:output source="=(if adversarialFindings != null and trim(adversarialFindings) != &#34;&#34; then null else question)" target="question" />
267
+ <zeebe:output source="=guardRound" target="round" />
268
+ <zeebe:output source="=guardMaxRounds" target="maxRounds" />
269
+ <zeebe:output source="=guardAdvMax" target="advMax" />
270
+ <zeebe:output source="=guardAckRetryRound" target="ackRetryRound" />
271
+ <zeebe:output source="=guardAckRetryMax" target="ackRetryMax" />
272
+ <zeebe:output source="=guardHuskRetries" target="huskRetries" />
273
+ <zeebe:output source="=guardPrKey" target="prKey" />
274
+ <zeebe:output source="=guardRepo" target="repo" />
275
+ <zeebe:output source="=guardPrNumber" target="prNumber" />
276
+ <zeebe:output source="=guardPrUrl" target="prUrl" />
277
+ <zeebe:output source="=guardReviewWaitTimeout" target="reviewWaitTimeout" />
278
+ <zeebe:output source="=(guardHumanApproval = true) or (humanApproval = true)" target="humanApproval" />
279
+ <zeebe:output source="=guardConvergeOnly" target="convergeOnly" />
280
+ <zeebe:output source="=guardReviewStale" target="reviewStale" />
281
+ <zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
282
+ <zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
283
+ <zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
213
284
  </zeebe:ioMapping>
214
285
  </bpmn:extensionElements>
215
286
  <bpmn:incoming>f_advDue</bpmn:incoming>
@@ -478,7 +549,43 @@
478
549
  <zeebe:property name="io.nanobpm.dataEnvelope.out" value="PrScopeClassifyOut" />
479
550
  </zeebe:properties>
480
551
  <zeebe:ioMapping>
552
+ <zeebe:input source="=round" target="guardRound" />
553
+ <zeebe:input source="=maxRounds" target="guardMaxRounds" />
554
+ <zeebe:input source="=advPass" target="guardAdvPass" />
555
+ <zeebe:input source="=advMax" target="guardAdvMax" />
556
+ <zeebe:input source="=ackRetryRound" target="guardAckRetryRound" />
557
+ <zeebe:input source="=ackRetryMax" target="guardAckRetryMax" />
558
+ <zeebe:input source="=huskRetries" target="guardHuskRetries" />
559
+ <zeebe:input source="=prKey" target="guardPrKey" />
560
+ <zeebe:input source="=repo" target="guardRepo" />
561
+ <zeebe:input source="=prNumber" target="guardPrNumber" />
562
+ <zeebe:input source="=prUrl" target="guardPrUrl" />
563
+ <zeebe:input source="=reviewWaitTimeout" target="guardReviewWaitTimeout" />
564
+ <zeebe:input source="=humanApproval" target="guardHumanApproval" />
565
+ <zeebe:input source="=convergeOnly" target="guardConvergeOnly" />
566
+ <zeebe:input source="=reviewStale" target="guardReviewStale" />
567
+ <zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
568
+ <zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
569
+ <zeebe:input source="=abandonBrief" target="guardAbandonBrief" />
481
570
  <zeebe:output source="=null" target="scopeAnswer" />
571
+ <zeebe:output source="=guardRound" target="round" />
572
+ <zeebe:output source="=guardMaxRounds" target="maxRounds" />
573
+ <zeebe:output source="=guardAdvPass" target="advPass" />
574
+ <zeebe:output source="=guardAdvMax" target="advMax" />
575
+ <zeebe:output source="=guardAckRetryRound" target="ackRetryRound" />
576
+ <zeebe:output source="=guardAckRetryMax" target="ackRetryMax" />
577
+ <zeebe:output source="=guardHuskRetries" target="huskRetries" />
578
+ <zeebe:output source="=guardPrKey" target="prKey" />
579
+ <zeebe:output source="=guardRepo" target="repo" />
580
+ <zeebe:output source="=guardPrNumber" target="prNumber" />
581
+ <zeebe:output source="=guardPrUrl" target="prUrl" />
582
+ <zeebe:output source="=guardReviewWaitTimeout" target="reviewWaitTimeout" />
583
+ <zeebe:output source="=(guardHumanApproval = true) or (humanApproval = true)" target="humanApproval" />
584
+ <zeebe:output source="=guardConvergeOnly" target="convergeOnly" />
585
+ <zeebe:output source="=guardReviewStale" target="reviewStale" />
586
+ <zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
587
+ <zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
588
+ <zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
482
589
  </zeebe:ioMapping>
483
590
  </bpmn:extensionElements>
484
591
  <bpmn:incoming>f_convergeOk</bpmn:incoming>
@@ -362,7 +362,46 @@
362
362
  </zeebe:properties>
363
363
  <zeebe:ioMapping>
364
364
  <zeebe:input source="=(if (failingChecksList = null or failingChecksList = &#34;&#34;) then &#34;&#34; else &#34;&#10;&#10;Failing checks to make green:&#10;&#34; + failingChecksList) + (if (abandonBrief = null) then &#34;&#34; else abandonBrief)" target="appendPrompt" />
365
- <zeebe:output source="=ciFixRound + 1" target="ciFixRound" />
365
+ <zeebe:input source="=ciFixRound" target="guardCiFixRound" />
366
+ <zeebe:input source="=ciFixMax" target="guardCiFixMax" />
367
+ <zeebe:input source="=rebaseRound" target="guardRebaseRound" />
368
+ <zeebe:input source="=rebaseMax" target="guardRebaseMax" />
369
+ <zeebe:input source="=mergeRetryRound" target="guardMergeRetryRound" />
370
+ <zeebe:input source="=mergeRetryMax" target="guardMergeRetryMax" />
371
+ <zeebe:input source="=mergeStallRounds" target="guardMergeStallRounds" />
372
+ <zeebe:input source="=mergeStallMax" target="guardMergeStallMax" />
373
+ <zeebe:input source="=prKey" target="guardPrKey" />
374
+ <zeebe:input source="=repo" target="guardRepo" />
375
+ <zeebe:input source="=prNumber" target="guardPrNumber" />
376
+ <zeebe:input source="=prUrl" target="guardPrUrl" />
377
+ <zeebe:input source="=ciBlockedReconciled" target="guardCiBlockedReconciled" />
378
+ <zeebe:input source="=agentSlaTimeout" target="guardAgentSlaTimeout" />
379
+ <zeebe:input source="=landedWaitTimeout" target="guardLandedWaitTimeout" />
380
+ <zeebe:input source="=mergeableWaitTimeout" target="guardMergeableWaitTimeout" />
381
+ <zeebe:input source="=mergeableRepollInterval" target="guardMergeableRepollInterval" />
382
+ <zeebe:input source="=round" target="guardRound" />
383
+ <zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
384
+ <zeebe:input source="=abandonBrief" target="guardAbandonBrief" />
385
+ <zeebe:output source="=guardCiFixRound + 1" target="ciFixRound" />
386
+ <zeebe:output source="=guardCiFixMax" target="ciFixMax" />
387
+ <zeebe:output source="=guardRebaseRound" target="rebaseRound" />
388
+ <zeebe:output source="=guardRebaseMax" target="rebaseMax" />
389
+ <zeebe:output source="=guardMergeRetryRound" target="mergeRetryRound" />
390
+ <zeebe:output source="=guardMergeRetryMax" target="mergeRetryMax" />
391
+ <zeebe:output source="=guardMergeStallRounds" target="mergeStallRounds" />
392
+ <zeebe:output source="=guardMergeStallMax" target="mergeStallMax" />
393
+ <zeebe:output source="=guardPrKey" target="prKey" />
394
+ <zeebe:output source="=guardRepo" target="repo" />
395
+ <zeebe:output source="=guardPrNumber" target="prNumber" />
396
+ <zeebe:output source="=guardPrUrl" target="prUrl" />
397
+ <zeebe:output source="=guardCiBlockedReconciled" target="ciBlockedReconciled" />
398
+ <zeebe:output source="=guardAgentSlaTimeout" target="agentSlaTimeout" />
399
+ <zeebe:output source="=guardLandedWaitTimeout" target="landedWaitTimeout" />
400
+ <zeebe:output source="=guardMergeableWaitTimeout" target="mergeableWaitTimeout" />
401
+ <zeebe:output source="=guardMergeableRepollInterval" target="mergeableRepollInterval" />
402
+ <zeebe:output source="=guardRound" target="round" />
403
+ <zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
404
+ <zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
366
405
  </zeebe:ioMapping>
367
406
  </bpmn:extensionElements>
368
407
  <bpmn:incoming>f_ci_s0</bpmn:incoming>
@@ -524,7 +563,46 @@
524
563
  </zeebe:properties>
525
564
  <zeebe:ioMapping>
526
565
  <zeebe:input source="=(if (abandonBrief = null) then &#34;&#34; else abandonBrief)" target="appendPrompt" />
527
- <zeebe:output source="=rebaseRound + 1" target="rebaseRound" />
566
+ <zeebe:input source="=ciFixRound" target="guardCiFixRound" />
567
+ <zeebe:input source="=ciFixMax" target="guardCiFixMax" />
568
+ <zeebe:input source="=rebaseRound" target="guardRebaseRound" />
569
+ <zeebe:input source="=rebaseMax" target="guardRebaseMax" />
570
+ <zeebe:input source="=mergeRetryRound" target="guardMergeRetryRound" />
571
+ <zeebe:input source="=mergeRetryMax" target="guardMergeRetryMax" />
572
+ <zeebe:input source="=mergeStallRounds" target="guardMergeStallRounds" />
573
+ <zeebe:input source="=mergeStallMax" target="guardMergeStallMax" />
574
+ <zeebe:input source="=prKey" target="guardPrKey" />
575
+ <zeebe:input source="=repo" target="guardRepo" />
576
+ <zeebe:input source="=prNumber" target="guardPrNumber" />
577
+ <zeebe:input source="=prUrl" target="guardPrUrl" />
578
+ <zeebe:input source="=ciBlockedReconciled" target="guardCiBlockedReconciled" />
579
+ <zeebe:input source="=agentSlaTimeout" target="guardAgentSlaTimeout" />
580
+ <zeebe:input source="=landedWaitTimeout" target="guardLandedWaitTimeout" />
581
+ <zeebe:input source="=mergeableWaitTimeout" target="guardMergeableWaitTimeout" />
582
+ <zeebe:input source="=mergeableRepollInterval" target="guardMergeableRepollInterval" />
583
+ <zeebe:input source="=round" target="guardRound" />
584
+ <zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
585
+ <zeebe:input source="=abandonBrief" target="guardAbandonBrief" />
586
+ <zeebe:output source="=guardRebaseRound + 1" target="rebaseRound" />
587
+ <zeebe:output source="=guardCiFixRound" target="ciFixRound" />
588
+ <zeebe:output source="=guardCiFixMax" target="ciFixMax" />
589
+ <zeebe:output source="=guardRebaseMax" target="rebaseMax" />
590
+ <zeebe:output source="=guardMergeRetryRound" target="mergeRetryRound" />
591
+ <zeebe:output source="=guardMergeRetryMax" target="mergeRetryMax" />
592
+ <zeebe:output source="=guardMergeStallRounds" target="mergeStallRounds" />
593
+ <zeebe:output source="=guardMergeStallMax" target="mergeStallMax" />
594
+ <zeebe:output source="=guardPrKey" target="prKey" />
595
+ <zeebe:output source="=guardRepo" target="repo" />
596
+ <zeebe:output source="=guardPrNumber" target="prNumber" />
597
+ <zeebe:output source="=guardPrUrl" target="prUrl" />
598
+ <zeebe:output source="=guardCiBlockedReconciled" target="ciBlockedReconciled" />
599
+ <zeebe:output source="=guardAgentSlaTimeout" target="agentSlaTimeout" />
600
+ <zeebe:output source="=guardLandedWaitTimeout" target="landedWaitTimeout" />
601
+ <zeebe:output source="=guardMergeableWaitTimeout" target="mergeableWaitTimeout" />
602
+ <zeebe:output source="=guardMergeableRepollInterval" target="mergeableRepollInterval" />
603
+ <zeebe:output source="=guardRound" target="round" />
604
+ <zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
605
+ <zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
528
606
  </zeebe:ioMapping>
529
607
  </bpmn:extensionElements>
530
608
  <bpmn:incoming>f_reb_s0</bpmn:incoming>