@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/app/reviewWait.ts CHANGED
@@ -20,6 +20,14 @@ export const DEFAULT_REVIEW_WAIT_TIMEOUT = "PT20M";
20
20
  // would fail to interpret; not a full grammar (we don't need fractional seconds here).
21
21
  const ISO_DURATION = /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;
22
22
 
23
+ /** True when `raw` is a well-formed ISO-8601 duration under {@link isoDuration}'s grammar — the strict
24
+ * predicate an operator submission door uses to REJECT a malformed duration (400) rather than silently
25
+ * fall back to a default. Derives from the same {@link ISO_DURATION} grammar so the accept/reject
26
+ * decision can never drift from the normalise-or-default one. Case-insensitive (`pt2h` is valid). */
27
+ export function isValidIsoDuration(raw: string): boolean {
28
+ return ISO_DURATION.test(raw.trim().toUpperCase());
29
+ }
30
+
23
31
  /** Validate an ISO-8601 duration string for a BPMN timer's `<bpmn:timeDuration>`, falling back to
24
32
  * `def` when the value is absent, blank, or malformed — a bad env value must never deploy an
25
33
  * uninterpretable timer expression into a process. Normalises to upper case (`pt20m` → `PT20M`).
package/app/service.ts CHANGED
@@ -511,7 +511,14 @@ export async function submitPr(
511
511
  ) {
512
512
  const table = prs(data);
513
513
  const existing = await table.get(parsed.prKey);
514
- if (existing && !TERMINAL_STATUSES.includes(existing.status)) {
514
+ // ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. A
515
+ // PR whose engine instance was terminated out-of-band (or by an ordinary in-app cancel — derive-only
516
+ // under urban 0.81.0) has a base row frozen at its last worker transient (e.g. `converging`) but a
517
+ // `pull_requests__tracking.derived_status` of `abandoned`; reading the base `status` here would wedge
518
+ // it `alreadyRunning` forever (the #497 phantom). Route the idempotency gate through the derived view
519
+ // so a cancelled PR is correctly seen terminal and RESUBMITTABLE.
520
+ const trackedExisting = existing ? await prsTracking(data).get(parsed.prKey) : undefined;
521
+ if (trackedExisting && !TERMINAL_STATUSES.includes(trackedExisting.derived_status)) {
515
522
  return { prKey: parsed.prKey, alreadyRunning: true };
516
523
  }
517
524
 
@@ -747,9 +754,17 @@ export interface ActivePr {
747
754
  * BOTH loops' escalations uniformly. Once answered the row leaves `open`, so `openEscalation`
748
755
  * derives back to null. */
749
756
  export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
750
- const all = await prs(data).all();
757
+ const all = await prsTracking(data).all();
751
758
  const active = all
752
- .filter((p) => !TERMINAL_STATUSES.includes(p.status))
759
+ // ADR-0065: classify "in flight" on the DERIVED terminal edge, not the base transient. A PR whose
760
+ // engine instance was terminated out-of-band (or by an ordinary in-app cancel — derive-only under
761
+ // urban 0.81.0) keeps its base `status` frozen at the last worker transient but reads `abandoned`
762
+ // on `pull_requests__tracking.derived_status`; filtering on the base `status` here left the
763
+ // Convergence tab showing a cancelled PR active indefinitely (the #497 phantom). The base
764
+ // `status`/worker-owned transient columns are still surfaced on each row below (the view re-exports
765
+ // `base.*`), and `pull_requests` has no `onWaitingHuman` edge, so for a still-active PR
766
+ // `derived_status === status`.
767
+ .filter((p) => !TERMINAL_STATUSES.includes(p.derived_status))
753
768
  .sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0));
754
769
  // Only an `escalated` PR is parked awaiting a human answer (either loop). Surface the question
755
770
  // from its latest still-open `escalations` row; a resubmit retires stale rows and finalize/merge
@@ -972,9 +987,9 @@ async function classifyWaveTarget(
972
987
  prKey: string,
973
988
  token: string,
974
989
  ): Promise<"cleared" | "closed" | "pending"> {
975
- const tracked = await prs(data).get(prKey);
976
- if (tracked && tracked.status === "merged") return "cleared";
977
- if (tracked && tracked.status === ABANDONED_STATUS) return "cleared"; // already reconciled terminal → non-blocking, no re-reconcile needed
990
+ const tracked = await prsTracking(data).get(prKey);
991
+ if (tracked && tracked.status === "merged") return "cleared"; // worker-owned terminal, passes through the derive edge unchanged
992
+ if (tracked && tracked.derived_status === ABANDONED_STATUS) return "cleared"; // ADR-0065 derive-only terminal → non-blocking, no re-reconcile needed
978
993
  const parsed = parsePr(prKey);
979
994
  if (!parsed) return "cleared"; // unparseable ref can't be checked → never wedge the barrier
980
995
  let st: Awaited<ReturnType<typeof fetchPrState>>;
@@ -1016,7 +1031,7 @@ async function flipToMergingThenPublish(
1016
1031
  }
1017
1032
  }
1018
1033
 
1019
- async function mergeLaneDecisionForPr(data: DataLayer, prKey: string): Promise<PrLaneDecision | null> {
1034
+ export async function mergeLaneDecisionForPr(data: DataLayer, prKey: string): Promise<PrLaneDecision | null> {
1020
1035
  const taskRows = await planTasks(data).find({ pr_key: prKey });
1021
1036
  const task = taskRows[0];
1022
1037
  if (!task) return null;
@@ -1034,8 +1049,12 @@ async function mergeLaneDecisionForPr(data: DataLayer, prKey: string): Promise<P
1034
1049
  const lanePrKeys = new Set([...taskToPr.values()]);
1035
1050
  const completedPrKeys = new Set<string>();
1036
1051
  for (const lanePrKey of lanePrKeys) {
1037
- const lanePr = await prs(data).get(lanePrKey);
1038
- if (lanePr && (lanePr.status === "merged" || lanePr.status === "abandoned")) {
1052
+ const lanePr = await prsTracking(data).get(lanePrKey);
1053
+ // ADR-0065: a lane member is complete when it MERGED (worker-owned terminal, base `status`) OR was
1054
+ // cancelled/terminated (derive-only terminal → `derived_status === "abandoned"`; the base row is
1055
+ // still frozen at its transient). Reading only base `status` here missed a cancelled member and
1056
+ // stalled/misrouted the lane.
1057
+ if (lanePr && (lanePr.status === "merged" || lanePr.derived_status === "abandoned")) {
1039
1058
  completedPrKeys.add(lanePr.pr_key);
1040
1059
  }
1041
1060
  }
@@ -1400,12 +1419,15 @@ async function pollJobActivation(
1400
1419
  const headers: Record<string, string> = { "content-type": "application/json" };
1401
1420
  if (engineToken) headers.authorization = `Bearer ${engineToken}`;
1402
1421
 
1403
- const all = await prs(data).all();
1422
+ const all = await prsTracking(data).all();
1404
1423
  for (const pr of all) {
1405
1424
  // Only a `converging` PR has a live review-round job. Any other status with a stale worker
1406
1425
  // set (e.g. it just moved to `waiting_review`) gets cleared so the grid can't show a
1407
- // phantom "agent working".
1408
- if (pr.status !== "converging") {
1426
+ // phantom "agent working". ADR-0065: classify on the DERIVED status, not the base transient —
1427
+ // a PR whose instance was terminated out-of-band keeps its base `status` frozen at `converging`
1428
+ // but reads `abandoned` on `derived_status`, so a base read would leave `active_worker`/
1429
+ // `lease_until` set on a dead run (a phantom "agent working"). Writes stay on the base `prs`.
1430
+ if (pr.derived_status !== "converging") {
1409
1431
  if (pr.active_worker || pr.lease_until) {
1410
1432
  await prs(data).update(pr.pr_key, {
1411
1433
  active_worker: null,
@@ -1512,13 +1534,16 @@ export async function pollIncidentsImpl(
1512
1534
  base: string,
1513
1535
  headers: Record<string, string>,
1514
1536
  ) {
1515
- const all = await prs(data).all();
1537
+ const all = await prsTracking(data).all();
1516
1538
  for (const pr of all) {
1517
1539
  // No live instance to inspect (never created, mid-transition, or terminal — the run has
1518
1540
  // finished or was given up, so its instance is gone) → make sure no stale incident lingers on
1519
- // the row, then move on. Reuses the canonical `TERMINAL_STATUSES` so incident logic can't drift
1520
- // from the rest of the status machine.
1521
- if (!pr.process_key || TERMINAL_STATUSES.includes(pr.status)) {
1541
+ // the row, then move on. ADR-0065: classify on the DERIVED terminal edge, not the base transient
1542
+ // a PR terminated out-of-band keeps its base `status` frozen at `converging` but reads
1543
+ // `abandoned` on `derived_status`, so a base read would keep reconciling incidents against a dead
1544
+ // instance. Reuses the canonical `TERMINAL_STATUSES` so incident logic can't drift from the rest
1545
+ // of the status machine. Writes stay on the base `prs`.
1546
+ if (!pr.process_key || TERMINAL_STATUSES.includes(pr.derived_status)) {
1522
1547
  if (pr.incident_key || pr.incident_message) {
1523
1548
  await prs(data).update(pr.pr_key, {
1524
1549
  incident_key: null,
package/app/stage.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  // anywhere (not in SQL, not in the page, not in each poller/worker): every reader flows through the
15
15
  // VIEW or these adapters, both sourced from the one declaration.
16
16
 
17
- import { type FeatureReadModelDerivedColumn, featureReadModel, STAGE_DONE_STATUSES, USER_TASKS_PROJECTION } from "./featureReadModel.ts";
17
+ import { EFFECTIVE_STATUS_COLUMN, type FeatureReadModelDerivedColumn, featureReadModel, STAGE_DONE_STATUSES, USER_TASKS_PROJECTION } from "./featureReadModel.ts";
18
18
 
19
19
  // Re-exported for back-compat with existing importers (operations/acknowledgeDone.ts). Its canonical
20
20
  // home is now app/featureReadModel.ts, where it feeds the terminal tier of the derived columns.
@@ -90,7 +90,12 @@ function evalDerived<T>(column: FeatureReadModelDerivedColumn, baseRow: Record<s
90
90
  export function deriveStage(run: StageInput): DerivedStage {
91
91
  const baseRow = {
92
92
  feature_key: SELF_KEY,
93
- status: run.status,
93
+ // The status-classifying derivations read the tracking VIEW's terminal-folded `derived_status`
94
+ // (ADR-0065), so this façade feeds the caller's effective `status` under that column name — the SQL
95
+ // VIEW reads `fr."derived_status"` off `feature_runs__tracking`, and both lowerings agree by
96
+ // construction (`assertReadModelParity`). Callers off the write path pass the run's effective
97
+ // status (which equals the base transient for any non-terminated run).
98
+ [EFFECTIVE_STATUS_COLUMN]: run.status,
94
99
  pr_key: run.pr_key ?? null,
95
100
  converge: run.converge ?? null,
96
101
  auto_merge: run.auto_merge ?? null,
@@ -111,5 +116,5 @@ export function deriveStage(run: StageInput): DerivedStage {
111
116
  * `feature_runs.list_bucket` base column is vestigial (retired as a write projection, issue #439). This
112
117
  * adapter is the TS lowering of that derivation, used off the write path (redispatch gating, tests). */
113
118
  export function deriveListBucket(status: string, acknowledgedAt: string | null | undefined): "active" | "history" {
114
- return evalDerived<"active" | "history">("list_bucket", { status, acknowledged_at: acknowledgedAt ?? null });
119
+ return evalDerived<"active" | "history">("list_bucket", { [EFFECTIVE_STATUS_COLUMN]: status, acknowledged_at: acknowledgedAt ?? null });
115
120
  }
@@ -0,0 +1,289 @@
1
+ // Behaviour coverage for issue #503 — the ADR-0065 derive-only-terminal divergence at the terminal
2
+ // EDGE readers. Under `@nanobpm/urban@0.81.0` the `instanceTracking` reconciler no longer WRITES the
3
+ // terminal `abandoned`/`failed`/`reviewed` onto the base `status`; it re-derives it on read via the
4
+ // `<table>__tracking.derived_status` VIEW. A PR/plan whose engine instance was terminated out-of-band
5
+ // (or by an ordinary in-app cancel) therefore keeps its base `status` frozen at its last worker
6
+ // transient (e.g. `converging`/`dispatched`) while `derived_status` reads `abandoned`.
7
+ //
8
+ // Each test seeds that EXACT divergence (base row `status: "converging"`, `derived_status: "abandoned"`)
9
+ // via the `withTrackingViews` seam, and asserts the reader classifies on the derived edge:
10
+ // - a terminated PR is RESUBMITTABLE (not wedged `alreadyRunning`) and absent from `activePrs`,
11
+ // - a terminated instance sheds its stale incident,
12
+ // - a terminated lane member counts as COMPLETE (does not stall the merge lane),
13
+ // - a terminated epic is not counted active (no false same-base conflict) and is RE-ADMITTABLE.
14
+ // Reading only the base `status` (the pre-#503 behaviour) fails every one of these — the RED.
15
+
16
+ import { test } from "node:test";
17
+ import { assertEquals } from "#test-assert";
18
+ import { withTrackingViews } from "../test/trackingViews.ts";
19
+ import { findActivePlansByBase, startPlan } from "./plan.ts";
20
+ import { activePrs, mergeLaneDecisionForPr, pollIncidentsImpl, pollWaveGatesImpl, submitPr } from "./service.ts";
21
+
22
+ function memTable(rows: any[], key: string) {
23
+ return {
24
+ get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
25
+ all: () => Promise.resolve([...rows]),
26
+ find: (q: any) =>
27
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
28
+ findOne: (q: any) =>
29
+ Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
30
+ count: (q: any) =>
31
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)).length),
32
+ insert: (r: any) => {
33
+ rows.push(r);
34
+ return Promise.resolve(r);
35
+ },
36
+ update: (k: any, patch: any) => {
37
+ const r = rows.find((x) => x[key] === k);
38
+ if (r) Object.assign(r, patch);
39
+ return Promise.resolve(r);
40
+ },
41
+ delete: (k: any) => {
42
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
43
+ return Promise.resolve();
44
+ },
45
+ };
46
+ }
47
+
48
+ type Stores = Record<string, { rows: any[]; key: string }>;
49
+
50
+ function memData(stores: Stores) {
51
+ return {
52
+ table: withTrackingViews((name: string, key: string) =>
53
+ memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
54
+ } as any;
55
+ }
56
+
57
+ function withGithubOff(run: () => Promise<void>): Promise<void> {
58
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
59
+ const prevTok = process.env["GITHUB_TOKEN"];
60
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token"; // no token below -> fetchPrMeta returns null
61
+ delete process.env["GITHUB_TOKEN"];
62
+ return run().finally(() => {
63
+ if (prevMode !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
64
+ else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
65
+ if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
66
+ });
67
+ }
68
+
69
+ // #503 / #497: `submitPr`'s idempotency gate reads `derived_status`, so a derive-only-terminated PR
70
+ // (base frozen at `converging`, `derived_status = abandoned`) is seen terminal and RESUBMITTABLE —
71
+ // it re-opens for a fresh convergence run instead of wedging `alreadyRunning`.
72
+ test("submitPr re-opens a derive-only-terminated PR (base 'converging', derived 'abandoned') — not alreadyRunning", async () => {
73
+ await withGithubOff(async () => {
74
+ const PR_KEY = "owner/repo#42";
75
+ const stores: Stores = {
76
+ pull_requests: {
77
+ rows: [{
78
+ pr_key: PR_KEY,
79
+ repo: "owner/repo",
80
+ number: 42,
81
+ url: "https://github.com/owner/repo/pull/42",
82
+ title: "t",
83
+ status: "converging", // base transient FROZEN — reconciler no longer writes the terminal
84
+ derived_status: "abandoned", // ADR-0065 derive-only terminal
85
+ current_round: 3,
86
+ }],
87
+ key: "pr_key",
88
+ },
89
+ escalations: { rows: [], key: "id" },
90
+ pr_dependencies: { rows: [], key: "pr_key" },
91
+ };
92
+ const data = memData(stores);
93
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }) } as any;
94
+
95
+ const res = await submitPr(data, engine, {
96
+ repo: "owner/repo",
97
+ number: 42,
98
+ url: "https://github.com/owner/repo/pull/42",
99
+ prKey: PR_KEY,
100
+ });
101
+
102
+ assertEquals((res as any).alreadyRunning, undefined); // NOT wedged
103
+ assertEquals(res.processKey, "PI-9");
104
+ const pr = stores.pull_requests.rows[0];
105
+ assertEquals(pr.status, "converging");
106
+ assertEquals(pr.current_round, 1); // re-opened for a fresh run
107
+ assertEquals(pr.process_key, "PI-9");
108
+ });
109
+ });
110
+
111
+ // #503 / #497 phantom: `activePrs` filters on `derived_status`, so a derive-only-terminated PR drops
112
+ // off the Convergence tab instead of showing "active" indefinitely.
113
+ test("activePrs excludes a derive-only-terminated PR and keeps a genuinely active one", async () => {
114
+ const stores: Stores = {
115
+ pull_requests: {
116
+ rows: [
117
+ { pr_key: "o/r#1", repo: "o/r", number: 1, url: "u1", status: "converging", derived_status: "abandoned", current_round: 2, updated_at: "2024-01-02" },
118
+ { pr_key: "o/r#2", repo: "o/r", number: 2, url: "u2", status: "converging", derived_status: "converging", current_round: 1, updated_at: "2024-01-01" },
119
+ ],
120
+ key: "pr_key",
121
+ },
122
+ escalations: { rows: [], key: "id" },
123
+ };
124
+ const active = await activePrs(memData(stores));
125
+ assertEquals(active.map((p) => p.prKey), ["o/r#2"]);
126
+ });
127
+
128
+ // #503: `pollIncidentsImpl` classifies a dead instance on `derived_status`, so a derive-only-terminated
129
+ // PR sheds its stale incident (rather than re-reconciling against a gone instance), and NEVER queries
130
+ // the engine for it. A live PR still gets queried.
131
+ test("pollIncidentsImpl clears a stale incident off a derive-only-terminated PR without querying the engine", async () => {
132
+ const stores: Stores = {
133
+ pull_requests: {
134
+ rows: [{
135
+ pr_key: "o/r#7",
136
+ repo: "o/r",
137
+ number: 7,
138
+ url: "u",
139
+ status: "converging",
140
+ derived_status: "abandoned",
141
+ process_key: "PI-DEAD",
142
+ incident_key: "INC-1",
143
+ incident_message: "boom",
144
+ }],
145
+ key: "pr_key",
146
+ },
147
+ };
148
+ const prevFetch = globalThis.fetch;
149
+ let queried = false;
150
+ globalThis.fetch = (() => {
151
+ queried = true;
152
+ throw new Error("engine must not be queried for a derive-only-terminated PR");
153
+ }) as any;
154
+ try {
155
+ await pollIncidentsImpl(memData(stores), "http://engine", {});
156
+ } finally {
157
+ globalThis.fetch = prevFetch;
158
+ }
159
+ assertEquals(queried, false);
160
+ const pr = stores.pull_requests.rows[0];
161
+ assertEquals(pr.incident_key, null);
162
+ assertEquals(pr.incident_message, null);
163
+ });
164
+
165
+ // #503: the merge-lane decision counts a lane member COMPLETE on the derived terminal edge, so a
166
+ // derive-only-abandoned member (base 'converging') no longer holds its lane-mate behind a dead PR.
167
+ test("mergeLaneDecisionForPr treats a derive-only-abandoned lane member as complete (does not hold the lane)", async () => {
168
+ const PLAN_KEY = "o/r#100";
169
+ const stores: Stores = {
170
+ plan_tasks: {
171
+ rows: [
172
+ { id: 1, plan_key: PLAN_KEY, task_id: "a", pr_key: "o/r#1" },
173
+ { id: 2, plan_key: PLAN_KEY, task_id: "b", pr_key: "o/r#2" },
174
+ ],
175
+ key: "id",
176
+ },
177
+ plan_merge_exclusions: {
178
+ // a & b collide on a shared surface → one landing lane, land one-at-a-time
179
+ rows: [{ id: 1, plan_key: PLAN_KEY, task_a: "a", task_b: "b", files: JSON.stringify(["shared.ts"]), source: "file-overlap" }],
180
+ key: "id",
181
+ },
182
+ plan_task_deps: { rows: [], key: "plan_key" },
183
+ pull_requests: {
184
+ rows: [
185
+ // lane head candidate `a`: derive-only-terminated (base frozen, derived abandoned)
186
+ { pr_key: "o/r#1", repo: "o/r", number: 1, status: "converging", derived_status: "abandoned" },
187
+ // `b`: the PR we ask about — still converging
188
+ { pr_key: "o/r#2", repo: "o/r", number: 2, status: "converging", derived_status: "converging" },
189
+ ],
190
+ key: "pr_key",
191
+ },
192
+ };
193
+ const decision = await mergeLaneDecisionForPr(memData(stores), "o/r#2");
194
+ // With `a` counted complete, `b` is free to land — NOT held behind the dead member.
195
+ assertEquals(decision?.isHeld, false);
196
+ });
197
+
198
+ // #503: `classifyWaveTarget` classifies a wave member on the derived terminal edge, so a
199
+ // derive-only-abandoned member (base frozen at `converging`, still notionally open) is treated
200
+ // NON-BLOCKING (`cleared`) WITHOUT a GitHub round-trip — the wave gate advances instead of wedging on
201
+ // a dead member. Driven through `pollWaveGatesImpl`: with the sole gate member cleared and the token
202
+ // parked at `wait-wave-merged`, the barrier is released (`wave-merged` published). Reading the base
203
+ // `status` (the RED) would fall through to a GitHub liveness read (which, with no live "merged"
204
+ // signal, returns `pending`) and never publish.
205
+ test("classifyWaveTarget treats a derive-only-abandoned wave member as cleared and releases the gate (no GitHub read)", async () => {
206
+ await withGithubOff(async () => {
207
+ const PLAN_KEY = "o/r#200";
208
+ const stores: Stores = {
209
+ plans: { rows: [{ plan_key: PLAN_KEY, gate_wave: 0, process_key: "PI-1" }], key: "plan_key" },
210
+ plan_tasks: {
211
+ rows: [{ id: 1, plan_key: PLAN_KEY, task_id: "a", wave: 0, status: "opened", pr_key: "o/r#1" }],
212
+ key: "id",
213
+ },
214
+ pull_requests: {
215
+ rows: [{ pr_key: "o/r#1", repo: "o/r", number: 1, status: "converging", derived_status: "abandoned" }],
216
+ key: "pr_key",
217
+ },
218
+ };
219
+ const published: unknown[] = [];
220
+ const engine = { publishMessage: (m: unknown) => (published.push(m), Promise.resolve()) } as any;
221
+ const prevFetch = globalThis.fetch;
222
+ // Confirm the token is parked at the `wave-merged` wait so the barrier is releasable; a GitHub
223
+ // liveness read for the abandoned member would be a bug (it's classified `cleared` off the view).
224
+ globalThis.fetch = ((url: string) => {
225
+ if (String(url).endsWith("/message-subscriptions/search")) {
226
+ return Promise.resolve(
227
+ new Response(
228
+ JSON.stringify({ items: [{ messageName: "wave-merged", correlationKey: PLAN_KEY, messageSubscriptionState: "CREATED" }] }),
229
+ { status: 200, headers: { "content-type": "application/json" } },
230
+ ),
231
+ );
232
+ }
233
+ throw new Error(`no GitHub read expected for a derive-only-abandoned wave member: ${url}`);
234
+ }) as any;
235
+ try {
236
+ await pollWaveGatesImpl(memData(stores), engine, "", "http://engine", {});
237
+ } finally {
238
+ globalThis.fetch = prevFetch;
239
+ }
240
+ assertEquals(published.length, 1); // wave released — the abandoned member did not block it
241
+ });
242
+ });
243
+
244
+
245
+ // derive-only-abandoned epic (base frozen at `dispatched`) is NOT counted active and raises no false
246
+ // same-base conflict.
247
+ test("findActivePlansByBase excludes a derive-only-abandoned epic", async () => {
248
+ const stores: Stores = {
249
+ plans: {
250
+ rows: [
251
+ { plan_key: "o/r#10", repo: "o/r", base_branch: "epic/x", status: "dispatched", derived_status: "abandoned" },
252
+ { plan_key: "o/r#11", repo: "o/r", base_branch: "epic/x", status: "dispatched", derived_status: "dispatched" },
253
+ ],
254
+ key: "plan_key",
255
+ },
256
+ };
257
+ const active = await findActivePlansByBase(memData(stores), "o/r", "epic/x");
258
+ assertEquals(active.map((p) => p.plan_key), ["o/r#11"]);
259
+ });
260
+
261
+ // #503: `startPlan`'s idempotency gate reads `derived_status`, so a derive-only-abandoned epic (base
262
+ // frozen at `dispatched`) is seen terminal and RE-ADMITTABLE — it re-plans instead of wedging
263
+ // `alreadyRunning`.
264
+ test("startPlan re-admits a derive-only-abandoned epic (base 'dispatched', derived 'abandoned') — not alreadyRunning", async () => {
265
+ await withGithubOff(async () => {
266
+ const PLAN_KEY = "owner/repo#7";
267
+ const stores: Stores = {
268
+ plans: {
269
+ rows: [{ plan_key: PLAN_KEY, repo: "owner/repo", base_branch: "epic/x", status: "dispatched", derived_status: "abandoned", task_count: 1 }],
270
+ key: "plan_key",
271
+ },
272
+ plan_tasks: { rows: [{ id: 1, plan_key: PLAN_KEY }], key: "id" },
273
+ plan_reviews: { rows: [], key: "plan_key" },
274
+ plan_task_deps: { rows: [], key: "plan_key" },
275
+ };
276
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }) } as any;
277
+
278
+ const res = await startPlan(memData(stores), engine, {
279
+ repo: "owner/repo",
280
+ number: 7,
281
+ url: "https://github.com/owner/repo/issues/7",
282
+ planKey: PLAN_KEY,
283
+ }, "epic/x");
284
+
285
+ assertEquals((res as any).alreadyRunning, undefined); // NOT wedged — re-planned
286
+ // The prior epic's tasks were cleared on the re-plan path (proves it did NOT short-circuit).
287
+ assertEquals(stores.plan_tasks.rows.length, 0);
288
+ });
289
+ });
@@ -0,0 +1,106 @@
1
+ // Class guard for the ADR-0065 terminal-edge reader migration (issue #503).
2
+ //
3
+ // Since ADR-0065 (`@nanobpm/urban@0.81.0`) the `instanceTracking` reconciler is a SOURCE, not a
4
+ // writer: on cancel/terminate it feeds urban's instance projection and the terminal edge
5
+ // (`onTerminated`) is RECOMPUTED ON READ as `<table>__tracking.derived_status` — it NO LONGER writes
6
+ // the terminal (`abandoned`/`failed`/`reviewed`) onto the base `status` column. A classifying reader
7
+ // that inspects the BASE `status` column of a DERIVE-ONLY tracked table therefore sees a row frozen at
8
+ // its last worker-owned transient after the instance ends → phantom-active / wedged-idempotency bugs
9
+ // (the #497 / #503 class).
10
+ //
11
+ // This is a SOURCE-SCAN guard over the defect CLASS, not a single instance: it asserts that every
12
+ // terminal/active classification (`TERMINAL_STATUSES.includes` / `=== ABANDONED_STATUS` /
13
+ // `PLAN_TERMINAL_STATUSES` / the feature read model's status DSL) for the three derive-only tracked
14
+ // tables (`pull_requests`, `plans`, `feature_runs`) reads the DERIVED effective status
15
+ // (`.derived_status`), never the frozen base `.status`. A future reader that silently re-drifts onto
16
+ // the base column fails here.
17
+ //
18
+ // Worker-owned terminals that PASS THROUGH the derive edge unchanged (`merged`) are exempt: a base
19
+ // `=== "merged"` read is legitimate (see `isDepMerged` / `classifyWaveTarget` / `mergeLaneDecisionForPr`
20
+ // in app/service.ts). Only the DERIVE-ONLY terminals (`abandoned`/`failed`/`reviewed`) must route
21
+ // through the derived accessor. Writers (`data.table(<table>).update({ status: … })`) are unaffected —
22
+ // they still write the base column.
23
+ import { readFileSync } from "node:fs";
24
+ import { fileURLToPath } from "node:url";
25
+ import { test } from "node:test";
26
+ import { assert, assertEquals } from "#test-assert";
27
+ import { EFFECTIVE_STATUS_COLUMN } from "./featureReadModel.ts";
28
+
29
+ const SRC = (name: string): string => readFileSync(fileURLToPath(new URL(`./${name}`, import.meta.url)), "utf8");
30
+
31
+ /** Strip line (`//`) and block (`/* … *​/`) comments so the scan only inspects executable code — a
32
+ * doc comment may legitimately mention `.status` in prose without being a classification. The line
33
+ * stripper skips a `//` preceded by `:` so a URL scheme inside a string/template literal (e.g.
34
+ * `https://…` in app/service.ts) is not mistaken for a comment start and does not corrupt the scan. */
35
+ function stripComments(src: string): string {
36
+ return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
37
+ }
38
+
39
+ test("class guard: every TERMINAL_STATUSES classification in service.ts reads derived_status, not base status", () => {
40
+ const code = stripComments(SRC("service.ts"));
41
+ const calls = [...code.matchAll(/TERMINAL_STATUSES\.includes\(([^)]*)\)/g)];
42
+ assert(calls.length > 0, "expected TERMINAL_STATUSES.includes classifications in service.ts");
43
+ for (const m of calls) {
44
+ const arg = m[1];
45
+ assert(
46
+ /\.derived_status\b/.test(arg),
47
+ `TERMINAL_STATUSES.includes(${arg}) classifies on the BASE status of a derive-only tracked table — ` +
48
+ `route it through prsTracking and read \`.derived_status\` (ADR-0065, #503)`,
49
+ );
50
+ assert(
51
+ !/[A-Za-z0-9_)\]]\.status\b/.test(arg),
52
+ `TERMINAL_STATUSES.includes(${arg}) still reads a base \`.status\` — the terminal edge is derive-only (#503)`,
53
+ );
54
+ }
55
+ });
56
+
57
+ test("class guard: no base `.status === ABANDONED_STATUS` / `.status === \"abandoned\"` read classification in service.ts", () => {
58
+ const code = stripComments(SRC("service.ts"));
59
+ // A READ classification against the derive-only `abandoned` terminal must use `.derived_status`. A
60
+ // base `.status === ABANDONED_STATUS`/`"abandoned"` would miss a derive-only-terminated PR. (Writers
61
+ // use the object-literal form `{ status: ABANDONED_STATUS }`, which this pattern never matches.)
62
+ const bad = [
63
+ ...code.matchAll(/[A-Za-z0-9_)\]]\.status\s*===\s*ABANDONED_STATUS/g),
64
+ ...code.matchAll(/[A-Za-z0-9_)\]]\.status\s*===\s*["']abandoned["']/g),
65
+ ];
66
+ assertEquals(
67
+ bad.length,
68
+ 0,
69
+ `a base \`.status\` is compared to the derive-only \`abandoned\` terminal — read \`.derived_status\` off ` +
70
+ `prsTracking instead (ADR-0065, #503): ${bad.map((m) => m[0]).join(", ")}`,
71
+ );
72
+ });
73
+
74
+ test("class guard: every PLAN_TERMINAL_STATUSES classification in plan.ts reads derived_status, not base status", () => {
75
+ const code = stripComments(SRC("plan.ts"));
76
+ // Match the `.some((s) => s === <ref>)` classification form used at both admission sites.
77
+ const calls = [...code.matchAll(/PLAN_TERMINAL_STATUSES\.some\(\([^)]*\)\s*=>\s*[^)]*===\s*([A-Za-z0-9_.]+)\)/g)];
78
+ assert(calls.length > 0, "expected PLAN_TERMINAL_STATUSES classifications in plan.ts");
79
+ for (const m of calls) {
80
+ const ref = m[1];
81
+ assert(
82
+ /\.derived_status$/.test(ref),
83
+ `PLAN_TERMINAL_STATUSES classification reads \`${ref}\` — route it through plansTracking and read ` +
84
+ `\`.derived_status\` so a derive-only-terminated epic is seen terminal (ADR-0065, #503)`,
85
+ );
86
+ }
87
+ });
88
+
89
+ test("class guard: the feature read model classifies on derived_status, never the base status column", () => {
90
+ // The feature history read model (app/featureReadModel.ts) buckets a run's pipeline `stage`/
91
+ // `list_bucket` off its status. Under ADR-0065 that must be the terminal-folded `derived_status`
92
+ // (off `feature_runs__tracking`), or a terminated run renders "Implementing" forever. Assert the DSL
93
+ // never references the base `col("status")` for classification and that the effective-status column
94
+ // is the derived one.
95
+ assertEquals(EFFECTIVE_STATUS_COLUMN, "derived_status", "the feature read model's effective status must be the derived column");
96
+ const code = stripComments(SRC("featureReadModel.ts"));
97
+ assert(
98
+ !/col\(\s*["']status["']\s*\)/.test(code),
99
+ 'app/featureReadModel.ts still references col("status") — the status-classifying derivations must ' +
100
+ 'read col("derived_status") off feature_runs__tracking (ADR-0065, #503)',
101
+ );
102
+ assert(
103
+ /baseTable:\s*FEATURE_READ_MODEL_BASE_TABLE/.test(code) || /feature_runs__tracking/.test(code),
104
+ "the feature read model must be based on the feature_runs__tracking derived VIEW (ADR-0065, #503)",
105
+ );
106
+ });
@@ -0,0 +1,75 @@
1
+ -- Fold the ADR-0065 DERIVED terminal edge into the epic read model (issue #503 — the plans row of the
2
+ -- "migrate remaining terminal-edge readers to derived_status" class).
3
+ --
4
+ -- Since ADR-0065 (`@nanobpm/urban@0.81.0`) the `instanceTracking` reconciler is a SOURCE, not a writer:
5
+ -- on cancel/terminate it feeds urban's instance projection and the terminal edge (`onTerminated →
6
+ -- abandoned`) is RECOMPUTED ON READ as `plans__tracking.derived_status` — it NO LONGER writes
7
+ -- `abandoned` onto the base `plans.status` column. `plans` had NO derived reader, so a terminated epic's
8
+ -- base row stayed frozen at `planning`/`dispatched` and 074's `plan_read_model` bucketed it ACTIVE
9
+ -- forever (a terminated epic rendered active on the epic index/detail).
10
+ --
11
+ -- 074_plan_read_model_derive_bucket.sql made `plan_read_model` the composite VIEW the epic pages bind
12
+ -- and DERIVED `list_bucket`/`ack_open` from the base `plans.status` (+ `acknowledged_at` + the derived
13
+ -- `plan_delivery.delivery` signal). This migration redefines `plan_read_model` (same name, so no page
14
+ -- repoint is needed) to read the EFFECTIVE status off the auto-provisioned `plans__tracking` derived
15
+ -- VIEW instead of the frozen base column: the projected `status` and the bucket/ack derivations now
16
+ -- fold in the reconciler's terminal edge, so a cancelled/terminated epic drops out of Active with no
17
+ -- worker write and no poller pass.
18
+ --
19
+ -- `plans__tracking` is the managed VIEW urban provisions at mount (`<table>__tracking`, ADR-0065),
20
+ -- re-exporting `plans.*` plus a `derived_status` column that is `abandoned` on a terminated instance and
21
+ -- the base `plans.status` otherwise. SQLite does not validate a view body at CREATE time, so this
22
+ -- migration (which runs before the runtime mount that provisions `plans__tracking`) is created fine and
23
+ -- resolves once the managed VIEW exists. The `COALESCE(t.derived_status, pl.status)` fallback degrades to
24
+ -- the previous base-column behaviour only for an unexpected NULL `derived_status` or a missing joined row
25
+ -- (the LEFT JOIN yielding no `t` row) — it does NOT protect against the `plans__tracking` VIEW being
26
+ -- absent, which would fail this VIEW's query at read time.
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 (only the SOURCE of `status`/`list_bucket`/`ack_open` moved from the
31
+ -- base column to the derived VIEW), so the pages↔schema contract guard and every page binding stay
32
+ -- valid.
33
+ --
34
+ -- Forward-only. NO BEGIN/COMMIT — the runner wraps each file in its own transaction. Numbered after 078.
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
+ COALESCE(t.derived_status, 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 COALESCE(t.derived_status, pl.status) IN ('planning', 'dispatched') THEN 'active'
58
+ WHEN COALESCE(t.derived_status, pl.status) = 'done' AND d.delivery = 'converging' THEN 'active'
59
+ WHEN COALESCE(t.derived_status, pl.status) = 'done' AND pl.acknowledged_at IS NULL THEN 'active'
60
+ WHEN COALESCE(t.derived_status, pl.status) = 'done' THEN 'history'
61
+ ELSE 'history'
62
+ END) AS list_bucket,
63
+ (CASE
64
+ WHEN COALESCE(t.derived_status, 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 plans__tracking t ON t.plan_key = pl.plan_key
74
+ LEFT JOIN plan_wave_label wl ON wl.plan_key = pl.plan_key
75
+ LEFT JOIN plan_delivery d ON d.plan_key = pl.plan_key;