@nanobpm/nano-workforce 0.201.3 → 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/.github/workflows/ci.yml +11 -0
- package/AGENTS.md +3 -1
- package/CHANGELOG.md +12 -0
- package/SPEC.md +123 -5
- package/app/agentOutputEnvelope.test.ts +464 -0
- package/app/agentic/vocab/agent-marker.test.ts +30 -1
- package/app/agentic/vocab/job-types.test.ts +133 -1
- package/app/agentic/vocab/job-types.ts +57 -0
- package/app/contracts.ts +2 -2
- package/app/deliveryGraphCompiler.test.ts +81 -0
- package/app/deliveryGraphCompiler.ts +22 -1
- package/app/mergeLoopBehaviour.test.ts +208 -1
- package/package.json +2 -1
- package/resources/processes/convergence-loop.bpmn +108 -1
- package/resources/processes/merge-loop.bpmn +80 -2
- package/scripts/check-agent-definitions.test.ts +126 -0
- package/scripts/check-agent-definitions.ts +191 -0
|
@@ -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
|
+
});
|
|
@@ -9,7 +9,8 @@ import { test } from "node:test";
|
|
|
9
9
|
import { dirname, join, relative } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { assert, assertEquals } from "#test-assert";
|
|
12
|
-
import {
|
|
12
|
+
import { compileDeliveryGraph } from "../../deliveryGraphCompiler.ts";
|
|
13
|
+
import { agentTaskTypesMissingExternalMarker, missingExternalMarkerAgentTaskTypes, promptBearingTaskTypes } from "./job-types.ts";
|
|
13
14
|
|
|
14
15
|
const PROCESSES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../resources/processes");
|
|
15
16
|
|
|
@@ -72,3 +73,31 @@ test("DEFECT-CLASS GUARD: every deployed prompt-bearing agent task carries the e
|
|
|
72
73
|
// Sanity: the models really do declare agent tasks (guard is not vacuously green).
|
|
73
74
|
assert(anyAgentTasks, "the deployed models declare prompt-bearing agent tasks");
|
|
74
75
|
});
|
|
76
|
+
|
|
77
|
+
test("DEFECT-CLASS GUARD (compiler output, issue #884): every COMPILED delivery-graph agent cell carries the marker", async () => {
|
|
78
|
+
// The hand-authored corpus above is only HALF the class — the delivery-graph COMPILER
|
|
79
|
+
// (`app/deliveryGraphCompiler.ts`) is the other producer of agent service tasks, and it emitted
|
|
80
|
+
// cells with NO `agentDefinition` until #884: every delivery-graph agent run failed AgentInstance
|
|
81
|
+
// creation (`… has no agentDefinition`) and persisted no durable transcript (269 failed creates on
|
|
82
|
+
// omarchy). Per the resolved "Decide" half of #884 (Option (a), PR #886 review) a compiled cell is
|
|
83
|
+
// prompt-LESS (no base-prompt link), so the prompt-keyed scanner cannot see it — it is the
|
|
84
|
+
// type-keyed half of the union scanner (`missingExternalMarkerAgentTaskTypes`, keyed by the
|
|
85
|
+
// `<rank>:<task>` colon-form type) that sees these cells. Apply the SAME union to the compiler's
|
|
86
|
+
// OUTPUT so a compiler regression that drops the marker fails here — not silently at runtime.
|
|
87
|
+
const r = await compileDeliveryGraph({
|
|
88
|
+
nodes: [
|
|
89
|
+
{ id: "impl", kind: "agent", agent: { jobType: "senior:feature", prompt: "implement #1" }, emits: [{ name: "pr", type: "pr" }] },
|
|
90
|
+
{ id: "review", kind: "agent", agent: { jobType: "senior:pr-review" } },
|
|
91
|
+
],
|
|
92
|
+
edges: [{ from: "impl", to: "review" }],
|
|
93
|
+
});
|
|
94
|
+
assert(r.ok, `expected the graph to compile, got ${JSON.stringify(r)}`);
|
|
95
|
+
// The compiled agent cells are prompt-LESS (Option (a)), so the prompt-keyed scan sees none — the
|
|
96
|
+
// union's type-keyed half is what covers them.
|
|
97
|
+
assertEquals(promptBearingTaskTypes(r.bpmn), []);
|
|
98
|
+
const missing = missingExternalMarkerAgentTaskTypes(r.bpmn);
|
|
99
|
+
assert(
|
|
100
|
+
missing.length === 0,
|
|
101
|
+
`compiled delivery-graph agent cell(s) missing <zeebe:agentDefinition agentType="external" /> — ${missing.join(", ")}`,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
@@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import { assert, assertEquals } from "#test-assert";
|
|
10
10
|
import type { Capability } from "@nanobpm/agentic/protocol";
|
|
11
11
|
import { crewResolver } from "./crew-vocab.ts";
|
|
12
|
-
import { jobTypeToRoutingToken, promptBearingTaskTypes } from "./job-types.ts";
|
|
12
|
+
import { externalAgentTaskTypesMissingMarker, jobTypeToRoutingToken, missingExternalMarkerAgentTaskTypes, promptBearingTaskTypes } from "./job-types.ts";
|
|
13
13
|
|
|
14
14
|
const PROCESSES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../resources/processes");
|
|
15
15
|
|
|
@@ -105,3 +105,135 @@ test("DEFECT-CLASS GUARD: every deployed prompt-bearing agent job type resolves
|
|
|
105
105
|
);
|
|
106
106
|
}
|
|
107
107
|
});
|
|
108
|
+
|
|
109
|
+
test("externalAgentTaskTypesMissingMarker flags an UNMAPPED (prompt-less) agent task with no marker", () => {
|
|
110
|
+
// The prompt-keyed scanner (`agentTaskTypesMissingExternalMarker`) cannot see this block — it has no
|
|
111
|
+
// prompt link. The marker scan must key on the colon-form job type ALONE (issue #886 review): a
|
|
112
|
+
// compiled delivery-graph cell for a bespoke `senior:<task>` (no deployed base prompt, deliberately
|
|
113
|
+
// prompt-less) must still carry the marker or it runs transcript-less.
|
|
114
|
+
const xml = `
|
|
115
|
+
<bpmn:serviceTask id="agent">
|
|
116
|
+
<bpmn:extensionElements>
|
|
117
|
+
<zeebe:taskDefinition type="senior:bespoke" />
|
|
118
|
+
</bpmn:extensionElements>
|
|
119
|
+
</bpmn:serviceTask>`;
|
|
120
|
+
assertEquals(externalAgentTaskTypesMissingMarker(xml), ["senior:bespoke"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("externalAgentTaskTypesMissingMarker passes a marked prompt-less agent task and ignores a plain host task", () => {
|
|
124
|
+
const xml = `
|
|
125
|
+
<bpmn:serviceTask id="host">
|
|
126
|
+
<bpmn:extensionElements>
|
|
127
|
+
<zeebe:taskDefinition type="pr.finalize" />
|
|
128
|
+
</bpmn:extensionElements>
|
|
129
|
+
</bpmn:serviceTask>
|
|
130
|
+
<bpmn:serviceTask id="agent">
|
|
131
|
+
<bpmn:extensionElements>
|
|
132
|
+
<zeebe:taskDefinition type="senior:bespoke" />
|
|
133
|
+
<zeebe:agentDefinition agentType="external" />
|
|
134
|
+
</bpmn:extensionElements>
|
|
135
|
+
</bpmn:serviceTask>`;
|
|
136
|
+
assertEquals(externalAgentTaskTypesMissingMarker(xml), []);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("externalAgentTaskTypesMissingMarker flags a marked-outside-extensionElements task (placement contract)", () => {
|
|
140
|
+
// A marker sitting OUTSIDE `<bpmn:extensionElements>` is ignored by the engine, so it must not
|
|
141
|
+
// "cover" the task here either (the same placement scope the prompt-keyed scanner enforces).
|
|
142
|
+
const xml = `
|
|
143
|
+
<bpmn:serviceTask id="agent">
|
|
144
|
+
<zeebe:agentDefinition agentType="external" />
|
|
145
|
+
<bpmn:extensionElements>
|
|
146
|
+
<zeebe:taskDefinition type="senior:bespoke" />
|
|
147
|
+
</bpmn:extensionElements>
|
|
148
|
+
</bpmn:serviceTask>`;
|
|
149
|
+
assertEquals(externalAgentTaskTypesMissingMarker(xml), ["senior:bespoke"]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("externalAgentTaskTypesMissingMarker ignores malformed/non-agent types (no false positives)", () => {
|
|
153
|
+
// Only a `<rank>:<task>` colon-form type (the `jobTypeToRoutingToken` grammar) keys a block as an
|
|
154
|
+
// agent task: a dot-form host job, a typeless block, and a malformed colon form are all out of
|
|
155
|
+
// scope — the scanner must not flag them as unmarked agents.
|
|
156
|
+
const xml = `
|
|
157
|
+
<bpmn:serviceTask id="host1">
|
|
158
|
+
<bpmn:extensionElements>
|
|
159
|
+
<zeebe:taskDefinition type="pr.finalize" />
|
|
160
|
+
</bpmn:extensionElements>
|
|
161
|
+
</bpmn:serviceTask>
|
|
162
|
+
<bpmn:serviceTask id="host2">
|
|
163
|
+
<bpmn:extensionElements>
|
|
164
|
+
<zeebe:taskDefinition type="senior:feature:extra" />
|
|
165
|
+
</bpmn:extensionElements>
|
|
166
|
+
</bpmn:serviceTask>
|
|
167
|
+
<bpmn:serviceTask id="host3">
|
|
168
|
+
<bpmn:extensionElements>
|
|
169
|
+
<zeebe:taskDefinition type="" />
|
|
170
|
+
</bpmn:extensionElements>
|
|
171
|
+
</bpmn:serviceTask>
|
|
172
|
+
<bpmn:serviceTask id="host4">
|
|
173
|
+
<bpmn:extensionElements>
|
|
174
|
+
</bpmn:extensionElements>
|
|
175
|
+
</bpmn:serviceTask>`;
|
|
176
|
+
assertEquals(externalAgentTaskTypesMissingMarker(xml), []);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("missingExternalMarkerAgentTaskTypes unions BOTH scanners — catches a prompt-bearing AND a prompt-less unmarked agent task", () => {
|
|
180
|
+
// The single "every agent task carries the external marker" invariant (PR #886 review): a
|
|
181
|
+
// prompt-BEARING senior:* task that loses its marker is caught only by the prompt-keyed scanner,
|
|
182
|
+
// while a prompt-LESS senior:* task that loses its marker is caught only by the type-keyed scanner.
|
|
183
|
+
// Scanning by just one leaves the other's blind spot open, so the union must flag BOTH in one pass.
|
|
184
|
+
const xml = `
|
|
185
|
+
<bpmn:serviceTask id="prompted">
|
|
186
|
+
<bpmn:extensionElements>
|
|
187
|
+
<zeebe:taskDefinition type="senior:feature" />
|
|
188
|
+
<zeebe:linkedResources>
|
|
189
|
+
<zeebe:linkedResource resourceId="prompts/feature.md" linkName="prompt" />
|
|
190
|
+
</zeebe:linkedResources>
|
|
191
|
+
</bpmn:extensionElements>
|
|
192
|
+
</bpmn:serviceTask>
|
|
193
|
+
<bpmn:serviceTask id="bespoke">
|
|
194
|
+
<bpmn:extensionElements>
|
|
195
|
+
<zeebe:taskDefinition type="senior:bespoke" />
|
|
196
|
+
</bpmn:extensionElements>
|
|
197
|
+
</bpmn:serviceTask>`;
|
|
198
|
+
assertEquals(missingExternalMarkerAgentTaskTypes(xml), ["senior:feature", "senior:bespoke"]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("missingExternalMarkerAgentTaskTypes passes when every agent task is marked and ignores host tasks", () => {
|
|
202
|
+
const xml = `
|
|
203
|
+
<bpmn:serviceTask id="host">
|
|
204
|
+
<bpmn:extensionElements>
|
|
205
|
+
<zeebe:taskDefinition type="pr.finalize" />
|
|
206
|
+
</bpmn:extensionElements>
|
|
207
|
+
</bpmn:serviceTask>
|
|
208
|
+
<bpmn:serviceTask id="prompted">
|
|
209
|
+
<bpmn:extensionElements>
|
|
210
|
+
<zeebe:taskDefinition type="senior:feature" />
|
|
211
|
+
<zeebe:agentDefinition agentType="external" />
|
|
212
|
+
<zeebe:linkedResources>
|
|
213
|
+
<zeebe:linkedResource resourceId="prompts/feature.md" linkName="prompt" />
|
|
214
|
+
</zeebe:linkedResources>
|
|
215
|
+
</bpmn:extensionElements>
|
|
216
|
+
</bpmn:serviceTask>
|
|
217
|
+
<bpmn:serviceTask id="bespoke">
|
|
218
|
+
<bpmn:extensionElements>
|
|
219
|
+
<zeebe:taskDefinition type="senior:bespoke" />
|
|
220
|
+
<zeebe:agentDefinition agentType="external" />
|
|
221
|
+
</bpmn:extensionElements>
|
|
222
|
+
</bpmn:serviceTask>`;
|
|
223
|
+
assertEquals(missingExternalMarkerAgentTaskTypes(xml), []);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("missingExternalMarkerAgentTaskTypes de-duplicates a type caught by both scanners", () => {
|
|
227
|
+
// A prompt-bearing task missing its marker is seen by BOTH the prompt-keyed and the type-keyed
|
|
228
|
+
// scanner; the union must report it ONCE, not twice.
|
|
229
|
+
const xml = `
|
|
230
|
+
<bpmn:serviceTask id="prompted">
|
|
231
|
+
<bpmn:extensionElements>
|
|
232
|
+
<zeebe:taskDefinition type="senior:feature" />
|
|
233
|
+
<zeebe:linkedResources>
|
|
234
|
+
<zeebe:linkedResource resourceId="prompts/feature.md" linkName="prompt" />
|
|
235
|
+
</zeebe:linkedResources>
|
|
236
|
+
</bpmn:extensionElements>
|
|
237
|
+
</bpmn:serviceTask>`;
|
|
238
|
+
assertEquals(missingExternalMarkerAgentTaskTypes(xml), ["senior:feature"]);
|
|
239
|
+
});
|