合并最新主分支

同步 Runtime、外部 API 与统一弹窗等主分支更新

解决开发栈进程探活、Runtime continuation 与启动测试冲突

保留资源依赖图排障记忆并验证冲突路径
This commit is contained in:
2026-08-03 19:21:12 +08:00
102 changed files with 9147 additions and 1581 deletions
@@ -2370,6 +2370,7 @@ function createDeterministicCanvasFixture(apiKey) {
const projectId = 'deterministic-canvas-project';
const folderId = 'deterministic-canvas-folder';
const images = new Map();
const generationOperations = new Map();
const imageCache = new Map();
const stats = {
canvasApiRequestCount: 0,
@@ -2476,6 +2477,16 @@ function createDeterministicCanvasFixture(apiKey) {
request.method === 'POST' &&
parsed.pathname === '/api/external/v1/editor/images/generations'
) {
const idempotencyKey = request.headers['idempotency-key'];
if (
typeof idempotencyKey !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
idempotencyKey,
)
) {
request.resume();
return json(400, { error: { message: 'invalid idempotency key' } });
}
const body = await readJsonBody(request);
const image = imageForAspectRatio(body?.aspectRatio);
generationSequence += 1;
@@ -2489,8 +2500,22 @@ function createDeterministicCanvasFixture(apiKey) {
const assetKind =
typeof body?.assetKind === 'string' ? body.assetKind : 'game-art';
images.set(imageId, { ...image, objectKey });
return json(200, {
data: {
const operationId = `task-${imageId}`;
generationOperations.set(operationId, {
imageSrc: `/${objectKey}`,
objectKey,
assetObjectId,
width: image.width,
height: image.height,
sourceType: 'generated',
prompt: body?.prompt ?? 'deterministic canvas fixture',
actualPrompt: body?.prompt ?? 'deterministic canvas fixture',
model: 'deterministic-canvas-v1',
provider: 'deterministic-loopback',
taskId: `task-${imageId}`,
resource: {
resourceId,
projectId,
imageSrc: `/${objectKey}`,
objectKey,
assetObjectId,
@@ -2502,22 +2527,45 @@ function createDeterministicCanvasFixture(apiKey) {
model: 'deterministic-canvas-v1',
provider: 'deterministic-loopback',
taskId: `task-${imageId}`,
resource: {
resourceId,
projectId,
imageSrc: `/${objectKey}`,
objectKey,
assetObjectId,
width: image.width,
height: image.height,
sourceType: 'generated',
assetKind,
},
asset: {
assetId: `asset-${imageId}`,
assetObjectId,
assetKind,
},
assetKind,
},
asset: {
assetId: `asset-${imageId}`,
assetObjectId,
assetKind,
},
});
return json(202, {
data: {
operationId,
kind: 'editor_image_generation',
status: 'queued',
statusUrl: `/api/external/v1/generations/${operationId}`,
pollAfterMs: 1,
updatedAtMicros: generationSequence,
},
});
}
if (
request.method === 'GET' &&
parsed.pathname.startsWith('/api/external/v1/generations/')
) {
request.resume();
const operationId = parsed.pathname.slice(
'/api/external/v1/generations/'.length,
);
const result = generationOperations.get(operationId);
if (!result) return json(404, { error: { message: 'operation not found' } });
return json(200, {
data: {
operationId,
kind: 'editor_image_generation',
status: 'completed',
phaseLabel: '图片画布生成图片',
phaseDetail: '生成已完成。',
progress: 100,
result,
updatedAtMicros: generationSequence,
},
});
}
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import { join, resolve } from 'node:path';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -300,80 +300,52 @@ function stopChild(child, signal = 'SIGTERM') {
}
}
function parseLinuxProcessStat(value) {
const commandEnd = value.lastIndexOf(')');
if (commandEnd < 0) {
return null;
}
const fields = value
.slice(commandEnd + 1)
.trim()
.split(/\s+/);
if (fields.length < 3 || fields[0].length !== 1) {
return null;
}
const processGroupId = Number(fields[2]);
if (!Number.isInteger(processGroupId)) {
return null;
}
return { state: fields[0], processGroupId };
}
function readLinuxProcessGroupRunning(
function readLinuxProcessGroupAlive(
processGroupId,
{
procRoot = '/proc',
readdirImpl = readdirSync,
readFileImpl = readFileSync,
} = {},
{ readdirImpl = readdirSync, readFileImpl = readFileSync } = {},
) {
let entries;
let processIds;
try {
entries = readdirImpl(procRoot, { withFileTypes: true });
processIds = readdirImpl('/proc');
} catch {
return null;
}
let inspectedProcess = false;
for (const entry of entries) {
const name = typeof entry === 'string' ? entry : entry.name;
if (!/^\d+$/.test(name)) {
continue;
}
if (typeof entry !== 'string' && !entry.isDirectory()) {
for (const processId of processIds) {
if (!/^\d+$/.test(processId)) {
continue;
}
let stat;
try {
stat = readFileImpl(join(procRoot, name, 'stat'), 'utf8');
} catch (error) {
// 进程可能在枚举后立刻退出;继续检查同组的其它成员。
if (error?.code === 'ENOENT' || error?.code === 'ESRCH') {
continue;
}
return null;
stat = readFileImpl(`/proc/${processId}/stat`, 'utf8');
} catch {
continue;
}
const parsed = parseLinuxProcessStat(stat);
if (!parsed) {
return null;
const commandEnd = stat.lastIndexOf(') ');
if (commandEnd < 0) {
continue;
}
inspectedProcess = true;
const [state, , processGroup] = stat
.slice(commandEnd + 2)
.trim()
.split(/\s+/);
if (
parsed?.processGroupId === processGroupId &&
!['Z', 'X', 'x'].includes(parsed.state)
Number(processGroup) === processGroupId &&
state !== 'Z' &&
state !== 'X'
) {
return true;
}
}
return inspectedProcess ? false : null;
return false;
}
function isProcessGroupRunning(
function isProcessGroupAlive(
processGroupId,
{
platform = process.platform,
killImpl = process.kill,
readLinuxProcessGroup = readLinuxProcessGroupRunning,
readLinuxGroupAlive = readLinuxProcessGroupAlive,
} = {},
) {
if (!Number.isInteger(processGroupId)) {
@@ -384,16 +356,13 @@ function isProcessGroupRunning(
} catch (error) {
return error?.code !== 'ESRCH';
}
if (platform !== 'linux') {
return true;
}
try {
// Linux 的 kill(-PGID, 0) 会把尚未被容器 PID 1 回收的 zombie 也视为
// 存在;zombie 已不能执行代码,不应让有界清理被误判为失败。
return readLinuxProcessGroup(processGroupId) ?? true;
} catch {
return true;
if (platform === 'linux') {
const linuxGroupAlive = readLinuxGroupAlive(processGroupId);
if (typeof linuxGroupAlive === 'boolean') {
return linuxGroupAlive;
}
}
return true;
}
async function waitUntil(check, timeoutMs, pollIntervalMs = 25) {
@@ -499,7 +468,7 @@ async function terminateChildTree(
stopChild(child, 'SIGTERM');
if (
await waitUntil(
() => !isProcessGroupRunning(processGroupId, { platform, killImpl }),
() => !isProcessGroupAlive(processGroupId, { platform, killImpl }),
gracefulTimeoutMs,
)
) {
@@ -514,7 +483,7 @@ async function terminateChildTree(
}
}
const stopped = await waitUntil(
() => !isProcessGroupRunning(processGroupId, { platform, killImpl }),
() => !isProcessGroupAlive(processGroupId, { platform, killImpl }),
forceTimeoutMs,
);
return { stopped, forced: true };
@@ -684,10 +653,10 @@ export {
ensureBackend,
formatChildFailure,
isDirectModuleExecution,
isProcessGroupRunning,
isProcessGroupAlive,
preflightExistingVite,
readChildFailure,
readLinuxProcessGroupRunning,
readLinuxProcessGroupAlive,
resolveBackendTargetsFromState,
runWindowsTaskkill,
spawnChild,
+1
View File
@@ -1507,6 +1507,7 @@ dependencies = [
"tokio",
"unicode-normalization",
"url",
"uuid",
"windows-sys 0.61.2",
"zip",
]
@@ -36,6 +36,7 @@ tempfile = "3"
tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
url = "2"
unicode-normalization = "0.1"
uuid = { version = "1", features = ["v4"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
tauri-plugin-clipboard-manager = "2.3.2"
@@ -3,6 +3,7 @@ use super::*;
mod canvas_generation;
mod draft_validation;
mod draft_writer;
mod external_generation_state;
mod loop_orchestration;
mod pass_artifacts;
mod prompt_context;
@@ -13,9 +14,22 @@ mod tests;
mod trace;
pub(in crate::agent) use canvas_generation::{
commit_prepared_platform_art_asset_at, request_platform_art_asset_with_options_at,
commit_prepared_platform_art_asset_at, platform_art_generation_error_needs_reconciliation,
request_platform_art_asset_with_runtime_options_at,
};
pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks;
pub(in crate::agent) use external_generation_state::{
game_creator_agent_runtime_external_generation_exists,
platform_art_generation_runtime_context_from_pending,
platform_art_generation_runtime_recovery_at, remove_platform_art_generation_runtime_state_at,
PlatformArtGenerationRuntimeContext, PlatformArtGenerationRuntimeRecovery,
PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION,
};
#[cfg(test)]
pub(crate) use external_generation_state::{
setup_platform_art_generation_runtime_accepted_for_recovery_test,
write_platform_art_generation_runtime_accepted_for_test,
};
pub(in crate::agent) use loop_orchestration::build_game_creator_agent_runtime_llm_client;
pub(in crate::agent) use trace::game_creation_agent_group_id;
File diff suppressed because it is too large Load Diff
@@ -382,6 +382,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
run_id,
task,
&action.input,
pending_action,
)
.await
}
@@ -235,6 +235,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_has_pending_action_ledger(
game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, run_id)
|| game_creator_agent_runtime_parallel_read_batch_exists(root, agent_id, run_id)
|| game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, run_id)
|| game_creator_agent_runtime_external_generation_exists(root, agent_id, run_id)
}
pub(in crate::agent) fn agent_runtime_parallel_read_batch_id(
@@ -529,6 +529,10 @@ pub(in crate::agent) fn remove_game_creator_agent_runtime_pending_tool_action(
{
let _ = cancel_game_creator_agent_user_input_request_for_pending_at(root, &pending);
}
// pending action 是 generation / parallel sidecar 的 durable 身份锚点。先收束附属账本,
// 确保任何清理失败或进程中断都不会留下无法归属、却持续触发恢复扫描的孤儿。
remove_platform_art_generation_runtime_state_at(root, agent_id, run_id)?;
remove_game_creator_agent_runtime_parallel_read_batch(root, agent_id, run_id)?;
let path = game_creator_agent_runtime_pending_tool_action_path(root, agent_id, run_id);
let backup_path = agent_runtime_json_sidecar_backup_path(&path);
remove_agent_runtime_json_sidecar_backup(&backup_path, "Agent Runtime 待确认动作")?;
@@ -547,8 +551,7 @@ pub(in crate::agent) fn remove_game_creator_agent_runtime_pending_tool_action(
"读取 Agent Runtime 待确认动作元数据失败:{}: {error}",
path.display()
)),
}?;
remove_game_creator_agent_runtime_parallel_read_batch(root, agent_id, run_id)
}
}
pub(in crate::agent) fn remove_game_creator_agent_runtime_confirmations(
@@ -682,9 +685,61 @@ pub(in crate::agent) fn consume_game_creator_agent_runtime_tool_confirmation(
#[cfg(test)]
mod tests {
use super::validate_agent_runtime_pending_serialized_content;
use super::*;
use std::path::Path;
fn pending_external_generation_action(
root: &Path,
run_id: &str,
) -> AgentRuntimePendingToolAction {
let mut runtime = start_game_creator_agent_runtime_task_at(
root,
"art-director",
"生成视觉规范图",
run_id,
"agent-ready-task-scheduler",
"准备生成视觉规范图",
vec!["生成视觉规范图".to_string()],
)
.expect("start runtime");
runtime.loop_iteration = 1;
let action = AgentRuntimeToolAction {
tool: "canvas.asset_generate".to_string(),
reason: Some("生成统一视觉规范".to_string()),
input: serde_json::json!({
"prompt": "生成统一视觉规范图",
"outputPath": "assets/art-spec.png"
}),
};
let plan = AgentRuntimeToolPlan {
thinking_summary: "准备生成".to_string(),
plan_update: None,
plan: vec!["生成视觉规范图".to_string()],
actions: vec![action.clone()],
response: String::new(),
};
let revision =
read_game_creator_agent_runtime_project_revision(root).expect("read project revision");
let repository_fingerprint = build_repository_startup_context_at(root)
.expect("repository context")
.fingerprint;
build_game_creator_agent_runtime_pending_tool_action(
root,
&runtime,
&runtime.current_task,
&plan,
&[],
&revision,
&repository_fingerprint,
&action,
0,
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING,
None,
)
.expect("build pending external generation action")
}
#[test]
fn pending_content_allows_api_key_security_guidance_without_secret_material() {
for task in [
@@ -732,4 +787,59 @@ mod tests {
assert!(error.contains(&format!("#{rule}")), "{content}: {error}");
}
}
#[test]
fn generation_cleanup_failure_preserves_pending_identity_anchor() {
let temporary = tempfile::tempdir().expect("create pending cleanup project");
let root = temporary.path();
let run_id = "generation-cleanup-order-run";
init_local_game_project_at(root, "generation-cleanup-order", "生成账本清理顺序测试")
.expect("init project");
let pending = pending_external_generation_action(root, run_id);
write_game_creator_agent_runtime_pending_tool_action(root, &pending)
.expect("write pending action");
write_platform_art_generation_runtime_accepted_for_test(root, &pending)
.expect("write accepted generation state");
let generation_path = root.join(format!(
".agent/runtime/canvas-generation-requests/art-director/{run_id}.json"
));
fs::remove_file(&generation_path).expect("remove generation state fixture");
fs::create_dir(&generation_path).expect("replace generation state with invalid directory");
let error = remove_game_creator_agent_runtime_pending_tool_action(
root,
&pending.agent_id,
&pending.run_id,
)
.expect_err("generation cleanup failure must stop pending removal");
assert!(error.contains("External Editor 生成账本必须是普通文件"));
assert!(game_creator_agent_runtime_pending_tool_action_exists(
root,
&pending.agent_id,
&pending.run_id
));
assert_eq!(
read_game_creator_agent_runtime_pending_tool_action(
root,
&pending.agent_id,
&pending.run_id,
)
.expect("read preserved pending identity"),
pending
);
fs::remove_dir(&generation_path).expect("remove invalid generation fixture");
remove_game_creator_agent_runtime_pending_tool_action(
root,
&pending.agent_id,
&pending.run_id,
)
.expect("retry cleanup after generation state is absent");
assert!(!game_creator_agent_runtime_pending_tool_action_exists(
root,
&pending.agent_id,
&pending.run_id
));
}
}
@@ -503,6 +503,7 @@ fn response_stream_finalization_commits_exactly_one_canonical_assistant() {
#[test]
fn non_stream_professional_final_reply_remains_queryable_after_later_project_revision() {
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
assert!(
!GameCreatorLlmConfig::default().stream,
"the production default exercises the non-stream final-reply path"
@@ -559,10 +560,10 @@ fn non_stream_professional_final_reply_remains_queryable_after_later_project_rev
&[],
)
.expect("finalize non-stream professional reply");
assert!(matches!(
completed,
AgentBackgroundFinalizationOutcome::Completed(_)
));
assert!(
matches!(completed, AgentBackgroundFinalizationOutcome::Completed(_)),
"unexpected finalization outcome: {completed:?}"
);
let mut later_revision = read_game_creator_agent_runtime_project_revision(root)
.expect("read project revision before later stage mutation");
@@ -332,28 +332,51 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
session_id: &str,
fallback: AgentRuntimeState,
) -> AgentBackgroundTaskOutcome {
let runtime = latest_game_chat_deadline_runtime_at(root, fallback);
let mut runtime = latest_game_chat_deadline_runtime_at(root, fallback);
let pending_action = read_game_creator_agent_runtime_pending_tool_action(
root,
&runtime.agent_id,
&runtime.run_id,
)
.ok();
let error = format!(
"{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: root Run 自 bound_at 起已达到 {} 秒绝对硬截止;在途动作已取消并进入失败收尾",
GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS
);
// The generic background failure helper deliberately preserves a durable
// needs-reconciliation state. A hard deadline is different: no action may
// remain recoverable after the root budget expires. Persist the terminal
// failure first, explicitly bypassing that guard, and only then remove the
// recovery material.
let terminal_failure_error = fail_game_creator_agent_runtime_turn_at(
root,
runtime.clone(),
&redact_agent_runtime_error(root, &error, 500),
)
.err();
let preserves_external_reconciliation = pending_action.as_ref().is_some_and(|pending| {
pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING
&& pending.action.tool == "canvas.asset_generate"
});
let external_generation_record_preserved = preserves_external_reconciliation
&& game_creator_agent_runtime_external_generation_exists(
root,
&runtime.agent_id,
&runtime.run_id,
);
let error = if preserves_external_reconciliation {
format!(
"{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: root Run 自 bound_at 起已达到 {} 秒绝对硬截止;外部生成结果未知,已结束本轮并保留人工对账证据",
GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS
)
} else {
format!(
"{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: root Run 自 bound_at 起已达到 {} 秒绝对硬截止;在途动作已取消并进入失败收尾",
GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS
)
};
let terminal_failure_error = if let Some(pending) = pending_action
.as_ref()
.filter(|_| preserves_external_reconciliation)
{
mark_game_creator_agent_runtime_needs_reconciliation_at(root, &mut runtime, pending, &error)
.err()
} else {
// Local and read-only work remains safe to cancel at the product hard
// deadline. Unknown external generation side effects are handled above
// and must retain their durable pending action instead.
fail_game_creator_agent_runtime_turn_at(
root,
runtime.clone(),
&redact_agent_runtime_error(root, &error, 500),
)
.err()
};
let terminal_runtime = latest_game_chat_deadline_runtime_at(root, runtime);
let (_, preview_stopped) = game_creator_preview_registry().stop_for_project(Some(root));
let process_cleanup_error = terminate_process_sessions_for_run_at(
@@ -362,18 +385,7 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
&terminal_runtime.run_id,
)
.err();
let mut cleanup_errors = Vec::new();
for result in [
remove_game_creator_agent_runtime_pending_tool_action(
root,
&terminal_runtime.agent_id,
&terminal_runtime.run_id,
),
remove_game_creator_agent_runtime_provider_action_batch(
root,
&terminal_runtime.agent_id,
&terminal_runtime.run_id,
),
let mut cleanup_results = vec![
remove_game_creator_agent_runtime_confirmations(
root,
&terminal_runtime.agent_id,
@@ -384,7 +396,21 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
&terminal_runtime.agent_id,
&terminal_runtime.run_id,
),
] {
];
if !preserves_external_reconciliation {
cleanup_results.push(remove_game_creator_agent_runtime_pending_tool_action(
root,
&terminal_runtime.agent_id,
&terminal_runtime.run_id,
));
cleanup_results.push(remove_game_creator_agent_runtime_provider_action_batch(
root,
&terminal_runtime.agent_id,
&terminal_runtime.run_id,
));
}
let mut cleanup_errors = Vec::new();
for result in cleanup_results {
if let Err(error) = result {
cleanup_errors.push(sanitize_agent_runtime_text(&error, 160));
}
@@ -406,6 +432,8 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
"hardBudgetSeconds": GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS,
"pendingActionId": pending_action.as_ref().map(|pending| pending.action_id.as_str()),
"pendingTool": pending_action.as_ref().map(|pending| pending.action.tool.as_str()),
"reconciliationPreserved": preserves_external_reconciliation,
"externalGenerationRecordPreserved": external_generation_record_preserved,
"previewStopped": preview_stopped,
"cleanupErrorCount": cleanup_errors.len(),
}),
@@ -62,8 +62,8 @@ async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry()
assert_eq!(result.expect("in-flight action completes"), "completed");
}
#[test]
fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_cleanup() {
#[tokio::test]
async fn game_chat_absolute_deadline_preserves_external_generation_reconciliation() {
let root = std::env::temp_dir().join(format!(
"genarrative-game-chat-deadline-reconciliation-{}-{}",
std::process::id(),
@@ -74,11 +74,218 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea
));
init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试")
.expect("project init");
bind_game_creator_agent_runtime_run_profile_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"game-chat-deadline-reconciliation-root-run",
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("bind autonomous game-chat root profile");
bind_game_creator_agent_runtime_run_profile_at(
&root,
"art-director",
"game-chat-deadline-reconciliation-run",
"agent-ready-task-scheduler",
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
Some(&AgentRuntimeTaskLink {
parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()),
parent_run_id: Some("game-chat-deadline-reconciliation-root-run".to_string()),
delegation_id: None,
}),
)
.expect("bind autonomous game-chat art profile");
let mut runtime = start_game_creator_agent_runtime_task_at(
&root,
"art-director",
"执行可能悬挂的外部图片生成",
"game-chat-deadline-reconciliation-run",
"agent-ready-task-scheduler",
"正在执行外部图片生成",
vec!["执行外部图片生成".to_string()],
)
.expect("start runtime");
runtime.loop_iteration = 1;
let action = AgentRuntimeToolAction {
tool: "canvas.asset_generate".to_string(),
reason: Some("模拟截止时仍在途的外部生成".to_string()),
input: serde_json::json!({
"prompt": "生成首版统一视觉规范图",
"outputPath": "assets/art-spec.png"
}),
};
let queued_action = AgentRuntimeToolAction {
tool: "canvas.asset_generate".to_string(),
reason: Some("验证同批次后续外部生成不会在恢复时重放".to_string()),
input: serde_json::json!({
"prompt": "生成首版角色立绘",
"outputPath": "assets/hero.png"
}),
};
let plan = AgentRuntimeToolPlan {
thinking_summary: "准备外部图片生成".to_string(),
plan_update: None,
plan: vec![
"生成首版统一视觉规范图".to_string(),
"生成首版角色立绘".to_string(),
],
actions: vec![action.clone(), queued_action],
response: String::new(),
};
let project_revision =
read_game_creator_agent_runtime_project_revision(&root).expect("read project revision");
let repository_fingerprint = build_repository_startup_context_at(&root)
.expect("repository context")
.fingerprint;
let prepared_batch = prepare_game_creator_agent_runtime_provider_action_batch(
&root,
&runtime,
&runtime.current_task,
&plan,
&[],
&project_revision,
&repository_fingerprint,
)
.await
.expect("prepare durable provider action batch");
let batch = match prepared_batch {
AgentRuntimeProviderActionBatchPreparation::Ready(batch) => batch,
other => panic!("expected ready provider action batch, got {other:?}"),
};
assert_eq!(batch.actions.len(), 2);
let mut pending = batch.actions[0].clone();
pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string();
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
.expect("write pending action");
write_platform_art_generation_runtime_accepted_for_test(&root, &pending)
.expect("write accepted External Editor generation ledger");
update_game_creator_agent_runtime_provider_batch_member(&root, &pending)
.expect("persist executing provider batch member");
let executing_batch = read_game_creator_agent_runtime_provider_action_batch(
&root,
&runtime.agent_id,
&runtime.run_id,
)
.expect("read executing provider action batch");
assert_eq!(
executing_batch.actions[0].status,
AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING
);
assert_eq!(
executing_batch.actions[1].status,
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED
);
assert!(game_creator_agent_runtime_external_generation_exists(
&root,
&runtime.agent_id,
&runtime.run_id
));
runtime.pending_tool_action = Some(pending.summary());
runtime.status = "running".to_string();
runtime.phase = "action".to_string();
runtime.current_action = "调用工具 canvas.asset_generate".to_string();
runtime.waiting_on.clear();
runtime.next_step = "等待外部生成结果".to_string();
runtime.error = None;
append_game_creator_agent_runtime_task(&root, &runtime).expect("append reconciliation task");
write_game_creator_agent_runtime_state(&root, &runtime).expect("write reconciliation state");
let outcome = finish_game_chat_absolute_deadline_timeout_at(
&root,
&runtime.agent_id,
&runtime.session_id,
runtime.clone(),
);
assert!(matches!(outcome, AgentBackgroundTaskOutcome::Finished));
let terminal = read_game_creator_agent_runtime_at(&root, &runtime.agent_id)
.expect("read terminal runtime")
.state;
assert_eq!(terminal.run_id, runtime.run_id);
assert_eq!(terminal.status, "failed");
assert_eq!(terminal.phase, "needs-reconciliation");
assert!(terminal.pending_tool_action.is_some());
assert!(game_creator_agent_runtime_pending_tool_action_exists(
&root,
&runtime.agent_id,
&runtime.run_id
));
let durable_pending = read_game_creator_agent_runtime_pending_tool_action(
&root,
&runtime.agent_id,
&runtime.run_id,
)
.expect("preserved external generation pending action");
assert_eq!(
durable_pending.status,
AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING
);
let preserved_batch = read_game_creator_agent_runtime_provider_action_batch(
&root,
&runtime.agent_id,
&runtime.run_id,
)
.expect("read preserved provider action batch");
assert_eq!(preserved_batch, executing_batch);
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"reconciliationPreserved\":true"));
assert!(agent_db.contains("\"externalGenerationRecordPreserved\":true"));
assert!(agent_db.contains("agent.runtime.tool_action.needs_reconciliation"));
assert!(!agent_db.contains("test-operation-id"));
let resumed = resume_game_creator_agent_background_tasks_at(&root)
.expect("scan durable runtime state after simulated runner restart");
assert!(resumed.iter().any(|result| {
result.state.agent_id == runtime.agent_id
&& result.state.run_id == runtime.run_id
&& result.state.phase == "needs-reconciliation"
}));
let recovered_pending = read_game_creator_agent_runtime_pending_tool_action(
&root,
&runtime.agent_id,
&runtime.run_id,
)
.expect("read pending action after recovery scan");
assert_eq!(recovered_pending, durable_pending);
let recovered_batch = read_game_creator_agent_runtime_provider_action_batch(
&root,
&runtime.agent_id,
&runtime.run_id,
)
.expect("read provider action batch after recovery scan");
assert_eq!(recovered_batch, preserved_batch);
assert!(game_creator_agent_runtime_external_generation_exists(
&root,
&runtime.agent_id,
&runtime.run_id
));
assert_eq!(
fs::read_to_string(root.join(".agent/agent.db")).expect("agent db after recovery scan"),
agent_db,
"needs-reconciliation recovery barrier must not append a replay receipt"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn game_chat_absolute_deadline_still_cleans_local_action_recovery() {
let root = std::env::temp_dir().join(format!(
"genarrative-game-chat-deadline-local-cleanup-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock")
.as_nanos()
));
init_local_game_project_at(&root, "deadline-local-cleanup", "硬截止本地清理测试")
.expect("project init");
let mut runtime = start_game_creator_agent_runtime_task_at(
&root,
"code-prototype",
"执行可能悬挂的首版写入",
"game-chat-deadline-reconciliation-run",
"game-chat-deadline-local-cleanup-run",
"agent-ready-task-scheduler",
"正在执行首版写入",
vec!["执行首版写入".to_string()],
@@ -87,7 +294,7 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea
runtime.loop_iteration = 1;
let action = AgentRuntimeToolAction {
tool: "file.write".to_string(),
reason: Some("模拟截止时仍在途的写入".to_string()),
reason: Some("模拟截止时仍在途的本地写入".to_string()),
input: serde_json::json!({
"path": "game/index.html",
"content": "<!doctype html><title>deadline</title>"
@@ -105,7 +312,7 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea
let repository_fingerprint = build_repository_startup_context_at(&root)
.expect("repository context")
.fingerprint;
let pending = build_game_creator_agent_runtime_pending_tool_action(
let mut pending = build_game_creator_agent_runtime_pending_tool_action(
&root,
&runtime,
&runtime.current_task,
@@ -120,17 +327,12 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea
None,
)
.expect("build pending action");
pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string();
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
.expect("write pending action");
runtime.pending_tool_action = Some(pending.summary());
runtime.status = "failed".to_string();
runtime.phase = "needs-reconciliation".to_string();
runtime.current_action = "等待人工核对在途动作".to_string();
runtime.waiting_on = "开发者核对副作用".to_string();
runtime.next_step = "核对后恢复".to_string();
runtime.error = Some("模拟 needs-reconciliation".to_string());
append_game_creator_agent_runtime_task(&root, &runtime).expect("append reconciliation task");
write_game_creator_agent_runtime_state(&root, &runtime).expect("write reconciliation state");
append_game_creator_agent_runtime_task(&root, &runtime).expect("append runtime task");
write_game_creator_agent_runtime_state(&root, &runtime).expect("write runtime state");
let outcome = finish_game_chat_absolute_deadline_timeout_at(
&root,
@@ -143,7 +345,6 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea
let terminal = read_game_creator_agent_runtime_at(&root, &runtime.agent_id)
.expect("read terminal runtime")
.state;
assert_eq!(terminal.run_id, runtime.run_id);
assert_eq!(terminal.status, "failed");
assert_eq!(terminal.phase, "failed");
assert!(terminal.pending_tool_action.is_none());
@@ -152,11 +353,8 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea
&runtime.agent_id,
&runtime.run_id
));
assert!(!game_creator_agent_runtime_provider_action_batch_exists(
&root,
&runtime.agent_id,
&runtime.run_id
));
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"reconciliationPreserved\":false"));
fs::remove_dir_all(root).ok();
}
@@ -1,5 +1,18 @@
use super::*;
async fn run_join_owned_pending_task<T>(
future: std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'static>>,
) -> Result<T, tokio::task::JoinError>
where
T: Send + 'static,
{
let mut tasks = tokio::task::JoinSet::new();
tasks.spawn(future);
tasks
.join_next()
.await
.expect("pending continuation task must exist")
}
async fn run_after_pending_stack_boundary<T>(
future: std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'static>>,
) -> T
@@ -10,39 +23,13 @@ where
// poll frames. The boxed future keeps that large frame out of its caller before a joined child
// task gives it an independent poll boundary. JoinSet still aborts the child if its parent
// continuation is dropped.
let mut tasks = tokio::task::JoinSet::new();
tasks.spawn(future);
match tasks
.join_next()
.await
.expect("pending continuation task must exist")
{
match run_join_owned_pending_task(future).await {
Ok(output) => output,
Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
Err(error) => panic!("pending continuation task was cancelled: {error}"),
}
}
async fn run_game_creator_agent_background_task_after_pending_stack_boundary(
root: PathBuf,
agent_id: String,
task: String,
runtime: AgentRuntimeState,
continuation: AgentRuntimeContinuationContext,
) -> AgentBackgroundTaskOutcome {
run_after_pending_stack_boundary(Box::pin(async move {
run_game_creator_agent_background_task_with_context(
root,
agent_id,
task,
runtime,
continuation,
)
.await
}))
.await
}
async fn drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
root: PathBuf,
agent_id: String,
@@ -53,6 +40,115 @@ async fn drain_next_game_creator_agent_background_tasks_after_pending_stack_boun
.await;
}
async fn run_recovered_game_creator_context_on_fresh_task(
root: PathBuf,
agent_id: String,
task: String,
runtime: AgentRuntimeState,
continuation: AgentRuntimeContinuationContext,
) -> Result<AgentBackgroundTaskOutcome, String> {
run_join_owned_pending_task(Box::pin(async move {
run_game_creator_agent_background_task_with_context(
root,
agent_id,
task,
runtime,
continuation,
)
.await
}))
.await
.map_err(|error| format!("恢复 Agent Runtime continuation 的独立任务异常结束:{error}"))
}
fn mark_game_creator_agent_runtime_continuation_needs_reconciliation_at(
root: &Path,
runtime: &mut AgentRuntimeState,
error: &str,
) -> Result<(), String> {
runtime.status = "failed".to_string();
runtime.phase = "needs-reconciliation".to_string();
runtime.current_action = "恢复后的 Agent continuation 需要人工核对".to_string();
runtime.waiting_on = "开发者核对已持久化工具观察与 Provider 状态".to_string();
runtime.next_step = "核对外部结果后显式取消或恢复当前 run".to_string();
runtime.pending_tool_action = None;
runtime.error = Some(redact_agent_runtime_error(root, error, 500));
runtime.updated_at = unix_timestamp();
append_game_creator_agent_runtime_task(root, runtime)?;
refresh_game_creator_agent_runtime_task_queue(root, runtime)?;
write_game_creator_agent_runtime_state(root, runtime)?;
append_game_creator_agent_runtime_event(
root,
runtime,
"runtime.continuation.needs_reconciliation",
"failed",
"needs-reconciliation",
"恢复后的 Runtime continuation 异常结束,已保留持久化证据并停止自动续跑。",
runtime.error.as_deref(),
)?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.continuation.needs_reconciliation",
"agentId": runtime.agent_id,
"taskId": runtime.task_id,
"sessionId": runtime.session_id,
"runId": runtime.run_id,
"source": runtime.source,
"error": runtime.error,
}),
)?;
emit_game_creator_agent_runtime_update(root, &runtime.agent_id);
Ok(())
}
fn persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at(
root: &Path,
runtime: &mut AgentRuntimeState,
join_error: &str,
persistence_error: &str,
) {
let error = redact_agent_runtime_error(
root,
&format!(
"恢复 continuation 异常结束,且正式 reconciliation 持久化不完整;joinError={join_error};persistenceError={persistence_error}"
),
500,
);
runtime.status = "failed".to_string();
runtime.phase = "needs-reconciliation".to_string();
runtime.current_action = "恢复后的 Agent continuation 需要人工核对".to_string();
runtime.waiting_on = "开发者核对 Runtime 持久化证据".to_string();
runtime.next_step = "修复持久化链后显式取消或恢复当前 run".to_string();
runtime.pending_tool_action = None;
runtime.error = Some(error.clone());
runtime.updated_at = unix_timestamp();
let _ = append_game_creator_agent_runtime_task(root, runtime);
let _ = refresh_game_creator_agent_runtime_task_queue(root, runtime);
let _ = write_game_creator_agent_runtime_state(root, runtime);
let _ = append_game_creator_agent_runtime_event(
root,
runtime,
"runtime.continuation.reconciliation_persistence_failed",
"failed",
"needs-reconciliation",
"Runtime continuation 异常后的正式对账记录未完整落盘,已尝试写入紧急阻断。",
Some(&error),
);
let _ = append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.continuation.reconciliation_persistence_failed",
"agentId": runtime.agent_id,
"taskId": runtime.task_id,
"sessionId": runtime.session_id,
"runId": runtime.run_id,
"source": runtime.source,
"error": error,
}),
);
emit_game_creator_agent_runtime_update(root, &runtime.agent_id);
}
pub(crate) async fn continue_game_creator_agent_pending_tool_action(
root: PathBuf,
agent_id: String,
@@ -129,14 +225,40 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary(
usize::try_from(batch.loop_iteration.saturating_sub(1)).unwrap_or(usize::MAX);
continuation.context_stalled = false;
continuation.applied_steer_cursor = batch.planned_steer_cursor;
let outcome = run_game_creator_agent_background_task_after_pending_stack_boundary(
let runtime_fallback = runtime.clone();
let outcome = match run_recovered_game_creator_context_on_fresh_task(
root.clone(),
agent_id.clone(),
pending.task.clone(),
runtime,
continuation,
)
.await;
.await
{
Ok(outcome) => outcome,
Err(error) => {
let mut failed_runtime = read_game_creator_agent_runtime_at(&root, &agent_id)
.ok()
.filter(|current| current.state.run_id == pending.run_id)
.map(|current| current.state)
.unwrap_or(runtime_fallback);
if let Err(persistence_error) =
mark_game_creator_agent_runtime_provider_batch_needs_reconciliation_at(
&root,
&mut failed_runtime,
&error,
)
{
persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at(
&root,
&mut failed_runtime,
&error,
&persistence_error,
);
}
return;
}
};
if matches!(outcome, AgentBackgroundTaskOutcome::Finished) {
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
root, agent_id,
@@ -276,7 +398,48 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary(
&root, &pending,
);
}
let observation =
let observation = if action.tool == "canvas.asset_generate"
&& game_creator_agent_runtime_external_generation_exists(
&root,
&pending.agent_id,
&pending.run_id,
) {
// Recovery already adds a deep pending/runtime continuation stack. Poll the
// durable external-generation execution in a fresh Tokio task so the normal
// 2 MiB worker stack is sufficient while this task keeps the Agent lock held.
let execution_root = root.clone();
let execution_agent_id = agent_id.clone();
let execution_pending = pending.clone();
let execution_action = action.clone();
match tauri::async_runtime::spawn(async move {
execute_game_creator_agent_runtime_tool_action_with_pending_action(
&execution_root,
&execution_agent_id,
&execution_pending.run_id,
&execution_pending.task,
&execution_action,
Some(&execution_pending.action_id),
Some(&execution_pending),
)
.await
})
.await
{
Ok(observation) => observation,
Err(error) => AgentRuntimeToolObservation {
tool: action.tool.clone(),
status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
.to_string(),
summary: "External Editor 恢复执行任务异常结束,结果需要人工核对"
.to_string(),
detail: Some(redact_agent_runtime_error(
&root,
&error.to_string(),
500,
)),
},
}
} else {
execute_game_creator_agent_runtime_tool_action_with_pending_action(
&root,
&agent_id,
@@ -286,7 +449,8 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary(
Some(&pending.action_id),
Some(&pending),
)
.await;
.await
};
if observation.is_waiting_for_confirmation() && auto_execution {
pending.execution_mode =
AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string();
@@ -870,14 +1034,40 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary(
return;
}
}
let outcome = run_game_creator_agent_background_task_after_pending_stack_boundary(
let runtime_fallback = runtime.clone();
let outcome = match run_recovered_game_creator_context_on_fresh_task(
root.clone(),
agent_id.clone(),
pending.task.clone(),
runtime,
continuation,
)
.await;
.await
{
Ok(outcome) => outcome,
Err(error) => {
let mut failed_runtime = read_game_creator_agent_runtime_at(&root, &agent_id)
.ok()
.filter(|current| current.state.run_id == pending.run_id)
.map(|current| current.state)
.unwrap_or(runtime_fallback);
if let Err(persistence_error) =
mark_game_creator_agent_runtime_continuation_needs_reconciliation_at(
&root,
&mut failed_runtime,
&error,
)
{
persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at(
&root,
&mut failed_runtime,
&error,
&persistence_error,
);
}
return;
}
};
if matches!(outcome, AgentBackgroundTaskOutcome::Finished) {
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id)
.await;
@@ -999,6 +1189,12 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_batch_needs_rec
runtime: &mut AgentRuntimeState,
error: &str,
) -> Result<(), String> {
let batch = read_game_creator_agent_runtime_provider_action_batch(
root,
&runtime.agent_id,
&runtime.run_id,
)
.ok();
runtime.status = "failed".to_string();
runtime.phase = "needs-reconciliation".to_string();
runtime.current_action = "Provider action 批次需要人工核对".to_string();
@@ -1027,9 +1223,137 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_batch_needs_rec
"taskId": runtime.task_id,
"sessionId": runtime.session_id,
"runId": runtime.run_id,
"batchId": batch.as_ref().map(|batch| batch.batch_id.as_str()),
"nextActionIndex": batch.as_ref().map(|batch| batch.next_action_index),
"actionCount": batch.as_ref().map(|batch| batch.actions.len()),
"error": runtime.error,
}),
)?;
emit_game_creator_agent_runtime_update(root, &runtime.agent_id);
Ok(())
}
#[cfg(test)]
mod continuation_join_error_tests {
use super::*;
struct PendingChildDropSignal(Option<tokio::sync::oneshot::Sender<()>>);
impl Drop for PendingChildDropSignal {
fn drop(&mut self) {
if let Some(sender) = self.0.take() {
let _ = sender.send(());
}
}
}
fn started_runtime(root: &Path, run_id: &str) -> AgentRuntimeState {
init_local_game_project_at(root, "continuation-join-error", "continuation 对账测试")
.expect("init project");
start_game_creator_agent_runtime_task_at(
root,
"code-prototype",
"恢复已持久化工具观察",
run_id,
"agent-ready-task-scheduler",
"恢复 continuation",
vec!["恢复 continuation".to_string()],
)
.expect("start runtime")
}
#[test]
fn continuation_join_error_persists_full_redacted_reconciliation_projection() {
let temporary = tempfile::tempdir().expect("create continuation reconciliation project");
let root = temporary.path();
let mut runtime = started_runtime(root, "continuation-reconciliation-run");
let sensitive_error = format!(
"panic at {}/private.rs with api key sk-test-secret-value",
root.display()
);
mark_game_creator_agent_runtime_continuation_needs_reconciliation_at(
root,
&mut runtime,
&sensitive_error,
)
.expect("persist continuation reconciliation");
let current = read_game_creator_agent_runtime_at(root, &runtime.agent_id)
.expect("read reconciled runtime")
.state;
assert_eq!(current.phase, "needs-reconciliation");
assert!(current.task_queue.failed >= 1);
let persisted = fs::read_to_string(root.join(".agent/agent.db")).expect("read Agent DB");
assert!(persisted.contains("agent.runtime.continuation.needs_reconciliation"));
assert!(!persisted.contains(&root.display().to_string()));
assert!(!persisted.contains("sk-test-secret-value"));
let events = fs::read_to_string(game_creator_agent_runtime_event_path(
root,
&runtime.agent_id,
))
.expect("read runtime events");
assert!(events.contains("runtime.continuation.needs_reconciliation"));
}
#[test]
fn continuation_join_error_uses_emergency_audit_when_formal_audit_fails() {
let temporary = tempfile::tempdir().expect("create continuation emergency project");
let root = temporary.path();
let mut runtime = started_runtime(root, "continuation-emergency-run");
fs::create_dir_all(root.join(".agent/runtime")).expect("create runtime directory");
fs::write(
root.join(".agent/runtime/test-fail-next-agent-db-record"),
"agent.runtime.continuation.needs_reconciliation",
)
.expect("inject formal audit failure");
let join_error = "panic at /private/path with sk-emergency-secret";
let persistence_error =
mark_game_creator_agent_runtime_continuation_needs_reconciliation_at(
root,
&mut runtime,
join_error,
)
.expect_err("formal reconciliation audit must fail once");
persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at(
root,
&mut runtime,
join_error,
&persistence_error,
);
let current = read_game_creator_agent_runtime_at(root, &runtime.agent_id)
.expect("read emergency reconciled runtime")
.state;
assert_eq!(current.phase, "needs-reconciliation");
let persisted = fs::read_to_string(root.join(".agent/agent.db")).expect("read Agent DB");
assert!(persisted.contains("agent.runtime.continuation.reconciliation_persistence_failed"));
assert!(!persisted.contains("/private/path"));
assert!(!persisted.contains("sk-emergency-secret"));
}
#[tokio::test]
async fn pending_child_task_is_aborted_when_its_parent_is_cancelled() {
let (started_sender, started_receiver) = tokio::sync::oneshot::channel();
let (dropped_sender, dropped_receiver) = tokio::sync::oneshot::channel();
let parent = tokio::spawn(async move {
run_join_owned_pending_task(Box::pin(async move {
let _drop_signal = PendingChildDropSignal(Some(dropped_sender));
let _ = started_sender.send(());
std::future::pending::<()>().await;
}))
.await
});
started_receiver
.await
.expect("pending child must start before parent cancellation");
parent.abort();
let _ = parent.await;
tokio::time::timeout(Duration::from_secs(1), dropped_receiver)
.await
.expect("owned pending child must be aborted with its parent")
.expect("pending child drop signal must be delivered");
}
}
@@ -769,6 +769,51 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at(
}
}
}
if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING
&& pending.action.tool == "canvas.asset_generate"
{
match platform_art_generation_runtime_recovery_at(root, &pending) {
Ok(
PlatformArtGenerationRuntimeRecovery::ResumeAccepted
| PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted,
) => {
pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string();
pending.observation = None;
pending.updated_at = unix_timestamp();
write_game_creator_agent_runtime_pending_tool_action(root, &pending)?;
}
Ok(PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown) => {
mark_game_creator_agent_runtime_needs_reconciliation_at(
root,
&mut runtime,
&pending,
"External Editor 生成账本停在 prepared,POST 是否受理未知;Runtime 禁止自动重放",
)?;
return read_game_creator_agent_runtime_at(root, agent_id)
.map(AgentRuntimePendingActionResume::Handled);
}
Ok(PlatformArtGenerationRuntimeRecovery::Missing) => {
mark_game_creator_agent_runtime_needs_reconciliation_at(
root,
&mut runtime,
&pending,
"canvas.asset_generate 已进入 executing 但缺少 durable External Editor 生成账本",
)?;
return read_game_creator_agent_runtime_at(root, agent_id)
.map(AgentRuntimePendingActionResume::Handled);
}
Err(error) => {
mark_game_creator_agent_runtime_needs_reconciliation_at(
root,
&mut runtime,
&pending,
&format!("External Editor 生成账本无法通过恢复校验:{error}"),
)?;
return read_game_creator_agent_runtime_at(root, agent_id)
.map(AgentRuntimePendingActionResume::Handled);
}
}
}
if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING {
let recovered_mcp_observation =
match recover_game_creator_mcp_observation_from_sidecar_at(root, &pending) {
@@ -434,6 +434,7 @@ pub(crate) fn has_recoverable_game_creator_agent_background_tasks_at(
".agent/runtime/pending-actions",
".agent/runtime/parallel-read-batches",
".agent/runtime/provider-action-batches",
".agent/runtime/canvas-generation-requests",
".agent/runtime/cancel",
] {
if durable_agent_runtime_recovery_directory_has_entries(&root.join(relative_directory)) {
@@ -479,6 +480,143 @@ fn durable_agent_runtime_recovery_directory_has_entries(directory: &Path) -> boo
false
}
fn cleanup_orphaned_platform_art_generation_runtime_states_at(
root: &Path,
) -> Result<usize, String> {
let directory = resolve_local_project_path(root, ".agent/runtime/canvas-generation-requests")?;
let agent_entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(error) => {
return Err(format!(
"读取 External Editor 生成账本目录失败:{}: {error}",
directory.display()
));
}
};
let mut identities = std::collections::BTreeSet::<(String, String)>::new();
for agent_entry in agent_entries {
let agent_entry = agent_entry.map_err(|error| {
format!(
"遍历 External Editor 生成账本 Agent 目录失败:{}: {error}",
directory.display()
)
})?;
let agent_metadata = fs::symlink_metadata(agent_entry.path()).map_err(|error| {
format!(
"读取 External Editor 生成账本 Agent 目录元数据失败:{}: {error}",
agent_entry.path().display()
)
})?;
if agent_metadata.file_type().is_symlink() || !agent_metadata.is_dir() {
return Err("External Editor 生成账本 Agent 路径必须是普通目录".to_string());
}
let entries = fs::read_dir(agent_entry.path()).map_err(|error| {
format!(
"读取 External Editor 生成账本 Agent 目录失败:{}: {error}",
agent_entry.path().display()
)
})?;
for entry in entries {
let entry = entry.map_err(|error| {
format!(
"遍历 External Editor 生成账本失败:{}: {error}",
agent_entry.path().display()
)
})?;
let metadata = fs::symlink_metadata(entry.path()).map_err(|error| {
format!(
"读取 External Editor 生成账本元数据失败:{}: {error}",
entry.path().display()
)
})?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("External Editor 生成账本必须是普通文件".to_string());
}
if metadata.len() > 256 * 1024 {
return Err("External Editor 生成账本超过 262144 字节上限".to_string());
}
let file_name = entry
.file_name()
.to_str()
.map(str::to_string)
.ok_or_else(|| "External Editor 生成账本文件名不是 UTF-8".to_string())?;
if !file_name.ends_with(".json") && !file_name.ends_with(".json.previous") {
return Err(format!("External Editor 生成账本文件名无效:{file_name}"));
}
let payload = fs::read(&entry.path()).map_err(|error| {
format!(
"读取 External Editor 生成账本失败:{}: {error}",
entry.path().display()
)
})?;
let payload =
serde_json::from_slice::<serde_json::Value>(&payload).map_err(|error| {
format!(
"解析 External Editor 生成账本失败:{}: {error}",
entry.path().display()
)
})?;
let schema_version = payload
.get("schemaVersion")
.and_then(serde_json::Value::as_str)
.unwrap_or("(missing)");
if schema_version != PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION {
return Err(format!(
"External Editor 生成账本版本无效:{file_name}: {schema_version}"
));
}
let agent_id = payload
.get("agentId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "External Editor 生成账本缺少 agentId".to_string())?;
let run_id = payload
.get("runId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "External Editor 生成账本缺少 runId".to_string())?;
let expected_agent_component =
agent_runtime_confirmation_path_component(agent_id, "agent");
let expected_file_name = format!(
"{}.json",
agent_runtime_confirmation_path_component(run_id, "run")
);
let expected_backup_name = format!(".{expected_file_name}.previous");
if agent_entry.file_name().to_str() != Some(expected_agent_component.as_str())
|| (file_name != expected_file_name && file_name != expected_backup_name)
{
return Err("External Editor 生成账本路径与内部身份不一致".to_string());
}
identities.insert((agent_id.to_string(), run_id.to_string()));
}
}
let mut removed = 0_usize;
for (agent_id, run_id) in identities {
if game_creator_agent_runtime_pending_tool_action_exists(root, &agent_id, &run_id) {
continue;
}
let task = read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, &run_id)?
.ok_or_else(|| {
format!(
"External Editor 孤儿生成账本缺少所属任务,已保留供人工核对:agent={agent_id} runId={run_id}"
)
})?;
if !matches!(task.status.as_str(), "completed" | "cancelled")
|| task.phase == "needs-reconciliation"
{
return Err(format!(
"External Editor 孤儿生成账本所属任务未安全终结,已保留供人工核对:agent={agent_id} runId={run_id} status={} phase={}",
task.status, task.phase
));
}
remove_platform_art_generation_runtime_state_at(root, &agent_id, &run_id)?;
removed = removed.saturating_add(1);
}
Ok(removed)
}
fn durable_process_session_recovery_exists_at(root: &Path) -> bool {
let directory = root.join(".agent/runtime/process-sessions");
let entries = match fs::read_dir(&directory) {
@@ -532,6 +670,7 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at
resume_external_agent_runner(root)?;
return read_game_creator_agent_runtimes_at(root);
}
cleanup_orphaned_platform_art_generation_runtime_states_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)?;
@@ -1075,3 +1214,132 @@ pub(crate) fn resume_game_creator_agent_pending_action_for_agent_at(
}
}
}
#[cfg(test)]
mod orphaned_external_generation_recovery_tests {
use super::*;
#[test]
fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() {
let temporary = tempfile::tempdir().expect("create orphan generation recovery project");
let root = temporary.path();
let run_id = "orphan-generation-recovery-run";
init_local_game_project_at(root, "orphan-generation-recovery", "孤儿生成账本恢复测试")
.expect("init project");
let mut runtime = start_game_creator_agent_runtime_task_at(
root,
"art-director",
"生成视觉规范图",
run_id,
"agent-ready-task-scheduler",
"准备生成视觉规范图",
vec!["生成视觉规范图".to_string()],
)
.expect("start runtime");
runtime.loop_iteration = 1;
let action = AgentRuntimeToolAction {
tool: "canvas.asset_generate".to_string(),
reason: Some("生成统一视觉规范".to_string()),
input: serde_json::json!({
"prompt": "生成统一视觉规范图",
"outputPath": "assets/art-spec.png"
}),
};
let plan = AgentRuntimeToolPlan {
thinking_summary: "准备生成".to_string(),
plan_update: None,
plan: vec!["生成视觉规范图".to_string()],
actions: vec![action.clone()],
response: String::new(),
};
let revision =
read_game_creator_agent_runtime_project_revision(root).expect("read project revision");
let repository_fingerprint = build_repository_startup_context_at(root)
.expect("repository context")
.fingerprint;
let pending = build_game_creator_agent_runtime_pending_tool_action(
root,
&runtime,
&runtime.current_task,
&plan,
&[],
&revision,
&repository_fingerprint,
&action,
0,
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING,
None,
)
.expect("build pending identity without writing its sidecar");
write_platform_art_generation_runtime_accepted_for_test(root, &pending)
.expect("write legacy orphan generation state");
let active_error = resume_game_creator_agent_background_tasks_at(root)
.expect_err("active generation orphan must remain fail closed");
assert!(active_error.contains("未安全终结"));
assert!(game_creator_agent_runtime_external_generation_exists(
root,
&pending.agent_id,
&pending.run_id
));
runtime.status = "completed".to_string();
runtime.phase = "completed".to_string();
runtime.current_action = "测试任务已完成".to_string();
runtime.waiting_on.clear();
runtime.next_step.clear();
runtime.pending_tool_action = None;
runtime.updated_at = unix_timestamp();
append_game_creator_agent_runtime_task(root, &runtime).expect("append terminal task");
write_game_creator_agent_runtime_state(root, &runtime).expect("write terminal state");
assert!(game_creator_agent_runtime_external_generation_exists(
root,
&pending.agent_id,
&pending.run_id
));
assert!(has_recoverable_game_creator_agent_background_tasks_at(root)
.expect("orphan initially looks recoverable"));
let resumed = resume_game_creator_agent_background_tasks_at(root)
.expect("recovery scan cleans generation orphan");
assert!(resumed.is_empty());
assert!(!game_creator_agent_runtime_external_generation_exists(
root,
&pending.agent_id,
&pending.run_id
));
assert!(
!has_recoverable_game_creator_agent_background_tasks_at(root)
.expect("cleaned orphan must not trigger permanent recovery")
);
}
#[cfg(unix)]
#[test]
fn orphan_scan_rejects_symlinked_generation_ledger_root() {
use std::os::unix::fs::symlink;
let project = tempfile::tempdir().expect("create orphan symlink project");
let root = project.path();
init_local_game_project_at(root, "orphan-symlink", "孤儿生成账本符号链接测试")
.expect("init project");
let outside = tempfile::tempdir().expect("create outside orphan directory");
let sentinel = outside.path().join("sentinel.json");
fs::write(&sentinel, b"outside-sentinel").expect("write outside sentinel");
let runtime_directory = root.join(".agent/runtime");
fs::create_dir_all(&runtime_directory).expect("create runtime directory");
let linked_directory = runtime_directory.join("canvas-generation-requests");
if linked_directory.exists() {
fs::remove_dir_all(&linked_directory).expect("remove existing ledger directory");
}
symlink(outside.path(), &linked_directory).expect("link outside orphan directory");
let error = cleanup_orphaned_platform_art_generation_runtime_states_at(root)
.expect_err("orphan scan must reject symlinked ledger root");
assert!(error.contains("符号链接"), "{error}");
assert_eq!(
fs::read(&sentinel).expect("outside sentinel remains"),
b"outside-sentinel"
);
}
}
@@ -1549,6 +1549,7 @@ fn gui_ready_child_still_rejects_pending_manifest_status() {
#[test]
fn autonomous_ready_child_missing_or_invalid_owner_artifact_is_blocked() {
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
let (_temporary, root, parent_state, _contract) =
autonomous_fixture("做一个完整小游戏", "autonomous-ready-child-artifact-parent");
update_manifest_task_status_at(&root, "balance-seed", GameCreationAppTaskStatus::Running)
@@ -1613,10 +1614,13 @@ fn autonomous_ready_child_missing_or_invalid_owner_artifact_is_blocked() {
let code_state = agent_runtime_state_from_task_record(&code_record);
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state)
.expect("initial code placeholder must block child completion");
assert!(blocker
.detail
.as_deref()
.is_some_and(|detail| detail.contains("game/index.html(initial-placeholder)")));
assert!(
blocker
.detail
.as_deref()
.is_some_and(|detail| detail.contains("game/index.html(initial-placeholder)")),
"unexpected blocker: {blocker:?}"
);
}
#[test]
@@ -2222,6 +2226,7 @@ fn superseded_or_cancelled_autonomous_root_cannot_project_or_schedule() {
#[test]
fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest() {
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
let (_temporary, root, mut state, contract) = autonomous_fixture(
"做一个塔防游戏,选择植物阻挡敌人并正常闯关",
"autonomous-completion-evidence-run",
@@ -2243,7 +2248,10 @@ fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest(
mark_verification_passed(&root, &state, "project.verify");
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state)
.expect("project.verify cannot replace static smoke");
assert!(blocker.summary.contains("game.static_smoke"));
assert!(
blocker.summary.contains("game.static_smoke"),
"unexpected blocker: {blocker:?}"
);
mark_verification_passed(&root, &state, "game.static_smoke");
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state)
@@ -492,6 +492,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
run_id: &str,
task: &str,
input: &serde_json::Value,
pending_action: Option<&AgentRuntimePendingToolAction>,
) -> AgentRuntimeToolObservation {
let prompt = agent_runtime_tool_input_text(input, &["prompt", "assetPrompt", "description"]);
let prompt = if prompt.trim().is_empty() {
@@ -670,7 +671,30 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
detail: None,
};
}
if !options.replace_existing {
let resumes_durable_generation = match pending_action {
Some(pending) => match platform_art_generation_runtime_recovery_at(root, pending) {
Ok(PlatformArtGenerationRuntimeRecovery::Missing) => false,
Ok(
PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown
| PlatformArtGenerationRuntimeRecovery::ResumeAccepted
| PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted,
) => true,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(),
summary: redact_agent_runtime_project_paths(
root,
&format!("External Editor 生成账本无法通过恢复预检:{error}"),
240,
),
detail: None,
};
}
},
None => false,
};
if !options.replace_existing && !resumes_durable_generation {
if let Err(error) =
prepare_platform_art_asset_output_path(root, options.output_path.as_deref())
{
@@ -687,11 +711,13 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
{
return blocker;
}
let prepared = match request_platform_art_asset_with_options_at(
let runtime_context = pending_action.map(platform_art_generation_runtime_context_from_pending);
let prepared = match request_platform_art_asset_with_runtime_options_at(
root,
prompt.trim(),
&[],
&options,
runtime_context.as_ref(),
)
.await
{
@@ -699,7 +725,8 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
Err(error) => {
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(),
status: platform_art_generation_observation_status(root, agent_id, run_id, &error)
.to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
@@ -808,34 +835,56 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
.as_deref()
.map(|reason| format!(";透明图集可用,但自动切片未完成:{reason}"))
.unwrap_or_default();
let warning_summary = generated
.warning
.as_deref()
.map(|reason| format!(";平台非阻断告警:{reason}"))
.unwrap_or_default();
AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: "ok".to_string(),
summary: format!(
"已生成美术素材:{}{slice_warning_summary}",
"已生成美术素材:{}{warning_summary}{slice_warning_summary}",
generated.asset.local_path
),
detail: Some(format!(
"assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}, sliceWarning={}, verifiedRevision={mutation_revision}",
"assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}, warning={}, sliceWarning={}, verifiedRevision={mutation_revision}",
generated.asset.id,
generated.asset.local_path,
generated.resource_id.as_deref().unwrap_or(""),
generated.asset_object_id.as_deref().unwrap_or(""),
generated.task_id.as_deref().unwrap_or(""),
generated.model.as_deref().unwrap_or(""),
generated.warning.as_deref().unwrap_or(""),
generated.slice_warning.as_deref().unwrap_or("")
)),
}
}
Err(error) => AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(),
status: platform_art_generation_observation_status(root, agent_id, run_id, &error)
.to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
},
}
}
fn platform_art_generation_observation_status(
root: &Path,
agent_id: &str,
run_id: &str,
error: &str,
) -> &'static str {
if platform_art_generation_error_needs_reconciliation(error)
|| game_creator_agent_runtime_external_generation_exists(root, agent_id, run_id)
{
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
} else {
"failed"
}
}
#[cfg(test)]
pub(crate) async fn observe_agent_runtime_platform_art_asset_generation_after_dispatch_for_test(
root: &Path,
@@ -844,5 +893,43 @@ pub(crate) async fn observe_agent_runtime_platform_art_asset_generation_after_di
task: &str,
input: &serde_json::Value,
) -> AgentRuntimeToolObservation {
observe_agent_runtime_platform_art_asset_generation(root, agent_id, run_id, task, input).await
observe_agent_runtime_platform_art_asset_generation(root, agent_id, run_id, task, input, None)
.await
}
#[cfg(test)]
mod platform_art_generation_observation_tests {
use super::*;
#[test]
fn unknown_external_generation_result_requires_runtime_reconciliation() {
let root = tempfile::tempdir().expect("create observation status root");
assert_eq!(
platform_art_generation_observation_status(
root.path(),
"art-director",
"run-unknown",
"platform-generation-result-unknown: 平台已受理但响应丢失"
),
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
);
assert_eq!(
platform_art_generation_observation_status(
root.path(),
"art-asset-plan",
"run-source-preserved",
"platform-generation-source-preserved-no-retry: provider 源图已保留"
),
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
);
assert_eq!(
platform_art_generation_observation_status(
root.path(),
"art-director",
"run-failed",
"平台明确返回生成失败"
),
"failed"
);
}
}
@@ -141,12 +141,81 @@ fn root_run_source_is_game_chat(root: &Path, agent_id: &str, run_id: &str) -> Re
mod tests {
use super::*;
fn register_task_list_visual_fixture(
root: &Path,
local_path: &str,
kind: &str,
generation_kind: &str,
alpha: u8,
reference_resource_ids: Vec<String>,
) {
image::RgbaImage::from_pixel(4, 4, image::Rgba([80, 140, 220, alpha]))
.save(root.join(local_path))
.expect("write task list visual fixture");
register_local_asset_at(
root,
local_path,
kind,
"image/png",
"canvas",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("fixture-canvas".to_string()),
resource_id: Some(format!("fixture-{kind}-resource")),
asset_object_id: Some(format!("fixture-{kind}-object")),
task_id: Some(format!("fixture-{kind}-task")),
prompt: None,
model: None,
generation_route: Some(
if kind == "art-spritesheet" {
"/api/external/v1/editor/icon-spritesheets/generations"
} else {
"/api/external/v1/editor/images/generations"
}
.to_string(),
),
generation_kind: Some(generation_kind.to_string()),
reference_resource_ids,
},
)
.expect("register task list visual fixture");
}
fn register_task_list_visual_fixtures(root: &Path) {
let art_spec_resource_id = "fixture-icon-spec-resource".to_string();
register_task_list_visual_fixture(
root,
"assets/art-spec.png",
"icon-spec",
"spec",
u8::MAX,
Vec::new(),
);
register_task_list_visual_fixture(
root,
"assets/ui-prototype.png",
"ui-prototype",
"ui-design",
u8::MAX,
vec![art_spec_resource_id.clone()],
);
register_task_list_visual_fixture(
root,
"assets/art-spritesheet.png",
"art-spritesheet",
"icon-spritesheet",
0,
vec![art_spec_resource_id],
);
}
#[test]
fn game_chat_task_list_hides_publish_tasks_and_counts() {
let temporary = tempfile::tempdir().expect("create task list project");
let root = temporary.path();
init_local_game_project_at(root, "game-chat-task-list", "game-chat task list")
.expect("initialize project");
register_task_list_visual_fixtures(root);
bind_game_creator_agent_runtime_run_profile_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
@@ -891,6 +891,7 @@ struct GeneratedPlatformArtAsset {
asset_object_id: Option<String>,
task_id: Option<String>,
model: Option<String>,
warning: Option<String>,
slice_warning: Option<String>,
}
@@ -1265,7 +1265,7 @@ fn spawn_mock_llm_server(response_content: String) -> String {
spawn_mock_llm_server_responses(vec![response_content])
}
fn spawn_mock_llm_server_responses(response_contents: Vec<String>) -> String {
pub(crate) fn spawn_mock_llm_server_responses(response_contents: Vec<String>) -> String {
spawn_mock_llm_server_responses_with_capture(response_contents, None)
}
@@ -1309,7 +1309,7 @@ pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply(
base_url
}
fn final_tool_plan_response(response: impl Into<String>) -> String {
pub(crate) fn final_tool_plan_response(response: impl Into<String>) -> String {
serde_json::json!({
"thinkingSummary": "已有工具观察足够,可以收束后台任务",
"planUpdate": null,
@@ -2651,6 +2651,17 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
}
})
.to_string();
let generation_accepted_body = serde_json::json!({
"data": {
"operationId": "task-external-fixture-1",
"kind": "editor_image_generation",
"status": "queued",
"statusUrl": "/api/external/v1/generations/task-external-fixture-1",
"pollAfterMs": 1,
"updatedAtMicros": 1
}
})
.to_string();
let read_body = serde_json::json!({
"read": {
"provider": "aliyun-oss",
@@ -2677,6 +2688,8 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
.to_string();
std::thread::spawn(move || {
let mut generation_response_gate = generation_response_gate;
let mut pending_generation_result: Option<String> = None;
let mut generation_poll_index = 0_u8;
for _ in 0..expected_requests {
let (mut stream, _) = listener.accept().expect("mock canvas api accept");
let mut request_buffer = [0_u8; 8192];
@@ -2686,61 +2699,119 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
let _ = sender.send(request.to_string());
}
let normalized_request = request.to_ascii_lowercase();
let (content_type, body) = if request
let (status, content_type, body) = if request
.starts_with("GET /api/external/v1/editor/projects ")
{
assert!(normalized_request.contains("authorization: bearer "));
("application/json", projects_body.as_bytes().to_vec())
("200 OK", "application/json", projects_body.as_bytes().to_vec())
} else if request.starts_with("GET /api/external/v1/editor/assets/library ") {
assert!(normalized_request.contains("authorization: bearer "));
("application/json", library_body.as_bytes().to_vec())
("200 OK", "application/json", library_body.as_bytes().to_vec())
} else if request.starts_with("GET /api/external/v1/editor/projects/canvas-project-1 ")
{
assert!(normalized_request.contains("authorization: bearer "));
("application/json", project_body.as_bytes().to_vec())
("200 OK", "application/json", project_body.as_bytes().to_vec())
} else if request.starts_with("POST /api/external/v1/editor/images/generations ") {
assert!(normalized_request.contains("authorization: bearer "));
if let Some(gate) = generation_response_gate.take() {
gate.recv_timeout(Duration::from_secs(5))
.expect("release mock canvas generation response");
}
("application/json", generation_body.as_bytes().to_vec())
let idempotency_key = request
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("idempotency-key")
.then_some(value.trim())
})
.expect("generation request idempotency key");
uuid::Uuid::parse_str(idempotency_key.trim())
.expect("generation idempotency key must be UUID");
pending_generation_result = Some(generation_body.clone());
generation_poll_index = 0;
(
"202 Accepted",
"application/json",
generation_accepted_body.as_bytes().to_vec(),
)
} else if request
.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ")
{
assert!(normalized_request.contains("authorization: bearer "));
let idempotency_key = request
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("idempotency-key")
.then_some(value.trim())
})
.expect("generation request idempotency key");
uuid::Uuid::parse_str(idempotency_key.trim())
.expect("generation idempotency key must be UUID");
pending_generation_result = Some(icon_spritesheet_body.clone());
generation_poll_index = 0;
(
"202 Accepted",
"application/json",
generation_accepted_body.as_bytes().to_vec(),
)
} else if request.starts_with(
"GET /api/external/v1/generations/task-external-fixture-1 ",
) {
assert!(normalized_request.contains("authorization: bearer "));
if let Some(gate) = generation_response_gate.take() {
gate.recv_timeout(Duration::from_secs(5))
.expect("release mock canvas generation response");
}
let status = match generation_poll_index {
0 => "queued",
1 => "running",
_ => "completed",
};
generation_poll_index = generation_poll_index.saturating_add(1);
let result = (status == "completed").then(|| {
serde_json::from_str::<serde_json::Value>(
pending_generation_result
.as_deref()
.expect("generation query follows one submission"),
)
.expect("fixture generation result JSON")
});
(
"200 OK",
"application/json",
icon_spritesheet_body.as_bytes().to_vec(),
serde_json::json!({
"data": {
"operationId": "task-external-fixture-1",
"kind": "editor_image_generation",
"status": status,
"phaseLabel": "图片画布生成图片",
"phaseDetail": if status == "completed" { "生成已完成。" } else { "正在生成。" },
"progress": if status == "completed" { 100 } else { 35 },
"result": result,
"pollAfterMs": 1,
"updatedAtMicros": 2
}
})
.to_string()
.into_bytes(),
)
} else if request.starts_with(
"GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fhero.png ",
) {
assert!(normalized_request.contains("authorization: bearer "));
("application/json", read_body.as_bytes().to_vec())
("200 OK", "application/json", read_body.as_bytes().to_vec())
} else if request.starts_with(
"GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fspritesheet.png ",
) {
assert!(normalized_request.contains("authorization: bearer "));
(
"200 OK",
"application/json",
spritesheet_read_body.as_bytes().to_vec(),
)
} else if request.starts_with("GET /signed/hero.png ") {
("image/png", valid_test_png_bytes())
("200 OK", "image/png", valid_test_png_bytes())
} else if request.starts_with("GET /signed/spritesheet.png ") {
("image/png", transparent_test_png_bytes())
("200 OK", "image/png", transparent_test_png_bytes())
} else {
("text/plain", b"not found".to_vec())
};
let status = if content_type == "text/plain" {
"404 Not Found"
} else {
"200 OK"
("404 Not Found", "text/plain", b"not found".to_vec())
};
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
@@ -2762,7 +2833,7 @@ fn spawn_mock_external_canvas_api_server() -> String {
fn spawn_mock_external_canvas_generation_api_server(
request_sender: Option<mpsc::Sender<String>>,
) -> String {
spawn_mock_external_canvas_api_server_with_capture(5, request_sender)
spawn_mock_external_canvas_api_server_with_capture(8, request_sender)
}
fn spawn_mock_external_canvas_generation_api_server_with_gate(
@@ -2770,7 +2841,7 @@ fn spawn_mock_external_canvas_generation_api_server_with_gate(
generation_response_gate: mpsc::Receiver<()>,
) -> String {
spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
5,
8,
Some(request_sender),
Some(generation_response_gate),
)
@@ -791,7 +791,11 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
assert!(agent_db.contains("\"agentId\":\"art-asset-plan\""));
assert!(agent_db.contains("测试图集保持整图,未生成独立切片。"));
assert!(!agent_db.contains("editor-runtime-key"));
let canvas_requests = (0..5)
assert!(!agent_db.contains("idempotencyKey"));
assert!(!root
.join(".agent/runtime/canvas-generation-requests/art-asset-plan/art-generate-run.json")
.exists());
let canvas_requests = (0..8)
.map(|_| {
canvas_receiver
.recv_timeout(Duration::from_secs(2))
@@ -804,6 +808,22 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
request.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ")
})
.expect("canvas generation request");
assert_eq!(
canvas_requests
.iter()
.filter(|request| request.starts_with("POST /api/external/v1/editor/"))
.count(),
1,
"queued/running polling must not submit generation again"
);
assert_eq!(
canvas_requests
.iter()
.filter(|request| request.starts_with("GET /api/external/v1/generations/"))
.count(),
3,
"fixture should exercise queued, running, and completed states"
);
for expected in [
r#""referenceImageSrc":"resource-icon-spec""#,
r#""iconDescriptions":"#,
@@ -850,7 +870,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() {
request_platform_art_asset_with_options_for_test(root, "原创贪吃蛇视觉", &options)
.await
.expect("prepare canonical visual request");
(0..5)
(0..8)
.map(|_| {
request_receiver
.recv_timeout(Duration::from_secs(2))
@@ -7,9 +7,9 @@ import { describe, expect, test, vi } from 'vitest';
import {
ensureBackend,
isProcessGroupRunning,
isProcessGroupAlive,
preflightExistingVite,
readLinuxProcessGroupRunning,
readLinuxProcessGroupAlive,
resolveBackendTargetsFromState,
runWindowsTaskkill,
spawnChild,
@@ -93,6 +93,36 @@ describe('AI 游戏创作配套后端复用门禁', () => {
describe('AI 游戏创作启动子进程生命周期', () => {
const posixTest = process.platform === 'win32' ? test.skip : test;
test('Linux 进程组只剩僵尸进程时视为已经停止', () => {
const procStats = new Map([
['/proc/101/stat', '101 (node worker) Z 1 700 700 0'],
['/proc/102/stat', '102 (other worker) S 1 701 701 0'],
]);
const readLinuxGroupAlive = (processGroupId: number) =>
readLinuxProcessGroupAlive(processGroupId, {
readdirImpl: () => ['101', '102', 'not-a-pid'],
readFileImpl: (path: string) => {
const stat = procStats.get(path);
if (!stat) {
throw new Error('missing proc stat fixture');
}
return stat;
},
});
const killImpl = vi.fn();
expect(readLinuxGroupAlive(700)).toBe(false);
expect(readLinuxGroupAlive(701)).toBe(true);
expect(
isProcessGroupAlive(700, {
platform: 'linux',
killImpl,
readLinuxGroupAlive,
}),
).toBe(false);
expect(killImpl).toHaveBeenCalledWith(-700, 0);
});
posixTest('npm 不可解析时进入受控 error 结果而不是未处理事件', async () => {
const child = spawnChild('genarrative-command-that-does-not-exist', [], {
cwd: process.cwd(),
@@ -156,40 +186,6 @@ describe('AI 游戏创作启动子进程生命周期', () => {
}
});
test('Linux 进程组探活忽略已退出但尚未回收的 zombie', () => {
const stats = new Map([
['/proc/4822/stat', '4822 (node worker) Z 1 4821 4821'],
['/proc/7001/stat', '7001 (other) S 1 7001 7001'],
]);
const readLinuxGroup = () =>
readLinuxProcessGroupRunning(4821, {
readdirImpl: () => [
{ name: '4822', isDirectory: () => true },
{ name: '7001', isDirectory: () => true },
],
readFileImpl: (path) => stats.get(path),
});
expect(readLinuxGroup()).toBe(false);
expect(
isProcessGroupRunning(4821, {
platform: 'linux',
killImpl: vi.fn(),
readLinuxProcessGroup: readLinuxGroup,
}),
).toBe(false);
expect(
isProcessGroupRunning(4821, {
platform: 'linux',
killImpl: vi.fn(),
readLinuxProcessGroup: () => null,
}),
).toBe(true);
stats.set('/proc/4822/stat', '4822 (node worker) R 1 4821 4821');
expect(readLinuxGroup()).toBe(true);
});
test('后端句柄在 ready 等待前交给外层且异常时立即清理', async () => {
const child = Object.assign(new EventEmitter(), {
exitCode: null,
@@ -6,7 +6,7 @@ import { join } from 'node:path';
import { describe, expect, test, vi } from 'vitest';
import {
isProcessGroupRunning,
isProcessGroupAlive,
spawnChild,
terminateChildTree,
} from '../scripts/start-dev-stack.mjs';
@@ -204,7 +204,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => {
});
expect(result).toBe(42);
expect(isProcessGroupRunning(cliChild.pid)).toBe(false);
expect(isProcessGroupAlive(cliChild.pid)).toBe(false);
} finally {
if (Number.isInteger(cliChild?.pid)) {
try {