完成资源画布竞态审计与最终门禁
增加 expectedProjectId 身份栅栏并在写入关键阶段复核项目身份 在 revision 耗尽时失败关闭并保持乐观锁 CAS 契约 折叠同资源连续拖动并解除旧 scope 请求对新项目的阻塞 补齐多窗口并发、异步迟到响应和极端边界回归测试 修复目标分支格式、前端门禁及负载敏感测试时序 同步更新资源画布契约、决策记录和竞态风险说明
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+10
-3
@@ -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('/');
|
||||
|
||||
+30
-13
@@ -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`。
|
||||
|
||||
@@ -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 point,Unix 还核对 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 point,Unix 还核对 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
|
||||
|
||||
Reference in New Issue
Block a user