@nanobpm/nano-workforce 0.143.0 → 0.145.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 +12 -0
- package/app/deliveryGraphReadModel.test.ts +392 -0
- package/app/deliveryGraphReadModel.ts +166 -0
- package/app/deliveryGraphRun.test.ts +1 -1
- package/app/epicPhase.test.ts +86 -3
- package/app/epicPhase.ts +98 -4
- package/app/pollEpicPhase.test.ts +159 -0
- package/app/service.ts +71 -7
- package/app/stepAxis.test.ts +142 -0
- package/app/stepAxis.ts +217 -0
- package/db/migrations/087_delivery_graph_read_model.sql +75 -0
- package/package.json +2 -2
- package/pages/delivery-graph-detail.page.json +18 -2
- package/pages/delivery-graphs.page.json +25 -8
- package/pages/overview.page.json +18 -2
- package/scripts/pages-contract.test.ts +15 -11
- package/workers/record-plan/worker.ts +0 -6
- package/workers/record-results/worker.ts +7 -8
- package/workers/record-wave/worker.test.ts +6 -5
- package/workers/record-wave/worker.ts +5 -21
- package/workers/select-wave/worker.test.ts +4 -3
- package/workers/select-wave/worker.ts +15 -29
package/app/epicPhase.test.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// epic view can show WHICH phase an epic is in — not only the process-instance terminal status.
|
|
6
6
|
import { test } from "node:test";
|
|
7
7
|
import { assertEquals } from "#test-assert";
|
|
8
|
-
import { deriveEpicPhase, EPIC_PHASE, implementingPhase } from "./epicPhase.ts";
|
|
8
|
+
import { deriveEpicPhase, deriveEpicPhaseLive, deriveTerminalEpicPhase, EPIC_PHASE, implementingPhase } from "./epicPhase.ts";
|
|
9
9
|
|
|
10
10
|
test("deriveEpicPhase maps each spine element to its domain phase", () => {
|
|
11
11
|
// Planning genesis + hand-off into Reviewing when the plan is recorded.
|
|
@@ -20,8 +20,10 @@ test("deriveEpicPhase maps each spine element to its domain phase", () => {
|
|
|
20
20
|
assertEquals(deriveEpicPhase("record-trial-merge"), EPIC_PHASE.TRIAL_MERGING);
|
|
21
21
|
assertEquals(deriveEpicPhase("trial-merge-decision"), EPIC_PHASE.TRIAL_MERGING);
|
|
22
22
|
assertEquals(deriveEpicPhase("resolve-trial-attention"), EPIC_PHASE.TRIAL_MERGING);
|
|
23
|
-
// Finalize step
|
|
24
|
-
|
|
23
|
+
// Finalize step ("Finalize plan") reads Finalizing while its token is ACTIVE; the terminal
|
|
24
|
+
// "Fleet dispatched" phase is derived from the terminal status, not this element (see
|
|
25
|
+
// deriveTerminalEpicPhase), so Finalizing is reachable and Dispatched is not raced off a live token.
|
|
26
|
+
assertEquals(deriveEpicPhase("record-results"), EPIC_PHASE.FINALIZING);
|
|
25
27
|
});
|
|
26
28
|
|
|
27
29
|
test("deriveEpicPhase wave-labels the Implementing band from the levelize records", () => {
|
|
@@ -59,4 +61,85 @@ test("implementingPhase clamps the 1-based label to the total and degrades grace
|
|
|
59
61
|
assertEquals(implementingPhase(0, 0), "Implementing");
|
|
60
62
|
assertEquals(implementingPhase(undefined, undefined), "Implementing");
|
|
61
63
|
assertEquals(implementingPhase("x", "y"), "Implementing");
|
|
64
|
+
// A NULL `current_wave` (unknown wave) with a known `wave_count` is ABSENT, not wave 0 — it must
|
|
65
|
+
// NOT mislabel as "wave 1/t" (`Number(null)` is 0). Missing wave data stays missing.
|
|
66
|
+
assertEquals(implementingPhase(null, 3), "Implementing");
|
|
67
|
+
assertEquals(implementingPhase(null, null), "Implementing");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("deriveEpicPhaseLive reads Finalizing from an ACTIVE finalizer token", () => {
|
|
71
|
+
// The finalize step is Finalizing while its token is ACTIVE — the phase is reachable in the live
|
|
72
|
+
// model (it is the furthest spine element short of the terminal Dispatched marker).
|
|
73
|
+
assertEquals(
|
|
74
|
+
deriveEpicPhaseLive([{ elementId: "record-results", state: "ACTIVE" }]),
|
|
75
|
+
EPIC_PHASE.FINALIZING,
|
|
76
|
+
);
|
|
77
|
+
// Finalizing (ordinal) outranks a still-live trial-merge token.
|
|
78
|
+
assertEquals(
|
|
79
|
+
deriveEpicPhaseLive([
|
|
80
|
+
{ elementId: "trial-merge", state: "ACTIVE" },
|
|
81
|
+
{ elementId: "record-results", state: "ACTIVE" },
|
|
82
|
+
]),
|
|
83
|
+
EPIC_PHASE.FINALIZING,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("deriveTerminalEpicPhase reads Dispatched only from a done epic that dispatched a fleet", () => {
|
|
88
|
+
// A done epic that opened ≥1 slice reaches the terminal "Fleet dispatched" phase.
|
|
89
|
+
assertEquals(deriveTerminalEpicPhase("done", 3), EPIC_PHASE.DISPATCHED);
|
|
90
|
+
assertEquals(deriveTerminalEpicPhase("done", 1), EPIC_PHASE.DISPATCHED);
|
|
91
|
+
// A taskless done (planner emitted no tasks — nothing dispatched) and non-done terminals are NOT
|
|
92
|
+
// Dispatched, so the caller leaves the last live phase untouched.
|
|
93
|
+
assertEquals(deriveTerminalEpicPhase("done", 0), null);
|
|
94
|
+
assertEquals(deriveTerminalEpicPhase("failed", 3), null);
|
|
95
|
+
assertEquals(deriveTerminalEpicPhase("abandoned", 3), null);
|
|
96
|
+
assertEquals(deriveTerminalEpicPhase("dispatched", 3), null);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── deriveEpicPhaseLive: the S8 live element-instance derivation (#542) ────────────────────────────
|
|
100
|
+
test("deriveEpicPhaseLive projects the FURTHEST active spine element onto its phase", () => {
|
|
101
|
+
// A pre-PR Reviewing epic: the plan is recorded (COMPLETED) and the review-plan agent is running.
|
|
102
|
+
assertEquals(
|
|
103
|
+
deriveEpicPhaseLive([
|
|
104
|
+
{ elementId: "record-plan", state: "COMPLETED" },
|
|
105
|
+
{ elementId: "review-plan", state: "ACTIVE" },
|
|
106
|
+
]),
|
|
107
|
+
EPIC_PHASE.REVIEWING,
|
|
108
|
+
);
|
|
109
|
+
// The implement multi-instance keeps select-wave/record-wave AND per-child implement-task tokens
|
|
110
|
+
// live at once; a later trial-merge token, once reached, is the epic's true furthest position.
|
|
111
|
+
assertEquals(
|
|
112
|
+
deriveEpicPhaseLive([
|
|
113
|
+
{ elementId: "implement-task", state: "ACTIVE" },
|
|
114
|
+
{ elementId: "record-wave", state: "ACTIVE" },
|
|
115
|
+
{ elementId: "trial-merge", state: "ACTIVE" },
|
|
116
|
+
]),
|
|
117
|
+
EPIC_PHASE.TRIAL_MERGING,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("deriveEpicPhaseLive wave-labels a live Implementing token from the wave context", () => {
|
|
122
|
+
assertEquals(
|
|
123
|
+
deriveEpicPhaseLive([{ elementId: "implement-task", state: "ACTIVE" }], { current: 1, total: 3 }),
|
|
124
|
+
"Implementing (wave 2/3)",
|
|
125
|
+
);
|
|
126
|
+
// Mid-cell fidelity (S8): an active implement job with no wave numbers yet still reads Implementing.
|
|
127
|
+
assertEquals(
|
|
128
|
+
deriveEpicPhaseLive([{ elementId: "implement-task", state: "ACTIVE" }]),
|
|
129
|
+
EPIC_PHASE.IMPLEMENTING,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("deriveEpicPhaseLive ignores non-ACTIVE tokens and non-spine plumbing, returning null when nothing marks a phase", () => {
|
|
134
|
+
// COMPLETED/TERMINATED tokens are past, not the live position — an all-completed set marks nothing.
|
|
135
|
+
assertEquals(
|
|
136
|
+
deriveEpicPhaseLive([
|
|
137
|
+
{ elementId: "plan", state: "COMPLETED" },
|
|
138
|
+
{ elementId: "review-plan", state: "TERMINATED" },
|
|
139
|
+
]),
|
|
140
|
+
null,
|
|
141
|
+
);
|
|
142
|
+
// A token parked only on non-spine plumbing (no ELEMENT_PHASE entry) leaves the phase untouched.
|
|
143
|
+
assertEquals(deriveEpicPhaseLive([{ elementId: "some-gateway", state: "ACTIVE" }]), null);
|
|
144
|
+
assertEquals(deriveEpicPhaseLive([]), null);
|
|
62
145
|
});
|
package/app/epicPhase.ts
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
|
-
// app/epicPhase.ts — reify the epic's own domain lifecycle as a derived `epic_phase` (issue #261
|
|
1
|
+
// app/epicPhase.ts — reify the epic's own domain lifecycle as a derived `epic_phase` (issue #261,
|
|
2
|
+
// S8 #542 / ADR 0006 §4b).
|
|
3
|
+
//
|
|
4
|
+
// LIVE READ-MODEL DERIVATION (S8, #542). The epic phase is now a PURE read-model derivation off the
|
|
5
|
+
// live engine element-instance model — the write-time provenance stamp (each spine worker stamping
|
|
6
|
+
// the phase it enters) is RETIRED. `deriveEpicPhaseLive` reads the plan-fanout instance's live
|
|
7
|
+
// element instances (`EngineClient.searchElementInstances`, nano-ide#473) and projects the
|
|
8
|
+
// FURTHEST-REACHED active element onto the same structural `ELEMENT_PHASE` map the write-stamp used
|
|
9
|
+
// (derive-don't-duplicate: one structural source, two consumers retired to one). This lifts S7's
|
|
10
|
+
// coarse lifecycle-stage fidelity to true per-cell / mid-cell position — an active `implement` job or
|
|
11
|
+
// a pre-PR `review-plan` agent is read live from the token position, ahead of any work-table row.
|
|
12
|
+
// The `pollEpicPhase` poll pass (app/service.ts) owns the write, so no worker stamps `epic_phase`.
|
|
13
|
+
//
|
|
14
|
+
// Because plan-fanout.bpmn runs the WHOLE epic spine (`plan` → `review-plan` → the `implement`
|
|
15
|
+
// multi-instance subProcess → `trial-merge` → `record-results`) as ONE process instance — the
|
|
16
|
+
// `implement` fan-out is an embedded subProcess, not a callActivity child instance — a single
|
|
17
|
+
// element-instance search over the plan's `process_key` sees every spine cell. (When S4 callActivity
|
|
18
|
+
// composition lands, the same derivation extends to child instances via the engine's native
|
|
19
|
+
// parent/root keys, Magikcraft/nano-bpm#977 — the #464 option-B correlation decision.)
|
|
2
20
|
//
|
|
3
21
|
// `plans.status` only distinguishes `planning` / `dispatched` / `done` / `failed` / `abandoned` —
|
|
4
22
|
// and `dispatched` is the `plan-fanout.bpmn` PROCESS-INSTANCE terminal ("fan-out job done"), not the
|
|
@@ -42,6 +60,9 @@ export const EPIC_PHASE = {
|
|
|
42
60
|
* `toWave` coercion the wave workers already apply, so a NaN/absent counter degrades to an
|
|
43
61
|
* unlabelled `Implementing` rather than emitting `wave NaN/…`. */
|
|
44
62
|
const toWave = (v: unknown): number | null => {
|
|
63
|
+
// `null`/`undefined` are ABSENT, not zero: `Number(null)` is `0`, which would otherwise label a
|
|
64
|
+
// missing `current_wave` as `wave 1/t`. Treat them as unusable so missing wave data stays missing.
|
|
65
|
+
if (v === null || v === undefined) return null;
|
|
45
66
|
const n = Math.trunc(Number(v));
|
|
46
67
|
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
47
68
|
};
|
|
@@ -74,8 +95,10 @@ export function implementingPhase(current: unknown, total: unknown): string {
|
|
|
74
95
|
* • `select-wave` ("Select wave") → Implementing: it dispatches the wave and is the last host write
|
|
75
96
|
* before the write-silent `implement` MI, so it durably marks the implementation phase for the
|
|
76
97
|
* wave it launches (wave-labelled via {@link implementingPhase} at the call site).
|
|
77
|
-
* • `record-results` ("Finalize plan") →
|
|
78
|
-
* "Fleet dispatched"
|
|
98
|
+
* • `record-results` ("Finalize plan") → Finalizing: while the finalizer token is ACTIVE the epic
|
|
99
|
+
* is finalizing. Its TERMINAL "Fleet dispatched" phase is NOT read from this (fleeting) live
|
|
100
|
+
* token — a completion marker has no ACTIVE element to read once the instance ends — but derived
|
|
101
|
+
* from the durable terminal status (see {@link deriveTerminalEpicPhase}).
|
|
79
102
|
* `record-wave`'s next phase is data-dependent (trial-merge vs. next wave vs. finalize), so it is
|
|
80
103
|
* resolved at its call site rather than from the element id alone; its structural fallback here is
|
|
81
104
|
* the wave it just landed.
|
|
@@ -97,7 +120,7 @@ const ELEMENT_PHASE: Readonly<Record<string, string>> = {
|
|
|
97
120
|
"record-trial-merge": EPIC_PHASE.TRIAL_MERGING,
|
|
98
121
|
"trial-merge-decision": EPIC_PHASE.TRIAL_MERGING,
|
|
99
122
|
"resolve-trial-attention": EPIC_PHASE.TRIAL_MERGING,
|
|
100
|
-
"record-results": EPIC_PHASE.
|
|
123
|
+
"record-results": EPIC_PHASE.FINALIZING,
|
|
101
124
|
};
|
|
102
125
|
|
|
103
126
|
/** Optional wave context for a wave-bearing phase, sourced from the wave/levelize records. */
|
|
@@ -123,3 +146,74 @@ export function deriveEpicPhase(
|
|
|
123
146
|
if (base === EPIC_PHASE.IMPLEMENTING) return implementingPhase(wave?.current, wave?.total);
|
|
124
147
|
return base;
|
|
125
148
|
}
|
|
149
|
+
|
|
150
|
+
/** The epic spine's phase ORDER — the total order `deriveEpicPhaseLive` compares "furthest reached"
|
|
151
|
+
* by. It IS the declaration order of {@link EPIC_PHASE} (Planning → Reviewing → Implementing → Trial
|
|
152
|
+
* merging → Finalizing → Dispatched), the epic's natural forward spine, so the ordinal cannot drift
|
|
153
|
+
* from the phase vocabulary. */
|
|
154
|
+
const EPIC_PHASE_ORDER: readonly string[] = Object.values(EPIC_PHASE);
|
|
155
|
+
|
|
156
|
+
/** Constant-time phase→ordinal lookup for {@link deriveEpicPhaseLive}'s hot loop — precomputed once
|
|
157
|
+
* from {@link EPIC_PHASE_ORDER} so the per-element "furthest reached" compare is O(1) instead of a
|
|
158
|
+
* linear `indexOf` per ACTIVE instance (avoids O(n·k) on high-fanout epics; #542 review). */
|
|
159
|
+
const EPIC_PHASE_ORDINAL: ReadonlyMap<string, number> = new Map(
|
|
160
|
+
EPIC_PHASE_ORDER.map((phase, ordinal) => [phase, ordinal]),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
/** The finest-grained element-instance signal `deriveEpicPhaseLive` reads — the structural subset of
|
|
164
|
+
* urban's `ElementInstanceSummary` it needs (the element's BPMN id and whether a token is currently
|
|
165
|
+
* AT it). Kept structural (not the full binding type) so the derivation unit-tests in isolation. */
|
|
166
|
+
export interface EpicElementInstance {
|
|
167
|
+
readonly elementId: string;
|
|
168
|
+
readonly state: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Derive the epic phase LIVE from the plan-fanout instance's element instances (S8 #542) — the pure
|
|
173
|
+
* read-model derivation that RETIRES the write-time stamp. Among the ACTIVE element instances (a token
|
|
174
|
+
* currently sitting AT the element — a running agent job, an open human gate, a readiness-probe loop),
|
|
175
|
+
* pick the one mapping FURTHEST along the epic spine ({@link EPIC_PHASE_ORDER}) and project it via the
|
|
176
|
+
* SAME structural {@link deriveEpicPhase} map — so the live derivation and the (now retired) stamp
|
|
177
|
+
* share one source. Returns `null` when no active element marks a phase (e.g. the instance is parked
|
|
178
|
+
* only on non-spine plumbing), so the caller leaves the last known phase untouched rather than
|
|
179
|
+
* clobbering it. A wave-bearing phase (`Implementing`) is wave-labelled from {@link WaveContext}.
|
|
180
|
+
*
|
|
181
|
+
* "Furthest reached" (max spine ordinal), not "least advanced": the `implement` multi-instance
|
|
182
|
+
* subProcess keeps `select-wave`/`record-wave` and per-child `implement-task` tokens live at once, all
|
|
183
|
+
* mapping to `Implementing`; a later `trial-merge` token, once reached, is the epic's true position, so
|
|
184
|
+
* the max is the faithful "where has this epic got to" read.
|
|
185
|
+
*/
|
|
186
|
+
export function deriveEpicPhaseLive(
|
|
187
|
+
elements: readonly EpicElementInstance[],
|
|
188
|
+
wave?: WaveContext,
|
|
189
|
+
): string | null {
|
|
190
|
+
let bestBase: string | null = null;
|
|
191
|
+
let bestOrdinal = -1;
|
|
192
|
+
for (const el of elements) {
|
|
193
|
+
if (el.state !== "ACTIVE") continue;
|
|
194
|
+
const base = deriveEpicPhase(el.elementId);
|
|
195
|
+
if (base === null) continue;
|
|
196
|
+
const ordinal = EPIC_PHASE_ORDINAL.get(base) ?? -1;
|
|
197
|
+
if (ordinal > bestOrdinal) {
|
|
198
|
+
bestOrdinal = ordinal;
|
|
199
|
+
bestBase = base;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (bestBase === null) return null;
|
|
203
|
+
return bestBase === EPIC_PHASE.IMPLEMENTING ? implementingPhase(wave?.current, wave?.total) : bestBase;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Derive the epic's TERMINAL phase from its durable status — the completion-marker counterpart to the
|
|
208
|
+
* live derivation (S8 #542 review). The "Fleet dispatched" phase is reached only when the plan-fanout
|
|
209
|
+
* instance ENDS, at which point there is no ACTIVE element to read; live-observing the fleeting ACTIVE
|
|
210
|
+
* `record-results` token via a coarse (default 60s) poll would miss it on nearly every fast finalize,
|
|
211
|
+
* freezing the row at the last live phase. So `Dispatched` is derived from the durable read-model
|
|
212
|
+
* (`plans.status`) instead: a `done` epic that dispatched ≥1 slice (`taskCount > 0`) reads Dispatched.
|
|
213
|
+
* Returns `null` for a taskless `done` (planner emitted no tasks — nothing was dispatched) and for any
|
|
214
|
+
* non-`done` terminal (`failed`/`abandoned`), so those never mislabel as Dispatched and the caller
|
|
215
|
+
* leaves the last live phase untouched.
|
|
216
|
+
*/
|
|
217
|
+
export function deriveTerminalEpicPhase(status: string, taskCount: number): string | null {
|
|
218
|
+
return status === "done" && taskCount > 0 ? EPIC_PHASE.DISPATCHED : null;
|
|
219
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Coverage for `pollEpicPhase` (S8, #542 / ADR 0006 §4b) — the poll pass that reconciles the epic's
|
|
2
|
+
// `plans.epic_phase` from the LIVE engine element-instance model, the pure read-model derivation that
|
|
3
|
+
// RETIRED the write-time stamp the spine workers used to write. Booted against the real provisioned
|
|
4
|
+
// SQLite data layer (so the `plans` table and the `plan_wave_progress` wave-frontier VIEW exist) with
|
|
5
|
+
// a stubbed `searchElementInstances`, proving: a live plan's phase advances to the furthest active
|
|
6
|
+
// spine element; the wave label rides the wave-progress rollup; a steady-state pass is a no-op; and a
|
|
7
|
+
// terminal (non-live) plan is never touched.
|
|
8
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import { assertEquals } from "#test-assert";
|
|
13
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
14
|
+
import { bootTestApp } from "@nanobpm/urban-testkit";
|
|
15
|
+
import { EPIC_PHASE } from "./epicPhase.ts";
|
|
16
|
+
import { plans, planTasks } from "./plan.ts";
|
|
17
|
+
import { pollEpicPhase } from "./service.ts";
|
|
18
|
+
|
|
19
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
20
|
+
|
|
21
|
+
async function withData(fn: (data: DataLayer) => Promise<void>): Promise<void> {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-epicphase-"));
|
|
23
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
|
|
24
|
+
try {
|
|
25
|
+
await fn(app.db);
|
|
26
|
+
} finally {
|
|
27
|
+
await app.stop?.();
|
|
28
|
+
rmSync(dir, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const now = () => new Date().toISOString();
|
|
33
|
+
|
|
34
|
+
async function seedPlan(
|
|
35
|
+
data: DataLayer,
|
|
36
|
+
over: {
|
|
37
|
+
status?: string;
|
|
38
|
+
process_key?: string | null;
|
|
39
|
+
epic_phase?: string | null;
|
|
40
|
+
task_count?: number;
|
|
41
|
+
} = {},
|
|
42
|
+
) {
|
|
43
|
+
await plans(data).insert({
|
|
44
|
+
plan_key: "owner/repo#7",
|
|
45
|
+
repo: "owner/repo",
|
|
46
|
+
issue_number: 7,
|
|
47
|
+
issue_url: "https://github.com/owner/repo/issues/7",
|
|
48
|
+
title: "Epic",
|
|
49
|
+
status: over.status ?? "dispatched",
|
|
50
|
+
task_count: over.task_count ?? 0,
|
|
51
|
+
epic_phase: over.epic_phase ?? EPIC_PHASE.PLANNING,
|
|
52
|
+
process_key: "process_key" in over ? over.process_key : "pi-1",
|
|
53
|
+
created_at: now(),
|
|
54
|
+
updated_at: now(),
|
|
55
|
+
} as never);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test("pollEpicPhase advances a live epic's phase to the furthest ACTIVE spine element", async () => {
|
|
59
|
+
await withData(async (data) => {
|
|
60
|
+
await seedPlan(data, { epic_phase: EPIC_PHASE.PLANNING });
|
|
61
|
+
// The plan is recorded (COMPLETED) and the review-plan agent is running → Reviewing.
|
|
62
|
+
const engine = {
|
|
63
|
+
searchElementInstances: async () => [
|
|
64
|
+
{ elementInstanceKey: "e1", processInstanceKey: "pi-1", elementId: "record-plan", state: "COMPLETED" },
|
|
65
|
+
{ elementInstanceKey: "e2", processInstanceKey: "pi-1", elementId: "review-plan", state: "ACTIVE" },
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
await pollEpicPhase(data, engine as never);
|
|
69
|
+
assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, EPIC_PHASE.REVIEWING);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("pollEpicPhase wave-labels a live Implementing token from the plan_wave_progress rollup", async () => {
|
|
74
|
+
await withData(async (data) => {
|
|
75
|
+
await seedPlan(data, { epic_phase: EPIC_PHASE.REVIEWING });
|
|
76
|
+
// Two levelized waves (0,1); wave 0 is settled (skipped → not in-flight) and wave 1 is still in
|
|
77
|
+
// flight, so the frontier is wave 1 → current_wave 1, wave_count 2 → "Implementing (wave 2/2)".
|
|
78
|
+
await planTasks(data).insert({ id: 1, plan_key: "owner/repo#7", task_index: 0, task_id: "a", status: "skipped", wave: 0, created_at: now(), updated_at: now() } as never);
|
|
79
|
+
await planTasks(data).insert({ id: 2, plan_key: "owner/repo#7", task_index: 1, task_id: "b", status: "pending", wave: 1, created_at: now(), updated_at: now() } as never);
|
|
80
|
+
const engine = {
|
|
81
|
+
searchElementInstances: async () => [
|
|
82
|
+
{ elementInstanceKey: "e3", processInstanceKey: "pi-1", elementId: "implement-task", state: "ACTIVE" },
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
await pollEpicPhase(data, engine as never);
|
|
86
|
+
assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, "Implementing (wave 2/2)");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("pollEpicPhase is a no-op when the derived phase is unchanged, and leaves the phase when nothing marks one", async () => {
|
|
91
|
+
await withData(async (data) => {
|
|
92
|
+
await seedPlan(data, { epic_phase: EPIC_PHASE.REVIEWING });
|
|
93
|
+
// Only non-spine plumbing is active → derivation returns null → the last phase is untouched.
|
|
94
|
+
const engine = {
|
|
95
|
+
searchElementInstances: async () => [
|
|
96
|
+
{ elementInstanceKey: "e4", processInstanceKey: "pi-1", elementId: "some-gateway", state: "ACTIVE" },
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
await pollEpicPhase(data, engine as never);
|
|
100
|
+
assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, EPIC_PHASE.REVIEWING);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("pollEpicPhase never touches a terminal (non-live) epic", async () => {
|
|
105
|
+
await withData(async (data) => {
|
|
106
|
+
await seedPlan(data, { status: "done", epic_phase: EPIC_PHASE.DISPATCHED });
|
|
107
|
+
let called = false;
|
|
108
|
+
const engine = {
|
|
109
|
+
searchElementInstances: async () => {
|
|
110
|
+
called = true;
|
|
111
|
+
return [];
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
await pollEpicPhase(data, engine as never);
|
|
115
|
+
assertEquals(called, false);
|
|
116
|
+
assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, EPIC_PHASE.DISPATCHED);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("pollEpicPhase freezes a done epic that dispatched a fleet at the terminal Dispatched phase", async () => {
|
|
121
|
+
await withData(async (data) => {
|
|
122
|
+
await seedPlan(data, { status: "done", task_count: 2, epic_phase: EPIC_PHASE.TRIAL_MERGING });
|
|
123
|
+
let called = false;
|
|
124
|
+
const engine = {
|
|
125
|
+
searchElementInstances: async () => {
|
|
126
|
+
called = true;
|
|
127
|
+
return [];
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
await pollEpicPhase(data, engine as never);
|
|
131
|
+
// Derived from the terminal status, not the (skipped) live element search.
|
|
132
|
+
assertEquals(called, false);
|
|
133
|
+
assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, EPIC_PHASE.DISPATCHED);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("pollEpicPhase never labels a taskless done epic Dispatched", async () => {
|
|
138
|
+
await withData(async (data) => {
|
|
139
|
+
// A done epic that dispatched nothing (planner emitted no tasks) must NOT read Dispatched.
|
|
140
|
+
await seedPlan(data, { status: "done", task_count: 0, epic_phase: EPIC_PHASE.PLANNING });
|
|
141
|
+
await pollEpicPhase(data, { searchElementInstances: async () => [] } as never);
|
|
142
|
+
assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, EPIC_PHASE.PLANNING);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("pollEpicPhase skips a live epic that has no engine instance yet", async () => {
|
|
147
|
+
await withData(async (data) => {
|
|
148
|
+
await seedPlan(data, { status: "planning", process_key: null, epic_phase: EPIC_PHASE.PLANNING });
|
|
149
|
+
let called = false;
|
|
150
|
+
const engine = {
|
|
151
|
+
searchElementInstances: async () => {
|
|
152
|
+
called = true;
|
|
153
|
+
return [];
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
await pollEpicPhase(data, engine as never);
|
|
157
|
+
assertEquals(called, false);
|
|
158
|
+
});
|
|
159
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -27,11 +27,12 @@ import {
|
|
|
27
27
|
conformanceEscalationQuestion,
|
|
28
28
|
} from "./conformance.ts";
|
|
29
29
|
import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
30
|
-
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
30
|
+
import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
|
|
31
31
|
import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
|
|
32
32
|
import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
|
|
33
33
|
import { isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
34
34
|
import { fleetSupportsDurableResume } from "./durableResume.ts";
|
|
35
|
+
import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
|
|
35
36
|
import { deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
|
|
36
37
|
import {
|
|
37
38
|
classifyMergeability,
|
|
@@ -2309,6 +2310,65 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
|
|
|
2309
2310
|
* completed task's row is deleted (answered here, via the task inbox, or out-of-band) and `showCount`
|
|
2310
2311
|
* reflects live pending work. Best-effort + idempotent — per-instance failures are isolated so one bad
|
|
2311
2312
|
* instance never stalls the pass. */
|
|
2313
|
+
/** Poll pass (S8, #542 / ADR 0006 §4b): reconcile each LIVE epic's `plans.epic_phase` from the engine
|
|
2314
|
+
* element-instance model — the PURE read-model derivation that RETIRES the write-time stamp the spine
|
|
2315
|
+
* workers used to write. For each plan still live (`EPIC_LIVE_STATUSES`) with a running instance, read
|
|
2316
|
+
* its element instances (`searchElementInstances`, nano-ide#473) and project the furthest-reached
|
|
2317
|
+
* active spine element onto its domain phase (`deriveEpicPhaseLive`, app/epicPhase.ts — the SAME
|
|
2318
|
+
* `ELEMENT_PHASE` structural map the stamp used). The wave label rides the `plan_wave_progress` rollup
|
|
2319
|
+
* VIEW (the single wave-frontier source, 060/082), so the Implementing band reads `wave n/t` without a
|
|
2320
|
+
* second wave derivation. Writes only on a real change (a steady-state pass is a no-op) and leaves the
|
|
2321
|
+
* last phase untouched when nothing active marks one (`null`), so a plan parked on non-spine plumbing
|
|
2322
|
+
* never clobbers to blank. The terminal `Dispatched` phase is a COMPLETION marker (no ACTIVE token to
|
|
2323
|
+
* read once the instance ends), so a second pass derives it from the durable terminal status
|
|
2324
|
+
* (`deriveTerminalEpicPhase` over `done` epics) rather than the fleeting ACTIVE `record-results` token
|
|
2325
|
+
* a coarse poll would miss. Best-effort + idempotent — a per-plan failure is isolated. */
|
|
2326
|
+
export async function pollEpicPhase(
|
|
2327
|
+
data: DataLayer,
|
|
2328
|
+
engine: Pick<EngineClient, "searchElementInstances">,
|
|
2329
|
+
) {
|
|
2330
|
+
const waveByPlan = new Map<string, { current: number | null; total: number | null }>();
|
|
2331
|
+
for (const w of await data
|
|
2332
|
+
.table<{ plan_key: string; wave_count: number | null; current_wave: number | null }>(
|
|
2333
|
+
"plan_wave_progress",
|
|
2334
|
+
"plan_key",
|
|
2335
|
+
)
|
|
2336
|
+
.all()) {
|
|
2337
|
+
// Coerce SQL NULL to `null` so a missing `current_wave`/`wave_count` stays MISSING through the
|
|
2338
|
+
// wave label — a wave number that coerced to `0` would otherwise mislabel an unknown wave as
|
|
2339
|
+
// `wave 1/t` (the derivation guards this too, see `toWave`, which treats `null`/`undefined` alike).
|
|
2340
|
+
waveByPlan.set(w.plan_key, {
|
|
2341
|
+
current: w.current_wave ?? null,
|
|
2342
|
+
total: w.wave_count ?? null,
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
for (const status of EPIC_LIVE_STATUSES) {
|
|
2346
|
+
for (const plan of await plans(data).find({ status })) {
|
|
2347
|
+
if (!plan.process_key) continue;
|
|
2348
|
+
try {
|
|
2349
|
+
const elements = await engine.searchElementInstances({ processInstanceKey: plan.process_key });
|
|
2350
|
+
const phase = deriveEpicPhaseLive(elements, waveByPlan.get(plan.plan_key) ?? undefined);
|
|
2351
|
+
if (phase !== null && phase !== plan.epic_phase) {
|
|
2352
|
+
await plans(data).update(plan.plan_key, { epic_phase: phase, updated_at: now() });
|
|
2353
|
+
}
|
|
2354
|
+
} catch (err) {
|
|
2355
|
+
console.error(`[poller] epic phase ${plan.plan_key}: ${err}`);
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
// Terminal "Fleet dispatched" phase: a COMPLETION marker, derived from the durable terminal status
|
|
2360
|
+
// rather than a fleeting ACTIVE `record-results` token a coarse poll would miss (#542 review). A
|
|
2361
|
+
// `done` epic that dispatched ≥1 slice freezes at Dispatched; a taskless `done` and any non-`done`
|
|
2362
|
+
// terminal are left untouched (`deriveTerminalEpicPhase` returns null). Idempotent — writes only on
|
|
2363
|
+
// a real change, so a steady-state pass over already-Dispatched rows is a no-op.
|
|
2364
|
+
for (const plan of await plans(data).find({ status: "done" })) {
|
|
2365
|
+
const phase = deriveTerminalEpicPhase(plan.status, plan.task_count);
|
|
2366
|
+
if (phase !== null && phase !== plan.epic_phase) {
|
|
2367
|
+
await plans(data).update(plan.plan_key, { epic_phase: phase, updated_at: now() });
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2312
2372
|
/** Poll pass (ADR 0005 slice S5): reconcile each RUNNING delivery-graph run's derived phase from
|
|
2313
2373
|
* engine truth, and complete it when its instance ends. A delivery graph is a DYNAMIC compiled
|
|
2314
2374
|
* process with no happy-path host worker, so — unlike `plans`/`feature_runs`, whose spine workers
|
|
@@ -2316,22 +2376,25 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
|
|
|
2316
2376
|
* transition (instanceTracking's `onTerminated` edge reconciles only TERMINATED, never COMPLETED, so
|
|
2317
2377
|
* a graph that ends normally would otherwise stay `running` forever). Generalises the `epic_phase`
|
|
2318
2378
|
* derived-phase machinery to a graph whose element ids aren't known ahead of time: the parked-node
|
|
2319
|
-
* label is derived from the run row's stamped `human_labels` + the instance's
|
|
2320
|
-
*
|
|
2379
|
+
* label is derived from the run row's stamped `human_labels` + the instance's live USER_TASK parks.
|
|
2380
|
+
* The parked node is now sourced from the unified element-instance wait-state channel
|
|
2381
|
+
* (`searchElementInstanceWaitStates`, nano-ide#473) rather than a separate user-task search, folding
|
|
2382
|
+
* this read onto the same live element-instance model the epic derivation uses (S8, #542). Scoped to
|
|
2383
|
+
* `running` rows (an `awaiting-approval` run has no instance yet), so it stays O(in-flight). */
|
|
2321
2384
|
export async function pollDeliveryGraphPhase(
|
|
2322
2385
|
data: DataLayer,
|
|
2323
|
-
engine: Pick<EngineClient, "searchProcessInstances" | "
|
|
2386
|
+
engine: Pick<EngineClient, "searchProcessInstances" | "searchElementInstanceWaitStates">,
|
|
2324
2387
|
) {
|
|
2325
2388
|
for (const run of await deliveryGraphRuns(data).find({ status: "running" })) {
|
|
2326
2389
|
if (!run.process_key) continue;
|
|
2327
2390
|
const processKey = run.process_key;
|
|
2328
2391
|
try {
|
|
2329
|
-
const [snapshots,
|
|
2392
|
+
const [snapshots, parks] = await Promise.all([
|
|
2330
2393
|
engine.searchProcessInstances({ processInstanceKeys: [processKey] }),
|
|
2331
|
-
engine.
|
|
2394
|
+
engine.searchElementInstanceWaitStates({ processInstanceKey: processKey, waitStateType: "USER_TASK" }),
|
|
2332
2395
|
]);
|
|
2333
2396
|
const state = snapshots.find((s) => String(s.processInstanceKey) === processKey)?.state ?? null;
|
|
2334
|
-
const projection = deriveDeliveryPhase(state,
|
|
2397
|
+
const projection = deriveDeliveryPhase(state, parks, parseHumanLabels(run.human_labels));
|
|
2335
2398
|
if (run.status !== projection.status || run.phase !== projection.phase || run.phase_node_id !== projection.phase_node_id) {
|
|
2336
2399
|
await deliveryGraphRuns(data).update(run.run_key, {
|
|
2337
2400
|
status: projection.status,
|
|
@@ -2538,6 +2601,7 @@ export async function pollOnce(
|
|
|
2538
2601
|
await pollFeatureDelivery(data);
|
|
2539
2602
|
await pollLineage(data);
|
|
2540
2603
|
await pollUserTasks(data, engine, engineRest);
|
|
2604
|
+
await pollEpicPhase(data, engine);
|
|
2541
2605
|
await pollDeliveryGraphPhase(data, engine);
|
|
2542
2606
|
await pollDeliveryProposals(data);
|
|
2543
2607
|
if (engineRest) {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Unit coverage for the ONE canonical progress step axis (app/stepAxis.ts) — the single source of the
|
|
2
|
+
// derived stepper's vocabulary, cell→step mapping, terminal-tier normalization, and the deterministic
|
|
3
|
+
// parallel-frontier reduction (ADR 0006 §4b, issue #541 / S7).
|
|
4
|
+
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assert, assertEquals } from "#test-assert";
|
|
7
|
+
import { STAGE_DONE_STATUSES, STAGE_KEYS } from "./stage.ts";
|
|
8
|
+
import {
|
|
9
|
+
CELL_STEP,
|
|
10
|
+
DONE_TERMINAL,
|
|
11
|
+
type FrontierBranch,
|
|
12
|
+
INITIAL_STEP,
|
|
13
|
+
reduceFrontier,
|
|
14
|
+
STEP_KEYS,
|
|
15
|
+
stepOrdinal,
|
|
16
|
+
terminalTier,
|
|
17
|
+
TERMINAL_STEP,
|
|
18
|
+
} from "./stepAxis.ts";
|
|
19
|
+
|
|
20
|
+
test("STEP_KEYS is SEEDED from STAGE_KEYS — the axis cannot fork across surfaces", () => {
|
|
21
|
+
assertEquals([...STEP_KEYS], [...STAGE_KEYS]);
|
|
22
|
+
// The two lifecycle bookends are the head/tail of the shared axis.
|
|
23
|
+
assertEquals(INITIAL_STEP, STAGE_KEYS[0]);
|
|
24
|
+
assertEquals(INITIAL_STEP, "Requested");
|
|
25
|
+
assertEquals(TERMINAL_STEP, STAGE_KEYS[STAGE_KEYS.length - 1]);
|
|
26
|
+
assertEquals(TERMINAL_STEP, "Done");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("the explicit cell→step mapping collapses every process cell onto an existing STAGE_KEYS bracket (no new axis entries)", () => {
|
|
30
|
+
// The three executable cells map to their lifecycle bracket; the interstitial wait/human/escalation
|
|
31
|
+
// cells HOLD at the host bracket rather than owning a distinct step.
|
|
32
|
+
assertEquals(CELL_STEP.implement, "Implementing");
|
|
33
|
+
assertEquals(CELL_STEP.converge, "Converging");
|
|
34
|
+
assertEquals(CELL_STEP.merge, "Merging");
|
|
35
|
+
assertEquals(CELL_STEP.wait, "Implementing");
|
|
36
|
+
assertEquals(CELL_STEP.human, "Converging");
|
|
37
|
+
assertEquals(CELL_STEP.escalation, "Converging");
|
|
38
|
+
// Every mapped bracket is a real axis key — v1 adds no stages, so the pipeline renderer is unchanged.
|
|
39
|
+
for (const step of Object.values(CELL_STEP)) {
|
|
40
|
+
assert(STEP_KEYS.includes(step), `cell step "${step}" is not a STAGE_KEYS member`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("stepOrdinal gives the axis total order the frontier reduction compares advancement by", () => {
|
|
45
|
+
assertEquals(stepOrdinal("Requested"), 0);
|
|
46
|
+
assertEquals(stepOrdinal("Implementing"), 1);
|
|
47
|
+
assertEquals(stepOrdinal("PR open"), 2);
|
|
48
|
+
assertEquals(stepOrdinal("Converging"), 3);
|
|
49
|
+
assertEquals(stepOrdinal("Merging"), 4);
|
|
50
|
+
assertEquals(stepOrdinal("Done"), 5);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("terminalTier reuses the shipped stage_state tiers (converged/merged/done→ok, blocked distinct, failed/skipped/abandoned→failed, active→null)", () => {
|
|
54
|
+
assertEquals(terminalTier("merged"), "ok");
|
|
55
|
+
assertEquals(terminalTier("converged"), "ok");
|
|
56
|
+
assertEquals(terminalTier("done"), "ok");
|
|
57
|
+
assertEquals(terminalTier("blocked"), "blocked");
|
|
58
|
+
assertEquals(terminalTier("failed"), "failed");
|
|
59
|
+
assertEquals(terminalTier("skipped"), "failed");
|
|
60
|
+
assertEquals(terminalTier("abandoned"), "failed");
|
|
61
|
+
assertEquals(terminalTier("running"), null);
|
|
62
|
+
assertEquals(terminalTier("converging"), null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("terminalTier's partition is DERIVED from STAGE_DONE_STATUSES — every canonical terminal is tiered exactly once (drift guard)", () => {
|
|
66
|
+
// The stepAxis module already throws at load if the partition drifts from STAGE_DONE_STATUSES; assert
|
|
67
|
+
// the coupling here too so a reviewer sees the invariant. Every STAGE_DONE_STATUSES member gets a
|
|
68
|
+
// non-null tier, and the only status tiered OUTSIDE that canonical set is the delivery-graph `done`.
|
|
69
|
+
for (const status of STAGE_DONE_STATUSES) {
|
|
70
|
+
assert(terminalTier(status) !== null, `STAGE_DONE_STATUSES member "${status}" must be tiered by terminalTier`);
|
|
71
|
+
}
|
|
72
|
+
assertEquals(terminalTier(DONE_TERMINAL), "ok");
|
|
73
|
+
assert(!STAGE_DONE_STATUSES.includes(DONE_TERMINAL), "`done` is the S7 canonical success value, tiered on top of STAGE_DONE_STATUSES (not a member of it)");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── reduceFrontier — the deterministic parallel-frontier rollup (§4b §280-332) ────────────────────
|
|
77
|
+
|
|
78
|
+
const branch = (nodeId: string, step: FrontierBranch["step"], terminal: string | null = null): FrontierBranch => ({ nodeId, step, terminal });
|
|
79
|
+
|
|
80
|
+
test("a single branch reduces trivially to itself (the feature + S7 delivery-graph coarse case)", () => {
|
|
81
|
+
assertEquals(reduceFrontier([branch("n0", "Implementing")]), { step: "Implementing", state: null });
|
|
82
|
+
assertEquals(reduceFrontier([branch("n0", "Done", "done")]), { step: "Done", state: "ok" });
|
|
83
|
+
assertEquals(reduceFrontier([branch("n0", "Done", "failed")]), { step: "Done", state: "failed" });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("all-active frontier reduces to the LEAST-ADVANCED active branch (never further than the slowest in-flight branch)", () => {
|
|
87
|
+
const r = reduceFrontier([branch("a", "Merging"), branch("b", "Implementing"), branch("c", "Converging")]);
|
|
88
|
+
assertEquals(r, { step: "Implementing", state: null });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("all-active ties on step break deterministically by stable node id", () => {
|
|
92
|
+
const r = reduceFrontier([branch("z", "Converging"), branch("a", "Converging")]);
|
|
93
|
+
assertEquals(r, { step: "Converging", state: null });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("MIXED with only SUCCESS terminals + active branches reduces to the least-advanced ACTIVE branch (terminal branches are past, not 'still blocked on')", () => {
|
|
97
|
+
const r = reduceFrontier([branch("done1", "Done", "merged"), branch("active1", "Converging"), branch("active2", "Merging")]);
|
|
98
|
+
assertEquals(r, { step: "Converging", state: null });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("MIXED: a NON-SUCCESS terminal (failed) takes precedence over in-flight siblings — the aggregate renders that branch's step + failed state", () => {
|
|
102
|
+
const r = reduceFrontier([branch("active", "Implementing"), branch("bad", "Converging", "failed"), branch("done", "Done", "merged")]);
|
|
103
|
+
assertEquals(r, { step: "Converging", state: "failed" });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("MIXED: a blocked terminal surfaces as the DISTINCT blocked render state (operator-actionable), not masked by an active sibling", () => {
|
|
107
|
+
const r = reduceFrontier([branch("active", "Merging"), branch("stuck", "Implementing", "blocked")]);
|
|
108
|
+
assertEquals(r, { step: "Implementing", state: "blocked" });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("MULTIPLE non-success terminals tie-break by earliest terminal step, then stable node id", () => {
|
|
112
|
+
// Two failed branches at different steps → earliest step wins.
|
|
113
|
+
assertEquals(
|
|
114
|
+
reduceFrontier([branch("a", "Converging", "failed"), branch("b", "Implementing", "blocked"), branch("act", "Merging")]),
|
|
115
|
+
{ step: "Implementing", state: "blocked" },
|
|
116
|
+
);
|
|
117
|
+
// Two non-success terminals at the SAME step → stable node id wins (and its own render state).
|
|
118
|
+
assertEquals(
|
|
119
|
+
reduceFrontier([branch("z", "Converging", "failed"), branch("a", "Converging", "blocked")]),
|
|
120
|
+
{ step: "Converging", state: "blocked" },
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("ALL-TERMINAL, all success → done (the axis tail, ok)", () => {
|
|
125
|
+
const r = reduceFrontier([branch("a", "Converging", "converged"), branch("b", "Merging", "merged")]);
|
|
126
|
+
assertEquals(r, { step: "Done", state: "ok" });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("ALL-TERMINAL with any non-success → earliest non-success terminal step (same tie-break)", () => {
|
|
130
|
+
const r = reduceFrontier([branch("a", "Merging", "merged"), branch("b", "Converging", "failed"), branch("c", "Implementing", "blocked")]);
|
|
131
|
+
assertEquals(r, { step: "Implementing", state: "blocked" });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("reduceFrontier throws on an empty frontier — a unit always has at least its own branch", () => {
|
|
135
|
+
let threw = false;
|
|
136
|
+
try {
|
|
137
|
+
reduceFrontier([]);
|
|
138
|
+
} catch {
|
|
139
|
+
threw = true;
|
|
140
|
+
}
|
|
141
|
+
assert(threw, "expected reduceFrontier([]) to throw");
|
|
142
|
+
});
|