@nanobpm/nano-workforce 0.120.0 → 0.120.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,10 @@
1
+ ## [0.120.1](https://github.com/nanobpm/nano-workforce/compare/v0.120.0...v0.120.1) (2026-08-22)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **readiness:** make pr.readiness-probe single-shot; engine owns retry cadence ([#428](https://github.com/nanobpm/nano-workforce/issues/428)) ([#429](https://github.com/nanobpm/nano-workforce/issues/429)) ([07db031](https://github.com/nanobpm/nano-workforce/commit/07db031f0d5e3f63ff47ffa51583bdef3012a2ab))
7
+
1
8
  # [0.120.0](https://github.com/nanobpm/nano-workforce/compare/v0.119.0...v0.120.0) (2026-08-22)
2
9
 
3
10
 
@@ -106,9 +106,11 @@ test("capabilityNeedToProbeInput: maps a need to the readiness-gate capability p
106
106
  planKey: "owner/repo#289",
107
107
  taskId: "issue-289",
108
108
  probeTimeout: "PT12H",
109
+ probePollEvery: "PT15S",
109
110
  });
110
111
  assertEquals(input.gateKey, "owner/repo#289:issue-289:nanobpm/nano-ide#274:@nanobpm/urban");
111
112
  assertEquals(input.probeTimeout, "PT12H");
113
+ assertEquals(input.probePollEvery, "PT15S");
112
114
  assertEquals(input.onTimeout, "escalate");
113
115
  assertEquals(input.probe.kind, "capability");
114
116
  assertEquals(input.probe.target, "github-releases:nanobpm/nano-ide");
