Skip to content

fix(runtime): concurrent background tasks under one WorkItem — the N−1 unwaited task results are silently dropped as canonical_task_rejoin_stale (51 in 7 days, exit=0 output never delivered) #2967

Description

@tuptupbot

Summary

当一个 WorkItem 下并发启动多个后台任务(ExecCommand 自动晋升的 command_task)、而 agent 只对其中一个调用 WaitFor(wake=task_result) 时,其余任务即使 status=completed、exit_status=0,它们的 TaskResult 消息也会被 provably_stale_task_rejoin() 判为「可证明过期」,走 CanonicalClaimOutcome::RejectQueued { reason: "canonical_task_rejoin_stale" } 被静默丢弃:不产生 turn、不通知 agent、tasks.last_turn_id 永远为 NULL。成功的工具输出就这样消失了,而调度器只留下一条 InvariantViolation 诊断。

这不是边缘状态:本机生产环境近 7 天 51 次,每天都有,占全部 InvariantViolation 的 91%(51/56)。

决定性证据(production,holon 0.39.0,agent tuptup-assistant,WI work_94bf57719721264)

9 秒内启动 4 个并发命令任务,随后只等待了其中 1 个:

tasks:
  task_79bfa5ff85fe9c1 created=2026-09-12T17:02:42Z completed=17:05:08Z exit=0 last_turn=NULL
  task_1f968a1f4850a1a created=2026-09-12T17:02:45Z completed=17:05:18Z exit=0 last_turn=NULL
  task_0cdf577cac7d245 created=2026-09-12T17:02:48Z completed=17:14:44Z exit=0 last_turn=NULL
  task_9e738b250e5739b created=2026-09-12T17:02:51Z completed=17:10:26Z exit=0 last_turn=NULL

wait_conditions(该时段唯一一条):
  wait_0c0068e2f14b4bf  status=resolved  kind=task  work_item_id=work_94bf57719721264
  created=2026-09-12T17:02:59Z
  wake_sources=[{"kind":"task_result","task_id":"task_0cdf577cac7d245"}]

消息与轮次的对应关系:

任务 TaskResult 消息 分配的 turn_id turn_records 是否存在 结果
task_79bfa5ff85fe9c1 msg_a23bfc242ca4bed 17:05:08Z turn_ee200e4e6dcf57f 否 丢弃
task_1f968a1f4850a1a msg_f5fa0ff6d48aef9 17:05:18Z turn_9a9d4e5df7321b1 否 丢弃
task_9e738b250e5739b msg_f85981eaa164ac6 17:10:26Z turn_18fc0a648d9c943 否 丢弃
task_0cdf577cac7d245(被等待的那个) msg_511646b51213ced 17:14:44Z turn_480a80c16281bf9 是,17:21:17Z 起跑 正常
SELECT count(*) FROM turn_records
 WHERE turn_id IN ('turn_ee200e4e6dcf57f','turn_9a9d4e5df7321b1','turn_18fc0a648d9c943');
-- 0

调度器诊断(audit_events.kind='scheduler_diagnostic',data.decision='InvariantViolation')三条都带同一组证据字段:

{"reason": "canonical_task_rejoin_stale", "boundary": "run_loop",
 "evidence": ["message_kind=TaskResult",
              "message_origin=Task { task_id: \"task_79bfa5ff85fe9c1\" }",
              "authority_class=RuntimeInstruction",
              "delivery_surface=Some(TaskRejoin)",
              "admission_context=Some(RuntimeOwned)",
              "queue_disposition=dropped"],
 "work_item_id": "work_94bf57719721264", "task_id": null}

注意 "task_id": null —— 诊断本身也没有把受害 task 记进结构化字段,只能从 evidence 字符串里抠,所以这类丢失在数据库里几乎不可查询。

同日同因的另两例(不同 agent、不同 WI,说明与具体业务无关):

  • tuptup-reviewer 2026-09-13T03:56:31Z,task_7ddafa14eb5949d(created 03:49:29Z,completed,exit=0),WI work_e0ab3e14bfd63ec
  • tuptup-tester 2026-09-13T05:59:13Z,task_f07cb31e0a80687(created 05:54:56Z,completed,exit=0),WI work_13763faa7585025

规模(近 7 天,2026-09-06T16:00Z → 09-13T16:00Z,按 agent 循环走 (agent_id, created_at) 索引)

InvariantViolation reasons:
  canonical_task_rejoin_stale                        51   ← 本 issue
  canonical_work_item_not_open_same_agent             3
  canonical_autonomous_work_item_revision_stale       1
  canonical_internal_followup_work_item_not_runnable  1

canonical_task_rejoin_stale by agent:
  tuptup-reviewer 32 · tuptup-assistant 10 · holon-ops 4 · xinghai-pm 3 · tuptup-tester 2
by UTC day: 09-06:1  09-07:19  09-08:6  09-09:3  09-10:6  09-11:8  09-12:6  09-13:2

