@nanobpm/nano-workforce 0.186.4 → 0.187.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +35 -0
- package/app/agentic/vocab/auto-subscribe.test.ts +261 -0
- package/app/agentic/vocab/job-types.ts +123 -3
- package/app/contracts.ts +9 -1
- package/app/deliveryGraphCompiler.ts +11 -7
- package/app/deliveryRunner.test.ts +66 -1
- package/app/deliveryRunner.ts +22 -5
- package/app/repoEnvelope.ts +22 -6
- package/openapi.yaml +4 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.187.1](https://github.com/nanobpm/nano-workforce/compare/v0.187.0...v0.187.1) (2026-09-12)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **delivery-graph:** emit a deterministic branch.create so agent cells never commit on the base branch ([#781](https://github.com/nanobpm/nano-workforce/issues/781)) ([56f6488](https://github.com/nanobpm/nano-workforce/commit/56f6488344b439a5ac12f5069567facd9519bf66)), closes [#776](https://github.com/nanobpm/nano-workforce/issues/776) [pre-#776](https://github.com/nanobpm/pre-/issues/776) [#776](https://github.com/nanobpm/nano-workforce/issues/776)
|
|
6
|
+
|
|
7
|
+
## [0.187.0](https://github.com/nanobpm/nano-workforce/compare/v0.186.4...v0.187.0) (2026-09-12)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **agentic:** autoSubscribe opt-out marker + single agentDefinition-external convention ([#782](https://github.com/nanobpm/nano-workforce/issues/782)) ([6a0c616](https://github.com/nanobpm/nano-workforce/commit/6a0c616158db7e83ab2479d66677dd652c12f886)), closes [jwulf/c8ctl-plugin-nano#235](https://github.com/jwulf/c8ctl-plugin-nano/issues/235) [#779](https://github.com/nanobpm/nano-workforce/issues/779) [#745](https://github.com/nanobpm/nano-workforce/issues/745)
|
|
12
|
+
|
|
1
13
|
## [0.186.4](https://github.com/nanobpm/nano-workforce/compare/v0.186.3...v0.186.4) (2026-09-12)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/SPEC.md
CHANGED
|
@@ -357,6 +357,41 @@ the app, which deploys on boot), and the next agent job of that type picks it up
|
|
|
357
357
|
> (`$AGENT_RESULT_FILE` / `::nano:result::`), and no task may still carry the retired
|
|
358
358
|
> baked `io.nanobpm.agentTask.task.prompt` header.
|
|
359
359
|
|
|
360
|
+
> **`<zeebe:agentDefinition agentType="external" />` is the ONE agentic-task signal.**
|
|
361
|
+
> Every hand-authored `senior:*` agent service task in `resources/processes` carries this
|
|
362
|
+
> engine-native AgentTask marker
|
|
363
|
+
> (issue #745) alongside its `<zeebe:taskDefinition>`, and it is the **single
|
|
364
|
+
> convention** the worker harness `--auto` reconciliation scans to discover agentic
|
|
365
|
+
> tasks — replacing the legacy `linkName="prompt"` / header dual signal so the app and
|
|
366
|
+
> harness converge on one signal (issue #779, harness jwulf/c8ctl-plugin-nano#235). The
|
|
367
|
+
> marker is CI-enforced over the deployed `resources/` process models
|
|
368
|
+
> (`agentTaskTypesMissingExternalMarker`,
|
|
369
|
+
> `agent-marker.test.ts`), so no prompt-bearing agent task in those models relies on
|
|
370
|
+
> prompt-link-only discovery. (The delivery-graph compiler's GENERATED agent BPMN —
|
|
371
|
+
> deployed at run time by `runDeliveryGraph`, not authored under `resources/` — is a
|
|
372
|
+
> separate deployed path NOT covered by this static guard; whether its generated cells
|
|
373
|
+
> should also carry the marker/opt-out convention is tracked separately under issue #745,
|
|
374
|
+
> not #779.) To **exclude** a task from `--auto` — one that must be served only by a
|
|
375
|
+
> worker that explicitly subscribes (`--job-type <type>` / a profile capability) — add
|
|
376
|
+
> the inert opt-out property inside its `extensionElements`, nested in the
|
|
377
|
+
> `<zeebe:properties>` wrapper the models and engine expect (as
|
|
378
|
+
> `resources/processes/feature.bpmn:57-63` does — a bare `<zeebe:property>` placed
|
|
379
|
+
> directly under `<bpmn:extensionElements>` is NOT the accepted shape):
|
|
380
|
+
>
|
|
381
|
+
> ```xml
|
|
382
|
+
> <bpmn:extensionElements>
|
|
383
|
+
> <zeebe:properties>
|
|
384
|
+
> <zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
|
|
385
|
+
> </zeebe:properties>
|
|
386
|
+
> </bpmn:extensionElements>
|
|
387
|
+
> ```
|
|
388
|
+
>
|
|
389
|
+
> Absence (or any value other than `"false"`) auto-subscribes as normal — opt-out is
|
|
390
|
+
> explicit and fail-safe. The property is inert to the engine (no migration, no
|
|
391
|
+
> behaviour change). It is a registered contract (`agentTask.autoSubscribe` in
|
|
392
|
+
> `app/contracts.ts`), read by the ONE helper `agentTaskTypesOptedOutOfAuto`
|
|
393
|
+
> (`app/agentic/vocab/job-types.ts`) and guarded by `auto-subscribe.test.ts`.
|
|
394
|
+
|
|
360
395
|
Per-instance dynamic context still rides **`appendPrompt`** (unchanged): an ioMapping
|
|
361
396
|
sets a job-local `appendPrompt` string (a plan's rejection findings, a feature task's
|
|
362
397
|
brief, the failing-check list) which the agent harness concatenates **verbatim** onto
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Tests for the `--auto` opt-OUT marker (issue #779, harness jwulf/c8ctl-plugin-nano#235). The single
|
|
2
|
+
// agentic-task signal the harness `--auto` reconciliation scans is `<zeebe:agentDefinition
|
|
3
|
+
// agentType="external" />`; this marker — `<zeebe:property name="io.nanobpm.agentTask.autoSubscribe"
|
|
4
|
+
// value="false" />` inside an agent task's extensionElements — is the explicit escape hatch that
|
|
5
|
+
// EXCLUDES a task from `--auto` so it is served only by a worker that explicitly subscribes. Mirrors
|
|
6
|
+
// agent-marker.test.ts: assert the marker's shape/placement, and guard that any opted-out task in the
|
|
7
|
+
// deployed models is itself a real (externally-marked) agent task, so the opt-out can't drift onto a
|
|
8
|
+
// non-agent element.
|
|
9
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { dirname, join, relative } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { assert, assertEquals } from "#test-assert";
|
|
14
|
+
import {
|
|
15
|
+
agentTaskTypesMissingExternalMarker,
|
|
16
|
+
agentTaskTypesOptedOutMissingExternalMarker,
|
|
17
|
+
agentTaskTypesOptedOutOfAuto,
|
|
18
|
+
MALFORMED_OPTOUT_LABEL,
|
|
19
|
+
} from "./job-types.ts";
|
|
20
|
+
|
|
21
|
+
const RESOURCES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../resources");
|
|
22
|
+
|
|
23
|
+
// urban deploys `resources/` recursively (every file at ANY depth), so an opted-out task in a `.bpmn`
|
|
24
|
+
// added under any resources subdirectory — not just `resources/processes` — would still deploy. Root
|
|
25
|
+
// the walk at the `resources/` convention root (mirroring the deploy contract) so a deployed BPMN
|
|
26
|
+
// placed elsewhere under `resources/` cannot bypass this guard.
|
|
27
|
+
function bpmnFiles(): string[] {
|
|
28
|
+
const walk = (dir: string): string[] =>
|
|
29
|
+
readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
30
|
+
const full = join(dir, entry.name);
|
|
31
|
+
if (entry.isDirectory()) return walk(full);
|
|
32
|
+
return entry.name.endsWith(".bpmn") ? [relative(RESOURCES_DIR, full)] : [];
|
|
33
|
+
});
|
|
34
|
+
return walk(RESOURCES_DIR).sort();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test("agentTaskTypesOptedOutOfAuto flags a task carrying the value=\"false\" opt-out property", () => {
|
|
38
|
+
const xml = `
|
|
39
|
+
<bpmn:serviceTask id="agent">
|
|
40
|
+
<bpmn:extensionElements>
|
|
41
|
+
<zeebe:taskDefinition type="senior:special" />
|
|
42
|
+
<zeebe:agentDefinition agentType="external" />
|
|
43
|
+
<zeebe:properties>
|
|
44
|
+
<zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
|
|
45
|
+
</zeebe:properties>
|
|
46
|
+
</bpmn:extensionElements>
|
|
47
|
+
</bpmn:serviceTask>`;
|
|
48
|
+
assertEquals(agentTaskTypesOptedOutOfAuto(xml), ["senior:special"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("agentTaskTypesOptedOutOfAuto tolerates reversed attribute order (name/value swapped)", () => {
|
|
52
|
+
const xml = `
|
|
53
|
+
<bpmn:serviceTask id="agent">
|
|
54
|
+
<bpmn:extensionElements>
|
|
55
|
+
<zeebe:taskDefinition type="senior:special" />
|
|
56
|
+
<zeebe:properties>
|
|
57
|
+
<zeebe:property value="false" name="io.nanobpm.agentTask.autoSubscribe" />
|
|
58
|
+
</zeebe:properties>
|
|
59
|
+
</bpmn:extensionElements>
|
|
60
|
+
</bpmn:serviceTask>`;
|
|
61
|
+
assertEquals(agentTaskTypesOptedOutOfAuto(xml), ["senior:special"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("agentTaskTypesOptedOutOfAuto ignores a task without the marker and one with a non-false value", () => {
|
|
65
|
+
const xml = `
|
|
66
|
+
<bpmn:serviceTask id="plain">
|
|
67
|
+
<bpmn:extensionElements>
|
|
68
|
+
<zeebe:taskDefinition type="senior:feature" />
|
|
69
|
+
<zeebe:agentDefinition agentType="external" />
|
|
70
|
+
</bpmn:extensionElements>
|
|
71
|
+
</bpmn:serviceTask>
|
|
72
|
+
<bpmn:serviceTask id="explicitTrue">
|
|
73
|
+
<bpmn:extensionElements>
|
|
74
|
+
<zeebe:taskDefinition type="senior:retro" />
|
|
75
|
+
<zeebe:properties>
|
|
76
|
+
<zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="true" />
|
|
77
|
+
</zeebe:properties>
|
|
78
|
+
</bpmn:extensionElements>
|
|
79
|
+
</bpmn:serviceTask>`;
|
|
80
|
+
assertEquals(agentTaskTypesOptedOutOfAuto(xml), []);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("agentTaskTypesOptedOutOfAuto ignores an unrelated property named the same-ish", () => {
|
|
84
|
+
const xml = `
|
|
85
|
+
<bpmn:serviceTask id="agent">
|
|
86
|
+
<bpmn:extensionElements>
|
|
87
|
+
<zeebe:taskDefinition type="senior:feature" />
|
|
88
|
+
<zeebe:properties>
|
|
89
|
+
<zeebe:property name="io.nanobpm.agentTask.autoSubscribeMode" value="false" />
|
|
90
|
+
</zeebe:properties>
|
|
91
|
+
</bpmn:extensionElements>
|
|
92
|
+
</bpmn:serviceTask>`;
|
|
93
|
+
assertEquals(agentTaskTypesOptedOutOfAuto(xml), []);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("agentTaskTypesOptedOutOfAuto ignores a bare opt-out property OUTSIDE the <zeebe:properties> wrapper (placement contract)", () => {
|
|
97
|
+
// The property carries the exact name/value, but it sits directly under <bpmn:extensionElements>
|
|
98
|
+
// rather than inside the <zeebe:properties> wrapper the engine honours — so the engine ignores it
|
|
99
|
+
// and it is NOT an active opt-out. Both the reader and the drift guard must treat it as absent.
|
|
100
|
+
const xml = `
|
|
101
|
+
<bpmn:serviceTask id="bare">
|
|
102
|
+
<bpmn:extensionElements>
|
|
103
|
+
<zeebe:taskDefinition type="senior:special" />
|
|
104
|
+
<zeebe:agentDefinition agentType="external" />
|
|
105
|
+
<zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
|
|
106
|
+
</bpmn:extensionElements>
|
|
107
|
+
</bpmn:serviceTask>`;
|
|
108
|
+
assertEquals(agentTaskTypesOptedOutOfAuto(xml), []);
|
|
109
|
+
assertEquals(agentTaskTypesOptedOutMissingExternalMarker(xml), []);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("agentTaskTypesMissingExternalMarker flags a prompt-bearing task whose external marker sits OUTSIDE extensionElements (placement contract)", () => {
|
|
113
|
+
// The task is a real prompt-bearing agent task, but its <zeebe:agentDefinition> marker is a sibling
|
|
114
|
+
// of <serviceTask> rather than inside extensionElements, so the engine/harness ignores it — the
|
|
115
|
+
// task is effectively unmarked. A whole-block scan would see the marker "somewhere" and wrongly
|
|
116
|
+
// pass; the placement-scoped scan flags the drift.
|
|
117
|
+
const xml = `
|
|
118
|
+
<bpmn:serviceTask id="misplaced">
|
|
119
|
+
<zeebe:agentDefinition agentType="external" />
|
|
120
|
+
<bpmn:extensionElements>
|
|
121
|
+
<zeebe:taskDefinition type="senior:special" />
|
|
122
|
+
<zeebe:linkedResource resourceId="prompts/x.md" linkName="prompt" />
|
|
123
|
+
</bpmn:extensionElements>
|
|
124
|
+
</bpmn:serviceTask>`;
|
|
125
|
+
assertEquals(agentTaskTypesMissingExternalMarker(xml), ["senior:special"]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("agentTaskTypesOptedOutMissingExternalMarker flags an opt-out on a block lacking the external marker", () => {
|
|
129
|
+
// A host task (no external marker, no prompt link) that carries the opt-out is authoring drift:
|
|
130
|
+
// the block-level check catches it even though `agentTaskTypesMissingExternalMarker` (prompt-only)
|
|
131
|
+
// never reports it.
|
|
132
|
+
const xml = `
|
|
133
|
+
<bpmn:serviceTask id="host">
|
|
134
|
+
<bpmn:extensionElements>
|
|
135
|
+
<zeebe:taskDefinition type="pr.finalize" />
|
|
136
|
+
<zeebe:properties>
|
|
137
|
+
<zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
|
|
138
|
+
</zeebe:properties>
|
|
139
|
+
</bpmn:extensionElements>
|
|
140
|
+
</bpmn:serviceTask>`;
|
|
141
|
+
assertEquals(agentTaskTypesMissingExternalMarker(xml), []);
|
|
142
|
+
assertEquals(agentTaskTypesOptedOutMissingExternalMarker(xml), ["pr.finalize"]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("agentTaskTypesOptedOutMissingExternalMarker passes an opt-out on an externally-marked agent task", () => {
|
|
146
|
+
const xml = `
|
|
147
|
+
<bpmn:serviceTask id="agent">
|
|
148
|
+
<bpmn:extensionElements>
|
|
149
|
+
<zeebe:taskDefinition type="senior:special" />
|
|
150
|
+
<zeebe:agentDefinition agentType="external" />
|
|
151
|
+
<zeebe:properties>
|
|
152
|
+
<zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
|
|
153
|
+
</zeebe:properties>
|
|
154
|
+
</bpmn:extensionElements>
|
|
155
|
+
</bpmn:serviceTask>`;
|
|
156
|
+
assertEquals(agentTaskTypesOptedOutMissingExternalMarker(xml), []);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("agentTaskTypesOptedOutMissingExternalMarker checks each block independently (a marked sibling does not cover a drifted opt-out)", () => {
|
|
160
|
+
// Two tasks share the `senior:special` type: one is a proper externally-marked agent task, the
|
|
161
|
+
// other opts out but lacks the marker. A deduplicated cross-task comparison would miss this; the
|
|
162
|
+
// per-block check flags the unmarked one.
|
|
163
|
+
const xml = `
|
|
164
|
+
<bpmn:serviceTask id="marked">
|
|
165
|
+
<bpmn:extensionElements>
|
|
166
|
+
<zeebe:taskDefinition type="senior:special" />
|
|
167
|
+
<zeebe:agentDefinition agentType="external" />
|
|
168
|
+
</bpmn:extensionElements>
|
|
169
|
+
</bpmn:serviceTask>
|
|
170
|
+
<bpmn:serviceTask id="drifted">
|
|
171
|
+
<bpmn:extensionElements>
|
|
172
|
+
<zeebe:taskDefinition type="senior:special" />
|
|
173
|
+
<zeebe:properties>
|
|
174
|
+
<zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
|
|
175
|
+
</zeebe:properties>
|
|
176
|
+
</bpmn:extensionElements>
|
|
177
|
+
</bpmn:serviceTask>`;
|
|
178
|
+
assertEquals(agentTaskTypesOptedOutMissingExternalMarker(xml), ["senior:special"]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("agentTaskTypesOptedOutMissingExternalMarker flags an opt-out whose external marker sits OUTSIDE extensionElements (placement contract)", () => {
|
|
182
|
+
// The opt-out is correctly placed inside extensionElements, but the external marker is out of place
|
|
183
|
+
// (a sibling of <serviceTask>, not inside extensionElements) so the engine ignores it — the block
|
|
184
|
+
// is therefore NOT a real marked agent task. A whole-block scan would see the marker "somewhere" and
|
|
185
|
+
// wrongly pass; the placement-scoped scan flags the drift.
|
|
186
|
+
const xml = `
|
|
187
|
+
<bpmn:serviceTask id="misplaced">
|
|
188
|
+
<zeebe:agentDefinition agentType="external" />
|
|
189
|
+
<bpmn:extensionElements>
|
|
190
|
+
<zeebe:taskDefinition type="senior:special" />
|
|
191
|
+
<zeebe:properties>
|
|
192
|
+
<zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
|
|
193
|
+
</zeebe:properties>
|
|
194
|
+
</bpmn:extensionElements>
|
|
195
|
+
</bpmn:serviceTask>`;
|
|
196
|
+
assertEquals(agentTaskTypesOptedOutMissingExternalMarker(xml), ["senior:special"]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("agentTaskTypesOptedOutMissingExternalMarker surfaces an opt-out on a block with a missing/empty taskDefinition type", () => {
|
|
200
|
+
// An opt-out on an unmarked block whose <zeebe:taskDefinition> type is empty cannot be a real agent
|
|
201
|
+
// task, so it is still drift — surfaced under the sentinel rather than silently skipped.
|
|
202
|
+
const xml = `
|
|
203
|
+
<bpmn:serviceTask id="typeless">
|
|
204
|
+
<bpmn:extensionElements>
|
|
205
|
+
<zeebe:taskDefinition type="" />
|
|
206
|
+
<zeebe:properties>
|
|
207
|
+
<zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
|
|
208
|
+
</zeebe:properties>
|
|
209
|
+
</bpmn:extensionElements>
|
|
210
|
+
</bpmn:serviceTask>`;
|
|
211
|
+
assertEquals(agentTaskTypesOptedOutMissingExternalMarker(xml), [MALFORMED_OPTOUT_LABEL]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("agentTaskTypesOptedOutMissingExternalMarker surfaces a MARKED opt-out block with a missing/empty taskDefinition type", () => {
|
|
215
|
+
// The block carries BOTH the external marker AND the opt-out, but its <zeebe:taskDefinition> type
|
|
216
|
+
// is empty — so it still cannot be a real agent task. The malformed check must run BEFORE the
|
|
217
|
+
// external-marker short-circuit, or the marker would wrongly let this typeless opt-out pass.
|
|
218
|
+
const xml = `
|
|
219
|
+
<bpmn:serviceTask id="markedTypeless">
|
|
220
|
+
<bpmn:extensionElements>
|
|
221
|
+
<zeebe:taskDefinition type="" />
|
|
222
|
+
<zeebe:agentDefinition agentType="external" />
|
|
223
|
+
<zeebe:properties>
|
|
224
|
+
<zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
|
|
225
|
+
</zeebe:properties>
|
|
226
|
+
</bpmn:extensionElements>
|
|
227
|
+
</bpmn:serviceTask>`;
|
|
228
|
+
assertEquals(agentTaskTypesOptedOutMissingExternalMarker(xml), [MALFORMED_OPTOUT_LABEL]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("GUARD: the deploy-scan walks from the resources/ convention root (not just resources/processes)", () => {
|
|
232
|
+
// Regression for the deploy-by-convention coverage gap: urban deploys `resources/` recursively, so
|
|
233
|
+
// the guard must root its BPMN walk at `resources/` — a `.bpmn` added under any other resources
|
|
234
|
+
// subdirectory must still be scanned. Assert the walk actually reaches the process models AND that
|
|
235
|
+
// every returned path is relative to the resources root (carries its subdirectory segment), so a
|
|
236
|
+
// future refactor that narrows the root back to `resources/processes` is caught here.
|
|
237
|
+
const files = bpmnFiles();
|
|
238
|
+
assert(files.length > 0, "expected the resources/ walk to discover deployed BPMN models");
|
|
239
|
+
assert(
|
|
240
|
+
files.some((f) => f.startsWith("processes/")),
|
|
241
|
+
`expected resources-root-relative paths (e.g. "processes/…"); got ${JSON.stringify(files.slice(0, 3))}`,
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("GUARD: every deployed opted-out task is itself an externally-marked agent task", () => {
|
|
246
|
+
for (const file of bpmnFiles()) {
|
|
247
|
+
const xml = readFileSync(join(RESOURCES_DIR, file), "utf8");
|
|
248
|
+
// Drive the guard DIRECTLY from the block-level, placement-scoped helper rather than gating on
|
|
249
|
+
// `agentTaskTypesOptedOutOfAuto` (which skips missing/empty task-definition types, so a typeless
|
|
250
|
+
// opt-out would never reach the check). The helper scans every service task itself, surfaces a
|
|
251
|
+
// malformed typeless opt-out under the sentinel, and checks the external marker on the SAME
|
|
252
|
+
// block's extensionElements — so a typeless opt-out, an out-of-place property, or a marker that
|
|
253
|
+
// drifted onto a non-agent element all fail CI here.
|
|
254
|
+
const drifted = agentTaskTypesOptedOutMissingExternalMarker(xml);
|
|
255
|
+
assertEquals(
|
|
256
|
+
drifted,
|
|
257
|
+
[],
|
|
258
|
+
`${file}: task(s) ${JSON.stringify(drifted)} opt out of --auto but lack <zeebe:agentDefinition agentType="external" /> on the same block — an opt-out belongs only on a real agent task`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
@@ -48,11 +48,53 @@ export function jobTypeToRoutingToken(jobType: string): string | undefined {
|
|
|
48
48
|
|
|
49
49
|
const SERVICE_TASK = /<(?:\w+:)?serviceTask\b[\s\S]*?<\/(?:\w+:)?serviceTask>/g;
|
|
50
50
|
const TASK_DEFINITION_TYPE = /<(?:\w+:)?taskDefinition\b[^>]*\btype="([^"]*)"/;
|
|
51
|
+
const EXTENSION_ELEMENTS = /<(?:\w+:)?extensionElements\b[\s\S]*?<\/(?:\w+:)?extensionElements>/;
|
|
52
|
+
// The engine only honours a `<zeebe:property>` nested inside a `<zeebe:properties>` wrapper (itself
|
|
53
|
+
// inside `<bpmn:extensionElements>`). A bare `<zeebe:property>` placed directly under
|
|
54
|
+
// `extensionElements` (or `serviceTask`) is ignored, so property-contract scans (the `--auto`
|
|
55
|
+
// opt-out) run against THIS wrapper — a misplaced bare property is treated as absent, exactly as the
|
|
56
|
+
// engine treats it.
|
|
57
|
+
const ZEEBE_PROPERTIES = /<(?:\w+:)?properties\b[\s\S]*?<\/(?:\w+:)?properties>/;
|
|
51
58
|
const PROMPT_LINK = /<(?:\w+:)?linkedResource\b[^>]*\blinkName="prompt"/;
|
|
52
59
|
// The engine-native AgentTask marker (issue #745): a `<zeebe:agentDefinition agentType="external" />`
|
|
53
60
|
// sibling of the `<zeebe:taskDefinition>` inside a `senior:*` agent task's extensionElements. It is
|
|
54
61
|
// what makes the element eligible for engine-native AgentInstance minting by the worker harness.
|
|
55
62
|
const EXTERNAL_AGENT_MARKER = /<(?:\w+:)?agentDefinition\b[^>]*\bagentType="external"/;
|
|
63
|
+
// The `--auto` opt-OUT marker (issue #779): a `<zeebe:property name="io.nanobpm.agentTask.
|
|
64
|
+
// autoSubscribe" value="false" />` sibling inside an agent task's extensionElements. It declares the
|
|
65
|
+
// task is EXCLUDED from the harness `--auto` reconciliation (which keys on EXTERNAL_AGENT_MARKER) and
|
|
66
|
+
// is served only by a worker that explicitly subscribes. The property is inert to the engine. Only
|
|
67
|
+
// the exact `value="false"` opts out — any other value auto-subscribes as normal (fail-safe).
|
|
68
|
+
const AUTO_SUBSCRIBE_OPTOUT =
|
|
69
|
+
/<(?:\w+:)?property\b[^>]*\bname="io\.nanobpm\.agentTask\.autoSubscribe"[^>]*\bvalue="false"|<(?:\w+:)?property\b[^>]*\bvalue="false"[^>]*\bname="io\.nanobpm\.agentTask\.autoSubscribe"/;
|
|
70
|
+
|
|
71
|
+
// The label surfaced for an opted-out, unmarked service task whose `<zeebe:taskDefinition>` is
|
|
72
|
+
// missing or has an empty `type` (issue #779 drift guard). Such a block cannot be a real
|
|
73
|
+
// externally-marked agent task, so the opt-out is drift regardless of the absent type — we surface it
|
|
74
|
+
// under a descriptive sentinel rather than letting the dedupe skip swallow it.
|
|
75
|
+
export const MALFORMED_OPTOUT_LABEL = "(opted-out task with missing/empty taskDefinition type)";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The `<bpmn:extensionElements>…</bpmn:extensionElements>` content of a service-task block, or the
|
|
79
|
+
* empty string when the block has none. Placement-contract scans (issue #745/#779: a marker is only
|
|
80
|
+
* honoured by the engine INSIDE `extensionElements`) run against THIS scope, so a property/marker
|
|
81
|
+
* sitting outside `extensionElements` is treated as absent — the engine ignores it, and so must the
|
|
82
|
+
* guard (an out-of-place external marker cannot "cover" an opt-out).
|
|
83
|
+
*/
|
|
84
|
+
function extensionElementsOf(block: string): string {
|
|
85
|
+
return block.match(EXTENSION_ELEMENTS)?.[0] ?? "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The `<zeebe:properties>…</zeebe:properties>` content nested inside a block's `extensionElements`,
|
|
90
|
+
* or the empty string when absent. The engine only honours `<zeebe:property>` entries INSIDE this
|
|
91
|
+
* wrapper, so the `--auto` opt-out property scan runs against THIS scope — a bare `<zeebe:property>`
|
|
92
|
+
* sitting directly under `extensionElements` (or `serviceTask`) is treated as absent, exactly as the
|
|
93
|
+
* engine treats it, matching the registered/documented shape (`<zeebe:properties>`-nested).
|
|
94
|
+
*/
|
|
95
|
+
function optOutPropertiesOf(block: string): string {
|
|
96
|
+
return extensionElementsOf(block).match(ZEEBE_PROPERTIES)?.[0] ?? "";
|
|
97
|
+
}
|
|
56
98
|
|
|
57
99
|
/**
|
|
58
100
|
* Scan one BPMN document for the job types of its PROMPT-BEARING service tasks — the deployed fleet
|
|
@@ -78,15 +120,18 @@ export function promptBearingTaskTypes(xml: string): string[] {
|
|
|
78
120
|
* engine-native AgentTask marker `<zeebe:agentDefinition agentType="external" />` (issue #745). Every
|
|
79
121
|
* deployed `senior:*` agent task must carry the marker so the worker harness mints an AgentInstance
|
|
80
122
|
* for it; a newly-added agent task that forgets it is a silent drift surface (its run never persists
|
|
81
|
-
* durable AgentHistory), so the regression guard fails CI.
|
|
82
|
-
*
|
|
123
|
+
* durable AgentHistory), so the regression guard fails CI. The marker check is scoped to the block's
|
|
124
|
+
* `<bpmn:extensionElements>` (the engine-honoured PLACEMENT scope, via `extensionElementsOf`) — a
|
|
125
|
+
* marker sitting outside `extensionElements` is ignored by the engine, so it must not "cover" a task
|
|
126
|
+
* here either. Returns the offending task types in first-occurrence order (empty when every agent
|
|
127
|
+
* task is marked).
|
|
83
128
|
*/
|
|
84
129
|
export function agentTaskTypesMissingExternalMarker(xml: string): string[] {
|
|
85
130
|
const seen = new Set<string>();
|
|
86
131
|
const missing: string[] = [];
|
|
87
132
|
for (const [block] of xml.matchAll(SERVICE_TASK)) {
|
|
88
133
|
if (!PROMPT_LINK.test(block)) continue;
|
|
89
|
-
if (EXTERNAL_AGENT_MARKER.test(block)) continue;
|
|
134
|
+
if (EXTERNAL_AGENT_MARKER.test(extensionElementsOf(block))) continue;
|
|
90
135
|
const type = block.match(TASK_DEFINITION_TYPE)?.[1];
|
|
91
136
|
if (type === undefined || type.length === 0 || seen.has(type)) continue;
|
|
92
137
|
seen.add(type);
|
|
@@ -94,3 +139,78 @@ export function agentTaskTypesMissingExternalMarker(xml: string): string[] {
|
|
|
94
139
|
}
|
|
95
140
|
return missing;
|
|
96
141
|
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Scan one BPMN document for the job types of agent service tasks that OPT OUT of the harness
|
|
145
|
+
* `--auto` reconciliation (issue #779) — those carrying `<zeebe:property
|
|
146
|
+
* name="io.nanobpm.agentTask.autoSubscribe" value="false" />` inside their extensionElements. An
|
|
147
|
+
* opted-out task is served ONLY by a worker that explicitly subscribes (`--job-type <type>` / a
|
|
148
|
+
* profile capability), never by `--auto` auto-discovery (which keys on the
|
|
149
|
+
* `<zeebe:agentDefinition agentType="external" />` marker). The property is inert to the engine;
|
|
150
|
+
* only the exact `value="false"` opts out (any other value auto-subscribes, fail-safe). The opt-out
|
|
151
|
+
* scan is scoped to the block's `<zeebe:properties>` wrapper inside `<bpmn:extensionElements>` (the
|
|
152
|
+
* engine-honoured PLACEMENT scope, via `optOutPropertiesOf`) — a bare `<zeebe:property>` sitting
|
|
153
|
+
* directly under `<bpmn:extensionElements>` or `<bpmn:serviceTask>` is ignored by the engine, so it
|
|
154
|
+
* must not be reported as an active opt-out here (this keeps the reader consistent with the
|
|
155
|
+
* placement-scoped drift guard `agentTaskTypesOptedOutMissingExternalMarker`). Returns the
|
|
156
|
+
* distinct opted-out task types in first-occurrence order (empty when no task opts out).
|
|
157
|
+
*/
|
|
158
|
+
export function agentTaskTypesOptedOutOfAuto(xml: string): string[] {
|
|
159
|
+
const seen = new Set<string>();
|
|
160
|
+
const optedOut: string[] = [];
|
|
161
|
+
for (const [block] of xml.matchAll(SERVICE_TASK)) {
|
|
162
|
+
if (!AUTO_SUBSCRIBE_OPTOUT.test(optOutPropertiesOf(block))) continue;
|
|
163
|
+
const type = block.match(TASK_DEFINITION_TYPE)?.[1];
|
|
164
|
+
if (type === undefined || type.length === 0 || seen.has(type)) continue;
|
|
165
|
+
seen.add(type);
|
|
166
|
+
optedOut.push(type);
|
|
167
|
+
}
|
|
168
|
+
return optedOut;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Scan one BPMN document for the job types of service tasks that OPT OUT of `--auto` (issue #779) yet
|
|
173
|
+
* are NOT themselves externally-marked agent tasks — i.e. an opt-out property on a block WITHOUT a
|
|
174
|
+
* sibling `<zeebe:agentDefinition agentType="external" />`. An opt-out only makes sense on a real
|
|
175
|
+
* agent task (one that WOULD otherwise be auto-discovered via its external marker); a marker that has
|
|
176
|
+
* drifted onto a host task (e.g. `pr.finalize`, which carries no external marker) or onto one task
|
|
177
|
+
* that merely shares a `taskDefinition` type with a properly-marked sibling is authoring drift. This
|
|
178
|
+
* checks both markers on the SAME service-task block — the opt-out property inside the block's
|
|
179
|
+
* `<zeebe:properties>` wrapper (via `optOutPropertiesOf`, so a bare misplaced `<zeebe:property>` is
|
|
180
|
+
* ignored just as the engine ignores it) and the external marker inside that block's
|
|
181
|
+
* `<bpmn:extensionElements>` (the engine-honoured PLACEMENT scope), so an out-of-place external
|
|
182
|
+
* marker sitting outside `extensionElements` cannot spuriously "cover" the opt-out — so, unlike
|
|
183
|
+
* comparing the deduplicated `agentTaskTypesOptedOutOfAuto` / `agentTaskTypesMissingExternalMarker`
|
|
184
|
+
* lists (the latter only reports PROMPT-BEARING tasks, so a non-prompt host task's opt-out is
|
|
185
|
+
* invisible to it), the drift cannot hide. An opt-out on a block with a missing/empty
|
|
186
|
+
* `<zeebe:taskDefinition>` type (which likewise cannot be a real agent task) is surfaced under a
|
|
187
|
+
* descriptive sentinel BEFORE the external-marker short-circuit — so even a typeless block that
|
|
188
|
+
* carries the external marker is still flagged as malformed drift. Returns the offending task types
|
|
189
|
+
* in first-occurrence order (empty when every opted-out task is a properly-typed, externally-marked
|
|
190
|
+
* agent task).
|
|
191
|
+
*/
|
|
192
|
+
export function agentTaskTypesOptedOutMissingExternalMarker(xml: string): string[] {
|
|
193
|
+
const seen = new Set<string>();
|
|
194
|
+
const offending: string[] = [];
|
|
195
|
+
for (const [block] of xml.matchAll(SERVICE_TASK)) {
|
|
196
|
+
const ext = extensionElementsOf(block);
|
|
197
|
+
if (!AUTO_SUBSCRIBE_OPTOUT.test(optOutPropertiesOf(block))) continue;
|
|
198
|
+
const type = block.match(TASK_DEFINITION_TYPE)?.[1];
|
|
199
|
+
// A missing/empty type cannot be a real agent task, so an opt-out here is malformed drift
|
|
200
|
+
// REGARDLESS of any external marker — surface it under the sentinel BEFORE the marker
|
|
201
|
+
// short-circuit, so a marked-yet-typeless opt-out is caught rather than passed by the marker.
|
|
202
|
+
if (type === undefined || type.length === 0) {
|
|
203
|
+
if (seen.has(MALFORMED_OPTOUT_LABEL)) continue;
|
|
204
|
+
seen.add(MALFORMED_OPTOUT_LABEL);
|
|
205
|
+
offending.push(MALFORMED_OPTOUT_LABEL);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// A properly-typed opt-out is fine only when the SAME block is externally marked inside its
|
|
209
|
+
// extensionElements (the engine-honoured placement scope).
|
|
210
|
+
if (EXTERNAL_AGENT_MARKER.test(ext)) continue;
|
|
211
|
+
if (seen.has(type)) continue;
|
|
212
|
+
seen.add(type);
|
|
213
|
+
offending.push(type);
|
|
214
|
+
}
|
|
215
|
+
return offending;
|
|
216
|
+
}
|
package/app/contracts.ts
CHANGED
|
@@ -399,7 +399,7 @@ export const WIRE_CONTRACTS = {
|
|
|
399
399
|
name: "io.nanobpm.agentTask.repository",
|
|
400
400
|
owner: "app/repoEnvelope.ts",
|
|
401
401
|
semantics:
|
|
402
|
-
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted
|
|
402
|
+
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted for a single-task feature run AND — per issue #776 — the deterministic `feat/<node.id>` for each single-instance delivery-graph agent cell, injected per-cell by `agentNodeRepoEnvelope`/`app/deliveryRunner.ts` so a forgetful agent can never be left committing on the base branch and stranding its run on a non-ff push; the epic plan-fanout seed still omits it because its MI children each cut a per-child `feat/<task.id>` the app can't name at compile time, so those agents branch themselves). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable) and a `cloneTimeoutMs` (from `NANO_PR_CLONE_TIMEOUT_MS`, default 600000 = 10 min) that raises the harness's 120s default so a large monorepo's blobless single-branch clone provisions instead of dying at 120s (issue #694). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `sha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. The field is named `sha` because that is the field the c8ctl harness's `provisionRepo` reads to drive the checkout — an earlier `commitSha` key was a silent no-op (issue #695). Alongside the `repository` slice the envelope carries a sibling `task.allowPr: true` (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the git credential (GITHUB_TOKEN, or the `gh` default) for repo provisioning behind that flag, so every repo-backed envelope sets it or the clone dies with `unable to get password from user`; the repoless path emits no envelope and so no `task`. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91, branch-cut guard jwulf/c8ctl-plugin-nano#231).",
|
|
403
403
|
shape:
|
|
404
404
|
'io.nanobpm.agentTask: { repository: { provider: "github", url: string, ref?: string, singleBranch: true, filter: "blob:none", cloneTimeoutMs: number, baseRef?: string, sha?: string, branch?: { create: string } }, task: { allowPr: true } }',
|
|
405
405
|
},
|
|
@@ -494,6 +494,14 @@ export const WIRE_CONTRACTS = {
|
|
|
494
494
|
"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.",
|
|
495
495
|
shape: '<zeebe:agentDefinition agentType="external" /> (sibling of <zeebe:taskDefinition> in a senior:* service task\'s extensionElements)',
|
|
496
496
|
},
|
|
497
|
+
"agentTask.autoSubscribe": {
|
|
498
|
+
category: "wire",
|
|
499
|
+
name: "agentTask.autoSubscribe",
|
|
500
|
+
owner: "resources/processes/*.bpmn",
|
|
501
|
+
semantics:
|
|
502
|
+
"The `--auto` opt-OUT marker (issue #779, harness jwulf/c8ctl-plugin-nano#235). The ONE agentic-task signal the harness `--auto` reconciliation scans is `<zeebe:agentDefinition agentType=\"external\" />` (the `agentTask.agentDefinition` marker) — it replaces the legacy `linkName=\"prompt\"` / header dual signal so both sides converge on a single convention. This marker is the escape hatch: a `<zeebe:property name=\"io.nanobpm.agentTask.autoSubscribe\" value=\"false\" />` INSIDE an agent task's `<bpmn:extensionElements>` declares the task is EXCLUDED from `--auto` auto-discovery and is served ONLY by a worker that explicitly subscribes (`--job-type <type>` / a profile capability). Absence of the marker (or any value other than the literal string `\"false\"`) means the task auto-subscribes as normal — opt-out is explicit and fail-safe. The `zeebe:property` is INERT to the engine (no runtime/behaviour change, no migration). Authored in the hand-written BPMN semantic model, NOT the generated `<bpmndi:…>` DI, and survives `npm run layout` untouched. The scan helper `agentTaskTypesOptedOutOfAuto(xml)` in app/agentic/vocab/job-types.ts is the ONE reader of this marker (mirrors `agentTaskTypesMissingExternalMarker`); a CI guard asserts its shape/placement. Use THIS one marker to opt a task out — never a second/synonym opt-out property.",
|
|
503
|
+
shape: '<zeebe:properties><zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" /></zeebe:properties> (the <zeebe:property> nested in a <zeebe:properties> wrapper inside a senior:* agent service task\'s <bpmn:extensionElements> — a bare <zeebe:property> directly under extensionElements is NOT the accepted shape; see SPEC.md)',
|
|
504
|
+
},
|
|
497
505
|
"agentTask.historyRead": {
|
|
498
506
|
category: "wire",
|
|
499
507
|
name: "agentTask.historyRead",
|
|
@@ -1057,15 +1057,19 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>): stri
|
|
|
1057
1057
|
|
|
1058
1058
|
/** Render the DECLARED per-node repository-spec marker task header (#739) for an `agent` node — a single
|
|
1059
1059
|
* `<zeebe:taskHeaders>` block carrying {@link AGENT_REPO_SPEC_HEADER} with a compact JSON of the node's
|
|
1060
|
-
* DECLARED `{ repository, baseBranch }` (each `null` when absent). It is emitted on EVERY
|
|
1061
|
-
* task (even one with no declared repo → `{"repository":null,"baseBranch":null}`) so the
|
|
1062
|
-
* single, uniform anchor to replace with the effective envelope on every cell.
|
|
1063
|
-
*
|
|
1064
|
-
*
|
|
1065
|
-
*
|
|
1060
|
+
* `id` and its DECLARED `{ repository, baseBranch }` (each `null` when absent). It is emitted on EVERY
|
|
1061
|
+
* agent service task (even one with no declared repo → `{"repository":null,"baseBranch":null}`) so the
|
|
1062
|
+
* runner has a single, uniform anchor to replace with the effective envelope on every cell. The node
|
|
1063
|
+
* `id` is carried here (issue #776) so the runner can emit the deterministic per-node `feat/<node.id>`
|
|
1064
|
+
* `branch.create` on the injected envelope — the identity is not otherwise recoverable from the isolated
|
|
1065
|
+
* `<zeebe:taskHeaders>` block the runner rewrites. Digest-stable and env-free — only the node id and
|
|
1066
|
+
* declared values (pure graph content) appear here; the run-level fallback and the env-dependent
|
|
1067
|
+
* `cloneTimeoutMs` are injected by the runner POST-digest. The id + declared values pass the
|
|
1068
|
+
* `owner/repo` + node-id/branch-name allowlists (validator/OpenAPI), so the JSON carries no XML-hostile
|
|
1069
|
+
* chars. */
|
|
1066
1070
|
function agentRepoSpecHeaderLines(node: Extract<DeliveryNode, { kind: "agent" }>): string[] {
|
|
1067
1071
|
const trimOrNull = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null);
|
|
1068
|
-
const spec = JSON.stringify({ repository: trimOrNull(node.agent.repository), baseBranch: trimOrNull(node.agent.baseBranch) });
|
|
1072
|
+
const spec = JSON.stringify({ nodeId: node.id, repository: trimOrNull(node.agent.repository), baseBranch: trimOrNull(node.agent.baseBranch) });
|
|
1069
1073
|
return [
|
|
1070
1074
|
" <zeebe:taskHeaders>",
|
|
1071
1075
|
` <zeebe:header key="${AGENT_REPO_SPEC_HEADER}" ${attr("value", spec)} />`,
|
|
@@ -13,7 +13,7 @@ import { test } from "node:test";
|
|
|
13
13
|
import { assert, assertEquals, assertRejects } from "#test-assert";
|
|
14
14
|
import { AGENT_TERMINAL_SUCCESS_STATUSES } from "./deliveryGraphCompiler.ts";
|
|
15
15
|
import { prepareDeliveryGraph, renderEmitContract, renderIdempotencyPreamble, renderProducerContract, runDeliveryGraph } from "./deliveryRunner.ts";
|
|
16
|
-
import { RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
|
|
16
|
+
import { RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError, agentNodeRepoEnvelope } from "./repoEnvelope.ts";
|
|
17
17
|
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
18
18
|
|
|
19
19
|
const GRAPH: DeliveryGraph = {
|
|
@@ -488,6 +488,9 @@ test("prepareDeliveryGraph injects the repository envelope PER agent cell from t
|
|
|
488
488
|
// Repo-provisioning auth gate (issue #770): the per-cell envelope carries `task.allowPr:true` so the
|
|
489
489
|
// c8ctl harness resolves the git credential for the clone instead of failing on password prompt.
|
|
490
490
|
assert(headers.some((h) => h.includes('task.allowPr" value="true"')), `expected a task.allowPr header, got ${JSON.stringify(headers)}`);
|
|
491
|
+
// The deterministic per-node isolation branch (issue #776): the harness cuts `feat/<node.id>` itself,
|
|
492
|
+
// so the agent can never be left committing on the base branch (a non-ff push that strands the run).
|
|
493
|
+
assert(headers.some((h) => h.includes('repository.branch.create" value="feat/open-b"')), `expected a per-node branch.create header, got ${JSON.stringify(headers)}`);
|
|
491
494
|
// No `__repoSpec` marker survives injection — it is the compiler's digest-stable anchor only.
|
|
492
495
|
assert(!p.bpmn.includes("__repoSpec"), "the __repoSpec marker is fully replaced");
|
|
493
496
|
// No run-root `io.nanobpm.agentTask` variable — the envelope rides headers now, not a run variable.
|
|
@@ -542,6 +545,68 @@ test("a declared repository WITHOUT a base branch omits ref/baseRef — the harn
|
|
|
542
545
|
assert(headers.some((h) => h.includes('repository.url" value="https://github.com/acme/one.git"')), "url present");
|
|
543
546
|
assert(!headers.some((h) => h.includes("repository.ref")), "no ref → clone the repo default branch");
|
|
544
547
|
assert(!headers.some((h) => h.includes("repository.baseRef")), "no baseRef either");
|
|
548
|
+
// Even without a known base, the deterministic isolation branch is still emitted (issue #776): the
|
|
549
|
+
// harness cuts `feat/<node.id>` off the cloned default tip, so the agent never commits on the default.
|
|
550
|
+
assert(headers.some((h) => h.includes('repository.branch.create" value="feat/a"')), `expected branch.create even without a base, got ${JSON.stringify(headers)}`);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("each agent cell gets its OWN deterministic feat/<node.id> branch.create — never a shared branch (#776)", async () => {
|
|
554
|
+
const graph: DeliveryGraph = {
|
|
555
|
+
name: "deterministic isolation branches",
|
|
556
|
+
nodes: [
|
|
557
|
+
{ id: "impl-a", kind: "agent", agent: { jobType: "senior:feature", prompt: "a", repository: "acme/one" } },
|
|
558
|
+
{ id: "impl-b", kind: "agent", agent: { jobType: "senior:feature", prompt: "b", repository: "acme/two" } },
|
|
559
|
+
],
|
|
560
|
+
edges: [{ from: "impl-a", to: "impl-b" }],
|
|
561
|
+
};
|
|
562
|
+
const p = await prepareOk(graph, {});
|
|
563
|
+
const branches = agentHeaders(p.bpmn).filter((h) => h.includes("repository.branch.create"));
|
|
564
|
+
// Two distinct cells → two distinct per-node branches, so no two agents can collide on one branch.
|
|
565
|
+
assert(branches.some((h) => h.includes('value="feat/impl-a"')), `expected feat/impl-a, got ${JSON.stringify(branches)}`);
|
|
566
|
+
assert(branches.some((h) => h.includes('value="feat/impl-b"')), `expected feat/impl-b, got ${JSON.stringify(branches)}`);
|
|
567
|
+
assertEquals(branches.length, 2, "exactly one branch.create per agent cell");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("an explicit repoless run emits NO branch.create — no envelope at all (#776/#729)", async () => {
|
|
571
|
+
const p = await prepareOk(GRAPH, { repoless: true });
|
|
572
|
+
assert(!p.bpmn.includes("repository.branch.create"), "a repoless run carries no isolation branch either");
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("a node id that derives an invalid git ref degrades to NO branch.create — never emits an unusable feat/... (#776)", async () => {
|
|
576
|
+
// Node ids are only constrained by `^[A-Za-z_][A-Za-z0-9_.-]*$`, laxer than git's ref rules: `a..b` and
|
|
577
|
+
// `a.lock` pass id validation but produce ill-formed `feat/...` refs the harness cannot create. The
|
|
578
|
+
// runner must degrade to the pre-#776 agent-cuts-its-own-branch behaviour (no branch.create) for those,
|
|
579
|
+
// not emit a branch the harness will choke on — while a sibling with a valid id still gets its branch.
|
|
580
|
+
const graph: DeliveryGraph = {
|
|
581
|
+
name: "invalid-ref node ids degrade",
|
|
582
|
+
nodes: [
|
|
583
|
+
{ id: "a..b", kind: "agent", agent: { jobType: "senior:feature", prompt: "a", repository: "acme/one" } },
|
|
584
|
+
{ id: "a.lock", kind: "agent", agent: { jobType: "senior:feature", prompt: "b", repository: "acme/two" } },
|
|
585
|
+
{ id: "ok", kind: "agent", agent: { jobType: "senior:feature", prompt: "c", repository: "acme/three" } },
|
|
586
|
+
],
|
|
587
|
+
edges: [{ from: "a..b", to: "a.lock" }, { from: "a.lock", to: "ok" }],
|
|
588
|
+
};
|
|
589
|
+
const p = await prepareOk(graph, {});
|
|
590
|
+
const branches = agentHeaders(p.bpmn).filter((h) => h.includes("repository.branch.create"));
|
|
591
|
+
// Only the valid-id cell keeps a branch.create; the two invalid-ref ids emit none (their agents still
|
|
592
|
+
// provision an isolated clone, they just cut their own branch inside it — the pre-#776 fallback).
|
|
593
|
+
assertEquals(branches.length, 1, `only the valid id keeps a branch.create, got ${JSON.stringify(branches)}`);
|
|
594
|
+
assert(branches[0].includes('value="feat/ok"'), `expected feat/ok, got ${JSON.stringify(branches)}`);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("agentNodeRepoEnvelope emits branch.create off the base when known, off the default when not, and omits it when blank (#776)", () => {
|
|
598
|
+
const withBase = (agentNodeRepoEnvelope("acme/one", "main", "feat/n1") as any)["io.nanobpm.agentTask"].repository;
|
|
599
|
+
assertEquals(withBase.ref, "main", "known base → checked-out ref");
|
|
600
|
+
assertEquals(withBase.branch.create, "feat/n1", "the harness cuts feat/<node.id> off the base");
|
|
601
|
+
const noBase = (agentNodeRepoEnvelope("acme/one", null, "feat/n2") as any)["io.nanobpm.agentTask"].repository;
|
|
602
|
+
assertEquals("ref" in noBase, false, "no base → clone the default branch");
|
|
603
|
+
assertEquals(noBase.branch.create, "feat/n2", "branch.create is still emitted off the default tip");
|
|
604
|
+
// A whitespace-tainted branch is trimmed; a blank/absent one omits the `branch` key (pre-#776 behaviour).
|
|
605
|
+
assertEquals((agentNodeRepoEnvelope("acme/one", "main", " feat/n3 ") as any)["io.nanobpm.agentTask"].repository.branch.create, "feat/n3");
|
|
606
|
+
for (const blank of [null, undefined, "", " "]) {
|
|
607
|
+
const r = (agentNodeRepoEnvelope("acme/one", "main", blank as any) as any)["io.nanobpm.agentTask"].repository;
|
|
608
|
+
assertEquals("branch" in r, false, `expected no branch key for ${JSON.stringify(blank)}`);
|
|
609
|
+
}
|
|
545
610
|
});
|
|
546
611
|
|
|
547
612
|
test("prepareDeliveryGraph seeds NO envelope ONLY on an EXPLICIT repoless run — the conscious opt-out (#729)", async () => {
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
18
18
|
import type { EngineClient } from "@nanobpm/urban";
|
|
19
19
|
import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
|
|
20
20
|
import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
|
|
21
|
+
import { isPlausibleBranchName } from "./baseBranch.ts";
|
|
21
22
|
import { AGENT_REPO_SPEC_HEADER, AGENT_TERMINAL_SUCCESS_STATUSES, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
|
|
22
23
|
import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
|
|
23
24
|
import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, isResolvableRepo, RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
|
|
@@ -80,9 +81,10 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
|
|
|
80
81
|
repository?: string | null;
|
|
81
82
|
/** OPTIONAL run-level base-branch DEFAULT for `agent` nodes that declare a `repository` but no
|
|
82
83
|
* `baseBranch` — the `ref` the harness checks out in the isolated clone (the PRE-PR shape: no PR head
|
|
83
|
-
* exists yet
|
|
84
|
-
*
|
|
85
|
-
*
|
|
84
|
+
* exists yet). Per issue #776 the harness itself cuts the deterministic `feat/<node.id>` branch off
|
|
85
|
+
* this base (emitted as `branch.create`) so a forgetful agent can never be left committing on the base
|
|
86
|
+
* branch; a node with a resolvable repository but no base clones the repo's default branch, so this is
|
|
87
|
+
* a convenience default, not a hard requirement. */
|
|
86
88
|
baseBranch?: string | null;
|
|
87
89
|
/** EXPLICIT opt-out of repository provisioning (issue #729). `true` → the run is dispatched with NO
|
|
88
90
|
* isolation envelope on ANY node (the per-node headers are stripped, the legacy launch-dir behaviour),
|
|
@@ -345,13 +347,28 @@ function injectAgentRepoEnvelopes(bpmn: string, graph: DeliveryGraph, options: D
|
|
|
345
347
|
);
|
|
346
348
|
return bpmn.replace(blockRe, (_full, indent: string, sq: string | undefined, dq: string | undefined, tail: string) => {
|
|
347
349
|
const raw = sq ?? (dq ?? "").replace(/'/g, "'").replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
348
|
-
const declared: { repository: string | null; baseBranch: string | null } = JSON.parse(raw);
|
|
350
|
+
const declared: { nodeId?: string; repository: string | null; baseBranch: string | null } = JSON.parse(raw);
|
|
349
351
|
// `repoless` → strip the block entirely (no isolation envelope, the launch-dir fallback).
|
|
350
352
|
if (repoless) return "";
|
|
351
353
|
const effRepo = trimOrNull(declared.repository) ?? runRepo;
|
|
352
354
|
const effBase = trimOrNull(declared.baseBranch) ?? runBase;
|
|
355
|
+
// The deterministic per-node isolation branch (issue #776): the harness cuts `feat/<node.id>` itself
|
|
356
|
+
// so a cell can never leave its agent committing on the checked-out base branch (a non-ff push that
|
|
357
|
+
// strands the run — merlin job 20974). The node id rides the marker so it survives into this
|
|
358
|
+
// per-block rewrite; delivery-graph agent cells are single-instance, so the static per-node branch
|
|
359
|
+
// never collides across siblings. Omitted when the marker predates the id (a blank id degrades to the
|
|
360
|
+
// pre-#776 agent-cuts-its-own-branch behaviour rather than an ill-formed `feat/`).
|
|
361
|
+
// A graph node id is only constrained by `^[A-Za-z_][A-Za-z0-9_.-]*$` (deliveryGraph.ts), which is
|
|
362
|
+
// laxer than git's ref rules: ids like `a..b`, `a.`, or `a.lock` pass id validation yet produce an
|
|
363
|
+
// ill-formed `feat/...` ref the harness cannot create — turning the isolation guarantee into a
|
|
364
|
+
// launch/checkout failure. Validate the DERIVED branch with the same `isPlausibleBranchName` gate the
|
|
365
|
+
// dispatch doors use and degrade to null (the pre-#776 agent-cuts-its-own-branch behaviour) rather
|
|
366
|
+
// than emit an unusable branch.create.
|
|
367
|
+
const nodeId = trimOrNull(declared.nodeId);
|
|
368
|
+
const derivedBranch = nodeId ? `feat/${nodeId}` : null;
|
|
369
|
+
const branchCreate = derivedBranch !== null && isPlausibleBranchName(derivedBranch) ? derivedBranch : null;
|
|
353
370
|
// The unresolved invariant above guarantees a resolvable repo here on a non-repoless run.
|
|
354
|
-
const envelope = agentNodeRepoEnvelope(effRepo ?? "", effBase);
|
|
371
|
+
const envelope = agentNodeRepoEnvelope(effRepo ?? "", effBase, branchCreate);
|
|
355
372
|
const flat = flattenAgentTaskEnvelope(envelope);
|
|
356
373
|
const headerLines = Object.entries(flat).map(([k, v]) => `${indent} <zeebe:header key="${xmlAttr(k)}" value="${xmlAttr(v)}" />`);
|
|
357
374
|
if (headerLines.length === 0) return "";
|
package/app/repoEnvelope.ts
CHANGED
|
@@ -211,13 +211,26 @@ export function repoEnvelopeVars(
|
|
|
211
211
|
* `ref`): a cross-repo graph node carries only its `owner/repo` (an issue ref names no branch), so when
|
|
212
212
|
* neither the node nor the run declares a base the cell must still provision an isolated clone — of the
|
|
213
213
|
* repository's DEFAULT branch. The harness `provisionRepo` omits `--branch` when `ref` is blank, so a
|
|
214
|
-
* `ref`-less envelope clones the default branch
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
|
|
214
|
+
* `ref`-less envelope clones the default branch. Returns `{}` for a `repo` that is not a plain
|
|
215
|
+
* `owner/repo` (an unresolved cell — the runner's invariant rejects the run before this degrades to a
|
|
216
|
+
* launch-dir share). Blobless single-branch shaping + `cloneTimeoutMs` (issues #287/#694) are emitted on
|
|
217
|
+
* every cell.
|
|
218
|
+
*
|
|
219
|
+
* Deterministic isolation branch (issue #776): `branchCreate` (the per-node `feat/<node.id>`) is emitted
|
|
220
|
+
* as `branch.create` so the harness cuts the isolation branch ITSELF — mirroring the single-feature path
|
|
221
|
+
* (`repoEnvelopeVars`/`feature.ts`) — instead of delegating branch-cutting to agent prompt discipline. A
|
|
222
|
+
* forgetful agent that never branched used to commit on the checked-out BASE branch; its `git push
|
|
223
|
+
* origin <base>` was then rejected non-fast-forward and the whole run's work was stranded (merlin job
|
|
224
|
+
* 20974; harness counterpart jwulf/c8ctl-plugin-nano#231). Emitting `branch.create` makes it impossible
|
|
225
|
+
* for a cell to leave its agent committing on the base. Emitted independently of `ref`: when the base is
|
|
226
|
+
* unknown the harness cuts the branch off the cloned default tip; when known, off `ref`. Omitted for a
|
|
227
|
+
* blank `branchCreate` (falls back to the pre-#776 agent-cuts-its-own-branch behaviour). Delivery-graph
|
|
228
|
+
* agent cells are single-instance, so a static per-node `feat/<node.id>` never collides across siblings
|
|
229
|
+
* (the MI-child collision the issue flags does not arise in the current single-instance cells). */
|
|
230
|
+
export function agentNodeRepoEnvelope(repo: string, base: string | null, branchCreate: string | null = null): Record<string, unknown> {
|
|
219
231
|
if (!isPlainOwnerRepo(repo)) return {};
|
|
220
232
|
const ref = typeof base === "string" && base.trim() !== "" ? base.trim() : null;
|
|
233
|
+
const create = typeof branchCreate === "string" && branchCreate.trim() !== "" ? branchCreate.trim() : null;
|
|
221
234
|
return {
|
|
222
235
|
[AGENT_TASK_NS]: {
|
|
223
236
|
repository: {
|
|
@@ -226,9 +239,12 @@ export function agentNodeRepoEnvelope(repo: string, base: string | null): Record
|
|
|
226
239
|
singleBranch: true,
|
|
227
240
|
filter: "blob:none",
|
|
228
241
|
cloneTimeoutMs: cloneTimeoutMs(),
|
|
229
|
-
// A per-node base (declared, or defaulted from the run level) — the branch the
|
|
242
|
+
// A per-node base (declared, or defaulted from the run level) — the branch the harness cuts its
|
|
230
243
|
// `feat/<node.id>` off. Omitted when unknown so the harness clones the repo's default branch.
|
|
231
244
|
...(ref ? { ref, baseRef: ref } : {}),
|
|
245
|
+
// The deterministic isolation branch the harness cuts (issue #776), so the agent can never be
|
|
246
|
+
// left committing on the checked-out base branch. Off `ref` when known, else off the default tip.
|
|
247
|
+
...(create ? { branch: { create } } : {}),
|
|
232
248
|
},
|
|
233
249
|
// Repo-provisioning auth gate (issue #770): as in `repoEnvelopeVars`, a repo-backed cell must
|
|
234
250
|
// opt the harness into git-credential resolution (`task.allowPr`) or the clone fails with
|
package/openapi.yaml
CHANGED
|
@@ -2112,8 +2112,9 @@ components:
|
|
|
2112
2112
|
pattern: '^(?![/.-])(?!.*[/.]$)(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*\.lock(?:/|$))[A-Za-z0-9._/-]+$'
|
|
2113
2113
|
description: >-
|
|
2114
2114
|
OPTIONAL per-node base branch this agent node branches off (#739) — the `ref` the
|
|
2115
|
-
harness checks out in the isolated clone (the pre-PR shape: the
|
|
2116
|
-
`feat/<node.id>` branch off this base
|
|
2115
|
+
harness checks out in the isolated clone (the pre-PR shape: the harness cuts the
|
|
2116
|
+
deterministic `feat/<node.id>` branch off this base itself, per #776, so a forgetful
|
|
2117
|
+
agent can never be left committing on the base branch). Absent → the run-level dispatch `baseBranch`
|
|
2117
2118
|
(else the node's repository default branch). A value that is not a plausible git
|
|
2118
2119
|
branch name is rejected at submit; the pattern mirrors the authoritative server-side
|
|
2119
2120
|
gate (`isPlausibleBranchName`, app/baseBranch.ts).
|
|
@@ -5005,7 +5006,7 @@ paths:
|
|
|
5005
5006
|
type: string
|
|
5006
5007
|
maxLength: 255
|
|
5007
5008
|
pattern: ^(?![/.-])(?!.*[/.]$)(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*\.lock(?:/|$))[A-Za-z0-9._/-]+$
|
|
5008
|
-
description: "OPTIONAL per-node base branch this agent node branches off (#739) — the `ref` the harness checks out in the isolated clone (the pre-PR shape: the
|
|
5009
|
+
description: "OPTIONAL per-node base branch this agent node branches off (#739) — the `ref` the harness checks out in the isolated clone (the pre-PR shape: the harness cuts the deterministic `feat/<node.id>` branch off this base itself, per #776, so a forgetful agent can never be left committing on the base branch). Absent → the run-level dispatch `baseBranch` (else the node's repository default branch). A value that is not a plausible git branch name is rejected at submit; the pattern mirrors the authoritative server-side gate (`isPlausibleBranchName`, app/baseBranch.ts)."
|
|
5009
5010
|
converge:
|
|
5010
5011
|
type: boolean
|
|
5011
5012
|
description: 'OPTIONAL first-class CONVERGE policy (ADR 0006 §3 / S5) — a DECLARED, compiler- validated completion-policy flag on this cell node. It declares that the node''s opened PR is to be driven through the review-convergence loop to green as an edge-gated completion policy; this slice adds and validates the flag, with the delivery-graph execution wiring that consumes it landing in a follow-up slice. It supersedes (in intent) the emergent `feature.bpmn` `gw-converge` gateway and the "un-draft + merge #B" prompt prose a delivery-graph `agent` node used to smuggle. Converge and merge are SEPARABLE phases; a node may converge without merging (stop at green and gate the landing behind a downstream node).'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.187.1",
|
|
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",
|