新增开发态游戏聊天创作页
新增仅开发态游戏聊天入口与本地安全预览布局 将Supervisor进度、返工、测试、迭代和图片证据写入聊天 完善自动预览授权持久化、锁竞争重试与Runtime收尾恢复 规范视觉产物走规范图、UI设计和透明图集专用接口 补齐共享契约、回归测试、技术方案和项目记忆
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
+59
-1
@@ -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)
|
||||
}
|
||||
|
||||
+4
-4
@@ -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.html;quality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 [];如当前 policy 的 requiredStaticAgentIds 包含 art-asset-plan,还必须加入非只读美术生成委派且 expectedArtifacts 同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。所有静态委派都使用 agent.delegate,repairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。"
|
||||
"上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同:code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.html;quality-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.delegate,repairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
|
||||
+95
@@ -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) =
|
||||
|
||||
+4
-1
@@ -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(¤t_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,
|
||||
|
||||
+81
-2
@@ -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
Reference in New Issue
Block a user