@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 +12 -0
- package/SPEC.md +14 -2
- package/app/agentCompletion.test.ts +33 -0
- package/app/agentCompletion.ts +11 -1
- package/app/enginePreflight.ts +15 -1
- package/app/epicPhase.test.ts +2 -0
- package/app/epicPhase.ts +1 -0
- package/app/instanceTracking.ts +17 -0
- package/app/pollTasklessPlanTermination.test.ts +188 -0
- package/app/reconcile.test.ts +212 -0
- package/app/reconcile.ts +326 -0
- package/app/service.ts +53 -0
- package/app/userTasks.ts +8 -0
- package/db/migrations/092_engine_reconcile.sql +53 -0
- package/e2e/plan-fanout.e2e.ts +86 -11
- package/main.ts +25 -0
- package/openapi.yaml +92 -0
- package/operations/reconcileEngineState.test.ts +81 -0
- package/operations/reconcileEngineState.ts +44 -0
- package/package.json +1 -1
- package/resources/forms/empty-plan-escalation.form +31 -0
- package/resources/processes/plan-fanout.bpmn +72 -17
- package/test/reconcileDb.ts +61 -0
- package/workers/record-plan/worker.test.ts +8 -4
- package/workers/record-plan/worker.ts +31 -8
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
|
});
|
package/app/agentCompletion.ts
CHANGED
|
@@ -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
|
package/app/enginePreflight.ts
CHANGED
|
@@ -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?:
|
|
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
|
|
package/app/epicPhase.test.ts
CHANGED
|
@@ -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,
|
package/app/instanceTracking.ts
CHANGED
|
@@ -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
|
+
});
|