根因分析(源码,tag v0.39.0;src/runtime/tasks.rs 自 v0.39.0 起无提交,origin/main@2d144f3 同样存在)

src/runtime/scheduler_executor.rs:922 provably_stale_task_rejoin():

let Some(work) = execution.work_items.get(work_item_id) else { ... };
let WorkItemExecutionState::Waiting { wait, .. } = &work.state else { return Ok(false) };
// 以及 Open 分支里的:
let exact_wait_exists = ... latest_wait_conditions_for_agent(agent_id) ... .any(|c|
        c.work_item_id == Some(work_item_id)
     && matches!(c.status, Active | Triggered | Resolved)
     && c.kind == WaitConditionKind::Task
     && c.wake_sources.iter().any(|s| matches!(s, WakeSource::TaskResult { task_id } if task_id == task_id)));
return Ok(!exact_wait_exists);

判定「过期」的依据是该 WorkItem 上是否还存在一条精确匹配这个 task_id 的 durable task wait。而等待模型是 一个 WorkItem 一条 wait(Waiting { wait } 只有一个 wait_id,wake_sources 里只放 agent 当时选择的那个 task)。于是:

并发启动 N 个任务、只 WaitFor 其中 1 个 ⇒ 另外 N−1 个任务的结果必然判为 stale ⇒ 必然被丢弃。

丢弃点在 scheduler_executor.rs:1086:

if stale_task_rejoin {
    return Ok(CanonicalClaimOutcome::RejectQueued { scenario_class, reason: "canonical_task_rejoin_stale" });
}

关键矛盾:紧邻上方(:1081-1085)已经存在一条优雅降级路径——

if original_candidate == CanonicalActivationCandidate::UnboundTaskResultWaitOrReduce {
    return Ok(CanonicalClaimOutcome::ReduceOnly);
}

也就是说「没有绑定 wait 的 task result」会被 reduce 进上下文而不是丢弃;但「绑定了 ExactTaskRejoin、只是那条 wait 已被别的 task 占位」的情形却直接 drop。同样是成功完成的任务输出,待遇相反。

这与 agent 被教导的用法直接冲突:ExecCommand 文档明确说长命令会自动晋升为 command_task,tool_wait_for 也要求「等普通完成就用 WaitFor(wake=task_result)」——并发启动多个后台任务、只显式等待一个,是完全正常且被鼓励的写法。

Impact

  1. 成功的工作成果静默丢失:exit=0 的命令输出永远不进入模型上下文。agent 无从得知,通常表现为「以为还在等」或基于缺失数据下结论,或重复执行同一命令(我们日报的 tools.tool_level_waste 里就有同 turn 重复调用 ExecCommand 的样本)。
  2. 不可查询:tasks.last_turn_id 保持 NULL、turn_records 无对应行、scheduler_diagnostic.data.task_id 为 null(task 只出现在 evidence 字符串里)。运维侧只能靠 InvariantViolation 计数感知,无法定位是哪个任务丢了。
  3. InvariantViolation 语义被稀释:91% 的 invariant violation 其实是这个可预期的并发模式,掩盖了真正异常的 canonical_work_item_not_open_same_agent 等信号。

Suggested fix

按侵入性从小到大:

  1. stale 的 task result 降级为 ReduceOnly 而不是 RejectQueued(最小改动、与既有 UnboundTaskResultWaitOrReduce 分支一致):任务已 completed 且有输出时,把结果 reduce 进当前/下一轮上下文,保证不丢数据。若确需拒绝,至少把输出落到 agent 可读的位置。
  2. 允许一个 WorkItem 持有多个 task wait / 多个 wake_sources,或让 WaitFor(wake=task_result) 在存在同 WI 其他未决 task 时自动纳入,从根上消除「N−1 必然 stale」。
  3. 把受害 task 写进结构化字段:scheduler_diagnostic.data.task_id 填上真实 task(现在是 null),并为丢弃单独发一条可查询的 audit event(例如 task_result_dropped,含 task_id/exit_status/output_path),使「丢了多少成功输出」可被统计。
  4. 若 1–3 都不可行,请在 WaitFor/ExecCommand 的工具文档里明确禁止「同 WI 并发多任务只等一个」,并让 WaitFor 在这种情况下返回显式错误,而不是让运行时事后静默丢弃。

可能与已合并的 #2949 相关

origin/main 上 0563b8fb fix: recover interrupted canonical lifecycle deliveries (#2949) 处理的是 interrupted reentry 沿 canonical activation root 的 recovery chain 解析。如果它也改动了 ExactTaskRejoin 的 stale 判定或 drop 行为,请在本次修复中一并说明;我们在升级到含 #2949 的版本后会用同一组查询复测(判据:canonical_task_rejoin_stale 计数、以及 tasks.last_turn_id IS NULL AND status='completed' 的行数)。

Environment

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions