From 4912aa4df0533613275478e2ffb6f087e3241636 Mon Sep 17 00:00:00 2001
From: Linghong
Date: Thu, 24 Sep 2026 16:08:42 +0000
Subject: [PATCH 1/2] =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=88=9B=E4=BD=9C?=
=?UTF-8?q?=E9=A6=96=E9=A1=B5=E7=99=BB=E5=BD=95=E9=80=81=E6=B3=A5=E7=82=B9?=
=?UTF-8?q?=E8=90=A5=E9=94=80=E6=96=87=E6=A1=88=20=E5=88=A0=E9=99=A4=20Cre?=
=?UTF-8?q?ationLandingView=20hero=20=E5=8C=BA=E2=80=9C=E7=99=BB=E5=BD=95?=
=?UTF-8?q?=E5=8D=B3=E9=80=81=20100=20=E6=B3=A5=E7=82=B9=EF=BC=8C=E5=8F=AF?=
=?UTF-8?q?=E4=BB=A5=E5=85=8D=E8=B4=B9=E5=88=B6=E4=BD=9C=2050=20=E4=B8=AA?=
=?UTF-8?q?=E7=B4=A0=E6=9D=90=E2=80=9D=E6=96=87=E6=A1=88=20=E5=88=A0?=
=?UTF-8?q?=E9=99=A4=20CreationLandingView.test.tsx=20=E4=B8=AD=E5=AF=B9?=
=?UTF-8?q?=E5=BA=94=E7=9A=84=E6=96=87=E6=A1=88=E6=96=AD=E8=A8=80=20?=
=?UTF-8?q?=E6=B8=85=E7=90=86=20index.css=20=E4=B8=AD=E5=B7=B2=E6=97=A0?=
=?UTF-8?q?=E8=B0=83=E7=94=A8=E6=96=B9=E7=9A=84=20creation-landing=5F=5Fhe?=
=?UTF-8?q?ro-benefit=20=E6=A0=B7=E5=BC=8F=20=E6=9B=B4=E6=AD=A3=20decision?=
=?UTF-8?q?-log=20=E4=B8=AD=E5=B7=B2=E8=BF=87=E6=9C=9F=E7=9A=84=E2=80=9C?=
=?UTF-8?q?=E6=B3=A8=E5=86=8C=E8=B5=A0=E9=80=81=E5=9B=BA=E5=AE=9A=E4=B8=BA?=
=?UTF-8?q?=20100=20=E6=B3=A5=E7=82=B9=E2=80=9D=E5=86=B3=E7=AD=96=E8=AE=B0?=
=?UTF-8?q?=E5=BD=95=EF=BC=8C=E6=B3=A8=E6=98=8E=E9=87=91=E9=A2=9D=E7=94=B1?=
=?UTF-8?q?=E7=BA=BF=E4=B8=8A=E9=92=B1=E5=8C=85=E9=85=8D=E7=BD=AE=E5=86=B3?=
=?UTF-8?q?=E5=AE=9A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../project-memory/shared-memory/decision-log.md | 2 +-
.../creation-home/CreationLandingView.test.tsx | 3 ---
.../creation-home/CreationLandingView.tsx | 3 ---
src/index.css | 16 +---------------
4 files changed, 2 insertions(+), 22 deletions(-)
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 53b75ec40..28d8e2a64 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -2884,7 +2884,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
## 2026-06-22 编辑器生成扣费与新用户赠送收口
- 背景:画板多个生成按钮已经展示泥点消耗,但部分图片、图标、UI 提取、视频、角色动作或音频链路只校验 / 展示价格,没有统一进入钱包预扣;新用户注册送泥点也需要与当前生成价格匹配。
-- 决策:编辑器所有外部生成入口不再从前端请求接收 `priceMudPoints`,后端按运行时模型定价配置计算价格后统一进入 `execute_billable_asset_operation_with_cost` 或等价音频发布扣费链路;角色动作和视频使用真实登录用户作为扣费 owner。新用户注册赠送固定为 `100` 泥点。
+- 决策:编辑器所有外部生成入口不再从前端请求接收 `priceMudPoints`,后端按运行时模型定价配置计算价格后统一进入 `execute_billable_asset_operation_with_cost` 或等价音频发布扣费链路;角色动作和视频使用真实登录用户作为扣费 owner。~~新用户注册赠送固定为 `100` 泥点。~~(2026-09-24 更正:注册赠送金额不是固定值,由线上 `profile_wallet_config.initial_mud_points` 配置决定,后台通过 `/admin/api/profile/wallet-config` 与钱包配置页随时调整;代码内常量仅为未写入配置时的兜底默认,本地编译行为不代表线上实际赠送金额,线上数值以配置表当前值为准。契约说明见 [`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](../../【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md)。)
- 影响范围:编辑器图片 / 图片修改 / 图标 spritesheet / UI 提取 / 视频 / 角色动作 / 音频生成 BFF,前端画板生成提交模型,外部 OpenAPI,`module-runtime` 钱包注册奖励。
- 验证方式:运行编辑器图片、图标、UI 提取、视频、角色动作、音频扣费结构性测试,前端生成提交和 API client 测试,`module-runtime` 注册奖励测试。
- 关联文档:`docs/【编辑器】模型定价配置管理方案-2026-06-22.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx
index d0c59ef83..3025d17be 100644
--- a/src/components/creation-home/CreationLandingView.test.tsx
+++ b/src/components/creation-home/CreationLandingView.test.tsx
@@ -208,9 +208,6 @@ describe('CreationLandingView', () => {
expect(subtitle.textContent).toContain('美术 Agent');
expect(subtitle.textContent).toContain('无限画布');
expect(subtitle.textContent).toContain('角色、场景、UI 与宣发素材');
- expect(
- screen.getByText('登录即送 100 泥点,可以免费制作 50 个素材'),
- ).toBeTruthy();
expect(
screen.getByRole('heading', {
level: 2,
diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx
index 8edad3daa..587b94238 100644
--- a/src/components/creation-home/CreationLandingView.tsx
+++ b/src/components/creation-home/CreationLandingView.tsx
@@ -1418,9 +1418,6 @@ export function CreationLandingView({
用美术 Agent 与无限画布,快速制作角色、场景、UI 与宣发素材。
-
- 登录即送 100 泥点,可以免费制作 50 个素材
-
Date: Thu, 24 Sep 2026 16:45:07 +0000
Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Linux=20=E6=B2=99?=
=?UTF-8?q?=E7=AE=B1=E5=91=BD=E4=BB=A4=E9=80=80=E5=87=BA=E5=9B=9E=E6=94=B6?=
=?UTF-8?q?=E7=9A=84=E5=81=B6=E5=8F=91=E7=AB=9E=E6=80=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
统一正常退出、取消和超时的有界进程组退出确认,保留归属校验
提前记录启动身份,修补终态协议错误的清理及输出任务回收
补充确定性竞态回归并同步运行时规范和排障记忆
保持现有 CI 并行、分片和重试策略不变
---
.../src-tauri/src/command_exec.rs | 208 +++++++++++++-----
docs/project-memory/shared-memory/pitfalls.md | 6 +
...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 2 +
3 files changed, 161 insertions(+), 55 deletions(-)
diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs
index c3a0343e1..5d970df99 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs
@@ -15,6 +15,8 @@ const PROJECT_COMMAND_MAX_ARGUMENT_BYTES: usize = 8 * 1024;
const PROJECT_COMMAND_MIN_TIMEOUT_SECONDS: u64 = 1;
const PROJECT_COMMAND_MAX_TIMEOUT_SECONDS: u64 = 300;
const PROJECT_COMMAND_OUTPUT_MAX_BYTES: usize = 24 * 1024;
+#[cfg(target_os = "linux")]
+const PROJECT_COMMAND_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5);
const PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES: usize = 20_000;
const PROJECT_COMMAND_FINGERPRINT_MAX_FILES: usize = 10_000;
const PROJECT_COMMAND_FINGERPRINT_MAX_BYTES: u64 = 512 * 1024 * 1024;
@@ -222,14 +224,30 @@ impl ProjectCommandTree {
}
#[cfg(not(windows))]
{
+ let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
let requested = self.request_owned_group_termination();
let _ = child.start_kill();
- let waited = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
- requested?;
+ let waited = tokio::time::timeout_at(deadline, child.wait()).await;
waited
.map_err(|_| "等待受控命令主进程退出超时")?
.map_err(|_| "受控命令主进程退出未确认")?;
- Ok("已请求终止受控进程组并回收主进程,完整子树状态未证明".into())
+ #[cfg(target_os = "linux")]
+ {
+ let Self::Group { pid, .. } = self;
+ // leader 可能与取消同时退出;只要组已停止便无需补发信号。
+ if let Err(error) = wait_linux_project_command_group_exit(*pid, deadline).await {
+ return Err(match requested {
+ Ok(_) => error,
+ Err(request_error) => format!("{request_error};{error}"),
+ });
+ }
+ return Ok("主进程已回收,受控进程组已无活成员;完整子树状态未证明".into());
+ }
+ #[cfg(not(target_os = "linux"))]
+ {
+ requested?;
+ Ok("已请求终止受控进程组并回收主进程,完整子树状态未证明".into())
+ }
}
}
@@ -244,17 +262,39 @@ impl ProjectCommandTree {
#[cfg(target_os = "linux")]
{
let Self::Group { pid, .. } = self;
- // 容器 PID 1 可能不回收 bwrap 的孤儿僵尸;它们不再执行,也无法被信号终止。
- // 仅在确认没有存活成员时免除清理,存活成员仍须通过 leader 身份核对。
- if !linux_project_command_group_has_live_members(*pid)? {
- return Ok(());
- }
+ // wait 已回收 leader,不能再用旧 PID 授权发信号。
+ // namespace 后代可能仍在退出,容器 PID 1 也可能保留孤儿僵尸。
+ return wait_linux_project_command_group_exit(
+ *pid,
+ tokio::time::Instant::now() + PROJECT_COMMAND_CLEANUP_TIMEOUT,
+ )
+ .await;
}
+ #[cfg(not(target_os = "linux"))]
self.request_owned_group_termination().map(|_| ())
}
}
}
+#[cfg(target_os = "linux")]
+async fn wait_linux_project_command_group_exit(
+ group: u32,
+ deadline: tokio::time::Instant,
+) -> Result<(), String> {
+ while linux_project_command_group_has_live_members(group)? {
+ if tokio::time::Instant::now() >= deadline {
+ return Err(
+ "受控进程组仍有存活成员,退出未确认;不得向身份未确认的进程组补发信号".into(),
+ );
+ }
+ tokio::time::sleep_until(
+ deadline.min(tokio::time::Instant::now() + Duration::from_millis(20)),
+ )
+ .await;
+ }
+ Ok(())
+}
+
#[cfg(target_os = "linux")]
fn linux_project_command_group_has_live_members(group: u32) -> Result {
let inspect = || -> std::io::Result {
@@ -1842,6 +1882,17 @@ where
),
));
}
+ // 目标放行后可能立即退出,必须趁 ready gate 仍持有 launcher 时记录归属。
+ let tree = match ProjectCommandTree::attach(&child) {
+ Ok(tree) => tree,
+ Err(error) => {
+ let termination = terminate_project_command_process_group(&mut child).await;
+ return Err(ProjectCommandError::new(
+ ProjectCommandErrorStage::Preflight,
+ project_command_launch_error_with_termination(error, termination),
+ ));
+ }
+ };
if let Err(error) = durable_commit() {
let termination = terminate_project_command_process_group(&mut child).await;
return Err(ProjectCommandError::new(
@@ -1860,12 +1911,7 @@ where
// cancelled future must not erase the launch-unknown decision window.
let exec = gate.wait_target_exec(Duration::from_secs(3));
match exec {
- Ok(TargetExecState::Established) => {
- let tree = ProjectCommandTree::attach(&child).map_err(|error| {
- ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error)
- })?;
- Ok(EstablishedProjectCommand { tree, child, gate })
- }
+ Ok(TargetExecState::Established) => Ok(EstablishedProjectCommand { tree, child, gate }),
Ok(TargetExecState::Failed { errno }) => {
let termination = terminate_project_command_process_group_after_commit(&mut child);
Err(ProjectCommandError::new(
@@ -1977,7 +2023,11 @@ async fn terminate_project_command_process_group(
.ok_or_else(|| "请求终止受控进程组失败:子进程缺少 pid".to_string())?;
let group_result = request_unix_project_command_process_group_termination(process_id);
let child_kill_error = child.start_kill().err();
- let wait_result = child.wait().await;
+ let deadline = tokio::time::Instant::now() + PROJECT_COMMAND_CLEANUP_TIMEOUT;
+ let wait_result = tokio::time::timeout_at(deadline, child.wait())
+ .await
+ .map_err(|_| "等待受控命令主进程退出超时".to_string())?
+ .map_err(|error| error.to_string());
if let Err(error) = &group_result {
let fallback = match (&child_kill_error, &wait_result) {
(_, Ok(_)) => "主进程已回收,但无法确认其余组内进程".to_string(),
@@ -1989,6 +2039,7 @@ async fn terminate_project_command_process_group(
return Err(format!("{error};{fallback}"));
}
wait_result.map_err(|error| format!("请求终止受控进程组后等待主进程失败:{error}"))?;
+ wait_linux_project_command_group_exit(process_id, deadline).await?;
Ok(format!(
"{}并完成主进程回收",
group_result.expect("group termination result checked")
@@ -2220,7 +2271,7 @@ where
let (exit_code, timed_out, termination_summary) = match wait {
ProjectCommandWait::Exited(Ok(status)) => {
#[cfg(target_os = "linux")]
- let _terminal = wait_established_project_command_terminal(gate).await?;
+ let terminal = wait_established_project_command_terminal(gate).await;
if let Err(error) = tree.after_main_exit(&mut child).await {
stdout_task.abort();
stderr_task.abort();
@@ -2229,6 +2280,12 @@ where
format!("command.exec 主进程退出后进程树未确认回收,需要人工核对:{error}"),
));
}
+ #[cfg(target_os = "linux")]
+ if let Err(error) = terminal {
+ stdout_task.abort();
+ stderr_task.abort();
+ return Err(error);
+ }
(status.code(), false, None)
}
ProjectCommandWait::Exited(Err(error)) => {
@@ -2541,45 +2598,83 @@ mod tests {
return;
}
assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0);
- let mut command = tokio::process::Command::new("/bin/sh");
- command
- .args(["-c", "sleep 60 & echo $!; read release"])
- .stdin(Stdio::piped())
- .stdout(Stdio::piped());
- command.as_std_mut().process_group(0);
- let mut child = command.spawn().unwrap();
- let tree = ProjectCommandTree::attach(&child).unwrap();
- let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap());
- let mut line = String::new();
- tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line)
- .await
- .unwrap();
- let descendant: i32 = line.trim().parse().unwrap();
- drop(child.stdin.take());
- child.wait().await.unwrap();
- let live_result = tree.after_main_exit(&mut child).await;
- assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0);
- let mut info = unsafe { std::mem::zeroed::() };
- assert_eq!(
- unsafe {
- libc::waitid(
- libc::P_PID,
- descendant as u32,
- &mut info,
- libc::WEXITED | libc::WNOWAIT,
- )
- },
- 0
- );
- let zombie_result = tree.after_main_exit(&mut child).await;
- assert_eq!(
- unsafe { libc::waitpid(descendant, std::ptr::null_mut(), 0) },
- descendant
- );
- let error = live_result.expect_err("存活成员缺少 leader 身份时必须拒绝清理");
- assert!(error.contains("leader 身份未确认"), "{error}");
- zombie_result.expect("已回收 leader 的进程组只剩僵尸时不应要求人工核对");
- tree.after_main_exit(&mut child).await.unwrap();
+ struct DescendantGuard(i32);
+ impl Drop for DescendantGuard {
+ fn drop(&mut self) {
+ unsafe {
+ libc::kill(self.0, libc::SIGKILL);
+ libc::waitpid(self.0, std::ptr::null_mut(), 0);
+ }
+ }
+ }
+ // 正常退出与取消/超时共用的 terminate 都覆盖 leader 已回收的窗口。
+ for terminate in [false, true] {
+ let mut command = tokio::process::Command::new("/bin/sh");
+ command
+ .args(["-c", "sleep 60 & echo $!; read release"])
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .kill_on_drop(true);
+ command.as_std_mut().process_group(0);
+ let mut child = command.spawn().unwrap();
+ let tree = ProjectCommandTree::attach(&child).unwrap();
+ let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap());
+ let mut line = String::new();
+ tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line)
+ .await
+ .unwrap();
+ let descendant: i32 = line.trim().parse().unwrap();
+ let descendant_guard = DescendantGuard(descendant);
+ drop(child.stdin.take());
+ child.wait().await.unwrap();
+ let ProjectCommandTree::Group { pid, .. } = &tree;
+ let error = tree
+ .request_owned_group_termination()
+ .expect_err("存活成员缺少 leader 身份时必须拒绝发送信号");
+ assert!(error.contains("leader 身份未确认"), "{error}");
+ let error = wait_linux_project_command_group_exit(*pid, tokio::time::Instant::now())
+ .await
+ .expect_err("持续存活成员必须在预算用尽时失败");
+ assert!(error.contains("仍有存活成员"), "{error}");
+ {
+ let cleanup = async {
+ if terminate {
+ tree.terminate(&mut child).await.map(|_| ())
+ } else {
+ tree.after_main_exit(&mut child).await
+ }
+ };
+ tokio::pin!(cleanup);
+ // 先 poll 生产清理,确认它确实等待,才让后代进入僵尸态。
+ tokio::select! {
+ biased;
+ result = &mut cleanup => panic!("后代仍存活时提前结束清理:{result:?}"),
+ _ = tokio::task::yield_now() => {}
+ }
+ assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0);
+ let mut info = unsafe { std::mem::zeroed::() };
+ assert_eq!(
+ unsafe {
+ libc::waitid(
+ libc::P_PID,
+ descendant as u32,
+ &mut info,
+ libc::WEXITED | libc::WNOWAIT,
+ )
+ },
+ 0
+ );
+ cleanup.await.expect("leader 消失后,组成员停止应完成清理");
+ }
+ tree.after_main_exit(&mut child)
+ .await
+ .expect("僵尸不应阻止完成");
+ wait_linux_project_command_group_exit(*pid, tokio::time::Instant::now())
+ .await
+ .expect("无活成员时不消耗等待预算");
+ drop(descendant_guard);
+ tree.after_main_exit(&mut child).await.unwrap();
+ }
}
#[test]
@@ -3426,6 +3521,9 @@ raise SystemExit(code)'
.expect("run timeout test");
assert!(timed_out.timed_out);
assert_eq!(timed_out.status, "failed");
+ #[cfg(target_os = "linux")]
+ assert!(timed_out.output.contains("主进程已回收"));
+ #[cfg(not(target_os = "linux"))]
assert!(timed_out.output.contains("请求终止受控进程组"));
assert!(timed_out.output.contains("不等同完整 OS sandbox"));
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index 0a3a42c64..20e41dd15 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -5994,6 +5994,12 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **验证**:宿主 `the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、前端 `本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合` 与 `收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)`。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/direct_runtime/user_input.rs`、`.../chat/conversation/{directThreadChat.ts,directTurnPresentation.ts}`、`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。
+## Linux command.exec 的 leader 回收与后代退出存在时序差
+
+- bwrap 主进程已经 `wait` 回收时,namespace 后代仍可能短暂处于退出过程;一次 `/proc` 扫描发现活成员后再读取 leader 身份,会把正常退出误报为需人工核对。容器 PID 1 未回收的 `Z / X` 成员也不能当作活进程。
+- 正常退出与取消/超时共用有界的组退出确认,组内无活成员立即返回;缺失或变化的 leader 身份不能授权补发信号,持续活成员或读取失败仍报错。启动身份在 ready 后、commit 前记录;terminal 协议错误也不能跳过清理与 reader 取消。
+- 回归使用独立 subreaper 夹具:先 poll 清理并确认仍在等待,再让同组后代退出,覆盖正常收尾和取消/超时的共同清理路径;保持现有 CI 分片与并行,不靠取消并行或失败重试消除竞态。
+
## 2026-09-24 DirectProject「接单窗口里看不到自己刚发的话」是设计,不是丢消息
- **现象**:按下发送后聊天区里不会立刻出现自己那句话;宿主还在接单 / 落盘的那段时间只能看到 composer 忙态、状态行与「陶泥儿正在处理」卡片(卡片这一段不读秒——起点要等宿主的 `turn.started.at`),滚动也停在原地。订阅重建的窗口同理。容易被读成"消息丢了 / 没发出去"。
diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md
index cb16b123e..3bfaa6684 100644
--- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md
+++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md
@@ -582,6 +582,8 @@ V1.11 把命令安全边界从“固定 program + argv 规则 + 隔离环境变
### V1.11.1 可信 launch 握手
+Linux 一次性命令的退出确认按进程组最终状态判断:正常退出、取消与超时在同一有界清理预算内等待组内非 `Z / X` 成员消失,空组立即返回。bwrap leader 已回收而 namespace 后代尚在退出时,不凭一次瞬时扫描报错,也不向无法确认 leader 启动身份的进程组发送信号;持续存活、不可读或归属不明仍失败关闭。启动身份在放行目标前记录,terminal 协议错误也必须完成清理并停止输出 reader。回归用独立 subreaper 夹具控制后代退出时序,保留 CI 的现有并行与分片,不增加测试失败重试。
+
V1.11.1 必须把 `prepared -> child-created -> sandbox-ready -> commit-persisted -> exec-established -> running/exited` 做成 launcher 状态机,不能再把 bwrap 进程 spawn 或 `--json-status-fd` 的 `child-pid` 当作 sandbox-ready。实测 `child-pid` 会在 `--block-fd` 放行前出现,此时目标程序尚未执行;它只能证明 namespace child 已创建。关闭 block writer 也不能作为 abort,因为 bwrap 会把 EOF 当作可读并继续执行,失败关闭必须显式 kill + wait/reap。
- Linux 最终 `COMMAND` 必须先进入受信任 trampoline,而不是直接进入用户目标。trampoline 通过与 PTY/transcript 分离的私有控制通道发送带随机 nonce 的 `SANDBOX_READY`,等待 Runtime 完成 revision / verification gate / process record 的 durable commit 后接收 `COMMIT_EXEC`,再用 exec-error pipe 启动目标并回报 `EXEC_ESTABLISHED` 或 `TARGET_EXEC_FAILED`。commit 前的 EOF、错 nonce、协议错误和持久化失败都必须杀死并回收整个 bwrap 树,目标零执行。
--
2.52.0