@nanobpm/nano-workforce 0.162.2 → 0.163.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 CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.163.1](https://github.com/nanobpm/nano-workforce/compare/v0.163.0...v0.163.1) (2026-08-30)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **plan:** taskless plan follows engine liveness, not empty-plan heuristic ([#624](https://github.com/nanobpm/nano-workforce/issues/624)) ([#626](https://github.com/nanobpm/nano-workforce/issues/626)) ([1dbb0d6](https://github.com/nanobpm/nano-workforce/commit/1dbb0d650c955886588a1e4caf3e8af7b150c4ca)), closes [#623](https://github.com/nanobpm/nano-workforce/issues/623) [#623](https://github.com/nanobpm/nano-workforce/issues/623) [#623](https://github.com/nanobpm/nano-workforce/issues/623)
6
+
7
+ ## [0.163.0](https://github.com/nanobpm/nano-workforce/compare/v0.162.2...v0.163.0) (2026-08-30)
8
+
9
+ ### Features
10
+
11
+ * **reconcile:** engine-reset reconciliation surface for inflight work ([#622](https://github.com/nanobpm/nano-workforce/issues/622)) ([#627](https://github.com/nanobpm/nano-workforce/issues/627)) ([a10ba65](https://github.com/nanobpm/nano-workforce/commit/a10ba650829b15228e35eb45f08ecef98eff69d8)), closes [Magikcraft/nano-bpm#1065](https://github.com/Magikcraft/nano-bpm/issues/1065)
12
+
1
13
  ## [0.162.2](https://github.com/nanobpm/nano-workforce/compare/v0.162.1...v0.162.2) (2026-08-30)
2
14
 
3
15
  ### Bug Fixes
package/SPEC.md CHANGED
@@ -283,7 +283,7 @@ child grids and a lazily-loaded transcript. The round/escalation grids are
283
283
  read-only audit. Open native user-task escalations are additionally resolved
284
284
  app-side from the **Tasks** page (`pages/tasks.page.json`, issue #236) — a nav
285
285
  tab whose per-kind `dataGrid`s list every open escalation (feature / plan-review
286
- / trial-merge / PR review / blocked-run) off the `user_tasks` read-model and
286
+ / empty-plan / trial-merge / PR review / blocked-run) off the `user_tasks` read-model and
287
287
  submit the typed decision to the canonical human completer — so an operator no
288
288
  longer depends on Urban's read-only `taskInbox` stub at `/tasks`.
289
289
 
@@ -519,7 +519,19 @@ Start(issue) → plan → record-plan → implement (parallel MI) → record-res
519
519
  - **`record-plan`** — app worker `pr.record-plan`. Normalizes the tasks (assigns a
520
520
  stable `id`/index), writes one `plan_tasks` row each, sets `plans.task_count` and
521
521
  status `dispatched`, and **re-emits** the normalized `tasks` so the fan-out
522
- iterates the canonical list.
522
+ iterates the canonical list. It also emits `taskCount`, which the `gw-plan-empty`
523
+ gateway reads: a taskful plan proceeds to plan-review; an **empty plan**
524
+ (`{tasks:[]}`) is neither auto-terminated (which rendered "Done" over a still-live
525
+ instance, #624) nor fed into the adversarial plan-review loop (a plan↔plan-review
526
+ livelock, #623) — instead it parks at the **`empty-plan-escalation`** operator user
527
+ task for a human directive: **Accept** a legitimate no-op epic (→ the terminal
528
+ `EndTasklessDone` end; the poller reconciles the COMPLETED instance to `done`) or
529
+ **Revise** (→ back to `plan` to re-plan, folding the operator's `notes` into
530
+ `planFindings` so the guidance reaches the re-plan's `appendPrompt`, mirroring
531
+ `plan-review-decision`). An empty plan stays NON-terminal
532
+ (`planning`) with its planner `note` as the `outcome` while parked — terminal
533
+ status follows engine liveness via `pollTasklessPlanTermination`, never the
534
+ empty-plan signal (#624).
523
535
  - **`implement`** — service task, job type `senior:feature`, **parallel
524
536
  multi-instance** over `=tasks` (`inputElement="task"`,
525
537
  `outputCollection="results"`). Its base prompt is delivered via the `feature.md`
@@ -257,6 +257,35 @@ test("conformance-escalation is HUMAN-completable but NOT agent-completable (iss
257
257
  assertEquals(completed[0].variables, { note: "filed follow-up" });
258
258
  });
259
259
 
260
+ test("empty-plan-escalation is HUMAN-completable but NOT agent-completable (issues #623/#624)", async () => {
261
+ // The empty-plan operator decision mirrors feature-blocked/conformance: a HUMAN operator adjudicates
262
+ // whether an empty plan is a legitimate no-op (accept) or needs re-planning (revise), through
263
+ // `completeEscalationAsHuman`. It stays OUTSIDE the agent surface (`ESCALATION_TASK_ELEMENTS`) — the
264
+ // fleet must never silently auto-resolve the very "no work was produced" case a human must attend.
265
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
266
+ const data = memData(stores);
267
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-e", elementId: "empty-plan-escalation" }]);
268
+
269
+ const asAgent = await completeEscalationAsAgent(data, engine, {
270
+ userTaskKey: "ut-e",
271
+ agentId: "bot",
272
+ variables: { directive: "accept" },
273
+ });
274
+ assertEquals(asAgent.ok, false, "the agent completer refuses empty-plan-escalation");
275
+ assertEquals(asAgent.reason, "not a completable task");
276
+ assertEquals(completed.length, 0);
277
+
278
+ const asHuman = await completeEscalationAsHuman(data, engine, {
279
+ userTaskKey: "ut-e",
280
+ operatorId: "alice",
281
+ variables: { directive: "revise", notes: "look again" },
282
+ });
283
+ assertEquals(asHuman.ok, true, "the human completer retires empty-plan-escalation");
284
+ assertEquals(asHuman.elementId, "empty-plan-escalation");
285
+ assertEquals(completed.length, 1);
286
+ assertEquals(completed[0].variables, { directive: "revise", notes: "look again" });
287
+ });
288
+
260
289
  test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
261
290
  const stores = { task_completions: { rows: [] as any[], key: "id" } };
262
291
  const data = memData(stores);
@@ -543,6 +572,10 @@ test("validateEscalationVariables derives its contract from the canonical .form
543
572
  // plan-review-decision -> directive required, allowed proceed/revise
544
573
  assertEquals(validateEscalationVariables("plan-review-decision", { directive: "proceed" }), null);
545
574
  assert(validateEscalationVariables("plan-review-decision", { directive: "" }) !== null);
575
+ // empty-plan-escalation -> directive required, allowed accept/revise
576
+ assertEquals(validateEscalationVariables("empty-plan-escalation", { directive: "accept" }), null);
577
+ assertEquals(validateEscalationVariables("empty-plan-escalation", { directive: "revise" }), null);
578
+ assert(validateEscalationVariables("empty-plan-escalation", { directive: "" }) !== null);
546
579
  // an element with no linked form contract is not enforced
547
580
  assertEquals(validateEscalationVariables("some-other-task", { whatever: 1 }), null);
548
581
  });
@@ -24,7 +24,7 @@ import { readFileSync } from "node:fs";
24
24
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
25
25
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
26
26
  import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
27
- import { ACP_PERMISSION_ELEMENT } from "./userTasks.ts";
27
+ import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT } from "./userTasks.ts";
28
28
 
29
29
  const now = () => new Date().toISOString();
30
30
 
@@ -91,6 +91,14 @@ export const FEATURE_BLOCKED_TASK_ELEMENT = "feature-blocked";
91
91
  * `CONFORMANCE_ESCALATION_ELEMENT` (app/conformance.ts) — one source of truth, no drift surface. */
92
92
  export const CONFORMANCE_ESCALATION_TASK_ELEMENT = CONFORMANCE_ESCALATION_ELEMENT;
93
93
 
94
+ /** The `empty-plan-escalation` operator user-task element id (plan-fanout.bpmn) — the native decision a
95
+ * plan-fanout run parks on when the planner emits an EMPTY plan. Like `feature-blocked` and
96
+ * `conformance-escalation` it is a HUMAN-only operator decision (never agent-answerable, or the fleet
97
+ * could silently auto-resolve the very "no work was produced" case a human must adjudicate), so it
98
+ * lives OUTSIDE `ESCALATION_TASK_ELEMENTS` and only the HUMAN completer accepts it (issues #623/#624).
99
+ * Re-exported from the canonical `EMPTY_PLAN_ELEMENT` (app/userTasks.ts) — one source of truth. */
100
+ export const EMPTY_PLAN_TASK_ELEMENT = EMPTY_PLAN_ELEMENT;
101
+
94
102
  /** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
95
103
  * `complete-user-task` door: every agent-answerable escalation PLUS the human-only `feature-blocked`
96
104
  * and `conformance-escalation` acknowledgements, PLUS the advisory ACP permission prompt
@@ -104,6 +112,7 @@ export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
104
112
  ...ESCALATION_TASK_ELEMENTS,
105
113
  FEATURE_BLOCKED_TASK_ELEMENT,
106
114
  CONFORMANCE_ESCALATION_TASK_ELEMENT,
115
+ EMPTY_PLAN_TASK_ELEMENT,
107
116
  ACP_PERMISSION_ELEMENT,
108
117
  ]);
109
118
 
@@ -117,6 +126,7 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
117
126
  "wait-answer": "pr-escalation",
118
127
  "wait-merge-answer": "pr-escalation",
119
128
  "feature-blocked": "feature-blocked",
129
+ [EMPTY_PLAN_TASK_ELEMENT]: "empty-plan-escalation",
120
130
  [CONFORMANCE_ESCALATION_TASK_ELEMENT]: "conformance-escalation",
121
131
  // NOTE: the delivery-graph `human` node (`DELIVERY_HUMAN_ELEMENT`, ADR 0005 S3) is intentionally
122
132
  // ABSENT here. Unlike the fixed-form escalations above, ONE `delivery-human-task` element is DESIGNED
@@ -56,7 +56,21 @@ export function resolveEngineAddress(
56
56
  * stock Camunda 8 gateway, which returns the same shape without it.
57
57
  */
58
58
  export interface TopologyProbe {
59
- nano?: { engine?: string; version?: string; falconPath?: string } | null;
59
+ nano?:
60
+ | {
61
+ engine?: string;
62
+ version?: string;
63
+ falconPath?: string;
64
+ /** Monotonic incarnation / epoch id the engine stamps at boot and re-mints on a
65
+ * reset/restore/rewind (companion to the versioned snapshot envelope,
66
+ * Magikcraft/nano-bpm#1068). The app persists the last-seen value; a REGRESSION is the
67
+ * robust "engine was reset → reconcile" signal (issue #622, app/reconcile.ts). */
68
+ incarnation?: number | string;
69
+ /** Alias for {@link incarnation} — accepted so the app tolerates either spelling the engine
70
+ * status endpoint settles on without a code change. */
71
+ epoch?: number | string;
72
+ }
73
+ | null;
60
74
  gatewayVersion?: string;
61
75
  }
62
76
 
@@ -15,6 +15,8 @@ test("deriveEpicPhase maps each spine element to its domain phase", () => {
15
15
  assertEquals(deriveEpicPhase("review-plan"), EPIC_PHASE.REVIEWING);
16
16
  assertEquals(deriveEpicPhase("record-plan-review"), EPIC_PHASE.REVIEWING);
17
17
  assertEquals(deriveEpicPhase("plan-review-decision"), EPIC_PHASE.REVIEWING);
18
+ // The empty-plan operator escalation (gw-plan-empty) is a planning-stage decision (Accept/Revise).
19
+ assertEquals(deriveEpicPhase("empty-plan-escalation"), EPIC_PHASE.PLANNING);
18
20
  // Trial-merge band.
19
21
  assertEquals(deriveEpicPhase("trial-merge"), EPIC_PHASE.TRIAL_MERGING);
20
22
  assertEquals(deriveEpicPhase("record-trial-merge"), EPIC_PHASE.TRIAL_MERGING);
package/app/epicPhase.ts CHANGED
@@ -107,6 +107,7 @@ const ELEMENT_PHASE: Readonly<Record<string, string>> = {
107
107
  "ensure-base-branch": EPIC_PHASE.PLANNING,
108
108
  "plan": EPIC_PHASE.PLANNING,
109
109
  "record-plan": EPIC_PHASE.REVIEWING,
110
+ "empty-plan-escalation": EPIC_PHASE.PLANNING,
110
111
  "review-plan": EPIC_PHASE.REVIEWING,
111
112
  "record-plan-review": EPIC_PHASE.REVIEWING,
112
113
  "plan-review-decision": EPIC_PHASE.REVIEWING,
@@ -63,6 +63,23 @@ export function activeStatusesFor(table: string): readonly string[] {
63
63
  return binding.activeStatuses;
64
64
  }
65
65
 
66
+ /** A tracked table's engine-instance key column (the `keyField` in nano.app.json — e.g.
67
+ * `process_key`), the single source of truth the app-side reconcile probes/orphans by so it can
68
+ * never drift from the reconciler's notion of "which column holds the engine instance key". */
69
+ export function keyFieldFor(table: string): string {
70
+ return trackingBindingFor(table).keyField;
71
+ }
72
+
73
+ /** Every `instanceTracking` binding — the full registry of ENGINE-BACKED base tables (each row is
74
+ * projected off a live engine process instance keyed by `keyField`). The app-side engine-reset
75
+ * reconcile (app/reconcile.ts) scans exactly this set: a row whose `statusField` is still in the
76
+ * binding's `activeStatuses` and whose `keyField` is populated is non-terminal engine-backed work,
77
+ * the only surface reconcile may drive to `orphaned`. Terminal rows and non-engine-backed surfaces
78
+ * (presence, append-only audit) are, by construction, not in this set and are never touched. */
79
+ export function engineBackedBindings(): readonly InstanceTracking[] {
80
+ return INSTANCE_TRACKING_BINDINGS;
81
+ }
82
+
66
83
  /** The managed derived read-model VIEW name + effective-status column for a base table, resolved by
67
84
  * urban's OWN target resolver so the app never drifts from the framework's `<table>__tracking` /
68
85
  * `derived_status` naming (ADR 0065). */
@@ -0,0 +1,188 @@
1
+ // Coverage for `pollTasklessPlanTermination` (issue #624) — the poll pass that owns a TASKLESS plan's
2
+ // COMPLETED → `done` transition from ENGINE truth, so terminal `plans.status` follows engine instance
3
+ // liveness instead of the retired "record-plan with task_count = 0 ⇒ done" heuristic (which rendered
4
+ // the epic "Done" over a still-active, in fact looping, plan-fanout instance).
5
+ //
6
+ // Booted against the real provisioned SQLite data layer (so the `plans` table exists) with a stubbed
7
+ // `searchProcessInstances`, proving: a taskless plan whose instance is STILL ACTIVE stays non-terminal
8
+ // (the core acceptance guarantee); it flips to `done` only once the instance reads COMPLETED; a
9
+ // TASKFUL plan is never touched (its `record-results` finalizer owns `done`); a plan with no engine
10
+ // instance is skipped; and an already-`done` plan is left alone.
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join, resolve } from "node:path";
14
+ import { test } from "node:test";
15
+ import { assertEquals } from "#test-assert";
16
+ import type { DataLayer } from "@nanobpm/urban";
17
+ import { bootTestApp } from "@nanobpm/urban-testkit";
18
+ import { plans } from "./plan.ts";
19
+ import { pollTasklessPlanTermination } from "./service.ts";
20
+ import { withTrackingViews } from "../test/trackingViews.ts";
21
+
22
+ const APP_ROOT = resolve(import.meta.dirname, "..");
23
+
24
+ async function withData(fn: (data: DataLayer) => Promise<void>): Promise<void> {
25
+ const dir = mkdtempSync(join(tmpdir(), "nwf-taskless-term-"));
26
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
27
+ try {
28
+ await fn(app.db);
29
+ } finally {
30
+ await app.stop?.();
31
+ rmSync(dir, { recursive: true, force: true });
32
+ }
33
+ }
34
+
35
+ const now = () => new Date().toISOString();
36
+
37
+ async function seedPlan(
38
+ data: DataLayer,
39
+ over: { status?: string; process_key?: string | null; task_count?: number; outcome?: string | null } = {},
40
+ ) {
41
+ await plans(data).insert({
42
+ plan_key: "owner/repo#7",
43
+ repo: "owner/repo",
44
+ issue_number: 7,
45
+ issue_url: "https://github.com/owner/repo/issues/7",
46
+ title: "Epic",
47
+ status: over.status ?? "planning",
48
+ task_count: over.task_count ?? 0,
49
+ outcome: "outcome" in over ? over.outcome : "planner emitted no tasks",
50
+ process_key: "process_key" in over ? over.process_key : "pi-1",
51
+ created_at: now(),
52
+ updated_at: now(),
53
+ } as never);
54
+ }
55
+
56
+ test("pollTasklessPlanTermination leaves a taskless plan NON-terminal while its instance is ACTIVE", async () => {
57
+ await withData(async (data) => {
58
+ await seedPlan(data, { status: "planning", task_count: 0 });
59
+ const engine = { searchProcessInstances: async () => [{ processInstanceKey: "pi-1", state: "ACTIVE" }] };
60
+ await pollTasklessPlanTermination(data, engine as never);
61
+ // The core acceptance guarantee: a task_count = 0 plan is not terminal while its instance runs.
62
+ assertEquals((await plans(data).get("owner/repo#7"))?.status, "planning");
63
+ });
64
+ });
65
+
66
+ test("pollTasklessPlanTermination flips a taskless plan to done once its instance reads COMPLETED", async () => {
67
+ await withData(async (data) => {
68
+ await seedPlan(data, { status: "planning", task_count: 0 });
69
+ const engine = { searchProcessInstances: async () => [{ processInstanceKey: "pi-1", state: "COMPLETED" }] };
70
+ await pollTasklessPlanTermination(data, engine as never);
71
+ const row = await plans(data).get("owner/repo#7");
72
+ assertEquals(row?.status, "done");
73
+ assertEquals(row?.outcome, "planner emitted no tasks");
74
+ });
75
+ });
76
+
77
+ test("pollTasklessPlanTermination matches a numeric engine processInstanceKey against the string process_key", async () => {
78
+ await withData(async (data) => {
79
+ await seedPlan(data, { status: "planning", task_count: 0, process_key: "12345" });
80
+ const engine = { searchProcessInstances: async () => [{ processInstanceKey: 12345, state: "COMPLETED" }] };
81
+ await pollTasklessPlanTermination(data, engine as never);
82
+ assertEquals((await plans(data).get("owner/repo#7"))?.status, "done");
83
+ });
84
+ });
85
+
86
+ test("pollTasklessPlanTermination never touches a TASKFUL plan (record-results owns its done)", async () => {
87
+ await withData(async (data) => {
88
+ await seedPlan(data, { status: "dispatched", task_count: 3, outcome: null });
89
+ let called = false;
90
+ const engine = {
91
+ searchProcessInstances: async () => {
92
+ called = true;
93
+ return [{ processInstanceKey: "pi-1", state: "COMPLETED" }];
94
+ },
95
+ };
96
+ await pollTasklessPlanTermination(data, engine as never);
97
+ assertEquals(called, false);
98
+ assertEquals((await plans(data).get("owner/repo#7"))?.status, "dispatched");
99
+ });
100
+ });
101
+
102
+ test("pollTasklessPlanTermination skips a taskless plan that has no engine instance yet", async () => {
103
+ await withData(async (data) => {
104
+ await seedPlan(data, { status: "planning", task_count: 0, process_key: null });
105
+ let called = false;
106
+ const engine = {
107
+ searchProcessInstances: async () => {
108
+ called = true;
109
+ return [];
110
+ },
111
+ };
112
+ await pollTasklessPlanTermination(data, engine as never);
113
+ assertEquals(called, false);
114
+ assertEquals((await plans(data).get("owner/repo#7"))?.status, "planning");
115
+ });
116
+ });
117
+
118
+ test("pollTasklessPlanTermination never re-touches an already-terminal plan", async () => {
119
+ await withData(async (data) => {
120
+ await seedPlan(data, { status: "done", task_count: 0 });
121
+ let called = false;
122
+ const engine = {
123
+ searchProcessInstances: async () => {
124
+ called = true;
125
+ return [];
126
+ },
127
+ };
128
+ await pollTasklessPlanTermination(data, engine as never);
129
+ // `done` is not in EPIC_LIVE_STATUSES, so the pass never queries the engine for it.
130
+ assertEquals(called, false);
131
+ });
132
+ });
133
+
134
+ // A taskless plan whose instance TERMINATED out of band keeps its base `status = planning` (the
135
+ // worker-owned transient the reconciler no longer overwrites) while the ADR-0065 tracking VIEW folds
136
+ // its `derived_status` to `abandoned`. A base-`status` scan would keep re-querying the engine for that
137
+ // already-dead row every pass forever; the pass MUST read `derived_status` off `plansTracking` and skip
138
+ // it. Modelled with the `withTrackingViews` fake so the base `status` and derived `derived_status`
139
+ // diverge exactly as the real terminated instance produces, without a live engine.
140
+ // biome-ignore lint/suspicious/noExplicitAny: test-only fake over dynamic row shapes.
141
+ function trackedMemData(): DataLayer {
142
+ // biome-ignore lint/suspicious/noExplicitAny: test-only dynamic row store.
143
+ const store: any[] = [];
144
+ const tbl = (_name: string, pk = "plan_key") => ({
145
+ // biome-ignore lint/suspicious/noExplicitAny: test-only dynamic row.
146
+ async insert(row: any) {
147
+ store.push({ ...row });
148
+ return row[pk];
149
+ },
150
+ async get(key: unknown) {
151
+ return store.find((r) => r[pk] === key);
152
+ },
153
+ async find(where: Record<string, unknown>) {
154
+ return store.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
155
+ },
156
+ // biome-ignore lint/suspicious/noExplicitAny: test-only patch.
157
+ async update(key: unknown, patch: any) {
158
+ const r = store.find((x) => x[pk] === key);
159
+ if (r) Object.assign(r, patch);
160
+ },
161
+ });
162
+ // biome-ignore lint/suspicious/noExplicitAny: test-only fake DataLayer.
163
+ return { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) } as any as DataLayer;
164
+ }
165
+
166
+ test("pollTasklessPlanTermination skips a derive-only-terminal (TERMINATED) taskless plan without querying the engine", async () => {
167
+ const data = trackedMemData();
168
+ // Base `status` still reads live `planning` (frozen transient), but the instance terminated out of
169
+ // band so the tracking VIEW's `derived_status` is `abandoned`.
170
+ await plans(data).insert({
171
+ plan_key: "owner/repo#7",
172
+ status: "planning",
173
+ derived_status: "abandoned",
174
+ task_count: 0,
175
+ process_key: "pi-1",
176
+ } as never);
177
+ let called = false;
178
+ const engine = {
179
+ searchProcessInstances: async () => {
180
+ called = true;
181
+ return [{ processInstanceKey: "pi-1", state: "COMPLETED" }];
182
+ },
183
+ };
184
+ await pollTasklessPlanTermination(data, engine as never);
185
+ // The regression guard: a base-status scan would re-query the engine here forever.
186
+ assertEquals(called, false);
187
+ assertEquals((await plans(data).get("owner/repo#7"))?.status, "planning");
188
+ });
@@ -0,0 +1,212 @@
1
+ // Red/green coverage for the app-side engine-reset reconciliation surface (issue #622).
2
+ //
3
+ // The core scenario the incident (Magikcraft/nano-bpm#1065) demanded a supported remedy for: the
4
+ // engine is reset and its incarnation epoch REGRESSES, while `app.db` still projects engine-backed
5
+ // inflight work (an active `feature_runs`/`delivery_graph_runs`/… row keyed on a now-dead
6
+ // `process_key`). Reconcile must drive exactly those rows to the defined `orphaned` terminal WITH
7
+ // PROVENANCE, leave terminal history + non-engine-backed rows untouched, and be idempotent.
8
+ //
9
+ // These run against the REAL migration set (092 applied to an in-memory SQLite via urban's own
10
+ // `makeGateway`), so the tables/columns/indexes reconcile reads and writes are the shipping schema.
11
+ import { DatabaseSync } from "node:sqlite";
12
+ import { test } from "node:test";
13
+ import { assertEquals } from "#test-assert";
14
+ import { freshData } from "../test/reconcileDb.ts";
15
+ import {
16
+ ORPHANED_STATUS,
17
+ parseEngineEpoch,
18
+ RECONCILE_ORPHAN_REASON,
19
+ reconcileEngineBackedWork,
20
+ } from "./reconcile.ts";
21
+
22
+ const AT = () => new Date("2026-02-02T00:00:00.000Z");
23
+
24
+ function seedFeatureRun(raw: DatabaseSync, key: string, status: string, processKey: string | null): void {
25
+ raw
26
+ .prepare(
27
+ `INSERT INTO feature_runs (feature_key, repo, issue_number, issue_url, base_branch, status, process_key, created_at, updated_at)
28
+ VALUES (?, 'o/r', 1, 'https://x', 'main', ?, ?, '2026-01-01', '2026-01-01')`,
29
+ )
30
+ .run(key, status, processKey);
31
+ }
32
+
33
+ function seedDeliveryGraphRun(raw: DatabaseSync, runKey: string, status: string, processKey: string | null): void {
34
+ raw
35
+ .prepare(
36
+ `INSERT INTO delivery_graph_runs (run_key, process_key, digest, status, created_at, updated_at)
37
+ VALUES (?, ?, 'deadbeef', ?, '2026-01-01', '2026-01-01')`,
38
+ )
39
+ .run(runKey, processKey, status);
40
+ }
41
+
42
+ test("parseEngineEpoch reads nano.incarnation (or its epoch alias), else null", () => {
43
+ assertEquals(parseEngineEpoch({ nano: { incarnation: 7 } }), 7);
44
+ assertEquals(parseEngineEpoch({ nano: { epoch: "9" } }), 9);
45
+ assertEquals(parseEngineEpoch({ nano: { engine: "nano" } }), null);
46
+ assertEquals(parseEngineEpoch({ gatewayVersion: "8.6" }), null);
47
+ assertEquals(parseEngineEpoch(null), null);
48
+ });
49
+
50
+ test("first observation SEEDS the epoch without orphaning anything", async () => {
51
+ const { data, raw } = freshData();
52
+ seedFeatureRun(raw, "o/r#1", "running", "pk-1");
53
+
54
+ const res = await reconcileEngineBackedWork(data, { reachable: true, epoch: 5 }, { now: AT, runId: "run-seed" });
55
+
56
+ assertEquals(res.reason, "seed-epoch");
57
+ assertEquals(res.orphanedCount, 0);
58
+ const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='o/r#1'").get() as { status: string };
59
+ assertEquals(row.status, "running");
60
+ const rec = raw.prepare("SELECT epoch FROM engine_incarnation WHERE id=1").get() as { epoch: number };
61
+ assertEquals(rec.epoch, 5);
62
+ });
63
+
64
+ test("RED→GREEN: an epoch regression orphans dangling inflight rows with provenance", async () => {
65
+ const { data, raw } = freshData();
66
+ await reconcileEngineBackedWork(data, { reachable: true, epoch: 10 }, { now: AT, runId: "run-0" });
67
+ seedFeatureRun(raw, "o/r#1", "running", "41");
68
+ seedDeliveryGraphRun(raw, "graph-1", "running", "77");
69
+
70
+ // The engine was reset/rewound: its incarnation epoch regressed 10 → 2.
71
+ const res = await reconcileEngineBackedWork(data, { reachable: true, epoch: 2 }, { now: AT, runId: "run-1" });
72
+
73
+ assertEquals(res.reason, "epoch-regression");
74
+ assertEquals(res.orphanedCount, 2);
75
+
76
+ const fr = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='o/r#1'").get() as { status: string };
77
+ assertEquals(fr.status, ORPHANED_STATUS);
78
+ const dg = raw.prepare("SELECT status FROM delivery_graph_runs WHERE run_key='graph-1'").get() as { status: string };
79
+ assertEquals(dg.status, ORPHANED_STATUS);
80
+
81
+ const prov = raw
82
+ .prepare("SELECT * FROM reconcile_provenance WHERE source_table='feature_runs'")
83
+ .get() as Record<string, unknown>;
84
+ assertEquals(prov.to_status, ORPHANED_STATUS);
85
+ assertEquals(prov.from_status, "running");
86
+ assertEquals(prov.reason, RECONCILE_ORPHAN_REASON);
87
+ assertEquals(prov.observed_epoch, 2);
88
+ assertEquals(prov.run_id, "run-1");
89
+ assertEquals(prov.key_value, "41");
90
+
91
+ const run = raw.prepare("SELECT reason, orphaned_count FROM reconcile_runs WHERE run_id='run-1'").get() as {
92
+ reason: string;
93
+ orphaned_count: number;
94
+ };
95
+ assertEquals(run.reason, "epoch-regression");
96
+ assertEquals(run.orphaned_count, 2);
97
+ const rec = raw.prepare("SELECT epoch FROM engine_incarnation WHERE id=1").get() as { epoch: number };
98
+ assertEquals(rec.epoch, 2);
99
+ });
100
+
101
+ test("terminal history and rows without a process_key are NEVER touched", async () => {
102
+ const { data, raw } = freshData();
103
+ await reconcileEngineBackedWork(data, { reachable: true, epoch: 10 }, { now: AT, runId: "run-0" });
104
+ seedFeatureRun(raw, "term#1", "merged", "88"); // terminal — not in activeStatuses
105
+ seedFeatureRun(raw, "await#1", "opened", "89"); // terminal-for-tracking
106
+ seedFeatureRun(raw, "nokeed#1", "running", null); // active but no engine key
107
+
108
+ const res = await reconcileEngineBackedWork(data, { reachable: true, epoch: 2 }, { now: AT, runId: "run-1" });
109
+
110
+ assertEquals(res.orphanedCount, 0);
111
+ const statuses = raw.prepare("SELECT feature_key, status FROM feature_runs ORDER BY feature_key").all() as {
112
+ feature_key: string;
113
+ status: string;
114
+ }[];
115
+ assertEquals(statuses.find((r) => r.feature_key === "term#1")?.status, "merged");
116
+ assertEquals(statuses.find((r) => r.feature_key === "await#1")?.status, "opened");
117
+ assertEquals(statuses.find((r) => r.feature_key === "nokeed#1")?.status, "running");
118
+ assertEquals((raw.prepare("SELECT COUNT(*) c FROM reconcile_provenance").get() as { c: number }).c, 0);
119
+ });
120
+
121
+ test("idempotent: a second pass with a matching epoch is a no-op", async () => {
122
+ const { data, raw } = freshData();
123
+ await reconcileEngineBackedWork(data, { reachable: true, epoch: 10 }, { now: AT, runId: "run-0" });
124
+ seedFeatureRun(raw, "o/r#1", "running", "41");
125
+
126
+ const first = await reconcileEngineBackedWork(data, { reachable: true, epoch: 2 }, { now: AT, runId: "run-1" });
127
+ assertEquals(first.orphanedCount, 1);
128
+
129
+ const second = await reconcileEngineBackedWork(data, { reachable: true, epoch: 2 }, { now: AT, runId: "run-2" });
130
+ assertEquals(second.reason, "no-op");
131
+ assertEquals(second.orphanedCount, 0);
132
+
133
+ const provCount = raw.prepare("SELECT COUNT(*) c FROM reconcile_provenance").get() as { c: number };
134
+ assertEquals(provCount.c, 1);
135
+ });
136
+
137
+ test("an unreachable engine is a hard no-op — live work is never orphaned", async () => {
138
+ const { data, raw } = freshData();
139
+ await reconcileEngineBackedWork(data, { reachable: true, epoch: 10 }, { now: AT, runId: "run-0" });
140
+ seedFeatureRun(raw, "o/r#1", "running", "41");
141
+
142
+ const res = await reconcileEngineBackedWork(data, { reachable: false, epoch: null }, { now: AT, runId: "run-1" });
143
+ assertEquals(res.reason, "engine-unreachable");
144
+ assertEquals(res.orphanedCount, 0);
145
+
146
+ const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='o/r#1'").get() as { status: string };
147
+ assertEquals(row.status, "running");
148
+ const run = raw.prepare("SELECT reason FROM reconcile_runs WHERE run_id='run-1'").get() as { reason: string };
149
+ assertEquals(run.reason, "engine-unreachable");
150
+ });
151
+
152
+ test("RED→GREEN: a concurrent terminal transition wins — the guarded UPDATE never clobbers it", async () => {
153
+ const { data, raw } = freshData();
154
+ await reconcileEngineBackedWork(data, { reachable: true, epoch: 10 }, { now: AT, runId: "run-0" });
155
+ seedFeatureRun(raw, "o/r#1", "running", "41");
156
+
157
+ // Interpose a writer that flips the row to a newer terminal status AFTER reconcile has SELECTed it
158
+ // as "running" but BEFORE its UPDATE lands — the exact TOCTOU window. With a blind UPDATE-by-pk the
159
+ // reset would clobber `merged` back to `orphaned` (and write provenance); the guarded UPDATE (status
160
+ // re-asserted) sees `res.changed === 0` and leaves the terminal history untouched.
161
+ const gw = data.open();
162
+ let raced = false;
163
+ const wrapTx = (t: { query: (...a: unknown[]) => unknown; exec: (sql: string, params?: unknown[]) => unknown }) => ({
164
+ query: (...a: unknown[]) => t.query(...a),
165
+ exec: (sql: string, params?: unknown[]) => {
166
+ if (!raced && /^UPDATE/.test(sql.trim())) {
167
+ raced = true;
168
+ raw.prepare("UPDATE feature_runs SET status='merged' WHERE feature_key='o/r#1'").run();
169
+ }
170
+ return t.exec(sql, params);
171
+ },
172
+ });
173
+ const wrappedSrc = {
174
+ query: (...a: unknown[]) => (gw as { query: (...a: unknown[]) => unknown }).query(...a),
175
+ exec: (sql: string, params?: unknown[]) => (gw as { exec: (sql: string, params?: unknown[]) => unknown }).exec(sql, params),
176
+ tx: (fn: (t: unknown) => unknown) => (gw as { tx: (f: (t: unknown) => unknown) => unknown }).tx((t) => fn(wrapTx(t as never))),
177
+ };
178
+ const wrapped = { open: () => wrappedSrc } as unknown as DataLayer;
179
+
180
+ const res = await reconcileEngineBackedWork(wrapped, { reachable: true, epoch: 2 }, { now: AT, runId: "run-1" });
181
+
182
+ assertEquals(raced, true);
183
+ assertEquals(res.orphanedCount, 0);
184
+ const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='o/r#1'").get() as { status: string };
185
+ assertEquals(row.status, "merged");
186
+ assertEquals((raw.prepare("SELECT COUNT(*) c FROM reconcile_provenance").get() as { c: number }).c, 0);
187
+ });
188
+
189
+ test("RED→GREEN: orphaning stamps updated_at so the transition timestamp isn't left stale", async () => {
190
+ const { data, raw } = freshData();
191
+ await reconcileEngineBackedWork(data, { reachable: true, epoch: 10 }, { now: AT, runId: "run-0" });
192
+ seedFeatureRun(raw, "o/r#1", "running", "41");
193
+ seedDeliveryGraphRun(raw, "graph-1", "running", "77");
194
+
195
+ // The seeded rows carry updated_at='2026-01-01'; AT() (the reconcile clock) is 2026-02-02. A blind
196
+ // `SET status='orphaned'` would leave updated_at at the stale seed value, misrepresenting when the
197
+ // row was orphaned to the UI/audits. The transition must refresh updated_at like every other one.
198
+ const res = await reconcileEngineBackedWork(data, { reachable: true, epoch: 2 }, { now: AT, runId: "run-1" });
199
+
200
+ assertEquals(res.orphanedCount, 2);
201
+ const at = AT().toISOString();
202
+ const fr = raw
203
+ .prepare("SELECT status, updated_at FROM feature_runs WHERE feature_key='o/r#1'")
204
+ .get() as { status: string; updated_at: string };
205
+ assertEquals(fr.status, ORPHANED_STATUS);
206
+ assertEquals(fr.updated_at, at);
207
+ const dg = raw
208
+ .prepare("SELECT status, updated_at FROM delivery_graph_runs WHERE run_key='graph-1'")
209
+ .get() as { status: string; updated_at: string };
210
+ assertEquals(dg.status, ORPHANED_STATUS);
211
+ assertEquals(dg.updated_at, at);
212
+ });