@nanobpm/nano-workforce 0.200.9 → 0.201.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/SPEC.md +188 -0
  3. package/app/agentCompletion.test.ts +52 -0
  4. package/app/agentCompletion.ts +20 -5
  5. package/app/contracts.ts +9 -0
  6. package/app/deliveryGraph.test.ts +261 -0
  7. package/app/deliveryGraph.ts +334 -1
  8. package/app/deliveryGraphCompiler.test.ts +1698 -39
  9. package/app/deliveryGraphCompiler.ts +1047 -149
  10. package/app/deliveryGraphDeploy.test.ts +472 -3
  11. package/app/deliveryGraphRun.ts +2 -2
  12. package/app/deliveryHuman.test.ts +59 -5
  13. package/app/deliveryHuman.ts +115 -3
  14. package/app/deliveryRunner.test.ts +3 -3
  15. package/app/deliveryRunner.ts +14 -5
  16. package/app/pullRequestReadModel.test.ts +1 -1
  17. package/app/redactText.ts +88 -0
  18. package/app/roundChurn.test.ts +388 -0
  19. package/app/roundChurn.ts +265 -0
  20. package/app/roundProgress.test.ts +224 -1
  21. package/app/service.test.ts +7 -0
  22. package/app/service.ts +28 -2
  23. package/db/migrations/117_pull_requests_churn_escalated_round.sql +12 -0
  24. package/db/migrations/118_pull_requests_read_model_churn_escalated_round.sql +55 -0
  25. package/docs/agent-guide.md +10 -7
  26. package/e2e/delivery-graph-dispatch.e2e.ts +2 -1
  27. package/e2e/delivery-graph-node-scope.e2e.ts +358 -0
  28. package/e2e/delivery-graph.e2e.ts +580 -7
  29. package/package.json +1 -1
  30. package/resources/forms/delivery-escalation.form +41 -0
  31. package/resources/forms/delivery-human-generic.form +4 -3
  32. package/workers/progress-check/worker.ts +98 -1
  33. package/workers/readiness-probe/worker.test.ts +72 -1
  34. package/workers/readiness-probe/worker.ts +39 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [0.201.1](https://github.com/nanobpm/nano-workforce/compare/v0.201.0...v0.201.1) (2026-10-07)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **delivery-graph:** actionable node escalations — node-local agent results, retry-node, inputs preflight ([#863](https://github.com/nanobpm/nano-workforce/issues/863)) ([50f7595](https://github.com/nanobpm/nano-workforce/commit/50f7595f3156b2e1e9d52e30e87d3e1c22951dc8)), closes [nano-bpm#1335](https://github.com/nanobpm/nano-bpm/issues/1335) [nano-bpm#1334](https://github.com/nanobpm/nano-bpm/issues/1334) [nano-bpm#1336](https://github.com/nanobpm/nano-bpm/issues/1336) [nanobpm/nano-workforce#866](https://github.com/nanobpm/nano-workforce/issues/866) [nano-bpm#1336](https://github.com/nanobpm/nano-bpm/issues/1336) [nano-bpm#1334](https://github.com/nanobpm/nano-bpm/issues/1334) [nano-bpm#946](https://github.com/nanobpm/nano-bpm/issues/946) [#543](https://github.com/nanobpm/nano-workforce/issues/543) [#866](https://github.com/nanobpm/nano-workforce/issues/866) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#772](https://github.com/nanobpm/nano-workforce/issues/772) [#772](https://github.com/nanobpm/nano-workforce/issues/772) [#461](https://github.com/nanobpm/nano-workforce/issues/461) [#876](https://github.com/nanobpm/nano-workforce/issues/876)
6
+
7
+ ## [0.201.0](https://github.com/nanobpm/nano-workforce/compare/v0.200.9...v0.201.0) (2026-10-06)
8
+
9
+ ### Features
10
+
11
+ * **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))
12
+
13
+ ### Bug Fixes
14
+
15
+ * **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)
16
+
1
17
  ## [0.200.9](https://github.com/nanobpm/nano-workforce/compare/v0.200.8...v0.200.9) (2026-10-05)
