Compare commits

...

1 Commits

Author SHA1 Message Date
kdletters 00053cf1d3 修复游戏创作工作流与UI图集事务
将固定图片规则降为默认合同并允许Supervisor持久委派扩展UI图集
修复条件Graph路由、终态投影、steer窗口和manifest只读刷新
将UI主图、切片、manifest与Agent DB审计收口为可恢复事务
加固跨平台句柄、journal重放、残留隔离和Agent DB容量门禁
补充工作流、事务恢复、Provider与项目夹具回归
同步技术方案与团队共享决策
2026-08-07 19:28:13 +08:00
22 changed files with 10362 additions and 499 deletions
File diff suppressed because it is too large Load Diff
@@ -519,7 +519,7 @@ fn game_creator_art_asset_plan_tool_plan_prompt(
);
}
format!(
"{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceImageSrc,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。"
"{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceImageSrc,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。当且仅当当前 Project Supervisor 的静态委派 expectedArtifacts 同时保留 assets/art-spritesheet.png 并声明另一个 UI 图集 PNG 时,可按委派语义额外生成该路径,固定使用 1:1、1K、assetKind=ui-spritesheet 和贴合任务的 assetLabel;初次生成 replaceExisting=false,只有完整继承同一 expectedArtifacts 的唯一返工委派可设 replaceExisting=true。不得自行发明额外路径。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。"
)
}
@@ -1234,6 +1234,9 @@ mod tests {
assert!(with_canvas.contains("warning.code=postprocess-failed-source-preserved"));
assert!(with_canvas.contains("不得登记、验收或自动重试"));
assert!(with_canvas.contains("仅 sliceWarning"));
assert!(with_canvas.contains("Project Supervisor 的静态委派 expectedArtifacts"));
assert!(with_canvas.contains("assetKind=ui-spritesheet"));
assert!(with_canvas.contains("不得自行发明额外路径"));
}
#[test]
@@ -629,6 +629,11 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness(
.actions
.iter()
.any(|action| action.tool.trim() == "agent.delegate");
let has_code_asset_route = agent_id == "code-director"
&& plan
.actions
.iter()
.any(|action| action.tool.trim() == "agent.route_manifest");
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
if let Some(failed_playtest_revision) = verification_gate.failed_playtest_revision {
if project_revision < failed_playtest_revision {
@@ -765,6 +770,7 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness(
|| mutation_revision.is_some()
|| !plan.response.trim().is_empty()
|| has_mutation
|| has_code_asset_route
{
return Ok(());
}
@@ -813,6 +819,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_read_only_delivery_pla
| "command.output_read"
| "command.poll"
| "image.inspect" => false,
"agent.route_manifest" => agent_id != "code-director",
"preview.validate" => agent_id != "preview-playtest",
"command.run_limited" => {
agent_id != "preview-readiness"
@@ -1771,6 +1778,13 @@ mod tests {
serde_json::json!({"commandId": "game.static_smoke"}),
);
let preview = plan_for("preview.validate", serde_json::json!({}));
let route_manifest = plan_for(
"agent.route_manifest",
serde_json::json!({
"strategy": "use-existing-art",
"missingAssetSlots": [],
}),
);
assert!(validate_agent_runtime_autonomous_read_only_delivery_plan(
"preview-readiness",
@@ -1784,9 +1798,41 @@ mod tests {
&preview,
)
.is_ok());
assert!(validate_agent_runtime_autonomous_read_only_delivery_plan(
"code-director",
true,
&route_manifest,
)
.is_ok());
let verification_gate = AgentRuntimeVerificationGate {
schema_version: "test".to_string(),
project_id: "test".to_string(),
agent_id: "code-director".to_string(),
run_id: "test".to_string(),
requires_verification: false,
mutation_revision: None,
verified_revision: None,
last_mutation_tool: None,
last_verification_tool: None,
last_verification_status: None,
failed_playtest_revision: None,
updated_at: 0,
};
assert!(validate_agent_runtime_autonomous_plan_liveness(
"code-director",
AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1,
0,
&verification_gate,
&[],
&route_manifest,
false,
false,
)
.is_ok());
for (agent_id, plan) in [
("quality-review", &smoke),
("quality-review", &preview),
("quality-review", &route_manifest),
("preview-readiness", &preview),
("preview-playtest", &smoke),
] {
@@ -19,12 +19,22 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_content(
"taskContext": task,
}))
.map_err(|error| format!("序列化待确认工具动作失败:{error}"))?;
validate_agent_runtime_pending_serialized_content(root, &content)
validate_agent_runtime_pending_sensitive_serialized_content(&content)?;
let action_content = serde_json::to_string(action)
.map_err(|error| format!("序列化待确认工具动作失败:{error}"))?;
validate_agent_runtime_pending_project_path_content(root, &action_content)
}
pub(in crate::agent) fn validate_agent_runtime_pending_serialized_content(
root: &Path,
content: &str,
) -> Result<(), String> {
validate_agent_runtime_pending_sensitive_serialized_content(content)?;
validate_agent_runtime_pending_project_path_content(root, content)
}
fn validate_agent_runtime_pending_sensitive_serialized_content(
content: &str,
) -> Result<(), String> {
let lower = content.to_ascii_lowercase();
let sensitive_rule = [
@@ -46,6 +56,13 @@ pub(in crate::agent) fn validate_agent_runtime_pending_serialized_content(
"待确认工具输入命中敏感规则 #{rule}Runtime 已拒绝持久化"
));
}
Ok(())
}
fn validate_agent_runtime_pending_project_path_content(
root: &Path,
content: &str,
) -> Result<(), String> {
let root_display = root.to_string_lossy();
if !root_display.is_empty() && content.contains(root_display.as_ref()) {
return Err("待确认工具输入包含项目绝对路径,Runtime 已拒绝持久化".to_string());
@@ -374,7 +391,12 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_record(
}
let serialized = serde_json::to_string(pending)
.map_err(|error| format!("序列化 Agent Runtime 待确认动作失败:{error}"))?;
validate_agent_runtime_pending_serialized_content(root, &serialized)?;
validate_agent_runtime_pending_sensitive_serialized_content(&serialized)?;
let mut project_path_record = pending.clone();
project_path_record.task.clear();
let project_path_serialized = serde_json::to_string(&project_path_record)
.map_err(|error| format!("序列化 Agent Runtime 待确认路径校验记录失败:{error}"))?;
validate_agent_runtime_pending_project_path_content(root, &project_path_serialized)?;
let action_fingerprint = agent_runtime_pending_tool_action_fingerprint(
&pending.action,
&pending.task,
@@ -762,6 +784,115 @@ mod tests {
}
}
#[test]
fn pending_content_allows_project_root_in_task_context_when_action_is_relative() {
let root = Path::new("/data/dsk/games/game01");
let action = AgentRuntimeToolAction {
tool: "file.list".to_string(),
reason: Some("核对当前资产".to_string()),
input: serde_json::json!({ "path": "assets" }),
};
validate_agent_runtime_pending_tool_action_content(
root,
&action,
"继续修复 /data/dsk/games/game01 中的现有项目",
)
.expect("task context may identify the current project while tool input stays relative");
}
#[test]
fn pending_content_rejects_project_root_in_tool_action() {
let root = Path::new("/data/dsk/games/game01");
let action = AgentRuntimeToolAction {
tool: "file.read".to_string(),
reason: Some("读取当前入口".to_string()),
input: serde_json::json!({
"path": "/data/dsk/games/game01/game/index.html"
}),
};
let error =
validate_agent_runtime_pending_tool_action_content(root, &action, "继续修复当前项目")
.expect_err("tool action must keep using a project-relative path");
assert!(error.contains("项目绝对路径"), "{error}");
}
#[tokio::test]
async fn provider_batch_round_trips_when_only_task_identifies_project_root() {
let temporary = crate::tests::canonical_test_tempdir("pending-task-project-root-");
let root = temporary.path();
init_local_game_project_at(root, "pending-task-project-root", "待确认路径批次测试")
.expect("init project");
let task = format!("继续修复 {} 中的现有项目", root.display());
let mut runtime = start_game_creator_agent_runtime_task_at(
root,
"art-director",
&task,
"pending-task-project-root-run",
"agent-ready-task-scheduler",
"核对当前项目",
vec!["核对当前项目".to_string()],
)
.expect("start runtime");
runtime.loop_iteration = 1;
let plan = AgentRuntimeToolPlan {
thinking_summary: "核对相对路径项目上下文".to_string(),
plan_update: None,
plan: vec!["核对资产".to_string(), "核对入口".to_string()],
actions: vec![
AgentRuntimeToolAction {
tool: "file.list".to_string(),
reason: Some("核对当前资产".to_string()),
input: serde_json::json!({ "path": "assets" }),
},
AgentRuntimeToolAction {
tool: "file.read".to_string(),
reason: Some("核对当前入口".to_string()),
input: serde_json::json!({
"path": "game/index.html",
"startLine": 1,
"maxLines": 20
}),
},
],
response: String::new(),
};
let project_revision =
read_game_creator_agent_runtime_project_revision(root).expect("read project revision");
let repository_fingerprint = build_repository_startup_context_at(root)
.expect("build repository context")
.fingerprint;
let prepared = prepare_game_creator_agent_runtime_provider_action_batch(
root,
&runtime,
&task,
&plan,
&[],
&project_revision,
&repository_fingerprint,
)
.await
.expect("prepare provider batch");
let prepared = match prepared {
AgentRuntimeProviderActionBatchPreparation::Ready(batch) => batch,
other => panic!("expected ready provider batch, got {other:?}"),
};
let persisted = read_game_creator_agent_runtime_provider_action_batch(
root,
&runtime.agent_id,
&runtime.run_id,
)
.expect("read persisted provider batch");
assert_eq!(persisted.batch_id, prepared.batch_id);
assert_eq!(persisted.actions.len(), 2);
assert!(persisted.actions.iter().all(|pending| pending.task == task));
assert_eq!(persisted.actions[0].action, plan.actions[0]);
assert_eq!(persisted.actions[1].action, plan.actions[1]);
}
#[test]
fn pending_content_still_rejects_api_key_fields_and_secret_tokens() {
let root = Path::new("C:\\workspace");
@@ -1,7 +1,12 @@
use super::*;
pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[&str] =
&["game-art", "icon-spec", "ui-prototype", "art-spritesheet"];
pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[&str] = &[
"game-art",
"icon-spec",
"ui-prototype",
"art-spritesheet",
"ui-spritesheet",
];
#[cfg(test)]
mod canvas_asset_kind_contract_tests {
@@ -11,7 +16,13 @@ mod canvas_asset_kind_contract_tests {
fn canvas_asset_kind_catalog_preserves_authoritative_contract() {
assert_eq!(
AGENT_RUNTIME_CANVAS_ASSET_KINDS,
&["game-art", "icon-spec", "ui-prototype", "art-spritesheet"]
&[
"game-art",
"icon-spec",
"ui-prototype",
"art-spritesheet",
"ui-spritesheet",
]
);
}
}
@@ -7495,6 +7495,47 @@ fn autonomous_ready_terminal_failures_are_projected_without_retry() {
}
}
#[test]
fn autonomous_scheduler_reprojects_a_recovered_terminal_child_before_returning() {
let (_temporary, root, parent_state, _contract) =
autonomous_fixture("做一个完整小游戏", "autonomous-recovered-terminal-parent");
update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Running)
.expect("mark recovered autonomous child running");
let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "design-director");
append_game_creator_agent_runtime_task_record(
&root,
&AgentRuntimeTaskRecord {
status: "completed".to_string(),
phase: "completed".to_string(),
current_action: "recovered child already completed".to_string(),
terminal_detail: Some("completed before scheduler recovery".to_string()),
error: None,
updated_at: unix_timestamp(),
..record
},
)
.expect("append recovered autonomous child terminal");
let scheduled = schedule_autonomous_game_build_ready_tasks_at(
&root,
&parent_state.agent_id,
&parent_state.run_id,
3,
)
.expect("recover terminal autonomous child");
assert_eq!(scheduled.len(), 1);
assert_eq!(
read_manifest_for_project(&root)
.expect("read reprojected manifest")
.tasks
.iter()
.find(|task| task.id == "design-director")
.map(|task| &task.status),
Some(&GameCreationAppTaskStatus::Completed),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_autonomous_child_terminal_projection_preserves_all_manifest_updates() {
let (_temporary, root, parent_state, _contract) =
@@ -705,6 +705,19 @@ pub(in crate::agent) fn continuation_for_game_creator_agent_runtime_steer(
..AgentRuntimeContinuationContext::default()
};
context_tracker.apply_to_continuation(&mut continuation);
if next_loop_index > 0
&& next_loop_index % AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT == 0
&& continuation.window_completed_loops > 0
{
continuation.window_completed_loops = 0;
continuation.window_observation_fingerprints.clear();
continuation.last_window_fingerprint =
super::context_window::agent_runtime_context_window_fingerprint(
&context_tracker.observation_signatures,
)
.or_else(|| context_tracker.last_window_fingerprint.clone());
continuation.context_stalled = false;
}
continuation
}
@@ -876,4 +889,41 @@ mod tests {
fs::remove_dir_all(root).ok();
}
#[test]
fn steer_at_a_skipped_checkpoint_boundary_starts_a_fresh_window() {
let temporary = crate::tests::canonical_test_tempdir("steer-window-boundary-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "project-steer-window", "追加指令窗口边界")
.expect("project init");
let runtime = start_game_creator_agent_runtime_task_at(
&root,
"code-prototype",
"验证追加指令窗口边界",
"steer-context-window-boundary-run",
"agent-background-task",
"追加指令窗口边界测试",
vec!["恢复时保持 context bundle 有效".to_string()],
)
.expect("start steer window boundary runtime state");
let mut tracker = AgentRuntimeContextWindowTracker {
completed_loops: AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT - 1,
..AgentRuntimeContextWindowTracker::default()
};
tracker
.observation_signatures
.insert("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string());
let continuation = continuation_for_game_creator_agent_runtime_steer(
&runtime,
&AgentRuntimeToolPlan::default(),
&[],
AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT,
&tracker,
);
assert_eq!(continuation.window_completed_loops, 0);
assert!(continuation.window_observation_fingerprints.is_empty());
assert!(!continuation.context_stalled);
}
}
@@ -33,7 +33,11 @@ pub(in crate::agent) use run_status::*;
pub(in crate::agent) use task_ops::*;
#[cfg(test)]
pub(crate) use media::validate_agent_runtime_canvas_replacement_authorization_at;
pub(crate) use media::{
resolve_agent_runtime_platform_art_generation_options_at,
validate_agent_runtime_canvas_delegated_ui_spritesheet_authorization_at,
validate_agent_runtime_canvas_replacement_authorization_at, AGENT_RUNTIME_ART_SPRITESHEET_PATH,
};
pub(crate) use action_history::{
is_valid_agent_runtime_action_id, observe_agent_runtime_action_history,
File diff suppressed because it is too large Load Diff
@@ -1257,7 +1257,6 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
"missingAssetSlots": {
"type": "array",
"maxItems": 2,
"uniqueItems": true,
"items": { "type": "string", "enum": ["art-spec", "core-spritesheet"] }
}
}
@@ -1381,7 +1380,7 @@ mod tests {
let Some(object) = schema.as_object() else {
return;
};
for keyword in ["oneOf", "anyOf", "allOf", "not"] {
for keyword in ["oneOf", "anyOf", "allOf", "not", "uniqueItems"] {
if object.contains_key(keyword) {
issues.push(format!(
"strict schema contains unsupported {keyword} at {path}"
@@ -1026,9 +1026,131 @@ pub(crate) fn register_local_asset_entry(
id_prefix: &str,
source: GameCreationAppAssetSource,
) -> Result<UploadLocalAssetResult, String> {
let normalized_path = normalize_relative_path(local_path)?;
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
register_local_asset_entry_internal(root, local_path, kind, media_type, id_prefix, source, None)
.map(|(registered, _)| registered)
.map_err(LocalAssetRegistrationError::into_message)
}
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct CanvasAssetRegistrationAuditIdentity {
pub(crate) asset_id: String,
pub(crate) local_path: String,
pub(crate) record_type: String,
pub(crate) transaction_id: String,
}
#[derive(Debug, Eq, PartialEq)]
pub(crate) enum CanvasAssetRegistrationError {
BeforeMutation(String),
AuditAppendOutcomeUnknown {
registered: UploadLocalAssetResult,
audit: CanvasAssetRegistrationAuditIdentity,
message: String,
},
}
impl CanvasAssetRegistrationError {
pub(crate) fn audit_identity(&self) -> Option<&CanvasAssetRegistrationAuditIdentity> {
match self {
Self::BeforeMutation(_) => None,
Self::AuditAppendOutcomeUnknown { audit, .. } => Some(audit),
}
}
}
impl std::fmt::Display for CanvasAssetRegistrationError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BeforeMutation(message) => formatter.write_str(message),
Self::AuditAppendOutcomeUnknown {
audit, message, ..
} => write!(
formatter,
"{message};素材登记审计追加结果未知:assetId={}, localPath={}, recordType={}, transactionId={}",
audit.asset_id, audit.local_path, audit.record_type, audit.transaction_id
),
}
}
}
pub(crate) fn register_local_asset_entry_for_canvas_transaction(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
transaction_id: &str,
) -> Result<(UploadLocalAssetResult, String), CanvasAssetRegistrationError> {
let transaction_id = transaction_id.trim();
validate_agent_db_canvas_asset_transaction_id(transaction_id)
.map_err(CanvasAssetRegistrationError::BeforeMutation)?;
register_local_asset_entry_internal(
root,
local_path,
kind,
media_type,
id_prefix,
source,
Some(transaction_id),
)
.map_err(|error| error.into_canvas_error(transaction_id))
}
enum LocalAssetRegistrationError {
BeforeMutation(String),
AuditAppendOutcomeUnknown {
registered: UploadLocalAssetResult,
record_type: String,
message: String,
},
}
impl LocalAssetRegistrationError {
fn into_message(self) -> String {
match self {
Self::BeforeMutation(message) | Self::AuditAppendOutcomeUnknown { message, .. } => {
message
}
}
}
fn into_canvas_error(self, transaction_id: &str) -> CanvasAssetRegistrationError {
match self {
Self::BeforeMutation(message) => CanvasAssetRegistrationError::BeforeMutation(message),
Self::AuditAppendOutcomeUnknown {
registered,
record_type,
message,
} => CanvasAssetRegistrationError::AuditAppendOutcomeUnknown {
audit: CanvasAssetRegistrationAuditIdentity {
asset_id: registered.id.clone(),
local_path: registered.local_path.clone(),
record_type,
transaction_id: transaction_id.to_string(),
},
registered,
message,
},
}
}
}
fn register_local_asset_entry_internal(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
transaction_id: Option<&str>,
) -> Result<(UploadLocalAssetResult, String), LocalAssetRegistrationError> {
let normalized_path =
normalize_relative_path(local_path).map_err(LocalAssetRegistrationError::BeforeMutation)?;
let absolute_path = resolve_local_project_path(root, &normalized_path)
.map_err(LocalAssetRegistrationError::BeforeMutation)?;
let (manifest_path, mut manifest) =
read_or_create_manifest(root).map_err(LocalAssetRegistrationError::BeforeMutation)?;
let kind = if kind.is_empty() { "asset" } else { kind };
let media_type = if media_type.is_empty() {
"application/octet-stream"
@@ -1062,25 +1184,49 @@ pub(crate) fn register_local_asset_entry(
});
(id, "asset.register")
};
write_manifest(&manifest_path, &manifest)?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": record_type,
"assetId": id.clone(),
"localPath": normalized_path.clone(),
"kind": kind,
"mediaType": media_type,
"source": source_for_record,
}),
)?;
Ok(UploadLocalAssetResult {
if let Some(transaction_id) = transaction_id {
validate_agent_db_canvas_asset_audit_identity(transaction_id, &id, &normalized_path)
.map_err(LocalAssetRegistrationError::BeforeMutation)?;
}
write_manifest(&manifest_path, &manifest)
.map_err(LocalAssetRegistrationError::BeforeMutation)?;
let mut audit = serde_json::json!({
"recordType": record_type,
"assetId": id.clone(),
"localPath": normalized_path.clone(),
"kind": kind,
"mediaType": media_type,
"source": source_for_record,
});
if let Some(transaction_id) = transaction_id {
audit
.as_object_mut()
.expect("asset registration audit is an object")
.insert(
"transactionId".to_string(),
serde_json::Value::String(transaction_id.to_string()),
);
}
let registered = UploadLocalAssetResult {
id,
local_path: normalized_path.clone(),
absolute_path: absolute_path.to_string_lossy().into_owned(),
manifest_path: manifest_path.to_string_lossy().into_owned(),
})
};
let append_result = if transaction_id.is_some() {
append_agent_db_canvas_asset_transaction_audit_idempotent(root, audit).map(|_| ())
} else {
append_agent_db_record(root, audit)
};
if let Err(message) = append_result {
return Err(LocalAssetRegistrationError::AuditAppendOutcomeUnknown {
registered,
record_type: record_type.to_string(),
message,
});
}
Ok((registered, record_type.to_string()))
}
#[cfg(test)]
@@ -1088,6 +1234,152 @@ mod tests {
use super::*;
use std::io::{Read, Write};
fn unique_asset_registration_test_root(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"genarrative-asset-registration-{label}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
))
}
fn canvas_registration_source() -> GameCreationAppAssetSource {
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-project-1".to_string()),
resource_id: Some("resource-1".to_string()),
asset_object_id: Some("asset-object-1".to_string()),
task_id: Some("task-1".to_string()),
prompt: Some("test prompt".to_string()),
model: Some("test-model".to_string()),
generation_route: Some("test-route".to_string()),
generation_kind: Some("test-kind".to_string()),
reference_resource_ids: Vec::new(),
}
}
#[test]
fn canvas_registration_sync_unknown_error_retains_complete_audit_identity() {
let root = unique_asset_registration_test_root("post-sync-identity");
init_local_game_project_at(&root, "asset-registration-test", "素材登记测试")
.expect("initialize asset registration fixture");
let local_path = "assets/ui-spritesheet.png";
fs::write(root.join(local_path), b"test-image").expect("write asset registration fixture");
fs::write(
root.join(".agent/runtime/test-fail-after-agent-db-record-sync"),
"asset.register",
)
.expect("inject post-sync Agent DB failure");
let error = register_local_asset_entry_for_canvas_transaction(
&root,
local_path,
"ui-spritesheet",
"image/png",
"platform-art",
canvas_registration_source(),
"canvas-transaction-1",
)
.expect_err("post-sync append outcome must be surfaced as unknown");
let audit = error
.audit_identity()
.expect("unknown append error carries audit identity");
let registered = match &error {
CanvasAssetRegistrationError::AuditAppendOutcomeUnknown { registered, .. } => {
registered
}
CanvasAssetRegistrationError::BeforeMutation(_) => {
panic!("post-sync error must carry registered asset")
}
};
assert_eq!(audit.asset_id, registered.id);
assert_eq!(audit.local_path, local_path);
assert_eq!(audit.record_type, "asset.register");
assert_eq!(audit.transaction_id, "canvas-transaction-1");
assert!(error.to_string().contains("追加结果未知"));
let records = fs::read_to_string(root.join(".agent/agent.db"))
.expect("read durably appended asset audit");
let matching = records
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.find(|record| {
record.get("recordType").and_then(serde_json::Value::as_str)
== Some("asset.register")
&& record
.get("transactionId")
.and_then(serde_json::Value::as_str)
== Some("canvas-transaction-1")
})
.expect("post-sync failure occurs after the audit is durable");
assert_eq!(matching["assetId"], audit.asset_id);
assert_eq!(matching["localPath"], audit.local_path);
fs::remove_dir_all(root).ok();
}
#[test]
fn canvas_registration_rejects_non_compensatable_identity_before_manifest_mutation() {
let cases = [
(
"unsafe-transaction",
"assets/ui-spritesheet.png",
"platform-art",
"../unsafe",
),
(
"outside-assets",
"ui/ui-spritesheet.png",
"platform-art",
"canvas-transaction-2",
),
(
"unsafe-asset-id",
"assets/ui-spritesheet.png",
"../unsafe",
"canvas-transaction-3",
),
];
for (label, local_path, id_prefix, transaction_id) in cases {
let root = unique_asset_registration_test_root(label);
init_local_game_project_at(&root, "asset-registration-test", "素材登记测试")
.expect("initialize asset registration fixture");
let absolute_path = root.join(local_path);
fs::create_dir_all(
absolute_path
.parent()
.expect("asset identity fixture has parent"),
)
.expect("create asset identity fixture parent");
fs::write(&absolute_path, b"test-image").expect("write asset identity fixture");
let (manifest_path, manifest_before) =
read_or_create_manifest(&root).expect("read manifest before rejected registration");
let error = register_local_asset_entry_for_canvas_transaction(
&root,
local_path,
"ui-spritesheet",
"image/png",
id_prefix,
canvas_registration_source(),
transaction_id,
)
.expect_err("non-compensatable Canvas identity must be rejected");
assert!(
matches!(error, CanvasAssetRegistrationError::BeforeMutation(_)),
"{error}"
);
assert_eq!(
read_manifest(&manifest_path).expect("read manifest after rejected registration"),
manifest_before
);
fs::remove_dir_all(root).ok();
}
}
fn read_asset_test_request(stream: &mut std::net::TcpStream) {
stream
.set_read_timeout(Some(Duration::from_secs(2)))
@@ -193,7 +193,7 @@ pub(crate) fn get_local_game_manifest(
return Err(format!("不支持通过 manifest 执行命令:{command_id}"));
}
enforce_project_permission_policy(root, command_id)?;
read_manifest_for_project(root)
read_existing_manifest_for_project(root)
}
#[tauri::command]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -282,8 +282,11 @@ pub(crate) fn record_command_run(
pub(crate) fn read_manifest_for_project(root: &Path) -> Result<GameCreationAppManifest, String> {
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
let persisted = manifest.clone();
ensure_manifest_seed_tasks(root, &mut manifest);
write_manifest(&manifest_path, &manifest)?;
if manifest != persisted {
write_manifest(&manifest_path, &manifest)?;
}
Ok(manifest)
}
@@ -35,7 +35,8 @@ fn manifest_read_and_project_write_recover_previous_file() {
assert_eq!(recovered.project_id, "project-recovered");
assert_eq!(recovered.goal.as_deref(), Some("保留恢复副本内容"));
let recovered = read_manifest_for_project(&root).expect("rewrite recovered manifest");
write_manifest(&manifest_path, &recovered).expect("rewrite recovered manifest");
let recovered = read_manifest(&manifest_path).expect("read installed manifest");
assert_eq!(recovered.project_id, "project-recovered");
assert!(manifest_path.is_file());
assert!(!backup_path.exists());
@@ -43,6 +44,20 @@ fn manifest_read_and_project_write_recover_previous_file() {
fs::remove_dir_all(root).ok();
}
#[test]
fn current_manifest_read_does_not_acquire_the_write_lock() {
let root = unique_manifest_test_root("current-read-is-pure");
let manifest_path = root.join(".agent/manifest.json");
let manifest = new_game_creation_app_manifest("project-current", "当前项目");
write_manifest(&manifest_path, &manifest).expect("write current manifest");
let _write_lock = acquire_manifest_write_lock(&manifest_path).expect("hold manifest lock");
let read = read_manifest_for_project(&root).expect("read current manifest without writing");
assert_eq!(read, manifest);
fs::remove_dir_all(root).ok();
}
fn version_fixture(
version_id: &str,
parent_version_id: Option<&str>,
@@ -1215,6 +1215,172 @@ async fn visual_specialists_reject_overriding_their_fixed_image_contract() {
fs::remove_dir_all(root).ok();
}
#[test]
fn supervisor_delegation_can_extend_art_asset_plan_with_a_ui_spritesheet_contract() {
let root = unique_project_path();
init_local_game_project_at(
&root,
"project-ui-spritesheet-contract",
"UI 图集委派合同测试",
)
.expect("project init");
let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID;
let parent_session_id = "ui-spritesheet-parent-session";
let parent_run_id = "ui-spritesheet-parent-run";
let child_agent_id = "art-asset-plan";
let child_session_id = "ui-spritesheet-child-session";
let parent_action_id = "ui-spritesheet-delegate-action";
let delegation_id = agent_runtime_delegation_id(
parent_agent_id,
parent_run_id,
child_agent_id,
parent_action_id,
);
let child_run_id = format!("delegated-{delegation_id}");
let expected_artifacts = vec![
AGENT_RUNTIME_ART_SPRITESHEET_PATH.to_string(),
"assets/ui-spritesheet.png".to_string(),
];
let delivery = new_static_delegate_delivery_with_contract(
parent_agent_id,
parent_session_id,
parent_run_id,
parent_action_id,
&delegation_id,
child_agent_id,
child_session_id,
&child_run_id,
&["保留核心图集并补齐运行时 UI 透明图集".to_string()],
&expected_artifacts,
None,
);
create_or_read_static_delegate_delivery_at(&root, &delivery)
.expect("persist UI spritesheet delivery");
let parent_task = AgentRuntimeTaskRecord {
goal_id: None,
goal_revision: 0,
goal_status: None,
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
agent_id: parent_agent_id.to_string(),
task_id: parent_agent_id.to_string(),
session_id: parent_session_id.to_string(),
run_id: parent_run_id.to_string(),
source: AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE.to_string(),
run_profile: default_agent_runtime_run_profile(),
run_profile_binding_fingerprint: String::new(),
parent_agent_id: None,
parent_run_id: None,
delegation_id: None,
task: "安排补充 UI 图集".to_string(),
status: "running".to_string(),
phase: "waiting-for-delegate-receipts".to_string(),
current_action: "等待 UI 图集".to_string(),
terminal_detail: None,
error: None,
updated_at: unix_timestamp(),
};
write_agent_runtime_task_record_for_test(&root, &parent_task);
write_agent_runtime_task_record_for_test(
&root,
&AgentRuntimeTaskRecord {
goal_id: None,
goal_revision: 0,
goal_status: None,
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
agent_id: child_agent_id.to_string(),
task_id: child_agent_id.to_string(),
session_id: child_session_id.to_string(),
run_id: child_run_id.clone(),
source: "agent-delegate".to_string(),
run_profile: default_agent_runtime_run_profile(),
run_profile_binding_fingerprint: String::new(),
parent_agent_id: Some(parent_agent_id.to_string()),
parent_run_id: Some(parent_run_id.to_string()),
delegation_id: Some(delegation_id),
task: "生成运行时 UI 透明图集".to_string(),
status: "running".to_string(),
phase: "planning".to_string(),
current_action: "准备生成 UI 图集".to_string(),
terminal_detail: None,
error: None,
updated_at: unix_timestamp(),
},
);
let canonical = PlatformArtAssetGenerationOptions {
output_path: Some(AGENT_RUNTIME_ART_SPRITESHEET_PATH.to_string()),
aspect_ratio: "1:1".to_string(),
image_size: "1K".to_string(),
asset_kind: "art-spritesheet".to_string(),
asset_label: "游戏首版核心美术素材".to_string(),
replace_existing: false,
};
let requested = PlatformArtAssetGenerationOptions {
output_path: Some("assets/ui-spritesheet.png".to_string()),
aspect_ratio: "1:1".to_string(),
image_size: "1K".to_string(),
asset_kind: "ui-spritesheet".to_string(),
asset_label: "晶穹叠阵运行时 UI 透明图集".to_string(),
replace_existing: false,
};
let resolved = resolve_agent_runtime_platform_art_generation_options_at(
&root,
child_agent_id,
&child_run_id,
canonical.clone(),
requested.clone(),
)
.expect("durable Supervisor delivery authorizes the declared UI spritesheet");
assert_eq!(resolved, requested);
assert!(resolve_agent_runtime_platform_art_generation_options_at(
&root,
child_agent_id,
"ordinary-art-run",
canonical.clone(),
requested,
)
.expect_err("ordinary direct run cannot extend the fixed image contract")
.contains("静态专业委派"));
assert!(resolve_agent_runtime_platform_art_generation_options_at(
&root,
child_agent_id,
&child_run_id,
canonical.clone(),
PlatformArtAssetGenerationOptions {
output_path: Some(AGENT_RUNTIME_ART_SPRITESHEET_PATH.to_string()),
asset_kind: "ui-spritesheet".to_string(),
..canonical.clone()
},
)
.expect_err("delegation cannot change the canonical core path semantics")
.contains("不能覆盖固定输出合同"));
write_agent_runtime_task_record_for_test(
&root,
&AgentRuntimeTaskRecord {
status: "failed".to_string(),
phase: "failed".to_string(),
current_action: "父 run 已失败".to_string(),
terminal_detail: Some("父 run 已失败".to_string()),
error: Some("parent-failed".to_string()),
updated_at: unix_timestamp(),
..parent_task
},
);
assert!(
validate_agent_runtime_canvas_delegated_ui_spritesheet_authorization_at(
&root,
child_agent_id,
&child_run_id,
"assets/ui-spritesheet.png",
)
.expect_err("terminal parent revokes delegated UI generation")
.contains("未授权")
);
fs::remove_dir_all(root).ok();
}
#[test]
fn design_foundation_rejects_scene_image_stale_run_and_stale_sha_visual_proofs() {
let _config_guard = crate::tests::write_test_local_config(
@@ -1849,6 +1849,8 @@ async fn platform_art_generation_step_falls_back_without_leaking_editor_key() {
let root = unique_project_path();
let config_dir = unique_project_path();
let base_url = spawn_mock_external_canvas_generation_failure_server();
init_local_game_project_at(&root, "project-art-fallback", "未命名游戏原型")
.expect("initialize platform art fallback project");
fs::create_dir_all(&config_dir).expect("runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
@@ -1888,7 +1890,7 @@ async fn platform_art_generation_step_falls_back_without_leaking_editor_key() {
assert_eq!(step.status, "failed");
assert!(step.output_paths.is_empty());
assert!(step.summary.contains("HTTP 500"));
assert!(step.summary.contains("HTTP 500"), "{}", step.summary);
assert!(!step.summary.contains("editor-fallback-secret"));
assert!(read_manifest_for_project(&root).unwrap().assets.is_empty());
@@ -7691,6 +7691,7 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() {
"icon-spec",
"ui-prototype",
"art-spritesheet",
"ui-spritesheet",
null
])
);
@@ -1,5 +1,39 @@
# 决策记录
## 2026-08-07 UI 图集固定输出续跑与跨平台整组回滚
- 背景:UI 图集 durable 续跑在主图已经安装后仍会把同一 cohort 当成“初次覆盖”拒绝;进程若中断在 cohort 的 backup / publish 边界,`.previous / .replacement` 没有 UI 专用恢复入口。live rollback 又复用原事务 suffix,在 Windows 已存在 `.previous.<suffix>` 时会因目标存在而无法恢复主图。最后一条 `canvas.asset_generate` 审计追加错误也曾被 UI 分支吞掉,留下已登记但无完整审计的半成功合同。
- 决策:`ui-spritesheet + outputPath` 在项目写锁内扫描与该输出一一对应的 cohort 残留;canonical cohort 存在时,先完整校验同源清单、连续切片、稳定身份、PNG 尺寸和双摘要,再清理至多一组可信残留;canonical 缺失时只允许恰好一组可信 previous 恢复,多组、replacement-only 或任一不可信目录 / 文件失败关闭。恢复和清理还必须匹配项目 manifest 已登记主图的 `resourceId`,并把校验、删除和 no-replace 恢复绑定到同一目录句柄,不能在 pathname 复核后操作可能已被外部进程替换的目录。durable 主图已安装后,只有当前 cohort 与同一次结果的 source、resourceId、清单和全部 PNG 字节完全一致时才按幂等成功继续登记。
- 原子性:UI 首次本地写入前即要求主图具有非空 `resourceId / assetObjectId`、切片非空,且每片具有非空唯一身份、PNG 格式、有效尺寸、内容摘要和规范像素摘要。live rollback 使用新的唯一 rollback suffix 恢复快照,成功后再安全清理原事务残留;UI 的 `asset.register / asset.update``canvas.asset_generate` 审计共享同一 `transactionId`。后续环节失败时外层事务恢复主图、cohort 和项目资产登记,并向 append-only Agent DB 追加使用 terminal reserve 的 `canvas.asset_generate.rollback` 终态补偿;本地恢复或补偿审计失败必须进入 reconciliation。
- durable journal:事务先在同一锚定 `.agent/runtime` parent 下创建 `.preparing`,完整冻结旧主图 / manifest、目标 manifest、asset audit 和 canvas audit 后,以 no-replace 原子发布为 active。已有 journal 不能信任自身派生字段:恢复时重新计算目标登记、assetId、recordType、两条审计、旧快照摘要和存在性,并按 prepared / committed / rollback-requested / rolled-back 状态验证精确允许 child 集合;未知 child、内部矛盾、active / preparing 并存或终态组合冲突全部进入 reconciliation。committed 后的清理中断只继续清理并做 terminal replay,不重新安装或追加 `asset.update`
- 句柄与残留:previous cohort 的已验证目录句柄跨 publish 错误、rollback 和 cleanup 保留;目录 residue 先退休为事务私有名称再删除,文件型 previous / replacement / discard 先通过 retained parent 做 no-replace 隔离并复核冻结字节,再清理 retired 叶子。Windows 的目录创建、child 读写、rename、CAS 与删除都逐级使用相对 retained handle,避免回到绝对 pathname。
- Agent DB:终态精确审计在 append lock 内运行;先修复可确认的 torn tail,再对全文件执行物理字节和记录数上限并做精确记录匹配。32 MiB 尾窗只适用于普通有界读取,不能作为 committed / rolled-back 的 durable 证明。
- 验证:定向 Rust 回归覆盖 Windows destination-exists 模拟、单组 previous 恢复、已发布 canonical 清理残留、文件删除竞态、retired residue、未知 journal child、preparing 部分创建、五个 rollback 崩溃阶段、未登记 / 他源身份拒绝、父目录 pathname 替换、多组 / 损坏残留拒绝、已安装同源 cohort 续跑、首次身份与切片元数据拒绝、成功审计同事务、最终审计失败后的整组回滚补偿,以及 Agent DB 超尾窗、torn tail 和物理容量门禁。
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-06 额外 UI 图集主图、切片 cohort 与资产登记整组提交
- 背景:额外 UI 图集的主图曾先于切片安装和登记;cohort 发布失败又被降级为 `sliceWarning`,会把新主图与旧切片留在同一项目。repair 只核对清单 schema/source,也无法证明旧 cohort 真正属于当前已登记主图。
- 决策:`ui-spritesheet` 的主图、与该主图一一对应的切片 cohort 和项目 manifest 资产登记作为一个外层事务。UI 切片写入失败不再降级告警;切片发布、资产登记或后续审计失败时整组恢复旧主图、旧 cohort 与旧登记。repair 前必须证明旧清单 `sourceResourceId` 等于当前 Canvas 主图登记,`slices` 非空、路径连续且身份唯一,并逐文件核对普通文件类型、尺寸、内容摘要和规范像素摘要。
- 路径边界:额外 UI 图集输出继续允许精确委派的独立 PNG,但不得覆盖 `assets/art-spec.png``assets/ui-prototype.png``assets/art-spritesheet.png` 或核心/UI 派生切片目录。Runtime 从项目 manifest 枚举每一张已登记 `ui-spritesheet`,推导其专属切片目录,并在请求前与 durable 结果提交前对输出路径执行 ASCII 大小写不敏感的双向祖先 / 后代冲突检查,避免额外主图通过任意文件名的派生目录覆盖既有 cohort。
- 验证:定向 Rust 回归覆盖外层资产登记失败后三类状态整组恢复,cohort 发布失败恢复,缩容 repair 清理旧尾部,错误主图身份、空/不连续清单、摘要不符和非普通文件拒绝,以及固定路径的大小写别名拒绝。
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-06 待确认账本区分任务上下文路径与真实工具输入
- 背景:Supervisor 任务正文可明确写出当前项目绝对路径;当 Provider 同轮返回两个以上只读动作时,durable action batch 会把原始任务正文复制进每个 pending record。旧校验对整个 record 搜索项目根路径,导致相对路径的 `asset.list / project.search / file.list` 也在批次预检阶段被误报为“工具输入包含项目绝对路径”,根 Run 在第一次实际动作前失败。
- 决策:敏感字段、API Key 与 secret token 继续检查完整 pending record 和任务上下文;项目绝对路径校验只豁免 pending record 中复制的原始 `task` 字段,`plan``thinking``observations``action.reason / action.input` 及其它 pending 字段仍检查。任务正文可以标识当前项目,但工具 reason / input 仍必须使用项目相对路径。Provider batch、恢复、确认与 action fingerprint 合同不变。
- 验证:覆盖“只有原始任务正文包含项目根、其余 pending 字段与工具输入均为相对路径”通过,以及 `plan``thinking``observations` 或工具 action 自身包含项目绝对路径时继续拒绝;保留全部敏感信息拒绝回归。
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-06 图片固定合同作为默认值,Supervisor 可通过 durable 委派扩展 UI 图集槽位
- 背景:`art-asset-plan` 能正确理解 Supervisor 要求的额外运行时 UI 图集,但 `canvas.asset_generate` 仍按 Agent ID 强制改写为 `assets/art-spritesheet.png`,导致语义决策被静态规则替代;直接复用核心图集切片提交还会覆盖玩家、目标、场景和反馈四张玩法切片。
- 决策:三类 canonical 图片合同继续作为默认值且无委派时失败关闭。只有运行中的 Project Supervisor 通过 durable static delivery 委派 `art-asset-plan``expectedArtifacts` 同时保留 canonical `assets/art-spritesheet.png` 并精确声明额外 PNG 路径,父子 task、session、run、action 派生 delegationId、source 和 delivery 状态全部闭合时,才允许以 `assetKind=ui-spritesheet` 扩展输出槽位。授权在外部请求前和 durable 结果本地提交前各复核一次;替换仍要求原 delivery 已认领且只有唯一 repair。
- 产物边界:`ui-spritesheet` 复用 External Editor icon-spritesheet、`art-spec` 稳定资源引用、透明 Alpha 与 PNG 校验,但使用独立 UI 组件描述;canonical 主图 `assets/ui-spritesheet.png` 的切片清单保持为 `assets/ui-spritesheet-slices/manifest.json`,其它额外主图使用与主图同目录、由完整主图文件名确定的 `<主图文件名>-slices/manifest.json`。每个主图只拥有自己的切片 cohort;初次生成不得替换既有 cohort,只有已通过 durable repair 授权且旧清单 `source` 精确绑定同一路径时才整体替换。任何 UI cohort 都不得进入 game-chat 核心四切片严格合同或写入 `assets/art-spritesheet-slices/*`。普通专业 Agent 直调、伪造 `delegated-*`、未声明路径、终态父 run 或 canonical 路径非 canonical 参数继续拒绝。
- 验证:正向覆盖 durable Supervisor 委派解析、UI kind/catalog、18 类 UI 组件描述、真实透明图集路由和独立切片 manifest;负向覆盖无委派固定合同、canonical 四切片隔离与父 run 终态撤权,并保留既有 art-spritesheet 确定性路由回归。
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-03 资源管理阶段七以完整 CI 与可重复界面合同收口
- 背景:飞书资源管理需求的阶段零至阶段六已经分别完成资源卡禁拖、固定资源投影、中央聚焦、安全文档 / 媒体预览、依赖深度与正式版本只读模型;最后需要统一复核需求边界并用当前主分支完整门禁排除集成回归。
@@ -93,7 +93,11 @@ cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml struc
npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts apps/ai-game-creator-shell/tests/appSurface.test.ts
```
game-chat 条件快车道仍采用七任务口径,但执行顺序由持久合同控制。父 Run 与全部 child Run 共用 4200 秒软预算和 4500 秒累计硬上限;Supervisor 首轮先调用 `agent.route_manifest`,决策前零 child。`audit-existing-first` 的首波仅为 `design-director + code-director`,已登记但无效的旧派生视觉不得阻断这两项审计启动;code-director 必须先 `asset.list`,读取 Supervisor 已持久化的 authoritative 决策,再提交正式资产覆盖合同。覆盖完整时跳过图片生成,存在缺口时只开放缺口对应 owner,`regenerate-art` 才强制重新开放两个美术 owner;已登记但校验失败的固定资产只允许当前路由绑定的 canonical owner 原位替换。新生成仍按 `art-spec.png -> art-spritesheet.png + 独立切片` 推进,最后才允许 code-prototype 写入或局部修复入口。软预算后只允许使用已登记图集、当前 resourceId 对应切片清单的确定性本地 fallback、`game.static_smoke``preview.validate`;不得退回普通生图、猜测 atlas 网格或纯代码核心画面。完成门要求活动 Canvas 分别绘制 player、blocks-and-targets、obstacles-and-scene、feedback-effects 四类不同切片;整图 `<img>`、CSS background、完整图集直绘、单个猜测裁切和路径诱饵均失败。对应定向测试至少包括:决策前零 child、旧无效视觉不阻断首波、首波无美术 child、code-director 收到真实持久策略、完整覆盖零生成、仅缺图集只运行 `art-asset-plan`、无效已登记资产由 owner 原位替换、art spec 缺失导致引用合同失效时同时补齐两个槽位、显式重做原位替换两个正式资产、旧 root/fingerprint/缺口或重复路由失败关闭,以及补齐后恢复 code-prototype。
game-chat 条件快车道仍采用七任务口径,但执行顺序由持久合同控制。父 Run 与全部 child Run 共用 4200 秒软预算和 4500 秒累计硬上限;Supervisor 首轮先调用 `agent.route_manifest`,决策前零 child。`audit-existing-first` 的首波仅为 `design-director + code-director`,已登记但无效的旧派生视觉不得阻断这两项审计启动;code-director 必须先 `asset.list`,读取 Supervisor 已持久化的 authoritative 决策,再提交正式资产覆盖合同。`code-director` 的 manifest 任务仍是只读协调合同,但 `agent.route_manifest` 是该角色唯一额外允许的持久路由动作;只读动作门禁不得拒绝它并与完成门形成重复规划死循环,其他只读专业 Agent 仍不得调用。覆盖完整时跳过图片生成,存在缺口时只开放缺口对应 owner,`regenerate-art` 才强制重新开放两个美术 owner;已登记但校验失败的固定资产只允许当前路由绑定的 canonical owner 原位替换。新生成仍按 `art-spec.png -> art-spritesheet.png + 独立切片` 推进,最后才允许 code-prototype 写入或局部修复入口。软预算后只允许使用已登记图集、当前 resourceId 对应切片清单的确定性本地 fallback、`game.static_smoke``preview.validate`;不得退回普通生图、猜测 atlas 网格或纯代码核心画面。完成门要求活动 Canvas 分别绘制 player、blocks-and-targets、obstacles-and-scene、feedback-effects 四类不同切片;整图 `<img>`、CSS background、完整图集直绘、单个猜测裁切和路径诱饵均失败。对应定向测试至少包括:决策前零 child、旧无效视觉不阻断首波、首波无美术 child、code-director 收到真实持久策略、完整覆盖零生成、仅缺图集只运行 `art-asset-plan`、无效已登记资产由 owner 原位替换、art spec 缺失导致引用合同失效时同时补齐两个槽位、显式重做原位替换两个正式资产、旧 root/fingerprint/缺口或重复路由失败关闭,以及补齐后恢复 code-prototype。
项目状态刷新必须是纯读:`get_local_game_manifest` 不得通过 `read_manifest_for_project` 无条件回写整份 `.agent/manifest.json`。否则 GUI 在专业任务终态投影前读到的 `running` 快照,可能在 Runtime 已写入 `completed` 后再次覆盖任务状态,导致 scheduler 重复恢复同一终态 child。seed-task 规范化读取也只在内容确实变化时落盘;正式 manifest mutation 继续由持有项目写锁的 Runtime 路径负责。
运行中追加 steer 恰好打断上下文窗口边界时,`continuation_for_game_creator_agent_runtime_steer` 必须把该边界视为新窗口起点,清零窗口轮次与观察指纹后再继续。否则跳过的 checkpoint 会让 `windowCompletedLoops` 跨窗口累加;后续 Provider retry 恢复会把 Runtime 自己写出的 context bundle 判为无效并错误终止同一 Run。
失败续跑还必须覆盖同 Session 同 source 继承、跨 Session / 跨 source 不继承、首次与连续 successor 的 effective task / contract / scheduler 一致性,以及中英文纯继续短语使用同一识别函数。非占位入口的新 `code-prototype` 必须先产生本人 mutation 再 smoke;连续只读 smoke 不得收束。占位 fallback 只允许显式支持的真实玩法模板,俄罗斯方块必须验证棋盘、下落、旋转、锁定和消行语义,未知玩法必须失败关闭。
@@ -857,6 +857,9 @@ game-project/
- 自动验收现在严格要求 manifest 恰好包含固定 16 个不重复 task ID 且全部为 `completed`,并逐任务核对当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物存在并满足文件 / JSON / 非占位入口检查,配置画布 API Key 时再增加 `art-spec / ui-prototype / art-spritesheet` 三张图片。PNG 验收不止检查 magic / IHDR / 比例,还会校验 chunk CRC、zlib 解压、scanline 长度、索引色 PLTE 和未知 critical chunk。Runtime 根 Supervisor 的完成合同已升级为 `game-creator-autonomous-completion-contract.v2``baselineArtifacts` 必填并纳入指纹,旧 v1 或缺基线合同失败关闭;最终门禁要求最后一次验证工具是 `game.static_smoke`、状态通过且 `verifiedRevision == currentRevision``preview.validate` 回执必须绑定同一 Agent、run、current revision、当前 `game/index.html` 摘要、固定试玩场景、持久浏览器报告以及 desktop / mobile 两张截图的路径、摘要和 PNG 身份,任一证据缺失、变化、过期或来自其它 run / revision 都阻止最终回复。旧两图合同的确定性证据不替代新三图 DAG 验收;新合同实现后必须新起独立单轮。
- `design-foundation` 已增加专属职责边界:项目文件只允许写 `memory/project.md``game/game_design.md`;配置 External Editor API Key 且合同要求界面原型时,只额外允许固定 `assets/ui-prototype.png`。它不得创建、修改、删除或补丁 `game/index.html`,不得改动其它程序实现、发布、音频或美术素材,也不得调用 `preview.start``preview.validate``game.static_smoke`,或借 `command.exec / command.start / command.run_limited` 启动预览服务、浏览器、Playwright 和桌面 / 移动试玩。程序和质量 Agent 的共享 Runtime 工具合同不因此缩减;有 / 无画布配置和其它 Agent 不受影响的聚焦回归为 `3/3` 通过。
- `canvas.asset_generate.replaceExisting` 默认并必须保持 `false`;只有静态专业 Agent 的 `delegated-*` 唯一 repair run 才能申请 `true`。Runtime 要求当前 delivery 带 `repairOfDelegationId`,原 delivery 已被同一父 Agent / 父 run 认领,原始与返工合同的目标 Agent 和精确 `expectedArtifacts` 路径一致;普通 run、未声明路径、错误 Agent、未认领原交付或缺失原图都失败关闭。图片生成仍服从 `art-director` / `design-foundation` / `art-asset-plan` 的固定输出路径、比例、尺寸、kind 和 label,禁止先删除正式图片;请求前记录旧文件 SHA-256,外部生成返回后在项目写锁内复核,旧图在网络请求期间变化即拒绝覆盖。授权替换先写私有临时文件,再以备份 / rename 切换;落盘或 manifest 登记失败时恢复旧图,不把新旧文件并存状态当作成功。
- 2026-08-06 补充图集委派扩展合同:上述固定图片参数是专业 Agent 的 canonical 默认值,不替代 Project Supervisor 对新增素材缺口的语义决策。仅当运行中的 Supervisor 通过 durable static delivery 委派 `art-asset-plan`,父子 task 与 delivery 身份完全闭合,且 `expectedArtifacts` 同时包含 canonical `assets/art-spritesheet.png` 和精确的额外 UI PNG 路径时,Agent 才可用 `1:1 / 1K / assetKind=ui-spritesheet` 生成该额外图集;无委派、伪造 runId、未声明路径、终态父 run 或试图改变 canonical 主图语义均失败关闭。授权在 External Editor 请求前和 durable 结果本地提交前各复核一次,后验撤权进入 reconciliation。`ui-spritesheet` 继续调用 icon-spritesheet API、引用当前 `art-spec` 并要求真实透明 PNG,但使用独立 UI 组件 descriptionscanonical `assets/ui-spritesheet.png` 继续使用 `assets/ui-spritesheet-slices/manifest.json`,其它 UI 主图则使用与主图同目录、由完整文件名确定的 `<主图文件名>-slices/manifest.json`,避免不同委派共享或替换同一个切片 cohort。初次生成不得替换任何既有 cohort;只有已验证的同源 repair 才能在旧清单 `source` 精确等于当前主图路径、`sourceResourceId` 精确等于当前 Canvas 主图登记,且非空连续唯一的每个切片路径都在对应 cohort 内并与普通 PNG 文件的尺寸/内容摘要/规范像素摘要一致时,才能整体替换对应 cohort。UI 主图、对应 cohort 与项目 manifest 资产登记同属一个外层事务;UI 切片发布失败不得降级为 `sliceWarning`,任一环节失败都恢复旧三者。额外图集输出不得指向 `assets/art-spec.png``assets/ui-prototype.png``assets/art-spritesheet.png` 或任何已登记 UI 主图的派生切片目录;Runtime 在 External Editor 请求前和 durable 结果提交前均从 manifest 推导全部专属目录,并按 Windows 所需的 ASCII 大小写不敏感语义执行双向祖先 / 后代冲突检查。所有 UI cohort 均绝不进入或覆盖 `assets/art-spritesheet-slices/*` 的核心四切片合同;主图原位替换仍完整继承唯一 repair 规则。
- 2026-08-07 补充 UI 图集 durable 固定输出恢复:首次写入 UI 主图前必须已经取得非空 `resourceId / assetObjectId` 和非空切片;每个切片必须具有非空唯一 `resourceId / assetObjectId`、真实 PNG、与待写字节一致的有效尺寸、内容 SHA-256 和规范像素 SHA-256,使任一次成功提交都具备后续 repair / recovery 所需身份。项目写锁内恢复除核心图集事务外,还按 `assetKind=ui-spritesheet + outputPath` 定位唯一 cohortcanonical 存在时先校验完整同源合同再清理至多一组可信 `.previous / .replacement`;canonical 缺失时只恢复恰好一组可信 previous,多个 suffix、replacement-only、符号链接、非目录、损坏、未登记或跨源身份全部失败关闭。manifest 主图 `resourceId` 是允许处理残留的外部身份锚点;文件校验、删除和 no-replace 恢复还必须绑定同一目录 / 父目录句柄,pathname 在操作期间被替换时失败关闭。主图已由同一 durable 结果安装时,当前 cohort 只有在 source、sourceResourceId、完整清单及全部 PNG 字节与待提交结果一致时才作为幂等续跑接受。live rollback 不复用原事务 suffix,而用新 rollback suffix 恢复内存快照,成功后再清理原 residue,避免 Windows destination-existsUI 的 `asset.register / asset.update``canvas.asset_generate` 审计共享 `transactionId`。后续任一步失败时先整组恢复主图、cohort 与项目登记,再向 append-only Agent DB 写入 terminal-reserve `canvas.asset_generate.rollback` 补偿;恢复或补偿失败必须进入 reconciliation。journal 先在同一锚定 `.agent/runtime` parent 下写入 `.preparing`,冻结旧主图 / manifest、目标 manifest 和两条精确审计后,以 no-replace 原子发布为 active;恢复时重新计算登记合同、旧快照摘要、assetId、recordType 和精确 child 集合,未知 child、内部字段矛盾或 active / preparing 并存都进入 reconciliation。committed / rollback-requested / rolled-back 只按各自允许终态续跑,terminal replay 不再次产生 `asset.update`。cohort previous 句柄跨 publish / rollback / cleanup 保留;目录 residue 先退休,文件 residue 先原子隔离并复核后再删除,Windows 的创建、读写、rename、CAS 和删除均使用逐级相对 retained handle。Agent DB 终态精确验证在 append lock 内先修复可确认的 torn tail,再全量扫描并执行物理字节 / 记录数上限;不得用 32 MiB 尾窗代替 durable 终态证明。
- 2026-08-06 补充 pending action 路径校验边界:用户原始任务正文可以用绝对路径明确当前项目,durable Provider action batch 不得因 pending record 复制了该 `task` 字段而误判工具越界;敏感字段与 secret token 仍检查完整任务和 pending record。项目绝对路径校验仅豁免复制的原始 `task` 字段,`plan``thinking``observations``action.reason / action.input` 及其它 pending 字段仍全部检查;因此 `file.* / project.* / asset.*` 等工具输入继续强制使用项目相对路径,批次持久化、恢复、确认和 action fingerprint 语义不变。
- 2026-07-28 起,在既有 16-task manifest 内固定正式视觉 DAG,不新增平行任务系统:`art-director` 先通过 `/api/external/v1/editor/images/generations``kind=spec` 生成 `assets/art-spec.png`,并登记为 `assetKind=icon-spec``design-foundation` 使用该规范图的 External Editor 稳定资源 ID 作为 `referenceImageSrcs` 中的视觉规范参考,再通过同一 images 接口的 `kind=ui-design` 生成完整 `assets/ui-prototype.png``art-asset-plan` 以同一 `assets/art-spec.png` 资源 ID 作为必填 `referenceImageSrc` 调用 `/api/external/v1/editor/icon-spritesheets/generations`,传入具体 `iconDescriptions``screenColor=auto`、同名画布 / 素材库与 `canvasCompletion`,生成透明 `assets/art-spritesheet.png``generationInputs.artSpec` 只是辅助结构化上下文,不能代替真实 `art-spec.png`;严禁把 `assets/ui-prototype.png` 当作图集规范图。规范图缺失、未登记为当前画布的 `icon-spec` 或缺少稳定资源 ID 时,两个下游任务均等待 `art-director`,不得退回普通生图。UI extraction 只适用于已有且带红框标注的 UI 设计图,不用于生成完整 UI,也不进入本次 canonical DAG。单波最多 `3` 个静态职责的资源上限保持不变,只调整现有任务的依赖边与就绪顺序。图集返回 `warning` 时以 `postprocess-failed-source-preserved` 源图保留语义失败关闭,不把不透明源图登记为正式 spritesheet,也不自动重试;仅有 `sliceWarning` 时完整透明图集仍可登记,但必须原样保留切片失败原因。客户端下载后还要解码 PNG 并确认至少存在一个 alpha 小于 255 的像素,未形成真实透明像素时拒绝落盘和 manifest 登记。
- 旧项目已有同路径派生图但缺少上述 provenance 时,一律标记为 legacy,不得只因文件、kind 或通用视觉检查存在就完成。原位替换仍走显式 repair:`design-foundation``art-asset-plan` 先在同一 Supervisor 批次分别建立 owner 精确原合同并交付 `needs-repair`,父 run 认领后再在同一批次分别发起各自唯一 repair;两个 repair 合称一个显式视觉返工阶段。`art-director` 不得跨 owner 声明或替换 UI / spritesheetRuntime 在委派落盘前就拒绝这类合同,不再等到生图阶段才失败。
- 2026-07-27 新起的“16 任务正式产物 + 两张真实画布图片 + current revision 静态 / 双视口浏览器 / PNG 证据 + 受限 repair 替换”独立外部 Provider 验收,使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**。同一轮真实生成并登记 `assets/ui-prototype.png``2829418` bytes)与 `assets/art-spritesheet.png``1361906` bytes),固定 `16` 个 manifest task 均为当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物、两张 PNG、当前 revision 的 `game.static_smoke`、desktop / mobile `lane-defense-v1` playtest、浏览器报告与截图全部通过。`turn.report=settled` 且唯一 assistantbusy / pending / running / confirmation / user-input / reconciliation 均为 `0`;隔离 Runner、一次性项目和隔离 AppData 已自动清理。此前失败轮继续独立保留,不与本轮拼接;未来合同变化仍须新起完整轮次复验。