← 回到知识图谱
实践持续完善

任务图、后台与定时:让工作撑过一轮对话

本文总结s10 把任务落盘成依赖图,s11 让慢命令转后台,s12 让 Agent 按时间被叫醒。

创建 2026-11-05更新 2026-11-05阅读约 25 分钟
AgentHarnesslearn-claude-code

跟读 learn-claude-code 第 4 站 · 上一站:跨会话记忆 · 下一站:多 Agent 团队 · 全程:总览

先说清楚这一站的结构:s10、s11、s12 三章都直接建在 s04(五个基础工具加权限和 Hooks)上,不是一章叠一章。课程测试把这一点写死了:s10 的测试断言没有 s09 的 MEMORY_DIR,s11 断言没有 s10 的 Task,s12 断言 Task、MEMORY_DIR、background_tasks 都不在。所以读的时候不要找"s11 怎么用了 s10 的任务图",三者各自回答一个问题,到 s15 集成版才合流(见 第 6 站)。

章 回答的问题 动没动主循环
s10 任务系统 这件事能不能开始、谁在做、做到哪 没动,纯工具层,多了 6 个任务工具
s11 后台任务 慢命令跑着的时候,循环能不能继续干别的 动了一行:每次调模型前注入完成通知
s12 定时调度 没人说话的时候,什么东西能让 Agent 开工 动了入口和出口:入口注入定时 prompt,第一次成功响应后 ack

三章有一个共同点:把原来只存在于一轮对话里的东西(计划、进程、承诺)搬到对话外面,由 harness 负责它的生命周期。

s10 任务系统:依赖由代码判断

要解决的问题

需求是"先建数据库表,再写 API,再写测试"。s05 的 TodoWrite 能把这三项列出来,但列表里没有边:模型完全可以先把"写测试"标成进行中,harness 无从拒绝,依赖只存在于模型脑子里。TodoWrite 还存在进程内存里,Ctrl+C 之后全没了;它没有稳定 ID,想改第 3 项只能整表重写;也没有 owner,以后多个 Agent 一起干时说不清谁在做。

课程 README 的说法很准:TodoWrite 能显示"编写 API"未完成,但 harness 无法据此判断某个任务能否开始。s10 要让代码来回答能不能开始。

机制

每个任务一个文件 .tasks/task_xxxxxxxx.json,字段是 id, subject, description, status, owner, blockedBy。状态机只有两个动作:

pending ──claim_task(status 是 pending,且 blockedBy 全部 completed)──► in_progress
in_progress ──complete_task(status 是 in_progress,且 owner 是调用者)──► completed
update_task 加边:只允许对 pending 且无人认领的任务

建图分两阶段。create_task 的 schema 故意没有 blockedBy,还加了 additionalProperties: False(s10_task_system/code.py 第 406-407 行);系统提示要求"先建全部节点,拿到运行时生成的 ID,再用 update_task 加依赖"(第 60-65 行)。一次典型的建图:

第 1 轮回复(并行 4 个 tool_use):
  create_task("建表")  create_task("写 API")  create_task("写测试")  create_task("写文档")
  → tool_result:Created task_1a2b3c4d / task_5e6f7a8b / task_99aabbcc / task_0d1e2f3a

第 2 轮回复:
  update_task(task_5e6f7a8b, addBlockedBy=[task_1a2b3c4d])
  update_task(task_99aabbcc, addBlockedBy=[task_5e6f7a8b])
  update_task(task_0d1e2f3a, addBlockedBy=[task_1a2b3c4d])

(ID 为示意。)ID 由 harness 生成:task_ 加 secrets.token_hex(4),用 open(path, "x") 排他创建,撞了就重试,最多 100 次,绝不覆盖已有文件。

依赖门在 incomplete_dependencies 里(第 228-233 行):

try:
    if load_task(dependency).status != "completed":
        incomplete.append(dependency)
except (FileNotFoundError, ValueError):
    incomplete.append(dependency)

