@nanobpm/nano-workforce 0.95.0 → 0.96.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 +14 -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 +3 -8
- 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,516 @@
|
|
|
1
|
+
// Adversarial completeness / regression suite for the INTER-EPIC dependency feature (issue #292,
|
|
2
|
+
// slice S5). Where the per-slice tests each prove one layer, THIS suite pins the whole feature to its
|
|
3
|
+
// worked example end to end and fails loudly if ANY of the eight load-bearing guarantees regresses:
|
|
4
|
+
//
|
|
5
|
+
// producer epic `owner/repo#1` publishes a capability (a Release of `@scope/pkg` whose provenance
|
|
6
|
+
// carries `#1`) → consumer epic `owner/repo#2` depends on it via the edge
|
|
7
|
+
// { consumer: #2, producer: #1, package: @scope/pkg, capabilityRef: #1 }.
|
|
8
|
+
//
|
|
9
|
+
// Each test is labelled `S5 Pn` for the property it guards. The properties that are fundamentally
|
|
10
|
+
// ENGINE behaviours (a red gate HOLDS wave 0; a never-publishing producer escalates on a bounded
|
|
11
|
+
// timer; the SLA auto-abandon prevents an eternal hang) are proven on the real `plan-fanout.bpmn` in
|
|
12
|
+
// the sibling e2e (e2e/inter-epic-dependency.e2e.ts); here we drive the real admission door, the pure
|
|
13
|
+
// planner lowering, the capability resolver, and the operator projection — deterministic, no network.
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import { assert, assertEquals } from "#test-assert";
|
|
16
|
+
import type { AppApi, DataLayer, EngineClient } from "@nanobpm/urban";
|
|
17
|
+
import { resetDefaultBranchCache } from "./github.ts";
|
|
18
|
+
import type { PlanDep } from "./plan.ts";
|
|
19
|
+
import { EpicSetValidationError, validateEpicSet } from "./plan.ts";
|
|
20
|
+
import { capabilityProbeForEdge, deriveEpicSchedule, lowerAdmittedSet } from "./planLowering.ts";
|
|
21
|
+
import {
|
|
22
|
+
type GithubRelease,
|
|
23
|
+
matchCapability,
|
|
24
|
+
newestPublishedVersion,
|
|
25
|
+
type ProbeExec,
|
|
26
|
+
probeOnce,
|
|
27
|
+
} from "./readiness.ts";
|
|
28
|
+
import { deriveWaitGate, type WaitGateLifecycle } from "./waitGate.ts";
|
|
29
|
+
|
|
30
|
+
// ── the worked example ───────────────────────────────────────────────────────────────────────────
|
|
31
|
+
const REPO = "owner/repo";
|
|
32
|
+
const PRODUCER = `${REPO}#1`;
|
|
33
|
+
const CONSUMER = `${REPO}#2`;
|
|
34
|
+
const PKG = "@scope/pkg";
|
|
35
|
+
const CAP_REF = PRODUCER; // the producer's own issue handle is the capability ref
|
|
36
|
+
|
|
37
|
+
const workedEdge: PlanDep = {
|
|
38
|
+
plan_key: CONSUMER,
|
|
39
|
+
depends_on_plan_key: PRODUCER,
|
|
40
|
+
package: PKG,
|
|
41
|
+
capability_ref: CAP_REF,
|
|
42
|
+
created_at: "2026-01-01T00:00:00.000Z",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** A GitHub Release whose `## Provenance` section references the given issue numbers. */
|
|
46
|
+
const rel = (tag: string, refs: number[]): GithubRelease => ({
|
|
47
|
+
tag,
|
|
48
|
+
body: `Automated release of \`${tag}\`.\n\n## Provenance\n${refs.map((n) => `- #${n}`).join("\n")}\n`,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ── in-memory app double (data + engine) — mirrors planLowering.test / the admission integration ──
|
|
52
|
+
function makeApp(seedPlans: Record<string, unknown>[] = []) {
|
|
53
|
+
const tables = new Map<string, Record<string, unknown>[]>();
|
|
54
|
+
tables.set("plans", [...seedPlans]);
|
|
55
|
+
const started: { processDefinitionId: string; variables?: Record<string, unknown> }[] = [];
|
|
56
|
+
const table = (name: string, key: string) => {
|
|
57
|
+
const rows = tables.get(name) ?? (() => {
|
|
58
|
+
const fresh: Record<string, unknown>[] = [];
|
|
59
|
+
tables.set(name, fresh);
|
|
60
|
+
return fresh;
|
|
61
|
+
})();
|
|
62
|
+
return {
|
|
63
|
+
get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
64
|
+
find: (q: Record<string, unknown>) =>
|
|
65
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
66
|
+
insert: (r: Record<string, unknown>) => {
|
|
67
|
+
rows.push(r);
|
|
68
|
+
return Promise.resolve(r);
|
|
69
|
+
},
|
|
70
|
+
update: (k: unknown, patch: Record<string, unknown>) => {
|
|
71
|
+
const row = rows.find((r) => r[key] === k);
|
|
72
|
+
if (row) Object.assign(row, patch);
|
|
73
|
+
return Promise.resolve(row);
|
|
74
|
+
},
|
|
75
|
+
delete: (k: unknown) => {
|
|
76
|
+
const i = rows.findIndex((r) => r[key] === k);
|
|
77
|
+
if (i >= 0) rows.splice(i, 1);
|
|
78
|
+
return Promise.resolve();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
const app = {
|
|
83
|
+
data: { table },
|
|
84
|
+
engine: {
|
|
85
|
+
createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
|
|
86
|
+
started.push(req);
|
|
87
|
+
return Promise.resolve({ processInstanceKey: `PI-${started.length}` });
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
log: { debug() {}, info() {}, warn() {}, error() {} },
|
|
91
|
+
} as unknown as AppApi;
|
|
92
|
+
return { app, started, tables };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── hermetic github transport (base-ref admission), mirroring the admission integration harness ────
|
|
96
|
+
interface GithubState {
|
|
97
|
+
repo: string;
|
|
98
|
+
defaultBranch: string;
|
|
99
|
+
branches: Set<string>;
|
|
100
|
+
creates: { ref: string; sha: string }[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function githubFetch(state: GithubState) {
|
|
104
|
+
return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
105
|
+
const u = new URL(String(url));
|
|
106
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
107
|
+
const path = u.pathname;
|
|
108
|
+
const json = (obj: unknown, status = 200) =>
|
|
109
|
+
new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
|
|
110
|
+
if (method === "GET" && path === `/repos/${state.repo}`) {
|
|
111
|
+
return Promise.resolve(json({ default_branch: state.defaultBranch }));
|
|
112
|
+
}
|
|
113
|
+
const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
|
|
114
|
+
if (method === "GET" && path.startsWith(refPrefix)) {
|
|
115
|
+
const branch = decodeURIComponent(path.slice(refPrefix.length));
|
|
116
|
+
if (!state.branches.has(branch)) return Promise.resolve(new Response("Not Found", { status: 404 }));
|
|
117
|
+
return Promise.resolve(json({ ref: `refs/heads/${branch}`, object: { sha: `${branch}-sha` } }));
|
|
118
|
+
}
|
|
119
|
+
if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
|
|
120
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
121
|
+
const bodyObj = JSON.parse(String(init?.body ?? "{}")) as { ref?: string; sha?: string };
|
|
122
|
+
const ref = String(bodyObj.ref ?? "");
|
|
123
|
+
const sha = String(bodyObj.sha ?? "");
|
|
124
|
+
const branch = ref.replace(/^refs\/heads\//, "");
|
|
125
|
+
if (state.branches.has(branch)) return Promise.resolve(json({ message: "Reference already exists" }, 422));
|
|
126
|
+
state.creates.push({ ref, sha });
|
|
127
|
+
state.branches.add(branch);
|
|
128
|
+
return Promise.resolve(json({ ref }, 201));
|
|
129
|
+
}
|
|
130
|
+
return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function withGithub<T>(state: GithubState, fn: () => Promise<T>): Promise<T> {
|
|
135
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
136
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
137
|
+
const prevFetch = globalThis.fetch;
|
|
138
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
139
|
+
process.env["GITHUB_TOKEN"] = "tok";
|
|
140
|
+
resetDefaultBranchCache();
|
|
141
|
+
globalThis.fetch = githubFetch(state) as typeof fetch;
|
|
142
|
+
try {
|
|
143
|
+
return await fn();
|
|
144
|
+
} finally {
|
|
145
|
+
resetDefaultBranchCache();
|
|
146
|
+
globalThis.fetch = prevFetch;
|
|
147
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
148
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
149
|
+
if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
|
|
150
|
+
else process.env["GITHUB_TOKEN"] = prevTok;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function freshGithub(extraBranches: string[] = []): GithubState {
|
|
155
|
+
return { repo: REPO, defaultBranch: "main", branches: new Set(["main", ...extraBranches]), creates: [] };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function input(body: unknown) {
|
|
159
|
+
return {
|
|
160
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal request stub for the operation edge
|
|
161
|
+
req: { method: "POST", path: "/", query: new URLSearchParams(), headers: new Headers(), text: async () => "" } as any,
|
|
162
|
+
params: {},
|
|
163
|
+
query: {},
|
|
164
|
+
body,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The delegate is imported lazily inside each door test so the pure-property tests need no github env.
|
|
169
|
+
async function callDoor(app: AppApi, body: unknown) {
|
|
170
|
+
const { default: startEpicSet } = await import("../operations/startEpicSet.ts");
|
|
171
|
+
// biome-ignore lint/suspicious/noExplicitAny: the operation returns an HTTP-shaped result envelope
|
|
172
|
+
return startEpicSet(input(body), app) as Promise<any>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const rowsFor = (tables: Map<string, Record<string, unknown>[]>, name: string) => tables.get(name) ?? [];
|
|
176
|
+
|
|
177
|
+
// A pure in-memory data/engine double (no github) for the lowering-level property tests.
|
|
178
|
+
function makeData() {
|
|
179
|
+
const tables = new Map<string, Record<string, unknown>[]>();
|
|
180
|
+
const table = (name: string, key: string) => {
|
|
181
|
+
const rows = tables.get(name) ?? (() => {
|
|
182
|
+
const fresh: Record<string, unknown>[] = [];
|
|
183
|
+
tables.set(name, fresh);
|
|
184
|
+
return fresh;
|
|
185
|
+
})();
|
|
186
|
+
return {
|
|
187
|
+
get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
188
|
+
find: (q: Record<string, unknown>) =>
|
|
189
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
190
|
+
insert: (r: Record<string, unknown>) => {
|
|
191
|
+
rows.push(r);
|
|
192
|
+
return Promise.resolve(r);
|
|
193
|
+
},
|
|
194
|
+
update: (k: unknown, patch: Record<string, unknown>) => {
|
|
195
|
+
const row = rows.find((r) => r[key] === k);
|
|
196
|
+
if (row) Object.assign(row, patch);
|
|
197
|
+
return Promise.resolve(row);
|
|
198
|
+
},
|
|
199
|
+
delete: (k: unknown) => {
|
|
200
|
+
const i = rows.findIndex((r) => r[key] === k);
|
|
201
|
+
if (i >= 0) rows.splice(i, 1);
|
|
202
|
+
return Promise.resolve();
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
const started: { processDefinitionId: string; variables?: Record<string, unknown> }[] = [];
|
|
207
|
+
const engine = {
|
|
208
|
+
createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
|
|
209
|
+
started.push(req);
|
|
210
|
+
return Promise.resolve({ processInstanceKey: `PI-${started.length}` });
|
|
211
|
+
},
|
|
212
|
+
} as unknown as EngineClient;
|
|
213
|
+
const data = { table } as unknown as DataLayer;
|
|
214
|
+
return { data, engine, tables, started };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function stageEpic(tables: Map<string, Record<string, unknown>[]>, planKey: string, base: string) {
|
|
218
|
+
const rows = tables.get("admitted_epics") ?? [];
|
|
219
|
+
tables.set("admitted_epics", rows);
|
|
220
|
+
const [repo, num] = planKey.split("#");
|
|
221
|
+
rows.push({
|
|
222
|
+
plan_key: planKey,
|
|
223
|
+
repo,
|
|
224
|
+
issue_number: Number(num),
|
|
225
|
+
issue_url: `https://github.com/${repo}/issues/${num}`,
|
|
226
|
+
base_branch: base,
|
|
227
|
+
created_at: "t0",
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function stageEdge(tables: Map<string, Record<string, unknown>[]>, e: PlanDep) {
|
|
232
|
+
const rows = tables.get("admitted_plan_deps") ?? [];
|
|
233
|
+
tables.set("admitted_plan_deps", rows);
|
|
234
|
+
rows.push({ ...e });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Force token-transport with NO token so startPlan's best-effort issue-title lookup short-circuits to
|
|
238
|
+
// null (no `gh` shell-out, no network) — the epic falls back to its plan key for identity.
|
|
239
|
+
async function noFetch<T>(fn: () => Promise<T>): Promise<T> {
|
|
240
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
241
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
242
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
243
|
+
delete process.env["GITHUB_TOKEN"];
|
|
244
|
+
try {
|
|
245
|
+
return await fn();
|
|
246
|
+
} finally {
|
|
247
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
248
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
249
|
+
if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
|
|
250
|
+
else process.env["GITHUB_TOKEN"] = prevTok;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
255
|
+
// S5 P1 — a dependent is admitted GATED (a leading capability preflight), never as an eager root, so
|
|
256
|
+
// it CANNOT fan out wave 0 before its producer publishes. The engine HOLD is proven end to end in the
|
|
257
|
+
// e2e; here we prove the door seeds the gate that makes the hold possible.
|
|
258
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
259
|
+
test("S5 P1: the consumer is admitted behind a capability gate (seeded probe), the producer as an eager root", async () => {
|
|
260
|
+
const gh = freshGithub();
|
|
261
|
+
await withGithub(gh, async () => {
|
|
262
|
+
const { app, started } = makeApp();
|
|
263
|
+
const res = await callDoor(app, {
|
|
264
|
+
epics: [
|
|
265
|
+
{ issue: PRODUCER, baseBranch: "epic/producer" },
|
|
266
|
+
{ issue: CONSUMER, baseBranch: "epic/consumer" },
|
|
267
|
+
],
|
|
268
|
+
deps: [{ consumer: CONSUMER, producer: PRODUCER, package: PKG, capabilityRef: CAP_REF }],
|
|
269
|
+
});
|
|
270
|
+
assertEquals(res.status, 202);
|
|
271
|
+
assertEquals(res.body.roots, [PRODUCER]); // only the producer is a root
|
|
272
|
+
const byKey = new Map(started.map((s) => [s.variables?.["planKey"], s.variables ?? {}]));
|
|
273
|
+
// The producer fans out immediately — no leading probe.
|
|
274
|
+
assertEquals(byKey.get(PRODUCER)?.["readinessProbes"], null);
|
|
275
|
+
// The consumer carries a leading capability probe + a bounded timeout: it is parked at the
|
|
276
|
+
// preflight and cannot reach wave 0 until that probe goes green.
|
|
277
|
+
const depProbes = byKey.get(CONSUMER)?.["readinessProbes"] as unknown[] | null;
|
|
278
|
+
assert(Array.isArray(depProbes) && depProbes.length === 1, "the consumer is seeded with a capability gate");
|
|
279
|
+
assert(byKey.get(CONSUMER)?.["probeTimeout"] != null, "the consumer's gate carries a bounded timeout");
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
284
|
+
// S5 P2 — a submitted CYCLE is rejected at the edge with NO partial start: no epic started, no gate
|
|
285
|
+
// seeded, no durable/staged edge, no base branch created — one clean 4xx.
|
|
286
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
287
|
+
test("S5 P2: a submitted cycle is one clean 400 with NO epic started, NO gate seeded, NO edge, NO branch", async () => {
|
|
288
|
+
const gh = freshGithub();
|
|
289
|
+
await withGithub(gh, async () => {
|
|
290
|
+
const { app, started, tables } = makeApp();
|
|
291
|
+
const res = await callDoor(app, {
|
|
292
|
+
epics: [
|
|
293
|
+
{ issue: PRODUCER, baseBranch: "epic/a" },
|
|
294
|
+
{ issue: CONSUMER, baseBranch: "epic/b" },
|
|
295
|
+
],
|
|
296
|
+
deps: [
|
|
297
|
+
{ consumer: CONSUMER, producer: PRODUCER, package: PKG, capabilityRef: PRODUCER },
|
|
298
|
+
{ consumer: PRODUCER, producer: CONSUMER, package: PKG, capabilityRef: CONSUMER },
|
|
299
|
+
],
|
|
300
|
+
});
|
|
301
|
+
assertEquals(res.status, 400);
|
|
302
|
+
assertEquals(typeof res.body.error, "string");
|
|
303
|
+
assertEquals(started.length, 0, "no epic instance started on a rejected cycle");
|
|
304
|
+
assertEquals(rowsFor(tables, "plan_deps").length, 0, "no durable edge on a rejected cycle");
|
|
305
|
+
assertEquals(rowsFor(tables, "admitted_plan_deps").length, 0, "no staged edge on a rejected cycle");
|
|
306
|
+
assertEquals(rowsFor(tables, "admitted_epics").length, 0, "no epic staged on a rejected cycle");
|
|
307
|
+
assertEquals(gh.creates, [], "no base branch created — the cycle is rejected before any admitPlan side effect");
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("S5 P2: the pure validator rejects the two-node cycle as a 400 (the rule the door composes)", () => {
|
|
312
|
+
let err: EpicSetValidationError | undefined;
|
|
313
|
+
try {
|
|
314
|
+
validateEpicSet(
|
|
315
|
+
[PRODUCER, CONSUMER],
|
|
316
|
+
[
|
|
317
|
+
{ consumer: CONSUMER, producer: PRODUCER, package: PKG, capabilityRef: PRODUCER },
|
|
318
|
+
{ consumer: PRODUCER, producer: CONSUMER, package: PKG, capabilityRef: CONSUMER },
|
|
319
|
+
],
|
|
320
|
+
);
|
|
321
|
+
} catch (e) {
|
|
322
|
+
if (e instanceof EpicSetValidationError) err = e;
|
|
323
|
+
else throw e;
|
|
324
|
+
}
|
|
325
|
+
assert(err, "a cycle must raise EpicSetValidationError");
|
|
326
|
+
assertEquals(err!.status, 400);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
330
|
+
// S5 P3 — a never-publishing producer ESCALATES (bounded) and never wedges the dependent or the REST
|
|
331
|
+
// of the set. The engine timer + escalation task are proven in the e2e; here we prove the invariants
|
|
332
|
+
// that make it bounded: the derived probe escalates on timeout (never silently fails), an unpublished
|
|
333
|
+
// capability keeps waiting (never throws / never spuriously binds), and a stuck dependent does not
|
|
334
|
+
// stop its sibling ROOT from starting.
|
|
335
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
336
|
+
test("S5 P3: the derived capability probe escalates on timeout (bounded), it never fails the dependent silently", () => {
|
|
337
|
+
const probe = capabilityProbeForEdge(workedEdge);
|
|
338
|
+
assertEquals(probe.kind, "capability");
|
|
339
|
+
assertEquals(probe.onTimeout, "escalate"); // a stuck producer surfaces to a human, never a silent skip
|
|
340
|
+
assertEquals(probe.target, `github-releases:${REPO}`);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("S5 P3: an unpublished capability is 'not ready' (keeps waiting), never throws and never binds a phantom version", () => {
|
|
344
|
+
// The producer has published a release, but its provenance does NOT carry the consumer's capabilityRef.
|
|
345
|
+
const res = matchCapability(
|
|
346
|
+
{ package: PKG, capabilityRef: CAP_REF },
|
|
347
|
+
[rel(`${PKG}@9.9.9`, [999])],
|
|
348
|
+
);
|
|
349
|
+
assert(!res.ready, "an unpublished capability parks the gate, it does not go green");
|
|
350
|
+
assertEquals(res.bind, undefined, "nothing is bound while the capability is unpublished");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("S5 P3: a stuck dependent does not wedge the rest of the set — its sibling ROOT still starts", async () => {
|
|
354
|
+
const { data, engine, tables, started } = makeData();
|
|
355
|
+
// Two independent epics plus the gated consumer: the sibling root #3 must start even though the
|
|
356
|
+
// consumer #2 is parked behind a (never-publishing) producer #1.
|
|
357
|
+
stageEpic(tables, PRODUCER, "epic/producer");
|
|
358
|
+
stageEpic(tables, CONSUMER, "epic/consumer");
|
|
359
|
+
stageEpic(tables, `${REPO}#3`, "epic/independent");
|
|
360
|
+
stageEdge(tables, workedEdge);
|
|
361
|
+
|
|
362
|
+
const res = await noFetch(() => lowerAdmittedSet(data, engine, [PRODUCER, CONSUMER, `${REPO}#3`]));
|
|
363
|
+
|
|
364
|
+
assertEquals(res.roots.sort(), [PRODUCER, `${REPO}#3`], "both non-dependent epics start immediately");
|
|
365
|
+
assertEquals(res.dependents, [{ planKey: CONSUMER, producers: [PRODUCER] }]);
|
|
366
|
+
assertEquals(started.length, 3, "every epic is started — a gated dependent never blocks its siblings");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
370
|
+
// S5 P4 — the bound `pkg@version` is EXACTLY the one carrying the capability, NOT merely the newest
|
|
371
|
+
// published version. This is the crux adversarial case: a strictly-newer release exists that does NOT
|
|
372
|
+
// carry the capability, so a "take the latest" resolver would bind the wrong version.
|
|
373
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
374
|
+
test("S5 P4: with a newer non-carrying release present, the gate binds the LOWER capability-carrying version, not the newest", async () => {
|
|
375
|
+
const match = { package: PKG, capabilityRef: CAP_REF };
|
|
376
|
+
const releases = [
|
|
377
|
+
rel(`${PKG}@2.0.0`, [500]), // newest, but carries a DIFFERENT capability (#500), not #1
|
|
378
|
+
rel(`${PKG}@1.4.0`, [1]), // the version that FIRST carries #1 — the correct bind
|
|
379
|
+
rel(`${PKG}@1.6.0`, [1]), // also carries #1, but is higher than 1.4.0
|
|
380
|
+
];
|
|
381
|
+
|
|
382
|
+
const resolved = matchCapability(match, releases);
|
|
383
|
+
assert(resolved.ready, "the capability is published, so the gate goes green");
|
|
384
|
+
assertEquals(resolved.bind?.resolvedArtifact, `${PKG}@1.4.0`, "binds the lowest version that carries the capability");
|
|
385
|
+
|
|
386
|
+
// Prove the bind genuinely DISAGREES with 'newest published' — a latest-wins resolver would be wrong.
|
|
387
|
+
const newest = newestPublishedVersion(PKG, releases);
|
|
388
|
+
assertEquals(newest, "2.0.0", "the newest published version is a strictly higher, non-carrying release");
|
|
389
|
+
assert(resolved.bind?.resolvedArtifact !== `${PKG}@${newest}`, "the bound version is NOT the newest published version");
|
|
390
|
+
|
|
391
|
+
// And prove it through the REAL probe the planner lowers (capabilityProbeForEdge → probeOnce), so
|
|
392
|
+
// the guarantee holds on the executed path, not just the bare matcher. The exec stub returns the
|
|
393
|
+
// same provenance the gh api call would.
|
|
394
|
+
const probe = capabilityProbeForEdge(workedEdge);
|
|
395
|
+
// The probe's ONE outside-world seam: `run` returns the `gh api .../releases` payload the resolver
|
|
396
|
+
// reads. `httpGet` is unused by a capability probe but required by the ProbeExec interface.
|
|
397
|
+
const exec: ProbeExec = {
|
|
398
|
+
httpGet: () => Promise.resolve({ status: 200, body: "" }),
|
|
399
|
+
run: (command: string) =>
|
|
400
|
+
Promise.resolve({
|
|
401
|
+
code: 0,
|
|
402
|
+
stdout: command.includes("releases")
|
|
403
|
+
? JSON.stringify(releases.map((r) => ({ tag_name: r.tag, body: r.body })))
|
|
404
|
+
: "",
|
|
405
|
+
stderr: "",
|
|
406
|
+
}),
|
|
407
|
+
};
|
|
408
|
+
const viaProbe = await probeOnce(probe, exec, {});
|
|
409
|
+
assertEquals(viaProbe.bind?.resolvedArtifact, `${PKG}@1.4.0`, "the executed probe binds the capability-carrying version");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
413
|
+
// S5 P5 — a ROOT epic with no inbound edge starts IMMEDIATELY (no gate, fans out at once).
|
|
414
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
415
|
+
test("S5 P5: a root epic with no deps starts immediately with no readiness gate", async () => {
|
|
416
|
+
const gh = freshGithub();
|
|
417
|
+
await withGithub(gh, async () => {
|
|
418
|
+
const { app, started } = makeApp();
|
|
419
|
+
const res = await callDoor(app, { epics: [{ issue: PRODUCER, baseBranch: "epic/solo" }] });
|
|
420
|
+
assertEquals(res.status, 202);
|
|
421
|
+
assertEquals(res.body.roots, [PRODUCER]);
|
|
422
|
+
assertEquals(started.length, 1, "the root is started at once");
|
|
423
|
+
assertEquals(started[0].variables?.["readinessProbes"], null, "a root carries no leading gate");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// And at the pure schedule layer: an epic with no inbound edge is a root, no dependents.
|
|
427
|
+
const sched = deriveEpicSchedule([PRODUCER], []);
|
|
428
|
+
assertEquals(sched.roots, [PRODUCER]);
|
|
429
|
+
assertEquals(sched.dependents.length, 0);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
433
|
+
// S5 P6 — re-submitting the SAME set is idempotent: no duplicate gate, no double-start, no duplicate
|
|
434
|
+
// edge. Proven through the real door AND the lowering executor.
|
|
435
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
436
|
+
test("S5 P6: re-submitting the identical set through the door neither double-starts an epic nor duplicates an edge", async () => {
|
|
437
|
+
const gh = freshGithub();
|
|
438
|
+
await withGithub(gh, async () => {
|
|
439
|
+
const { app, started, tables } = makeApp();
|
|
440
|
+
const set = {
|
|
441
|
+
epics: [
|
|
442
|
+
{ issue: PRODUCER, baseBranch: "epic/producer" },
|
|
443
|
+
{ issue: CONSUMER, baseBranch: "epic/consumer" },
|
|
444
|
+
],
|
|
445
|
+
deps: [{ consumer: CONSUMER, producer: PRODUCER, package: PKG, capabilityRef: CAP_REF }],
|
|
446
|
+
};
|
|
447
|
+
const first = await callDoor(app, set);
|
|
448
|
+
assertEquals(first.status, 202);
|
|
449
|
+
const startsAfterFirst = started.length;
|
|
450
|
+
const edgesAfterFirst = rowsFor(tables, "plan_deps").length;
|
|
451
|
+
assertEquals(edgesAfterFirst, 1);
|
|
452
|
+
|
|
453
|
+
const second = await callDoor(app, set);
|
|
454
|
+
assertEquals(second.status, 202);
|
|
455
|
+
assertEquals(started.length, startsAfterFirst, "no epic re-started on an identical re-submission");
|
|
456
|
+
assertEquals(rowsFor(tables, "plan_deps").length, 1, "no duplicate durable edge on re-submission");
|
|
457
|
+
// The consumer's gate is seeded exactly once — it was not re-started, so no second preflight instance.
|
|
458
|
+
const consumerStarts = started.filter((s) => s.variables?.["planKey"] === CONSUMER).length;
|
|
459
|
+
assertEquals(consumerStarts, 1, "the consumer's capability gate is seeded exactly once");
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
464
|
+
// S5 P7 — removing/finishing a producer does NOT strand a dependent's gate: it either proceeds on the
|
|
465
|
+
// already-published capability, or escalates cleanly — never hangs forever. The engine SLA auto-abandon
|
|
466
|
+
// (the "no eternal hang" bound) is proven in the e2e; here we prove the two clean outcomes.
|
|
467
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
468
|
+
test("S5 P7: an already-published capability keeps the gate GREEN even after the producer is 'finished' — the dependent proceeds", () => {
|
|
469
|
+
// The producer published the capability (release provenance persists) and is then finished/archived.
|
|
470
|
+
// The resolver reads the durable Release list, so the gate stays green and binds the version.
|
|
471
|
+
const res = matchCapability({ package: PKG, capabilityRef: CAP_REF }, [rel(`${PKG}@1.4.0`, [1])]);
|
|
472
|
+
assert(res.ready, "the gate proceeds on the already-published capability");
|
|
473
|
+
assertEquals(res.bind?.resolvedArtifact, `${PKG}@1.4.0`);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("S5 P7: a still-gated dependent past its bounded timeout reads 'escalated', never 'waiting forever'", () => {
|
|
477
|
+
const start = "2026-01-01T00:00:00.000Z";
|
|
478
|
+
const plan: WaitGateLifecycle = { status: "planning", current_wave: null, bound_artifacts: null, created_at: start };
|
|
479
|
+
const got = deriveWaitGate([workedEdge], plan, {
|
|
480
|
+
nowMs: Date.parse(start) + 48 * 60 * 60 * 1000, // well past the default 30m bound
|
|
481
|
+
});
|
|
482
|
+
assertEquals(got.wait_gate, "escalated", "a stranded gate surfaces as escalated, not an eternal wait");
|
|
483
|
+
assert(got.wait_gate_label?.includes(PRODUCER), "the escalation still names the blocking producer");
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
487
|
+
// S5 P8 — the operator projection (S4) shows a parked dependent as "waiting on #N", and shows the
|
|
488
|
+
// bound version once resolved.
|
|
489
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
490
|
+
test("S5 P8: a parked dependent projects 'waiting on <producer> @ <package>' with a live escalation deadline", () => {
|
|
491
|
+
const start = "2026-01-01T00:00:00.000Z";
|
|
492
|
+
const plan: WaitGateLifecycle = { status: "planning", current_wave: null, bound_artifacts: null, created_at: start };
|
|
493
|
+
const got = deriveWaitGate([workedEdge], plan, { nowMs: Date.parse(start) + 1000 });
|
|
494
|
+
assertEquals(got.wait_gate, "waiting");
|
|
495
|
+
assert(got.wait_gate_label?.includes(`${PRODUCER} @ ${PKG}`), "names the producer#N @ package it is blocked on");
|
|
496
|
+
assert(got.wait_gate_label?.includes("escalates by"), "shows the bounded escalation deadline, not a silent stall");
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("S5 P8: once resolved, the dependent projects 'ready' with the exact bound pkg@version", () => {
|
|
500
|
+
const start = "2026-01-01T00:00:00.000Z";
|
|
501
|
+
const plan: WaitGateLifecycle = {
|
|
502
|
+
status: "dispatched",
|
|
503
|
+
current_wave: 0,
|
|
504
|
+
bound_artifacts: JSON.stringify([`${PKG}@1.4.0`]),
|
|
505
|
+
created_at: start,
|
|
506
|
+
};
|
|
507
|
+
const got = deriveWaitGate([workedEdge], plan, { nowMs: Date.parse(start) + 1000 });
|
|
508
|
+
assertEquals(got.wait_gate, "ready");
|
|
509
|
+
assert(got.wait_gate_label?.includes(`${PKG}@1.4.0`), "surfaces the exact bound version once green");
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("S5 P8: a ROOT epic (no inbound edge) projects NO wait-gate at all", () => {
|
|
513
|
+
const start = "2026-01-01T00:00:00.000Z";
|
|
514
|
+
const plan: WaitGateLifecycle = { status: "planning", current_wave: null, bound_artifacts: null, created_at: start };
|
|
515
|
+
assertEquals(deriveWaitGate([], plan, { nowMs: Date.parse(start) }), { wait_gate: null, wait_gate_label: null });
|
|
516
|
+
});
|
package/app/plan.ts
CHANGED
|
@@ -121,6 +121,21 @@ export interface Plan {
|
|
|
121
121
|
acknowledged_at: string | null;
|
|
122
122
|
list_bucket: string | null;
|
|
123
123
|
ack_open: number | null;
|
|
124
|
+
// Operator visibility for the inter-epic gate (047_plan_wait_gate.sql, #292 slice S4). Derived,
|
|
125
|
+
// display-only projection over the S1 `plan_deps` edges + this epic's own S3 preflight lifecycle —
|
|
126
|
+
// recomputed idempotently by `pollWaitGate` (app/service.ts) from the pure `deriveWaitGate`
|
|
127
|
+
// (app/waitGate.ts); NEVER written by admission/scheduling (this slice is read-only). A ROOT epic
|
|
128
|
+
// (no inbound edge) carries NULL for both — it shows no wait-gate; pre-S4 rows grandfather in NULL.
|
|
129
|
+
// • wait_gate — 'waiting' (parked at the preflight, blocked on a producer's capability) |
|
|
130
|
+
// 'ready' (preflight green, fanned out, bound to a version) | 'escalated'
|
|
131
|
+
// (the gate's bounded timeout elapsed with no publish).
|
|
132
|
+
// • wait_gate_label — the human at-a-glance rollup the epic index/detail read as a flat column.
|
|
133
|
+
wait_gate: string | null;
|
|
134
|
+
wait_gate_label: string | null;
|
|
135
|
+
// JSON array of the resolved `pkg@version` strings the S3 preflight bound (the exact versions first
|
|
136
|
+
// carrying each producer's capability), stamped by the `select-wave` worker from the
|
|
137
|
+
// `resolvedArtifacts` process variable once the gate goes green. NULL until green / for roots.
|
|
138
|
+
bound_artifacts: string | null;
|
|
124
139
|
created_at: string;
|
|
125
140
|
updated_at: string;
|
|
126
141
|
}
|
|
@@ -152,6 +152,37 @@ test("pollUserTasks: projects a merge-loop wait-merge-answer escalation into use
|
|
|
152
152
|
assertEquals(byKey["ut-merge"].question, "not mergeable — resolve the conflict");
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
+
test("pollUserTasks: sources the feature-escalation question from the feature_escalations audit log (issue #305)", async () => {
|
|
156
|
+
// The canonical source is the append-only `feature_escalations` log (what `record-feature-escalation`
|
|
157
|
+
// writes); the denormalised `feature_runs.escalation_question` is a legacy fallback during the expand
|
|
158
|
+
// phase. When both exist the newest audit row wins, so a re-escalation shows the latest question.
|
|
159
|
+
const { data, stores } = memData({
|
|
160
|
+
feature_runs: [
|
|
161
|
+
{
|
|
162
|
+
feature_key: "o/r#42",
|
|
163
|
+
status: "escalated",
|
|
164
|
+
process_key: "fp-42",
|
|
165
|
+
issue_url: "https://github.com/o/r/issues/42",
|
|
166
|
+
title: "Wire the audit log",
|
|
167
|
+
escalation_user_task_key: "ut-feat",
|
|
168
|
+
escalation_question: "stale legacy question",
|
|
169
|
+
blocked_user_task_key: null,
|
|
170
|
+
delivery_label: null,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
feature_escalations: [
|
|
174
|
+
{ id: 1, feature_key: "o/r#42", question: "first ask", created_at: "2025-01-01T00:00:00.000Z", job_key: "j1" },
|
|
175
|
+
{ id: 2, feature_key: "o/r#42", question: "latest ask", created_at: "2025-01-02T00:00:00.000Z", job_key: "j2" },
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
const engine = fakeEngine({ "fp-42": [{ userTaskKey: "ut-feat", elementId: "feature-escalation" }] });
|
|
179
|
+
|
|
180
|
+
await pollUserTasks(data, engine);
|
|
181
|
+
|
|
182
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
183
|
+
assertEquals(byKey["ut-feat"].question, "latest ask");
|
|
184
|
+
});
|
|
185
|
+
|
|
155
186
|
test("pollUserTasks: removes a row once its task is no longer open (completed / out-of-band)", async () => {
|
|
156
187
|
const { data, stores } = memData({
|
|
157
188
|
user_tasks: [
|