Skip to content

fix(e2e): set busy_timeout in memory-injection seedMemory + classify memory.external in dashboard coverage - #2

Open
codeo1io wants to merge 25 commits into
iceteaSA:external-memory-backendfrom
codeo1io:external-memory-backend
Open

fix(e2e): set busy_timeout in memory-injection seedMemory + classify memory.external in dashboard coverage#2
codeo1io wants to merge 25 commits into
iceteaSA:external-memory-backendfrom
codeo1io:external-memory-backend

Conversation

@codeo1io

Copy link
Copy Markdown

Problem

Two CI failures on the external-memory-backend branch:

  1. Check (dashboard): config-parity.test.ts reported 16 uncovered leaves under memory.external.* — the schema gained the external-memory subtree but the dashboard's config-field-coverage.ts manifest was never updated.

  2. E2E (OpenCode, host behavior): memory injection > injects <project-memory> intermittently failed with SQLITE_BUSY at seedMemory (.../memory-injection.test.ts:79).

Root causes

  1. The external-memory-backend feature branch added the memory.external schema subtree (provider/endpoint/banks/retain_sources/tags + recall sub-block) but didn't add a corresponding entry to the dashboard's coverage manifest. The parity guard enforces that every schema leaf is either RENDERED by the form or in OMITTED_BY_DESIGN.

  2. seedMemory opens context.db with a bare new Database(dbPath) and immediately runs an INSERT. The live opencode serve process spawned by the harness holds the WAL writer lock during historian checkpoints / dreamer runs, and the test's external write had no busy_timeout — so SQLite returned SQLITE_BUSY immediately instead of waiting. Same contention pattern the plugin itself was fixed for.

Fixes

1. Classify memory.external as OMITTED_BY_DESIGN (3688e259)

memory.external is USER-config-only and operator-configured (endpoint/api_key, bank routing, recall tuning) — no form widgets exist for it. Added the whole subtree to OMITTED_BY_DESIGN with the memory.external prefix (covers all 16 leaves).

