澄清转述 pending 不再被自己的任务身份校验判成 needs-reconciliation

D11 里 Runtime 代 Supervisor 汇总子 Agent 澄清问题时,pending 动作的 task 字段
存的不是本 run 的任务,而是一段 Runtime 生成的转述指令(provider_recovery.rs
的「子 Agent 需要用户澄清后才能继续。delegationId=…」),delegationId 就编码在
前缀之后,另有四处按这个前缀反解绑定关系。

但 validate_agent_runtime_pending_context 会拿 pending.task 与 runtime
.current_task 做相等比较,而落等待态的 persist_game_creator_agent_user_input_
wait 只改 status/phase/waiting_on,从不同步 current_task。其余五个 pending 创建
点传的都是 run 的真实 task,相等断言对它们成立;转述这一处永不可能成立。

后果是必现而非抖动:用户答完问题、观察已落盘(status=observed-approved)后,
resume 立刻被判 needs-reconciliation,整条立项策划链在第一次澄清就断。

实测证据(run 14):
  runtime.currentTask  = 做个横版像素解谜小游戏,主角是个能操控自己影子的小机器人。(29 字符)
  pending.task         = 子 Agent 需要用户澄清后才能继续。delegationId=delegation-ec059abd…(355 字符)
  任务日志 27 条全部是前者;plan 根没有 autonomous completion contract,
  autonomous_effective_root_task_at 两侧都走 fallback,比较退化成裸文本相等。

这条路径此前从未执行过——澄清信封在 run 11/12/13 一次都没触发,缺陷因此一直
藏在 0 轮问询后面。

只豁免文本相等这一条。agent/task_id/session/run/source 五项身份检查在它之前已
全部通过,轮次检查在它之后继续执行,转述 pending 本身也只能由 Runtime 在本 run
内生成,所以不放开任何跨 run 或跨身份的重放面。没有选择改 pending 的持久格式:
task 字段被当载荷用是既成事实,四处反解加在途 pending 迁移,风险与收益不成比例。

顺带把散落五处的同一段 strip_prefix 链收成 user_input.rs 的一个共用判据。抄本
之间一旦漂移,转述路径会静默失去与原 delivery 的绑定,那是比本次更难查的故障。

