@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 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
- searchUserTasks: async () => [],
118
+ searchElementInstanceWaitStates: async () => [],
119
119
  };
120
120
  await pollDeliveryGraphPhase(data, engine as never);
121
121
  assertEquals((await runs.get("rk"))?.status, "done");
@@ -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's lasting result is the "Fleet dispatched" terminal.
24
- assertEquals(deriveEpicPhase("record-results"), EPIC_PHASE.DISPATCHED);
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") → Dispatched: the finalize step's lasting result is the
78
- * "Fleet dispatched" terminal end event.
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.DISPATCHED,
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 OPEN user tasks. Scoped
2320
- * to `running` rows (an `awaiting-approval` run has no instance yet), so it stays O(in-flight). */
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" | "searchUserTasks">,
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, tasks] = await Promise.all([
2392
+ const [snapshots, parks] = await Promise.all([
2330
2393
  engine.searchProcessInstances({ processInstanceKeys: [processKey] }),
2331
- engine.searchUserTasks({ processInstanceKey: processKey, state: "CREATED" }),
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, tasks, parseHumanLabels(run.human_labels));
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.144.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.13.1",
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
- // Domain-phase projection (#261): the finalizer landed with opened PRs the epic reaches its
54
- // terminal "Fleet dispatched" phase (derived structurally from this worker's BPMN element id).
55
- // The failed/no-work path above leaves epic_phase untouched: its terminal signal is status +
56
- // outcome, and stamping "Dispatched" against a failed epic would misread. A null derivation
57
- // (element id absent) must not clobber the last implementing phase.
58
- const epicPhase = deriveEpicPhase(job.elementId);
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 (#261): more waves remain, so the epic stays Implementing (wave n/t).
149
- assertEquals((planUpdates[0].patch as Record<string, unknown>).epic_phase, "Implementing (wave 2/2)");
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 (#261): the final wave landed with no successor and no trial merge, so
182
- // the epic enters Finalizing (record-results then advances to the Dispatched terminal).
183
- assertEquals((planUpdates[0].patch as Record<string, unknown>).epic_phase, "Finalizing");
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
- // Wave index the epic is now on (issue #137): while more waves remain, the wave about to run; on
306
- // the final wave, pinned to the last index so a finished epic reads N/N (nextWave would be
307
- // waveCount, one past the last band). This is a LOCAL value only it is used below to derive the
308
- // `epic_phase` (Implementing wave n/t) and the domain phase.
309
- const projectedCurrentWave = hasMoreWaves ? nextWave : Math.max(0, waveCount - 1);
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 (#261): dispatching the wave marks the epic Implementing (wave n/t),
87
- // derived from this worker's BPMN element id + the levelize records.
88
- assertEquals(plans[0].epic_phase, "Implementing (wave 2/2)");
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
- try {
80
- await plans(app.data).update(planKey, {
81
- // Operator-visibility wave progress (current_wave / wave_count / wave_label) was RETIRED as a
82
- // stored projection (epic #412) — it is now derived from `plan_tasks` by the `plan_wave_label`
83
- // / `plan_read_model` VIEWs (060/061), so select-wave no longer denormalises it. This write
84
- // still stamps the derived domain phase and the inter-epic gate's bound artifacts.
85
- ...(epicPhase ? { epic_phase: epicPhase } : {}),
86
- ...(boundArtifacts.length > 0 ? { bound_artifacts: JSON.stringify(boundArtifacts) } : {}),
87
- updated_at: ts,
88
- });
89
- } catch (err) {
90
- app.log.error(`select-wave: projecting plan row (epic phase / bound artifacts) failed for ${planKey}`, {
91
- err: String(err),
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 });