From c2689a7ecb1e763a5d8c2e96bf7755ed44fcdc12 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:13:03 -0700 Subject: [PATCH 1/6] =?UTF-8?q?fix(server):=20harden=20claude=20transcript?= =?UTF-8?q?=20locator=20=E2=80=94=20multi-root,=20subagents,=20error=20pro?= =?UTF-8?q?pagation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the merged locator with the reference implementation's robust version, preserving the exported interface (ClaudeTranscriptHit with sourceFile; locateClaudeTranscript now accepts one root or several): - multi-root: accepts string | readonly string[] so secondary claude project roots are searchable without caller changes - subagent layout: second pass probes //subagents/.jsonl for index-missed child sessions (direct layout still wins on pass ordering) - error contract: only ENOENT/ENOTDIR read as a miss; every other fs failure (EACCES, EIO, ...) propagates as the typed ClaudeTranscriptLocatorError β€” the health seam for the provider-health lane. Provider failure must never read as "not found". Tests: RED first (multi-root, subagent, and both EACCES cases fail against the old locator), then green β€” 12/12 locator integration tests, 14/14 sessions-resolve-router, typecheck clean. πŸ€– Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../coding-cli/claude-transcript-locator.ts | 140 ++++++++++++++---- .../server/claude-transcript-locator.test.ts | 77 +++++++++- 2 files changed, 186 insertions(+), 31 deletions(-) diff --git a/server/coding-cli/claude-transcript-locator.ts b/server/coding-cli/claude-transcript-locator.ts index 0b98108e0..97f061928 100644 --- a/server/coding-cli/claude-transcript-locator.ts +++ b/server/coding-cli/claude-transcript-locator.ts @@ -7,56 +7,136 @@ export interface ClaudeTranscriptHit { cwd?: string } +/** + * HEALTH SEAM: typed wrapper for locator provider failures. + * + * The locator distinguishes "transcript absent" (null) from "the claude store + * could not be searched" (this error). Callers today (resolve-session.ts) do + * not catch it, so a provider failure propagates instead of silently reading + * as "not found". The later provider-health lane catches THIS class at the + * resolve seam to record providerErrors and report 'degraded'. + */ +export class ClaudeTranscriptLocatorError extends Error { + /** errno code from the underlying fs failure (e.g. 'EACCES', 'EIO'). */ + readonly code?: string + + constructor(message: string, cause: unknown) { + super(message, { cause }) + this.name = 'ClaudeTranscriptLocatorError' + this.code = (cause as NodeJS.ErrnoException | null)?.code + } +} + const UUID_ONLY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ +const CWD_SCAN_BYTES = 64 * 1024 + +/** Expected-absence errors are misses; EVERYTHING else is a provider failure. */ +function isAbsenceError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | null)?.code + return code === 'ENOENT' || code === 'ENOTDIR' +} /** * Exact-id fallback for claude sessions the index cannot see (e.g. cold-start - * skipped cwd-less transcripts). Scans //.jsonl. + * skipped cwd-less transcripts, subagent child sessions). Claude stores + * transcripts in TWO layouts: + * 1. direct: //.jsonl + * 2. subagent: ///subagents/.jsonl + * An exact pasted id must resolve for BOTH β€” child sessions included β€” so the + * locator probes the cheap direct layout first (one readdir per root + one + * stat per project dir) and only on a total miss falls back to the subagent + * layout (one readdir per project dir + one stat per session subdirectory). + * + * Accepts one root or several (claude can have secondary project roots). + * + * ERROR CONTRACT: expected absence (ENOENT/ENOTDIR β€” missing root, missing + * transcript, non-directory entries probed as directories) is a miss (null). + * Any OTHER failure (EACCES, EMFILE, EIO, …) PROPAGATES as + * ClaudeTranscriptLocatorError β€” a provider failure must never read as + * "not found". */ export async function locateClaudeTranscript( sessionId: string, - projectsDir: string, + projectsDir: string | readonly string[], ): Promise { + // Claude writes lowercase-UUID transcript filenames; the input contract + // accepts UUIDs in ANY case and Linux filesystems are case-sensitive, so + // normalize before building paths and return the canonical lowercase id. const normalized = sessionId.toLowerCase() if (!UUID_ONLY_RE.test(normalized)) return null + const roots = typeof projectsDir === 'string' ? [projectsDir] : projectsDir - let entries: string[] - try { - entries = await fsp.readdir(projectsDir) - } catch { - return null - } - - for (const entry of entries) { - const candidate = path.join(projectsDir, entry, `${normalized}.jsonl`) - try { - const stat = await fsp.stat(candidate) - if (!stat.isFile()) continue - } catch { - continue + // PASS 1 β€” direct layout. + for (const root of roots) { + for (const dir of await readdirOrEmpty(root)) { + const hit = await probeTranscript(path.join(root, dir, `${normalized}.jsonl`), normalized) + if (hit) return hit } - return { - sessionId: normalized, - sourceFile: candidate, - cwd: await readCwdFromTranscript(candidate), + } + // PASS 2 β€” subagent layout (only when the direct layout missed everywhere). + for (const root of roots) { + for (const dir of await readdirOrEmpty(root)) { + const projectDir = path.join(root, dir) + for (const entry of await readdirOrEmpty(projectDir)) { + const hit = await probeTranscript( + path.join(projectDir, entry, 'subagents', `${normalized}.jsonl`), + normalized, + ) + if (hit) return hit + } } } return null } +/** readdir treating absence as empty; anything else PROPAGATES (provider failure). */ +async function readdirOrEmpty(dir: string): Promise { + try { + return await fsp.readdir(dir) + } catch (err) { + if (isAbsenceError(err)) return [] + throw new ClaudeTranscriptLocatorError(`failed to list claude projects dir: ${dir}`, err) + } +} + +/** stat + cwd-read for one candidate; absence = null, anything else PROPAGATES. */ +async function probeTranscript( + candidate: string, + id: string, +): Promise { + try { + const stat = await fsp.stat(candidate) + if (!stat.isFile()) return null + } catch (err) { + if (isAbsenceError(err)) return null + throw new ClaudeTranscriptLocatorError(`failed to probe claude transcript: ${candidate}`, err) + } + return { + sessionId: id, + sourceFile: candidate, + cwd: await readCwdFromTranscript(candidate), + } +} + async function readCwdFromTranscript(filePath: string): Promise { + let handle + try { + handle = await fsp.open(filePath, 'r') + } catch (err) { + // The file existed a moment ago (stat succeeded): absence = raced + // deletion (miss the cwd only); anything else is a provider failure. + if (isAbsenceError(err)) return undefined + throw new ClaudeTranscriptLocatorError(`failed to open claude transcript: ${filePath}`, err) + } let head: string try { - const handle = await fsp.open(filePath, 'r') - try { - const buffer = Buffer.alloc(64 * 1024) - const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) - head = buffer.subarray(0, bytesRead).toString('utf8') - } finally { - await handle.close() - } - } catch { - return undefined + const buffer = Buffer.alloc(CWD_SCAN_BYTES) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) + head = buffer.subarray(0, bytesRead).toString('utf8') + } catch (err) { + throw new ClaudeTranscriptLocatorError(`failed to read claude transcript: ${filePath}`, err) + } finally { + await handle.close() } for (const line of head.split('\n')) { const trimmed = line.trim() diff --git a/test/integration/server/claude-transcript-locator.test.ts b/test/integration/server/claude-transcript-locator.test.ts index 62014c2de..94b52f29d 100644 --- a/test/integration/server/claude-transcript-locator.test.ts +++ b/test/integration/server/claude-transcript-locator.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest' import fsp from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { locateClaudeTranscript } from '../../../server/coding-cli/claude-transcript-locator.js' +import { + ClaudeTranscriptLocatorError, + locateClaudeTranscript, +} from '../../../server/coding-cli/claude-transcript-locator.js' const SESSION_ID = 'ed2afda6-a340-443e-ba60-024a1b3554b4' @@ -66,4 +69,76 @@ describe('locateClaudeTranscript', () => { locateClaudeTranscript(SESSION_ID, path.join(projectsDir, 'missing')), ).resolves.toBeNull() }) + + it('tolerates a plain file entry inside the projects dir (ENOTDIR is a miss)', async () => { + await fsp.writeFile(path.join(projectsDir, 'stray.txt'), 'not a project dir', 'utf8') + await expect(locateClaudeTranscript(SESSION_ID, projectsDir)).resolves.toBeNull() + }) + + it('searches SECONDARY roots when given multiple project roots', async () => { + // Claude can have more than one projects root; the locator must not be + // single-root only. + const secondRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'claude-projects-2-')) + try { + const dir = path.join(secondRoot, '-repo-delta') + await fsp.mkdir(dir, { recursive: true }) + const file = path.join(dir, `${SESSION_ID}.jsonl`) + await fsp.writeFile(file, JSON.stringify({ cwd: '/repo/delta' }), 'utf8') + await expect(locateClaudeTranscript(SESSION_ID, [projectsDir, secondRoot])).resolves.toEqual({ + sessionId: SESSION_ID, + sourceFile: file, + cwd: '/repo/delta', + }) + } finally { + await fsp.rm(secondRoot, { recursive: true, force: true }) + } + }) + + it('finds a SUBAGENT transcript at //subagents/.jsonl (index-missed child session)', async () => { + // Claude also stores child-session transcripts one level deeper (see + // claude.ts listSessionFiles): the exact-id contract covers them too. + const PARENT_ID = 'aaaaaaaa-a340-443e-ba60-024a1b3554b4' + const dir = path.join(projectsDir, '-repo-alpha', PARENT_ID, 'subagents') + await fsp.mkdir(dir, { recursive: true }) + const file = path.join(dir, `${SESSION_ID}.jsonl`) + await fsp.writeFile(file, JSON.stringify({ cwd: '/repo/alpha' }) + '\n', 'utf8') + const hit = await locateClaudeTranscript(SESSION_ID, projectsDir) + expect(hit).not.toBeNull() + expect(hit?.cwd).toBe('/repo/alpha') + expect(hit?.sourceFile).toBe(file) + }) + + it('prefers the direct layout when both layouts contain the id (pass ordering)', async () => { + await writeTranscript('-repo-alpha', SESSION_ID, [JSON.stringify({ cwd: '/direct' })]) + const dir = path.join(projectsDir, '-repo-alpha', 'aaaaaaaa-a340-443e-ba60-024a1b3554b4', 'subagents') + await fsp.mkdir(dir, { recursive: true }) + await fsp.writeFile(path.join(dir, `${SESSION_ID}.jsonl`), JSON.stringify({ cwd: '/sub' }) + '\n', 'utf8') + const hit = await locateClaudeTranscript(SESSION_ID, projectsDir) + expect(hit?.cwd).toBe('/direct') + }) + + it('PROPAGATES non-absence failures on the projects root (EACCES) instead of swallowing them as a miss', async () => { + // Provider failure β‰  not found: an unreadable root must reject so the + // provider-health lane can report 'degraded', never "no matching session". + // chmod-based: CI/dev runs unprivileged (root would bypass the mode bits). + const lockedRoot = path.join(projectsDir, 'locked') + await fsp.mkdir(lockedRoot, { recursive: true }) + await fsp.chmod(lockedRoot, 0o000) + try { + await expect(locateClaudeTranscript(SESSION_ID, lockedRoot)).rejects.toThrow() + } finally { + await fsp.chmod(lockedRoot, 0o700) + } + }) + + it('PROPAGATES non-absence failures on a project dir probe (EACCES on stat) instead of swallowing', async () => { + const dir = path.join(projectsDir, '-repo-locked') + await fsp.mkdir(dir, { recursive: true }) + await fsp.chmod(dir, 0o000) + try { + await expect(locateClaudeTranscript(SESSION_ID, projectsDir)).rejects.toThrow() + } finally { + await fsp.chmod(dir, 0o700) + } + }) }) From bd28331c035c932ae1bbcb9737184423d140d3c3 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:16:01 -0700 Subject: [PATCH 2/6] =?UTF-8?q?fix(client):=20graft=20safety=20guards=20in?= =?UTF-8?q?to=20Resume=20dialog=20=E2=80=94=20stale-response,=20edit-inval?= =?UTF-8?q?idation,=20cwd=20gating,=20focus=20trap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the reference implementation's (feat/resume-button) client-side guards into the merged Resume dialog, keeping the merged dialog's phase machine, warming bounded auto-retry, zod contract parse, and testids: - Stale-response guard: a resolve sequence ref; only the LATEST request may mutate state, so an out-of-order response can never override results or auto-resume the WRONG session. Closing also invalidates in-flight resolves. - Edit-invalidation: typing bumps the sequence and resets the phase, so stale "Resume anyway"/disambiguation actions can never act on old tokens. - cwd-required gating: auto-resume only for a single match WITH a recorded cwd on a ready response; cwd-less matches render in the match list with a required editable working-directory field (blank blocks with an inline error); "Resume anyway" is disabled while the cwd field is blank. '~' keeps the documented server-home convention. - Never-auto-resume on non-ready responses, with a clearly marked DEGRADED SEAM comment where status==='degraded' handling plugs in once the provider-health lane extends the contract. - Modal a11y per the repo confirm-modal convention: Tab focus trap, focus restore on close, background scroll lock. πŸ€– Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- src/components/ResumeSessionDialog.tsx | 162 +++++++++++++++++- .../components/ResumeSessionDialog.test.tsx | 122 ++++++++++++- 2 files changed, 274 insertions(+), 10 deletions(-) diff --git a/src/components/ResumeSessionDialog.tsx b/src/components/ResumeSessionDialog.tsx index 7b8558f65..a4eab2b68 100644 --- a/src/components/ResumeSessionDialog.tsx +++ b/src/components/ResumeSessionDialog.tsx @@ -41,6 +41,21 @@ export interface ResumeSessionDialogProps { const providers = DEFAULT_ENABLED_CLI_PROVIDERS as readonly string[] +// Focus-trap helper β€” same pattern as src/components/ui/confirm-modal.tsx +// (repo modal a11y convention: trap Tab, restore focus, lock scroll). +function getFocusable(container: HTMLElement): HTMLElement[] { + const selectors = [ + 'button', + '[href]', + 'input', + 'select', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + ] + return Array.from(container.querySelectorAll(selectors.join(','))) + .filter((el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')) +} + export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSessionDialogProps) { const dispatch = useAppDispatch() const store = useStore() @@ -49,9 +64,14 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession const [agentTouched, setAgentTouched] = useState(false) const [anywayCwd, setAnywayCwd] = useState('~') const [phase, setPhase] = useState({ kind: 'idle' }) + // Inline error for confirming a cwd-less match with a blank cwd field. + const [matchCwdError, setMatchCwdError] = useState(false) const inputRef = useRef(null) + const dialogRef = useRef(null) const closeTimerRef = useRef(undefined) const warmingRetriesRef = useRef(0) + // Stale-response guard: only the LATEST resolve request may mutate state. + const resolveSeqRef = useRef(0) // Advisory hint pre-fills the picker; never overrides a manual choice. useEffect(() => { @@ -77,6 +97,11 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession setPhase({ kind: 'no-token' }) return } + // Stale-response guard: bump the sequence; only the LATEST request may + // mutate state. A stale single-match response must NEVER auto-resume β€” + // it could open the WRONG session. + const seq = ++resolveSeqRef.current + setMatchCwdError(false) setPhase({ kind: 'resolving' }) let response try { @@ -84,10 +109,19 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession await api.post('/api/sessions/resolve', { input: trimmed }), ) } catch { + if (seq !== resolveSeqRef.current) return // stale β€” ignore setPhase({ kind: 'request-failed' }) return } - if (response.status === 'warming') { + if (seq !== resolveSeqRef.current) return // stale β€” ignore + if (response.status !== 'ready') { + // Any non-ready response is a retry state, never "not found" β€” and it + // must NEVER reach the auto-resume below. + // DEGRADED SEAM: when the provider-health lane extends the contract + // (status === 'degraded' + providerErrors/unsearchedProviders), handle + // 'degraded' here as its own retry state: a failed provider means a + // higher-priority exact match may have been missed, so auto-opening a + // surviving match could open the WRONG session. if (warmingRetriesRef.current >= WARMING_RETRY_LIMIT) { setPhase({ kind: 'index-unavailable' }) return @@ -96,12 +130,15 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession setPhase({ kind: 'warming' }) return } - if (response.matches.length === 1) { + // Auto-resume needs a healthy response AND a concrete recorded cwd: a + // lone match without one renders in the match list below alongside an + // editable working-directory field (spec: never open without a cwd). + if (response.matches.length === 1 && response.matches[0].cwd) { const found = response.matches[0] finishResume(found, `Found in ${found.provider}`) return } - if (response.matches.length > 1) { + if (response.matches.length >= 1) { setPhase({ kind: 'disambiguate', matches: response.matches }) return } @@ -137,8 +174,23 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession [], ) + // Closing invalidates any in-flight resolve. useEffect(() => { - if (open) inputRef.current?.focus() + if (!open) resolveSeqRef.current += 1 + }, [open]) + + // Modal a11y (mirrors src/components/ui/confirm-modal.tsx): capture + restore + // the previously focused element, lock background scroll, focus the paste field. + useEffect(() => { + if (!open) return + const previousFocus = document.activeElement as HTMLElement | null + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + inputRef.current?.focus() + return () => { + document.body.style.overflow = previousOverflow || '' + previousFocus?.focus() + } }, [open]) if (!open) return null @@ -149,18 +201,47 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession setPhase({ kind: 'no-token' }) return } + // cwd-required gating: the button is disabled while the field is blank; + // this is the backstop. '~' stays the documented server-home default. const cwd = anywayCwd.trim() + if (cwd === '') return finishResume( { provider: agent, sessionId: token, sessionType: agent, - cwd: cwd === '' || cwd === '~' ? undefined : cwd, + cwd: cwd === '~' ? undefined : cwd, }, `Resuming with ${agent}`, ) } + // Confirming a listed match: a match with a recorded cwd resumes directly; a + // cwd-less match (exact-id fallback hit) requires the editable + // working-directory field β€” a session must NEVER open from a blank field. + const confirmMatch = (candidate: ResumeResolveMatch) => { + if (candidate.cwd) { + finishResume(candidate, `Found in ${candidate.provider}`) + return + } + const cwd = anywayCwd.trim() + if (cwd === '') { + setMatchCwdError(true) + return + } + finishResume( + { + provider: candidate.provider, + sessionId: candidate.sessionId, + sessionType: candidate.sessionType, + cwd: cwd === '~' ? undefined : cwd, + title: candidate.title, + firstUserMessage: candidate.firstUserMessage, + }, + `Found in ${candidate.provider}`, + ) + } + const controlClass = 'min-w-0 flex-1 h-7 px-2 text-xs bg-muted/50 border-0 rounded-md focus:outline-none focus:ring-1 focus:ring-border' @@ -171,6 +252,7 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession > {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- same convention as App.tsx's update-instructions dialog: the container's onClick is a stopPropagation shield and onKeyDown handles Escape; the dialog's real controls are native buttons/inputs. */}
event.stopPropagation()} onKeyDown={(event) => { - if (event.key === 'Escape') onClose() + if (event.key === 'Escape') { + onClose() + return + } + if (event.key !== 'Tab') return + // Focus trap β€” repo modal pattern (see src/components/ui/confirm-modal.tsx). + const dialog = dialogRef.current + if (!dialog) return + const focusables = getFocusable(dialog) + if (focusables.length === 0) { + event.preventDefault() + return + } + const first = focusables[0] + const last = focusables[focusables.length - 1] + const active = document.activeElement as HTMLElement | null + if (event.shiftKey) { + if (active === first || !dialog.contains(active)) { + event.preventDefault() + last.focus() + } + } else if (active === last) { + event.preventDefault() + first.focus() + } }} >

Resume a session

@@ -192,7 +298,16 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession value={input} rows={3} className="w-full text-xs bg-muted/50 border-0 rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-border resize-none" - onChange={(event) => setInput(event.target.value)} + onChange={(event) => { + setInput(event.target.value) + // EDITING invalidates everything derived from the previous text: + // bump the sequence so in-flight responses go stale, and reset the + // phase so stale "Resume anyway"/disambiguation actions can never + // act on old tokens. + resolveSeqRef.current += 1 + setMatchCwdError(false) + setPhase({ kind: 'idle' }) + }} onKeyDown={(event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() @@ -292,7 +407,7 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession type="button" data-testid="resume-match" className="w-full text-left text-xs p-2 rounded-md bg-muted/50 hover:bg-muted focus:outline-none focus:ring-1 focus:ring-border" - onClick={() => finishResume(candidate, `Found in ${candidate.provider}`)} + onClick={() => confirmMatch(candidate)} > {candidate.title ?? candidate.firstUserMessage ?? candidate.sessionId} @@ -309,6 +424,34 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession ))} )} + {phase.kind === 'disambiguate' && phase.matches.some((candidate) => !candidate.cwd) && ( +
+
+ + { + setAnywayCwd(event.target.value) + setMatchCwdError(false) + }} + className={controlClass} + /> +
+

+ Required for sessions without a recorded working directory. ~ resolves to the + server's home directory. +

+ {matchCwdError && ( +
+ Enter a working directory to open this session. +
+ )} +
+ )} {phase.kind === 'no-match' && (
@@ -333,7 +476,8 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession type="button" data-testid="resume-anyway-button" onClick={resumeAnyway} - className="h-8 px-3 text-xs rounded-md bg-muted/50 hover:bg-muted focus:outline-none focus:ring-1 focus:ring-border" + disabled={anywayCwd.trim() === ''} + className="h-8 px-3 text-xs rounded-md bg-muted/50 hover:bg-muted focus:outline-none focus:ring-1 focus:ring-border disabled:opacity-50 disabled:cursor-not-allowed" > Resume anyway with {agent} diff --git a/test/unit/client/components/ResumeSessionDialog.test.tsx b/test/unit/client/components/ResumeSessionDialog.test.tsx index 9ebbcd7b0..70fce2c23 100644 --- a/test/unit/client/components/ResumeSessionDialog.test.tsx +++ b/test/unit/client/components/ResumeSessionDialog.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { Provider } from 'react-redux' import { configureStore } from '@reduxjs/toolkit' @@ -182,4 +182,124 @@ describe('ResumeSessionDialog', () => { fireEvent.keyDown(screen.getByTestId('resume-dialog'), { key: 'Escape' }) expect(onClose).toHaveBeenCalled() }) + + it('ignores STALE responses: a late first response cannot override or auto-resume', async () => { + // Overlapping resolves (edit-then-Enter) can deliver out of order; a stale + // single-match response must NEVER auto-resume β€” it could open the WRONG session. + let resolveFirst!: (value: unknown) => void + apiPost + .mockReturnValueOnce(new Promise((resolve) => { resolveFirst = resolve })) + .mockReturnValueOnce(ok([])) + renderDialog() + typeAndResolve('ed2afda6') + typeAndResolve(V4) + await screen.findByTestId('resume-error') + expect(screen.getByTestId('resume-error').textContent).toMatch(/no matching session/i) + // The stale FIRST response now arrives with a single match: ignore it. + await act(async () => { + resolveFirst({ status: 'ready', matches: [match()], hint: null }) + await Promise.resolve() + await Promise.resolve() + }) + expect(resumeSessionInTab).not.toHaveBeenCalled() + expect(screen.getByTestId('resume-error').textContent).toMatch(/no matching session/i) + }) + + it('EDITING the input invalidates the previous result: stale resume-anyway cannot act', async () => { + // Without this, resolve(A) -> "not found" -> replace text with B -> + // "Resume anyway" would still be actionable against STALE id A. + apiPost.mockReturnValue(ok([])) + renderDialog() + typeAndResolve(V4) + await screen.findByTestId('resume-anyway-button') + fireEvent.change(screen.getByTestId('resume-input'), { target: { value: SES } }) + expect(screen.queryByTestId('resume-anyway-button')).toBeNull() + expect(screen.queryByTestId('resume-error')).toBeNull() + }) + + it('single match WITHOUT a cwd does NOT auto-resume: asks for a working directory instead', async () => { + // Exact-id fallback hits can lack a recorded cwd; the spec requires a + // concrete working directory before opening β€” never auto-open without one. + apiPost.mockReturnValue(ok([match({ cwd: undefined })])) + renderDialog() + typeAndResolve(V7) + const row = await screen.findByTestId('resume-match') + expect(resumeSessionInTab).not.toHaveBeenCalled() + fireEvent.change(screen.getByTestId('resume-anyway-cwd'), { target: { value: '/repo/beta' } }) + fireEvent.click(row) + expect(resumeSessionInTab).toHaveBeenCalledTimes(1) + expect(resumeSessionInTab.mock.calls[0][2]).toMatchObject({ + provider: 'codex', + sessionId: V7, + cwd: '/repo/beta', + }) + }) + + it('cwd-less match with a BLANK working-directory field: confirm is blocked with an inline error', async () => { + apiPost.mockReturnValue(ok([match({ cwd: undefined })])) + renderDialog() + typeAndResolve(V7) + const row = await screen.findByTestId('resume-match') + fireEvent.change(screen.getByTestId('resume-anyway-cwd'), { target: { value: ' ' } }) + fireEvent.click(row) + expect(resumeSessionInTab).not.toHaveBeenCalled() + expect(screen.getByTestId('resume-error').textContent).toMatch(/working directory/i) + }) + + it('"Resume anyway" is DISABLED while the working-directory field is blank', async () => { + apiPost.mockReturnValue(ok([])) + renderDialog() + typeAndResolve(V4) + const anyway = await screen.findByTestId('resume-anyway-button') + fireEvent.change(screen.getByTestId('resume-anyway-cwd'), { target: { value: ' ' } }) + expect(anyway).toBeDisabled() + fireEvent.click(anyway) + expect(resumeSessionInTab).not.toHaveBeenCalled() + }) + + it('a NON-ready response never auto-resumes, even with a single cwd match (degraded seam)', async () => { + // Pins the ordering the future 'degraded' status depends on: any non-ready + // response must be handled as a retry state BEFORE match handling. + apiPost.mockReturnValue(Promise.resolve({ status: 'warming', matches: [match()], hint: null })) + renderDialog() + typeAndResolve(V7) + await screen.findByTestId('resume-warming') + expect(resumeSessionInTab).not.toHaveBeenCalled() + }) + + it('traps Tab focus inside the dialog: wraps lastβ†’first and firstβ†’last (Shift+Tab)', () => { + renderDialog() + const dialog = screen.getByTestId('resume-dialog') + const input = screen.getByTestId('resume-input') + const resolveBtn = screen.getByTestId('resume-resolve-button') + resolveBtn.focus() + fireEvent.keyDown(dialog, { key: 'Tab' }) + expect(document.activeElement).toBe(input) + fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(resolveBtn) + }) + + it('locks background scroll while open; restores scroll and focus on close', () => { + const outside = document.createElement('button') + document.body.appendChild(outside) + outside.focus() + const store = configureStore({ + reducer: { connection: () => ({ serverInstanceId: 'srv-1' }) }, + }) + const onClose = vi.fn() + const { rerender } = render( + + + , + ) + expect(document.body.style.overflow).toBe('hidden') + rerender( + + + , + ) + expect(document.body.style.overflow).toBe('') + expect(document.activeElement).toBe(outside) + outside.remove() + }) }) From 0d3d54a2071153249b7c107a4a9354582910a7f9 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:24:09 -0700 Subject: [PATCH 3/6] fix(server): run opencode by-id resume lookups off-thread with shape gates and a per-request budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the reference implementation's (feat/resume-button) session-lookup engine onto the merged Resume feature, fixing the event-loop-stall defect: the resolve endpoint's opencode exact-id fallback ran OpencodeProvider.resolveOpencodeSessionRoots β€” synchronous DatabaseSync with a 5 s busy timeout x 3 attempts ON THE MAIN THREAD, once per ses_-shaped candidate, uncapped β€” so a locked opencode DB could stall the whole server ~15 s per candidate. - opencode-by-id-{query,runner}.ts + opencode-by-id.worker.ts: exact-id sqlite lookup (child + archived sessions included) executed in a short-lived worker thread with a 500 ms busy timeout, 15 s hard timeout, sentinel-guarded auto-run (Vitest thread-pool safe), full message-shape validation, and no double-settle. Errors REJECT (provider unavailable != not found). - resolve-fallbacks.ts: full-id shape gates enforced BEFORE the per-request budget (wrong-shape tokens are free no-op misses), FALLBACK_BUDGET_PER_REQUEST=2 per fallback, and metadata-store sessionType so freshclaude/kilroy/freshopencode sessions reopen through their recorded runtime instead of a hardcoded provider default. - resolve-session.ts: the exact-id fallback loop now consumes budget-wrapped ResolveFallbacks; fallback failures are logged and never reject the request (degraded/providerErrors channel is follow-up work). Matching core, contract, and match cap unchanged. - sessions-router.ts + index.ts: wire buildResolveFallbacks(providers, { sessionMetadataStore, locateClaudeTranscript }) in place of the main-thread resolveOpencodeSessionRoots fallback. - opencode.ts: make getDatabasePath() public for the fallback builder; resolveOpencodeSessionRoots itself is untouched (WS reconcile). Tests: ported by-id query suite (4), by-id runner suite incl. a REAL worker integration test (13), resolve-fallbacks suite (12); resolve router integration updated to the fallback seam plus new pins for shape gating, per-request budget freshness, and locked-DB non-failure (17). πŸ€– Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../providers/opencode-by-id-query.ts | 56 +++++ .../providers/opencode-by-id-runner.ts | 126 ++++++++++++ .../providers/opencode-by-id.worker.ts | 44 ++++ server/coding-cli/providers/opencode.ts | 3 +- server/coding-cli/resolve-fallbacks.ts | 137 +++++++++++++ server/coding-cli/resolve-session.ts | 84 +++----- server/index.ts | 25 +-- server/sessions-router.ts | 13 +- .../server/sessions-resolve-router.test.ts | 92 +++++++-- .../coding-cli/opencode-by-id-query.test.ts | 79 +++++++ .../coding-cli/opencode-by-id-runner.test.ts | 182 +++++++++++++++++ .../coding-cli/resolve-fallbacks.test.ts | 192 ++++++++++++++++++ 12 files changed, 934 insertions(+), 99 deletions(-) create mode 100644 server/coding-cli/providers/opencode-by-id-query.ts create mode 100644 server/coding-cli/providers/opencode-by-id-runner.ts create mode 100644 server/coding-cli/providers/opencode-by-id.worker.ts create mode 100644 server/coding-cli/resolve-fallbacks.ts create mode 100644 test/unit/server/coding-cli/opencode-by-id-query.test.ts create mode 100644 test/unit/server/coding-cli/opencode-by-id-runner.test.ts create mode 100644 test/unit/server/coding-cli/resolve-fallbacks.test.ts diff --git a/server/coding-cli/providers/opencode-by-id-query.ts b/server/coding-cli/providers/opencode-by-id-query.ts new file mode 100644 index 000000000..32ce7400c --- /dev/null +++ b/server/coding-cli/providers/opencode-by-id-query.ts @@ -0,0 +1,56 @@ +import type { OpencodeSessionRow } from './opencode-listing-query.js' + +/** + * SHORT busy timeout, deliberately much smaller than the listing query's 5 s: + * a locked DB must fail fast (the failure is surfaced as provider-unavailable, + * NOT "not found"). This synchronous function runs INSIDE THE WORKER THREAD + * (opencode-by-id.worker.ts) β€” same rule as the listing query, which was moved + * off the event loop even at ~180 ms. Never call it on the main thread in + * production: `DatabaseSync` blocks whatever thread runs it for up to this + * timeout when the DB is locked. + */ +const OPENCODE_BYID_BUSY_TIMEOUT_MS = 500 + +/** + * Exact-id opencode lookup for the resolve endpoint's fallback path β€” the + * Node-server sibling of the Rust by-id existence probe (#579). Unlike the + * listing query it deliberately includes ARCHIVED and CHILD sessions: an + * exact id pasted by the user must resolve even when the listing hides it. + * Lazy `node:sqlite` import for the same vi.mock/TDZ reason documented in + * opencode-listing-query.ts. Errors PROPAGATE to the caller (provider + * unavailable β‰  not found). + */ +export async function runOpencodeSessionByIdQuery( + dbPath: string, + sessionId: string, +): Promise { + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(dbPath, { readOnly: true }) + try { + db.exec(`PRAGMA busy_timeout = ${OPENCODE_BYID_BUSY_TIMEOUT_MS}`) + const tableNames = new Set( + (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as Array<{ name?: unknown }>) + .map((row) => row.name), + ) + if (!tableNames.has('session')) return null + const hasProject = tableNames.has('project') + const projectSelect = hasProject ? 'p.worktree' : 'NULL' + const projectJoin = hasProject ? 'LEFT JOIN project p ON p.id = s.project_id' : '' + const row = 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, + ${projectSelect} AS projectPath + FROM session s + ${projectJoin} + WHERE s.id = ? + LIMIT 1 + `).get(sessionId) as OpencodeSessionRow | undefined + return row ?? null + } finally { + db.close() + } +} diff --git a/server/coding-cli/providers/opencode-by-id-runner.ts b/server/coding-cli/providers/opencode-by-id-runner.ts new file mode 100644 index 000000000..91b882416 --- /dev/null +++ b/server/coding-cli/providers/opencode-by-id-runner.ts @@ -0,0 +1,126 @@ +import { Worker } from 'node:worker_threads' +import type { 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_BYID_WORKER_KIND } from './opencode-by-id.worker.js' + +export type OpencodeByIdQueryInput = { dbPath: string; sessionId: string } +export type OpencodeByIdQueryRunner = (input: OpencodeByIdQueryInput) => 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 CreateWorkerByIdRunnerOptions = { + /** Injectable for unit tests; default spawns a real worker_threads Worker. */ + spawn?: (workerUrl: URL, options: WorkerSpawnOptions) => WorkerLike + /** Override the query-module URL (used by off-thread integration fixtures). */ + queryModuleUrl?: string + /** Hard timeout for a single by-id query. Default 15 s (same as the listing runner). */ + 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-by-id.worker${SELF_EXT}`, import.meta.url) +} +function defaultQueryModuleUrl(): string { + return new URL(`./opencode-by-id-query${SELF_EXT}`, import.meta.url).href +} +function defaultSpawn(workerUrl: URL, options: WorkerSpawnOptions): WorkerLike { + return new Worker(workerUrl, options) +} + +type OkMessage = { ok: true; row: OpencodeSessionRow | null } +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 garbage as a hit or a miss. +function isOkMessage(value: unknown): value is OkMessage { + if (typeof value !== 'object' || value === null) return false + if ((value as { ok?: unknown }).ok !== true) return false + if (!('row' in (value as object))) return false + const row = (value as { row?: unknown }).row + return row === null || (typeof row === 'object' && row !== null) +} +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 createWorkerByIdRunner( + options: CreateWorkerByIdRunnerOptions = {}, +): OpencodeByIdQueryRunner { + const spawn = options.spawn ?? defaultSpawn + const queryModuleUrl = options.queryModuleUrl ?? defaultQueryModuleUrl() + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const workerUrl = defaultWorkerUrl() + + return (input: OpencodeByIdQueryInput): Promise => { + return new Promise((resolve, reject) => { + const worker = spawn(workerUrl, { workerData: { ...input, queryModuleUrl, kind: OPENCODE_BYID_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 = (row: OpencodeSessionRow | null) => { + if (settled) return + settled = true + cleanup() + resolve(row) + } + const settleReject = (err: Error) => { + if (settled) return + settled = true + cleanup() + reject(err) + } + + timer = setTimeout(() => settleReject(new Error(`OpenCode by-id 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(value.row) + } else if (isErrMessage(value)) { + const err = new Error(value.error.message || 'OpenCode by-id worker failed') + err.name = value.error.name ?? 'Error' + settleReject(err) + } else { + settleReject(new Error('OpenCode by-id worker sent a malformed message')) + } + }) + worker.on('error', (err: Error) => settleReject(err)) + worker.on('exit', (code: number) => settleReject(new Error(`OpenCode by-id worker exited (code ${code}) before responding`))) + }) + } +} + +/** + * Default production runner: one short-lived worker per lookup, hard timeout. + * Worker/spawn/timeout failures REJECT (provider unavailable β‰  not found). + * Per-request cost is bounded upstream (shape gate + FALLBACK_BUDGET_PER_REQUEST), + * and the EVENT LOOP stays free even when the DB is locked for the full + * 500 ms busy timeout β€” DatabaseSync blocks only the worker thread. + */ +export async function runOpencodeSessionByIdOffThread(dbPath: string, sessionId: string): Promise { + return createWorkerByIdRunner()({ dbPath, sessionId }) +} diff --git a/server/coding-cli/providers/opencode-by-id.worker.ts b/server/coding-cli/providers/opencode-by-id.worker.ts new file mode 100644 index 000000000..8a983adcd --- /dev/null +++ b/server/coding-cli/providers/opencode-by-id.worker.ts @@ -0,0 +1,44 @@ +import { parentPort, workerData } from 'node:worker_threads' +import type { OpencodeSessionRow } 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_BYID_WORKER_KIND = 'opencode-by-id-worker' + +export type WorkerByIdInput = { + kind: typeof OPENCODE_BYID_WORKER_KIND + queryModuleUrl: string + dbPath: string + sessionId: string +} + +/** + * Run the by-id 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 (same constraint as the listing worker). + */ +export async function executeById( + input: { queryModuleUrl: string; dbPath: string; sessionId: string }, +): Promise { + const mod = await import(input.queryModuleUrl) as typeof import('./opencode-by-id-query.js') + return mod.runOpencodeSessionByIdQuery(input.dbPath, input.sessionId) +} + +// 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_BYID_WORKER_KIND) { + const port = parentPort + executeById(workerData as WorkerByIdInput) + .then((row) => port.postMessage({ ok: true, row })) + .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 5c9e10399..bb5321bff 100644 --- a/server/coding-cli/providers/opencode.ts +++ b/server/coding-cli/providers/opencode.ts @@ -77,7 +77,8 @@ export class OpencodeProvider implements CodingCliProvider { this.queryRunner = options.queryRunner ?? createWorkerListingRunner() } - private getDatabasePath(): string { + /** Public: resolve-fallbacks builds the off-thread by-id lookup from this path. */ + getDatabasePath(): string { return path.join(this.homeDir, 'opencode.db') } diff --git a/server/coding-cli/resolve-fallbacks.ts b/server/coding-cli/resolve-fallbacks.ts new file mode 100644 index 000000000..d686a8651 --- /dev/null +++ b/server/coding-cli/resolve-fallbacks.ts @@ -0,0 +1,137 @@ +import type { CodingCliProvider } from './provider.js' +import type { ResumeResolveMatch } from '../../shared/resume-resolve-contract.js' +import type { SessionMetadataEntry } from '../session-metadata-store.js' +import type { ClaudeTranscriptHit } from './claude-transcript-locator.js' +import { runOpencodeSessionByIdOffThread } from './providers/opencode-by-id-runner.js' +import type { OpencodeSessionRow } from './providers/opencode-listing-query.js' + +/** Exact-id resolve fallback: full id in, contract match (or miss) out. */ +export type ExactIdFallback = (id: string) => Promise + +export type ResolveFallbacks = { + claudeTranscriptById?: ExactIdFallback + opencodeSessionById?: ExactIdFallback +} + +/** + * FULL-id shape gates, enforced in withRequestBudget BEFORE the budget check: + * a wrong-shape token is a free no-op miss that must neither do work nor + * consume budget (otherwise earlier ses_ tokens could exhaust the claude + * budget before a valid later claude UUID β€” false negative). + */ +export const FALLBACK_ID_SHAPES: Record = { + claudeTranscriptById: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, + opencodeSessionById: /^ses_[0-9a-zA-Z]{26}$/, +} + +/** + * Per-request work budget: each fallback may do REAL work at most this many + * times per request; beyond that it reports a miss without doing work. + * Combined with the shape gates this bounds the fallback work (FS probes, + * worker spawns) one request can trigger, no matter how many id-shaped + * tokens a pasted blob contains. + */ +export const FALLBACK_BUDGET_PER_REQUEST = 2 + +export function withRequestBudget( + fallbacks: ResolveFallbacks, + max = FALLBACK_BUDGET_PER_REQUEST, +): ResolveFallbacks { + const budgeted = (key: keyof ResolveFallbacks): ExactIdFallback | undefined => { + const fallback = fallbacks[key] + if (!fallback) return undefined + const shape = FALLBACK_ID_SHAPES[key] + let used = 0 + return async (id) => { + // Shape FIRST, budget SECOND β€” order is load-bearing (see FALLBACK_ID_SHAPES). + if (!shape.test(id)) return null + if (used >= max) return null + used += 1 + return fallback(id) + } + } + return { + claudeTranscriptById: budgeted('claudeTranscriptById'), + opencodeSessionById: budgeted('opencodeSessionById'), + } +} + +export type BuildResolveFallbacksOptions = { + /** + * Metadata store (already a SessionsRouterDeps member): sessions opened via + * freshclaude/freshopencode/kilroy record their real runtime here, and a + * resume MUST reopen through that runtime, not the bare provider default. + */ + sessionMetadataStore?: { getAll(): Promise> } + /** Injectable for unit tests; production default runs the worker-thread runner. */ + runOpencodeById?: (dbPath: string, sessionId: string) => Promise + /** + * Claude exact-id transcript locate. Injected (rather than defaulted) because + * the locator needs the claude projects dir, which index.ts owns wiring. + */ + locateClaudeTranscript?: (sessionId: string) => Promise +} + +/** Build the production exact-id fallbacks from the live provider set. */ +export function buildResolveFallbacks( + providers: CodingCliProvider[], + opts: BuildResolveFallbacksOptions = {}, +): ResolveFallbacks { + const claude = providers.find((p) => p.name === 'claude') + const opencode = providers.find((p) => p.name === 'opencode') as + (CodingCliProvider & { getDatabasePath?: () => string }) | undefined + const runById = opts.runOpencodeById ?? runOpencodeSessionByIdOffThread + + // Resume tuple correctness: prefer the runtime recorded in session metadata + // (freshclaude/freshopencode/kilroy), fall back to the provider name. + // Metadata-store failures degrade to the default β€” they must not turn a + // located session into a provider error. + const sessionTypeFor = async (provider: 'claude' | 'opencode', id: string): Promise => { + const all = await opts.sessionMetadataStore?.getAll().catch(() => undefined) + return all?.[`${provider}:${id}`]?.sessionType ?? provider + } + + const claudeTranscriptById: ExactIdFallback | undefined = claude && opts.locateClaudeTranscript + ? async (id): Promise => { + const hit = await opts.locateClaudeTranscript!(id) + if (!hit) return null + return { + provider: 'claude', + sessionId: hit.sessionId, + // cwd may legitimately be missing here β€” the CLIENT must then ask + // for a working directory instead of auto-opening. + cwd: hit.cwd, + sessionType: await sessionTypeFor('claude', hit.sessionId), + matchKind: 'exact', + } + } + : undefined + + const opencodeSessionById: ExactIdFallback | undefined = opencode?.getDatabasePath + ? async (id): Promise => { + // NO catch here: a locked/corrupt DB or worker failure must PROPAGATE + // (provider unavailable β‰  not found). Runs OFF the event loop β€” the + // worker runner blocks only its own thread. + const row = await runById(opencode.getDatabasePath!(), id) + if (!row) return null + return { + provider: 'opencode', + sessionId: row.sessionId, + // opencode resumes in the SPAWN cwd; the sqlite row's NOT NULL + // `directory` column supplies it. + cwd: row.cwd || undefined, + sessionType: await sessionTypeFor('opencode', row.sessionId), + title: row.title || undefined, + // SQLite columns are dynamically typed (REAL possible); the contract + // requires integer epoch-ms, so floor. + lastActivityAt: + typeof row.lastActivityAt === 'number' && Number.isFinite(row.lastActivityAt) + ? Math.floor(row.lastActivityAt) + : undefined, + matchKind: 'exact', + } + } + : undefined + + return { claudeTranscriptById, opencodeSessionById } +} diff --git a/server/coding-cli/resolve-session.ts b/server/coding-cli/resolve-session.ts index ec91feb9c..4a16fbc35 100644 --- a/server/coding-cli/resolve-session.ts +++ b/server/coding-cli/resolve-session.ts @@ -4,21 +4,21 @@ import type { ResumeResolveResponse, } from '../../shared/resume-resolve-contract.js' import type { CodingCliSession, ProjectGroup } from './types.js' -import type { ClaudeTranscriptHit } from './claude-transcript-locator.js' +import { withRequestBudget, type ResolveFallbacks } from './resolve-fallbacks.js' +import { logger } from '../logger.js' export const RESOLVE_MATCH_CAP = 20 +const log = logger.child({ component: 'resolve-session' }) + export interface ResolveResumeDeps { getProjects: () => ProjectGroup[] isIndexReady: () => boolean - resolveOpencodeSessionIds?: ( - ids: readonly string[], - ) => Promise<{ - rootsBySessionId: Map - directoriesBySessionId?: Map - unresolvedSessionIds: Set - }> - locateClaudeTranscript?: (sessionId: string) => Promise + /** + * Exact-id fallbacks (buildResolveFallbacks). Shape-gated and budget-capped + * PER REQUEST here; the opencode lookup runs off the event loop. + */ + fallbacks?: ResolveFallbacks } export async function resolveResumeInput( @@ -55,50 +55,30 @@ export async function resolveResumeInput( } // Exact-id fallbacks for sessions the index cannot see (opencode child - // sessions; cwd-less claude transcripts skipped on cold start). - for (const candidate of candidates) { - if ( - candidate.kind === 'prefixed-id' && - candidate.token.startsWith('ses_') && - deps.resolveOpencodeSessionIds - ) { - const resolution = await deps.resolveOpencodeSessionIds([candidate.token]) - if (!resolution.unresolvedSessionIds.has(candidate.token)) { - return { - status: 'ready', - matches: [ - { - provider: 'opencode', - sessionId: candidate.token, - // opencode resumes in the SPAWN cwd, not the session's stored - // project dir β€” a cwd-less match would run the agent in the - // wrong directory. The sqlite row's NOT NULL `directory` - // column always supplies it. - cwd: resolution.directoriesBySessionId?.get(candidate.token), - sessionType: 'opencode', - matchKind: 'exact', - }, - ], - hint, - } - } - } - if (candidate.kind === 'uuid' && deps.locateClaudeTranscript) { - const hit = await deps.locateClaudeTranscript(candidate.token) - if (hit) { - return { - status: 'ready', - matches: [ - { - provider: 'claude', - sessionId: hit.sessionId, - cwd: hit.cwd, - sessionType: 'claude', - matchKind: 'exact', - }, - ], - hint, + // sessions; cwd-less claude transcripts skipped on cold start). Full-id + // shape gates make wrong-shape tokens free no-ops, the per-request budget + // bounds the real work a pasted blob can trigger, and the opencode lookup + // runs OFF the event loop (worker thread) β€” a locked DB can never stall + // the server. + if (deps.fallbacks) { + const fallbacks = withRequestBudget(deps.fallbacks) + for (const candidate of candidates) { + for (const fallback of [fallbacks.opencodeSessionById, fallbacks.claudeTranscriptById]) { + if (!fallback) continue + let match: ResumeResolveMatch | null = null + try { + match = await fallback(candidate.token) + } catch (err) { + // Provider failure β‰  not found, but the contract has no degraded + // channel yet (follow-up work). Log and keep resolving β€” never + // reject: an async express 4 handler would surface that as an + // unhandled rejection, not a response. + log.warn( + { candidateKind: candidate.kind, error: err instanceof Error ? err.message : String(err) }, + 'Resume resolve exact-id fallback failed', + ) } + if (match) return { status: 'ready', matches: [match], hint } } } } diff --git a/server/index.ts b/server/index.ts index a56cd48b4..9fd5dcfc3 100644 --- a/server/index.ts +++ b/server/index.ts @@ -31,8 +31,9 @@ import { AmplifierSessionController } from './coding-cli/amplifier-session-contr import { createOpencodeActivityIntegration } from './coding-cli/opencode-activity-integration.js' import { claudeProvider } from './coding-cli/providers/claude.js' import { codexProvider } from './coding-cli/providers/codex.js' -import { opencodeProvider, OpencodeProvider } from './coding-cli/providers/opencode.js' +import { opencodeProvider } from './coding-cli/providers/opencode.js' import { locateClaudeTranscript } from './coding-cli/claude-transcript-locator.js' +import { buildResolveFallbacks } from './coding-cli/resolve-fallbacks.js' import { getClaudeProjectsDir } from './claude-home.js' import { amplifierProvider } from './coding-cli/providers/amplifier.js' import { overrideKeysToClear } from './coding-cli/provider-title-cleanup.js' @@ -759,20 +760,14 @@ async function main() { serverInstanceId, validCliProviders: allCliNames, getIndexReadiness: () => startupState.snapshot().tasks.codingCliIndexer === true, - resolveOpencodeSessionIds: (ids) => { - const opencode = codingCliProviders.find( - (provider): provider is OpencodeProvider => provider instanceof OpencodeProvider, - ) - if (!opencode) { - return Promise.resolve({ - rootsBySessionId: new Map(), - unresolvedSessionIds: new Set(ids), - }) - } - return opencode.resolveOpencodeSessionRoots(ids) - }, - locateClaudeTranscript: (sessionId) => - locateClaudeTranscript(sessionId, getClaudeProjectsDir()), + // Exact-id resolve fallbacks: shape-gated, budget-capped per request, and + // the opencode by-id lookup runs OFF the event loop (worker thread) β€” a + // locked opencode DB must never stall the server. + resolveFallbacks: buildResolveFallbacks(codingCliProviders, { + sessionMetadataStore, + locateClaudeTranscript: (sessionId) => + locateClaudeTranscript(sessionId, getClaudeProjectsDir()), + }), })) app.use('/api', createProjectColorsRouter({ configStore, codingCliIndexer })) diff --git a/server/sessions-router.ts b/server/sessions-router.ts index c32c3caac..7b91b32a8 100644 --- a/server/sessions-router.ts +++ b/server/sessions-router.ts @@ -21,7 +21,7 @@ import { import { querySessionDirectory } from './session-directory/service.js' import { ResumeResolveRequestSchema } from '../shared/resume-resolve-contract.js' import { resolveResumeInput } from './coding-cli/resolve-session.js' -import type { ClaudeTranscriptHit } from './coding-cli/claude-transcript-locator.js' +import type { ResolveFallbacks } from './coding-cli/resolve-fallbacks.js' import { createRequestAbortSignal } from './read-models/request-abort.js' import { defaultReadModelScheduler, @@ -60,12 +60,8 @@ export interface SessionsRouterDeps { readModelScheduler?: ReadModelWorkScheduler /** Global index readiness (startup-state codingCliIndexer task). Defaults to ready. */ getIndexReadiness?: () => boolean - /** Opencode by-id sqlite fallback (OpencodeProvider.resolveOpencodeSessionRoots). */ - resolveOpencodeSessionIds?: ( - ids: readonly string[], - ) => Promise<{ rootsBySessionId: Map; unresolvedSessionIds: Set }> - /** Claude transcript exact-id fallback. */ - locateClaudeTranscript?: (sessionId: string) => Promise + /** Exact-id resolve fallbacks (buildResolveFallbacks); budget applied per request. */ + resolveFallbacks?: ResolveFallbacks } export function createSessionsRouter(deps: SessionsRouterDeps): Router { @@ -250,8 +246,7 @@ export function createSessionsRouter(deps: SessionsRouterDeps): Router { const response = await resolveResumeInput(parsed.data.input, { getProjects: () => deps.codingCliIndexer.getProjects(), isIndexReady: deps.getIndexReadiness ?? (() => true), - resolveOpencodeSessionIds: deps.resolveOpencodeSessionIds, - locateClaudeTranscript: deps.locateClaudeTranscript, + fallbacks: deps.resolveFallbacks, }) res.json(response) }) diff --git a/test/integration/server/sessions-resolve-router.test.ts b/test/integration/server/sessions-resolve-router.test.ts index ee94d7432..9b8250a4e 100644 --- a/test/integration/server/sessions-resolve-router.test.ts +++ b/test/integration/server/sessions-resolve-router.test.ts @@ -3,6 +3,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import express, { type Express } from 'express' import request from 'supertest' import { createSessionsRouter } from '../../../server/sessions-router.js' +import { + buildResolveFallbacks, + FALLBACK_BUDGET_PER_REQUEST, + type ResolveFallbacks, +} from '../../../server/coding-cli/resolve-fallbacks.js' +import type { CodingCliProvider } from '../../../server/coding-cli/provider.js' import type { ProjectGroup } from '../../../server/coding-cli/types.js' const CLAUDE_ID = 'ed2afda6-a340-443e-ba60-024a1b3554b4' @@ -67,18 +73,13 @@ function fixtureProjects(): ProjectGroup[] { interface HarnessOptions { projects?: ProjectGroup[] ready?: boolean - resolveOpencodeSessionIds?: ( - ids: readonly string[], - ) => Promise<{ - rootsBySessionId: Map - directoriesBySessionId?: Map - unresolvedSessionIds: Set - }> - locateClaudeTranscript?: ( - id: string, - ) => Promise<{ sessionId: string; sourceFile: string; cwd?: string } | null> + resolveFallbacks?: ResolveFallbacks } +const opencodeStub = () => + ({ name: 'opencode', getDatabasePath: () => '/tmp/x.db' }) as unknown as CodingCliProvider +const claudeStub = () => ({ name: 'claude' }) as unknown as CodingCliProvider + function buildApp(options: HarnessOptions = {}): Express { const app = express() app.use(express.json()) @@ -98,8 +99,7 @@ function buildApp(options: HarnessOptions = {}): Express { perfConfig: { slowSessionRefreshMs: 500 }, terminalMetadata: { list: () => [] }, getIndexReadiness: () => options.ready ?? true, - resolveOpencodeSessionIds: options.resolveOpencodeSessionIds, - locateClaudeTranscript: options.locateClaudeTranscript, + resolveFallbacks: options.resolveFallbacks, }), ) return app @@ -212,24 +212,31 @@ describe('POST /api/sessions/resolve', () => { expect(res.body).toMatchObject({ status: 'warming', matches: [] }) }) - it('falls back to the opencode by-id query on exact-id index miss (with the row directory as cwd)', async () => { + it('falls back to the off-thread opencode by-id lookup on exact-id index miss (row directory as cwd)', async () => { const unknown = 'ses_child000000000000000000000' + const runOpencodeById = vi.fn().mockResolvedValue({ + sessionId: unknown, + cwd: '/repo/beta', + title: 'child session', + createdAt: 1, + lastActivityAt: 2, + projectPath: '/repo/beta', + }) const res = await post( buildApp({ - resolveOpencodeSessionIds: vi.fn().mockResolvedValue({ - rootsBySessionId: new Map([[unknown, OPENCODE_ID]]), - directoriesBySessionId: new Map([[unknown, '/repo/beta']]), - unresolvedSessionIds: new Set(), - }), + resolveFallbacks: buildResolveFallbacks([opencodeStub()], { runOpencodeById }), }), { input: unknown }, ) + expect(runOpencodeById).toHaveBeenCalledWith('/tmp/x.db', unknown) expect(res.body.matches).toEqual([ { provider: 'opencode', sessionId: unknown, cwd: '/repo/beta', sessionType: 'opencode', + title: 'child session', + lastActivityAt: 2, matchKind: 'exact', }, ]) @@ -239,10 +246,12 @@ describe('POST /api/sessions/resolve', () => { const unknown = 'aaaaaaaa-1111-4222-8333-444444444444' const res = await post( buildApp({ - locateClaudeTranscript: vi.fn().mockResolvedValue({ - sessionId: unknown, - sourceFile: `/home/u/.claude/projects/x/${unknown}.jsonl`, - cwd: '/repo/gamma', + resolveFallbacks: buildResolveFallbacks([claudeStub()], { + locateClaudeTranscript: vi.fn().mockResolvedValue({ + sessionId: unknown, + sourceFile: `/home/u/.claude/projects/x/${unknown}.jsonl`, + cwd: '/repo/gamma', + }), }), }), { input: unknown }, @@ -258,6 +267,45 @@ describe('POST /api/sessions/resolve', () => { ]) }) + it('shape-gates the opencode fallback: a short ses_ token never touches the DB path', async () => { + const runOpencodeById = vi.fn().mockResolvedValue(null) + const res = await post( + buildApp({ + resolveFallbacks: buildResolveFallbacks([opencodeStub()], { runOpencodeById }), + }), + { input: 'ses_short123' }, + ) + expect(res.body).toMatchObject({ status: 'ready', matches: [] }) + expect(runOpencodeById).not.toHaveBeenCalled() + }) + + it('caps opencode by-id fallback work per request, with a FRESH budget on the next request', async () => { + const runOpencodeById = vi.fn().mockResolvedValue(null) + const app2 = buildApp({ + resolveFallbacks: buildResolveFallbacks([opencodeStub()], { runOpencodeById }), + }) + const ids = ['a', 'b', 'c'].map((c) => `ses_${c.repeat(26)}`) + const input = ids.join(' ') + const first = await post(app2, { input }) + expect(first.body.matches).toEqual([]) + expect(runOpencodeById).toHaveBeenCalledTimes(FALLBACK_BUDGET_PER_REQUEST) + // The budget is per-request, not per-server: a second request gets its own. + await post(app2, { input }) + expect(runOpencodeById).toHaveBeenCalledTimes(FALLBACK_BUDGET_PER_REQUEST * 2) + }) + + it('a failing opencode by-id lookup (locked DB) never fails the request', async () => { + const runOpencodeById = vi.fn().mockRejectedValue(new Error('database is locked')) + const res = await post( + buildApp({ + resolveFallbacks: buildResolveFallbacks([opencodeStub()], { runOpencodeById }), + }), + { input: 'ses_child000000000000000000000' }, + ) + expect(res.status).toBe(200) + expect(res.body).toMatchObject({ status: 'ready', matches: [] }) + }) + it('returns ready + empty matches for garbage input with no id-like token', async () => { const res = await post(app, { input: 'hello decade facade!!' }) expect(res.body).toMatchObject({ status: 'ready', matches: [], hint: null }) diff --git a/test/unit/server/coding-cli/opencode-by-id-query.test.ts b/test/unit/server/coding-cli/opencode-by-id-query.test.ts new file mode 100644 index 000000000..2d3700bcd --- /dev/null +++ b/test/unit/server/coding-cli/opencode-by-id-query.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import fsp from 'fs/promises' +import os from 'os' +import path from 'path' +import { runOpencodeSessionByIdQuery } from '../../../../server/coding-cli/providers/opencode-by-id-query' + +const SES_ROOT = 'ses_root0000000000000000000000' +const SES_CHILD = 'ses_child000000000000000000000' + +let dir: string +let dbPath: string + +beforeEach(async () => { + // Throwaway tmp DB β€” never the user's real opencode data dir (session safety rule). + dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'opencode-byid-')) + dbPath = path.join(dir, 'opencode.db') + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(dbPath) + db.exec(` + CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT); + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT, + parent_id TEXT, + directory TEXT, + title TEXT, + time_created INTEGER, + time_updated INTEGER, + time_archived INTEGER + ); + INSERT INTO project (id, worktree) VALUES ('p1', '/home/u/oc-proj'); + INSERT INTO session (id, project_id, parent_id, directory, title, time_created, time_updated, time_archived) + VALUES ('${SES_ROOT}', 'p1', NULL, '/home/u/oc-proj', 'root session', 100, 200, NULL); + INSERT INTO session (id, project_id, parent_id, directory, title, time_created, time_updated, time_archived) + VALUES ('${SES_CHILD}', 'p1', '${SES_ROOT}', '/home/u/oc-proj', 'child session', 110, 210, 999); + `) + db.close() +}) + +afterEach(async () => { + await fsp.rm(dir, { recursive: true, force: true }) +}) + +describe('runOpencodeSessionByIdQuery', () => { + it('finds a root session by exact id with metadata', async () => { + const row = await runOpencodeSessionByIdQuery(dbPath, SES_ROOT) + expect(row).toMatchObject({ + sessionId: SES_ROOT, + cwd: '/home/u/oc-proj', + title: 'root session', + lastActivityAt: 200, + projectPath: '/home/u/oc-proj', + }) + }) + + it('finds CHILD and ARCHIVED sessions too (unlike the listing query)', async () => { + const row = await runOpencodeSessionByIdQuery(dbPath, SES_CHILD) + expect(row?.sessionId).toBe(SES_CHILD) + }) + + it('returns null for an unknown id', async () => { + expect(await runOpencodeSessionByIdQuery(dbPath, 'ses_missing0000000000000000000')).toBeNull() + }) + + it('works when the project table is absent (degraded schema)', async () => { + const bare = path.join(dir, 'bare.db') + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(bare) + db.exec(` + CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT, title TEXT, time_created INTEGER, time_updated INTEGER); + INSERT INTO session VALUES ('${SES_ROOT}', '/d', 't', 1, 2); + `) + db.close() + const row = await runOpencodeSessionByIdQuery(bare, SES_ROOT) + expect(row?.sessionId).toBe(SES_ROOT) + expect(row?.projectPath ?? null).toBeNull() + }) +}) diff --git a/test/unit/server/coding-cli/opencode-by-id-runner.test.ts b/test/unit/server/coding-cli/opencode-by-id-runner.test.ts new file mode 100644 index 000000000..1a9782fd9 --- /dev/null +++ b/test/unit/server/coding-cli/opencode-by-id-runner.test.ts @@ -0,0 +1,182 @@ +// @vitest-environment node +import { EventEmitter } from 'events' +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' +import fsp from 'fs/promises' +import os from 'os' +import path from 'path' +import { + createWorkerByIdRunner, + runOpencodeSessionByIdOffThread, +} from '../../../../server/coding-cli/providers/opencode-by-id-runner' + +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 = createWorkerByIdRunner({ spawn: spawn as any, timeoutMs: 50, ...overrides }) + return { runner, workers, spawn } +} + +const SES_ROOT = 'ses_root0000000000000000000000' +const input = { dbPath: '/tmp/opencode.db', sessionId: SES_ROOT } +const row = { sessionId: SES_ROOT, cwd: '/home/u/oc-proj', title: 'root session', createdAt: 100, lastActivityAt: 200, projectPath: '/home/u/oc-proj' } + +describe('createWorkerByIdRunner', () => { + it('resolves a row 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, row }) + await expect(promise).resolves.toEqual(row) + expect(workers[0].terminated).toBe(1) + }) + + it('resolves null from an ok-null message (id not in the DB)', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitMessage({ ok: true, row: null }) + await expect(promise).resolves.toBeNull() + expect(workers[0].terminated).toBe(1) + }) + + it('passes dbPath, sessionId, queryModuleUrl and the sentinel 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.sessionId).toBe(SES_ROOT) + expect(String(data.queryModuleUrl)).toContain('opencode-by-id-query') + expect(data.kind).toBe('opencode-by-id-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, row: null }) + 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, row }) + workers[0].emitExit(0) + await expect(promise).resolves.toEqual(row) + 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: 'database is locked' } }) + await expect(promise).rejects.toThrow(/database is locked/) + expect(workers[0].terminated).toBe(1) + }) + + it.each([ + ['truncated ok without row key', { ok: true }], + ['ok:true with a non-object row', { ok: true, row: 'nope' }], + ['ok:false without error', { ok: false }], + ['no ok key', { row: null }], + ])('rejects a malformed message (%s) instead of resolving garbage', 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() + } + }) +}) + +describe('runOpencodeSessionByIdOffThread (real worker integration)', () => { + // Throwaway tmp DB β€” never the user's real opencode data dir (session safety rule). + let dir: string + let dbPath: string + + beforeEach(async () => { + dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'opencode-byid-runner-')) + dbPath = path.join(dir, 'opencode.db') + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(dbPath) + db.exec(` + CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT); + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT, + parent_id TEXT, + directory TEXT, + title TEXT, + time_created INTEGER, + time_updated INTEGER, + time_archived INTEGER + ); + INSERT INTO project (id, worktree) VALUES ('p1', '/home/u/oc-proj'); + INSERT INTO session (id, project_id, parent_id, directory, title, time_created, time_updated, time_archived) + VALUES ('${SES_ROOT}', 'p1', NULL, '/home/u/oc-proj', 'root session', 100, 200, NULL); + `) + db.close() + }) + + afterEach(async () => { + await fsp.rm(dir, { recursive: true, force: true }) + }) + + it('resolves the root session through a REAL worker (off-thread wiring end to end)', async () => { + const found = await runOpencodeSessionByIdOffThread(dbPath, SES_ROOT) + expect(found?.sessionId).toBe(SES_ROOT) + expect(found?.projectPath).toBe('/home/u/oc-proj') + }) +}) diff --git a/test/unit/server/coding-cli/resolve-fallbacks.test.ts b/test/unit/server/coding-cli/resolve-fallbacks.test.ts new file mode 100644 index 000000000..a24dbfa2b --- /dev/null +++ b/test/unit/server/coding-cli/resolve-fallbacks.test.ts @@ -0,0 +1,192 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import fsp from 'fs/promises' +import os from 'os' +import path from 'path' +import { + buildResolveFallbacks, + withRequestBudget, + FALLBACK_BUDGET_PER_REQUEST, +} from '../../../../server/coding-cli/resolve-fallbacks' +import { locateClaudeTranscript } from '../../../../server/coding-cli/claude-transcript-locator' +import type { CodingCliProvider } from '../../../../server/coding-cli/provider' + +const CLAUDE_V4 = 'ed2afda6-a340-443e-ba60-024a1b3554b4' +const OPENCODE_ID = 'ses_root0000000000000000000000' + +describe('withRequestBudget', () => { + it('shape gate runs BEFORE the budget: wrong-shape tokens do no work and consume no budget', async () => { + const inner = vi.fn().mockResolvedValue(null) + const budgeted = withRequestBudget({ claudeTranscriptById: inner }) + // Prefix hex and ses_ tokens are not full claude UUIDs β€” free no-op misses. + expect(await budgeted.claudeTranscriptById!('417e8345')).toBeNull() + expect(await budgeted.claudeTranscriptById!(OPENCODE_ID)).toBeNull() + expect(inner).not.toHaveBeenCalled() + // Budget still fully available for later valid-shape tokens. + const ids = [ + 'ed2afda6-a340-443e-ba60-024a1b3554b1', + 'ed2afda6-a340-443e-ba60-024a1b3554b2', + 'ed2afda6-a340-443e-ba60-024a1b3554b3', + ] + for (const id of ids) await budgeted.claudeTranscriptById!(id) + expect(inner.mock.calls.map((c) => c[0])).toEqual(ids.slice(0, FALLBACK_BUDGET_PER_REQUEST)) + }) + + it('the (budget+1)-th valid-shape token returns null WITHOUT calling the inner fallback', async () => { + const inner = vi.fn().mockResolvedValue(null) + const budgeted = withRequestBudget({ opencodeSessionById: inner }, 1) + expect(await budgeted.opencodeSessionById!(OPENCODE_ID)).toBeNull() + expect(inner).toHaveBeenCalledTimes(1) + expect(await budgeted.opencodeSessionById!('ses_next0000000000000000000000')).toBeNull() + expect(inner).toHaveBeenCalledTimes(1) + }) + + it('leaves absent fallbacks undefined', () => { + const budgeted = withRequestBudget({}) + expect(budgeted.claudeTranscriptById).toBeUndefined() + expect(budgeted.opencodeSessionById).toBeUndefined() + }) +}) + +describe('buildResolveFallbacks β€” claude wiring', () => { + // Throwaway fixture root β€” never the real HOME (session safety rule). + let root: string + + beforeEach(async () => { + root = await fsp.mkdtemp(path.join(os.tmpdir(), 'resolve-fallbacks-')) + }) + + afterEach(async () => { + await fsp.rm(root, { recursive: true, force: true }) + }) + + function claudeStub(): CodingCliProvider { + return { name: 'claude' } as unknown as CodingCliProvider + } + + // The locator itself stays injected (the merged locator takes a projects + // dir); wire it to the REAL locator over the fixture root, as index.ts does. + const locateInRoot = (id: string) => locateClaudeTranscript(id, root) + + async function writeTranscript(projectDir: string, id: string, lines: string[]) { + const dir = path.join(root, projectDir) + await fsp.mkdir(dir, { recursive: true }) + await fsp.writeFile(path.join(dir, `${id}.jsonl`), lines.join('\n') + '\n') + } + + it('locates a real transcript and returns the full match tuple (no metadata β†’ sessionType claude)', async () => { + await writeTranscript('-home-u-proj', CLAUDE_V4, [ + JSON.stringify({ type: 'user', cwd: '/home/u/proj', message: { content: 'hello' } }), + ]) + const { claudeTranscriptById } = buildResolveFallbacks([claudeStub()], { + locateClaudeTranscript: locateInRoot, + }) + const match = await claudeTranscriptById!(CLAUDE_V4) + expect(match).toEqual({ + provider: 'claude', + sessionId: CLAUDE_V4, + cwd: '/home/u/proj', + sessionType: 'claude', + matchKind: 'exact', + }) + }) + + it('uses the metadata-store sessionType (freshclaude) so resume reopens the right runtime', async () => { + await writeTranscript('-home-u-proj', CLAUDE_V4, [JSON.stringify({ cwd: '/home/u/proj' })]) + const sessionMetadataStore = { + getAll: async () => ({ [`claude:${CLAUDE_V4}`]: { sessionType: 'freshclaude' } }), + } + const { claudeTranscriptById } = buildResolveFallbacks([claudeStub()], { + sessionMetadataStore: sessionMetadataStore as any, + locateClaudeTranscript: locateInRoot, + }) + const match = await claudeTranscriptById!(CLAUDE_V4) + expect(match?.sessionType).toBe('freshclaude') + }) + + it('a REJECTING metadata store degrades to the provider default (never a provider error)', async () => { + await writeTranscript('-home-u-proj', CLAUDE_V4, [JSON.stringify({ cwd: '/home/u/proj' })]) + const sessionMetadataStore = { + getAll: async () => { throw new Error('metadata store corrupt') }, + } + const { claudeTranscriptById } = buildResolveFallbacks([claudeStub()], { + sessionMetadataStore: sessionMetadataStore as any, + locateClaudeTranscript: locateInRoot, + }) + const match = await claudeTranscriptById!(CLAUDE_V4) + expect(match?.sessionType).toBe('claude') + }) + + it('resolves null on a genuine miss', async () => { + const { claudeTranscriptById } = buildResolveFallbacks([claudeStub()], { + locateClaudeTranscript: locateInRoot, + }) + expect(await claudeTranscriptById!(CLAUDE_V4)).toBeNull() + }) +}) + +describe('buildResolveFallbacks β€” opencode wiring', () => { + const row = { + sessionId: OPENCODE_ID, + cwd: '/home/u/oc', + title: 'oc root', + createdAt: 1, + lastActivityAt: 7, + projectPath: '/home/u/oc-proj', + } + + function opencodeStub(): CodingCliProvider { + return { name: 'opencode', getDatabasePath: () => '/tmp/x.db' } as unknown as CodingCliProvider + } + + it('returns the full match tuple from an injected runOpencodeById (metadata-driven sessionType)', async () => { + const runOpencodeById = vi.fn().mockResolvedValue(row) + const sessionMetadataStore = { + getAll: async () => ({ [`opencode:${OPENCODE_ID}`]: { sessionType: 'freshopencode' } }), + } + const { opencodeSessionById } = buildResolveFallbacks([opencodeStub()], { + sessionMetadataStore: sessionMetadataStore as any, + runOpencodeById, + }) + const match = await opencodeSessionById!(OPENCODE_ID) + expect(runOpencodeById).toHaveBeenCalledWith('/tmp/x.db', OPENCODE_ID) + expect(match).toEqual({ + provider: 'opencode', + sessionId: OPENCODE_ID, + cwd: '/home/u/oc', + sessionType: 'freshopencode', + title: 'oc root', + lastActivityAt: 7, + matchKind: 'exact', + }) + }) + + it('floors a REAL lastActivityAt so the zod contract (int) stays satisfiable', async () => { + const runOpencodeById = vi.fn().mockResolvedValue({ ...row, lastActivityAt: 7.9 }) + const { opencodeSessionById } = buildResolveFallbacks([opencodeStub()], { runOpencodeById }) + const match = await opencodeSessionById!(OPENCODE_ID) + expect(match?.lastActivityAt).toBe(7) + }) + + it('REJECTS when runOpencodeById rejects (error propagation, not null)', async () => { + const runOpencodeById = vi.fn().mockRejectedValue(new Error('database is locked')) + const { opencodeSessionById } = buildResolveFallbacks([opencodeStub()], { runOpencodeById }) + await expect(opencodeSessionById!(OPENCODE_ID)).rejects.toThrow(/locked/) + }) + + it('resolves null when the DB has no such session', async () => { + const runOpencodeById = vi.fn().mockResolvedValue(null) + const { opencodeSessionById } = buildResolveFallbacks([opencodeStub()], { runOpencodeById }) + expect(await opencodeSessionById!(OPENCODE_ID)).toBeNull() + }) +}) + +describe('buildResolveFallbacks β€” provider absence', () => { + it('returns undefined fallbacks when no claude/opencode provider is in the set', () => { + const { claudeTranscriptById, opencodeSessionById } = buildResolveFallbacks([], { + locateClaudeTranscript: async () => null, + }) + expect(claudeTranscriptById).toBeUndefined() + expect(opencodeSessionById).toBeUndefined() + }) +}) From 78f7a1b03081400419c92881e5bfbf364c4c84a5 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:35:26 -0700 Subject: [PATCH 4/6] fix(server): port reference match-ranking semantics into resume resolve core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the merged resolver's ranking with the reference implementation's (feat/resume-button session-resolver) correct per-token ordering, fixing the live wrong-session defect: - Per candidate token, in priority order: exact index hit, then exact-id fallback lookups, then and only then prefix matches. A prefix match can never outrank any exact resolution of the same or higher-priority token (an indexed lookalike no longer beats the exact id you pasted whose session just isn't indexed yet). - Case-fold only UUID/hex-family tokens; ses_ base62 ids now match case-SENSITIVELY (folding could resolve the wrong session). - Exact ids still reach subagent/child sessions; prefix DISCOVERY now excludes them (disambiguation noise). - sessionType defaults to the provider name when the index has none. - Parser: restrict xxx_ extraction to known id families (arbitrary snake_case identifiers no longer rank first) and cap candidates at MAX_RESUME_CANDIDATES=8 (server work budget). Kept from the merged shape: shared zod contract, (provider,id) dedupe with most-recent survivor, per-request withRequestBudget fallback wrapping, and the catch-and-log fallback seam (degraded channel is the provider-health follow-up; typed ClaudeTranscriptLocatorError still propagates to the seam). Ported/adapted the reference's resolver test suite (20 tests; 5 were RED against the old core) plus parser family/cap pins. πŸ€– Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- server/coding-cli/resolve-session.ts | 102 ++++--- shared/resume-input-parser.ts | 19 +- .../server/coding-cli/resolve-session.test.ts | 267 ++++++++++++++++++ test/unit/shared/resume-input-parser.test.ts | 21 +- 4 files changed, 371 insertions(+), 38 deletions(-) create mode 100644 test/unit/server/coding-cli/resolve-session.test.ts diff --git a/server/coding-cli/resolve-session.ts b/server/coding-cli/resolve-session.ts index 4a16fbc35..10b112ae5 100644 --- a/server/coding-cli/resolve-session.ts +++ b/server/coding-cli/resolve-session.ts @@ -21,6 +21,33 @@ export interface ResolveResumeDeps { fallbacks?: ResolveFallbacks } +/** + * UUID/hex-family tokens (hex digits + dashes only) match case-insensitively. + * Everything else β€” notably ses_ + base62 ids β€” matches case-SENSITIVELY: + * base62 upper/lower case are distinct values, so case-folding could resolve + * the WRONG session. + */ +function isCaseInsensitiveToken(token: string): boolean { + return /^[0-9a-fA-F-]+$/.test(token) +} + +/** + * One scan answers all providers at once (spec: "evidence decides"). Candidate + * tokens are tried in priority order; PER TOKEN the resolution order is: + * + * 1. exact index hits (ALL sessions, including subagent children β€” an exact + * pasted id must resolve even for hidden child sessions), + * 2. exact-id fallbacks for sessions the index cannot see (opencode child + * sessions; cwd-less claude transcripts skipped on cold start), + * 3. and only then prefix matches (top-level sessions only β€” surfacing + * hidden subagent children for partial ids would flood disambiguation + * with noise). + * + * A prefix match must NEVER outrank any exact resolution of the same or a + * higher-priority token: an unindexed session whose id EQUALS the token beats + * any indexed session whose id merely begins with it, or the wrong session + * gets resumed. + */ export async function resolveResumeInput( input: string, deps: ResolveResumeDeps, @@ -36,50 +63,61 @@ export async function resolveResumeInput( const sessions = deps.getProjects().flatMap((group) => group.sessions) - // Evidence pass: one scan answers all providers at once. Candidates are - // tried in priority order until one resolves. + // The fallback budget is PER REQUEST (not per server): wrap once, before + // the token loop. Full-id shape gates make wrong-shape tokens free no-ops, + // the budget bounds the real work a pasted blob can trigger, and the + // opencode lookup runs OFF the event loop (worker thread) β€” a locked DB + // can never stall the server. + const fallbacks = deps.fallbacks ? withRequestBudget(deps.fallbacks) : undefined + + const finish = (matches: ResumeResolveMatch[]): ResumeResolveResponse => { + matches.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0)) + return { status: 'ready', matches: dedupe(matches).slice(0, RESOLVE_MATCH_CAP), hint } + } + for (const candidate of candidates) { - const needle = candidate.token.toLowerCase() - const exact: ResumeResolveMatch[] = [] - const prefix: ResumeResolveMatch[] = [] - for (const session of sessions) { - const id = session.sessionId.toLowerCase() - if (id === needle) exact.push(toMatch(session, 'exact')) - else if (id.startsWith(needle)) prefix.push(toMatch(session, 'prefix')) - } - const matches = exact.length > 0 ? exact : prefix - if (matches.length > 0) { - matches.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0)) - return { status: 'ready', matches: dedupe(matches).slice(0, RESOLVE_MATCH_CAP), hint } + const ci = isCaseInsensitiveToken(candidate.token) + const norm = (value: string) => (ci ? value.toLowerCase() : value) + const target = norm(candidate.token) + + // 1. Exact index hits β€” scan ALL sessions, subagent children included. + const exact = sessions.filter((session) => norm(session.sessionId) === target) + if (exact.length > 0) { + return finish(exact.map((session) => toMatch(session, 'exact'))) } - } - // Exact-id fallbacks for sessions the index cannot see (opencode child - // sessions; cwd-less claude transcripts skipped on cold start). Full-id - // shape gates make wrong-shape tokens free no-ops, the per-request budget - // bounds the real work a pasted blob can trigger, and the opencode lookup - // runs OFF the event loop (worker thread) β€” a locked DB can never stall - // the server. - if (deps.fallbacks) { - const fallbacks = withRequestBudget(deps.fallbacks) - for (const candidate of candidates) { - for (const fallback of [fallbacks.opencodeSessionById, fallbacks.claudeTranscriptById]) { + // 2. Exact-id fallbacks run BEFORE prefix matching. Cheap: the shape + // gates inside withRequestBudget mean prefix-length tokens do no + // fallback work at all. + if (fallbacks) { + const hits: ResumeResolveMatch[] = [] + for (const fallback of [fallbacks.claudeTranscriptById, fallbacks.opencodeSessionById]) { if (!fallback) continue - let match: ResumeResolveMatch | null = null try { - match = await fallback(candidate.token) + const match = await fallback(candidate.token) + if (match) hits.push(match) } catch (err) { // Provider failure β‰  not found, but the contract has no degraded - // channel yet (follow-up work). Log and keep resolving β€” never - // reject: an async express 4 handler would surface that as an - // unhandled rejection, not a response. + // channel yet (follow-up work: the provider-health lane). Log and + // keep resolving β€” never reject: an async express 4 handler would + // surface that as an unhandled rejection, not a response. Typed + // locator errors (ClaudeTranscriptLocatorError) arrive here intact. log.warn( { candidateKind: candidate.kind, error: err instanceof Error ? err.message : String(err) }, 'Resume resolve exact-id fallback failed', ) } - if (match) return { status: 'ready', matches: [match], hint } } + if (hits.length > 0) return finish(hits) + } + + // 3. Prefix DISCOVERY β€” top-level sessions only; exact ids above still + // reach subagent children. + const prefix = sessions.filter( + (session) => !session.isSubagent && norm(session.sessionId).startsWith(target), + ) + if (prefix.length > 0) { + return finish(prefix.map((session) => toMatch(session, 'prefix'))) } } @@ -91,7 +129,7 @@ function toMatch(session: CodingCliSession, matchKind: 'exact' | 'prefix'): Resu provider: session.provider, sessionId: session.sessionId, cwd: session.cwd ?? session.projectPath, - sessionType: session.sessionType, + sessionType: session.sessionType ?? session.provider, title: session.title, firstUserMessage: session.firstUserMessage, lastActivityAt: session.lastActivityAt, diff --git a/shared/resume-input-parser.ts b/shared/resume-input-parser.ts index cb0db7adf..5b6a25ca0 100644 --- a/shared/resume-input-parser.ts +++ b/shared/resume-input-parser.ts @@ -17,17 +17,24 @@ export interface ResumeHint { } export interface ResumeInputParse { - /** Candidate tokens in resolution-priority order. */ + /** Candidate tokens in resolution-priority order, capped at MAX_RESUME_CANDIDATES. */ candidates: ResumeCandidate[] hint: ResumeHint | null } +/** + * Work budget: candidates are capped so one pasted blob can never trigger + * unbounded server-side scans/DB lookups in the resolve endpoint. + */ +export const MAX_RESUME_CANDIDATES = 8 + const ANSI_ESCAPE_RE = /\u001b\[[0-9;?]*[0-9A-Za-z]/g const UUID_RE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g -// ses_ + 26 base62 is the first-class shape; the generic form also accepts -// other known xxx_-prefixed id families. -const PREFIXED_ID_RE = /\b[a-z]{2,10}_[0-9A-Za-z]{8,40}\b/g +// Known xxx_-prefixed id families only (ses_ + 26 base62 is opencode's, +// first-class). Arbitrary snake_case identifiers must NOT match: they would +// rank FIRST and waste resolver passes on non-ids. +const PREFIXED_ID_RE = /\b(?:ses|sess|session|thread|thr|run|msg|task|amp)_[0-9A-Za-z]{8,64}\b/g // >=8 hex chars, <=32; must contain a digit (filters decade/facade/deadbeef). const HEX_PREFIX_RE = /\b[0-9a-fA-F]{8,32}\b/g @@ -118,5 +125,7 @@ export function parseResumeInput(text: string): ResumeInputParse { for (const token of uuids) push(token, 'uuid') for (const token of hexTokens) push(token, 'hex-prefix') - return { candidates, hint: deriveHint(sanitized, candidates) } + // Cap = work budget: bounds resolver scans + exact-id fallback lookups per request. + const capped = candidates.slice(0, MAX_RESUME_CANDIDATES) + return { candidates: capped, hint: deriveHint(sanitized, capped) } } diff --git a/test/unit/server/coding-cli/resolve-session.test.ts b/test/unit/server/coding-cli/resolve-session.test.ts new file mode 100644 index 000000000..9cee361bb --- /dev/null +++ b/test/unit/server/coding-cli/resolve-session.test.ts @@ -0,0 +1,267 @@ +// @vitest-environment node +// Ported from the reference session-resolver suite (feat/resume-button), +// adapted to the merged resolveResumeInput API and resume-resolve contract. +// Pins the ordering rule: per candidate token β€” exact index hit, then exact +// fallback lookups, then and only then prefix matches. A prefix match must +// NEVER outrank any exact resolution of the same or higher-priority token. +import { describe, it, expect, vi } from 'vitest' +import { resolveResumeInput, RESOLVE_MATCH_CAP, type ResolveResumeDeps } from '../../../../server/coding-cli/resolve-session' +import type { ResolveFallbacks } from '../../../../server/coding-cli/resolve-fallbacks' +import type { ResumeResolveMatch } from '../../../../shared/resume-resolve-contract' +import type { ProjectGroup, CodingCliSession } from '../../../../server/coding-cli/types' + +function session( + overrides: Partial & Pick, +): CodingCliSession { + return { + projectPath: '/home/u/proj', + lastActivityAt: 1000, + cwd: '/home/u/proj', + title: 'a session', + ...overrides, + } +} + +function projects(sessions: CodingCliSession[]): ProjectGroup[] { + return [{ projectPath: '/home/u/proj', sessions }] +} + +function deps(groups: ProjectGroup[], fallbacks?: ResolveFallbacks): ResolveResumeDeps { + return { + getProjects: () => groups, + isIndexReady: () => true, + fallbacks, + } +} + +const AMPLIFIER_FULL = '417e8345-90ab-4cde-8f01-234567890abc' +const CODEX_V7 = '019fac27-69d7-78a0-b972-b339d551042e' +const CLAUDE_V4 = 'ed2afda6-a340-443e-ba60-024a1b3554b4' +const OPENCODE_ID = 'ses_root0000000000000000000000' + +const fourProviderSnapshot = projects([ + session({ provider: 'claude', sessionId: CLAUDE_V4, sessionType: 'claude' }), + session({ provider: 'codex', sessionId: CODEX_V7, sessionType: 'codex' }), + session({ provider: 'opencode', sessionId: OPENCODE_ID, sessionType: 'opencode' }), + session({ provider: 'amplifier', sessionId: AMPLIFIER_FULL, sessionType: 'amplifier' }), +]) + +describe('resolveResumeInput β€” matching core', () => { + it('exact match wins across all providers at once (claude UUID, no hint needed)', async () => { + const { matches } = await resolveResumeInput(CLAUDE_V4, deps(fourProviderSnapshot)) + expect(matches).toHaveLength(1) + expect(matches[0]).toMatchObject({ + provider: 'claude', + sessionId: CLAUDE_V4, + sessionType: 'claude', + cwd: '/home/u/proj', + matchKind: 'exact', + }) + }) + + it('short hex prefix matches the amplifier session (spec row: 417e8345)', async () => { + const { matches } = await resolveResumeInput('417e8345', deps(fourProviderSnapshot)) + expect(matches).toHaveLength(1) + expect(matches[0]).toMatchObject({ + provider: 'amplifier', + sessionId: AMPLIFIER_FULL, + matchKind: 'prefix', + }) + }) + + it('exact-id match is case-insensitive for UUID/hex tokens', async () => { + const { matches } = await resolveResumeInput(CLAUDE_V4.toUpperCase(), deps(fourProviderSnapshot)) + expect(matches).toHaveLength(1) + expect(matches[0].sessionId).toBe(CLAUDE_V4) + }) + + it('ses_ ids are case-SENSITIVE (base62): a case-variant does NOT match', async () => { + const { matches } = await resolveResumeInput( + 'ses_ROOT0000000000000000000000', + deps(fourProviderSnapshot), + ) + expect(matches).toHaveLength(0) + }) + + it('opencode ses_ id resolves to opencode even though other providers exist', async () => { + const { matches } = await resolveResumeInput(OPENCODE_ID, deps(fourProviderSnapshot)) + expect(matches).toHaveLength(1) + expect(matches[0].provider).toBe('opencode') + }) + + it('exact match takes precedence over prefix matches of the same token', async () => { + const snapshot = projects([ + session({ provider: 'amplifier', sessionId: '417e8345', lastActivityAt: 1 }), + session({ provider: 'amplifier', sessionId: AMPLIFIER_FULL, lastActivityAt: 2 }), + ]) + const { matches } = await resolveResumeInput('417e8345', deps(snapshot)) + expect(matches).toHaveLength(1) + expect(matches[0].matchKind).toBe('exact') + }) + + it('ambiguous prefix returns all matches most-recent first, capped', async () => { + const many = Array.from({ length: RESOLVE_MATCH_CAP + 5 }, (_, i) => + session({ + provider: 'amplifier', + sessionId: `417e8345-90ab-4cde-8f01-${String(i).padStart(12, '0')}`, + lastActivityAt: i, + })) + const { matches } = await resolveResumeInput('417e8345', deps(projects(many))) + expect(matches).toHaveLength(RESOLVE_MATCH_CAP) + expect(matches[0].lastActivityAt).toBe(RESOLVE_MATCH_CAP + 4) + expect(matches[matches.length - 1].lastActivityAt).toBeGreaterThanOrEqual(5) + }) + + it('tries candidates in priority order until one resolves', async () => { + // ses_ token (highest parser priority) misses everywhere; the UUID resolves. + const { matches } = await resolveResumeInput( + `ses_zzzzzzzzzzzzzzzzzzzzzzzzzz ${CLAUDE_V4}`, + deps(fourProviderSnapshot), + ) + expect(matches).toHaveLength(1) + expect(matches[0].sessionId).toBe(CLAUDE_V4) + }) + + it('an EXACT id finds a subagent/child session (spec: scan ALL sessions)', async () => { + const snapshot = projects([ + session({ provider: 'claude', sessionId: CLAUDE_V4, isSubagent: true }), + ]) + const { matches } = await resolveResumeInput(CLAUDE_V4, deps(snapshot)) + expect(matches).toHaveLength(1) + expect(matches[0].sessionId).toBe(CLAUDE_V4) + }) + + it('prefix DISCOVERY does not surface subagent sessions', async () => { + const snapshot = projects([ + session({ provider: 'claude', sessionId: CLAUDE_V4, isSubagent: true }), + ]) + const { matches } = await resolveResumeInput('ed2afda6', deps(snapshot)) + expect(matches).toHaveLength(0) + }) + + it('an exact FALLBACK hit beats an indexed PREFIX match of the same token', async () => { + // Token exactly equals an unindexed session id AND is a prefix of an + // indexed one: exact must win or the wrong session gets resumed. + const indexedPrefix = projects([ + session({ provider: 'amplifier', sessionId: `${CLAUDE_V4}9999`, lastActivityAt: 999 }), + ]) + const fallbackMatch: ResumeResolveMatch = { + provider: 'claude', + sessionId: CLAUDE_V4, + cwd: '/tmp/exact', + sessionType: 'claude', + lastActivityAt: 1, + matchKind: 'exact', + } + const { matches } = await resolveResumeInput(CLAUDE_V4, deps(indexedPrefix, { + claudeTranscriptById: async (id) => (id === CLAUDE_V4 ? fallbackMatch : null), + })) + expect(matches).toEqual([fallbackMatch]) + }) + + it('sessionType defaults to the provider name when the index has none', async () => { + const snapshot = projects([session({ provider: 'codex', sessionId: CODEX_V7 })]) + const { matches } = await resolveResumeInput(CODEX_V7, deps(snapshot)) + expect(matches[0].sessionType).toBe('codex') + }) + + it('index miss consults exact-id fallbacks (claude transcript locator)', async () => { + const fallbackMatch: ResumeResolveMatch = { + provider: 'claude', + sessionId: CLAUDE_V4, + cwd: '/tmp/found', + sessionType: 'claude', + lastActivityAt: 42, + matchKind: 'exact', + } + const { matches } = await resolveResumeInput(CLAUDE_V4, deps(projects([]), { + claudeTranscriptById: async (id) => (id === CLAUDE_V4 ? fallbackMatch : null), + })) + expect(matches).toEqual([fallbackMatch]) + }) + + it('index miss consults opencode by-id fallback', async () => { + const fallbackMatch: ResumeResolveMatch = { + provider: 'opencode', + sessionId: OPENCODE_ID, + cwd: '/tmp/oc', + sessionType: 'opencode', + lastActivityAt: 7, + matchKind: 'exact', + } + const { matches } = await resolveResumeInput(OPENCODE_ID, deps(projects([]), { + opencodeSessionById: async () => fallbackMatch, + })) + expect(matches).toEqual([fallbackMatch]) + }) + + it('zero matches when nothing resolves anywhere', async () => { + const { matches } = await resolveResumeInput('deadbeef1234', deps(fourProviderSnapshot, { + claudeTranscriptById: async () => null, + opencodeSessionById: async () => null, + })) + expect(matches).toEqual([]) + }) + + it('a THROWING fallback never fails the request (seam: degraded channel is follow-up work)', async () => { + const response = await resolveResumeInput(OPENCODE_ID, deps(projects([]), { + opencodeSessionById: async () => { + throw new Error('database is locked') + }, + })) + expect(response).toMatchObject({ status: 'ready', matches: [] }) + }) + + it('a failed exact-id fallback does NOT hide a later lower-priority match', async () => { + // First token: full claude UUID, index miss, claude fallback THROWS. + // Second token: prefix that matches an indexed amplifier session. + // Resolution must continue so the surviving match is still returned. + const MISSING_V4 = 'ed2afda6-a340-443e-ba60-024a1b3554b9' + const snapshot = projects([session({ provider: 'amplifier', sessionId: AMPLIFIER_FULL })]) + const { matches } = await resolveResumeInput(`${MISSING_V4} 417e8345`, deps(snapshot, { + claudeTranscriptById: async () => { + throw new Error('EACCES') + }, + })) + expect(matches).toHaveLength(1) + expect(matches[0]).toMatchObject({ provider: 'amplifier', matchKind: 'prefix' }) + }) + + it('a fallback exact hit for a HIGHER-priority token beats an indexed exact hit of a LOWER-priority token', async () => { + // ses_ (priority 1) resolves only via fallback; the UUID (priority 2) is + // an indexed exact hit. The higher-priority token must win. + const fallbackMatch: ResumeResolveMatch = { + provider: 'opencode', + sessionId: OPENCODE_ID, + cwd: '/tmp/oc', + sessionType: 'opencode', + matchKind: 'exact', + } + const snapshot = projects([session({ provider: 'claude', sessionId: CLAUDE_V4 })]) + const opencodeSessionById = vi.fn(async () => fallbackMatch) + const { matches } = await resolveResumeInput( + `${OPENCODE_ID} ${CLAUDE_V4}`, + deps(snapshot, { opencodeSessionById }), + ) + expect(matches).toEqual([fallbackMatch]) + expect(opencodeSessionById).toHaveBeenCalledWith(OPENCODE_ID) + }) + + it('dedupes duplicate (provider, sessionId) snapshot entries, keeping the most recent', async () => { + const snapshot = projects([ + session({ provider: 'claude', sessionId: CLAUDE_V4, title: 'older file', lastActivityAt: 100 }), + session({ provider: 'claude', sessionId: CLAUDE_V4, title: 'newer file', lastActivityAt: 500 }), + ]) + const { matches } = await resolveResumeInput(CLAUDE_V4, deps(snapshot)) + expect(matches).toHaveLength(1) + expect(matches[0]).toMatchObject({ title: 'newer file', lastActivityAt: 500 }) + }) + + it('returns warming (not "not found") while the index is not ready', async () => { + const response = await resolveResumeInput(CLAUDE_V4, { + getProjects: () => fourProviderSnapshot, + isIndexReady: () => false, + }) + expect(response).toMatchObject({ status: 'warming', matches: [] }) + }) +}) diff --git a/test/unit/shared/resume-input-parser.test.ts b/test/unit/shared/resume-input-parser.test.ts index f1814c6ea..fbfa2f6fa 100644 --- a/test/unit/shared/resume-input-parser.test.ts +++ b/test/unit/shared/resume-input-parser.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseResumeInput } from '@shared/resume-input-parser' +import { parseResumeInput, MAX_RESUME_CANDIDATES } from '@shared/resume-input-parser' const V4 = 'ed2afda6-a340-443e-ba60-024a1b3554b4' const V7 = '019fac27-69d7-78a0-b972-b339d551042e' @@ -57,6 +57,25 @@ describe('parseResumeInput β€” candidate extraction', () => { it('caps hex tokens at 32 chars (git shas do not match)', () => { expect(parseResumeInput('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2').candidates).toEqual([]) }) + + it('rejects arbitrary snake_case identifiers (only known xxx_ id families match)', () => { + expect(parseResumeInput('my_function123 snake_casedword9').candidates).toEqual([]) + }) + + it('accepts other known xxx_ id families (thread_)', () => { + expect(parseResumeInput('thread_abc123456').candidates).toEqual([ + { token: 'thread_abc123456', kind: 'prefixed-id' }, + ]) + }) + + it('caps candidates at MAX_RESUME_CANDIDATES (server work budget)', () => { + const tokens = Array.from( + { length: MAX_RESUME_CANDIDATES + 4 }, + (_, i) => `417e83450a${String(i).padStart(2, '0')}`, + ) + const { candidates } = parseResumeInput(tokens.join(' ')) + expect(candidates).toHaveLength(MAX_RESUME_CANDIDATES) + }) }) describe('parseResumeInput β€” advisory hint', () => { From b1281745b18c05b8d63689bc8fc3a6b921d14c17 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:18:22 -0700 Subject: [PATCH 5/6] =?UTF-8?q?feat(resume):=20provider-health=20channel?= =?UTF-8?q?=20=E2=80=94=20broken=20providers=20surface=20as=20degraded,=20?= =?UTF-8?q?never=20"not=20found"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the reference implementation's provider-health surface (feat/resume-button, comparison item 3) onto the merged resolve pipeline, closing the silent-empty- results incident class: a locked opencode DB, unreadable claude/codex/amplifier store, or failed index scan now answers "something's wrong", never "session not found". Contract (shared/resume-resolve-contract.ts, additive + backward-tolerant): - status gains 'degraded'; providerErrors (per-provider {provider, code?, message?}); unsearchedProviders (settings-disabled, so absence never overclaims); homeDir (concrete cwd prefill instead of the '~' sentinel). Server: - resolve-session.ts: the per-token fallback catch now collects providerErrors (typed ClaudeTranscriptLocatorError errno in .code) while resolution CONTINUES; fallbacks iterate as [provider, fallback] PAIRS so identity travels with the entry, never its position; any error makes the result degraded β€” even with matches, because a failed higher-priority exact search may have hidden the right session. - session-indexer.ts: scanFailures channel (recorded per attempt, cleared on success, pruned for disabled providers so a failed-then-disabled provider can't trap Retry), isReady() (first refresh completed), requestRefresh() fire-and-forget wrapper. - providers/{claude,codex,amplifier}.ts: root-level guards β€” root ABSENCE is a legitimate empty result (ENOENT/ENOTDIR), any other root error REJECTS so the indexer records a scan failure. - sessions-router.ts: readiness = startupState OR'd with the indexer signal; merges fallback errors with enabled-provider scan failures (fallback errors win the dedupe); reports disabled providers as unsearched; fire-and-forgets requestRefresh() on degraded so Retry converges; returns homeDir. - index.ts: claude transcript locator rewired to claudeProvider.getSessionRoots() (multi-root activation, evaluated per lookup). Client (ResumeSessionDialog): - 'degraded' renders an explicit "could not be searched" state with per-provider details and MANUAL retry (no warming auto-retry budget), never auto-resumes, and still offers surviving matches for manual confirmation. - cwd prefilled from homeDir when the user hasn't edited the field ('~' still accepted for back-compat); no-match copy names disabled/unsearched providers. Tests: contract extension suite, provider-root-failures suite (ported from the reference), indexer health suite, router degraded/unsearched/homeDir/readiness cases, dialog degraded/prefill cases; the two lane-4 seam tests extended to assert providerErrors content. RED verified against pre-change tree (29 new failures), now green; full coordinated sweep green (unit 4466, integration 478). πŸ€– Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- server/coding-cli/providers/amplifier.ts | 12 ++ server/coding-cli/providers/claude.ts | 15 ++- server/coding-cli/providers/codex.ts | 12 ++ server/coding-cli/resolve-session.ts | 77 +++++++++--- server/coding-cli/session-indexer.ts | 53 ++++++++- server/index.ts | 6 +- server/sessions-router.ts | 69 ++++++++++- shared/resume-resolve-contract.ts | 32 ++++- src/components/ResumeSessionDialog.tsx | 79 ++++++++++--- .../server/sessions-resolve-router.test.ts | 110 +++++++++++++++++- .../components/ResumeSessionDialog.test.tsx | 91 +++++++++++++++ .../coding-cli/provider-root-failures.test.ts | 50 ++++++++ .../server/coding-cli/resolve-session.test.ts | 63 ++++++++-- .../server/coding-cli/session-indexer.test.ts | 59 ++++++++++ .../shared/resume-resolve-contract.test.ts | 54 +++++++++ 15 files changed, 733 insertions(+), 49 deletions(-) create mode 100644 test/unit/server/coding-cli/provider-root-failures.test.ts create mode 100644 test/unit/shared/resume-resolve-contract.test.ts diff --git a/server/coding-cli/providers/amplifier.ts b/server/coding-cli/providers/amplifier.ts index 75ce73954..b5fe5d232 100644 --- a/server/coding-cli/providers/amplifier.ts +++ b/server/coding-cli/providers/amplifier.ts @@ -218,6 +218,18 @@ export const amplifierProvider: CodingCliProvider = { // lands at the first prompt:complete. async listSessionFiles() { const projectsDir = path.join(this.homeDir, 'projects') + // Root-level guard: ABSENCE of the root is a legitimate empty result; any + // OTHER root error (EACCES/EIO/EMFILE, ...) must REJECT so the indexer + // records a scan failure β€” a provider outage must never read as "no + // sessions". ACCEPTED LIMITATION: deeper per-subdirectory errors inside + // walkMetadataFiles remain best-effort skips (partial results beat none). + try { + await fsp.readdir(projectsDir) + } catch (err) { + const code = (err as NodeJS.ErrnoException | null)?.code + if (code === 'ENOENT' || code === 'ENOTDIR') return [] + throw err + } const files = await walkMetadataFiles(projectsDir) // Restrict to files under a `sessions` directory (projects//sessions//metadata.json). return files.filter((file) => diff --git a/server/coding-cli/providers/claude.ts b/server/coding-cli/providers/claude.ts index aa6f11957..dcefd64da 100644 --- a/server/coding-cli/providers/claude.ts +++ b/server/coding-cli/providers/claude.ts @@ -543,9 +543,18 @@ export const claudeProvider: CodingCliProvider = { let projectDirs: string[] = [] try { projectDirs = (await fsp.readdir(projectsDir)).map((name) => path.join(projectsDir, name)) - } catch { - return [] - } + } catch (err) { + // Root-level ABSENCE is a legitimate empty result; any OTHER root error + // (EACCES/EIO/EMFILE, ...) must REJECT so the indexer records a scan + // failure β€” a provider outage must never read as "no sessions". + const code = (err as NodeJS.ErrnoException | null)?.code + if (code === 'ENOENT' || code === 'ENOTDIR') return [] + throw err + } + + // ACCEPTED LIMITATION: per-subdirectory/per-file errors deeper in the tree + // are best-effort skips (partial results beat none); only the root-level + // enumeration above distinguishes failure from absence. const files: string[] = [] for (const projectDir of projectDirs) { diff --git a/server/coding-cli/providers/codex.ts b/server/coding-cli/providers/codex.ts index 14063d618..e013be963 100644 --- a/server/coding-cli/providers/codex.ts +++ b/server/coding-cli/providers/codex.ts @@ -469,6 +469,18 @@ export const codexProvider: CodingCliProvider = { async listSessionFiles() { const sessionsDir = path.join(this.homeDir, 'sessions') + // Root-level guard: ABSENCE of the root is a legitimate empty result; any + // OTHER root error (EACCES/EIO/EMFILE, ...) must REJECT so the indexer + // records a scan failure β€” a provider outage must never read as "no + // sessions". ACCEPTED LIMITATION: deeper per-subdirectory errors inside + // walkJsonlFiles remain best-effort skips (partial results beat none). + try { + await fsp.readdir(sessionsDir) + } catch (err) { + const code = (err as NodeJS.ErrnoException | null)?.code + if (code === 'ENOENT' || code === 'ENOTDIR') return [] + throw err + } return walkJsonlFiles(sessionsDir) }, diff --git a/server/coding-cli/resolve-session.ts b/server/coding-cli/resolve-session.ts index 10b112ae5..d348ca967 100644 --- a/server/coding-cli/resolve-session.ts +++ b/server/coding-cli/resolve-session.ts @@ -1,10 +1,12 @@ import { parseResumeInput } from '../../shared/resume-input-parser.js' import type { + ResumeResolveHint, ResumeResolveMatch, - ResumeResolveResponse, + ResumeResolveProviderError, } from '../../shared/resume-resolve-contract.js' import type { CodingCliSession, ProjectGroup } from './types.js' -import { withRequestBudget, type ResolveFallbacks } from './resolve-fallbacks.js' +import { withRequestBudget, type ExactIdFallback, type ResolveFallbacks } from './resolve-fallbacks.js' +import { ClaudeTranscriptLocatorError } from './claude-transcript-locator.js' import { logger } from '../logger.js' export const RESOLVE_MATCH_CAP = 20 @@ -21,6 +23,25 @@ export interface ResolveResumeDeps { fallbacks?: ResolveFallbacks } +/** + * Resolve pipeline result. providerErrors carries fallback failures only + * (provider unavailable β‰  not found); the route merges in indexer scan + * failures and adds unsearchedProviders/homeDir before responding. + */ +export interface ResolveResumeResult { + status: 'ready' | 'warming' | 'degraded' + matches: ResumeResolveMatch[] + hint: ResumeResolveHint | null + providerErrors: ResumeResolveProviderError[] +} + +/** Errno code for a provider-error summary: typed locator errors carry it in .code. */ +function errnoCodeOf(err: unknown): string | undefined { + if (err instanceof ClaudeTranscriptLocatorError) return err.code + const code = (err as NodeJS.ErrnoException | null)?.code + return typeof code === 'string' ? code : undefined +} + /** * UUID/hex-family tokens (hex digits + dashes only) match case-insensitively. * Everything else β€” notably ses_ + base62 ids β€” matches case-SENSITIVELY: @@ -51,14 +72,14 @@ function isCaseInsensitiveToken(token: string): boolean { export async function resolveResumeInput( input: string, deps: ResolveResumeDeps, -): Promise { +): Promise { const { candidates, hint } = parseResumeInput(input) if (!deps.isIndexReady()) { - return { status: 'warming', matches: [], hint } + return { status: 'warming', matches: [], hint, providerErrors: [] } } if (candidates.length === 0) { - return { status: 'ready', matches: [], hint } + return { status: 'ready', matches: [], hint, providerErrors: [] } } const sessions = deps.getProjects().flatMap((group) => group.sessions) @@ -70,9 +91,21 @@ export async function resolveResumeInput( // can never stall the server. const fallbacks = deps.fallbacks ? withRequestBudget(deps.fallbacks) : undefined - const finish = (matches: ResumeResolveMatch[]): ResumeResolveResponse => { + // Provider failure β‰  not found: a throwing fallback records a per-provider + // error summary while resolution CONTINUES (prefix/later tokens). Any entry + // here makes the result 'degraded' β€” even with matches, because a failed + // HIGHER-priority exact search may have hidden the right session. + const errorsByProvider = new Map() + + const finish = (matches: ResumeResolveMatch[]): ResolveResumeResult => { matches.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0)) - return { status: 'ready', matches: dedupe(matches).slice(0, RESOLVE_MATCH_CAP), hint } + const providerErrors = [...errorsByProvider.values()] + return { + status: providerErrors.length > 0 ? 'degraded' : 'ready', + matches: dedupe(matches).slice(0, RESOLVE_MATCH_CAP), + hint, + providerErrors, + } } for (const candidate of candidates) { @@ -88,22 +121,34 @@ export async function resolveResumeInput( // 2. Exact-id fallbacks run BEFORE prefix matching. Cheap: the shape // gates inside withRequestBudget mean prefix-length tokens do no - // fallback work at all. + // fallback work at all. Iterated as [provider, fallback] PAIRS so a + // failure is attributed to the RIGHT provider (identity travels with + // the entry, never its position). if (fallbacks) { const hits: ResumeResolveMatch[] = [] - for (const fallback of [fallbacks.claudeTranscriptById, fallbacks.opencodeSessionById]) { + const entries: Array<[string, ExactIdFallback | undefined]> = [ + ['claude', fallbacks.claudeTranscriptById], + ['opencode', fallbacks.opencodeSessionById], + ] + for (const [provider, fallback] of entries) { if (!fallback) continue try { const match = await fallback(candidate.token) if (match) hits.push(match) } catch (err) { - // Provider failure β‰  not found, but the contract has no degraded - // channel yet (follow-up work: the provider-health lane). Log and - // keep resolving β€” never reject: an async express 4 handler would - // surface that as an unhandled rejection, not a response. Typed - // locator errors (ClaudeTranscriptLocatorError) arrive here intact. + // Never reject: an async express 4 handler would surface that as + // an unhandled rejection, not a response. Typed locator errors + // (ClaudeTranscriptLocatorError) arrive here intact β€” errno in .code. + const code = errnoCodeOf(err) + if (!errorsByProvider.has(provider)) { + errorsByProvider.set(provider, { + provider, + ...(code ? { code } : {}), + message: err instanceof Error ? err.message : String(err), + }) + } log.warn( - { candidateKind: candidate.kind, error: err instanceof Error ? err.message : String(err) }, + { provider, candidateKind: candidate.kind, error: err instanceof Error ? err.message : String(err) }, 'Resume resolve exact-id fallback failed', ) } @@ -121,7 +166,7 @@ export async function resolveResumeInput( } } - return { status: 'ready', matches: [], hint } + return finish([]) } function toMatch(session: CodingCliSession, matchKind: 'exact' | 'prefix'): ResumeResolveMatch { diff --git a/server/coding-cli/session-indexer.ts b/server/coding-cli/session-indexer.ts index db843b3f7..5e712c5da 100644 --- a/server/coding-cli/session-indexer.ts +++ b/server/coding-cli/session-indexer.ts @@ -461,6 +461,7 @@ export class CodingCliSessionIndexer { private seenSessionIds = new Map() private onNewSessionHandlers = new Set<(session: CodingCliSession) => void>() private initialized = false + private scanFailures = new Set() private sessionKeyToFilePath = new Map() private urgentRefreshNeeded = false private dirtyProviders = new Set() @@ -684,6 +685,32 @@ export class CodingCliSessionIndexer { this.rootWatcher.on('error', (err) => logger.warn({ err }, 'Root watcher error')) } + /** True once at least one refresh has completed (resolve endpoint: 'warming' until then). */ + isReady(): boolean { + return this.initialized + } + + /** + * Providers whose MOST RECENT listing attempt failed. The scan path swallows + * these failures into empty lists and still reports the index initialized, + * so without this the resolve endpoint would report 'ready' + zero matches + * (i.e. "not found") during a provider outage β€” spec violation. + */ + getScanFailures(): CodingCliProviderName[] { + return [...this.scanFailures] + } + + /** + * Public fire-and-forget refresh request (debounced scheduleRefresh). The + * resolve route calls this on a degraded response so the user's "Retry" + * converges after a failed provider recovers β€” otherwise a recorded scan + * failure could outlive the outage until the next watcher event/periodic + * full scan. + */ + requestRefresh(): void { + this.scheduleRefresh() + } + onUpdate(handler: (projects: ProjectGroup[]) => void): () => void { this.onUpdateHandlers.add(handler) return () => this.onUpdateHandlers.delete(handler) @@ -1047,7 +1074,11 @@ export class CodingCliSessionIndexer { let sessions: CodingCliSession[] = [] try { sessions = await provider.listSessionsDirect() + this.scanFailures.delete(provider.name) } catch { + // Record per attempt (never bulk-cleared): the resolve endpoint must + // report this provider as unsearchable, not silently "no sessions". + this.scanFailures.add(provider.name) // 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. @@ -1222,8 +1253,12 @@ export class CodingCliSessionIndexer { try { const files = await provider.listSessionFiles() filesByProvider.set(provider, files) + this.scanFailures.delete(provider.name) } catch (err) { logger.warn({ err, provider: provider.name }, 'Could not list session files') + // Record per attempt (never bulk-cleared): the resolve endpoint must + // report this provider as unsearchable, not silently "no sessions". + this.scanFailures.add(provider.name) } }), ) @@ -1429,8 +1464,14 @@ export class CodingCliSessionIndexer { // On warm rescan this processes all files normally. for (const provider of this.providers) { if (!enabledSet.has(provider.name) || provider.listSessionsDirect) continue - const files = filesByProvider?.get(provider) ?? await provider.listSessionFiles().catch((err) => { + const files = filesByProvider?.get(provider) ?? await provider.listSessionFiles().then((listed) => { + this.scanFailures.delete(provider.name) + return listed + }).catch((err) => { logger.warn({ err, provider: provider.name }, 'Could not list session files') + // Record per attempt (never bulk-cleared): the resolve endpoint must + // report this provider as unsearchable, not silently "no sessions". + this.scanFailures.add(provider.name) return [] as string[] }) fileCount += files.length @@ -1527,6 +1568,16 @@ export class CodingCliSessionIndexer { if (!changed) { logger.debug({ sessionCount, fileCount }, 'Skipping no-op refresh (no project changes)') } + // A DISABLED provider is UNSEARCHED, not failed β€” prune its stale failure + // so a failed-then-disabled provider cannot keep resolve responses + // 'degraded' forever (no successful scan could ever clear it). + for (const name of [...this.scanFailures]) { + if (!enabledSet.has(name)) this.scanFailures.delete(name) + } + // Readiness = at least one refresh completed. start() also sets this after + // its initial refresh β€” setting it here is idempotent and makes isReady() + // observable without start() (unit tests drive refresh() directly). + this.initialized = true endRefreshTimer({ projectCount: groups.length, sessionCount, fileCount, skipped: !changed }) } diff --git a/server/index.ts b/server/index.ts index 9fd5dcfc3..1b4722d10 100644 --- a/server/index.ts +++ b/server/index.ts @@ -765,8 +765,12 @@ async function main() { // locked opencode DB must never stall the server. resolveFallbacks: buildResolveFallbacks(codingCliProviders, { sessionMetadataStore, + // Multi-root: the claude provider's getSessionRoots() covers the primary + // projects dir AND any secondary roots (evaluated per lookup so runtime + // root changes are picked up) β€” an exact pasted id must resolve from + // every root the indexer scans. locateClaudeTranscript: (sessionId) => - locateClaudeTranscript(sessionId, getClaudeProjectsDir()), + locateClaudeTranscript(sessionId, claudeProvider.getSessionRoots()), }), })) diff --git a/server/sessions-router.ts b/server/sessions-router.ts index 7b91b32a8..c70a36e46 100644 --- a/server/sessions-router.ts +++ b/server/sessions-router.ts @@ -1,3 +1,4 @@ +import os from 'os' import { Router } from 'express' import { z } from 'zod' import { cleanString } from './utils.js' @@ -19,7 +20,11 @@ import { SessionTypeMetadataSourceSchema, } from '../shared/session-flavor.js' import { querySessionDirectory } from './session-directory/service.js' -import { ResumeResolveRequestSchema } from '../shared/resume-resolve-contract.js' +import { + ResumeResolveRequestSchema, + type ResumeResolveProviderError, + type ResumeResolveResponse, +} from '../shared/resume-resolve-contract.js' import { resolveResumeInput } from './coding-cli/resolve-session.js' import type { ResolveFallbacks } from './coding-cli/resolve-fallbacks.js' import { createRequestAbortSignal } from './read-models/request-abort.js' @@ -48,6 +53,12 @@ export interface SessionsRouterDeps { codingCliIndexer: { getProjects: () => any[] refresh: () => Promise + /** True once at least one index refresh completed (resolve readiness signal). */ + isReady?: () => boolean + /** Providers whose MOST RECENT listing attempt failed (unsearchable, not empty). */ + getScanFailures?: () => string[] + /** Fire-and-forget refresh so a degraded response's Retry can converge. */ + requestRefresh?: () => void } codingCliProviders: CodingCliProvider[] perfConfig: { slowSessionRefreshMs: number } @@ -62,6 +73,8 @@ export interface SessionsRouterDeps { getIndexReadiness?: () => boolean /** Exact-id resolve fallbacks (buildResolveFallbacks); budget applied per request. */ resolveFallbacks?: ResolveFallbacks + /** Server home directory returned to the resolve client for cwd prefill. Defaults to os.homedir(). */ + homeDir?: string } export function createSessionsRouter(deps: SessionsRouterDeps): Router { @@ -236,6 +249,11 @@ export function createSessionsRouter(deps: SessionsRouterDeps): Router { res.json({ ok: true }) }) + // The indexer scans ONLY settings-enabled providers, so a disabled + // provider's sessions can never be found. Report those as UNSEARCHED so + // "not found" never overclaims. Order matches the canonical provider list. + const KNOWN_RESUME_PROVIDERS = ['claude', 'codex', 'opencode', 'amplifier'] as const + router.post('/sessions/resolve', async (req, res) => { const parsed = ResumeResolveRequestSchema.safeParse(req.body ?? {}) if (!parsed.success) { @@ -243,11 +261,56 @@ export function createSessionsRouter(deps: SessionsRouterDeps): Router { .status(400) .json({ error: 'Invalid resolve request', details: parsed.error.issues }) } - const response = await resolveResumeInput(parsed.data.input, { + // Readiness = startupState (getIndexReadiness) OR'd with the indexer's own + // isReady() signal: startup readiness can stick false forever (its + // markReady only runs in the start chain's success path), so once the + // indexer has completed a refresh the endpoint must stop reporting + // warming. When NEITHER signal is wired, default to ready. + const readinessSignals: Array<() => boolean> = [] + if (deps.getIndexReadiness) readinessSignals.push(deps.getIndexReadiness) + const indexerIsReady = deps.codingCliIndexer.isReady + if (indexerIsReady) readinessSignals.push(() => indexerIsReady.call(deps.codingCliIndexer)) + const result = await resolveResumeInput(parsed.data.input, { getProjects: () => deps.codingCliIndexer.getProjects(), - isIndexReady: deps.getIndexReadiness ?? (() => true), + isIndexReady: () => readinessSignals.length === 0 || readinessSignals.some((fn) => fn()), fallbacks: deps.resolveFallbacks, }) + const settings = await configStore.getSettings().catch(() => ({})) + const enabled = new Set( + settings?.codingCli?.enabledProviders ?? KNOWN_RESUME_PROVIDERS, + ) + const unsearchedProviders = KNOWN_RESUME_PROVIDERS.filter((name) => !enabled.has(name)) + // A provider whose last index SCAN failed was not searched either β€” the + // indexer swallows listing failures into empty lists. A DISABLED provider + // is unsearched (reported above), never a provider error: otherwise a + // failed-then-disabled provider would keep responses degraded forever (no + // successful scan could ever clear it). Fallback errors win the dedupe β€” + // they carry the more specific message/code. + const errorsByProvider = new Map( + result.providerErrors.map((entry) => [entry.provider, entry]), + ) + for (const name of deps.codingCliIndexer.getScanFailures?.() ?? []) { + if (!enabled.has(name) || errorsByProvider.has(name)) continue + errorsByProvider.set(name, { provider: name, message: 'session scan failed' }) + } + const providerErrors = [...errorsByProvider.values()] + // degraded = something FAILED β€” even when matches exist: a failed provider + // means a HIGHER-priority exact match may have been missed, so the client + // must never auto-resume a surviving lower-priority match. + const status: 'ready' | 'warming' | 'degraded' = + result.status === 'warming' ? 'warming' : providerErrors.length > 0 ? 'degraded' : 'ready' + // Fire-and-forget: give the user's Retry a chance to converge once a + // failed provider recovers (scan failures only clear on a new scan). + if (status === 'degraded') deps.codingCliIndexer.requestRefresh?.() + const response: ResumeResolveResponse = { + status, + matches: result.matches, + hint: result.hint, + providerErrors, + unsearchedProviders, + // Lets the client prefill a CONCRETE cwd instead of the '~' sentinel. + homeDir: deps.homeDir ?? os.homedir(), + } res.json(response) }) diff --git a/shared/resume-resolve-contract.ts b/shared/resume-resolve-contract.ts index a942a962e..8e45cc9f0 100644 --- a/shared/resume-resolve-contract.ts +++ b/shared/resume-resolve-contract.ts @@ -22,12 +22,42 @@ export const ResumeResolveHintSchema = z.object({ source: z.enum(['command', 'word', 'id-shape']), }) +/** + * Per-provider error summary. A provider that could not be searched (locked + * DB, unreadable store, failed index scan) is 'degraded' β€” NEVER "not found". + */ +export const ResumeResolveProviderErrorSchema = z.object({ + provider: z.string().min(1), + /** errno code from the underlying failure when known (e.g. 'EACCES'). */ + code: z.string().optional(), + message: z.string().optional(), +}) + +/** + * Provider-health extension is ADDITIVE and backward-tolerant: legacy + * responses without the new fields still parse (defaults apply), and legacy + * clients ignore the extra fields. + * + * status semantics: + * - 'ready': every enabled provider was searched successfully. + * - 'warming': the index has not completed its first scan β€” retry, not "not found". + * - 'degraded': at least one provider FAILED. Even with matches present the + * client must never auto-resume: a failed higher-priority exact search may + * have hidden the right session. + */ export const ResumeResolveResponseSchema = z.object({ - status: z.enum(['ready', 'warming']), + status: z.enum(['ready', 'warming', 'degraded']), matches: z.array(ResumeResolveMatchSchema), hint: ResumeResolveHintSchema.nullable(), + providerErrors: z.array(ResumeResolveProviderErrorSchema).default([]), + /** Settings-disabled providers β€” reported so "not found" never overclaims. */ + unsearchedProviders: z.array(z.string()).default([]), + /** Server home directory, so the client can prefill a concrete cwd. */ + homeDir: z.string().optional(), }) export type ResumeResolveRequest = z.infer export type ResumeResolveMatch = z.infer +export type ResumeResolveHint = z.infer +export type ResumeResolveProviderError = z.infer export type ResumeResolveResponse = z.infer diff --git a/src/components/ResumeSessionDialog.tsx b/src/components/ResumeSessionDialog.tsx index a4eab2b68..d2cdea074 100644 --- a/src/components/ResumeSessionDialog.tsx +++ b/src/components/ResumeSessionDialog.tsx @@ -11,6 +11,7 @@ import { parseResumeInput } from '@shared/resume-input-parser' import { ResumeResolveResponseSchema, type ResumeResolveMatch, + type ResumeResolveProviderError, } from '@shared/resume-resolve-contract' const WARMING_RETRY_MS = 2000 @@ -28,8 +29,14 @@ type Phase = | { kind: 'warming' } | { kind: 'index-unavailable' } | { kind: 'no-token' } - | { kind: 'no-match' } + | { kind: 'no-match'; unsearchedProviders: string[] } | { kind: 'disambiguate'; matches: ResumeResolveMatch[] } + // Provider unavailable β‰  not found: some agent stores could not be searched. + // MANUAL retry only (the server fire-and-forgets an index refresh on every + // degraded response, so Retry converges once the provider recovers) β€” never + // the warming auto-retry budget, and NEVER auto-resume (a failed higher- + // priority exact search may have hidden the right session). + | { kind: 'degraded'; matches: ResumeResolveMatch[]; providerErrors: ResumeResolveProviderError[] } | { kind: 'resumed'; note: string } | { kind: 'request-failed' } @@ -72,6 +79,8 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession const warmingRetriesRef = useRef(0) // Stale-response guard: only the LATEST resolve request may mutate state. const resolveSeqRef = useRef(0) + // homeDir prefill never overwrites a USER-edited working directory. + const cwdTouchedRef = useRef(false) // Advisory hint pre-fills the picker; never overrides a manual choice. useEffect(() => { @@ -114,14 +123,26 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession return } if (seq !== resolveSeqRef.current) return // stale β€” ignore + // Prefill the working directory with the server's CONCRETE home instead + // of the '~' sentinel β€” but never clobber a user-edited value. + if (response.homeDir && !cwdTouchedRef.current) setAnywayCwd(response.homeDir) + if (response.status === 'degraded') { + // Provider unavailable β‰  not found β€” and it must NEVER reach the + // auto-resume below: a failed provider means a higher-priority exact + // match may have been missed, so auto-opening a surviving match could + // open the WRONG session. Surviving matches render for MANUAL + // confirmation; retry is MANUAL only (the server already schedules an + // index refresh on every degraded response, so Retry converges). + setPhase({ + kind: 'degraded', + matches: response.matches, + providerErrors: response.providerErrors, + }) + return + } if (response.status !== 'ready') { - // Any non-ready response is a retry state, never "not found" β€” and it - // must NEVER reach the auto-resume below. - // DEGRADED SEAM: when the provider-health lane extends the contract - // (status === 'degraded' + providerErrors/unsearchedProviders), handle - // 'degraded' here as its own retry state: a failed provider means a - // higher-priority exact match may have been missed, so auto-opening a - // surviving match could open the WRONG session. + // Warming is a retry state, never "not found" β€” and it must NEVER + // reach the auto-resume below. if (warmingRetriesRef.current >= WARMING_RETRY_LIMIT) { setPhase({ kind: 'index-unavailable' }) return @@ -142,7 +163,9 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession setPhase({ kind: 'disambiguate', matches: response.matches }) return } - setPhase({ kind: 'no-match' }) + // Absence claims must name what was NOT searched (disabled providers) β€” + // otherwise "not found" implies the id does not exist anywhere. + setPhase({ kind: 'no-match', unsearchedProviders: response.unsearchedProviders }) }, [finishResume], ) @@ -384,6 +407,29 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession
)} + {phase.kind === 'degraded' && ( +
+ Some agents could not be searched:{' '} + {phase.providerErrors + .map( + (entry) => + `${entry.provider}${entry.code ? ` (${entry.code})` : ''}${entry.message ? ` β€” ${entry.message}` : ''}`, + ) + .join('; ')} + . + {phase.matches.length > 0 + ? ' The matches below may be incomplete β€” confirm one manually or retry.' + : ' This is not a "not found".'} + +
+ )} {phase.kind === 'no-token' && (
No session id found in the pasted text. @@ -399,7 +445,7 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession {phase.note}
)} - {phase.kind === 'disambiguate' && ( + {(phase.kind === 'disambiguate' || phase.kind === 'degraded') && phase.matches.length > 0 && (
    {phase.matches.map((candidate) => (
  • @@ -424,7 +470,8 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession ))}
)} - {phase.kind === 'disambiguate' && phase.matches.some((candidate) => !candidate.cwd) && ( + {(phase.kind === 'disambiguate' || phase.kind === 'degraded') && + phase.matches.some((candidate) => !candidate.cwd) && (