@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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Integration coverage for the inter-epic gate projection PASS (issue #292, slice S4). `pollWaitGate`
|
|
2
|
+
// is the `pollDelivery` twin for the readiness gate: it joins each `plans` row against its inbound
|
|
3
|
+
// `plan_deps` edges (the S1 read API) and stamps the pure `deriveWaitGate` result onto the row so the
|
|
4
|
+
// declarative epic index/detail can read `wait_gate` / `wait_gate_label` as flat columns. Runs against
|
|
5
|
+
// an in-memory data layer + the real `plans` gateway, mirroring app/promotionPoll.test.ts.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assert, assertEquals } from "#test-assert";
|
|
8
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
9
|
+
import { plans, recordPlanDep } from "./plan.ts";
|
|
10
|
+
import { pollWaitGate } from "./service.ts";
|
|
11
|
+
|
|
12
|
+
// In-memory record gateway (all/get/find/insert/update/delete), same shape as promotionPoll.test.ts.
|
|
13
|
+
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
14
|
+
const stores: Record<string, any[]> = {};
|
|
15
|
+
function tbl(name: string, pk = "id") {
|
|
16
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
17
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
18
|
+
return {
|
|
19
|
+
async all() {
|
|
20
|
+
return rows.slice();
|
|
21
|
+
},
|
|
22
|
+
async get(id: any) {
|
|
23
|
+
return rows.find((r) => r[pk] === id);
|
|
24
|
+
},
|
|
25
|
+
async find(where: any = {}) {
|
|
26
|
+
return rows.filter((r) => match(r, where));
|
|
27
|
+
},
|
|
28
|
+
async insert(row: any) {
|
|
29
|
+
rows.push({ ...row });
|
|
30
|
+
return row[pk];
|
|
31
|
+
},
|
|
32
|
+
async update(id: any, patch: any) {
|
|
33
|
+
const r = rows.find((row) => row[pk] === id);
|
|
34
|
+
if (r) Object.assign(r, patch);
|
|
35
|
+
},
|
|
36
|
+
async delete(id: any) {
|
|
37
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][pk] === id) rows.splice(i, 1);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
42
|
+
return { data, stores };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function seedPlan(data: DataLayer, over: Record<string, unknown>) {
|
|
46
|
+
const planKey = over.plan_key as string;
|
|
47
|
+
const [repo, num] = planKey.split("#");
|
|
48
|
+
await plans(data).insert({
|
|
49
|
+
plan_key: planKey,
|
|
50
|
+
repo,
|
|
51
|
+
issue_number: Number(num),
|
|
52
|
+
issue_url: `https://github.com/${repo}/issues/${num}`,
|
|
53
|
+
title: planKey,
|
|
54
|
+
status: "planning",
|
|
55
|
+
task_count: 0,
|
|
56
|
+
current_wave: null,
|
|
57
|
+
bound_artifacts: null,
|
|
58
|
+
wait_gate: null,
|
|
59
|
+
wait_gate_label: null,
|
|
60
|
+
created_at: "2026-01-01T00:00:00.000Z",
|
|
61
|
+
updated_at: "2026-01-01T00:00:00.000Z",
|
|
62
|
+
...over,
|
|
63
|
+
} as any);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("pollWaitGate: a root epic (no inbound edge) gets no wait-gate", async () => {
|
|
67
|
+
const { data } = memData();
|
|
68
|
+
await seedPlan(data, { plan_key: "o/r#1" });
|
|
69
|
+
await pollWaitGate(data);
|
|
70
|
+
const row = await plans(data).get("o/r#1");
|
|
71
|
+
assertEquals(row!.wait_gate, null);
|
|
72
|
+
assertEquals(row!.wait_gate_label, null);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("pollWaitGate: a parked dependent is projected 'waiting on <producer>'", async () => {
|
|
76
|
+
const { data } = memData();
|
|
77
|
+
await seedPlan(data, { plan_key: "o/r#1" }); // producer
|
|
78
|
+
// Dependent parked NOW (within the gate's bounded timeout — not yet escalated).
|
|
79
|
+
await seedPlan(data, { plan_key: "o/r#2", created_at: new Date().toISOString() }); // dependent, parked (no wave)
|
|
80
|
+
await recordPlanDep(data, {
|
|
81
|
+
plan_key: "o/r#2",
|
|
82
|
+
depends_on_plan_key: "o/r#1",
|
|
83
|
+
package: "@scope/api",
|
|
84
|
+
capability_ref: "o/r#1",
|
|
85
|
+
});
|
|
86
|
+
await pollWaitGate(data);
|
|
87
|
+
const dep = await plans(data).get("o/r#2");
|
|
88
|
+
assertEquals(dep!.wait_gate, "waiting");
|
|
89
|
+
assert(dep!.wait_gate_label.includes("o/r#1 @ @scope/api"), "shows the blocking producer/package");
|
|
90
|
+
// The producer itself is a root — never gets a gate.
|
|
91
|
+
assertEquals((await plans(data).get("o/r#1"))!.wait_gate, null);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("pollWaitGate: a satisfied dependent shows its bound version", async () => {
|
|
95
|
+
const { data } = memData();
|
|
96
|
+
await seedPlan(data, { plan_key: "o/r#1" });
|
|
97
|
+
await seedPlan(data, {
|
|
98
|
+
plan_key: "o/r#2",
|
|
99
|
+
current_wave: 0,
|
|
100
|
+
bound_artifacts: JSON.stringify(["@scope/api@1.4.0"]),
|
|
101
|
+
});
|
|
102
|
+
await recordPlanDep(data, {
|
|
103
|
+
plan_key: "o/r#2",
|
|
104
|
+
depends_on_plan_key: "o/r#1",
|
|
105
|
+
package: "@scope/api",
|
|
106
|
+
capability_ref: "o/r#1",
|
|
107
|
+
});
|
|
108
|
+
await pollWaitGate(data);
|
|
109
|
+
const dep = await plans(data).get("o/r#2");
|
|
110
|
+
assertEquals(dep!.wait_gate, "ready");
|
|
111
|
+
assert(dep!.wait_gate_label.includes("@scope/api@1.4.0"), "surfaces the bound pkg@version");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("pollWaitGate: is idempotent — a steady-state second pass rewrites nothing", async () => {
|
|
115
|
+
const { data } = memData();
|
|
116
|
+
await seedPlan(data, { plan_key: "o/r#1" });
|
|
117
|
+
await seedPlan(data, { plan_key: "o/r#2" });
|
|
118
|
+
await recordPlanDep(data, {
|
|
119
|
+
plan_key: "o/r#2",
|
|
120
|
+
depends_on_plan_key: "o/r#1",
|
|
121
|
+
package: "@scope/api",
|
|
122
|
+
capability_ref: "o/r#1",
|
|
123
|
+
});
|
|
124
|
+
await pollWaitGate(data);
|
|
125
|
+
const afterFirst = (await plans(data).get("o/r#2"))!.updated_at;
|
|
126
|
+
await pollWaitGate(data);
|
|
127
|
+
const afterSecond = (await plans(data).get("o/r#2"))!.updated_at;
|
|
128
|
+
assertEquals(afterSecond, afterFirst, "no-op pass must not re-stamp updated_at");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("pollWaitGate: clears a stale gate when a plan no longer has inbound edges", async () => {
|
|
132
|
+
const { data } = memData();
|
|
133
|
+
await seedPlan(data, {
|
|
134
|
+
plan_key: "o/r#2",
|
|
135
|
+
wait_gate: "waiting",
|
|
136
|
+
wait_gate_label: "waiting on o/r#1 @ @scope/api",
|
|
137
|
+
});
|
|
138
|
+
// No plan_deps edges exist -> the projection must clear the phantom gate.
|
|
139
|
+
await pollWaitGate(data);
|
|
140
|
+
const row = await plans(data).get("o/r#2");
|
|
141
|
+
assertEquals(row!.wait_gate, null);
|
|
142
|
+
assertEquals(row!.wait_gate_label, null);
|
|
143
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Structural guard for the OPERATOR-VISIBILITY wiring of the inter-epic gate (issue #292, slice S4).
|
|
2
|
+
// S4 makes a parked dependent observable rather than a silent stall: the `select-wave` worker captures
|
|
3
|
+
// the preflight's bound `pkg@version`s off `resolvedArtifacts`, and the epic index/detail pages read
|
|
4
|
+
// the derived `wait_gate` projection + the raw `plan_deps` DAG. These pure text assertions lock the
|
|
5
|
+
// three surfaces that make that projection reachable end-to-end (the BPMN envelope that feeds the
|
|
6
|
+
// capture, and the two page datasources that display it), matching the repo's model-guard style (see
|
|
7
|
+
// planFanoutPreflight.test.ts).
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { assert, assertStringIncludes } from "#test-assert";
|
|
11
|
+
|
|
12
|
+
test("select-wave's input envelope carries resolvedArtifacts so the worker can capture the bound version", () => {
|
|
13
|
+
const bpmn = readFileSync("resources/processes/plan-fanout.bpmn", "utf8");
|
|
14
|
+
const flat = bpmn.replace(/\s+/g, " ");
|
|
15
|
+
const shape = flat.match(/<nano:shape\b[^>]*\bid="SelectWaveIn"[\s\S]*?<\/nano:shape>/);
|
|
16
|
+
assert(shape, "SelectWaveIn envelope must exist");
|
|
17
|
+
assertStringIncludes(
|
|
18
|
+
shape![0],
|
|
19
|
+
'name="resolvedArtifacts"',
|
|
20
|
+
"select-wave must receive the preflight's resolvedArtifacts (the bound pkg@version list)",
|
|
21
|
+
);
|
|
22
|
+
// It is a process var the preflight MI produces — optional so a root (no preflight) is well-formed.
|
|
23
|
+
assertStringIncludes(shape![0], 'name="resolvedArtifacts" type="string" list="true" optional="true"');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("the epics index projects the wait-gate column", () => {
|
|
27
|
+
const page = JSON.parse(readFileSync("pages/epic.page.json", "utf8"));
|
|
28
|
+
const grid = page.nodes.find((n: any) => n.id === "epic-plans");
|
|
29
|
+
assert(grid, "epics index must have the epic-plans grid");
|
|
30
|
+
const cols: string[] = grid.props.columns.map((c: any) => c.field);
|
|
31
|
+
assert(cols.includes("wait_gate_label"), "the index shows the wait-gate at a glance");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("the epic detail projects both the DAG (plan_deps) and the wait-gate state", () => {
|
|
35
|
+
const page = JSON.parse(readFileSync("pages/epic-detail.page.json", "utf8"));
|
|
36
|
+
// 1. The inter-epic DAG: a dataGrid over plan_deps filtered to this epic (who it waits on).
|
|
37
|
+
const dag = page.nodes.find((n: any) => n.id === "inter-epic-deps");
|
|
38
|
+
assert(dag, "epic detail must project the inter-epic dependency DAG");
|
|
39
|
+
assert(dag.props.data.table === "plan_deps", "the DAG grid reads the plan_deps edges");
|
|
40
|
+
assert(
|
|
41
|
+
dag.props.data.filter.some((f: any) => f.field === "plan_key" && f.eqParam),
|
|
42
|
+
"the DAG grid is scoped to this epic's inbound edges",
|
|
43
|
+
);
|
|
44
|
+
const dagCols: string[] = dag.props.columns.map((c: any) => c.field);
|
|
45
|
+
for (const f of ["depends_on_plan_key", "package", "capability_ref"]) {
|
|
46
|
+
assert(dagCols.includes(f), `the DAG grid shows ${f}`);
|
|
47
|
+
}
|
|
48
|
+
// 2. The gate state on the Plan grid: a column + detail fields for the label and bound version.
|
|
49
|
+
const plan = page.nodes.find((n: any) => n.id === "epic-plan");
|
|
50
|
+
const planCols: string[] = plan.props.columns.map((c: any) => c.field);
|
|
51
|
+
assert(planCols.includes("wait_gate_label"), "the Plan grid shows the wait-gate label");
|
|
52
|
+
const detailFields: string[] = plan.props.detail.fields.map((f: any) => f.field);
|
|
53
|
+
assert(detailFields.includes("wait_gate_label"), "the detail explains the gate state");
|
|
54
|
+
assert(detailFields.includes("bound_artifacts"), "the detail shows the bound producer capabilities");
|
|
55
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- 047_plan_wait_gate.sql — issue #292 slice S4: make the inter-epic schedule OBSERVABLE to an
|
|
2
|
+
-- operator. S1 landed the `plan_deps` edges (which epic waits on which); S3 lowers each dependent
|
|
3
|
+
-- behind a leading `capability` readiness PREFLIGHT (plan-fanout.bpmn) that holds wave 0 until every
|
|
4
|
+
-- producer publishes the awaited `pkg@version`, then escalates (bounded) on timeout. Until now a
|
|
5
|
+
-- parked dependent was a SILENT stall — the epic views showed no wave, no delivery, no reason.
|
|
6
|
+
--
|
|
7
|
+
-- This slice reifies the dependent's gate state as three derived, display-only columns on `plans`,
|
|
8
|
+
-- mirroring the existing `delivery`/`delivery_label` (029) and `wave_label` (022) projections so the
|
|
9
|
+
-- declarative epic index/detail dataGrids read them as flat columns (Urban's datasource can't read a
|
|
10
|
+
-- SQL VIEW or join). All three are DERIVED — never written by the plan lifecycle nor hand-derived in
|
|
11
|
+
-- SQL or the page:
|
|
12
|
+
-- • wait_gate — the dependent's gate state: 'waiting' (parked at the preflight, blocked on a
|
|
13
|
+
-- producer's capability) | 'ready' (the preflight went green and the epic has
|
|
14
|
+
-- fanned out, bound to a concrete version) | 'escalated' (the gate's bounded
|
|
15
|
+
-- timeout elapsed with no publish). NULL for a ROOT epic (no inbound edge → no
|
|
16
|
+
-- wait-gate) and for pre-S4 rows. Recomputed idempotently by `pollWaitGate`
|
|
17
|
+
-- (app/service.ts) from the pure `deriveWaitGate` (app/waitGate.ts), joining
|
|
18
|
+
-- each plan's inbound `plan_deps` edges against its own lifecycle.
|
|
19
|
+
-- • wait_gate_label — the human at-a-glance rollup the epic index/detail show, e.g.
|
|
20
|
+
-- "waiting on owner/repo#12 @ @scope/pkg · re-checks every 30s · escalates by …"
|
|
21
|
+
-- or "ready · bound @scope/pkg@1.4.0". NULL alongside `wait_gate`.
|
|
22
|
+
-- • bound_artifacts — JSON array of the resolved `pkg@version` strings the preflight bound (the
|
|
23
|
+
-- exact versions FIRST carrying each producer's capability), stamped by the
|
|
24
|
+
-- `select-wave` worker from the `resolvedArtifacts` process variable the
|
|
25
|
+
-- preflight produced — the same value that rides the implement task's prompt.
|
|
26
|
+
-- NULL until the gate goes green (and for roots, whose preflight is skipped).
|
|
27
|
+
--
|
|
28
|
+
-- Forward-only, additive (expand): all nullable with no default, so pre-S4 rows grandfather in as
|
|
29
|
+
-- NULL and never gate control flow (this is read-only projection over S1–S3 state — admission and
|
|
30
|
+
-- scheduling are untouched). Numbered after the current highest prefix on origin/main (046); the
|
|
31
|
+
-- runner wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
32
|
+
ALTER TABLE plans ADD COLUMN wait_gate TEXT;
|
|
33
|
+
ALTER TABLE plans ADD COLUMN wait_gate_label TEXT;
|
|
34
|
+
ALTER TABLE plans ADD COLUMN bound_artifacts TEXT;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
-- 048_feature_escalations.sql — issue #305: canonical, append-only source for a feature run's
|
|
2
|
+
-- escalation QUESTION, so the denormalised `feature_runs.escalation_question` column can be dropped
|
|
3
|
+
-- in the later CONTRACT phase without losing the text the Tasks inbox shows.
|
|
4
|
+
--
|
|
5
|
+
-- Today the `feature-escalation` question is denormalised onto `feature_runs.escalation_question`
|
|
6
|
+
-- (written by `record-feature-escalation`, read by `pollUserTasks`). #305 consolidates escalation
|
|
7
|
+
-- state onto the native `user_tasks` projection and removes the duplicate `feature_runs.escalation_*`
|
|
8
|
+
-- surface. The question text still has to come from SOMEWHERE the poller can read while a run is
|
|
9
|
+
-- parked — so, exactly like the surviving `plan_reviews` (adversarial plan-review log), `escalations`
|
|
10
|
+
-- (PR review-loop log) and `plan_trial_merges` (D3 trial-merge gate log) audit tables that already
|
|
11
|
+
-- enrich the plan/PR kinds' questions in `pollUserTasks`, the FEATURE kind gets its own append-only
|
|
12
|
+
-- audit log. `record-feature-escalation` appends one row per escalation entry (with the agent's
|
|
13
|
+
-- `question`), and the poller reads the newest row per feature as the live question.
|
|
14
|
+
--
|
|
15
|
+
-- Append-only (never updated/deleted): the newest `id` for a `feature_key` is the current question,
|
|
16
|
+
-- mirroring `escalations`/`latestOpenEscalationQuestion`. No "answered" flag is needed for display —
|
|
17
|
+
-- `pollUserTasks` already gates the row's existence on the run being parked at an observable task, so
|
|
18
|
+
-- the enrichment only supplies the text.
|
|
19
|
+
--
|
|
20
|
+
-- EXPAND (additive) phase: this only ADDS a table and BACKFILLS the currently-open escalations'
|
|
21
|
+
-- questions from `feature_runs`; nothing is dropped and the poller reads it with a fallback to the
|
|
22
|
+
-- legacy column, so it is safe to land before the contract phase. Numbered after the current highest
|
|
23
|
+
-- prefix on origin/main (047). The runner wraps each file in its own transaction, so this file must
|
|
24
|
+
-- NOT contain BEGIN/COMMIT.
|
|
25
|
+
CREATE TABLE feature_escalations (
|
|
26
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
27
|
+
feature_key TEXT NOT NULL,
|
|
28
|
+
question TEXT,
|
|
29
|
+
created_at TEXT NOT NULL,
|
|
30
|
+
-- Idempotency guard: the engine `jobKey` that wrote the row, exactly like `plan_reviews`
|
|
31
|
+
-- (migration 007) and `plan_trial_merges`. `record-feature-escalation` is at-least-once — a job
|
|
32
|
+
-- retried after the insert (crash/timeout before job completion) re-runs with the SAME `jobKey`, so
|
|
33
|
+
-- the writer reuses its existing row instead of appending a duplicate, which would otherwise bloat
|
|
34
|
+
-- this append-only log. NULL only for backfill rows (below), which have no originating job.
|
|
35
|
+
job_key TEXT
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE INDEX idx_feature_escalations_feature ON feature_escalations(feature_key, id);
|
|
39
|
+
-- Unique per (feature_key, job_key) enforces the retry guard. SQLite treats NULLs as distinct in a
|
|
40
|
+
-- UNIQUE index, so the backfill's NULL-job_key rows never collide with each other or a live insert.
|
|
41
|
+
CREATE UNIQUE INDEX idx_feature_escalations_job ON feature_escalations(feature_key, job_key);
|
|
42
|
+
|
|
43
|
+
-- Backfill: seed one audit row for every run currently parked at an answerable escalation with a
|
|
44
|
+
-- captured question, so a run parked WHEN THIS LANDS keeps showing its question after the poller
|
|
45
|
+
-- switches to reading the audit log (the poller's legacy-column fallback covers this too, but the
|
|
46
|
+
-- backfill makes the new log authoritative from boot). `escalation_open = 1` is the fail-closed
|
|
47
|
+
-- signal (migration 040) that all three legacy columns agree the run is answerable.
|
|
48
|
+
INSERT INTO feature_escalations (feature_key, question, created_at)
|
|
49
|
+
SELECT feature_key, escalation_question, COALESCE(updated_at, created_at)
|
|
50
|
+
FROM feature_runs
|
|
51
|
+
WHERE escalation_open = 1 AND escalation_question IS NOT NULL;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Adversarial end-to-end proof for the inter-epic capability gate on the REAL `plan-fanout.bpmn`
|
|
2
|
+
// (issue #292, slice S5). The sibling e2e (plan-fanout-preflight.e2e.ts) proves the GREEN and ROOT
|
|
3
|
+
// paths; this suite proves the RED, adversarial ones the whole feature hinges on — driven on the
|
|
4
|
+
// engine + virtual clock via `bootTestApp`, hermetic (a deterministic shell-builtin probe, no
|
|
5
|
+
// network, no GitHub):
|
|
6
|
+
//
|
|
7
|
+
// • S5 P1 — HOLDS WAVE 0: a dependent whose producer never publishes parks at the preflight and
|
|
8
|
+
// NEVER reaches `ensure-base-branch` (the head of the fan-out). The gate is a true barrier.
|
|
9
|
+
// • S5 P3 — ESCALATES, then DOESN'T WEDGE: the never-green probe opens exactly one
|
|
10
|
+
// `readiness-escalation-pf` user task; an operator who acknowledges it releases the gate onward
|
|
11
|
+
// to the fan-out head — the stall is surfaced and recoverable, not a silent dead end.
|
|
12
|
+
// • S5 P7 — NEVER HANGS FOREVER: with NO human acting, the escalation's bounded SLA timer
|
|
13
|
+
// (`be_pf_sla`) fires and auto-abandons the gate onward — a removed/never-finishing producer can
|
|
14
|
+
// never strand the dependent indefinitely.
|
|
15
|
+
//
|
|
16
|
+
// We assert on the cumulative taken sequence flows (the WASM engine folds completed variables away),
|
|
17
|
+
// exactly like the sibling plan-fanout e2es.
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { dirname, join, resolve } from "node:path";
|
|
22
|
+
import { after, before, describe, test } from "node:test";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
25
|
+
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
26
|
+
|
|
27
|
+
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
28
|
+
|
|
29
|
+
const GITHUB_ENV_OVERRIDES: Record<string, string> = {
|
|
30
|
+
NANO_PR_GITHUB_TRANSPORT: "token",
|
|
31
|
+
GITHUB_TOKEN: "",
|
|
32
|
+
};
|
|
33
|
+
const savedEnv = new Map<string, string | undefined>();
|
|
34
|
+
|
|
35
|
+
interface TakenFlow {
|
|
36
|
+
from: string;
|
|
37
|
+
to: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function takenFlows(app: TestApp): string[] {
|
|
41
|
+
const snapshot = app.snapshot();
|
|
42
|
+
const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
|
|
43
|
+
return flows
|
|
44
|
+
.filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
|
|
45
|
+
.map((f) => `${f.from}->${f.to}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The full variable set `startPlan` seeds onto a plan-fanout instance (mirrors
|
|
49
|
+
// plan-fanout-preflight.e2e.ts). We inject `readinessProbes` directly — the shape S3's set lowering
|
|
50
|
+
// seeds for a DEPENDENT — so we can drive the gate without standing up a whole two-epic set.
|
|
51
|
+
function planVars(overrides: Record<string, unknown>): Record<string, unknown> {
|
|
52
|
+
return {
|
|
53
|
+
planKey: "owner/repo#2",
|
|
54
|
+
repo: "owner/repo",
|
|
55
|
+
issue: "owner/repo#2",
|
|
56
|
+
issueNumber: 2,
|
|
57
|
+
issueUrl: "https://github.com/owner/repo/issues/2",
|
|
58
|
+
planFindings: null,
|
|
59
|
+
planReviewEpoch: 0,
|
|
60
|
+
escalationSlaTimeout: "PT24H",
|
|
61
|
+
escalationAssignee: null,
|
|
62
|
+
blackboardUrl: "http://blackboard.local/x",
|
|
63
|
+
blackboardBrief: "",
|
|
64
|
+
baseBranch: "epic/e2e",
|
|
65
|
+
baseBranchBrief: "",
|
|
66
|
+
waveCount: 1,
|
|
67
|
+
readinessProbes: null,
|
|
68
|
+
probeTimeout: null,
|
|
69
|
+
gateKey: null,
|
|
70
|
+
resolvedArtifacts: null,
|
|
71
|
+
...overrides,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A never-green probe: `false` is never ready. The worker's local poll budget is the seeded
|
|
76
|
+
// `probeTimeout` (in real time), so a SHORT `probeTimeout` (PT2S below) makes the worker exhaust its
|
|
77
|
+
// budget quickly and settle NOT-ready — the gateway's default `pf_escalate` arm then fires, parking
|
|
78
|
+
// the gate on the escalation user task (P1/P3) until either a human or the SLA timer (P7) resolves it.
|
|
79
|
+
function redProbe(): Record<string, unknown> {
|
|
80
|
+
return { kind: "command", target: "false", poll: { everyMs: 5, backoff: "fixed" } };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function boot(): Promise<{ app: TestApp; dbDir: string }> {
|
|
84
|
+
const dbDir = mkdtempSync(join(tmpdir(), "nwf-s5-interepic-"));
|
|
85
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` } });
|
|
86
|
+
return { app, dbDir };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("inter-epic capability gate — adversarial (plan-fanout.bpmn, issue #292 S5)", () => {
|
|
90
|
+
let restoreGithub: (() => void) | undefined;
|
|
91
|
+
|
|
92
|
+
before(() => {
|
|
93
|
+
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
94
|
+
savedEnv.set(k, process.env[k]);
|
|
95
|
+
process.env[k] = v;
|
|
96
|
+
}
|
|
97
|
+
// The fan-out head (`pr.ensure-base-branch`) reads/creates the base ref via the token transport;
|
|
98
|
+
// pin the shared hermetic admit-github stub so a released gate reaches it offline.
|
|
99
|
+
restoreGithub = installAdmitGithub(admitGithubState("owner/repo", "main"));
|
|
100
|
+
});
|
|
101
|
+
after(() => {
|
|
102
|
+
restoreGithub?.();
|
|
103
|
+
for (const [k, v] of savedEnv) {
|
|
104
|
+
if (v === undefined) delete process.env[k];
|
|
105
|
+
else process.env[k] = v;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── S5 P1 — the gate HOLDS wave 0 ───────────────────────────────────────────────────────────────
|
|
110
|
+
test("S5 P1: a dependent whose producer never publishes parks at the preflight and never reaches the fan-out head", async () => {
|
|
111
|
+
const { app, dbDir } = await boot();
|
|
112
|
+
try {
|
|
113
|
+
const { processInstanceKey } = await app.engine.createInstance({
|
|
114
|
+
processDefinitionId: "plan-fanout",
|
|
115
|
+
variables: planVars({
|
|
116
|
+
readinessProbes: [redProbe()],
|
|
117
|
+
probeTimeout: "PT2S",
|
|
118
|
+
gateKey: "preflight:owner/repo#2",
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
await app.settle();
|
|
122
|
+
|
|
123
|
+
const flows = takenFlows(app);
|
|
124
|
+
// It entered the gate (it is a dependent, not a root skip)…
|
|
125
|
+
assert.ok(
|
|
126
|
+
flows.includes("gw-readiness->readiness-preflight"),
|
|
127
|
+
`the dependent enters the preflight (flows: ${flows.join(", ")})`,
|
|
128
|
+
);
|
|
129
|
+
// …the probe went NOT-ready, so the gate never released as ready…
|
|
130
|
+
assert.ok(!flows.includes("pf_gw->pf_end"), "a never-green probe never releases the gate as ready");
|
|
131
|
+
// …and CRUCIALLY it never fanned out: the head of the fan-out was never reached.
|
|
132
|
+
assert.ok(
|
|
133
|
+
!flows.includes("readiness-preflight->ensure-base-branch"),
|
|
134
|
+
"the gate HOLDS wave 0 — a parked dependent never reaches the fan-out head",
|
|
135
|
+
);
|
|
136
|
+
// The token is parked on the escalation user task, not lost.
|
|
137
|
+
const tasks = (await app.engine.searchUserTasks({ processInstanceKey })).filter(
|
|
138
|
+
(t) => t.elementId === "readiness-escalation-pf",
|
|
139
|
+
);
|
|
140
|
+
assert.equal(tasks.length, 1, "the held gate surfaces as exactly one escalation task, not a silent stall");
|
|
141
|
+
} finally {
|
|
142
|
+
await app.stop();
|
|
143
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── S5 P3 — it ESCALATES, and an operator ack un-wedges it ───────────────────────────────────────
|
|
148
|
+
test("S5 P3: the never-green gate escalates exactly once, and acknowledging it releases the fan-out (no wedge)", async () => {
|
|
149
|
+
const { app, dbDir } = await boot();
|
|
150
|
+
try {
|
|
151
|
+
const { processInstanceKey } = await app.engine.createInstance({
|
|
152
|
+
processDefinitionId: "plan-fanout",
|
|
153
|
+
variables: planVars({
|
|
154
|
+
readinessProbes: [redProbe()],
|
|
155
|
+
probeTimeout: "PT2S",
|
|
156
|
+
gateKey: "preflight:owner/repo#2",
|
|
157
|
+
}),
|
|
158
|
+
});
|
|
159
|
+
await app.settle();
|
|
160
|
+
|
|
161
|
+
const escalations = (await app.engine.searchUserTasks({ processInstanceKey })).filter(
|
|
162
|
+
(t) => t.elementId === "readiness-escalation-pf",
|
|
163
|
+
);
|
|
164
|
+
assert.equal(escalations.length, 1, "a never-publishing producer escalates exactly once (bounded, not a storm)");
|
|
165
|
+
|
|
166
|
+
// An operator acknowledges the stall — the gate must NOT wedge: it proceeds onward to the fan-out.
|
|
167
|
+
await app.engine.completeUserTask(escalations[0].userTaskKey, { resolution: "acknowledge" });
|
|
168
|
+
await app.settle();
|
|
169
|
+
|
|
170
|
+
const flows = takenFlows(app);
|
|
171
|
+
assert.ok(
|
|
172
|
+
flows.includes("readiness-escalation-pf->pf_gw_res") && flows.includes("pf_gw_res->pf_end"),
|
|
173
|
+
`acknowledging routes through the resolution gateway to settle the gate (flows: ${flows.join(", ")})`,
|
|
174
|
+
);
|
|
175
|
+
assert.ok(
|
|
176
|
+
flows.includes("readiness-preflight->ensure-base-branch"),
|
|
177
|
+
"an acknowledged gate is NOT wedged — it releases onward to the fan-out head",
|
|
178
|
+
);
|
|
179
|
+
} finally {
|
|
180
|
+
await app.stop();
|
|
181
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ── S5 P7 — the SLA timer means it NEVER hangs forever, even with no human ────────────────────────
|
|
186
|
+
test("S5 P7: with no operator acting, the escalation's bounded SLA auto-abandons onward — the gate never hangs forever", async () => {
|
|
187
|
+
const { app, dbDir } = await boot();
|
|
188
|
+
try {
|
|
189
|
+
const { processInstanceKey } = await app.engine.createInstance({
|
|
190
|
+
processDefinitionId: "plan-fanout",
|
|
191
|
+
variables: planVars({
|
|
192
|
+
readinessProbes: [redProbe()],
|
|
193
|
+
probeTimeout: "PT2S",
|
|
194
|
+
escalationSlaTimeout: "PT1H",
|
|
195
|
+
gateKey: "preflight:owner/repo#2",
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
await app.settle();
|
|
199
|
+
|
|
200
|
+
// Parked on the escalation, no human acts. Before the SLA it has NOT proceeded.
|
|
201
|
+
const before = takenFlows(app);
|
|
202
|
+
assert.ok(!before.includes("be_pf_sla->pf_end"), "the SLA has not yet fired");
|
|
203
|
+
assert.ok(
|
|
204
|
+
!before.includes("readiness-preflight->ensure-base-branch"),
|
|
205
|
+
"the gate is still held before the SLA elapses",
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// Advancing past the escalation SLA is the ONLY thing that ends the unattended wait — proving the
|
|
209
|
+
// bound is engine-owned. It auto-abandons the gate ONWARD (never strands the dependent forever).
|
|
210
|
+
await app.advanceTime(61 * 60 * 1000);
|
|
211
|
+
await app.settle();
|
|
212
|
+
|
|
213
|
+
const after = takenFlows(app);
|
|
214
|
+
assert.ok(
|
|
215
|
+
after.includes("be_pf_sla->pf_end"),
|
|
216
|
+
`the bounded SLA timer fires and settles the held gate (flows: ${after.join(", ")})`,
|
|
217
|
+
);
|
|
218
|
+
assert.ok(
|
|
219
|
+
after.includes("readiness-preflight->ensure-base-branch"),
|
|
220
|
+
"the auto-abandoned gate releases onward — the dependent is never stranded indefinitely",
|
|
221
|
+
);
|
|
222
|
+
} finally {
|
|
223
|
+
await app.stop();
|
|
224
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.96.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
{ "field": "epic_phase", "header": "Phase" },
|
|
62
62
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
63
63
|
{ "field": "delivery", "header": "Delivery" },
|
|
64
|
+
{ "field": "wait_gate_label", "header": "Wait gate" },
|
|
64
65
|
{ "field": "promotion_state", "header": "Promotion" },
|
|
65
66
|
{ "field": "wave_label", "header": "Wave" },
|
|
66
67
|
{ "field": "task_count", "header": "Tasks" },
|
|
@@ -73,12 +74,39 @@
|
|
|
73
74
|
{ "field": "issue_number", "label": "Issue number" },
|
|
74
75
|
{ "field": "base_branch", "label": "Base branch (blank = repo default)" },
|
|
75
76
|
{ "field": "delivery_label", "label": "Delivery rollup (slices merged / converging)" },
|
|
77
|
+
{ "field": "wait_gate_label", "label": "Inter-epic gate (waiting on / bound version / escalated)" },
|
|
78
|
+
{ "field": "bound_artifacts", "label": "Bound producer capabilities (pkg@version)" },
|
|
76
79
|
{ "field": "promotion_pr", "label": "Promotion PR (epic/* → default branch)" },
|
|
77
80
|
{ "field": "outcome", "label": "Outcome" }
|
|
78
81
|
]
|
|
79
82
|
}
|
|
80
83
|
}
|
|
81
84
|
},
|
|
85
|
+
{
|
|
86
|
+
"type": "dataGrid",
|
|
87
|
+
"id": "inter-epic-deps",
|
|
88
|
+
"props": {
|
|
89
|
+
"title": "Inter-epic dependencies (waiting on)",
|
|
90
|
+
"rowKey": "depends_on_plan_key",
|
|
91
|
+
"refreshMs": 5000,
|
|
92
|
+
"collapsible": true,
|
|
93
|
+
"defaultCollapsed": true,
|
|
94
|
+
"empty": "No inter-epic dependencies — this is a root epic (it starts immediately, no capability wait-gate).",
|
|
95
|
+
"data": {
|
|
96
|
+
"kind": "datasource",
|
|
97
|
+
"source": "app",
|
|
98
|
+
"table": "plan_deps",
|
|
99
|
+
"orderBy": { "field": "created_at", "dir": "asc" },
|
|
100
|
+
"filter": [{ "field": "plan_key", "eqParam": true }]
|
|
101
|
+
},
|
|
102
|
+
"columns": [
|
|
103
|
+
{ "field": "depends_on_plan_key", "header": "Waits on (producer epic)" },
|
|
104
|
+
{ "field": "package", "header": "Package" },
|
|
105
|
+
{ "field": "capability_ref", "header": "Capability ref" },
|
|
106
|
+
{ "field": "created_at", "header": "Recorded", "width": "9rem" }
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
},
|
|
82
110
|
{
|
|
83
111
|
"type": "dataGrid",
|
|
84
112
|
"id": "wave-state",
|
package/pages/epic.page.json
CHANGED
|
@@ -79,6 +79,7 @@
|
|
|
79
79
|
{ "field": "epic_phase", "header": "Phase" },
|
|
80
80
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
81
81
|
{ "field": "delivery", "header": "Delivery" },
|
|
82
|
+
{ "field": "wait_gate_label", "header": "Wait gate" },
|
|
82
83
|
{ "field": "promotion_state", "header": "Promotion" },
|
|
83
84
|
{ "field": "delivery_label", "header": "Landing" },
|
|
84
85
|
{ "field": "base_branch", "header": "Base branch" },
|