Skip to content

fix(opencode): Windows path case mismatch in SQLite scanner#617

Open
spralle wants to merge 4 commits into
tiann:mainfrom
spralle:feature/opencode-sqlite-scanner
Open

fix(opencode): Windows path case mismatch in SQLite scanner#617
spralle wants to merge 4 commits into
tiann:mainfrom
spralle:feature/opencode-sqlite-scanner

Conversation

@spralle
Copy link
Copy Markdown

@spralle spralle commented May 12, 2026

Builds on #589 — the WHERE directory = ? query never matched on Windows because normalizePath() lowercases paths but the DB stores original casing (C:\foo vs c:\foo).

Uses LOWER(directory) on win32 so the comparison works. Tested locally, session discovery now succeeds.

DullJZ and others added 3 commits May 11, 2026 16:54
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] DB priming can replay stale parts from the whole session, evidence cli/src/opencode/utils/opencodeStorageScanner.ts:408.
  • [Major] Seeded/resumed sessions never select the SQLite storage source, evidence cli/src/opencode/utils/opencodeStorageScanner.ts:536.

Summary

  • Review mode: initial
  • The new SQLite scanner path has correctness risks for replay filtering and resumed sessions.

Testing

  • Not run (automation; review-only, did not execute PR code).

messageIds.push(msg.id);
this.messageDbVersion.set(msg.id, msg.time_updated);

if (msg.time_created >= replayThresholdMs) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] msg.time_created >= replayThresholdMs only gates message.updated; the later part loop still iterates every messageId and emits every matching part in the session. The file-backed path records replayMessageIds and only replays parts for messages created near scanner startup, so a DB-backed session with older messages can resend stale text/tool parts into HAPI as fresh output.

Suggested fix:

const replayMessageIds = new Set<string>();

// inside the message loop
if (msg.time_created >= replayThresholdMs) {
    replayMessageIds.add(msg.id);
    // emit message.updated
}

// before loading parts for a message
for (const messageId of messageIds) {
    if (!replayMessageIds.has(messageId)) {
        continue;
    }
    // load and emit parts
}

// Use the same storage source as setActiveSession for consistency
if (this.activeStorageSource === 'database' && this.dbReady && this.db) {
await this.scanMessagesAndPartsFromDatabase(sessionId);
} else if (this.activeStorageSource === 'files' || !this.activeStorageSource) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] When opts.sessionId is already known, the constructor sets activeSessionId, so discoverSessionId() never calls setActiveSession(). With activeStorageSource still null, this branch scans only files and never reads opencode.db; DB-backed resume sessions can miss all message/part updates until a different session id is observed.

Suggested fix:

private async ensureActiveStorageSource(sessionId: string): Promise<void> {
    if (this.activeStorageSource) {
        return;
    }
    this.messageRoles.clear();
    this.messageDbVersion.clear();
    this.partDbVersion.clear();

    if (this.dbReady && this.db) {
        await this.primeSessionFilesFromDatabase(sessionId);
        this.activeStorageSource = 'database';
        return;
    }
    if (await this.ensureStorageDir()) {
        await this.primeSessionFilesFromFiles(sessionId);
        this.activeStorageSource = 'files';
    }
}

// before scanMessagesAndParts(this.activeSessionId)
await this.ensureActiveStorageSource(this.activeSessionId);

120s was too tight — heavier projects need more time for opencode to create the session entry.
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Database priming replays stale parts — primeSessionFilesFromDatabase() emits every part for every message in the session, while the file-backed path only replays parts for messages newer than referenceTimestampMs - REPLAY_CLOCK_SKEW_MS. Once SQLite is available, switching to activeStorageSource = 'database' can therefore resend older assistant text/tool parts into the live HAPI transcript when attaching to a session with existing history. Evidence: cli/src/opencode/utils/opencodeStorageScanner.ts:451
    Suggested fix:
    const replayMessageIds = new Set<string>();
    
    // inside the message loop, only mark messages selected for replay
    if (msg.time_created >= replayThresholdMs) {
        replayMessageIds.add(msg.id);
        // emit message.updated
    }
    
    // inside the part loop
    if (!replayMessageIds.has(messageId)) {
        continue;
    }

Questions

  • None.

Summary
Review mode: initial

The PR changes OpenCode local tracking from file-only polling to SQLite-first polling. The main regression is that the new database priming path does not preserve the existing replay window for parts. Scanner coverage is also absent: not found in repo/docs for opencodeStorageScanner tests.

Testing

  • Not run (automation): bun run typecheck:cli failed because bun is not installed in this runner.

HAPI Bot

messageID: partRow.message_id,
sessionID: partRow.session_id
};
if (this.shouldEmitPart(part, messageId)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] This database priming path emits parts for every messageId, but the existing file-backed path only emits parts for messages added to replayMessageIds after passing the startup replay threshold. With SQLite available, attaching to a session that already has history can resend old assistant text/tool parts into the current transcript. Preserve the same replay gate here.

const replayMessageIds = new Set<string>();

if (msg.time_created >= replayThresholdMs) {
    replayMessageIds.add(msg.id);
    // emit message.updated
}

if (!replayMessageIds.has(messageId)) {
    continue;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants