新增开发态游戏聊天创作页

新增仅开发态游戏聊天入口与本地安全预览布局
将Supervisor进度、返工、测试、迭代和图片证据写入聊天
完善自动预览授权持久化、锁竞争重试与Runtime收尾恢复
规范视觉产物走规范图、UI设计和透明图集专用接口
补齐共享契约、回归测试、技术方案和项目记忆
This commit is contained in:
AIGameCreator App
2026-07-29 11:54:06 +08:00
parent 26555e66af
commit dae59992b1
68 changed files with 7146 additions and 548 deletions
@@ -142,6 +142,20 @@ client.generate_image(
)
```
For a transparent game/UI atlas, call the dedicated helper instead of ordinary image generation:
```python
client.generate_icon_spritesheet(
"editor-resource-current-art-spec",
["蛇头四方向", "直身与四种转角", "尾部四方向", "四类可区分食物"],
canvasSession=session,
assetLabel="贪吃蛇透明图集",
screenColor="auto",
)
```
Pass the registered visual-spec resource ID as `reference_image_src`; do not pass the UI prototype or a local path.
Use the helper directly from this skill path, or copy it into the caller's project. Do not change the fixed base URL or move the API Key into environment variables.
Use this shared base:
@@ -326,6 +340,18 @@ Character image generation (including character redraw through `kind: "character
- `sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Keep `sliceWarning.reason` as the original diagnostic and continue using the complete transparent spritesheet; a UI may add context when displaying it, but must not rewrite the stored reason.
- The service contract keeps `warning` and `sliceWarning` mutually exclusive. As defensive handling for a malformed response containing both, treat the general `warning` as authoritative and do not misclassify the source-preserved result as a slicing-only warning.
For reusable transparent game/UI sheets, do not substitute ordinary image generation merely because it can draw several objects in one image. Use icon spritesheet generation when a stable visual-spec reference and `iconDescriptions` exist; use UI extraction only for an existing annotated UI design. Pass `screenColor: "auto"` unless the art direction requires one of the supported solid chroma colors. A client must verify the returned full sheet really contains transparency before treating it as a transparent spritesheet. If a source-preserved `warning` is present, do not register the opaque provider source as the requested transparent deliverable. When only `sliceWarning` is present, the full transparent sheet remains usable, but no individual slices may be claimed.
## AI Game Creator Canonical Visual DAG
The AI game creator reuses its existing 16-task manifest; do not add a parallel task system or collapse the following artifacts into one ordinary generation request:
1. `art-director` generates `assets/art-spec.png` with `POST /api/external/v1/editor/images/generations`, `kind: "spec"`, and registers it as `assetKind: "icon-spec"`. This is the real visual-spec image. The JSON value in `generationInputs.artSpec` is supporting structured context and does not replace this image.
2. `design-foundation` uses the registered External Editor resource ID for `assets/art-spec.png` in `referenceImageSrcs`, then generates the complete `assets/ui-prototype.png` through `POST /api/external/v1/editor/images/generations` with `kind: "ui-design"`.
3. `art-asset-plan` uses the same registered `assets/art-spec.png` resource ID as the required `referenceImageSrc` for `POST /api/external/v1/editor/icon-spritesheets/generations`, supplies concrete `iconDescriptions`, and registers the transparent full result as `assets/art-spritesheet.png`.
Never use `assets/ui-prototype.png` as the icon spritesheet's visual-spec reference. `POST /api/external/v1/editor/ui-designs/assets/extractions` requires an existing UI design image with red-box annotations; it is not UI generation and is not part of this canonical DAG.
## Guardrails
- Do not invent endpoints outside the OpenAPI, especially internal worker or runtime task-list routes.
@@ -20,6 +20,14 @@ At the start of a new conversation, ask for a canvas name before the first gener
Before generating art assets, normalize the user's request into a current art spec with `assetType`, `subject`, `style`, `palette`, `composition`, `format`, `constraints`, and `references`. Ask follow-up questions only for missing fields that block the selected endpoint. Reuse the current spec automatically when the user asks for another asset without changing style/spec requirements. Put the spec in `generationInputs.artSpec` and summarize it in the prompt when useful.
For the AI game creator's existing 16-task autonomous build, distinguish that JSON art spec from the required visual-spec image and keep this dependency chain:
1. `art-director` -> `assets/art-spec.png` via `POST /api/external/v1/editor/images/generations`, with `kind=spec` and registered `assetKind=icon-spec`.
2. `design-foundation` -> `assets/ui-prototype.png` via the same image generation endpoint with `kind=ui-design`, using the registered art-spec resource ID in `referenceImageSrcs`.
3. `art-asset-plan` -> transparent `assets/art-spritesheet.png` via `POST /api/external/v1/editor/icon-spritesheets/generations`, using the registered art-spec resource ID as `referenceImageSrc` and providing `iconDescriptions`.
Do not use the UI prototype as the spritesheet specification. UI extraction requires a stable source image with red-box annotations and is outside this canonical DAG.
## Intent Routing
Infer the endpoint from the user's description. Do not present this as a menu unless the request is genuinely ambiguous.
@@ -69,8 +77,8 @@ Ask a follow-up only when two routes could both be correct and produce different
| --- | --- | --- | --- |
| Generate image/spec/character/UI/publication material | `POST /api/external/v1/editor/images/generations` | `prompt` | `kind`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Edit/redraw image | `POST /api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` |
| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `referenceImageSrcs`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `canvasCompletion` |
| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Generate character animation | `POST /api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `canvasCompletion`; then create a library asset from the first returned frame |
| Generate video | `POST /api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Generate sound effect | `POST /api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` | `model`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
@@ -450,6 +450,29 @@ class GenarrativeExternalClient:
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
)
def generate_icon_spritesheet(
self,
reference_image_src: str,
icon_descriptions: list[str],
**fields: Any,
) -> Any:
descriptions = [item.strip() for item in icon_descriptions if item.strip()]
if not descriptions:
raise GenarrativeApiError("icon_descriptions must contain at least one non-empty item")
label = fields.get("assetLabel", "图标图集")
self._apply_canvas_session_fields(fields, label, 1024, 1024)
fields.setdefault("screenColor", "auto")
return self.request_json(
"POST",
"/api/external/v1/editor/icon-spritesheets/generations",
{
"referenceImageSrc": reference_image_src,
"iconDescriptions": descriptions,
**fields,
},
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
)
def extract_ui_assets(self, source_image_src: str, image_size: str = "1K", **fields: Any) -> Any:
fields.pop("aspectRatio", None)
self._apply_canvas_session_fields(fields, fields.get("spritesheetLabel", "UI 素材拆分"), 1024, 1024, "spritesheetLabel")
@@ -619,6 +642,17 @@ def _self_test() -> None:
assert calls[0]["body"]["canvasCompletion"]["title"] == "角色呼吸动画"
assert calls[1]["path"] == "/api/external/v1/editor/assets"
assert result["asset"]["assetId"] == "editor-asset-demo"
calls.clear()
client.generate_icon_spritesheet(
"editor-resource-spec",
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
canvasSession=session,
assetLabel="贪吃蛇透明图集",
)
assert calls[0]["path"] == "/api/external/v1/editor/icon-spritesheets/generations"
assert calls[0]["body"]["referenceImageSrc"] == "editor-resource-spec"
assert calls[0]["body"]["screenColor"] == "auto"
assert calls[0]["body"]["iconDescriptions"][0] == "蛇头向上"
print("self-test ok")
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "npm --prefix ../.. exec tauri -- dev",
"game-chat": "npm --prefix ../.. exec tauri -- dev -- -- --game-chat",
"dev-server": "node scripts/start-dev-server.mjs",
"dev-stack": "node scripts/start-dev-stack.mjs",
"build": "npm --prefix ../.. exec tauri -- build",
+1
View File
@@ -1476,6 +1476,7 @@ dependencies = [
"chromiumoxide",
"futures",
"http",
"image",
"libc",
"platform-agent",
"platform-llm",
@@ -12,6 +12,7 @@ base64 = "0.22"
chromiumoxide = "0.9.1"
futures = "0.3"
http = "1"
image = { version = "0.25", default-features = false, features = ["png"] }
rmcp = { version = "2.2.0", default-features = false, features = ["client", "reqwest-native-tls", "transport-child-process", "transport-streamable-http-client-reqwest"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -405,9 +405,88 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration {
generated_prompt: Option<String>,
model: Option<String>,
provider: Option<String>,
slice_warning: Option<String>,
generation_route: String,
generation_kind: String,
reference_resource_ids: Vec<String>,
extension: String,
}
fn canonical_art_spec_reference_at(
root: &Path,
expected_canvas_project_id: &str,
) -> Result<String, String> {
let manifest = read_manifest_for_project(root)?;
let source = manifest
.assets
.iter()
.find(|asset| {
asset.local_path == AGENT_RUNTIME_ART_SPEC_PATH
&& asset.kind == "icon-spec"
&& asset.media_type.starts_with("image/")
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
})
.ok_or_else(|| {
"派生视觉资产需要先完成并登记 assets/art-spec.png;请等待 art-director 后重试"
.to_string()
})?;
let source_path = resolve_local_project_path(root, &source.local_path)?;
if !source_path.is_file() {
return Err(
"派生视觉资产的规范图 assets/art-spec.png 不存在;请等待 art-director 后重试"
.to_string(),
);
}
if source.source.canvas_project_id.as_deref() != Some(expected_canvas_project_id) {
return Err(
"assets/art-spec.png 不属于当前 External Editor 画布项目,不能作为派生视觉资产参考源"
.to_string(),
);
}
source
.source
.resource_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| {
"assets/art-spec.png 缺少 External Editor 项目资源 ID,不能伪造规范图引用".to_string()
})
}
fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec<String> {
let project_context = truncate_inline(prompt.trim(), 320);
[
"当前玩法的玩家主体、朝向或动作状态与可组合部件",
"当前玩法的目标物、收集物、敌对实体或危险物",
"当前玩法需要的地块、障碍、资源物件与场景装饰",
"得分、受击、成长、失败、胜利与操作反馈特效",
]
.into_iter()
.map(|category| format!("{category};遵循同一项目视觉规范:{project_context}"))
.collect()
}
fn platform_art_spritesheet_has_transparent_pixels(download: &CanvasResourceDownload) -> bool {
image::load_from_memory(&download.bytes)
.ok()
.map(|image| image.to_rgba8().pixels().any(|pixel| pixel[3] < u8::MAX))
.unwrap_or(false)
}
fn platform_art_generation_postprocess_failure(generated: &serde_json::Value) -> Option<String> {
let warning = generated
.get("warning")
.filter(|warning| !warning.is_null())?;
let code = json_string_field(warning, "code").unwrap_or_else(|| "unknown".to_string());
let reason = json_string_field(warning, "reason")
.unwrap_or_else(|| "透明背景后处理未生成可用衍生物".to_string());
Some(format!(
"平台图片生成完成但透明后处理失败({code}):{reason};provider 源图已由服务端保留,不得登记为透明图集或自动重试"
))
}
pub(in crate::agent) async fn generate_platform_art_asset_with_options_at(
root: &Path,
prompt: &str,
@@ -444,34 +523,69 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at(
let canvas_context =
prepare_external_canvas_generation_context(root, &client, &api_base_url, &api_key).await?;
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
let generation_kind = if options.asset_kind == "ui-prototype" {
"ui-design"
let generation_kind = match options.asset_kind.as_str() {
"ui-prototype" => "ui-design",
"art-spritesheet" => "icon-spritesheet",
_ => "spec",
};
let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet";
let canonical_reference = matches!(
options.asset_kind.as_str(),
"ui-prototype" | "art-spritesheet"
)
.then(|| canonical_art_spec_reference_at(root, &canvas_context.project_id))
.transpose()?;
let (endpoint, request_body) = if is_canonical_art_spritesheet {
let reference_image_src = canonical_reference
.as_deref()
.ok_or_else(|| "透明美术图集缺少规范图引用".to_string())?;
(
"/api/external/v1/editor/icon-spritesheets/generations",
serde_json::json!({
"referenceImageSrc": reference_image_src,
"iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt),
"screenColor": "auto",
"aspectRatio": options.aspect_ratio,
"imageSize": options.image_size,
"assetLabel": options.asset_label,
"projectId": canvas_context.project_id,
"assetFolderId": canvas_context.asset_folder_id,
"generationInputs": {
"artSpec": platform_art_asset_art_spec(options),
},
"canvasCompletion": {
"title": options.asset_label,
"placeholder": external_canvas_placeholder(&options.aspect_ratio),
},
}),
)
} else {
"spec"
(
"/api/external/v1/editor/images/generations",
serde_json::json!({
"prompt": generation_prompt,
"kind": generation_kind,
"aspectRatio": options.aspect_ratio,
"imageSize": options.image_size,
"assetKind": options.asset_kind,
"assetLabel": options.asset_label,
"projectId": canvas_context.project_id,
"assetFolderId": canvas_context.asset_folder_id,
"generationInputs": {
"artSpec": platform_art_asset_art_spec(options),
},
"referenceImageSrcs": canonical_reference.clone().into_iter().collect::<Vec<_>>(),
"canvasCompletion": {
"title": options.asset_label,
"placeholder": external_canvas_placeholder(&options.aspect_ratio),
},
}),
)
};
let response = client
.post(format!(
"{}/api/external/v1/editor/images/generations",
api_base_url
))
.post(format!("{api_base_url}{endpoint}"))
.bearer_auth(&api_key)
.json(&serde_json::json!({
"prompt": generation_prompt,
"kind": generation_kind,
"aspectRatio": options.aspect_ratio,
"imageSize": options.image_size,
"assetKind": options.asset_kind,
"assetLabel": options.asset_label,
"projectId": canvas_context.project_id,
"assetFolderId": canvas_context.asset_folder_id,
"generationInputs": {
"artSpec": platform_art_asset_art_spec(options),
},
"canvasCompletion": {
"title": options.asset_label,
"placeholder": external_canvas_placeholder(&options.aspect_ratio),
},
}))
.json(&request_body)
.send()
.await
.map_err(|error| format!("请求平台图片生成失败:{error}"))?;
@@ -484,12 +598,39 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at(
.await
.map_err(|error| format!("解析平台图片生成响应失败:{error}"))?;
let generated = payload.get("data").unwrap_or(&payload);
let download = resolve_canvas_resource_download(&client, &api_base_url, &api_key, generated)
.await?
.ok_or_else(|| "平台图片生成响应缺少可下载图片".to_string())?;
if let Some(error) = platform_art_generation_postprocess_failure(generated) {
return Err(error);
}
let null = serde_json::Value::Null;
let resource = generated.get("resource").unwrap_or(&null);
let asset = generated.get("asset").unwrap_or(&null);
let resource = if is_canonical_art_spritesheet {
generated.get("spritesheetResource").unwrap_or(&null)
} else {
generated.get("resource").unwrap_or(&null)
};
let asset = if is_canonical_art_spritesheet {
generated.get("spritesheetAsset").unwrap_or(&null)
} else {
generated.get("asset").unwrap_or(&null)
};
let download_source = if resource.is_object() {
resource
} else {
generated
};
let download =
resolve_canvas_resource_download(&client, &api_base_url, &api_key, download_source)
.await?
.ok_or_else(|| "平台图片生成响应缺少可下载图片".to_string())?;
if is_canonical_art_spritesheet && !platform_art_spritesheet_has_transparent_pixels(&download) {
return Err(
"External Editor 返回的 art-spritesheet 没有真实透明像素,已拒绝把不透明源图登记为正式图集"
.to_string(),
);
}
let slice_warning = generated
.get("sliceWarning")
.filter(|warning| !warning.is_null())
.and_then(|warning| json_string_field(warning, "reason"));
let resource_id = json_string_field(resource, "resourceId");
let task_id =
json_string_field(generated, "taskId").or_else(|| json_string_field(resource, "taskId"));
@@ -507,8 +648,11 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at(
json_string_field(generated, "model").or_else(|| json_string_field(resource, "model"));
let provider = json_string_field(generated, "provider")
.or_else(|| json_string_field(resource, "provider"));
let source_hint = json_string_field(generated, "objectKey")
.or_else(|| json_string_field(generated, "imageSrc"));
let source_hint = json_string_field(resource, "objectKey")
.or_else(|| json_string_field(resource, "imageSrc"))
.or_else(|| json_string_field(generated, "objectKey"))
.or_else(|| json_string_field(generated, "imageSrc"))
.or_else(|| json_string_field(generated, "spritesheetImageSrc"));
let extension = infer_file_extension(source_hint.as_deref(), &download.media_type).to_string();
Ok(PreparedPlatformArtAssetGeneration {
requested_output_path,
@@ -522,6 +666,10 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at(
generated_prompt,
model,
provider,
slice_warning,
generation_route: endpoint.to_string(),
generation_kind: generation_kind.to_string(),
reference_resource_ids: canonical_reference.into_iter().collect(),
extension,
})
}
@@ -594,6 +742,10 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
generated_prompt,
model,
provider,
slice_warning,
generation_route,
generation_kind,
reference_resource_ids,
extension,
} = prepared;
let file_stem = resource_id
@@ -743,6 +895,9 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
task_id: task_id.clone(),
prompt: generated_prompt.clone(),
model: model.clone(),
generation_route: Some(generation_route.clone()),
generation_kind: Some(generation_kind.clone()),
reference_resource_ids: reference_resource_ids.clone(),
},
) {
Ok(registered) => registered,
@@ -776,6 +931,10 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
"provider": provider.clone(),
"assetFolderId": canvas_context.asset_folder_id,
"canvasName": canvas_context.canvas_name,
"sliceWarning": slice_warning.clone(),
"generationRoute": generation_route,
"generationKind": generation_kind,
"referenceResourceIds": reference_resource_ids,
}),
)?;
Ok(GeneratedPlatformArtAsset {
@@ -784,31 +943,44 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
asset_object_id,
task_id,
model,
slice_warning,
})
}
pub(crate) fn platform_art_asset_art_spec(
options: &PlatformArtAssetGenerationOptions,
) -> serde_json::Value {
if options.asset_kind == "icon-spec" {
return serde_json::json!({
"assetType": "icon-spec",
"subject": "游戏角色、场景、UI 图标与反馈特效的统一视觉规范",
"style": "原创、正交展示、轮廓清楚、材质与光照一致的视觉规范板",
"palette": "统一主色、辅色、强调色和状态色,保证桌面端与移动端可读性",
"composition": "严格 1:1 规范板;分区展示玩家主体、目标物、场景地块、UI 图标、状态反馈与材质色板",
"format": format!("{} {}", options.aspect_ratio, options.image_size),
"constraints": "这是后续完整 UI 与透明图集共同引用的真实规范图;元素不得相互遮挡,不得做成完整游戏截图、海报或无结构概念画;必须原创,不得复刻现有游戏角色、Logo、贴图、标志性轮廓或受保护视觉语言",
"references": [],
});
}
if options.asset_kind == "ui-prototype" {
return serde_json::json!({
"assetType": "ui",
"subject": "完整桌面端游戏 UI 原型,包含 HUD、卡牌控件、战场区和操作控件",
"subject": "严格依据当前项目玩法合同生成的完整游戏 UI 原型,包含状态 HUD、主要可玩区域、关键实体、操作控件、失败与重开流程",
"style": "正视角、清晰分区、可指导 HTML/CSS 实现的高保真 UI/UX mockup",
"palette": "与原创游戏主题一致,文字与控件对比清楚",
"composition": "严格 16:9 单屏界面;顶部资源与波次 HUD,左侧或顶部单位卡槽,中部战场网格,右侧敌人入口,底部或角落放置开始、暂停重开和操作提示",
"composition": "严格 16:9 单屏界面;按当前玩法组织状态 HUD、主要可玩区域、目标或收集物说明、开始/移动/暂停/重开控件,并体现移动端触控布局",
"format": format!("{} {}", options.aspect_ratio, options.image_size),
"constraints": "必须明显展示资源数值、单位卡牌、冷却/费用、波次进度、开始或暂停或重开控件和操作反馈;不得只生成无 HUD 的场景插画、战斗概念图、地图或宣传图;玩法类型只描述功能,不授权复刻现有作品,必须重建原创标题、资源名、单位名、角色轮廓与场景语言不得复刻、翻译或近似改写现有游戏角色、单位名、Logo、贴图、标志性布局或受保护视觉语言",
"constraints": "不得假设项目属于塔防或补入玩法合同中不存在的卡牌、波次、敌人入口等结构;必须清楚展示当前玩法实际需要的信息、实体、操作、失败与重开状态;不得只生成无 HUD 的场景插画、概念图、地图或宣传图;必须重建原创标题、资源名、角色轮廓与场景语言不得复刻、翻译或近似改写现有游戏角色、Logo、贴图、标志性布局或受保护视觉语言",
"references": [],
});
}
serde_json::json!({
"assetType": "art",
"subject": format!("{};使用项目原创命名和原创阵营设计", options.asset_label),
"style": "清晰可切分的原创 Web 游戏素材图集;以非拟人生态构装体、晶体、菌丝、雾气或器物轮廓建立独立视觉身份,避免现有塔防游戏的可识别角色语言",
"composition": format!("{} 游戏素材图集,按单位、敌人、地块和特效分区,留出清楚切分间距", options.aspect_ratio),
"style": "清晰可切分的原创 Web 游戏素材图集;严格沿用当前规范图的轮廓、材质、色板与光照,不预设塔防或其他固定玩法",
"composition": format!("{} 游戏素材图集,按玩家主体及状态、目标或收集物、障碍或场景元素、反馈特效分区,留出清楚切分间距", options.aspect_ratio),
"format": format!("{} {}", options.aspect_ratio, options.image_size),
"constraints": "必须是可见的真实图片产物,不得用纯文本计划代替;禁止带脸向日葵、嘴状豌豆炮管、草坪横排、僵尸人形、现有游戏 Logo、贴图、图标式角色排布、标志性轮廓或近似配色;植物或生态主题不得直接画成大众熟知塔防角色的变体",
"constraints": "必须是可见的真实透明图片产物,不得用纯文本计划代替;只生成当前项目玩法合同需要的实体和反馈,不得擅自加入单位卡牌、波次敌人或其他玩法;禁止现有游戏 Logo、贴图、标志性角色轮廓、图标排布或近似配色",
"references": [],
})
}
@@ -818,9 +990,15 @@ pub(crate) fn build_platform_art_asset_prompt(
briefs: &[AgentGroupBrief],
options: &PlatformArtAssetGenerationOptions,
) -> String {
if options.asset_kind == "icon-spec" {
return format!(
"为这个 Web 小游戏生成一张 1:1 的统一视觉规范图,作为后续 UI 设计图和透明游戏图集的共同权威参考。规范板必须分区展示:玩家主体及其成长形态、核心目标或收集物、场景地块与障碍、HUD/操作图标、得分/受击/胜负反馈、主辅强调色与材质规则。所有元素使用一致的正交视角、轮廓、光照和原创视觉语言,留出清楚间距;不要生成完整游戏截图、海报、黑底图集或纯文字说明。玩法机制只用于理解功能,不授权复刻现有作品。\n\n项目视觉需求:{}",
truncate_prompt_context(prompt.trim())
);
}
if options.asset_kind == "ui-prototype" {
return format!(
"生成一张真正的游戏 UI/UX 原型图,不是场景概念图。画面必须是完整 16:9 桌面端单屏界面,明确可见:顶部资源数值与波次/状态 HUD;单位卡牌及费用、冷却状态;中部战场网格;右侧敌人来袭方向;开始、暂停、重开控件;基础操作提示和点击/资源不足等反馈。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。禁止只画草地、角色和敌人的无 HUD 战斗画面,禁止做海报、地图或纯插画。玩法类型和机制只用于理解功能,不代表允许复刻现有作品;生成前必须把输入中可识别的现有游戏标题、资源、角色或单位名转换为新的原创命名,并重建角色轮廓、配色、场景材质和界面视觉语言。不得在画面中渲染 Sunflower、Peashooter、向日葵、豌豆射手、僵尸等知名塔防元素,也不得使用现有游戏 Logo、贴图、标志性布局或受保护视觉语言。\n\n项目 UI 需求:{}",
"生成一张真正的游戏 UI/UX 原型图,不是场景概念图。必须严格从下方当前项目 UI 需求提取玩法,不得自行假设它属于塔防或加入需求中不存在的单位卡牌、费用、波次、敌人入口等结构。画面是完整 16:9 桌面端单屏界面,并同时明确移动端重排意图;清楚呈现当前玩法所需的分数/资源/生命/局内状态 HUD、主要可玩区域、玩家与目标/收集物/危险物、开始和主要操作、失败状态与重新开始、键盘和触控提示。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。禁止只画无 HUD 场景、概念图、地图、海报或纯插画。玩法机制只用于理解功能,不授权复刻现有作品;必须采用项目已经确定的原创命名角色轮廓、配色、场景材质和界面视觉语言不得使用现有游戏 Logo、贴图、标志性布局或受保护视觉语言。\n\n当前项目 UI 需求:{}",
truncate_prompt_context(prompt.trim())
);
}
@@ -834,7 +1012,7 @@ pub(crate) fn build_platform_art_asset_prompt(
.filter(|markdown| !markdown.is_empty())
.unwrap_or("需要一张可直接用于 Web 小游戏首版原型的核心美术素材。");
format!(
"为 Web 小游戏首版原型生成一张可切分的原创核心美术素材图集,适合放入本地 assets 并被 canvas 游戏直接引用。必须先从美术 brief 提取项目自己的标题、阵营、资源和单位名,并据此创造非拟人、非标志性的新轮廓。即使用户提到植物塔防或相似机制,也禁止画带脸向日葵、嘴状豌豆炮管、草坪横排、僵尸人形、现有游戏 Logo、贴图、标志性角色轮廓、图标式排布或近似配色不得把知名角色改色、换名或机械化后继续使用。优先采用晶体、菌丝、雾气、陶质器物、潮汐生态构装体等与项目设定一致的独立形态,按守卫、敌人、地块和特效分区并留出清楚切分间距。\n用户需求:{}\n美术资产 brief{}",
"为 Web 小游戏首版原型生成一张可切分的原创透明核心美术素材图集,适合放入本地 assets 并被游戏直接引用。严格从用户需求和美术 brief 提取当前项目自己的标题、玩法实体、目标物、收集物、障碍、状态与反馈;不得自行假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌等内容。所有元素沿用当前规范图的轮廓、配色、材质和光照,分区排布并留出清楚切分间距。禁止现有游戏 Logo、贴图、标志性角色轮廓、图标式排布或近似配色不得把知名角色改色、换名或机械化后继续使用。\n用户需求:{}\n美术资产 brief{}",
truncate_inline(prompt, 240),
truncate_prompt_context(art_asset_brief)
)
@@ -843,6 +1021,61 @@ pub(crate) fn build_platform_art_asset_prompt(
#[cfg(test)]
mod canvas_generation_tests {
use super::*;
use image::{codecs::png::PngEncoder, ColorType, ImageEncoder};
fn rgba_test_png(alpha: u8) -> CanvasResourceDownload {
let mut bytes = Vec::new();
PngEncoder::new(&mut bytes)
.write_image(&[12, 34, 56, alpha], 1, 1, ColorType::Rgba8.into())
.expect("encode RGBA fixture");
CanvasResourceDownload {
bytes,
media_type: "image/png".to_string(),
}
}
#[test]
fn canonical_art_spritesheet_requires_real_transparent_pixels() {
assert!(platform_art_spritesheet_has_transparent_pixels(
&rgba_test_png(0)
));
assert!(!platform_art_spritesheet_has_transparent_pixels(
&rgba_test_png(u8::MAX)
));
}
#[test]
fn derived_visual_asset_waits_for_registered_art_spec() {
let temporary = tempfile::tempdir().expect("create art dependency project");
let root = temporary.path();
init_local_game_project_at(root, "art-dependency", "美术依赖测试").expect("init project");
let error = canonical_art_spec_reference_at(root, "canvas-project")
.expect_err("missing art spec must block derived visual generation");
assert!(error.contains("先完成并登记 assets/art-spec.png"));
assert!(error.contains("等待 art-director"));
}
#[test]
fn postprocess_warning_wins_over_slice_warning_and_preserves_reason() {
let payload = serde_json::json!({
"warning": {
"code": "postprocess-failed-source-preserved",
"reason": "抠图服务暂不可用,已保留源图。"
},
"sliceWarning": {
"code": "insufficient-connected-components",
"reason": "不应覆盖通用告警"
}
});
let error = platform_art_generation_postprocess_failure(&payload)
.expect("general warning must be authoritative");
assert!(error.contains("postprocess-failed-source-preserved"));
assert!(error.contains("抠图服务暂不可用,已保留源图。"));
assert!(error.contains("不得登记为透明图集或自动重试"));
assert!(!error.contains("不应覆盖通用告警"));
}
fn replacement_options() -> PlatformArtAssetGenerationOptions {
PlatformArtAssetGenerationOptions {
@@ -878,6 +1111,10 @@ mod canvas_generation_tests {
generated_prompt: Some("原创替换图集".to_string()),
model: Some("test-image-model".to_string()),
provider: Some("test-provider".to_string()),
slice_warning: None,
generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(),
generation_kind: "icon-spritesheet".to_string(),
reference_resource_ids: vec!["art-spec-resource".to_string()],
extension: "png".to_string(),
}
}
File diff suppressed because one or more lines are too long
@@ -44,6 +44,8 @@ pub(in crate::agent) use run_status_observation::*;
pub(in crate::agent) use structured_plan::*;
pub(in crate::agent) use tool_plan_protocol::*;
#[cfg(test)]
pub(crate) use action_audit::agent_runtime_action_receipt_safe_detail_for_owner_for_test;
pub(crate) use action_audit::{
agent_runtime_git_commit_safe_detail_value, agent_runtime_tool_action_fingerprint,
agent_runtime_tool_action_id, agent_runtime_tool_action_input_summary,
@@ -86,6 +88,7 @@ pub(crate) use pending_confirmation_ledger::{
write_game_creator_agent_runtime_tool_confirmation,
};
pub(crate) use project_gates::{
acquire_game_creator_agent_provider_plan_project_write_lock_with_wait,
acquire_game_creator_agent_runtime_project_write_lock_with_wait,
advance_agent_runtime_project_revision_locked,
agent_runtime_observation_advances_project_revision,
@@ -179,6 +179,21 @@ pub(in crate::agent) fn agent_runtime_action_receipt_safe_detail_for_owner(
)
}
#[cfg(test)]
pub(crate) fn agent_runtime_action_receipt_safe_detail_for_owner_for_test(
root: &Path,
receipt_agent_id: &str,
receipt_run_id: &str,
observation: &AgentRuntimeToolObservation,
) -> Option<String> {
agent_runtime_action_receipt_safe_detail_for_owner(
root,
receipt_agent_id,
receipt_run_id,
observation,
)
}
fn agent_runtime_action_receipt_safe_detail_with_owner(
root: &Path,
receipt_owner: Option<(&str, &str)>,
@@ -279,27 +294,58 @@ fn agent_runtime_action_receipt_safe_detail_with_owner(
let validation_profile = detail
.get("validationProfile")
.and_then(serde_json::Value::as_str)
.filter(|value| *value == AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE);
.filter(|value| {
matches!(
*value,
AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE
| AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE
)
});
let (passed, checks, issues) = if validation_profile.is_some() {
if inspection_kind != Some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND) {
return None;
}
let passed = detail.get("passed")?.as_bool()?;
let assessment = AgentRuntimeUiPrototypeAssessment {
checks: serde_json::from_value(detail.get("checks")?.clone()).ok()?,
issues: serde_json::from_value(detail.get("issues")?.clone()).ok()?,
summary: detail.get("conclusion")?.as_str()?.to_string(),
if validation_profile == Some(AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE) {
let assessment = AgentRuntimeUiPrototypeAssessment {
checks: serde_json::from_value(detail.get("checks")?.clone()).ok()?,
issues: serde_json::from_value(detail.get("issues")?.clone()).ok()?,
summary: detail.get("conclusion")?.as_str()?.to_string(),
}
.validate()
.ok()?;
if passed != assessment.passed() || passed != (observation.status == "ok") {
return None;
}
(
Some(passed),
Some(serde_json::to_value(&assessment.checks).ok()?),
Some(serde_json::to_value(&assessment.issues).ok()?),
)
} else {
let checks = serde_json::from_value::<AgentRuntimeUiPrototypeLegacyChecks>(
detail.get("checks")?.clone(),
)
.ok()?;
let issues =
serde_json::from_value::<Vec<String>>(detail.get("issues")?.clone()).ok()?;
if issues.len() > 8
|| issues.iter().any(|issue| {
issue.trim().is_empty()
|| issue.chars().count() > 160
|| sanitize_agent_runtime_text(issue, 160) != *issue
})
|| passed != (checks.all_passed() && issues.is_empty())
|| passed != (observation.status == "ok")
{
return None;
}
(
Some(passed),
Some(serde_json::to_value(&checks).ok()?),
Some(serde_json::to_value(&issues).ok()?),
)
}
.validate()
.ok()?;
if passed != assessment.passed() || passed != (observation.status == "ok") {
return None;
}
(
Some(passed),
Some(serde_json::to_value(&assessment.checks).ok()?),
Some(serde_json::to_value(&assessment.issues).ok()?),
)
} else {
if inspection_kind.is_some()
|| !matches!(detail.get("passed"), None | Some(serde_json::Value::Null))
@@ -264,6 +264,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_
) -> Result<(), String> {
let mut code_prototype = None;
let mut quality_review = None;
let mut art_director = None;
let mut art_asset_plan = None;
for action in &plan.actions {
let Some(input) = autonomous_initial_delegate_input(action)? else {
@@ -285,6 +286,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_
let slot = match target_agent_id {
"code-prototype" => &mut code_prototype,
AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID => &mut quality_review,
"art-director" => &mut art_director,
"art-asset-plan" => &mut art_asset_plan,
_ => continue,
};
@@ -348,6 +350,31 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_
"首批 quality-review 的 expectedArtifacts 必须为 []",
));
}
if let Some(art_director) = art_director {
let art_task = autonomous_initial_delegate_task(art_director, "art-director")?;
let art_criteria = agent_runtime_tool_input_string_list(
&serde_json::Value::Object(art_director.clone()),
&["acceptanceCriteria", "acceptance_criteria", "criteria"],
);
if std::iter::once(art_task.as_str())
.chain(art_criteria.iter().map(String::as_str))
.any(agent_runtime_task_explicitly_requires_read_only_delivery)
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-director 必须是非只读规范图生成任务",
));
}
let art_artifacts =
autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?;
if !art_artifacts
.iter()
.any(|path| path == "assets/art-spec.png")
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png",
));
}
}
if let Some(art_asset_plan) = art_asset_plan {
let art_task = autonomous_initial_delegate_task(art_asset_plan, "art-asset-plan")?;
let art_criteria = agent_runtime_tool_input_string_list(
@@ -1495,10 +1522,38 @@ mod tests {
.expect("resume first manifest task fixture");
for (local_path, kind) in [
("assets/art-spec.png", "icon-spec"),
("assets/ui-prototype.png", "ui-prototype"),
("assets/art-spritesheet.png", "art-spritesheet"),
] {
std::fs::write(root.join(local_path), b"visual fixture").expect("write visual fixture");
let (generation_route, generation_kind, reference_resource_ids) = match kind {
"icon-spec" => (
"/api/external/v1/editor/images/generations",
"spec",
Vec::new(),
),
"ui-prototype" => (
"/api/external/v1/editor/images/generations",
"ui-design",
vec!["resource-icon-spec".to_string()],
),
_ => (
"/api/external/v1/editor/icon-spritesheets/generations",
"icon-spritesheet",
vec!["resource-icon-spec".to_string()],
),
};
let bytes = if kind == "art-spritesheet" {
use image::ImageEncoder;
let mut bytes = Vec::new();
image::codecs::png::PngEncoder::new(&mut bytes)
.write_image(&[12, 34, 56, 0], 1, 1, image::ColorType::Rgba8.into())
.expect("encode transparent visual fixture");
bytes
} else {
b"\x89PNG\r\n\x1a\nfixture".to_vec()
};
std::fs::write(root.join(local_path), bytes).expect("write visual fixture");
register_local_asset_at(
&root,
local_path,
@@ -1513,6 +1568,9 @@ mod tests {
task_id: Some(format!("task-{kind}")),
prompt: Some("测试视觉资产".to_string()),
model: Some("gpt-image-2".to_string()),
generation_route: Some(generation_route.to_string()),
generation_kind: Some(generation_kind.to_string()),
reference_resource_ids,
},
)
.expect("register visual fixture");
@@ -857,13 +857,13 @@ pub(in crate::agent) fn ui_prototype_visual_inspection_blocker_detail_at_locked(
return Ok(None);
}
Ok(Some(format!(
"expectedPath={expected_path} · resourceBar={} · unitCardTray={} · battlefieldGrid={} · enemyEntryDirection={} · waveStatus={} · primaryControls={} · implementationClarity={} · originalTheme={} · issues={}",
assessment.checks.resource_bar,
assessment.checks.unit_card_tray,
assessment.checks.battlefield_grid,
assessment.checks.enemy_entry_direction,
assessment.checks.wave_status,
"expectedPath={expected_path} · informationHud={} · gameplaySurface={} · objectiveEntities={} · primaryControls={} · failureRestartFlow={} · responsiveLayout={} · implementationClarity={} · originalTheme={} · issues={}",
assessment.checks.information_hud,
assessment.checks.gameplay_surface,
assessment.checks.objective_entities,
assessment.checks.primary_controls,
assessment.checks.failure_restart_flow,
assessment.checks.responsive_layout,
assessment.checks.implementation_clarity,
assessment.checks.original_theme,
assessment.issues.join(""),
@@ -879,6 +879,7 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked(
return None;
}
let (expected_path, expected_kind, label) = match agent_id {
"art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"),
"design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"),
"art-asset-plan" => (
"assets/art-spritesheet.png",
@@ -898,23 +899,15 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked(
});
}
};
let registered = manifest.assets.iter().any(|asset| {
asset.local_path == expected_path
&& asset.kind == expected_kind
&& asset.media_type.starts_with("image/")
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
&& resolve_local_project_path(root, &asset.local_path)
.ok()
.is_some_and(|path| path.is_file())
});
if !registered {
if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) {
return Some(AgentRuntimeToolObservation {
tool: "runtime.visual_asset".to_string(),
status: "blocked".to_string(),
summary: format!("{label}尚未生成并登记,不能完成任务"),
summary: format!("{label}尚未按正式视觉流程生成并登记,不能完成任务"),
detail: Some(format!(
"expectedPath={expected_path} · expectedKind={expected_kind} · editorApiKeyConfigured={}",
editor_api_key_is_configured()
"expectedPath={expected_path} · expectedKind={expected_kind} · editorApiKeyConfigured={} · reason={}",
editor_api_key_is_configured(),
redact_agent_runtime_project_paths(root, &error, 300),
)),
});
}
@@ -1318,7 +1311,7 @@ pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root: &Path,
command_id: &str,
) -> Result<ProjectWriteLock, String> {
const MAX_ATTEMPTS: usize = 200;
const MAX_ATTEMPTS: usize = 2_000;
for attempt in 0..MAX_ATTEMPTS {
match acquire_project_write_lock(root, command_id) {
Err(error)
@@ -1332,3 +1325,10 @@ pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_wait(
}
unreachable!("project write lock retry loop always returns")
}
pub(crate) fn acquire_game_creator_agent_provider_plan_project_write_lock_with_wait(
root: &Path,
command_id: &str,
) -> Result<ProjectWriteLock, String> {
acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, command_id)
}
@@ -53,7 +53,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
agent_runtime_run_profile_identity_at(root, agent_id, run_id, None, None)?;
let mcp_catalog = read_game_creator_mcp_catalog_at(root).await?;
let mut built_request = {
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait(
root,
"runtime.provider_request.build.tool_plan",
)?;
@@ -103,7 +103,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
}
}
built_request = {
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait(
root,
"runtime.provider_request.rebuild.tool_plan",
)?;
@@ -139,7 +139,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
.map(|sidecar| context_compaction_result(sidecar, true));
}
let provider_snapshot = {
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait(
root,
"runtime.provider_request.capture.tool_plan",
)?;
@@ -733,7 +733,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
== AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
{
format!(
"上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同:code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.htmlquality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 [];如当前 policy 的 requiredStaticAgentIds 包含 art-asset-plan,还必须加入非只读美术生成委派且 expectedArtifacts 同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。所有静态委派都使用 agent.delegaterepairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。"
"上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同:code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.htmlquality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 [];如当前 policy 的 requiredStaticAgentIds 包含 art-director,必须加入非只读规范图委派且 expectedArtifacts 包含 assets/art-spec.png;如包含 design-foundation,必须加入非只读设计委派且 expectedArtifacts 包含 memory/project.md、game/game_design.md 与 assets/ui-prototype.png;如包含 art-asset-plan,还必须加入非只读美术生成委派且 expectedArtifacts 同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。所有静态委派都使用 agent.delegaterepairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。"
)
} else {
format!(
@@ -768,6 +768,101 @@ fn response_stream_committed_checkpoint_recovers_by_idempotent_cleanup() {
.is_none());
}
#[test]
fn completed_orphan_finalization_cleanup_keeps_newer_terminal_run() {
let (project, state, response_revision, snapshot) =
response_stream_fixture("response-stream-completed-orphan-run");
let root = project.path();
let response = "旧 run 已完成,只残留幂等清理 journal。";
write_game_creator_agent_runtime_response_stream_ready_at(
root,
&snapshot,
response_revision,
response,
Some("stop"),
)
.expect("write orphan ready stream");
let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at(
root,
state.clone(),
response,
response_revision,
&[],
|checkpoint| {
if checkpoint == AgentRuntimeFinalizationCheckpoint::ResponseStreamCommitted {
Err("injected-orphan-after-response-stream-committed".to_string())
} else {
Ok(())
}
},
)
.expect("leave completed orphan finalization");
assert!(matches!(
outcome,
AgentBackgroundFinalizationOutcome::Pending(_)
));
assert!(read_game_creator_agent_runtime_finalization_journal(
root,
&state.agent_id,
&state.run_id,
)
.expect("read retained orphan journal")
.is_some());
let mut stale_stream =
read_game_creator_agent_runtime_response_stream_at(root, &state.agent_id, &state.run_id)
.expect("read committed orphan stream")
.expect("committed orphan stream exists");
stale_stream.status = AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY.to_string();
stale_stream.request_slot = "final-reply-stale-slot".to_string();
stale_stream.response_revision = response_revision.saturating_sub(1);
stale_stream.accumulated_text = "上一版已过期回复".to_string();
write_game_creator_agent_runtime_response_stream_at(root, &stale_stream)
.expect("replace committed stream with stale ready residue");
let newer_run_id = "response-stream-newer-cancelled-run";
start_game_creator_agent_runtime_task_at(
root,
&state.agent_id,
"更新后的下一轮任务",
newer_run_id,
"agent-background-task",
"等待取消",
vec!["保留新 run 身份".to_string()],
)
.expect("start newer run");
cancel_game_creator_agent_runtime_task_at(root, &state.agent_id, newer_run_id)
.expect("cancel newer run");
assert_eq!(
cleanup_game_creator_agent_runtime_completed_finalizations_at(root)
.expect("clean completed orphan finalization"),
1
);
assert!(read_game_creator_agent_runtime_finalization_journal(
root,
&state.agent_id,
&state.run_id,
)
.expect("read cleaned orphan journal")
.is_none());
let repaired_stream =
read_game_creator_agent_runtime_response_stream_at(root, &state.agent_id, &state.run_id)
.expect("read repaired orphan stream")
.expect("repaired orphan stream exists");
assert_eq!(
repaired_stream.status,
AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED
);
assert_eq!(repaired_stream.accumulated_text, response);
assert_eq!(repaired_stream.response_revision, response_revision);
let current = read_game_creator_agent_runtime_at(root, &state.agent_id)
.expect("read newer runtime after orphan cleanup")
.state;
assert_eq!(current.run_id, newer_run_id);
assert_eq!(current.status, "cancelled");
assert_eq!(current.phase, "cancelled");
}
#[tokio::test]
async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_response() {
let (project, state, _response_revision, snapshot) =
@@ -47,7 +47,10 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
}
fn autonomous_game_build_agent_can_generate_canvas_asset(agent_id: &str) -> bool {
matches!(agent_id.trim(), "design-foundation" | "art-asset-plan")
matches!(
agent_id.trim(),
"art-director" | "design-foundation" | "art-asset-plan"
)
}
pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at(
@@ -253,17 +253,19 @@ pub(crate) use pending_execution::{
pub(crate) use pending_recovery::resume_game_creator_agent_pending_tool_action_at;
#[cfg(test)]
pub(crate) use pending_recovery::resume_game_creator_agent_provider_action_batch_for_test_at;
pub(crate) use provider_recovery::{
autonomous_manifest_parent_wake_error_is_transient,
schedule_waiting_autonomous_manifest_parent_wake_after_lane_release,
schedule_waiting_static_delegate_parent_wake_after_lane_release,
static_delegate_parent_wake_error_is_transient,
};
#[cfg(test)]
pub(crate) use provider_recovery::{
ensure_waiting_provider_retry_records_for_test,
probe_static_delegate_parent_wake_singleflight_coalescing,
};
pub(crate) use provider_recovery::{
schedule_waiting_autonomous_manifest_parent_wake_after_lane_release,
schedule_waiting_static_delegate_parent_wake_after_lane_release,
static_delegate_parent_wake_error_is_transient,
};
pub(crate) use recovery_scan::{
cleanup_game_creator_agent_runtime_completed_finalizations_at,
resume_game_creator_agent_background_tasks_at,
resume_game_creator_agent_pending_action_for_agent_at,
wake_pending_game_creator_agent_background_tasks_at,
@@ -1,5 +1,31 @@
use super::*;
pub(in crate::agent) fn autonomous_registered_derived_visuals_need_repair_at(root: &Path) -> bool {
let Ok(manifest) = read_manifest_for_project(root) else {
return false;
};
[
(
"design-foundation",
"assets/ui-prototype.png",
"ui-prototype",
),
(
"art-asset-plan",
"assets/art-spritesheet.png",
"art-spritesheet",
),
]
.into_iter()
.any(|(task_id, local_path, kind)| {
manifest
.assets
.iter()
.any(|asset| asset.local_path == local_path && asset.kind == kind)
&& validate_manifest_required_visual_asset(root, &manifest, task_id).is_err()
})
}
pub(super) fn game_creator_agent_background_final_reply_fallback(
plan_response: &str,
run_profile: &str,
@@ -225,6 +251,7 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
}
let autonomous_manifest_can_wait = agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& !autonomous_registered_derived_visuals_need_repair_at(&root)
&& !game_creator_agent_runtime_provider_action_batch_exists(
&root,
&agent_id,
@@ -1089,8 +1116,8 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
if agent_id == "design-foundation" {
runtime.current_action = "等待可验收的 UI 原型图".to_string();
runtime.waiting_on =
"图片生成、manifest 登记与 ui-prototype.v1 结构化视觉检查".to_string();
runtime.next_step = "缺图时调用 canvas.asset_generate;已有候选时对 assets/ui-prototype.png 调用 image.inspect;未通过则经 file.delete 权限流程删除后重新生成".to_string();
"图片生成、manifest 登记与 ui-prototype.v2 结构化视觉检查".to_string();
runtime.next_step = "缺图时调用 canvas.asset_generate;已有候选时对 assets/ui-prototype.png 调用 image.inspect;未通过时如实返回 needs-repair,由 Supervisor 认领后发起唯一 repair 原位替换,禁止先删除正式图片".to_string();
} else {
runtime.current_action = "等待实际图片产物".to_string();
runtime.waiting_on = "图片生成确认、配置与本地 manifest 登记".to_string();
@@ -17,11 +17,34 @@ fn register_autonomous_recovery_visual_fixture(root: &Path, local_path: &str, ki
task_id: Some(format!("task-{kind}")),
prompt: None,
model: None,
generation_route: Some("/api/external/v1/editor/images/generations".to_string()),
generation_kind: Some("spec".to_string()),
reference_resource_ids: Vec::new(),
},
)
.expect("register autonomous recovery visual fixture");
}
#[test]
fn autonomous_parent_keeps_planning_before_scheduling_registered_legacy_derived_visuals() {
let root = std::env::temp_dir().join(format!(
"genarrative-agent-main-loop-legacy-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock")
.as_nanos()
));
init_local_game_project_at(&root, "legacy-derived-visuals", "旧派生视觉返工门禁")
.expect("project init");
assert!(!autonomous_registered_derived_visuals_need_repair_at(&root));
register_autonomous_recovery_visual_fixture(&root, "assets/ui-prototype.png", "ui-prototype");
assert!(autonomous_registered_derived_visuals_need_repair_at(&root));
fs::remove_dir_all(root).ok();
}
fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState) -> u64 {
let contract = read_autonomous_completion_contract(root, &state.agent_id, &state.run_id)
.expect("read autonomous completion contract")
@@ -324,7 +347,7 @@ fn autonomous_manifest_waiting_context_persists_without_finishing_parent_run() {
async fn missing_completed_visual_asset_fails_same_child_without_retry() {
const PARENT_RUN_ID: &str = "autonomous-visual-recovery-parent";
const PARENT_TASK: &str = "生成完整小游戏并恢复丢失的正式视觉产物";
const CHILD_ID: &str = "design-foundation";
const CHILD_ID: &str = "art-director";
let _config_guard = crate::tests::write_test_local_config(
r#"{"editorApi":{"apiKey":"visual-recovery-test-key"}}"#.to_string(),
);
@@ -358,7 +381,7 @@ async fn missing_completed_visual_asset_fails_same_child_without_retry() {
"# 游戏设计\n\n恢复丢失图片后才能完成。\n",
)
.expect("write game design fixture");
register_autonomous_recovery_visual_fixture(&root, "assets/ui-prototype.png", "ui-prototype");
register_autonomous_recovery_visual_fixture(&root, "assets/art-spec.png", "icon-spec");
update_manifest_task_status_at(
&root,
"design-director",
@@ -400,7 +423,7 @@ async fn missing_completed_visual_asset_fails_same_child_without_retry() {
update_manifest_task_status_at(&root, CHILD_ID, GameCreationAppTaskStatus::Completed)
.expect("project initial visual completion fixture");
fs::remove_file(root.join("assets/ui-prototype.png")).expect("remove registered visual file");
fs::remove_file(root.join("assets/art-spec.png")).expect("remove registered visual file");
let downgraded = read_manifest_for_project(&root).expect("refresh missing visual manifest");
assert_eq!(
downgraded
@@ -584,6 +607,19 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac
}
assert_eq!(stream.status, "committed");
assert_eq!(stream.accumulated_text, fallback);
for _ in 0..500 {
if read_game_creator_agent_runtime_finalization_journal(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
RUN_ID,
)
.expect("poll finalization residue")
.is_none()
{
break;
}
std::thread::sleep(Duration::from_millis(20));
}
assert!(read_game_creator_agent_runtime_finalization_journal(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
@@ -1,5 +1,9 @@
use super::*;
pub(crate) fn autonomous_manifest_parent_wake_error_is_transient(error: &str) -> bool {
error.starts_with("项目正在被其他写操作占用:")
}
pub(in crate::agent) fn schedule_waiting_provider_retry_wake_after_lane_release(
root: PathBuf,
agent_id: String,
@@ -127,6 +131,9 @@ async fn drive_waiting_autonomous_manifest_parent_wake_pass(
return;
}
},
Err(error) if autonomous_manifest_parent_wake_error_is_transient(&error) => {
continue;
}
Err(error) => {
let _ = mark_autonomous_manifest_parent_wake_needs_reconciliation_at(
root, agent_id, run_id, &error,
@@ -149,6 +149,272 @@ pub(in crate::agent) fn remove_game_creator_agent_runtime_provider_recovery_at(
provider_retry::remove_at(root, agent_id, run_id)
}
pub(crate) fn cleanup_game_creator_agent_runtime_completed_finalizations_at(
root: &Path,
) -> Result<usize, String> {
let journals = list_game_creator_agent_runtime_finalization_journals(root)?;
let mut removed = 0_usize;
for journal in journals {
if journal.status != AGENT_RUNTIME_FINALIZATION_STATUS_RUNTIME_COMPLETED {
continue;
}
let task = read_latest_game_creator_agent_runtime_task_by_run_id(
root,
&journal.agent_id,
&journal.run_id,
)?
.ok_or_else(|| {
format!(
"已完成 finalization 缺少所属任务:agent={} runId={}",
journal.agent_id, journal.run_id
)
})?;
if task.status != "completed"
|| task.phase != "completed"
|| task.agent_id != journal.agent_id
|| task.task_id != journal.task_id
|| task.session_id != journal.session_id
|| task.run_id != journal.run_id
|| task.source != journal.source
|| task.parent_agent_id != journal.parent_agent_id
|| task.parent_run_id != journal.parent_run_id
|| task.delegation_id != journal.delegation_id
|| task.goal_id != journal.goal_id
|| task.goal_revision != journal.goal_revision
{
return Err(format!(
"已完成 finalization 与所属终态任务身份冲突:agent={} runId={}",
journal.agent_id, journal.run_id
));
}
if !game_creator_agent_runtime_finalization_assistant_exists(root, &journal)? {
return Err(format!(
"已完成 finalization 的 assistant 消息缺失:agent={} runId={}",
journal.agent_id, journal.run_id
));
}
let (records, scan_truncated) =
read_agent_db_records_bounded(root, AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES)?;
let finalization_records = records
.iter()
.filter(|record| {
record
.get("finalizationId")
.and_then(serde_json::Value::as_str)
== Some(journal.finalization_id.as_str())
})
.collect::<Vec<_>>();
let lifecycle_records = finalization_records
.iter()
.filter(|record| {
record.get("recordType").and_then(serde_json::Value::as_str)
== Some(AGENT_RUNTIME_FINALIZATION_LIFECYCLE_RECORD_TYPE)
})
.copied()
.collect::<Vec<_>>();
let lifecycle_stages = lifecycle_records
.iter()
.filter_map(|record| record.get("stage").and_then(serde_json::Value::as_str))
.collect::<Vec<_>>();
let exact_record_count = |record_type: &str| {
finalization_records
.iter()
.filter(|record| {
record.get("recordType").and_then(serde_json::Value::as_str)
== Some(record_type)
&& record.get("agentId").and_then(serde_json::Value::as_str)
== Some(journal.agent_id.as_str())
&& record.get("taskId").and_then(serde_json::Value::as_str)
== Some(journal.task_id.as_str())
&& record.get("sessionId").and_then(serde_json::Value::as_str)
== Some(journal.session_id.as_str())
&& record.get("runId").and_then(serde_json::Value::as_str)
== Some(journal.run_id.as_str())
&& record.get("messageId").and_then(serde_json::Value::as_str)
== Some(journal.message_id.as_str())
})
.count()
};
let conversation_message_count = finalization_records
.iter()
.filter(|record| {
record.get("recordType").and_then(serde_json::Value::as_str)
== Some("conversation.message")
&& record.get("agentId").and_then(serde_json::Value::as_str)
== Some(journal.agent_id.as_str())
&& record.get("sessionId").and_then(serde_json::Value::as_str)
== Some(journal.session_id.as_str())
&& record.get("messageId").and_then(serde_json::Value::as_str)
== Some(journal.message_id.as_str())
&& record.get("role").and_then(serde_json::Value::as_str) == Some("assistant")
})
.count();
let lifecycle_identity_matches = lifecycle_records.iter().all(|record| {
record.get("agentId").and_then(serde_json::Value::as_str)
== Some(journal.agent_id.as_str())
&& record.get("taskId").and_then(serde_json::Value::as_str)
== Some(journal.task_id.as_str())
&& record.get("sessionId").and_then(serde_json::Value::as_str)
== Some(journal.session_id.as_str())
&& record.get("runId").and_then(serde_json::Value::as_str)
== Some(journal.run_id.as_str())
&& record.get("messageId").and_then(serde_json::Value::as_str)
== Some(journal.message_id.as_str())
&& record.get("source").and_then(serde_json::Value::as_str)
== Some(journal.source.as_str())
});
if scan_truncated
|| finalization_records.len() != 7
|| !lifecycle_identity_matches
|| lifecycle_stages
!= [
"prepared",
"assistant-persisted",
"runtime-completed",
"goal-completed",
]
|| conversation_message_count != 1
|| exact_record_count("agent.runtime.completed") != 1
|| exact_record_count("agent.runtime.background_task.completed") != 1
{
return Err(format!(
"已完成 finalization 缺少完整唯一七槽审计:agent={} runId={} scanTruncated={scan_truncated}",
journal.agent_id, journal.run_id
));
}
let mut stream = read_game_creator_agent_runtime_response_stream_at(
root,
&journal.agent_id,
&journal.run_id,
)?
.ok_or_else(|| {
format!(
"已完成 finalization 的 committed 回复流缺失:agent={} runId={}",
journal.agent_id, journal.run_id
)
})?;
let stream_base_identity_conflicts = stream.agent_id != journal.agent_id
|| stream.task_id != journal.task_id
|| stream.session_id != journal.session_id
|| stream.run_id != journal.run_id
|| stream.request_kind != "final-reply";
if stream_base_identity_conflicts {
return Err(format!(
"已完成 finalization 与 committed 回复流身份冲突:agent={} runId={}",
journal.agent_id, journal.run_id
));
}
let stream_matches = |stream: &AgentRuntimeResponseStream| {
stream.status == AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED
&& stream.request_slot == journal.response_request_slot
&& stream.applied_steer_cursor == journal.response_steer_cursor
&& stream.response_revision == journal.response_revision
&& stream.accumulated_text == journal.response
};
if !stream_matches(&stream) {
if stream.status == AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED {
return Err(format!(
"已完成 finalization 与 committed 回复流身份冲突:agent={} runId={}",
journal.agent_id, journal.run_id
));
}
let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"runtime.finalization.completed-stream-repair",
)?;
remove_game_creator_agent_runtime_response_stream_at(
root,
&journal.agent_id,
&journal.run_id,
)?;
let mut state = agent_runtime_state_from_task_record(&task);
state.applied_steer_cursor = journal.response_steer_cursor;
mark_game_creator_agent_runtime_response_stream_committed_at(root, &state, &journal)?;
stream = read_game_creator_agent_runtime_response_stream_at(
root,
&journal.agent_id,
&journal.run_id,
)?
.ok_or_else(|| "已完成 finalization 的回复流修复后缺失".to_string())?;
if !stream_matches(&stream) {
return Err(format!(
"已完成 finalization 的回复流修复后仍冲突:agent={} runId={}",
journal.agent_id, journal.run_id
));
}
let _ = append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.background_task.finalization_completed_stream_repaired",
"agentId": journal.agent_id,
"taskId": journal.task_id,
"sessionId": journal.session_id,
"runId": journal.run_id,
"source": journal.source,
}),
);
}
let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"runtime.finalization.completed-cleanup",
)?;
remove_game_creator_agent_runtime_finalization_recovery_sidecars(
root,
&journal.agent_id,
&journal.run_id,
)?;
let _ = append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.background_task.finalization_completed_cleaned",
"agentId": journal.agent_id,
"taskId": journal.task_id,
"sessionId": journal.session_id,
"runId": journal.run_id,
"source": journal.source,
}),
);
removed = removed.saturating_add(1);
}
Ok(removed)
}
fn current_game_creator_agent_runtime_finalization_exists_at(
root: &Path,
agent_ids: &std::collections::BTreeSet<String>,
) -> Result<bool, String> {
for agent_id in agent_ids {
let (state, _) =
read_game_creator_agent_runtime_state_for_finalization_resume(root, agent_id)?;
if state.run_id.trim().is_empty() {
continue;
}
let path = game_creator_agent_runtime_finalization_path(root, agent_id, &state.run_id);
match fs::symlink_metadata(&path) {
Ok(_) => return Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"读取当前 Agent Runtime finalization 元数据失败:{}: {error}",
path.display()
));
}
}
let backup_path = agent_runtime_json_sidecar_backup_path(&path);
match fs::symlink_metadata(&backup_path) {
Ok(_) => return Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"读取当前 Agent Runtime finalization 恢复副本元数据失败:{}: {error}",
backup_path.display()
));
}
}
}
Ok(false)
}
pub(crate) fn resume_game_creator_agent_background_tasks_at(
root: &Path,
) -> Result<Vec<AgentRuntimeResult>, String> {
@@ -165,6 +431,9 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at
return read_game_creator_agent_runtimes_at(root);
}
let agent_ids = collect_game_creator_agent_runtime_agent_ids(root)?;
if !current_game_creator_agent_runtime_finalization_exists_at(root, &agent_ids)? {
cleanup_game_creator_agent_runtime_completed_finalizations_at(root)?;
}
for ledger in tool_plan_handoff::list_at(root)? {
let agent_id = ledger.agent_id();
let run_id = ledger.run_id();
@@ -559,6 +559,7 @@ fn validate_autonomous_game_build_ready_task_parent_at(
root: &Path,
parent_agent_id: &str,
parent_run_id: &str,
require_static_delegate_barrier: bool,
) -> Result<AgentRuntimeRunProfileBinding, String> {
let parent_agent_id = normalize_game_creator_runtime_agent_id(parent_agent_id)?;
if parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
@@ -606,12 +607,14 @@ fn validate_autonomous_game_build_ready_task_parent_at(
&agent_runtime_state_from_task_record(&current_root),
)?
.ok_or_else(|| "autonomous ready-task scheduler 父 Run 缺少完成合同".to_string())?;
let barrier = static_delegate_completion_barrier_at(root, &parent_agent_id, parent_run_id)?;
if !barrier.is_clear() {
return Err(format!(
"autonomous ready-task scheduler 必须等待静态委派完成屏障收束:{}",
barrier.detail()
));
if require_static_delegate_barrier {
let barrier = static_delegate_completion_barrier_at(root, &parent_agent_id, parent_run_id)?;
if !barrier.is_clear() {
return Err(format!(
"autonomous ready-task scheduler 必须等待静态委派完成屏障收束:{}",
barrier.detail()
));
}
}
Ok(binding)
}
@@ -880,6 +883,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at(
root,
parent_agent_id,
parent_run_id,
true,
)?;
let manifest = read_manifest_for_project(root)?;
let active_count = manifest
@@ -923,10 +927,12 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at(
}
for (task, transition_to_running) in candidates {
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &task.id)?;
let parent_binding = match validate_autonomous_game_build_ready_task_parent_at(
root,
parent_agent_id,
parent_run_id,
true,
) {
Ok(binding) => binding,
Err(error) => {
@@ -940,6 +946,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at(
root,
parent_agent_id,
parent_run_id,
true,
) {
let cancelled = AgentRuntimeTaskRecord {
status: "cancelled".to_string(),
@@ -965,7 +972,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at(
if game_creator_agent_runtime_terminal_status(&record).is_some() {
terminal_records.push(record.clone());
}
scheduled.push((task, result, record, needs_notification));
scheduled.push((task, result, record, needs_notification, runtime_lock));
}
Err(error) => {
update_manifest_task_status_at(
@@ -995,7 +1002,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at(
}
let mut results = Vec::new();
for (task, result, record, needs_notification) in scheduled {
for (task, result, record, needs_notification, runtime_lock) in scheduled {
append_agent_db_record(
root,
serde_json::json!({
@@ -1013,6 +1020,15 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at(
"recovered": needs_notification,
}),
)?;
if game_creator_agent_runtime_terminal_status(&record).is_none() {
if let Some(runtime_lock) = runtime_lock {
spawn_next_game_creator_agent_background_task_drain_with_lock(
root,
&record.agent_id,
runtime_lock,
);
}
}
if needs_notification
&& game_creator_agent_runtime_terminal_status(&record).is_none()
&& notify_external_agent_runner_after_background_task_enqueue(
@@ -1101,6 +1117,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
root,
&parent_agent_id,
&parent_run_id,
false,
)?;
let manifest = read_manifest_for_project(root)?;
let Some(manifest_task) = manifest.tasks.iter().find(|task| task.id == state.agent_id) else {
@@ -1119,7 +1136,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
if status == GameCreationAppTaskStatus::Completed
&& matches!(
manifest_task.id.as_str(),
"design-foundation" | "art-asset-plan"
"art-director" | "design-foundation" | "art-asset-plan"
)
&& !manifest_has_required_visual_asset(root, &manifest, &manifest_task.id)
{
@@ -82,6 +82,7 @@ pub(crate) use run_configuration::{
read_game_creator_agent_runtime_run_profile_binding,
};
pub(crate) use steering::{
acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait,
consume_game_creator_agent_runtime_steers, game_creator_agent_runtime_accepts_steer,
game_creator_agent_runtime_steer_ledger_path,
interrupt_game_creator_agent_runtime_provider_request_at,
@@ -57,8 +57,35 @@ fn autonomous_fixture_with_setup(
}
fn register_autonomous_visual_fixture(root: &Path, local_path: &str, kind: &str) {
fs::write(root.join(local_path), b"\x89PNG\r\n\x1a\nfixture")
.expect("write autonomous visual fixture");
let bytes = if kind == "art-spritesheet" {
use image::ImageEncoder;
let mut bytes = Vec::new();
image::codecs::png::PngEncoder::new(&mut bytes)
.write_image(&[12, 34, 56, 0], 1, 1, image::ColorType::Rgba8.into())
.expect("encode transparent autonomous visual fixture");
bytes
} else {
b"\x89PNG\r\n\x1a\nfixture".to_vec()
};
fs::write(root.join(local_path), bytes).expect("write autonomous visual fixture");
let (generation_route, generation_kind, reference_resource_ids) = match kind {
"icon-spec" => (
"/api/external/v1/editor/images/generations",
"spec",
Vec::new(),
),
"ui-prototype" => (
"/api/external/v1/editor/images/generations",
"ui-design",
vec!["resource-icon-spec".to_string()],
),
"art-spritesheet" => (
"/api/external/v1/editor/icon-spritesheets/generations",
"icon-spritesheet",
vec!["resource-icon-spec".to_string()],
),
_ => ("", "", Vec::new()),
};
register_local_asset_at(
root,
local_path,
@@ -73,6 +100,9 @@ fn register_autonomous_visual_fixture(root: &Path, local_path: &str, kind: &str)
task_id: Some(format!("task-{kind}")),
prompt: None,
model: None,
generation_route: (!generation_route.is_empty()).then(|| generation_route.to_string()),
generation_kind: (!generation_kind.is_empty()).then(|| generation_kind.to_string()),
reference_resource_ids,
},
)
.expect("register autonomous visual fixture");
@@ -100,6 +130,7 @@ fn prepare_completed_autonomous_manifest_fixture(root: &Path) {
.expect("write audio manifest fixture");
fs::write(root.join("exports/README.md"), "# 发布说明\n\n可试玩。\n")
.expect("write publish fixture");
register_autonomous_visual_fixture(root, "assets/art-spec.png", "icon-spec");
register_autonomous_visual_fixture(root, "assets/ui-prototype.png", "ui-prototype");
register_autonomous_visual_fixture(root, "assets/art-spritesheet.png", "art-spritesheet");
for task in new_game_creation_app_seed_tasks() {
@@ -1010,6 +1041,54 @@ fn autonomous_scheduler_child_terminal_projects_with_existing_project_lock() {
);
}
#[test]
fn autonomous_scheduler_child_terminal_projects_while_static_delegate_is_still_running() {
let (_temporary, root, parent_state, _contract) = autonomous_fixture(
"做一个完整小游戏",
"autonomous-scheduler-static-overlap-parent",
);
update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Running)
.expect("mark scheduler child running");
let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "design-director");
let waiting_delivery = new_static_delegate_delivery(
&parent_state.agent_id,
&parent_state.session_id,
&parent_state.run_id,
"overlapping-static-action",
"overlapping-static-delegation",
"quality-review",
"overlapping-quality-session",
"overlapping-quality-run",
);
create_or_read_static_delegate_delivery_at(&root, &waiting_delivery)
.expect("create overlapping static delivery");
assert!(static_delegate_completion_barrier_at(
&root,
&parent_state.agent_id,
&parent_state.run_id,
)
.expect("read overlapping static barrier")
.has_waiting());
let mut state = agent_runtime_state_from_task_record(&record);
state.status = "completed".to_string();
state.phase = "completed".to_string();
assert!(
project_autonomous_manifest_ready_task_terminal_at(&root, &state)
.expect("project scheduler child despite independent static delivery")
);
assert_eq!(
read_manifest_for_project(&root)
.expect("read scheduler child manifest")
.tasks
.iter()
.find(|task| task.id == "design-director")
.map(|task| &task.status),
Some(&GameCreationAppTaskStatus::Completed)
);
}
#[tokio::test]
async fn autonomous_scheduler_recovers_running_without_journal_and_journal_without_running() {
let (_temporary, root, parent_state, _contract) =
@@ -377,6 +377,89 @@ pub(crate) fn read_game_creator_agent_runtime_finalization_journal(
Ok(Some(journal))
}
pub(in crate::agent) fn list_game_creator_agent_runtime_finalization_journals(
root: &Path,
) -> Result<Vec<AgentRuntimeFinalizationJournal>, String> {
let directory = root.join(".agent/runtime/finalizations");
let agent_entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => {
return Err(format!(
"读取 Agent Runtime finalization 目录失败:{}: {error}",
directory.display()
));
}
};
let mut identities = BTreeSet::<(String, String)>::new();
for agent_entry in agent_entries {
let agent_entry = agent_entry.map_err(|error| {
format!(
"读取 Agent Runtime finalization Agent 目录项失败:{}: {error}",
directory.display()
)
})?;
let agent_type = agent_entry.file_type().map_err(|error| {
format!(
"读取 Agent Runtime finalization Agent 类型失败:{}: {error}",
agent_entry.path().display()
)
})?;
if agent_type.is_symlink() || !agent_type.is_dir() {
return Err("Agent Runtime finalization Agent 项必须是普通目录".to_string());
}
let agent_id = agent_entry
.file_name()
.to_str()
.ok_or_else(|| "Agent Runtime finalization Agent 目录名必须是 UTF-8".to_string())?
.to_string();
for journal_entry in fs::read_dir(agent_entry.path()).map_err(|error| {
format!(
"读取 Agent Runtime finalization Agent 目录失败:{}: {error}",
agent_entry.path().display()
)
})? {
let journal_entry = journal_entry.map_err(|error| {
format!(
"读取 Agent Runtime finalization 目录项失败:{}: {error}",
agent_entry.path().display()
)
})?;
let journal_type = journal_entry.file_type().map_err(|error| {
format!(
"读取 Agent Runtime finalization 类型失败:{}: {error}",
journal_entry.path().display()
)
})?;
if journal_type.is_symlink() || !journal_type.is_file() {
return Err("Agent Runtime finalization 项必须是普通文件".to_string());
}
let file_name = journal_entry
.file_name()
.to_str()
.ok_or_else(|| "Agent Runtime finalization 文件名必须是 UTF-8".to_string())?
.to_string();
let run_id = file_name
.strip_suffix(".json")
.or_else(|| {
file_name
.strip_prefix('.')
.and_then(|value| value.strip_suffix(".json.previous"))
})
.ok_or_else(|| "Agent Runtime finalization 目录含未知文件".to_string())?;
identities.insert((agent_id.clone(), run_id.to_string()));
}
}
let mut journals = Vec::with_capacity(identities.len());
for (agent_id, run_id) in identities {
let journal =
read_game_creator_agent_runtime_finalization_journal(root, &agent_id, &run_id)?
.ok_or_else(|| "Agent Runtime finalization 列举期间消失".to_string())?;
journals.push(journal);
}
Ok(journals)
}
pub(in crate::agent) fn write_game_creator_agent_runtime_finalization_journal(
root: &Path,
journal: &AgentRuntimeFinalizationJournal,
@@ -129,6 +129,33 @@ pub(crate) fn read_game_creator_agent_runtime_response_stream_at(
Ok(stream)
}
pub(in crate::agent) fn remove_game_creator_agent_runtime_response_stream_at(
root: &Path,
agent_id: &str,
run_id: &str,
) -> Result<(), String> {
let path = game_creator_agent_runtime_response_stream_path(root, agent_id, run_id);
let backup_path = agent_runtime_json_sidecar_backup_path(&path);
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
Err("Agent Runtime 回复流必须是普通文件".to_string())
}
Ok(_) => remove_agent_runtime_json_sidecar_backup(&backup_path, "Agent Runtime 回复流")
.and_then(|_| {
fs::remove_file(&path).map_err(|error| {
format!("删除 Agent Runtime 回复流失败:{}: {error}", path.display())
})
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
remove_agent_runtime_json_sidecar_backup(&backup_path, "Agent Runtime 回复流")
}
Err(error) => Err(format!(
"读取 Agent Runtime 回复流元数据失败:{}: {error}",
path.display()
)),
}
}
pub(in crate::agent) fn write_game_creator_agent_runtime_response_stream_at(
root: &Path,
stream: &AgentRuntimeResponseStream,

Some files were not shown because too many files have changed in this diff Show More