- 人工智能
- AI 应用
- 桌面应用
- 交互助手
【免费下载链接】ClawX
ClawX is a desktop app that provides a graphical interface for OpenClaw AI agents. It turns CLI-based AI orchestration into a desktop experience without using the terminal. China website is https://clawx.com.cn.
导读
ClawX 将 OpenClaw 的 CLI 型定时任务(Cron)管理封装为桌面图形界面。OpenClaw 在cron.runs中持久化的运行摘要默认受2000 字符上限约束,超长回复会被截断并以省略号(…)结尾,导致用户打开"已完成定时任务会话"时只能看到残缺的回复。本文基于仓库中的任务规格 fix-cron-job-summary-truncation.md 及其关联规则,完整讲解 ClawX 如何在 Electron Main 进程中把有界摘要与其对应的磁盘运行转录(run transcript)拼接,恢复完整的最终助手回复;读完你将掌握截断检测算法、会话键推导规则、前缀匹配约束、降级路径以及对应的单元/E2E 验证方式。
问题背景:OpenClaw 的有界运行摘要
OpenClaw 为每个 Cron 运行生成一条摘要(summary),并持久化在cron.runs中。出于存储与控制考虑,该摘要有明确的长度边界。从源码常量可以确认这条边界的具体数值:
// electron/services/cron-api.ts const OPENCLAW_CRON_SUMMARY_TRUNCATION_MIN_CHARS = 2_000;当最终助手回复超过该边界时,OpenClaw 的摘要会以省略号收尾,形成所谓的bounded summary envelope。任务规格明确指出的用户可见症状是:
- 打开一个已完成定时任务的会话,看到的不是完整的最终助手回复,而是一条 2000 字符、以省略号结尾的 Cron 摘要;
- 用户需要看到完整回复,同时希望运行时长(Duration)和模型(Model)元数据在恢复后的回复末尾依然可见;
- 当运行转录缺失或不可读时,界面必须继续回退到有界摘要,而不是报错或显示空内容。
修复架构:Main 持有 Cron 历史,ACP 回放保持权威
本次修复的定位是runtime-bridge类型的运行时桥接任务,归属gateway-backend-communication场景。其架构约束在任务规格的 acceptance 与规则文档中写得很清楚,可以归纳为三条主线:
- Electron Main 是定时任务历史的唯一持有者。Renderer(前端)不直接读取运行日志,而是通过类型化 Host API(
cron.sessionHistory)向 Main 请求;Main 则继续通过 Gateway WebSocket 查询cron.runsRPC。 - ACP 回放始终是权威。恢复出来的 Cron 历史只在 ACP 回放为空时才被投影(project)到渲染层的时间线上,绝不能替换或与已有的非空 ACP 回放重复。规则原文见 acp-chat-state-and-history.md:"When ACP replay for a cron session is completely empty, scheduled-task prompt and completion summaries may instead come from Main's typed cron-history host API."以及"must never replace or duplicate non-empty ACP replay"。
- 摘要替换必须满足"更长的相同前缀"条件,否则保持摘要原样。
这三条约束共同保证了:修复只作用于"有界摘要 + 可用转录"这一种情况,不会引入第二条历史事实源。
核心算法:截断检测与转录恢复
1. 截断摘要判定
isBoundedCronSummary是恢复流程的入口判定函数(cron-api.ts):
function isBoundedCronSummary(summary: string): boolean { return summary.length >= OPENCLAW_CRON_SUMMARY_TRUNCATION_MIN_CHARS && summary.endsWith('…'); }只有同时满足"长度达到 2000 字符阈值"且"以省略号结尾"两条条件,该摘要才被视为 OpenClaw 有界截断的产物。短摘要不会被触碰——它们要么是完整回复,要么本来就不需要恢复。
2. 定位对应运行转录
每一条cron.runs条目都可能携带sessionId或sessionKey,恢复流程先解析 Cron 会话键,再推导出本次运行专属的转录会话键:
// 解析 agent:<agentId>:cron:<jobId> 与 agent:<agentId>:cron:<jobId>:run:<runSessionId> function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null { if (!sessionKey.startsWith('agent:')) return null; const parts = sessionKey.split(':'); if (parts.length < 4 || parts[2] !== 'cron') return null; const agentId = parts[1] || 'main'; const jobId = parts[3]; if (!jobId) return null; if (parts.length === 4) return { agentId, jobId }; if (parts.length === 6 && parts[4] === 'run' && parts[5]) { return { agentId, jobId, runSessionId: parts[5] }; } return null; }键格式有两种形态:
| 形态 | 示例 | 含义 |
|---|---|---|
| 基础会话键 | agent:main:cron:job-1 | 定时任务的基础会话,对应会话列表中的一行 |
| 运行级会话键 | agent:main:cron:job-1:run:run-session-1 | 某次具体运行的转录会话 |
resolveCronRunSessionKey(cron-api.ts)遵循任务规格的 acceptance 要求——"Run lookup accepts an explicit run-scoped sessionKey and can derive one from agent ID, job ID, and sessionId":
- 优先使用条目自带的、且能解析出
runSessionId的显式sessionKey; - 否则用
sessionId拼出agent:<agentId>:cron:<jobId>:run:<sessionId>。
3. 读取转录并提取最终助手回复
loadFullCronRunReplies对每条有界摘要并行执行恢复(cron-api.ts):
async function loadFullCronRunReplies( parsed: CronSessionKeyParts, runs: CronRunLogEntry[], ): Promise<Map<CronRunLogEntry, string>> { const replies = new Map<CronRunLogEntry, string>(); await Promise.all(runs.map(async (entry) => { const summary = typeof entry.summary === 'string' ? entry.summary.trim() : ''; if (!isBoundedCronSummary(summary)) return; const runSessionKey = resolveCronRunSessionKey(parsed, entry); if (!runSessionKey) return; const transcript = await loadSessionTranscriptByKey(runSessionKey, 1_000); if (!transcript?.length) return; const fullReply = getFinalAssistantReply(transcript); const summaryPrefix = summary.slice(0, -1); if (fullReply.length > summaryPrefix.length && fullReply.startsWith(summaryPrefix)) { replies.set(entry, fullReply); } })); return replies; }其中loadSessionTranscriptByKey来自 sessions-api.ts,它按会话键解析出agents/<agentId>/sessions/下的转录文件(通过sessions.json索引解析路径),并只读取最新的 1000 条消息记录:
export async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Promise<RawMessage[] | null> { const parsed = parseSessionKey(sessionKey); if (!parsed) return null; try { const sessionsDir = join(resolveOpenClawStateDir(), 'agents', parsed.agentId, 'sessions'); const sessionsJson = await readSessionsJson(parsed.agentId); const transcriptPath = resolveSessionTranscriptPathByKey(sessionKey, sessionsDir, sessionsJson); if (!transcriptPath) return null; return readRecentTranscriptMessages(transcriptPath, limit); } catch { return null; } }4. 前缀匹配约束:防止错误拼接
恢复并非无条件进行,必须同时满足两个条件(与任务规格 acceptance 完全一致:"replaced only when the corresponding run transcript contains a longer assistant reply with the same prefix"):
- 更长:
fullReply.length > summaryPrefix.length; - 共享完整前缀:
fullReply.startsWith(summaryPrefix),其中summaryPrefix = summary.slice(0, -1)即摘要去掉末尾省略号后的文本。
getFinalAssistantReply会从转录末尾向前扫描,跳过非 assistant 消息、跳过空文本,取出最后一条非空的 assistant 回复(支持字符串与结构化文本块两种RawMessage.content形态)。前缀匹配的设计意图是:截断是"掐尾不掐头"的,转录中的完整回复必然以摘要内容为前缀,前缀不一致即视为转录与本次运行不对应,宁可保留截断摘要也不冒险拼接错误内容。
数据来源与降级:cron.runs 为主,旧 JSONL 为辅
恢复流程的数据锚点是 Gateway 的cron.runsRPC。readCronRunHistory(cron-api.ts)以 8 秒超时调用:
const result = await gatewayManager.rpc<{ entries?: CronRunLogEntry[] }>('cron.runs', { id: jobId, limit, sortDir: 'asc', }, 8000);sortDir: 'asc'保证运行记录按时间正序返回,与时间线呈现顺序一致。对于尚未升级到 SQLite 型 Cron 历史的旧 OpenClaw 版本,调用会失败并自动降级到 Main 持有的遗留文件回退——readLegacyCronRunLog读取cron/runs/<jobId>.jsonl(cron-api.ts),逐行解析 JSON,只接受jobId匹配且action为finished(或缺失)的条目。这一降级路径正是场景文档 gateway-backend-communication.md 所规定的:"direct run-log file reads are allowed only as a compatibility fallback for older file-backed runtimes"。
在sessionHistory服务中,Main 会并行发起三件事(cron-api.ts):
cron.list(含禁用任务)——用于匹配当前 job 的 name / payload / state;readCronRunHistory——获取运行记录;readSessionStoreEntry——读取agents/<agentId>/sessions/sessions.json中的会话标签与更新时间。
随后调用loadFullCronRunReplies做转录恢复,再由buildCronSessionFallbackMessages组装消息序列。
消息组装与元数据保留
buildCronRunMessage(cron-api.ts)负责把恢复后的完整回复写回消息内容,并保证任务规格要求的元数据仍然可见:
const meta: string[] = []; const duration = formatDuration(entry.durationMs); if (duration) meta.push(`Duration: ${duration}`); if (entry.provider && entry.model) meta.push(`Model: ${entry.provider}/${entry.model}`); else if (entry.model) meta.push(`Model: ${entry.model}`); if (meta.length > 0) content = `${content}\n\n${meta.join(' | ')}`;要点:
- Duration 格式化:小于 1 秒显示毫秒(如
500ms),1~10 秒保留一位小数(如5.0s),更长则取整秒; - 模型元数据:优先输出
provider/model,只有 provider 缺失时才只显示Model: <model>; - 错误标记:
status === 'error'的条目会被加Run failed:前缀并标记isError: true,恢复流程不会触碰这类内容; - 时间戳归一化:
normalizeTimestampMs兼容毫秒/秒两种单位(值小于1e12视为秒自动 ×1000),也兼容 ISO 字符串。
组装出的会话结构为:一条携带任务提示词(prompt)或任务名的user消息 + 逐条assistant运行消息。若匹配到运行记录但任务仍在执行中(无转录可用),会插入一条"任务仍在运行"的占位 assistant 消息。最终按limit(默认 200,最大 200)截取尾部消息。
边界行为与降级清单
综合任务规格的 acceptance、规则文档与单元测试,恢复逻辑在以下情况下一律保持摘要原样:
| 场景 | 行为 | 依据 |
|---|---|---|
| 摘要长度不足 2000 或不以省略号结尾 | 不视为有界摘要,不恢复 | isBoundedCronSummary判定 |
| 转录缺失 / 不可读 / 为空 | 保留有界摘要 | loadSessionTranscriptByKey返回null时跳过 |
| 转录中的最终回复与摘要前缀不一致 | 保留有界摘要 | 前缀匹配失败 |
| 转录回复不更长(等于或短于前缀) | 保留有界摘要 | fullReply.length > summaryPrefix.length不成立 |
| 无法解析出运行级会话键 | 保留有界摘要 | resolveCronRunSessionKey返回null |
这些边界在 cron-schedule.test.ts 中被逐一固化。
测试验证:单元测试与 E2E 双轨
单元测试:前缀匹配的三种情形
test 文件 通过 mockloadSessionTranscriptByKey精确验证恢复路径:
- SQLite 摘要直读:
summary: 'Time to drink water.'(短摘要)不会触发转录读取,loadSessionTranscriptByKey不应被调用; - 有界摘要恢复成功:构造
summaryPrefix = 'A'.repeat(2000)、fullReply = prefix + 'B'.repeat(500)、summary = prefix + '…',断言转录以agent:main:cron:job-1:run:run-session-1与会话键、1000条上限被读取,最终 assistant 消息内容为fullReply + "\n\nDuration: 5.0s | Model: provider-a/model-a"—— 同时验证了完整回复恢复与元数据拼接; - 前缀不匹配保持原样:转录回复以
'X'.repeat(2000)开头,与摘要前缀不符,最终消息内容仍为原始'A'.repeat(2000) + '…'。
第一条测试还断言cron.runs的调用参数为{ id: 'job-1', limit: 200, sortDir: 'asc' }且超时为8000,锁定了 Gateway 查询契约。
E2E 测试:空 ACP 回放时的历史投影
cron-run-live-status.spec.ts 中的shows cron run summaries when ACP replay is empty用例完整模拟了用户路径:
- 侧边栏出现
Cron: 喝水提醒会话; - 打开会话后,Main 被调用
cron.sessionHistory(payload 中sessionKey为 Cron 基础会话键); - 界面上同时渲染出用户提示(
提醒我喝水)、完整回复的开头(该喝水了!💧)与结尾(完整回复结尾)——证明不再是截断摘要; acp-chat-empty-state数量为 0,即空状态被历史内容替代。
同文件的另一个用例验证了 Cron 运行中的实时状态(tool_call / tool_call_update)无需切换会话即可通过 ACP 事件呈现,与本次历史修复形成互补:运行中的任务走 ACP 实时事件,已完成任务走 Main 的 Cron 历史桥接。
权威边界与工程约束总结
最后回到任务规格 fix-cron-job-summary-truncation.md 开篇给出的定位:这是一个Cron history fallback repair——把有界的cron.runs摘要与磁盘上对应的运行转录"对接"起来。整个修复必须遵守以下不可逾越的边界(均有仓库证据):
- ACP 回放优先:Cron 历史只在回放为空时投影,见 acp-chat-state-and-history.md 中的 cron 例外条款;
- Main 独占:Renderer 不直接读日志文件、不建立第二条历史源,所有 Cron 历史都经类型化 Host API 由 Main 提供,见 gateway-backend-communication.md;
- 恢复是有条件的:只有"更长 + 完整前缀共享"才允许替换,任何缺失、不匹配、不可读都保持原样;
- 生成作用域与内存态:投影出的历史是 generation-scoped 的进程内存数据,不持久化,不污染 ACP 语义。
这套设计既解决了"打开已完成定时任务会话只能看到省略号摘要"的用户体验问题,又守住了"ACP 回放是 Chat 语义唯一权威"的架构底线,是典型的"有界补丁式"运行时桥接实现,值得作为参考模式。
- 人工智能
- AI 应用
- 桌面应用
- 交互助手
【免费下载链接】ClawX
ClawX is a desktop app that provides a graphical interface for OpenClaw AI agents. It turns CLI-based AI orchestration into a desktop experience without using the terminal. China website is https://clawx.com.cn.
相关推荐
Quartz.NET任务中断与恢复终极指南:如何优雅处理长时间运行任务
Quartz.NET任务中断与恢复终极指南:如何优雅处理长时间运行任务 在现代企业应用中,长时间运行的任务调度管理是每个开发人员都需要面对的挑战。Quartz.
任务调度后端PrivateBin备份自动化:从cron任务到灾难恢复的完整指南
PrivateBin备份自动化:从cron任务到灾难恢复的完整指南 引言:你还在手动备份PrivateBin数据吗? 作为一款采用AES 256加密的开源pas
后端密码学应用安全WePush定时任务完全指南:从基础配置到高级Cron表达式实战
WePush定时任务完全指南:从基础配置到高级Cron表达式实战 WePush是一款专注于批量推送的小而美工具,支持多种消息类型的定时推送功能。作为一款强大的批
开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考