Goal、Plan 与 Todo
有三种持久结构在跟踪工作,它们回答不同的问题。Goal 是工作为什么继续。Plan 是它如何分阶段。Todo 是某个阶段下面有哪些具体条目。
三者都存在 SQLite 中。当前周期拥有的 active Goal 授权续跑;Plan 和 Todo 记录工作及证据。 没有类型化等待或保护门禁时,真正的普通 provider final 会结束当前 cycle,即使步骤仍未 完成或可执行,也不改变它们的 状态。真实工具/外部等待和保护门禁继续有效;「接下来我要跑测试」或旧的 in_progress 步骤本身都不会生成下一轮模型请求。
三个层次
| 层次 | 问题 | 生命周期 | 典型规模 |
|---|---|---|---|
| Goal | 这个会话为什么还在运行? | 直到完成、暂停、阻塞、预算耗尽或永久失败 | 每个会话一个 |
| Plan | 有哪些阶段、依赖和验收门? | 随工作推进而修订 | 若干个步骤 |
| Todo | 某个阶段之下有哪些具体工作? | 在一个 plan 步骤内创建并关闭 | 可选,按需要任意多 |
它们不要求互相镜像。Plan 步骤是阶段;Todo 条目是其下可选的具体工作。机械地为每个 Plan 步骤造一个 Todo,只会增加记账负担而不增加信息。
Goal
Goal 是续跑的授权来源。一个活跃的 goal 会持续下去,直到它完成、被显式暂停或阻塞、达到预算,或遇到一个带类型的永久失败。这正是长时间工作能够续跑、而不是在某个回合结束时就停下的原因。
恢复是分层的。provider 恢复窗口只在原始请求返回首次可重试失败后开始;原始请求仍由传输与流空闲策略约束,且不消耗该窗口。本地带抖动的退避、回滚和每一次替换请求都必须在由该窗口形成的绝对截止时间内完成。截止时间到达时,正在运行的替换请求会被取消,并以带类型的 attempt 失败落盘。每次等待之前,请求层还会持久化请求、回合、失败与下一次 attempt、带类型的原因、选定延迟和等待截止时间。进程重启不会复活旧 HTTP 请求,而是在等待剩余时间后启动新的 Goal 回合。如果这个有界序列仍以可恢复错误结束,goal 控制器会在等待之前写入一条重试记录,并在落盘的截止时间到达时启动一个新回合。
当对端自己给出延迟时,两层之间的交接也是明确的。默认策略为替换工作提供 180 秒的 provider 恢复窗口;如果 Retry-After 超过了这个窗口所剩的时间,请求层既不会睡过自己的截止时间,也不会换成一个更短的本地退避,因为对端已经说明本地延迟太早了。回合以对端的类型化错误结束,Goal 级重试等待的是对端值按 max_delay_ms 截断后的结果。在该策略下,对端要求 400 秒就得到 300 秒的 Goal 等待,而不是 2 秒的本地重试。
{
"goal": {
"retry": {
"initial_delay_ms": 2000,
"max_delay_ms": 300000,
"jitter_percent": 20,
"poll_interval_ms": 250
}
}
}2
3
4
5
6
7
8
9
10
失败处理使用执行前冻结的 cycle、turn 和 Goal 身份。迟到失败不能阻塞新的周期,也不能 修改已被替换或无关的 Goal。普通周期耗尽有界 provider 重试次数或恢复窗口后,只关闭该 失败周期,新独立输入不继承这次失败;相同的可恢复失败只有属于确切 active owned Goal 时才调度持久重试。既有审批、必需输入、认证、turn_budget、blocked 和未知副作用门禁保留。
对可恢复的 Goal 失败没有跨回合重试次数上限:延迟指数增长、达到上限,而 Goal 保持活跃。 重试记录绑定确切 Goal ID,保存尝试次数、类型化原因、延迟、调度时间和下次可执行时间, 重新打开会话会重建这段等待。
已排队的用户输入优先于自动回合,长时间等待会按 poll_interval_ms 切分,以便交互界面能及时察觉输入。
对于当前周期拥有的 active Goal,恢复方式由带类型的错误决定:
| 类别 | 结果 |
|---|---|
| 传输失败、限流、不完整的流、SQLite 写者争用、空的 assistant 消息 | 调度另一个回合 |
| 上下文超限失败 | 压缩保留的历史,然后重试 |
| 认证失败、用户中断、单回合额度耗尽、事件消费者已关闭 | 暂停,等待人工处理 |
| provider 协议无效、不支持的类型化输入、Agent 或模型不可用、持久状态损坏 | 阻塞 |
永久运行时失败会把稳定的类型化代码和已脱敏说明写入 blocked_reason;Goal 不会再以 “已阻塞但没有原因”的状态落盘。
如果某个 Goal 是被更早的版本停在一个已记为 cancelled、expired 或 failed 的请求上,Start Work 无法恢复它,因为该请求再也不可能变成 answered。请用 /goal cancel 然后 /goal create <objective> 恢复该会话;请求行会原样保留作为证据。
持久存储也按同样的方式分类,依据是带类型的错误而不是它的文字。读取或记账 Goal 预算时遇到 SQLite 写者争用,或宿主写入 Plan 对账、人工请求等 Goal 所属持久状态时遇到争用,会落盘一条 database_busy 重试,Goal 保持活跃,因为别的写者持有锁是会自行消失的状况;延迟采用存储自己 给出的建议值,没有时才用指数退避。读取或记账预算时遇到的其他任何数据库失败,会以 usage_unknown 停止回合并暂停 Goal,让人先查看数据库,而不是让运行在没有计量的情况下继续。 此构建完全无法读取的持久状态,例如无法解码的值或未知的格式,会阻塞 Goal 并把类型化原因写入 blocked_reason;宿主自身的持久写入遇到争用以外的失败时,同样以 database_permanent 阻塞。 重启后的进程读回的是 goal_retry 行,因此重启既不会丢掉也不会缩短这段等待。
在终端应用中用 /goal 检查与管理它:
/goal
/goal <目标>
/goal create <目标>
/goal edit <目标>
/goal budget <正整数 token|none>
/goal pause
/goal resume
/goal block <原因>
/goal complete
/goal cancel
/goal history2
3
4
5
6
7
8
9
10
11
/goal 显示当前状态。/goal <目标> 会在尚无 Goal,或上一条 Goal 已完成、已取消时 创建新 Goal;否则直接更新当前目标,同时保留已有状态、预算和累计用量。生命周期管理仍可 使用显式 action 形式。创建、编辑或简写更新目标时,宿主也会同步活跃 Plan: 多阶段目标会归档此前可见 Plan,并安装一个只包含新目标步骤、绑定当前 goal_id 的 新根 Plan。若新目标是原子任务,可以终结陈旧未完成工作,但不会改绑已终态的历史 Plan;若该 Plan 属于上一个 Goal,则归档为已完成的历史,新 Goal 不会被拿去和别的 Goal 的清单对账。
创建或编辑 active Goal 是执行命令,不是只写一条状态。原生命令到达终态事件后,共享 Goal driver 会立即准备下一次自主回合。若会话此前没有 user message,Zuno 会先把用户 给出的目标通过持久 FIFO inbox 准入,并将它保存为首个 user turn anchor。字面的 /goal ... 控制文本不会进入 provider 输入。
每次自主 Goal 回合都在启动时从当前 turn host 捕获 Agent 与目录模型。旧 user message 只保留为因果 transcript anchor,不授予权限,其中保存的 Agent 与模型也不再决定 Goal 的 执行路由。因此切换 Agent 或模型不会改写历史,而下一次 /goal 或 /goal resume 会使用 新选择的配置。普通用户回合仍从它自己的持久消息读取身份。该边界会落盘为 session.turn.started.1,记录触发类型、Goal id/revision、anchor message、Agent、provider 与模型;若 Goal revision 在准备后、执行前发生变化,这份 continuation 会被丢弃。
ACP 的 session/load 与 session/resume 在重建会话运行时后也会检查持久 Goal。active Goal 会在后台恢复,不需要额外发送一条“垫脚”提示词。这也会修复旧版本中已经落盘 active Goal、但尚未准入首个 user turn 就停止的会话。
当首词为已知 action 时,action 优先:show、get、status、history、create、 edit、budget、pause、resume、block、complete、cancel 和 help。 /goal budget 256000000 设置当前 Goal 的显式上限,/goal budget none 清除该上限, 但不会改变 Goal 的生命周期状态或累计用量。如果目标本身以 这些词开头,请用 /goal create <目标> 或 /goal edit <目标> 消歧。/goal help 可以查看精简用法。
Goal 状态同时给出类型化 pause、跨回合重试、provider 退避检查点、待处理人工请求,以及 本目标下仍欠一次权威状态检查的调用。只要还有未结束的 Plan 步骤、WorkItem、Job、 next-step 报告或 Goal 所属人工请求,完成就会被拒绝。
一次暂停只有在指明该看什么时才是可操作的。/goal show 给出 pendingUncertainCalls: 每条不确定调用一项,带工具名、call id、该调用自己报告已改动的路径、类型化原因和观察 时刻。/goal resume 与可跳过的 Resume goal 选择都不代表检查已完成: 它们保留检查义务,在义务未解决时拒绝通用恢复。/resume 同样不写检查标记, 也不清除认证或 turn-budget 门禁。
/inspect-outcome 列出待检查 part ID;/inspect-outcome <part-id> [part-id ...] 可检查 1–16 个带有持久 native.filesystem.intent 记录的原生 write、edit、apply_patch 调用。 它不调用模型,可在已停止周期运行,并持有真实会话独占 lease 到结算完成。回执保存文件 是否存在、内容哈希、metadata、确切原调用和观察时间。读取限制在原工作区,以句柄锚定; 链接、不支持的操作、缺少原生来源及过期调用记录都会失败关闭。
上限为 64 个目标、单文件 8 MiB、总量 32 MiB、读取期限 30 秒。原 outcome 仍为 uncertain;检查不声称原操作成功,不自动恢复,也不授权重放。成功后仍需明确执行 /resume 或 /goal resume,其他门禁照常有效。缺少原生记录的旧调用、Shell 与远程操作 需要各自绑定目标的检查集成;普通成功命令或“已经人工检查”的陈述不能代替证据。现有 job_reconcile 处理后台 Job 记录,不处理这里的待检查工具调用。/goal pause 与 /goal cancel 不结清任何一条,因为它们都没有声称检查已经发生。
成功标准与证据
已声明的成功标准仍须通过证据审计,散文里的“测试通过了”不能满足检查项。用户创建的 Goal 可以有意不设清单:当声明和 criterion 台账都为空时,修改文件不会事后增加检查项,也不会使 Goal 永远无法完成。未结束的工作、所有权、未知结果及现有能力声明审计仍然有效。
goal_propose 必须给出 success_criteria,即一组具体检查项:一个不指名任何检查的提案会被 拒绝,而且标准事后不能补加。每项会成为一行记录,带一个形如 c1 的短 id,并在结果中回显 一次。模型此后不能改写它们:能被改成“正好符合已发生情况”的标准,不是标准。一个提案最多 可以指名 32 条标准,每条最多 500 个字符;不含任何可见字符的语句——只有空白,或诸如 U+200B 这类格式字符——不算标准,因为“在数据库里满满当当、在屏幕上空无一物”的清单不是可供审阅的 证据面。/goal create 是人自己的路径,不受清单约束。这些上限只在写入提案时生效,因此早先 版本存下的更长清单依然可读、可渲染、也依然能完成。
由 0.6.x 版本提出的 Goal 把标准保存在 goal 行里。打开数据库时,会为每个尚未 complete 或 cancelled 的这类 Goal 铸出 c1..cN 的 open 标准行;已经结束的 Goal 保持原样读取,若再次 被激活,则以同样的方式回填。
关闭一条标准只有两种方式,都通过 goal_update:
| 字段 | 含义 |
|---|---|
satisfy_criteria | criterionId 加上一次已运行且通过的检查的 receiptId |
waive_criteria | criterionId 加上 reason,原文落盘 |
receipt id 来自工具结果本身。运行过命令的工具会追加一行,指名它记录的 receipt:
[verification rcp_01HQ...] passed: cargo test --workspace (exit 0, authoritative).
Cite this id as evidence that the check passed.2
若退出状态是推断得来、而非直接观测到的,这一行会明确说明,并声明它不能被引用。这个区分 正是关键:一条 shell 流水线里失败的中间环节被后面的 grep 吞掉后,会给出一个什么都 证明不了的零退出状态,因此它会被记录下来并被拒绝,而不是被默默接受。参见 Shell 退出状态能证明什么。
证据在两端都有边界。被引用的 receipt 必须记录于 Goal 被提出之后:更早跑过的检查,证明不了 当时还没被描述出来的工作;而一个 Goal 被替换之后,前一个 Goal 的 receipt 正属于这种情况。 拒绝信息会同时打印两个时间,并要求把检查重新跑一遍。另一端是:每一次写入文件的工具调用都会 在 Goal 上打下改动时间戳,凡是 receipt 早于该时间戳的标准都会被退回到 open。这个退回会在 写入发生的当时,就在工具结果里报告出来:
[goal evidence] 2 satisfied criteria went back to open, because this change came
after the check that satisfied them. Verify again after your last edit and cite the
new receipts.2
3
这些拒绝会指出问题所在,而不是笼统地要求重试。被引用的 receipt 在本会话中不存在、其结论 是失败或无法判定、以及它早于最后一次写入,各自给出不同的句子;过期的那种还会打印两个 时间戳,让先后关系直接可见。带着仍处于 open 的标准去完成,会报出哪些 id 尚未被证明。
文件写入会改变 Goal 的种类,不会改变它最初的验收契约。非空声明对应的台账缺失或不一致时, 按损坏状态失败关闭,不能当作无清单 Goal。已有清单的证据和新鲜度检查保持不变。 有一件事在“运行自己报告完成”时一律会被审计,无论有没有清单:它记录过、却从未 验证过的能力声明(见下文)。在没有清单的 Goal 上,你自己执行的 /goal complete 不会因为 这种声明被拒绝——声明是模型留下的记录,而目前没有命令可以清除它——但运行本身不能带着它把 Goal 收尾。目前也还没有从 CLI 关闭或豁免单条标准的命令,因此清单仍处于 open 的 Goal,要么 由运行本身完成,要么用 /goal cancel 取消。
渲染出的 Goal 文档会列出每条标准及其状态,因此人看到的门禁与模型被约束的门禁是同一个。
模型不能借独立请求完成已暂停的 Goal,也不能先改成 blocked 绕过暂停。原生 /goal complete 可在剩余审计通过后关闭合法旧 Goal,保留身份、历史、预算和用量;仅因最初清单为空,不再需要 取消并重建 Goal。
阻塞观察
发现具体阻塞条件时即可报告。goal_update 返回 staged、真实 Goal 状态以及相同条件对应的 已完成引擎回合计数;暂存不等于已阻塞。三回合阈值由宿主执行,模型不应先等待三回合再让 宿主重新计数三回合。观察、计数、状态、历史和结算回执在同一事务提交。同一个 Goal/cycle/turn 回执重放不会重复计数或消费较新的观察。显式恢复开始新计数,未绑定身份的旧观察不计入。
能力声明
有些声明根本不是关于工作区的。因为一个相关的模型被记录为具备某项特性,就去启用某个 provider 特性,然后报告成功,这样留不下任何持久的东西来说明这个信念是推断出来的、而不是观测到的。 capability_claim 工具按能力与对象各记录一条声明,并明确回答它是否可以被依赖。
| 状态 | 需要什么 |
|---|---|
documented | 至少一处被引用的来源 |
probed | 本会话中一条证明成功、且晚于最后一次写入的 receipt |
inferred | 什么都不需要,并且会阻止完成 |
unknown | 什么都不需要,并且会阻止完成 |
只要某条在当前 Goal 下记录的声明处于 inferred 或 unknown,完成审计就会拒绝一个 change Goal;对 probed 的声明,审计会在当时重新拿它的 receipt 与写入标记比对,因此后来的一次写入 会让这次探测过期,而无需任何人去改动这份账本。重新记录一条声明会更新那一行并报出它此前的状 态,这样退回到更弱的状态就成为一个被记录下来的事件,而不是一次拒绝或一次无声覆盖。声明作为 出处会比 Goal 的替换活得更久,但只有在当前这个 Goal 实例开始之后记录的声明才会对它构成门禁。
bedrock-model-capability-review Skill 说明了对一个 Amazon Bedrock 模型什么才算证据:一份 点名了这个确切 model id 与区域的厂商文档,或一次实际观测到的探测。它还要求在写配置之前记录 声明,而不是之后。
评审证据
评审声明是第三类:既不是关于改动之后的工作区,也不是关于某个外部系统,而是关于这个仓库现在 究竟怎么运作。它要防的失败很具体。一个受委派的评审者只读了一处互斥保护,就报告并发请求「只会 返回 session_busy」,而父层继承了这个结论——因为那份报告是散文,没有任何东西要求去追踪主调 用路径。随后这份方案被描述为可以直接实施。
四个工具让这件事变得可复核。
| 工具 | 作用 | 重放 |
|---|---|---|
review_open | 针对工件或 Plan 开启评审;Git、限定范围内的工作树字节和 CodeGraph 状态由宿主采集 | Never |
review_claim | 记录、验证、质疑或反驳声明,或结清一条评审问题 | Never |
review_get | 返回由事件重建的当前评审投影与有界声明集合 | Safe |
review_finalize | 重新读取全部锚点,在确切 revision 上判定 draft 或 ready | Never |
模型不能提交 head SHA、dirty、CodeGraph 状态、锚点摘要、verifiedByParent 或 readiness receipt。review_open 通过宿主仓库探针捕获来源;记录和 finalize 会重新打开仓库相对锚点,对真实 字节计算摘要。限定范围内的 diff 或锚点内容变化时,即使前后两次工作树都已经是 dirty,声明仍会 变成 stale。
评审状态使用已有的 session event stream,而不是新增专用表: review.started.1、review.claim.recorded.1、review.claim.changed.1 和 review.finalized.1。声明或问题变化会在同一事务里递增 revision、撤销旧 Ready 并清除 receipt。
否定性声明——「不存在」「只会返回」「从不发生」——在集齐全部五项反证之前无法被验证:定义查 找、直接与间接的调用方与被调用方、至少一个替代入口或相反路径、相关测试或协议契约,以及一次 明确的「如果这个结论为假,它最可能出现在哪里」的搜索。只读一处保护并不能证明某个东西不存在。
来源快照没有 indexed revision 字段,因为 codegraph status --json 不发布这个值。它保存该命令 实际发布的字段、限定范围内 Git diff 与未跟踪文件字节的摘要,以及命名 artifact 的独立摘要——即使 该文件被 Git ignore。literal pathspec、基于目录能力的 no-follow 打开、对实际读取字节的硬上限、 4 MiB 单文件上限、每轮完整锚点观测按路径去重后的 32 MiB 总预算,以及有时限的本地命令共同把 探测限制在仓库内,即使路径在读取期间被替换也不能逃逸。索引不可用或陈旧时会明确降级,证据 锚点仍以宿主直接读取为准。
review_open 会自动启动 balanced-review;评审模型不能调用 council_run 或替换 preset。 席位必须返回可解析的 DelegationEvidenceReport。原始字节、格式、source snapshot 或锚点校验 失败会成为 invalid seat attempt,并可使用 preset 的 retry;只有通过校验的报告才进入 synthesis。 accepted claims、contradictions 与 unresolved questions 由 runtime 连同结构化 run/job/seat receipt 自动导入指定 review。finalize 只接受能在 completed durable Job 中找到同一 seat、Agent 和报告摘要 的 receipt。
task_report 与评审台账相互独立。它按 durable jobID 或子会话 taskID 读取终态记录,受父 session 隔离;超大结果会作为一个整体被有界省略,不会回放或截断子会话转录。
draft 与 ready 是两个不同的答案,不得互相替代。draft 是一次正常的交付:它列出自己的 blocker,并且不会阻塞产生它的那个 Goal。只有 ready 才授权实施,而它成立的条件是:每条 load-bearing 声明都是带证据的事实声明、都通过宿主派生的 review tool-call receipt 重新核验、并且 锚定在当前来源上,同时没有 open issue、contested、stale 或未验证的 P0。绑定 Plan 必须仍是当前 revision,并且至少两个有效席位来自同一次 balanced-review run。Ready receipt 由宿主生成,绑定最终 review revision、source snapshot、HEAD、worktree digest、artifact digest 与全部证据锚点摘要; 调用者无法提供。finalize 提交后会立即再做一次 reconcile,后续每次 review_get 也会重新打开证据, 因此提交后观测到的变更会立即撤销 Ready receipt。请求 ready 但没过门禁时,会被记录成带全部 blocker 的 draft。
生成物不会进入提交
.zuno/ 下面一层的所有东西,除了人自己写的,都算生成物。Goal 文档、落盘的工具输出、后台执行 记录是 Zuno 按名字登记过的那几个;将来某个版本新增的目录同样算生成物,不需要任何人去更新一份 名单。被排除在外的是人写在那里的配置——zuno.json、zuno.jsonc、tui.json、tui.jsonc、 RULES.md、agents/、commands/、extensions/、plans/、rules/、skills/,以及 Zuno 同样会加载的单数写法 agent/、command/、skill/。它们在 git status 里照常可见,也本该进 入你的提交。
这层排除写在两个地方,都来自同一份登记表。**/.zuno/* 这条模式,加上每个人写条目对应的一条否 定模式,写进仓库私有的 .git/info/exclude,而不是写进纳入版本控制的 .gitignore:Zuno 去改一 个由仓库历史拥有的文件,会变成别人下一次提交里一段没人解释得清的 diff。除此之外,每个生成目录 在被创建时还会在自己里面写一个内容为 * 的 .gitignore,这条 * 也盖住了这个文件本身,所以 即使某处没有写过 exclude 段——一个新克隆、第二个 worktree、有人把那段内容删掉的仓库,或者下面 那次查询没能及时作答的检出——这个目录依然是隐藏的。每条模式都在任意层级上匹配,所以旧版本留在子 目录里的 .zuno/ 目录同样会被隐藏;它已经不起作用,你可以直接删掉。
写这段内容之前要先问 git 它的 exclude 文件在哪里,而这次询问有大约十秒的上限。如果 git 没有作 答——最常见的原因是挂载点无响应——会话照样启动,并带上一条点名了这次超时和该 worktree 的警告, Zuno 随后不写这段内容继续运行。自己占一个目录的生成物这时仍然被那个目录自己的 .gitignore 隐 藏,下面那道交付拒绝也照样生效,所以一次超时真正丢掉的,是对 .zuno/ 直接子项中那些并非由 Zuno 创建的目录的排除。
忽略规则对 git 已经跟踪的路径从来不起作用,这就是为什么在排除之上还需要一道拒绝。一次仍会把生 成物交付出去的 git commit 会在执行之前被拒绝,拒绝信息点名这些路径,说明每一条为什么算生成 物,并给出该怎么做:只是被暂存的路径用 git restore --staged,git 已经跟踪的路径用 git rm --cached——因为只有取消跟踪,才能让下一次 git add -A 不再把它收进来。
一次提交交付了什么,是从 git 读出来的,绝不从命令行读:命令行并不知道答案——一个 alias、一份 commit.template,或者一个会顺手暂存的 pre-commit hook,都能把没有任何参数点到的路径塞进提 交。索引总是会被读取。而一条先暂存再提交的命令,会改从工作区读,读的范围就是这次暂存所触及的那 一部分:检查运行的那一刻,git add -A && git commit -m wip 还什么都没暂存,所以被分类的正是 这个范围,也就是所有已跟踪的改动,加上所有 git 不忽略的未跟踪文件。更窄的 pathspec 会把读取范 围收窄到它身上。至于范围无法当作一个路径来读的 pathspec——一个 glob,或者 git 那些以 : 开头 的魔法写法——读取范围会退回整个工作区,而不是去猜它的含义。
命令行上的每一个 git commit 都会被读到,而不只是第一个:git commit -m a && git commit -am b 会通过第二次提交把工作区里已跟踪的改动交付出去;一次 git add 算在它后面那次提交上,而不算 在已经跑过的那次上。git 这个程序无论怎么拼写都认得——GIT、带路径的 /usr/bin/git、.exe 后缀——git 自己的全局选项也按 git 的方式跳过,所以没有哪种拼法能绕过一项以子命令为准的检查。
自己选定仓库的提交会被拒绝,而不是被检查。-C、--git-dir、--work-tree、--namespace,以 及行首的 GIT_DIR=…,指向的都是这些读取不会跟过去的仓库,硬要检查就会报出另一棵树的情况,而 不是正在被写入的那一棵。请把仓库放在 Shell 工具的 workdir 里,让检查和提交共用同一个事实。不 在仓库里就没有什么可检查的,也不会有拒绝。
这些读取是有界的,而这个界属于整项检查,而不属于其中任何一次读取。单次调用最多做五次读取 —— 工作区根、索引、一次暂存覆盖范围、一份未跟踪清单,以及调用自带 expected_head 时的那次比较 —— 它们总共共用一个三十秒的上限。五个各自独立的三十秒上限,等于模型写一条命令就能花掉两分半。 上限到点时,命令在运行之前就被拒绝,拒绝信息会点明是哪一条 git 调用没有回答、它们共用的上限 是多少,以及这个决定所依赖的仓库状态现在是未知的。这是刻意的:一次从未回答的读取不等于一个空 答案,把挂住的读取当成「没什么可报的」,就等于把它变成了提交许可。
同一批读取也会与会话的中断竞速,因此 Esc 或客户端取消在预检阶段就能生效,而不是必须等 git 跑完。两种退出路径都会拆掉 git 已经拉起的东西 —— 到点与被中断都会在放弃这次读取之前先停掉进程 组,因为不会再有别的东西去停它。已经开始运行的 git commit 永远不会被这条路径中断;整个预检阶 段都跑在命令之前。
它存在的理由是:进入索引的生成物,正是一个 Agent 把脏工作区当作自己并未做出的改动的证据来报 告、或者把自己的临时输出当作工作成果交付出去的方式。
Token 预算
Goal 的 token_budget 在回合内的每一次 provider request 前后执行,而不是等到回合边界。 否则一个很长的回合可以在任何计数器被读取之前就花掉全部额度。每次响应先记账到 Goal,再从 这次写入产生的那一行读回决定,因此让运行停下来的东西,正是事后人所看到的那个数字。
| 条件 | 结果 |
|---|---|
| Goal token 预算已用尽 | Goal 进入 budget_limited;与单回合额度耗尽后的 turn_budget 暂停不同 |
| provider 未上报用量 | 回合停止;你亲自设下的预算如果数不清,就无法被遵守 |
| 只剩最后十分之一额度 | 先请求压缩,然后继续 |
| 读取或记账 Goal 时数据库繁忙 | 回合结束,Goal 调度一次 database_busy 重试 |
| 读取或记账 Goal 时数据库以其他方式失败 | 回合以 usage_unknown 停止,Goal 暂停到数据库可读 |
| 此构建无法读取已存储的 Goal | 回合失败,Goal 阻塞 |
最后十分之一是刻意留出的。压缩本身要花掉一次请求,还得给摘要和下一次真正的请求留出空间, 所以正好在额度用尽的那一刻才要求压缩,等于在无钱可付时才去付账。
把一次响应的 token 记账进去,并不会推进 Goal 的 revision。记账是宿主代模型做的簿记,所以一个 读过 Goal 再去完成它的模型,不应该在乐观并发上输给自己的用量记账:那会让它重新读取、再完成一 次、再被记一次账,永远落不了地。只有当这次记账改变了 Goal 的状态时 revision 才会推进,因为那 是模型确实必须看到的关于 Goal 的事实。
宿主的默认额度
token_budget 没有填写的 Goal 默认不受 token 上限约束。标准 Harness profile 发布的是 TurnAllowance::UNLIMITED,不会自行猜测 token、工具调用次数或墙上时间上限。
希望给所有未单独设预算的 Goal 加一道共享兜底时,可在 zuno.json 中配置 goal.default_token_budget:
{
"goal": {
"default_token_budget": 256000000
}
}2
3
4
5
Goal 自己显式写下的预算永远优先,宿主兜底也不会写回 Goal 那一行。移除配置键即可恢复默认 无限;已有 Goal 还可以用 /goal budget <正整数 token|none> 单独修改。
上面那张表里有一条规则不适用于默认值。provider 未上报用量时,你亲自设下的预算会让回合停止: 数不清的预算无法被遵守,而拿没上报的数字继续下去,等于让预算悄悄变成建议。但在配置的宿主 兜底额度下回合会继续:不上报用量是某个端点自己的选择,不是失控;而让每一次这样的运行都停在 一个没有写进 Goal 的上限上,除了“去设一个预算”以外不留任何补救。兜底值仍然对已经数出来的 用量生效,所以一个跨过它的下界照样会停在上面。
还有两道上限约束的是回合本身而不是 Goal。除非宿主设置,它们默认都是关闭的;而且无论是否有 活跃 Goal 都生效,因为没有 Goal 的回合一样可以空转。想要一个 provider 无法扣下的边界,用的 就是这两道上限。
| 上限 | 何时停止回合 |
|---|---|
| 工具调用次数 | 一个回合内已派发这么多次调用 |
| 墙上时间 | 回合已经运行这么久,精度为一秒 |
工具调用次数从循环真正执行的派发批次里数出来,所以被一次停止或一次紧急人工请求挡下、没有真正 运行的调用永远不会被计入。已经到达的上限会覆盖压缩请求或继续,因为两者都要花掉这道上限已经不 再允许的一次请求;但它让位于 Goal 已经给出的停止。上限被跨过时已经在途的请求会先完成。
没有 Goal 的会话不受 token 默认值影响。它没有可供记账的持久计数器,而从回合内的内存总量去执 行一个默认值,会在每个回合归零,因此永远不会真正约束到什么。
Goal 已经结束的会话同样不受影响。预算约束的是朝某个目标推进的工作;没有拥有当前原生 工作范围的 Goal 不会让回合停止,也不会接收这轮用量。独立请求仍计入会话总量,但不会 向旧 Goal 收费或把它改回暂停。每次供应商请求都冻结归属,收尾记账再次校验,因此更换 Goal 或请求途中恢复 Goal 不会追溯接管先前用量。属于 Goal 的最终响应仍计入原请求所属 Goal。 budget_limited 是例外,仅约束继续该 Goal 的工作,不能跨过已耗尽的预算;独立请求不会 恢复旧 Goal,也不修改它的预算或状态。宿主回合与会话限制仍约束独立请求。
人工请求与回答投递
Work/Goal 的可选问题使用 deferred 投递,不阻塞继续执行或最终回复。旧 pending 表单 本身不禁止新输入,也不授权自动续跑。普通 Work 真正需要一个阻塞性用户选择时,在受影响 动作之前用一个清楚的纯文本 final 问题结束当前回合,不制造持久暂停。必需的 Goal 输入仍 走 goal_request_input;Plan 保留同步澄清及绑定精确 revision 的批准门禁。 真正的等待依据 runtime execution wait reference,不依据表单可见性或未完成 Plan。
回答和模型可见的 FIFO 输入一起提交,原生 Goal/Plan 控制仍校验各自的身份和门禁。 QuestionView.delivery 从关联输入回执派生,不是第二份持久 question 状态:
普通 deferred 澄清还带 autoDefer:deadlineAt 与 armed|snoozed|deferred。 120 秒期限由原生服务持久化;到期不产生答案或 inbox 输入,原问题仍可迟答。交互 可以 Snooze 并保留草稿。只有确切的自动延后后继版本允许在途表单回答刷新 revision, 其他版本冲突仍然拒绝。必需输入、审批和阻塞式问题没有这项计时器。
| 阶段 | 含义 |
|---|---|
WaitingAnswer(waiting_answer) | 表单仍为 pending,可以继续回答。 |
AnsweredPendingDelivery(answered_pending_delivery) | 已关闭表单仍有未应用输入,或提前批准的 Plan 仍在等待交接。 |
Applied(applied) | 已关闭表单已有应用证据,且没有剩余未应用输入。 |
「Keep paused」等已结束且不产生模型输入的控制,delivery 为 None,序列化时省略, 不是 WaitingAnswer,也不重开表单。提前批准的 Plan 若仍待交接,即使控制输入尚未 生成,也保持 AnsweredPendingDelivery。
问题列表保留已关闭但仍待投递的状态行,不会重开表单。pending/applied 计数、最新输入 状态及 appliedAt 区分「答案已保存」与「应用到 provider 请求」;应用仍不证明 provider 成功或任务完成。
Plan
Plan 承载阶段、它们的依赖顺序,以及它们的验收状态。它存在的意义是:让进展可见性、中断恢复和验证能够在重启或上下文压缩之后存活。
只有在持久协调真正增加信息时才使用 Plan:跨组件工作、委派、彼此独立的验收门或依赖,以及很可能被中断的工作。一个有界的单一负责人改动,不会仅仅因为包含“检查、编辑、测试”几个阶段就需要 Plan。已有 active Goal criteria 明确验收门时,只有 Plan 能补充顺序、负责人或重启价值才再创建它。普通 Work 模式由模型依据完整会话判断,宿主不再解析中英文动作词、问句或确认语来猜复杂度;简单和单步工作直接执行,并且绝不创建只有一个步骤的 Plan。显式 Plan 协作模式仍要求写入持久 Plan。
实践中真正重要的规则:
- 步骤 id 在各次修订之间保持稳定。一次更新要携带上次读取返回的 revision,过期的 revision 会被拒绝且不改变任何东西。
- 只要还有 pending 的步骤,就恰好有一个步骤处于进行中。
completed与superseded都是不可回退的终态。- 一个完全完成的 plan 没有进行中的步骤。
- 显式 Plan 模式要求持久 Plan;Work 模式把创建 Plan 留给模型。需要 Plan 时,模型用
action=create创建首个根 Plan;新目标用create加当前expected_revision归档旧根,而不是追加模板步骤。 patch只发送变化的 id,append只发送新步骤定义;create、append、push的 id 都由宿主生成。- 聚焦的临时工作使用
plan_update action=push:父 Plan 被持久暂停,子 Plan 暂时 成为可见 Plan。子步骤全部终态后使用只带expected_revision的action=pop, 父 Plan 只恢复一次。工作开始、完成、被替代或范围变化时立即更新活跃 Plan,并在 最终回复前完成状态对账。 - 验证证据只属于实际检查过的 commit、build、tag、部署、配置和输入。任一项发生变化 后都要追加新的验收门,不能复用旧的已完成结果。
- Task job 由宿主绑定到准入它的 Plan 步骤。关联 job 仍为 queued、running、 uncertain,或报告尚未被消费时,该步骤不能完成。显式的新 Goal 可以替代旧步骤, 但不会假装已经结算或取消关联 Job。
Plan 模式在提示词之下强制执行其只读的一面:一层默认拒绝的覆盖层允许检查、只读搜索与 LSP、shell 与 bg、提问、Skill 以及类型化的 Goal/Plan/Todo 操作,同时拒绝文件修改、委派、job 与 execute。Plan 模式是一条“不得修改”的边界,而不是一条“没有 shell”的边界:运行时会把任何只读角色强制为 SandboxMode::ReadOnly,因此命令可以取证,但改不了工作树。/plan 与 /start-plan 都是幂等进入 Plan;切换 Agent 只更新以后使用的 Work Agent。回到 Work 必须显式执行 /start-work,要求当前 Plan revision 已写入 handoff-ready 记录;若绑定 review,则默认还要求 Ready,除非用户持久化接受 Draft 风险的原因。
默认宿主通过类型化 Planning service 负责协作模式约束、active 状态与最终对账。普通 final 返回 Finish,不会把未完成 Plan/Todo 标成完成,也不因它们存在就再续跑。前台工具等待 和真实续步在这之前处理,active owned Goal 的续跑与无进展检查保留。模型负责 Work 模式 是否需要 Plan 的判断,并通过操作式 plan_update 创建战略步骤。禁用该工具会 阻止新的模型修改;已有 Plan 的持久化、客户端投影与重启恢复仍然保留。
有副作用的工具丢失响应,就是一次不确定结局。Goal 以 uncertain_side_effect 暂停,要求 检查权威状态,绝不机械重放那次调用;只有显式标记为只读或幂等的工具才可以使用安全重试。 一次在结算自己结果之前就被取消的调用属于同一类结局,只是类型化原因不同:响应从未到达是 lost_outcome,中断留下未结算声明是 interrupted。
这份义务是落在工具记录上的持久证据,而不是 pause 的一个字段。dispatcher 在让结果对模型 可见的同一条语句里写入 state.outcome = "uncertain" 与 state.uncertain(tool、 callID、appliedPaths、cause、observedAtMs),因此记录先于任何关于 Goal 的判定 存在。正是这个次序让恢复能扛过崩溃:进程若死在工具写入之后、pause 行之前,pause 缺失而 义务仍在,下一次 continuation 读到它并再次暂停。state.uncertain.reconciledAtMs 会一直 在待检查调用上保持缺失,通用恢复不会写入它。/inspect-outcome 先保存与确切原生文件调用 绑定的新鲜权威观察,再登记“已检查”;检查仍不等于原操作成功,也不授权自动重放。
并非每一行未回答的工具记录都要背上这份义务,而这个差别是持久的,不靠推断。一次被 checkpoint 的调用从 写入的那一刻就带 state.dispatchTracked,从交给执行器的那一刻起再带 state.dispatchedAtMs,两者 各自单独提交。重启修复读这一对字段,只会给出三种结果之一:
- 有
dispatchTracked、没有dispatchedAtMs—— 该调用可证明从未到达任何工具。该行按“被中断的调用” 收尾,不写state.outcome,也不排入任何检查队列。 - 有
dispatchedAtMs—— 该调用已经到达工具,而它的回答丢了。该行按上文的不确定结局收尾。 - 两个字段都没有 —— 这一行由不记录交接的旧构建写下,因此无法排除交接已经发生,该行按不确定收尾。
第三类正是从 0.9.0 及更早的任何版本升级之后、第一次 goal continuation 可能为一次旧进程其实只排过队的 调用以 uncertain_side_effect 暂停的原因:直到 0.9.0 为止的每个已发布构建,对一次随后交给执行器的调用 都只持久化了 status、input 与 raw 三个字段,因此旧记录本身说不出到底是哪一种,而向模型报告一个它 可以重新发起的“已判定失败”,是两种错误里更坏的那个。这些行完全按存下来的样子读取 —— 没有迁移,也没有 回填 —— 而且本构建自己开始写记录之后,这一类就不再新增成员。
一次连自己都无法命名的已交接调用,同样欠一次检查。当 provider 或网关给出空的 call id 或空工具名时, state.uncertain.tool 与 state.uncertain.callID 记为 <unnamed>,而不是丢弃这份义务; 通用 Goal 恢复不能仅凭 part id 就结清它。<unnamed> 不可能与真实工具名相撞,它本身就是 一份报告:该 provider 的 tool-call 身份不可用。
来自 OpenAI 兼容 chat 或 Responses 网关的工具调用如果没有 id、或 id 为空,会在到达 engine 之前被赋予一个 合成身份:zuno-unnamed-call-<position>,其中 <position> 是该流自己用来关联这次调用的键 —— chat 的 tool_calls[].index,或 Responses 的 item id;自身 id 也为空的 item 则改为按序编号。engine 用这个精确的 字符串关联该调用的各个事件,把它作为该行的 callID 持久化,并在下一次请求的两侧原样重放,因此这个值在 线上自洽,不需要对端回显。该前缀与任何网关签发的形式(call_…、fc_…、toolu_…)都不同,所以合成身份 在持久行或日志里绝不会被误认为真实 id,同一响应中两次无 id 的调用也不再作为重复开始而相撞。上文的 <unnamed> 只留给那些到达 engine 时 id 或工具名仍然为空的调用。
在 Responses 表面上,这个按序编号的位置同时也是该调用被跟踪的键,因此两个无 id 的 item 绝不会共用同 一条记录。带 item_id: "" 到达的参数增量或完成事件会被路由到当前唯一打开的无 id item。有两种形状无 论怎样解读都会把事件归错调用,因此都作为类型化的 invalid_upstream_tool_call 协议失败被拒绝,而不是 猜测:在一个无 id 的 function_call item 仍然打开时又添加了第二个无 id item;以及在没有任何无 id item 打开时收到无 id item 的完成事件。协议失败对该回合是终止性的,不会重试。
/plan
/start-plan
/start-work2
3
进入 Plan 时会原子写入 paused(plan_mode)。Start Work 会在同一个事务中恢复保存的 Agent/provider/model/reasoning identity,写入精确的 Plan authorization、cycle 与 continuation token,并接纳唯一的 UserControl 输入;Goal resume 不能替代这份 Work authorization。
自动完成通知与压缩恢复使用独立的 durable trigger,不伪造 user message。 session_input.source_key 去重 producer delivery,completion_delivery 让同步 wait 与 异步 callback 竞争唯一消费权,session_execution_state.context_epoch 则在压缩后递增。 因此后台完成会主动唤醒空闲 Work 会话,在 Plan 模式中仍保持只读,而且 bg wait 已消费 终态后不会再启动重复 turn。
Todo
Todo 条目是某个 plan 步骤之下的具体工作。它们携带稳定 id、revision、goal 与 plan 关联、父级与依赖 id、负责人、状态、优先级、时间和 Token 用量。
这些约束存在的目的是让图保持有意义:
- 一个会话中最多一个条目处于进行中。
- 一次批处理之后,每一个被引用的父级、依赖和 plan 步骤都必须存在。
- 父级图与依赖图无环。
- 任何校验或 revision 错误都会让整个批次回滚;绝不会提交部分更新。
请保留已有 id,而不是删除后重建条目;当一次原子批处理添加了相互依赖的条目时,先分配显式的稳定 id 再引用它们。
这些如何在压缩中存活
压缩改变的是 provider 对话边界。它不会删除 Goal、Plan、Todo、Job、inbox、事件日志或提示词收据状态。
恢复后的 provider 请求发送前,安全点按持久 FIFO 顺序选取合法回答、已结算报告和 steering,即使唤醒通知缺失也一样。恢复控制不能跳过更早的合法回答;明确留给下一回合的 普通输入仍排队。InputDeliveryBatch 和 session.input.delivery_batch.1 记录实际消费 的输入及其身份和 revision,不证明应用;实际 post-hook provider dispatch 对应的输入 回执才建立应用事实。
相反,每一次相关的 provider 请求都会从 SQLite 重新生成一个有界的 runtime.work_state 开发者分段:当前的 plan revision 与步骤、Todo 身份与依赖、 活跃或不确定的 job、仍关联到未完成 Plan 步骤的终态 job、报告尚未被消费的终态 job、待处理报告的身份,以及最近一条先前提示词收据的 id。存在关联时,job 条目还会 带版本化的 Plan workContext。一个 deferred 事务从同一个快照读取全部内容。
每个集合上限 64 条,渲染出的分段上限 16 KiB。冗长文本会先以 UTF-8 安全的方式缩短,之后才整条省略尾部条目,被省略的数量始终显式给出,身份字段一律保留。如果连身份字段都装不下,组装会失败即拒绝,因为一个静默丢掉了身份的工作状态分段,比没有这个分段更糟。
转录回退是另一种移动转录边界的操作,它对 inbox 遵守同一条规则:提交回退会丢弃暂存边界之后的转录行,但永远不会删除 inbox 行。所有 queued、steering、promoted 的输入原本都指向已被丢弃的尾部,因此每条都经常规取消迁移退役,并各记一条 session.input.cancelled;已消费的输入是不可变历史,不受影响。提交随后追加一条 session.reverted 事件,记录边界、被删除的行数与被退役的输入 id,被丢弃的尾部因此仍可从持久日志重建。完整字段见运行时参考中的持久输入。
旧暂停不会因重启、新输入或迁移自动解锁,数据库格式 15 保持不变。对于证据完整的单条 旧误阻塞,可检查已 consumed、回执仍为 recorded 且从未应用的真实用户输入:
zuno session repair SESSION --input INPUT --dry-run
zuno session repair SESSION --input INPUT --apply --expected-revision N2
将 N 替换为检查返回的正整数 expectedRevision。应用需要离线、独占既有数据库, 并重新校验完整原生证据;成功只准入一个审计过的恢复控制,不重排队原输入、不重放工具、 不修改 Goal,也不迁移数据库。成功返回 control_queued,不证明输入已应用或 provider 成功。仅在同一控制仍为 queued,且全部证据与执行状态不变时,重复调用才以 already_queued 幂等返回。控制已推进或原输入已绑定/应用后,修复拒绝重投。 缺失证据和真实保护门禁仍失败关闭。详见 有界修复。
长任务上下文不授予执行权限
task_context 通过带版本的会话事件保存有界快照:用户来源 ID/摘要、revision、 原始目标、约束、决策归属及已报告的交付/安全证据。来源排除压缩标记和自动报告, 摘要覆盖全部持久用户输入部分(含附件引用);验收定义修订须显式关联用户来源并重置 状态和证据。更新是原子的,并按工具调用幂等; 不会改变数据库格式、授予权限或恢复 Goal。runtime.task_context 在模型请求前刷新, 标出更晚的用户输入和失效来源;查询进展不会自动接管旧任务。
实施者负责常规技术选择。只有负向保护生效不能说明交付成功;完成交付上下文要求交付 检查通过且全部验收项有证据。上下文状态不会覆盖 Plan/Goal 归属、真实等待、预算、 认证或未知副作用,也不会仅因“尚未完成”就让任务自动续跑。
Job
后台委派会产生持久 job,它们的生命周期也是这套状态的一部分。
一个后台 job 会在等待委派容量之前提交 queued,只有在被准入时才变为 running。重启时,仍处于 queued 的 job 会结算为 cancelled,因为它的 runner 从未启动;running 的 job 结算为 uncertain,并且绝不会被重放。
原生 Task 准入会把可选 Goal id、Plan id、准入时的 revision 和活跃 Plan 步骤 id 写入 Job 的 workContext。只要该步骤尚未结束,已完成或失败的子任务证据在压缩和重启 之后仍会对模型可见,避免同一失败调查在没有新假设时被重复委派。
下一步依赖 CI 或发布结果时默认前台执行;只有父级确实有独立工作时才显式后台运行。 选用后台 Shell 观察器时设置 backgroundPurpose: "remoteObserver",由持久终态报告 唤醒合法周期,再读取返回句柄的输出并按稳定 ID 查询远端权威状态。不要叠加 watcher 或 手写轮询,也不能把观察器退出当作远端成功。
确切的 runtime 外部等待引用才使 driver 持久化 waiting_background,未完成工作本身 不建立等待。匹配完成结果满足该等待;active Goal 再遵循其余门禁续跑,普通 final 则结束 本周期,不通过轮询或制造人工问题继续。
自动报告唤醒遵循冻结的工作周期归属。该周期拥有的 Goal 非 active 时,可以记录和消费 报告,但不能据此启动 provider 请求。明确无 Goal 的普通周期不受无关残留 Goal 阻止; 旧数据缺少归属则不能推定独立。已停止周期和真实认证、权限、人工输入、Plan 模式或未知 副作用门禁保留。记录报告不等于应用,也不恢复 Goal;打开报告宿主不授予 Start Work 权限。
必需的 Job 证据尚未结算时,不得宣称 Job 或关联 Plan 步骤完成;这不要求普通 final 后 再启动一轮模型请求。报告投递见编排。