@nanobpm/nano-workforce 0.201.0 → 0.201.2

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +153 -0
  3. package/app/agentActivationLedger.test.ts +109 -0
  4. package/app/agentActivationLedger.ts +144 -0
  5. package/app/agentActivationsPoll.test.ts +180 -0
  6. package/app/agentCompletion.test.ts +52 -0
  7. package/app/agentCompletion.ts +20 -5
  8. package/app/agentSlaEscalation.test.ts +137 -0
  9. package/app/agentSlaEscalation.ts +169 -0
  10. package/app/agentSlaMarkerGuard.test.ts +172 -0
  11. package/app/contracts.ts +9 -0
  12. package/app/deliveryGraph.test.ts +251 -0
  13. package/app/deliveryGraph.ts +334 -1
  14. package/app/deliveryGraphCompiler.test.ts +1345 -30
  15. package/app/deliveryGraphCompiler.ts +882 -240
  16. package/app/deliveryGraphDeploy.test.ts +472 -3
  17. package/app/deliveryGraphRun.ts +2 -2
  18. package/app/deliveryHuman.test.ts +59 -5
  19. package/app/deliveryHuman.ts +115 -3
  20. package/app/deliveryRunner.test.ts +3 -3
  21. package/app/mergeLoopBehaviour.test.ts +46 -1
  22. package/app/redactText.ts +88 -0
  23. package/app/service.ts +110 -2
  24. package/db/migrations/119_agent_job_activations.sql +50 -0
  25. package/e2e/agent-sla-boundary.e2e.ts +51 -8
  26. package/e2e/delivery-graph-dispatch.e2e.ts +2 -1
  27. package/e2e/delivery-graph-node-scope.e2e.ts +358 -0
  28. package/e2e/delivery-graph.e2e.ts +11 -7
  29. package/package.json +1 -1
  30. package/resources/forms/delivery-escalation.form +41 -0
  31. package/resources/forms/delivery-human-generic.form +4 -3
  32. package/resources/processes/convergence-loop.bpmn +10 -0
  33. package/resources/processes/implement-cell.bpmn +4 -1
  34. package/resources/processes/merge-cell.bpmn +4 -1
  35. package/resources/processes/merge-loop.bpmn +7 -0
  36. package/resources/processes/plan-fanout.bpmn +5 -0
  37. package/resources/processes/retro.bpmn +5 -2
  38. package/test/derivation-parity/flows.ts +5 -3
  39. package/workers/conformance-record/worker.ts +15 -1
  40. package/workers/persist-escalation/worker.ts +22 -3
  41. package/workers/record-feature-escalation/worker.ts +28 -12
  42. package/workers/record-trial-merge/worker.ts +16 -1
@@ -21,6 +21,7 @@ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
21
21
  import { deliveryGraphProposals } from "../app/deliveryGraphProposals.ts";
22
22
  import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
23
23
  import { pollDeliveryGraphPhase } from "../app/service.ts";
24
+ import { isDeliveryEscalationTwin } from "../app/deliveryHuman.ts";
24
25
  import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
25
26
 
26
27
  const APP_ROOT = resolve(import.meta.dirname, "..");
@@ -129,7 +130,7 @@ describe("delivery-graph dispatch — agent compiles→stages, operator dispatch
129
130
 
130
131
  // ── Completion: complete the human stop → the instance ends → the poller reconciles to done ───
131
132
  const open = await app.engine.searchUserTasks({ state: "CREATED" });
132
- const human = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !t.elementId?.endsWith("__esc"));
133
+ const human = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !isDeliveryEscalationTwin(t.elementId));
133
134
  assert.ok(human, `a human user task is open, got ${JSON.stringify(open.map((t) => t.elementId))}`);
134
135
  await app.engine.completeUserTask(human.userTaskKey, { humanOutcome: "completed" });
135
136
  await app.settle();
