17 KiB
003 执行
本文带着一份已接单的 Ticket 走完执行、交付与恢复。它建立在 001 领域模型 与 002 授权 之上。
Ticket 阶段
每个 open Ticket 恰好处于一个阶段,由它在等什么决定。表从上往下读,第一条匹配的行生效。
| 阶段 | 条件 | 面板标签 |
|---|---|---|
| person / delivery | 向 Executor Session 投递一个 Input 失败;2 秒后重试,之后延迟逐次翻倍、封顶一分钟;一次成功、一个新的 Assignment、或 Ticket 的结束都会清掉它 | Input undelivered |
| person / answer | 它有一个 Attention | Awaiting answer |
| person / review | 最近的 Report 是 ready |
Awaiting review |
| person / blocked, failed | 最近的 Report 是 blocked 或 failed,且此后没有人的一条消息到达过该 Ticket |
Blocked · Failed |
| working / queued | 没有 Assignment,或只有一个 pending 的 | Queued |
| idle | 最近的 Report 是 waiting,或 Assignment 已结束 |
Waiting for a Trigger |
| working | 其余一切 | Working |
person 阶段意味着这个 Ticket 没有人的输入就走不下去。一条新的人类消息会把 blocked 或 failed 的 Ticket 释放给它的 Executor,于是它进入下一条匹配的阶段,通常是 working。
一个 idle Ticket 结束了它的回合,没有悬而未决的问题。被订阅 Thread 上的一个 Trigger 可以让它继续。
Executor
每个 Ticket 得到一个名为 worker-<ticket id> 的 Executor Session,和一个以 Ticket 短名命名的目录。目录创建在配置的执行根之下,默认为 <stateDir>/work。
Executor Sessions 以操作者的 OS 用户身份免审批地运行命令。它们的 dsh-superagent-worker 预设包含干活所需的工具,并把前台 shell 命令在 5 秒后移入后台任务。这让一个 worker 能在下一个步骤接收新消息,而不是干等一条长命令跑完。
main Session 使用 dsh-superagent-router,带同样的 5 秒 shell 与 job 等待上限,以及一个更适合路由的更小工具集。Sessions 保留创建时选定的预设。Worker subagents 使用 DeepSeek Harness 的 standard 预设。
后台工作与 worker 槽位
当一个回合结束、还有 shell 任务或 subagents 在运行时,Ticket 保留它的 worker 槽位、维持 working。它不会被上报为等待,main Session 也不会收到 waiting 报告。一个完成的 job 或 subagent 带着它的结果、在同一个槽位里唤醒 Executor。一旦某个回合在没有后台工作的情况下结束,Executor 才照常上报 waiting。
每个回合之后,后台工作至多还有 30 分钟完成。到点时,shell 任务被杀掉,Ticket 被上报为 waiting。报告会带上 Executor 最后说的话、以及哪些 job 被杀、哪些 subagents 仍在活动。Subagents 允许继续运行。Executor 在它的下一个回合读取被杀 job 的结果。
向人的移交会立刻释放槽位,并让 Report 保持有效。这适用于一次 ready Report 或一个问题之后——除非此后又有一个 Input 到达——以及任何 blocked 或 failed Report 之后。
移交时,subagents 停止。Shell 任务——比如一个为评审而开着的服务器——可以继续到 Assignment 结束为止。它们不占槽位、也不受 30 分钟上限约束,包括在之后的回合里。它们的完成不唤醒 Executor;Executor 在下一个回合读它们的结果。
人也可以直接在 Executor Session 里写一条消息来发起一个回合。它的后台工作遵循同样的规则。
一个 Assignment 结束时,它的 shell 任务被杀掉且不唤醒 Session。它的 subagents 被释放,它们唤醒的任何完成回合都被丢弃。接替的 Assignment 及其等待中的 Inputs 在那次关闭完成之后才开始。
在 DeepSeek Harness 进程内停止或重启插件走同一条关闭路径。尚未进入 Session 历史的 Inputs 会被保留。关闭会为每个 worker 的后台工作至多等待 10 秒;一次进程重启会直接终结这些工作。一个在忙碌中被中断的 Assignment 会带着恢复备注重启。
Worker subagents(包括嵌套的)使用 DeepSeek Harness 的 standard 预设,并带着 worker 的权限。它们只保留 worker 允许的工作工具,并在 Ticket 结束时失去这些工具。standard 之下,一条 shell 命令在 60 秒后转入后台。一个 subagent 的 jobs 在它结束时被杀掉。
准备 Ticket 目录
在第一个 Assignment 之前,若 Ticket 目录不存在,prepare command 会在执行根用 sh 运行一次。它有十分钟完成。没有 prepare command 时,worker 在一个空目录里启动。
命令失败会让 Ticket 失败并记录它的输出。一次重试、或 Ticket 某个 Threads 上的一条人类消息,会再次尝试准备。一条典型的命令用 git worktree 创建一个 checkout。当一个分支的最后一段路径与 Ticket 的短名一致时,那个 pull request 就属于这个 Ticket。
并发
一个 Ticket 从一次启动(包括准备)开始、或从唤醒其空闲 Executor 的那次投递开始,持有 worker 槽位。在一个活跃回合的整个过程里,它也持有该槽位。
配置的并发上限作用于这些槽位,并为人类消息或状态请求多留一个额外槽位。这样的唤醒在普通槽位占满时可以使用额外槽位,并在新启动之前拿走下一个空槽。因此,新的工作永远不会妨碍一个人继续一个已有的 Ticket。
其他 Inputs——例如 CI 结果——等待普通槽位。一次启动在 working / queued 里等到有槽位空出。启动们并发运行,所以一条慢的 prepare command 只拖慢它自己的 Ticket。
执行变量
prepare command 与 Executor 指令收到同样的执行变量。命令读环境变量;Executor 在它的提示词里读这些变量的值与说明。其中没有凭据。
| 变量 | 注册方 | 值 |
|---|---|---|
DSH_SUPERAGENT_DIR、DSH_SUPERAGENT_NAME、DSH_SUPERAGENT_TICKET |
Executor | Ticket 的目录、短名与 id |
DSH_SUPERAGENT_BLOCKED_COMMANDS |
Executor | Executor 不得运行的命令 |
DSH_SUPERAGENT_GITHUB_REPOSITORY |
GitHub | 该 Source 盯守的仓库,owner/name |
DSH_SUPERAGENT_GITHUB_ACCOUNT、DSH_SUPERAGENT_GITHUB_COMMIT_EMAIL |
GitHub | agent 账号及其 commit email |
DSH_SUPERAGENT_GITHUB_NUMBER、DSH_SUPERAGENT_GITHUB_PR_HEAD |
GitHub | 发起该 Ticket 的 Issue 或 pull request,以及它是 pull request 时的 head ref |
DSH_SUPERAGENT_FEISHU_BOT、DSH_SUPERAGENT_FEISHU_CHAT |
Feishu | 发起该 Ticket 的 bot 与 chat |
Ticket 专属的值来自它的目录与发起它的 Trigger。其他值来自当前的部署设置。执行环境设置只有面板能改;发给 main Session 的文本改不了配置好的 shell 命令。
Workers 通过 agent 账号的 gh 访问 GitHub,通过 bot 的 lark-cli 访问 Feishu。被屏蔽的命令按名字调用时会失败。
这些包装器与目录立的是默认值,不是安全边界。worker 仍可以用绝对路径执行一个程序。
合并
Auto-merge 由 Delivery requirements 提示词管辖。host 没有合并专属的决策逻辑。
默认情况下,Executor 从不以 agent 账号开启 auto-merge:GitHub 会把合并记在开启它的人头上。Delivery requirements 必须点名另一个账号的 CLI 来合并。没有指定时,由人来合并 pull request。
默认指令只在这个 pull request 为该 Ticket 所开、已经就绪、所需批准覆盖了 head commit、且新增加删除合计少于 200 行时,允许 auto-merge。在推送下一个 revision 之前,Executor 先撤回 auto-merge。
没有正文的评审——例如批准——也作为 Triggers 到达,并能唤醒一个在等评审的 Executor。这些指令管的是 agent,不是 GitHub 的合并规则。要强制评审,就在分支保护里要求评审并让过期批准失效。想强制人工合并,就不配合并 CLI、或删掉 auto-merge 那一段。
Ticket 如何结束
- 没有 pull request 的就绪: 一次
readyReport 把 Ticket 以 completed 结束。 - 无事可做: 一个发现没有可供人行动的工作的 Executor——例如无法复现或已被取代的请求——上报
dropped。Ticket 静默结束,除非命中的规则要求说一声;此后其 Threads 上的人类消息会让它恢复。
Pull requests
一个 Ticket 一旦它的 Executor 上报了它由此开始的那个 pull request,就开始跟踪它。它同样跟踪每个从其分支开出、并被 Executor 上报或被 GitHub Source 发现的 pull request。重新打开的 pull request 会被重新跟踪。
当它开出的 pull requests 没有一个还开着时,这个 Ticket 就完成了。若它一个都没开,则它跟踪的所有 pull requests 都必须已关闭。于是它带着最后一次相关关闭的结果结束,并保留它的 Subscriptions,让后来的评论能让它恢复。
在有被恢复、被重试或被找回的工作尚未完成时,一次关闭不会结束 Ticket。若 Executor 认领或答复过一条它认领的跟进、但读完那条消息之后既没有再发过一次非 progress 的 Report、也没有结束过一次回合,同样要等。之后的 ready Report 一旦满足同样的 pull-request 完成规则,就结束 Ticket。
Chat Channels 会收到一条关于 pull-request 结果的最终通知。GitHub 本来就会在 pull request 上显示结果。移除 Ticket 对某个 pull request 的 Subscription,也同时让它不再跟踪那个 pull request。
因此"Awaiting review"意味着一份带着 pull request 的 ready Report。
跟进与上下文 Threads
Executor 决定其 Ticket 自己 Threads 上的人类跟进。它处理 scope 内的请求,把超出 scope 或属于另一个 Ticket 的请求交还。一旦没有 Executor 认领这条消息,它成为 main Session 的候选。否则 main Session 对跟进只能应人的要求去停止工作。
一个 Input 在 Session 把它纳入一个步骤、并且该步骤随后上报或结束回合之后,算作已读。Ticket 结束时,那些未读、未被认领、未答复、未交还的消息回到 Eval,如 002 授权 所述。已读而未处理的,例如感谢,不会。
仅为上下文关注的 Threads 仍会投递消息,但不让 Ticket 拥有人类跟进的所有权,也不恢复 ended Tickets。那些消息可以被独立评估。
Sources 在一个活着的 Ticket 订阅期间轮询被订阅的 Thread,并在最后一个 Trigger 与订阅的 Tickets 结束两者中较晚者之后继续轮询两周。此后消息只经普通准入到达:GitHub notifications 或 Feishu 实时事件。
失败与恢复
Executor 受阻或失败
当一个 Executor Session 上报 blocked 或 failed,或启动失败、出错(后者记录为 failed)时,Ticket 停止接受自动的 Input。
一次会过去的模型 provider 或网络失败——HTTP 408、429、5xx,或被拒、被重置、超时的连接——先重试:共三次、退避递增;一次启动会重新运行,一个回合失败的 worker 会被告知继续;一条失败的 prepare command 不再重试。
一旦失败,CI 与 bot 消息会记录在它身上,但不投递、也不唤醒 Session。
它的工具保持可用,所以人可以直接在 Session 里继续。
一次失败的启动不会自行重试。
一条人类消息、一次此后既非 blocked 也非 failed 的 Report、或一次重试,会解除这个扣留。
它是从存储的 Reports 与 Inputs 推算出来的,所以重启后仍在。
重试
当 open Ticket 处于 person / delivery、person / blocked 或 person / failed 时可用:一个新的 Assignment——重启它的 worker、拿走那些等待中的 Inputs——分别是它们的补救。
一旦一条人类消息到达过 blocked 或 failed 的 Ticket,消息就已在 Executor 手里,于是 Ticket 显示为 working——failed 启动重新进行时显示为 queued——并不再提供重试,除非那条消息的投递失败了。
一次重试结束当前的 Assignment,并在同一个 Executor Session、目录、分支与 pull request 里开始一个新的。
Executor 模型只在创建 Session 时生效;已有的 Session 保持它的模型。
重启
运行中的 Assignments 以 "host restarted" 结束,新的带着一条恢复备注启动,要求 Executor 在重复任何对外动作之前先检查它的目录、git 历史与 GitHub,最老的 Ticket 优先。
处于 waiting、ready、blocked 或 failed 的 Assignments 保持原样——除非那份 Report 之后有 Input 到达过它们,那种情况下它们也会重新启动。
Sessions、目录、Subscriptions、待处理的消息、以及下文的 stored work 都挺过重启;"每条消息被哪个 Executor Session 纳入了历史"的记录同样挺过重启——一个步骤在它认领消息之后、发起模型调用之前写这条记录:重启打断的那个回合纳入的消息,在随后出现一次 Report 或回合结束时算作已读;被一个步骤认领、但在写入历史之前被取消或错误打断的,算作未读。
一次重试或重启,会把曾投递给被替换的那个 Assignment、而其 Executor Session 未纳入的消息——取消或被中断的步骤可能丢掉的那些——重新交给新的 Assignment;一份仍在 Session 队列里等待、被重启保留、或跨崩溃持久化的副本,会在新 Assignment 的第一条消息之前被取走,让新的投递是唯一的一份。
Stored work
dsh-superagent 在一个决定之后做的事——把一个候选、一条跟进或一份报告转交给 main Session,启动一个 Assignment,投递一个 Input——都先存储、后执行。
一次失败的转交或投递——例如它的 Session 联系不上——在 2 秒后重试,之后延迟逐次翻倍、封顶一分钟;一次失败的启动会失败它的 Ticket,除非那是一次会过去的失败,如上文。
每一种 work 只有在同一条目连续失败三次之后,才在 overview 的系统问题里报一个错;失败的 Input 投递改为显示在它的 Ticket 上。
向 main Session 的转交保持顺序,通知同样;一条失败会压住它后面同类的条目。
Source 失败
GitHub Source 逐个读取每个 Issue、pull request 与 Thread:一次失败的读取把它的错误留在它的 Thread 上,下一次轮询重读,并计入 Source 的错误——默认连续三次轮询失败后才上报;任何一次成功的轮询会重置计数。
GitHub 在 agent 失去对一个私有仓库的访问权时也回答 404,所以 404 会被重试。
一个被通知的 Issue 或 pull request,若读取持续失败,会在首次失败读取的一天后被放弃;GitHub 报告它已删除时则立刻放弃。此后它不再让轮询失败,它的 Thread 保留着错误,Source 的状态会点名它。
它的读取位置停在原地,所以后来的通知或对它 Thread 的轮询会补上失败读取错过的内容。
Feishu 事件消费者报告每一次失败;对订阅话题的轮询把每次失败留在它的 Thread 上。
GitHub 列表或 Feishu 分页里的一个畸形条目会被跳过,Source 的状态为它计数。
Channel 失败
对外消息作为 stored work 存储并发送,从 5 秒开始退避重试、逐次翻倍、封顶 300 秒。
一个 Ticket 发往同一个对话的消息按排队的先后发出,所以它的认领回执先于它后来的答复或结果:只要同一 Ticket 发往同一对话的更前一条还没发出,当前这条就等着——直到那一条连续失败三次、显示为一个问题。
其他 Tickets 的消息、以及不属于任何 Ticket 的消息,不为它等待。
一条有失败尝试的消息,在发出或被撤回之前,是一个系统问题。
一条消息只有在它的 Source 不再监听那个对话时、或 Source 回答那个对话永远收不到它时才被丢弃;它的 Source 关闭期间,它等着。
GitHub 对已删除的 Issue 或被锁定的对话给出这样的回答;Feishu 对被撤回或已不存在的消息给出这样的回答。
对一条已不存在的 review comment 的答复,改发到那个 pull request 的对话里。
一条被丢弃的消息保留它的记录——丢弃的时间、原因、以及它本属于哪个 Ticket——因此它绝不会被重新排队;一次被丢弃的认领回执读作 dropped,而非 sent。
暂停处理
停止派发、新的 Evals、Eval 超时、Input 投递与答复;已在进行中的启动会等到处理恢复。
Sources 继续记录 Triggers,正在运行的 Executor 回合不被打断。
发往 ended Ticket 的人类消息会等待:暂停解除后的第一个回合若该 Ticket 的 Mandate 按当时的 Policy 仍然成立,就恢复它。
一次进行中的 Eval 在暂停期间不过期;它的截止时间按暂停的时长顺延。
无效配置
一份被其 kind 拒绝的、已存储的 Source 配置,会禁用那个 Source 实例。