@nanobpm/nano-workforce 0.144.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 +6 -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/package.json +2 -2
- 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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.145.0](https://github.com/nanobpm/nano-workforce/compare/v0.144.0...v0.145.0) (2026-08-25)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **stepper:** live element-instance epic-phase projection (S8, [#542](https://github.com/nanobpm/nano-workforce/issues/542)) ([#554](https://github.com/nanobpm/nano-workforce/issues/554)) ([fdc3ccc](https://github.com/nanobpm/nano-workforce/commit/fdc3ccc8ffe3797954c8502385634f7c0937571e)), closes [#464](https://github.com/nanobpm/nano-workforce/issues/464) [#541](https://github.com/nanobpm/nano-workforce/issues/541) [#546](https://github.com/nanobpm/nano-workforce/issues/546) [nano-ide#473](https://github.com/nanobpm/nano-ide/issues/473)
|
|
6
|
+
|
|
1
7
|
## [0.144.0](https://github.com/nanobpm/nano-workforce/compare/v0.143.0...v0.144.0) (2026-08-25)
|
|
2
8
|
|
|
3
9
|
### Features
|
|
@@ -115,7 +115,7 @@ test("pollDeliveryGraphPhase: a numeric engine processInstanceKey still matches
|
|
|
115
115
|
// The engine can yield a NUMERIC key; the poller compares against the string process_key.
|
|
116
116
|
const engine = {
|
|
117
117
|
searchProcessInstances: async () => [{ processInstanceKey: 12345, state: "COMPLETED" }],
|
|
118
|
-
|
|
118
|
+
searchElementInstanceWaitStates: async () => [],
|
|
119
119
|
};
|
|
120
120
|
await pollDeliveryGraphPhase(data, engine as never);
|
|
121
121
|
assertEquals((await runs.get("rk"))?.status, "done");
|
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) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.145.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",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@biomejs/biome": "^2.4.11",
|
|
67
|
-
"@nanobpm/urban-testkit": "^0.
|
|
67
|
+
"@nanobpm/urban-testkit": "^0.14.0",
|
|
68
68
|
"@nanobpm/workflow": "^0.14.0",
|
|
69
69
|
"@semantic-release/changelog": "^7.0.0",
|
|
70
70
|
"@semantic-release/git": "^11.0.0",
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
// in that case (the edges were invalid).
|
|
18
18
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
19
19
|
import { type CapabilityNeed, parseCapabilityNeeds } from "../../app/capabilityNeed.ts";
|
|
20
|
-
import { deriveEpicPhase } from "../../app/epicPhase.ts";
|
|
21
20
|
import { plans, planTaskDeps, planTaskNeeds, planTasks } from "../../app/plan.ts";
|
|
22
21
|
import { computeWaves, WaveError, type WaveTask } from "../../app/waves.ts";
|
|
23
22
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
@@ -147,11 +146,6 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
147
146
|
// denormalises it onto the `plans` row.
|
|
148
147
|
updated_at: ts,
|
|
149
148
|
};
|
|
150
|
-
// Domain-phase projection (#261): recording the plan hands the epic to the `review-plan` agent,
|
|
151
|
-
// so it enters the Reviewing phase (derived structurally from this worker's BPMN element id).
|
|
152
|
-
// Guard against a null derivation (element id absent) clobbering the genesis phase.
|
|
153
|
-
const epicPhase = deriveEpicPhase(job.elementId);
|
|
154
|
-
if (epicPhase) patch.epic_phase = epicPhase;
|
|
155
149
|
if (tasks.length === 0) patch.outcome = note ? str(note) : "planner emitted no tasks";
|
|
156
150
|
await plans(app.data).update(planKey, patch);
|
|
157
151
|
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
|
|
14
14
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
15
15
|
import { BpmnError } from "@nanobpm/urban";
|
|
16
|
-
import { deriveEpicPhase } from "../../app/epicPhase.ts";
|
|
17
16
|
import { plans, planTasks } from "../../app/plan.ts";
|
|
18
17
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
19
18
|
|
|
@@ -50,16 +49,16 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
50
49
|
throw new BpmnError("NO_WORK_DISPATCHED", `${planKey}: ${outcome}`);
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
52
|
+
// The epic's terminal "Fleet dispatched" phase is no longer stamped here (S8, #542). While this
|
|
53
|
+
// finalizer's token is ACTIVE the epic reads `Finalizing` from the live element-instance model
|
|
54
|
+
// (`pollEpicPhase` → `deriveEpicPhaseLive`); the terminal `Dispatched` is then derived from this
|
|
55
|
+
// `done` status by `pollEpicPhase` (`deriveTerminalEpicPhase`) — a completion marker with no live
|
|
56
|
+
// token to read, so it is taken from the durable terminal status, not a fleeting ACTIVE token. The
|
|
57
|
+
// failed/no-work path above likewise stamps no phase — its terminal signal is status + outcome, and
|
|
58
|
+
// a `failed` epic is deliberately never labelled Dispatched.
|
|
59
59
|
await plans(app.data).update(planKey, {
|
|
60
60
|
status: "done",
|
|
61
61
|
outcome: `${opened} PR(s) dispatched to convergence`,
|
|
62
|
-
...(epicPhase ? { epic_phase: epicPhase } : {}),
|
|
63
62
|
updated_at: ts,
|
|
64
63
|
});
|
|
65
64
|
|
|
@@ -145,8 +145,9 @@ test("record-wave retries the same wave when a task is still pending", async ()
|
|
|
145
145
|
// Wave progress (current_wave/wave_label) was retired as a stored projection (epic #412) — derived
|
|
146
146
|
// from `plan_tasks` by the plan_wave_label VIEW — so record-wave no longer writes it.
|
|
147
147
|
assertEquals("current_wave" in (planUpdates[0].patch as Record<string, unknown>), false);
|
|
148
|
-
// Domain-phase projection
|
|
149
|
-
|
|
148
|
+
// Domain-phase projection is no longer stamped by this worker (S8, #542) — the epic phase is a pure
|
|
149
|
+
// read-model derivation off the live element-instance model (`pollEpicPhase`, app/service.ts).
|
|
150
|
+
assertEquals("epic_phase" in (planUpdates[0].patch as Record<string, unknown>), false);
|
|
150
151
|
});
|
|
151
152
|
|
|
152
153
|
test("record-wave pins current_wave to the last index and clears gate_wave on the final wave", async () => {
|
|
@@ -178,9 +179,9 @@ test("record-wave pins current_wave to the last index and clears gate_wave on th
|
|
|
178
179
|
assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, null);
|
|
179
180
|
assertEquals("current_wave" in (planUpdates[0].patch as Record<string, unknown>), false);
|
|
180
181
|
assertEquals("wave_label" in (planUpdates[0].patch as Record<string, unknown>), false);
|
|
181
|
-
// Domain-phase projection
|
|
182
|
-
//
|
|
183
|
-
assertEquals((planUpdates[0].patch as Record<string, unknown>)
|
|
182
|
+
// Domain-phase projection is no longer stamped by this worker (S8, #542) — the epic phase is a pure
|
|
183
|
+
// read-model derivation off the live element-instance model (`pollEpicPhase`, app/service.ts).
|
|
184
|
+
assertEquals("epic_phase" in (planUpdates[0].patch as Record<string, unknown>), false);
|
|
184
185
|
});
|
|
185
186
|
|
|
186
187
|
test("record-wave writes no wave-progress columns for a taskless plan (waveCount 0)", async () => {
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
// and, crucially, so a later wave's `dependsOn` can reference the PR keys earlier waves produced.
|
|
16
16
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
17
17
|
import { appendEntry } from "../../app/blackboard.ts";
|
|
18
|
-
import { EPIC_PHASE, implementingPhase } from "../../app/epicPhase.ts";
|
|
19
18
|
import { fetchPrFiles, fetchPrHead } from "../../app/github.ts";
|
|
20
19
|
import { deriveExclusions, recordExclusions } from "../../app/mergeExclusion.ts";
|
|
21
20
|
import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
|
|
@@ -302,25 +301,11 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
302
301
|
const nextWave = stillPendingCurrentWave ? currentWave : currentWave + 1;
|
|
303
302
|
const hasMoreWaves = stillPendingCurrentWave || nextWave < waveCount;
|
|
304
303
|
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
|
|
310
|
-
// Operator-visibility wave progress (current_wave / wave_label) was RETIRED as a stored projection
|
|
311
|
-
// (epic #412) — it is now derived from `plan_tasks` by the `plan_wave_label` / `plan_read_model`
|
|
312
|
-
// VIEWs (060/061), so this worker no longer denormalises it (select-wave no longer writes it
|
|
313
|
-
// either). `projectedCurrentWave` above is not persisted; it only feeds the phase derivation.
|
|
314
|
-
|
|
315
|
-
// Domain-phase projection (#261): the wave landed — stamp the phase the epic is ENTERING next,
|
|
316
|
-
// which is data-dependent here (unlike the structural spine writers). A trial merge runs → Trial
|
|
317
|
-
// merging; another wave follows → Implementing (next wave n/t); otherwise the finalizer runs →
|
|
318
|
-
// Finalizing (record-results then advances to the Dispatched terminal).
|
|
319
|
-
const epicPhase = runTrialMerge
|
|
320
|
-
? EPIC_PHASE.TRIAL_MERGING
|
|
321
|
-
: hasMoreWaves
|
|
322
|
-
? implementingPhase(projectedCurrentWave, waveCount)
|
|
323
|
-
: EPIC_PHASE.FINALIZING;
|
|
304
|
+
// The epic's domain phase is no longer stamped here (S8, #542) — it is a pure read-model derivation
|
|
305
|
+
// off the live element-instance model (`pollEpicPhase` → `deriveEpicPhaseLive`, app/epicPhase.ts),
|
|
306
|
+
// which reads the live token position (a running `implement` fan-out, `trial-merge`, or the
|
|
307
|
+
// finalizer) directly rather than this worker projecting the phase it is ABOUT to enter. This write
|
|
308
|
+
// now only arms the durable wave-merge barrier marker.
|
|
324
309
|
|
|
325
310
|
// Wave-merge barrier: when another wave follows, park the plan-fanout instance at the
|
|
326
311
|
// `wait-wave-merged` catch event until THIS wave's opened PRs have MERGED (not merely opened).
|
|
@@ -334,7 +319,6 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
334
319
|
try {
|
|
335
320
|
await plans(app.data).update(planKey, {
|
|
336
321
|
gate_wave: hasMoreWaves ? currentWave : null,
|
|
337
|
-
epic_phase: epicPhase,
|
|
338
322
|
updated_at: ts,
|
|
339
323
|
});
|
|
340
324
|
} catch (err) {
|
|
@@ -83,9 +83,10 @@ test("select-wave dispatches the active wave without writing wave-progress colum
|
|
|
83
83
|
assertEquals(plans[0].current_wave, undefined);
|
|
84
84
|
assertEquals(plans[0].wave_count, undefined);
|
|
85
85
|
assertEquals(plans[0].wave_label, undefined);
|
|
86
|
-
// Domain-phase projection (#
|
|
87
|
-
//
|
|
88
|
-
|
|
86
|
+
// Domain-phase projection is no longer stamped by this worker (S8, #542) — the epic phase is now a
|
|
87
|
+
// pure read-model derivation off the live element-instance model (`pollEpicPhase`, app/service.ts),
|
|
88
|
+
// so select-wave introduces no `epic_phase` onto the plan row.
|
|
89
|
+
assertEquals(plans[0].epic_phase, undefined);
|
|
89
90
|
});
|
|
90
91
|
|
|
91
92
|
test("select-wave captures the preflight's resolvedArtifacts onto plans.bound_artifacts (#292 S4)", async () => {
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
// immediately (the same 0-task path the flat fan-out already relied on).
|
|
17
17
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
18
18
|
import type { CapabilityNeed } from "../../app/capabilityNeed.ts";
|
|
19
|
-
import { deriveEpicPhase } from "../../app/epicPhase.ts";
|
|
20
19
|
import { plans, planTaskDeps, planTaskNeeds, planTasks } from "../../app/plan.ts";
|
|
21
20
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
22
21
|
|
|
@@ -52,20 +51,6 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
52
51
|
const statusById = new Map<string, string>();
|
|
53
52
|
for (const r of rows) statusById.set(r.task_id, r.status);
|
|
54
53
|
|
|
55
|
-
// Operator-visibility projection (issue #137): mark this as the wave the fleet is now
|
|
56
|
-
// implementing, so the epics-index can show wave X/N at a glance. wave_count is derivable from
|
|
57
|
-
// the levelized rows (max wave + 1), so the "X/N" label stays correct even if a re-levelize
|
|
58
|
-
// changed the total. Best-effort + idempotent (a retry re-writes the same value) and
|
|
59
|
-
// display-only — it must never gate control flow, which stays driven by the process
|
|
60
|
-
// `currentWave`/`waveCount`/`gate_wave` state.
|
|
61
|
-
const waveCount = rows.reduce((m, r) => Math.max(m, r.wave ?? 0), -1) + 1;
|
|
62
|
-
// Domain-phase projection (#261): select-wave dispatches this wave and is the last host write
|
|
63
|
-
// before the write-silent `implement` MI, so it durably marks the implementation phase for the
|
|
64
|
-
// wave it launches — `Implementing (wave n/t)` from the levelize records (job.elementId +
|
|
65
|
-
// current/total waves). A null derivation (element id absent) must not clobber the phase.
|
|
66
|
-
const epicPhase = waveCount > 0
|
|
67
|
-
? deriveEpicPhase(job.elementId, { current: currentWave, total: waveCount })
|
|
68
|
-
: null;
|
|
69
54
|
// Inter-epic gate projection (#292 slice S4): reaching select-wave proves this epic's leading
|
|
70
55
|
// capability PREFLIGHT (S3) already went GREEN, so capture the `resolvedArtifacts` the preflight
|
|
71
56
|
// bound — the exact `pkg@version`s first carrying each producer's awaited capability — onto the
|
|
@@ -76,20 +61,21 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
76
61
|
const boundArtifacts = Array.isArray(job.variables.resolvedArtifacts)
|
|
77
62
|
? job.variables.resolvedArtifacts.filter((v): v is string => typeof v === "string" && v.trim().length > 0)
|
|
78
63
|
: [];
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
64
|
+
// The epic's Implementing (wave n/t) phase is no longer stamped here (S8, #542) — it is a pure
|
|
65
|
+
// read-model derivation off the live element-instance model (`pollEpicPhase`, app/service.ts), which
|
|
66
|
+
// reads the running `implement` fan-out directly. This write now only carries the inter-epic gate's
|
|
67
|
+
// bound artifacts, so it is skipped entirely when there are none.
|
|
68
|
+
if (boundArtifacts.length > 0) {
|
|
69
|
+
try {
|
|
70
|
+
await plans(app.data).update(planKey, {
|
|
71
|
+
bound_artifacts: JSON.stringify(boundArtifacts),
|
|
72
|
+
updated_at: ts,
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
app.log.error(`select-wave: projecting plan row (bound artifacts) failed for ${planKey}`, {
|
|
76
|
+
err: String(err),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
93
79
|
}
|
|
94
80
|
|
|
95
81
|
const deps = await planTaskDeps(app.data).find({ plan_key: planKey });
|