@@ -0,0 +1,358 @@
1
+ // End-to-end proof (WASM engine) of the delivery-graph node-scope hardening, driven by the failure on
2
+ // live instance 171774: three escalations whose prompts carried no usable information.
3
+ //
4
+ // • NODE-LOCAL REPORT: parallel agent nodes keep their result (`status`/`summary`/`pr`/…) in their own
5
+ // subProcess scope. A blocked node's contract escalation shows ITS OWN report (summary, question,
6
+ // transcript) — not whichever sibling finished last — and the shared root holds no `pr`/`status`
7
+ // a sibling's `<el>_pr` output could bind (171774: a timed-out node would have bound a sibling's PR).
8
+ // • RETRY-NODE: completing a service-node escalation with `decision: "retry"` re-runs the node's job,
9
+ // passing the operator's `note` to the agent as guidance; the previous attempt's stale `blocked`
10
+ // status is cleared, so a rerun that succeeds without reporting a status passes the contract gate.
11
+ // • PREFLIGHT: a node whose runner-seeded `nodeInputs` config is missing (lost root variables) raises
12
+ // an incident naming the cause instead of running blind; restoring the variables and resolving the
13
+ // incident lets the node proceed.
14
+ import { mkdtempSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join, resolve } from "node:path";
17
+ import { after, describe, test } from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
20
+ import { runDeliveryGraph } from "../app/deliveryRunner.ts";
21
+ import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
22
+ import { pollUserTasks } from "../app/service.ts";
23
+ import { userTasks } from "../app/userTasks.ts";
24
+ import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
25
+
26
+ const APP_ROOT = resolve(import.meta.dirname, "..");
27
+ const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
28
+
29
+ const PR_A = "https://github.com/acme/repo/pull/1";
30
+ const PR_B = "https://github.com/acme/repo/pull/2";
31
+
32
+ /** Two independent producer→consumer chains (`a → ca`, `b → cb`): each agent's `pr` is a REQUIRED data
33
+ * dependency of its connector, so each agent node carries a producer-contract gate. */
34
+ const TWO_CHAINS: DeliveryGraph = {
35
+ name: "node-scope e2e",
36
+ nodes: [
37
+ { id: "a", kind: "agent", agent: { jobType: "senior:a" }, emits: [{ name: "pr", type: "pr" }] },
38
+ { id: "b", kind: "agent", agent: { jobType: "senior:b" }, emits: [{ name: "pr", type: "pr" }] },
39
+ { id: "ca", kind: "connector", connector: { target: "slack", dedupeKey: "ca-1", payload: { pr: "a.pr" } } },
40
+ { id: "cb", kind: "connector", connector: { target: "slack", dedupeKey: "cb-1", payload: { pr: "b.pr" } } },
41
+ ],
42
+ edges: [
43
+ { from: "a.pr", to: "ca" },
44
+ { from: "b.pr", to: "cb" },
45
+ ],
46
+ } as DeliveryGraph;
47
+
48
+ describe("delivery-graph node scope — node-local agent reports, retry-node, preflight", () => {
49
+ const dirs: string[] = [];
50
+ const apps: TestApp[] = [];
51
+ after(async () => {
52
+ for (const app of apps) await app.stop?.();
53
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
54
+ });
55
+ async function boot(): Promise<TestApp> {
56
+ const dir = mkdtempSync(join(tmpdir(), "nwf-node-scope-e2e-"));
57
+ dirs.push(dir);
58
+ const app = await bootTestApp(APP_ROOT, { env: { ...GITHUB_ENV, NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
59
+ apps.push(app);
60
+ return app;
61
+ }
62
+ /** Observe each connector's late-bound facts, keyed by its dedupe key. */
63
+ async function observeConnectors(app: TestApp): Promise<Map<string, unknown>> {
64
+ const seen = new Map<string, unknown>();
65
+ await app.engine.registerWorker(
66
+ "pr.delivery-connector",
67
+ async (job) => {
68
+ const vars = job.variables as Record<string, unknown>;
69
+ seen.set(String(vars.dedupeKey), vars.boundFacts);
70
+ return {};
71
+ },
72
+ { fetchVariables: ["boundFacts", "dedupeKey"] },
73
+ );
74
+ return seen;
75
+ }
76
+ async function rootVars(app: TestApp, pik: string): Promise<Map<string, unknown>> {
77
+ const vars = await app.engine.searchVariables({ processInstanceKey: pik } as never);
78
+ return new Map(vars.filter((v) => String(v.scopeKey) === pik).map((v) => [v.name, JSON.parse(v.value) as unknown]));
79
+ }
80
+ async function openTask(app: TestApp, suffix: string) {
81
+ const open = await app.engine.searchUserTasks({ state: "CREATED" });
82
+ return open.find((t) => t.elementId?.endsWith(suffix));
83
+ }
84
+
85
+ test("a blocked agent's contract escalation carries ITS OWN report; siblings and the root never mix results", async () => {
86
+ const app = await boot();
87
+ const connectors = await observeConnectors(app);
88
+ await app.engine.registerWorker("senior:a", async () => ({ status: "done", pr: PR_A, summary: "opened PR 1" }));
89
+ await app.engine.registerWorker("senior:b", async () => ({
90
+ status: "blocked",
91
+ summary: "workspace clone not provisioned: repository unset",
92
+ question: "Which repository should I clone?",
93
+ transcriptUrl: "http://localhost:3000/app/api/agentic/transcripts/w/42",
94
+ }));
95
+
96
+ const run = await runDeliveryGraph(app.engine, TWO_CHAINS, { repoless: true });
97
+ assert.ok(run.ok, JSON.stringify(run));
98
+ const pik = run.handle.processInstanceKey;
99
+ await app.settle();
100
+
101
+ // `a` met its contract and handed ITS pr to `ca`; `b` parked on its own contract escalation.
102
+ assert.deepEqual(connectors.get("ca-1"), [{ from: "a", name: "pr", value: PR_A }]);
103
+ assert.equal(connectors.has("cb-1"), false, "the blocked producer's consumer did not fire");
104
+ const esc = await openTask(app, "__contract");
105
+ assert.ok(esc, "b parked on its producer-contract escalation");
106
+ // What the OPERATOR sees: the Tasks inbox "Decision context" (`user_tasks.question`) projected by
107
+ // the poller for a running delivery run — previously the static "A scheduled delivery-graph step is
108
+ // waiting to be completed." for every agent escalation twin.
109
+ const ts = new Date().toISOString();
110
+ await deliveryGraphRuns(app.db).insert({ run_key: "r-node-scope", digest: "d", status: "running", process_key: pik, created_at: ts, updated_at: ts } as never);
111
+ await pollUserTasks(app.db, app.engine);
112
+ const row = await userTasks(app.db).get(String(esc.userTaskKey));
113
+ const prompt = String(row?.question ?? "");
114
+ assert.match(prompt, /Reported status=blocked/);
115
+ assert.match(prompt, /Agent report: workspace clone not provisioned: repository unset/);
116
+ assert.match(prompt, /Agent question: Which repository should I clone\?/);
117
+ assert.match(prompt, /Transcript: http:\/\/localhost:3000\/app\/api\/agentic\/transcripts\/w\/42/);
118
+ assert.match(prompt, /Retry this step/, "the prompt names how to resolve it");
119
+ assert.doesNotMatch(prompt, /opened PR 1/, "a sibling's report never leaks into this node's escalation");
120
+
121
+ // The shared root holds no agent result a sibling could read back.
122
+ const root = await rootVars(app, pik);
123
+ for (const name of ["status", "summary", "question", "pr", "value", "decision"]) {
124
+ assert.equal(root.has(name), false, `root must not carry the node-local '${name}'`);
125
+ }
126
+ assert.equal(root.get("n0_pr") ?? root.get(`${"n0"}_pr`), PR_A, "a's emit still publishes onward as <el>_pr");
127
+ });
128
+
129
+ test("retry-node: decision=retry re-runs the job with the operator's note; a status-less success then passes the gate", async () => {
130
+ const app = await boot();
131
+ const connectors = await observeConnectors(app);
132
+ await app.engine.registerWorker("senior:a", async () => ({ status: "done", pr: PR_A }));
133
+ const bPrompts: unknown[] = [];
134
+ await app.engine.registerWorker(
135
+ "senior:b",
136
+ async (job) => {
137
+ bPrompts.push((job.variables as Record<string, unknown>).appendPrompt);
138
+ // 1st attempt: blocked. 2nd attempt: succeeds WITHOUT reporting a status (a `completed` ACP
139
+ // outcome derives no flat status) — the stale `blocked` must not fail the gate again.
140
+ return bPrompts.length === 1 ? { status: "blocked", summary: "no repo" } : { pr: PR_B };
141
+ },
142
+ { fetchVariables: ["appendPrompt"] },
143
+ );
144
+
145
+ const run = await runDeliveryGraph(app.engine, TWO_CHAINS, { repoless: true });
146
+ assert.ok(run.ok, JSON.stringify(run));
147
+ await app.settle();
148
+ const esc = await openTask(app, "__contract");
149
+ assert.ok(esc, "b parked on its contract escalation");
150
+
151
+ await app.engine.completeUserTask(esc.userTaskKey, { decision: "retry", escalationNote: "clone acme/repo" });
152
+ await app.settle();
153
+
154
+ assert.equal(bPrompts.length, 2, "the retry re-ran b's job");
155
+ assert.match(String(bPrompts[1]), /Operator guidance for this retry: clone acme\/repo/);
156
+ assert.equal(await openTask(app, "__contract"), undefined, "the status-less rerun passed the contract gate");
157
+ assert.deepEqual(connectors.get("cb-1"), [{ from: "b", name: "pr", value: PR_B }], "b's retried PR threads onward");
158
+ const flows = (app.snapshot().takenSequenceFlows ?? []) as { to: string }[];
159
+ assert.ok(flows.some((f) => f.to === "End"), "the graph completed");
160
+ });
161
+
162
+ test("retry-node: consecutive retries never accumulate stale operator notes in the prompt", async () => {
163
+ // Regression (PR #863 adversarial review): the retry reset must re-derive `appendPrompt` from the
164
+ // runner-seeded `nodeInputs` baseline — building on the live var carried retry 1's note into retry
165
+ // 2's prompt, growing one stale "Operator guidance…" paragraph per consecutive retry.
166
+ const app = await boot();
167
+ await observeConnectors(app);
168
+ await app.engine.registerWorker("senior:a", async () => ({ status: "done", pr: PR_A }));
169
+ const bPrompts: unknown[] = [];
170
+ await app.engine.registerWorker(
171
+ "senior:b",
172
+ async (job) => {
173
+ bPrompts.push((job.variables as Record<string, unknown>).appendPrompt);
174
+ // Every attempt reports blocked until the THIRD, which succeeds — forcing TWO retries.
175
+ return bPrompts.length < 3 ? { status: "blocked", summary: "no repo" } : { pr: PR_B };
176
+ },
177
+ { fetchVariables: ["appendPrompt"] },
178
+ );
179
+
180
+ const run = await runDeliveryGraph(app.engine, TWO_CHAINS, { repoless: true });
181
+ assert.ok(run.ok, JSON.stringify(run));
182
+ await app.settle();
183
+
184
+ const first = await openTask(app, "__contract");
185
+ assert.ok(first, "b parked on its contract escalation");
186
+ await app.engine.completeUserTask(first.userTaskKey, { decision: "retry", escalationNote: "first note" });
187
+ await app.settle();
188
+ assert.equal(bPrompts.length, 2, "retry 1 re-ran b's job");
189
+
190
+ const second = await openTask(app, "__contract");
191
+ assert.ok(second, "b parked again after retry 1 reported blocked");
192
+ await app.engine.completeUserTask(second.userTaskKey, { decision: "retry", escalationNote: "second note" });
193
+ await app.settle();
194
+ assert.equal(bPrompts.length, 3, "retry 2 re-ran b's job");
195
+
196
+ assert.match(String(bPrompts[1]), /Operator guidance for this retry: first note/);
197
+ assert.match(String(bPrompts[2]), /Operator guidance for this retry: second note/, "retry 2 carries its own note");
198
+ assert.doesNotMatch(String(bPrompts[2]), /first note/, "retry 2's prompt does NOT accumulate retry 1's note");
199
+ assert.equal((String(bPrompts[2]).match(/Operator guidance for this retry:/g) ?? []).length, 1, "exactly one guidance paragraph");
200
+ assert.equal(await openTask(app, "__contract"), undefined, "the third attempt passed the contract gate");
201
+ });
202
+
203
+ test("retry-node: a retry with an omitted operator note never reuses the previous attempt's worker `note` result", async () => {
204
+ // Regression (PR #863 Copilot "Previously missed", deliveryGraphCompiler.ts:2156): the escalation
205
+ // note control shared the plain name `note` with a WORKER RESULT field the built-in plan prompt
206
+ // returns (resources/prompts/plan.md). Declared node-local via ESCALATION_LOCAL_VARS, a worker's
207
+ // `note` landed in the same subProcess scope the retry reset reads for "Operator guidance…" — so
208
+ // completing the escalation with `{ decision: "retry" }` and NO note (the form contract permits
209
+ // it) fed the worker's own stale note back to the agent as if the operator had written it. The
210
+ // control is renamed `escalationNote` (a name no worker result contract may use), so an omitted
211
+ // operator note appends NOTHING.
212
+ const app = await boot();
213
+ await observeConnectors(app);
214
+ await app.engine.registerWorker("senior:a", async () => ({ status: "done", pr: PR_A }));
215
+ const bPrompts: unknown[] = [];
216
+ await app.engine.registerWorker(
217
+ "senior:b",
218
+ async (job) => {
219
+ bPrompts.push((job.variables as Record<string, unknown>).appendPrompt);
220
+ // 1st attempt: blocked AND returns a worker `note` (the plan.md contract). 2nd: succeeds.
221
+ return bPrompts.length === 1
222
+ ? { status: "blocked", summary: "no repo", note: "worker-produced note" }
223
+ : { pr: PR_B };
224
+ },
225
+ { fetchVariables: ["appendPrompt"] },
226
+ );
227
+
228
+ const run = await runDeliveryGraph(app.engine, TWO_CHAINS, { repoless: true });
229
+ assert.ok(run.ok, JSON.stringify(run));
230
+ await app.settle();
231
+ const esc = await openTask(app, "__contract");
232
+ assert.ok(esc, "b parked on its contract escalation");
233
+
234
+ // Retry WITHOUT an operator note — the escalation form permits omitting it.
235
+ await app.engine.completeUserTask(esc.userTaskKey, { decision: "retry" });
236
+ await app.settle();
237
+
238
+ assert.equal(bPrompts.length, 2, "the retry re-ran b's job");
239
+ assert.doesNotMatch(
240
+ String(bPrompts[1]),
241
+ /Operator guidance for this retry:/,
242
+ "an omitted operator note appends NO guidance paragraph",
243
+ );
244
+ assert.doesNotMatch(
245
+ String(bPrompts[1]),
246
+ /worker-produced note/,
247
+ "the previous attempt's worker `note` result is never fed back as operator guidance",
248
+ );
249
+ assert.equal(await openTask(app, "__contract"), undefined, "the retried run passed the contract gate");
250
+ });
251
+
252
+ test("a continue resolution (no decision) supplies the missing emit and does not loop", async () => {
253
+ const app = await boot();
254
+ const connectors = await observeConnectors(app);
255
+ await app.engine.registerWorker("senior:a", async () => ({ status: "done", pr: PR_A }));
256
+ let bRuns = 0;
257
+ await app.engine.registerWorker("senior:b", async () => {
258
+ bRuns++;
259
+ return { status: "blocked", summary: "no repo" };
260
+ });
261
+ const run = await runDeliveryGraph(app.engine, TWO_CHAINS, { repoless: true });
262
+ assert.ok(run.ok, JSON.stringify(run));
263
+ await app.settle();
264
+ const esc = await openTask(app, "__contract");
265
+ assert.ok(esc);
266
+ await app.engine.completeUserTask(esc.userTaskKey, { value: PR_B });
267
+ await app.settle();
268
+ assert.equal(bRuns, 1, "no retry without decision=retry");
269
+ assert.deepEqual(connectors.get("cb-1"), [{ from: "b", name: "pr", value: PR_B }], "the supplied PR resumes b");
270
+ });
271
+
272
+ test("a boolean single-emit Continue coerces the textfield string to a real boolean (typed-resume regression)", async () => {
273
+ // Regression guard (PR #863 Copilot High, thread r4182488193): the escalation form captures `value`
274
+ // as textfield TEXT, but a `boolean` emit's downstream guard compares against a typed `= true`. The
275
+ // single-emit resume must coerce the string to a real boolean — a raw `"true"` string would skip the
276
+ // guard. Verified end-to-end against the WASM engine (the FEEL `matches` has no inline `(?i:)` flag,
277
+ // so the coercion lower-cases the input).
278
+ const app = await boot();
279
+ const connectors = await observeConnectors(app);
280
+ const graph = {
281
+ name: "bool-coerce",
282
+ nodes: [
283
+ { id: "b", kind: "agent", agent: { jobType: "senior:b" }, emits: [{ name: "ok", type: "boolean" }] },
284
+ { id: "cb", kind: "connector", connector: { target: "slack", dedupeKey: "cb-1", payload: { ok: "b.ok" } } },
285
+ ],
286
+ edges: [{ from: "b.ok", to: "cb" }],
287
+ } as never;
288
+ await app.engine.registerWorker("senior:b", async () => ({ status: "blocked", summary: "no repo" }));
289
+ const run = await runDeliveryGraph(app.engine, graph, { repoless: true });
290
+ assert.ok(run.ok, JSON.stringify(run));
291
+ await app.settle();
292
+ const esc = await openTask(app, "__contract");
293
+ assert.ok(esc);
294
+ await app.engine.completeUserTask(esc.userTaskKey, { value: "true" });
295
+ await app.settle();
296
+ assert.deepEqual(connectors.get("cb-1"), [{ from: "b", name: "ok", value: true }], "the textfield \"true\" resumes b as a real boolean");
297
+ });
298
+
299
+ test("preflight: a node whose nodeInputs were lost raises an incident naming the cause, and proceeds once restored", async () => {
300
+ const app = await boot();
301
+ await observeConnectors(app);
302
+ // `a` simulates the root-variable loss (nano-bpm#1331) while it runs: it completes with
303
+ // `nodeInputs: null`, which propagates to the root `nodeInputs` the runner seeded.
304
+ let seed: unknown;
305
+ await app.engine.registerWorker("senior:a", async () => ({ status: "done", pr: PR_A, nodeInputs: null }));
306
+ let bRuns = 0;
307
+ const bJobTypes: unknown[] = [];
308
+ await app.engine.registerWorker(
309
+ "senior:b",
310
+ async (job) => {
311
+ bRuns++;
312
+ bJobTypes.push((job.variables as Record<string, unknown>).jobType);
313
+ return { status: "done", pr: PR_B };
314
+ },
315
+ { fetchVariables: ["jobType"] },
316
+ );
317
+ const graph = {
318
+ name: "preflight e2e",
319
+ nodes: [
320
+ { id: "a", kind: "agent", agent: { jobType: "senior:a" }, emits: [{ name: "pr", type: "pr" }] },
321
+ { id: "b", kind: "agent", agent: { jobType: "senior:b" }, emits: [{ name: "pr", type: "pr" }] },
322
+ ],
323
+ edges: [{ from: "a.pr", to: "b" }],
324
+ } as DeliveryGraph;
325
+ const run = await runDeliveryGraph(app.engine, graph, { repoless: true });
326
+ assert.ok(run.ok, JSON.stringify(run));
327
+ seed = run.handle.nodeInputs;
328
+ const pik = run.handle.processInstanceKey;
329
+ await app.settle();
330
+
331
+ assert.equal(bRuns, 0, "b never ran blind");
332
+ const incidents = await app.engine.searchIncidents({ processInstanceKey: pik } as never);
333
+ const inc = incidents.find((i) => /nodeInputs\.n1 is missing/.test(String(i.errorMessage ?? "")));
334
+ assert.ok(inc, `an incident names the missing node inputs, got ${JSON.stringify(incidents)}`);
335
+
336
+ // Repair: restore the root variables, resolve the incident → b runs and the graph completes.
337
+ await app.engine.setVariables({ scopeKey: pik, variables: { nodeInputs: seed } });
338
+ await app.engine.resolveIncident({ incidentKey: String(inc.incidentKey) });
339
+ await app.settle();
340
+ assert.equal(bRuns, 1, "b ran once its inputs were restored");
341
+ // Do NOT bless leaf-only resolution as a CONFIGURED recovery (PR #863 Copilot Medium, thread
342
+ // r4181322055). The KNOWN LIMITATION (nano-workforce#866) is that resolving the leaf incident
343
+ // re-evaluates ONLY the leaf's inputs — the subProcess-level config mappings ran once at subProcess
344
+ // entry and are NOT re-mapped on resolve — so this leaf-only path runs b BLIND (`jobType` is null).
345
+ // Assert that honestly: this scenario proves the preflight incident PREVENTS the blind run up front
346
+ // (bRuns stayed 0 while the config was missing) and that resolution unblocks the node — NOT that
347
+ // leaf-resolution restores the config. The correct recovery (re-entering the sub-process so its
348
+ // input mappings re-evaluate) is a separate operator action the testkit cannot drive via
349
+ // resolveIncident; SPEC documents it as the required workaround.
350
+ assert.equal(
351
+ bJobTypes[0] ?? null,
352
+ null,
353
+ "leaf-only resolution re-runs b BLIND (the #866 known limitation) — the preflight's value is blocking the blind run BEFORE a job exists, not restoring config on resolve",
354
+ );
355
+ const flows = (app.snapshot().takenSequenceFlows ?? []) as { to: string }[];
356
+ assert.ok(flows.some((f) => f.to === "End"), "the graph completed");
357
+ });
358
+ });
@@ -25,6 +25,7 @@ import type { CommandResult } from "../app/readiness.ts";
25
25
  import { readConnectorInput } from "../workers/delivery-connector/worker.ts";
26
26
  import { __setProbeExecForTest } from "../workers/readiness-probe/worker.ts";
27
27
  import { prepareDeliveryGraph, runDeliveryGraph } from "../app/deliveryRunner.ts";
28
+ import { isDeliveryEscalationTwin } from "../app/deliveryHuman.ts";
28
29
  import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
29
30
  import { deterministicProbeSeam } from "./support/probe-exec.ts";
30
31
 
@@ -148,14 +149,17 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
148
149
 
149
150
  // The human node scheduled its per-node user task (the isDeliveryHumanElement convention id).
150
151
  const open = await app.engine.searchUserTasks({ state: "CREATED" });
151
- const human = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !t.elementId?.endsWith("__esc"));
152
+ const human = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !isDeliveryEscalationTwin(t.elementId));
152
153
  assert.ok(human, `a human user task is open, got ${JSON.stringify(open.map((t) => t.elementId))}`);
153
154
 
154
155
  // Before the human completes, the connector has NOT fired — the fan-in edge from `h` gates it.
155
156
  assert.equal((await deliveryConnectorDispatches(app.db).find({})).length, 0, "connector waits on the human edge");
156
157
 
157
- // Complete the human with a resolved artifact — its typed emit late-binds downstream.
158
- await app.engine.completeUserTask(human.userTaskKey, { resolvedArtifact: "ARTIFACT-1", humanOutcome: "completed" });
158
+ // Complete the human with a resolved artifact — its typed emit late-binds downstream. The value is a
159
+ // canonical `pkg@version` handle: the human node's required-artifact completion gate (thread
160
+ // r4198662345) validates the captured handle against the SAME `pkg@version` grammar the canonical
161
+ // `coerceFactValue` enforces, so a non-conforming handle would re-park the task instead of binding.
162
+ await app.engine.completeUserTask(human.userTaskKey, { resolvedArtifact: "pkg@1.0.0", humanOutcome: "completed" });
159
163
  await app.settle();
160
164
 
161
165
  // The connector fired exactly once (fan-in of the wait AND the human both satisfied), and it
@@ -163,7 +167,7 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
163
167
  const rows = await deliveryConnectorDispatches(app.db).find({ dedupe_key: "c-e2e-1" });
164
168
  assert.equal(rows.length, 1, "the connector fired exactly once");
165
169
  assert.equal(rows[0].outcome, "delivered");
166
- assert.deepEqual(connectorBoundFacts, [{ from: "h", name: "art", value: "ARTIFACT-1" }], "the human fact late-binds into the connector");
170
+ assert.deepEqual(connectorBoundFacts, [{ from: "h", name: "art", value: "pkg@1.0.0" }], "the human fact late-binds into the connector");
167
171
 
168
172
  // The graph reached End — the fan-in join released only after BOTH upstream branches completed.
169
173
  assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the graph reached its End event");
@@ -399,7 +403,7 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
399
403
 
400
404
  // Complete the UNRELATED human node — an upstream event with no edge to the wait.
401
405
  const open = await app.engine.searchUserTasks({ state: "CREATED" });
402
- const side = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !t.elementId?.endsWith("__esc"));
406
+ const side = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !isDeliveryEscalationTwin(t.elementId));
403
407
  assert.ok(side, "the unrelated human task is open");
