fix: improve shutdown and project continuity

This commit is contained in:
lofyer
2026-08-07 18:46:26 +08:00
parent 53d18e2b06
commit 954b42ef55
10 changed files with 245 additions and 34 deletions
+25
View File
@@ -135,6 +135,31 @@ describe('AgentRuntimeController', () => {
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)(
'denies tool authorization in %s mode without prompting the user',
async (workMode) => {
+19 -4
View File
@@ -22,7 +22,10 @@ export class AgentRuntimeController implements AgentRuntime {
private replacementQueue: Promise<void> = Promise.resolve()
private closing = false
constructor(runtime: AgentRuntime) {
constructor(
runtime: AgentRuntime,
private readonly shutdownGraceMs = 2_000
) {
this.current = {
runtime,
activeRequests: 0,
@@ -212,9 +215,21 @@ export class AgentRuntimeController implements AgentRuntime {
async dispose(): Promise<void> {
this.closing = true
const operation = this.replacementQueue.then(() =>
this.retire(this.current)
)
const operation = this.replacementQueue.then(async () => {
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)
await operation
}
+45 -1
View File
@@ -2,12 +2,13 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
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'
const temporaryDirectories: string[] = []
afterEach(async () => {
vi.useRealTimers()
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
@@ -52,6 +53,49 @@ describe('AssistantDatabase', () => {
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 () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-')
+3 -2
View File
@@ -806,10 +806,11 @@ export class AssistantDatabase {
const rows = database
.prepare(
includeArchived
? 'SELECT * FROM projects ORDER BY updated_at DESC'
? `SELECT * FROM projects
ORDER BY created_at ASC, rowid ASC`
: `SELECT * FROM projects
WHERE status = 'active'
ORDER BY updated_at DESC`
ORDER BY created_at ASC, rowid ASC`
)
.all() as ProjectRow[]
return rows.map(toProject)
+17 -12
View File
@@ -60,6 +60,7 @@ import { EmbeddingIndexCoordinator } from './knowledge/embedding-index-coordinat
import { KnowledgeEmbeddingIndexRepository } from './knowledge/knowledge-embedding-index-repository'
import { GlobalTlsPolicy } from './global-tls-policy'
import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts'
import { waitForCleanup } from './shutdown'
const shortcut = 'CommandOrControl+Shift+Space'
const portableUserDataPath = resolvePortableUserDataPath({
@@ -485,21 +486,25 @@ app.on('before-quit', (event) => {
cleanupStarted = true
void (async () => {
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()
tray?.destroy()
await Promise.allSettled([
runtime?.dispose(),
selectedRuntimeManager?.dispose(),
knowledgeGateway?.dispose(),
knowledgeService?.dispose(),
browserService?.dispose(),
globalTlsPolicy?.dispose()
])
await waitForCleanup(cleanup, 8_000)
} finally {
assistantDatabase?.close()
cleanupComplete = true
app.quit()
try {
assistantDatabase?.close()
} finally {
cleanupComplete = true
app.exit(0)
}
}
})()
})
+18 -14
View File
@@ -2941,12 +2941,11 @@ export function registerIpcHandlers(
return async () => {
shuttingDown = true
await Promise.allSettled(
[
...channelServices.map((service) => service.stop()),
channelManager?.stopAll()
]
)
const channelCleanup = Promise.allSettled([
...channelServices.map((service) => service.stop()),
channelManager?.stopAll()
])
const subagentCleanup = subagentService?.dispose()
removeBrowserStateListener?.()
removeEmbeddingStatusListener?.()
clearInterval(scheduleInterval)
@@ -2957,21 +2956,26 @@ export function registerIpcHandlers(
}
heartbeatControllers.clear()
speechTranscriptionService?.dispose()
if (speechModelManager) {
for (const operation of (await speechModelManager.getSnapshot()).operations) {
speechModelManager.cancel(operation.modelId)
}
}
const speechModelCleanup = speechModelManager
?.getSnapshot()
.then((snapshot) => {
for (const operation of snapshot.operations) {
speechModelManager.cancel(operation.modelId)
}
})
embeddingIndexCoordinator?.cancel()
approvalBroker.clear()
contextManager.clear()
subagentService?.cancelAll('应用正在退出')
await Promise.allSettled([...activeExecutions])
await subagentService?.dispose()
window.removeListener('maximize', notifyMaximizedChanged)
window.removeListener('unmaximize', notifyMaximizedChanged)
for (const channel of channels) {
ipcMain.removeHandler(channel)
}
await Promise.allSettled([
channelCleanup,
speechModelCleanup,
subagentCleanup,
...activeExecutions
])
}
}
+26
View File
@@ -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)
})
})
+18
View File
@@ -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
}