@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.
@@ -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
- // Prove the blocker is real AND that it is the ONLY divergence, so the parked
177
- // entry is a complete, verified port held ready — not an abandoned one.
178
- test("retro's complete port differs from its golden by ONLY the agent-task marker", () => {
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 blocker is real, not a guess.
190
- // When this starts failing, upstream task() grew marker support: un-park retro
191
- // by threading `retroFlow` back into its PORTS entry in ./flows.ts.
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 from the golden and the port derives the
195
- // WHOLE model green, through the shared harness's own normalize/equality —
196
- // so nothing but the marker diverges.
197
- const unmarked = golden.replace(/^[ \t]*<(?:\w+:)?agentDefinition\b[^>]*\/>[ \t]*\r?\n/gm, "");
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 is stripped — residual drift:\n${diffModels(expected, actual)}`,
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", { jobType: "pr.conformance-record" });
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 { assertEquals, assertRejects } from "#test-assert";
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 (hasDeviations && processKey == null) {
107
+ if (escalate && processKey == null) {
92
108
  throw new Error(
93
- `conformance-record: ${planKey} has deviations to escalate but no processInstanceKey to track ` +
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 `hasDeviations` with a null
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: hasDeviations ? "reviewing" : "reviewed",
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