404
408
  await app.engine.completeUserTask(side.userTaskKey, { value: "done", humanOutcome: "completed" });
405
409
  await app.settle();
@@ -410,7 +414,7 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
410
414
  // for a human rather than silently wedging or falsely resolving.
411
415
  assert.ok(!takenFlows(app).some((f) => f.endsWith("->End")), "the wait branch never falsely resolves to End");
412
416
  await app.advanceTime(2_100);
413
- const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).filter((t) => t.elementId?.endsWith("__esc"));
417
+ const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).filter((t) => t.elementId !== undefined && isDeliveryEscalationTwin(t.elementId));
414
418
  assert.ok(
415
419
  esc.length >= 1,
416
420
  `the parked wait escalates (bounded), never falsely resolved by the unrelated event, got ${JSON.stringify((await app.engine.searchUserTasks({ state: "CREATED" })).map((t) => t.elementId))}`,
@@ -828,7 +832,7 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
828
832
 
829
833
  // Bounded, not wedged: the gate escalates once its poll budget elapses.
830
834
  await app.advanceTime(2_100);
831
- const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).filter((t) => t.elementId?.endsWith("__esc"));
835
+ const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).filter((t) => t.elementId?.includes("__esc"));
832
836
  assert.ok(esc.length >= 1, `the unresolved-target gate escalates (bounded), got ${JSON.stringify((await app.engine.searchUserTasks({ state: "CREATED" })).map((t) => t.elementId))}`);
