@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.
- package/CHANGELOG.md +15 -0
- package/app/capabilityNeed.test.ts +4 -2
- package/app/capabilityNeed.ts +3 -1
- package/app/deliveryGraphCompiler.test.ts +72 -44
- package/app/deliveryGraphCompiler.ts +121 -19
- package/app/deliveryGraphRun.test.ts +2 -2
- package/app/deliveryRunner.test.ts +29 -17
- package/app/deliveryRunner.ts +31 -10
- package/app/feature.test.ts +3 -1
- package/app/feature.ts +9 -0
- package/app/featureReadiness.test.ts +7 -4
- package/app/featureReadiness.ts +8 -3
- package/app/plan.test.ts +1 -1
- package/app/plan.ts +9 -0
- package/app/planFanoutPreflight.test.ts +12 -12
- package/app/planLowering.test.ts +2 -0
- package/app/planLowering.ts +7 -2
- package/app/pollUserTasks.test.ts +49 -1
- package/app/readiness.test.ts +9 -0
- package/app/readiness.ts +25 -0
- package/app/service.ts +24 -6
- package/biome.json +24 -1
- package/e2e/delivery-graph.e2e.ts +2 -1
- package/e2e/feature-preflight.e2e.ts +2 -0
- package/e2e/inter-epic-dependency.e2e.ts +7 -1
- package/e2e/plan-fanout-preflight.e2e.ts +2 -0
- package/e2e/readiness-gate.e2e.ts +27 -11
- package/operations/compileDeliveryGraph.test.ts +3 -0
- package/operations/compileDeliveryGraph.ts +1 -1
- package/operations/dispatchDeliveryGraph.ts +1 -1
- package/operations/previewDeliveryGraph.ts +1 -1
- package/operations/startDeliveryGraph.ts +1 -1
- package/package.json +3 -2
- package/resources/processes/feature.bpmn +168 -52
- package/resources/processes/plan-fanout.bpmn +168 -52
- package/resources/processes/readiness-gate.bpmn +194 -84
- package/workers/readiness-probe/worker.test.ts +82 -238
- package/workers/readiness-probe/worker.ts +46 -128
|
@@ -29,31 +29,42 @@ const GRAPH: DeliveryGraph = {
|
|
|
29
29
|
],
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
-
function prepareOk(graph: DeliveryGraph, options = {}) {
|
|
33
|
-
const r = prepareDeliveryGraph(graph, options);
|
|
32
|
+
async function prepareOk(graph: DeliveryGraph, options = {}) {
|
|
33
|
+
const r = await prepareDeliveryGraph(graph, options);
|
|
34
34
|
assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
|
|
35
35
|
return r.prepared;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
test("content-addressed id: deterministic for the same graph, content-sensitive across graphs", () => {
|
|
39
|
-
const a = prepareOk(GRAPH);
|
|
40
|
-
const b = prepareOk(GRAPH);
|
|
38
|
+
test("content-addressed id: deterministic for the same graph, content-sensitive across graphs", async () => {
|
|
39
|
+
const a = await prepareOk(GRAPH);
|
|
40
|
+
const b = await prepareOk(GRAPH);
|
|
41
41
|
assert(/^delivery-graph-[0-9a-f]{12}$/.test(a.processDefinitionId), `id is content-addressed, got ${a.processDefinitionId}`);
|
|
42
42
|
assertEquals(a.processDefinitionId, b.processDefinitionId);
|
|
43
43
|
|
|
44
44
|
// A structurally different graph gets a DIFFERENT id (no collision / no accidental redeploy-as-same).
|
|
45
|
-
const other = prepareOk({ ...GRAPH, nodes: [...GRAPH.nodes, { id: "extra", kind: "agent", agent: { jobType: "senior:feature" } }], edges: [...GRAPH.edges, { from: "consume", to: "extra" }] });
|
|
45
|
+
const other = await prepareOk({ ...GRAPH, nodes: [...GRAPH.nodes, { id: "extra", kind: "agent", agent: { jobType: "senior:feature" } }], edges: [...GRAPH.edges, { from: "consume", to: "extra" }] });
|
|
46
46
|
assert(other.processDefinitionId !== a.processDefinitionId, "a different graph yields a different id");
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
test("the deployable BPMN rewrites the base process id to the content-addressed deploy id", () => {
|
|
50
|
-
const p = prepareOk(GRAPH);
|
|
49
|
+
test("the deployable BPMN rewrites the base process id to the content-addressed deploy id", async () => {
|
|
50
|
+
const p = await prepareOk(GRAPH);
|
|
51
51
|
assert(p.bpmn.includes(`<bpmn:process id="${p.processDefinitionId}"`), "process id is the content-addressed id");
|
|
52
52
|
assert(!p.bpmn.includes('<bpmn:process id="delivery-graph"'), "the base id no longer appears as the process id");
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
test("
|
|
56
|
-
|
|
55
|
+
test("DI (#440): the deployable definition carries diagram interchange bound to the rewritten process id", async () => {
|
|
56
|
+
// The DEPLOYED definition (not just the compile preview) must render in the process explorer, so it
|
|
57
|
+
// carries the auto-laid-out `bpmndi:BPMNDiagram`. The top-level plane's `bpmnElement` reference is
|
|
58
|
+
// rewritten in lock-step with the process id, otherwise the deployed diagram would dangle and render
|
|
59
|
+
// positionless — the exact bug #440 fixes.
|
|
60
|
+
const p = await prepareOk(GRAPH);
|
|
61
|
+
assert(p.bpmn.includes("<bpmndi:BPMNDiagram"), "deployable bpmn carries a diagram");
|
|
62
|
+
assert(p.bpmn.includes(`bpmnElement="${p.processDefinitionId}"`), "the plane binds to the content-addressed id");
|
|
63
|
+
assert(!p.bpmn.includes('bpmnElement="delivery-graph"'), "no dangling reference to the base process id remains");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMapping reads", async () => {
|
|
67
|
+
const p = await prepareOk(GRAPH, { nodeTimeout: "PT10M", probeTimeout: "PT20M", escalationSlaTimeout: "PT2H", escalationAssignee: "alice", runKey: "run-7" });
|
|
57
68
|
// Element ids are positional by sorted node id: consume, open-b, publish, watch-b → n0..n3.
|
|
58
69
|
const inputs = p.nodeInputs;
|
|
59
70
|
const byField = (pred: (v: Record<string, unknown>) => boolean) => Object.values(inputs).find((v) => pred(v as Record<string, unknown>)) as Record<string, unknown> | undefined;
|
|
@@ -64,6 +75,7 @@ test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMappin
|
|
|
64
75
|
const wait = byField((v) => "gateKey" in v);
|
|
65
76
|
assertEquals(wait?.gateKey, "run-7:n3");
|
|
66
77
|
assertEquals(wait?.probeTimeout, "PT20M");
|
|
78
|
+
assertEquals(wait?.probePollEvery, "PT15S");
|
|
67
79
|
assert(wait?.probe && typeof wait.probe === "object", "the wait node carries its ReadinessProbe descriptor");
|
|
68
80
|
|
|
69
81
|
const human = byField((v) => "escalationSlaTimeout" in v);
|
|
@@ -73,12 +85,12 @@ test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMappin
|
|
|
73
85
|
assertEquals(connector, { target: "npm:install", dedupeKey: "consume-1", payload: null, timeout: "PT10M" });
|
|
74
86
|
});
|
|
75
87
|
|
|
76
|
-
test("wait gateKeys default to a fresh per-run token so concurrent runs of one graph never cross-correlate", () => {
|
|
77
|
-
const gateKeyOf = (p: ReturnType<typeof prepareOk
|
|
88
|
+
test("wait gateKeys default to a fresh per-run token so concurrent runs of one graph never cross-correlate", async () => {
|
|
89
|
+
const gateKeyOf = (p: Awaited<ReturnType<typeof prepareOk>>) =>
|
|
78
90
|
(Object.values(p.nodeInputs).find((v) => "gateKey" in v) as { gateKey?: string } | undefined)?.gateKey;
|
|
79
91
|
|
|
80
|
-
const a = prepareOk(GRAPH);
|
|
81
|
-
const b = prepareOk(GRAPH);
|
|
92
|
+
const a = await prepareOk(GRAPH);
|
|
93
|
+
const b = await prepareOk(GRAPH);
|
|
82
94
|
assert(gateKeyOf(a) && gateKeyOf(b), "each run seeds a wait gateKey");
|
|
83
95
|
assert(gateKeyOf(a) !== gateKeyOf(b), "two runs of the same graph get DISTINCT default gate scopes");
|
|
84
96
|
// The gate key must NOT be derived from the (shared) content digest — that is the bug this guards.
|
|
@@ -88,12 +100,12 @@ test("wait gateKeys default to a fresh per-run token so concurrent runs of one g
|
|
|
88
100
|
assertEquals(a.bpmn, b.bpmn);
|
|
89
101
|
|
|
90
102
|
// An explicit runKey is honoured verbatim (reproducible seed).
|
|
91
|
-
const seeded = prepareOk(GRAPH, { runKey: "run-7" });
|
|
103
|
+
const seeded = await prepareOk(GRAPH, { runKey: "run-7" });
|
|
92
104
|
assertEquals(gateKeyOf(seeded), "run-7:n3");
|
|
93
105
|
});
|
|
94
106
|
|
|
95
|
-
test("a malformed graph returns the S1 compile errors and prepares nothing", () => {
|
|
96
|
-
const r = prepareDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] } as unknown as DeliveryGraph);
|
|
107
|
+
test("a malformed graph returns the S1 compile errors and prepares nothing", async () => {
|
|
108
|
+
const r = await prepareDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] } as unknown as DeliveryGraph);
|
|
97
109
|
assert(!r.ok, "a dangling edge fails to prepare");
|
|
98
110
|
assert(r.errors.some((e) => e.path === "edges[0].to"), `expected a dangling-edge error, got ${JSON.stringify(r.errors)}`);
|
|
99
111
|
});
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -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
|
|
|
@@ -93,8 +97,11 @@ export type RunDeliveryResult =
|
|
|
93
97
|
* so each call's `wait` `gateKey`s differ (two concurrent runs of the same graph never cross-correlate);
|
|
94
98
|
* pass an explicit `runKey` for a reproducible seed. Returns the S1 compile errors verbatim for a
|
|
95
99
|
* malformed graph. */
|
|
96
|
-
export function prepareDeliveryGraph(
|
|
97
|
-
|
|
100
|
+
export async function prepareDeliveryGraph(
|
|
101
|
+
graph: DeliveryGraph,
|
|
102
|
+
options: DeliveryRunOptions = {},
|
|
103
|
+
): Promise<PrepareDeliveryResult> {
|
|
104
|
+
const compiled = await compileDeliveryGraph(graph);
|
|
98
105
|
if (!compiled.ok) return { ok: false, errors: compiled.errors };
|
|
99
106
|
|
|
100
107
|
const digest = deliveryGraphDigest(compiled.bpmn);
|
|
@@ -105,6 +112,7 @@ export function prepareDeliveryGraph(graph: DeliveryGraph, options: DeliveryRunO
|
|
|
105
112
|
const timeouts = {
|
|
106
113
|
nodeTimeout: options.nodeTimeout ?? DEFAULTS.nodeTimeout,
|
|
107
114
|
probeTimeout: options.probeTimeout ?? DEFAULTS.probeTimeout,
|
|
115
|
+
probePollEvery: options.probePollEvery ?? DEFAULTS.probePollEvery,
|
|
108
116
|
escalationSlaTimeout: options.escalationSlaTimeout ?? DEFAULTS.escalationSlaTimeout,
|
|
109
117
|
escalationAssignee: options.escalationAssignee ?? null,
|
|
110
118
|
};
|
|
@@ -127,7 +135,7 @@ export async function runDeliveryGraph(
|
|
|
127
135
|
graph: DeliveryGraph,
|
|
128
136
|
options: DeliveryRunOptions = {},
|
|
129
137
|
): Promise<RunDeliveryResult> {
|
|
130
|
-
const prep = prepareDeliveryGraph(graph, options);
|
|
138
|
+
const prep = await prepareDeliveryGraph(graph, options);
|
|
131
139
|
if (!prep.ok) return prep;
|
|
132
140
|
const { processDefinitionId, bpmn, nodeInputs } = prep.prepared;
|
|
133
141
|
|
|
@@ -145,23 +153,36 @@ export async function runDeliveryGraph(
|
|
|
145
153
|
}
|
|
146
154
|
|
|
147
155
|
/** Rewrite the compiled BPMN's base `bpmn:process` id to the content-addressed deploy id. The base id
|
|
148
|
-
* appears exactly once
|
|
149
|
-
* `End`, never the process id)
|
|
156
|
+
* appears exactly once as the process element's `id` attribute (element ids are `n<i>`/`gw*`/`Start`/
|
|
157
|
+
* `End`, never the process id), and once more as the top-level `bpmndi:BPMNPlane`'s `bpmnElement`
|
|
158
|
+
* reference back to that process (the diagram interchange the compiler now attaches, #440). Both must
|
|
159
|
+
* move together, otherwise the deployed definition carries a DANGLING plane reference and renders
|
|
160
|
+
* positionless — the very bug DI was added to fix. Nested sub-process planes reference `n<i>` element
|
|
161
|
+
* ids, which are untouched. */
|
|
150
162
|
function rewriteProcessId(bpmn: string, processDefinitionId: string): string {
|
|
151
|
-
return bpmn
|
|
163
|
+
return bpmn
|
|
164
|
+
.replace(`id="${DELIVERY_GRAPH_PROCESS_ID}"`, `id="${processDefinitionId}"`)
|
|
165
|
+
.replace(`bpmnElement="${DELIVERY_GRAPH_PROCESS_ID}"`, `bpmnElement="${processDefinitionId}"`);
|
|
152
166
|
}
|
|
153
167
|
|
|
154
168
|
/** Build the `nodeInputs.<element>` seed for one node, per its kind — the exact fields the compiled
|
|
155
169
|
* subProcess ioMapping pulls. Total over the closed kind set. */
|
|
156
170
|
function buildNodeInput(
|
|
157
171
|
node: DeliveryNode,
|
|
158
|
-
ctx: { runKey: string; element: string; nodeTimeout: string; probeTimeout: string; escalationSlaTimeout: string; escalationAssignee: string | null },
|
|
172
|
+
ctx: { runKey: string; element: string; nodeTimeout: string; probeTimeout: string; probePollEvery: string; escalationSlaTimeout: string; escalationAssignee: string | null },
|
|
159
173
|
): NodeInput {
|
|
160
174
|
switch (node.kind) {
|
|
161
175
|
case "agent":
|
|
162
176
|
return { jobType: node.agent.jobType, appendPrompt: node.agent.prompt ?? "", timeout: ctx.nodeTimeout };
|
|
163
|
-
case "wait":
|
|
164
|
-
|
|
177
|
+
case "wait": {
|
|
178
|
+
const probe = parseProbe(node.wait);
|
|
179
|
+
return {
|
|
180
|
+
gateKey: `${ctx.runKey}:${ctx.element}`,
|
|
181
|
+
probe: node.wait,
|
|
182
|
+
probeTimeout: ctx.probeTimeout,
|
|
183
|
+
probePollEvery: probe.poll?.everyMs ? readinessPollEvery(probe, {}) : ctx.probePollEvery,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
165
186
|
case "human":
|
|
166
187
|
return { escalationSlaTimeout: ctx.escalationSlaTimeout, escalationAssignee: ctx.escalationAssignee };
|
|
167
188
|
case "connector":
|
package/app/feature.test.ts
CHANGED
|
@@ -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)", () => {
|
package/app/featureReadiness.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
52
|
-
assertStringIncludes(
|
|
53
|
-
assertStringIncludes(
|
|
54
|
-
assertStringIncludes(
|
|
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
|
-
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
assertStringIncludes(
|
|
63
|
-
assertStringIncludes(
|
|
64
|
-
assert(hasFlow("be_pf_probe_timeout", "
|
|
65
|
-
assert(hasFlow("
|
|
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
|
|
package/app/planLowering.test.ts
CHANGED
|
@@ -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);
|
package/app/planLowering.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
@@ -535,4 +535,52 @@ test("pollUserTasks (engine-first): pages through a large open set (no first-pag
|
|
|
535
535
|
}
|
|
536
536
|
|
|
537
537
|
assertEquals((stores.user_tasks ?? []).length, 150);
|
|
538
|
-
});
|
|
538
|
+
});
|
|
539
|
+
test("pollUserTasks (engine-first): surfaces an inlined delivery-graph human task, enriched + bucketed as `delivery` (issue #442)", async () => {
|
|
540
|
+
// A delivery-graph `human` node is compiled (S4) as an INLINED user task with a per-node id
|
|
541
|
+
// `delivery-human-task__<node>` — the bare `delivery-human-task` never appears at runtime. The poller's
|
|
542
|
+
// leak guards must recognise it through the single-source-of-truth predicate (`userTaskKindLabel` /
|
|
543
|
+
// `isDeliveryHumanElement`), NOT exact `USER_TASK_KIND_LABELS` membership — else every delivery-graph
|
|
544
|
+
// human gate is silently dropped from the Tasks inbox and no operator can tick it off (merlin task 35002).
|
|
545
|
+
const { data, stores } = memData({
|
|
546
|
+
delivery_graph_runs: [
|
|
547
|
+
{ run_key: "delivery-graph-403eb22e", process_key: "dg-1", status: "running", title: "release runbook" },
|
|
548
|
+
],
|
|
549
|
+
});
|
|
550
|
+
const restore = stubUserTaskSearch([
|
|
551
|
+
{ userTaskKey: "35002", elementId: "delivery-human-task__n1", processInstanceKey: "dg-1", state: "CREATED" },
|
|
552
|
+
]);
|
|
553
|
+
try {
|
|
554
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
555
|
+
} finally {
|
|
556
|
+
restore();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
560
|
+
assertEquals(Object.keys(byKey), ["35002"]);
|
|
561
|
+
assertEquals(byKey["35002"].element_id, "delivery-human-task__n1");
|
|
562
|
+
assertEquals(byKey["35002"].kind_label, "Delivery: human step");
|
|
563
|
+
assertEquals(byKey["35002"].subject_type, "delivery");
|
|
564
|
+
assertEquals(byKey["35002"].subject_key, "delivery-graph-403eb22e");
|
|
565
|
+
assertEquals(byKey["35002"].subject_title, "release runbook");
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("pollUserTasks (engine-first): a delivery-human task on an UNTRACKED run still surfaces (bucketed `delivery`, instance fallback) (issue #442)", async () => {
|
|
569
|
+
// Even with no `delivery_graph_runs` row referencing the instance, the kind implies its aggregate, so
|
|
570
|
+
// the row renders and stays answerable — mirroring the orphaned-escalation guarantee (#358).
|
|
571
|
+
const { data, stores } = memData({});
|
|
572
|
+
const restore = stubUserTaskSearch([
|
|
573
|
+
{ userTaskKey: "35002", elementId: "delivery-human-task__n1", processInstanceKey: "dg-9", state: "CREATED" },
|
|
574
|
+
]);
|
|
575
|
+
try {
|
|
576
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
577
|
+
} finally {
|
|
578
|
+
restore();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
582
|
+
assertEquals(Object.keys(byKey), ["35002"]);
|
|
583
|
+
assertEquals(byKey["35002"].kind_label, "Delivery: human step");
|
|
584
|
+
assertEquals(byKey["35002"].subject_type, "delivery");
|
|
585
|
+
assertEquals(byKey["35002"].subject_key, "dg-9"); // instance fallback — non-blank so it renders
|
|
586
|
+
});
|
package/app/readiness.test.ts
CHANGED
|
@@ -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
|