回归测试双向变异验证:恢复严格比较则转述被拒(红),整条删掉则被改写 task 的
pending 不再被拒(红)。受影响测试组单线程 124 passed / 0 failed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 05:19:34 +00:00
parent 7a01f944dd
commit ddb8eaec37
6 changed files with 125 additions and 27 deletions
@@ -391,7 +391,7 @@ pub(crate) fn ensure_static_delegate_user_input_wait_at_locked(
let question_binding = game_creator_agent_user_input_action_input_summary(&action.input)
.unwrap_or_else(|| "questionsSha256=unavailable".to_string());
let task = format!(
"子 Agent 需要用户澄清后才能继续。delegationId={}{question_binding}。请回答以下问题;回答完成后只创建一次 agent.delegate continuation,并将 repairOfDelegationId 与 continuationOfDelegationId 指向该原 delegation,同时提交 questionsSha256/answersSha256。",
"{AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX}{}{question_binding}。请回答以下问题;回答完成后只创建一次 agent.delegate continuation,并将 repairOfDelegationId 与 continuationOfDelegationId 指向该原 delegation,同时提交 questionsSha256/answersSha256。",
delivery.delegation_id
);
// Re-entry after a wake or restart may only reuse the exact request that
@@ -173,7 +173,20 @@ pub(in crate::agent) fn validate_agent_runtime_pending_context(
{
return Err("Agent Runtime 待确认动作身份与当前状态不匹配".to_string());
}
validate_agent_runtime_context_task_parameter(root, runtime, &pending.task)?;
// 转述 pending 是唯一一处 `task` 字段存的不是本 run 任务的动作:Runtime 代
// Supervisor 汇总子 Agent 澄清问题时,把「回答后创建唯一 continuation 委派」
// 那段指令连同 delegationId 写在这里(provider_recovery.rs),而 run 的
// current_task 始终是用户原始请求。其余五个 pending 创建点传的都是 run 的真实
// task,所以这条相等断言对它们成立,对转述则永不可能成立——D11 澄清路径实测
// 每一次都会在 resume 时被判成 needs-reconciliation,用户答案已落盘却无法继续。
//
// 这里只豁免文本相等这一条。pending 与 runtime 的 agent/task_id/session/run/
// source 五项身份检查在上面已经全部通过,轮次检查在下面继续执行,转述 pending
// 本身也只能由 Runtime 在本 run 内生成,所以豁免不放开任何跨 run 或跨身份的
// 重放面。
if !agent_runtime_pending_is_delegate_clarification_relay(pending) {
validate_agent_runtime_context_task_parameter(root, runtime, &pending.task)?;
}
if pending.loop_iteration != runtime.loop_iteration {
return Err(format!(
"Agent Runtime 待确认动作轮次与当前状态不匹配:pending={} state={}",
@@ -820,6 +833,88 @@ mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
/// D11 转述路径的回归锚点。
///
/// Runtime 代 Supervisor 汇总子 Agent 澄清问题时,pending 的 `task` 存的是转述
/// 指令(含 delegationId),而 run 的 current_task 始终是用户原始请求。此前这条
/// 相等断言对转述 pending 永不可能成立:用户答完后 resume 立刻被判
/// needs-reconciliation,答案已落盘却无法继续,整条立项策划链在第一次澄清就断。
/// 生产 run 11/12/13 没暴露,只是因为澄清信封从来没触发过。
#[test]
fn delegate_clarification_relay_pending_survives_the_task_identity_check() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after epoch")
.as_nanos();
let temporary = crate::tests::canonical_test_tempdir(&format!(
"clarification-relay-task-{}-{unique}-",
std::process::id()
));
let root = temporary.path().join("project");
init_local_game_project_at(&root, "project-1", "澄清转述任务身份项目")
.expect("project init");
let runtime = start_game_creator_agent_runtime_task_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"做个横版像素解谜小游戏",
"clarification-relay-run",
"agent-background-task",
"转述澄清问题",
vec!["等待用户回答后创建唯一 continuation 委派".to_string()],
)
.expect("start supervisor runtime state");
let action = AgentRuntimeToolAction {
tool: GAME_CREATOR_USER_INPUT_REQUEST_TOOL.to_string(),
reason: Some("代 Supervisor 汇总子 Agent 的澄清问题".to_string()),
input: serde_json::json!({"questions": []}),
};
let relay_task = format!(
"{AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX}delegation-abcquestionsSha256=fixture。请回答以下问题。"
);
let build = |task: &str, action: &AgentRuntimeToolAction| {
build_game_creator_agent_runtime_pending_tool_action(
&root,
&runtime,
task,
&AgentRuntimeToolPlan::default(),
&[],
&read_game_creator_agent_runtime_project_revision(&root)
.expect("read project revision"),
&build_repository_startup_context_at(&root)
.expect("repository context")
.fingerprint,
action,
0,
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT,
None,
)
.expect("build pending action")
};
let relay = build(&relay_task, &action);
assert!(agent_runtime_pending_is_delegate_clarification_relay(
&relay
));
assert_ne!(relay.task, runtime.current_task);
validate_agent_runtime_pending_context(&root, &runtime, &relay)
.expect("转述 pending 必须能通过身份校验,否则用户答案落盘后无法续跑");
// 豁免只覆盖转述本身。同一工具、同一 run,但 task 不是 Runtime 生成的
// 转述指令时,原有的相等断言必须照旧拒绝——否则这就是个洞而不是修复。
let forged = build("被改写的任务", &action);
assert!(!agent_runtime_pending_is_delegate_clarification_relay(
&forged
));
assert!(validate_agent_runtime_pending_context(&root, &runtime, &forged).is_err());
// 身份五项仍然照查:换一个 run_id 的转述 pending 不得被放行。
let mut cross_run = relay.clone();
cross_run.run_id = format!("{}-other", cross_run.run_id);
assert!(validate_agent_runtime_pending_context(&root, &runtime, &cross_run).is_err());
}
#[test]
fn agent_runtime_context_bundle_restores_pre_checkpoint_window_boundary() {
let unique = SystemTime::now()
@@ -710,12 +710,7 @@ pub(crate) fn plan_clarification_pending_requires_project_lock_at(
root: &Path,
pending: &AgentRuntimePendingToolAction,
) -> Result<bool, String> {
let Some(delegation_id) = pending
.task
.strip_prefix("子 Agent 需要用户澄清后才能继续。delegationId=")
.and_then(|value| value.split('').next())
.map(str::trim)
.filter(|value| !value.is_empty())
let Some(delegation_id) = agent_runtime_delegate_clarification_delegation_id(&pending.task)
else {
return Ok(false);
};
@@ -730,12 +725,7 @@ fn validate_plan_clarification_answer_for_pending_at_unlocked(
questions: &[AgentRuntimeUserInputQuestion],
answers: &BTreeMap<String, String>,
) -> Result<(), String> {
let Some(delegation_id) = pending
.task
.strip_prefix("子 Agent 需要用户澄清后才能继续。delegationId=")
.and_then(|value| value.split('').next())
.map(str::trim)
.filter(|value| !value.is_empty())
let Some(delegation_id) = agent_runtime_delegate_clarification_delegation_id(&pending.task)
else {
return Ok(());
};
@@ -1631,12 +1631,7 @@ pub(crate) fn static_delegate_clarification_pending_matches_delivery_at(
{
return Ok(false);
}
let Some(delegation_id) = pending
.task
.strip_prefix("子 Agent 需要用户澄清后才能继续。delegationId=")
.and_then(|value| value.split('').next())
.map(str::trim)
.filter(|value| !value.is_empty())
let Some(delegation_id) = agent_runtime_delegate_clarification_delegation_id(&pending.task)
else {
return Ok(false);
};
@@ -2564,7 +2564,7 @@ fn claimed_needs_user_input_delivery_becomes_one_supervisor_wait() {
);
let mut spoofed = first.clone();
spoofed.task = format!(
"子 Agent 需要用户澄清后才能继续。delegationId={delegation_id}questionsSha256={}。",
"{AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX}{delegation_id}questionsSha256={}。",
"a".repeat(64)
);
assert!(
@@ -2,6 +2,29 @@ use super::*;
use sha2::{Digest, Sha256};
pub(crate) const GAME_CREATOR_USER_INPUT_REQUEST_TOOL: &str = "user.input_request";
/// Runtime 代 Supervisor 汇总子 Agent 澄清问题时,pending 动作的 `task` 字段存的
/// 不是本 run 的任务,而是这段由 Runtime 生成的转述指令,delegationId 编码在前缀
/// 之后。系统里另外五处都要按这个前缀反解,此前各自抄了一份 `strip_prefix` 链;
/// 抄本之间一旦漂移,转述路径会静默失去与原 delivery 的绑定,所以收成一处。
pub(crate) const AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX: &str =
"子 Agent 需要用户澄清后才能继续。delegationId=";
/// 从转述 pending 的 `task` 里取回它绑定的原 delegationId;不是转述 task 返回 None。
pub(crate) fn agent_runtime_delegate_clarification_delegation_id(task: &str) -> Option<&str> {
task.strip_prefix(AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX)
.and_then(|value| value.split('').next())
.map(str::trim)
.filter(|value| !value.is_empty())
}
/// 该 pending 是否由 Runtime 代 Supervisor 生成的子 Agent 澄清转述。
pub(crate) fn agent_runtime_pending_is_delegate_clarification_relay(
pending: &AgentRuntimePendingToolAction,
) -> bool {
pending.action.tool == GAME_CREATOR_USER_INPUT_REQUEST_TOOL
&& agent_runtime_delegate_clarification_delegation_id(&pending.task).is_some()
}
pub(crate) const AGENT_RUNTIME_USER_INPUT_SCHEMA_VERSION: &str =
"game-creator-runtime-user-input.v1";
pub(crate) const AGENT_RUNTIME_USER_INPUT_STATUS_PENDING: &str = "pending";
@@ -998,12 +1021,7 @@ fn bind_user_input_record_to_static_delegate_at(
pending: &AgentRuntimePendingToolAction,
record: &AgentRuntimeUserInputRecord,
) -> Result<(), String> {
let Some(delegation_id) = pending
.task
.strip_prefix("子 Agent 需要用户澄清后才能继续。delegationId=")
.and_then(|value| value.split('').next())
.map(str::trim)
.filter(|value| !value.is_empty())
let Some(delegation_id) = agent_runtime_delegate_clarification_delegation_id(&pending.task)
else {
return Ok(());
};