From cc58518aaa8aa4e92802ae38bc3ae0eca01457ee Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 4 Jun 2026 09:57:52 -0700 Subject: [PATCH 1/2] docs(plan): off-thread OpenCode listing worker plan Plan for moving OpencodeProvider.listSessionsDirect()'s synchronous node:sqlite marker scan off the shared event loop into a worker_thread. Retains the superseded 2026-06-03 DB-change-gate plan as the record of how live measurement falsified the per-session-cache and gate approaches. Co-Authored-By: Claude Opus 4.8 --- ...6-06-03-opencode-marker-cache-eventloop.md | 308 ++++ ...06-04-opencode-listing-offthread-worker.md | 1417 +++++++++++++++++ 2 files changed, 1725 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-03-opencode-marker-cache-eventloop.md create mode 100644 docs/superpowers/plans/2026-06-04-opencode-listing-offthread-worker.md diff --git a/docs/superpowers/plans/2026-06-03-opencode-marker-cache-eventloop.md b/docs/superpowers/plans/2026-06-03-opencode-marker-cache-eventloop.md new file mode 100644 index 00000000..5648128d --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-opencode-marker-cache-eventloop.md @@ -0,0 +1,308 @@ +# OpenCode Listing DB-Change Gate — Stop Blocking the Event Loop Implementation Plan + +> **⚠️ STATUS: SUPERSEDED — do not execute as-is.** Live measurement (2026-06-04) showed OpenCode is typically **active** (its `-wal` mtime advances every refresh while the db file is static), so this DB-change gate would re-run the scan every time and save nothing in the condition that actually occurs. The gate only helps fully-idle OpenCode. The converged fix is to run the synchronous query **off the event-loop thread** (kata **wab5**, now P0); a cheap complementary mitigation is to **throttle** OpenCode listing frequency. This document is retained as the record of how three review/measurement passes ruled out the per-session-cache and gate approaches. See kata `xe4t`/`wab5` for the current direction. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop Freshell's OpenCode session listing from running a ~180 ms synchronous, event-loop-blocking SQLite content scan on every indexer refresh (~every 5 s) when OpenCode's database has not actually changed — which is the common case that makes all terminals/panes stutter under multi-agent load. + +**Architecture:** Leave the existing, correct `listSessionsDirect()` query **completely unchanged**. Add a cheap change-gate in front of it: before opening/querying the database, `stat()` `opencode.db` and `opencode.db-wal`; if their `(size, mtimeMs)` signature is identical to the last successful run, return the cached `CodingCliSession[]` and skip the query entirely. Any write to the SQLite database (WAL append or checkpoint) changes that signature, so a changed DB always re-runs the full, correct query. This removes the redundant scans without altering which sessions are classified `isSubagent`/`isNonInteractive`. + +**Tech Stack:** TypeScript (NodeNext/ESM), Node `fs/promises` `stat`, Node built-in `node:sqlite` `DatabaseSync`, Vitest. Server file: `server/coding-cli/providers/opencode.ts`. + +--- + +## Plain-English Problem + +`OpencodeProvider.listSessionsDirect()` computes `hasThreeViewsMarker` per session via two correlated `EXISTS` subqueries doing a leading-wildcard `LIKE` over `part.data`/`message.data`. Measured on the live server: **~180 ms, ~432 MB scanned** warm-cache, **synchronous** (`DatabaseSync.all()`), so it blocks the event loop. The indexer calls `listSessionsDirect()` on **every** refresh (~5 s floor under churn), and refreshes are triggered by activity in **any** provider (Claude/Codex too), so the OpenCode scan runs every ~5 s even when OpenCode itself is idle — freezing every terminal ~12×/min. Tracked as kata issue **xe4t** (P0). + +## Why a change-gate, not a per-session marker cache (review history) + +This plan originally proposed replacing the query with a per-session marker cache keyed by a cheap "change token." Two review passes killed that approach because **no cheap, sound per-session change token exists** for this schema: + +- **`session.time_updated` is unsound** (load-bearing, falsified): 173/343 live root sessions have `part.time_created > session.time_updated`; it does not track content. +- **A row-`COUNT` token is unsound** (fresheyes, falsified): count-neutral churn (delete one row + insert a marker row before the next refresh) leaves the count unchanged, so a newly-marked session would be missed. +- **`MAX(id)` is sound but not cheap**: OpenCode `id`s are monotonic ULIDs (verified: ids order with `rowid`), so `MAX(id)` strictly increases on every insert — but `part` has no covering index on `id`, so `SELECT session_id, MAX(id) FROM part GROUP BY session_id` measures **~90 ms** (reads rows). `MAX(rowid)` is cheap (covering, ~20 ms) but `rowid` is **reused** after deleting the max row, reintroducing the count-neutral hole. + +So any cheap per-session token is unsound, and the sound one is nearly as expensive as the original scan. The correct move is therefore **not** to make the scan incremental, but to **avoid running it when nothing changed**. The existing query stays byte-for-byte the same (so no marker is ever missed), and a file-stat gate skips it when `opencode.db`/`-wal` are unchanged. + +**Why file-stat and not `PRAGMA data_version`:** `data_version` is **connection-local** (verified: two fresh read-only connections each report their own baseline, not comparable), and the provider opens a fresh connection per call. A persistent connection + `data_version` is the stricter alternative but adds handle-lifecycle complexity (stale handle if the DB file is replaced/deleted). `stat()` of the two files is cross-connection-safe, robust to file replacement, and is the same signal kata **wab5** proposed. + +## File Structure + +- Modify: `server/coding-cli/providers/opencode.ts` + - Add private cache fields: the last `(size, mtimeMs)` signature of `[db, wal]` and the last returned `CodingCliSession[]`. + - Add a public instrumentation counter `listQueryCount` (number of times the heavy listing query actually ran). + - Add a private `readDbSignature()` that `stat`s `getWatchedDatabasePaths()` and returns a comparable string (missing file ⇒ a sentinel). + - At the top of `listSessionsDirect()`, compute the signature; if it equals the cached one and a cached result exists, return the cached result. Otherwise run the existing query unchanged, then store `{ signature, result }`. +- Test: + - `test/unit/server/coding-cli/opencode-provider.sqlite.test.ts` (real `node:sqlite`): add the perf-contract Red driver (unchanged DB ⇒ query runs once) and a correctness guard (DB changes ⇒ re-query reflects it). Keep the existing marker-detection test green. + - `test/unit/server/coding-cli/opencode-provider.test.ts` (mock): only a check that existing behavior is preserved — **no mock changes are required** because no new SQL is introduced. + +## Scope Check + +This plan covers one change: gating `OpencodeProvider.listSessionsDirect()` on a DB-change signal. It does **not** modify the listing/marker SQL, the indexer cadence, the watchers, or the WebSocket/terminal path, and it does **not** move the query off the event loop (still tracked under kata **wab5** as the residual: when OpenCode *is* actively writing, the gate re-runs the 180 ms query per change; running it in a worker removes even that). Session classification is preserved exactly. + +> **TDD note:** the gate is observable only via "did the heavy query run again?" — so the genuine Red→Green driver is the `listQueryCount` perf-contract test (Task 1), which fails today because the field doesn't exist and the query runs on every call. The classification-correctness behavior is already covered by the existing marker test and is re-asserted by the change guard (Task 3). + +--- + +## Task 1: Perf-contract test — unchanged DB does not re-run the query (Red driver) + +**Files:** +- Test: `test/unit/server/coding-cli/opencode-provider.sqlite.test.ts` + +- [ ] **Step 1: Add the test** (inside the existing `describe('OpencodeProvider SQLite marker detection', ...)`; helpers `createOpencodeSchema`, `insertSession`, `insertMessage` already exist): + +```ts + it('does not re-run the listing query when the database is unchanged', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const db = new DatabaseSync(dbPath) + try { + createOpencodeSchema(db) + db.prepare(` + INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) + VALUES (?, ?, ?, ?, ?) + `).run('project-1', '/repo/root', 900, 4000, '[]') + insertSession(db, 'session-a', 'Session A', 2000) + insertMessage(db, 'message-a', 'session-a', JSON.stringify({ role: 'user', text: 'hello a' })) + } finally { + db.close() + } + + const provider = new OpencodeProvider(tempDir) + + const first = await provider.listSessionsDirect() + expect(provider.listQueryCount).toBe(1) + expect(first).toHaveLength(1) + + const second = await provider.listSessionsDirect() + expect(provider.listQueryCount).toBe(1) // unchanged DB -> served from cache, no new query + expect(second).toEqual(first) + }) +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npm run test:vitest -- test/unit/server/coding-cli/opencode-provider.sqlite.test.ts -t "does not re-run the listing query"` +Expected: FAIL — `provider.listQueryCount` is `undefined` today (`undefined` !== `1`), and the query runs on every call. This is the true Red. + +--- + +## Task 2: Implement the DB-change gate (Green) + +**Files:** +- Modify: `server/coding-cli/providers/opencode.ts` + +- [ ] **Step 1: Add cache fields and the instrumentation counter** + +In `class OpencodeProvider`, next to `private sessionSchemaCache?: OpencodeSessionSchema`, add: + +```ts + /** Last `(size:mtimeMs)` signature of [opencode.db, opencode.db-wal] for which + * `lastDirectSessions` is valid. `undefined` until the first successful run. */ + private lastDbSignature?: string + /** Cached result of the most recent successful `listSessionsDirect()` run. */ + private lastDirectSessions?: CodingCliSession[] + + /** Test/perf instrumentation: number of times the heavy listing query actually ran. */ + listQueryCount = 0 +``` + +- [ ] **Step 2: Add the signature reader** + +Add this private method (place it directly above `listSessionsDirect()`). It stats both watched files; a missing file contributes a sentinel so its later appearance/disappearance is detected: + +```ts + /** + * Cheap change-signal for the OpenCode database: the size + mtime of the db + * file and its -wal sidecar. Any committed write (WAL append) or checkpoint + * changes one of these, so an unchanged signature means the DB content the + * listing query would read is unchanged. + */ + private async readDbSignature(): Promise { + const parts = await Promise.all( + this.getWatchedDatabasePaths().map(async (p) => { + try { + const st = await fsp.stat(p) + return `${st.size}:${st.mtimeMs}` + } catch { + return 'absent' + } + }), + ) + return parts.join('|') + } +``` + +- [ ] **Step 3: Gate the query at the top of `listSessionsDirect()`** + +In `listSessionsDirect()`, after the existing `await fsp.access(dbPath)` existence check and before opening the database (`db = new sqlite.DatabaseSync(...)`), add the gate. Insert immediately after the `sqlite = await import('node:sqlite')` block (so we only serve the cache when sqlite is available and the db exists): + +```ts + const signature = await this.readDbSignature() + if (this.lastDirectSessions && this.lastDbSignature === signature) { + return this.lastDirectSessions + } +``` + +- [ ] **Step 4: Count the query run and populate the cache** + +In `listSessionsDirect()`, at the start of the `phase = 'query'` section (immediately before `const rows = db.prepare(`), add: + +```ts + this.listQueryCount += 1 +``` + +Then change the success `return sessions` (end of the `try`) to cache first: + +```ts + this.lastDbSignature = signature + this.lastDirectSessions = sessions + return sessions +``` + +(Leave the `catch` returning `[]` and the `finally { db?.close() }` unchanged. On failure we do not update the cache, so the next call retries.) + +- [ ] **Step 5: Run the Task 1 test to verify it passes** + +Run: `npm run test:vitest -- test/unit/server/coding-cli/opencode-provider.sqlite.test.ts -t "does not re-run the listing query"` +Expected: PASS. + +- [ ] **Step 6: Run the whole real-sqlite file to verify classification is preserved** + +Run: `npm run test:vitest -- test/unit/server/coding-cli/opencode-provider.sqlite.test.ts` +Expected: PASS (the existing `marks 3-views OpenCode sessions...` test still passes — first call runs the unchanged query and classifies all three sessions correctly). + +--- + +## Task 3: Correctness guard — a changed DB re-runs the query and reflects the change + +**Files:** +- Test: `test/unit/server/coding-cli/opencode-provider.sqlite.test.ts` + +This guard proves the gate never serves stale data after a real change (including a newly-marked session). It would fail against a gate that cached too aggressively. + +- [ ] **Step 1: Add the test** + +```ts + it('re-runs the query and reflects changes after the database is modified', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + let db = new DatabaseSync(dbPath) + try { + createOpencodeSchema(db) + db.prepare(` + INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) + VALUES (?, ?, ?, ?, ?) + `).run('project-1', '/repo/root', 900, 4000, '[]') + insertSession(db, 'session-normal', 'Normal session', 2000) + insertMessage(db, 'message-normal', 'session-normal', JSON.stringify({ role: 'user', text: 'ordinary prompt' })) + } finally { + db.close() + } + + const provider = new OpencodeProvider(tempDir) + + const first = await provider.listSessionsDirect() + expect(first).toHaveLength(1) + expect(first[0]?.isSubagent).toBeUndefined() + expect(provider.listQueryCount).toBe(1) + + // Modify the DB: add a brand-new 3-views session (this grows both files, so the + // stat signature changes and the gate must re-run the query). + db = new DatabaseSync(dbPath) + try { + insertSession(db, 'session-marker', '3-views session', 2500) + insertMessage( + db, + 'message-marker', + 'session-marker', + JSON.stringify({ role: 'user', text: `attached\n${threeViewsMarker}` }), + ) + } finally { + db.close() + } + + const second = await provider.listSessionsDirect() + expect(provider.listQueryCount).toBe(2) // signature changed -> query re-ran + expect(second).toHaveLength(2) + const marked = second.find((s) => s.sessionId === 'session-marker') + expect(marked?.isSubagent).toBe(true) + expect(marked?.isNonInteractive).toBe(true) + }) +``` + +- [ ] **Step 2: Run it to verify it passes** + +Run: `npm run test:vitest -- test/unit/server/coding-cli/opencode-provider.sqlite.test.ts -t "re-runs the query and reflects changes"` +Expected: PASS. + +--- + +## Task 4: Typecheck, related suite, commit + +- [ ] **Step 1: Typecheck** + +Run: `npm run build:server` +Expected: Compiles with no errors. + +- [ ] **Step 2: Run the related coding-cli unit tests (all must pass, including the mock-based file which is unchanged)** + +Run: `npm run test:vitest -- test/unit/server/coding-cli/opencode-provider.test.ts test/unit/server/coding-cli/opencode-provider.sqlite.test.ts test/unit/server/coding-cli/session-indexer-provider-refresh.test.ts` +Expected: PASS. Note `opencode-provider.test.ts` uses `FakeDatabaseSync` and writes a real `opencode.db` placeholder file (`fs.writeFile(dbPath, 'fake sqlite file')`); each test builds a fresh `OpencodeProvider`, so the gate's cache starts empty and the first (only) call runs the query as before. No mock changes are needed because no new SQL is introduced. + +- [ ] **Step 3: Commit** + +```bash +git add server/coding-cli/providers/opencode.ts \ + test/unit/server/coding-cli/opencode-provider.sqlite.test.ts +git commit -m "perf(opencode): gate listSessionsDirect on a DB-change signal to stop per-refresh 432MB scan + +listSessionsDirect ran a ~180ms synchronous LIKE-scan of ~432MB of part/message +content on every indexer refresh (~every 5s), blocking the event loop even when +OpenCode was idle (refreshes are triggered by any provider's activity). Gate the +query on the (size,mtime) signature of opencode.db + -wal: when unchanged, return +the cached session list and skip the query. The listing/marker SQL is unchanged, +so classification is identical; only redundant scans are removed. + +Refs kata xe4t." +``` + +--- + +## Manual verification (optional, on the live machine — read-only, no restart) + +```bash +DB=~/.local/share/opencode/opencode.db +# The gate's signal: any OpenCode write advances the -wal (or db) size/mtime. +stat -c '%n size=%s mtime=%Y' "$DB" "$DB-wal" +``` + +No server restart is implied; deploying to the self-hosted server requires explicit user approval per repo rules. + +## Load-bearing assumption ledger (outcome) + +| Assumption | Verdict | Evidence | +|---|---|---| +| `OpencodeProvider` is a singleton; instance cache fields persist across refreshes | **verified** | `export const opencodeProvider = new OpencodeProvider()` (opencode.ts:412); held by the indexer (index.ts:197); `refreshDirectProvider` calls the same reference (session-indexer.ts:919) | +| The indexer re-queries OpenCode every refresh even when OpenCode is idle (so gating helps) | **verified** | `refreshDirectProvider` runs on full scans and whenever OpenCode is dirty; full scans are triggered by any provider's activity (session-indexer.ts:1274–1279, 1332–1344) | +| Any OpenCode DB write changes the `(size, mtimeMs)` of `opencode.db` or `-wal` | **verified (WAL semantics)** | WAL mode appends committed frames to `-wal` (size/mtime change); checkpoints rewrite `opencode.db` (size/mtime change). Live files: `opencode.db-wal` is 6.27 MB and updates on writes | +| `PRAGMA data_version` is unusable across fresh connections (so file-stat is chosen) | **verified** | Two fresh read-only connections each return their own baseline; SQLite documents `data_version` as connection-local | +| Per-session `time_updated` token | **falsified** (load-bearing) | 173/343 root sessions have `part.time_created > time_updated`; zero triggers | +| Per-session row-`COUNT` token | **falsified** (fresheyes) | Count-neutral churn (delete + insert) leaves the token unchanged → missed marker. Avoided: query is unchanged, no token used | +| The listing/marker SQL is unchanged, so classification is byte-for-byte identical when the query runs | **verified by construction** | Task 2 adds only a pre-query gate and post-query cache assignment; the `db.prepare(...).all(...)` block and the map loop are untouched | +| `isSubagent`/`isNonInteractive` derive solely from the marker; no other consumer affected | **verified** | Set only at opencode.ts:206/215–216 | + +### Accepted residual risks + +1. **mtime granularity.** If a write left both files' `(size, mtimeMs)` identical to the previous run (e.g. an in-place WAL-frame rewrite within the same `mtimeMs` tick and identical size), the gate would skip a re-query and serve a slightly stale list until the next detected change. This is near-impossible for an append (size changes) and self-heals on the next write; the consequence is a brief stale sidebar entry, not data loss. The stricter alternative (persistent connection + `PRAGMA data_version`) is deferred to wab5. +2. **`projectPath` staleness from cached results.** When `row.projectPath` is null the listing resolves it via `resolveGitRepoRoot(cwd)` (filesystem). A cached result will not re-resolve until the DB changes. Matches the current best-effort behavior; git checkout roots rarely move. +3. **Residual cost while OpenCode is actively writing.** When the DB changes every refresh, the gate re-runs the full ~180 ms query each time. Eliminated by moving the query off the event loop (kata **wab5**); out of scope here. The primary symptom — idle-OpenCode scans blocking other agents' terminals — is fully addressed. + +## Self-Review + +- **Spec coverage:** (1) stop the redundant per-refresh scan → Task 2 gate. (2) Red-first proof → Task 1 `listQueryCount`. (3) never serve stale data after a change → Task 3. (4) classification unchanged → existing marker test (Task 2 Step 6) + Task 3 marked-session assertion. (5) mock suite stays green with no changes → Task 4 Step 2 (no new SQL). (6) typecheck/full related suite → Task 4. +- **TDD validity:** Task 1 is genuinely Red today (`listQueryCount` undefined; query runs every call). Task 3 asserts re-query on change (`listQueryCount` 1→2) and the new marked session is detected. +- **Soundness:** the listing/marker query is unchanged, so no marker is ever misclassified when the query runs; the only new failure mode is the bounded mtime-granularity staleness in Accepted Residual Risks #1. +- **Placeholder scan:** none — every step has full code and exact commands. +- **Type consistency:** `lastDbSignature?: string`, `lastDirectSessions?: CodingCliSession[]`, `listQueryCount: number` are used consistently; `readDbSignature(): Promise` returns the value compared against `lastDbSignature`; `getWatchedDatabasePaths()` is an existing method (opencode.ts) returning `[db, wal]`. diff --git a/docs/superpowers/plans/2026-06-04-opencode-listing-offthread-worker.md b/docs/superpowers/plans/2026-06-04-opencode-listing-offthread-worker.md new file mode 100644 index 00000000..74c9fa1a --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-opencode-listing-offthread-worker.md @@ -0,0 +1,1417 @@ +# OpenCode Listing Off-Thread Worker Implementation Plan + +> **For agentic workers:** Implement this plan task-by-task with Red-Green-Refactor TDD, committing after each task. Steps use checkbox (`- [ ]`) syntax for tracking. (Note: the superpowers `subagent-driven-development`/`executing-plans` skills are **disabled** in this repo, so do not invoke them; the user selects the execution mechanism. Do not invoke any skill unless the user asks.) + +**Goal:** Run the OpenCode `listSessionsDirect` synchronous `node:sqlite` query in a `worker_thread` so the ~180 ms `hasThreeViewsMarker` content scan never blocks Freshell's shared event loop (the cause of terminal stutter while OpenCode agents are active). + +**Architecture:** Extract the synchronous DB work into a pure function. A thin worker entry runs that function off the main thread and posts the rows back. The provider keeps its cheap pre-checks (`missing_db`, `sqlite_unavailable`) and async row→session mapping on the main thread, but delegates the heavy open+query to an injectable async *query runner* whose default implementation spawns the worker. Tests inject an in-process runner (real `node:sqlite`, no thread) for behavior coverage and a fake-spawn runner for orchestration coverage; one integration test spawns the real worker and proves the loop stays responsive. + +**Tech Stack:** Node.js `node:worker_threads`, `node:sqlite` (`DatabaseSync`, Node ≥ 22.5), TypeScript NodeNext/ESM, Vitest (server config, `pool: 'threads'`). + +--- + +## Background & Validated Constraints (read before starting) + +This plan replaces the superseded `2026-06-03-opencode-marker-cache-eventloop.md` (cache/gate approaches were falsified). The root cause is documented in kata `xe4t`/`wab5`: `listSessionsDirect()` runs a leading-wildcard `LIKE` over `part.data`+`message.data` (~180 ms, ~432 MB scanned) synchronously via `DatabaseSync.prepare().all()` on the event loop, re-run every indexer refresh (~7 s) while OpenCode writes its WAL. It cannot be cheaply/soundly incrementalized or gated, so the fix is to move it off-thread. + +The following were **empirically validated by spike** on this machine (Node v22.21.1, repo tsx 4.19.x) — the design depends on them: + +1. **Dev runtime (`tsx watch server/index.ts`):** A spawned `Worker` inherits `process.execArgv`, which under tsx contains tsx's `--import .../loader.mjs`. So a `.ts` worker entry loads in dev. ✓ +2. **Prod runtime (`node dist/server/index.js`):** A compiled `.js` worker loads under plain node. ✓ +3. **Test runtime (Vitest server config, `pool: 'threads'`):** `process.execArgv` is `["--conditions","node","--conditions","development"]` (no TS loader). Node 22.21's native type-stripping loads a nested `.ts` worker, and `node:sqlite` works inside it. ✓ +4. **`node:sqlite` `DatabaseSync` works inside a worker thread.** ✓ +5. **NodeNext `.js`→`.ts` remap FAILS inside a worker** under both plain node and tsx (`Cannot find module './x.js'`). Therefore the worker entry must NOT statically import a relative sibling. Instead the main thread resolves the *exact* sibling URL (`.ts` in dev/test, `.js` in prod — the file that actually exists) and passes it via `workerData`; the worker `await import()`s that exact URL (no remap). This works in all three runtimes. ✓ +6. **`import.meta.url.endsWith('.ts')`** reliably distinguishes dev/test (source `.ts`) from prod (compiled `.js`), enabling extension-swap path resolution. ✓ +7. **`tsc -p tsconfig.server.json`** has `include: ["server/**/*", ...]`, so new `server/coding-cli/providers/*.ts` files (including the worker) compile to `dist/server/...` automatically — **no build-step change required.** ✓ +8. **Electron is a fourth+fifth runtime, and it works (validator-confirmed).** The desktop app reaches this exact code path (`server/index.ts` instantiates `opencodeProvider` + `CodingCliSessionIndexer` identically). Electron **prod** spawns the **compiled** `dist/server/index.js` under a **bundled standalone Node v22.12.0** (`electron/server-spawner.ts`, `scripts/bundled-node-version.json`) — `import.meta.url` ends in `.js`, so the ext-swap picks `.js`; 22.12.0 ≥ 22.5 so `node:sqlite`/`worker_threads` are present; and `electron-builder.yml` `extraResources` copies `dist/server/**/*` (including the new `opencode-listing*.js`) onto the real filesystem (not ASAR), so the worker file is loadable. Electron **dev** runs `tsx server/index.ts` (`.ts`, same as constraint §1). The throw-on-failure contract means even a worst-case Electron worker failure degrades to a preserved/empty listing, never a crash. ⚠️ The plan's tests run on the host Node (22.21), not the bundled 22.12.0 binary; Task 6 includes an optional step to run the prod one-off under the bundled binary when present. ✓ +9. **Cross-platform path safety.** All worker/query URLs are resolved with `new URL('./sibling.', import.meta.url).href` and consumed via `await import(url)` / `new Worker(url)` — no raw path-string concatenation — so Windows drive/backslash paths, spaces, and the mandated `.worktrees/...` layout are handled by the URL layer. Windows Electron remains a manual-verify residual (the automated suite runs on Linux). ✓ + +**Non-goals (keep scope tight):** +- `resolveOpencodeSessionRoots()` stays on the main thread. It is an indexed `id IN (...)` lookup (small result set), not the hot-path bottleneck. Do not touch it. +- No persistent worker pool. Spawn-per-call is simpler and safe here: refreshes are single-flight (`refreshInFlight`) and throttled (≥ 5 s), so at most one listing runs at a time; worker startup (~30–100 ms) happens off the main thread. +- No marker caching / DB-change gating (falsified — see kata `xe4t`). +- No retry inside `listSessionsDirect` (unlike `resolveOpencodeSessionRoots`). With throw-on-failure, a transient `SQLITE_BUSY`/worker hiccup throws → the indexer preserves the prior sidebar → the next scheduled refresh (~7 s) retries. Adding per-call retry is unnecessary. + +**Residual risks (accepted / verified at execution):** +- The real-worker integration test (Task 5) is validated standalone; its behavior under the **full shuffled `npm test`** (vitest `pool:'threads'`, `isolate`, `shuffle`) is confirmed by Task 6 Step 4. If a nested-worker/teardown issue appears there, treat it as a real signal (it would also affect production), not a test artifact. +- The non-blocking assertion (`ticks ≥ 10`) has generous margin: a spike measured ~61 main-thread ticks (5 ms) during a 319 ms real-DB worker query; at 10 ms over a 250 ms busy fixture, ~25 ticks are expected. GC pauses won't drop it below 10. +- **Windows Electron** worker spawn + URL resolution is not covered by the Linux CI suite; the URL-based resolution (constraint §9) is cross-platform by construction, but flag a manual verify on the Windows desktop build. +- Spawn-per-call lifecycle: a spike ran 100 spawn/terminate cycles with **zero fd growth**; spawning one worker per ~7 s refresh is safe (no persistent-pool needed). + +--- + +## File Structure + +**New files (all under `server/coding-cli/providers/`):** +- `opencode-listing-query.ts` — The DB work. `runOpencodeListingQuery(dbPath, markerPattern)` opens the DB read-only, inspects schema, runs the marker SELECT, returns `{ rows, schemaMissingParentId }`. The DB operations are synchronous (thread-blocking); the function is `async` only because it imports `node:sqlite` lazily (mock-compat — see Lazy import note). Owns the `OpencodeSessionRow` type and `THREE_VIEWS_MARKER_SQL_PATTERN`. Real-sqlite-testable. No worker, no fs-async, no logging. +- `opencode-listing.worker.ts` — Thin worker entry. Reads `workerData = { queryModuleUrl, dbPath, markerPattern }`, dynamically imports the exact `queryModuleUrl`, awaits `runOpencodeListingQuery`, posts `{ ok: true, ...result }` or `{ ok: false, error }`. The message-handling logic is an exported `executeListing(workerData)` (unit-testable in-process); the `parentPort` wiring runs only when `parentPort` is present. +- `opencode-listing-runner.ts` — `createWorkerListingRunner(options?)` returns an async `OpencodeListingQueryRunner`. Resolves the worker URL and query-module URL by extension swap, spawns the worker (injectable `spawn` for tests), enforces a timeout, terminates the worker on every exit path, and returns the rows / schema flag. Owns the `OpencodeListingQueryRunner` and `OpencodeListingResult` types. + +**Modified files:** +- `server/coding-cli/providers/opencode.ts` — `OpencodeProvider` gains an injectable `queryRunner` (default: real worker runner). `listSessionsDirect()` keeps `missing_db` + `sqlite_unavailable` pre-checks and the async row→session mapping, but delegates open+query to `this.queryRunner`; it returns `[]` for absent/empty states and **throws** on a worker/read failure (so the indexer preserves the prior sidebar). Removes the inline schema+SELECT (now in the query module). Re-exports `THREE_VIEWS_MARKER_SQL_PATTERN`/`OpencodeSessionRow` from the query module for compatibility. `resolveOpencodeSessionRoots`, `inspectSessionSchema`, logging helpers unchanged. +- `server/coding-cli/session-indexer.ts` (Task 4b) — `refreshDirectProvider`'s catch now preserves the failing provider's existing direct-cache keys (so the full-scan global prune doesn't wipe the sidebar on a transient failure) and logs `{ provider }` at debug instead of `{ err }` at warn (no raw-error re-leak / per-refresh spam). + +**Modified tests:** +- `test/unit/server/coding-cli/opencode-provider.test.ts` — Construct the provider with the in-process runner so the `node:sqlite` mock still drives `listSessionsDirect`. +- `test/unit/server/coding-cli/opencode-provider.sqlite.test.ts` — Construct with the in-process runner; keep the end-to-end marker/mapping assertions. + +**New tests:** +- `test/unit/server/coding-cli/opencode-listing-query.test.ts` — Pure function, real sqlite. +- `test/unit/server/coding-cli/opencode-listing-runner.test.ts` — Orchestration, fake spawn. +- `test/unit/server/coding-cli/opencode-listing-worker.test.ts` — `executeListing` in-process, real sqlite. +- `test/integration/server/opencode-listing-offthread.test.ts` — Real worker: correctness vs baseline + event-loop-not-blocked. +- `test/integration/server/fixtures/slow-opencode-listing-query.ts` — Fixture query module that busy-sleeps then returns known rows (drives the non-blocking assertion). + +--- + +## Shared Type & Contract Reference (used across tasks) + +```ts +// In opencode-listing-query.ts +export type OpencodeSessionRow = { + sessionId: string + cwd: string + title: string + createdAt: number + lastActivityAt: number + projectPath: string | null + hasThreeViewsMarker?: number | null +} + +export type OpencodeListingResult = { + rows: OpencodeSessionRow[] + schemaMissingParentId: boolean +} + +export const THREE_VIEWS_MARKER_SQL_PATTERN = '% +``` + +> **Lazy import (load-bearing — validated by spike):** the query module must import `node:sqlite` **lazily** (`const { DatabaseSync } = await import('node:sqlite')` inside the function), NOT with a static top-level `import`. A static import is eagerly triggered when `opencode.ts` is imported, which fires `vi.mock('node:sqlite')`'s hoisted factory **before** the mock test's inline `FakeDatabaseSync` class initializes → `ReferenceError: Cannot access 'FakeDatabaseSync' before initialization`. The current production code already imports `node:sqlite` lazily for this reason. A spike confirmed: lazy `await import('node:sqlite')` in a transitively-imported module IS intercepted by `vi.mock` and avoids the TDZ. This makes `runOpencodeListingQuery` async; the worker and the in-process runner both `await` it. + +```ts +// In opencode-listing-runner.ts +import type { OpencodeListingResult } from './opencode-listing-query.js' + +export type OpencodeListingQueryInput = { dbPath: string; markerPattern: string } +export type OpencodeListingQueryRunner = (input: OpencodeListingQueryInput) => Promise +``` + +The worker message protocol (posted by `opencode-listing.worker.ts`, consumed by the runner): +```ts +type WorkerListingMessage = + | { ok: true; rows: OpencodeSessionRow[]; schemaMissingParentId: boolean } + | { ok: false; error: { name: string; message: string } } +``` + +**Failure contract (load-bearing — validated, refined after review):** `OpencodeProvider.listSessionsDirect()` returns `[]` ONLY for genuinely-absent/empty states (missing DB file, `node:sqlite` unavailable, zero rows). On a **worker/read failure** (spawn, load, timeout, exit, malformed message, or DB open/query error) it **throws**. The throw is what tells the indexer "this is a transient failure, not 'no sessions'". + +For the throw to actually preserve the sidebar, BOTH refresh paths must be handled (this is why Task 4b changes the indexer, not just the provider): +- **Incremental refresh:** `refreshDirectProvider`'s catch returns early *before* its local direct-key prune (session-indexer.ts:918–923), so existing entries survive. ✓ already true. +- **Full scan (periodic safety scan / enabled-set change / root events):** `refreshDirectProvider`'s return value feeds the **global** prune in `performRefresh` (session-indexer.ts:~1273–1317) — any direct cache key NOT in the returned set is deleted. An empty return (the current catch) would let the global prune wipe every OpenCode entry. **Task 4b fixes this:** on failure, `refreshDirectProvider` returns the provider's *existing* direct cache keys, so both the local and global prune preserve them. + +Returning `[]` (current behavior) wipes the sidebar on *both* paths; throw + the Task 4b indexer change preserves it on both. The indexer catch also stops logging the raw error (logs `{ provider }` at debug, not `{ err }` at warn) so the rethrown error cannot re-leak paths/messages or spam per-refresh — the sanitized one-time detail already comes from the provider's `logDatabaseStateOnce`. + +**Per-spawn warning suppression (load-bearing — validated):** every worker spawn re-emits Node's `ExperimentalWarning` for `node:sqlite` (a fresh module realm). At ~7 s cadence that is log spam. The runner spawns the worker with `execArgv: [...process.execArgv, '--disable-warning=ExperimentalWarning']`. Spike-validated: this keeps tsx's loader (`--import .../loader.mjs`) under `tsx watch` (dev) AND is accepted by plain `node` (prod), while silencing the warning. We append to `process.execArgv` (not replace) precisely so the inherited tsx loader survives in dev. + +--- + +## Task 1: Listing query module (sync DB work, lazy `node:sqlite`) + +**Files:** +- Create: `server/coding-cli/providers/opencode-listing-query.ts` +- Test: `test/unit/server/coding-cli/opencode-listing-query.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// test/unit/server/coding-cli/opencode-listing-query.test.ts +import path from 'path' +import os from 'os' +import fsp from 'fs/promises' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + runOpencodeListingQuery, + THREE_VIEWS_MARKER_SQL_PATTERN, +} from '../../../../server/coding-cli/providers/opencode-listing-query' + +vi.unmock('node:sqlite') + +type SqliteModule = typeof import('node:sqlite') +type DatabaseSyncConstructor = SqliteModule['DatabaseSync'] +type DatabaseSyncInstance = InstanceType + +const threeViewsMarker = '' + +describe('runOpencodeListingQuery', () => { + let tempDir: string + let DatabaseSync: DatabaseSyncConstructor + + beforeAll(async () => { + DatabaseSync = (await import('node:sqlite')).DatabaseSync + }) + beforeEach(async () => { + tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-query-')) + }) + afterEach(async () => { + await fsp.rm(tempDir, { recursive: true, force: true }) + }) + + function createSchema(db: DatabaseSyncInstance, opts: { parentId?: boolean } = {}): void { + const parentCol = opts.parentId === false ? '' : 'parent_id text,' + db.exec(` + CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, sandboxes text NOT NULL); + CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, ${parentCol} slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer); + CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + `) + } + function seedProject(db: DatabaseSyncInstance) { + db.prepare(`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, ?)`).run('project-1', '/repo/root', 900, 4000, '[]') + } + function seedSession(db: DatabaseSyncInstance, id: string, title: string, timeUpdated: number, parentId: string | null = null, archived: number | null = null) { + db.prepare(`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(id, 'project-1', parentId, id, '/repo/root', title, 'test', 1000, timeUpdated, archived) + } + // For the parent_id-absent schema: an INSERT that does NOT reference the + // missing parent_id column (seedSession would fail at bind time otherwise). + function seedFlatSession(db: DatabaseSyncInstance, id: string, title: string, timeUpdated: number) { + db.prepare(`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(id, 'project-1', id, '/repo/root', title, 'test', 1000, timeUpdated, null) + } + function seedMessage(db: DatabaseSyncInstance, id: string, sessionId: string, data: string) { + db.prepare(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)`).run(id, sessionId, 1100, 1100, data) + } + function seedPart(db: DatabaseSyncInstance, id: string, messageId: string, sessionId: string, data: string) { + db.prepare(`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)`).run(id, messageId, sessionId, 1100, 1100, data) + } + + it('flags marker in a part, marker in a message, and leaves a normal session unmarked; sorted by time_updated desc', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const db = new DatabaseSync(dbPath) + try { + createSchema(db) + seedProject(db) + seedSession(db, 'session-part-marker', 'Part marker', 3000) + seedSession(db, 'session-message-marker', 'Message marker', 2500) + seedSession(db, 'session-normal', 'Normal', 2000) + seedMessage(db, 'm-part', 'session-part-marker', JSON.stringify({ role: 'user' })) + seedPart(db, 'p-marked', 'm-part', 'session-part-marker', JSON.stringify({ type: 'text', text: `hi\n${threeViewsMarker}` })) + seedMessage(db, 'm-msg', 'session-message-marker', JSON.stringify({ role: 'user', text: `hi\n${threeViewsMarker}` })) + seedMessage(db, 'm-normal', 'session-normal', JSON.stringify({ role: 'user', text: 'ordinary prompt' })) + } finally { + db.close() + } + + const result = await runOpencodeListingQuery(dbPath, THREE_VIEWS_MARKER_SQL_PATTERN) + + expect(result.schemaMissingParentId).toBe(false) + expect(result.rows.map((r) => [r.sessionId, !!r.hasThreeViewsMarker])).toEqual([ + ['session-part-marker', true], + ['session-message-marker', true], + ['session-normal', false], + ]) + expect(result.rows[0]).toMatchObject({ cwd: '/repo/root', title: 'Part marker', createdAt: 1000, lastActivityAt: 3000, projectPath: '/repo/root' }) + }) + + it('excludes archived sessions and child sessions (parent_id not null)', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const db = new DatabaseSync(dbPath) + try { + createSchema(db) + seedProject(db) + seedSession(db, 'root', 'Root', 3000) + seedSession(db, 'child', 'Child', 2900, 'root') + seedSession(db, 'archived', 'Archived', 2800, null, 5000) + } finally { + db.close() + } + const result = await runOpencodeListingQuery(dbPath, THREE_VIEWS_MARKER_SQL_PATTERN) + expect(result.rows.map((r) => r.sessionId)).toEqual(['root']) + }) + + it('reports schemaMissingParentId and returns all non-archived sessions when parent_id is absent', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const db = new DatabaseSync(dbPath) + try { + createSchema(db, { parentId: false }) + seedProject(db) + seedFlatSession(db, 'a', 'A', 3000) + seedFlatSession(db, 'b', 'B', 2000) + } finally { + db.close() + } + const result = await runOpencodeListingQuery(dbPath, THREE_VIEWS_MARKER_SQL_PATTERN) + expect(result.schemaMissingParentId).toBe(true) + expect(result.rows.map((r) => r.sessionId)).toEqual(['a', 'b']) + }) + + it('degrades to unmarked (no throw) when part/message tables are absent', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const db = new DatabaseSync(dbPath) + try { + // Only project + session — mirrors the e2e fake-opencode fixture's schema. + db.exec(` + CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, sandboxes text NOT NULL); + CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, parent_id text, slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer); + `) + seedProject(db) + seedSession(db, 'only', 'Only', 2000) + } finally { + db.close() + } + const result = await runOpencodeListingQuery(dbPath, THREE_VIEWS_MARKER_SQL_PATTERN) + expect(result.rows.map((r) => [r.sessionId, !!r.hasThreeViewsMarker])).toEqual([['only', false]]) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:vitest -- --config vitest.server.config.ts test/unit/server/coding-cli/opencode-listing-query.test.ts --run` +Expected: FAIL — cannot resolve `opencode-listing-query` / `runOpencodeListingQuery is not a function`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// server/coding-cli/providers/opencode-listing-query.ts +export type OpencodeSessionRow = { + sessionId: string + cwd: string + title: string + createdAt: number + lastActivityAt: number + projectPath: string | null + hasThreeViewsMarker?: number | null +} + +export type OpencodeListingResult = { + rows: OpencodeSessionRow[] + schemaMissingParentId: boolean +} + +export const THREE_VIEWS_MARKER_SQL_PATTERN = '% { + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(dbPath, { readOnly: true }) + try { + db.exec(`PRAGMA busy_timeout = ${OPENCODE_DB_BUSY_TIMEOUT_MS}`) + const columns = db.prepare('PRAGMA table_info(session)').all() as Array<{ name?: unknown }> + const hasParentId = columns.some((c) => c.name === 'parent_id') + const rootFilter = hasParentId ? 'AND s.parent_id IS NULL' : '' + // The 3-views marker lives in part.data / message.data. Older/partial schemas + // (and the e2e fake-opencode fixture, which has only project+session) may lack + // those tables; degrade gracefully to "no marker" instead of throwing + // "no such table: part". When absent, every session is simply unmarked. + const tableNames = new Set( + (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as Array<{ name?: unknown }>) + .map((row) => row.name), + ) + const canDetectMarker = tableNames.has('part') && tableNames.has('message') + const markerExpr = canDetectMarker + ? `( + EXISTS (SELECT 1 FROM part pa WHERE pa.session_id = s.id AND pa.data LIKE ?) + OR EXISTS (SELECT 1 FROM message m WHERE m.session_id = s.id AND m.data LIKE ?) + )` + : '0' + const rows = db.prepare(` + SELECT + s.id AS sessionId, + s.directory AS cwd, + s.title AS title, + s.time_created AS createdAt, + s.time_updated AS lastActivityAt, + p.worktree AS projectPath, + ${markerExpr} AS hasThreeViewsMarker + FROM session s + LEFT JOIN project p ON p.id = s.project_id + WHERE s.time_archived IS NULL + ${rootFilter} + ORDER BY s.time_updated DESC + `).all(...(canDetectMarker ? [markerPattern, markerPattern] : [])) as OpencodeSessionRow[] + return { rows, schemaMissingParentId: !hasParentId } + } finally { + db.close() + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:vitest -- --config vitest.server.config.ts test/unit/server/coding-cli/opencode-listing-query.test.ts --run` +Expected: PASS (4 tests: part-marker/message-marker/normal ordering, archived+child exclusion, parent_id-absent flat roots, missing part/message degrades to unmarked). + +- [ ] **Step 5: Commit** + +```bash +git add server/coding-cli/providers/opencode-listing-query.ts test/unit/server/coding-cli/opencode-listing-query.test.ts +git commit -m "feat(opencode): extract pure synchronous session-listing query" +``` + +--- + +## Task 2: Worker entry + +**Files:** +- Create: `server/coding-cli/providers/opencode-listing.worker.ts` +- Test: `test/unit/server/coding-cli/opencode-listing-worker.test.ts` + +- [ ] **Step 1: Write the failing test** (tests `executeListing` in-process — no real thread) + +```ts +// test/unit/server/coding-cli/opencode-listing-worker.test.ts +import path from 'path' +import os from 'os' +import fsp from 'fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { executeListing, OPENCODE_LISTING_WORKER_KIND } from '../../../../server/coding-cli/providers/opencode-listing.worker' +import { THREE_VIEWS_MARKER_SQL_PATTERN } from '../../../../server/coding-cli/providers/opencode-listing-query' + +vi.unmock('node:sqlite') + +const queryModuleUrl = new URL('../../../../server/coding-cli/providers/opencode-listing-query.ts', import.meta.url).href + +describe('opencode listing worker executeListing', () => { + let tempDir: string + beforeEach(async () => { tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-worker-')) }) + afterEach(async () => { await fsp.rm(tempDir, { recursive: true, force: true }) }) + + it('dynamically imports the query module and returns its result', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(dbPath) + try { + db.exec(` + CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, sandboxes text NOT NULL); + CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, parent_id text, slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer); + CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + `) + db.prepare(`INSERT INTO project VALUES (?, ?, ?, ?, ?)`).run('project-1', '/repo/root', 900, 4000, '[]') + db.prepare(`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?,?,?,?)`) + .run('s1', 'project-1', null, 's1', '/repo/root', 'Title', 'test', 1000, 2000, null) + } finally { + db.close() + } + + const result = await executeListing({ queryModuleUrl, dbPath, markerPattern: THREE_VIEWS_MARKER_SQL_PATTERN }) + expect(result.schemaMissingParentId).toBe(false) + expect(result.rows.map((r) => r.sessionId)).toEqual(['s1']) + }) + + it('does not auto-run on import under the threaded test runtime (sentinel guard)', () => { + // The server Vitest config uses pool: 'threads', so this test file runs inside + // a worker thread (parentPort is non-null). Importing the worker module at the + // top of this file must NOT have triggered executeListing against Vitest's + // workerData — the sentinel guard (workerData.kind !== OPENCODE_LISTING_WORKER_KIND) + // prevents it. If the guard were broken, the import would have posted to Vitest's + // parent port and likely corrupted the run; reaching this assertion proves it didn't. + expect(typeof executeListing).toBe('function') + expect(OPENCODE_LISTING_WORKER_KIND).toBe('opencode-listing-worker') + expect(queryModuleUrl).toContain('opencode-listing-query') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:vitest -- --config vitest.server.config.ts test/unit/server/coding-cli/opencode-listing-worker.test.ts --run` +Expected: FAIL — cannot resolve `opencode-listing.worker` / `executeListing` not exported. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// server/coding-cli/providers/opencode-listing.worker.ts +import { parentPort, workerData } from 'node:worker_threads' +import type { OpencodeListingResult } from './opencode-listing-query.js' + +/** + * Sentinel proving this thread was spawned by OUR runner. REQUIRED because the + * server Vitest config runs test files in worker threads (`pool: 'threads'`), so + * `parentPort` is non-null when a test imports this module. Without the sentinel, + * the auto-run block below would fire on import using Vitest's OWN workerData and + * post a message to Vitest's parent port — corrupting/hanging the test worker. + * The runner injects this exact value in workerData; Vitest's workerData never has it. + */ +export const OPENCODE_LISTING_WORKER_KIND = 'opencode-listing-worker' + +export type WorkerListingInput = { + kind: typeof OPENCODE_LISTING_WORKER_KIND + queryModuleUrl: string + dbPath: string + markerPattern: string +} + +/** + * Run the listing query by dynamically importing the EXACT resolved query-module + * URL (.ts in dev/test, .js in prod) provided by the spawning code. We pass the + * exact URL rather than a static relative import because NodeNext `.js`→`.ts` + * remapping fails inside a worker thread (validated by spike). + */ +export async function executeListing( + input: { queryModuleUrl: string; dbPath: string; markerPattern: string }, +): Promise { + const mod = await import(input.queryModuleUrl) as typeof import('./opencode-listing-query.js') + return mod.runOpencodeListingQuery(input.dbPath, input.markerPattern) +} + +// Auto-run ONLY when we are a real worker spawned by our runner (parentPort present +// AND our sentinel in workerData). This is import-safe under Vitest's thread pool. +if (parentPort && (workerData as Partial | undefined)?.kind === OPENCODE_LISTING_WORKER_KIND) { + const port = parentPort + executeListing(workerData as WorkerListingInput) + .then((result) => port.postMessage({ ok: true, rows: result.rows, schemaMissingParentId: result.schemaMissingParentId })) + .catch((err: unknown) => { + const error = err instanceof Error ? { name: err.name, message: err.message } : { name: 'Error', message: String(err) } + port.postMessage({ ok: false, error }) + }) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:vitest -- --config vitest.server.config.ts test/unit/server/coding-cli/opencode-listing-worker.test.ts --run` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add server/coding-cli/providers/opencode-listing.worker.ts test/unit/server/coding-cli/opencode-listing-worker.test.ts +git commit -m "feat(opencode): add off-thread listing worker entry" +``` + +--- + +## Task 3: Off-thread query runner + +**Files:** +- Create: `server/coding-cli/providers/opencode-listing-runner.ts` +- Test: `test/unit/server/coding-cli/opencode-listing-runner.test.ts` + +The runner spawns the worker, awaits the first message, terminates the worker on every path, and enforces a timeout. `spawn` is injectable so unit tests exercise orchestration with a fake worker (no real OS thread). `queryModuleUrl` is overridable so the integration test can point at a slow fixture. + +- [ ] **Step 1: Write the failing test** + +```ts +// test/unit/server/coding-cli/opencode-listing-runner.test.ts +import { EventEmitter } from 'events' +import { describe, expect, it, vi } from 'vitest' +import { createWorkerListingRunner } from '../../../../server/coding-cli/providers/opencode-listing-runner' +import { THREE_VIEWS_MARKER_SQL_PATTERN } from '../../../../server/coding-cli/providers/opencode-listing-query' + +class FakeWorker extends EventEmitter { + terminated = 0 + postedData: unknown + execArgv: string[] + constructor(public url: URL, public options: { workerData: unknown; execArgv: string[] }) { + super() + this.postedData = options.workerData + this.execArgv = options.execArgv + } + terminate() { this.terminated += 1; return Promise.resolve(0) } + // helpers + emitMessage(msg: unknown) { this.emit('message', msg) } + emitError(err: Error) { this.emit('error', err) } + emitExit(code: number) { this.emit('exit', code) } +} + +function makeRunner(overrides: Partial[0]> = {}) { + const workers: FakeWorker[] = [] + const spawn = vi.fn((url: URL, options: { workerData: unknown; execArgv: string[] }) => { + const w = new FakeWorker(url, options) + workers.push(w) + return w + }) + const runner = createWorkerListingRunner({ spawn: spawn as any, timeoutMs: 50, ...overrides }) + return { runner, workers, spawn } +} + +const input = { dbPath: '/tmp/opencode.db', markerPattern: THREE_VIEWS_MARKER_SQL_PATTERN } + +describe('createWorkerListingRunner', () => { + it('resolves rows from an ok message and terminates the worker', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitMessage({ ok: true, rows: [{ sessionId: 's1' }], schemaMissingParentId: false }) + const result = await promise + expect(result.rows).toEqual([{ sessionId: 's1' }]) + expect(result.schemaMissingParentId).toBe(false) + expect(workers[0].terminated).toBe(1) + }) + + it('passes dbPath, markerPattern and a queryModuleUrl in workerData, and suppresses the experimental warning via execArgv', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + const data = workers[0].postedData as any + expect(data.dbPath).toBe(input.dbPath) + expect(data.markerPattern).toBe(THREE_VIEWS_MARKER_SQL_PATTERN) + expect(String(data.queryModuleUrl)).toContain('opencode-listing-query') + expect(data.kind).toBe('opencode-listing-worker') // sentinel that gates the worker auto-run + // Appended to process.execArgv so the tsx loader (dev) survives AND the + // per-spawn node:sqlite ExperimentalWarning is silenced. + expect(workers[0].execArgv).toEqual([...process.execArgv, '--disable-warning=ExperimentalWarning']) + workers[0].emitMessage({ ok: true, rows: [], schemaMissingParentId: false }) + await promise + }) + + it('ignores a late exit event after a successful message (no double-settle)', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitMessage({ ok: true, rows: [{ sessionId: 's1' }], schemaMissingParentId: false }) + // A real Worker emits 'exit' after terminate(); the settled guard must swallow it. + workers[0].emitExit(0) + await expect(promise).resolves.toMatchObject({ rows: [{ sessionId: 's1' }] }) + expect(workers[0].terminated).toBe(1) + }) + + it('rejects on an error message and terminates', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitMessage({ ok: false, error: { name: 'SqliteError', message: 'boom' } }) + await expect(promise).rejects.toThrow(/boom/) + expect(workers[0].terminated).toBe(1) + }) + + it.each([ + ['ok:true without rows', { ok: true, schemaMissingParentId: false }], + ['ok:true with non-array rows', { ok: true, rows: 'nope', schemaMissingParentId: false }], + ['ok:true without schemaMissingParentId', { ok: true, rows: [] }], + ['ok:false without error', { ok: false }], + ['no ok key', { rows: [] }], + ])('rejects a malformed message (%s) instead of resolving undefined', async (_label, msg) => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitMessage(msg) + await expect(promise).rejects.toThrow(/malformed|failed/i) + expect(workers[0].terminated).toBe(1) + }) + + it('rejects on a worker error event and terminates', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitError(new Error('worker crashed')) + await expect(promise).rejects.toThrow(/worker crashed/) + expect(workers[0].terminated).toBe(1) + }) + + it('rejects when the worker exits before sending a message', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitExit(1) + await expect(promise).rejects.toThrow(/exit/i) + }) + + it('rejects and terminates on timeout', async () => { + vi.useFakeTimers() + try { + const { runner, workers } = makeRunner({ timeoutMs: 25 }) + const promise = runner(input) + await Promise.resolve() + const expectation = expect(promise).rejects.toThrow(/timed out/i) + await vi.advanceTimersByTimeAsync(30) + await expectation + expect(workers[0].terminated).toBe(1) + } finally { + vi.useRealTimers() + } + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:vitest -- --config vitest.server.config.ts test/unit/server/coding-cli/opencode-listing-runner.test.ts --run` +Expected: FAIL — `createWorkerListingRunner` not found. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// server/coding-cli/providers/opencode-listing-runner.ts +import { Worker } from 'node:worker_threads' +import type { OpencodeListingResult, OpencodeSessionRow } from './opencode-listing-query.js' +// Importing the worker module on the MAIN thread (or a Vitest worker) is safe: +// its auto-run is sentinel-guarded, so this import never spawns/posts anything. +import { OPENCODE_LISTING_WORKER_KIND } from './opencode-listing.worker.js' + +export type OpencodeListingQueryInput = { dbPath: string; markerPattern: string } +export type OpencodeListingQueryRunner = (input: OpencodeListingQueryInput) => Promise + +type WorkerLike = { + on(event: 'message', listener: (value: unknown) => void): unknown + on(event: 'error', listener: (err: Error) => void): unknown + on(event: 'exit', listener: (code: number) => void): unknown + terminate(): Promise | void +} + +export type WorkerSpawnOptions = { workerData: unknown; execArgv: string[] } + +export type CreateWorkerListingRunnerOptions = { + /** Injectable for unit tests; default spawns a real worker_threads Worker. */ + spawn?: (workerUrl: URL, options: WorkerSpawnOptions) => WorkerLike + /** Override the query-module URL (used by the off-thread integration fixture). */ + queryModuleUrl?: string + /** Hard timeout for a single listing query. Default 15 s (the real query is ~180 ms). */ + timeoutMs?: number +} + +const DEFAULT_TIMEOUT_MS = 15_000 +// import.meta.url ends with `.ts` in dev/test (tsx / native strip-types) and +// `.js` in prod (compiled dist). Resolve siblings with the matching extension. +const SELF_EXT = import.meta.url.endsWith('.ts') ? '.ts' : '.js' +// Append to process.execArgv (do NOT replace) so tsx's `--import .../loader.mjs` +// is inherited in dev; the flag silences node:sqlite's per-spawn ExperimentalWarning. +const WORKER_EXECARGV = [...process.execArgv, '--disable-warning=ExperimentalWarning'] + +function defaultWorkerUrl(): URL { + return new URL(`./opencode-listing.worker${SELF_EXT}`, import.meta.url) +} +function defaultQueryModuleUrl(): string { + return new URL(`./opencode-listing-query${SELF_EXT}`, import.meta.url).href +} +function defaultSpawn(workerUrl: URL, options: WorkerSpawnOptions): WorkerLike { + return new Worker(workerUrl, options) +} + +type OkMessage = { ok: true; rows: OpencodeSessionRow[]; schemaMissingParentId: boolean } +type ErrMessage = { ok: false; error: { name: string; message: string } } + +// Validate the FULL shape, not just the presence of `ok` — a truncated/garbled +// message like `{ ok: true }` must NOT resolve `{ rows: undefined }`. +function isOkMessage(value: unknown): value is OkMessage { + return typeof value === 'object' && value !== null + && (value as { ok?: unknown }).ok === true + && Array.isArray((value as { rows?: unknown }).rows) + && typeof (value as { schemaMissingParentId?: unknown }).schemaMissingParentId === 'boolean' +} +function isErrMessage(value: unknown): value is ErrMessage { + if (typeof value !== 'object' || value === null) return false + if ((value as { ok?: unknown }).ok !== false) return false + const error = (value as { error?: unknown }).error + return typeof error === 'object' && error !== null + && typeof (error as { message?: unknown }).message === 'string' +} + +export function createWorkerListingRunner( + options: CreateWorkerListingRunnerOptions = {}, +): OpencodeListingQueryRunner { + const spawn = options.spawn ?? defaultSpawn + const queryModuleUrl = options.queryModuleUrl ?? defaultQueryModuleUrl() + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const workerUrl = defaultWorkerUrl() + + return (input: OpencodeListingQueryInput): Promise => { + return new Promise((resolve, reject) => { + const worker = spawn(workerUrl, { workerData: { ...input, queryModuleUrl, kind: OPENCODE_LISTING_WORKER_KIND }, execArgv: WORKER_EXECARGV }) + let settled = false + let timer: NodeJS.Timeout | undefined + + const cleanup = () => { + if (timer) clearTimeout(timer) + try { void worker.terminate() } catch { /* ignore */ } + } + const settleResolve = (result: OpencodeListingResult) => { + if (settled) return + settled = true + cleanup() + resolve(result) + } + const settleReject = (err: Error) => { + if (settled) return + settled = true + cleanup() + reject(err) + } + + timer = setTimeout(() => settleReject(new Error(`OpenCode listing worker timed out after ${timeoutMs}ms`)), timeoutMs) + if (typeof (timer as NodeJS.Timeout).unref === 'function') (timer as NodeJS.Timeout).unref() + + worker.on('message', (value: unknown) => { + if (isOkMessage(value)) { + settleResolve({ rows: value.rows, schemaMissingParentId: value.schemaMissingParentId }) + } else if (isErrMessage(value)) { + const err = new Error(value.error.message || 'OpenCode listing worker failed') + err.name = value.error.name ?? 'Error' + settleReject(err) + } else { + settleReject(new Error('OpenCode listing worker sent a malformed message')) + } + }) + worker.on('error', (err: Error) => settleReject(err)) + worker.on('exit', (code: number) => settleReject(new Error(`OpenCode listing worker exited (code ${code}) before responding`))) + }) + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:vitest -- --config vitest.server.config.ts test/unit/server/coding-cli/opencode-listing-runner.test.ts --run` +Expected: PASS (all runner orchestration cases: ok→resolve+terminate, workerData+execArgv, late-exit-after-success, error message, malformed messages, error event, early exit, timeout). + +- [ ] **Step 5: Commit** + +```bash +git add server/coding-cli/providers/opencode-listing-runner.ts test/unit/server/coding-cli/opencode-listing-runner.test.ts +git commit -m "feat(opencode): add worker-backed listing query runner" +``` + +--- + +## Task 4: Wire the provider to the off-thread runner + +**Files:** +- Modify: `server/coding-cli/providers/opencode.ts` +- Modify: `test/unit/server/coding-cli/opencode-provider.test.ts` +- Modify: `test/unit/server/coding-cli/opencode-provider.sqlite.test.ts` + +The provider keeps `missing_db` / `sqlite_unavailable` pre-checks and the async row→session mapping, but delegates open+query to `this.queryRunner`, returning `[]` for absent/empty states and throwing on a worker/read failure. Existing tests inject an **in-process runner** so the `node:sqlite` mock and real-sqlite paths run without a thread. + +- [ ] **Step 1 (Red): Add the in-process runner seam and update existing tests to use it** + +First, define an exported in-process runner so tests (and any non-worker callers) can opt out of the thread. Add to `opencode-listing-runner.ts`: + +```ts +import { runOpencodeListingQuery } from './opencode-listing-query.js' + +/** Runs the listing query on the caller's thread (no worker). For tests and fallbacks. */ +export const inProcessListingRunner: OpencodeListingQueryRunner = (input) => + runOpencodeListingQuery(input.dbPath, input.markerPattern) +``` + +Update `test/unit/server/coding-cli/opencode-provider.sqlite.test.ts` — change construction to inject the in-process runner: + +```ts +import { inProcessListingRunner } from '../../../../server/coding-cli/providers/opencode-listing-runner' +// ... +const provider = new OpencodeProvider(tempDir, { queryRunner: inProcessListingRunner }) +const sessions = await provider.listSessionsDirect() +// (assertions unchanged) +``` + +Update `test/unit/server/coding-cli/opencode-provider.test.ts`: + +- Add the import: + ```ts + import { inProcessListingRunner } from '../../../../server/coding-cli/providers/opencode-listing-runner' + ``` +- Every construction whose test calls `listSessionsDirect()` becomes `new OpencodeProvider(, { queryRunner: inProcessListingRunner })`. Concretely the constructions in: `'lists root sessions from the OpenCode database'` (~L194), `'logs an empty OpenCode database as empty'` (~L268), `'logs OpenCode database read failures distinctly'` (~L243), and `'treats an OpenCode schema without parent_id as flat roots'` (~L381). Tests that only call `resolveOpencodeSessionRoots` (root-mapping ~L311, retry ~L348) or neither (`'watches...'` ~L214, `'logs missing OpenCode database'` ~L224 — `access()` fails before the runner) work either way; injecting everywhere is harmless and consistent, so inject in all of them. +- **Change this test (two intentional changes).** In `'logs OpenCode database read failures distinctly from an empty database'` (~L239–259): + 1. **Return contract:** a read failure now **throws** (preserving the sidebar). Change the body from `await expect(provider.listSessionsDirect()).resolves.toEqual([])` to `await expect(provider.listSessionsDirect()).rejects.toThrow()`. + 2. **Classification:** the open now happens inside the worker, so the main thread can no longer distinguish open/schema/query phases — they collapse to a single `read_error` class. Change the asserted `messageClass` from `'sqlite_open_failed'` to `'read_error'`: + ```ts + expect(loggerMock.warn).toHaveBeenCalledWith(expect.objectContaining({ + provider: 'opencode', + dbPathLabel: '/opencode.db', + dbFile: 'opencode.db', + pathSanitized: true, + errorName: 'Error', + messageClass: 'read_error', + }), 'Failed to read OpenCode sessions database') + ``` + The test's intent is preserved: a read failure is logged **distinctly from `empty_db`**, and the raw error message (`'bad sqlite'`) and paths still must not leak from the **logs** (those `loggerMock.warn` assertions stay). `FakeDatabaseSync.failOpenOnce` makes the constructor throw inside `runOpencodeListingQuery`; the in-process runner rejects; the provider logs `read_error` passing only `{ error }` (so `errorName: 'Error'` is logged but the message is not) and then re-throws — the thrown error's message is not a log, so the no-leak assertions are unaffected. + +Add a new behavior test to `opencode-provider.test.ts` asserting the provider **throws** (preserving the sidebar via the indexer's no-prune-on-throw) when the injected runner rejects, and logs `read_error`: + +```ts +it('throws (does not return []) when the listing runner fails, and logs read_error', async () => { + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-fail-')) + await fsp.writeFile(path.join(dir, 'opencode.db'), '') // make access() succeed + const failingRunner = vi.fn().mockRejectedValue(new Error('worker exploded')) + const provider = new OpencodeProvider(dir, { queryRunner: failingRunner }) + await expect(provider.listSessionsDirect()).rejects.toThrow('worker exploded') + expect(failingRunner).toHaveBeenCalledOnce() + expect(loggerMock.warn).toHaveBeenCalledWith( + expect.objectContaining({ messageClass: 'read_error' }), + 'Failed to read OpenCode sessions database', + ) + await fsp.rm(dir, { recursive: true, force: true }) +}) + +it('still returns [] (not throw) for a genuinely empty database', async () => { + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-empty-')) + await fsp.writeFile(path.join(dir, 'opencode.db'), '') + const emptyRunner = vi.fn().mockResolvedValue({ rows: [], schemaMissingParentId: false }) + const provider = new OpencodeProvider(dir, { queryRunner: emptyRunner }) + await expect(provider.listSessionsDirect()).resolves.toEqual([]) + await fsp.rm(dir, { recursive: true, force: true }) +}) +``` + +> The in-provider single-flight coalescing considered in an earlier draft was **dropped** (YAGNI): the load-bearing pass confirmed `listSessionsDirect`'s only production caller is `refreshDirectProvider`, and the indexer's `refreshInFlight` gate already serializes every production call — so coalescing in the provider is provably unreachable in production. Less surface, fewer failure modes. + +> Note: the mock test file calls `vi.mock('node:sqlite', () => ({ DatabaseSync: FakeDatabaseSync }))`, which replaces `node:sqlite` for the whole module graph of that test file — including the lazily-imported `node:sqlite` inside `opencode-listing-query.ts`. So `inProcessListingRunner` → `runOpencodeListingQuery` → `await import('node:sqlite')` → `new DatabaseSync(dbPath, { readOnly: true })` resolves to `FakeDatabaseSync`. The fake's `exec()` (no-op) satisfies the `PRAGMA busy_timeout`, its `prepare('PRAGMA table_info(session)').all()` returns the column list (so `hasParentId` resolves), and its listing-query `all()` returns seeded rows without a `hasThreeViewsMarker` field (so `isSubagent`/`isNonInteractive` stay undefined — matching the existing assertions). The lazy import is essential: a static `import node:sqlite` would trip `vi.mock` hoisting (TDZ) — see the Lazy import note in the shared contract section. + +- [ ] **Step 2 (Red): Run the existing + new provider tests — expect failures** + +Run: `npm run test:vitest -- --config vitest.server.config.ts test/unit/server/coding-cli/opencode-provider.test.ts test/unit/server/coding-cli/opencode-provider.sqlite.test.ts --run` +Expected: FAIL — `OpencodeProvider` constructor does not accept a second `{ queryRunner }` argument; `listSessionsDirect` ignores it. + +- [ ] **Step 3 (Green): Implement the provider wiring** + +Edit `server/coding-cli/providers/opencode.ts`: + +1. Replace the local `OpencodeSessionRow` type and `THREE_VIEWS_MARKER_SQL_PATTERN` with imports from the query module (re-export for any external importers): + +```ts +import { createWorkerListingRunner, type OpencodeListingQueryRunner } from './opencode-listing-runner.js' +import { THREE_VIEWS_MARKER_SQL_PATTERN, type OpencodeSessionRow } from './opencode-listing-query.js' +export { THREE_VIEWS_MARKER_SQL_PATTERN } from './opencode-listing-query.js' +export type { OpencodeSessionRow } from './opencode-listing-query.js' +``` + +2. Add the constructor option: + +```ts +export type OpencodeProviderOptions = { queryRunner?: OpencodeListingQueryRunner } + +export class OpencodeProvider implements CodingCliProvider { + // ...existing fields... + private readonly queryRunner: OpencodeListingQueryRunner + + constructor( + readonly homeDir: string = defaultOpencodeDataHome(), + options: OpencodeProviderOptions = {}, + ) { + this.queryRunner = options.queryRunner ?? createWorkerListingRunner() + } +``` + +3. Rewrite `listSessionsDirect()` to keep pre-checks + mapping and delegate the query. Return `[]` for genuinely-absent/empty states; **throw** on a worker/read failure so the indexer preserves the prior sidebar (see Failure contract above): + +```ts + async listSessionsDirect(): Promise { + const dbPath = this.getDatabasePath() + try { + await fsp.access(dbPath) + } catch { + this.logDatabaseStateOnce('info', 'missing_db', 'OpenCode sessions database is not available') + return [] + } + + try { + await import('node:sqlite') + } catch (err) { + this.logDatabaseStateOnce('warn', 'sqlite_unavailable', 'node:sqlite unavailable — OpenCode sessions will not appear. Upgrade to Node 22.5+ to enable.', { + error: err, + extra: { nodeVersion: process.version }, + }) + return [] + } + + let result + try { + result = await this.queryRunner({ dbPath, markerPattern: THREE_VIEWS_MARKER_SQL_PATTERN }) + } catch (err) { + // A worker/read failure is transient infrastructure failure, NOT "no sessions". + // Log once and re-throw: refreshDirectProvider catches this and returns early + // WITHOUT pruning, preserving the previously-listed OpenCode sessions. Returning + // [] here would make the indexer prune the entire OpenCode sidebar. + this.logDatabaseStateOnce('warn', 'read_error', 'Failed to read OpenCode sessions database', { error: err }) + throw err + } + + if (result.schemaMissingParentId) { + this.logDatabaseStateOnce('warn', 'schema_missing_parent_id', 'OpenCode session schema does not expose parent_id; treating sessions as flat roots') + } + if (result.rows.length === 0) { + this.logDatabaseStateOnce('info', 'empty_db', 'OpenCode sessions database has no active root sessions', { extra: { rowCount: 0 } }) + } + + const sessions: CodingCliSession[] = [] + for (const row of result.rows) { + if (typeof row.cwd !== 'string' || !row.cwd) continue + const projectPath = row.projectPath || await resolveGitRepoRoot(row.cwd) + const isThreeViewsSession = toSqliteBoolean(row.hasThreeViewsMarker) + sessions.push({ + provider: this.name, + sessionId: row.sessionId, + projectPath, + cwd: row.cwd, + title: typeof row.title === 'string' ? row.title : undefined, + lastActivityAt: toValidTimestamp(row.lastActivityAt) ?? Date.now(), + createdAt: toValidTimestamp(row.createdAt), + isSubagent: isThreeViewsSession || undefined, + isNonInteractive: isThreeViewsSession || undefined, + }) + } + return sessions + } +``` + +4. Delete the now-unused inline open/schema/SELECT in the old `listSessionsDirect` body and the local `OpencodeSessionRow` type declaration. Keep `inspectSessionSchema`, `sessionSchemaCache`, `configureReadOnlyDatabase`, and `resolveOpencodeSessionRoots` (still used by root resolution). Keep `toSqliteBoolean`, `toValidTimestamp`, `OPENCODE_DB_BUSY_TIMEOUT_MS` (root resolution uses the busy timeout). If `THREE_VIEWS_MARKER_SQL_PATTERN`/`toSqliteBoolean` become referenced only here, that's fine. + +> The module singleton `export const opencodeProvider = new OpencodeProvider()` now defaults to the worker runner. No change needed at the call site in `server/index.ts`. + +- [ ] **Step 4 (Green): Run the provider tests** + +Run: `npm run test:vitest -- --config vitest.server.config.ts test/unit/server/coding-cli/opencode-provider.test.ts test/unit/server/coding-cli/opencode-provider.sqlite.test.ts --run` +Expected: PASS (existing assertions + new throw-on-failure and empty-db tests). + +- [ ] **Step 5: Typecheck** + +Run: `npm run typecheck:server` +Expected: PASS (no unused-symbol or type errors). + +- [ ] **Step 6: Commit** + +```bash +git add server/coding-cli/providers/opencode.ts test/unit/server/coding-cli/opencode-provider.test.ts test/unit/server/coding-cli/opencode-provider.sqlite.test.ts server/coding-cli/providers/opencode-listing-runner.ts +git commit -m "feat(opencode): run session listing off the event loop via worker" +``` + +--- + +## Task 4b: Preserve the sidebar when a direct provider fails (indexer) + +**Files:** +- Modify: `server/coding-cli/session-indexer.ts` (`refreshDirectProvider`) +- Modify: `test/unit/server/coding-cli/session-indexer-provider-refresh.test.ts` + +`listSessionsDirect()` now throws on failure (Task 4). For that to preserve the OpenCode sidebar on a **full scan** (not just incremental), `refreshDirectProvider` must, on failure, return the provider's existing direct-cache keys (so the global prune keeps them) and stop logging the raw error. + +- [ ] **Step 1: Write the failing regression test** + +Add to `test/unit/server/coding-cli/session-indexer-provider-refresh.test.ts`. Mock the logger so we can assert the catch no longer warns with the raw error, and drive a second **full scan** by changing `enabledProviders` (an enabled-set change forces `needsFullScan`), which avoids needing watchers/timers: + +```ts +// add near the top, after the existing config-store mock: +const loggerMock = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn(), child: vi.fn() })) +loggerMock.child.mockReturnValue(loggerMock) +vi.mock('../../../../server/logger', () => ({ logger: loggerMock, sessionLifecycleLogger: loggerMock })) + +import { configStore } from '../../../../server/config-store' +``` + +```ts +it('preserves cached direct-provider sessions (and does not warn-log the raw error) when listSessionsDirect throws during a full scan', async () => { + vi.useRealTimers() // this test drives refresh() directly, no timers needed + loggerMock.warn.mockClear() + // First refresh: opencode enabled. Second refresh: enabled-set changes + // (add 'claude') -> enabledKey changes -> needsFullScan -> full-scan path. + vi.mocked(configStore.snapshot) + .mockResolvedValueOnce({ settings: { codingCli: { enabledProviders: ['opencode'], providers: {} } } } as never) + .mockResolvedValueOnce({ settings: { codingCli: { enabledProviders: ['opencode', 'claude'], providers: {} } } } as never) + + const sessions = [{ provider: 'opencode', sessionId: 's1', projectPath: '/repo', cwd: '/repo', lastActivityAt: 2000, createdAt: 1000 }] + const listSessionsDirect = vi.fn() + .mockResolvedValueOnce(sessions) // full scan #1 succeeds -> s1 cached + .mockRejectedValue(new Error('worker exploded at /secret/path')) // full scan #2 throws + const provider = { ...makeDirectProvider(), listSessionsDirect } + const indexer = new CodingCliSessionIndexer([provider]) + + await indexer.refresh() // full scan #1 (needsFullScan defaults true) + expect(indexer.getProjects().flatMap((g) => g.sessions).map((s) => s.sessionId)).toEqual(['s1']) + + await indexer.refresh() // full scan #2 (enabled-set changed) — listSessionsDirect throws + // The cached session must survive the global full-scan prune. + expect(indexer.getProjects().flatMap((g) => g.sessions).map((s) => s.sessionId)).toEqual(['s1']) + + // The catch must NOT warn-log the failure (it logs debug now), and NO warn/debug + // payload may carry a raw `err`/Error. NOTE: do NOT assert via JSON.stringify — + // Error objects serialize to "{}", so a leaked `{ err: new Error('/secret/path') }` + // would pass a string check. Inspect the call args STRUCTURALLY. + expect(loggerMock.warn).not.toHaveBeenCalledWith(expect.anything(), 'Could not list provider sessions directly') + const allLogCalls = [...loggerMock.warn.mock.calls, ...loggerMock.debug.mock.calls] + for (const [payload] of allLogCalls) { + if (payload && typeof payload === 'object') { + expect(Object.prototype.hasOwnProperty.call(payload, 'err')).toBe(false) + expect(Object.values(payload).some((v) => v instanceof Error)).toBe(false) + } + } + // Assert the exact intended debug call shape (provider only, no error). + expect(loggerMock.debug).toHaveBeenCalledWith( + { provider: 'opencode' }, + 'Direct provider listing failed; preserving cached sessions', + ) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npm run test:vitest -- --config vitest.server.config.ts test/unit/server/coding-cli/session-indexer-provider-refresh.test.ts --run` +Expected: FAIL — after the second (full-scan) refresh, `getProjects()` is empty (the global prune deleted `s1`), and/or `loggerMock.warn` was called with the raw error. + +- [ ] **Step 3: Implement the indexer fix** + +In `server/coding-cli/session-indexer.ts`, change the `refreshDirectProvider` catch block (currently ~lines 918–923): + +```ts + try { + sessions = await provider.listSessionsDirect() + } catch (err) { + // A direct-listing failure is transient (e.g. the off-thread worker failed), + // NOT "no sessions". Preserve this provider's existing direct-cache entries so + // neither the local prune (below) nor the full-scan global prune deletes them. + // Log at debug with only the provider name — the provider already emitted a + // sanitized one-time detail via logDatabaseStateOnce, and logging `err` here + // would re-leak paths/messages and spam once per failed refresh. + logger.debug({ provider: provider.name }, 'Direct provider listing failed; preserving cached sessions') + for (const cacheKey of this.fileCache.keys()) { + if (this.isDirectCacheKey(cacheKey) && this.fileCache.get(cacheKey)?.provider === provider.name) { + seenKeys.add(cacheKey) + } + } + return seenKeys + } +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npm run test:vitest -- --config vitest.server.config.ts test/unit/server/coding-cli/session-indexer-provider-refresh.test.ts --run` +Expected: PASS (existing coalescing test + new preservation test). + +- [ ] **Step 5: Commit** + +```bash +git add server/coding-cli/session-indexer.ts test/unit/server/coding-cli/session-indexer-provider-refresh.test.ts +git commit -m "fix(indexer): preserve direct-provider sessions on listing failure (full scan + incremental)" +``` + +--- + +## Task 5: Integration test — real worker proves off-thread + non-blocking + +**Files:** +- Create: `test/integration/server/fixtures/slow-opencode-listing-query.ts` +- Create: `test/integration/server/opencode-listing-offthread.test.ts` + +This is the user-story test: it spawns the **real** worker via the default runner and proves (a) it returns the same rows as the synchronous baseline, and (b) the main event loop keeps ticking while the worker runs a deliberately slow query. + +- [ ] **Step 1: Write the slow fixture query module** + +```ts +// test/integration/server/fixtures/slow-opencode-listing-query.ts +// Drop-in replacement for opencode-listing-query's runOpencodeListingQuery that +// blocks its OWN (worker) thread for a fixed duration, then returns known rows. +// Used to prove the main event loop is not blocked while the worker runs. +// (Returns synchronously; the worker awaits it, so a non-Promise return is fine.) +import type { OpencodeListingResult } from '../../../../server/coding-cli/providers/opencode-listing-query.js' + +const SLEEP_MS = 250 + +export function runOpencodeListingQuery(_dbPath: string, _markerPattern: string): OpencodeListingResult { + const end = Date.now() + SLEEP_MS + while (Date.now() < end) { /* busy-block this worker thread */ } + return { + rows: [{ sessionId: 'slow-1', cwd: '/repo/root', title: 'Slow', createdAt: 1000, lastActivityAt: 2000, projectPath: '/repo/root', hasThreeViewsMarker: 0 }], + schemaMissingParentId: false, + } +} +``` + +- [ ] **Step 2: Write the failing integration test** + +```ts +// test/integration/server/opencode-listing-offthread.test.ts +import path from 'path' +import os from 'os' +import fsp from 'fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createWorkerListingRunner } from '../../../server/coding-cli/providers/opencode-listing-runner' +import { runOpencodeListingQuery, THREE_VIEWS_MARKER_SQL_PATTERN } from '../../../server/coding-cli/providers/opencode-listing-query' + +vi.unmock('node:sqlite') + +const threeViewsMarker = '' +// Exact (.ts in dev/test) URL of the slow fixture — same resolution strategy as the runner. +const slowQueryModuleUrl = new URL('./fixtures/slow-opencode-listing-query.ts', import.meta.url).href + +describe('OpenCode listing off-thread (real worker)', () => { + let tempDir: string + beforeEach(async () => { tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-offthread-')) }) + afterEach(async () => { await fsp.rm(tempDir, { recursive: true, force: true }) }) + + it('returns the same rows as the synchronous baseline', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(dbPath) + try { + db.exec(` + CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, sandboxes text NOT NULL); + CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, parent_id text, slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer); + CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + `) + db.prepare(`INSERT INTO project VALUES (?, ?, ?, ?, ?)`).run('project-1', '/repo/root', 900, 4000, '[]') + db.prepare(`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?,?,?,?)`) + .run('marked', 'project-1', null, 'marked', '/repo/root', 'Marked', 'test', 1000, 3000, null) + db.prepare(`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?,?,?,?)`) + .run('plain', 'project-1', null, 'plain', '/repo/root', 'Plain', 'test', 1000, 2000, null) + db.prepare(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?)`).run('m1', 'marked', 1100, 1100, JSON.stringify({ role: 'user', text: threeViewsMarker })) + db.prepare(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?)`).run('m2', 'plain', 1100, 1100, JSON.stringify({ role: 'user', text: 'ordinary' })) + } finally { + db.close() + } + + const runner = createWorkerListingRunner() + const result = await runner({ dbPath, markerPattern: THREE_VIEWS_MARKER_SQL_PATTERN }) + const baseline = await runOpencodeListingQuery(dbPath, THREE_VIEWS_MARKER_SQL_PATTERN) + expect(result.rows).toEqual(baseline.rows) + expect(result.rows.map((r) => [r.sessionId, !!r.hasThreeViewsMarker])).toEqual([['marked', true], ['plain', false]]) + }) + + it('does not block the event loop while the worker runs a slow query', async () => { + const runner = createWorkerListingRunner({ queryModuleUrl: slowQueryModuleUrl }) + let ticks = 0 + const interval = setInterval(() => { ticks += 1 }, 10) + try { + const result = await runner({ dbPath: path.join(tempDir, 'unused.db'), markerPattern: THREE_VIEWS_MARKER_SQL_PATTERN }) + expect(result.rows.map((r) => r.sessionId)).toEqual(['slow-1']) + // The worker busy-blocks its OWN thread for ~250 ms. If the main loop were + // blocked, the interval could not fire. We expect many ticks (>= ~10). + expect(ticks).toBeGreaterThanOrEqual(10) + } finally { + clearInterval(interval) + } + }) +}) +``` + +- [ ] **Step 3: Run to verify it fails, then passes** + +Run: `npm run test:vitest -- --config vitest.server.config.ts test/integration/server/opencode-listing-offthread.test.ts --run` +Expected before Tasks 1–3 exist: FAIL (imports unresolved). After Tasks 1–4: PASS (2 tests). The non-blocking test confirms the main loop ticked ≥ 10 times during a 250 ms worker query. + +> If this test reveals the real worker cannot load in the vitest runtime on some machine (e.g. Node < 22.18 without native strip-types), that is a genuine failure of the production path on that runtime — do not paper over it. The spike validated Node 22.21; if CI uses an older Node, gate the real-worker test behind a `node:sqlite`-availability + Node-version check and add a `tsx`-run smoke (`scripts/`) instead. Record the decision in the PR. + +- [ ] **Step 4: Commit** + +```bash +git add test/integration/server/opencode-listing-offthread.test.ts test/integration/server/fixtures/slow-opencode-listing-query.ts +git commit -m "test(opencode): integration coverage for off-thread listing (correctness + non-blocking)" +``` + +--- + +## Task 5b: Server-side discovery proof (real worker through provider + indexer) + +**Files:** +- Create: `test/integration/server/opencode-listing-discovery.test.ts` + +Task 5 spawns the worker via the runner in isolation. This proves the **production discovery path** — the real `OpencodeProvider` (DEFAULT worker runner, no injection) feeding the real `CodingCliSessionIndexer`, which is the source of the `/api/sessions` project state — actually runs the off-thread worker and surfaces sessions with correct marker classification. This is the meaningful "session discovery populated from a DB after refresh" gate (the existing browser e2e does NOT exercise `listSessionsDirect`). + +- [ ] **Step 1: Write the test** + +```ts +// test/integration/server/opencode-listing-discovery.test.ts +import path from 'path' +import os from 'os' +import fsp from 'fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.unmock('node:sqlite') +vi.mock('../../../server/config-store', () => ({ + configStore: { + getProjectColors: vi.fn().mockResolvedValue({}), + snapshot: vi.fn().mockResolvedValue({ settings: { codingCli: { enabledProviders: ['opencode'], providers: {} } } }), + }, +})) + +import { OpencodeProvider } from '../../../server/coding-cli/providers/opencode' +import { CodingCliSessionIndexer } from '../../../server/coding-cli/session-indexer.js' + +const marker = '' + +describe('OpenCode discovery via the off-thread worker (provider + indexer)', () => { + let home: string + beforeEach(async () => { home = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-discovery-')) }) + afterEach(async () => { await fsp.rm(home, { recursive: true, force: true }) }) + + it('surfaces DB sessions (with marker→isSubagent) through a full refresh using the real worker', async () => { + const dbPath = path.join(home, 'opencode.db') + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(dbPath) + try { + db.exec(` + CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, sandboxes text NOT NULL); + CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, parent_id text, slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer); + CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + `) + db.prepare(`INSERT INTO project VALUES (?, ?, ?, ?, ?)`).run('p', '/repo/root', 900, 4000, '[]') + db.prepare(`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?,?,?,?)`) + .run('marked', 'p', null, 'marked', '/repo/root', 'Marked', 'v', 1000, 3000, null) + db.prepare(`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?,?,?,?)`) + .run('normal', 'p', null, 'normal', '/repo/root', 'Normal', 'v', 1000, 2000, null) + db.prepare(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?)`).run('m1', 'marked', 1100, 1100, JSON.stringify({ role: 'user', text: marker })) + db.prepare(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?)`).run('m2', 'normal', 1100, 1100, JSON.stringify({ role: 'user', text: 'ordinary' })) + } finally { + db.close() + } + + // DEFAULT runner = the real off-thread worker (no injection). + const provider = new OpencodeProvider(home) + const indexer = new CodingCliSessionIndexer([provider]) + await indexer.refresh() + + const sessions = indexer.getProjects().flatMap((g) => g.sessions) + const marked = sessions.find((s) => s.sessionId === 'marked') + const normal = sessions.find((s) => s.sessionId === 'normal') + expect(marked).toBeDefined() + expect(marked?.isSubagent).toBe(true) + expect(marked?.isNonInteractive).toBe(true) + expect(normal).toBeDefined() + expect(normal?.isSubagent).toBeUndefined() + }) +}) +``` + +- [ ] **Step 2: Run it** + +Run: `npm run test:vitest -- --config vitest.server.config.ts test/integration/server/opencode-listing-discovery.test.ts --run` +Expected before Tasks 1–4: FAIL (imports/behavior). After Tasks 1–4: PASS — the real worker spawned by the default runner discovered both sessions and classified the marker one as subagent, end-to-end through the indexer. + +- [ ] **Step 3: Commit** + +```bash +git add test/integration/server/opencode-listing-discovery.test.ts +git commit -m "test(opencode): prove off-thread worker discovery through provider + indexer" +``` + +--- + +## Task 6: Production-path verification & full suite + +**Files:** none (verification only) + +- [ ] **Step 1: Typecheck the whole server** + +Run: `npm run typecheck:server` +Expected: PASS. + +- [ ] **Step 2: Build the server and verify the worker compiled to dist** + +Run (from this worktree, which is safe to build — not the live-served checkout): +```bash +npm run build:server +ls dist/server/coding-cli/providers/opencode-listing.worker.js dist/server/coding-cli/providers/opencode-listing-query.js dist/server/coding-cli/providers/opencode-listing-runner.js +``` +Expected: all three `.js` files exist (confirms `tsc` compiled the new files; the prod extension-swap will resolve them). + +- [ ] **Step 3: Prove the compiled (prod) worker path runs against a real DB** + +Write the check to a file (reused verbatim by Step 3b), then run it with the host `node` (prod resolution — `import.meta.url` ends in `.js`): +```bash +cat > /tmp/oc-prod-check.mjs <<'EOF' +import os from 'os'; import path from 'path'; import fsp from 'fs/promises'; +import { pathToFileURL } from 'url'; +// Resolve dist absolutely from the cwd (where `node` is invoked = the worktree +// root). A relative './dist' import would resolve against /tmp where this file +// lives, not the repo. Run this from the worktree root. +const distBase = pathToFileURL(path.join(process.cwd(), 'dist', 'server', 'coding-cli', 'providers') + path.sep).href; +const { createWorkerListingRunner } = await import(distBase + 'opencode-listing-runner.js'); +const { THREE_VIEWS_MARKER_SQL_PATTERN } = await import(distBase + 'opencode-listing-query.js'); +const { DatabaseSync } = await import('node:sqlite'); +const dir = await fsp.mkdtemp(path.join(os.tmpdir(),'oc-prod-')); const dbPath = path.join(dir,'opencode.db'); +const db = new DatabaseSync(dbPath); +db.exec('CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, sandboxes text NOT NULL); CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, parent_id text, slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer); CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL);'); +db.prepare('INSERT INTO project VALUES (?,?,?,?,?)').run('p','/repo',900,4000,'[]'); +db.prepare('INSERT INTO session (id,project_id,parent_id,slug,directory,title,version,time_created,time_updated,time_archived) VALUES (?,?,?,?,?,?,?,?,?,?)').run('s','p',null,'s','/repo','T','v',1000,2000,null); +db.close(); +const runner = createWorkerListingRunner(); +const r = await runner({ dbPath, markerPattern: THREE_VIEWS_MARKER_SQL_PATTERN }); +console.log('PROD_WORKER_OK', JSON.stringify(r.rows.map(x=>x.sessionId))); +await fsp.rm(dir,{recursive:true,force:true}); +EOF +node /tmp/oc-prod-check.mjs +``` +Expected: `PROD_WORKER_OK ["s"]` (proves the compiled worker spawns and resolves `.js` siblings under plain `node`). + +- [ ] **Step 3b (optional): Prove the Electron bundled Node (v22.12.0) spawns the worker** + +The desktop app runs the compiled server under a bundled standalone Node (v22.12.0, `scripts/bundled-node-version.json`), not the host Node. If a bundled binary is present locally, run the **same** `/tmp/oc-prod-check.mjs` from Step 3 with it: +```bash +BN=$(find . node_modules -path '*bundled-node*/bin/node' -type f 2>/dev/null | head -1) +if [ -n "$BN" ]; then echo "bundled node: $("$BN" --version)"; "$BN" /tmp/oc-prod-check.mjs; else echo "no bundled node locally — skip (covered by 22.12>=22.5 floor + extraResources)"; fi +``` +Expected: `PROD_WORKER_OK ["s"]` under v22.12.0, or a clean skip. This is belt-and-suspenders — `node:sqlite` (≥22.5) and `worker_threads` are guaranteed present in 22.12.0, the binary is byte-for-byte from nodejs.org, and the throw-on-failure contract degrades gracefully if it ever regressed. + +- [ ] **Step 4: Run the full coordinated suite** + +Run: `FRESHELL_TEST_SUMMARY="opencode off-thread listing worker" npm test` +Expected: green (default + server configs). Investigate any failure; do not proceed with a red suite. + +- [ ] **Step 4b: OpenCode browser e2e regression guard** + +Where coverage actually lives (don't overclaim the browser spec): +- **Worker discovery** (the changed behavior — a DB session surfacing via `listSessionsDirect`→worker into the project state that feeds `/api/sessions`) is proven by **Task 5b** (real worker through provider+indexer) and **Task 5** (real worker correctness + non-blocking). **Marker detection** is proven by Task 1 + Task 5 against the real schema. The browser `fake-opencode.cjs` fixture has only `project`+`session` (no `part`/`message`), so it does NOT exercise marker detection. +- The browser recovery spec is a **regression guard** for the UI recovery/`sessionRef` flow. It does NOT assert the worker's output (OpenCode sidebar sessions originate from `listSessionsDirect`, but the spec checks tab recovery, not the sidebar list), so it is not the worker-discovery proof — Task 5b is. Run it to confirm the off-thread change doesn't regress recovery: +```bash +npm run test:e2e -- specs/opencode-restart-recovery.spec.ts +``` +Expected: PASS. (Do **not** cite `fresh-agent.spec.ts` — it only toggles client harness state and checks pane-picker visibility; it does not exercise `listSessionsDirect`.) + +> Behavior-change to watch: before this change, `listSessionsDirect` threw against the fixture (no `part` table) and contributed nothing; now (Task 1 robustness) it returns the fixture's root session (unmarked), deduped by `makeSessionKey(provider, sessionId)`. If the recovery spec's assertions shift because the root session now also arrives via the direct path, reconcile them; do not weaken the spec. +> If the e2e harness is unavailable in this environment, record that and ensure CI runs it; do not silently skip. + +- [ ] **Step 5: Commit any incidental fixes, then update kata** + +If Step 4 surfaced fixes, commit them. Then mark kata `wab5` in-progress/closed with a pointer to the branch/PR (kata is local-first, not a code change): +```bash +kata comment wab5 -m "Implemented off-thread listing worker on branch perf/opencode-marker-cache (plan: docs/superpowers/plans/2026-06-04-opencode-listing-offthread-worker.md). Validated dev(tsx)/prod(dist)/test(vitest) runtimes." +``` + +--- + +## Self-Review + +**1. Spec coverage** +- "Run the query off the event loop" → Tasks 2–4 (worker entry, runner, provider wiring); Task 5 proves non-blocking. +- "Preserve `isSubagent`/`isNonInteractive` from the marker, titles, ordering, archived/child filtering, missing_db/sqlite_unavailable/empty_db logging" → Task 1 (query correctness), Task 4 (mapping + pre-checks preserved), existing sqlite/mock tests retained. +- "Don't block the loop on a large/cold scan" → Task 5 non-blocking test. +- "No build-step change / works in dev, prod, test" → validated constraints §1–7; Task 6 builds dist and runs the compiled worker. +- "Failure contract: `[]` for absent/empty, **throw** on worker/read failure" → Task 4 throw-on-failure + empty-db tests. +- "Throw actually preserves the sidebar on BOTH incremental and full-scan refresh, with no error re-leak/spam" → **Task 4b** indexer fix + full-scan preservation regression test (cross-checked against `refreshDirectProvider` + `performRefresh` global prune). +- "Malformed worker message rejects (not resolve-undefined)" → Task 3 `isOkMessage`/`isErrMessage` + malformed-message `it.each`. +- "Worker auto-run is import-safe under Vitest's thread pool" → Task 2 sentinel guard (`OPENCODE_LISTING_WORKER_KIND`) + the import-safety test; runner injects the sentinel (Task 3). +- "Query degrades to unmarked (no throw) on a schema without `part`/`message`" → Task 1 missing-tables test. +- "The real worker runs through the PRODUCTION discovery path (provider default runner → indexer → project state that feeds `/api/sessions`)" → **Task 5b** (asserts a DB session, marker→isSubagent, surfaces after a refresh using the real worker). +- "e2e coverage (AGENTS.md)" → worker discovery is proven by Task 5b + Task 5 (server-side, real worker). Task 6 Step 4b runs the browser recovery spec as a regression guard for the UI recovery flow (it does not itself assert the worker output). Marker detection is covered by Task 1 + Task 5 against the real schema (the browser fixture lacks `part`/`message`). + +**2. Placeholder scan** — No TBD/TODO/"add error handling"; every code step is complete and runnable. The prod-path check is written to `/tmp/oc-prod-check.mjs` and reused by Step 3 and Step 3b (no `` placeholder). `node:sqlite` is imported **lazily** in the query module (required for `vi.mock` compatibility — see the Lazy import note). + +**3. Type consistency** — `OpencodeSessionRow`, `OpencodeListingResult`, `OpencodeListingQueryInput`, `OpencodeListingQueryRunner`, `WorkerSpawnOptions`, `WorkerListingInput`, message shape `{ ok, rows, schemaMissingParentId } | { ok, error }` are used identically across Tasks 1–5. `runOpencodeListingQuery(dbPath, markerPattern): Promise` (async, lazy `node:sqlite`) is awaited everywhere it is called (query test, worker, in-process runner, integration baseline). The runner's injectable `spawn(url, { workerData, execArgv })` signature is identical in the unit test fake and `defaultSpawn`. The provider's `queryRunner` is typed `OpencodeListingQueryRunner` and returns `OpencodeListingResult`, mapped to `CodingCliSession[]`. + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-06-04-opencode-listing-offthread-worker.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks. + +**2. Inline Execution** — execute tasks in this session with checkpoints. From 8f43a9f20044a62fd86641a73a39b40206580508 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 4 Jun 2026 09:57:53 -0700 Subject: [PATCH 2/2] perf(opencode): run session listing off the event loop in a worker OpencodeProvider.listSessionsDirect() ran a ~180ms / ~432MB synchronous node:sqlite scan (the hasThreeViewsMarker LIKE over part.data+message.data) on the shared event loop every ~7s while OpenCode is active, freezing every terminal and pane under multi-agent load. Move the blocking query into a worker_thread so it never blocks the loop. - Extract the synchronous DB work into a pure query module (opencode-listing-query.ts) with a lazy node:sqlite import and per-table marker detection. - Add a sentinel-guarded worker entry (opencode-listing.worker.ts). The main thread loads it by exact sibling URL (.ts in dev/test, .js in prod) to dodge NodeNext's broken .js->.ts module remap inside workers. - Add a spawn-per-call, shape-validated, timeout-bounded worker runner (opencode-listing-runner.ts) plus an in-process runner for tests. - Provider returns [] for an absent/empty DB but THROWS on read failure, so the indexer never prunes the sidebar on a transient worker hiccup. - Indexer preserves cached direct-provider sessions on listing failure (full scan + incremental paths). Covered by unit tests (query/runner/worker) and integration tests that spawn the real worker, prove the loop stays responsive while it runs, and exercise the compiled .js worker path. The TS-worker test guard uses process.features.typescript as a capability probe instead of version math. Co-Authored-By: Claude Opus 4.8 --- .../providers/opencode-listing-query.ts | 84 +++++++++ .../providers/opencode-listing-runner.ts | 119 ++++++++++++ .../providers/opencode-listing.worker.ts | 44 +++++ server/coding-cli/providers/opencode.ts | 139 ++++++-------- server/coding-cli/session-indexer.ts | 15 +- .../fixtures/slow-opencode-listing-query.ts | 16 ++ .../server/fixtures/ts-worker-support.ts | 18 ++ .../opencode-listing-compiled-worker.test.ts | 57 ++++++ .../server/opencode-listing-discovery.test.ts | 63 +++++++ .../server/opencode-listing-offthread.test.ts | 66 +++++++ .../coding-cli/opencode-listing-query.test.ts | 178 ++++++++++++++++++ .../opencode-listing-runner.test.ts | 129 +++++++++++++ .../opencode-listing-worker.test.ts | 51 +++++ .../opencode-provider.sqlite.test.ts | 3 +- .../coding-cli/opencode-provider.test.ts | 47 ++++- .../session-indexer-provider-refresh.test.ts | 49 +++++ 16 files changed, 979 insertions(+), 99 deletions(-) create mode 100644 server/coding-cli/providers/opencode-listing-query.ts create mode 100644 server/coding-cli/providers/opencode-listing-runner.ts create mode 100644 server/coding-cli/providers/opencode-listing.worker.ts create mode 100644 test/integration/server/fixtures/slow-opencode-listing-query.ts create mode 100644 test/integration/server/fixtures/ts-worker-support.ts create mode 100644 test/integration/server/opencode-listing-compiled-worker.test.ts create mode 100644 test/integration/server/opencode-listing-discovery.test.ts create mode 100644 test/integration/server/opencode-listing-offthread.test.ts create mode 100644 test/unit/server/coding-cli/opencode-listing-query.test.ts create mode 100644 test/unit/server/coding-cli/opencode-listing-runner.test.ts create mode 100644 test/unit/server/coding-cli/opencode-listing-worker.test.ts diff --git a/server/coding-cli/providers/opencode-listing-query.ts b/server/coding-cli/providers/opencode-listing-query.ts new file mode 100644 index 00000000..0fef77dc --- /dev/null +++ b/server/coding-cli/providers/opencode-listing-query.ts @@ -0,0 +1,84 @@ +export type OpencodeSessionRow = { + sessionId: string + cwd: string + title: string + createdAt: number + lastActivityAt: number + projectPath: string | null + hasThreeViewsMarker?: number | null +} + +export type OpencodeListingResult = { + rows: OpencodeSessionRow[] + schemaMissingParentId: boolean +} + +export const THREE_VIEWS_MARKER_SQL_PATTERN = '% { + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(dbPath, { readOnly: true }) + try { + db.exec(`PRAGMA busy_timeout = ${OPENCODE_DB_BUSY_TIMEOUT_MS}`) + const columns = db.prepare('PRAGMA table_info(session)').all() as Array<{ name?: unknown }> + const hasParentId = columns.some((c) => c.name === 'parent_id') + const rootFilter = hasParentId ? 'AND s.parent_id IS NULL' : '' + // The 3-views marker lives in part.data and/or message.data. Older/partial + // schemas (and the e2e fake-opencode fixture, which has only project+session) + // may lack one or both tables; build the marker check from whichever tables + // are present (the marker can live in either), and degrade to "unmarked" if + // neither exists — instead of throwing "no such table: part". + const tableNames = new Set( + (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as Array<{ name?: unknown }>) + .map((row) => row.name), + ) + const markerClauses: string[] = [] + const markerParams: string[] = [] + if (tableNames.has('part')) { + markerClauses.push('EXISTS (SELECT 1 FROM part pa WHERE pa.session_id = s.id AND pa.data LIKE ?)') + markerParams.push(markerPattern) + } + if (tableNames.has('message')) { + markerClauses.push('EXISTS (SELECT 1 FROM message m WHERE m.session_id = s.id AND m.data LIKE ?)') + markerParams.push(markerPattern) + } + const markerExpr = markerClauses.length > 0 ? `(${markerClauses.join(' OR ')})` : '0' + const rows = db.prepare(` + SELECT + s.id AS sessionId, + s.directory AS cwd, + s.title AS title, + s.time_created AS createdAt, + s.time_updated AS lastActivityAt, + p.worktree AS projectPath, + ${markerExpr} AS hasThreeViewsMarker + FROM session s + LEFT JOIN project p ON p.id = s.project_id + WHERE s.time_archived IS NULL + ${rootFilter} + ORDER BY s.time_updated DESC + `).all(...markerParams) as OpencodeSessionRow[] + return { rows, schemaMissingParentId: !hasParentId } + } finally { + db.close() + } +} diff --git a/server/coding-cli/providers/opencode-listing-runner.ts b/server/coding-cli/providers/opencode-listing-runner.ts new file mode 100644 index 00000000..55ac22e3 --- /dev/null +++ b/server/coding-cli/providers/opencode-listing-runner.ts @@ -0,0 +1,119 @@ +import { Worker } from 'node:worker_threads' +import type { OpencodeListingResult, OpencodeSessionRow } from './opencode-listing-query.js' +import { runOpencodeListingQuery } from './opencode-listing-query.js' +// Importing the worker module on the MAIN thread (or a Vitest worker) is safe: +// its auto-run is sentinel-guarded, so this import never spawns/posts anything. +import { OPENCODE_LISTING_WORKER_KIND } from './opencode-listing.worker.js' + +export type OpencodeListingQueryInput = { dbPath: string; markerPattern: string } +export type OpencodeListingQueryRunner = (input: OpencodeListingQueryInput) => Promise + +type WorkerLike = { + on(event: 'message', listener: (value: unknown) => void): unknown + on(event: 'error', listener: (err: Error) => void): unknown + on(event: 'exit', listener: (code: number) => void): unknown + terminate(): Promise | void +} + +export type WorkerSpawnOptions = { workerData: unknown; execArgv: string[] } + +export type CreateWorkerListingRunnerOptions = { + /** Injectable for unit tests; default spawns a real worker_threads Worker. */ + spawn?: (workerUrl: URL, options: WorkerSpawnOptions) => WorkerLike + /** Override the query-module URL (used by the off-thread integration fixture). */ + queryModuleUrl?: string + /** Hard timeout for a single listing query. Default 15 s (the real query is ~180 ms). */ + timeoutMs?: number +} + +const DEFAULT_TIMEOUT_MS = 15_000 +// import.meta.url ends with `.ts` in dev/test (tsx / native strip-types) and +// `.js` in prod (compiled dist). Resolve siblings with the matching extension. +const SELF_EXT = import.meta.url.endsWith('.ts') ? '.ts' : '.js' +// Append to process.execArgv (do NOT replace) so tsx's `--import .../loader.mjs` +// is inherited in dev; the flag silences node:sqlite's per-spawn ExperimentalWarning. +const WORKER_EXECARGV = [...process.execArgv, '--disable-warning=ExperimentalWarning'] + +function defaultWorkerUrl(): URL { + return new URL(`./opencode-listing.worker${SELF_EXT}`, import.meta.url) +} +function defaultQueryModuleUrl(): string { + return new URL(`./opencode-listing-query${SELF_EXT}`, import.meta.url).href +} +function defaultSpawn(workerUrl: URL, options: WorkerSpawnOptions): WorkerLike { + return new Worker(workerUrl, options) +} + +type OkMessage = { ok: true; rows: OpencodeSessionRow[]; schemaMissingParentId: boolean } +type ErrMessage = { ok: false; error: { name: string; message: string } } + +// Validate the FULL shape, not just the presence of `ok` — a truncated/garbled +// message like `{ ok: true }` must NOT resolve `{ rows: undefined }`. +function isOkMessage(value: unknown): value is OkMessage { + return typeof value === 'object' && value !== null + && (value as { ok?: unknown }).ok === true + && Array.isArray((value as { rows?: unknown }).rows) + && typeof (value as { schemaMissingParentId?: unknown }).schemaMissingParentId === 'boolean' +} +function isErrMessage(value: unknown): value is ErrMessage { + if (typeof value !== 'object' || value === null) return false + if ((value as { ok?: unknown }).ok !== false) return false + const error = (value as { error?: unknown }).error + return typeof error === 'object' && error !== null + && typeof (error as { message?: unknown }).message === 'string' +} + +export function createWorkerListingRunner( + options: CreateWorkerListingRunnerOptions = {}, +): OpencodeListingQueryRunner { + const spawn = options.spawn ?? defaultSpawn + const queryModuleUrl = options.queryModuleUrl ?? defaultQueryModuleUrl() + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const workerUrl = defaultWorkerUrl() + + return (input: OpencodeListingQueryInput): Promise => { + return new Promise((resolve, reject) => { + const worker = spawn(workerUrl, { workerData: { ...input, queryModuleUrl, kind: OPENCODE_LISTING_WORKER_KIND }, execArgv: WORKER_EXECARGV }) + let settled = false + let timer: NodeJS.Timeout | undefined + + const cleanup = () => { + if (timer) clearTimeout(timer) + try { void worker.terminate() } catch { /* ignore */ } + } + const settleResolve = (result: OpencodeListingResult) => { + if (settled) return + settled = true + cleanup() + resolve(result) + } + const settleReject = (err: Error) => { + if (settled) return + settled = true + cleanup() + reject(err) + } + + timer = setTimeout(() => settleReject(new Error(`OpenCode listing worker timed out after ${timeoutMs}ms`)), timeoutMs) + if (typeof (timer as NodeJS.Timeout).unref === 'function') (timer as NodeJS.Timeout).unref() + + worker.on('message', (value: unknown) => { + if (isOkMessage(value)) { + settleResolve({ rows: value.rows, schemaMissingParentId: value.schemaMissingParentId }) + } else if (isErrMessage(value)) { + const err = new Error(value.error.message || 'OpenCode listing worker failed') + err.name = value.error.name ?? 'Error' + settleReject(err) + } else { + settleReject(new Error('OpenCode listing worker sent a malformed message')) + } + }) + worker.on('error', (err: Error) => settleReject(err)) + worker.on('exit', (code: number) => settleReject(new Error(`OpenCode listing worker exited (code ${code}) before responding`))) + }) + } +} + +/** Runs the listing query on the caller's thread (no worker). For tests and fallbacks. */ +export const inProcessListingRunner: OpencodeListingQueryRunner = (input) => + runOpencodeListingQuery(input.dbPath, input.markerPattern) diff --git a/server/coding-cli/providers/opencode-listing.worker.ts b/server/coding-cli/providers/opencode-listing.worker.ts new file mode 100644 index 00000000..0abd9a4b --- /dev/null +++ b/server/coding-cli/providers/opencode-listing.worker.ts @@ -0,0 +1,44 @@ +import { parentPort, workerData } from 'node:worker_threads' +import type { OpencodeListingResult } from './opencode-listing-query.js' + +/** + * Sentinel proving this thread was spawned by OUR runner. REQUIRED because the + * server Vitest config runs test files in worker threads (`pool: 'threads'`), so + * `parentPort` is non-null when a test imports this module. Without the sentinel, + * the auto-run block below would fire on import using Vitest's OWN workerData and + * post a message to Vitest's parent port — corrupting/hanging the test worker. + * The runner injects this exact value in workerData; Vitest's workerData never has it. + */ +export const OPENCODE_LISTING_WORKER_KIND = 'opencode-listing-worker' + +export type WorkerListingInput = { + kind: typeof OPENCODE_LISTING_WORKER_KIND + queryModuleUrl: string + dbPath: string + markerPattern: string +} + +/** + * Run the listing query by dynamically importing the EXACT resolved query-module + * URL (.ts in dev/test, .js in prod) provided by the spawning code. We pass the + * exact URL rather than a static relative import because NodeNext `.js`→`.ts` + * remapping fails inside a worker thread (validated by spike). + */ +export async function executeListing( + input: { queryModuleUrl: string; dbPath: string; markerPattern: string }, +): Promise { + const mod = await import(input.queryModuleUrl) as typeof import('./opencode-listing-query.js') + return mod.runOpencodeListingQuery(input.dbPath, input.markerPattern) +} + +// Auto-run ONLY when we are a real worker spawned by our runner (parentPort present +// AND our sentinel in workerData). This is import-safe under Vitest's thread pool. +if (parentPort && (workerData as Partial | undefined)?.kind === OPENCODE_LISTING_WORKER_KIND) { + const port = parentPort + executeListing(workerData as WorkerListingInput) + .then((result) => port.postMessage({ ok: true, rows: result.rows, schemaMissingParentId: result.schemaMissingParentId })) + .catch((err: unknown) => { + const error = err instanceof Error ? { name: err.name, message: err.message } : { name: 'Error', message: String(err) } + port.postMessage({ ok: false, error }) + }) +} diff --git a/server/coding-cli/providers/opencode.ts b/server/coding-cli/providers/opencode.ts index 815cd76d..e1ec63db 100644 --- a/server/coding-cli/providers/opencode.ts +++ b/server/coding-cli/providers/opencode.ts @@ -5,16 +5,10 @@ import { logger } from '../../logger.js' import type { CodingCliProvider } from '../provider.js' import type { CodingCliSession, NormalizedEvent, ParsedSessionMeta } from '../types.js' import { resolveGitRepoRoot } from '../utils.js' - -type OpencodeSessionRow = { - sessionId: string - cwd: string - title: string - createdAt: number - lastActivityAt: number - projectPath: string | null - hasThreeViewsMarker?: number | null -} +import { createWorkerListingRunner, type OpencodeListingQueryRunner } from './opencode-listing-runner.js' +import { THREE_VIEWS_MARKER_SQL_PATTERN } from './opencode-listing-query.js' +export { THREE_VIEWS_MARKER_SQL_PATTERN } from './opencode-listing-query.js' +export type { OpencodeSessionRow } from './opencode-listing-query.js' type OpencodeSessionSchema = { hasParentId: boolean @@ -59,19 +53,25 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } -const THREE_VIEWS_MARKER_SQL_PATTERN = '%() + private readonly queryRunner: OpencodeListingQueryRunner - constructor(readonly homeDir: string = defaultOpencodeDataHome()) {} + constructor( + readonly homeDir: string = defaultOpencodeDataHome(), + options: OpencodeProviderOptions = {}, + ) { + this.queryRunner = options.queryRunner ?? createWorkerListingRunner() + } private getDatabasePath(): string { return path.join(this.homeDir, 'opencode.db') @@ -139,9 +139,11 @@ export class OpencodeProvider implements CodingCliProvider { return [] } - let sqlite: typeof import('node:sqlite') + // Availability probe (cheap; cached). Preserves the exact sqlite_unavailable + // behavior even though the heavy query runs off-thread. Same Node => same + // availability inside the worker. try { - sqlite = await import('node:sqlite') + await import('node:sqlite') } catch (err) { this.logDatabaseStateOnce('warn', 'sqlite_unavailable', 'node:sqlite unavailable — OpenCode sessions will not appear. Upgrade to Node 22.5+ to enable.', { error: err, @@ -150,81 +152,46 @@ export class OpencodeProvider implements CodingCliProvider { return [] } - let db: InstanceType | undefined - let phase: 'open' | 'schema' | 'query' | 'map' = 'open' + let result try { - db = new sqlite.DatabaseSync(dbPath, { readOnly: true }) - this.configureReadOnlyDatabase(db) - phase = 'schema' - const schema = this.inspectSessionSchema(db) - const rootFilter = schema.hasParentId ? 'AND s.parent_id IS NULL' : '' - phase = 'query' - const rows = db.prepare(` - SELECT - s.id AS sessionId, - s.directory AS cwd, - s.title AS title, - s.time_created AS createdAt, - s.time_updated AS lastActivityAt, - p.worktree AS projectPath, - ( - EXISTS ( - SELECT 1 - FROM part pa - WHERE pa.session_id = s.id - AND pa.data LIKE ? - ) - OR EXISTS ( - SELECT 1 - FROM message m - WHERE m.session_id = s.id - AND m.data LIKE ? - ) - ) AS hasThreeViewsMarker - FROM session s - LEFT JOIN project p - ON p.id = s.project_id - WHERE s.time_archived IS NULL - ${rootFilter} - ORDER BY s.time_updated DESC - `).all( - THREE_VIEWS_MARKER_SQL_PATTERN, - THREE_VIEWS_MARKER_SQL_PATTERN, - ) as OpencodeSessionRow[] - - if (rows.length === 0) { - this.logDatabaseStateOnce('info', 'empty_db', 'OpenCode sessions database has no active root sessions', { - extra: { rowCount: 0 }, - }) - } - - phase = 'map' - const sessions: CodingCliSession[] = [] - for (const row of rows) { - if (typeof row.cwd !== 'string' || !row.cwd) continue - const projectPath = row.projectPath || await resolveGitRepoRoot(row.cwd) - const isThreeViewsSession = toSqliteBoolean(row.hasThreeViewsMarker) - sessions.push({ - provider: this.name, - sessionId: row.sessionId, - projectPath, - cwd: row.cwd, - title: typeof row.title === 'string' ? row.title : undefined, - lastActivityAt: toValidTimestamp(row.lastActivityAt) ?? Date.now(), - createdAt: toValidTimestamp(row.createdAt), - isSubagent: isThreeViewsSession || undefined, - isNonInteractive: isThreeViewsSession || undefined, - }) - } - return sessions + // The heavy open+schema+marker query runs OFF the event loop (worker thread). + result = await this.queryRunner({ dbPath, markerPattern: THREE_VIEWS_MARKER_SQL_PATTERN }) } catch (err) { - this.logDatabaseStateOnce('warn', this.classifyDatabaseFailure(phase), 'Failed to read OpenCode sessions database', { - error: err, + // A worker/read failure is transient infrastructure failure, NOT "no sessions". + // Log once (sanitized) and re-throw: refreshDirectProvider catches this and + // returns early WITHOUT pruning, preserving the previously-listed OpenCode + // sessions. Returning [] here would make the indexer prune the whole sidebar. + this.logDatabaseStateOnce('warn', 'read_error', 'Failed to read OpenCode sessions database', { error: err }) + throw err + } + + if (result.schemaMissingParentId) { + this.logDatabaseStateOnce('warn', 'schema_missing_parent_id', 'OpenCode session schema does not expose parent_id; treating sessions as flat roots') + } + if (result.rows.length === 0) { + this.logDatabaseStateOnce('info', 'empty_db', 'OpenCode sessions database has no active root sessions', { + extra: { rowCount: 0 }, + }) + } + + const sessions: CodingCliSession[] = [] + for (const row of result.rows) { + if (typeof row.cwd !== 'string' || !row.cwd) continue + const projectPath = row.projectPath || await resolveGitRepoRoot(row.cwd) + const isThreeViewsSession = toSqliteBoolean(row.hasThreeViewsMarker) + sessions.push({ + provider: this.name, + sessionId: row.sessionId, + projectPath, + cwd: row.cwd, + title: typeof row.title === 'string' ? row.title : undefined, + lastActivityAt: toValidTimestamp(row.lastActivityAt) ?? Date.now(), + createdAt: toValidTimestamp(row.createdAt), + isSubagent: isThreeViewsSession || undefined, + isNonInteractive: isThreeViewsSession || undefined, }) - return [] - } finally { - db?.close() } + return sessions } async resolveOpencodeSessionRoots(sessionIds: readonly string[]): Promise { diff --git a/server/coding-cli/session-indexer.ts b/server/coding-cli/session-indexer.ts index eb0c3766..04b99a5b 100644 --- a/server/coding-cli/session-indexer.ts +++ b/server/coding-cli/session-indexer.ts @@ -917,8 +917,19 @@ export class CodingCliSessionIndexer { let sessions: CodingCliSession[] = [] try { sessions = await provider.listSessionsDirect() - } catch (err) { - logger.warn({ err, provider: provider.name }, 'Could not list provider sessions directly') + } catch { + // A direct-listing failure is transient (e.g. the off-thread worker failed), + // NOT "no sessions". Preserve this provider's existing direct-cache entries so + // neither the local prune (below) nor the full-scan global prune deletes them. + // Log at debug with only the provider name — the provider already emitted a + // sanitized one-time detail via logDatabaseStateOnce, and logging `err` here + // would re-leak paths/messages and spam once per failed refresh. + logger.debug({ provider: provider.name }, 'Direct provider listing failed; preserving cached sessions') + for (const cacheKey of this.fileCache.keys()) { + if (this.isDirectCacheKey(cacheKey) && this.fileCache.get(cacheKey)?.provider === provider.name) { + seenKeys.add(cacheKey) + } + } return seenKeys } diff --git a/test/integration/server/fixtures/slow-opencode-listing-query.ts b/test/integration/server/fixtures/slow-opencode-listing-query.ts new file mode 100644 index 00000000..5728c410 --- /dev/null +++ b/test/integration/server/fixtures/slow-opencode-listing-query.ts @@ -0,0 +1,16 @@ +// Drop-in replacement for opencode-listing-query's runOpencodeListingQuery that +// blocks its OWN (worker) thread for a fixed duration, then returns known rows. +// Used to prove the main event loop is not blocked while the worker runs. +// (Returns synchronously; the worker awaits it, so a non-Promise return is fine.) +import type { OpencodeListingResult } from '../../../../server/coding-cli/providers/opencode-listing-query.js' + +const SLEEP_MS = 250 + +export function runOpencodeListingQuery(_dbPath: string, _markerPattern: string): OpencodeListingResult { + const end = Date.now() + SLEEP_MS + while (Date.now() < end) { /* busy-block this worker thread */ } + return { + rows: [{ sessionId: 'slow-1', cwd: '/repo/root', title: 'Slow', createdAt: 1000, lastActivityAt: 2000, projectPath: '/repo/root', hasThreeViewsMarker: 0 }], + schemaMissingParentId: false, + } +} diff --git a/test/integration/server/fixtures/ts-worker-support.ts b/test/integration/server/fixtures/ts-worker-support.ts new file mode 100644 index 00000000..ad8da1dc --- /dev/null +++ b/test/integration/server/fixtures/ts-worker-support.ts @@ -0,0 +1,18 @@ +// The source-mode real-worker integration tests spawn a worker that loads the +// `.ts` query module via Node's native TypeScript type-stripping. Whether that is +// active depends on the Node version AND flags (default on v22.18+ and v23.6+, but +// NOT v23.0–23.5, and off on v22.5–22.17). Rather than encode that version matrix +// (easy to get wrong), probe the actual capability: `process.features.typescript` +// reports the active mode ("strip"/"transform") when type-stripping is enabled and +// `false`/`undefined` otherwise. The spawned worker shares this process's Node +// binary and flags, so this exactly predicts whether it can load the `.ts` module. +// +// When unavailable we skip ONLY these source-mode `.ts` spawn tests — the product +// still works there (prod runs the COMPILED `.js` worker, dev runs under tsx). +// Worker spawn is additionally proven on EVERY supported Node by the unguarded +// compiled-worker test (opencode-listing-compiled-worker.test.ts), and orchestration +// by the fake-spawn unit tests. +export function supportsNativeTsWorker(): boolean { + // `process.features.typescript` may not be in the installed @types/node yet. + return Boolean((process.features as { typescript?: unknown }).typescript) +} diff --git a/test/integration/server/opencode-listing-compiled-worker.test.ts b/test/integration/server/opencode-listing-compiled-worker.test.ts new file mode 100644 index 00000000..f95d629a --- /dev/null +++ b/test/integration/server/opencode-listing-compiled-worker.test.ts @@ -0,0 +1,57 @@ +import path from 'path' +import os from 'os' +import fsp from 'fs/promises' +import { existsSync } from 'fs' +import { pathToFileURL } from 'url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { THREE_VIEWS_MARKER_SQL_PATTERN } from '../../../server/coding-cli/providers/opencode-listing-query' + +vi.unmock('node:sqlite') + +// The server vitest globalSetup (test/setup/server-global-setup.ts) rebuilds dist/ +// unconditionally via `npm run build:server`, so the COMPILED .js worker is always +// present here. Importing the COMPILED runner means the spawned worker loads as +// plain .js — no native type-stripping required — which proves the real off-thread +// spawn works on ANY supported Node (>=22.5), unlike the source-mode `.ts` real-worker +// tests (opencode-listing-offthread/discovery) that need Node >=22.18. +const distRunnerPath = path.join(process.cwd(), 'dist', 'server', 'coding-cli', 'providers', 'opencode-listing-runner.js') +const distRunnerUrl = pathToFileURL(distRunnerPath).href + +describe('OpenCode listing — compiled (.js) worker spawns on any supported Node', () => { + let tempDir: string + beforeEach(async () => { tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-compiled-')) }) + afterEach(async () => { await fsp.rm(tempDir, { recursive: true, force: true }) }) + + it('spawns the compiled worker and returns rows with marker→hasThreeViewsMarker', async () => { + // dist is guaranteed by globalSetup; fail loudly (do not skip) if it is absent, + // since that would mean the compiled production artifact was never exercised. + expect(existsSync(distRunnerPath), `compiled runner missing at ${distRunnerPath} (server globalSetup should build dist)`).toBe(true) + + const dbPath = path.join(tempDir, 'opencode.db') + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(dbPath) + try { + db.exec(` + CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, sandboxes text NOT NULL); + CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, parent_id text, slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer); + CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + `) + db.prepare(`INSERT INTO project VALUES (?, ?, ?, ?, ?)`).run('p', '/repo/root', 900, 4000, '[]') + db.prepare(`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?,?,?,?)`) + .run('marked', 'p', null, 'marked', '/repo/root', 'Marked', 'v', 1000, 3000, null) + db.prepare(`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?,?,?,?)`) + .run('plain', 'p', null, 'plain', '/repo/root', 'Plain', 'v', 1000, 2000, null) + db.prepare(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?)`) + .run('m1', 'marked', 1100, 1100, JSON.stringify({ role: 'user', text: '' })) + } finally { + db.close() + } + + const mod = await import(distRunnerUrl) as typeof import('../../../server/coding-cli/providers/opencode-listing-runner.js') + const runner = mod.createWorkerListingRunner() + const result = await runner({ dbPath, markerPattern: THREE_VIEWS_MARKER_SQL_PATTERN }) + + expect(result.rows.map((r) => [r.sessionId, !!r.hasThreeViewsMarker])).toEqual([['marked', true], ['plain', false]]) + }) +}) diff --git a/test/integration/server/opencode-listing-discovery.test.ts b/test/integration/server/opencode-listing-discovery.test.ts new file mode 100644 index 00000000..fd18f6ed --- /dev/null +++ b/test/integration/server/opencode-listing-discovery.test.ts @@ -0,0 +1,63 @@ +import path from 'path' +import os from 'os' +import fsp from 'fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.unmock('node:sqlite') +vi.mock('../../../server/config-store', () => ({ + configStore: { + getProjectColors: vi.fn().mockResolvedValue({}), + snapshot: vi.fn().mockResolvedValue({ settings: { codingCli: { enabledProviders: ['opencode'], providers: {} } } }), + }, +})) + +import { OpencodeProvider } from '../../../server/coding-cli/providers/opencode' +import { CodingCliSessionIndexer } from '../../../server/coding-cli/session-indexer.js' +import { supportsNativeTsWorker } from './fixtures/ts-worker-support' + +const marker = '' + +// Uses the DEFAULT worker runner (real .ts worker via native type-stripping, +// Node >= 22.18). Skip below that — see fixtures/ts-worker-support.ts. +describe.skipIf(!supportsNativeTsWorker())('OpenCode discovery via the off-thread worker (provider + indexer)', () => { + let home: string + beforeEach(async () => { home = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-discovery-')) }) + afterEach(async () => { await fsp.rm(home, { recursive: true, force: true }) }) + + it('surfaces DB sessions (with marker→isSubagent) through a full refresh using the real worker', async () => { + const dbPath = path.join(home, 'opencode.db') + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(dbPath) + try { + db.exec(` + CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, sandboxes text NOT NULL); + CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, parent_id text, slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer); + CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + `) + db.prepare(`INSERT INTO project VALUES (?, ?, ?, ?, ?)`).run('p', '/repo/root', 900, 4000, '[]') + db.prepare(`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?,?,?,?)`) + .run('marked', 'p', null, 'marked', '/repo/root', 'Marked', 'v', 1000, 3000, null) + db.prepare(`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?,?,?,?)`) + .run('normal', 'p', null, 'normal', '/repo/root', 'Normal', 'v', 1000, 2000, null) + db.prepare(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?)`).run('m1', 'marked', 1100, 1100, JSON.stringify({ role: 'user', text: marker })) + db.prepare(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?)`).run('m2', 'normal', 1100, 1100, JSON.stringify({ role: 'user', text: 'ordinary' })) + } finally { + db.close() + } + + // DEFAULT runner = the real off-thread worker (no injection). + const provider = new OpencodeProvider(home) + const indexer = new CodingCliSessionIndexer([provider]) + await indexer.refresh() + + const sessions = indexer.getProjects().flatMap((g) => g.sessions) + const marked = sessions.find((s) => s.sessionId === 'marked') + const normal = sessions.find((s) => s.sessionId === 'normal') + expect(marked).toBeDefined() + expect(marked?.isSubagent).toBe(true) + expect(marked?.isNonInteractive).toBe(true) + expect(normal).toBeDefined() + expect(normal?.isSubagent).toBeUndefined() + }) +}) diff --git a/test/integration/server/opencode-listing-offthread.test.ts b/test/integration/server/opencode-listing-offthread.test.ts new file mode 100644 index 00000000..89d69ee4 --- /dev/null +++ b/test/integration/server/opencode-listing-offthread.test.ts @@ -0,0 +1,66 @@ +import path from 'path' +import os from 'os' +import fsp from 'fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createWorkerListingRunner } from '../../../server/coding-cli/providers/opencode-listing-runner' +import { runOpencodeListingQuery, THREE_VIEWS_MARKER_SQL_PATTERN } from '../../../server/coding-cli/providers/opencode-listing-query' +import { supportsNativeTsWorker } from './fixtures/ts-worker-support' + +vi.unmock('node:sqlite') + +const threeViewsMarker = '' +// Exact (.ts in dev/test) URL of the slow fixture — same resolution strategy as the runner. +const slowQueryModuleUrl = new URL('./fixtures/slow-opencode-listing-query.ts', import.meta.url).href + +// Spawns a REAL worker that loads the .ts query module via native type-stripping +// (Node >= 22.18). Skip below that (prod uses compiled .js; orchestration covered +// by fake-spawn unit tests). See fixtures/ts-worker-support.ts. +describe.skipIf(!supportsNativeTsWorker())('OpenCode listing off-thread (real worker)', () => { + let tempDir: string + beforeEach(async () => { tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-offthread-')) }) + afterEach(async () => { await fsp.rm(tempDir, { recursive: true, force: true }) }) + + it('returns the same rows as the synchronous baseline', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(dbPath) + try { + db.exec(` + CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, sandboxes text NOT NULL); + CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, parent_id text, slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer); + CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + `) + db.prepare(`INSERT INTO project VALUES (?, ?, ?, ?, ?)`).run('project-1', '/repo/root', 900, 4000, '[]') + db.prepare(`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?,?,?,?)`) + .run('marked', 'project-1', null, 'marked', '/repo/root', 'Marked', 'test', 1000, 3000, null) + db.prepare(`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?,?,?,?)`) + .run('plain', 'project-1', null, 'plain', '/repo/root', 'Plain', 'test', 1000, 2000, null) + db.prepare(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?)`).run('m1', 'marked', 1100, 1100, JSON.stringify({ role: 'user', text: threeViewsMarker })) + db.prepare(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?)`).run('m2', 'plain', 1100, 1100, JSON.stringify({ role: 'user', text: 'ordinary' })) + } finally { + db.close() + } + + const runner = createWorkerListingRunner() + const result = await runner({ dbPath, markerPattern: THREE_VIEWS_MARKER_SQL_PATTERN }) + const baseline = await runOpencodeListingQuery(dbPath, THREE_VIEWS_MARKER_SQL_PATTERN) + expect(result.rows).toEqual(baseline.rows) + expect(result.rows.map((r) => [r.sessionId, !!r.hasThreeViewsMarker])).toEqual([['marked', true], ['plain', false]]) + }) + + it('does not block the event loop while the worker runs a slow query', async () => { + const runner = createWorkerListingRunner({ queryModuleUrl: slowQueryModuleUrl }) + let ticks = 0 + const interval = setInterval(() => { ticks += 1 }, 10) + try { + const result = await runner({ dbPath: path.join(tempDir, 'unused.db'), markerPattern: THREE_VIEWS_MARKER_SQL_PATTERN }) + expect(result.rows.map((r) => r.sessionId)).toEqual(['slow-1']) + // The worker busy-blocks its OWN thread for ~250 ms. If the main loop were + // blocked, the interval could not fire. We expect many ticks (>= ~10). + expect(ticks).toBeGreaterThanOrEqual(10) + } finally { + clearInterval(interval) + } + }) +}) diff --git a/test/unit/server/coding-cli/opencode-listing-query.test.ts b/test/unit/server/coding-cli/opencode-listing-query.test.ts new file mode 100644 index 00000000..08110a92 --- /dev/null +++ b/test/unit/server/coding-cli/opencode-listing-query.test.ts @@ -0,0 +1,178 @@ +import path from 'path' +import os from 'os' +import fsp from 'fs/promises' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + runOpencodeListingQuery, + THREE_VIEWS_MARKER_SQL_PATTERN, +} from '../../../../server/coding-cli/providers/opencode-listing-query' + +vi.unmock('node:sqlite') + +type SqliteModule = typeof import('node:sqlite') +type DatabaseSyncConstructor = SqliteModule['DatabaseSync'] +type DatabaseSyncInstance = InstanceType + +const threeViewsMarker = '' + +describe('runOpencodeListingQuery', () => { + let tempDir: string + let DatabaseSync: DatabaseSyncConstructor + + beforeAll(async () => { + DatabaseSync = (await import('node:sqlite')).DatabaseSync + }) + beforeEach(async () => { + tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-query-')) + }) + afterEach(async () => { + await fsp.rm(tempDir, { recursive: true, force: true }) + }) + + function createSchema(db: DatabaseSyncInstance, opts: { parentId?: boolean } = {}): void { + const parentCol = opts.parentId === false ? '' : 'parent_id text,' + db.exec(` + CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, sandboxes text NOT NULL); + CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, ${parentCol} slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer); + CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + `) + } + function seedProject(db: DatabaseSyncInstance) { + db.prepare(`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, ?)`).run('project-1', '/repo/root', 900, 4000, '[]') + } + function seedSession(db: DatabaseSyncInstance, id: string, title: string, timeUpdated: number, parentId: string | null = null, archived: number | null = null) { + db.prepare(`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(id, 'project-1', parentId, id, '/repo/root', title, 'test', 1000, timeUpdated, archived) + } + // For the parent_id-absent schema: an INSERT that does NOT reference the + // missing parent_id column (seedSession would fail at bind time otherwise). + function seedFlatSession(db: DatabaseSyncInstance, id: string, title: string, timeUpdated: number) { + db.prepare(`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(id, 'project-1', id, '/repo/root', title, 'test', 1000, timeUpdated, null) + } + function seedMessage(db: DatabaseSyncInstance, id: string, sessionId: string, data: string) { + db.prepare(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)`).run(id, sessionId, 1100, 1100, data) + } + function seedPart(db: DatabaseSyncInstance, id: string, messageId: string, sessionId: string, data: string) { + db.prepare(`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)`).run(id, messageId, sessionId, 1100, 1100, data) + } + + it('flags marker in a part, marker in a message, and leaves a normal session unmarked; sorted by time_updated desc', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const db = new DatabaseSync(dbPath) + try { + createSchema(db) + seedProject(db) + seedSession(db, 'session-part-marker', 'Part marker', 3000) + seedSession(db, 'session-message-marker', 'Message marker', 2500) + seedSession(db, 'session-normal', 'Normal', 2000) + seedMessage(db, 'm-part', 'session-part-marker', JSON.stringify({ role: 'user' })) + seedPart(db, 'p-marked', 'm-part', 'session-part-marker', JSON.stringify({ type: 'text', text: `hi\n${threeViewsMarker}` })) + seedMessage(db, 'm-msg', 'session-message-marker', JSON.stringify({ role: 'user', text: `hi\n${threeViewsMarker}` })) + seedMessage(db, 'm-normal', 'session-normal', JSON.stringify({ role: 'user', text: 'ordinary prompt' })) + } finally { + db.close() + } + + const result = await runOpencodeListingQuery(dbPath, THREE_VIEWS_MARKER_SQL_PATTERN) + + expect(result.schemaMissingParentId).toBe(false) + expect(result.rows.map((r) => [r.sessionId, !!r.hasThreeViewsMarker])).toEqual([ + ['session-part-marker', true], + ['session-message-marker', true], + ['session-normal', false], + ]) + expect(result.rows[0]).toMatchObject({ cwd: '/repo/root', title: 'Part marker', createdAt: 1000, lastActivityAt: 3000, projectPath: '/repo/root' }) + }) + + it('excludes archived sessions and child sessions (parent_id not null)', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const db = new DatabaseSync(dbPath) + try { + createSchema(db) + seedProject(db) + seedSession(db, 'root', 'Root', 3000) + seedSession(db, 'child', 'Child', 2900, 'root') + seedSession(db, 'archived', 'Archived', 2800, null, 5000) + } finally { + db.close() + } + const result = await runOpencodeListingQuery(dbPath, THREE_VIEWS_MARKER_SQL_PATTERN) + expect(result.rows.map((r) => r.sessionId)).toEqual(['root']) + }) + + it('reports schemaMissingParentId and returns all non-archived sessions when parent_id is absent', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const db = new DatabaseSync(dbPath) + try { + createSchema(db, { parentId: false }) + seedProject(db) + seedFlatSession(db, 'a', 'A', 3000) + seedFlatSession(db, 'b', 'B', 2000) + } finally { + db.close() + } + const result = await runOpencodeListingQuery(dbPath, THREE_VIEWS_MARKER_SQL_PATTERN) + expect(result.schemaMissingParentId).toBe(true) + expect(result.rows.map((r) => r.sessionId)).toEqual(['a', 'b']) + }) + + it('degrades to unmarked (no throw) when part/message tables are absent', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const db = new DatabaseSync(dbPath) + try { + // Only project + session — mirrors the e2e fake-opencode fixture's schema. + db.exec(` + CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, sandboxes text NOT NULL); + CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, parent_id text, slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer); + `) + seedProject(db) + seedSession(db, 'only', 'Only', 2000) + } finally { + db.close() + } + const result = await runOpencodeListingQuery(dbPath, THREE_VIEWS_MARKER_SQL_PATTERN) + expect(result.rows.map((r) => [r.sessionId, !!r.hasThreeViewsMarker])).toEqual([['only', false]]) + }) + + it('still detects a marker when only the part table is present (no message table)', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const db = new DatabaseSync(dbPath) + try { + db.exec(` + CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, sandboxes text NOT NULL); + CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, parent_id text, slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer); + CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + `) + seedProject(db) + seedSession(db, 'p-marked', 'PartMarked', 3000) + seedSession(db, 'p-plain', 'PartPlain', 2000) + seedPart(db, 'pp', 'mm', 'p-marked', JSON.stringify({ type: 'text', text: `x\n${threeViewsMarker}` })) + } finally { + db.close() + } + const result = await runOpencodeListingQuery(dbPath, THREE_VIEWS_MARKER_SQL_PATTERN) + expect(result.rows.map((r) => [r.sessionId, !!r.hasThreeViewsMarker])).toEqual([['p-marked', true], ['p-plain', false]]) + }) + + it('still detects a marker when only the message table is present (no part table)', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const db = new DatabaseSync(dbPath) + try { + db.exec(` + CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, sandboxes text NOT NULL); + CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, parent_id text, slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer); + CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + `) + seedProject(db) + seedSession(db, 'm-marked', 'MsgMarked', 3000) + seedSession(db, 'm-plain', 'MsgPlain', 2000) + seedMessage(db, 'mm', 'm-marked', JSON.stringify({ role: 'user', text: `x\n${threeViewsMarker}` })) + } finally { + db.close() + } + const result = await runOpencodeListingQuery(dbPath, THREE_VIEWS_MARKER_SQL_PATTERN) + expect(result.rows.map((r) => [r.sessionId, !!r.hasThreeViewsMarker])).toEqual([['m-marked', true], ['m-plain', false]]) + }) +}) diff --git a/test/unit/server/coding-cli/opencode-listing-runner.test.ts b/test/unit/server/coding-cli/opencode-listing-runner.test.ts new file mode 100644 index 00000000..89ee1822 --- /dev/null +++ b/test/unit/server/coding-cli/opencode-listing-runner.test.ts @@ -0,0 +1,129 @@ +import { EventEmitter } from 'events' +import { describe, expect, it, vi } from 'vitest' +import { createWorkerListingRunner } from '../../../../server/coding-cli/providers/opencode-listing-runner' +import { THREE_VIEWS_MARKER_SQL_PATTERN } from '../../../../server/coding-cli/providers/opencode-listing-query' + +class FakeWorker extends EventEmitter { + terminated = 0 + postedData: unknown + execArgv: string[] + constructor(public url: URL, public options: { workerData: unknown; execArgv: string[] }) { + super() + this.postedData = options.workerData + this.execArgv = options.execArgv + } + terminate() { this.terminated += 1; return Promise.resolve(0) } + // helpers + emitMessage(msg: unknown) { this.emit('message', msg) } + emitError(err: Error) { this.emit('error', err) } + emitExit(code: number) { this.emit('exit', code) } +} + +function makeRunner(overrides: Partial[0]> = {}) { + const workers: FakeWorker[] = [] + const spawn = vi.fn((url: URL, options: { workerData: unknown; execArgv: string[] }) => { + const w = new FakeWorker(url, options) + workers.push(w) + return w + }) + const runner = createWorkerListingRunner({ spawn: spawn as any, timeoutMs: 50, ...overrides }) + return { runner, workers, spawn } +} + +const input = { dbPath: '/tmp/opencode.db', markerPattern: THREE_VIEWS_MARKER_SQL_PATTERN } + +describe('createWorkerListingRunner', () => { + it('resolves rows from an ok message and terminates the worker', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitMessage({ ok: true, rows: [{ sessionId: 's1' }], schemaMissingParentId: false }) + const result = await promise + expect(result.rows).toEqual([{ sessionId: 's1' }]) + expect(result.schemaMissingParentId).toBe(false) + expect(workers[0].terminated).toBe(1) + }) + + it('passes dbPath, markerPattern and a queryModuleUrl in workerData, and suppresses the experimental warning via execArgv', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + const data = workers[0].postedData as any + expect(data.dbPath).toBe(input.dbPath) + expect(data.markerPattern).toBe(THREE_VIEWS_MARKER_SQL_PATTERN) + expect(String(data.queryModuleUrl)).toContain('opencode-listing-query') + expect(data.kind).toBe('opencode-listing-worker') // sentinel that gates the worker auto-run + // Appended to process.execArgv so the tsx loader (dev) survives AND the + // per-spawn node:sqlite ExperimentalWarning is silenced. + expect(workers[0].execArgv).toEqual([...process.execArgv, '--disable-warning=ExperimentalWarning']) + workers[0].emitMessage({ ok: true, rows: [], schemaMissingParentId: false }) + await promise + }) + + it('ignores a late exit event after a successful message (no double-settle)', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitMessage({ ok: true, rows: [{ sessionId: 's1' }], schemaMissingParentId: false }) + // A real Worker emits 'exit' after terminate(); the settled guard must swallow it. + workers[0].emitExit(0) + await expect(promise).resolves.toMatchObject({ rows: [{ sessionId: 's1' }] }) + expect(workers[0].terminated).toBe(1) + }) + + it('rejects on an error message and terminates', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitMessage({ ok: false, error: { name: 'SqliteError', message: 'boom' } }) + await expect(promise).rejects.toThrow(/boom/) + expect(workers[0].terminated).toBe(1) + }) + + it.each([ + ['ok:true without rows', { ok: true, schemaMissingParentId: false }], + ['ok:true with non-array rows', { ok: true, rows: 'nope', schemaMissingParentId: false }], + ['ok:true without schemaMissingParentId', { ok: true, rows: [] }], + ['ok:false without error', { ok: false }], + ['no ok key', { rows: [] }], + ])('rejects a malformed message (%s) instead of resolving undefined', async (_label, msg) => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitMessage(msg) + await expect(promise).rejects.toThrow(/malformed|failed/i) + expect(workers[0].terminated).toBe(1) + }) + + it('rejects on a worker error event and terminates', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitError(new Error('worker crashed')) + await expect(promise).rejects.toThrow(/worker crashed/) + expect(workers[0].terminated).toBe(1) + }) + + it('rejects when the worker exits before sending a message', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitExit(1) + await expect(promise).rejects.toThrow(/exit/i) + }) + + it('rejects and terminates on timeout', async () => { + vi.useFakeTimers() + try { + const { runner, workers } = makeRunner({ timeoutMs: 25 }) + const promise = runner(input) + await Promise.resolve() + const expectation = expect(promise).rejects.toThrow(/timed out/i) + await vi.advanceTimersByTimeAsync(30) + await expectation + expect(workers[0].terminated).toBe(1) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/test/unit/server/coding-cli/opencode-listing-worker.test.ts b/test/unit/server/coding-cli/opencode-listing-worker.test.ts new file mode 100644 index 00000000..30569a5d --- /dev/null +++ b/test/unit/server/coding-cli/opencode-listing-worker.test.ts @@ -0,0 +1,51 @@ +import path from 'path' +import os from 'os' +import fsp from 'fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { executeListing, OPENCODE_LISTING_WORKER_KIND } from '../../../../server/coding-cli/providers/opencode-listing.worker' +import { THREE_VIEWS_MARKER_SQL_PATTERN } from '../../../../server/coding-cli/providers/opencode-listing-query' + +vi.unmock('node:sqlite') + +const queryModuleUrl = new URL('../../../../server/coding-cli/providers/opencode-listing-query.ts', import.meta.url).href + +describe('opencode listing worker executeListing', () => { + let tempDir: string + beforeEach(async () => { tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-worker-')) }) + afterEach(async () => { await fsp.rm(tempDir, { recursive: true, force: true }) }) + + it('dynamically imports the query module and returns its result', async () => { + const dbPath = path.join(tempDir, 'opencode.db') + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(dbPath) + try { + db.exec(` + CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, sandboxes text NOT NULL); + CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, parent_id text, slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer); + CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + `) + db.prepare(`INSERT INTO project VALUES (?, ?, ?, ?, ?)`).run('project-1', '/repo/root', 900, 4000, '[]') + db.prepare(`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?,?,?,?)`) + .run('s1', 'project-1', null, 's1', '/repo/root', 'Title', 'test', 1000, 2000, null) + } finally { + db.close() + } + + const result = await executeListing({ queryModuleUrl, dbPath, markerPattern: THREE_VIEWS_MARKER_SQL_PATTERN }) + expect(result.schemaMissingParentId).toBe(false) + expect(result.rows.map((r) => r.sessionId)).toEqual(['s1']) + }) + + it('does not auto-run on import under the threaded test runtime (sentinel guard)', () => { + // The server Vitest config uses pool: 'threads', so this test file runs inside + // a worker thread (parentPort is non-null). Importing the worker module at the + // top of this file must NOT have triggered executeListing against Vitest's + // workerData — the sentinel guard (workerData.kind !== OPENCODE_LISTING_WORKER_KIND) + // prevents it. If the guard were broken, the import would have posted to Vitest's + // parent port and likely corrupted the run; reaching this assertion proves it didn't. + expect(typeof executeListing).toBe('function') + expect(OPENCODE_LISTING_WORKER_KIND).toBe('opencode-listing-worker') + expect(queryModuleUrl).toContain('opencode-listing-query') + }) +}) diff --git a/test/unit/server/coding-cli/opencode-provider.sqlite.test.ts b/test/unit/server/coding-cli/opencode-provider.sqlite.test.ts index 0cfb8271..4184f3c8 100644 --- a/test/unit/server/coding-cli/opencode-provider.sqlite.test.ts +++ b/test/unit/server/coding-cli/opencode-provider.sqlite.test.ts @@ -3,6 +3,7 @@ import os from 'os' import fsp from 'fs/promises' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { OpencodeProvider } from '../../../../server/coding-cli/providers/opencode' +import { inProcessListingRunner } from '../../../../server/coding-cli/providers/opencode-listing-runner' vi.unmock('node:sqlite') @@ -143,7 +144,7 @@ describe('OpencodeProvider SQLite marker detection', () => { db.close() } - const provider = new OpencodeProvider(tempDir) + const provider = new OpencodeProvider(tempDir, { queryRunner: inProcessListingRunner }) const sessions = await provider.listSessionsDirect() expect(sessions).toEqual([ diff --git a/test/unit/server/coding-cli/opencode-provider.test.ts b/test/unit/server/coding-cli/opencode-provider.test.ts index 41fd08b5..bff57422 100644 --- a/test/unit/server/coding-cli/opencode-provider.test.ts +++ b/test/unit/server/coding-cli/opencode-provider.test.ts @@ -132,6 +132,7 @@ vi.mock('node:sqlite', () => ({ })) import { OpencodeProvider } from '../../../../server/coding-cli/providers/opencode' +import { inProcessListingRunner } from '../../../../server/coding-cli/providers/opencode-listing-runner' describe('OpencodeProvider', () => { let tempDir: string @@ -191,7 +192,7 @@ describe('OpencodeProvider', () => { ], }) - const provider = new OpencodeProvider(tempDir) + const provider = new OpencodeProvider(tempDir, { queryRunner: inProcessListingRunner }) const sessions = await provider.listSessionsDirect() expect(provider.getSessionGlob()).toEqual([dbPath, walPath]) @@ -211,7 +212,7 @@ describe('OpencodeProvider', () => { }) it('watches OpenCode sqlite database and WAL but not SHM', () => { - const provider = new OpencodeProvider(tempDir) + const provider = new OpencodeProvider(tempDir, { queryRunner: inProcessListingRunner }) const dbPath = path.join(tempDir, 'opencode.db') const walPath = `${dbPath}-wal` @@ -221,7 +222,7 @@ describe('OpencodeProvider', () => { }) it('logs missing OpenCode database as unavailable, not as a successful empty session list', async () => { - const provider = new OpencodeProvider(tempDir) + const provider = new OpencodeProvider(tempDir, { queryRunner: inProcessListingRunner }) await expect(provider.listSessionsDirect()).resolves.toEqual([]) @@ -240,9 +241,10 @@ describe('OpencodeProvider', () => { const dbPath = path.join(tempDir, 'opencode.db') await fsp.writeFile(dbPath, 'fake sqlite file', 'utf8') FakeDatabaseSync.failOpenOnce(new Error('bad sqlite')) - const provider = new OpencodeProvider(tempDir) + const provider = new OpencodeProvider(tempDir, { queryRunner: inProcessListingRunner }) - await expect(provider.listSessionsDirect()).resolves.toEqual([]) + // A worker/read failure now THROWS (so the indexer preserves the prior sidebar). + await expect(provider.listSessionsDirect()).rejects.toThrow() expect(loggerMock.warn).toHaveBeenCalledWith(expect.objectContaining({ provider: 'opencode', @@ -250,7 +252,9 @@ describe('OpencodeProvider', () => { dbFile: 'opencode.db', pathSanitized: true, errorName: 'Error', - messageClass: 'sqlite_open_failed', + // open/schema/query phases collapse to a single read_error class once the + // open happens inside the worker; intent (distinct from empty_db) preserved. + messageClass: 'read_error', }), 'Failed to read OpenCode sessions database') const serializedCalls = JSON.stringify(loggerMock.warn.mock.calls) expect(serializedCalls).not.toContain(tempDir) @@ -258,6 +262,29 @@ describe('OpencodeProvider', () => { expect(serializedCalls).not.toContain('bad sqlite') }) + it('throws (does not return []) when the listing runner fails, and logs read_error', async () => { + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-fail-')) + await fsp.writeFile(path.join(dir, 'opencode.db'), '') // make access() succeed + const failingRunner = vi.fn().mockRejectedValue(new Error('worker exploded')) + const provider = new OpencodeProvider(dir, { queryRunner: failingRunner }) + await expect(provider.listSessionsDirect()).rejects.toThrow('worker exploded') + expect(failingRunner).toHaveBeenCalledOnce() + expect(loggerMock.warn).toHaveBeenCalledWith( + expect.objectContaining({ messageClass: 'read_error' }), + 'Failed to read OpenCode sessions database', + ) + await fsp.rm(dir, { recursive: true, force: true }) + }) + + it('still returns [] (not throw) for a genuinely empty database', async () => { + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-empty-')) + await fsp.writeFile(path.join(dir, 'opencode.db'), '') + const emptyRunner = vi.fn().mockResolvedValue({ rows: [], schemaMissingParentId: false }) + const provider = new OpencodeProvider(dir, { queryRunner: emptyRunner }) + await expect(provider.listSessionsDirect()).resolves.toEqual([]) + await fsp.rm(dir, { recursive: true, force: true }) + }) + it('logs an empty OpenCode database as empty, not broken', async () => { const dbPath = path.join(tempDir, 'opencode.db') await fsp.writeFile(dbPath, 'fake sqlite file', 'utf8') @@ -265,7 +292,7 @@ describe('OpencodeProvider', () => { projects: [], sessions: [], }) - const provider = new OpencodeProvider(tempDir) + const provider = new OpencodeProvider(tempDir, { queryRunner: inProcessListingRunner }) await expect(provider.listSessionsDirect()).resolves.toEqual([]) @@ -308,7 +335,7 @@ describe('OpencodeProvider', () => { ], }) - const provider = new OpencodeProvider(tempDir) + const provider = new OpencodeProvider(tempDir, { queryRunner: inProcessListingRunner }) const resolved = await provider.resolveOpencodeSessionRoots(['child_session']) expect(resolved.rootsBySessionId.get('child_session')).toBe('root_session') @@ -345,7 +372,7 @@ describe('OpencodeProvider', () => { }) FakeDatabaseSync.failRootQueryOnce(new Error('database is locked')) - const provider = new OpencodeProvider(tempDir) + const provider = new OpencodeProvider(tempDir, { queryRunner: inProcessListingRunner }) const resolved = await provider.resolveOpencodeSessionRoots(['child_session']) expect(resolved.rootsBySessionId.get('child_session')).toBe('root_session') @@ -378,7 +405,7 @@ describe('OpencodeProvider', () => { ], }) - const provider = new OpencodeProvider(tempDir) + const provider = new OpencodeProvider(tempDir, { queryRunner: inProcessListingRunner }) const resolved = await provider.resolveOpencodeSessionRoots(['flat_session']) const sessions = await provider.listSessionsDirect() diff --git a/test/unit/server/coding-cli/session-indexer-provider-refresh.test.ts b/test/unit/server/coding-cli/session-indexer-provider-refresh.test.ts index aaa39fd8..aea09b02 100644 --- a/test/unit/server/coding-cli/session-indexer-provider-refresh.test.ts +++ b/test/unit/server/coding-cli/session-indexer-provider-refresh.test.ts @@ -17,6 +17,12 @@ vi.mock('../../../../server/config-store', () => ({ }, })) +const loggerMock = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn(), child: vi.fn() })) +loggerMock.child.mockReturnValue(loggerMock) +vi.mock('../../../../server/logger', () => ({ logger: loggerMock, sessionLifecycleLogger: loggerMock })) + +import { configStore } from '../../../../server/config-store' + function makeDirectProvider(): CodingCliProvider { return { name: 'opencode', @@ -58,4 +64,47 @@ describe('CodingCliSessionIndexer provider refresh', () => { expect(provider.listSessionsDirect).toHaveBeenCalledTimes(1) }) + + it('preserves cached direct-provider sessions (and does not warn-log the raw error) when listSessionsDirect throws during a full scan', async () => { + vi.useRealTimers() // this test drives refresh() directly, no timers needed + loggerMock.warn.mockClear() + loggerMock.debug.mockClear() + // First refresh: opencode enabled. Second refresh: enabled-set changes + // (add 'claude') -> enabledKey changes -> needsFullScan -> full-scan path. + vi.mocked(configStore.snapshot) + .mockResolvedValueOnce({ settings: { codingCli: { enabledProviders: ['opencode'], providers: {} } } } as never) + .mockResolvedValueOnce({ settings: { codingCli: { enabledProviders: ['opencode', 'claude'], providers: {} } } } as never) + + const sessions = [{ provider: 'opencode', sessionId: 's1', projectPath: '/repo', cwd: '/repo', lastActivityAt: 2000, createdAt: 1000 }] + const listSessionsDirect = vi.fn() + .mockResolvedValueOnce(sessions) // full scan #1 succeeds -> s1 cached + .mockRejectedValue(new Error('worker exploded at /secret/path')) // full scan #2 throws + const provider = { ...makeDirectProvider(), listSessionsDirect } + const indexer = new CodingCliSessionIndexer([provider]) + + await indexer.refresh() // full scan #1 (needsFullScan defaults true) + expect(indexer.getProjects().flatMap((g) => g.sessions).map((s) => s.sessionId)).toEqual(['s1']) + + await indexer.refresh() // full scan #2 (enabled-set changed) — listSessionsDirect throws + // The cached session must survive the global full-scan prune. + expect(indexer.getProjects().flatMap((g) => g.sessions).map((s) => s.sessionId)).toEqual(['s1']) + + // The catch must NOT warn-log the failure (it logs debug now), and NO warn/debug + // payload may carry a raw `err`/Error. NOTE: do NOT assert via JSON.stringify — + // Error objects serialize to "{}", so a leaked `{ err: new Error('/secret/path') }` + // would pass a string check. Inspect the call args STRUCTURALLY. + expect(loggerMock.warn).not.toHaveBeenCalledWith(expect.anything(), 'Could not list provider sessions directly') + const allLogCalls = [...loggerMock.warn.mock.calls, ...loggerMock.debug.mock.calls] + for (const [payload] of allLogCalls) { + if (payload && typeof payload === 'object') { + expect(Object.prototype.hasOwnProperty.call(payload, 'err')).toBe(false) + expect(Object.values(payload).some((v) => v instanceof Error)).toBe(false) + } + } + // Assert the exact intended debug call shape (provider only, no error). + expect(loggerMock.debug).toHaveBeenCalledWith( + { provider: 'opencode' }, + 'Direct provider listing failed; preserving cached sessions', + ) + }) })