@nanobpm/nano-workforce 0.183.0 → 0.183.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/agentic/claim-registry.test.ts +3 -2
- package/app/agentic/claim-registry.ts +8 -6
- package/app/agentic/cockpit/supply-view.ts +3 -3
- package/app/agentic/cockpit/transcript-view.ts +1 -1
- package/app/agentic/correlation-store.test.ts +14 -10
- package/app/agentic/correlation-store.ts +4 -3
- package/app/agentic/correlation.test.ts +24 -16
- package/app/agentic/correlation.ts +25 -12
- package/app/agentic/families/claim.family.test.ts +2 -1
- package/app/agentic/families/relay.family.test.ts +74 -73
- package/app/agentic/families/relay.family.ts +24 -21
- package/app/agentic/transcript-read.test.ts +108 -17
- package/app/agentic/transcript-read.ts +41 -7
- package/app/contracts.ts +1 -1
- package/app/reconcile.test.ts +283 -19
- package/app/reconcile.ts +200 -23
- package/operations/getAgenticSupply.test.ts +41 -2
- package/operations/getAgenticSupply.ts +7 -5
- package/operations/getAgenticTranscript.test.ts +5 -4
- package/operations/listAgenticTranscripts.test.ts +10 -9
- package/package.json +1 -1
- package/test/agentic-e2e.test.ts +4 -1
package/app/reconcile.test.ts
CHANGED
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
// `makeGateway`), so the tables/columns/indexes reconcile reads and writes are the shipping schema.
|
|
11
11
|
import { DatabaseSync } from "node:sqlite";
|
|
12
12
|
import { test } from "node:test";
|
|
13
|
-
import {
|
|
13
|
+
import type { DataLayer, GatewayDataSource as DataSource } from "@nanobpm/urban";
|
|
14
|
+
import { assertEquals, assertNotEquals } from "#test-assert";
|
|
14
15
|
import { freshData } from "../test/reconcileDb.ts";
|
|
15
16
|
import {
|
|
16
17
|
DEFAULT_VANISHED_GRACE_MS,
|
|
18
|
+
makeEngineActiveProbe,
|
|
17
19
|
ORPHANED_STATUS,
|
|
18
20
|
parseEngineEpoch,
|
|
19
21
|
RECONCILE_ORPHAN_REASON,
|
|
@@ -393,28 +395,115 @@ test("idempotent: a second vanished pass is a no-op (the orphaned row left activ
|
|
|
393
395
|
assertEquals((raw.prepare("SELECT COUNT(*) c FROM reconcile_provenance").get() as { c: number }).c, 1);
|
|
394
396
|
});
|
|
395
397
|
|
|
398
|
+
// --- Engine-truth cross-check before orphaning a "vanished" row (issue #736) -------------------
|
|
399
|
+
// The `_urban_instance_state` projection is an app-side read model that can lag / be pruned / be
|
|
400
|
+
// rebuilt while the instance is still ACTIVE on the engine. "No projection row" is therefore NOT
|
|
401
|
+
// "instance vanished": on merlin (whose engine exposes no incarnation epoch, so the robust epoch
|
|
402
|
+
// detector is disabled) this false-orphaned 3 concurrently-LIVE instances in one pass. The vanished
|
|
403
|
+
// pass now cross-checks ENGINE TRUTH via `engineActive` before folding — an ACTIVE instance is spared.
|
|
404
|
+
|
|
405
|
+
test("RED→GREEN #736: an engine-ACTIVE instance with no _urban_instance_state row (past grace) is NOT orphaned", async () => {
|
|
406
|
+
const { data, raw } = freshData();
|
|
407
|
+
ensureInstanceState(raw);
|
|
408
|
+
// The merlin repro: an inflight run past grace whose projection row is absent (lagging/pruned) but
|
|
409
|
+
// whose instance the engine still reports ACTIVE. RED (pre-fix): folded to `orphaned`. GREEN: spared.
|
|
410
|
+
seedFeatureRun(raw, "nanobpm/nano-workforce#336", "running", "11625");
|
|
411
|
+
// Engine truth says ACTIVE for this key.
|
|
412
|
+
const engineActive = async (key: string) => (key === "11625" ? true : false);
|
|
413
|
+
|
|
414
|
+
const res = await reconcileVanishedInstances(data, { now: AT, runId: "van-1", engineActive });
|
|
415
|
+
|
|
416
|
+
assertEquals(res.reason, "no-op");
|
|
417
|
+
assertEquals(res.orphanedCount, 0);
|
|
418
|
+
const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='nanobpm/nano-workforce#336'").get() as {
|
|
419
|
+
status: string;
|
|
420
|
+
};
|
|
421
|
+
assertEquals(row.status, "running");
|
|
422
|
+
assertEquals((raw.prepare("SELECT COUNT(*) c FROM reconcile_provenance").get() as { c: number }).c, 0);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("#736: an engine-CONFIRMED-gone instance (engineActive=false) IS still orphaned", async () => {
|
|
426
|
+
const { data, raw } = freshData();
|
|
427
|
+
ensureInstanceState(raw);
|
|
428
|
+
seedFeatureRun(raw, "Magikcraft/nano-bpm#1051", "escalated", "71506");
|
|
429
|
+
// Engine answered and the instance is absent/terminated — genuinely gone in engine truth.
|
|
430
|
+
const engineActive = async () => false;
|
|
431
|
+
|
|
432
|
+
const res = await reconcileVanishedInstances(data, { now: AT, runId: "van-1", engineActive });
|
|
433
|
+
|
|
434
|
+
assertEquals(res.reason, "instance-vanished");
|
|
435
|
+
assertEquals(res.orphanedCount, 1);
|
|
436
|
+
const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='Magikcraft/nano-bpm#1051'").get() as {
|
|
437
|
+
status: string;
|
|
438
|
+
};
|
|
439
|
+
assertEquals(row.status, ORPHANED_STATUS);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("#736: an instance whose engine truth is UNKNOWN (engineActive=null) is spared — never orphan unconfirmed", async () => {
|
|
443
|
+
const { data, raw } = freshData();
|
|
444
|
+
ensureInstanceState(raw);
|
|
445
|
+
seedFeatureRun(raw, "o/r#unknown", "escalated", "71506");
|
|
446
|
+
// The engine truth could not be established (unreachable / non-2xx / malformed) — we must NOT orphan.
|
|
447
|
+
const engineActive = async () => null;
|
|
448
|
+
|
|
449
|
+
const res = await reconcileVanishedInstances(data, { now: AT, runId: "van-1", engineActive });
|
|
450
|
+
|
|
451
|
+
assertEquals(res.reason, "no-op");
|
|
452
|
+
assertEquals(res.orphanedCount, 0);
|
|
453
|
+
const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='o/r#unknown'").get() as {
|
|
454
|
+
status: string;
|
|
455
|
+
};
|
|
456
|
+
assertEquals(row.status, "escalated");
|
|
457
|
+
});
|
|
458
|
+
|
|
396
459
|
// --- Merged seam: runEngineReconcile (both passes, one result) --------------------------------
|
|
397
460
|
// The operator/startup seam merges the epoch-regression and vanished-instance passes into ONE
|
|
398
461
|
// 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`)
|
|
400
|
-
//
|
|
462
|
+
// correlation (the vanished pass's provenance must be locatable from the returned `runId`), the
|
|
463
|
+
// engine-truth cross-check the seam wires from the live engine (#736), and `reason` selection.
|
|
464
|
+
|
|
465
|
+
/** A `/v2` fetch stub: `/topology` answers `topologyBody` (200), and `/process-instances/search`
|
|
466
|
+
* answers with `searchItems` (200) — the engine-truth cross-check the vanished pass runs (#736). */
|
|
467
|
+
function engineFetch(
|
|
468
|
+
topologyBody: unknown,
|
|
469
|
+
searchItems: { processInstanceKey?: string | number; state?: string }[],
|
|
470
|
+
): typeof fetch {
|
|
471
|
+
return (async (url: string, init?: { method?: string }) => {
|
|
472
|
+
const u = String(url);
|
|
473
|
+
if (u.endsWith("/topology")) return new Response(JSON.stringify(topologyBody), { status: 200 });
|
|
474
|
+
if (u.endsWith("/process-instances/search") && init?.method === "POST") {
|
|
475
|
+
return new Response(JSON.stringify({ items: searchItems }), { status: 200 });
|
|
476
|
+
}
|
|
477
|
+
return new Response("not found", { status: 404 });
|
|
478
|
+
}) as unknown as typeof fetch;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
test("runEngineReconcile #736: a reachable engine reporting the instance ACTIVE spares it (no false-orphan)", async () => {
|
|
482
|
+
const { data, raw } = freshData();
|
|
483
|
+
ensureInstanceState(raw);
|
|
484
|
+
// No projection row, past grace — but the engine (reachable, no epoch, like merlin) reports ACTIVE.
|
|
485
|
+
seedFeatureRun(raw, "nanobpm/nano-workforce#731", "escalated", "11644");
|
|
486
|
+
const fetchImpl = engineFetch({ nano: { engine: "merlin" } }, [{ processInstanceKey: "11644", state: "ACTIVE" }]);
|
|
487
|
+
|
|
488
|
+
const res = await runEngineReconcile(data, { restAddress: "http://engine.local/v2" }, { now: AT, fetchImpl });
|
|
489
|
+
|
|
490
|
+
assertEquals(res.orphanedCount, 0);
|
|
491
|
+
const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='nanobpm/nano-workforce#731'").get() as {
|
|
492
|
+
status: string;
|
|
493
|
+
};
|
|
494
|
+
assertEquals(row.status, "escalated");
|
|
495
|
+
assertEquals((raw.prepare("SELECT COUNT(*) c FROM reconcile_provenance").get() as { c: number }).c, 0);
|
|
496
|
+
});
|
|
401
497
|
|
|
402
|
-
test("runEngineReconcile: engine
|
|
498
|
+
test("runEngineReconcile #736: a reachable engine that no longer knows the instance folds it, with a correlatable run id", async () => {
|
|
403
499
|
const { data, raw } = freshData();
|
|
404
500
|
ensureInstanceState(raw);
|
|
405
|
-
// A vanished orphan (escalated, past grace
|
|
501
|
+
// A genuinely-vanished orphan (escalated, past grace); the engine answers but the instance is absent.
|
|
406
502
|
seedFeatureRun(raw, "Magikcraft/nano-bpm#1051", "escalated", "71506");
|
|
503
|
+
const fetchImpl = engineFetch({ nano: { engine: "merlin" } }, []);
|
|
407
504
|
|
|
408
|
-
|
|
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
|
-
);
|
|
505
|
+
const res = await runEngineReconcile(data, { restAddress: "http://engine.local/v2" }, { now: AT, fetchImpl });
|
|
416
506
|
|
|
417
|
-
// The vanished pass acted even though the epoch pass could not reach the engine.
|
|
418
507
|
assertEquals(res.reason, "instance-vanished");
|
|
419
508
|
assertEquals(res.orphanedCount, 1);
|
|
420
509
|
const orphan = raw
|
|
@@ -429,14 +518,189 @@ test("runEngineReconcile: engine-unreachable epoch pass still folds vanished ins
|
|
|
429
518
|
.get() as { run_id: string };
|
|
430
519
|
assertEquals(prov.run_id, `${res.runId}-vanished`);
|
|
431
520
|
|
|
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
521
|
const vanishedRun = raw
|
|
438
522
|
.prepare("SELECT reason, orphaned_count FROM reconcile_runs WHERE run_id=?")
|
|
439
523
|
.get(`${res.runId}-vanished`) as { reason: string; orphaned_count: number } | undefined;
|
|
440
524
|
assertEquals(vanishedRun?.reason, "instance-vanished");
|
|
441
525
|
assertEquals(vanishedRun?.orphaned_count, 1);
|
|
442
526
|
});
|
|
527
|
+
|
|
528
|
+
test("runEngineReconcile #736: an UNREACHABLE engine spares vanished candidates (truth unconfirmed → never orphan)", async () => {
|
|
529
|
+
const { data, raw } = freshData();
|
|
530
|
+
ensureInstanceState(raw);
|
|
531
|
+
// A candidate that looks vanished (escalated, past grace, no projection row) — but with the engine
|
|
532
|
+
// unreachable we cannot confirm it is gone, so it MUST be spared (issue #736): the old projection-only
|
|
533
|
+
// behavior would have orphaned it, potentially false-orphaning a live instance mid-outage.
|
|
534
|
+
seedFeatureRun(raw, "Magikcraft/nano-bpm#1051", "escalated", "71506");
|
|
535
|
+
|
|
536
|
+
const fetchImpl = (() => Promise.reject(new Error("ECONNREFUSED"))) as unknown as typeof fetch;
|
|
537
|
+
const res = await runEngineReconcile(data, { restAddress: "http://engine.invalid" }, { now: AT, fetchImpl });
|
|
538
|
+
|
|
539
|
+
// The epoch pass could not reach the engine, and the vanished pass could not confirm death → no-op.
|
|
540
|
+
assertEquals(res.reason, "engine-unreachable");
|
|
541
|
+
assertEquals(res.orphanedCount, 0);
|
|
542
|
+
const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='Magikcraft/nano-bpm#1051'").get() as {
|
|
543
|
+
status: string;
|
|
544
|
+
};
|
|
545
|
+
assertEquals(row.status, "escalated");
|
|
546
|
+
assertEquals((raw.prepare("SELECT COUNT(*) c FROM reconcile_provenance").get() as { c: number }).c, 0);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// --- Hardening the cross-check itself (#736 review) ---------------------------------------------
|
|
550
|
+
// Two defects of the SAME class the first cut still carried — "never orphan a row we could not
|
|
551
|
+
// positively confirm is gone" — plus the lock-hold the cross-check introduced:
|
|
552
|
+
// 1. a MATCHING search item whose `state` was missing or outside the engine's lifecycle enum read as
|
|
553
|
+
// `false` ("gone"), so a malformed/partial engine answer folded live work;
|
|
554
|
+
// 2. the probe (network I/O) was awaited INSIDE `src.tx(...)`, so a slow or unreachable engine held
|
|
555
|
+
// the SQLite write transaction open for the probe's full timeout PER CANDIDATE ROW — stalling
|
|
556
|
+
// every other writer, including boot — and an injected probe that threw aborted the whole pass.
|
|
557
|
+
|
|
558
|
+
/** Answer `/v2/process-instances/search` with exactly `items` (200), so a probe's classification can
|
|
559
|
+
* be read off one wire shape varying only in the item's `state`. */
|
|
560
|
+
function searchItemsFetch(items: { processInstanceKey?: string | number; state?: string }[]): typeof fetch {
|
|
561
|
+
return (async () => new Response(JSON.stringify({ items }), { status: 200 })) as unknown as typeof fetch;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Probe the key `11644` against a stubbed engine search answer of `items`. */
|
|
565
|
+
function probeAgainst(items: { processInstanceKey?: string | number; state?: string }[]): Promise<boolean | null> {
|
|
566
|
+
const probe = makeEngineActiveProbe({ restAddress: "http://engine.local/v2" }, { fetchImpl: searchItemsFetch(items) });
|
|
567
|
+
return probe("11644");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
test("#736: the probe answers `true` for ACTIVE and `false` ONLY for a known-terminal engine state", async () => {
|
|
571
|
+
assertEquals(await probeAgainst([{ processInstanceKey: "11644", state: "ACTIVE" }]), true);
|
|
572
|
+
// The wire may carry the key as a JSON number and the state in any casing.
|
|
573
|
+
assertEquals(await probeAgainst([{ processInstanceKey: 11644, state: "active" }]), true);
|
|
574
|
+
for (const state of ["COMPLETED", "TERMINATED", "CANCELED", "FAILED"]) {
|
|
575
|
+
assertEquals(await probeAgainst([{ processInstanceKey: "11644", state }]), false, `${state} is a positive "gone"`);
|
|
576
|
+
}
|
|
577
|
+
// Absent from the read model is STILL a positive "gone": the engine answered and does not know it.
|
|
578
|
+
assertEquals(await probeAgainst([]), false);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("RED→GREEN #736: a MISSING or UNRECOGNIZED engine state is UNKNOWN truth (`null` → spare), never `false`", async () => {
|
|
582
|
+
// RED (pre-fix): the probe answered `String(match.state ?? "").toUpperCase() === "ACTIVE"`, so a
|
|
583
|
+
// partial item (no `state`) or a state outside the enum this app knows read as "gone" and folded the
|
|
584
|
+
// row — contradicting the probe's own contract that malformed engine truth degrades to `null`.
|
|
585
|
+
assertEquals(await probeAgainst([{ processInstanceKey: "11644" }]), null, "a missing state is not a confirmed death");
|
|
586
|
+
assertEquals(await probeAgainst([{ processInstanceKey: "11644", state: "" }]), null);
|
|
587
|
+
assertEquals(await probeAgainst([{ processInstanceKey: "11644", state: "SUSPENDED" }]), null);
|
|
588
|
+
// An item for a DIFFERENT key is no match for this one, so that stays "absent" (gone), not unknown.
|
|
589
|
+
assertEquals(await probeAgainst([{ processInstanceKey: "99999" }]), false);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("RED→GREEN #736: a malformed search item (no `state`) spares the candidate end-to-end", async () => {
|
|
593
|
+
const { data, raw } = freshData();
|
|
594
|
+
ensureInstanceState(raw);
|
|
595
|
+
// Past grace, no projection row, and the engine ANSWERS — but its item carries no lifecycle state, so
|
|
596
|
+
// engine truth is unestablished and the row must survive (RED pre-fix: folded to `orphaned`).
|
|
597
|
+
seedFeatureRun(raw, "nanobpm/nano-workforce#731", "escalated", "11644");
|
|
598
|
+
const fetchImpl = engineFetch({ nano: { engine: "merlin" } }, [{ processInstanceKey: "11644" }]);
|
|
599
|
+
|
|
600
|
+
const res = await runEngineReconcile(data, { restAddress: "http://engine.local/v2" }, { now: AT, fetchImpl });
|
|
601
|
+
|
|
602
|
+
assertEquals(res.orphanedCount, 0);
|
|
603
|
+
const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='nanobpm/nano-workforce#731'").get() as {
|
|
604
|
+
status: string;
|
|
605
|
+
};
|
|
606
|
+
assertEquals(row.status, "escalated");
|
|
607
|
+
assertEquals((raw.prepare("SELECT COUNT(*) c FROM reconcile_provenance").get() as { c: number }).c, 0);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("RED→GREEN #736: a probe that THROWS is unknown truth — it spares the row instead of aborting the pass", async () => {
|
|
611
|
+
const { data, raw } = freshData();
|
|
612
|
+
ensureInstanceState(raw);
|
|
613
|
+
seedFeatureRun(raw, "o/r#boom", "escalated", "71506");
|
|
614
|
+
// `engineActive` is injectable: an implementation that rejects means truth could NOT be established
|
|
615
|
+
// (spare), and must not bubble out of the pass.
|
|
616
|
+
const engineActive = async (): Promise<boolean | null> => {
|
|
617
|
+
throw new Error("engine exploded");
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const res = await reconcileVanishedInstances(data, { now: AT, runId: "van-1", engineActive });
|
|
621
|
+
|
|
622
|
+
assertEquals(res.reason, "no-op");
|
|
623
|
+
assertEquals(res.orphanedCount, 0);
|
|
624
|
+
const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='o/r#boom'").get() as { status: string };
|
|
625
|
+
assertEquals(row.status, "escalated");
|
|
626
|
+
// The pass COMPLETED and recorded its run — pre-fix the throw escaped the transaction and rejected the
|
|
627
|
+
// whole reconcile, so no `reconcile_runs` row was written at all.
|
|
628
|
+
const run = raw.prepare("SELECT reason, orphaned_count FROM reconcile_runs WHERE run_id='van-1'").get() as
|
|
629
|
+
| { reason: string; orphaned_count: number }
|
|
630
|
+
| undefined;
|
|
631
|
+
assertEquals(run?.reason, "no-op");
|
|
632
|
+
assertEquals(run?.orphaned_count, 0);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("RED→GREEN #736: the probe (network I/O) is never awaited INSIDE the write transaction", async () => {
|
|
636
|
+
const { data, raw } = freshData();
|
|
637
|
+
ensureInstanceState(raw);
|
|
638
|
+
seedFeatureRun(raw, "o/r#tx", "escalated", "71506");
|
|
639
|
+
|
|
640
|
+
// Wrap the gateway so the probe can report how deep in `src.tx(...)` it was awaited. RED (pre-fix):
|
|
641
|
+
// depth 1 — every candidate row held the SQLite write transaction open across a network round trip
|
|
642
|
+
// (up to the probe's full timeout on a slow/unreachable engine), delaying every other writer and
|
|
643
|
+
// slowing/locking boot. GREEN: depth 0 — the probes finish first, and only the guarded UPDATE +
|
|
644
|
+
// provenance writes run in a short transaction.
|
|
645
|
+
let depth = 0;
|
|
646
|
+
const observed: number[] = [];
|
|
647
|
+
const inner = data.open();
|
|
648
|
+
const tracked = {
|
|
649
|
+
open: () => ({
|
|
650
|
+
query: (sql: string, params?: unknown[]) => inner.query(sql, params),
|
|
651
|
+
exec: (sql: string, params?: unknown[]) => inner.exec(sql, params),
|
|
652
|
+
schema: () => inner.schema(),
|
|
653
|
+
table: (name: string, pk?: string) => inner.table(name, pk),
|
|
654
|
+
tx: async <T>(fn: (t: DataSource) => Promise<T>): Promise<T> => {
|
|
655
|
+
depth += 1;
|
|
656
|
+
try {
|
|
657
|
+
return await inner.tx(fn);
|
|
658
|
+
} finally {
|
|
659
|
+
depth -= 1;
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
}),
|
|
663
|
+
} as unknown as DataLayer;
|
|
664
|
+
|
|
665
|
+
const engineActive = async (): Promise<boolean | null> => {
|
|
666
|
+
observed.push(depth);
|
|
667
|
+
return false;
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
const res = await reconcileVanishedInstances(tracked, { now: AT, runId: "van-tx", engineActive });
|
|
671
|
+
|
|
672
|
+
// Exactly one entry: the seeded row also mirrors into `delivery_units` (same `process_key`), and one
|
|
673
|
+
// instance is probed once (see the next test) — at depth 0, outside any open write transaction.
|
|
674
|
+
assertEquals(observed, [0], "the engine-truth probe must be awaited outside any open write transaction");
|
|
675
|
+
// Hoisting the probe out of the transaction must not weaken the pass: a confirmed-gone row still folds.
|
|
676
|
+
assertEquals(res.orphanedCount, 1);
|
|
677
|
+
const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='o/r#tx'").get() as { status: string };
|
|
678
|
+
assertEquals(row.status, ORPHANED_STATUS);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test("#736: one instance backing several tracked rows is probed ONCE (engine truth is per instance)", async () => {
|
|
682
|
+
const { data, raw } = freshData();
|
|
683
|
+
ensureInstanceState(raw);
|
|
684
|
+
// A `feature_runs` row is ALSO mirrored into the `delivery_units` aggregate by DB trigger
|
|
685
|
+
// (db/migrations/089), carrying the same `process_key` — so one vanished instance yields TWO
|
|
686
|
+
// candidates. Engine truth is per instance, so it must be asked once, not once per row (each probe
|
|
687
|
+
// can block for its full timeout on a slow engine).
|
|
688
|
+
seedFeatureRun(raw, "o/r#mirror", "escalated", "71506");
|
|
689
|
+
const probed: string[] = [];
|
|
690
|
+
const engineActive = async (key: string): Promise<boolean | null> => {
|
|
691
|
+
probed.push(key);
|
|
692
|
+
return false;
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
const res = await reconcileVanishedInstances(data, { now: AT, runId: "van-mirror", engineActive });
|
|
696
|
+
|
|
697
|
+
assertEquals(probed, ["71506"], "one probe per distinct instance key");
|
|
698
|
+
// Folding the base row re-projects its mirror (the sync trigger clears `dispatch_status`), so the
|
|
699
|
+
// mirror is not folded a second time for the same instance — no duplicate provenance.
|
|
700
|
+
assertEquals(res.orphaned.map((o) => o.table), ["feature_runs"]);
|
|
701
|
+
assertEquals(res.orphanedCount, 1);
|
|
702
|
+
const mirror = raw.prepare("SELECT dispatch_status FROM delivery_units WHERE legacy_key='o/r#mirror'").get() as {
|
|
703
|
+
dispatch_status: string | null;
|
|
704
|
+
};
|
|
705
|
+
assertNotEquals(mirror.dispatch_status, "dispatched");
|
|
706
|
+
});
|
package/app/reconcile.ts
CHANGED
|
@@ -38,8 +38,13 @@
|
|
|
38
38
|
// `reconcileVanishedInstances` drives every such row — active, dispatched, its key absent from
|
|
39
39
|
// `_urban_instance_state`, and PAST A GRACE WINDOW (so a still-starting run not yet projected is
|
|
40
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. `
|
|
42
|
-
//
|
|
41
|
+
// tell a vanished-instance orphan apart from an epoch-regression one. Because `_urban_instance_state`
|
|
42
|
+
// is an app-side projection that can lag / be pruned / be rebuilt while the instance is still ACTIVE
|
|
43
|
+
// on the engine, "no projection row" is NOT proof the instance vanished — so before folding, the pass
|
|
44
|
+
// CROSS-CHECKS ENGINE TRUTH (`/v2/process-instances/search`, issue #736): an instance the engine still
|
|
45
|
+
// reports ACTIVE (or whose truth cannot be established) is SPARED, closing the false-orphan class on
|
|
46
|
+
// deployments whose engine omits the incarnation epoch. `runEngineReconcile` runs BOTH passes, so
|
|
47
|
+
// startup and the operator command converge both failure modes in one call.
|
|
43
48
|
//
|
|
44
49
|
// The provenance is app-owned (not urban's `_urban_write_provenance`, which is a domain-free
|
|
45
50
|
// insert-join sidecar written only inside a job): reconcile runs at boot / over HTTP, outside any
|
|
@@ -152,6 +157,15 @@ export interface VanishedReconcileOptions extends ReconcileOptions {
|
|
|
152
157
|
* still-starting run (not yet projected into `_urban_instance_state`) is not orphaned prematurely.
|
|
153
158
|
* Defaults to {@link DEFAULT_VANISHED_GRACE_MS}. */
|
|
154
159
|
graceMs?: number;
|
|
160
|
+
/** Cross-check against ENGINE TRUTH before orphaning a candidate row (issue #736). Given the row's
|
|
161
|
+
* `keyField` (process instance key), it reports whether the engine still considers the instance
|
|
162
|
+
* ACTIVE. An ACTIVE instance is NEVER orphaned — the app-side `_urban_instance_state` projection is
|
|
163
|
+
* merely lagging — and an instance whose truth could not be established (`null`) is spared too (we
|
|
164
|
+
* never orphan what we could not confirm dead). Only a row the engine positively confirms is gone
|
|
165
|
+
* (`false`) is folded. When omitted the pass falls back to projection-only behaviour (no live
|
|
166
|
+
* cross-check); production always wires one via {@link runEngineReconcile}. See
|
|
167
|
+
* {@link makeEngineActiveProbe}. */
|
|
168
|
+
engineActive?: EngineActiveProbe;
|
|
155
169
|
}
|
|
156
170
|
|
|
157
171
|
/** Read the incarnation epoch out of a `/v2/topology` body — `nano.incarnation` (or its `epoch`
|
|
@@ -189,6 +203,80 @@ export async function probeEngineEpoch(
|
|
|
189
203
|
}
|
|
190
204
|
}
|
|
191
205
|
|
|
206
|
+
/** A cross-check against ENGINE TRUTH for one process instance key, used to spare a live instance from
|
|
207
|
+
* the vanished-instance pass. Returns:
|
|
208
|
+
* • `true` — the engine reports the instance ACTIVE. It is live; the app-side `_urban_instance_state`
|
|
209
|
+
* projection is merely lagging/pruned/rebuilding, so the row MUST NOT be orphaned.
|
|
210
|
+
* • `false` — the engine answered and the instance is NOT active: absent from the read model, or in a
|
|
211
|
+
* known terminal state ({@link ENGINE_TERMINAL_STATES}). It is genuinely gone in engine
|
|
212
|
+
* truth → orphan-eligible.
|
|
213
|
+
* • `null` — engine truth could NOT be established (unreachable, non-2xx, malformed — including an
|
|
214
|
+
* item whose `state` is missing or outside {@link ENGINE_TERMINAL_STATES}). We never
|
|
215
|
+
* orphan a row we could not confirm dead, so the caller spares it (a transient outage
|
|
216
|
+
* must never fold live work). */
|
|
217
|
+
export type EngineActiveProbe = (processKey: string) => Promise<boolean | null>;
|
|
218
|
+
|
|
219
|
+
/** One item of a `/v2/process-instances/search` result, narrowed to what the engine-truth cross-check
|
|
220
|
+
* reads: the instance key (to match the row we probed for) and its lifecycle `state`. Keys are
|
|
221
|
+
* stringified defensively (the wire may send a JSON number or string); `state` is the engine's
|
|
222
|
+
* lifecycle enum (`ACTIVE`/`COMPLETED`/`CANCELED`/…). */
|
|
223
|
+
interface InstanceSearchStateItem {
|
|
224
|
+
processInstanceKey?: string | number;
|
|
225
|
+
state?: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The lifecycle states that POSITIVELY mean "this instance is no longer running" — urban's
|
|
229
|
+
* `ProcessInstanceState` terminals (`COMPLETED`/`TERMINATED`) plus the Camunda-8-parity v2 REST
|
|
230
|
+
* terminals (`CANCELED`/`FAILED`), since the probe reads that raw surface rather than the typed
|
|
231
|
+
* client. ONLY these may answer `false` (orphan-eligible): a `state` that is missing, empty, or
|
|
232
|
+
* outside this set is a partial/malformed read this app cannot interpret, so it degrades to `null`
|
|
233
|
+
* ("unknown" → the caller spares the row). Classifying an unrecognized state as "gone" would fold
|
|
234
|
+
* live work off a wire shape we misread — the exact failure mode the #736 cross-check exists to stop. */
|
|
235
|
+
const ENGINE_TERMINAL_STATES: ReadonlySet<string> = new Set(["COMPLETED", "TERMINATED", "CANCELED", "FAILED"]);
|
|
236
|
+
|
|
237
|
+
/** Build an {@link EngineActiveProbe} that queries the engine's own `/v2/process-instances/search` for
|
|
238
|
+
* a single process instance key and reports whether the engine still considers it ACTIVE. This is the
|
|
239
|
+
* authoritative engine-truth check the vanished-instance pass consults before orphaning: an ACTIVE
|
|
240
|
+
* engine instance must NEVER be orphaned regardless of the app-side projection (issue #736), so a
|
|
241
|
+
* merlin-style deployment whose `_urban_instance_state` lags no longer false-orphans live work.
|
|
242
|
+
* Never throws — every transport/parse failure, and every `state` this app cannot interpret (missing,
|
|
243
|
+
* or outside {@link ENGINE_TERMINAL_STATES}), degrades to `null` ("unknown"), which the caller treats
|
|
244
|
+
* as "spare" (we never orphan what we could not confirm dead). */
|
|
245
|
+
export function makeEngineActiveProbe(
|
|
246
|
+
engineRest: { restAddress: string; token?: string },
|
|
247
|
+
opts: { fetchImpl?: typeof fetch; timeoutMs?: number } = {},
|
|
248
|
+
): EngineActiveProbe {
|
|
249
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
250
|
+
const base = engineRest.restAddress.replace(/\/+$/, "");
|
|
251
|
+
const headers: Record<string, string> = { accept: "application/json", "content-type": "application/json" };
|
|
252
|
+
if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
|
|
253
|
+
return async (processKey: string): Promise<boolean | null> => {
|
|
254
|
+
try {
|
|
255
|
+
const res = await fetchImpl(`${base}/process-instances/search`, {
|
|
256
|
+
method: "POST",
|
|
257
|
+
headers,
|
|
258
|
+
body: JSON.stringify({ filter: { processInstanceKey: processKey }, page: { from: 0, limit: 10 } }),
|
|
259
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 3000),
|
|
260
|
+
});
|
|
261
|
+
if (!res.ok) return null;
|
|
262
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
263
|
+
const body = (await res.json()) as { items?: InstanceSearchStateItem[] };
|
|
264
|
+
const items = body.items ?? [];
|
|
265
|
+
const match = items.find((it) => it.processInstanceKey != null && String(it.processInstanceKey) === processKey);
|
|
266
|
+
// Engine answered but the instance is absent from the read model → genuinely gone (not active).
|
|
267
|
+
if (!match) return false;
|
|
268
|
+
const state = String(match.state ?? "").trim().toUpperCase();
|
|
269
|
+
if (state === "ACTIVE") return true;
|
|
270
|
+
// A KNOWN terminal state is a positive "gone". Anything else — a missing/empty `state`, or one
|
|
271
|
+
// outside the enum this app can interpret — is a partial or malformed answer, NOT a confirmed
|
|
272
|
+
// death, so it degrades to `null` and the caller spares the row.
|
|
273
|
+
return ENGINE_TERMINAL_STATES.has(state) ? false : null;
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
192
280
|
/** Double-quote a SQL identifier (table/column) so a manifest-declared name is safe to interpolate. */
|
|
193
281
|
function q(id: string): string {
|
|
194
282
|
return `"${id.replace(/"/g, '""')}"`;
|
|
@@ -334,19 +422,24 @@ function withinGrace(updated: unknown, nowMs: number, graceMs: number): boolean
|
|
|
334
422
|
return nowMs - t < graceMs;
|
|
335
423
|
}
|
|
336
424
|
|
|
337
|
-
/**
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
425
|
+
/** A vanished-instance candidate: the row selected for possible orphaning plus the binding shape that
|
|
426
|
+
* resolves its physical schema. Selected OUTSIDE any transaction, because the engine-truth
|
|
427
|
+
* cross-check that narrows these is network I/O (see {@link confirmVanishedGone}). */
|
|
428
|
+
interface VanishedCandidate {
|
|
429
|
+
shape: BindingShape;
|
|
430
|
+
row: OrphanCandidate;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** SELECT every NON-terminal, engine-backed row whose `keyField` (process instance key) has NO
|
|
434
|
+
* `_urban_instance_state` row — the instance is absent/unknown in the app-side read model (vanished,
|
|
435
|
+
* issue #630) — and whose last transition is older than the grace window. READ-ONLY and network-free,
|
|
436
|
+
* so it is safe to run outside the orphaning transaction. */
|
|
437
|
+
async function selectVanishedCandidates(
|
|
343
438
|
src: DataSource,
|
|
344
|
-
runId: string,
|
|
345
|
-
at: string,
|
|
346
439
|
nowMs: number,
|
|
347
440
|
graceMs: number,
|
|
348
|
-
): Promise<
|
|
349
|
-
const
|
|
441
|
+
): Promise<VanishedCandidate[]> {
|
|
442
|
+
const candidates: VanishedCandidate[] = [];
|
|
350
443
|
for (const binding of engineBackedBindings()) {
|
|
351
444
|
const shape = await resolveShape(src, binding);
|
|
352
445
|
if (!shape) continue;
|
|
@@ -360,17 +453,79 @@ async function orphanVanishedRows(
|
|
|
360
453
|
`AND NOT EXISTS (SELECT 1 FROM ${q(INSTANCE_STATE_TABLE)} s WHERE s.process_instance_key = b.${q(shape.keyField)})`,
|
|
361
454
|
[...shape.active],
|
|
362
455
|
);
|
|
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
456
|
for (const row of rows) {
|
|
369
457
|
if (shape.hasUpdatedAt && withinGrace(row.__updated, nowMs, graceMs)) continue;
|
|
370
|
-
|
|
371
|
-
if (o) orphaned.push(o);
|
|
458
|
+
candidates.push({ shape, row });
|
|
372
459
|
}
|
|
373
460
|
}
|
|
461
|
+
return candidates;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Narrow candidates to the ones ENGINE TRUTH positively confirms are gone (issue #736): the
|
|
465
|
+
* `_urban_instance_state` projection is an app-side read model that can lag / be pruned / be rebuilt
|
|
466
|
+
* while the instance is still ACTIVE on the engine, so "no projection row" is NOT "instance vanished".
|
|
467
|
+
* An ACTIVE instance (`true`) is spared, and one whose truth could not be established (`null` — engine
|
|
468
|
+
* unreachable, malformed answer, or a probe that THREW) is spared too: we never orphan a row we could
|
|
469
|
+
* not positively confirm is gone, and an injected probe's failure must never abort the pass. Only a
|
|
470
|
+
* `false` (engine confirms absent/terminated) survives. Runs OUTSIDE the DB transaction — this is
|
|
471
|
+
* network I/O, and awaiting it under an open write transaction would hold the SQLite lock for up to
|
|
472
|
+
* the probe's timeout PER ROW, stalling every other writer (including boot). Without a probe (omitted)
|
|
473
|
+
* the pass falls back to projection-only behaviour, so every candidate survives. */
|
|
474
|
+
async function confirmVanishedGone(
|
|
475
|
+
candidates: VanishedCandidate[],
|
|
476
|
+
engineActive?: EngineActiveProbe,
|
|
477
|
+
): Promise<VanishedCandidate[]> {
|
|
478
|
+
if (!engineActive) return candidates;
|
|
479
|
+
// Engine truth is PER INSTANCE, not per row, and one instance can back several tracked rows: the
|
|
480
|
+
// `delivery_units` aggregate is a DB-trigger mirror of its legacy base row (db/migrations/089) and
|
|
481
|
+
// carries the same `process_key`, so both are candidates for one vanished instance. Probe each
|
|
482
|
+
// DISTINCT key once and apply that verdict to every row carrying it — the same answer, without
|
|
483
|
+
// doubling engine calls that can each block for the probe's full timeout.
|
|
484
|
+
const verdicts = new Map<string, boolean | null>();
|
|
485
|
+
const gone: VanishedCandidate[] = [];
|
|
486
|
+
for (const candidate of candidates) {
|
|
487
|
+
const key = candidate.row.__key == null ? null : String(candidate.row.__key);
|
|
488
|
+
// Defensive: the SELECT requires a populated key, so with no key there is nothing to cross-check
|
|
489
|
+
// and the projection-only verdict stands.
|
|
490
|
+
if (key == null) {
|
|
491
|
+
gone.push(candidate);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
let verdict = verdicts.get(key);
|
|
495
|
+
if (verdict === undefined) {
|
|
496
|
+
try {
|
|
497
|
+
verdict = await engineActive(key);
|
|
498
|
+
} catch {
|
|
499
|
+
// A probe that throws established nothing — treat it exactly like an unreachable engine.
|
|
500
|
+
verdict = null;
|
|
501
|
+
}
|
|
502
|
+
verdicts.set(key, verdict);
|
|
503
|
+
}
|
|
504
|
+
if (verdict === false) gone.push(candidate);
|
|
505
|
+
}
|
|
506
|
+
return gone;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Fold each confirmed-gone candidate to the `orphaned` terminal, recording one
|
|
510
|
+
* `reconcile_provenance` row per transition (reason {@link RECONCILE_VANISHED_REASON}). WRITE-ONLY and
|
|
511
|
+
* network-free, so the caller's transaction stays short. Runs inside the caller's transaction. */
|
|
512
|
+
async function orphanVanishedCandidates(
|
|
513
|
+
t: DataSource,
|
|
514
|
+
candidates: VanishedCandidate[],
|
|
515
|
+
runId: string,
|
|
516
|
+
at: string,
|
|
517
|
+
): Promise<OrphanedRow[]> {
|
|
518
|
+
const orphaned: OrphanedRow[] = [];
|
|
519
|
+
for (const { shape, row } of candidates) {
|
|
520
|
+
// Re-assert "still no instance-state row" in the guarded UPDATE (which also re-asserts the exact
|
|
521
|
+
// status read), so a row that went terminal — or an instance that reappeared, the poller recording
|
|
522
|
+
// it — between the out-of-transaction SELECT/probe and this UPDATE wins the race.
|
|
523
|
+
const stillVanishedGuard =
|
|
524
|
+
` AND NOT EXISTS (SELECT 1 FROM ${q(INSTANCE_STATE_TABLE)} s ` +
|
|
525
|
+
`WHERE s.process_instance_key = ${q(shape.table)}.${q(shape.keyField)})`;
|
|
526
|
+
const o = await orphanRow(t, shape, row, RECONCILE_VANISHED_REASON, null, runId, at, stillVanishedGuard);
|
|
527
|
+
if (o) orphaned.push(o);
|
|
528
|
+
}
|
|
374
529
|
return orphaned;
|
|
375
530
|
}
|
|
376
531
|
|
|
@@ -475,9 +630,20 @@ export async function reconcileEngineBackedWork(
|
|
|
475
630
|
* it), every dispatched row would look vanished, so the pass is a hard no-op.
|
|
476
631
|
* • GUARDED — the same status-re-assert as the epoch pass, plus a still-vanished re-check, so a
|
|
477
632
|
* concurrent terminal write or a reappearing instance wins the race.
|
|
478
|
-
* •
|
|
479
|
-
*
|
|
480
|
-
*
|
|
633
|
+
* • ENGINE-TRUTH CROSS-CHECK (issue #736) — the `_urban_instance_state` projection is an app-side
|
|
634
|
+
* read model that can lag / be pruned / be rebuilt for an instance that is still ACTIVE on the
|
|
635
|
+
* engine, so "no projection row" is NOT "instance vanished". Before folding, the pass consults
|
|
636
|
+
* `opts.engineActive` (production wires one from the live engine via {@link makeEngineActiveProbe};
|
|
637
|
+
* {@link runEngineReconcile}): an instance the engine reports ACTIVE — or whose truth could not be
|
|
638
|
+
* established (engine unreachable, malformed answer, a probe that threw) — is SPARED. Only an
|
|
639
|
+
* instance the engine positively confirms is gone is orphaned. Without a cross-check (omitted) the
|
|
640
|
+
* pass falls back to projection-only.
|
|
641
|
+
* • SHORT TRANSACTION — the cross-check is network I/O, so the candidate SELECT and every probe run
|
|
642
|
+
* OUTSIDE `src.tx(...)`; the transaction covers only the guarded UPDATE + provenance writes. A
|
|
643
|
+
* slow or unreachable engine therefore cannot hold the SQLite write lock open (for up to the
|
|
644
|
+
* probe's timeout per candidate row), stalling other writers or boot. The guards make the split
|
|
645
|
+
* safe: a row that went terminal, or an instance that reappeared in the projection, between the
|
|
646
|
+
* out-of-transaction read and the in-transaction UPDATE wins the race and is not folded.
|
|
481
647
|
*/
|
|
482
648
|
export async function reconcileVanishedInstances(
|
|
483
649
|
data: DataLayer,
|
|
@@ -498,8 +664,13 @@ export async function reconcileVanishedInstances(
|
|
|
498
664
|
return { runId, reason: "no-op", observedEpoch: null, recordedEpoch: null, orphanedCount: 0, orphaned: [] };
|
|
499
665
|
}
|
|
500
666
|
|
|
667
|
+
// READ + PROBE first, transaction second: the engine-truth cross-check is network I/O and must never
|
|
668
|
+
// be awaited under an open write transaction (see SHORT TRANSACTION above).
|
|
669
|
+
const candidates = await selectVanishedCandidates(src, nowMs, graceMs);
|
|
670
|
+
const confirmedGone = await confirmVanishedGone(candidates, opts.engineActive);
|
|
671
|
+
|
|
501
672
|
const orphaned = await src.tx(async (t) => {
|
|
502
|
-
const rows = await
|
|
673
|
+
const rows = await orphanVanishedCandidates(t, confirmedGone, runId, at);
|
|
503
674
|
const reason: ReconcileReason = rows.length > 0 ? "instance-vanished" : "no-op";
|
|
504
675
|
await recordRun(t, { runId, at, observedEpoch: null, recordedEpoch: null, reason, orphanedCount: rows.length });
|
|
505
676
|
return rows;
|
|
@@ -561,6 +732,12 @@ export async function runEngineReconcile(
|
|
|
561
732
|
const vanished = await reconcileVanishedInstances(data, {
|
|
562
733
|
...opts,
|
|
563
734
|
runId: `${epoch.runId}-vanished`,
|
|
735
|
+
// Cross-check ENGINE TRUTH before orphaning any vanished-instance candidate (issue #736): an
|
|
736
|
+
// instance the engine still reports ACTIVE is spared even when its `_urban_instance_state`
|
|
737
|
+
// projection is absent (the merlin false-orphan: the projection lags, the instance is live). A
|
|
738
|
+
// caller-supplied `engineActive` wins (tests inject a deterministic one); otherwise build one from
|
|
739
|
+
// the same engine address/token the epoch probe used.
|
|
740
|
+
engineActive: opts.engineActive ?? makeEngineActiveProbe(engineRest, { fetchImpl: opts.fetchImpl }),
|
|
564
741
|
});
|
|
565
742
|
|
|
566
743
|
const orphaned = [...epoch.orphaned, ...vanished.orphaned];
|