fix(e2e): set busy_timeout in memory-injection seedMemory + classify memory.external in dashboard coverage - #2
Open
codeo1io wants to merge 25 commits into
Conversation
…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.
…gration fence, search merge
…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.
…, not external-memory block
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
force-pushed
the
external-memory-backend
branch
from
June 23, 2026 12:35
d2e0a6f to
bf317be
Compare
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
force-pushed
the
external-memory-backend
branch
2 times, most recently
from
June 25, 2026 18:54
aa0da12 to
5a38766
Compare
iceteaSA
force-pushed
the
external-memory-backend
branch
2 times, most recently
from
July 2, 2026 15:26
9af9e9f to
f2e290f
Compare
iceteaSA
force-pushed
the
external-memory-backend
branch
5 times, most recently
from
July 12, 2026 06:58
d2cedc6 to
a6e4517
Compare
iceteaSA
force-pushed
the
external-memory-backend
branch
3 times, most recently
from
July 22, 2026 17:30
2c934af to
e81dc75
Compare
iceteaSA
force-pushed
the
external-memory-backend
branch
from
July 24, 2026 22:18
e81dc75 to
b832ce5
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Two CI failures on the external-memory-backend branch:
Check (dashboard):config-parity.test.tsreported 16 uncovered leaves undermemory.external.*— the schema gained the external-memory subtree but the dashboard'sconfig-field-coverage.tsmanifest was never updated.E2E (OpenCode, host behavior):memory injection > injects <project-memory>intermittently failed withSQLITE_BUSYatseedMemory (.../memory-injection.test.ts:79).Root causes
The external-memory-backend feature branch added the
memory.externalschema 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.seedMemoryopenscontext.dbwith a barenew Database(dbPath)and immediately runs anINSERT. The liveopencode serveprocess spawned by the harness holds the WAL writer lock during historian checkpoints / dreamer runs, and the test's external write had nobusy_timeout— so SQLite returnedSQLITE_BUSYimmediately instead of waiting. Same contention pattern the plugin itself was fixed for.Fixes
1. Classify
memory.externalas OMITTED_BY_DESIGN (3688e259)memory.externalis USER-config-only and operator-configured (endpoint/api_key, bank routing, recall tuning) — no form widgets exist for it. Added the whole subtree toOMITTED_BY_DESIGNwith thememory.externalprefix (covers all 16 leaves).2. Set
busy_timeoutinseedMemory(1abddf61)const db = new Database(dbPath); try { + db.query("PRAGMA busy_timeout = 5000").run(); const now = Date.now();Matches the pattern already used by
writeContextDbincache-invariants.test.tsand the plugin's ownopenDatabase().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-startbusy_timeoutinstall + centralizedrunWriteTransactionwith bounded SQLITE_BUSY retry) that the external-memory-backend branch needs to eliminate thedatabase is lockedplugin-load failures observed in production.