@nanobpm/nano-workforce 0.200.7 → 0.200.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/SPEC.md +41 -0
- package/app/agentSla.test.ts +48 -0
- package/app/agentSla.ts +36 -11
- package/app/agentic/vocab/agent-sla-boundary.test.ts +170 -0
- package/app/agentic/vocab/job-types.ts +60 -0
- package/app/conformance.test.ts +114 -0
- package/app/conformance.ts +72 -0
- package/app/contracts.ts +2 -2
- package/app/deliveryRunner.ts +5 -0
- package/app/feature.ts +5 -0
- package/app/plan.ts +12 -0
- package/app/retro.test.ts +5 -0
- package/app/retro.ts +4 -0
- package/app/service.ts +27 -8
- package/e2e/agent-sla-boundary.e2e.ts +598 -0
- package/e2e/support/pin-agent-sla.ts +47 -0
- package/package.json +1 -1
- package/resources/processes/feature.bpmn +1 -0
- package/resources/processes/implement-cell.bpmn +96 -46
- package/resources/processes/merge-cell.bpmn +60 -12
- package/resources/processes/plan-fanout.bpmn +1 -0
- package/resources/processes/retro.bpmn +100 -3
- package/test/derivation-parity/derivation-parity.test.ts +35 -11
- package/test/derivation-parity/flows.ts +14 -1
- package/workers/conformance-record/worker.test.ts +87 -1
- package/workers/conformance-record/worker.ts +34 -7
|
@@ -173,33 +173,57 @@ test("loop() inserts a gateway head, so back-edges cannot merge into a task", ()
|
|
|
173
173
|
// structured topology (clears class 2), but issue #745 added the engine-native
|
|
174
174
|
// AgentTask marker `<zeebe:agentDefinition agentType="external" />` to its two
|
|
175
175
|
// prompt-bearing `senior:*` tasks and the published compiler cannot emit it.
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
176
|
+
// Issue #849 then added an interrupting `agentSlaTimeout` timer boundary to each
|
|
177
|
+
// of those tasks (routing to the `conformance-escalation` user task) — the
|
|
178
|
+
// code-first builder cannot express a boundary event on a task either (its
|
|
179
|
+
// `FlowNode` union has no such node; AGENTS.md). Prove the blockers are real AND
|
|
180
|
+
// that they are the ONLY divergence, so the parked entry is a complete, verified
|
|
181
|
+
// port held ready — not an abandoned one.
|
|
182
|
+
test("retro's complete port differs from its golden by ONLY the agent-task marker + SLA boundaries", () => {
|
|
179
183
|
const golden = readFileSync(goldenPath("retro"), "utf8");
|
|
180
184
|
const derived = declarativeToBpmn(retroFlow);
|
|
181
185
|
const markers = (xml: string): number =>
|
|
182
186
|
(xml.match(/<(?:\w+:)?agentDefinition\b[^>]*\bagentType="external"/g) ?? []).length;
|
|
187
|
+
const boundaries = (xml: string): number =>
|
|
188
|
+
(xml.match(/<(?:\w+:)?boundaryEvent\b/g) ?? []).length;
|
|
183
189
|
|
|
184
190
|
// (a) the golden really carries the marker, on BOTH of its agent tasks — and it
|
|
185
191
|
// cannot simply be dropped: app/agentic/vocab/agent-marker.test.ts is a
|
|
186
192
|
// defect-class guard requiring it on every deployed prompt-bearing agent task.
|
|
187
193
|
assertEquals(markers(golden), 2, "retro's golden should mark senior:conformance and senior:retro");
|
|
194
|
+
// …and each marked task is SLA-bounded (issue #849; the defect-class guard is
|
|
195
|
+
// app/agentic/vocab/agent-sla-boundary.test.ts).
|
|
196
|
+
assertEquals(boundaries(golden), 2, "retro's golden should SLA-bound both agent tasks");
|
|
188
197
|
|
|
189
|
-
// (b) the published compiler emits none of it — the
|
|
190
|
-
// When
|
|
191
|
-
//
|
|
198
|
+
// (b) the published compiler emits none of it — the blockers are real, not a guess.
|
|
199
|
+
// When the marker assertion starts failing, upstream task() grew marker support;
|
|
200
|
+
// when the boundary assertion starts failing, the builder grew boundary events:
|
|
201
|
+
// un-park retro by threading `retroFlow` back into its PORTS entry in ./flows.ts.
|
|
192
202
|
assertEquals(markers(derived), 0, "@nanobpm/workflow can now emit <zeebe:agentDefinition/> — un-park retro");
|
|
203
|
+
assertEquals(boundaries(derived), 0, "@nanobpm/workflow can now emit boundary events — un-park retro");
|
|
193
204
|
|
|
194
|
-
// (c) strip exactly the marker lines
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
205
|
+
// (c) strip exactly the marker lines AND the SLA boundary blocks (plus their
|
|
206
|
+
// sequence flows, the two SLA recorder service tasks they feed, and the
|
|
207
|
+
// extra incomings on the escalation user task) from the golden and the port
|
|
208
|
+
// derives the WHOLE model green, through the shared harness's own
|
|
209
|
+
// normalize/equality — so nothing but the marker + SLA-escalation apparatus
|
|
210
|
+
// diverges.
|
|
211
|
+
const unmarked = golden
|
|
212
|
+
.replace(/^[ \t]*<(?:\w+:)?agentDefinition\b[^>]*\/>[ \t]*\r?\n/gm, "")
|
|
213
|
+
.replace(/^[ \t]*<(?:\w+:)?boundaryEvent\b[\s\S]*?<\/(?:\w+:)?boundaryEvent>\r?\n/gm, "")
|
|
214
|
+
// the SLA recorder service tasks (record-conformance-sla / record-synthesize-sla) and the
|
|
215
|
+
// `<!-- Agent-SLA recorders: … -->` comment block immediately preceding them
|
|
216
|
+
.replace(/^[ \t]*<!--[\s\S]*?-->[ \t]*\r?\n(?=[ \t]*<(?:\w+:)?serviceTask\b[^>]*\bid="record-(?:conformance|synthesize)-sla")/gm, "")
|
|
217
|
+
.replace(/^[ \t]*<(?:\w+:)?serviceTask\b[^>]*\bid="record-(?:conformance|synthesize)-sla"[\s\S]*?<\/(?:\w+:)?serviceTask>\r?\n/gm, "")
|
|
218
|
+
.replace(/^[ \t]*<(?:\w+:)?sequenceFlow\b[^>]*\bsourceRef="be_[^"]*"[^>]*\/>[ \t]*\r?\n/gm, "")
|
|
219
|
+
.replace(/^[ \t]*<(?:\w+:)?sequenceFlow\b[^>]*\bsourceRef="record-(?:conformance|synthesize)-sla"[^>]*\/>[ \t]*\r?\n/gm, "")
|
|
220
|
+
.replace(/^[ \t]*<(?:\w+:)?incoming>f_(?:conformanceSla|synthesizeSla)(?:ToEsc)?<\/(?:\w+:)?incoming>[ \t]*\r?\n/gm, "");
|
|
198
221
|
assertEquals(markers(unmarked), 0, "the strip must remove every marker line");
|
|
222
|
+
assertEquals(boundaries(unmarked), 0, "the strip must remove every SLA boundary");
|
|
199
223
|
const expected = normalize(unmarked);
|
|
200
224
|
const actual = normalize(derived);
|
|
201
225
|
assert(
|
|
202
226
|
modelsEqual(expected, actual),
|
|
203
|
-
`retro's port must derive its golden once the agent-task marker
|
|
227
|
+
`retro's port must derive its golden once the agent-task marker and SLA boundaries are stripped — residual drift:\n${diffModels(expected, actual)}`,
|
|
204
228
|
);
|
|
205
229
|
});
|
|
@@ -133,7 +133,20 @@ export const retroFlow: DeclarativeFlow = defineFlow(
|
|
|
133
133
|
jobType: "senior:conformance",
|
|
134
134
|
prompt: { resourceId: "prompts/conformance.md", bindingType: "latest", append: "=conformanceDigest" },
|
|
135
135
|
});
|
|
136
|
-
w.task("record-conformance", {
|
|
136
|
+
w.task("record-conformance", {
|
|
137
|
+
jobType: "pr.conformance-record",
|
|
138
|
+
// The golden pins both SLA-mode control flags to `=false` on this NORMAL-path recorder
|
|
139
|
+
// (round-7 review, PR #864): the flags share the external conformance agent's result
|
|
140
|
+
// namespace, so a hoisted agent output must never select the SLA/preserve branch. The port
|
|
141
|
+
// reproduces those pins so the parity diagnostic's only residual is the class-3 marker +
|
|
142
|
+
// SLA boundaries.
|
|
143
|
+
io: {
|
|
144
|
+
input: [
|
|
145
|
+
{ source: "=false", target: "agentSlaElapsed" },
|
|
146
|
+
{ source: "=false", target: "preserveConformance" },
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
});
|
|
137
150
|
w.branch("hasDeviations = true", {
|
|
138
151
|
then: (b) => {
|
|
139
152
|
b.human("conformance-escalation", {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
1
3
|
import { test } from "node:test";
|
|
2
|
-
import {
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { assert, assertEquals, assertRejects } from "#test-assert";
|
|
3
6
|
import { noopLog } from "../../test/log.ts";
|
|
4
7
|
import handler from "./worker.ts";
|
|
5
8
|
|
|
@@ -239,3 +242,86 @@ test("conformance-record: fails (not a silent, untrackable escalation) when ther
|
|
|
239
242
|
// Nothing was persisted: the throw precedes the write, so no untrackable row is left behind.
|
|
240
243
|
assertEquals(stores.plan_conformance.length, 0);
|
|
241
244
|
});
|
|
245
|
+
|
|
246
|
+
test("conformance-record: an agent-SLA timeout persists a reviewing row (so the ack finds it) with no deviations", async () => {
|
|
247
|
+
const { app, stores } = fakeApp();
|
|
248
|
+
// The retro SLA arm routes here with agentSlaElapsed=true + an SLA summary and NO agent verdict
|
|
249
|
+
// (the conformance/synthesize agent timed out). The shared conformance-escalation ack task always
|
|
250
|
+
// runs pr.conformance-ack → acknowledgeConformance, which THROWS when no plan_conformance row
|
|
251
|
+
// exists — so the SLA path must persist a `reviewing` row even though nothing was scored.
|
|
252
|
+
const out = await handler(
|
|
253
|
+
{
|
|
254
|
+
processInstanceKey: "retro-inst-13",
|
|
255
|
+
variables: {
|
|
256
|
+
planKey: "o/r#13",
|
|
257
|
+
agentSlaElapsed: true,
|
|
258
|
+
summary: "The spec-conformance agent exceeded its time budget (SLA PT2H) without returning a verdict.",
|
|
259
|
+
},
|
|
260
|
+
} as any,
|
|
261
|
+
app as any,
|
|
262
|
+
);
|
|
263
|
+
const row = stores.plan_conformance[0];
|
|
264
|
+
// A timeout produces no verdict: status coerces to skipped, all counts zeroed, has_deviations 0 …
|
|
265
|
+
assertEquals(row.status, "skipped");
|
|
266
|
+
assertEquals(row.has_deviations, 0);
|
|
267
|
+
// … but the row IS parked at `reviewing` (keyed off the instance) so acknowledgeConformance settles
|
|
268
|
+
// it instead of throwing, and the Tasks inbox shows the SLA-timeout summary as the reason.
|
|
269
|
+
assertEquals(row.review_status, "reviewing");
|
|
270
|
+
assertEquals(row.process_key, "retro-inst-13");
|
|
271
|
+
assertEquals(row.summary, "The spec-conformance agent exceeded its time budget (SLA PT2H) without returning a verdict.");
|
|
272
|
+
// The SLA arm routes to the escalation task directly (not via gw-deviations), so the returned flag
|
|
273
|
+
// stays the ground-truth `hasDeviations` (false) — it is not read on the SLA path.
|
|
274
|
+
assertEquals(out, { hasDeviations: false });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("conformance-record: an agent-SLA timeout with no processKey fails loudly (untrackable escalation)", async () => {
|
|
278
|
+
const { app, stores } = fakeApp();
|
|
279
|
+
await assertRejects(
|
|
280
|
+
() =>
|
|
281
|
+
handler(
|
|
282
|
+
{ variables: { planKey: "o/r#14", agentSlaElapsed: true, summary: "sla" } } as any,
|
|
283
|
+
app as any,
|
|
284
|
+
),
|
|
285
|
+
Error,
|
|
286
|
+
"no processInstanceKey",
|
|
287
|
+
);
|
|
288
|
+
assertEquals(stores.plan_conformance.length, 0);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// DEFECT-CLASS GUARD (PR #864 review r4181010853): the SLA-control flags `agentSlaElapsed` and
|
|
292
|
+
// `preserveConformance` share the process-variable namespace the external conformance agent's
|
|
293
|
+
// result-JSON is hoisted into. If a `pr.conformance-record` task does NOT pin both flags to a
|
|
294
|
+
// literal via ioMapping, a completed agent that emits `agentSlaElapsed:true` / `preserveConformance:true`
|
|
295
|
+
// could make the NORMAL recorder persist `review_status='reviewing'` (or take the preserve branch)
|
|
296
|
+
// while `gw-deviations` routes away from the ack task — leaving the row permanently stuck. Every
|
|
297
|
+
// recorder task must therefore pin BOTH flags to a constant so agent output can never select this
|
|
298
|
+
// internal control path. This structural guard fails if any recorder (now or a future one) forgets.
|
|
299
|
+
const RETRO_BPMN = join(
|
|
300
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
301
|
+
"../../resources/processes/retro.bpmn",
|
|
302
|
+
);
|
|
303
|
+
const SERVICE_TASK = /<bpmn:serviceTask\b[\s\S]*?<\/bpmn:serviceTask>/g;
|
|
304
|
+
|
|
305
|
+
test("DEFECT-CLASS GUARD: every pr.conformance-record task pins agentSlaElapsed + preserveConformance to a literal (agent output cannot select SLA control path)", () => {
|
|
306
|
+
const xml = readFileSync(RETRO_BPMN, "utf8");
|
|
307
|
+
const recorders: string[] = [];
|
|
308
|
+
for (const [block] of xml.matchAll(SERVICE_TASK)) {
|
|
309
|
+
if (!/type="pr\.conformance-record"/.test(block)) continue;
|
|
310
|
+
const id = block.match(/<bpmn:serviceTask\b[^>]*\bid="([^"]*)"/)?.[1] ?? "(unknown)";
|
|
311
|
+
recorders.push(id);
|
|
312
|
+
for (const flag of ["agentSlaElapsed", "preserveConformance"]) {
|
|
313
|
+
// A literal pin is `<zeebe:input source="=true|false" target="<flag>" />` — the source must be
|
|
314
|
+
// a boolean constant, NOT a process-variable reference (which would read hoisted agent output).
|
|
315
|
+
const pin = new RegExp(
|
|
316
|
+
`<zeebe:input\\s+source="=(?:true|false)"\\s+target="${flag}"\\s*/>`,
|
|
317
|
+
);
|
|
318
|
+
assert(
|
|
319
|
+
pin.test(block),
|
|
320
|
+
`retro.bpmn: record task "${id}" does not pin "${flag}" to a literal — agent output could ` +
|
|
321
|
+
`select the internal SLA/preserve control path (PR #864 review r4181010853)`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Sanity: there really are recorder tasks (guard is not vacuously green) — normal + 2 SLA arms.
|
|
326
|
+
assert(recorders.length >= 3, `expected >=3 pr.conformance-record tasks, found ${recorders.length}`);
|
|
327
|
+
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// reads `status`/`pr`/`summary` through), and exposes the raw transcript under the
|
|
10
10
|
// `io.nanobpm.agentResult` envelope's `.output`.
|
|
11
11
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
12
|
-
import { recordConformance } from "../../app/conformance.ts";
|
|
12
|
+
import { markConformanceSynthesisSla, recordConformance } from "../../app/conformance.ts";
|
|
13
13
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
14
14
|
|
|
15
15
|
const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
|
|
@@ -81,6 +81,22 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
81
81
|
// the same `String(...)` coercion app/service.ts applies when it stamps `process_key`.
|
|
82
82
|
const processKey = job.processInstanceKey != null ? String(job.processInstanceKey) : null;
|
|
83
83
|
|
|
84
|
+
// Agent-SLA path (issue #849 review): the conformance/synthesize agent's interrupting timer
|
|
85
|
+
// boundary fired (retro.bpmn routes the SLA arm here with `agentSlaElapsed=true` + an SLA summary).
|
|
86
|
+
// The agent produced no verdict, so there is nothing to score — but the shared conformance-escalation
|
|
87
|
+
// ack task ALWAYS runs `pr.conformance-ack`, whose `acknowledgeConformance` throws when no
|
|
88
|
+
// `plan_conformance` row exists. Persist a `reviewing` row so the ack settles it and the Tasks inbox
|
|
89
|
+
// shows the SLA-timeout reason. This escalates exactly like a deviations finding.
|
|
90
|
+
const agentSlaElapsed = asBool(job.variables.agentSlaElapsed);
|
|
91
|
+
const escalate = hasDeviations || agentSlaElapsed;
|
|
92
|
+
|
|
93
|
+
// Synthesis-SLA preserve path (PR #864 review): the `record-synthesize-sla` arm runs AFTER
|
|
94
|
+
// `record-conformance` already persisted a real verdict, so it sets `preserveConformance=true` to
|
|
95
|
+
// flip the row to `reviewing` + append the synthesis-timeout context WITHOUT overwriting the filed
|
|
96
|
+
// status/counts/report/summary. Only the synthesize arm sets this; the conformance-SLA arm
|
|
97
|
+
// (`record-conformance-sla`) interrupts BEFORE any verdict exists, so it takes the upsert below.
|
|
98
|
+
const preserveConformance = asBool(job.variables.preserveConformance);
|
|
99
|
+
|
|
84
100
|
// Invariant: an escalation must be trackable. If we found deviations to escalate but have no
|
|
85
101
|
// process key to key the `reviewing` row off, the `hasDeviations` return below would still route
|
|
86
102
|
// retro to the `conformance-escalation` user task — yet `pollUserTasks` can never surface that ack
|
|
@@ -88,13 +104,23 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
88
104
|
// escalation wedges forever, invisible to any human. Rather than record that silent, unreachable
|
|
89
105
|
// state, fail loudly so the run retries/alerts. `job.processInstanceKey` is always present for an
|
|
90
106
|
// activated job, so this only fires on a genuine engine-contract violation.
|
|
91
|
-
if (
|
|
107
|
+
if (escalate && processKey == null) {
|
|
92
108
|
throw new Error(
|
|
93
|
-
`conformance-record: ${planKey} has
|
|
109
|
+
`conformance-record: ${planKey} has an escalation to record but no processInstanceKey to track ` +
|
|
94
110
|
"the escalation — refusing to route to an untrackable conformance-escalation ack task",
|
|
95
111
|
);
|
|
96
112
|
}
|
|
97
113
|
|
|
114
|
+
if (preserveConformance) {
|
|
115
|
+
// Preserve the existing conformance verdict; only mark it reviewing + append the SLA reason.
|
|
116
|
+
await markConformanceSynthesisSla(app.data, planKey, {
|
|
117
|
+
summary: summary ?? "The retrospective-synthesis agent exceeded its time budget without completing.",
|
|
118
|
+
processKey,
|
|
119
|
+
});
|
|
120
|
+
app.log.info(`conformance-record: ${planKey} — synthesis SLA, preserved existing verdict (reviewing)`);
|
|
121
|
+
return { hasDeviations };
|
|
122
|
+
}
|
|
123
|
+
|
|
98
124
|
await recordConformance(app.data, planKey, {
|
|
99
125
|
status,
|
|
100
126
|
commentUrl: filed ? commentUrl : null,
|
|
@@ -110,17 +136,18 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
110
136
|
// Only enter the `reviewing` inbox scan when we actually have a `processKey` to key off — a
|
|
111
137
|
// null key can never be found by `pollUserTasks` (it skips rows without `process_key`) nor
|
|
112
138
|
// cleared by the `instanceTracking` `onTerminated` binding, so a `reviewing` row with no key
|
|
113
|
-
// would wedge forever. The invariant guard above already rejected
|
|
139
|
+
// would wedge forever. The invariant guard above already rejected any escalation with a null
|
|
114
140
|
// key, so `reviewing` here always carries a non-null `processKey`.
|
|
115
|
-
reviewStatus:
|
|
141
|
+
reviewStatus: escalate ? "reviewing" : "reviewed",
|
|
116
142
|
});
|
|
117
143
|
|
|
118
144
|
app.log.info(
|
|
119
|
-
`conformance-record: ${planKey} — status=${status} deviations=${hasDeviations ? "yes" : "no"}`,
|
|
145
|
+
`conformance-record: ${planKey} — status=${status} deviations=${hasDeviations ? "yes" : "no"} agentSla=${agentSlaElapsed ? "yes" : "no"}`,
|
|
120
146
|
);
|
|
121
147
|
// Return the ground-truth `hasDeviations` as a process variable so the `gw-deviations` gateway
|
|
122
148
|
// routes to the human ack task (retro.bpmn) — overriding the agent's hoisted flag with the value
|
|
123
|
-
// reconciled against the recorded counts above.
|
|
149
|
+
// reconciled against the recorded counts above. (The SLA arm routes to the escalation task
|
|
150
|
+
// directly, not through gw-deviations, so this flag is only read on the normal path.)
|
|
124
151
|
return { hasDeviations };
|
|
125
152
|
};
|
|
126
153
|
|