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:
YeonGyu-Kim
2026-02-08 17:34:47 +09:00
74 changed files with 4016 additions and 654 deletions
+22 -5
View File
@@ -535,18 +535,35 @@ export function buildPlanAgentSystemPrepend(
}
/**
* List of agent names that should be treated as plan agents.
* List of agent names that should be treated as plan agents (receive plan system prompt).
* Case-insensitive matching is used.
*/
export const PLAN_AGENT_NAMES = ["plan", "prometheus", "planner"]
export const PLAN_AGENT_NAMES = ["plan"]
/**
* Check if the given agent name is a plan agent.
* @param agentName - The agent name to check
* @returns true if the agent is a plan agent
* Check if the given agent name is a plan agent (receives plan system prompt).
*/
export function isPlanAgent(agentName: string | undefined): boolean {
if (!agentName) return false
const lowerName = agentName.toLowerCase().trim()
return PLAN_AGENT_NAMES.some(name => lowerName === name || lowerName.includes(name))
}
/**
* Plan family: plan + prometheus. Shares mutual delegation blocking and task tool permission.
* Does NOT share system prompt (only isPlanAgent controls that).
*/
export const PLAN_FAMILY_NAMES = ["plan", "prometheus"]
/**
* Check if the given agent belongs to the plan family (blocking + task permission).
*/
export function isPlanFamily(category: string): boolean
export function isPlanFamily(category: string | undefined): boolean
export function isPlanFamily(category: string | undefined): boolean {
if (!category) return false
const lowerCategory = category.toLowerCase().trim()
return PLAN_FAMILY_NAMES.some(
(name) => lowerCategory === name || lowerCategory.includes(name)
)
}
+4 -2
View File
@@ -1,5 +1,5 @@
import type { BackgroundManager } from "../../features/background-agent"
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider } from "../../config/schema"
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider, AgentOverrides } from "../../config/schema"
import type { OpencodeClient } from "./types"
export interface ExecutorContext {
@@ -10,6 +10,7 @@ export interface ExecutorContext {
gitMasterConfig?: GitMasterConfig
sisyphusJuniorModel?: string
browserProvider?: BrowserAutomationProvider
agentOverrides?: AgentOverrides
onSyncSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise<void>
}
@@ -25,9 +26,10 @@ export interface SessionMessage {
role?: string
time?: { created?: number }
agent?: string
model?: { providerID: string; modelID: string }
model?: { providerID: string; modelID: string; variant?: string }
modelID?: string
providerID?: string
variant?: string
}
parts?: Array<{ type?: string; text?: string }>
}
+46 -7
View File
@@ -1,15 +1,20 @@
import type { DelegateTaskArgs } from "./types"
import type { ExecutorContext } from "./executor-types"
import { isPlanAgent } from "./constants"
import { isPlanFamily } from "./constants"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { parseModelString } from "./model-string-parser"
import { resolveModelPipeline } from "../../shared"
import { fetchAvailableModels } from "../../shared/model-availability"
import { readConnectedProvidersCache } from "../../shared/connected-providers-cache"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
export async function resolveSubagentExecution(
args: DelegateTaskArgs,
executorCtx: ExecutorContext,
parentAgent: string | undefined,
categoryExamples: string
): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string } | undefined; error?: string }> {
const { client } = executorCtx
): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string; variant?: string } | undefined; error?: string }> {
const { client, agentOverrides } = executorCtx
if (!args.subagent_type?.trim()) {
return { agentToUse: "", categoryModel: undefined, error: `Agent name cannot be empty.` }
@@ -27,18 +32,18 @@ Sisyphus-Junior is spawned automatically when you specify a category. Pick the a
}
}
if (isPlanAgent(agentName) && isPlanAgent(parentAgent)) {
if (isPlanFamily(agentName) && isPlanFamily(parentAgent)) {
return {
agentToUse: "",
categoryModel: undefined,
error: `You are prometheus. You cannot delegate to prometheus via task.
error: `You are a plan-family agent (plan/prometheus). You cannot delegate to other plan-family agents via task.
Create the work plan directly - that's your job as the planning agent.`,
}
}
let agentToUse = agentName
let categoryModel: { providerID: string; modelID: string } | undefined
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
try {
const agentsResult = await client.app.agents()
@@ -76,7 +81,41 @@ Create the work plan directly - that's your job as the planning agent.`,
agentToUse = matchedAgent.name
if (matchedAgent.model) {
const agentNameLower = agentToUse.toLowerCase()
const agentOverride = agentOverrides?.[agentNameLower as keyof typeof agentOverrides]
?? (agentOverrides ? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentNameLower)?.[1] : undefined)
const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentNameLower]
if (agentOverride?.model || agentRequirement) {
const connectedProviders = readConnectedProvidersCache()
const availableModels = await fetchAvailableModels(client, {
connectedProviders: connectedProviders ?? undefined,
})
const matchedAgentModelStr = matchedAgent.model
? `${matchedAgent.model.providerID}/${matchedAgent.model.modelID}`
: undefined
const resolution = resolveModelPipeline({
intent: {
userModel: agentOverride?.model,
categoryDefaultModel: matchedAgentModelStr,
},
constraints: { availableModels },
policy: {
fallbackChain: agentRequirement?.fallbackChain,
systemDefaultModel: undefined,
},
})
if (resolution) {
const parsed = parseModelString(resolution.model)
if (parsed) {
const variantToUse = agentOverride?.variant ?? resolution.variant
categoryModel = variantToUse ? { ...parsed, variant: variantToUse } : parsed
}
}
} else if (matchedAgent.model) {
categoryModel = matchedAgent.model
}
} catch {
+22 -40
View File
@@ -1,9 +1,9 @@
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
import type { ExecutorContext, SessionMessage } from "./executor-types"
import { getTimingConfig } from "./timing"
import { isPlanFamily } from "./constants"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { getTaskToastManager } from "../../features/task-toast-manager"
import { getAgentToolRestrictions, getMessageDir } from "../../shared"
import { getAgentToolRestrictions, getMessageDir, promptSyncWithModelSuggestionRetry } from "../../shared"
import { findNearestMessageWithFields } from "../../features/hook-message-injector"
import { formatDuration } from "./time-formatter"
@@ -46,6 +46,7 @@ export async function executeSyncContinuation(
try {
let resumeAgent: string | undefined
let resumeModel: { providerID: string; modelID: string } | undefined
let resumeVariant: string | undefined
try {
const messagesResp = await client.session.messages({ path: { id: args.session_id! } })
@@ -55,6 +56,7 @@ export async function executeSyncContinuation(
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
resumeAgent = info.agent
resumeModel = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
resumeVariant = info.variant
break
}
}
@@ -65,22 +67,26 @@ export async function executeSyncContinuation(
resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID
? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID }
: undefined
resumeVariant = resumeMessage?.model?.variant
}
await (client.session as any).promptAsync({
path: { id: args.session_id! },
body: {
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
...(resumeModel !== undefined ? { model: resumeModel } : {}),
tools: {
...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}),
task: false,
call_omo_agent: true,
question: false,
},
parts: [{ type: "text", text: args.prompt }],
},
})
const allowTask = isPlanFamily(resumeAgent)
await promptSyncWithModelSuggestionRetry(client, {
path: { id: args.session_id! },
body: {
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
...(resumeModel !== undefined ? { model: resumeModel } : {}),
...(resumeVariant !== undefined ? { variant: resumeVariant } : {}),
tools: {
...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}),
task: allowTask,
call_omo_agent: true,
question: false,
},
parts: [{ type: "text", text: args.prompt }],
},
})
} catch (promptError) {
if (toastManager) {
toastManager.removeTask(taskId)
@@ -89,30 +95,6 @@ export async function executeSyncContinuation(
return `Failed to send continuation prompt: ${errorMessage}\n\nSession ID: ${args.session_id}`
}
const timing = getTimingConfig()
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
while (Date.now() - pollStart < 60000) {
await new Promise(resolve => setTimeout(resolve, timing.POLL_INTERVAL_MS))
const elapsed = Date.now() - pollStart
if (elapsed < timing.SESSION_CONTINUATION_STABILITY_MS) continue
const messagesCheck = await client.session.messages({ path: { id: args.session_id! } })
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
const currentMsgCount = msgs.length
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
stablePolls++
if (stablePolls >= timing.STABILITY_POLLS_REQUIRED) break
} else {
stablePolls = 0
lastMsgCount = currentMsgCount
}
}
const messagesResult = await client.session.messages({
path: { id: args.session_id! },
})
@@ -1,6 +1,6 @@
import type { DelegateTaskArgs, OpencodeClient } from "./types"
import { isPlanAgent } from "./constants"
import { promptWithModelSuggestionRetry } from "../../shared"
import { isPlanFamily } from "./constants"
import { promptSyncWithModelSuggestionRetry } from "../../shared"
import { formatDetailedError } from "./error-formatting"
export async function sendSyncPrompt(
@@ -16,8 +16,8 @@ export async function sendSyncPrompt(
}
): Promise<string | null> {
try {
const allowTask = isPlanAgent(input.agentToUse)
await promptWithModelSuggestionRetry(client, {
const allowTask = isPlanFamily(input.agentToUse)
await promptSyncWithModelSuggestionRetry(client, {
path: { id: input.sessionID },
body: {
agent: input.agentToUse,
+600 -237
View File
@@ -1,6 +1,6 @@
declare const require: (name: string) => any
const { describe, test, expect, beforeEach, afterEach, spyOn } = require("bun:test")
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, isPlanAgent, PLAN_AGENT_NAMES } from "./constants"
const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require("bun:test")
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, isPlanAgent, PLAN_AGENT_NAMES, isPlanFamily, PLAN_FAMILY_NAMES } from "./constants"
import { resolveCategoryConfig } from "./tools"
import type { CategoryConfig } from "../../config/schema"
import { __resetModelCache } from "../../shared/model-availability"
@@ -135,19 +135,19 @@ describe("sisyphus-task", () => {
expect(result).toBe(true)
})
test("returns true for 'prometheus'", () => {
// given / #when
test("returns false for 'prometheus' (decoupled from plan)", () => {
//#given / #when
const result = isPlanAgent("prometheus")
// then
expect(result).toBe(true)
//#then - prometheus is NOT a plan agent
expect(result).toBe(false)
})
test("returns true for 'planner'", () => {
// given / #when
test("returns true for 'planner' (matches via includes('plan'))", () => {
//#given / #when
const result = isPlanAgent("planner")
// then
//#then - "planner" contains "plan" so it matches via includes
expect(result).toBe(true)
})
@@ -159,12 +159,12 @@ describe("sisyphus-task", () => {
expect(result).toBe(true)
})
test("returns true for case-insensitive match 'Prometheus'", () => {
// given / #when
test("returns false for case-insensitive match 'Prometheus' (decoupled from plan)", () => {
//#given / #when
const result = isPlanAgent("Prometheus")
// then
expect(result).toBe(true)
//#then - Prometheus is NOT a plan agent
expect(result).toBe(false)
})
test("returns false for 'oracle'", () => {
@@ -199,11 +199,44 @@ describe("sisyphus-task", () => {
expect(result).toBe(false)
})
test("PLAN_AGENT_NAMES contains expected values", () => {
// given / #when / #then
expect(PLAN_AGENT_NAMES).toContain("plan")
expect(PLAN_AGENT_NAMES).toContain("prometheus")
expect(PLAN_AGENT_NAMES).toContain("planner")
test("PLAN_AGENT_NAMES contains only plan", () => {
//#given / #when / #then
expect(PLAN_AGENT_NAMES).toEqual(["plan"])
})
})
describe("isPlanFamily", () => {
test("returns true for 'plan'", () => {
//#given / #when
const result = isPlanFamily("plan")
//#then
expect(result).toBe(true)
})
test("returns true for 'prometheus'", () => {
//#given / #when
const result = isPlanFamily("prometheus")
//#then
expect(result).toBe(true)
})
test("returns false for 'oracle'", () => {
//#given / #when
const result = isPlanFamily("oracle")
//#then
expect(result).toBe(false)
})
test("returns false for undefined", () => {
//#given / #when
const result = isPlanFamily(undefined)
//#then
expect(result).toBe(false)
})
test("PLAN_FAMILY_NAMES contains plan and prometheus", () => {
//#given / #when / #then
expect(PLAN_FAMILY_NAMES).toEqual(["plan", "prometheus"])
})
})
@@ -852,42 +885,42 @@ describe("sisyphus-task", () => {
test("skills parameter is required - throws error when not provided", async () => {
// given
const { createDelegateTask } = require("./tools")
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
// when - skills not provided (undefined)
// then - should throw error about missing skills
await expect(tool.execute(
{
description: "Test task",
prompt: "Do something",
category: "ultrabrain",
run_in_background: false,
},
toolContext
)).rejects.toThrow("IT IS HIGHLY RECOMMENDED")
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
// when - skills not provided (undefined)
// then - should throw error about missing skills
await expect(tool.execute(
{
description: "Test task",
prompt: "Do something",
category: "ultrabrain",
run_in_background: false,
},
toolContext
)).rejects.toThrow("IT IS HIGHLY RECOMMENDED")
})
test("null skills throws error", async () => {
@@ -1055,6 +1088,75 @@ describe("sisyphus-task", () => {
expect(result).not.toContain("Background task continued")
}, { timeout: 10000 })
test("sync continuation preserves variant from previous session message", async () => {
//#given a session with a previous message that has variant "max"
const { createDelegateTask } = require("./tools")
const promptMock = mock(async (input: any) => {
return { data: {} }
})
const mockClient = {
session: {
prompt: promptMock,
promptAsync: async () => ({ data: {} }),
messages: async () => ({
data: [
{
info: {
role: "user",
agent: "sisyphus-junior",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
variant: "max",
time: { created: Date.now() },
},
parts: [{ type: "text", text: "previous message" }],
},
{
info: { role: "assistant", time: { created: Date.now() + 1 } },
parts: [{ type: "text", text: "Completed." }],
},
],
}),
},
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
app: {
agents: async () => ({ data: [] }),
},
}
const tool = createDelegateTask({
manager: { resume: async () => ({ id: "task-var", sessionID: "ses_var_test", description: "Variant test", agent: "sisyphus-junior", status: "running" }) },
client: mockClient,
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
//#when continuing the session
await tool.execute(
{
description: "Continue with variant",
prompt: "Continue the task",
session_id: "ses_var_test",
run_in_background: false,
load_skills: [],
},
toolContext
)
//#then prompt should include variant from previous message
expect(promptMock).toHaveBeenCalled()
const callArgs = promptMock.mock.calls[0][0]
expect(callArgs.body.variant).toBe("max")
expect(callArgs.body.agent).toBe("sisyphus-junior")
expect(callArgs.body.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
}, { timeout: 10000 })
test("session_id with background=true should return immediately without waiting", async () => {
// given
const { createDelegateTask } = require("./tools")
@@ -2058,6 +2160,127 @@ describe("sisyphus-task", () => {
expect(launchInput.model.providerID).toBe("openai")
expect(launchInput.model.modelID).toBe("gpt-5.3-codex")
})
test("sisyphus-junior model override works with quick category (#1295)", async () => {
// given - user configures agents.sisyphus-junior.model but uses quick category
const { createDelegateTask } = require("./tools")
let launchInput: any
const mockManager = {
launch: async (input: any) => {
launchInput = input
return {
id: "task-1295-quick",
sessionID: "ses_1295_quick",
description: "Issue 1295 regression",
agent: "sisyphus-junior",
status: "running",
}
},
}
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [] },
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
sisyphusJuniorModel: "anthropic/claude-sonnet-4-5",
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
// when - using quick category (default: anthropic/claude-haiku-4-5)
await tool.execute(
{
description: "Issue 1295 quick category test",
prompt: "Quick task",
category: "quick",
run_in_background: true,
load_skills: [],
},
toolContext
)
// then - sisyphus-junior override model should be used, not category default
expect(launchInput.model.providerID).toBe("anthropic")
expect(launchInput.model.modelID).toBe("claude-sonnet-4-5")
})
test("sisyphus-junior model override works with user-defined category (#1295)", async () => {
// given - user has a custom category with no model requirement
const { createDelegateTask } = require("./tools")
let launchInput: any
const mockManager = {
launch: async (input: any) => {
launchInput = input
return {
id: "task-1295-custom",
sessionID: "ses_1295_custom",
description: "Issue 1295 custom category",
agent: "sisyphus-junior",
status: "running",
}
},
}
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [] },
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
sisyphusJuniorModel: "openai/gpt-5.2",
userCategories: {
"my-custom": { temperature: 0.5 },
},
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
// when - using custom category with no explicit model
await tool.execute(
{
description: "Custom category with agent model",
prompt: "Do something custom",
category: "my-custom",
run_in_background: true,
load_skills: [],
},
toolContext
)
// then - sisyphus-junior override model should be used as fallback
expect(launchInput.model.providerID).toBe("openai")
expect(launchInput.model.modelID).toBe("gpt-5.2")
})
})
describe("browserProvider propagation", () => {
@@ -2258,68 +2481,36 @@ describe("sisyphus-task", () => {
expect(result).toBe(buildPlanAgentSystemPrepend(availableCategories, availableSkills))
})
test("prepends plan agent system prompt when agentName is 'prometheus'", () => {
// given
test("does not prepend plan agent prompt for prometheus agent", () => {
//#given - prometheus is NOT a plan agent (decoupled)
const { buildSystemContent } = require("./tools")
const { buildPlanAgentSystemPrepend } = require("./constants")
const skillContent = "You are a strategic planner"
const availableCategories = [
{
name: "ultrabrain",
description: "Complex architecture, deep logical reasoning",
model: "openai/gpt-5.3-codex",
},
]
const availableSkills = [
{
name: "git-master",
description: "Atomic commits, git operations.",
location: "plugin",
},
]
// when
//#when
const result = buildSystemContent({
skillContent,
agentName: "prometheus",
availableCategories,
availableSkills,
})
// then
expect(result).toContain("<system>")
expect(result).toBe(buildPlanAgentSystemPrepend(availableCategories, availableSkills))
//#then - prometheus should NOT get plan agent system prepend
expect(result).toBe(skillContent)
expect(result).not.toContain("MANDATORY CONTEXT GATHERING PROTOCOL")
})
test("prepends plan agent system prompt when agentName is 'Prometheus' (case insensitive)", () => {
// given
test("does not prepend plan agent prompt for Prometheus (case insensitive)", () => {
//#given - Prometheus (capitalized) is NOT a plan agent
const { buildSystemContent } = require("./tools")
const { buildPlanAgentSystemPrepend } = require("./constants")
const skillContent = "You are a strategic planner"
const availableCategories = [
{
name: "quick",
description: "Trivial tasks",
model: "anthropic/claude-haiku-4-5",
},
]
const availableSkills = [
{
name: "dev-browser",
description: "Persistent browser state automation.",
location: "plugin",
},
]
// when
//#when
const result = buildSystemContent({
skillContent,
agentName: "Prometheus",
availableCategories,
availableSkills,
})
// then
expect(result).toContain("<system>")
expect(result).toBe(buildPlanAgentSystemPrepend(availableCategories, availableSkills))
//#then
expect(result).toBe(skillContent)
expect(result).not.toContain("MANDATORY CONTEXT GATHERING PROTOCOL")
})
test("combines plan agent prepend with skill content", () => {
@@ -2565,149 +2756,95 @@ describe("sisyphus-task", () => {
})
})
describe("prometheus self-delegation block", () => {
test("prometheus cannot delegate to prometheus - returns error with guidance", async () => {
// given - current agent is prometheus
describe("plan family mutual delegation block", () => {
test("plan cannot delegate to plan (self-delegation)", async () => {
//#given
const { createDelegateTask } = require("./tools")
const mockClient = {
app: { agents: async () => ({ data: [{ name: "plan", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: { get: async () => ({ data: { directory: "/project" } }), create: async () => ({ data: { id: "s" } }), prompt: async () => ({ data: {} }), promptAsync: async () => ({ data: {} }), messages: async () => ({ data: [] }), status: async () => ({ data: {} }) },
}
const tool = createDelegateTask({ manager: { launch: async () => ({}) }, client: mockClient })
const mockManager = { launch: async () => ({}) }
//#when
const result = await tool.execute(
{ description: "test", prompt: "Create a plan", subagent_type: "plan", run_in_background: false, load_skills: [] },
{ sessionID: "p", messageID: "m", agent: "plan", abort: new AbortController().signal }
)
//#then
expect(result).toContain("plan-family")
expect(result).toContain("directly")
})
test("prometheus cannot delegate to plan (cross-blocking)", async () => {
//#given
const { createDelegateTask } = require("./tools")
const mockClient = {
app: { agents: async () => ({ data: [{ name: "plan", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: { get: async () => ({ data: { directory: "/project" } }), create: async () => ({ data: { id: "s" } }), prompt: async () => ({ data: {} }), promptAsync: async () => ({ data: {} }), messages: async () => ({ data: [] }), status: async () => ({ data: {} }) },
}
const tool = createDelegateTask({ manager: { launch: async () => ({}) }, client: mockClient })
//#when
const result = await tool.execute(
{ description: "test", prompt: "Create a plan", subagent_type: "plan", run_in_background: false, load_skills: [] },
{ sessionID: "p", messageID: "m", agent: "prometheus", abort: new AbortController().signal }
)
//#then
expect(result).toContain("plan-family")
})
test("plan cannot delegate to prometheus (cross-blocking)", async () => {
//#given
const { createDelegateTask } = require("./tools")
const mockClient = {
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
status: async () => ({ data: {} }),
},
session: { get: async () => ({ data: { directory: "/project" } }), create: async () => ({ data: { id: "s" } }), prompt: async () => ({ data: {} }), promptAsync: async () => ({ data: {} }), messages: async () => ({ data: [] }), status: async () => ({ data: {} }) },
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "prometheus",
abort: new AbortController().signal,
}
const tool = createDelegateTask({ manager: { launch: async () => ({}) }, client: mockClient })
// when - prometheus tries to delegate to prometheus
//#when
const result = await tool.execute(
{
description: "Test self-delegation block",
prompt: "Create a plan",
subagent_type: "prometheus",
run_in_background: false,
load_skills: [],
},
toolContext
{ description: "test", prompt: "Execute", subagent_type: "prometheus", run_in_background: false, load_skills: [] },
{ sessionID: "p", messageID: "m", agent: "plan", abort: new AbortController().signal }
)
// then - should return error telling prometheus to create plan directly
expect(result).toContain("prometheus")
expect(result).toContain("directly")
//#then
expect(result).toContain("plan-family")
})
test("non-prometheus agent CAN delegate to prometheus - proceeds normally", async () => {
// given - current agent is sisyphus
test("sisyphus CAN delegate to plan (not in plan family)", async () => {
//#given
const { createDelegateTask } = require("./tools")
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
const mockClient = {
app: { agents: async () => ({ data: [{ name: "plan", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_prometheus_allowed" } }),
create: async () => ({ data: { id: "ses_ok" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created successfully" }] }]
}),
status: async () => ({ data: { "ses_prometheus_allowed": { type: "idle" } } }),
messages: async () => ({ data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created" }] }] }),
status: async () => ({ data: { "ses_ok": { type: "idle" } } }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
})
const tool = createDelegateTask({ manager: { launch: async () => ({}) }, client: mockClient })
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
// when - sisyphus delegates to prometheus
//#when
const result = await tool.execute(
{
description: "Test prometheus delegation from non-prometheus agent",
prompt: "Create a plan",
subagent_type: "prometheus",
run_in_background: false,
load_skills: [],
},
toolContext
{ description: "test", prompt: "Create a plan", subagent_type: "plan", run_in_background: false, load_skills: [] },
{ sessionID: "p", messageID: "m", agent: "sisyphus", abort: new AbortController().signal }
)
// then - should proceed normally
expect(result).not.toContain("Cannot delegate")
expect(result).toContain("Plan created successfully")
//#then
expect(result).not.toContain("plan-family")
expect(result).toContain("Plan created")
}, { timeout: 20000 })
test("case-insensitive: Prometheus (capitalized) cannot delegate to prometheus", async () => {
// given - current agent is Prometheus (capitalized)
const { createDelegateTask } = require("./tools")
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
status: async () => ({ data: {} }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "Prometheus",
abort: new AbortController().signal,
}
// when - Prometheus tries to delegate to prometheus
const result = await tool.execute(
{
description: "Test case-insensitive block",
prompt: "Create a plan",
subagent_type: "prometheus",
run_in_background: false,
load_skills: [],
},
toolContext
)
// then - should still return error
expect(result).toContain("prometheus")
expect(result).toContain("directly")
})
})
describe("subagent_type model extraction (issue #1225)", () => {
@@ -2841,8 +2978,8 @@ describe("sisyphus-task", () => {
})
}, { timeout: 20000 })
test("agent without model does not override categoryModel", async () => {
// given - agent registered without model field
test("agent without model resolves via fallback chain", async () => {
// given - agent registered without model field, fallback chain should resolve
const { createDelegateTask } = require("./tools")
let promptBody: any
@@ -2857,7 +2994,7 @@ describe("sisyphus-task", () => {
app: {
agents: async () => ({
data: [
{ name: "explore", mode: "subagent" }, // no model field
{ name: "explore", mode: "subagent" },
],
}),
},
@@ -2898,14 +3035,211 @@ describe("sisyphus-task", () => {
toolContext
)
// then - no model should be passed to session.prompt
expect(promptBody.model).toBeUndefined()
// then - model should be resolved via AGENT_MODEL_REQUIREMENTS fallback chain
expect(promptBody.model).toBeDefined()
}, { timeout: 20000 })
test("agentOverrides model takes priority over matchedAgent.model (#1357)", async () => {
// given - user configured oracle to use a specific model in oh-my-opencode.json
const { createDelegateTask } = require("./tools")
let promptBody: any
const mockManager = { launch: async () => ({}) }
const promptMock = async (input: any) => {
promptBody = input.body
return { data: {} }
}
const mockClient = {
app: {
agents: async () => ({
data: [
{ name: "oracle", mode: "subagent", model: { providerID: "openai", modelID: "gpt-5.2" } },
],
}),
},
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_override_model" } }),
prompt: promptMock,
promptAsync: promptMock,
messages: async () => ({
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }],
}),
status: async () => ({ data: { "ses_override_model": { type: "idle" } } }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
agentOverrides: {
oracle: { model: "anthropic/claude-opus-4-6" },
},
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
// when - delegating to oracle via subagent_type with user override
await tool.execute(
{
description: "Consult oracle with override",
prompt: "Review architecture",
subagent_type: "oracle",
run_in_background: false,
load_skills: [],
},
toolContext
)
// then - user-configured model should take priority over matchedAgent.model
expect(promptBody.model).toEqual({
providerID: "anthropic",
modelID: "claude-opus-4-6",
})
}, { timeout: 20000 })
test("agentOverrides variant is applied when model is overridden (#1357)", async () => {
// given - user configured oracle with model and variant
const { createDelegateTask } = require("./tools")
let promptBody: any
const mockManager = { launch: async () => ({}) }
const promptMock = async (input: any) => {
promptBody = input.body
return { data: {} }
}
const mockClient = {
app: {
agents: async () => ({
data: [
{ name: "oracle", mode: "subagent", model: { providerID: "openai", modelID: "gpt-5.2" } },
],
}),
},
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_variant_test" } }),
prompt: promptMock,
promptAsync: promptMock,
messages: async () => ({
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }],
}),
status: async () => ({ data: { "ses_variant_test": { type: "idle" } } }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
agentOverrides: {
oracle: { model: "anthropic/claude-opus-4-6", variant: "max" },
},
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
// when - delegating to oracle via subagent_type with variant override
await tool.execute(
{
description: "Consult oracle with variant",
prompt: "Review architecture",
subagent_type: "oracle",
run_in_background: false,
load_skills: [],
},
toolContext
)
// then - user-configured variant should be applied
expect(promptBody.variant).toBe("max")
}, { timeout: 20000 })
test("fallback chain resolves model when no override and no matchedAgent.model (#1357)", async () => {
// given - agent registered without model, no override, but AGENT_MODEL_REQUIREMENTS has fallback
const { createDelegateTask } = require("./tools")
let promptBody: any
const mockManager = { launch: async () => ({}) }
const promptMock = async (input: any) => {
promptBody = input.body
return { data: {} }
}
const mockClient = {
app: {
agents: async () => ({
data: [
{ name: "oracle", mode: "subagent" }, // no model field
],
}),
},
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_fallback_test" } }),
prompt: promptMock,
promptAsync: promptMock,
messages: async () => ({
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }],
}),
status: async () => ({ data: { "ses_fallback_test": { type: "idle" } } }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
// no agentOverrides
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
// when - delegating to oracle with no override and no matchedAgent model
await tool.execute(
{
description: "Consult oracle with fallback",
prompt: "Review architecture",
subagent_type: "oracle",
run_in_background: false,
load_skills: [],
},
toolContext
)
// then - should resolve via AGENT_MODEL_REQUIREMENTS fallback chain for oracle
// oracle fallback chain: gpt-5.2 (openai) > gemini-3-pro (google) > claude-opus-4-6 (anthropic)
// Since openai is in connectedProviders, should resolve to openai/gpt-5.2
expect(promptBody.model).toBeDefined()
expect(promptBody.model.providerID).toBe("openai")
expect(promptBody.model.modelID).toContain("gpt-5.2")
}, { timeout: 20000 })
})
describe("prometheus subagent task permission", () => {
test("prometheus subagent should have task permission enabled", async () => {
// given - sisyphus delegates to prometheus
describe("subagent task permission", () => {
test("plan subagent should have task permission enabled", async () => {
//#given - sisyphus delegates to plan agent
const { createDelegateTask } = require("./tools")
let promptBody: any
@@ -2917,17 +3251,17 @@ describe("sisyphus-task", () => {
}
const mockClient = {
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
app: { agents: async () => ({ data: [{ name: "plan", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_prometheus_delegate" } }),
create: async () => ({ data: { id: "ses_plan_delegate" } }),
prompt: promptMock,
promptAsync: promptMock,
messages: async () => ({
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created" }] }]
}),
status: async () => ({ data: { "ses_prometheus_delegate": { type: "idle" } } }),
status: async () => ({ data: { "ses_plan_delegate": { type: "idle" } } }),
},
}
@@ -2943,24 +3277,53 @@ describe("sisyphus-task", () => {
abort: new AbortController().signal,
}
// when - sisyphus delegates to prometheus
//#when - sisyphus delegates to plan
await tool.execute(
{
description: "Test prometheus task permission",
description: "Test plan task permission",
prompt: "Create a plan",
subagent_type: "prometheus",
subagent_type: "plan",
run_in_background: false,
load_skills: [],
},
toolContext
)
// then - prometheus should have task permission
//#then - plan agent should have task permission
expect(promptBody.tools.task).toBe(true)
}, { timeout: 20000 })
test("non-prometheus subagent should NOT have task permission", async () => {
// given - sisyphus delegates to oracle (non-prometheus)
test("prometheus subagent should have task permission (plan family)", async () => {
//#given
const { createDelegateTask } = require("./tools")
let promptBody: any
const promptMock = async (input: any) => { promptBody = input.body; return { data: {} } }
const mockClient = {
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_prometheus_task" } }),
prompt: promptMock,
promptAsync: promptMock,
messages: async () => ({ data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created" }] }] }),
status: async () => ({ data: { "ses_prometheus_task": { type: "idle" } } }),
},
}
const tool = createDelegateTask({ manager: { launch: async () => ({}) }, client: mockClient })
//#when
await tool.execute(
{ description: "Test prometheus task permission", prompt: "Create a plan", subagent_type: "prometheus", run_in_background: false, load_skills: [] },
{ sessionID: "p", messageID: "m", agent: "sisyphus", abort: new AbortController().signal }
)
//#then
expect(promptBody.tools.task).toBe(true)
}, { timeout: 20000 })
test("non-plan subagent should NOT have task permission", async () => {
//#given - sisyphus delegates to oracle (non-plan)
const { createDelegateTask } = require("./tools")
let promptBody: any
+2 -1
View File
@@ -1,6 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider } from "../../config/schema"
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider, AgentOverrides } from "../../config/schema"
import type {
AvailableCategory,
AvailableSkill,
@@ -53,6 +53,7 @@ export interface DelegateTaskToolOptions {
disabledSkills?: Set<string>
availableCategories?: AvailableCategory[]
availableSkills?: AvailableSkill[]
agentOverrides?: AgentOverrides
onSyncSessionCreated?: (event: SyncSessionCreatedEvent) => Promise<void>
}
+112 -2
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
@@ -0,0 +1,45 @@
type ManagedClientForCleanup = {
client: {
stop: () => Promise<void>
}
}
type ProcessCleanupOptions = {
getClients: () => IterableIterator<[string, ManagedClientForCleanup]>
clearClients: () => void
clearCleanupInterval: () => void
}
export function registerLspManagerProcessCleanup(options: ProcessCleanupOptions): void {
// Synchronous cleanup for 'exit' event (cannot await)
const syncCleanup = () => {
for (const [, managed] of options.getClients()) {
try {
// Fire-and-forget during sync exit - process is terminating
void managed.client.stop().catch(() => {})
} catch {}
}
options.clearClients()
options.clearCleanupInterval()
}
// Async cleanup for signal handlers - properly await all stops
const asyncCleanup = async () => {
const stopPromises: Promise<void>[] = []
for (const [, managed] of options.getClients()) {
stopPromises.push(managed.client.stop().catch(() => {}))
}
await Promise.allSettled(stopPromises)
options.clearClients()
options.clearCleanupInterval()
}
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(() => {}))
}
}
@@ -0,0 +1,29 @@
type ManagedClientForTempDirectoryCleanup = {
refCount: number
client: {
stop: () => Promise<void>
}
}
export async function cleanupTempDirectoryLspClients(
clients: Map<string, ManagedClientForTempDirectoryCleanup>
): Promise<void> {
const keysToRemove: string[] = []
for (const [key, managed] of 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 = clients.get(key)
if (managed) {
clients.delete(key)
try {
await managed.client.stop()
} catch {}
}
}
}
+83 -69
View File
@@ -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)
}
}