fix: handle stale locks and missing ui_messages.json on startup

- Add stale lock detection on extension activation
- Automatically recover by removing stale locks and creating empty ui_messages.json
- Update safeWriteJson to create file before lock acquisition
- Add comprehensive tests for stale lock recovery

Fixes #6022
This commit is contained in:
Roo Code 2025-07-21 17:03:54 +00:00
parent 9fce90be9d
commit a2ed5bca7c
5 changed files with 635 additions and 0 deletions

View file

@ -39,6 +39,7 @@ import {
CodeActionProvider,
} from "./activate"
import { initializeI18n } from "./i18n"
import { performStartupStaleLockRecovery } from "./utils/staleLockRecovery"
/**
* Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@ -86,6 +87,18 @@ export async function activate(context: vscode.ExtensionContext) {
// Initialize i18n for internationalization support
initializeI18n(context.globalState.get("language") ?? formatLanguage(vscode.env.language))
// Perform stale lock recovery on startup
try {
await performStartupStaleLockRecovery(context.globalStorageUri.fsPath, {
maxLockAge: 10 * 60 * 1000, // 10 minutes
autoRecover: true,
})
} catch (error) {
outputChannel.appendLine(
`[StaleLockRecovery] Error during startup recovery: ${error instanceof Error ? error.message : String(error)}`,
)
}
// Initialize terminal shell execution handlers.
TerminalRegistry.initialize()

View file

@ -0,0 +1,108 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
import * as fs from "fs/promises"
import * as path from "path"
import * as os from "os"
import { safeWriteJson } from "../safeWriteJson"
describe("safeWriteJson - stale lock handling", () => {
let tempDir: string
let testFilePath: string
beforeEach(async () => {
// Create a temporary directory for testing
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-json-stale-lock-test-"))
testFilePath = path.join(tempDir, "test.json")
})
afterEach(async () => {
// Clean up temporary directory
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {})
// Clean up any stray lock files
try {
await fs.unlink(`${testFilePath}.lock`)
} catch {
// Ignore if doesn't exist
}
})
it("should create file before acquiring lock if file doesn't exist", async () => {
// Ensure file doesn't exist
await expect(fs.access(testFilePath)).rejects.toThrow()
// Write data
const testData = { test: "data" }
await safeWriteJson(testFilePath, testData)
// Verify file was created with correct content
const content = await fs.readFile(testFilePath, "utf8")
expect(JSON.parse(content)).toEqual(testData)
})
it("should handle missing target file by creating it first", async () => {
// Ensure file doesn't exist
await expect(fs.access(testFilePath)).rejects.toThrow()
// Write data - should succeed by creating the file first
const testData = { test: "data for missing file" }
await safeWriteJson(testFilePath, testData)
// Verify file was created with correct content
const content = await fs.readFile(testFilePath, "utf8")
expect(JSON.parse(content)).toEqual(testData)
})
it("should work normally when file already exists", async () => {
// Create file first
const initialData = { initial: "content" }
await fs.writeFile(testFilePath, JSON.stringify(initialData))
// Write new data
const newData = { updated: "content" }
await safeWriteJson(testFilePath, newData)
// Verify file was updated
const content = await fs.readFile(testFilePath, "utf8")
expect(JSON.parse(content)).toEqual(newData)
})
it("should handle concurrent writes correctly", async () => {
// Create file first
await fs.writeFile(testFilePath, JSON.stringify({ initial: "data" }))
// Attempt concurrent writes
const writes = []
for (let i = 0; i < 5; i++) {
writes.push(safeWriteJson(testFilePath, { count: i }))
}
// All writes should complete without error
await expect(Promise.all(writes)).resolves.toBeDefined()
// File should contain data from one of the writes
const content = await fs.readFile(testFilePath, "utf8")
const data = JSON.parse(content)
expect(data).toHaveProperty("count")
expect(typeof data.count).toBe("number")
})
it("should handle temporary files correctly", async () => {
// Create initial file
const initialData = { initial: "data", important: true }
await fs.writeFile(testFilePath, JSON.stringify(initialData))
// Write new data multiple times to ensure temp files are cleaned up
for (let i = 0; i < 3; i++) {
const newData = { updated: "content", iteration: i }
await safeWriteJson(testFilePath, newData)
// Verify file was updated
const content = await fs.readFile(testFilePath, "utf8")
expect(JSON.parse(content)).toEqual(newData)
}
// Check that no temp files remain in the directory
const files = await fs.readdir(tempDir)
const tempFiles = files.filter((f) => f.includes(".tmp") || f.includes(".bak"))
expect(tempFiles).toHaveLength(0)
})
})

View file

@ -0,0 +1,306 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
import * as fs from "fs/promises"
import * as path from "path"
import * as os from "os"
import { detectStaleLocks, recoverStaleLocks, performStartupStaleLockRecovery } from "../staleLockRecovery"
import { GlobalFileNames } from "../../shared/globalFileNames"
describe("staleLockRecovery", () => {
let tempDir: string
let globalStoragePath: string
let tasksDir: string
beforeEach(async () => {
// Create a temporary directory for testing
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "stale-lock-test-"))
globalStoragePath = tempDir
tasksDir = path.join(globalStoragePath, "tasks")
await fs.mkdir(tasksDir, { recursive: true })
})
afterEach(async () => {
// Clean up temporary directory
await fs.rm(tempDir, { recursive: true, force: true })
})
describe("detectStaleLocks", () => {
it("should return empty array when no tasks exist", async () => {
const results = await detectStaleLocks(globalStoragePath)
expect(results).toEqual([])
})
it("should detect task with lock files and missing ui_messages.json", async () => {
// Create a task directory with lock files but no ui_messages.json
const taskId = "test-task-1"
const taskPath = path.join(tasksDir, taskId)
await fs.mkdir(taskPath)
// Create lock files
await fs.writeFile(path.join(taskPath, "file1.lock"), "")
await fs.writeFile(path.join(taskPath, "file2.lock"), "")
// Make lock files old enough to be stale
const oldTime = new Date(Date.now() - 15 * 60 * 1000) // 15 minutes ago
await fs.utimes(path.join(taskPath, "file1.lock"), oldTime, oldTime)
await fs.utimes(path.join(taskPath, "file2.lock"), oldTime, oldTime)
const results = await detectStaleLocks(globalStoragePath)
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
taskId,
taskPath,
hasLockFiles: true,
hasUiMessagesFile: false,
lockFiles: expect.arrayContaining(["file1.lock", "file2.lock"]),
isStale: true,
})
})
it("should not mark as stale when ui_messages.json exists", async () => {
// Create a task directory with lock files and ui_messages.json
const taskId = "test-task-2"
const taskPath = path.join(tasksDir, taskId)
await fs.mkdir(taskPath)
// Create lock files
await fs.writeFile(path.join(taskPath, "file1.lock"), "")
// Create ui_messages.json
await fs.writeFile(path.join(taskPath, GlobalFileNames.uiMessages), "[]")
// Make lock file old
const oldTime = new Date(Date.now() - 15 * 60 * 1000)
await fs.utimes(path.join(taskPath, "file1.lock"), oldTime, oldTime)
const results = await detectStaleLocks(globalStoragePath)
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
taskId,
hasLockFiles: true,
hasUiMessagesFile: true,
isStale: false,
})
})
it("should not mark as stale when lock files are recent", async () => {
// Create a task directory with recent lock files
const taskId = "test-task-3"
const taskPath = path.join(tasksDir, taskId)
await fs.mkdir(taskPath)
// Create recent lock file
await fs.writeFile(path.join(taskPath, "file1.lock"), "")
const results = await detectStaleLocks(globalStoragePath, {
maxLockAge: 10 * 60 * 1000, // 10 minutes
})
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
taskId,
hasLockFiles: true,
hasUiMessagesFile: false,
isStale: false,
})
})
it("should handle multiple tasks correctly", async () => {
// Create multiple tasks with different states
const task1 = "task-1"
const task1Path = path.join(tasksDir, task1)
await fs.mkdir(task1Path)
await fs.writeFile(path.join(task1Path, "file.lock"), "")
const task2 = "task-2"
const task2Path = path.join(tasksDir, task2)
await fs.mkdir(task2Path)
await fs.writeFile(path.join(task2Path, "file.lock"), "")
await fs.writeFile(path.join(task2Path, GlobalFileNames.uiMessages), "[]")
// Make task1 lock stale
const oldTime = new Date(Date.now() - 15 * 60 * 1000)
await fs.utimes(path.join(task1Path, "file.lock"), oldTime, oldTime)
const results = await detectStaleLocks(globalStoragePath)
expect(results).toHaveLength(2)
expect(results.find((r) => r.taskId === task1)?.isStale).toBe(true)
expect(results.find((r) => r.taskId === task2)?.isStale).toBe(false)
})
})
describe("recoverStaleLocks", () => {
it("should remove lock files and create ui_messages.json for stale tasks", async () => {
// Create a stale task
const taskId = "stale-task"
const taskPath = path.join(tasksDir, taskId)
await fs.mkdir(taskPath)
const lockFile1 = path.join(taskPath, "file1.lock")
const lockFile2 = path.join(taskPath, "file2.lock")
await fs.writeFile(lockFile1, "")
await fs.writeFile(lockFile2, "")
const detectionResults = [
{
taskId,
taskPath,
hasLockFiles: true,
hasUiMessagesFile: false,
lockFiles: ["file1.lock", "file2.lock"],
isStale: true,
oldestLockAge: 15 * 60 * 1000,
},
]
await recoverStaleLocks(detectionResults)
// Check that lock files were removed
await expect(fs.access(lockFile1)).rejects.toThrow()
await expect(fs.access(lockFile2)).rejects.toThrow()
// Check that ui_messages.json was created
const uiMessagesPath = path.join(taskPath, GlobalFileNames.uiMessages)
const uiMessagesContent = await fs.readFile(uiMessagesPath, "utf8")
expect(uiMessagesContent).toBe("[]")
})
it("should not recover non-stale tasks", async () => {
// Create a non-stale task
const taskId = "non-stale-task"
const taskPath = path.join(tasksDir, taskId)
await fs.mkdir(taskPath)
const lockFile = path.join(taskPath, "file.lock")
await fs.writeFile(lockFile, "")
const detectionResults = [
{
taskId,
taskPath,
hasLockFiles: true,
hasUiMessagesFile: false,
lockFiles: ["file.lock"],
isStale: false,
oldestLockAge: 5 * 60 * 1000,
},
]
await recoverStaleLocks(detectionResults)
// Check that lock file was NOT removed
await expect(fs.access(lockFile)).resolves.toBeUndefined()
// Check that ui_messages.json was NOT created
const uiMessagesPath = path.join(taskPath, GlobalFileNames.uiMessages)
await expect(fs.access(uiMessagesPath)).rejects.toThrow()
})
it("should respect autoRecover config", async () => {
// Create a stale task
const taskId = "stale-task"
const taskPath = path.join(tasksDir, taskId)
await fs.mkdir(taskPath)
const lockFile = path.join(taskPath, "file.lock")
await fs.writeFile(lockFile, "")
const detectionResults = [
{
taskId,
taskPath,
hasLockFiles: true,
hasUiMessagesFile: false,
lockFiles: ["file.lock"],
isStale: true,
oldestLockAge: 15 * 60 * 1000,
},
]
// Disable auto recovery
await recoverStaleLocks(detectionResults, { autoRecover: false })
// Check that lock file was NOT removed
await expect(fs.access(lockFile)).resolves.toBeUndefined()
})
it("should handle errors gracefully", async () => {
// Create a mock console.error to suppress error output in tests
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
// Create invalid detection results
const detectionResults = [
{
taskId: "invalid-task",
taskPath: "/invalid/path/that/does/not/exist",
hasLockFiles: true,
hasUiMessagesFile: false,
lockFiles: ["file.lock"],
isStale: true,
oldestLockAge: 15 * 60 * 1000,
},
]
// Should not throw
await expect(recoverStaleLocks(detectionResults)).resolves.toBeUndefined()
// Should have logged errors
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
})
describe("performStartupStaleLockRecovery", () => {
it("should detect and recover stale locks on startup", async () => {
// Create a stale task
const taskId = "startup-stale-task"
const taskPath = path.join(tasksDir, taskId)
await fs.mkdir(taskPath)
const lockFile = path.join(taskPath, "file.lock")
await fs.writeFile(lockFile, "")
// Make lock file old
const oldTime = new Date(Date.now() - 15 * 60 * 1000)
await fs.utimes(lockFile, oldTime, oldTime)
// Mock console.log to verify logging
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {})
await performStartupStaleLockRecovery(globalStoragePath)
// Check that lock file was removed
await expect(fs.access(lockFile)).rejects.toThrow()
// Check that ui_messages.json was created
const uiMessagesPath = path.join(taskPath, GlobalFileNames.uiMessages)
const uiMessagesContent = await fs.readFile(uiMessagesPath, "utf8")
expect(uiMessagesContent).toBe("[]")
// Check that appropriate logs were made
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Starting stale lock detection"))
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Found 1 task(s) with stale locks"))
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Recovery completed"))
consoleLogSpy.mockRestore()
})
it("should handle errors gracefully", async () => {
// Mock console methods
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {})
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
// Use invalid path
await performStartupStaleLockRecovery("/invalid/path/that/does/not/exist")
// Should have logged error
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("No stale locks detected"))
consoleLogSpy.mockRestore()
consoleErrorSpy.mockRestore()
})
})
})

View file

@ -37,6 +37,24 @@ async function safeWriteJson(filePath: string, data: any): Promise<void> {
throw dirError
}
// Check if the file exists before attempting to acquire lock
// This prevents issues where lock files exist but the target file doesn't
const fileExists = await fs
.access(absoluteFilePath)
.then(() => true)
.catch(() => false)
// If file doesn't exist, create an empty file first to ensure lock can be acquired
if (!fileExists) {
try {
await fs.writeFile(absoluteFilePath, "", "utf8")
} catch (createError: any) {
// If we can't create the file, we can't proceed
console.error(`Failed to create file for locking at ${absoluteFilePath}:`, createError)
throw createError
}
}
// Acquire the lock before any file operations
try {
releaseLock = await lockfile.lock(absoluteFilePath, {

View file

@ -0,0 +1,190 @@
import * as fs from "fs/promises"
import * as path from "path"
import { fileExistsAtPath } from "./fs"
import { GlobalFileNames } from "../shared/globalFileNames"
/**
* Configuration for stale lock detection
*/
interface StaleLockConfig {
/**
* Maximum age in milliseconds for a lock file to be considered stale
* Default: 10 minutes (600000ms)
*/
maxLockAge?: number
/**
* Whether to automatically recover stale locks
* Default: true
*/
autoRecover?: boolean
}
/**
* Result of stale lock detection for a task
*/
interface StaleLockDetectionResult {
taskId: string
taskPath: string
hasLockFiles: boolean
hasUiMessagesFile: boolean
lockFiles: string[]
isStale: boolean
oldestLockAge?: number
}
/**
* Detects stale lock conditions in task directories
* A stale lock condition exists when:
* 1. Lock files exist in the task directory
* 2. ui_messages.json is missing
* 3. Lock files are older than the configured threshold
*/
export async function detectStaleLocks(
globalStoragePath: string,
config: StaleLockConfig = {},
): Promise<StaleLockDetectionResult[]> {
const { maxLockAge = 10 * 60 * 1000 } = config // Default: 10 minutes
const results: StaleLockDetectionResult[] = []
try {
// Get the tasks directory
const tasksDir = path.join(globalStoragePath, "tasks")
// Check if tasks directory exists
if (!(await fileExistsAtPath(tasksDir))) {
return results
}
// Read all task directories
const taskDirs = await fs.readdir(tasksDir)
const currentTime = Date.now()
for (const taskId of taskDirs) {
const taskPath = path.join(tasksDir, taskId)
// Skip if not a directory
const stat = await fs.stat(taskPath).catch(() => null)
if (!stat || !stat.isDirectory()) {
continue
}
// Check for lock files
const files = await fs.readdir(taskPath)
const lockFiles = files.filter((f) => f.endsWith(".lock"))
const hasUiMessagesFile = await fileExistsAtPath(path.join(taskPath, GlobalFileNames.uiMessages))
if (lockFiles.length > 0) {
// Check age of lock files
let oldestLockAge = 0
let isStale = false
for (const lockFile of lockFiles) {
const lockPath = path.join(taskPath, lockFile)
const lockStat = await fs.stat(lockPath).catch(() => null)
if (lockStat) {
const age = currentTime - lockStat.mtimeMs
oldestLockAge = Math.max(oldestLockAge, age)
// Consider stale if missing ui_messages.json and lock is old enough
if (!hasUiMessagesFile && age > maxLockAge) {
isStale = true
}
}
}
results.push({
taskId,
taskPath,
hasLockFiles: true,
hasUiMessagesFile,
lockFiles,
isStale,
oldestLockAge,
})
}
}
return results
} catch (error) {
console.error("Error detecting stale locks:", error)
return results
}
}
/**
* Recovers from stale lock conditions by:
* 1. Removing stale lock files
* 2. Creating an empty ui_messages.json if missing
*/
export async function recoverStaleLocks(
detectionResults: StaleLockDetectionResult[],
config: StaleLockConfig = {},
): Promise<void> {
const { autoRecover = true } = config
if (!autoRecover) {
return
}
for (const result of detectionResults) {
if (!result.isStale) {
continue
}
try {
console.log(`[StaleLockRecovery] Recovering stale locks for task ${result.taskId}`)
// Remove lock files
for (const lockFile of result.lockFiles) {
const lockPath = path.join(result.taskPath, lockFile)
try {
await fs.unlink(lockPath)
console.log(`[StaleLockRecovery] Removed stale lock: ${lockFile}`)
} catch (error) {
console.error(`[StaleLockRecovery] Failed to remove lock ${lockFile}:`, error)
}
}
// Create empty ui_messages.json if missing
if (!result.hasUiMessagesFile) {
const uiMessagesPath = path.join(result.taskPath, GlobalFileNames.uiMessages)
try {
// Create an empty array to represent no messages
await fs.writeFile(uiMessagesPath, "[]", "utf8")
console.log(`[StaleLockRecovery] Created empty ui_messages.json for task ${result.taskId}`)
} catch (error) {
console.error(`[StaleLockRecovery] Failed to create ui_messages.json:`, error)
}
}
} catch (error) {
console.error(`[StaleLockRecovery] Error recovering task ${result.taskId}:`, error)
}
}
}
/**
* Performs stale lock detection and recovery on startup
*/
export async function performStartupStaleLockRecovery(
globalStoragePath: string,
config: StaleLockConfig = {},
): Promise<void> {
try {
console.log("[StaleLockRecovery] Starting stale lock detection...")
const detectionResults = await detectStaleLocks(globalStoragePath, config)
const staleCount = detectionResults.filter((r) => r.isStale).length
if (staleCount > 0) {
console.log(`[StaleLockRecovery] Found ${staleCount} task(s) with stale locks`)
await recoverStaleLocks(detectionResults, config)
console.log("[StaleLockRecovery] Recovery completed")
} else {
console.log("[StaleLockRecovery] No stale locks detected")
}
} catch (error) {
console.error("[StaleLockRecovery] Error during startup recovery:", error)
}
}