fix: preserve long run activity and large previews

Long Runtime sessions could silently stop retaining tool and Subagent activity, truncate Run history details, and reject workspace previews over 256 KB. Run history now persists in Main SQLite without the former fixed record or detail caps, surfaces possible loss from legacy caches, and renders long histories in batches.

Local and remote workspace previews now load UTF-8-safe 256 KiB pages on demand instead of rejecting larger text files. The legacy migration, clear-data path, IPC contracts, tests, and feature documentation are updated with the resulting behavior.

Release note: 长时间 Runtime 运行现在会完整保留工具和 Subagent 活动;运行记录迁移到 SQLite,并提示旧版可能已丢失的内容。超过 256KB 的本地及远程文本文件可通过分页面预览继续加载。
This commit is contained in:
mesalogo
2026-09-05 00:31:42 +08:00
parent d180f63abd
commit 9588c4b901
35 changed files with 1316 additions and 546 deletions
+81 -84
View File
@@ -3,12 +3,11 @@ import { DatabaseSync } from 'node:sqlite'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
activityHistorySnapshotSchema,
assistantIdSchema,
conversationMessageSchema,
conversationSnapshotSchema,
expertCreateSchema,
maximumConversationMessageBlocks,
maximumConversationRetainedActivities,
normalizeInteractiveWorkMode,
persistedProjectExecutionSpaceSchema,
projectCreateSchema,
@@ -24,6 +23,7 @@ import type {
AssistantProject,
AssistantSchedule,
AssistantTask,
ActivityHistorySnapshot,
ConversationQueueItem,
ConversationBranchInput,
ConversationMessageBlock,
@@ -95,7 +95,7 @@ import {
} from '../magic-notes/rich-content'
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 32
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 33
export type RemoteTaskEventInput = {
taskId: string
@@ -1123,6 +1123,8 @@ function toConversationSnapshot(
blocks: interrupted
? interruptActiveToolBlocks(metadata.blocks)
: metadata.blocks,
displayCaptureTruncated:
metadata.displayCaptureTruncated,
createdAt:
metadata.createdAt ?? Date.parse(message.created_at),
state: interrupted ? ('error' as const) : message.state,
@@ -1192,9 +1194,6 @@ function appendRecoveredMessageBlock(
)
}
} else {
if (current.length >= maximumConversationMessageBlocks) {
return blocks
}
current.push({
id: randomUUID(),
type,
@@ -1225,9 +1224,6 @@ function upsertRecoveredToolBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{ id: randomUUID(), type: 'tool' as const, tool }
@@ -1267,9 +1263,6 @@ function upsertRecoveredSubagentBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{ id: randomUUID(), type: 'subagent' as const, childTaskId }
@@ -1302,16 +1295,6 @@ function failedToolRepresentsError(
)
}
function canRetainRecoveredActivity(
message: ConversationMessage
): boolean {
return (
(message.tools?.length ?? 0) +
(message.subagents?.length ?? 0) <
maximumConversationRetainedActivities
)
}
function reduceRecoveredAgentEvent(
message: ConversationMessage,
event: RemoteConversationTaskEventInput['event']
@@ -1332,9 +1315,6 @@ function reduceRecoveredAgentEvent(
...message,
content: `${message.content}${delta}`,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(delta && message.blocks && blocks === message.blocks),
status:
event.delta.length > remaining
? '回答过长,已在本地截断显示'
@@ -1355,9 +1335,6 @@ function reduceRecoveredAgentEvent(
...message,
reasoning: `${reasoning}${delta}`,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(delta && message.blocks && blocks === message.blocks),
status: undefined
}
} else if (event.type === 'status') {
@@ -1405,25 +1382,14 @@ function reduceRecoveredAgentEvent(
)
if (index >= 0) {
tools[index] = tool
} else if (!canRetainRecoveredActivity(message)) {
next = {
...message,
displayCaptureTruncated: true
}
}
if (index < 0 && next === message) {
} else {
tools.push(tool)
}
if (next === message) {
const blocks = upsertRecoveredToolBlock(message.blocks, tool)
next = {
...message,
tools,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
}
const blocks = upsertRecoveredToolBlock(message.blocks, tool)
next = {
...message,
tools,
blocks
}
} else if (event.type === 'subagent') {
const commonSubagent = {
@@ -1448,49 +1414,25 @@ function reduceRecoveredAgentEvent(
const index = subagents.findIndex(
(candidate) => candidate.childTaskId === event.childTaskId
)
let retainSubagent = true
if (index >= 0) {
subagents[index] = subagent
} else {
const replacesTool = Boolean(
event.runtimeCallId &&
message.tools?.some(
(tool) => tool.callId === event.runtimeCallId
)
)
if (
!replacesTool &&
!canRetainRecoveredActivity(message)
) {
retainSubagent = false
} else {
subagents.push(subagent)
}
subagents.push(subagent)
}
if (!retainSubagent) {
next = {
...message,
displayCaptureTruncated: true
}
} else {
const blocks = upsertRecoveredSubagentBlock(
message.blocks,
event.childTaskId,
event.runtimeCallId
)
next = {
...message,
subagents,
tools: event.runtimeCallId
? message.tools?.filter(
(tool) => tool.callId !== event.runtimeCallId
)
: message.tools,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
}
const blocks = upsertRecoveredSubagentBlock(
message.blocks,
event.childTaskId,
event.runtimeCallId
)
next = {
...message,
subagents,
tools: event.runtimeCallId
? message.tools?.filter(
(tool) => tool.callId !== event.runtimeCallId
)
: message.tools,
blocks
}
} else if (event.type === 'artifact') {
next = {
@@ -2087,6 +2029,11 @@ export class AssistantDatabase {
]) {
database.exec(`DELETE FROM ${table}`)
}
database.exec(
`UPDATE activity_history
SET records_json = '[]', legacy_history_may_be_incomplete = 0
WHERE singleton = 1`
)
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
@@ -4283,6 +4230,40 @@ export class AssistantDatabase {
return rows.map(toTask)
}
getActivityHistory(): ActivityHistorySnapshot {
const row = this.requireDatabase()
.prepare(
`SELECT records_json, legacy_history_may_be_incomplete
FROM activity_history
WHERE singleton = 1`
)
.get() as
| {
records_json: string
legacy_history_may_be_incomplete: number
}
| undefined
return activityHistorySnapshotSchema.parse({
records: row ? JSON.parse(row.records_json) : [],
legacyHistoryMayBeIncomplete:
row?.legacy_history_may_be_incomplete === 1
})
}
replaceActivityHistory(input: ActivityHistorySnapshot): void {
const snapshot = activityHistorySnapshotSchema.parse(input)
this.requireDatabase()
.prepare(
`UPDATE activity_history
SET records_json = ?, legacy_history_may_be_incomplete = ?
WHERE singleton = 1`
)
.run(
JSON.stringify(snapshot.records),
Number(snapshot.legacyHistoryMayBeIncomplete)
)
}
createTask(input: {
id: string
projectId?: string
@@ -9648,6 +9629,22 @@ export class AssistantDatabase {
throw error
}
}
if (version.user_version < 33) {
database.exec(`
BEGIN IMMEDIATE;
CREATE TABLE IF NOT EXISTS activity_history (
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
records_json TEXT NOT NULL,
legacy_history_may_be_incomplete INTEGER NOT NULL DEFAULT 0
CHECK(legacy_history_may_be_incomplete IN (0, 1))
);
INSERT OR IGNORE INTO activity_history
(singleton, records_json, legacy_history_may_be_incomplete)
VALUES (1, '[]', 0);
PRAGMA user_version = 33;
COMMIT;
`)
}
}
private requireDatabase(): DatabaseSync {