Skip to content

等待与唤醒 ​

任务一生中大部分时间都在等:等人的下一条消息、等批准、等提问的答复、等子代理、等定时器。本页讲东西进入任务的唯一途径(收件箱),任务怎样在不占工人的情况下等待、怎样被唤醒,以及两个例外:只活在某一个工人进程里的工作,和插件在后台做的模型调用。

收件箱 ​

除了持有认领的工人,谁都不会往任务的日志里写东西。其他人一律往任务的收件箱里投递,工人在下一步开始时把收件箱里的东西取走,在日志里记成一条 InputTaken(ADR-001)。

谁来投递 投递什么 人(经客户端) Say · Answer · Cancel 父任务 Say · Cancel 子任务结束 ChildEnded 到点的定时器 TimerFired 插件、外部渠道、信号 External 收件箱 按顺序,每个任务一个 #1 #2 #3 #4 未读 #5 未读 持有认领的工人 别人只能投递,不能取件 InputTaken(items, through) 任务日志 dedupe_key:重复投递 返回第一次的序号 投递会唤醒等待中的任务 (wake=False 时不唤醒) 每一项恰好被取一次 Cancel 还会打断正在进行的这一步

收件箱条目有六种(noeta/core/events.py):

条目谁来投递取走后的效果
Say(message, when)人(client.send)、父任务、交回报告的子代理、插件的通知给代理的一条消息。when="next_turn"(默认)排队到这一轮结束;when="now" 在下一步加入正在进行的这一轮。
Answer(request_id, decision, always, data)人,通过前端(client.answer)答复一个批准请求(allow / deny,可带 always)或一个提问(data)。
Cancel(reason, turn_only)人(client.cancel)、父任务结束任务;带 turn_only 时只叫停当前这一轮。
ChildEnded(child_id, status, ...)子任务,和它自己的结束在同一个事务里告诉父任务某个子代理结束了,附上答案或错误,以及它用了多少资源。
TimerFired(name)工人,为到点的定时器投递触发定时器;定时器带了文字的话,文字作为一条消息到达。
External(name, data)插件、外部渠道、client.signal一个有名字的信号,由某个插件读取(切换权限模式、后台任务结束、换模型)。

为什么只留一个入口:这样每个前端、父任务、定时器、插件都排在同一个有序队列里,不存在第二条可以和工人抢先后的路;任务的日志也清清楚楚地记着它看到了什么、按什么顺序看到的。

投递可以去重。投递时可以带一个 dedupe_key:同样的键再投一次什么都不做,返回第一次的收件箱序号。凡是崩溃后可能重复投递的地方,内核都用了它:答复用 answer:<请求 id>,子任务结束用 child:<任务 id>,定时器触发用 timer:<名字>:<序号>。前端重试 send 时,自己带一个键。

唤醒。往等待中的任务投递,会在同一个事务里把它变成 ready;带 wake=False 的投递除外(不值得专门开一轮的消息,比如归档,等任务下次被唤醒时再读)。反过来,工人要去等待时收件箱里还有没读的条目,存储就让任务留在 ready。两头一起,投递就不可能掉进“工人刚看过”和“任务睡下了”之间的缝里:在 Postgres 上,投递和进入等待的追加都会锁住任务那一行,所以一定是一个严格地先于另一个。

恰好取一次。InputTaken.through 是取到的最后一个收件箱序号,下一次从日志折叠出这个位置,接着往后取。任务行里的 inbox_read_seq 要等工人把任务交回(进入等待或结束)时才跟上,存储正是靠它在那一刻看出还有没有没读的条目。投递之后、取走之前崩溃,条目留给下一个认领者;取走之后崩溃,条目已经在日志里了。

停下来等待:不占工人 ​

需要等外部某件事的工具调用会停下来等待(park)。它的工具不返回结果,而是返回 ToolOutput(park=Park(on=..., keys=...)):等 "question"(人的答复,以调用 id 为键)或者等 "children"(它创建的子代理,以子任务 id 为键)。工人记下哪个调用在等什么(core/parked),等这次回复里没有别的可以执行了,就追加 Waiting(on=..., ids=..., detail=...)。批准也是一回事,只不过是关卡而不是工具让它停下的:Waiting(on="approval")。

这次追加在同一个事务里交还认领。任务处于 waiting,没有工人持有它,也没有哪个进程为它留着任何东西。它可以等一分钟,也可以等一个月,不花任何资源;所有工人重启也无妨;答复可以从另一台机器来。