833
837
  });
834
838
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.201.0",
3
+ "version": "0.201.2",
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",
@@ -0,0 +1,41 @@
1
+ {
2
+ "id": "delivery-escalation",
3
+ "schemaVersion": 18,
4
+ "type": "default",
5
+ "components": [
6
+ {
7
+ "type": "text",
8
+ "label": "Now do this",
9
+ "text": "This task's instruction is shown in the Decision context above."
10
+ },
11
+ {
12
+ "type": "select",
13
+ "key": "decision",
14
+ "label": "Resolution",
15
+ "defaultValue": "continue",
16
+ "description": "Escalation of an agent or connector step: Retry re-runs the step (for an agent step, the note below is passed to the agent as guidance; a connector step ignores the note). Continue completes it as resolved — supply the emitted value if the step was finished out of band.",
17
+ "values": [
18
+ {
19
+ "label": "Continue",
20
+ "value": "continue"
21
+ },
22
+ {
23
+ "label": "Retry this step",
24
+ "value": "retry"
25
+ }
26
+ ]
27
+ },
28
+ {
29
+ "type": "textfield",
30
+ "key": "value",
31
+ "label": "Emitted value (only when this step emits exactly one fact)",
32
+ "description": "The typed value this step hands forward to its downstream dependents, applied on Continue. It is applied ONLY when the step emits exactly one fact AND that fact is actually consumed downstream: leave it blank for a click-done step that emits nothing (or whose sole emit is routing-only/unconsumed, where any entry is discarded — nothing reads it), and for a step emitting more than one fact any entry here is discarded (a single value cannot satisfy several distinct facts) — choose \"Retry this step\" to actually produce those facts."
33
+ },
34
+ {
35
+ "type": "textarea",
36
+ "key": "escalationNote",
37
+ "label": "Note (optional)",
38
+ "description": "Optional. On Retry of an AGENT step, passed to the agent as guidance for the next attempt. A connector-step retry ignores this note."
39
+ }
40
+ ]
41
+ }
@@ -11,13 +11,14 @@
11
11
  {
12
12
  "type": "textfield",
13
13
  "key": "value",
14
- "label": "Emitted value (only if this step emits a fact)",
15
- "description": "The typed value this step hands forward to its downstream dependents. Leave blank for a click-done step that emits nothing."
14
+ "label": "Emitted value (the single value this step hands forward)",
15
+ "description": "The typed value this task hands forward to its downstream dependents. For a human step it is the one value-emitting fact; for a timed-out wait gate it is the awaited value — which may be an artifact handle (entered here and mapped onto the gate's resolved value). Leave it blank when the step emits nothing. One field carries at most one value: a human step that must emit several facts is refused at authoring time, and a wait gate with more than one emit leaves this field inert — Continue simply acknowledges this escalation and proceeds without those facts."
16
16
  },
