立项策划链路关掉循环窗口记账,做游戏链路原样保留
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 4m15s
Project CI / Native shell tests (pull_request) Failing after 12m29s

窗口机制的用途是长 run 的进度 checkpoint 与停滞检测。立项策划链路两样都够不着:
策划子 Run 最多五轮左右,窗口是 AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT,边界一次都
不会越过,checkpoint 和停滞判定永远不产出。它在这条链路上唯一还能产出的是失败——
windowCompletedLoops 跨暂停由 continuation 携带,nextLoopIndex 却从 pending 重读,
澄清应答恢复时两者分岔,context bundle 校验器把这个分岔变成整根 run 硬失败
(线上symptom:windowCompletedLoops=6 max=5)。一个在某条链路上无法产出收益的机制,
也不该有能力向它收费。

做法是给 tracker 加 window_disabled,构造时按 agent_runtime_context_window_applies
判定:source 为 project-supervisor-plan 的策划根、agentId 为 project-planning 的
策划子,两者关掉。complete_loop 命中标志即返回 Continue,计数恒为 0,于是持久化
bundle 在任何 nextLoopIndex 下都落在校验器的允许区间内,分岔不再有落点。

from_continuation 增加 &AgentRuntimeState 参数,让编译器强制 5 个生产构造点和 4 个
测试构造点都显式表态,而不是靠默认值静默选边。字段默认 false,未标记的 tracker
保持完整记账。

做游戏链路是这套机制真正的受益方(run 跨几十轮),一行不改:window_disabled 为
false 时 complete_loop 逐字未变。autonomous 相关 309 条测试全绿。

新增两条测试,都做过 A/B:
- 窗口分档:做游戏链路仍在窗口边界产出 checkpoint;策划根与策划子跑满两个窗口
  长度也只返回 Continue。
- 恢复偏移:continuation 逐轮接力,复现「计数携带、序号重读」的真实形状,断言每个
  nextLoopIndex 下 bundle 都能通过窗口校验。撤掉修复后此测试报
  「窗口轮次无效:windowCompletedLoops=1 max=0」,与线上同一条校验。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 11:33:17 +00:00
