@nanobpm/nano-workforce 0.200.8 → 0.201.0
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 +16 -0
- package/SPEC.md +35 -0
- package/app/deliveryGraph.test.ts +10 -0
- package/app/deliveryGraphCompiler.test.ts +354 -10
- package/app/deliveryGraphCompiler.ts +296 -40
- package/app/deliveryRunner.ts +14 -5
- package/app/implementEscalationReason.test.ts +216 -0
- package/app/implementEscalationReason.ts +213 -0
- package/app/implementReconcile.test.ts +168 -11
- package/app/implementReconcile.ts +112 -18
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/roundChurn.test.ts +388 -0
- package/app/roundChurn.ts +265 -0
- package/app/roundProgress.test.ts +224 -1
- package/app/service.test.ts +7 -0
- package/app/service.ts +12 -0
- package/db/migrations/117_pull_requests_churn_escalated_round.sql +12 -0
- package/db/migrations/118_pull_requests_read_model_churn_escalated_round.sql +55 -0
- package/docs/agent-guide.md +10 -7
- package/e2e/delivery-graph.e2e.ts +570 -1
- package/e2e/feature-run.e2e.ts +8 -1
- package/e2e/plan-fanout.e2e.ts +75 -2
- package/package.json +1 -1
- package/resources/processes/implement-cell.bpmn +42 -17
- package/resources/processes/plan-fanout.bpmn +236 -211
- package/resources/prompts/feature.md +16 -1
- package/workers/progress-check/worker.ts +98 -1
- package/workers/readiness-probe/worker.test.ts +72 -1
- package/workers/readiness-probe/worker.ts +39 -0
- package/workers/reconcile-implement/worker.test.ts +31 -3
- package/workers/reconcile-implement/worker.ts +5 -0
- package/workers/record-feature-escalation/worker.test.ts +58 -3
- package/workers/record-feature-escalation/worker.ts +24 -10
package/docs/agent-guide.md
CHANGED
|
@@ -730,12 +730,15 @@ declared facts, which downstream edges bind.
|
|
|
730
730
|
> it — extend the gate's `poll.timeoutMs` and re-dispatch, or abandon the run. (Same for
|
|
731
731
|
> `onTimeout: "continue"`, which proceeds past the gate as not-ready with **no** human stop
|
|
732
732
|
> at all.)
|
|
733
|
+
>
|
|
734
|
+
> **Exception (#872):** a `wait` that declares `emits` **validates** its resume value — an
|
|
735
|
+
> omitted/invalid value **re-parks** the task rather than releasing not-ready.
|
|
733
736
|
|
|
734
737
|
> **Why the split?** Making the compile door the end of the agent surface closes a
|
|
735
|
-
> self-approval hole: the old flow handed the same caller
|
|
736
|
-
>
|
|
737
|
-
>
|
|
738
|
-
>
|
|
738
|
+
> self-approval hole: the old flow handed the same caller an approval token to re-submit with,
|
|
739
|
+
> so any holder of the API credential approved its own graph. With no dispatch affordance on the
|
|
740
|
+
> agent surface (capability by absence), the human in the cockpit is the only actor who can
|
|
741
|
+
> launch side effects.
|
|
739
742
|
|
|
740
743
|
### 9.3 Worked example — the cross-repo human-in-the-loop release
|
|
741
744
|
|
|
@@ -921,9 +924,9 @@ Semantics:
|
|
|
921
924
|
- **Set `poll.timeoutMs` to a realistic budget.** An epic reaching "fully merged" is a
|
|
922
925
|
multi-day, human-paced event, so the example gives it `poll: { everyMs: 300000, timeoutMs:
|
|
923
926
|
259200000 }` (re-probe every 5 minutes, budget 3 days). **Omitting `poll` inherits the
|
|
924
|
-
30-minute default** (§9.1) — the gate would escalate long before the epic lands
|
|
925
|
-
|
|
926
|
-
|
|
927
|
+
30-minute default** (§9.1) — the gate would escalate long before the epic lands. Because this
|
|
928
|
+
gate binds `prCount`, that escalation is **validated** (§9.2): it **re-parks** unless the
|
|
929
|
+
operator supplies a valid `prCount`, rather than silently releasing `start-b` as not-ready.
|
|
927
930
|
- On a fully-merged match it binds **`prCount`** (how many slice PRs the epic landed) as an
|
|
928
931
|
output fact, so a downstream node can consume it (parity with the `pr` kind's `mergedSha`).
|
|
929
932
|
- **It also gates a single-PR *feature run*, not just a plan-fanout epic.** The gate resolves the
|
|
@@ -21,8 +21,10 @@ import { after, before, describe, test } from "node:test";
|
|
|
21
21
|
import assert from "node:assert/strict";
|
|
22
22
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
23
23
|
import { connectorDedupeKey, deliveryConnectorDispatches, dispatchConnector } from "../app/deliveryConnector.ts";
|
|
24
|
+
import type { CommandResult } from "../app/readiness.ts";
|
|
24
25
|
import { readConnectorInput } from "../workers/delivery-connector/worker.ts";
|
|
25
|
-
import {
|
|
26
|
+
import { __setProbeExecForTest } from "../workers/readiness-probe/worker.ts";
|
|
27
|
+
import { prepareDeliveryGraph, runDeliveryGraph } from "../app/deliveryRunner.ts";
|
|
26
28
|
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
27
29
|
import { deterministicProbeSeam } from "./support/probe-exec.ts";
|
|
28
30
|
|
|
@@ -41,6 +43,18 @@ function takenFlows(app: TestApp): string[] {
|
|
|
41
43
|
.map((f) => `${f.from}->${f.to}`);
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
/** Read the re-parked `__esc` escalation user task after a resume. The resume-validation gateway's
|
|
47
|
+
* loop-back (esc → gate → esc) is engine-synchronous: `completeUserTask` drains the engine to
|
|
48
|
+
* quiescence, so the fresh task already exists in the read model by the time the completion returns.
|
|
49
|
+
* One `settle()` is the deterministic completion signal — the drain fixpoint absorbs any follow-on
|
|
50
|
+
* scheduling turn — after which the task is read ONCE. No retry loop (AGENTS.md: a test must pass or
|
|
51
|
+
* fail deterministically on a single run), so if the re-park ever regresses this fails loudly instead
|
|
52
|
+
* of masking it behind a retry budget. */
|
|
53
|
+
async function waitForEsc(app: TestApp): Promise<{ userTaskKey: string; elementId?: string } | undefined> {
|
|
54
|
+
await app.settle();
|
|
55
|
+
return (await app.engine.searchUserTasks({ state: "CREATED" })).find((t) => t.elementId?.endsWith("__esc"));
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
/** Boot a fresh app per scenario (the WASM engine's taken-flow snapshot is engine-global cumulative). */
|
|
45
59
|
async function boot(dir: string): Promise<TestApp> {
|
|
46
60
|
return bootTestApp(APP_ROOT, { env: { ...GITHUB_ENV, NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
|
|
@@ -185,6 +199,53 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
|
|
|
185
199
|
assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the late-bound wait resolved and the graph reached End");
|
|
186
200
|
});
|
|
187
201
|
|
|
202
|
+
test("#876 review: a DOTTED command literal target (check.sh) is NOT misclassified as an unresolved fact-ref — the gate probes it and resolves", async () => {
|
|
203
|
+
const app = track(await boot(freshDir()));
|
|
204
|
+
|
|
205
|
+
// PR #876 review finding: the #872 fail-closed guard keyed on a fact-ref SYNTAX test
|
|
206
|
+
// (`isFactRefTarget`), which a valid dotted command target like `check.sh` also satisfies — so the
|
|
207
|
+
// gate would have parked forever without ever running the probe. The fix moves the
|
|
208
|
+
// resolved/unresolved provenance into the COMPILER (an unresolved late-bind writes null), so a
|
|
209
|
+
// dotted literal reaches the probe verbatim. The deterministic seam maps the hermetic `true`/
|
|
210
|
+
// `false` builtins; `check.sh` itself would be an escape, so the upstream agent late-binds the
|
|
211
|
+
// dotted literal — proving it flows through the late-bind machinery to the probe UNTOUCHED.
|
|
212
|
+
let probed: string[] = [];
|
|
213
|
+
const restore = __setProbeExecForTest({
|
|
214
|
+
run(command: string): Promise<CommandResult> {
|
|
215
|
+
probed.push(command);
|
|
216
|
+
// The dotted literal is the probe under test; treat it green so the gate can resolve.
|
|
217
|
+
return Promise.resolve({ code: 0, stdout: "", stderr: "" });
|
|
218
|
+
},
|
|
219
|
+
httpGet(): Promise<never> {
|
|
220
|
+
return Promise.reject(new Error("unexpected http probe"));
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
try {
|
|
224
|
+
await app.engine.registerWorker("senior:demo", async () => ({ cmd: "check.sh" }));
|
|
225
|
+
|
|
226
|
+
const graph: DeliveryGraph = {
|
|
227
|
+
name: "e2e dotted command literal",
|
|
228
|
+
nodes: [
|
|
229
|
+
{ id: "a", kind: "agent", agent: { jobType: "senior:demo" }, emits: [{ name: "cmd", type: "string" }] },
|
|
230
|
+
{ id: "w", kind: "wait", wait: { kind: "command", target: "a.cmd", poll: { everyMs: 5, backoff: "fixed" } } },
|
|
231
|
+
],
|
|
232
|
+
edges: [{ from: "a.cmd", to: "w" }],
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S", repoless: true });
|
|
236
|
+
assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
|
|
237
|
+
await app.settle();
|
|
238
|
+
|
|
239
|
+
// The dotted late-bound value reached the probe VERBATIM (not parked as an "unresolved fact-ref")
|
|
240
|
+
// and the gate resolved on it — the graph reached End.
|
|
241
|
+
assert.deepEqual(probed, ["check.sh"], "the dotted command target was probed, not misclassified as unresolved");
|
|
242
|
+
assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the dotted-target wait resolved and the graph reached End");
|
|
243
|
+
} finally {
|
|
244
|
+
__setProbeExecForTest(restore);
|
|
245
|
+
probed = [];
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
188
249
|
test("#731 producer contract gate: an agent that completes with status=in_progress and a null required emit escalates AT the producer, does NOT thread null downstream, and resumes", async () => {
|
|
189
250
|
const app = track(await boot(freshDir()));
|
|
190
251
|
|
|
@@ -355,4 +416,512 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
|
|
|
355
416
|
`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))}`,
|
|
356
417
|
);
|
|
357
418
|
});
|
|
419
|
+
|
|
420
|
+
test("#872 timeout escalation value BINDS the node's emit: a timed-out agent, unstuck with `value`, threads that value downstream (not null)", async () => {
|
|
421
|
+
const app = track(await boot(freshDir()));
|
|
422
|
+
|
|
423
|
+
// The instance-216710 failure mode: an agent node that owes a required `pr` emit TIMES OUT (its job
|
|
424
|
+
// never completes). Its bounded `=nodeTimeout` boundary fires and parks the node on its `__esc`
|
|
425
|
+
// timeout escalation. A human/agent unsticks it by supplying the eventually-created PR as `value`.
|
|
426
|
+
// Before #872 the `__esc` had NO resume output mapping, so completing it published `open_pr = null`
|
|
427
|
+
// — the downstream connector then bound `pr = null`. The fix makes the timeout `__esc` resumable:
|
|
428
|
+
// `value` maps onto the node's emit-source var and the subProcess republishes `open_pr` non-null.
|
|
429
|
+
// We deliberately DO NOT register `senior:demo`, so the agent job parks and only the timer fires.
|
|
430
|
+
let connectorBoundFacts: unknown;
|
|
431
|
+
let connectorFired = 0;
|
|
432
|
+
await app.engine.registerWorker(
|
|
433
|
+
"pr.delivery-connector",
|
|
434
|
+
async (job) => {
|
|
435
|
+
connectorFired++;
|
|
436
|
+
const vars = job.variables as Record<string, unknown>;
|
|
437
|
+
connectorBoundFacts = vars.boundFacts;
|
|
438
|
+
const { target, payload, boundFacts } = readConnectorInput(vars as Parameters<typeof readConnectorInput>[0]);
|
|
439
|
+
const dedupeKey = connectorDedupeKey({
|
|
440
|
+
dedupeKey: (vars.dedupeKey as string | null | undefined) ?? null,
|
|
441
|
+
processInstanceKey: job.processInstanceKey ?? null,
|
|
442
|
+
elementId: job.elementId ?? null,
|
|
443
|
+
});
|
|
444
|
+
if (!dedupeKey) throw new Error("connector stub: no dedupe key (author-supplied or graph-derived) available");
|
|
445
|
+
return await dispatchConnector(app.db, { dedupeKey, target, payload, boundFacts }, new Date().toISOString());
|
|
446
|
+
},
|
|
447
|
+
{ fetchVariables: ["boundFacts", "target", "dedupeKey", "payload"] },
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const graph: DeliveryGraph = {
|
|
451
|
+
name: "e2e timeout escalation binds emit",
|
|
452
|
+
nodes: [
|
|
453
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:demo" }, emits: [{ name: "pr", type: "pr" }] },
|
|
454
|
+
{ id: "land", kind: "connector", connector: { target: "slack", payload: { pr: "open.pr" }, dedupeKey: "land-872a" } },
|
|
455
|
+
],
|
|
456
|
+
edges: [{ from: "open.pr", to: "land" }],
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const run = await runDeliveryGraph(app.engine, graph, { nodeTimeout: "PT2S", escalationSlaTimeout: "PT1H", repoless: true });
|
|
460
|
+
assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
|
|
461
|
+
await app.settle();
|
|
462
|
+
|
|
463
|
+
// The job never completed (no worker) and the timer has not elapsed: no escalation yet, no End.
|
|
464
|
+
assert.ok(
|
|
465
|
+
(await app.engine.searchUserTasks({ state: "CREATED" })).every((t) => !t.elementId?.endsWith("__esc")),
|
|
466
|
+
"the timeout escalation has not fired before the SLA elapses",
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
// Elapse the node SLA → the `=nodeTimeout` boundary fires and parks the node on its timeout `__esc`.
|
|
470
|
+
await app.advanceTime(2_100);
|
|
471
|
+
const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).find((t) => t.elementId?.endsWith("__esc"));
|
|
472
|
+
assert.ok(esc, "the timed-out agent parks on its timeout escalation task");
|
|
473
|
+
assert.equal(connectorFired, 0, "the downstream connector has not fired before the escalation resolves");
|
|
474
|
+
|
|
475
|
+
// Unstick it with the missing PR as `value` — the #872 resume output must bind `open_pr` to it.
|
|
476
|
+
await app.engine.completeUserTask(esc.userTaskKey, { value: "owner/repo#872", humanOutcome: "completed" });
|
|
477
|
+
await app.settle();
|
|
478
|
+
|
|
479
|
+
assert.equal(connectorFired, 1, "resuming the timeout escalation with `value` unblocks the downstream connector");
|
|
480
|
+
assert.deepEqual(
|
|
481
|
+
connectorBoundFacts,
|
|
482
|
+
[{ from: "open", name: "pr", value: "owner/repo#872" }],
|
|
483
|
+
"the escalation `value` binds the node's `pr` emit and late-binds downstream (NOT null)",
|
|
484
|
+
);
|
|
485
|
+
const rows = await deliveryConnectorDispatches(app.db).find({ dedupe_key: "land-872a" });
|
|
486
|
+
assert.equal(rows.length, 1, "the connector fired exactly once after the resume");
|
|
487
|
+
assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the resumed node's result reaches End");
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("#876 review: a timeout escalation resumed with a MISSING or MALFORMED value re-parks (validated), never threads null/garbage downstream", async () => {
|
|
491
|
+
const app = track(await boot(freshDir()));
|
|
492
|
+
|
|
493
|
+
// PR #876 review finding: the resumable timeout `__esc` mapped the form's optional `value`
|
|
494
|
+
// straight onto the emit-source var, so completing it with `{}` (or a malformed value) released
|
|
495
|
+
// the downstream consumer with a null/invalid fact. The fix routes the resume through a
|
|
496
|
+
// validation gateway: the task re-publishes to a scratch var, and a per-required-emit FEEL
|
|
497
|
+
// condition binds the emit-source var ONLY when the value is present and type-valid — otherwise
|
|
498
|
+
// the node loops back onto a fresh escalation task (fail closed).
|
|
499
|
+
let connectorFired = 0;
|
|
500
|
+
let connectorBoundFacts: unknown;
|
|
501
|
+
await app.engine.registerWorker(
|
|
502
|
+
"pr.delivery-connector",
|
|
503
|
+
async (job) => {
|
|
504
|
+
connectorFired++;
|
|
505
|
+
const vars = job.variables as Record<string, unknown>;
|
|
506
|
+
connectorBoundFacts = vars.boundFacts;
|
|
507
|
+
const { target, payload, boundFacts } = readConnectorInput(vars as Parameters<typeof readConnectorInput>[0]);
|
|
508
|
+
const dedupeKey = connectorDedupeKey({
|
|
509
|
+
dedupeKey: (vars.dedupeKey as string | null | undefined) ?? null,
|
|
510
|
+
processInstanceKey: job.processInstanceKey ?? null,
|
|
511
|
+
elementId: job.elementId ?? null,
|
|
512
|
+
});
|
|
513
|
+
if (!dedupeKey) throw new Error("connector stub: no dedupe key (author-supplied or graph-derived) available");
|
|
514
|
+
return await dispatchConnector(app.db, { dedupeKey, target, payload, boundFacts }, new Date().toISOString());
|
|
515
|
+
},
|
|
516
|
+
{ fetchVariables: ["boundFacts", "target", "dedupeKey", "payload"] },
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
const graph: DeliveryGraph = {
|
|
520
|
+
name: "e2e resume validation",
|
|
521
|
+
nodes: [
|
|
522
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:demo" }, emits: [{ name: "pr", type: "pr" }] },
|
|
523
|
+
{ id: "land", kind: "connector", connector: { target: "slack", payload: { pr: "open.pr" }, dedupeKey: "land-876v" } },
|
|
524
|
+
],
|
|
525
|
+
edges: [{ from: "open.pr", to: "land" }],
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
// `senior:demo` is never registered, so the node parks and only its SLA timer fires.
|
|
529
|
+
const run = await runDeliveryGraph(app.engine, graph, { nodeTimeout: "PT2S", escalationSlaTimeout: "PT1H", repoless: true });
|
|
530
|
+
assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
|
|
531
|
+
await app.settle();
|
|
532
|
+
await app.advanceTime(2_100);
|
|
533
|
+
const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).find((t) => t.elementId?.endsWith("__esc"));
|
|
534
|
+
assert.ok(esc, "the timed-out agent parks on its timeout escalation task");
|
|
535
|
+
|
|
536
|
+
// (a) Resume with NO value — the missing required `pr` emit must NOT release downstream.
|
|
537
|
+
await app.engine.completeUserTask(esc.userTaskKey, { humanOutcome: "completed" });
|
|
538
|
+
await app.settle();
|
|
539
|
+
assert.equal(connectorFired, 0, "a value-less resume never fires the downstream connector");
|
|
540
|
+
assert.ok(!takenFlows(app).some((f) => f.endsWith("->End")), "a value-less resume never reaches End");
|
|
541
|
+
const reparked = await waitForEsc(app);
|
|
542
|
+
assert.ok(reparked, "the invalid resume loops back onto a fresh escalation task (fail closed)");
|
|
543
|
+
|
|
544
|
+
// (b) Resume with a MALFORMED `pr` value — same fail-closed re-park.
|
|
545
|
+
await app.engine.completeUserTask(reparked.userTaskKey, { value: "not-a-pr-key", humanOutcome: "completed" });
|
|
546
|
+
await app.settle();
|
|
547
|
+
assert.equal(connectorFired, 0, "a malformed resume value never fires the downstream connector");
|
|
548
|
+
const reparked2 = await waitForEsc(app);
|
|
549
|
+
assert.ok(reparked2, "the malformed resume loops back onto a fresh escalation task");
|
|
550
|
+
|
|
551
|
+
// (c) Resume with a VALID `pr` value — the gate binds the emit-source var and releases downstream.
|
|
552
|
+
await app.engine.completeUserTask(reparked2.userTaskKey, { value: "owner/repo#876", humanOutcome: "completed" });
|
|
553
|
+
await app.settle();
|
|
554
|
+
assert.equal(connectorFired, 1, "a valid resume value unblocks the downstream connector");
|
|
555
|
+
assert.deepEqual(connectorBoundFacts, [{ from: "open", name: "pr", value: "owner/repo#876" }], "the validated value late-binds downstream");
|
|
556
|
+
assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the validated resume reaches End");
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("#876 round-4 'Previously missed': a timeout escalation resumed with a canonical GitHub PR URL binds (the pr grammar aligns with parsePr)", async () => {
|
|
560
|
+
const app = track(await boot(freshDir()));
|
|
561
|
+
|
|
562
|
+
// The resume FEEL `pr` branch must accept what `parsePr` accepts — a canonical GitHub PR URL
|
|
563
|
+
// (`https://github.com/owner/repo/pull/N`) in addition to the `owner/repo#N` shorthand — or a
|
|
564
|
+
// timed-out node cannot be resumed with the URL form its normal completion path produces. The
|
|
565
|
+
// bound value is threaded verbatim (the escalation binds the raw `value`), so the URL lands as-is.
|
|
566
|
+
let connectorBoundFacts: unknown;
|
|
567
|
+
let connectorFired = 0;
|
|
568
|
+
await app.engine.registerWorker(
|
|
569
|
+
"pr.delivery-connector",
|
|
570
|
+
async (job) => {
|
|
571
|
+
connectorFired++;
|
|
572
|
+
const vars = job.variables as Record<string, unknown>;
|
|
573
|
+
connectorBoundFacts = vars.boundFacts;
|
|
574
|
+
const { target, payload, boundFacts } = readConnectorInput(vars as Parameters<typeof readConnectorInput>[0]);
|
|
575
|
+
const dedupeKey = connectorDedupeKey({
|
|
576
|
+
dedupeKey: (vars.dedupeKey as string | null | undefined) ?? null,
|
|
577
|
+
processInstanceKey: job.processInstanceKey ?? null,
|
|
578
|
+
elementId: job.elementId ?? null,
|
|
579
|
+
});
|
|
580
|
+
if (!dedupeKey) throw new Error("connector stub: no dedupe key (author-supplied or graph-derived) available");
|
|
581
|
+
return await dispatchConnector(app.db, { dedupeKey, target, payload, boundFacts }, new Date().toISOString());
|
|
582
|
+
},
|
|
583
|
+
{ fetchVariables: ["boundFacts", "target", "dedupeKey", "payload"] },
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
const graph: DeliveryGraph = {
|
|
587
|
+
name: "e2e resume pr url",
|
|
588
|
+
nodes: [
|
|
589
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:demo" }, emits: [{ name: "pr", type: "pr" }] },
|
|
590
|
+
{ id: "land", kind: "connector", connector: { target: "slack", payload: { pr: "open.pr" }, dedupeKey: "land-876url" } },
|
|
591
|
+
],
|
|
592
|
+
edges: [{ from: "open.pr", to: "land" }],
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const run = await runDeliveryGraph(app.engine, graph, { nodeTimeout: "PT2S", escalationSlaTimeout: "PT1H", repoless: true });
|
|
596
|
+
assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
|
|
597
|
+
await app.settle();
|
|
598
|
+
await app.advanceTime(2_100);
|
|
599
|
+
const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).find((t) => t.elementId?.endsWith("__esc"));
|
|
600
|
+
assert.ok(esc, "the timed-out agent parks on its timeout escalation task");
|
|
601
|
+
|
|
602
|
+
// Resume with a canonical GitHub PR URL — the aligned pr grammar must accept it (no re-park).
|
|
603
|
+
await app.engine.completeUserTask(esc.userTaskKey, { value: "https://github.com/owner/repo/pull/876", humanOutcome: "completed" });
|
|
604
|
+
await app.settle();
|
|
605
|
+
assert.equal(connectorFired, 1, "a canonical GitHub PR URL resume unblocks the downstream connector");
|
|
606
|
+
assert.deepEqual(
|
|
607
|
+
connectorBoundFacts,
|
|
608
|
+
[{ from: "open", name: "pr", value: "https://github.com/owner/repo/pull/876" }],
|
|
609
|
+
"the canonical PR URL late-binds downstream verbatim",
|
|
610
|
+
);
|
|
611
|
+
assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the PR-URL resume reaches End");
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("#876 round-4 review: a timeout escalation resumed with a MIXED-CASE GitHub PR URL binds (the pr grammar is case-insensitive like parsePr's /i)", async () => {
|
|
615
|
+
const app = track(await boot(freshDir()));
|
|
616
|
+
|
|
617
|
+
// parsePr matches the PR URL with the `/i` flag, so a mixed-case host/scheme
|
|
618
|
+
// (`https://GitHub.com/...`, `HTTPS://...`) the NORMAL completion path accepts must ALSO resume a
|
|
619
|
+
// timed-out node — before the fix the resume FEEL matched case-sensitively and REJECTED it, re-
|
|
620
|
+
// parking a legitimate value (fail-closed on a value the canonical path takes). The bound value
|
|
621
|
+
// stays verbatim (only the accept/reject test folds to lower case).
|
|
622
|
+
let connectorBoundFacts: unknown;
|
|
623
|
+
let connectorFired = 0;
|
|
624
|
+
await app.engine.registerWorker(
|
|
625
|
+
"pr.delivery-connector",
|
|
626
|
+
async (job) => {
|
|
627
|
+
connectorFired++;
|
|
628
|
+
const vars = job.variables as Record<string, unknown>;
|
|
629
|
+
connectorBoundFacts = vars.boundFacts;
|
|
630
|
+
const { target, payload, boundFacts } = readConnectorInput(vars as Parameters<typeof readConnectorInput>[0]);
|
|
631
|
+
const dedupeKey = connectorDedupeKey({
|
|
632
|
+
dedupeKey: (vars.dedupeKey as string | null | undefined) ?? null,
|
|
633
|
+
processInstanceKey: job.processInstanceKey ?? null,
|
|
634
|
+
elementId: job.elementId ?? null,
|
|
635
|
+
});
|
|
636
|
+
if (!dedupeKey) throw new Error("connector stub: no dedupe key (author-supplied or graph-derived) available");
|
|
637
|
+
return await dispatchConnector(app.db, { dedupeKey, target, payload, boundFacts }, new Date().toISOString());
|
|
638
|
+
},
|
|
639
|
+
{ fetchVariables: ["boundFacts", "target", "dedupeKey", "payload"] },
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
const graph: DeliveryGraph = {
|
|
643
|
+
name: "e2e resume mixed-case pr url",
|
|
644
|
+
nodes: [
|
|
645
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:demo" }, emits: [{ name: "pr", type: "pr" }] },
|
|
646
|
+
{ id: "land", kind: "connector", connector: { target: "slack", payload: { pr: "open.pr" }, dedupeKey: "land-876mixed" } },
|
|
647
|
+
],
|
|
648
|
+
edges: [{ from: "open.pr", to: "land" }],
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const run = await runDeliveryGraph(app.engine, graph, { nodeTimeout: "PT2S", escalationSlaTimeout: "PT1H", repoless: true });
|
|
652
|
+
assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
|
|
653
|
+
await app.settle();
|
|
654
|
+
await app.advanceTime(2_100);
|
|
655
|
+
const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).find((t) => t.elementId?.endsWith("__esc"));
|
|
656
|
+
assert.ok(esc, "the timed-out agent parks on its timeout escalation task");
|
|
657
|
+
|
|
658
|
+
// Resume with a MIXED-CASE host + uppercase scheme — the case-insensitive pr grammar must accept it.
|
|
659
|
+
const mixed = "HTTPS://GitHub.com/owner/repo/pull/876";
|
|
660
|
+
await app.engine.completeUserTask(esc.userTaskKey, { value: mixed, humanOutcome: "completed" });
|
|
661
|
+
await app.settle();
|
|
662
|
+
assert.equal(connectorFired, 1, "a mixed-case GitHub PR URL resume unblocks the downstream connector");
|
|
663
|
+
assert.deepEqual(
|
|
664
|
+
connectorBoundFacts,
|
|
665
|
+
[{ from: "open", name: "pr", value: mixed }],
|
|
666
|
+
"the mixed-case PR URL late-binds downstream VERBATIM (only the accept test folds to lower case)",
|
|
667
|
+
);
|
|
668
|
+
assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the mixed-case PR-URL resume reaches End");
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("#876 round-4 'Previously missed': a timeout escalation resumed with a SCOPED artifact binds (the artifact grammar aligns with coerceFactValue)", async () => {
|
|
672
|
+
const app = track(await boot(freshDir()));
|
|
673
|
+
|
|
674
|
+
// The resume FEEL `artifact` branch must accept what `coerceFactValue` accepts — a SCOPED package
|
|
675
|
+
// handle (`@nanobpm/urban@0.54.0`, split on the LAST `@`) — not just an unscoped `pkg@version`.
|
|
676
|
+
let connectorBoundFacts: unknown;
|
|
677
|
+
let connectorFired = 0;
|
|
678
|
+
await app.engine.registerWorker(
|
|
679
|
+
"pr.delivery-connector",
|
|
680
|
+
async (job) => {
|
|
681
|
+
connectorFired++;
|
|
682
|
+
const vars = job.variables as Record<string, unknown>;
|
|
683
|
+
connectorBoundFacts = vars.boundFacts;
|
|
684
|
+
const { target, payload, boundFacts } = readConnectorInput(vars as Parameters<typeof readConnectorInput>[0]);
|
|
685
|
+
const dedupeKey = connectorDedupeKey({
|
|
686
|
+
dedupeKey: (vars.dedupeKey as string | null | undefined) ?? null,
|
|
687
|
+
processInstanceKey: job.processInstanceKey ?? null,
|
|
688
|
+
elementId: job.elementId ?? null,
|
|
689
|
+
});
|
|
690
|
+
if (!dedupeKey) throw new Error("connector stub: no dedupe key (author-supplied or graph-derived) available");
|
|
691
|
+
return await dispatchConnector(app.db, { dedupeKey, target, payload, boundFacts }, new Date().toISOString());
|
|
692
|
+
},
|
|
693
|
+
{ fetchVariables: ["boundFacts", "target", "dedupeKey", "payload"] },
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
const graph: DeliveryGraph = {
|
|
697
|
+
name: "e2e resume scoped artifact",
|
|
698
|
+
nodes: [
|
|
699
|
+
{ id: "pub", kind: "agent", agent: { jobType: "senior:demo" }, emits: [{ name: "artifactRef", type: "artifact" }] },
|
|
700
|
+
{ id: "land", kind: "connector", connector: { target: "slack", payload: { a: "pub.artifactRef" }, dedupeKey: "land-876art" } },
|
|
701
|
+
],
|
|
702
|
+
edges: [{ from: "pub.artifactRef", to: "land" }],
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
const run = await runDeliveryGraph(app.engine, graph, { nodeTimeout: "PT2S", escalationSlaTimeout: "PT1H", repoless: true });
|
|
706
|
+
assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
|
|
707
|
+
await app.settle();
|
|
708
|
+
await app.advanceTime(2_100);
|
|
709
|
+
const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).find((t) => t.elementId?.endsWith("__esc"));
|
|
710
|
+
assert.ok(esc, "the timed-out agent parks on its timeout escalation task");
|
|
711
|
+
|
|
712
|
+
// Resume with a SCOPED artifact handle — the aligned artifact grammar must accept it (no re-park).
|
|
713
|
+
await app.engine.completeUserTask(esc.userTaskKey, { value: "@nanobpm/urban@0.54.0", humanOutcome: "completed" });
|
|
714
|
+
await app.settle();
|
|
715
|
+
assert.equal(connectorFired, 1, "a scoped artifact resume unblocks the downstream connector");
|
|
716
|
+
assert.deepEqual(
|
|
717
|
+
connectorBoundFacts,
|
|
718
|
+
[{ from: "pub", name: "artifactRef", value: "@nanobpm/urban@0.54.0" }],
|
|
719
|
+
"the scoped artifact late-binds downstream verbatim",
|
|
720
|
+
);
|
|
721
|
+
assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the scoped-artifact resume reaches End");
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
test("#876 round-4 review: a declared emit named to the OLD flag string does NOT collide with the namespaced resume-valid flag", async () => {
|
|
725
|
+
const app = track(await boot(freshDir()));
|
|
726
|
+
|
|
727
|
+
// The pre-fix flag (`<esc>__resumeValid`) was a legal fact name, so a node declaring an emit of
|
|
728
|
+
// that exact name shared ONE variable between the recovered fact value and the boolean flag — a
|
|
729
|
+
// valid resume published `true` instead of the supplied value. The `__flag__` marker namespaces the
|
|
730
|
+
// flag, so a node whose single emit is named to the OLD flag string now binds the fact under its
|
|
731
|
+
// own name AND routes on a DISTINCT flag var — the resume releases the real value downstream.
|
|
732
|
+
const collisionName = "delivery_human_task__open__esc__resumeValid";
|
|
733
|
+
let connectorBoundFacts: unknown;
|
|
734
|
+
let connectorFired = 0;
|
|
735
|
+
await app.engine.registerWorker(
|
|
736
|
+
"pr.delivery-connector",
|
|
737
|
+
async (job) => {
|
|
738
|
+
connectorFired++;
|
|
739
|
+
const vars = job.variables as Record<string, unknown>;
|
|
740
|
+
connectorBoundFacts = vars.boundFacts;
|
|
741
|
+
const { target, payload, boundFacts } = readConnectorInput(vars as Parameters<typeof readConnectorInput>[0]);
|
|
742
|
+
const dedupeKey = connectorDedupeKey({
|
|
743
|
+
dedupeKey: (vars.dedupeKey as string | null | undefined) ?? null,
|
|
744
|
+
processInstanceKey: job.processInstanceKey ?? null,
|
|
745
|
+
elementId: job.elementId ?? null,
|
|
746
|
+
});
|
|
747
|
+
if (!dedupeKey) throw new Error("connector stub: no dedupe key (author-supplied or graph-derived) available");
|
|
748
|
+
return await dispatchConnector(app.db, { dedupeKey, target, payload, boundFacts }, new Date().toISOString());
|
|
749
|
+
},
|
|
750
|
+
{ fetchVariables: ["boundFacts", "target", "dedupeKey", "payload"] },
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
const graph: DeliveryGraph = {
|
|
754
|
+
name: "e2e resume flag namespace",
|
|
755
|
+
nodes: [
|
|
756
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:demo" }, emits: [{ name: collisionName, type: "string" }] },
|
|
757
|
+
{ id: "land", kind: "connector", connector: { target: "slack", payload: { v: `open.${collisionName}` }, dedupeKey: "land-876ns" } },
|
|
758
|
+
],
|
|
759
|
+
edges: [{ from: `open.${collisionName}`, to: "land" }],
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
const run = await runDeliveryGraph(app.engine, graph, { nodeTimeout: "PT2S", escalationSlaTimeout: "PT1H", repoless: true });
|
|
763
|
+
assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
|
|
764
|
+
await app.settle();
|
|
765
|
+
await app.advanceTime(2_100);
|
|
766
|
+
const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).find((t) => t.elementId?.endsWith("__esc"));
|
|
767
|
+
assert.ok(esc, "the timed-out agent parks on its timeout escalation task");
|
|
768
|
+
|
|
769
|
+
// Resume with a real string value. Before the namespacing fix the flag and the fact shared one
|
|
770
|
+
// variable, so the gate would read the flag as the value (or vice versa); now the value binds the
|
|
771
|
+
// fact and the DISTINCT flag routes the resume — the real value releases downstream.
|
|
772
|
+
await app.engine.completeUserTask(esc.userTaskKey, { value: "the-real-value", humanOutcome: "completed" });
|
|
773
|
+
await app.settle();
|
|
774
|
+
assert.equal(connectorFired, 1, "a valid resume on a colliding-named emit still unblocks the downstream connector");
|
|
775
|
+
assert.deepEqual(
|
|
776
|
+
connectorBoundFacts,
|
|
777
|
+
[{ from: "open", name: collisionName, value: "the-real-value" }],
|
|
778
|
+
"the supplied value binds the emit (NOT the boolean flag) despite the collision-shaped name",
|
|
779
|
+
);
|
|
780
|
+
assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the namespaced-flag resume reaches End");
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
test("#872 a wait gate whose target resolves to NULL fails closed: it does NOT pass through, it parks and escalates", async () => {
|
|
784
|
+
const app = track(await boot(freshDir()));
|
|
785
|
+
|
|
786
|
+
// The instance-216710 downstream failure: a `wait` gate late-binds its probe `target` from an
|
|
787
|
+
// upstream emit (`up.cmd`). When that upstream fact resolves to NULL, the compiler leaves the
|
|
788
|
+
// authored `<node>.<fact>` reference in place rather than rewriting it to a real handle. Before
|
|
789
|
+
// #872 that null/fact-ref target threaded through as if the gate were satisfiable (it passed
|
|
790
|
+
// through / incidented). The fix makes the readiness worker fail CLOSED on an unresolved target, so
|
|
791
|
+
// the gate stays parked and escalates on its bounded timeout. The upstream here is a CONNECTOR
|
|
792
|
+
// (no producer-contract gate), so its null emit is not caught upstream and genuinely reaches the gate.
|
|
793
|
+
await app.engine.registerWorker(
|
|
794
|
+
"pr.delivery-connector",
|
|
795
|
+
async (job) => {
|
|
796
|
+
const vars = job.variables as Record<string, unknown>;
|
|
797
|
+
const { target, payload, boundFacts } = readConnectorInput(vars as Parameters<typeof readConnectorInput>[0]);
|
|
798
|
+
const dedupeKey = connectorDedupeKey({
|
|
799
|
+
dedupeKey: (vars.dedupeKey as string | null | undefined) ?? null,
|
|
800
|
+
processInstanceKey: job.processInstanceKey ?? null,
|
|
801
|
+
elementId: job.elementId ?? null,
|
|
802
|
+
});
|
|
803
|
+
if (!dedupeKey) throw new Error("connector stub: no dedupe key (author-supplied or graph-derived) available");
|
|
804
|
+
// Return the dispatch outcome only — it NEVER sets `cmd`, so the node's `cmd` emit is null.
|
|
805
|
+
return await dispatchConnector(app.db, { dedupeKey, target, payload, boundFacts }, new Date().toISOString());
|
|
806
|
+
},
|
|
807
|
+
{ fetchVariables: ["boundFacts", "target", "dedupeKey", "payload"] },
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
const graph: DeliveryGraph = {
|
|
811
|
+
name: "e2e null wait target fails closed",
|
|
812
|
+
nodes: [
|
|
813
|
+
{ id: "up", kind: "connector", connector: { target: "noop", dedupeKey: "up-872b" }, emits: [{ name: "cmd", type: "string" }] },
|
|
814
|
+
{ id: "w", kind: "wait", wait: { kind: "command", target: "up.cmd", poll: { everyMs: 5, backoff: "fixed" } } },
|
|
815
|
+
],
|
|
816
|
+
edges: [{ from: "up.cmd", to: "w" }],
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S", probePollEvery: "PT1S", escalationSlaTimeout: "PT1H", repoless: true });
|
|
820
|
+
assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
|
|
821
|
+
await app.settle();
|
|
822
|
+
|
|
823
|
+
// The upstream connector completed (no gate), threading `up_cmd = null`. Because the fact resolved
|
|
824
|
+
// to null, the compiler writes a literal `null` into the wait probe's `target` (it does NOT preserve
|
|
825
|
+
// the authored `up.cmd` fact-reference), so the worker receives a null target → fails closed → the
|
|
826
|
+
// gate NEVER resolves to ready.
|
|
827
|
+
assert.ok(!takenFlows(app).some((f) => f.endsWith("->End")), "a gate with a null target must NOT pass through to End");
|
|
828
|
+
|
|
829
|
+
// Bounded, not wedged: the gate escalates once its poll budget elapses.
|
|
830
|
+
await app.advanceTime(2_100);
|
|
831
|
+
const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).filter((t) => t.elementId?.endsWith("__esc"));
|
|
832
|
+
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
|
+
});
|
|
834
|
+
|
|
835
|
+
test("#872 a node released with NO per-node timeout gets the default SLA — not a zero-length timer that fires instantly", async () => {
|
|
836
|
+
const app = track(await boot(freshDir()));
|
|
837
|
+
|
|
838
|
+
// The instance-216710 third failure: nodes released with `nodeTimeout = null` fired their SLA
|
|
839
|
+
// boundary almost immediately (~0.5s) — a null `=nodeTimeout` duration. The fix makes the
|
|
840
|
+
// bounded-timeout ioMapping fall back to the run-level `runNodeTimeout` (then a `PT1H` literal). We
|
|
841
|
+
// simulate the degenerate seed by nulling the node's per-node `timeout` in the prepared inputs, then
|
|
842
|
+
// deploy + start manually. If the bug were present the `__esc` would appear during `settle()` (an
|
|
843
|
+
// instant timer); with the fallback the node waits the real `runNodeTimeout` SLA first.
|
|
844
|
+
await app.engine.registerWorker("senior:demo", async () => ({}));
|
|
845
|
+
|
|
846
|
+
const graph: DeliveryGraph = {
|
|
847
|
+
name: "e2e null nodeTimeout default SLA",
|
|
848
|
+
nodes: [{ id: "solo", kind: "agent", agent: { jobType: "never-registered-so-it-parks" } }],
|
|
849
|
+
edges: [],
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
// Run-level SLA PT2S → `runNodeTimeout`. Null the per-node timeout to drive the fallback path.
|
|
853
|
+
const prep = await prepareDeliveryGraph(graph, { nodeTimeout: "PT2S", repoless: true });
|
|
854
|
+
assert.ok(prep.ok, `prepare should succeed, got ${JSON.stringify(prep)}`);
|
|
855
|
+
const { processDefinitionId, bpmn, nodeInputs, runKey, runNodeTimeout } = prep.prepared;
|
|
856
|
+
const agentElement = Object.keys(nodeInputs).find((el) => "jobType" in (nodeInputs[el] as Record<string, unknown>));
|
|
857
|
+
assert.ok(agentElement, "the agent node's element input exists");
|
|
858
|
+
(nodeInputs[agentElement] as Record<string, unknown>).timeout = null;
|
|
859
|
+
|
|
860
|
+
await app.engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
|
|
861
|
+
await app.engine.createInstance({ processDefinitionId, variables: { nodeInputs, runKey, runNodeTimeout } });
|
|
862
|
+
// The agent job type is never registered, so the node parks on its service task awaiting the timer.
|
|
863
|
+
await app.settle();
|
|
864
|
+
|
|
865
|
+
// A null per-node timeout must NOT fire a zero-length timer: with the fallback the SLA is PT2S, so
|
|
866
|
+
// no escalation yet. (Pre-fix, the `=nodeTimeout` null duration fires here during settle.)
|
|
867
|
+
assert.ok(
|
|
868
|
+
(await app.engine.searchUserTasks({ state: "CREATED" })).every((t) => !t.elementId?.endsWith("__esc")),
|
|
869
|
+
"a null per-node timeout must fall back to the default SLA, NOT fire the boundary instantly",
|
|
870
|
+
);
|
|
871
|
+
|
|
872
|
+
// Elapse the fallback SLA → the boundary fires exactly as a real PT2S timer would.
|
|
873
|
+
await app.advanceTime(2_100);
|
|
874
|
+
const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).filter((t) => t.elementId?.endsWith("__esc"));
|
|
875
|
+
assert.ok(esc.length >= 1, `the fallback SLA eventually escalates the parked node, got ${JSON.stringify((await app.engine.searchUserTasks({ state: "CREATED" })).map((t) => t.elementId))}`);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test("#872 a legacy instance with BOTH timeouts absent falls back to the literal PT1H default — not a zero-length timer", async () => {
|
|
879
|
+
const app = track(await boot(freshDir()));
|
|
880
|
+
|
|
881
|
+
// Covers the FINAL `else PT1H` branch of the bounded-timeout ioMapping, the compatibility path for
|
|
882
|
+
// legacy / hand-seeded instances that predate the runner seeding `runNodeTimeout`. The prior test
|
|
883
|
+
// seeds `runNodeTimeout` (PT2S), so it only exercises the run-level fallback; here we null the
|
|
884
|
+
// per-node timeout AND omit `runNodeTimeout` from the instance entirely, so `=nodeTimeout` resolves
|
|
885
|
+
// to the literal `DELIVERY_NODE_DEFAULT_TIMEOUT` (PT1H). If the literal default did not apply, a null
|
|
886
|
+
// duration would fire the boundary timer instantly during `settle()`.
|
|
887
|
+
await app.engine.registerWorker("senior:demo", async () => ({}));
|
|
888
|
+
|
|
889
|
+
const graph: DeliveryGraph = {
|
|
890
|
+
name: "e2e absent timeouts default PT1H SLA",
|
|
891
|
+
nodes: [{ id: "solo", kind: "agent", agent: { jobType: "never-registered-so-it-parks" } }],
|
|
892
|
+
edges: [],
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
const prep = await prepareDeliveryGraph(graph, { nodeTimeout: "PT2S", repoless: true });
|
|
896
|
+
assert.ok(prep.ok, `prepare should succeed, got ${JSON.stringify(prep)}`);
|
|
897
|
+
const { processDefinitionId, bpmn, nodeInputs, runKey } = prep.prepared;
|
|
898
|
+
const agentElement = Object.keys(nodeInputs).find((el) => "jobType" in (nodeInputs[el] as Record<string, unknown>));
|
|
899
|
+
assert.ok(agentElement, "the agent node's element input exists");
|
|
900
|
+
(nodeInputs[agentElement] as Record<string, unknown>).timeout = null;
|
|
901
|
+
|
|
902
|
+
await app.engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
|
|
903
|
+
// Deliberately OMIT `runNodeTimeout` to model a legacy/hand-seeded instance — so neither the per-node
|
|
904
|
+
// nor the run-level source is defined and the FEEL falls to the literal PT1H.
|
|
905
|
+
await app.engine.createInstance({ processDefinitionId, variables: { nodeInputs, runKey } });
|
|
906
|
+
await app.settle();
|
|
907
|
+
|
|
908
|
+
// Both timeout sources absent must NOT fire a zero-length timer: with the literal PT1H default there
|
|
909
|
+
// is no escalation yet. (Pre-fix, the `=nodeTimeout` null duration fired here during settle.)
|
|
910
|
+
assert.ok(
|
|
911
|
+
(await app.engine.searchUserTasks({ state: "CREATED" })).every((t) => !t.elementId?.endsWith("__esc")),
|
|
912
|
+
"both timeouts absent must fall back to the literal PT1H default, NOT fire the boundary instantly",
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
// Not fired well before PT1H either (a real hour-long SLA, not an almost-instant timer).
|
|
916
|
+
await app.advanceTime(2_100);
|
|
917
|
+
assert.ok(
|
|
918
|
+
(await app.engine.searchUserTasks({ state: "CREATED" })).every((t) => !t.elementId?.endsWith("__esc")),
|
|
919
|
+
"the literal default is a real PT1H SLA — it has not fired a couple of seconds in",
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
// Elapse past the literal PT1H → the boundary fires exactly as a real hour-long timer would.
|
|
923
|
+
await app.advanceTime(3_600_000);
|
|
924
|
+
const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).filter((t) => t.elementId?.endsWith("__esc"));
|
|
925
|
+
assert.ok(esc.length >= 1, `the literal PT1H default eventually escalates the parked node, got ${JSON.stringify((await app.engine.searchUserTasks({ state: "CREATED" })).map((t) => t.elementId))}`);
|
|
926
|
+
});
|
|
358
927
|
});
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -456,7 +456,14 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
456
456
|
.table<{ user_task_key: string; element_id: string; question: string | null }>("user_tasks", "user_task_key")
|
|
457
457
|
.findOne({ user_task_key: task!.userTaskKey });
|
|
458
458
|
assert.ok(inboxRow, "the poller projected the escalation onto the Tasks inbox read-model");
|
|
459
|
-
|
|
459
|
+
// The recorder enriches the agent's own question with its summary as supporting context
|
|
460
|
+
// (issue #865 review) — the audit log carries the leading question AND the appended summary, and
|
|
461
|
+
// `pollUserTasks` surfaces that enriched text unchanged.
|
|
462
|
+
assert.equal(
|
|
463
|
+
inboxRow!.question,
|
|
464
|
+
'Which API should I use? The agent\'s own summary: "parked for a human".',
|
|
465
|
+
"the agent's question, enriched with its summary, is surfaced from the audit log",
|
|
466
|
+
);
|
|
460
467
|
|
|
461
468
|
// Answer through the ONE canonical `complete-user-task` door (issue #332 retired the bespoke
|
|
462
469
|
// `answer-escalation` operation) — the attributed human completer resumes the SAME implement task
|