// Unit coverage for the single-issue feature run domain (issue #172). // // `startFeature` mirrors `startPlan`: it registers/refreshes the `feature_runs` aggregate // (idempotent on `feature_key`), starts `feature.bpmn`, and persists the process key. These tests // drive it against an in-memory data layer + a stub engine and assert the row shape, the // short-circuit on an already-running run, the in-place restart of a settled run, and the seeded // process variables (the single `task` slice + the base-branch brief). import { after, test } from "node:test"; import { assertEquals } from "#test-assert"; import { withTrackingViews } from "../test/trackingViews.ts"; import { FEATURE_PROCESS_ID, FEATURE_TERMINAL_STATUSES, featureTaskId, foldCompletedFeatureRun, startFeature } from "./feature.ts"; // `startFeature` now fetches the issue title (issue #248) via the GitHub transport. Force the token // transport with no token so the fetch is a hermetic no-op (returns null) — no `gh` subprocess, no // network — and the row `title` deterministically coalesces to the `owner/repo#N` key. A dedicated // test below stubs a successful fetch to cover the real-title path. Capture the prior values and // restore them after this file's tests so the module-scope mutation never leaks into other test // files under concurrent `node --test`. const PRIOR_TRANSPORT = process.env["NANO_PR_GITHUB_TRANSPORT"]; const PRIOR_TOKEN = process.env["GITHUB_TOKEN"]; process.env["NANO_PR_GITHUB_TRANSPORT"] = "token"; delete process.env["GITHUB_TOKEN"]; after(() => { if (PRIOR_TRANSPORT === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"]; else process.env["NANO_PR_GITHUB_TRANSPORT"] = PRIOR_TRANSPORT; if (PRIOR_TOKEN === undefined) delete process.env["GITHUB_TOKEN"]; else process.env["GITHUB_TOKEN"] = PRIOR_TOKEN; }); function memTable(rows: any[], key: string) { return { get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null), find: (q: any) => Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))), 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(); }, }; } function memData(stores: Record) { return { // Serve the ADR-0065 derived tracking VIEW (`feature_runs__tracking`) off the base store so the // intake idempotency reader (which reads `derived_status`, issue #704) resolves against the same // rows — a base row with no explicitly-seeded `derived_status` folds `derived_status := status`. table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)), // Minimal `open().exec()` for the guarded CAS folds (`foldCompletedFeatureRun`), so the re-seed- // window regression below can drive the real CAS predicate (same feature_key + process_key + // status = the observed transient, now a bound param) against the mid-reseed row. open: () => ({ exec: (sql: string, params: any[]) => { if (!/UPDATE "feature_runs" SET .* WHERE "feature_key" = \? AND "process_key" = \? AND "status" = \?/.test(sql)) { throw new Error(`memData.exec: unhandled sql: ${sql}`); } const [status, label, updated_at, feature_key, process_key, expect_status] = params; const rows = stores.feature_runs?.rows ?? []; let changed = 0; for (const r of rows) { if (r.feature_key === feature_key && r.process_key === process_key && r.status === expect_status) { r.status = status; r.delivery_label = label; r.updated_at = updated_at; changed += 1; } } return Promise.resolve({ changed }); }, }), } as any; } const PARSED = { repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/issues/42", planKey: "owner/repo#42", }; test("featureTaskId: deterministic branch slug derivable from the issue number alone", () => { assertEquals(featureTaskId(42), "issue-42"); assertEquals(featureTaskId(1), "issue-1"); }); test("startFeature: inserts a running feature_runs row and persists the process key", async () => { const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } }; const data = memData(stores); const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }) } as any; const result = await startFeature(data, engine, PARSED, "main", false, false); assertEquals(result.featureKey, "owner/repo#42"); assertEquals(result.processKey, "PI-9"); assertEquals(result.outcome, "started"); assertEquals(result.alreadyRunning, false); const row = stores.feature_runs.rows[0]; assertEquals(row.feature_key, "owner/repo#42"); assertEquals(row.repo, "owner/repo"); assertEquals(row.issue_number, 42); assertEquals(row.base_branch, "main"); assertEquals(row.status, "running"); assertEquals(row.process_key, "PI-9"); assertEquals(row.converge, 0); assertEquals(row.auto_merge, 0); assertEquals(row.pr_key, null); }); test("startFeature: converge/autoMerge flags are persisted as 0/1", async () => { const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } }; const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }) } as any; await startFeature(memData(stores), engine, PARSED, "main", true, true); const row = stores.feature_runs.rows[0]; assertEquals(row.converge, 1); assertEquals(row.auto_merge, 1); }); test("startFeature: seeds the single task slice + base-branch brief onto the instance", async () => { let captured: any = null; const engine = { createInstance: (req: any) => { captured = req; return Promise.resolve({ processInstanceKey: "PI-2" }); }, } as any; await startFeature( memData({ feature_runs: { rows: [], key: "feature_key" } }), engine, PARSED, "epic/x", true, false, ); assertEquals(captured.processDefinitionId, FEATURE_PROCESS_ID); const v = captured.variables; assertEquals(v.featureKey, "owner/repo#42"); assertEquals(v.issue, "owner/repo#42"); assertEquals(v.task.id, "issue-42"); assertEquals(v.task.title, "owner/repo#42"); assertEquals(typeof v.task.prompt, "string"); assertEquals(v.task.prompt.includes("owner/repo#42"), true); assertEquals(v.converge, true); assertEquals(v.autoMerge, false); // A single-issue run owns its issue, so the agent is told it may claim it (epic slices never set this). assertEquals(v.claimIssue, true); assertEquals(v.baseBranch, "epic/x"); // The brief is the authoritative base-branch override the agent gets via appendPrompt. assertEquals(v.baseBranchBrief.includes("epic/x"), true); // Agent-result variables are pre-seeded so the escalation loop + record worker can reference them. assertEquals(v.pr, null); assertEquals(v.status, null); }); test("startFeature: seeds the pre-PR repository envelope so the harness provisions an isolated clone (#684)", async () => { let captured: any = null; const engine = { createInstance: (req: any) => { captured = req; return Promise.resolve({ processInstanceKey: "PI-684" }); }, } as any; await startFeature( memData({ feature_runs: { rows: [], key: "feature_key" } }), engine, PARSED, "epic/x", true, false, ); // Without the envelope the c8ctl harness leaves cwd undefined and the agent mutates the worker's // shared launch dir; with it, the harness clones a throwaway workspace. The implementation path is // PRE-PR, so it checks out the BASE branch (`ref`) and the harness cuts the deterministic // `feat/` feature branch off it (`branch.create`). const repo = (captured.variables as Record)["io.nanobpm.agentTask"].repository; assertEquals(repo.url, "https://github.com/owner/repo.git"); assertEquals(repo.ref, "epic/x"); assertEquals(repo.branch.create, "feat/issue-42"); assertEquals(repo.branch.create, `feat/${featureTaskId(PARSED.number)}`); // The blobless/single-branch monorepo shaping rides along, exactly like the PR-based envelope. assertEquals(repo.singleBranch, true); assertEquals(repo.filter, "blob:none"); }); test("startFeature: custom instructions ride the instance as a variable (trimmed)", async () => { let captured: any = null; const engine = { createInstance: (req: any) => { captured = req; return Promise.resolve({ processInstanceKey: "PI-3" }); }, } as any; await startFeature( memData({ feature_runs: { rows: [], key: "feature_key" } }), engine, PARSED, "main", false, false, " prefer Deno; keep the diff small ", ); assertEquals(captured.variables.customInstructions, "prefer Deno; keep the diff small"); }); test("startFeature: blank/absent custom instructions are seeded as null", async () => { let captured: any = null; const engine = { createInstance: (req: any) => { captured = req; return Promise.resolve({ processInstanceKey: "PI-4" }); }, } as any; // Absent (default arg) → null. await startFeature(memData({ feature_runs: { rows: [], key: "feature_key" } }), engine, PARSED, "main", false, false); assertEquals(captured.variables.customInstructions, null); // Whitespace-only → null (so the appendPrompt FEEL skips the block instead of appending an empty heading). await startFeature(memData({ feature_runs: { rows: [], key: "feature_key" } }), engine, PARSED, "main", false, false, " "); assertEquals(captured.variables.customInstructions, null); }); test("startFeature: an already-running run short-circuits (no new instance)", async () => { const stores = { feature_runs: { rows: [{ feature_key: "owner/repo#42", status: "running", process_key: "PI-OLD" }], key: "feature_key", }, }; let created = 0; const engine = { createInstance: () => { created += 1; return Promise.resolve({ processInstanceKey: "PI-NEW" }); }, } as any; const result = await startFeature(memData(stores), engine, PARSED, "main", false, false); assertEquals(created, 0); assertEquals("alreadyRunning" in result && (result as any).alreadyRunning, true); assertEquals(result.outcome, "already-active"); assertEquals(result.processKey, "PI-OLD"); }); test("startFeature: a run parked at the operator task (awaiting_operator) short-circuits a re-dispatch", async () => { // A blocked run parked at the feature-blocked user task is NON-terminal, so re-dispatching the // same issue must not spawn an orphaned parallel instance — it short-circuits until the operator // acknowledges it (which settles it to terminal `blocked`). assertEquals(FEATURE_TERMINAL_STATUSES.includes("awaiting_operator" as any), false); const stores = { feature_runs: { rows: [{ feature_key: "owner/repo#42", status: "awaiting_operator", process_key: "PI-PARK" }], key: "feature_key", }, }; let created = 0; const engine = { createInstance: () => { created += 1; return Promise.resolve({ processInstanceKey: "PI-NEW" }); }, } as any; const result = await startFeature(memData(stores), engine, PARSED, "main", false, false); assertEquals(created, 0); assertEquals("alreadyRunning" in result && (result as any).alreadyRunning, true); assertEquals(result.processKey, "PI-PARK"); }); test("startFeature: a settled run is restarted in place (status reset, pr/outcome cleared)", async () => { const stores = { feature_runs: { rows: [ { feature_key: "owner/repo#42", status: "opened", process_key: "PI-OLD", pr_key: "owner/repo#100", outcome: "prior run", converge: 0, auto_merge: 0, }, ], key: "feature_key", }, }; const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-2" }) } as any; await startFeature(memData(stores), engine, PARSED, "main", true, true); const row = stores.feature_runs.rows[0]; assertEquals(row.status, "running"); assertEquals(row.pr_key, null); assertEquals(row.outcome, null); assertEquals(row.converge, 1); assertEquals(row.auto_merge, 1); assertEquals(row.process_key, "PI-2"); }); // Issue #808 (TOCTOU review follow-up, RED first): the re-seed of a settled run flips `status` back to // `running` but installs the NEW process key only AFTER `createInstance` returns. If the OLD // `process_key` were left in place across that await, the poller's guarded fold // (`foldCompletedFeatureRun`, keyed on the OLD key + `status='running'`) would still match the fresh // incarnation mid-reseed and terminalize it. The reset now clears `process_key` to NULL atomically // with the status reset, so throughout the window the guard matches ZERO rows. This test fires the // exact interval: inside the `createInstance` stub (the window after the reset, before the new key is // written) it runs the real CAS against the OLD key and asserts it is a no-op and the row is untouched. test("startFeature: the re-seed window nulls process_key so a concurrent COMPLETED fold on the OLD key is a no-op (issue #808)", async () => { const stores = { feature_runs: { rows: [{ feature_key: "owner/repo#42", repo: "owner/repo", issue_number: 42, status: "opened", // a prior settled incarnation process_key: "PI-OLD", pr_key: "owner/repo#100", delivery_label: "PR raised", converge: 0, auto_merge: 0, }], key: "feature_key", }, }; const data = memData(stores); let windowStatus: string | undefined; let windowProcessKey: string | undefined; let foldWon: boolean | undefined; const engine = { // Runs DURING the re-seed window: the row has been reset to `running` but the new key is not yet // installed. Simulate the poller's guarded fold on the OLD key racing here. createInstance: async () => { const row = stores.feature_runs.rows[0]; windowStatus = row.status; windowProcessKey = row.process_key; foldWon = await foldCompletedFeatureRun(data, "owner/repo#42", "PI-OLD", "opened", "PR raised"); return { processInstanceKey: "PI-NEW" }; }, } as any; await startFeature(data, engine, PARSED, "main", false, false); // Mid-reseed the row was `running` with process_key CLEARED, so the OLD-key guard matched nothing. assertEquals(windowStatus, "running"); assertEquals(windowProcessKey, null); assertEquals(foldWon, false); // the concurrent fold was a no-op — the fresh incarnation is safe const row = stores.feature_runs.rows[0]; // The run finished the reseed uncorrupted: still `running`, now carrying the NEW key. assertEquals(row.status, "running"); assertEquals(row.process_key, "PI-NEW"); }); test("startFeature: an in-place restart clears a stale acknowledged_at (re-earn the tick-off)", async () => { const stores = { feature_runs: { rows: [ { feature_key: "owner/repo#42", status: "merged", process_key: "PI-OLD", pr_key: "owner/repo#100", acknowledged_at: "2024-01-01T00:00:00Z", converge: 1, auto_merge: 1, }, ], key: "feature_key", }, }; const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-3" }) } as any; await startFeature(memData(stores), engine, PARSED, "main", true, true); const row = stores.feature_runs.rows[0]; assertEquals(row.status, "running"); assertEquals(row.acknowledged_at, null); }); // Issue #704 (RED first): the feature-side twin of the #503 `submitPr`/epic re-admission wedges. Under // urban 0.81.0 the `instanceTracking` reconciler is a SOURCE, not a writer: on cancel/terminate it no // longer stamps the terminal `abandoned` onto base `feature_runs.status` — the terminal is recomputed // on read as `feature_runs__tracking.derived_status`. A terminated run therefore keeps its base // `status` frozen at its last worker transient (`running`/`escalated`/`awaiting_operator`) while // `derived_status` reads `abandoned`. The intake idempotency reader MUST classify on `derived_status`, // or a terminated run wedges `already-active` forever — a green success that dispatches NO instance. // // (a) present + TERMINATED: base row present, `status` frozen, `derived_status: "abandoned"` → the // resubmit dispatches a FRESH instance and reports `started` with a new processKey. test("startFeature: resubmits a derive-only-terminated run (base 'running', derived 'abandoned') — started, not already-active", async () => { const stores = { feature_runs: { rows: [{ feature_key: "owner/repo#42", repo: "owner/repo", issue_number: 42, status: "running", // base transient FROZEN — the reconciler no longer writes the terminal derived_status: "abandoned", // ADR-0065 derive-only terminal process_key: "PI-DEAD", pr_key: null, converge: 0, auto_merge: 0, }], key: "feature_key", }, }; let created = 0; const engine = { createInstance: () => { created += 1; return Promise.resolve({ processInstanceKey: "PI-FRESH" }); }, } as any; const result = await startFeature(memData(stores), engine, PARSED, "main", false, false); assertEquals(created, 1); // NOT wedged — a fresh incarnation was dispatched assertEquals(result.outcome, "started"); assertEquals(result.alreadyRunning, false); assertEquals(result.processKey, "PI-FRESH"); // a NEW instance key, not the dead PI-DEAD const row = stores.feature_runs.rows[0]; assertEquals(row.status, "running"); // restarted in place assertEquals(row.process_key, "PI-FRESH"); }); // (b) vanished instance (feature_runs row absent — a clean reset dropped it): the intake sees no prior // run at all and inserts a fresh one. Resubmit dispatches a fresh instance and reports `started`. // (The present-but-instance-state-vanished shape — where derived_status cannot fold — is #630's edge; // this asserts the row-absent boundary is resubmittable here.) test("startFeature: resubmits when the prior run row has vanished (row absent) — started", async () => { const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } }; let created = 0; const engine = { createInstance: () => { created += 1; return Promise.resolve({ processInstanceKey: "PI-REBORN" }); }, } as any; const result = await startFeature(memData(stores), engine, PARSED, "main", false, false); assertEquals(created, 1); assertEquals(result.outcome, "started"); assertEquals(result.processKey, "PI-REBORN"); assertEquals(stores.feature_runs.rows[0].process_key, "PI-REBORN"); }); // A genuinely-live prior run (non-terminal derived edge) still short-circuits `already-active` — a // live run cannot be double-started. test("startFeature: a non-terminal derived run still short-circuits (already-active, no new instance)", async () => { const stores = { feature_runs: { rows: [{ feature_key: "owner/repo#42", status: "running", derived_status: "running", // still live process_key: "PI-LIVE", }], key: "feature_key", }, }; let created = 0; const engine = { createInstance: () => { created += 1; return Promise.resolve({ processInstanceKey: "PI-NEW" }); }, } as any; const result = await startFeature(memData(stores), engine, PARSED, "main", false, false); assertEquals(created, 0); assertEquals(result.outcome, "already-active"); assertEquals(result.alreadyRunning, true); assertEquals(result.processKey, "PI-LIVE"); }); // The silent-success contract (#704 fix #3): when the engine returns NO instance key the intake // dispatched nothing — it must report `noop-terminal` (a distinct non-success), never a green // `started`. The operation maps this to a 502 so the page renders "nothing started" distinctly. test("startFeature: a start that dispatches no instance reports noop-terminal (not a green success)", async () => { const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } }; const engine = { createInstance: () => Promise.resolve({ processInstanceKey: null }) } as any; const result = await startFeature(memData(stores), engine, PARSED, "main", false, false); assertEquals(result.outcome, "noop-terminal"); assertEquals(result.processKey, null); assertEquals(result.alreadyRunning, false); }); // #704 follow-up (review): a `noop-terminal` start leaves a `feature_runs` row at base `running` with // `process_key: null` — NO engine instance was ever dispatched. That phantom row's `derived_status` // folds to the non-terminal base `running` (there is no terminated instance to fold to `abandoned`), // so without the null-`process_key` admission guard a retry would short-circuit `already-active` and // wedge resubmission forever. Assert the retry stays RESUBMITTABLE and re-dispatches. test("startFeature: retry after a noop-terminal re-dispatches (a null process_key row is not already-active)", async () => { const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } }; // Round 1: the engine dispatches nothing → noop-terminal, leaving a running/process_key:null row. const noopEngine = { createInstance: () => Promise.resolve({ processInstanceKey: null }) } as any; const first = await startFeature(memData(stores), noopEngine, PARSED, "main", false, false); assertEquals(first.outcome, "noop-terminal"); assertEquals(stores.feature_runs.rows[0].status, "running"); assertEquals(stores.feature_runs.rows[0].process_key, null); // Round 2: a healthy engine now dispatches — the retry must NOT wedge as already-active. let created = 0; const engine = { createInstance: () => { created += 1; return Promise.resolve({ processInstanceKey: "PI-RETRY" }); }, } as any; const second = await startFeature(memData(stores), engine, PARSED, "main", false, false); assertEquals(created, 1); assertEquals(second.outcome, "started"); assertEquals(second.alreadyRunning, false); assertEquals(second.processKey, "PI-RETRY"); }); // `title` — the fetched issue title when available, else the `owner/repo#N` key — on BOTH the insert // (new run) and update (in-place restart) paths, so the title-led grid never renders a blank cell. test("startFeature: coalesces title to the key when the fetch yields nothing (insert path)", async () => { const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } }; const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T1" }) } as any; await startFeature(memData(stores), engine, PARSED, "main", false, false); assertEquals(stores.feature_runs.rows[0].title, "owner/repo#42"); }); test("startFeature: repopulates a non-blank title on the in-place restart (update path)", async () => { const stores = { feature_runs: { rows: [{ feature_key: "owner/repo#42", status: "opened", title: null, process_key: "PI-OLD" }], key: "feature_key", }, }; const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T2" }) } as any; await startFeature(memData(stores), engine, PARSED, "main", false, false); assertEquals(stores.feature_runs.rows[0].title, "owner/repo#42"); }); test("startFeature: persists the real issue title when the fetch succeeds", async () => { const prevTok = process.env["GITHUB_TOKEN"]; const prevFetch = globalThis.fetch; process.env["GITHUB_TOKEN"] = "t0ken"; globalThis.fetch = ((url: string | URL | Request) => { const u = String(url); if (u.endsWith("/repos/owner/repo/issues/42")) { return Promise.resolve(new Response(JSON.stringify({ title: "Add the widget" }), { status: 200 })); } throw new Error(`unexpected fetch: ${u}`); }) as typeof fetch; try { const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } }; const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T3" }) } as any; await startFeature(memData(stores), engine, PARSED, "main", false, false); assertEquals(stores.feature_runs.rows[0].title, "Add the widget"); } finally { globalThis.fetch = prevFetch; if (prevTok === undefined) delete process.env["GITHUB_TOKEN"]; else process.env["GITHUB_TOKEN"] = prevTok; } }); test("startFeature: no readiness ⇒ readinessProbes/probeTimeout/gateKey seeded null (gate skipped)", async () => { let captured: any = null; const engine = { createInstance: (req: any) => { captured = req; return Promise.resolve({ processInstanceKey: "PI-R0" }); }, } as any; await startFeature(memData({ feature_runs: { rows: [], key: "feature_key" } }), engine, PARSED, "main", false, false); const v = captured.variables; assertEquals(v.readinessProbes, null); assertEquals(v.probeTimeout, null); assertEquals(v.probePollEvery, null); assertEquals(v.gateKey, null); assertEquals(v.resolvedArtifacts, null); }); test("startFeature: readiness probes seed the gate variables + a non-blank correlation key", async () => { let captured: any = null; const engine = { createInstance: (req: any) => { captured = req; return Promise.resolve({ processInstanceKey: "PI-R1" }); }, } as any; const probes = [ { kind: "capability", target: "github-releases:nanobpm/nano-bpm", match: { package: "@nanobpm/engine-wasm", capabilityRef: "nanobpm/nano-bpm#631" }, onTimeout: "escalate", }, ] as any; await startFeature( memData({ feature_runs: { rows: [], key: "feature_key" } }), engine, PARSED, "main", false, false, null, { probes, probeTimeout: "PT30M", probePollEvery: "PT15S" }, ); const v = captured.variables; assertEquals(v.readinessProbes, probes); assertEquals(v.probeTimeout, "PT30M"); assertEquals(v.probePollEvery, "PT15S"); // The preflight probe worker requires a non-blank gateKey to publish readiness-ready on. assertEquals(v.gateKey, "feature-readiness:owner/repo#42"); assertEquals(v.resolvedArtifacts, null); }); test("startFeature: probes without a probeTimeout fail fast (both are load-bearing together)", async () => { const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-R2" }) } as any; let threw = false; try { await startFeature( memData({ feature_runs: { rows: [], key: "feature_key" } }), engine, PARSED, "main", false, false, null, { probes: [{ kind: "command", target: "x" }] as any, probeTimeout: null }, ); } catch (err) { threw = true; assertEquals((err as Error).message.includes("probeTimeout"), true); } assertEquals(threw, true); });