fix(opencode): capture every assistant turn and stop losing concurrent extraction records#252
Merged
Merged
Conversation
…ss it exposes Phase 4 was marked complete, but its e2e synthesizes the session.idle payload instead of observing OpenCode emit it, and the frequency the design hedged on was never measured. Measured against a live OpenCode 1.18.5 server: session.idle fires once per assistant turn (3 turns -> 3 events). Because the adapter's tracker suppresses a session permanently after the first idle, every turn after the first is silently discarded.
session.idle fires once per assistant turn, not once per session (measured against OpenCode 1.18.5: three turns produced three events, one per turn). The adapter gated on a permanent tracker, so the first idle wrote a drop containing only turn 1 and every later idle returned early. Reproduced end to end with a real three-turn session and the real plugin loaded by a real OpenCode server: the drop froze at 154 bytes and two of three markers never reached it. The tracker now records a content digest per session instead of 'this session is done', so a grown session overwrites its earlier, shorter drop while an unchanged one still costs no write. RecallBatchExtract already re-extracts a drop that grows, so the fuller transcript reaches memory without new machinery. Legacy array trackers load with an empty digest and converge after one re-export. Replaces two tracker tests that re-implemented the logic inline — and so passed with the bug present — with tests that drive the real plugin factory. All four new behavioural cases fail on the pre-fix adapter.
OpenCode invokes EVERY export of a top-level plugins/*.ts as a plugin factory. opencode/RecallExtract.ts exported three test helpers beside its factory, so on each launch OpenCode called exportSession() with its plugin context as the shell, the tagged-template call hit a non-function, and the server logged 'failed to load plugin ... Object is not a function'. Observed against a real OpenCode 1.18.5 server; reproduced with the plugin installed by the real installer, and confirmed by exporting only the factory, which loads clean. The existing e2e could not catch it: it imports the adapter directly and supplies its own $, fabricating the very shape OpenCode breaks on. Helpers move to opencode/lib/session-export.ts. OpenCode globs only top-level plugins/*.ts, so a nested module is importable without being mistaken for a plugin — verified empirically before choosing this layout. Install and uninstall carry the nested helper, and uninstall only removes plugins/lib when Recall emptied it. A new test asserts each plugin entry exports exactly one function, so a stray helper export cannot silently reintroduce this.
…erver The existing e2e imports the adapter and synthesizes the session.idle payload, so it proves Recall's handler accepts that shape — not that OpenCode emits it, and not that OpenCode ever loads the plugin. This harness closes both gaps: - Discovery: installs via the real recall_install_opencode_platform, then observes OpenCode load the plugin from that path and asserts zero plugin load errors. - Frequency: counts session.idle from OpenCode's own /event stream across three turns of ONE session in ONE long-lived server process, so the number cannot be an artifact of process teardown. Measured 1.00 per assistant turn, and the script fails loudly if that ever changes. - Completeness: asserts the drop carries every turn, which is the end-to-end regression for the discarded-turns fix. The model is a local OpenAI-compatible stub — the subject under test is OpenCode's session lifecycle, so a hosted model would only add flakiness. A PATH shim supplies `opencode` because the plugin shells out to it, exactly as in a real install. The pinned version now lives once in scripts/lib/opencode-runtime.ts; the existing e2e resolves the CLI through it instead of repeating the pin.
Concurrent Claude and OpenCode extraction against the shared WAL database lost records to 'database is locked' even though openDb sets busy_timeout=5000 — the isolated OpenCode e2e failed its own concurrency assertion this way. Cause: the batch writers probe for duplicates (skipDuplicates) before inserting, and SQLite fails a read-to-write upgrade inside a DEFERRED transaction with SQLITE_BUSY *immediately*, never consulting busy_timeout. Measured on this machine while a peer held the write lock: the deferred read-then-write path failed in 1ms, while taking the write lock up front waited the full timeout. So the timeout the comment relies on was never reachable on the retry-safe path extraction actually uses. Taking the transactions IMMEDIATE makes the peer wait honoured. Scoped to the four batch writers: the other hook transactions (consolidate-core, RecallPreCompact) already begin with a write, so busy_timeout applies to them and they are left alone. The new tests hold the write lock from a separate process — the writers are synchronous, so an in-process timer could never release it — and assert the write waits and lands. They fail with 'database is locked' on the deferred version.
./install.sh restore is documented in docs/installation.md but recall_do_restore had no coverage, and neither did the collision backup that is the other half of rollback. Covers restoring the latest snapshot, restoring a specific timestamp, the pre-restore snapshot that makes a restore itself reversible, and non-zero exits for an unknown timestamp and for no backups at all. Also pins the automation contract: restore gates on a default-No confirm, and _confirm returns the default when stdin is not a TTY, so restore is a deliberate no-op in CI or scripts. That is asserted directly — it declines, mutates nothing, and writes no pre-restore snapshot — rather than left as a surprise. The restoring path is exercised by overriding _confirm in the sourced shell, so the safety default is tested rather than weakened. The collision tests prove a hand-edited user file at a Recall-managed path survives install as recoverable bytes, and that an identical file is replaced without a needless backup.
AGENTS.md mandates workers run in an isolated worktree, so a worktree is the default environment for automated work. Ten lifecycle/config tests once failed there while passing in a normal checkout (#243), which teaches agents to read a red suite as normal — the exact signal loss a suite exists to prevent. This leg creates a real worktree, asserts it starts without node_modules (#165's condition, so the workaround stays visible rather than accidental), installs there, and runs lint plus the full suite. Verified locally in a fresh worktree: 1298 pass, 0 fail. Closes the last open acceptance item on #243; the other two were already met — recall_configure_opencode_mcp and recall_configure_pi_mcp both exit 1 and leave a malformed config byte-identical.
The design doc asserted OpenCode 1.18.4 'emits session.idle through the event hook' and cited the pipeline e2e as pinning it. That e2e imports the adapter and supplies the payload itself, so it proved Recall ACCEPTS that shape — not that OpenCode sends it, and not that OpenCode loads the plugin at all. Phase 4 Evidence now says which of the two scripts proves what, and names compaction context injection as still unverified at runtime. Records the measured session.idle frequency (once per assistant turn, OpenCode 1.18.5) in place of the hedge about adding debouncing 'if future evidence shows repeated idle events' — the evidence exists now, and it points the other way. Also documents the single-export plugin contract and the PATH requirement. README moves OpenCode from Alpha/In progress to Beta with the scope stated: capture is runtime-verified, compaction injection is not. Fixes stale details in the same doc (7 tools -> 9, the tracker's digest semantics, missing files).
opencode/ became a durable boundary with rules a future session cannot infer from the code, so it gets a child AGENTS.md: plugin entry points export exactly one factory, helpers nest under lib/ and must be installed and uninstalled with the plugins, and session.idle is per-turn so dedup is by content digest rather than 'session seen'. hooks/ gains the transaction rule the concurrency fix established: a write transaction that reads first must be IMMEDIATE, because SQLite fails the upgrade with SQLITE_BUSY instantly and never consults busy_timeout. tests/ records why OpenCode has two e2e scripts and which claims belong in each, so a host-behaviour claim does not get added to the script that structurally cannot support it. Root index gains opencode/, and scripts/ is named in the owned-at-root list it was missing from.
…s/lib The runtime e2e was not wired into CI, so nothing on a PR actually enforced the measured once-per-turn session.idle frequency that CHANGELOG and the design doc both claim is asserted. It runs on the macOS leg now, making that claim true. Verified the gate is not vacuous on the path CI takes: with OPENCODE_BIN unset (so the run goes through bunx, adding a process layer between the server and the captured log) a deliberately reintroduced second plugin export still produced 'OpenCode reported 1 plugin load error(s)' and failed the run. The pipeline e2e now also proves uninstall removes Recall's plugin and its nested helper while leaving a user-owned file in plugins/lib untouched — the directory Recall creates is only removed when Recall emptied it.
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.
Intent
Deliver OpenCode integration Phase 4 (Testing and Polish) for Recall: genuinely exercise its four acceptance items against a real OpenCode runtime and record the results, including the session.idle frequency as a measured number.
Key context a reviewer reading only the diff would not know:
Phase 4 was ALREADY marked complete by merged PR test(opencode): complete Phase 4 testing and polish #248, but that was not a real proof. Its e2e (scripts/e2e-opencode.ts) imports the adapter directly and SYNTHESIZES the session.idle payload and its own $ shell. So it proved Recall ACCEPTS that event shape - never that OpenCode emits it, and never that OpenCode loads the plugin at all. Closing that blind spot is the point of this branch, which is why a second e2e script exists rather than extending the first. The two are deliberately kept separate: pipeline vs live-server contract.
The measurement: session.idle fires 1.00 times per assistant turn (3 turns -> 3 events), measured from OpenCode's own /event SSE stream inside ONE long-lived server process so it cannot be a process-teardown artifact, against OpenCode 1.18.5. The design doc previously hedged toward adding DEBOUNCING; the measured evidence points the opposite way, so the doc hedge was replaced.
That measurement exposed three real, pre-existing defects, each reproduced first and each with a regression test I verified FAILS on the pre-fix code:
Deliberate scope decisions: uninstall does NOT auto-restore collision backups and I did not add that - the documented rollback path is ./install.sh restore, so I tested that instead of inventing product behaviour. Restore gates on a default-No confirm that returns the default when stdin is not a TTY, making it a no-op in CI; rather than weakening that safety default I asserted it directly and exercised the restoring path by overriding _confirm in the sourced shell. Compaction context injection (RecallPreCompact) is deliberately left runtime-unverified and README/docs say so rather than implying otherwise, which is why the README row moves to Beta and not Stable.
10 lifecycle tests fail in any git worktree — the environment AGENTS.md mandates for workers has a permanently red baseline #243 is closed by this branch: all three of its acceptance items are met (fresh-worktree suite green, both config helpers exit non-zero leaving a malformed config byte-identical, and CI now runs the suite inside a worktree). chore: fresh /ce-worktree worktrees miss node_modules → ~10 MCP-config tests fail spuriously #165 is deliberately NOT closed - the worktree suite is green only after bun install inside it, which is chore: fresh /ce-worktree worktrees miss node_modules → ~10 MCP-config tests fail spuriously #165's gap, so the CI job asserts the empty-node_modules precondition rather than hiding it.
The OpenCode version pin moved 1.18.4 -> 1.18.5 and now lives once in scripts/lib/opencode-runtime.ts (DRY), which is why the older e2e no longer defines its own. Two pre-existing tests that re-implemented tracker logic inline - and therefore passed with the data-loss bug present - were replaced with tests that drive the real plugin factory.
Verification state: bun test 1298 pass / 0 fail, bun run lint clean, bash -n clean on all lifecycle scripts, both OpenCode e2e scripts pass against a live OpenCode 1.18.5, and the runtime gate was confirmed non-vacuous on the bunx path CI uses by deliberately re-introducing the defect.
What Changed
opencode/RecallExtract.ts) —session.idlefires once per assistant turn, not once per session, so the permanent "session seen" dedup tracker froze each drop on turn 1 and discarded the rest of the conversation. The tracker now keys on a SHA-256 digest of the rendered markdown (legacy array-format trackers load with an empty digest and converge on the next idle), and both the drop and the tracker are written through a temp file plusrenameSyncso the cron-drivenRecallBatchExtractin a separate process can't read a torn file. OpenCode also calls every export of a top-levelplugins/*.tsas a plugin factory, which made it logfailed to load plugin ... Object is not a functionon every launch — the shared helpers moved toopencode/lib/session-export.ts, withlib/install-lib.shanduninstall.shnow installing and removing that nested file from one sharedRECALL_OPENCODE_PLUGIN_HELPERSlist. The shipped agent definition's/dumprule now requires explicitmessages+source: "opencode"(title-only resolved to Claude Code's transcript on a dual-host machine), and its tool table is replaced by the prefix rule plus a pointer todocs/mcp-tools.md.hooks/lib/sqlite-writers.ts, all hosts) — the four batch writers now commit throughinsertMany.immediate(...)instead of the default DEFERRED. TheskipDuplicatesprobe makes those transactions read-then-write, and SQLite fails that lock upgrade withSQLITE_BUSYimmediately without ever consultingbusy_timeout, so a peer host extracting into the same WAL database lost records; taking the write lock up front is what makes the timeout apply.consolidate-coreandRecallPreCompactopen with a write and were left DEFERRED.scripts/e2e-opencode-runtime.tsplus a sharedscripts/lib/opencode-runtime.ts(the OpenCode version pin now lives there only) that drives a realopencode serve, subscribes to its/eventSSE stream, and asserts the plugin loads with zero errors and the measured once-per-turnsession.idlefrequency; the existingscripts/e2e-opencode.tsstays as the pipeline-level harness. CI gains a job running that runtime gate and aworktreejob that runs lint + the suite inside a freshgit worktree(asserting the empty-node_modulesprecondition rather than hiding it). Newtests/hooks/sqlite-writers-concurrency.test.tsandtests/install/restore.test.ts; two OpenCode tests that re-implemented tracker logic inline now drive the real plugin factory. README anddocs/OPENCODE_INTEGRATION.mdmove OpenCode from Alpha to Beta, record the measured event frequency in place of the old debouncing hedge, and state that compaction injection remains runtime-unverified.Risk Assessment
✅ Low: All nine prior findings are resolved with surgical, verifiable changes and no warnings remain; the shared SQLite write-path surface is unchanged but is now guarded by a cross-process regression test that provably fails on the pre-fix DEFERRED code, leaving only one info-level hardening nit.
Testing
Ran both OpenCode e2e scripts against a live OpenCode 1.18.5 server (runtime contract and pipeline, both EXIT=0), capturing the branch's headline measurement - session.idle fires exactly 1.00 times per assistant turn across three turns of one long-lived process - plus zero plugin load errors and a 560-byte drop containing all three turn markers, which is the direct product-level contrast to the 154-byte 1-of-3 drop the fixes address. Ran the full suite twice: 1298 pass / 0 fail under
--timeout 30000(matching the handoff's claim exactly) and again under CI's barebun test, both inside a confirmed detached-HEAD git worktree, which satisfies #243's fresh-worktree acceptance item. Verified the newly addedpistub does not buy speed with coverage: it is behaviorally transparent becauseremove_pidiscards the call's exit code and output, and a mutation that disables Pi memory-section removal still turns the stubbed test red, with uninstall.sh restored andbash -nclean afterward. The stub's AFTER timings (0.781s / 0.681s under load) match the handoff, though its ~10s BEFORE figure did not reproduce on this host - realpi removecosts 0.50s solo and the pre-stub file passes under the default 5s timeout - so only that magnitude claim in the handoff prose is unconfirmed, not the fix. Captured reviewer-visible screenshots of the rendered README showing the intro line and the compatibility table now agreeing on Beta with matching caveats. Left #251's cold-network install flake untouched as directed; the worktree is clean anddist/was removed.Evidence: Live OpenCode 1.18.5 runtime contract e2e - measured session.idle frequency
opencode.version=1.18.5 opencode.installed_plugin=.../xdg-config/opencode/plugins/RecallExtract.ts opencode.installed_helper=.../xdg-config/opencode/plugins/lib/session-export.ts opencode.plugin_loaded_by_host=true opencode.plugin_load_errors=0 opencode.session_idle_total=3 opencode.session_idle_turns=3 opencode.session_idle_per_turn=1.00 opencode.session_idle_deltas=[{"turn":1,"idleDelta":1},{"turn":2,"idleDelta":1},{"turn":3,"idleDelta":1}] opencode.drop_contains_all_turns=true (3 turns, 560 bytes) opencode.tracker_records_content_digest=true opencode.export_contains_all_turns=true isolation.production_db_opened=false opencode.runtime_contract=verified against 1.18.5 EXIT=0/var/folders/88/3h9cyc4979d2l6p7xkn79yqr0000gn/T/no-mistakes-evidence/01KYCZPQNZX62ATN5XVEWZYTQA/readme-intro-line21.png) - Evidence: README compatibility table rendered - OpenCode row agrees with the intro (local file:/var/folders/88/3h9cyc4979d2l6p7xkn79yqr0000gn/T/no-mistakes-evidence/01KYCZPQNZX62ATN5XVEWZYTQA/readme-compat-table.png)Evidence: Non-vacuity proof: pi-stubbed test still catches a broken Pi removal path
# MUTANT: uninstall.sh remove_memory_section "$agents_md" pi -> disabled 579: : # MUTANT error: expect(received).not.toContain(expected) Expected to not contain: "## MEMORY" Received: "# Existing Pi instructions\n\n## MEMORY\n\nYou have persistent memory via Recall..." at tests/install/uninstall.test.ts:456:25 (fail) uninstall.sh > Pi AGENTS.md: complete legacy Recall section is removed [1399.13ms] 1 pass 1 fail Ran 2 tests across 1 file. [2.19s]Evidence: Pipeline e2e at HEAD against live OpenCode 1.18.5
opencode.uninstall_preserves_user_plugin_lib=verified opencode.version=1.18.5 opencode.search_markers=OPENCODE_PHASE4_SEARCHABLE_MARKER,OPENCODE_PHASE4_SEARCHABLE_MARKER_RETRY,OPENCODEPHASE4CONCURRENTA,OPENCODEPHASE4CONCURRENTB opencode.export_failure_retry=verified opencode.concurrent_wal_writers=verified opencode.installer_jsonc_rollback=verified isolation.production_db_opened=false EXIT=0Evidence: pi stub timing A/B on a host that has Pi installed
host pi: /Users/ed/.bun/bin/pi BEFORE (pre-stub file @6f59fdd), --timeout 60000: 13 pass / 0 fail [8.44s] BEFORE (pre-stub file @6f59fdd), bun DEFAULT 5s: 13 pass / 0 fail [8.45s] <- no individual test exceeded 5s here AFTER (HEAD, pi stubbed), bun DEFAULT 5s: 13 pass / 0 fail [7.30s] direct cost of the call remove_pi makes (isolated PI_CODING_AGENT_DIR): real 0.50 / real 0.50 / real 0.49 junit per-test, under full-suite load (matches handoff's "stable 0.7-1.0s each"): 0.781s Pi AGENTS.md: complete legacy Recall section is removed 0.681s Pi AGENTS.md: customized legacy-looking section is preservedEvidence: Full suite under CI's exact command (bare bun test, 5s default)
$ bun test # .github/workflows/ci.yml:38 1298 pass 0 fail 47918 expect() calls Ran 1298 tests across 107 files. [44.10s] EXIT=0 (also: bun test --timeout 30000 -> 1298 pass / 0 fail [47.02s], run inside git worktree .../01KYCZPQNZX62ATN5XVEWZYTQA d5a53d5 (detached HEAD))Evidence: Round 2 validation summary (all checks, with reasoning)
Evidence: Rendered README used for the screenshots
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
opencode/RecallExtract.ts:95- The markdown drop is written with a barewriteFileSync(truncate-then-write), and the fix changes how often that happens: from once per session to once per assistant turn.RecallBatchExtractruns in a separate process on a 30-minute cron and reads the same path, so the torn-read window now recurs for the whole life of a session instead of existing once. Write to${sessionId}.md.tmpandrenameSynconto the final path — the temp name does not end in.md, sofindMarkdownSessions(hooks/RecallBatchExtract.ts:227) skips it.tests/hooks/sqlite-writers-concurrency.test.ts:110-writeDecisionsBatchis fully synchronous, soPromise.all([Promise.resolve().then(fn), Promise.resolve().then(fn)])runs the two calls in sequential microtasks with zero overlap — the first opens, commits, and closes its connection before the second begins. The test therefore passes identically on the pre-fix DEFERRED code and adds no protection for theimmediatechange, despite the name and the "two hosts writing the same tables" comment. Either drive the second writer from a subprocess (aspeerHoldingWriteLockalready does) or rename it to what it actually asserts: that two sequential writes keep their ownprojectattribution.hooks/lib/sqlite-writers.ts:434- The rationale comment aboveinsertMany.immediate(items)inwriteExtractionErrorssays these batches "read (the skipDuplicates probe) before they insert" — but this function takes noWriteOptionsand has no probe; it is a singleINSERT ... ON CONFLICT DO UPDATE, so the transaction already begins with a write andbusy_timeoutapplied before the change.immediateis harmless here, but the stated justification does not describe the code, which will mislead the next person auditing which transactions need it. The same 5-line block is also pasted verbatim at all four sites while the canonical statement of the rule now lives inhooks/AGENTS.md:28; collapse it to one comment nearopenDbplus one-line pointers, with the upsert site described accurately.hooks/AGENTS.md:28- The new invariant ("A write transaction that reads first must be IMMEDIATE") reads as covering every read-then-write path inlib/sqlite-writers.ts, and lists only "a transaction whose first statement is a write" as the exemption.writeLoaEntryFromExtraction(sqlite-writers.ts:361) probes with a SELECT and then inserts, but is in neither category: it uses no explicit transaction, so the SELECT autocommits and releases its read lock before the INSERT takes the write lock withbusy_timeouthonoured. Naming that third case in the rule prevents a future reader from either "fixing" a fifth call site that needs nothing, or assuming the invariant guarantees atomicity it does not (two concurrent replays of the same extraction can still both pass the LoA probe and insert).tests/hooks/sqlite-writers-concurrency.test.ts:83-expect(waited).toBeGreaterThan(200)against a 600 ms peer hold fails if the test process is descheduled for ~400 ms between readingLOCKEDand starting the write — exactly what a loaded macOS runner executing the full suite can produce, and exactly what an isolated run cannot. That matters because the handoff (.agents/atlas/handoffs/2026-07-25-opencode-phase4-runtime-verification.md:104) clears the unreproduced full-suite failure from this branch on the strength of 15 targeted runs of this file; targeted runs are the wrong instrument for a wall-clock floor. Have the peer print a timestamp when it commits and assert the writer's completion is after that, instead of against a fixed floor.scripts/e2e-opencode-runtime.ts:57-STUB_PORT/SERVER_PORTare hardcoded to 47611/47612. If either is occupied,Bun.servethrowsEADDRINUSEbefore any diagnostic output, orwaitForServerburns 60 s and then reports "never became healthy" with a log that will not explain why. Teardown alsoSIGKILLs thebunxwrapper, which is not a reliable way to reap the OpenCode server it launched, so an interrupted local run can leave the port held and make every rerun fail opaquely. Bind the stub on port 0 and read back the assigned port, and pass a free port toopencode serve(or spawn detached and kill the process group).docs/OPENCODE_INTEGRATION.md:162- "RecallBatchExtractalready re-extracts a drop that grows, so the fuller transcript reaches memory without new machinery" is true only past a threshold:findCandidatesrequiresGROWTH_THRESHOLD = 0.5(hooks/RecallBatchExtract.ts:64), so once a drop is extracted at size S, later turns must add >50% before it is re-extracted. The drop file now holds every turn (the fix works), but the last portion of a long session may never reach the database. Worth one clause of precision on a claim this branch newly makes, since the same threshold governs Claude Code JSONL and is presumably intentional.CHANGELOG.md:18- The existing Unreleased entry still says the e2e harness "provisions OpenCode 1.18.4", while the pin moved to 1.18.5 in this branch (scripts/lib/opencode-runtime.ts:19). It is the only remaining 1.18.4 reference in the repo, and it ships in the next release notes alongside the new entry that says 1.18.5.uninstall.sh:470-for helper in session-export.tsis hardcoded here and again atlib/install-lib.sh:2304, so adding a second helper requires editing two files or the file is orphaned in every existing install — the drift the repo's DRY rule and theRECALL_SKILL_NAMESprecedent exist to prevent, and whichopencode/AGENTS.md:29currently documents as a manual obligation. A singleRECALL_OPENCODE_PLUGIN_HELPERSarray inlib/install-lib.shconsumed by both loops removes the obligation (the adjacent plugin-name list has the same shape and could follow).🔧 Fix: atomic drops, real concurrency test, ephemeral e2e ports
1 info still open:
opencode/RecallExtract.ts:75-writeAtomicderives the temp name from the target alone (${path}.tmp), so it is a fixed, process-independent path. For the drop file that is unreachable in practice — the path is keyed by session ID,inFlightserializes same-session exports, and a session lives in one server process. For the tracker it is a genuinely shared path:.extracted.json.tmpis the same for every OpenCode instance, two instances across two projects is a normal condition, and both rewrite it on every turn, so their writes can interleave into that temp file before either rename publishes the mixed bytes. Not a regression — the pre-fix code interleaved on the final file instead, and the failure self-heals through the existing "tracker unreadable; starting fresh" path at the cost of one redundant re-export. Interpolatingprocess.pidinto the temp name removes the window entirely and keeps the suffix outsidefindMarkdownSessions's*.mdfilter.🔧 **Test** - 2 issues found → auto-fixed ✅
tests/install/npm-pack.test.ts- Pre-existing flake, not from this branch:tests/install/npm-pack.test.tsandtests/install/update.test.tstrip bun's 5s default per-test timeout when the npm registry / GitHub release lookup are cold. Across three full-suite runs I saw 4 fail → 2 fail → 0 fail. Both pass in isolation with headroom (bun test --timeout 60000 ./tests/install/npm-pack.test.ts→ 9 pass;update.test.ts→ 17 pass once warm), and standalone the underlying commands take 0.4-0.7s. Neither file, norupdate.sh, nor the packaging path is touched by this branch, so this is out of scope here — but the suite has a load-dependent red baseline that an explicit per-test timeout would remove.README.md:21- README now contradicts itself on OpenCode's maturity. The compatibility table at line 324 moved to "⚠ Beta", but the intro paragraph at line 21 still reads "Alpha for OpenCode (MCP works; lifecycle extensions are early)". Before this diff both said Alpha, so the inconsistency is introduced here — a one-line copy fix on the front page.bun test— full suite, three runs (final: 1298 pass / 0 fail)bun run test:e2e:opencode:runtime— live OpenCode 1.18.5 server, run twice; producedsession_idle_per_turn=1.00, deltas [1,1,1],plugin_load_errors=0RECALL_E2E_KEEP=1 bun run test:e2e:opencode:runtime— kept the temp root to capture the real drop markdown and.extracted.jsondigest trackerbun run test:e2e:opencode— pipeline e2e, includinguninstall_preserves_user_plugin_lib=verifiedandinstaller_jsonc_rollback=verifiedManual full-chain bridge:bun run <evidence>/bridge-live-opencode-to-recall-search.ts— liveopencode servesession → installed plugin → drop →RecallBatchExtract --all→recall search LIVEBRIDGEMARKER_BRAVO --table loa→Found 1 result(s); production DB asserted byte-identical (size/ino unchanged)Non-vacuity: revertedinsertMany.immediate(items)→insertMany(items)at all 4 sites, thenbun test ./tests/hooks/sqlite-writers-concurrency.test.ts→ 0 pass / 3 fail with SQLITE_BUSY at 64-218msNon-vacuity: added a top-levelexport { exportSession, renderSessionExport }toopencode/RecallExtract.ts, thenbun run test:e2e:opencode:runtime→ exit 1, quoting OpenCode'sfailed to load plugin … "Object is not a function"Non-vacuity: restoredtracker.has(sessionId) ||to the idle early-return, thenbun test ./tests/opencode-integration.test.ts→ 3 fail, andbun run test:e2e:opencode:runtime→the drop lost RUNTIMETURNBRAVO, RUNTIMETURNCHARLIE … drop bytes=250bun test ./tests/opencode-integration.test.ts ./tests/pi-integration.test.ts --reporter=junitinside a linked git worktree — extracted the 10 tests #243 named, incl. both helpers'V-1/V-4non-zero-exit + byte-identical assertions, 0 failuresbun test ./tests/install/restore.test.ts— documented./install.sh restorerollback path, incl.a non-interactive restore declines and changes nothingbun test --timeout 60000 ./tests/install/npm-pack.test.ts→ 9 pass — discriminates the cold-network flake from a real failuregrep -c 'insertMany.immediate(' hooks/lib/sqlite-writers.ts→ exactly 4 sites, no bareinsertMany(left; inspectedhooks/lib/consolidate-core.ts:192andhooks/RecallPreCompact.ts:370to confirm both open with a write (so leaving them DEFERRED is correct)npm pack --dry-run --json→ confirmsopencode/lib/session-export.tsships in the published packagegit status --porcelainafter every revert experiment → clean;git worktree list→ no stray worktrees🔧 Fix: stub host pi in uninstall tests; align README OpenCode maturity
✅ Re-checked - no issues remain.
bun run test:e2e:opencode:runtime- live OpenCode 1.18.5 server, EXIT=0, session_idle_per_turn=1.00, plugin_load_errors=0, drop_contains_all_turns=true (560 bytes)bun run test:e2e:opencode- pipeline e2e at HEAD, EXIT=0 (export-failure retry, concurrent WAL writers, JSONC rollback all verified)bun test --timeout 30000- 1298 pass / 0 fail / 40335 expect() across 107 files, run inside a confirmed detached-HEAD git worktree (#243 acceptance item 1)bun test- CI's exact command (bare, bun's 5s default), 1298 pass / 0 fail, no failuresMutation proof of the pi stub: disabledremove_memory_section "$agents_md" piin uninstall.sh, ranbun test ./tests/install/uninstall.test.ts -t "Pi AGENTS.md"-> (fail) withexpect(received).not.toContain("## MEMORY"); uninstall.sh restored,bash -n uninstall.shcleanReadremove_pi(uninstall.sh:513-525) to confirmpi removeexit code and output are both discarded, and grepped tests for assertions on its log lines (none)Timing A/B:git checkout 6f59fdd -- tests/install/uninstall.test.tsthenbun test --timeout 60000and barebun test, vs HEAD; plus 3x/usr/bin/time -p env PI_CODING_AGENT_DIR=... pi remove ... --no-approve(0.50s each)Per-test timings extracted from--reporter=junit: all 13 uninstall.test.ts tests between 0.431s and 0.862s under full-suite load; slowest test in whole suite 4.167sRendered README.md to HTML and screenshotted both maturity statements via chrome-devtools-axi (intro blockquote at line 21 and the Roadmap compatibility table row)rg -n 'Alpha for|lifecycle extensions are early'across the tree - no matches remaingit status --shortclean anddist/build output removed after both e2e runs🔧 **Document** - 1 issue found → auto-fixed (2) ✅
docs/OPENCODE_INTEGRATION.md:360- This change corrected the doc's architecture diagram from "7 tools" to "9 tools", which makes the same file's "Tool Name Mapping" table (7 rows) self-contradictory, andFOR_OPENCODE.mdenumerates the same 7.docs/mcp-tools.mdis the owner and documents 9:memory_dumpanddecision_updateare missing from both OpenCode-facing lists. This is pre-existing drift, not caused by this change, and the fix is a consolidation rather than an edit: the mapping table adds nothing over the one prefix rule (memory_search->recall-memory_memory_search) plus a pointer to the owner, while FOR_OPENCODE.md genuinely needs two new tool sections. Adding rows to a duplicate list would be synchronization; deleting the table is restructuring beyond this change's scope. No tracking issue exists (searched open and closed viagh issue list). Proposed follow-up: reduce the mapping table to the prefix rule plus a link to docs/mcp-tools.md, and add the two missing tools to FOR_OPENCODE.md.🔧 Fix: point OpenCode tool docs at mcp-tools owner
1 warning still open:
opencode/recall-memory.md:27- The installed OpenCode agent definition now contradicts the guide this pass corrected. Its rule 5 tells the agent "When the user says/dump, capture the session:recall-memory_memory_dumpwith a descriptive title" — the title-only call I just documented inFOR_OPENCODE.mdas resolving to the wrong host's transcript, becausecoreDump(src/commands/dump.ts:122) falls through todiscoverCurrentSession()andclaudeSessionSourceis tried first with no host gate. That is not theoretical:FOR_OPENCODE.md:165advertises the shared-database dual-host setup, which is exactly the configuration where the Claude adapter wins. Separately, its Available Tools table (lines 41-50) lists 8 of 9 tools, omittingdecision_update; the frontmatterrecall-memory_*: truewildcard means no capability is actually lost, so that half is completeness rather than breakage. I did not fix either: this is a shipped agent prompt (package.json#filesincludesopencode/) whose rule text shapes agent behavior rather than describing it, it is outside the two files the round-1 directive authorized, and adding a table row would be the same list synchronization that directive rejected. Proposed follow-up: correct rule 5 to require explicitmessages+source: "opencode", and decide whether the tool table should stay an enumeration at all or become a pointer todocs/mcp-tools.md.🔧 Fix: fix OpenCode agent dump rule, point tools at owner
✅ Re-checked - no issues remain.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.