merge: integrate origin/dev into modular-enforcement branch
Resolves all merge conflicts, preserving our split module structure while integrating all dev changes: - Custom agent summaries support (parseRegisteredAgentSummaries) - Background notification queue (enqueueNotificationForParent) - Atlas shared git-worktree module (collectGitDiffStats, formatFileChanges) - Ralph-loop withTimeout + DEFAULT_API_TIMEOUT=5000 - Session recovery assistant_prefill_unsupported error type - Atlas agentOverrides forwarding - Config handler plan model demotion (buildPlanDemoteConfig) - Delegate-task agentOverrides, promptSyncWithModelSuggestionRetry, variant - LSP init timeout + stale init detection - isPlanFamily function + task-continuation-enforcer hook - Handoff command
This commit is contained in:
+83
-69
@@ -1,4 +1,6 @@
|
||||
import type { ResolvedServer } from "./types"
|
||||
import { registerLspManagerProcessCleanup } from "./lsp-manager-process-cleanup"
|
||||
import { cleanupTempDirectoryLspClients } from "./lsp-manager-temp-directory-cleanup"
|
||||
import { LSPClient } from "./lsp-client"
|
||||
interface ManagedClient {
|
||||
client: LSPClient
|
||||
@@ -6,52 +8,31 @@ interface ManagedClient {
|
||||
refCount: number
|
||||
initPromise?: Promise<void>
|
||||
isInitializing: boolean
|
||||
initializingSince?: number
|
||||
}
|
||||
class LSPServerManager {
|
||||
private static instance: 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()
|
||||
this.registerProcessCleanup()
|
||||
}
|
||||
private registerProcessCleanup(): void {
|
||||
// Synchronous cleanup for 'exit' event (cannot await)
|
||||
const syncCleanup = () => {
|
||||
for (const [, managed] of this.clients) {
|
||||
try {
|
||||
// Fire-and-forget during sync exit - process is terminating
|
||||
void managed.client.stop().catch(() => {})
|
||||
} catch {}
|
||||
}
|
||||
this.clients.clear()
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
this.cleanupInterval = null
|
||||
}
|
||||
}
|
||||
// Async cleanup for signal handlers - properly await all stops
|
||||
const asyncCleanup = async () => {
|
||||
const stopPromises: Promise<void>[] = []
|
||||
for (const [, managed] of this.clients) {
|
||||
stopPromises.push(managed.client.stop().catch(() => {}))
|
||||
}
|
||||
await Promise.allSettled(stopPromises)
|
||||
this.clients.clear()
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
this.cleanupInterval = null
|
||||
}
|
||||
}
|
||||
process.on("exit", syncCleanup)
|
||||
|
||||
// Don't call process.exit() here; other handlers (background-agent manager) handle final exit.
|
||||
process.on("SIGINT", () => void asyncCleanup().catch(() => {}))
|
||||
process.on("SIGTERM", () => void asyncCleanup().catch(() => {}))
|
||||
if (process.platform === "win32") {
|
||||
process.on("SIGBREAK", () => void asyncCleanup().catch(() => {}))
|
||||
}
|
||||
registerLspManagerProcessCleanup({
|
||||
getClients: () => this.clients.entries(),
|
||||
clearClients: () => {
|
||||
this.clients.clear()
|
||||
},
|
||||
clearCleanupInterval: () => {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
this.cleanupInterval = null
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
static getInstance(): LSPServerManager {
|
||||
@@ -85,17 +66,46 @@ class LSPServerManager {
|
||||
async getClient(root: string, server: ResolvedServer): Promise<LSPClient> {
|
||||
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)
|
||||
@@ -103,19 +113,30 @@ class LSPServerManager {
|
||||
await client.start()
|
||||
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
|
||||
@@ -130,21 +151,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 {
|
||||
@@ -174,23 +204,7 @@ class LSPServerManager {
|
||||
}
|
||||
|
||||
async cleanupTempDirectoryClients(): Promise<void> {
|
||||
const keysToRemove: string[] = []
|
||||
for (const [key, managed] of this.clients.entries()) {
|
||||
const isTempDir = key.startsWith("/tmp/") || key.startsWith("/var/folders/")
|
||||
const isIdle = managed.refCount === 0
|
||||
if (isTempDir && isIdle) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
for (const key of keysToRemove) {
|
||||
const managed = this.clients.get(key)
|
||||
if (managed) {
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await managed.client.stop()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
await cleanupTempDirectoryLspClients(this.clients)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user