@nanobpm/nano-workforce 0.174.0 → 0.174.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,9 @@
1
+ ## [0.174.1](https://github.com/nanobpm/nano-workforce/compare/v0.174.0...v0.174.1) (2026-09-02)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **reconcile:** fold vanished engine instances to orphaned, unwedging inflight runs ([#706](https://github.com/nanobpm/nano-workforce/issues/706)) ([68461da](https://github.com/nanobpm/nano-workforce/commit/68461dae90fba5ab6caaa8eef595e63f3995dc88)), closes [#2](https://github.com/nanobpm/nano-workforce/issues/2) [#630](https://github.com/nanobpm/nano-workforce/issues/630) [#627](https://github.com/nanobpm/nano-workforce/issues/627)
6
+
1
7
  ## [0.174.0](https://github.com/nanobpm/nano-workforce/compare/v0.173.0...v0.174.0) (2026-09-02)
2
8
 
3
9
  ### Features
@@ -1,4 +1,4 @@
1
- // Red/green coverage for the app-side engine-reset reconciliation surface (issue #622).
1
+ // Red/green coverage for the app-side engine-reset reconciliation surface (issues #622 and #630).
2
2
  //
3
3
  // The core scenario the incident (Magikcraft/nano-bpm#1065) demanded a supported remedy for: the
4
4
  // engine is reset and its incarnation epoch REGRESSES, while `app.db` still projects engine-backed
@@ -13,21 +13,55 @@ import { test } from "node:test";
13
13
  import { assertEquals } from "#test-assert";
14
14
  import { freshData } from "../test/reconcileDb.ts";
15
15
  import {
16
+ DEFAULT_VANISHED_GRACE_MS,
16
17
  ORPHANED_STATUS,
17
18
  parseEngineEpoch,
18
19
  RECONCILE_ORPHAN_REASON,
20
+ RECONCILE_VANISHED_REASON,
19
21
  reconcileEngineBackedWork,
22
+ reconcileVanishedInstances,
23
+ runEngineReconcile,
20
24
  } from "./reconcile.ts";
21
25
 
22
26
  const AT = () => new Date("2026-02-02T00:00:00.000Z");
23
27
 
