@nanobpm/nano-workforce 0.122.0 → 0.123.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/app/plan.ts CHANGED
@@ -12,7 +12,6 @@
12
12
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
13
13
  import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
14
14
  import { capsWaitTimeout, DEFAULT_CAPS_WAIT_TIMEOUT } from "./capsWait.ts";
15
- import { deriveEpicBucket, epicIsAcknowledgeable } from "./delivery.ts";
16
15
  import { EPIC_PHASE } from "./epicPhase.ts";
17
16
  import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
18
17
  import {
@@ -93,8 +92,9 @@ export interface Plan {
93
92
  // #412): `delivery` / `delivery_label` are now a DERIVED SQL VIEW (`plan_delivery` /
94
93
  // `plan_read_model`, 061), computed from the SAME pure `deriveDelivery`/`TERMINAL_STATUSES`
95
94
  // (app/delivery.ts). The pages read them off the view; the pollers that still need the signal
96
- // (`pollPlanBucket` for list_bucket/ack_open, `pollPromotion` for `isPromotable`) recompute it at
97
- // read time via `deriveDelivery`. There is no stored column and no write-path any more.
95
+ // (`pollPromotion` for `isPromotable`) recompute it at
96
+ // read time via `deriveDelivery`, as does the `plan_read_model` VIEW's bucket derivation (074).
97
+ // There is no stored column and no write-path any more.
98
98
  // Derived epic domain phase (038_plan_epic_phase.sql, #261): the epic's own lifecycle phase —
99
99
  // Planning / Reviewing / Implementing (wave n/t) / Trial merging / Finalizing / Dispatched —
100
100
  // projected at write time from plan-fanout.bpmn's named activities (app/epicPhase.ts), so the epic
@@ -112,16 +112,18 @@ export interface Plan {
112
112
  // which has nothing to promote). Display-only; projected by the poller.
113
113
  promotion_pr: string | null;
114
114
  promotion_state: string | null;
115
- // Active/History partition + operator tick-off (044_plan_list_bucket.sql, #298). Derived,
116
- // write-time-projected by the `plans` gateway (below) from the pure `deriveEpicBucket` /
117
- // `epicIsAcknowledgeable` helpers (app/delivery.ts) never written by the plan lifecycle or a
118
- // poller directly. Bucket epics on the derived `delivery` rollup, not raw `status`, so a `done`
119
- // epic still converging or landed-but-unpromoted does not vanish from Active.
115
+ // Active/History partition + operator tick-off (044_plan_list_bucket.sql, #298). RETIRED as a
116
+ // write-time projection (issue #439): `list_bucket`/`ack_open` are now DERIVED by the
117
+ // `plan_read_model` VIEW (074) from `status`, `acknowledged_at`, and the derived `plan_delivery`
118
+ // signal mirroring the pure `deriveEpicBucket` / `epicIsAcknowledgeable` (app/delivery.ts). The
119
+ // Epics pages bind the VIEW, never these base columns, so a raw-datasource `status` write (the
120
+ // `instanceTracking` reconciler) can no longer leave them stale, and the delivery-aware
121
+ // `pollPlanBucket` correction is retired (the VIEW sees the live signal). The base columns survive
122
+ // (expand/contract — a later migration drops them) but are no longer written or read.
120
123
  // • acknowledged_at — NULL until an operator dismisses a resolved `done` epic (acknowledge-epic).
121
- // list_bucket — 'active' | 'history': the page tabs filter on this flat column.
122
- // • ack_open 1 | 0: 1 iff a resolved (`done`, not converging) but unacknowledged epic,
123
- // gating the Dismiss button's `showWhenField`. NULL only on pre-#298 rows
124
- // until `backfillPlanBuckets`.
124
+ // Still written; the sole live input the derivation reads off the row.
125
+ // • list_bucket 'active' | 'history': VESTIGIAL base column; the pages filter the VIEW's.
126
+ // ack_open — 1 | 0: VESTIGIAL base column; the pages gate Dismiss on the VIEW's.
125
127
  acknowledged_at: string | null;
126
128
  list_bucket: string | null;
127
129
  ack_open: number | null;
@@ -182,91 +184,19 @@ export const PLAN_TASK_STATUSES = [
182
184
  ] as const;
183
185
  export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
184
186
 
185
- export const plans = (data: DataLayer) => {
186
- const table = data.table<Plan>("plans", "plan_key");
187
- return new Proxy(table, {
188
- get(target, prop) {
189
- if (prop === "insert") {
190
- return (row: Partial<Plan>) => target.insert({ ...row, ...projectPlanBucket(row) });
191
- }
192
- if (prop === "update") {
193
- return async (id: unknown, patch: Partial<Plan>) => {
194
- // Only re-read + reproject when the patch changes a projection input (status /
195
- // acknowledged_at) or writes a derived column directly. A projection-irrelevant patch (e.g.
196
- // an `updated_at`-only write — including the direct `data.table` writes in
197
- // e.g. `app/retro.ts` that stamp `retro_started_at`) leaves the stored projection correct, so
198
- // skip the extra `get` roundtrip and delegate straight. Any bucket-relevant write
199
- // (status/acknowledged_at or a derived column) MUST go through this gateway to stay
200
- // reprojected.
201
- if (!patchAffectsPlanProjection(patch)) return target.update(id, patch);
202
- const existing = await target.get(id);
203
- const merged: Partial<Plan> = { ...existing, ...patch };
204
- return target.update(id, { ...patch, ...projectPlanBucket(merged) });
205
- };
206
- }
207
- // Delegate every other method straight through. Bind functions to the real target so the
208
- // gateway's private class fields resolve — a Proxy `this` would not carry them.
209
- const value = Reflect.get(target, prop, target);
210
- return typeof value === "function" ? value.bind(target) : value;
211
- },
212
- });
213
- };
214
-
215
- /** The `plans` fields the bucket projection READS: a patch touching none of these (and none it
216
- * writes) cannot change `list_bucket`/`ack_open`, so the gateway skips the read-back+reproject. Kept
217
- * adjacent to {@link projectPlanBucket} so the two stay in lockstep. */
218
- const PLAN_PROJECTION_INPUT_KEYS: readonly (keyof Plan)[] = ["status", "acknowledged_at"];
219
-
220
- /** The `plans` fields the bucket projection WRITES. Included in the reproject trigger so a caller who
221
- * writes a derived column directly (e.g. `list_bucket`/`ack_open`) can never bypass derivation: the
222
- * gateway re-reads, recomputes, and OVERRIDES the raw value with the canonical derived one. */
223
- const PLAN_PROJECTION_OUTPUT_KEYS: readonly (keyof Plan)[] = ["list_bucket", "ack_open"];
224
-
225
- /** True when a patch changes at least one field the bucket projection derives from OR one it writes —
226
- * i.e. the projection must be recomputed (mirrors feature.ts `patchAffectsProjection`). */
227
- function patchAffectsPlanProjection(patch: Partial<Plan>): boolean {
228
- return (
229
- PLAN_PROJECTION_INPUT_KEYS.some((k) => k in patch) ||
230
- PLAN_PROJECTION_OUTPUT_KEYS.some((k) => k in patch)
231
- );
232
- }
233
-
234
- /** Compute the write-time bucket projection columns for a merged `plans` row. Centralised so the
235
- * gateway is the ONE place `deriveEpicBucket` / `epicIsAcknowledgeable` are applied — the page, SQL,
236
- * pollers and workers never re-derive the mapping (AGENTS.md "derivation over duplication").
187
+ /** The `plans` record gateway (keyed on `plan_key`) a plain record table.
237
188
  *
238
- * The `delivery` signal was retired as a stored column (epic #412), so the write-time projection can
239
- * no longer read it it derives the bucket with `delivery` treated as UNKNOWN (null). This is
240
- * provably safe for `list_bucket`: `deriveEpicBucket` returns the same bucket with `delivery=null` as
241
- * with the real signal for every reachable `(status, acknowledged_at)` state. `ack_open` is only a
242
- * *candidate* here (any unacknowledged `done` epic); the delivery-aware correction — clearing it
243
- * while an epic is still `converging` is applied at read time by `pollPlanBucket` (app/service.ts),
244
- * which recomputes `delivery` via `deriveDelivery`. */
245
- function projectPlanBucket(row: Partial<Plan>): Partial<Plan> {
246
- if (!row.status) return {};
247
- return {
248
- list_bucket: deriveEpicBucket(row.status, null, row.acknowledged_at ?? null),
249
- ack_open:
250
- epicIsAcknowledgeable(row.status, null) && (row.acknowledged_at ?? null) === null ? 1 : 0,
251
- };
252
- }
253
-
254
- /** Re-project every `plans` row through the gateway so rows written before migration 042 (whose
255
- * `list_bucket`/`ack_open` are NULL) get a correct Active/History bucket. Idempotent and safe to
256
- * re-run: it re-derives from each row's own stored fields, so a second pass is a no-op. Runs once at
257
- * boot (pollOnce) — the gateway keeps every future write fresh, so this only needs to catch legacy
258
- * rows. Returns the count actually stamped. Mirrors `backfillFeatureStages` (app/feature.ts). */
259
- export async function backfillPlanBuckets(data: DataLayer): Promise<number> {
260
- const table = plans(data);
261
- let stamped = 0;
262
- for (const row of await table.all()) {
263
- // Only touch rows the projection has never reached — a legacy row whose `list_bucket` is NULL.
264
- if (row.list_bucket != null) continue;
265
- await table.update(row.plan_key, projectPlanBucket(row));
266
- stamped++;
267
- }
268
- return stamped;
269
- }
189
+ * The epic-bucket projection (`list_bucket`/`ack_open`) is NO LONGER a write-time projection here
190
+ * (issue #439): it is DERIVED by the `plan_read_model` VIEW (074) from each row's own `status` /
191
+ * `acknowledged_at` and the derived `plan_delivery` signal, mirroring `deriveEpicBucket` /
192
+ * `epicIsAcknowledgeable` (app/delivery.ts). The Epics pages bind the VIEW, never this table's stored
193
+ * derived columns. Removing the write-time projection closes the drift the framework
194
+ * `instanceTracking` reconciler opened (a raw-datasource `{status:"abandoned"}` write on a terminated
195
+ * instance bypassed the projecting gateway and froze the bucket) AND retires the read-time
196
+ * `pollPlanBucket` correction: the VIEW sees the live delivery signal, so a still-converging epic never
197
+ * offers Dismiss without a poller pass. The pure helpers stay the acknowledge-epic guard and the VIEW's
198
+ * test oracle (app/plansReadModel.test.ts). */
199
+ export const plans = (data: DataLayer) => data.table<Plan>("plans", "plan_key");
270
200
  export const planTasks = (data: DataLayer) => data.table<PlanTask>("plan_tasks", "id");
271
201
 
272
202
  /** One dependency edge in the plan DAG (issue #20): `task_id` waits for `depends_on_task_id`.
@@ -19,7 +19,7 @@ import { DatabaseSync } from "node:sqlite";
19
19
  import { test } from "node:test";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { assert, assertEquals } from "#test-assert";
22
- import { deriveDelivery } from "./delivery.ts";
22
+ import { deriveDelivery, deriveEpicBucket, epicIsAcknowledgeable } from "./delivery.ts";
23
23
 
24
24
  const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
25
25
  const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
@@ -34,7 +34,7 @@ function viewDb(): DatabaseSync {
34
34
  plan_key TEXT PRIMARY KEY, repo TEXT, issue_number INTEGER, issue_url TEXT, title TEXT,
35
35
  status TEXT, task_count INTEGER, process_key TEXT, outcome TEXT, created_at TEXT,
36
36
  updated_at TEXT, epic_phase TEXT, base_branch TEXT, wait_gate_label TEXT, bound_artifacts TEXT,
37
- promotion_pr TEXT, promotion_state TEXT, list_bucket TEXT, ack_open INTEGER);
37
+ promotion_pr TEXT, promotion_state TEXT, acknowledged_at TEXT, list_bucket TEXT, ack_open INTEGER);
38
38
  CREATE TABLE plan_tasks (
39
39
  id INTEGER PRIMARY KEY, plan_key TEXT, task_index INTEGER, task_id TEXT, title TEXT,
40
40
  prompt TEXT, status TEXT, pr_key TEXT, summary TEXT, created_at TEXT, updated_at TEXT,
@@ -44,6 +44,9 @@ function viewDb(): DatabaseSync {
44
44
  db.exec(MIG("059_plan_wave_summary.sql"));
45
45
  db.exec(MIG("060_plan_wave_rollup.sql"));
46
46
  db.exec(MIG("061_plan_delivery_rollup.sql"));
47
+ // 074 redefines plan_read_model to DERIVE list_bucket/ack_open from status + acknowledged_at + the
48
+ // derived plan_delivery signal (issue #439), instead of reading the denormalised base columns.
49
+ db.exec(MIG("074_plan_read_model_derive_bucket.sql"));
47
50
  return db;
48
51
  }
49
52
 
@@ -66,10 +69,16 @@ const MISSING_PR_STATUS = "missing";
66
69
  // Insert a plan plus its tasks (and each task's PR, if any). PR keys are derived so the test rows
67
70
  // stay terse. Returns the flat `pull_requests.status` list `deriveDelivery` consumes (only tasks
68
71
  // that opened a PR), so the delivery assertions can cross-check the view against it.
69
- function addPlan(db: DatabaseSync, plan_key: string, status: string, tasks: SampleTask[]): string[] {
72
+ function addPlan(
73
+ db: DatabaseSync,
74
+ plan_key: string,
75
+ status: string,
76
+ tasks: SampleTask[],
77
+ opts: { acknowledged_at?: string | null } = {},
78
+ ): string[] {
70
79
  db.prepare(
71
- "INSERT INTO plans (plan_key, repo, issue_number, issue_url, status, task_count, updated_at, list_bucket) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
72
- ).run(plan_key, "o/r", 1, `https://gh/${plan_key}`, status, tasks.length, "2026-01-01T00:00:00Z", "active");
80
+ "INSERT INTO plans (plan_key, repo, issue_number, issue_url, status, task_count, updated_at, acknowledged_at, list_bucket) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
81
+ ).run(plan_key, "o/r", 1, `https://gh/${plan_key}`, status, tasks.length, "2026-01-01T00:00:00Z", opts.acknowledged_at ?? null, "active");
73
82
  const prStatuses: string[] = [];
74
83
  tasks.forEach((t, i) => {
75
84
  const prKey = t.pr || t.danglingPr ? `${plan_key}::pr${i}` : null;
@@ -107,6 +116,16 @@ function counts(db: DatabaseSync, plan_key: string): { prs_opened: number; prs_m
107
116
  return { ...r };
108
117
  }
109
118
 
119
+ // The derived Active/History bucket flags the epics pages bind — read straight off `plan_read_model`
120
+ // (074), plus the delivery signal the derivation folds in, so the assertions can cross-check the
121
+ // VIEW against the pure `deriveEpicBucket` / `epicIsAcknowledgeable` oracles.
122
+ function bucket(db: DatabaseSync, plan_key: string): { list_bucket: unknown; ack_open: unknown; delivery: string | null } {
123
+ const r = db
124
+ .prepare("SELECT list_bucket, ack_open, delivery FROM plan_read_model WHERE plan_key = ?")
125
+ .get(plan_key) as { list_bucket: unknown; ack_open: unknown; delivery: string | null };
126
+ return { ...r };
127
+ }
128
+
110
129
  function waveLabel(db: DatabaseSync, plan_key: string): Record<string, unknown> | undefined {
111
130
  const r = db.prepare("SELECT wave_count, current_wave, wave_label FROM plan_wave_label WHERE plan_key = ?").get(plan_key) as
112
131
  | Record<string, unknown>
@@ -259,4 +278,92 @@ test("the operator pages read the derived plan_read_model VIEW for the wave/deli
259
278
  assertEquals(byId("epic-plan").props.data.table, "plan_read_model");
260
279
  assert(/\{\{\s*wave_label\s*\}\}/.test(byId("wave-banner").props.header), "the banner surfaces wave_label");
261
280
  assertEquals(byId("wave-banner").props.body, "delivery_label", "the banner body is the delivery_label");
281
+
282
+ // Epic INDEX page (epic.page.json) — the standalone Epics grid must also read the derived VIEW, not
283
+ // the raw `plans` table. `plans` stays a valid schema table, so a regression here (reverting the
284
+ // binding) would leave every OTHER test green while the index silently resumed reading stale
285
+ // list_bucket/ack_open; this pins it (suppressed advisory epic.page.json — issue #439).
286
+ const epicIndex = PAGE("epic.page.json");
287
+ const epicPlans = (epicIndex.nodes ?? []).find((n: { id: string }) => n.id === "epic-plans");
288
+ assert(epicPlans, "epic index must keep the Epics grid");
289
+ assertEquals(epicPlans.props.data.table, "plan_read_model");
290
+ });
291
+
292
+ test("plan_read_model DERIVES list_bucket / ack_open from status + delivery + acknowledged_at, matching deriveEpicBucket / epicIsAcknowledgeable (issue #439)", () => {
293
+ const db = viewDb();
294
+ // done, still converging (a slice in flight): Active, Dismiss suppressed — never ticked off mid-flight.
295
+ const converging = addPlan(db, "o/r#conv", "done", [
296
+ { status: "opened", wave: 0, pr: { status: "merged" } },
297
+ { status: "opened", wave: 1, pr: { status: "converging" } },
298
+ ]);
299
+ // done, fully landed, unacknowledged: Active with Dismiss OPEN (ack_open=1).
300
+ const landed = addPlan(db, "o/r#land", "done", [
301
+ { status: "opened", wave: 0, pr: { status: "merged" } },
302
+ { status: "opened", wave: 0, pr: { status: "merged" } },
303
+ ]);
304
+ // done, landed AND acknowledged: History, Dismiss closed.
305
+ const acked = addPlan(
306
+ db,
307
+ "o/r#ack",
308
+ "done",
309
+ [{ status: "opened", wave: 0, pr: { status: "merged" } }],
310
+ { acknowledged_at: "2026-02-02T00:00:00Z" },
311
+ );
312
+ // resolved-not-landed (one abandoned), unacknowledged: delivery null → acknowledgeable, Active + Dismiss.
313
+ const resolved = addPlan(db, "o/r#res", "done", [
314
+ { status: "opened", wave: 0, pr: { status: "merged" } },
315
+ { status: "opened", wave: 0, pr: { status: "abandoned" } },
316
+ ]);
317
+ // live (dispatched) epic: Active, not acknowledgeable.
318
+ const live = addPlan(db, "o/r#live", "dispatched", [{ status: "opened", wave: 0, pr: { status: "converging" } }]);
319
+
320
+ for (const [plan_key, status, prStatuses, ackAt] of [
321
+ ["o/r#conv", "done", converging, null],
322
+ ["o/r#land", "done", landed, null],
323
+ ["o/r#ack", "done", acked, "2026-02-02T00:00:00Z"],
324
+ ["o/r#res", "done", resolved, null],
325
+ ["o/r#live", "dispatched", live, null],
326
+ ] as const) {
327
+ const b = bucket(db, plan_key);
328
+ const expectedDelivery = deriveDelivery(status, prStatuses).delivery;
329
+ assertEquals(b.delivery, expectedDelivery, `${plan_key}: delivery`);
330
+ // Cross-check the VIEW against the pure helpers — the SAME oracle the acknowledge-epic op guards on.
331
+ assertEquals(
332
+ b.list_bucket,
333
+ deriveEpicBucket(status, expectedDelivery, ackAt),
334
+ `${plan_key}: list_bucket must equal deriveEpicBucket`,
335
+ );
336
+ const expectedAckOpen = epicIsAcknowledgeable(status, expectedDelivery) && ackAt === null ? 1 : 0;
337
+ assertEquals(b.ack_open, expectedAckOpen, `${plan_key}: ack_open must equal epicIsAcknowledgeable`);
338
+ }
339
+
340
+ // Pin the human-visible outcomes so a derivation drift can't hide behind the cross-check.
341
+ assertEquals(bucket(db, "o/r#conv"), { list_bucket: "active", ack_open: 0, delivery: "converging" });
342
+ assertEquals(bucket(db, "o/r#land"), { list_bucket: "active", ack_open: 1, delivery: "landed" });
343
+ assertEquals(bucket(db, "o/r#ack"), { list_bucket: "history", ack_open: 0, delivery: "landed" });
344
+ assertEquals(bucket(db, "o/r#res"), { list_bucket: "active", ack_open: 1, delivery: null });
345
+ assertEquals(bucket(db, "o/r#live"), { list_bucket: "active", ack_open: 0, delivery: null });
346
+ });
347
+
348
+ test("RED/GREEN GUARD: a RAW-datasource plans.status write (the instanceTracking reconciler bypass) leaves plan_read_model's bucket CONSISTENT", () => {
349
+ // Reproduce the framework `instanceTracking` reconciler class of bug: on a terminated process
350
+ // instance it writes `{status:"abandoned"}` to `plans` through the RAW datasource — bypassing the
351
+ // (now retired) projecting `plans` gateway. Under the OLD write-time projection the stored
352
+ // `list_bucket`/`ack_open` would freeze at their pre-terminal values; because they are now a VIEW
353
+ // over `status`, the read model stays correct with no write-path for any writer to leave stale.
354
+ const db = viewDb();
355
+ // A live epic mid-flight — Active, its (stale) stored projection says active/converging.
356
+ addPlan(db, "o/r#kill", "dispatched", [{ status: "opened", wave: 0, pr: { status: "converging" } }]);
357
+ assertEquals(bucket(db, "o/r#kill").list_bucket, "active");
358
+
359
+ // The reconciler flips status terminal via the RAW table — NOT the gateway. (Simulated with a raw
360
+ // UPDATE, exactly what the raw datasource emits.) It touches neither list_bucket nor ack_open.
361
+ db.prepare("UPDATE plans SET status = 'abandoned' WHERE plan_key = ?").run("o/r#kill");
362
+
363
+ // `abandoned` is a terminal non-`done` status: History, never acknowledgeable — matches the oracle.
364
+ const b = bucket(db, "o/r#kill");
365
+ assertEquals(b.list_bucket, deriveEpicBucket("abandoned", b.delivery, null), "list_bucket tracks status via the VIEW");
366
+ assertEquals(b.list_bucket, "history", "an abandoned epic is filed under History, not wedged in Active");
367
+ assertEquals(b.ack_open, epicIsAcknowledgeable("abandoned", b.delivery) ? 1 : 0);
368
+ assertEquals(b.ack_open, 0, "no phantom Dismiss on a reconciler-cancelled epic");
262
369
  });
package/app/service.ts CHANGED
@@ -27,11 +27,11 @@ import {
27
27
  conformanceEscalationQuestion,
28
28
  } from "./conformance.ts";
29
29
  import { isUniqueConstraintFence } from "./dbFence.ts";
30
- import { deriveDelivery, deriveEpicBucket, epicIsAcknowledgeable, TERMINAL_STATUSES } from "./delivery.ts";
30
+ import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
31
31
  import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
32
32
  import { isDeliveryHumanElement } from "./deliveryHuman.ts";
33
33
  import { fleetSupportsDurableResume } from "./durableResume.ts";
34
- import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
34
+ import { deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
35
35
  import {
36
36
  classifyMergeability,
37
37
  classifyPrLiveness,
@@ -54,7 +54,6 @@ import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
54
54
  import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
55
55
  import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
56
56
  import {
57
- backfillPlanBuckets,
58
57
  capabilityGates,
59
58
  inboundPlanDeps,
60
59
  type Plan,
@@ -1895,46 +1894,6 @@ export async function derivePlanDelivery(
1895
1894
  return deriveDelivery(plan.status, prStatuses).delivery;
1896
1895
  }
1897
1896
 
1898
- /** Idempotent read-model pass (epic #412 — successor to the retired `pollDelivery`): keep each
1899
- * epic's Active/History `list_bucket` + `ack_open` tick-off flags fresh now that the `plans.delivery`
1900
- * column is gone. The `plans` gateway projects both at write time, but with `delivery` treated as
1901
- * UNKNOWN (null) — provably correct for `list_bucket`, but it cannot clear `ack_open` while a `done`
1902
- * epic is still CONVERGING (its slices not all merged). This pass supplies the delivery-aware
1903
- * correction: it recomputes `delivery` at read time via {@link derivePlanDelivery} and re-derives
1904
- * `list_bucket`/`ack_open` from the pure `deriveEpicBucket`/`epicIsAcknowledgeable` helpers, writing
1905
- * the result via the RAW `plans` table so the gateway's delivery-free reprojection can't clobber the
1906
- * corrected value. Writes only when the projection actually changes, so a steady-state pass is a
1907
- * no-op. Never touches `plan.status` — additive/derived only. */
1908
- export async function pollPlanBucket(data: DataLayer) {
1909
- // Preload every PR status once per pass into a pr_key→status map (avoids an N+1 `prs(data).get`).
1910
- const statusByPrKey = new Map<string, string>();
1911
- for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
1912
- // Write through the RAW table, NOT the `plans` gateway: the gateway reprojects `list_bucket`/
1913
- // `ack_open` with `delivery=null` (it can't see the read-time signal), which would undo the
1914
- // delivery-aware `ack_open` correction below. This pass is the authoritative writer of the
1915
- // delivery-aware bucket flags.
1916
- const plansTable = data.table<Plan>("plans", "plan_key");
1917
- for (const plan of await plans(data).all()) {
1918
- try {
1919
- const delivery = await derivePlanDelivery(data, plan, statusByPrKey);
1920
- const listBucket = deriveEpicBucket(plan.status, delivery, plan.acknowledged_at ?? null);
1921
- const ackOpen =
1922
- epicIsAcknowledgeable(plan.status, delivery) && (plan.acknowledged_at ?? null) === null
1923
- ? 1
1924
- : 0;
1925
- if (plan.list_bucket !== listBucket || plan.ack_open !== ackOpen) {
1926
- await plansTable.update(plan.plan_key, {
1927
- list_bucket: listBucket,
1928
- ack_open: ackOpen,
1929
- updated_at: now(),
1930
- });
1931
- }
1932
- } catch (err) {
1933
- console.error(`[poller] plan-bucket ${plan.plan_key}: ${err}`);
1934
- }
1935
- }
1936
- }
1937
-
1938
1897
  /** Idempotent read-model pass (issue #292 slice S4): project each DEPENDENT epic's inter-epic gate
1939
1898
  * state onto its `plans` row so the epic index/detail views can show — as flat columns — which
1940
1899
  * producer/package a parked dependent is blocked on, its poll cadence + escalation deadline, and the
@@ -2442,42 +2401,14 @@ export async function pollUserTasks(
2442
2401
  * The wave-merge barrier is now level-triggered and probes the engine's message-subscription state
2443
2402
  * over the same raw-REST search surface, so it runs only when `engineRest` is supplied (as in
2444
2403
  * production — `main.ts` always passes it). */
2445
- /** One-shot guard so the feature-stage backfill (`backfillFeatureStages`) runs at most once per
2446
- * process, on the first `pollOnce`. Idempotent regardless, but there is no need to re-scan every row
2447
- * on every poll. */
2448
- let featureStagesBackfilled = false;
2449
-
2450
- /** One-shot guard so the epic-bucket backfill (`backfillPlanBuckets`, #298) runs at most once per
2451
- * process, on the first `pollOnce` — re-projecting pre-migration-042 `plans` rows whose
2452
- * `list_bucket` is still NULL. The gateway keeps every future write fresh; idempotent regardless. */
2453
- let planBucketsBackfilled = false;
2454
-
2455
2404
  export async function pollOnce(
2456
2405
  data: DataLayer,
2457
2406
  engine: EngineClient,
2458
2407
  token: string,
2459
2408
  engineRest?: { restAddress: string; token?: string },
2460
2409
  ) {
2461
- // One-shot: re-project any pre-#254 feature_runs rows whose pipeline columns are still NULL. The
2462
- // gateway keeps every future write fresh, so this only needs to run once per process and is safe to
2463
- // re-run (it re-derives from each row's own stored fields).
2464
- if (!featureStagesBackfilled) {
2465
- // Only arm the one-shot guard AFTER a successful backfill: setting it first would swallow a
2466
- // transient failure (e.g. a DB blip) and leave legacy rows unprojected forever, since every later
2467
- // pass would skip. On a throw the guard stays false and the next `pollOnce` retries.
2468
- await backfillFeatureStages(data);
2469
- featureStagesBackfilled = true;
2470
- }
2471
- // One-shot: re-project any pre-#298 `plans` rows whose `list_bucket` is still NULL, so a legacy
2472
- // epic buckets correctly into Active/History from the first pass. Guard armed only after success so
2473
- // a transient failure retries next pass (mirrors the feature-stage backfill above).
2474
- if (!planBucketsBackfilled) {
2475
- await backfillPlanBuckets(data);
2476
- planBucketsBackfilled = true;
2477
- }
2478
2410
  await pollReviews(data, engine, token);
2479
2411
  await pollMerges(data, engine, token);
2480
- await pollPlanBucket(data);
2481
2412
  await pollWaitGate(data);
2482
2413
  await pollPromotion(data, engine, token);
2483
2414
  await pollFeatureDelivery(data);
@@ -0,0 +1,80 @@
1
+ -- Feature-run display projection as a derived SQL VIEW (issue #439 — the status-driven follow-up to
2
+ -- epic #412).
3
+ --
4
+ -- 039_feature_pipeline_stage.sql denormalised the pipeline projection
5
+ -- (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) onto the `feature_runs` row,
6
+ -- projected at WRITE TIME by the `featureRuns()` gateway (app/feature.ts) from the pure `deriveStage`
7
+ -- / `deriveListBucket` (app/stage.ts). That "the gateway is the sole write path" invariant held for
8
+ -- app-layer writes but NOT for the framework `instanceTracking` reconciler, which writes
9
+ -- `feature_runs.status` through the RAW datasource (`{status:"abandoned"}` on a terminated instance,
10
+ -- see nano.app.json → instanceTracking) — bypassing the gateway, so `status` flipped terminal while
11
+ -- the display columns stayed frozen at their pre-terminal values (a cancelled run wedged in Active as
12
+ -- a live-looking `Implementing ⚠`, its Dismiss gated shut on a null `stage_state`).
13
+ --
14
+ -- The fix is the same technique #412/#411 established (projection → VIEW, enabled by nano-ide#424):
15
+ -- express the derived columns as a VIEW over `status` (+ `pr_key`/`converge`/`auto_merge`/
16
+ -- `acknowledged_at`), removing the write-time projection entirely. There is then no stored column and
17
+ -- no write-path for ANY writer (the reconciler or a future one) to leave stale — the projection is a
18
+ -- pure function of the row's own base columns, recomputed on every read. `deriveStage` /
19
+ -- `deriveListBucket` remain the canonical TS implementation (used by the acknowledge operations and
20
+ -- as the test oracle); this VIEW's CASE expressions MIRROR them exactly, and
21
+ -- app/featureReadModel.test.ts pins the two in lockstep over the full status matrix.
22
+ --
23
+ -- A single plain `CREATE VIEW <name> AS SELECT … FROM …` (no CTE / no select-list subquery), so its
24
+ -- output columns stay parseable by the static pages↔schema contract guard (scripts/pages-contract.
25
+ -- test.ts) — every projected column is aliased and the derived ones are wrapped so nothing but the
26
+ -- real table reference reads as the top-level FROM. It projects the `feature_runs` columns the Feature
27
+ -- page references PLUS the five derived columns (same shape as 061's plan_read_model): the base
28
+ -- `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket` columns are deliberately NOT
29
+ -- selected — the derived CASE expressions take those names — so a stale stored value can never surface.
30
+ --
31
+ -- Forward-only, additive (a new read model, no schema change to feature_runs). The runner wraps each
32
+ -- file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after the current
33
+ -- highest prefix (072 — the #412 wave-1 contract cleanup landed a disjoint 070-079 block).
34
+
35
+ CREATE VIEW feature_read_model AS
36
+ SELECT
37
+ fr.feature_key AS feature_key,
38
+ fr.repo AS repo,
39
+ fr.issue_number AS issue_number,
40
+ fr.issue_url AS issue_url,
41
+ fr.title AS title,
42
+ fr.base_branch AS base_branch,
43
+ fr.status AS status,
44
+ fr.process_key AS process_key,
45
+ fr.pr_key AS pr_key,
46
+ fr.converge AS converge,
47
+ fr.auto_merge AS auto_merge,
48
+ fr.outcome AS outcome,
49
+ fr.delivery_label AS delivery_label,
50
+ fr.acknowledged_at AS acknowledged_at,
51
+ fr.created_at AS created_at,
52
+ fr.updated_at AS updated_at,
53
+ (CASE
54
+ WHEN fr.status IN ('merged', 'converged', 'blocked', 'failed', 'skipped', 'abandoned') THEN 'Done'
55
+ WHEN fr.status = 'converging' THEN 'Converging'
56
+ WHEN (fr.pr_key IS NOT NULL AND fr.pr_key <> '') OR fr.status = 'opened' THEN 'PR open'
57
+ WHEN fr.status IN ('running', 'escalated', 'awaiting_operator') THEN 'Implementing'
58
+ ELSE 'Requested'
59
+ END) AS stage,
60
+ (CASE
61
+ WHEN fr.status IN ('merged', 'converged') THEN 'ok'
62
+ WHEN fr.status = 'blocked' THEN 'blocked'
63
+ WHEN fr.status IN ('failed', 'skipped', 'abandoned') THEN 'failed'
64
+ ELSE NULL
65
+ END) AS stage_state,
66
+ (CASE
67
+ WHEN NOT (fr.converge IS NOT NULL AND fr.converge <> 0) THEN 'Converging Merging'
68
+ WHEN NOT (fr.auto_merge IS NOT NULL AND fr.auto_merge <> 0) THEN 'Merging'
69
+ ELSE ''
70
+ END) AS stage_skipped,
71
+ (CASE
72
+ WHEN fr.status = 'awaiting_operator' THEN 'blocked'
73
+ WHEN fr.status = 'escalated' THEN '⚠'
74
+ ELSE NULL
75
+ END) AS attention,
76
+ (CASE
77
+ WHEN fr.status IN ('merged', 'converged', 'blocked', 'failed', 'skipped', 'abandoned') AND fr.acknowledged_at IS NOT NULL THEN 'history'
78
+ ELSE 'active'
79
+ END) AS list_bucket
80
+ FROM feature_runs fr;
@@ -0,0 +1,74 @@
1
+ -- Derive the epic Active/History bucket in the read-model VIEW instead of at write time (issue #439 —
2
+ -- the status-driven follow-up to epic #412).
3
+ --
4
+ -- 044_plan_list_bucket.sql denormalised `plans.list_bucket` / `plans.ack_open` onto the row, projected
5
+ -- at WRITE TIME by the `plans()` gateway (app/plan.ts) from the pure `deriveEpicBucket` /
6
+ -- `epicIsAcknowledgeable` (app/delivery.ts), with a read-time `pollPlanBucket` pass (app/service.ts)
7
+ -- supplying the delivery-aware correction the delivery-free gateway could not. As with feature_runs
8
+ -- (073), that write path is bypassed by the framework `instanceTracking` reconciler, which writes
9
+ -- `plans.status` through the RAW datasource (`{status:"abandoned"}` on a terminated instance) —
10
+ -- leaving `list_bucket`/`ack_open` frozen at their pre-terminal values, so a cancelled epic drifts the
11
+ -- same way a cancelled feature run does.
12
+ --
13
+ -- 061_plan_delivery_rollup.sql already made `plan_read_model` the composite VIEW the operator pages
14
+ -- bind, but it still READ `pl.list_bucket` / `pl.ack_open` straight off the denormalised columns. This
15
+ -- migration redefines `plan_read_model` (same name, so no page repoint is needed) to DERIVE both from
16
+ -- the base inputs — `status`, `acknowledged_at`, and the already-derived `plan_delivery.delivery`
17
+ -- signal — removing the last read of the stored columns. The bucket is now a pure function with no
18
+ -- write-path: there is nothing for the reconciler (or `pollPlanBucket`, which this retires) to leave
19
+ -- stale. The CASE expressions MIRROR `deriveEpicBucket` / `epicIsAcknowledgeable` exactly, cross-checked
20
+ -- against those pure functions in app/plansReadModel.test.ts.
21
+ --
22
+ -- Deriving from the REAL delivery signal is strictly MORE correct than the old write-time projection,
23
+ -- which had to assume `delivery = null` (it could not see the read-time signal) and relied on
24
+ -- `pollPlanBucket` to clear `ack_open` while a `done` epic was still converging. The view sees the live
25
+ -- `plan_delivery.delivery`, so a still-converging epic never offers Dismiss (`ack_open = 0`) without a
26
+ -- poller pass.
27
+ --
28
+ -- A merged view is not editable in place (that would edit a shipped migration), so this DROPs and
29
+ -- re-CREATEs it. `plan_read_model` is a leaf — no other view builds on it — so the DROP is safe. Its
30
+ -- output column set is UNCHANGED (list_bucket/ack_open are still projected, only their derivation
31
+ -- changed), so the pages↔schema contract guard and every page binding stay valid.
32
+ --
33
+ -- Forward-only. NO BEGIN/COMMIT — the runner wraps each file in its own transaction. Numbered after
34
+ -- 073.
35
+
36
+ DROP VIEW plan_read_model;
37
+
38
+ CREATE VIEW plan_read_model AS
39
+ SELECT
40
+ pl.plan_key AS plan_key,
41
+ pl.repo AS repo,
42
+ pl.issue_number AS issue_number,
43
+ pl.issue_url AS issue_url,
44
+ pl.title AS title,
45
+ pl.status AS status,
46
+ pl.task_count AS task_count,
47
+ pl.process_key AS process_key,
48
+ pl.outcome AS outcome,
49
+ pl.updated_at AS updated_at,
50
+ pl.epic_phase AS epic_phase,
51
+ pl.base_branch AS base_branch,
52
+ pl.wait_gate_label AS wait_gate_label,
53
+ pl.bound_artifacts AS bound_artifacts,
54
+ pl.promotion_pr AS promotion_pr,
55
+ pl.promotion_state AS promotion_state,
56
+ (CASE
57
+ WHEN pl.status IN ('planning', 'dispatched') THEN 'active'
58
+ WHEN pl.status = 'done' AND d.delivery = 'converging' THEN 'active'
59
+ WHEN pl.status = 'done' AND pl.acknowledged_at IS NULL THEN 'active'
60
+ WHEN pl.status = 'done' THEN 'history'
61
+ ELSE 'history'
62
+ END) AS list_bucket,
63
+ (CASE
64
+ WHEN pl.status = 'done' AND d.delivery IS NOT 'converging' AND pl.acknowledged_at IS NULL THEN 1
65
+ ELSE 0
66
+ END) AS ack_open,
67
+ wl.wave_count AS wave_count,
68
+ wl.current_wave AS current_wave,
69
+ wl.wave_label AS wave_label,
70
+ d.delivery AS delivery,
71
+ d.delivery_label AS delivery_label
72
+ FROM plans pl
73
+ LEFT JOIN plan_wave_label wl ON wl.plan_key = pl.plan_key
74
+ LEFT JOIN plan_delivery d ON d.plan_key = pl.plan_key;
package/main.ts CHANGED
@@ -109,10 +109,11 @@ async function pollLoop(): Promise<void> {
109
109
  }
110
110
  if (!shuttingDown) pollTimer = setTimeout(() => void pollLoop(), POLL_MS);
111
111
  }
112
- // Run the first pass immediately at boot (not after POLL_MS) so the one-shot feature-stage backfill
113
- // runs before the UI is relied upon the Feature Runs grid/tabs filter on the stored `list_bucket`
114
- // projection, which is NULL on legacy rows until `backfillFeatureStages()` runs inside `pollOnce()`.
115
- // Deferring the first pass would leave those rows missing from Active/History for up to POLL_MS.
112
+ // Run the first pass immediately at boot (not after POLL_MS) so the read-model pollers (delivery,
113
+ // wait-gate, promotion, lineage) reconcile before the UI is relied upon rather than after up to
114
+ // POLL_MS. The Feature Runs grid/tabs now filter the `feature_read_model` VIEW's derived `stage`/
115
+ // `list_bucket` (issue #439), computed from each row's own `status`, so no boot-time backfill of a
116
+ // stored projection is required.
116
117
  if (app.data) void pollLoop();
117
118
 
118
119
  async function drainAndExit(): Promise<void> {