@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.
@@ -0,0 +1,326 @@
1
+ // nano-workforce — the app-side engine-reset reconciliation surface (issue #622).
2
+ //
3
+ // When the Nano engine is reset, restored, or rolled to an incarnation whose key generator has
4
+ // rewound (Magikcraft/nano-bpm#1065), `app.db` keeps projecting ENGINE-BACKED inflight work that no
5
+ // longer exists on the engine: open user tasks pointing at dead instances, active runs keyed on a
6
+ // `process_key` the fresh engine has re-minted for something unrelated (the release-train run
7
+ // recorded `process_key=41`; the fresh engine re-minted 41 for an unrelated probe). Without a
8
+ // supported way to converge, the app silently trusts stale projections — orphaned human gates and
9
+ // key-collision identity confusion.
10
+ //
11
+ // `reconcile` is the first-class remedy. It runs ON STARTUP (main.ts) and ON DEMAND (the
12
+ // `reconcileEngineState` operator command), scoped NARROWLY to claimed inflight work:
13
+ //
14
+ // • Detection — engine INCARNATION EPOCH (preferred over fragile per-key 404 probing). The engine
15
+ // stamps a monotonic incarnation id at boot and exposes it on `/v2/topology`; the app persists
16
+ // the last-seen value (`engine_incarnation`). An epoch REGRESSION (observed < recorded) — the
17
+ // #1065 rewind signature — or its absence where one was recorded means "engine was reset/rewound
18
+ // → reconcile", ONE cheap check instead of N per-instance probes.
19
+ // • Convergence — for every NON-terminal, engine-backed app row (a nano.app.json instanceTracking
20
+ // binding whose `statusField` is still in its `activeStatuses` set and whose `keyField` is
21
+ // populated), drive the row to the defined `orphaned` terminal WITH PROVENANCE
22
+ // (`reconcile_provenance`: the reason, the observed engine epoch, and the reconcile run id) —
23
+ // instead of trusting a stale projection or silently dropping data.
24
+ // • Guardrails — TERMINAL rows (done/failed/merged/abandoned/…) and append-only / non-engine-backed
25
+ // surfaces (presence, audit, provenance) are NEVER mutated: reconcile only touches rows whose
26
+ // status is in a binding's `activeStatuses`. Every pass is recorded in `reconcile_runs`.
27
+ // • Idempotent — a second pass with a matching epoch is a no-op (nothing regressed, and every
28
+ // already-orphaned row has left its `activeStatuses`, so it is not re-scanned). An UNREACHABLE
29
+ // engine is a no-op too: reconcile NEVER orphans when it could not confirm a reset (a 401/5xx or
30
+ // a network error yields `reachable:false`, not a false "engine missing").
31
+ //
32
+ // The provenance is app-owned (not urban's `_urban_write_provenance`, which is a domain-free
33
+ // insert-join sidecar written only inside a job): reconcile runs at boot / over HTTP, outside any
34
+ // job, and needs to record the REASON + epoch + run id — which the app-owned `reconcile_provenance`
35
+ // table carries, and the existing `app.db` backup convention makes the whole mutation reversible.
36
+
37
+ import type { DataLayer, GatewayDataSource as DataSource } from "@nanobpm/urban";
38
+ import type { TopologyProbe } from "./enginePreflight.ts";
39
+ import { activeStatusesFor, baseStatusFieldFor, engineBackedBindings, keyFieldFor } from "./instanceTracking.ts";
40
+
41
+ /** The defined terminal state a reset-orphaned engine-backed row is driven to. Deliberately DISTINCT
42
+ * from a binding's natural terminal (`abandoned`/`failed`/…) so an operator can tell a row that was
43
+ * orphaned by an engine reset apart from one that drained normally. Not in any binding's
44
+ * `activeStatuses`, so an orphaned row is never re-scanned (idempotency) nor re-polled by the urban
45
+ * instance-tracking reconciler. */
46
+ export const ORPHANED_STATUS = "orphaned";
47
+
48
+ /** The provenance reason stamped on every orphaned transition: the engine was reset/rewound and the
49
+ * recorded incarnation epoch regressed (the #1065 signature). */
50
+ export const RECONCILE_ORPHAN_REASON = "engine-reset/epoch-regression";
51
+
52
+ /** The single-row epoch ledger + its append-only run/provenance sidecars (migration 092). */
53
+ const INCARNATION_TABLE = "engine_incarnation";
54
+ const RUNS_TABLE = "reconcile_runs";
55
+ const PROVENANCE_TABLE = "reconcile_provenance";
56
+ /** The conventional last-touched timestamp column stamped on every status transition; orphaning
57
+ * refreshes it too, but only on the tables that actually declare it (introspected per binding). */
58
+ const UPDATED_AT_COLUMN = "updated_at";
59
+
60
+ /** What a `/v2/topology` epoch probe observed. `reachable:false` means the engine could not be
61
+ * confirmed (network error, or a non-2xx like 401/5xx) — reconcile then does NOTHING, so a
62
+ * transient outage can never be mistaken for a reset and orphan live work. `reachable:true` with a
63
+ * null `epoch` means the engine answered but exposes no incarnation id (e.g. a stock Camunda 8
64
+ * gateway, or before Magikcraft/nano-bpm#1068 ships). That null is a no-op ONLY when no epoch was
65
+ * ever recorded; if a concrete epoch WAS recorded, a now-null observation reads as a regression
66
+ * ("the epoch disappeared" — the reset signature), so reconcile orphans inflight work. See the
67
+ * decision table on {@link reconcileEngineBackedWork}. */
68
+ export interface EngineEpochObservation {
69
+ reachable: boolean;
70
+ epoch: number | null;
71
+ }
72
+
73
+ /** Why a reconcile pass acted (or did not). */
74
+ export type ReconcileReason = "epoch-regression" | "seed-epoch" | "no-op" | "engine-unreachable";
75
+
76
+ /** One orphaned engine-backed row. */
77
+ export interface OrphanedRow {
78
+ table: string;
79
+ pk: string;
80
+ key: string | null;
81
+ fromStatus: string;
82
+ }
83
+
84
+ /** The outcome of one reconcile pass — the same shape the run row records and the operator command
85
+ * returns. */
86
+ export interface ReconcileResult {
87
+ runId: string;
88
+ reason: ReconcileReason;
89
+ observedEpoch: number | null;
90
+ recordedEpoch: number | null;
91
+ orphanedCount: number;
92
+ orphaned: OrphanedRow[];
93
+ }
94
+
95
+ export interface ReconcileLog {
96
+ info(msg: string): void;
97
+ warn(msg: string): void;
98
+ }
99
+
100
+ export interface ReconcileOptions {
101
+ /** Injectable clock (defaults to `Date`), so tests are deterministic. */
102
+ now?: () => Date;
103
+ /** Injectable run id (defaults to a random UUID). */
104
+ runId?: string;
105
+ /** The data source name to reconcile (defaults to the DataLayer's default source). */
106
+ sourceName?: string;
107
+ log?: ReconcileLog;
108
+ }
109
+
110
+ /** Read the incarnation epoch out of a `/v2/topology` body — `nano.incarnation` (or its `epoch`
111
+ * alias), coerced from a number or a numeric string. Any other shape (absent, non-numeric) yields
112
+ * null: "the engine exposes no epoch". A null is a no-op ONLY when no epoch was previously recorded;
113
+ * when one WAS recorded, reconcile reads a now-null observation as a regression ("epoch disappeared"),
114
+ * not a no-op — see the decision table on {@link reconcileEngineBackedWork}. */
115
+ export function parseEngineEpoch(body: TopologyProbe | null | undefined): number | null {
116
+ const raw = body?.nano?.incarnation ?? body?.nano?.epoch;
117
+ if (raw == null) return null;
118
+ const n = typeof raw === "number" ? raw : Number(raw);
119
+ return Number.isFinite(n) ? n : null;
120
+ }
121
+
122
+ /** Probe `/v2/topology` for the engine incarnation epoch. Never throws: a network error or a non-2xx
123
+ * yields `reachable:false` (reconcile then does nothing), so an outage can never orphan live work. */
124
+ export async function probeEngineEpoch(
125
+ restAddress: string,
126
+ opts: { token?: string; fetchImpl?: typeof fetch; timeoutMs?: number } = {},
127
+ ): Promise<EngineEpochObservation> {
128
+ const fetchImpl = opts.fetchImpl ?? fetch;
129
+ const url = `${restAddress.replace(/\/+$/, "")}/topology`;
130
+ const headers: Record<string, string> = { accept: "application/json" };
131
+ if (opts.token) headers.authorization = `Bearer ${opts.token}`;
132
+ try {
133
+ const res = await fetchImpl(url, {
134
+ headers,
135
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 3000),
136
+ });
137
+ if (!res.ok) return { reachable: false, epoch: null };
138
+ const body: TopologyProbe = await res.json();
139
+ return { reachable: true, epoch: parseEngineEpoch(body) };
140
+ } catch {
141
+ return { reachable: false, epoch: null };
142
+ }
143
+ }
144
+
145
+ /** Double-quote a SQL identifier (table/column) so a manifest-declared name is safe to interpolate. */
146
+ function q(id: string): string {
147
+ return `"${id.replace(/"/g, '""')}"`;
148
+ }
149
+
150
+ /** The schema of `table` we need to orphan a row: its primary-key column (the first `pk`-flagged
151
+ * column from `PRAGMA table_info`, or `rowid` when the table declares none — so provenance always
152
+ * records a stable row identity) and whether it carries an `updated_at` column to stamp. */
153
+ async function tableShape(src: DataSource, table: string): Promise<{ pkCol: string; hasUpdatedAt: boolean }> {
154
+ const cols = await src.query<{ name: string; pk: number }>(`PRAGMA table_info(${q(table)})`);
155
+ const pk = cols.find((c) => Number(c.pk) > 0);
156
+ return { pkCol: pk?.name ?? "rowid", hasUpdatedAt: cols.some((c) => c.name === UPDATED_AT_COLUMN) };
157
+ }
158
+
159
+ /** The recorded last-seen epoch, or null when none was ever recorded (no row, or a null epoch). */
160
+ async function readRecordedEpoch(src: DataSource): Promise<number | null> {
161
+ const rows = await src.query<{ epoch: number | null }>(
162
+ `SELECT epoch FROM ${INCARNATION_TABLE} WHERE id = 1`,
163
+ );
164
+ const epoch = rows.length ? rows[0].epoch : null;
165
+ return epoch == null ? null : Number(epoch);
166
+ }
167
+
168
+ /** Persist (seed or advance) the last-seen epoch. Only ever called with a concrete number, so a
169
+ * recorded epoch always means "an epoch was actually observed". */
170
+ async function persistEpoch(src: DataSource, epoch: number, at: string): Promise<void> {
171
+ await src.exec(
172
+ `INSERT INTO ${INCARNATION_TABLE} (id, epoch, observed_at) VALUES (1, ?, ?) ` +
173
+ `ON CONFLICT(id) DO UPDATE SET epoch = excluded.epoch, observed_at = excluded.observed_at`,
174
+ [epoch, at],
175
+ );
176
+ }
177
+
178
+ /** Orphan every NON-terminal, engine-backed row across all instanceTracking bindings, recording one
179
+ * `reconcile_provenance` row per transition. Runs inside the caller's transaction. */
180
+ async function orphanEngineBackedRows(
181
+ src: DataSource,
182
+ runId: string,
183
+ observedEpoch: number | null,
184
+ at: string,
185
+ ): Promise<OrphanedRow[]> {
186
+ const orphaned: OrphanedRow[] = [];
187
+ for (const binding of engineBackedBindings()) {
188
+ const table = binding.table;
189
+ // A binding with no active-status selector cannot classify "in-flight" — skip it rather than
190
+ // guess (activeStatusesFor would throw; we tolerate a selector-less binding).
191
+ if (!binding.activeStatuses?.length) continue;
192
+ const active = activeStatusesFor(table);
193
+ const statusField = baseStatusFieldFor(table);
194
+ const keyField = keyFieldFor(table);
195
+ const { pkCol, hasUpdatedAt } = await tableShape(src, table);
196
+ const placeholders = active.map(() => "?").join(", ");
197
+ const rows = await src.query<{ __pk: unknown; __key: unknown; __status: unknown }>(
198
+ `SELECT ${q(pkCol)} AS __pk, ${q(keyField)} AS __key, ${q(statusField)} AS __status ` +
199
+ `FROM ${q(table)} WHERE ${q(statusField)} IN (${placeholders}) AND ${q(keyField)} IS NOT NULL`,
200
+ [...active],
201
+ );
202
+ for (const row of rows) {
203
+ const pk = String(row.__pk);
204
+ const key = row.__key == null ? null : String(row.__key);
205
+ const fromStatus = String(row.__status);
206
+ // GUARDED update: re-assert the exact status we read AND a populated key, so a writer that
207
+ // flipped the row to a newer terminal status (or cleared its key) between the SELECT above and
208
+ // this UPDATE wins the race — we never clobber that terminal history back to `orphaned`. Only a
209
+ // row we actually transitioned (`res.changed > 0`) gets provenance and is counted. We also stamp
210
+ // `updated_at` (when the table has one) so the transition to `orphaned` refreshes the row's
211
+ // timestamp the same way every other status transition in the codebase does — leaving it stale
212
+ // would misrepresent the orphaning moment to the UI/audits.
213
+ const res = await src.exec(
214
+ `UPDATE ${q(table)} SET ${q(statusField)} = ?` +
215
+ (hasUpdatedAt ? `, ${q(UPDATED_AT_COLUMN)} = ?` : "") +
216
+ ` WHERE ${q(pkCol)} = ? AND ${q(statusField)} = ? AND ${q(keyField)} IS NOT NULL`,
217
+ hasUpdatedAt
218
+ ? [ORPHANED_STATUS, at, row.__pk, fromStatus]
219
+ : [ORPHANED_STATUS, row.__pk, fromStatus],
220
+ );
221
+ if (res.changed <= 0) continue;
222
+ await src.exec(
223
+ `INSERT INTO ${PROVENANCE_TABLE} ` +
224
+ `(run_id, source_table, pk_value, key_value, from_status, to_status, reason, observed_epoch, at) ` +
225
+ `VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
226
+ [runId, table, pk, key, fromStatus, ORPHANED_STATUS, RECONCILE_ORPHAN_REASON, observedEpoch, at],
227
+ );
228
+ orphaned.push({ table, pk, key, fromStatus });
229
+ }
230
+ }
231
+ return orphaned;
232
+ }
233
+
234
+ /**
235
+ * Reconcile the app's engine-backed projections against one epoch observation. Pure of I/O beyond the
236
+ * data layer (the topology probe is {@link probeEngineEpoch}, injected as `observation`), so the
237
+ * red/green test drives it with a seeded row + a regressed epoch directly.
238
+ *
239
+ * Decision table (engine reachable):
240
+ * • recorded != null AND (observed == null OR observed < recorded) → REGRESSION: orphan inflight.
241
+ * • recorded == null AND observed != null → SEED: first epoch learned.
242
+ * • otherwise → NO-OP (incl. a matching epoch).
243
+ * The epoch is persisted whenever a concrete one was observed (seed, advance, or the fresh
244
+ * post-rewind incarnation), so the very next pass with that same epoch is a pure no-op.
245
+ *
246
+ * This surface is deliberately EPOCH-SCOPED: it only ever acts on the epoch signal. An engine that
247
+ * is reachable but exposes NO epoch and for which none was ever recorded (observed == null AND
248
+ * recorded == null) is, by contract, an intentional no-op — we have no reset signal to act on, and
249
+ * we never orphan live work speculatively. Converging engine-backed rows against an engine with no
250
+ * epoch support (e.g. via a per-instance existence probe) is out of contract for this surface.
251
+ */
252
+ export async function reconcileEngineBackedWork(
253
+ data: DataLayer,
254
+ observation: EngineEpochObservation,
255
+ opts: ReconcileOptions = {},
256
+ ): Promise<ReconcileResult> {
257
+ const src = data.open(opts.sourceName);
258
+ const at = (opts.now?.() ?? new Date()).toISOString();
259
+ const runId = opts.runId ?? crypto.randomUUID();
260
+
261
+ // An unreachable engine is a hard no-op: we could not confirm a reset, so we NEVER orphan.
262
+ if (!observation.reachable) {
263
+ const recorded = await readRecordedEpoch(src);
264
+ await recordRun(src, { runId, at, observedEpoch: null, recordedEpoch: recorded, reason: "engine-unreachable", orphanedCount: 0 });
265
+ opts.log?.warn("reconcile: engine unreachable — skipped (no rows orphaned; live work left intact).");
266
+ return { runId, reason: "engine-unreachable", observedEpoch: null, recordedEpoch: recorded, orphanedCount: 0, orphaned: [] };
267
+ }
268
+
269
+ const observedEpoch = observation.epoch;
270
+ const recordedEpoch = await readRecordedEpoch(src);
271
+ const regression = recordedEpoch != null && (observedEpoch == null || observedEpoch < recordedEpoch);
272
+
273
+ const result = await src.tx(async (t) => {
274
+ let orphaned: OrphanedRow[] = [];
275
+ let reason: ReconcileReason;
276
+ if (regression) {
277
+ orphaned = await orphanEngineBackedRows(t, runId, observedEpoch, at);
278
+ reason = "epoch-regression";
279
+ } else if (recordedEpoch == null && observedEpoch != null) {
280
+ reason = "seed-epoch";
281
+ } else {
282
+ reason = "no-op";
283
+ }
284
+ if (observedEpoch != null) await persistEpoch(t, observedEpoch, at);
285
+ await recordRun(t, { runId, at, observedEpoch, recordedEpoch, reason, orphanedCount: orphaned.length });
286
+ return { reason, orphaned };
287
+ });
288
+
289
+ if (result.reason === "epoch-regression") {
290
+ opts.log?.warn(
291
+ `reconcile: engine epoch regressed ${recordedEpoch} → ${observedEpoch} (reset/rewind) — ` +
292
+ `orphaned ${result.orphaned.length} inflight row(s) [run ${runId}].`,
293
+ );
294
+ } else if (result.reason === "seed-epoch") {
295
+ opts.log?.info(`reconcile: recorded engine epoch ${observedEpoch} (first observation) [run ${runId}].`);
296
+ } else {
297
+ opts.log?.info(`reconcile: engine epoch ${observedEpoch ?? "n/a"} unchanged — no-op [run ${runId}].`);
298
+ }
299
+
300
+ return { runId, reason: result.reason, observedEpoch, recordedEpoch, orphanedCount: result.orphaned.length, orphaned: result.orphaned };
301
+ }
302
+
303
+ async function recordRun(
304
+ src: DataSource,
305
+ run: { runId: string; at: string; observedEpoch: number | null; recordedEpoch: number | null; reason: ReconcileReason; orphanedCount: number },
306
+ ): Promise<void> {
307
+ await src.exec(
308
+ `INSERT INTO ${RUNS_TABLE} (run_id, started_at, observed_epoch, recorded_epoch, reason, orphaned_count) ` +
309
+ `VALUES (?, ?, ?, ?, ?, ?)`,
310
+ [run.runId, run.at, run.observedEpoch, run.recordedEpoch, run.reason, run.orphanedCount],
311
+ );
312
+ }
313
+
314
+ /** Probe the engine's incarnation epoch, then reconcile — the wiring both startup (main.ts) and the
315
+ * `reconcileEngineState` operator command share, so the two paths can never diverge. */
316
+ export async function runEngineReconcile(
317
+ data: DataLayer,
318
+ engineRest: { restAddress: string; token?: string },
319
+ opts: ReconcileOptions & { fetchImpl?: typeof fetch } = {},
320
+ ): Promise<ReconcileResult> {
321
+ const observation = await probeEngineEpoch(engineRest.restAddress, {
322
+ token: engineRest.token,
323
+ fetchImpl: opts.fetchImpl,
324
+ });
325
+ return reconcileEngineBackedWork(data, observation, opts);
326
+ }
package/app/service.ts CHANGED
@@ -68,6 +68,7 @@ import {
68
68
  type Plan,
69
69
  planReviews,
70
70
  plans,
71
+ plansTracking,
71
72
  planTaskDeps,
72
73
  planTaskNeeds,
73
74
  planTasks,
@@ -2383,6 +2384,57 @@ export async function pollEpicPhase(
2383
2384
  }
2384
2385
  }
2385
2386
 
2387
+ /** Poll pass (issue #624): own the taskless plan's COMPLETED → `done` transition from ENGINE truth.
2388
+ *
2389
+ * A taskless plan (`task_count = 0`, the planner emitted no tasks) is NO LONGER collapsed to terminal
2390
+ * `done` by `record-plan` — "the planner produced nothing this pass" is an INTERMEDIATE state, not an
2391
+ * ended process (the plan-fanout instance is still live and may re-plan, escalate, or be cancelled).
2392
+ * So the plan stays non-terminal (`planning`) until its process instance ACTUALLY ends, and terminal
2393
+ * `plans.status` follows engine instance liveness rather than the empty-plan heuristic that rendered
2394
+ * the epic "Done" over a live (in fact looping) instance.
2395
+ *
2396
+ * This pass owns the COMPLETED → `done` edge for such plans, mirroring the delivery-graph
2397
+ * COMPLETED → `done` transition ({@link pollDeliveryGraphPhase}): `instanceTracking`'s `onTerminated`
2398
+ * edge reconciles only TERMINATED (→ `abandoned` on `derived_status`), never COMPLETED, so a taskless
2399
+ * plan whose instance ends GREEN would otherwise stay `planning` forever. A taskful plan reaches
2400
+ * `done` through its own `record-results` finalizer, so this pass is scoped to `task_count = 0` and to
2401
+ * the live rows only. Liveness is read off the ADR-0065 derived tracking VIEW (`plansTracking`'s
2402
+ * `derived_status`), NOT the base `plans.status`: a taskless plan whose instance TERMINATED out of
2403
+ * band keeps base `status = planning`/`dispatched` while `derived_status` folds to `abandoned`, so a
2404
+ * base-status scan would re-query the engine for that already-dead row every pass forever. Skipping
2405
+ * rows whose `derived_status` is no longer live confines the engine read to genuinely-live plans and
2406
+ * never races a worker-owned terminal. Best-effort +
2407
+ * idempotent: writes only on a real COMPLETED read, so a steady-state pass over a still-active
2408
+ * instance is a no-op (the acceptance guarantee — a taskless plan is never terminal while active). */
2409
+ export async function pollTasklessPlanTermination(
2410
+ data: DataLayer,
2411
+ engine: Pick<EngineClient, "searchProcessInstances">,
2412
+ ) {
2413
+ for (const status of EPIC_LIVE_STATUSES) {
2414
+ for (const plan of await plansTracking(data).find({ status })) {
2415
+ if (plan.task_count !== 0 || !plan.process_key) continue;
2416
+ // Base `status` is live, but the instance may have TERMINATED out of band (folding
2417
+ // `derived_status` → `abandoned`); skip such derive-only-terminal rows so the pass only
2418
+ // queries the engine for genuinely-live plans (ADR-0065).
2419
+ if (!EPIC_LIVE_STATUSES.some((s) => s === plan.derived_status)) continue;
2420
+ const processKey = plan.process_key;
2421
+ try {
2422
+ const snapshots = await engine.searchProcessInstances({ processInstanceKeys: [processKey] });
2423
+ const state = snapshots.find((s) => String(s.processInstanceKey) === processKey)?.state ?? null;
2424
+ if (state === "COMPLETED") {
2425
+ await plans(data).update(plan.plan_key, {
2426
+ status: "done",
2427
+ outcome: plan.outcome ?? "planner emitted no tasks",
2428
+ updated_at: now(),
2429
+ });
2430
+ }
2431
+ } catch (err) {
2432
+ console.error(`[poller] taskless plan termination ${plan.plan_key}: ${err}`);
2433
+ }
2434
+ }
2435
+ }
2436
+ }
2437
+
2386
2438
  /** Poll pass (ADR 0005 slice S5): reconcile each RUNNING delivery-graph run's derived phase from
2387
2439
  * engine truth, and complete it when its instance ends. A delivery graph is a DYNAMIC compiled
2388
2440
  * process with no happy-path host worker, so — unlike `plans`/`feature_runs`, whose spine workers
@@ -2622,6 +2674,7 @@ export async function pollOnce(
2622
2674
  await pollLineage(data);
2623
2675
  await pollUserTasks(data, engine, engineRest);
2624
2676
  await pollEpicPhase(data, engine);
2677
+ await pollTasklessPlanTermination(data, engine);
2625
2678
  await pollDeliveryGraphPhase(data, engine);
2626
2679
  await pollDeliveryProposals(data);
2627
2680
  if (engineRest) {
package/app/userTasks.ts CHANGED
@@ -30,6 +30,13 @@ const now = () => new Date().toISOString();
30
30
  * when the adversarial review loop exhausts its budget without approval. */
31
31
  export const PLAN_REVIEW_ELEMENT = "plan-review-decision";
32
32
 
33
+ /** The empty-plan operator escalation user task (plan-fanout.bpmn) — a human directive (accept/revise)
34
+ * when the planner emits an EMPTY plan (`{tasks:[]}`). Instead of auto-terminating the run from an
35
+ * intermediate signal (which rendered "Done" over a still-live instance, #624) or feeding the empty
36
+ * plan into the adversarial plan-review loop (a plan↔plan-review livelock, #623), the run parks here
37
+ * for operator attention: Accept a legitimate no-op epic (→ terminal done) or Revise (→ re-plan). */
38
+ export const EMPTY_PLAN_ELEMENT = "empty-plan-escalation";
39
+
33
40
  /** The trial-merge escalation user task (plan-fanout.bpmn) — a human decision (proceed/rebase/abandon)
34
41
  * when a wave's trial merge comes back red. */
35
42
  export const TRIAL_MERGE_ELEMENT = "trial-merge-decision";
@@ -87,6 +94,7 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
87
94
  [FEATURE_ESCALATION_ELEMENT]: "Feature escalation",
88
95
  [FEATURE_BLOCKED_ELEMENT]: "Blocked feature run",
89
96
  [PLAN_REVIEW_ELEMENT]: "Plan review",
97
+ [EMPTY_PLAN_ELEMENT]: "Empty plan",
90
98
  [TRIAL_MERGE_ELEMENT]: "Trial merge",
91
99
  [PR_WAIT_ANSWER_ELEMENT]: "PR review",
92
100
  [PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
@@ -0,0 +1,53 @@
1
+ -- Engine-reset reconciliation surface (issue #622).
2
+ --
3
+ -- When the Nano engine is reset, restored, or rolled to an incarnation whose key generator has
4
+ -- rewound (Magikcraft/nano-bpm#1065), `app.db` keeps projecting engine-backed inflight work that no
5
+ -- longer exists on the engine. These three sidecars give the app a first-class, idempotent,
6
+ -- provenance-stamped `reconcile` surface (app/reconcile.ts) that runs on startup and on demand:
7
+ --
8
+ -- • engine_incarnation — the single-row last-seen engine incarnation/epoch id. The engine stamps
9
+ -- a monotonic incarnation id at boot (companion to the versioned snapshot envelope,
10
+ -- Magikcraft/nano-bpm#1068) and exposes it on `/v2/topology`. An epoch REGRESSION (the observed
11
+ -- epoch is lower than the recorded one) — or its absence where one was recorded — is the cheap,
12
+ -- robust "engine was reset/rewound → reconcile" signal, one check instead of N per-instance probes.
13
+ -- • reconcile_runs — one row per reconcile pass: the observed vs recorded epoch, the outcome
14
+ -- reason, and how many rows were orphaned. The append-only audit of every convergence.
15
+ -- • reconcile_provenance— one row per orphaned transition: which engine-backed app row
16
+ -- (source_table, pk_value, its engine instance key) moved from which status to `orphaned`, why
17
+ -- (engine-reset/epoch-regression), the observed engine epoch, and the owning reconcile run id.
18
+ -- This is the provenance that makes each mutation legible and reversible instead of a silent drop.
19
+ --
20
+ -- These are app-owned bookkeeping surfaces; reconcile itself never mutates append-only audit or
21
+ -- already-terminal history — only NON-terminal, engine-backed rows (nano.app.json instanceTracking
22
+ -- bindings) whose status is still in the binding's activeStatuses set.
23
+
24
+ CREATE TABLE IF NOT EXISTS engine_incarnation (
25
+ id INTEGER PRIMARY KEY CHECK (id = 1), -- single-row table: the app only tracks one engine
26
+ epoch INTEGER, -- last-seen engine incarnation/epoch id (NULL until the engine exposes one)
27
+ observed_at TEXT NOT NULL
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS reconcile_runs (
31
+ run_id TEXT PRIMARY KEY,
32
+ started_at TEXT NOT NULL,
33
+ observed_epoch INTEGER, -- the engine epoch observed on this run (NULL when the engine exposes none)
34
+ recorded_epoch INTEGER, -- the previously-recorded epoch (NULL on the first ever run)
35
+ reason TEXT NOT NULL, -- 'epoch-regression' | 'seed-epoch' | 'no-op' | 'engine-unreachable'
36
+ orphaned_count INTEGER NOT NULL DEFAULT 0
37
+ );
38
+
39
+ CREATE TABLE IF NOT EXISTS reconcile_provenance (
40
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ run_id TEXT NOT NULL,
42
+ source_table TEXT NOT NULL, -- the engine-backed base table the orphaned row lives in
43
+ pk_value TEXT NOT NULL, -- the row's primary-key value
44
+ key_value TEXT, -- the engine instance key (keyField, e.g. process_key) the row projected
45
+ from_status TEXT, -- the non-terminal status the row carried before reconcile
46
+ to_status TEXT NOT NULL, -- always the defined 'orphaned' terminal
47
+ reason TEXT NOT NULL, -- 'engine-reset/epoch-regression'
48
+ observed_epoch INTEGER, -- the engine epoch observed when the row was orphaned
49
+ at TEXT NOT NULL
50
+ );
51
+
52
+ CREATE INDEX IF NOT EXISTS ix_reconcile_provenance_run ON reconcile_provenance (run_id);
53
+ CREATE INDEX IF NOT EXISTS ix_reconcile_provenance_row ON reconcile_provenance (source_table, pk_value);
@@ -241,27 +241,30 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
241
241
  );
242
242
  });
243
243
 
244
- // Empty-plan short-circuit (issue #623 — regression for Merlin instance-46 / epic #1067). A
244
+ // Empty-plan escalation (issues #623/#624 — regression for Merlin instance-46 / epic #1067). A
245
245
  // planner that legitimately emits `{tasks:[]}` (meta/tracking epic, or all sub-issues closed) must
246
- // reach a terminal state WITHOUT entering the adversarial plan-review loop feeding an empty plan
247
- // into review caused a plan↔plan-review livelock (it can neither be approved nor produce findings).
248
- test("empty plan short-circuits to the taskless-done end, never entering plan-review (issue #623)", async () => {
246
+ // NOT enter the adversarial plan-review loop (feeding an empty plan into review caused a
247
+ // plan↔plan-review livelock it can neither be approved nor produce findings), and must NOT
248
+ // auto-terminate from an intermediate signal while the instance is still live (#624). Instead it is
249
+ // parked for OPERATOR ATTENTION at the `empty-plan-escalation` user task; a human then Accepts
250
+ // (no-op done) or Revises (re-plan).
251
+ test("empty plan parks at the operator escalation (non-terminal), never entering plan-review (issues #623/#624)", async () => {
249
252
  let reviewCalls = 0;
250
253
  await withApp(
251
254
  {
252
255
  "senior:plan": () => ({ tasks: [], note: "all sub-issues closed" }),
253
- // If this ever fires, the short-circuit failed and the empty plan entered the review loop.
256
+ // If this ever fires, the empty plan wrongly entered the review loop.
254
257
  "senior:plan-review": () => {
255
258
  reviewCalls += 1;
256
259
  return { approved: false, findings: "" };
257
260
  },
258
261
  "senior:feature": () => ({ status: "blocked", summary: "n/a" }),
259
262
  },
260
- async ({ app, planKey }) => {
263
+ async ({ app, planKey, processKey }) => {
261
264
  const flows = takenFlows(app);
262
265
  assert.ok(
263
- flows.includes("gw-plan-empty->EndTasklessDone"),
264
- `empty plan routed to the taskless-done end (flows: ${flows.join(", ")})`,
266
+ flows.includes("gw-plan-empty->empty-plan-escalation"),
267
+ `empty plan routed to the operator escalation (flows: ${flows.join(", ")})`,
265
268
  );
266
269
  assert.ok(
267
270
  !flows.includes("gw-plan-empty->review-plan"),
@@ -269,11 +272,83 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
269
272
  );
270
273
  assert.equal(reviewCalls, 0, "the plan-review agent must never run for an empty plan");
271
274
 
272
- const plan = await app.db
275
+ // The instance is parked at the operator user task — a completable escalation exists.
276
+ const task = await openTask(app, processKey, "empty-plan-escalation");
277
+ assert.ok(task.userTaskKey, "the empty-plan escalation carries a completable userTaskKey");
278
+
279
+ // While parked, the plan is NON-terminal: the instance is still live (#624), so terminal
280
+ // `done` is owned by the poller on COMPLETED, not written here from the empty-plan signal.
281
+ const parked = await app.db
273
282
  .table<{ plan_key: string; status: string; outcome: string | null }>("plans", "plan_key")
274
283
  .findOne({ plan_key: planKey });
275
- assert.equal(plan?.status, "done", "the empty plan reached a terminal done state");
276
- assert.equal(plan?.outcome, "all sub-issues closed", "the planner note was recorded as the outcome");
284
+ assert.equal(parked?.status, "planning", "a parked empty plan stays non-terminal (planning)");
285
+ assert.equal(parked?.outcome, "all sub-issues closed", "the planner note was recorded as the outcome");
286
+ },
287
+ );
288
+ });
289
+
290
+ test("empty-plan escalation: accept routes to the taskless-done end (no-op epic)", async () => {
291
+ await withApp(
292
+ {
293
+ "senior:plan": () => ({ tasks: [], note: "all sub-issues closed" }),
294
+ "senior:plan-review": () => ({ approved: true, findings: "" }),
295
+ "senior:feature": () => ({ status: "blocked", summary: "n/a" }),
296
+ },
297
+ async ({ app, processKey }) => {
298
+ const task = await openTask(app, processKey, "empty-plan-escalation");
299
+ await app.engine.completeUserTask(task.userTaskKey, { directive: "accept", notes: "meta epic" });
300
+ await app.settle();
301
+
302
+ const flows = takenFlows(app);
303
+ assert.ok(
304
+ flows.includes("gw-empty-plan-answer->EndTasklessDone"),
305
+ `accept routed to the taskless-done end (flows: ${flows.join(", ")})`,
306
+ );
307
+ assert.ok(
308
+ !flows.includes("gw-empty-plan-answer->plan"),
309
+ "the revise (default) flow was NOT taken",
310
+ );
311
+ },
312
+ );
313
+ });
314
+
315
+ test("empty-plan escalation: revise re-plans, re-parking a still-empty plan at the operator (never auto-terminating)", async () => {
316
+ let reviewCalls = 0;
317
+ const planPrompts: Array<string | undefined> = [];
318
+ await withApp(
319
+ {
320
+ "senior:plan": (job) => {
321
+ planPrompts.push((job.variables as Record<string, unknown>).appendPrompt as string | undefined);
322
+ return { tasks: [], note: "all sub-issues closed" };
323
+ },
324
+ "senior:plan-review": () => {
325
+ reviewCalls += 1;
326
+ return { approved: true, findings: "" };
327
+ },
328
+ "senior:feature": () => ({ status: "blocked", summary: "n/a" }),
329
+ },
330
+ async ({ app, processKey }) => {
331
+ const task = await openTask(app, processKey, "empty-plan-escalation");
332
+ await app.engine.completeUserTask(task.userTaskKey, { directive: "revise", notes: "look again" });
333
+ await app.settle();
334
+
335
+ const flows = takenFlows(app);
336
+ assert.ok(
337
+ flows.includes("gw-empty-plan-answer->plan"),
338
+ `revise routed back to the planner (flows: ${flows.join(", ")})`,
339
+ );
340
+ // The operator's revision guidance must actually reach the re-plan: `empty-plan-escalation`
341
+ // folds `notes` into `planFindings`, which the `plan` task renders into its `appendPrompt`.
342
+ const rePlanPrompt = planPrompts.at(-1);
343
+ assert.ok(
344
+ rePlanPrompt?.includes("look again"),
345
+ `operator revise notes were delivered to the re-plan appendPrompt (got: ${JSON.stringify(rePlanPrompt)})`,
346
+ );
347
+ // The re-plan is still empty, so it re-parks at a fresh operator escalation — it neither
348
+ // enters plan-review nor auto-terminates.
349
+ const reparked = await openTask(app, processKey, "empty-plan-escalation");
350
+ assert.ok(reparked.userTaskKey, "a still-empty re-plan re-parks at the operator escalation");
351
+ assert.equal(reviewCalls, 0, "the plan-review agent must never run for an empty plan");
277
352
  },
278
353
  );
279
354
  });
package/main.ts CHANGED
@@ -22,6 +22,7 @@ import { createNanoSdkEngineClient, runFromEnv, selectHost } from "@nanobpm/urba
22
22
  import { type AgenticChannelHandle, mountAgenticChannel } from "./app/agentic/channel.ts";
23
23
  import { makeElementInstanceResolver } from "./app/agentic/element-instance.ts";
24
24
  import { announceEngine, resolveEngineAddress } from "./app/enginePreflight.ts";
25
+ import { runEngineReconcile } from "./app/reconcile.ts";
25
26
  import { MAX_ROUNDS, pollOnce } from "./app/service.ts";
26
27
  import { envVar } from "./app/version.ts";
27
28
 
@@ -102,6 +103,30 @@ if (httpServer instanceof Server) {
102
103
  app.log.warn("agentic channel not mounted: app.httpServer is not a node:http Server on this host");
103
104
  }
104
105
 
106
+ // Engine-reset reconciliation (issue #622). On boot, compare the engine's incarnation epoch against
107
+ // the last-seen value; on a REGRESSION (the engine was reset/restored/rewound and re-minted its keys,
108
+ // Magikcraft/nano-bpm#1065) drive every dangling engine-backed inflight row to the defined `orphaned`
109
+ // terminal WITH PROVENANCE — BEFORE the pollers below start projecting off stale, dead instances.
110
+ // Guarded: an unreachable engine is a no-op (it never orphans live work), and any failure degrades to
111
+ // a warn so reconcile can never block boot.
112
+ if (app.data) {
113
+ try {
114
+ const reconciled = await runEngineReconcile(
115
+ app.data,
116
+ { restAddress: engineAddress.restAddress, token: process.env.CAMUNDA_TOKEN },
117
+ { log: { info: (m) => app.log.info(m), warn: (m) => app.log.warn(m) } },
118
+ );
119
+ if (reconciled.orphanedCount > 0) {
120
+ app.log.warn(
121
+ `startup reconcile: engine reset detected — orphaned ${reconciled.orphanedCount} engine-backed ` +
122
+ `inflight row(s) [run ${reconciled.runId}].`,
123
+ );
124
+ }
125
+ } catch (err) {
126
+ app.log.warn(`startup reconcile skipped: ${err instanceof Error ? err.message : String(err)}`);
127
+ }
128
+ }
129
+
105
130
  // Review-ready poller. Self-scheduling (not setInterval) so a slow GitHub call can never
106
131
  // overlap two passes (which could double-signal `readiness-ready`); the next pass is scheduled
107
132
  // only after the previous one settles.