合并主分支
Project CI / Repository checks (pull_request) Successful in 1m8s
Project CI / Frontend tests (pull_request) Successful in 2m53s
Project CI / Native shell tests (pull_request) Failing after 10m56s
Project CI / Backend tests (pull_request) Successful in 3m7s

解决合并冲突
This commit is contained in:
2026-08-14 10:41:28 +08:00
16 changed files with 573 additions and 31 deletions
@@ -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!(