@nanobpm/nano-workforce 0.95.0 → 0.96.0
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 +7 -0
- package/app/agentic/vocab/crew-vocab.test.ts +12 -0
- package/app/agentic/vocab/crew-vocab.ts +18 -0
- package/app/agentic/vocab/demand-report.test.ts +34 -0
- package/app/agentic/vocab/demand-report.ts +21 -1
- package/app/agentic/vocab/job-types.test.ts +107 -0
- package/app/agentic/vocab/job-types.ts +70 -0
- package/app/feature.ts +43 -0
- package/app/interEpicRegression.test.ts +516 -0
- package/app/plan.ts +15 -0
- package/app/pollUserTasks.test.ts +31 -0
- package/app/service.ts +54 -3
- package/app/userTasks.test.ts +14 -0
- package/app/userTasks.ts +15 -1
- package/app/waitGate.test.ts +176 -0
- package/app/waitGate.ts +199 -0
- package/app/waitGatePoll.test.ts +143 -0
- package/app/waitGateVisibility.test.ts +55 -0
- package/db/migrations/047_plan_wait_gate.sql +34 -0
- package/db/migrations/048_feature_escalations.sql +51 -0
- package/e2e/inter-epic-dependency.e2e.ts +227 -0
- package/package.json +1 -1
- package/pages/epic-detail.page.json +28 -0
- package/pages/epic.page.json +1 -0
- package/pages/tasks.page.json +133 -3
- package/resources/processes/plan-fanout.bpmn +1 -0
- package/workers/record-feature-escalation/worker.test.ts +27 -3
- package/workers/record-feature-escalation/worker.ts +7 -1
- package/workers/select-wave/worker.test.ts +37 -0
- package/workers/select-wave/worker.ts +11 -0
package/app/service.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
|
12
12
|
import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
13
13
|
import { agentSlaTimeout } from "./agentSla.ts";
|
|
14
14
|
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
15
|
-
import { backfillFeatureStages, deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureRuns } from "./feature.ts";
|
|
15
|
+
import { backfillFeatureStages, deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
|
|
16
16
|
import {
|
|
17
17
|
classifyMergeability,
|
|
18
18
|
coalesceTitle,
|
|
@@ -33,12 +33,20 @@ import { pollLineage } from "./lineage.ts";
|
|
|
33
33
|
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
34
34
|
import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
|
|
35
35
|
import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
backfillPlanBuckets,
|
|
38
|
+
inboundPlanDeps,
|
|
39
|
+
planReviews,
|
|
40
|
+
plans,
|
|
41
|
+
planTaskDeps,
|
|
42
|
+
planTasks,
|
|
43
|
+
} from "./plan.ts";
|
|
37
44
|
import { derivePromotionState, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
|
|
38
45
|
import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
|
|
39
46
|
import { trialMergeAudits } from "./trialMerge.ts";
|
|
40
47
|
import {
|
|
41
48
|
buildUserTaskRow,
|
|
49
|
+
latestFeatureEscalationQuestion,
|
|
42
50
|
latestOpenEscalationQuestion,
|
|
43
51
|
latestPlanReviewFindings,
|
|
44
52
|
latestTrialMergeQuestion,
|
|
@@ -51,6 +59,7 @@ import {
|
|
|
51
59
|
type UserTaskRow,
|
|
52
60
|
userTasks,
|
|
53
61
|
} from "./userTasks.ts";
|
|
62
|
+
import { deriveWaitGate } from "./waitGate.ts";
|
|
54
63
|
import { waveMergeTargets } from "./waves.ts";
|
|
55
64
|
|
|
56
65
|
/** The BPMN process that drives review convergence (`resources/processes/convergence-loop.bpmn`). */
|
|
@@ -1350,6 +1359,40 @@ export async function pollDelivery(data: DataLayer) {
|
|
|
1350
1359
|
}
|
|
1351
1360
|
}
|
|
1352
1361
|
|
|
1362
|
+
/** Idempotent read-model pass (issue #292 slice S4): project each DEPENDENT epic's inter-epic gate
|
|
1363
|
+
* state onto its `plans` row so the epic index/detail views can show — as flat columns — which
|
|
1364
|
+
* producer/package a parked dependent is blocked on, its poll cadence + escalation deadline, and the
|
|
1365
|
+
* bound `pkg@version` once green. Mirrors `pollDelivery`: joins each plan against its inbound
|
|
1366
|
+
* `plan_deps` edges (the S1 read API) and stamps the pure `deriveWaitGate` (app/waitGate.ts) result,
|
|
1367
|
+
* writing only when the projection actually changes so a steady-state pass is a no-op. Read-only over
|
|
1368
|
+
* the state S1–S3 produce — it NEVER touches admission, scheduling, or `plan.status`.
|
|
1369
|
+
*
|
|
1370
|
+
* A ROOT epic (no inbound edge) derives `{ null, null }` — no wait-gate; any stale projection left by
|
|
1371
|
+
* a prior edge (e.g. an edge later removed) is cleared defensively so the read model never keeps a
|
|
1372
|
+
* phantom gate. */
|
|
1373
|
+
export async function pollWaitGate(data: DataLayer) {
|
|
1374
|
+
for (const plan of await plans(data).all()) {
|
|
1375
|
+
try {
|
|
1376
|
+
const edges = await inboundPlanDeps(data, plan.plan_key);
|
|
1377
|
+
const { wait_gate, wait_gate_label } = deriveWaitGate(edges, {
|
|
1378
|
+
status: plan.status,
|
|
1379
|
+
current_wave: plan.current_wave,
|
|
1380
|
+
bound_artifacts: plan.bound_artifacts,
|
|
1381
|
+
created_at: plan.created_at,
|
|
1382
|
+
});
|
|
1383
|
+
if (plan.wait_gate !== wait_gate || plan.wait_gate_label !== wait_gate_label) {
|
|
1384
|
+
await plans(data).update(plan.plan_key, {
|
|
1385
|
+
wait_gate,
|
|
1386
|
+
wait_gate_label,
|
|
1387
|
+
updated_at: now(),
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
} catch (err) {
|
|
1391
|
+
console.error(`[poller] wait-gate ${plan.plan_key}: ${err}`);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1353
1396
|
/** Idempotent promotion pass (issue #299): open — and then track — the `epic/* → <default>`
|
|
1354
1397
|
* promotion PR for every epic that has LANDED on a custom integration branch. This is the missing
|
|
1355
1398
|
* counterpart to `ensureBaseBranch`: that creates the `epic/*` branch slices merge into; this
|
|
@@ -1619,6 +1662,13 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1619
1662
|
if (featureSeen.has(run.feature_key)) continue;
|
|
1620
1663
|
featureSeen.add(run.feature_key);
|
|
1621
1664
|
if (run.escalation_user_task_key) {
|
|
1665
|
+
// Source the question from the canonical append-only `feature_escalations` audit log (issue
|
|
1666
|
+
// #305) — the surviving table `record-feature-escalation` writes — falling back to the legacy
|
|
1667
|
+
// denormalised `feature_runs.escalation_question` while both coexist (expand phase). This is the
|
|
1668
|
+
// feature analogue of the plan-review/trial-merge/PR-loop question enrichment below, and lets the
|
|
1669
|
+
// denormalised column be dropped in the contract phase without the Tasks grid losing the text.
|
|
1670
|
+
const question = latestFeatureEscalationQuestion(await featureEscalations(data).find({ feature_key: run.feature_key })) ??
|
|
1671
|
+
run.escalation_question;
|
|
1622
1672
|
push(
|
|
1623
1673
|
buildUserTaskRow(
|
|
1624
1674
|
{
|
|
@@ -1628,7 +1678,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1628
1678
|
subjectKey: run.feature_key,
|
|
1629
1679
|
subjectTitle: run.title,
|
|
1630
1680
|
subjectUrl: run.issue_url,
|
|
1631
|
-
question
|
|
1681
|
+
question,
|
|
1632
1682
|
processKey: run.process_key,
|
|
1633
1683
|
},
|
|
1634
1684
|
at,
|
|
@@ -1804,6 +1854,7 @@ export async function pollOnce(
|
|
|
1804
1854
|
await pollReviews(data, engine, token);
|
|
1805
1855
|
await pollMerges(data, engine, token);
|
|
1806
1856
|
await pollDelivery(data);
|
|
1857
|
+
await pollWaitGate(data);
|
|
1807
1858
|
await pollPromotion(data, engine, token);
|
|
1808
1859
|
await pollFeatureDelivery(data);
|
|
1809
1860
|
await pollLineage(data);
|
package/app/userTasks.test.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
buildUserTaskRow,
|
|
12
12
|
PLAN_REVIEW_ELEMENT,
|
|
13
13
|
PR_WAIT_ANSWER_ELEMENT,
|
|
14
|
+
latestFeatureEscalationQuestion,
|
|
14
15
|
latestOpenEscalationQuestion,
|
|
15
16
|
latestPlanReviewFindings,
|
|
16
17
|
latestTrialMergeQuestion,
|
|
@@ -230,3 +231,16 @@ test("latestOpenEscalationQuestion: null when there is no open escalation", () =
|
|
|
230
231
|
assertEquals(latestOpenEscalationQuestion([esc({ id: 1, status: "answered" })]), null);
|
|
231
232
|
assertEquals(latestOpenEscalationQuestion([]), null);
|
|
232
233
|
});
|
|
234
|
+
|
|
235
|
+
test("latestFeatureEscalationQuestion: picks the newest audit row (highest id), not a positional [0]", () => {
|
|
236
|
+
const rows = [
|
|
237
|
+
{ id: 3, feature_key: "o/r#1", question: "stale", created_at: "t0", job_key: "j0" },
|
|
238
|
+
{ id: 8, feature_key: "o/r#1", question: "newest", created_at: "t1", job_key: "j1" },
|
|
239
|
+
{ id: 5, feature_key: "o/r#1", question: "middle", created_at: "t2", job_key: "j2" },
|
|
240
|
+
];
|
|
241
|
+
assertEquals(latestFeatureEscalationQuestion(rows), "newest");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("latestFeatureEscalationQuestion: null when the feature has no recorded escalation", () => {
|
|
245
|
+
assertEquals(latestFeatureEscalationQuestion([]), null);
|
|
246
|
+
});
|
package/app/userTasks.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// tasks visible; a completed task's row is removed on the next pass when the engine no longer reports
|
|
19
19
|
// it open.
|
|
20
20
|
import type { DataLayer } from "@nanobpm/urban";
|
|
21
|
-
import { FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT } from "./feature.ts";
|
|
21
|
+
import { FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, type FeatureEscalationRow } from "./feature.ts";
|
|
22
22
|
import type { PlanReview } from "./plan.ts";
|
|
23
23
|
import type { TrialMergeAuditRow } from "./trialMerge.ts";
|
|
24
24
|
|
|
@@ -233,3 +233,17 @@ export function latestOpenEscalationQuestion(rows: readonly PrEscalationRow[]):
|
|
|
233
233
|
}
|
|
234
234
|
return latest?.question ?? null;
|
|
235
235
|
}
|
|
236
|
+
|
|
237
|
+
/** Pure: the question for the still-open `feature-escalation`, sourced from the append-only
|
|
238
|
+
* `feature_escalations` audit log (migration 048) — the feature analogue of `latestOpenEscalationQuestion`.
|
|
239
|
+
* `record-feature-escalation` appends one row per escalation entry, so the newest row (highest `id`) is
|
|
240
|
+
* the live question the operator is being asked; a run that escalated, was answered, then re-escalated
|
|
241
|
+
* with a fresh question is covered because the later entry has the higher `id`. `null` when the feature
|
|
242
|
+
* has no recorded escalation (the poller then falls back to the legacy `feature_runs` column). */
|
|
243
|
+
export function latestFeatureEscalationQuestion(rows: readonly FeatureEscalationRow[]): string | null {
|
|
244
|
+
let latest: FeatureEscalationRow | undefined;
|
|
245
|
+
for (const r of rows) {
|
|
246
|
+
if (!latest || r.id > latest.id) latest = r;
|
|
247
|
+
}
|
|
248
|
+
return latest?.question ?? null;
|
|
249
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Adversarial unit coverage for the inter-epic gate projection (issue #292, slice S4) — the pure
|
|
2
|
+
// `deriveWaitGate` that turns a dependent's inbound `plan_deps` edges + its own lifecycle into the
|
|
3
|
+
// operator-visible `wait_gate` / `wait_gate_label` columns. No data/engine/network: pure derivation,
|
|
4
|
+
// exactly like app/delivery.test.ts.
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assert, assertEquals } from "#test-assert";
|
|
7
|
+
import type { PlanDep } from "./plan.ts";
|
|
8
|
+
import {
|
|
9
|
+
deriveWaitGate,
|
|
10
|
+
humanizeMs,
|
|
11
|
+
parseBoundArtifacts,
|
|
12
|
+
type WaitGateLifecycle,
|
|
13
|
+
} from "./waitGate.ts";
|
|
14
|
+
|
|
15
|
+
const T0 = "2026-01-01T00:00:00.000Z";
|
|
16
|
+
const T0_MS = Date.parse(T0);
|
|
17
|
+
|
|
18
|
+
const edge = (
|
|
19
|
+
consumer: string,
|
|
20
|
+
producer: string,
|
|
21
|
+
pkg = "@scope/pkg",
|
|
22
|
+
capRef = producer,
|
|
23
|
+
): PlanDep => ({
|
|
24
|
+
plan_key: consumer,
|
|
25
|
+
depends_on_plan_key: producer,
|
|
26
|
+
package: pkg,
|
|
27
|
+
capability_ref: capRef,
|
|
28
|
+
created_at: T0,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const plan = (over: Partial<WaitGateLifecycle> = {}): WaitGateLifecycle => ({
|
|
32
|
+
status: "planning",
|
|
33
|
+
current_wave: null,
|
|
34
|
+
bound_artifacts: null,
|
|
35
|
+
created_at: T0,
|
|
36
|
+
...over,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ── root ───────────────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
test("a root epic (no inbound edge) has NO wait-gate", () => {
|
|
41
|
+
const got = deriveWaitGate([], plan(), { nowMs: T0_MS });
|
|
42
|
+
assertEquals(got, { wait_gate: null, wait_gate_label: null });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ── waiting (parked at the preflight) ────────────────────────────────────────────────────────────
|
|
46
|
+
test("a parked dependent is 'waiting' and names exactly which producer/package it is blocked on", () => {
|
|
47
|
+
const got = deriveWaitGate([edge("o/r#2", "o/r#1", "@scope/api")], plan(), {
|
|
48
|
+
nowMs: T0_MS + 1000,
|
|
49
|
+
});
|
|
50
|
+
assertEquals(got.wait_gate, "waiting");
|
|
51
|
+
assert(got.wait_gate_label!.includes("o/r#1 @ @scope/api"), "names the producer#N @ package");
|
|
52
|
+
assert(got.wait_gate_label!.includes("re-checks every"), "shows the poll cadence");
|
|
53
|
+
assert(got.wait_gate_label!.includes("escalates by"), "shows the escalation deadline");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a fan-in dependent waits on ALL its producers, capping the visible list", () => {
|
|
57
|
+
const edges = [
|
|
58
|
+
edge("o/r#9", "o/r#1", "@p/one"),
|
|
59
|
+
edge("o/r#9", "o/r#2", "@p/two"),
|
|
60
|
+
edge("o/r#9", "o/r#3", "@p/three"),
|
|
61
|
+
edge("o/r#9", "o/r#4", "@p/four"),
|
|
62
|
+
];
|
|
63
|
+
const got = deriveWaitGate(edges, plan(), { nowMs: T0_MS + 1000 });
|
|
64
|
+
assertEquals(got.wait_gate, "waiting");
|
|
65
|
+
assert(got.wait_gate_label!.includes("o/r#1 @ @p/one"), "shows the first producer");
|
|
66
|
+
assert(got.wait_gate_label!.includes("+1 more"), "caps the fan-in list");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── ready (preflight went green) ─────────────────────────────────────────────────────────────────
|
|
70
|
+
test("a dependent that has fanned out (current_wave set) is 'ready'", () => {
|
|
71
|
+
const got = deriveWaitGate([edge("o/r#2", "o/r#1")], plan({ current_wave: 0 }), {
|
|
72
|
+
nowMs: T0_MS + 1000,
|
|
73
|
+
});
|
|
74
|
+
assertEquals(got.wait_gate, "ready");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a satisfied dependent shows its BOUND version, not merely 'ready'", () => {
|
|
78
|
+
const got = deriveWaitGate(
|
|
79
|
+
[edge("o/r#2", "o/r#1")],
|
|
80
|
+
plan({ current_wave: 1, bound_artifacts: JSON.stringify(["@scope/api@1.4.0"]) }),
|
|
81
|
+
{ nowMs: T0_MS + 1000 },
|
|
82
|
+
);
|
|
83
|
+
assertEquals(got.wait_gate, "ready");
|
|
84
|
+
assert(got.wait_gate_label!.includes("@scope/api@1.4.0"), "surfaces the exact bound pkg@version");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a dispatched/done dependent with no captured version still reads 'ready' (green, gate passed)", () => {
|
|
88
|
+
for (const status of ["dispatched", "done"]) {
|
|
89
|
+
const got = deriveWaitGate([edge("o/r#2", "o/r#1")], plan({ status }), { nowMs: T0_MS + 1000 });
|
|
90
|
+
assertEquals(got.wait_gate, "ready", `status=${status}`);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a bound version wins even before a wave is stamped (green the instant the preflight resolves)", () => {
|
|
95
|
+
const got = deriveWaitGate(
|
|
96
|
+
[edge("o/r#2", "o/r#1")],
|
|
97
|
+
plan({ bound_artifacts: JSON.stringify(["@scope/api@2.0.0"]) }),
|
|
98
|
+
{ nowMs: T0_MS + 1000 },
|
|
99
|
+
);
|
|
100
|
+
assertEquals(got.wait_gate, "ready");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── escalated (bounded timeout / terminal failure) ───────────────────────────────────────────────
|
|
104
|
+
test("a still-gated dependent past its bounded timeout is 'escalated', never a silent stall", () => {
|
|
105
|
+
const got = deriveWaitGate([edge("o/r#2", "o/r#1")], plan(), {
|
|
106
|
+
// Well past the default readiness timeout (30m) with no publish.
|
|
107
|
+
nowMs: T0_MS + 48 * 60 * 60 * 1000,
|
|
108
|
+
});
|
|
109
|
+
assertEquals(got.wait_gate, "escalated");
|
|
110
|
+
assert(got.wait_gate_label!.includes("escalated"), "labels the escalation");
|
|
111
|
+
assert(got.wait_gate_label!.includes("o/r#1 @ @scope/pkg"), "still names the blocking producer");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a terminal FAILED dependent that never went green surfaces as 'escalated'", () => {
|
|
115
|
+
for (const status of ["failed", "abandoned"]) {
|
|
116
|
+
const got = deriveWaitGate([edge("o/r#2", "o/r#1")], plan({ status }), { nowMs: T0_MS + 1000 });
|
|
117
|
+
assertEquals(got.wait_gate, "escalated", `status=${status}`);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a FAILED dependent that DID go green (bound) stays 'ready' — the gate resolved before the failure", () => {
|
|
122
|
+
const got = deriveWaitGate(
|
|
123
|
+
[edge("o/r#2", "o/r#1")],
|
|
124
|
+
plan({ status: "failed", bound_artifacts: JSON.stringify(["@scope/pkg@1.0.0"]) }),
|
|
125
|
+
{ nowMs: T0_MS + 1000 },
|
|
126
|
+
);
|
|
127
|
+
assertEquals(got.wait_gate, "ready");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ── defensive parsing / formatting ───────────────────────────────────────────────────────────────
|
|
131
|
+
test("parseBoundArtifacts tolerates null/garbage/non-array/non-string, never throwing", () => {
|
|
132
|
+
assertEquals(parseBoundArtifacts(null), []);
|
|
133
|
+
assertEquals(parseBoundArtifacts(""), []);
|
|
134
|
+
assertEquals(parseBoundArtifacts("not json"), []);
|
|
135
|
+
assertEquals(parseBoundArtifacts('{"a":1}'), []);
|
|
136
|
+
assertEquals(parseBoundArtifacts('["@a/b@1.0.0", 3, "", null]'), ["@a/b@1.0.0"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a green dependent whose bound_artifacts is garbage still reads 'ready' (via the wave signal)", () => {
|
|
140
|
+
const got = deriveWaitGate(
|
|
141
|
+
[edge("o/r#2", "o/r#1")],
|
|
142
|
+
plan({ current_wave: 0, bound_artifacts: "garbage" }),
|
|
143
|
+
{ nowMs: T0_MS + 1000 },
|
|
144
|
+
);
|
|
145
|
+
assertEquals(got.wait_gate, "ready");
|
|
146
|
+
assertEquals(got.wait_gate_label, "ready", "no phantom version from an unparseable value");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("humanizeMs renders compact human spans", () => {
|
|
150
|
+
assertEquals(humanizeMs(30_000), "30s");
|
|
151
|
+
assertEquals(humanizeMs(90_000), "1m 30s");
|
|
152
|
+
assertEquals(humanizeMs(30 * 60 * 1000), "30m");
|
|
153
|
+
assertEquals(humanizeMs(0), "0s");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("the waiting label reflects the exponential backoff cadence, not a flat interval", () => {
|
|
157
|
+
const got = deriveWaitGate([edge("o/r#2", "o/r#1", "@scope/api")], plan(), { nowMs: T0_MS + 1000 });
|
|
158
|
+
assertEquals(got.wait_gate, "waiting");
|
|
159
|
+
assert(
|
|
160
|
+
got.wait_gate_label!.includes("(exponential backoff)"),
|
|
161
|
+
"names the default exponential backoff so 'every N' can't imply a fixed cadence",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("the waiting label is stable regardless of inbound edge order (no idempotent-poll churn)", () => {
|
|
166
|
+
const a = edge("o/r#9", "o/r#1", "@p/one");
|
|
167
|
+
const b = edge("o/r#9", "o/r#2", "@p/two");
|
|
168
|
+
const c = edge("o/r#9", "o/r#3", "@p/three");
|
|
169
|
+
const forward = deriveWaitGate([a, b, c], plan(), { nowMs: T0_MS + 1000 });
|
|
170
|
+
const shuffled = deriveWaitGate([c, a, b], plan(), { nowMs: T0_MS + 1000 });
|
|
171
|
+
assertEquals(shuffled.wait_gate_label, forward.wait_gate_label, "order-independent label");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("parseBoundArtifacts drops whitespace-only entries, not just empty strings", () => {
|
|
175
|
+
assertEquals(parseBoundArtifacts('["@a/b@1.0.0", " ", "\\t"]'), ["@a/b@1.0.0"]);
|
|
176
|
+
});
|
package/app/waitGate.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// app/waitGate.ts — operator visibility for the inter-epic gate (issue #292, slice S4).
|
|
2
|
+
//
|
|
3
|
+
// S1 landed the durable inter-epic edge (`plan_deps`): one row per "dependent epic B waits on
|
|
4
|
+
// producer epic A's published {package, capabilityRef}". S3 lowers each dependent behind a LEADING
|
|
5
|
+
// `capability` readiness PREFLIGHT (resources/processes/plan-fanout.bpmn): the dependent fans out NO
|
|
6
|
+
// wave until every producer publishes the awaited `pkg@version`, and a never-publishing producer
|
|
7
|
+
// ESCALATES (bounded) rather than wedging the set. Until S4, a parked dependent was a SILENT stall —
|
|
8
|
+
// the epic views showed no wave, no delivery, no reason it hadn't started.
|
|
9
|
+
//
|
|
10
|
+
// This module is the pure derivation half of S4: given a dependent's inbound `plan_deps` edges plus
|
|
11
|
+
// its own lifecycle signals, it projects the epic's GATE STATE — is it waiting on a producer, has it
|
|
12
|
+
// gone green (bound to a concrete version), or has its bounded timeout elapsed (escalated)? — as the
|
|
13
|
+
// two flat, display-only `plans` columns (`wait_gate` / `wait_gate_label`) the declarative epic
|
|
14
|
+
// index/detail dataGrids read. `pollWaitGate` (app/service.ts) is the idempotent projection pass that
|
|
15
|
+
// joins each plan against its edges and stamps the result; this file holds NO data/engine access so
|
|
16
|
+
// it stays unit-testable, mirroring `deriveDelivery` (app/delivery.ts) / `deriveEpicPhase`
|
|
17
|
+
// (app/epicPhase.ts). It NEVER changes admission or scheduling — read-only over the state S1–S3
|
|
18
|
+
// produce.
|
|
19
|
+
//
|
|
20
|
+
// The gate's poll cadence and bounded timeout are NOT re-invented here: the probe is derived from the
|
|
21
|
+
// edge by the SAME `capabilityProbeForEdge` (app/planLowering.ts) S3 lowers with, and its cadence /
|
|
22
|
+
// budget come from the SAME `normalizePoll` / `readinessTimeoutMs` (app/readiness.ts) the worker and
|
|
23
|
+
// gate timers use — so the projected "re-checks every N / escalates by T" can never drift from the
|
|
24
|
+
// real gate.
|
|
25
|
+
|
|
26
|
+
import type { PlanDep } from "./plan.ts";
|
|
27
|
+
import { capabilityProbeForEdge } from "./planLowering.ts";
|
|
28
|
+
import { normalizePoll, readinessTimeoutMs } from "./readiness.ts";
|
|
29
|
+
|
|
30
|
+
/** The dependent epic's derived gate state. A ROOT epic (no inbound edge) has NO gate → `null`.
|
|
31
|
+
* • waiting — parked at the preflight, blocked on ≥1 producer's capability, timeout not yet spent.
|
|
32
|
+
* • ready — the preflight went green: the epic has fanned out, bound to a concrete version.
|
|
33
|
+
* • escalated — the gate's bounded timeout elapsed with the capability still unpublished. */
|
|
34
|
+
export type WaitGateState = "waiting" | "ready" | "escalated";
|
|
35
|
+
|
|
36
|
+
/** The lifecycle signals `deriveWaitGate` reads off the dependent's own `plans` row to tell whether
|
|
37
|
+
* its preflight has already passed (green) or failed. All are the ordinary projections S1–S3 already
|
|
38
|
+
* maintain — this derivation only READS them. */
|
|
39
|
+
export interface WaitGateLifecycle {
|
|
40
|
+
/** The plan lifecycle status (`planning` | `dispatched` | `done` | `failed` | `abandoned`). */
|
|
41
|
+
status: string;
|
|
42
|
+
/** The 0-based wave the fleet is implementing, stamped by `select-wave` once fan-out begins — so a
|
|
43
|
+
* non-null value proves the leading preflight already went green. NULL while still gated. */
|
|
44
|
+
current_wave: number | null;
|
|
45
|
+
/** JSON array (`["@scope/pkg@1.4.0", …]`) of the versions the preflight bound, stamped by
|
|
46
|
+
* `select-wave` from the `resolvedArtifacts` process variable. NULL until green / for roots. */
|
|
47
|
+
bound_artifacts: string | null;
|
|
48
|
+
/** When the dependent's plan instance was created — the reference point the bounded gate timeout is
|
|
49
|
+
* measured from (the preflight is the epic's very first step). */
|
|
50
|
+
created_at: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The flat projection `pollWaitGate` stamps onto the `plans` row. Both NULL for a root epic. */
|
|
54
|
+
export interface WaitGateProjection {
|
|
55
|
+
wait_gate: WaitGateState | null;
|
|
56
|
+
wait_gate_label: string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface WaitGateOptions {
|
|
60
|
+
/** Injectable clock (ms epoch) for deterministic tests; defaults to `Date.now()`. */
|
|
61
|
+
nowMs?: number;
|
|
62
|
+
/** Injectable env for the readiness timeout/poll defaults (mirrors `readinessTimeoutMs`). */
|
|
63
|
+
env?: Record<string, string | undefined>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Terminal plan states in which a dependent that never went green has effectively given up on its
|
|
67
|
+
* gate — surfaced as `escalated` so the operator sees a blocked epic, never a silent dead end. */
|
|
68
|
+
const FAILED_STATES = new Set(["failed", "abandoned"]);
|
|
69
|
+
|
|
70
|
+
/** Parse the stored `bound_artifacts` JSON into a clean list of `pkg@version` strings. Defensive: a
|
|
71
|
+
* malformed / non-array / non-string-element value degrades to an empty list rather than throwing, so
|
|
72
|
+
* a bad row can never break the whole projection pass. */
|
|
73
|
+
export function parseBoundArtifacts(raw: string | null | undefined): string[] {
|
|
74
|
+
if (!raw) return [];
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(raw);
|
|
77
|
+
if (!Array.isArray(parsed)) return [];
|
|
78
|
+
return parsed.filter((v): v is string => typeof v === "string" && v.trim().length > 0);
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Human-readable duration for a millisecond span, e.g. `90000 → "1m 30s"`, `1800000 → "30m"`. Used
|
|
85
|
+
* only for the display label; keeps the projection readable without pulling in a date library. */
|
|
86
|
+
export function humanizeMs(ms: number): string {
|
|
87
|
+
const total = Math.max(0, Math.round(ms / 1000));
|
|
88
|
+
const h = Math.floor(total / 3600);
|
|
89
|
+
const m = Math.floor((total % 3600) / 60);
|
|
90
|
+
const s = total % 60;
|
|
91
|
+
const parts: string[] = [];
|
|
92
|
+
if (h > 0) parts.push(`${h}h`);
|
|
93
|
+
if (m > 0) parts.push(`${m}m`);
|
|
94
|
+
if (s > 0 || parts.length === 0) parts.push(`${s}s`);
|
|
95
|
+
return parts.join(" ");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Format one producer/package the dependent is blocked on: the producer plan key already carries its
|
|
99
|
+
* issue handle (`owner/repo#N`), so `owner/repo#12 @ @scope/pkg` reads as "waiting on #12 @ pkg". */
|
|
100
|
+
function waitingOnTarget(edge: PlanDep): string {
|
|
101
|
+
return `${edge.depends_on_plan_key} @ ${edge.package}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Join the producers a dependent still waits on into a compact clause, capping the visible list so a
|
|
105
|
+
* fan-in of many producers stays a one-line label ("…, +3 more"). */
|
|
106
|
+
function waitingOnClause(edges: readonly PlanDep[]): string {
|
|
107
|
+
const MAX = 3;
|
|
108
|
+
const shown = edges.slice(0, MAX).map(waitingOnTarget);
|
|
109
|
+
const rest = edges.length - shown.length;
|
|
110
|
+
return rest > 0 ? `${shown.join(", ")}, +${rest} more` : shown.join(", ");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Derive a dependent epic's gate projection from its inbound inter-epic edges and its own lifecycle.
|
|
115
|
+
*
|
|
116
|
+
* • No inbound edge → ROOT → `{ null, null }`: the epic shows no wait-gate and starts immediately.
|
|
117
|
+
* • Green (the preflight passed — the epic fanned out (`current_wave` set) / dispatched, or already
|
|
118
|
+
* carries `bound_artifacts`) → `ready`, labelled with the bound `pkg@version`s.
|
|
119
|
+
* • Still gated and the bounded timeout has elapsed (`now ≥ created_at + gate timeout`), or the
|
|
120
|
+
* epic reached a terminal FAILED state without going green → `escalated`.
|
|
121
|
+
* • Otherwise → `waiting`, labelled with which producer/package it is blocked on plus the gate's
|
|
122
|
+
* own poll cadence and escalation deadline, so a parked dependent is never a silent stall.
|
|
123
|
+
*
|
|
124
|
+
* Pure: the cadence/timeout come from the SAME readiness helpers the gate itself uses (derived off the
|
|
125
|
+
* edge via `capabilityProbeForEdge`), so the projection can never drift from the real schedule.
|
|
126
|
+
*/
|
|
127
|
+
export function deriveWaitGate(
|
|
128
|
+
edges: readonly PlanDep[],
|
|
129
|
+
plan: WaitGateLifecycle,
|
|
130
|
+
opts: WaitGateOptions = {},
|
|
131
|
+
): WaitGateProjection {
|
|
132
|
+
// A root epic has no inbound edge — nothing to wait on, so no gate projection at all.
|
|
133
|
+
if (edges.length === 0) return { wait_gate: null, wait_gate_label: null };
|
|
134
|
+
|
|
135
|
+
const bound = parseBoundArtifacts(plan.bound_artifacts);
|
|
136
|
+
// The leading preflight is the epic's FIRST step, so ANY downstream progress proves it went green:
|
|
137
|
+
// `select-wave` stamped a wave, the fan-out dispatched, or a bound version was already captured.
|
|
138
|
+
const green = bound.length > 0 ||
|
|
139
|
+
plan.current_wave != null ||
|
|
140
|
+
plan.status === "dispatched" ||
|
|
141
|
+
plan.status === "done";
|
|
142
|
+
|
|
143
|
+
if (green) {
|
|
144
|
+
const detail = bound.length > 0 ? ` · bound ${bound.join(", ")}` : "";
|
|
145
|
+
return { wait_gate: "ready", wait_gate_label: `ready${detail}` };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Not green. Order the edges deterministically before deriving anything display-facing: the poller
|
|
149
|
+
// re-runs this derivation every pass, and `plan_deps.find` gives no ordering guarantee, so an
|
|
150
|
+
// unstable order would rewrite the same label (and `updated_at`) pass after pass. Sort by producer
|
|
151
|
+
// then package so the clause, probe list and cadence are stable across passes.
|
|
152
|
+
const orderedEdges = [...edges].sort(
|
|
153
|
+
(a, b) =>
|
|
154
|
+
a.depends_on_plan_key.localeCompare(b.depends_on_plan_key) ||
|
|
155
|
+
a.package.localeCompare(b.package),
|
|
156
|
+
);
|
|
157
|
+
// Derive the gate's own bounded timeout from the edge (the same probe S3 lowers) so the projected
|
|
158
|
+
// deadline matches the engine timer. Every edge yields the same default budget; take the max
|
|
159
|
+
// defensively in case a future edge overrides it.
|
|
160
|
+
const env = opts.env;
|
|
161
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
162
|
+
const polls = orderedEdges.map(capabilityProbeForEdge).map((p) => ({
|
|
163
|
+
...normalizePoll(p.poll),
|
|
164
|
+
timeoutMs: readinessTimeoutMs(p, env),
|
|
165
|
+
}));
|
|
166
|
+
const timeoutMs = Math.max(...polls.map((p) => p.timeoutMs));
|
|
167
|
+
const everyMs = Math.min(...polls.map((p) => p.everyMs));
|
|
168
|
+
// The probe cadence is not a fixed interval unless every probe uses `fixed` backoff — the default
|
|
169
|
+
// is exponential, so a flat "every N" would misrepresent the schedule. Reflect the real shape so the
|
|
170
|
+
// operator label can't claim a cadence the gate doesn't keep.
|
|
171
|
+
const exponential = polls.some((p) => p.backoff === "exponential");
|
|
172
|
+
const startMs = Date.parse(plan.created_at);
|
|
173
|
+
const deadlineMs = Number.isFinite(startMs) ? startMs + timeoutMs : NaN;
|
|
174
|
+
|
|
175
|
+
const clause = waitingOnClause(orderedEdges);
|
|
176
|
+
|
|
177
|
+
// A terminal FAILED epic that never went green, or one whose bounded timeout has elapsed, has
|
|
178
|
+
// effectively escalated — surface it so the operator sees a blocked epic rather than a silent stall.
|
|
179
|
+
const timedOut = Number.isFinite(deadlineMs) && nowMs >= deadlineMs;
|
|
180
|
+
if (timedOut || FAILED_STATES.has(plan.status)) {
|
|
181
|
+
return {
|
|
182
|
+
wait_gate: "escalated",
|
|
183
|
+
wait_gate_label: `escalated · still waiting on ${clause} after ${humanizeMs(timeoutMs)}`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const deadline = Number.isFinite(deadlineMs)
|
|
188
|
+
? ` · escalates by ${new Date(deadlineMs).toISOString()}`
|
|
189
|
+
: "";
|
|
190
|
+
// `re-checks every N` for a fixed cadence; for the default exponential ramp N is only the FIRST
|
|
191
|
+
// interval, so say so rather than implying a flat cadence the gate never keeps.
|
|
192
|
+
const cadence = exponential
|
|
193
|
+
? `re-checks every ${humanizeMs(everyMs)}+ (exponential backoff)`
|
|
194
|
+
: `re-checks every ${humanizeMs(everyMs)}`;
|
|
195
|
+
return {
|
|
196
|
+
wait_gate: "waiting",
|
|
197
|
+
wait_gate_label: `waiting on ${clause} · ${cadence}${deadline}`,
|
|
198
|
+
};
|
|
199
|
+
}
|