依赖文件丢了、损坏了、ID 格式不对,一律算"未完成"。complete_task 完成后,会对比完成前后哪些任务变成可开工,只把"因为这次完成才解锁的"报给模型。

为什么这么设计

  • 为什么必须两阶段:模型可能在同一次回复里并行发出多个 create_task。这些调用的参数在任何 tool_result 回来之前就定死了,"写 API"那条调用根本拿不到"建表"的 ID,因为那个 ID 还没生成。如果 create 允许传 blockedBy,模型只能编一个,比如 task_1,而这个 ID 一定不存在。只靠提示词"请分两步"不够,所以 schema 直接去掉字段,测试也锁死了这一条,防止后人好心加回去。
  • 为什么 ID 不让模型起、也不自增:模型起名会撞名、拼错,甚至起出带 ../ 的名字,而 ID 会进文件路径,模型起名就等于给了一个注入口。自增需要一个共享计数器,多进程下要加锁。随机 hex 加排他创建不需要任何协调,格式还能用正则 ^task_[0-9a-f]{8}$ 严格校验,校验通过就不可能路径穿越。
  • 为什么依赖门 fail-closed:依赖状态未知时,拒绝开工最多是卡住,看得见、可以人工修;放行则可能在表还没建时就去写 API,这是静默错误。
  • 为什么只存 blockedBy 不存 blocks:两个方向都存,加一条边要改两个文件,本章没有跨文件事务,中途崩溃图就不一致。代价是查"谁依赖我"要全表扫描。
  • 为什么加边前整体校验再一次写盘:传 [有效ID, 不存在的ID] 时,如果边校验边写,会留下加了一半的图,模型重试时行为难以预测。环检测也放在写入时做,保证磁盘上的图永远是有向无环图,读路径不用再考虑环。

和 s05 TodoWrite 的分工

s05 TodoWrite s10 任务系统
定位 模型给自己写的便签 harness 管理的工作项数据库
粒度 "下一步 grep 一下"这类执行步骤 "写 API"这类可交付、可认领的单元
更新方式 整表替换 单条 create / update / claim / complete
约束 只有格式校验(条数、最多一个进行中) 依赖门、状态机、owner、环检测都由代码拒绝
寿命 当前进程 跨会话,直到有人删目录

两者不是替代关系。一个 Task 内部,模型照样可以用 todo 拆执行步骤。合并成一个的话,要么 todo 变重(每个小步骤都落盘带 ID),要么任务变轻(丢掉门禁和持久化)。

