合并主分支
解决合并冲突
This commit is contained in:
@@ -84,7 +84,8 @@ fn game_creator_codex_cli_executable_candidates() -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
fn game_creator_codex_cli_version_at(executable: &Path) -> Result<String, String> {
|
||||
let output = std::process::Command::new(executable)
|
||||
let mut command = crate::new_windows_background_std_command(executable);
|
||||
let output = command
|
||||
.arg("--version")
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
|
||||
@@ -394,6 +394,84 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate(
|
||||
};
|
||||
}
|
||||
};
|
||||
let durable_receipt = match read_autonomous_playtest_receipt(root, contract) {
|
||||
Ok(Some(receipt)) if receipt.revision == revision_after.revision => receipt,
|
||||
Ok(Some(_)) => {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "preview.validate".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: "浏览器试玩已通过,但持久回执不属于当前 revision".to_string(),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
Ok(None) => {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "preview.validate".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: "浏览器试玩已通过,但持久回执回读失败".to_string(),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
Err(error) => {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "preview.validate".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: "浏览器试玩已通过,但持久回执无法验证".to_string(),
|
||||
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
|
||||
};
|
||||
}
|
||||
};
|
||||
{
|
||||
let _project_lock =
|
||||
match acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"preview.validate.initial-version",
|
||||
) {
|
||||
Ok(lock) => lock,
|
||||
Err(error) => {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "preview.validate".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: "浏览器试玩已通过,但首个可玩版本暂时无法登记".to_string(),
|
||||
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
|
||||
};
|
||||
}
|
||||
};
|
||||
let locked_revision = match read_game_creator_agent_runtime_project_revision(root) {
|
||||
Ok(revision) if revision.revision == durable_receipt.revision => revision,
|
||||
Ok(revision) => {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "preview.validate".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: "浏览器试玩回执落盘后项目 revision 已变化,未登记旧版本"
|
||||
.to_string(),
|
||||
detail: Some(format!(
|
||||
"receiptRevision={}, currentRevision={}",
|
||||
durable_receipt.revision, revision.revision
|
||||
)),
|
||||
};
|
||||
}
|
||||
Err(error) => {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "preview.validate".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: "浏览器试玩已通过,但登记版本前无法复核项目 revision"
|
||||
.to_string(),
|
||||
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
|
||||
};
|
||||
}
|
||||
};
|
||||
if let Err(error) =
|
||||
ensure_initial_game_iteration_version_at(root, locked_revision.revision)
|
||||
{
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "preview.validate".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: "浏览器试玩已通过,但首个可玩版本无法登记".to_string(),
|
||||
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
|
||||
};
|
||||
}
|
||||
}
|
||||
if contract_belongs_to_runtime {
|
||||
if let Err(error) = clear_agent_runtime_failed_playtest_at(
|
||||
root,
|
||||
|
||||
@@ -373,7 +373,8 @@ pub(crate) fn check_game_creator_codex_cli_available() -> Result<(), String> {
|
||||
|
||||
fn check_game_creator_codex_app_server_available() -> Result<(), String> {
|
||||
let executable = crate::agent::game_creator_codex_cli_executable_path()?;
|
||||
let output = std::process::Command::new(executable)
|
||||
let mut command = crate::new_windows_background_std_command(executable);
|
||||
let output = command
|
||||
.args(["app-server", "--help"])
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
|
||||
@@ -32,6 +32,7 @@ use shared_contracts::game_creation_app::{
|
||||
GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor,
|
||||
GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState,
|
||||
GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus,
|
||||
GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding,
|
||||
ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition,
|
||||
UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus,
|
||||
GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
|
||||
|
||||
@@ -371,6 +371,53 @@ pub(crate) fn read_existing_manifest_for_project(
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Registers the first formally playable project version after the current
|
||||
/// revision has produced a durable successful browser-playtest receipt.
|
||||
/// Replays are idempotent: once any formal version exists, validation never
|
||||
/// rewrites or appends another initial record.
|
||||
pub(crate) fn ensure_initial_game_iteration_version_at(
|
||||
root: &Path,
|
||||
project_revision: u64,
|
||||
) -> Result<bool, String> {
|
||||
if project_revision == 0 {
|
||||
return Err("首个可玩版本必须绑定大于 0 的项目 revision".to_string());
|
||||
}
|
||||
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
|
||||
if !manifest.versions.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let resource_bindings = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.map(|asset| GameIterationVersionResourceBinding {
|
||||
slot_id: format!("asset:{}", asset.id),
|
||||
resource_id: asset.id.clone(),
|
||||
})
|
||||
.collect();
|
||||
manifest.versions.push(GameIterationVersion {
|
||||
version_id: format!("initial-{project_revision}"),
|
||||
parent_version_id: None,
|
||||
project_revision,
|
||||
resource_bindings,
|
||||
created_reason: GameIterationVersionCreatedReason::Initial,
|
||||
created_at: unix_timestamp(),
|
||||
edit_prompt: None,
|
||||
});
|
||||
match write_manifest(&manifest_path, &manifest) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(error) => {
|
||||
// Another writer may have committed the same logical transition
|
||||
// after our read. Treat an installed formal version as a replay;
|
||||
// every other storage failure remains visible to the Runtime.
|
||||
if read_manifest(&manifest_path).is_ok_and(|current| !current.versions.is_empty()) {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_manifest_has_seed_tasks(
|
||||
root: &Path,
|
||||
goal: Option<&str>,
|
||||
|
||||
@@ -63,6 +63,58 @@ fn version_fixture(
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_first_playable_registration_creates_one_initial_version_with_asset_bindings() {
|
||||
let root = unique_manifest_test_root("first-playable-version");
|
||||
let manifest_path = root.join(".agent/manifest.json");
|
||||
let mut manifest = new_game_creation_app_manifest("project-first-playable", "首板项目");
|
||||
manifest.assets.push(GameCreationAppAssetManifestEntry {
|
||||
id: "asset-player".to_string(),
|
||||
kind: "character".to_string(),
|
||||
media_type: "image/png".to_string(),
|
||||
local_path: "assets/player.png".to_string(),
|
||||
source: GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
canvas_project_id: None,
|
||||
resource_id: None,
|
||||
asset_object_id: None,
|
||||
task_id: Some("art-asset-plan".to_string()),
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: None,
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
});
|
||||
write_manifest(&manifest_path, &manifest).expect("write first playable manifest");
|
||||
|
||||
let created = ensure_initial_game_iteration_version_at(&root, 7)
|
||||
.expect("register verified first playable");
|
||||
assert!(created);
|
||||
let replayed = ensure_initial_game_iteration_version_at(&root, 7)
|
||||
.expect("replay verified first playable registration");
|
||||
assert!(!replayed);
|
||||
|
||||
let installed = read_manifest(&manifest_path).expect("read versioned manifest");
|
||||
assert_eq!(installed.versions.len(), 1);
|
||||
assert_eq!(installed.versions[0].version_id, "initial-7");
|
||||
assert_eq!(installed.versions[0].parent_version_id, None);
|
||||
assert_eq!(installed.versions[0].project_revision, 7);
|
||||
assert_eq!(
|
||||
installed.versions[0].created_reason,
|
||||
GameIterationVersionCreatedReason::Initial
|
||||
);
|
||||
assert_eq!(
|
||||
installed.versions[0].resource_bindings,
|
||||
vec![GameIterationVersionResourceBinding {
|
||||
slot_id: "asset:asset-player".to_string(),
|
||||
resource_id: "asset-player".to_string(),
|
||||
}]
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_versions_are_append_only_at_the_storage_boundary() {
|
||||
let root = unique_manifest_test_root("versions-append-only");
|
||||
|
||||
@@ -41,6 +41,15 @@ pub(crate) fn configure_windows_background_std_command(
|
||||
) {
|
||||
}
|
||||
|
||||
pub(crate) fn new_windows_background_std_command<S>(program: S) -> std::process::Command
|
||||
where
|
||||
S: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
let mut command = std::process::Command::new(program);
|
||||
configure_windows_background_std_command(&mut command, false);
|
||||
command
|
||||
}
|
||||
|
||||
pub(crate) fn configure_windows_background_tokio_command(
|
||||
command: &mut tokio::process::Command,
|
||||
create_process_group: bool,
|
||||
@@ -48,6 +57,64 @@ pub(crate) fn configure_windows_background_tokio_command(
|
||||
configure_windows_background_std_command(command.as_std_mut(), create_process_group);
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod windows_background_command_tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::process::Stdio;
|
||||
|
||||
const CHILD_ENV: &str = "GENARRATIVE_WINDOWS_NO_CONSOLE_CHILD";
|
||||
const RESULT_ENV: &str = "GENARRATIVE_WINDOWS_NO_CONSOLE_RESULT";
|
||||
const FIXTURE_TEST: &str =
|
||||
"windows::windows_background_command_tests::background_command_console_fixture";
|
||||
|
||||
#[test]
|
||||
#[ignore = "child-process fixture"]
|
||||
fn background_command_console_fixture() {
|
||||
if std::env::var_os(CHILD_ENV).is_none() {
|
||||
return;
|
||||
}
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
fn GetConsoleWindow() -> windows_sys::Win32::Foundation::HWND;
|
||||
}
|
||||
let result_path = std::env::var_os(RESULT_ENV).expect("result path");
|
||||
let has_console_window = unsafe { !GetConsoleWindow().is_null() };
|
||||
fs::write(
|
||||
result_path,
|
||||
if has_console_window {
|
||||
"console"
|
||||
} else {
|
||||
"hidden"
|
||||
},
|
||||
)
|
||||
.expect("write console-window result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_std_command_does_not_allocate_a_console_window() {
|
||||
let directory = tempfile::tempdir().expect("create no-console test directory");
|
||||
let result_path = directory.path().join("console-window.txt");
|
||||
let mut command = new_windows_background_std_command(
|
||||
std::env::current_exe().expect("current test binary"),
|
||||
);
|
||||
command
|
||||
.args(["--exact", FIXTURE_TEST, "--ignored"])
|
||||
.env(CHILD_ENV, "1")
|
||||
.env(RESULT_ENV, &result_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
let status = command.status().expect("run no-console child fixture");
|
||||
assert!(status.success(), "no-console child fixture must succeed");
|
||||
assert_eq!(
|
||||
fs::read_to_string(result_path).expect("read console-window result"),
|
||||
"hidden",
|
||||
"CREATE_NO_WINDOW must keep background command probes from flashing a console window"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(windows, feature = "game-chat-release"))]
|
||||
pub(crate) fn configure_windows_suspended_background_std_command(
|
||||
command: &mut std::process::Command,
|
||||
|
||||
@@ -413,16 +413,59 @@ export function agentRuntimeStateFromResult(
|
||||
result: AgentRuntimeResult,
|
||||
previous?: AgentRuntimeState | null,
|
||||
): AgentRuntimeState {
|
||||
const acceptedRunId = result.acceptedRunId?.trim();
|
||||
const acceptedTask = acceptedRunId
|
||||
? (result.recentTasks ?? result.state.recentTasks ?? []).find(
|
||||
(task) => task.runId === acceptedRunId,
|
||||
)
|
||||
: null;
|
||||
const state =
|
||||
acceptedTask && result.state.runId !== acceptedRunId
|
||||
? {
|
||||
...result.state,
|
||||
agentId: acceptedTask.agentId,
|
||||
taskId: acceptedTask.taskId,
|
||||
sessionId: acceptedTask.sessionId,
|
||||
runId: acceptedTask.runId,
|
||||
source: acceptedTask.source,
|
||||
parentAgentId: acceptedTask.parentAgentId ?? null,
|
||||
parentRunId: acceptedTask.parentRunId ?? null,
|
||||
delegationId: acceptedTask.delegationId ?? null,
|
||||
goalId: acceptedTask.goalId ?? null,
|
||||
goalRevision: acceptedTask.goalRevision ?? 0,
|
||||
goalStatus: acceptedTask.goalStatus ?? null,
|
||||
currentTask: acceptedTask.task,
|
||||
currentGoal: acceptedTask.task,
|
||||
status: acceptedTask.status,
|
||||
phase: acceptedTask.phase,
|
||||
currentAction: acceptedTask.currentAction,
|
||||
waitingOn: agentRuntimeWaitingOnFromPhase(acceptedTask.phase),
|
||||
nextStep: agentRuntimeNextStepFromPhase(acceptedTask.phase),
|
||||
plan: [],
|
||||
planRevision: undefined,
|
||||
planExplanation: undefined,
|
||||
planSteps: [],
|
||||
activePlanStepIndex: null,
|
||||
observations: [],
|
||||
recentToolCalls: [],
|
||||
pendingToolAction: null,
|
||||
userInputRequest: null,
|
||||
lastResponse: null,
|
||||
error: acceptedTask.error,
|
||||
startedAt: acceptedTask.updatedAt,
|
||||
updatedAt: acceptedTask.updatedAt,
|
||||
}
|
||||
: result.state;
|
||||
return normalizeAgentRuntimeState(
|
||||
{
|
||||
...result.state,
|
||||
taskQueue: result.taskQueue ?? result.state.taskQueue,
|
||||
recentEvents: result.recentEvents ?? result.state.recentEvents,
|
||||
recentTasks: result.recentTasks ?? result.state.recentTasks,
|
||||
...state,
|
||||
taskQueue: result.taskQueue ?? state.taskQueue,
|
||||
recentEvents: result.recentEvents ?? state.recentEvents,
|
||||
recentTasks: result.recentTasks ?? state.recentTasks,
|
||||
userInputRequest:
|
||||
result.userInputRequest !== undefined
|
||||
? result.userInputRequest
|
||||
: result.state.userInputRequest,
|
||||
: state.userInputRequest,
|
||||
},
|
||||
previous,
|
||||
);
|
||||
|
||||
@@ -120,6 +120,16 @@ export function ProjectSupervisorView({
|
||||
value={chatInput}
|
||||
placeholder="告诉项目总控接下来要做什么"
|
||||
onChange={(event) => onChatInputChange(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
!event.shiftKey &&
|
||||
!event.nativeEvent.isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from '../src/app/types';
|
||||
import {
|
||||
agentRuntimeConversationStatus,
|
||||
agentRuntimeStateFromResult,
|
||||
formatAgentRecentRuntimeTask,
|
||||
formatAgentRuntimeEvent,
|
||||
isAgentRuntimeTerminalState,
|
||||
@@ -45,6 +46,78 @@ describe('普通用户工作区状态', () => {
|
||||
});
|
||||
|
||||
describe('Agent 最近任务失败摘要', () => {
|
||||
test('重试已入队时立即投影新 Run 并保留旧失败历史', () => {
|
||||
const previous: AgentRuntimeState = {
|
||||
...providerRetryRuntime(),
|
||||
runId: 'code-failed',
|
||||
source: 'agent-delegate',
|
||||
parentAgentId: 'project-supervisor',
|
||||
parentRunId: 'supervisor-active',
|
||||
status: 'failed',
|
||||
phase: 'failed',
|
||||
error: 'kind=budget-exhausted',
|
||||
updatedAt: 20,
|
||||
};
|
||||
const retryTask: AgentRuntimeTaskRecord = {
|
||||
schemaVersion: 'game-creator-agent-runtime-task.v1',
|
||||
agentId: previous.agentId,
|
||||
taskId: previous.taskId,
|
||||
sessionId: previous.sessionId,
|
||||
runId: 'code-retry',
|
||||
source: 'agent-delegate-retry',
|
||||
parentAgentId: 'project-supervisor',
|
||||
parentRunId: 'supervisor-active',
|
||||
delegationId: 'retry-delegation',
|
||||
task: previous.currentTask,
|
||||
status: 'pending',
|
||||
phase: 'queued',
|
||||
currentAction: '等待当前后台任务完成',
|
||||
error: null,
|
||||
updatedAt: 21,
|
||||
};
|
||||
const result: AgentRuntimeResult = {
|
||||
state: previous,
|
||||
acceptedRunId: retryTask.runId,
|
||||
sessionPath: '.agent/runtime/session.json',
|
||||
eventPath: '.agent/runtime/events.jsonl',
|
||||
taskPath: '.agent/runtime/tasks.jsonl',
|
||||
taskQueue: {
|
||||
total: 2,
|
||||
pending: 1,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 1,
|
||||
latestRunId: retryTask.runId,
|
||||
updatedAt: 21,
|
||||
},
|
||||
recentEvents: [],
|
||||
recentTasks: [
|
||||
{
|
||||
...retryTask,
|
||||
runId: previous.runId,
|
||||
source: previous.source,
|
||||
status: 'failed',
|
||||
phase: 'failed',
|
||||
currentAction: '等待开发者处理失败',
|
||||
terminalDetail: 'kind=budget-exhausted',
|
||||
error: 'kind=budget-exhausted',
|
||||
updatedAt: 20,
|
||||
},
|
||||
retryTask,
|
||||
],
|
||||
};
|
||||
|
||||
const projected = agentRuntimeStateFromResult(result, previous);
|
||||
|
||||
expect(projected.runId).toBe('code-retry');
|
||||
expect(projected.source).toBe('agent-delegate-retry');
|
||||
expect(projected.status).toBe('pending');
|
||||
expect(projected.phase).toBe('queued');
|
||||
expect(projected.error).toBeNull();
|
||||
expect(projected.parentRunId).toBe('supervisor-active');
|
||||
expect(projected.recentTasks).toEqual(result.recentTasks);
|
||||
});
|
||||
|
||||
test('展示安全可行动原因且不透传私有诊断', () => {
|
||||
const task: AgentRuntimeTaskRecord = {
|
||||
schemaVersion: 'game-creator-agent-runtime-task.v1',
|
||||
|
||||
@@ -9476,22 +9476,58 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseProfessionalRetry = resolve;
|
||||
});
|
||||
return supervisorHarness.runtimeResult(
|
||||
supervisorHarness.runtimeState({
|
||||
agentId: String(args?.agentId ?? ''),
|
||||
taskId: String(args?.agentId ?? ''),
|
||||
sessionId: 'design-runtime-status-session',
|
||||
runId: String(args?.nextRunId ?? ''),
|
||||
source: 'agent-delegate-retry',
|
||||
parentAgentId: 'project-supervisor',
|
||||
parentRunId: supervisorRunId,
|
||||
status: 'running',
|
||||
phase: 'planning',
|
||||
currentTask: '在当前项目重试策划任务',
|
||||
currentAction: '重新生成 Agent 工具计划',
|
||||
updatedAt: 7000,
|
||||
}),
|
||||
const retryRunId = String(args?.nextRunId ?? '');
|
||||
const staleFailedResult = supervisorHarness.runtimeResult(
|
||||
professionalRuntimes[0] as AgentRuntimeState,
|
||||
);
|
||||
return {
|
||||
...staleFailedResult,
|
||||
acceptedRunId: retryRunId,
|
||||
taskQueue: {
|
||||
...staleFailedResult.taskQueue,
|
||||
pending: 1,
|
||||
latestRunId: retryRunId,
|
||||
updatedAt: 7000,
|
||||
},
|
||||
recentTasks: [
|
||||
{
|
||||
schemaVersion: 'game-creator-agent-runtime-task.v1',
|
||||
agentId: 'design-director',
|
||||
taskId: 'design-director',
|
||||
sessionId: 'design-runtime-status-session',
|
||||
runId: 'design-runtime-status-run',
|
||||
source: 'agent-delegate',
|
||||
parentAgentId: 'project-supervisor',
|
||||
parentRunId: supervisorRunId,
|
||||
delegationId: 'design-runtime-status-delegation',
|
||||
task: '拆解首版玩法',
|
||||
status: 'failed',
|
||||
phase: 'failed',
|
||||
currentAction: '策划 Runtime 已失败',
|
||||
terminalDetail: 'kind=budget-exhausted',
|
||||
error: 'kind=budget-exhausted',
|
||||
updatedAt: 6100,
|
||||
},
|
||||
{
|
||||
schemaVersion: 'game-creator-agent-runtime-task.v1',
|
||||
agentId: 'design-director',
|
||||
taskId: 'design-director',
|
||||
sessionId: 'design-runtime-status-session',
|
||||
runId: retryRunId,
|
||||
source: 'agent-delegate-retry',
|
||||
parentAgentId: 'project-supervisor',
|
||||
parentRunId: supervisorRunId,
|
||||
delegationId: 'design-runtime-retry-delegation',
|
||||
task: '拆解首版玩法',
|
||||
status: 'pending',
|
||||
phase: 'queued',
|
||||
currentAction: '等待当前后台任务完成',
|
||||
terminalDetail: null,
|
||||
error: null,
|
||||
updatedAt: 7000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (
|
||||
failProfessionalAction &&
|
||||
@@ -9772,6 +9808,18 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
await within(professionalList).findByText(
|
||||
'重试请求已受理,正在同步新一轮状态',
|
||||
);
|
||||
const retriedDesignCard = within(professionalList)
|
||||
.getByText('策划 Agent')
|
||||
.closest('article')!;
|
||||
expect(retriedDesignCard.textContent).not.toContain(
|
||||
'策划 Agent 服务连接失败,请稍后重试',
|
||||
);
|
||||
expect(retriedDesignCard.textContent).toContain('执行中');
|
||||
expect(
|
||||
within(retriedDesignCard).queryByRole('button', {
|
||||
name: '在当前项目重试',
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
failProfessionalRuntimeReads = false;
|
||||
exposeProfessionalRuntimes = false;
|
||||
@@ -9884,7 +9932,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens a selected Godot project as the active root without initializing the web layout', async () => {
|
||||
it('opens a selected Godot project as the active root and keeps Supervisor composer keyboard semantics', async () => {
|
||||
const projectPath = '/tmp/existing-godot-project';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'local-project-draft',
|
||||
@@ -9943,7 +9991,25 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
|
||||
const composer = within(surface).getByLabelText('项目需求');
|
||||
fireEvent.change(composer, { target: { value: '修改玩家移动脚本' } });
|
||||
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
|
||||
expect(fireEvent.keyDown(composer, { key: 'Enter', shiftKey: true })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
invoke.mock.calls.some(
|
||||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
fireEvent.keyDown(composer, { key: 'Enter', isComposing: true }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
invoke.mock.calls.some(
|
||||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false);
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'start_game_creator_supervisor_runtime_task',
|
||||
|
||||
@@ -366,7 +366,7 @@ type ProjectVersionResourceReplacement = {
|
||||
|
||||
### 5.4 游戏迭代版本(P1)
|
||||
|
||||
阶段六实现状态(2026-08-03):正式版本业务真相扩展在本地项目 `.agent/manifest.json` 的可选 `versions` 字段中;旧项目字段缺失时等价于空列表,不根据 checkpoint、布局 sidecar、预览记录或 `game-creator-project-revision.v1` 自动伪造版本。版本数组只允许追加,已有记录不得删除、重排或修改;首轮没有版本创建按钮,也不自动把当前编辑态登记为版本。
|
||||
阶段六实现状态(2026-08-13 更新):正式版本业务真相扩展在本地项目 `.agent/manifest.json` 的可选 `versions` 字段中;旧项目字段缺失时等价于空列表,不根据 checkpoint、布局 sidecar、静态检查、失败试玩或单独的 `game-creator-project-revision.v1` 自动伪造版本。版本数组只允许追加,已有记录不得删除、重排或修改;首轮没有版本创建按钮。自主首板只有在当前 revision 的 `preview.validate` 已成功形成持久试玩回执后,才幂等追加首条 `initial` 版本,并绑定当时 manifest 中全部已登记资源;同一完成态恢复不得重复创建。已有正式版本时,后续试玩通过不自动追加版本,仍由明确的资源派生事务创建子版本。
|
||||
|
||||
```ts
|
||||
type GameIterationVersion = {
|
||||
|
||||
@@ -7152,6 +7152,11 @@
|
||||
- 根因:AGC 子目录存在独立 `node_modules` 时,AGC 源码会解析子目录 React,而仓库共享组件解析根目录 React;登录页不依赖共享 Hooks,进入首页后才触发 `Cannot read properties of null (reading 'useCallback')` 并白屏。
|
||||
- 决策:AGC Vite 配置必须对 `react` 与 `react-dom` 启用 `resolve.dedupe`,release 和本地构建统一复用仓库根 React runtime。登录后内容保留错误边界,渲染异常必须显示可恢复提示,不能再次退化为无提示白屏。
|
||||
|
||||
## 2026-08-13 AGC 自主首板试玩通过后登记初始版本
|
||||
|
||||
- 决策:自主游戏构建的当前 revision 只有在 `preview.validate` 成功且对应持久试玩回执已经写入后,才允许向 `.agent/manifest.json` 幂等追加首条 `initial` 版本;失败试玩、静态 smoke、checkpoint、预览启动和单独 revision 均不是版本事实。
|
||||
- 绑定与恢复:初始版本用 `initial-<projectRevision>` 稳定标识,并以 `asset:<manifest asset id>` 槽位绑定当时全部已登记资源。已有任意正式版本或同一成功完成态恢复时不重复追加;后续子版本继续由明确资源派生事务创建。
|
||||
|
||||
## 2026-08-10 AGC 打开现有 Godot 项目
|
||||
|
||||
- 项目根决策:项目首页与项目组提供同一“打开 Godot 项目”能力;被选目录需包含普通文件 `project.godot`,选中目录本身即为文件工具、命令与 Agent Runtime 的唯一项目根,不复制工程或建立第二套工作区。
|
||||
|
||||
@@ -487,7 +487,7 @@ game-project/
|
||||
|
||||
2026-08-03 阶段五加固,2026-08-05 明确截断信任边界:Rust read model 把 producer assignment 与布局深度分离。完整任务图先经 SCC 压缩形成可信 producer 的任务深度下限,精确资源引用图再经迭代式 SCC 压缩和确定性最长层级传播形成所有可见资源的 `dependencyDepths`;因此同一任务的派生资源、缺少 producer 审计的 manifest 资源和引用环都能稳定满足“被引用资源在前、引用资源在后”,没有引用关系的资源保持深度 `0`。`producerMappingTruncated=true` 只表示有界 Agent DB 尾部不足以证明 producer:前端必须失败关闭 `producerAssignments`、`taskFlows` 及其 `cyclicTaskIds`,但继续严格校验并消费 Rust 从当前 manifest、精确引用和仍可信下限构建的 `dependencyDepths`;`referenceEdges`、connection index 中的 reference 关系、`cyclicResourceIds` 和 unresolved reference 同样继续有效。前端不递归推导正式依赖层级。dependency 模式以 scope 化 `idle / loading / ready / failed` 状态阻断布局 Hook;图终态前不创建 fallback、不读取或写入 sidecar,图失败只以空图初始化一次。读取已有 dependency 布局时保留全部 `manuallyPlaced=true` 坐标,把 `manuallyPlaced=false` 作为可派生自动位置按最终深度重新协调;结果未变化时不写入。任务流仍按任务对聚合,只作为布局超边使用,不生成资源笛卡尔积或 SVG。
|
||||
|
||||
2026-08-03 阶段六:正式迭代版本直接扩展本地 `.agent/manifest.json`,不新增 checkpoint / layout sidecar / SpacetimeDB 平行业务真相。共享 Rust / TypeScript 合同新增可选 `versions: GameIterationVersion[]`;旧项目缺失字段时只读为空,不回填。Rust 在 manifest 读写边界校验版本唯一性、父先于子、根/原因一致、父子修订与时间单调、slot 唯一和 JavaScript 安全整数,并在覆盖已有 manifest 前要求磁盘版本数组是新数组的逐项相等前缀,从存储边界保证历史记录不可修改、删除或重排。
|
||||
2026-08-03 阶段六:正式迭代版本直接扩展本地 `.agent/manifest.json`,不新增 checkpoint / layout sidecar / SpacetimeDB 平行业务真相。共享 Rust / TypeScript 合同新增可选 `versions: GameIterationVersion[]`;旧项目缺失字段时只读为空,不回填。Rust 在 manifest 读写边界校验版本唯一性、父先于子、根/原因一致、父子修订与时间单调、slot 唯一和 JavaScript 安全整数,并在覆盖已有 manifest 前要求磁盘版本数组是新数组的逐项相等前缀,从存储边界保证历史记录不可修改、删除或重排。2026-08-13 起,自主首板在当前 revision 的 `preview.validate` 成功结果和持久试玩回执均落盘后,幂等追加唯一首条 `initial` 版本,并以稳定 `asset:<manifest asset id>` 槽位绑定当时全部已登记资源;失败试玩、静态 smoke、checkpoint 和普通预览状态不得触发版本创建,恢复重放和已有版本项目也不得重复追加。
|
||||
|
||||
工作台资源投影只从 `manifest.versions` 构建版本卡,按数组追加顺序生成稳定“版本 N”标题;不再接收前端独立 `projectVersions` 注入。`resourceBindings.resourceId` 只解释为 manifest asset ID,并映射到现有 `asset:<id>` 卡片。选中版本后在 dependency / type 两种布局中高亮当前仍存在的绑定资产;缺失历史资产只留在版本聚焦详情,不能合成幽灵卡或猜测 External Editor resource ID。版本聚焦复用中央只读容器,展示身份、修订、原因、父版本、直接子版本、创建时间与 slot 绑定。本阶段不提供版本创建、替换、切换、回滚、测试切片或运行态消费入口。
|
||||
|
||||
@@ -1065,6 +1065,7 @@ game-project/
|
||||
- Windows 原子文件事务在 rename 安装成功后必须立即释放临时文件句柄;恢复扫描遇到仍被活跃 writer 独占的临时文件时保留该文件并继续扫描已提交账本,不能让单个 `ERROR_SHARING_VIOLATION / ERROR_LOCK_VIOLATION` 阻断整个恢复。新建目录与安装关键 sidecar 后仍按既有平台能力同步文件和目录,不能把 Windows 目录 `sync_all` 失败误判为业务提交失败。
|
||||
- `.agent/project.lock` 的 `create_new` 在 Windows 目标存在或处于 delete-pending 竞争时,可能返回 `ACCESS_DENIED(5)`、sharing violation(32) 或 lock violation(33),这些结果统一投影为“项目正在被其他写操作占用”并进入既有有界等待;其他权限错误继续失败关闭。Runtime 测试若在终态后立即二次恢复,必须同时等待 `status/phase` 终态和 Agent execution lane 释放,不能只观察 state JSON。
|
||||
- Windows 子进程启动把 `npm.cmd` 解析为当前 Node 与 `npm-cli.js` 的显式 argv,保留 CRLF/ANSI/ConPTY 处理和 Job Object 生命周期;项目验证使用隔离 Cargo target wrapper,避免开发 GUI 或旧 runner 持有测试需要替换的 EXE。Agent DB 打开继续允许同进程读写共享并修复唯一 JSONL 残尾,不能用默认独占句柄破坏并发读取。
|
||||
- 发布 GUI 内的 Codex CLI 可用性探测(包括候选版本检查和 `app-server --help`)必须与实际 Codex、MCP、Runner 和项目命令一样使用 `CREATE_NO_WINDOW`;读取对话或配置状态时即使连续探测多个候选,也不能创建或闪烁控制台窗口。
|
||||
- Node ESM 脚本必须用 `fileURLToPath()` 把 `import.meta.url` 转为 Windows 本地路径,禁止直接把 URL pathname 交给 `path.resolve()`;真实 agent-run smoke 的浏览器探测覆盖 Windows Chrome/Edge 固定安装位置。开发态 smoke 在旧安装版持有默认 AppData GUI owner 时使用独立 `--config-dir`,不得终止用户现有客户端。
|
||||
|
||||
- 2026-08-12 计划拒绝恢复:结构化 `runtime.plan_update` 被 Runtime 拒绝后,下一轮 Provider 请求按请求级目录收窄到实际项目 mutation 与 `respond_to_user`(已进入协作编排的 Supervisor 保留 `agent.delegate / agent.run_status`),并明确禁止再次规划、读取、搜索或验证;后续已有真实 mutation observation 后解除临时目录,不改变持久 executable policy。
|
||||
|
||||
@@ -63,7 +63,7 @@ SpacetimeDB 模块会在事务内重复执行同等强度的校验,并拒绝
|
||||
|
||||
所有会调用外部生成 provider 的编辑器生成请求都必须由后端计算价格,前端请求不提交价格字段;同步执行按当前运行时配置进入 `execute_billable_asset_operation_with_cost` 预扣泥点,预扣失败不得继续调用上游。外部生成队列在入队时把价格写入 `external_generation_job.price_mud_points`,worker 必须用该冻结价格完成扣费、退款、响应和资产成本持久化,配置更新不得改变已入队任务金额。普通图片、规范、角色、UI 设计、宣发素材、快速编辑 / 图片修改、图标 spritesheet、UI 设计图提取素材、视频、角色动作、音效和背景音乐均遵循该规则。背景色决策(gpt-5-mini)本身也是一次上游调用,同样必须在预扣泥点之后发起:预扣前只做颜色无关的算价 / 校验(动画用默认色占位算价),决策放进 billable 闭包,余额不足则决策不跑、决策失败走失败退款。需要向前端展示实际扣费时,由后端在响应中返回 `priceMudPoints`。
|
||||
|
||||
SFX V2 上线前已经存在的 SpacetimeDB 定价快照可能只有 `audio1.0`。读取这类历史快照时,`api-server` 只允许从当前受控默认配置或本地 override 补入缺失的 `eleven_text_to_sound_v2` 条目,使旧快照可继续读取;其它必需模型缺失仍失败。该兼容不修改 schema,也不在读取时写数据库;下一次后台保存完整定价矩阵时自然持久化新键。发布前仍应确认运行时配置中的新键和价格已经批准。
|
||||
SFX V2 上线前已经存在的 SpacetimeDB 定价快照或旧本地 override 可能只有 `audio1.0`。读取这类历史快照时,`api-server` 只允许从当前受控默认配置补入缺失的 `eleven_text_to_sound_v2` 条目,再执行完整配置校验,使旧快照可继续读取;其它必需模型缺失仍失败。该兼容不修改 schema,也不在读取时写数据库或 override;下一次后台保存完整定价矩阵时自然持久化新键。发布前仍应确认运行时配置中的新键和价格已经批准。
|
||||
|
||||
## 运行时身份首次授权
|
||||
|
||||
|
||||
@@ -321,15 +321,49 @@ fn load_editor_generation_pricing_from_candidates(
|
||||
.or(fallback_legacy_path);
|
||||
if let Some(path) = selected_override_path {
|
||||
let override_json = fs::read_to_string(path).map_err(EditorGenerationPricingError::Io)?;
|
||||
config = parse_editor_generation_pricing_json(
|
||||
override_json.as_str(),
|
||||
path.to_string_lossy().as_ref(),
|
||||
)?;
|
||||
let source = path.to_string_lossy();
|
||||
let mut override_config =
|
||||
serde_json::from_str::<EditorGenerationPricingConfig>(override_json.as_str())
|
||||
.map_err(EditorGenerationPricingError::Json)?;
|
||||
backfill_legacy_sfx_pricing(&mut override_config, &config, source.as_ref())?;
|
||||
override_config.validate().map_err(|error| match error {
|
||||
EditorGenerationPricingError::Invalid(message) => {
|
||||
EditorGenerationPricingError::Invalid(format!("{source}: {message}"))
|
||||
}
|
||||
other => other,
|
||||
})?;
|
||||
config = override_config;
|
||||
}
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn backfill_legacy_sfx_pricing(
|
||||
config: &mut EditorGenerationPricingConfig,
|
||||
fallback: &EditorGenerationPricingConfig,
|
||||
source: &str,
|
||||
) -> Result<(), EditorGenerationPricingError> {
|
||||
if config
|
||||
.models
|
||||
.contains_key(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let pricing = fallback
|
||||
.models
|
||||
.get(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
EditorGenerationPricingError::Invalid(format!(
|
||||
"{source}: 受控默认配置缺少模型 {EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS}"
|
||||
))
|
||||
})?;
|
||||
config
|
||||
.models
|
||||
.insert(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS.to_string(), pricing);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_editor_generation_pricing_json(
|
||||
json: &str,
|
||||
source: &str,
|
||||
@@ -739,6 +773,69 @@ mod tests {
|
||||
assert_eq!(loaded.sound_effect_model_mud_points(Some("audio1.0")), 17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_generation_pricing_legacy_override_backfills_new_sfx_model() {
|
||||
let temp_dir = unique_temp_dir("genarrative-pricing-legacy-sfx-test");
|
||||
std::fs::create_dir_all(&temp_dir).expect("temp dir should create");
|
||||
let override_path = temp_dir.join("editor-generation-pricing.override.json");
|
||||
let mut legacy_config = default_runtime_pricing();
|
||||
legacy_config
|
||||
.models
|
||||
.remove(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS);
|
||||
legacy_config
|
||||
.models
|
||||
.get_mut(EDITOR_SOUND_EFFECT_MODEL_VIDU)
|
||||
.expect("legacy sound effect pricing should exist")
|
||||
.price = Some(17);
|
||||
std::fs::write(
|
||||
&override_path,
|
||||
serde_json::to_string(&legacy_config).expect("legacy config should serialize"),
|
||||
)
|
||||
.expect("legacy override should write");
|
||||
|
||||
let loaded = load_editor_generation_pricing_from_paths(Some(&override_path))
|
||||
.expect("legacy override should backfill the new SFX model");
|
||||
|
||||
assert_eq!(
|
||||
loaded.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)),
|
||||
5
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_VIDU)),
|
||||
17
|
||||
);
|
||||
std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_generation_pricing_legacy_override_still_rejects_other_missing_models() {
|
||||
let temp_dir = unique_temp_dir("genarrative-pricing-legacy-required-model-test");
|
||||
std::fs::create_dir_all(&temp_dir).expect("temp dir should create");
|
||||
let override_path = temp_dir.join("editor-generation-pricing.override.json");
|
||||
let mut legacy_config = default_runtime_pricing();
|
||||
legacy_config
|
||||
.models
|
||||
.remove(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS);
|
||||
legacy_config
|
||||
.models
|
||||
.remove(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO);
|
||||
std::fs::write(
|
||||
&override_path,
|
||||
serde_json::to_string(&legacy_config).expect("legacy config should serialize"),
|
||||
)
|
||||
.expect("legacy override should write");
|
||||
|
||||
let error = load_editor_generation_pricing_from_paths(Some(&override_path))
|
||||
.expect_err("only the new SFX model may be backfilled");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO)
|
||||
);
|
||||
std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_image_and_spec_prices_share_model_size_rates() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user