@nanobpm/nano-workforce 0.120.0 → 0.120.2

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 (38) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/app/capabilityNeed.test.ts +4 -2
  3. package/app/capabilityNeed.ts +3 -1
  4. package/app/deliveryGraphCompiler.test.ts +72 -44
  5. package/app/deliveryGraphCompiler.ts +121 -19
  6. package/app/deliveryGraphRun.test.ts +2 -2
  7. package/app/deliveryRunner.test.ts +29 -17
  8. package/app/deliveryRunner.ts +31 -10
  9. package/app/feature.test.ts +3 -1
  10. package/app/feature.ts +9 -0
  11. package/app/featureReadiness.test.ts +7 -4
  12. package/app/featureReadiness.ts +8 -3
  13. package/app/plan.test.ts +1 -1
  14. package/app/plan.ts +9 -0
  15. package/app/planFanoutPreflight.test.ts +12 -12
  16. package/app/planLowering.test.ts +2 -0
  17. package/app/planLowering.ts +7 -2
  18. package/app/pollUserTasks.test.ts +49 -1
  19. package/app/readiness.test.ts +9 -0
  20. package/app/readiness.ts +25 -0
  21. package/app/service.ts +24 -6
  22. package/biome.json +24 -1
  23. package/e2e/delivery-graph.e2e.ts +2 -1
  24. package/e2e/feature-preflight.e2e.ts +2 -0
  25. package/e2e/inter-epic-dependency.e2e.ts +7 -1
  26. package/e2e/plan-fanout-preflight.e2e.ts +2 -0
  27. package/e2e/readiness-gate.e2e.ts +27 -11
  28. package/operations/compileDeliveryGraph.test.ts +3 -0
  29. package/operations/compileDeliveryGraph.ts +1 -1
  30. package/operations/dispatchDeliveryGraph.ts +1 -1
  31. package/operations/previewDeliveryGraph.ts +1 -1
  32. package/operations/startDeliveryGraph.ts +1 -1
  33. package/package.json +3 -2
  34. package/resources/processes/feature.bpmn +168 -52
  35. package/resources/processes/plan-fanout.bpmn +168 -52
  36. package/resources/processes/readiness-gate.bpmn +194 -84
  37. package/workers/readiness-probe/worker.test.ts +82 -238
  38. package/workers/readiness-probe/worker.ts +46 -128
@@ -1,29 +1,21 @@
1
1
  // pr.readiness-probe — the ReadinessProbe executor (ADR 0001 §2, issue #258).
2
2
  //
3
- // The service-task half of the durable wait-gate (`resources/processes/readiness-gate.bpmn`). It is
4
- // handed a declared `ReadinessProbe` descriptor + a `gateKey` correlation key, and polls the probe
5
- // with backoff until it goes green at which point it publishes the `readiness-ready` message the
6
- // gate's event-based gateway correlates, releasing the wait. It does NOT own the timeout: the gate's
7
- // timer arm is the authoritative bound (a hung or forever-red probe is escalated by the engine, not
8
- // by a poller-side sentinel — ADR 0001 §2 pinned decision 3). Because the probe only *reads*
9
- // readiness, a worker restart simply re-activates this job and re-probes (idempotent / resumable).
3
+ // The service-task half of the durable wait-gate (`resources/processes/readiness-gate.bpmn`). Since
4
+ // #428, the worker performs exactly one probe per activation; retry cadence and timeout bounds live in
5
+ // BPMN timers owned by the deterministic engine. A green probe publishes the `readiness-ready` message
6
+ // the gate's event-based gateway correlates; a red probe returns not-ready and lets the model schedule
7
+ // the next activation or the final timeout path.
10
8
  //
11
9
  // Secrets never appear in the descriptor, a process variable, or a log line: a credential is read at
12
10
  // execution time from the typed env-contract (`credentialEnv` → `readEnv`) and the probe's
13
11
  // target/output is redacted before logging (ADR 0004 pinned decision 2).
14
12
  import type { AppJobHandler } from "@nanobpm/urban";
