fix: synchronize scheduled task status and run actions
Reflect queued and active schedule runs in task status, refresh tasks on queue changes, and prevent duplicate Run Now actions until the current run finishes. Add lifecycle regression coverage and update scheduled task behavior documentation. This file-level commit also includes concurrent conversation history, browser workbar, and runtime selection changes in the affected files. Focused schedule regressions passed; full workspace validation has unrelated failures as recorded during development. Release note: Scheduled tasks now refresh their queued and running status and keep Run Now disabled while an occurrence is active.
This commit is contained in:
@@ -1965,7 +1965,7 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('returns the latest 500 remote messages in chronological order', () => {
|
||||
it('returns all remote messages in chronological order beyond 500', () => {
|
||||
// Exercise the query boundary without 502 disk-synchronized fixture writes.
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize('C:\\Workspace')
|
||||
@@ -1992,8 +1992,8 @@ describe('AssistantDatabase', () => {
|
||||
}
|
||||
|
||||
const messages = database.getConversation(conversation.id).messages
|
||||
expect(messages).toHaveLength(500)
|
||||
expect(messages[0]?.content).toBe('消息 2')
|
||||
expect(messages).toHaveLength(502)
|
||||
expect(messages[0]?.content).toBe('消息 0')
|
||||
expect(messages.at(-1)?.content).toBe('消息 501')
|
||||
database.close()
|
||||
})
|
||||
@@ -3708,6 +3708,9 @@ describe('AssistantDatabase', () => {
|
||||
recurrence: 'daily',
|
||||
nextRunAt: '2026-08-20T09:00:00.000Z'
|
||||
})
|
||||
const taskStatus = (): string | undefined =>
|
||||
database.listTasks().find((task) => task.id === schedule.taskId)?.status
|
||||
expect(taskStatus()).toBe('idle')
|
||||
|
||||
const [dueItem] = database.queueDueSchedules(
|
||||
new Date('2026-08-20T09:01:00.000Z')
|
||||
@@ -3718,6 +3721,10 @@ describe('AssistantDatabase', () => {
|
||||
scheduleId: schedule.id,
|
||||
taskId: schedule.taskId
|
||||
})
|
||||
expect(taskStatus()).toBe('queued')
|
||||
expect(() => database.queueScheduleNow(schedule.id)).toThrow(
|
||||
'定时任务已有一次运行正在进行'
|
||||
)
|
||||
expect(database.listConversationQueueItems()).toEqual([
|
||||
expect.objectContaining({ id: dueItem!.id })
|
||||
])
|
||||
@@ -3737,18 +3744,28 @@ describe('AssistantDatabase', () => {
|
||||
schedule: { id: schedule.id },
|
||||
runId: dueItem!.id
|
||||
})
|
||||
expect(taskStatus()).toBe('running')
|
||||
database.updateTaskStatus(schedule.taskId, 'waiting_approval')
|
||||
expect(taskStatus()).toBe('waiting_approval')
|
||||
database.updateTaskStatus(schedule.taskId, 'running')
|
||||
expect(taskStatus()).toBe('running')
|
||||
database.completeScheduleRun(
|
||||
dueItem!.id,
|
||||
'completed',
|
||||
new Date('2026-08-20T09:02:00.000Z')
|
||||
)
|
||||
expect(taskStatus()).toBe('idle')
|
||||
|
||||
database.setScheduleEnabled(schedule.id, false)
|
||||
expect(taskStatus()).toBe('paused')
|
||||
const manualItem = database.queueScheduleNow(schedule.id)
|
||||
expect(taskStatus()).toBe('queued')
|
||||
expect(manualItem).toMatchObject({
|
||||
source: 'schedule',
|
||||
scheduleId: schedule.id
|
||||
})
|
||||
database.cancelConversationQueueItem(manualItem.id)
|
||||
expect(taskStatus()).toBe('paused')
|
||||
expect(database.listConversationQueueItems()).toEqual([])
|
||||
database.close()
|
||||
})
|
||||
@@ -4781,7 +4798,7 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('keeps incremental local conversation storage bounded to 500 messages', async () => {
|
||||
it('preserves history beyond 500 messages across incremental saves', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-bounded-local-conversation-')
|
||||
)
|
||||
@@ -4832,8 +4849,9 @@ describe('AssistantDatabase', () => {
|
||||
])
|
||||
|
||||
const restored = database.getConversation(conversationId)
|
||||
expect(restored.messages).toHaveLength(500)
|
||||
expect(restored.messages[0]?.id).toBe(messageId(1))
|
||||
expect(restored.messages).toHaveLength(501)
|
||||
expect(restored.messages[0]?.id).toBe(messageId(0))
|
||||
expect(database.listConversations().find((item) => item.id === conversationId)?.messages).toHaveLength(501)
|
||||
expect(restored.messages.at(-1)?.id).toBe(newestMessageId)
|
||||
const raw = new DatabaseSync(databasePath)
|
||||
expect(
|
||||
@@ -4846,8 +4864,8 @@ describe('AssistantDatabase', () => {
|
||||
)
|
||||
.get(conversationId)
|
||||
).toEqual({
|
||||
count: 500,
|
||||
minimum: 1,
|
||||
count: 501,
|
||||
minimum: 0,
|
||||
maximum: 500
|
||||
})
|
||||
raw.close()
|
||||
|
||||
@@ -178,6 +178,7 @@ type TaskRow = {
|
||||
instructions: string
|
||||
origin: AssistantTask['origin']
|
||||
status: AssistantTask['status']
|
||||
active_run_status?: 'pending' | 'running' | null
|
||||
work_mode: LegacyWorkMode
|
||||
progress: number | null
|
||||
created_at: string
|
||||
@@ -576,6 +577,14 @@ function readProject(
|
||||
return toProject(row)
|
||||
}
|
||||
|
||||
const taskSelect = `SELECT tasks.*,
|
||||
(SELECT status FROM schedule_runs
|
||||
WHERE schedule_id = tasks.schedule_id
|
||||
AND status IN ('pending', 'running')
|
||||
ORDER BY CASE status WHEN 'running' THEN 0 ELSE 1 END
|
||||
LIMIT 1) AS active_run_status
|
||||
FROM tasks`
|
||||
|
||||
function toTask(row: TaskRow): AssistantTask {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -589,9 +598,15 @@ function toTask(row: TaskRow): AssistantTask {
|
||||
instructions: row.instructions,
|
||||
origin: row.origin,
|
||||
status:
|
||||
row.origin === 'schedule' && row.status === 'queued'
|
||||
? 'idle'
|
||||
: row.status,
|
||||
row.active_run_status === 'pending'
|
||||
? 'queued'
|
||||
: row.active_run_status === 'running'
|
||||
? row.status === 'waiting_approval'
|
||||
? 'waiting_approval'
|
||||
: 'running'
|
||||
: row.origin === 'schedule' && row.status === 'queued'
|
||||
? 'idle'
|
||||
: row.status,
|
||||
workMode: normalizeInteractiveWorkMode(row.work_mode),
|
||||
progress: row.progress ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
@@ -1168,8 +1183,6 @@ function serializeConversationMessageMetadata(
|
||||
})
|
||||
}
|
||||
|
||||
const maximumRecoveredMessageLength = 1_000_000
|
||||
|
||||
function appendRecoveredMessageBlock(
|
||||
blocks: ConversationMessageBlock[] | undefined,
|
||||
type: 'text' | 'reasoning',
|
||||
@@ -1183,16 +1196,13 @@ function appendRecoveredMessageBlock(
|
||||
if (previous?.type === type) {
|
||||
current[current.length - 1] = {
|
||||
...previous,
|
||||
content: `${previous.content}${delta}`.slice(
|
||||
0,
|
||||
maximumRecoveredMessageLength
|
||||
)
|
||||
content: `${previous.content}${delta}`
|
||||
}
|
||||
} else {
|
||||
current.push({
|
||||
id: randomUUID(),
|
||||
type,
|
||||
content: delta.slice(0, maximumRecoveredMessageLength)
|
||||
content: delta
|
||||
})
|
||||
}
|
||||
return current
|
||||
@@ -1296,11 +1306,7 @@ function reduceRecoveredAgentEvent(
|
||||
): ConversationMessage {
|
||||
let next: ConversationMessage = message
|
||||
if (event.type === 'text') {
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
maximumRecoveredMessageLength - message.content.length
|
||||
)
|
||||
const delta = event.delta.slice(0, remaining)
|
||||
const delta = event.delta
|
||||
const blocks = appendRecoveredMessageBlock(
|
||||
message.blocks,
|
||||
'text',
|
||||
@@ -1310,17 +1316,11 @@ function reduceRecoveredAgentEvent(
|
||||
...message,
|
||||
content: `${message.content}${delta}`,
|
||||
blocks,
|
||||
status:
|
||||
event.delta.length > remaining
|
||||
? '回答过长,已在本地截断显示'
|
||||
: undefined
|
||||
status: undefined
|
||||
}
|
||||
} else if (event.type === 'reasoning') {
|
||||
const reasoning = message.reasoning ?? ''
|
||||
const delta = event.delta.slice(
|
||||
0,
|
||||
Math.max(0, maximumRecoveredMessageLength - reasoning.length)
|
||||
)
|
||||
const delta = event.delta
|
||||
const blocks = appendRecoveredMessageBlock(
|
||||
message.blocks,
|
||||
'reasoning',
|
||||
@@ -1493,7 +1493,7 @@ function reduceRecoveredAgentEvent(
|
||||
: ('interrupted' as const)
|
||||
const fallback =
|
||||
event.type === 'error' && !represented && !message.content
|
||||
? event.message.slice(0, maximumRecoveredMessageLength)
|
||||
? event.message
|
||||
: ''
|
||||
next = {
|
||||
...message,
|
||||
@@ -2604,7 +2604,6 @@ export class AssistantDatabase {
|
||||
FROM messages
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY sequence DESC
|
||||
LIMIT 500
|
||||
)
|
||||
ORDER BY sequence ASC`
|
||||
)
|
||||
@@ -2643,7 +2642,6 @@ export class AssistantDatabase {
|
||||
FROM messages
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY sequence DESC
|
||||
LIMIT 500
|
||||
)
|
||||
ORDER BY sequence ASC`
|
||||
)
|
||||
@@ -2782,7 +2780,6 @@ export class AssistantDatabase {
|
||||
updatedAt
|
||||
)
|
||||
for (const [sequence, message] of conversation.messages
|
||||
.slice(-500)
|
||||
.entries()) {
|
||||
insertMessage.run(
|
||||
message.id,
|
||||
@@ -2851,16 +2848,6 @@ export class AssistantDatabase {
|
||||
SET content = ?, state = ?, metadata_json = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
const trimMessages = database.prepare(
|
||||
`DELETE FROM messages
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM messages
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY sequence DESC
|
||||
LIMIT -1 OFFSET 500
|
||||
)`
|
||||
)
|
||||
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
@@ -2910,7 +2897,6 @@ export class AssistantDatabase {
|
||||
let sequence = (
|
||||
nextSequence.get(header.id) as { sequence: number }
|
||||
).sequence
|
||||
let insertedMessage = false
|
||||
for (const message of save.messages) {
|
||||
const existingMessage = findMessage.get(message.id) as
|
||||
| {
|
||||
@@ -2948,10 +2934,6 @@ export class AssistantDatabase {
|
||||
new Date(message.createdAt).toISOString()
|
||||
)
|
||||
sequence += 1
|
||||
insertedMessage = true
|
||||
}
|
||||
if (insertedMessage) {
|
||||
trimMessages.run(header.id)
|
||||
}
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
@@ -3316,23 +3298,6 @@ export class AssistantDatabase {
|
||||
}),
|
||||
new Date(now).toISOString()
|
||||
)
|
||||
database
|
||||
.prepare(
|
||||
`DELETE FROM messages
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM messages
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY sequence DESC
|
||||
LIMIT -1 OFFSET 500
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tasks
|
||||
WHERE tasks.id = messages.request_id
|
||||
AND tasks.remote_recoverable = 1
|
||||
)`
|
||||
)
|
||||
.run(input.conversationId)
|
||||
database
|
||||
.prepare(
|
||||
'UPDATE conversations SET updated_at = ? WHERE id = ?'
|
||||
@@ -4207,7 +4172,7 @@ export class AssistantDatabase {
|
||||
const safeLimit = Math.max(1, Math.min(500, Math.trunc(limit)))
|
||||
const rows = this.requireDatabase()
|
||||
.prepare(
|
||||
`SELECT * FROM tasks
|
||||
`${taskSelect}
|
||||
WHERE visible = 1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`
|
||||
@@ -7698,7 +7663,7 @@ export class AssistantDatabase {
|
||||
|
||||
private getTask(taskId: string): AssistantTask {
|
||||
const row = this.requireDatabase()
|
||||
.prepare('SELECT * FROM tasks WHERE id = ?')
|
||||
.prepare(`${taskSelect} WHERE tasks.id = ?`)
|
||||
.get(taskId) as TaskRow | undefined
|
||||
if (!row) {
|
||||
throw new Error('任务不存在')
|
||||
|
||||
Reference in New Issue
Block a user