diff --git a/src/main/agent/runtime-controller.test.ts b/src/main/agent/runtime-controller.test.ts index b97f600..dfa8181 100644 --- a/src/main/agent/runtime-controller.test.ts +++ b/src/main/agent/runtime-controller.test.ts @@ -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) => { diff --git a/src/main/agent/runtime-controller.ts b/src/main/agent/runtime-controller.ts index 2370eef..fea7665 100644 --- a/src/main/agent/runtime-controller.ts +++ b/src/main/agent/runtime-controller.ts @@ -22,7 +22,10 @@ export class AgentRuntimeController implements AgentRuntime { private replacementQueue: Promise = 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 { 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((resolve) => + setTimeout(resolve, this.shutdownGraceMs) + ) + ]) + await this.disposeSlot(slot) + return disposal + }) this.replacementQueue = operation.catch(() => undefined) await operation } diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 8876ccc..cdb308e 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -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-') diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 11bd905..d3a7a61 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -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) diff --git a/src/main/index.ts b/src/main/index.ts index ea43b7d..c091161 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) + } } })() }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e06da5c..de13264 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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 + ]) } } diff --git a/src/main/shutdown.test.ts b/src/main/shutdown.test.ts new file mode 100644 index 0000000..2f85e3f --- /dev/null +++ b/src/main/shutdown.test.ts @@ -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) + }) +}) diff --git a/src/main/shutdown.ts b/src/main/shutdown.ts new file mode 100644 index 0000000..be6669c --- /dev/null +++ b/src/main/shutdown.ts @@ -0,0 +1,18 @@ +export async function waitForCleanup( + cleanup: Promise, + timeoutMs: number +): Promise { + let timeout: NodeJS.Timeout | undefined + const timedOut = new Promise((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 +} diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 835cdae..0f6cc62 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -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() + + 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() + + expect(await screen.findByLabelText('当前项目')).toHaveValue( + project.id + ) + await waitFor(() => + expect( + localStorage.getItem('goodbuddy.active-project.v1') + ).toBe(project.id) + ) + }) + it.each([ ['opencode', 'OpenCode'], ['continue', 'Continue CLI'] diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f8b64ba..978ec96 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -352,6 +352,8 @@ const emptyTokenUsage: TokenUsageSummary = { const storageKey = 'goodbuddy.conversations.v1' +const activeProjectStorageKey = 'goodbuddy.active-project.v1' + const quickActions = [ { 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 { if (!value || typeof value !== 'object') { return false @@ -2200,6 +2210,13 @@ function App(): React.JSX.Element { useEffect(() => { activeProjectIdRef.current = activeProjectId + if (activeProjectId) { + try { + localStorage.setItem(activeProjectStorageKey, activeProjectId) + } catch { + // The project selection still works when persistence is unavailable. + } + } }, [activeProjectId]) useEffect(() => { @@ -2238,7 +2255,11 @@ function App(): React.JSX.Element { if (!active || value.length === 0) { return } - const project = value[0]! + const lastActiveProjectId = loadActiveProjectId() + const project = + value.find( + (candidate) => candidate.id === lastActiveProjectId + ) ?? value[0]! setProjects(value) setActiveProjectId(project.id) setWorkMode(