From cec971c438772205524dd26d487f69ae1068fdeb Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 16 Sep 2026 19:53:23 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B1=80=E9=83=A8?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=B7=A5=E5=85=B7=E7=9A=84=E7=BC=BA=E9=99=B7?= =?UTF-8?q?=E5=92=8C=E5=81=B6=E5=8F=91=E9=94=81=E9=97=AE=E9=A2=98=20(#386)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/386 Co-authored-by: Linghong Co-committed-by: Linghong --- .../src-tauri/design-agent/tools.json | 2 +- .../src-tauri/src/agent/design_runtime.rs | 135 +++++++++++++++++- .../src-tauri/src/agent/design_tools.rs | 116 ++++++++++++++- .../shared-memory/decision-log.md | 6 + ...】策划Agent生产迁移与工作区浏览-2026-09-10.md | 4 +- 5 files changed, 249 insertions(+), 14 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json index c9c1bd6cf..ce086275f 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json @@ -2,7 +2,7 @@ {"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}}, - {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}}, + {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一。所有 edit 会一次性校验;任何失败都不修改文件,错误会列出各失败项及可唯一匹配的其余项。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index ebe6152b8..1a5a6e486 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -527,10 +527,6 @@ fn process_design_batch( let result = if uncertain { Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string()) } else { - let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "design.tool", - )?; execute_design_tool(root, resources, session, &call) }; let error = result @@ -1026,6 +1022,15 @@ pub(crate) async fn continue_design_agent_at( finish_design_command(root, resources, session, active, run, emit).await } +async fn recover_uncertain_design_batch( + root: &Path, + resources: &DesignResources, + session: DesignSession, + active: File, +) -> Result { + finish_design_command(root, resources, session, active, true, |_| {}).await +} + pub(crate) async fn decide_design_phase_at( root: &Path, resources: &DesignResources, @@ -1058,7 +1063,8 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> { } #[tauri::command] -pub(crate) fn hydrate_design_agent_session( +pub(crate) async fn hydrate_design_agent_session( + app: tauri::AppHandle, project_path: String, ) -> Result, String> { let root = Path::new(project_path.trim()); @@ -1084,8 +1090,33 @@ pub(crate) fn hydrate_design_agent_session( if session.project_id != project_id { return Err("策划会话与当前项目不匹配".into()); } - let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?; - Ok(Some(design_view(&session, active.is_none()))) + let Some(active) = + try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)? + else { + return Ok(Some(design_view(&session, true))); + }; + if design_session_has_uncertain_batch(&session) { + let resources = DesignResources::new(resolve_design_resources_root(&app)?)?; + let view = recover_uncertain_design_batch(root, &resources, session, active).await?; + return Ok(Some(view)); + } + drop(active); + Ok(Some(design_view(&session, false))) +} + +fn design_session_has_uncertain_batch(session: &DesignSession) -> bool { + let Some(batch) = session.pending_batch.as_ref() else { + return false; + }; + if !batch.executing || batch.cursor >= batch.calls.len() { + return false; + } + let call_id = batch.calls[batch.cursor].id.as_str(); + session.turn.as_ref().is_some_and(|turn| turn.pending) + && !session.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some(call_id) + }) } fn design_session_error_is_recoverable(error: &str) -> bool { @@ -1958,4 +1989,94 @@ mod tests { .any(|message| message.text.contains("重试后继续"))); assert!(next.session.last_error.is_none()); } + + #[tokio::test(flavor = "current_thread")] + async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() { + let (_temp, root, resources) = init_design_project(); + execute_design_file_tool( + &root, + "write_file", + &json!({"path":"project/00_concept/design.md","content":"概念"}), + ) + .expect("write concept"); + let mut session = new_design_session("design-fake", "quality"); + let call = platform_llm::LlmToolCall { + id: "interrupted-call".into(), + name: "patch_file".into(), + arguments: json!({ + "path":"project/00_concept/design.md", + "old_text":"概念", + "new_text":"概念设计" + }) + .to_string(), + }; + session.history.push(json!({ + "type":"function_call", + "call_id":call.id, + "name":call.name, + "arguments":call.arguments, + })); + session.messages = vec![DesignMessage { + id: "turn:user".into(), + role: "user".into(), + text: "继续".into(), + }]; + session.turn = Some(DesignTurn { + id: "turn-recovery".into(), + pending: true, + request_index: 0, + attempt: 0, + }); + session.pending_batch = Some(DesignToolBatch { + calls: vec![call], + cursor: 0, + executing: true, + }); + assert!(design_session_has_uncertain_batch(&session)); + write_design_session(&root, &session).expect("write interrupted session"); + + let _fake = fake_provider::install( + vec![Ok(fake_response( + "recovered-after-uncertain-tool", + "已读取文件并确认。", + Vec::new(), + ))], + 0, + ); + let view = recover_uncertain_design_batch(&root, &resources, session, { + try_open_game_creator_agent_runtime_task_lock_file( + &root, + ".agent/design-agent/active.lock", + ) + .expect("open active lock") + .expect("active lock is free") + }) + .await + .expect("recover uncertain batch"); + + assert!(!view.running); + assert!(view.session.last_error.is_none()); + let restored = read_design_session(&root) + .expect("read restored") + .expect("session"); + assert!(restored.pending_batch.is_none()); + assert!(!restored.turn.expect("turn").pending); + assert!(restored.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some("interrupted-call") + && item + .get("output") + .and_then(Value::as_str) + .is_some_and(|output| output.contains("执行结果未保存")) + })); + assert!(restored.history.iter().any(|item| { + item.get("role").and_then(Value::as_str) == Some("assistant") + && item.get("content").is_some() + })); + assert!( + fs::read_to_string(root.join("design_artifacts/project/00_concept/design.md")) + .expect("read target") + == "概念" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index 5221030b1..730d68004 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -321,15 +321,31 @@ pub(crate) fn execute_design_file_tool( }) .collect::>(); let mut matches = Vec::new(); + let mut edit_errors = Vec::new(); + let mut valid_edits = 0; for (index, (old, new)) in normalized.iter().enumerate() { + if old == new { + edit_errors.push(format!( + "edits[{index}] new_text 与 old_text 相同,不会产生修改" + )); + continue; + } let count = content.matches(old).count(); if count == 0 { - return Err(format!("edits[{index}] 原文未找到:{display}")); + edit_errors.push(format!( + "edits[{index}] 原文未找到:{}{}", + display, + design_patch_location_hint(&content, old) + )); + continue; } if count != 1 { - return Err(format!( - "edits[{index}] 原文匹配 {count} 处,必须唯一:{display}" + let start = content.find(old).expect("count checked"); + let line = design_patch_line_number(&content, start); + edit_errors.push(format!( + "edits[{index}] 原文匹配 {count} 处,必须唯一;首次位于第 {line} 行" )); + continue; } let start = content.find(old).expect("count checked"); let end = start + old.len(); @@ -337,13 +353,33 @@ pub(crate) fn execute_design_file_tool( .iter() .find(|(_, other_start, other_end)| start < *other_end && *other_start < end) { - return Err(format!( - "edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}" + edit_errors.push(format!( + "edits[{index}] 与 edits[{other_index}] 修改范围重叠;请合并为一个 edit 或缩短 old_text" )); + continue; } matches.push((index, start, end)); + valid_edits += 1; let _ = new; } + if !edit_errors.is_empty() { + let shown = edit_errors.len().min(4); + let mut details = edit_errors[..shown].to_vec(); + if shown < edit_errors.len() { + details.push(format!( + "另有 {} 个 edit 校验失败(详情省略)", + edit_errors.len() - shown + )); + } + if valid_edits > 0 { + details.push(format!( + "其余 {valid_edits} 个 edit 当前可唯一匹配;本次未写入文件" + )); + } else { + details.push("本次未写入文件".to_string()); + } + return Err(details.join("\n")); + } let mut updated = content.clone(); for (index, start, end) in matches.into_iter().rev() { let (_, new) = &normalized[index]; @@ -396,6 +432,60 @@ pub(crate) fn execute_design_file_tool( } } +fn design_patch_line_number(content: &str, start: usize) -> usize { + 1 + content[..start] + .bytes() + .filter(|byte| *byte == b'\n') + .count() +} + +fn design_patch_visible_line(line: &str) -> String { + line.replace('\t', "\\t").chars().take(180).collect() +} + +fn design_patch_location_hint(content: &str, old: &str) -> String { + let Some(anchor) = old.lines().map(str::trim).find(|line| !line.is_empty()) else { + return String::new(); + }; + + let mut candidates = content + .lines() + .enumerate() + .filter(|(_, line)| line.trim() == anchor) + .map(|(index, line)| (index + 1, line)) + .collect::>(); + if candidates.is_empty() { + let token = anchor.split_whitespace().find(|token| token.len() >= 3); + if let Some(token) = token { + candidates = content + .lines() + .enumerate() + .filter(|(_, line)| line.trim().contains(token)) + .map(|(index, line)| (index + 1, line)) + .collect(); + } + } + if candidates.is_empty() { + return format!( + ";未找到与 old_text 首个非空行相似的行(当前文件约 {} 行)", + content.lines().count() + ); + } + + let details = candidates + .iter() + .take(2) + .map(|(line, text)| format!("第 {line} 行:{}", design_patch_visible_line(text))) + .collect::>() + .join(";"); + let suffix = if candidates.len() > 2 { + format!("等 {} 处", candidates.len()) + } else { + String::new() + }; + format!(";old_text 首个非空行可能对应 {details}{suffix}(tab 显示为 \\t)") +} + pub(crate) fn list_design_workspace_files( root: &Path, ) -> Result, String> { @@ -693,6 +783,22 @@ mod tests { ) .expect_err("escape"); assert!(escaped.contains("路径")); + let mismatch = execute_design_file_tool( + root, + "patch_file", + &json!({ + "path":"notes/design.md", + "edits":[ + {"old_text":" 游戏设计","new_text":"游戏概念"}, + {"old_text":"设计","new_text":"方案"} + ] + }), + ) + .expect_err("report all patch failures"); + assert!(mismatch.contains("edits[0] 原文未找到")); + assert!(mismatch.contains("第 1 行:游戏设计")); + assert!(mismatch.contains("其余 1 个 edit 当前可唯一匹配")); + assert!(mismatch.contains("本次未写入文件")); let patched = execute_design_file_tool( root, "patch_file", diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0ba04d1c5..ccd59c705 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,6 +2,12 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 +## 2026-09-16 策划 Agent 工具执行退出项目级写锁并自动接续中断批次 + +- 背景:策划 Agent 每个 `read_file` / `write_file` / `patch_file` 工具都在执行前竞争全局项目写锁,但同一会话已由 `.agent/design-agent/active.lock` 串行化,工具目标又限定在 `design_artifacts`;项目锁既不覆盖「工具 + 会话 checkpoint」事务,还把进程中断时的 `executing=true` 不确定窗口扩大到等锁与工具执行全程。真机项目出现 `pendingBatch.executing=true`、`function_call` 无配对 output、UI 只显示工作中且无错误的状态。 +- 决策:单次策划工具不再竞争项目级写锁,只保留策划命令锁与既有原子写入;GameAgent / DirectProject 的公共项目锁实现与调用不变。重开项目 hydrate 时,若命令锁可获取且当前批次处于 `executing=true`、当前 call 无 output,则自动续跑原回合:为该 call 补写「执行结果未保存」的工具错误、跳过剩余调用并交回 Provider 自愈;不得重放文件副作用,也不要求用户手动重试。 +- 验证:新增定向用例证明中断批次自动补齐工具 output、收到后续 assistant 回复、清空 pendingBatch 并结束原 turn,同时目标文件保持未修改(未重放 `patch_file`);策划 Runtime 定向 14 条、策划工具 3 条通过,`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。 +- 关联文档:[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 ## 2026-09-16 AGC 同 AppData 多窗口共享 Agent Runner diff --git a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md index ce891e8fc..36b5d19a4 100644 --- a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md +++ b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md @@ -132,6 +132,8 @@ Runtime 不维护文档版本号,不解析文档版本,不提供版本回退 一轮有多个工具调用时沿用正常工具执行循环。澄清或审批进入等待后,不继续请求 Provider,也不执行同批剩余文件操作;未执行调用明确记录为因等待用户而未执行,不伪造成功结果。恢复历史必须保持工具调用与结果配对,避免出现缺少 tool output 的协议错误。这属于协议与暂停处理,不引入同轮调用次数门禁。 +单次策划工具不再竞争项目级写锁;策划命令锁与会话原子写入已保证同一会话内工具按批次顺序执行。若进程在工具执行标记与结果落盘之间中断,重开项目时的只读 hydrate 必须在拿到策划命令锁后自动续跑原回合,为不确定调用补写“执行结果未保存”的工具错误、跳过剩余调用,并把错误交回 Provider 自愈;不得重放文件副作用,也不要求用户手动恢复。 + 迁移工具集合: ```text @@ -150,7 +152,7 @@ get_workflow_status 工具使用相对工作区路径。工具执行结果继续通过 Runtime 统一记录和展示,但不向 Agent 暴露宿主绝对路径。 -`patch_file` 保留原型按唯一原文匹配修改的语义、换行归一化和缺文件错误。正常工作区写入与删除不逐次请求用户审批;阶段审批不能被复用为文件操作许可。 +`patch_file` 保留原型按唯一原文匹配、范围不重叠、全部通过才原子写入的语义、换行归一化和缺文件错误。批量 edits 会一次性完成全部校验,并把未找到、多处匹配、重叠等失败项汇总返回;未找到时同时给出候选行号和可见化缩进提示,帮助 Provider 基于当前文件修正锚点。正常工作区写入与删除不逐次请求用户审批;阶段审批不能被复用为文件操作许可。 `list_resources` 一次返回完整逻辑分类、资源 ID、标题和简介;`read_resource` 按一个资源 ID 读取一个文件。资源描述不增加 `required=true/false` 分类,也不增加引导同轮多次调用的说明。 From 1e434e0cb5e2debfffb55457af8dca86c6a34085 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 16 Sep 2026 21:17:30 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E6=96=87=E6=A1=A3=E8=AE=B0=E5=BD=95=20CI?= =?UTF-8?q?=20=E5=AE=BF=E4=B8=BB=20CPU=20=E4=B8=8A=E9=99=90=EF=BC=9AJenkin?= =?UTF-8?q?s=2016=20=E6=A0=B8=20/=20Gitea=20Actions=20runner=2012=20?= =?UTF-8?q?=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在【开发运维】本地开发验证与生产运维新增「CI 宿主 CPU 上限」小节,写明 jenkins.service CPUQuota=1600% 与 gitea-stack runner cpus=12.0 的生效位置、核验命令和回滚方式 - 在 shared-memory/decision-log.md 补记该资源上限决策的背景、边界与验证证据 --- docs/project-memory/shared-memory/decision-log.md | 8 ++++++++ docs/【开发运维】本地开发验证与生产运维-2026-05-15.md | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ccd59c705..a12a05be6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8804,3 +8804,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:采用后台继续运行语义。Direct 回合由进程内项目身份锁持有,页面离开不取消;重进项目通过活动回合只读快照与 Thread Manager bootstrap/consume 恢复忙碌态和进度。左上角面板复用同一快照列出正在运行的 Direct 项目并支持进入。 - 边界:快照不写项目文件、不进入公共 API、不跨应用重启恢复;读取失败保留上一份结果并单独提示,不改写成权限或审批失败。身份锁排他性、付费身份和项目写锁不变。 + +## 2026-09-16 CI 宿主 CPU 上限:Jenkins 16 核 / Gitea Actions runner 12 核 + +- 背景:`genarrative-station`(32 逻辑核)上 Jenkins Built-In Node 与 Gitea Actions runner 共用同一宿主。Jenkins `jenkins.service` 原先没有任何 CPU 限制(`cpu.max=max`),构建期 Web / Api / Stdb 三分支并行(Vitest 8 线程 + 两次默认 32 job 的 cargo)把整机顶到 80%~95%;`gitea-runner` 容器 `--cpus=24`(75%)在 push 触发的 CI 波峰里实测峰值 24.8~25.3 核,是同一时间窗里更大的单一消耗方。 +- 决策:两路 CI 都设硬上限。Jenkins 侧 `systemctl set-property jenkins.service CPUQuota=1600%`(16 核 / 50%,覆盖 Built-In Node 上所有子构建,立即生效、无需重启,drop-in 落 `/etc/systemd/system.control/jenkins.service.d/50-CPUQuota.conf`)。runner 侧把 `/opt/gitea-stack/compose.yml` 的 `cpus` 由 `"24.0"` 改为 `"12.0"`(12 核 / 37.5%),并用 `docker update --cpus=12 gitea-runner` 让运行中的容器立即生效,不重建容器、不中断在跑 job。 +- 边界:Deploy 阶段在远端 dev / release agent 执行,不受该上限约束。调整只动这两处:`systemctl set-property / revert jenkins.service`、`docker update --cpus= gitea-runner` 加同步 compose(备份 `/opt/gitea-stack/compose.yml.bak-<时间戳>`)。 +- 验证:限速后 `Genarrative-Full-Build-And-Deploy` #289 / #290 SUCCESS;采样期 Jenkins 峰值 10.2~10.5 核、限流不足 2s(可忽略),runner 峰值 12.07 核且持续出现 throttling,整机回落到 2.6%~19.8%。 +- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index e036f5205..0d1af4ab8 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -670,6 +670,12 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. `Genarrative-Web-Build` 打包 `web.tar.gz` 前、`Genarrative-Web-Deploy` 解包后都会把 Web 静态目录规范为目录 `755`、文件 `644`。如果前端页面能打开但 public 图片、字体或音频返回 `403 Forbidden`,优先检查当前 `/srv/genarrative/web` 指向的 release 中对应文件权限是否被异常归档为 `600`,临时恢复可对该 release 的 `web` 目录执行目录 `755`、文件 `644` 的权限修正。 +### CI 宿主 CPU 上限(Jenkins 16 核 / Gitea Actions runner 12 核) + +`genarrative-station` 上 Jenkins Built-In Node 与 Gitea Actions runner 容器共用同一台 32 逻辑核宿主机,两路 CI 都必须有硬上限,避免构建期把机器顶满、让交互用户卡顿。Jenkins 固定 16 核(50%):在宿主执行 `systemctl set-property jenkins.service CPUQuota=1600%`,立即生效且不需要重启 Jenkins,drop-in 落在 `/etc/systemd/system.control/jenkins.service.d/50-CPUQuota.conf`;该配额覆盖 Built-In Node 上所有子构建(Web / Api / Stdb 的 `npm ci`、Vitest、`tsc`、`cargo` 都跑在这台机器上),Deploy 阶段在远端 `genarrative-dev-deploy` / `genarrative-release-deploy` agent 执行,不受该上限约束。Gitea Actions runner 固定 12 核(37.5%):`/opt/gitea-stack/compose.yml` 的 `runner.cpus` 为 `"12.0"`,调整运行中的容器用 `docker update --cpus=12 gitea-runner`(不重建容器、不中断在跑 job);需要让容器配置与 compose 完全一致时,先确认 Gitea 没有 `in_progress` run,再 `cd /opt/gitea-stack && docker compose up -d runner`。 + +核验与回滚:`cat /sys/fs/cgroup/system.slice/jenkins.service/cpu.max` 期望 `1600000 100000`,`docker inspect gitea-runner --format '{{.HostConfig.NanoCpus}}'` 期望 `12000000000`,`cat /sys/fs/cgroup/system.slice/docker-.scope/cpu.max` 期望 `1200000 100000`。两处 cgroup 的 `cpu.stat` 里 `nr_throttled` / `throttled_usec` 持续增长说明工作负载已经顶到上限,属预期而不是故障。放宽或回滚用 `sudo systemctl set-property jenkins.service CPUQuota=%`、`sudo systemctl revert jenkins.service`、`docker update --cpus= gitea-runner`,并同步 `/opt/gitea-stack/compose.yml`(改前先备份该文件)。 + ## 维护模式只拦截公网流量 Nginx 与 Pingora 在维护 marker 存在时对内网来源绕过整站维护闸,主站页面与静态资源、普通 API、后台页面与 `/admin/api/**`、SpacetimeDB 路由均按非维护状态继续处理;公网应用主站、普通 API、后台和 SpacetimeDB 路由继续返回维护响应。内网范围为 IPv4 loopback / RFC1918 / link-local 和 IPv6 loopback / ULA / link-local。Nginx 只按 TCP `$remote_addr` 判定;Pingora 只按 TCP peer 判定,peer 为 loopback 的同机 Nginx 时才读取 Nginx 强制覆盖的 `X-Real-IP`,绝不能把客户端可伪造的 `X-Forwarded-For` 用作维护放行依据。应用本身的登录、管理员鉴权和其它业务鉴权不变。 From 362edcc49d2f2191cbf6e1fd29f92eeda1cf2188 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 16 Sep 2026 12:53:24 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=81=B6=E5=8F=91=E8=B6=85=E6=97=B6=E4=B8=8E?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct 活动回合读取失败后的重试定时器随组件卸载清理,避免测试环境销毁后访问 window。 后台素材查询大量缩略图测试合并可见性触发,并补充独立超时预算。 创作首页活动图与资源卡预览调度重载用例分别补充独立超时预算。 清单快照测试补齐 Direct 活动回合 IPC mock,避免未预期命令进入重试。 --- .../pages/AdminEditorAssetQueryPage.test.tsx | 28 ++++++++++++++++--- .../agent-runtime/directActiveTurns.ts | 17 +++++++---- .../appSurface/project-development.suite.ts | 2 +- .../workspaceLauncherManifestMerge.test.tsx | 2 ++ .../CreationLandingView.test.tsx | 2 +- 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index f262b8254..0feeb767d 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -38,6 +38,7 @@ vi.mock('../api/adminApiClient', () => ({ interface MockIntersectionObserverController { enter: (target: Element) => void; + enterAll: (targets: Element[]) => void; isObserved: (target: Element) => boolean; } @@ -106,6 +107,25 @@ function installIntersectionObserverMock(): MockIntersectionObserverController { ); }); }, + enterAll(targets) { + act(() => { + for (const target of targets) { + const record = observed.get(target); + if (!record) { + throw new Error('目标缩略图尚未进入 IntersectionObserver'); + } + record.callback( + [ + { + isIntersecting: true, + target, + } as IntersectionObserverEntry, + ], + record.observer, + ); + } + }); + }, isObserved(target) { return observed.has(target); }, @@ -753,10 +773,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as const thumbnails = entries.map((entry) => thumbnailElementForLabel(entry.label), ); - thumbnails.forEach((thumbnail) => { + for (const thumbnail of thumbnails) { expect(observer.isObserved(thumbnail)).toBe(true); - observer.enter(thumbnail); - }); + } + observer.enterAll(thumbnails); await act(async () => { await Promise.resolve(); }); @@ -776,7 +796,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as await vi.advanceTimersByTimeAsync(200); }); expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105); -}); +}, 10_000); test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => { const observer = installIntersectionObserverMock(); diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts index 0eadbeacd..58401f52a 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -38,11 +38,16 @@ export function useDirectActiveTurns({ const [snapshotReadFailed, setSnapshotReadFailed] = useState(false); const mountedRef = useRef(true); const inFlightRef = useRef | null>(null); + const retryTimerRef = useRef(null); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; + if (retryTimerRef.current !== null) { + window.clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } }; }, []); @@ -73,12 +78,12 @@ export function useDirectActiveTurns({ return; } catch { if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) { - await new Promise((resolve) => - window.setTimeout( - resolve, - DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt, - ), - ); + await new Promise((resolve) => { + retryTimerRef.current = window.setTimeout(() => { + retryTimerRef.current = null; + resolve(); + }, DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt); + }); } } } diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 8ca03bfe5..51e004f20 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -3350,7 +3350,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(URL.createObjectURL).toHaveBeenCalledTimes( objectUrlCountBeforeLateResult, ); - }); + }, 20_000); it('renders immutable manifest versions, their parent graph, and bound asset highlights', async () => { const manifest = createGameCreationAppManifest( diff --git a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx index 28dde6e59..5ea530842 100644 --- a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx @@ -106,6 +106,8 @@ function installInvokeMock() { return { revision: HELD_REVISION }; case 'get_design_agent_runtime_mode': return null; + case 'list_game_creator_direct_active_turns': + return []; case 'read_project_permission_policy': return { projectPath: PROJECT_PATH, diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index 1785dad1d..d1f0f2b4e 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -1351,7 +1351,7 @@ describe('CreationLandingView', () => { `/api/assets/read-url?objectKey=${encodeURIComponent(campaignObjectKey)}`, expect.objectContaining({ method: 'GET' }), ); - }); + }, 10_000); it('loads more featured resources when the list reaches the end', async () => { const observers = installIntersectionObserverMock(); From 79b153e3fa60a0b77ea9f12a50f43e67beb4fc9d Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 16 Sep 2026 13:24:14 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8DDirect=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E9=87=8D=E8=AF=95=E7=B1=BB=E5=9E=8B=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重试等待 Promise 显式声明为 void,修复 shell typecheck 与原生构建失败。 补充卸载后取消读取重试的回归测试。 --- .../agent-runtime/directActiveTurns.ts | 2 +- .../tests/directActiveTurns.test.tsx | 39 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts index 58401f52a..f859284e1 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -78,7 +78,7 @@ export function useDirectActiveTurns({ return; } catch { if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) { - await new Promise((resolve) => { + await new Promise((resolve) => { retryTimerRef.current = window.setTimeout(() => { retryTimerRef.current = null; resolve(); diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx index 5a000bf39..71fc665ef 100644 --- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -1,12 +1,49 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { + act, + cleanup, + fireEvent, + render, + renderHook, + screen, +} from '@testing-library/react'; import { afterEach, expect, it, vi } from 'vitest'; +import { useDirectActiveTurns } from '../src/features/agent-runtime/directActiveTurns'; import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel'; afterEach(() => cleanup()); +it('读取失败后的重试定时器会在卸载后清理', async () => { + vi.useFakeTimers(); + const invoke = vi.fn(async () => { + throw new Error('temporarily unavailable'); + }); + const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout'); + + try { + const { unmount } = renderHook(() => + useDirectActiveTurns({ invoke, enabled: true }), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(invoke).toHaveBeenCalledTimes(1); + + unmount(); + expect(clearTimeoutSpy).toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + expect(invoke).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + clearTimeoutSpy.mockRestore(); + } +}); + it('按开始时间展示正在运行的项目并支持进入项目', () => { const onOpenProject = vi.fn(); render(