Skip to content

fix(agent): defer finalized receiver SWM cleanup to idle GC - #1996

Open
Bojan131 wants to merge 63 commits into
testnet-canaryfrom
codex/fix-receiver-swm-finalization-cleanup
Open

fix(agent): defer finalized receiver SWM cleanup to idle GC#1996
Bojan131 wants to merge 63 commits into
testnet-canaryfrom
codex/fix-receiver-swm-finalization-cleanup

Conversation

@Bojan131

@Bojan131 Bojan131 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • keep receiver finalization constant-size by writing only a durable finalized-SWM cleanup task and immutable operation tombstone
  • move candidate discovery, VM/SWM verification, and deletion into one independent, low-priority maintenance worker
  • remove finalized cleanup from SWM snapshot materialization and catch-up completion; those paths only re-arm or wake the worker
  • delete only an SWM lifecycle that still exactly matches the finalized VM assertion, preserving newer, different, missing-VM, or corrupt state
  • expose cleanup backlog, oldest-marker age, pressure skips, deleted items, runs, and last error through /api/slo

The exact finalized KA is already cleared unconditionally on the publisher side. clearSharedMemoryAfter controls only the separate family-wide sweep, so this change makes receiver behavior consistent without changing that opt-in.

Architecture

Finalization and background cleanup

sequenceDiagram
    participant Chain
    participant Finalizer as Receiver finalizer
    participant Store
    participant GC as Finalized SWM GC worker

    Chain->>Finalizer: Finalized assertion
    Finalizer->>Store: Promote exact assertion to VM
    Finalizer->>Store: Write fixed-size cleanup task + tombstone
    Finalizer-->>Chain: Return without cleanup discovery or payload reads
    Finalizer->>GC: Non-blocking wake

    GC->>Store: Check ACK, health, and normal-lane pressure
    alt Store is busy
        GC-->>GC: Stop this slice and retry later
    else Store is idle
        GC->>Store: Discover at most 4 candidates (background priority)
        GC->>Store: Verify exact VM and SWM outside the KA lock
        GC->>Store: Acquire existing per-KA writer lock
        GC->>Store: Re-read task and head; conditionally delete exact SWM graph/head
        GC->>Store: Retire completed task
    end
Loading

Late or repeated snapshot

sequenceDiagram
    participant Peer
    participant Sync as SWM snapshot materializer
    participant Store
    participant GC as Finalized SWM GC worker

    Peer->>Sync: Late/repeated finalized SWM snapshot
    Sync->>Store: Materialize through the normal per-KA writer lock
    Sync->>Store: Read immutable operation tombstone and re-arm cleanup task
    Sync-->>Peer: Complete without VM/SWM verification or deletion
    Sync->>GC: Catch-up performs a non-blocking wake
    GC->>Store: On the next safe idle slice, verify and remove only the exact duplicate
Loading

The worker is single-flight, checks pressure before listContextGraphs, before SWM-meta discovery, and between candidates, uses background scheduler priority, yields between items, and stops after 4 candidates or 10 seconds. A periodic wake is the restart/lost-wakeup backstop; TTL expiry remains independently disabled when sharedMemoryTtlMs is 0.

Safety

  • VM is verified from live graph content, count, digest, and recomputed Merkle root; matching metadata alone is not enough
  • SWM must still match the same full workspace-head fingerprint and finalized root
  • expensive graph reads and hashing happen outside the lock
  • the final destructive step uses the existing per-KA writer lock only for a short generation/head/task re-read and conditional atomic delete
  • a changed generation, missing or changed VM, newer/different SWM lifecycle, corrupt head, or mismatched payload fails closed and leaves SWM intact
  • ingest never discovers cleanup candidates, scans VM/SWM payloads, or deletes finalized state

Cleanup is deliberately eventually consistent. Under sustained foreground load an exact SWM+VM duplicate can remain until the next low-load window, but the worker performs no graph discovery or payload scanning while pressure is active.

Validation

  • agent topological build: passed (9 packages)
  • complete agent unit/integration lane: 153 files passed; 2,054 tests passed; 5 skipped
  • complete publisher unit lane: 47 files passed; 555 tests passed
  • CLI /api/slo cleanup metrics: 9/9 passed
  • focused finalization/SWM regression lane: 10 files; 154/154 passed
  • SPARQL scalability gate: 0 new blocking findings
  • git diff --check origin/testnet-canary...HEAD: passed

The regression coverage includes: zero discovery while pressure is active; catch-up wake without awaiting GC; late/repeated snapshot re-arm and eventual idle convergence; task retirement; exact VM digest preservation; and fail-closed behavior for missing VM, changed/newer SWM, and corrupt content.

Historical blackbox context

The original failure was reproduced with real two-node harness runs as 40 correct VM KAs plus the same 40 KAs remaining in SWM. Those runs establish the workload and failure mode; the current worker revision is validated by the updated build and regression suites above and should be followed by a fresh harness run that allows the low-load GC window before asserting final SWM parity.

Current PR status

  • merged the latest testnet-canary base and resolved the combined-base attribution conflict
  • GitHub reports 59 checks with no failures or pending jobs, including the Windows SQLite lifecycle lane
  • PR is non-draft and reports a clean merge state

Comment thread packages/agent/src/finalization-handler.ts Outdated
Comment thread packages/agent/src/finalization-handler.ts Outdated
Comment thread packages/agent/src/finalization-handler.ts Outdated
Comment thread packages/agent/src/finalization-handler.ts Outdated
@Bojan131 Bojan131 changed the title fix(agent): safely clean finalized receiver SWM fix(agent): defer finalized receiver SWM cleanup under load Jul 30, 2026
Comment thread packages/agent/test/sync-responder-swm-meta-ceiling.test.ts
Comment thread packages/agent/src/finalization-handler.ts Outdated
Comment thread packages/agent/src/dkg-agent-lifecycle.ts Outdated
Comment thread packages/agent/src/sync/responder/graph-plan.ts Outdated
Comment thread packages/agent/src/sync/requester/shared-memory-sync.ts
Comment thread packages/agent/src/sync/graph-scoped-swm-recovery.ts
Comment thread packages/agent/src/sync/requester/swm-recovery.ts
Comment thread packages/agent/src/dkg-agent-lifecycle.ts Outdated
Comment thread packages/agent/src/sync/requester/swm-snapshot-materializer.ts Outdated
Comment thread packages/agent/src/sync/requester/swm-snapshot-materializer.ts
Comment thread packages/agent/test/ka-graph-finalization-handler.test.ts
Comment thread packages/agent/src/sync/responder/graph-plan.ts Outdated
Comment thread packages/agent/src/dkg-agent-lifecycle.ts Outdated
Comment thread packages/agent/test/ka-graph-finalization-handler.test.ts Outdated
Comment thread packages/agent/src/sync/requester/shared-memory-sync.ts
Comment thread packages/agent/src/finalization-handler.ts Outdated
Comment thread packages/agent/src/sync/requester/swm-snapshot-materializer.ts Outdated
@Bojan131 Bojan131 changed the title fix(agent): defer finalized receiver SWM cleanup under load fix(agent): prevent finalized receiver SWM resurrection Jul 30, 2026
@Bojan131

Copy link
Copy Markdown
Contributor Author

Final live confirmation is complete on commit 84353e2b4ad0759307415d5e8027d291e9d85da3. A two-node PR #6 blackbox run exercised the exact failing order—40 VM KAs were already canonical, then late SWM synchronization ran under load—and finished PASS at exactly 40 total/VM KAs, 6,117 entities, 24,541 triples, zero SWM KAs, exact curator digest, no differences, 4/4 successful receipts, and 2/2 assertions. Independent store inspection confirmed 40 VM content graphs, zero SWM content graphs, and no active SWM heads; the complete agent suite also passed 3,340 tests with 10 skipped.

@Bojan131
Bojan131 changed the base branch from main to testnet-canary July 31, 2026 07:21
@Jurij89

Jurij89 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review — #1996 fix(agent): prevent finalized receiver SWM resurrection

Scope. Head 79019ce99, merge-base with origin/testnet-canary = b676567723d038649690681375a0fbf1142915db. All 19 files in merge-base..HEAD are the author's real change across 10 commits (0b42ebc7b79019ce99) — no base drift, no unrelated files. Reviewed in an isolated worktree at C:/Projects/dkg-review-1996.

Real change: packages/agent/src/{finalization-handler.ts, dkg-agent-lifecycle.ts, dkg-agent-constants.ts, dkg-agent-swm-substrate.ts}, packages/agent/src/sync/{graph-scoped-swm-recovery.ts, requester/shared-memory-sync.ts, requester/swm-recovery.ts, requester/swm-snapshot-materializer.ts, responder/graph-plan.ts}, packages/publisher/src/{index.ts, workspace-resolution.ts}, plus 7 test files.

