Paperclip 执行语义:一次运行到底保证了什么(幂等、锁、终态与超时的定义)
多 Agent 系统写 demo 很爽,跑到第三周就冒出一类没人愿意查的故障:某条任务显示”进行中”,底下却没有任何进程;某个批过的计划被重新读了一遍,于是子任务建了第二遍;某个 Agent 说”我在后台起了脚本盯着”,结果那脚本随心跳退出一起没了。
这些不是模型能力问题,是执行语义没定义清楚。Paperclip 把这件事写成了一份单独文档(doc/execution-semantics.md),它不解释界面怎么用,而是回答一个更硬的问题:控制平面说”这条任务正在被执行”,到底承诺了什么。
这篇只讲这份文档和执行策略文档写明的保证。任务日常怎么建怎么关,见任务从建到关的流转。
四个概念必须拆开
文档开篇强调 Paperclip 刻意分开四件容易糊在一起的事:
| 概念 | 载体 | 回答的问题 |
|---|---|---|
| 结构 | parentId | 这条工作从哪拆出来 |
| 依赖 | blockedByIssueIds | 它在等谁 |
| 所有权 | assigneeAgentId / assigneeUserId | 现在谁负责 |
| 执行 | checkoutRunId / executionRunId | 现在有没有活的推进路径 |
最容易误用的是第一条。文档写明:不要把 parentId 本身当执行依赖,它只负责工作分解、上卷上下文,以及”所有直接子任务进入终态时唤醒父任务负责人”。父任务真在等子任务,必须用 blocker 建模。
所有权层有条硬不变量:一条 issue 最多一个负责人,assigneeAgentId 和 assigneeUserId 不能同时设置,文档称之为”设计上单负责人”。
由此推出:Agent 持有和人持有的任务执行语义完全不同。Agent 持有的进控制平面执行循环——能被唤醒、能关联 run、崩溃后能恢复部分执行状态;人持有的不走心跳调度器,滞留工作核对(stranded-work reconciliation)也不适用。所以 in_progress 对 Agent 是严格的执行支撑状态,对人只是所有权状态。
锁有两把,别当成一把
进入 Agent 持有的 in_progress 必须先 checkout,但文档区分了两个字段:checkoutRunId 回答谁拥有这条 issue 的执行权,executionRunId 回答哪个 run 现在真的活着。围绕它们的生命周期约束:
| 规则 | 含义 |
|---|---|
run 仅在非终态时持有 checkoutRunId | 终态是 succeeded、failed、cancelled、timed_out |
| 终结时 compare-and-clear | 只清仍指向自己的锁列 |
| 不得清后继 run 已重新获取的锁 | 防止误清接班人 |
| 进程丢失重试的交接 | executionRunId 移到重试 run 时,checkoutRunId 不能还钉在失败那个 run 上 |
| checkout 检查可自愈 | 判冲突前先修指向终态或缺失 run 的锁列 |
关键是文档给这套机制划的性质:陈旧锁回收是崩溃恢复,不是重试循环,绝不允许清除或接管非终态 run 持有的锁。所以清理跑完之后,checkout 返回 409 的含义就很确定:真有活的持有者,或状态/负责人不匹配,或有未解决 blocker,或还有生效中的门禁。Agent 应把它当所有权冲突并停下,而不是原地重试。
另有一道门在 checkout 之后、真正派发之前跑:预派发配置校验。缺必需的 secret/env 绑定会产生显式呈现的”配置不完整”阻塞,而不是派发一个注定失败的 run。这把配置缺失从”运行时失败”归类成”门禁结果”——缺绑定是派发前就能知道的条件。
blocked 不是随便能进的状态
很多系统里 blocked 是垃圾桶状态。Paperclip 给它加了准入条件:至少具备一条可路由的等待路径——一等公民 blocker;或待处理的 issue 线程交互 / 点名了响应人的关联审批;或结构化解阻描述符 {owner, action}(owner 是 agent id、user id 或董事会)。
用第三种时,Paperclip 会立刻通知被点名的 owner:agent 收到唤醒,user 或董事会收到收件箱通知。而”散文式 blocked”——只在评论里用自由文本提到某人某事——路由不到任何人,会在 API 层被拒绝或自动归类为 needs_attention 并通知董事会,不会被当成健康的等待状态默默接受。
还有一条:权限被拒本身不构成 blocker。被指示的步骤在授权边界被拒、但这条 issue 自己的交付物已完成时,正确处置是 done。审查代理若把”被拒”转成带散文 owner 的 blocked,整棵树会被无限期搁浅。这条准入是前瞻性生效的,按 blocked 迁移时间戳判定,升级时已 blocked 的 issue 原样不动。
幂等的样板:已接受计划的分解
一次计划接受,是对某个特定的已接受计划修订版做分解的许可,不是长期通行证。Paperclip 要求把它当控制平面的 exact-once 原语,标准指纹是 (sourceIssueId, acceptedPlanRevisionId)。配套约束:
- 扇出开始前先为该指纹创建或复用一条持久化认领记录
- 这条记录要能在不回溯评论和会话记录的前提下回答:分解是
in_flight还是completed、谁持有在飞认领、已建了哪些子 issue、最终子 issue id 集合是什么 - 扇出中的部分进度必须持久;某个 run 建了一半就死了,重试必须沿同一指纹续做并复用已记录的部分结果
后来的每个 run 遇到同一指纹都要先查认领:没认领可原子创建并成为所有者;在飞则复用(作为合法续做者继续,或观察到别人在做后退出);已完成则复用已记录的子任务结果、不得再建同辈 issue。文档给了结论:为同一指纹建出多棵子树是产品缺陷。
分解完成后,伞形任务剩下的事只是等子任务时,必须持有一等公民等待路径——被子任务 blocked,而不是靠 parentId 上卷停在 in_progress。因为 parentId 不是依赖,一个没有 run、没有唤醒、没有 blocker 的 in_progress 伞形任务,在恢复逻辑眼里就是搁浅。
存活性合同:什么叫”这条任务还活着”
对 Agent 持有的非终态 issue,Paperclip 承诺不留下”没人负责下一步、也没有任何东西会唤醒或呈现它”的状态。注意这是可见性合同,不是自动完成合同:推不出下一步时,正确做法是把不确定性显式摆出来,而不是从散文评论里猜出一个”完成”。
合法的动作路径原语包括:关联该 issue 的活跃 run;能投递给负责 Agent 的排队唤醒或续做;类型化执行策略参与者(executionState.currentParticipant);等待特定响应人的交互或关联审批;一次性监视器(executionPolicy.monitor.nextCheckAt);assigneeUserId 指向的人类负责人;未解决叶子自身健康的 blocker 链;点名了 owner 与动作的显式恢复动作。
反过来,文档专门点名不算存活路径的东西:用 & 起的 shell 任务、nohup、本地轮询循环、脱离的 PTY 会话、适配器子进程。除非被持久化成 run,或给托管运行时服务配上监视器、定时唤醒、blocker 或委派任务,否则它们不能让 issue 保持存活。PID、会话 id、日志文件、“我待会儿看一眼”的承诺,全部只算证据——心跳退出时进程可能被杀,别的 worker 无法假定它可恢复。
所以心跳终结前,处置判断必须从持久化状态评估,而不是从这个心跳还看得见的进程评估。唯一声称的续做若是本地守望者,即使进程还没被观察到退出,也按”没有活路径”处理。
各状态的健康条件因此有了统一形状:
| 状态 | 至少要有一条 |
|---|---|
已指派 todo | 已排队唤醒;或心跳完成后有意停留且无中断派发证据;或已被显式呈现为滞留 |
已指派 backlog | 这是”停放”不是”派发”;但别的任务被它阻塞且无等待路径时,被阻塞方应显示”被停放的工作阻塞” |
已指派 in_progress | 活跃 run;或已排队续做;或活跃的一次性监视器;或针对丢失执行路径的显式恢复动作 |
in_review | 类型化参与者;或待处理交互/审批;或人类负责人;或活跃 run / 排队唤醒;或活跃监视器;或显式恢复动作 |
blocked | blocker(未解决叶子自身健康);或自带活/等待路径的显式恢复动作;或待处理交互、关联审批、人类负责人、明确点名的外部 owner 与动作 |
in_review 有条反直觉规则:把 issue 指派回产生这次移交的同一个 Agent,本身不构成审查路径——执行策略文档里也写了,运行时选参与者时会排除原执行者以防自审。blocker 链同理,只有未解决的叶子是活的或显式等待的,整条链才算被覆盖;这时应呈现第一个搁浅的叶子。
超时、静默、失败是三件事
超时是 run 的终态之一(timed_out),与 failed、cancelled、succeeded 并列,触发同样的锁 compare-and-clear。监视器另有边界:timeoutAt、maxAttempts、recoveryPolicy。监视器不是周期轮询——触发时 Paperclip 清掉计划、给负责人排一个 issue_monitor_due 唤醒;外部服务还没好,负责人必须用新的 nextCheckAt 显式重新武装。已耗尽边界的监视器重新武装会被拒绝;触发时发现耗尽则按 recoveryPolicy 走:wake_owner 排一次有界恢复唤醒,create_recovery_issue 开出可见的恢复工作,escalate_to_board 记一条董事会可见升级。
静默不是失败。进程状态还是 running 的 run 也可能不健康,长时间无输出被当作看门狗信号而非失败证明,分级是 ok / suspicious / critical / snoozed / not_applicable。可疑静默创建中优先级恢复动作,严重静默升为高优先级,并在正确性需要时把源 issue 阻塞到显式评估任务上——不取消那个还在跑的进程。操作者决策也是显式的:snooze 记安静到期时间,continue 只是对当前证据的短暂确认(默认 30 分钟重新武装窗口),dismissed_false_positive 记为不可行动。
恢复只重试一次
崩溃/重启后有两种失效形态,恢复规则对称:
| 形态 | 现象 | 恢复 |
|---|---|---|
滞留的已指派 todo | 派发中或派发后原唤醒/run 死了,重启后无排队唤醒 | 最新关联 run 失败/超时/取消且无存活路径时,排一次指派恢复唤醒 |
滞留的已指派 in_progress | 活的 run 消失,重启后既无活跃 run 也无排队续做 | 排一次续做唤醒 |
第二步也一样:那次恢复唤醒结束后仍滞留,就把 issue 移到 blocked,并在能确定有界 owner 与动作时开出显式恢复动作。文档补了一句我很认同的话——可见评论是证据,它本身不是恢复路径。
有一种情况被特意从”丢失 run”里摘出来:被陈旧门以 issue_continuation_waiting_on_review 取消的续做,是有意的停泊而非消失的执行路径,典型场景就是刚分解完的伞形任务。若它有真实等待目标(未终态子任务或未解决 blocker),Paperclip 会把这次有意等待转成一等公民依赖等待:设为被那些 issue blocked、保留原负责人、留一条大白话评论,然后走 issue_blockers_resolved 自恢复。没有等待目标时才落回标准升级。
恢复用的模型档位也有约束。便宜档(modelProfile: "cheap")只能用于状态类的运营恢复开销,唤醒要带 allowDeliverableWork: false、allowDocumentUpdates: false、resumeRequiresNormalModel: true 这类护栏。任何可能继续源工作、或能改动仓库文件与 issue 文档的 run,必须走正常模型通道;便宜档一旦判定还有实际工作,必须先交回正常模型的 worker run。
恢复最终分三档:自动恢复(只丢执行连续性,保留既有负责人,不挑替代 Agent)、显式恢复动作(能识别问题但无法安全自己完成,如重试已耗尽、依赖图里有不可调用的负责人、活跃 run 静默超阈值)、人工升级(候选 owner 全被暂停或预算卡住,或 issue 是人持有的)。
什么时候这套语义不适用
人持有的工作不在这套保证里。 心跳调度器不执行它,滞留工作核对也不适用。流程大量依赖人推进的,别指望存活性合同兜住。
这不是自动完成,也不是自动改派。 文档在”这不意味着什么”一节再强调一遍:Paperclip 不会自动改派给另一个 Agent、不会只凭 parentId 推断依赖、不会把人持有的工作当心跳托管执行。
恢复是有界的,界满就该有人看了。 一次自动重试、一条显式恢复动作、然后升级,链条设计成不会无限循环,代价是有些任务确实会停在 blocked 上等你。任务看门狗同理:同一指纹谱系尝试 N 次(文档给的 N 是 2 到 3)后停止再触发,带尝试历史升级给人。排查按什么层次走,见Agent 不干活时的分层排查。
存储形态是留白的。 分解认领记录可以放专用表、源 issue 执行状态或别的持久面,文档明说不强制存储形状,只约束契约。
两个默认值值得留意: 执行策略里 approvalsNeeded 固定为 1,多重审批按官方文档标注为尚未支持;实例级的 issue 图存活性自动恢复默认关闭,开启后回看窗口指”最近 N 小时内更新过的依赖路径”。
工作区一致性是独立的前置条件。 适配器支撑的执行里,活跃 run 或排队唤醒只有在 Paperclip 能证明所选工作区对这次调用一致时才算存活路径——公司作用域一致、projectWorkspaceId 必须带 projectId、有效 cwd 存在或可达、依赖 git 时 cwd 要 git 有效。这块展开见执行工作区与 git worktree;Agent 侧在一次运行内怎么被驱动,见Agent 在 Paperclip 里怎么被驱动。
以上全部来自官方文档写明的机制描述,是 Paperclip 声明的契约,不是我们实测的行为。契约和实现之间有没有差距,只有你自己跑起来对着 checkoutRunId、executionRunId 和恢复动作的实际落库才能确认。
延伸阅读
- 从头读起:Paperclip 是什么:一个自己不跑 Agent 的控制平面,怎么管住一整家 AI 公司
- 本专题共 40 篇,完整分组目录见专题页
- Paperclip 执行工作区与 git worktree:一个 issue 到底跑在哪份代码上
- Paperclip 内置 Agent 有哪些角色:briefs 与 learning 的注册表机制与自建方法
本文依据 Paperclip 官方仓库(github.com/paperclipai/paperclip,MIT 协议)的 docs/ 用户文档
与 doc/ 下的规范、运维与连接器手册整理,核对日 2026-08-17。
我们没有部署或运行过 Paperclip,因此不涉及界面外观与操作手感;
部分规范文档描述的是目标架构而非当前实现,文中已就地标注,不构成对实际行为的保证。
请以仓库最新内容为准。