修复Linux进程树探活与Runtime默认栈溢出
Linux进程组探活忽略已终止但未回收的zombie成员 pending恢复与后续队列统一跨越装箱的Tokio任务边界 补充Tauri生命周期和Runtime默认栈回归测试与文档
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import { resolve } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -300,16 +300,100 @@ function stopChild(child, signal = 'SIGTERM') {
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessGroupAlive(processGroupId, killImpl = process.kill) {
|
||||
function parseLinuxProcessStat(value) {
|
||||
const commandEnd = value.lastIndexOf(')');
|
||||
if (commandEnd < 0) {
|
||||
return null;
|
||||
}
|
||||
const fields = value
|
||||
.slice(commandEnd + 1)
|
||||
.trim()
|
||||
.split(/\s+/);
|
||||
if (fields.length < 3 || fields[0].length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const processGroupId = Number(fields[2]);
|
||||
if (!Number.isInteger(processGroupId)) {
|
||||
return null;
|
||||
}
|
||||
return { state: fields[0], processGroupId };
|
||||
}
|
||||
|
||||
function readLinuxProcessGroupRunning(
|
||||
processGroupId,
|
||||
{
|
||||
procRoot = '/proc',
|
||||
readdirImpl = readdirSync,
|
||||
readFileImpl = readFileSync,
|
||||
} = {},
|
||||
) {
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirImpl(procRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let inspectedProcess = false;
|
||||
for (const entry of entries) {
|
||||
const name = typeof entry === 'string' ? entry : entry.name;
|
||||
if (!/^\d+$/.test(name)) {
|
||||
continue;
|
||||
}
|
||||
if (typeof entry !== 'string' && !entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
let stat;
|
||||
try {
|
||||
stat = readFileImpl(join(procRoot, name, 'stat'), 'utf8');
|
||||
} catch (error) {
|
||||
// 进程可能在枚举后立刻退出;继续检查同组的其它成员。
|
||||
if (error?.code === 'ENOENT' || error?.code === 'ESRCH') {
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const parsed = parseLinuxProcessStat(stat);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
inspectedProcess = true;
|
||||
if (
|
||||
parsed?.processGroupId === processGroupId &&
|
||||
!['Z', 'X', 'x'].includes(parsed.state)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return inspectedProcess ? false : null;
|
||||
}
|
||||
|
||||
function isProcessGroupRunning(
|
||||
processGroupId,
|
||||
{
|
||||
platform = process.platform,
|
||||
killImpl = process.kill,
|
||||
readLinuxProcessGroup = readLinuxProcessGroupRunning,
|
||||
} = {},
|
||||
) {
|
||||
if (!Number.isInteger(processGroupId)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
killImpl(-processGroupId, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code !== 'ESRCH';
|
||||
}
|
||||
if (platform !== 'linux') {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
// Linux 的 kill(-PGID, 0) 会把尚未被容器 PID 1 回收的 zombie 也视为
|
||||
// 存在;zombie 已不能执行代码,不应让有界清理被误判为失败。
|
||||
return readLinuxProcessGroup(processGroupId) ?? true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntil(check, timeoutMs, pollIntervalMs = 25) {
|
||||
@@ -415,7 +499,7 @@ async function terminateChildTree(
|
||||
stopChild(child, 'SIGTERM');
|
||||
if (
|
||||
await waitUntil(
|
||||
() => !isProcessGroupAlive(processGroupId, killImpl),
|
||||
() => !isProcessGroupRunning(processGroupId, { platform, killImpl }),
|
||||
gracefulTimeoutMs,
|
||||
)
|
||||
) {
|
||||
@@ -430,7 +514,7 @@ async function terminateChildTree(
|
||||
}
|
||||
}
|
||||
const stopped = await waitUntil(
|
||||
() => !isProcessGroupAlive(processGroupId, killImpl),
|
||||
() => !isProcessGroupRunning(processGroupId, { platform, killImpl }),
|
||||
forceTimeoutMs,
|
||||
);
|
||||
return { stopped, forced: true };
|
||||
@@ -600,8 +684,10 @@ export {
|
||||
ensureBackend,
|
||||
formatChildFailure,
|
||||
isDirectModuleExecution,
|
||||
isProcessGroupRunning,
|
||||
preflightExistingVite,
|
||||
readChildFailure,
|
||||
readLinuxProcessGroupRunning,
|
||||
resolveBackendTargetsFromState,
|
||||
runWindowsTaskkill,
|
||||
spawnChild,
|
||||
|
||||
+75
-23
@@ -1,5 +1,28 @@
|
||||
use super::*;
|
||||
|
||||
async fn run_after_pending_stack_boundary<T>(
|
||||
future: std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'static>>,
|
||||
) -> T
|
||||
where
|
||||
T: Send + 'static,
|
||||
{
|
||||
// Debug builds give pending execution, the background main loop, and queue draining large
|
||||
// poll frames. The boxed future keeps that large frame out of its caller before a joined child
|
||||
// task gives it an independent poll boundary. JoinSet still aborts the child if its parent
|
||||
// continuation is dropped.
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
tasks.spawn(future);
|
||||
match tasks
|
||||
.join_next()
|
||||
.await
|
||||
.expect("pending continuation task must exist")
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
|
||||
Err(error) => panic!("pending continuation task was cancelled: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_game_creator_agent_background_task_after_pending_stack_boundary(
|
||||
root: PathBuf,
|
||||
agent_id: String,
|
||||
@@ -7,11 +30,7 @@ async fn run_game_creator_agent_background_task_after_pending_stack_boundary(
|
||||
runtime: AgentRuntimeState,
|
||||
continuation: AgentRuntimeContinuationContext,
|
||||
) -> AgentBackgroundTaskOutcome {
|
||||
// Debug builds give both pending execution and the background main loop large poll frames.
|
||||
// A joined child task prevents those frames from sharing one Tokio worker stack while
|
||||
// JoinSet still aborts the child if its parent continuation is dropped.
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
tasks.spawn(async move {
|
||||
run_after_pending_stack_boundary(Box::pin(async move {
|
||||
run_game_creator_agent_background_task_with_context(
|
||||
root,
|
||||
agent_id,
|
||||
@@ -20,19 +39,36 @@ async fn run_game_creator_agent_background_task_after_pending_stack_boundary(
|
||||
continuation,
|
||||
)
|
||||
.await
|
||||
});
|
||||
match tasks
|
||||
.join_next()
|
||||
.await
|
||||
.expect("pending continuation task must exist")
|
||||
{
|
||||
Ok(outcome) => outcome,
|
||||
Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
|
||||
Err(error) => panic!("pending continuation task was cancelled: {error}"),
|
||||
}
|
||||
}))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
|
||||
root: PathBuf,
|
||||
agent_id: String,
|
||||
) {
|
||||
run_after_pending_stack_boundary(Box::pin(async move {
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
root: PathBuf,
|
||||
agent_id: String,
|
||||
pending: AgentRuntimePendingToolAction,
|
||||
runtime: AgentRuntimeState,
|
||||
) {
|
||||
run_after_pending_stack_boundary(Box::pin(async move {
|
||||
continue_game_creator_agent_pending_tool_action_within_stack_boundary(
|
||||
root, agent_id, pending, runtime,
|
||||
)
|
||||
.await;
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary(
|
||||
root: PathBuf,
|
||||
agent_id: String,
|
||||
mut pending: AgentRuntimePendingToolAction,
|
||||
@@ -102,7 +138,10 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
)
|
||||
.await;
|
||||
if matches!(outcome, AgentBackgroundTaskOutcome::Finished) {
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
|
||||
root, agent_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -111,7 +150,8 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
if !has_persisted_terminal_observation
|
||||
&& stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime)
|
||||
{
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if let Err(error) = validate_agent_runtime_pending_context(&root, &runtime, &pending) {
|
||||
@@ -373,14 +413,18 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
&pending,
|
||||
&observation,
|
||||
) {
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
|
||||
root, agent_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if observation.is_waiting_for_confirmation()
|
||||
&& stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime)
|
||||
{
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if observation.is_waiting_for_confirmation() {
|
||||
@@ -557,7 +601,8 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
}),
|
||||
);
|
||||
if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) {
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if !auto_execution {
|
||||
@@ -734,7 +779,10 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
runtime,
|
||||
&format!("恢复 Agent Runtime context bundle 失败:{error}"),
|
||||
);
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
|
||||
root, agent_id,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -785,7 +833,10 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
runtime,
|
||||
&format!("持久化 Agent Runtime context bundle 失败:{error}"),
|
||||
);
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
|
||||
root, agent_id,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -828,7 +879,8 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
)
|
||||
.await;
|
||||
if matches!(outcome, AgentBackgroundTaskOutcome::Finished) {
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
ensureBackend,
|
||||
isProcessGroupRunning,
|
||||
preflightExistingVite,
|
||||
readLinuxProcessGroupRunning,
|
||||
resolveBackendTargetsFromState,
|
||||
runWindowsTaskkill,
|
||||
spawnChild,
|
||||
@@ -154,6 +156,40 @@ describe('AI 游戏创作启动子进程生命周期', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('Linux 进程组探活忽略已退出但尚未回收的 zombie', () => {
|
||||
const stats = new Map([
|
||||
['/proc/4822/stat', '4822 (node worker) Z 1 4821 4821'],
|
||||
['/proc/7001/stat', '7001 (other) S 1 7001 7001'],
|
||||
]);
|
||||
const readLinuxGroup = () =>
|
||||
readLinuxProcessGroupRunning(4821, {
|
||||
readdirImpl: () => [
|
||||
{ name: '4822', isDirectory: () => true },
|
||||
{ name: '7001', isDirectory: () => true },
|
||||
],
|
||||
readFileImpl: (path) => stats.get(path),
|
||||
});
|
||||
|
||||
expect(readLinuxGroup()).toBe(false);
|
||||
expect(
|
||||
isProcessGroupRunning(4821, {
|
||||
platform: 'linux',
|
||||
killImpl: vi.fn(),
|
||||
readLinuxProcessGroup: readLinuxGroup,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isProcessGroupRunning(4821, {
|
||||
platform: 'linux',
|
||||
killImpl: vi.fn(),
|
||||
readLinuxProcessGroup: () => null,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
stats.set('/proc/4822/stat', '4822 (node worker) R 1 4821 4821');
|
||||
expect(readLinuxGroup()).toBe(true);
|
||||
});
|
||||
|
||||
test('后端句柄在 ready 等待前交给外层且异常时立即清理', async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
exitCode: null,
|
||||
|
||||
@@ -5,7 +5,11 @@ import { join } from 'node:path';
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { spawnChild, terminateChildTree } from '../scripts/start-dev-stack.mjs';
|
||||
import {
|
||||
isProcessGroupRunning,
|
||||
spawnChild,
|
||||
terminateChildTree,
|
||||
} from '../scripts/start-dev-stack.mjs';
|
||||
import {
|
||||
buildTauriArguments,
|
||||
runTauriDev,
|
||||
@@ -200,7 +204,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => {
|
||||
});
|
||||
|
||||
expect(result).toBe(42);
|
||||
expect(() => process.kill(-cliChild.pid, 0)).toThrow();
|
||||
expect(isProcessGroupRunning(cliChild.pid)).toBe(false);
|
||||
} finally {
|
||||
if (Number.isInteger(cliChild?.pid)) {
|
||||
try {
|
||||
|
||||
@@ -3990,8 +3990,8 @@
|
||||
|
||||
- 现象:Supervisor collaboration durable isolated spawn 恢复测试在默认 Tokio worker 栈下稳定 `stack overflow`;单独运行同样失败,提高 `RUST_MIN_STACK` 后通过。
|
||||
- 原因:不是业务递归。debug 构建中 pending action continuation、后台 task queue 和 Agent 主循环各自形成大型 async poll frame;恢复路径在同一次 poll 调用链直接进入下一层状态机,累计超过 worker 默认栈。
|
||||
- 处理:在 pending continuation 与后台主循环之间建立独立 Tokio task 轮询边界,使 pending poll 先退栈后再轮询主循环。边界必须保留结构化取消语义;当前使用 `JoinSet`,父 continuation 被丢弃时同步 abort 子任务。不得只增大 CI 的 `RUST_MIN_STACK`,否则生产默认栈仍可能崩溃。
|
||||
- 验证:失败用例必须在未设置 `RUST_MIN_STACK` 时通过;同时覆盖 policy batch 全组和 pending/cancellation 回归,证明恢复不重复生成 isolated spawn、父任务取消不遗留后台子任务。
|
||||
- 处理:整个 pending continuation、它进入的后台主循环,以及完成、取消或失败后 drain 同 Agent 后续队列时,都必须跨越独立 Tokio task 轮询边界,使上层 poll 先退栈后再轮询下一层状态机。传入边界的 future 必须先装箱;若泛型 helper 直接持有大型 future,即使随后 `spawn`,调用方 async frame 仍会把它保留在默认 worker 栈上。边界必须保留结构化取消语义;当前使用 boxed future 与 `JoinSet`,父 continuation 被丢弃时同步 abort 子任务。不得只增大 CI 的 `RUST_MIN_STACK`,否则生产默认栈仍可能崩溃。
|
||||
- 验证:失败用例必须在未设置 `RUST_MIN_STACK` 时通过;同时覆盖 policy batch 全组、拒绝 pending 后重规划并 drain 下一任务,以及 pending/cancellation 回归,证明恢复不重复生成 isolated spawn、队列继续推进且父任务取消不遗留后台子任务。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs`。
|
||||
|
||||
## Provider 可扩展不能用一个全局 protocol 枚举代替实例隔离
|
||||
@@ -4027,8 +4027,8 @@
|
||||
|
||||
- 现象:旧 worktree 的 AGC Vite 长期占用 `127.0.0.1:3080`,marker 仍指向旧 API;新 worktree 启动 game-chat 后,配套后端在新端口 ready,随后 `beforeDevCommand` 因代理 target 不匹配返回非零,终端已经回到提示符,但原生客户端和它启动的 Runner 仍存活。客户端 WebView 实际加载旧 Vite,因此当前 master 的界面优化看起来全部缺失。
|
||||
- 原因:Tauri 的字符串 `beforeDevCommand` 默认 `wait=false`。只要固定 `devUrl` 上已有可访问页面,Tauri CLI 可以在配套启动脚本完成前创建原生窗口;旧实现又直接从 npm 启动 Tauri CLI,没有在 CLI leader 退出后继续持有其 PGID / Windows 进程树。`start-dev-stack.mjs` 虽会在后端 ready 后识别 marker/API 错配,但检查时机已经晚于窗口创建,且只清理自己登记的后端和 Vite。
|
||||
- 处理:`dev` 与 `game-chat` 统一先进入 `start-tauri-dev.mjs`,在启动 Tauri CLI 前无副作用检查 3080。现有 marker 只有 API target,不能证明监听器属于当前 worktree,因此任何已存在的 3080 都失败关闭,不主动杀不能证明归属的旧服务,也不因 target 看似匹配而复用。Tauri CLI 使用独立 POSIX 进程组,任意退出后按负 PGID 先 TERM、有界等待、再 KILL;Windows 固定调用 `taskkill /PID <pid> /T /F`。`start-dev-stack.mjs` 自己的后端 / Vite 独立组也在返回前有界收束。
|
||||
- 验证:定向测试必须覆盖旧 marker target 在 CLI spawn 前被拒绝、target 看似匹配仍拒绝无归属 Vite、非 HTTP 3080 失败、预检调用顺序、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL,以及 Windows taskkill 的 `/PID /T /F` 参数。人工复验旧 worktree 占用 3080 时,新命令不得启动后端或弹出新窗口;正常启动后退出,确认 Tauri 客户端、Runner 和本轮自有后端 / Vite 均按生命周期收束。
|
||||
- 处理:`dev` 与 `game-chat` 统一先进入 `start-tauri-dev.mjs`,在启动 Tauri CLI 前无副作用检查 3080。现有 marker 只有 API target,不能证明监听器属于当前 worktree,因此任何已存在的 3080 都失败关闭,不主动杀不能证明归属的旧服务,也不因 target 看似匹配而复用。Tauri CLI 使用独立 POSIX 进程组,任意退出后按负 PGID 先 TERM、有界等待、再 KILL;Windows 固定调用 `taskkill /PID <pid> /T /F`。Linux 容器的 PID 1 可能不及时回收已退出的孤儿后代,`kill(-PGID, 0)` 会继续命中 zombie;Linux 探活必须扫描 `/proc/<pid>/stat`,只把同 PGID 的非 zombie 成员视为仍在运行,`/proc` 不可读时继续失败关闭。`start-dev-stack.mjs` 自己的后端 / Vite 独立组也在返回前有界收束。
|
||||
- 验证:定向测试必须覆盖旧 marker target 在 CLI spawn 前被拒绝、target 看似匹配仍拒绝无归属 Vite、非 HTTP 3080 失败、预检调用顺序、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL、Linux 同组只剩 zombie 时视为已停止,以及 Windows taskkill 的 `/PID /T /F` 参数。人工复验旧 worktree 占用 3080 时,新命令不得启动后端或弹出新窗口;正常启动后退出,确认 Tauri 客户端、Runner 和本轮自有后端 / Vite 均按生命周期收束。
|
||||
- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`、`apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts`、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts`。
|
||||
|
||||
## game-chat 快车道首波与已提交回复不能被后续 revision 破坏(2026-08-03)
|
||||
|
||||
@@ -765,7 +765,7 @@ game-project/
|
||||
- 开发模式可通过本地项目文件面板执行 `file.list/read/write/delete`,普通用户界面不暴露文件面板。
|
||||
- 2026-07-17 起,同一 Runtime 文档的“V1.32 Runtime 强制 Supervisor 协作合同”作为 mixed swarm 可靠性事实源。项目可用 `.agent/collaboration-policy.json` 约束首波 static/isolated 模式、数量和 required static Agent;Runtime 在任何 child 副作用前整批校验并把合同指纹固化进 Provider batch v2。当前父 run 一旦形成 delivery/group,正式 `project-supervisor` 默认只负责编排、状态认领和验证,不再直接执行项目 mutation;专业 Agent/isolated child 权限与唯一 Supervisor 最终回复边界保持不变。
|
||||
- 2026-07-17 V1.32 最终代码已完成独立真实 Provider PASS:首批 mixed batch、三 isolated child、Runner 强杀恢复、专业返工、宿主验证、唯一最终回复与零重复/残留/泄漏同时成立。真实报告计数、隔离重试配置和仍待收敛的 tool-plan repair 成本统一以 Runtime 文档 V1.32 章节与共享决策记录为准。
|
||||
- 2026-08-03 恢复执行补充约束:pending action continuation 进入后台主循环时必须先跨越独立 Tokio task 轮询边界,不能让 pending executor、task queue 与 Agent 主循环的大型 async poll frame 在同一 worker 调用栈连续嵌套。边界必须随父 continuation 取消子任务并保持 durable action、batch、run/session 身份及恢复防重语义;当前使用 `JoinSet` 承担结构化取消。CI 和生产均使用默认 worker 栈验证,不以提高 `RUST_MIN_STACK` 代替代码边界。
|
||||
- 2026-08-03 恢复执行补充约束:整个 pending action continuation、它进入的后台主循环,以及完成、取消或失败后 drain 同 Agent 后续队列时,都必须跨越独立 Tokio task 轮询边界,不能让 pending executor、task queue 与 Agent 主循环的大型 async poll frame 在同一 worker 调用栈连续嵌套。边界输入必须先装箱,避免泛型 helper 在真正 spawn 前仍把大型 future 保留在调用方 async frame;边界同时必须随父 continuation 取消子任务并保持 durable action、batch、run/session 身份及恢复防重语义,当前使用 boxed future 与 `JoinSet` 承担该约束。CI 和生产均使用默认 worker 栈验证,不以提高 `RUST_MIN_STACK` 代替代码边界。
|
||||
- 2026-07-18 起,同一 Runtime 文档的“V1.34 动态隔离子 Agent writeScopes 命令绕过封堵”作为 isolated child 的现行能力事实源。在 scope-aware OS sandbox 完成前,动态 child 无条件禁用 `project.verify / project.git_commit / command.exec / command.start / command.stdin / preview.start / agent.delegate / agent.spawn_isolated / project.restore / agent.schedule_ready / canvas.asset_generate / task.create / task.update / blackboard.write` 和全部 MCP;原生工具策略统一显示 `denied`,模板、项目 policy 与用户确认均不能放宽。保留固定只读 `command.run_limited`、同身份 `command.output_read / command.poll / command.terminate`、既有预览的 `preview.validate`,以及严格位于 `writeScopes` 内的 `file.write / file.patch / file.delete / project.patchset`。
|
||||
- V1.34 的新单动作在 confirmation 和 OS launcher 前拒绝;新多 action 原生 batch 只要含一个 denied member 就在独立 pending-action sidecar、confirmation、OS spawn、revision 和任何成员项目副作用前整批 abort,只保留 `aborted / nextActionIndex=0` batch 事实。旧 pending / approval / batch 真正进入执行器时仍重新应用当前 child 边界,旧 executing 未知结果继续进入既有 reconciliation。该安全收紧由恶意 sibling 写入、策略快照、batch、旧 pending 执行器重验和 isolated/mixed/collaboration/provider-batch 回归证明;不因本切片重跑已通过且 isolated mutation 为 0 的 V1.31/V1.32 外部 Provider suite。通用命令只有在后续 scope-aware OS sandbox 对所有后代强制同一 `writeScopes` 并通过独立决策与测试后才可重新评估开放。
|
||||
- 2026-07-18 起,同一 Runtime 文档的“V1.35 多 ready isolated all-join 原子认领与恢复”作为 `agent.run_status` 同父 run 多 group 认领的现行事实源。Runtime 按 `delegationGroupId` 排序并一次性预取全部 join 锁;任一后续锁忙时保持零 delivery mutation、零 claim sidecar。全锁就绪后,同一 action 的 durable claim journal 按 `prepared -> committed -> observed` 推进;部分 commit 或 Runner 恢复只能复用该 journal 幂等补齐。只认领可完整放入优先 `readyIsolatedJoins` 观察预算的有序前缀,未观察旧 claim 可由后续 action 完整重放,但不创建第二份 isolated claim。每个 claimed delivery 必须由匹配原 action/group 的 journal 覆盖;无 journal 的旧 delivery 每轮只迁移一个原 action,已有 journal 不得扩写或状态倒退,跨 action group 归属冲突失败关闭。成功 observation 写入 pending sidecar 后只能把本轮完整输出的 claim 标记 `observed`,任一未观察或无 journal claim 继续阻断 finalization;每个 group 审计按 `actionId + delegationGroupId` 唯一,并在 Agent DB 锁内修复 torn tail、全量核对后幂等追加。
|
||||
|
||||
@@ -60,7 +60,7 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模
|
||||
|
||||
AI 游戏创作客户端使用 `npm run agc`,开发态 game-chat 使用 `npm run agc:game-chat`。两个入口都先由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在 Tauri CLI 启动前检查固定地址 `http://127.0.0.1:3080/`:只有端口空闲时才继续启动。现有 marker 只包含 API target,不能证明监听器属于当前 worktree;即使页面和 target 看似匹配,也不得复用已经存在的 3080。旧 worktree Vite、无响应监听器或非 AGC 服务一律在创建原生窗口前失败关闭,并提示先停止旧服务;启动器不擅自终止无法证明归属的进程。
|
||||
|
||||
Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:旧 3080 已就绪时,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID <pid> /T /F`。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对 3080 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
|
||||
Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:旧 3080 已就绪时,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID <pid> /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc/<pid>/stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对 3080 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
|
||||
|
||||
Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user