feat: add app management, local inference controls, and task statistics
This commit is contained in:
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user