fix(#680): session sort corruption with mtime fallback for corrupted JSON

When a session JSON file is corrupted, getFileMainSessions() previously
silently skipped it with 'catch { continue }', causing the session to
disappear from the sorted list entirely.

Now, when JSON.parse fails:
- Use the file's mtime as fallback for both created and updated timestamps
- Set load_error field with the parse error message
- Include the session in sort results so it remains visible

This prevents session sort corruption where corrupted sessions vanish
from the list, breaking chronological ordering and confusing users.

Closes #680
This commit is contained in:
YeonGyu-Kim
2026-05-25 10:53:05 +09:00
parent 222adeb606
commit 4906d9207a
3 changed files with 93 additions and 5 deletions
+21 -4
View File
@@ -1,5 +1,5 @@
import { existsSync } from "node:fs"
import { readdir, readFile } from "node:fs/promises"
import { readdir, readFile, stat } from "node:fs/promises"
import { join } from "node:path"
import { MESSAGE_STORAGE, PART_STORAGE, SESSION_STORAGE, TODO_DIR, TRANSCRIPT_DIR } from "./constants"
import { getMessageDir } from "../../shared/opencode-message-dir"
@@ -20,14 +20,31 @@ export async function getFileMainSessions(directory?: string): Promise<SessionMe
for (const file of sessionFiles) {
if (!file.endsWith(".json")) continue
const filePath = join(projectPath, file)
try {
const content = await readFile(join(projectPath, file), "utf-8")
const content = await readFile(filePath, "utf-8")
const meta = JSON.parse(content) as SessionMetadata
if (meta.parentID) continue
if (directory && meta.directory !== directory) continue
sessions.push(meta)
} catch {
continue
} catch (err) {
const sessionID = file.replace(/\.json$/, "")
try {
const stats = await stat(filePath)
const mtime = Math.floor(stats.mtimeMs)
sessions.push({
id: sessionID,
projectID: projectDir.name,
directory: directory || "",
time: {
created: mtime,
updated: mtime,
},
load_error: err instanceof Error ? err.message : String(err),
})
} catch {
continue
}
}
}
}
+56
View File
@@ -397,6 +397,62 @@ describe("session-manager storage - getMainSessions", () => {
expect(sessionsB[0].id).toBe("ses_projB")
})
test("getMainSessions includes corrupted JSON files with mtime fallback and load_error", async () => {
//#given
const projectID = "proj_corrupt"
const now = Date.now()
// Create a valid session
createSessionMetadata(projectID, "ses_valid", { directory: "/test/path", updated: now - 1000 })
// Create a corrupted session file
const projectDir = join(TEST_SESSION_STORAGE, projectID)
mkdirSync(projectDir, { recursive: true })
const corruptFile = join(projectDir, "ses_corrupt.json")
writeFileSync(corruptFile, "{ invalid json", "utf-8")
// Manually set the mtime to be newer than the valid session
// Note: we can't easily control mtime in tests, so we'll verify it's included with load_error
//#when
const sessions = await storage.getMainSessions({ directory: "/test/path" })
//#then
expect(sessions.length).toBe(2)
const corruptSession = sessions.find((s) => s.id === "ses_corrupt")
expect(corruptSession).toBeDefined()
expect(corruptSession?.load_error).toBeTruthy()
expect(corruptSession?.time.updated).toBeGreaterThan(0)
})
test("getMainSessions sorts corrupted JSON files alongside valid sessions using mtime", async () => {
//#given
const projectID = "proj_sort"
const now = Date.now()
createSessionMetadata(projectID, "ses_old", { directory: "/test/path", updated: now - 5000 })
createSessionMetadata(projectID, "ses_new", { directory: "/test/path", updated: now })
// Create a corrupted file with mtime in between
const projectDir = join(TEST_SESSION_STORAGE, projectID)
mkdirSync(projectDir, { recursive: true })
const corruptFile = join(projectDir, "ses_mid.json")
writeFileSync(corruptFile, "{ invalid json", "utf-8")
// Touch the file to set mtime between old and new
// We use utimesSync to set a specific mtime
const { utimesSync } = require("node:fs")
utimesSync(corruptFile, new Date(now - 2500), new Date(now - 2500))
//#when
const sessions = await storage.getMainSessions({ directory: "/test/path" })
//#then
expect(sessions.length).toBe(3)
expect(sessions[0].id).toBe("ses_new")
expect(sessions[1].id).toBe("ses_mid")
expect(sessions[2].id).toBe("ses_old")
})
test("getMainSessions returns all main sessions when directory is not specified", async () => {
// given
const projectA = "proj_aaa"
+16 -1
View File
@@ -49,7 +49,22 @@ export interface SearchResult {
timestamp?: number
}
export interface SessionMetadata {
id: string
version?: string
projectID: string
directory: string
title?: string
parentID?: string
time: {
created: number
updated: number
}
summary?: {
additions: number
deletions: number
files: number
}
load_error?: string
id: string
version?: string
projectID: string