@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.
@@ -140,6 +140,63 @@ export function agentTaskTypesMissingExternalMarker(xml: string): string[] {
140
140
  return missing;
141
141
  }
142
142
 
143
+ /**
144
+ * Scan one BPMN document for the job types of agent service tasks — keyed by their `<rank>:<task>`
145
+ * colon-form `<zeebe:taskDefinition type>` ALONE, NOT by a prompt link — that are MISSING the
146
+ * engine-native AgentTask marker `<zeebe:agentDefinition agentType="external" />` (issue #884,
147
+ * PR #886 review). The prompt-keyed scanner (`agentTaskTypesMissingExternalMarker`) is blind to a
148
+ * DELIBERATELY prompt-less agent task — and EVERY compiled delivery-graph cell is prompt-less (the
149
+ * resolved #884 "Decide", Option (a): the cell carries the marker alone and is steered by its
150
+ * per-node `appendPrompt`, never a linked base prompt) — so a marker regression on exactly those
151
+ * cells would pass that scan vacuously. This scan keys a service task as an AGENT by the same grammar
152
+ * the crew bridge uses (`jobTypeToRoutingToken`: a single-colon `<rank>:<task>` whose rank is a bare
153
+ * routing segment), which needs no prompt link, so EVERY agent task — prompt-bearing or not — is
154
+ * inspected for the marker independently of its prompt status. A dot-form host job (`pr.finalize`),
155
+ * a typeless block, and a malformed colon form (`senior:feature:extra`) are not agent job types and
156
+ * are ignored (no false positives). The marker check is scoped to the block's
157
+ * `<bpmn:extensionElements>` (the engine-honoured PLACEMENT scope, via `extensionElementsOf`) — a
158
+ * marker sitting outside `extensionElements` is ignored by the engine, so it must not "cover" a
159
+ * task here either. Returns the offending task types in first-occurrence order (empty when every
160
+ * agent task is marked).
161
+ */
162
+ export function externalAgentTaskTypesMissingMarker(xml: string): string[] {
163
+ const seen = new Set<string>();
164
+ const missing: string[] = [];
165
+ for (const [block] of xml.matchAll(SERVICE_TASK)) {
166
+ const type = block.match(TASK_DEFINITION_TYPE)?.[1];
167
+ if (type === undefined || jobTypeToRoutingToken(type) === undefined) continue;
168
+ if (EXTERNAL_AGENT_MARKER.test(extensionElementsOf(block))) continue;
169
+ if (seen.has(type)) continue;
170
+ seen.add(type);
171
+ missing.push(type);
172
+ }
173
+ return missing;
174
+ }
175
+
176
+ /**
177
+ * The SINGLE "every agent service task carries the engine-native `<zeebe:agentDefinition
178
+ * agentType="external" />` marker" invariant (issue #745/#884, PR #886 review), as the UNION of both
179
+ * marker scanners. The two scanners each have a blind spot: `agentTaskTypesMissingExternalMarker` keys
180
+ * a task as an agent by its PROMPT link, so it cannot see a deliberately prompt-less agent cell;
181
+ * `externalAgentTaskTypesMissingMarker` keys it by its `<rank>:<task>` colon-form type, so it covers
182
+ * the prompt-less case but is the newer path. Scanning a corpus with only one leaves the other's blind
183
+ * spot open — a hand-authored (or compiled) `senior:*` task that loses BOTH its prompt link and its
184
+ * marker would pass a prompt-keyed-only gate. Every producer of agent tasks (the hand-authored corpus
185
+ * AND the delivery-graph compiler) is checked against THIS union, so neither half can regress through
186
+ * the other's blind spot. Returns the distinct offending task types in first-occurrence order (the
187
+ * prompt-keyed matches first, then any type-keyed-only ones), empty when every agent task is marked.
188
+ */
189
+ export function missingExternalMarkerAgentTaskTypes(xml: string): string[] {
190
+ const seen = new Set<string>();
191
+ const missing: string[] = [];
192
+ for (const type of [...agentTaskTypesMissingExternalMarker(xml), ...externalAgentTaskTypesMissingMarker(xml)]) {
193
+ if (seen.has(type)) continue;
194
+ seen.add(type);
195
+ missing.push(type);
196
+ }
197
+ return missing;
198
+ }
199
+
143
200
  /**
144
201
  * Scan one BPMN document for the job types of agent service tasks that OPT OUT of the harness
145
202
  * `--auto` reconciliation (issue #779) — those carrying `<zeebe:property
package/app/contracts.ts CHANGED
@@ -538,9 +538,9 @@ export const WIRE_CONTRACTS = {
538
538
  "agentTask.agentDefinition": {
539
539
  category: "wire",
540
540
  name: "agentTask.agentDefinition",
541
- owner: "resources/processes/*.bpmn",
541
+ owner: "resources/processes/*.bpmn + app/deliveryGraphCompiler.ts",
542
542
  semantics:
543
- "The engine-native AgentTask marker (issue #745, umbrella #746 — Camunda 8.10 parity). Every `senior:*` agent service task carries `<zeebe:agentDefinition agentType=\"external\" />` INSIDE its `<bpmn:extensionElements>`, COEXISTING with the existing `<zeebe:taskDefinition type=\"senior:*\"/>` dispatch verb (the verb stays, per #464). The marker makes the element eligible for engine-native AgentInstance minting by the worker harness (jwulf/c8ctl-plugin-nano#194): the harness mints Create/Update/Complete AgentInstance/AgentHistory records against the pinned engine (`@nanobpm/engine-wasm` 0.8.6, broker REST, SDK) while the element still emits its NORMAL `senior:*` job. `agentType=\"external\"` means the agent runs OUTSIDE the engine (a remote fleet worker), not an engine-embedded model call. This is the PRODUCER half; the durable AgentInstance/AgentHistory it mints is read back by the Cockpit historical view via `searchAgentInstanceHistory` (the CONSUMER half — see the `agentTask.historyRead` contract; the read path landed on `@nanobpm/urban`'s EngineClient in urban 0.93 / nanobpm/nano-ide#563). It is authored in the hand-written BPMN semantic model, NOT the generated `<bpmndi:…>` DI, and survives `npm run layout` untouched. Add the marker to a NEW `senior:*` agent task — never a second/synonym marker element.",
543
+ "The engine-native AgentTask marker (issue #745, umbrella #746 — Camunda 8.10 parity). Every `senior:*` agent service task carries `<zeebe:agentDefinition agentType=\"external\" />` INSIDE its `<bpmn:extensionElements>`, COEXISTING with the existing `<zeebe:taskDefinition type=\"senior:*\"/>` dispatch verb (the verb stays, per #464). The marker makes the element eligible for engine-native AgentInstance minting by the worker harness (jwulf/c8ctl-plugin-nano#194): the harness mints Create/Update/Complete AgentInstance/AgentHistory records against the pinned engine (`@nanobpm/engine-wasm` 0.8.6, broker REST, SDK) while the element still emits its NORMAL `senior:*` job. `agentType=\"external\"` means the agent runs OUTSIDE the engine (a remote fleet worker), not an engine-embedded model call. This is the PRODUCER half; the durable AgentInstance/AgentHistory it mints is read back by the Cockpit historical view via `searchAgentInstanceHistory` (the CONSUMER half — see the `agentTask.historyRead` contract; the read path landed on `@nanobpm/urban`'s EngineClient in urban 0.93 / nanobpm/nano-ide#563). It is authored in the hand-written BPMN semantic model, NOT the generated `<bpmndi:…>` DI, and survives `npm run layout` untouched. The class is EVERY agent service task the app generates (issue #884): the hand-authored corpus AND every delivery-graph-compiler `agent` cell. The compiler emits the marker on EVERY cell and — per the resolved \"Decide\" half of #884 (Option (a), PR #886 review) — NO base-prompt link: the hand-authored process prompts are process-specific contracts (they require process variables and a `feat/<task.id>` branch the generic compiled cell never supplies), so a compiled cell is deliberately prompt-LESS and steered by its per-node `appendPrompt` alone. `npm run check:agent-definitions` (scripts/check-agent-definitions.ts, in CI) gates BOTH producers: it scans the compiled output with the prompt-keyed `agentTaskTypesMissingExternalMarker` AND the prompt-link-independent `externalAgentTaskTypesMissingMarker` (an agent task is keyed by its `<rank>:<task>` colon-form type alone, so a marker regression on a prompt-less cell cannot hide). Add the marker to a NEW `senior:*` agent task — never a second/synonym marker element.",
544
544
  shape: '<zeebe:agentDefinition agentType="external" /> (sibling of <zeebe:taskDefinition> in a senior:* service task\'s extensionElements)',
545
545
  },
546
546
  "agentTask.autoSubscribe": {
@@ -14,6 +14,7 @@
14
14
  import { test } from "node:test";
15
15
  import { assert, assertEquals } from "#test-assert";
16
16
  import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
17
+ import { missingExternalMarkerAgentTaskTypes } from "./agentic/vocab/job-types.ts";
17
18
  import { compileDeliveryGraph, digestInvisibleRawValues, graphCarriesRedactedSecrets, nodeDisplay, redactFreeText, resumeValidVar } from "./deliveryGraphCompiler.ts";
18
19
  import { isDeliveryEscalationTwin } from "./deliveryHuman.ts";
19
20
 
@@ -158,6 +159,86 @@ test("trust bound: every node inlines an embedded subProcess delegating to an al
158
159
  assert(/<bpmn:userTask id="delivery-human-task__n\d+__esc"/.test(r.bpmn), "a bounded node inlines an escalation user task");
159
160
  });
160
161
 
162
+ test("#884 agentDefinition: every compiled agent cell carries the engine-native AgentTask marker (durable transcript)", async () => {
163
+ // Issue #884: a compiled `agent` cell's service task carried NO
164
+ // `<zeebe:agentDefinition agentType="external"/>`, so the engine refused to attach an AgentInstance
165
+ // to it (`createAgentInstance failed — … has no agentDefinition`) and every delivery-graph agent run
166
+ // persisted NO durable agentic transcript. The hand-authored processes declare the marker on every
167
+ // `senior:*` task (issue #745); the compiler must emit it on every agent cell it generates — the
168
+ // defect CLASS is "every agent service task the app generates", so this guards the compiler output
169
+ // through the SAME canonical union scanner (`missingExternalMarkerAgentTaskTypes`, the one the
170
+ // repo-wide gate in `check-agent-definitions.ts` uses) rather than a second, driftable regex.
171
+ const r = await compileOk(RELEASE_RUNBOOK);
172
+ // RELEASE_RUNBOOK has exactly one agent node (`open-b` → `senior:feature`); its compiled service
173
+ // task is `<el>_task`. A compiled agent cell is prompt-LESS (the resolved #884 "Decide", Option (a)),
174
+ // so it is the type-keyed half of the union scanner (keyed by the `<rank>:<task>` colon-form type)
175
+ // that sees it — and the marker must be present on THAT cell, not merely somewhere in the document
176
+ // (a stray marker on a DIFFERENT element would not make THIS cell eligible for AgentInstance minting).
177
+ const taskBlock = [...r.bpmn.matchAll(/<bpmn:serviceTask id="n\d+_task"[\s\S]*?<\/bpmn:serviceTask>/g)]
178
+ .map((m) => m[0])
179
+ .find((b) => b.includes('<zeebe:taskDefinition type="senior:feature" />')) ?? "";
180
+ assert(taskBlock !== "", "the agent node compiles to an inner senior:feature service task");
181
+ const ext = /<bpmn:extensionElements>[\s\S]*?<\/bpmn:extensionElements>/.exec(taskBlock)?.[0] ?? "";
182
+ assert(
183
+ /<zeebe:agentDefinition agentType="external" \/>/.test(ext),
184
+ `the compiled agent cell carries <zeebe:agentDefinition agentType="external" /> inside its extensionElements, got:\n${taskBlock}`,
185
+ );
186
+ // The marker is a SIBLING of the dispatch verb, which stays (per #464): both must be present.
187
+ assert(/<zeebe:taskDefinition type="senior:feature" \/>/.test(ext), "the dispatch verb stays alongside the marker");
188
+ // And the CANONICAL union scanner — the one the repo-wide guard applies — finds NO unmarked agent
189
+ // task in the whole compiled document (it keys an agent by its colon-form type, which every compiled
190
+ // agent cell carries; a connector/wait/host task is not colon-form and is correctly ignored).
191
+ assertEquals(missingExternalMarkerAgentTaskTypes(r.bpmn), []);
192
+ });
193
+
194
+ test("#884 agentDefinition: EVERY agent cell in a multi-agent graph is marked (not just the first)", async () => {
195
+ // A marker emitted once (or only on a specific shape) would leave sibling agent cells transcript-less
196
+ // — the 269 failed creates on omarchy came from several processes. Compile a graph whose agent nodes
197
+ // differ (distinct job types, one with a repo spec + emits, one bare) and assert EVERY compiled
198
+ // `<el>_task` service task carries the marker inside its own extensionElements.
199
+ const r = await compileOk({
200
+ nodes: [
201
+ { id: "impl", kind: "agent", agent: { jobType: "senior:feature", repository: "nanobpm/nano-workforce", prompt: "implement #1" }, emits: [{ name: "pr", type: "pr" }] },
202
+ { id: "review", kind: "agent", agent: { jobType: "senior:pr-review" } },
203
+ { id: "ship", kind: "agent", agent: { jobType: "senior:trial-merge", prompt: "merge it" } },
204
+ ],
205
+ edges: [
206
+ { from: "impl", to: "review" },
207
+ { from: "review", to: "ship" },
208
+ ],
209
+ });
210
+ const taskBlocks = [...r.bpmn.matchAll(/<bpmn:serviceTask id="(n\d+_task)"[\s\S]*?<\/bpmn:serviceTask>/g)];
211
+ assertEquals(taskBlocks.length, 3, "all three agent nodes compile to an inner service task");
212
+ for (const [block, id] of taskBlocks) {
213
+ const ext = /<bpmn:extensionElements>[\s\S]*?<\/bpmn:extensionElements>/.exec(block)?.[0] ?? "";
214
+ assert(
215
+ /<zeebe:agentDefinition agentType="external" \/>/.test(ext),
216
+ `agent cell ${id} is missing <zeebe:agentDefinition agentType="external" /> in its extensionElements`,
217
+ );
218
+ }
219
+ });
220
+
221
+ test("#884 prompt-less: a compiled agent cell carries NO base-prompt link (Option (a))", async () => {
222
+ // The resolved "Decide" half of #884 (Option (a), PR #886 review): a compiled agent cell is
223
+ // deliberately prompt-LESS. The hand-authored process prompts (`prompts/feature.md`, …) are
224
+ // process-specific contracts — they require process variables (`variables.task`/`variables.issue`)
225
+ // and a `feat/<task.id>` branch the generic compiled cell never supplies (it seeds only
226
+ // `{jobType, appendPrompt, timeout}` and the harness provisions `feat/<node.id>`, #776) — so linking
227
+ // one hands the cell instructions referencing missing inputs and a conflicting branch. The cell is
228
+ // steered by its per-node `appendPrompt` alone; the marker alone fixes the createAgentInstance
229
+ // failure. Pin that NO compiled agent cell emits a prompt `linkedResource` — for a mapped-looking
230
+ // type, a bespoke type, AND a prototype-chain jobType (which must never resolve to a bogus link).
231
+ for (const jobType of ["senior:feature", "senior:pr-review", "senior:no-such-prompt", "constructor", "__proto__", "toString"]) {
232
+ const r = await compileOk({ nodes: [{ id: "p", kind: "agent", agent: { jobType } }], edges: [] });
233
+ assert(
234
+ !r.bpmn.includes('linkName="prompt"'),
235
+ `compiled agent cell ${JSON.stringify(jobType)} must link NO base prompt (prompt-less, Option (a)), got:\n${
236
+ r.bpmn.split("\n").filter((l) => l.includes("linkedResource")).join("\n")
237
+ }`,
238
+ );
239
+ }
240
+ });
241
+
161
242
  test("late-binding: a fact-qualified edge threads a boundFacts input into the consumer subProcess", async () => {
162
243
  const r = await compileOk(RELEASE_RUNBOOK);
163
244
  // `publish.resolvedArtifact -> consume`: the connector subProcess receives the human's emitted fact as
@@ -1931,7 +1931,7 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>, displ
1931
1931
  // be a leak here), but the `descriptor` is embedded by `serviceBodyLines` into the operator-visible
1932
1932
  // timeout / producer-contract escalation FEEL `prompt` — so it takes the SAME display redaction
1933
1933
  // `nodeDisplay` applies, never the raw value (issue #778 review — thread deliveryGraphCompiler.ts:1606).
1934
- return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], redactConnectorValue(node.agent.jobType), contractGate, agentRepoSpecHeaderLines(node), displayName, "agent", nodeEmits);
1934
+ return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], redactConnectorValue(node.agent.jobType), contractGate, agentRepoSpecHeaderLines(node), displayName, "agent", nodeEmits, undefined);
1935
1935
  }
1936
1936
  case "connector": {
1937
1937
  // TRIM the target before building the escalation descriptor — `nodeDisplay` and the connector
@@ -2113,6 +2113,27 @@ function serviceBodyLines(
2113
2113
  const taskExt = [
2114
2114
  " <bpmn:extensionElements>",
2115
2115
  ` <zeebe:taskDefinition ${taskDefAttr} />`,
2116
+ // Issue #884: an `agent` cell MUST carry the engine-native AgentTask marker
2117
+ // (`<zeebe:agentDefinition agentType="external"/>`, the `agentTask.agentDefinition` contract —
2118
+ // issue #745) INSIDE its extensionElements, as a sibling of the dispatch verb. Without it the
2119
+ // engine refuses to attach an AgentInstance to the element (`createAgentInstance failed — … has
2120
+ // no agentDefinition`), so every delivery-graph agent run persisted NO durable agentic
2121
+ // transcript (269 failed creates on omarchy). The hand-authored `senior:*` tasks all declare it
2122
+ // (guarded by `agent-marker.test.ts`); the compiler emits it on EVERY agent cell it generates —
2123
+ // the defect class is "every agent service task the app generates". A `connector` delegates to a
2124
+ // HOST worker (`pr.delivery-connector`), not an external agent, so it is NOT marked.
2125
+ ...(kind === "agent" ? [' <zeebe:agentDefinition agentType="external" />'] : []),
2126
+ // Issue #884 (the "Decide" half — resolved Option (a), PR #886 review): an agent cell carries ONLY
2127
+ // the marker above and NO base-prompt `linkedResource`. The hand-authored process prompts
2128
+ // (`prompts/feature.md`, `prompts/review-round.md`, …) are PROCESS-specific contracts, not generic
2129
+ // cell contracts — they require process variables (`variables.task`/`variables.issue`) and a branch
2130
+ // (`feat/<task.id>`) the generic compiled cell never supplies (it seeds only
2131
+ // `{jobType, appendPrompt, timeout}` and the harness provisions `feat/<node.id>`, #776). Linking one
2132
+ // would hand every compiled cell instructions referencing missing inputs and a conflicting branch.
2133
+ // The cell is therefore deliberately prompt-LESS: the per-node `appendPrompt` FEEL (runner-seeded
2134
+ // from `node.agent.prompt` + the emit/producer contracts) is its sole steering channel, matching the
2135
+ // historically-intentional design (`deliveryRunner.ts` — "the delivery agent node carries no
2136
+ // base-prompt resource"). The marker alone is what fixes the #884 `createAgentInstance` failure.
2116
2137
  ...(taskProps.length > 0 ? [" <zeebe:properties>", ...taskProps, " </zeebe:properties>"] : []),
2117
2138
  ...taskHeaders,
2118
2139
  " <zeebe:ioMapping>",
@@ -89,13 +89,15 @@ const DEFAULT_VARS: Record<string, unknown> = {
89
89
  mergeableRepollInterval: "PT2M",
90
90
  mergeStallRounds: 0,
91
91
  mergeStallMax: 3,
92
- abandonBrief: null,
92
+ abandonBrief: "\n\n---\nAbort if cancelled: curl …",
93
93
  failingChecksList: null,
94
94
  status: null,
95
95
  mergeState: null,
96
96
  mergeStatus: null,
97
97
  ciBlockedReconciled: null,
98
98
  failingChecks: 0,
99
+ round: 2,
100
+ abandonUrl: "http://host/app/api/hooks/abandon?token=tok-seeded",
99
101
  };
100
102
 
101
103
  /**
@@ -303,6 +305,86 @@ test("a CONFLICTING PR whose poller never signalled reaches the rebase arm via t
303
305
  .hasVariable("rebaseRound", 1);
304
306
  });
305
307
 
308
+ test("a rebase agent echoing engine-owned timeout state cannot clobber it (#887)", async () => {
309
+ // Same guard class as fix-ci: the rebase agent must not reshape the seeded timers that later loop
310
+ // passes re-arm, nor forge `ciBlockedReconciled`. Drive to the rebase arm via the timeout path with
311
+ // the agent echoing a forged marker and tiny timeouts; the restore must pin the timers.
312
+ const engine = await boot({
313
+ responses: {
314
+ "pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "" },
315
+ "senior:rebase": {
316
+ status: "rebased",
317
+ ciBlockedReconciled: true,
318
+ agentSlaTimeout: "PT1S",
319
+ landedWaitTimeout: "PT1S",
320
+ mergeableWaitTimeout: "PT1S",
321
+ mergeableRepollInterval: "PT1S",
322
+ },
323
+ },
324
+ });
325
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
326
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
327
+ assertThatInstance(engine, byProcessId("merge-loop"))
328
+ .isActive()
329
+ .hasCompletedElements("rebase")
330
+ .hasVariable("agentSlaTimeout", "PT30M")
331
+ .hasVariable("landedWaitTimeout", "PT30M")
332
+ .hasVariable("mergeableWaitTimeout", "PT30M")
333
+ .hasVariable("mergeableRepollInterval", "PT2M");
334
+ // The forged lifecycle marker was pinned out too (the rebase path never reconciles).
335
+ assert(instanceVars(engine).ciBlockedReconciled !== true, "a rebase agent must not forge ciBlockedReconciled:true");
336
+ });
337
+
338
+ test("a rebase agent echoing round/abandonUrl cannot corrupt the converged round or the abandon token (#887)", async () => {
339
+ // Same guard class as fix-ci: `round`/`abandonUrl` are seeded once and consumed by `pr.merge`
340
+ // after a successful rebase re-arms the poller. The restore must pin both against an agent echo.
341
+ const engine = await boot({
342
+ responses: {
343
+ "pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "" },
344
+ "senior:rebase": { status: "rebased", round: 99, abandonUrl: "http://evil.test/abandon?token=forged" },
345
+ },
346
+ });
347
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
348
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
349
+ assertThatInstance(engine, byProcessId("merge-loop"))
350
+ .isActive()
351
+ .hasCompletedElements("rebase")
352
+ .hasVariable("round", 2)
353
+ .hasVariable("abandonUrl", "http://host/app/api/hooks/abandon?token=tok-seeded");
354
+ });
355
+
356
+ test("a rebase agent echoing abandonBrief cannot corrupt the seeded abort brief (#887 round 5)", async () => {
357
+ // `abandonBrief` feeds the fix-ci/rebase `appendPrompt` input on the NEXT re-arm; a non-string
358
+ // echo would make that later prompt mapping carry a non-string brief. The restore must pin it.
359
+ const engine = await boot({
360
+ responses: {
361
+ "pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "" },
362
+ "senior:rebase": { status: "rebased", abandonBrief: 42 },
363
+ },
364
+ });
365
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
366
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
367
+ assertThatInstance(engine, byProcessId("merge-loop"))
368
+ .isActive()
369
+ .hasCompletedElements("rebase")
370
+ .hasVariable("abandonBrief", "\n\n---\nAbort if cancelled: curl …");
371
+ });
372
+
373
+ test("a fix-ci agent echoing abandonBrief cannot corrupt the seeded abort brief (#887 round 5)", async () => {
374
+ const engine = await boot({
375
+ responses: {
376
+ "pr.merge-stall-probe": { mergeState: "blocked", failingChecks: 1, failingChecksList: "build" },
377
+ "senior:fix-ci": { status: "fixed", abandonBrief: 42 },
378
+ },
379
+ });
380
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
381
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
382
+ assertThatInstance(engine, byProcessId("merge-loop"))
383
+ .isActive()
384
+ .hasCompletedElements("merge-stall-probe", "fix-ci")
385
+ .hasVariable("abandonBrief", "\n\n---\nAbort if cancelled: curl …");
386
+ });
387
+
306
388
  test("a blocked PR probed on timeout reaches the CI-fix arm via the timeout path (#636)", async () => {
307
389
  const engine = await boot({
308
390
  responses: {
@@ -529,6 +611,67 @@ test("a blocked CI verdict with nothing pushed reconciles once before escalating
529
611
  assert(!completedElementIds(engine).has("merge-esc-attempt"), "the first block episode must reconcile, not page a human");
530
612
  });
531
613
 
614
+ test("a fix-ci agent echoing ciBlockedReconciled:true cannot skip the one-shot ground-truth reconcile (#887)", async () => {
615
+ // `ciBlockedReconciled` is an engine-owned lifecycle marker (arm-merge seeds it null; ci-reconcile
616
+ // sets it true). gw-ci-blocked reconciles exactly once per episode while it is != true. If the agent's
617
+ // completion could echo `ciBlockedReconciled:true`, a `{status:"blocked", pushed:false}` verdict would
618
+ // skip the mandatory first reconcile and page a human immediately. The snapshot/restore guard pins it,
619
+ // so the engine still reconciles once.
620
+ const engine = await boot({
621
+ responses: { "senior:fix-ci": { status: "blocked", pushed: false, ciBlockedReconciled: true } },
622
+ });
623
+ await driveToCiFix(engine);
624
+ assertThatInstance(engine, byProcessId("merge-loop"))
625
+ .isActive()
626
+ .hasActiveElement("wait-mergeable")
627
+ .hasCompletedElements("ci-reconcile") // the reconcile STILL runs — the agent's echo was pinned out
628
+ .hasVariable("ciBlockedReconciled", true);
629
+ assert(!completedElementIds(engine).has("merge-esc-attempt"), "an agent-forged reconciled flag must not skip the first reconcile");
630
+ });
631
+
632
+ test("a fix-ci agent echoing seeded timeouts cannot clobber the re-armed merge/SLA timers (#887)", async () => {
633
+ // The seeded timeout/interval config (agentSlaTimeout, landedWaitTimeout, mergeableWaitTimeout,
634
+ // mergeableRepollInterval) drives timers re-armed on every loop pass. A `reattempt` re-arms the
635
+ // merge wait, so a clobbered timeout would silently reshape the next wait. The guard restores them.
636
+ const engine = await boot({
637
+ responses: {
638
+ "senior:fix-ci": {
639
+ status: "reattempt",
640
+ agentSlaTimeout: "PT1S",
641
+ landedWaitTimeout: "PT1S",
642
+ mergeableWaitTimeout: "PT1S",
643
+ mergeableRepollInterval: "PT1S",
644
+ },
645
+ },
646
+ });
647
+ await driveToCiFix(engine);
648
+ assertThatInstance(engine, byProcessId("merge-loop"))
649
+ .isActive()
650
+ .hasActiveElement("wait-mergeable")
651
+ .hasVariable("agentSlaTimeout", "PT30M")
652
+ .hasVariable("landedWaitTimeout", "PT30M")
653
+ .hasVariable("mergeableWaitTimeout", "PT30M")
654
+ .hasVariable("mergeableRepollInterval", "PT2M");
655
+ });
656
+
657
+ test("a fix-ci agent echoing round/abandonUrl cannot corrupt the converged round or the abandon token (#887)", async () => {
658
+ // `round` (the converged round recorded by ensurePr) and `abandonUrl` (the cooperative-abandon
659
+ // capability URL) are seeded once and consumed directly by `pr.merge` (attempt-merge). A fix-ci
660
+ // completion echoing either would corrupt the recorded round or replace the abandon token. The
661
+ // snapshot/restore guard pins both.
662
+ const engine = await boot({
663
+ responses: {
664
+ "senior:fix-ci": { status: "reattempt", round: 99, abandonUrl: "http://evil.test/abandon?token=forged" },
665
+ },
666
+ });
667
+ await driveToCiFix(engine);
668
+ assertThatInstance(engine, byProcessId("merge-loop"))
669
+ .isActive()
670
+ .hasActiveElement("wait-mergeable")
671
+ .hasVariable("round", 2)
672
+ .hasVariable("abandonUrl", "http://host/app/api/hooks/abandon?token=tok-seeded");
673
+ });
674
+
532
675
  test("a blocked CI verdict that already pushed escalates with a could-not-fix question", async () => {
533
676
  const engine = await boot({ responses: { "senior:fix-ci": { status: "blocked", pushed: true } } });
534
677
  await driveToCiFix(engine);
@@ -602,6 +745,70 @@ test("a no-verdict rebase result reconciles from ground truth, not escalation (#
602
745
  assert(!completedElementIds(engine).has("merge-esc-attempt"), "a missing rebase status must not escalate");
603
746
  });
604
747
 
748
+ // ---------------------------------------------------------------------------
749
+ // #887 — an external-agent completion must not clobber engine-owned counters
750
+ // ---------------------------------------------------------------------------
751
+ // The engine merges EVERY variable an external agent returns into process scope unfiltered, so a
752
+ // `senior:fix-ci`/`senior:rebase` agent echoing a loop counter (`ciFixRound`, `rebaseRound`, …) with
753
+ // the wrong type/value would corrupt it — and because the counter feeds the task's own `+ 1`
754
+ // increment and the `ciFixRound < ciFixMax` / `rebaseRound < rebaseMax` guards, a string echo wedges
755
+ // the sub-process on a "comparison/arithmetic not defined for string and number" incident. The fix
756
+ // snapshots each engine-owned counter into a task-local input before the agent runs and restores it
757
+ // on the task output (driving the increment from the snapshot), so the agent can never alter it.
758
+
759
+ test("a fix-ci agent echoing ciFixRound:\"9\" cannot clobber the counter or wedge the increment (#887)", async () => {
760
+ const engine = await boot({ responses: { "senior:fix-ci": { status: "fixed", ciFixRound: "9", ciFixMax: "1" } } });
761
+ await driveToCiFix(engine);
762
+ assertEquals((await engine.searchIncidents()).length, 0, "the agent echo must not raise an incident");
763
+ assertThatInstance(engine, byProcessId("merge-loop"))
764
+ .isActive()
765
+ .hasCompletedElements("fix-ci")
766
+ .hasVariable("ciFixRound", 1) // the engine increment (0 → 1), NOT the agent's "9"
767
+ .hasVariable("ciFixMax", 3); // the budget ceiling is engine-owned, unclobbered
768
+ });
769
+
770
+ test("a rebase agent echoing rebaseRound:\"9\" cannot clobber the counter or wedge the increment (#887)", async () => {
771
+ const engine = await boot({ responses: { "senior:rebase": { status: "rebased", rebaseRound: "9", rebaseMax: "1" } } });
772
+ await driveToRebase(engine);
773
+ assertEquals((await engine.searchIncidents()).length, 0, "the agent echo must not raise an incident");
774
+ assertThatInstance(engine, byProcessId("merge-loop"))
775
+ .isActive()
776
+ .hasCompletedElements("rebase")
777
+ .hasVariable("rebaseRound", 1) // the engine increment (0 → 1), NOT the agent's "9"
778
+ .hasVariable("rebaseMax", 3); // the budget ceiling is engine-owned, unclobbered
779
+ });
780
+
781
+ test("a fix-ci agent echoing PR identity inputs cannot clobber them (#887)", async () => {
782
+ // `repo`/`prNumber`/`prUrl` are the agent's declared *inputs* (FixCiIn), not its outputs — but the
783
+ // engine merges a completion UNFILTERED, so an input-echo would replace the process identity that
784
+ // `pr.merge` reads for GitHub operations after re-arming. The snapshot/restore guard must pin them.
785
+ const engine = await boot({
786
+ responses: { "senior:fix-ci": { status: "fixed", repo: "evil/repo", prNumber: 999, prUrl: "https://evil.test/pr/999" } },
787
+ });
788
+ await driveToCiFix(engine);
789
+ assertEquals((await engine.searchIncidents()).length, 0, "the agent echo must not raise an incident");
790
+ assertThatInstance(engine, byProcessId("merge-loop"))
791
+ .isActive()
792
+ .hasCompletedElements("fix-ci")
793
+ .hasVariable("repo", "acme/app")
794
+ .hasVariable("prNumber", 1)
795
+ .hasVariable("prUrl", "https://example.test/pr/1");
796
+ });
797
+
798
+ test("a rebase agent echoing PR identity inputs cannot clobber them (#887)", async () => {
799
+ const engine = await boot({
800
+ responses: { "senior:rebase": { status: "rebased", repo: "evil/repo", prNumber: 999, prUrl: "https://evil.test/pr/999" } },
801
+ });
802
+ await driveToRebase(engine);
803
+ assertEquals((await engine.searchIncidents()).length, 0, "the agent echo must not raise an incident");
804
+ assertThatInstance(engine, byProcessId("merge-loop"))
805
+ .isActive()
806
+ .hasCompletedElements("rebase")
807
+ .hasVariable("repo", "acme/app")
808
+ .hasVariable("prNumber", 1)
809
+ .hasVariable("prUrl", "https://example.test/pr/1");
810
+ });
811
+
605
812
  test("rebase budget exhaustion escalates as not-mergeable without running the agent", async () => {
606
813
  const engine = await boot({ responses: { "senior:rebase": { status: "rebased" } }, vars: { rebaseMax: 0 } });
607
814
  await driveToRebase(engine);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.201.3",
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",
@@ -37,6 +37,7 @@
37
37
  "typecheck": "tsc --noEmit",
38
38
  "pretypecheck": "urban gen",
39
39
  "check:prompts": "node --experimental-strip-types scripts/check-agent-prompts.ts",
40
+ "check:agent-definitions": "node --experimental-strip-types scripts/check-agent-definitions.ts",
40
41
  "check:migrations": "node --experimental-strip-types scripts/check-migrations.ts",
41
42
  "heal:migrations": "node --experimental-strip-types scripts/heal-migration-ledger.ts",
42
43
  "check:contracts": "node --experimental-strip-types scripts/check-contracts.ts",
@@ -147,9 +147,45 @@
147
147
  </zeebe:properties>
148
148
  <zeebe:ioMapping>
149
149
  <zeebe:input source="=(if (abandonBrief = null) then &#34;&#34; else abandonBrief)" target="appendPrompt" />
150
+ <zeebe:input source="=round" target="guardRound" />
151
+ <zeebe:input source="=maxRounds" target="guardMaxRounds" />
152
+ <zeebe:input source="=advPass" target="guardAdvPass" />
153
+ <zeebe:input source="=advMax" target="guardAdvMax" />
154
+ <zeebe:input source="=ackRetryRound" target="guardAckRetryRound" />
155
+ <zeebe:input source="=ackRetryMax" target="guardAckRetryMax" />
156
+ <zeebe:input source="=huskRetries" target="guardHuskRetries" />
157
+ <zeebe:input source="=prKey" target="guardPrKey" />
158
+ <zeebe:input source="=repo" target="guardRepo" />
159
+ <zeebe:input source="=prNumber" target="guardPrNumber" />
160
+ <zeebe:input source="=prUrl" target="guardPrUrl" />
161
+ <zeebe:input source="=reviewWaitTimeout" target="guardReviewWaitTimeout" />
162
+ <zeebe:input source="=humanApproval" target="guardHumanApproval" />
163
+ <zeebe:input source="=convergeOnly" target="guardConvergeOnly" />
164
+ <zeebe:input source="=reviewStale" target="guardReviewStale" />
165
+ <zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
166
+ <zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
167
+ <zeebe:input source="=abandonBrief" target="guardAbandonBrief" />
150
168
  <zeebe:output source="=null" target="answer" />
151
169
  <zeebe:output source="=false" target="scopePending" />
152
170
  <zeebe:output source="=null" target="adversarialFindings" />
171
+ <zeebe:output source="=guardRound" target="round" />
172
+ <zeebe:output source="=guardMaxRounds" target="maxRounds" />
173
+ <zeebe:output source="=guardAdvPass" target="advPass" />
174
+ <zeebe:output source="=guardAdvMax" target="advMax" />
175
+ <zeebe:output source="=guardAckRetryRound" target="ackRetryRound" />
176
+ <zeebe:output source="=guardAckRetryMax" target="ackRetryMax" />
177
+ <zeebe:output source="=guardHuskRetries" target="huskRetries" />
178
+ <zeebe:output source="=guardPrKey" target="prKey" />
179
+ <zeebe:output source="=guardRepo" target="repo" />
180
+ <zeebe:output source="=guardPrNumber" target="prNumber" />
181
+ <zeebe:output source="=guardPrUrl" target="prUrl" />
182
+ <zeebe:output source="=guardReviewWaitTimeout" target="reviewWaitTimeout" />
183
+ <zeebe:output source="=(guardHumanApproval = true) or (humanApproval = true)" target="humanApproval" />
184
+ <zeebe:output source="=guardConvergeOnly" target="convergeOnly" />
185
+ <zeebe:output source="=guardReviewStale" target="reviewStale" />
186
+ <zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
187
+ <zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
188
+ <zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
153
189
  </zeebe:ioMapping>
154
190
  </bpmn:extensionElements>
155
191
  <bpmn:incoming>f_capture</bpmn:incoming>
@@ -205,11 +241,46 @@
205
241
  </zeebe:properties>
206
242
  <zeebe:ioMapping>
207
243
  <zeebe:input source="=(if (abandonBrief = null) then &#34;&#34; else abandonBrief)" target="appendPrompt" />
244
+ <zeebe:input source="=round" target="guardRound" />
245
+ <zeebe:input source="=maxRounds" target="guardMaxRounds" />
246
+ <zeebe:input source="=advPass" target="guardAdvPass" />
247
+ <zeebe:input source="=advMax" target="guardAdvMax" />
248
+ <zeebe:input source="=ackRetryRound" target="guardAckRetryRound" />
249
+ <zeebe:input source="=ackRetryMax" target="guardAckRetryMax" />
250
+ <zeebe:input source="=huskRetries" target="guardHuskRetries" />
251
+ <zeebe:input source="=prKey" target="guardPrKey" />
252
+ <zeebe:input source="=repo" target="guardRepo" />
253
+ <zeebe:input source="=prNumber" target="guardPrNumber" />
254
+ <zeebe:input source="=prUrl" target="guardPrUrl" />
255
+ <zeebe:input source="=reviewWaitTimeout" target="guardReviewWaitTimeout" />
256
+ <zeebe:input source="=humanApproval" target="guardHumanApproval" />
257
+ <zeebe:input source="=convergeOnly" target="guardConvergeOnly" />
258
+ <zeebe:input source="=reviewStale" target="guardReviewStale" />
259
+ <zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
260
+ <zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
261
+ <zeebe:input source="=abandonBrief" target="guardAbandonBrief" />
208
262
  <zeebe:output source="=adversarialFindings" target="adversarialFindings" />
209
- <zeebe:output source="=(if advPass = null then 0 else advPass) + 1" target="advPass" />
263
+ <zeebe:output source="=(if guardAdvPass = null then 0 else guardAdvPass) + 1" target="advPass" />
210
264
  <zeebe:output source="=(if adversarialFindings != null and trim(adversarialFindings) != &#34;&#34; then null else status)" target="status" />
211
265
  <zeebe:output source="=(if adversarialFindings != null and trim(adversarialFindings) != &#34;&#34; then null else summary)" target="summary" />
212
266
  <zeebe:output source="=(if adversarialFindings != null and trim(adversarialFindings) != &#34;&#34; then null else question)" target="question" />
267
+ <zeebe:output source="=guardRound" target="round" />
268
+ <zeebe:output source="=guardMaxRounds" target="maxRounds" />
269
+ <zeebe:output source="=guardAdvMax" target="advMax" />
270
+ <zeebe:output source="=guardAckRetryRound" target="ackRetryRound" />
271
+ <zeebe:output source="=guardAckRetryMax" target="ackRetryMax" />
272
+ <zeebe:output source="=guardHuskRetries" target="huskRetries" />
273
+ <zeebe:output source="=guardPrKey" target="prKey" />
274
+ <zeebe:output source="=guardRepo" target="repo" />
275
+ <zeebe:output source="=guardPrNumber" target="prNumber" />
276
+ <zeebe:output source="=guardPrUrl" target="prUrl" />
277
+ <zeebe:output source="=guardReviewWaitTimeout" target="reviewWaitTimeout" />
278
+ <zeebe:output source="=(guardHumanApproval = true) or (humanApproval = true)" target="humanApproval" />
279
+ <zeebe:output source="=guardConvergeOnly" target="convergeOnly" />
280
+ <zeebe:output source="=guardReviewStale" target="reviewStale" />
281
+ <zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
282
+ <zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
283
+ <zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
213
284
  </zeebe:ioMapping>
214
285
  </bpmn:extensionElements>
215
286
  <bpmn:incoming>f_advDue</bpmn:incoming>
@@ -478,7 +549,43 @@
478
549
  <zeebe:property name="io.nanobpm.dataEnvelope.out" value="PrScopeClassifyOut" />
479
550
  </zeebe:properties>
480
551
  <zeebe:ioMapping>
552
+ <zeebe:input source="=round" target="guardRound" />
553
+ <zeebe:input source="=maxRounds" target="guardMaxRounds" />
554
+ <zeebe:input source="=advPass" target="guardAdvPass" />
555
+ <zeebe:input source="=advMax" target="guardAdvMax" />
556
+ <zeebe:input source="=ackRetryRound" target="guardAckRetryRound" />
557
+ <zeebe:input source="=ackRetryMax" target="guardAckRetryMax" />
558
+ <zeebe:input source="=huskRetries" target="guardHuskRetries" />
559
+ <zeebe:input source="=prKey" target="guardPrKey" />
560
+ <zeebe:input source="=repo" target="guardRepo" />
561
+ <zeebe:input source="=prNumber" target="guardPrNumber" />
562
+ <zeebe:input source="=prUrl" target="guardPrUrl" />
563
+ <zeebe:input source="=reviewWaitTimeout" target="guardReviewWaitTimeout" />
564
+ <zeebe:input source="=humanApproval" target="guardHumanApproval" />
565
+ <zeebe:input source="=convergeOnly" target="guardConvergeOnly" />
566
+ <zeebe:input source="=reviewStale" target="guardReviewStale" />
567
+ <zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
568
+ <zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
569
+ <zeebe:input source="=abandonBrief" target="guardAbandonBrief" />
481
570
  <zeebe:output source="=null" target="scopeAnswer" />
571
+ <zeebe:output source="=guardRound" target="round" />
572
+ <zeebe:output source="=guardMaxRounds" target="maxRounds" />
573
+ <zeebe:output source="=guardAdvPass" target="advPass" />
574
+ <zeebe:output source="=guardAdvMax" target="advMax" />
575
+ <zeebe:output source="=guardAckRetryRound" target="ackRetryRound" />
576
+ <zeebe:output source="=guardAckRetryMax" target="ackRetryMax" />
577
+ <zeebe:output source="=guardHuskRetries" target="huskRetries" />
578
+ <zeebe:output source="=guardPrKey" target="prKey" />
579
+ <zeebe:output source="=guardRepo" target="repo" />
580
+ <zeebe:output source="=guardPrNumber" target="prNumber" />
581
+ <zeebe:output source="=guardPrUrl" target="prUrl" />
582
+ <zeebe:output source="=guardReviewWaitTimeout" target="reviewWaitTimeout" />
583
+ <zeebe:output source="=(guardHumanApproval = true) or (humanApproval = true)" target="humanApproval" />
584
+ <zeebe:output source="=guardConvergeOnly" target="convergeOnly" />
585
+ <zeebe:output source="=guardReviewStale" target="reviewStale" />
586
+ <zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
587
+ <zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
588
+ <zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
482
589
  </zeebe:ioMapping>
483
590
  </bpmn:extensionElements>
484
591
  <bpmn:incoming>f_convergeOk</bpmn:incoming>