2. Set busy_timeout in seedMemory (1abddf61)

 const db = new Database(dbPath);
 try {
+    db.query("PRAGMA busy_timeout = 5000").run();
     const now = Date.now();

Matches the pattern already used by writeContextDb in cache-invariants.test.ts and the plugin's own openDatabase().

Verification

  • bun run --cwd packages/dashboard test — 10 pass, 0 fail ✅
  • bun test --timeout 120000 tests/memory-injection.test.ts — 1 pass, 0 fail ✅
  • bun run --cwd packages/plugin typecheck — clean ✅
  • bun run --cwd packages/dashboard lint — clean ✅

Also includes

This PR supersedes the closed PR #1 (fix/cold-start-db-locked-rebase) and bundles the full concurrency fix series (cold-start busy_timeout install + centralized runWriteTransaction with bounded SQLITE_BUSY retry) that the external-memory-backend branch needs to eliminate the database is locked plugin-load failures observed in production.

Tehan and others added 25 commits June 18, 2026 21:38
…r-config-only

Engine-agnostic external memory config (provider/endpoint/banks/retain_sources/
tags) + recall sub-block (timeout, per-slice budget, dedup threshold,
global_tags, global_from_prompt, search, mental models). Whole block stripped
from project-level config (a hostile repo must not redirect a user's memory
store). Schema asset regenerated.
Neutral backend interface (retain/recall/remove/mentalModels) with a Hindsight
implementation: bank routing (per-project banks + main bank), idempotent
content-hash document_ids, circuit breaker, SSRF guard, 422 no-retry,
benign-404 read paths, mental-model seeding after first retain. All three
durable creation points tee fire-and-forget (historian promotion, ctx_memory
write, dreamer user-memory promotion); origin provenance on global-scope items
(origin-* tags + extraction-context entity link). Never throws, never blocks a
local write.
…er, not memory.enabled

Compartment embeddings are the ctx_search substrate, not memory-store data.
embedding-on/memory-off users now get search embeddings + project registration;
promotion stays gated by memory flags (issue cortexkit#44 preserved).
…safe m[0]/m[1] injection

Once-per-session 3-slice recall (project/profile/global; mental-model fast
path; optional first-prompt-enriched global query), deduped against local rows
(cosine + hash fallback), trimmed, sorted, frozen into session_meta (migration
v33 — renumbered from pre-v0.23 v31). Rendered as <external-memory> in m[0]
with marker-hash m[1] delta for late arrivals; NOT a mustMaterialize trigger;
excluded from the m[1] pressure-refold math; A-path await bounded and
first-render-only. Defer passes replay frozen bytes.
…e writes

Local memory truth signals propagate to the external store: archive (incl.
v0.23.0 batch archive — one batched DELETE per call) removes documents by
original-content document_id; update removes the stale doc and tees the
corrected fact; NEW dreamer-only verify action records local verification and
verbatim-upserts (refreshes engine recency, verifiedAt metadata); user-memory
dismiss/update propagate so the profile recall slice cannot resurrect dismissed
memories. merge and memory-migration deletes deliberately propagate NOTHING.
ctx_memory write gains scope="global": main-bank-only tee (no local row) with
origin-project provenance. Removes/upserts bypass retain_sources; the update
re-tee respects it.
Provider/endpoint/circuit-state/session-recall-state plus a best-effort
failed-retain count from the operations endpoint (benign-404, breaker-safe,
null on any failure). executeStatus goes async; both harness call paths await
it.
…for read tools

session.created parentID registrations (bounded LRU, cycle-safe walk);
ctx_search and ctx_expand resolve child sessions (sidekick, task subagents) to
the ROOT conversation so message-history search, the visible-memory filter,
and the injected-external-recall filter target the session that actually owns
that state. Mutating tools stay child-scoped. Sidekick registers synchronously
at spawn (event-race belt-and-braces).
Renders the StatusDetail.externalMemory payload that buildStatusDetail
already ships over RPC: provider, endpoint, circuit-breaker state (warning
color when not closed), this session's recall state, and the server-side
failed-retain count. TUI parity with the text-mode /ctx-status section.
isMagicContextEntry only matched the npm package name
(opencode-magic-context), so a dev entry like
file:///…/magic-context/packages/plugin was invisible to the detector
and ensureTuiPluginEntry re-appended @latest on every startup,
double-loading the plugin. Match magic-context as a whole path
segment too.
When opencode runs inside the user config directory itself
(~/.config/opencode as project root), project-config discovery
resolves to the same magic-context.jsonc as the user config. It was
loaded a second time as untrusted repo config, so the project
security strips fired against the user's own file: {file:} tokens
left literal and memory.external dropped — silently disabling the
external memory backend. Skip the project load when its resolved
path equals the user config path.
…on test

Previous filter admitted ALL stored vectors when queryModelId was falsy
or 'off', producing meaningless cosine scores across different embedding
spaces. Fix: only populate localVectors when queryModelId is known and
not 'off'; otherwise fall back to hash-only dedup.

Regression tests: hash-dedup baseline (integration), model-guard unit
tests for off/empty queryModelId and known queryModelId with two stored
model IDs.
…al parity

- index.ts: call initializeExternalMemory(config.memory?.external) at
  startup (mirrors OpenCode). Config flows through the shared
  MagicContextConfig schema which already includes memory.external.

- ctx-memory.ts: mirror OpenCode's external wiring:
  - write (project scope): tee to external backend after local insert
  - write (global scope): external-only write, no local row; gated on
    getExternalMemoryStatus()
  - update: W2 corrective propagation (remove old + tee new)
  - archive: removeFromExternalBackend for all archived memories
  - verify (dreamer-only): upsertToExternalBackend to refresh recency
  - scope param added to schema (project/global)
  - verify added to ALL_ACTIONS and DREAMER_ONLY_ACTIONS

All external calls are fire-and-forget (void), never blocking local
mutations. memoryVisibleToTool gate respected (external calls go after
local mutation, same as OpenCode).
…rch source

context-handler.ts:
- Fire startSessionRecall on first context pass per session (mirrors
  OpenCode transform.ts loadedSessions gate). Extracts first user
  prompt text for global_from_prompt enrichment.
- Call maybeAwaitExternalRecall before injectM0M1Pi when injection is
  enabled (A-path await: waits only when no cached m[0] exists, bounded
  by recall.timeout_ms). Mirrors OpenCode transform.ts timing.

inject-compartments-pi.ts:
- Add externalRecallHash to PiM0SnapshotMarkers (NOT a mustMaterializePi
  trigger — rides m[1] external delta only, invariant preserved).
- Add cached_m0_external_recall_hash to CachedPiM0M1Row + SQL SELECT.
- readFrozenM0InputsPi: read external recall snapshot in-transaction
  (TOCTOU-safe, same as projectDocsHash override in OpenCode).
- renderM0Pi: accept externalRecallOverride param; merge profile slice
  into user-profile; render renderExternalMemoryBlock after project-memory.
- renderUserProfileBlock: accept externalProfileLines param for profile
  slice merge (mirrors OpenCode renderUserProfileBlock signature).
- materializeM0Pi: pass frozen.externalRecall to renderM0Pi; persist
  externalRecallHash in persistCachedM0.
- renderM1PiWithMetadata: render external memory delta when live hash
  differs from m[0] baseline hash.
- markersFromCachedPiRow: populate externalRecallHash from DB row.
- readCurrentMarkersFromCompartments: read live externalRecallHash.

ctx-search.ts: already wired (explicitSearch + projectName + external
source in schema + formatResult). No changes needed.
Biome import ordering and formatting fixes for:
- pi-plugin/src/context-handler.ts (import order + indentation)
- pi-plugin/src/index.ts (import order)
- pi-plugin/src/inject-compartments-pi.ts (import order + SQL formatting)
- pi-plugin/src/tools/ctx-memory.ts (indentation)
- plugin/src/.../external-recall.test.ts (import order + arrow fn)
…, STRUCTURE, CONFIGURATION, README)

Adds the external-memory-backend feature to root docs:

- ARCHITECTURE.md: new 'External-memory flow' subsection under Data Flow
  (tee-on-write → 3-slice recall + dedup + persist-before-settle → m[1]
  <external-memory> render → W2 correctives → ctx_search external source);
  new 'External memory backend (Hindsight)' Key Abstraction; cached_m0_
  external_recall_hash marker added to the schema-migrations note
  (deliberately NOT a mustMaterialize trigger); project-config trust
  boundary updated to include memory.external in the user-only strip
  list; unified search entry updated with the external source.

- STRUCTURE.md: new external-memory files listed under src/features/
  plus a Key File Locations block (engine-agnostic provider,
  Hindsight impl, recall orchestrator, snapshot read, W2 hooks,
  project-security strip) and the Pi mirror path
  (@magic-context/core re-export).

- CONFIGURATION.md: full memory.external block (provider, endpoint,
  api_key, project_bank template, main_bank, retain_sources, tags,
  recall sub-block) and the memory.external.recall defaults; full
  failure-semantics + cross-harness note; user-config-only security
  callout; example updated.

- README.md: concise mention in the Recall section + a new callout
  for long-term memory; external added to the four-source ctx_search
  description and the agent-tools table.
Three-layer fix for the recurring 'failed to load plugin
@cortexkit/opencode-magic-context error="database is locked"' errors
that crash opencode startup when multiple processes cold-open context.db
concurrently.

Root cause: during a concurrent cold open (two opencode processes
booting, or a child session spawning while the parent is mid-checkpoint),
the WAL writer lock is held by a sibling. The first read in openDatabase()
— enforceSchemaFence() -> getPersistedSchemaVersion() — ran BEFORE
busy_timeout was installed (it was set later inside initializeDatabase()),
so SQLite returned SQLITE_BUSY immediately instead of waiting.

Layer 1 (storage-db.ts): Set PRAGMA busy_timeout=5000 immediately after
new Database(), before enforceSchemaFence()'s schema_migrations read. This
was the actual race window.

Layer 2 (dream-timer.ts): openTimerDatabaseOrNull() only handled
openDatabase()'s null return (schema-fence path) but NOT its throw path
(fatal open error). The throw propagated through
startDreamScheduleTimer() -> plugin() -> opencode's plugin loader, which
logged 'failed to load plugin' and disabled magic-context for the entire
session. Now wrapped in try/catch.

Layer 3 (index.ts): Defensive try/catch around the
startDreamScheduleTimer() await in the plugin entry, so even if a future
code path inside the timer setup throws, plugin() survives and the rest
of magic-context (hooks, tools, RPC) still initializes.
…USY retry

Replaces 9 hand-rolled BEGIN IMMEDIATE / COMMIT / finally-ROLLBACK blocks
with a single shared runWriteTransaction helper that adds a bounded
SQLITE_BUSY retry loop (4 attempts, 100ms backoff). This is a better
concurrency solution than the previous per-site pattern because:

1. Centralized retry policy: transient SQLITE_BUSY from cross-process WAL
   writer lock contention (sibling migration, dreamer run, Channel-2 bulk
   delivery) now waits-and-retries instead of propagating up and disabling
   Magic Context for the run. Previously each site either swallowed,
   propagated, or retried inconsistently.

2. Composition-safe: if called inside an existing db.transaction() or
   another runWriteTransaction, the body runs inline WITHOUT issuing a
   nested BEGIN (SQLite doesn't allow nested BEGIN; the outer transaction
   already holds the writer lock). Detected via the bun:sqlite
   inTransaction flag or the node:sqlite shim's isTransaction flag.

3. One correct transaction plumbing pattern instead of 9 slightly-different
   copies. Several sites were missing the ROLLBACK path or had subtle
   ordering bugs at the edges.

Sites converted (all sync write paths with hand-rolled BEGIN IMMEDIATE):
- dreamer/lease.ts (acquireLease, renewLease, releaseLease)
- git-commits/sweep-coordinator.ts (acquireGitSweepLease, renewGitSweepLease,
  markGitSweepSuccessAndRelease)
- message-index.ts (indexMessagesAfterOrdinal — cross-process FTS dedup)
- workspaces.ts (bumpEpochsForWorkspaceMembers, bumpEpochsForWorkspaceMemberSet)
- compartment-storage.ts (replaceAllCompartmentStateAndBumpDepth,
  promoteRecompStaging)
- key-files/project-key-files.ts (replaceAllKeyFiles)
- key-files/identify-key-files.ts (commitKeyFilesUnderLease)
- hooks/compartment-runner-recomp.ts (promoteRecompStagingWithM0Mutation)
- hooks/compartment-runner-incremental.ts (historian publish path)

inject-compartments.ts is intentionally NOT converted: its two BEGIN IMMEDIATE
blocks have complex early-return-with-rollback-and-fallback-read patterns
(Phase 3 materialization contention retry, soft-refresh cache replay) that
don't map cleanly onto the shared helper without significant restructuring.
The helper is still a net win across the 9 simpler sites.

Verification:
- bun run typecheck (tsc --noEmit + scripts) passes
- bun run lint (biome check) passes
- 12 new unit tests for runWriteTransaction/runWriteTransactionAsync pass
  (BEGIN/COMMIT, rollback on throw, transient BUSY retry, max-attempts
  give-up, nested-transaction composition, non-transient error passthrough)
- All existing tests in the converted files' test suites pass:
  dreamer/lease.test.ts (7), git-commits (19), compartment-storage-atomic
  (8), compartment-storage-v6, compartment-lease, compartment-runner-recomp-fk
  (4), compartment-runner-partial-recomp, key-files (14), workspaces,
  storage-db (11), message-index

Net: -199 lines / +40 lines across the 9 converted sites, +201 lines for
the shared helper + 156 lines for its tests.
…ifest

The external-memory-backend branch added the memory.external schema subtree
(provider/endpoint/banks/retain_sources/tags + recall sub-block) but did not
add a corresponding entry to the dashboard's config-field-coverage manifest.
The config-parity guard (config-parity.test.ts) enforces that every schema
leaf is either RENDERED by the ConfigEditor form or listed in
OMITTED_BY_DESIGN, so CI failed with 16 uncovered leaves.

memory.external is USER-config-only and operator-configured (no form widgets
exist for it), so the correct classification is OMITTED_BY_DESIGN with the
whole subtree covered by the 'memory.external' prefix.
The seedMemory helper opened context.db without setting PRAGMA busy_timeout,
so under the live opencode process's concurrent writer (historian checkpoint
or dreamer run) the seed INSERT could throw SQLITE_BUSY and fail the
'memory injection > injects <project-memory>' E2E test. This is the same
contention pattern fixed in the plugin itself; the test's external write
just wasn't opt-in to the retry window.

