@nanobpm/nano-workforce 0.138.0 → 0.138.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.138.1](https://github.com/nanobpm/nano-workforce/compare/v0.138.0...v0.138.1) (2026-08-24)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **delivery-graph:** self-diagnosing + resumable capability wait-gate escalation ([#514](https://github.com/nanobpm/nano-workforce/issues/514)) ([#515](https://github.com/nanobpm/nano-workforce/issues/515)) ([b13ae50](https://github.com/nanobpm/nano-workforce/commit/b13ae50bf9638a5eabd12342e9c3d1ba7d70eaab)), closes [#499](https://github.com/nanobpm/nano-workforce/issues/499)
6
+
1
7
  ## [0.138.0](https://github.com/nanobpm/nano-workforce/compare/v0.137.0...v0.138.0) (2026-08-24)
2
8
 
3
9
  ### Features
@@ -160,6 +160,99 @@ test("#499 escalation context: an agent-node timeout escalation seeds a context
160
160
  assert(escBlock.includes('="none"') && escBlock.includes('target="emitMode"'), "the escalation labels its emit field N/A so the generic form hides the inert value input");
161
161
  });
162
162
 
163
+ // The #514 motivating case: a `capability` wait gate that emits a version, escalating on a
164
+ // false-negative. Its escalation must be self-diagnosing (Defect A) AND resumable with its emit
165
+ // (Defect B). Two emit shapes (`version` → `detail`, `artifact` → `resolvedArtifact`) exercise the
166
+ // per-emit-type mapping.
167
+ const CAP_GATE = {
168
+ name: "capability gate",
169
+ nodes: [
170
+ { id: "gv", kind: "agent", agent: { jobType: "senior:feature", prompt: "ship the rollup" } },
171
+ {
172
+ id: "n2",
173
+ kind: "wait",
174
+ wait: {
175
+ kind: "capability",
176
+ target: "github-releases:nanobpm/nano-ide",
177
+ match: { package: "@nanobpm/urban", capabilityRef: "#468" },
178
+ onTimeout: "escalate",
179
+ },
180
+ emits: [{ name: "publishedVersion", type: "version" }],
181
+ },
182
+ {
183
+ id: "n3",
184
+ kind: "wait",
185
+ wait: {
186
+ kind: "capability",
187
+ target: "github-releases:nanobpm/nano-ide",
188
+ match: { package: "@nanobpm/urban", capabilityRef: "#469" },
189
+ onTimeout: "escalate",
190
+ },
191
+ emits: [{ name: "artifactRef", type: "artifact" }],
192
+ },
193
+ { id: "sink", kind: "connector", connector: { target: "npm:install", dedupeKey: "c1" } },
194
+ ],
195
+ edges: [
196
+ { from: "gv", to: "n2" },
197
+ { from: "n2.publishedVersion", to: "n3" },
198
+ { from: "n3.artifactRef", to: "sink" },
199
+ ],
200
+ };
201
+
202
+ /** The subProcess element id the compiler assigned to a node (elements are positional `n<k>`, not the
203
+ * node id). Located via the subProcess `name="<kind>: <nodeId>"`. */
204
+ function elementForNode(bpmn: string, nodeId: string): string {
205
+ const m = bpmn.match(new RegExp(`<bpmn:subProcess id="([^"]+)" name="[^"]*: ${nodeId}"`));
206
+ assert(m, `a subProcess for node ${nodeId} exists`);
207
+ return m![1];
208
+ }
209
+
210
+ /** Slice a compiled BPMN to a node's escalation user task body. */
211
+ function escBlockForNode(bpmn: string, nodeId: string): string {
212
+ const esc = `delivery-human-task__${elementForNode(bpmn, nodeId)}__esc`;
213
+ const start = bpmn.indexOf(`<bpmn:userTask id="${esc}"`);
214
+ assert(start !== -1, `escalation task ${esc} for node ${nodeId} exists`);
215
+ return bpmn.slice(start, bpmn.indexOf("</bpmn:userTask>", start));
216
+ }
217
+
218
+ test("#514 Defect A: a capability wait-gate escalation surfaces the probe's last detail, target/match, and observed releases so it is self-diagnosing", async () => {
219
+ const r = await compileOk(CAP_GATE);
220
+ const esc = escBlockForNode(r.bpmn, "n2");
221
+ // The read-only prompt context now folds in the RUNTIME last probe detail + observed candidate summary.
222
+ assert(esc.includes('target="prompt"'), "the escalation seeds a prompt context line");
223
+ assert(esc.includes("Last probe: ") && esc.includes("if (is defined(detail)) then string(detail)"), "the prompt folds in the last probe detail");
224
+ assert(esc.includes("Observed: ") && esc.includes("if (is defined(observed)) then string(observed)"), "the prompt folds in the observed candidate summary");
225
+ // Discrete diagnostic task variables the form/agent can bind directly.
226
+ assert(esc.includes('target="probeDetail"'), "the escalation surfaces the probe's last detail as a discrete variable");
227
+ assert(esc.includes('target="observedReleases"'), "the escalation surfaces the observed candidate releases");
228
+ assert(/source="=nodeInputs\.[^"]+\.probe\.target" target="probeTarget"/.test(esc), "the escalation surfaces the resolved probe target");
229
+ assert(/source="=nodeInputs\.[^"]+\.probe\.match" target="probeMatch"/.test(esc), "the escalation surfaces the resolved probe match");
230
+ });
231
+
232
+ 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 () => {
233
+ const r = await compileOk(CAP_GATE);
234
+ // Red before the fix: the wait escalation forced emitMode="none" (hiding the value field) and carried
235
+ // NO output mapping, so a resume published `<el>_<fact> = null`, starving the downstream consumer.
236
+ const escV = escBlockForNode(r.bpmn, "n2");
237
+ // A `version` emit is sourced from `detail` — the operator's captured `value` must be mapped there.
238
+ assert(escV.includes('="typed"') && escV.includes('target="emitMode"'), "a wait node with emits PRESENTS its value field on escalation, not 'none'");
239
+ assert(escV.includes("publishedVersion (version)") && escV.includes('target="emitLabel"'), "the emit label names the awaited fact");
240
+ 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)");
241
+
242
+ const escA = escBlockForNode(r.bpmn, "n3");
243
+ // An `artifact` emit is ALSO sourced from the generic form's single `value` field (the form has no
244
+ // `resolvedArtifact` field), mapped onto the artifact emit's source var (resolvedArtifact) — so an
245
+ // artifact wait-node escalation is actually resumable via the UI.
246
+ 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)");
247
+ });
248
+
249
+ test("#514 Defect B: a service-node escalation (agent) stays inert — no emit field, no resume output mapping (only wait resumes)", async () => {
250
+ const r = await compileOk(CAP_GATE);
251
+ const esc = escBlockForNode(r.bpmn, "gv");
252
+ assert(esc.includes('="none"') && esc.includes('target="emitMode"'), "an agent-node escalation keeps its emit field hidden");
253
+ assert(!esc.includes("<bpmn:output") && !esc.includes("<zeebe:output"), "an agent-node escalation carries no emit-source output mapping");
254
+ });
255
+
163
256
 
