@nanobpm/nano-workforce 0.119.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 +15 -0
- package/app/capabilityNeed.test.ts +4 -2
- package/app/capabilityNeed.ts +3 -1
- package/app/deliveryGraphCompiler.test.ts +2 -2
- package/app/deliveryGraphCompiler.ts +63 -17
- package/app/deliveryRunner.test.ts +1 -0
- package/app/deliveryRunner.ts +16 -4
- 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/lineage.ts +25 -0
- package/app/mergesPerDayView.test.ts +166 -0
- package/app/migration064.test.ts +176 -0
- 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/planWaveSummary.test.ts +4 -2
- package/app/plansReadModel.test.ts +262 -0
- package/app/readiness.test.ts +9 -0
- package/app/readiness.ts +25 -0
- package/app/service.ts +8 -1
- package/biome.json +24 -1
- package/db/migrations/060_plan_wave_rollup.sql +52 -0
- package/db/migrations/061_plan_delivery_rollup.sql +98 -0
- package/db/migrations/062_merges_per_day_view.sql +72 -0
- package/db/migrations/064_lineage_thread_view.sql +75 -0
- 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/package.json +1 -1
- package/pages/epic-detail.page.json +2 -2
- package/pages/lineage.page.json +1 -1
- package/pages/overview.page.json +1 -1
- package/pages/velocity.page.json +1 -1
- package/resources/processes/feature.bpmn +168 -52
- package/resources/processes/plan-fanout.bpmn +168 -52
- package/resources/processes/readiness-gate.bpmn +194 -84
- package/scripts/pages-contract.test.ts +3 -1
- package/workers/readiness-probe/worker.test.ts +82 -238
- package/workers/readiness-probe/worker.ts +46 -128
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Read-model guard for migration 064's `lineage_thread_view` VIEW (epic #412: retire the
|
|
2
|
+
// worker-maintained `lineage_threads` denormalisation in favour of SQL VIEWs for the parts that are
|
|
3
|
+
// clean rollups). Mirrors the derived-read-model test style of app/migration037.test.ts and
|
|
4
|
+
// app/planWaveSummary.test.ts: apply the migration to a real in-memory SQLite DB and assert the
|
|
5
|
+
// VIEW's output over sample rows — so this exercises the real view, not a re-implementation.
|
|
6
|
+
//
|
|
7
|
+
// The view DERIVES the view-expressible identity columns (`kind`, `issue_url`, and an epic/feature
|
|
8
|
+
// thread's `title`) from the `plans` / `feature_runs` origin joins, and PASSES THROUGH the
|
|
9
|
+
// procedural frontier columns (`stage`/`stage_label`/`process_key`/`pr_keys`/`pr_count`/`active`/
|
|
10
|
+
// timestamps) from `lineage_threads`. It must reproduce EXACTLY what `pollLineage` wrote for the
|
|
11
|
+
// migrated columns, so a future drop of those `lineage_threads` columns is behaviour-preserving.
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { DatabaseSync } from "node:sqlite";
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { assertEquals } from "#test-assert";
|
|
17
|
+
|
|
18
|
+
const MIGRATION = fileURLToPath(new URL("../db/migrations/064_lineage_thread_view.sql", import.meta.url));
|
|
19
|
+
|
|
20
|
+
/** A DB with the base shapes the view reads (`lineage_threads`, `plans`, `feature_runs`) plus the
|
|
21
|
+
* view applied. The `lineage_threads` schema also models `kind`/`issue_url`, which the view does
|
|
22
|
+
* NOT read (it derives them from the `plans`/`feature_runs` origin joins) — they are kept here so
|
|
23
|
+
* `addThread` can write exactly what `pollLineage` denormalises. */
|
|
24
|
+
function viewDb(): DatabaseSync {
|
|
25
|
+
const db = new DatabaseSync(":memory:");
|
|
26
|
+
db.exec(
|
|
27
|
+
`CREATE TABLE lineage_threads (
|
|
28
|
+
root_request_key TEXT PRIMARY KEY, kind TEXT, title TEXT, issue_url TEXT, stage TEXT,
|
|
29
|
+
stage_label TEXT, process_key TEXT, pr_keys TEXT, pr_count INTEGER, active INTEGER,
|
|
30
|
+
created_at TEXT, updated_at TEXT);
|
|
31
|
+
CREATE TABLE plans (plan_key TEXT PRIMARY KEY, title TEXT, issue_url TEXT);
|
|
32
|
+
CREATE TABLE feature_runs (feature_key TEXT PRIMARY KEY, title TEXT, issue_url TEXT);`,
|
|
33
|
+
);
|
|
34
|
+
db.exec(readFileSync(MIGRATION, "utf8"));
|
|
35
|
+
return db;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Insert a `lineage_threads` row exactly as `pollLineage` denormalises one. */
|
|
39
|
+
function addThread(
|
|
40
|
+
db: DatabaseSync,
|
|
41
|
+
row: {
|
|
42
|
+
root_request_key: string;
|
|
43
|
+
kind: string;
|
|
44
|
+
title: string | null;
|
|
45
|
+
issue_url: string | null;
|
|
46
|
+
stage: string;
|
|
47
|
+
stage_label: string | null;
|
|
48
|
+
process_key: string | null;
|
|
49
|
+
pr_keys: string | null;
|
|
50
|
+
pr_count: number;
|
|
51
|
+
active: number;
|
|
52
|
+
},
|
|
53
|
+
): void {
|
|
54
|
+
db.prepare(
|
|
55
|
+
`INSERT INTO lineage_threads (root_request_key, kind, title, issue_url, stage, stage_label,
|
|
56
|
+
process_key, pr_keys, pr_count, active, created_at, updated_at)
|
|
57
|
+
VALUES (@root_request_key, @kind, @title, @issue_url, @stage, @stage_label, @process_key,
|
|
58
|
+
@pr_keys, @pr_count, @active, 't0', 't1')`,
|
|
59
|
+
).run(row);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test("lineage_thread_view derives kind/issue_url/title for an epic thread from the plans origin", () => {
|
|
63
|
+
const db = viewDb();
|
|
64
|
+
db.prepare("INSERT INTO plans (plan_key, title, issue_url) VALUES (?, ?, ?)").run(
|
|
65
|
+
"o/r#2",
|
|
66
|
+
"Epic: retire projections",
|
|
67
|
+
"https://github.com/o/r/issues/2",
|
|
68
|
+
);
|
|
69
|
+
// pollLineage wrote the same identity values (denormalised) alongside the procedural frontier.
|
|
70
|
+
addThread(db, {
|
|
71
|
+
root_request_key: "o/r#2",
|
|
72
|
+
kind: "epic",
|
|
73
|
+
title: "Epic: retire projections",
|
|
74
|
+
issue_url: "https://github.com/o/r/issues/2",
|
|
75
|
+
stage: "converging",
|
|
76
|
+
stage_label: "3/5 slices merged, 2 converging",
|
|
77
|
+
process_key: "P-epic",
|
|
78
|
+
pr_keys: '["o/r#20","o/r#21"]',
|
|
79
|
+
pr_count: 5,
|
|
80
|
+
active: 1,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const v = db
|
|
84
|
+
.prepare("SELECT * FROM lineage_thread_view WHERE root_request_key = ?")
|
|
85
|
+
.get("o/r#2") as Record<string, unknown>;
|
|
86
|
+
// Derived from the plans join — identical to what the poller denormalised.
|
|
87
|
+
assertEquals(v.kind, "epic");
|
|
88
|
+
assertEquals(v.title, "Epic: retire projections");
|
|
89
|
+
assertEquals(v.issue_url, "https://github.com/o/r/issues/2");
|
|
90
|
+
// Procedural frontier columns pass through unchanged from lineage_threads.
|
|
91
|
+
assertEquals(v.stage, "converging");
|
|
92
|
+
assertEquals(v.stage_label, "3/5 slices merged, 2 converging");
|
|
93
|
+
assertEquals(v.process_key, "P-epic");
|
|
94
|
+
assertEquals(v.pr_keys, '["o/r#20","o/r#21"]');
|
|
95
|
+
assertEquals(v.pr_count, 5);
|
|
96
|
+
assertEquals(v.active, 1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("lineage_thread_view derives kind/issue_url/title for a feature thread from the feature_runs origin", () => {
|
|
100
|
+
const db = viewDb();
|
|
101
|
+
db.prepare("INSERT INTO feature_runs (feature_key, title, issue_url) VALUES (?, ?, ?)").run(
|
|
102
|
+
"o/r#1",
|
|
103
|
+
"Feature: add widget",
|
|
104
|
+
"https://github.com/o/r/issues/1",
|
|
105
|
+
);
|
|
106
|
+
addThread(db, {
|
|
107
|
+
root_request_key: "o/r#1",
|
|
108
|
+
kind: "feature",
|
|
109
|
+
title: "Feature: add widget",
|
|
110
|
+
issue_url: "https://github.com/o/r/issues/1",
|
|
111
|
+
stage: "merged",
|
|
112
|
+
stage_label: "Merged",
|
|
113
|
+
process_key: "P-feat",
|
|
114
|
+
pr_keys: '["o/r#10"]',
|
|
115
|
+
pr_count: 1,
|
|
116
|
+
active: 0,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const v = db
|
|
120
|
+
.prepare("SELECT * FROM lineage_thread_view WHERE root_request_key = ?")
|
|
121
|
+
.get("o/r#1") as Record<string, unknown>;
|
|
122
|
+
assertEquals(v.kind, "feature");
|
|
123
|
+
assertEquals(v.title, "Feature: add widget");
|
|
124
|
+
assertEquals(v.issue_url, "https://github.com/o/r/issues/1");
|
|
125
|
+
assertEquals(v.stage, "merged");
|
|
126
|
+
assertEquals(v.active, 0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("lineage_thread_view self-roots an origin-less PR: kind 'pr', NULL issue_url, title falls back to the poller value", () => {
|
|
130
|
+
const db = viewDb();
|
|
131
|
+
// No plans / feature_runs row for this root — it is a human/webhook PR that is its own root.
|
|
132
|
+
addThread(db, {
|
|
133
|
+
root_request_key: "o/r#30",
|
|
134
|
+
kind: "pr",
|
|
135
|
+
title: "hotfix: bump dep",
|
|
136
|
+
issue_url: null,
|
|
137
|
+
stage: "converging",
|
|
138
|
+
stage_label: "Converging (round 2)",
|
|
139
|
+
process_key: "P-pr",
|
|
140
|
+
pr_keys: '["o/r#30"]',
|
|
141
|
+
pr_count: 1,
|
|
142
|
+
active: 1,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const v = db
|
|
146
|
+
.prepare("SELECT * FROM lineage_thread_view WHERE root_request_key = ?")
|
|
147
|
+
.get("o/r#30") as Record<string, unknown>;
|
|
148
|
+
assertEquals(v.kind, "pr");
|
|
149
|
+
// issue_url is always NULL for a self-rooted PR, exactly as deriveLineage sets it.
|
|
150
|
+
assertEquals(v.issue_url, null);
|
|
151
|
+
// The PR title is the procedural representative-PR pick, so it comes through from lineage_threads.
|
|
152
|
+
assertEquals(v.title, "hotfix: bump dep");
|
|
153
|
+
assertEquals(v.stage_label, "Converging (round 2)");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("lineage_thread_view reproduces the migrated columns for every thread the poller wrote", () => {
|
|
157
|
+
const db = viewDb();
|
|
158
|
+
db.prepare("INSERT INTO plans (plan_key, title, issue_url) VALUES ('o/r#2', 'Epic', 'u-epic')").run();
|
|
159
|
+
db.prepare("INSERT INTO feature_runs (feature_key, title, issue_url) VALUES ('o/r#1', 'Feat', 'u-feat')").run();
|
|
160
|
+
const rows = [
|
|
161
|
+
{ root_request_key: "o/r#2", kind: "epic", title: "Epic", issue_url: "u-epic", stage: "converging", stage_label: "…", process_key: "a", pr_keys: "[]", pr_count: 2, active: 1 },
|
|
162
|
+
{ root_request_key: "o/r#1", kind: "feature", title: "Feat", issue_url: "u-feat", stage: "merged", stage_label: "Merged", process_key: "b", pr_keys: "[]", pr_count: 1, active: 0 },
|
|
163
|
+
{ root_request_key: "o/r#30", kind: "pr", title: "PR", issue_url: null, stage: "opened", stage_label: "Opened", process_key: null, pr_keys: "[]", pr_count: 1, active: 1 },
|
|
164
|
+
];
|
|
165
|
+
for (const r of rows) addThread(db, r);
|
|
166
|
+
|
|
167
|
+
// The view's migrated columns must equal what pollLineage denormalised, for all three kinds.
|
|
168
|
+
for (const r of rows) {
|
|
169
|
+
const v = db
|
|
170
|
+
.prepare("SELECT kind, title, issue_url FROM lineage_thread_view WHERE root_request_key = ?")
|
|
171
|
+
.get(r.root_request_key) as Record<string, unknown>;
|
|
172
|
+
assertEquals(v.kind, r.kind);
|
|
173
|
+
assertEquals(v.title, r.title);
|
|
174
|
+
assertEquals(v.issue_url, r.issue_url);
|
|
175
|
+
}
|
|
176
|
+
});
|
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
|
|
|
@@ -126,10 +126,12 @@ test("epic-detail projects the wave banner, the per-wave summary, and task→rep
|
|
|
126
126
|
const page = JSON.parse(readFileSync(PAGE, "utf8"));
|
|
127
127
|
const byId = (id: string) => page.nodes.find((n: { id: string }) => n.id === id);
|
|
128
128
|
|
|
129
|
-
// 1. The epic-level wave banner: a prose node reading wave_label + epic_phase off
|
|
129
|
+
// 1. The epic-level wave banner: a prose node reading wave_label + epic_phase off the derived
|
|
130
|
+
// `plan_read_model` VIEW (epic #412 — retiring the worker-maintained plans.wave_label column;
|
|
131
|
+
// the banner now reads the single-source-of-truth view instead of the raw `plans` table).
|
|
130
132
|
const banner = byId("wave-banner");
|
|
131
133
|
assert(banner, "epic detail must show the epic-level wave banner");
|
|
132
|
-
assertEquals(banner.props.data.table, "
|
|
134
|
+
assertEquals(banner.props.data.table, "plan_read_model");
|
|
133
135
|
assert(
|
|
134
136
|
banner.props.data.filter.some((f: { field: string; eqParam?: boolean }) => f.field === "plan_key" && f.eqParam),
|
|
135
137
|
"the banner is scoped to this epic",
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Read-model VIEW coverage for the plans-table wave (022) and delivery (029) projections (epic #412
|
|
2
|
+
// — "Retire worker-maintained denormalized projections in favour of SQL VIEWs").
|
|
3
|
+
//
|
|
4
|
+
// Historically `plans.wave_count`/`current_wave`/`wave_label` (022_plan_wave_progress.sql) were
|
|
5
|
+
// written by the wave workers, and `plans.delivery`/`delivery_label` (029_plan_delivery.sql) by
|
|
6
|
+
// `pollDelivery` (app/service.ts) via the pure `deriveDelivery` (app/delivery.ts) — both denormalised
|
|
7
|
+
// onto `plans` only because "Urban's datasource cannot read a SQL VIEW". That constraint is gone
|
|
8
|
+
// (nano-ide#424), so 060/061 express the SAME projections as DERIVED views. This asserts the views
|
|
9
|
+
// reproduce the previous projections' EXACT values — including the pre-formatted `wave_label` /
|
|
10
|
+
// `delivery_label` display strings — over sample `plans` × `plan_tasks` × `pull_requests` rows, so
|
|
11
|
+
// the wave-1 cleanup can drop the worker write-paths + columns with no behavioural change.
|
|
12
|
+
//
|
|
13
|
+
// The delivery assertions cross-check against the real `deriveDelivery` (the single source of truth
|
|
14
|
+
// the poller used), not a re-implementation; the wave assertions pin the frontier derivation that
|
|
15
|
+
// reproduces the workers' `current_wave` (record-plan starts at 0, advances per landed wave, pins to
|
|
16
|
+
// wave_count-1 on completion).
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { DatabaseSync } from "node:sqlite";
|
|
19
|
+
import { test } from "node:test";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { assert, assertEquals } from "#test-assert";
|
|
22
|
+
import { deriveDelivery } from "./delivery.ts";
|
|
23
|
+
|
|
24
|
+
const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
|
|
25
|
+
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
26
|
+
|
|
27
|
+
// A DB with the base `plans` / `plan_tasks` / `pull_requests` shapes the views read (the `plans`
|
|
28
|
+
// columns `plan_read_model` projects, and the full `plan_tasks` shape 059's `plan_wave_tasks`
|
|
29
|
+
// reads), plus 059→061 applied in order.
|
|
30
|
+
function viewDb(): DatabaseSync {
|
|
31
|
+
const db = new DatabaseSync(":memory:");
|
|
32
|
+
db.exec(
|
|
33
|
+
`CREATE TABLE plans (
|
|
34
|
+
plan_key TEXT PRIMARY KEY, repo TEXT, issue_number INTEGER, issue_url TEXT, title TEXT,
|
|
35
|
+
status TEXT, task_count INTEGER, process_key TEXT, outcome TEXT, created_at TEXT,
|
|
36
|
+
updated_at TEXT, epic_phase TEXT, base_branch TEXT, wait_gate_label TEXT, bound_artifacts TEXT,
|
|
37
|
+
promotion_pr TEXT, promotion_state TEXT, list_bucket TEXT, ack_open INTEGER);
|
|
38
|
+
CREATE TABLE plan_tasks (
|
|
39
|
+
id INTEGER PRIMARY KEY, plan_key TEXT, task_index INTEGER, task_id TEXT, title TEXT,
|
|
40
|
+
prompt TEXT, status TEXT, pr_key TEXT, summary TEXT, created_at TEXT, updated_at TEXT,
|
|
41
|
+
wave INTEGER, open_question TEXT, answer TEXT, draft_pr_key TEXT, corr_key TEXT);
|
|
42
|
+
CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, url TEXT, status TEXT, process_key TEXT);`,
|
|
43
|
+
);
|
|
44
|
+
db.exec(MIG("059_plan_wave_summary.sql"));
|
|
45
|
+
db.exec(MIG("060_plan_wave_rollup.sql"));
|
|
46
|
+
db.exec(MIG("061_plan_delivery_rollup.sql"));
|
|
47
|
+
return db;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface SampleTask {
|
|
51
|
+
status: string;
|
|
52
|
+
wave: number | null;
|
|
53
|
+
pr?: { status: string };
|
|
54
|
+
// A slice that OPENED a PR (so `pr_key` is set and it counts toward `prs_opened`) but whose
|
|
55
|
+
// `pull_requests` row is ABSENT — a DB desync. Mirrors `pollDelivery`'s `MISSING_PR_STATUS`
|
|
56
|
+
// sentinel: the LEFT JOIN yields `status IS NULL`, which `plan_delivery_counts` treats as
|
|
57
|
+
// in-flight (non-terminal), so it can never wrongly promote an epic to `landed`.
|
|
58
|
+
danglingPr?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Sentinel mirroring `pollDelivery`'s `MISSING_PR_STATUS` — the status fed to `deriveDelivery` for a
|
|
62
|
+
// `pr_key` with no `pull_requests` row. Any non-terminal string works (it's counted as in-flight); it
|
|
63
|
+
// exists only to keep the `deriveDelivery` cross-check aligned with the view's `status IS NULL` branch.
|
|
64
|
+
const MISSING_PR_STATUS = "missing";
|
|
65
|
+
|
|
66
|
+
// Insert a plan plus its tasks (and each task's PR, if any). PR keys are derived so the test rows
|
|
67
|
+
// stay terse. Returns the flat `pull_requests.status` list `deriveDelivery` consumes (only tasks
|
|
68
|
+
// that opened a PR), so the delivery assertions can cross-check the view against it.
|
|
69
|
+
function addPlan(db: DatabaseSync, plan_key: string, status: string, tasks: SampleTask[]): string[] {
|
|
70
|
+
db.prepare(
|
|
71
|
+
"INSERT INTO plans (plan_key, repo, issue_number, issue_url, status, task_count, updated_at, list_bucket) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
72
|
+
).run(plan_key, "o/r", 1, `https://gh/${plan_key}`, status, tasks.length, "2026-01-01T00:00:00Z", "active");
|
|
73
|
+
const prStatuses: string[] = [];
|
|
74
|
+
tasks.forEach((t, i) => {
|
|
75
|
+
const prKey = t.pr || t.danglingPr ? `${plan_key}::pr${i}` : null;
|
|
76
|
+
db.prepare(
|
|
77
|
+
"INSERT INTO plan_tasks (plan_key, task_index, task_id, status, pr_key, wave) VALUES (?, ?, ?, ?, ?, ?)",
|
|
78
|
+
).run(plan_key, i, `t${i}`, t.status, prKey, t.wave);
|
|
79
|
+
if (t.danglingPr) {
|
|
80
|
+
// Opened a PR (pr_key set → counts toward prs_opened) but NO `pull_requests` row: the DB desync
|
|
81
|
+
// `pollDelivery` feeds to `deriveDelivery` as MISSING_PR_STATUS (in-flight).
|
|
82
|
+
prStatuses.push(MISSING_PR_STATUS);
|
|
83
|
+
} else if (t.pr && prKey) {
|
|
84
|
+
db.prepare("INSERT INTO pull_requests (pr_key, url, status, process_key) VALUES (?, ?, ?, ?)").run(
|
|
85
|
+
prKey,
|
|
86
|
+
`https://gh/${prKey}`,
|
|
87
|
+
t.pr.status,
|
|
88
|
+
`P${i}`,
|
|
89
|
+
);
|
|
90
|
+
prStatuses.push(t.pr.status);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
return prStatuses;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function delivery(db: DatabaseSync, plan_key: string): { delivery: unknown; delivery_label: unknown } {
|
|
97
|
+
return db.prepare("SELECT delivery, delivery_label FROM plan_delivery WHERE plan_key = ?").get(plan_key) as {
|
|
98
|
+
delivery: unknown;
|
|
99
|
+
delivery_label: unknown;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function counts(db: DatabaseSync, plan_key: string): { prs_opened: number; prs_merged: number; prs_in_flight: number } {
|
|
104
|
+
const r = db
|
|
105
|
+
.prepare("SELECT prs_opened, prs_merged, prs_in_flight FROM plan_delivery_counts WHERE plan_key = ?")
|
|
106
|
+
.get(plan_key) as { prs_opened: number; prs_merged: number; prs_in_flight: number };
|
|
107
|
+
return { ...r };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function waveLabel(db: DatabaseSync, plan_key: string): Record<string, unknown> | undefined {
|
|
111
|
+
const r = db.prepare("SELECT wave_count, current_wave, wave_label FROM plan_wave_label WHERE plan_key = ?").get(plan_key) as
|
|
112
|
+
| Record<string, unknown>
|
|
113
|
+
| undefined;
|
|
114
|
+
return r === undefined ? undefined : { ...r };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
test("plan_delivery reproduces deriveDelivery exactly (converging / landed / not-done / resolved-not-landed / taskless)", () => {
|
|
118
|
+
const db = viewDb();
|
|
119
|
+
// A `done` epic with slices still in flight → converging.
|
|
120
|
+
const a = addPlan(db, "o/r#1", "done", [
|
|
121
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
122
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
123
|
+
{ status: "opened", wave: 1, pr: { status: "converging" } },
|
|
124
|
+
{ status: "blocked", wave: 1 },
|
|
125
|
+
]);
|
|
126
|
+
// A `done` epic with every slice PR merged → landed.
|
|
127
|
+
const b = addPlan(db, "o/r#2", "done", [
|
|
128
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
129
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
130
|
+
]);
|
|
131
|
+
// A `dispatched` (not-done) epic → no positive delivery signal yet, even with an open PR.
|
|
132
|
+
const c = addPlan(db, "o/r#3", "dispatched", [
|
|
133
|
+
{ status: "opened", wave: 0, pr: { status: "converging" } },
|
|
134
|
+
{ status: "pending", wave: 1 },
|
|
135
|
+
{ status: "pending", wave: 2 },
|
|
136
|
+
]);
|
|
137
|
+
// A `done` epic where every PR is terminal but not all merged (one abandoned) → resolved, NOT landed.
|
|
138
|
+
const d = addPlan(db, "o/r#4", "done", [
|
|
139
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
140
|
+
{ status: "opened", wave: 0, pr: { status: "abandoned" } },
|
|
141
|
+
]);
|
|
142
|
+
// A `planning` epic with no tasks → no PRs → null.
|
|
143
|
+
const e = addPlan(db, "o/r#5", "planning", []);
|
|
144
|
+
|
|
145
|
+
for (const [plan_key, status, prStatuses] of [
|
|
146
|
+
["o/r#1", "done", a],
|
|
147
|
+
["o/r#2", "done", b],
|
|
148
|
+
["o/r#3", "dispatched", c],
|
|
149
|
+
["o/r#4", "done", d],
|
|
150
|
+
["o/r#5", "planning", e],
|
|
151
|
+
] as const) {
|
|
152
|
+
const expected = deriveDelivery(status, prStatuses);
|
|
153
|
+
const row = delivery(db, plan_key);
|
|
154
|
+
assertEquals(row.delivery, expected.delivery, `${plan_key}: delivery`);
|
|
155
|
+
assertEquals(row.delivery_label, expected.label, `${plan_key}: delivery_label`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Pin the exact pre-formatted strings so a formatting drift can't hide behind the cross-check.
|
|
159
|
+
assertEquals(delivery(db, "o/r#1").delivery_label, "2/3 slices merged, 1 converging");
|
|
160
|
+
assertEquals(delivery(db, "o/r#2").delivery_label, "2/2 slices merged");
|
|
161
|
+
assertEquals(delivery(db, "o/r#4").delivery, null);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("plan_delivery_counts pins the two subtle predicates: `converged` is terminal, a dangling pr_key is in-flight", () => {
|
|
165
|
+
const db = viewDb();
|
|
166
|
+
// `converged` is in TERMINAL_STATUSES (review-only mode): a `done` epic whose only remaining PR is
|
|
167
|
+
// `converged` (not `merged`) is resolved-not-landed. It must NOT count as in-flight — so delivery is
|
|
168
|
+
// NULL, never `converging`. This pins the hard-coded terminal set in the view's SQL against
|
|
169
|
+
// TERMINAL_STATUSES; drop `converged` from either and prs_in_flight becomes 1 here.
|
|
170
|
+
const conv = addPlan(db, "o/r#c", "done", [
|
|
171
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
172
|
+
{ status: "opened", wave: 0, pr: { status: "converged" } },
|
|
173
|
+
]);
|
|
174
|
+
assertEquals(counts(db, "o/r#c"), { prs_opened: 2, prs_merged: 1, prs_in_flight: 0 });
|
|
175
|
+
assertEquals(delivery(db, "o/r#c").delivery, null, "converged is terminal-not-merged → resolved, not landed");
|
|
176
|
+
assertEquals({ ...delivery(db, "o/r#c") }, { delivery: deriveDelivery("done", conv).delivery, delivery_label: deriveDelivery("done", conv).label });
|
|
177
|
+
|
|
178
|
+
// A dangling `pr_key` (task opened a PR but the `pull_requests` row is absent — the poller's
|
|
179
|
+
// MISSING_PR_STATUS desync). The LEFT JOIN yields `status IS NULL`, which the view's
|
|
180
|
+
// `p.status IS NULL OR …` branch counts as in-flight — so even though every OTHER PR merged, the
|
|
181
|
+
// epic stays `converging` and can never be wrongly promoted to `landed`.
|
|
182
|
+
const dangling = addPlan(db, "o/r#d", "done", [
|
|
183
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
184
|
+
{ status: "opened", wave: 0, danglingPr: true },
|
|
185
|
+
]);
|
|
186
|
+
assertEquals(counts(db, "o/r#d"), { prs_opened: 2, prs_merged: 1, prs_in_flight: 1 });
|
|
187
|
+
assertEquals(delivery(db, "o/r#d").delivery, "converging", "a dangling pr_key keeps the epic in flight (never landed)");
|
|
188
|
+
assertEquals({ ...delivery(db, "o/r#d") }, {
|
|
189
|
+
delivery: deriveDelivery("done", dangling).delivery,
|
|
190
|
+
delivery_label: deriveDelivery("done", dangling).label,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("plan_wave_label reproduces the workers' wave_count / current_wave / wave_label projection", () => {
|
|
195
|
+
const db = viewDb();
|
|
196
|
+
// Wave 0 fully merged, wave 1 still converging → frontier is wave 1 (the gating wave). "2/2".
|
|
197
|
+
addPlan(db, "o/r#1", "done", [
|
|
198
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
199
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
200
|
+
{ status: "opened", wave: 1, pr: { status: "converging" } },
|
|
201
|
+
{ status: "blocked", wave: 1 },
|
|
202
|
+
]);
|
|
203
|
+
// Single wave, all merged → frontier pins to the last index (0). "1/1".
|
|
204
|
+
addPlan(db, "o/r#2", "done", [
|
|
205
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
206
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
207
|
+
]);
|
|
208
|
+
// Three waves, freshly dispatched (all in flight) → frontier is wave 0. "1/3".
|
|
209
|
+
addPlan(db, "o/r#3", "dispatched", [
|
|
210
|
+
{ status: "opened", wave: 0, pr: { status: "converging" } },
|
|
211
|
+
{ status: "pending", wave: 1 },
|
|
212
|
+
{ status: "pending", wave: 2 },
|
|
213
|
+
]);
|
|
214
|
+
// No levelized tasks → no wave rollup row (matches the workers leaving a taskless plan NULL).
|
|
215
|
+
addPlan(db, "o/r#5", "planning", []);
|
|
216
|
+
|
|
217
|
+
assertEquals(waveLabel(db, "o/r#1"), { wave_count: 2, current_wave: 1, wave_label: "2/2" });
|
|
218
|
+
assertEquals(waveLabel(db, "o/r#2"), { wave_count: 1, current_wave: 0, wave_label: "1/1" });
|
|
219
|
+
assertEquals(waveLabel(db, "o/r#3"), { wave_count: 3, current_wave: 0, wave_label: "1/3" });
|
|
220
|
+
assertEquals(waveLabel(db, "o/r#5"), undefined);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("plan_read_model joins the derived wave + delivery projections onto the plans row", () => {
|
|
224
|
+
const db = viewDb();
|
|
225
|
+
addPlan(db, "o/r#1", "done", [
|
|
226
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
227
|
+
{ status: "opened", wave: 1, pr: { status: "converging" } },
|
|
228
|
+
]);
|
|
229
|
+
addPlan(db, "o/r#5", "planning", []);
|
|
230
|
+
|
|
231
|
+
const row = db.prepare("SELECT * FROM plan_read_model WHERE plan_key = ?").get("o/r#1") as Record<string, unknown>;
|
|
232
|
+
assertEquals(row.plan_key, "o/r#1");
|
|
233
|
+
assertEquals(row.status, "done");
|
|
234
|
+
assertEquals(row.list_bucket, "active");
|
|
235
|
+
assertEquals(row.wave_label, "2/2");
|
|
236
|
+
assertEquals(row.wave_count, 2);
|
|
237
|
+
assertEquals(row.current_wave, 1);
|
|
238
|
+
assertEquals(row.delivery, "converging");
|
|
239
|
+
assertEquals(row.delivery_label, "1/2 slices merged, 1 converging");
|
|
240
|
+
|
|
241
|
+
// A taskless plan still appears (LEFT JOINs), with the derived columns NULL.
|
|
242
|
+
const empty = db.prepare("SELECT wave_label, delivery, delivery_label FROM plan_read_model WHERE plan_key = ?").get("o/r#5") as Record<string, unknown>;
|
|
243
|
+
assertEquals({ ...empty }, { wave_label: null, delivery: null, delivery_label: null });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("the operator pages read the derived plan_read_model VIEW for the wave/delivery cells", () => {
|
|
247
|
+
// Overview epics grid — binds the view and still surfaces wave_label + delivery_label.
|
|
248
|
+
const overview = PAGE("overview.page.json");
|
|
249
|
+
const epics = (overview.nodes ?? []).find((n: { id: string }) => n.id === "overview-epics");
|
|
250
|
+
assert(epics, "overview must keep the Active Epics grid");
|
|
251
|
+
assertEquals(epics.props.data.table, "plan_read_model");
|
|
252
|
+
const epicCols: string[] = epics.props.columns.map((c: { field: string }) => c.field);
|
|
253
|
+
assert(epicCols.includes("wave_label") && epicCols.includes("delivery_label"), "overview epics grid surfaces wave_label + delivery_label");
|
|
254
|
+
|
|
255
|
+
// Epic-detail wave banner + plan grid both read the view.
|
|
256
|
+
const detail = PAGE("epic-detail.page.json");
|
|
257
|
+
const byId = (id: string) => (detail.nodes ?? []).find((n: { id: string }) => n.id === id);
|
|
258
|
+
assertEquals(byId("wave-banner").props.data.table, "plan_read_model");
|
|
259
|
+
assertEquals(byId("epic-plan").props.data.table, "plan_read_model");
|
|
260
|
+
assert(/\{\{\s*wave_label\s*\}\}/.test(byId("wave-banner").props.header), "the banner surfaces wave_label");
|
|
261
|
+
assertEquals(byId("wave-banner").props.body, "delivery_label", "the banner body is the delivery_label");
|
|
262
|
+
});
|
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
|