@nanobpm/nano-workforce 0.132.0 → 0.133.1
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/deliveryConnector.test.ts +19 -0
- package/app/deliveryConnector.ts +50 -10
- package/app/deliveryGraphCompiler.test.ts +23 -0
- 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/service.test.ts +28 -1
- package/app/service.ts +56 -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/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
- package/docs/agent-guide.md +47 -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/workers/delivery-connector/worker.test.ts +239 -1
- package/workers/delivery-connector/worker.ts +87 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.133.1](https://github.com/nanobpm/nano-workforce/compare/v0.133.0...v0.133.1) (2026-08-24)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **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))
|
|
6
|
+
|
|
7
|
+
## [0.133.0](https://github.com/nanobpm/nano-workforce/compare/v0.132.0...v0.133.0) (2026-08-24)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **delivery-graph:** connector `converge`/`converge-merge` target enrolls a PR via submitPr (retire the manual land gate) ([#501](https://github.com/nanobpm/nano-workforce/issues/501)) ([a24562e](https://github.com/nanobpm/nano-workforce/commit/a24562eb12db8e6f81e6f382e94886a537f16378)), closes [#500](https://github.com/nanobpm/nano-workforce/issues/500)
|
|
12
|
+
|
|
1
13
|
## [0.132.0](https://github.com/nanobpm/nano-workforce/compare/v0.131.1...v0.132.0) (2026-08-24)
|
|
2
14
|
|
|
3
15
|
### Features
|
|
@@ -15,10 +15,14 @@ import { join, resolve } from "node:path";
|
|
|
15
15
|
import type { DataLayer } from "@nanobpm/urban";
|
|
16
16
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
17
17
|
import {
|
|
18
|
+
CONVERGE_MERGE_TARGET,
|
|
19
|
+
CONVERGE_TARGET,
|
|
18
20
|
connectorDedupeKey,
|
|
21
|
+
convergeOnlyForTarget,
|
|
19
22
|
type DeliveryConnectorDispatchRow,
|
|
20
23
|
deliveryConnectorDispatches,
|
|
21
24
|
dispatchConnector,
|
|
25
|
+
isConvergeTarget,
|
|
22
26
|
} from "./deliveryConnector.ts";
|
|
23
27
|
|
|
24
28
|
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
@@ -84,6 +88,21 @@ test("connectorDedupeKey: author key wins; else derives <processInstanceKey>:<el
|
|
|
84
88
|
assertEquals(connectorDedupeKey({ processInstanceKey: 12345, elementId: "n3" }), "12345:n3");
|
|
85
89
|
});
|
|
86
90
|
|
|
91
|
+
test("converge targets: `converge`/`converge-merge` are the enrollment targets; `converge` is review-only", () => {
|
|
92
|
+
assertEquals(CONVERGE_TARGET, "converge");
|
|
93
|
+
assertEquals(CONVERGE_MERGE_TARGET, "converge-merge");
|
|
94
|
+
// Only the two converge literals route into `submitPr`; any other target stays a stub dispatch.
|
|
95
|
+
assert(isConvergeTarget("converge"));
|
|
96
|
+
assert(isConvergeTarget("converge-merge"));
|
|
97
|
+
assert(!isConvergeTarget("slack"));
|
|
98
|
+
assert(!isConvergeTarget("Converge"));
|
|
99
|
+
assert(!isConvergeTarget(""));
|
|
100
|
+
// `convergeOnly` default maps onto `submitPr`'s arg: `converge` stops at converged (true),
|
|
101
|
+
// `converge-merge` drives the merge loop (false) — mirroring converge-feature's autoMerge inversion.
|
|
102
|
+
assertEquals(convergeOnlyForTarget("converge"), true);
|
|
103
|
+
assertEquals(convergeOnlyForTarget("converge-merge"), false);
|
|
104
|
+
});
|
|
105
|
+
|
|
87
106
|
test("first dispatch delivers exactly once; a redelivery on the same key dedupes and never re-acts", async () => {
|
|
88
107
|
await withApp(async (app) => {
|
|
89
108
|
const at = "2025-01-01T00:00:00.000Z";
|
package/app/deliveryConnector.ts
CHANGED
|
@@ -33,6 +33,33 @@ export const DELIVERY_CONNECTOR_TASK_TYPE = "pr.delivery-connector";
|
|
|
33
33
|
export const OUTCOME_CLAIMED = "claimed";
|
|
34
34
|
export const OUTCOME_DELIVERED = "delivered";
|
|
35
35
|
|
|
36
|
+
/** The two connector `target`s that enroll an agent-opened PR into the app's SHARED convergence /
|
|
37
|
+
* merge doors via `submitPr` (issue #500) — the delivery-graph side of the exact seam the feature
|
|
38
|
+
* cell reuses (`workers/converge-feature`), no duplicated machinery. `converge-merge` drives review
|
|
39
|
+
* convergence AND the merge loop; `converge` stops at `converged` (converge-only). This is the "real
|
|
40
|
+
* target dispatch" ADR 0005 deferred as a later slice for the connector I/O surface: a `converge`/
|
|
41
|
+
* `converge-merge` connector IS the "automated, side-effecting outbound action" a connector is
|
|
42
|
+
* defined to be. Named constants so the worker's dispatch branch and the docs/preview can never drift
|
|
43
|
+
* on the literal. */
|
|
44
|
+
export const CONVERGE_TARGET = "converge";
|
|
45
|
+
export const CONVERGE_MERGE_TARGET = "converge-merge";
|
|
46
|
+
|
|
47
|
+
/** Is `target` one of the converge-enrollment targets (`converge` / `converge-merge`)? The single
|
|
48
|
+
* predicate the worker branches on to route a dispatch into `submitPr` instead of the forward-declared
|
|
49
|
+
* stub. */
|
|
50
|
+
export function isConvergeTarget(target: string): boolean {
|
|
51
|
+
return target === CONVERGE_TARGET || target === CONVERGE_MERGE_TARGET;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The DEFAULT `convergeOnly` for a converge target: `converge` is review-only (`true` — stop at
|
|
55
|
+
* `converged`), `converge-merge` drives the merge loop too (`false`). Maps directly onto `submitPr`'s
|
|
56
|
+
* `convergeOnly` argument (mirroring how `converge-feature` inverts `autoMerge`). An author may still
|
|
57
|
+
* override it per-dispatch via the connector payload's `convergeOnly`. Only ever consulted behind
|
|
58
|
+
* `isConvergeTarget`, so a non-converge target's `false` is unreachable. */
|
|
59
|
+
export function convergeOnlyForTarget(target: string): boolean {
|
|
60
|
+
return target === CONVERGE_TARGET;
|
|
61
|
+
}
|
|
62
|
+
|
|
36
63
|
/** One durable dispatch-claim row — the at-most-once ledger entry a connector writes before it acts. */
|
|
37
64
|
export interface DeliveryConnectorDispatchRow extends Record<string, unknown> {
|
|
38
65
|
id?: number;
|
|
@@ -77,16 +104,27 @@ export function connectorDedupeKey(input: {
|
|
|
77
104
|
return null;
|
|
78
105
|
}
|
|
79
106
|
|
|
80
|
-
/** The
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
|
|
107
|
+
/** The side effect a connector dispatch performs EXACTLY ONCE per dedupe key. It runs only on the claim
|
|
108
|
+
* winner (or a resumed crashed claim), never on a `deduped` settled redelivery, so a real, non-idempotent
|
|
109
|
+
* side effect (e.g. `submitPr`, which deliberately re-opens a TERMINAL PR) is fenced by the ledger and
|
|
110
|
+
* can never double-fire — the reason the enrollment lives HERE rather than unconditionally around the
|
|
111
|
+
* dispatch. Returns the `detail` recorded on the ledger row. May be async (the real converge enrollment
|
|
112
|
+
* awaits `submitPr`). Must be idempotent so a resumed crashed claim can safely re-perform it — including
|
|
113
|
+
* terminal-safe against a NON-idempotent target (the converge action no-ops when its PR already settled,
|
|
114
|
+
* so a resume can never regress a terminal PR by re-opening it). */
|
|
115
|
+
export type ConnectorAction = (input: {
|
|
84
116
|
target: string;
|
|
85
117
|
payload: Record<string, unknown> | null;
|
|
86
118
|
boundFacts: readonly BoundFact[];
|
|
87
|
-
})
|
|
119
|
+
}) => { detail: string } | Promise<{ detail: string }>;
|
|
120
|
+
|
|
121
|
+
/** The forward-declared connector I/O surface (ADR non-goal — the concrete scheme is deferred). The
|
|
122
|
+
* DEFAULT `ConnectorAction`: a STUB that "performs" the action by returning a deterministic
|
|
123
|
+
* acknowledgement; a caller with a real side effect (the converge worker's `submitPr` enrollment) injects
|
|
124
|
+
* its own action into `dispatchConnector` instead, without touching the idempotency envelope around it. */
|
|
125
|
+
const performConnectorAction: ConnectorAction = (_input) => {
|
|
88
126
|
return { detail: "connector stub — I/O surface forward-declared (ADR 0005 non-goal)" };
|
|
89
|
-
}
|
|
127
|
+
};
|
|
90
128
|
|
|
91
129
|
/** The result of one connector dispatch attempt. `delivered` — the claim was won and the action fired
|
|
92
130
|
* exactly once; `deduped` — the key was already claimed (an at-least-once redelivery), so the recorded
|
|
@@ -107,13 +145,14 @@ async function resumeOrDedupe(
|
|
|
107
145
|
ledger: ReturnType<typeof deliveryConnectorDispatches>,
|
|
108
146
|
row: DeliveryConnectorDispatchRow,
|
|
109
147
|
input: { dedupeKey: string; target: string; payload?: Record<string, unknown> | null; boundFacts?: readonly BoundFact[] | null },
|
|
148
|
+
perform: ConnectorAction,
|
|
110
149
|
): Promise<ConnectorDispatchResult> {
|
|
111
150
|
if (row.outcome === OUTCOME_DELIVERED) {
|
|
112
151
|
return { connectorOutcome: "deduped", connectorDedupeKey: input.dedupeKey, connectorDetail: row.detail ?? "" };
|
|
113
152
|
}
|
|
114
153
|
// Still `claimed` — a prior attempt (sequential or the concurrent-race winner) claimed the key but
|
|
115
154
|
// never recorded delivery. Resume on the existing row rather than dedupe forever on an un-acted claim.
|
|
116
|
-
const { detail } =
|
|
155
|
+
const { detail } = await perform({
|
|
117
156
|
target: input.target,
|
|
118
157
|
payload: input.payload ?? null,
|
|
119
158
|
boundFacts: input.boundFacts ?? [],
|
|
@@ -149,6 +188,7 @@ export async function dispatchConnector(
|
|
|
149
188
|
data: DataLayer,
|
|
150
189
|
input: { dedupeKey: string; target: string; payload?: Record<string, unknown> | null; boundFacts?: readonly BoundFact[] | null },
|
|
151
190
|
at: string,
|
|
191
|
+
perform: ConnectorAction = performConnectorAction,
|
|
152
192
|
): Promise<ConnectorDispatchResult> {
|
|
153
193
|
const ledger = deliveryConnectorDispatches(data);
|
|
154
194
|
const existing = await ledger.findOne({ dedupe_key: input.dedupeKey });
|
|
@@ -167,7 +207,7 @@ export async function dispatchConnector(
|
|
|
167
207
|
if (existing) {
|
|
168
208
|
// A prior attempt recorded (`delivered`) or claimed-but-crashed (`claimed`) this key. Dedupe or
|
|
169
209
|
// resume it on the existing row — the ONE decision shared with the fence-loser path below.
|
|
170
|
-
return resumeOrDedupe(ledger, existing, input);
|
|
210
|
+
return resumeOrDedupe(ledger, existing, input, perform);
|
|
171
211
|
}
|
|
172
212
|
let claimId: number | bigint;
|
|
173
213
|
try {
|
|
@@ -196,11 +236,11 @@ export async function dispatchConnector(
|
|
|
196
236
|
// as the sequential path. Deduping a still-`claimed` winner here would complete the job on our ack,
|
|
197
237
|
// so a winner that then crashed would strand the side effect forever (the engine won't redeliver an
|
|
198
238
|
// acked job); resuming closes that gap and is safe because the action is idempotent.
|
|
199
|
-
if (won) return resumeOrDedupe(ledger, won, input);
|
|
239
|
+
if (won) return resumeOrDedupe(ledger, won, input, perform);
|
|
200
240
|
return { connectorOutcome: "deduped", connectorDedupeKey: input.dedupeKey, connectorDetail: "" };
|
|
201
241
|
}
|
|
202
242
|
// We alone won the claim — perform the side effect exactly once and record its outcome on our row.
|
|
203
|
-
const { detail } =
|
|
243
|
+
const { detail } = await perform({
|
|
204
244
|
target: input.target,
|
|
205
245
|
payload: input.payload ?? null,
|
|
206
246
|
boundFacts: input.boundFacts ?? [],
|
|
@@ -280,6 +280,29 @@ test("sideEffects: agent + connector only; connector carries its dedupeKey", asy
|
|
|
280
280
|
assert(!r.sideEffects.some((s) => s.nodeId === "publish"));
|
|
281
281
|
});
|
|
282
282
|
|
|
283
|
+
test("converge-merge worked graph: agent → connector[converge-merge] → wait[pr,merged] compiles with NO human node (retires the manual land gate, #500)", async () => {
|
|
284
|
+
const graph = {
|
|
285
|
+
name: "open → converge+merge → wait merged",
|
|
286
|
+
nodes: [
|
|
287
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:feature", prompt: "Implement the change and open a PR." } },
|
|
288
|
+
{ id: "land", kind: "connector", connector: { target: "converge-merge", payload: { pr: "acme/repo#123" } } },
|
|
289
|
+
{ id: "merged", kind: "wait", wait: { kind: "pr", target: "acme/repo#123", match: { prState: "merged" }, onTimeout: "escalate" } },
|
|
290
|
+
],
|
|
291
|
+
edges: [
|
|
292
|
+
{ from: "open", to: "land" },
|
|
293
|
+
{ from: "land", to: "merged" },
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
const r = await compileOk(graph);
|
|
297
|
+
// The canonical shape has NO human land-* gate — convergence is driven by the connector itself.
|
|
298
|
+
assertEquals(r.humanNodes.length, 0, "no human node bridges the PR to convergence");
|
|
299
|
+
// The connector is a side effect, naming its converge-merge target; the wait gate is read-only.
|
|
300
|
+
const connector = r.sideEffects.find((s) => s.nodeId === "land");
|
|
301
|
+
assertEquals(connector?.kind, "connector");
|
|
302
|
+
assert(connector?.description.includes("converge-merge"), "the side-effect names the converge-merge target");
|
|
303
|
+
assert(!r.sideEffects.some((s) => s.nodeId === "merged"), "the wait gate is not a side effect");
|
|
304
|
+
});
|
|
305
|
+
|
|
283
306
|
test("resolved edges carry the resolved fromNode and the referenced fact", async () => {
|
|
284
307
|
const r = await compileOk(RELEASE_RUNBOOK);
|
|
285
308
|
const factEdge = r.resolved.edges.find((e) => e.from === "watch-b.mergedSha");
|
|
@@ -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
|
+
});
|