@@ -121,7 +123,7 @@ test("capabilityNeedToProbeInput: maps a need to the readiness-gate capability p
121
123
  test("capabilityNeedToProbeInput: omits verifyCommand when the need has none", () => {
122
124
  const input = capabilityNeedToProbeInput(
123
125
  { capabilityRef: "nanobpm/nano-ide#274", package: "@nanobpm/urban" },
124
- { planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H" },
126
+ { planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H", probePollEvery: "PT15S" },
125
127
  );
126
128
  assertEquals(input.probe.match?.verifyCommand, undefined);
127
129
  });
@@ -131,7 +133,7 @@ test("capabilityNeedToProbeInput: throws when the handle names no releases sourc
131
133
  () =>
132
134
  capabilityNeedToProbeInput(
133
135
  { capabilityRef: "#274", package: "@nanobpm/urban" },
134
- { planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H" },
136
+ { planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H", probePollEvery: "PT15S" },
135
137
  ),
136
138
  UnresolvableCapabilityRefError,
137
139
  "names no owner/repo releases source",
@@ -36,6 +36,7 @@ export interface CapabilityNeed {
36
36
  export interface ReadinessProbeInput {
37
37
  readonly gateKey: string;
38
38
  readonly probeTimeout: string;
39
+ readonly probePollEvery: string;
39
40
  readonly onTimeout?: OnTimeout;
40
41
  readonly probe: ReadinessProbe;
41
42
  }
@@ -152,7 +153,7 @@ export function capabilityTaskBarrierKey(planKey: string, taskId: string): strin
152
153
  * the handle names no releases source. */
153
154
  export function capabilityNeedToProbeInput(
154
155
  need: CapabilityNeed,
155
- opts: { planKey: string; taskId: string; probeTimeout: string },
156
+ opts: { planKey: string; taskId: string; probeTimeout: string; probePollEvery: string },
156
157
  ): ReadinessProbeInput {
157
158
  const repo = capabilityReleasesRepo(need.capabilityRef);
158
159
  if (!repo) throw new UnresolvableCapabilityRefError(need.capabilityRef);
@@ -170,6 +171,7 @@ export function capabilityNeedToProbeInput(
170
171
  return {
171
172
  gateKey: capabilityGateKey(opts.planKey, opts.taskId, need.capabilityRef, need.package),
172
173
  probeTimeout: opts.probeTimeout,
174
+ probePollEvery: opts.probePollEvery,
173
175
  onTimeout: "escalate",
174
176
  probe,
175
177
  };
@@ -85,10 +85,10 @@ test("determinism: the same JSON always yields byte-identical bpmn/diagram/resol
85
85
 
86
86
  test("trust bound: every node inlines an embedded subProcess delegating to an allowlisted body — no other activity type", () => {
87
87
  const r = compileOk(RELEASE_RUNBOOK);
88
- // Each of the 4 nodes compiles to an EMBEDDED subProcess (call activities are a no-op on the pinned
88
+ // Each of the 4 nodes compiles to an EMBEDDED subProcess; wait adds one nested retry-loop subProcess (call activities are a no-op on the pinned
89
89
  // WASM engine, so delegation is an inlined subProcess sharing the parent scope — never a callActivity).
90
90
  assertEquals((r.bpmn.match(/<bpmn:callActivity/g) ?? []).length, 0);
91
- assertEquals((r.bpmn.match(/<bpmn:subProcess /g) ?? []).length, 4);
91
+ assertEquals((r.bpmn.match(/<bpmn:subProcess /g) ?? []).length, 5);
92
92
  assert(!r.bpmn.includes("<bpmn:scriptTask"), "no script task is ever emitted");
93
93
  // Each node's inner body delegates to an allowlisted engine-native body: a `serviceTask` typed to a
94
94
  // worker (agent → its `senior:*` job; wait → `pr.readiness-probe`; connector → `pr.delivery-connector`)
@@ -604,6 +604,7 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
604
604
  inputs.push({ source: cfg("gateKey"), target: "gateKey" });
605
605
  inputs.push({ source: cfg("probe"), target: "probe" });
606
606
  inputs.push({ source: cfg("probeTimeout"), target: "probeTimeout" });
607
+ inputs.push({ source: cfg("probePollEvery"), target: "probePollEvery" });
607
608
  break;
608
609
  case "human":
609
610
  inputs.push({ source: cfg("escalationSlaTimeout"), target: "escalationSlaTimeout" });
@@ -712,33 +713,78 @@ function waitBodyLines(el: string, nodeId: string): string[] {
712
713
  const esc = escalationTaskElement(el);
713
714
  return [
714
715
  ` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
715
- ` <bpmn:serviceTask id="${el}_task" name="Probe readiness: ${escapeXml(nodeId)}">`,
716
+ ` <bpmn:subProcess id="${el}_probeLoop" name="Probe readiness loop: ${escapeXml(nodeId)}">`,
717
+ " <bpmn:extensionElements>",
718
+ " <zeebe:ioMapping>",
719
+ ' <zeebe:output source="=ready" target="ready" />',
720
+ ' <zeebe:output source="=if (is defined(detail)) then detail else null" target="detail" />',
721
+ ' <zeebe:output source="=if (is defined(resolvedArtifact)) then resolvedArtifact else null" target="resolvedArtifact" />',
722
+ ' <zeebe:output source="=if (is defined(mergedSha)) then mergedSha else null" target="mergedSha" />',
723
+ " </zeebe:ioMapping>",
724
+ " </bpmn:extensionElements>",
725
+ ` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
726
+ ` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
727
+ ` <bpmn:startEvent id="${el}_loopStart"><bpmn:outgoing>${el}_li0</bpmn:outgoing></bpmn:startEvent>`,
728
+ ` <bpmn:serviceTask id="${el}_task" name="Probe readiness: ${escapeXml(nodeId)}">`,
729
+ " <bpmn:extensionElements>",
730
+ ` <zeebe:taskDefinition type="${DELEGATE_TASK_TYPE.wait}" />`,
731
+ " <zeebe:properties>",
732
+ ' <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReadinessProbeIn" />',
733
+ ' <zeebe:property name="io.nanobpm.dataEnvelope.out" value="ReadinessProbeOut" />',
734
+ " </zeebe:properties>",
735
+ " </bpmn:extensionElements>",
736
+ ` <bpmn:incoming>${el}_li0</bpmn:incoming>`,
737
+ ` <bpmn:incoming>${el}_li4</bpmn:incoming>`,
738
+ ` <bpmn:outgoing>${el}_li1</bpmn:outgoing>`,
739
+ " </bpmn:serviceTask>",
740
+ ` <bpmn:exclusiveGateway id="${el}_gw" name="ready?" default="${el}_li3">`,
741
+ ` <bpmn:incoming>${el}_li1</bpmn:incoming>`,
742
+ ` <bpmn:outgoing>${el}_li2</bpmn:outgoing>`,
743
+ ` <bpmn:outgoing>${el}_li3</bpmn:outgoing>`,
744
+ " </bpmn:exclusiveGateway>",
745
+ ` <bpmn:intermediateCatchEvent id="${el}_waitPoll" name="Wait poll interval">`,
746
+ ` <bpmn:incoming>${el}_li3</bpmn:incoming>`,
747
+ ` <bpmn:outgoing>${el}_li4</bpmn:outgoing>`,
748
+ ` <bpmn:timerEventDefinition id="${el}_pollTed"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=probePollEvery</bpmn:timeDuration></bpmn:timerEventDefinition>`,
749
+ " </bpmn:intermediateCatchEvent>",
750
+ ` <bpmn:endEvent id="${el}_loopEnd"><bpmn:incoming>${el}_li2</bpmn:incoming></bpmn:endEvent>`,
751
+ flow(`${el}_li0`, `${el}_loopStart`, `${el}_task`),
752
+ flow(`${el}_li1`, `${el}_task`, `${el}_gw`),
753
+ ` <bpmn:sequenceFlow id="${el}_li2" name="ready" sourceRef="${el}_gw" targetRef="${el}_loopEnd"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=ready = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
754
+ ` <bpmn:sequenceFlow id="${el}_li3" name="not ready" sourceRef="${el}_gw" targetRef="${el}_waitPoll" />`,
755
+ flow(`${el}_li4`, `${el}_waitPoll`, `${el}_task`),
756
+ " </bpmn:subProcess>",
757
+ ` <bpmn:boundaryEvent id="${el}_be" name="Gate timed out" attachedToRef="${el}_probeLoop">`,
758
+ ` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
759
+ ` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=probeTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
760
+ " </bpmn:boundaryEvent>",
761
+ ` <bpmn:serviceTask id="${el}_lastAttempt" name="Probe readiness at boundary: ${escapeXml(nodeId)}">`,
716
762
  " <bpmn:extensionElements>",
717
763
  ` <zeebe:taskDefinition type="${DELEGATE_TASK_TYPE.wait}" />`,
718
764
  " <zeebe:properties>",
719
765
  ' <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReadinessProbeIn" />',
720
766
  ' <zeebe:property name="io.nanobpm.dataEnvelope.out" value="ReadinessProbeOut" />',
721
767
  " </zeebe:properties>",
768
+ " <zeebe:ioMapping>",
769
+ ' <zeebe:input source="=true" target="lastAttempt" />',
770
+ " </zeebe:ioMapping>",
722
771
  " </bpmn:extensionElements>",
723
- ` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
724
- ` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
772
+ ` <bpmn:incoming>${el}_i2</bpmn:incoming>`,
773
+ ` <bpmn:outgoing>${el}_i6</bpmn:outgoing>`,
725
774
  " </bpmn:serviceTask>",
726
- ` <bpmn:boundaryEvent id="${el}_be" name="Gate timed out" attachedToRef="${el}_task">`,
727
- ` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
728
- ` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=probeTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
729
- " </bpmn:boundaryEvent>",
730
- ` <bpmn:exclusiveGateway id="${el}_gw" name="ready?" default="${el}_i4">`,
731
- ` <bpmn:incoming>${el}_i1</bpmn:incoming>`,
732
- ` <bpmn:outgoing>${el}_i3</bpmn:outgoing>`,
775
+ ` <bpmn:exclusiveGateway id="${el}_lastGw" name="ready after boundary?" default="${el}_i4">`,
776
+ ` <bpmn:incoming>${el}_i6</bpmn:incoming>`,
777
+ ` <bpmn:outgoing>${el}_i7</bpmn:outgoing>`,
733
778
  ` <bpmn:outgoing>${el}_i4</bpmn:outgoing>`,
734
779
  " </bpmn:exclusiveGateway>",
735
- ...escalationTaskLines(esc, nodeId, [`${el}_i2`, `${el}_i4`], `${el}_i5`),
736
- ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i3</bpmn:incoming><bpmn:incoming>${el}_i5</bpmn:incoming></bpmn:endEvent>`,
737
- flow(`${el}_i0`, `${el}_start`, `${el}_task`),
738
- flow(`${el}_i1`, `${el}_task`, `${el}_gw`),
739
- flow(`${el}_i2`, `${el}_be`, esc),
740
- ` <bpmn:sequenceFlow id="${el}_i3" name="ready" sourceRef="${el}_gw" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=ready = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
741
- ` <bpmn:sequenceFlow id="${el}_i4" name="not ready" sourceRef="${el}_gw" targetRef="${esc}" />`,
780
+ ...escalationTaskLines(esc, nodeId, [`${el}_i4`], `${el}_i5`),
781
+ ` <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>`,
782
+ flow(`${el}_i0`, `${el}_start`, `${el}_probeLoop`),
783
+ flow(`${el}_i1`, `${el}_probeLoop`, `${el}_end`),
784
+ flow(`${el}_i2`, `${el}_be`, `${el}_lastAttempt`),
785
+ flow(`${el}_i6`, `${el}_lastAttempt`, `${el}_lastGw`),
786
+ ` <bpmn:sequenceFlow id="${el}_i7" name="ready" sourceRef="${el}_lastGw" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=ready = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
787
+ ` <bpmn:sequenceFlow id="${el}_i4" name="not ready" sourceRef="${el}_lastGw" targetRef="${esc}" />`,
742
788
  flow(`${el}_i5`, esc, `${el}_end`),
743
789
  ];
744
790
  }
@@ -64,6 +64,7 @@ test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMappin
64
64
  const wait = byField((v) => "gateKey" in v);
65
65
  assertEquals(wait?.gateKey, "run-7:n3");
66
66
  assertEquals(wait?.probeTimeout, "PT20M");
67
+ assertEquals(wait?.probePollEvery, "PT15S");
67
68
  assert(wait?.probe && typeof wait.probe === "object", "the wait node carries its ReadinessProbe descriptor");
68
69
 
69
70
  const human = byField((v) => "escalationSlaTimeout" in v);
@@ -17,6 +17,7 @@ import { createHash, randomUUID } from "node:crypto";
17
17
  import type { EngineClient } from "@nanobpm/urban";
18
18
  import type { DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
19
19
  import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
20
+ import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery } from "./readiness.ts";
20
21
 
21
22
  /** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
22
23
  * content-addressed deploy id (`delivery-graph-<digest>`) AND the S5 dispatch door's approval token /
@@ -35,6 +36,8 @@ export interface DeliveryRunTimeouts {
35
36
  probeTimeout?: string;
36
37
  /** `human` node SLA before it records an `escalated` outcome and settles. */
37
38
  escalationSlaTimeout?: string;
39
+ /** `wait` gate retry cadence owned by the engine. */
40
+ probePollEvery?: string;
38
41
  /** Optional explicit assignee for `human` nodes + escalation tasks (else candidate-group routed). */
39
42
  escalationAssignee?: string | null;
40
43
  }
@@ -51,6 +54,7 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
51
54
  const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
52
55
  nodeTimeout: "PT30M",
53
56
  probeTimeout: "PT30M",
57
+ probePollEvery: msToIsoDuration(DEFAULT_EVERY_MS),
54
58
  escalationSlaTimeout: "P1D",
55
59
  };
56
60
 
@@ -59,7 +63,7 @@ const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
59
63
  * silently seeds `null` into a node body), so both derive from the same node kinds. */
60
64
  type NodeInput =
61
65
  | { jobType: string; appendPrompt: string; timeout: string }
62
- | { gateKey: string; probe: unknown; probeTimeout: string }
66
+ | { gateKey: string; probe: unknown; probeTimeout: string; probePollEvery: string }
63
67
  | { escalationSlaTimeout: string; escalationAssignee: string | null }
64
68
  | { target: string; dedupeKey: string | null; payload: Record<string, unknown> | null; timeout: string };
65
69
 
@@ -105,6 +109,7 @@ export function prepareDeliveryGraph(graph: DeliveryGraph, options: DeliveryRunO
105
109
  const timeouts = {
106
110
  nodeTimeout: options.nodeTimeout ?? DEFAULTS.nodeTimeout,
107
111
  probeTimeout: options.probeTimeout ?? DEFAULTS.probeTimeout,
112
+ probePollEvery: options.probePollEvery ?? DEFAULTS.probePollEvery,
108
113
  escalationSlaTimeout: options.escalationSlaTimeout ?? DEFAULTS.escalationSlaTimeout,
109
114
  escalationAssignee: options.escalationAssignee ?? null,
110
115
  };
@@ -155,13 +160,20 @@ function rewriteProcessId(bpmn: string, processDefinitionId: string): string {
155
160
  * subProcess ioMapping pulls. Total over the closed kind set. */
156
161
  function buildNodeInput(
157
162
  node: DeliveryNode,
158
- ctx: { runKey: string; element: string; nodeTimeout: string; probeTimeout: string; escalationSlaTimeout: string; escalationAssignee: string | null },
163
+ ctx: { runKey: string; element: string; nodeTimeout: string; probeTimeout: string; probePollEvery: string; escalationSlaTimeout: string; escalationAssignee: string | null },
159
164
  ): NodeInput {
160
165
  switch (node.kind) {
161
166
  case "agent":
162
167
  return { jobType: node.agent.jobType, appendPrompt: node.agent.prompt ?? "", timeout: ctx.nodeTimeout };
163
- case "wait":
164
- return { gateKey: `${ctx.runKey}:${ctx.element}`, probe: node.wait, probeTimeout: ctx.probeTimeout };
168
+ case "wait": {
169
+ const probe = parseProbe(node.wait);
170
+ return {
171
+ gateKey: `${ctx.runKey}:${ctx.element}`,
172
+ probe: node.wait,
173
+ probeTimeout: ctx.probeTimeout,
174
+ probePollEvery: probe.poll?.everyMs ? readinessPollEvery(probe, {}) : ctx.probePollEvery,
175
+ };
176
+ }
165
177
  case "human":
166
178
  return { escalationSlaTimeout: ctx.escalationSlaTimeout, escalationAssignee: ctx.escalationAssignee };
167
179
  case "connector":
@@ -320,6 +320,7 @@ test("startFeature: no readiness ⇒ readinessProbes/probeTimeout/gateKey seeded
320
320
  const v = captured.variables;
321
321
  assertEquals(v.readinessProbes, null);
322
322
  assertEquals(v.probeTimeout, null);
323
+ assertEquals(v.probePollEvery, null);
323
324
  assertEquals(v.gateKey, null);
324
325
  assertEquals(v.resolvedArtifacts, null);
325
326
  });
@@ -348,11 +349,12 @@ test("startFeature: readiness probes seed the gate variables + a non-blank corre
348
349
  false,
349
350
  false,
350
351
  null,
351
- { probes, probeTimeout: "PT30M" },
352
+ { probes, probeTimeout: "PT30M", probePollEvery: "PT15S" },
352
353
  );
353
354
  const v = captured.variables;
354
355
  assertEquals(v.readinessProbes, probes);
355
356
  assertEquals(v.probeTimeout, "PT30M");
357
+ assertEquals(v.probePollEvery, "PT15S");
356
358
  // The preflight probe worker requires a non-blank gateKey to publish readiness-ready on.
357
359
  assertEquals(v.gateKey, "feature-readiness:owner/repo#42");
358
360
  assertEquals(v.resolvedArtifacts, null);
package/app/feature.ts CHANGED
@@ -29,6 +29,7 @@ import { deriveListBucket, deriveStage } from "./stage.ts";
29
29
  export interface FeatureReadinessOptions {
30
30
  readonly probes?: ReadinessProbe[];
31
31
  readonly probeTimeout?: string | null;
32
+ readonly probePollEvery?: string | null;
32
33
  }
33
34
 
34
35
  /** The BPMN process this module drives (resources/processes/feature.bpmn). */
@@ -367,6 +368,13 @@ export async function startFeature(
367
368
  "a non-blank bound. Derive it via parseFeatureReadiness before starting a gated feature.",
368
369
  );
369
370
  }
371
+ if (readinessProbes && (readiness.probePollEvery ?? "").trim() === "") {
372
+ throw new Error(
373
+ `startFeature(${parsed.planKey}): ${readinessProbes.length} readiness probe(s) seeded without a ` +
374
+ "probePollEvery — the preflight retry timers (=probePollEvery) require a non-blank cadence. " +
375
+ "Derive it via parseFeatureReadiness before starting a gated feature.",
376
+ );
377
+ }
370
378
  // Operator free-text steering for the implementation agent (issue #172 follow-on): blank/absent →
371
379
  // null so the implement task's `appendPrompt` FEEL (`customInstructions = null`) skips the block
372
380
  // rather than appending an empty "Operator custom instructions" heading.
@@ -487,6 +495,7 @@ export async function startFeature(
487
495
  // gate-less run still resolves the variable in that FEEL instead of raising an incident.
488
496
  readinessProbes,
489
497
  probeTimeout: readinessProbes ? (readiness.probeTimeout ?? null) : null,
498
+ probePollEvery: readinessProbes ? (readiness.probePollEvery ?? null) : null,
490
499
  gateKey: readinessProbes ? `feature-readiness:${parsed.planKey}` : null,
491
500
  resolvedArtifacts: null,
492
501
  },
@@ -9,12 +9,12 @@ import { test } from "node:test";
9
9
  import { assertEquals } from "#test-assert";
10
10
  import { parseFeatureReadiness } from "./featureReadiness.ts";
11
11
 
12
- const ENV = { NANO_READINESS_POLL_TIMEOUT: "PT30M" } as Record<string, string | undefined>;
12
+ const ENV = { NANO_READINESS_POLL_TIMEOUT: "PT30M", NANO_READINESS_POLL_EVERY_MS: "15000" } as Record<string, string | undefined>;
13
13
 
14
14
  test("parseFeatureReadiness: no intake ⇒ empty probes, null bound (gate skipped)", () => {
15
- assertEquals(parseFeatureReadiness(undefined, ENV), { probes: [], probeTimeout: null });
16
- assertEquals(parseFeatureReadiness({}, ENV), { probes: [], probeTimeout: null });
17
- assertEquals(parseFeatureReadiness({ readiness: [], blockedOn: [] }, ENV), { probes: [], probeTimeout: null });
15
+ assertEquals(parseFeatureReadiness(undefined, ENV), { probes: [], probeTimeout: null, probePollEvery: null });
16
+ assertEquals(parseFeatureReadiness({}, ENV), { probes: [], probeTimeout: null, probePollEvery: null });
17
+ assertEquals(parseFeatureReadiness({ readiness: [], blockedOn: [] }, ENV), { probes: [], probeTimeout: null, probePollEvery: null });
18
18
  });
19
19
 
20
20
  test("parseFeatureReadiness: blockedOn + consumerPackage ⇒ capability probes with derived bound", () => {
@@ -32,6 +32,7 @@ test("parseFeatureReadiness: blockedOn + consumerPackage ⇒ capability probes w
32
32
  assertEquals(out.probes[1].match?.capabilityRef, "nanobpm/nano-bpm#808");
33
33
  // Every derived probe shares the env default, so the bound is that default.
34
34
  assertEquals(out.probeTimeout, "PT30M");
35
+ assertEquals(out.probePollEvery, "PT15S");
35
36
  });
36
37
 
37
38
  test("parseFeatureReadiness: blockedOn without consumerPackage ⇒ command state probes (merged-is-enough)", () => {
@@ -43,6 +44,7 @@ test("parseFeatureReadiness: blockedOn without consumerPackage ⇒ command state
43
44
  onTimeout: "escalate",
44
45
  });
45
46
  assertEquals(out.probeTimeout, "PT30M");
47
+ assertEquals(out.probePollEvery, "PT15S");
46
48
  });
47
49
 
48
50
  test("parseFeatureReadiness: full readiness descriptors round-trip through parseProbe", () => {
@@ -82,6 +84,7 @@ test("parseFeatureReadiness: a longer per-probe budget wins the derived bound",
82
84
  ENV,
83
85
  );
84
86
  assertEquals(out.probeTimeout, "PT3600S");
87
+ assertEquals(out.probePollEvery, "PT15S");
85
88
  });
86
89
 
87
90
  test("parseFeatureReadiness: a bare repo#N handle is rejected (cannot name a provenance repo)", () => {
@@ -26,6 +26,7 @@ import {
26
26
  DEFAULT_READINESS_TIMEOUT,
27
27
  parseProbe,
28
28
  type ReadinessProbe,
29
+ readinessPollEvery,
29
30
  readinessTimeout,
30
31
  } from "./readiness.ts";
31
32
  import { isoDurationToMs } from "./reviewWait.ts";
@@ -47,6 +48,7 @@ export interface FeatureReadinessInput {
47
48
  export interface FeatureReadiness {
48
49
  readonly probes: ReadinessProbe[];
49
50
  readonly probeTimeout: string | null;
51
+ readonly probePollEvery: string | null;
50
52
  }
51
53
 
52
54
  function isNonEmptyString(v: unknown): v is string {
@@ -104,7 +106,7 @@ function desugarHandle(handle: string, consumerPackage: string | null): Readines
104
106
  }
105
107
 
106
108
  /** Parse + desugar a feature's optional intake readiness into the gate's `readinessProbes` +
107
- * `probeTimeout`. Accepts EITHER the full `readiness` descriptor list OR the `blockedOn` shorthand
109
+ * `probeTimeout`/`probePollEvery`. Accepts EITHER the full `readiness` descriptor list OR the `blockedOn` shorthand
108
110
  * (or both — they concatenate). Returns an empty probe set (gate skipped) when neither is present.
109
111
  *
110
112
  * Throws a descriptive error on a malformed descriptor (via {@link parseProbe}), a `blockedOn` entry
@@ -139,7 +141,7 @@ export function parseFeatureReadiness(
139
141
  }
140
142
  }
141
143
 
142
- if (probes.length === 0) return { probes: [], probeTimeout: null };
144
+ if (probes.length === 0) return { probes: [], probeTimeout: null, probePollEvery: null };
143
145
 
144
146
  // One bound governs the whole preflight's escalation timers — the LONGEST of the probes' derived
145
147
  // timeouts (via the canonical `readinessTimeout`), so no probe is cut short. Mirrors the epic
@@ -147,5 +149,8 @@ export function parseFeatureReadiness(
147
149
  const probeTimeout = probes
148
150
  .map((p) => readinessTimeout(p, env))
149
151
  .reduce((a, b) => (isoDurationToMs(b, DEFAULT_READINESS_TIMEOUT) > isoDurationToMs(a, DEFAULT_READINESS_TIMEOUT) ? b : a));
150
- return { probes, probeTimeout };
152
+ const probePollEvery = probes
153
+ .map((p) => readinessPollEvery(p, env))
154
+ .reduce((a, b) => (isoDurationToMs(b, DEFAULT_READINESS_TIMEOUT) < isoDurationToMs(a, DEFAULT_READINESS_TIMEOUT) ? b : a));
155
+ return { probes, probeTimeout, probePollEvery };
151
156
  }
package/app/plan.test.ts CHANGED
@@ -350,7 +350,7 @@ test("startPlan fails fast when readiness probes are seeded without a probeTimeo
350
350
  engine,
351
351
  { repo: "owner/repo", number: 292, url: "https://github.com/owner/repo/issues/292", planKey: PLAN_KEY },
352
352
  "epic/gate-branch",
353
- { readinessProbes: [probe] as any, probeTimeout: " " },
353
+ { readinessProbes: [probe] as any, probeTimeout: " ", probePollEvery: "PT15S" },
354
354
  ),
355
355
  Error,
356
356
  "probeTimeout",
package/app/plan.ts CHANGED
@@ -964,6 +964,7 @@ function assertAcyclic(adjacency: Map<string, Set<string>>): void {
964
964
  export interface StartPlanOptions {
965
965
  readinessProbes?: ReadinessProbe[];
966
966
  probeTimeout?: string;
967
+ probePollEvery?: string;
967
968
  }
968
969
 
969
970
  /** Register a plan row (if new) and start the plan-fanout process. Idempotent on
@@ -988,6 +989,13 @@ export async function startPlan(
988
989
  "bound. Derive it via readinessTimeout (see planLowering) before starting a gated dependent.",
989
990
  );
990
991
  }
992
+ if (probes && (opts.probePollEvery ?? "").trim() === "") {
993
+ throw new Error(
994
+ `startPlan(${parsed.planKey}): ${probes.length} readiness probe(s) seeded without a probePollEvery — ` +
995
+ "the preflight retry timers (=probePollEvery) require a non-blank cadence. Derive it via " +
996
+ "readinessPollEvery (see planLowering) before starting a gated dependent.",
997
+ );
998
+ }
991
999
  const table = plans(data);
992
1000
  const existing = await table.get(parsed.planKey);
993
1001
  if (existing && !PLAN_TERMINAL_STATUSES.includes(existing.status)) {
@@ -1108,6 +1116,7 @@ export async function startPlan(
1108
1116
  // the variable in that FEEL expression instead of raising an incident.
1109
1117
  readinessProbes: probes,
1110
1118
  probeTimeout: opts.probeTimeout ?? null,
1119
+ probePollEvery: opts.probePollEvery ?? null,
1111
1120
  // The preflight probe worker (`pr.readiness-probe`) requires a non-blank `gateKey` correlation
1112
1121
  // key (it publishes `readiness-ready` on it). The typed `ReadinessProbeIn` envelope projects it
1113
1122
  // from THIS process scope (not task-local ioMapping), so it is seeded here — one per dependent
@@ -48,21 +48,21 @@ test("the preflight is a multi-instance subprocess over =readinessProbes collect
48
48
  });
49
49
 
50
50
  test("the preflight reuses the pr.readiness-probe worker and the readiness-escalation form (no reinvention)", () => {
51
- const sub = flat.match(/<bpmn:subProcess\b[^>]*\bid="readiness-preflight"[\s\S]*?<\/bpmn:subProcess>/)![0];
52
- assertStringIncludes(sub, 'type="pr.readiness-probe"', "reuses the existing capability probe worker");
53
- assertStringIncludes(sub, 'value="ReadinessProbeIn"', "feeds the shared probe input envelope");
54
- assertStringIncludes(sub, 'value="ReadinessProbeOut"', "reads the shared probe output envelope");
55
- assertStringIncludes(sub, 'formId="readiness-escalation"', "reuses the existing readiness escalation form");
51
+ assertStringIncludes(flat, 'type="pr.readiness-probe"', "reuses the existing capability probe worker");
52
+ assertStringIncludes(flat, 'value="ReadinessProbeIn"', "feeds the shared probe input envelope");
53
+ assertStringIncludes(flat, 'value="ReadinessProbeOut"', "reads the shared probe output envelope");
54
+ assertStringIncludes(flat, 'formId="readiness-escalation"', "reuses the existing readiness escalation form");
56
55
  });
57
56
 
58
57
  test("a never-green producer escalates (bounded) without wedging: probe timeout + SLA both settle the gate", () => {
59
- const sub = flat.match(/<bpmn:subProcess\b[^>]*\bid="readiness-preflight"[\s\S]*?<\/bpmn:subProcess>/)![0];
60
- // The probe carries an interrupting timeout bound (reuses the gate's =probeTimeout), and the human
61
- // escalation carries the shared SLA bound so a stuck producer can never wedge the dependent.
62
- assertStringIncludes(sub, "=probeTimeout", "the probe is bounded by the reused =probeTimeout");
63
- assertStringIncludes(sub, "=escalationSlaTimeout", "the escalation is bounded by the shared SLA");
64
- assert(hasFlow("be_pf_probe_timeout", "readiness-escalation-pf"), "a timed-out probe routes to escalation");
65
- assert(hasFlow("pf_gw", "readiness-escalation-pf"), "a not-ready probe routes to escalation");
58
+ // The probe loop carries an interrupting timeout bound (reuses the gate's =probeTimeout), and the
59
+ // human escalation carries the shared SLA bound so a stuck producer can never wedge the dependent.
60
+ assertStringIncludes(flat, "=probeTimeout", "the probe loop is bounded by the reused =probeTimeout");
61
+ assertStringIncludes(flat, "=probePollEvery", "retry cadence is driven by the engine timer");
62
+ assertStringIncludes(flat, "=escalationSlaTimeout", "the escalation is bounded by the shared SLA");
63
+ assert(hasFlow("be_pf_probe_timeout", "preflight-probe-last-attempt"), "a timed-out loop routes to one last empirical probe");
64
+ assert(hasFlow("preflight-probe-last-attempt", "pf_gw"), "the last attempt can still take the ready path");
65
+ assert(hasFlow("pf_gw", "readiness-escalation-pf"), "a not-ready final probe routes to escalation");
66
66
  assert(hasFlow("be_pf_sla", "pf_end"), "an elapsed escalation SLA settles the preflight instead of wedging");
67
67
  });
68
68
 
@@ -139,6 +139,7 @@ test("deriveEpicSchedule: a dependent with MULTIPLE inbound edges waits for ALL
139
139
  assertEquals(dep.producers.sort(), ["o/r#1", "o/r#2"]);
140
140
  assertEquals(dep.probes.length, 2); // one probe per producer — must satisfy both to fan out
141
141
  assert(dep.probeTimeout.startsWith("PT") || dep.probeTimeout.startsWith("P"), "an ISO-8601 bound");
142
+ assert(dep.probePollEvery.startsWith("PT") || dep.probePollEvery.startsWith("P"), "an ISO-8601 cadence");
142
143
  });
143
144
 
144
145
  // ── lowerAdmittedSet ────────────────────────────────────────────────────────────────────────────
@@ -160,6 +161,7 @@ test("lowerAdmittedSet starts roots with no probe and dependents with their seed
160
161
  const depProbes = byKey.get("o/r#2")?.["readinessProbes"] as unknown[] | null;
161
162
  assert(Array.isArray(depProbes) && depProbes.length === 1, "dependent seeded with one capability probe");
162
163
  assert(byKey.get("o/r#2")?.["probeTimeout"] != null, "dependent seeded with a bounded timeout");
164
+ assert(byKey.get("o/r#2")?.["probePollEvery"] != null, "dependent seeded with a poll cadence");
163
165
 
164
166
  // Durable edge materialized (after the plans rows exist), and a plans row per epic.
165
167
  assertEquals((tables.get("plan_deps") ?? []).length, 1);
@@ -35,7 +35,7 @@ import {
35
35
  recordPlanDep,
36
36
  startPlan,
37
37
  } from "./plan.ts";
38
- import { type ReadinessProbe, readinessTimeout } from "./readiness.ts";
38
+ import { type ReadinessProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
39
39
 
40
40
  /** Derive the `capability` readiness probe for ONE inbound inter-epic edge: it goes green when the
41
41
  * producer epic (`depends_on_plan_key`) has published a release of `package` whose provenance carries
@@ -64,6 +64,7 @@ export interface DependentGate {
64
64
  probes: ReadinessProbe[];
65
65
  producers: string[];
66
66
  probeTimeout: string;
67
+ probePollEvery: string;
67
68
  }
68
69
 
69
70
  /** The pure schedule derived from a validated set: the ROOTS to start immediately and the
@@ -104,7 +105,10 @@ export function deriveEpicSchedule(
104
105
  const probeTimeout = probes
105
106
  .map((p) => readinessTimeout(p, env))
106
107
  .reduce((a, b) => (isoLonger(a, b) ? a : b));
107
- dependents.push({ planKey, probes, producers: edgesForKey.map((e) => e.depends_on_plan_key), probeTimeout });
108
+ const probePollEvery = probes
109
+ .map((p) => readinessPollEvery(p, env))
110
+ .reduce((a, b) => (isoLonger(a, b) ? b : a));
111
+ dependents.push({ planKey, probes, producers: edgesForKey.map((e) => e.depends_on_plan_key), probeTimeout, probePollEvery });
108
112
  }
109
113
  return { roots, dependents };
110
114
  }
@@ -161,6 +165,7 @@ export async function lowerAdmittedSet(
161
165
  await startPlan(data, engine, parsed, staged.base_branch, {
162
166
  readinessProbes: gate?.probes,
163
167
  probeTimeout: gate?.probeTimeout,
168
+ probePollEvery: gate?.probePollEvery,
164
169
  });
165
170
  }
166
171
 
@@ -37,6 +37,7 @@ import {
37
37
  type ProbeExec,
38
38
  type PrObservation,
39
39
  prViewCommand,
40
+ readinessPollEvery,
40
41
  readinessTimeout,
41
42
  readinessTimeoutMs,
42
43
  redactString,
@@ -585,6 +586,14 @@ test("readinessTimeoutMs: the ms twin of readinessTimeout — same precedence, n
585
586
  );
586
587
  });
587
588
 
589
+
590
+ test("readinessPollEvery: derives the engine retry cadence from descriptor/env/default with clamp", () => {
591
+ assertEquals(readinessPollEvery(parseProbe({ kind: "http", target: "x", poll: { everyMs: 1500 } }), {}), "PT2S");
592
+ assertEquals(readinessPollEvery(parseProbe({ kind: "http", target: "x" }), { NANO_READINESS_POLL_EVERY_MS: "2500" }), "PT3S");
593
+ assertEquals(readinessPollEvery(parseProbe({ kind: "http", target: "x", poll: { everyMs: MAX_EVERY_MS + 1 } }), {}), msToIsoDuration(MAX_EVERY_MS));
594
+ assertEquals(readinessPollEvery(parseProbe({ kind: "http", target: "x" }), { NANO_READINESS_POLL_EVERY_MS: "bad" }), msToIsoDuration(DEFAULT_EVERY_MS));
595
+ });
596
+
588
597
  test("probeBudgetMs: prefers the seeded probeTimeout (the gate timer's bound), falling back to the env twin", () => {
589
598
  const probe = parseProbe({ kind: "http", target: "x" });
590
599
  // The seeded probeTimeout wins over the ambient env — binding worker and engine to ONE per-instance
package/app/readiness.ts CHANGED
@@ -776,6 +776,31 @@ export function readinessTimeout(
776
776
  return isoDuration(readEnvOr("NANO_READINESS_POLL_TIMEOUT", DEFAULT_READINESS_TIMEOUT, env), DEFAULT_READINESS_TIMEOUT);
777
777
  }
778
778
 
779
+ /** The poll cadence (an ISO-8601 duration) seeded onto a readiness-gate instance as `probePollEvery`:
780
+ * the descriptor's `poll.everyMs` when present, else `NANO_READINESS_POLL_EVERY_MS`, else the built-in
781
+ * {@link DEFAULT_EVERY_MS}, clamped to {@link MAX_EVERY_MS}. Since Option A (#428), the engine — not the
782
+ * worker — owns the retry cadence: the `wait-poll` timers in `readiness-gate.bpmn` (and the preflight
783
+ * loops in `feature.bpmn`/`plan-fanout.bpmn`) read `=probePollEvery`, re-activating the now single-shot
784
+ * `pr.readiness-probe` once per interval. Derived here so whoever seeds a gate derives the cadence from
785
+ * ONE place (mirroring {@link readinessTimeout} for the bound), and worker/engine can never drift.
786
+ * `msToIsoDuration` rounds up (via `Math.ceil`) to a whole second, with a one-second minimum, so the
787
+ * timer never rounds to an immediately-refiring zero-length duration (which would reintroduce a
788
+ * busy-spin — the very defect Option A removes). */
789
+ export function readinessPollEvery(
790
+ probe: ReadinessProbe,
791
+ env: Record<string, string | undefined> = process.env,
792
+ ): string {
793
+ const declared = probe.poll?.everyMs;
794
+ const ms =
795
+ typeof declared === "number" && declared >= 1
796
+ ? Math.min(Math.trunc(declared), MAX_EVERY_MS)
797
+ : (() => {
798
+ const envEvery = Number(readEnvOr("NANO_READINESS_POLL_EVERY_MS", String(DEFAULT_EVERY_MS), env));
799
+ return Number.isFinite(envEvery) && envEvery >= 1 ? Math.min(Math.trunc(envEvery), MAX_EVERY_MS) : DEFAULT_EVERY_MS;
800
+ })();
801
+ return msToIsoDuration(ms);
802
+ }
803
+
779
804
  /** The effective gate budget in **milliseconds** — the ms twin of {@link readinessTimeout}, resolved
780
805
  * by the SAME precedence (descriptor `poll.timeoutMs`, else `NANO_READINESS_POLL_TIMEOUT`, else the
781
806
  * built-in default) and sharing its env key + default. The worker's local poll budget MUST use this