@nanobpm/nano-workforce 0.133.0 → 0.134.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,15 @@
1
+ ## [0.134.0](https://github.com/nanobpm/nano-workforce/compare/v0.133.1...v0.134.0) (2026-08-24)
2
+
3
+ ### Features
4
+
5
+ * **delivery-graph:** node timeout PT1H default, submission + per-node override ([#505](https://github.com/nanobpm/nano-workforce/issues/505)) ([#507](https://github.com/nanobpm/nano-workforce/issues/507)) ([96fe961](https://github.com/nanobpm/nano-workforce/commit/96fe96165bb898919f7000002e68cb4ba5a925e9))
6
+
7
+ ## [0.133.1](https://github.com/nanobpm/nano-workforce/compare/v0.133.0...v0.133.1) (2026-08-24)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **read-model:** migrate remaining terminal-edge readers to derived_status ([#503](https://github.com/nanobpm/nano-workforce/issues/503)) ([#508](https://github.com/nanobpm/nano-workforce/issues/508)) ([663f113](https://github.com/nanobpm/nano-workforce/commit/663f1135a79a14436565f2a6b2320ad62bb0db6b))
12
+
1
13
  ## [0.133.0](https://github.com/nanobpm/nano-workforce/compare/v0.132.0...v0.133.0) (2026-08-24)
2
14
 
3
15
  ### Features
@@ -23,6 +23,7 @@ import {
23
23
  DELIVERY_PHASE,
24
24
  deliveryGraphRuns,
25
25
  } from "./deliveryGraphRun.ts";
26
+ import type { DeliveryRunTimeouts } from "./deliveryRunner.ts";
26
27
  import { deliveryGraphDigest, runDeliveryGraph } from "./deliveryRunner.ts";
27
28
 
28
29
  /** The outcome of a dispatch attempt — mirrors the retained run lifecycle. `ok:false` carries the
@@ -48,7 +49,7 @@ export type DispatchDeliveryGraphResult =
48
49
  export async function dispatchDeliveryGraphRun(
49
50
  app: Pick<AppApi, "data" | "engine" | "log">,
50
51
  graph: unknown,
51
- options: { runKey?: string | null; title?: string | null } = {},
52
+ options: { runKey?: string | null; title?: string | null } & DeliveryRunTimeouts = {},
52
53
  ): Promise<DispatchDeliveryGraphResult> {
53
54
  const validationErrors = validateDeliveryGraph(graph);
54
55
  if (validationErrors.length > 0) {
@@ -130,7 +131,16 @@ export async function dispatchDeliveryGraphRun(
130
131
  };
131
132
  let launched: Awaited<ReturnType<typeof runDeliveryGraph>>;
132
133
  try {
133
- launched = await runDeliveryGraph(app.engine, typedGraph, { runKey });
134
+ // Thread the operator-supplied run-level timeouts (#505) so a submission override reaches every
135
+ // node's seeded `nodeInputs` (absent → the runner's PT1H/PT30M/P1D defaults).
136
+ launched = await runDeliveryGraph(app.engine, typedGraph, {
137
+ runKey,
138
+ nodeTimeout: options.nodeTimeout,
139
+ probeTimeout: options.probeTimeout,
140
+ escalationSlaTimeout: options.escalationSlaTimeout,
141
+ probePollEvery: options.probePollEvery,
142
+ escalationAssignee: options.escalationAssignee,
143
+ });
134
144
  } catch (err) {
135
145
  await markClaimFailed();
136
146
  app.log.error("dispatch-delivery-graph launch threw", { runKey });
@@ -138,6 +138,93 @@ test("wait gateKeys default to a fresh per-run token so concurrent runs of one g
138
138
  assertEquals(gateKeyOf(seeded), "run-7:n3");
139
139
  });
140
140
 
141
+ test("the node timeout defaults to PT1H (raised from PT30M) when no option is supplied (#505)", async () => {
142
+ // #505: the hard PT30M default tripped the boundary timer on legitimately-long implementation nodes.
143
+ // With no timeout option, every agent/connector node inherits the NEW PT1H run default.
144
+ const p = await prepareOk(GRAPH);
145
+ const timeouts = Object.values(p.nodeInputs)
146
+ .filter((v) => "timeout" in v)
147
+ .map((v) => (v as { timeout: string }).timeout);
148
+ assert(timeouts.length === 2, `expected the agent + connector nodes to seed a timeout, got ${timeouts.length}`);
149
+ for (const t of timeouts) assertEquals(t, "PT1H");
150
+ });
151
+
152
+ test("a submission nodeTimeout override seeds every agent/connector node with that duration (#505)", async () => {
153
+ // AC: an operator dispatch that sets nodeTimeout: "PT2H" seeds PT2H for ALL agent/connector nodes.
154
+ const p = await prepareOk(GRAPH, { nodeTimeout: "PT2H" });
155
+ const timeouts = Object.values(p.nodeInputs)
156
+ .filter((v) => "timeout" in v)
157
+ .map((v) => (v as { timeout: string }).timeout);
158
+ assert(timeouts.length === 2, `expected two seeded node timeouts, got ${timeouts.length}`);
159
+ for (const t of timeouts) assertEquals(t, "PT2H");
160
+ });
161
+
162
+ test("a per-node timeout override wins for its node while siblings keep the run/default value (#505)", async () => {
163
+ // AC: a node declaring timeout: "PT4H" seeds nodeInputs.<el>.timeout == "PT4H" while its siblings keep
164
+ // the run-level (here PT2H) value. Asserted positionally on the compiled nodeInputs map.
165
+ const graph: DeliveryGraph = {
166
+ name: "per-node override",
167
+ nodes: [
168
+ { id: "heavy", kind: "agent", agent: { jobType: "senior:feature", prompt: "long build", timeout: "PT4H" } },
169
+ { id: "quick", kind: "agent", agent: { jobType: "senior:demo" } },
170
+ { id: "notify", kind: "connector", connector: { target: "slack:post", dedupeKey: "n-1", timeout: "PT10M" } },
171
+ ],
172
+ edges: [
173
+ { from: "heavy", to: "quick" },
174
+ { from: "quick", to: "notify" },
175
+ ],
176
+ };
177
+ const p = await prepareOk(graph, { nodeTimeout: "PT2H" });
178
+ const byJobType = (jt: string) =>
179
+ Object.values(p.nodeInputs).find((v) => (v as { jobType?: string }).jobType === jt) as { timeout: string } | undefined;
180
+ const connector = Object.values(p.nodeInputs).find((v) => (v as { target?: string }).target === "slack:post") as
181
+ | { timeout: string }
182
+ | undefined;
183
+
184
+ assertEquals(byJobType("senior:feature")?.timeout, "PT4H"); // per-node override wins
185
+ assertEquals(byJobType("senior:demo")?.timeout, "PT2H"); // sibling keeps the run-level value
186
+ assertEquals(connector?.timeout, "PT10M"); // connector per-node override wins too
187
+ });
188
+
189
+ test("a per-node timeout is normalized (lower-case → canonical) and a malformed one falls back to the run value (#505)", async () => {
190
+ // A graph built programmatically (bypassing the OpenAPI pattern) can carry a lower-case or malformed
191
+ // per-node duration. The runner normalizes it through `isoDuration` so a bad value never bakes an
192
+ // uninterpretable boundary timer: `pt4h` → `PT4H`, and `nonsense` falls back to the run-level default.
193
+ const graph: DeliveryGraph = {
194
+ name: "per-node normalization",
195
+ nodes: [
196
+ { id: "lower", kind: "agent", agent: { jobType: "senior:feature", timeout: "pt4h" } },
197
+ { id: "bad", kind: "connector", connector: { target: "slack:post", dedupeKey: "n-1", timeout: "nonsense" } },
198
+ ],
199
+ edges: [{ from: "lower", to: "bad" }],
200
+ } as unknown as DeliveryGraph;
201
+ const p = await prepareOk(graph, { nodeTimeout: "PT2H" });
202
+ const agent = Object.values(p.nodeInputs).find((v) => (v as { jobType?: string }).jobType === "senior:feature") as
203
+ | { timeout: string }
204
+ | undefined;
205
+ const connector = Object.values(p.nodeInputs).find((v) => (v as { target?: string }).target === "slack:post") as
206
+ | { timeout: string }
207
+ | undefined;
208
+
209
+ assertEquals(agent?.timeout, "PT4H"); // lower-case normalized to canonical form
210
+ assertEquals(connector?.timeout, "PT2H"); // malformed value rejected → run-level default
211
+ });
212
+
213
+ test("a RUN-LEVEL timeout is normalized (lower-case → canonical) and a malformed one falls back to the default (#505)", async () => {
214
+ // A programmatic caller of prepareDeliveryGraph/runDeliveryGraph bypasses the OpenAPI/door validators,
215
+ // so a lower-case or malformed run-level `nodeTimeout` must not become the fallback baked into a node's
216
+ // boundary timer FEEL. isoDuration canonicalizes it (`pt3h` → `PT3H`) at the run level too, and a
217
+ // malformed value falls back to the DEFAULTS run value rather than an uninterpretable duration.
218
+ const lower = await prepareOk(GRAPH, { nodeTimeout: "pt3h" });
219
+ for (const v of Object.values(lower.nodeInputs).filter((v) => "timeout" in v)) {
220
+ assertEquals((v as { timeout: string }).timeout, "PT3H"); // lower-case run value normalized
221
+ }
222
+ const bad = await prepareOk(GRAPH, { nodeTimeout: "nonsense" });
223
+ for (const v of Object.values(bad.nodeInputs).filter((v) => "timeout" in v)) {
224
+ assertEquals((v as { timeout: string }).timeout, "PT1H"); // malformed run value → PT1H default, never baked raw
225
+ }
226
+ });
227
+
141
228
  test("a malformed graph returns the S1 compile errors and prepares nothing", async () => {
142
229
  const r = await prepareDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] } as unknown as DeliveryGraph);
143
230
  assert(!r.ok, "a dangling edge fails to prepare");
@@ -18,6 +18,7 @@ import type { EngineClient } from "@nanobpm/urban";
18
18
  import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
19
19
  import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
20
20
  import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery } from "./readiness.ts";
21
+ import { isoDuration } from "./reviewWait.ts";
21
22
 
22
23
  /** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
23
24
  * content-addressed deploy id (`delivery-graph-<digest>`) AND the dispatch fence's default idempotency
@@ -53,7 +54,7 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
53
54
  }
54
55
 
55
56
  const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
56
- nodeTimeout: "PT30M",
57
+ nodeTimeout: "PT1H",
57
58
  probeTimeout: "PT30M",
58
59
  probePollEvery: msToIsoDuration(DEFAULT_EVERY_MS),
59
60
  escalationSlaTimeout: "P1D",
@@ -110,11 +111,14 @@ export async function prepareDeliveryGraph(
110
111
  const bpmn = rewriteProcessId(compiled.bpmn, processDefinitionId);
111
112
 
112
113
  const runKey = options.runKey?.trim() || randomUUID();
114
+ // Normalize the run-level timeouts through isoDuration so a programmatic caller that bypasses the
115
+ // OpenAPI/door validators cannot bake a malformed or lower-case duration into a BPMN timer FEEL —
116
+ // isoDuration canonicalizes case and falls back to the default on a malformed/blank value.
113
117
  const timeouts = {
114
- nodeTimeout: options.nodeTimeout ?? DEFAULTS.nodeTimeout,
115
- probeTimeout: options.probeTimeout ?? DEFAULTS.probeTimeout,
116
- probePollEvery: options.probePollEvery ?? DEFAULTS.probePollEvery,
117
- escalationSlaTimeout: options.escalationSlaTimeout ?? DEFAULTS.escalationSlaTimeout,
118
+ nodeTimeout: isoDuration(options.nodeTimeout, DEFAULTS.nodeTimeout),
119
+ probeTimeout: isoDuration(options.probeTimeout, DEFAULTS.probeTimeout),
120
+ probePollEvery: isoDuration(options.probePollEvery, DEFAULTS.probePollEvery),
121
+ escalationSlaTimeout: isoDuration(options.escalationSlaTimeout, DEFAULTS.escalationSlaTimeout),
118
122
  escalationAssignee: options.escalationAssignee ?? null,
119
123
  };
120
124
  const elementByNodeId = new Map(compiled.resolved.nodes.map((n) => [n.id, n.element]));
@@ -174,7 +178,7 @@ function buildNodeInput(
174
178
  ): NodeInput {
175
179
  switch (node.kind) {
176
180
  case "agent":
177
- return { jobType: node.agent.jobType, appendPrompt: node.agent.prompt ?? "", timeout: ctx.nodeTimeout };
181
+ return { jobType: node.agent.jobType, appendPrompt: node.agent.prompt ?? "", timeout: isoDuration(node.agent.timeout, ctx.nodeTimeout) };
178
182
  case "wait": {
179
183
  const probe = parseProbe(node.wait);
180
184
  return {
@@ -201,7 +205,7 @@ function buildNodeInput(
201
205
  target: node.connector.target,
202
206
  dedupeKey: node.connector.dedupeKey ?? null,
203
207
  payload: node.connector.payload ?? null,
204
- timeout: ctx.nodeTimeout,
208
+ timeout: isoDuration(node.connector.timeout, ctx.nodeTimeout),
205
209
  };
206
210
  default:
207
211
  return assertNever(node, "buildNodeInput");
@@ -10,12 +10,12 @@
10
10
  // emitted from the ONE `featureReadModel` declaration, which ALSO drives the TS via `fnFor`. This
11
11
  // suite therefore guards THREE things:
12
12
  //
13
- // 1. DRIFT GUARD — migration 076 embeds each derived column's SQL VERBATIM from
13
+ // 1. DRIFT GUARD — migration 080 embeds each derived column's SQL VERBATIM from
14
14
  // `featureReadModel.sqlSelectFor(...)`, so the checked-in VIEW cannot drift from the declaration.
15
15
  // 2. FRAMEWORK PARITY GUARD — `assertReadModelParity` proves the SQL and TS lowerings the ONE
16
16
  // declaration compiles to agree (the role the old hand-written lockstep test played, now
17
17
  // framework-owned).
18
- // 3. END-TO-END BEHAVIOUR on the REAL migration VIEW (076 applied to an in-memory DB): the full
18
+ // 3. END-TO-END BEHAVIOUR on the REAL migration VIEW (080 applied to an in-memory DB): the full
19
19
  // status × open-task matrix vs the model-derived oracle, the stale-stored-column ignore, the
20
20
  // reconciler `status`-bypass, the #422 answered-escalation drift, and the page binding.
21
21
  import { readFileSync } from "node:fs";
@@ -31,11 +31,14 @@ import { deriveListBucket, deriveStage } from "./stage.ts";
31
31
  const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
32
32
  const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
33
33
 
34
- const MIGRATION_076 = "076_feature_read_model_declare_once.sql";
34
+ const MIGRATION_LATEST = "081_feature_read_model_derive_terminal.sql";
35
35
 
36
36
  // The base `feature_runs` shape the VIEW reads, plus the `user_tasks` inbox (034) the `attention`
37
- // derivation `EXISTS`-reads. The stored derived columns are present precisely so the tests can seed
38
- // STALE values and prove the VIEW ignores them.
37
+ // derivation `EXISTS`-reads, plus a stand-in for the managed `feature_runs__tracking` derived VIEW
38
+ // (ADR-0065) the read model now reads its terminal-folded `derived_status` off. The stored derived
39
+ // columns are present precisely so the tests can seed STALE values and prove the VIEW ignores them;
40
+ // `derived_status_override` lets a test model the reconciler's derive edge (a terminated instance ⇒
41
+ // `abandoned` while base `status` stays frozen).
39
42
  function viewDb(): DatabaseSync {
40
43
  const db = new DatabaseSync(":memory:");
41
44
  db.exec(
@@ -43,14 +46,23 @@ function viewDb(): DatabaseSync {
43
46
  feature_key TEXT PRIMARY KEY, repo TEXT, issue_number INTEGER, issue_url TEXT, title TEXT,
44
47
  base_branch TEXT, status TEXT, process_key TEXT, pr_key TEXT, converge INTEGER, auto_merge INTEGER,
45
48
  outcome TEXT, delivery_label TEXT, acknowledged_at TEXT, created_at TEXT, updated_at TEXT,
46
- stage TEXT, stage_state TEXT, stage_skipped TEXT, attention TEXT, list_bucket TEXT);`,
49
+ stage TEXT, stage_state TEXT, stage_skipped TEXT, attention TEXT, list_bucket TEXT,
50
+ derived_status_override TEXT);`,
47
51
  );
48
52
  db.exec(
49
53
  `CREATE TABLE user_tasks (
50
54
  user_task_key TEXT PRIMARY KEY, element_id TEXT NOT NULL, subject_type TEXT NOT NULL,
51
55
  subject_key TEXT NOT NULL);`,
52
56
  );
53
- db.exec(MIG(MIGRATION_076));
57
+ // Stand-in for the managed `feature_runs__tracking` VIEW urban provisions at mount: re-exports
58
+ // `feature_runs.*` plus the terminal-folded `derived_status` the read model (migration 080) reads. A
59
+ // test seeds `derived_status_override` to model the reconciler's derive edge; absent, it falls through
60
+ // to the base `status`, exactly as the real VIEW's `ELSE base.status` branch does.
61
+ db.exec(
62
+ `CREATE VIEW feature_runs__tracking AS
63
+ SELECT f.*, COALESCE(f.derived_status_override, f.status) AS derived_status FROM feature_runs f;`,
64
+ );
65
+ db.exec(MIG(MIGRATION_LATEST));
54
66
  return db;
55
67
  }
56
68
 
@@ -126,22 +138,22 @@ function parityDb(db: DatabaseSync): ParityDb {
126
138
  };
127
139
  }
128
140
 
129
- test("DRIFT GUARD: migration 076 embeds each derived column VERBATIM from featureReadModel.sqlSelectFor (the VIEW cannot drift from the declaration)", () => {
130
- const sql = MIG(MIGRATION_076);
141
+ test("DRIFT GUARD: migration 080 embeds each derived column VERBATIM from featureReadModel.sqlSelectFor (the VIEW cannot drift from the declaration)", () => {
142
+ const sql = MIG(MIGRATION_LATEST);
131
143
  for (const col of FEATURE_READ_MODEL_DERIVED) {
132
144
  const emitted = featureReadModel.sqlSelectFor(col, { baseAlias: FEATURE_READ_MODEL_BASE_ALIAS });
133
145
  assert(
134
146
  sql.includes(`${emitted} AS ${col}`),
135
- `migration ${MIGRATION_076} no longer embeds the declaration's SQL for "${col}" — regenerate it ` +
147
+ `migration ${MIGRATION_LATEST} no longer embeds the declaration's SQL for "${col}" — regenerate it ` +
136
148
  `from featureReadModel (or add a new superseding migration). Expected to contain:\n ${emitted} AS ${col}`,
137
149
  );
138
150
  }
139
151
  // The VIEW is a DROP+CREATE that supersedes 073/075, and keeps every base column as an aliased
140
152
  // pass-through so the static pages↔schema contract guard still sees them.
141
- assert(/DROP VIEW IF EXISTS feature_read_model;/.test(sql), "076 must DROP the superseded VIEW first");
142
- assert(/CREATE VIEW feature_read_model AS/.test(sql), "076 must (re)create feature_read_model");
153
+ assert(/DROP VIEW IF EXISTS feature_read_model;/.test(sql), "080 must DROP the superseded VIEW first");
154
+ assert(/CREATE VIEW feature_read_model AS/.test(sql), "080 must (re)create feature_read_model");
143
155
  for (const base of ["feature_key", "status", "pr_key", "converge", "auto_merge", "acknowledged_at", "title", "repo"]) {
144
- assert(sql.includes(`fr.${base} AS ${base}`), `076 must pass base column "${base}" through the VIEW`);
156
+ assert(sql.includes(`fr.${base} AS ${base}`), `080 must pass base column "${base}" through the VIEW`);
145
157
  }
146
158
  });
147
159
 
@@ -157,7 +169,11 @@ test("FRAMEWORK PARITY GUARD: featureReadModel's SQL and TS lowerings agree over
157
169
  const userTasks =
158
170
  openTask && el !== null ? [{ subject_type: "feature", subject_key: "self", element_id: el }] : [];
159
171
  samples.push({
160
- baseRow: { feature_key: "self", status, pr_key, converge, auto_merge, acknowledged_at },
172
+ // The status-classifying derivations read the tracking VIEW's terminal-folded
173
+ // `derived_status`; for a live (non-terminated) run it equals the base transient, so
174
+ // parity samples set it from `status`. (The parity guard's fixture table is named for the
175
+ // model's baseTable, `feature_runs__tracking`.)
176
+ baseRow: { feature_key: "self", status, derived_status: status, pr_key, converge, auto_merge, acknowledged_at },
161
177
  projections: { user_tasks: userTasks },
162
178
  });
163
179
  }
@@ -172,7 +188,7 @@ test("FRAMEWORK PARITY GUARD: featureReadModel's SQL and TS lowerings agree over
172
188
  db.close();
173
189
  });
174
190
 
175
- test("the migration 076 VIEW derives stage/stage_state/stage_skipped/attention EXACTLY like deriveStage, over every status × converge/auto_merge/pr_key × open-task combination", () => {
191
+ test("the migration 080 VIEW derives stage/stage_state/stage_skipped/attention EXACTLY like deriveStage, over every status × converge/auto_merge/pr_key × open-task combination", () => {
176
192
  const db = viewDb();
177
193
  const cases: Array<{ key: string; run: SampleRun; hasOpenBlockedTask: boolean; hasOpenEscalationTask: boolean }> = [];
178
194
  let i = 0;
@@ -217,7 +233,7 @@ test("the migration 076 VIEW derives stage/stage_state/stage_skipped/attention E
217
233
  }
218
234
  });
219
235
 
220
- test("the migration 076 VIEW derives list_bucket EXACTLY like deriveListBucket (history iff terminal AND acknowledged)", () => {
236
+ test("the migration 080 VIEW derives list_bucket EXACTLY like deriveListBucket (history iff terminal AND acknowledged)", () => {
221
237
  const db = viewDb();
222
238
  let i = 0;
223
239
  const cases: Array<{ key: string; status: string; ackAt: string | null }> = [];
@@ -237,7 +253,7 @@ test("the migration 076 VIEW derives list_bucket EXACTLY like deriveListBucket (
237
253
  }
238
254
  });
239
255
 
240
- test("the migration 076 VIEW IGNORES any stale STORED projection columns — it reads only from status et al.", () => {
256
+ test("the migration 080 VIEW IGNORES any stale STORED projection columns — it reads only from status et al.", () => {
241
257
  const db = viewDb();
242
258
  // A merged run whose STORED columns lie (frozen from when it was `running`). The VIEW must re-derive.
243
259
  addRun(db, "o/r#stale", {
@@ -312,6 +328,35 @@ test("RED/GREEN GUARD: a RAW-datasource feature_runs.status write (the instanceT
312
328
  assertEquals(row.list_bucket, deriveListBucket("abandoned", null));
313
329
  });
314
330
 
331
+ test("RED/GREEN #503: a DERIVE-ONLY terminated run (base status frozen at 'running', derived_status='abandoned') renders Done/failed, not wedged 'Implementing'", () => {
332
+ // ADR-0065 (urban 0.81.0): cancel/terminate is DERIVE-ONLY — the reconciler feeds urban's projection
333
+ // and `feature_runs__tracking.derived_status` recomputes `abandoned` on READ; it does NOT write the
334
+ // terminal onto the base `feature_runs.status` column. So the base row stays frozen at its last
335
+ // transient (`running`) while the run is really terminated. Before 080 the read model classified off
336
+ // the frozen base `status` and rendered the dead run "Implementing" on the Feature history grid
337
+ // forever (the #503 phantom). 080 reads the terminal-folded `derived_status`, so it renders Done/
338
+ // failed with no worker write.
339
+ const db = viewDb();
340
+ // Seed a run whose engine instance was terminated out-of-band: base status still `running`, but the
341
+ // derive edge reports `abandoned` (modelled via the feature_runs__tracking stand-in's override).
342
+ addRun(db, "o/r#term", {
343
+ status: "running",
344
+ stored: { stage: "Implementing", stage_state: undefined, attention: "⚠", list_bucket: "active" },
345
+ });
346
+ assertEquals(projection(db, "o/r#term").stage, "Implementing", "precondition: the live transient renders Implementing");
347
+
348
+ db.prepare("UPDATE feature_runs SET derived_status_override = 'abandoned' WHERE feature_key = ?").run("o/r#term");
349
+
350
+ const row = projection(db, "o/r#term");
351
+ const oracle = deriveStage({ status: "abandoned", pr_key: null, converge: 0, auto_merge: 0 });
352
+ assertEquals(row.stage, "Done", "a derive-only terminated run is Done, not wedged at Implementing (the #503 phantom)");
353
+ assertEquals(row.stage, oracle.stage);
354
+ assertEquals(row.stage_state, "failed", "the derived terminal renders a FAILED state (was frozen NULL/Implementing)");
355
+ assertEquals(row.stage_state, oracle.state);
356
+ assertEquals(row.attention, null, "the stale ⚠ badge is gone once the run is terminated");
357
+ assertEquals(row.list_bucket, "active", "a just-cancelled run sits in Active until dismissed");
358
+ });
359
+
315
360
  test("the Feature page binds the derived feature_read_model VIEW (not the raw feature_runs table)", () => {
316
361
  // `feature.page.json`'s runs grid is the ONLY thing making the UI consume the derived projection.
317
362
  const page = PAGE("feature.page.json");
@@ -47,20 +47,38 @@ export const STAGE_DONE_STATUSES: readonly string[] = ["merged", "converged", "b
47
47
  * promote this to the framework's canonical `urban_open_user_tasks` projection.) */
48
48
  export const USER_TASKS_PROJECTION = "user_tasks";
49
49
 
50
- /** `status IN (…)` as a closed-DSL predicate: an OR of equalities over the base row's `status`. */
51
- const statusIn = (...statuses: readonly string[]): Expr => or(...statuses.map((s) => eq(col("status"), lit(s))));
50
+ /** The base table the read model reads: the auto-provisioned `feature_runs__tracking` derived VIEW
51
+ * (ADR-0065, urban 0.81.0), NOT the raw `feature_runs` table. The VIEW re-exports `feature_runs.*`
52
+ * plus a `derived_status` column that folds the `instanceTracking` reconciler's terminal edge
53
+ * (out-of-band terminate / in-app cancel → `abandoned`) over the worker-owned transient `status`. The
54
+ * status-classifying derivations below read `derived_status`, so a terminated run renders `Done`/
55
+ * `failed` instead of freezing at its last transient (`Implementing` forever — issue #503). The
56
+ * non-status base columns (`pr_key`/`converge`/`auto_merge`/`acknowledged_at`/`feature_key`) come off
57
+ * the same VIEW's pass-through of `base.*`. */
58
+ export const FEATURE_READ_MODEL_BASE_TABLE = "feature_runs__tracking";
52
59
 
53
- /** The terminal tier the row's `status` is one of the 6 `Done` statuses. */
60
+ /** The effective-status column the status-classifying derivations read: the tracking VIEW's ADR-0065
61
+ * `derived_status` (terminal-folded), NOT the frozen base `status`. Single source of truth for the
62
+ * column name so the derivations can't drift from it. */
63
+ export const EFFECTIVE_STATUS_COLUMN = "derived_status";
64
+
65
+ /** `derived_status IN (…)` as a closed-DSL predicate: an OR of equalities over the tracking VIEW's
66
+ * terminal-folded effective status. */
67
+ const statusIn = (...statuses: readonly string[]): Expr =>
68
+ or(...statuses.map((s) => eq(col(EFFECTIVE_STATUS_COLUMN), lit(s))));
69
+
70
+ /** The terminal tier — the row's effective (terminal-folded) status is one of the 6 `Done` statuses. */
54
71
  const isDone: Expr = statusIn(...STAGE_DONE_STATUSES);
55
72
 
56
73
  /** The canonical pipeline `stage`. TOTAL over all 11 statuses. Terminal → `Done`; else `converging`
57
74
  * → `Converging`; else a raised PR (`pr_key` set, mirroring `(pr_key ?? "") !== ""`) or `opened` →
58
- * `PR open`; else a live/parked implementation status → `Implementing`; else `Requested`. */
75
+ * `PR open`; else a live/parked implementation status → `Implementing`; else `Requested`. Classifies
76
+ * on the terminal-folded `derived_status` so a cancelled/terminated run is `Done`, not frozen. */
59
77
  const stage: Expr = caseWhen(
60
78
  [
61
79
  when(isDone, lit("Done")),
62
- when(eq(col("status"), lit("converging")), lit("Converging")),
63
- when(or(neq(col("pr_key"), lit("")), eq(col("status"), lit("opened"))), lit("PR open")),
80
+ when(eq(col(EFFECTIVE_STATUS_COLUMN), lit("converging")), lit("Converging")),
81
+ when(or(neq(col("pr_key"), lit("")), eq(col(EFFECTIVE_STATUS_COLUMN), lit("opened"))), lit("PR open")),
64
82
  when(statusIn("running", "escalated", "awaiting_operator"), lit("Implementing")),
65
83
  ],
66
84
  lit("Requested"),
@@ -68,11 +86,12 @@ const stage: Expr = caseWhen(
68
86
 
69
87
  /** The active stage's render state in the `kind:"pipeline"` column's vocabulary: `ok` (merged/
70
88
  * converged), `blocked` (terminal blocked), `failed` (failed/skipped/abandoned), else NULL (in
71
- * progress). A pure function of `status`, so it is correct even for a parked/live status (NULL). */
89
+ * progress). A pure function of the terminal-folded `derived_status`, so a terminated run renders a
90
+ * terminal `failed` state instead of a frozen NULL. */
72
91
  const stageState: Expr = caseWhen(
73
92
  [
74
93
  when(statusIn("merged", "converged"), lit("ok")),
75
- when(eq(col("status"), lit("blocked")), lit("blocked")),
94
+ when(eq(col(EFFECTIVE_STATUS_COLUMN), lit("blocked")), lit("blocked")),
76
95
  when(statusIn("failed", "skipped", "abandoned"), lit("failed")),
77
96
  ],
78
97
  lit(null),
@@ -136,7 +155,7 @@ export type FeatureReadModelDerivedColumn = (typeof FEATURE_READ_MODEL_DERIVED)[
136
155
  */
137
156
  export const featureReadModel: ReadModel = defineReadModel({
138
157
  name: "feature_read_model",
139
- baseTable: "feature_runs",
158
+ baseTable: FEATURE_READ_MODEL_BASE_TABLE,
140
159
  selectBaseColumns: false,
141
160
  derive: {
142
161
  stage,
@@ -18,6 +18,7 @@ import { resetDefaultBranchCache } from "./github.ts";
18
18
  import type { PlanDep } from "./plan.ts";
19
19
  import { EpicSetValidationError, validateEpicSet } from "./plan.ts";
20
20
  import { capabilityProbeForEdge, deriveEpicSchedule, lowerAdmittedSet } from "./planLowering.ts";
21
+ import { withTrackingViews } from "../test/trackingViews.ts";
21
22
  import {
22
23
  type GithubRelease,
23
24
  matchCapability,
@@ -80,7 +81,7 @@ function makeApp(seedPlans: Record<string, unknown>[] = []) {
80
81
  };
81
82
  };
82
83
  const app = {
83
- data: { table },
84
+ data: { table: withTrackingViews(table) },
84
85
  engine: {
85
86
  createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
86
87
  started.push(req);
@@ -210,7 +211,7 @@ function makeData() {
210
211
  return Promise.resolve({ processInstanceKey: `PI-${started.length}` });
211
212
  },
212
213
  } as unknown as EngineClient;
213
- const data = { table } as unknown as DataLayer;
214
+ const data = { table: withTrackingViews(table) } as unknown as DataLayer;
214
215
  return { data, engine, tables, started };
215
216
  }
216
217
 
package/app/plan.test.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // is not a positive integer, so the loop is always bounded.
7
7
  import { after, test } from "node:test";
8
8
  import { assertEquals, assertRejects, assertThrows } from "#test-assert";
9
+ import { withTrackingViews } from "../test/trackingViews.ts";
9
10
  import { positiveIntEnv } from "./plan.ts";
10
11
 
11
12
  const KEY = "NANO_PLAN_REVIEW_ROUNDS_TEST";
@@ -126,8 +127,8 @@ test("re-plan of a finished issue clears stale plan_reviews rows", async () => {
126
127
  plan_task_deps: { rows: [], key: "plan_key" },
127
128
  };
128
129
  const data = {
129
- table: (name: string, key: string) =>
130
- memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
130
+ table: withTrackingViews((name: string, key: string) =>
131
+ memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
131
132
  } as any;
132
133
  const engine = {
133
134
  createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }),
@@ -146,8 +147,8 @@ test("re-plan of a finished issue clears stale plan_reviews rows", async () => {
146
147
 
147
148
  function memData(stores: Record<string, { rows: any[]; key: string }>) {
148
149
  return {
149
- table: (name: string, key: string) =>
150
- memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
150
+ table: withTrackingViews((name: string, key: string) =>
151
+ memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
151
152
  } as any;
152
153
  }
153
154
 
package/app/plan.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  fetchDefaultBranch,
22
22
  fetchIssueTitle,
23
23
  } from "./github.ts";
24
+ import { derivedTrackingTable } from "./instanceTracking.ts";
24
25
  import { clearExclusions } from "./mergeExclusion.ts";
25
26
  import type { ReadinessProbe } from "./readiness.ts";
26
27
  import { clearTaskDeltas } from "./taskDelta.ts";
@@ -197,6 +198,17 @@ export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
197
198
  * offers Dismiss without a poller pass. The pure helpers stay the acknowledge-epic guard and the VIEW's
198
199
  * test oracle (app/plansReadModel.test.ts). */
199
200
  export const plans = (data: DataLayer) => data.table<Plan>("plans", "plan_key");
201
+ /** A plan row as seen through its derived tracking VIEW (`plans__tracking`): the base columns plus
202
+ * urban's ADR-0065 `derived_status`, which folds the reconciler's terminal edge (out-of-band
203
+ * terminate / in-app cancel → `abandoned`) over the worker-owned transient. */
204
+ type TrackedPlan = Plan & { derived_status: string };
205
+ /** Read-only accessor over the plan derived tracking VIEW. Use this — and read `derived_status`, not
206
+ * `status` — for any terminal/active classification (the shared-base admission filter, the
207
+ * epic-admission idempotency gate), so an out-of-band-terminated epic (whose base row is still
208
+ * `planning`/`dispatched`) is correctly seen as `abandoned`. Worker-written terminals (`done`/
209
+ * `failed`) pass through unchanged. Writes stay on `plans`. */
210
+ export const plansTracking = (data: DataLayer) =>
211
+ derivedTrackingTable<TrackedPlan>(data, "plans", "plan_key");
200
212
  export const planTasks = (data: DataLayer) => data.table<PlanTask>("plan_tasks", "id");
201
213
 
202
214
  /** One dependency edge in the plan DAG (issue #20): `task_id` waits for `depends_on_task_id`.
@@ -590,8 +602,12 @@ export async function findActivePlansByBase(
590
602
  repo: string,
591
603
  base: string,
592
604
  ): Promise<Plan[]> {
593
- const rows = await plans(data).find({ repo, base_branch: base });
594
- return rows.filter((p) => !PLAN_TERMINAL_STATUSES.some((s) => s === p.status));
605
+ const rows = await plansTracking(data).find({ repo, base_branch: base });
606
+ // ADR-0065: classify "active" on the DERIVED terminal edge, not the base transient — an epic whose
607
+ // engine instance was terminated out-of-band (or by an ordinary in-app cancel) keeps its base
608
+ // `status` frozen at `planning`/`dispatched` but reads `abandoned` on `plans__tracking.derived_status`.
609
+ // Reading the base `status` here counted a dead epic as ACTIVE and raised a false same-repo conflict.
610
+ return rows.filter((p) => !PLAN_TERMINAL_STATUSES.some((s) => s === p.derived_status));
595
611
  }
596
612
 
597
613
  /** Options gating the confirm-default (rule 3) and shared-base (rule 4) admission rules. Both
@@ -926,7 +942,14 @@ export async function startPlan(
926
942
  }
927
943
  const table = plans(data);
928
944
  const existing = await table.get(parsed.planKey);
929
- if (existing && !PLAN_TERMINAL_STATUSES.some((s) => s === existing.status)) {
945
+ // ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. An epic
946
+ // whose engine instance was terminated out-of-band (or by an ordinary in-app cancel — derive-only
947
+ // under urban 0.81.0) has a base row frozen at `planning`/`dispatched` but a
948
+ // `plans__tracking.derived_status` of `abandoned`; reading the base `status` here wedged a cancelled
949
+ // epic `alreadyRunning` (the `submitPr`-wedge twin). Route the idempotency gate through the derived
950
+ // view so a terminated epic is correctly seen terminal and RE-ADMITTABLE.
951
+ const trackedExisting = existing ? await plansTracking(data).get(parsed.planKey) : undefined;
952
+ if (trackedExisting && !PLAN_TERMINAL_STATUSES.some((s) => s === trackedExisting.derived_status)) {
930
953
  return { planKey: parsed.planKey, alreadyRunning: true };
931
954
  }
932
955
  const base = normalizeBaseBranch(baseBranch);
@@ -8,7 +8,7 @@ import { assert, assertEquals } from "#test-assert";
8
8
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
9
9
  import type { PlanDep } from "./plan.ts";
10
10
  import { capabilityProbeForEdge, deriveEpicSchedule, lowerAdmittedSet } from "./planLowering.ts";
11
-
11
+ import { withTrackingViews } from "../test/trackingViews.ts";
12
12
  const edge = (consumer: string, producer: string, pkg = "@scope/pkg", capRef = producer): PlanDep => ({
13
13
  plan_key: consumer,
14
14
  depends_on_plan_key: producer,
@@ -54,7 +54,7 @@ function makeData() {
54
54
  return Promise.resolve({ processInstanceKey: `PI-${started.length}` });
55
55
  },
56
56
  } as unknown as EngineClient;
57
- const data = { table } as unknown as DataLayer;
57
+ const data = { table: withTrackingViews(table) } as unknown as DataLayer;
58
58
  return { data, engine, tables, started };
59
59
  }
60
60
 
@@ -34,19 +34,32 @@ 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, acknowledged_at TEXT, list_bucket TEXT, ack_open INTEGER);
37
+ promotion_pr TEXT, promotion_state TEXT, acknowledged_at TEXT, list_bucket TEXT, ack_open INTEGER,
38
+ derived_status_override TEXT);
38
39
  CREATE TABLE plan_tasks (
39
40
  id INTEGER PRIMARY KEY, plan_key TEXT, task_index INTEGER, task_id TEXT, title TEXT,
40
41
  prompt TEXT, status TEXT, pr_key TEXT, summary TEXT, created_at TEXT, updated_at TEXT,
41
42
  wave INTEGER, open_question TEXT, answer TEXT, draft_pr_key TEXT, corr_key TEXT);
42
43
  CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, url TEXT, status TEXT, process_key TEXT);`,
43
44
  );
45
+ // Stand-in for the managed `plans__tracking` VIEW urban provisions at mount (ADR-0065): re-exports
46
+ // `plans.*` plus the `derived_status` the terminal-edge reader (migration 079) reads. A test seeds
47
+ // `derived_status_override` to model the reconciler's derive edge (a terminated instance ⇒
48
+ // `abandoned` while base `status` stays frozen); absent, it falls through to the base `status`, exactly
49
+ // as the real VIEW's `ELSE base.status` branch does.
50
+ db.exec(
51
+ `CREATE VIEW plans__tracking AS
52
+ SELECT p.*, COALESCE(p.derived_status_override, p.status) AS derived_status FROM plans p;`,
53
+ );
44
54
  db.exec(MIG("059_plan_wave_summary.sql"));
45
55
  db.exec(MIG("060_plan_wave_rollup.sql"));
46
56
  db.exec(MIG("061_plan_delivery_rollup.sql"));
47
57
  // 074 redefines plan_read_model to DERIVE list_bucket/ack_open from status + acknowledged_at + the
48
58
  // derived plan_delivery signal (issue #439), instead of reading the denormalised base columns.
49
59
  db.exec(MIG("074_plan_read_model_derive_bucket.sql"));
60
+ // 079 re-points plan_read_model's status/bucket derivations at the derived plans__tracking VIEW so a
61
+ // terminated (derive-only `abandoned`) epic drops out of Active (issue #503).
62
+ db.exec(MIG("080_plan_read_model_derive_terminal.sql"));
50
63
  return db;
51
64
  }
52
65
 
@@ -367,3 +380,27 @@ test("RED/GREEN GUARD: a RAW-datasource plans.status write (the instanceTracking
367
380
  assertEquals(b.ack_open, epicIsAcknowledgeable("abandoned", b.delivery) ? 1 : 0);
368
381
  assertEquals(b.ack_open, 0, "no phantom Dismiss on a reconciler-cancelled epic");
369
382
  });
383
+
384
+ test("RED/GREEN #503: a DERIVE-ONLY terminated epic (base status frozen, derived_status='abandoned') drops out of Active", () => {
385
+ // ADR-0065 (urban 0.81.0): cancel/terminate is DERIVE-ONLY — the reconciler feeds urban's projection
386
+ // and `plans__tracking.derived_status` recomputes `abandoned` on READ; it does NOT write the terminal
387
+ // onto the base `plans.status` column. So the base row stays frozen at its last transient
388
+ // (`dispatched`) while the epic is really terminated. Before 079 `plan_read_model` bucketed off the
389
+ // frozen base column and rendered the dead epic ACTIVE on the epic index/detail forever (the #503 /
390
+ // #497 phantom). 079 reads the effective status off `plans__tracking`, so it drops to History.
391
+ const db = viewDb();
392
+ // Seed a plan whose engine instance was terminated out-of-band: base status still `dispatched`, but
393
+ // the derive edge reports `abandoned` (modelled via the plans__tracking stand-in's override column).
394
+ db.prepare(
395
+ "INSERT INTO plans (plan_key, repo, issue_number, issue_url, status, task_count, updated_at, acknowledged_at, list_bucket, derived_status_override) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
396
+ ).run("o/r#term", "o/r", 7, "https://gh/o/r#term", "dispatched", 0, "2026-01-01T00:00:00Z", null, "active", "abandoned");
397
+
398
+ const r = db
399
+ .prepare("SELECT status, list_bucket, ack_open FROM plan_read_model WHERE plan_key = ?")
400
+ .get("o/r#term") as { status: string; list_bucket: string; ack_open: number };
401
+
402
+ assertEquals(r.status, "abandoned", "plan_read_model surfaces the DERIVED terminal, not the frozen base transient");
403
+ assertEquals(r.list_bucket, "history", "a derive-only terminated epic is filed under History, not wedged Active");
404
+ assertEquals(r.list_bucket, deriveEpicBucket("abandoned", null, null));
405
+ assertEquals(r.ack_open, 0, "no phantom Dismiss on a derive-only terminated epic");
406
+ });