@nanobpm/nano-workforce 0.173.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.
@@ -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
+ });