@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/SPEC.md +35 -0
  3. package/app/deliveryGraph.test.ts +10 -0
  4. package/app/deliveryGraphCompiler.test.ts +354 -10
  5. package/app/deliveryGraphCompiler.ts +296 -40
  6. package/app/deliveryRunner.ts +14 -5
  7. package/app/implementEscalationReason.test.ts +216 -0
  8. package/app/implementEscalationReason.ts +213 -0
  9. package/app/implementReconcile.test.ts +168 -11
  10. package/app/implementReconcile.ts +112 -18
  11. package/app/pullRequestReadModel.test.ts +1 -1
  12. package/app/roundChurn.test.ts +388 -0
  13. package/app/roundChurn.ts +265 -0
  14. package/app/roundProgress.test.ts +224 -1
  15. package/app/service.test.ts +7 -0
  16. package/app/service.ts +12 -0
  17. package/db/migrations/117_pull_requests_churn_escalated_round.sql +12 -0
  18. package/db/migrations/118_pull_requests_read_model_churn_escalated_round.sql +55 -0
  19. package/docs/agent-guide.md +10 -7
  20. package/e2e/delivery-graph.e2e.ts +570 -1
  21. package/e2e/feature-run.e2e.ts +8 -1
  22. package/e2e/plan-fanout.e2e.ts +75 -2
  23. package/package.json +1 -1
  24. package/resources/processes/implement-cell.bpmn +42 -17
  25. package/resources/processes/plan-fanout.bpmn +236 -211
  26. package/resources/prompts/feature.md +16 -1
  27. package/workers/progress-check/worker.ts +98 -1
  28. package/workers/readiness-probe/worker.test.ts +72 -1
  29. package/workers/readiness-probe/worker.ts +39 -0
  30. package/workers/reconcile-implement/worker.test.ts +31 -3
  31. package/workers/reconcile-implement/worker.ts +5 -0
  32. package/workers/record-feature-escalation/worker.test.ts +58 -3
  33. package/workers/record-feature-escalation/worker.ts +24 -10
@@ -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 a content-addressed approval token
736
- > to re-submit with, so any holder of the API credential approved its own graph. Removing the
737
- > dispatch affordance from the agent surface entirely (capability by absence) means there is
738
- > nothing to replay — the human in the cockpit is the only actor who can launch side effects.
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, and (per
925
- §9.2) completing that escalation would release `start-b` **as not-ready**, launching feature
926
- B before its dependency merged. Size `timeoutMs` to how long the epic realistically takes.
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 { runDeliveryGraph } from "../app/deliveryRunner.ts";
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
  });
@@ -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
- assert.equal(inboxRow!.question, "Which API should I use?", "the agent's question is surfaced from the audit log");
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