Compare commits

..

3 Commits

Author SHA1 Message Date
k88936 053e0bd932 Thread Manager:待发消息队列的入队 / 取消 / 放行认领
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m31s
Project CI / Backend tests (pull_request) Successful in 4m1s
Project CI / Frontend tests (pull_request) Successful in 2m5s
Project CI / Native shell tests (pull_request) Successful in 6m7s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m0s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 10m3s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m52s
Project CI / Repository checks (pull_request) Successful in 2m17s
- `StoredEvent` 增加非序列化的 `pending` 产物字段:它的有无就是队列成员身份,队列的先后就是事件先后,不另建队列表
- 新增 `enqueue_pending_turn`:按 `clientTurnId` 幂等(判重范围「在队 ∪ 正在跑的那一轮」),容量用 `queue_has_room` 挡在队条目
- 新增 `remove_pending_turn`:typed 结果 `Removed | AlreadyDispatched | NotFound`,在临界区里取走产物并追加 `queue.removed{cancelled}`
- 新增 `claim_pending_turn`:同一临界区取队首 → 登记占用 → 追加 `turn.started` 与 `queue.removed{dispatched}`,已有未收口回合或队列为空时返回 `None`
- `mark_queue_events_cleanable` 跳过仍挂着产物的条目,保证在队条目永远不会被回收
- 新增薄包装 `enqueue_direct_pending_turn` / `remove_direct_pending_turn` / `claim_direct_pending_turn`,并补 5 条单测覆盖 bootstrap 可见、幂等、容量、放行原子性与三种取消结果
2026-09-24 21:11:15 +08:00
k88936 faf85837bd 运行态事件:待发消息队列的入队/移除事件与队列条目规则
- 新增 `DirectThreadEvent::QueueEnqueued`(`queue.enqueued`,带 canonical 用户条目与可选 `creationType`,不带 prompt)与 `QueueRemoved`(`queue.removed`,`reason` 为 typed 枚举 `cancelled | dispatched`)
- 新增 typed 枚举 `DirectQueueRemovalReason` 与 `DirectQueueRemovalOutcome`,并重跑 ts-rs 绑定
- 新增 `agent/direct_thread_queue.rs`:`PendingDirectTurn`(入队时冻结 canonical 形状与 prompt)、上限 `MAX_PENDING_DIRECT_TURNS = 5`、`EnqueueOutcome` / `EnqueueRejection`
- Thread Manager 的 `observe_event` 认队列事件:在队期间的 `queue.enqueued` 不可回收,`queue.removed` 把它转成可回收,新订阅者的 bootstrap 因此天然看得见当前队列
- `DirectCodexUserItem` 及其子类型补 `PartialEq`,`DirectThreadEvent` 不再需要 `Eq`
2026-09-24 20:58:08 +08:00
k88936 1e7ea51368 文档:DirectProject 命令入队化与待发消息队列归宿主设计定稿
- 新增 ADR:命令=入队、放行归 Thread Manager、待发消息队列作为运行态事件归宿主
- 新增实施计划:五步落地顺序、标识符映射表、每步不变式与验收证据
- CONTEXT.md:接单/拒单词条替换为入队/入队失败/放行,新增待发消息队列与待发消息词条
- docs/README.md 补两条索引;decision-log.md 追加同日决策记录
2026-09-24 20:46:53 +08:00
46 changed files with 2014 additions and 4986 deletions
-2
View File
@@ -57,8 +57,6 @@ temp*build*/
/apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
/apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/
/apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/
/apps/ai-game-creator-shell/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json
/apps/mobile-shell/.expo/
+21 -9
View File
@@ -192,7 +192,7 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject)
**DirectProject 专属聊天模块**:
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、待发消息队列的投影、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
**项目工作台布局**:
@@ -208,20 +208,32 @@ Thread Manager 向订阅者推送的当前回合原始事件流,只服务运
_Avoid_: 进度通知、快照轮询、第二套历史
**逻辑回合**:
Thread Manager 拥有的一对回合边界(开始与结束),由接单动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。
Thread Manager 拥有的一对回合边界(开始与结束),由放行动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。
_Avoid_: Codex 原生回合、原生日志、进程生命周期
**接单**:
把一条用户消息交给宿主开始执行的动作,成立即表示这一轮已经存在;此后结果只由运行态事件回答。
_Avoid_: 发送成功、命令调用、接口返回
**待发消息队列**:
Thread Manager 按项目持有的待发用户消息序列,只支持按入队顺序追加与按身份移除,状态由运行态事件派生,不落盘、不构成第二份事实源。
_Avoid_: 前端本地队列、队列副本、待发消息的持久化记录
**拒单**:
接单成立之前拒绝这次请求(并发、权限、目录、参数、工程准备未就绪),只回一条可展示原因,不产生回合事件,也不写用户条目。
**待发消息**:
已经通过入队检查、等待被放行的用户消息;它在放行之前不是回合,不写用户条目、不产生回合事件。
_Avoid_: 回合、在途回合、草稿
**入队**:
把一条用户消息交给宿主的动作:宿主跑完入队检查后把它放进待发消息队列;入队成立只表示这条消息会按顺序被放行。
_Avoid_: 发送成功、已经开跑、回合成立
**入队失败**:
入队检查未通过(身份、形状、容量、权限、目录、参数、工程准备未就绪)时拒绝这次请求,只回一条可展示原因,不入队、不产生回合事件,也不写用户条目。
_Avoid_: 回合失败、执行失败、失败事件
**放行**:
Thread Manager 在一个回合收口之后把队首的待发消息送进回合:同一临界区里登记占用、落盘用户条目、发出逻辑回合开始事件并起整轮;放行之后的结果只由运行态事件回答。
_Avoid_: 前端放行、定时轮询、放行失败
**在途回合**:
界面本地已经把这条用户消息发出去、宿主还没有对应回合开始事件的那一小段状态。
_Avoid_: 运行中回合、乐观锁、发送队列
界面本地已经入队、宿主还没有对应回合开始事件的那一小段状态。
_Avoid_: 运行中回合、乐观锁、前端发送队列
**聊天投影**:
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
+4
View File
@@ -0,0 +1,4 @@
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
# “构建 -> 监听 -> 再构建”的自触发循环。
resources/plugins/
+1 -5
View File
@@ -13,10 +13,6 @@
"skill-pack:check": "node scripts/check-skill-pack.mjs",
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
"bundled-resources:check": "node scripts/check-package-layout.mjs",
"bundled-resources:sync": "node scripts/check-package-layout.mjs --write",
"bundled-resources:prepare": "node scripts/prepare-bundled-resources.mjs",
"bundled-resources:test": "node --test scripts/prepare-bundled-resources.test.mjs",
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
"config": "node scripts/game-creator-config-wizard.mjs",
@@ -28,7 +24,7 @@
"agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill",
"agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs",
"agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill",
"typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs"
"typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && node scripts/check-config.mjs"
},
"dependencies": {
"@cubone/react-file-manager": "^1.35.0",
@@ -18,7 +18,6 @@ import {
resolveReleaseChannel,
} from './channel-identity.mjs';
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
import { prepareBundledResources } from './prepare-bundled-resources.mjs';
import { stageNodeRuntime } from './stage-node-runtime.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -432,30 +431,10 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
return configPath;
}
/// 随包资源必须在打包工具之前生成:构建脚本只做只读校验,不再生成。
export function stageBundledResources(
target,
{ prepare = prepareBundledResources } = {},
) {
const summaries = prepare({
target,
features: new Set(defaultEditorFeatures(target)),
profile: 'release',
log: (line) => console.log(`[ai-game-creator-shell] ${line}`),
});
for (const summary of summaries) {
console.log(`[ai-game-creator-shell] ${summary}`);
}
}
export function runTauriBuild(
args = [],
context = resolveReleaseContext(args),
{
spawn = spawnSync,
stageRuntime = stageNodeRuntime,
stageBundled = stageBundledResources,
} = {},
{ spawn = spawnSync, stageRuntime = stageNodeRuntime } = {},
) {
if (
explicitBuildTarget(args) &&
@@ -465,10 +444,7 @@ export function runTauriBuild(
}
const tauriArguments = buildTauriBuildArguments(args, context.target);
const { channel, target } = context;
if (!args.includes('--no-bundle')) {
stageRuntime(target);
stageBundled(target);
}
if (!args.includes('--no-bundle')) stageRuntime(target);
const configPath = writeChannelConfigFile(
channel,
target,
@@ -363,8 +363,6 @@ test('packaged renderer receives the same channel as the updater manifest', () =
// 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的
// Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。
stageRuntime: () => {},
// 同上:随包资源准备会读取真实上游包,本用例只关心渠道环境变量。
stageBundled: () => {},
spawn: (_binary, _args, options) => {
spawnOptions = options;
return { status: 0 };
@@ -458,8 +456,6 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
seenContexts.push(context);
runTauriBuild(args, context, {
stageRuntime: () => {},
// 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。
stageBundled: () => {},
spawn: (_binary, command) => {
const configIndex = command.lastIndexOf('--config');
const config = JSON.parse(
@@ -709,7 +705,6 @@ test('Windows remains the default and explicit Windows overrides macOS environme
context,
{
stageRuntime: () => {},
stageBundled: () => {},
spawn: (_binary, command) => {
assert.ok(
command.includes(
@@ -823,10 +818,6 @@ test('release stages Node before Tauri and injects its resource mapping only for
assert.equal(target, windowsTarget);
events.push('stage');
},
stageBundled(target) {
assert.equal(target, windowsTarget);
events.push('bundled');
},
spawn(_binary, args) {
events.push('build');
const config = JSON.parse(
@@ -838,14 +829,11 @@ test('release stages Node before Tauri and injects its resource mapping only for
return { status: 0 };
},
});
assert.deepEqual(events, ['stage', 'bundled', 'build']);
assert.deepEqual(events, ['stage', 'build']);
runTauriBuild(['--no-bundle', '--target', windowsTarget], context, {
stageRuntime() {
assert.fail('no-bundle must not stage resources');
},
stageBundled() {
assert.fail('no-bundle must not stage bundled resources');
},
spawn(_binary, args) {
const config = JSON.parse(
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
@@ -860,9 +848,6 @@ test('release stages Node before Tauri and injects its resource mapping only for
stageRuntime() {
throw new Error('missing runtime');
},
stageBundled() {
assert.fail('invalid runtime must prevent bundled staging');
},
spawn() {
assert.fail('invalid runtime must prevent build');
},
@@ -975,8 +960,6 @@ for (const channel of ['release', 'beta-2']) {
);
runTauriBuild([`--target=${target}`], context, {
stageRuntime: () => {},
// 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。
stageBundled: () => {},
spawn: (_binary, command) => {
const config = JSON.parse(
readFileSync(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,6 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs';
import {
defaultEditorFeatures,
withDefaultCargoFeatures,
@@ -11,10 +10,6 @@ import {
resolveAgcDevEndpoint,
withAgcDevEndpointEnv,
} from './dev-port.mjs';
import {
prepareBundledResources,
supportedHostTarget,
} from './prepare-bundled-resources.mjs';
import {
isAiGameCreatorServer,
preflightExistingVite,
@@ -72,29 +67,6 @@ function withDevCargoFeatures(argv, features = readDevCargoFeatures()) {
return withDefaultCargoFeatures(argv, features);
}
/// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。
/// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。
function prepareBundledResourcesBeforeTauri(
features = readDevCargoFeatures(),
{ prepare = prepareBundledResources, log = console.log } = {},
) {
const target = supportedHostTarget();
if (!target) {
log(
'[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备',
);
return;
}
const summaries = prepare({
target,
features: new Set(features),
log: (line) => log(`[ai-game-creator-shell] ${line}`),
});
for (const summary of summaries) {
log(`[ai-game-creator-shell] ${summary}`);
}
}
function spawnTauriCli(argv, { env = process.env } = {}) {
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
cwd: appRoot,
@@ -103,17 +75,6 @@ function spawnTauriCli(argv, { env = process.env } = {}) {
});
}
/// Tauri dev 的 Cargo 直接继承启动器环境,用户级 / 仓库级 Cargo 配置里的
/// `rustc-wrapper`(本地常见为 sccache)会在这里生效。本地 sccache daemon 状态
/// 一旦损坏,`cargo` 的首次 rustc 探测就会失败并阻断整个 AGC 启动;因此这里复用
/// `npm run dev` 的本地 Rust 环境规则,由脚本而不是本机 Cargo 配置决定 wrapper。
function buildTauriDevProcessEnv(endpoint, env = process.env) {
return buildLocalRustProcessEnv({
...withAgcDevEndpointEnv(endpoint, env),
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
});
}
async function runTauriDev(
argv = process.argv.slice(2),
{
@@ -123,7 +84,6 @@ async function runTauriDev(
spawnCli = spawnTauriCli,
waitForCli = waitForChildTermination,
terminateTree = terminateChildTree,
prepareResources = prepareBundledResourcesBeforeTauri,
} = {},
) {
const endpoint = await resolveDevEndpoint();
@@ -170,14 +130,15 @@ async function runTauriDev(
shutdownRequested.then(() => false),
]);
if (!prepared || shutdownSignal) return 1;
const devFeatures = readDevCargoFeatures();
prepareResources(devFeatures);
const tauriArguments = buildTauriArguments(
withDevCargoFeatures(argv, devFeatures),
withDevCargoFeatures(argv),
endpoint.url,
);
child = spawnCli(tauriArguments, {
env: buildTauriDevProcessEnv(endpoint),
env: {
...withAgcDevEndpointEnv(endpoint),
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
},
});
const childResult = waitForCli(child);
const outcome = await Promise.race([
@@ -264,9 +225,7 @@ function isDirectModuleExecution() {
export {
buildTauriArguments,
buildTauriDevProcessEnv,
isDirectModuleExecution,
prepareBundledResourcesBeforeTauri,
runTauriDev,
spawnTauriCli,
withDevCargoFeatures,
@@ -0,0 +1,4 @@
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
# “构建 -> 监听 -> 再构建”的自触发循环。
resources/plugins/
File diff suppressed because it is too large Load Diff
@@ -1,18 +1,8 @@
//! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。
//!
//! 布局、组件白名单与版本常量来自唯一声明 `build_support/package-layout.json`
//! (Rust 侧经 `build_support/package-layout.generated.rs` 取得编译期常量,
//! 由 `scripts/check-package-layout.mjs` 生成并在门禁中校验一致)。
//! 本模块只读声明,不写任何随包资源。
// 共享声明模块:构建脚本、运行期与测试各自只用到其中一部分,未用到的入口不算缺陷。
#[allow(dead_code)]
#[path = "package_layout.rs"]
pub(crate) mod package_layout;
pub const VERSION: &str = package_layout::CODEX_VERSION;
pub const CLI_VERSION: &str = package_layout::CODEX_CLI_VERSION;
pub const SCHEMA: &str = package_layout::CODEX_MANIFEST_SCHEMA;
pub const VERSION: &str = "0.155.1";
pub const CLI_VERSION: &str = "codex-cli 0.155.1";
pub const SCHEMA: &str = "genarrative-codex-sidecar.v2";
#[derive(Clone, Copy, Debug)]
pub struct Layout {
@@ -22,13 +12,46 @@ pub struct Layout {
pub files: &'static [&'static str],
}
const WINDOWS_FILES: &[&str] = &[
"bin/codex.exe",
"bin/codex-code-mode-host.exe",
"codex-path/rg.exe",
"codex-resources/codex-command-runner.exe",
"codex-resources/codex-windows-sandbox-setup.exe",
"codex-package.json",
];
const MAC_FILES: &[&str] = &[
"bin/codex",
"bin/codex-code-mode-host",
"codex-path/rg",
"codex-resources/zsh/bin/zsh",
"codex-package.json",
];
pub fn for_target(target: &str) -> Option<Layout> {
package_layout::codex_target(target).map(|declared| Layout {
platform: declared.platform,
directory: declared.directory,
executable: declared.executable,
files: declared.files,
})
match target {
"x86_64-pc-windows-msvc" => Some(Layout {
platform: "win32-x64",
directory: "win-x64",
executable: "bin/codex.exe",
files: WINDOWS_FILES,
}),
"aarch64-apple-darwin" | "x86_64-apple-darwin" => Some(Layout {
platform: if target.starts_with("aarch64") {
"darwin-arm64"
} else {
"darwin-x64"
},
directory: if target.starts_with("aarch64") {
"mac-native/darwin-arm64"
} else {
"mac-native/darwin-x64"
},
executable: "bin/codex",
files: MAC_FILES,
}),
_ => None,
}
}
#[cfg(test)]
@@ -57,11 +80,4 @@ mod tests {
assert!(for_target("aarch64-pc-windows-msvc").is_none());
assert!(for_target("x86_64-unknown-linux-gnu").is_none());
}
#[test]
fn constants_come_from_the_shared_declaration() {
assert_eq!(VERSION, "0.155.1");
assert_eq!(CLI_VERSION, format!("codex-cli {VERSION}"));
assert_eq!(SCHEMA, "genarrative-codex-sidecar.v2");
}
}
@@ -0,0 +1,57 @@
//! 随包阶段的原生包元数据校验,不进入运行时生产模块。
use super::codex_bundle::{Layout, VERSION};
pub fn validate_package_metadata(
metadata: &serde_json::Value,
target: &str,
layout: Layout,
) -> Result<(), String> {
if metadata["layoutVersion"] == 1
&& metadata["version"] == VERSION
&& metadata["target"] == target
&& metadata["entrypoint"] == layout.executable
&& metadata["resourcesDir"] == "codex-resources"
&& metadata["pathDir"] == "codex-path"
{
Ok(())
} else {
Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}"))
}
}
#[cfg(test)]
mod tests {
use super::super::codex_bundle::for_target;
use super::*;
#[test]
fn metadata_rejects_version_architecture_and_layout_drift() {
let target = "aarch64-apple-darwin";
let layout = for_target(target).unwrap();
let valid = serde_json::json!({
"layoutVersion": 1,
"version": VERSION,
"target": target,
"entrypoint": "bin/codex",
"resourcesDir": "codex-resources",
"pathDir": "codex-path",
});
assert!(validate_package_metadata(&valid, target, layout).is_ok());
for (key, value) in [
("layoutVersion", serde_json::json!(2)),
("version", serde_json::json!("0.0.0")),
("target", serde_json::json!("x86_64-apple-darwin")),
("entrypoint", serde_json::json!("bin/codex.exe")),
("resourcesDir", serde_json::json!("../private")),
("pathDir", serde_json::json!(null)),
] {
let mut invalid = valid.clone();
invalid[key] = value;
assert!(
validate_package_metadata(&invalid, target, layout).is_err(),
"{key}"
);
}
}
}
@@ -1,13 +1,13 @@
use sha2::{Digest, Sha256};
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
// 共享随包资源声明:Godot 随包文件清单与构建期校验共用同一份来源。
#[allow(dead_code)]
#[path = "package_layout.rs"]
mod package_layout;
pub const BUNDLE_FILES: &[&str] = package_layout::GODOT_BUNDLE_FILES;
pub const BUNDLE_FILES: [&str; 4] = [
"bin/win-x64/agc_godot_editor.dll",
"bin/win-x64/metadata.json",
"vendor/LICENSE.txt",
"vendor/provenance.json",
];
fn plain_metadata(path: &Path) -> Result<fs::Metadata, String> {
let metadata = fs::symlink_metadata(path)
@@ -74,6 +74,39 @@ pub fn validate(root: &Path) -> Result<Vec<(&'static str, Vec<u8>)>, String> {
Ok(files)
}
pub fn stage(root: &Path, destination: &Path, target: &str, enabled: bool) -> Result<(), String> {
if target != "x86_64-pc-windows-msvc" || !enabled {
return Ok(());
}
for (relative, bytes) in validate(root)? {
let path = destination.join(relative);
fs::create_dir_all(path.parent().expect("Godot resource parent"))
.map_err(|error| format!("创建 Godot 资源目录失败:{error}"))?;
fs::write(&path, bytes).map_err(|error| format!("写入 Godot 资源失败:{error}"))?;
}
Ok(())
}
pub fn source_files(root: &Path) -> Result<Vec<PathBuf>, String> {
plain_metadata(root)?;
let mut sources = Vec::new();
for entry in fs::read_dir(root).map_err(|error| format!("读取 Godot 源码失败:{error}"))?
{
let entry = entry.map_err(|error| format!("读取 Godot 源码目录项失败:{error}"))?;
if matches!(entry.file_name().to_str(), Some("bin" | ".build")) {
continue;
}
let metadata = plain_metadata(&entry.path())?;
if metadata.is_dir() {
sources.extend(source_files(&entry.path())?);
} else if metadata.is_file() {
sources.push(entry.path());
}
}
sources.sort();
Ok(sources)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -99,4 +132,84 @@ mod tests {
)
.unwrap();
}
#[test]
fn stage_only_verified_windows_runtime_and_not_build_inputs() {
let source = tempfile::tempdir().unwrap();
let destination = tempfile::tempdir().unwrap();
fixture(source.path());
fs::write(source.path().join("bridge.gd"), "source").unwrap();
fs::write(source.path().join("bin/win-x64/extra.dll"), "excluded").unwrap();
stage(
source.path(),
destination.path(),
"x86_64-pc-windows-msvc",
true,
)
.unwrap();
for relative in BUNDLE_FILES {
assert_eq!(
fs::read(source.path().join(relative)).unwrap(),
fs::read(destination.path().join(relative)).unwrap()
);
}
assert!(!destination.path().join("bridge.gd").exists());
assert!(!destination.path().join("bin/win-x64/extra.dll").exists());
}
#[test]
fn unsupported_or_disabled_targets_need_no_native_artifacts() {
let destination = tempfile::tempdir().unwrap();
for (target, enabled) in [
("aarch64-apple-darwin", true),
("x86_64-apple-darwin", true),
("x86_64-unknown-linux-gnu", true),
("aarch64-pc-windows-msvc", true),
("x86_64-pc-windows-msvc", false),
] {
stage(
Path::new("missing-godot-native"),
destination.path(),
target,
enabled,
)
.unwrap();
assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0);
}
}
#[test]
fn incomplete_or_tampered_bundle_fails_before_copying() {
let source = tempfile::tempdir().unwrap();
let destination = tempfile::tempdir().unwrap();
fixture(source.path());
fs::write(source.path().join(BUNDLE_FILES[0]), b"tampered").unwrap();
assert!(stage(
source.path(),
destination.path(),
"x86_64-pc-windows-msvc",
true
)
.unwrap_err()
.contains("SHA256"));
assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0);
fixture(source.path());
fs::remove_file(source.path().join("vendor/LICENSE.txt")).unwrap();
assert!(validate(source.path()).is_err());
}
#[test]
fn source_watch_list_excludes_build_outputs() {
let source = tempfile::tempdir().unwrap();
fixture(source.path());
fs::create_dir(source.path().join(".build")).unwrap();
fs::write(source.path().join(".build/bridge.obj"), "generated").unwrap();
fs::write(source.path().join("bridge.gd"), "source").unwrap();
let sources = source_files(source.path()).unwrap();
assert_eq!(sources.len(), 3);
assert!(sources.contains(&source.path().join("bridge.gd")));
assert!(!sources.iter().any(|path| path
.components()
.any(|component| component.as_os_str() == "bin" || component.as_os_str() == ".build")));
}
}
@@ -1,132 +0,0 @@
// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs
// 来源:build_support/package-layout.json。不要手工编辑本文件。
// 修改随包资源布局请编辑声明文件,然后运行
// npm run agc:package-layout:sync(在仓库根目录)
// 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。
pub const DECLARATION_SCHEMA: &str = "agc-package-layout.v1";
pub const LAYOUT_VERSION: u64 = 1;
pub const CODEX_VERSION: &str = "0.155.1";
pub const CODEX_CLI_VERSION: &str = "codex-cli 0.155.1";
pub const CODEX_MANIFEST_SCHEMA: &str = "genarrative-codex-sidecar.v2";
pub const GODOT_BUNDLE_FILES: &[&str] = &["bin/win-x64/agc_godot_editor.dll", "bin/win-x64/metadata.json", "vendor/LICENSE.txt", "vendor/provenance.json"];
pub const CODEX: Codex = Codex {
package_metadata: PackageMetadata {
layout_version: 1,
resources_dir: "codex-resources",
path_dir: "codex-path",
},
resource_directory: "resources/codex",
manifest_file_name: "manifest.json",
package_metadata_file_name: "codex-package.json",
notice_file_name: "NOTICE.md",
source_roots: &["app", "repo"],
source_relative_paths: &["node_modules/@openai/codex-<platform>/vendor/<target>", "node_modules/@openai/codex/node_modules/@openai/codex-<platform>/vendor/<target>"],
notice_sources: &[
NoticeSource {
targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"],
source: "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md",
preserve: false,
},
NoticeSource {
targets: &["x86_64-pc-windows-msvc"],
source: "resources/codex/win-x64/NOTICE.md",
preserve: true,
}
],
universal_groups: &[
UniversalGroup {
name: "mac-native",
directory: "mac-native",
targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"],
}
],
targets: &[
CodexTarget {
target: "x86_64-pc-windows-msvc",
platform: "win32-x64",
directory: "win-x64",
executable: "bin/codex.exe",
files: &["bin/codex.exe", "bin/codex-code-mode-host.exe", "codex-path/rg.exe", "codex-resources/codex-command-runner.exe", "codex-resources/codex-windows-sandbox-setup.exe", "codex-package.json"],
},
CodexTarget {
target: "aarch64-apple-darwin",
platform: "darwin-arm64",
directory: "mac-native/darwin-arm64",
executable: "bin/codex",
files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"],
},
CodexTarget {
target: "x86_64-apple-darwin",
platform: "darwin-x64",
directory: "mac-native/darwin-x64",
executable: "bin/codex",
files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"],
}
],
};
pub const PLUGINS: Plugins = Plugins {
source_directory: "plugins",
destination_directory: "resources/plugins",
manifest_file_name: "plugin.json",
target_contains_any: &["windows", "apple-darwin"],
subdirectories: &[
Subdirectory {
path: "src",
plugin: "",
origin: "source",
target_contains: &[],
targets: &[],
features: &[],
},
Subdirectory {
path: "panels",
plugin: "",
origin: "source",
target_contains: &[],
targets: &[],
features: &[],
},
Subdirectory {
path: "skills",
plugin: "",
origin: "source",
target_contains: &[],
targets: &[],
features: &[],
},
Subdirectory {
path: "native/payload",
plugin: "agc-cocos-editor",
origin: "prepared",
target_contains: &["windows"],
targets: &[],
features: &["cocos-editor-injection"],
},
Subdirectory {
path: "dotnet/publish/win-x64",
plugin: "agc-unity-editor",
origin: "prepared",
target_contains: &[],
targets: &["x86_64-pc-windows-msvc"],
features: &["unity-editor-execute"],
}
],
library_staging: &[
LibraryStaging {
plugin: "agc-godot-editor",
source_subdirectory: "native/gdextension",
targets: &["x86_64-pc-windows-msvc"],
features: &["godot-editor-execute"],
layout: "godot-bundle",
}
],
skip_directory_names: &["target", "node_modules"],
skip_directory_name_prefixes: &["."],
skip_file_name_prefixes: &["."],
skip_file_name_fragments: &[".test."],
};
@@ -1,258 +0,0 @@
{
"schema": "agc-package-layout.v1",
"layoutVersion": 1,
"description": "AGC 随包资源布局与复制规则的唯一声明。Rust 侧构建期校验与 Node 侧准备步骤共用本文件,任何一侧都不得再写第二份布局或组件白名单。含 <platform>、<target> 占位符的字段由调用方按目标三元展开。修改布局时同步递增 layoutVersion(准备步骤的缓存 key 组成部分)。",
"codex": {
"version": "0.155.1",
"cliVersionPrefix": "codex-cli ",
"manifestSchema": "genarrative-codex-sidecar.v2",
"packageMetadata": {
"layoutVersion": 1,
"resourcesDir": "codex-resources",
"pathDir": "codex-path"
},
"resourceDirectory": "resources/codex",
"manifestFileName": "manifest.json",
"packageMetadataFileName": "codex-package.json",
"noticeFileName": "NOTICE.md",
"sourceRoots": [
"app",
"repo"
],
"sourceRelativePaths": [
"node_modules/@openai/codex-<platform>/vendor/<target>",
"node_modules/@openai/codex/node_modules/@openai/codex-<platform>/vendor/<target>"
],
"noticeSources": [
{
"targets": [
"aarch64-apple-darwin",
"x86_64-apple-darwin"
],
"source": "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md",
"preserve": false
},
{
"targets": [
"x86_64-pc-windows-msvc"
],
"source": "resources/codex/win-x64/NOTICE.md",
"preserve": true
}
],
"universalGroups": [
{
"name": "mac-native",
"directory": "mac-native",
"targets": [
"aarch64-apple-darwin",
"x86_64-apple-darwin"
]
}
],
"targets": [
{
"target": "x86_64-pc-windows-msvc",
"platform": "win32-x64",
"directory": "win-x64",
"executable": "bin/codex.exe",
"files": [
"bin/codex.exe",
"bin/codex-code-mode-host.exe",
"codex-path/rg.exe",
"codex-resources/codex-command-runner.exe",
"codex-resources/codex-windows-sandbox-setup.exe",
"codex-package.json"
]
},
{
"target": "aarch64-apple-darwin",
"platform": "darwin-arm64",
"directory": "mac-native/darwin-arm64",
"executable": "bin/codex",
"files": [
"bin/codex",
"bin/codex-code-mode-host",
"codex-path/rg",
"codex-resources/zsh/bin/zsh",
"codex-package.json"
]
},
{
"target": "x86_64-apple-darwin",
"platform": "darwin-x64",
"directory": "mac-native/darwin-x64",
"executable": "bin/codex",
"files": [
"bin/codex",
"bin/codex-code-mode-host",
"codex-path/rg",
"codex-resources/zsh/bin/zsh",
"codex-package.json"
]
}
]
},
"plugins": {
"sourceDirectory": "plugins",
"destinationDirectory": "resources/plugins",
"manifestFileName": "plugin.json",
"targetContainsAny": [
"windows",
"apple-darwin"
],
"subdirectories": [
{
"path": "src",
"origin": "source"
},
{
"path": "panels",
"origin": "source"
},
{
"path": "skills",
"origin": "source"
},
{
"path": "native/payload",
"origin": "prepared",
"targetContains": [
"windows"
],
"plugin": "agc-cocos-editor",
"prepare": "cocos-bridge-build",
"features": [
"cocos-editor-injection"
]
},
{
"path": "dotnet/publish/win-x64",
"origin": "prepared",
"prepare": "unity-helper-publish",
"targets": [
"x86_64-pc-windows-msvc"
],
"features": [
"unity-editor-execute"
],
"plugin": "agc-unity-editor"
}
],
"libraryStaging": [
{
"plugin": "agc-godot-editor",
"sourceSubdirectory": "native/gdextension",
"prepare": "godot-extension-build",
"targets": [
"x86_64-pc-windows-msvc"
],
"features": [
"godot-editor-execute"
],
"layout": "godot-bundle",
"files": [
"bin/win-x64/agc_godot_editor.dll",
"bin/win-x64/metadata.json",
"vendor/LICENSE.txt",
"vendor/provenance.json"
]
}
],
"nativePayloads": [
{
"plugin": "agc-cocos-editor",
"prepare": "cocos-bridge-build",
"sourceFileName": "cocos_editor_bridge.dll",
"destinationSubdirectory": "native/payload",
"targets": [
"x86_64-pc-windows-msvc"
],
"features": [
"cocos-editor-injection"
],
"destinationFileName": "cocos-editor-bridge.dll"
}
],
"prepareSteps": [
{
"name": "unity-helper-publish",
"kind": "powershell",
"workingDirectory": "plugins/agc-unity-editor/dotnet",
"scriptFileName": "build.ps1",
"fingerprint": {
"roots": [
"."
],
"excludeDirectoryNames": [
"bin",
"obj",
"publish",
"native-build"
],
"stampRelativePath": "publish/win-x64/.agc-source.sha256"
},
"requiredOutputs": [
"plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe",
"plugins/agc-unity-editor/dotnet/publish/win-x64/NOTICE",
"plugins/agc-unity-editor/dotnet/publish/win-x64/THIRD-PARTY-NOTICES.txt",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/DotCraft-Apache-2.0.txt",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/Roslyn-MIT.txt",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/upstream.json",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/dotnet-LICENSE.TXT",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/dotnet-THIRD-PARTY-NOTICES.TXT",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf"
]
},
{
"name": "godot-extension-build",
"kind": "powershell",
"workingDirectory": "plugins/agc-godot-editor/native/gdextension",
"scriptFileName": "build.ps1",
"removeEnvironment": [
"PSModulePath"
],
"requiredOutputs": [
"plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll",
"plugins/agc-godot-editor/native/gdextension/bin/win-x64/metadata.json",
"plugins/agc-godot-editor/native/gdextension/vendor/LICENSE.txt",
"plugins/agc-godot-editor/native/gdextension/vendor/provenance.json"
],
"fingerprint": {
"roots": [
"."
],
"excludeDirectoryNames": [
"bin",
".build",
"native-build"
],
"stampRelativePath": "bin/win-x64/.agc-source.sha256"
}
},
{
"name": "cocos-bridge-build",
"kind": "cargo",
"packageDirectory": "plugins/agc-cocos-editor/native/cocos-editor-bridge",
"features": [
"windows-injection"
],
"requiredOutputs": []
}
],
"skipDirectoryNames": [
"target",
"node_modules"
],
"skipDirectoryNamePrefixes": [
"."
],
"skipFileNamePrefixes": [
"."
],
"skipFileNameFragments": [
".test."
]
}
}
File diff suppressed because it is too large Load Diff
@@ -29,6 +29,7 @@ mod direct_project_history;
mod direct_project_turn_history;
mod direct_runtime;
mod direct_thread_manager;
mod direct_thread_queue;
mod direct_thread_wire;
mod direct_tool_bridge;
mod direct_tool_calls;
@@ -65,6 +66,7 @@ pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*;
pub(crate) use direct_thread_manager::*;
pub(crate) use direct_thread_queue::*;
pub(crate) use direct_thread_wire::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*;
@@ -8,6 +8,11 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
#[path = "../../build_support/codex_bundle.rs"]
pub(crate) mod codex_bundle;
// 复用构建端校验的既有单测,生产运行时只编译共享布局。
#[cfg(test)]
#[path = "../../build_support/codex_package_metadata.rs"]
mod codex_package_metadata;
const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex";
const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024;
const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024;
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use ts_rs::TS;
/// DirectProject 本轮 user input 的唯一结构化入口。
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)]
#[serde(tag = "type", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectCodexUserItem {
@@ -10,7 +10,7 @@ pub(crate) enum DirectCodexUserItem {
Message(DirectCodexUserMessageItem),
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectCodexUserMessageItem {
@@ -19,14 +19,14 @@ pub(crate) struct DirectCodexUserMessageItem {
pub(crate) id: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectCodexUserRole {
User,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)]
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectCodexUserContentPart {
@@ -43,7 +43,7 @@ pub(crate) enum DirectCodexUserContentPart {
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectCodexUserAttachmentReferencePart {
@@ -55,7 +55,7 @@ pub(crate) struct DirectCodexUserAttachmentReferencePart {
pub(crate) status: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectCodexUserRuntimeRegionPart {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,200 @@
//! DirectProject 待发消息队列的条目与规则。
//!
//! 队列的成员与顺序**就是 Thread Manager 的事件列表本身**:一条待发消息在队期间,它的
//! `queue.enqueued` 事件不可回收;离开队列(取消或放行)时才转成可回收。新订阅者的 bootstrap 因此
//! 天然看得见当前队列,不需要第二张队列表,也不会有"事件与队列不一致"的窗口。
//!
//! 这个模块只放三件事:一条待发消息带走什么([`PendingDirectTurn`])、容量规则
//! ([`MAX_PENDING_DIRECT_TURNS`])、以及它在线上长什么样([`PendingDirectTurn::enqueued_event`])。
//! 它不碰锁、不碰 Tauri、不写盘:入队检查在命令侧,放行顺序在 Thread Manager。
use serde_json::Value;
use crate::agent::{
direct_codex_user_item_id_for_client_turn_id, DirectCodexUserItem, DirectThreadEvent,
};
/// 一个项目最多能同时排队的待发消息条数。
///
/// 只数**在队**条目,不算正在跑的那一轮。上限只落在宿主这一处:前端不再自己数,满队由命令返回
/// typed 入队失败。
pub(crate) const MAX_PENDING_DIRECT_TURNS: usize = 5;
/// 一条已经通过入队检查、正在等放行的用户消息。
///
/// 只在内存里,进程重启即消失(与 ADR 记的边界一致)。它同时是**放行时要用的全部输入**:放行
/// 没有失败出口,所以检查产物在入队时就地冻结,放行只搬运、不重算。
#[derive(Clone, Debug)]
pub(crate) struct PendingDirectTurn {
/// 这条消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。
pub(crate) client_turn_id: String,
/// canonical 用户条目:事件与界面 chip 都读它,Rust 不渲染展示形状。
pub(crate) user_item: DirectCodexUserItem,
/// canonical 用户条目的 JSON 形状:入队时算好,放行时直接落盘。
pub(crate) canonical_user_item: Value,
/// 入队检查产出的 prompt:放行不重算。
pub(crate) prompt: String,
pub(crate) creation_type: Option<String>,
/// 入队那一刻的宿主毫秒钟。
pub(crate) at: u64,
}
impl PendingDirectTurn {
/// 组一条待发消息:入队检查已经全部通过,这里只把放行要用的产物冻结下来。
///
/// 冻结是刻意的:放行没有失败出口,所以任何可能在放行时才失败的计算都必须提前到这里
/// (canonical 形状与 prompt 都是)。
pub(crate) fn prepare(
client_turn_id: String,
user_item: DirectCodexUserItem,
prompt: String,
creation_type: Option<String>,
at: u64,
) -> Result<Self, serde_json::Error> {
let canonical_user_item = serde_json::to_value(&user_item)?;
Ok(Self {
client_turn_id,
user_item,
canonical_user_item,
prompt,
creation_type,
at,
})
}
/// 入队事件的投影:带 canonical 用户条目与 `creationType`,**不带 prompt**(prompt 只留在宿主的
/// 队列条目里,它不是要下发的展示形状)。
pub(crate) fn enqueued_event(&self) -> DirectThreadEvent {
DirectThreadEvent::queue_enqueued(
self.client_turn_id.clone(),
self.user_item.clone(),
self.creation_type.clone(),
self.at,
)
}
/// 这条待发消息在历史里的用户条目 id:前端用它把 chip 与回合边界对上。
pub(crate) fn user_item_id(&self) -> Option<String> {
direct_codex_user_item_id_for_client_turn_id(&self.client_turn_id)
}
}
/// 入队的结果。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum EnqueueOutcome {
/// 这次真的排进队尾了。
Enqueued,
/// 同一 `clientTurnId` 已经在队(或正在跑):按幂等返回成功,不排第二条、不发事件。
AlreadyKnown,
}
/// 入队被检查挡下来的原因。满队之外的原因由入队半自己的 typed 错误表达。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum EnqueueRejection {
/// 在队条目已达 [`MAX_PENDING_DIRECT_TURNS`]。
QueueFull,
}
/// 队列还有没有位置。`pending_count` 只数在队条目。
pub(crate) fn queue_has_room(pending_count: usize) -> Result<(), EnqueueRejection> {
if pending_count >= MAX_PENDING_DIRECT_TURNS {
return Err(EnqueueRejection::QueueFull);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn user_item(text: &str, id: &str) -> DirectCodexUserItem {
serde_json::from_value(json!({
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": text}],
"id": id,
}))
.expect("canonical user item")
}
fn pending(client_turn_id: &str, creation_type: Option<&str>) -> PendingDirectTurn {
PendingDirectTurn::prepare(
client_turn_id.to_string(),
user_item("生成一个游戏", "direct-codex:turn-1:user"),
"生成一个游戏".to_string(),
creation_type.map(str::to_string),
1_700_000_000_123,
)
.expect("prepare pending turn")
}
/// 入队事件只带 canonical 用户条目与 `creationType`:prompt 是宿主的入队检查产物,
/// 不许顺着事件下发。
#[test]
fn enqueued_event_carries_the_canonical_item_and_no_prompt() {
let event = pending("turn-1", Some("web-game")).enqueued_event();
let value = serde_json::to_value(&event).expect("serialize queue.enqueued");
assert_eq!(
value,
json!({
"type": "queue.enqueued",
"clientTurnId": "turn-1",
"userItem": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "生成一个游戏"}],
"id": "direct-codex:turn-1:user",
},
"creationType": "web-game",
"at": 1_700_000_000_123u64,
})
);
assert!(value.get("prompt").is_none(), "{value}");
// 没有创建类型时不写字段,也不补 `null`。
let bare = serde_json::to_value(pending("turn-2", None).enqueued_event())
.expect("serialize queue.enqueued without creation type");
assert!(bare.get("creationType").is_none(), "{bare}");
// 事件读得出自己的待发消息身份。
assert_eq!(event.queue_client_turn_id(), Some("turn-1"));
assert_eq!(event.queue_removal_reason(), None);
}
/// canonical 形状在入队时就冻结:放行时落盘的就是这一份,不再重算。
#[test]
fn prepare_freezes_the_canonical_item() {
let turn = pending("turn-1", None);
assert_eq!(
turn.canonical_user_item["id"],
json!("direct-codex:turn-1:user")
);
assert_eq!(
turn.canonical_user_item,
serde_json::to_value(&turn.user_item).expect("serialize user item")
);
}
/// 用户条目身份由 `clientTurnId` 派生,与落盘 / 下发用的是同一个函数。
#[test]
fn user_item_id_derives_from_the_client_turn_id() {
assert_eq!(
pending("turn-1", None).user_item_id().as_deref(),
Some("direct-codex:turn-1:user")
);
assert_eq!(pending(" ", None).user_item_id(), None);
}
/// 容量只数在队条目,5 条封口;在跑的那一轮不算进去。
#[test]
fn capacity_closes_at_five_pending_turns() {
for count in 0..MAX_PENDING_DIRECT_TURNS {
assert_eq!(queue_has_room(count), Ok(()), "{count} 条时仍该有位置");
}
assert_eq!(
queue_has_room(MAX_PENDING_DIRECT_TURNS),
Err(EnqueueRejection::QueueFull)
);
}
}
@@ -13,6 +13,7 @@
use crate::agent::redact_secret_tokens;
use crate::agent::sanitize_error_context;
use crate::agent::DirectCodexUserItem;
use crate::agent::DirectTurnFailureKind;
use crate::redact_absolute_path_tokens;
use serde::{Deserialize, Serialize};
@@ -212,6 +213,33 @@ impl DirectThreadRequestKind {
}
}
/// 一条待发消息离开队列的原因。
///
/// typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经接单并成为回合
/// (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectQueueRemovalReason {
/// 用户取消了这条待发消息。
Cancelled,
/// 放行:这条待发消息已经接单并成为回合。
Dispatched,
}
/// 取消一条待发消息的结果。
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectQueueRemovalOutcome {
/// 已从队列移除。
Removed,
/// 这条消息已经被放行(正在跑的那一轮就是它),不能按待发消息取消。
AlreadyDispatched,
/// 队列里没有这个身份,也没有在跑的一轮是它。
NotFound,
}
/// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。
///
/// `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的
@@ -259,7 +287,7 @@ impl DirectTurnFailure {
/// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点
/// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明
/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)]
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectThreadEvent {
@@ -321,6 +349,34 @@ pub(crate) enum DirectThreadEvent {
#[serde(default)]
request_id: Option<String>,
},
/// 待发消息入队:数组顺序就是队首到队尾的顺序。
///
/// 这条事件在条目仍在队期间**不可回收**,离开队列(取消或放行)时才转成可回收——新订阅者
/// 靠这一点在 bootstrap 里看到当前队列,`is_bootstrap_event` 不需要为它加特例。
///
/// 事件不带 prompt:prompt 是入队检查的产物,只留在宿主的队列条目里。
#[serde(rename = "queue.enqueued")]
QueueEnqueued {
/// 这条待发消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。
client_turn_id: String,
/// canonical 用户条目:前端据此派生 chip 文案,Rust 不渲染展示形状。
user_item: DirectCodexUserItem,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
creation_type: Option<String>,
/// 入队那一刻的宿主毫秒钟。
#[ts(as = "f64")]
at: u64,
},
/// 待发消息离开队列:`reason` 是取消还是放行。
#[serde(rename = "queue.removed")]
QueueRemoved {
client_turn_id: String,
reason: DirectQueueRemovalReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional, as = "Option<f64>")]
at: Option<u64>,
},
}
impl DirectThreadEvent {
@@ -417,6 +473,51 @@ impl DirectThreadEvent {
Self::Request { kind, request_id }
}
/// 待发消息入队事件。
pub(crate) fn queue_enqueued(
client_turn_id: String,
user_item: DirectCodexUserItem,
creation_type: Option<String>,
at: u64,
) -> Self {
Self::QueueEnqueued {
client_turn_id,
user_item,
creation_type,
at,
}
}
/// 待发消息离开队列事件。
pub(crate) fn queue_removed(
client_turn_id: String,
reason: DirectQueueRemovalReason,
at: u64,
) -> Self {
Self::QueueRemoved {
client_turn_id,
reason,
at: Some(at),
}
}
/// 这条事件属于哪条待发消息:只有队列事件有。
pub(crate) fn queue_client_turn_id(&self) -> Option<&str> {
match self {
Self::QueueEnqueued { client_turn_id, .. }
| Self::QueueRemoved { client_turn_id, .. } => Some(client_turn_id),
_ => None,
}
}
/// 待发消息离开队列的原因:只有 `queue.removed` 有。
pub(crate) fn queue_removal_reason(&self) -> Option<DirectQueueRemovalReason> {
match self {
Self::QueueRemoved { reason, .. } => Some(*reason),
_ => None,
}
}
/// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。
///
/// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。
@@ -427,6 +528,8 @@ impl DirectThreadEvent {
| Self::TurnCompleted { at, .. }
| Self::ItemStarted { at, .. }
| Self::ItemCompleted { at, .. } => *at,
Self::QueueEnqueued { at, .. } => Some(*at),
Self::QueueRemoved { at, .. } => *at,
Self::ItemDelta { .. } | Self::Request { .. } => None,
}
}
@@ -456,7 +559,7 @@ impl DirectThreadEvent {
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectThreadSubscriptionBootstrap {
@@ -468,7 +571,7 @@ pub(crate) struct DirectThreadSubscriptionBootstrap {
pub(crate) events: Vec<DirectThreadEvent>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectThreadConsumeResult {
@@ -1466,4 +1569,61 @@ mod tests {
.expect("failed turn without failure payload");
assert_eq!(sparse.failure(), None);
}
/// 待发消息离开队列:`reason` 是 typed 枚举(`cancelled` / `dispatched`),界面按取值分流,
/// 不解析字符串。`at` 缺省时反序列化仍是 `None`。
#[test]
fn queue_removed_carries_a_typed_reason() {
let dispatched = DirectThreadEvent::queue_removed(
"turn-1".to_string(),
DirectQueueRemovalReason::Dispatched,
2_000,
);
assert_eq!(
serde_json::to_value(&dispatched).expect("serialize queue.removed"),
json!({
"type": "queue.removed",
"clientTurnId": "turn-1",
"reason": "dispatched",
"at": 2_000u64,
})
);
assert_eq!(
serde_json::from_value::<DirectThreadEvent>(
serde_json::to_value(&dispatched).expect("serialize")
)
.expect("round trip"),
dispatched
);
assert_eq!(dispatched.queue_client_turn_id(), Some("turn-1"));
assert_eq!(
dispatched.queue_removal_reason(),
Some(DirectQueueRemovalReason::Dispatched)
);
// 未识别的取值必须失败关闭:队列归属是宿主事实,不能让界面猜。
serde_json::from_value::<DirectThreadEvent>(json!({
"type": "queue.removed",
"clientTurnId": "turn-1",
"reason": "timeout",
}))
.expect_err("unknown queue removal reason must fail closed");
// 没有 `at` 的老形状仍能反序列化,回写不补 `null`。
let legacy: DirectThreadEvent = serde_json::from_value(json!({
"type": "queue.removed",
"clientTurnId": "turn-1",
"reason": "cancelled",
}))
.expect("queue.removed without at");
assert_eq!(legacy.at(), None);
assert_eq!(
serde_json::to_value(legacy).expect("serialize legacy"),
json!({
"type": "queue.removed",
"clientTurnId": "turn-1",
"reason": "cancelled",
})
);
}
}
@@ -15,8 +15,6 @@ 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;
@@ -224,30 +222,14 @@ 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_at(deadline, child.wait()).await;
let waited = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
requested?;
waited
.map_err(|_| "等待受控命令主进程退出超时")?
.map_err(|_| "受控命令主进程退出未确认")?;
#[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())
}
Ok("已请求终止受控进程组并回收主进程,完整子树状态未证明".into())
}
}
@@ -262,39 +244,17 @@ impl ProjectCommandTree {
#[cfg(target_os = "linux")]
{
let Self::Group { pid, .. } = self;
// wait 已回收 leader,不能再用旧 PID 授权发信号。
// namespace 后代可能仍在退出,容器 PID 1 也可能保留孤儿僵尸。
return wait_linux_project_command_group_exit(
*pid,
tokio::time::Instant::now() + PROJECT_COMMAND_CLEANUP_TIMEOUT,
)
.await;
// 容器 PID 1 可能不回收 bwrap 的孤儿僵尸;它们不再执行,也无法被信号终止。
// 仅在确认没有存活成员时免除清理,存活成员仍须通过 leader 身份核对。
if !linux_project_command_group_has_live_members(*pid)? {
return Ok(());
}
}
#[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<bool, String> {
let inspect = || -> std::io::Result<bool> {
@@ -1882,17 +1842,6 @@ 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(
@@ -1911,7 +1860,12 @@ 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) => Ok(EstablishedProjectCommand { tree, child, gate }),
Ok(TargetExecState::Established) => {
let tree = ProjectCommandTree::attach(&child).map_err(|error| {
ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error)
})?;
Ok(EstablishedProjectCommand { tree, child, gate })
}
Ok(TargetExecState::Failed { errno }) => {
let termination = terminate_project_command_process_group_after_commit(&mut child);
Err(ProjectCommandError::new(
@@ -2023,11 +1977,7 @@ 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 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());
let wait_result = child.wait().await;
if let Err(error) = &group_result {
let fallback = match (&child_kill_error, &wait_result) {
(_, Ok(_)) => "主进程已回收,但无法确认其余组内进程".to_string(),
@@ -2039,7 +1989,6 @@ 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")
@@ -2271,7 +2220,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();
@@ -2280,12 +2229,6 @@ 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)) => {
@@ -2598,83 +2541,45 @@ mod tests {
return;
}
assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0);
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::<libc::siginfo_t>() };
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();
}
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::<libc::siginfo_t>() };
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();
}
#[test]
@@ -3521,9 +3426,6 @@ 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"));

Some files were not shown because too many files have changed in this diff Show More