Repository navigation
bug(runtime): a refusal that names the violation but not the exit left a completed task pending forever #2310
Description
In a real session on a packaged build, a task that the model had actually completed was left pending in the ledger forever — because the refusal that blocked it named the rule it broke and not the one call that would have satisfied it. The model read the refusal, could not derive the exit, and abandoned the task.
This is the same shape as #2234, one layer up. #2234 was about a refusal never reaching the model at all. This is about a refusal reaching the model and being useless when it arrives.
What happened
Session a34d6343, run 86204787 (packaged macOS arm64 build, DeepSeek V4 Pro). The model created an 8-task plan and worked it. Task T7 = 第5步:五清扫sweep.
It did the work — ran the sweep script, got a clean result — and then:
seq 223 function_call task_update {"id":"T7","status":"completed",
"completionEvidence":"五清扫通过,零残留"}
seq 225 function_response task_update isError: true
"Invalid task status transition from pending to completed"
seq 228 function_call task_update {"id":"T8","status":"in_progress"}
That is the entire refusal text the model received. Not a truncation — the full result.text.
T7 was never started, so pending → completed is genuinely not allowed. The fix is one call: task_update {id: "T7", status: "in_progress"} first. The message does not say that. It does not name in_progress, does not name the tool, does not say a transition exists that would work.
The model's response was to skip T7 and move on. Replaying workflow_task_ledger_events to final state:
第0步:标的定性与中立数据包 in_progress
第1步:三框架盲测 completed
第2步:crux定向核实 completed
第3步:裁决与定价 completed
第3.5步:红队自攻 completed
第4步:撰写并生成.docx completed
第5步:五清扫sweep pending ← the work was done
第6步:交付与台账登记 completed
The ledger now asserts step 5 never happened. It did happen; the evidence string the model tried to attach describes it. The ledger's single job is to hold the plan's true state across a volatile context, and a refusal message caused it to hold a false one.
This model was not confused about the protocol in general — it used in_progress → completed correctly for T1, T2, T3, T4, T5, T6 and T8. It slipped once on T7 and the refusal gave it nothing to recover with.
The same session, the same shape, one turn earlier
seq 149 function_call task_update {"id":"T4","status":"completed"}
seq 150 function_response "Tool \"task_update\" arguments failed validation: [
{ \"code\": \"custom\",
\"message\": \"completionEvidence is required when status is completed.\",
\"path\": [\"completionEvidence\"] } ]
task_update takes `id`, `status`, `subject`, `blockedReason`,
`failureReason`, `completionEvidence`, `explicitReopen`."
seq 153 function_call task_update {"id":"T4","status":"completed",
"completionEvidence":"裁决完成:…"}
This one the model recovered from immediately — because the message names the field. That contrast is the whole point: same tool, same session, same model. The recoverable refusal named a thing to do; the unrecoverable one named only a thing that was wrong.
Note also that neither message mentions the other rule. The model learned "completion needs evidence" at seq 150, applied it correctly at seq 223, and walked straight into the second rule it had no way to know about.
Still-unfixed instance of the message #2240 replaces
packages/runtime/src/subagent-tools.ts:142 — agent_spawn carries verbatim the message that #2240 replaces in agent_swarm:
if (Boolean(input.profile) === Boolean(input.subagent_id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Provide exactly one of subagent_id or legacy profile.',
});Three separate dead ends in one sentence: it does not distinguish "you set neither" from "you set both", it does not list the legal profile values, and it does not say where a subagent_id comes from (agent_list). #2240 fixed exactly this in agent-swarm-tools.ts and deliberately scoped agent_spawn out; this is that follow-up. It is a small, self-contained change and the agent_swarm version can be lifted directly.
Suggested rule
A refusal the model is expected to recover from should name a next action, not only a violated constraint. Concretely, for these three:
| Site | Now | Should also say |
|---|---|---|
packages/core/src/task-ledger.ts:460 |
Invalid task status transition from pending to completed |
that pending → in_progress → completed is the path, i.e. call task_update with in_progress first |
packages/runtime/src/subagent-tools.ts:142 |
Provide exactly one of subagent_id or legacy profile. |
which mistake was made, the legal profile values, and that subagent_id comes from agent_list |
packages/runtime/src/task-ledger-tools.ts:214 |
completionEvidence is required when status is completed. |
— already fine; kept here as the control case |
The transition message is the one that cost a task, so it is the one worth fixing first.
Interaction with #2290
#2290 proposes cutting the transition whitelist and completionEvidence outright ("Transition whitelist + explicitReopen + evidence rules — cut, validate enum membership only"). If that lands as specified, the task-ledger.ts:460 message disappears rather than improves, and this issue's first row becomes moot.
I am not proposing to pre-empt that decision. Two things are worth saying either way:
- This is a data point for RFC: Radically slim the Session Task Ledger #2290's open question 4, not against it. A rule the authority enforces and cannot explain is a rule that costs real state. That is an argument for cutting or for explaining — but not for leaving it as is.
agent_spawnis outside RFC: Radically slim the Session Task Ledger #2290 entirely and can be fixed today regardless of how the RFC resolves.
If #2290's P1 is close, fixing only subagent-tools.ts and letting the transition message die with the whitelist is the cheaper path. If P1 is not close, the transition message is a one-line change that stops a live loss.
What I verified
- Everything above is read from a packaged build's
runtime.sqlite:runtime_eventsfor the exact sequence numbers quoted,workflow_task_ledger_eventsreplayed for the final state table. - The refusal texts are the complete
result.textvalues the model received, not summaries. - I did not reproduce this deliberately — it surfaced while verifying fix(runtime): decide the tool call event's ledger lane at push time #2240 against a packaged build. n=1 for the abandonment behaviour specifically; a different model might have recovered.
- The
agent_spawnmessage is a static read of currentmain, not an observed failure.
简体中文要点
在打包版本的一次真实会话里,一个模型确实做完了的任务被永久留在账本的 pending 状态——因为挡住它的那句拒绝只说了违反哪条规则,没说哪一个调用能满足它。模型读完拒绝,推不出出路,直接放弃了这个任务。
这和 #2234 是同一个形状,只是高了一层:#2234 是拒绝根本到不了模型;这条是拒绝到了模型手里,但没有用。
经过:会话 a34d6343,run 86204787。模型建了 8 步计划,T7 = 第5步:五清扫sweep。它跑完了 sweep 脚本,拿到干净结果,然后:
seq 223 task_update {"id":"T7","status":"completed","completionEvidence":"五清扫通过,零残留"}
seq 225 → isError: "Invalid task status transition from pending to completed"
seq 228 task_update {"id":"T8","status":"in_progress"}
那就是模型收到的全文,没有截断。T7 确实没 start 过,所以这个跃迁本来就不合法,正确解法只需要一次调用:先 task_update {id:"T7", status:"in_progress"}。而这句话里没有 in_progress,没有工具名,也没说存在一条走得通的路径。模型的反应是跳过 T7 直接做 T8。
重放 workflow_task_ledger_events 的终态:第5步 pending,而它前后的第4步、第6步都 completed。账本现在断言第5步没做过——它做过,模型试图附上的那段证据就是在描述它。账本唯一的职责是在易失上下文之外持有计划的真实状态,而一句拒绝文案让它持有了假状态。
这个模型并不是不懂协议:T1–T6、T8 它都规规矩矩走了 in_progress → completed。它在 T7 上失手一次,而拒绝没给它任何可恢复的东西。
同一会话、前一轮的对照组:seq 149 漏了 completionEvidence,拒绝里点了字段名,模型 seq 153 立刻补上改对。同一个工具、同一个会话、同一个模型——能恢复的那句说了"该做什么",不能恢复的那句只说了"什么错了"。另外两条规则互不提及:模型在 seq 150 学会"完成要证据",在 seq 223 正确用上,然后一头撞进它无从得知的第二条规则。
#2240 换掉的那句文案,agent_spawn 还原样留着:subagent-tools.ts:142 的 Provide exactly one of subagent_id or legacy profile.——一句话里三个死胡同:不区分"一个都没给"和"两个都给了"、不列出合法的 profile 值、不说 subagent_id 从哪来(agent_list)。#2240 在 agent-swarm-tools.ts 里修的就是这一条,并明确把 agent_spawn 划到范围外,这就是那个后续项,改动很小,可以直接照搬。
建议的规则:期望模型自行恢复的拒绝,应当指出下一个动作,而不只是被违反的约束。三处对照见上表——task-ledger.ts:460 是真的赔掉了一个任务的那条,优先级最高。
与 #2290 的关系:#2290 提议直接砍掉迁移白名单和 completionEvidence。若按原案落地,task-ledger.ts:460 这句是消失而不是改进,本 issue 的第一行就作废。我不打算抢在那个决定前面,但两点无论如何成立:(1) 这是 #2290 开放问题 4 的一个数据点,不是反对意见——一条权威在执行却解释不了的规则会赔掉真实状态,这既可以论证"砍"也可以论证"讲清楚",但唯独不能论证"就这样放着";(2) agent_spawn 完全在 #2290 范围之外,今天就能修。
验证边界:以上全部从打包版 runtime.sqlite 读出;拒绝文案是模型收到的完整 result.text,不是摘要;不是刻意复现的,是在打包验证 #2240 时撞见的,放弃任务这个行为 n=1,换个模型未必如此;agent_spawn 那条是对当前 main 的静态阅读,不是观察到的失败。
Activity
me2seeks commentedon Aug 8, 2026
I can take this. I’ll make the task-transition refusal name the recoverable path and update the missing-selector guidance for
agent_spawnto match its currentsubagent_id-first semantics.