parent d86ea61a68
commit 07ab58d2b9
10 changed files with 196 additions and 11 deletions
@@ -595,7 +595,7 @@ pub(crate) fn project_game_creator_agent_runtime_parallel_read_batch_for_test_at
});
let plan = continuation.plan.clone();
let mut observations = continuation.observations.clone();
let mut tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation);
let mut tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation, &runtime);
project_game_creator_agent_runtime_parallel_read_batch(
root,
&mut runtime,
@@ -786,7 +786,8 @@ pub(in crate::agent) async fn continue_game_creator_agent_parallel_read_batch(
let mut observations = first_pending.observations.clone();
let mut plan = first_pending.tool_plan();
let next_loop_index = usize::try_from(first_pending.loop_iteration).unwrap_or(usize::MAX);
let mut context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation);
let mut context_tracker =
AgentRuntimeContextWindowTracker::from_continuation(&continuation, &runtime);
if let Err(error) = project_game_creator_agent_runtime_parallel_read_batch(
&root,
&mut runtime,
@@ -1083,7 +1083,8 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
let mut plan = continuation.plan.clone();
let mut observations = continuation.observations.clone();
let start_loop_index = continuation.next_loop_index;
let mut context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation);
let mut context_tracker =
AgentRuntimeContextWindowTracker::from_continuation(&continuation, &runtime);
let mut final_reply = None;
let mut final_reply_revision = None;
let mut converged = false;
@@ -2861,7 +2861,8 @@ fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_nee
);
let mut observations = Vec::new();
let continuation = AgentRuntimeContinuationContext::default();
let mut context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation);
let mut context_tracker =
AgentRuntimeContextWindowTracker::from_continuation(&continuation, &parent_state);
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &parent_state)
.expect("active manifest child must block parent completion");
persist_waiting_autonomous_manifest_parent_context_at(
@@ -5698,7 +5699,8 @@ fn autonomous_manifest_waiting_context_persists_without_finishing_parent_run() {
let mut runtime = agent_runtime_state_from_task_record(&task_record);
let mut observations = Vec::new();
let continuation = AgentRuntimeContinuationContext::default();
let mut context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation);
let mut context_tracker =
AgentRuntimeContextWindowTracker::from_continuation(&continuation, &runtime);
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &runtime)
.expect("running manifest must block completion");
@@ -979,7 +979,8 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary(
usize::try_from(pending.loop_iteration).unwrap_or(usize::MAX),
)
};
let mut context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation);
let mut context_tracker =
AgentRuntimeContextWindowTracker::from_continuation(&continuation, &runtime);
if let Some(observation) = observations.last() {
context_tracker.record(observation);
}
@@ -324,7 +324,8 @@ pub(in crate::agent) fn ensure_waiting_provider_retry_projection_at(
let bundle = read_game_creator_agent_runtime_context_bundle(root, &runtime)?
.ok_or_else(|| "Provider retry 等待投影缺少 Runtime context bundle".to_string())?;
let continuation = continuation_from_game_creator_agent_runtime_context_bundle(bundle);
let context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation);
let context_tracker =
AgentRuntimeContextWindowTracker::from_continuation(&continuation, &runtime);
persist_waiting_provider_retry_context_at(
root,
&mut runtime,
@@ -53,7 +53,8 @@ pub(crate) use context_bundle::{
write_game_creator_agent_runtime_context_bundle,
};
pub(crate) use context_window::{
sanitize_agent_runtime_context_observation, AgentRuntimeContextCheckpoint,
agent_runtime_context_window_applies, sanitize_agent_runtime_context_observation,
AgentRuntimeContextCheckpoint,
};
#[allow(unused_imports)]
pub(crate) use finalization::{
@@ -1,7 +1,30 @@
use super::*;
/// Whether the fixed loop window applies to this run.
///
/// The window exists to checkpoint progress and detect a stalled Agent across
/// long runs. The 立项策划 lane cannot reach either: its child run tops out
/// around five loops against a window of
/// `AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT`, so no boundary is ever crossed and no
/// checkpoint or stall verdict is ever produced. What the bookkeeping does
/// still produce there is a way to fail — `windowCompletedLoops` is carried
/// across a pause while `nextLoopIndex` is re-read from the pending, and the
/// two drift apart on the clarification resume, which the context-bundle
/// validator turns into a hard run failure. A mechanism that cannot pay out on
/// a lane should not be able to charge it either.
///
/// The 做游戏 lane is the real beneficiary (runs there span dozens of loops) and
/// is deliberately left untouched.
pub(crate) fn agent_runtime_context_window_applies(runtime: &AgentRuntimeState) -> bool {
runtime.source != AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE
&& runtime.agent_id != crate::GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
}
impl AgentRuntimeContextWindowTracker {
pub(crate) fn from_continuation(continuation: &AgentRuntimeContinuationContext) -> Self {
pub(crate) fn from_continuation(
continuation: &AgentRuntimeContinuationContext,
runtime: &AgentRuntimeState,
) -> Self {
Self {
completed_loops: continuation.window_completed_loops,
observation_signatures: continuation
@@ -12,6 +35,7 @@ impl AgentRuntimeContextWindowTracker {
.collect(),
last_window_fingerprint: continuation.last_window_fingerprint.clone(),
stalled: continuation.context_stalled,
window_disabled: !agent_runtime_context_window_applies(runtime),
}
}
@@ -45,6 +69,19 @@ impl AgentRuntimeContextWindowTracker {
&mut self,
next_loop_index: usize,
) -> AgentRuntimeContextCheckpoint {
// Stay at zero rather than counting into a window that will never
// close. A zero count also keeps the persisted bundle inside the
// context-bundle window validator for every `nextLoopIndex`, so a
// paused-and-resumed run on this lane can no longer be failed by a
// counter that drifted from the index it is validated against.
// Stay at zero rather than counting into a window that will never
// close. A zero count also keeps the persisted bundle inside the
// context-bundle window validator for every `nextLoopIndex`, so a
// paused-and-resumed run on this lane can no longer be failed by a
// counter that drifted from the index it is validated against.
if self.window_disabled {
return AgentRuntimeContextCheckpoint::Continue;
}
if self.stalled {
return AgentRuntimeContextCheckpoint::Stalled;
}
@@ -438,6 +438,10 @@ pub(crate) struct AgentRuntimeContextWindowTracker {
pub(in crate::agent) observation_signatures: std::collections::BTreeSet<String>,
pub(in crate::agent) last_window_fingerprint: Option<String>,
pub(in crate::agent) stalled: bool,
/// Set for runs the loop window does not apply to (see
/// `agent_runtime_context_window_applies`). Defaults to `false` so an
/// unmarked tracker keeps the full bookkeeping.
pub(in crate::agent) window_disabled: bool,
}
#[derive(Clone, Debug, Default)]
@@ -67,7 +67,7 @@ fn agent_runtime_context_window_restores_progress_and_counts_dynamic_detail_chan
.expect("read mid-window context bundle")
.expect("mid-window bundle exists");
let continuation = continuation_from_game_creator_agent_runtime_context_bundle(loaded);
let mut restored = AgentRuntimeContextWindowTracker::from_continuation(&continuation);
let mut restored = AgentRuntimeContextWindowTracker::from_continuation(&continuation, &state);
for next_loop_index in 4..AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT {
restored.record(&AgentRuntimeToolObservation {
tool: "project.search".to_string(),
@@ -162,7 +162,7 @@ fn agent_runtime_context_window_persists_stall_across_revision_drift_and_restart
.expect("stalled context bundle exists");
assert!(loaded.context_stalled);
let continuation = continuation_from_game_creator_agent_runtime_context_bundle(loaded);
let mut restored = AgentRuntimeContextWindowTracker::from_continuation(&continuation);
let mut restored = AgentRuntimeContextWindowTracker::from_continuation(&continuation, &state);
assert_eq!(
restored.complete_loop(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + 1),
AgentRuntimeContextCheckpoint::Stalled,
@@ -263,6 +263,143 @@ fn agent_runtime_context_window_counts_distinct_agent_message_bodies() {
}
}
#[test]
fn agent_runtime_context_window_is_off_for_the_planning_lane_and_on_elsewhere() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-window-lane", "窗口分档项目").expect("project init");
// 判据只读 agent_id / source 两个字段,所以起一次普通 run 再克隆覆盖即可;
// 直接以 project-planning 起 run 需要额外的 Run Profile 绑定,与本用例无关。
let started = start_game_creator_agent_runtime_task_at(
&root,
"design-director",
"验证窗口分档",
"window-lane-run",
"agent-background-task",
"窗口分档测试",
vec!["跑满一个窗口".to_string()],
)
.expect("start window lane runtime state");
let lane_state = |agent_id: &str, source: &str| {
let mut state = started.clone();
state.agent_id = agent_id.to_string();
state.source = source.to_string();
state
};
// 做游戏链路:窗口照常记账,跑满一轮窗口后给出 checkpoint。
let game_state = lane_state("design-director", "agent-background-task");
assert!(agent_runtime_context_window_applies(&game_state));
let mut game_tracker =
AgentRuntimeContextWindowTracker::from_continuation(&Default::default(), &game_state);
for next_loop_index in 1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT {
game_tracker.record(&AgentRuntimeToolObservation {
tool: "project.search".to_string(),
status: "ok".to_string(),
summary: format!("第 {next_loop_index} 轮"),
detail: None,
});
let checkpoint = game_tracker.complete_loop(next_loop_index);
if next_loop_index == AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT {
assert_ne!(
checkpoint,
AgentRuntimeContextCheckpoint::Continue,
"做游戏链路必须保留窗口边界 checkpoint"
);
} else {
assert_eq!(checkpoint, AgentRuntimeContextCheckpoint::Continue);
}
}
// 立项策划链路(策划根 + 策划子):计数恒为 0,永远不越窗口边界。
for state in [
lane_state(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
),
lane_state(
crate::GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"agent-delegate",
),
] {
assert!(!agent_runtime_context_window_applies(&state));
let mut tracker =
AgentRuntimeContextWindowTracker::from_continuation(&Default::default(), &state);
for next_loop_index in 1..=(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT * 2 + 1) {
tracker.record(&AgentRuntimeToolObservation {
tool: "project.search".to_string(),
status: "ok".to_string(),
summary: "重复窗口标记".to_string(),
detail: None,
});
assert_eq!(
tracker.complete_loop(next_loop_index),
AgentRuntimeContextCheckpoint::Continue,
"立项策划链路不产出 checkpoint,也不得被判停滞"
);
}
}
}
/// 计数恒为 0 的直接收益:暂停后恢复时,无论 `nextLoopIndex` 落在窗口的哪个位置,
/// 持久化的 bundle 都能通过窗口校验。这正是澄清应答后 `windowCompletedLoops=6 max=5`
/// 把整根 run 判失败的那条路径。
#[test]
fn planning_lane_context_bundle_survives_every_resume_offset() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-window-resume", "窗口恢复项目")
.expect("project init");
let mut state = start_game_creator_agent_runtime_task_at(
&root,
"design-director",
"验证澄清恢复",
"planning-window-resume-run",
AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
"窗口恢复测试",
vec!["提交 GDD".to_string()],
)
.expect("start planning runtime state");
assert!(!agent_runtime_context_window_applies(&state));
// continuation 逐轮接力,复现真实恢复路径上「计数跨暂停携带、序号从 pending 重读」
// 的形状——这正是两个源分岔的地方。
let mut continuation = AgentRuntimeContinuationContext::default();
for next_loop_index in 0..=(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT * 2) {
let mut tracker =
AgentRuntimeContextWindowTracker::from_continuation(&continuation, &state);
tracker.record(&AgentRuntimeToolObservation {
tool: "project.search".to_string(),
status: "ok".to_string(),
summary: "窗口标记".to_string(),
detail: None,
});
assert_eq!(
tracker.complete_loop(next_loop_index),
AgentRuntimeContextCheckpoint::Continue
);
let bundle = build_game_creator_agent_runtime_context_bundle(
&root,
&state,
&state.current_task,
&AgentRuntimeToolPlan::default(),
&[],
next_loop_index,
&tracker,
)
.expect("build planning context bundle");
write_game_creator_agent_runtime_context_bundle(&root, &bundle)
.expect("write planning context bundle");
state.loop_iteration = u32::try_from(next_loop_index).unwrap_or(u32::MAX);
let loaded = read_game_creator_agent_runtime_context_bundle(&root, &state)
.unwrap_or_else(|error| {
panic!("nextLoopIndex={next_loop_index} 的策划 bundle 必须通过窗口校验:{error}")
})
.unwrap_or_else(|| panic!("nextLoopIndex={next_loop_index} 的策划 bundle 必须存在"));
assert_eq!(loaded.window_completed_loops, 0);
continuation = continuation_from_game_creator_agent_runtime_context_bundle(loaded);
}
}
#[test]
fn platform_art_asset_output_path_rejects_escape_overwrite_and_symlink() {
let root = unique_project_path();