fix: improve shutdown and project continuity
This commit is contained in:
@@ -135,6 +135,31 @@ describe('AgentRuntimeController', () => {
|
|||||||
await controller.dispose()
|
await controller.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('forces runtime disposal when active work does not stop during shutdown', async () => {
|
||||||
|
const runtime = new TestRuntime(true)
|
||||||
|
const controller = new AgentRuntimeController(runtime, 1)
|
||||||
|
const stream = controller.run(
|
||||||
|
{
|
||||||
|
requestId: '1c608898-ecb7-4081-8174-2b6a52f53b12',
|
||||||
|
conversationId: 'conversation-shutdown',
|
||||||
|
prompt: 'test',
|
||||||
|
workMode: 'ask'
|
||||||
|
},
|
||||||
|
new AbortController().signal
|
||||||
|
)
|
||||||
|
const pendingEvent = stream.next()
|
||||||
|
await runtime.started
|
||||||
|
|
||||||
|
await controller.dispose()
|
||||||
|
expect(runtime.dispose).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
|
runtime.finish()
|
||||||
|
await expect(pendingEvent).resolves.toMatchObject({
|
||||||
|
value: { type: 'text' }
|
||||||
|
})
|
||||||
|
await stream.return()
|
||||||
|
})
|
||||||
|
|
||||||
it.each(['ask', 'plan'] as const)(
|
it.each(['ask', 'plan'] as const)(
|
||||||
'denies tool authorization in %s mode without prompting the user',
|
'denies tool authorization in %s mode without prompting the user',
|
||||||
async (workMode) => {
|
async (workMode) => {
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ export class AgentRuntimeController implements AgentRuntime {
|
|||||||
private replacementQueue: Promise<void> = Promise.resolve()
|
private replacementQueue: Promise<void> = Promise.resolve()
|
||||||
private closing = false
|
private closing = false
|
||||||
|
|
||||||
constructor(runtime: AgentRuntime) {
|
constructor(
|
||||||
|
runtime: AgentRuntime,
|
||||||
|
private readonly shutdownGraceMs = 2_000
|
||||||
|
) {
|
||||||
this.current = {
|
this.current = {
|
||||||
runtime,
|
runtime,
|
||||||
activeRequests: 0,
|
activeRequests: 0,
|
||||||
@@ -212,9 +215,21 @@ export class AgentRuntimeController implements AgentRuntime {
|
|||||||
|
|
||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
this.closing = true
|
this.closing = true
|
||||||
const operation = this.replacementQueue.then(() =>
|
const operation = this.replacementQueue.then(async () => {
|
||||||
this.retire(this.current)
|
const slot = this.current
|
||||||
)
|
const disposal = this.retire(slot)
|
||||||
|
if (slot.activeRequests === 0) {
|
||||||
|
return disposal
|
||||||
|
}
|
||||||
|
await Promise.race([
|
||||||
|
disposal,
|
||||||
|
new Promise<void>((resolve) =>
|
||||||
|
setTimeout(resolve, this.shutdownGraceMs)
|
||||||
|
)
|
||||||
|
])
|
||||||
|
await this.disposeSlot(slot)
|
||||||
|
return disposal
|
||||||
|
})
|
||||||
this.replacementQueue = operation.catch(() => undefined)
|
this.replacementQueue = operation.catch(() => undefined)
|
||||||
await operation
|
await operation
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { DatabaseSync } from 'node:sqlite'
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { AssistantDatabase } from './assistant-database'
|
import { AssistantDatabase } from './assistant-database'
|
||||||
|
|
||||||
const temporaryDirectories: string[] = []
|
const temporaryDirectories: string[] = []
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers()
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
temporaryDirectories.splice(0).map((directory) =>
|
temporaryDirectories.splice(0).map((directory) =>
|
||||||
rm(directory, { recursive: true, force: true })
|
rm(directory, { recursive: true, force: true })
|
||||||
@@ -52,6 +53,49 @@ describe('AssistantDatabase', () => {
|
|||||||
unchanged.close()
|
unchanged.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('lists projects by creation time with newer projects last', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2026-08-07T00:00:00.000Z'))
|
||||||
|
const database = await createDatabase()
|
||||||
|
const defaultProject = database.listProjects()[0]!
|
||||||
|
|
||||||
|
vi.setSystemTime(new Date('2026-08-07T00:01:00.000Z'))
|
||||||
|
const secondProject = database.createProject({
|
||||||
|
name: '第二项目',
|
||||||
|
description: '',
|
||||||
|
rootPath: 'C:\\Second',
|
||||||
|
defaultWorkMode: 'ask'
|
||||||
|
})
|
||||||
|
vi.setSystemTime(new Date('2026-08-07T00:02:00.000Z'))
|
||||||
|
const thirdProject = database.createProject({
|
||||||
|
name: '第三项目',
|
||||||
|
description: '',
|
||||||
|
rootPath: 'C:\\Third',
|
||||||
|
defaultWorkMode: 'execute'
|
||||||
|
})
|
||||||
|
|
||||||
|
database.updateProject(secondProject.id, {
|
||||||
|
name: '第二项目(已更新)',
|
||||||
|
description: '',
|
||||||
|
rootPath: 'C:\\Second',
|
||||||
|
defaultWorkMode: 'ask'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(database.listProjects().map((project) => project.id)).toEqual([
|
||||||
|
defaultProject.id,
|
||||||
|
secondProject.id,
|
||||||
|
thirdProject.id
|
||||||
|
])
|
||||||
|
expect(
|
||||||
|
database.listProjects(true).map((project) => project.id)
|
||||||
|
).toEqual([
|
||||||
|
defaultProject.id,
|
||||||
|
secondProject.id,
|
||||||
|
thirdProject.id
|
||||||
|
])
|
||||||
|
database.close()
|
||||||
|
})
|
||||||
|
|
||||||
it('migrates existing databases to schema version 8', async () => {
|
it('migrates existing databases to schema version 8', async () => {
|
||||||
const directory = await mkdtemp(
|
const directory = await mkdtemp(
|
||||||
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
||||||
|
|||||||
@@ -806,10 +806,11 @@ export class AssistantDatabase {
|
|||||||
const rows = database
|
const rows = database
|
||||||
.prepare(
|
.prepare(
|
||||||
includeArchived
|
includeArchived
|
||||||
? 'SELECT * FROM projects ORDER BY updated_at DESC'
|
? `SELECT * FROM projects
|
||||||
|
ORDER BY created_at ASC, rowid ASC`
|
||||||
: `SELECT * FROM projects
|
: `SELECT * FROM projects
|
||||||
WHERE status = 'active'
|
WHERE status = 'active'
|
||||||
ORDER BY updated_at DESC`
|
ORDER BY created_at ASC, rowid ASC`
|
||||||
)
|
)
|
||||||
.all() as ProjectRow[]
|
.all() as ProjectRow[]
|
||||||
return rows.map(toProject)
|
return rows.map(toProject)
|
||||||
|
|||||||
+17
-12
@@ -60,6 +60,7 @@ import { EmbeddingIndexCoordinator } from './knowledge/embedding-index-coordinat
|
|||||||
import { KnowledgeEmbeddingIndexRepository } from './knowledge/knowledge-embedding-index-repository'
|
import { KnowledgeEmbeddingIndexRepository } from './knowledge/knowledge-embedding-index-repository'
|
||||||
import { GlobalTlsPolicy } from './global-tls-policy'
|
import { GlobalTlsPolicy } from './global-tls-policy'
|
||||||
import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts'
|
import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts'
|
||||||
|
import { waitForCleanup } from './shutdown'
|
||||||
|
|
||||||
const shortcut = 'CommandOrControl+Shift+Space'
|
const shortcut = 'CommandOrControl+Shift+Space'
|
||||||
const portableUserDataPath = resolvePortableUserDataPath({
|
const portableUserDataPath = resolvePortableUserDataPath({
|
||||||
@@ -485,21 +486,25 @@ app.on('before-quit', (event) => {
|
|||||||
cleanupStarted = true
|
cleanupStarted = true
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await Promise.allSettled([removeIpcHandlers?.()])
|
const cleanup = Promise.allSettled([
|
||||||
|
Promise.resolve().then(() => removeIpcHandlers?.()),
|
||||||
|
Promise.resolve().then(() => runtime?.dispose()),
|
||||||
|
Promise.resolve().then(() => selectedRuntimeManager?.dispose()),
|
||||||
|
Promise.resolve().then(() => knowledgeGateway?.dispose()),
|
||||||
|
Promise.resolve().then(() => knowledgeService?.dispose()),
|
||||||
|
Promise.resolve().then(() => browserService?.dispose()),
|
||||||
|
Promise.resolve().then(() => globalTlsPolicy?.dispose())
|
||||||
|
])
|
||||||
globalShortcut.unregisterAll()
|
globalShortcut.unregisterAll()
|
||||||
tray?.destroy()
|
tray?.destroy()
|
||||||
await Promise.allSettled([
|
await waitForCleanup(cleanup, 8_000)
|
||||||
runtime?.dispose(),
|
|
||||||
selectedRuntimeManager?.dispose(),
|
|
||||||
knowledgeGateway?.dispose(),
|
|
||||||
knowledgeService?.dispose(),
|
|
||||||
browserService?.dispose(),
|
|
||||||
globalTlsPolicy?.dispose()
|
|
||||||
])
|
|
||||||
} finally {
|
} finally {
|
||||||
assistantDatabase?.close()
|
try {
|
||||||
cleanupComplete = true
|
assistantDatabase?.close()
|
||||||
app.quit()
|
} finally {
|
||||||
|
cleanupComplete = true
|
||||||
|
app.exit(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
})
|
})
|
||||||
|
|||||||
+18
-14
@@ -2941,12 +2941,11 @@ export function registerIpcHandlers(
|
|||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
shuttingDown = true
|
shuttingDown = true
|
||||||
await Promise.allSettled(
|
const channelCleanup = Promise.allSettled([
|
||||||
[
|
...channelServices.map((service) => service.stop()),
|
||||||
...channelServices.map((service) => service.stop()),
|
channelManager?.stopAll()
|
||||||
channelManager?.stopAll()
|
])
|
||||||
]
|
const subagentCleanup = subagentService?.dispose()
|
||||||
)
|
|
||||||
removeBrowserStateListener?.()
|
removeBrowserStateListener?.()
|
||||||
removeEmbeddingStatusListener?.()
|
removeEmbeddingStatusListener?.()
|
||||||
clearInterval(scheduleInterval)
|
clearInterval(scheduleInterval)
|
||||||
@@ -2957,21 +2956,26 @@ export function registerIpcHandlers(
|
|||||||
}
|
}
|
||||||
heartbeatControllers.clear()
|
heartbeatControllers.clear()
|
||||||
speechTranscriptionService?.dispose()
|
speechTranscriptionService?.dispose()
|
||||||
if (speechModelManager) {
|
const speechModelCleanup = speechModelManager
|
||||||
for (const operation of (await speechModelManager.getSnapshot()).operations) {
|
?.getSnapshot()
|
||||||
speechModelManager.cancel(operation.modelId)
|
.then((snapshot) => {
|
||||||
}
|
for (const operation of snapshot.operations) {
|
||||||
}
|
speechModelManager.cancel(operation.modelId)
|
||||||
|
}
|
||||||
|
})
|
||||||
embeddingIndexCoordinator?.cancel()
|
embeddingIndexCoordinator?.cancel()
|
||||||
approvalBroker.clear()
|
approvalBroker.clear()
|
||||||
contextManager.clear()
|
contextManager.clear()
|
||||||
subagentService?.cancelAll('应用正在退出')
|
|
||||||
await Promise.allSettled([...activeExecutions])
|
|
||||||
await subagentService?.dispose()
|
|
||||||
window.removeListener('maximize', notifyMaximizedChanged)
|
window.removeListener('maximize', notifyMaximizedChanged)
|
||||||
window.removeListener('unmaximize', notifyMaximizedChanged)
|
window.removeListener('unmaximize', notifyMaximizedChanged)
|
||||||
for (const channel of channels) {
|
for (const channel of channels) {
|
||||||
ipcMain.removeHandler(channel)
|
ipcMain.removeHandler(channel)
|
||||||
}
|
}
|
||||||
|
await Promise.allSettled([
|
||||||
|
channelCleanup,
|
||||||
|
speechModelCleanup,
|
||||||
|
subagentCleanup,
|
||||||
|
...activeExecutions
|
||||||
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { waitForCleanup } from './shutdown'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('waitForCleanup', () => {
|
||||||
|
it('reports completed and failed cleanup as settled', async () => {
|
||||||
|
await expect(
|
||||||
|
waitForCleanup(Promise.resolve(), 100)
|
||||||
|
).resolves.toBe(true)
|
||||||
|
await expect(
|
||||||
|
waitForCleanup(Promise.reject(new Error('cleanup failed')), 100)
|
||||||
|
).resolves.toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops waiting after the shutdown deadline', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const result = waitForCleanup(new Promise(() => {}), 100)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
|
||||||
|
await expect(result).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export async function waitForCleanup(
|
||||||
|
cleanup: Promise<unknown>,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
let timeout: NodeJS.Timeout | undefined
|
||||||
|
const timedOut = new Promise<false>((resolve) => {
|
||||||
|
timeout = setTimeout(() => resolve(false), timeoutMs)
|
||||||
|
})
|
||||||
|
const settled = Promise.resolve(cleanup).then(
|
||||||
|
() => true as const,
|
||||||
|
() => true as const
|
||||||
|
)
|
||||||
|
const completed = await Promise.race([settled, timedOut])
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
return completed
|
||||||
|
}
|
||||||
@@ -1723,6 +1723,58 @@ describe('App', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('restores and persists the last active project', async () => {
|
||||||
|
const secondProject = {
|
||||||
|
...project,
|
||||||
|
id: '00000000-0000-4000-8000-000000000102',
|
||||||
|
name: '第二项目',
|
||||||
|
rootPath: 'C:\\Second',
|
||||||
|
defaultWorkMode: 'execute' as const
|
||||||
|
}
|
||||||
|
vi.mocked(api.projects.list).mockResolvedValueOnce([
|
||||||
|
project,
|
||||||
|
secondProject
|
||||||
|
])
|
||||||
|
localStorage.setItem(
|
||||||
|
'goodbuddy.active-project.v1',
|
||||||
|
secondProject.id
|
||||||
|
)
|
||||||
|
|
||||||
|
render(<App />)
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText('当前项目')).toHaveValue(
|
||||||
|
secondProject.id
|
||||||
|
)
|
||||||
|
expect(screen.getByLabelText('工作模式')).toHaveValue('execute')
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('当前项目'), {
|
||||||
|
target: { value: project.id }
|
||||||
|
})
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
localStorage.getItem('goodbuddy.active-project.v1')
|
||||||
|
).toBe(project.id)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back when the last active project is no longer available', async () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'goodbuddy.active-project.v1',
|
||||||
|
'00000000-0000-4000-8000-000000000999'
|
||||||
|
)
|
||||||
|
|
||||||
|
render(<App />)
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText('当前项目')).toHaveValue(
|
||||||
|
project.id
|
||||||
|
)
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
localStorage.getItem('goodbuddy.active-project.v1')
|
||||||
|
).toBe(project.id)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['opencode', 'OpenCode'],
|
['opencode', 'OpenCode'],
|
||||||
['continue', 'Continue CLI']
|
['continue', 'Continue CLI']
|
||||||
|
|||||||
@@ -352,6 +352,8 @@ const emptyTokenUsage: TokenUsageSummary = {
|
|||||||
|
|
||||||
const storageKey = 'goodbuddy.conversations.v1'
|
const storageKey = 'goodbuddy.conversations.v1'
|
||||||
|
|
||||||
|
const activeProjectStorageKey = 'goodbuddy.active-project.v1'
|
||||||
|
|
||||||
const quickActions = [
|
const quickActions = [
|
||||||
{
|
{
|
||||||
title: '总结一段内容',
|
title: '总结一段内容',
|
||||||
@@ -644,6 +646,14 @@ function loadConversations(): Conversation[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadActiveProjectId(): string | undefined {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(activeProjectStorageKey) || undefined
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isConversation(value: unknown): value is Conversation {
|
function isConversation(value: unknown): value is Conversation {
|
||||||
if (!value || typeof value !== 'object') {
|
if (!value || typeof value !== 'object') {
|
||||||
return false
|
return false
|
||||||
@@ -2200,6 +2210,13 @@ function App(): React.JSX.Element {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeProjectIdRef.current = activeProjectId
|
activeProjectIdRef.current = activeProjectId
|
||||||
|
if (activeProjectId) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(activeProjectStorageKey, activeProjectId)
|
||||||
|
} catch {
|
||||||
|
// The project selection still works when persistence is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
}, [activeProjectId])
|
}, [activeProjectId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2238,7 +2255,11 @@ function App(): React.JSX.Element {
|
|||||||
if (!active || value.length === 0) {
|
if (!active || value.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const project = value[0]!
|
const lastActiveProjectId = loadActiveProjectId()
|
||||||
|
const project =
|
||||||
|
value.find(
|
||||||
|
(candidate) => candidate.id === lastActiveProjectId
|
||||||
|
) ?? value[0]!
|
||||||
setProjects(value)
|
setProjects(value)
|
||||||
setActiveProjectId(project.id)
|
setActiveProjectId(project.id)
|
||||||
setWorkMode(
|
setWorkMode(
|
||||||
|
|||||||
Reference in New Issue
Block a user