完善 Agent Runtime 成功交接与终态恢复

新增 Provider 成功响应 handoff,绑定真实 requestId 并支持原子恢复
补齐 compaction 与 finalization v4 的 stream committed 提交事务
让 Runner 在 handoff 存在时保持 busy,并覆盖冲突与崩溃窗口
同步 V1.41 技术方案、实施计划和项目共享记忆
This commit is contained in:
AIGameCreator App
2026-07-20 02:31:11 +08:00
parent 50e33dd469
commit c321aef489
12 changed files with 2493 additions and 92 deletions
File diff suppressed because it is too large Load Diff
@@ -66,6 +66,7 @@ mod preview;
mod process_session;
mod process_session_bridge;
mod project;
mod provider_handoff;
mod provider_retry;
mod repository_context;
mod runner;
@@ -91,6 +91,7 @@ const AGENT_DB_FINALIZATION_LIFECYCLE_SCHEMA_VERSION: &str =
const AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V1: &str = "game-creator-runtime-finalization.v1";
const AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V2: &str = "game-creator-runtime-finalization.v2";
const AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V3: &str = "game-creator-runtime-finalization.v3";
const AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V4: &str = "game-creator-runtime-finalization.v4";
const AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES: u64 = 256 * 1024 * 1024;
const AGENT_DB_MAX_SCAN_RECORDS: usize = 1_000_000;
const AGENT_DB_TERMINAL_RESERVE_RECORDS: u64 = 64;
@@ -1439,6 +1440,7 @@ fn validate_agent_db_finalization_lifecycle_semantics(
AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V1
| AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V2
| AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V3
| AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V4
) {
return Err("Agent DB finalization lifecycle journal schema 无效".to_string());
}
@@ -1506,7 +1508,10 @@ fn validate_agent_db_finalization_lifecycle_semantics(
.ok_or_else(|| {
"Agent DB finalization lifecycle goalSnapshotFingerprint 无效".to_string()
})?;
if journal_schema != AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V3 {
if !matches!(
journal_schema,
AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V3 | AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V4
) {
if !record.get("goalId").is_some_and(serde_json::Value::is_null)
|| goal_revision != 0
|| !goal_fingerprint.is_empty()
@@ -8547,7 +8552,7 @@ mod agent_db_security_tests {
finalization_lifecycle_record_with_schema(
finalization_id,
stage,
AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V3,
AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V4,
)
}
@@ -9507,11 +9512,12 @@ mod agent_db_security_tests {
}
#[test]
fn finalization_lifecycle_accepts_supported_v1_through_v3_journals() {
fn finalization_lifecycle_accepts_supported_v1_through_v4_journals() {
for (index, (schema, hex)) in [
(AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V1, '6'),
(AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V2, '7'),
(AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V3, '8'),
(AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V4, '9'),
]
.into_iter()
.enumerate()
File diff suppressed because it is too large Load Diff
@@ -352,7 +352,9 @@ fn validate_path_identity(agent_id: &str, run_id: &str) -> Result<(), String> {
Ok(())
}
fn validate_identity(identity: &AgentRuntimeProviderRetryIdentity) -> Result<(), String> {
pub(crate) fn validate_identity(
identity: &AgentRuntimeProviderRetryIdentity,
) -> Result<(), String> {
validate_path_identity(&identity.agent_id, &identity.run_id)?;
for (label, value) in [
("projectId", identity.project_id.as_str()),
@@ -2745,6 +2745,7 @@ fn external_agent_runner_root_is_idle(root: &Path) -> Result<bool, String> {
for durable_dir in [
root.join(".agent/runtime/pending-actions"),
root.join(".agent/runtime/finalizations"),
root.join(".agent/runtime/provider-handoffs"),
root.join(".agent/runtime/provider-retries"),
] {
if external_agent_runner_directory_has_durable_files(&durable_dir)? {
@@ -4570,6 +4571,125 @@ mod tests {
assert!(state.draining.load(Ordering::Acquire));
}
#[test]
fn durable_provider_handoff_prevents_shutdown_even_when_corrupt() {
let directory = unique_test_directory();
let root = directory.0.join("project");
let identity = crate::provider_retry::AgentRuntimeProviderRetryIdentity {
project_id: "project-provider-handoff-idle".to_string(),
agent_id: "code-prototype".to_string(),
task_id: "provider-handoff-task-idle".to_string(),
session_id: "session-provider-handoff-idle".to_string(),
run_id: "run-provider-handoff-idle".to_string(),
source: "agent-chat".to_string(),
goal_id: None,
goal_revision: 0,
goal_snapshot_fingerprint: String::new(),
applied_steer_cursor: 0,
request_kind: "final-reply".to_string(),
base_request_slot: "final-reply-loop-1-revision-0".to_string(),
request_fingerprint: "d".repeat(64),
provider_config_fingerprint: "e".repeat(64),
web_search_enabled: false,
allow_idle_context_compaction: false,
};
let response = platform_llm::LlmRunResponse {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "provider-handoff-runner-test".to_string(),
text: "durable final reply".to_string(),
finish_reason: Some("stop".to_string()),
response_id: Some("provider-handoff-response".to_string()),
usage: None,
tool_calls: Vec::new(),
};
let provider_request_id = format!("provider-request-{}", "f".repeat(64));
crate::provider_handoff::write_at(
&root,
&identity,
&identity.base_request_slot,
0,
&provider_request_id,
&response,
)
.expect("write durable Provider handoff");
assert!(!external_agent_runner_root_is_idle(&root).expect("scan primary handoff"));
let agent_key = format!("{:x}", Sha256::digest(identity.agent_id.as_bytes()));
let run_key = format!("{:x}", Sha256::digest(identity.run_id.as_bytes()));
let handoff_path = root
.join(".agent/runtime/provider-handoffs")
.join(agent_key)
.join(format!("{run_key}.json"));
let previous_path = crate::agent::agent_runtime_json_sidecar_backup_path(&handoff_path);
fs::rename(&handoff_path, &previous_path).expect("move Provider handoff to previous");
assert_eq!(
crate::provider_handoff::read_for_run_at(&root, &identity.agent_id, &identity.run_id,)
.expect("recover previous Provider handoff")
.map(|record| record.to_llm_response()),
Some(response)
);
assert!(!external_agent_runner_root_is_idle(&root).expect("scan previous handoff"));
fs::write(&previous_path, b"{").expect("corrupt Provider handoff");
crate::provider_handoff::read_for_run_at(&root, &identity.agent_id, &identity.run_id)
.expect_err("corrupt Provider handoff must enter recovery error handling");
assert!(!external_agent_runner_root_is_idle(&root).expect("scan corrupt handoff"));
let token = "provider-handoff-shutdown-token-provider-handoff-shutdown-token";
let state = ExternalAgentRunnerServerState::new(
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "provider-handoff-shutdown-boot", 32325),
);
state.remember_root(&root);
let busy_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "shutdown-provider-handoff-busy-1".to_string(),
token: token.to_string(),
method: "runner.shutdown_if_idle".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(busy_response.ok);
assert_eq!(
busy_response
.result
.as_ref()
.and_then(|value| value["idle"].as_bool()),
Some(false)
);
assert!(!state.shutdown_requested.load(Ordering::Acquire));
assert!(!state.draining.load(Ordering::Acquire));
crate::provider_handoff::remove_at(&root, &identity.agent_id, &identity.run_id)
.expect("remove corrupt Provider handoff");
assert!(external_agent_runner_root_is_idle(&root).expect("scan idle root"));
let idle_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "shutdown-provider-handoff-idle-1".to_string(),
token: token.to_string(),
method: "runner.shutdown_if_idle".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(idle_response.ok);
assert_eq!(
idle_response
.result
.as_ref()
.and_then(|value| value["idle"].as_bool()),
Some(true)
);
assert!(state.shutdown_requested.load(Ordering::Acquire));
assert!(state.draining.load(Ordering::Acquire));
}
#[test]
fn stale_protocol_endpoint_does_not_override_instance_lock_arbitration() {
let directory = unique_test_directory();
File diff suppressed because it is too large Load Diff
@@ -4910,3 +4910,13 @@
- 恢复:进入 Provider 前先同步 response 阶段计划投影与 context bundle。Runner 重启后即使公共 state 先投影 planning,执行 pass 也先按当前 run sidecar 识别 `requestKind=final-reply / final-reply-context-compaction`,恢复原 `nextLoopIndex` 并跳过新的 tool-plan;后者先恢复同一压缩请求,再继续原 final-reply。重建请求或 Provider/Goal/steer/revision 身份漂移时删除旧 sidecar,只记录漂移字段名并在同一 run 重新规划,不提交旧回复。
- 流式与终态:失败 attempt 的半句只进入 failed/discarded response-stream,恢复 attempt 沿原基础 stream 身份推进;唯一 canonical assistant 仍只由 finalization journal 提交。确定性测试覆盖 sidecar-first 投影窗口、到期前零请求、失败/恢复 HTTP body 逐字节一致、无重复 tool-plan、唯一 assistant/completed/committed stream 和终局零 retry sidecar。
- 证据边界:当前 `provider_retry_` 21/21、`provider_transient_retry_` 5/5、`response_stream_` 16/16Tauri/Rust 串行全量 `969 passed / 4 ignored`。尚未完成真实外部 Provider 的 final-reply 退避期 Runner 强杀,因此不能复用 V1.39 首次 tool-plan 的真实 PASS;手动压缩和 tool-plan `repair-N` 继续保持进程内重试。Provider 成功返回到压缩 sidecar 或 finalization journal `prepared` 之间仍有崩溃窗口,重启可能重新请求 Providerfinalization journal 清理到 stream committed 之间也不是可恢复事务。本切片只保证失败重试与最终 assistant 幂等,不能声称成功请求 exactly-once 或 stream 终态事务已经完成。
## 2026-07-20 AI 游戏创作 Agent Runtime V1.41 Provider 成功交接与回复流终态恢复
- 持久所有权:`game-creator-provider-handoff.v1` 是 Provider 成功返回与消费端 durable commit 之间的私有交接记录,每个 Agent/run 最多一条。只允许无 tool call 的 `context-compaction / final-reply-context-compaction / final-reply`,绑定完整 retry identity、真实 request slot/attempt、真实 Provider lifecycle requestId、规范化文本响应及其指纹;不保存 prompt、请求消息、API Key、Provider URL、tool call/arguments 或错误正文。
- 提交顺序:物理 Provider 成功后先去 thinking、按既有规则脱敏并原子写入 handoff,再回读逐字段完全一致,之后才允许为 handoff 中保存的真实 requestId 写 `completed` lifecycle。handoff 成为 durable owner 后,恢复先补齐同一 requestId 的 `started -> completed`,再零网络回放响应;不得生成新 requestId、把真实成功记到 base requestId,或在 handoff 未回读成功时宣称 lifecycle completed。
- 所有权转移:上下文压缩必须先把规范 compaction sidecar 原子写入并回读一致,才可删除匹配 handoff;final-reply 先把回复转入 finalization journal `prepared`,随后由 journal 持有 assistant、Runtime/Goal 终态和 response stream 提交责任。终局清理不得早于下一 durable owner 建立;取消、steer、Goal 作废、失败或其它终态也必须按完整身份清理所属 handoff。
- 冲突与漂移:同一 run 的 handoff 与 retry identity/attempt/slot 完全一致时,以 handoff 为成功事实并清理 retry;任一字段冲突必须在网络调用前进入 `needs-reconciliation`,保留两份 sidecar 和真实 requestId 供核对。相同 durable run 的 Goal/steer/request/config 等身份漂移,先按 handoff 的旧真实 requestId 补齐 lifecycle,再只记录漂移字段名、删除旧 handoff/retry 并回到同 run planning;响应正文不得进入公共审计。跨 run 身份冲突直接失败关闭。
- 回复流事务:finalization journal 升级为 v4,并固定保存 `responseRequestSlot / responseSteerCursor / responseRevision / response`。assistant 与 Runtime/Goal 已幂等完成后,journal 仍必须保留到匹配 response stream 写成 `committed` 且立即回读身份、状态和正文完全一致;之后才可删除 journal 和剩余 handoff。提交使用 journal 的固定 Agent/task/Session/run/request slot/steer cursor/revision,禁止调用面向 UI 的可见性过滤或用当前全局 project revision 静默跳过既定 run 的 stream。
- 回复流恢复:stream 缺失或仍为 `streaming` 时,可用 journal 固定身份和正文重建 `ready` 后提交;已 `committed` 且正文一致时按幂等成功继续清理。既有 stream 身份冲突、ready/committed 正文冲突、写入失败或回读失败都必须保留 journal 并保持可恢复 finalization,不能删除证据、覆盖冲突正文或把 finalization 当成已经清理。
- Runner 与边界:primary、`.previous` 或损坏的 handoff 都使所属 root 保持 busy,并阻止 `runner.shutdown_if_idle`。handoff 原子提交并回读前强杀 Runner,仍可能留下 Provider 已成功但本地只有未闭合 `started` 的未知窗口;Runtime 只能失败关闭,V1.41 不因此承诺端到端 exactly-once。`tool-plan` 和 function arguments 明确不在本协议内,真实外部 Provider 的 final-reply 退避期 Runner 强杀仍需独立 E2E 后才能记 PASS。
@@ -115,6 +115,26 @@ cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml provi
第一条测试必须停在 final-reply retry sidecar 已提交而 task/state 尚未投影的窗口,恢复扫描补齐 waiting 后在到期前保持零请求;第二条必须让 final-reply 前置自动压缩先失败并进入 `requestKind=final-reply-context-compaction` 等待态,到期后恢复同一压缩请求,再继续原 final-reply。两条链路都不得重新调用 tool-plan;失败和恢复请求只比较 HTTP body 字节、SHA-256 和长度,测试失败不得打印正文。真实 Provider 验收必须另起独立 suite,在 Supervisor 已认领全部专业回执、repair 和宿主验证后注入 final-reply 故障并于退避期强杀 Runner;该 suite 尚未 PASS 前,不能复用 V1.39 首次 tool-plan 的真实证据。Provider 成功返回到 finalization journal `prepared` 之间的崩溃窗口仍须独立补齐和验收,当前门禁不能据此宣称 Provider 调用 exactly-once。
### AI 游戏创作 Runtime V1.41 成功交接与回复流恢复复验
修改 Provider 成功响应、持久重试、finalization、response stream 或 Runner idle 判定后,至少运行:
```bash
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml provider_handoff_ -- --nocapture --test-threads=1
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml response_stream_ -- --nocapture --test-threads=1
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml durable_provider_handoff_prevents_shutdown_even_when_corrupt -- --nocapture --test-threads=1
```
复验和排障按 durable ownership 的顺序取证:
1. 先按 handoff 中保存的真实 `providerRequestId / requestSlot / attempt` 核对 Provider lifecycle。成功响应必须先原子写入 handoff 并回读完全一致,再为同一真实 requestId 补 `completed`;恢复不得生成替代 requestId。
2. 在 handoff 已提交、lifecycle 仍只有 `started` 的 checkpoint 停止执行,关闭 mock Provider 后再恢复。final-reply 必须零网络回放并只产生唯一 assistant/completed/committed stream;前置压缩只允许在回放压缩结果后发出后续必要的 final-reply,不能重复 tool-plan 或 compaction。
3. 同一 run 同时存在 handoff 与 retry 时,完全匹配才允许回放并清理 retryidentity、attempt 或 slot 冲突必须零网络进入 `needs-reconciliation`,保留两份 sidecar 和真实 requestId 证据,不要为了让 Runner 退出而手工择一删除。
4. finalization 已到 `runtime-completed` 后,故意删除 stream、保留 `streaming` 半句、注入 committed 写失败、在 committed 后 journal 清理前停止,并在期间推进全局 project revision。恢复必须始终使用 journal 固定的 run/request slot/steer cursor/response revision 与正文重建或幂等提交,写入后回读成功才可删除 journal。
5. 固定身份或正文冲突、stream 写入或回读失败时,断言 journal 保留且恢复扫描继续处理同一 finalizationRunner 对 primary、`.previous` 或损坏 handoff 都应保持 busy`runner.shutdown_if_idle` 不得返回 idle。终局再核对 retry/handoff/finalization sidecar 全部为零,并扫描公共 task/event/Agent DB/CLI,确保没有响应正文、thinking、凭据、Provider URL 或绝对路径。
V1.41 只覆盖无 tool call 的 `context-compaction / final-reply-context-compaction / final-reply`。Runner 在 Provider 成功后、handoff 原子提交并回读前被硬杀时,仍只能把未闭合 `started` 视为结果未知并失败关闭;该窗口不是 exactly-once。`tool-plan` 及其 function arguments 不进入 handoff,真实外部 Provider 的 final-reply 退避期 Runner 强杀仍需独立 E2E,不能用上述确定性测试或 V1.39 PASS 代替。
suite 只能读正式 AppData,在其同级目录写入 sentinel 管理的 `0600` 私有副本和 overlay;启动 CLI/Runner 时须把 loopback 合并进大小写两套 no-proxy 环境,防止系统 HTTP 代理绕过本地故障门禁;source-dir guard 必须证明本 suite 前缀未进入源目录,源配置和 endpoint 身份保持不变,报告不得保存 Provider URL、headers、正文、凭据或绝对配置路径。sidecar 先于 task/state 投影是合法提交窗口,验收器应等待完整等待态后再强杀;若后续协作或终局失败,partial report 仍应保留已取得的 retry checkpoint,但失败轮不得与后续成功轮拼接。
### AI 游戏创作自主 Swarm 终端复验
@@ -3367,3 +3367,14 @@
- 真实验收陷阱:sidecar 按设计早于 task/state 等待投影落盘,验收器看到 sidecar 后必须继续等完整 `running / waiting-for-provider-retry`,不能把合法提交窗口误判为 torn projection。共享 Runner 强杀会同时中断其它 Agent 的 in-flight Provider 请求;要隔离验证单个持久 retry,应在子请求产生前对父 Agent 首次规划注入故障,恢复后再完成同一 run 的并行协作。首批同批双委派若只依赖自然语言提示会受模型波动影响,真实 suite 应使用正式 collaboration policy/preflight 固定两个指定 static Agent,并保留无正文的 batch 数量诊断。长链路还可能发生额外真实瞬态失败,不能用“全局 failed/retry 必须等于 1”把已正确恢复的网络抖动误判为注入失败;应按 request identity 锁定唯一受控链,额外 failure/retry 独立计数并继续执行全部 lifecycle、后继终态和零残留门禁。
- final-reply 恢复陷阱:不能把当前 Runtime `status / phase / currentAction` 投影或整份临时 tool-plan 放进要求跨进程稳定的请求指纹。前者在 `waiting -> planning -> response` 恢复过程中必然变化,后者的 `planUpdate/actions` 不会由 context bundle 原样保存;两者都会让合法 `-transient-N` 被误判为 drift。final-reply 必须在请求前同步 response 状态与 context bundle,并只使用可由 bundle 精确恢复的有界收束摘要;恢复 pass 先识别 `final-reply``final-reply-context-compaction` sidecar、恢复原 loop 并跳过新 planning。Agent DB lifecycle 的 requestKind 白名单也必须同步扩展,否则压缩请求会在网络调用前失败并被 planning fallback 掩盖。测试必须制造两种 sidecar-first 窗口、调用恢复扫描、按网络接收时间证明 `acceptedAtMs >= retryAtMs`,并比较失败/恢复 HTTP body 的 SHA-256 和字节一致性;失败输出不得打印正文片段。Provider 成功返回到压缩 sidecar 或 finalization journal `prepared` 之间仍不是 durable 提交点,进程退出可能重发 Providerfinalization journal 清理后才标记 stream committed 的窗口也可能留下 assistant 已落盘但 stream 仍为 ready。在新增成功响应 journal 与可恢复 stream commit 前不得宣称成功请求 exactly-once 或 stream 终态事务。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/provider_retry.rs``agent.rs``runner.rs``tests.rs``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
## Provider 成功不等于已交接,stream ready 也不等于 finalization 已完成
- 现象:Provider 已返回完整 final-replyRunner 在 finalization `prepared` 前退出后却再次请求;或 assistant/completed 已唯一落盘,response stream 长期停在 `ready / streaming`。更危险的修复是看到 handoff 与 retry 同时存在便择一删除,或因为另一个 Agent 已推进全局 project revision,就把当前固定 run 的 stream 当成不可见缓存并静默跳过提交。
- 原因:Provider 网络 future、成功 handoff、compaction/finalization journal 和 response stream 是连续但不同的 durable owner。只有内存中的成功响应、`started` lifecycle、流式半句或 `ready` 展示缓存都不能证明下一 owner 已接管;面向 UI 的 stream 可见性还会读取当前全局 revision,不适合作为 finalization 的提交判据。
- 正确顺序:成功响应先规范化并写入 `game-creator-provider-handoff.v1`,原子落盘并回读一致后,才用 handoff 保存的真实 requestId 补 `completed` lifecycle。恢复先修复该真实 requestId,再零网络回放。压缩结果先持久化并回读 compaction sidecar 后再清 handofffinal-reply 至少先进入 finalization `prepared`journal 持续负责唯一 assistant、Runtime/Goal completed 和 stream committed,直到 committed 写入后的身份、状态、正文回读全部成功才清理。
- 冲突处理:handoff/retry 只有完整 identity、attempt 和 slot 一致才可把 retry 当作已被成功结果覆盖;冲突时必须零网络进入 reconciliation,并保留两份 sidecar、`.previous` 和真实 requestId 证据。stream 身份或正文与 journal 冲突时也保留 journal;禁止覆盖冲突 stream、删除 journal、补造 requestId 或靠重复 Provider 调用“刷新”现场。Runner 对存在、备份或损坏 handoff 的 root 都必须报告 busy,不能为 idle shutdown 自动删证据。
- stream 恢复:finalization 使用 journal v4 固定的 Agent/task/Session/run/request slot/steer cursor/response revision 和正文直接读取提交面。缺失或 `streaming` 可由 journal 重建为规范 `ready` 再提交;已 committed 且正文一致可幂等清理。即使全局 project revision 已被其它 Agent 推进,也不能跳过这个既定 run 的 stream;写入、回读、固定身份或正文任一不一致,都保持 journal 和可恢复 finalization。
- 排障与验证:先核对 handoff 的 `providerRequestId / requestSlot / attempt` 与 Agent DB lifecycle,再看 retry/handoff/finalization/response-stream sidecar,最后才看 Runtime/UI 投影。用关闭 mock Provider 后恢复证明 handoff 回放零网络;分别覆盖 compaction 与 final-reply 消费窗口、handoff/retry 冲突、stream 缺失/streaming、commit 写失败、committed 后清理前退出、全局 revision 漂移和 Runner busy。日志与断言只公开指纹、字符数、状态和差异字段,不能打印 handoff 正文、请求体、凭据、URL 或绝对路径。
- 保留边界:Runner 若在 Provider 成功后、handoff 原子提交并回读前被硬杀,本地仍只有结果未知的 `started`,不能安全补发或宣称 exactly-once。handoff 只覆盖无 tool call 的 `context-compaction / final-reply-context-compaction / final-reply``tool-plan` 及其 function arguments 不在内,真实外部 Provider 的 final-reply Runner 强杀门禁也需单独完成。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/provider_handoff.rs``provider_retry.rs``agent.rs``project.rs``runner.rs``tests.rs``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
@@ -1391,16 +1391,64 @@ V1.40 把后台 `final-reply` 请求及其前置自动 context-compaction 分别
### 验收边界
- 确定性测试必须分别停在“final-reply 或其前置压缩 retry sidecar 已提交、task/state 尚未投影”的窗口,确认恢复前 phase 仍为 response、重启扫描补齐 waiting 状态且网络接收时间不早于 `retryAt`;到期后必须直接恢复原 `-transient-1` final-reply,或先恢复原 `final-reply-context-compaction` 再继续 final-reply,不得多一次 tool-plan。失败与恢复两次 HTTP body 必须逐字节相同,只在失败信息中公开 SHA-256、字节数和首个差异位置,不公开正文。
- 终态必须只有 1 条 completed task、1 条 conversation assistant 和 1 个 committed response streamfinal-reply lifecycle 固定为初始 `started -> failed` 加恢复 `started -> completed`retry audit/waiting 各 1provider retry sidecar 为 0。前置压缩恢复还必须形成 `final-reply-context-compaction` 的两组唯一 lifecycle,并且不增加 tool-plan。当前确定性门禁为 `provider_retry_` 21/21、`provider_transient_retry_` 5/5、`response_stream_` 16/16Tauri/Rust 串行全量 `969 passed / 4 ignored`
- 终态必须只有 1 条 completed task、1 条 conversation assistant 和 1 个 committed response streamfinal-reply lifecycle 固定为初始 `started -> failed` 加恢复 `started -> completed`retry audit/waiting 各 1provider retry sidecar 为 0。前置压缩恢复还必须形成 `final-reply-context-compaction` 的两组唯一 lifecycle,并且不增加 tool-plan。最终实现的当前验证计数统一见 V1.41,不再沿用本切片较早的局部计数
- V1.40 尚未以真实外部 Provider 完成 final-reply 退避期 Runner 强杀门禁,因此不得把 V1.39 首次 tool-plan 的真实 PASS 外推为 V1.40 PASS。后续独立 suite 必须在 Supervisor 已完整认领专业回执、完成 repair 和宿主验证后,对首次 final-reply 注入唯一瞬态故障;退避期强杀后证明原父 Session/run、request fingerprint、retryAt 和 slot 稳定,且 delivery/claim/receipt/finalization/assistant 无重复、终局零 sidecar/泄漏。手动压缩与 tool-plan `repair-N` 仍不在本切片内。
- Provider 成功返回到消费端 durable commit 之间仍有独立崩溃窗口:`final-reply-context-compaction` 的成功响应在压缩 sidecar 落盘前、final-reply 的成功响应在 finalization journal `prepared` 前都可能因 Runner 退出而丢失并重新请求 Provider。当前 response stream ready 不是 durable 结果提交协议;finalization journal 清理后到 stream 标记 committed 之间退出或写失败,还可能留下已持久化唯一 assistant、但展示 stream 仍为 ready 的终态差异。V1.40 只保证瞬态失败后的请求恢复与最终 assistant 幂等,不能把它扩大为成功请求 exactly-once 或 stream 终态事务;后续须以可重放成功响应 journal 和可恢复 stream commit 或等价提交协议单独解决。
## V1.41 Provider 成功响应持久交接与回复流终态恢复
V1.41 为 V1.40 明确留下的成功响应交接窗口增加 `.agent/runtime/provider-handoffs/<agentKey>/<runKey>.json` 私有 sidecarschema 固定为 `game-creator-provider-handoff.v1`。每个 Agent/run 最多一条记录,绑定完整 Provider retry identity、**实际已注册的物理 `providerRequestId`**、request slot/attempt、Provider/model、脱敏文本响应、finish reason、response ID、usage、响应指纹和创建时间。schema 拒绝未知字段,记录受字段长度、安全路径和 512 KiB 硬上限约束;写入使用 `0600` 临时文件、`sync_data`、原子替换、父目录同步和跨平台 `.previous` 恢复副本,写后必须回读并与内存记录完全一致。
本轮实际写入 handoff 的是 tool-plan 前置自动 `context-compaction`、final-reply 前置自动 `final-reply-context-compaction``final-reply` 三种无工具调用文本请求。手动压缩仍走非持久重试路径,不会写 handoff;tool-plan 本身的成功响应和 function arguments 也不在本轮。响应落盘前会删除 thinking block,并依次过滤密钥、项目路径和其它绝对路径;sidecar 不保存 prompt、请求消息、API Key、Provider URL、tool call/arguments 或错误正文,也不复制到公共 Runtime、event、Agent DB、CLI 或报告。
### Durable handoff 提交与 handoff-first 恢复
- 支持 handoff 的物理 Provider 请求返回成功后,Runtime 先用该次 lifecycle 实际解析出的 `requestId` 原子写入并回读 handoff,然后才允许同一 request identity 追加 `completed` 终态。handoff 写入、回读或内容校验失败时,原 lifecycle 保持只有 `started`Runtime 进入 `needs-reconciliation`,不得自动补发该成功请求。
- 每次可持久化请求在读 retry sidecar 或发起网络请求之前必须先读 handoff。完整 identity 相同时,Runtime 核对可选 retry sidecar 的 identity/attempt/slot,并把原实际 `providerRequestId``started` lifecycle 幂等补齐为 `completed`;随后删除匹配 retry sidecar 并回放响应,不注册新 `started`、不生成新 request identity,也不调用 Provider。
- handoff 与 retry sidecar 同时存在时,后者必须精确指向 handoff 的 identity/attempt/slot;任一不符都进入 `needs-reconciliation`,不允许以任意一方覆盖另一方。同一 durable run 内若 Goal/steer/revision/request/config 等稳定 identity 已漂移,Runtime 先用旧 handoff 的实际 `requestId` 闭合旧 lifecycle,公共审计只记录漂移字段名,再删除旧 handoff/retry 并让同一 run 重新规划;跨 durable run 身份冲突则直接失败关闭。
- cancel、生效 steer、非 pause Goal 控制、失败、budget-exhausted 和其它终态清理 handoff/retryGoal pause 保留原交接记录,resume 后仍消费同一响应。`provider-handoffs` 中任何 primary、`.previous` 或损坏普通文件都是 Runner busy 事实,必须阻止 `runner.shutdown_if_idle`,直到恢复消费、明确作废或人工核对后清理。
### Compaction 消费与 finalization 固定流身份
- 自动 context-compaction 消费 handoff 后,先原子写入规范 `game-creator-runtime-context-compaction.v1` sidecar,再回读确认整条记录与本次结果完全一致,最后才删除匹配 handoff。若上次已完成 compaction 但在清理 handoff 前退出,恢复时以 source fingerprint、request kind 和 base slot 识别“没有新 source”,复用已提交 compaction 并幂等删除 handoff,不重新请求 Provider。compaction 回读或 handoff 清理失败保持 reconciliation。
- 流式 final-reply 在 Provider 成功时调用 publisher `handoff`:持久化最新可见快照并停止 Drop 把它改成 discarded,但不提前写 ready。只有 handoff 完成且经规范脱敏响应回放后,才将同一 stream 身份写为 ready;中途 streaming 半句不是可提交 assistant。
- finalization journal 正式升级为 `game-creator-runtime-finalization.v4`,在 `prepared` 时固定绑定 `responseRequestSlot`,并把该 slot 与 Agent/Session/run、response fingerprint/revision、steer cursor、计划和 Goal 快照一起纳入 `finalizationId` 指纹;篡改 slot 必须直接造成幂等身份不匹配。v3 journal 缺少 `responseRequestSlot` 仍可读,按 v1-v3 旧指纹规则校验,并在 stream commit 时由已绑定的 Runtime/finalization revision 与 steer 身份派生 legacy slot,不能反向伪装成 v4。`project.rs` 的 Agent DB `agent.runtime.finalization.lifecycle` 白名单必须兼容 `journalSchemaVersion` v1-v4,保证升级前四阶段 lifecycle 仍可扫描和幂等核对;lifecycle audit schema 本身继续为 v1。final-reply handoff 在 `prepared` 后仍保留,直到整个 finalization 和 stream commit 均完成后才与 journal 一起清理。
- assistant、Runtime completed 和 Goal completed 幂等提交后,finalization 必须用 journal 固定身份读取回复流。流缺失,或仍为 `streaming / failed / discarded` 等非 ready 状态时,Runtime 以 journal 中的 canonical response 重建同身份 ready stream;已有流身份冲突则失败关闭。ready 正文必须与 journal 完全一致才能推进为 committed;已 committed 且正文一致是幂等成功,正文冲突则停止恢复。
- committed 写入后必须立即回读,再次核对固定身份、`status=committed` 和完整正文;只有回读通过后才删除 finalization journal 与所属 handoff。stream 写入/回读失败,或在 `ResponseStreamCommitted` checkpoint 后、清理前退出,都保留 journal 和可恢复 `finalizing` 状态;Runner 重放同一 finalization,只补 stream commit 或幂等清理,不重写 assistant、不调用 Provider。
### Exactly-once 承诺边界
- V1.41 **只保证 handoff 已可靠落盘之后的 exactly-once 消费**:同一响应只能由匹配的 compaction 或 finalization 身份消费,恢复只回放该 handoff,不再请求 Provider,终局只有一条 canonical assistant/completed/committed stream。
- **外部 Provider 已返回完整响应、但 Runtime 尚未写入 handoff 的硬杀窗口仍然不能承诺 Provider 调用 exactly-once**。该窗口没有可重放成功响应,只能把未闭合 `started` 视为未知并进入 reconciliation;后续人工处理可能需要重新请求 Provider,因此不得把消费幂等扩大为端到端 Provider 物理调用幂等。
- tool-plan 成功响应/function arguments 的 durable handoff 另行设计;真实外部 Provider 的 final-reply 退避期 Runner 强杀也仍需独立 E2E,不得借用 V1.39 首次 tool-plan PASS 或本轮确定性 mock 门禁宣称已通过。
### 确定性测试矩阵
| 范围 | 故障/恢复窗口 | 必须断言 | 定向用例 |
| --- | --- | --- | --- |
| sidecar 契约 | 首次写入、同内容重写、`.previous`、损坏/超限/路径或内容冲突 | 严格 schema、`0600` 原子交接、实际 requestId 稳定、tool call 拒绝、thinking/密钥/路径零落盘 | `provider_handoff_*` 单元用例 |
| final-reply handoff-first | Provider 成功交接后、lifecycle 终态前停止 | 恢复零新网络请求,原 requestId `started -> completed`,唯一 assistant/completed/committed stream,终局零 retry/handoff/finalization | `provider_handoff_final_reply_restart_replays_success_without_network_request` |
| compaction consumer | 前置压缩 handoff 已落盘、compaction sidecar 未提交,以及 sidecar 已提交但 handoff 未清理 | 压缩结果回读后清理,不重放 tool-plan/压缩,只发后续 final-reply | `provider_handoff_final_reply_compaction_restart_only_requests_final_reply` |
| identity/retry 冲突 | Goal/steer/revision/request/config 漂移,或 retry identity/attempt/slot 与 handoff 不一致 | 漂移先闭合旧 lifecycle 再作废,审计零响应正文;retry 冲突进入 reconciliation 且零 Provider 请求 | `provider_handoff_identity_drift_closes_lifecycle_without_leaking_response``provider_handoff_` 冲突门禁 |
| finalization schema/身份 | v4 slot 被篡改,或读取缺少 slot 的 v3 journal 与 v1-v4 lifecycle | v4 `responseRequestSlot` 进入 `finalizationId` 指纹;v3 按旧身份可读;Agent DB lifecycle 白名单接受 v1-v4 journal schema | `finalization_v4_binds_response_request_slot_into_identity``finalization_v3_without_response_request_slot_remains_readable``finalization_lifecycle_accepts_supported_v1_through_v4_journals` |
| finalization 四 checkpoint | `Prepared / AssistantAppended / RuntimeCompleted / ResponseStreamCommitted` 任一点中断 | 原 messageId/finalizationId 幂等恢复,无 Provider/工具重放,最终 journal/handoff 清理 | `finalization_*``response_stream_committed_checkpoint_recovers_by_idempotent_cleanup` |
| 回复流重建 | stream 缺失、停在 streaming、commit 写失败、已 committed 后清理中断 | 按 v4 固定 slot 从 journal 重建 readycommitted 写后回读,正文唯一,冲突失败关闭 | `response_stream_finalization_recovers_missing_stream_after_project_revision_drift``response_stream_finalization_repairs_streaming_after_commit_write_failure``response_stream_committed_checkpoint_recovers_by_idempotent_cleanup` |
| Runner busy | primary、`.previous` 或损坏 handoff 存在时请求 idle shutdown | `idle=false`,不进入 draining;清理后才可 shutdown | `durable_provider_handoff_prevents_shutdown_even_when_corrupt` |
2026-07-20 当前最终实现的最新验证证据为:`provider_retry_` 21/21、`response_stream_` 23/23、`finalization_resume_` 12/12Tauri/Rust 串行全量共 989 tests`985 passed / 4 ignored / 0 failed`。这些数字替代 V1.40 较早快照,后续当前结果统一使用本行口径。
上表是 V1.41 的确定性门禁,不代表真实外部 Provider E2E 结论。除表内窗口外,还必须继续扫描 task/event/Agent DB/CLI/report,确认 Provider 响应、compaction summary、API Key、Provider URL 和项目/配置绝对路径公共泄漏均为 0。
## 验收命令
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml agent_goal_ -- --nocapture`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml goal_context_bundle_v4_migrates_v3_and_v2_then_rejects_plan_mismatch -- --nocapture`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml provider_handoff_ -- --nocapture --test-threads=1`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml response_stream_ -- --nocapture`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml finalization_v4_binds_response_request_slot_into_identity -- --nocapture`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml finalization_v3_without_response_request_slot_remains_readable -- --nocapture`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml finalization_lifecycle_accepts_supported_v1_through_v4_journals -- --nocapture`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml mcp_ -- --nocapture`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml provider_action_batch_ -- --nocapture`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml provider_retry_ -- --nocapture --test-threads=1`
@@ -607,3 +607,7 @@ game-project/
- 已有 durable/未观察 claim 与 legacy claimed delivery 继续按原 action/group 身份恢复,不要求先创建新绑定;新 claim 必须先成功解析 effective snapshot 并核对 binding,再进入 V1.35-V1.37 的全锁、预算、完整 observation 和 group 数量门禁。snapshot 绑定后 global policy 的 `matched / drifted / unreadable` 只进入有界 status/诊断,不能改变后续执行;新 policy 只由后续新父 run 采用。2026-07-19 self-test、52/52 collaboration 定向回归和 949 passed/4 ignored Rust 全量已完成,终态快照保留也有独立回归;真实 mixed-swarm 功能样本已闭合但受正式 endpoint 外部重启污染,私有配置源的后续独立运行又连续耗尽 transient Provider retry,不能拼接证据,当前仍**不得声称 V1.38 真实 E2E 已 PASS**。详细报告以 Runtime 技术方案 V1.38 节为准。
- 2026-07-19 起,同一 Runtime 文档的“V1.39 首次规划 Provider 瞬态重试持久等待态”作为后台首次规划重试的恢复事实源。tool-plan `repair-0` 及其自动 context-compaction 在瞬态失败后先写 per-Agent/run retry sidecar,再投影 `waiting-for-provider-retry` 并释放 lane;Runner 重启按到期时间恢复同一 Session/run/loop/attempt,同 Agent 后续任务保持 FIFO,其它 Agent 可并行。final-reply、手动压缩和 tool-plan `repair-N` 暂不扩展为持久重试;工具策略刷新时间不得进入请求指纹。最终代码已完成独立真实 `gpt-5.5 / openai_chat / high` PASS:父 Agent 首次规划在 `30s` 退避期执行一次 pidfd Runner 强杀,重启后 sidecar 身份/attempt/retryAt 稳定,到期前零早发且第二个请求网络接收时间不早于 retryAt;随后同一父 run 完成双专业 Agent 真重叠、2+1 delivery、唯一 repair、唯一最终回复和零重复/残留/正文/Key/路径泄漏,`37/37` lifecycle 闭合为 `36 completed + 1 injected failed + 1 retry`incidental failure/retry 均为 0,隔离现场完整清理。额外真实瞬态失败按 request identity 单独计数且仍须完整恢复,不能混入受控注入链。该证据不改变 V1.38 真实 E2E 尚未 PASS 的独立结论。
- 2026-07-19 V1.40 在上述 sidecar 协议上补齐后台 `final-reply` 和其前置自动 context-compaction,分别使用 `requestKind=final-reply / final-reply-context-compaction`。final-reply prompt 改用可由 context bundle 精确恢复的有界收束摘要,不包含会随恢复阶段变化的 Runtime 投影或无法持久重建的临时 tool-plan 字段;response 状态先与 context bundle 同步,再允许发出 Provider 请求。Runner 恢复命中任一 final-reply sidecar 时按原 `nextLoopIndex / baseRequestSlot / requestFingerprint` 跳过新 planning,压缩链先完成原压缩再继续 final reply;流式失败半句不提交,成功后仍只经原 finalization journal 写一条 canonical assistant。稳定身份漂移则删除旧 sidecar并在同一 run 重新规划。确定性门禁已覆盖两种 sidecar-first 投影窗口、恢复扫描、网络接收时间不早于 `retryAt`、失败/恢复 HTTP body 逐字节一致、唯一 lifecycle/assistant/stream 和终局零 sidecar;当前 `provider_retry_` 为 21/21。真实外部 Provider 的 final-reply 退避期 Runner 强杀尚未独立 PASS;成功响应到压缩 sidecar/finalization journal 的交接窗口,以及 journal 清理到 stream committed 的窗口也尚未补齐,不能借用 V1.39 首次 tool-plan 证据或宣称成功请求 exactly-once/stream 终态事务。手动压缩与 tool-plan `repair-N` 仍保持进程内重试。
- 2026-07-20 起,同一 Runtime 文档的“V1.41 Provider 成功响应持久交接与回复流终态恢复”覆盖 V1.40 的两个成功窗口。`.agent/runtime/provider-handoffs/<agentKey>/<runKey>.json` 使用严格 `game-creator-provider-handoff.v1`,绑定完整 retry identity、实际物理 `providerRequestId`、slot/attempt 和经 thinking、密钥、项目路径及绝对路径过滤后的规范文本响应;写入采用 `0600` 原子 sidecar、`.previous` 恢复和写后完整回读。当前仅覆盖 tool-plan 前置自动 `context-compaction`、final-reply 前置自动 `final-reply-context-compaction``final-reply`;手动压缩和 tool-plan 成功响应/function arguments 不写 handoff。
- V1.41 对支持范围执行 handoff-firstProvider 成功后先提交并回读 handoff,再闭合同一实际 requestId 的 `completed` lifecycle;恢复先消费 handoff,匹配 retry 时复用原 attempt/slot 且零新网络请求,retry identity/attempt/slot 冲突进入 reconciliation,稳定身份漂移先闭合旧 lifecycle 再作废并同 run 重规划。自动 compaction 必须在规范 sidecar 写入并回读一致后才清理 handofffinal-reply handoff 则保留到 finalization 和 stream commit 全部完成。任一 primary、`.previous` 或损坏 handoff 都让 Runner 保持 busy,阻止 `runner.shutdown_if_idle`
- finalization schema 已正式升为 `game-creator-runtime-finalization.v4``responseRequestSlot` 与 response revision/fingerprint、steer cursor、计划及 Goal 快照共同进入 `finalizationId` 指纹,篡改 slot 必须失败关闭;`project.rs` 的 Agent DB finalization lifecycle 白名单兼容 `journalSchemaVersion` v1-v4,缺少 slot 的 v3 journal 继续按旧指纹和 legacy slot 派生规则读取。assistant、Runtime 和 Goal 提交后,缺失或仍为 streaming/failed/discarded 的回复流由 journal 固定身份重建为 ready,再推进 committedcommitted 写后必须回读身份、状态和正文,成功后才一起删除 finalization journal/handoff,失败则保留 `finalizing` 供 Runner 幂等恢复。确定性测试矩阵以 Runtime V1.41 章节中的 handoff、compaction、四 checkpoint、stream 重建、schema 兼容和 Runner busy 用例为准。2026-07-20 最新验证为 `provider_handoff_` 11/11、`provider_retry_` 21/21、`response_stream_` 23/23、`finalization_resume_` 12/12Tauri/Rust 串行全量共 989 tests`985 passed / 4 ignored / 0 failed`,不再沿用 V1.40 的旧局部计数。
- V1.41 的承诺严格限定为 **handoff 已落盘后的 exactly-once 消费**。外部 Provider 已返回完整响应、但 Runtime 尚未写 handoff 的硬杀窗口仍不能承诺 Provider 调用 exactly-once,后续只能进入 reconciliation 并可能需要人工决定是否重发;tool-plan 成功响应仍不在本轮 handoff。真实外部 Provider 的 final-reply 退避期 Runner 强杀继续是独立 E2E,不能用 V1.39 首次 tool-plan PASS 或确定性 mock 结果替代。