24
- function seedFeatureRun(raw: DatabaseSync, key: string, status: string, processKey: string | null): void {
28
+ /** The canonical `_urban_instance_state` DDL (urban's framework projection, `_urban_`-prefixed so it
29
+ * is provisioned by the runtime — NOT our migrations). Mirrors `InstanceStateStore`'s schema so the
30
+ * vanished-instance reconcile is exercised against exactly the table it reads in production. */
31
+ function ensureInstanceState(raw: DatabaseSync): void {
32
+ raw.exec(
33
+ `CREATE TABLE IF NOT EXISTS _urban_instance_state (
34
+ process_instance_key TEXT NOT NULL,
35
+ state TEXT NOT NULL,
36
+ waiting_on_human INTEGER NOT NULL DEFAULT 0,
37
+ updated_at TEXT NOT NULL,
38
+ PRIMARY KEY (process_instance_key)
39
+ );`,
40
+ );
41
+ }
42
+
43
+ function seedInstanceState(raw: DatabaseSync, processKey: string, state: string): void {
44
+ raw
45
+ .prepare(
46
+ `INSERT INTO _urban_instance_state (process_instance_key, state, waiting_on_human, updated_at)
47
+ VALUES (?, ?, 0, '2026-01-15')`,
48
+ )
49
+ .run(processKey, state);
50
+ }
51
+
52
+ function seedFeatureRun(
53
+ raw: DatabaseSync,
54
+ key: string,
55
+ status: string,
56
+ processKey: string | null,
57
+ updatedAt = "2026-01-01",
58
+ ): void {
25
59
  raw
26
60
  .prepare(
27
61
  `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')`,
62
+ VALUES (?, 'o/r', 1, 'https://x', 'main', ?, ?, '2026-01-01', ?)`,
29
63
  )
30
- .run(key, status, processKey);
64
+ .run(key, status, processKey, updatedAt);
31
65
  }
32
66
 
33
67
  function seedDeliveryGraphRun(raw: DatabaseSync, runKey: string, status: string, processKey: string | null): void {
@@ -210,3 +244,199 @@ test("RED→GREEN: orphaning stamps updated_at so the transition timestamp isn't
210
244
  assertEquals(dg.status, ORPHANED_STATUS);
211
245
  assertEquals(dg.updated_at, at);
212
246
  });
247
+
248
+ // --- Vanished-instance reconciliation (issue #630) --------------------------------------------
249
+ // The "instance absent/unknown" gap, DISTINCT from the epoch-regression reset above: when an engine
250
+ // instance VANISHES from the read model (`_urban_instance_state` row pruned/never re-created after a
251
+ // clean reset), the derived terminal edge has no `TERMINATED` row to match, so the run freezes at its
252
+ // last worker-owned status (`escalated`) and wedges Active forever. `reconcileVanishedInstances`
253
+ // drives those orphaned-in-truth rows to `orphaned` WITH PROVENANCE — gated on a grace window so a
254
+ // still-starting run (not yet projected) is spared.
255
+
256
+ test("RED→GREEN: a vanished instance (no _urban_instance_state row, past grace) is orphaned", async () => {
257
+ const { data, raw } = freshData();
258
+ ensureInstanceState(raw);
259
+ // The pre-reset orphan from the incident: escalated, keyed on a HIGH pre-reset process_key whose
260
+ // instance is absent from the current read model. Its updated_at is ~32 days before AT() (past grace).
261
+ seedFeatureRun(raw, "Magikcraft/nano-bpm#1051", "escalated", "71506");
262
+ // A live sibling: still ACTIVE in the projection — must be left untouched.
263
+ seedFeatureRun(raw, "o/r#live", "running", "200");
264
+ seedInstanceState(raw, "200", "ACTIVE");
265
+
266
+ // RED (pre-fix): the orphan reads `escalated` (Active) indefinitely — no terminal edge fires.
267
+ const before = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='Magikcraft/nano-bpm#1051'").get() as {
268
+ status: string;
269
+ };
270
+ assertEquals(before.status, "escalated");
271
+
272
+ const res = await reconcileVanishedInstances(data, { now: AT, runId: "van-1" });
273
+
274
+ assertEquals(res.reason, "instance-vanished");
275
+ assertEquals(res.orphanedCount, 1);
276
+
277
+ const orphan = raw
278
+ .prepare("SELECT status, updated_at FROM feature_runs WHERE feature_key='Magikcraft/nano-bpm#1051'")
279
+ .get() as { status: string; updated_at: string };
280
+ assertEquals(orphan.status, ORPHANED_STATUS);
281
+ // The transition refreshes updated_at like every other status transition (not left stale).
282
+ assertEquals(orphan.updated_at, AT().toISOString());
283
+
284
+ // The live instance (ACTIVE row present) is never touched.
285
+ const live = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='o/r#live'").get() as { status: string };
286
+ assertEquals(live.status, "running");
287
+
288
+ const prov = raw
289
+ .prepare("SELECT * FROM reconcile_provenance WHERE source_table='feature_runs'")
290
+ .get() as Record<string, unknown>;
291
+ assertEquals(prov.to_status, ORPHANED_STATUS);
292
+ assertEquals(prov.from_status, "escalated");
293
+ assertEquals(prov.reason, RECONCILE_VANISHED_REASON);
294
+ assertEquals(prov.key_value, "71506");
295
+ assertEquals(prov.run_id, "van-1");
296
+ assertEquals(prov.observed_epoch, null);
297
+
298
+ const run = raw.prepare("SELECT reason, orphaned_count FROM reconcile_runs WHERE run_id='van-1'").get() as {
299
+ reason: string;
300
+ orphaned_count: number;
301
+ };
302
+ assertEquals(run.reason, "instance-vanished");
303
+ assertEquals(run.orphaned_count, 1);
304
+ });
305
+
306
+ test("a still-starting run within the grace window is NOT prematurely folded", async () => {
307
+ const { data, raw } = freshData();
308
+ ensureInstanceState(raw);
309
+ // Dispatched moments ago — its process_key is set but the reconciler has not yet projected the
310
+ // instance into _urban_instance_state. updated_at is 30s before AT(), inside the grace window.
311
+ const justNow = new Date(AT().getTime() - 30_000).toISOString();
312
+ seedFeatureRun(raw, "o/r#starting", "running", "999", justNow);
313
+
314
+ const res = await reconcileVanishedInstances(data, { now: AT, runId: "van-1" });
315
+
316
+ assertEquals(res.orphanedCount, 0);
317
+ const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='o/r#starting'").get() as {
318
+ status: string;
319
+ };
320
+ assertEquals(row.status, "running");
321
+ assertEquals((raw.prepare("SELECT COUNT(*) c FROM reconcile_provenance").get() as { c: number }).c, 0);
322
+ // A generous grace window is the point — the default comfortably exceeds a poll cycle.
323
+ assertEquals(DEFAULT_VANISHED_GRACE_MS >= 60_000, true);
324
+ });
325
+
326
+ test("RED→GREEN: a row whose updated_at is null/unparseable is spared, never orphaned", async () => {
327
+ const { data, raw } = freshData();
328
+ ensureInstanceState(raw);
329
+ // A tracked table's `updated_at` can be nullable (e.g. delivery_units.updated_at,
330
+ // db/migrations/088_delivery_units.sql) or carry an unparseable value. Its instance is absent from
331
+ // the read model, so without a usable age we cannot tell a genuinely-vanished row from a live one.
332
+ // RED (pre-fix): withinGrace treated an unestablishable age as "old enough" and folded the row.
333
+ // GREEN: we err toward sparing — an ageless row is treated as within grace and left untouched.
334
+ seedFeatureRun(raw, "o/r#ageless", "running", "888", "not-a-timestamp");
335
+
336
+ const res = await reconcileVanishedInstances(data, { now: AT, runId: "van-1" });
337
+
338
+ assertEquals(res.orphanedCount, 0);
339
+ const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='o/r#ageless'").get() as {
340
+ status: string;
341
+ };
342
+ assertEquals(row.status, "running");
343
+ assertEquals((raw.prepare("SELECT COUNT(*) c FROM reconcile_provenance").get() as { c: number }).c, 0);
344
+ });
345
+
346
+ test("terminal history, keyless rows, and rows with a live instance are never folded as vanished", async () => {
347
+ const { data, raw } = freshData();
348
+ ensureInstanceState(raw);
349
+ seedFeatureRun(raw, "term#1", "merged", "88"); // terminal — not in activeStatuses
350
+ seedFeatureRun(raw, "nokeed#1", "running", null); // active but never dispatched (no engine key)
351
+ seedFeatureRun(raw, "live#1", "escalated", "89"); // active, but its instance is still present
352
+ seedInstanceState(raw, "89", "ACTIVE");
353
+
354
+ const res = await reconcileVanishedInstances(data, { now: AT, runId: "van-1" });
355
+
356
+ assertEquals(res.orphanedCount, 0);
357
+ const statuses = raw.prepare("SELECT feature_key, status FROM feature_runs ORDER BY feature_key").all() as {
358
+ feature_key: string;
359
+ status: string;
360
+ }[];
361
+ assertEquals(statuses.find((r) => r.feature_key === "term#1")?.status, "merged");
362
+ assertEquals(statuses.find((r) => r.feature_key === "nokeed#1")?.status, "running");
363
+ assertEquals(statuses.find((r) => r.feature_key === "live#1")?.status, "escalated");
364
+ assertEquals((raw.prepare("SELECT COUNT(*) c FROM reconcile_provenance").get() as { c: number }).c, 0);
365
+ });
366
+
367
+ test("no-op when the _urban_instance_state projection is absent (never orphan on its absence)", async () => {
368
+ const { data, raw } = freshData();
369
+ // NOTE: no ensureInstanceState — the framework projection has not been provisioned.
370
+ seedFeatureRun(raw, "o/r#1", "escalated", "71506");
371
+
372
+ const res = await reconcileVanishedInstances(data, { now: AT, runId: "van-1" });
373
+
374
+ assertEquals(res.reason, "no-op");
375
+ assertEquals(res.orphanedCount, 0);
376
+ const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='o/r#1'").get() as { status: string };
377
+ assertEquals(row.status, "escalated");
378
+ const run = raw.prepare("SELECT reason FROM reconcile_runs WHERE run_id='van-1'").get() as { reason: string };
379
+ assertEquals(run.reason, "no-op");
380
+ });
381
+
382
+ test("idempotent: a second vanished pass is a no-op (the orphaned row left activeStatuses)", async () => {
383
+ const { data, raw } = freshData();
384
+ ensureInstanceState(raw);
385
+ seedFeatureRun(raw, "o/r#1", "escalated", "71506");
386
+
387
+ const first = await reconcileVanishedInstances(data, { now: AT, runId: "van-1" });
388
+ assertEquals(first.orphanedCount, 1);
389
+
390
+ const second = await reconcileVanishedInstances(data, { now: AT, runId: "van-2" });
391
+ assertEquals(second.reason, "no-op");
392
+ assertEquals(second.orphanedCount, 0);
393
+ assertEquals((raw.prepare("SELECT COUNT(*) c FROM reconcile_provenance").get() as { c: number }).c, 1);
394
+ });
395
+
396
+ // --- Merged seam: runEngineReconcile (both passes, one result) --------------------------------
397
+ // The operator/startup seam merges the epoch-regression and vanished-instance passes into ONE
398
+ // result. This guards the merged behavior the two per-pass suites above don't reach: run-id
399
+ // correlation (the vanished pass's provenance must be locatable from the returned `runId`) and
400
+ // `reason` selection when the epoch pass is `engine-unreachable` yet vanished instances are orphaned.
401
+
402
+ test("runEngineReconcile: engine-unreachable epoch pass still folds vanished instances, with a correlatable run id", async () => {
403
+ const { data, raw } = freshData();
404
+ ensureInstanceState(raw);
405
+ // A vanished orphan (escalated, past grace, instance absent from the read model).
406
+ seedFeatureRun(raw, "Magikcraft/nano-bpm#1051", "escalated", "71506");
407
+
408
+ // The engine is unreachable — the epoch probe fails, so the epoch pass reports `engine-unreachable`
409
+ // and orphans nothing; the vanished pass must still act.
410
+ const fetchImpl = (() => Promise.reject(new Error("ECONNREFUSED"))) as unknown as typeof fetch;
411
+ const res = await runEngineReconcile(
412
+ data,
413
+ { restAddress: "http://engine.invalid" },
414
+ { now: AT, fetchImpl },
415
+ );
416
+
417
+ // The vanished pass acted even though the epoch pass could not reach the engine.
418
+ assertEquals(res.reason, "instance-vanished");
419
+ assertEquals(res.orphanedCount, 1);
420
+ const orphan = raw
421
+ .prepare("SELECT status FROM feature_runs WHERE feature_key='Magikcraft/nano-bpm#1051'")
422
+ .get() as { status: string };
423
+ assertEquals(orphan.status, ORPHANED_STATUS);
424
+
425
+ // The vanished pass's provenance is stamped with the DERIVED, correlatable id `<runId>-vanished`
426
+ // (the boot path omits opts.runId, so a bare random UUID would be non-locatable from the result).
427
+ const prov = raw
428
+ .prepare("SELECT run_id FROM reconcile_provenance WHERE source_table='feature_runs'")
429
+ .get() as { run_id: string };
430
+ assertEquals(prov.run_id, `${res.runId}-vanished`);
431
+
432
+ // Both passes recorded their own reconcile_runs row under correlatable ids.
433
+ const epochRun = raw.prepare("SELECT reason FROM reconcile_runs WHERE run_id=?").get(res.runId) as
434
+ | { reason: string }
435
+ | undefined;
436
+ assertEquals(epochRun?.reason, "engine-unreachable");
437
+ const vanishedRun = raw
438
+ .prepare("SELECT reason, orphaned_count FROM reconcile_runs WHERE run_id=?")
439
+ .get(`${res.runId}-vanished`) as { reason: string; orphaned_count: number } | undefined;
440
+ assertEquals(vanishedRun?.reason, "instance-vanished");
441
+ assertEquals(vanishedRun?.orphaned_count, 1);
442
+ });
package/app/reconcile.ts CHANGED
@@ -29,12 +29,24 @@
29
29
  // engine is a no-op too: reconcile NEVER orphans when it could not confirm a reset (a 401/5xx or
30
30
  // a network error yields `reachable:false`, not a false "engine missing").
31
31
  //
32
+ // A second, complementary pass covers the "instance absent/unknown" gap (issue #630) the epoch signal
33
+ // alone cannot: an inflight run whose engine instance has VANISHED from the read model — its
34
+ // `keyField` (process_key) has NO `_urban_instance_state` row at all (engine clean-reset, cluster
35
+ // rebuild, or read-model pruning removed it), so the derived tracking edge has no `TERMINATED` row to
36
+ // match and the run freezes at its last worker-owned status, wedging Active forever with no
37
+ // reconciliation path (the observed pre-reset orphan `Magikcraft/nano-bpm#1051`, process_key 71506).
38
+ // `reconcileVanishedInstances` drives every such row — active, dispatched, its key absent from
39
+ // `_urban_instance_state`, and PAST A GRACE WINDOW (so a still-starting run not yet projected is
40
+ // spared) — to the same `orphaned` terminal, with a DISTINCT provenance reason so an operator can
41
+ // tell a vanished-instance orphan apart from an epoch-regression one. `runEngineReconcile` runs BOTH
42
+ // passes, so startup and the operator command converge both failure modes in one call.
43
+ //
32
44
  // The provenance is app-owned (not urban's `_urban_write_provenance`, which is a domain-free
33
45
  // insert-join sidecar written only inside a job): reconcile runs at boot / over HTTP, outside any
34
46
  // job, and needs to record the REASON + epoch + run id — which the app-owned `reconcile_provenance`
35
47
  // table carries, and the existing `app.db` backup convention makes the whole mutation reversible.
36
48
 
37
- import type { DataLayer, GatewayDataSource as DataSource } from "@nanobpm/urban";
49
+ import type { DataLayer, GatewayDataSource as DataSource, InstanceTracking } from "@nanobpm/urban";
38
50
  import type { TopologyProbe } from "./enginePreflight.ts";
39
51
  import { activeStatusesFor, baseStatusFieldFor, engineBackedBindings, keyFieldFor } from "./instanceTracking.ts";
40
52
 
@@ -49,6 +61,28 @@ export const ORPHANED_STATUS = "orphaned";
49
61
  * recorded incarnation epoch regressed (the #1065 signature). */
50
62
  export const RECONCILE_ORPHAN_REASON = "engine-reset/epoch-regression";
51
63
 
64
+ /** The provenance reason stamped when a row is orphaned because its engine instance VANISHED from the
65
+ * read model — the run's `keyField` (process_key) has NO `_urban_instance_state` row at all, so the
66
+ * instance is absent/unknown in engine truth (engine clean-reset, cluster rebuild, or read-model
67
+ * pruning removed the instance-state row entirely — issue #630). Deliberately DISTINCT from
68
+ * {@link RECONCILE_ORPHAN_REASON} so an operator can tell an epoch-regression orphan apart from a
69
+ * vanished-instance orphan, even though both land on the same `orphaned` terminal. */
70
+ export const RECONCILE_VANISHED_REASON = "engine-instance/vanished";
71
+
72
+ /** The default grace window (ms) a dispatched-but-instance-less row is spared before it is considered
73
+ * vanished. A run dispatched moments ago (its `process_key` set) has not yet been polled into
74
+ * `_urban_instance_state` by the instanceTracking reconciler (`pollMs` 5s + engine search latency),
75
+ * so it transiently looks "vanished". This window (comfortably larger than a poll cycle) keeps a
76
+ * legitimately-still-starting run from being folded to terminal prematurely (issue #630 AC #2). */
77
+ export const DEFAULT_VANISHED_GRACE_MS = 5 * 60_000;
78
+
79
+ /** The framework's canonical per-instance engine-lifecycle projection table (urban's
80
+ * `_urban_instance_state`, keyed by `process_instance_key`). The vanished-instance reconcile joins
81
+ * each engine-backed row's `keyField` against it: a run whose key has NO row here has no backing
82
+ * instance in engine truth. `_urban_` prefixed (framework bookkeeping) so it is provisioned by the
83
+ * runtime, not our migrations — the reconcile guards on its existence before acting. */
84
+ const INSTANCE_STATE_TABLE = "_urban_instance_state";
85
+
52
86
  /** The single-row epoch ledger + its append-only run/provenance sidecars (migration 092). */
53
87
  const INCARNATION_TABLE = "engine_incarnation";
54
88
  const RUNS_TABLE = "reconcile_runs";
@@ -71,7 +105,12 @@ export interface EngineEpochObservation {
71
105
  }
72
106
 
73
107
  /** Why a reconcile pass acted (or did not). */
74
- export type ReconcileReason = "epoch-regression" | "seed-epoch" | "no-op" | "engine-unreachable";
108
+ export type ReconcileReason =
109
+ | "epoch-regression"
110
+ | "seed-epoch"
111
+ | "no-op"
112
+ | "engine-unreachable"
113
+ | "instance-vanished";
75
114
 
76
115
  /** One orphaned engine-backed row. */
77
116
  export interface OrphanedRow {
@@ -107,6 +146,14 @@ export interface ReconcileOptions {
107
146
  log?: ReconcileLog;
108
147
  }
109
148
 
149
+ /** Options for the vanished-instance reconcile pass ({@link reconcileVanishedInstances}). */
150
+ export interface VanishedReconcileOptions extends ReconcileOptions {
151
+ /** How long (ms) a dispatched-but-instance-less row is spared before it is folded to terminal, so a
152
+ * still-starting run (not yet projected into `_urban_instance_state`) is not orphaned prematurely.
153
+ * Defaults to {@link DEFAULT_VANISHED_GRACE_MS}. */
154
+ graceMs?: number;
155
+ }
156
+
110
157
  /** Read the incarnation epoch out of a `/v2/topology` body — `nano.incarnation` (or its `epoch`
111
158
  * alias), coerced from a number or a numeric string. Any other shape (absent, non-numeric) yields
112
159
  * null: "the engine exposes no epoch". A null is a no-op ONLY when no epoch was previously recorded;
@@ -175,6 +222,78 @@ async function persistEpoch(src: DataSource, epoch: number, at: string): Promise
175
222
  );
176
223
  }
177
224
 
225
+ /** The resolved schema + tracking selectors for one engine-backed binding, or `null` when the binding
226
+ * carries no `activeStatuses` selector (it cannot classify "in-flight", so it is skipped). */
227
+ interface BindingShape {
228
+ table: string;
229
+ active: readonly string[];
230
+ statusField: string;
231
+ keyField: string;
232
+ pkCol: string;
233
+ hasUpdatedAt: boolean;
234
+ }
235
+
236
+ /** The shape of a row selected for possible orphaning. */
237
+ interface OrphanCandidate {
238
+ __pk: unknown;
239
+ __key: unknown;
240
+ __status: unknown;
241
+ __updated?: unknown;
242
+ }
243
+
244
+ /** Resolve a binding's tracking selectors + physical schema, or `null` to skip a selector-less
245
+ * binding (activeStatusesFor would throw; we tolerate it here). */
246
+ async function resolveShape(src: DataSource, binding: InstanceTracking): Promise<BindingShape | null> {
247
+ if (!binding.activeStatuses?.length) return null;
248
+ const table = binding.table;
249
+ const { pkCol, hasUpdatedAt } = await tableShape(src, table);
250
+ return {
251
+ table,
252
+ active: activeStatusesFor(table),
253
+ statusField: baseStatusFieldFor(table),
254
+ keyField: keyFieldFor(table),
255
+ pkCol,
256
+ hasUpdatedAt,
257
+ };
258
+ }
259
+
260
+ /** Drive ONE candidate row to `orphaned` with provenance, GUARDED: the UPDATE re-asserts the exact
261
+ * status read AND a populated key (plus any `extraGuardSql`, e.g. the still-vanished re-check), so a
262
+ * writer that flipped the row to a newer terminal status (or an instance that reappeared) between the
263
+ * SELECT and this UPDATE wins the race — we never clobber that history back to `orphaned`. Only a row
264
+ * we actually transitioned (`res.changed > 0`) gets provenance and is returned. `updated_at` is
265
+ * stamped (when the table has one) so the transition refreshes the row's timestamp like every other
266
+ * status transition in the codebase. Runs inside the caller's transaction. */
267
+ async function orphanRow(
268
+ src: DataSource,
269
+ shape: BindingShape,
270
+ row: OrphanCandidate,
271
+ reason: string,
272
+ observedEpoch: number | null,
273
+ runId: string,
274
+ at: string,
275
+ extraGuardSql = "",
276
+ ): Promise<OrphanedRow | null> {
277
+ const { table, pkCol, statusField, keyField, hasUpdatedAt } = shape;
278
+ const pk = String(row.__pk);
279
+ const key = row.__key == null ? null : String(row.__key);
280
+ const fromStatus = String(row.__status);
281
+ const res = await src.exec(
282
+ `UPDATE ${q(table)} SET ${q(statusField)} = ?` +
283
+ (hasUpdatedAt ? `, ${q(UPDATED_AT_COLUMN)} = ?` : "") +
284
+ ` WHERE ${q(pkCol)} = ? AND ${q(statusField)} = ? AND ${q(keyField)} IS NOT NULL${extraGuardSql}`,
285
+ hasUpdatedAt ? [ORPHANED_STATUS, at, row.__pk, fromStatus] : [ORPHANED_STATUS, row.__pk, fromStatus],
286
+ );
287
+ if (res.changed <= 0) return null;
288
+ await src.exec(
289
+ `INSERT INTO ${PROVENANCE_TABLE} ` +
290
+ `(run_id, source_table, pk_value, key_value, from_status, to_status, reason, observed_epoch, at) ` +
291
+ `VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
292
+ [runId, table, pk, key, fromStatus, ORPHANED_STATUS, reason, observedEpoch, at],
293
+ );
294
+ return { table, pk, key, fromStatus };
295
+ }
296
+
178
297
  /** Orphan every NON-terminal, engine-backed row across all instanceTracking bindings, recording one
179
298
  * `reconcile_provenance` row per transition. Runs inside the caller's transaction. */
180
299
  async function orphanEngineBackedRows(
@@ -185,52 +304,87 @@ async function orphanEngineBackedRows(
185
304
  ): Promise<OrphanedRow[]> {
186
305
  const orphaned: OrphanedRow[] = [];
187
306
  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],
307
+ const shape = await resolveShape(src, binding);
308
+ if (!shape) continue;
309
+ const placeholders = shape.active.map(() => "?").join(", ");
310
+ const rows = await src.query<OrphanCandidate>(
311
+ `SELECT ${q(shape.pkCol)} AS __pk, ${q(shape.keyField)} AS __key, ${q(shape.statusField)} AS __status ` +
312
+ `FROM ${q(shape.table)} WHERE ${q(shape.statusField)} IN (${placeholders}) AND ${q(shape.keyField)} IS NOT NULL`,
313
+ [...shape.active],
201
314
  );
202
315
  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 });
316
+ const o = await orphanRow(src, shape, row, RECONCILE_ORPHAN_REASON, observedEpoch, runId, at);
317
+ if (o) orphaned.push(o);
229
318
  }
230
319
  }
231
320
  return orphaned;
232
321
  }
233
322
 
323
+ /** Whether a row's `updated_at` is younger than the grace window — i.e. it was (re)dispatched too
324
+ * recently to have been projected into `_urban_instance_state` yet, so it must NOT be folded. A
325
+ * null/unparseable timestamp is treated as "within grace" (spared): when we cannot establish a row's
326
+ * age we must NOT orphan it — a nullable `updated_at` (e.g. `delivery_units.updated_at`,
327
+ * db/migrations/088_delivery_units.sql) would otherwise fold a live row. Erring toward sparing at
328
+ * worst leaves a genuinely-vanished ageless row for a later pass once it carries a usable timestamp;
329
+ * erring the other way wedges/destroys a live run, so we choose the safe default. */
330
+ function withinGrace(updated: unknown, nowMs: number, graceMs: number): boolean {
331
+ if (updated == null) return true;
332
+ const t = Date.parse(String(updated));
333
+ if (!Number.isFinite(t)) return true;
334
+ return nowMs - t < graceMs;
335
+ }
336
+
337
+ /** Orphan every NON-terminal, engine-backed row whose `keyField` (process instance key) has NO
338
+ * `_urban_instance_state` row — the instance is absent/unknown in engine truth (vanished, issue
339
+ * #630) — and whose last transition is older than the grace window. Records one
340
+ * `reconcile_provenance` row per transition (reason {@link RECONCILE_VANISHED_REASON}). Runs inside
341
+ * the caller's transaction. */
342
+ async function orphanVanishedRows(
343
+ src: DataSource,
344
+ runId: string,
345
+ at: string,
346
+ nowMs: number,
347
+ graceMs: number,
348
+ ): Promise<OrphanedRow[]> {
349
+ const orphaned: OrphanedRow[] = [];
350
+ for (const binding of engineBackedBindings()) {
351
+ const shape = await resolveShape(src, binding);
352
+ if (!shape) continue;
353
+ const placeholders = shape.active.map(() => "?").join(", ");
354
+ const updatedSel = shape.hasUpdatedAt ? `, ${q(UPDATED_AT_COLUMN)} AS __updated` : "";
355
+ // Active, dispatched (key populated) rows whose engine instance key has NO matching
356
+ // `_urban_instance_state` row — absent/unknown in engine truth.
357
+ const rows = await src.query<OrphanCandidate>(
358
+ `SELECT ${q(shape.pkCol)} AS __pk, ${q(shape.keyField)} AS __key, ${q(shape.statusField)} AS __status${updatedSel} ` +
359
+ `FROM ${q(shape.table)} b WHERE ${q(shape.statusField)} IN (${placeholders}) AND ${q(shape.keyField)} IS NOT NULL ` +
360
+ `AND NOT EXISTS (SELECT 1 FROM ${q(INSTANCE_STATE_TABLE)} s WHERE s.process_instance_key = b.${q(shape.keyField)})`,
361
+ [...shape.active],
362
+ );
363
+ // Re-assert "still no instance-state row" in the guarded UPDATE too, so an instance that reappears
364
+ // (the poller records it) between the SELECT above and the UPDATE wins the race.
365
+ const stillVanishedGuard =
366
+ ` AND NOT EXISTS (SELECT 1 FROM ${q(INSTANCE_STATE_TABLE)} s ` +
367
+ `WHERE s.process_instance_key = ${q(shape.table)}.${q(shape.keyField)})`;
368
+ for (const row of rows) {
369
+ if (shape.hasUpdatedAt && withinGrace(row.__updated, nowMs, graceMs)) continue;
370
+ const o = await orphanRow(src, shape, row, RECONCILE_VANISHED_REASON, null, runId, at, stillVanishedGuard);
371
+ if (o) orphaned.push(o);
372
+ }
373
+ }
374
+ return orphaned;
375
+ }
376
+
377
+ /** Whether the framework `_urban_instance_state` projection exists in this source. When it does NOT,
378
+ * the vanished-instance pass is a hard no-op: without the projection every dispatched row would look
379
+ * "vanished", so we must never orphan on its absence. */
380
+ async function instanceStateTableExists(src: DataSource): Promise<boolean> {
381
+ const rows = await src.query<{ n: number }>(
382
+ `SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = ?`,
383
+ [INSTANCE_STATE_TABLE],
384
+ );
385
+ return rows.length > 0 && Number(rows[0].n) > 0;
386
+ }
387
+
234
388
  /**
235
389
  * Reconcile the app's engine-backed projections against one epoch observation. Pure of I/O beyond the
236
390
  * data layer (the topology probe is {@link probeEngineEpoch}, injected as `observation`), so the
@@ -300,6 +454,75 @@ export async function reconcileEngineBackedWork(
300
454
  return { runId, reason: result.reason, observedEpoch, recordedEpoch, orphanedCount: result.orphaned.length, orphaned: result.orphaned };
301
455
  }
302
456
 
457
+ /**
458
+ * Reconcile engine-backed inflight work against the framework's canonical per-instance projection
459
+ * (`_urban_instance_state`) — the "instance absent/unknown" gap (issue #630), DISTINCT from the
460
+ * epoch-regression reset the {@link reconcileEngineBackedWork} pass handles.
461
+ *
462
+ * When an engine instance VANISHES from the read model — engine clean-reset, cluster rebuild, or
463
+ * read-model pruning removes the `_urban_instance_state` row entirely — there is no `TERMINATED` row
464
+ * for the derived tracking edge to match, so the run freezes at its last worker-owned status
465
+ * (`escalated`/`awaiting_operator`) and wedges the Active list forever with no reconciliation path.
466
+ * "The instance backing this run no longer exists in engine truth" is a terminal condition: this pass
467
+ * drives every such row (active, dispatched, its `keyField` absent from `_urban_instance_state`, and
468
+ * past the grace window) to the defined `orphaned` terminal WITH PROVENANCE.
469
+ *
470
+ * Safety:
471
+ * • GRACE WINDOW — a just-dispatched run has not yet been polled into `_urban_instance_state`; only
472
+ * rows whose last transition is older than `graceMs` are folded, so a still-starting run is never
473
+ * prematurely orphaned (AC #2).
474
+ * • PROJECTION-PRESENT — if `_urban_instance_state` does not exist (the runtime has not provisioned
475
+ * it), every dispatched row would look vanished, so the pass is a hard no-op.
476
+ * • GUARDED — the same status-re-assert as the epoch pass, plus a still-vanished re-check, so a
477
+ * concurrent terminal write or a reappearing instance wins the race.
478
+ * • This pass reads the app's OWN last-known projection, not a live probe, so it acts correctly on a
479
+ * genuinely-vanished instance regardless of transient engine reachability (a live instance keeps
480
+ * its persisted ACTIVE row across a restart, so it is never mistaken for vanished).
481
+ */
482
+ export async function reconcileVanishedInstances(
483
+ data: DataLayer,
484
+ opts: VanishedReconcileOptions = {},
485
+ ): Promise<ReconcileResult> {
486
+ const src = data.open(opts.sourceName);
487
+ const clock = opts.now?.() ?? new Date();
488
+ const at = clock.toISOString();
489
+ const nowMs = clock.getTime();
490
+ const runId = opts.runId ?? crypto.randomUUID();
491
+ const graceMs = opts.graceMs ?? DEFAULT_VANISHED_GRACE_MS;
492
+
493
+ // Without the framework projection we cannot tell a vanished instance from a live one — every
494
+ // dispatched row would look vanished. NO-OP rather than orphan live work.
495
+ if (!(await instanceStateTableExists(src))) {
496
+ await recordRun(src, { runId, at, observedEpoch: null, recordedEpoch: null, reason: "no-op", orphanedCount: 0 });
497
+ opts.log?.info(`reconcile(vanished): instance-state projection absent — no-op [run ${runId}].`);
498
+ return { runId, reason: "no-op", observedEpoch: null, recordedEpoch: null, orphanedCount: 0, orphaned: [] };
499
+ }
500
+
501
+ const orphaned = await src.tx(async (t) => {
502
+ const rows = await orphanVanishedRows(t, runId, at, nowMs, graceMs);
503
+ const reason: ReconcileReason = rows.length > 0 ? "instance-vanished" : "no-op";
504
+ await recordRun(t, { runId, at, observedEpoch: null, recordedEpoch: null, reason, orphanedCount: rows.length });
505
+ return rows;
506
+ });
507
+
508
+ if (orphaned.length > 0) {
509
+ opts.log?.warn(
510
+ `reconcile(vanished): orphaned ${orphaned.length} inflight row(s) whose engine instance ` +
511
+ `vanished from the read model [run ${runId}].`,
512
+ );
513
+ } else {
514
+ opts.log?.info(`reconcile(vanished): no vanished instances — no-op [run ${runId}].`);
515
+ }
516
+ return {
517
+ runId,
518
+ reason: orphaned.length > 0 ? "instance-vanished" : "no-op",
519
+ observedEpoch: null,
520
+ recordedEpoch: null,
521
+ orphanedCount: orphaned.length,
522
+ orphaned,
523
+ };
524
+ }
525
+
303
526
  async function recordRun(
304
527
  src: DataSource,
305
528
  run: { runId: string; at: string; observedEpoch: number | null; recordedEpoch: number | null; reason: ReconcileReason; orphanedCount: number },
@@ -312,15 +535,47 @@ async function recordRun(
312
535
  }
313
536
 
314
537
  /** 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. */
538
+ * `reconcileEngineState` operator command share, so the two paths can never diverge. Runs BOTH the
539
+ * epoch-regression pass (engine reset/rewind) and the vanished-instance pass (an inflight run whose
540
+ * instance is absent/unknown in `_urban_instance_state` — issue #630), returning ONE merged result.
541
+ * Reason precedence: `epoch-regression` always wins; otherwise, if the vanished pass orphaned any
542
+ * rows the reason is `instance-vanished` (even when the epoch pass reported a non-regression state
543
+ * such as `engine-unreachable`); otherwise the epoch pass's reason stands. `orphanedCount` /
544
+ * `orphaned` cover both passes. The two passes never double-fold a row: once the epoch pass orphans a
545
+ * row it leaves `activeStatuses`, so the vanished pass no longer selects it. */
316
546
  export async function runEngineReconcile(
317
547
  data: DataLayer,
318
548
  engineRest: { restAddress: string; token?: string },
319
- opts: ReconcileOptions & { fetchImpl?: typeof fetch } = {},
549
+ opts: VanishedReconcileOptions & { fetchImpl?: typeof fetch } = {},
320
550
  ): Promise<ReconcileResult> {
321
551
  const observation = await probeEngineEpoch(engineRest.restAddress, {
322
552
  token: engineRest.token,
323
553
  fetchImpl: opts.fetchImpl,
324
554
  });
325
- return reconcileEngineBackedWork(data, observation, opts);
555
+ const epoch = await reconcileEngineBackedWork(data, observation, opts);
556
+ // A distinct run id so the vanished pass's `reconcile_runs`/provenance rows never collide with the
557
+ // epoch pass's (run_id is a PRIMARY KEY). DERIVE it from the epoch pass's resolved run id — which is
558
+ // also the merged result's `runId` — so it is `<runId>-vanished` on EVERY path, including the boot
559
+ // path where `opts.runId` is omitted (a bare random UUID here would be non-correlatable to the
560
+ // returned `runId`). Operators can always locate the vanished pass's provenance from the reported id.
561
+ const vanished = await reconcileVanishedInstances(data, {
562
+ ...opts,
563
+ runId: `${epoch.runId}-vanished`,
564
+ });
565
+
566
+ const orphaned = [...epoch.orphaned, ...vanished.orphaned];
567
+ const reason: ReconcileReason =
568
+ epoch.reason === "epoch-regression"
569
+ ? "epoch-regression"
570
+ : vanished.orphanedCount > 0
571
+ ? "instance-vanished"
572
+ : epoch.reason;
573
+ return {
574
+ runId: epoch.runId,
575
+ reason,
576
+ observedEpoch: epoch.observedEpoch,
577
+ recordedEpoch: epoch.recordedEpoch,
578
+ orphanedCount: orphaned.length,
579
+ orphaned,
580
+ };
326
581
  }
package/main.ts CHANGED
@@ -103,12 +103,14 @@ if (httpServer instanceof Server) {
103
103
  app.log.warn("agentic channel not mounted: app.httpServer is not a node:http Server on this host");
104
104
  }
105
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.
106
+ // Engine-reset reconciliation (issues #622, #630). On boot, compare the engine's incarnation epoch
107
+ // against the last-seen value; on a REGRESSION (the engine was reset/restored/rewound and re-minted
108
+ // its keys, Magikcraft/nano-bpm#1065) drive every dangling engine-backed inflight row to the defined
109
+ // `orphaned` terminal WITH PROVENANCE. A second pass also folds any run whose engine instance has
110
+ // VANISHED from the read model (no `_urban_instance_state` row, past a grace window issue #630),
111
+ // which the epoch signal alone can't catch BEFORE the pollers below start projecting off stale,
112
+ // dead instances. Guarded: an unreachable engine / an absent projection is a no-op (never orphans
113
+ // live work), and any failure degrades to a warn so reconcile can never block boot.
112
114
  if (app.data) {
113
115
  try {
114
116
  const reconciled = await runEngineReconcile(
package/openapi.yaml CHANGED
@@ -1042,7 +1042,10 @@ components:
1042
1042
  type: integer
1043
1043
  ReconcileReport:
1044
1044
  type: object
1045
- description: The result of one engine-reset reconciliation pass (issue #622).
1045
+ description: "The merged result of the engine-reconcile invocation an aggregate of both the
1046
+ epoch-regression (engine reset/rewind) pass and the vanished-instance pass (issues #622 and
1047
+ #630). `runId` is the epoch pass's run id; the vanished pass records its own provenance under
1048
+ the derived id `<runId>-vanished`."
1046
1049
  additionalProperties: false
1047
1050
  required:
1048
1051
  - runId
@@ -1054,15 +1057,19 @@ components:
1054
1057
  properties:
1055
1058
  runId:
1056
1059
  type: string
1057
- description: The reconcile run id every orphaned transition's provenance is stamped with.
1060
+ description: The reconcile run id the epoch pass's orphaned-transition provenance is stamped
1061
+ with. The vanished-instance pass's provenance is stamped with the derived, deterministic id
1062
+ `<runId>-vanished`, so operators can locate the provenance rows for either pass from this id.
1058
1063
  reason:
1059
1064
  type: string
1060
- description: Why this pass acted (or did not).
1065
+ description: Why this pass acted (or did not). `instance-vanished` — a run whose engine
1066
+ instance is absent/unknown in the read model was orphaned (issue #630).
1061
1067
  enum:
1062
1068
  - epoch-regression
1063
1069
  - seed-epoch
1064
1070
  - no-op
1065
1071
  - engine-unreachable
1072
+ - instance-vanished
1066
1073
  observedEpoch:
1067
1074
  type: integer
1068
1075
  nullable: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.174.0",
3
+ "version": "0.174.1",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",