feat: add persistent conversation input queue
Messages and Scheduled Task occurrences previously could not share one ordered path while a Conversation was active. They now enter a durable FIFO queue with frozen execution settings and bounded attachments, recover safely after restart, and never write concurrently to the same timeline. The compact queue above Composer supports removal and explicit interrupt-and-promote actions. SQLite schema v23 preserves pending schedule work, while Conversation Task children reuse the shared status-dot semantics for running, completed, failed, approval, paused, and cancelled states. Release note: 回复生成期间仍可继续发送普通消息;消息与 Scheduled Task 会按 Conversation 顺序排队,并支持删除待发送项或立即中断后优先执行。
This commit is contained in:
@@ -26,6 +26,64 @@ async function createDatabase(): Promise<AssistantDatabase> {
|
||||
return database
|
||||
}
|
||||
|
||||
function claimQueuedSchedules(
|
||||
database: AssistantDatabase,
|
||||
now: Date,
|
||||
limit = 4
|
||||
): Array<{
|
||||
schedule: ReturnType<AssistantDatabase['listSchedules']>[number]
|
||||
runId: string
|
||||
}> {
|
||||
database.queueDueSchedules(now, limit)
|
||||
const claims: Array<{
|
||||
schedule: ReturnType<AssistantDatabase['listSchedules']>[number]
|
||||
runId: string
|
||||
}> = []
|
||||
const seenConversations = new Set<string>()
|
||||
for (const item of database.listConversationQueueItems()) {
|
||||
if (
|
||||
item.source !== 'schedule' ||
|
||||
seenConversations.has(item.conversationId) ||
|
||||
claims.length >= limit
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const claimed = database.claimConversationQueueItem(
|
||||
item.conversationId,
|
||||
item.id
|
||||
)
|
||||
if (claimed?.source === 'schedule') {
|
||||
claims.push({
|
||||
schedule: claimed.schedule,
|
||||
runId: claimed.runId
|
||||
})
|
||||
seenConversations.add(item.conversationId)
|
||||
}
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
function claimManualScheduleQueueItem(
|
||||
database: AssistantDatabase,
|
||||
scheduleId: string
|
||||
): {
|
||||
schedule: ReturnType<AssistantDatabase['listSchedules']>[number]
|
||||
runId: string
|
||||
} {
|
||||
const item = database.queueScheduleNow(scheduleId)
|
||||
const claimed = database.claimConversationQueueItem(
|
||||
item.conversationId,
|
||||
item.id
|
||||
)
|
||||
if (claimed?.source !== 'schedule') {
|
||||
throw new Error('Expected a claimed schedule queue item')
|
||||
}
|
||||
return {
|
||||
schedule: claimed.schedule,
|
||||
runId: claimed.runId
|
||||
}
|
||||
}
|
||||
|
||||
describe('AssistantDatabase', () => {
|
||||
it('rejects a newer unsupported schema without changing its version', async () => {
|
||||
const directory = await mkdtemp(
|
||||
@@ -98,7 +156,7 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('migrates existing databases to schema version 22', async () => {
|
||||
it('migrates existing databases to schema version 23', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
||||
)
|
||||
@@ -127,7 +185,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(22)
|
||||
).toBe(23)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -198,6 +256,15 @@ describe('AssistantDatabase', () => {
|
||||
)
|
||||
.get()
|
||||
).toEqual({ name: 'magic_todos' })
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name = 'conversation_queue_items'`
|
||||
)
|
||||
.get()
|
||||
).toEqual({ name: 'conversation_queue_items' })
|
||||
current.close()
|
||||
})
|
||||
|
||||
@@ -231,7 +298,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(22)
|
||||
).toBe(23)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -369,7 +436,7 @@ describe('AssistantDatabase', () => {
|
||||
const inspected = new DatabaseSync(databasePath)
|
||||
expect(
|
||||
inspected.prepare('PRAGMA user_version').get()
|
||||
).toEqual({ user_version: 22 })
|
||||
).toEqual({ user_version: 23 })
|
||||
expect(
|
||||
inspected
|
||||
.prepare(
|
||||
@@ -1142,7 +1209,8 @@ describe('AssistantDatabase', () => {
|
||||
title: '每日摘要',
|
||||
messages: []
|
||||
})
|
||||
const [claim] = database.claimDueSchedules(
|
||||
const [claim] = claimQueuedSchedules(
|
||||
database,
|
||||
new Date('2026-07-31T00:01:00.000Z')
|
||||
)
|
||||
expect(claim?.schedule).toEqual(
|
||||
@@ -1169,12 +1237,17 @@ describe('AssistantDatabase', () => {
|
||||
status: 'idle',
|
||||
completedAt: undefined
|
||||
})
|
||||
const manualClaim = database.claimScheduleNow(schedule.id)
|
||||
const manualClaim = claimManualScheduleQueueItem(
|
||||
database,
|
||||
schedule.id
|
||||
)
|
||||
expect(manualClaim.schedule).toMatchObject({
|
||||
taskId: schedule.taskId,
|
||||
conversationId: schedule.conversationId
|
||||
})
|
||||
expect(() => database.claimScheduleNow(schedule.id)).toThrow(
|
||||
expect(() =>
|
||||
claimManualScheduleQueueItem(database, schedule.id)
|
||||
).toThrow(
|
||||
'已有一次运行正在进行'
|
||||
)
|
||||
database.completeScheduleRun(
|
||||
@@ -1254,7 +1327,8 @@ describe('AssistantDatabase', () => {
|
||||
recurrence: 'daily',
|
||||
nextRunAt: '2025-07-31T00:00:00.000Z'
|
||||
})
|
||||
const [overdueClaim] = database.claimDueSchedules(
|
||||
const [overdueClaim] = claimQueuedSchedules(
|
||||
database,
|
||||
new Date('2026-07-31T00:01:00.000Z')
|
||||
)
|
||||
database.completeScheduleRun(
|
||||
@@ -1288,6 +1362,137 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('persists and arbitrates a FIFO conversation input queue', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-conversation-queue-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const initial = new AssistantDatabase(databasePath)
|
||||
initial.initialize('C:\\Workspace')
|
||||
const project = initial.listProjects()[0]!
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000901'
|
||||
initial.replaceConversations([
|
||||
{
|
||||
id: conversationId,
|
||||
projectId: project.id,
|
||||
title: '排队对话',
|
||||
updatedAt: Date.now(),
|
||||
messages: []
|
||||
}
|
||||
])
|
||||
|
||||
const first = initial.enqueueConversationUserInput({
|
||||
conversationId,
|
||||
label: '第一条消息',
|
||||
payloadJson: JSON.stringify({ prompt: '第一条消息' })
|
||||
})
|
||||
const second = initial.enqueueConversationUserInput({
|
||||
conversationId,
|
||||
label: '第二条消息',
|
||||
payloadJson: JSON.stringify({ prompt: '第二条消息' })
|
||||
})
|
||||
expect(
|
||||
initial
|
||||
.listConversationQueueItems(conversationId)
|
||||
.map((item) => item.id)
|
||||
).toEqual([first.id, second.id])
|
||||
|
||||
const preferred = initial.claimConversationQueueItem(
|
||||
conversationId,
|
||||
second.id
|
||||
)
|
||||
expect(preferred).toMatchObject({
|
||||
source: 'user',
|
||||
item: { id: second.id, source: 'user' },
|
||||
payloadJson: JSON.stringify({ prompt: '第二条消息' })
|
||||
})
|
||||
expect(initial.listConversationQueueItems(conversationId)).toEqual([
|
||||
expect.objectContaining({ id: first.id })
|
||||
])
|
||||
initial.completeConversationUserQueueItem(second.id)
|
||||
|
||||
const firstClaim =
|
||||
initial.claimConversationQueueItem(conversationId)
|
||||
expect(firstClaim).toMatchObject({
|
||||
source: 'user',
|
||||
item: { id: first.id }
|
||||
})
|
||||
initial.close()
|
||||
|
||||
const recovered = new AssistantDatabase(databasePath)
|
||||
recovered.initialize('C:\\Workspace')
|
||||
expect(
|
||||
recovered.listConversationQueueItems(conversationId)
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
id: first.id,
|
||||
source: 'user',
|
||||
label: '第一条消息'
|
||||
})
|
||||
])
|
||||
recovered.cancelConversationQueueItem(first.id)
|
||||
expect(
|
||||
recovered.listConversationQueueItems(conversationId)
|
||||
).toEqual([])
|
||||
recovered.close()
|
||||
})
|
||||
|
||||
it('materializes due and manual schedule runs in the conversation queue', async () => {
|
||||
const database = await createDatabase()
|
||||
const schedule = database.createSchedule({
|
||||
title: '排队提醒',
|
||||
prompt: '检查排队结果',
|
||||
workMode: 'execute',
|
||||
recurrence: 'daily',
|
||||
nextRunAt: '2026-08-20T09:00:00.000Z'
|
||||
})
|
||||
|
||||
const [dueItem] = database.queueDueSchedules(
|
||||
new Date('2026-08-20T09:01:00.000Z')
|
||||
)
|
||||
expect(dueItem).toMatchObject({
|
||||
conversationId: schedule.conversationId,
|
||||
source: 'schedule',
|
||||
scheduleId: schedule.id,
|
||||
taskId: schedule.taskId
|
||||
})
|
||||
expect(database.listConversationQueueItems()).toEqual([
|
||||
expect.objectContaining({ id: dueItem!.id })
|
||||
])
|
||||
expect(
|
||||
database.listPendingScheduleQueueConversationIds()
|
||||
).toEqual([schedule.conversationId])
|
||||
expect(database.listPendingConversationQueueIds()).toEqual([
|
||||
schedule.conversationId
|
||||
])
|
||||
|
||||
const claimed = database.claimConversationQueueItem(
|
||||
schedule.conversationId
|
||||
)
|
||||
expect(claimed).toMatchObject({
|
||||
source: 'schedule',
|
||||
item: { id: dueItem!.id },
|
||||
schedule: { id: schedule.id },
|
||||
runId: dueItem!.id
|
||||
})
|
||||
database.completeScheduleRun(
|
||||
dueItem!.id,
|
||||
'completed',
|
||||
new Date('2026-08-20T09:02:00.000Z')
|
||||
)
|
||||
|
||||
const manualItem = database.queueScheduleNow(schedule.id)
|
||||
expect(manualItem).toMatchObject({
|
||||
source: 'schedule',
|
||||
scheduleId: schedule.id
|
||||
})
|
||||
database.cancelConversationQueueItem(manualItem.id)
|
||||
expect(database.listConversationQueueItems()).toEqual([])
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('recovers a claimed schedule without swallowing its occurrence', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-schedule-recovery-')
|
||||
@@ -1303,7 +1508,8 @@ describe('AssistantDatabase', () => {
|
||||
recurrence: 'once',
|
||||
nextRunAt: '2026-08-13T00:00:00.000Z'
|
||||
})
|
||||
const [claimed] = initial.claimDueSchedules(
|
||||
const [claimed] = claimQueuedSchedules(
|
||||
initial,
|
||||
new Date('2026-08-13T00:01:00.000Z')
|
||||
)
|
||||
expect(claimed?.schedule.id).toBe(schedule.id)
|
||||
@@ -1311,7 +1517,8 @@ describe('AssistantDatabase', () => {
|
||||
|
||||
const recovered = new AssistantDatabase(databasePath)
|
||||
recovered.initialize('C:\\Workspace')
|
||||
const [reclaimed] = recovered.claimDueSchedules(
|
||||
const [reclaimed] = claimQueuedSchedules(
|
||||
recovered,
|
||||
new Date('2026-08-13T00:02:00.000Z')
|
||||
)
|
||||
expect(reclaimed).toMatchObject({
|
||||
@@ -1350,7 +1557,8 @@ describe('AssistantDatabase', () => {
|
||||
}).id
|
||||
)
|
||||
|
||||
const firstBatch = database.claimDueSchedules(
|
||||
const firstBatch = claimQueuedSchedules(
|
||||
database,
|
||||
new Date('2026-08-13T00:01:00.000Z'),
|
||||
2
|
||||
)
|
||||
@@ -1359,7 +1567,8 @@ describe('AssistantDatabase', () => {
|
||||
new Set(firstBatch.map((claim) => claim.schedule.id)).size
|
||||
).toBe(2)
|
||||
|
||||
const secondBatch = database.claimDueSchedules(
|
||||
const secondBatch = claimQueuedSchedules(
|
||||
database,
|
||||
new Date('2026-08-13T00:01:00.000Z'),
|
||||
2
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
AssistantProject,
|
||||
AssistantSchedule,
|
||||
AssistantTask,
|
||||
ConversationQueueItem,
|
||||
ConversationMessage,
|
||||
ConversationSnapshot,
|
||||
ExpertCreateInput,
|
||||
@@ -251,11 +252,32 @@ type ScheduleWithTaskRow = ScheduleRow & {
|
||||
conversation_id: string
|
||||
}
|
||||
|
||||
export type ClaimedSchedule = {
|
||||
schedule: AssistantSchedule
|
||||
runId: string
|
||||
type ConversationQueueRow = {
|
||||
id: string
|
||||
conversation_id: string
|
||||
source: ConversationQueueItem['source']
|
||||
label: string
|
||||
payload_json: string
|
||||
schedule_run_id: string | null
|
||||
schedule_id: string | null
|
||||
task_id: string | null
|
||||
status: 'pending' | 'dispatching'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type ClaimedConversationQueueItem =
|
||||
| {
|
||||
source: 'user'
|
||||
item: ConversationQueueItem & { source: 'user' }
|
||||
payloadJson: string
|
||||
}
|
||||
| {
|
||||
source: 'schedule'
|
||||
item: ConversationQueueItem & { source: 'schedule' }
|
||||
schedule: AssistantSchedule
|
||||
runId: string
|
||||
}
|
||||
|
||||
type ExpertRow = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -554,6 +576,21 @@ function toSchedule(row: ScheduleWithTaskRow): AssistantSchedule {
|
||||
}
|
||||
}
|
||||
|
||||
function toConversationQueueItem(
|
||||
row: ConversationQueueRow
|
||||
): ConversationQueueItem {
|
||||
return {
|
||||
id: row.id,
|
||||
conversationId: row.conversation_id,
|
||||
source: row.source,
|
||||
label: row.label,
|
||||
createdAt: row.created_at,
|
||||
scheduleRunId: row.schedule_run_id ?? undefined,
|
||||
scheduleId: row.schedule_id ?? undefined,
|
||||
taskId: row.task_id ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
function toExpert(row: ExpertRow): AssistantExpert {
|
||||
let routingKeywords: string[]
|
||||
let modelProfileId: string | undefined
|
||||
@@ -1012,6 +1049,24 @@ export class AssistantDatabase {
|
||||
WHERE status = 'running'`
|
||||
)
|
||||
.run()
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE conversation_queue_items
|
||||
SET status = 'pending'
|
||||
WHERE status = 'dispatching'`
|
||||
)
|
||||
.run()
|
||||
database.exec(`
|
||||
INSERT OR IGNORE INTO conversation_queue_items
|
||||
(id, conversation_id, source, label, payload_json,
|
||||
schedule_run_id, schedule_id, task_id, status, created_at)
|
||||
SELECT sr.id, t.conversation_id, 'schedule', t.title, '{}',
|
||||
sr.id, sr.schedule_id, t.id, 'pending', sr.scheduled_for
|
||||
FROM schedule_runs sr
|
||||
INNER JOIN tasks t ON t.schedule_id = sr.schedule_id
|
||||
WHERE sr.status = 'pending'
|
||||
AND t.conversation_id IS NOT NULL;
|
||||
`)
|
||||
const interruptedTasks = database
|
||||
.prepare(
|
||||
`SELECT id, error
|
||||
@@ -1115,6 +1170,7 @@ export class AssistantDatabase {
|
||||
'delegation_outbox',
|
||||
'delegations',
|
||||
'notifications',
|
||||
'conversation_queue_items',
|
||||
'schedule_runs',
|
||||
'schedules',
|
||||
'memory_items',
|
||||
@@ -3517,6 +3573,317 @@ export class AssistantDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
listConversationQueueItems(
|
||||
conversationId?: string
|
||||
): ConversationQueueItem[] {
|
||||
const database = this.requireDatabase()
|
||||
const rows = conversationId
|
||||
? database
|
||||
.prepare(
|
||||
`SELECT *
|
||||
FROM conversation_queue_items
|
||||
WHERE conversation_id = ? AND status = 'pending'
|
||||
ORDER BY created_at ASC, rowid ASC
|
||||
LIMIT 100`
|
||||
)
|
||||
.all(conversationId)
|
||||
: database
|
||||
.prepare(
|
||||
`SELECT *
|
||||
FROM conversation_queue_items
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at ASC, rowid ASC
|
||||
LIMIT 500`
|
||||
)
|
||||
.all()
|
||||
return (rows as ConversationQueueRow[]).map(
|
||||
toConversationQueueItem
|
||||
)
|
||||
}
|
||||
|
||||
listPendingScheduleQueueConversationIds(limit = 4): string[] {
|
||||
const safeLimit = Math.max(1, Math.min(20, Math.trunc(limit)))
|
||||
const rows = this.requireDatabase()
|
||||
.prepare(
|
||||
`SELECT conversation_id
|
||||
FROM conversation_queue_items
|
||||
WHERE source = 'schedule' AND status = 'pending'
|
||||
GROUP BY conversation_id
|
||||
ORDER BY MIN(created_at) ASC, MIN(rowid) ASC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(safeLimit) as Array<{ conversation_id: string }>
|
||||
return rows.map((row) => row.conversation_id)
|
||||
}
|
||||
|
||||
listPendingConversationQueueIds(): string[] {
|
||||
const rows = this.requireDatabase()
|
||||
.prepare(
|
||||
`SELECT conversation_id
|
||||
FROM conversation_queue_items
|
||||
WHERE status = 'pending'
|
||||
GROUP BY conversation_id
|
||||
ORDER BY MIN(created_at) ASC, MIN(rowid) ASC`
|
||||
)
|
||||
.all() as Array<{ conversation_id: string }>
|
||||
return rows.map((row) => row.conversation_id)
|
||||
}
|
||||
|
||||
getConversationQueueItem(
|
||||
itemId: string
|
||||
): ConversationQueueItem | undefined {
|
||||
const row = this.requireDatabase()
|
||||
.prepare(
|
||||
`SELECT *
|
||||
FROM conversation_queue_items
|
||||
WHERE id = ?`
|
||||
)
|
||||
.get(itemId) as ConversationQueueRow | undefined
|
||||
return row ? toConversationQueueItem(row) : undefined
|
||||
}
|
||||
|
||||
getConversationUserQueuePayloadJson(itemId: string): string | undefined {
|
||||
const row = this.requireDatabase()
|
||||
.prepare(
|
||||
`SELECT payload_json
|
||||
FROM conversation_queue_items
|
||||
WHERE id = ? AND source = 'user'`
|
||||
)
|
||||
.get(itemId) as { payload_json: string } | undefined
|
||||
return row?.payload_json
|
||||
}
|
||||
|
||||
isConversationUserQueueItemDispatching(itemId: string): boolean {
|
||||
return Boolean(
|
||||
this.requireDatabase()
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM conversation_queue_items
|
||||
WHERE id = ? AND source = 'user' AND status = 'dispatching'`
|
||||
)
|
||||
.get(itemId)
|
||||
)
|
||||
}
|
||||
|
||||
enqueueConversationUserInput(input: {
|
||||
conversationId: string
|
||||
label: string
|
||||
payloadJson: string
|
||||
}): ConversationQueueItem {
|
||||
const database = this.requireDatabase()
|
||||
const id = randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
const label = input.label.trim().slice(0, 200)
|
||||
if (!label) {
|
||||
throw new Error('待发送消息标题不能为空')
|
||||
}
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const conversation = database
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM conversations
|
||||
WHERE id = ? AND status = 'active' AND channel IS NULL`
|
||||
)
|
||||
.get(input.conversationId)
|
||||
if (!conversation) {
|
||||
throw new Error('对话不存在或不能加入发送队列')
|
||||
}
|
||||
const count = database
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM conversation_queue_items
|
||||
WHERE conversation_id = ?
|
||||
AND status IN ('pending', 'dispatching')`
|
||||
)
|
||||
.get(input.conversationId) as { count: number }
|
||||
if (count.count >= 20) {
|
||||
throw new Error('当前对话最多保留 20 条待执行项')
|
||||
}
|
||||
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 (?, ?, 'user', ?, ?, NULL, NULL, NULL, 'pending', ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
input.conversationId,
|
||||
label,
|
||||
input.payloadJson,
|
||||
now
|
||||
)
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
return this.getConversationQueueItem(id)!
|
||||
}
|
||||
|
||||
claimConversationQueueItem(
|
||||
conversationId: string,
|
||||
preferredItemId?: string
|
||||
): ClaimedConversationQueueItem | undefined {
|
||||
const database = this.requireDatabase()
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const row = database
|
||||
.prepare(
|
||||
preferredItemId
|
||||
? `SELECT *
|
||||
FROM conversation_queue_items
|
||||
WHERE id = ? AND conversation_id = ?
|
||||
AND status = 'pending'`
|
||||
: `SELECT *
|
||||
FROM conversation_queue_items
|
||||
WHERE conversation_id = ? AND status = 'pending'
|
||||
ORDER BY created_at ASC, rowid ASC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(
|
||||
...(preferredItemId
|
||||
? [preferredItemId, conversationId]
|
||||
: [conversationId])
|
||||
) as ConversationQueueRow | undefined
|
||||
if (!row) {
|
||||
database.exec('COMMIT')
|
||||
return undefined
|
||||
}
|
||||
const claimed = database
|
||||
.prepare(
|
||||
`UPDATE conversation_queue_items
|
||||
SET status = 'dispatching'
|
||||
WHERE id = ? AND status = 'pending'`
|
||||
)
|
||||
.run(row.id)
|
||||
if (claimed.changes !== 1) {
|
||||
database.exec('COMMIT')
|
||||
return undefined
|
||||
}
|
||||
const item = toConversationQueueItem({
|
||||
...row,
|
||||
status: 'dispatching'
|
||||
})
|
||||
if (row.source === 'user') {
|
||||
database.exec('COMMIT')
|
||||
return {
|
||||
source: 'user',
|
||||
item: { ...item, source: 'user' },
|
||||
payloadJson: row.payload_json
|
||||
}
|
||||
}
|
||||
if (!row.schedule_run_id) {
|
||||
throw new Error('定时任务队列项缺少运行记录')
|
||||
}
|
||||
const scheduleRow = database
|
||||
.prepare(
|
||||
`SELECT s.*, t.id AS task_id,
|
||||
t.conversation_id AS conversation_id
|
||||
FROM schedule_runs sr
|
||||
INNER JOIN schedules s ON s.id = sr.schedule_id
|
||||
INNER JOIN tasks t ON t.schedule_id = s.id
|
||||
WHERE sr.id = ? AND sr.status = 'pending'`
|
||||
)
|
||||
.get(row.schedule_run_id) as ScheduleWithTaskRow | undefined
|
||||
if (!scheduleRow) {
|
||||
throw new Error('定时任务队列项已失效')
|
||||
}
|
||||
const scheduleClaim = database
|
||||
.prepare(
|
||||
`UPDATE schedule_runs
|
||||
SET status = 'running'
|
||||
WHERE id = ? AND status = 'pending'`
|
||||
)
|
||||
.run(row.schedule_run_id)
|
||||
if (scheduleClaim.changes !== 1) {
|
||||
throw new Error('定时任务运行记录无法认领')
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
return {
|
||||
source: 'schedule',
|
||||
item: { ...item, source: 'schedule' },
|
||||
schedule: toSchedule(scheduleRow),
|
||||
runId: row.schedule_run_id
|
||||
}
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
completeConversationUserQueueItem(itemId: string): void {
|
||||
const result = this.requireDatabase()
|
||||
.prepare(
|
||||
`DELETE FROM conversation_queue_items
|
||||
WHERE id = ? AND source = 'user' AND status = 'dispatching'`
|
||||
)
|
||||
.run(itemId)
|
||||
if (result.changes !== 1) {
|
||||
throw new Error('待发送消息不存在或状态已变化')
|
||||
}
|
||||
}
|
||||
|
||||
releaseConversationUserQueueItem(itemId: string): void {
|
||||
const result = this.requireDatabase()
|
||||
.prepare(
|
||||
`UPDATE conversation_queue_items
|
||||
SET status = 'pending'
|
||||
WHERE id = ? AND source = 'user' AND status = 'dispatching'`
|
||||
)
|
||||
.run(itemId)
|
||||
if (
|
||||
result.changes !== 1 &&
|
||||
!this.requireDatabase()
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM conversation_queue_items
|
||||
WHERE id = ? AND source = 'user' AND status = 'pending'`
|
||||
)
|
||||
.get(itemId)
|
||||
) {
|
||||
throw new Error('待发送消息不存在或状态已变化')
|
||||
}
|
||||
}
|
||||
|
||||
removeConversationUserQueueItem(itemId: string): void {
|
||||
const result = this.requireDatabase()
|
||||
.prepare(
|
||||
`DELETE FROM conversation_queue_items
|
||||
WHERE id = ? AND source = 'user' AND status = 'pending'`
|
||||
)
|
||||
.run(itemId)
|
||||
if (result.changes !== 1) {
|
||||
throw new Error('待发送消息不存在或状态已变化')
|
||||
}
|
||||
}
|
||||
|
||||
cancelConversationQueueItem(
|
||||
itemId: string
|
||||
): ConversationQueueItem {
|
||||
const row = this.requireDatabase()
|
||||
.prepare(
|
||||
`SELECT *
|
||||
FROM conversation_queue_items
|
||||
WHERE id = ? AND status = 'pending'`
|
||||
)
|
||||
.get(itemId) as ConversationQueueRow | undefined
|
||||
if (!row) {
|
||||
throw new Error('待执行项不存在或状态已变化')
|
||||
}
|
||||
const item = toConversationQueueItem(row)
|
||||
if (row.source === 'user') {
|
||||
this.removeConversationUserQueueItem(itemId)
|
||||
return item
|
||||
}
|
||||
if (!row.schedule_run_id) {
|
||||
throw new Error('定时任务队列项缺少运行记录')
|
||||
}
|
||||
this.completeScheduleRun(row.schedule_run_id, 'cancelled')
|
||||
return item
|
||||
}
|
||||
|
||||
listSchedules(projectId?: string): AssistantSchedule[] {
|
||||
const rows = projectId
|
||||
? this.requireDatabase()
|
||||
@@ -3772,21 +4139,22 @@ export class AssistantDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
claimDueSchedules(now = new Date(), limit = 4): ClaimedSchedule[] {
|
||||
queueDueSchedules(
|
||||
now = new Date(),
|
||||
limit = 100
|
||||
): ConversationQueueItem[] {
|
||||
const database = this.requireDatabase()
|
||||
const nowIso = now.toISOString()
|
||||
const safeLimit = Math.max(1, Math.min(16, Math.trunc(limit)))
|
||||
const safeLimit = Math.max(1, Math.min(100, Math.trunc(limit)))
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const claims: ClaimedSchedule[] = []
|
||||
const pending = database
|
||||
const due = database
|
||||
.prepare(
|
||||
`SELECT sr.id AS run_id, s.*, t.id AS task_id,
|
||||
`SELECT s.*, t.id AS task_id,
|
||||
t.conversation_id AS conversation_id
|
||||
FROM schedule_runs sr
|
||||
INNER JOIN schedules s ON s.id = sr.schedule_id
|
||||
FROM schedules s
|
||||
INNER JOIN tasks t ON t.schedule_id = s.id
|
||||
WHERE sr.status = 'pending'
|
||||
WHERE s.enabled = 1 AND s.next_run_at <= ?
|
||||
AND (
|
||||
s.project_id IS NULL OR EXISTS (
|
||||
SELECT 1 FROM projects p
|
||||
@@ -3795,83 +4163,69 @@ export class AssistantDatabase {
|
||||
AND p.kind = 'user'
|
||||
)
|
||||
)
|
||||
ORDER BY sr.scheduled_for
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM schedule_runs active
|
||||
WHERE active.schedule_id = s.id
|
||||
AND active.status IN ('pending', 'running')
|
||||
)
|
||||
ORDER BY s.next_run_at
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(safeLimit) as Array<
|
||||
ScheduleWithTaskRow & { run_id: string }
|
||||
>
|
||||
const claimPending = database
|
||||
.prepare(
|
||||
`UPDATE schedule_runs
|
||||
SET status = 'running'
|
||||
WHERE id = ? AND status = 'pending'`
|
||||
)
|
||||
for (const row of pending) {
|
||||
if (claimPending.run(row.run_id).changes === 1) {
|
||||
claims.push({
|
||||
schedule: toSchedule(row),
|
||||
runId: row.run_id
|
||||
.all(nowIso, safeLimit) as ScheduleWithTaskRow[]
|
||||
const insertRun = database.prepare(
|
||||
`INSERT OR IGNORE INTO schedule_runs
|
||||
(id, schedule_id, scheduled_for, task_id, status)
|
||||
VALUES (?, ?, ?, ?, 'pending')`
|
||||
)
|
||||
const insertQueueItem = 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', ?)`
|
||||
)
|
||||
const queued: ConversationQueueItem[] = []
|
||||
for (const row of due) {
|
||||
const schedule = toSchedule(row)
|
||||
const runId = randomUUID()
|
||||
if (
|
||||
insertRun.run(
|
||||
runId,
|
||||
schedule.id,
|
||||
schedule.nextRunAt,
|
||||
schedule.taskId
|
||||
).changes === 1
|
||||
) {
|
||||
insertQueueItem.run(
|
||||
runId,
|
||||
schedule.conversationId,
|
||||
schedule.title,
|
||||
runId,
|
||||
schedule.id,
|
||||
schedule.taskId,
|
||||
schedule.nextRunAt
|
||||
)
|
||||
queued.push({
|
||||
id: runId,
|
||||
conversationId: schedule.conversationId,
|
||||
source: 'schedule',
|
||||
label: schedule.title,
|
||||
createdAt: schedule.nextRunAt,
|
||||
scheduleRunId: runId,
|
||||
scheduleId: schedule.id,
|
||||
taskId: schedule.taskId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = safeLimit - claims.length
|
||||
if (remaining > 0) {
|
||||
const due = database
|
||||
.prepare(
|
||||
`SELECT s.*, t.id AS task_id,
|
||||
t.conversation_id AS conversation_id
|
||||
FROM schedules s
|
||||
INNER JOIN tasks t ON t.schedule_id = s.id
|
||||
WHERE s.enabled = 1 AND s.next_run_at <= ?
|
||||
AND (
|
||||
s.project_id IS NULL OR EXISTS (
|
||||
SELECT 1 FROM projects p
|
||||
WHERE p.id = s.project_id
|
||||
AND p.status = 'active'
|
||||
AND p.kind = 'user'
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM schedule_runs active
|
||||
WHERE active.schedule_id = s.id
|
||||
AND active.status IN ('pending', 'running')
|
||||
)
|
||||
ORDER BY s.next_run_at
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(nowIso, remaining) as ScheduleWithTaskRow[]
|
||||
const insertRun = database.prepare(
|
||||
`INSERT OR IGNORE INTO schedule_runs
|
||||
(id, schedule_id, scheduled_for, task_id, status)
|
||||
VALUES (?, ?, ?, ?, 'running')`
|
||||
)
|
||||
for (const row of due) {
|
||||
const schedule = toSchedule(row)
|
||||
const runId = randomUUID()
|
||||
if (
|
||||
insertRun.run(
|
||||
runId,
|
||||
schedule.id,
|
||||
schedule.nextRunAt,
|
||||
schedule.taskId
|
||||
).changes === 1
|
||||
) {
|
||||
claims.push({ schedule, runId })
|
||||
}
|
||||
}
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
return claims
|
||||
return queued
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
claimScheduleNow(scheduleId: string): ClaimedSchedule {
|
||||
queueScheduleNow(scheduleId: string): ConversationQueueItem {
|
||||
const schedule = this.getSchedule(scheduleId)
|
||||
const database = this.requireDatabase()
|
||||
const runId = randomUUID()
|
||||
@@ -3897,7 +4251,7 @@ export class AssistantDatabase {
|
||||
.prepare(
|
||||
`INSERT INTO schedule_runs
|
||||
(id, schedule_id, scheduled_for, task_id, status)
|
||||
VALUES (?, ?, ?, ?, 'running')
|
||||
VALUES (?, ?, ?, ?, 'pending')
|
||||
ON CONFLICT(schedule_id, scheduled_for) DO NOTHING`
|
||||
)
|
||||
.run(
|
||||
@@ -3910,17 +4264,34 @@ export class AssistantDatabase {
|
||||
if (!inserted) {
|
||||
throw new Error('定时任务正在启动,请稍后重试')
|
||||
}
|
||||
const createdAt = new Date(baseTime).toISOString()
|
||||
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,
|
||||
schedule.conversationId,
|
||||
schedule.title,
|
||||
runId,
|
||||
schedule.id,
|
||||
schedule.taskId,
|
||||
createdAt
|
||||
)
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
return { schedule, runId }
|
||||
return this.getConversationQueueItem(runId)!
|
||||
}
|
||||
|
||||
completeScheduleRun(
|
||||
runId: string,
|
||||
status: 'completed' | 'failed',
|
||||
status: 'completed' | 'failed' | 'cancelled',
|
||||
now = new Date()
|
||||
): void {
|
||||
const database = this.requireDatabase()
|
||||
@@ -3935,7 +4306,8 @@ export class AssistantDatabase {
|
||||
FROM schedule_runs sr
|
||||
INNER JOIN schedules s ON s.id = sr.schedule_id
|
||||
INNER JOIN tasks t ON t.schedule_id = s.id
|
||||
WHERE sr.id = ? AND sr.status = 'running'`
|
||||
WHERE sr.id = ?
|
||||
AND sr.status IN ('pending', 'running')`
|
||||
)
|
||||
.get(runId) as
|
||||
| (ScheduleWithTaskRow & { scheduled_for: string })
|
||||
@@ -3950,6 +4322,12 @@ export class AssistantDatabase {
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(status, runId)
|
||||
database
|
||||
.prepare(
|
||||
`DELETE FROM conversation_queue_items
|
||||
WHERE schedule_run_id = ?`
|
||||
)
|
||||
.run(runId)
|
||||
const schedule = toSchedule(row)
|
||||
if (row.scheduled_for === row.next_run_at) {
|
||||
const next = new Date(row.scheduled_for)
|
||||
@@ -3972,7 +4350,12 @@ export class AssistantDatabase {
|
||||
.prepare(
|
||||
`UPDATE schedules
|
||||
SET enabled = CASE WHEN ? = 1 THEN 0 ELSE enabled END,
|
||||
next_run_at = ?, last_run_at = ?, updated_at = ?
|
||||
next_run_at = ?,
|
||||
last_run_at = CASE
|
||||
WHEN ? = 'cancelled' THEN last_run_at
|
||||
ELSE ?
|
||||
END,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND next_run_at = ?`
|
||||
)
|
||||
.run(
|
||||
@@ -3980,19 +4363,22 @@ export class AssistantDatabase {
|
||||
schedule.recurrence === 'once'
|
||||
? schedule.nextRunAt
|
||||
: next.toISOString(),
|
||||
status,
|
||||
nowIso,
|
||||
nowIso,
|
||||
schedule.id,
|
||||
row.scheduled_for
|
||||
)
|
||||
} else {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE schedules
|
||||
SET last_run_at = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(nowIso, nowIso, schedule.id)
|
||||
if (status !== 'cancelled') {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE schedules
|
||||
SET last_run_at = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(nowIso, nowIso, schedule.id)
|
||||
}
|
||||
}
|
||||
const updatedSchedule = database
|
||||
.prepare('SELECT enabled FROM schedules WHERE id = ?')
|
||||
@@ -4000,6 +4386,9 @@ export class AssistantDatabase {
|
||||
const finalTaskStatus =
|
||||
status === 'failed'
|
||||
? 'failed'
|
||||
: status === 'cancelled' &&
|
||||
schedule.recurrence === 'once'
|
||||
? 'cancelled'
|
||||
: updatedSchedule.enabled === 1
|
||||
? 'queued'
|
||||
: schedule.recurrence === 'once'
|
||||
@@ -4010,7 +4399,7 @@ export class AssistantDatabase {
|
||||
`UPDATE tasks
|
||||
SET status = ?,
|
||||
completed_at = CASE
|
||||
WHEN ? IN ('completed', 'failed') THEN ?
|
||||
WHEN ? IN ('completed', 'failed', 'cancelled') THEN ?
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE id = ?`
|
||||
@@ -5352,12 +5741,12 @@ export class AssistantDatabase {
|
||||
const version = database
|
||||
.prepare('PRAGMA user_version')
|
||||
.get() as { user_version: number }
|
||||
if (version.user_version > 22) {
|
||||
if (version.user_version > 23) {
|
||||
throw new Error(
|
||||
`当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试`
|
||||
)
|
||||
}
|
||||
if (version.user_version === 22) {
|
||||
if (version.user_version === 23) {
|
||||
return
|
||||
}
|
||||
if (version.user_version < 1) {
|
||||
@@ -6562,6 +6951,56 @@ export class AssistantDatabase {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (version.user_version < 23) {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS conversation_queue_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL
|
||||
REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
source TEXT NOT NULL CHECK(source IN ('user', 'schedule')),
|
||||
label TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
schedule_run_id TEXT UNIQUE
|
||||
REFERENCES schedule_runs(id) ON DELETE CASCADE,
|
||||
schedule_id TEXT
|
||||
REFERENCES schedules(id) ON DELETE CASCADE,
|
||||
task_id TEXT
|
||||
REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL
|
||||
CHECK(status IN ('pending', 'dispatching')),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS conversation_queue_pending_idx
|
||||
ON conversation_queue_items(
|
||||
conversation_id, status, created_at, id
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS conversation_queue_schedule_pending_idx
|
||||
ON conversation_queue_items(
|
||||
source, status, created_at, conversation_id
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS conversation_queue_status_created_idx
|
||||
ON conversation_queue_items(
|
||||
status, created_at, conversation_id
|
||||
);
|
||||
INSERT OR IGNORE INTO conversation_queue_items
|
||||
(id, conversation_id, source, label, payload_json,
|
||||
schedule_run_id, schedule_id, task_id, status, created_at)
|
||||
SELECT sr.id, t.conversation_id, 'schedule', t.title, '{}',
|
||||
sr.id, sr.schedule_id, t.id, 'pending', sr.scheduled_for
|
||||
FROM schedule_runs sr
|
||||
INNER JOIN tasks t ON t.schedule_id = sr.schedule_id
|
||||
WHERE sr.status IN ('pending', 'running')
|
||||
AND t.conversation_id IS NOT NULL;
|
||||
PRAGMA user_version = 23;
|
||||
COMMIT;
|
||||
`)
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private requireDatabase(): DatabaseSync {
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
|
||||
).count
|
||||
check.close()
|
||||
migrated.close()
|
||||
expect(version).toBe(22)
|
||||
expect(version).toBe(23)
|
||||
expect(heartbeatTableCount).toBe(4)
|
||||
})
|
||||
|
||||
|
||||
@@ -121,6 +121,45 @@ describe('ContextManager', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('serializes queued attachments and restores their bounded contents', async () => {
|
||||
const manager = new ContextManager()
|
||||
const content = Buffer.from('persisted queued context', 'utf8')
|
||||
const attachment = await manager.ingestRemoteAttachment({
|
||||
name: 'queued.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: content.byteLength,
|
||||
kind: 'file',
|
||||
dataBase64: content.toString('base64')
|
||||
})
|
||||
const serialized = manager.serializeForQueue([attachment.id])
|
||||
|
||||
manager.clear()
|
||||
manager.restoreFromQueue(serialized)
|
||||
|
||||
expect(
|
||||
manager.enrichRequest({
|
||||
requestId: '1f6a37b6-e0a3-449f-8878-b10d353fbfb4',
|
||||
conversationId: 'conversation-1',
|
||||
prompt: 'summarize',
|
||||
contextIds: [attachment.id]
|
||||
}).prompt
|
||||
).toContain('persisted queued context')
|
||||
expect(() =>
|
||||
manager.restoreFromQueue(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'bad',
|
||||
name: 'bad.txt',
|
||||
preview: '',
|
||||
kind: 'text',
|
||||
size: 99,
|
||||
content: 'short'
|
||||
}
|
||||
])
|
||||
)
|
||||
).toThrow('待发送文本附件大小无效')
|
||||
})
|
||||
|
||||
it('only enriches prompts with files explicitly selected by the user', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-context-'))
|
||||
temporaryDirectories.push(directory)
|
||||
|
||||
@@ -585,6 +585,138 @@ export class ContextManager {
|
||||
}
|
||||
}
|
||||
|
||||
serializeForQueue(contextIds: string[]): string {
|
||||
if (contextIds.length > maximumAttachmentsPerMessage) {
|
||||
throw new Error('单次消息最多添加 8 个附件')
|
||||
}
|
||||
const contexts = contextIds.map((contextId) => {
|
||||
const context = this.contexts.get(contextId)
|
||||
if (!context) {
|
||||
throw new Error('附件上下文已失效,请重新添加')
|
||||
}
|
||||
return context
|
||||
})
|
||||
return JSON.stringify(contexts)
|
||||
}
|
||||
|
||||
restoreFromQueue(serialized: string): void {
|
||||
if (
|
||||
Buffer.byteLength(serialized) >
|
||||
maximumContextBytes * 2 + 2_000_000
|
||||
) {
|
||||
throw new Error('待发送附件数据超过恢复上限')
|
||||
}
|
||||
const parsed = JSON.parse(serialized) as unknown
|
||||
if (
|
||||
!Array.isArray(parsed) ||
|
||||
parsed.length > maximumAttachmentsPerMessage
|
||||
) {
|
||||
throw new Error('待发送附件数据无效')
|
||||
}
|
||||
const restoredContexts: StoredContext[] = []
|
||||
const restoredIds = new Set<string>()
|
||||
for (const value of parsed) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('待发送附件数据无效')
|
||||
}
|
||||
const candidate = value as Record<string, unknown>
|
||||
if (
|
||||
typeof candidate.id !== 'string' ||
|
||||
candidate.id.length === 0 ||
|
||||
candidate.id.length > 200 ||
|
||||
typeof candidate.name !== 'string' ||
|
||||
candidate.name.length === 0 ||
|
||||
candidate.name.length > 500 ||
|
||||
typeof candidate.preview !== 'string' ||
|
||||
candidate.preview.length > 500 ||
|
||||
(candidate.kind !== 'text' && candidate.kind !== 'image')
|
||||
) {
|
||||
throw new Error('待发送附件数据无效')
|
||||
}
|
||||
if (this.contexts.has(candidate.id)) {
|
||||
continue
|
||||
}
|
||||
if (restoredIds.has(candidate.id)) {
|
||||
throw new Error('待发送附件数据包含重复项目')
|
||||
}
|
||||
let context: StoredContext
|
||||
if (candidate.kind === 'text') {
|
||||
if (typeof candidate.content !== 'string') {
|
||||
throw new Error('待发送文本附件数据无效')
|
||||
}
|
||||
const size = Buffer.byteLength(candidate.content)
|
||||
if (
|
||||
size === 0 ||
|
||||
size > maximumContextBytes ||
|
||||
candidate.size !== size
|
||||
) {
|
||||
throw new Error('待发送文本附件大小无效')
|
||||
}
|
||||
context = {
|
||||
id: candidate.id,
|
||||
name: candidate.name,
|
||||
preview: candidate.preview,
|
||||
kind: 'text',
|
||||
size,
|
||||
content: candidate.content
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
candidate.mediaType !== 'image/jpeg' ||
|
||||
typeof candidate.data !== 'string' ||
|
||||
!/^[A-Za-z0-9+/]+={0,2}$/u.test(candidate.data)
|
||||
) {
|
||||
throw new Error('待发送图片附件数据无效')
|
||||
}
|
||||
const image = Buffer.from(candidate.data, 'base64')
|
||||
if (
|
||||
image.byteLength === 0 ||
|
||||
image.byteLength > maximumContextBytes ||
|
||||
candidate.size !== image.byteLength
|
||||
) {
|
||||
throw new Error('待发送图片附件大小无效')
|
||||
}
|
||||
const thumbnailUrl =
|
||||
typeof candidate.thumbnailUrl === 'string' &&
|
||||
candidate.thumbnailUrl.length <= 2_000_000 &&
|
||||
candidate.thumbnailUrl.startsWith(
|
||||
'data:image/jpeg;base64,'
|
||||
)
|
||||
? candidate.thumbnailUrl
|
||||
: undefined
|
||||
context = {
|
||||
id: candidate.id,
|
||||
name: candidate.name,
|
||||
preview: candidate.preview,
|
||||
kind: 'image',
|
||||
size: image.byteLength,
|
||||
mediaType: 'image/jpeg',
|
||||
data: candidate.data,
|
||||
...(thumbnailUrl ? { thumbnailUrl } : {})
|
||||
}
|
||||
}
|
||||
restoredIds.add(context.id)
|
||||
restoredContexts.push(context)
|
||||
}
|
||||
const restoredBytes = restoredContexts.reduce(
|
||||
(total, context) => total + context.size,
|
||||
0
|
||||
)
|
||||
if (
|
||||
this.contexts.size + restoredContexts.length >
|
||||
maximumContextCount
|
||||
) {
|
||||
throw new Error('最多可暂存 16 个上下文项目')
|
||||
}
|
||||
if (this.totalBytes + restoredBytes > maximumContextBytes) {
|
||||
throw new Error('上下文总大小不能超过 12MB')
|
||||
}
|
||||
for (const context of restoredContexts) {
|
||||
this.contexts.set(context.id, context)
|
||||
this.totalBytes += context.size
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.contexts.clear()
|
||||
this.totalBytes = 0
|
||||
|
||||
+459
-28
@@ -3,7 +3,10 @@ import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import type { AssistantProject } from '../shared/assistant-contracts'
|
||||
import type {
|
||||
AssistantProject,
|
||||
ConversationQueueItem
|
||||
} from '../shared/assistant-contracts'
|
||||
import type { AgentEvent, BrowserLiveState } from '../shared/contracts'
|
||||
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
|
||||
import { AssistantDatabase } from './assistant/assistant-database'
|
||||
@@ -163,7 +166,11 @@ describe('registerIpcHandlers computer capabilities', () => {
|
||||
capabilityService as never,
|
||||
{ clear: vi.fn(), selectFiles } as never,
|
||||
{} as never,
|
||||
{ claimDueSchedules: vi.fn(() => []) } as never,
|
||||
{
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => [])
|
||||
} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
onRuntimeSettingsChanged,
|
||||
@@ -384,7 +391,11 @@ describe('registerIpcHandlers update source routing', () => {
|
||||
{} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
{ claimDueSchedules: vi.fn(() => []) } as never,
|
||||
{
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => [])
|
||||
} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
vi.fn(async () => undefined),
|
||||
@@ -502,7 +513,11 @@ describe('registerIpcHandlers model download source routing', () => {
|
||||
{} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
{ claimDueSchedules: vi.fn(() => []) } as never,
|
||||
{
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => [])
|
||||
} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
vi.fn(async () => undefined),
|
||||
@@ -694,7 +709,11 @@ describe('registerIpcHandlers DSH runtime extensions', () => {
|
||||
{} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
{ claimDueSchedules: vi.fn(() => []) } as never,
|
||||
{
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => [])
|
||||
} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
onRuntimeSettingsChanged,
|
||||
@@ -879,7 +898,9 @@ describe('registerIpcHandlers lifecycle tracking', () => {
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
{
|
||||
claimDueSchedules: vi.fn(() => []),
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
repairConversationRuntimeSelections: vi.fn()
|
||||
} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
@@ -1007,7 +1028,11 @@ describe('registerIpcHandlers knowledge snapshot ontology', () => {
|
||||
{} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
knowledgeService as never,
|
||||
{ claimDueSchedules: vi.fn(() => []) } as never,
|
||||
{
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => [])
|
||||
} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
vi.fn(async () => undefined)
|
||||
@@ -1091,7 +1116,11 @@ describe('registerIpcHandlers knowledge embedding index', () => {
|
||||
{} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
knowledgeService as never,
|
||||
{ claimDueSchedules: vi.fn(() => []) } as never,
|
||||
{
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => [])
|
||||
} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
vi.fn(async () => undefined)
|
||||
@@ -1181,7 +1210,11 @@ describe('registerIpcHandlers knowledge task actions', () => {
|
||||
{} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
knowledgeService as never,
|
||||
{ claimDueSchedules: vi.fn(() => []) } as never,
|
||||
{
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => [])
|
||||
} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
vi.fn(async () => undefined)
|
||||
@@ -1280,7 +1313,11 @@ describe('registerIpcHandlers model ZIP dialogs', () => {
|
||||
{} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
{ claimDueSchedules: vi.fn(() => []) } as never,
|
||||
{
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => [])
|
||||
} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
vi.fn(async () => undefined),
|
||||
@@ -1453,7 +1490,9 @@ describe('registerIpcHandlers document parsing', () => {
|
||||
}
|
||||
const createInlineArtifact = vi.fn((input) => input)
|
||||
const assistantDatabase = {
|
||||
claimDueSchedules: vi.fn(() => []),
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
createInlineArtifact
|
||||
}
|
||||
const webContents = {
|
||||
@@ -1622,7 +1661,11 @@ describe('registerIpcHandlers connection tests', () => {
|
||||
{} as never,
|
||||
contextManager as never,
|
||||
{} as never,
|
||||
{ claimDueSchedules: vi.fn(() => []) } as never,
|
||||
{
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => [])
|
||||
} as never,
|
||||
approvalBroker as never,
|
||||
{} as never,
|
||||
vi.fn(async () => {})
|
||||
@@ -1739,7 +1782,11 @@ describe('registerIpcHandlers connection tests', () => {
|
||||
{} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
{ claimDueSchedules: vi.fn(() => []) } as never,
|
||||
{
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => [])
|
||||
} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
vi.fn(async () => {}),
|
||||
@@ -1831,7 +1878,11 @@ describe('registerIpcHandlers Runtime config actions', () => {
|
||||
{} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
{ claimDueSchedules: vi.fn(() => []) } as never,
|
||||
{
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => [])
|
||||
} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
vi.fn(async () => {})
|
||||
@@ -1968,7 +2019,11 @@ describe('registerIpcHandlers window controls', () => {
|
||||
{} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
{ claimDueSchedules: vi.fn(() => []) } as never,
|
||||
{
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => [])
|
||||
} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
vi.fn(async () => {})
|
||||
@@ -2028,7 +2083,9 @@ describe('registerIpcHandlers workspace files', () => {
|
||||
await writeFile(join(rootPath, 'README.md'), '# GoodBuddy\n')
|
||||
const projectId = '00000000-0000-4000-8000-000000000101'
|
||||
const assistantDatabase = {
|
||||
claimDueSchedules: vi.fn(() => []),
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
getProject: vi.fn(() => ({ id: projectId, rootPath }))
|
||||
}
|
||||
const webContents = {
|
||||
@@ -2107,14 +2164,17 @@ describe('registerIpcHandlers token usage', () => {
|
||||
records: []
|
||||
}
|
||||
const assistantDatabase = {
|
||||
claimDueSchedules: vi.fn(() => []),
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
getTokenUsageSummary: vi.fn(() => summary)
|
||||
}
|
||||
const webContents = {
|
||||
mainFrame: {
|
||||
url: 'file:///goodbuddy/index.html'
|
||||
},
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html')
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
}
|
||||
const window = {
|
||||
webContents,
|
||||
@@ -2159,16 +2219,57 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
||||
})
|
||||
|
||||
it('validates and forwards incremental saves and explicit deletions', async () => {
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000301'
|
||||
const queuedAttachmentId =
|
||||
'00000000-0000-4000-8000-000000000303'
|
||||
const queuedItem = {
|
||||
id: '00000000-0000-4000-8000-000000000304',
|
||||
conversationId,
|
||||
source: 'user' as const,
|
||||
label: '待删除消息',
|
||||
createdAt: '2026-08-20T09:01:00.000Z'
|
||||
}
|
||||
const assistantDatabase = {
|
||||
claimDueSchedules: vi.fn(() => []),
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => [queuedItem]),
|
||||
listPendingConversationQueueIds: vi.fn(() => [conversationId]),
|
||||
getConversationUserQueuePayloadJson: vi.fn(() =>
|
||||
JSON.stringify({
|
||||
input: {
|
||||
conversationId,
|
||||
runtimeSelection: { provider: 'auto' },
|
||||
workMode: 'ask',
|
||||
includeMemoryContext: true,
|
||||
prompt: queuedItem.label,
|
||||
attachments: [
|
||||
{
|
||||
id: queuedAttachmentId,
|
||||
name: 'queued.txt',
|
||||
size: 6,
|
||||
preview: 'queued',
|
||||
kind: 'text'
|
||||
}
|
||||
],
|
||||
knowledgeLibraryIds: [],
|
||||
knowledgeRetrievalMode: 'auto'
|
||||
},
|
||||
serializedContexts: '[]'
|
||||
})
|
||||
),
|
||||
saveLocalConversations: vi.fn(),
|
||||
deleteLocalConversation: vi.fn(() => true)
|
||||
}
|
||||
const contextManager = {
|
||||
clear: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
const webContents = {
|
||||
mainFrame: {
|
||||
url: 'file:///goodbuddy/index.html'
|
||||
},
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html')
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
}
|
||||
const window = {
|
||||
webContents,
|
||||
@@ -2182,7 +2283,7 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
||||
'CommandOrControl+Shift+Space',
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
contextManager as never,
|
||||
{} as never,
|
||||
assistantDatabase as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
@@ -2193,8 +2294,6 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
||||
sender: webContents,
|
||||
senderFrame: webContents.mainFrame
|
||||
}
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000301'
|
||||
const messageId = '00000000-0000-4000-8000-000000000302'
|
||||
const batch = [
|
||||
{
|
||||
@@ -2231,6 +2330,9 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
||||
expect(
|
||||
assistantDatabase.deleteLocalConversation
|
||||
).toHaveBeenCalledWith(conversationId)
|
||||
expect(contextManager.remove).toHaveBeenCalledWith(
|
||||
queuedAttachmentId
|
||||
)
|
||||
|
||||
expect(() =>
|
||||
electronMocks.handlers.get(
|
||||
@@ -2260,7 +2362,9 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
||||
|
||||
it('waits for the renderer persistence acknowledgement before removing handlers', async () => {
|
||||
const assistantDatabase = {
|
||||
claimDueSchedules: vi.fn(() => [])
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => [])
|
||||
}
|
||||
const webContents = {
|
||||
mainFrame: {
|
||||
@@ -2424,7 +2528,9 @@ describe('registerIpcHandlers Runtime customization', () => {
|
||||
| { provider: 'opencode' }
|
||||
| undefined = { provider: 'opencode' }
|
||||
const assistantDatabase = {
|
||||
claimDueSchedules: vi.fn(() => []),
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
getProject: vi.fn(() => ({
|
||||
id: projectId,
|
||||
rootPath: 'C:\\ProjectWorkspace'
|
||||
@@ -2634,7 +2740,6 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
capabilityServiceOverride?: Record<string, unknown>
|
||||
) {
|
||||
const assistantDatabase = {
|
||||
claimDueSchedules: vi.fn(() => []),
|
||||
createTask: vi.fn(),
|
||||
appendTaskEvent: vi.fn(),
|
||||
updateTaskStatus: vi.fn(),
|
||||
@@ -2670,8 +2775,22 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
})),
|
||||
appendConversationMessage: vi.fn(),
|
||||
appendRemoteConversationMessage: vi.fn(),
|
||||
listConversationQueueItems: vi.fn<
|
||||
() => ConversationQueueItem[]
|
||||
>(() => []),
|
||||
listPendingScheduleQueueConversationIds: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
getConversationQueueItem: vi.fn(),
|
||||
getConversationUserQueuePayloadJson: vi.fn(),
|
||||
isConversationUserQueueItemDispatching: vi.fn(() => true),
|
||||
enqueueConversationUserInput: vi.fn(),
|
||||
claimConversationQueueItem: vi.fn(),
|
||||
completeConversationUserQueueItem: vi.fn(),
|
||||
releaseConversationUserQueueItem: vi.fn(),
|
||||
cancelConversationQueueItem: vi.fn(),
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
queueScheduleNow: vi.fn(),
|
||||
completeScheduleRun: vi.fn(),
|
||||
claimScheduleNow: vi.fn(),
|
||||
listSchedules: vi.fn(() => []),
|
||||
createSchedule: vi.fn(),
|
||||
setScheduleEnabled: vi.fn(),
|
||||
@@ -2711,6 +2830,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
kind: attachment.kind === 'image' ? 'image' : 'text'
|
||||
})),
|
||||
remove: vi.fn(),
|
||||
serializeForQueue: vi.fn(() => '[]'),
|
||||
restoreFromQueue: vi.fn(),
|
||||
clear: vi.fn()
|
||||
}
|
||||
const approvalBroker = {
|
||||
@@ -2893,7 +3014,25 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
createdAt: '2026-08-19T00:00:00.000Z',
|
||||
updatedAt: '2026-08-19T00:00:00.000Z'
|
||||
}
|
||||
harness.assistantDatabase.claimScheduleNow.mockReturnValue({
|
||||
const queueItem = {
|
||||
id: runId,
|
||||
conversationId,
|
||||
source: 'schedule' as const,
|
||||
label: schedule.title,
|
||||
scheduleRunId: runId,
|
||||
scheduleId,
|
||||
taskId,
|
||||
createdAt: '2026-08-19T00:01:00.000Z'
|
||||
}
|
||||
harness.assistantDatabase.queueScheduleNow.mockReturnValue(
|
||||
queueItem
|
||||
)
|
||||
harness.assistantDatabase.listConversationQueueItems
|
||||
.mockReturnValueOnce([queueItem])
|
||||
.mockReturnValue([])
|
||||
harness.assistantDatabase.claimConversationQueueItem.mockReturnValue({
|
||||
source: 'schedule',
|
||||
item: queueItem,
|
||||
schedule,
|
||||
runId
|
||||
})
|
||||
@@ -2940,6 +3079,298 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('serializes Agent runs that target the same Conversation', async () => {
|
||||
let finishRun: (() => void) | undefined
|
||||
const runtimeStarted = vi.fn()
|
||||
const runtime = {
|
||||
runtimeId: 'model',
|
||||
capability: 'chat',
|
||||
supportsToolExecution: false,
|
||||
async *run(request: { requestId: string }) {
|
||||
runtimeStarted(request.requestId)
|
||||
await new Promise<void>((resolve) => {
|
||||
finishRun = resolve
|
||||
})
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'done'
|
||||
} as const
|
||||
}
|
||||
}
|
||||
const harness = createHarness(runtime)
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000721'
|
||||
const firstRun = harness.handler?.(
|
||||
trustedEvent(harness.webContents),
|
||||
{
|
||||
requestId: '00000000-0000-4000-8000-000000000722',
|
||||
conversationId,
|
||||
prompt: '第一条',
|
||||
workMode: 'ask',
|
||||
knowledgeLibraryIds: []
|
||||
}
|
||||
)
|
||||
await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled())
|
||||
|
||||
await expect(
|
||||
harness.handler?.(trustedEvent(harness.webContents), {
|
||||
requestId: '00000000-0000-4000-8000-000000000723',
|
||||
conversationId,
|
||||
prompt: '第二条',
|
||||
workMode: 'ask',
|
||||
knowledgeLibraryIds: []
|
||||
})
|
||||
).rejects.toThrow('当前对话已有执行中的请求')
|
||||
|
||||
finishRun?.()
|
||||
await firstRun
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('reserves a Conversation while its Runtime is resolving', async () => {
|
||||
const runtime = {
|
||||
runtimeId: 'model',
|
||||
capability: 'chat',
|
||||
supportsToolExecution: false,
|
||||
async *run(request: { requestId: string }) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'done'
|
||||
} as const
|
||||
}
|
||||
}
|
||||
let resolveRuntime:
|
||||
| ((value: typeof runtime) => void)
|
||||
| undefined
|
||||
const runtimeResolution = new Promise<typeof runtime>((resolve) => {
|
||||
resolveRuntime = resolve
|
||||
})
|
||||
const selectedRuntimes = {
|
||||
getRuntime: vi.fn(() => runtimeResolution)
|
||||
}
|
||||
const harness = createHarness(
|
||||
runtime,
|
||||
undefined,
|
||||
'always',
|
||||
undefined,
|
||||
false,
|
||||
selectedRuntimes
|
||||
)
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000724'
|
||||
const firstRun = harness.handler?.(
|
||||
trustedEvent(harness.webContents),
|
||||
{
|
||||
requestId: '00000000-0000-4000-8000-000000000728',
|
||||
conversationId,
|
||||
runtimeSelection: { provider: 'auto' },
|
||||
prompt: '等待 Runtime',
|
||||
workMode: 'ask',
|
||||
knowledgeLibraryIds: []
|
||||
}
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.handler?.(trustedEvent(harness.webContents), {
|
||||
requestId: '00000000-0000-4000-8000-000000000729',
|
||||
conversationId,
|
||||
runtimeSelection: { provider: 'auto' },
|
||||
prompt: '不能并发',
|
||||
workMode: 'ask',
|
||||
knowledgeLibraryIds: []
|
||||
})
|
||||
).rejects.toThrow('当前对话已有执行中的请求')
|
||||
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
|
||||
|
||||
resolveRuntime?.(runtime)
|
||||
await firstRun
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('interrupts the active response and promotes the selected queue item', async () => {
|
||||
const runtimeStarted = vi.fn()
|
||||
const runtime = {
|
||||
runtimeId: 'model',
|
||||
capability: 'chat',
|
||||
supportsToolExecution: false,
|
||||
async *run(
|
||||
request: { requestId: string },
|
||||
signal: AbortSignal
|
||||
) {
|
||||
runtimeStarted(request.requestId)
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => resolve(), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
if (signal.aborted) {
|
||||
throw signal.reason
|
||||
}
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'done'
|
||||
} as const
|
||||
}
|
||||
}
|
||||
const harness = createHarness(runtime)
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000725'
|
||||
const item = {
|
||||
id: '00000000-0000-4000-8000-000000000726',
|
||||
conversationId,
|
||||
source: 'user' as const,
|
||||
label: '优先执行',
|
||||
createdAt: '2026-08-20T09:01:00.000Z'
|
||||
}
|
||||
const input = {
|
||||
conversationId,
|
||||
runtimeSelection: { provider: 'auto' as const },
|
||||
workMode: 'ask' as const,
|
||||
includeMemoryContext: true,
|
||||
prompt: item.label,
|
||||
attachments: [],
|
||||
knowledgeLibraryIds: [],
|
||||
knowledgeRetrievalMode: 'auto' as const
|
||||
}
|
||||
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(
|
||||
item
|
||||
)
|
||||
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({
|
||||
source: 'user',
|
||||
item,
|
||||
payloadJson: JSON.stringify(input)
|
||||
})
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.conversationQueueReady
|
||||
)?.(trustedEvent(harness.webContents), conversationId)
|
||||
|
||||
await harness.handler?.(trustedEvent(harness.webContents), {
|
||||
requestId: '00000000-0000-4000-8000-000000000727',
|
||||
conversationId,
|
||||
prompt: '当前回复',
|
||||
workMode: 'ask',
|
||||
knowledgeLibraryIds: []
|
||||
})
|
||||
await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled())
|
||||
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.conversationQueueInterruptAndRun
|
||||
)?.(trustedEvent(harness.webContents), item.id)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.webContents.send).toHaveBeenCalledWith(
|
||||
ipcChannels.conversationQueueDispatch,
|
||||
{ item, input }
|
||||
)
|
||||
)
|
||||
expect(
|
||||
harness.assistantDatabase.claimConversationQueueItem
|
||||
).toHaveBeenCalledWith(conversationId, item.id)
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('dispatches and accepts a queued user message through the renderer', async () => {
|
||||
const runtime = {
|
||||
runtimeId: 'model',
|
||||
capability: 'chat',
|
||||
supportsToolExecution: false,
|
||||
async *run(request: { requestId: string }) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'done'
|
||||
} as const
|
||||
}
|
||||
}
|
||||
const harness = createHarness(runtime)
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000731'
|
||||
const itemId = '00000000-0000-4000-8000-000000000732'
|
||||
const attachmentId =
|
||||
'00000000-0000-4000-8000-000000000734'
|
||||
const input = {
|
||||
conversationId,
|
||||
runtimeSelection: { provider: 'auto' as const },
|
||||
workMode: 'ask' as const,
|
||||
includeMemoryContext: true,
|
||||
prompt: '排队发送',
|
||||
attachments: [
|
||||
{
|
||||
id: attachmentId,
|
||||
name: 'queued.txt',
|
||||
size: 6,
|
||||
preview: 'queued',
|
||||
kind: 'text' as const
|
||||
}
|
||||
],
|
||||
knowledgeLibraryIds: [],
|
||||
knowledgeRetrievalMode: 'auto' as const
|
||||
}
|
||||
const item = {
|
||||
id: itemId,
|
||||
conversationId,
|
||||
source: 'user' as const,
|
||||
label: input.prompt,
|
||||
createdAt: '2026-08-19T00:01:00.000Z'
|
||||
}
|
||||
harness.assistantDatabase.enqueueConversationUserInput.mockReturnValue(
|
||||
item
|
||||
)
|
||||
harness.assistantDatabase.listConversationQueueItems
|
||||
.mockReturnValueOnce([item])
|
||||
.mockReturnValue([])
|
||||
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({
|
||||
source: 'user',
|
||||
item,
|
||||
payloadJson: JSON.stringify({
|
||||
input,
|
||||
serializedContexts: '[]'
|
||||
})
|
||||
})
|
||||
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(
|
||||
item
|
||||
)
|
||||
|
||||
const enqueueHandler = electronMocks.handlers.get(
|
||||
ipcChannels.conversationQueueEnqueueUser
|
||||
)
|
||||
expect(
|
||||
enqueueHandler?.(trustedEvent(harness.webContents), input)
|
||||
).toEqual(item)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.webContents.send).toHaveBeenCalledWith(
|
||||
ipcChannels.conversationQueueDispatch,
|
||||
{ item, input }
|
||||
)
|
||||
)
|
||||
|
||||
await harness.handler?.(trustedEvent(harness.webContents), {
|
||||
requestId: '00000000-0000-4000-8000-000000000733',
|
||||
conversationId,
|
||||
queueItemId: itemId,
|
||||
runtimeSelection: input.runtimeSelection,
|
||||
prompt: input.prompt,
|
||||
workMode: input.workMode,
|
||||
knowledgeLibraryIds: []
|
||||
})
|
||||
|
||||
expect(
|
||||
harness.assistantDatabase.completeConversationUserQueueItem
|
||||
).toHaveBeenCalledWith(itemId)
|
||||
expect(harness.contextManager.serializeForQueue).toHaveBeenCalledWith([
|
||||
attachmentId
|
||||
])
|
||||
expect(harness.contextManager.remove).toHaveBeenCalledWith(
|
||||
attachmentId
|
||||
)
|
||||
expect(harness.contextManager.restoreFromQueue).toHaveBeenCalledWith(
|
||||
'[]'
|
||||
)
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('publishes Runtime usage as context metrics with one settings read', async () => {
|
||||
const runtime = {
|
||||
runtimeId: 'continue',
|
||||
|
||||
+554
-57
@@ -24,6 +24,7 @@ import {
|
||||
agentRequestSchema,
|
||||
browserInteractRequestSchema,
|
||||
browserStopRequestSchema,
|
||||
conversationQueueUserInputSchema,
|
||||
defaultRuntimeSettings,
|
||||
knowledgeCreateSchema,
|
||||
knowledgeEntityUpdateSchema,
|
||||
@@ -52,6 +53,8 @@ import {
|
||||
type AgentRequest,
|
||||
type AppInfo,
|
||||
type BrowserLiveState,
|
||||
type ConversationQueueDispatch,
|
||||
type ConversationQueueUserInput,
|
||||
type KnowledgeSearchReference,
|
||||
type KnowledgeSnapshot,
|
||||
type RuntimeSettings
|
||||
@@ -883,6 +886,7 @@ export function registerIpcHandlers(
|
||||
runtimeExtensionStore?: RuntimeExtensionStore
|
||||
): () => Promise<void> {
|
||||
const activeRequests = new Map<string, AbortController>()
|
||||
const activeRequestConversations = new Map<string, string>()
|
||||
const activeEventBuffers = new Map<string, { flush(): void }>()
|
||||
const pendingAgentQuestions = new Map<
|
||||
string,
|
||||
@@ -1247,6 +1251,220 @@ export function registerIpcHandlers(
|
||||
window.webContents.send(ipcChannels.conversationsChanged)
|
||||
}
|
||||
}
|
||||
const publishConversationQueueChange = (
|
||||
conversationId?: string
|
||||
): void => {
|
||||
if (!window.isDestroyed()) {
|
||||
if (conversationId) {
|
||||
window.webContents.send(
|
||||
ipcChannels.conversationQueueChanged,
|
||||
conversationId
|
||||
)
|
||||
} else {
|
||||
window.webContents.send(
|
||||
ipcChannels.conversationQueueChanged
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
const readyConversationQueues = new Set<string>()
|
||||
const preferredConversationQueueItems = new Map<string, string>()
|
||||
const reservedConversationQueueItems = new Map<string, string>()
|
||||
const preparingRequestConversations = new Map<string, string>()
|
||||
const rendererReadyConversationQueues = new Set<string>()
|
||||
const queueDispatchTimers = new Map<string, NodeJS.Timeout>()
|
||||
const parseConversationQueueUserPayload = (
|
||||
payloadJson: string,
|
||||
restoreContexts = false
|
||||
): ConversationQueueUserInput => {
|
||||
const parsed = JSON.parse(payloadJson) as unknown
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
'input' in parsed
|
||||
) {
|
||||
const stored = parsed as {
|
||||
input: unknown
|
||||
serializedContexts?: unknown
|
||||
}
|
||||
const input = conversationQueueUserInputSchema.parse(stored.input)
|
||||
if (
|
||||
stored.serializedContexts !== undefined &&
|
||||
typeof stored.serializedContexts !== 'string'
|
||||
) {
|
||||
throw new Error('待发送附件数据无效')
|
||||
}
|
||||
if (
|
||||
restoreContexts &&
|
||||
typeof stored.serializedContexts === 'string'
|
||||
) {
|
||||
contextManager.restoreFromQueue(stored.serializedContexts)
|
||||
}
|
||||
return input
|
||||
}
|
||||
return conversationQueueUserInputSchema.parse(parsed)
|
||||
}
|
||||
const pumpingConversationQueues = new Set<string>()
|
||||
const maximumConcurrentScheduleRuns = 4
|
||||
let activeScheduleRuns = 0
|
||||
|
||||
const isConversationExecuting = (
|
||||
conversationId: string
|
||||
): boolean =>
|
||||
reservedConversationQueueItems.has(conversationId) ||
|
||||
[...preparingRequestConversations.values()].some(
|
||||
(candidate) => candidate === conversationId
|
||||
) ||
|
||||
[...activeRequestConversations.values()].some(
|
||||
(candidate) => candidate === conversationId
|
||||
)
|
||||
|
||||
const pumpConversationQueue = async (
|
||||
conversationId: string,
|
||||
preferredItemId?: string
|
||||
): Promise<void> => {
|
||||
const preferred =
|
||||
preferredItemId ??
|
||||
preferredConversationQueueItems.get(conversationId)
|
||||
if (
|
||||
shuttingDown ||
|
||||
executionPaused ||
|
||||
pumpingConversationQueues.has(conversationId) ||
|
||||
isConversationExecuting(conversationId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const pendingItem = preferred
|
||||
? assistantDatabase.getConversationQueueItem(preferred)
|
||||
: assistantDatabase.listConversationQueueItems(conversationId)[0]
|
||||
if (!pendingItem || pendingItem.conversationId !== conversationId) {
|
||||
preferredConversationQueueItems.delete(conversationId)
|
||||
return
|
||||
}
|
||||
if (
|
||||
pendingItem.source === 'user' &&
|
||||
!rendererReadyConversationQueues.has(conversationId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
pendingItem.source === 'schedule' &&
|
||||
activeScheduleRuns >= maximumConcurrentScheduleRuns
|
||||
) {
|
||||
return
|
||||
}
|
||||
pumpingConversationQueues.add(conversationId)
|
||||
try {
|
||||
if (isConversationExecuting(conversationId)) {
|
||||
return
|
||||
}
|
||||
const claimed = assistantDatabase.claimConversationQueueItem(
|
||||
conversationId,
|
||||
preferred
|
||||
)
|
||||
if (!claimed) {
|
||||
return
|
||||
}
|
||||
readyConversationQueues.delete(conversationId)
|
||||
preferredConversationQueueItems.delete(conversationId)
|
||||
publishConversationQueueChange(conversationId)
|
||||
if (claimed.source === 'user') {
|
||||
if (window.isDestroyed()) {
|
||||
assistantDatabase.releaseConversationUserQueueItem(
|
||||
claimed.item.id
|
||||
)
|
||||
readyConversationQueues.add(conversationId)
|
||||
return
|
||||
}
|
||||
let input: ConversationQueueUserInput
|
||||
try {
|
||||
input = parseConversationQueueUserPayload(
|
||||
claimed.payloadJson,
|
||||
true
|
||||
)
|
||||
} catch {
|
||||
assistantDatabase.releaseConversationUserQueueItem(
|
||||
claimed.item.id
|
||||
)
|
||||
readyConversationQueues.add(conversationId)
|
||||
publishConversationQueueChange(conversationId)
|
||||
return
|
||||
}
|
||||
reservedConversationQueueItems.set(
|
||||
conversationId,
|
||||
claimed.item.id
|
||||
)
|
||||
const dispatchTimeout = setTimeout(() => {
|
||||
queueDispatchTimers.delete(claimed.item.id)
|
||||
if (
|
||||
reservedConversationQueueItems.get(conversationId) !==
|
||||
claimed.item.id
|
||||
) {
|
||||
return
|
||||
}
|
||||
reservedConversationQueueItems.delete(conversationId)
|
||||
try {
|
||||
assistantDatabase.releaseConversationUserQueueItem(
|
||||
claimed.item.id
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const attachment of input.attachments) {
|
||||
contextManager.remove(attachment.id)
|
||||
}
|
||||
readyConversationQueues.add(conversationId)
|
||||
publishConversationQueueChange(conversationId)
|
||||
void pumpConversationQueue(conversationId)
|
||||
}, 30_000)
|
||||
queueDispatchTimers.set(claimed.item.id, dispatchTimeout)
|
||||
const dispatch: ConversationQueueDispatch = {
|
||||
item: claimed.item,
|
||||
input
|
||||
}
|
||||
window.webContents.send(
|
||||
ipcChannels.conversationQueueDispatch,
|
||||
dispatch
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
activeScheduleRuns += 1
|
||||
const execution = (async () => {
|
||||
const result = await executeTaskWork({
|
||||
origin: 'schedule',
|
||||
schedule: claimed.schedule,
|
||||
scheduleRunId: claimed.runId
|
||||
})
|
||||
assistantDatabase.completeScheduleRun(
|
||||
claimed.runId,
|
||||
result.status
|
||||
)
|
||||
publishConversationChange()
|
||||
})()
|
||||
publishConversationChange()
|
||||
void trackExecution(execution)
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
activeScheduleRuns -= 1
|
||||
readyConversationQueues.add(conversationId)
|
||||
publishConversationQueueChange(conversationId)
|
||||
void pumpConversationQueue(conversationId)
|
||||
for (const pendingConversationId of
|
||||
assistantDatabase.listPendingScheduleQueueConversationIds(
|
||||
maximumConcurrentScheduleRuns
|
||||
)) {
|
||||
if (
|
||||
readyConversationQueues.has(pendingConversationId)
|
||||
) {
|
||||
void pumpConversationQueue(pendingConversationId)
|
||||
}
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
pumpingConversationQueues.delete(conversationId)
|
||||
}
|
||||
}
|
||||
|
||||
type ExecutionTemplate = Omit<
|
||||
AssistantSchedule,
|
||||
@@ -1289,7 +1507,7 @@ export function registerIpcHandlers(
|
||||
const executeTaskWork = async (
|
||||
input: TaskWorkExecution
|
||||
): Promise<{
|
||||
status: 'completed' | 'failed'
|
||||
status: 'completed' | 'failed' | 'cancelled'
|
||||
output?: string
|
||||
error?: string
|
||||
attachments?: ChannelMediaAttachment[]
|
||||
@@ -1303,7 +1521,7 @@ export function registerIpcHandlers(
|
||||
return { status: 'failed', error: '应用正在退出' }
|
||||
}
|
||||
if (externalSignal?.aborted) {
|
||||
return { status: 'failed', error: '请求已取消' }
|
||||
return { status: 'cancelled', error: '请求已取消' }
|
||||
}
|
||||
const taskId =
|
||||
input.origin === 'schedule'
|
||||
@@ -1327,6 +1545,7 @@ export function registerIpcHandlers(
|
||||
? input.schedule.conversationId
|
||||
: undefined) ??
|
||||
`${origin}:${schedule.id}`
|
||||
activeRequestConversations.set(requestId, runtimeConversationId)
|
||||
if (input.origin !== 'delegation') {
|
||||
assistantDatabase.updateTaskStatus(taskId, 'running')
|
||||
} else {
|
||||
@@ -1661,9 +1880,10 @@ export function registerIpcHandlers(
|
||||
} catch (error) {
|
||||
eventBuffer.flush()
|
||||
const message = safeRuntimeError(error, '定时任务执行失败')
|
||||
const cancelled = controller.signal.aborted
|
||||
assistantDatabase.updateTaskStatus(
|
||||
taskId,
|
||||
controller.signal.aborted ? 'cancelled' : 'failed',
|
||||
cancelled ? 'cancelled' : 'failed',
|
||||
message
|
||||
)
|
||||
if (input.origin === 'schedule') {
|
||||
@@ -1672,7 +1892,7 @@ export function registerIpcHandlers(
|
||||
role: 'assistant',
|
||||
content: message,
|
||||
state: 'error',
|
||||
status: '定时任务失败',
|
||||
status: cancelled ? '定时任务已取消' : '定时任务失败',
|
||||
task: {
|
||||
id: taskId,
|
||||
title: schedule.title
|
||||
@@ -1684,13 +1904,18 @@ export function registerIpcHandlers(
|
||||
title:
|
||||
origin === 'channel'
|
||||
? `${remoteContext?.channelLabel ?? '远程通道'}请求失败`
|
||||
: `定时任务失败:${schedule.title}`,
|
||||
: cancelled
|
||||
? `定时任务已取消:${schedule.title}`
|
||||
: `定时任务失败:${schedule.title}`,
|
||||
body:
|
||||
origin === 'channel'
|
||||
? '打开 GoodBuddy 查看远程通道会话详情。'
|
||||
: '打开 GoodBuddy 任务工作栏查看详情。'
|
||||
})
|
||||
return { status: 'failed', error: message }
|
||||
return {
|
||||
status: cancelled ? 'cancelled' : 'failed',
|
||||
error: message
|
||||
}
|
||||
} finally {
|
||||
eventBuffer.close()
|
||||
externalSignal?.removeEventListener(
|
||||
@@ -1700,6 +1925,7 @@ export function registerIpcHandlers(
|
||||
knowledgeGateway?.revoke(knowledgeCapabilityToken)
|
||||
goodbuddyConfigService?.revokeRequest(requestId)
|
||||
activeRequests.delete(requestId)
|
||||
activeRequestConversations.delete(requestId)
|
||||
await flushGoodBuddyConfigReload().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
@@ -1810,47 +2036,37 @@ export function registerIpcHandlers(
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
|
||||
const maximumConcurrentScheduleRuns = 4
|
||||
let scheduleClaimRunning = false
|
||||
let activeScheduleRuns = 0
|
||||
const launchDueSchedules = (): void => {
|
||||
let scheduleQueueTickRunning = false
|
||||
const queueDueSchedules = (): void => {
|
||||
if (
|
||||
scheduleClaimRunning ||
|
||||
scheduleQueueTickRunning ||
|
||||
shuttingDown ||
|
||||
executionPaused ||
|
||||
activeScheduleRuns >= maximumConcurrentScheduleRuns
|
||||
executionPaused
|
||||
) {
|
||||
return
|
||||
}
|
||||
scheduleClaimRunning = true
|
||||
scheduleQueueTickRunning = true
|
||||
try {
|
||||
const claims = assistantDatabase.claimDueSchedules(
|
||||
new Date(),
|
||||
maximumConcurrentScheduleRuns - activeScheduleRuns
|
||||
const queued = assistantDatabase.queueDueSchedules(new Date())
|
||||
const conversationIds = new Set(
|
||||
queued.map((item) => item.conversationId)
|
||||
)
|
||||
activeScheduleRuns += claims.length
|
||||
for (const claim of claims) {
|
||||
const execution = (async () => {
|
||||
const result = await executeTaskWork({
|
||||
origin: 'schedule',
|
||||
schedule: claim.schedule,
|
||||
scheduleRunId: claim.runId
|
||||
})
|
||||
assistantDatabase.completeScheduleRun(
|
||||
claim.runId,
|
||||
result.status
|
||||
)
|
||||
publishConversationChange()
|
||||
})()
|
||||
void trackExecution(execution)
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
activeScheduleRuns -= 1
|
||||
launchDueSchedules()
|
||||
})
|
||||
for (const conversationId of conversationIds) {
|
||||
publishConversationQueueChange(conversationId)
|
||||
if (!isConversationExecuting(conversationId)) {
|
||||
readyConversationQueues.add(conversationId)
|
||||
void pumpConversationQueue(conversationId)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
scheduleClaimRunning = false
|
||||
scheduleQueueTickRunning = false
|
||||
}
|
||||
}
|
||||
const resumePendingConversationQueues = (): void => {
|
||||
for (const conversationId of
|
||||
assistantDatabase.listPendingConversationQueueIds()) {
|
||||
readyConversationQueues.add(conversationId)
|
||||
void pumpConversationQueue(conversationId)
|
||||
}
|
||||
}
|
||||
let heartbeatTickRunning = false
|
||||
@@ -1870,10 +2086,11 @@ export function registerIpcHandlers(
|
||||
}
|
||||
}
|
||||
const runDueWork = (): void => {
|
||||
launchDueSchedules()
|
||||
queueDueSchedules()
|
||||
void trackExecution(runDueHeartbeats()).catch(() => undefined)
|
||||
}
|
||||
const scheduleInterval = setInterval(runDueWork, 30_000)
|
||||
resumePendingConversationQueues()
|
||||
runDueWork()
|
||||
const delegationEndpoint =
|
||||
process.env.GOODBUDDY_DELEGATION_ENDPOINT?.trim()
|
||||
@@ -1911,7 +2128,14 @@ export function registerIpcHandlers(
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
)
|
||||
).then((result) => ({
|
||||
status:
|
||||
result.status === 'completed'
|
||||
? ('completed' as const)
|
||||
: ('failed' as const),
|
||||
...(result.output ? { output: result.output } : {}),
|
||||
...(result.error ? { error: result.error } : {})
|
||||
}))
|
||||
})
|
||||
: undefined
|
||||
remoteDelegation?.start()
|
||||
@@ -2355,6 +2579,16 @@ export function registerIpcHandlers(
|
||||
await executionTracker.drain()
|
||||
await onBeforeClearLocalData?.()
|
||||
assistantDatabase.clearAssistantData()
|
||||
readyConversationQueues.clear()
|
||||
preferredConversationQueueItems.clear()
|
||||
reservedConversationQueueItems.clear()
|
||||
preparingRequestConversations.clear()
|
||||
rendererReadyConversationQueues.clear()
|
||||
for (const timeout of queueDispatchTimers.values()) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
queueDispatchTimers.clear()
|
||||
publishConversationQueueChange()
|
||||
} finally {
|
||||
executionPaused = false
|
||||
}
|
||||
@@ -2413,9 +2647,50 @@ export function registerIpcHandlers(
|
||||
throw new Error('本地数据维护期间暂不接受新任务')
|
||||
}
|
||||
const parsedInput = agentRequestSchema.parse(input)
|
||||
if (activeRequests.has(parsedInput.requestId)) {
|
||||
if (
|
||||
activeRequests.has(parsedInput.requestId) ||
|
||||
preparingRequestConversations.has(parsedInput.requestId)
|
||||
) {
|
||||
throw new Error('请求正在执行')
|
||||
}
|
||||
const queuedItem = parsedInput.queueItemId
|
||||
? assistantDatabase.getConversationQueueItem(
|
||||
parsedInput.queueItemId
|
||||
)
|
||||
: undefined
|
||||
if (
|
||||
parsedInput.queueItemId &&
|
||||
(!queuedItem ||
|
||||
queuedItem.source !== 'user' ||
|
||||
queuedItem.conversationId !== parsedInput.conversationId ||
|
||||
!assistantDatabase.isConversationUserQueueItemDispatching(
|
||||
parsedInput.queueItemId
|
||||
))
|
||||
) {
|
||||
throw new Error('待发送消息不存在或与当前对话不一致')
|
||||
}
|
||||
const reservationItemId =
|
||||
reservedConversationQueueItems.get(parsedInput.conversationId)
|
||||
if (
|
||||
[...activeRequestConversations.values()].some(
|
||||
(conversationId) =>
|
||||
conversationId === parsedInput.conversationId
|
||||
) ||
|
||||
[...preparingRequestConversations.values()].some(
|
||||
(conversationId) =>
|
||||
conversationId === parsedInput.conversationId
|
||||
) ||
|
||||
(parsedInput.queueItemId
|
||||
? reservationItemId !== parsedInput.queueItemId
|
||||
: reservationItemId !== undefined)
|
||||
) {
|
||||
throw new Error('当前对话已有执行中的请求')
|
||||
}
|
||||
preparingRequestConversations.set(
|
||||
parsedInput.requestId,
|
||||
parsedInput.conversationId
|
||||
)
|
||||
try {
|
||||
const knowledgeLibraryIds = [
|
||||
...new Set(parsedInput.knowledgeLibraryIds)
|
||||
]
|
||||
@@ -2549,6 +2824,41 @@ export function registerIpcHandlers(
|
||||
throw error
|
||||
}
|
||||
activeRequests.set(request.requestId, controller)
|
||||
activeRequestConversations.set(
|
||||
request.requestId,
|
||||
request.conversationId
|
||||
)
|
||||
if (parsedInput.queueItemId) {
|
||||
const dispatchTimeout = queueDispatchTimers.get(
|
||||
parsedInput.queueItemId
|
||||
)
|
||||
if (dispatchTimeout) {
|
||||
clearTimeout(dispatchTimeout)
|
||||
queueDispatchTimers.delete(parsedInput.queueItemId)
|
||||
}
|
||||
if (
|
||||
reservedConversationQueueItems.get(request.conversationId) ===
|
||||
parsedInput.queueItemId
|
||||
) {
|
||||
reservedConversationQueueItems.delete(request.conversationId)
|
||||
}
|
||||
try {
|
||||
assistantDatabase.completeConversationUserQueueItem(
|
||||
parsedInput.queueItemId
|
||||
)
|
||||
publishConversationQueueChange(request.conversationId)
|
||||
} catch (error) {
|
||||
activeRequests.delete(request.requestId)
|
||||
activeRequestConversations.delete(request.requestId)
|
||||
assistantDatabase.updateTaskStatus(
|
||||
request.requestId,
|
||||
'cancelled',
|
||||
'待发送消息状态已变化'
|
||||
)
|
||||
knowledgeGateway?.revoke(knowledgeCapabilityToken)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const execution = (async () => {
|
||||
let completed = false
|
||||
@@ -3027,6 +3337,7 @@ export function registerIpcHandlers(
|
||||
}
|
||||
knowledgeGateway?.revoke(request.knowledgeCapabilityToken)
|
||||
activeRequests.delete(request.requestId)
|
||||
activeRequestConversations.delete(request.requestId)
|
||||
const configReload =
|
||||
goodbuddyConfigService?.takePendingReload(request.requestId) ??
|
||||
'none'
|
||||
@@ -3035,9 +3346,15 @@ export function registerIpcHandlers(
|
||||
pendingGoodBuddyConfigReload = true
|
||||
}
|
||||
await flushGoodBuddyConfigReload().catch(() => undefined)
|
||||
if (readyConversationQueues.has(request.conversationId)) {
|
||||
void pumpConversationQueue(request.conversationId)
|
||||
}
|
||||
}
|
||||
})()
|
||||
void trackExecution(execution)
|
||||
} finally {
|
||||
preparingRequestConversations.delete(parsedInput.requestId)
|
||||
}
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.agentCancel, (event, input: unknown) => {
|
||||
@@ -3137,6 +3454,10 @@ export function registerIpcHandlers(
|
||||
5 * 60_000
|
||||
)
|
||||
activeRequests.set(request.requestId, controller)
|
||||
activeRequestConversations.set(
|
||||
request.requestId,
|
||||
request.conversationId
|
||||
)
|
||||
assistantDatabase.createTask({
|
||||
id: request.requestId,
|
||||
projectId: request.projectId,
|
||||
@@ -3221,6 +3542,9 @@ export function registerIpcHandlers(
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
activeRequests.delete(request.requestId)
|
||||
activeRequestConversations.delete(request.requestId)
|
||||
readyConversationQueues.add(request.conversationId)
|
||||
void pumpConversationQueue(request.conversationId)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -4150,9 +4474,185 @@ export function registerIpcHandlers(
|
||||
ipcChannels.conversationsDeleteLocal,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.deleteLocalConversation(
|
||||
assistantIdSchema.parse(input)
|
||||
const conversationId = assistantIdSchema.parse(input)
|
||||
const queuedItems =
|
||||
assistantDatabase.listConversationQueueItems(conversationId)
|
||||
for (const item of queuedItems) {
|
||||
if (item.source !== 'user') {
|
||||
continue
|
||||
}
|
||||
const payloadJson =
|
||||
assistantDatabase.getConversationUserQueuePayloadJson(item.id)
|
||||
if (payloadJson) {
|
||||
const queuedInput =
|
||||
parseConversationQueueUserPayload(payloadJson)
|
||||
for (const attachment of queuedInput.attachments) {
|
||||
contextManager.remove(attachment.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
const deleted = assistantDatabase.deleteLocalConversation(
|
||||
conversationId
|
||||
)
|
||||
preferredConversationQueueItems.delete(conversationId)
|
||||
readyConversationQueues.delete(conversationId)
|
||||
rendererReadyConversationQueues.delete(conversationId)
|
||||
publishConversationQueueChange(conversationId)
|
||||
return deleted
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.conversationQueueList,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.listConversationQueueItems(
|
||||
assistantIdSchema.optional().parse(input)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.conversationQueueEnqueueUser,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
if (executionPaused || shuttingDown) {
|
||||
throw new Error('本地数据维护期间暂不接受新消息')
|
||||
}
|
||||
const parsed = conversationQueueUserInputSchema.parse(input)
|
||||
const serializedContexts = contextManager.serializeForQueue(
|
||||
parsed.attachments.map((attachment) => attachment.id)
|
||||
)
|
||||
const item = assistantDatabase.enqueueConversationUserInput({
|
||||
conversationId: parsed.conversationId,
|
||||
label: parsed.prompt,
|
||||
payloadJson: JSON.stringify({
|
||||
input: parsed,
|
||||
serializedContexts
|
||||
})
|
||||
})
|
||||
for (const attachment of parsed.attachments) {
|
||||
contextManager.remove(attachment.id)
|
||||
}
|
||||
rendererReadyConversationQueues.add(item.conversationId)
|
||||
publishConversationQueueChange(item.conversationId)
|
||||
if (!isConversationExecuting(item.conversationId)) {
|
||||
readyConversationQueues.add(item.conversationId)
|
||||
setTimeout(() => {
|
||||
void pumpConversationQueue(item.conversationId)
|
||||
}, 0)
|
||||
}
|
||||
return item
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.conversationQueueRemove,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const itemId = assistantIdSchema.parse(input)
|
||||
const item = assistantDatabase.getConversationQueueItem(itemId)
|
||||
if (!item) {
|
||||
throw new Error('待执行项不存在或状态已变化')
|
||||
}
|
||||
let queuedInput: ConversationQueueUserInput | undefined
|
||||
if (item.source === 'user') {
|
||||
const payloadJson =
|
||||
assistantDatabase.getConversationUserQueuePayloadJson(item.id)
|
||||
if (!payloadJson) {
|
||||
throw new Error('待发送消息不存在或状态已变化')
|
||||
}
|
||||
queuedInput =
|
||||
parseConversationQueueUserPayload(payloadJson)
|
||||
}
|
||||
assistantDatabase.cancelConversationQueueItem(itemId)
|
||||
if (
|
||||
preferredConversationQueueItems.get(item.conversationId) ===
|
||||
itemId
|
||||
) {
|
||||
preferredConversationQueueItems.delete(item.conversationId)
|
||||
}
|
||||
for (const attachment of queuedInput?.attachments ?? []) {
|
||||
contextManager.remove(attachment.id)
|
||||
}
|
||||
publishConversationQueueChange(item.conversationId)
|
||||
if (item.source === 'schedule') {
|
||||
publishConversationChange()
|
||||
}
|
||||
if (readyConversationQueues.has(item.conversationId)) {
|
||||
void pumpConversationQueue(item.conversationId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.conversationQueueInterruptAndRun,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const itemId = assistantIdSchema.parse(input)
|
||||
const item = assistantDatabase.getConversationQueueItem(itemId)
|
||||
if (!item) {
|
||||
throw new Error('待执行项不存在或状态已变化')
|
||||
}
|
||||
preferredConversationQueueItems.set(item.conversationId, item.id)
|
||||
readyConversationQueues.add(item.conversationId)
|
||||
for (const [requestId, conversationId] of activeRequestConversations) {
|
||||
if (conversationId === item.conversationId) {
|
||||
activeRequests
|
||||
.get(requestId)
|
||||
?.abort(new Error('用户中断当前回复并插入队列项'))
|
||||
}
|
||||
}
|
||||
if (!isConversationExecuting(item.conversationId)) {
|
||||
void pumpConversationQueue(item.conversationId, item.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.conversationQueueReleaseUser,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const itemId = assistantIdSchema.parse(input)
|
||||
const item = assistantDatabase.getConversationQueueItem(itemId)
|
||||
const dispatchTimeout = queueDispatchTimers.get(itemId)
|
||||
if (dispatchTimeout) {
|
||||
clearTimeout(dispatchTimeout)
|
||||
queueDispatchTimers.delete(itemId)
|
||||
}
|
||||
if (item) {
|
||||
if (
|
||||
reservedConversationQueueItems.get(item.conversationId) ===
|
||||
itemId
|
||||
) {
|
||||
reservedConversationQueueItems.delete(item.conversationId)
|
||||
}
|
||||
const payloadJson =
|
||||
assistantDatabase.getConversationUserQueuePayloadJson(itemId)
|
||||
if (payloadJson) {
|
||||
const queuedInput =
|
||||
parseConversationQueueUserPayload(payloadJson)
|
||||
for (const attachment of queuedInput.attachments) {
|
||||
contextManager.remove(attachment.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
assistantDatabase.releaseConversationUserQueueItem(itemId)
|
||||
if (item) {
|
||||
readyConversationQueues.add(item.conversationId)
|
||||
}
|
||||
publishConversationQueueChange(item?.conversationId)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.conversationQueueReady,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const conversationId = assistantIdSchema.parse(input)
|
||||
rendererReadyConversationQueues.add(conversationId)
|
||||
readyConversationQueues.add(conversationId)
|
||||
void pumpConversationQueue(conversationId)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4397,6 +4897,7 @@ export function registerIpcHandlers(
|
||||
assertTrustedSender(event, window)
|
||||
assistantDatabase.removeSchedule(assistantIdSchema.parse(input))
|
||||
publishConversationChange()
|
||||
publishConversationQueueChange()
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.schedulesRunNow, (event, input: unknown) => {
|
||||
@@ -4404,22 +4905,14 @@ export function registerIpcHandlers(
|
||||
if (executionPaused || shuttingDown) {
|
||||
throw new Error('本地数据维护期间暂不接受新任务')
|
||||
}
|
||||
const claim = assistantDatabase.claimScheduleNow(
|
||||
const item = assistantDatabase.queueScheduleNow(
|
||||
assistantIdSchema.parse(input)
|
||||
)
|
||||
const execution = (async () => {
|
||||
const result = await executeTaskWork({
|
||||
origin: 'schedule',
|
||||
schedule: claim.schedule,
|
||||
scheduleRunId: claim.runId
|
||||
})
|
||||
assistantDatabase.completeScheduleRun(
|
||||
claim.runId,
|
||||
result.status
|
||||
)
|
||||
publishConversationChange()
|
||||
})()
|
||||
void trackExecution(execution).catch(() => undefined)
|
||||
publishConversationQueueChange(item.conversationId)
|
||||
if (!isConversationExecuting(item.conversationId)) {
|
||||
readyConversationQueues.add(item.conversationId)
|
||||
void pumpConversationQueue(item.conversationId)
|
||||
}
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.heartbeatsList, (event, input: unknown) => {
|
||||
@@ -5649,6 +6142,10 @@ export function registerIpcHandlers(
|
||||
shuttingDown = true
|
||||
removeBrowserStateListener?.()
|
||||
clearInterval(scheduleInterval)
|
||||
for (const timeout of queueDispatchTimers.values()) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
queueDispatchTimers.clear()
|
||||
window.removeListener('maximize', notifyMaximizedChanged)
|
||||
window.removeListener('unmaximize', notifyMaximizedChanged)
|
||||
abortActiveRequests('应用正在退出')
|
||||
|
||||
Reference in New Issue
Block a user