feat(agent): chain-driven VM reconciliation engine (Phase B)#910
feat(agent): chain-driven VM reconciliation engine (Phase B)#910branarakic wants to merge 11 commits into
Conversation
…ase B foundation) Foundational layer for chain-driven VM reconciliation: - chain-adapter: add optional getContextGraphKCCount / getContextGraphKCAt — the per-CG registration ordinal that the reconciler uses as its dense, monotonic cursor key (resolves the B.0 ordering-key spike). - evm-adapter: implement the two reads (ContextGraphStorage) and decode the KnowledgeAssetRegisteredToContextGraph event in listenForEvents. - mock-adapter: implement ordinal reads over the in-memory collections map (insertion order == registration order) + __emitKARegisteredToContextGraph test helper. - chain-event-poller: subscribe to KnowledgeAssetRegisteredToContextGraph and surface it via a new onKARegisteredToContextGraph callback (low-latency nudge; gated so it only subscribes when wired). Tests: mock ordinal reads + event surfacing; poller dispatch + opt-in gating. Co-authored-by: Cursor <cursoragent@cursor.com>
…nHash (Phase B.3) Adds the persisted per-CG reconciliation cursor and fixes a latent persistence gap: - ContextGraphSub / ContextGraphSubscriptionRecord: add lastReconciledOrdinal (the per-CG registration-ordinal VM watermark — count of KAs promoted to VM contiguously). The sweep resumes from here, so it MUST survive restarts. - db.ts: V17 migration adds on_chain_hash + last_reconciled_ordinal columns (defensive PRAGMA-then-ALTER), threads them through upsert + row type. - onChainHash was declared on the type but never persisted (no column + the save/load mapping dropped it); V17 folds in that fix so host-mode recovery resumes on the right topic after restart. - dkg-agent: thread both fields through persist + rehydrate. - lifecycle: thread both fields through loadAll + save. Tests: column round-trip (NULL default + set/advance), V16->V17 upgrade preserves rows, fresh install carries columns; bump current-version pins. Co-authored-by: Cursor <cursoragent@cursor.com>
…/B.3b core) Pure, side-effect-free cursor over the per-CG registration ordinal: - watermark === N means ordinals [0,N) are durably in VM; gap to reconcile is [watermark, head) where head = getContextGraphKCCount(cgId). - recordCompletion/absorbConfirmed enforce the two correctness invariants the plan flags as critical: (1) no gap is ever skipped (out-of-order completions wait in `ahead` until the run below them fills), (2) reorg safety (an ordinal only advances the watermark once its registration block is buried by confirmationDepth; promotion stays eager, the cursor advance waits). - ordinalsToReconcile drives the B.2 sweep and skips already-completed-but-held ordinals so a sweep never re-fetches them; a failed (unrecorded) fetch is naturally retried next sweep. Extracted as a pure module so the watermark logic is unit-testable without a chain/libp2p/store harness (12 tests: in-order, gap-hold, out-of-order burst, depth gate, depth+gap interaction, sweep planning, fetch-retry). Co-authored-by: Cursor <cursoragent@cursor.com>
…ciledKC (Phase B.5) - Extract the TOCTOU-safe promoteUnderLocks/runUnderLocks inline closure from handleFinalizationMessage into a private applyVerifiedFinalization(...) so the gossip path and the new chain-driven path share one locked-promotion impl. Gossip behavior is byte-identical (existing 25 finalization tests stay green); the only change is captured locals become explicit params. - Add public handleChainReconciledKC(input, ctx): promotes a chain-registered KC to VM with no gossip FinalizationMessage. Idempotent (already-confirmed), recovers the published roots by matching the chain merkleRoot to a local SWM WorkspaceOperation's RECOMPUTED flat-KC root (SWM meta has no stored root since it predates publish), runs the same verifyOnChain + applyVerifiedFinalization, and does not re-broadcast. Returns promoted | already-confirmed | no-swm | unverified | stale-target so the agent reconciler can drive the watermark. - handleFinalizationMessage signature unchanged (still the sole gossip entry). Tests: 4 new cases (promoted via verifying chain, no-swm on root mismatch, unverified with no chain, already-confirmed idempotency). Co-authored-by: Cursor <cursoragent@cursor.com>
…se B.2/B.4 core) Testable, side-effect-injected orchestrator over the ordinal cursor: - reconcileContextGraph: one sweep pass for a CG — reconcile [watermark, head) via injected reconcileOrdinal, advance the contiguous + confirmation-depth watermark, persist only when it moves. Pending ordinals hold the watermark and are retried next sweep; held ordinals are never re-attempted. - ReconcileCoalescer: per-CG single-flight so a burst of KACG events triggers one sweep (+ one trailing run if events land mid-sweep), not N. - RecentUalSet: bounded UAL dedupe for the live-event nudge. Extracted as a module (deps injected) so the sweep/watermark/coalescing logic is unit-tested without a chain/libp2p/store harness (8 tests). Agent wiring of the real reconcileOrdinal + poller callback + sweep timer is the next step. Co-authored-by: Cursor <cursoragent@cursor.com>
…(Phase B.5) handleChainReconciledKC now verifies the KA→CG binding with a direct getKAContextGraphId(kaId) chain read instead of re-scanning KCCreated events. The chain-driven trigger is already chain truth (merkleRoot + publisher come from getLatestMerkleRoot/Publisher), so the event re-scan (which defends the untrusted gossip wire) was redundant — and the sweep path has no event, hence no txHash/block to scan with. Integrity is still the flat-KC root recompute match against the chain merkleRoot. The materialization version is stamped with the chain head block (getLatestMerkleRoot returns latest state, so reconcile is "as of now"; a later real update supersedes correctly). Drops txHash/blockNumber from the input in favour of versionBlock; adds a 'bound to a different CG' -> unverified guard. Tests updated. Co-authored-by: Cursor <cursoragent@cursor.com>
…e B.4)
Integrates the chain-reconciler sweep engine into DKGAgent:
* Live nudge: the ChainEventPoller's onKARegisteredToContextGraph
callback resolves the local CG and triggers a coalesced sweep — the
event carries no ordinal, so it's a low-latency nudge, never a cursor
position.
* Periodic + startup sweep (DKG_VM_RECONCILE_INTERVAL_MS, default 60s):
the safety net that backfills missed events / transient fetch
failures and catches up late subscribers (the "Monday Fun Facts"
case). Primed once on start so a late subscriber reconciles
immediately.
* reconcileChainOrdinal: per-ordinal driver — getContextGraphKCAt ->
kaId -> getLatestMerkleRoot/Publisher -> handleChainReconciledKC,
with a core-first active catch-up fetch + one retry on no-swm.
headBlock is reused as the materialization version AND the cursor
observation block (reorg gate), so no per-ordinal block lookup.
* Watermark persistence via lastReconciledOrdinal (V17 column); cursor
state held per-CG in memory and rehydrated from the persisted
watermark.
Only armed when the chain adapter exposes the per-CG registration-ordinal
reads (vmReconcileEnabled), so non-V10 / no-chain nodes pay nothing.
reconcileOrdinal gains a headBlock param (additive). Adds an end-to-end
test driving the real sweep + FinalizationHandler over MockChainAdapter:
multi-KC promotion + gap-hold-then-fill.
Co-authored-by: Cursor <cursoragent@cursor.com>
| subGraphName?: string, | ||
| ): Promise<{ rootEntities: string[]; sharedMemoryQuads: Quad[] } | null> { | ||
| const graphManager = new GraphManager(this.store); | ||
| const wsMetaGraph = subGraphName |
There was a problem hiding this comment.
🔴 Bug: this only looks in the root SWM workspace unless subGraphName is already known, but the new chain-driven path in DKGAgent.reconcileChainOrdinal() never supplies a sub-graph name. Any KA published into a named sub-graph will therefore stay no-swm forever and its ordinal will never reconcile. Please add a fallback that scans all shared-memory sub-graphs (or persist/recover the target sub-graph from SWM metadata before calling this helper).
|
|
||
| let cursor = this.reconcileCursors.get(localCgId); | ||
| if (!cursor) { | ||
| cursor = createCursorState(sub.lastReconciledOrdinal ?? 0); |
There was a problem hiding this comment.
🔴 Bug: the persisted watermark is reused purely by local CG id here, but the PR never invalidates it when onChainId changes or is cleared/re-registered. If a local CG is repaired/recreated on-chain, the old lastReconciledOrdinal will make the new chain graph start sweeping from the wrong ordinal and permanently skip earlier KAs. Please key the persisted watermark by onChainId, or reset lastReconciledOrdinal whenever the bound on-chain id changes.
…910) - SWM sub-graph fallback: the chain-driven path never knows a publish's sub-graph, so findSwmSnapshotForMerkleRoot now searches the root workspace AND every registered sub-graph, returning the namespace it matched in so promotion lands in the right data graph. Previously a KA published into a named sub-graph stayed `no-swm` forever. - Reset the reconcile watermark when a local CG's bound on-chain id changes (repair/recreate). lastReconciledOrdinal counts contiguous KAs for a specific on-chain graph; reusing it across a rebind would start the sweep at the wrong ordinal and skip earlier KAs. New bindSubscriptionOnChainId() zeroes the watermark + drops the cursor on a real id change, and the reset is persisted (restart-safe). Adds a regression test for the sub-graph fallback. Co-authored-by: Cursor <cursoragent@cursor.com>
| } | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
🔴 Bug: This reset is gated on lastReconciledOrdinal > 0, but stale reconcile state can survive even when that is false. In particular, the re-register flow clears sub.onChainId before the new id is bound, and an in-memory cursor can also still hold ahead ordinals while the watermark is 0. In both cases the new on-chain graph inherits old cursor state and the sweep can skip early ordinals. Reset the cursor/watermark whenever the bound on-chain id changes (or a cursor exists), not only when the persisted watermark is positive.
| const localCgId = this.resolveLocalCgIdByOnChainId(BigInt(onChainId)); | ||
| if (!localCgId) return; // chain replay hasn't resolved the cleartext CG yet; sweep heals it | ||
| const sub = this.subscribedContextGraphs.get(localCgId); | ||
| if (!sub?.subscribed) return; // only populate VM for CGs we actually subscribe to |
There was a problem hiding this comment.
🟡 Issue: resolveLocalCgIdByOnChainId() returns the first match, which can be the synthetic hash-keyed host record created from ContextGraphCreated (subscribed: false). When that happens this handler exits at the next guard and never nudges the real subscribed cleartext CG that shares the same onChainId. Prefer a subscribed match, or iterate all matches, so live KACG nudges do not become no-ops until the periodic sweep catches up.
| // Chain-driven reconciliation never requests same-graph dual-write — the | ||
| // root-copy decision is a publisher gossip signal (`keepRootCopyOnLabel`) | ||
| // that has no chain equivalent. Promote per-cgId only. | ||
| const isDualWrite = false; |
There was a problem hiding this comment.
🔴 Bug: For chain-driven backfill this forces every recovery into the per-cgId graph only. If a node missed the original finalization gossip for a same-graph publish, the sweep will promote the VM data but never recreate the root-label copy/meta that label-scoped reads depend on, so agent.query(<cg label>) can still miss data after 'reconciliation'. The reconcile path needs a durable keep/drop-root signal (for example persisted in SWM metadata) and should mirror the normal dual-write decision here.
Addresses the unresolved automated review findings on #908/#910/#911/#912/#914: #908 warm-core: - only claim a maxCores slot when pin() actually succeeds (skip on failure) - serialize reconcile passes with an in-flight guard (no overlapping dials/unpins) - freshness-aware core selection: drop stale phonebook rows + sort by lastSeen before the cap (reuse AGENT_PROFILE_STALE_THRESHOLD_MS) #910/#911 chain-reconcile: - reset the reconcile cursor AND watermark whenever the bound onChainId changes, not only when the persisted watermark is >0 (an in-memory cursor can hold ahead ordinals while the watermark is 0) - resolveLocalCgIdByOnChainId prefers a subscribed match over a synthetic host-only record so live KACG nudges aren't no-ops - finalization root-label dual-write on chain backfill: documented as a tracked follow-up (needs a share-time persisted keepRootCopyOnLabel signal; a heuristic risks double-counting). Scoped reads are unaffected. #912 sinceBatchId: - parse the marker only from the trailing |since|<n> suffix (no segment scan) - normalize legacy batchId placement (KA vs KC/UAL) and literal form (typed xsd:integer vs untyped) in the delta join so legacy data isn't hidden #914 core-fill: - discard the swmGraphId hint only when it equals the on-chain id (numeric local cleartext ids like "1" are now honoured) - devnet test: clear stale daemon.pid/devnet.pid before restart-node - devnet test: pick a HOST-ONLY victim (core_hosted=1, subscribed=0) so the fill-the-gap scenario truly gates Phase D (not subscriber reconciliation); expose `subscribed` on the /api/replication/cursors row to enable it All 1115 agent + 1331 node-ui unit tests pass. Co-authored-by: Cursor <cursoragent@cursor.com>
- resolveLocalCgIdByOnChainId now prefers a SUBSCRIBED match over a synthetic hash-keyed host record (subscribed:false) that can share the same on-chain id, so live KACG nudges + reconcile target the CG the user actually reads instead of no-op'ing at the next guard. - bindSubscriptionOnChainId resets the reconcile watermark AND the in-memory cursor on ANY on-chain-id change, not only when the persisted watermark is > 0. A cursor can hold `ahead` ordinals (reconciled but awaiting confirmation depth) while the watermark is still 0; the old gate let that stale state survive a rebind and skip early ordinals of the new chain graph. - Durable keep-root signal for the chain-driven path: the publisher now persists keepRootCopyOnLabel per root into SWM workspace meta (replaces prior value on update), co-located with privateMerkleRoot so it replicates to subscribers. handleChainReconciledKC recovers it via getKeepRootCopySignal and mirrors the gossip dual-write decision, so a same-graph publish recovered through the sweep still lands the root `<cg>` label copy that label-scoped reads need. Absent signal (legacy / remap) stays per-cgId-only — no root copy re-added, no data-isolation regression. Adds chain-reconcile dual-write regression tests (keep-root=true mirrors the root-label copy; absent signal does not). Co-authored-by: Cursor <cursoragent@cursor.com>
| recordCompletion( | ||
| state, | ||
| { ordinal, blockNumber: outcome.blockNumber }, | ||
| headBlock ?? outcome.blockNumber, |
There was a problem hiding this comment.
🔴 Bug: when getHeadBlock() is temporarily unavailable, this falls back to confirmationDepth = 0 and advances/persists the watermark immediately. A transient RPC failure on the head-block read can therefore move the cursor past registrations that are still inside the reorg window, so they will never be retried if the chain reorgs. Keep completed ordinals in ahead until a real head block is available, or abort watermark advancement for that sweep.
| ual, | ||
| rootEntities, | ||
| publisherAddress, | ||
| txHash: '', |
There was a problem hiding this comment.
🔴 Bug: this materializes chain-reconciled KCs with txHash: '', which means confirmed metadata is written with an empty dkg:transactionHash and no stable publication provenance for every backfilled VM entry. The live KnowledgeAssetRegisteredToContextGraph event already carries txHash; please thread the real hash through when available, and on the pure sweep path omit tx-hash-derived metadata instead of writing a false empty value.
Test 1 (chained sign-at-creation lifecycle) asserted an exact-ordered list of four standalone-route operations, but captured every memory_graph_changed event for the CG. A core also emits verified_memory_finalized whenever it promotes a peer-published KA to VM — including via the Phase B periodic chain-reconcile sweep, which can fire mid-test for unrelated bootstrap KAs in the same CG. Those are correct background events but polluted the CG-only filter, doubling the captured list and failing the equality assertion once a bootstrapped 6-node devnet is used. Scope the SSE capture to the four lifecycle ops the test actually asserts, so legitimate background VM activity no longer leaks in. Co-authored-by: Cursor <cursoragent@cursor.com>
| state, | ||
| { ordinal, blockNumber: outcome.blockNumber }, | ||
| headBlock ?? outcome.blockNumber, | ||
| headBlock !== undefined ? deps.confirmationDepth : 0, |
There was a problem hiding this comment.
🔴 Bug: Falling back to confirmationDepth = 0 whenever getHeadBlock() returns undefined breaks the reorg-safety guarantee on real chains. A transient getBlockNumber failure will let the watermark commit immediately, and a shallow reorg can then permanently skip ordinals below the new head. Treat "head unavailable" as "hold completed ordinals in ahead until a real head is observed", not as the no-chain case.
| // KC root and comparing to the chain root. This is the same flat-KC root | ||
| // the gossip path verifies against, so a match is an authoritative | ||
| // merkle verification. | ||
| const snapshot = await this.findSwmSnapshotForMerkleRoot(contextGraphId, merkleRoot, subGraphName); |
There was a problem hiding this comment.
🔴 Bug: This reconcile path identifies the SWM snapshot only by matching the recomputed merkle root. Two different publishes in the same CG can legitimately have identical content (same merkle root but different KA/publisher/sub-graph), and in that case the first matching WorkspaceOperation wins and we attach the current kaId/publisher metadata to the wrong roots. Please persist or compare an additional discriminator from SWM meta/chain data, or fail when the root match is ambiguous.
| ual, | ||
| rootEntities, | ||
| publisherAddress, | ||
| txHash: '', |
There was a problem hiding this comment.
🔴 Bug: Chain-driven reconciliation always materializes confirmed metadata with an empty txHash. That drops dkg:transactionHash provenance and suppresses dkg:Publication/dkg:authoredBy triples even when authorAddress is known, so VM metadata differs from the normal finalization path. The live KACG callback already provides txHash/txIndex; the sweep needs to propagate or fetch those values before writing confirmed metadata.
…subscribe path Tier 1 (dual-write at chain reconcile, GH #910 follow-up): The chain-reconcile path now mirrors the gossip-path dual-write decision instead of hardcoding isDualWrite=false. keepRootCopyOnLabel can't be persisted at share-time (share predates the publish/remap decision) and a publish-time local meta update never reaches the missed-gossip recipient that runs reconcile, so we DERIVE it: a merkle-verified SWM snapshot found under the on-chain-bound CG proves a same-graph publish (a remap's snapshot lives under the source CG and never matches here), so isDualWrite = !!ctxGraphId && !resolvedSubGraphName. A reconcile-recovered node now converges to the identical graph state as one that received the finalization gossip, with no double-count risk. Tier 2 tracked in GH #919 (eliminate dual-write via registry-resolved label reads). Host-only Phase D gate (#914 follow-up): Add DKGAgent.unsubscribeFromContextGraph + POST /api/context-graph/unsubscribe. Drops the live member subscription (gossip topics + sync scope) while retaining any coreHosted hosting obligation, so a core hosting a public CG can be driven to the pure host-only state (subscribed=0, coreHosted=1). The devnet Phase D test now manufactures that state via unsubscribe so the missed-publish gap can only be filled by the chain reconcile sweep, not the gossip fast-path. Tests: chain-reconcile-e2e asserts a same-graph reconcile dual-writes the root label graph. Full agent suite (1116) green; agent + cli build clean. Co-authored-by: Cursor <cursoragent@cursor.com>
…tion Brings in the four new review-fix commits other agents pushed to the original PRs that the integration branch was missing: - #908 warm-core: closure in-flight guard at the interval site + staleThresholdMs/lastSeen freshness handling inside the module. - #910 chain reconcile: prefer subscribed CG in resolveLocalCgIdByOnChainId, reset watermark+cursor on ANY on-chain-id change. - #912 delta sync: trailing-only `|since|` parse + legacy batchId placement/literal-form tolerance. - #914 Phase D: numeric cleartext swmGraphId hint kept unless == on-chain id, `subscribed` exposed in cursors API, clearNodePidFiles helper. Conflict-resolution decisions: - Dual-write: ADOPT the canonical #910 PERSIST approach (`dkg:keepRootCopyOnLabel` persisted to SWM meta + recovered via getKeepRootCopySignal) and DROP the integration branch's competing derive-based change, so there is a single reviewed implementation. - Warm-core: take the PR's design (module-level freshness + interval-site closure guard); drop the redundant class-field guard. - Kept net-new work not on any PR: unsubscribeFromContextGraph + /api/context-graph/unsubscribe, and the devnet Phase D test that MANUFACTURES a pure host-only victim via unsubscribe. - Realigned chain-reconcile-e2e dual-write test to the persist signal and added an absent-signal negative case. Build + full agent (1124) + node-ui (1331) unit suites green. DO NOT merge. Co-authored-by: Cursor <cursoragent@cursor.com>
…-ups) Follow-up to the #927 squash merge, closing the correctness + coverage findings from the original stacked PRs (#906/#908/#910/#911/#912/#914) that weren't carried into #927. Rebased onto main after #941 split the DKGAgent god class into subsystem mixin holders; the dkg-agent.ts hunks are relocated into their new homes (dkg-agent-swm-host.ts: #35/#13 + trackCoreHostRecording; dkg-agent-cg-resolve.ts: #26 parser delegate; dkg-agent-base.ts: coreHostRecordings field; dkg-agent.ts: #30 stop() flush; dkg-agent-lifecycle.ts: tracked ACK pre-sign call site). Correctness: - #13 reorg-safety: getHeadBlock lets a transient RPC error throw instead of masquerading as "no chain"; reconciler holds the contiguous watermark until a real head is observed (+ head-fetch diagnostics). - #35 access-policy existence: recordCoreHostedPublicCg resolves the policy via readLiveOnChainAccessPolicy (existence-gated) so an UNKNOWN CG no longer looks public(0). Fails closed. - #30 coreHosted persistence is tracked + flushed in stop() instead of fire-and-forget. - #14 sweep provenance: derive a chain-unique publication id from the KC UAL when txHash is empty on the chain-driven path. - #15 snapshot discriminator: sort WorkspaceOperation candidates by URI so every node converges on the same op (per-root tokenId order tracked in #936). - #8 warm-core unpin: only count successful unpins; retain peer on failure. Test coverage: - #26 parsePipeDelimitedSyncRequest extracted to request-build.ts as a pure parser with round-trip + misparse-guard tests. - #27 requester-side sinceBatchId threading (sync-checkpoint-key + durable-sync-since-threading suites). - #37 typed boolean keepRootCopyOnLabel recovery in chain-reconcile-e2e. Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Phase B of the Telegram-style chain-anchored sync plan. Installs the engine that turns "chain says a KA was registered to a CG" into "that KA is in my VM" — the missing chain-driven promotion path behind the
monday-fun-factsmissing-VM symptom. Stacked on #908 (warm Core connections) → #906 (Core-preferred peer order) →main.The finalization handler has long logged "will retry via ChainEventPoller" when it can't promote a publish, but that retry was never implemented: the chain-event path was confirm-only-local. A node that was offline (or transport-flaky) during the finalization gossip would get the SWM via regular sync but nothing then promoted it to VM. Phase B finishes that sentence.
How it works
batchIdis a KC merkle hash (no ordering), so the cursor key is the per-CG registration ordinal —ContextGraphStorage.getContextGraphKCCount(cgId)is the chain-head ordinal andgetContextGraphKCAt(cgId, i)maps ordinal → kaId. Dense, monotonic, comparable: plain integer cursor math.KnowledgeAssetRegisteredToContextGraphevent is a low-latency nudge (it carries no ordinal, so it's never a cursor position); the periodic + on-startup sweep is the safety net that guarantees eventual reconciliation and catches up late subscribers.aheadset until the gap below fills; the persistedlastReconciledOrdinalonly advances over a contiguous run buried by a confirmation depth (observation-block based, so no per-ordinal block lookup).handleChainReconciledKCverifies the KA→CG binding via a directgetKAContextGraphIdread and matches the chain merkle root against a recomputed local-SWM flat-KC root — no untrusted-gossip event re-scan. Active fetch is core-first (reuses Phase A peer ordering) with one retry onno-swm.Commits
feat(chain,publisher)— per-CG ordinal reads (getContextGraphKCCount/getContextGraphKCAt) + KACG event decode/plumbing (evm + mock adapters, poller callback).feat(agent,node-ui,cli)— persistlastReconciledOrdinalwatermark end-to-end via V17 migration; opportunistically fix the latentonChainHashnon-persistence.feat(agent)— pure contiguous-watermark + confirmation-depth cursor module.feat(agent)— extractapplyVerifiedFinalization; add publichandleChainReconciledKC.feat(agent)— sweep orchestrator + per-CG coalescer + UAL dedupe.refactor(agent)— chain-reconcile verifies via direct CG-binding read.feat(agent)— wire it all intoDKGAgent(live nudge, periodic/startup sweep, watermark persistence). Gated behindvmReconcileEnabled().Tunables:
DKG_VM_RECONCILE_INTERVAL_MS(default 60s),DKG_VM_RECONCILE_CONFIRMATION_DEPTH(default 5).Test plan
reconcile-cursor.test.ts(12) — watermark contiguity, gap handling, reorg depth, sweep planningchain-reconciler.test.ts(8) — sweep gap-fill, watermark-persist-on-move, reorg depth, coalescer, UAL dedupefinalization-handler.test.ts(15) — incl. 5handleChainReconciledKCcases (promote / no-swm / unverified / wrong-CG / already-confirmed)chain-reconcile-e2e.test.ts(2) — full sweep + realFinalizationHandleroverMockChainAdapter: multi-KC promotion + gap-hold-then-fillmock-adapter-kc-views.test.ts(8),chain-event-poller-ka-registered.test.ts(2),db.test.ts(58),messenger-stores.test.ts(21)agent.test.ts+context-graph-discovery.test.ts(158),e2e-context-graph.test.ts(11)pnpm build, agent + cli builds, lint cleanMade with Cursor