@nanobpm/nano-workforce 0.140.0 → 0.142.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.
@@ -731,3 +731,114 @@ test("a wait node's onTimeout: fail is rejected (unsupported-on-timeout) while c
731
731
  assertEquals(validateDeliveryGraph(waitWith("continue")), []);
732
732
  assertEquals(validateDeliveryGraph(waitWith("escalate")), []);
733
733
  });
734
+
735
+ // ── Pass 4 (#548): converge/wait PR late-binding ──────────────────────────────────────────────────
736
+
737
+ /** The canonical no-literal converge shape: `open` emits a `pr` fact, both the converge connector and
738
+ * the pr-wait reference it (`open.pr`) on incoming fact edges. */
739
+ function noLiteralConverge(overrides?: {
740
+ connectorPr?: unknown;
741
+ waitTarget?: string;
742
+ openEmitsPr?: boolean;
743
+ edges?: { from: string; to: string }[];
744
+ }) {
745
+ const emits = overrides?.openEmitsPr === false ? [{ name: "pr", type: "url" }] : [{ name: "pr", type: "pr" }];
746
+ const connector =
747
+ "connectorPr" in (overrides ?? {})
748
+ ? { target: "converge-merge", payload: { pr: overrides?.connectorPr } }
749
+ : { target: "converge-merge", payload: { pr: "open.pr" } };
750
+ return {
751
+ name: "no-literal converge",
752
+ nodes: [
753
+ { id: "open", kind: "agent", agent: { jobType: "senior:feature", prompt: "open a PR" }, emits },
754
+ { id: "land", kind: "connector", connector },
755
+ { id: "merged", kind: "wait", wait: { kind: "pr", target: overrides?.waitTarget ?? "open.pr", match: { prState: "merged" } } },
756
+ ],
757
+ edges: overrides?.edges ?? [
758
+ { from: "open.pr", to: "land" },
759
+ { from: "open.pr", to: "merged" },
760
+ ],
761
+ };
762
+ }
763
+
764
+ test("#548 a converge connector + pr-wait that reference a threaded `pr` fact validate", () => {
765
+ assertEquals(validateDeliveryGraph(noLiteralConverge()), []);
766
+ });
767
+
768
+ test("#548 a LITERAL owner/repo#N PR on both the connector and the wait validates (no binding needed)", () => {
769
+ const g = noLiteralConverge({ connectorPr: "acme/repo#12", waitTarget: "acme/repo#12", edges: [{ from: "open", to: "land" }, { from: "land", to: "merged" }] });
770
+ assertEquals(validateDeliveryGraph(g), []);
771
+ });
772
+
773
+ test("#548 a PR reference that is NOT threaded by a fact edge is rejected (unbound-pr)", () => {
774
+ // The connector/wait name `open.pr`, but the edges carry only a plain completion dependency — the
775
+ // `pr` fact never flows in, so it can never late-bind.
776
+ const g = noLiteralConverge({ edges: [{ from: "open", to: "land" }, { from: "land", to: "merged" }] });
777
+ const errs = validateDeliveryGraph(g);
778
+ const unbound = errs.filter((e) => e.code === "unbound-pr");
779
+ assertEquals(unbound.length, 2, `both consumers are unbound: ${JSON.stringify(errs)}`);
780
+ assert(unbound.some((e) => e.path === "nodes[1].connector.payload.pr"));
781
+ assert(unbound.some((e) => e.path === "nodes[2].wait.target"));
782
+ });
783
+
784
+ test("#548 a PR reference to a non-`pr`-typed fact is rejected (unbound-pr)", () => {
785
+ const g = noLiteralConverge({ openEmitsPr: false }); // `open` emits `pr` as a `url`, not `pr`
786
+ const err = hasCode(validateDeliveryGraph(g), "unbound-pr");
787
+ assert(err.message.includes("must reference a `pr`-typed fact") || err.message.includes('"url"'), err.message);
788
+ });
789
+
790
+ test("#548 a PR reference to an undeclared fact is rejected (unbound-pr)", () => {
791
+ // `open` emits no facts, but the connector references `open.pr`.
792
+ const g = {
793
+ name: "undeclared ref",
794
+ nodes: [
795
+ { id: "open", kind: "agent", agent: { jobType: "j", prompt: "p" } },
796
+ { id: "land", kind: "connector", connector: { target: "converge", payload: { pr: "open.pr" } } },
797
+ ],
798
+ edges: [{ from: "open", to: "land" }],
799
+ };
800
+ const err = hasCode(validateDeliveryGraph(g), "unbound-pr");
801
+ assert(err.message.includes("does not declare"), err.message);
802
+ });
803
+
804
+ test("#548 a converge connector that OMITS payload.pr auto-binds a single threaded `pr` fact", () => {
805
+ const g = {
806
+ name: "omitted auto-bind",
807
+ nodes: [
808
+ { id: "open", kind: "agent", agent: { jobType: "senior:feature", prompt: "open" }, emits: [{ name: "pr", type: "pr" }] },
809
+ { id: "land", kind: "connector", connector: { target: "converge-merge" } },
810
+ ],
811
+ edges: [{ from: "open.pr", to: "land" }],
812
+ };
813
+ assertEquals(validateDeliveryGraph(g), []);
814
+ });
815
+
816
+ test("#548 a converge connector with NO PR at all is rejected (unbound-pr)", () => {
817
+ const g = {
818
+ name: "no pr",
819
+ nodes: [
820
+ { id: "open", kind: "agent", agent: { jobType: "j", prompt: "p" } },
821
+ { id: "land", kind: "connector", connector: { target: "converge-merge" } },
822
+ ],
823
+ edges: [{ from: "open", to: "land" }],
824
+ };
825
+ const err = hasCode(validateDeliveryGraph(g), "unbound-pr");
826
+ assert(err.message.includes("has no target PR"), err.message);
827
+ });
828
+
829
+ test("#548 a converge connector with MULTIPLE incoming `pr` facts is rejected as ambiguous (unbound-pr)", () => {
830
+ const g = {
831
+ name: "ambiguous pr",
832
+ nodes: [
833
+ { id: "a", kind: "agent", agent: { jobType: "j", prompt: "p" }, emits: [{ name: "pr", type: "pr" }] },
834
+ { id: "b", kind: "agent", agent: { jobType: "j", prompt: "p" }, emits: [{ name: "pr", type: "pr" }] },
835
+ { id: "land", kind: "connector", connector: { target: "converge-merge" } },
836
+ ],
837
+ edges: [
838
+ { from: "a.pr", to: "land" },
839
+ { from: "b.pr", to: "land" },
840
+ ],
841
+ };
842
+ const err = hasCode(validateDeliveryGraph(g), "unbound-pr");
843
+ assert(err.message.includes("disambiguate"), err.message);
844
+ });
@@ -19,6 +19,8 @@
19
19
  // Every error carries a JSON-path-qualified `path` (`nodes[2].kind`, `edges[1].from`, …) so the
20
20
  // caller can point the author straight at the offending input.
21
21
 
22
+ import { isConvergeTarget } from "./convergeTargets.ts";
23
+
22
24
  /** The CLOSED node-kind allowlist (ADR 0005 Decision 2) — the trust boundary. Extensible only by a
23
25
  * deliberate ADR/PR (add the openapi variant + a case here), never by a graph author. Kept as the
24
26
  * single source of truth for "which kinds are legal" so the validator and any future compiler agree. */
@@ -30,8 +32,11 @@ export type DeliveryNodeKind = (typeof DELIVERY_NODE_KINDS)[number];
30
32
  /** The CLOSED emitted-fact type allowlist (ADR 0005 Decision 3/4) — mirrors the `DeliveryFact.type`
31
33
  * enum in `openapi.yaml`. Kept as the single source of truth so the semantic validator rejects an
32
34
  * untyped/unknown fact type even when the OpenAPI shape validator is bypassed (a directly-invoked
33
- * delegate), since later compilation/execution steps rely on this allowlist. */
34
- export const DELIVERY_FACT_TYPES = ["string", "number", "boolean", "artifact", "version", "url"] as const;
35
+ * delegate), since later compilation/execution steps rely on this allowlist. `pr` (issue #548) is the
36
+ * PR-reference type an `agent` node emits for the PR it opened (`owner/repo#N`), so a downstream
37
+ * `connector[converge*]` / `wait[pr]` node LATE-BINDS its target PR from that fact instead of a
38
+ * hardcoded literal (the canonical `agent → connector[converge-merge] → wait[pr, merged]` shape). */
39
+ export const DELIVERY_FACT_TYPES = ["string", "number", "boolean", "artifact", "version", "url", "pr"] as const;
35
40
 
36
41
  /** An emitted fact's declared `type`, narrowed to the closed allowlist. */
37
42
  export type DeliveryFactType = (typeof DELIVERY_FACT_TYPES)[number];
@@ -84,7 +89,8 @@ export type DeliveryGraphErrorCode =
84
89
  | "multiple-defaults"
85
90
  | "non-exhaustive-split"
86
91
  | "exclusive-merge-parity"
87
- | "unsupported-on-timeout";
92
+ | "unsupported-on-timeout"
93
+ | "unbound-pr";
88
94
 
89
95
  /** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
90
96
  * input (`nodes[2].kind`, `edges[1].from`, `nodes[0].emits[1].name`), `message` is human-actionable,
@@ -280,6 +286,19 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
280
286
  // validation (pass 3) reads to enforce that a `when` references a SCALAR fact.
281
287
  const nodeFacts = new Map<string, Set<string>>();
282
288
  const nodeFactTypes = new Map<string, Map<string, DeliveryFactType>>();
289
+ // #548 PR late-binding: the converge-connector / pr-wait nodes whose target PR must resolve to a
290
+ // literal `owner/repo#N` OR a threaded upstream `pr` fact. Collected in pass 1, validated in pass 4
291
+ // once the incoming fact edges are known — so an author who references a `pr` fact that isn't
292
+ // actually threaded (or isn't `pr`-typed) is rejected at COMPILE, not left to fail closed at runtime.
293
+ const prBindConsumers: {
294
+ path: string;
295
+ field: "connector.payload.pr" | "wait.target";
296
+ id: string;
297
+ // The authored PR ref: the connector's `payload.pr` or the wait's `target`. `undefined` when
298
+ // absent (a connector may omit it and auto-bind the single incoming `pr` fact; a wait's `target`
299
+ // is a required field reported missing elsewhere).
300
+ authored: string | undefined;
301
+ }[] = [];
283
302
  nodes.forEach((rawNode, i) => {
284
303
  const path = `nodes[${i}]`;
285
304
  if (!isRecord(rawNode)) {
@@ -358,6 +377,26 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
358
377
  code: "unsupported-on-timeout",
359
378
  });
360
379
  }
380
+ // #548: register a converge-connector / pr-wait as a PR-binding consumer (pass 4 validates the
381
+ // binding once edges are resolved). Only when the id is usable so pass 4 can key by node id.
382
+ if (typeof id === "string" && id.length > 0) {
383
+ if (kind === "connector" && typeof config.target === "string" && isConvergeTarget(config.target)) {
384
+ const payload = isRecord(config.payload) ? config.payload : undefined;
385
+ prBindConsumers.push({
386
+ path,
387
+ field: "connector.payload.pr",
388
+ id,
389
+ authored: payload !== undefined && typeof payload.pr === "string" ? payload.pr : undefined,
390
+ });
391
+ } else if (kind === "wait" && config.kind === "pr") {
392
+ prBindConsumers.push({
393
+ path,
394
+ field: "wait.target",
395
+ id,
396
+ authored: typeof config.target === "string" ? config.target : undefined,
397
+ });
398
+ }
399
+ }
361
400
  }
362
401
  } else if (rawNode.human !== undefined && !isRecord(rawNode.human)) {
363
402
  // `human` config is OPTIONAL (formKey/prompt both resolve to a generic fallback in S3), but
@@ -472,6 +511,9 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
472
511
  }
473
512
  // consumer (`to`) → set of upstream node ids (`from`'s node) — the dependency direction.
474
513
  const adjacency = new Map<string, Set<string>>();
514
+ // #548: consumer (`to`) → the resolved fact edges threaded INTO it (`<node>.<fact>` refs + declared
515
+ // types), so pass 4 can confirm a converge/wait PR reference is actually threaded and `pr`-typed.
516
+ const incomingFactRefs = new Map<string, { ref: string; factName: string; factType: DeliveryFactType | undefined }[]>();
475
517
  // Resolved, well-formed edges captured for the guard/topology pass (pass 3). Only edges whose BOTH
476
518
  // endpoints resolve are kept — a dangling/self edge is already reported and must not reach pass 3.
477
519
  const guardEdges: {
@@ -556,6 +598,13 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
556
598
  const ups = adjacency.get(to) ?? new Set<string>();
557
599
  ups.add(nodeId);
558
600
  adjacency.set(to, ups);
601
+ // #548: record a resolved, DECLARED fact edge so pass 4 can confirm a converge/wait PR reference
602
+ // is actually threaded into its consumer (and is `pr`-typed).
603
+ if (fact !== undefined && upstreamFacts.has(fact)) {
604
+ const refs = incomingFactRefs.get(to) ?? [];
605
+ refs.push({ ref: `${nodeId}.${fact}`, factName: fact, factType: nodeFactTypes.get(nodeId)?.get(fact) });
606
+ incomingFactRefs.set(to, refs);
607
+ }
559
608
  guardEdges.push({
560
609
  index: i,
561
610
  fromNode: nodeId,
@@ -570,14 +619,119 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
570
619
  });
571
620
 
572
621
  collectCycle(adjacency, errors);
573
- // Pass 3: guard (S7) semantics only when the graph is otherwise structurally sound (every edge
574
- // resolved, no cycle). A malformed base graph is reported first; guard analysis assumes a DAG.
622
+ // Passes 3 & 4 assume an otherwise structurally-sound graph (every edge resolved, no cycle) — a
623
+ // malformed base graph is reported first, and guard/binding analysis assumes a DAG with resolved
624
+ // fact edges. Both run under the SAME soundness snapshot so a pass-3 guard error can't suppress a
625
+ // pass-4 binding error (they are independent classes and should surface together).
575
626
  if (errors.length === 0) {
627
+ // Pass 3: guard (S7) semantics.
576
628
  validateGuardedEdges(guardEdges, nodeFactTypes, errors);
629
+ // Pass 4 (#548): converge/wait PR late-binding — a referenced PR fact must be threaded + `pr`-typed.
630
+ validatePrBindings(prBindConsumers, incomingFactRefs, nodeFacts, nodeFactTypes, errors);
577
631
  }
578
632
  return errors;
579
633
  }
580
634
 
635
+ /** Pass 4 (#548) — validate that every converge-connector / pr-wait node can resolve its target PR.
636
+ * A converge/wait node needs a PR to enroll or poll; it may name it three ways (mirroring the runtime
637
+ * `resolveConvergePr` / the wait compiler `context put` late-bind):
638
+ * • a LITERAL `owner/repo#N` — never `<node>.<fact>`-shaped, so it resolves to no graph node and is
639
+ * accepted here (the worker/probe validates the literal at runtime);
640
+ * • a fact REFERENCE `<node>.pr` — REQUIRES that the referenced node declares a `pr`-typed fact of
641
+ * that name AND that a fact edge actually threads it into this consumer (else it can never bind);
642
+ * • OMITTED (connectors only) — auto-binds the single incoming `pr` fact, else the single incoming
643
+ * fact; rejected when there is no PR to bind or the choice is ambiguous.
644
+ * Rejecting these at compile (fail closed, path-qualified) turns a silent runtime "requires payload.pr"
645
+ * / unparseable-target failure into an actionable authoring error. */
646
+ function validatePrBindings(
647
+ consumers: readonly {
648
+ path: string;
649
+ field: "connector.payload.pr" | "wait.target";
650
+ id: string;
651
+ authored: string | undefined;
652
+ }[],
653
+ incomingFactRefs: ReadonlyMap<string, { ref: string; factName: string; factType: DeliveryFactType | undefined }[]>,
654
+ nodeFacts: ReadonlyMap<string, ReadonlySet<string>>,
655
+ nodeFactTypes: ReadonlyMap<string, ReadonlyMap<string, DeliveryFactType>>,
656
+ errors: DeliveryGraphError[],
657
+ ): void {
658
+ for (const c of consumers) {
659
+ const incoming = incomingFactRefs.get(c.id) ?? [];
660
+ const errPath = `${c.path}.${c.field}`;
661
+ if (c.authored !== undefined) {
662
+ // `resolveFrom` returns a `fact` iff the reference's prefix is an existing node — so a real PR
663
+ // literal (`owner/repo#N`, no node-qualified dot) resolves to no fact and passes through.
664
+ const resolved = resolveFrom(c.authored, nodeFacts);
665
+ if (resolved.fact === undefined) continue; // a literal — validated at runtime.
666
+ const ref = `${resolved.nodeId}.${resolved.fact}`;
667
+ const declaredType = nodeFactTypes.get(resolved.nodeId)?.get(resolved.fact);
668
+ if (declaredType === undefined) {
669
+ errors.push({
670
+ path: errPath,
671
+ message:
672
+ `${c.id}'s PR reference "${c.authored}" points at fact "${resolved.fact}" that node ` +
673
+ `"${resolved.nodeId}" does not declare in its \`emits[]\``,
674
+ code: "unbound-pr",
675
+ });
676
+ } else if (declaredType !== "pr") {
677
+ errors.push({
678
+ path: errPath,
679
+ message:
680
+ `${c.id}'s PR reference "${c.authored}" resolves to a "${declaredType}" fact — a converge/` +
681
+ "wait PR binding must reference a `pr`-typed fact",
682
+ code: "unbound-pr",
683
+ });
684
+ } else if (!incoming.some((f) => f.ref === ref)) {
685
+ errors.push({
686
+ path: errPath,
687
+ message:
688
+ `${c.id}'s PR reference "${ref}" is not threaded into it — add a fact edge ` +
689
+ `{ from: "${ref}", to: "${c.id}" } so the emitted PR late-binds`,
690
+ code: "unbound-pr",
691
+ });
692
+ }
693
+ continue;
694
+ }
695
+ // OMITTED. Only a connector may omit its PR (auto-bind); a wait's `target` is a required field
696
+ // already reported missing elsewhere, so skip it here to avoid a duplicate error.
697
+ if (c.field !== "connector.payload.pr") continue;
698
+ const prNamed = incoming.filter((f) => f.factName === "pr");
699
+ if (prNamed.length === 1) {
700
+ if (prNamed[0].factType !== "pr") {
701
+ errors.push({
702
+ path: errPath,
703
+ message:
704
+ `converge connector "${c.id}" auto-binds its single incoming \`pr\` fact, but that fact is ` +
705
+ `typed "${prNamed[0].factType}" — declare it as \`pr\` or set connector.payload.pr explicitly`,
706
+ code: "unbound-pr",
707
+ });
708
+ }
709
+ } else if (prNamed.length === 0 && incoming.length === 1) {
710
+ if (incoming[0].factType !== "pr") {
711
+ errors.push({
712
+ path: errPath,
713
+ message:
714
+ `converge connector "${c.id}" would auto-bind its single incoming fact "${incoming[0].ref}", ` +
715
+ `but it is typed "${incoming[0].factType}", not \`pr\` — thread a \`pr\` fact or set ` +
716
+ "connector.payload.pr to a literal or `<node>.pr` reference",
717
+ code: "unbound-pr",
718
+ });
719
+ }
720
+ } else {
721
+ errors.push({
722
+ path: errPath,
723
+ message:
724
+ prNamed.length === 0
725
+ ? `converge connector "${c.id}" has no target PR — set connector.payload.pr to a literal ` +
726
+ '"owner/repo#N" or a "<node>.pr" reference, or thread a single `pr` fact edge into it'
727
+ : `converge connector "${c.id}" has ${prNamed.length} incoming \`pr\` facts — disambiguate ` +
728
+ 'by setting connector.payload.pr to the specific "<node>.pr" reference',
729
+ code: "unbound-pr",
730
+ });
731
+ }
732
+ }
733
+ }
734
+
581
735
  /** True when a guard `equals` literal's JSON type matches the referenced fact's declared scalar type. */
582
736
  function equalsMatchesFactType(equals: unknown, factType: DeliveryGuardScalarType): boolean {
583
737
  switch (factType) {
@@ -252,7 +252,7 @@ test("#514 Defect A: a capability wait-gate escalation surfaces the probe's last
252
252
  // Discrete diagnostic task variables the form/agent can bind directly.
253
253
  assert(esc.includes('target="probeDetail"'), "the escalation surfaces the probe's last detail as a discrete variable");
254
254
  assert(esc.includes('target="observedReleases"'), "the escalation surfaces the observed candidate releases");
255
- assert(/source="=nodeInputs\.[^"]+\.probe\.target" target="probeTarget"/.test(esc), "the escalation surfaces the resolved probe target");
255
+ assert(/source="=if \(is defined\(probe\.target\)\) then probe\.target else nodeInputs\.[^"]+\.probe\.target" target="probeTarget"/.test(esc), "the escalation surfaces the resolved (late-bound) probe target");
256
256
  assert(/source="=nodeInputs\.[^"]+\.probe\.match" target="probeMatch"/.test(esc), "the escalation surfaces the resolved probe match");
257
257
  });
258
258
 
@@ -423,6 +423,56 @@ test("converge-merge worked graph: agent → connector[converge-merge] → wait[
423
423
  assert(!r.sideEffects.some((s) => s.nodeId === "merged"), "the wait gate is not a side effect");
424
424
  });
425
425
 
426
+ test("#548 no-literal converge shape: an emitted `pr` fact late-binds the connector (boundFacts) AND the wait target (context put)", async () => {
427
+ // The canonical `agent → connector[converge-merge] → wait[pr, merged]` shape carrying NO hardcoded PR
428
+ // number: `open` emits the PR it opened as a typed `pr` fact, and both downstream consumers reference
429
+ // it (`open.pr`) on incoming fact edges. The compiler must (a) publish the fact as `<open>_pr`, (b)
430
+ // thread it into the connector's `boundFacts` input, and (c) rewrite the wait's probe target via
431
+ // `context put` to poll the late-bound PR.
432
+ const graph = {
433
+ name: "no-literal converge",
434
+ nodes: [
435
+ {
436
+ id: "open",
437
+ kind: "agent",
438
+ agent: { jobType: "senior:feature", prompt: "Implement and open a PR." },
439
+ emits: [{ name: "pr", type: "pr" }],
440
+ },
441
+ { id: "land", kind: "connector", connector: { target: "converge-merge", payload: { pr: "open.pr" } } },
442
+ { id: "merged", kind: "wait", wait: { kind: "pr", target: "open.pr", match: { prState: "merged" } } },
443
+ ],
444
+ edges: [
445
+ { from: "open.pr", to: "land" },
446
+ { from: "open.pr", to: "merged" },
447
+ ],
448
+ };
449
+ const r = await compileOk(graph);
450
+ const openEl = r.resolved.edges.find((e) => e.from === "open.pr")?.fromNode;
451
+ assertEquals(openEl, "open", "the fact edge resolves to the `open` producer node");
452
+ // (a) the producer publishes its declared `pr` emit into a flat `<element>_pr` parent variable.
453
+ assert(/target="[^"]*_pr"/.test(r.bpmn), "the agent's `pr` emit is published as `<element>_pr`");
454
+ // (b) the connector receives the fact list — its `boundFacts` input names the `pr` fact + producer.
455
+ assert(/target="boundFacts"/.test(r.bpmn), "the connector is threaded a boundFacts input");
456
+ assert(/name: "pr"/.test(r.bpmn), "the boundFacts entry names the `pr` fact");
457
+ // (c) the wait probe target is late-bound via `context put`, not the raw `open.pr` reference literal.
458
+ assert(/context put\([^)]*\.probe, "target",/.test(r.bpmn), "the wait probe target is rewritten via context put");
459
+ assert(!/target="owner\/repo#/.test(r.bpmn), "no hardcoded PR literal is compiled into the graph");
460
+ });
461
+
462
+ test("a wait node with a LITERAL pr target compiles the probe unchanged (no spurious context put)", async () => {
463
+ const graph = {
464
+ name: "literal target",
465
+ nodes: [
466
+ { id: "open", kind: "agent", agent: { jobType: "senior:feature", prompt: "open a PR" } },
467
+ { id: "merged", kind: "wait", wait: { kind: "pr", target: "acme/repo#7", match: { prState: "merged" } } },
468
+ ],
469
+ edges: [{ from: "open", to: "merged" }],
470
+ };
471
+ const r = await compileOk(graph);
472
+ assert(!/context put/.test(r.bpmn), "a literal target is not wrapped in a context put rewrite");
473
+ assert(/source="=nodeInputs\.[^"]+\.probe" target="probe"/.test(r.bpmn), "the probe is seeded directly from nodeInputs");
474
+ });
475
+
426
476
  test("resolved edges carry the resolved fromNode and the referenced fact", async () => {
427
477
  const r = await compileOk(RELEASE_RUNBOOK);
428
478
  const factEdge = r.resolved.edges.find((e) => e.from === "watch-b.mergedSha");
@@ -822,12 +822,30 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
822
822
  // threads null rather than raising a FEEL error.
823
823
  inputs.push({ source: guarded(TRANSCRIPT_URL_BASE_VAR), target: TRANSCRIPT_URL_BASE_VAR });
824
824
  break;
825
- case "wait":
825
+ case "wait": {
826
826
  inputs.push({ source: cfg("gateKey"), target: "gateKey" });
827
- inputs.push({ source: cfg("probe"), target: "probe" });
827
+ // #548 late-binding: when the authored probe `target` is a `<node>.<fact>` reference to an
828
+ // upstream emitted fact threaded on an incoming edge, rewrite the seeded probe's `target` to the
829
+ // OBSERVED value via FEEL `context put`, so the canonical `agent → connector[converge-merge] →
830
+ // wait[pr, merged]` shape polls the PR the agent opened with NO hardcoded literal. A plain literal
831
+ // target (a real `owner/repo#N` is never `<node>.<fact>`-shaped) can't match a bound ref, so it
832
+ // passes through unchanged. Guarded (`is defined`) so an as-yet-unobserved fact keeps the
833
+ // authored value rather than raising a FEEL error.
834
+ const boundTarget = boundInputs.find((b) => `${b.fromNode}.${b.fact}` === node.wait.target);
835
+ if (boundTarget) {
836
+ const varName = `${boundTarget.producerElement}_${boundTarget.fact}`;
837
+ const probeRef = cfg("probe").slice(1);
838
+ inputs.push({
839
+ source: `=context put(${probeRef}, "target", if (is defined(${varName})) then ${varName} else ${probeRef}.target)`,
840
+ target: "probe",
841
+ });
842
+ } else {
843
+ inputs.push({ source: cfg("probe"), target: "probe" });
844
+ }
828
845
  inputs.push({ source: cfg("probeTimeout"), target: "probeTimeout" });
829
846
  inputs.push({ source: cfg("probePollEvery"), target: "probePollEvery" });
830
847
  break;
848
+ }
831
849
  case "human":
832
850
  inputs.push({ source: cfg("escalationSlaTimeout"), target: "escalationSlaTimeout" });
833
851
  inputs.push({ source: cfg("escalationAssignee"), target: "escalationAssignee" });
@@ -983,7 +1001,7 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
983
1001
  const diagnosticInputs = [
984
1002
  { source: "=if (is defined(detail)) then detail else null", target: "probeDetail" },
985
1003
  { source: "=if (is defined(observed)) then observed else null", target: "observedReleases" },
986
- { source: `=nodeInputs.${el}.probe.target`, target: "probeTarget" },
1004
+ { source: `=if (is defined(probe.target)) then probe.target else nodeInputs.${el}.probe.target`, target: "probeTarget" },
987
1005
  { source: `=nodeInputs.${el}.probe.match`, target: "probeMatch" },
988
1006
  ];
989
1007
  return [
@@ -0,0 +1,21 @@
1
+ -- Key the durable agent correlation on the ELEMENT INSTANCE, not just the static BPMN element id
2
+ -- (#544, Stage 1 of transcript↔process-run correlation; ADR 0006 §4b intersection, #464).
3
+ --
4
+ -- `078_agentic_correlation.sql` records `element_id` — the STATIC BPMN id — which is ambiguous across
5
+ -- a looping / retried job: the same activity id occupies many distinct element instances over a
6
+ -- process instance's life, so a transcript keyed only by `element_id` cannot say WHICH occupancy a
7
+ -- token was in. `element_instance_key` is the engine's per-occupancy handle (the same one Nano
8
+ -- Explorer addresses runtime position by, and the one Camunda keys its agent model on), resolved from
9
+ -- the agent job's `jobKey` via the engine element-instance wait-state read (nano-ide#473's binding).
10
+ --
11
+ -- Expand-and-contract: this is the EXPAND step — a nullable, additive column alongside the retained
12
+ -- `element_id` (kept during the transition, never dropped here). NULL for pre-#544 rows and whenever
13
+ -- the (advisory, best-effort) resolution did not land, so it never gates a BPMN sequence flow.
14
+ --
15
+ -- Single source of truth: the durable table's canonical DDL is `AGENTIC_CORRELATION_SCHEMA_SQL` in
16
+ -- `app/agentic/correlation-store.ts` (applied idempotently at store construction). This migration
17
+ -- brings an already-078-migrated DB up to that same effective shape; a drift-guard test
18
+ -- (`correlation-store.test.ts`) pins the migrated schema (078 + 086) to the canonical DDL so the two
19
+ -- can never diverge.
20
+ ALTER TABLE agentic_correlation ADD COLUMN element_instance_key TEXT;
21
+ CREATE INDEX IF NOT EXISTS ix_agentic_correlation_element_instance ON agentic_correlation (element_instance_key);
@@ -113,9 +113,11 @@ The closed set (extensible only by a deliberate ADR/PR, never by graph authors):
113
113
  > PR back to `converging`. `submitPr`'s own `prKey` idempotency additionally makes a resumed re-perform
114
114
  > double-safe on a still-live row. This retires the manual `land-*` human gate whose only job was "go run convergence
115
115
  > yourself" — the canonical shape is now `agent (opens PR) → connector[converge-merge] →
116
- > wait[pr, merged]` with no human node. The payload is `{ pr, convergeOnly?, dependsOn? }`; the MVP
117
- > sources `pr` as a literal (auto-emitting it from the `agent` node as a typed `pr` fact is a deferred
118
- > follow-up). Other connector targets remain the forward-declared stub.
116
+ > wait[pr, merged]` with no human node. The payload is `{ pr, convergeOnly?, dependsOn? }`; the `pr`
117
+ > may be a literal `owner/repo#N`, a `<node>.pr` fact reference late-bound from an upstream `agent`
118
+ > node's emitted `pr` fact, or omitted to auto-bind the single incoming `pr` fact (issue #548 —
119
+ > shipped: the connector resolves it via `resolveConvergePr`, and the `wait[pr]` target is late-bound
120
+ > in the compiler via FEEL `context put`). Other connector targets remain the forward-declared stub.
119
121
 
120
122
  Crucially, **execution stays engine-native**: each node kind is a real, already-deployed
121
123
  sub-process / call activity (`readiness-gate`, a user task, the implementation task, a connector
@@ -597,28 +597,41 @@ into the PR's merge-stage dependency set. The enrollment is idempotent (the conn
597
597
  at-least-once dedupe fence **plus** `submitPr`'s own `prKey` idempotency), so a graph resume /
598
598
  redelivery never double-enrolls.
599
599
 
600
- **Canonical shape** — the agent opens the PR, the connector enrolls it, and a `wait[pr, merged]`
601
- gate binds `mergedSha` when it lands, with **no human node**:
600
+ **Canonical shape** — the agent opens the PR, emits it as a typed **`pr` fact**, and the connector
601
+ and `wait[pr, merged]` gate **late-bind** that fact (no hardcoded PR number, no human node). The
602
+ author never knows the PR number at compose time, so reference it by fact:
602
603
 
603
604
  ```json
604
605
  {
605
606
  "name": "open → converge+merge → wait merged",
606
607
  "nodes": [
607
608
  { "id": "open", "kind": "agent",
608
- "agent": { "jobType": "senior:feature", "prompt": "Implement the change in acme/repo and open a PR." } },
609
+ "agent": { "jobType": "senior:feature", "prompt": "Implement the change in acme/repo and open a PR." },
610
+ "emits": [ { "name": "pr", "type": "pr" } ] },
609
611
  { "id": "land", "kind": "connector",
610
- "connector": { "target": "converge-merge", "payload": { "pr": "acme/repo#123" } } },
612
+ "connector": { "target": "converge-merge", "payload": { "pr": "open.pr" } } },
611
613
  { "id": "merged", "kind": "wait",
612
- "wait": { "kind": "pr", "target": "acme/repo#123", "match": { "prState": "merged" }, "onTimeout": "escalate" } }
614
+ "wait": { "kind": "pr", "target": "open.pr", "match": { "prState": "merged" }, "onTimeout": "escalate" } }
613
615
  ],
614
616
  "edges": [
615
- { "from": "open", "to": "land" },
616
- { "from": "land", "to": "merged" }
617
+ { "from": "open.pr", "to": "land" },
618
+ { "from": "open.pr", "to": "merged" }
617
619
  ]
618
620
  }
619
621
  ```
620
622
 
621
- > **Follow-up (not shipped):** the MVP sources the connector's `pr` as a **literal**. Auto-emitting
622
- > the opened PR from the `agent` node as a typed `pr` fact (so the connector/`wait` bind it instead of
623
- > a literal) is a later slice not required for the graph above.
623
+ The `pr` fact is threaded along the **fact-qualified edges** (`open.pr land`, `open.pr merged`)
624
+ those edges are what carry the observed PR into each consumer, so they are **required** when you
625
+ reference `open.pr` (the validator rejects a reference with no threading edge, `unbound-pr`). Three
626
+ ways to name the target PR:
627
+
628
+ - **fact reference** `"<node>.pr"` — late-bound from the upstream `agent`'s emitted `pr` fact (the
629
+ shape above). The referenced fact must be declared **`pr`-typed** and threaded on an incoming edge.
630
+ - **omitted** (connector only) — `payload` without a `pr` auto-binds the **single** incoming `pr`
631
+ fact, so `"connector": { "target": "converge-merge" }` works when exactly one `pr` fact flows in.
632
+ - **literal** `"owner/repo#N"` — still accepted for a PR you already know (a real ref is never
633
+ `<node>.pr`-shaped, so it never collides with a reference).
634
+
635
+ `senior:feature` already returns the PR it opened, so declaring `emits: [{ "name": "pr", "type": "pr" }]`
636
+ on the agent node is all it takes to publish it (issue #548).
624
637
 
@@ -155,6 +155,36 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
155
155
  assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the graph reached its End event");
156
156
  });
157
157
 
158
+ test("#548 wait target LATE-BINDS from an upstream fact via FEEL `context put` (engine-native)", async () => {
159
+ const app = track(await boot(freshDir()));
160
+
161
+ // The agent emits a `cmd` fact whose OBSERVED value is the deterministic green probe command
162
+ // ("true"). The downstream wait references that fact as its probe `target` (`a.cmd`) — the compiler
163
+ // rewrites the seeded probe's target via FEEL `context put`, so the probe polls the LATE-BOUND
164
+ // value, not a hardcoded literal. This is the exact mechanism the canonical `agent →
165
+ // connector[converge-merge] → wait[pr, merged]` shape uses to poll the PR the agent opened (#548).
166
+ await app.engine.registerWorker("senior:demo", async () => ({ cmd: "true" }));
167
+
168
+ const graph: DeliveryGraph = {
169
+ name: "e2e wait late-bind",
170
+ nodes: [
171
+ { id: "a", kind: "agent", agent: { jobType: "senior:demo" }, emits: [{ name: "cmd", type: "string" }] },
172
+ { id: "w", kind: "wait", wait: { kind: "command", target: "a.cmd", poll: { everyMs: 5, backoff: "fixed" } } },
173
+ ],
174
+ edges: [{ from: "a.cmd", to: "w" }],
175
+ };
176
+
177
+ const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S" });
178
+ assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
179
+ await app.settle();
180
+
181
+ // The wait's probe ran the LATE-BOUND command "true" (green) — had `context put` not resolved the
182
+ // `a.cmd` reference to "true", the probe would have run the literal "a.cmd" (a probe escape the
183
+ // hermetic seam records + fails on), and the gate would never have resolved. The graph reaching End
184
+ // proves the wait released its ready branch off the late-bound target.
185
+ assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the late-bound wait resolved and the graph reached End");
186
+ });
187
+
158
188
  test("resume never double-fires: an at-least-once redelivery of the connector dedupes", async () => {
159
189
  const app = track(await boot(freshDir()));
160
190
  // The connector fired once above's-style; here prove the idempotency directly against the ledger a
package/main.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  import { Server } from "node:http";
21
21
  import { createNanoSdkEngineClient, runFromEnv, selectHost } from "@nanobpm/urban";
22
22
  import { type AgenticChannelHandle, mountAgenticChannel } from "./app/agentic/channel.ts";
23
+ import { makeElementInstanceResolver } from "./app/agentic/element-instance.ts";
23
24
  import { announceEngine, resolveEngineAddress } from "./app/enginePreflight.ts";
24
25
  import { MAX_ROUNDS, pollOnce } from "./app/service.ts";
25
26
  import { envVar } from "./app/version.ts";
@@ -79,6 +80,11 @@ if (httpServer instanceof Server) {
79
80
  secret: agenticSecret ?? "",
80
81
  secure,
81
82
  data: app.data,
83
+ // #544: advisory, read-only element-instance resolution over the shared engine's wait-state
84
+ // read model, so the relay slice can key a captured agent session on the element INSTANCE it
85
+ // occupied (unambiguous across a looping / retried job), not just the static element id. A
86
+ // narrow closure — the agentic families never hold the engine handle itself.
87
+ resolveElementInstance: makeElementInstanceResolver(engine),
82
88
  log: app.log,
83
89
  });
84
90
  if (!secure) {