工人 A 任务行 人 工人 B 工具返回 park Parked 记录 追加 Waiting 工人 A 去跑别的任务 running · A · epoch 4 waiting(等提问)· 无持有者 · 无租约 ready running · B · epoch 5 几分钟或几天:不占任何资源 前端显示问题 client.answer 投递 Answer · 唤醒 认领 InputTaken(Answer) → tool.resume → ToolFinished → …

答复一到,投递把任务变成 ready;任意一个工人认领它、折叠日志、取走 Answer,发现那个等待中的调用等的答复到了(ResumeCalls),就把答复交给工具的 resume(input, ctx, outcome)。工具像从没停过一样把活干完,它的 ToolFinished 照常追加。

几条规则让这件事可预期:

  • 关卡停在第一个询问处。同一次回复里,排在等批准的调用后面的调用,既不执行也不拒绝,等批准答复了再过关卡。排在它前面的调用照常执行。
  • 唤醒了却没带来新东西(投递的不是那个调用在等的东西):任务照旧交回、继续等,不会把同样的 Waiting 再写一遍。
  • 没人可问。配置里写了 unattended 的任务(后台子代理)碰到提问或批准,这一轮以 needs_human 失败,而不是永远等下去。
  • 前端知道该显示什么。Waiting.detail 按调用 id 带着批准的提示语,或者提问的问题和选项。所有前端都按这同一个格式(noeta/core/questions.py)显示提问,用不着引用提问的那个插件。

向人提问 ​

插件要提问,就停在 noeta/core/questions.py 的 ask(...) 上:一个或多个问题,每个可以带选项、可以多选。人的答复以 Answer(request_id=<调用 id>, data={"answers": ...}) 回来,工具的 resume 用 read_answer 读取。只是告诉人一件事、不需要回答,用 notify(...):写一条 core/notice 记录,前端会显示,模型永远看不到。两者都不会被模型当成人说的话。AskUserQuestion 就是这样做的(ask_user 插件)。

子任务结束 ​

子代理是一个子任务(带 parent_id、root_id),和父任务那次工具调用的结果(或停下来等待)在同一次追加里创建,所以重试的调用永远不会造出第二个子任务。前台的 Agent 调用会停下来等它的子任务;每个子任务结束时,在写自己的 TaskEnded 的同一个事务里,往父任务投递 ChildEnded(去重键 child:<id>)。调用等的所有子任务都报到之后,调用带着它们的答案继续。后台子代理的调用会立刻返回,它的 ChildEnded 稍后作为消息送到父任务。父任务结束时,还活着的子任务都会被取消。细节见子代理与后台任务。

定时器 ​

插件或工具通过自己的 view 或工具上下文调用 set_timer(name, at, text) 设定时器(写一条 core/timer 记录;clear_timer 删除)。定时器是记在日志里的内核状态,所以就算所有工人都重启过,预定的唤醒也不会丢。

任务闲下来时,工人写 Waiting(on="input", until=<最早的定时器>),存储随之设置这一行的 ready_at。时间一过,任务就可以被认领;哪个工人认领了它,fire_timers(noeta/core/runner/timers.py)就投递 TimerFired(name),去重键是 timer:<名字>:<设定它的那条记录的序号>,所以投递和取走之间崩溃也没关系。定时器的文字作为下一轮的消息到达。

定时器只在任务空闲时触发。在一轮进行中到点的定时器——包括这一轮停下来等批准、等提问、等子任务的时候——会在这一轮结束时触发一次,和 Claude Code 只在空闲时触发预定提示一样。schedule 插件的 ScheduleWakeup 和 cron 工具、SendMessage 空闲通知的过期、压缩插件的空闲检查,用的都是这套定时器。

存储按自己的时钟(Postgres 上是数据库的时钟)判断“到点”,可能和工人的时钟不一致。如果工人被叫醒去处理一个按它自己时钟还没到点的定时器,它会在这次认领里按存储的时间把到点的定时器触发一次,而不是把同样的 Waiting 再写一遍、再被叫醒,陷入循环。

保留认领 ​

停下来等待之所以可行,是因为任务需要的一切都在日志里。有些工作不是这样:一个后台 Bash 任务、一个 Monitor、一次本身正在等人答复的 MCP 调用。这些工作活在某一个工人的进程里,搬不走。如果任务照常回到存储,别的工人认领它时会发现根本没有这个后台任务。

所以,启动这类工作的工具会保留任务的认领(ADR-029)。ToolContext.keep() 返回一个 KeptClaim;只要还有一个没放掉,工人交回等待中的任务时就带上 Patch.keep。这一行显示 waiting,但持有者、epoch 和租约都留着。工人继续续租,并且自己盯着这个任务的收件箱(Runtime.run_claim 返回,这次运行转到后台继续,只在被唤醒、真正干活的时候才占用工人 concurrency 里的一个空位)。认领会跳过租约还没过期的行,所以别的工人不会去跑这个任务,投递给它的东西也由同一次运行取走。等到没有东西再保留认领(后台任务结束、结果也取走了),这次运行交还认领,任务像其他任务一样等待。

工人 A 任务行 Bash 后台运行 ctx.keep() Waiting + 保留 每 1/3 租期续租一次 自己取这个任务的收件箱 后台任务结束: 投递到收件箱 接着在 工人 A 上跑 running · A waiting · 持有者 A · 租约保留 running · A 如果工人 A 中途死掉 工人 A 任务行 工人 B Bash 后台运行 · keep 被杀掉 waiting · 持有者 A · 租约保留 租约过期:立刻可被认领 认领(epoch + 1) 这里没有那个后台任务 投递 JOBS_LOST 告诉模型:后台任务丢了

出问题时:

  • 工人死了。它保留的租约过期,等待中的任务立刻可被认领。下一个认领者手里没有那个后台任务;shell 插件在 on_start 里发现这一点,在收件箱已有内容之后投递 JOBS_LOST,于是模型被告知后台任务丢了,而不是一直傻等。后台任务的看守每过三分之一租期删一次它的 .alive 文件,发现文件已经不在了就杀掉任务,所以卡住的工人的后台任务,最迟在别的工人能宣布它丢失的时候就已经死了。
  • 工人正常停机。Runtime.let_go 在退出 serve 时放掉所有保留的认领:后台任务随之结束,任务继续等待,下次被唤醒时会得知这件事。
  • 工人卡住超过租期,后来又醒了。它发现认领已经被别人拿走(每次写入都受 epoch 约束,见恢复),于是停掉自己保留着的东西。

这是按任务算的,不是按任务树。任务的后台任务归它自己;父任务要停掉子任务的后台任务,就往子任务的收件箱投递;任务结束时它的后台任务都会被杀掉(子代理是在交回报告时)。一个保留着认领在等待的任务,开销是每个周期一次续租和一次收件箱检查。

被否掉的方案:每轮结束就杀掉所有后台任务(简单,但每个后台开发服务器都会在一轮结束时死掉,单机也一样);把后台任务交给别的工人(进程搬不走);以及更早的“hold”设计,把整棵任务树钉在一个工人上,每修一个问题就多一条规则,最后被现在这套取代。

插件在后台调用模型 ​

有些插件会在对话之外调用模型:压缩插件在任务继续干活的同时提前写好摘要(Claude Code 的预先压缩),标题插件给任务起名。在一步之外算出来的结果,不能由算它的人写进日志(那就成了第二个写入者),而一步也绝不能依赖只存在于某个进程内存里的东西。

所以这类工作经由收件箱回来(ADR-031):

  1. 插件在某个 hook 里启动调用,只记下一个随机的工作 id。
  2. 调用完成后,把结果(以及花了多少)作为一个 External 信号投递到任务自己的收件箱。
  3. 工人在下一步开始时像取其他输入一样取走它;如果它对应的正是日志里记着还在跑的那个工作 id,插件的 on_event 就把它记下来。其他的(过时的,或者别人投递的)只记下花费。从这时起,它就是普通的折叠状态。
  4. 记下的结果写明它是根据什么算的(摘要会写明压缩次数,以及它覆盖的那些消息的摘要哈希)。要用它的那一步拿这些信息和当前状态比对,不一致就不用。

工人在调用中途死掉,这次调用就丢了,重新启动一次即可。结果投递到正在等待的任务,会把它唤醒一次,好把结果记下来。压缩这一侧的细节见上下文、缓存与压缩。

取消与叫停 ​

两者都是收件箱条目,而且都生效得很快。投递一条 Cancel 后,工人会听到(来自存储的变更通知,或者每隔 poll_interval 查一次收件箱;变更通知能跨机器送达的存储上,改成每三分之一租期查一次),立刻取消正在进行的这一步:正在流式返回的模型调用、正在执行的工具都会马上停下。然后在下一步开始时取走这条:

  • Cancel 让任务以 cancelled 结束。还活着的子任务也会收到 Cancel,父任务收到 ChildEnded(status="cancelled")。
  • Cancel(turn_only=True)(人按了 Esc)只叫停这一轮:没结束的调用得到“已中断”的答复,停下来等待的调用被撤回,它们在等的子任务被取消。如果回复还在流式输出,已经出来的文字留在对话里,后面跟着 Claude Code 的 [Request interrupted by user] 标记。任务等待下一条消息;和叫停一起发来的消息,会在新的一轮里得到回应。

相关 ​