@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 +6 -0
- package/SPEC.md +104 -0
- package/app/agentOutputEnvelope.test.ts +464 -0
- package/app/mergeLoopBehaviour.test.ts +208 -1
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +108 -1
- package/resources/processes/merge-loop.bpmn +80 -2
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:
|
|
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.
|
|
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 "" 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 "" 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
|
|
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) != "" then null else status)" target="status" />
|
|
211
265
|
<zeebe:output source="=(if adversarialFindings != null and trim(adversarialFindings) != "" then null else summary)" target="summary" />
|
|
212
266
|
<zeebe:output source="=(if adversarialFindings != null and trim(adversarialFindings) != "" 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 = "") then "" else " Failing checks to make green: " + failingChecksList) + (if (abandonBrief = null) then "" else abandonBrief)" target="appendPrompt" />
|
|
365
|
-
<zeebe:
|
|
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 "" else abandonBrief)" target="appendPrompt" />
|
|
527
|
-
<zeebe:
|
|
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>
|