@nanobpm/nano-workforce 0.201.3 → 0.201.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +11 -0
- package/AGENTS.md +3 -1
- package/CHANGELOG.md +6 -0
- package/SPEC.md +19 -5
- package/app/agentic/vocab/agent-marker.test.ts +30 -1
- package/app/agentic/vocab/job-types.test.ts +133 -1
- package/app/agentic/vocab/job-types.ts +57 -0
- package/app/contracts.ts +2 -2
- package/app/deliveryGraphCompiler.test.ts +81 -0
- package/app/deliveryGraphCompiler.ts +22 -1
- package/package.json +2 -1
- package/scripts/check-agent-definitions.test.ts +126 -0
- package/scripts/check-agent-definitions.ts +191 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -58,6 +58,17 @@ jobs:
|
|
|
58
58
|
- name: Check agent prompt templates
|
|
59
59
|
run: npm run check:prompts
|
|
60
60
|
|
|
61
|
+
# Agent-definition gate (issue #884): every `senior:*` agent service task the app deploys —
|
|
62
|
+
# hand-authored in resources/processes/*.bpmn OR produced by the delivery-graph compiler — must
|
|
63
|
+
# carry `<zeebe:agentDefinition agentType="external"/>`, or the engine refuses to mint an
|
|
64
|
+
# AgentInstance for it and the run persists no durable agentic transcript (the 269 failed
|
|
65
|
+
# creates on omarchy). Compiled agent cells are deliberately prompt-LESS (the resolved #884
|
|
66
|
+
# "Decide", Option (a)): the compiler emits the marker on EVERY agent cell unconditionally, and
|
|
67
|
+
# the gate keys agent-ness on the `<rank>:<task>` colon-form type (the type-keyed scanner), so a
|
|
68
|
+
# compiled cell that drops the marker fails here, not silently at runtime.
|
|
69
|
+
- name: Check agent task definitions (external marker)
|
|
70
|
+
run: npm run check:agent-definitions
|
|
71
|
+
|
|
61
72
|
# Merge-safety gate: migrations are applied in numeric-prefix order, but each fan-out branch
|
|
62
73
|
# picks "the next" prefix independently, so parallel siblings silently collide on one slot
|
|
63
74
|
# (the historical 004/005/006/007 pairs; recurred on epic #142). This fails the build on any
|
package/AGENTS.md
CHANGED
|
@@ -393,13 +393,15 @@ npm run typecheck # tsc --noEmit (Node)
|
|
|
393
393
|
npm run check # urban check (manifest validation)
|
|
394
394
|
npm run layout:check # BPMN diagram freshness (no drift)
|
|
395
395
|
npm run check:prompts # agent-prompt linkedResource resolution
|
|
396
|
+
npm run check:agent-definitions # every senior:* agent task (authored corpus + compiled delivery-graph output) carries the agentDefinition marker
|
|
396
397
|
npm run check:migrations # migration prefixes + immutability (no rename/delete/edit of a merged migration)
|
|
397
398
|
npm run check:contracts # contract registry (no synonyms / undeclared env keys)
|
|
398
399
|
npm test # unit tests (node --test)
|
|
399
400
|
```
|
|
400
401
|
|
|
401
402
|
CI (`.github/workflows/ci.yml`) gates lint, typecheck, `urban check`, `layout:check`,
|
|
402
|
-
the prompt check, the
|
|
403
|
+
the prompt check, the agent-definition check, the migration-prefix check, the
|
|
404
|
+
contract-registry check, and the Node test suite. Run `npm run layout <file.bpmn>` after
|
|
403
405
|
any BPMN flow change and commit the regenerated diagram — the `layout:check`
|
|
404
406
|
gate fails the build otherwise.
|
|
405
407
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.201.4](https://github.com/nanobpm/nano-workforce/compare/v0.201.3...v0.201.4) (2026-10-11)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **delivery-graph:** emit zeebe:agentDefinition marker on every compiled agent cell (prompt-less, Option (a)) ([#886](https://github.com/nanobpm/nano-workforce/issues/886)) ([1e4fa4f](https://github.com/nanobpm/nano-workforce/commit/1e4fa4f46aad542b2e6fc175a3af0b46d0980f50)), closes [#464](https://github.com/nanobpm/nano-workforce/issues/464) [#745](https://github.com/nanobpm/nano-workforce/issues/745) [#884](https://github.com/nanobpm/nano-workforce/issues/884) [#884](https://github.com/nanobpm/nano-workforce/issues/884) [#776](https://github.com/nanobpm/nano-workforce/issues/776) [#884](https://github.com/nanobpm/nano-workforce/issues/884) [#884](https://github.com/nanobpm/nano-workforce/issues/884) [#884](https://github.com/nanobpm/nano-workforce/issues/884)
|
|
6
|
+
|
|
1
7
|
## [0.201.3](https://github.com/nanobpm/nano-workforce/compare/v0.201.2...v0.201.3) (2026-10-10)
|
|
2
8
|
|
|
3
9
|
### Bug Fixes
|
package/SPEC.md
CHANGED
|
@@ -646,11 +646,25 @@ the app, which deploys on boot), and the next agent job of that type picks it up
|
|
|
646
646
|
> marker is CI-enforced over the deployed `resources/` process models
|
|
647
647
|
> (`agentTaskTypesMissingExternalMarker`,
|
|
648
648
|
> `agent-marker.test.ts`), so no prompt-bearing agent task in those models relies on
|
|
649
|
-
> prompt-link-only discovery.
|
|
650
|
-
> deployed at run time by `runDeliveryGraph`, not authored under `resources/` — is
|
|
651
|
-
>
|
|
652
|
-
>
|
|
653
|
-
>
|
|
649
|
+
> prompt-link-only discovery. The delivery-graph compiler's GENERATED agent BPMN —
|
|
650
|
+
> deployed at run time by `runDeliveryGraph`, not authored under `resources/` — is the
|
|
651
|
+
> second producer of agent service tasks, and it is covered too (issue #884): the
|
|
652
|
+
> compiler emits the marker on **every** `agent` cell it compiles. Per the resolved
|
|
653
|
+
> "Decide" half of #884 (Option (a), PR #886 review) a compiled cell carries **no**
|
|
654
|
+
> base-prompt link — the hand-authored process prompts (`prompts/feature.md`, …) are
|
|
655
|
+
> process-specific contracts that require process variables (`variables.task`/
|
|
656
|
+
> `variables.issue`) and a `feat/<task.id>` branch the generic compiled cell never
|
|
657
|
+
> supplies (it seeds only `{jobType, appendPrompt, timeout}` and the harness provisions
|
|
658
|
+
> `feat/<node.id>`, #776), so linking one would hand the cell instructions referencing
|
|
659
|
+
> missing inputs and a conflicting branch. The cell is deliberately prompt-less, steered
|
|
660
|
+
> by its per-node `appendPrompt` alone; the marker alone fixes the #884
|
|
661
|
+
> `createAgentInstance` failure. `npm run check:agent-definitions`
|
|
662
|
+
> (CI gate `check:agent-definitions`, `scripts/check-agent-definitions.ts`) compiles a
|
|
663
|
+
> representative graph — including a prompt-less probe cell — and scans the
|
|
664
|
+
> output with both the prompt-keyed scanner and the prompt-link-independent
|
|
665
|
+
> `externalAgentTaskTypesMissingMarker` (a task is an agent by its `<rank>:<task>`
|
|
666
|
+
> colon-form type alone), so a compiler regression that drops the marker fails the
|
|
667
|
+
> build rather than silently at runtime. To **exclude** a task from `--auto` — one that must be served only by a
|
|
654
668
|
> worker that explicitly subscribes (`--job-type <type>` / a profile capability) — add
|
|
655
669
|
> the inert opt-out property inside its `extensionElements`, nested in the
|
|
656
670
|
> `<zeebe:properties>` wrapper the models and engine expect (as
|
|
@@ -9,7 +9,8 @@ import { test } from "node:test";
|
|
|
9
9
|
import { dirname, join, relative } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { assert, assertEquals } from "#test-assert";
|
|
12
|
-
import {
|
|
12
|
+
import { compileDeliveryGraph } from "../../deliveryGraphCompiler.ts";
|
|
13
|
+
import { agentTaskTypesMissingExternalMarker, missingExternalMarkerAgentTaskTypes, promptBearingTaskTypes } from "./job-types.ts";
|
|
13
14
|
|
|
14
15
|
const PROCESSES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../resources/processes");
|
|
15
16
|
|
|
@@ -72,3 +73,31 @@ test("DEFECT-CLASS GUARD: every deployed prompt-bearing agent task carries the e
|
|
|
72
73
|
// Sanity: the models really do declare agent tasks (guard is not vacuously green).
|
|
73
74
|
assert(anyAgentTasks, "the deployed models declare prompt-bearing agent tasks");
|
|
74
75
|
});
|
|
76
|
+
|
|
77
|
+
test("DEFECT-CLASS GUARD (compiler output, issue #884): every COMPILED delivery-graph agent cell carries the marker", async () => {
|
|
78
|
+
// The hand-authored corpus above is only HALF the class — the delivery-graph COMPILER
|
|
79
|
+
// (`app/deliveryGraphCompiler.ts`) is the other producer of agent service tasks, and it emitted
|
|
80
|
+
// cells with NO `agentDefinition` until #884: every delivery-graph agent run failed AgentInstance
|
|
81
|
+
// creation (`… has no agentDefinition`) and persisted no durable transcript (269 failed creates on
|
|
82
|
+
// omarchy). Per the resolved "Decide" half of #884 (Option (a), PR #886 review) a compiled cell is
|
|
83
|
+
// prompt-LESS (no base-prompt link), so the prompt-keyed scanner cannot see it — it is the
|
|
84
|
+
// type-keyed half of the union scanner (`missingExternalMarkerAgentTaskTypes`, keyed by the
|
|
85
|
+
// `<rank>:<task>` colon-form type) that sees these cells. Apply the SAME union to the compiler's
|
|
86
|
+
// OUTPUT so a compiler regression that drops the marker fails here — not silently at runtime.
|
|
87
|
+
const r = await compileDeliveryGraph({
|
|
88
|
+
nodes: [
|
|
89
|
+
{ id: "impl", kind: "agent", agent: { jobType: "senior:feature", prompt: "implement #1" }, emits: [{ name: "pr", type: "pr" }] },
|
|
90
|
+
{ id: "review", kind: "agent", agent: { jobType: "senior:pr-review" } },
|
|
91
|
+
],
|
|
92
|
+
edges: [{ from: "impl", to: "review" }],
|
|
93
|
+
});
|
|
94
|
+
assert(r.ok, `expected the graph to compile, got ${JSON.stringify(r)}`);
|
|
95
|
+
// The compiled agent cells are prompt-LESS (Option (a)), so the prompt-keyed scan sees none — the
|
|
96
|
+
// union's type-keyed half is what covers them.
|
|
97
|
+
assertEquals(promptBearingTaskTypes(r.bpmn), []);
|
|
98
|
+
const missing = missingExternalMarkerAgentTaskTypes(r.bpmn);
|
|
99
|
+
assert(
|
|
100
|
+
missing.length === 0,
|
|
101
|
+
`compiled delivery-graph agent cell(s) missing <zeebe:agentDefinition agentType="external" /> — ${missing.join(", ")}`,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
@@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import { assert, assertEquals } from "#test-assert";
|
|
10
10
|
import type { Capability } from "@nanobpm/agentic/protocol";
|
|
11
11
|
import { crewResolver } from "./crew-vocab.ts";
|
|
12
|
-
import { jobTypeToRoutingToken, promptBearingTaskTypes } from "./job-types.ts";
|
|
12
|
+
import { externalAgentTaskTypesMissingMarker, jobTypeToRoutingToken, missingExternalMarkerAgentTaskTypes, promptBearingTaskTypes } from "./job-types.ts";
|
|
13
13
|
|
|
14
14
|
const PROCESSES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../resources/processes");
|
|
15
15
|
|
|
@@ -105,3 +105,135 @@ test("DEFECT-CLASS GUARD: every deployed prompt-bearing agent job type resolves
|
|
|
105
105
|
);
|
|
106
106
|
}
|
|
107
107
|
});
|
|
108
|
+
|
|
109
|
+
test("externalAgentTaskTypesMissingMarker flags an UNMAPPED (prompt-less) agent task with no marker", () => {
|
|
110
|
+
// The prompt-keyed scanner (`agentTaskTypesMissingExternalMarker`) cannot see this block — it has no
|
|
111
|
+
// prompt link. The marker scan must key on the colon-form job type ALONE (issue #886 review): a
|
|
112
|
+
// compiled delivery-graph cell for a bespoke `senior:<task>` (no deployed base prompt, deliberately
|
|
113
|
+
// prompt-less) must still carry the marker or it runs transcript-less.
|
|
114
|
+
const xml = `
|
|
115
|
+
<bpmn:serviceTask id="agent">
|
|
116
|
+
<bpmn:extensionElements>
|
|
117
|
+
<zeebe:taskDefinition type="senior:bespoke" />
|
|
118
|
+
</bpmn:extensionElements>
|
|
119
|
+
</bpmn:serviceTask>`;
|
|
120
|
+
assertEquals(externalAgentTaskTypesMissingMarker(xml), ["senior:bespoke"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("externalAgentTaskTypesMissingMarker passes a marked prompt-less agent task and ignores a plain host task", () => {
|
|
124
|
+
const xml = `
|
|
125
|
+
<bpmn:serviceTask id="host">
|
|
126
|
+
<bpmn:extensionElements>
|
|
127
|
+
<zeebe:taskDefinition type="pr.finalize" />
|
|
128
|
+
</bpmn:extensionElements>
|
|
129
|
+
</bpmn:serviceTask>
|
|
130
|
+
<bpmn:serviceTask id="agent">
|
|
131
|
+
<bpmn:extensionElements>
|
|
132
|
+
<zeebe:taskDefinition type="senior:bespoke" />
|
|
133
|
+
<zeebe:agentDefinition agentType="external" />
|
|
134
|
+
</bpmn:extensionElements>
|
|
135
|
+
</bpmn:serviceTask>`;
|
|
136
|
+
assertEquals(externalAgentTaskTypesMissingMarker(xml), []);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("externalAgentTaskTypesMissingMarker flags a marked-outside-extensionElements task (placement contract)", () => {
|
|
140
|
+
// A marker sitting OUTSIDE `<bpmn:extensionElements>` is ignored by the engine, so it must not
|
|
141
|
+
// "cover" the task here either (the same placement scope the prompt-keyed scanner enforces).
|
|
142
|
+
const xml = `
|
|
143
|
+
<bpmn:serviceTask id="agent">
|
|
144
|
+
<zeebe:agentDefinition agentType="external" />
|
|
145
|
+
<bpmn:extensionElements>
|
|
146
|
+
<zeebe:taskDefinition type="senior:bespoke" />
|
|
147
|
+
</bpmn:extensionElements>
|
|
148
|
+
</bpmn:serviceTask>`;
|
|
149
|
+
assertEquals(externalAgentTaskTypesMissingMarker(xml), ["senior:bespoke"]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("externalAgentTaskTypesMissingMarker ignores malformed/non-agent types (no false positives)", () => {
|
|
153
|
+
// Only a `<rank>:<task>` colon-form type (the `jobTypeToRoutingToken` grammar) keys a block as an
|
|
154
|
+
// agent task: a dot-form host job, a typeless block, and a malformed colon form are all out of
|
|
155
|
+
// scope — the scanner must not flag them as unmarked agents.
|
|
156
|
+
const xml = `
|
|
157
|
+
<bpmn:serviceTask id="host1">
|
|
158
|
+
<bpmn:extensionElements>
|
|
159
|
+
<zeebe:taskDefinition type="pr.finalize" />
|
|
160
|
+
</bpmn:extensionElements>
|
|
161
|
+
</bpmn:serviceTask>
|
|
162
|
+
<bpmn:serviceTask id="host2">
|
|
163
|
+
<bpmn:extensionElements>
|
|
164
|
+
<zeebe:taskDefinition type="senior:feature:extra" />
|
|
165
|
+
</bpmn:extensionElements>
|
|
166
|
+
</bpmn:serviceTask>
|
|
167
|
+
<bpmn:serviceTask id="host3">
|
|
168
|
+
<bpmn:extensionElements>
|
|
169
|
+
<zeebe:taskDefinition type="" />
|
|
170
|
+
</bpmn:extensionElements>
|
|
171
|
+
</bpmn:serviceTask>
|
|
172
|
+
<bpmn:serviceTask id="host4">
|
|
173
|
+
<bpmn:extensionElements>
|
|
174
|
+
</bpmn:extensionElements>
|
|
175
|
+
</bpmn:serviceTask>`;
|
|
176
|
+
assertEquals(externalAgentTaskTypesMissingMarker(xml), []);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("missingExternalMarkerAgentTaskTypes unions BOTH scanners — catches a prompt-bearing AND a prompt-less unmarked agent task", () => {
|
|
180
|
+
// The single "every agent task carries the external marker" invariant (PR #886 review): a
|
|
181
|
+
// prompt-BEARING senior:* task that loses its marker is caught only by the prompt-keyed scanner,
|
|
182
|
+
// while a prompt-LESS senior:* task that loses its marker is caught only by the type-keyed scanner.
|
|
183
|
+
// Scanning by just one leaves the other's blind spot open, so the union must flag BOTH in one pass.
|
|
184
|
+
const xml = `
|
|
185
|
+
<bpmn:serviceTask id="prompted">
|
|
186
|
+
<bpmn:extensionElements>
|
|
187
|
+
<zeebe:taskDefinition type="senior:feature" />
|
|
188
|
+
<zeebe:linkedResources>
|
|
189
|
+
<zeebe:linkedResource resourceId="prompts/feature.md" linkName="prompt" />
|
|
190
|
+
</zeebe:linkedResources>
|
|
191
|
+
</bpmn:extensionElements>
|
|
192
|
+
</bpmn:serviceTask>
|
|
193
|
+
<bpmn:serviceTask id="bespoke">
|
|
194
|
+
<bpmn:extensionElements>
|
|
195
|
+
<zeebe:taskDefinition type="senior:bespoke" />
|
|
196
|
+
</bpmn:extensionElements>
|
|
197
|
+
</bpmn:serviceTask>`;
|
|
198
|
+
assertEquals(missingExternalMarkerAgentTaskTypes(xml), ["senior:feature", "senior:bespoke"]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("missingExternalMarkerAgentTaskTypes passes when every agent task is marked and ignores host tasks", () => {
|
|
202
|
+
const xml = `
|
|
203
|
+
<bpmn:serviceTask id="host">
|
|
204
|
+
<bpmn:extensionElements>
|
|
205
|
+
<zeebe:taskDefinition type="pr.finalize" />
|
|
206
|
+
</bpmn:extensionElements>
|
|
207
|
+
</bpmn:serviceTask>
|
|
208
|
+
<bpmn:serviceTask id="prompted">
|
|
209
|
+
<bpmn:extensionElements>
|
|
210
|
+
<zeebe:taskDefinition type="senior:feature" />
|
|
211
|
+
<zeebe:agentDefinition agentType="external" />
|
|
212
|
+
<zeebe:linkedResources>
|
|
213
|
+
<zeebe:linkedResource resourceId="prompts/feature.md" linkName="prompt" />
|
|
214
|
+
</zeebe:linkedResources>
|
|
215
|
+
</bpmn:extensionElements>
|
|
216
|
+
</bpmn:serviceTask>
|
|
217
|
+
<bpmn:serviceTask id="bespoke">
|
|
218
|
+
<bpmn:extensionElements>
|
|
219
|
+
<zeebe:taskDefinition type="senior:bespoke" />
|
|
220
|
+
<zeebe:agentDefinition agentType="external" />
|
|
221
|
+
</bpmn:extensionElements>
|
|
222
|
+
</bpmn:serviceTask>`;
|
|
223
|
+
assertEquals(missingExternalMarkerAgentTaskTypes(xml), []);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("missingExternalMarkerAgentTaskTypes de-duplicates a type caught by both scanners", () => {
|
|
227
|
+
// A prompt-bearing task missing its marker is seen by BOTH the prompt-keyed and the type-keyed
|
|
228
|
+
// scanner; the union must report it ONCE, not twice.
|
|
229
|
+
const xml = `
|
|
230
|
+
<bpmn:serviceTask id="prompted">
|
|
231
|
+
<bpmn:extensionElements>
|
|
232
|
+
<zeebe:taskDefinition type="senior:feature" />
|
|
233
|
+
<zeebe:linkedResources>
|
|
234
|
+
<zeebe:linkedResource resourceId="prompts/feature.md" linkName="prompt" />
|
|
235
|
+
</zeebe:linkedResources>
|
|
236
|
+
</bpmn:extensionElements>
|
|
237
|
+
</bpmn:serviceTask>`;
|
|
238
|
+
assertEquals(missingExternalMarkerAgentTaskTypes(xml), ["senior:feature"]);
|
|
239
|
+
});
|
|
@@ -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>",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.201.
|
|
3
|
+
"version": "0.201.4",
|
|
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",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Red/green coverage for the agent-definition repo-wide guard (scripts/check-agent-definitions.ts,
|
|
2
|
+
// issue #884). The gate fails the build when any `senior:*` agent service task — hand-authored in
|
|
3
|
+
// `resources/processes/*.bpmn` OR produced by the delivery-graph compiler — lacks the engine-native
|
|
4
|
+
// `<zeebe:agentDefinition agentType="external" />` marker (the omission that left every delivery-graph
|
|
5
|
+
// agent run without a durable transcript). These cases assert the scanner wiring flags an unmarked
|
|
6
|
+
// prompt-bearing task and that the gate's compiled-output half sees a real compiled agent cell.
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { assert, assertEquals } from "#test-assert";
|
|
12
|
+
import {
|
|
13
|
+
agentTaskTypesMissingExternalMarker,
|
|
14
|
+
externalAgentTaskTypesMissingMarker,
|
|
15
|
+
} from "../app/agentic/vocab/job-types.ts";
|
|
16
|
+
import { checkAuthoredProcesses, checkCompiledOutput } from "./check-agent-definitions.ts";
|
|
17
|
+
|
|
18
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
|
+
|
|
20
|
+
test("check-agent-definitions: the canonical scanner flags an unmarked prompt-bearing senior:* task", () => {
|
|
21
|
+
// The gate's red path rests entirely on the canonical scanner — pin that wiring here so the gate
|
|
22
|
+
// can never rot into a vacuous green (a scanner that stopped flagging would pass every corpus).
|
|
23
|
+
const xml = `
|
|
24
|
+
<bpmn:serviceTask id="agent">
|
|
25
|
+
<bpmn:extensionElements>
|
|
26
|
+
<zeebe:taskDefinition type="senior:feature" />
|
|
27
|
+
<zeebe:linkedResources>
|
|
28
|
+
<zeebe:linkedResource resourceId="prompts/feature.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
29
|
+
</zeebe:linkedResources>
|
|
30
|
+
</bpmn:extensionElements>
|
|
31
|
+
</bpmn:serviceTask>`;
|
|
32
|
+
assertEquals(agentTaskTypesMissingExternalMarker(xml), ["senior:feature"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("check-agent-definitions: the hand-authored corpus passes (every senior:* task is marked)", () => {
|
|
36
|
+
const r = checkAuthoredProcesses();
|
|
37
|
+
assert(r.ok, `expected the authored corpus to pass, got ${JSON.stringify(r.errors)}`);
|
|
38
|
+
// Sanity: the corpus really does declare agent tasks (the guard is not vacuously green).
|
|
39
|
+
assert(r.agentTypes.length > 0, "the authored corpus declares prompt-bearing senior:* agent tasks");
|
|
40
|
+
assert(r.agentTypes.includes("senior:feature"), "senior:feature is among the scanned agent types");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("check-agent-definitions: the compiled-output half sees a real compiled agent cell and finds it marked", async () => {
|
|
44
|
+
const r = await checkCompiledOutput();
|
|
45
|
+
assert(r.ok, `expected the compiled output to pass, got ${JSON.stringify(r.errors)}`);
|
|
46
|
+
// Every compiled agent cell is prompt-LESS (Option (a)), so it is the type-keyed half of the union
|
|
47
|
+
// scanner (keyed by the `<rank>:<task>` colon-form type) that sees them. The probe's agent types are
|
|
48
|
+
// derived by that same grammar — assert the cells really compiled (the scan is not vacuously green).
|
|
49
|
+
assert(r.agentTypes.length > 0, "the compiled probe declares senior:* agent cells");
|
|
50
|
+
assert(r.agentTypes.includes("senior:feature"), "the compiled senior:feature cell is scanned");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("check-agent-definitions: the compiled probe includes a prompt-LESS agent cell, and it is marked", async () => {
|
|
54
|
+
// Every compiled agent cell is deliberately prompt-less (the resolved #884 "Decide", Option (a)), so
|
|
55
|
+
// the prompt-keyed scanner never sees it — the gate must probe one and assert its marker with the
|
|
56
|
+
// type-keyed scan, or a compiler regression that drops the marker on exactly those cells reports ok
|
|
57
|
+
// (the false-negative path from the PR #886 review).
|
|
58
|
+
const r = await checkCompiledOutput();
|
|
59
|
+
assert(r.ok, `expected the compiled output to pass, got ${JSON.stringify(r.errors)}`);
|
|
60
|
+
assert(
|
|
61
|
+
r.agentTypes.includes("senior:bespoke-promptless"),
|
|
62
|
+
`the compiled probe includes a prompt-less agent cell, got [${r.agentTypes.join(", ")}]`,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("check-agent-definitions: the type-keyed scanner flags a prompt-less compiled cell that lost its marker", () => {
|
|
67
|
+
// Pin the false-negative shape the review cited: a prompt-less agent task with NO marker. The
|
|
68
|
+
// prompt-keyed scan reports nothing (it cannot see the block); the type-keyed scan must flag it.
|
|
69
|
+
const xml = `
|
|
70
|
+
<bpmn:serviceTask id="n0_task">
|
|
71
|
+
<bpmn:extensionElements>
|
|
72
|
+
<zeebe:taskDefinition type="senior:bespoke-promptless" />
|
|
73
|
+
</bpmn:extensionElements>
|
|
74
|
+
</bpmn:serviceTask>`;
|
|
75
|
+
assertEquals(agentTaskTypesMissingExternalMarker(xml), []);
|
|
76
|
+
assertEquals(externalAgentTaskTypesMissingMarker(xml), ["senior:bespoke-promptless"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("check-agent-definitions: the authored half unions BOTH scanners — a prompt-less + marker-less senior:* task fails", () => {
|
|
80
|
+
// The authored-process half must hold the SAME "every senior:* task carries the marker" invariant
|
|
81
|
+
// the compiled half does (PR #886 review). A hand-authored senior:* task that loses BOTH its prompt
|
|
82
|
+
// link and its marker is invisible to the prompt-keyed scanner, so a prompt-keyed-only authored gate
|
|
83
|
+
// reports ok — the false-negative the compiled half already closes. Inject a doc with exactly that
|
|
84
|
+
// shape and assert the gate flags it (via the type-keyed half of the union).
|
|
85
|
+
const bad = `
|
|
86
|
+
<bpmn:serviceTask id="agent">
|
|
87
|
+
<bpmn:extensionElements>
|
|
88
|
+
<zeebe:taskDefinition type="senior:bespoke-promptless" />
|
|
89
|
+
</bpmn:extensionElements>
|
|
90
|
+
</bpmn:serviceTask>`;
|
|
91
|
+
const r = checkAuthoredProcesses([{ file: "bad.bpmn", xml: bad }]);
|
|
92
|
+
assert(!r.ok, "a prompt-less + marker-less senior:* authored task must fail the gate");
|
|
93
|
+
assert(
|
|
94
|
+
r.errors.some((e) => e.includes("bad.bpmn") && e.includes("senior:bespoke-promptless")),
|
|
95
|
+
`the error names the unmarked prompt-less task, got ${JSON.stringify(r.errors)}`,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("check-agent-definitions: the authored half still flags a prompt-BEARING task that lost its marker", () => {
|
|
100
|
+
const bad = `
|
|
101
|
+
<bpmn:serviceTask id="agent">
|
|
102
|
+
<bpmn:extensionElements>
|
|
103
|
+
<zeebe:taskDefinition type="senior:feature" />
|
|
104
|
+
<zeebe:linkedResources>
|
|
105
|
+
<zeebe:linkedResource resourceId="prompts/feature.md" linkName="prompt" />
|
|
106
|
+
</zeebe:linkedResources>
|
|
107
|
+
</bpmn:extensionElements>
|
|
108
|
+
</bpmn:serviceTask>`;
|
|
109
|
+
const r = checkAuthoredProcesses([{ file: "bad.bpmn", xml: bad }]);
|
|
110
|
+
assert(!r.ok, "a prompt-bearing senior:* authored task without a marker must fail the gate");
|
|
111
|
+
assert(
|
|
112
|
+
r.errors.some((e) => e.includes("bad.bpmn") && e.includes("senior:feature")),
|
|
113
|
+
`the error names the unmarked prompt-bearing task, got ${JSON.stringify(r.errors)}`,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("check-agent-definitions: AGENTS.md documents the check:agent-definitions CI gate", () => {
|
|
118
|
+
// Advisory (PR #886 review): adding the `check:agent-definitions` CI command left the contributor
|
|
119
|
+
// guide's "Runtime & CI gates" list stale — AGENTS.md tells contributors how to match CI locally
|
|
120
|
+
// but did not name the new gate. Pin the doc so the gate cannot silently drop out of the guide.
|
|
121
|
+
const agents = readFileSync(join(ROOT, "AGENTS.md"), "utf8");
|
|
122
|
+
assert(
|
|
123
|
+
agents.includes("check:agent-definitions"),
|
|
124
|
+
"AGENTS.md must document the check:agent-definitions gate in its Runtime & CI gates list",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// check-agent-definitions — repo-wide CI gate (issue #884) for the engine-native AgentTask marker:
|
|
2
|
+
// EVERY `senior:*` agent service task the app deploys must carry
|
|
3
|
+
// `<zeebe:agentDefinition agentType="external" />` INSIDE its `<bpmn:extensionElements>`, as a sibling
|
|
4
|
+
// of its `<zeebe:taskDefinition type="senior:*"/>` dispatch verb. Without the marker the engine refuses
|
|
5
|
+
// to attach an AgentInstance to the element (`createAgentInstance failed — … has no agentDefinition`),
|
|
6
|
+
// so the agent run persists NO durable agentic transcript — the exact #884 failure (269 failed creates
|
|
7
|
+
// on omarchy across three compiled delivery-graph processes).
|
|
8
|
+
//
|
|
9
|
+
// The defect CLASS is "every agent service task the app generates", and there are TWO producers.
|
|
10
|
+
// Both are checked against the SAME union invariant `missingExternalMarkerAgentTaskTypes`
|
|
11
|
+
// (`app/agentic/vocab/job-types.ts`) — the union of the two marker scanners, each of which alone has
|
|
12
|
+
// a blind spot (PR #886 review):
|
|
13
|
+
//
|
|
14
|
+
// • the prompt-keyed `agentTaskTypesMissingExternalMarker` keys a task as an AGENT by its
|
|
15
|
+
// `<zeebe:linkedResource … linkName="prompt">` (the base-prompt link a fleet agent task carries),
|
|
16
|
+
// so it cannot see a DELIBERATELY prompt-less agent cell; and
|
|
17
|
+
// • the type-keyed `externalAgentTaskTypesMissingMarker` keys it by its `<rank>:<task>` colon-form
|
|
18
|
+
// type ALONE, covering the prompt-less case.
|
|
19
|
+
//
|
|
20
|
+
// Scanning a corpus with only ONE leaves the other's blind spot open — a `senior:*` task that loses
|
|
21
|
+
// BOTH its prompt link and its marker would pass a prompt-keyed-only gate. The union closes that for
|
|
22
|
+
// every producer:
|
|
23
|
+
//
|
|
24
|
+
// 1. The HAND-AUTHORED corpus — `resources/processes/**.bpmn`. `checkAuthoredProcesses` runs the
|
|
25
|
+
// union over every deployed model. This is the SAME invariant the `agent-marker.test.ts` unit
|
|
26
|
+
// guard rests on — kept here as a standalone, fast CI gate (alongside `check:prompts`) so the
|
|
27
|
+
// corpus invariant is visible as its own check, not only inside the test suite.
|
|
28
|
+
//
|
|
29
|
+
// 2. The delivery-graph COMPILER (`app/deliveryGraphCompiler.ts`) — the producer #884 caught. Its
|
|
30
|
+
// compiled cells carried NO marker AND no prompt link, so they were invisible to the
|
|
31
|
+
// prompt-link-keyed scanner. The compiler now emits the marker on EVERY `agent` cell — and, per
|
|
32
|
+
// the resolved "Decide" half of #884 (Option (a), PR #886 review), NO base-prompt link: the
|
|
33
|
+
// hand-authored process prompts are process-specific contracts (they require process variables
|
|
34
|
+
// and a `feat/<task.id>` branch the generic compiled cell never supplies), so a compiled cell is
|
|
35
|
+
// deliberately prompt-LESS and steered by its per-node `appendPrompt` alone. This gate compiles a
|
|
36
|
+
// representative graph — including a prompt-less probe cell — and runs the SAME union over the
|
|
37
|
+
// OUTPUT, so a marker regression on a prompt-less cell cannot hide behind the prompt-keyed scan's
|
|
38
|
+
// blind spot.
|
|
39
|
+
//
|
|
40
|
+
// In the hand-authored corpus a `senior:*` task is identified by its prompt link OR its colon-form
|
|
41
|
+
// type; a plain host job (`pr.finalize`, dot-form, no prompt link) is correctly out of scope. The
|
|
42
|
+
// scanners are the ONE canonical implementations — this gate DERIVES all its halves from them rather
|
|
43
|
+
// than re-implementing a second, driftable regex.
|
|
44
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
45
|
+
import { dirname, join, relative } from "node:path";
|
|
46
|
+
import { fileURLToPath } from "node:url";
|
|
47
|
+
import {
|
|
48
|
+
missingExternalMarkerAgentTaskTypes,
|
|
49
|
+
promptBearingTaskTypes,
|
|
50
|
+
} from "../app/agentic/vocab/job-types.ts";
|
|
51
|
+
import { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
|
|
52
|
+
|
|
53
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
54
|
+
const PROCESSES_DIR = join(ROOT, "resources/processes");
|
|
55
|
+
|
|
56
|
+
// The agent job type the compiled-output probe uses for a cell with NO base-prompt link (every
|
|
57
|
+
// compiled agent cell is prompt-less per the resolved #884 "Decide" — Option (a)). It is colon-form
|
|
58
|
+
// (`<rank>:<task>`) so the type-keyed marker scanner keys it as an agent — the exact prompt-less shape
|
|
59
|
+
// the prompt-keyed scanner cannot see (PR #886 review).
|
|
60
|
+
const PROMPTLESS_PROBE_JOB_TYPE = "senior:bespoke-promptless";
|
|
61
|
+
|
|
62
|
+
// urban deploys `resources/` recursively (every file at any depth), so a process model added under a
|
|
63
|
+
// subdirectory would still deploy — walk recursively here too, or the guard would miss it and let an
|
|
64
|
+
// unmarked agent task slip through. Mirrors `agent-marker.test.ts`'s `bpmnFiles()`.
|
|
65
|
+
function bpmnFiles(): string[] {
|
|
66
|
+
const walk = (dir: string): string[] =>
|
|
67
|
+
readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
68
|
+
const full = join(dir, entry.name);
|
|
69
|
+
if (entry.isDirectory()) return walk(full);
|
|
70
|
+
return entry.name.endsWith(".bpmn") ? [relative(PROCESSES_DIR, full)] : [];
|
|
71
|
+
});
|
|
72
|
+
return walk(PROCESSES_DIR).sort();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AgentDefinitionCheckResult {
|
|
76
|
+
ok: boolean;
|
|
77
|
+
errors: string[];
|
|
78
|
+
/** prompt-bearing agent job types found across the hand-authored corpus (for the CLI summary). */
|
|
79
|
+
agentTypes: string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One hand-authored BPMN document to scan: its path (relative to `resources/processes`) and XML. */
|
|
83
|
+
export interface AuthoredDoc {
|
|
84
|
+
file: string;
|
|
85
|
+
xml: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Read the hand-authored deployed corpus (`resources/processes/**.bpmn`) off disk. */
|
|
89
|
+
function authoredDocs(): AuthoredDoc[] {
|
|
90
|
+
return bpmnFiles().map((file) => ({ file, xml: readFileSync(join(PROCESSES_DIR, file), "utf8") }));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Scan the HAND-AUTHORED deployed corpus (`resources/processes/**.bpmn`). `docs` is injectable so the
|
|
95
|
+
* red-path test can feed a drifting document without writing to the corpus; it defaults to the real
|
|
96
|
+
* on-disk corpus. Every agent task — prompt-bearing OR deliberately prompt-less — must carry the
|
|
97
|
+
* external marker, so this holds the SAME union invariant as the compiled half (PR #886 review).
|
|
98
|
+
*/
|
|
99
|
+
export function checkAuthoredProcesses(docs: readonly AuthoredDoc[] = authoredDocs()): AgentDefinitionCheckResult {
|
|
100
|
+
const errors: string[] = [];
|
|
101
|
+
const agentTypes = new Set<string>();
|
|
102
|
+
for (const { file, xml } of docs) {
|
|
103
|
+
for (const t of promptBearingTaskTypes(xml)) agentTypes.add(t);
|
|
104
|
+
const missing = missingExternalMarkerAgentTaskTypes(xml);
|
|
105
|
+
if (missing.length > 0) {
|
|
106
|
+
errors.push(
|
|
107
|
+
`resources/processes/${file}: agent task(s) missing <zeebe:agentDefinition agentType="external" /> — ` +
|
|
108
|
+
`${missing.join(", ")} (every senior:* agent task must carry the marker so the engine mints a ` +
|
|
109
|
+
`durable AgentInstance/transcript for it, issue #745/#884)`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (agentTypes.size === 0) {
|
|
114
|
+
errors.push("no prompt-bearing agent task found in resources/processes — the guard would be vacuously green");
|
|
115
|
+
}
|
|
116
|
+
return { ok: errors.length === 0, errors, agentTypes: [...agentTypes].sort() };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Scan the delivery-graph COMPILER's output — the second producer of agent service tasks (#884). */
|
|
120
|
+
export async function checkCompiledOutput(): Promise<AgentDefinitionCheckResult> {
|
|
121
|
+
const errors: string[] = [];
|
|
122
|
+
const r = await compileDeliveryGraph({
|
|
123
|
+
nodes: [
|
|
124
|
+
{ id: "impl", kind: "agent", agent: { jobType: "senior:feature", prompt: "implement #1" }, emits: [{ name: "pr", type: "pr" }] },
|
|
125
|
+
{ id: "review", kind: "agent", agent: { jobType: "senior:pr-review" } },
|
|
126
|
+
// A prompt-LESS agent cell (every compiled agent cell is prompt-less per the resolved #884
|
|
127
|
+
// "Decide" — Option (a)). The prompt-keyed scanner never sees it, so probe the shape to keep the
|
|
128
|
+
// type-keyed marker scan below covering it (PR #886 review).
|
|
129
|
+
{ id: "bespoke", kind: "agent", agent: { jobType: PROMPTLESS_PROBE_JOB_TYPE } },
|
|
130
|
+
],
|
|
131
|
+
edges: [
|
|
132
|
+
{ from: "impl", to: "review" },
|
|
133
|
+
{ from: "review", to: "bespoke" },
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
if (!r.ok) {
|
|
137
|
+
return { ok: false, errors: [`delivery-graph compiler rejected the probe graph: ${JSON.stringify(r.errors)}`], agentTypes: [] };
|
|
138
|
+
}
|
|
139
|
+
// Inspect EVERY generated agent service task for the marker via the single union invariant
|
|
140
|
+
// (`missingExternalMarkerAgentTaskTypes`). Every compiled agent cell is prompt-LESS (Option (a)), so
|
|
141
|
+
// it is the type-keyed half of the union (keyed by the `<rank>:<task>` colon-form type alone) that
|
|
142
|
+
// sees these cells — a compiler regression that drops the marker on a prompt-less cell cannot hide
|
|
143
|
+
// behind the prompt-keyed scan's blind spot (PR #886 review).
|
|
144
|
+
const missing = missingExternalMarkerAgentTaskTypes(r.bpmn);
|
|
145
|
+
if (missing.length > 0) {
|
|
146
|
+
errors.push(
|
|
147
|
+
`compiled delivery-graph agent cell(s) missing <zeebe:agentDefinition agentType="external" /> — ` +
|
|
148
|
+
`${missing.join(", ")} (the compiler must emit the marker on EVERY agent cell, including a ` +
|
|
149
|
+
"prompt-less one, issue #884)",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
// Sanity: the prompt-less probe cell really compiled into the output (the type-keyed scan is not
|
|
153
|
+
// vacuously green over a graph that lost its prompt-less cell).
|
|
154
|
+
if (!r.bpmn.includes(`type="${PROMPTLESS_PROBE_JOB_TYPE}"`)) {
|
|
155
|
+
errors.push(
|
|
156
|
+
`the compiled probe graph lost its prompt-less agent cell ${PROMPTLESS_PROBE_JOB_TYPE} — ` +
|
|
157
|
+
"the type-keyed marker scan would be vacuously green over it",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
// The agent job types this probe compiled (for the CLI summary) — derived by the SAME type-keyed
|
|
161
|
+
// grammar the marker scanner uses, since the cells are prompt-less (a prompt-keyed list is empty).
|
|
162
|
+
const agentTypes = [
|
|
163
|
+
...new Set(
|
|
164
|
+
[...r.bpmn.matchAll(/<(?:\w+:)?taskDefinition\b[^>]*\btype="([^"]*)"/g)]
|
|
165
|
+
.map((m) => m[1])
|
|
166
|
+
.filter((t) => t.startsWith("senior:")),
|
|
167
|
+
),
|
|
168
|
+
];
|
|
169
|
+
return { ok: errors.length === 0, errors, agentTypes: agentTypes.sort() };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function main(): Promise<void> {
|
|
173
|
+
const authored = checkAuthoredProcesses();
|
|
174
|
+
const compiled = await checkCompiledOutput();
|
|
175
|
+
const errors = [...authored.errors, ...compiled.errors];
|
|
176
|
+
if (errors.length > 0) {
|
|
177
|
+
console.error(`✖ agent-definition check failed (${errors.length} problem(s)):`);
|
|
178
|
+
for (const e of errors) console.error(` - ${e}`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
console.log(
|
|
182
|
+
`✔ every senior:* agent service task carries <zeebe:agentDefinition agentType="external" /> ` +
|
|
183
|
+
`(authored: ${authored.agentTypes.length} type(s) [${authored.agentTypes.join(", ")}]; ` +
|
|
184
|
+
`compiled: ${compiled.agentTypes.length} type(s) [${compiled.agentTypes.join(", ")}])`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// CLI: check the repo rooted at the package root. Exit non-zero (fail CI) on any problem.
|
|
189
|
+
if (import.meta.main) {
|
|
190
|
+
await main();
|
|
191
|
+
}
|