高级治理阅读约 25 分钟最后核对 2026-09-24

会话与消息流

Spark Work 会话与消息流完整指南:先讲清「会话 → 轮次 → 事件」三层模型与 turnId 的作用,再展开消息流的真实链路(submit-turn 与 send-turn 的持久性差异、started 的真实语义、排队时消息气泡为何消失)、队列的两层结构与面板操作(含「拖拽排序重启后丢失」这个真实缺口)、出错暂停与两种恢复方式、过程性断连的轮次内自动重试(Spark 引擎 10 次重试 / 共 11 次尝试的退避与「只按次数终止」、Codex 自带 5 次重连、重放安全边界与「重新尝试」的真实语义)、调度并发(单会话 1 / 全局硬编码 6)。目标与计划部分覆盖 /goal 命令族、验收契约门控的解析规则(success_criteria 缺失即整块作废)、契约卡片、spark-goal-status 块的四种状态映射、五项预算熔断及其「计数依赖进度日志」的真实缺口、计划模式审批与闸门解除。会话级操作部分覆盖消息级操作(悬停栏按钮的真实出现条件、右键菜单、多选、乐观气泡状态机)、编辑最后一轮的先删后发语义、删除的按轮扩大与「浅清理」、会话分叉的轮次级锚点与元数据继承坑、参考会话的 10 条上限、代码还原点的 git 实现,以及历史导入的四个来源路径与四步流程。文末附 20 行排查表。

这一篇讲的是「一轮对话是怎么跑起来的」——你按下发送之后,消息去了哪里、为什么有时候会排队、 队列为什么会停下来、目标模式和计划模式各自卡在什么地方等你确认。 这里覆盖三条主线:消息流(发送 → 排队 → 调度 → 落库)、目标与计划(验收契约门控、计划审批)、 以及会话级操作(分叉、参考会话、还原点、历史导入)。

所有字段名、枚举值、界面文案与默认值都来自应用仓库的真实代码,不写代码里不存在的东西。 凡是代码本身就自相矛盾或者存在明显缺口的地方,本文会直接点出来,并在18. 排查表里给出判断方法。

1. 会话的三层模型

先把名词对齐。Spark 的对话不是「一个会话 = 一串消息」,而是三层:会话 → 轮次 → 事件。很多看起来奇怪的行为,根源都在这层结构上。

层承载表说明
会话 sessionsessions一条对话记录。状态枚举idle/running/disabled /error(packages/storage/migrations/024_scheduled_tasks.sql)。 运行态与「上次运行结果」都挂在会话上。
轮次 turnturn_requests一次「你发一条 → Agent 回一条」。有一个 UUID 形式的turnId, 它是排队、编辑、分叉、删除的共同锚点。
事件 eventagent_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.2started不等于「已经在跑」

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. 排队:两层结构

队列不是一个对象,而是两层——一层在内存里,一层在数据库里。两者覆盖范围不同, 这是理解后面所有队列行为的关键。

层位置持久
内存 FIFOSessionService.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 重启后的恢复

应用启动时会跑一遍恢复,顺序是:

  1. status = 'running'的行统一标记为失败,错误信息Turn interrupted by application restart,不重新排队;
  2. status = 'accepted'的行解析回PendingTurn重新入队,enqueuedAt取created_at;
  3. 对会话状态为error的会话,读最近 32 条agent_status事件, 回溯出暂停原因并重建暂停态;
  4. 然后调度下一轮。

暂停在重启后的权威来源是会话状态(sessions.status = 'error'), 而不只是内存里的闸门对象。

5.5 一轮跑到一半断网了会怎样

上面讲的暂停是「这一轮已经失败」之后的事。但更多时候你遇到的是过程性断连:流式连接抖了一下、网关返回 429、网络瞬断了几秒。这类失败不会把会话打成红色失败卡,而是在轮次内部自动重试,界面上只留一行轻量提示。

三条执行路径的重试都由各自的引擎负责,真实上限如下:

执行路径自动重试上限界面提示
Spark 引擎(spark-engine)单个 LLM 路由 10 次重试 / 共 11 次尝试(maxRetries = 10)一行「模型调用中断,正在自动重试」,附重试进度、本次等待与失败原因
CodexSDK 自带最多 5 次自动重连(Reconnecting... n/5)一行「网络连接中断,正在自动重连(n/5)」
Claude SDKSDK 与执行器侧重试;会话恢复失败会自动改用新会话重试一次,连续失败达阈值后熔断放弃恢复过程以 agent_status 事件说明

Spark 引擎的重试细节(其他路径按各自引擎实现):

  • 退避:从 500 ms 起指数增长,上限 60 s,带 20% 抖动。
  • 终止只看次数:provider 返回的 Retry-After 只决定下一次等多久;哪怕它要求一个超过上限的冷却时间,也只是被钳制到上限,不会因此提前放弃这条路由。
  • 重试只保留一行提示:同一轮的多次尝试在渲染层原地合并,界面始终显示最新一次进度,不会堆成一串错误卡。
  • 可重放的前提:失败尝试已经产出的正文与思考会被消费方按 resetOutput 丢弃后重新开始;但一旦有工具调用已经落地,重放就不安全——那会让同一个工具被调用两次,此时失败直接成为终态。

真正重试耗尽后,才会变成终态失败:模型调用层抛 llm.partial_stream_failed,错误卡上出现「重新尝试」按钮(仅在 retryable 为真时显示)。点它不是去续跑原来的回答,而是把那一轮的原始用户消息回填到输入框作为新轮次重发——这样既避免了重复执行工具,也让你有机会改一下提示再发。

