// Behaviour coverage for issue #503 — the ADR-0065 derive-only-terminal divergence at the terminal
// EDGE readers. Under `@nanobpm/urban@0.81.0` the `instanceTracking` reconciler no longer WRITES the
// terminal `abandoned`/`failed`/`reviewed` onto the base `status`; it re-derives it on read via the
// `
__tracking.derived_status` VIEW. A PR/plan whose engine instance was terminated out-of-band
// (or by an ordinary in-app cancel) therefore keeps its base `status` frozen at its last worker
// transient (e.g. `converging`/`dispatched`) while `derived_status` reads `abandoned`.
//
// Each test seeds that EXACT divergence (base row `status: "converging"`, `derived_status: "abandoned"`)
// via the `withTrackingViews` seam, and asserts the reader classifies on the derived edge:
// - a terminated PR is RESUBMITTABLE (not wedged `alreadyRunning`) and absent from `activePrs`,
// - a terminated instance sheds its stale incident,
// - a terminated lane member counts as COMPLETE (does not stall the merge lane),
// - a terminated epic is not counted active (no false same-base conflict) and is RE-ADMITTABLE.
// Reading only the base `status` (the pre-#503 behaviour) fails every one of these — the RED.
import { test } from "node:test";
import { assertEquals } from "#test-assert";
import { withTrackingViews } from "../test/trackingViews.ts";
import { findActivePlansByBase, startPlan } from "./plan.ts";
import { activePrs, mergeLaneDecisionForPr, pollIncidentsImpl, pollWaveGatesImpl, submitPr } from "./service.ts";
function memTable(rows: any[], key: string) {
return {
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
all: () => Promise.resolve([...rows]),
find: (q: any) =>
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
findOne: (q: any) =>
Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
count: (q: any) =>
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)).length),
insert: (r: any) => {
rows.push(r);
return Promise.resolve(r);
},
update: (k: any, patch: any) => {
const r = rows.find((x) => x[key] === k);
if (r) Object.assign(r, patch);
return Promise.resolve(r);
},
delete: (k: any) => {
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
return Promise.resolve();
},
};
}
type Stores = Record;
function memData(stores: Stores) {
return {
table: withTrackingViews((name: string, key: string) =>
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
// Emulates the atomic bulk `DELETE FROM "pr_adjudications" WHERE "pr_key" = ?` submitPr issues via
// `data.open().exec` to reset a reopened PR's adjudication memory (Copilot review of #806). The SQL
// is validated against real SQLite in app/adjudications.test.ts; here it need only mutate the store.
open: () => ({
exec: async (sql: string, params: any[] = []) => {
if (/DELETE FROM "pr_adjudications" WHERE "pr_key" = \?/.test(sql)) {
const store = stores.pr_adjudications;
let changed = 0;
if (store) {
for (let i = store.rows.length - 1; i >= 0; i--) {
if (store.rows[i].pr_key === params[0]) {
store.rows.splice(i, 1);
changed++;
}
}
}
return { changed };
}
throw new Error(`unexpected exec sql: ${sql}`);
},
}),
} as any;
}
function withGithubOff(run: () => Promise): Promise {
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
const prevTok = process.env["GITHUB_TOKEN"];
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token"; // no token below -> fetchPrMeta returns null
delete process.env["GITHUB_TOKEN"];
return run().finally(() => {
if (prevMode !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
});
}
// #503 / #497: `submitPr`'s idempotency gate reads `derived_status`, so a derive-only-terminated PR
// (base frozen at `converging`, `derived_status = abandoned`) is seen terminal and RESUBMITTABLE —
// it re-opens for a fresh convergence run instead of wedging `alreadyRunning`.
test("submitPr re-opens a derive-only-terminated PR (base 'converging', derived 'abandoned') — not alreadyRunning", async () => {
await withGithubOff(async () => {
const PR_KEY = "owner/repo#42";
const stores: Stores = {
pull_requests: {
rows: [{
pr_key: PR_KEY,
repo: "owner/repo",
number: 42,
url: "https://github.com/owner/repo/pull/42",
title: "t",
status: "converging", // base transient FROZEN — reconciler no longer writes the terminal
derived_status: "abandoned", // ADR-0065 derive-only terminal
current_round: 3,
}],
key: "pr_key",
},
escalations: { rows: [], key: "id" },
pr_dependencies: { rows: [], key: "pr_key" },
};
const data = memData(stores);
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }) } as any;
const res = await submitPr(data, engine, {
repo: "owner/repo",
number: 42,
url: "https://github.com/owner/repo/pull/42",
prKey: PR_KEY,
});
assertEquals((res as any).alreadyRunning, undefined); // NOT wedged
assertEquals(res.processKey, "PI-9");
const pr = stores.pull_requests.rows[0];
assertEquals(pr.status, "converging");
assertEquals(pr.current_round, 1); // re-opened for a fresh run
assertEquals(pr.process_key, "PI-9");
});
});
// #503 / #497 phantom: `activePrs` filters on `derived_status`, so a derive-only-terminated PR drops
// off the Convergence tab instead of showing "active" indefinitely.
test("activePrs excludes a derive-only-terminated PR and keeps a genuinely active one", async () => {
const stores: Stores = {
pull_requests: {
rows: [
{ 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" },
{ 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" },
],
key: "pr_key",
},
escalations: { rows: [], key: "id" },
};
const active = await activePrs(memData(stores));
assertEquals(active.map((p) => p.prKey), ["o/r#2"]);
});
// #503: `pollIncidentsImpl` classifies a dead instance on `derived_status`, so a derive-only-terminated
// PR sheds its stale incident (rather than re-reconciling against a gone instance), and NEVER queries
// the engine for it. A live PR still gets queried.
test("pollIncidentsImpl clears a stale incident off a derive-only-terminated PR without querying the engine", async () => {
const stores: Stores = {
pull_requests: {
rows: [{
pr_key: "o/r#7",
repo: "o/r",
number: 7,
url: "u",
status: "converging",
derived_status: "abandoned",
process_key: "PI-DEAD",
incident_key: "INC-1",
incident_message: "boom",
}],
key: "pr_key",
},
};
const prevFetch = globalThis.fetch;
let queried = false;
globalThis.fetch = (() => {
queried = true;
throw new Error("engine must not be queried for a derive-only-terminated PR");
}) as any;
try {
await pollIncidentsImpl(memData(stores), "http://engine", {});
} finally {
globalThis.fetch = prevFetch;
}
assertEquals(queried, false);
const pr = stores.pull_requests.rows[0];
assertEquals(pr.incident_key, null);
assertEquals(pr.incident_message, null);
});
// #503: the merge-lane decision counts a lane member COMPLETE on the derived terminal edge, so a
// derive-only-abandoned member (base 'converging') no longer holds its lane-mate behind a dead PR.
test("mergeLaneDecisionForPr treats a derive-only-abandoned lane member as complete (does not hold the lane)", async () => {
const PLAN_KEY = "o/r#100";
const stores: Stores = {
plan_tasks: {
rows: [
{ id: 1, plan_key: PLAN_KEY, task_id: "a", pr_key: "o/r#1" },
{ id: 2, plan_key: PLAN_KEY, task_id: "b", pr_key: "o/r#2" },
],
key: "id",
},
plan_merge_exclusions: {
// a & b collide on a shared surface → one landing lane, land one-at-a-time
rows: [{ id: 1, plan_key: PLAN_KEY, task_a: "a", task_b: "b", files: JSON.stringify(["shared.ts"]), source: "file-overlap" }],
key: "id",
},
plan_task_deps: { rows: [], key: "plan_key" },
pull_requests: {
rows: [
// lane head candidate `a`: derive-only-terminated (base frozen, derived abandoned)
{ pr_key: "o/r#1", repo: "o/r", number: 1, status: "converging", derived_status: "abandoned" },
// `b`: the PR we ask about — still converging
{ pr_key: "o/r#2", repo: "o/r", number: 2, status: "converging", derived_status: "converging" },
],
key: "pr_key",
},
};
const decision = await mergeLaneDecisionForPr(memData(stores), "o/r#2");
// With `a` counted complete, `b` is free to land — NOT held behind the dead member.
assertEquals(decision?.isHeld, false);
});
// #503: `classifyWaveTarget` classifies a wave member on the derived terminal edge, so a
// derive-only-abandoned member (base frozen at `converging`, still notionally open) is treated
// NON-BLOCKING (`cleared`) WITHOUT a GitHub round-trip — the wave gate advances instead of wedging on
// a dead member. Driven through `pollWaveGatesImpl`: with the sole gate member cleared and the token
// parked at `wait-wave-merged`, the barrier is released (`wave-merged` published). Reading the base
// `status` (the RED) would fall through to a GitHub liveness read (which, with no live "merged"
// signal, returns `pending`) and never publish.
test("classifyWaveTarget treats a derive-only-abandoned wave member as cleared and releases the gate (no GitHub read)", async () => {
await withGithubOff(async () => {
const PLAN_KEY = "o/r#200";
const stores: Stores = {
plans: { rows: [{ plan_key: PLAN_KEY, gate_wave: 0, process_key: "PI-1" }], key: "plan_key" },
plan_tasks: {
rows: [{ id: 1, plan_key: PLAN_KEY, task_id: "a", wave: 0, status: "opened", pr_key: "o/r#1" }],
key: "id",
},
pull_requests: {
rows: [{ pr_key: "o/r#1", repo: "o/r", number: 1, status: "converging", derived_status: "abandoned" }],
key: "pr_key",
},
};
const published: unknown[] = [];
const engine = { publishMessage: (m: unknown) => (published.push(m), Promise.resolve()) } as any;
const prevFetch = globalThis.fetch;
// Confirm the token is parked at the `wave-merged` wait so the barrier is releasable; a GitHub
// liveness read for the abandoned member would be a bug (it's classified `cleared` off the view).
globalThis.fetch = ((url: string) => {
if (String(url).endsWith("/message-subscriptions/search")) {
return Promise.resolve(
new Response(
JSON.stringify({ items: [{ messageName: "wave-merged", correlationKey: PLAN_KEY, messageSubscriptionState: "CREATED" }] }),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
}
throw new Error(`no GitHub read expected for a derive-only-abandoned wave member: ${url}`);
}) as any;
try {
await pollWaveGatesImpl(memData(stores), engine, "", "http://engine", {});
} finally {
globalThis.fetch = prevFetch;
}
assertEquals(published.length, 1); // wave released — the abandoned member did not block it
});
});
// derive-only-abandoned epic (base frozen at `dispatched`) is NOT counted active and raises no false
// same-base conflict.
test("findActivePlansByBase excludes a derive-only-abandoned epic", async () => {
const stores: Stores = {
plans: {
rows: [
{ plan_key: "o/r#10", repo: "o/r", base_branch: "epic/x", status: "dispatched", derived_status: "abandoned" },
{ plan_key: "o/r#11", repo: "o/r", base_branch: "epic/x", status: "dispatched", derived_status: "dispatched" },
],
key: "plan_key",
},
};
const active = await findActivePlansByBase(memData(stores), "o/r", "epic/x");
assertEquals(active.map((p) => p.plan_key), ["o/r#11"]);
});
// #503: `startPlan`'s idempotency gate reads `derived_status`, so a derive-only-abandoned epic (base
// frozen at `dispatched`) is seen terminal and RE-ADMITTABLE — it re-plans instead of wedging
// `alreadyRunning`.
test("startPlan re-admits a derive-only-abandoned epic (base 'dispatched', derived 'abandoned') — not alreadyRunning", async () => {
await withGithubOff(async () => {
const PLAN_KEY = "owner/repo#7";
const stores: Stores = {
plans: {
rows: [{ plan_key: PLAN_KEY, repo: "owner/repo", base_branch: "epic/x", status: "dispatched", derived_status: "abandoned", task_count: 1 }],
key: "plan_key",
},
plan_tasks: { rows: [{ id: 1, plan_key: PLAN_KEY }], key: "id" },
plan_reviews: { rows: [], key: "plan_key" },
plan_task_deps: { rows: [], key: "plan_key" },
};
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }) } as any;
const res = await startPlan(memData(stores), engine, {
repo: "owner/repo",
number: 7,
url: "https://github.com/owner/repo/issues/7",
planKey: PLAN_KEY,
}, "epic/x");
assertEquals((res as any).alreadyRunning, undefined); // NOT wedged — re-planned
// The prior epic's tasks were cleared on the re-plan path (proves it did NOT short-circuit).
assertEquals(stores.plan_tasks.rows.length, 0);
});
});