fix: preserve conversation settings for models and scheduled messages

Preserve explicit Runtime model selections and Harness configuration rules without pinning resolved defaults. Expose fixed Runtime models in project settings and keep SSH conversations inheriting their project selection.

Dispatch scheduled instructions through ordinary conversation requests using current history, Runtime, work mode, and knowledge scope. Persist conversation work modes and update scheduling documentation and regression tests.

Validation checkpoint: typecheck, lint, and 104 focused tests passed. The SSH inheritance regression passed in isolation, but its process timed out on exit. Full-suite completion, remaining model documentation review, and real end-to-end validation are still pending.

Release note: Preserve fixed model choices and let scheduled messages use their conversation's current settings and history.
This commit is contained in:
mesalogo
2026-09-10 10:17:45 +08:00
parent fedafa21bd
commit 444902d8ad
22 changed files with 628 additions and 353 deletions
+77 -2
View File
@@ -131,6 +131,80 @@ function claimManualScheduleQueueItem(
}
describe('AssistantDatabase', () => {
it('keeps timed messages independent of creation-time execution settings', async () => {
const database = await createDatabase()
const project = database.createProject({
name: 'Scheduled project', rootPath: 'C:\\Workspace', description: '',
defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' }
})
const schedule = database.createSchedule({
projectId: project.id, title: 'Timed message', prompt: 'Continue our work',
recurrence: 'daily', nextRunAt: '2026-09-10T00:00:00Z'
})
expect(schedule.runtimeSelection).toBeUndefined()
const conversation = database.getConversation(schedule.conversationId)
expect(conversation.runtimeSelection).toBeUndefined()
expect(conversation.workMode).toBeUndefined()
const { messages, ...header } = conversation
database.saveLocalConversations([{ header: {
...header, workMode: 'execute',
runtimeSelection: { provider: 'continue' }
}, messages }])
const existing = database.createSchedule({
projectId: project.id, conversationId: conversation.id,
title: 'Second message', prompt: 'Continue', workMode: 'ask',
recurrence: 'once', nextRunAt: '2026-09-11T00:00:00Z'
})
expect(existing.runtimeSelection).toBeUndefined()
database.close()
database.initialize('C:\\Workspace')
expect(database.getConversation(conversation.id)).toMatchObject({
workMode: 'execute', runtimeSelection: { provider: 'continue' }
})
database.close()
})
it('releases scheduled dispatches for retry and completes accepted ordinary runs without replay on restart', async () => {
const database = await createDatabase()
const schedule = database.createSchedule({
title: 'Timed message', prompt: 'Continue', recurrence: 'daily',
nextRunAt: '2026-09-10T00:00:00Z'
})
const item = database.queueScheduleNow(schedule.id)
expect(database.claimConversationQueueItem(schedule.conversationId)?.source).toBe('schedule')
expect(database.isConversationUserQueueItemDispatching(item.id)).toBe(true)
database.releaseConversationUserQueueItem(item.id)
expect(database.claimConversationQueueItem(schedule.conversationId)?.source).toBe('schedule')
database.createTask({ id: item.scheduleRunId!, conversationId: schedule.conversationId,
title: 'Continue', instructions: 'Continue', workMode: 'execute', visible: false })
database.completeConversationUserQueueItem(item.id)
database.updateTaskStatus(item.scheduleRunId!, 'completed')
database.completeTaskScheduleRun(item.scheduleRunId!)
database.completeTaskScheduleRun(item.scheduleRunId!)
database.close()
database.initialize('C:\\Workspace')
expect(database.listConversationQueueItems(schedule.conversationId)).toEqual([])
expect(database.listSchedules().find(candidate => candidate.id === schedule.id)?.lastRunAt).toBeDefined()
database.close()
})
it('does not resend an accepted scheduled message interrupted by a local restart', async () => {
const database = await createDatabase()
const schedule = database.createSchedule({
title: 'Timed message', prompt: 'Continue', recurrence: 'once',
nextRunAt: '2026-09-10T00:00:00Z'
})
const item = database.queueScheduleNow(schedule.id)
database.claimConversationQueueItem(schedule.conversationId)
database.createTask({ id: item.scheduleRunId!, conversationId: schedule.conversationId,
title: 'Continue', instructions: 'Continue', workMode: 'execute', visible: false })
database.completeConversationUserQueueItem(item.id)
database.close()
database.initialize('C:\\Workspace')
expect(database.listConversationQueueItems(schedule.conversationId)).toEqual([])
expect(database.listTasks().find(task => task.id === schedule.taskId)?.status).toBe('failed')
database.close()
})
it('rejects a newer unsupported schema without changing its version', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-future-')
@@ -5221,7 +5295,7 @@ describe('AssistantDatabase', () => {
},
continueModelSource: { kind: 'platform' }
})
).toBe(4)
).toBe(3)
expect(
database
.listConversations()
@@ -5235,7 +5309,8 @@ describe('AssistantDatabase', () => {
{ provider: 'model', profileId: runtimeProfileId }
])
expect(database.getProject(channelProject.id).runtimeSelection).toEqual({
provider: 'opencode'
provider: 'opencode',
profileId: runtimeProfileId
})
expect(
database.getProject(imageChannelProject.id).runtimeSelection
+45 -17
View File
@@ -1047,6 +1047,7 @@ function interruptActiveToolBlocks(
}
const conversationContextStateSchema = conversationSnapshotSchema.pick({
workMode: true,
knowledgeLibraryIds: true,
contextMetrics: true,
contextCompressionState: true
@@ -1056,7 +1057,7 @@ function parseConversationContextState(
value: string | null
): Pick<
ConversationSnapshot,
'knowledgeLibraryIds' | 'contextMetrics' | 'contextCompressionState'
'workMode' | 'knowledgeLibraryIds' | 'contextMetrics' | 'contextCompressionState'
> {
if (!value) {
return {}
@@ -1074,13 +1075,14 @@ function parseConversationContextState(
function serializeConversationContextState(
conversation: Pick<
ConversationSnapshot,
'knowledgeLibraryIds' | 'contextMetrics' | 'contextCompressionState'
'workMode' | 'knowledgeLibraryIds' | 'contextMetrics' | 'contextCompressionState'
>
): string | null {
return conversation.knowledgeLibraryIds !== undefined ||
return conversation.workMode !== undefined || conversation.knowledgeLibraryIds !== undefined ||
conversation.contextMetrics ||
conversation.contextCompressionState
? JSON.stringify({
workMode: conversation.workMode,
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState
@@ -1847,7 +1849,8 @@ export class AssistantDatabase {
.prepare(
`UPDATE schedule_runs
SET status = 'pending'
WHERE status = 'running'`
WHERE status = 'running'
AND NOT EXISTS (SELECT 1 FROM tasks WHERE tasks.id = schedule_runs.id)`
)
.run()
database
@@ -1985,6 +1988,12 @@ export class AssistantDatabase {
database.exec('ROLLBACK')
throw error
}
const startedSchedules = database.prepare(
`SELECT id FROM schedule_runs WHERE status = 'running'`
).all() as Array<{ id: string }>
for (const run of startedSchedules) {
this.completeTaskScheduleRun(run.id)
}
} catch (error) {
database.close()
throw error
@@ -5795,7 +5804,7 @@ export class AssistantDatabase {
.prepare(
`SELECT 1
FROM conversation_queue_items
WHERE id = ? AND source = 'user' AND status = 'dispatching'`
WHERE id = ? AND status = 'dispatching'`
)
.get(itemId)
)
@@ -5953,7 +5962,7 @@ export class AssistantDatabase {
const result = this.requireDatabase()
.prepare(
`DELETE FROM conversation_queue_items
WHERE id = ? AND source = 'user' AND status = 'dispatching'`
WHERE id = ? AND status = 'dispatching'`
)
.run(itemId)
if (result.changes !== 1) {
@@ -5962,11 +5971,14 @@ export class AssistantDatabase {
}
releaseConversationUserQueueItem(itemId: string): void {
const result = this.requireDatabase()
const database = this.requireDatabase()
database.exec('BEGIN IMMEDIATE')
try {
const result = database
.prepare(
`UPDATE conversation_queue_items
SET status = 'pending'
WHERE id = ? AND source = 'user' AND status = 'dispatching'`
WHERE id = ? AND status = 'dispatching'`
)
.run(itemId)
if (
@@ -5975,12 +5987,20 @@ export class AssistantDatabase {
.prepare(
`SELECT 1
FROM conversation_queue_items
WHERE id = ? AND source = 'user' AND status = 'pending'`
WHERE id = ? AND status = 'pending'`
)
.get(itemId)
) {
throw new Error('待发送消息不存在或状态已变化')
}
database.prepare(`UPDATE schedule_runs SET status = 'pending'
WHERE id = (SELECT schedule_run_id FROM conversation_queue_items WHERE id = ?)
AND status = 'running'`).run(itemId)
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
removeConversationUserQueueItem(itemId: string): void {
@@ -6054,7 +6074,6 @@ export class AssistantDatabase {
try {
let projectId = parsed.projectId ?? null
let conversationId = parsed.conversationId
let runtimeSelectionJson: string | null = null
if (conversationId) {
const conversation = database
.prepare(
@@ -6083,7 +6102,6 @@ export class AssistantDatabase {
throw new Error('任务项目与所选对话不一致')
}
projectId = conversation.project_id
runtimeSelectionJson = conversation.runtime_selection_json
} else {
if (projectId) {
const project = database
@@ -6098,7 +6116,6 @@ export class AssistantDatabase {
if (!project) {
throw new Error('任务项目不存在或不可用')
}
runtimeSelectionJson = project.runtime_selection_json
}
conversationId = randomUUID()
database
@@ -6111,8 +6128,8 @@ export class AssistantDatabase {
.run(
conversationId,
projectId,
runtimeSelectionJson,
parsed.workMode,
null,
'ask',
parsed.title,
now,
now
@@ -6132,9 +6149,7 @@ export class AssistantDatabase {
JSON.stringify({
title: parsed.title,
prompt: parsed.prompt,
workMode: parsed.workMode,
runtimeSelection:
parseRuntimeSelection(runtimeSelectionJson)
workMode: parsed.workMode
}),
JSON.stringify({ type: parsed.recurrence }),
parsed.nextRunAt,
@@ -6425,6 +6440,19 @@ export class AssistantDatabase {
return this.getConversationQueueItem(runId)!
}
completeTaskScheduleRun(taskId: string): void {
const run = this.requireDatabase().prepare(
`SELECT t.status, t.remote_recoverable FROM schedule_runs sr
INNER JOIN tasks t ON t.id = sr.id
WHERE sr.id = ? AND sr.status = 'running'`
).get(taskId) as { status: string; remote_recoverable: number } | undefined
if (run && !(run.status === 'interrupted' && run.remote_recoverable === 1) &&
['completed', 'failed', 'cancelled', 'interrupted'].includes(run.status)) {
this.completeScheduleRun(taskId,
run.status === 'completed' || run.status === 'cancelled' ? run.status : 'failed')
}
}
completeScheduleRun(
runId: string,
status: 'completed' | 'failed' | 'cancelled',