feat: add app management, local inference controls, and task statistics

This commit is contained in:
mesalogo
2026-09-18 08:20:45 +08:00
parent 6dd6297414
commit bbae893c45
103 changed files with 7962 additions and 1490 deletions
+88 -16
View File
@@ -1,7 +1,7 @@
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ApplicationSettingsStore,
applicationSettingsSchema,
@@ -44,6 +44,55 @@ afterEach(async () => {
})
describe('ApplicationSettingsStore', () => {
it('keeps the confirmed snapshot when an atomic save fails', async () => {
const { filePath, store } = await createStore()
const confirmed = await store.get()
const changed = vi.fn()
store.onChanged(changed)
await mkdir(filePath)
await expect(store.update({ localInferenceEnabled: false })).rejects.toThrow()
expect(await store.get()).toEqual(confirmed)
expect(changed).not.toHaveBeenCalled()
})
it('defaults missing released Notes fields to enabled without resetting unrelated values', async () => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
for (const key of ['applicationNavigation', 'localInferenceEnabled', 'magicNotesEnabled', 'magicNotesShowIncompleteTodoCount']) delete legacy[key]
await writeFile(filePath, JSON.stringify(legacy), 'utf8')
expect(await store.get()).toEqual({ ...defaultApplicationSettings, checkUpdatesOnStartup: false })
expect(await createApplicationSettingsStore(filePath).get()).toEqual(await store.get())
})
it('migrates released settings without replacing explicit false values or comment choices', async () => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' }
for (const key of ['applicationNavigation', 'localInferenceEnabled']) delete legacy[key]
await writeFile(filePath, JSON.stringify(legacy), 'utf8')
const migrated = await store.get()
expect(migrated).toMatchObject({ magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, localInferenceEnabled: true, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' })
await store.update({ localInferenceEnabled: false })
await store.update({ applicationNavigation: { ...migrated.applicationNavigation, order: [...migrated.applicationNavigation.order].reverse(), pinned: { ...migrated.applicationNavigation.pinned, 'local-inference': false } } })
const reloaded = await createApplicationSettingsStore(filePath).get()
expect(reloaded).toMatchObject({ magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, localInferenceEnabled: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' })
expect(reloaded.applicationNavigation.order).toEqual([...migrated.applicationNavigation.order].reverse())
expect(reloaded.applicationNavigation.pinned['local-inference']).toBe(false)
})
it('rejects incomplete, duplicate, unknown order and pin values without applying defaults to patches', () => {
const navigation = defaultApplicationSettings.applicationNavigation
for (const invalid of [
{ ...navigation, order: navigation.order.slice(1) },
{ ...navigation, order: ['magic-notes', 'magic-notes'] },
{ ...navigation, order: ['unknown', 'local-inference'] },
{ ...navigation, order: ['knowledge', 'local-inference'] },
{ ...navigation, order: ['heartbeat', 'local-inference'] },
{ ...navigation, pinned: { ...navigation.pinned, heartbeat: true } },
{ ...navigation, pinned: { ...navigation.pinned, knowledge: true } },
{ ...navigation, pinned: { ...navigation.pinned, extra: true } },
{ ...navigation, pinned: { knowledge: true } }
]) expect(applicationSettingsUpdateSchema.safeParse({ applicationNavigation: invalid }).success).toBe(false)
expect(applicationSettingsUpdateSchema.parse({ checkUpdatesOnStartup: false })).toEqual({ checkUpdatesOnStartup: false })
})
it('returns defaults without creating a settings file', async () => {
const { directory, store } = await createStore()
@@ -61,6 +110,7 @@ describe('ApplicationSettingsStore', () => {
magicNotesShowIncompleteTodoCount: false
})
).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -73,6 +123,7 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -85,7 +136,8 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 11,
...defaultApplicationSettings,
version: 12,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -119,7 +171,7 @@ describe('ApplicationSettingsStore', () => {
remoteProjectsEnabled: true
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 11,
version: 12,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings,
remoteProjectsEnabled: true
@@ -138,6 +190,7 @@ describe('ApplicationSettingsStore', () => {
await expect(
createApplicationSettingsStore(filePath).get()
).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -152,7 +205,7 @@ describe('ApplicationSettingsStore', () => {
})
it.each([1, 2])(
'loads version %s settings missing the field with Magic Notes disabled',
'loads version %s settings missing the field with Magic Notes enabled',
async (version) => {
const { filePath, store } = await createStore()
await writeFile(
@@ -165,13 +218,14 @@ describe('ApplicationSettingsStore', () => {
)
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
@@ -192,6 +246,7 @@ describe('ApplicationSettingsStore', () => {
)
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -219,6 +274,7 @@ describe('ApplicationSettingsStore', () => {
)
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -252,7 +308,8 @@ describe('ApplicationSettingsStore', () => {
createApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 11,
...defaultApplicationSettings,
version: 12,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -283,6 +340,7 @@ describe('ApplicationSettingsStore', () => {
)
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -311,6 +369,7 @@ describe('ApplicationSettingsStore', () => {
await writeFile(filePath, JSON.stringify(versionSix), 'utf8')
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'modelscope',
@@ -323,8 +382,9 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'structured'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
...versionSix,
version: 11,
version: 12,
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
@@ -334,8 +394,9 @@ describe('ApplicationSettingsStore', () => {
await store.update({ modelDownloadSource: 'hugging-face' })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
...versionSix,
version: 11,
version: 12,
modelDownloadSource: 'hugging-face',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
@@ -359,6 +420,7 @@ describe('ApplicationSettingsStore', () => {
await writeFile(filePath, JSON.stringify(versionSeven), 'utf8')
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -371,8 +433,9 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
...versionSeven,
version: 11,
version: 12,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -398,6 +461,7 @@ describe('ApplicationSettingsStore', () => {
const migratedSettings = await store.get()
expect(migratedSettings).toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
@@ -411,8 +475,9 @@ describe('ApplicationSettingsStore', () => {
})
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
...versionEight,
version: 11,
version: 12,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false
@@ -420,8 +485,9 @@ describe('ApplicationSettingsStore', () => {
await store.update({ checkUpdatesOnStartup: true })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
...versionEight,
version: 11,
version: 12,
checkUpdatesOnStartup: true,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
@@ -454,6 +520,7 @@ describe('ApplicationSettingsStore', () => {
})
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
@@ -476,8 +543,9 @@ describe('ApplicationSettingsStore', () => {
const persisted = JSON.parse(await readFile(filePath, 'utf8'))
expect(persisted).toEqual({
...defaultApplicationSettings,
...versionNine,
version: 11,
version: 12,
conversationHtmlRenderingEnabled: true,
localToolEnvironment: {
node: {
@@ -538,6 +606,7 @@ describe('ApplicationSettingsStore', () => {
await expect(
store.update({ checkUpdatesOnStartup: false })
).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -596,7 +665,7 @@ describe('ApplicationSettingsStore', () => {
await expect(store.get()).resolves.toEqual(defaultApplicationSettings)
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionTenSettings,
version: 11,
version: 12,
conversationHtmlRenderingEnabled: true
})
})
@@ -606,7 +675,7 @@ describe('ApplicationSettingsStore', () => {
JSON.stringify({
version: 3,
checkUpdatesOnStartup: false,
magicNotesEnabled: false
magicNotesEnabled: 'false'
}),
JSON.stringify({
version: 2,
@@ -685,6 +754,7 @@ describe('ApplicationSettingsStore', () => {
])
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -697,7 +767,8 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 11,
...defaultApplicationSettings,
version: 12,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -727,6 +798,7 @@ describe('ApplicationSettingsStore', () => {
magicNotesEnabled: true
})
).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
+30 -4
View File
@@ -4,6 +4,7 @@ import { z } from 'zod'
import {
applicationSettingsSchema,
applicationSettingsUpdateSchema,
defaultApplicationNavigation,
type ApplicationSettings
} from '../shared/application-settings-contracts'
import {
@@ -27,7 +28,7 @@ export {
} from '../shared/application-settings-contracts'
export type { ApplicationSettings } from '../shared/application-settings-contracts'
const CURRENT_SETTINGS_VERSION = 11
const CURRENT_SETTINGS_VERSION = 12
const legacyStoredApplicationSettingsSchema = z
.object({
@@ -148,6 +149,8 @@ const storedApplicationSettingsSchema = applicationSettingsSchema
})
.strict()
const versionElevenStoredApplicationSettingsSchema = storedApplicationSettingsSchema.extend({ version: z.literal(11) })
type StoredApplicationSettings = z.infer<typeof storedApplicationSettingsSchema>
export type LegacyLocalToolEnvironmentPaths = {
@@ -234,7 +237,9 @@ export const defaultApplicationSettings: ApplicationSettings = {
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
applicationNavigation: defaultApplicationNavigation,
localInferenceEnabled: true,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
@@ -245,18 +250,26 @@ export class ApplicationSettingsStore {
private settingsLoad?: Promise<StoredApplicationSettings>
private warnings: SettingsWarning[] = []
private updateQueue: Promise<void> = Promise.resolve()
private readonly listeners = new Set<(settings: ApplicationSettings) => void>()
constructor(
private readonly filePath: string,
private readonly legacyLocalToolEnvironmentResolver: LegacyLocalToolEnvironmentResolver = resolveLegacyLocalToolEnvironmentPaths
) {}
onChanged(listener: (settings: ApplicationSettings) => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
private async migrateLegacy(
settings: Omit<
StoredApplicationSettings,
| 'conversationHtmlRenderingEnabled'
| 'localToolEnvironment'
| 'version'
| 'applicationNavigation'
| 'localInferenceEnabled'
> & {
conversationHtmlRenderingEnabled?: boolean
version: number
@@ -271,6 +284,7 @@ export class ApplicationSettingsStore {
? { source: 'custom', executablePath }
: { source: 'managed' }
const next: StoredApplicationSettings = {
...defaultApplicationSettings,
...settings,
conversationHtmlRenderingEnabled:
settings.conversationHtmlRenderingEnabled ?? true,
@@ -328,6 +342,12 @@ export class ApplicationSettingsStore {
)
const result = storedApplicationSettingsSchema.safeParse(parsed)
if (!result.success) {
const versionElevenResult = versionElevenStoredApplicationSettingsSchema.safeParse(parsed)
if (versionElevenResult.success) {
const next: StoredApplicationSettings = { ...versionElevenResult.data, version: CURRENT_SETTINGS_VERSION }
await this.persist(next)
return next
}
const versionTenResult =
versionTenStoredApplicationSettingsSchema.safeParse(parsed)
if (versionTenResult.success) {
@@ -437,7 +457,7 @@ export class ApplicationSettingsStore {
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
@@ -482,6 +502,8 @@ export class ApplicationSettingsStore {
conversationHtmlRenderingEnabled:
stored.conversationHtmlRenderingEnabled,
remoteProjectsEnabled: stored.remoteProjectsEnabled,
applicationNavigation: stored.applicationNavigation,
localInferenceEnabled: stored.localInferenceEnabled,
magicNotesEnabled: stored.magicNotesEnabled,
magicNotesShowIncompleteTodoCount:
stored.magicNotesShowIncompleteTodoCount,
@@ -511,7 +533,7 @@ export class ApplicationSettingsStore {
}
await this.persist(next)
this.warnings = []
return {
const settings: ApplicationSettings = {
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
@@ -519,12 +541,16 @@ export class ApplicationSettingsStore {
conversationHtmlRenderingEnabled:
next.conversationHtmlRenderingEnabled,
remoteProjectsEnabled: next.remoteProjectsEnabled,
applicationNavigation: next.applicationNavigation,
localInferenceEnabled: next.localInferenceEnabled,
magicNotesEnabled: next.magicNotesEnabled,
magicNotesShowIncompleteTodoCount:
next.magicNotesShowIncompleteTodoCount,
magicNoteCommentMode: next.magicNoteCommentMode,
magicNoteCommentFormat: next.magicNoteCommentFormat
}
for (const listener of this.listeners) listener(settings)
return settings
})
this.updateQueue = operation.then(
() => undefined,
+265 -3
View File
@@ -6,6 +6,7 @@ import {
builtInDefaultProjectSeedName,
activityHistorySnapshotSchema,
assistantIdSchema,
executionStatsInputSchema,
conversationAnsweredQuestionSchema,
conversationMessageSchema,
conversationSnapshotSchema,
@@ -26,6 +27,8 @@ import type {
AssistantProject,
AssistantSchedule,
AssistantTask,
ExecutionStats,
ExecutionStatsInput,
ActivityHistorySnapshot,
ConversationQueueItem,
ConversationAnsweredQuestion,
@@ -4317,6 +4320,230 @@ export class AssistantDatabase {
return rows.map(toTask)
}
getExecutionStats(
input: ExecutionStatsInput,
activeRequestIds: ReadonlySet<string> = new Set()
): ExecutionStats {
const scope = executionStatsInputSchema.parse(input)
const result: ExecutionStats = {
durationMs: 0, requestCount: 0, incompleteRequestCount: 0,
activeRequestCount: 0, asOf: Date.now(), taskDurations: []
}
const database = this.requireDatabase()
const scopeId = 'conversationId' in scope ? scope.conversationId : scope.projectId
const scopeColumn = 'conversationId' in scope ? 'conversation_id' : 'project_id'
const durations = new Map<string, ExecutionStats['taskDurations'][number]>()
const observed = new Set<string>()
const coverage = new Map<string, number>()
const bucket = (conversationId: string | null, owner: string | null): string =>
JSON.stringify([conversationId, owner])
const add = (id: string | null, durationMs: number, incomplete: number): void => {
if (!id || !('projectId' in scope)) return
const entry = durations.get(id) ?? { id, durationMs: 0, incompleteRequestCount: 0 }
entry.durationMs += durationMs
entry.incompleteRequestCount += incomplete
durations.set(id, entry)
}
// Materialize task-level evidence once. Stream/tool payloads are never parsed;
// task_events_task_idx supplies request membership and boundary seeks.
const rows = database.prepare(`
WITH scoped AS MATERIALIZED (
SELECT t.id, t.conversation_id, t.visible, t.started_at, t.remote_recoverable,
COALESCE(parent.id, retained.id) AS schedule_task_id,
space.kind = 'ssh' AS ssh_project,
EXISTS (SELECT 1 FROM messages m
WHERE m.conversation_id = t.conversation_id AND m.request_id = t.id) AS linked,
EXISTS (SELECT 1 FROM task_events proof WHERE proof.task_id = t.id
AND proof.kind IN ('text', 'reasoning', 'tool', 'done', 'error',
'subagent', 'generated-image', 'question')) AS reply_event,
EXISTS (SELECT 1 FROM task_events remote WHERE remote.task_id = t.id
AND remote.remote_operation_id IS NOT NULL) AS remote_event,
(SELECT tail.created_at FROM task_events tail WHERE tail.task_id = t.id
ORDER BY tail.id DESC LIMIT 1) AS last_at
FROM tasks t
LEFT JOIN schedule_runs sr ON sr.id = t.id
LEFT JOIN tasks parent ON parent.schedule_id = sr.schedule_id
LEFT JOIN tasks retained ON retained.id = t.parent_task_id
AND retained.origin = 'schedule' AND retained.visible = 1
LEFT JOIN project_execution_spaces space ON space.project_id = t.project_id
WHERE t.${scopeColumn} = ?
AND t.schedule_id IS NULL AND t.origin != 'subagent'
AND NOT (t.origin = 'schedule' AND t.visible = 1)
)
SELECT scoped.*, e.kind, e.created_at,
CASE WHEN e.kind = 'status'
THEN json_extract(e.payload_json, '$.status') END AS event_status,
CASE WHEN e.kind = 'status' THEN
json_extract(e.payload_json, '$.requestId') = scoped.id
AND json_extract(e.payload_json, '$.type') = 'status'
AND json_type(e.payload_json, '$.message') = 'text'
END AS runtime_status,
CASE WHEN e.kind = 'status' THEN (
SELECT previous.created_at FROM task_events previous
WHERE previous.task_id = scoped.id AND previous.id < e.id
ORDER BY previous.id DESC LIMIT 1
) END AS previous_at
FROM scoped LEFT JOIN task_events e ON e.task_id = scoped.id
AND e.kind IN ('status', 'done', 'error')
ORDER BY scoped.id, e.id
`).iterate(scopeId)
let task: {
id: string; proven: boolean; remote: boolean; incomplete: boolean
start: number | undefined; last: number | undefined; duration: number
conversationId: string | null; owner: string | null
lastEvidence: number
} | undefined
const finish = (): void => {
if (!task?.proven) return
result.requestCount++
observed.add(task.id)
const key = bucket(task.conversationId, task.owner)
coverage.set(key, (coverage.get(key) ?? 0) + 1)
if (activeRequestIds.has(task.id)) result.activeRequestCount++
if (task.remote) {
task.duration = 0
task.incomplete = true
} else if (task.start !== undefined) {
if (Number.isFinite(task.lastEvidence) && task.lastEvidence >= task.start && task.lastEvidence <= result.asOf) {
task.last = Math.max(task.last ?? task.start, task.lastEvidence)
}
const live = activeRequestIds.has(task.id) && task.start <= result.asOf
task.duration += Math.max(0, (live ? result.asOf : task.last ?? task.start) - task.start)
task.incomplete ||= !live
}
result.durationMs += task.duration
if (task.incomplete) result.incompleteRequestCount++
add(task.owner ?? task.id, task.duration, Number(task.incomplete))
}
for (const raw of rows) {
const row = raw as unknown as {
id: string; started_at: string | null; remote_recoverable: number
linked: number; kind: string | null; created_at: string | null
reply_event: number; remote_event: number; last_at: string | null; previous_at: string | null
event_status: string | null; runtime_status: number | null; ssh_project: number | null
conversation_id: string | null; visible: number; schedule_task_id: string | null
}
if (task?.id !== row.id) {
finish()
const start = Date.parse(row.started_at ?? '')
task = {
id: row.id, proven: row.linked === 1 || row.reply_event === 1 || row.schedule_task_id !== null || activeRequestIds.has(row.id),
remote: row.remote_recoverable === 1 || row.remote_event === 1 || row.ssh_project === 1,
lastEvidence: Date.parse(row.last_at ?? ''),
conversationId: row.conversation_id,
owner: row.schedule_task_id ?? (row.visible === 1 ? row.id : null),
incomplete: !Number.isFinite(start),
start: Number.isFinite(start) ? start : undefined,
last: Number.isFinite(start) ? start : undefined, duration: 0
}
}
const current = task!
current.proven ||= row.runtime_status === 1
if (row.kind === null) continue
const status = row.event_status
// Recovery writes interrupted at restart, not when execution actually stopped.
if (status === 'interrupted') {
if (current.start !== undefined) {
const previous = Date.parse(row.previous_at ?? '')
if (Number.isFinite(previous) && previous >= current.start && previous <= result.asOf) {
current.last = Math.max(current.last ?? current.start, previous)
}
current.duration += Math.max(0, (current.last ?? current.start) - current.start)
}
current.start = undefined
current.last = undefined
current.incomplete = true
continue
}
const time = Date.parse(row.created_at ?? '')
if (!Number.isFinite(time) || time > result.asOf || (current.last !== undefined && time < current.last)) {
current.incomplete = true
continue
}
if (status === 'running' && current.start === undefined) current.start = time
const closes = row.kind === 'done' || row.kind === 'error' ||
['completed', 'failed', 'cancelled', 'paused', 'queued', 'idle'].includes(status ?? '')
if (closes && current.start !== undefined) {
current.duration += time - current.start
current.start = undefined
}
current.last = time
}
finish()
// A schedule run has an independent request ID. Its stable task is never a clock.
for (const row of database.prepare(`
SELECT t.id, t.conversation_id, t.origin, t.schedule_id,
sr.id AS run_id, sr.status AS run_status
FROM tasks t LEFT JOIN schedule_runs sr ON sr.schedule_id = t.schedule_id
WHERE t.${scopeColumn} = ? AND t.visible = 1 AND t.origin != 'subagent'
ORDER BY t.id, sr.id
`).iterate(scopeId)) {
const id = row.id as string
add(id, 0, 0)
const key = bucket(row.conversation_id as string | null, id)
// A removed plan without retained execution links cannot establish a zero.
if (row.origin === 'schedule' && row.schedule_id === null && !coverage.has(key)) {
result.requestCount++
result.incompleteRequestCount++
add(id, 0, 1)
coverage.set(key, 1)
}
if (row.run_id && ['completed', 'failed', 'running'].includes(row.run_status as string) && !observed.has(row.run_id as string)) {
observed.add(row.run_id as string)
result.requestCount++
result.incompleteRequestCount++
add(id, 0, 1)
coverage.set(key, (coverage.get(key) ?? 0) + 1)
}
}
// Local snapshots lack request IDs. Compare top-level assistant rows per
// conversation/task, never nested subagent blocks or child-task messages.
const messages = database.prepare(`
SELECT m.conversation_id, m.request_id,
COALESCE(parent.id, retained.id, json_extract(m.metadata_json, '$.task.id'),
CASE WHEN mt.visible = 1 THEN mt.id END) AS owner,
COUNT(*) AS count
FROM messages m JOIN conversations c ON c.id = m.conversation_id
LEFT JOIN tasks mt ON mt.id = m.request_id
LEFT JOIN schedule_runs sr ON sr.id = m.request_id
LEFT JOIN tasks parent ON parent.schedule_id = sr.schedule_id
LEFT JOIN tasks retained ON retained.id = mt.parent_task_id
AND retained.origin = 'schedule' AND retained.visible = 1
WHERE ${'conversationId' in scope ? 'm.conversation_id' : 'c.project_id'} = ?
AND m.role = 'assistant'
AND NOT (c.channel IS NULL AND m.sequence = 0 AND m.request_id IS NULL
AND json_extract(m.metadata_json, '$.task.id') IS NULL
AND m.content IN (?, ?))
AND COALESCE(mt.origin, '') != 'subagent'
AND NOT EXISTS (SELECT 1 FROM tasks child WHERE child.origin = 'subagent'
AND child.id = json_extract(m.metadata_json, '$.task.id'))
GROUP BY m.conversation_id, owner, m.request_id
ORDER BY m.request_id IS NULL
`).all(
scopeId,
// Persisted greetings have no marker. Match only shipped default copy,
// not arbitrary assistant-only/imported history or localized prefixes.
'你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件,或使用知识库整理和检索信息。需要我操作文件或调用工具时,请选择合适的 Agent Runtime 和工作模式。',
'Hi, I’m GoodBuddy. Ask me a question, add local files, or use your knowledge base to organize and retrieve information. When you want me to operate on files or use tools, choose the appropriate Agent Runtime and work mode.'
)
for (const row of messages) {
const owner = row.owner as string | null
const key = bucket(row.conversation_id as string, owner)
const available = coverage.get(key) ?? 0
const linked = row.request_id as string | null
const count = linked ? 1 : row.count as number
const covered = linked ? Number(observed.has(linked)) : Math.min(count, available)
coverage.set(key, Math.max(0, available - covered))
const missing = count - covered
result.requestCount += missing
result.incompleteRequestCount += missing
add(owner, 0, missing)
}
result.taskDurations = [...durations.values()]
return result
}
getActivityHistory(): ActivityHistorySnapshot {
const row = this.requireDatabase()
.prepare(
@@ -4424,13 +4651,18 @@ export class AssistantDatabase {
work_mode, progress, created_at, started_at, visible,
remote_recoverable, current_user_message_id,
current_assistant_message_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?, ?,
VALUES (?, ?, ?, COALESCE((
SELECT parent.id FROM schedule_runs sr
JOIN tasks parent ON parent.schedule_id = sr.schedule_id
WHERE sr.id = ?
), ?), ?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?, ?,
?, ?, ?)`
)
.run(
taskId,
input.projectId ?? null,
input.conversationId ?? null,
taskId,
input.parentTaskId ?? null,
input.expertId ?? null,
input.routingMode ?? null,
@@ -6486,7 +6718,7 @@ export class AssistantDatabase {
(id, project_id, task_template_json, timezone, recurrence_json,
next_run_at, missed_run_policy, enabled, last_run_at,
created_at, updated_at)
VALUES (?, ?, ?, 'UTC', ?, ?, 'run_once', 1, NULL, ?, ?)`
VALUES (?, ?, ?, 'UTC', ?, ?, 'run_once', ?, ?, ?, ?)`
)
.run(
scheduleId,
@@ -6497,7 +6729,11 @@ export class AssistantDatabase {
workMode: parsed.workMode
}),
JSON.stringify({ type: parsed.recurrence }),
parsed.nextRunAt,
parsed.runImmediately ? now : parsed.nextRunAt,
parsed.runImmediately ? 0 : 1,
// Immediate creation consumes the once trigger even if its queue item
// is cancelled. Further execution must use an explicit manual run.
parsed.runImmediately ? now : null,
now,
now
)
@@ -6521,6 +6757,22 @@ export class AssistantDatabase {
parsed.workMode,
now
)
if (parsed.runImmediately) {
// Commit the task and its only automatic occurrence together. The timer
// must never see an enabled immediate schedule between create and queue.
const runId = randomUUID()
database.prepare(
`INSERT INTO schedule_runs
(id, schedule_id, scheduled_for, task_id, status)
VALUES (?, ?, ?, ?, 'pending')`
).run(runId, scheduleId, now, taskId)
database.prepare(
`INSERT INTO conversation_queue_items
(id, conversation_id, source, label, payload_json,
schedule_run_id, schedule_id, task_id, status, created_at)
VALUES (?, ?, 'schedule', ?, '{}', ?, ?, ?, 'pending', ?)`
).run(runId, conversationId, parsed.title, runId, scheduleId, taskId, now)
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
@@ -6605,6 +6857,16 @@ export class AssistantDatabase {
if (activeRun) {
throw new Error('定时任务正在运行,完成后才能删除计划')
}
// Preserve older requests' proven run ownership before the cascade removes it.
database.prepare(`
UPDATE tasks AS request SET parent_task_id = (
SELECT parent.id FROM schedule_runs sr
JOIN tasks parent ON parent.schedule_id = sr.schedule_id
WHERE sr.id = request.id AND sr.schedule_id = ?
)
WHERE request.parent_task_id IS NULL
AND request.id IN (SELECT id FROM schedule_runs WHERE schedule_id = ?)
`).run(scheduleId, scheduleId)
database
.prepare(
`UPDATE tasks
@@ -0,0 +1,390 @@
import { randomUUID } from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { performance } from 'node:perf_hooks'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AssistantDatabase } from './assistant-database'
describe('execution duration evidence', () => {
let directory: string
let database: AssistantDatabase
let projectId: string
let conversationId: string
const epoch = Date.parse('2026-09-17T00:00:00Z')
const at = (seconds: number): void => { vi.setSystemTime(epoch + seconds * 1000) }
const request = (overrides: Partial<Parameters<AssistantDatabase['createTask']>[0]> = {}): string => {
const id = randomUUID()
database.createTask({ id, projectId, conversationId, title: 'Reply', instructions: 'Reply', workMode: 'ask', visible: false, ...overrides })
return id
}
const text = (id: string): void => database.appendTaskEvent(id, 'text', { requestId: id, type: 'text', delta: 'Reply' })
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'goodbuddy-execution-stats-'))
database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
database.initialize(directory)
projectId = database.listProjects()[0]!.id
conversationId = randomUUID()
vi.useFakeTimers({ toFake: ['Date'] })
at(0)
})
afterEach(async () => {
database.close()
vi.useRealTimers()
await rm(directory, { recursive: true, force: true })
})
it('sums hidden replies without round idle, counts approval/tool time, and scopes projects', () => {
const first = request()
text(first)
at(2)
database.updateTaskStatus(first, 'waiting_approval')
at(8)
database.updateTaskStatus(first, 'running')
at(10)
database.updateTaskStatus(first, 'completed')
at(100)
const second = request()
text(second)
at(105)
database.updateTaskStatus(second, 'failed')
const anotherConversation = request({ conversationId: randomUUID() })
text(anotherConversation)
at(108)
database.updateTaskStatus(anotherConversation, 'cancelled')
const unrelated = request({ projectId: undefined, conversationId: randomUUID() })
text(unrelated)
at(120)
database.updateTaskStatus(unrelated, 'completed')
expect(database.listTasks()).toEqual([])
expect(database.getExecutionStats({ conversationId })).toEqual({
durationMs: 15000, requestCount: 2, incompleteRequestCount: 0,
activeRequestCount: 0, asOf: epoch + 120000, taskDurations: []
})
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 18000, requestCount: 3 })
expect(database.getExecutionStats({ conversationId: randomUUID() })).toMatchObject({ durationMs: 0, requestCount: 0 })
})
it('excludes stable schedules, nested experts, and status-only maintenance but includes scheduled reply requests', () => {
const schedule = database.createSchedule({ projectId, title: 'Schedule', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
database.updateTaskStatus(schedule.taskId, 'running')
text(schedule.taskId)
const child = request({ origin: 'subagent', parentTaskId: schedule.taskId })
text(child)
const compact = request({ title: 'Compressed context' })
const actual = request({ conversationId: schedule.conversationId, parentTaskId: schedule.taskId })
text(actual)
at(10)
for (const id of [schedule.taskId, child, compact, actual]) database.updateTaskStatus(id, 'completed')
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 10000, requestCount: 1, incompleteRequestCount: 0 })
})
it('uses message request identity when a reply has no runtime events and does not use message timestamps', () => {
const id = request()
database.saveLocalConversations([{
header: { id: conversationId, projectId, title: 'Chat', updatedAt: epoch },
messages: [{ id: randomUUID(), role: 'assistant', content: '', state: 'error', createdAt: epoch - 86400000 }]
}])
// Only persisted request links are evidence; local snapshot saves do not supply them.
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
raw.prepare('UPDATE messages SET request_id = ? WHERE conversation_id = ?').run(id, conversationId)
raw.close()
at(3)
database.updateTaskStatus(id, 'failed')
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 3000, requestCount: 1, incompleteRequestCount: 0 })
})
it('keeps only evidenced time for open and restart-interrupted replies', () => {
const id = request()
at(5)
text(id)
at(100)
expect(database.getExecutionStats({ conversationId }, new Set([id]))).toMatchObject({ durationMs: 100000, requestCount: 1, incompleteRequestCount: 0, activeRequestCount: 1 })
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, incompleteRequestCount: 1, activeRequestCount: 0 })
database.close()
at(86400)
database.initialize(directory)
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1, activeRequestCount: 0 })
// A later resumed segment cannot include the shutdown/restart gap.
at(86500)
database.updateTaskStatus(id, 'running')
at(86510)
database.updateTaskStatus(id, 'completed')
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 15000, incompleteRequestCount: 1 })
})
it('segments repeated runs and pauses without using the final completed_at span', () => {
const id = request()
text(id)
at(5)
database.updateTaskStatus(id, 'paused')
at(100)
database.updateTaskStatus(id, 'running')
at(107)
database.updateTaskStatus(id, 'completed')
at(200)
database.updateTaskStatus(id, 'running')
at(203)
database.updateTaskStatus(id, 'completed')
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 15000, requestCount: 1, incompleteRequestCount: 0 })
})
it('does not substitute remote replay receipt time or unknown starts for actual execution time', () => {
const remote = request()
at(86400)
database.appendRemoteTaskEventOnce({ taskId: remote, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'done', payload: { requestId: remote, type: 'done' } })
database.updateTaskStatus(remote, 'completed')
const unknown = request()
text(unknown)
at(86410)
database.updateTaskStatus(unknown, 'completed')
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
raw.prepare('UPDATE tasks SET started_at = NULL WHERE id = ?').run(unknown)
raw.close()
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 0, requestCount: 2, incompleteRequestCount: 2 })
})
it('includes all retained requests beyond list pagination', () => {
for (let index = 0; index < 105; index++) {
at(index * 10)
const id = request()
text(id)
at(index * 10 + 1)
database.updateTaskStatus(id, 'completed')
}
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 105000, requestCount: 105, incompleteRequestCount: 0 })
})
it('groups real schedule request IDs into stable cards across runs without parent lifecycle time', () => {
const schedule = database.createSchedule({ projectId, title: 'Schedule', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
const idle = database.createSchedule({ projectId, conversationId: schedule.conversationId, title: 'Idle', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch + 86400000).toISOString() })
for (const start of [100, 1000]) {
at(start)
const queued = database.queueScheduleNow(schedule.id)
database.claimConversationQueueItem(schedule.conversationId)
database.completeConversationUserQueueItem(queued.id)
// Production agentRun uses scheduleRunId as requestId, without parentTaskId.
const id = queued.scheduleRunId!
request({ id, conversationId: schedule.conversationId })
database.updateTaskStatus(schedule.taskId, 'running')
text(id)
at(start + 10)
database.updateTaskStatus(id, 'completed')
database.completeTaskScheduleRun(id)
}
const live = database.queueScheduleNow(schedule.id)
database.claimConversationQueueItem(schedule.conversationId)
request({ id: live.scheduleRunId!, conversationId: schedule.conversationId })
text(live.scheduleRunId!)
at(1025)
const stats = database.getExecutionStats({ projectId }, new Set([live.scheduleRunId!]))
expect(stats).toMatchObject({ durationMs: 35000, requestCount: 3, incompleteRequestCount: 0, activeRequestCount: 1 })
expect(stats.taskDurations).toEqual(expect.arrayContaining([
{ id: schedule.taskId, durationMs: 35000, incompleteRequestCount: 0 },
{ id: idle.taskId, durationMs: 0, incompleteRequestCount: 0 }
]))
expect(stats.taskDurations.some(item => item.id === live.scheduleRunId)).toBe(false)
})
it.each([false, true])('retains schedule execution totals after plan deletion (repair old link: %s)', (oldLink) => {
const schedule = database.createSchedule({ projectId, title: 'Removable plan', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
at(100)
const run = database.queueScheduleNow(schedule.id)
database.claimConversationQueueItem(schedule.conversationId)
const task = database.createTask({ id: run.scheduleRunId!, projectId, conversationId: schedule.conversationId, title: 'Run', instructions: 'Reply', workMode: 'ask', visible: false })
expect(task.parentTaskId).toBe(schedule.taskId)
text(task.id)
at(110)
database.updateTaskStatus(task.id, 'completed')
database.completeTaskScheduleRun(task.id)
if (oldLink) {
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
raw.prepare('UPDATE tasks SET parent_task_id = NULL WHERE id = ?').run(task.id)
raw.close()
}
const before = database.getExecutionStats({ projectId })
at(10000)
database.removeSchedule(schedule.id)
expect(database.getExecutionStats({ projectId })).toEqual({ ...before, asOf: epoch + 10000000 })
expect(before.taskDurations).toEqual([{ id: schedule.taskId, durationMs: 10000, incompleteRequestCount: 0 }])
database.close()
database.initialize(directory)
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual(before.taskDurations)
})
it('does not claim a complete zero for an already removed plan with no retained execution relation', () => {
const schedule = database.createSchedule({ projectId, title: 'Old removed plan', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
database.removeSchedule(schedule.id)
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual([{ id: schedule.taskId, durationMs: 0, incompleteRequestCount: 1 }])
})
it('recognizes runtime status-only replies but not maintenance statuses at restart', () => {
const id = request()
const maintenance = request({ title: 'Maintenance' })
at(5)
database.appendTaskEvent(id, 'status', { requestId: id, type: 'status', message: 'Processing request' })
database.updateTaskStatus(maintenance, 'running')
database.close()
at(10000)
database.initialize(directory)
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
})
it('marks SSH channel timing unknown before the first remote provenance and keeps that result on replay', () => {
const project = database.createSshProject({
project: { name: 'SSH project', description: '', rootPath: '/srv/workspace', defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' } },
executionSpace: { kind: 'ssh', hostId: randomUUID(), remoteRootPath: '/srv/workspace' },
assertCurrent: () => {}
})
const conversation = database.getOrCreateRemoteConversation({ projectId: project.id, channel: 'wecom', accountId: 'account', externalConversationId: 'external', conversationType: 'direct', title: 'SSH channel', accountDisplay: 'Account' })
const id = request({ projectId: project.id, conversationId: conversation.id, origin: 'delegation' })
at(5)
database.appendTaskEvent(id, 'status', { requestId: id, type: 'status', message: 'Remote Agent is processing' })
const expected = { durationMs: 0, requestCount: 1, incompleteRequestCount: 1, activeRequestCount: 1 }
expect(database.getExecutionStats({ projectId: project.id }, new Set([id]))).toMatchObject(expected)
at(100)
database.appendRemoteTaskEventOnce({ taskId: id, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'text', payload: { requestId: id, type: 'text', delta: 'Reply' } })
expect(database.getExecutionStats({ projectId: project.id }, new Set([id]))).toMatchObject(expected)
database.updateTaskStatus(id, 'completed')
expect(database.getExecutionStats({ conversationId: conversation.id })).toMatchObject({ ...expected, activeRequestCount: 0 })
})
it('marks retained assistant replies without request history incomplete and ignores nested subagent content', () => {
const known = request()
text(known)
at(2)
database.updateTaskStatus(known, 'completed')
const child = request({ origin: 'subagent', parentTaskId: known })
text(child)
database.updateTaskStatus(child, 'completed')
const childMessage = randomUUID()
database.saveLocalConversations([{
header: { id: conversationId, projectId, title: 'Retained', updatedAt: epoch },
messages: [
{ id: randomUUID(), role: 'assistant', content: 'Known reply', state: 'complete', createdAt: epoch, subagents: [] },
{ id: randomUUID(), role: 'assistant', content: 'Older reply without a task', state: 'complete', createdAt: epoch },
{ id: childMessage, role: 'assistant', content: 'Child reply', state: 'complete', createdAt: epoch }
]
}])
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
raw.prepare('UPDATE messages SET request_id = ? WHERE id = ?').run(child, childMessage)
raw.close()
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 2000, requestCount: 2, incompleteRequestCount: 1 })
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 2000, requestCount: 2, incompleteRequestCount: 1 })
})
it('flags missing schedule execution once even when its assistant message is retained', () => {
const schedule = database.createSchedule({ projectId, title: 'Lost run', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
const queued = database.queueScheduleNow(schedule.id)
database.claimConversationQueueItem(schedule.conversationId)
database.completeScheduleRun(queued.scheduleRunId!, 'completed')
database.saveLocalConversations([{
header: { id: schedule.conversationId, projectId, title: 'Lost run', updatedAt: epoch },
messages: [{ id: randomUUID(), role: 'assistant', content: 'Retained result', state: 'complete', createdAt: epoch, task: { id: schedule.taskId, title: 'Lost run' } }]
}])
expect(database.getExecutionStats({ projectId })).toMatchObject({
durationMs: 0, requestCount: 1, incompleteRequestCount: 1,
taskDurations: [{ id: schedule.taskId, durationMs: 0, incompleteRequestCount: 1 }]
})
})
it('cannot make a remote or missing-start request complete just because it has a live lease', () => {
const remote = request()
database.appendRemoteTaskEventOnce({ taskId: remote, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'text', payload: { requestId: remote, type: 'text', delta: 'Remote' } })
const unknown = request()
text(unknown)
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
raw.prepare('UPDATE tasks SET started_at = NULL WHERE id = ?').run(unknown)
raw.close()
at(100)
expect(database.getExecutionStats({ conversationId }, new Set([remote, unknown]))).toMatchObject({ durationMs: 0, incompleteRequestCount: 2, activeRequestCount: 2 })
})
it('counts confirmed live replies before their first event without counting maintenance leases', () => {
const id = request()
request({ title: 'Context compression' })
at(10)
expect(database.getExecutionStats({ conversationId }, new Set([id]))).toMatchObject({ durationMs: 10000, requestCount: 1, incompleteRequestCount: 0, activeRequestCount: 1 })
})
it('reports wholly missing reply history and task attribution without using message timestamps', () => {
const schedule = database.createSchedule({ projectId, title: 'Old task', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
database.saveLocalConversations([{
header: { id: schedule.conversationId, projectId, title: 'Old conversation', updatedAt: epoch },
messages: [{ id: randomUUID(), role: 'assistant', content: 'Old reply', state: 'complete', createdAt: epoch - 100000, task: { id: schedule.taskId, title: 'Old task' } }]
}])
expect(database.getExecutionStats({ conversationId: schedule.conversationId })).toMatchObject({ durationMs: 0, requestCount: 1, incompleteRequestCount: 1 })
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual([{ id: schedule.taskId, durationMs: 0, incompleteRequestCount: 1 }])
})
it('does not let request coverage in another conversation conceal missing replies', () => {
const id = request()
text(id)
at(1)
database.updateTaskStatus(id, 'completed')
database.saveLocalConversations([{
header: { id: randomUUID(), projectId, title: 'Other conversation', updatedAt: epoch },
messages: [{ id: randomUUID(), role: 'assistant', content: 'Missing history', state: 'complete', createdAt: epoch }]
}])
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 1000, requestCount: 2, incompleteRequestCount: 1 })
})
it.each([
['zh-CN', '你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件,或使用知识库整理和检索信息。需要我操作文件或调用工具时,请选择合适的 Agent Runtime 和工作模式。'],
['en-US', 'Hi, I’m GoodBuddy. Ask me a question, add local files, or use your knowledge base to organize and retrieve information. When you want me to operate on files or use tools, choose the appropriate Agent Runtime and work mode.']
])('excludes the persisted default greeting but retains unknown reply history (%s)', async (locale, greeting) => {
expect(await readFile(join(process.cwd(), 'src/renderer/src/i18n/locales', locale, 'app.ts'), 'utf8')).toContain(greeting)
const header = { id: conversationId, projectId, title: 'New conversation', updatedAt: epoch }
const welcome = { id: randomUUID(), role: 'assistant' as const, content: greeting, state: 'complete' as const, createdAt: epoch }
database.saveLocalConversations([{ header, messages: [welcome] }])
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 0, requestCount: 0, incompleteRequestCount: 0 })
database.saveLocalConversations([{ header, messages: [welcome,
{ id: randomUUID(), role: 'user', content: 'Question', state: 'complete', createdAt: epoch + 1 },
{ id: randomUUID(), role: 'assistant', content: greeting, state: 'complete', createdAt: epoch + 2 }
] }])
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 0, requestCount: 1, incompleteRequestCount: 1 })
})
it('seeks interruption boundaries without parsing large tool/delta payloads on repeated scope queries', () => {
const id = request()
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
const prepare = vi.spyOn(DatabaseSync.prototype, 'prepare')
try {
const insert = raw.prepare('INSERT INTO task_events (task_id, kind, payload_json, created_at) VALUES (?, ?, ?, ?)')
const payload = JSON.stringify({ requestId: id, output: 'x'.repeat(32768) })
raw.exec('BEGIN')
for (let index = 0; index < 3000; index++) {
insert.run(id, index % 2 ? 'text' : 'tool', payload, new Date(epoch + 5000).toISOString())
}
raw.exec('COMMIT')
at(100)
database.updateTaskStatus(id, 'interrupted')
const baseline = raw.prepare("SELECT SUM(length(json_extract(payload_json, '$.requestId'))) FROM task_events WHERE task_id = ?")
const baselineStart = performance.now()
baseline.get(id)
baseline.get(id)
const baselineMs = performance.now() - baselineStart
// One visible-panel tick reads both scopes; repeat to exercise warm polling.
const start = performance.now()
for (let iteration = 0; iteration < 5; iteration++) {
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
}
const pairMs = (performance.now() - start) / 5
const query = prepare.mock.calls.find(([sql]) => sql.includes('WITH scoped AS MATERIALIZED'))![0]
const plan = raw.prepare(`EXPLAIN QUERY PLAN ${query}`).all(projectId).map(row => row.detail as string)
for (const alias of ['proof', 'remote', 'tail', 'previous', 'e']) {
expect(plan.some(detail => detail.includes(`SEARCH ${alias} USING INDEX task_events_task_idx`))).toBe(true)
}
// Malformed bodies make accidental JSON extraction fail deterministically.
raw.prepare("UPDATE task_events SET payload_json = '{unparsed' WHERE task_id = ? AND kind IN ('text', 'tool')").run(id)
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
console.info(`execution stats: 3000 events, ${Math.round(payload.length * 3000 / 1024 / 1024)} MiB payload; old JSON extraction pair ${baselineMs.toFixed(1)} ms; scalar scope pair ${pairMs.toFixed(1)} ms`)
} finally {
prepare.mockRestore()
raw.close()
}
})
})
@@ -76,7 +76,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
database.close()
const raw = new DatabaseSync(path)
raw.exec('PRAGMA user_version = 2')
raw.exec('ALTER TABLE conversations DROP COLUMN pinned; PRAGMA user_version = 2')
raw.close()
const migrated = new AssistantDatabase(path)
@@ -128,7 +128,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
'DELETE FROM heartbeat_config_projects WHERE config_id = ?'
)
.run(config.id)
raw.exec('PRAGMA user_version = 20')
raw.exec('ALTER TABLE conversations DROP COLUMN pinned; PRAGMA user_version = 20')
raw.close()
const migrated = new AssistantDatabase(path)
@@ -0,0 +1,138 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { describe, expect, it } from 'vitest'
import { AssistantDatabase } from './assistant-database'
const input = {
title: 'Immediate task',
prompt: 'Run exactly once',
recurrence: 'once' as const,
nextRunAt: '2020-01-01T00:00:00.000Z',
runImmediately: true
}
describe('immediate schedule creation', () => {
it('commits one real queue occurrence and never rematerializes it on scheduler ticks', () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
try {
const schedule = database.createSchedule(input)
expect(schedule.enabled).toBe(false)
expect(schedule).not.toHaveProperty('runImmediately')
const [item] = database.listConversationQueueItems(schedule.conversationId)
expect(item).toMatchObject({ scheduleId: schedule.id, taskId: schedule.taskId })
expect(database.listTasks().find(task => task.id === schedule.taskId)?.status).toBe('queued')
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
const claimed = database.claimConversationQueueItem(schedule.conversationId)
expect(claimed).toMatchObject({ source: 'schedule', runId: item!.id })
expect(database.claimConversationQueueItem(schedule.conversationId)).toBeUndefined()
database.completeScheduleRun(item!.id, 'completed')
expect(database.listTasks().find(task => task.id === schedule.taskId)?.status).toBe('completed')
expect(database.listSchedules()[0]).toMatchObject({ enabled: false, lastRunAt: expect.any(String) })
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
expect(database.listConversationQueueItems()).toEqual([])
} finally {
database.close()
}
})
it('rolls back conversation, task and schedule when queue insertion fails, so retry creates only one', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-schedule-create-'))
const path = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(path)
database.initialize(process.cwd())
const sql = new DatabaseSync(path)
try {
sql.exec(`CREATE TRIGGER reject_queue BEFORE INSERT ON conversation_queue_items
BEGIN SELECT RAISE(ABORT, 'queue unavailable'); END;`)
expect(() => database.createSchedule(input)).toThrow('queue unavailable')
expect(database.listSchedules()).toEqual([])
expect(database.listTasks()).toEqual([])
expect(database.listConversations()).toEqual([])
expect(sql.prepare('SELECT COUNT(*) AS count FROM schedule_runs').get()?.count).toBe(0)
sql.exec('DROP TRIGGER reject_queue')
database.createSchedule(input)
expect(database.listSchedules()).toHaveLength(1)
expect(database.listTasks()).toHaveLength(1)
expect(database.listConversations()).toHaveLength(1)
expect(database.listConversationQueueItems()).toHaveLength(1)
} finally {
sql.close()
database.close()
await rm(directory, { recursive: true, force: true })
}
})
it('consumes the immediate trigger even when cancelled and requires an explicit new run', () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
try {
const schedule = database.createSchedule(input)
expect(schedule.lastRunAt).toBe(schedule.nextRunAt)
const [item] = database.listConversationQueueItems(schedule.conversationId)
database.cancelConversationQueueItem(item!.id)
expect(database.listSchedules()[0]).toMatchObject({
enabled: false, lastRunAt: schedule.lastRunAt
})
expect(database.getTask(schedule.taskId).status).toBe('cancelled')
expect(() => database.setScheduleEnabled(schedule.id, true)).toThrow(
'已执行的一次性计划不能恢复自动运行'
)
expect(database.getTask(schedule.taskId).status).toBe('cancelled')
const tomorrow = new Date(Date.parse(schedule.nextRunAt) + 86_400_000)
expect(database.queueDueSchedules(tomorrow)).toEqual([])
const retry = database.queueScheduleNow(schedule.id)
expect(retry.id).not.toBe(item!.id)
expect(database.listSchedules()).toHaveLength(1)
expect(database.listConversationQueueItems()).toHaveLength(1)
expect(database.getTask(schedule.taskId).status).toBe('queued')
expect(database.claimConversationQueueItem(schedule.conversationId)).toMatchObject({
runId: retry.id
})
database.completeScheduleRun(retry.id, 'completed')
expect(database.getTask(schedule.taskId).status).toBe('completed')
expect(database.queueDueSchedules(tomorrow)).toEqual([])
} finally {
database.close()
}
})
it.each(['once', 'daily', 'weekly'] as const)('preserves pause and manual-run cancellation for a future %s schedule', (recurrence) => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
try {
const nextRunAt = new Date(Date.now() + 86_400_000).toISOString()
const schedule = database.createSchedule({
...input, runImmediately: false, recurrence, nextRunAt
})
database.setScheduleEnabled(schedule.id, false)
const manual = database.queueScheduleNow(schedule.id)
database.cancelConversationQueueItem(manual.id)
expect(database.listSchedules()[0]).toMatchObject({
enabled: false, lastRunAt: undefined, nextRunAt
})
expect(database.queueDueSchedules(new Date(nextRunAt))).toEqual([])
database.setScheduleEnabled(schedule.id, true)
expect(database.queueDueSchedules(new Date(nextRunAt))).toHaveLength(1)
} finally {
database.close()
}
})
it('keeps scheduled creation enabled and rejects recurring immediate requests', () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
try {
expect(() => database.createSchedule({ ...input, recurrence: 'daily' })).toThrow('立即执行只支持单次任务')
const schedule = database.createSchedule({ ...input, runImmediately: false })
expect(schedule.enabled).toBe(true)
expect(database.listConversationQueueItems()).toEqual([])
expect(database.queueDueSchedules()).toHaveLength(1)
} finally {
database.close()
}
})
})
+3 -2
View File
@@ -1,4 +1,5 @@
import type { BrowserWindow } from 'electron'
import { localInferenceService } from './local-inference-service'
import { ipcChannels } from '../shared/ipc-channels'
import {
documentOcrFailureSchema,
@@ -59,7 +60,7 @@ export class DocumentOcrBroker {
1_000
)
)
return new Promise<DocumentOcrResult>((resolve, reject) => {
return localInferenceService.run('ocr', '文档解析', () => new Promise<DocumentOcrResult>((resolve, reject) => {
const onAbort = (): void =>
this.cancelRequest(request.requestId, 'OCR 解析已取消')
signal?.addEventListener('abort', onAbort, { once: true })
@@ -78,7 +79,7 @@ export class DocumentOcrBroker {
return
}
this.dispatchNext()
})
}), { model: request.modelId, cancelUnavailableReason: '现有 OCR 取消会终止共享 Worker;管理页不将服务释放用作单任务取消。' })
}
respond(input: unknown): void {
+17
View File
@@ -96,6 +96,23 @@ afterEach(async () => {
})
describe('GoodBuddyConfigService', () => {
it('publishes persisted application changes from config apply through the settings event', async () => {
const { application, service, workspace } = await createHarness()
const changed = vi.fn()
const unsubscribe = application.onChanged(changed)
const current = await application.get()
const applicationNavigation = { ...current.applicationNavigation, order: [...current.applicationNavigation.order].reverse() }
const plan = await service.plan('settings-sync', workspace, {
operations: [{ operation: 'application.update', updates: { applicationNavigation } }],
})
expect(changed).not.toHaveBeenCalled()
await service.apply('settings-sync', { planId: plan.planId }, new AbortController().signal, async () => true)
expect(changed).toHaveBeenCalledExactlyOnceWith(await application.get())
expect(changed.mock.calls[0]![0].applicationNavigation).toEqual(applicationNavigation)
unsubscribe()
await application.update({ localInferenceEnabled: false })
expect(changed).toHaveBeenCalledOnce()
})
it('returns only sanitized configuration and publishes common examples', async () => {
const { service, capabilities } = await createHarness()
await capabilities.saveMcpServer(undefined, {
+67 -53
View File
@@ -1,3 +1,5 @@
import { localInferenceService } from './local-inference-service'
import { createEmbeddingUtilityTransport } from './knowledge/embedding-utility-transport'
import {
app,
BrowserWindow,
@@ -45,10 +47,7 @@ import { OpenAIEmbeddingClient } from './knowledge/openai-embedding-client'
import { embeddingProviderFingerprint } from './knowledge/embedding-provider-key'
import type { EmbeddingProvider } from './knowledge/types'
import { EmbeddingModelManager } from './knowledge/embedding-model-manager'
import {
EmbeddingInferenceBroker,
type EmbeddingInferenceTransport
} from './knowledge/embedding-inference-broker'
import { EmbeddingInferenceBroker } from './knowledge/embedding-inference-broker'
import { CohereRerankClient } from './knowledge/cohere-rerank-client'
import { RuntimeSettingsStore } from './runtime-settings-store'
import type { ResolvedRuntimeSettings } from './runtime-settings-store'
@@ -220,6 +219,7 @@ let documentOcrBroker: DocumentOcrBroker | undefined
let documentOcrModelManager: DocumentOcrModelManager | undefined
let embeddingModelManager: EmbeddingModelManager | undefined
const embeddingBrokers = new Set<EmbeddingInferenceBroker>()
const sharedEmbeddingBrokers = new Map<string, { broker: EmbeddingInferenceBroker; users: number; unregister: () => void }>()
let stopRuntimeReconfiguration: (() => Promise<void>) | undefined
let dshExtensionInstaller: DshNpmExtensionInstaller | undefined
let remoteAgentServices: RemoteAgentServices | undefined
@@ -234,47 +234,6 @@ type ManagedEmbeddingProvider = EmbeddingProvider & {
dispose?: () => void | Promise<void>
}
function createEmbeddingUtilityTransport(
modulePath: string,
modelDirectory: string
): EmbeddingInferenceTransport {
const child = utilityProcess.fork(
modulePath,
[modelDirectory],
{
serviceName: 'GoodBuddy Embedding Inference',
stdio: 'pipe',
allowLoadingUnsignedLibraries: false
}
)
return {
postMessage: (message) => child.postMessage(message),
onMessage: (listener) => {
child.on('message', listener)
return () => child.removeListener('message', listener)
},
onClose: (listener) => {
let closed = false
const notify = (): void => {
if (closed) {
return
}
closed = true
listener()
}
child.on('exit', notify)
child.on('error', notify)
return () => {
child.removeListener('exit', notify)
child.removeListener('error', notify)
}
},
close: () => {
child.kill()
}
}
}
async function createEmbeddingProvider(
settings: ResolvedRuntimeSettings,
manager: EmbeddingModelManager,
@@ -320,14 +279,38 @@ async function createEmbeddingProvider(
if (!tokenizerDigest || !modelDigest) {
throw new Error('内置向量模型安装清单不完整')
}
const broker = new EmbeddingInferenceBroker({
createTransport: () =>
createEmbeddingUtilityTransport(
join(mainModuleDirectory, 'embedding-inference-bootstrap.js'),
modelDirectory
)
})
embeddingBrokers.add(broker)
let shared = sharedEmbeddingBrokers.get(modelDirectory)
if (!shared) {
const broker = new EmbeddingInferenceBroker({
createTransport: () =>
createEmbeddingUtilityTransport(
join(mainModuleDirectory, 'embedding-inference-bootstrap.js'),
modelDirectory
)
})
embeddingBrokers.add(broker)
const unregister = localInferenceService.register('embedding', {
snapshot: () => ({
id: 'embedding', name: '向量生成', engine: 'Granite / ONNX',
ownership: 'managed-process', state: broker.getManagementState(), model: model.id,
error: broker.getManagementError(),
resources: broker.getResources(),
detail: 'GoodBuddy 托管进程,由知识检索与索引共享。进程启动后模型在首个请求时加载;资源指标为整个向量服务进程占用。停止会中断使用方任务,启动不重放任务。',
actions: broker.getManagementState() === 'stopped' || broker.getManagementState() === 'idle' || broker.getManagementState() === 'error'
? ['start'] : broker.getManagementState() === 'running' ? ['stop', 'restart'] : []
}),
act: async (action) => {
if (action !== 'start') await broker.stop()
if (action !== 'stop') await broker.start()
}
})
shared = { broker, users: 0, unregister }
sharedEmbeddingBrokers.set(modelDirectory, shared)
}
shared.users += 1
const sharedBroker = shared
const broker = shared.broker
let disposed = false
return {
provider: 'builtin',
model: model.id,
@@ -354,6 +337,12 @@ async function createEmbeddingProvider(
embedDocuments: (texts, signal) =>
broker.embed(texts, 'document', signal),
dispose: async () => {
if (disposed) return
disposed = true
sharedBroker.users -= 1
if (sharedBroker.users > 0) return
sharedEmbeddingBrokers.delete(modelDirectory)
sharedBroker.unregister()
try {
await broker.shutdown()
} finally {
@@ -803,6 +792,19 @@ if (hasSingleInstanceLock) {
documentOcrModelManager,
documentOcrBroker
)
localInferenceService.register('ocr', {
snapshot: async () => {
const snapshot = await documentParsingService.snapshot()
const model = snapshot.status.localOcr
return {
id: 'ocr', name: '文字识别', engine: 'PaddleOCR / ONNX Web', ownership: 'renderer-worker',
resources: { scope: 'unavailable', reason: 'Web Worker 与界面共享渲染进程,无法独立统计 CPU / 内存' },
state: model.available ? 'unknown' : 'unavailable', model: model.id,
detail: `${model.detail}。模型由 Renderer Worker 持有,文档解析按需加载,空闲 60 秒自动释放;仅允许无活动任务时手动释放。`,
actions: []
}
}
})
const versionChecker = new VersionChecker({
fetch: globalThis.fetch,
currentVersion: app.getVersion(),
@@ -818,6 +820,18 @@ if (hasSingleInstanceLock) {
const speechTranscriptionService = new SpeechTranscriptionService(
speechModelManager
)
localInferenceService.register('asr', {
snapshot: async () => {
const model = await speechModelManager.getSelectedRuntimeModel()
return {
id: 'asr', name: '语音识别', engine: 'sherpa-onnx', ownership: 'request-worker',
resources: { scope: 'unavailable', reason: '识别线程与主进程共享资源,无法独立统计 CPU / 内存' },
state: model ? 'unknown' : 'unavailable', model: model?.id,
detail: model ? '每个识别请求独立加载模型,完成后释放;支持取消单次识别,没有常驻服务可启停。' : '请在设置中安装并选择本地语音模型。',
actions: []
}
}
})
browserService = new BrowserService({ parentWindow: mainWindow })
const bundledRuntimePaths = resolveBundledRuntimePaths({
appPath: app.getAppPath(),
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import type { ProcessMetric } from 'electron'
import { createInferenceProcessSampler } from './inference-process-resources'
describe('inference process resources', () => {
it('measures only the utility PID, warms CPU and converts KiB to bytes', () => {
let time = 0
const metric: ProcessMetric = { pid: 42, type: 'Utility', creationTime: 1, cpu: { cumulativeCPUUsage: 2, percentCPUUsage: 0, idleWakeupsPerSecond: 0 }, memory: { workingSetSize: 2048, peakWorkingSetSize: 2048 } }
const sample = createInferenceProcessSampler(() => 42, () => [{ ...metric, pid: 99 }, metric], () => time)
expect(sample()).toMatchObject({ scope: 'service-process', pid: 42, workingSetBytes: 2097152, cpuUnavailableReason: '正在建立采样基线' })
time = 5100
metric.cpu.cumulativeCPUUsage = 9.65
expect(sample()).toMatchObject({ cpuPercent: 150 })
time = 21000
expect(sample()).toMatchObject({ cpuUnavailableReason: '正在建立采样基线' })
time = 26000
metric.creationTime = 2
expect(sample()).not.toHaveProperty('cpuPercent')
time = 31000
metric.cpu.cumulativeCPUUsage = 0
expect(sample()).not.toHaveProperty('cpuPercent')
})
it('clears baselines on exit, missing metrics and failure; preserves partial readings', () => {
let pid: number | undefined = 42
let time = 0
let fail = false
const metric: ProcessMetric = { pid: 42, type: 'Utility', creationTime: 1, cpu: { percentCPUUsage: 0, idleWakeupsPerSecond: 0 }, memory: { workingSetSize: 1024, peakWorkingSetSize: 1024 } }
const sample = createInferenceProcessSampler(() => pid, () => { if (fail) throw new Error('sampling'); return [metric] }, () => time += 2000)
expect(sample()).toMatchObject({ workingSetBytes: 1048576, cpuUnavailableReason: '系统未提供进程 CPU 时间' })
metric.cpu.cumulativeCPUUsage = 1
metric.memory.workingSetSize = NaN
expect(sample()).toMatchObject({ memoryUnavailableReason: '系统未提供工作集内存' })
fail = true
expect(sample()).toEqual({ scope: 'unavailable', reason: '进程资源采样失败' })
fail = false
expect(sample()).not.toHaveProperty('cpuPercent')
pid = undefined
expect(sample()).toEqual({ scope: 'unavailable', reason: '服务进程未运行' })
pid = 43
expect(sample()).toEqual({ scope: 'unavailable', reason: '等待进程资源数据' })
metric.pid = 43
expect(sample()).not.toHaveProperty('cpuPercent')
metric.type = 'Tab'
expect(sample()).toMatchObject({ scope: 'unavailable' })
})
})
+48
View File
@@ -0,0 +1,48 @@
import type { ProcessMetric } from 'electron'
import type { InferenceResources } from '../shared/local-inference-contracts'
// One sampler per transport: a replacement process never inherits the old baseline.
export function createInferenceProcessSampler(
getPid: () => number | undefined,
getMetrics: () => ProcessMetric[],
now: () => number = () => performance.now()
): () => InferenceResources {
let previous: { pid: number; creationTime: number; time: number; cpu: number } | undefined
return () => {
const pid = getPid()
let metric: ProcessMetric | undefined
try {
if (pid !== undefined) metric = getMetrics().find((entry) => entry.pid === pid && entry.type === 'Utility')
} catch {
previous = undefined
return { scope: 'unavailable', reason: '进程资源采样失败' }
}
if (!metric) {
previous = undefined
return { scope: 'unavailable', reason: pid === undefined ? '服务进程未运行' : '等待进程资源数据' }
}
const time = now()
const cpu = metric.cpu.cumulativeCPUUsage
const resources: InferenceResources = {
scope: 'service-process', pid: metric.pid, sampledAt: Date.now(),
cpuUnavailableReason: '正在建立采样基线'
}
// Electron reports working set in KiB. Do not replace absent readings with zero.
const memory = metric.memory?.workingSetSize
if (Number.isFinite(memory) && memory >= 0) resources.workingSetBytes = memory * 1024
else resources.memoryUnavailableReason = '系统未提供工作集内存'
if (cpu !== undefined && Number.isFinite(cpu) && cpu >= 0) {
if (previous && previous.pid === metric.pid && previous.creationTime === metric.creationTime &&
time > previous.time && time - previous.time <= 15000 && cpu >= previous.cpu) {
// Single-core capacity is 100%; multithreaded inference may exceed 100%.
resources.cpuPercent = (cpu - previous.cpu) * 100_000 / (time - previous.time)
resources.cpuUnavailableReason = undefined
}
previous = { pid: metric.pid, creationTime: metric.creationTime, time, cpu }
} else {
previous = undefined
resources.cpuUnavailableReason = '系统未提供进程 CPU 时间'
}
return resources
}
}
+158 -5
View File
@@ -12,6 +12,7 @@ import { join } from 'node:path'
import { ipcChannels } from '../shared/ipc-channels'
import type {
AssistantProject,
AssistantSchedule,
ConversationSnapshot,
ConversationQueueItem
} from '../shared/assistant-contracts'
@@ -21,7 +22,8 @@ import {
runtimeSettingsInputSchema,
type AgentEvent,
type BrowserLiveState,
type BrowserTabId
type BrowserTabId,
type ConversationQueueDispatch
} from '../shared/contracts'
import type {
SshHostAgentConnectionStatus
@@ -1056,6 +1058,8 @@ describe('registerIpcHandlers update source routing', () => {
const versionChecker = {
check: vi.fn(async () => result)
}
const unsubscribe = vi.fn()
const onChanged = vi.fn<(listener: (settings: unknown) => void) => () => void>(() => unsubscribe)
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
@@ -1076,10 +1080,14 @@ describe('registerIpcHandlers update source routing', () => {
undefined,
undefined,
undefined,
{ get: getApplicationSettings } as never,
{ get: getApplicationSettings, onChanged } as never,
versionChecker as never
)
const changedSettings = { updateSource: 'github' }
onChanged.mock.calls[0]![0](changedSettings)
expect(webContents.send).toHaveBeenCalledWith(ipcChannels.applicationSettingsChanged, changedSettings)
await expect(
electronMocks.handlers.get(ipcChannels.versionCheck)?.(event)
).resolves.toEqual(result)
@@ -1111,6 +1119,7 @@ describe('registerIpcHandlers update source routing', () => {
)
await dispose()
expect(unsubscribe).toHaveBeenCalledOnce()
})
})
@@ -1177,6 +1186,7 @@ describe('registerIpcHandlers model download source routing', () => {
snapshot: vi.fn(async () => ocrSnapshot)
}
const applicationSettingsStore = {
onChanged: vi.fn(),
get: vi.fn(async () => ({
modelDownloadSource: 'hugging-face'
}))
@@ -1781,7 +1791,8 @@ describe('registerIpcHandlers SSH hosts', () => {
undefined, // subagentService
undefined, // channelSettingsStore
{
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
get: vi.fn(async () => ({ remoteProjectsEnabled: true })),
onChanged: vi.fn()
} as never, // applicationSettingsStore
undefined, // versionChecker
undefined, // speechModelManager
@@ -2212,6 +2223,7 @@ describe('registerIpcHandlers SSH hosts', () => {
updatedAt: '2026-08-01T00:00:00.000Z'
}
const applicationSettingsStore = {
onChanged: vi.fn(),
get: vi.fn(async () => ({ remoteProjectsEnabled: false }))
}
const sshHostService = {
@@ -4235,6 +4247,7 @@ describe('registerIpcHandlers workspace files', () => {
undefined,
undefined,
{
onChanged: vi.fn(),
get: vi.fn(async () => {
throw new Error('settings unavailable')
})
@@ -4323,6 +4336,7 @@ describe('registerIpcHandlers workspace files', () => {
undefined,
undefined,
{
onChanged: vi.fn(),
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
} as never
)
@@ -4363,6 +4377,42 @@ describe('registerIpcHandlers token usage', () => {
vi.clearAllMocks()
})
it('validates execution statistics scope and sender before reading evidence', async () => {
const summary = { durationMs: 5000, requestCount: 2, incompleteRequestCount: 1, activeRequestCount: 0, asOf: 10000, taskDurations: [{ id: 'task', durationMs: 5000, incompleteRequestCount: 1 }] }
const assistantDatabase = {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
getExecutionStats: vi.fn(() => summary)
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn()
}
const window = { webContents, isDestroyed: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() }
const dispose = registerIpcHandlers(
window as never, { capability: 'text' } as never,
'CommandOrControl+Shift+Space', {} as never, {} as never,
{ clear: vi.fn() } as never, {} as never, assistantDatabase as never,
{ clear: vi.fn() } as never, {} as never, vi.fn(async () => {})
)
try {
const handler = electronMocks.handlers.get(ipcChannels.tasksExecutionStats)!
const event = { sender: webContents, senderFrame: webContents.mainFrame }
const id = '00000000-0000-4000-8000-000000000301'
for (const scope of [{ conversationId: id }, { projectId: id }]) {
expect(handler(event, scope)).toBe(summary)
expect(assistantDatabase.getExecutionStats).toHaveBeenLastCalledWith(scope, new Set())
}
expect(() => handler(event, {})).toThrow()
expect(() => handler(event, { projectId: id, conversationId: id })).toThrow()
expect(() => handler({ sender: {}, senderFrame: webContents.mainFrame }, { projectId: id })).toThrow()
expect(assistantDatabase.getExecutionStats).toHaveBeenCalledTimes(2)
} finally {
await dispose()
}
})
it('returns the database token summary to a trusted renderer', async () => {
const summary = {
totals: {
@@ -5273,7 +5323,8 @@ describe('registerIpcHandlers agent terminal state', () => {
subagentService as never,
undefined,
{
get: getApplicationSettings
get: getApplicationSettings,
onChanged: vi.fn()
} as never,
undefined,
undefined,
@@ -5516,7 +5567,7 @@ describe('registerIpcHandlers agent terminal state', () => {
title: '每周汇总',
prompt: '汇总本周进展',
recurrence: 'weekly' as const,
nextRunAt: '2026-08-21T09:00:00.000Z'
nextRunAt: new Date(Date.now() + 60_000).toISOString()
}
await electronMocks.handlers.get(
@@ -5532,6 +5583,68 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose()
})
it.each(['completed', 'failed'] as const)('creates an immediate task into the production queue and dispatches exactly one ordinary run (%s)', async (status) => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const conversationId = crypto.randomUUID()
database.saveLocalConversations([{
header: { id: conversationId, title: 'Immediate', updatedAt: Date.now(), workMode: 'ask' },
messages: []
}])
const run = vi.fn(async function* (request: AgentExecutionRequest) {
if (status === 'failed') {
yield { type: 'error' as const, requestId: request.requestId, message: 'Runtime failed' }
return
}
yield { type: 'text' as const, requestId: request.requestId, delta: 'Once' }
yield { type: 'done' as const, requestId: request.requestId }
})
const harness = createHarness(
{ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run },
undefined, 'always', undefined, false, undefined, undefined, undefined,
false, undefined, undefined, undefined, undefined, database
)
const event = trustedEvent(harness.webContents)
try {
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
const create = electronMocks.handlers.get(ipcChannels.schedulesCreate)!
const input = {
conversationId, title: 'Immediate', prompt: 'Run once', recurrence: 'once',
nextRunAt: '2020-01-01T00:00:00.000Z'
}
expect(() => create(event, input)).toThrow('首次运行时间必须晚于当前时间')
expect(database.listSchedules()).toEqual([])
const schedule = await create(event, { ...input, runImmediately: true }) as AssistantSchedule
expect(schedule.enabled).toBe(false)
const dispatches = () => harness.webContents.send.mock.calls.filter(
([channel]) => channel === ipcChannels.conversationQueueDispatch
)
expect(dispatches()).toHaveLength(1)
const dispatch = dispatches()[0]![1] as ConversationQueueDispatch
expect(dispatch).toMatchObject({ scheduled: true, item: { taskId: schedule.taskId } })
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
await harness.handler!(event, {
requestId: dispatch.item.id,
conversationId,
queueItemId: dispatch.item.id,
prompt: input.prompt,
workMode: 'ask',
currentUserMessageId: crypto.randomUUID(),
currentAssistantMessageId: crypto.randomUUID()
})
await vi.waitFor(() => expect(database.getTask(schedule.taskId).status).toBe(status))
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
expect(dispatches()).toHaveLength(1)
expect(run).toHaveBeenCalledOnce()
expect(database.listConversationQueueItems()).toEqual([])
expect(database.listSchedules()).toHaveLength(1)
} finally {
await harness.dispose()
database.close()
}
})
it('dispatches scheduled messages through ordinary agent runs with current history and tools', async () => {
const fullOutput = `${'x'.repeat(1_000_001)} scheduled tail`
const taskId = '00000000-0000-4000-8000-000000000701'
@@ -7814,6 +7927,46 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose()
})
it('retains user-enabled knowledge scopes when other applications are disabled', async () => {
const libraryId = '11111111-1111-4111-8111-111111111111'
let finish: (() => void) | undefined
const held = new Promise<void>(resolve => { finish = resolve })
const run = vi.fn(async function* (request: { requestId: string; knowledgeLibraryIds: string[] }) {
if (request.requestId.endsWith('21')) await held
yield { requestId: request.requestId, type: 'done' }
})
const retrieveMany = vi.fn(async () => [])
const gateway = { grant: vi.fn(() => 'capability'), getAvailableToolNames: vi.fn(() => ['knowledge_search']), drainReferences: vi.fn(() => []), revoke: vi.fn() }
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, supportsScopedDataTools: true, run }, undefined, 'always', undefined, false, undefined, {
database: { listKnowledgeBases: vi.fn(() => [{ id: libraryId, name: 'Knowledge' }]) }, retrieveMany
}, gateway)
const request = { prompt: 'use saved knowledge', workMode: 'ask', knowledgeLibraryIds: [libraryId], knowledgeRetrievalMode: 'always' }
try {
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000021', conversationId: 'in-flight' })
await vi.waitFor(() => expect(run).toHaveBeenCalledOnce())
expect(retrieveMany).toHaveBeenCalledOnce()
harness.getApplicationSettings.mockResolvedValue({ localInferenceEnabled: false, magicNotesEnabled: false })
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000022', conversationId: 'disabled' })
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2))
expect(run.mock.calls[0]![0].knowledgeLibraryIds).toEqual([libraryId])
expect(run.mock.calls[1]![0].knowledgeLibraryIds).toEqual([libraryId])
expect(retrieveMany).toHaveBeenCalledTimes(2)
expect(gateway.grant).toHaveBeenCalledTimes(2)
expect(harness.assistantDatabase.updateTaskStatus).not.toHaveBeenCalledWith('00000000-0000-4000-8000-000000000021', 'cancelled')
finish!()
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000021', 'completed'))
harness.getApplicationSettings.mockResolvedValue({ localInferenceEnabled: true, magicNotesEnabled: false })
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000023', conversationId: 'restored' })
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(3))
expect(run.mock.calls[2]![0].knowledgeLibraryIds).toEqual([libraryId])
expect(retrieveMany).toHaveBeenCalledTimes(3)
expect(request.knowledgeLibraryIds).toEqual([libraryId])
} finally {
finish!()
await harness.dispose()
}
})
it('grants read-only Magic Notes tools in Ask and write tools in Execute', async () => {
const runtime = {
runtimeId: 'model',
+62 -5
View File
@@ -1,5 +1,7 @@
import { externalKnowledgeInstanceInputSchema, externalKnowledgeInstanceEnabledInputSchema, externalKnowledgeInstanceSaveInputSchema, externalKnowledgeCatalogListInputSchema, externalKnowledgeCatalogGetInputSchema, externalKnowledgeBindingSaveInputSchema, externalKnowledgeBindingUpdateInputSchema, externalKnowledgeBindingTestInputSchema } from '../shared/external-knowledge-contracts'
import { knowledgeReferenceKey, toKnowledgeReference } from '../shared/knowledge-reference'
import { localInferenceService } from './local-inference-service'
import { inferenceActionSchema, inferenceCancelSchema } from '../shared/local-inference-contracts'
import {
app,
BrowserWindow,
@@ -200,6 +202,7 @@ import {
} from '../shared/magic-notes-contracts'
import {
assistantIdSchema,
executionStatsInputSchema,
activityHistorySnapshotSchema,
conversationBranchInputSchema,
conversationSnapshotsSchema,
@@ -1345,6 +1348,7 @@ export function registerIpcHandlers(
): () => Promise<void> {
type ActiveRequestLease = {
controller: AbortController
isReply: boolean
conversationId: string
detachOnApplicationExit: boolean
teamMode?: boolean
@@ -1375,10 +1379,12 @@ export function registerIpcHandlers(
const leaseActiveRequest = (
requestId: string,
conversationId: string,
controller: AbortController
controller: AbortController,
isReply = true
): ActiveRequestLease => {
const lease: ActiveRequestLease = {
controller,
isReply,
conversationId,
detachOnApplicationExit: false,
release: (): void => {
@@ -1580,6 +1586,11 @@ export function registerIpcHandlers(
}
window.on('maximize', notifyMaximizedChanged)
window.on('unmaximize', notifyMaximizedChanged)
const removeApplicationSettingsListener = applicationSettingsStore?.onChanged((settings) => {
if (!window.isDestroyed()) {
window.webContents.send(ipcChannels.applicationSettingsChanged, settings)
}
})
const removeBrowserStateListener = browserControl?.onState((state) => {
if (!window.isDestroyed() && state.ownerWindowId === window.webContents.id) {
window.webContents.send(ipcChannels.browserState, state)
@@ -5117,7 +5128,8 @@ export function registerIpcHandlers(
const activeRequestLease = leaseActiveRequest(
request.requestId,
request.conversationId,
controller
controller,
false
)
assistantDatabase.createTask({
id: request.requestId,
@@ -6480,6 +6492,29 @@ export function registerIpcHandlers(
}
)
registerHandler(ipcChannels.localInferenceGet, async (event) => {
assertTrustedSender(event, window)
const snapshot = await localInferenceService.snapshot()
const settings = await settingsStore.getPublicSettings()
return {
...snapshot,
externalConnections: settings.embeddingConnections?.filter((connection) => connection.kind !== 'builtin')
.map((connection) => ({ id: connection.id, name: connection.name, model: connection.modelName })) ?? []
}
})
registerHandler(ipcChannels.localInferenceOpenSettings, (event) => {
assertTrustedSender(event, window)
window.webContents.send(ipcChannels.settingsOpen)
})
registerHandler(ipcChannels.localInferenceAct, (event, input: unknown) => {
assertTrustedSender(event, window)
return trackExecution(localInferenceService.act(inferenceActionSchema.parse(input)))
})
registerHandler(ipcChannels.localInferenceCancel, (event, input: unknown) => {
assertTrustedSender(event, window)
localInferenceService.cancel(inferenceCancelSchema.parse(input).taskId)
})
registerHandler(ipcChannels.speechModelsGet, (event) => {
assertTrustedSender(event, window)
if (!speechModelManager) {
@@ -7220,6 +7255,13 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
return assistantDatabase.listTasks()
})
registerHandler(ipcChannels.tasksExecutionStats, (event, input: unknown) => {
assertTrustedSender(event, window)
return assistantDatabase.getExecutionStats(
executionStatsInputSchema.parse(input),
new Set([...activeRequests].filter(([, lease]) => lease.isReply && !lease.controller.signal.aborted).map(([id]) => id))
)
})
registerHandler(ipcChannels.tasksSetStatus, (event, input: unknown) => {
assertTrustedSender(event, window)
const parsed = taskStatusRequestSchema.parse(input)
@@ -7398,9 +7440,23 @@ export function registerIpcHandlers(
registerHandler(ipcChannels.schedulesCreate, (event, input: unknown) => {
assertTrustedSender(event, window)
return assistantDatabase.createSchedule(
scheduleCreateSchema.parse(input)
)
const value = scheduleCreateSchema.parse(input)
if (value.runImmediately) {
if (executionPaused || shuttingDown) {
throw new Error('本地数据维护期间暂不接受新任务')
}
} else if (new Date(value.nextRunAt).getTime() <= Date.now()) {
throw new Error('首次运行时间必须晚于当前时间。')
}
const schedule = assistantDatabase.createSchedule(value)
if (value.runImmediately) {
publishConversationQueueChange(schedule.conversationId)
if (!isConversationExecuting(schedule.conversationId)) {
readyConversationQueues.add(schedule.conversationId)
void pumpConversationQueue(schedule.conversationId)
}
}
return schedule
})
registerHandler(
@@ -8737,6 +8793,7 @@ export function registerIpcHandlers(
return async () => {
shuttingDown = true
removeApplicationSettingsListener?.()
removeLocalToolEnvironmentProgressListener?.()
await localToolEnvironmentService?.dispose()
removeBrowserStateListener?.()
@@ -1,5 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { localInferenceService } from '../local-inference-service'
import { EmbeddingInferenceWorker } from './embedding-inference-worker'
import {
EMBEDDING_INFERENCE_MAX_IN_FLIGHT,
EMBEDDING_INFERENCE_PROTOCOL,
EMBEDDING_INFERENCE_PROTOCOL_VERSION,
type EmbeddingInferenceRequest
@@ -62,6 +65,255 @@ function embedRequest(transport: TestTransport): Extract<
}
describe('EmbeddingInferenceBroker', () => {
it('drops exited process resources and uses the replacement transport after restart', async () => {
const first = new TestTransport()
const second = new TestTransport()
const resourceTransport = (transport: TestTransport, pid: number): EmbeddingInferenceTransport => Object.assign(transport, {
getResources: () => ({ scope: 'service-process' as const, pid, sampledAt: 1, workingSetBytes: 1024 })
})
const factory = vi.fn().mockReturnValueOnce(resourceTransport(first, 42)).mockReturnValueOnce(resourceTransport(second, 43))
const broker = new EmbeddingInferenceBroker({ createTransport: factory })
expect(broker.getResources()).toMatchObject({ scope: 'unavailable' })
await broker.start()
expect(broker.getResources()).toMatchObject({ pid: 42 })
first.crash()
expect(broker.getResources()).toMatchObject({ scope: 'unavailable' })
await broker.start()
expect(broker.getResources()).toMatchObject({ pid: 43 })
await broker.stop()
expect(broker.getResources()).toMatchObject({ scope: 'unavailable' })
})
it('keeps source-cancelled computation in stop impact until the worker returns', async () => {
const transport = new TestTransport()
let finish!: (vectors: number[][]) => void
let workerSignal!: AbortSignal
const engine = {
tokenizer: { tokenize: vi.fn() },
onnx: { run: vi.fn() },
embed: vi.fn(({ signal }: { signal: AbortSignal }) => {
workerSignal = signal
return new Promise<number[][]>((resolve) => { finish = resolve })
})
}
const worker = new EmbeddingInferenceWorker({
modelDirectory: process.cwd(), engine,
port: {
postMessage: (message) => transport.receive(message),
onMessage: (listener) => {
const spy = vi.spyOn(transport, 'postMessage').mockImplementation((message) => {
transport.sent.push(message)
listener(message)
})
return () => spy.mockRestore()
}
}
})
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
const act = vi.fn(async () => undefined)
const unregister = localInferenceService.register('embedding', {
snapshot: () => ({ id: 'embedding', name: 'Embedding', engine: 'ONNX', detail: '', ownership: 'managed-process', state: 'running', actions: ['stop', 'restart'] }),
act
})
try {
const controller = new AbortController()
const pending = broker.embed(['delayed computation'], 'query', controller.signal)
const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
await vi.waitFor(() => expect(engine.embed).toHaveBeenCalledOnce())
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
controller.abort()
await rejection
expect(workerSignal.aborted).toBe(true)
const activeTask = (await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)!
expect(activeTask.state).toBe('cancelling')
expect(activeTask.finishedAt).toBeUndefined()
await expect(localInferenceService.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [] })).rejects.toThrow('任务已变化')
await expect(localInferenceService.act({ serviceId: 'embedding', action: 'restart', confirmedTaskIds: [] })).rejects.toThrow('任务已变化')
localInferenceService.cancel(task.id)
expect(transport.sent.filter((message) => message.type === 'cancel')).toHaveLength(1)
expect(act).not.toHaveBeenCalled()
expect(transport.close).not.toHaveBeenCalled()
finish([[1, 2]])
await vi.waitFor(async () => {
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({ state: 'cancelled', finishedAt: expect.any(Number) })
})
await localInferenceService.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [] })
expect(act).toHaveBeenCalledOnce()
} finally {
unregister()
finish?.([[1, 2]])
await worker.shutdown()
await broker.stop()
}
})
it.each([
['abort', 'acknowledgement'], ['abort', 'late result'], ['abort', 'process exit'],
['timeout', 'acknowledgement'], ['timeout', 'late result'], ['timeout', 'process exit']
] as const)('keeps execution cancelling after %s until %s', async (cause, completion) => {
vi.useFakeTimers()
const transport = new TestTransport()
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport, timeoutMs: 100 })
try {
const controller = new AbortController()
const pending = broker.embed(['cancelled work'], 'query', controller.signal)
const rejection = expect(pending).rejects.toMatchObject({ name: cause === 'abort' ? 'AbortError' : 'TimeoutError' })
await vi.advanceTimersByTimeAsync(0)
const request = embedRequest(transport)
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
if (cause === 'abort') controller.abort()
else await vi.advanceTimersByTimeAsync(100)
await rejection
await vi.advanceTimersByTimeAsync(10_000)
const activeTask = (await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)!
expect(activeTask.state).toBe('cancelling')
expect(activeTask.finishedAt).toBeUndefined()
expect(transport.close).not.toHaveBeenCalled()
expect(transport.sent.filter((message) => message.type === 'cancel')).toHaveLength(1)
if (completion === 'process exit') transport.crash()
else transport.receive(response(completion === 'late result'
? { type: 'result', requestId: request.requestId, vectors: [[1, 2]] }
: { type: 'error', requestId: request.requestId, code: 'CANCELLED', message: 'cancelled' }))
await vi.advanceTimersByTimeAsync(0)
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({
state: cause === 'abort' ? 'cancelled' : 'failed',
error: cause === 'abort' ? 'Embedding inference was cancelled' : 'Embedding inference timed out',
finishedAt: expect.any(Number)
})
} finally {
await broker.stop()
vi.useRealTimers()
}
})
it('keeps rejected work active through an unconfirmed stop until process exit', async () => {
const transport = new TestTransport()
transport.close.mockRejectedValue(new Error('exit not confirmed'))
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
const pending = broker.embed(['still computing'], 'query')
const rejection = expect(pending).rejects.toThrow('停止')
await vi.waitFor(() => expect(transport.sent).toHaveLength(1))
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
await expect(broker.stop()).rejects.toThrow('exit not confirmed')
await rejection
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)?.finishedAt).toBeUndefined()
transport.crash()
await vi.waitFor(async () => {
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({ state: 'failed', finishedAt: expect.any(Number) })
})
})
it('keeps cancelled requests within the in-flight bound until acknowledgement', async () => {
const transport = new TestTransport()
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
const controller = new AbortController()
try {
const callers = Array.from({ length: EMBEDDING_INFERENCE_MAX_IN_FLIGHT }, () =>
expect(broker.embed(['pending'], 'query', controller.signal)).rejects.toMatchObject({ name: 'AbortError' }))
await vi.waitFor(() => expect(transport.sent).toHaveLength(EMBEDDING_INFERENCE_MAX_IN_FLIGHT))
controller.abort()
await Promise.all(callers)
await expect(broker.embed(['over capacity'], 'query')).rejects.toMatchObject({ code: 'OVERLOADED' })
for (const request of transport.sent.filter((message) => message.type === 'embed')) {
transport.receive(response({ type: 'error', requestId: request.requestId, code: 'CANCELLED', message: 'cancelled' }))
}
const next = broker.embed(['capacity released'], 'query')
await vi.waitFor(() => expect(transport.sent.at(-1)?.type).toBe('embed'))
const request = transport.sent.at(-1)!
if (request.type !== 'embed') throw new Error('No next embed request was sent')
transport.receive(response({ type: 'result', requestId: request.requestId, vectors: [[1, 2]] }))
await expect(next).resolves.toEqual([[1, 2]])
} finally {
await broker.stop()
}
})
it('keeps execution active after shutdown grace and close timeout until process exit', async () => {
vi.useFakeTimers()
const transport = new TestTransport()
transport.close.mockImplementation(() => new Promise<void>((_resolve, reject) => {
setTimeout(() => reject(new Error('exit not confirmed')), 5000)
}))
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
try {
const controller = new AbortController()
const rejection = expect(broker.embed(['still computing'], 'query', controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
await vi.advanceTimersByTimeAsync(0)
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
controller.abort()
await rejection
const shutdown = broker.shutdown()
await vi.advanceTimersByTimeAsync(1000)
await shutdown
expect(transport.close).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(5000)
expect(broker.getManagementError()).toBe('exit not confirmed')
const activeTask = (await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)!
expect(activeTask.state).toBe('cancelling')
expect(activeTask.finishedAt).toBeUndefined()
transport.crash()
await vi.advanceTimersByTimeAsync(0)
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({ state: 'cancelled', finishedAt: expect.any(Number) })
} finally {
transport.crash()
vi.useRealTimers()
}
})
it('retains a live transport and reports a failed stop instead of claiming stopped', async () => {
const transport: EmbeddingInferenceTransport = {
postMessage: vi.fn(), onMessage: () => () => undefined, onClose: () => () => undefined,
close: vi.fn(async () => { throw new Error('exit not confirmed') })
}
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
await broker.start()
await expect(broker.stop()).rejects.toThrow('exit not confirmed')
expect(broker.getManagementState()).toBe('running')
expect(broker.getManagementError()).toBe('exit not confirmed')
})
it('reports startup failures and supports an explicit retry', async () => {
const factory = vi.fn().mockRejectedValueOnce(new Error('spawn failed')).mockResolvedValue(new TestTransport())
const broker = new EmbeddingInferenceBroker({ createTransport: factory })
await expect(broker.start()).rejects.toThrow('START_FAILURE')
expect(broker.getManagementState()).toBe('error')
await broker.start()
expect(broker.getManagementState()).toBe('running')
expect(broker.getManagementError()).toBeUndefined()
await broker.stop()
})
it('management cancellation waits for worker acknowledgement without closing shared transport', async () => {
const transport = new TestTransport()
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
const pending = broker.embed(['cancel this'], 'query')
const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
await vi.waitFor(() => expect(transport.sent).toHaveLength(1))
const request = embedRequest(transport)
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
localInferenceService.cancel(task.id)
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)?.state).toBe('cancelling')
expect(transport.close).not.toHaveBeenCalled()
transport.receive(response({ type: 'error', requestId: request.requestId, code: 'CANCELLED', message: 'cancelled' }))
await rejection
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)?.state).toBe('cancelled')
await broker.stop()
})
it('stops shared execution, rejects work until explicitly started, and never replays', async () => {
const transports: TestTransport[] = []
const broker = new EmbeddingInferenceBroker({ createTransport: () => { const transport = new TestTransport(); transports.push(transport); return transport } })
const pending = broker.embed(['interrupted'], 'document')
const rejection = expect(pending).rejects.toThrow('停止')
await vi.waitFor(() => expect(transports[0]?.sent).toHaveLength(1))
await broker.stop()
await rejection
expect(transports[0]!.close).toHaveBeenCalledOnce()
await expect(broker.embed(['blocked'], 'query')).rejects.toMatchObject({ code: 'CLOSED' })
await broker.start()
expect(transports).toHaveLength(2)
expect(transports[1]!.sent).toEqual([])
await broker.stop()
})
it('uses UUID requests with roles and validates bounded results', async () => {
const transport = new TestTransport()
const broker = new EmbeddingInferenceBroker({
+141 -39
View File
@@ -1,4 +1,6 @@
import { randomUUID } from 'node:crypto'
import { localInferenceService } from '../local-inference-service'
import type { InferenceResources } from '../../shared/local-inference-contracts'
import {
EMBEDDING_INFERENCE_MAX_IN_FLIGHT,
embeddingInferenceProtocolBase,
@@ -15,13 +17,13 @@ const MAX_TIMEOUT_MS = 300_000
const DEFAULT_MAX_RESTARTS = 2
const MAX_RESTARTS = 10
const SHUTDOWN_GRACE_MS = 1_000
const MAX_RETIRED_REQUESTS = 256
export interface EmbeddingInferenceTransport {
getResources?(): InferenceResources
postMessage(message: EmbeddingInferenceRequest): void
onMessage(listener: (message: unknown) => void): () => void
onClose(listener: () => void): () => void
close(): void
close(): void | Promise<void>
}
export type EmbeddingInferenceTransportFactory = () =>
@@ -42,6 +44,8 @@ type PendingRequest = {
readonly expectedCount: number
readonly resolve: (vectors: number[][]) => void
readonly reject: (error: Error) => void
readonly rejectCaller: (error: Error) => void
callerError?: Error
readonly timeout: ReturnType<typeof setTimeout>
removeAbortListener?: () => void
}
@@ -62,9 +66,10 @@ export class EmbeddingInferenceBrokerError extends Error {
| 'START_FAILURE'
| 'ENGINE_FAILURE'
| 'INVALID_RESULT'
| 'OVERLOADED'
| 'OVERLOADED',
detail?: string
) {
super(`Embedding inference failed (${code})`)
super(`Embedding inference failed (${code})${detail ? `: ${detail}` : ''}`)
this.name = 'EmbeddingInferenceBrokerError'
}
}
@@ -100,13 +105,65 @@ export class EmbeddingInferenceBroker {
private readonly timeoutMs: number
private readonly maxRestarts: number
private readonly pending = new Map<string, PendingRequest>()
private readonly retiredRequestIds = new Set<string>()
private active?: ActiveTransport
private starting?: Promise<ActiveTransport>
private restarts = 0
private everStarted = false
private closed = false
private shutdownComplete?: () => void
private paused = false
private stopping = false
private managementError?: string
getManagementState(): 'stopped' | 'stopping' | 'starting' | 'running' | 'idle' | 'error' {
if (this.stopping) return 'stopping'
if (this.paused || this.closed) return 'stopped'
if (this.starting) return 'starting'
if (this.active) return 'running'
return this.managementError ? 'error' : 'idle'
}
getManagementError(): string | undefined { return this.managementError }
getResources(): InferenceResources {
return this.active?.transport.getResources?.() ?? {
scope: 'unavailable', reason: this.active ? '当前执行端未提供进程指标' : '服务进程未运行'
}
}
async start(): Promise<void> {
if (this.closed) throw new EmbeddingInferenceBrokerError('CLOSED')
this.paused = false
this.restarts = 0
try {
await this.ensureTransport()
this.managementError = undefined
} catch (error) {
this.managementError = String(error)
throw error
}
}
async stop(): Promise<void> {
this.paused = true
this.stopping = true
const error = new Error('向量执行服务已由用户停止;任务不会自动重放')
this.rejectCallers(error)
try {
if (this.starting) await this.starting.catch(() => undefined)
const active = this.active
if (!active) return
await active.transport.close()
this.disposeTransport(active.transport, false)
this.rejectAll(error)
} catch (error) {
this.paused = false
this.managementError = error instanceof Error ? error.message : String(error)
throw error
} finally {
this.stopping = false
}
}
constructor(options: EmbeddingInferenceBrokerOptions) {
if (typeof options.createTransport !== 'function') {
@@ -131,12 +188,29 @@ export class EmbeddingInferenceBroker {
texts: readonly string[],
role: EmbeddingInferenceRole,
signal?: AbortSignal
): Promise<number[][]> {
const controller = new AbortController()
return new Promise<number[][]>((resolve, reject) => {
// Track worker execution separately from prompt caller cancellation/timeout.
void localInferenceService.run('embedding', role === 'query' ? '向量请求 · 查询' : '向量请求 · 文档',
(markCancelling) => this.embedRequest(texts, role, signal, controller.signal, (error) => {
markCancelling()
reject(error)
}),
{ cancel: () => controller.abort() }).then(resolve, reject)
})
}
private async embedRequest(
texts: readonly string[], role: EmbeddingInferenceRole, signal: AbortSignal | undefined,
managementSignal: AbortSignal,
rejectCaller: (error: Error) => void
): Promise<number[][]> {
const normalizedTexts = validateEmbeddingInferenceTexts(texts)
if (role !== 'query' && role !== 'document') {
throw new TypeError('role must be query or document')
}
if (this.closed) {
if (this.closed || this.paused) {
throw new EmbeddingInferenceBrokerError('CLOSED')
}
if (signal?.aborted) {
@@ -147,7 +221,7 @@ export class EmbeddingInferenceBroker {
}
const active = await this.ensureTransport()
if (this.closed) {
if (this.closed || this.paused) {
throw new EmbeddingInferenceBrokerError('CLOSED')
}
if (signal?.aborted) {
@@ -166,6 +240,7 @@ export class EmbeddingInferenceBroker {
expectedCount: normalizedTexts.length,
resolve,
reject,
rejectCaller,
timeout
}
if (signal) {
@@ -178,6 +253,21 @@ export class EmbeddingInferenceBroker {
}
this.pending.set(requestId, pending)
const requestCancellation = (): void => {
// Management cancellation waits for the worker's result or CANCELLED response.
try {
active.transport.postMessage({ ...embeddingInferenceProtocolBase(), type: 'cancel', requestId })
} catch {
this.failTransport(active.transport, new EmbeddingInferenceBrokerError('PROCESS_CLOSED'))
}
}
const removeSourceAbort = pending.removeAbortListener
pending.removeAbortListener = () => {
removeSourceAbort?.()
managementSignal.removeEventListener('abort', requestCancellation)
}
managementSignal.addEventListener('abort', requestCancellation, { once: true })
try {
active.transport.postMessage({
...embeddingInferenceProtocolBase(),
@@ -186,6 +276,7 @@ export class EmbeddingInferenceBroker {
role,
texts: normalizedTexts
})
if (managementSignal.aborted) requestCancellation()
} catch {
this.failTransport(
active.transport,
@@ -200,10 +291,7 @@ export class EmbeddingInferenceBroker {
return
}
this.closed = true
for (const requestId of this.pending.keys()) {
this.retireRequestId(requestId)
}
this.rejectAll(new EmbeddingInferenceBrokerError('CLOSED'))
this.rejectCallers(new EmbeddingInferenceBrokerError('CLOSED'))
let active = this.active
if (!active && this.starting) {
@@ -261,7 +349,7 @@ export class EmbeddingInferenceBroker {
.then(() => this.createTransport())
.then((transport) => {
if (this.closed) {
transport.close()
void Promise.resolve(transport.close()).catch(() => undefined)
throw new EmbeddingInferenceBrokerError('CLOSED')
}
const active: ActiveTransport = {
@@ -272,7 +360,8 @@ export class EmbeddingInferenceBroker {
removeCloseListener: transport.onClose(() =>
this.failTransport(
transport,
new EmbeddingInferenceBrokerError('PROCESS_CLOSED')
new EmbeddingInferenceBrokerError('PROCESS_CLOSED'),
true
)
)
}
@@ -280,6 +369,7 @@ export class EmbeddingInferenceBroker {
return active
})
.catch((error: unknown) => {
this.managementError = error instanceof Error ? error.message : String(error)
if (error instanceof EmbeddingInferenceBrokerError) {
throw error
}
@@ -317,6 +407,7 @@ export class EmbeddingInferenceBroker {
)
return
}
this.rejectAll(new EmbeddingInferenceBrokerError('CLOSED'))
this.shutdownComplete()
return
}
@@ -330,9 +421,6 @@ export class EmbeddingInferenceBroker {
const pending = this.pending.get(message.requestId)
if (!pending) {
if (this.retiredRequestIds.delete(message.requestId)) {
return
}
this.failTransport(
transport,
new EmbeddingInferenceBrokerError('PROTOCOL_VIOLATION')
@@ -345,7 +433,7 @@ export class EmbeddingInferenceBroker {
message.requestId,
message.code === 'CANCELLED'
? abortError()
: new EmbeddingInferenceBrokerError(message.code)
: new EmbeddingInferenceBrokerError(message.code, message.message)
)
return
}
@@ -365,11 +453,12 @@ export class EmbeddingInferenceBroker {
private cancelPending(requestId: string, error: Error): void {
const pending = this.pending.get(requestId)
if (!pending) {
if (!pending || pending.callerError) {
return
}
this.retireRequestId(requestId)
this.finishPending(requestId, error)
pending.callerError = error
clearTimeout(pending.timeout)
pending.rejectCaller(error)
try {
this.active?.transport.postMessage({
...embeddingInferenceProtocolBase(),
@@ -398,6 +487,7 @@ export class EmbeddingInferenceBroker {
this.pending.delete(requestId)
clearTimeout(pending.timeout)
pending.removeAbortListener?.()
error = pending.callerError ?? error
if (error) {
pending.reject(error)
} else {
@@ -405,26 +495,23 @@ export class EmbeddingInferenceBroker {
}
}
private retireRequestId(requestId: string): void {
this.retiredRequestIds.add(requestId)
if (this.retiredRequestIds.size > MAX_RETIRED_REQUESTS) {
const oldest = this.retiredRequestIds.values().next().value
if (typeof oldest === 'string') {
this.retiredRequestIds.delete(oldest)
}
}
}
private failTransport(
transport: EmbeddingInferenceTransport,
error: Error
error: Error,
closed = false
): void {
if (this.active?.transport !== transport) {
return
}
this.rejectCallers(error)
if (closed) {
this.disposeTransport(transport, false)
this.rejectAll(error)
this.shutdownComplete?.()
return
}
this.managementError = error.message
this.disposeTransport(transport, true)
this.rejectAll(error)
this.shutdownComplete?.()
}
private disposeTransport(
@@ -435,15 +522,30 @@ export class EmbeddingInferenceBroker {
if (!active || active.transport !== transport) {
return
}
if (close) {
try {
void Promise.resolve(transport.close()).then(() => {
if (this.active !== active) return
this.disposeTransport(transport, false)
this.rejectAll(new EmbeddingInferenceBrokerError('PROCESS_CLOSED'))
}).catch((error: unknown) => {
this.managementError = error instanceof Error ? error.message : String(error)
})
} catch (error) {
this.managementError = error instanceof Error ? error.message : String(error)
}
return
}
this.active = undefined
active.removeMessageListener()
active.removeCloseListener()
if (close) {
try {
transport.close()
} catch {
// The process is already unavailable.
}
}
private rejectCallers(error: Error): void {
for (const pending of this.pending.values()) {
pending.callerError ??= error
clearTimeout(pending.timeout)
pending.rejectCaller(pending.callerError)
}
}
@@ -206,13 +206,13 @@ export class EmbeddingInferenceWorker {
requestId: message.requestId,
vectors
})
} catch {
} catch (error) {
this.postError(
message.requestId,
controller.signal.aborted ? 'CANCELLED' : 'ENGINE_FAILURE',
controller.signal.aborted
? 'Embedding inference was cancelled'
: 'Embedding engine failed'
: error instanceof Error ? error.message : 'Embedding engine failed'
)
}
}
@@ -0,0 +1,61 @@
import { app, utilityProcess } from 'electron'
import { createInferenceProcessSampler } from '../inference-process-resources'
import type { EmbeddingInferenceTransport } from './embedding-inference-broker'
export function createEmbeddingUtilityTransport(
modulePath: string,
modelDirectory: string
): Promise<EmbeddingInferenceTransport> {
return new Promise((resolve, reject) => {
const child = utilityProcess.fork(modulePath, [modelDirectory], {
serviceName: 'GoodBuddy Embedding Inference',
stdio: 'pipe',
allowLoadingUnsignedLibraries: false
})
const startupError = (): void => { reject(new Error('向量进程启动失败')) }
child.once('error', startupError)
child.once('exit', startupError)
child.once('spawn', () => {
child.removeListener('error', startupError)
child.removeListener('exit', startupError)
resolve({
getResources: createInferenceProcessSampler(() => child.pid, () => app.getAppMetrics()),
postMessage: (message) => child.postMessage(message),
onMessage: (listener) => {
child.on('message', listener)
return () => { child.removeListener('message', listener) }
},
onClose: (listener) => {
let closed = false
const notify = (): void => {
if (closed) return
closed = true
listener()
}
child.on('exit', notify)
child.on('error', notify)
return () => {
child.removeListener('exit', notify)
child.removeListener('error', notify)
}
},
close: () => new Promise<void>((resolveClose, rejectClose) => {
const finish = (): void => {
clearTimeout(timeout)
resolveClose()
}
const timeout = setTimeout(() => {
child.removeListener('exit', finish)
rejectClose(new Error('向量进程尚未确认退出,请刷新状态后重试'))
}, 5000)
child.once('exit', finish)
if (!child.kill()) {
clearTimeout(timeout)
child.removeListener('exit', finish)
rejectClose(new Error('无法停止向量进程,请刷新状态后重试'))
}
})
})
})
})
}
@@ -1,7 +1,7 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { Tokenizer } from '@huggingface/tokenizers'
import * as ort from 'onnxruntime-web/wasm'
import * as ort from 'onnxruntime-web'
import type {
EmbeddingInferenceEngine,
EmbeddingOnnxRuntime,
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest'
import { LocalInferenceService } from './local-inference-service'
import type { InferenceService } from '../shared/local-inference-contracts'
const service: InferenceService = { id: 'embedding', name: 'Embedding', engine: 'ONNX', ownership: 'managed-process', state: 'running', detail: '', actions: ['stop', 'restart'] }
describe('local inference management', () => {
it('does not infer service readiness from an active ASR request', async () => {
const registry = new LocalInferenceService()
registry.register('asr', { snapshot: () => ({ ...service, id: 'asr', state: 'unknown', actions: [] }) })
let finish!: () => void
const work = registry.run('asr', 'speech', () => new Promise<void>((resolve) => { finish = resolve }))
expect((await registry.snapshot()).services.find((entry) => entry.id === 'asr')?.state).toBe('unknown')
finish()
await work
})
it('requires confirmation of every current task and blocks new work during stop', async () => {
const registry = new LocalInferenceService()
let finish!: () => void
const work = registry.run('embedding', 'index', () => new Promise<void>((resolve) => { finish = resolve }))
const act = vi.fn(async () => {
await expect(registry.run('embedding', 'new query', async () => [])).rejects.toThrow('切换')
finish()
})
registry.register('embedding', { snapshot: () => service, act })
await expect(registry.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [] })).rejects.toThrow('重新确认')
expect(act).not.toHaveBeenCalled()
const task = (await registry.snapshot()).tasks[0]!
await registry.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [task.id] })
await work
expect(act).toHaveBeenCalledOnce()
})
it('waits for cancellation acknowledgement and preserves natural completion', async () => {
const registry = new LocalInferenceService()
let finish!: () => void
const cancel = vi.fn()
const work = registry.run('asr', 'speech', () => new Promise<void>((resolve) => { finish = resolve }), { cancel })
const id = (await registry.snapshot()).tasks[0]!.id
registry.cancel(id)
registry.cancel(id)
expect(cancel).toHaveBeenCalledOnce()
expect((await registry.snapshot()).tasks[0]!.state).toBe('cancelling')
finish()
await work
expect((await registry.snapshot()).tasks[0]!.state).toBe('completed')
})
it('records real failures, bounds history, and isolates unavailable engines', async () => {
const registry = new LocalInferenceService()
registry.register('asr', { snapshot: () => { throw new Error('missing model') } })
for (let index = 0; index < 103; index++) await registry.run('embedding', 'query', async () => [])
await expect(registry.run('ocr', 'document', async () => { throw new Error('OCR failed') })).rejects.toThrow('OCR failed')
const snapshot = await registry.snapshot()
expect(snapshot.tasks).toHaveLength(100)
expect(snapshot.tasks[0]).toMatchObject({ state: 'failed', error: 'OCR failed' })
expect(snapshot.services.find((entry) => entry.id === 'asr')).toMatchObject({ state: 'error', error: 'Error: missing model' })
expect(snapshot.services.find((entry) => entry.id === 'tts')).toMatchObject({ state: 'unavailable', actions: [] })
})
})
+109
View File
@@ -0,0 +1,109 @@
import { randomUUID } from 'node:crypto'
import {
inferenceActionSchema,
type InferenceAction,
type InferenceService,
type InferenceServiceId,
type InferenceTask,
type LocalInferenceSnapshot
} from '../shared/local-inference-contracts'
type Adapter = {
snapshot: () => InferenceService | Promise<InferenceService>
act?: (action: InferenceAction['action']) => Promise<void>
}
const inventory: InferenceService[] = [
{ id: 'asr', name: '语音识别', engine: 'sherpa-onnx', ownership: 'request-worker', state: 'unavailable', detail: '语音服务尚未初始化。', actions: [] },
{ id: 'ocr', name: '文字识别', engine: 'PaddleOCR / ONNX Web', ownership: 'renderer-worker', state: 'unknown', detail: '模型由文档解析按需加载,空闲 60 秒自动释放。', actions: [] },
{ id: 'embedding', name: '向量生成', engine: 'Granite / ONNX', ownership: 'managed-process', state: 'unavailable', detail: '内置向量提供方尚未初始化;请在设置中安装内置模型并启用内置向量连接。外部向量端点由外部管理,本页不控制其进程。', actions: [] },
{ id: 'tts', name: '语音合成', engine: '未接入', ownership: 'unavailable', state: 'unavailable', detail: '当前版本没有语音合成执行服务。', actions: [] }
]
export class LocalInferenceService {
private readonly adapters = new Map<InferenceServiceId, Adapter>()
private readonly tasks = new Map<string, InferenceTask>()
private readonly cancellations = new Map<string, () => void>()
private readonly operations = new Set<InferenceServiceId>()
register(id: InferenceServiceId, adapter: Adapter): () => void {
this.adapters.set(id, adapter)
return () => { if (this.adapters.get(id) === adapter) this.adapters.delete(id) }
}
async snapshot(): Promise<LocalInferenceSnapshot> {
const services = await Promise.all(inventory.map(async (fallback) => {
try {
return await this.adapters.get(fallback.id)?.snapshot() ?? {
...fallback, resources: { scope: 'unavailable' as const, reason: fallback.id === 'tts' ? '尚未接入执行服务' : '服务尚未初始化' }
}
} catch (error) {
return { ...fallback, state: 'error' as const, error: String(error), actions: [] }
}
}))
return { services, tasks: [...this.tasks.values()].map((task) => ({ ...task })).reverse() }
}
async run<T>(
serviceId: InferenceServiceId,
source: string,
work: (markCancelling: () => void) => Promise<T>,
options: { cancel?: () => void; model?: string; cancelUnavailableReason?: string } = {}
): Promise<T> {
if (this.operations.has(serviceId)) throw new Error('服务正在切换状态,请稍后重试')
const id = randomUUID()
const task: InferenceTask = {
id, serviceId, source, model: options.model, state: 'running', startedAt: Date.now(),
cancelUnavailableReason: options.cancel ? undefined : options.cancelUnavailableReason ?? '该引擎未提供独立任务取消接口。'
}
this.tasks.set(id, task)
if (options.cancel) this.cancellations.set(id, options.cancel)
try {
const result = await work(() => {
if (task.finishedAt === undefined) task.state = 'cancelling'
})
task.state = 'completed'
return result
} catch (error) {
task.state = error instanceof Error && error.name === 'AbortError' ? 'cancelled' : 'failed'
task.error = error instanceof Error ? error.message : String(error)
throw error
} finally {
task.finishedAt = Date.now()
this.cancellations.delete(id)
const finished = [...this.tasks.values()].filter((entry) => entry.finishedAt !== undefined)
for (const entry of finished.slice(0, Math.max(0, finished.length - 100))) this.tasks.delete(entry.id)
}
}
cancel(taskId: string): void {
const task = this.tasks.get(taskId)
if (!task || task.finishedAt !== undefined) return
if (task.state === 'cancelling') return
const cancel = this.cancellations.get(taskId)
if (!cancel) throw new Error(task.cancelUnavailableReason)
cancel()
task.state = 'cancelling'
}
async act(input: InferenceAction): Promise<void> {
const request = inferenceActionSchema.parse(input)
const adapter = this.adapters.get(request.serviceId)
if (!adapter?.act) throw new Error('该服务不支持手动进程控制')
if (this.operations.has(request.serviceId)) throw new Error('服务操作正在进行')
this.operations.add(request.serviceId)
try {
const state = await adapter.snapshot()
if (!state.actions.includes(request.action)) throw new Error('当前状态不支持此操作')
const active = [...this.tasks.values()].filter((task) => task.serviceId === request.serviceId && task.finishedAt === undefined)
if (request.action !== 'start' && active.some((task) => !request.confirmedTaskIds.includes(task.id))) {
throw new Error('使用此服务的任务已变化,请刷新并重新确认影响')
}
await adapter.act(request.action)
} finally {
this.operations.delete(request.serviceId)
}
}
}
export const localInferenceService = new LocalInferenceService()
+20 -16
View File
@@ -1,6 +1,7 @@
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { Worker } from 'node:worker_threads'
import { localInferenceService } from '../local-inference-service'
import {
speechTranscriptionInputSchema,
speechTranscriptionResultSchema,
@@ -190,9 +191,10 @@ export const runSherpaTranscription: SpeechTranscriptionRunner = (
signal.removeEventListener('abort', abort)
worker.removeAllListeners()
if (terminate) {
void worker.terminate()
void worker.terminate().then(action, reject)
} else {
action()
}
action()
}
const abort = (): void =>
finish(() => reject(createAbortError()))
@@ -265,21 +267,23 @@ export class SpeechTranscriptionService {
}
const controller = new AbortController()
this.active.set(request.requestId, controller)
try {
const model = await this.models.getSelectedRuntimeModel()
if (!model) {
throw new Error('请先在设置中安装并选择本地语音模型')
return localInferenceService.run('asr', '语音输入', async () => {
try {
const model = await this.models.getSelectedRuntimeModel()
if (!model) {
throw new Error('请先在设置中安装并选择本地语音模型')
}
const text = await this.runner(
createSherpaRecognizerConfig(model),
samples,
request.sampleRate,
controller.signal
)
return speechTranscriptionResultSchema.parse({ text })
} finally {
this.active.delete(request.requestId)
}
const text = await this.runner(
createSherpaRecognizerConfig(model),
samples,
request.sampleRate,
controller.signal
)
return speechTranscriptionResultSchema.parse({ text })
} finally {
this.active.delete(request.requestId)
}
}, { cancel: () => controller.abort() })
}
cancel(requestId: SpeechTranscriptionInput['requestId']): boolean {