会话与消息流
Spark Work 会话与消息流完整指南:先讲清「会话 → 轮次 → 事件」三层模型与 turnId 的作用,再展开消息流的真实链路(submit-turn 与 send-turn 的持久性差异、started 的真实语义、排队时消息气泡为何消失)、队列的两层结构与面板操作(含「拖拽排序重启后丢失」这个真实缺口)、出错暂停与两种恢复方式、调度并发(单会话 1 / 全局硬编码 6)。目标与计划部分覆盖 /goal 命令族、验收契约门控的解析规则(success_criteria 缺失即整块作废)、契约卡片、spark-goal-status 块的四种状态映射、五项预算熔断及其「计数依赖进度日志」的真实缺口、计划模式审批与闸门解除。会话级操作部分覆盖消息级操作(悬停栏按钮的真实出现条件、右键菜单、多选、乐观气泡状态机)、编辑最后一轮的先删后发语义、删除的按轮扩大与「浅清理」、会话分叉的轮次级锚点与元数据继承坑、参考会话的 10 条上限、代码还原点的 git 实现,以及历史导入的四个来源路径与四步流程。文末附 20 行排查表。
这一篇讲的是「一轮对话是怎么跑起来的」——你按下发送之后,消息去了哪里、为什么有时候会排队、 队列为什么会停下来、目标模式和计划模式各自卡在什么地方等你确认。 这里覆盖三条主线:消息流(发送 → 排队 → 调度 → 落库)、目标与计划(验收契约门控、计划审批)、 以及会话级操作(分叉、参考会话、还原点、历史导入)。
所有字段名、枚举值、界面文案与默认值都来自应用仓库的真实代码,不写代码里不存在的东西。 凡是代码本身就自相矛盾或者存在明显缺口的地方,本文会直接点出来,并在18. 排查表里给出判断方法。
1. 会话的三层模型
先把名词对齐。Spark 的对话不是「一个会话 = 一串消息」,而是三层:会话 → 轮次 → 事件。很多看起来奇怪的行为,根源都在这层结构上。
| 层 | 承载表 | 说明 |
|---|---|---|
| 会话 session | sessions | 一条对话记录。状态枚举 idle / running / disabled / error(packages/storage/migrations/024_scheduled_tasks.sql)。 运行态与「上次运行结果」都挂在会话上。 |
| 轮次 turn | turn_requests | 一次「你发一条 → Agent 回一条」。有一个 UUID 形式的 turnId, 它是排队、编辑、分叉、删除的共同锚点。 |
| 事件 event | agent_events | 轮次内部的原子记录:user_message、assistant_message、tool_call、tool_result、agent_status、file_change、checkpoint 等。按 seq 单调递增。 |
为什么要注意这层结构:因为几乎所有「消息级」操作实际作用在轮次上, 而不是单条消息上。比如删除一条助手消息,数据库会把同一轮的user_message + assistant_message 一起删掉(见13. 删除消息)。
1.1 turnId 是这一整套功能的通用语言
turnId 同时出现在这些地方,理解了它就理解了这一篇的大半:
- 队列里每一项的标识(
SessionQueuedTurn.turnId); - 分叉的锚点(
anchorTurnId,见 14. 会话分叉); - 编辑最后一轮的入参(
session:rewind-last-turn); - 删除消息时用来反查整个轮次;
- 错误暂停状态里指向「是哪一轮失败」(
failedTurnId)。
2. 发一条消息会发生什么
按下发送后,渲染进程调用的是 session:submit-turn。 主进程把它交给 SessionService.dispatchTurn(),然后分三种走向。
2.1 两个发送通道,持久性不同
| 通道 | 持久化 turn_requests | 谁在用 |
|---|---|---|
session:submit-turn | 是(durable = true),返回 accepted: true | 桌面端聊天输入框、计划批准、工作流启动 |
session:send-turn | 否(durable = false) | IM 远程连接收进来的消息、定时任务执行器 |
这个差异有实际后果。只有走 submit-turn 的轮次会写进turn_requests;应用重启后,恢复逻辑只能找回status = 'accepted' 的行。也就是说:IM 消息或定时任务如果恰好排在队列里, 重启后会被静默丢弃——没有事件、没有报错、界面上也看不到痕迹。
2.2 started 不等于「已经在跑」
SessionSendTurnResponse 的注释写的是「Turn 是否立即开始执行(false 表示排队中)」, 但代码实际语义更松:持久化路径下,会话空闲时 dispatchTurn 会先把轮次入队,用 setTimeout 调度,然后立刻返回started: true。所以 started: true 的真实含义是 「已受理并即将开跑」,不是「执行器已经在运行」。
真正判断「在不在跑」要看 session:get-queue 返回的running,或者会话状态 running。
2.3 排队时,你的消息气泡会消失
这是第一次遇到时最容易困惑的地方:发送时消息会立刻以半透明的「乐观气泡」出现, 但如果轮次判定为排队(started: false),这个气泡会被移除。
commitOptimisticUserMessage(..., started=false)直接把气泡过滤掉;- 队列快照里出现的
turnId也会让乐观气泡被剥掉; - 消息此时只存在于队列面板里,等轮次真正开跑、写入
user_message事件后,才作为正式消息回到对话流。
所以「我发的消息不见了」通常不是丢了,而是去看队列面板(见下一节)。
3. 排队:两层结构
队列不是一个对象,而是两层——一层在内存里,一层在数据库里。两者覆盖范围不同, 这是理解后面所有队列行为的关键。
| 层 | 位置 | 持久 |
|---|---|---|
| 内存 FIFO | SessionService.pendingTurns(Map<sessionId, PendingTurn[]>) | 否。进程结束即消失 |
| 持久受理行 | turn_requests 表 | 是,但只对 session:submit-turn 生效 |
3.1 持久行的完整字段
turn_requests (
id TEXT PRIMARY KEY, -- 就是 turnId
session_id TEXT NOT NULL, -- FK -> sessions(id) ON DELETE CASCADE
payload_json TEXT NOT NULL, -- JSON.stringify(PendingTurn)
status TEXT NOT NULL DEFAULT 'accepted',
error_message TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)status 枚举:accepted / running / completed / failed / cancelled。 轮次进入终态时 payload_json 会被清空成 '', 所以事后无法从这张表回读出当时的消息原文。
注意:status 只在轮次排队到开跑这段时间代表「队列」。 一旦开跑就变成 running,终态后只剩一条历史记录。 它不是一张「消息表」。
4. 队列面板能做什么
队列面板出现在输入框上方。默认可折叠,折叠按钮文案是显示队列 / 隐藏队列,带当前条数。 并且只在瞬间上有两条以上排队项时才显示头部的拖拽提示与「清空队列」按钮。
4.1 单项操作
| 操作 | 提示文案 | 真实行为 |
|---|---|---|
| 拖拽手柄 | 拖动调整任务顺序 | 基于 dnd-kit 的纵向排序,拖动阈值 6px |
| 编辑 | 编辑 | 把内容/附件/参考会话回填到输入框,然后取消该排队项。 需要你重新按发送——会生成一个新的 turnId |
| 立即执行 | 立即执行 | 中断当前正在跑的轮次,把这一项提到最前并立即起跑 |
| 移除 | 移除 | 取消该排队项 |
每一项右侧还会显示该排队轮次起跑时将使用的模型(模型:X 或Provider:X · 模型:Y)。这个快照是在入队时冻结的。
4.2 批量操作
- 头部提示
拖动调整执行顺序; 清空队列(执行中显示清空中…), 提示为取消全部排队消息,不影响当前正在执行的任务—— 这句话是准确的:清空只动队列数组,不碰正在运行的执行器。
4.3 拖拽排序在重启后会丢失
这是本篇最需要记住的队列行为之一。reorderQueuedTurns 只重排内存里的 pendingTurns 数组并广播,不写数据库,也不更新 updated_at。 而重启恢复是按 created_at ASC, id ASC 重新入队的。
结论:你把第 3 项拖到第 1 项,在应用本次运行期间有效; 重启应用后顺序会回到按入队时间排列。
4.4 「编辑」排队项后模型可能变
排队项在入队时冻结了一份运行配置快照。但「编辑」是把内容取回输入框、 取消原项、由你重新发送——新轮次用的是输入框当前的 Provider / 模型, 不是原来那一项的快照。如果中间换过模型,重新发送时会用新模型。
5. 队列出错暂停与恢复
5.1 什么会暂停队列
只有一个触发条件:出现 status: 'error' 的 agent_status 事件,且此时内存队列非空。
暂停状态的结构是:
SessionQueuePauseState {
reason: 'turn_error' // 目前是单值字面量
failedTurnId?: string
errorMessage?: string
pausedAt: string
}容易误判的一点:下面这些情况会让队列卡住不动, 但不会产生暂停状态,所以不会显示任何横幅:
- 有等待用户审批的计划(计划模式);
- 有等待回答的提问(结构化问答闸门);
- 执行器忙、或全局并发已达上限。
也就是说「队列没动静但也没有错误提示」是预期行为, 需要你去处理那个前置条件,而不是等它自己恢复。
5.2 暂停时你会看到什么
队列面板顶部出现一条横幅:当前回复出错,队列已暂停(内容未丢失)。后面有两个按钮:
| 按钮 | 悬停提示 | 真实行为 |
|---|---|---|
重试 / 重试中… | 使用当前模型重试失败消息 | 从本地对话流重建那一轮的用户输入,作为新轮次重新发送, 并带 resumePausedQueue: true 让它排到队首 |
跳过继续 / 继续中… | 跳过失败消息,并使用当前模型继续剩余队列 | 解除暂停,用当前模型路由重快照剩余队列后继续 |
如果失败的那条消息无法安全还原(内部隐藏轮次、远程消息、或原文已取不到),重试 会被禁用,提示变成 失败消息无法安全还原, 此时只能选「跳过继续」或手动重发。
5.3 发送一条新消息也会解除暂停
协议注释里写的是「普通发送路径不受影响」,但代码实际上是这样的: 只要发出的是可见的用户轮次,就判定为「队列已恢复」, 然后解除暂停、把会话状态改回 idle。resumePausedQueue 这个标志控制的是排在队首还是队尾, 不是「是否解除暂停」。
所以横幅挂着的时候直接输入新消息并发送,也会让队列继续跑——新消息排在队尾。 顺序是安全的:重快照和持久化写入都发生在解除暂停之前。
5.4 重启后的恢复
应用启动时会跑一遍恢复,顺序是:
status = 'running'的行统一标记为失败,错误信息Turn interrupted by application restart,不重新排队;status = 'accepted'的行解析回PendingTurn重新入队,enqueuedAt取created_at;- 对会话状态为
error的会话,读最近 32 条agent_status事件, 回溯出暂停原因并重建暂停态; - 然后调度下一轮。
暂停在重启后的权威来源是会话状态(sessions.status = 'error'), 而不只是内存里的闸门对象。
6. 调度与并发
| 维度 | 规则 |
|---|---|
| 单会话并发 | 严格 1。有活跃执行器或处于 starting 中间态时, 新轮次一律入队 |
| 全局并发 | 硬编码 6(DEFAULT_MAX_CONCURRENT_SESSIONS)。 每个执行器约等于一个 SDK 查询或 CLI 子进程 |
| 跨会话公平性 | 全局调度按各会话队首的 enqueuedAt 升序,即跨会话 FIFO |
| 会话内顺序 | 纯数组顺序(FIFO),例外:出错重试排到队首 |
全局并发 6 没有任何设置项或 IPC 可以调——它是个常量。 如果第六个会话在跑、第七个会话的消息进来了,它就会排队等待。
另外,队列没有深度上限:pendingTurns 和turn_requests 都不限条数。
顺带区分一个容易混淆的名字:skipIfSessionRunning 是定时任务的开关,不是队列开关。它默认开启,判断「忙不忙」时会把 排队中的轮次也算作忙。
7. 目标模式(Goal)与验收契约
目标模式让会话持续围绕一个目标自我迭代,而不是一轮一问一答。 它的核心设计是先定验收标准再开工——没有明确验收标准就不允许开始执行。
7.1 /goal 命令族
| 命令 | 作用 |
|---|---|
/goal <objective> | 创建目标 |
/goal pause / resume | 暂停 / 恢复迭代循环 |
/goal clear | 清除当前目标 |
/goal complete | 手动标记完成 |
/goal confirm | 确认验收契约,开始执行 |
/goal reject | 拒绝验收契约,目标被清除 |
/goal status 或裸 /goal | 查看当前目标状态 |
目标存在 session_goals 表里,状态枚举共 7 个:active / paused / completed / failed / cleared / stopped_by_budget / pending_contract。其中前四个是「当前目标」的判定集合。
命令解析的坑(务必知道):/goal 的处理器用action = cmd.subcommand ?? cmd.args[0] 来取动作, 而命令解析器永远不会产生 subcommand——parseCommand 里那行是 args.length > 0 ? undefined : undefined, 恒为 undefined。
后果:目标描述的第一个词如果恰好是控制词,就会被当成子命令执行。例如 /goal clear all TODOs 会直接清除当前目标,/goal complete the docs 会直接标记完成。
规避:目标描述避免以 pause / resume / clear / complete / confirm / reject / status 这几个词开头,或者换个说法(如 /goal 清理所有 TODO)。
7.2 验收契约门控
触发条件是唯一的:运行模式为 spark-loop(默认)且没有显式提供 successCriteria。
因为 /goal 命令只能传「目标描述 + 附件」,传不了验收标准, 所以从界面创建的目标都会走契约门控。流程是:
- 目标状态置为
pending_contract; - 发出一条
goal_contract_drafting事件; - 跑一次隐藏轮次起草契约(该轮次在对话流里显示为
目标模式:生成验收标准); - 起草提示词明确要求「先不要动手实现」,并必须以一个机器可读代码块结尾。
契约块的格式是固定的三行:
```spark-goal-contract
success_criteria: <逗号分隔的可验收条件>
constraints: <逗号分隔的约束,可为空>
validation: <逗号分隔的验证命令,可为空>
```解析规则(这些细节决定了契约能不能被识别):
- 正则不区分大小写、非贪婪——第一个结束围栏就收尾;
- 键名大小写不敏感并去空格;
success_criteria也接受successcriteria这种连写; - 值是逗号切分、去空格、去空项;
- 每行以第一个冒号切分键值,所以行首的
-项目符号会 让这一行被静默忽略; - 没有
success_criteria或它为空 → 整个契约解析失败。 失败后目标停留在pending_contract,goal_contract_proposed事件也不会发出。
另外,validation.checklist 这个字段虽然类型和卡片里都有, 但没有任何代码会写入它——契约解析器只产出 commands。 所以契约卡片上的「检查清单」区块实际上不会出现。
7.3 契约卡片
契约起草完成后,对话流里出现一张内联卡片,标题是目标验收契约,右上角徽标三态:待确认 / 已确认 / 已拒绝。
卡片按字段展示:目标、验收标准、约束、验证命令、检查清单(后者不会出现,见上)。
| 按钮 | 行为 |
|---|---|
拒绝(执行中显示 清除中…) | 取消当前运行中的执行器,目标置为 cleared,不启动循环 |
确认并开始执行(执行中显示 启动中…) | 校验验收标准非空 → 状态置 active → 发 goal_started → 启动迭代循环 |
如果点确认时契约缺验收标准,会提示契约缺少验收标准或已失效,未能启动目标。,目标继续停在 pending_contract。
会话的 Git 面板里也有一份目标区块,按钮文案不同: 待确认时是 确认契约 / 拒绝, 之后是 暂停、恢复(或预算停止后显示 继续)、完成、清除。
7.4 spark-goal-status 块
目标进入执行循环后,每一轮迭代的提示词都要求模型以这样一个块结尾:
```spark-goal-status
status: continue|completed|blocked|failed
phase: review|act|validate
summary: <一句话>
evidence: <逗号分隔的证据>
next_step: <下一步或留空>
```status 只接受这四个值——写成别的(或漏写)会导致整块被丢弃, 本轮什么记录都不会产生。
| 解析到的 status | 目标状态变化 | 发出的事件 |
|---|---|---|
continue | 不变(仍 active) | goal_progress |
completed | completed | goal_completed |
failed | failed,并写入 last_error | goal_failed |
blocked | paused(不是单独的状态) | goal_paused |
| 缺失或非法 | 不变 | 无 |
phase 写错不会导致整块失败——会被静默强制成 validate。界面上的阶段文案是复盘 / 执行 / 验证。
这个块只在轮次正常完成(completed)时才会被解析; 轮次以 error 或 cancelled 结束时不会走这段后处理。
7.5 目标与队列的关系
目标激活(active 且 spark-loop)时,用户消息会排队, 不会插进迭代中间。下一个迭代开始时,队列里的消息会被「排空」并注入到迭代提示词里, 标注为「上一轮迭代以来收到的补充指令(当作目标更新,不是重置)」。
能被注入的只有纯文本用户轮次,以下情况会留在队列里等正常执行:
- 命令轮次(
/开头的命令); - 隐藏轮次(内部自动执行的);
- 带附件的、带 Skill 的、带参考会话的、带
@指定 Agent 的; - 运行时配置(Agent / 适配器 / 权限模式 / 推理强度等)与基线不一致的。
每次迭代最多注入 8 条消息,每条截断到 2000 字符。
另外,只要存在「当前目标」,就不能修改会话的工作流绑定, 提示是当前目标仍在进行中,暂时不能修改工作流。注意 pending_contract 也算「当前目标」——契约没确认前同样改不了工作流。
8. 目标的预算与熔断
默认预算是 { maxConsecutiveFailures: 3, noProgressLimit: 3 },不设轮次上限、不设运行时长上限、不设费用上限—— 设计取向是让模型自驱跑到完成,只保留两个「卡死」熔断。
| 预算字段 | 触发条件 | 允许范围 |
|---|---|---|
maxIterations | 本轮次迭代数 ≥ 该值 | 1–500 |
maxRuntimeMinutes | 扣除暂停时长后的运行分钟数 ≥ 该值 | 1–10080 |
maxBudgetUsd | 会话用量账本累计费用 ≥ 该值 | 0–10000 |
maxConsecutiveFailures | 末尾连续 failed / blocked / paused 条目 ≥ 该值 | 1–50 |
noProgressLimit | 末尾连续无证据且 next_step 未变的 continue 条目 ≥ 该值 | 1–50 |
命中任何一个后,目标置为 stopped_by_budget 并发goal_budget_stopped 事件,之后不会自动继续—— 需要你手动恢复,恢复后开始一个新的预算周期。
一个真实的缺口:两个默认熔断(连续失败、无进展)的计数完全来自进度日志,而进度日志只在「轮次正常完成 + 解析到合法spark-goal-status 块」时才会追加。
因此如果模型始终不产出合法状态块,或者每一轮都以 error / cancelled 结束,进度日志就一直不变,五个判断全部为假, 循环会一轮接一轮地跑下去。
队列的错误暂停闸门不覆盖这种情况——它只在会话队列非空时才生效。 遇到疑似空转,请手动 /goal pause 或 /goal clear。
另外提醒:codex-native 模式与自定义预算从界面走不到。 只有 session:set-goal 这个 IPC 能传 mode / budget / constraints,而渲染进程没有任何地方调用它。 实际用起来,所有目标都是 spark-loop + 上面的默认预算。
9. 计划模式审批
在 claude-plan 模式下,Agent 不会直接动手:它先调研、产出一份实施计划、 提交,然后立即停止等你审批。
9.1 事件
plan_proposed:携带 Markdown 格式的计划文本。当前轮次就此结束(状态completed),不会再调用工具。plan_rejected:拒绝是一个「已决议」标记,会写入事件流。 历史回放(切换会话、重开会话)时据此清掉待审批状态,避免已拒绝的计划重新弹出审批面板。
计划文本的来源有两处:优先取工具入参里的 plan 字段; 新版 CLI 是把计划写到 .claude/plans/*.md 文件,这时会回退到 本轮的计划文件内容。完全相同的重复提交会被折叠。
9.2 审批面板
| 按钮 | 行为 |
|---|---|
批准执行 | 以 批准上述计划。请按如下计划继续执行: + 计划正文发出一条新轮次, 带 permissionMode: claude-auto-edits 与 interruptActive: true(中断当前 loop 立即起跑), 同时把本地默认权限模式切到自动执行 |
拒绝 | 调用 session:reject-plan,解除审批闸门并写入 plan_rejected |
编辑 → 保存编辑 | 就地编辑计划文本后再批准(即「编辑后批准」) |
恢复原计划 | 编辑过之后才出现,把文本还原成 Agent 原稿 |
放弃修改 | 退出编辑态 |
批准成功的提示是计划已批准,已切换为自动执行模式; 拒绝的提示是已拒绝计划,未执行。
9.3 审批闸门会阻塞队列
存在待审批计划时,会话被加入一个「等待计划审批」集合,队列不会自动推进。这个闸门在三个地方被检查: 队列调度、全局调度、以及轮次结束后的续跑判断。
闸门的解除方式有两条,任选其一即可:
- 调用
session:reject-plan拒绝计划; - 直接切换权限模式——代码把「切换 permissionMode」视为用户已做出选择, 会顺带解除闸门并推进队列。
这是个很实用的逃生口:如果计划面板因为某种原因点不动了, 在输入框切一下权限模式,被卡住的队列就会继续跑。
注意拒绝后不需要你补发一条消息——闸门解除时就会去调度队列。
10. 消息级操作
把鼠标悬停到任意一条消息上,会出现一条操作栏。操作栏在鼠标移入前opacity: 0 且 pointer-events: none, 用户消息靠右、助手消息靠左。进入多选模式时整条栏会被隐藏。
10.1 悬停操作栏
按钮的渲染顺序是固定的,但是否出现取决于消息角色:
| 按钮 | 提示文案 | 出现条件 | 行为 |
|---|---|---|---|
| 重发 | 重发 | 仅用户消息 | 把文本 + 附件 + 参考会话回填到输入框,不会自动发送 |
| 复制 | 复制 | 有文本内容时 | 写剪贴板,1.5 秒内图标变成对勾 |
| 编辑消息 | 编辑消息 | 仅最后一轮的用户消息 | 就地进入行内编辑器 |
| 从此处分支 | 从此处分支 | 仅助手消息,且是轮次末尾且非流式中 | 打开分叉对话框 |
| 删除 | 删除 | 用户消息或非流式助手消息 | 硬删除数据库事件 |
助手消息没有「编辑」和「重发」,这是设计如此—— 你只能编辑自己发出去的内容。
值得强调:「重发」和「重试」都不会自动重跑。 悬停栏的重发、发送失败气泡上的重试、错误卡片上的重新尝试, 走的都是同一条路径:把内容塞回输入框并聚焦,等你按发送。 失败的那一轮仍然留在历史里。
另外,没有「重新生成」这个功能——应用里不存在这个动作。
10.2 右键菜单
右键消息弹出的菜单结构是固定的,用户消息和助手消息使用同一套构建逻辑:
| 顺序 | 菜单项 | 出现条件 |
|---|---|---|
| 1 | 复制图片 | 仅当指针正好在图片上 |
| 2 | 复制内容 / 复制选中 | 总是 |
| 3 | 回复 / 引用选中 | 总是 |
| 4 | 多选 | 总是 |
| 5 | 删除(危险样式) | 总是 |
菜单项点击后立即执行并关闭菜单,没有二级确认。 另外在消息气泡之外的普通文本选区上右键,会多出一个引用对话,用于把选中的文字带进下一轮。
10.3 多选
从右键菜单进入多选后,出现一条工具栏:
- 左侧计数:
已选 N 条; 全选—— 注意它故意排除仍在流式输出的消息;全不选;复制(成功后变已复制)——批量复制格式是发言对象:正文,条目之间空行分隔;删除(危险样式);取消。
10.4 乐观气泡与发送失败
发送时先插入一个乐观气泡再发请求,这样界面不会卡顿。 它的状态机是:
| 状态 | 发生时机 | 气泡表现 |
|---|---|---|
submitting | 刚发出请求 | 正常显示 |
accepted | 轮次已受理并开跑 | 与落库消息合并,不重复显示 |
| — | 轮次被判定为排队(started: false) | 气泡被移除,只存在于队列面板 |
failed | 请求抛错 | 气泡保留,显示发送失败 · <错误>与一个重试按钮 |
cancelled | 你手动取消 | 按时间戳锚定位置,不会被挤到列表底部 |
发送失败时输入框里的草稿会恢复,所以内容不会丢。
11. 内部轮次的可见性
定时任务、目标迭代、命令续跑这些系统自动发起的轮次也会进对话流, 但它们的输入提示词通常不应该给你看。处理规则是「投影式脱敏」: 只在渲染时替换,不改动原始数据。
| 轮次来源 | 对话流里显示的标签 |
|---|---|
scheduled_task | 定时任务自动执行 |
goal_contract_draft | 目标模式:生成验收标准 |
goal_iteration | 目标模式自动执行 |
command_follow_up | 命令自动执行 |
| 其他 | 内部任务自动执行 |
具体投影规则:
- 如果轮次提供了安全的展示文本(
userMessageDisplayContent), 原始内容块会被替换成这段文本; - 如果标记为隐藏且没有安全展示文本,这条用户消息从对话流里整条消失(助手回复仍然保留);
- 提示词检查器里,取不到安全文本时统一显示
内部提示已隐藏; - 历史上 Telegram 远程连接的固定提示词前缀会被专门脱敏。
12. 编辑最后一轮消息
只能编辑最后一轮。这不是限制没做完,而是刻意的设计: 编辑采用的是「前缀回退并删除被替换轮」的做法,如果允许编辑历史中间某一轮, 就必须处理「后面那些轮怎么办」——当前实现选择直接拒绝。
行内编辑器的键盘约定:Enter 提交、Shift+Enter 换行、Esc 取消;按钮是 取消 / 发送(提交中显示 发送中…)。
12.1 点发送之后到底发生了什么
调用 session:rewind-last-turn,它会先删后发:
- 删除该轮全部
agent_events; - 删除该轮的
turn_requests记录; - 删除该轮的
turn_perf_metrics; - 轮转 Codex 原生线程代号、清空 Spark 账本绑定;
- 删除该会话的全部摘要;
- 会话状态置回
idle并清掉「上次运行结果」; - 最后才用新文本发出新轮次。
渲染进程会收到一条 transcript_retraction 事件,据此把这一轮从本地列表里摘掉。
没有二次确认。整条链路上不存在任何确认弹窗—— 按下发送(或 Enter)就直接执行删除。这是一个不可撤销的操作。
工作区文件不会被回滚。代码里明确注释了「这里故意不修改工作区文件」, 所以编辑消息不会撤销 Agent 已经改过的文件,只是把对话历史退掉。
12.2 会被拒绝的情况
| 拒绝原因 | 提示文案 |
|---|---|
| 会话正在运行或有活跃执行器 | Agent 正在执行,请结束本轮后再编辑消息 |
队列里还有 accepted / running 的轮次 | 会话仍有待处理消息,请先处理或清空队列 |
| 不是最后一轮用户消息 | 只能编辑当前会话最后一轮用户消息 |
| 轮次是内部隐藏轮次 | 内部续轮消息不能编辑 |
一个体验上的坑:界面判断「忙不忙」时不看队列, 所以队列里有活时编辑按钮照样显示。 你要等到敲完新文本按发送,才会收到会话仍有待处理消息,请先处理或清空队列 的报错(草稿会保留)。
13. 删除消息
删除是硬删除——直接 DELETE FROM agent_events, 没有软删标记、没有回收站、没有撤销。
13.1 删一条消息会连带整轮
如果你删的是 user_message 或 assistant_message, 系统会把同一轮的所有消息事件一起删掉,避免留下半截轮次。 纯工具事件(tool_call / tool_result / file_change) 可以单独删,因为它们不影响轮次边界。
已知的界面/数据不一致:删一条助手消息时,数据库会连带删掉同一轮的 用户消息,但界面只移除你点的那一条。 这个差异要等会话重新加载才会显现——那时你会发现用户消息也不见了。
13.2 删除是「浅」的,编辑是「深」的
两者对连续性数据的处理完全不同:
| 动作 | 删事件 | 清摘要 | 轮转原生线程 | 清账本绑定 |
|---|---|---|---|---|
| 删除消息 | 是 | 否 | 否 | 否 |
| 编辑最后一轮 | 是 | 是 | 是 | 是 |
编辑路径的代码注释里写得很清楚:只删事件是不够的,否则下一轮会继续 resume 到 包含旧轮次的上游上下文。这个论断同样适用于删除路径——但删除路径没有做这些清理。
实践建议:如果你希望「这条消息及其上下文影响一起消失」, 用编辑而不是删除。删除更适合清掉一段不该留在记录里的内容。
14. 会话分叉与血缘
分叉会创建一个独立的会话副本,把源会话到某个锚点为止的已完成历史 物化复制过去。两个会话之后各自独立演进。
14.1 两个入口
| 入口 | 位置 | 锚点 |
|---|---|---|
复制会话 | 侧栏会话右键菜单 | 最后一个已完成轮次,无对话框,标题固定加后缀 |
从此处分支 | 助手消息悬停栏 | 该消息所在轮次,弹出对话框可改标题 |
对话框标题是从此处分支,正文说明 「创建一个独立会话副本 …… 将复制《某会话》从开始到第 N 轮的已完成历史」, 底部提示「新会话会继承当前会话的工作区和运行配置,但不会继续源会话正在执行的任务」, 确认按钮是复制会话。
新会话标题默认是 源标题 · 分支,最多 200 字符。
14.2 锚点是「轮次」,不是「消息」
虽然按钮叫「从此处分支」并挂在某条消息上,实际传的是该消息所属的 turnId。这带来两条重要规则:
- 不指定锚点时,取最后一个已完成轮次;
- 指定的轮次如果没有「完成」(仍在运行,或根本不存在),会直接报错
只能从已完成的会话轮次处分支;当前轮次仍在运行或不存在。
什么叫「已完成轮次」有严格定义:必须有终态 agent_status(idle / completed / cancelled / error)、 必须有用户消息、且该用户消息不是隐藏轮次。 所以目标迭代、定时任务这类内部轮次永远不会成为分叉锚点。
一个反直觉的行为:源会话正在运行时也能分叉,但此时副本是空的——因为没有任何已完成的轮次,fork_cutoff_seq 为 0。 界面会提示源会话仍在运行;副本只复制到最近一个已完成轮次。
14.3 复制了什么、没复制什么
复制的:
- 会话行上的:
kind、project_id、工作区、 规则包、权限配置,以及整套运行配置(Provider、模型、Agent 适配器、Agent、权限模式、聊天模式、推理强度); - 事件白名单:用户/助手/团队成员/子代理消息、工具调用与结果、终态状态事件、 文件变更、终端输出、产物文件、计划提案与拒绝、错误、运行时信号、重试轨迹;
- 附件——附件是内嵌在用户消息事件里的,所以跟着一起过去,且指向同一批磁盘文件;
- 工作流绑定(会生成新的绑定实例标识,但不复制历史运行记录)。
不复制的:
| 内容 | 原因 |
|---|---|
| 排队中的轮次 | 分叉事务完全不碰队列表 |
| 目标状态 | 目标按会话存储,不参与复制 |
| 还原点(事件与 git ref) | 事件类型在复制时被显式排除,ref 按会话隔离 |
| 会话摘要 | 不复制 |
| 团队运行态、任务图、交接记录 | 不复制 |
| 工作流历史运行 | 只复制绑定,不复制 runs |
事件复制时会重新编号:新事件 ID、seq 从 0 重排、sdkSessionId 被删除;但源会话的 run_id 与 turn_id 会保留,便于追溯。
记忆不需要复制——记忆是按 user/project/agent 作用域存的, 不是按会话,两个会话看到的是同一份。
14.4 元数据会被整体继承(一个真实的坑)
分叉时 metadata_json 是原样复制的。这会带来几个副作用:
- 源会话如果打过彩色标签,副本会继承标签,因而出现在置顶区—— 尽管
pinned_at已经被重置为null, 但侧栏的置顶判定是「有置顶时间或有标签」; - 副本会继承源会话的「上次运行结果」,侧栏按结果筛选时新副本可能被错误归类;
- 副本会继承 worktree 信息,于是标题栏会显示「运行在隔离 worktree」, 并且和源会话共用同一个物理工作目录;
- 副本会继承「已开启还原点」标志,但还原点事件并没有复制——见下一节。
14.5 血缘关系
session_lineage 表的字段:
session_lineage (
child_session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
parent_session_id TEXT NOT NULL, -- 刻意不加外键
fork_anchor_turn_id TEXT,
fork_cutoff_seq INTEGER NOT NULL CHECK (fork_cutoff_seq >= 0),
source_title_snapshot TEXT NOT NULL,
created_at TEXT NOT NULL
)父会话刻意不加外键:删掉源会话,副本的血缘记录仍在, 界面显示「来源不可用」;血缘是链式的——从副本再分叉, 指向的是副本而不是最初的根。
分叉会同时写入 stream:session:created 事件并把新会话返回给调用方, 两边都会做去重,不会出现重复条目。
没有的功能:会话不支持导出、分享、另存为模板; 也没有任何斜杠命令或工具能分叉会话——只有上面那两个界面入口。
15. 参考会话
参考会话让另一个会话的内容作为只读资料被当前会话引用, 解决「我不想把两边合并成一条对话流,但希望 Agent 能读到那边的结论」。
入口:侧栏会话右键菜单里的添加到对话。
15.1 结构与约束
每条参考记录钉在源会话的某个已完成轮次边界(snapshot_seq), 状态枚举 active / revoked / unavailable。
上限是每个目标会话 10 条 active 记录,超出会报错。 这个额度是累计的,而界面里的选择器只检查「本次选了几条」, 并且界面没有任何撤销或刷新参考的入口。 因此理论上存在死路:分散在多轮里累计挂满 10 条之后,你既加不了新的、也删不掉旧的。
参考内容不会自动注入提示词。Agent 只有通过三个只读工具才能读到:referenced_sessions_list、referenced_session_read、referenced_session_search。
另有一张 session_reference_audit 审计表记录attach / update_snapshot / revoke / read 动作与操作者,但它只有写入方、没有读取方—— 目前没有任何界面或接口会展示这份审计。
16. 代码还原点
还原点不是会话快照,而是工作区文件系统的 git 快照, 用来撤销 Agent 对代码的改动。两者很容易被混淆。
16.1 怎么工作
- 实现方式:临时索引 +
git add -A+write-tree+commit-tree,然后写一个refs/spark/checkpoints/<sessionId>/<checkpointId>引用。不会碰你真实的索引和 HEAD,也天然遵守.gitignore。 - 每个会话最多保留 20 个还原点,超出会被裁掉。
- 捕获时机是每个轮次执行器开跑之前,并且要同时满足三个条件: 会话打开了还原点开关(默认关闭)、工作区是 git 仓库、 工作树相对上一个还原点有变化。
- 捕获失败不会影响轮次执行,只记录日志。
- 标签用的是你当轮的消息文本(截断到 80 字符)。
16.2 还原
还原前会自动先做一次备份,标签是还原前自动备份; 然后用 git restore --source=<ref> --worktree -- ., 这是非破坏性的。
会被阻止的情况:工作区不再是 git 仓库、ref 已不存在、 或者另一个会话正用同一工作区且有活跃轮次。
操作入口:代码还原点时间线面板、对话流里的还原点卡片, 以及 /checkpoint 命令族(别名 cp):on / off / status / list / restore。
没有 session:restore-checkpoint 这样的 IPC 通道—— 还原是走斜杠命令路径执行的。
与分叉的交互坑:分叉会复制「还原点已开启」这个标志, 但不会复制还原点事件。所以副本会显示「已开启」而时间线是空的, 并且副本的第一个轮次会对它继承来的工作区做一次快照。
17. 历史导入
「导入对话历史」把你在其他 CLI / 桌面里跑过的会话搬进 Spark, 之后可以继续对话。支持三个来源。
17.1 三个入口
- 侧栏项目工具栏的上传图标,提示「从 Claude、Codex、ZCode 导入继续会话」;
- 命令面板里的
导入对话历史; - 设置 → 导入与恢复 →
导入。
17.2 扫描哪些路径
| 来源 | 路径 | 筛选规则 |
|---|---|---|
| Claude Code | ~/.claude/projects | 下一级项目目录里的顶层 *.jsonl;排除 subagents 子目录 |
| Codex | ~/.codex/sessions(递归)+ ~/.codex/session_index.jsonl | 只认 rollout-*.jsonl |
| ZCode(桌面) | ~/.zcode/v2/sessions/<工作区哈希>/<任务ID>.json | 下一级哈希目录、只认 *.json |
| ZCode(CLI) | ~/.zcode/cli/db/db.sqlite | 以只读方式打开 SQLite,从 session / message / part 表枚举 |
大文件做了保护:超过 8 MiB 的文件只读头部 512 KiB + 尾部 128 KiB 来取元信息; ZCode 桌面版的 JSON 因为不能截断,超过 64 MiB 会被静默跳过(只写日志,界面不会提示,来源仍然显示「可用」)。
17.3 四步流程
- 扫描:打开即自动并行扫三个来源,画面显示
正在检索本机会话,三张来源卡片分别显示扫描会话索引/扫描完成/来源不可用。 扫描有无缓存决定是否直接跳到选择页。 - 选择:来源页签带数量、搜索框(匹配标题、项目、 源会话 ID、工作目录)、项目筛选、时间筛选 (
全部时间/最近 7 天/最近 30 天/最近 90 天)、显示已导入复选框、重新检索本机会话。右侧预览面板可以仅看用户消息、专注预览,并提供加载完整会话。 - 导入:显示
正在导入会话与进度条,逐条上报<当前> / <总数> · <标题>。 - 完成:显示
成功导入/跳过(已导入)/失败三行统计,以及继续导入/完成。
17.4 导入后的会话长什么样
- 事件会重新生成 ID、seq 从 0 重排、按用户轮次生成新的 turnId;
- 有活动但缺终态的轮次会补一条
agent_status: completed, 摘要文本是Imported history turn completed; - 会话的创建/更新时间会被改写成原始对话的首末时间戳,这样侧栏能按原始时间排序;
- 来源信息写进元数据:
importedFrom加importHistory(含来源、源会话 ID、源文件、导入时间); - 工作目录按原对话的
cwd匹配,不存在就自动创建, 标为projectKind: 'imported';取不到cwd时落到一个叫导入历史的兜底工作区; - Provider 按来源挑:Claude 优先本地 Claude CLI,Codex 优先本地 Codex CLI, ZCode 按里面的提示字段分流。一个都找不到时直接报错
没有可用的 Provider,请先在「Providers」中添加。
17.5 几个需要注意的点
- 导入后的会话在界面上没有任何标记。
importedFrom数据是写了的,也通过会话摘要暴露出来了, 但渲染进程里零处引用——你分不清哪条是导入的,除非点进去看内容。 - 去重不区分来源。已导入集合是一个扁平的「源会话 ID」集合, 不按来源分组。极小概率下不同来源的 ID 撞车,会被误判为「已导入」。
- 扫描结果有进程级缓存,不会自动失效。重新打开对话框会直接复用上次结果,需要手动点
重新检索本机会话。 - 自己 Spark 产生的会话会出现在 Codex 页签里, 并标记为已导入(无法再次导入)。看着奇怪,但这是刻意处理—— 避免把自己刚跑过的会话又导一遍。
- 导入的轮次是可以编辑的。导入器不写
turnSource/userMessageVisibility, 而这两个字段正是「不可编辑」的判据。所以对导入会话的最后一轮做编辑, 会把那一轮从库里真删掉再重发。
18. 排查表
| 现象 | 原因 | 怎么办 |
|---|---|---|
| 发出去的消息不见了 | 轮次判定为排队,乐观气泡被移除 | 看输入框上方的队列面板,展开即可看到 |
| 队列不动,但也没有报错横幅 | 不是错误暂停——可能有待审批计划、待回答问题,或并发满了 | 检查计划面板 / 待回答问题;若都没有,切一下权限模式可解除计划闸门 |
| 拖拽排序重启后乱了 | 排序只改内存,不落库 | 重启后重新拖一次;需要严格顺序就不要重启 |
| 队列卡在暂停横幅不动 | 失败轮次需要你决定重试还是跳过 | 点重试或跳过继续;直接发新消息也会解除暂停 |
重试按钮是灰的 | 失败消息无法安全还原(内部轮次 / 远程消息 / 原文不可取) | 用跳过继续,或手动重发 |
| 重启后排队消息没了 | 该轮次不是走 submit-turn 进来的(IM / 定时任务),没有持久行 | 行为符合当前实现;重要消息建议在桌面端发送 |
/goal clear xxx 把目标清了 | 目标描述首词被当成子命令 | 换个不以控制词开头的描述,例如/goal 清理 xxx |
| 契约卡片不出现 | 起草轮次没产出合法的 spark-goal-contract 块 | 用/goal status看是否停在待确认;重新起草或手动/goal confirm |
| 契约里没有验收标准 | 块里缺 success_criteria,整块被丢弃 | 缺这一项契约一律视为无效,必须重新起草 |
| 目标一直跑不完 | 模型不产出合法状态块 → 熔断计数永远不动 | 手动/goal pause或/goal clear |
| 改不了会话的工作流 | 存在「当前目标」,未确认的契约也算 | 先/goal clear或完成目标 |
| 编辑按钮点了报「仍有待处理消息」 | 界面判断忙不忙时没看队列,后端才拦 | 先清空队列再编辑 |
| 删了一条消息,另一条也消失了 | 删除按轮次扩大范围 | 预期行为;不想连带就用编辑 |
| 删除后 Agent 还记得旧内容 | 删除不清理摘要与上游恢复上下文 | 用编辑代替删除,或开新会话 |
| 分叉出来的会话是空的 | 源会话当时没有已完成轮次 | 等一轮跑完再分叉 |
| 分叉后显示「运行在隔离 worktree」 | 元数据被整体继承 | 预期行为;注意它与源会话共用同一物理目录 |
| 加不了更多参考会话 | 累计 10 条上限,且界面无撤销入口 | 当前实现下无解,需要换会话或等修复 |
| 还原点时间线是空的但显示已开启 | 分叉复制了开关但没复制还原点事件 | 在新会话里重新产生还原点 |
| 大体积 ZCode 会话在列表里找不到 | 超过 64 MiB 被静默跳过 | 当前实现下无法导入 |
| 导入后分不清哪些是导入的 | 渲染进程未消费 importedFrom | 打开会话内容辨认;这只是展示缺失,数据是完整的 |
速查表
- 单会话并发
- 严格 1(有活跃执行器时新轮次一律入队)
- 全局并发上限
- 6(硬编码常量,无设置项可调)
- 队列深度上限
- 无
- 队列排序持久性
- 不持久,重启后按 created_at 重建
- 持久发送通道
- session:submit-turn(session:send-turn 不落库)
- 队列暂停触发条件
- agent_status 事件 status='error' 且队列非空
- 暂停状态字段
- reason / failedTurnId / errorMessage / pausedAt
- 目标状态枚举
- active、paused、completed、failed、cleared、stopped_by_budget、pending_contract(7 个)
- 目标模式枚举
- spark-loop、codex-native
- 目标阶段枚举
- review、act、validate
- 默认目标预算
- maxConsecutiveFailures 3 + noProgressLimit 3(无轮次/时长/费用上限)
- 迭代注入消息上限
- 8 条,每条截断 2000 字符
- 契约块
- spark-goal-contract(success_criteria 必填,缺失即整块作废)
- 状态块
- spark-goal-status(status 只接受 continue/completed/blocked/failed)
- 计划批准
- send-turn + permissionMode=claude-auto-edits + interruptActive=true
- 消息悬停按钮
- 重发、复制、编辑消息、从此处分支、删除
- 可编辑范围
- 只有最后一轮用户消息
- 分叉锚点
- turnId(不是消息 ID)
- 分叉标题默认值
- 「源标题 · 分支」,上限 200 字符
- 参考会话上限
- 每个目标会话 10 条 active
- 还原点上限
- 每会话 20 个,存储为 refs/spark/checkpoints/<sessionId>/<id>
- 还原点默认状态
- 关闭
- 历史导入来源
- claude-code、codex、zcode
- 大文件保护
- 扫描元信息 >8 MiB 只读头尾;ZCode 桌面版 >64 MiB 静默跳过
常见问题
展开 6 条常见问题
- 队列里的消息会不会因为应用重启而丢失?
取决于它是怎么发出来的。走 session:submit-turn 的轮次会写进 turn_requests 表,重启后会按 created_at 顺序恢复。走 session:send-turn 的(IM 远程连接收进来的消息、定时任务执行器发起的)没有持久行,重启后会静默丢弃,界面上不会有任何提示。另外重启时 status 仍是 running 的轮次会被标记为失败(错误信息 Turn interrupted by application restart),不会重新排队。
- 为什么我拖拽调整了队列顺序,重启后又变回去了?
因为队列排序只改内存里的 pendingTurns 数组,不写数据库,也不更新 updated_at。重启恢复是按 created_at ASC, id ASC 重建队列的,所以顺序会回到按入队时间排列。这是当前实现的行为,不是偶发故障。
- 队列卡住了但没有任何错误提示,是怎么回事?
错误暂停横幅只在「出现 error 状态事件且队列非空」时才会出现。其他会让队列停滞的原因——有待审批的计划、有等待回答的提问、执行器忙或全局并发到上限——都不会产生暂停状态,因此没有任何横幅。全局并发上限是硬编码的 6,没有设置项可调。如果是计划审批导致的卡住,直接切换一下权限模式就能解除闸门并推进队列。
- 编辑最后一条消息,之前的对话会被删掉吗?
编辑只作用于最后一轮,并且会先删除该轮的全部事件、turn_requests 记录、性能指标,轮转 Codex 原生线程代号、清空账本绑定、删除会话摘要,然后再发送新内容。整个过程没有任何二次确认。它不会回滚工作区文件——代码里明确注释了这一点,Agent 改过的文件仍然保持改动后的状态。
- 删除一条消息和编辑一条消息有什么区别?
删除是硬删 agent_events 行,无软删标记、无撤销,而且删用户或助手消息时会连带把同一轮的所有消息事件一起删掉。但删除不做连续性清理:不轮转原生线程、不清摘要、不清账本绑定。编辑则会把这些都做掉。所以如果目的是「让这段内容及其上下文影响彻底消失」,应该用编辑而不是删除。
- 目标模式会不会一直跑下去停不下来?
默认预算只设了两个熔断:连续失败 3 次、连续无进展 3 次,不设轮次上限、运行时长上限和费用上限。问题在于这两个熔断的计数完全依赖进度日志,而进度日志只在「轮次正常完成并且解析到合法的 spark-goal-status 块」时才追加。如果模型始终不产出合法状态块,或者每轮都以 error / cancelled 结束,计数永远不动,循环就会一直运行。队列的错误暂停闸门不覆盖这种情况,需要手动 /goal pause 或 /goal clear。