17
17
  {
18
18
  "type": "textarea",
19
19
  "key": "note",
20
- "label": "Note (optional)"
20
+ "label": "Note (optional)",
21
+ "description": "Optional operator note."
21
22
  }
22
23
  ]
23
24
  }
@@ -80,6 +80,8 @@
80
80
  <nano:extend name="prNumber" type="integer" optional="true" />
81
81
  <nano:extend name="prUrl" type="string" optional="true" />
82
82
  <nano:extend name="abandonUrl" type="string" optional="true" />
83
+ <nano:extend name="agentSlaJobType" type="string" optional="true" />
84
+ <nano:extend name="agentSlaTimeout" type="string" optional="true" />
83
85
  </nano:shape>
84
86
  <nano:shape id="EscalationOut" name="Record escalation — result">
85
87
  <nano:extend name="escalationId" type="integer" optional="true" />
@@ -268,6 +270,7 @@
268
270
  <zeebe:input source="=&#34;blocked&#34;" target="status" />
269
271
  <zeebe:input source="=false" target="recordRound" />
270
272
  <zeebe:input source="=if (noProgressQuestion != null and trim(noProgressQuestion) != &#34;&#34;) then noProgressQuestion else &#34;Round &#34; + string(round) + &#34; reported the review comments were addressed, but the PR head did not advance (no commit was pushed), so another review round would loop on identical code. A human must decide how to proceed (reply to resume the loop).&#34;" target="question" />
273
+ <zeebe:input source="=null" target="agentSlaJobType" />
271
274
  </zeebe:ioMapping>
272
275
  </bpmn:extensionElements>
273
276
  <bpmn:incoming>f_huskEscalate</bpmn:incoming>
@@ -307,6 +310,7 @@
307
310
  <zeebe:input source="=&#34;blocked&#34;" target="status" />
308
311
  <zeebe:input source="=false" target="recordRound" />
309
312
  <zeebe:input source="=&#34;No review arrived within the review-wait timeout (&#34; + reviewWaitTimeout + &#34;). A human must decide how to proceed (reply to resume the loop).&#34;" target="question" />
313
+ <zeebe:input source="=null" target="agentSlaJobType" />
310
314
  </zeebe:ioMapping>
311
315
  </bpmn:extensionElements>
312
316
  <bpmn:incoming>f_reviewStalled</bpmn:incoming>
@@ -319,6 +323,9 @@
319
323
  <zeebe:property name="io.nanobpm.dataEnvelope.in" value="EscalationIn" />
320
324
  <zeebe:property name="io.nanobpm.dataEnvelope.out" value="EscalationOut" />
321
325
  </zeebe:properties>
326
+ <zeebe:ioMapping>
327
+ <zeebe:input source="=null" target="agentSlaJobType" />
328
+ </zeebe:ioMapping>
322
329
  </bpmn:extensionElements>
