mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
9fce90be9d
commit
a2ed5bca7c
5 changed files with 635 additions and 0 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
108
src/utils/__tests__/safeWriteJson.staleLock.test.ts
Normal file
108
src/utils/__tests__/safeWriteJson.staleLock.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
306
src/utils/__tests__/staleLockRecovery.test.ts
Normal file
306
src/utils/__tests__/staleLockRecovery.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
190
src/utils/staleLockRecovery.ts
Normal file
190
src/utils/staleLockRecovery.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue