feat: add stable scheduled tasks
Scheduled tasks previously created separate visible work for each run and lacked one conversation-backed product identity. Custom tasks now create or reuse one stable Task and Conversation, reuse that identity across triggers, and write text results back with Task provenance. Tasks default to Execute while preserving the configured Runtime, tool authorization, and high-risk approval boundaries. Schema v22 backfills existing schedules to stable Task and Conversation links without deleting historical runs. The conversation list, conversation Task strip, and Task Center now expose the same Task, with localized status metadata, overflow-aware titles, and shared schedule controls. Release note: 现在可以创建关联当前或新会话的定制计划任务;重复执行会复用同一 Task 并将文本结果回写会话,左侧会话列表和 Task Center 可直接查看和管理。
This commit is contained in:
@@ -98,7 +98,7 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('migrates existing databases to schema version 20', async () => {
|
||||
it('migrates existing databases to schema version 22', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
||||
)
|
||||
@@ -127,7 +127,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(21)
|
||||
).toBe(22)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -231,7 +231,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(21)
|
||||
).toBe(22)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -269,6 +269,131 @@ describe('AssistantDatabase', () => {
|
||||
current.close()
|
||||
})
|
||||
|
||||
it('backfills one stable Task and Conversation for each v21 schedule', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-stable-schedule-migration-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const initial = new AssistantDatabase(databasePath)
|
||||
initial.initialize('C:\\Workspace')
|
||||
const project = initial.listProjects()[0]!
|
||||
const legacySchedule = initial.createSchedule({
|
||||
projectId: project.id,
|
||||
title: '旧版每日报告',
|
||||
prompt: '生成每日报告',
|
||||
workMode: 'ask',
|
||||
recurrence: 'daily',
|
||||
nextRunAt: '2026-08-20T00:00:00.000Z'
|
||||
})
|
||||
initial.close()
|
||||
|
||||
const legacyTaskId =
|
||||
'00000000-0000-4000-8000-000000000221'
|
||||
const legacyRunId =
|
||||
'00000000-0000-4000-8000-000000000222'
|
||||
const raw = new DatabaseSync(databasePath)
|
||||
raw.exec('BEGIN IMMEDIATE')
|
||||
raw
|
||||
.prepare('DELETE FROM tasks WHERE id = ?')
|
||||
.run(legacySchedule.taskId)
|
||||
raw
|
||||
.prepare('DELETE FROM conversations WHERE id = ?')
|
||||
.run(legacySchedule.conversationId)
|
||||
raw
|
||||
.prepare(
|
||||
`INSERT INTO tasks
|
||||
(id, project_id, conversation_id, schedule_id, parent_task_id,
|
||||
expert_id, routing_mode, title, instructions, origin, status,
|
||||
priority, work_mode, progress, created_at, started_at,
|
||||
completed_at, error, visible)
|
||||
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, ?, ?, 'schedule',
|
||||
'completed', 0, 'ask', NULL, ?, ?, ?, NULL, 1)`
|
||||
)
|
||||
.run(
|
||||
legacyTaskId,
|
||||
project.id,
|
||||
`schedule:${legacySchedule.id}`,
|
||||
legacySchedule.title,
|
||||
legacySchedule.prompt,
|
||||
'2026-08-18T00:00:00.000Z',
|
||||
'2026-08-18T00:00:00.000Z',
|
||||
'2026-08-18T00:01:00.000Z'
|
||||
)
|
||||
raw
|
||||
.prepare(
|
||||
`INSERT INTO schedule_runs
|
||||
(id, schedule_id, scheduled_for, task_id, status)
|
||||
VALUES (?, ?, ?, ?, 'completed')`
|
||||
)
|
||||
.run(
|
||||
legacyRunId,
|
||||
legacySchedule.id,
|
||||
'2026-08-18T00:00:00.000Z',
|
||||
legacyTaskId
|
||||
)
|
||||
raw.exec(`
|
||||
DROP INDEX IF EXISTS idx_tasks_schedule;
|
||||
PRAGMA user_version = 21;
|
||||
COMMIT;
|
||||
`)
|
||||
raw.close()
|
||||
|
||||
const migrated = new AssistantDatabase(databasePath)
|
||||
migrated.initialize('C:\\Workspace')
|
||||
const [schedule] = migrated.listSchedules(project.id)
|
||||
expect(schedule).toMatchObject({
|
||||
id: legacySchedule.id,
|
||||
title: legacySchedule.title,
|
||||
taskId: expect.any(String),
|
||||
conversationId: expect.any(String)
|
||||
})
|
||||
expect(schedule!.taskId).not.toBe(legacyTaskId)
|
||||
expect(
|
||||
migrated.getConversation(schedule!.conversationId)
|
||||
).toMatchObject({
|
||||
projectId: project.id,
|
||||
title: legacySchedule.title,
|
||||
messages: []
|
||||
})
|
||||
expect(migrated.listTasks()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: schedule!.taskId,
|
||||
conversationId: schedule!.conversationId,
|
||||
scheduleId: schedule!.id,
|
||||
status: 'idle'
|
||||
})
|
||||
])
|
||||
migrated.close()
|
||||
|
||||
const inspected = new DatabaseSync(databasePath)
|
||||
expect(
|
||||
inspected.prepare('PRAGMA user_version').get()
|
||||
).toEqual({ user_version: 22 })
|
||||
expect(
|
||||
inspected
|
||||
.prepare(
|
||||
`SELECT parent_task_id, visible
|
||||
FROM tasks
|
||||
WHERE id = ?`
|
||||
)
|
||||
.get(legacyTaskId)
|
||||
).toEqual({
|
||||
parent_task_id: schedule!.taskId,
|
||||
visible: 0
|
||||
})
|
||||
expect(
|
||||
inspected
|
||||
.prepare(
|
||||
`SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'index' AND name = 'idx_tasks_schedule'`
|
||||
)
|
||||
.get()
|
||||
).toEqual({ name: 'idx_tasks_schedule' })
|
||||
inspected.close()
|
||||
})
|
||||
|
||||
it('backfills checklist todos when migrating existing magic notes', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-magic-todo-migration-')
|
||||
@@ -997,6 +1122,26 @@ describe('AssistantDatabase', () => {
|
||||
recurrence: 'daily',
|
||||
nextRunAt: '2026-07-31T00:00:00.000Z'
|
||||
})
|
||||
expect(schedule).toMatchObject({
|
||||
projectId: project.id,
|
||||
workMode: 'ask',
|
||||
taskId: expect.any(String),
|
||||
conversationId: expect.any(String)
|
||||
})
|
||||
expect(
|
||||
database.listTasks().find((task) => task.id === schedule.taskId)
|
||||
).toMatchObject({
|
||||
conversationId: schedule.conversationId,
|
||||
scheduleId: schedule.id,
|
||||
origin: 'schedule',
|
||||
status: 'idle'
|
||||
})
|
||||
expect(database.getConversation(schedule.conversationId)).toMatchObject({
|
||||
id: schedule.conversationId,
|
||||
projectId: project.id,
|
||||
title: '每日摘要',
|
||||
messages: []
|
||||
})
|
||||
const [claim] = database.claimDueSchedules(
|
||||
new Date('2026-07-31T00:01:00.000Z')
|
||||
)
|
||||
@@ -1011,7 +1156,6 @@ describe('AssistantDatabase', () => {
|
||||
database.completeScheduleRun(
|
||||
claim!.runId,
|
||||
'completed',
|
||||
undefined,
|
||||
new Date('2026-07-31T00:01:00.000Z')
|
||||
)
|
||||
expect(database.listSchedules(project.id)[0]).toMatchObject({
|
||||
@@ -1019,6 +1163,89 @@ describe('AssistantDatabase', () => {
|
||||
nextRunAt: '2026-08-01T00:00:00.000Z',
|
||||
lastRunAt: '2026-07-31T00:01:00.000Z'
|
||||
})
|
||||
expect(
|
||||
database.listTasks().find((task) => task.id === schedule.taskId)
|
||||
).toMatchObject({
|
||||
status: 'idle',
|
||||
completedAt: undefined
|
||||
})
|
||||
const manualClaim = database.claimScheduleNow(schedule.id)
|
||||
expect(manualClaim.schedule).toMatchObject({
|
||||
taskId: schedule.taskId,
|
||||
conversationId: schedule.conversationId
|
||||
})
|
||||
expect(() => database.claimScheduleNow(schedule.id)).toThrow(
|
||||
'已有一次运行正在进行'
|
||||
)
|
||||
database.completeScheduleRun(
|
||||
manualClaim.runId,
|
||||
'completed',
|
||||
new Date('2026-07-31T00:02:00.000Z')
|
||||
)
|
||||
database.appendConversationMessage({
|
||||
conversationId: schedule.conversationId,
|
||||
role: 'assistant',
|
||||
content: '今日任务状态正常',
|
||||
status: '定时任务',
|
||||
task: {
|
||||
id: schedule.taskId,
|
||||
title: schedule.title
|
||||
}
|
||||
})
|
||||
expect(
|
||||
database.getConversation(schedule.conversationId).messages
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
content: '今日任务状态正常',
|
||||
task: {
|
||||
id: schedule.taskId,
|
||||
title: schedule.title
|
||||
}
|
||||
})
|
||||
])
|
||||
const sharedConversationSchedule = database.createSchedule({
|
||||
projectId: project.id,
|
||||
conversationId: schedule.conversationId,
|
||||
title: '每周复盘',
|
||||
prompt: '复盘本周任务',
|
||||
workMode: 'execute',
|
||||
recurrence: 'weekly',
|
||||
nextRunAt: '2027-01-01T00:00:00.000Z'
|
||||
})
|
||||
expect(sharedConversationSchedule.conversationId).toBe(
|
||||
schedule.conversationId
|
||||
)
|
||||
expect(
|
||||
database
|
||||
.listTasks()
|
||||
.filter(
|
||||
(task) =>
|
||||
task.conversationId === schedule.conversationId &&
|
||||
task.origin === 'schedule'
|
||||
)
|
||||
).toHaveLength(2)
|
||||
const countsBeforeFailedCreate = {
|
||||
schedules: database.listSchedules().length,
|
||||
tasks: database.listTasks().length,
|
||||
conversations: database.listConversations().length
|
||||
}
|
||||
expect(() =>
|
||||
database.createSchedule({
|
||||
projectId: project.id,
|
||||
conversationId:
|
||||
'00000000-0000-4000-8000-000000000299',
|
||||
title: '不应创建',
|
||||
prompt: '无效对话',
|
||||
workMode: 'execute',
|
||||
recurrence: 'once',
|
||||
nextRunAt: '2027-01-02T00:00:00.000Z'
|
||||
})
|
||||
).toThrow('所选对话不存在或不可用于任务')
|
||||
expect({
|
||||
schedules: database.listSchedules().length,
|
||||
tasks: database.listTasks().length,
|
||||
conversations: database.listConversations().length
|
||||
}).toEqual(countsBeforeFailedCreate)
|
||||
const overdue = database.createSchedule({
|
||||
projectId: project.id,
|
||||
title: '过期摘要',
|
||||
@@ -1033,7 +1260,6 @@ describe('AssistantDatabase', () => {
|
||||
database.completeScheduleRun(
|
||||
overdueClaim!.runId,
|
||||
'completed',
|
||||
undefined,
|
||||
new Date('2026-07-31T00:01:00.000Z')
|
||||
)
|
||||
expect(
|
||||
@@ -1043,6 +1269,22 @@ describe('AssistantDatabase', () => {
|
||||
).toMatchObject({
|
||||
nextRunAt: '2026-08-01T00:00:00.000Z'
|
||||
})
|
||||
database.removeSchedule(sharedConversationSchedule.id)
|
||||
expect(
|
||||
database
|
||||
.listSchedules(project.id)
|
||||
.some((item) => item.id === sharedConversationSchedule.id)
|
||||
).toBe(false)
|
||||
expect(
|
||||
database
|
||||
.listTasks()
|
||||
.find((task) => task.id === sharedConversationSchedule.taskId)
|
||||
).toMatchObject({
|
||||
conversationId: schedule.conversationId,
|
||||
scheduleId: undefined,
|
||||
status: 'completed',
|
||||
workMode: 'execute'
|
||||
})
|
||||
database.close()
|
||||
})
|
||||
|
||||
@@ -1083,7 +1325,6 @@ describe('AssistantDatabase', () => {
|
||||
recovered.completeScheduleRun(
|
||||
reclaimed!.runId,
|
||||
'completed',
|
||||
undefined,
|
||||
new Date('2026-08-13T00:02:00.000Z')
|
||||
)
|
||||
expect(recovered.listSchedules()[0]).toMatchObject({
|
||||
@@ -1091,9 +1332,45 @@ describe('AssistantDatabase', () => {
|
||||
enabled: false,
|
||||
lastRunAt: '2026-08-13T00:02:00.000Z'
|
||||
})
|
||||
expect(() =>
|
||||
recovered.setScheduleEnabled(schedule.id, true)
|
||||
).toThrow('已执行的一次性计划不能恢复自动运行')
|
||||
recovered.close()
|
||||
})
|
||||
|
||||
it('claims a bounded batch of independent due schedules', async () => {
|
||||
const database = await createDatabase()
|
||||
const scheduleIds = Array.from({ length: 3 }, (_, index) =>
|
||||
database.createSchedule({
|
||||
title: `批量任务 ${index + 1}`,
|
||||
prompt: `执行批量任务 ${index + 1}`,
|
||||
workMode: 'execute',
|
||||
recurrence: 'once',
|
||||
nextRunAt: '2026-08-13T00:00:00.000Z'
|
||||
}).id
|
||||
)
|
||||
|
||||
const firstBatch = database.claimDueSchedules(
|
||||
new Date('2026-08-13T00:01:00.000Z'),
|
||||
2
|
||||
)
|
||||
expect(firstBatch).toHaveLength(2)
|
||||
expect(
|
||||
new Set(firstBatch.map((claim) => claim.schedule.id)).size
|
||||
).toBe(2)
|
||||
|
||||
const secondBatch = database.claimDueSchedules(
|
||||
new Date('2026-08-13T00:01:00.000Z'),
|
||||
2
|
||||
)
|
||||
expect(secondBatch).toHaveLength(1)
|
||||
expect(scheduleIds).toContain(secondBatch[0]?.schedule.id)
|
||||
for (const claim of [...firstBatch, ...secondBatch]) {
|
||||
database.completeScheduleRun(claim.runId, 'completed')
|
||||
}
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('durably interrupts active tasks with completion times and audit events on startup', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-assistant-recovery-')
|
||||
@@ -1847,9 +2124,27 @@ describe('AssistantDatabase', () => {
|
||||
role: 'user',
|
||||
content: '远程消息'
|
||||
})
|
||||
const linkedSchedule = database.createSchedule({
|
||||
conversationId: localId,
|
||||
title: '随会话删除的任务',
|
||||
prompt: '整理会话',
|
||||
workMode: 'execute',
|
||||
recurrence: 'daily',
|
||||
nextRunAt: '2027-01-01T00:00:00.000Z'
|
||||
})
|
||||
|
||||
expect(database.deleteLocalConversation(localId)).toBe(true)
|
||||
expect(database.deleteLocalConversation(localId)).toBe(false)
|
||||
expect(
|
||||
database
|
||||
.listSchedules()
|
||||
.some((schedule) => schedule.id === linkedSchedule.id)
|
||||
).toBe(false)
|
||||
expect(
|
||||
database
|
||||
.listTasks()
|
||||
.some((task) => task.id === linkedSchedule.taskId)
|
||||
).toBe(false)
|
||||
expect(() =>
|
||||
database.getConversation(localId)
|
||||
).toThrow('对话不存在')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -102,7 +102,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
|
||||
).count
|
||||
check.close()
|
||||
migrated.close()
|
||||
expect(version).toBe(21)
|
||||
expect(version).toBe(22)
|
||||
expect(heartbeatTableCount).toBe(4)
|
||||
})
|
||||
|
||||
|
||||
@@ -165,7 +165,8 @@ export class SubagentService {
|
||||
instructions: input.parentRequest.prompt,
|
||||
workMode: 'ask',
|
||||
origin: 'subagent',
|
||||
status: 'queued'
|
||||
status: 'queued',
|
||||
visible: false
|
||||
})
|
||||
this.emit(input, {
|
||||
childTaskId,
|
||||
|
||||
@@ -2668,7 +2668,14 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
updatedAt: Date.now(),
|
||||
messages: []
|
||||
})),
|
||||
appendConversationMessage: vi.fn(),
|
||||
appendRemoteConversationMessage: vi.fn(),
|
||||
completeScheduleRun: vi.fn(),
|
||||
claimScheduleNow: vi.fn(),
|
||||
listSchedules: vi.fn(() => []),
|
||||
createSchedule: vi.fn(),
|
||||
setScheduleEnabled: vi.fn(),
|
||||
removeSchedule: vi.fn(),
|
||||
upsertModelUsageCall: vi.fn(),
|
||||
clearAssistantData: vi.fn(),
|
||||
listExperts: vi.fn<() => Array<Record<string, unknown>>>(() => []),
|
||||
@@ -2799,6 +2806,140 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
senderFrame: webContents.mainFrame
|
||||
})
|
||||
|
||||
it('defaults custom scheduled Tasks to Execute', async () => {
|
||||
const harness = createHarness({
|
||||
runtimeId: 'continue',
|
||||
capability: 'chat',
|
||||
supportsToolExecution: true,
|
||||
async *run() {
|
||||
yield { type: 'done', requestId: 'unused' } as const
|
||||
}
|
||||
})
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000705'
|
||||
const createInput = {
|
||||
projectId: '00000000-0000-4000-8000-000000000401',
|
||||
conversationId,
|
||||
title: '每周汇总',
|
||||
prompt: '汇总本周进展',
|
||||
recurrence: 'weekly' as const,
|
||||
nextRunAt: '2026-08-21T09:00:00.000Z'
|
||||
}
|
||||
|
||||
await electronMocks.handlers.get(
|
||||
ipcChannels.schedulesCreate
|
||||
)?.(trustedEvent(harness.webContents), createInput)
|
||||
|
||||
expect(
|
||||
harness.assistantDatabase.createSchedule
|
||||
).toHaveBeenCalledWith({
|
||||
...createInput,
|
||||
workMode: 'execute'
|
||||
})
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('reuses a scheduled Task and writes text results to its Conversation', async () => {
|
||||
const taskId = '00000000-0000-4000-8000-000000000701'
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000702'
|
||||
const scheduleId =
|
||||
'00000000-0000-4000-8000-000000000703'
|
||||
const runId = '00000000-0000-4000-8000-000000000704'
|
||||
const runtime = {
|
||||
runtimeId: 'model',
|
||||
capability: 'chat',
|
||||
supportsToolExecution: true,
|
||||
async *run(
|
||||
request: { requestId: string },
|
||||
_signal: AbortSignal,
|
||||
authorize: (
|
||||
input: {
|
||||
scopeKey: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
) => Promise<string>
|
||||
) {
|
||||
expect(request.requestId).toBe(runId)
|
||||
await authorize({
|
||||
scopeKey: 'workspace.write',
|
||||
title: '写入工作区',
|
||||
description: '更新状态文件'
|
||||
})
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: '每日状态正常'
|
||||
} as const
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'done'
|
||||
} as const
|
||||
}
|
||||
}
|
||||
const harness = createHarness(runtime)
|
||||
const schedule = {
|
||||
id: scheduleId,
|
||||
projectId: '00000000-0000-4000-8000-000000000401',
|
||||
taskId,
|
||||
conversationId,
|
||||
title: '每日状态',
|
||||
prompt: '汇总状态',
|
||||
workMode: 'execute' as const,
|
||||
recurrence: 'daily' as const,
|
||||
nextRunAt: '2026-08-20T00:00:00.000Z',
|
||||
enabled: true,
|
||||
createdAt: '2026-08-19T00:00:00.000Z',
|
||||
updatedAt: '2026-08-19T00:00:00.000Z'
|
||||
}
|
||||
harness.assistantDatabase.claimScheduleNow.mockReturnValue({
|
||||
schedule,
|
||||
runId
|
||||
})
|
||||
harness.approvalBroker.request.mockResolvedValue('once')
|
||||
|
||||
await electronMocks.handlers.get(
|
||||
ipcChannels.schedulesRunNow
|
||||
)?.(trustedEvent(harness.webContents), scheduleId)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
harness.assistantDatabase.completeScheduleRun
|
||||
).toHaveBeenCalledWith(runId, 'completed')
|
||||
)
|
||||
expect(
|
||||
harness.assistantDatabase.updateTaskStatus
|
||||
).toHaveBeenCalledWith(taskId, 'running')
|
||||
expect(harness.approvalBroker.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestId: taskId,
|
||||
conversationId
|
||||
}),
|
||||
expect.any(AbortSignal),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(
|
||||
harness.assistantDatabase.appendConversationMessage
|
||||
).toHaveBeenCalledWith({
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: '每日状态正常',
|
||||
status: '定时任务',
|
||||
task: {
|
||||
id: taskId,
|
||||
title: '每日状态'
|
||||
}
|
||||
})
|
||||
expect(
|
||||
harness.assistantDatabase.createTextArtifact
|
||||
).not.toHaveBeenCalled()
|
||||
expect(harness.webContents.send).toHaveBeenCalledWith(
|
||||
ipcChannels.conversationsChanged
|
||||
)
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('publishes Runtime usage as context metrics with one settings read', async () => {
|
||||
const runtime = {
|
||||
runtimeId: 'continue',
|
||||
|
||||
+232
-92
@@ -156,8 +156,7 @@ import {
|
||||
expertCreateSchema,
|
||||
type AssistantSchedule,
|
||||
type AssistantArtifact,
|
||||
type ConversationAttachment,
|
||||
type WorkMode
|
||||
type ConversationAttachment
|
||||
} from '../shared/assistant-contracts'
|
||||
import {
|
||||
CHANNEL_LIMITS,
|
||||
@@ -1158,7 +1157,8 @@ export function registerIpcHandlers(
|
||||
title: '智能心跳回顾',
|
||||
instructions: '根据有界本地输入生成智能心跳报告',
|
||||
workMode: 'ask',
|
||||
origin: 'assistant'
|
||||
origin: 'assistant',
|
||||
visible: false
|
||||
})
|
||||
let output = ''
|
||||
let completed = false
|
||||
@@ -1242,28 +1242,52 @@ export function registerIpcHandlers(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const executeSchedule = async (
|
||||
schedule: Omit<AssistantSchedule, 'workMode'> & {
|
||||
workMode: WorkMode
|
||||
},
|
||||
origin: 'schedule' | 'delegation' | 'channel' = 'schedule',
|
||||
externalSignal?: AbortSignal,
|
||||
remoteContext?: {
|
||||
channel: keyof typeof projectChannelLabels
|
||||
channelLabel: string
|
||||
senderDisplay: string
|
||||
projectId: string
|
||||
projectName: string
|
||||
rootPath: string
|
||||
conversationId: string
|
||||
runtimeSelection: AgentRuntimeSelection
|
||||
followConfiguredAgentRuntime?: boolean
|
||||
runtime?: AgentRuntime
|
||||
taskId?: string
|
||||
contextIds?: string[]
|
||||
resultFileRequested?: boolean
|
||||
const publishConversationChange = (): void => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send(ipcChannels.conversationsChanged)
|
||||
}
|
||||
}
|
||||
|
||||
type ExecutionTemplate = Omit<
|
||||
AssistantSchedule,
|
||||
'taskId' | 'conversationId'
|
||||
>
|
||||
type ChannelExecutionContext = {
|
||||
channel: keyof typeof projectChannelLabels
|
||||
channelLabel: string
|
||||
senderDisplay: string
|
||||
projectId: string
|
||||
projectName: string
|
||||
rootPath: string
|
||||
conversationId: string
|
||||
runtimeSelection: AgentRuntimeSelection
|
||||
followConfiguredAgentRuntime?: boolean
|
||||
runtime?: AgentRuntime
|
||||
taskId: string
|
||||
contextIds?: string[]
|
||||
resultFileRequested?: boolean
|
||||
}
|
||||
type TaskWorkExecution =
|
||||
| {
|
||||
origin: 'schedule'
|
||||
schedule: AssistantSchedule
|
||||
scheduleRunId: string
|
||||
externalSignal?: AbortSignal
|
||||
}
|
||||
| {
|
||||
origin: 'delegation'
|
||||
schedule: ExecutionTemplate
|
||||
externalSignal?: AbortSignal
|
||||
}
|
||||
| {
|
||||
origin: 'channel'
|
||||
schedule: ExecutionTemplate
|
||||
externalSignal: AbortSignal
|
||||
remoteContext: ChannelExecutionContext
|
||||
}
|
||||
|
||||
const executeTaskWork = async (
|
||||
input: TaskWorkExecution
|
||||
): Promise<{
|
||||
status: 'completed' | 'failed'
|
||||
output?: string
|
||||
@@ -1271,13 +1295,24 @@ export function registerIpcHandlers(
|
||||
attachments?: ChannelMediaAttachment[]
|
||||
artifactIds?: string[]
|
||||
}> => {
|
||||
const { origin, schedule } = input
|
||||
const externalSignal = input.externalSignal
|
||||
const remoteContext =
|
||||
input.origin === 'channel' ? input.remoteContext : undefined
|
||||
if (shuttingDown || executionPaused) {
|
||||
return { status: 'failed', error: '应用正在退出' }
|
||||
}
|
||||
if (externalSignal?.aborted) {
|
||||
return { status: 'failed', error: '请求已取消' }
|
||||
}
|
||||
const requestId = remoteContext?.taskId ?? randomUUID()
|
||||
const taskId =
|
||||
input.origin === 'schedule'
|
||||
? input.schedule.taskId
|
||||
: input.origin === 'channel'
|
||||
? input.remoteContext.taskId
|
||||
: randomUUID()
|
||||
const requestId =
|
||||
input.origin === 'schedule' ? input.scheduleRunId : taskId
|
||||
const controller = new AbortController()
|
||||
const abortFromExternal = (): void => {
|
||||
controller.abort(externalSignal?.reason)
|
||||
@@ -1287,22 +1322,27 @@ export function registerIpcHandlers(
|
||||
})
|
||||
activeRequests.set(requestId, controller)
|
||||
const runtimeConversationId =
|
||||
remoteContext?.conversationId ?? `${origin}:${schedule.id}`
|
||||
if (remoteContext?.taskId) {
|
||||
assistantDatabase.updateTaskStatus(requestId, 'running')
|
||||
remoteContext?.conversationId ??
|
||||
(input.origin === 'schedule'
|
||||
? input.schedule.conversationId
|
||||
: undefined) ??
|
||||
`${origin}:${schedule.id}`
|
||||
if (input.origin !== 'delegation') {
|
||||
assistantDatabase.updateTaskStatus(taskId, 'running')
|
||||
} else {
|
||||
assistantDatabase.createTask({
|
||||
id: requestId,
|
||||
id: taskId,
|
||||
projectId: schedule.projectId,
|
||||
conversationId: runtimeConversationId,
|
||||
title: schedule.title,
|
||||
instructions: schedule.prompt,
|
||||
workMode: schedule.workMode,
|
||||
origin: origin === 'channel' ? 'delegation' : origin
|
||||
origin: 'delegation',
|
||||
visible: false
|
||||
})
|
||||
}
|
||||
if (origin === 'schedule') {
|
||||
assistantDatabase.bindScheduleRunTask(schedule.id, requestId)
|
||||
publishConversationChange()
|
||||
}
|
||||
let output = ''
|
||||
let completed = false
|
||||
@@ -1313,7 +1353,7 @@ export function registerIpcHandlers(
|
||||
onError: (error) => controller.abort(error),
|
||||
onEvent: (event) => {
|
||||
assistantDatabase.appendTaskEvent(
|
||||
requestId,
|
||||
taskId,
|
||||
event.type,
|
||||
event
|
||||
)
|
||||
@@ -1324,7 +1364,9 @@ export function registerIpcHandlers(
|
||||
remoteContext?.runtime ??
|
||||
(await resolveRequestRuntime({
|
||||
projectId: schedule.projectId,
|
||||
runtimeSelection: remoteContext?.runtimeSelection,
|
||||
runtimeSelection:
|
||||
remoteContext?.runtimeSelection ??
|
||||
schedule.runtimeSelection,
|
||||
workspaceOverride: remoteContext?.rootPath,
|
||||
followConfiguredAgentRuntime:
|
||||
remoteContext?.followConfiguredAgentRuntime
|
||||
@@ -1389,9 +1431,12 @@ export function registerIpcHandlers(
|
||||
return channelToolPolicy === 'policy' ? 'deny' : 'once'
|
||||
}
|
||||
assistantDatabase.updateTaskStatus(
|
||||
requestId,
|
||||
taskId,
|
||||
'waiting_approval'
|
||||
)
|
||||
if (origin === 'schedule') {
|
||||
publishConversationChange()
|
||||
}
|
||||
const settings = await settingsStore.getPolicySettings()
|
||||
try {
|
||||
return await approvalBroker.request(
|
||||
@@ -1401,7 +1446,8 @@ export function registerIpcHandlers(
|
||||
settings.toolApproval === 'policy'
|
||||
? 'policy'
|
||||
: undefined,
|
||||
requestId,
|
||||
requestId:
|
||||
origin === 'schedule' ? taskId : requestId,
|
||||
conversationId: runtimeConversationId
|
||||
},
|
||||
controller.signal,
|
||||
@@ -1417,7 +1463,10 @@ export function registerIpcHandlers(
|
||||
)
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
assistantDatabase.updateTaskStatus(requestId, 'running')
|
||||
assistantDatabase.updateTaskStatus(taskId, 'running')
|
||||
if (origin === 'schedule') {
|
||||
publishConversationChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1445,14 +1494,21 @@ export function registerIpcHandlers(
|
||||
agentRuntimeSelected ? undefined : authorize
|
||||
)) {
|
||||
if (agentEvent.type === 'model-usage') {
|
||||
persistModelUsage(agentEvent)
|
||||
persistModelUsage({
|
||||
...agentEvent,
|
||||
requestId: taskId,
|
||||
callId:
|
||||
origin === 'schedule'
|
||||
? `${requestId}:${agentEvent.callId}`
|
||||
: agentEvent.callId
|
||||
})
|
||||
continue
|
||||
}
|
||||
const taskEvent =
|
||||
agentEvent.type === 'generated-image'
|
||||
? persistGeneratedImage(agentEvent, {
|
||||
projectId: schedule.projectId,
|
||||
taskId: requestId,
|
||||
taskId,
|
||||
title: schedule.title
|
||||
})
|
||||
: agentEvent
|
||||
@@ -1553,15 +1609,35 @@ export function registerIpcHandlers(
|
||||
})
|
||||
}
|
||||
}
|
||||
if (origin !== 'channel' && output.trim()) {
|
||||
if (
|
||||
input.origin === 'schedule' &&
|
||||
(output.trim() || artifactIds.length > 0)
|
||||
) {
|
||||
assistantDatabase.appendConversationMessage({
|
||||
conversationId: input.schedule.conversationId,
|
||||
role: 'assistant',
|
||||
content:
|
||||
output.trim() ||
|
||||
'任务已完成,独立成果已保存到成果工作栏。',
|
||||
status: '定时任务',
|
||||
...(artifactIds.length > 0 ? { artifactIds } : {}),
|
||||
task: {
|
||||
id: taskId,
|
||||
title: schedule.title
|
||||
}
|
||||
})
|
||||
publishConversationChange()
|
||||
} else if (origin === 'delegation' && output.trim()) {
|
||||
assistantDatabase.createTextArtifact({
|
||||
projectId: schedule.projectId,
|
||||
taskId: requestId,
|
||||
taskId,
|
||||
title: schedule.title,
|
||||
content: output
|
||||
})
|
||||
}
|
||||
assistantDatabase.updateTaskStatus(requestId, 'completed')
|
||||
if (origin !== 'schedule') {
|
||||
assistantDatabase.updateTaskStatus(taskId, 'completed')
|
||||
}
|
||||
showDesktopNotificationWhenUnfocused(window, {
|
||||
title:
|
||||
origin === 'channel'
|
||||
@@ -1571,7 +1647,7 @@ export function registerIpcHandlers(
|
||||
origin === 'channel'
|
||||
? '结果已回复,并保存到远程通道会话。'
|
||||
: origin === 'schedule'
|
||||
? '结果已保存到 GoodBuddy 成果工作栏。'
|
||||
? '结果已写入关联对话。'
|
||||
: '结果已保存到成果工作栏和委派记录。'
|
||||
})
|
||||
return {
|
||||
@@ -1586,10 +1662,24 @@ export function registerIpcHandlers(
|
||||
eventBuffer.flush()
|
||||
const message = safeRuntimeError(error, '定时任务执行失败')
|
||||
assistantDatabase.updateTaskStatus(
|
||||
requestId,
|
||||
taskId,
|
||||
controller.signal.aborted ? 'cancelled' : 'failed',
|
||||
message
|
||||
)
|
||||
if (input.origin === 'schedule') {
|
||||
assistantDatabase.appendConversationMessage({
|
||||
conversationId: input.schedule.conversationId,
|
||||
role: 'assistant',
|
||||
content: message,
|
||||
state: 'error',
|
||||
status: '定时任务失败',
|
||||
task: {
|
||||
id: taskId,
|
||||
title: schedule.title
|
||||
}
|
||||
})
|
||||
publishConversationChange()
|
||||
}
|
||||
showDesktopNotificationWhenUnfocused(window, {
|
||||
title:
|
||||
origin === 'channel'
|
||||
@@ -1720,34 +1810,71 @@ export function registerIpcHandlers(
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
|
||||
let scheduleTickRunning = false
|
||||
const runDueSchedules = async (): Promise<void> => {
|
||||
if (scheduleTickRunning || shuttingDown || executionPaused) {
|
||||
const maximumConcurrentScheduleRuns = 4
|
||||
let scheduleClaimRunning = false
|
||||
let activeScheduleRuns = 0
|
||||
const launchDueSchedules = (): void => {
|
||||
if (
|
||||
scheduleClaimRunning ||
|
||||
shuttingDown ||
|
||||
executionPaused ||
|
||||
activeScheduleRuns >= maximumConcurrentScheduleRuns
|
||||
) {
|
||||
return
|
||||
}
|
||||
scheduleTickRunning = true
|
||||
scheduleClaimRunning = true
|
||||
try {
|
||||
for (const claim of assistantDatabase.claimDueSchedules()) {
|
||||
const result = await trackExecution(
|
||||
executeSchedule(claim.schedule)
|
||||
)
|
||||
assistantDatabase.completeScheduleRun(
|
||||
claim.runId,
|
||||
result.status,
|
||||
assistantDatabase.getScheduleRunTaskId(claim.runId)
|
||||
)
|
||||
}
|
||||
if (!shuttingDown && !executionPaused) {
|
||||
await trackExecution(heartbeatService.processDue())
|
||||
const claims = assistantDatabase.claimDueSchedules(
|
||||
new Date(),
|
||||
maximumConcurrentScheduleRuns - activeScheduleRuns
|
||||
)
|
||||
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()
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
scheduleTickRunning = false
|
||||
scheduleClaimRunning = false
|
||||
}
|
||||
}
|
||||
const scheduleInterval = setInterval(() => {
|
||||
void trackExecution(runDueSchedules()).catch(() => undefined)
|
||||
}, 30_000)
|
||||
void trackExecution(runDueSchedules()).catch(() => undefined)
|
||||
let heartbeatTickRunning = false
|
||||
const runDueHeartbeats = async (): Promise<void> => {
|
||||
if (
|
||||
heartbeatTickRunning ||
|
||||
shuttingDown ||
|
||||
executionPaused
|
||||
) {
|
||||
return
|
||||
}
|
||||
heartbeatTickRunning = true
|
||||
try {
|
||||
await heartbeatService.processDue()
|
||||
} finally {
|
||||
heartbeatTickRunning = false
|
||||
}
|
||||
}
|
||||
const runDueWork = (): void => {
|
||||
launchDueSchedules()
|
||||
void trackExecution(runDueHeartbeats()).catch(() => undefined)
|
||||
}
|
||||
const scheduleInterval = setInterval(runDueWork, 30_000)
|
||||
runDueWork()
|
||||
const delegationEndpoint =
|
||||
process.env.GOODBUDDY_DELEGATION_ENDPOINT?.trim()
|
||||
const delegationToken =
|
||||
@@ -1768,18 +1895,23 @@ export function registerIpcHandlers(
|
||||
assistantDatabase.markDelegationDelivered(taskId)
|
||||
},
|
||||
onTask: (task) =>
|
||||
trackExecution(executeSchedule({
|
||||
id: task.id,
|
||||
projectId: task.projectId,
|
||||
title: task.title,
|
||||
prompt: task.prompt,
|
||||
workMode: task.workMode,
|
||||
recurrence: 'once',
|
||||
nextRunAt: new Date().toISOString(),
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}, 'delegation'))
|
||||
trackExecution(
|
||||
executeTaskWork({
|
||||
origin: 'delegation',
|
||||
schedule: {
|
||||
id: task.id,
|
||||
projectId: task.projectId,
|
||||
title: task.title,
|
||||
prompt: task.prompt,
|
||||
workMode: task.workMode,
|
||||
recurrence: 'once',
|
||||
nextRunAt: new Date().toISOString(),
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
: undefined
|
||||
remoteDelegation?.start()
|
||||
@@ -1927,7 +2059,8 @@ export function registerIpcHandlers(
|
||||
title: `${channelLabel}远程请求`,
|
||||
instructions: executionPrompt,
|
||||
workMode: parsed.workMode,
|
||||
origin: 'delegation'
|
||||
origin: 'delegation',
|
||||
visible: false
|
||||
})
|
||||
publishRemoteActivity({
|
||||
requestId: remoteTaskId,
|
||||
@@ -2021,8 +2154,9 @@ export function registerIpcHandlers(
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const result = await trackExecution(
|
||||
executeSchedule(
|
||||
{
|
||||
executeTaskWork({
|
||||
origin: 'channel',
|
||||
schedule: {
|
||||
id: randomUUID(),
|
||||
projectId: project.id,
|
||||
title: `${channelLabel}远程请求`,
|
||||
@@ -2034,9 +2168,8 @@ export function registerIpcHandlers(
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
'channel',
|
||||
signal,
|
||||
{
|
||||
externalSignal: signal,
|
||||
remoteContext: {
|
||||
channel,
|
||||
channelLabel,
|
||||
senderDisplay,
|
||||
@@ -2053,7 +2186,7 @@ export function registerIpcHandlers(
|
||||
message.text
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
)
|
||||
const responseText =
|
||||
result.output?.trim() ||
|
||||
@@ -2408,7 +2541,8 @@ export function registerIpcHandlers(
|
||||
conversationId: request.conversationId,
|
||||
title: parsedRequest.prompt.slice(0, 120),
|
||||
instructions: parsedRequest.prompt,
|
||||
workMode: request.workMode ?? 'ask'
|
||||
workMode: request.workMode ?? 'ask',
|
||||
visible: false
|
||||
})
|
||||
} catch (error) {
|
||||
knowledgeGateway?.revoke(knowledgeCapabilityToken)
|
||||
@@ -4255,12 +4389,14 @@ export function registerIpcHandlers(
|
||||
value.scheduleId,
|
||||
value.enabled
|
||||
)
|
||||
publishConversationChange()
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(ipcChannels.schedulesRemove, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
assistantDatabase.removeSchedule(assistantIdSchema.parse(input))
|
||||
publishConversationChange()
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.schedulesRunNow, (event, input: unknown) => {
|
||||
@@ -4271,15 +4407,19 @@ export function registerIpcHandlers(
|
||||
const claim = assistantDatabase.claimScheduleNow(
|
||||
assistantIdSchema.parse(input)
|
||||
)
|
||||
void trackExecution(executeSchedule(claim.schedule))
|
||||
.then((result) => {
|
||||
assistantDatabase.completeScheduleRun(
|
||||
claim.runId,
|
||||
result.status,
|
||||
assistantDatabase.getScheduleRunTaskId(claim.runId)
|
||||
)
|
||||
const execution = (async () => {
|
||||
const result = await executeTaskWork({
|
||||
origin: 'schedule',
|
||||
schedule: claim.schedule,
|
||||
scheduleRunId: claim.runId
|
||||
})
|
||||
.catch(() => undefined)
|
||||
assistantDatabase.completeScheduleRun(
|
||||
claim.runId,
|
||||
result.status
|
||||
)
|
||||
publishConversationChange()
|
||||
})()
|
||||
void trackExecution(execution).catch(() => undefined)
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.heartbeatsList, (event, input: unknown) => {
|
||||
|
||||
Reference in New Issue
Block a user