完成资源画布竞态审计与最终门禁
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Failing after 20s
Project CI / Native shell tests (pull_request) Failing after 8m46s

增加 expectedProjectId 身份栅栏并在写入关键阶段复核项目身份
在 revision 耗尽时失败关闭并保持乐观锁 CAS 契约
折叠同资源连续拖动并解除旧 scope 请求对新项目的阻塞
补齐多窗口并发、异步迟到响应和极端边界回归测试
修复目标分支格式、前端门禁及负载敏感测试时序
同步更新资源画布契约、决策记录和竞态风险说明
This commit is contained in:
2026-07-30 17:02:31 +08:00
parent 7d0098bfec
commit c162e1de44
14 changed files with 297 additions and 66 deletions
@@ -207,6 +207,7 @@ pub(crate) fn read_local_project_resource_canvas_layout(
#[tauri::command]
pub(crate) fn update_local_project_resource_canvas_layout(
project_path: String,
expected_project_id: String,
mode: ProjectResourceCanvasLayoutMode,
expected_revision: u64,
positions: Vec<ProjectResourceCanvasPosition>,
@@ -214,6 +215,7 @@ pub(crate) fn update_local_project_resource_canvas_layout(
update_project_resource_canvas_layout_at(
Path::new(project_path.trim()),
mode,
&expected_project_id,
expected_revision,
positions,
)
@@ -432,14 +432,22 @@ pub(crate) fn read_project_resource_canvas_layout_at(
pub(crate) fn update_project_resource_canvas_layout_at(
root: &Path,
mode: ProjectResourceCanvasLayoutMode,
expected_project_id: &str,
expected_revision: u64,
positions: Vec<ProjectResourceCanvasPosition>,
) -> Result<UpdateProjectResourceCanvasLayoutResult, String> {
validate_resource_layout_positions(&positions)?;
let expected_project_id = expected_project_id.trim();
if expected_project_id.is_empty() {
return Err("资源布局 expectedProjectId 不能为空".to_string());
}
let preflight_project_id = current_resource_layout_project_id(root)?;
if preflight_project_id != expected_project_id {
return Err("资源布局 expectedProjectId 与当前项目不匹配".to_string());
}
let _lock = acquire_resource_layout_write_lock(root)?;
let project_id = current_resource_layout_project_id(root)?;
if project_id != preflight_project_id {
if project_id != expected_project_id {
return Err("项目 manifest 在资源布局锁获取期间发生变化,请重试".to_string());
}
let relative_path = resource_layout_relative_path(mode);
@@ -456,11 +464,18 @@ pub(crate) fn update_project_resource_canvas_layout_at(
layout: current,
});
}
let next_revision = current
.revision
.checked_add(1)
.ok_or_else(|| "资源布局 revision 已达到上限,无法继续保存".to_string())?;
if current_resource_layout_project_id(root)? != project_id {
return Err("项目 manifest 在资源布局写入前发生变化,请重试".to_string());
}
let next = ProjectResourceCanvasLayout {
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
project_id,
mode,
revision: current.revision.saturating_add(1),
revision: next_revision,
positions,
updated_at: unix_millis().min(u128::from(u64::MAX)) as u64,
};
@@ -481,16 +496,22 @@ pub(crate) fn update_project_resource_canvas_layout_at(
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Barrier};
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc, Barrier,
};
static NEXT_RESOURCE_LAYOUT_TEST_ID: AtomicU64 = AtomicU64::new(0);
fn unique_resource_layout_project_path() -> PathBuf {
std::env::temp_dir().join(format!(
"genarrative-resource-layout-{}-{}",
"genarrative-resource-layout-{}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.as_nanos(),
NEXT_RESOURCE_LAYOUT_TEST_ID.fetch_add(1, Ordering::Relaxed)
))
}
@@ -525,6 +546,7 @@ mod tests {
let updated = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-project",
0,
vec![layout_position("asset-a", 18)],
)
@@ -549,6 +571,7 @@ mod tests {
let missing_error = update_project_resource_canvas_layout_at(
&missing_root,
ProjectResourceCanvasLayoutMode::Dependency,
"missing-project",
0,
vec![layout_position("asset-a", 10)],
)
@@ -561,6 +584,7 @@ mod tests {
update_project_resource_canvas_layout_at(
&non_project_root,
ProjectResourceCanvasLayoutMode::Dependency,
"non-project",
0,
vec![layout_position("asset-a", 10)],
)
@@ -576,6 +600,7 @@ mod tests {
update_project_resource_canvas_layout_at(
&invalid_manifest_root,
ProjectResourceCanvasLayoutMode::Dependency,
"invalid-project",
0,
vec![layout_position("asset-a", 10)],
)
@@ -584,6 +609,25 @@ mod tests {
fs::remove_dir_all(invalid_manifest_root).ok();
}
#[test]
fn resource_layout_expected_project_id_fences_stale_windows_before_lock_side_effects() {
let root = unique_resource_layout_project_path();
init_local_game_project_at(&root, "layout-current-project", "布局当前项目")
.expect("init project");
let error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-stale-project",
0,
vec![layout_position("asset-a", 10)],
)
.expect_err("stale project window must fail");
assert!(error.contains("expectedProjectId"));
assert!(!root.join(".agent/workbench").exists());
fs::remove_dir_all(root).ok();
}
#[cfg(unix)]
#[test]
fn resource_layout_live_system_lock_is_not_reclaimed_from_old_mtime() {
@@ -627,6 +671,7 @@ mod tests {
let first = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-cas",
0,
vec![layout_position("asset-a", 10)],
)
@@ -636,6 +681,7 @@ mod tests {
let conflict = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-cas",
0,
vec![layout_position("asset-a", 999)],
)
@@ -656,6 +702,39 @@ mod tests {
fs::remove_dir_all(root).ok();
}
#[test]
fn resource_layout_revision_exhaustion_fails_without_overwrite() {
let root = unique_resource_layout_project_path();
init_local_game_project_at(&root, "layout-revision-max", "布局 revision 上限")
.expect("init project");
let path = root.join(resource_layout_relative_path(
ProjectResourceCanvasLayoutMode::Dependency,
));
fs::create_dir_all(path.parent().expect("layout parent")).expect("create layout parent");
let exhausted = ProjectResourceCanvasLayout {
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
project_id: "layout-revision-max".to_string(),
mode: ProjectResourceCanvasLayoutMode::Dependency,
revision: u64::MAX,
positions: vec![layout_position("asset-a", 10)],
updated_at: 1,
};
let original = serde_json::to_vec(&exhausted).expect("serialize exhausted layout");
fs::write(&path, &original).expect("write exhausted layout");
let error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-revision-max",
u64::MAX,
vec![layout_position("asset-a", 999)],
)
.expect_err("revision exhaustion must fail");
assert!(error.contains("revision"));
assert_eq!(fs::read(&path).expect("read exhausted layout"), original);
fs::remove_dir_all(root).ok();
}
#[test]
fn resource_layout_rejects_duplicate_ids_and_project_identity_drift() {
let root = unique_resource_layout_project_path();
@@ -663,6 +742,7 @@ mod tests {
let duplicate_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-identity",
0,
vec![
layout_position("asset-a", 10),
@@ -737,6 +817,7 @@ mod tests {
let coordinate_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-invalid",
0,
vec![layout_position(
"asset-coordinate",
@@ -748,6 +829,7 @@ mod tests {
let id_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-invalid",
0,
vec![layout_position(
&"x".repeat(RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS + 1),
@@ -815,6 +897,7 @@ mod tests {
update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-concurrent",
0,
vec![layout_position("asset-a", x)],
)
@@ -2151,8 +2151,7 @@ fn visual_specialist_delegations_require_image_artifacts_but_read_only_work_allo
fn visual_specialist_delegation_degrades_to_text_artifacts_without_editor_api_key() {
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "无图片密钥委派合同测试")
.expect("project init");
init_local_game_project_at(&root, "project-1", "无图片密钥委派合同测试").expect("project init");
let parent_run_id = "text-only-design-delegate-parent-run";
start_game_creator_agent_runtime_task_at(
&root,
+34 -25
View File
@@ -4,6 +4,7 @@ import {
type ChangeEvent,
type FormEvent,
type UIEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
@@ -64,10 +65,10 @@ import type {
LocalGameMemoryResult,
LocalPreviewResult,
LocalPreviewStatus,
LocalProjectDirectoryStatus,
LocalProjectCheckpointResult,
LocalProjectCheckpointSummary,
LocalProjectDiffResult,
LocalProjectDirectoryStatus,
LocalProjectExportPackageResult,
LocalProjectExportPackagesResult,
LocalProjectFileEntry,
@@ -200,6 +201,7 @@ import {
} from './features/project-workspace/agentRunTrace';
import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels';
import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels';
import { resolveEmbeddedPreviewUrl } from './features/project-workspace/LocalGamePreviewFrame';
import {
appendMemoryContent,
memoryScopeLabel,
@@ -221,7 +223,6 @@ import {
import { handleProjectSummaryChatCommand } from './features/project-workspace/projectSummaryCommands';
import { ProjectSupervisorView } from './features/project-workspace/ProjectSupervisorView';
import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWorkspaceChatPane';
import { resolveEmbeddedPreviewUrl } from './features/project-workspace/LocalGamePreviewFrame';
import {
buildGameChatProgressEvidence,
collectGameChatResultImages,
@@ -596,6 +597,9 @@ export function App({
) => Promise<void>)
| null
>(null);
const executeChatAgentReplyRef = useRef<
(prompt: string) => Promise<void>
>(async () => undefined);
const agentConversationSavingRef = useRef(false);
const agentConversationBackgroundBusyRef = useRef(false);
const agentConversationLoadVersionRef = useRef(0);
@@ -620,27 +624,30 @@ export function App({
storeGameChatAutoPreviewAuthorization(authorization);
}
function updateProjectSupervisorRuntime(
runtime: AgentRuntimeState | null,
previous = projectSupervisorRuntimeRef.current,
) {
const nextRuntime = runtime
? normalizeAgentRuntimeState(runtime, previous)
: null;
const nextProjectPath = localProjectPathRef.current;
if (
gameChatOnly &&
nextProjectPath &&
nextRuntime?.runId &&
!isAgentRuntimeTerminalState(nextRuntime)
) {
gameChatObservedRunKeysRef.current.add(
`${nextProjectPath}\n${nextRuntime.runId}`,
);
}
projectSupervisorRuntimeRef.current = nextRuntime;
setProjectSupervisorRuntime(nextRuntime);
}
const updateProjectSupervisorRuntime = useCallback(
(
runtime: AgentRuntimeState | null,
previous = projectSupervisorRuntimeRef.current,
) => {
const nextRuntime = runtime
? normalizeAgentRuntimeState(runtime, previous)
: null;
const nextProjectPath = localProjectPathRef.current;
if (
gameChatOnly &&
nextProjectPath &&
nextRuntime?.runId &&
!isAgentRuntimeTerminalState(nextRuntime)
) {
gameChatObservedRunKeysRef.current.add(
`${nextProjectPath}\n${nextRuntime.runId}`,
);
}
projectSupervisorRuntimeRef.current = nextRuntime;
setProjectSupervisorRuntime(nextRuntime);
},
[gameChatOnly],
);
function updateProjectSupervisorResponseStream(
incoming: AgentRuntimeResponseStream | null | undefined,
@@ -946,7 +953,7 @@ export function App({
disposed = true;
cleanup?.();
};
}, []);
}, [updateProjectSupervisorRuntime]);
useEffect(() => {
const invoke = resolveTauriInvoke();
@@ -5149,6 +5156,8 @@ export function App({
}
}
executeChatAgentReplyRef.current = executeChatAgentReply;
useEffect(() => {
const latch = initialSupervisorMessageLatchRef.current;
if (!gameChatOnly || !latch.prompt || !localProject) {
@@ -5169,7 +5178,7 @@ export function App({
...current,
{ role: 'user', text: latch.prompt, runtimeOwned: true },
]);
void executeChatAgentReply(latch.prompt);
void executeChatAgentReplyRef.current(latch.prompt);
}, [chatAgentBusy, gameChatOnly, initialSupervisorMessage, localProject]);
async function handleProjectSupervisorToolAction(
@@ -1,3 +1,5 @@
/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its small rendering adapter for focused tests. */
export type LocalGamePreviewLike = {
status?: string | null;
url?: string | null;
@@ -1,3 +1,5 @@
/* eslint-disable react-refresh/only-export-components -- Testable game-chat presentation helpers share this focused view module. */
import { FolderOpen, Send, Settings } from 'lucide-react';
import type {
ComponentProps,
@@ -21,12 +23,12 @@ import type {
import {
formatAgentRuntimeEvent,
isAgentRuntimeTerminalState,
projectProfessionalAgentLabel,
projectNameFromPath,
projectProfessionalAgentLabel,
projectRuntimePlanProgress,
projectRuntimeVisibleCurrentWork,
projectSupervisorCollaboratingAgentRuntimes,
projectSupervisorChatRuntimeStatus,
projectSupervisorCollaboratingAgentRuntimes,
ProjectSupervisorRuntimeControls,
} from '../agent-runtime';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
@@ -111,7 +113,12 @@ function gameChatResultImageLabel(kind: string, path: string) {
}
function isGameChatResultImagePath(path: string) {
if (/[\\\u0000-\u001f\u007f]/u.test(path)) {
if (
Array.from(path).some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return character === '\\' || codePoint <= 0x1f || codePoint === 0x7f;
})
) {
return false;
}
const segments = path.split('/');
@@ -284,6 +284,7 @@ export function useProjectResourceCanvasLayout({
'update_local_project_resource_canvas_layout',
{
projectPath: scope.projectPath,
expectedProjectId: scope.projectId,
mode: scope.mode,
expectedRevision: persistedLayoutRef.current.revision,
positions: candidate.positions,
@@ -315,19 +316,21 @@ export function useProjectResourceCanvasLayout({
queued.scopeEpoch !== currentScope.epoch ||
queued.kind !== 'manual',
);
setNotice('布局已在其他窗口更新,请重新拖动');
const needsResourceSync = reconcileResourceCanvasLayout(
result.layout,
resourcesRef.current,
).changed;
const nextRetry =
intent.kind === 'resources' ? intent.conflictRetries + 1 : 0;
if (
const willRetryResourceSync =
needsResourceSync &&
nextRetry <= MAX_RESOURCE_SYNC_CONFLICT_RETRIES
) {
nextRetry <= MAX_RESOURCE_SYNC_CONFLICT_RETRIES;
if (willRetryResourceSync) {
enqueueResourceSyncRef.current(currentScope.epoch, nextRetry);
}
if (intent.kind === 'manual' || !willRetryResourceSync) {
setNotice('布局已在其他窗口更新,请重新拖动');
}
}
rebuildOptimisticLayout(currentScope.epoch);
})
@@ -370,6 +373,7 @@ export function useProjectResourceCanvasLayout({
};
initializedScopeEpochRef.current = null;
writeQueueRef.current = [];
activeWriteIntentRef.current = null;
};
}, []);
@@ -385,6 +389,7 @@ export function useProjectResourceCanvasLayout({
scopeRef.current = scope;
initializedScopeEpochRef.current = null;
writeQueueRef.current = [];
activeWriteIntentRef.current = null;
const initialFallback = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout(projectId, mode),
resourcesRef.current,
@@ -489,15 +494,27 @@ export function useProjectResourceCanvasLayout({
if (scope.key !== scopeKey) {
return;
}
const intent: ManualLayoutWriteIntent = {
kind: 'manual',
scopeEpoch: scope.epoch,
resourceId,
section,
x,
y,
};
writeQueueRef.current.push(intent);
const queuedIntent = writeQueueRef.current.find(
(intent): intent is ManualLayoutWriteIntent =>
intent.kind === 'manual' &&
intent.scopeEpoch === scope.epoch &&
intent.resourceId === resourceId &&
intent.section === section &&
intent !== activeWriteIntentRef.current,
);
if (queuedIntent) {
queuedIntent.x = x;
queuedIntent.y = y;
} else {
writeQueueRef.current.push({
kind: 'manual',
scopeEpoch: scope.epoch,
resourceId,
section,
x,
y,
});
}
applyLayout(
moveResourceCanvasPosition(
layoutRef.current,
@@ -65,6 +65,7 @@ describe('useProjectResourceCanvasLayout', () => {
let resolveRead: ((layout: ProjectResourceCanvasLayout) => void) | null =
null;
const updates: Array<{
expectedProjectId: string;
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
}> = [];
@@ -77,6 +78,7 @@ describe('useProjectResourceCanvasLayout', () => {
}
if (command === 'update_local_project_resource_canvas_layout') {
const input = args as {
expectedProjectId: string;
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
};
@@ -119,6 +121,7 @@ describe('useProjectResourceCanvasLayout', () => {
result.current.layout.positions.map(({ resourceId }) => resourceId),
).toEqual(expect.arrayContaining(['resource-a', 'resource-b']));
expect(updates[0]?.expectedRevision).toBe(1);
expect(updates[0]?.expectedProjectId).toBe(projectId);
expect(
updates[0]?.positions.some(
({ resourceId }) => resourceId === 'resource-b',
@@ -222,6 +225,89 @@ describe('useProjectResourceCanvasLayout', () => {
).toBe(true);
});
it('coalesces repeated queued drags for the same resource behind an in-flight CAS', async () => {
const resourceA = resource('resource-a');
const updates: Array<{
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
}> = [];
let resolveFirstUpdate:
| ((result: {
status: 'updated';
layout: ProjectResourceCanvasLayout;
}) => void)
| null = null;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 1, [
position('resource-a', 10, 20),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
const input = args as {
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
};
updates.push(structuredClone(input));
if (updates.length === 1) {
return await new Promise((resolve) => {
resolveFirstUpdate = resolve;
});
}
return {
status: 'updated',
layout: persistedLayout(
'dependency',
input.expectedRevision + 1,
structuredClone(input.positions),
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources: [resourceA],
}),
);
await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10));
act(() => result.current.commitPosition('resource-a', 'document', 100, 30));
await waitFor(() => expect(updates).toHaveLength(1));
act(() => {
for (let x = 200; x < 300; x += 1) {
result.current.commitPosition('resource-a', 'document', x, 40);
}
});
expect(updates).toHaveLength(1);
expect(result.current.layout.positions[0]?.x).toBe(299);
await act(async () => {
resolveFirstUpdate?.({
status: 'updated',
layout: persistedLayout(
'dependency',
2,
structuredClone(updates[0]!.positions),
),
});
await Promise.resolve();
});
await waitFor(() => expect(updates).toHaveLength(2));
expect(updates.map(({ expectedRevision }) => expectedRevision)).toEqual([
1, 2,
]);
expect(updates[1]?.positions[0]?.x).toBe(299);
await waitFor(() => expect(result.current.saving).toBe(false));
});
it('drops manual intents queued behind a CAS conflict', async () => {
const resourceA = resource('resource-a');
let updateCalls = 0;
@@ -332,7 +418,7 @@ describe('useProjectResourceCanvasLayout', () => {
expect(result.current.layout.positions[0]).toMatchObject({ x: 200, y: 50 });
});
it('waits for an old-scope write to settle before pumping the new scope queue', async () => {
it('does not let a stuck old-scope write block the new scope queue', async () => {
const resourceA = resource('resource-a');
const updates: Array<{
mode: ProjectResourceCanvasLayoutMode;
@@ -388,7 +474,11 @@ describe('useProjectResourceCanvasLayout', () => {
rerender({ mode: 'type' });
await waitFor(() => expect(result.current.layout.mode).toBe('type'));
act(() => result.current.commitPosition('resource-a', 'document', 300, 40));
expect(updates).toEqual([{ mode: 'dependency', expectedRevision: 1 }]);
await waitFor(() => expect(updates).toHaveLength(2));
expect(updates[1]).toEqual({ mode: 'type', expectedRevision: 5 });
await waitFor(() => expect(result.current.saving).toBe(false));
expect(result.current.layout.mode).toBe('type');
expect(result.current.layout.positions[0]?.x).toBe(300);
await act(async () => {
resolveDependencyUpdate?.({
@@ -400,9 +490,7 @@ describe('useProjectResourceCanvasLayout', () => {
await Promise.resolve();
});
await waitFor(() => expect(updates).toHaveLength(2));
expect(updates[1]).toEqual({ mode: 'type', expectedRevision: 5 });
await waitFor(() => expect(result.current.saving).toBe(false));
expect(updates).toHaveLength(2);
expect(result.current.layout.mode).toBe('type');
expect(result.current.layout.positions[0]?.x).toBe(300);
});
@@ -441,6 +529,7 @@ describe('useProjectResourceCanvasLayout', () => {
}),
);
await waitFor(() => expect(expectedRevisions).toHaveLength(3));
await waitFor(() => expect(result.current.saving).toBe(false));
expect(expectedRevisions).toEqual([1, 2, 3]);
expect(result.current.notice).toBe('布局已在其他窗口更新,请重新拖动');
@@ -209,6 +209,7 @@ type ReadProjectResourceCanvasLayoutInput = {
type UpdateProjectResourceCanvasLayoutInput = {
projectPath: string;
expectedProjectId: string;
mode: 'dependency' | 'type';
expectedRevision: number;
positions: ProjectResourceCanvasLayout['positions'];
@@ -226,8 +227,9 @@ type UpdateProjectResourceCanvasLayoutResult =
```
- 读取命令固定为 `read_local_project_resource_canvas_layout`,返回当前 mode 的完整布局;文件不存在时返回合成的 revision `0` 布局,不为只读操作创建目录或文件。
- 更新命令固定为 `update_local_project_resource_canvas_layout`。调用方不提交 `projectId / revision / updatedAt` 的权威新值;Tauri 必须先只读确认项目存在manifest 有效,随后获取系统锁并在锁内复核 `projectId`、重新读取当前布局,再生成新的 revision 与时间戳。不存在目录、普通非项目目录或损坏 manifest 均不得先创建 `.agent/workbench`、锁文件或布局文件。
- 更新命令固定为 `update_local_project_resource_canvas_layout`。调用方只提交当前已读取布局的 `expectedProjectId` 身份栅栏,不提交 `projectId / revision / updatedAt` 的权威新值;Tauri 必须先只读确认项目存在manifest 有效且 projectId 与栅栏一致,随后获取系统锁并在锁内复核 `projectId`、重新读取当前布局,再生成新的 revision 与时间戳。路径被其它窗口重建为新项目时,旧窗口必须在任何布局副作用前失败。不存在目录、普通非项目目录或损坏 manifest 均不得先创建 `.agent/workbench`、锁文件或布局文件。
- `expectedRevision` 与锁内 revision 相同才允许原子写入并返回 `updated`;不同时不得写文件,返回 `conflict` 和锁内最新完整布局。前端不得通过解析错误字符串识别 CAS 冲突。
- revision 使用 `u64` 且每次成功必须严格递增;当前值已经是 `u64::MAX` 时失败关闭并保持原文件不变,不得饱和后继续以相同 revision 返回成功。
- 项目无效、布局损坏、字段校验失败和文件系统错误继续作为安全、可理解的 Tauri command error 返回;错误不得包含配置、凭据或项目外绝对路径。
#### 5.2.4 前端布局与协调合同
@@ -239,7 +241,8 @@ type UpdateProjectResourceCanvasLayoutResult =
- 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。
- 窗口尺寸变化只改变可视范围和分区滚动边界,不回写、裁切或缩放持久坐标。当前客户端继续以 `1280×800` 横屏合同验收。
- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;项目或 mode 已切换后返回的旧异步结果必须丢弃。
- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。单窗口内全部手动拖动和资源自动协调写入使用同一 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision,不能用“最后请求获胜”跳过中间 CAS。
- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。同一 scope 内全部手动拖动和资源自动协调写入使用同一 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision,不能用“最后请求获胜”跳过中间 CAS。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。
- 某笔 CAS 在途期间,同一 scope 内对相同 `resourceId + section` 重复产生但尚未发送的拖动意图必须折叠为最后坐标;已经在途的请求不得取消,不同资源的顺序不得跨越。队列增长必须受当前资源与分区数量约束,不能随连续 pointer 事件无界累积。
- 用户拖动结束后先乐观更新,再立即提交一次 CAS。成功后以返回布局更新 revision;普通写入失败时恢复最近可信持久布局并提示“布局保存失败,已恢复上次布局”。
- CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,丢弃所有基于冲突前快照排队的手动拖动,不得自动重放本地旧坐标或静默覆盖另一窗口结果。资源自动协调可以基于冲突返回的新 revision 有界重试,单次资源签名最多追加 `2` 次,持续跨窗口写入时不得无限自旋。
- 缺少 Tauri bridge 的浏览器开发态可以保留当前会话内布局用于界面测试,但不得宣称已经持久保存。
@@ -20,7 +20,7 @@
- 背景:项目开发工作台当前只在 React 会话内保存同分类资源的一维拖拽顺序,项目切换或客户端重启后重建默认排列;工作台 PRD 虽已给出二维位置字段,但缺少落盘路径、坐标系、Tauri API、CAS、异常与安全边界,仍不足以直接编码。
- 决策:dependency 与 type 两套布局分别保存为项目内 `.agent/workbench/resource-layouts/dependency.json``type.json`,统一使用 `game-creator-resource-layout.v1``x / y` 是 section 内容 CSS 像素,revision 从缺文件时的 `0` 单调递增;新资源首次默认放置,任何已有坐标不因排序、筛选、模式切换或 resize 被自动覆盖。
- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout``update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。锁入口文件持久存在,Unix 以 `flock` 文件描述符、Windows 以不共享句柄持有互斥;应用不按 mtime / PID 猜测 stale、不删除锁文件,进程退出由操作系统释放。更新在创建锁目录前只读验证 manifest,锁内复核 projectId;无效根保持零 workbench 副作用。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或在途写;同一窗口的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。
- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout``update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。更新额外携带只读结果中的 `expectedProjectId` 身份栅栏,路径被重建为新项目时旧窗口在锁副作用前失败;revision 使用 checked increment,耗尽时保持原文件。锁入口文件持久存在,Unix 以 `flock` 文件描述符、Windows 以不共享句柄持有互斥;应用不按 mtime / PID 猜测 stale、不删除锁文件,进程退出由操作系统释放。更新在创建锁目录前只读验证 manifest,锁内复核 projectId;无效根保持零 workbench 副作用。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或同 scope 在途写;同 scope 的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。切换 scope 会释放旧活动槽,旧请求即使卡死也不能阻塞新 scope;同资源尚未发送的连续拖动折叠为最后坐标,已经在途的 CAS 不取消。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。
- 业务边界:布局是本地工作台 UI sidecar,不进入 manifest,不推进游戏项目 mutation revision,不使 Runtime verification 失效,不触发 Agent 权限,也不属于资产、Agent 产物、Git 或云端事实。本切片不包含关系线、资源替换、浮层位置、缩放 / 平移、搜索 / 筛选条件和当前 mode。
- 影响范围:`packages/shared` 与 Rust `shared-contracts` 的跨边界 DTO、AI 游戏创作 Tauri 项目持久层与命令、项目开发资源画布、定向 Rust / React 测试、工作台 PRD 和客户端实施计划。
- 验证方式:序列化与字段上限测试、缺文件 / 损坏 / 原子恢复 / 链接安全测试、同 revision 双写最多一个成功、两种 mode 跨重启独立恢复、新增资源不移动旧坐标、`1280×800` 横屏无页面级溢出,以及 `npm run agc:typecheck`、定向测试、`npm run check:encoding``git diff --check`
+10 -2
View File
@@ -3867,6 +3867,14 @@
- 现象:两个窗口基于同一 revision 保存资源布局时,正常测试看似只有一个成功;锁文件超过 stale 阈值或两个竞争者同时判断过期时,却可能各自删除 / 重建锁并同时进入 read-check-write,击穿“同 revision 最多一个成功”。无效绝对路径还会在 manifest 报错前遗留 `.agent/workbench/resource-layouts`
- 原因:`create_new` 只保证某一时刻创建文件原子,不保证“判断过期 → 删除 → 重建”整体原子;mtime 不能证明 owner 已退出,token 文本也不能阻止另一个竞争者删除新锁。先获取锁再读 manifest 又把目录创建副作用提前到了项目身份验证之前。
- 处理:锁文件作为持久入口永不由应用删除;Unix 用文件描述符持有 `flock(LOCK_EX | LOCK_NB)`Windows 用 `share_mode(0)` 独占句柄,Drop / 进程退出让操作系统释放锁。安全打开逐级拒绝符号链接 / reparse pointUnix 还核对 owner、硬链接数、inode 和 `0600`。更新先只读验证 manifest,再获取系统锁并在锁内复核 projectId;不存在根、非项目根损坏 manifest 都不能创建 workbench。
- 验证:必须覆盖活锁 mtime 被设为 epoch 后竞争者仍拿不到锁、释放后同一 inode 可重新获取、同 revision 并发双写仍恰好一个 updated / 一个 conflict以及三类无效根零 workbench 副作用。锁等待超时只能返回可重试错误,不得转为 stale 删除。
- 处理:锁文件作为持久入口永不由应用删除;Unix 用文件描述符持有 `flock(LOCK_EX | LOCK_NB)`Windows 用 `share_mode(0)` 独占句柄,Drop / 进程退出让操作系统释放锁。安全打开逐级拒绝符号链接 / reparse pointUnix 还核对 owner、硬链接数、inode 和 `0600`。更新携带只用于校验的 `expectedProjectId`先只读验证 manifest,再获取系统锁并在锁内复核 projectId;不存在根、非项目根损坏 manifest 和路径复用后的旧窗口都不能创建 workbench。revision 必须 checked increment,耗尽时不能饱和成功。
- 验证:必须覆盖活锁 mtime 被设为 epoch 后竞争者仍拿不到锁、释放后同一 inode 可重新获取、同 revision 并发双写仍恰好一个 updated / 一个 conflict,三类无效根和旧 projectId 零 workbench 副作用,以及 `u64::MAX` revision 保持原文件。锁等待超时只能返回可重试错误,不得转为 stale 删除。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs``docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
## 旧 scope 的卡死请求不能占住新资源画布队列(2026-07-30)
- 现象:用户在 dependency 布局保存尚未返回时切到 type 或另一个项目,新 scope 已完成读取且拖动已进入队列,但因为全局活动请求引用仍指向旧 scope,新的保存会无限等待旧请求结束。
- 原因:epoch 只阻止迟到响应覆盖新状态,不会自动释放前端单写者槽;把“不能取消已经发出的请求”误写成“所有后续 scope 都必须等待它”,会把一个网络或 IPC 卡死扩大到整个 Hook 生命周期。
- 处理:FIFO 和单写者只约束同一 `projectPath + projectId + mode` scope。切换 scope 或卸载时立即放弃旧活动槽并清空旧队列,旧 Promise 仍可在后台结束,但其结果由 epoch 丢弃,finally 也只能按意图身份清理自己,不能清掉新 scope 的活动请求。后端继续用 `expectedProjectId`、CAS revision 和系统锁仲裁已经发出的旧写入。同 scope 在途 CAS 不取消;其后相同资源与 section 的排队拖动只保留最后坐标,避免连续输入造成无界队列。
- 验证:让旧 mode 更新 Promise 永不先 resolve,切换 mode 后应立即发送并完成新 mode CAS;随后再 resolve 旧请求,新布局、saving 状态和请求数均不得变化。另以百次同资源拖动证明在途请求之后只追加一笔、坐标为最后一次输入。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts``apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts`
@@ -344,9 +344,9 @@ game-project/
实施顺序固定为:先同步 TypeScript / Rust DTO 与序列化测试,再实现 Tauri sidecar 读写和 CAS,随后接入前端纯模型、持久 Hook 与二维拖动,最后完成 Rust 安全测试、React 交互测试、跨重启 / 双窗口验收和文档状态回写。任何一步不得用 `localStorage`、manifest 字段或只在当前 React 会话有效的状态冒充项目持久化。
2026-07-30 前端并发与性能加固状态:首读、资源更新和拖动保存已拆成 scope epoch + 单写者 FIFO;定向 Hook 测试覆盖首读期间资源变化、在途手动 CAS 后资源协调、冲突清除排队拖动、旧 mode 迟到读取 / 写入、新 scope 队列唤醒和持续冲突有界停止。默认布局用 section 分组与二维占用索引替代逐 slot 全量扫描,dependency 使用按列单调游标,type 使用单调 slot 游标;`4096` 项双模式性能回归纳入前端测试,避免恢复到接近 `O(N³)` 的主线程阻塞实现。
2026-07-30 前端并发与性能加固状态:首读、资源更新和拖动保存已拆成 scope epoch + scope 内单写者 FIFO;定向 Hook 测试覆盖首读期间资源变化、在途手动 CAS 后资源协调、冲突清除排队拖动、旧 mode 迟到读取 / 写入、新 scope 不等待旧 scope 卡死请求、持续冲突有界停止,以及在途 CAS 后同资源连续拖动折叠为最后坐标。旧 scope 请求已经发出后不做不安全取消,但会释放前端活动槽并由 epoch 丢弃迟到响应;同 scope 的在途请求仍保持唯一。默认布局用 section 分组与二维占用索引替代逐 slot 全量扫描,dependency 使用按列单调游标,type 使用单调 slot 游标;`4096` 项双模式性能回归纳入前端测试,避免恢复到接近 `O(N³)` 的主线程阻塞实现。
2026-07-30 Rust 并发与零副作用加固状态:资源布局锁已由 `create_new + mtime stale 删除` 改为持久锁文件上的 Unix `flock` / Windows 独占句柄,活锁即使 mtime 很旧也不能被另一个写入者回收,释放后仍复用同一文件实例。更新命令在任何目录创建前先读取 manifest,锁内再次核对 projectId;不存在根、非项目根损坏 manifest 的回归测试均确认不产生 `.agent/workbench`
2026-07-30 Rust 并发与零副作用加固状态:资源布局锁已由 `create_new + mtime stale 删除` 改为持久锁文件上的 Unix `flock` / Windows 独占句柄,活锁即使 mtime 很旧也不能被另一个写入者回收,释放后仍复用同一文件实例。更新命令携带只用于校验的 `expectedProjectId`在任何目录创建前先读取 manifest 并拒绝旧项目窗口,锁内再次核对 projectId;不存在根、非项目根损坏 manifest 和路径重建后的旧窗口均不产生 `.agent/workbench`revision 使用 checked increment`u64::MAX` 时保持原文件并失败关闭,不能让饱和值击穿 CAS。
## 分阶段实施
@@ -351,15 +351,16 @@ async fn vector_engine_deadline_clips_stalled_attempt_and_prevents_retry() {
}
});
let started_at = Instant::now();
let settings = VectorEngineImageSettings {
let mut settings = VectorEngineImageSettings {
base_url: format!("http://{server_addr}/v1"),
api_key: "test-key".to_string(),
request_timeout_ms: 5_000,
request_deadline: Some(started_at + Duration::from_millis(150)),
request_deadline: None,
};
let http_client =
build_vector_engine_image_http_client(&settings).expect("client should build");
let started_at = Instant::now();
settings.request_deadline = Some(started_at + Duration::from_secs(1));
let error = create_vector_engine_image_generation(
&http_client,
@@ -379,9 +380,16 @@ async fn vector_engine_deadline_clips_stalled_attempt_and_prevents_retry() {
PlatformImageError::Request { timeout: true, .. }
));
assert!(
started_at.elapsed() < Duration::from_secs(1),
started_at.elapsed() < Duration::from_secs(3),
"attempt 应使用剩余 deadline,而不是完整配置 timeout"
);
tokio::time::timeout(Duration::from_secs(1), async {
while request_count.load(Ordering::SeqCst) == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("mock server should observe the single attempted request");
assert_eq!(request_count.load(Ordering::SeqCst), 1);
server.abort();
}
@@ -293,7 +293,9 @@ pub fn new_game_creation_app_seed_tasks() -> Vec<GameCreationAppTaskState> {
"game/game_design.md",
"assets/ui-prototype.png",
],
["核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图"],
[
"核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图",
],
),
task(
"balance-director",
@@ -1358,7 +1360,9 @@ mod tests {
);
assert_eq!(
design.acceptance_criteria,
["核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图"]
[
"核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图"
]
);
let art = manifest