独立评审收口 #599:命名行事件时序、命名任务 update 门禁、失败关闭与 legacy 用例
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m1s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m35s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m21s
Project CI / Frontend tests (pull_request) Successful in 3m36s
Project CI / Backend tests (pull_request) Successful in 7m48s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m12s
Project CI / Repository checks (pull_request) Successful in 7m2s
Project CI / Native shell tests (pull_request) Successful in 8m41s

- P2-2:命名行的账本订阅与恢复读合并为同一次 effect 的「先订阅、后读、只读一次」(与素材队列先订阅后派发同口径),并在「生成任务」面板打开且**确有在途命名行**时补读一次兜底——多读会改变既有生成任务链路可观测的轮询计数,因此不给没有命名任务的项目加读。新增 2 条 appSurface 用例(错过终态事件仍到终态 / 面板打开补读),并做了「改前红」验证:退回「先读后订阅 + 面板不补读」两条即红。
- P2-3:update_local_project_naming_task 补 project.rename 权限门禁(与 enqueue 同口径)、校验记录 project_id 与项目一致、终态只接受同状态幂等重放(completed→failed 一律 Err 且不写盘);新增 3 条 Rust 用例。
- P2-4:新增失败关闭与 schema 分支用例——超长项目名条件改名 Err 且 manifest/revision 不变、不支持 schemaVersion Err、缺 schemaVersion 按 v1 接受。
- P3-1:legacy v1 兼容用例改为断言兼容性质(字段原样保留),不再引用 phase 文案常量。
- 文档:pitfalls 补「兼容是单向的(旧构建读含命名记录的账本会整份失败,含影响面/触发条件/潜在低成本正向兼容方向)」与「先订阅后读快照」「update 门禁与终态语义」三条边界;decision-log 同步边界与数字。
- 门禁:AGC 全量 1964 passed / 17 skipped(appSurface 200 passed / 9 skipped);cargo asset_generation_task 27 passed、conditional_project_rename_tests 7 passed;agc:typecheck、lint:eslint(0 problems)、check:encoding、check:spacetime-schema、cargo fmt --check、git diff --check 全过。
This commit is contained in:
2026-10-05 00:55:29 +08:00
parent d550d882d4
commit 67d06bc168
6 changed files with 541 additions and 63 deletions
@@ -569,9 +569,15 @@ pub(crate) fn enqueue_local_project_naming_task(
///
/// 语义(命名任务独有的口径,素材任务一律拒绝):
/// - 只作用于 `taskType = project-naming` 的记录,且 `task_id` 必须存在;
/// - 权限门禁与 [`enqueue_local_project_naming_task`] 同口径(`project.rename`),且在任何
/// 读/改账本之前执行;
/// - 记录的 `project_id` 必须与项目 manifest 的 `project_id` 一致:目录换了项目(或身份被
/// 改写)时一律返回 Err,不写盘;
/// - `status` 只接受 `queued` / `running` / `completed` / `failed`;
/// - 进入 `running` 时补 `started_at_millis`;进入终态时置 `finished_at_millis`;
/// - **已终态的记录不接受改回非终态**(返回 Err,避免命名结果被一次迟到的排队事件抹掉);
/// - **记录一旦终态,就只接受与其当前状态相同的写入(幂等重放)**;传入与当前状态不同的
/// `status`(改回非终态,或改写成另一种终态)一律返回 Err 且不写盘——命名结果不能被一次
/// 迟到的排队/失败事件覆盖;
/// - `phase_detail` 传入即覆盖;`error` 只在 `failed` 时保存,非 `failed` 传入会被忽略;
/// - live 集合与状态同步:非终态保持登记(在途任务不能被并发 `list` 收口成失败),终态摘除
/// (真中断的任务重启后仍能被收口)。落账失败只回滚本轮真正插入的那条登记。
@@ -586,6 +592,9 @@ pub(crate) fn update_local_project_naming_task(
let task_id = asset_generation_task_id(&task_id)?;
let status = naming_task_status(&status)?;
let root = validated_local_project_directory_path(project_path.trim())?;
// 门禁必须在读/改账本之前;与 `enqueue_local_project_naming_task` 用同一个命令位。
enforce_project_permission_policy(&root, "project.rename")?;
let project_id = read_existing_manifest_for_project(&root)?.project_id;
let _guard = lock_ledger()?;
let mut tasks = read_ledger(&root)?;
let record = tasks
@@ -595,8 +604,19 @@ pub(crate) fn update_local_project_naming_task(
if record.task_type != ASSET_GENERATION_TASK_TYPE_PROJECT_NAMING {
return Err(format!("生成任务不是项目命名任务:{task_id}"));
}
if record.is_terminal() && !is_terminal_asset_generation_status(status) {
return Err(format!("项目命名任务已结束,不能改回非终态:{task_id}"));
// 记录必须属于当前项目:账本是项目内的,但 task_id 仍可能来自另一个项目(或目录被换过)。
if record.project_id != project_id {
return Err(format!(
"项目命名任务记录不属于该项目:{task_id}(记录 {},当前项目 {project_id})",
record.project_id
));
}
// 终态一旦落定就是终态:同状态的幂等重放仍允许,任何不同状态都拒绝且不写盘。
if record.is_terminal() && record.status != status {
return Err(format!(
"项目命名任务已结束,不能再写入不同状态:{task_id}(当前 {},请求 {status})",
record.status
));
}
record.status = status.to_string();
if status == ASSET_GENERATION_TASK_STATUS_RUNNING && record.started_at_millis.is_none() {
@@ -1180,7 +1200,10 @@ mod asset_generation_task_tests {
assert_eq!(record.project_id, "project-legacy");
assert_eq!(record.asset_name, "旧图");
assert_eq!(record.status, ASSET_GENERATION_TASK_STATUS_COMPLETED);
assert_eq!(record.phase_detail, ASSET_GENERATION_TASK_PHASE_COMPLETED);
// 兼容性只保证字段**原样保留**:`phaseDetail` 是 v1 时代后端自己写的字符串,读回必须逐字
// 不变。它当年恰好等于某个 phase 常量并不构成契约——所以这里对着 fixture 自身的字面量
// 断言,不引用当前 phase 常量来「证明兼容」。
assert_eq!(record.phase_detail, "生成已完成。");
assert_eq!(record.output_path.as_deref(), Some("assets/legacy.png"));
assert_eq!(record.created_at_millis, 11);
assert_eq!(record.started_at_millis, Some(12));
@@ -1190,6 +1213,67 @@ mod asset_generation_task_tests {
std::fs::remove_dir_all(&root).ok();
}
/// 不认识的 `schemaVersion` 必须失败关闭:宁可整份读不出来报错,也不能拿旧形状去猜读。
#[test]
fn ledger_with_an_unsupported_schema_version_fails_closed() {
let root = temp_project_root("schema-unsupported");
let ledger_path = root.join(ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH);
std::fs::create_dir_all(ledger_path.parent().expect("ledger parent")).expect("ledger dir");
std::fs::write(
&ledger_path,
r#"{
"schemaVersion": "agc-asset-generation-task.v3",
"tasks": []
}"#,
)
.expect("write unsupported ledger");
let error = read_ledger(&root).expect_err("unsupported schema must fail closed");
assert!(error.contains("schema 版本不受支持"), "{error}");
std::fs::remove_dir_all(&root).ok();
}
/// 缺失 `schemaVersion` 的老账本按 v1 接受:记录必须原样读回(`taskType` 缺省为素材任务)。
#[test]
fn ledger_without_a_schema_version_is_accepted_as_v1() {
let root = temp_project_root("schema-missing");
let ledger_path = root.join(ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH);
std::fs::create_dir_all(ledger_path.parent().expect("ledger parent")).expect("ledger dir");
std::fs::write(
&ledger_path,
r#"{
"tasks": [
{
"taskId": "task-no-schema",
"projectId": "project-legacy",
"kind": "image",
"assetName": "无版本号旧图",
"status": "failed",
"phaseDetail": "旧版失败文案。",
"createdAtMillis": 21,
"startedAtMillis": 22,
"finishedAtMillis": 23,
"assetId": null,
"error": "旧版错误"
}
]
}"#,
)
.expect("write schema-less ledger");
let tasks = read_ledger(&root).expect("missing schemaVersion reads as v1");
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].task_id, "task-no-schema");
assert_eq!(
tasks[0].task_type,
ASSET_GENERATION_TASK_TYPE_ASSET_GENERATION
);
assert_eq!(tasks[0].kind.as_deref(), Some("image"));
assert_eq!(tasks[0].created_at_millis, 21);
assert_eq!(tasks[0].finished_at_millis, Some(23));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn naming_task_enqueue_lands_in_the_shared_ledger_without_an_asset_kind() {
let root = initialized_project_root("naming-enqueue");
@@ -1355,6 +1439,110 @@ mod asset_generation_task_tests {
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn naming_task_update_requires_the_project_rename_permission() {
let root = initialized_project_root("naming-update-permission");
let project_path = naming_task_project_path(&root);
enqueue_local_project_naming_task(project_path.clone(), "naming-denied".to_string())
.expect("enqueue");
let mut policy = crate::ProjectPermissionPolicy::default();
policy.denied_commands.push("project.rename".to_string());
write_project_permission_policy_at(&root, policy).expect("write permission policy");
let error = update_local_project_naming_task(
project_path,
"naming-denied".to_string(),
ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(),
None,
None,
)
.expect_err("denied permission must fail closed");
assert!(
error.contains("项目权限策略拒绝执行:project.rename"),
"{error}"
);
// 门禁在读写账本之前:账本必须原封不动。
let stored = read_ledger(&root).expect("read ledger");
assert_eq!(stored[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED);
remove_live_task_id("naming-denied");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn naming_task_update_rejects_a_record_from_another_project() {
let root = initialized_project_root("naming-foreign-record");
let project_path = naming_task_project_path(&root);
enqueue_local_project_naming_task(project_path.clone(), "naming-foreign".to_string())
.expect("enqueue");
// 账本记录的项目身份被改成另一个项目(目录被换过 / task id 来自别的项目)。
let mut stored = read_ledger(&root).expect("read ledger");
stored[0].project_id = "another-project".to_string();
write_ledger(&root, &stored).expect("write ledger");
let error = update_local_project_naming_task(
project_path,
"naming-foreign".to_string(),
ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(),
None,
None,
)
.expect_err("foreign record must fail closed");
assert!(error.contains("记录不属于该项目"), "{error}");
// 拒绝发生在改动之前:状态仍是 queued。
let stored = read_ledger(&root).expect("read ledger again");
assert_eq!(stored[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED);
remove_live_task_id("naming-foreign");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn naming_task_terminal_state_rejects_a_different_status_but_allows_the_same_one() {
let root = initialized_project_root("naming-terminal");
let project_path = naming_task_project_path(&root);
enqueue_local_project_naming_task(project_path.clone(), "naming-terminal".to_string())
.expect("enqueue");
update_local_project_naming_task(
project_path.clone(),
"naming-terminal".to_string(),
ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(),
Some("命名已完成。".to_string()),
None,
)
.expect("completed");
// 终态 → 另一种终态:必须拒绝且不写盘(旧实现会静默把 completed 改写成 failed)。
let error = update_local_project_naming_task(
project_path.clone(),
"naming-terminal".to_string(),
ASSET_GENERATION_TASK_STATUS_FAILED.to_string(),
Some("迟到的失败。".to_string()),
Some("迟到的失败".to_string()),
)
.expect_err("terminal state must not be overwritten");
assert!(error.contains("不能再写入不同状态"), "{error}");
let stored = read_ledger(&root).expect("read ledger");
assert_eq!(stored[0].status, ASSET_GENERATION_TASK_STATUS_COMPLETED);
assert_eq!(stored[0].phase_detail, "命名已完成。");
assert!(stored[0].error.is_none());
// 同状态幂等重放仍允许。
let replayed = update_local_project_naming_task(
project_path,
"naming-terminal".to_string(),
ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(),
None,
None,
)
.expect("same-state replay is allowed");
assert_eq!(replayed.status, ASSET_GENERATION_TASK_STATUS_COMPLETED);
assert_eq!(replayed.phase_detail, "命名已完成。");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn interrupted_naming_task_is_settled_as_failed_with_naming_wording() {
let root = initialized_project_root("naming-interrupted");
@@ -704,6 +704,28 @@ mod conditional_project_rename_tests {
assert_eq!(project_revision(root), revision_before);
}
/// 超长名字同样走 `normalize_game_creation_project_name` 的门禁:条件改名必须失败关闭,
/// 且盘上 manifest 与 revision 都不动。
#[test]
fn conditional_rename_rejects_an_over_long_name_without_touching_the_project() {
let project = fixture_root("conditional-rename-too-long");
let root = project.path();
let revision_before = project_revision(root);
let too_long = "名".repeat(crate::project::GAME_CREATION_PROJECT_NAME_MAX_CHARS + 1);
let error = rename_local_game_project_if_unchanged_at(
root,
"conditional-rename-too-long",
FALLBACK_NAME,
&too_long,
)
.expect_err("over-long name must fail closed");
assert!(error.contains("项目名称"), "{error}");
assert_eq!(manifest_name(root), FALLBACK_NAME);
assert_eq!(project_revision(root), revision_before);
}
#[test]
fn conditional_rename_rejects_empty_expectations_without_touching_the_project() {
let project = fixture_root("conditional-rename-empty-expectation");
@@ -8986,6 +8986,34 @@ export default function ProjectDevelopmentView({
[manifest.assets, resourceGenerationPlaceholders],
);
/**
* 刷新「AI 项目命名」行:从账本重读一次(受控快照读取)。
*
* 读失败或返回值不是数组时**保留上一次的命名行**——一次 IPC 失败不该把列表清空。
*/
const refreshProjectNamingTasks = useCallback(async () => {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
return;
}
try {
const response = await invoke<LocalProjectAssetGenerationTaskRecord[]>(
'list_local_project_asset_generations',
{ projectPath },
);
if (!Array.isArray(response)) {
return;
}
setProjectNamingTasks(
splitLocalProjectGenerationTaskRecords(response).namingRecords.map(
restoreResourceCanvasProjectNamingTask,
),
);
} catch {
// 账本读不到:保留上一次的命名行,不猜状态。
}
}, [projectPath]);
/**
* 重开项目时恢复项目内的任务账本。
*
@@ -9003,6 +9031,7 @@ export default function ProjectDevelopmentView({
}
const projectId = manifest.projectId;
let cancelled = false;
let unsubscribeNamingTasks: (() => void) | null = null;
setResourceAssetGenerationTasksPanelOpen(false);
setProjectNamingTasks([]);
resourceAssetGenerationPanelSubmissionRef.current = null;
@@ -9016,6 +9045,39 @@ export default function ProjectDevelopmentView({
resourceGenerationDraftRef.current.clear();
resourceAssetGenerationDraftRef.current.clear();
void (async () => {
/**
* **先订阅、再读快照**(与素材队列「先订阅后派发」同一口径)。
*
* 命名行的推进发生在本组件之外(首页后台命名链路),刷新只能靠「变更事件 + 一次受控快照
* 读取」;读取是异步的,若先读后订阅,落在这一次 IPC 往返里的终态事件没有消费者、直接丢掉,
* 命名行会永久停在「命名中」。订阅失败也要照读一次,靠面板打开时的补读兜底。
*
* 订阅与这次恢复读共用同一次 effect:本项目挂载时对账本的读次数保持不变(把命名读并进
* 这一次恢复读),不改变既有生成任务链路可观测的轮询计数。
*/
if (canSubscribeTauriEvents()) {
try {
const dispose = await subscribeTauriEvent<{ projectPath?: string }>(
RESOURCE_CANVAS_ASSET_GENERATION_CHANGED_EVENT,
(event) => {
if (cancelled || event.payload?.projectPath !== projectPath) {
return;
}
void refreshProjectNamingTasks();
},
);
if (cancelled) {
dispose();
return;
}
unsubscribeNamingTasks = dispose;
} catch {
// 订阅不上:退化成「进项目读一次 + 面板打开时重读」。
}
}
if (cancelled) {
return;
}
const reportUnavailable = () => {
if (!cancelled) {
setResourceWorkbenchNotice(
@@ -9057,70 +9119,38 @@ export default function ProjectDevelopmentView({
})();
return () => {
cancelled = true;
unsubscribeNamingTasks?.();
};
}, [manifest.projectId, projectPath]);
}, [manifest.projectId, projectPath, refreshProjectNamingTasks]);
/**
* 刷新「AI 项目命名」行:从账本重读一次(受控快照读取)。
* 面板打开时补一次快照读。
*
* 读失败或返回值不是数组时**保留上一次的命名行**——一次 IPC 失败不该把列表清空。
*/
const refreshProjectNamingTasks = useCallback(async () => {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
return;
}
try {
const response = await invoke<LocalProjectAssetGenerationTaskRecord[]>(
'list_local_project_asset_generations',
{ projectPath },
);
if (!Array.isArray(response)) {
return;
}
setProjectNamingTasks(
splitLocalProjectGenerationTaskRecords(response).namingRecords.map(
restoreResourceCanvasProjectNamingTask,
),
);
} catch {
// 账本读不到:保留上一次的命名行,不猜状态。
}
}, [projectPath]);
/**
* 「AI 项目命名」的推进发生在本组件之外(首页后台命名链路),所以这里只订阅账本变更事件,
* 在事件属于当前项目时做**一次**受控快照读取(团队口径:正式状态变化走事件 + 一次快照读取,
* 不新增固定频率轮询)。素材任务仍按各自既有的账本/队列路径推进,不在这里改。
* 这是事件之外的兜底:订阅尚未就绪、窗口刚恢复、或用户就是想确认进度时,打开面板就能看到
* 账本里的真实状态,而不是永久停在「命名中」。
*
* **只在列表里确实存在一条在途命名行时才读**:没有命名任务的项目(绝大多数)不该因为打开
* 面板就多打一次账本 IPC——那会改变「轮询计数」这类外部可观测行为(既有生成任务用例正是
* 按轮询次数推进 mock 的)。依赖的是布尔而不是行数组:读取结果只会让它在 true→false 之间
* 最多翻转一次,不会自激。
*/
const hasInFlightProjectNamingTask = projectNamingTasks.some(
(task) => task.status !== 'completed' && task.status !== 'failed',
);
useEffect(() => {
if (!canSubscribeTauriEvents() || !projectPath.trim()) {
if (
!resourceAssetGenerationTasksPanelOpen ||
!hasInFlightProjectNamingTask
) {
return undefined;
}
let disposed = false;
let unsubscribe: (() => void) | null = null;
void subscribeTauriEvent<{ projectPath?: string }>(
RESOURCE_CANVAS_ASSET_GENERATION_CHANGED_EVENT,
(event) => {
if (disposed || event.payload?.projectPath !== projectPath) {
return;
}
void refreshProjectNamingTasks();
},
)
.then((dispose) => {
if (disposed) {
dispose();
} else {
unsubscribe = dispose;
}
})
.catch(() => undefined);
return () => {
disposed = true;
unsubscribe?.();
};
}, [projectPath, refreshProjectNamingTasks]);
void refreshProjectNamingTasks();
return undefined;
}, [
hasInFlightProjectNamingTask,
resourceAssetGenerationTasksPanelOpen,
refreshProjectNamingTasks,
]);
/**
* 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。
@@ -1999,6 +1999,241 @@ export function registerHomeProjectCreationTests() {
expect(screen.queryByText('GameAgent 项目 1a2b3c4d')).toBeNull();
});
it('recovers the naming row when the terminal event lands before the subscription is ready', async () => {
const projectPath = '/tmp/home-naming-event-race';
const fallbackManifest = createGameCreationAppManifest(
'home-naming-event-race',
'GameAgent 项目 5f6a7b8c',
);
const renamedManifest = { ...fallbackManifest, name: '星轨夜航' };
const ledgerRecord = (status: string, phaseDetail: string) => [
{
taskId: `project-naming:${fallbackManifest.projectId}`,
projectId: fallbackManifest.projectId,
taskType: 'project-naming',
kind: null,
assetName: 'AI 项目命名',
status,
phaseDetail,
createdAtMillis: 1,
startedAtMillis: 2,
finishedAtMillis: status === 'completed' ? 3 : null,
assetId: null,
error: null,
},
];
let namingSettled = false;
// 订阅注册被闸门卡住:这一段里落地的事件在真实世界里没有订阅者——就是丢了。
let releaseSubscription: (() => void) | null = null;
let subscriptionReady = false;
let droppedWhileUnsubscribed = 0;
const chatHarness = createProjectChatRuntimeHarness({
projectPath,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'create_automatic_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest: fallbackManifest,
};
}
if (command === 'suggest_automatic_project_name') return '星轨夜航';
if (command === 'rename_local_game_project_if_unchanged') {
namingSettled = true;
return { renamed: true, manifest: renamedManifest, revision: 2 };
}
if (command === 'get_local_game_manifest') {
return namingSettled ? renamedManifest : fallbackManifest;
}
if (command === 'list_local_project_asset_generations') {
// 进项目那一次读拿到的是「老化快照」(响应在命名落定之前就算好了);
// 订阅就绪之后的读才是账本真相。
return namingSettled && subscriptionReady
? ledgerRecord('completed', '已应用 AI 名称「星轨夜航」。')
: ledgerRecord('running', '正在等待 AI 命名结果。');
}
if (command === 'inspect_local_project_directory') {
return {
projectPath: String(args?.projectPath ?? ''),
exists: true,
isDirectory: true,
isGameCreatorProject: true,
isCocosProject: false,
godotProjectRoot: null,
projectName: renamedManifest.name,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'enqueue_direct_codex_turn') {
chatHarness.completeDirectThreadTurn({
turnId: String(args?.clientTurnId ?? ''),
prompt: '做一个星轨游戏',
reply: '开工。',
});
return '开工。';
}
return chatHarness.invoke(command, args);
},
);
const listen = (
eventName: string,
handler: (event: { payload: unknown }) => void,
) => {
if (eventName !== 'game-creator-asset-generation-task-changed') {
return chatHarness.listen(eventName, handler);
}
return new Promise<() => void>((resolve) => {
releaseSubscription = () => {
subscriptionReady = true;
resolve(() => {});
};
});
};
installTauriRuntime({ core: { invoke }, event: { listen } });
renderLauncherAt('/?launcher', 'home', true);
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '做一个星轨游戏';
fireEvent.paste(promptInput);
await waitFor(() => {
expect(promptInput.textContent).toContain('做一个星轨游戏');
});
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
await screen.findByLabelText('项目开发工作台');
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'rename_local_game_project_if_unchanged',
expect.anything(),
);
});
// 次序:读返回(陈旧 running)→ 终态事件(此时没有订阅者,丢掉)→ 订阅才注册。
droppedWhileUnsubscribed += 1;
await act(async () => {
releaseSubscription?.();
});
// 命名行必须到终态:右上角开关的在途计数归零(旧实现会永久停在「生成任务 · 1」)。
await waitFor(() => {
expect(droppedWhileUnsubscribed).toBe(1);
expect(screen.getByLabelText('生成任务').textContent).toBe('生成任务');
});
});
it('re-reads the naming ledger when the generation tasks panel opens', async () => {
const projectPath = '/tmp/home-naming-panel-reread';
const fallbackManifest = createGameCreationAppManifest(
'home-naming-panel-reread',
'GameAgent 项目 6a7b8c9d',
);
const renamedManifest = { ...fallbackManifest, name: '星轨夜航' };
const ledgerRecord = (status: string, phaseDetail: string) => [
{
taskId: `project-naming:${fallbackManifest.projectId}`,
projectId: fallbackManifest.projectId,
taskType: 'project-naming',
kind: null,
assetName: 'AI 项目命名',
status,
phaseDetail,
createdAtMillis: 1,
startedAtMillis: 2,
finishedAtMillis: status === 'completed' ? 3 : null,
assetId: null,
error: null,
},
];
let ledgerSettled = false;
const chatHarness = createProjectChatRuntimeHarness({
projectPath,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'create_automatic_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest: fallbackManifest,
};
}
if (command === 'suggest_automatic_project_name') return '星轨夜航';
if (command === 'rename_local_game_project_if_unchanged') {
return { renamed: true, manifest: renamedManifest, revision: 2 };
}
if (command === 'get_local_game_manifest') return renamedManifest;
if (command === 'list_local_project_asset_generations') {
// 账本真相由 `ledgerSettled` 控制:打开面板之前它是陈旧快照,
// 之后会重读一次拿到终态(本用例里订阅直接失败,只剩这条兜底)。
return ledgerSettled
? ledgerRecord('completed', '已应用 AI 名称「星轨夜航」。')
: ledgerRecord('running', '正在等待 AI 命名结果。');
}
if (command === 'inspect_local_project_directory') {
return {
projectPath: String(args?.projectPath ?? ''),
exists: true,
isDirectory: true,
isGameCreatorProject: true,
isCocosProject: false,
godotProjectRoot: null,
projectName: renamedManifest.name,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'enqueue_direct_codex_turn') {
chatHarness.completeDirectThreadTurn({
turnId: String(args?.clientTurnId ?? ''),
prompt: '做一个星轨游戏',
reply: '开工。',
});
return '开工。';
}
return chatHarness.invoke(command, args);
},
);
const listen = (
eventName: string,
handler: (event: { payload: unknown }) => void,
) => {
if (eventName === 'game-creator-asset-generation-task-changed') {
// 订阅失败:这条链路只剩「面板打开时补读」这一个兜底。
return Promise.reject(new Error('subscription unavailable'));
}
return chatHarness.listen(eventName, handler);
};
installTauriRuntime({ core: { invoke }, event: { listen } });
renderLauncherAt('/?launcher', 'home', true);
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '做一个星轨游戏';
fireEvent.paste(promptInput);
await waitFor(() => {
expect(promptInput.textContent).toContain('做一个星轨游戏');
});
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
await screen.findByLabelText('项目开发工作台');
// 陈旧快照下命名行还在跑:开关上带着在途计数。
await waitFor(() => {
expect(screen.getByLabelText('生成任务').textContent).toContain(
'生成任务 · 1',
);
});
// 账本随后落定,但(订阅失败的场景里)这条链路收不到任何事件。
ledgerSettled = true;
fireEvent.click(screen.getByLabelText('生成任务'));
// 打开面板补一次读:行必须变成账本里的终态,而不是永远停在「命名中」。
expect(
await screen.findByText('已应用 AI 名称「星轨夜航」。'),
).not.toBeNull();
});
it('keeps the home composer out of chat mode while automatic project creation is pending', async () => {
let rejectAutomaticProject: ((error: Error) => void) | null = null;
const automaticProject = new Promise<never>((_resolve, reject) => {
@@ -5,9 +5,9 @@
- 背景:首页「开启创作」原先串行执行「Web 预检 → `await suggest_automatic_project_name` → `create_automatic_local_game_project`」。项目名称不是创建工作区、导入附件或发起首轮创作的前置条件,命名请求(`AUTOMATIC_PROJECT_NAME_TIMEOUT_MS = 15s`,正常请求同样占时)却把用户按在「正在创建工作区」上。
- 决策(建项与命名解耦):建项固定传 `name: null`,由宿主既有兜底名(`GameAgent 项目 <8 位短 id>` / `策划项目 <8 位短 id>`)落盘并立即进入项目;命名请求在建项前并行打出、结果交给后台任务。后台拿到合法名称后调用新增的 `rename_local_game_project_if_unchanged(projectPath, expectedProjectId, expectedName, name)`:只有「项目 ID 相同」且「当前名称仍是本次创建的兜底名」才改名,返回 `renamed: true/false`(跳过时不写盘、不是错误)。用户已手动改名、项目 ID 不符、名称与现状相同一律跳过;空 `expectedProjectId` / `expectedName`、空名 / 控制字符 / 超长一律失败关闭。
- 决策(纳入「生成任务」体系):自动命名做成**项目工作台那条既有「生成任务」列表里的一条后台任务**,而不只是页面里的一个 promise。账本记录加 `taskType`(`asset-generation` / `project-naming`,缺省 `asset-generation`)、`kind` 变可选,schema 升 `agc-asset-generation-task.v2`(读取同时接受 v1/v2,v1 记录按素材任务读回);新增 `enqueue_local_project_naming_task`(排队中)/ `update_local_project_naming_task`(命名中 → 已完成/失败),展示名固定「AI 项目命名」,与素材生成共用同一份账本、同一个变更事件与同一个侧栏。状态流转:排队中 → 命名中 → 已完成(已应用 AI 名称 / 建议名与现状一致 / 用户已手动改名而跳过)或失败(无可用名称 / 自动改名失败,均保留兜底名)。命名任务由命名链路推进、**不进 live 集合就会被中断收口误判**,所以 enqueue 写账本前登记 live、update 终态写盘后摘除(中断残留仍按命名口径收口为失败)。前端按 `taskType` 把命名记录从素材任务分支里剔除,命名行不渲染缩略图/提示词/派发与定位动作,结论只在终态显示。
- 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检;写入前再过壳的生命周期守卫(`mounted` + 代次),关窗/卸载后只保留已落盘的改名,不写 UI 投影。账本是**展示旁路**:`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作;「做方案」与手动选目录建项不发起自动命名。本次未改共享契约(`packages/shared/**`)、server-rs 与任何 SpacetimeDB schema/HTTP 路由。
- 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检;写入前再过壳的生命周期守卫(`mounted` + 代次),关窗/卸载后只保留已落盘的改名,不写 UI 投影。账本是**展示旁路**:`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作;「做方案」与手动选目录建项不发起自动命名。本次未改共享契约(`packages/shared/**`)、server-rs 与任何 SpacetimeDB schema/HTTP 路由。条件改名的 `expectedProjectId` / `expectedName` 为空时按仓库同类入口口径失败关闭;`update_local_project_naming_task` 与 enqueue 共用 `project.rename` 权限位、校验记录归属,且终态只接受同状态幂等重放。**兼容性写成显式边界:兼容是单向的**——新构建读 v1 账本 OK;旧构建读到含命名记录(`kind: null`)的账本会整份解析失败(面板报读失败、同批在途素材任务被按中断收口)。前端刷新必须先订阅事件再读快照,并在面板打开时补读一次兜底。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{commands.rs,desktop.rs,asset_generation_tasks.rs,asset_generation_tasks/runtime.rs}`、`apps/ai-game-creator-shell/src/{app/types.ts,features/app-shell/{useHomeProjectCreation.ts,WorkspaceLauncher.tsx},features/resource-canvas/{resourceCanvasAssetGenerationTaskModel.ts,ResourceCanvasAssetGenerationTasksPanelView.tsx},view/project-development/index.tsx}`、`apps/ai-game-creator-shell/tests/{homeProjectNamingAsync.test.tsx,projectNamingGenerationTaskRow.test.tsx,appSurface/home.suite.ts}`。
- 验证:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(12 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖且任务按已完成+已跳过收口」「非法/空响应 → 任务 failed 且保留兜底名」「做方案不发起命名」「切到别的工作区不被劫持」「卸载/pagehide 后不写上下文」,并断言入队→命名中→终态的账本推进序列);`projectNamingGenerationTaskRow.test.tsx`(3 passed:固定展示名/不渲染缩略图提示词定位、终态才显示结论、失败徽章与原因 + 在途计数);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1925 passed / 17 skipped);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute asset_generation_task`(22 passed,含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal` 与 `legacy_v1_ledger_reads_back_with_the_default_asset_generation_task_type`)与 `conditional_project_rename_tests`(6 passed);`npm run agc:typecheck`(含 `check:tests:types`)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。
- 验证:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(12 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖且任务按已完成+已跳过收口」「非法/空响应 → 任务 failed 且保留兜底名」「做方案不发起命名」「切到别的工作区不被劫持」「卸载/pagehide 后不写上下文」,并断言入队→命名中→终态的账本推进序列);`projectNamingGenerationTaskRow.test.tsx`(3 passed:固定展示名/不渲染缩略图提示词定位、终态才显示结论、失败徽章与原因 + 在途计数);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1964 passed / 17 skipped);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute asset_generation_task`(27 passed,含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal`、`naming_task_update_requires_the_project_rename_permission`、`naming_task_update_rejects_a_record_from_another_project`、`naming_task_terminal_state_rejects_a_different_status_but_allows_the_same_one`、`ledger_with_an_unsupported_schema_version_fails_closed`、`ledger_without_a_schema_version_is_accepted_as_v1`)与 `conditional_project_rename_tests`(7 passed,含超长名失败关闭);`npm run agc:typecheck`(含 `check:tests:types`)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。
## 2026-10-03 AGC 发布版本标签改为由工程内部版本派生,取代「用户可编辑标签」口径
- 背景:用户实机验收指出发布面板「项目版本」显示 v6,而 AGC 工程内部只有 4 条正式版本记录(资源总览「项目版本」栏目 4 张卡,顶栏「智能体修订」下拉同样只有这 4 条)。核实:面板值来自本地清单 `manifest.projectVersion` 这个可编辑标量,它被三条链路反复钉到**平台** `game_distribution_version.version_number` 上——发布成功回写(`apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs:1531-1535`)、打开面板回读绑定回填(`:536-541`)、用户手改(`:944-965`);而 `manifest.versions` 从头到尾不参与该值。`publicationRevision` 只做 CAS,与任何版本号都无推导关系(`module-game-distribution/src/domain.rs:27-40` 的版本号解析只比 `max_existing` 与 `requested`)。
@@ -8,7 +8,10 @@
- **根因**:账本的中断收口判据是「非终态 + 不在本进程 live 集合」= 上次运行的残留。素材生成任务由 Rust 后台任务在 `start` 里先登记 live 再落账本;项目命名任务由前端驱动(`enqueue` / `update` 两条 IPC),没有登记 live,于是任何一次 `list_local_project_asset_generations` 都把它当成残留收口。
- **处理(现行口径)**:账本记录加 `taskType`(`asset-generation` / `project-naming`,缺省 `asset-generation`)、`kind` 变可选;schema 升 `agc-asset-generation-task.v2`,读取同时接受 v1 与 v2(v1 记录按素材任务读回)。`enqueue_local_project_naming_task` 写账本前登记 live(写盘失败只回滚本轮插入的那条),`update_local_project_naming_task` 在非终态时幂等登记、终态写盘成功后摘除。命名没有素材可交叉核对,中断收口一律 failed 且文案与素材口径区分(不含「目标素材」)。
- **边界**:命名任务的结论文案(已应用 / 用户已改名跳过 / 失败保留兜底名)由命名链路给出;账本只是展示旁路——`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作。前端按 `taskType` 把命名记录从素材任务列表里剔除(素材分支会按 `kind` 解析),命名行固定展示名「AI 项目命名」,只在终态显示结论。
- **判据/取证**:Rust `cargo test … asset_generation_task` 22 条(含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal`、`legacy_v1_ledger_reads_back_with_the_default_asset_generation_task_type`、`interrupted_naming_task_is_settled_as_failed_with_naming_wording`);前端 `homeProjectNamingAsync.test.tsx`(入队→running→终态的推进序列)、`projectNamingGenerationTaskRow.test.tsx`(行渲染)、appSurface 的「syncs the workbench title and recent project list with the AI name after creation」(建项后行可见 + 事件驱动刷新出终态结论)。
- **边界(兼容是单向的,必须写清)**:**新构建读旧账本 OK,旧构建读新账本会失败**——命名记录写 `"kind": null`,而旧构建(`AssetGenerationTaskRecord.kind` 必填且 `read_ledger` 不校验 schema)解析含命名记录的账本会**整份失败** → 面板报「生成任务列表读取失败」,且同一次读里在途的素材任务会被按「上次运行中断」收口为失败。触发条件只有「同一项目先被新构建写过命名任务、之后又被旧构建打开(降级或新旧混跑)」。潜在的低成本正向兼容方向(**未实施,需先确认**):命名记录也写一个合法 `kind`(例如 `unknown`)只靠 `taskType` 区分——旧构建会把它渲染成一张名为「AI 项目命名」的素材任务卡,但账本能解析、不会误伤在途素材任务。
- **边界(前端读快照的时序)**:命名行的推进发生在本组件之外,刷新只能靠「变更事件 + 一次受控快照读取」。**必须先订阅再读**(与素材队列「先订阅后派发」同口径):先读后订阅时,落在这一次 IPC 往返里的终态事件没有消费者、直接丢掉,行会永久停在「命名中」;订阅失败时退化为「进项目读一次 + 面板打开时补读」。补读兜底是必须的(订阅不可用、窗口刚恢复、用户想确认进度)。
- **边界(update 的门禁与终态语义)**:`update_local_project_naming_task` 与 `enqueue` 共用 `project.rename` 权限位,并校验记录 `project_id` 与项目一致;记录一旦终态只接受**同状态幂等重放**,改写成另一种状态(含 `completed → failed`)一律 Err 且不写盘——否则一次迟到的排队/失败事件会覆盖已落定的命名结论。
- **判据/取证**:Rust `cargo test … asset_generation_task` 27 条(含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal`、`naming_task_update_requires_the_project_rename_permission`、`naming_task_update_rejects_a_record_from_another_project`、`naming_task_terminal_state_rejects_a_different_status_but_allows_the_same_one`、`ledger_with_an_unsupported_schema_version_fails_closed`、`ledger_without_a_schema_version_is_accepted_as_v1`)与 `conditional_project_rename_tests` 7 条(含超长名失败关闭);前端 `homeProjectNamingAsync.test.tsx`(入队→running→终态的推进序列)、`projectNamingGenerationTaskRow.test.tsx`(行渲染)、appSurface 的「syncs the workbench title and recent project list with the AI name after creation」「recovers the naming row when the terminal event lands before the subscription is ready」「re-reads the naming ledger when the generation tasks panel opens」(后两条已做「改前红」验证:退回「先读后订阅 / 面板打开不补读」两条即红)。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`。
## 2026-10-03 后台异步回填被「进项目」覆盖:AI 项目名只在兜底名仍成立时改