和「队列暂停的重试」区别开:错误卡上的「重新尝试」是消息级的重发;队列横幅上的「重试」是队列级的恢复(重建那一轮输入并排到队首)。两个入口在不同位置,处理的是不同层的问题。

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命令只能传「目标描述 + 附件」,传不了验收标准, 所以从界面创建的目标都会走契约门控。流程是:

  1. 目标状态置为pending_contract;
  2. 发出一条goal_contract_drafting事件;
  3. 跑一次隐藏轮次起草契约(该轮次在对话流里显示为目标模式:生成验收标准);
  4. 起草提示词明确要求「先不要动手实现」,并必须以一个机器可读代码块结尾。

契约块的格式是固定的三行:

```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.4spark-goal-status块

目标进入执行循环后,每一轮迭代的提示词都要求模型以这样一个块结尾:

```spark-goal-status
status: continue|completed|blocked|failed
phase: review|act|validate
summary: <一句话>
evidence: <逗号分隔的证据>
next_step: <下一步或留空>
```

status只接受这四个值——写成别的(或漏写)会导致整块被丢弃, 本轮什么记录都不会产生。

解析到的 status目标状态变化发出的事件
continue不变(仍active)goal_progress
completedcompletedgoal_completed
failedfailed,并写入last_errorgoal_failed
blockedpaused(不是单独的状态)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请求抛错气泡保留,显示发送失败 · &lt;错误&gt;与一个重试按钮
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,它会先删后发:

  1. 删除该轮全部agent_events;
  2. 删除该轮的turn_requests记录;
  3. 删除该轮的turn_perf_metrics;
  4. 轮转 Codex 原生线程代号、清空 Spark 账本绑定;
  5. 删除该会话的全部摘要;
  6. 会话状态置回idle并清掉「上次运行结果」;
  7. 最后才用新文本发出新轮次。

渲染进程会收到一条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/&lt;sessionId&gt;/&lt;checkpointId&gt;引用。不会碰你真实的索引和 HEAD,也天然遵守.gitignore。
  • 每个会话最多保留20 个还原点,超出会被裁掉。
  • 捕获时机是每个轮次执行器开跑之前,并且要同时满足三个条件: 会话打开了还原点开关(默认关闭)、工作区是 git 仓库、 工作树相对上一个还原点有变化。
  • 捕获失败不会影响轮次执行,只记录日志。
  • 标签用的是你当轮的消息文本(截断到 80 字符)。

16.2 还原

还原前会自动先做一次备份,标签是还原前自动备份; 然后用 git restore --source=&lt;ref&gt; --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/&lt;工作区哈希&gt;/&lt;任务ID&gt;.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 四步流程

  1. 扫描:打开即自动并行扫三个来源,画面显示正在检索本机会话,三张来源卡片分别显示扫描会话索引/扫描完成/来源不可用。 扫描有无缓存决定是否直接跳到选择页。
  2. 选择:来源页签带数量、搜索框(匹配标题、项目、 源会话 ID、工作目录)、项目筛选、时间筛选 (全部时间/最近 7 天/ 最近 30 天/最近 90 天)、显示已导入复选框、重新检索本机会话。右侧预览面板可以仅看用户消息、专注预览,并提供加载完整会话。
  3. 导入:显示正在导入会话与进度条,逐条上报&lt;当前&gt; / &lt;总数&gt; · &lt;标题&gt;。
  4. 完成:显示成功导入/跳过(已导入)/ 失败三行统计,以及继续导入/完成。

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
Spark 引擎自动重试单路由 10 次重试(共 11 次尝试);退避 500 ms→60 s,抖动 20%;终止只看次数
Codex 自动重连SDK 自带最多 5 次(Reconnecting... n/5)
重放安全边界无已落地工具调用才可重放;有工具调用则失败即终态
消息悬停按钮重发、复制、编辑消息、从此处分支、删除
可编辑范围只有最后一轮用户消息
分叉锚点turnId(不是消息 ID)
分叉标题默认值「源标题 · 分支」,上限 200 字符
参考会话上限每个目标会话 10 条 active
还原点上限每会话 20 个,存储为 refs/spark/checkpoints/<sessionId>/<id>
还原点默认状态关闭
历史导入来源claude-code、codex、zcode
大文件保护扫描元信息 >8 MiB 只读头尾;ZCode 桌面版 >64 MiB 静默跳过

上手清单:让一个会话在目标模式下安全地自我迭代

从零创建带验收契约的持久目标,并知道什么情况下该手动接管。

  1. 用 /goal <目标描述> 创建目标。描述不要以 pause、resume、clear、complete、confirm、reject、status 这几个词开头,否则会被当成子命令执行。
  2. 目标会进入 pending_contract 状态,并发出一条隐藏的起草轮次(对话流里显示为「目标模式:生成验收标准」)。
  3. 等契约卡片出现,检查「验收标准」是否具体可验证、「验证命令」是否是能真正跑的命令。
  4. 如果起草结果不合格,点「拒绝」会直接清除目标;想保留目标就重新起草,而不是点确认。
  5. 点「确认并开始执行」,目标转为 active 并开始迭代。每轮结束时会向对话流写入一条迭代分隔。
  6. 需要追加要求时直接发消息——目标激活期间它们会进队列,并在下一轮迭代开始时被注入(每次最多 8 条、每条 2000 字符)。
  7. 注意工作流绑定在目标存在期间无法修改,未确认的契约同样会阻塞。
  8. 如果发现目标一直跑不完,先 /goal pause 停下来看进度日志——两个默认熔断的计数依赖状态块,模型不输出合法块时它们不会触发。
  9. 确认目标已达成后用 /goal complete 收尾;不想继续就用 /goal clear。两者都会解除工作流绑定的阻塞。

常见问题

队列里的消息会不会因为应用重启而丢失?

取决于它是怎么发出来的。走 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。