Verdict: approve-with-fixes

The core mechanism is sound and the destructive path is fail-closed in the right direction. Two things hold it back from a clean approve: both of the guards that stand between this feature and data loss are unpinned by any test (I mutated each to a no-op and the suite stayed 52/52 green), and four of the seven changed test files do not run in this branch's CI lane — including the only test that pins the deterministic post-catch-up drain trigger.


What's verified correct

The design question — "should receivers always clear, when publishers only clear on request?" — is answered, and the premise behind the worry is wrong. clearSharedMemoryAfter does not gate the publisher's cleanup of the exact published KA. packages/agent/src/dkg-agent-publish.ts:5294 and :5446 call clearPublishedGraph(...)publisher.clearPublishedKnowledgeAssetSwm(...) unconditionally on every confirmed publish/update. clearSharedMemoryAfter gates only the separate clearRemainingSharedMemory() call on the next line — the family-wide sweep of every other leftover share in the bucket, which the in-code comment explicitly calls "a separate family-wide destructive action." So "the exact finalized KA's SWM copy is always drained once VM is durable" is already the established protocol on the publisher side, and this PR makes receivers match it. Nothing in the PR touches the family-wide sweep or the clearSharedMemoryAfter opt-in. This is a deliberate, consistent decision, not an unexamined assumption — I'd recommend saying so in the PR description, because the asymmetry is not obvious from the call sites.

The match condition is checked in the dangerous direction. clearMarkedFinalizedGraphScopedSwm (finalization-handler.ts:1778) will only delete after, all inside the canonical per-KA writer lock: the head re-resolves; sameKnowledgeAssetWorkspaceHead matches on all 11 fields including sorted allowedPeers; an ASK confirms the marker with the exact root literal; VM verifies exactly (count + publicQuadsDigest + recomputed merkle root); and SWM verifies exactly or is already empty. A KnowledgeAssetWorkspaceHeadCorruptError returns preserved, not cleared. I could not construct an input where a non-matching SWM graph gets deleted.

Factoring the head comparison into sameKnowledgeAssetWorkspaceHead (packages/publisher/src/workspace-resolution.ts:288) rather than inlining a field list is the right call — it means adding a lifecycle field cannot silently make the destructive compare weaker than the workspace model.

