合并最新主分支
同步 Runtime、外部 API 与统一弹窗等主分支更新 解决开发栈进程探活、Runtime continuation 与启动测试冲突 保留资源依赖图排障记忆并验证冲突路径
This commit is contained in:
@@ -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
@@ -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
+965
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(
|
||||
|
||||
+113
-3
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -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(),
|
||||
}),
|
||||
|
||||
+217
-19
@@ -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();
|
||||
}
|
||||
|
||||
+357
-33
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-5
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user