15
- import { readEnvOr } from "../../app/contracts.ts";
16
13
  import {
17
- DEFAULT_EVERY_MS,
18
14
  defaultProbeExec,
19
15
  makeCapabilityFallback,
20
- nextDelay,
21
- normalizePoll,
22
16
  type ProbeExec,
23
- type ProbePoll,
24
17
  type ProbeResult,
25
18
  parseProbe,
26
- probeBudgetMs,
27
19
  probeOnce,
28
20
  READINESS_READY_MESSAGE,
29
21
  type ReadinessProbe,
@@ -31,26 +23,18 @@ import {
31
23
  } from "../../app/readiness.ts";
32
24
  import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
33
25
 
34
- // Input/output typed off the model data envelopes (`ReadinessProbeIn` / `ReadinessProbeOut` in
35
- // readiness-gate.bpmn), the single source of truth for this worker's wire contract (ADR 0040).
36
- // `probe` is a `nano:reference` to the nested `ReadinessProbe` shape, so it derives to the descriptor.
26
+ // Input/output typed off the model data envelopes (`ReadinessProbeIn` / `ReadinessProbeOut`), the
27
+ // single source of truth for this worker's wire contract (ADR 0040).
37
28
  type In = WorkerInputs["pr.readiness-probe"];
38
29
  type Out = WorkerOutputs["pr.readiness-probe"];
39
30
 
40
- /** The message the gate's event-based gateway correlates on `=gateKey` to release the wait.
41
- * Re-exported from the canonical source (`app/readiness.ts`) so the worker and every out-of-band
42
- * publisher (the review-ready poller, #259) share ONE message name — no drift-prone local twin.
43
- * Re-exports the binding already imported above rather than re-referencing the module. */
31
+ /** The message the gate's event-based gateway correlates on `=gateKey` to release the wait. */
44
32
  export { READINESS_READY_MESSAGE };
45
33
 
46
- const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
47
-
48
34
  /** The canonical gate-payload keys the matcher's `bind` must never override. `bind` is the
49
35
  * kind-agnostic emit primitive (#274 Gap B), but it flows from matcher output into both the
50
- * `readiness-ready` message variables and the worker output — so a matcher that (accidentally or
51
- * maliciously) binds `ready`/`detail` could shadow the canonical payload and break the gate
52
- * contract. Strip them before spreading so a matcher can only ADD outputs, never overwrite the
53
- * shape the gate correlates on. */
36
+ * `readiness-ready` message variables and the worker output — so a matcher that binds `ready`/`detail`
37
+ * could shadow the canonical payload and break the gate contract. */
54
38
  const RESERVED_BIND_KEYS: ReadonlySet<string> = new Set(["ready", "detail"]);
55
39
  export function safeBind(bind?: Record<string, string>): Record<string, string> {
56
40
  if (!bind) return {};
@@ -61,110 +45,52 @@ export function safeBind(bind?: Record<string, string>): Record<string, string>
61
45
  return out;
62
46
  }
63
47
 
64
- /** The effective poll cadence: the descriptor's values, with `everyMs` defaulting through the env
65
- * contract (`NANO_READINESS_POLL_EVERY_MS`) when the descriptor omits it, then the built-in
66
- * defaults/clamps in {@link normalizePoll}. Reads the env value from the injected `env` (not the
67
- * ambient `process.env`) so the loop is deterministic under test. */
68
- function effectivePoll(
69
- poll: ProbePoll | undefined,
70
- env: Record<string, string | undefined>,
71
- ): ReturnType<typeof normalizePoll> {
72
- const envEvery = Number(readEnvOr("NANO_READINESS_POLL_EVERY_MS", String(DEFAULT_EVERY_MS), env));
73
- const everyMs = poll?.everyMs ?? (Number.isFinite(envEvery) && envEvery >= 1 ? envEvery : DEFAULT_EVERY_MS);
74
- return normalizePoll({ everyMs, timeoutMs: poll?.timeoutMs, backoff: poll?.backoff });
48
+ function errorDetail(err: unknown): string {
49
+ return `probe error: ${err instanceof Error ? err.name : "Error"}`;
75
50
  }
76
51
 
77
- /** The core poll loop, factored out with injectable I/O + clock + publisher so it is unit-testable
78
- * without a network, a subprocess, or a real timer. Polls until ready (publishes, returns ready) or
79
- * the local budget is exhausted (returns not-ready the engine timer then bounds the wait). */
80
- export async function pollUntilReady(deps: {
52
+ /** Single activation of the readiness probe. The engine owns retry cadence and the timeout boundary;
53
+ * this function performs one `probeOnce`, publishes only when ready, and optionally performs exactly
54
+ * one empirical fallback on a model-marked last attempt. */
55
+ export async function probeSingleShot(deps: {
81
56
  probe: ReadinessProbe;
82
- gateKey: string;
83
- probeTimeout?: string;
84
57
  exec: ProbeExec;
85
58
  env: Record<string, string | undefined>;
86
- now: () => number;
87
- wait: (ms: number) => Promise<void>;
88
59
  publish: (detail: string, bind?: Record<string, string>) => Promise<void>;
89
- /** An OPTIONAL last-attempt thunk run ONCE at the gate boundary (local budget exhausted) before
90
- * giving up — the seam for the gated empirical fallback (decision 5). A ready result is published
91
- * (with its bind) and returned; anything else keeps the not-ready outcome so the engine timer
92
- * bounds the wait as usual. Kept generic so the loop stays kind-agnostic. */
60
+ lastAttempt?: boolean;
93
61
  fallback?: () => Promise<ProbeResult | null>;
94
62
  log?: (msg: string) => void;
95
63
  }): Promise<ProbeResult> {
96
- const poll = effectivePoll(deps.probe.poll, deps.env);
97
- // The local budget is bound to the gate PER INSTANCE: it adopts the same seeded `probeTimeout`
98
- // process variable the gate's engine timers fire off (`=probeTimeout`), falling back to the env-
99
- // derived twin only when it's absent. Recomputing from the ambient env would let the worker go
100
- // silent while the engine timer is still waiting if the env changed after the instance was seeded.
101
- const deadline = deps.now() + probeBudgetMs(deps.probeTimeout, deps.probe, deps.env);
102
64
  const label = redactTarget(deps.probe);
103
- let attempt = 0;
104
- for (;;) {
105
- const res: ProbeResult = await probeOnce(deps.probe, deps.exec, deps.env).catch((err) => ({
106
- ready: false,
107
- // Never surface the raw error message: a fetch/subprocess error can embed the target URL
108
- // (with query tokens), command fragments, or other secrets. Log only the error class name.
109
- detail: `probe error: ${err instanceof Error ? err.name : "Error"}`,
110
- }));
111
- deps.log?.(`readiness probe ${label} attempt ${attempt + 1}: ${res.detail}`);
112
- if (res.ready) {
113
- await deps.publish(res.detail, res.bind);
114
- return res;
115
- }
116
- attempt += 1;
117
- const remaining = deadline - deps.now();
118
- if (remaining <= 0) {
119
- // The gate boundary: the deterministic poll is exhausted. Give the gated fallback (if any) ONE
120
- // empirical attempt before conceding to the engine timer — a capability provenance under-reports
121
- // can still resolve here, exactly once, never per unrelated release.
122
- const settled = deps.fallback
123
- ? await deps.fallback().catch((err) => {
124
- // Never swallow a fallback failure silently — it degrades to "not ready" and is hard
125
- // to diagnose. Log only the error class name (no message), consistent with the main
126
- // probeOnce error handling, so a target URL/token in the message never leaks.
127
- deps.log?.(
128
- `readiness probe ${label} fallback error: ${err instanceof Error ? err.name : "Error"}`,
129
- );
130
- return null;
131
- })
132
- : null;
133
- if (settled?.ready) {
134
- deps.log?.(`readiness probe ${label} fallback: ${settled.detail}`);
135
- await deps.publish(settled.detail, settled.bind);
136
- return settled;
137
- }
138
- // Surface the fallback's (already-redacted) diagnostic when one ran and reported not-ready, so a
139
- // timeout escalation is actionable instead of a generic "budget exhausted". `settled` is null when
140
- // there is no fallback or it threw (logged above), in which case only the generic detail applies.
141
- return {
142
- ready: false,
143
- detail: settled
144
- ? `probe budget exhausted; engine timer bounds the wait (fallback: ${settled.detail})`
145
- : "probe budget exhausted; engine timer bounds the wait",
146
- };
147
- }
148
- // Clamp the sleep to the time left until `deadline` so the worker keeps probing right up to the
149
- // SAME bound the engine timer enforces. Sleeping a full `nextDelay` unconditionally would stop
150
- // probing up to one backoff early — a window where readiness could flip to ready but no
151
- // `readiness-ready` message is published, forcing a spurious timeout escalation.
152
- await deps.wait(Math.min(nextDelay(attempt, poll), remaining));
65
+ const res: ProbeResult = await probeOnce(deps.probe, deps.exec, deps.env).catch((err) => ({
66
+ ready: false,
67
+ detail: errorDetail(err),
68
+ }));
69
+ deps.log?.(`readiness probe ${label}: ${res.detail}`);
70
+ if (res.ready) {
71
+ await deps.publish(res.detail, res.bind);
72
+ return res;
73
+ }
74
+ if (deps.lastAttempt !== true) return res;
75
+
76
+ const settled = deps.fallback
77
+ ? await deps.fallback().catch((err) => {
78
+ deps.log?.(`readiness probe ${label} fallback error: ${err instanceof Error ? err.name : "Error"}`);
79
+ return null;
80
+ })
81
+ : null;
82
+ if (settled?.ready) {
83
+ deps.log?.(`readiness probe ${label} fallback: ${settled.detail}`);
84
+ await deps.publish(settled.detail, settled.bind);
85
+ return settled;
153
86
  }
87
+ return {
88
+ ready: false,
89
+ detail: settled ? `gate boundary reached (fallback: ${settled.detail})` : "gate boundary reached",
90
+ };
154
91
  }
155
92
 
156
- /** Normalize the two required process variables the gate seeds, failing fast when either is unusable.
157
- *
158
- * Both are load-bearing for the worker↔gate binding, so a bad value must surface immediately rather
159
- * than silently degrade:
160
- * • `gateKey` is returned **raw** (only validated on a trimmed view). The gate's message subscription
161
- * binds `correlationKey="=gateKey"` — the *untrimmed* process variable — so the publish key must
162
- * match it byte-for-byte. Trimming the key we publish on would desync a whitespace-seeded `gateKey`
163
- * from the gate's subscription, leaving a green probe to release the wait only via the timeout arm.
164
- * • `probeTimeout` is required by the typed input envelope and drives BOTH the gate's engine timers
165
- * (`=probeTimeout`). A missing/blank value used to fall back silently to the env-derived twin,
166
- * breaking the per-instance bound (and masking a mis-seeded instance until it escalates); fail fast
167
- * instead and pass the raw string through so worker and engine share one seeded bound. */
93
+ /** Normalize the required process variables the gate seeds, failing fast when either is unusable. */
168
94
  export function readGateVars(vars: { gateKey?: unknown; probeTimeout?: unknown }): {
169
95
  gateKey: string;
170
96
  probeTimeout: string;
@@ -179,26 +105,18 @@ export function readGateVars(vars: { gateKey?: unknown; probeTimeout?: unknown }
179
105
 
180
106
  const handler: AppJobHandler<In, Out> = async (job, app) => {
181
107
  const probe = parseProbe(job.variables.probe);
182
- const { gateKey, probeTimeout } = readGateVars(job.variables);
108
+ const { gateKey } = readGateVars(job.variables);
183
109
  const exec = defaultProbeExec();
184
- const result = await pollUntilReady({
110
+ const result = await probeSingleShot({
185
111
  probe,
186
- gateKey,
187
- probeTimeout,
188
112
  exec,
189
113
  env: process.env,
190
- now: () => Date.now(),
191
- wait: sleep,
192
- // The gated empirical fallback (decision 5) — a no-op for every kind but a `capability` probe
193
- // that declares a `verifyCommand`, so the deterministic provenance lookup stays the default.
114
+ lastAttempt: job.variables.lastAttempt === true,
194
115
  fallback: makeCapabilityFallback(probe, exec, process.env),
195
116
  publish: async (detail, bind) => {
196
117
  await app.engine.publishMessage({
197
118
  name: READINESS_READY_MESSAGE,
198
119
  correlationKey: gateKey,
199
- // `bind` is the kind-agnostic emit primitive (#274 Gap B): forward whatever the matcher
200
- // discovered (e.g. `resolvedArtifact`) into the message so the gate surfaces it as output.
201
- // Reserved keys are stripped so a bind can only ADD outputs, never shadow `ready`/`detail`.
202
120
  variables: { ready: true, detail, ...safeBind(bind) },
203
121
  });
204
122
  },