2
18
 
3
19
  ### 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
@@ -1004,6 +1039,159 @@ decision. The target behaviour is an **autonomy ladder**:
1004
1039
  A human escalation is then reserved for its one true case: **two slices that each pass
1005
1040
  but encode incompatible decisions about a shared contract** — a genuine design call.
1006
1041
 
1042
+ ## 13.3 Delivery-graph node escalation — Continue / Retry / reset (PR #863)
1043
+
1044
+ The delivery-graph compiler (`app/deliveryGraphCompiler.ts`, ADR 0005) compiles each
1045
+ `agent`/`connector` node to a `start → serviceTask → end` subProcess guarded by a
1046
+ **bounded `=nodeTimeout` boundary**. On timeout (or, for an `agent`, on a broken
1047
+ producer contract — issue #731) the stalled node parks on a **human-completable
1048
+ escalation user task** (`delivery-human-task__<el>__esc`, and the agent-only
1049
+ `__contract` twin), surfaced in the Tasks inbox and answerable by a human or an agent
1050
+ (ADR 0046). Every such escalation names **two exits** (the `delivery-escalation` form's
1051
+ `decision` select):
1052
+
1053
+ - **Continue** (`decision="continue"`, the default) completes the node as resolved. If
1054
+ the node declares **exactly one** emit **and that emit is a downstream-required fact**,
1055
+ the form presents a typed-value field and maps
1056
+ the operator-supplied `value` onto the node's emit-source var (`factSourceVar`), so the
1057
+ subProcess output publishes the same `<el>_<fact>` a normal completion would —
1058
+ letting work finished out of band (a draft PR the stalled agent already opened) be
1059
+ handed onward instead of threading a null downstream. This applies to **both** `agent`
1060
+ and `connector` emits: a connector has no producer-contract gate, so its resume keys
1061
+ off the node's **own** declared `emits` (emit source = the fact's own name), never the
1062
+ agent-only gate metadata. The single-emit boundary is a hard cardinality rule: the
1063
+ form captures ONE value, so a node declaring **zero** emits has nothing to resume (the
1064
+ field stays blank) and a node declaring **two or more** emits is **not** value-resumable
1065
+ — one value cannot satisfy multiple distinct typed facts without corrupting them.
1066
+ **The value is also inert for a node with no *required* (downstream-consumed) resume
1067
+ target** — the resume keys off the **required** emit set, not the raw declared set, so a
1068
+ node whose facts nothing downstream reads has no required resume target and its `value` is
1069
+ discarded (there is nothing to publish it to). This covers a node that declares **zero**
1070
+ emits, exactly **one** routing-only/unconsumed emit, or **several** routing-only/unconsumed
1071
+ emits alike: because the required resume set (`resumeEmits`) is empty, **no post-escalation
1072
+ validation gate is compiled at all**, so **Continue simply proceeds past the node to its
1073
+ default (fallback) branch** — there is nothing to re-park (`serviceBodyLines` /
1074
+ `escalationTaskLines` add the `validTarget` gate only when `resumeEmits` is non-empty). The
1075
+ fail-closed validation gate exists **only** for a node that still owes a **required** emit:
1076
+ there, on Continue it **fails closed and loops
1077
+ back to the escalation** (it does NOT write nulls and does NOT advance to a fallback branch)
1078
+ — **Retry this step** is the only way to actually produce the facts, except that a node
1079
+ declaring exactly **one** emit — that single declared emit being its only required resume
1080
+ target — may instead satisfy it by entering the coerced `value` (the value field stays inert
1081
+ for a node declaring two or more emits even when only one is required, matching
1082
+ `declaredEmits.length === 1 && emits.length === 1` in the compiler).
1083
+ (A **wait** gate owing **two or more**
1084
+ emits is a special case: it has no Retry exit and its single value field can resume
1085
+ none of them, so its escalation grows **no** validation gate at all and Continue simply
1086
+ acknowledges and proceeds to the node end — see issue #863 — rather than looping forever.)
1087
+ Because the form
1088
+ captures `value` as textfield **text**, the single-emit resume **coerces/validates it to
1089
+ the emit's declared type** before publishing (`escalationResumeValueFeel`): a `boolean`
1090
+ emit accepts `"true"`/`"false"` **case-insensitively and surrounding-whitespace-trimmed**
1091
+ (the validity gate and the coercion share one `lower case(trim(string(value)))`
1092
+ normalization, so a value one accepts the other can never convert differently) and
1093
+ publishes a real boolean, a `number` emit parses via `number(trim(string(value)))`, and
1094
+ any other (text-valued) type passes through. An **invalid** entry
1095
+ publishes `null` — the defined failure path — so a required-emit producer gate escalates
1096
+ and a guarded split takes its default rather than routing on a mistyped value (a raw
1097
+ `"true"` string would silently skip a `= true` guard).
1098
+ - **Retry this step** (`decision="retry"`) re-runs the node. A none intermediate throw
1099
+ event (never a scriptTask) resets the node-local scratch — the decision, the captured
1100
+ `value`/`escalationNote`, and (for an agent) the **full declared result set**
1101
+ (`AGENT_RESULT_LOCAL_VARS` — the self-reported `status`/`summary`/… **and** every other
1102
+ node-local result field such as `transcriptUrl`, `agentCheckpoint`, and the PR aliases)
1103
+ — or, **for a connector**, its fixed result metadata (`CONNECTOR_RESULT_LOCAL_VARS` —
1104
+ `connectorOutcome`/`connectorDedupeKey`/`connectorDetail`) — plus the previous attempt's
1105
+ emits — and appends the operator's `escalationNote` to the agent
1106
+ prompt as guidance for the next attempt (re-derived from the runner-seeded
1107
+ `nodeInputs.<el>.appendPrompt` baseline, so consecutive retries never accumulate stale
1108
+ guidance). The guidance control is the escalation-specific `escalationNote`, never the
1109
+ plain `note`: `note` is a worker **result** field (`plan.md` returns one) that
1110
+ nearest-scope propagation lands in the same subProcess scope, so reading it would feed a
1111
+ worker-produced note back as "operator guidance" on a retry submitted without one. Clearing
1112
+ the whole declared set (not just the status fields) stops a retried
1113
+ worker that omits an optional field from republishing the previous attempt's value
1114
+ downstream or surfacing it in the next escalation.
1115
+
1116
+ All retry/reset targets are **node-local** (declared on the subProcess by
1117
+ `ioMappingLines`), so a node's result vars never leak to the root and two parallel nodes
1118
+ declaring the same emit never cross-publish — with a few deliberate publish-onward
1119
+ exceptions, all written under node-unique or intentionally-shared names rather than the
1120
+ raw result var. The **outward data** exceptions: each declared emit is republished to the
1121
+ root as `<el>_<fact>` (node-unique, so siblings never collide), and an agent's
1122
+ `transcriptUrl` is propagated back to the root under the shared `transcriptUrl` name so
1123
+ Nano Explorer can render the run→transcript link (app/deliveryGraphCompiler.ts:1872-1877).
1124
+ That one shared name means parallel agents can overwrite the root `transcriptUrl` —
1125
+ acceptable, since it is a display-only correlation link, not a result a downstream node
1126
+ binds. In addition, the per-node **control signals** `<el>_contractMet` (the agent
1127
+ contract-gate's proceed flag) and `<el>_retryRequested` (the per-escalation retry gate's
1128
+ route flag) are **also root-scoped** — their gateways live outside the node's subProcess
1129
+ and so cannot read node scope, and the `<el>_`-prefixed, node-unique names can never
1130
+ collide across parallel nodes. So the node-local rule governs a node's *result/emit scratch*;
1131
+ these node-unique control booleans are intentionally published to root so the routing
1132
+ gateways can read them on either engine's scope semantics. A **preflight `assert`** on the inner leaf
1133
+ task's input fails LOUD (raising an incident naming the missing `nodeInputs.<el>`)
1134
+ before any job exists when the runner-seeded config was lost — see the `KNOWN
1135
+ LIMITATION` in `serviceBodyLines` (fail-loud-only, nano-workforce#866). Resolving that
1136
+ incident re-evaluates **only the leaf's inputs** — the subProcess-level config mappings
1137
+ (`prompt`/`appendPrompt`/`nodeTimeout`, connector `target`/`payload`/`dedupeKey`) ran once
1138
+ at subProcess entry and are **not** re-mapped on a leaf resolve, so the node would
1139
+ activate **unconfigured**. The in-subprocess **"Retry this step"** loop does **not**
1140
+ help here either: it loops directly back to the inner service task (to reset the
1141
+ node-local scratch), bypassing the subProcess entry mappings, so it too leaves the
1142
+ config null. **Recovery therefore requires re-entering the sub-process from OUTSIDE —
1143
+ relaunch/re-enter the node so its subProcess input mappings re-evaluate against the
1144
+ restored `nodeInputs`** — not merely resolving the incident or using "Retry this step".
1145
+ The correct in-model fix is to run this check at sub-process *entry* (Camunda parity,
1146
+ nano-bpm#1336); until then the preflight fails loud so the node never runs blind
1147
+ *without* an incident, and the operator re-enters the sub-process to recover.
1148
+
1149
+ Because an `agent`/`connector` node's emit source **is the fact's own name**, declared
1150
+ node-local in the same subProcess scope as the escalation form controls and the node's
1151
+ seeded config, a fact name is not fully unrestricted: `validateDeliveryGraph` rejects an
1152
+ emit named after a **reserved delivery variable** (`reservedDeliveryFactNames(kind)`,
1153
+ app/deliveryGraph.ts) fail-closed at authoring time. The reserved set is **kind-aware**
1154
+ (issue #863 review): the **escalation controls** (`decision`/`value`/`escalationNote` — an escalation
1155
+ Continue would overwrite the fact, publishing `<el>_decision="continue"` instead of the
1156
+ agent's routing value) and the shared late-binding/preflight **scaffolding**
1157
+ (`boundFacts`/`nodeInputs`/`nodeInputsPresent`) are reserved for **every** kind, while a
1158
+ **config variable** is reserved only for the kind whose own config it is — and only for
1159
+ `agent`/`connector`, the kinds whose emit source is the fact's own name (the retry reset's
1160
+ clear-the-emits pass would null the node's own configuration, so the retried node activates
1161
+ unconfigured). A `wait`/`human` emit's source is a fixed intermediate (`detail`/
1162
+ `humanEmitValue`/…), never the fact's own name, so those kinds reserve only the escalation
1163
+ controls + scaffolding — a `wait` emitting `target` or a `human` emitting `prompt` is
1164
+ allowed. The node-local **result** fields
1165
+ (`AGENT_RESULT_LOCAL_VARS`/`CONNECTOR_RESULT_LOCAL_VARS`) are deliberately **not**
1166
+ reserved — with **one exception**: an agent emitting `pr` (the canonical
1167
+ `agent → connector[converge] → wait[pr]` shape) writes the same node-local value the
1168
+ result field holds, and the retry reset correctly clears both — reserving them would
1169
+ forbid that flagship pattern. The exception is the agent completion-control **`status`**:
1170
+ the producer status gate reads that same node-local field as the node's completion status
1171
+ and accepts only `done`/`opened`/`skipped`, so an agent emit named `status` carrying any
1172
+ other routing value (`approved`, …) always escalates, and `status="done"` can never take an
1173
+ `approved` branch — the emit contract and the gate conflict. `status` is therefore reserved
1174
+ for an **`agent`** (a connector has no producer status gate, so a connector `status` emit
1175
+ stays allowed); the rest of the result set remains unreserved.
1176
+
1177
+ A **`human` node's required emit is gated fail-closed on completion** (PR #863 review,
1178
+ thread r4198662345). A human node has no worker self-report to producer-gate on, but its
1179
+ single emit can be a downstream-**required** fact — and the captured value is coerced and
1180
+ validated to the emit's declared type (`coerceFactValueFeel`), so an invalid entry becomes
1181
+ `null`. Without a completion gate the node ended and the unconditional edge activated the
1182
+ consumer with a `null` required fact. The compiler therefore grows a **completion gate**
1183
+ when the node's single emit is downstream-required: the user task's own output mapping
1184
+ computes a node-unique validity flag (`resumeValidVar`) from the *raw* captured field via
1185
+ the **same** per-type `resumeValueCondition` grammar the escalation resume enforces
1186
+ (presence included, so a blank required entry is invalid), and a post-task exclusive
1187
+ gateway routes a **valid** completion to the node end while an **invalid** one loops back
1188
+ to the human task for re-entry — the human analogue of the escalation resume's fail-closed
1189
+ re-park (a human node has no separate escalation twin to re-park onto). The gate is grown
1190
+ **only** when the single emit is required: a routing-only/unconsumed emit keeps the direct
1191
+ `task → end` flow (its `null` simply takes a guarded split's deadlock-safe default, exactly
1192
+ like the escalation resume's no-required-target case), and a no-emit acknowledgement node
1193
+ has nothing to validate.
1194
+
1007
1195
  ## 14. Open questions / future
1008
1196
 
1009
1197
  - **Provisioning the existing PR branch** — resolved: the `c8ctl` host-git
@@ -18,6 +18,7 @@ import {
18
18
  completeEscalationAsAgent,
19
19
  completeEscalationAsHuman,
20
20
  completeUserTaskAttributed,
21
+ escalationFormId,
21
22
  latestCompletion,
22
23
  revertAgentCompletion,
23
24
  type TaskCompletion,
@@ -884,6 +885,57 @@ test("validateEscalationVariables derives its contract from the canonical .form
884
885
  assertEquals(validateEscalationVariables("some-other-task", { whatever: 1 }), null);
885
886
  });
886
887
 
888
+ test("validateEscalationVariables enforces the `decision` select on a delivery-graph service-escalation twin (retry-node)", async () => {
889
+ // A delivery-graph service-node escalation twin (`…__esc`/`…__contract`) ALWAYS renders the fixed
890
+ // `delivery-escalation` form, whose `decision` select is {continue, retry}. The canonical completers
891
+ // must therefore reject a present-but-invalid `decision` (e.g. an agent/direct-API typo `retrry`),
892
+ // instead of silently computing `retryRequested=false` and taking the deadlock-safe Continue default
893
+ // — which would skip the node (PR #863 review — MEDIUM "Validate decision values" advisory).
894
+ const escTwin = "delivery-human-task__n1__esc";
895
+ const contractTwin = "delivery-human-task__n2__contract";
896
+ // present-but-invalid decision → rejected on BOTH twin kinds
897
+ assert(validateEscalationVariables(escTwin, { decision: "retrry" }) !== null, "invalid decision rejected on __esc twin");
898
+ assert(validateEscalationVariables(contractTwin, { decision: "nope" }) !== null, "invalid decision rejected on __contract twin");
899
+ // valid decisions pass
900
+ assertEquals(validateEscalationVariables(escTwin, { decision: "retry" }), null);
901
+ assertEquals(validateEscalationVariables(escTwin, { decision: "continue" }), null);
902
+ // decision OMITTED → still valid (defaults to Continue); the generic-form wait-escalation twins, which
903
+ // never submit `decision`, must not be false-rejected (the form has no required fields).
904
+ assertEquals(validateEscalationVariables(escTwin, { value: "something", escalationNote: "n" }), null);
905
+ // the bare per-node human task (NOT a twin) stays unenforced — it renders a different form per node.
906
+ assertEquals(validateEscalationVariables("delivery-human-task__n1", { decision: "retrry" }), null);
907
+ // a node literally named `esc` has base id `delivery-human-task__esc` — NOT misread as a twin.
908
+ assertEquals(validateEscalationVariables("delivery-human-task__esc", { decision: "retrry" }), null);
909
+ });
910
+
911
+ test("escalationFormId derives the COMPILED form per twin kind — wait-gate twins get the generic form, not the retry form (PR #863 review)", () => {
912
+ // Regression guard (Copilot thread app/agentCompletion.ts:186): the bare `__esc` suffix is shared by a
913
+ // service-node timeout twin (retry-capable → `delivery-escalation`) AND a `wait`-gate twin (no retry
914
+ // path → `delivery-human-generic`). Inferring one form from the suffix misclassified the wait gate, so
915
+ // the #461 Tasks-inbox `form_key` fallback rendered a bogus "Retry this step" the wait process silently
916
+ // ignores. The compiler now stamps a `__wait` kind marker into the wait twin's id so the form is
917
+ // DERIVED from the id.
918
+ // service-node twins → retry-capable escalation form
919
+ assertEquals(escalationFormId("delivery-human-task__n1__esc"), "delivery-escalation");
920
+ assertEquals(escalationFormId("delivery-human-task__n2__contract"), "delivery-escalation");
921
+ // wait-gate twin (kind marker) → select-less generic form
922
+ assertEquals(escalationFormId("delivery-human-task__gate__esc__wait"), "delivery-human-generic");
923
+ // a bare per-node human task (variable form per node) has no static contract
924
+ assertEquals(escalationFormId("delivery-human-task__n1"), undefined);
925
+ // a node literally named `esc`/`esc__wait` is NOT misread as a twin (needs a non-empty base before the suffix)
926
+ assertEquals(escalationFormId("delivery-human-task__esc"), undefined);
927
+ assertEquals(escalationFormId("delivery-human-task__esc__wait"), undefined);
928
+ });
929
+
930
+ test("validateEscalationVariables: a wait-gate twin is validated against the generic form (no `decision` select to enforce)", () => {
931
+ // The wait-gate twin renders the select-less generic form, so a `decision` value is out of contract
932
+ // there — but the generic form declares no `decision` allowed-set, so the completer does not enforce
933
+ // one (the wait process ignores `decision` entirely; there is no retry path to guard).
934
+ const waitTwin = "delivery-human-task__gate__esc__wait";
935
+ assertEquals(validateEscalationVariables(waitTwin, { value: "pkg@1.2.3" }), null, "a value-only wait-gate resume is valid");
936
+ assertEquals(validateEscalationVariables(waitTwin, {}), null, "the generic form has no required fields");
937
+ });
938
+
887
939
  test("feature-escalation demands non-blank answer on the answer path, but not on the hidden abandon path", async () => {
888
940
  // resolution=answer shows the conditional `answer` field, which is required → blank/missing rejected.
889
941
  assert(
@@ -24,7 +24,7 @@ import { readFileSync } from "node:fs";
24
24
  import type { DataLayer, EngineClient, GatewayDataSource } from "@nanobpm/urban";
25
25
  import { invalidateAdjudication, invalidateAdjudicationByCompletion } from "./adjudications.ts";
26
26
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
27
- import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
27
+ import { DELIVERY_HUMAN_ELEMENT, ESCALATION_FORM, GENERIC_HUMAN_FORM, isDeliveryEscalationTwin, isDeliveryHumanElement, isDeliveryWaitEscalationTwin } from "./deliveryHuman.ts";
28
28
  import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT, PR_MERGE_APPROVAL_ELEMENT, READINESS_ESCALATION_ELEMENT, READINESS_ESCALATION_PF_ELEMENT } from "./userTasks.ts";
29
29
 
30
30
  const now = () => new Date().toISOString();
@@ -165,17 +165,32 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
165
165
  // contract is enforced instead by `bindHumanEmits` against the node's declared `emits[]` (binds are
166
166
  // validated, not stringly — Decision 3/4), so `validateEscalationVariables` leaves it unenforced
167
167
  // (returns `null`) and the completer accepts the captured form variables for the emit binder to type.
168
+ // (Its SERVICE-node escalation TWINS — `…__esc`/`…__contract` — are different: they render a FIXED
169
+ // form, so `escalationFormId` maps them to `ESCALATION_FORM` via `isDeliveryEscalationTwin` rather
170
+ // than listing each per-node id here.)
168
171
  };
169
172
 
170
173
  /** The fixed `.form` linkage (a `zeebe:formDefinition formId`) that governs a fixed-form escalation
171
- * kind's completion, or `undefined` for a kind with no static form (the delivery-graph `human` node,
172
- * which renders DIFFERENT forms per node — its `formKey` only ever comes from the engine at runtime).
174
+ * kind's completion, or `undefined` for a kind with no static form (the per-node delivery-graph `human`
175
+ * task, which renders DIFFERENT forms per node — its `formKey` only ever comes from the engine at
176
+ * runtime). A delivery-graph SERVICE-node escalation TWIN (`…__esc`/`…__contract`), by contrast, ALWAYS
177
+ * renders the fixed {@link ESCALATION_FORM} — a strict superset of the generic form — so it DOES carry a
178
+ * static contract here (via {@link isDeliveryEscalationTwin}): this is what lets the canonical completers
179
+ * reject a present-but-invalid `decision` (continue/retry) on a retry-node escalation, while leaving the
180
+ * select-less generic-form twins unaffected (the form has no required fields, so they never false-reject).
173
181
  * Exposed so the Tasks-inbox poller can denormalise a row's `form_key` from this SAME single source of
174
182
  * truth when the raw `/v2/user-tasks/search` result omits the engine-resolved key (issue #461) — the
175
183
  * REST gateway addresses a deployed form by value whether that value is a deploy key or an authored
176
184
  * form id, so this id resolves the same deployed `.form` the completer validates against. */
177
185
  export function escalationFormId(elementId: string): string | undefined {
178
- return ESCALATION_FORM_BY_ELEMENT[elementId];
186
+ // Derive the twin's COMPILED form from its id rather than inferring one form from the shared `__esc`
187
+ // suffix: a `wait`-gate twin (`…__esc__wait`, kind marker stamped by the compiler) renders the
188
+ // select-less GENERIC_HUMAN_FORM (no retry path); a service-node `__esc`/`__contract` twin renders the
189
+ // retry-capable ESCALATION_FORM. Mapping the wait twin to ESCALATION_FORM would denormalise a bogus
190
+ // "Retry this step" onto its Tasks row (the wait process silently ignores `decision`) whenever the
191
+ // engine omits `formKey` (PR #863 review, app/agentCompletion.ts:186).
192
+ if (isDeliveryWaitEscalationTwin(elementId)) return GENERIC_HUMAN_FORM;
193
+ return ESCALATION_FORM_BY_ELEMENT[elementId] ?? (isDeliveryEscalationTwin(elementId) ? ESCALATION_FORM : undefined);
179
194
  }
180
195
 
181
196
  /** A field's `conditional.hide` rule, parsed from the FEEL subset the `.form` files use
@@ -261,7 +276,7 @@ export function validateEscalationVariables(
261
276
  elementId: string,
262
277
  variables: Record<string, unknown>,
263
278
  ): string | null {
264
- const formId = ESCALATION_FORM_BY_ELEMENT[elementId];
279
+ const formId = escalationFormId(elementId);
265
280
  if (!formId) return null;
266
281
  const { required, allowed, hideWhen } = formContract(formId);
267
282
  for (const key of required) {
package/app/contracts.ts CHANGED
@@ -559,6 +559,15 @@ export const WIRE_CONTRACTS = {
559
559
  "The engine-native AgentInstance/AgentHistory READ path (issue #745/#747, umbrella #746 — the CONSUMER half of the PRODUCER `agentTask.agentDefinition` marker). The Cockpit HISTORICAL transcript + per-turn/instance metrics are sourced from the engine read model through the SINGLE engine-read seam — `@nanobpm/urban`'s `EngineClient.searchAgentInstances`/`searchAgentInstanceHistory`/`getAgentInstance` (added in urban 0.93 / nanobpm/nano-ide#563; the escalation's option (a) — NO `orchestration-cluster-api-js` fork / no second broker-REST client, so the read path is exercised by the testkit WASM double, which records read-as-absence — an empty list / null — while a live engine validates the behavioural parity). ONE narrow reader (`AgentHistoryReader`) + ONE projection (`listAgentInstances`/`readAgentHistory`) in app/agentic/agent-history.ts, served by `GET /agentic/agent-instances` + `GET /agentic/agent-instances/{agentInstanceKey}/history`. Correlation keys are the AGENT-INSTANCE / PROCESS-INSTANCE / ELEMENT-INSTANCE keys (the same #544 per-occupancy element-instance handle the relay correlation uses) — NEVER the slash-bearing `job:<jobKey>` relay stream id, so the #744 gateway-proxy bug class is moot for settled history. The token-granular relay stays the LIVE overlay only (settled history = engine, live tail = relay). Advisory read-only (ADR 0056): it observes the engine read model, never activates/completes a job or gates a sequence flow. The wire shapes (`AgentInstance`/`AgentInstanceList`/`AgentHistoryRecord`/`AgentHistory` + the metrics shapes) are declared in openapi.yaml and reuse the `@nanobpm/agentic/transcript` parity conversation grammar the transcript store already models. Consume this ONE read path — do not add a second agent-history client or a synonym endpoint.",
560
560
  shape: "GET /agentic/agent-instances[?processInstanceKey&rootProcessInstanceKey&elementId&status] → AgentInstanceList; GET /agentic/agent-instances/{agentInstanceKey}/history[?role&loopIteration&elementInstanceKey] → AgentHistory",
561
561
  },
562
+ "deliveryGraph.workerResult": {
563
+ category: "wire",
564
+ name: "deliveryGraph.workerResult",
565
+ owner: "app/deliveryGraph.ts",
566
+ semantics:
567
+ "The delivery-graph worker-completion RESULT envelope (issue #863) — the canonical set of variable names a fleet worker (agent/connector) or an escalation completion returns on a delivery node's job, which the deliveryGraph compiler declares node-LOCAL on the node's subProcess (and clears on retry) so a parallel node never overwrites a sibling's result and a retry never reads a stale one. Three sibling sets, all exported from app/deliveryGraph.ts (the validator's home, so the reserved-fact-name set derives from them without an import cycle): `AGENT_RESULT_LOCAL_VARS` (status/summary/question/output/error/pr/prUrl/pullRequest/branch/commits/exitCode/next_steps/issue/completed/pushed/truncated/agentCheckpoint/transcriptUrl/delta/approved/findings/commentUrl/hasDeviations/slicesMet/slicesReduced/slicesNotVerified/deviationsRaised/deviationsUnraised, plus the prompt-contract outputs dependsOn/adversarialFindings/adversarialSummary/scopeBlocked/scopeBlockReason/tasks/result/conflicts/failing/note), `CONNECTOR_RESULT_LOCAL_VARS` (connectorOutcome/connectorDedupeKey/connectorDetail), and `ESCALATION_LOCAL_VARS` (decision/value/escalationNote — the escalation-completion controls; the retry-guidance note is `escalationNote`, NOT the plain `note`, which is a plan.md WORKER result field that would otherwise be fed back as operator guidance on a note-less retry). These are job-completion outputs and escalation-form variables, NOT message payloads, so the scalar-only message-envelope rule does not apply (array-typed fields like dependsOn/tasks/conflicts/failing are legal here). A new worker/escalation result field MUST be added to the matching set here — never re-declared as a synonym list in the compiler or a worker — or it leaks to the shared root scope.",
568
+ shape:
569
+ "AGENT_RESULT_LOCAL_VARS: readonly string[] (28 base + 10 prompt-contract outputs); CONNECTOR_RESULT_LOCAL_VARS: readonly ['connectorOutcome','connectorDedupeKey','connectorDetail']; ESCALATION_LOCAL_VARS: readonly ['decision','value','escalationNote']",
570
+ },
562
571
  } as const satisfies Record<string, WireContract>;
563
572
 
564
573
  export const TYPE_CONTRACTS = {