Two small defensive changes that close the remaining cleanup-and-race
gaps from #488. No new ADRs needed — both are additive within
already-documented subsystem boundaries.
C.2 — dream_insights retention pruner
=====================================
The dream_insights schema has no expires_at column and no foreign-key
cascade fires on regular cycle delete (cycles aren't routinely purged
either). Without pruning the table grows linearly with hook activity —
V0.03 evidence in #488 documents 120+ rows in 30h, most with
applied=0. Over months `getPendingInsights` queries get slower and
vector embeddings (if attached) grow unbounded.
src/learning/dream/dream-insights-pruner.ts (NEW)
- pruneStaleDreamInsights(db, { retentionDays = 30 }):
DELETE FROM dream_insights
WHERE applied = 0
AND created_at < datetime('now', '-N days')
- Applied insights stay forever (audit trail).
- Recently-created unapplied insights stay regardless of applied
state — they might still be picked up by a future applyInsight.
- Guards against the table not existing (fresh init before the
dream schema is applied). Idempotent.
src/workers/workers/learning-consolidation.ts
- new Step 8 in runContinuousLearningLoop after the mineExperiences
sweep: prune insights, emit a finding if pruned > 0, surface
`dreamInsightsPruned` in ConsolidationResult and domainMetrics.
- Failures isolated per-try-block (worker continues with the rest
of its lifecycle work).
C.3 — bridge cursor monotonic guard
===================================
CapturedExperienceBridge.drain wrote `this.cursor` to the kv unconditionally.
Cross-process race: two daemons (possible before #488 B.1 fixed the
pidfile, still possible if user manually spawns extras) drain
overlapping rows, then the "slower" process overwrites the kv with its
older value. Next drain re-publishes already-drained rows; downstream
plugins without event-ID dedup see duplicates.
src/bridge/captured-experience-bridge.ts
- Before persisting the cursor, read back what's stored and write
Math.max(this.cursor, persisted). The cursor never regresses.
- Read-then-max-write is a TOCTOU but it collapses the regression
window to milliseconds — sufficient for the duplicate-daemon edge
case. Stronger guarantees (SQL UPDATE WHERE, advisory lock) would
bypass the MemoryBackend abstraction without a real-world benefit
post-B.1.
- Cursor persistence is now best-effort (catch and warn). On failure
the next bridge boot re-reads from kv and resumes — a missed write
just means we re-publish a small batch, idempotent for ID-dedup'd
consumers.
Tests
=====
tests/unit/learning/dream/dream-insights-pruner.test.ts (NEW)
- 8 tests: deletes stale unapplied, keeps applied regardless of age,
keeps recent unapplied, honors custom retention, no-op on empty
table, no-op when table doesn't exist, default 30-day retention,
idempotent re-run
tests/unit/bridge/captured-experience-bridge.test.ts (extended)
- 3 new tests in describe('cursor monotonic guard'):
- does not regress when another writer is ahead
- advances normally when this bridge is ahead
- sensible fallback when no persisted value exists
594/594 tests pass across CLI, workers, bridge, learning, init/phases,
architecture, and kernel suites; build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rescoped per @Jordi-Izquierdo-DDS's filed issue #484 and his Stage A.1
verified shop patch 050:
- KEEP the domain-specific fan-out events (test-execution.TestRunCompleted,
coverage-analysis.CoverageGapDetected/CoverageReportCreated,
test-generation.TestGenerated, code-intelligence.FileChanged) with
their existing flat basePayload shape. Their handlers were already
shipped against this shape; changing it would break out-of-tree
subscribers. Smaller blast radius.
- ONLY change the universal learning.ExperienceCaptured event to use the
canonical nested { experience: TaskExperience-shaped, reward } shape.
Tests parametrized across multiple non-fan-out domains
(requirements-validation, security-compliance, visual-accessibility) per
Jordi's #484 test improvement suggestion — these exercise the
universal-event path exclusively, which v3.9.28's test-execution-only
test inadvertently masked via the fan-out's side-effect kv write.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hotfix for v3.9.28 — bridge payload shape (#482 round 3)
After v3.9.28 fixed the subscriber-loading order, handlers fired for
every bridge event but immediately short-circuited because
event.payload.experience was undefined. The bridge had been publishing a
flat payload { experienceId, domain, agent, task, ... } that didn't
match handleExperienceCaptured's destructure.
Fix: bridge now publishes the canonical nested
{ experience: TaskExperience-shaped, reward } shape that matches the
existing publisher in src/learning/experience-capture.ts:1043.
Also dropped the domain-specific fan-out events (test-execution.TestRunCompleted,
coverage-analysis.CoverageGapDetected, etc.) — their handlers expect
domain-event-specific fields (runId, passed, failed, gapId, riskScore)
that hooks don't capture, so publishing them with undefined fields
recorded degenerate experiences. Universal learning.ExperienceCaptured
is now the bridge's sole publication.
Integration test now uses requirements-validation domain (per Jordi's
suggestion) so the universal-event path is the sole thing being tested,
not masked by a fan-out side effect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- AbortSignal threading bounds ReasoningBank.initialize() (#478)
prevents leaked init from runaway patterns.rvf writes
- CapturedExperienceBridge wires hook activity to domain plugins (#479)
via kernel-side drain of captured_experiences
- Removes dead v3-qe-bridge.sh, two stale planning docs, five orphan
workers in qe-workers.ts (subsumed by LearningConsolidationWorker)
Net: +430 / -2,090 lines
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>