@nanobpm/nano-workforce 0.200.9 → 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 CHANGED
@@ -1,3 +1,13 @@
1
+ ## [0.201.0](https://github.com/nanobpm/nano-workforce/compare/v0.200.9...v0.201.0) (2026-10-06)
2
+
3
+ ### Features
4
+
5
+ * **convergence:** escalate non-converging same-surface review churn ([#870](https://github.com/nanobpm/nano-workforce/issues/870)) ([#875](https://github.com/nanobpm/nano-workforce/issues/875)) ([307505b](https://github.com/nanobpm/nano-workforce/commit/307505b3e9603129c3ad101b29dc1f10992f038e))
6
+
7
+ ### Bug Fixes
8
+
9
+ * **delivery-graph:** bind escalation value to emit, fail closed on null gate target, default node SLA ([#876](https://github.com/nanobpm/nano-workforce/issues/876)) ([ff6f3d0](https://github.com/nanobpm/nano-workforce/commit/ff6f3d0f191b641c13e460ac03c8ecfa075026f4)), closes [#872](https://github.com/nanobpm/nano-workforce/issues/872) [#872](https://github.com/nanobpm/nano-workforce/issues/872) [#872](https://github.com/nanobpm/nano-workforce/issues/872) [owner/repo#N](https://github.com/owner/repo/issues/N) [#872](https://github.com/nanobpm/nano-workforce/issues/872) [#872](https://github.com/nanobpm/nano-workforce/issues/872)
10
+
1
11
  ## [0.200.9](https://github.com/nanobpm/nano-workforce/compare/v0.200.8...v0.200.9) (2026-10-05)
2
12
 
3
13
  ### Bug Fixes
package/SPEC.md CHANGED
@@ -372,6 +372,41 @@ Notes:
372
372
  poller does not solicit a spurious review against the still-running round. The
373
373
  round cap and the review-wait timeout remain the outer safety nets.
374
374
 
375
+ - **Non-converging same-surface churn guard (issue #870).** The no-progress guard
376
+ above catches a round that pushed NOTHING. Churn is the opposite failure mode: a
377
+ loop where EVERY round pushes a commit (so the head advances and the round
378
+ legitimately "progresses") yet never reaches a fixed point — a contested surface
379
+ (e.g. a fail-closed analyzer whose bypass forms are unbounded) keeps producing new
380
+ findings in the SAME file round after round, and the loop runs to `maxRounds`
381
+ without a human ever being asked to make the scope call. So on a head-advancing
382
+ `addressed` round, BEFORE parking for the next review, `pr.progress-check` reads
383
+ the PR's durable `rounds` history (`app/roundChurn.ts` `detectChurn`, the single
384
+ canonical detector) and escalates when the trailing run of CONSECUTIVE `addressed`
385
+ rounds is at least `CHURN_WINDOW` (4) long AND some single file appears in EVERY
386
+ round of that window. Requiring the SAME file across the whole window keeps normal
387
+ convergence — findings that move between areas and trend down — from escalating
388
+ early. The file names are mined from each round's free-text `summary` (URL spans
389
+ stripped, so a cited link is never mistaken for a repo path). When it fires, the
390
+ round routes to the SAME human escalation path the no-advance guard uses (`gw-husk`
391
+ → `persist-escalation-noprogress`) carrying a `noProgressReason:"churn"` SCOPE
392
+ question — it never parks at `waiting_review`. Two invariants keep the signal
393
+ honest across runs and resumes:
394
+ - **Run-scoped history.** The history read is filtered by the job's
395
+ `process_instance_key` (the `rounds` table is run-scoped — migration 102), so a
396
+ prior run's retained rows never leak into this run's window. A legacy NULL-key
397
+ row is dropped (fail-OPEN: dropping a row only ever reduces the churn signal).
398
+ - **Churn-reset watermark.** A churn escalation writes no `blocked` round
399
+ (`recordRound=false`) and the human-answer resume re-enters the SAME numeric
400
+ round (the counter only advances at the review-wait gateway), so the escalating
401
+ round number is persisted as `pull_requests.churn_escalated_round`
402
+ (migration 117, cleared on re-open with the rest of the per-run state).
403
+ `detectChurn` drops every round at or before that watermark, so the human's
404
+ scope decision restarts the churn clock: churn can only fire again after a
405
+ fresh `CHURN_WINDOW` of same-file rounds accumulates past the decision, exactly
406
+ as a recorded `needs_input`/`blocked` round would restart it.
407
+ A head that cannot be read, and a history read that throws, both fail OPEN (no
408
+ churn escalation) — a transient GitHub/datasource hiccup never fabricates one.
409
+
375
410
  - **Stale-output reset on round entry (issue #822).** `pr.capture-head` — the
376
411
  single task every entry into `review-round` routes through first (from `Start`,
377
412
  the review-loop re-enter, the human-answer resume, the husk auto-retry, and the
@@ -480,6 +480,16 @@ test("invalid-fact-name: an emitted fact name over the openapi 128-char cap is r
480
480
  assertEquals(err.path, "nodes[0].emits[0].name");
481
481
  });
482
482
 
483
+ test("fact-name `resumeValid` is NOT reserved — a user emit may use it (PR #876 review: the resume-validation gate's flag is an internal compiler-generated var, not a user fact name, so reserving `resumeValid` would needlessly break recompilation of durable rows that already carry a fact of that name)", () => {
484
+ const errors = validateDeliveryGraph({
485
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "resumeValid", type: "boolean" }] }],
486
+ });
487
+ assert(
488
+ !errors.some((e) => e.path === "nodes[0].emits[0].name"),
489
+ `expected no fact-name error for \`resumeValid\`, got ${JSON.stringify(errors)}`,
490
+ );
491
+ });
492
+
483
493
  test("invalid-fact-type: an emitted fact with a type outside the allowlist is rejected, path-qualified", () => {
484
494
  const errors = validateDeliveryGraph({
485
495
  nodes: [
@@ -14,7 +14,7 @@
14
14
  import { test } from "node:test";
15
15
  import { assert, assertEquals } from "#test-assert";
16
16
  import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
17
- import { compileDeliveryGraph, digestInvisibleRawValues, graphCarriesRedactedSecrets, nodeDisplay, redactFreeText } from "./deliveryGraphCompiler.ts";
17
+ import { compileDeliveryGraph, digestInvisibleRawValues, graphCarriesRedactedSecrets, nodeDisplay, redactFreeText, resumeValidVar } from "./deliveryGraphCompiler.ts";
18
18
 
19
19
  /** Compile and assert success, returning the narrowed ok-result. */
20
20
  async function compileOk(graph: unknown) {
@@ -320,21 +320,24 @@ test("#514 Defect A: a capability wait-gate escalation surfaces the probe's last
320
320
  assert(/source="=nodeInputs\.[^"]+\.probe\.match" target="probeMatch"/.test(esc), "the escalation surfaces the resolved probe match");
321
321
  });
322
322
 
323
- test("#514 Defect B: a resumed wait-node escalation maps the operator-supplied value onto the node's emit source (version→detail, artifact→resolvedArtifact)", async () => {
323
+ test("#514 Defect B + #876 review: a resumed wait-node escalation VALIDATES the operator value onto the node's emit source (version→detail, artifact→resolvedArtifact)", async () => {
324
324
  const r = await compileOk(CAP_GATE);
325
325
  // Red before the fix: the wait escalation forced emitMode="none" (hiding the value field) and carried
326
326
  // NO output mapping, so a resume published `<el>_<fact> = null`, starving the downstream consumer.
327
+ // (#876 review: the task VALIDATES the form's `value` on its OWN output mapping — a gateway ioMapping
328
+ // is not visible downstream in the pinned engine — binding the emit-source var only on a valid value.)
327
329
  const escV = escBlockForNode(r.bpmn, "n2");
328
- // A `version` emit is sourced from `detail` — the operator's captured `value` must be mapped there.
330
+ // A `version` emit is sourced from `detail` — the operator's captured `value` must land there.
329
331
  assert(escV.includes('="typed"') && escV.includes('target="emitMode"'), "a wait node with emits PRESENTS its value field on escalation, not 'none'");
330
332
  assert(escV.includes("publishedVersion (version)") && escV.includes('target="emitLabel"'), "the emit label names the awaited fact");
331
- assert(/source="=if \(is defined\(value\)\) then value else null" target="detail"/.test(escV), "the operator's value is mapped onto the version emit's source var (detail)");
333
+ assert(/<zeebe:output [\s\S]*?target="detail" \/>/.test(escV), "the version emit's source var (detail) is bound on the task output");
334
+ assert(/<zeebe:output [\s\S]*?target="[^"]*__resumeValid" \/>/.test(escV), "the task flags the resume validity");
332
335
 
333
336
  const escA = escBlockForNode(r.bpmn, "n3");
334
337
  // An `artifact` emit is ALSO sourced from the generic form's single `value` field (the form has no
335
- // `resolvedArtifact` field), mapped onto the artifact emit's source var (resolvedArtifact) — so an
336
- // artifact wait-node escalation is actually resumable via the UI.
337
- assert(/source="=if \(is defined\(value\)\) then value else null" target="resolvedArtifact"/.test(escA), "the operator's value is mapped onto the artifact emit's source var (resolvedArtifact)");
338
+ // `resolvedArtifact` field), validated and bound onto the artifact emit's source var
339
+ // (resolvedArtifact) — so an artifact wait-node escalation is actually resumable via the UI.
340
+ assert(/<zeebe:output [\s\S]*?target="resolvedArtifact" \/>/.test(escA), "the artifact emit's source var (resolvedArtifact) is bound on the task output");
338
341
  });
339
342
 
340
343
  test("#514 Defect B: a service-node escalation (agent) stays inert — no emit field, no resume output mapping (only wait resumes)", async () => {
@@ -344,6 +347,316 @@ test("#514 Defect B: a service-node escalation (agent) stays inert — no emit f
344
347
  assert(!esc.includes("<bpmn:output") && !esc.includes("<zeebe:output"), "an agent-node escalation carries no emit-source output mapping");
345
348
  });
346
349
 
350
+ // An agent that owes a required `pr` emit to a downstream consumer — its timeout `__esc` (and a
351
+ // producer-contract `__contract`) is RESUMABLE with that emit (#872), so it is the natural surface
352
+ // for the resume-validation gate (PR #876 review).
353
+ const RESUME_GATE_GRAPH = {
354
+ name: "resume validation gate",
355
+ nodes: [
356
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "open a PR" }, emits: [{ name: "pr", type: "pr" }] },
357
+ { id: "land", kind: "connector", connector: { target: "converge-merge", payload: { pr: "open.pr" } } },
358
+ ],
359
+ edges: [{ from: "open.pr", to: "land" }],
360
+ };
361
+
362
+ test("#876 review: a resumable escalation VALIDATES the operator value onto the emit-source var on its own output mapping", async () => {
363
+ // The emit-source var (`factSourceVar`) is bound from the form's `value` ONLY when it satisfies the
364
+ // per-emit type condition — never unconditionally — so an omitted/malformed resume can never thread
365
+ // null/garbage onto the emit the subProcess output mapping republishes downstream. The task also
366
+ // flags the resume-valid boolean for the post-escalation gateway.
367
+ const r = await compileOk(RESUME_GATE_GRAPH);
368
+ for (const suffix of ["esc", "contract"] as const) {
369
+ const esc = escBlockForNodeSuffix(r.bpmn, "open", suffix);
370
+ assert(/<zeebe:output [\s\S]*?target="pr" \/>/.test(esc), `the __${suffix} task binds the emit-source var (pr) on its output`);
371
+ assert(/<zeebe:output [\s\S]*?target="pr" \/>/.test(esc) && esc.includes("matches("), `the __${suffix} binding is type-validated (a pr must match the PR-key shape)`);
372
+ assert(/<zeebe:output [\s\S]*?target="[^"]*__resumeValid" \/>/.test(esc), `the __${suffix} task flags its resume-valid flag`);
373
+ }
374
+ });
375
+
376
+ test("#876 review: a resumable escalation is followed by a validation gate that loops back on a missing/invalid value and releases only a valid one", async () => {
377
+ const r = await compileOk(RESUME_GATE_GRAPH);
378
+ const openEl = elementForNode(r.bpmn, "open");
379
+ const esc = `delivery-human-task__${openEl}__esc`;
380
+ // The gate exists, named for its purpose, and defaults to the INVALID (loop-back) branch so a
381
+ // condition failure can never release a null downstream (fail closed). It is a PLAIN gateway (no
382
+ // ioMapping — a gateway ioMapping is not visible downstream in the pinned engine), routing on the
383
+ // simple `<resumeValidVar> = true` boolean the escalation task computed.
384
+ const gateStart = r.bpmn.indexOf(`<bpmn:exclusiveGateway id="${esc}Vg" name="resume value valid?" default="${esc}Vbad"`);
385
+ assert(gateStart !== -1, "the timeout escalation is followed by a resume-validation gateway defaulting to the invalid branch");
386
+ assert(
387
+ r.bpmn.includes(`<bpmn:sequenceFlow id="${esc}Vok" name="valid" sourceRef="${esc}Vg" targetRef="${openEl}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=${resumeValidVar(esc)} = true</bpmn:conditionExpression>`),
388
+ "the valid branch routes on the resume-valid flag and proceeds to the node end",
389
+ );
390
+ // The INVALID branch loops back to the escalation task (re-park with an explanation), so a blank or
391
+ // malformed `value` never threads null downstream.
392
+ assert(
393
+ r.bpmn.includes(`<bpmn:sequenceFlow id="${esc}Vbad" name="invalid" sourceRef="${esc}Vg" targetRef="${esc}" />`),
394
+ "the invalid branch loops back onto the escalation task",
395
+ );
396
+ // The gateway carries NO ioMapping (the validation ran on the task, not the gateway).
397
+ const gateBlock = r.bpmn.slice(gateStart, r.bpmn.indexOf("</bpmn:exclusiveGateway>", gateStart));
398
+ assert(!gateBlock.includes("<zeebe:ioMapping>"), "the validation gateway is a plain routing gateway (no ioMapping)");
399
+ });
400
+
401
+ test("#876 review: a NON-resumable escalation (no required emits) stays inert — no validation gate, no resume outputs", async () => {
402
+ const r = await compileOk(CAP_GATE);
403
+ // `gv` owes no required emit: its escalation is the inert acknowledgement and must NOT grow a gate.
404
+ const gvEl = elementForNode(r.bpmn, "gv");
405
+ assert(!r.bpmn.includes(`delivery-human-task__${gvEl}__escVg`), "a no-required-emit node carries no resume-validation gateway");
406
+ const esc = escBlockForNode(r.bpmn, "gv");
407
+ assert(!/target="[^"]*__resumeValid"/.test(esc), "an inert escalation publishes no resume-valid flag");
408
+ });
409
+
410
+ test("#876 review: the producer-contract escalation is validated by the SAME resume gate", async () => {
411
+ const r = await compileOk(RESUME_GATE_GRAPH);
412
+ const openEl = elementForNode(r.bpmn, "open");
413
+ const contract = `delivery-human-task__${openEl}__contract`;
414
+ assert(r.bpmn.includes(`<bpmn:exclusiveGateway id="${contract}Vg" name="resume value valid?" default="${contract}Vbad"`), "the contract escalation grows its own validation gateway");
415
+ assert(
416
+ r.bpmn.includes(`<bpmn:sequenceFlow id="${contract}Vbad" name="invalid" sourceRef="${contract}Vg" targetRef="${contract}" />`),
417
+ "the contract invalid branch loops back onto the contract escalation task",
418
+ );
419
+ });
420
+
421
+ test("#876 review: a resumed WAIT-gate escalation validates against its emit type (version→detail)", async () => {
422
+ const r = await compileOk(CAP_GATE);
423
+ const n2El = elementForNode(r.bpmn, "n2");
424
+ const esc = escBlockForNode(r.bpmn, "n2");
425
+ // The version emit's source var (detail) is bound on the task output, type-validated, plus the
426
+ // resume-valid flag for the gateway.
427
+ assert(/<zeebe:output [\s\S]*?target="detail" \/>/.test(esc), "the wait escalation binds the version emit's source var (detail) on its output");
428
+ assert(/<zeebe:output [\s\S]*?target="[^"]*__resumeValid" \/>/.test(esc), "the wait escalation flags its resume-valid flag");
429
+ const gateStart = r.bpmn.indexOf(`<bpmn:exclusiveGateway id="delivery-human-task__${n2El}__escVg"`);
430
+ assert(gateStart !== -1, "the wait timeout escalation grows a resume-validation gateway");
431
+ });
432
+
433
+ // A producer owing MORE THAN ONE required emit to downstream consumers. The generic escalation form
434
+ // captures a single `value`, so such a node CANNOT be resumed from the UI (one value can't supply two
435
+ // distinct facts) — the escalation must FAIL CLOSED (PR #876 review).
436
+ const MULTI_EMIT_GRAPH = {
437
+ name: "multi-emit fail-closed",
438
+ nodes: [
439
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "open a PR" }, emits: [{ name: "pr", type: "pr" }, { name: "version", type: "version" }] },
440
+ { id: "land", kind: "connector", connector: { target: "converge-merge", payload: { pr: "open.pr", version: "open.version" } } },
441
+ ],
442
+ edges: [
443
+ { from: "open.pr", to: "land" },
444
+ { from: "open.version", to: "land" },
445
+ ],
446
+ };
447
+
448
+ test("#876 review: a multi-required-emit escalation FAILS CLOSED — it binds no fact value and hard-sets its resume-valid flag to false (the single-value form can't supply two distinct emits)", async () => {
449
+ const r = await compileOk(MULTI_EMIT_GRAPH);
450
+ const openEl = elementForNode(r.bpmn, "open");
451
+ for (const suffix of ["esc", "contract"] as const) {
452
+ const esc = escBlockForNodeSuffix(r.bpmn, "open", suffix);
453
+ const flagVar = resumeValidVar(`delivery-human-task__${openEl}__${suffix}`);
454
+ // No per-fact value binding — copying one `value` onto two distinct emits would release garbage.
455
+ assert(!/<zeebe:output [\s\S]*?target="pr" \/>/.test(esc), `the __${suffix} multi-emit escalation binds NO pr value`);
456
+ assert(!/<zeebe:output [\s\S]*?target="detail" \/>/.test(esc), `the __${suffix} multi-emit escalation binds NO version value`);
457
+ // The flag is a hard `false` — so the downstream gate always takes its default (invalid) branch.
458
+ assert(esc.includes(`<zeebe:output source="=false" target="${flagVar}" />`), `the __${suffix} multi-emit escalation hard-sets its resume-valid flag to false`);
459
+ // The gate still exists and routes on the (always-false) flag, so a resume always re-parks.
460
+ assert(
461
+ r.bpmn.includes(`<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=${flagVar} = true</bpmn:conditionExpression>`),
462
+ `the __${suffix} valid branch routes on the (always-false) resume-valid flag`,
463
+ );
464
+ }
465
+ });
466
+
467
+ test("#876 round-4 review: the resume-valid flag is distinct from any declared emit's fact-source var", async () => {
468
+ // The flag is derived from the escalation element id, which is itself a legal fact-name string — so
469
+ // a node could declare an emit NAMED `delivery_human_task__<el>__esc__resumeValid`, and for an
470
+ // `agent`/`connector` node that fact's emit-source var IS its own name (factSourceVar), which could
471
+ // collide with the flag (the valid resume would publish `true` instead of the supplied value). The
472
+ // `__flag__` infix plus the collision-free suffix in escalationTaskLines keep the two distinct WITHOUT
473
+ // reserving any user fact name. A graph whose single emit is named close to the flag string must still
474
+ // compile with a DISTINCT flag var and bind the emit under its own name.
475
+ const collisionName = "delivery_human_task__open__esc__resumeValid";
476
+ const r = await compileOk({
477
+ name: "resume flag namespace",
478
+ nodes: [
479
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "x" }, emits: [{ name: collisionName, type: "string" }] },
480
+ { id: "land", kind: "connector", connector: { target: "slack", payload: { v: `open.${collisionName}` }, dedupeKey: "ns-c" } },
481
+ ],
482
+ edges: [{ from: `open.${collisionName}`, to: "land" }],
483
+ });
484
+ const openEl = elementForNode(r.bpmn, "open");
485
+ const esc = `delivery-human-task__${openEl}__esc`;
486
+ const flagVar = resumeValidVar(esc);
487
+ // The flag carries the internal `__flag__` marker …
488
+ assert(flagVar.includes("__flag__"), `the resume-valid flag is namespaced with __flag__, got ${flagVar}`);
489
+ // … and is DISTINCT from the declared emit's fact-source var (its own name for an agent node), so the
490
+ // two outputs can never share one variable.
491
+ const escBlock = escBlockForNodeSuffix(r.bpmn, "open", "esc");
492
+ assert(escBlock.includes(`target="${collisionName}"`), "the declared emit binds under its own fact name");
493
+ assert(escBlock.includes(`target="${flagVar}"`), "the resume-valid flag binds under its distinct namespaced var");
494
+ assert(flagVar !== collisionName, "the flag var differs from the declared emit's source var");
495
+ });
496
+
497
+ test("#876 round-4 'Previously missed': the resume FEEL grammar ALIGNS with the canonical artifact + PR contracts", async () => {
498
+ // A timed-out node must be resumable with a value that works on its normal completion path. The
499
+ // `artifact` branch must accept a SCOPED package (`@nanobpm/urban@0.54.0` — coerceFactValue splits on
500
+ // the LAST `@`), and the `pr` branch must accept a canonical GitHub PR URL (parsePr) in addition to
501
+ // the `owner/repo#N` shorthand — otherwise the escalation rejects a value the canonical path accepts.
502
+ const r = await compileOk({
503
+ name: "resume grammar alignment",
504
+ nodes: [
505
+ { id: "pub", kind: "agent", agent: { jobType: "senior:demo", prompt: "x" }, emits: [{ name: "artifactRef", type: "artifact" }] },
506
+ { id: "land", kind: "connector", connector: { target: "slack", payload: { a: "pub.artifactRef" }, dedupeKey: "gram-a" } },
507
+ ],
508
+ edges: [{ from: "pub.artifactRef", to: "land" }],
509
+ });
510
+ const escA = escBlockForNodeSuffix(r.bpmn, "pub", "esc");
511
+ // The artifact grammar permits an optional leading scope `@` before the name segment.
512
+ assert(escA.includes('"^@?[^@\\\\s]+@v?\\\\d[\\\\w.+-]*$"'), `the artifact resume grammar accepts a scoped package, got ${escA}`);
513
+
514
+ const rP = await compileOk(RESUME_GATE_GRAPH);
515
+ const escP = escBlockForNodeSuffix(rP.bpmn, "open", "esc");
516
+ // The pr grammar is the disjunction of the `owner/repo#N` shorthand AND a canonical GitHub PR URL.
517
+ assert(escP.includes("github\\\\.com/[^/]+/[^/]+/pull/"), `the pr resume grammar accepts a canonical GitHub PR URL, got ${escP}`);
518
+ assert(escP.includes("[^/#]+/[^/#]+)#"), "the pr resume grammar still accepts the owner/repo#N shorthand");
519
+ // Case-INSENSITIVE like parsePr's `/i`: the tested value is folded through `lower case(...)` so a
520
+ // mixed-case host/scheme (`https://GitHub.com/...`) the normal completion path accepts is not
521
+ // rejected here. The match folds; the bound `value` stays verbatim (see the verbatim-bind test).
522
+ assert(
523
+ escP.includes("matches(lower case(trim(string(value)))"),
524
+ `the pr resume grammar folds the tested value to lower case for case-insensitive alignment, got ${escP}`,
525
+ );
526
+ });
527
+
528
+ test("#876 round-5 review: a declared emit whose name EQUALS the generated resume-valid flag var is ACCEPTED — the flag deterministically grows a collision-free suffix instead of reserving the fact-name space", async () => {
529
+ // openapi's DeliveryFact.name permits `^[A-Za-z_][A-Za-z0-9_]*$`, so `my__flag__fact` and even the
530
+ // exact generated flag string are legal names that durable rows may already carry. Round-4 RESERVED
531
+ // the `__flag__` marker to forbid a collision, which silently rejected those previously valid names on
532
+ // recompilation (PR #876 round-5 review). Instead the single bind site makes the flag collision-free:
533
+ // if the single emit's fact-source var EQUALS the generated flag var, the flag grows a `_` suffix
534
+ // until distinct. The graph must COMPILE, bind the value under the fact's own name, and bind the flag
535
+ // under the distinct suffixed var.
536
+ //
537
+ // The flag is derived from `open`'s COMPILED element id (`nK`), which is not user-predictable, so
538
+ // probe-compile to learn it, then name the emit to the exact generated flag var (topology is
539
+ // unchanged by the rename, so the element id is stable).
540
+ const probe = await compileOk({
541
+ name: "flag collision probe",
542
+ nodes: [
543
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "x" }, emits: [{ name: "probeFact", type: "string" }] },
544
+ { id: "land", kind: "connector", connector: { target: "slack", payload: { v: "open.probeFact" }, dedupeKey: "coll-p" } },
545
+ ],
546
+ edges: [{ from: "open.probeFact", to: "land" }],
547
+ });
548
+ const collisionName = resumeValidVar(`delivery-human-task__${elementForNode(probe.bpmn, "open")}__esc`);
549
+ assert(collisionName.includes("__flag__"), "sanity: the flag var carries the internal infix");
550
+ const r = await compileOk({
551
+ name: "flag collision tolerated",
552
+ nodes: [
553
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "x" }, emits: [{ name: collisionName, type: "string" }] },
554
+ { id: "land", kind: "connector", connector: { target: "slack", payload: { v: `open.${collisionName}` }, dedupeKey: "coll-c" } },
555
+ ],
556
+ edges: [{ from: `open.${collisionName}`, to: "land" }],
557
+ });
558
+ const escBlock = escBlockForNodeSuffix(r.bpmn, "open", "esc");
559
+ // The declared emit still binds under its OWN name (the user's value is recovered on a valid resume).
560
+ assert(escBlock.includes(`target="${collisionName}"`), `the declared emit binds under its own fact name, got ${escBlock}`);
561
+ // The flag binds under a DISTINCT var — the collision-free `_`-suffixed name, never the fact's var.
562
+ const suffixedFlag = `${collisionName}_`;
563
+ assert(escBlock.includes(`target="${suffixedFlag}"`), `the resume-valid flag binds under the distinct suffixed var, got ${escBlock}`);
564
+ // … and the gateway routes on that SAME suffixed flag var, so the valid branch stays wired.
565
+ assert(
566
+ r.bpmn.includes(`<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=${suffixedFlag} = true</bpmn:conditionExpression>`),
567
+ `the valid branch routes on the suffixed flag var, got ${r.bpmn}`,
568
+ );
569
+
570
+ // A merely `__flag__`-bearing fact name (not the exact flag) is now also ACCEPTED — the public name
571
+ // space is no longer narrowed (PR #876 round-5 review).
572
+ const rOk = await compileOk({
573
+ name: "flag-infix name tolerated",
574
+ nodes: [
575
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "x" }, emits: [{ name: "my__flag__fact", type: "string" }] },
576
+ { id: "land", kind: "connector", connector: { target: "slack", payload: { v: "open.my__flag__fact" }, dedupeKey: "coll-d" } },
577
+ ],
578
+ edges: [{ from: "open.my__flag__fact", to: "land" }],
579
+ });
580
+ assert(escBlockForNodeSuffix(rOk.bpmn, "open", "esc").includes('target="my__flag__fact"'), "a __flag__-bearing fact name compiles and binds under its own name");
581
+ });
582
+
583
+ test("#876 round-6 review: the resume-valid flag dodges a NON-REQUIRED declared emit named exactly the flag, not only the resumed required emit", async () => {
584
+ // The round-5 fix grew the flag past only the SINGLE resumed required emit's source var. But every
585
+ // declared emit — including a NON-required one absent from `resume.emits` (a routing-only `when`-guard
586
+ // emit, or a declared-but-unconsumed emit) — is still republished by the node's subProcess output
587
+ // mapping from its own source var (for an agent that var IS the fact's own name). A node that declares
588
+ // a REQUIRED emit (so the escalation is resumable and a flag exists) PLUS a non-required emit named
589
+ // EXACTLY the generated flag would, under the old code, have the escalation write its boolean validity
590
+ // flag into that emit's var — and the subProcess then publishes `true`/`false` for that emit downstream
591
+ // (potentially selecting a guarded branch — PR #876 round-6 review). The flag must grow collision-free
592
+ // against ALL declared emits, so bind the extra emit to the exact flag var and assert the flag dodges.
593
+ //
594
+ // The flag is derived from `open`'s COMPILED element id; renaming/adding the extra (unconsumed) emit
595
+ // does not change topology, so probe-compile with the final shape to learn the element id, then name
596
+ // the extra emit the exact generated flag var.
597
+ const probe = await compileOk({
598
+ name: "flag non-required collision probe",
599
+ nodes: [
600
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "x" }, emits: [{ name: "pr", type: "pr" }, { name: "routeProbe", type: "boolean" }] },
601
+ { id: "land", kind: "connector", connector: { target: "slack", payload: { v: "open.pr" }, dedupeKey: "nr-p" } },
602
+ ],
603
+ edges: [{ from: "open.pr", to: "land" }],
604
+ });
605
+ const flagVar = resumeValidVar(`delivery-human-task__${elementForNode(probe.bpmn, "open")}__esc`);
606
+ const r = await compileOk({
607
+ name: "flag non-required collision tolerated",
608
+ nodes: [
609
+ // `pr` is REQUIRED (consumed by `land` below), so the timeout escalation is resumable and owns a
610
+ // flag; `flagVar` is a declared-but-unconsumed emit named exactly that flag — absent from the
611
+ // required set, so the old single-target collision check never saw it.
612
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "x" }, emits: [{ name: "pr", type: "pr" }, { name: flagVar, type: "boolean" }] },
613
+ { id: "land", kind: "connector", connector: { target: "slack", payload: { v: "open.pr" }, dedupeKey: "nr-c" } },
614
+ ],
615
+ edges: [{ from: "open.pr", to: "land" }],
616
+ });
617
+ const escBlock = escBlockForNodeSuffix(r.bpmn, "open", "esc");
618
+ // The flag binds under the DISTINCT suffixed var, never the declared emit's own var.
619
+ const suffixedFlag = `${flagVar}_`;
620
+ assert(escBlock.includes(`target="${suffixedFlag}" />`), `the flag grows a collision-free suffix past the non-required emit, got ${escBlock}`);
621
+ assert(!escBlock.includes(`target="${flagVar}" />`), "the flag never binds under the non-required emit's own var");
622
+ // The gateway routes on that SAME suffixed flag var, so the valid branch stays wired.
623
+ assert(
624
+ r.bpmn.includes(`<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=${suffixedFlag} = true</bpmn:conditionExpression>`),
625
+ `the valid branch routes on the suffixed flag var, got ${r.bpmn}`,
626
+ );
627
+ // The non-required emit is still republished from its OWN (now flag-free) var by the subProcess output.
628
+ const el = elementForNode(r.bpmn, "open");
629
+ assert(
630
+ r.bpmn.includes(`<zeebe:output source="=if (is defined(${flagVar})) then ${flagVar} else null" target="${el}_${flagVar}" />`),
631
+ "the non-required emit still republishes from its own source var, uncontaminated by the flag",
632
+ );
633
+ });
634
+
635
+ test("#876 round-6 review: the PRODUCER-CONTRACT (`__contract`) escalation flag ALSO dodges a non-required declared emit named the flag", async () => {
636
+ // The same subProcess scope hosts both the timeout `__esc` and the producer-contract `__contract`
637
+ // escalations, so the contract flag must dodge the full declared-emit set too (PR #876 round-6 review).
638
+ const probe = await compileOk({
639
+ name: "contract flag collision probe",
640
+ nodes: [
641
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "x" }, emits: [{ name: "pr", type: "pr" }, { name: "cProbe", type: "boolean" }] },
642
+ { id: "land", kind: "connector", connector: { target: "slack", payload: { v: "open.pr" }, dedupeKey: "cr-p" } },
643
+ ],
644
+ edges: [{ from: "open.pr", to: "land" }],
645
+ });
646
+ const flagVar = resumeValidVar(`delivery-human-task__${elementForNode(probe.bpmn, "open")}__contract`);
647
+ const r = await compileOk({
648
+ name: "contract flag collision tolerated",
649
+ nodes: [
650
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "x" }, emits: [{ name: "pr", type: "pr" }, { name: flagVar, type: "boolean" }] },
651
+ { id: "land", kind: "connector", connector: { target: "slack", payload: { v: "open.pr" }, dedupeKey: "cr-c" } },
652
+ ],
653
+ edges: [{ from: "open.pr", to: "land" }],
654
+ });
655
+ const escBlock = escBlockForNodeSuffix(r.bpmn, "open", "contract");
656
+ const suffixedFlag = `${flagVar}_`;
657
+ assert(escBlock.includes(`target="${suffixedFlag}" />`), `the contract flag grows a collision-free suffix past the non-required emit, got ${escBlock}`);
658
+ assert(!escBlock.includes(`target="${flagVar}" />`), "the contract flag never binds under the non-required emit's own var");
659
+ });
347
660
 
