Merge pull request #1657 from code-yeongyu/fix-1366-lsp-unblock

fix(lsp): reset safety block on server restart (#1366)
This commit is contained in:
YeonGyu-Kim
2026-02-08 15:13:30 +09:00
committed by GitHub
13 changed files with 388 additions and 130 deletions

View File

@@ -8,6 +8,17 @@ const TEST_DIR = join(tmpdir(), "mcp-loader-test-" + Date.now())
describe("getSystemMcpServerNames", () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
// Isolate tests from real user environment (e.g., ~/.claude.json).
// loader.ts reads user-level config via os.homedir() + getClaudeConfigDir().
mock.module("os", () => ({
homedir: () => TEST_DIR,
tmpdir,
}))
mock.module("../../shared", () => ({
getClaudeConfigDir: () => join(TEST_DIR, ".claude"),
}))
})
afterEach(() => {

View File

@@ -1,5 +1,4 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { execSync } from "node:child_process"
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import {
@@ -12,6 +11,7 @@ import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/ho
import { log } from "../../shared/logger"
import { createSystemDirective, SYSTEM_DIRECTIVE_PREFIX, SystemDirectiveTypes } from "../../shared/system-directive"
import { isCallerOrchestrator, getMessageDir } from "../../shared/session-utils"
import { collectGitDiffStats, formatFileChanges } from "../../shared/git-worktree"
import type { BackgroundManager } from "../../features/background-agent"
export const HOOK_NAME = "atlas"
@@ -269,113 +269,6 @@ function extractSessionIdFromOutput(output: string): string {
return match?.[1] ?? "<session_id>"
}
interface GitFileStat {
path: string
added: number
removed: number
status: "modified" | "added" | "deleted"
}
function getGitDiffStats(directory: string): GitFileStat[] {
try {
const output = execSync("git diff --numstat HEAD", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
if (!output) return []
const statusOutput = execSync("git status --porcelain", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
const statusMap = new Map<string, "modified" | "added" | "deleted">()
for (const line of statusOutput.split("\n")) {
if (!line) continue
const status = line.substring(0, 2).trim()
const filePath = line.substring(3)
if (status === "A" || status === "??") {
statusMap.set(filePath, "added")
} else if (status === "D") {
statusMap.set(filePath, "deleted")
} else {
statusMap.set(filePath, "modified")
}
}
const stats: GitFileStat[] = []
for (const line of output.split("\n")) {
const parts = line.split("\t")
if (parts.length < 3) continue
const [addedStr, removedStr, path] = parts
const added = addedStr === "-" ? 0 : parseInt(addedStr, 10)
const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10)
stats.push({
path,
added,
removed,
status: statusMap.get(path) ?? "modified",
})
}
return stats
} catch {
return []
}
}
function formatFileChanges(stats: GitFileStat[], notepadPath?: string): string {
if (stats.length === 0) return "[FILE CHANGES SUMMARY]\nNo file changes detected.\n"
const modified = stats.filter((s) => s.status === "modified")
const added = stats.filter((s) => s.status === "added")
const deleted = stats.filter((s) => s.status === "deleted")
const lines: string[] = ["[FILE CHANGES SUMMARY]"]
if (modified.length > 0) {
lines.push("Modified files:")
for (const f of modified) {
lines.push(` ${f.path} (+${f.added}, -${f.removed})`)
}
lines.push("")
}
if (added.length > 0) {
lines.push("Created files:")
for (const f of added) {
lines.push(` ${f.path} (+${f.added})`)
}
lines.push("")
}
if (deleted.length > 0) {
lines.push("Deleted files:")
for (const f of deleted) {
lines.push(` ${f.path} (-${f.removed})`)
}
lines.push("")
}
if (notepadPath) {
const notepadStat = stats.find((s) => s.path.includes("notepad") || s.path.includes(".sisyphus"))
if (notepadStat) {
lines.push("[NOTEPAD UPDATED]")
lines.push(` ${notepadStat.path} (+${notepadStat.added})`)
lines.push("")
}
}
return lines.join("\n")
}
interface ToolExecuteAfterInput {
tool: string
sessionID?: string
@@ -750,8 +643,8 @@ export function createAtlasHook(
}
if (output.output && typeof output.output === "string") {
const gitStats = getGitDiffStats(ctx.directory)
const fileChanges = formatFileChanges(gitStats)
const gitStats = collectGitDiffStats(ctx.directory)
const fileChanges = formatFileChanges(gitStats)
const subagentSessionId = extractSessionIdFromOutput(output.output)
const boulderState = readBoulderState(ctx.directory)

View File

@@ -1,8 +1,9 @@
import { afterEach, describe, expect, test } from "bun:test"
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
import type { BackgroundTask } from "../../features/background-agent"
import { createUnstableAgentBabysitterHook } from "./index"
const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode"
const projectDir = process.cwd()
type BabysitterContext = Parameters<typeof createUnstableAgentBabysitterHook>[0]
@@ -21,6 +22,9 @@ function createMockPluginInput(options: {
prompt: async (input: unknown) => {
promptCalls.push({ input })
},
promptAsync: async (input: unknown) => {
promptCalls.push({ input })
},
},
},
}

View File

@@ -0,0 +1,29 @@
import { execSync } from "node:child_process"
import { parseGitStatusPorcelain } from "./parse-status-porcelain"
import { parseGitDiffNumstat } from "./parse-diff-numstat"
import type { GitFileStat } from "./types"
export function collectGitDiffStats(directory: string): GitFileStat[] {
try {
const diffOutput = execSync("git diff --numstat HEAD", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
if (!diffOutput) return []
const statusOutput = execSync("git status --porcelain", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
const statusMap = parseGitStatusPorcelain(statusOutput)
return parseGitDiffNumstat(diffOutput, statusMap)
} catch {
return []
}
}

View File

@@ -0,0 +1,46 @@
import type { GitFileStat } from "./types"
export function formatFileChanges(stats: GitFileStat[], notepadPath?: string): string {
if (stats.length === 0) return "[FILE CHANGES SUMMARY]\nNo file changes detected.\n"
const modified = stats.filter((s) => s.status === "modified")
const added = stats.filter((s) => s.status === "added")
const deleted = stats.filter((s) => s.status === "deleted")
const lines: string[] = ["[FILE CHANGES SUMMARY]"]
if (modified.length > 0) {
lines.push("Modified files:")
for (const f of modified) {
lines.push(` ${f.path} (+${f.added}, -${f.removed})`)
}
lines.push("")
}
if (added.length > 0) {
lines.push("Created files:")
for (const f of added) {
lines.push(` ${f.path} (+${f.added})`)
}
lines.push("")
}
if (deleted.length > 0) {
lines.push("Deleted files:")
for (const f of deleted) {
lines.push(` ${f.path} (-${f.removed})`)
}
lines.push("")
}
if (notepadPath) {
const notepadStat = stats.find((s) => s.path.includes("notepad") || s.path.includes(".sisyphus"))
if (notepadStat) {
lines.push("[NOTEPAD UPDATED]")
lines.push(` ${notepadStat.path} (+${notepadStat.added})`)
lines.push("")
}
}
return lines.join("\n")
}

View File

@@ -0,0 +1,51 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { formatFileChanges, parseGitDiffNumstat, parseGitStatusPorcelain } from "./index"
describe("git-worktree", () => {
test("#given status porcelain output #when parsing #then maps paths to statuses", () => {
const porcelain = [
" M src/a.ts",
"A src/b.ts",
"?? src/c.ts",
"D src/d.ts",
].join("\n")
const map = parseGitStatusPorcelain(porcelain)
expect(map.get("src/a.ts")).toBe("modified")
expect(map.get("src/b.ts")).toBe("added")
expect(map.get("src/c.ts")).toBe("added")
expect(map.get("src/d.ts")).toBe("deleted")
})
test("#given diff numstat and status map #when parsing #then returns typed stats", () => {
const porcelain = [" M src/a.ts", "A src/b.ts"].join("\n")
const statusMap = parseGitStatusPorcelain(porcelain)
const numstat = ["1\t2\tsrc/a.ts", "3\t0\tsrc/b.ts", "-\t-\tbin.dat"].join("\n")
const stats = parseGitDiffNumstat(numstat, statusMap)
expect(stats).toEqual([
{ path: "src/a.ts", added: 1, removed: 2, status: "modified" },
{ path: "src/b.ts", added: 3, removed: 0, status: "added" },
{ path: "bin.dat", added: 0, removed: 0, status: "modified" },
])
})
test("#given git file stats #when formatting #then produces grouped summary", () => {
const summary = formatFileChanges([
{ path: "src/a.ts", added: 1, removed: 2, status: "modified" },
{ path: "src/b.ts", added: 3, removed: 0, status: "added" },
{ path: "src/c.ts", added: 0, removed: 4, status: "deleted" },
])
expect(summary).toContain("[FILE CHANGES SUMMARY]")
expect(summary).toContain("Modified files:")
expect(summary).toContain("Created files:")
expect(summary).toContain("Deleted files:")
expect(summary).toContain("src/a.ts")
expect(summary).toContain("src/b.ts")
expect(summary).toContain("src/c.ts")
})
})

View File

@@ -0,0 +1,5 @@
export type { GitFileStatus, GitFileStat } from "./types"
export { parseGitStatusPorcelain } from "./parse-status-porcelain"
export { parseGitDiffNumstat } from "./parse-diff-numstat"
export { collectGitDiffStats } from "./collect-git-diff-stats"
export { formatFileChanges } from "./format-file-changes"

View File

@@ -0,0 +1,27 @@
import type { GitFileStat, GitFileStatus } from "./types"
export function parseGitDiffNumstat(
output: string,
statusMap: Map<string, GitFileStatus>
): GitFileStat[] {
if (!output) return []
const stats: GitFileStat[] = []
for (const line of output.split("\n")) {
const parts = line.split("\t")
if (parts.length < 3) continue
const [addedStr, removedStr, path] = parts
const added = addedStr === "-" ? 0 : parseInt(addedStr, 10)
const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10)
stats.push({
path,
added,
removed,
status: statusMap.get(path) ?? "modified",
})
}
return stats
}

View File

@@ -0,0 +1,25 @@
import type { GitFileStatus } from "./types"
export function parseGitStatusPorcelain(output: string): Map<string, GitFileStatus> {
const map = new Map<string, GitFileStatus>()
if (!output) return map
for (const line of output.split("\n")) {
if (!line) continue
const status = line.substring(0, 2).trim()
const filePath = line.substring(3)
if (!filePath) continue
if (status === "A" || status === "??") {
map.set(filePath, "added")
} else if (status === "D") {
map.set(filePath, "deleted")
} else {
map.set(filePath, "modified")
}
}
return map
}

View File

@@ -0,0 +1,8 @@
export type GitFileStatus = "modified" | "added" | "deleted"
export interface GitFileStat {
path: string
added: number
removed: number
status: GitFileStatus
}

View File

@@ -41,5 +41,6 @@ export * from "./tmux"
export * from "./model-suggestion-retry"
export * from "./opencode-server-auth"
export * from "./port-utils"
export * from "./git-worktree"
export * from "./safe-create-hook"
export * from "./truncate-description"

View File

@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { describe, it, expect, spyOn, mock } from "bun:test"
import { describe, it, expect, spyOn, mock, beforeEach, afterEach } from "bun:test"
mock.module("vscode-jsonrpc/node", () => ({
createMessageConnection: () => {
@@ -12,10 +12,18 @@ mock.module("vscode-jsonrpc/node", () => ({
StreamMessageWriter: function StreamMessageWriter() {},
}))
import { LSPClient, validateCwd } from "./client"
import { LSPClient, lspManager, validateCwd } from "./client"
import type { ResolvedServer } from "./types"
describe("LSPClient", () => {
beforeEach(async () => {
await lspManager.stopAll()
})
afterEach(async () => {
await lspManager.stopAll()
})
describe("openFile", () => {
it("sends didChange when a previously opened file changes on disk", async () => {
// #given
@@ -61,6 +69,108 @@ describe("LSPClient", () => {
})
})
describe("LSPServerManager", () => {
it("recreates client after init failure instead of staying permanently blocked", async () => {
//#given
const dir = mkdtempSync(join(tmpdir(), "lsp-manager-test-"))
const server: ResolvedServer = {
id: "typescript",
command: ["typescript-language-server", "--stdio"],
extensions: [".ts"],
priority: 0,
}
const startSpy = spyOn(LSPClient.prototype, "start")
const initializeSpy = spyOn(LSPClient.prototype, "initialize")
const isAliveSpy = spyOn(LSPClient.prototype, "isAlive")
const stopSpy = spyOn(LSPClient.prototype, "stop")
startSpy.mockImplementationOnce(async () => {
throw new Error("boom")
})
startSpy.mockImplementation(async () => {})
initializeSpy.mockImplementation(async () => {})
isAliveSpy.mockImplementation(() => true)
stopSpy.mockImplementation(async () => {})
try {
//#when
await expect(lspManager.getClient(dir, server)).rejects.toThrow("boom")
const client = await lspManager.getClient(dir, server)
//#then
expect(client).toBeInstanceOf(LSPClient)
expect(startSpy).toHaveBeenCalledTimes(2)
expect(stopSpy).toHaveBeenCalled()
} finally {
startSpy.mockRestore()
initializeSpy.mockRestore()
isAliveSpy.mockRestore()
stopSpy.mockRestore()
rmSync(dir, { recursive: true, force: true })
}
})
it("resets stale initializing entry so a hung init does not permanently block future clients", async () => {
//#given
const dir = mkdtempSync(join(tmpdir(), "lsp-manager-stale-test-"))
const server: ResolvedServer = {
id: "typescript",
command: ["typescript-language-server", "--stdio"],
extensions: [".ts"],
priority: 0,
}
const dateNowSpy = spyOn(Date, "now")
const startSpy = spyOn(LSPClient.prototype, "start")
const initializeSpy = spyOn(LSPClient.prototype, "initialize")
const isAliveSpy = spyOn(LSPClient.prototype, "isAlive")
const stopSpy = spyOn(LSPClient.prototype, "stop")
// First client init hangs forever.
const never = new Promise<void>(() => {})
startSpy.mockImplementationOnce(async () => {
await never
})
// Second attempt should be allowed after stale reset.
startSpy.mockImplementationOnce(async () => {})
startSpy.mockImplementation(async () => {})
initializeSpy.mockImplementation(async () => {})
isAliveSpy.mockImplementation(() => true)
stopSpy.mockImplementation(async () => {})
try {
//#when
dateNowSpy.mockReturnValueOnce(0)
lspManager.warmupClient(dir, server)
dateNowSpy.mockReturnValueOnce(60_000)
const client = await Promise.race([
lspManager.getClient(dir, server),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("test-timeout")), 50)),
])
//#then
expect(client).toBeInstanceOf(LSPClient)
expect(startSpy).toHaveBeenCalledTimes(2)
expect(stopSpy).toHaveBeenCalled()
} finally {
dateNowSpy.mockRestore()
startSpy.mockRestore()
initializeSpy.mockRestore()
isAliveSpy.mockRestore()
stopSpy.mockRestore()
rmSync(dir, { recursive: true, force: true })
}
})
})
describe("validateCwd", () => {
it("returns valid for existing directory", () => {
// #given

View File

@@ -221,6 +221,7 @@ interface ManagedClient {
refCount: number
initPromise?: Promise<void>
isInitializing: boolean
initializingSince?: number
}
class LSPServerManager {
@@ -228,6 +229,7 @@ class LSPServerManager {
private clients = new Map<string, ManagedClient>()
private cleanupInterval: ReturnType<typeof setInterval> | null = null
private readonly IDLE_TIMEOUT = 5 * 60 * 1000
private readonly INIT_TIMEOUT = 60 * 1000
private constructor() {
this.startCleanupTimer()
@@ -309,17 +311,43 @@ class LSPServerManager {
const key = this.getKey(root, server.id)
let managed = this.clients.get(key)
if (managed) {
const now = Date.now()
if (managed.isInitializing && managed.initializingSince !== undefined && now - managed.initializingSince >= this.INIT_TIMEOUT) {
// Stale init can permanently block subsequent calls (e.g., LSP process hang)
try {
await managed.client.stop()
} catch {}
this.clients.delete(key)
managed = undefined
}
}
if (managed) {
if (managed.initPromise) {
await managed.initPromise
try {
await managed.initPromise
} catch {
// Failed init should not keep the key blocked forever.
try {
await managed.client.stop()
} catch {}
this.clients.delete(key)
managed = undefined
}
}
if (managed.client.isAlive()) {
managed.refCount++
managed.lastUsedAt = Date.now()
return managed.client
if (managed) {
if (managed.client.isAlive()) {
managed.refCount++
managed.lastUsedAt = Date.now()
return managed.client
}
try {
await managed.client.stop()
} catch {}
this.clients.delete(key)
}
await managed.client.stop()
this.clients.delete(key)
}
const client = new LSPClient(root, server)
@@ -328,19 +356,30 @@ class LSPServerManager {
await client.initialize()
})()
const initStartedAt = Date.now()
this.clients.set(key, {
client,
lastUsedAt: Date.now(),
lastUsedAt: initStartedAt,
refCount: 1,
initPromise,
isInitializing: true,
initializingSince: initStartedAt,
})
await initPromise
try {
await initPromise
} catch (error) {
this.clients.delete(key)
try {
await client.stop()
} catch {}
throw error
}
const m = this.clients.get(key)
if (m) {
m.initPromise = undefined
m.isInitializing = false
m.initializingSince = undefined
}
return client
@@ -356,21 +395,30 @@ class LSPServerManager {
await client.initialize()
})()
const initStartedAt = Date.now()
this.clients.set(key, {
client,
lastUsedAt: Date.now(),
lastUsedAt: initStartedAt,
refCount: 0,
initPromise,
isInitializing: true,
initializingSince: initStartedAt,
})
initPromise.then(() => {
const m = this.clients.get(key)
if (m) {
m.initPromise = undefined
m.isInitializing = false
}
})
initPromise
.then(() => {
const m = this.clients.get(key)
if (m) {
m.initPromise = undefined
m.isInitializing = false
m.initializingSince = undefined
}
})
.catch(() => {
// Warmup failures must not permanently block future initialization.
this.clients.delete(key)
void client.stop().catch(() => {})
})
}
releaseClient(root: string, serverId: string): void {