Merge remote-tracking branch 'origin/master' into fix/wrong-report
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m47s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m35s
Project CI / Backend tests (pull_request) Successful in 5m5s
Project CI / Native shell tests (pull_request) Successful in 6m7s
Project CI / Frontend tests (pull_request) Successful in 2m19s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m55s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m55s
Project CI / Repository checks (pull_request) Successful in 2m32s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 12m54s

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	docs/project-memory/shared-memory/pitfalls.md
This commit is contained in:
2026-10-02 14:36:32 +08:00
8 changed files with 129 additions and 70 deletions
@@ -17,6 +17,18 @@ const CLAUDE_NODE_ENV: &str = "GENARRATIVE_CLAUDE_NODE";
const CLAUDE_CODE_OUTPUT_MAX_BYTES: usize = 8 * 1024 * 1024;
const CLAUDE_CODE_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024;
const CLAUDE_CODE_MODEL_MAX_CHARS: usize = 200;
/// 超时收口时给 sidecar stderr 的排空窗口。
///
/// sidecar 自己还活着时 stderr 永远等不到 EOF(Claude Code CLI 正在网关侧重试),
/// 没有上限就会让超时分支自己挂住:这一轮既写不出终态,也留不下任何诊断
/// (2026-10-02 实测:回合 4 分钟不结束、应用日志一行不写)。
const CLAUDE_CODE_STDERR_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
/// sidecar stdout 静默多久就写一条"还没收到事件"的应用日志。
///
/// Claude Code CLI 在网关侧失败时会自己退避重试,这段时间**一行事件都没有**;
/// 不留痕的话用户与离线诊断只能看到一个卡住的回合(真实案例:ECONNRESET 重试,
/// 每轮 ~60 秒,共 10 轮)。
const CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT: Duration = Duration::from_secs(30);
struct ActiveClaudeCodeTurn {
client_turn_id: String,
@@ -216,11 +228,26 @@ async fn run_sidecar_turn(
let mut lines = BufReader::new(stdout).lines();
let mut events = Vec::new();
let mut terminal = None;
while let Some(line) = lines
.next_line()
.await
.map_err(|error| format!("读取 Claude Agent SDK sidecar 输出失败:{error}"))?
{
let mut idle_rounds = 0_u64;
loop {
let line =
match tokio::time::timeout(CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT, lines.next_line())
.await
{
Ok(Ok(Some(line))) => line,
Ok(Ok(None)) => break,
Ok(Err(error)) => {
return Err(format!("读取 Claude Agent SDK sidecar 输出失败:{error}"));
}
Err(_) => {
idle_rounds += 1;
app_log!(
"agent.direct_codex.claude_sidecar_idle idleSeconds={}",
CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT.as_secs() * idle_rounds
);
continue;
}
};
if line.trim().is_empty() {
continue;
}
@@ -237,6 +264,7 @@ async fn run_sidecar_turn(
break;
}
Some("error") => {
kill_claude_code_process_tree(pid);
return Err(value
.get("message")
.and_then(serde_json::Value::as_str)
@@ -260,13 +288,19 @@ async fn run_sidecar_turn(
{
Ok(result) => result?,
Err(_) => {
let detail = stderr_task
.await
.ok()
.and_then(Result::ok)
.and_then(|bytes| String::from_utf8(bytes).ok())
// 先杀进程树:不杀的话 sidecar(连同它守护的 Claude Code CLI)会一直活着,
// stderr 等不到 EOF,这一轮就永远出不了终态。
kill_claude_code_process_tree(pid);
let stderr_detail =
match tokio::time::timeout(CLAUDE_CODE_STDERR_DRAIN_TIMEOUT, stderr_task).await {
Ok(Ok(Ok(bytes))) => String::from_utf8(bytes).ok(),
_ => None,
}
.map(|value| value.trim().chars().take(512).collect::<String>())
.filter(|value| !value.is_empty())
// 断开 stdout 之后 sidecar 自己会抛 EPIPE;那是收尾噪声,不是失败原因。
.filter(|value| !value.contains("EPIPE"));
let detail = stderr_detail
.map(|value| format!(":{value}"))
.unwrap_or_default();
return Err(format!("Claude Agent SDK sidecar 回合超时{detail}"));
@@ -732,7 +766,9 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
client_turn_id: Option<&str>,
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
) -> Result<String, String> {
direct_turn_trace("claude-executor-enter");
let (mcp_url, mcp_token) = start_external_mcp_loopback(root, llm.web_search_enabled).await?;
direct_turn_trace("claude-mcp-loopback-ready");
let _mcp_guard = ExternalMcpStopGuard;
let home = crate::project::resolve_local_project_path(root, ".agent/runtime/claude-code/home")?;
std::fs::create_dir_all(&home)
@@ -742,6 +778,7 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
.lock()
.ok()
.and_then(|sessions| sessions.get(&session_key).cloned());
direct_turn_trace("claude-sidecar-start");
let payload = serde_json::json!({
"type": "turn",
"id": client_turn_id.unwrap_or("claude-direct-turn"),
@@ -769,6 +806,7 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
client_turn_id,
)
.await?;
direct_turn_trace("claude-sidecar-done");
if let Some(session_id) = sidecar
.result
.get("session_id")
@@ -785,6 +823,7 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
.filter_map(|event| serde_json::to_string(&event).ok())
.collect::<Vec<_>>();
stream.push(String::new());
direct_turn_trace("claude-parse-start");
parse_direct_stream_result(stream.join("\n").as_bytes(), observer)
}
@@ -4294,6 +4294,20 @@ pub(crate) fn check_direct_turn_preconditions(
.map_err(|detail| DirectTurnError::InputRejected { detail })?;
Ok(())
}
/// Direct 回合在 `direct_execution::begin` 之后的分段 trace。
///
/// 起因:2026-10-02 连续四轮回合都停在 `begin` 之后——账本 `phase=working`、
/// `nextSequence=0`、`active={}`、没有任何错误 sidecar、stderr 无 panic、连
/// `agent.direct_codex.*` 都没有。窗口就夹在 `begin` 与执行器调用之间,需要知道
/// 具体停在哪一步。测试态不写,避免污染本机 AppData。
#[cfg(not(test))]
pub(crate) fn direct_turn_trace(stage: &str) {
app_log!("agent.direct_turn.trace stage={stage}");
}
#[cfg(test)]
pub(crate) fn direct_turn_trace(_stage: &str) {}
pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root: &Path,
prompt: &str,
@@ -4401,6 +4415,7 @@ async fn run_direct_game_creator_turn_inner(
})?;
let execution_session = execution_guard.session();
execution_session.set_analytics_capture(capture.clone());
direct_turn_trace("session-ready");
let started = execution_session
.newly_accepted
.then(std::time::Instant::now);
@@ -4411,6 +4426,7 @@ async fn run_direct_game_creator_turn_inner(
}
}
}
direct_turn_trace("run-analytics-accepted");
// 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。
let result: Result<String, DirectTurnError> = async {
if let Some(report) = super::direct_delivery::terminal_report(&execution_session) {
@@ -4444,6 +4460,7 @@ async fn run_direct_game_creator_turn_inner(
);
}
}
direct_turn_trace("goal-analytics-accepted");
emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息");
if let Some(emitter) = turn_emitter {
emitter.emit("running", Some("preparing"));
@@ -4460,6 +4477,7 @@ async fn run_direct_game_creator_turn_inner(
DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error)
},
)?;
direct_turn_trace("system-prompt-ready");
// 三维请求:把"自选三维技术栈、解除 Phaser 固定约束"的合同放在系统提示最前,
// 避免被长度上限截断,也不阻断任何工具。
let engine_contract = direct_engine_three_dimensional_contract(root, prompt);
@@ -4471,6 +4489,7 @@ async fn run_direct_game_creator_turn_inner(
None => base_system_prompt,
};
let reply = if let Some(emitter) = turn_emitter {
direct_turn_trace("executor-dispatch");
let client_turn_id = emitter.turn_id().to_string();
let emitter = emitter.clone();
let mut observer = |observation: DirectCodexTurnObservation| {
@@ -104,17 +104,13 @@ impl Drop for TurnReservation {
),
};
let _ = record_direct_codex_failure(Path::new(&self.thread_id), &failure, None);
let _ = crate::append_application_log(
"error".to_string(),
"direct-turn".to_string(),
format!(
"agent.direct_turn.host_dropped threadId={} turnToken={} userItemId={} panicking={}",
self.thread_id,
self.token,
self.user_item_id.as_deref().unwrap_or("-"),
// true = 这一轮是被 panic 带走的;false = future 被丢弃或取消。
std::thread::panicking()
),
app_log!(
"agent.direct_turn.host_dropped threadId={} turnToken={} userItemId={} panicking={}",
self.thread_id,
self.token,
self.user_item_id.as_deref().unwrap_or("-"),
// true = 这一轮是被 panic 带走的;false = future 被丢弃或取消。
std::thread::panicking()
);
}
// 占用的释放就是"这个项目腾出了跑回合的位置":踢一脚,队里排着的消息不必等下一次用户动作。
@@ -152,16 +148,12 @@ fn ensure_direct_turn_panic_hook() {
})
.unwrap_or_else(|| "未知位置".to_string());
#[cfg(not(test))]
let _ = crate::append_application_log(
"error".to_string(),
"direct-turn".to_string(),
format!(
"agent.direct_turn.panic threadId={} turnToken={} location={} payload={}",
thread_id,
token,
location,
info.payload_as_str().unwrap_or("未知 panic 负载")
),
app_log!(
"agent.direct_turn.panic threadId={} turnToken={} location={} payload={}",
thread_id,
token,
location,
info.payload_as_str().unwrap_or("未知 panic 负载")
);
#[cfg(test)]
let _ = (thread_id, token, location);
@@ -241,7 +233,7 @@ async fn run_dispatched_direct_turn(
// 未通过验收、历史注入失败)都靠它把失败说明挂回自己那一轮。
crate::agent::codex_app_server::emit_direct_thread_user_item(&root, &canonical_user_item);
let capture = crate::analytics::gui::capture_writer_context();
let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id);
let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id.clone());
// 回合体 panic 不能落到 `TurnReservation` 的 Drop 兜底:那会把这一轮收成"说不出原因"的
// `HostDropped`,症状正是用户看到的「本轮执行已中断,请重试」,而项目侧诊断与应用日志
// **一行都不会有**(真实案例:2026-10-02 连续两轮)。这里把 panic 转成分类失败,
+27
View File
@@ -1332,6 +1332,19 @@ textarea {
overflow: hidden;
}
/*
* 页面高度契约:`.launcher-main` 是纵向 flex 列,页面根节点自己 `flex: 1 1 auto` 吃掉
* 舞台剩余高度(有横幅时自动少掉横幅那一份),页面内部的 `height: 100%`、内层滚动容器
* 才有确定高度可用。
*
* 页面根节点上不要再写 `100vh` / `100dvh` / `calc(100vh - 32px)`:整窗高度含自绘标题栏,
* 比真实舞台高一个标题栏(窗口 100vh=800、舞台 750),底部会被裁掉,横幅也会被重复扣一次。
* 窗口外壳用 `height: 100%` 覆盖这里的 `100dvh`,让页面贴合 `.window-chrome__content`
* 的真实舞台;没有窗口外壳时 `100dvh` 才是正确值。
*/
.launcher-main:has(.launcher-home-page),
.launcher-main:has(.launcher-help-page),
.launcher-main:has(.launcher-template-library),
.launcher-main:has(.launcher-projects-page) {
display: flex;
flex-direction: column;
@@ -1340,6 +1353,20 @@ textarea {
padding-bottom: 0;
}
.launcher-main > .launcher-home-page,
.launcher-main > .launcher-help-page,
.launcher-main > .launcher-template-library {
flex: 1 1 auto;
height: auto;
min-height: 0;
}
/* 帮助页没有内层滚动容器:占满剩余高度后自己滚动,底部留出与外壳一致的呼吸位。 */
.launcher-main > .launcher-help-page {
overflow-y: auto;
padding-bottom: 42px;
}
.launcher-page header {
display: flex;
align-items: flex-end;
@@ -209,11 +209,7 @@ export default function HomeView({
}
return (
<div
className={`platform-theme platform-theme--light overflow-x-hidden overflow-y-auto bg-(image:--platform-body-fill) pb-12 text-(--platform-text-strong) ${
hasPromo ? 'h-[calc(100vh-32px)]' : 'h-screen'
}`}
>
<div className="launcher-home-page platform-theme platform-theme--light overflow-x-hidden overflow-y-auto bg-(image:--platform-body-fill) pb-12 text-(--platform-text-strong)">
<section
className={`grid content-start justify-items-center gap-3.5 px-6 pb-4.5 pt-20 max-[760px]:gap-4 max-[760px]:px-3.5 max-[760px]:pb-8.5 max-[760px]:pt-21 ${
hasPromo ? 'pt-28 max-[760px]:pt-21' : ''
@@ -152,8 +152,6 @@ export default function TemplateLibraryView({
const gridRef = useRef<FixedSizeGrid>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const sectionRef = useRef<HTMLElement | null>(null);
const [sectionHeight, setSectionHeight] = useState<number | null>(null);
const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 });
const showEmptyLibrary = status === 'ready' && templates.length === 0;
@@ -162,35 +160,10 @@ export default function TemplateLibraryView({
const showGrid = status === 'ready' && visibleTemplates.length > 0;
/**
* 页面高度按**父级实测高度**定,不用百分比也不用 100vh。
*
* 外壳样式 `.launcher-main > .platform-theme { height: 100% }` 特异性高于 Tailwind
* 工具类,而它的百分比在 `.launcher-shell { min-height: 100vh }` 这条链上是不定高,
* 页面会退化成内容高度(虚拟网格视口高度 0、卡片区空白);`100vh` 又比真实舞台高
* 一个标题栏(窗口 100vh=800、舞台 750),底部会被裁掉。这里直接量父级。
* 页面高度不再自己量:`.launcher-main:has(.launcher-template-library)` 把外壳改成纵向
* flex 列,`flex: 1 1 auto` 让这一页拿到确定高度(有横幅时自动少掉横幅那一份),
* 下面的网格视口才能用 `flex-1` 量出真实尺寸。
*/
useEffect(() => {
const section = sectionRef.current;
const parent = section?.parentElement;
if (!section || !parent) {
return;
}
const apply = () => {
const height = Math.round(parent.getBoundingClientRect().height);
if (height > 0) {
setSectionHeight((current) => (current === height ? current : height));
}
};
apply();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', apply);
return () => window.removeEventListener('resize', apply);
}
const observer = new ResizeObserver(apply);
observer.observe(parent);
return () => observer.disconnect();
}, []);
useEffect(() => {
const element = viewportRef.current;
if (!element) {
@@ -279,9 +252,7 @@ export default function TemplateLibraryView({
return (
<section
ref={sectionRef}
style={sectionHeight ? { height: `${sectionHeight}px` } : undefined}
className="platform-theme platform-theme--light flex min-h-0 w-full flex-col gap-3.5 overflow-hidden px-6 pb-5 pt-5 max-[760px]:px-4"
className="launcher-template-library platform-theme platform-theme--light flex min-h-0 w-full flex-col gap-3.5 overflow-hidden px-6 pb-5 pt-5 max-[760px]:px-4"
aria-label="模板库"
>
<header className="flex flex-wrap items-center gap-3">
@@ -17,6 +17,14 @@
- 追加(2026-10-02,系统类失败保留原始错误载荷):`clientSessionPersistFailed` / `runtimeSessionInstallFailed` / `authClientInitFailed` 都带 `detail: string`(原始 error),既让调用方有机会分流处理,也让报告包带够诊断信息;原始 error 同时经 `app_log!`(落盘前过 `sanitize_diagnostic_message`)记一行本地日志。`detail` 不贴到界面上:三个 catch 用本操作的固定文案(登录检查 / 发码 / 登录各自不同)。取代上一版"本机 IO 失败不进载荷、原始 error 只进日志"。
- 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。
## 2026-10-02 launcher 页面高度契约:外壳分高度,页面不再自己算窗口高度
- 背景:PR #228(`6d2c275d3`)只给项目页补了「外壳纵向 flex + 页面 `flex: 1 1 auto`」的高度修复;其余页面仍各自算高度——帮助页没写高度也没有内层滚动容器,内容一长就被外壳 `overflow: hidden` 裁掉且无法滚动;首页用 `h-screen` / `h-[calc(100vh-32px)]`,模板库用 JS 量父级高度写内联 `height`。
- 决策:把 #228 的修复提升为 `apps/ai-game-creator-shell/src/styles.css` 里的通用契约——`.launcher-main:has(<页面钩子>)` 改纵向 flex 列(`height: 100dvh`,窗口外壳 `height: 100%` 命中时贴合真实舞台;`min-height: 0`、`padding-bottom: 0`),`.launcher-main > <页面根节点>` 统一 `flex: 1 1 auto; height: auto; min-height: 0`;帮助页额外 `overflow-y: auto; padding-bottom: 42px`(它没有内层滚动容器)。契约钩子:`launcher-home-page`、`launcher-help-page`、`launcher-template-library`、`launcher-projects-page`。
- 边界:页面根节点不再写 `100vh` / `100dvh` / `calc(100vh - Npx)`;横幅(`.launcher-promo`)是外壳里的真实行,有横幅就靠 flex 自动少一份,不手算偏移。工作台页(`.game-project-workbench`)在窗口外壳里已有等价契约(`.window-chrome__content ...` 那几条),移动端媒体查询里遗留的 `100dvh` / `calc(100dvh - 32px)` 由外层 `flex: 1 1 auto` 收缩兜住,本次不动。
- 影响范围:`apps/ai-game-creator-shell/src/styles.css`、`src/view/home/index.tsx`、`src/view/template-library/index.tsx`。
- 验证:Chromium 真机量测(1440x800 / 1440x560 / 390x844 / 390x560,带与不带横幅)——首页 / 模板库 / 项目页改动前后高度一致,帮助页从「被裁 112~150px 且无可滚动祖先」变为正好等于 `.window-chrome__content` 高度并可滚动到底;`npm run typecheck`、`npm run check:encoding`、`git diff --check`、`eslint` 通过。
## 2026-10-01 游戏广场评分展示边界
- 用户确认广场卡片增加一位小数的 10 分制平均分与评分人数,无有效评价显示“暂无评分”;保留现有排序、筛选、卡片打开详情及返回上下文。
@@ -12,6 +12,13 @@
- **Rust 侧不得把结构化错误降级成字符串**:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`,视图不带 `errorMessage`;一旦折成 `String`,前端就只能拿文案判断,变体信息永久丢失。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、`apps/ai-game-creator-shell/src/services/{clientAuth.ts,errorReporting.ts,platformSession.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`。
## 2026-10-02 AGC 页面在自绘标题栏外壳里自己算 `100vh`:底部被裁而且没得滚
- **现象**:帮助页(使用指南 / 联系客服 / 更新日志)在矮窗口里底部卡片看不到,把窗口拉高才出现;外壳 `.launcher-main { overflow: hidden }` 之下没有任何可滚动祖先,页面既滚不动也裁得干净。首页在通知横幅出现时用 `h-[calc(100vh-32px)]`,同样把窗口高度当成了舞台高度。
- **原因**:AGC 桌面外壳是自绘标题栏(`--window-chrome-height`;窗口 100vh=800 时舞台只有 750),页面根节点写 `100vh` / `100dvh` / `calc(100vh - Npx)` 就比真实舞台高一整个标题栏,差额被外壳裁掉;横幅是 `.launcher-main` 里的真实行,再写 `-32px` 等于重复扣一次。帮助页还没有内层滚动容器,连「内容超高就在内部滚动」这条兜底也不存在。
- **处理(现行口径)**:页面高度只由外壳分配——`apps/ai-game-creator-shell/src/styles.css` 里 `.launcher-main:has(<页面钩子>)` 是纵向 flex 列(`height: 100dvh`,窗口外壳命中 `height: 100%` 时贴合真实舞台),`.launcher-main > <页面根节点>` 统一 `flex: 1 1 auto; height: auto; min-height: 0`,帮助页这类没有内层滚动容器的再加 `overflow-y: auto`。页面根节点一律不再写 `100vh` / `100dvh` / `calc(100vh - Npx)`;有横幅就靠 flex 自动少一份,不要手算偏移。
- **验证**:真机判据是 Vite + Chromium 量页面根节点是否正好等于 `.window-chrome__content` 的高度(1440x800 / 1440x560 / 390x844 / 390x560,带与不带横幅),帮助页应可滚动到底。
## 2026-10-02 固定试玩误判祖先的指针穿透样式
- `pointer-events:none` 不会强制禁用整棵子树;后代显式 `auto` 可以恢复命中。控件探针只检查目标的计算样式,继承未覆盖的 `none` 仍拒绝;可见性、遮挡、disabled 与 inert 保留各自检查。