348
661
  test("rejects unknown kind (by construction) with a path-qualified error, nothing compiled", async () => {
349
662
  const errors = await compileFail({
@@ -579,6 +892,34 @@ test("a wait node with a LITERAL pr target compiles the probe unchanged (no spur
579
892
  assert(/source="=nodeInputs\.[^"]+\.probe" target="probe"/.test(r.bpmn), "the probe is seeded directly from nodeInputs");
580
893
  });
581
894
 
895
+ test("#872: a fact-bound wait target whose upstream fact is NULL binds probe.target to null (fail-closed provenance), never the unresolvable fact-ref literal", async () => {
896
+ // The compiler — not the worker — owns the resolved/unresolved provenance (PR #876 review): when the
897
+ // upstream fact is as-yet-unobserved, the `context put` else-branch must write NULL (which the
898
+ // readiness-probe worker fails closed on) rather than leave the authored `<node>.<fact>` literal in
899
+ // place. A runtime syntax test on the target cannot distinguish that literal from a VALID dotted
900
+ // command target (e.g. `check.sh`), which must always reach the probe verbatim.
901
+ const graph = {
902
+ name: "null fact-ref provenance",
903
+ nodes: [
904
+ {
905
+ id: "open",
906
+ kind: "agent",
907
+ agent: { jobType: "senior:feature", prompt: "Implement and open a PR." },
908
+ emits: [{ name: "pr", type: "pr" }],
909
+ },
910
+ { id: "merged", kind: "wait", wait: { kind: "pr", target: "open.pr", match: { prState: "merged" } } },
911
+ ],
912
+ edges: [{ from: "open.pr", to: "merged" }],
913
+ };
914
+ const r = await compileOk(graph);
915
+ // The else-branch binds NULL (provenance), not the authored fact-ref literal.
916
+ assert(
917
+ /context put\([^)]*\.probe, "target", if \(is defined\([^)]*\) and [^)]* != null\) then [^)]* else null\)/.test(r.bpmn),
918
+ "an unresolved upstream fact binds null (provenance), not the authored fact-ref literal",
919
+ );
920
+ assert(!/else [a-zA-Z0-9_.]*\.target\)/.test(r.bpmn), "the else-branch never falls back to the authored fact-ref target literal");
921
+ });
922
+
582
923
  test("a wait node whose fact-reference target is PADDED still late-binds via context put (target is trimmed before the bind match)", async () => {
583
924
  // Regression (#778 review, thread deliveryGraphCompiler.ts:1476): `parseProbe` trims `wait.target`
584
925
  // before the worker uses it, and the display/digest render the trimmed form — but the compiler's
@@ -925,10 +1266,13 @@ test("#731 required-emit gate: a producer's declared emit consumed as a required
925
1266
  assert(g0![1].includes("(is defined(pr) and pr != null)"), "a required-emit non-null clause gates the success flow on the populated fact");
926
1267
  const esc = escBlockForNodeSuffix(r.bpmn, "open", "contract");
927
1268
  assert(esc.includes("Required emit &apos;pr&apos;"), "the escalation NAMES the required fact that was not emitted");
928
- // Resumable (#514 Defect-B mirror): a human/agent supplies the missing fact, mapped onto the agent
929
- // emit source var (fact name), so the subProcess output ioMapping republishes `<el>_pr` non-null.
1269
+ // Resumable (#514 Defect-B mirror): a human/agent supplies the missing fact, VALIDATED on the task's
1270
+ // own output mapping (binding the agent emit source var — the fact name — only on a type-valid value)
1271
+ // so the subProcess output ioMapping republishes `<el>_pr` non-null (PR #876 review — a gateway
1272
+ // ioMapping is not visible downstream in the pinned engine, so the validation runs on the task).
930
1273
  assert(esc.includes('="typed"') && esc.includes('target="emitMode"'), "the contract escalation PRESENTS its value field (resumable)");
931
- assert(/source="=if \(is defined\(value\)\) then value else null" target="pr"/.test(esc), "the operator's value maps onto the required emit's source var");
1274
+ assert(/<zeebe:output [\s\S]*?target="pr" \/>/.test(esc) && esc.includes("matches("), "the operator's value is type-validated onto the required emit's source var");
1275
+ assert(/<zeebe:output [\s\S]*?target="[^"]*__resumeValid" \/>/.test(esc), "the contract escalation flags its resume-valid flag for its validation gateway");
932
1276
  });
933
1277
 
934
1278
  test("#731 routing-only emits stay optional: a fact referenced ONLY by an edge `when` guard is NOT gated as a required emit (omit ⇒ default branch)", async () => {