The "immutable operation keeps the marker" trick is real and proven. Cleanup deletes the head subject (and with it the head's marker) but leaves the marker on the operation subject, so the finalized snapshot can never be re-advertised even though it remains available for receipt/reorg recovery. ka-graph-finalization-handler.test.ts:2206-2220 asserts exactly that, and the responder test at sync-responder-swm-subgraphs.test.ts:461-483 walks the post-cleanup state at both ttl=0 and ttl=5000.

Anti-resurrection does not trust metadata. isExactConfirmedVmAsset (swm-snapshot-materializer.ts:301) re-reads the VM graph and recomputes count + digest + merkle root rather than believing the confirmed envelope, and swm-snapshot-materializer.test.ts pins that with "does not treat confirmed metadata as proof when the VM graph content differs".

Genuine fail-before exists. Every new assertion routes through cleanupFinalizedGraphScopedSwmWhenIdle / discardFinalizedGraphAsset, neither of which exists at the merge-base (git show b6765677:packages/agent/src/finalization-handler.ts has no reference to FINALIZED_SWM_CLEANUP_ROOT_PREDICATE and no cleanup entry point), so the new tests hard-fail on base.

Executed clean:

  • ka-graph-finalization-handler.test.ts + swm-snapshot-materializer.test.ts + swm-public-snapshot-materialization.test.ts82 passed.
  • sync-responder-swm-subgraphs.test.ts14 passed.
  • node scripts/sparql-scale-lint.mjs --diff b6765677 HEAD0 new blocking, 4 acknowledged, 6 grandfathered. The three new FILTER NOT EXISTS clauses in graph-plan.ts clear the gate.
  • tsc --noEmit on packages/agent → only the pre-existing Cannot find module '@origintrail-official/dkg-random-sampling' (unbuilt package in my worktree, unrelated to this PR).

Findings

MEDIUM-1 — Both data-loss guards in the drain are decorative; a refactor can delete either with CI green

packages/agent/src/finalization-handler.ts:1858 (VM re-verification) and :1877-1880 (SWM exactness).

The cleanup marker is durable — it survives restart by design (ka-graph-finalization-handler.test.ts:2200-2205 builds a fresh handler and drains). So an arbitrary amount of time and any number of chain-reconcile/reorg passes can elapse between marking and draining. During that window the VM graph can be replaced by a newer materialization, or dropped by a reorg path. These two if statements are the entire defence between that and deleting the node's last copy of the payload.

Reproduction (mutation, applied serially, each restored and the restore proven by git diff returning 0 lines):

# M1: neutralize the VM guard
-      if (vmVerification.status !== 'verified') {
+      if (false && vmVerification.status !== 'verified') {
$ pnpm exec vitest run --config vitest.unit.config.ts test/ka-graph-finalization-handler.test.ts
 Test Files  1 passed (1)
      Tests  52 passed (52)

# M2: neutralize the SWM exactness guard
-        swmVerification.status !== 'verified'
+        false && swmVerification.status !== 'verified'
$ pnpm exec vitest run --config vitest.unit.config.ts test/ka-graph-finalization-handler.test.ts
 Test Files  1 passed (1)
      Tests  52 passed (52)

Both stay green. Re-verified at 79019ce99 after the Bug Bot commit moved this code: neutralizing the VM guard (now finalization-handler.ts:1859) still leaves 53/53 passing — the commit added a discovery test, not guard coverage. Restore proven by sha1 match against the pre-mutation baseline and git diff returning 0 lines. No test in the file makes VM or SWM diverge from the marker before draining — the 52 tests only ever drain a state where everything matches.

Fix direction. Two tests in ka-graph-finalization-handler.test.ts:

  1. Finalize → await store.dropGraph(vmGraph)drainFinalizedSwm() expect 0 and countQuads(swmGraph) expect 2.
  2. Finalize → insert one extra triple into swmGraphdrainFinalizedSwm() expect 0 and SWM intact.

Trap in applying this literally: do not build case 2 by deleting SWM triples. Deleting all of them lands on the deliberate count-mismatch && actualCount === 0 → 'absent' branch, which returns cleared-equivalent and would make the test assert the opposite of what you meant; deleting some of them exercises the count check rather than the digest check. Adding a triple is the case that actually reaches the digest comparison. Also note case 1 must drop the VM graph after the marker is written — dropping it before finalization changes which code path runs entirely.

MEDIUM-2 — Four of seven changed test files never run in this PR's CI

packages/agent/vitest.unit.config.ts includes ka-graph-finalization-handler.test.ts (:104), swm-public-snapshot-materialization.test.ts (:129) and swm-snapshot-materializer.test.ts (:131). It does not include agent.part-16.test.ts, workspace-ttl.test.ts, swm-ttl-v2-cleanup.test.ts, or sync-responder-swm-subgraphs.test.ts. Evidence:

$ pnpm exec vitest run --config vitest.unit.config.ts test/swm-ttl-v2-cleanup.test.ts \
    test/workspace-ttl.test.ts test/agent.part-16.test.ts test/sync-responder-swm-subgraphs.test.ts
No test files found, exiting with code 1

PRs targeting testnet-canary in this repo run the RFC-64 Windows gate plus SPARQL-lint, not the full vitest lane — so these four files provide no CI signal here.

The specific loss matters. agent.part-16.test.ts:201-210 is the only test that pins the deterministic post-catch-up drain — both the exact argument shape ({finalizedOnly: true, contextGraphIds: [cg], finalizedCleanupBudget: 64, queueBehindActiveWork: true}) and its ordering after durable and shared-memory sync. That trigger at dkg-agent-lifecycle.ts:6052 is the only non-idle-gated path to cleanup; without it the feature degrades to "whenever the node happens to be completely idle." This is the classic shape where the behaviour ships but the call that starts it is unpinned, and a later refactor silently disables it.

Fix direction. Add test/sync-responder-swm-subgraphs.test.ts and test/agent.part-16.test.ts to the include list in vitest.unit.config.ts. Trap: agent.part-16.test.ts boots a DKGAgent and pulls a much heavier import graph than the responder file; check its runtime against the Windows gate's budget before adding it. If only one can go, take agent.part-16.test.ts — the responder filter is redundantly protected (see LOW-1), the drain trigger is not.

MEDIUM-3 — sharedMemoryTtlMs: 0 no longer means "no periodic SWM work", with no opt-out

packages/agent/src/dkg-agent-lifecycle.ts:3123-3130 (timer now starts unconditionally) and :7574-7584 (setSharedMemoryTtlMs(0) no longer clears it).

Before this PR, sharedMemoryTtlMs: 0 disabled the 15-minute cycle entirely. Now every node runs cleanupExpiredSharedMemory() every 15 minutes forever. The pressure gate lives inside cleanupFinalizedGraphScopedSwmWhenIdle (finalization-handler.ts:1629) — but graphManager.listContextGraphs() at dkg-agent-lifecycle.ts:7618 runs before it, unconditionally, and that helper is the known store-pressure source from #1549.

Concrete: an operator who set sharedMemoryTtlMs: 0 specifically to stop that scan now gets, every 15 minutes and forever, one listContextGraphs() plus one finalizedSwmCleanupRoot discover query per SWM meta bucket per context graph — even when the node holds zero markers. There is no config to turn it off; setSharedMemoryTtlMs(0) used to be that switch, and workspace-ttl.test.ts was rewritten to assert it no longer is.

The change is clearly deliberate (finalized cleanup must work even when TTL expiry is off), and I'd keep the intent. But the cheap listContextGraphs() avoidance is worth taking: hoist a "nothing to do" check — or gate the listContextGraphs() call on the store reporting idle — so a TTL-disabled, busy node does no more work than it did before.

Trap: do not "fix" this by restoring if (ttl > 0) around the timer. That reinstates the exact bug this PR exists to fix for every TTL-disabled node.

LOW-1 — The JS-side responder blocking is fully redundant with the SPARQL filters and is unpinned

packages/agent/src/sync/responder/graph-plan.ts:2694-2706.

The second loop (propagating blockedSubjects from a marked head to its sibling operation subject via tupleKeys) can be deleted with no test noticing:

# M3: delete the cross-subject propagation loop
$ pnpm exec vitest run test/sync-responder-swm-subgraphs.test.ts
 Test Files  1 passed (1)
      Tests  14 passed (14)

It stays green because both feeder queries already exclude the subject at the store: readSwmMetaRowsPage (:2756, the cutoffIso == null lane) and readFreshSwmMetaSubjects (:2866 and :2888, the TTL lane) each carry the same FILTER NOT EXISTS join. The JS layer is honest defence-in-depth and I would keep it — but neither layer is independently pinned, so a regression in the SPARQL filter would also go unnoticed. If you want one cheap test: call filterSwmMetaSnapshotRows directly with hand-built rows.

LOW-2 — isFresh closes over cutoffMs before its const declaration

graph-plan.ts:2673 (isFresh, reads cutoffMs) vs :2708 (const cutoffMs = Date.parse(cutoffIso)). This is legal today only because isFresh is never invoked during the new blocked-subject computation that now sits between them. Move any isFresh call — or any helper that transitively calls it — into that block and you get a TDZ ReferenceError on the sync-responder hot path, with a completely clean typecheck (confirmed: tsc --noEmit reports nothing here). Fix: hoist const cutoffMs = cutoffIso == null ? NaN : Date.parse(cutoffIso); above isFresh. Trap: do not restore the old if (cutoffIso == null) return ... early-return to the top of the function to sidestep this — the TTL-disabled lane must still get the blocking applied, which is precisely why the early return was moved down.

LOW-3 — The busy-timeout retry test injects on a different query than its name implies

ka-graph-finalization-handler.test.ts:2255-2270 gates the injected StoreSchedulerBusyError on query.includes('SELECT ?scopeVersion ?kaUal ?assertionVersion') while filtering options.source === 'agent.finalization.graphScopedSwmCleanup.discover'. The discover query is SELECT DISTINCT ?head ?ual ?version ?root ?shareId ?subGraphName; the matched string is resolveKnowledgeAssetWorkspaceHead's query (packages/publisher/src/workspace-resolution.ts:114), which happens to share the .discover source tag. The test is not broken — it asserts injectedBusyTimeout === true, so it does prove the retry loop works, and the priority assertion (new Set(cleanupPriorities) equals {'normal'}) is valuable. But it covers head resolution, not discovery. Either rename it or add a second case that injects on the actual discover SELECT.

Not tested — eventual-consistency worst case

The periodic path requires all eight pressure counters to be zero simultaneously (finalization-handler.ts:1629-1640, re-checked per candidate). The only non-idle-gated trigger is the post-catch-up boundary. A node that stays subscribed, receives KAs purely by gossip, and is under sustained load can hold the SWM+VM duplicate indefinitely. I did not build a load harness to measure how often that happens in practice, so I'm flagging it as accepted-behaviour-to-state rather than a defect: the reported symptom becomes "duplicate persists until the next idle window or catch-up sync," not "duplicate is removed promptly." Worth one sentence in the PR description.


Merge readiness

Mergeable after MEDIUM-1 and MEDIUM-2. MEDIUM-1 is two small tests against code that is already correct — the risk is a future refactor, not this diff. MEDIUM-2 is a one-line config change and is the difference between this fix having CI signal on testnet-canary and having none. MEDIUM-3 is a real behaviour change for TTL-disabled operators and should at least be called out in the PR description if not addressed; the LOWs can ride along or follow up.

Method footnote: Read the full diff against merge-base b6765677; verified the publisher's clearSharedMemoryAfter semantics directly at dkg-agent-publish.ts:5294/5446. Executed vitest run --config vitest.unit.config.ts over ka-graph-finalization-handler / swm-snapshot-materializer / swm-public-snapshot-materialization (82 pass), vitest run test/sync-responder-swm-subgraphs.test.ts (14 pass), tsc --noEmit -p packages/agent, and scripts/sparql-scale-lint.mjs --diff. Mutation-tested three production branches serially (VM guard, SWM exactness guard, responder cross-subject propagation) — all three stayed green, which is the basis for MEDIUM-1 and LOW-1. Each mutation was restored and the restore proven by git diff <file> | wc -l returning 0. Not tested: the hardhat-backed swm-ttl-v2-cleanup.test.ts and workspace-ttl.test.ts (no chain fixture in this worktree), and any live multi-node behaviour.


On 79019ce99 ("address red Bug Bot findings")

This commit landed while the review above was being written; everything above has been re-checked against it, and none of the findings are resolved by it:

  • MEDIUM-1 still stands — re-verified by mutation at this head (above): 53/53 green with the VM guard neutralized.
  • MEDIUM-2 still stands, unchanged — the commit does not touch vitest.unit.config.ts. The same four changed test files remain outside the unit lane: agent.part-16.test.ts, workspace-ttl.test.ts, swm-ttl-v2-cleanup.test.ts, sync-responder-swm-subgraphs.test.ts. Two others are in it (sync-responder-swm-meta-ceiling.test.ts, and the new swm-snapshot-sync.test.ts), so the fix is a two-line include addition, not a structural problem.
  • LOW-2 still standsisFresh (graph-plan.ts:2712) still closes over cutoffMs declared at :2747.

Depth disclosure: I verified my prior findings against this commit but did not deep-review its own 238 added lines (dkg-agent-lifecycle.ts +69, graph-plan.ts +48, swm-snapshot-materializer.ts +21 and the four test files). Since it is described as addressing bot findings on the same destructive path this review is about, it deserves its own pass — I'd rather say that than imply coverage I don't have. Happy to review it specifically on request.

Independently re-verified before posting: the clearSharedMemoryAfter semantics at dkg-agent-publish.ts:5445-5450 and :5294-5298 (both lanes call clearPublishedGraph unconditionally on confirmed, resolving to publisher.clearPublishedKnowledgeAssetSwm(...) scoped to a single UAL at :5232-5240); the two guard locations at finalization-handler.ts:1859 / :1880-1882 including the count-mismatch && actualCount === 0 carve-out that makes the suggested test trap real; the MEDIUM-1 mutation re-run at this head; and the CI-lane membership of all six changed test files.

Comment thread packages/agent/test/swm-public-snapshot-materialization.test.ts
Comment thread packages/agent/test/workspace-ttl.test.ts Outdated
@lupuszr

lupuszr commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Architecture feedback: keep finalized-SWM cleanup out of the write path

I am not comfortable with the current coupling of anti-resurrection cleanup to the SWM materialization path.

To be precise, this PR reuses the existing per-KA writer-lock map rather than introducing a global mutex. My concern is that it significantly expands the lock-held critical section on an already store- and CPU-heavy flow. In runSharedMemorySync, every candidate enters withKaWriteLock(...) and then awaits discardFinalizedGraphAsset(...). For a confirmed candidate that can include:

  • confirmed-VM metadata lookup;
  • a full VM graph CONSTRUCT;
  • digest and Merkle-root recomputation;
  • active SWM-head resolution;
  • a full SWM graph read;
  • an atomic graph-and-head replacement.

Marking those store operations as background does not remove their foreground effect: the sync lane is still awaiting them while holding the per-KA lock. This increases lock duration, store occupancy, and head-of-line risk in a flow that is already performance-sensitive.

The deterministic post-catch-up path has a similar coupling: it awaits cleanupExpiredSharedMemory(...), uses the normal lane with queueBehindActiveWork: true, and can retry admission for up to the configured budget before the sync job completes. Cleanup therefore remains part of sync completion rather than an independent maintenance concern.

I would prefer an eventual-convergence design:

  1. Keep finalization foreground work O(1): persist only a durable cleanup marker.
  2. Make a dedicated finalized-SWM GC the only component responsible for discovery, VM/SWM verification, and deletion.
  3. Run that GC only after checking store pressure before listContextGraphs(), listSharedMemoryMetaGraphs(), or candidate discovery. The current periodic path performs some of those scans before reaching the handler’s pressure gate.
  4. Use background priority, small batch and wall-clock budgets, yield between items, and stop immediately when ACK, health, or normal work appears.
  5. Do not await the GC from catch-up completion. A successful catch-up may wake it, but the GC should execute independently.
  6. Avoid a new locking subsystem. If the existing per-KA serialization is still required for the destructive compare-and-delete, acquire it only inside the GC for the final short re-read/conditional-delete step—not around VM graph verification and not from the SWM ingest path.
  7. Expose backlog depth, oldest marker age, pressure skips, and deleted-item counters so operators can distinguish “deferred under load” from “stuck.”

This deliberately changes the guarantee from “a late snapshot can never temporarily recreate finalized SWM” to “a late snapshot is removed after the next safe low-load GC window.” I prefer that tradeoff to adding more synchronous reads, hashing, lock time, and cleanup work to the network’s hot synchronization path.

The validation should then prove both sides of that contract:

  • under sustained load, the GC performs no discovery or payload scans and does not affect foreground sync latency;
  • after load falls, a late/repeated SWM snapshot converges to the exact VM state and the durable marker is retired;
  • a newer, different, or corrupt SWM lifecycle remains fail-closed and is never deleted.

The PR already contains much of the safe cleanup logic in cleanupFinalizedGraphScopedSwmWhenIdle; I would make that a standalone maintenance worker and remove synchronous discardFinalizedGraphAsset(...) from snapshot materialization.

@Bojan131

Copy link
Copy Markdown
Contributor Author

@Jurij89 Addressed every item from your review in 9958b67ff.

  • MEDIUM-1: added fail-closed regressions for VM loss after marker persistence and SWM mutation after marker persistence; both drain zero candidates and preserve SWM.
  • MEDIUM-2: added agent.part-16.test.ts and sync-responder-swm-subgraphs.test.ts to the unit inventory. Because part 16 is chain-backed, the config now starts the shared fixture only for the full inventory or that explicit file; targeted Windows RFC-64 runs remain chain-free.
  • MEDIUM-3: TTL-disabled periodic maintenance now exits before context-graph discovery whenever any store queue/inflight counter is active, while explicit post-catchup cleanup still queues and drains deterministically.
  • LOW-1/2/3: directly pinned the JS tuple propagation, hoisted cutoffMs and covered the non-null cutoff path, and changed the retry test to inject on the actual SELECT DISTINCT ?head ... discovery query.
  • Docs: clarified unconditional exact-KA publisher cleanup vs. the optional family-wide sweep, TTL=0 behavior, and eventual cleanup under sustained gossip-only load.

Validation: full corrected unit lane 149 files / 2,030 passed / 5 skipped, agent build/type/package-root checks passed, and GitHub is now 56 passed / 0 pending / 0 failed.

Jurij89 and others added 4 commits August 2, 2026 11:59
The previous commit asserted all three predicates inside one parametrized
case. That proves none of them leaks, but it cannot show the coverage
DISCRIMINATES: every single-predicate mutant killed the same two cases, and
an identical kill set is equally consistent with one assertion doing all
the work. Same shape as the bug being fixed — one fixture standing in for
three predicates.

Split them, so each mutant reddens cases the other two leave green. Kill
sets, captured by test name:

  drop finalizedSwmCleanupRoot            -> 2 shared + the 2 root cases
  drop finalizedSwmCleanupMarkedAt        -> 2 shared + the 2 markedAt cases
  drop finalizedSwmCleanupHeadFingerprint -> 2 shared + the 2 fingerprint cases

The lanes are pinned the same way. Removing the filter from ONE reader
reddens only that reader's cases, which is what shows a per-lane regression
actually reaches the suite rather than being masked by a sibling lane:

  readSwmMetaRowsPage (legacy, TTL-disabled)     -> only ttl=0 cases
  readFreshSwmMetaRowsPageFromPlan (TTL lane)    -> only ttl=5000 cases

Removing the predicate from the in-process set alone still SURVIVES, as
before: every lane also filters at the store, so that set is defence in
depth no test can pin without weakening the store-side clause.

Also fixes the it.each title, which printed its arguments in the wrong
order and labelled the predicate as the TTL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxoLY1KYH1cRrsqStKCsaH
The responder re-typed the finalized-cleanup predicate and task-type IRIs
as local literals off its own DKG prefix, while the marker writer imports
them from dkg-agent-constants. The values match today — verified — so this
is drift risk, not a live leak.

It is worth closing because of where it sits. If a predicate IRI ever
changed, the writer would follow and the responder would keep filtering the
old string, so local GC bookkeeping would be advertised to peers, silently.
And the mutation matrix on this filter shows in-process-only drift SURVIVES
testing: every lane also filters at the store, so no test can catch it. An
unguarded silent peer-leak path is worth four lines.

Derive all four, keeping the short aliases so the SPARQL templates stay
readable. graph-plan.ts already imported from dkg-agent-constants, so this
adds names to an existing import rather than a new module edge.

Behaviour is unchanged and measured, not argued: the same store-side
mutants kill the same cases through the indirection, per predicate, and the
task-type clause mutant produces a result identical to the pre-change
baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxoLY1KYH1cRrsqStKCsaH
<http://dkg.io/ontology/assertionGraph> ?assertionGraph .
OPTIONAL { ?task <http://dkg.io/ontology/subGraphName> ?subGraphName }
}
} ORDER BY ?task LIMIT ${limit}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: Preserved cleanup tasks can permanently starve later finalized SWM cleanup

What's wrong
The cleanup slice is bounded by LIMIT, but candidates that intentionally return preserved remain in place and are selected first again on the next sweep. If enough preserved tasks sort before valid tasks, the worker repeatedly spends its whole candidate budget on the same uncleanable rows and never reaches cleanable finalized SWM copies later in the ordering.

Example
With the default candidate budget of 4, if tasks a through d are preserved because their VM/SWM verification no longer matches, and task e is a valid cleanup candidate, every sweep re-reads a through d and never reaches e. The backlog metric still reports work, but the cleanable finalized SWM copy behind those preserved tasks is never deleted.

Suggested direction
Do not always restart candidate selection at the first task without a way to advance past fail-closed preserved entries. Track a cursor or rotate candidates within each meta graph while keeping the safety checks before deletion.

For Agents
Look at FinalizedSwmCleanupService.cleanupMetaGraph. Preserve the fail-closed behavior for tasks whose VM/SWM no longer matches, but add per-meta-graph pagination/cursoring, task aging, or another fair scheduling mechanism so preserved candidates cannot monopolize every bounded sweep. Add a test with more preserved tasks than maxCandidatesPerSweep followed by a cleanable task and prove the later task is eventually cleared.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and tracked as #2020 — deliberately not fixed in this PR.

Independently found in review here, and your diagnosis matches: preserved candidates are re-selected first every sweep, so the node-wide budget never reaches cleanable tasks behind them.

Fixed at one level: task selection within a meta graph now uses a keyset cursor (650174547), taking a fixture from 4/12 to 12/12 distinct tasks examined. Not fixed across meta graphs and context graphs, and that is the honest state.

Why it went to a follow-up rather than into this PR: separating the deletion traversal from the measurement rotation reaches 30/30 in a fixture, but couples three contracts — wall-clock split, rotation-cursor persistence, and pressure classification — where fixing the second breaks the third. An attempt that reached 30/30 regressed four tests, one of them the rotation starvation guard fixed earlier in this same PR. Two intermediate patches both stalled at 15/30, which is the signal it is not patchable level-by-level.

#2020 carries the full spec: the evidence table, both traps that re-created the bug class while fixing it, the three coupled contracts as an acceptance spec, and rebuild notes. Your acceptance-test shape — more preserved tasks than maxCandidatesPerSweep followed by a cleanable one, asserting it is cleared rather than merely examined — is recorded there as the fairness half, complementary to the coverage assertion.

Note no green test contradicts the limitation: the rotation suite seeds backlog-only markers that discovery can never select, so deletion is never exercised there.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Task-level cleanup pagination is not verified

What's wrong
The new cleanup service has three cursor layers, and the tests cover the context-graph and meta-graph rotation behavior, but not the innermost task cursor. That leaves a real backlog-stranding regression unguarded: the worker could keep scanning the same ordered task prefix forever while later cleanup tasks are never discovered.

Example
A test could seed one SWM meta graph with maxCandidatesPerSweep = 4, four early cleanup tasks that are preserved or otherwise uncleanable, and a fifth task that is clearable. Two sweeps should reach and clear the fifth task. If the cursor assignment/filter is removed, every sweep re-reads the same first four tasks and the fifth is never exercised.

Suggested direction
Add a focused regression test for a full candidate page whose prefix cannot be deleted, proving later tasks in the same meta graph are reached on a following sweep.

For Agents
Add coverage in packages/agent/test/finalized-swm-cleanup-rotation.test.ts or finalized-swm-cleanup-sweep.test.ts for full-page task discovery inside a single meta graph. Preserve the current behavior that full pages advance by task subject and short pages wrap. The test should fail if taskCursor is not persisted or afterTaskSubject is not applied.

}

/** Run one bounded, idle-only maintenance slice. */
async runSweep(): Promise<FinalizedSwmCleanupSweepResult> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: The cleanup service centralizes too many sweep responsibilities in one stateful method

What's wrong
The new service is dedicated, which is good, but its core method still carries several independent state machines at once. The repeated yield logic and mutable rotation fields make future changes expensive because a reader has to prove metric freshness, cursor progress, and destructive cleanup safety together.

Example
Changing the yield policy now requires touching several underPressure() / deadlineSignal.aborted / yieldRotation(...) branches and understanding how each affects rotationPending, stale backlog metrics, and deleted counts.

Suggested direction
Separate rotation/budget mechanics from candidate cleanup and SPARQL repository operations so the service reads as orchestration over smaller concepts.

Confidence note
This is a structural review finding; the behavior may be well covered, but the maintainability burden is visible from the new service shape.

For Agents
In packages/agent/src/finalized-swm-cleanup-service.ts, split the sweep into a pure rotation cursor/backlog accumulator and a candidate executor/repository. Consider a small SweepBudget helper that centralizes pressure/deadline classification. Preserve the current worker-facing result shape and rotation semantics.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Split the finalized-SWM sweep state machine before it becomes permanent debt

What's wrong
This is a new 900-line service, and its central method is carrying several independent state machines through shared mutable fields. The implementation is careful, but the design is brittle: the next maintenance change has to reason about too many coupled cursors and accumulators at once.

Example
A future change that adds one more yield/fault point inside runSweep() must remember to update rotationPending, maybe metaGraphResumeCursor, maybe taskCursor, and avoid publishing partial backlog totals. The long comments around the method are already documenting that hidden protocol rather than letting the structure enforce it.

Suggested direction
Make cursor advancement a dedicated abstraction, ideally with pure transition functions for defer, completeMetaGraph, completeContextGraph, and closeRotation. Then keep runSweep() as orchestration over those transitions instead of the place where every cursor invariant is manually maintained.

For Agents
Refactor packages/agent/src/finalized-swm-cleanup-service.ts: extract a small rotation/cursor planner with explicit states and a cleanup executor that only processes one (contextGraphId, swmMetaGraph, afterTask) unit. Preserve current pressure/budget behavior and assert the existing rotation tests against the planner plus service integration.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: The finalized cleanup service is a monolithic state machine

What's wrong
The new service concentrates too many independent responsibilities in one class and one large method. The long comments are carrying invariants that should be encoded in smaller abstractions, so the implementation is fragile even if the current behavior is correct.

Example
A future sweep exit path has to coordinate rotationPending, metaGraphResumeCursor, taskCursor, rotationBacklogDepth, and stale backlog reporting. The current code already has separate cursor persistence in yieldRotation, the task page branch, and the catch block.

Suggested direction
Split the scheduler/cursor mechanics from task deletion and metric aggregation. A small CleanupRotation or async iterator with explicit commitContextGraph / defer operations would delete much of the ad-hoc state coupling and make new yield points much safer to add.

For Agents
Refactor FinalizedSwmCleanupService by extracting a pure rotation/cursor component, a single-meta-graph task cleaner, and a backlog metric accumulator. Preserve current pressure/budget/stale semantics and keep the existing rotation/pressure tests green, but make runSweep read as orchestration instead of owning all mutable state transitions directly.

|| expectedHead.shareOperationId !== shareOperationId
|| expectedHead.assertionGraph !== assertionGraph
) {
await this.retireStaleTask({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Stale cleanup-task retirement is not covered

What's wrong
The PR adds a new cleanup path that deletes durable finalized-SWM cleanup tasks when their workspace head is absent or superseded. That is a meaningful lifecycle transition, but the added tests mostly cover exact cleanup, pressure/budget gates, and preservation races. Without a regression test for this branch, a change that never retires stale tasks, or one that retires a task while a payload is still live, can leave the backlog permanently noisy or strand a resurrected SWM copy without a cleanup task.

Example
Seed a finalized-cleanup task for assertion v1, then make the workspace head point at assertion v2 before cleanupKnownMetaGraph(). The test should prove the v1 task is retired while the v2 head/SWM data remains untouched. A separate headless case should prove a task is retired only when both the head and payload are gone.

Suggested direction
Add focused regression coverage for stale-task retirement, including the negative case where a headless payload is still present and the task must not be retired.

For Agents
Add service-level tests around FinalizedSwmCleanupService.cleanupKnownMetaGraph that enter retireStaleTask: superseded head, absent head with absent payload, and absent head with payload still present. Preserve live/newer SWM and assert task deletion only in the stale cases.

Jurij89 and others added 14 commits August 2, 2026 13:17
A yield inside a context graph's meta-graph loop leaves that graph at the
head of `rotationPending`. Once every other graph has completed, the
pending list is a single element, and the stall escape at resumeRotation
required `pending.length > 1` — correct in itself, since rotating a
one-element list is a no-op, but it left no escape at all for a sole head.
`rotationPending` then never returned to null, so the rotation never
re-seeded, and every OTHER context graph on the node was never swept
again. Because the cursor is in-memory, that persisted until restart:
#1996 reintroduced node-wide by the subsystem that exists to close it.

Reproduced against the compiled service (3 CGs, slow one last with 12 meta
graphs, 120ms budget): slices 1-7 entered only the slow graph and the
other two were served 0 times. After the fix they are served on every
third slice as the rotation re-seeds.

On a sole stalled head, close the rotation and re-seed from the current
context-graph list. The partial accumulator is discarded rather than
published: an incomplete rotation must never become a whole-node total, so
`lastKnownBacklogDepth` keeps the last complete measurement and slices keep
reporting `stale` until a rotation genuinely finishes. A node whose budget
cannot fit one context graph reports an honestly unknown backlog instead of
a confident wrong one.

The warning now also fires on this path. Previously it lived inside the
deferral that could not trigger for a sole head, so the one case that
stranded the node was the one case that logged nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxoLY1KYH1cRrsqStKCsaH
Closing the rotation for a sole stalled head restores node-wide progress,
but the stalled graph itself still restarted its meta-graph walk at index 0
every slice and yielded at the same point. Everything past that point was
never cleaned — the rotation cursor's own failure mode, one level down, and
#1996 for those meta graphs.

Measured on the repro (12 meta graphs, 120ms budget): 4 of 12 distinct meta
graphs were ever reached, no matter how many slices ran. Now 12 of 12.

Carry a per-graph resume offset and rotate the walk by it, wrapping. The
walk still covers every meta graph in one pass, so a context graph still
contributes to the rotation total only when fully measured and the no-
double-count invariant is untouched — only the starting point moves. The
cursor clears whenever the graph completes, so a graph that fits in one
slice always starts at the top.

Separate from the sole-head fix so it can be dropped independently: that
one closes a node-wide strand, this one a per-graph one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxoLY1KYH1cRrsqStKCsaH
A terminal fault mid-rotation rethrew without touching `rotationPending`.
Context graphs completed earlier in the same sweep were already credited to
`rotationBacklogDepth`, and resumeRotation only zeroes the accumulator when
`rotationPending` is null — which a throw never produces. The next sweep
resumed from the stale tail, re-measured those graphs and added them again,
and the inflated sum eventually published as `stale: false`: presented as a
trustworthy fresh whole-node measurement.

Reproduced on three context graphs of 10 markers each: true total 30,
reported 30 -> 40 after one fault. Requires the rotation to have been
resumed (a prior yield left `rotationPending` non-null); a fault during a
fresh rotation is already safe because resumeRotation zeroes the
accumulator on entry.

Catch around the whole loop rather than at each of the four throw sites, so
a raise site added later cannot miss the requirement — enumerating sites is
how three of the four pressure conversions went unpinned earlier in this
change set. The loop body is re-indented into the try; `git diff -w` shows
the change is four lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxoLY1KYH1cRrsqStKCsaH
runSweep derives two deadlines from wallClockBudgetMs: one from the
injected clock, and one from AbortSignal.timeout on real wall time that is
threaded into the store and aborts its queries. Only the first is
controllable from a test, and these tests injected a clock while setting a
100ms budget — so any slice taking longer than 100ms of real time yielded
at a boundary the injected clock never chose.

Reproduced deterministically before fixing: with the injected clock FROZEN,
so the injected deadline can never fire, a 150ms store query against a
100ms budget still returned budgetExhausted: true after 158ms real. The
yield came exclusively from the real timer.

That matters more than ordinary flakiness here, because these are the
tests pinning the cursor's forward-progress guarantee — the property that
turned out to be broken. A non-deterministic test on a guarantee that
failed is close to no test at all, and on a loaded machine or one running
several worktrees it reddens for reasons unrelated to the code.

The budget now exceeds any plausible real slice duration and the tick is
expressed as a fraction of it, so the injected clock alone decides every
deadline yield. The header records why, since the obvious tidying is to
shrink these back to small round numbers.

All five rotation mutants still die, so the tests remain load-bearing
rather than merely stable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxoLY1KYH1cRrsqStKCsaH
Discovery was ORDER BY ?task LIMIT n with no cursor, so every sweep
re-selected the same prefix. A task that can never be acted on — a marker
armed from VM state whose live SWM diverges, which finalization-handler
reaches whenever VM verifies — returns `preserved` on every sweep and is
charged to the node-wide deletion budget every time. Four such rows at the
ORDER BY head are enough that no other meta graph gets a candidate query
again.

The charging is not the bug and is deliberately unchanged: examination is
the expensive operation (a head resolve, plus two verifyExactGraphScopedLayer
passes per matching row), so counting only deletions would uncap exactly the
work the budget bounds. Selection is the bug.

Carry one keyset position, advancing while the page is full and clearing
when it is short — without the wrap this trades a stuck prefix for a
permanently stranded suffix, the same starvation with the opposite sign. A
single entry is bounded by construction rather than by an eviction policy:
charging per examined candidate means a full page spends the rest of the
budget, so at most one meta graph can hold an unfinished page.

Only runSweep supplies the cursor, so the cleanupKnownMetaGraph seam and its
~dozen drainFinalizedSwm callers keep selecting from the top unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxoLY1KYH1cRrsqStKCsaH
6501745 states that without the short-page wrap a stuck prefix is traded
for a permanently stranded suffix. Mutation testing refutes that: a mutant
that advances the cursor on short pages too is NOT killed, because an empty
page carries no last subject and clears the cursor anyway. Dropping the wrap
costs one wasted empty query per cycle; it does not strand anything.

The wrap is kept — one query per cycle is worth the line — but described as
the optimisation it is. I asserted the stronger claim in a commit message
and a code comment without testing the negative, which is the same failure
this change set has been correcting elsewhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxoLY1KYH1cRrsqStKCsaH
The sweep loop keeps growing rotation-cursor persist sites — a fault path,
then a task-selection cursor — and a mutant that enumerates them weakens
silently every time one is added. The enumeration is complete when
written, the code grows underneath it, and nothing announces that the
mutant now neutralises only part of what it names.

That is harder to notice than ordinary under-enumeration, because there is
no point at which anyone did anything wrong.

Records the site-independent form instead: neutralise the cursor at its
single declaration with a getter/setter pair that discards writes. It
cannot be outgrown by new assignment sites, so it stays valid across
restructures of the loop rather than needing maintenance alongside them.

Verified against the current shape, which already has two persist sites
plus a clear: the declaration mutant kills the resume test on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxoLY1KYH1cRrsqStKCsaH
The header now tells the next person to neutralise the rotation cursor at
its declaration, and that instruction is easy to over-apply: "one mutation
covering everything" is exactly the failure this change set spent its time
removing, where four conversion sites were killed by one test and three
predicates were asserted in one case.

The two are not the same. A blanket mutant stands in for many properties,
so its kill says something broke without saying what. A site-proof mutant
covers one property and is merely robust to that property gaining
implementation sites.

Records the check that separates them: whether the kill set stays narrow
and specific. The declaration mutation kills the resume test and nothing
else. A site-proof mutant that starts reddening half the suite has become
blanket, and that is a signal to split it rather than a stronger result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxoLY1KYH1cRrsqStKCsaH
markFinalizedGraphScopedSwmForCleanup wrote the tombstone with a bare
store.insert while every other writer of that operation subject takes
swmKaWriteLockKey. Catch-up REPLACES the same subject under that lock: it
reads the tombstone, deletes the subject, then re-inserts from the snapshot.
An unlocked marker lands inside that window — it runs at default priority
while the replace's reads and deletes are queued at background, so it is
admitted ahead of them — and the re-insert restores a snapshot taken before
it existed.

The tombstone is then gone while the independent task subject survives, so
the GC cleans the lifecycle once and retires the task, and the next
catch-up re-materializes the SWM copy with nothing left to re-arm cleanup
from. That is a permanent resurrection of exactly what this PR removes.

Taking the lock is compatible with keeping finalization foreground work
O(1) because that constraint is on WORK, not on waiting. This path already
reads and hashes the entire SWM payload via verifyExactGraphScopedLayer
before reaching the marker write, so a bounded wait behind one KA's replace
is smaller than what it has already done, and it adds no cleanup discovery,
verification, deletion, or GC wait. The insert is a leaf operation, so the
lock cannot nest and cannot deadlock.

writeLocks returns to FinalizationHandler injected and READ. The field
deleted earlier in this branch was dead — assigned, never used — which is
worse than absent because it advertised a serialization that never
happened, and is why this race survived review.

Rejected alternatives: re-reading the tombstone later inside the lock only
narrows the window, and replaceSubjectAtomicallyOrFallback does not close
it either, since the payload is still snapshotted before the atomic
boundary. Moving the tombstone to a subject catch-up never deletes is the
better end state but is a persisted-shape change with an upgrade path,
filed separately.

The test injects the interleaving rather than racing for it: the marker is
started as catch-up is about to delete the operation subject and given
twenty event-loop turns. Unlocked it completes every time; locked it cannot
complete at all, because the replace holds the lock — so the assertion
turns on mutual exclusion, not timing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxoLY1KYH1cRrsqStKCsaH
retireStaleTask is the only path that removes a cleanup task whose head
has moved on, and nothing reached it — not the function, not its call
site. Its guards were therefore free to be removed without any test
noticing.

The load-bearing case is the negative one: an absent head with the payload
still present must NOT retire. That is not a finished lifecycle, it is a
resurrected SWM copy whose head has not been rebuilt, and retiring its task
strands that copy with nothing left to collect it — the resurrection this
change set exists to prevent, arriving through the retirement path rather
than the deletion path. Removing that guard ships green today.

The superseded case asserts the newer assertion survives intact, not
merely that the stale task went: a change that retires v1 and damages v2
would pass a task-count assertion.

Retirement is also pinned as inert when the store cannot report write
generations, which is production behaviour on a backend that ships no
tracker. Pinning the inertness is deliberate — a later decision that
retiring on non-write-gen evidence is safe should have to turn this red
rather than pass silently.

Task presence is asserted directly because retirement is not reflected in
the drain's return value, which counts reclaimed lifecycles only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxoLY1KYH1cRrsqStKCsaH
retireStaleTask re-resolves the head under the writer lock and declines to
retire when the lifecycle it names has become current again. Nothing
reached that branch: the four retirement cases all leave the head
mismatched throughout, so the guard could be removed with every one of
them still green.

It is the twin of the re-check in clearIfStillExact, on the other
destructive path. Narrower — this removes a marker rather than payload, so
the damage is a finalized SWM copy left with nothing to collect it rather
than data destroyed — but the same class, and reachable only by changing
the head BETWEEN the two reads, which no static fixture produces.

The seam is positional rather than by source: both head resolutions on
this path carry the same discover source, so the fixture counts
occurrences. That is recorded in the test, because inserting another query
with that source ahead of these would retarget the seam silently and the
test would keep passing while no longer exercising the guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxoLY1KYH1cRrsqStKCsaH
@Jurij89

Jurij89 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Merge-readiness review — round 3, at ab63da7a1

Verification at the pushed head. Two independent runs against frozen tips
(gc cc0fa0704 / sync 6ea5a38e9 / qa c4c568bc7), pre-registered as different suite selections so
differing totals are expected rather than a finding:

  • mine — 175 passed across 9 chain-free suites at ab63da7a1
  • freeze pass — 279 passed across 5 tiers, every tier emitting a Tests … passed line (none
    INVALID), and no branch moved during the pass (post-pass tip check re-confirmed all three)

Overlap agrees: every suite both runs covered passed in both, and the one mutant both of us ran
killed the same single test.

Disclosure: I reviewed this PR, then directed the team that implemented the fixes, so this is not
an independent review of someone else's work. Everything I call verified below I ran myself.
Several of my own proposals were refused or corrected during this round and the refusals were right
each time — those are called out, because they are the best evidence of what this change set got
right.

This round exists because "merge-ready pending CI" was wrong. At that point the PR had 56 green
CI checks, "37/37 mutants killed", two reviews by me, five rounds of team implementation and 169
passing tests. That mutation figure is quoted as it stood then, and it is a good illustration of
this review's own point about enumeration: three of those 37 only began dying after the four
scheduler-conversion sites were re-enumerated, having previously been scored against a site list
that covered one of four. An independent pass over the assembled final state — seven read-only lenses, each
finding then adversarially refuted — raised 35 findings, refuted 25, and left 10, including a
HIGH I reproduced myself. Incremental verification asks "did each fix do what it claimed"; nobody
had asked "what does the assembled system now do".


The central structural finding: one bug class, four levels

Level Symptom Status
context-graph rotation re-walks the same CG prefix; later CGs never swept fixed
meta-graph walk re-walks the same meta prefix; later meta graphs never cleaned fixed
task selection re-walks the same task prefix; successors never examined fixed (keyset cursor)
cross-level (deletion traversal) node-wide budget always lands at the front open — documented

All four are no cursor, restart at the beginning, successors never reached. Levels 2–4 were found
only because level 1 was investigated. The fourth is not patchable level-by-level: two cascade
attempts both stalled at 15/30 distinct tasks examined, and a full separation reached 30/30
but regressed four tests — including the level-1 starvation guard fixed earlier in this same PR.

Root cause: the GC borrows one traversal for two jobs with opposite requirements. Measurement must
cover every context graph for the backlog total to be whole-node; deletion must resume exactly where
its budget ran out and ignore rotation shape. Separating them couples three contracts — wall-clock
split, rotation-cursor persistence, and pressure classification — where fixing the second breaks the
third. That is a design task, not a patch, and it goes to a follow-up with a full spec rather
than being converged on by trial at the end of a fix round.

Fixed and verified by me at the frozen tip

Fix How I verified it
rotation strand — every other CG starved forever own probe: cg-b/cg-c served every third slice, was never
intra-CG meta-graph walk own probe: 12/12 meta graphs measured, was 4/12
marker write unlocked → tombstone erased → SWM resurrected own single-site mutant: kills exactly 1 test, named for the behaviour
terminal fault double-counted backlogDepth as stale:false own probe: publishes 30, the true total, was 40 and growing
responder GC-row leak, per predicate and per lane own store-side mutant: kills the 2 shared + exactly the 2 named cases
responder IRIs retyped rather than derived same mutant kill set through the indirection; 16 passed alongside

Restores proven by git diff 0 lines plus a positive grep for the real construct — not sha1,
which is unreliable here because LF→CRLF normalization changes bytes while content is identical.

Known unguarded — real, not blocking

  1. Cross-level deletion starvation (above). On a node whose slice budget cannot cover a full
    traversal, deletion favours the front. Independently raised as a 🔴 by the review bot from a cold
    read. No green test contradicts this — the rotation suite seeds backlog-only markers that
    discovery can never select, so deletion is never exercised there.
  2. Peer-planted cleanup tasks — an authorized peer can write subjects the GC treats as its own
    work list, occupying its budget. Availability, not integrity: every guard in clearIfStillExact
    recomputes from local state, so a plant selects which KA is considered, never whether deletion is
    safe.
  3. Retirement is silently inert without write-generation tracking. retireStaleTask's absent-head
    branch returns when preflightWriteGen === undefined. GraphWriteGenTracker is present in the
    oxigraph and sparql-http adapters but not in blazegraph.ts — a supported backend. An operator
    there experiences it as unexplained budget starvation with lastError: null.
  4. The rotation tests inject a clock the abort deadline does not use. deadline is injectable;
    AbortSignal.timeout is real. Mitigated by a budget large enough that the real timer cannot fire
    first, with a header recording why the numbers must not shrink back. The injectable seam is the
    real fix and is deferred.
  5. Retirement is not counted as progressdeletedItems counts only 'cleared', so a sweep that
    only retires reports zero and the worker's fast re-drain never arms; work waits for the 15-minute
    backstop.
  6. Agent test files are never typechecked; ka-graph-finalization-recovery.test.ts is absent
    from vitest.unit.config.ts
    and silently skips when named. It exercises this PR's changed code
    indirectly — via DKGAgent, so a grep for FinalizationHandler in it returns zero and proves
    nothing. It was run explicitly in the freeze pass: 5 passed, so no bisect was needed. The
    wiring defect stands on its own; it was not covering a rotting suite.
  7. spawnHardhatEnv port cleanup is a no-op on Windows, and the two vitest configs pin different
    ports (9545 and 9547), so a manifest checking one can collide on the other.

What this round cost me, stated because it changed decisions

  • I proposed a fix that would have been a regression. "Charge the budget for work done" would let a
    meta graph with 16 preserved rows run ~32 layer verifications for zero budget spend — uncapping the
    work the budget exists to bound. Examination is the expensive operation.
  • I mis-framed the planted-task finding as integrity — "security boundary", "peer can plant deletion
    tasks". It is availability. That phrasing would have priced the PR wrong for anyone reading only the
    summary.
  • My write-up of the double-count omitted a precondition — it needs a prior yield; a fault during
    a fresh rotation is already safe. A repro without the yield would have cleared a real finding.
  • My first reproduction of the confirmed HIGH failed and the bug was real. My probe gave the slow CG
    no meta graphs, so its iteration completed and the rotation closed — the harness could not have shown
    the failure. Had I stopped there I would have reported it refuted.
  • My review apparatus overran its agent budget 3× (43 vs 12–15): one refuter per finding, uncapped,
    so agent count was set by how much the lenses found. Several refuters also verified against a stale
    worktree; one finding's evidence was stale as a result, and only re-checking at the real head caught it.
  • I treated a status report as a decision and issued a consequence of it, churning a teammate through
    drop → hold → drop.

The most transferable output — verification that lies

Four ways a mutation result gets fabricated, all found this round, all producing confident output:

  1. a run that never executed (Hardhat port collision) reporting as a kill;
  2. a mutation left on disk by a failed apply, contaminating the next result;
  3. a git checkout restore silently discarding the uncommitted change under test;
  4. the validity check itself failing silentlygrep -E "^ *Tests " matches nothing because of the
    ANSI prefix, so an empty result is indistinguishable from a clean run.

A mutant result counts only if the run emitted Tests … passed, the tree was provably clean
beforehand, and the change under test is still present afterwards
— with a matcher you have proven
can match.

And two beyond mutation:

  • A rewritten commit keeps its subject. "The branch still shows the commit I verified" is not a
    check. Compare blobs of the artefacts you touched — sha inequality says something moved; blob
    equality says your evidence survives.
  • A check that can't fail is not a property of tests. It is a property of any artefact that reads as
    a guarantee while nothing enforces it. Five instances this round: an untested negative in a code
    comment and commit message; a proposed cardinality bound in a doc comment (later proved false); a
    documented "callers MUST fail open" contract one caller quietly violates; a commit message describing
    a mechanism about to be removed; and an approved warning about a call being inside a lock it was two
    lines outside. A false warning rots exactly like a false guarantee.

Usable form: "the system guarantees X" stays out of code unless there is a fixture; "don't change
this to Y"
belongs in it.

And one thing stronger than any mutant. The measurement-rotation tests were backed all round by
mutation matrices, which establish sensitivity, not usefulness. Then a real change arrived and one went
red for the right reason before it shipped. Mutants prove a test can fail; a genuine regression proves
it catches one.

Follow-ups

  1. Deletion traversal starvation — full spec, evidence table, both traps, rebuild notes.
  2. Peer-planted cleanup tasks — canonical-subject gate plus a retirement path that terminates.
  3. Blazegraph write-generation gap — retirement inert; the store-abstraction hole is broader than this PR.
  4. Inbound predicate allowlist on validateOperationRows — the root cause behind (2).
  5. Tombstone on its own subject — closes the resurrection race with no lock and removes the tombstone read from the ingest path.
  6. GraphManager.listContextGraphs owner/name defect — pre-existing, already user-reported as GH SWM WorkspaceOperation writes prov:wasAttributedTo as PeerId literal instead of agent DID URI #748, four callers remain.

<${DKG_NS}publicQuadsCount> ?count .
FILTER(STR(?version) = ${JSON.stringify(input.scope.assertionVersion)})
}
} ORDER BY ?shareId LIMIT 16`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: Immutable snapshot recovery can permanently miss candidates after the first 16 share IDs

What's wrong
This fallback is meant to recover after finalized SWM cleanup removes the live SWM copy, but the fixed LIMIT 16 makes the answer depend on lexicographic share-operation ordering rather than on whether a matching immutable snapshot exists. Once the matching operation is outside that first page, retries are deterministic and keep missing the same valid snapshot, so recovery is stranded.

Example
If a KA has 17 same-version operation snapshots with the same public triple count, and the matching snapshot's shareOperationId sorts 17th, verifyImmutableGraphScopedSnapshot never examines it. Every retry re-runs the same ORDER BY ?shareId LIMIT 16, returns undefined, and chain reconciliation cannot restore the VM from the immutable snapshot.

Suggested direction
Do not truncate the search to the first 16 lexicographic share IDs. Page through candidates deterministically, or derive a stronger indexed key when evidence includes a digest, while still bounding repeated payload verification by digest memoization.

For Agents
In FinalizationHandler.verifyImmutableGraphScopedSnapshot, replace the fixed first-page lookup with a complete paged/keyset scan, or otherwise continue discovery until all matching metadata candidates have been considered. Preserve the content-digest rejection memo so duplicate-content candidates do not cause repeated payload reads. Add a regression where the matching share operation sorts after at least 16 nonmatching candidates and reconciliation still promotes from the immutable snapshot.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bb2efd5a9, shipped in 46243351a. Confirmed as filed — verified that verifyImmutableGraphScopedSnapshot has zero occurrences at the merge base, so this PR introduced the path, and the fallback fires whenever the live SWM no longer verifies, which after the GC has run is the normal case for a late receipt.

Both discriminating tests moved into the SPARQL WHERE, so LIMIT truncates among matching candidates rather than all candidates. The content-digest rejection memo is untouched — it bounds repeated payload verification, which is work; this was always the search.

The count filter is deliberately numeric-or-lexical and guarded by Number.isSafeInteger so it fails open: a pre-filter stricter than the JS check it precedes would be the same missed-discovery bug from the other direction.

Fail-before proven — reverting the filters to post-LIMIT reddens exactly finds a matching snapshot that sorts past the discovery limit with expected undefined to be 'verified', one test, no collateral. Detail in the addendum comment on the PR.

* tombstone read it already performs, so calling both duplicates a
* lock-held store read per KA for no added guarantee.
*/
ensureFinalizedCleanupTask(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Hide cleanup re-arming behind the materializer instead of making callers remember branch rules

What's wrong
The interface creates a temporal coupling: callers must know which materializer methods implicitly re-arm cleanup and which paths need a separate call. That is exactly the kind of special-case branching this PR should be eliminating, and it will be easy to regress as more snapshot decisions are added.

Example
Adding another early-return branch in runSharedMemorySync() after readStoredHead() but before replaceHeadMetadata() now requires the caller to remember both to call ensureFinalizedCleanupTask() and to add the descriptor metadata keys to replacedGraphScopedMetaKeys. That is lifecycle policy leaking into orchestration.

Suggested direction
Collapse ensureFinalizedCleanupTask, replaceHeadMetadata, and the caller-side replacedGraphScopedMetaKeys bookkeeping into a single higher-level materializer workflow for one descriptor. The sync caller should ask for an outcome, not coordinate cleanup-task policy itself.

For Agents
Look at packages/agent/src/sync/requester/shared-memory-sync.ts and packages/agent/src/sync/requester/swm-snapshot-materializer.ts. Move the finalized-cleanup rearm decision and metadata-suppression bookkeeping behind one materializer operation or return a typed materialization outcome that carries the metadata rows to insert/skip. Preserve all four current branches and their counters.

Jurij89 and others added 2 commits August 2, 2026 17:21
verifyImmutableGraphScopedSnapshot bound every same-version operation and
took ORDER BY ?shareId LIMIT 16, then applied the triple-count and digest
tests in JS to whatever survived. So LIMIT truncated the CANDIDATE SET, not
the work: sixteen same-version operations with the wrong count filled the
window and the matching snapshot was never examined.

That is not a flake that clears on retry. The ordering is deterministic
over stable store state, so every attempt re-derives the identical sixteen
and misses the same snapshot, and chain reconciliation can never restore
the VM. Reaching it needs only a KA re-shared repeatedly at one version.

This path is also new in this branch and load-bearing: the fallback fires
whenever the live SWM layer no longer verifies, which after the idle GC has
run is the normal case for a late receipt. The branch both created the path
and made it the common one.

Push the discriminating tests into the WHERE clause so LIMIT truncates
among MATCHING candidates. With a digest supplied the match count is ~1 and
the limit stops mattering; without one, count-filtering alone collapses the
field.

?count is compared numerically first so a non-canonical typed literal still
matches, with the lexical form as a fallback for an untyped one. The filter
must never be stricter than the JS check it precedes — one that rejects a
candidate the caller would have accepted is the same missed-discovery bug
wearing different clothes.

The content-digest rejection memo is unchanged. It bounds repeated payload
verification, which is work; this was always about the search.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxoLY1KYH1cRrsqStKCsaH

@otReviewAgent otReviewAgent left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Operational Notice: Review Agent could not complete this review.

Business logic reviewer failed: retry_exhausted

@Jurij89

Jurij89 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Addendum — head moved to 46243351a; one known-limitation entry was wrong

Posting this rather than editing the review above, because one of its entries mis-described a real bug and the record is worth keeping.

The correction

The review lists as known-unguarded:

LIMIT 16 no-digest residue. Without a digest, candidates genuinely differ and each must be resolved.

That describes duplicate work. It is missed discovery, which is a different and worse thing.

verifyImmutableGraphScopedSnapshot discovered candidates with ORDER BY ?shareId LIMIT 16, and the count and digest tests ran in JS on the returned rows — so the limit truncated the candidate set before anything discriminating was applied. If the matching snapshot's shareOperationId sorted past the first sixteen it was never examined, and because the ordering is deterministic over stable store state, every retry re-derived the identical sixteen. Permanently stranded recovery, not a slow path.

Raised as a 🔴 by the review bot against the previous head. Verified: verifyImmutableGraphScopedSnapshot has zero occurrences at the merge base — this PR introduced the path, and the fallback fires whenever the live SWM layer no longer verifies, which after the GC has run is the normal case for a late receipt. So the PR both created the path and made it load-bearing.

The fix — bb2efd5a9, in 46243351a

Both discriminating tests moved into the SPARQL WHERE, so LIMIT truncates among matching candidates:

FILTER(?count = N || STR(?count) = "N")        -- always
FILTER(STR(?digest) = "…")                     -- when a digest is supplied

The content-digest rejection memo is untouched — it bounds repeated payload verification, which is work. This was always the search.

The count filter is deliberately numeric-or-lexical, and guarded by Number.isSafeInteger so it fails open. A pre-filter stricter than the JS check it precedes would be the same missed-discovery bug from the other direction, failing just as silently.

Fail-before proven: reverting the filters to post-LIMIT reddens exactly finds a matching snapshot that sorts past the discovery limit with expected undefined to be 'verified' — one test, no collateral.

Why it survived three rounds

Worth recording, because it is the most instructive thing in this PR.

An earlier round removed a candidate cap (candidates.slice(0, 4)) on the explicit grounds that bounding a search is not bounding work — and left LIMIT 16 two lines above, satisfied that one bounded only duplicate work. The reviewer who found the original cap verified its removal and did not ask what else in that path truncated before filtering. I then wrote the residue into the review above as "duplicate work".

Three people, three independent write-ups, one misreading — each of us checking exactly the thing in front of us, with the class living in the gap between. It is the fourth instance of the truncation family in this subsystem, and the one that survived longest because it had been examined and dismissed rather than missed.

Verification at 46243351a

  • 280 passed across 5 tiers at frozen tips (gc cc0fa0704 / sync bb2efd5a9 / qa c4c568bc7), all three unchanged post-pass, Tier 4 from a clean isolated re-run after an INVALID first attempt was discarded rather than cited
  • 153 passed across 7 suites in an independent run, plus the fail-before mutant above
  • CI green: 56 success, 3 skipped, 0 failures

One claim from the review above, retracted

A follow-up investigation suggested the numeric arm of the count filter was reachable-but-unpinned via a peer-supplied non-canonical literal such as "02"^^xsd:integer. That is wrong and is withdrawn. Oxigraph canonicalises well-typed integer literals on insert — verified directly, "02" reads back as "2" — so the STR-only form matches everything the numeric arm does and the mutation is equivalent, not a coverage gap. The arm stays as correct defensive coding.

Whether sparql-http/Blazegraph canonicalises the same way is untested and not asserted; it is recorded as an open question on the write-generation issue, which already concerns behaviour differing by backend.

The other known-limitation entries in the review above stand unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants