@nanobpm/nano-workforce 0.201.0 → 0.201.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/SPEC.md +153 -0
- package/app/agentCompletion.test.ts +52 -0
- package/app/agentCompletion.ts +20 -5
- package/app/contracts.ts +9 -0
- package/app/deliveryGraph.test.ts +251 -0
- package/app/deliveryGraph.ts +334 -1
- package/app/deliveryGraphCompiler.test.ts +1345 -30
- package/app/deliveryGraphCompiler.ts +882 -240
- package/app/deliveryGraphDeploy.test.ts +472 -3
- package/app/deliveryGraphRun.ts +2 -2
- package/app/deliveryHuman.test.ts +59 -5
- package/app/deliveryHuman.ts +115 -3
- package/app/deliveryRunner.test.ts +3 -3
- package/app/redactText.ts +88 -0
- package/app/service.ts +16 -2
- package/e2e/delivery-graph-dispatch.e2e.ts +2 -1
- package/e2e/delivery-graph-node-scope.e2e.ts +358 -0
- package/e2e/delivery-graph.e2e.ts +11 -7
- package/package.json +1 -1
- package/resources/forms/delivery-escalation.form +41 -0
- package/resources/forms/delivery-human-generic.form +4 -3
|
@@ -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
|
|
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
|
-
|
|
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: "
|
|
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
|
|
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
|
|
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?.
|
|
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.
|
|
3
|
+
"version": "0.201.1",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -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 (
|
|
15
|
-
"description": "The typed value this
|
|
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
|
}
|