323
330
  <bpmn:incoming>f_escalate</bpmn:incoming>
324
331
  <bpmn:outgoing>f_escGate</bpmn:outgoing>
@@ -344,6 +351,7 @@
344
351
  <zeebe:input source="=&#34;blocked&#34;" target="status" />
345
352
  <zeebe:input source="=false" target="recordRound" />
346
353
  <zeebe:input source="=&#34;Not converged after &#34; + string(maxRounds) + &#34; rounds. A human must decide how to proceed.&#34;" target="question" />
354
+ <zeebe:input source="=null" target="agentSlaJobType" />
347
355
  </zeebe:ioMapping>
348
356
  </bpmn:extensionElements>
349
357
  <bpmn:incoming>f_guardMax</bpmn:incoming>
@@ -452,6 +460,7 @@
452
460
  <zeebe:input source="=&#34;blocked&#34;" target="status" />
453
461
  <zeebe:input source="=false" target="recordRound" />
454
462
  <zeebe:input source="=convergeBlockReason" target="question" />
463
+ <zeebe:input source="=null" target="agentSlaJobType" />
455
464
  </zeebe:ioMapping>
456
465
  </bpmn:extensionElements>
457
466
  <bpmn:incoming>f_ackEscalate</bpmn:incoming>
@@ -491,6 +500,7 @@
491
500
  <zeebe:input source="=&#34;blocked&#34;" target="status" />
492
501
  <zeebe:input source="=false" target="recordRound" />
493
502
  <zeebe:input source="=scopeBlockReason" target="question" />
503
+ <zeebe:input source="=null" target="agentSlaJobType" />
494
504
  <zeebe:output source="=true" target="scopePending" />
495
505
  </zeebe:ioMapping>
496
506
  </bpmn:extensionElements>
@@ -11,6 +11,8 @@
11
11
  <nano:extend name="transcriptUrl" type="string" optional="true" />
12
12
  <nano:extend name="pr" type="string" optional="true" />
13
13
  <nano:extend name="deliveryVerified" type="boolean" optional="true" />
14
+ <nano:extend name="agentSlaJobType" type="string" optional="true" />
15
+ <nano:extend name="agentSlaTimeout" type="string" optional="true" />
14
16
  </nano:shape>
15
17
  </nano:shapes>
16
18
  </bpmn:extensionElements>
@@ -91,6 +93,7 @@
91
93
  <zeebe:property name="io.nanobpm.dataEnvelope.in" value="RecordEscalationIn" />
92
94
  </zeebe:properties>
93
95
  <zeebe:ioMapping>
96
+ <zeebe:input source="=null" target="agentSlaJobType" />
94
97
  <zeebe:output source="=question" target="question" />
95
98
  </zeebe:ioMapping>
96
99
  </bpmn:extensionElements>
@@ -104,7 +107,7 @@
104
107
  <zeebe:property name="io.nanobpm.dataEnvelope.in" value="RecordEscalationIn" />
105
108
  </zeebe:properties>
106
109
  <zeebe:ioMapping>
107
- <zeebe:input source="=&#34;The implementation agent exceeded its time budget (SLA &#34; + agentSlaTimeout + &#34;) without completing — it is hung or looping. Check its progress or intervene, then answer to resume (the run continues on its deterministic feat/&#60;task.id&#62; branch), or abandon it.&#34;" target="question" />
110
+ <zeebe:input source="=&#34;senior:feature&#34;" target="agentSlaJobType" />
108
111
  <zeebe:output source="=question" target="question" />
109
112
  </zeebe:ioMapping>
110
113
  </bpmn:extensionElements>
@@ -36,6 +36,9 @@
36
36
  <zeebe:properties>
37
37
  <zeebe:property name="io.nanobpm.dataEnvelope.in" value="RecordTrialMergeIn" />
38
38
  </zeebe:properties>
39
+ <zeebe:ioMapping>
40
+ <zeebe:input source="=null" target="agentSlaJobType" />
41
+ </zeebe:ioMapping>
39
42
  </bpmn:extensionElements>
40
43
  <bpmn:incoming>mc_toRecord</bpmn:incoming>
41
44
  <bpmn:outgoing>mc_toGwTrial</bpmn:outgoing>
@@ -48,7 +51,7 @@
48
51
  </zeebe:properties>
49
52
  <zeebe:ioMapping>
50
53
  <zeebe:input source="=&#34;suite-failed&#34;" target="result" />
51
- <zeebe:input source="=&#34;The trial-merge agent exceeded its time budget (SLA &#34; + agentSlaTimeout + &#34;) without returning a machine-readable result — it is hung or looping. Acknowledge to record the timeout and decide whether to rerun the trial merge.&#34;" target="summary" />
54
+ <zeebe:input source="=&#34;senior:trial-merge&#34;" target="agentSlaJobType" />
52
55
  <zeebe:input source="=null" target="conflicts" />
53
56
  <zeebe:input source="=null" target="failing" />
54
57
  </zeebe:ioMapping>