坑与局限

  • claim_task 是"读、判断、写"三步,中间没有锁。两个进程同时认领同一个任务都会成功,后写的覆盖先写的 owner。README 明说 s10 只处理顺序执行,并发留到 s13。
  • 创建是原子的,保存不是:save 直接 write_text,不是写临时文件再改名。笔记里做过实验,把一个任务文件写成 {broken,list_tasks 整体报错,一个坏文件让整个列表不可用。
  • 列表按文件名排序,文件名是随机的,所以列表顺序不等于创建顺序。模型如果把列表顺序当执行顺序会出错,依赖必须靠 blockedBy 表达。
  • 没有删除、取消、失败状态,也没有超时回收。Agent 崩了,in_progress 的任务就永远挂着;删掉一个前置任务文件,下游会永远 blocked,只能手改文件。

s11 后台任务:占位结果加迭代开头的通知

要解决的问题

s04 的 bash 是同步的 subprocess.run(timeout=120)。模型发起一个 90 秒的 pytest,整个循环就卡在这里,同一条回复里排在后面的 tool_use 也只能干等。启动 npm run dev 这种不退出的命令,120 秒后被杀,服务也没起来。超时时只杀掉 shell 本身,shell 派生的孙进程变成孤儿继续跑。

所以 s11 要解决两件事:不阻塞,和进程生命周期。第二件 README 一笔带过,代码里却占了约 90 行。

机制

bash 多一个显式参数 run_in_background。判断条件是 tool_input.get("run_in_background") is True(s11_background_tasks/code.py 第 435 行),字符串 "true"、整数 1 都不算。

execute_tool(block)                      (第 470-488 行)
  ① PreToolUse(权限、日志),在主线程,被拒直接返回
  ② run_in_background is True ?
       是 → 登记 bg_0001,起 daemon 线程,当场返回占位 tool_result:
            "[Background task bg_0001 started] The result will be collected on a later turn."
       否 → 同步执行
  ③ PostToolUse(只能看到占位字符串)

agent_loop:
  while True:
    inject_background_results(messages)   ← 每次调模型之前
    response = LLM(messages)
    ...

后台线程跑完,把结果写进 results、把 ID 放进 _ready,三步在同一把锁里。循环每次迭代开头调用 collect(),在锁内把完成的任务 pop 出来,拼成文本块追加到最后一条 user 消息末尾:

<task_notification>
  <task_id>bg_0001</task_id>
  <status>completed</status>
  <command>pytest -q</command>
  <summary>(结果前 500 个字符)</summary>
</task_notification>

每个 shell 用 start_new_session=True 起在独立进程组里,finally 里无论成败都杀整组,再加 atexit 和 SIGTERM 处理兜底。

为什么这么设计

  • 为什么要占位 tool_result,而不是等结果回来再填:Messages API 要求每个 tool_use 在紧接着的下一条 user 消息里就有对应的 tool_result。后台任务发起时必须当场回一个;等结果出来,原来那个 tool_use 早就配好对了,不能再出现第二个同 ID 的 tool_result。于是结果作为新事件以普通文本进入对话,用 bg_0001 关联,历史不被改写。
  • 为什么在每次迭代开头注入,而不是每个用户回合开头:多步工具链中途完成的任务能及时被看到。注入点永远在"最后一条是 user"的边界上,消息结构总是合法。追加到最后一条 user 而不是新起一条,是因为连续两条 user 消息需要额外处理;追加时 tool_result 在前、通知在后。
  • 为什么要显式参数,不按关键词猜:只有模型知道后续工作是否依赖这个结果。npm test 往往要等结果再决定下一步,自动转后台会打断因果链;grep "install" file 也会被误判。参数判断用 is True 是安全默认:误判成同步最多慢一点,误判成后台会让模型拿着占位符去推理。
  • 为什么只投递一次:collect() 是取走(pop),不是标记已投递。结果只在内存里,进程挂了结果也没了,谈不上重投,pop 最简单也不会无限增长。测试断言第二次 collect 返回空。下面 s12 选了相反的语义,原因在那一节。
  • 为什么权限检查在后台分流之前、在主线程:如果先分流再检查,run_in_background: true 就成了绕过权限的通道。权限钩子还会调用 input() 让用户确认,后台线程读 stdin 会和主线程的 REPL 抢输入。测试锁定:被拒的命令不创建任何后台任务。
  • 为什么先登记再启动线程:反过来的话,printf ready 这种瞬间完成的命令可能在登记前就跑完,结果找不到归属被静默丢弃。线程启动失败时还要把登记回滚,否则留下一个永远 running 的记录。

坑与局限

  • 不会主动唤醒。模型启动后台测试后回复"等跑完再告诉你",这一轮没有工具调用,循环直接 return,回到等用户输入。后台跑完了也没人叫醒它,要等用户下次随便说一句话才注入。笔记里的实验 1 就是专门看这个现象。注入函数里有一个"最后一条不是 user"的分支,当前不可达,是给将来主动唤醒预留的。
  • 通知只带前 500 个字符,完整结果投递后丢弃。pytest 的失败摘要在末尾,前 500 个字符可能全是进度点,关键信息正好被截掉。改成"头 200 加尾 300"会好一些。
  • 后台任务仍受 120 秒超时,README 拿 npm install 举例,慢网络下照样失败,dev server 更跑不了。
  • PostToolUse 只看到占位字符串,真实输出不经过任何 hook,大输出处理对后台结果失效。
  • Windows 分支只能杀 cmd.exe 本身,孙进程照样存活。README 也承认这只是生命周期清理,不是沙箱,setsid 之类可以逃出进程组。

s12 定时调度:先落盘再入队,成功响应后才 ack

要解决的问题

用户说"每天 9 点跑一次测试"。循环只在用户输入时运转,没有任何组件看时间。模型说"我 30 分钟后再检查 CI",这只是一句承诺,进程里没有东西会在 30 分钟后叫醒它;用 sleep 1800 硬等会被 120 秒超时杀掉,还阻塞整个循环。还有几种更隐蔽的失败:到点时用户正在对话,如果另开一个循环并发写历史,tool_use 和 tool_result 的配对会被打乱;到点那一刻 API 正好挂了,触发即删除的话这次就永久丢了;进程重启,内存里的任务表没了。

机制

模型用 schedule_cron(cron, prompt, recurring, durable) 登记任务。运行时有三个线程:

主线程(REPL)            cron-scheduler 线程             cron-queue-processor 线程
input()(不持锁)         每秒 poll 一次:                每 0.2 秒:
with agent_lock:            跳过 pending 或本分钟已触发的     队列空或抢不到 agent_lock 就跳过
  跑用户回合                 匹配到 → pending_delivery=True    拿到锁后再查一次队列
                                     last_fired=当前分钟        跑一个定时回合
                                     先落盘,成功后才入队
          └──────────────── 共享同一份 session_history ────────────────┘

agent_loop 入口:取出队列,每个任务追加 user 消息 "[Scheduled] {prompt}"
  模型调用失败 → 删掉刚追加的消息,任务放回队列
  第一次成功响应 → ack:循环任务清 pending,一次性任务删除,再落盘

入队的关键几行在 s12_cron_scheduler/code.py 第 481-494 行:先设 pending_delivery 和 last_fired,调用 save_durable_jobs(),落盘失败就回滚这两个字段并抛错,成功后才 cron_queue.append(job)。投递和 ack 在第 649-679 行。落盘本身是写临时文件再 os.replace,读者不会看到半截 JSON。

为什么这么设计

  • 为什么调度线程和执行线程分开:匹配是"当前这一分钟是否命中"。如果调度线程到点直接跑 agent_loop,一轮可能跑好几分钟,这期间其他任务的触发分钟就过去了;它还要等 agent_lock,用户在对话时整个调度都会卡住。拆开以后调度线程只做毫秒级的匹配、落盘、入队,执行线程负责等空闲。执行线程用非阻塞 acquire 加轮询,是为了每 0.2 秒都能检查停止信号。
  • 为什么先落盘再入队:反过来的话,入队后、落盘前崩溃,重启时磁盘上没有"待投递"标记,而这一分钟已经过去,这次触发就永久丢了。先落盘则保证只要进了队列,重启时加载函数就会把它重新入队。
  • 为什么 ack 放在第一次成功响应之后:消费时就 ack,API 一失败就丢;整轮结束才 ack,长回合里某个工具异常或进程被杀,整轮都会重做,而且 pending 期间同一任务不会再触发。第一次成功响应是 harness 能确认"模型已经看到这条 prompt"的最早时刻。
  • 这给出的是至少一次语义。窗口在"模型已响应"和"ack 落盘"之间:这里崩溃,重启后会再投递一次。README 也承认这一点。所以定时 prompt 要写成幂等的,比如"生成今天的报告,已存在就跳过",而不是"追加一条今天的记录"。还要分清,ack 只代表送达,不代表做完;模型收到后执行失败,harness 不会重试。
  • 为什么要两个去重字段:last_fired 防同一分钟内重复:09:00:05 触发、09:00:20 ack,09:00:21 的 poll 仍然命中 0 9 * * *。pending_delivery 防跨分钟积压:Agent 忙了 10 分钟,* * * * * 的任务在队列里只留一份。在课程代码的临时副本里复现过:连续 poll 三分钟不消费,队列长度始终是 1。
  • 为什么定时回合一律拒绝交互式授权:request_permission 第一行判断当前是不是主线程(第 195 行),不是就直接返回拒绝。后台线程调 input() 会和 REPL 抢 stdin,用户敲的下一个问题可能被当成 "y";半夜触发时没人回答,线程永远阻塞,还一直持有 agent_lock,整个 Agent 卡死。无人值守时只能默认拒绝。s11 把权限检查留在主线程,这里是同一条原则用到了定时回合上。

不补跑错过的时间点

匹配是边沿触发,只看"这一分钟命中没有"。进程在 08:55 到 09:05 之间停机,今天 9 点的任务就不跑了;只有停机前已经处于 pending_delivery 的任务会在重启后重投。补跑要记录上次检查时间、算出区间内所有触发点,还要决定停机一周后 */5 的任务是补两千次还是合并成一次,这些都是策略问题,教学版选了最简单、最可预期的方案。

对比一下交给系统定时器的做法。systemd timer 的 Persistent=true 会记录上次触发时间,机器关机错过的触发在开机后补一次;RandomizedDelaySec 给触发时间加随机抖动,避免一批任务同时起。服务本身可以是一次性进程,不需要常驻,也就没有"Agent 进程关了闹钟也停了"的问题。README 在边界一节也建议:需要 Agent 关闭时仍按时执行的任务,用系统的 crontab 或 systemd timer。另一种思路是不做边沿匹配,改成电平判断:"现在在执行窗口内,且今天的产出物还不存在"就跑。重启后自然补上,产出物本身就是幂等键。

一个粗略的分工:用户在对话里临时说"30 分钟后提醒我",适合 s12 这种会话级调度;确定性的业务定时任务交给系统定时器或电平触发加幂等产出物,不让模型参与调度决策。

坑与局限

  • cron 方言有出入:日字段的 */2 实现成 value % 2 == 0,匹配 2、4、6 号,标准 cron 是 1、3、5 号;日和星期都受限时取"或"(0 9 15 * 1 在每月 15 号和每个周一的 9 点都命中,这一条和标准 cron 一致,但常被误读成"且");星期写 7 会被拒绝。这几条都在课程代码的临时副本里复现过。
  • API 持续失败时是热循环:队列线程每 0.2 秒发现队列非空,调一次模型,失败,放回队列。没有退避、没有上限,相当于每秒约 5 次请求(按 0.2 秒间隔推算,示意)。
  • 持久化文件整个损坏时只打印日志、不备份,之后任何一次 schedule_job 都会用内存里的空表把它覆盖。在临时副本里复现过,原本可能人工修好的数据被永久抹掉。
  • 用户回合开始时如果队列里有到期任务,[Scheduled] 消息排在用户问题后面一起发出,模型一次要处理两件事;定时回合的输出也会穿插在用户正在输入的那一行。
  • 定时回合共享会话历史,本章没有压缩,历史会越撑越长。
  • 持久化文件存的是 prompt 原文,也就是可以被执行的指令。谁能写这个文件,谁就能让 Agent 下次启动后定时执行任意指令。不要把这类工具开放给不受信任的用户(参见 安全边界:让权限落到工具层)。

两种投递语义放在一起看

s11 后台结果 s12 定时 prompt
投递的东西 命令的执行结果 一条待执行的 prompt
存在哪 只在内存 可选落盘
语义 恰好一次:取走即删 至少一次:成功响应后才 ack
为什么 进程挂了结果也没了,无从重投 丢一次就是"每天 9 点"静默失效,宁可重复一次
对使用方的要求 想看完整输出只能重跑 prompt 必须幂等

和真实 Claude Code 的差别

  • 会话内计划:真实 Claude Code 有 TodoWrite 工具,界面上显示成清单。持久化任务方面,据公开更新说明有 TaskCreate、TaskGet、TaskUpdate、TaskList 一组工具,支持依赖和 owner,存在用户目录下,可以让多个会话共享一个任务列表(推断,字段和存储格式未验证)。
  • 后台命令会唤醒:Bash 工具同名参数 run_in_background 是确定的。不同的是完成后的行为:工具描述原话是命令跨轮次继续运行,退出时重新调用 Agent,也就是循环结束了也会被拉起,没有教学版"等用户下次说话"的问题。停止后台任务有 TaskStop 工具(确定)。后台命令不受同步超时约束、输出落文件可反复读取,这两条是推断。
  • 定时任务两种形态:桌面端的定时任务(工具描述可见)每次运行都启动一个全新会话,不带当前对话记忆,所以 prompt 必须自包含;只在应用打开时运行,错过的触发会在下次启动时补跑;支持一次性的绝对时间触发;循环任务派发时加几分钟的确定性延迟分散负载。这和教学版"共享会话、不补跑、只有 cron 表达式"正好相反。CLI 里会话级的定时工具及其过期机制属于推断。
  • 定时运行的权限:教学版非主线程一律拒绝。桌面端定时任务沿用该任务已授予的权限模式和工具批准(依据是运行接口的描述),具体判定逻辑属于推断。

自测

为什么 create_task 不允许传 blockedBy?只在系统提示里要求"分两步"不够吗?

参考:模型可能在同一次回复里并行发出多个 create_task,这些调用的参数在任何 tool_result 回来之前就定了,被依赖任务的 ID 由 harness 随机生成,此时还不存在,模型只能编一个。所以必须先建节点拿到真实 ID,下一轮再用 update_task 加边。提示词只是引导,模型可以不听;schema 里去掉字段并禁止额外字段是结构性约束,测试还把它锁死了。

一个任务的前置任务文件被误删,它还能被认领吗?这样设计的理由和代价是什么?

参考:不能。依赖门把文件不存在、内容损坏都算成"未完成",这是 fail-closed。依赖状态未知时放行,可能在前置条件不成立时开工,属于静默错误;拒绝只是卡住,看得见,可以人工修。代价是本章没有删除任务或移除依赖的工具,只能手改文件。

模型启动了一个后台测试,回复"跑完再告诉你",然后就没下文了。为什么?有哪些修法?

参考:通知只在循环每次调模型之前注入。这一轮模型没有工具调用,循环直接 return,回到等待用户输入,后台跑完也没有机制叫醒它,要等用户下次输入才会注入。修法有三类:后台完成时以合成消息重新进入循环(真实 Claude Code 的做法);在停止前检查是否还有运行中的后台任务,有就提醒并继续;或者提供等待、查询类工具。

s12 为什么先落盘再入队,又为什么把 ack 放在模型第一次成功响应之后?这对定时 prompt 的写法提出了什么要求?

参考:先入队后落盘,两步之间崩溃会丢掉这次触发,因为这一分钟已经过去;先落盘则重启时能重新入队。消费时 ack 会在 API 失败时丢任务,整轮结束才 ack 会让长回合中途崩溃时整轮重做;第一次成功响应是能确认模型已看到 prompt 的最早时刻。整体是至少一次语义,"模型已响应"到"ack 落盘"之间崩溃会重投,所以 prompt 要设计成幂等的。

同样是异步投递,s11 选恰好一次,s12 选至少一次,为什么不一样?

参考:看失败后有没有恢复的可能,以及重复的代价。s11 的结果只在内存,进程挂了一起没,至少一次无从谈起,注入又几乎不会失败,pop 最简单。s12 的触发一旦丢了,用户设的"每天 9 点"就静默失效,所以落盘并在成功响应后才 ack,宁可偶尔重复一次,再用幂等的 prompt 把重复的影响消掉。

连接

课程仓库:https://github.com/shareAI-lab/learn-claude-code

这是一篇可以继续生长的笔记。沿着关系继续阅读 ↗