Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/main/presenter/configPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1192,7 +1192,7 @@ export class ConfigPresenter implements IConfigPresenter {

private refreshAcpProviderAgents(agentIds?: string[]): void {
try {
const providerInstance = presenter?.llmproviderPresenter?.getProviderInstance('acp')
const providerInstance = presenter?.llmproviderPresenter?.getProviderInstance?.('acp')
if (!providerInstance) {
return
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Ensure ACP-related processes/PTYs are terminated during shutdown
*/

import { LifecycleHook, LifecycleContext } from '@shared/presenter'
import { LifecyclePhase } from '@shared/lifecycle'
import { presenter } from '@/presenter'
import { killTerminal } from '../../../configPresenter/acpInitHelper'

export const acpCleanupHook: LifecycleHook = {
name: 'acp-cleanup',
phase: LifecyclePhase.BEFORE_QUIT,
priority: 6,
critical: false,
execute: async (_context: LifecycleContext) => {
console.log('[Lifecycle][ACP] acpCleanupHook: shutting down ACP resources')

try {
killTerminal()
} catch (error) {
console.warn('[Lifecycle][ACP] acpCleanupHook: failed to kill ACP init terminal:', error)
}

try {
const llmPresenter = presenter?.llmproviderPresenter
// Avoid instantiating ACP provider during shutdown; only clean up if already created
const acpProvider = llmPresenter?.getExistingProviderInstance?.('acp') as
| { cleanup?: () => Promise<void> }
| undefined
if (acpProvider?.cleanup) {
await acpProvider.cleanup()
}
} catch (error) {
console.warn('[Lifecycle][ACP] acpCleanupHook: failed to cleanup ACP provider:', error)
}
}
}
1 change: 1 addition & 0 deletions src/main/presenter/lifecyclePresenter/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export { floatingDestroyHook } from './beforeQuit/floatingDestroyHook'
export { presenterDestroyHook } from './beforeQuit/presenterDestroyHook'
export { builtinKnowledgeDestroyHook } from './beforeQuit/builtinKnowledgeDestroyHook'
export { windowQuittingHook } from './beforeQuit/windowQuittingHook'
export { acpCleanupHook } from './beforeQuit/acpCleanupHook'
44 changes: 41 additions & 3 deletions src/main/presenter/llmProviderPresenter/agent/acpProcessManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export class AcpProcessManager implements AgentProcessManager<AcpProcessHandle,
private readonly fsHandlers = new Map<string, AcpFsHandler>()
private readonly agentLocks = new Map<string, Promise<void>>()
private readonly preferredModes = new Map<string, string>()
private shuttingDown = false

constructor(options: AcpProcessManagerOptions) {
this.providerId = options.providerId
Expand Down Expand Up @@ -163,6 +164,9 @@ export class AcpProcessManager implements AgentProcessManager<AcpProcessHandle,
* Reuses an existing warmup handle when possible; never reuses bound handles.
*/
async warmupProcess(agent: AcpAgentConfig, workdir?: string): Promise<AcpProcessHandle> {
if (this.shuttingDown) {
throw new Error('[ACP] Process manager is shutting down, refusing to spawn new process')
}
const resolvedWorkdir = this.resolveWorkdir(workdir)
const warmupKey = this.getWarmupKey(agent.id, resolvedWorkdir)
const preferredModeId = this.preferredModes.get(warmupKey)
Expand Down Expand Up @@ -300,6 +304,10 @@ export class AcpProcessManager implements AgentProcessManager<AcpProcessHandle,
}

async shutdown(): Promise<void> {
if (this.shuttingDown) return
this.shuttingDown = true
// Kill eagerly so subprocesses don't survive app shutdown even if async cleanup is skipped
this.forceKillAllProcesses('shutdown')
const allAgents = new Set<string>()
for (const handle of this.handles.values()) {
allAgents.add(handle.agentId)
Expand Down Expand Up @@ -987,7 +995,7 @@ export class AcpProcessManager implements AgentProcessManager<AcpProcessHandle,

private async disposeHandle(handle: AcpProcessHandle): Promise<void> {
this.removeHandleReferences(handle)
this.killChild(handle.child)
this.killChild(handle.child, 'dispose')
}

private findReusableHandle(agentId: string, workdir: string): AcpProcessHandle | undefined {
Expand Down Expand Up @@ -1024,12 +1032,42 @@ export class AcpProcessManager implements AgentProcessManager<AcpProcessHandle,
}
}

private killChild(child: ChildProcessWithoutNullStreams): void {
private forceKillAllProcesses(reason: string): void {
const handles = this.listProcesses()
handles.forEach((handle) => this.killChild(handle.child, reason))
}

private killChild(child: ChildProcessWithoutNullStreams, reason?: string): void {
const pid = child.pid
if (pid) {
if (process.platform === 'win32') {
try {
spawn('taskkill', ['/PID', `${pid}`, '/T', '/F'], { stdio: 'ignore' })
} catch (error) {
console.warn(`[ACP] Failed to taskkill process ${pid} (${reason ?? 'unknown'}):`, error)
}
} else {
try {
spawn('pkill', ['-TERM', '-P', `${pid}`], { stdio: 'ignore' })
} catch (error) {
console.warn(`[ACP] Failed to pkill children for process ${pid}:`, error)
}
try {
process.kill(pid, 'SIGTERM')
} catch (error) {
console.warn(`[ACP] Failed to SIGTERM process ${pid}:`, error)
}
}
}

if (!child.killed) {
try {
child.kill()
} catch (error) {
console.warn('[ACP] Failed to kill agent process:', error)
console.warn(
`[ACP] Failed to kill agent process${pid ? ` ${pid}` : ''} (${reason ?? 'unknown'}):`,
error
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,16 @@ export class AcpSessionManager {
workdir: string
): Promise<AcpSessionRecord> {
// Pass workdir to process manager so the process runs in the correct directory
const handle = await this.processManager.getConnection(agent, workdir)
let handle: AcpProcessHandle
try {
handle = await this.processManager.getConnection(agent, workdir)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.includes('shutting down')) {
throw new Error('[ACP] Cannot create session: process manager is shutting down')
}
throw error
}
this.processManager.bindProcess(agent.id, conversationId, workdir)

const session = await this.initializeSession(handle, agent, workdir).catch(async (error) => {
Expand Down
18 changes: 16 additions & 2 deletions src/main/presenter/llmProviderPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ export class LLMProviderPresenter implements ILlmProviderPresenter {
return this.providerInstanceManager.getProviderInstance(providerId)
}

public getExistingProviderInstance(providerId: string): BaseLLMProvider | undefined {
return this.providerInstanceManager.getExistingProviderInstance(providerId)
}

async getModelList(providerId: string): Promise<MODEL_META[]> {
return this.modelManager.getModelList(providerId)
}
Expand Down Expand Up @@ -484,8 +488,18 @@ export class LLMProviderPresenter implements ILlmProviderPresenter {
async warmupAcpProcess(agentId: string, workdir: string): Promise<void> {
const provider = this.getAcpProviderInstance()
if (!provider) return

await provider.warmupProcess(agentId, workdir)
try {
await provider.warmupProcess(agentId, workdir)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.includes('shutting down')) {
console.warn(
`[ACP] Cannot warmup process for agent ${agentId}: process manager is shutting down`
)
return
}
throw error
}
}

async getAcpProcessModes(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ export class ProviderInstanceManager {
}
}

getExistingProviderInstance(providerId: string): BaseLLMProvider | undefined {
return this.providerInstances.get(providerId)
}

getProviders(): LLM_PROVIDER[] {
return Array.from(this.providers.values())
}
Expand Down
39 changes: 36 additions & 3 deletions src/main/presenter/llmProviderPresenter/providers/acpProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { eventBus, SendTarget } from '@/eventbus'
import { ACP_DEBUG_EVENTS, ACP_WORKSPACE_EVENTS, CONFIG_EVENTS } from '@/events'
import { app } from 'electron'
import { AcpProcessManager } from '../agent/acpProcessManager'
import { AcpProcessManager, type AcpProcessHandle } from '../agent/acpProcessManager'
import { AcpSessionManager } from '../agent/acpSessionManager'
import type { AcpSessionRecord } from '../agent/acpSessionManager'
import { AcpContentMapper } from '../agent/acpContentMapper'
Expand Down Expand Up @@ -458,8 +458,21 @@ export class AcpProvider extends BaseAgentProvider<
if (!agent) {
throw new Error(`[ACP] Agent not found: ${request.agentId}`)
}

const handle = await this.processManager.getConnection(agent, request.workdir)
let handle: AcpProcessHandle
try {
handle = await this.processManager.getConnection(agent, request.workdir)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.includes('shutting down')) {
return {
status: 'error',
sessionId: undefined,
error: 'Process manager is shutting down',
events: []
}
}
throw error
}
const connection = handle.connection
const events: AcpDebugEventEntry[] = []

Expand Down Expand Up @@ -1122,4 +1135,24 @@ export class AcpProvider extends BaseAgentProvider<

return result
}

async cleanup(): Promise<void> {
console.log('[ACP] Cleanup: shutting down ACP sessions and processes')
try {
await this.sessionManager.clearAllSessions()
} catch (error) {
console.warn('[ACP] Cleanup: failed to clear sessions:', error)
}

try {
await this.processManager.shutdown()
} catch (error) {
console.warn('[ACP] Cleanup: failed to shutdown process manager:', error)
}

for (const [requestId, state] of this.pendingPermissions.entries()) {
state.resolve({ outcome: { outcome: 'cancelled' } })
this.pendingPermissions.delete(requestId)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,8 @@ export class UtilityHandler extends BaseHandler {
}

// Get provider and request preview
const provider = this.ctx.llmProviderPresenter.getProviderInstance(effectiveProviderId)
const provider =
this.ctx.llmProviderPresenter.getProviderInstance?.(effectiveProviderId) ?? null
if (!provider) {
throw new Error(`Provider ${effectiveProviderId} not found`)
}
Expand Down
5 changes: 4 additions & 1 deletion src/main/presenter/threadPresenter/utils/promptBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,10 @@ function mergeConsecutiveMessages(messages: ChatMessage[]): ChatMessage[] {
let allowMessagePropertiesMerge = false

if (lastPushedMessage.role === currentMessage.role) {
if (currentMessage.role === 'assistant') {
// Never merge tool messages - each tool message must correspond to a specific tool_call_id
if (currentMessage.role === 'tool') {
allowMessagePropertiesMerge = false
} else if (currentMessage.role === 'assistant') {
if (!lastPushedMessage.tool_calls && !currentMessage.tool_calls) {
allowMessagePropertiesMerge = true
}
Expand Down
2 changes: 2 additions & 0 deletions src/shared/types/presenters/legacy.presenters.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,7 @@ export interface ILlmProviderPresenter {
getProviders(): LLM_PROVIDER[]
getProviderById(id: string): LLM_PROVIDER
isAgentProvider(providerId: string): boolean
getExistingProviderInstance?(providerId: string): unknown
getModelList(providerId: string): Promise<MODEL_META[]>
updateModelStatus(providerId: string, modelId: string, enabled: boolean): Promise<void>
addCustomModel(
Expand Down Expand Up @@ -941,6 +942,7 @@ export interface ILlmProviderPresenter {
resolveAgentPermission(requestId: string, granted: boolean): Promise<void>
runAcpDebugAction(request: AcpDebugRequest): Promise<AcpDebugRunResult>
getProviderInstance(providerId: string): unknown
getExistingProviderInstance?(providerId: string): unknown
}

export type CONVERSATION_SETTINGS = {
Expand Down
1 change: 1 addition & 0 deletions src/shared/types/presenters/llmprovider.presenter.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export interface ILlmProviderPresenter {
getProviderById(id: string): LLM_PROVIDER
isAgentProvider(providerId: string): boolean
getProviderInstance(providerId: string): unknown
getExistingProviderInstance(providerId: string): unknown
getModelList(providerId: string): Promise<MODEL_META[]>
updateModelStatus(providerId: string, modelId: string, enabled: boolean): Promise<void>
addCustomModel(
Expand Down
82 changes: 82 additions & 0 deletions test/main/presenter/acpProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect, vi } from 'vitest'
import { AcpProvider } from '../../../src/main/presenter/llmProviderPresenter/providers/acpProvider'

vi.mock('electron', () => ({
app: {
getVersion: vi.fn(() => '0.0.0-test')
}
}))

vi.mock('@/eventbus', () => ({
eventBus: {
on: vi.fn(),
sendToRenderer: vi.fn(),
emit: vi.fn(),
send: vi.fn()
},
SendTarget: {
ALL_WINDOWS: 'ALL_WINDOWS'
}
}))

vi.mock('@/presenter', () => ({
presenter: {
mcpPresenter: {
getAllToolDefinitions: vi.fn().mockResolvedValue([]),
callTool: vi.fn().mockResolvedValue({ content: '', rawData: {} })
}
}
}))

vi.mock('@/presenter/proxyConfig', () => ({
proxyConfig: {
getProxyUrl: vi.fn().mockReturnValue(null)
}
}))

describe('AcpProvider runDebugAction error handling', () => {
const agent = { id: 'agent1', name: 'Agent 1' }

it('returns error result when process manager is shutting down', async () => {
const provider = Object.create(AcpProvider.prototype) as any
provider.configPresenter = {
getAcpAgents: vi.fn().mockResolvedValue([agent])
}
provider.processManager = {
getConnection: vi
.fn()
.mockRejectedValue(new Error('[ACP] Process manager is shutting down, refusing to spawn'))
}

const result = await provider.runDebugAction({
agentId: 'agent1',
action: 'initialize',
workdir: '/tmp'
} as any)

expect(result).toEqual({
status: 'error',
sessionId: undefined,
error: 'Process manager is shutting down',
events: []
})
})

it('rethrows non-shutdown getConnection errors', async () => {
const provider = Object.create(AcpProvider.prototype) as any
provider.configPresenter = {
getAcpAgents: vi.fn().mockResolvedValue([agent])
}
provider.processManager = {
getConnection: vi.fn().mockRejectedValue(new Error('boom'))
}

await expect(
provider.runDebugAction({
agentId: 'agent1',
action: 'initialize',
workdir: '/tmp'
} as any)
).rejects.toThrow('boom')
})
})
Loading