164
257
  test("rejects unknown kind (by construction) with a path-qualified error, nothing compiled", async () => {
165
258
  const errors = await compileFail({
@@ -882,7 +882,7 @@ function innerBodyLines(w: NodeWiring): string[] {
882
882
  case "connector":
883
883
  return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${node.connector.target}`);
884
884
  case "wait":
885
- return waitBodyLines(el, node.id);
885
+ return waitBodyLines(el, node);
886
886
  case "human":
887
887
  return humanBodyLines(el, node.id);
888
888
  default:
@@ -951,8 +951,19 @@ function serviceBodyLines(
951
951
  /** `wait` body: `start → pr.readiness-probe (poll) → ready? → end`, escalating on not-ready or on the
952
952
  * `=probeTimeout` engine bound. The probe polls its OWN target, so an unrelated upstream event can
953
953
  * never flip it to ready (#274/S2 concurrency-correctness); the `pr` kind (S2) binds `mergedSha`. */
954
- function waitBodyLines(el: string, nodeId: string): string[] {
954
+ function waitBodyLines(el: string, node: DeliveryNode): string[] {
955
+ const nodeId = node.id;
955
956
  const esc = escalationTaskElement(el);
957
+ const emits = normaliseEmits(node);
958
+ // Defect A: read-only probe diagnostics seeded onto the escalation task so the operator/agent can
959
+ // tell a genuine "not published yet" from a transient false-negative — the probe's last detail, the
960
+ // resolved target/match, and a compact summary of the candidate releases the probe observed.
961
+ const diagnosticInputs = [
962
+ { source: "=if (is defined(detail)) then detail else null", target: "probeDetail" },
963
+ { source: "=if (is defined(observed)) then observed else null", target: "observedReleases" },
964
+ { source: `=nodeInputs.${el}.probe.target`, target: "probeTarget" },
965
+ { source: `=nodeInputs.${el}.probe.match`, target: "probeMatch" },
966
+ ];
956
967
  return [
957
968
  ` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
958
969
  ` <bpmn:subProcess id="${el}_probeLoop" name="Probe readiness loop: ${escapeXml(nodeId)}">`,
@@ -962,6 +973,7 @@ function waitBodyLines(el: string, nodeId: string): string[] {
962
973
  ' <zeebe:output source="=if (is defined(detail)) then detail else null" target="detail" />',
963
974
  ' <zeebe:output source="=if (is defined(resolvedArtifact)) then resolvedArtifact else null" target="resolvedArtifact" />',
964
975
  ' <zeebe:output source="=if (is defined(mergedSha)) then mergedSha else null" target="mergedSha" />',
976
+ ' <zeebe:output source="=if (is defined(observed)) then observed else null" target="observed" />',
965
977
  " </zeebe:ioMapping>",
966
978
  " </bpmn:extensionElements>",
967
979
  ` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
@@ -1024,7 +1036,8 @@ function waitBodyLines(el: string, nodeId: string): string[] {
1024
1036
  nodeId,
1025
1037
  [`${el}_i4`],
1026
1038
  `${el}_i5`,
1027
- escalationContextFeel(nodeId, "readiness gate", "probeTimeout", " before its ReadinessProbe went green — decide how to proceed."),
1039
+ waitEscalationContextFeel(nodeId),
1040
+ { resume: { kind: node.kind, emits }, diagnosticInputs },
1028
1041
  ),
1029
1042
  ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i5</bpmn:incoming><bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
1030
1043
  flow(`${el}_i0`, `${el}_start`, `${el}_probeLoop`),
@@ -1084,15 +1097,60 @@ function humanBodyLines(el: string, nodeId: string): string[] {
1084
1097
  * convention) that a human OR an agent (ADR 0046) answers to unstick a stalled node. `contextFeel` is
1085
1098
  * a FEEL expression yielding the context line seeded onto the generic form's read-only prompt field
1086
1099
  * (issue #499) — e.g. "Node n1 (senior:feature) exceeded its SLA (PT30M); …" — so the operator can see
1087
- * WHICH node timed out and that in-flight work may already exist, instead of a blank form. The emit
1088
- * field is labelled "none" so the generic form hides its (inert on an escalation) typed-value input. */
1100
+ * WHICH node timed out and that in-flight work may already exist, instead of a blank form.
1101
+ *
1102
+ * `opts.resume` turns an inert escalation into a RESUMABLE one (issue #514 Defect B): when the parked
1103
+ * `wait` node declares emits, the form must both PRESENT its typed-value field (so `emitMode`/
1104
+ * `emitLabel` are derived from those emits, not forced to "none") and, on completion, MAP the
1105
+ * operator-supplied value onto the node's emit-source variable (`detail` for scalar/version,
1106
+ * `resolvedArtifact` for artifact, `mergedSha` for a merge oid — {@link factSourceVar}). Without that
1107
+ * mapping a naive resume publishes `<el>_<fact> = null`, silently starving the downstream consumer.
1108
+ * `opts.diagnosticInputs` seeds read-only probe context (issue #514 Defect A) onto the same task so the
1109
+ * operator can see WHY the gate escalated (its last probe detail + observed candidate releases). */
1089
1110
  function escalationTaskLines(
1090
1111
  esc: string,
1091
1112
  nodeId: string,
1092
1113
  incoming: readonly string[],
1093
1114
  outgoing: string,
1094
1115
  contextFeel: string,
1116
+ opts?: {
1117
+ resume?: { kind: DeliveryNode["kind"]; emits: readonly DeliveryFact[] };
1118
+ diagnosticInputs?: readonly { source: string; target: string }[];
1119
+ },
1095
1120
  ): string[] {
1121
+ const emits = opts?.resume?.emits ?? [];
1122
+ const emitMode = emits.length > 0 ? "typed" : "none";
1123
+ const emitLabel = emits.map((e) => `${e.name} (${e.type})`).join(", ");
1124
+ const inputs: string[] = [
1125
+ ` <zeebe:input ${attr("source", contextFeel)} target="prompt" />`,
1126
+ ` <zeebe:input ${attr("source", `=${feelStr(nodeId)}`)} target="nodeId" />`,
1127
+ ` <zeebe:input ${attr("source", `=${feelStr(emitMode)}`)} target="emitMode" />`,
1128
+ ];
1129
+ if (emits.length > 0) {
1130
+ inputs.push(` <zeebe:input ${attr("source", `=${feelStr(emitLabel)}`)} target="emitLabel" />`);
1131
+ }
1132
+ for (const di of opts?.diagnosticInputs ?? []) {
1133
+ inputs.push(` <zeebe:input ${attr("source", di.source)} target="${di.target}" />`);
1134
+ }
1135
+ // Defect B: map the operator's captured typed value onto the node's emit-source var, so the
1136
+ // subProcess output ioMapping publishes the SAME `<el>_<fact>` shape a normally-completing node does.
1137
+ const outputs: string[] = [];
1138
+ if (opts?.resume) {
1139
+ const seen = new Set<string>();
1140
+ for (const fact of emits) {
1141
+ const target = factSourceVar(opts.resume.kind, fact);
1142
+ if (seen.has(target)) continue;
1143
+ seen.add(target);
1144
+ // The generic escalation form (`GENERIC_HUMAN_FORM`) captures the operator's answer in a single
1145
+ // `value` field — it has NO `resolvedArtifact` field — so every emit type resumes from `value`,
1146
+ // mapped onto that fact's emit-source var (artifact→resolvedArtifact, version→detail, …). Sourcing
1147
+ // an artifact from a `resolvedArtifact` form field the form never sets would publish null and make
1148
+ // an artifact wait-node escalation non-resumable via the UI.
1149
+ outputs.push(
1150
+ ` <zeebe:output ${attr("source", `=if (is defined(value)) then value else null`)} target="${target}" />`,
1151
+ );
1152
+ }
1153
+ }
1096
1154
  return [
1097
1155
  ` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(nodeId)}">`,
1098
1156
  " <bpmn:extensionElements>",
@@ -1100,9 +1158,8 @@ function escalationTaskLines(
1100
1158
  " <zeebe:userTask />",
1101
1159
  ' <zeebe:assignmentDefinition candidateGroups="operators" />',
1102
1160
  " <zeebe:ioMapping>",
1103
- ` <zeebe:input ${attr("source", contextFeel)} target="prompt" />`,
1104
- ` <zeebe:input ${attr("source", `=${feelStr(nodeId)}`)} target="nodeId" />`,
1105
- ` <zeebe:input ${attr("source", '="none"')} target="emitMode" />`,
1161
+ ...inputs,
1162
+ ...outputs,
1106
1163
  " </zeebe:ioMapping>",
1107
1164
  " </bpmn:extensionElements>",
1108
1165
  ...incoming.map((id) => ` <bpmn:incoming>${id}</bpmn:incoming>`),
@@ -1120,6 +1177,23 @@ function escalationContextFeel(nodeId: string, descriptor: string, timeoutVar: s
1120
1177
  return `=${head} + string(${timeoutVar}) + ${feelStr(`)${tail}`)}`;
1121
1178
  }
1122
1179
 
1180
+ /** The escalation context line for a `wait` gate (issue #514 Defect A). Extends the base #499 line with
1181
+ * the probe's RUNTIME last `detail` and its observed-candidate summary (`observed`), so a human/agent
1182
+ * reading the (read-only) prompt can immediately tell a genuine "not published yet" from a transient
1183
+ * false-negative — without hunting for the internal variables. Both are folded in defensively (an
1184
+ * as-yet-unset var renders "—", never a FEEL error). */
1185
+ function waitEscalationContextFeel(nodeId: string): string {
1186
+ const base = escalationContextFeel(
1187
+ nodeId,
1188
+ "readiness gate",
1189
+ "probeTimeout",
1190
+ " before its ReadinessProbe went green — decide how to proceed.",
1191
+ );
1192
+ const lastProbe = `(if (is defined(detail)) then string(detail) else "—")`;
1193
+ const observed = `(if (is defined(observed)) then string(observed) else "—")`;
1194
+ return `${base} + " Last probe: " + ${lastProbe} + ". Observed: " + ${observed} + "."`;
1195
+ }
1196
+
1123
1197
  /** A plain `<bpmn:sequenceFlow>` (6-space indented). */
1124
1198
  function flow(id: string, source: string, target: string): string {
1125
1199
  return ` <bpmn:sequenceFlow id="${id}" sourceRef="${source}" targetRef="${target}" />`;
@@ -28,6 +28,7 @@ import {
28
28
  normalizePoll,
29
29
  parseProbe,
30
30
  parsePrTarget,
31
+ summariseCapabilityCandidates,
31
32
  parsePrView,
32
33
  parseReleases,
33
34
  parseReleasesTarget,
@@ -214,6 +215,68 @@ test("matchCapability: the bare '#274' ref form resolves identically to 'nano-id
214
215
  assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@1.2.3");
215
216
  });
216
217
 
218
+ // ── #514 Defect A: the capability gate is self-diagnosing on escalation ──────────────────────────
219
+
220
+ test("#514 Defect A summariseCapabilityCandidates: lists candidate releases (newest first) and whether each references the ref", () => {
221
+ const summary = summariseCapabilityCandidates(capMatch, [
222
+ rel("@nanobpm/urban@0.9.0", [200]),
223
+ rel("@nanobpm/urban@0.82.0", [274]),
224
+ rel("@nanobpm/other@1.0.0", [274]), // wrong package — excluded from the per-package summary
225
+ ]);
226
+ assertStringIncludes(summary, "2 @nanobpm/urban release(s) observed");
227
+ assertStringIncludes(summary, "1 referencing #274");
228
+ // Newest candidate first, and the ref-flag per candidate.
229
+ assertStringIncludes(summary, "@nanobpm/urban@0.82.0 refs #274");
230
+ assertStringIncludes(summary, "@nanobpm/urban@0.9.0 no #274");
231
+ assert(!summary.includes("@nanobpm/other"), "a sibling package never leaks into the per-package summary");
232
+ });
233
+
234
+ test("#514 Defect A summariseCapabilityCandidates: no matching releases yields a plain observed note (never throws)", () => {
235
+ assertEquals(summariseCapabilityCandidates(capMatch, []), "no @nanobpm/urban releases observed");
236
+ assertEquals(summariseCapabilityCandidates(capMatch, [rel("@nanobpm/other@1.0.0", [274])]), "no @nanobpm/urban releases observed");
237
+ });
238
+
239
+ test("#514 Defect A summariseCapabilityCandidates: an unparseable capabilityRef is labelled distinctly and omits the meaningless per-candidate refs/no flags", () => {
240
+ const badRef = { ...capMatch, capabilityRef: "not-a-number" };
241
+ const summary = summariseCapabilityCandidates(badRef, [rel("@nanobpm/urban@0.82.0", [274])]);
242
+ assertStringIncludes(summary, "1 @nanobpm/urban release(s) observed (unparseable ref)");
243
+ assertStringIncludes(summary, "@nanobpm/urban@0.82.0");
244
+ assert(!summary.includes("(no ref configured)"), "a configured-but-unparseable ref is NOT reported as missing");
245
+ assert(!summary.includes("no #") && !summary.includes("refs #"), "no meaningless per-candidate ref flags when there is no parseable number");
246
+ assert(!summary.includes("referencing"), "no aggregate referencing count when there is no parseable number");
247
+ });
248
+
249
+ test("#514 Defect A summariseCapabilityCandidates: a genuinely absent ref is labelled '(no ref configured)'", () => {
250
+ const noRef = { ...capMatch, capabilityRef: undefined };
251
+ const summary = summariseCapabilityCandidates(noRef, [rel("@nanobpm/urban@0.82.0", [274])]);
252
+ assertStringIncludes(summary, "1 @nanobpm/urban release(s) observed (no ref configured)");
253
+ assert(!summary.includes("unparseable"), "a missing ref is not reported as unparseable");
254
+ });
255
+
256
+ test("#514 Defect A summariseCapabilityCandidates: caps the newest 8 candidates so a >100-release repo cannot bloat the form/log", () => {
257
+ const many: GithubRelease[] = [];
258
+ for (let i = 1; i <= 20; i++) many.push(rel(`@nanobpm/urban@0.${i}.0`, [200]));
259
+ const summary = summariseCapabilityCandidates(capMatch, many);
260
+ assertStringIncludes(summary, "20 @nanobpm/urban release(s) observed");
261
+ assertStringIncludes(summary, "(+12 more)");
262
+ assertStringIncludes(summary, "@nanobpm/urban@0.20.0 no #274"); // newest is shown
263
+ assert(!summary.includes("@nanobpm/urban@0.1.0 "), "the oldest is trimmed by the cap");
264
+ });
265
+
266
+ test("#514 Defect A matchCapability surfaces `observed` on the not-ready path — a false-negative is diagnosable, not contextless", () => {
267
+ // Red before the fix: matchCapability returned only {ready, detail} with no observed-release context,
268
+ // so an escalation could not tell a genuine 'not published yet' from a transient false-negative.
269
+ const notReady = matchCapability(capMatch, [rel("@nanobpm/urban@0.82.0", [200])]);
270
+ assert(!notReady.ready);
271
+ assertStringIncludes(notReady.observed ?? "", "@nanobpm/urban@0.82.0 no #274");
272
+ });
273
+
274
+ test("#514 Defect A matchCapability surfaces `observed` on the ready path too (the resolving release shows as referencing the ref)", () => {
275
+ const ready = matchCapability(capMatch, [rel("@nanobpm/urban@0.82.0", [274])]);
276
+ assert(ready.ready);
277
+ assertStringIncludes(ready.observed ?? "", "@nanobpm/urban@0.82.0 refs #274");
278
+ });
279
+
217
280
  test("cmpVersion: numeric dotted compare (0.9 < 0.54 < 0.60), matching nano-ide publish.mjs", () => {
218
281
  assert(cmpVersion("0.9.0", "0.54.0") < 0);
219
282
  assert(cmpVersion("0.54.0", "0.60.0") < 0);
package/app/readiness.ts CHANGED
@@ -120,6 +120,13 @@ export interface ProbeResult {
120
120
  readonly ready: boolean;
121
121
  readonly detail: string;
122
122
  readonly bind?: Record<string, string>;
123
+ /** An OPTIONAL compact, human-readable summary of what the probe actually OBSERVED at poll time
124
+ * (issue #514 Defect A). Diagnostic-only — it is NEVER part of the gate contract, it does not gate
125
+ * readiness, and it carries no secret material (provenance is public). The `capability` kind
126
+ * populates it with {@link summariseCapabilityCandidates} so an ESCALATION (false-negative wait)
127
+ * surfaces the candidate releases the matcher saw and whether each referenced the ref, letting a
128
+ * human/agent tell a genuine "not published yet" from a transient false-negative. */
129
+ readonly observed?: string;
123
130
  }
124
131
 
125
132
  /** A single published GitHub Release, reduced to the two fields the capability resolver reads: the
@@ -448,6 +455,51 @@ function versionForPackage(tag: string, pkg: string): string | undefined {
448
455
  return /^\d+(\.\d+)*$/.test(v) ? v : undefined;
449
456
  }
450
457
 
458
+ /** A compact, deterministic, BOUNDED summary of the capability candidate releases a probe observed
459
+ * (issue #514 Defect A). Lists the releases tagged `<match.package>@<version>` (newest first) and,
460
+ * for each, whether its body referenced `match.capabilityRef` — so an escalated capability gate can
461
+ * show a human/agent EXACTLY what the probe saw (a genuine "not published yet" vs. a transient
462
+ * false-negative where a matching release was live but its provenance body was momentarily empty).
463
+ * PURE / never throws — a malformed or empty list yields a plain "no releases observed" note.
464
+ * Output is capped (newest {@link CAPABILITY_SUMMARY_LIMIT}) so a repo with hundreds of releases
465
+ * cannot bloat the escalation form or a log line. Provenance is public — nothing here is redacted. */
466
+ export function summariseCapabilityCandidates(
467
+ match: ProbeMatch | undefined,
468
+ releases: readonly GithubRelease[],
469
+ ): string {
470
+ const pkg = match?.package;
471
+ const ref = match?.capabilityRef;
472
+ if (!pkg) return "capability: no package configured";
473
+ const num = ref ? capabilityNumber(ref) : undefined;
474
+ const candidates: { version: string; refs: boolean }[] = [];
475
+ for (const rel of releases) {
476
+ if (!rel || typeof rel.tag !== "string" || typeof rel.body !== "string") continue;
477
+ const version = versionForPackage(rel.tag, pkg);
478
+ if (!version) continue;
479
+ candidates.push({ version, refs: num !== undefined && bodyReferences(rel.body, num) });
480
+ }
481
+ if (candidates.length === 0) return `no ${pkg} releases observed`;
482
+ candidates.sort((a, b) => cmpVersion(b.version, a.version)); // newest first, deterministic
483
+ const shown = candidates.slice(0, CAPABILITY_SUMMARY_LIMIT);
484
+ const more = candidates.length > shown.length ? ` (+${candidates.length - shown.length} more)` : "";
485
+ if (num === undefined) {
486
+ // No parseable ref number to test candidates against: distinguish a configured-but-unparseable ref
487
+ // from a genuinely absent one, and OMIT the per-candidate/aggregate "refs/no" flags — with nothing
488
+ // to match, every candidate would carry a meaningless "no", which is misleading rather than diagnostic.
489
+ const why = ref ? "(unparseable ref)" : "(no ref configured)";
490
+ const parts = shown.map((c) => `${pkg}@${c.version}`);
491
+ return `${candidates.length} ${pkg} release(s) observed ${why}: ${parts.join("; ")}${more}`;
492
+ }
493
+ const referencing = candidates.filter((c) => c.refs).length;
494
+ const refLabel = `#${num}`;
495
+ const parts = shown.map((c) => `${pkg}@${c.version} ${c.refs ? `refs ${refLabel}` : `no ${refLabel}`}`);
496
+ return `${candidates.length} ${pkg} release(s) observed, ${referencing} referencing ${refLabel}: ${parts.join("; ")}${more}`;
497
+ }
498
+
499
+ /** Newest-N cap on {@link summariseCapabilityCandidates} so a >100-release repo cannot bloat the
500
+ * escalation form or a warn log line. */
501
+ const CAPABILITY_SUMMARY_LIMIT = 8;
502
+
451
503
  /** capability readiness (#274 Gap A): among GitHub Releases tagged `<match.package>@*` whose body
452
504
  * references `match.capabilityRef`, resolve the **lowest** SemVer version — that is the version that
453
505
  * *first* carries the capability (`firstVersion`). Late-binds it as `{ resolvedArtifact }` so the
@@ -457,9 +509,10 @@ function versionForPackage(tag: string, pkg: string): string | undefined {
457
509
  export function matchCapability(match: ProbeMatch | undefined, releases: readonly GithubRelease[]): ProbeResult {
458
510
  const pkg = match?.package;
459
511
  const ref = match?.capabilityRef;
460
- if (!pkg || !ref) return { ready: false, detail: "capability: missing package/capabilityRef" };
512
+ const observed = summariseCapabilityCandidates(match, releases);
513
+ if (!pkg || !ref) return { ready: false, detail: "capability: missing package/capabilityRef", observed };
461
514
  const num = capabilityNumber(ref);
462
- if (!num) return { ready: false, detail: "capability: unparseable capabilityRef (no #NNN)" };
515
+ if (!num) return { ready: false, detail: "capability: unparseable capabilityRef (no #NNN)", observed };
463
516
 
464
517
  let firstVersion: string | undefined;
465
518
  for (const rel of releases) {
@@ -469,9 +522,9 @@ export function matchCapability(match: ProbeMatch | undefined, releases: readonl
469
522
  if (!bodyReferences(rel.body, num)) continue;
470
523
  if (firstVersion === undefined || cmpVersion(version, firstVersion) < 0) firstVersion = version;
471
524
  }
472
- if (firstVersion === undefined) return { ready: false, detail: `capability #${num} not published in ${pkg} yet` };
525
+ if (firstVersion === undefined) return { ready: false, detail: `capability #${num} not published in ${pkg} yet`, observed };
473
526
  const resolvedArtifact = `${pkg}@${firstVersion}`;
474
- return { ready: true, detail: `capability #${num} carried by ${resolvedArtifact}`, bind: { resolvedArtifact } };
527
+ return { ready: true, detail: `capability #${num} carried by ${resolvedArtifact}`, bind: { resolvedArtifact }, observed };
475
528
  }
476
529
 
477
530
  /** The **newest** published SemVer version of `pkg` across `releases` — the target of the gated
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.138.0",
3
+ "version": "0.138.1",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -50,6 +50,7 @@
50
50
  <nano:extend name="detail" type="string" optional="true" />
51
51
  <nano:extend name="resolvedArtifact" type="string" optional="true" />
52
52
  <nano:extend name="mergedSha" type="string" optional="true" />
53
+ <nano:extend name="observed" type="string" optional="true" />
53
54
  </nano:shape>
54
55
  <nano:shape id="ReadinessReady" name="readiness-ready message payload">
55
56
  <nano:extend name="ready" type="boolean" />
@@ -23,11 +23,12 @@ function execReturning(seq: Array<HttpResponse>, counts: { http: number } = { ht
23
23
  const httpProbe = (poll?: ReadinessProbe["poll"]): ReadinessProbe =>
24
24
  parseProbe({ kind: "http", target: "https://x/health?token=s3cr3t", poll });
25
25
 
26
- test("safeBind: strips reserved keys (ready/detail) so a bind can only ADD outputs, never shadow the payload", () => {
27
- const cleaned = safeBind({ resolvedArtifact: "@nanobpm/urban@0.54.0", ready: "false", detail: "spoofed" });
26
+ test("safeBind: strips reserved keys (ready/detail/observed) so a bind can only ADD outputs, never shadow the payload", () => {
27
+ const cleaned = safeBind({ resolvedArtifact: "@nanobpm/urban@0.54.0", ready: "false", detail: "spoofed", observed: "spoofed" });
28
28
  assertEquals(cleaned.resolvedArtifact, "@nanobpm/urban@0.54.0");
29
29
  assertEquals("ready" in cleaned, false, "a bound 'ready' can never override the canonical payload");
30
30
  assertEquals("detail" in cleaned, false, "a bound 'detail' can never override the canonical payload");
31
+ assertEquals("observed" in cleaned, false, "a bound 'observed' can never clobber the top-level diagnostic summary");
31
32
  assertEquals(Object.keys(safeBind(undefined)).length, 0, "an absent bind yields an empty object");
32
33
  });
33
34
 
@@ -121,6 +122,59 @@ test("probeSingleShot: inconclusive lastAttempt fallback surfaces its redacted d
121
122
  assert(res.detail.includes("fallback: still nothing"), "the fallback diagnostic is preserved");
122
123
  });
123
124
 
125
+ test("#514 Defect A probeSingleShot: a not-ready capability at the boundary emits a structured WARN with the probe detail + observed releases, and threads `observed` to the return", async () => {
126
+ // Red before the fix: the boundary last-attempt returned a contextless "gate boundary reached" with
127
+ // no warn and no observed-release context, so an escalated false-negative was undiagnosable.
128
+ const capProbe = parseProbe({
129
+ kind: "capability",
130
+ target: "github-releases:nanobpm/nano-ide",
131
+ match: { package: "@nanobpm/urban", capabilityRef: "#468" },
132
+ });
133
+ // A live @nanobpm/urban release exists, but its provenance body does NOT (yet) reference #468 — the
134
+ // exact transient false-negative shape from the incident.
135
+ const releasesJson = JSON.stringify([
136
+ [{ tag_name: "@nanobpm/urban@0.82.0", body: "## Provenance\n- #200\n" }],
137
+ ]);
138
+ const exec: ProbeExec = {
139
+ async httpGet(): Promise<HttpResponse> {
140
+ throw new Error("unused");
141
+ },
142
+ async run(): Promise<CommandResult> {
143
+ return { code: 0, stdout: releasesJson, stderr: "" };
144
+ },
145
+ };
146
+ const warns: string[] = [];
147
+ let publishes = 0;
148
+ const res = await probeSingleShot({
149
+ probe: capProbe,
150
+ exec,
151
+ env: {},
152
+ lastAttempt: true,
153
+ publish: async () => {
154
+ publishes += 1;
155
+ },
156
+ warn: (msg) => warns.push(msg),
157
+ });
158
+ assert(!res.ready, "the boundary attempt is still not-ready");
159
+ assertEquals(publishes, 0, "a not-ready boundary never publishes readiness-ready");
160
+ assertEquals(warns.length, 1, "exactly one structured warn is emitted at the boundary last attempt");
161
+ assert(warns[0].includes("escalating"), "the warn says the gate is escalating");
162
+ assert(warns[0].includes("capability #468 not published"), "the warn carries the probe's last detail");
163
+ assert(warns[0].includes("@nanobpm/urban@0.82.0 no #468"), "the warn carries the observed candidate releases");
164
+ assert((res.observed ?? "").includes("@nanobpm/urban@0.82.0 no #468"), "the observed summary is threaded to the return for the escalation task");
165
+ // The return `detail` must carry the probe's actual last matcher detail — NOT a generic "gate boundary
166
+ // reached" string — because the wait-gate seeds `probeDetail` and the escalation context FEEL from it.
167
+ assert(
168
+ res.detail.includes("capability #468 not published"),
169
+ "the boundary return `detail` carries the probe's diagnostic last detail, keeping the escalation diagnostic",
170
+ );
171
+ assert(
172
+ !res.detail.includes("gate boundary reached"),
173
+ "the boundary return no longer clobbers `detail` with a generic contextless string",
174
+ );
175
+ });
176
+
177
+
124
178
  test("probeSingleShot: forwards a matcher's bind through publish into the message variables (#274 Gap B)", async () => {
125
179
  const published: Array<{ detail: string; bind?: Record<string, string> }> = [];
126
180
  const payload = JSON.stringify([{ tag_name: "@nanobpm/urban@0.54.0", body: "## Provenance\n- #274\n" }]);
@@ -35,7 +35,7 @@ export { READINESS_READY_MESSAGE };
35
35
  * kind-agnostic emit primitive (#274 Gap B), but it flows from matcher output into both the
36
36
  * `readiness-ready` message variables and the worker output — so a matcher that binds `ready`/`detail`
37
37
  * could shadow the canonical payload and break the gate contract. */
38
- const RESERVED_BIND_KEYS: ReadonlySet<string> = new Set(["ready", "detail"]);
38
+ const RESERVED_BIND_KEYS: ReadonlySet<string> = new Set(["ready", "detail", "observed"]);
39
39
  export function safeBind(bind?: Record<string, string>): Record<string, string> {
40
40
  if (!bind) return {};
41
41
  const out: Record<string, string> = {};
@@ -60,6 +60,7 @@ export async function probeSingleShot(deps: {
60
60
  lastAttempt?: boolean;
61
61
  fallback?: () => Promise<ProbeResult | null>;
62
62
  log?: (msg: string) => void;
63
+ warn?: (msg: string) => void;
63
64
  }): Promise<ProbeResult> {
64
65
  const label = redactTarget(deps.probe);
65
66
  const res: ProbeResult = await probeOnce(deps.probe, deps.exec, deps.env).catch((err) => ({
@@ -84,9 +85,25 @@ export async function probeSingleShot(deps: {
84
85
  await deps.publish(settled.detail, settled.bind);
85
86
  return settled;
86
87
  }
88
+ // Boundary last attempt and STILL not ready: this is the escalation trigger. Surface a structured
89
+ // warn (issue #514 Defect A) carrying the probe's last detail + what it OBSERVED, so a false-negative
90
+ // (a matching release was live but its provenance body was momentarily empty) is diagnosable from the
91
+ // logs — not just a contextless "escalated". `observed` is diagnostic-only and secret-free.
92
+ const failingDetail = settled ? `${res.detail} (fallback: ${settled.detail})` : res.detail;
93
+ const observed = settled?.observed ?? res.observed;
94
+ deps.warn?.(
95
+ `readiness gate escalating ${label}: not ready at boundary — ${failingDetail}` +
96
+ (observed ? `; observed: ${observed}` : ""),
97
+ );
98
+ // Return the actual failing probe detail (not a generic "gate boundary reached" string): downstream
99
+ // the wait-gate seeds `probeDetail` and the escalation context FEEL from `detail`, so overwriting it
100
+ // here would make the escalation non-diagnostic — the very defect this change fixes. The fact that the
101
+ // boundary/timeout was reached is already communicated by the escalation context ("exceeded its SLA …
102
+ // before its ReadinessProbe went green") and the structured WARN above.
87
103
  return {
88
104
  ready: false,
89
- detail: settled ? `gate boundary reached (fallback: ${settled.detail})` : "gate boundary reached",
105
+ detail: failingDetail,
106
+ observed,
90
107
  };
91
108
  }
92
109
 
@@ -149,8 +166,14 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
149
166
  });
150
167
  },
151
168
  log: (msg) => app.log.info(msg),
169
+ warn: (msg) => app.log.warn(msg),
152
170
  });
153
- return { ready: result.ready, detail: result.detail, ...safeBind(result.bind) };
171
+ return {
172
+ ready: result.ready,
173
+ detail: result.detail,
174
+ ...(result.observed !== undefined ? { observed: result.observed } : {}),
175
+ ...safeBind(result.bind),
176
+ };
154
177
  };
155
178
 
156
179
  export default handler;