Set busy_timeout=5000 immediately after open, matching the pattern already
used by writeContextDb in cache-invariants.test.ts.
@iceteaSA
iceteaSA force-pushed the external-memory-backend branch from d2e0a6f to bf317be Compare June 23, 2026 12:35
iceteaSA pushed a commit that referenced this pull request Jun 25, 2026
…ase split-brain, child leak, Pi primer seed)

Round-2 blind re-audit, councils A (embedding) + B (dreamer/smart-notes):

Council A (both source-confirmed, both touch this round's code):
- A#1: v49 ran a GLOBAL PRAGMA foreign_key_check after the FIRST table rebuild,
  before the git/chunk orphan pre-cleans ran — a pre-existing orphan in a
  later table failed the migration closed. Scoped assertForeignKeyIntegrity to
  the just-rebuilt table. +regression test seeding a chunk-table orphan.
- A#2 (regression I introduced adding truncate to identity): the provider
  constructor's own this.modelId omitted truncate while canonical
  getEmbeddingProviderIdentity included it → writes/reads under divergent
  model_ids when truncate set. Mirrored truncate into the constructor. +parity
  test asserting provider.modelId === getEmbeddingProviderIdentity(config)
  across all identity-affecting fields.

Council B:
- cortexkit#5: buildHiddenAgentConfig dropped user permission/tools under lockPermissions
  but NOT prompt/system — a user dreamer.prompt could clobber the privacy-
  hardened retrospective prompt. Now strips prompt/system under lock too. +2 tests.
- #1: failed map/verify/classify children (memory-pool text) were kept on the
  !phaseFailed path. Delete on success AND failure; still honor keep_subagents
  (memory-pool text, not raw user transcripts — unlike retrospective). Removed
  now-dead phaseFailed in all three runners.
- cortexkit#3: lease heartbeat reclaimed an expired-but-free lease even after a >TTL gap,
  risking split-brain after a multi-minute stall (a sibling could have run in the
  gap). Declare lost when the gap exceeds a full TTL; short ≤TTL self-inflicted
  expiry still reclaims. +split-brain regression test.
- Pi primer seed (single-reporter, real): scheduled refresh-primers never got
  primerRawProviderFactory (only the manual /ctx-dream path did) → scheduled Pi
  ran closed-book, defeating the open-book redesign. Threaded the factory through
  ProjectRegistration → scheduled executor; Pi supplies its JSONL factory.
  OpenCode unaffected (buildPrimerSeed reads opencode.db directly).

Accepted/false-positive (documented A50/A51): #2 smart-note egress is the
deliberate v1 design (manifest advisory, SSRF + secret-denylist are the real
boundaries); content-edit-not-invalidating-check is correct (check keys on the
trigger, not the body).

Gate: plugin 2516/0, Pi 509/0, both lint-clean.

Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
iceteaSA pushed a commit that referenced this pull request Jun 25, 2026
…block (#1), sentinel-on-notify-failure (cortexkit#7)

#2 (BLOCK, was a dead counter): the Pi grounding gate incremented toolCallCount
on a "tool_result_end" event that DOES NOT EXIST in Pi's vocabulary (confirmed
against Pi source via the PI peer — the real tool event is tool_execution_end).
The counter never fired, so every Pi refresh-primers answer was silently rejected
as closed-book. Replaced it with countToolCalls(), which sums "toolCall" content
parts across assistant message_end turns (role-filtered to assistant, counted on
message_end not message_update), reading agent_end's authoritative array when
present else the accumulated stream — robust to the tool-event name entirely.

#1 (BLOCK, unanimous): the config-location migration lock used an Atomics.wait
busy-wait that could block plugin init up to ~5s under multi-instance contention
(and Atomics.wait can throw on the Node main thread). Migration is not
correctness-critical per run (loaders read-legacy-on-conflict when the shared
base is absent), so the lock now NEVER blocks init: a live competing holder
returns null immediately (skip this run, retry next init), and only a stale
(crashed-holder) lock is reclaimed in a single one-shot retry. No busy-wait, no
synchronous sleep. +live-holder no-block test.

cortexkit#7 (fast-follow): every command path ends in throwSentinel() (the 204 that
suppresses the log leak and stops the command reaching the LLM). A sendNotification
throw before it would skip the sentinel, forwarding the raw command to the model
plus logging an error. Wrapped sendNotification once at handler construction so a
delivery failure is logged and swallowed, never preempting the sentinel (covers
the handler and the standalone executeAugmentation/executeDreaming via the shared
deps ref).

Gate: plugin tsc clean, migrate-config-location 11/0, command-handler 20/0, Pi
subagent-runner 42/0, all lint clean.

Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
@iceteaSA
iceteaSA force-pushed the external-memory-backend branch 2 times, most recently from aa0da12 to 5a38766 Compare June 25, 2026 18:54
@iceteaSA
iceteaSA force-pushed the external-memory-backend branch 2 times, most recently from 9af9e9f to f2e290f Compare July 2, 2026 15:26
@iceteaSA
iceteaSA force-pushed the external-memory-backend branch 5 times, most recently from d2cedc6 to a6e4517 Compare July 12, 2026 06:58
@iceteaSA
iceteaSA force-pushed the external-memory-backend branch 3 times, most recently from 2c934af to e81dc75 Compare July 22, 2026 17:30
@iceteaSA
iceteaSA force-pushed the external-memory-backend branch from e81dc75 to b832ce5 Compare July 24, 2026 22:18
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.

1 participant