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
- 成功的工作成果静默丢失:
exit=0 的命令输出永远不进入模型上下文。agent 无从得知,通常表现为「以为还在等」或基于缺失数据下结论,或重复执行同一命令(我们日报的 tools.tool_level_waste 里就有同 turn 重复调用 ExecCommand 的样本)。
- 不可查询:
tasks.last_turn_id 保持 NULL、turn_records 无对应行、scheduler_diagnostic.data.task_id 为 null(task 只出现在 evidence 字符串里)。运维侧只能靠 InvariantViolation 计数感知,无法定位是哪个任务丢了。
InvariantViolation 语义被稀释:91% 的 invariant violation 其实是这个可预期的并发模式,掩盖了真正异常的 canonical_work_item_not_open_same_agent 等信号。
Suggested fix
按侵入性从小到大:
- stale 的 task result 降级为
ReduceOnly 而不是 RejectQueued(最小改动、与既有 UnboundTaskResultWaitOrReduce 分支一致):任务已 completed 且有输出时,把结果 reduce 进当前/下一轮上下文,保证不丢数据。若确需拒绝,至少把输出落到 agent 可读的位置。
- 允许一个 WorkItem 持有多个 task wait / 多个
wake_sources,或让 WaitFor(wake=task_result) 在存在同 WI 其他未决 task 时自动纳入,从根上消除「N−1 必然 stale」。
- 把受害 task 写进结构化字段:
scheduler_diagnostic.data.task_id 填上真实 task(现在是 null),并为丢弃单独发一条可查询的 audit event(例如 task_result_dropped,含 task_id/exit_status/output_path),使「丢了多少成功输出」可被统计。
- 若 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
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,WIwork_94bf57719721264)9 秒内启动 4 个并发命令任务,随后只等待了其中 1 个:
消息与轮次的对应关系:
turn_records是否存在task_79bfa5ff85fe9c1msg_a23bfc242ca4bed17:05:08Zturn_ee200e4e6dcf57ftask_1f968a1f4850a1amsg_f5fa0ff6d48aef917:05:18Zturn_9a9d4e5df7321b1task_9e738b250e5739bmsg_f85981eaa164ac617:10:26Zturn_18fc0a648d9c943task_0cdf577cac7d245(被等待的那个)msg_511646b51213ced17:14:44Zturn_480a80c16281bf9调度器诊断(
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-reviewer2026-09-13T03:56:31Z,task_7ddafa14eb5949d(created 03:49:29Z,completed,exit=0),WIwork_e0ab3e14bfd63ectuptup-tester2026-09-13T05:59:13Z,task_f07cb31e0a80687(created 05:54:56Z,completed,exit=0),WIwork_13763faa7585025规模(近 7 天,2026-09-06T16:00Z → 09-13T16:00Z,按 agent 循环走
(agent_id, created_at)索引)根因分析(源码,tag v0.39.0;
src/runtime/tasks.rs自 v0.39.0 起无提交,origin/main@2d144f3 同样存在)src/runtime/scheduler_executor.rs:922provably_stale_task_rejoin():判定「过期」的依据是该 WorkItem 上是否还存在一条精确匹配这个
task_id的 durable task wait。而等待模型是 一个 WorkItem 一条 wait(Waiting { wait }只有一个wait_id,wake_sources里只放 agent 当时选择的那个 task)。于是:丢弃点在
scheduler_executor.rs:1086:关键矛盾:紧邻上方(
:1081-1085)已经存在一条优雅降级路径——也就是说「没有绑定 wait 的 task result」会被 reduce 进上下文而不是丢弃;但「绑定了 ExactTaskRejoin、只是那条 wait 已被别的 task 占位」的情形却直接 drop。同样是成功完成的任务输出,待遇相反。
这与 agent 被教导的用法直接冲突:
ExecCommand文档明确说长命令会自动晋升为command_task,tool_wait_for也要求「等普通完成就用WaitFor(wake=task_result)」——并发启动多个后台任务、只显式等待一个,是完全正常且被鼓励的写法。Impact
exit=0的命令输出永远不进入模型上下文。agent 无从得知,通常表现为「以为还在等」或基于缺失数据下结论,或重复执行同一命令(我们日报的tools.tool_level_waste里就有同 turn 重复调用ExecCommand的样本)。tasks.last_turn_id保持 NULL、turn_records无对应行、scheduler_diagnostic.data.task_id为null(task 只出现在evidence字符串里)。运维侧只能靠InvariantViolation计数感知,无法定位是哪个任务丢了。InvariantViolation语义被稀释:91% 的 invariant violation 其实是这个可预期的并发模式,掩盖了真正异常的canonical_work_item_not_open_same_agent等信号。Suggested fix
按侵入性从小到大:
ReduceOnly而不是RejectQueued(最小改动、与既有UnboundTaskResultWaitOrReduce分支一致):任务已completed且有输出时,把结果 reduce 进当前/下一轮上下文,保证不丢数据。若确需拒绝,至少把输出落到 agent 可读的位置。wake_sources,或让WaitFor(wake=task_result)在存在同 WI 其他未决 task 时自动纳入,从根上消除「N−1 必然 stale」。scheduler_diagnostic.data.task_id填上真实 task(现在是null),并为丢弃单独发一条可查询的 audit event(例如task_result_dropped,含task_id/exit_status/output_path),使「丢了多少成功输出」可被统计。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
0.39.0 (9ac7d26),源码对照 tagv0.39.0与origin/main@2d144f3daudit_events全表扫)与原始输出可提供PickWorkItem永久失败)、observability(provider): failed attempts (timeout / retry / deferred fallback) never reach transcript or audit evidence — retry & fallback metrics are structurally zero #2942(provider 失败 attempt 无结构化证据)