@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 +12 -0
- package/app/deliveryGraphDispatch.ts +12 -2
- package/app/deliveryRunner.test.ts +87 -0
- package/app/deliveryRunner.ts +11 -7
- package/app/featureReadModel.test.ts +62 -17
- package/app/featureReadModel.ts +28 -9
- package/app/interEpicRegression.test.ts +3 -2
- package/app/plan.test.ts +5 -4
- package/app/plan.ts +26 -3
- package/app/planLowering.test.ts +2 -2
- package/app/plansReadModel.test.ts +38 -1
- package/app/reviewWait.ts +8 -0
- package/app/service.ts +41 -16
- package/app/stage.ts +8 -3
- package/app/terminalReaderBehaviour.test.ts +289 -0
- package/app/terminalReaderGuard.test.ts +106 -0
- package/db/migrations/080_plan_read_model_derive_terminal.sql +75 -0
- package/db/migrations/081_feature_read_model_derive_terminal.sql +65 -0
- package/openapi.yaml +39 -0
- package/operations/dispatchDeliveryGraph.test.ts +65 -0
- package/operations/dispatchDeliveryGraph.ts +52 -1
- package/operations/listActivePrs.test.ts +2 -1
- package/operations/startEpicSet.admission.integration.test.ts +3 -2
- package/operations/startPlanFanout.admission.integration.test.ts +2 -1
- package/package.json +1 -1
- package/test/trackingViews.ts +11 -1
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
|
-
|
|
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");
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -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: "
|
|
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
|
|
115
|
-
probeTimeout: options.probeTimeout
|
|
116
|
-
probePollEvery: options.probePollEvery
|
|
117
|
-
escalationSlaTimeout: options.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
|
|
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 (
|
|
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
|
|
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
|
|
38
|
-
//
|
|
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
|
-
|
|
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
|
|
130
|
-
const sql = MIG(
|
|
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 ${
|
|
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), "
|
|
142
|
-
assert(/CREATE VIEW feature_read_model AS/.test(sql), "
|
|
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}`), `
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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");
|
package/app/featureReadModel.ts
CHANGED
|
@@ -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
|
-
/**
|
|
51
|
-
|
|
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
|
|
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(
|
|
63
|
-
when(or(neq(col("pr_key"), lit("")), eq(col(
|
|
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 `
|
|
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(
|
|
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:
|
|
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
|
|
594
|
-
|
|
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
|
-
|
|
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);
|
package/app/planLowering.test.ts
CHANGED
|
@@ -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
|
+
});
|