Skip to content

fix(storage): retry cold-open under SQLITE_BUSY instead of disabling plugin - #1

Closed
codeo1io wants to merge 24 commits into
external-memory-backendfrom
fix/cold-start-busy-locked
Closed

fix(storage): retry cold-open under SQLITE_BUSY instead of disabling plugin#1
codeo1io wants to merge 24 commits into
external-memory-backendfrom
fix/cold-start-busy-locked

Conversation

@codeo1io

Copy link
Copy Markdown
Owner

Root cause

Recurring failed to load plugin ... database is locked errors in opencode.log (25 occurrences 2026-06-14 through 2026-06-18). Each occurrence disabled Magic Context for the entire process lifetime.

When two plugin processes (OpenCode + Pi, or two OpenCode instances) cold-open the shared ~/.local/share/cortexkit/magic-context/context.db at the same time, the loser's first write-locking operation inside initializeDatabase()PRAGMA journal_mode=WAL or CREATE TABLE IF NOT EXISTS — throws SQLITE_BUSY immediately because:

  • busy_timeout was only set inside initializeDatabase, after enforceSchemaFence's reads
  • by the time initializeDatabase runs, the sibling already holds the WAL writer lock (mid-migration, dreamer lease, Channel-2 delivery)
  • the thrown error propagates through openDatabase()'s outer try/catch and disables Magic Context for the entire process lifetime

Live evidence from this host:

  • ~/.local/share/cortexkit/magic-context/context.db — 2.6 GB, held open by 4 opencode + 4 pi processes simultaneously
  • context.db-wal — 43 MB uncheckpointed (amplifies BUSY windows because checkpoint passes hold the write lock)
  • opencode.log — 25 database is locked plugin-load failures over 4 days

Fix

Three layered changes in packages/plugin/src/features/magic-context/storage-db.ts:

  1. Install PRAGMA busy_timeout on the raw Database handle BEFORE enforceSchemaFence / initializeDatabase run. The first PRAGMA on a fresh connection is now busy_timeout, so transient lock contention waits up to 5s instead of throwing SQLITE_BUSY immediately.

  2. Wrap the cold-open sequence in a bounded retry loop (3 attempts, 250ms backoff). A long-running sibling transaction can hold the writer lock past the 5s busy_timeout; retrying recovers in the common case instead of disabling Magic Context for the run. Only SQLITE_BUSY / SQLITE_LOCKED / database is locked errors are retried; other errors fail-closed per the existing contract.

  3. Enable PRAGMA wal_autocheckpoint=1000 in initializeDatabase so the WAL file doesn't grow unbounded under sustained concurrent writes. Unbounded WAL growth amplifies BUSY windows because checkpoint passes hold the write lock.

Tests

Adds three regression tests in storage-db.test.ts:

  • #when a sibling holds the writer lock #then openDatabase retries and succeeds after lock release
  • #when busy_timeout is set #then the connection reports the configured timeout
  • #when WAL mode is active #then wal_autocheckpoint is enabled

All 14 storage-db tests pass. typecheck and lint clean. Existing migration-race, storage, and message-index-async tests still pass (30/30 across the three files).

Files changed

  • packages/plugin/src/features/magic-context/storage-db.ts (+131 / -14)
  • packages/plugin/src/features/magic-context/storage-db.test.ts (+59)

Risk

Low. The retry loop only fires on the cold-open path (once per process boot), adds at most ~750ms to startup in the worst recoverable case, and only retries SQLITE_BUSY-shaped errors. All other failure modes (schema fence, corrupt file, unwritable path) still fail-closed per the existing contract. The wal_autocheckpoint=1000 value is SQLite's default, made explicit for discoverability.

Tehan and others added 24 commits June 16, 2026 17:48
…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.
Project + main bank recall in parallel under a 5s bound, rank-scored below
curated local memories, deduped against the session's injected
<external-memory> snapshot. The auto-search hot path never reaches the
network. Shared tool description documents the source for both harnesses.
…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.
…plugin

Root cause of recurring "failed to load plugin ... database is locked"
errors in opencode.log (25 occurrences 2026-06-14..2026-06-18):

When two plugin processes (OpenCode + Pi, or two OpenCode instances)
cold-open the shared context.db at the same time, the loser's first
write-locking operation inside initializeDatabase() — PRAGMA
journal_mode=WAL or CREATE TABLE IF NOT EXISTS — throws SQLITE_BUSY
because busy_timeout was only set INSIDE initializeDatabase, AFTER the
sibling already held the WAL writer lock. The thrown error propagates
through openDatabase()'s outer try/catch and disables Magic Context for
the entire process lifetime on every cold start under multi-process
contention.

Three layered fixes:

1. Install PRAGMA busy_timeout on the raw Database handle BEFORE
   enforceSchemaFence / initializeDatabase run. The first PRAGMA on a
   fresh connection is now busy_timeout, so transient lock contention
   waits instead of throwing immediately.

2. Wrap the cold-open sequence (new Database + enforceSchemaFence +
   initializeDatabase + runMigrations) in a bounded retry loop
   (3 attempts, 250ms backoff). A long-running sibling transaction
   (large migration, dreamer run, Channel-2 bulk delivery) can hold the
   writer lock past the 5s busy_timeout; retrying recovers in the common
   case instead of disabling Magic Context for the run.

3. Enable PRAGMA wal_autocheckpoint=1000 in initializeDatabase so the
   WAL file doesn't grow unbounded under sustained concurrent writes
   (observed: 40MB+ WAL on long-running multi-process installs), which
   amplifies BUSY windows because checkpoint passes hold the write lock.

Adds three regression tests covering the retry path, busy_timeout value,
and wal_autocheckpoint. All 14 storage-db tests pass; typecheck and
lint clean.
@codeo1io
codeo1io force-pushed the external-memory-backend branch from 62e49f9 to db3213e Compare June 19, 2026 17:41
@codeo1io codeo1io closed this Jun 20, 2026
@codeo1io
codeo1io deleted the fix/cold-start-busy-locked branch June 20, 2026 03:32
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