Merge branch 'master' into feat/external-scene-generation
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m31s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / Backend tests (pull_request) Successful in 3m53s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / Native shell tests (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m6s
Project CI / Repository checks (pull_request) Successful in 1m54s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m58s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m32s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m31s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / Backend tests (pull_request) Successful in 3m53s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / Native shell tests (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m6s
Project CI / Repository checks (pull_request) Successful in 1m54s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m58s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m32s
This commit is contained in:
@@ -65,6 +65,7 @@ temp*build*/
|
||||
/apps/preview-deployer-web/node_modules/
|
||||
/server-rs/.spacetimedb/
|
||||
/server-rs/.data/
|
||||
**/server-rs/.data/
|
||||
/public/generated-animations
|
||||
/public/generated-character-drafts
|
||||
/public/generated-characters
|
||||
|
||||
+33
@@ -48,6 +48,23 @@ _Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录
|
||||
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
|
||||
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
|
||||
|
||||
**UI 设计文档**:
|
||||
AGC 本地项目里 `kind=ui-design-doc`、`mediaType=application/json` 的界面编辑载体,保存设计图集合、UI 树、组件绑定和 State revision;一份文档可同时承载多张设计图与各自的结构树,不强制收敛成一棵树。
|
||||
_Avoid_: 把 UI 设计图当成设计文档、给设计文档再套一层「页面」概念
|
||||
|
||||
**设计图**:
|
||||
UI 设计文档里的一张参考界面图,以它在 manifest 中的资产 ID 作为文档内身份,附带像素尺寸与像素比;文档内的每棵树都必须引用文档里已存在的设计图。
|
||||
_Avoid_: 用文件名当设计图身份、设计图与设计文档同一概念
|
||||
|
||||
**UI 工作流步骤**:
|
||||
对一份 UI 设计文档执行的一次受控处理阶段,当前只有「结构识别」与「素材切分」两步;步骤产物只有在写入文档并保存后才算完成。
|
||||
_Avoid_: 把单次工具调用当成步骤、把中间产物当成步骤完成
|
||||
|
||||
**工作流检查点日志**:
|
||||
一份 UI 设计文档旁按行追加的恢复用日志,每完成一个工作流步骤追加一行;某步是否有对应行即代表该步是否完成,恢复时从第一个缺失的行继续。
|
||||
_Avoid_: 每步一个 sidecar 状态机、把切分专用的 SeparationState 泛化成通用检查点
|
||||
|
||||
|
||||
## Language
|
||||
|
||||
### Puzzle Clear
|
||||
@@ -190,6 +207,22 @@ _Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
||||
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
|
||||
_Avoid_: 进度通知、快照轮询、第二套历史
|
||||
|
||||
**逻辑回合**:
|
||||
Thread Manager 拥有的一对回合边界(开始与结束),由接单动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。
|
||||
_Avoid_: Codex 原生回合、原生日志、进程生命周期
|
||||
|
||||
**接单**:
|
||||
把一条用户消息交给宿主开始执行的动作,成立即表示这一轮已经存在;此后结果只由运行态事件回答。
|
||||
_Avoid_: 发送成功、命令调用、接口返回
|
||||
|
||||
**拒单**:
|
||||
接单成立之前拒绝这次请求(并发、权限、目录、参数、工程准备未就绪),只回一条可展示原因,不产生回合事件,也不写用户条目。
|
||||
_Avoid_: 回合失败、执行失败、失败事件
|
||||
|
||||
**在途回合**:
|
||||
界面本地已经把这条用户消息发出去、宿主还没有对应回合开始事件的那一小段状态。
|
||||
_Avoid_: 运行中回合、乐观锁、发送队列
|
||||
|
||||
**聊天投影**:
|
||||
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
|
||||
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"visibleModels": [],
|
||||
"apiKey": "",
|
||||
"baseUrl": "https://dev.genarrative.world/gpt/v1",
|
||||
"model": "gpt-6-astra",
|
||||
"model": "platform-default",
|
||||
"apiKind": "openai_responses",
|
||||
"reasoningEffort": "max",
|
||||
"stream": true,
|
||||
|
||||
@@ -157,11 +157,8 @@ const allowedUncalledTauriCommands = [
|
||||
'set_active_game_creator_agent_session',
|
||||
'start_game_creator_agent_goal',
|
||||
'start_game_creator_supervisor_runtime_task',
|
||||
// TODO: Remove the retired binding command after the legacy runtime path is removed.
|
||||
'bind_components',
|
||||
'chat_with_game_creator_agent',
|
||||
'check_ui_editor_font_glyph_coverage',
|
||||
'create_ui_design_resource',
|
||||
// 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本
|
||||
// (提交即返回、后台生成)。这条命令**没有生产调用方**,只有 Rust 集成测试
|
||||
// (`src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调
|
||||
@@ -1497,6 +1494,15 @@ if (defaultAppConfig.llm?.apiKey !== '') {
|
||||
throw new Error('AI game creator shell default llm.apiKey must stay empty');
|
||||
}
|
||||
|
||||
// 首次启动模板必须写入官方路由占位模型(与 config.rs 的
|
||||
// OFFICIAL_LLM_ROUTER_DEFAULT_MODEL 同源):钉死具体上游模型名会随上游目录
|
||||
// 变动失效,留空则首启配置不合法。
|
||||
if (defaultAppConfig.llm?.model !== 'platform-default') {
|
||||
throw new Error(
|
||||
'AI game creator shell default llm.model must stay the official route placeholder',
|
||||
);
|
||||
}
|
||||
|
||||
if (defaultAppConfig.agentMode !== 'codex_app_server') {
|
||||
throw new Error(
|
||||
'AI game creator shell default agentMode must be codex_app_server',
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main",
|
||||
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
|
||||
"description": "AI 游戏创作主窗口允许读写系统剪贴板,用于粘贴素材附件和复制生成文件路径;允许弹出原生打开/保存对话框用于素材上传与导出。",
|
||||
"windows": ["client"],
|
||||
"permissions": [
|
||||
"clipboard-manager:allow-read-image",
|
||||
"clipboard-manager:allow-read-text",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"core:image:allow-rgba",
|
||||
"core:image:allow-size",
|
||||
"core:resources:allow-close",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"execution": "texts/execution.json",
|
||||
"media": "texts/media.json",
|
||||
"nativeTools": "texts/native-tools.json",
|
||||
"uiDesignDoc": "texts/ui-design-doc.json",
|
||||
"recovery": "texts/recovery.json",
|
||||
"interaction": "texts/interaction.json",
|
||||
"goalContext": "texts/goal-context.json",
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
"preview.validate.description": "用真实浏览器验证桌面和移动预览并保存证据。",
|
||||
"image.inspect.description": "让视觉模型检查一至两张项目内图片。",
|
||||
"canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 的 prompt 去除首尾空白后须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交,超限拒绝,不截断、不拆条,客户端不追加生图指令。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。",
|
||||
"ui.workflow.run.description": "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-design 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。",
|
||||
"cocos.editor.execute.description": "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;仅提交 code,客户端负责绑定项目与编辑器进程。",
|
||||
"unity.editor.execute.description": "在当前 Unity 项目已打开的编辑器中执行 C#。仅提交 code;结果待核对时禁止自动重发。",
|
||||
"godot.editor.execute.description": "在当前 Godot 项目已打开的编辑器中执行支持 return/await 的 GDScript 函数体。仅提交 code;结果待核对时禁止自动重发。",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"from_images.description": "用一至四张设计图新建一份 UI 设计文档:输入给已登记资源的 assetId,或给项目内相对路径由本工具顺带登记;随后按 ui/UI 设计 N.json 取号创建文档、把设计图写进文档并登记为 ui-design-doc 资源,返回新文档的 assetId 与 relativePath。每次调用都新建文档,不复用既有文档。",
|
||||
"from_images.parameters.images": "一至四张设计图。每张给 assets 里已登记图片资源的 assetId,或给项目内相对路径(相对路径会先登记成图片资源);文档内的设计图身份就是该图片资源的 assetId。",
|
||||
"run_workflow.description": "对一份 UI 设计文档在 Rust 内依次执行结构识别与自动切分素材:把切分素材回填到 Image 组件的 target_graphic、清掉已处理节点的组件状态、给达到返工上限的问题节点写 NeedReview,最后写回文档并推进 revision。每完成一步追加一行检查点,崩溃后从第一个缺失的步骤继续;不做页面发现,也不做多树合并与独立组件绑定。文档在轮次中途被改动时本轮作废并报错,必须重新调用本工具开新一轮。",
|
||||
"run_workflow.parameters.designDocAssetId": "目标 UI 设计文档在 assets 里的 assetId;文档内必须已有设计图。",
|
||||
"into_js.description": "把一份 UI 设计文档的当前 revision 渲染成 ui/generated-<stem>-<digest>.js,返回相对路径与导出树。只读文档内容,不修改文档、不推进 revision。",
|
||||
"into_js.parameters.designDocAssetId": "目标 UI 设计文档在 assets 里的 assetId。"
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! ACL 提权修复目标的并发去重与结果记忆。
|
||||
//!
|
||||
//! 同一目标被并发请求时只允许一次真实提权,其余调用等待并复用同一结果;
|
||||
//! 结果在冷却窗口内直接复用,其中用户拒绝(UAC 取消)的窗口最长,
|
||||
//! 避免自动重试把用户反复拽回安全桌面。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::Hash;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Condvar, LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// 一次提权修复的结果。用户拒绝与修复失败必须可区分:前者不该被重试。
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum AclRepairOutcome {
|
||||
Repaired,
|
||||
Denied(String),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum AclRepairGateResult {
|
||||
Executed(AclRepairOutcome),
|
||||
Reused(AclRepairOutcome),
|
||||
/// leader 在等待窗口内仍未结束(例如 UAC 无人应答);调用方按失败关闭处理。
|
||||
WaitTimedOut,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct AclRepairPolicy {
|
||||
pub(crate) success_cooldown: Duration,
|
||||
pub(crate) denial_cooldown: Duration,
|
||||
pub(crate) failure_cooldown: Duration,
|
||||
pub(crate) wait_timeout: Duration,
|
||||
/// leader 超过这个时长仍未落库即视为卡死,允许新调用接管该 key。
|
||||
/// UAC 弹窗最多被系统挂约两分钟,所以这个上限取得比它宽得多;没有它,
|
||||
/// 一次挂死的 `Start-Process -Wait` 会让这个目标在进程重启前一直失败关闭。
|
||||
pub(crate) leader_deadline: Duration,
|
||||
}
|
||||
|
||||
impl AclRepairPolicy {
|
||||
fn cooldown_for(&self, outcome: &AclRepairOutcome) -> Duration {
|
||||
match outcome {
|
||||
AclRepairOutcome::Repaired => self.success_cooldown,
|
||||
AclRepairOutcome::Denied(_) => self.denial_cooldown,
|
||||
AclRepairOutcome::Failed(_) => self.failure_cooldown,
|
||||
}
|
||||
}
|
||||
|
||||
fn retention(&self) -> Duration {
|
||||
self.success_cooldown
|
||||
.max(self.denial_cooldown)
|
||||
.max(self.failure_cooldown)
|
||||
}
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
running: bool,
|
||||
outcome: Option<AclRepairOutcome>,
|
||||
recorded_at: Option<Instant>,
|
||||
/// leader 起跑时刻,用于判定该 leader 是否已经卡死。
|
||||
started_at: Instant,
|
||||
/// 当前 leader 的令牌:被接管后旧 leader 迟到的结果不得覆盖新 leader 的结果。
|
||||
leader_id: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct AclRepairGate<K> {
|
||||
entries: Mutex<HashMap<K, Entry>>,
|
||||
settled: Condvar,
|
||||
next_leader_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq + Hash> AclRepairGate<K> {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
settled: Condvar::new(),
|
||||
next_leader_id: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// 以 `key` 为粒度执行一次提权修复:并发调用只会有一次真正执行,
|
||||
/// 其余调用等待并复用结果;冷却窗口内直接复用上一次结果。
|
||||
pub(crate) fn run<F>(
|
||||
&self,
|
||||
key: K,
|
||||
now: Instant,
|
||||
policy: &AclRepairPolicy,
|
||||
execute: F,
|
||||
) -> AclRepairGateResult
|
||||
where
|
||||
F: FnOnce() -> AclRepairOutcome,
|
||||
{
|
||||
let wait_deadline = Instant::now() + policy.wait_timeout;
|
||||
let mut entries = lock(&self.entries);
|
||||
loop {
|
||||
match entries.get(&key) {
|
||||
Some(entry) if entry.running => {
|
||||
// 卡死的 leader(例如 `Start-Process -Wait` 真挂住)不能永久占住这个 key:
|
||||
// 超过 leader_deadline 就由新调用接管,否则该目标在进程重启前只会一直失败关闭。
|
||||
if now.saturating_duration_since(entry.started_at) >= policy.leader_deadline {
|
||||
break;
|
||||
}
|
||||
let remaining = wait_deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return AclRepairGateResult::WaitTimedOut;
|
||||
}
|
||||
let (guard, _) = self
|
||||
.settled
|
||||
.wait_timeout(entries, remaining)
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
entries = guard;
|
||||
}
|
||||
Some(entry) => {
|
||||
let reusable = entry.outcome.clone().zip(entry.recorded_at).filter(
|
||||
|(outcome, recorded_at)| {
|
||||
now.saturating_duration_since(*recorded_at)
|
||||
< policy.cooldown_for(outcome)
|
||||
},
|
||||
);
|
||||
match reusable {
|
||||
Some((outcome, _)) => return AclRepairGateResult::Reused(outcome),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
prune(&mut entries, now, policy);
|
||||
let leader_id = self.next_leader_id.fetch_add(1, Ordering::Relaxed);
|
||||
entries.insert(
|
||||
key.clone(),
|
||||
Entry {
|
||||
running: true,
|
||||
outcome: None,
|
||||
// 结果尚未落库:冷却基准只在真正记录结果时才写。
|
||||
recorded_at: None,
|
||||
started_at: now,
|
||||
leader_id,
|
||||
},
|
||||
);
|
||||
drop(entries);
|
||||
|
||||
let guard = LeaderGuard {
|
||||
gate: self,
|
||||
key: key.clone(),
|
||||
leader_id,
|
||||
armed: true,
|
||||
};
|
||||
let outcome = execute();
|
||||
guard.complete(outcome)
|
||||
}
|
||||
|
||||
/// 用户主动操作后允许重新尝试提权:清掉「被拒绝」的记忆。
|
||||
pub(crate) fn clear_denials(&self) {
|
||||
let mut entries = lock(&self.entries);
|
||||
entries.retain(|_, entry| {
|
||||
entry.running || !matches!(entry.outcome, Some(AclRepairOutcome::Denied(_)))
|
||||
});
|
||||
drop(entries);
|
||||
self.settled.notify_all();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn is_running(&self, key: &K) -> bool {
|
||||
lock(&self.entries)
|
||||
.get(key)
|
||||
.is_some_and(|entry| entry.running)
|
||||
}
|
||||
}
|
||||
|
||||
impl<K> Default for AclRepairGate<K>
|
||||
where
|
||||
K: Clone + Eq + Hash,
|
||||
{
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
struct LeaderGuard<'a, K: Clone + Eq + Hash> {
|
||||
gate: &'a AclRepairGate<K>,
|
||||
key: K,
|
||||
leader_id: u64,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq + Hash> LeaderGuard<'_, K> {
|
||||
fn complete(mut self, outcome: AclRepairOutcome) -> AclRepairGateResult {
|
||||
self.armed = false;
|
||||
let mut entries = lock(&self.gate.entries);
|
||||
// 只在仍是当前 leader 时落库:leader 卡死被接管后,迟到的结果必须丢弃,
|
||||
// 否则会把接管者已经写下的结果覆盖回去。
|
||||
if let Some(entry) = entries.get_mut(&self.key) {
|
||||
if entry.leader_id == self.leader_id {
|
||||
entry.running = false;
|
||||
entry.outcome = Some(outcome.clone());
|
||||
// 冷却从「结果落库」时刻算起,而不是 leader 起跑时刻:UAC 弹窗可能被挂着
|
||||
// 几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,
|
||||
// 紧接着的自动重查会立刻再弹一次。
|
||||
entry.recorded_at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
drop(entries);
|
||||
self.gate.settled.notify_all();
|
||||
AclRepairGateResult::Executed(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq + Hash> Drop for LeaderGuard<'_, K> {
|
||||
/// leader 异常退出时不能让等待者永久挂住:记成失败并唤醒全部等待者。
|
||||
fn drop(&mut self) {
|
||||
if !self.armed {
|
||||
return;
|
||||
}
|
||||
let mut entries = lock(&self.gate.entries);
|
||||
if let Some(entry) = entries.get_mut(&self.key) {
|
||||
if entry.leader_id == self.leader_id {
|
||||
entry.running = false;
|
||||
entry.outcome = Some(AclRepairOutcome::Failed(
|
||||
"AGC ACL 提权修复执行线程异常退出".to_string(),
|
||||
));
|
||||
entry.recorded_at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
drop(entries);
|
||||
self.gate.settled.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
mutex
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn prune<K: Eq + Hash>(entries: &mut HashMap<K, Entry>, now: Instant, policy: &AclRepairPolicy) {
|
||||
// 只是防止 map 随进程生命周期无限增长;窗口远大于冷却期即可。
|
||||
let retention = policy.retention().saturating_mul(4);
|
||||
entries.retain(|_, entry| {
|
||||
if entry.running {
|
||||
return true;
|
||||
}
|
||||
entry
|
||||
.recorded_at
|
||||
.is_none_or(|recorded_at| now.saturating_duration_since(recorded_at) < retention)
|
||||
});
|
||||
}
|
||||
|
||||
/// 提权修复的进程级闸门;key = (规范化目标路径, scope 名)。
|
||||
pub(crate) type AclRepairKey = (String, &'static str);
|
||||
|
||||
pub(crate) static ACL_REPAIR_GATE: LazyLock<AclRepairGate<AclRepairKey>> =
|
||||
LazyLock::new(AclRepairGate::new);
|
||||
|
||||
pub(crate) const ACL_REPAIR_POLICY: AclRepairPolicy = AclRepairPolicy {
|
||||
success_cooldown: Duration::from_secs(30),
|
||||
denial_cooldown: Duration::from_secs(120),
|
||||
failure_cooldown: Duration::from_secs(15),
|
||||
wait_timeout: Duration::from_secs(60),
|
||||
// 系统对无人应答的 UAC 弹窗约 2 分钟超时,取 5 分钟只兜「真挂死」这一种情况。
|
||||
leader_deadline: Duration::from_secs(300),
|
||||
};
|
||||
|
||||
/// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除「被拒绝」记忆。
|
||||
pub(crate) fn clear_acl_repair_denials() {
|
||||
ACL_REPAIR_GATE.clear_denials();
|
||||
}
|
||||
@@ -33,6 +33,9 @@ mod direct_thread_wire;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tool_calls;
|
||||
mod direct_tools_mcp;
|
||||
mod direct_turn_accept;
|
||||
mod direct_turn_error;
|
||||
mod direct_turn_failure;
|
||||
mod direct_turn_stream;
|
||||
mod direct_validation;
|
||||
mod generation;
|
||||
@@ -66,6 +69,9 @@ pub(crate) use direct_thread_wire::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tool_calls::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
pub(crate) use direct_turn_accept::*;
|
||||
pub(crate) use direct_turn_error::*;
|
||||
pub(crate) use direct_turn_failure::*;
|
||||
pub(crate) use direct_turn_stream::*;
|
||||
pub(crate) use direct_validation::DirectValidationConfig;
|
||||
pub(crate) use generation::*;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Native / third-party approval adapter. The host execution session owns policy
|
||||
//! and persistence; this module only binds the app-server protocol to its leases.
|
||||
|
||||
use super::super::{direct_delivery, direct_execution, direct_validation};
|
||||
use super::super::{direct_delivery, direct_execution, direct_validation, DirectTurnError};
|
||||
use super::{shutdown_game_creator_codex_app_server_inner, CodexAppServerInner};
|
||||
use direct_execution::{EffectKind, ExecutionLease, ExecutionPhase, ExecutionSession};
|
||||
use serde_json::{json, Value};
|
||||
@@ -16,11 +16,25 @@ use tokio::sync::{watch, Notify};
|
||||
const MAX_PROTOCOL_ITEMS: usize = 2048;
|
||||
const MAX_REQUEST_CACHE: usize = 512;
|
||||
|
||||
/// 逐次审批协议的版本门禁:发行构建只接受捆绑侧车的固定版本;开发构建用宿主自带的 Codex
|
||||
/// (Linux 与未 stage 侧车时没有固定版本可用),按 profile 直接跳过该门禁。
|
||||
pub(super) fn validate_approval_version(version: &str) -> Result<(), String> {
|
||||
if version.trim() == super::super::codex_cli::codex_bundle::CLI_VERSION {
|
||||
return Ok(());
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
if version.trim() == super::super::codex_cli::codex_bundle::CLI_VERSION {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!(
|
||||
"direct-execution-protocol: 当前 Codex 版本未通过逐次审批协议验收,请使用客户端配套版本(期望 {},实际 {});禁止降级为无控制执行",
|
||||
super::super::codex_cli::codex_bundle::CLI_VERSION,
|
||||
version.trim()
|
||||
));
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let _ = version;
|
||||
Ok(())
|
||||
}
|
||||
Err("direct-execution-protocol: 当前 Codex 版本未通过逐次审批协议验收,请使用客户端配套版本;禁止降级为无控制执行".into())
|
||||
}
|
||||
|
||||
pub(super) fn denied_response(id: u64, method: &str) -> Value {
|
||||
@@ -78,12 +92,41 @@ pub(super) enum HostOutcome {
|
||||
RepairRequired,
|
||||
}
|
||||
|
||||
pub(super) fn outcome_text(outcome: HostOutcome) -> Result<String, platform_llm::LlmError> {
|
||||
/// [`HostOutcome`] 的文本投影。
|
||||
///
|
||||
/// 返修要求([`HostOutcome::RepairRequired`])用**自己的变体**表达:它是控制流("继续当前返修
|
||||
/// 批次"),不是失败。以前它伪装成 `LlmError::InvalidRequest("validation-source-changed: …")`,
|
||||
/// 于是和真失败走同一条投影——终态被判成 `failed`、界面收到一条用户可见的失败说明。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(super) enum HostOutcomeText {
|
||||
/// 正常收尾:可展示的回复 / 交付报告文本。
|
||||
Report(String),
|
||||
/// 封口复核要求继续当前返修批次(控制流,不是失败)。
|
||||
RepairRequired { detail: String },
|
||||
}
|
||||
|
||||
/// 返修要求写回提示词时用的说明。
|
||||
pub(super) const HOST_OUTCOME_REPAIR_REQUIRED_DETAIL: &str =
|
||||
"宿主收尾复核发现输入或证据变化,请读取交付状态后继续当前返修批次";
|
||||
|
||||
impl HostOutcomeText {
|
||||
/// 投影成这一轮的收尾结果:正常报告是文本,返修要求是控制流(走 `Err` 侧自己的变体)。
|
||||
pub(super) fn into_run_result(self) -> Result<String, super::DirectTurnRunFailure> {
|
||||
match self {
|
||||
Self::Report(text) => Ok(text),
|
||||
Self::RepairRequired { detail } => {
|
||||
Err(super::DirectTurnRunFailure::RepairRequired { detail })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn outcome_text(outcome: HostOutcome) -> HostOutcomeText {
|
||||
match outcome {
|
||||
HostOutcome::Report(report) => Ok(report),
|
||||
HostOutcome::RepairRequired => Err(platform_llm::LlmError::InvalidRequest(
|
||||
"validation-source-changed: 宿主收尾复核发现输入或证据变化,请读取交付状态后继续当前返修批次".into(),
|
||||
)),
|
||||
HostOutcome::Report(report) => HostOutcomeText::Report(report),
|
||||
HostOutcome::RepairRequired => HostOutcomeText::RepairRequired {
|
||||
detail: HOST_OUTCOME_REPAIR_REQUIRED_DETAIL.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +190,11 @@ pub(super) struct ExecutionAdapter {
|
||||
changed: Notify,
|
||||
shutdown_gate: tokio::sync::Mutex<()>,
|
||||
outcome: watch::Sender<Option<HostOutcome>>,
|
||||
/// 宿主自己判定的"本轮以失败收口":`(分类, 原因)`。有值就代表本轮终态必须是失败,
|
||||
/// 原因与交付报告同一份文本。
|
||||
turn_failure: Mutex<Option<DirectTurnError>>,
|
||||
/// 用户/宿主是否主动要求终止这一轮(界面的「终止」按钮)。用户主动终止不是失败。
|
||||
host_stop_requested: AtomicBool,
|
||||
}
|
||||
|
||||
fn identity(value: Option<&Value>) -> Option<&str> {
|
||||
@@ -263,6 +311,8 @@ impl ExecutionAdapter {
|
||||
changed: Notify::new(),
|
||||
shutdown_gate: tokio::sync::Mutex::new(()),
|
||||
outcome,
|
||||
turn_failure: Mutex::new(None),
|
||||
host_stop_requested: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -654,6 +704,7 @@ impl ExecutionAdapter {
|
||||
}
|
||||
|
||||
pub(super) fn cancel_from_host(self: &Arc<Self>) {
|
||||
self.request_host_stop();
|
||||
if self.background_done.load(Ordering::Acquire) || self.closed.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
@@ -672,6 +723,55 @@ impl ExecutionAdapter {
|
||||
let _ = tokio::task::spawn_blocking(move || session.interrupt(message)).await;
|
||||
}
|
||||
|
||||
/// 宿主判定"这一轮以失败收口":记下 `(分类, 原因)`,再把同一条原因写进宿主交付报告。
|
||||
///
|
||||
/// 谁调用:宿主亲眼看到或亲手判定的异常收场——执行通道断开(app-server 进程退出 / 流断 / 回合
|
||||
/// 事件通道关闭)、等待模型回执超时、app-server 单方面把这一轮判成中断。终态判定会读这份事实,
|
||||
/// 于是这些收场不会再被收尾阶段(`ExecutionPhase::Interrupted`)抹成一次没有原因的"已结束"。
|
||||
///
|
||||
/// **宿主自己收束的这一轮不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉,
|
||||
/// 回合事件通道上看到的是同一个 `TransportClosed`;判据有两条,都收在这里,调用点不必各写一遍:
|
||||
///
|
||||
/// - [`Self::is_closed`]:适配器先于连接置位,说明这一轮是宿主在收束;
|
||||
/// - [`Self::host_stop_requested`]:用户按过「终止」。`cancel_from_host` 先**同步**置位再异步
|
||||
/// 中断会话,`closed` 与阶段都要等那个任务跑到才变,所以"标志已置、阶段未变"的窗口里到达的
|
||||
/// 通道断开 / 中断都是宿主自己收尾的结果,不能记成 `transport-failed`。
|
||||
///
|
||||
/// 不记失败事实不等于不收束:原因照样写进报告(`interrupt` 会把它追加进去),便于核对。
|
||||
///
|
||||
/// **事实要落在适配器上,不能落在调用点的局部变量里。** 回合还开着的时候,看门狗会在同一个
|
||||
/// `inner.closed` 标志上把本轮收束掉(见 [`Self::start_watchdog`]),谁先谁后取决于调度,而终态
|
||||
/// 判定发生在收束之后;记不下原因,界面就只能看到"本轮已结束"、看不到为什么。
|
||||
///
|
||||
/// 只记第一份:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束理由
|
||||
/// 不得覆盖它。
|
||||
pub(super) async fn fail_turn(&self, failure: DirectTurnError) {
|
||||
let reason = failure.to_string();
|
||||
if !self.is_closed() && !self.host_stop_requested() {
|
||||
if let Ok(mut slot) = self.turn_failure.lock() {
|
||||
if slot.is_none() {
|
||||
*slot = Some(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.interrupt(&reason).await;
|
||||
}
|
||||
|
||||
/// 本轮以什么理由失败;有值就是宿主记下的 typed 事实。终态判定只读这一次。
|
||||
pub(super) fn turn_failure(&self) -> Option<DirectTurnError> {
|
||||
self.turn_failure.lock().ok().and_then(|slot| slot.clone())
|
||||
}
|
||||
|
||||
/// 记下"用户主动要求终止这一轮"。用来把用户主动终止与 app-server 自己中断分开:
|
||||
/// 前者不是失败,后者是(判据不能被事件到达的先后顺序左右,所以用标志而不是看阶段)。
|
||||
pub(super) fn request_host_stop(&self) {
|
||||
self.host_stop_requested.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
pub(super) fn host_stop_requested(&self) -> bool {
|
||||
self.host_stop_requested.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(super) fn start_watchdog(self: &Arc<Self>, inner: Weak<CodexAppServerInner>) {
|
||||
let adapter = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
@@ -744,7 +844,19 @@ impl ExecutionAdapter {
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// 本轮是不是**由宿主自己**在收束(正常终态 / 用户主动停止 / 预算收尾 / 交付封口)。
|
||||
///
|
||||
/// 用来把"连接被我们关掉"和"连接自己断了"分开:两种情况下回合事件通道都会收到
|
||||
/// `TransportClosed`,但只有后者才算执行通道失败(见 [`Self::transport_failed`])。
|
||||
/// `finish_model_attempt` 与 `shutdown_and_report` 都会在收束连接之前把它置位。
|
||||
fn is_closed(&self) -> bool {
|
||||
self.closed.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(super) fn lifecycle_status(&self, fallback: &str) -> String {
|
||||
// 只按收尾阶段归类。失败事实(`fail_turn` 记下的)不在这里翻案:终态由
|
||||
// `direct_turn_terminal` 拿事实判定——否则"模型已经判失败"的一轮会被这里的
|
||||
// `Interrupted` 抹成一次没有原因的"已结束"。
|
||||
match self.session.snapshot().map(|state| state.phase) {
|
||||
Ok(ExecutionPhase::Completed) => "completed",
|
||||
Ok(ExecutionPhase::Exhausted | ExecutionPhase::Interrupted) => "interrupted",
|
||||
@@ -1037,6 +1149,8 @@ pub(super) async fn wait_outcome(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::DirectTurnDeadline;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> (tempfile::TempDir, Arc<ExecutionAdapter>) {
|
||||
@@ -1084,6 +1198,82 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_observed_failure_is_recorded_with_its_kind_and_reason() {
|
||||
let (_temp, adapter) = fixture();
|
||||
assert!(adapter.turn_failure().is_none());
|
||||
assert!(!adapter.host_stop_requested());
|
||||
|
||||
adapter
|
||||
.fail_turn(DirectTurnError::TransportClosed {
|
||||
diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(),
|
||||
})
|
||||
.await;
|
||||
|
||||
// 终态判定读这份事实,界面才有理由把它当失败讲,而不是"本轮已结束"。
|
||||
let failure = adapter.turn_failure().expect("host fact must be recorded");
|
||||
assert_eq!(
|
||||
failure.wire_kind(),
|
||||
Some(super::super::DirectTurnFailureKind::TransportFailed)
|
||||
);
|
||||
assert!(failure.to_string().contains("SIGKILL"));
|
||||
// 报告与事件载荷同一份原因:用户看到的现象和交付状态对得上。
|
||||
assert!(adapter.report().contains("SIGKILL"));
|
||||
|
||||
// 只认第一份原因:后续更粗的收束理由不得覆盖真实诊断。
|
||||
adapter
|
||||
.fail_turn(DirectTurnError::TimedOut {
|
||||
deadline: DirectTurnDeadline::ResponseIdle,
|
||||
})
|
||||
.await;
|
||||
let failure = adapter.turn_failure().expect("first reason is kept");
|
||||
assert_eq!(
|
||||
failure.wire_kind(),
|
||||
Some(super::super::DirectTurnFailureKind::TransportFailed)
|
||||
);
|
||||
assert!(failure.to_string().contains("SIGKILL"));
|
||||
assert!(!failure.to_string().contains("超时"));
|
||||
}
|
||||
|
||||
/// 宿主自己关的连接不算失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件通道
|
||||
/// 上看到的是同一个 `TransportClosed`。判据是适配器先于连接置位 `closed`。
|
||||
#[tokio::test]
|
||||
async fn host_ended_turn_is_not_a_failure() {
|
||||
let (_temp, adapter) = fixture();
|
||||
adapter.request_host_stop();
|
||||
adapter.closed.store(true, Ordering::Release);
|
||||
|
||||
adapter
|
||||
.fail_turn(DirectTurnError::TransportClosed {
|
||||
diagnostic: "模型本次执行结束,回收原生后台子树".into(),
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(adapter.turn_failure().is_none());
|
||||
assert!(adapter.host_stop_requested());
|
||||
// 原因照样进报告:不算失败不等于不用记。
|
||||
assert!(adapter.report().contains("模型本次执行结束"));
|
||||
}
|
||||
|
||||
/// 用户按下的「终止」不记失败事实:`cancel_from_host` 先同步置位 `host_stop_requested`、再异步
|
||||
/// 中断会话,这中间到达的通道断开 / 中断都是宿主自己收尾的结果,不能讲成 `transport-failed`。
|
||||
#[tokio::test]
|
||||
async fn user_requested_stop_is_not_recorded_as_a_failure() {
|
||||
let (_temp, adapter) = fixture();
|
||||
adapter.request_host_stop();
|
||||
|
||||
adapter
|
||||
.fail_turn(DirectTurnError::TransportClosed {
|
||||
diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(),
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(adapter.turn_failure().is_none());
|
||||
assert!(adapter.host_stop_requested());
|
||||
// 不算失败不等于不用记:原因照样进报告,排障能看到现场。
|
||||
assert!(adapter.report().contains("SIGKILL"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_snapshot_identity_uses_canonical_digest_and_preserves_manifest_authority() {
|
||||
let (_temp, adapter) = fixture();
|
||||
@@ -1428,9 +1618,13 @@ mod tests {
|
||||
super::super::super::codex_cli::codex_bundle::CLI_VERSION
|
||||
)
|
||||
.is_ok());
|
||||
// 开发构建(含本测试构建)跳过版本门禁,只有发行构建要求严格等于固定版本。
|
||||
#[cfg(debug_assertions)]
|
||||
assert!(validate_approval_version("codex-cli 0.156.0").is_ok());
|
||||
#[cfg(not(debug_assertions))]
|
||||
for version in [
|
||||
"codex-cli 0.155.0",
|
||||
"codex-cli 0.154.0",
|
||||
"codex-cli 0.156.0",
|
||||
"unknown",
|
||||
"0.155.1",
|
||||
] {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -697,10 +697,14 @@ pub(super) async fn finish_sealing(
|
||||
}).await.map_err(|_| "delivery-finalize-worker-exited")?
|
||||
}
|
||||
|
||||
/// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。
|
||||
///
|
||||
/// 返修要求是**控制流**([`DirectTurnError::ReviewRequired`]),不是失败:调用方据此把要求写回
|
||||
/// prompt 再跑一轮,界面不该看到失败文案。其余错误都是真的回合失败,按 typed 错误交给上层。
|
||||
pub(super) async fn review_reply(
|
||||
root: &Path,
|
||||
session: &Arc<ExecutionSession>,
|
||||
) -> Result<Option<String>, String> {
|
||||
) -> Result<Option<String>, DirectTurnError> {
|
||||
if let Some(report) = terminal_report(session) {
|
||||
return Ok(Some(report));
|
||||
}
|
||||
@@ -751,7 +755,9 @@ pub(super) async fn review_reply(
|
||||
.map_err(|_| "delivery-review-worker-exited")??;
|
||||
return Ok(Some(report));
|
||||
}
|
||||
Err(format!("delivery-review-required: {detail}"))
|
||||
Err(DirectTurnError::ReviewRequired {
|
||||
detail: format!("delivery-review-required: {detail}"),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -914,10 +920,10 @@ mod tests {
|
||||
assert_eq!(chat.snapshot().unwrap().delivery_reviews, 0);
|
||||
let (new_game, _new_host, required) = project_session(true);
|
||||
for _ in 0..2 {
|
||||
assert!(review_reply(new_game.path(), &required)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.starts_with("delivery-review-required:"));
|
||||
assert!(matches!(
|
||||
review_reply(new_game.path(), &required).await.unwrap_err(),
|
||||
DirectTurnError::ReviewRequired { .. }
|
||||
));
|
||||
}
|
||||
assert!(review_reply(new_game.path(), &required)
|
||||
.await
|
||||
|
||||
@@ -704,9 +704,19 @@ pub(super) fn open_with_analytics_at(
|
||||
|
||||
impl ExecutionSession {
|
||||
pub(super) fn bind_codex_executor(&self, path: &Path, version: &str) -> Result<(), String> {
|
||||
if version.trim() != super::codex_cli::codex_bundle::CLI_VERSION {
|
||||
return Err("direct-execution-executor: 尚未验证该执行器的补丁协议".into());
|
||||
// 发行构建只接受捆绑侧车固定版本;开发构建用宿主自带的 Codex,按 profile 跳过该门禁。
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
if version.trim() != super::codex_cli::codex_bundle::CLI_VERSION {
|
||||
return Err(format!(
|
||||
"direct-execution-executor: 尚未验证该执行器的补丁协议(期望 {},实际 {})",
|
||||
super::codex_cli::codex_bundle::CLI_VERSION,
|
||||
version.trim()
|
||||
));
|
||||
}
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
let _ = version;
|
||||
let path = path
|
||||
.canonicalize()
|
||||
.map_err(|_| "direct-execution-executor: 无法锚定执行器")?;
|
||||
|
||||
@@ -651,6 +651,8 @@ fn patch_executor_identity_is_frozen_and_content_changes_are_rejected() {
|
||||
std::fs::write(&path, "trusted test bytes").unwrap();
|
||||
let pinned = super::super::codex_cli::codex_bundle::CLI_VERSION;
|
||||
assert!(session.codex_executor().is_err());
|
||||
// 开发构建跳过执行器版本门禁;发行构建仍然拒绝版本漂移。
|
||||
#[cfg(not(debug_assertions))]
|
||||
assert!(session
|
||||
.bind_codex_executor(&path, "codex-cli 0.155.0")
|
||||
.is_err());
|
||||
|
||||
@@ -437,8 +437,14 @@ mod tests {
|
||||
async fn a_replaced_active_turn_marks_the_batch_stale() {
|
||||
let (_temp, root) = project();
|
||||
std::fs::write(root.join("code.js"), "unchanged").unwrap();
|
||||
// 身份来自逻辑回合(Thread Manager):接单才是"这一轮在跑"的唯一登记。
|
||||
let owner = Arc::new(std::sync::Mutex::new(Some(
|
||||
DirectTaonierActiveInvocationGuard::enter(&root, "turn-before").unwrap(),
|
||||
DirectTurnReservation::accept(
|
||||
&direct_thread_id_for_project(&root),
|
||||
"turn-before",
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
)));
|
||||
let swap = Arc::clone(&owner);
|
||||
let result = read_batch_with(
|
||||
@@ -449,7 +455,14 @@ mod tests {
|
||||
let result = read_file(r, f, b);
|
||||
let mut guard = swap.lock().unwrap();
|
||||
drop(guard.take());
|
||||
*guard = Some(DirectTaonierActiveInvocationGuard::enter(r, "turn-after").unwrap());
|
||||
*guard = Some(
|
||||
DirectTurnReservation::accept(
|
||||
&direct_thread_id_for_project(r),
|
||||
"turn-after",
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
result
|
||||
},
|
||||
)
|
||||
@@ -583,7 +596,14 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn host_prefetch_keeps_data_out_of_system_rules_and_matches_active_turn() {
|
||||
let (_temp, root) = project();
|
||||
// 调用身份(预取闸门)与逻辑回合(上下文身份)是两件事,生产入口两步都做。
|
||||
let _guard = DirectTaonierActiveInvocationGuard::enter(&root, "prefetch-turn").unwrap();
|
||||
let _turn = DirectTurnReservation::accept(
|
||||
&direct_thread_id_for_project(&root),
|
||||
"prefetch-turn",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let data = prefetch_turn_input(&root, "prefetch-turn")
|
||||
.await
|
||||
.unwrap()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,9 @@ use super::*;
|
||||
|
||||
pub(crate) fn normalize_direct_client_turn_id(
|
||||
client_turn_id: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<String, DirectTurnError> {
|
||||
let Some(client_turn_id) = client_turn_id else {
|
||||
return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string());
|
||||
return Err(DirectTurnError::ClientTurnIdMissing);
|
||||
};
|
||||
let client_turn_id = client_turn_id.trim();
|
||||
let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS)
|
||||
@@ -20,13 +20,28 @@ pub(crate) fn normalize_direct_client_turn_id(
|
||||
.is_some_and(|byte| byte.is_ascii_alphanumeric());
|
||||
let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-');
|
||||
if !valid_length || !valid_first || !valid_rest {
|
||||
return Err(format!(
|
||||
"clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字"
|
||||
));
|
||||
return Err(DirectTurnError::ClientTurnIdMalformed {
|
||||
min_chars: MIN_DIRECT_CLIENT_TURN_ID_CHARS,
|
||||
max_chars: MAX_DIRECT_CLIENT_TURN_ID_CHARS,
|
||||
});
|
||||
}
|
||||
Ok(client_turn_id.to_string())
|
||||
}
|
||||
|
||||
/// DirectProject 聊天命令:**只接单**,不再 await 整轮。
|
||||
///
|
||||
/// 边界文案仍只在这里生成一次(`Display`);但 `Err` 的含义收窄成**拒单**——接单成立之后的
|
||||
/// 一切失败(连不上 app-server、配置 / 凭据未就绪、历史注入失败、`turn/start` 被拒、模型与
|
||||
/// 交付失败)都由这一轮的占用对象收口成 `turn.completed` 带失败载荷,不再回到这条返回值上。
|
||||
///
|
||||
/// 于是"这一轮跑成什么"只有订阅事件一个来源:命令返回 `Ok` 只说明**接单成立**。可留痕的调用级
|
||||
/// 拒绝(宿主 / 环境事实)仍在边界补一份运行错误诊断,返回串不带诊断引用。
|
||||
///
|
||||
/// 与 CLI 的分工:CLI 入口(`cli.rs` 的 `direct-codex.chat`)**保持 await**——它要把那段回复文本
|
||||
/// 打到终端上,没有事件订阅可用;它复用同一份接单前检查与同一个命令主体,只是自己等整轮的返回值。
|
||||
/// 两个入口共用 [`direct_turn_error_boundary_text`] / [`direct_turn_rejection`],不要再各写一套判据。
|
||||
///
|
||||
/// 设计见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。
|
||||
#[tauri::command]
|
||||
pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
project_path: String,
|
||||
@@ -34,42 +49,369 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
creation_type: Option<String>,
|
||||
client_turn_id: Option<String>,
|
||||
analytics_attempt_id: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let capture = crate::analytics::gui::capture_writer_context();
|
||||
) -> Result<(), DirectTurnRejection> {
|
||||
let root = Path::new(project_path.trim());
|
||||
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
||||
let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
|
||||
recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| {
|
||||
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
|
||||
})?;
|
||||
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
|
||||
validate_direct_codex_user_item(root, &user_item)?;
|
||||
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?;
|
||||
if user_prompt.trim().is_empty() {
|
||||
return Err("聊天内容不能为空".to_string());
|
||||
}
|
||||
let canonical_user_item =
|
||||
// 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。
|
||||
match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?),
|
||||
Err(error) => return Err(redact_agent_runtime_error(root, &error, 1800)),
|
||||
};
|
||||
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
let boundary_turn_id = client_turn_id.clone();
|
||||
chat_with_game_creator_direct_codex_typed(
|
||||
root,
|
||||
user_item,
|
||||
creation_type,
|
||||
client_turn_id,
|
||||
analytics_attempt_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|failure| direct_turn_rejection(root, boundary_turn_id.as_deref(), failure))
|
||||
}
|
||||
|
||||
/// 命令主体:全程 typed。顺序固定,**每一步失败都还是拒单**:
|
||||
/// `clientTurnId` 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 前置条件 → 工程准备
|
||||
/// → 接单 → 落盘用户条目 → 后台起整轮。
|
||||
///
|
||||
/// 这个顺序不是风格问题:接单(`DirectTurnReservation::accept`)必须在所有"接单前就能判定"的
|
||||
/// 检查之后,也必须早于用户条目落盘与 `turn/start`,否则并发拒单会晚于副作用、逻辑回合的开始
|
||||
/// 事件会排在用户消息之后。
|
||||
async fn chat_with_game_creator_direct_codex_typed(
|
||||
root: &Path,
|
||||
user_item: DirectCodexUserItem,
|
||||
creation_type: Option<String>,
|
||||
client_turn_id: Option<String>,
|
||||
analytics_attempt_id: Option<String>,
|
||||
) -> Result<(), DirectTurnError> {
|
||||
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
||||
// 占用调用身份:并发拒单要早于工程准备,避免两个请求同时改同一个项目。它只挡并发,**不是**
|
||||
// 首页"运行中的项目"的来源(那张表由 Thread Manager 的逻辑回合导出),但仍必须与整轮同生
|
||||
// 共死——随任务一起搬进后台。
|
||||
let active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
|
||||
recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| {
|
||||
DirectTurnError::HostStateUnavailable {
|
||||
detail: redact_agent_runtime_error(
|
||||
root,
|
||||
&format!("恢复上一轮陶泥儿整包事务失败:{error}"),
|
||||
500,
|
||||
),
|
||||
}
|
||||
})?;
|
||||
validate_direct_codex_user_item(root, &user_item)
|
||||
.map_err(|detail| DirectTurnError::InputRejected { detail })?;
|
||||
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)
|
||||
.map_err(|detail| DirectTurnError::InputRejected { detail })?;
|
||||
check_direct_turn_preconditions(root, &user_prompt, creation_type.as_deref())?;
|
||||
let canonical_user_item =
|
||||
serde_json::to_value(&user_item).map_err(|error| DirectTurnError::InputRejected {
|
||||
detail: error.to_string(),
|
||||
})?;
|
||||
// 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。
|
||||
crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
let detail = redact_agent_runtime_error(root, &error, 1800);
|
||||
DirectTurnError::EnvironmentNotReady { detail }
|
||||
})?;
|
||||
// 接单:从这里开始这一轮就成立了。开始事件的身份由 `clientTurnId` 推导,**不读盘回填**
|
||||
// ——开始事件发生在用户条目落盘之前,而落盘本身也可能失败。
|
||||
let thread_id = direct_thread_id_for_project(root);
|
||||
let user_item_id = direct_codex_user_item_id_for_client_turn_id(&turn_id);
|
||||
let reservation = DirectTurnReservation::accept(&thread_id, &turn_id, user_item_id.as_deref())?;
|
||||
// 落盘即接单:接单成功就必须在历史里留下这条用户消息,哪怕这一轮随后失败。
|
||||
if let Err(error) = append_direct_project_user_message_at(root, &canonical_user_item) {
|
||||
// 这一轮**已经接单**,所以收口只能走占用对象:写出失败终态(事件流里的那条失败说明就是
|
||||
// 界面唯一一份解释),然后返回 `Ok`——命令的 `Err` 只表示**拒单**,回到那里会让同一个失败
|
||||
// 同时从事件与横幅两条通道下发,也会让前端把"已经开始的回合"读成"没开始"。
|
||||
// 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口
|
||||
// 用户消息的助手回复,而且失败会被静默掉。
|
||||
let failure = DirectTurnError::EnvironmentNotReady {
|
||||
detail: redact_agent_runtime_error(
|
||||
root,
|
||||
&format!("写入本项目对话历史失败:{error}"),
|
||||
600,
|
||||
),
|
||||
};
|
||||
reservation.finish_if_unfinished(DirectTurnTerminal::failed(root, &failure));
|
||||
return Ok(());
|
||||
}
|
||||
// 用户条目落盘成功即下发:这一轮从"接单"到"起 codex"之间的一切失败(连不上
|
||||
// app-server、执行器未通过验收、历史注入失败)都靠它把失败说明挂回自己那一轮;晚到
|
||||
// `turn/start` 之后才发,这些失败就没有用户条目可挂,界面会把说明显示在用户消息上面。
|
||||
crate::agent::codex_app_server::emit_direct_thread_user_item(root, &canonical_user_item);
|
||||
let capture = crate::analytics::gui::capture_writer_context();
|
||||
let root = root.to_path_buf();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_accepted_direct_turn(
|
||||
root,
|
||||
turn_id,
|
||||
user_prompt,
|
||||
creation_type,
|
||||
canonical_user_item,
|
||||
capture,
|
||||
analytics_attempt_id,
|
||||
active_invocation,
|
||||
reservation,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 接单之后的整轮:命令不再 await 它,它的收场只走事件流。
|
||||
///
|
||||
/// 三条收场路径都在这里收口:正常(深层的终态出口写 `turn.completed`)、失败(没有深层终态的
|
||||
/// 早退由这里的占用对象补)、任务被丢弃 / panic(占用对象的 `Drop` 补 `host-dropped`)。
|
||||
///
|
||||
/// 两个守卫都**必须活到整轮结束**,所以随任务搬进来,不留在命令里:
|
||||
/// `_active_invocation` 是这一轮的调用身份(并发拒单与首页在途回合都读它),`reservation`
|
||||
/// 是逻辑回合的占用。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_accepted_direct_turn(
|
||||
root: std::path::PathBuf,
|
||||
turn_id: String,
|
||||
user_prompt: String,
|
||||
creation_type: Option<String>,
|
||||
canonical_user_item: serde_json::Value,
|
||||
capture: Option<(
|
||||
crate::analytics::contract::Context,
|
||||
crate::analytics::store::AnalyticsWriter,
|
||||
)>,
|
||||
analytics_attempt_id: Option<String>,
|
||||
_active_invocation: DirectTaonierActiveInvocationGuard,
|
||||
reservation: DirectTurnReservation,
|
||||
) {
|
||||
let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id);
|
||||
let outcome = run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
&root,
|
||||
&user_prompt,
|
||||
creation_type.as_deref(),
|
||||
Some(&turn_emitter),
|
||||
canonical_user_item,
|
||||
Some(&emitter),
|
||||
Some(canonical_user_item),
|
||||
capture,
|
||||
analytics_attempt_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(reply) => reply,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
|
||||
Ok(reply)
|
||||
.await;
|
||||
match outcome {
|
||||
Ok(reply) => {
|
||||
// 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。
|
||||
emitter.emit("completed", Some("none"), Some(reply), None);
|
||||
}
|
||||
Err(error) => {
|
||||
// 接单之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。
|
||||
// 深层已经写出终态时它不覆盖(同一轮只允许一条终态)。
|
||||
reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::{consume_direct_thread, subscribe_direct_thread, DirectThreadEvent};
|
||||
|
||||
/// 接单之后的早退也必须有终态。
|
||||
///
|
||||
/// 这里用一个"目录存在但不是项目"的根制造一条**接单之后**才发现的失败(连 `run_turn` 的
|
||||
/// 收尾都走不到)。命令此时早已返回 `Ok`,前端唯一的收口依据就是事件流,所以占用对象必须
|
||||
/// 补出 `turn.completed`——这正是接单化要买的那条不变式。
|
||||
#[tokio::test]
|
||||
async fn a_failure_after_accept_still_closes_the_logical_turn() {
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
let root = temp.path().join("not-a-project");
|
||||
std::fs::create_dir_all(&root).expect("create project dir");
|
||||
let thread_id = direct_thread_id_for_project(&root);
|
||||
let subscription = subscribe_direct_thread(&thread_id);
|
||||
let _ = consume_direct_thread(&subscription.subscription_id);
|
||||
let reservation =
|
||||
DirectTurnReservation::accept(&thread_id, "turn-1", Some("direct-codex:turn-1:user"))
|
||||
.expect("accept logical turn");
|
||||
let invocation =
|
||||
DirectTaonierActiveInvocationGuard::enter(&root, "turn-1").expect("enter invocation");
|
||||
|
||||
run_accepted_direct_turn(
|
||||
root.clone(),
|
||||
"turn-1".to_string(),
|
||||
"你好".to_string(),
|
||||
None,
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "direct-codex:turn-1:user",
|
||||
"content": [{ "type": "input_text", "text": "你好" }],
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
invocation,
|
||||
reservation,
|
||||
)
|
||||
.await;
|
||||
|
||||
let events = consume_direct_thread(&subscription.subscription_id)
|
||||
.expect("consume logical turn")
|
||||
.events;
|
||||
let terminal = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
DirectThreadEvent::TurnCompleted {
|
||||
status,
|
||||
failure,
|
||||
user_item_id,
|
||||
..
|
||||
} => Some((status, failure, user_item_id)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(terminal.len(), 1, "一轮只许有一条终态:{events:?}");
|
||||
let (status, failure, user_item_id) = terminal[0];
|
||||
assert_eq!(status, "failed");
|
||||
let failure = failure.as_ref().expect("失败终态必须带载荷");
|
||||
assert!(
|
||||
!failure.message.trim().is_empty(),
|
||||
"接单之后的失败必须带上原因"
|
||||
);
|
||||
assert_eq!(user_item_id.as_deref(), Some("direct-codex:turn-1:user"));
|
||||
}
|
||||
|
||||
/// 本轮的开口用户条目必须先于整轮里任何可能失败的东西下发。
|
||||
///
|
||||
/// 现场(用户可见的坏体验):命令接单、用户条目落盘之后,整轮在 `turn/start` 之前就失败
|
||||
/// (连不上 app-server 一类)。这时如果用户条目还没下发,界面就只剩一条失败说明——它按位置
|
||||
/// 落进**上一轮**的分区里,于是"错误显示在用户消息上面"、上一轮顶替本轮显示耗时,本轮的用户
|
||||
/// 气泡再自成一个 0.0 秒的假回合。
|
||||
///
|
||||
/// 判据取事件流的前两条:命令体是顺序执行的,后台整轮是它之后才起的,所以"开始 → 用户条目"
|
||||
/// 一定在最前面,之后才可能有失败终态。
|
||||
#[tokio::test]
|
||||
async fn the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn() {
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
let root = temp.path().join("direct-user-item-first");
|
||||
crate::init_local_game_project_at(&root, "direct-user-item-first", "用户条目先下发")
|
||||
.expect("init project");
|
||||
let thread_id = direct_thread_id_for_project(&root);
|
||||
let subscription = subscribe_direct_thread(&thread_id);
|
||||
let _ = consume_direct_thread(&subscription.subscription_id);
|
||||
let user_item: DirectCodexUserItem = serde_json::from_value(serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "direct-codex:turn-1:user",
|
||||
"content": [{ "type": "input_text", "text": "hello" }],
|
||||
}))
|
||||
.expect("canonical user item");
|
||||
|
||||
chat_with_game_creator_direct_codex_typed(
|
||||
&root,
|
||||
user_item,
|
||||
None,
|
||||
Some("turn-1".to_string()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("接单成立:命令只回报接单");
|
||||
|
||||
let events = consume_direct_thread(&subscription.subscription_id)
|
||||
.expect("consume logical turn")
|
||||
.events;
|
||||
assert!(
|
||||
matches!(
|
||||
events.first(),
|
||||
Some(DirectThreadEvent::TurnStarted { user_item_id, .. })
|
||||
if user_item_id.as_deref() == Some("direct-codex:turn-1:user")
|
||||
),
|
||||
"第一条必须是带身份的回合开始:{events:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
events.get(1),
|
||||
Some(DirectThreadEvent::ItemCompleted { item, .. })
|
||||
if item.item_id() == "direct-codex:turn-1:user"
|
||||
),
|
||||
"第二条必须是本轮的开口用户条目:{events:?}"
|
||||
);
|
||||
let terminal = events
|
||||
.iter()
|
||||
.position(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. }));
|
||||
assert!(
|
||||
terminal.is_none_or(|index| index > 1),
|
||||
"终态只能在用户条目之后:{events:?}"
|
||||
);
|
||||
// 落盘与下发同一份身份:历史里的条目 id 就是事件里的 itemId。
|
||||
let persisted = std::fs::read_to_string(root.join(".agent/conversations/project.jsonl"))
|
||||
.expect("read project history");
|
||||
assert!(
|
||||
persisted.contains("direct-codex:turn-1:user"),
|
||||
"用户条目必须已经落盘:{persisted}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 接单之后的落盘失败:**只走占用对象的失败终态**,命令返回 `Ok`。
|
||||
///
|
||||
/// 这条路径的 `turn.started` 已经发过,命令再回一个 `Err` 就等于同一个失败下发两次(事件一条
|
||||
/// 说明、横幅又一份),而且 `Err` 的含义是**拒单**——前端会把它读成"这一轮没开始"。历史追加写
|
||||
/// 有一条测试注入(`.agent/runtime/test-fail-next-direct-project-history-append`),用它把这条
|
||||
/// 路径钉成确定性:恰好一条失败终态、命令 `Ok`、占用释放(下一轮还能接单)。
|
||||
#[tokio::test]
|
||||
async fn a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting() {
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
let root = temp.path().join("direct-history-write-failure");
|
||||
crate::init_local_game_project_at(&root, "direct-history-write", "落盘失败")
|
||||
.expect("init project");
|
||||
let thread_id = direct_thread_id_for_project(&root);
|
||||
let subscription = subscribe_direct_thread(&thread_id);
|
||||
let _ = consume_direct_thread(&subscription.subscription_id);
|
||||
// 接下来这次追加写的两次尝试都按"争用失败"返回:确定性地走到落盘失败分支。
|
||||
std::fs::write(
|
||||
root.join(".agent/runtime/test-fail-next-direct-project-history-append"),
|
||||
"9",
|
||||
)
|
||||
.expect("write history contention injection");
|
||||
let user_item: DirectCodexUserItem = serde_json::from_value(serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "direct-codex:turn-1:user",
|
||||
"content": [{ "type": "input_text", "text": "生成一个游戏" }],
|
||||
}))
|
||||
.expect("canonical user item");
|
||||
|
||||
chat_with_game_creator_direct_codex_typed(
|
||||
&root,
|
||||
user_item,
|
||||
None,
|
||||
Some("turn-1".to_string()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("接单之后的失败不再回到命令返回值:命令只回报接单成立");
|
||||
|
||||
let events = consume_direct_thread(&subscription.subscription_id)
|
||||
.expect("consume logical turn")
|
||||
.events;
|
||||
let terminals = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
DirectThreadEvent::TurnCompleted {
|
||||
status, failure, ..
|
||||
} => Some((status, failure)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(terminals.len(), 1, "一轮只许有一条终态:{events:?}");
|
||||
let (status, failure) = terminals[0];
|
||||
assert_eq!(status, "failed");
|
||||
let failure = failure.as_ref().expect("失败终态必须带载荷");
|
||||
assert!(
|
||||
failure.message.contains("写入本项目对话历史失败"),
|
||||
"{}",
|
||||
failure.message
|
||||
);
|
||||
// 这一轮已经接单,所以走的是**回合失败**:拒单那套 `direct-codex-failure:v2` 收口文案
|
||||
// 不许出现在这里(它只属于可留痕的拒单)。
|
||||
assert!(
|
||||
!failure.message.contains("direct-codex-failure"),
|
||||
"{}",
|
||||
failure.message
|
||||
);
|
||||
// 占用已释放:下一轮还能接单。
|
||||
assert!(!crate::agent::direct_thread_turn_is_active(&thread_id));
|
||||
assert!(DirectTurnReservation::accept(
|
||||
&thread_id,
|
||||
"turn-2",
|
||||
Some("direct-codex:turn-2:user")
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,46 @@ struct SubscriberState {
|
||||
cursor: u64,
|
||||
}
|
||||
|
||||
/// 一条正在跑的逻辑回合的占用:接单时登记,终态写出时解除。
|
||||
///
|
||||
/// 它同时是首页「运行中的项目」快照的**唯一事实源**([`list_direct_active_turns`]):这一格的
|
||||
/// 生命周期就是"这一轮在不在跑",进度字段由运行时那一侧经 [`update_direct_thread_active_turn`]
|
||||
/// 回填。任务侧不再另建一张活动回合表——同一件事只许有一处真相。
|
||||
///
|
||||
/// 两个身份别混:
|
||||
/// - `token` 是这一次接单的占用身份:终态出口只有拿着同一个 token 的占用对象才能写兜底终态,
|
||||
/// 避免迟到的旧占用把新回合的边界顶掉。它不对外。
|
||||
/// - `turn_id` 是给界面看的回合身份(`clientTurnId` 派生),只服务快照与进度回填的匹配。
|
||||
#[derive(Clone, Debug)]
|
||||
struct ActiveDirectTurn {
|
||||
token: String,
|
||||
turn_id: String,
|
||||
project_name: Option<String>,
|
||||
started_at: u64,
|
||||
status: String,
|
||||
activity: Option<String>,
|
||||
updated_at: u64,
|
||||
sequence: u64,
|
||||
}
|
||||
|
||||
/// 首页「运行中的项目」的一条快照。
|
||||
///
|
||||
/// `project_path` 与线上其它地方的项目身份取同一个字符串:Thread Manager 的线程身份就是项目的
|
||||
/// canonical 路径(见 `direct_thread_id_for_project`),所以快照里的项目身份与事件流里的身份
|
||||
/// 永远能对上,不需要调用方再做一次归一。
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectActiveTurnSnapshot {
|
||||
pub(crate) project_path: String,
|
||||
pub(crate) project_name: Option<String>,
|
||||
pub(crate) turn_id: String,
|
||||
pub(crate) started_at: u64,
|
||||
pub(crate) status: String,
|
||||
pub(crate) activity: Option<String>,
|
||||
pub(crate) updated_at: u64,
|
||||
pub(crate) sequence: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ThreadState {
|
||||
next_seq: u64,
|
||||
@@ -43,6 +83,8 @@ struct ThreadState {
|
||||
total_bytes: usize,
|
||||
active_items: HashSet<String>,
|
||||
unresolved_requests: HashSet<String>,
|
||||
/// 未收口的逻辑回合。`None` 表示这个 thread 没有正在跑的回合。
|
||||
active_turn: Option<ActiveDirectTurn>,
|
||||
/// 最近一条 `turn.started` / `turn.completed` 的独立拷贝。
|
||||
///
|
||||
/// TODO(thread-manager): 这里有意只保留"锚点",因为 replay 队列会回收可回收事件,
|
||||
@@ -63,6 +105,7 @@ impl Default for ThreadState {
|
||||
total_bytes: 0,
|
||||
active_items: HashSet::new(),
|
||||
unresolved_requests: HashSet::new(),
|
||||
active_turn: None,
|
||||
lifecycle_anchor: None,
|
||||
subscribers: HashMap::new(),
|
||||
}
|
||||
@@ -155,6 +198,139 @@ impl DirectThreadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// 接单:同一个临界区里拒绝并发、登记占用、追加逻辑回合开始事件。
|
||||
///
|
||||
/// 返回 `Err(existing_turn_id)` 表示这个 thread 已经有一条没收口的回合——此时不动队列,
|
||||
/// 由调用方把它投影成接单拒绝。回的是**回合身份**(调用方接单时给的 `turn_id`)而不是占用
|
||||
/// `token`:占用 token 只活在这个进程里,界面拿它匹配不了自己发出的那一轮,也没法判断
|
||||
/// "撞的是同一轮还是另一轮"。
|
||||
fn accept_turn(
|
||||
&mut self,
|
||||
thread_id: &str,
|
||||
token: &str,
|
||||
turn_id: &str,
|
||||
user_item_id: Option<&str>,
|
||||
started_at_ms: u64,
|
||||
) -> Result<DirectThreadEvent, String> {
|
||||
{
|
||||
let thread = self.threads.entry(thread_id.to_string()).or_default();
|
||||
if let Some(active) = thread.active_turn.as_ref() {
|
||||
return Err(active.turn_id.clone());
|
||||
}
|
||||
thread.active_turn = Some(ActiveDirectTurn {
|
||||
token: token.to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
project_name: std::path::Path::new(thread_id)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(str::to_string),
|
||||
started_at: started_at_ms,
|
||||
// 与"还没有任何进度事件"的状态一致:运行时给出的第一条进度会覆盖它。
|
||||
status: "accepted".to_string(),
|
||||
activity: Some("request-accepted".to_string()),
|
||||
updated_at: started_at_ms,
|
||||
sequence: 0,
|
||||
});
|
||||
}
|
||||
Ok(self.append(
|
||||
thread_id,
|
||||
DirectThreadEvent::turn_started(started_at_ms).with_user_item_id(user_item_id),
|
||||
))
|
||||
}
|
||||
|
||||
/// 运行时回填这一轮的进度。只认"仍在跑 + 回合身份一致 + 序号不倒退"的那一次。
|
||||
///
|
||||
/// 返回是否真的写进去了:没有未收口的回合、身份对不上(上一轮迟到的进度)、序号倒退
|
||||
/// (乱序到达的旧进度)都必须原地丢弃,不能把快照改成过期的样子。
|
||||
fn update_active_turn(
|
||||
&mut self,
|
||||
thread_id: &str,
|
||||
turn_id: &str,
|
||||
status: &str,
|
||||
activity: Option<&str>,
|
||||
sequence: u64,
|
||||
updated_at: u64,
|
||||
) -> bool {
|
||||
let Some(active) = self
|
||||
.threads
|
||||
.get_mut(thread_id)
|
||||
.and_then(|thread| thread.active_turn.as_mut())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if active.turn_id != turn_id || sequence < active.sequence {
|
||||
return false;
|
||||
}
|
||||
active.status = status.to_string();
|
||||
active.activity = activity.map(str::to_string);
|
||||
active.updated_at = updated_at;
|
||||
active.sequence = sequence;
|
||||
true
|
||||
}
|
||||
|
||||
/// 首页快照:只导出仍有未收口逻辑回合的 thread。
|
||||
fn active_turn_snapshots(&self) -> Vec<DirectActiveTurnSnapshot> {
|
||||
self.threads
|
||||
.iter()
|
||||
.filter_map(|(thread_id, thread)| {
|
||||
let active = thread.active_turn.as_ref()?;
|
||||
Some(DirectActiveTurnSnapshot {
|
||||
project_path: thread_id.clone(),
|
||||
project_name: active.project_name.clone(),
|
||||
turn_id: active.turn_id.clone(),
|
||||
started_at: active.started_at,
|
||||
status: active.status.clone(),
|
||||
activity: active.activity.clone(),
|
||||
updated_at: active.updated_at,
|
||||
sequence: active.sequence,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 深层的终态出口:解除占用并写下 `turn.completed`。
|
||||
///
|
||||
/// 不校验 token:这一条由真正跑完这一轮的代码调用,终态就是它算出来的那个(CLI 这类没有
|
||||
/// 占用登记的入口也走这里,保持"终态一定下发"的既有语义)。
|
||||
fn complete_turn(&mut self, thread_id: &str, event: DirectThreadEvent) -> DirectThreadEvent {
|
||||
if let Some(thread) = self.threads.get_mut(thread_id) {
|
||||
thread.active_turn = None;
|
||||
}
|
||||
self.append(thread_id, event)
|
||||
}
|
||||
|
||||
/// 占用对象的兜底出口:只有当这个 thread 仍被同一个 token 占用时才写。
|
||||
///
|
||||
/// 返回是否真的写了。深层已经写出终态时返回 `false`——兜底不覆盖真实结果。
|
||||
fn complete_turn_if_reserved(
|
||||
&mut self,
|
||||
thread_id: &str,
|
||||
token: &str,
|
||||
event: DirectThreadEvent,
|
||||
) -> bool {
|
||||
let reserved = match self.threads.get_mut(thread_id) {
|
||||
Some(thread) => match thread.active_turn.as_ref() {
|
||||
Some(active) if active.token == token => {
|
||||
thread.active_turn = None;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
None => false,
|
||||
};
|
||||
if !reserved {
|
||||
return false;
|
||||
}
|
||||
self.append(thread_id, event);
|
||||
true
|
||||
}
|
||||
|
||||
fn turn_is_active(&self, thread_id: &str) -> bool {
|
||||
self.threads
|
||||
.get(thread_id)
|
||||
.is_some_and(|thread| thread.active_turn.is_some())
|
||||
}
|
||||
|
||||
fn subscriber_ids(&self, thread_id: &str) -> Vec<String> {
|
||||
self.threads
|
||||
.get(thread_id)
|
||||
@@ -377,13 +553,105 @@ pub(crate) fn append_direct_thread_event(
|
||||
thread_id: &str,
|
||||
event: DirectThreadEvent,
|
||||
) -> DirectThreadEvent {
|
||||
let (event, subscriber_ids) = {
|
||||
let mut manager = global_direct_thread_manager()
|
||||
let event = {
|
||||
global_direct_thread_manager()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.append(thread_id, event)
|
||||
};
|
||||
notify_direct_thread_subscribers(thread_id);
|
||||
event
|
||||
}
|
||||
|
||||
/// 接单:拒绝并发 + 登记占用 + 发逻辑回合开始事件(见 [`DirectThreadManager::accept_turn`])。
|
||||
/// `Err` 是这一轮**已有的回合身份**(`turn_id`,也就是调用方的 `clientTurnId`),不是占用 token:
|
||||
/// 调用方拿它投影成 `TurnAlreadyRunning` 的两个身份字段,界面按"撞的是同一轮还是另一轮"决定要
|
||||
/// 不要动当前回合。
|
||||
pub(crate) fn accept_direct_thread_turn(
|
||||
thread_id: &str,
|
||||
token: &str,
|
||||
turn_id: &str,
|
||||
user_item_id: Option<&str>,
|
||||
started_at_ms: u64,
|
||||
) -> Result<(), String> {
|
||||
{
|
||||
global_direct_thread_manager()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.accept_turn(thread_id, token, turn_id, user_item_id, started_at_ms)?;
|
||||
}
|
||||
notify_direct_thread_subscribers(thread_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 运行时回填某一轮逻辑回合的进度(状态 / 活动 / 序号)。返回是否真的写进去了。
|
||||
pub(crate) fn update_direct_thread_active_turn(
|
||||
thread_id: &str,
|
||||
turn_id: &str,
|
||||
status: &str,
|
||||
activity: Option<&str>,
|
||||
sequence: u64,
|
||||
updated_at: u64,
|
||||
) -> bool {
|
||||
global_direct_thread_manager()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.update_active_turn(thread_id, turn_id, status, activity, sequence, updated_at)
|
||||
}
|
||||
|
||||
/// 首页「运行中的项目」快照:逻辑回合的唯一导出口(见 [`DirectActiveTurnSnapshot`])。
|
||||
pub(crate) fn list_direct_active_turns() -> Result<Vec<DirectActiveTurnSnapshot>, String> {
|
||||
let mut turns = global_direct_thread_manager()
|
||||
.lock()
|
||||
.map_err(|_| "Direct 线程管理器已损坏".to_string())?
|
||||
.active_turn_snapshots();
|
||||
turns.sort_by(|left, right| left.project_path.cmp(&right.project_path));
|
||||
Ok(turns)
|
||||
}
|
||||
|
||||
/// 深层终态出口:解除占用并写 `turn.completed`。
|
||||
pub(crate) fn complete_direct_thread_turn(thread_id: &str, event: DirectThreadEvent) {
|
||||
{
|
||||
global_direct_thread_manager()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.complete_turn(thread_id, event);
|
||||
}
|
||||
notify_direct_thread_subscribers(thread_id);
|
||||
}
|
||||
|
||||
/// 占用对象的兜底出口:仍被同一 token 占用时才写,返回是否写了。
|
||||
pub(crate) fn complete_direct_thread_turn_if_reserved(
|
||||
thread_id: &str,
|
||||
token: &str,
|
||||
event: DirectThreadEvent,
|
||||
) -> bool {
|
||||
let written = {
|
||||
global_direct_thread_manager()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.complete_turn_if_reserved(thread_id, token, event)
|
||||
};
|
||||
if written {
|
||||
notify_direct_thread_subscribers(thread_id);
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
/// 这个 thread 是否还有没收口的逻辑回合。
|
||||
pub(crate) fn direct_thread_turn_is_active(thread_id: &str) -> bool {
|
||||
global_direct_thread_manager()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.turn_is_active(thread_id)
|
||||
}
|
||||
|
||||
fn notify_direct_thread_subscribers(thread_id: &str) {
|
||||
let subscriber_ids = {
|
||||
let manager = global_direct_thread_manager()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let event = manager.append(thread_id, event);
|
||||
let subscriber_ids = manager.subscriber_ids(thread_id);
|
||||
(event, subscriber_ids)
|
||||
manager.subscriber_ids(thread_id)
|
||||
};
|
||||
if let Some(app) = DIRECT_THREAD_MANAGER_APP_HANDLE.get() {
|
||||
for subscription_id in subscriber_ids {
|
||||
@@ -394,7 +662,6 @@ pub(crate) fn append_direct_thread_event(
|
||||
);
|
||||
}
|
||||
}
|
||||
event
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe_direct_thread(thread_id: &str) -> DirectThreadSubscriptionBootstrap {
|
||||
@@ -603,6 +870,34 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// 失败终态与正常终态同权:`turn.completed(status="failed")` 必须顶替更早的 `turn.started`
|
||||
/// 成为锚点,否则队列被回收后新订阅只会看到 `turn.started`,把这轮已收口的回合重放成"还在跑"。
|
||||
#[test]
|
||||
fn failed_turn_completed_replaces_started_anchor() {
|
||||
let mut manager = DirectThreadManager::with_limits(100, 100_000);
|
||||
manager.append("thread-1", DirectThreadEvent::turn_started(1_000));
|
||||
manager.append(
|
||||
"thread-1",
|
||||
DirectThreadEvent::turn_completed_failed(
|
||||
crate::agent::DirectTurnFailure::new(
|
||||
crate::agent::DirectTurnFailureKind::HostDropped,
|
||||
"回合宿主任务提前结束",
|
||||
),
|
||||
FIXED_AT_MS,
|
||||
),
|
||||
);
|
||||
|
||||
let bootstrap = manager.subscribe("thread-1");
|
||||
assert!(matches!(
|
||||
bootstrap.events.as_slice(),
|
||||
[DirectThreadEvent::TurnCompleted { status, failure, at, .. }]
|
||||
if status == "failed"
|
||||
&& failure.as_ref().is_some_and(|failure| failure.kind
|
||||
== crate::agent::DirectTurnFailureKind::HostDropped)
|
||||
&& *at == Some(FIXED_AT_MS)
|
||||
));
|
||||
}
|
||||
|
||||
/// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**,
|
||||
/// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。
|
||||
#[test]
|
||||
@@ -723,4 +1018,79 @@ mod tests {
|
||||
"unfinished item at queue head blocks middle cleanup"
|
||||
);
|
||||
}
|
||||
|
||||
/// 首页快照就是逻辑回合的导出:接单即出现、进度按序号回填、收口即消失。
|
||||
fn snapshot_of(
|
||||
manager: &DirectThreadManager,
|
||||
thread_id: &str,
|
||||
) -> Option<DirectActiveTurnSnapshot> {
|
||||
manager
|
||||
.active_turn_snapshots()
|
||||
.into_iter()
|
||||
.find(|turn| turn.project_path == thread_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_turn_snapshot_follows_the_logical_turn_lifecycle() {
|
||||
let mut manager = DirectThreadManager::with_limits(100, 100_000);
|
||||
let thread_id = "/tmp/快照项目";
|
||||
assert!(snapshot_of(&manager, thread_id).is_none());
|
||||
|
||||
manager
|
||||
.accept_turn(thread_id, "token-1", "turn-1", Some("u-1"), FIXED_AT_MS)
|
||||
.expect("accept");
|
||||
let accepted = snapshot_of(&manager, thread_id).expect("accepted turn is visible");
|
||||
assert_eq!(accepted.turn_id, "turn-1");
|
||||
assert_eq!(accepted.project_name.as_deref(), Some("快照项目"));
|
||||
assert_eq!(accepted.started_at, FIXED_AT_MS);
|
||||
assert_eq!(accepted.status, "accepted");
|
||||
assert_eq!(accepted.activity.as_deref(), Some("request-accepted"));
|
||||
assert_eq!(accepted.sequence, 0);
|
||||
|
||||
assert!(manager.update_active_turn(
|
||||
thread_id,
|
||||
"turn-1",
|
||||
"streaming",
|
||||
Some("file-write"),
|
||||
3,
|
||||
42,
|
||||
));
|
||||
let running = snapshot_of(&manager, thread_id).expect("running turn is visible");
|
||||
assert_eq!(running.status, "streaming");
|
||||
assert_eq!(running.activity.as_deref(), Some("file-write"));
|
||||
assert_eq!(running.sequence, 3);
|
||||
assert_eq!(running.updated_at, 42);
|
||||
|
||||
// 序号倒退与身份对不上的进度都不许改快照。
|
||||
assert!(!manager.update_active_turn(thread_id, "turn-1", "failed", None, 2, 99));
|
||||
assert!(!manager.update_active_turn(thread_id, "turn-2", "failed", None, 4, 99));
|
||||
assert_eq!(
|
||||
snapshot_of(&manager, thread_id)
|
||||
.expect("snapshot unchanged")
|
||||
.status,
|
||||
"streaming"
|
||||
);
|
||||
|
||||
manager.complete_turn(
|
||||
thread_id,
|
||||
DirectThreadEvent::turn_completed("completed".to_string(), 5_000),
|
||||
);
|
||||
assert!(snapshot_of(&manager, thread_id).is_none());
|
||||
}
|
||||
|
||||
/// 并发接单回给调用方的是**回合身份**(`turn_id`),不是占用 `token`:token 只活在这个进程
|
||||
/// 里,界面拿它匹配不了自己发出的那一轮。
|
||||
#[test]
|
||||
fn accept_conflict_returns_the_existing_turn_id() {
|
||||
let mut manager = DirectThreadManager::with_limits(100, 100_000);
|
||||
manager
|
||||
.accept_turn("thread-1", "token-1", "turn-1", None, FIXED_AT_MS)
|
||||
.expect("accept");
|
||||
|
||||
let conflict = manager
|
||||
.accept_turn("thread-1", "token-2", "turn-2", None, FIXED_AT_MS)
|
||||
.err();
|
||||
|
||||
assert_eq!(conflict.as_deref(), Some("turn-1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
use crate::agent::redact_secret_tokens;
|
||||
use crate::agent::sanitize_error_context;
|
||||
use crate::agent::DirectTurnFailureKind;
|
||||
use crate::redact_absolute_path_tokens;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -211,6 +212,30 @@ impl DirectThreadRequestKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。
|
||||
///
|
||||
/// `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的
|
||||
/// 可展示原因——失败原因只走这一条通道,前端不再从命令返回或另一条 IPC 里另造文案。
|
||||
#[derive(Clone, Debug, Eq, 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 DirectTurnFailure {
|
||||
/// 稳定失败分类;取值表就是 [`DirectTurnFailureKind`],投影只走
|
||||
/// [`DirectTurnError::wire_kind`]。
|
||||
pub(crate) kind: DirectTurnFailureKind,
|
||||
/// 脱敏 + 截断后的失败原因。
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
impl DirectTurnFailure {
|
||||
pub(crate) fn new(kind: DirectTurnFailureKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread Manager 下发的运行态事件。
|
||||
///
|
||||
/// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`:
|
||||
@@ -229,7 +254,8 @@ impl DirectThreadRequestKind {
|
||||
/// 不能在前端收到或重放时重新取当前时间。
|
||||
///
|
||||
/// `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical
|
||||
/// itemId**(与同轮那条用户条目事件同源,由原生从已落盘条目上读取,不另造身份)。回合事件本身
|
||||
/// itemId**(与同轮那条用户条目事件同源,由宿主按 `clientTurnId` 现算,`direct-codex:{clientTurnId}:user`;
|
||||
/// **不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败)。回合事件本身
|
||||
/// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点
|
||||
/// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明
|
||||
/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。
|
||||
@@ -239,7 +265,8 @@ impl DirectThreadRequestKind {
|
||||
pub(crate) enum DirectThreadEvent {
|
||||
#[serde(rename = "turn.started")]
|
||||
TurnStarted {
|
||||
/// 本轮开始的阶段时间(毫秒):宿主处理 `turn/start` 的毫秒钟。
|
||||
/// 本轮开始的阶段时间(毫秒):**接单**那一刻的宿主毫秒钟(逻辑回合的起点,不是
|
||||
/// `turn/start` 的时刻)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional, as = "Option<f64>")]
|
||||
at: Option<u64>,
|
||||
@@ -250,8 +277,14 @@ pub(crate) enum DirectThreadEvent {
|
||||
},
|
||||
#[serde(rename = "turn.completed")]
|
||||
TurnCompleted {
|
||||
/// 终态语义:`completed` / `interrupted` / `aborted` 是正常收场;`failed` 是**失败**,
|
||||
/// 此时必须带 `failure` 载荷。
|
||||
status: String,
|
||||
/// 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。
|
||||
/// 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
failure: Option<DirectTurnFailure>,
|
||||
/// 本轮终态的阶段时间(毫秒):宿主写下终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional, as = "Option<f64>")]
|
||||
at: Option<u64>,
|
||||
@@ -301,11 +334,30 @@ impl DirectThreadEvent {
|
||||
pub(crate) fn turn_completed(status: String, at: u64) -> Self {
|
||||
Self::TurnCompleted {
|
||||
status,
|
||||
failure: None,
|
||||
at: Some(at),
|
||||
user_item_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败终态:`status` 固定 `"failed"`,原因必须随事件一起带出去。
|
||||
pub(crate) fn turn_completed_failed(failure: DirectTurnFailure, at: u64) -> Self {
|
||||
Self::TurnCompleted {
|
||||
status: "failed".to_string(),
|
||||
failure: Some(failure),
|
||||
at: Some(at),
|
||||
user_item_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败载荷:只有失败终态有。
|
||||
pub(crate) fn failure(&self) -> Option<&DirectTurnFailure> {
|
||||
match self {
|
||||
Self::TurnCompleted { failure, .. } => failure.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 附上本轮开口用户条目的 canonical itemId。
|
||||
///
|
||||
/// 只在构造之后补一次身份,避免 `turn.started` / `turn.completed` 的既有调用点(含各处兜底
|
||||
@@ -317,8 +369,14 @@ impl DirectThreadEvent {
|
||||
.map(str::to_string);
|
||||
match self {
|
||||
Self::TurnStarted { at, .. } => Self::TurnStarted { at, user_item_id },
|
||||
Self::TurnCompleted { status, at, .. } => Self::TurnCompleted {
|
||||
Self::TurnCompleted {
|
||||
status,
|
||||
failure,
|
||||
at,
|
||||
..
|
||||
} => Self::TurnCompleted {
|
||||
status,
|
||||
failure,
|
||||
at,
|
||||
user_item_id,
|
||||
},
|
||||
@@ -1353,4 +1411,59 @@ mod tests {
|
||||
);
|
||||
assert_eq!(item_event.user_item_id(), None);
|
||||
}
|
||||
|
||||
/// 失败终态:`status="failed"` 必须带 `failure{kind,message}`,正常终态不带;载荷跟着身份
|
||||
/// 一起流转,缺载荷的 `failed` 事件仍能反序列化(前端按"没有原因"处理,不猜)。
|
||||
#[test]
|
||||
fn turn_completed_carries_failure_payload_only_when_failed() {
|
||||
let failed = DirectThreadEvent::turn_completed_failed(
|
||||
DirectTurnFailure::new(
|
||||
crate::agent::DirectTurnFailureKind::ModelFailed,
|
||||
"上游返回 500:模型服务暂不可用",
|
||||
),
|
||||
4_000,
|
||||
)
|
||||
.with_user_item_id(Some("direct-codex:turn-1:user"));
|
||||
assert_eq!(
|
||||
failed.failure(),
|
||||
Some(&DirectTurnFailure::new(
|
||||
crate::agent::DirectTurnFailureKind::ModelFailed,
|
||||
"上游返回 500:模型服务暂不可用"
|
||||
))
|
||||
);
|
||||
assert_eq!(failed.user_item_id(), Some("direct-codex:turn-1:user"));
|
||||
assert_eq!(
|
||||
serde_json::to_value(&failed).expect("serialize failed turn"),
|
||||
json!({
|
||||
"type": "turn.completed",
|
||||
"status": "failed",
|
||||
"failure": {"kind": "model-failed", "message": "上游返回 500:模型服务暂不可用"},
|
||||
"at": 4_000u64,
|
||||
"userItemId": "direct-codex:turn-1:user",
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<DirectThreadEvent>(
|
||||
serde_json::to_value(&failed).expect("serialize")
|
||||
)
|
||||
.expect("round trip"),
|
||||
failed
|
||||
);
|
||||
|
||||
// 正常终态不带载荷,也不回写 `failure: null`。
|
||||
let completed = DirectThreadEvent::turn_completed("completed".to_string(), 5_000);
|
||||
assert_eq!(completed.failure(), None);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&completed).expect("serialize completed turn"),
|
||||
json!({"type": "turn.completed", "status": "completed", "at": 5_000u64})
|
||||
);
|
||||
|
||||
// 精简 / 旧形状:`failed` 但没有载荷也要能反序列化。
|
||||
let sparse: DirectThreadEvent = serde_json::from_value(json!({
|
||||
"type": "turn.completed",
|
||||
"status": "failed",
|
||||
}))
|
||||
.expect("failed turn without failure payload");
|
||||
assert_eq!(sparse.failure(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
//! DirectProject 的接单:把"一条用户消息被接单"变成 Thread Manager 里一对必然成对的逻辑回合事件。
|
||||
//!
|
||||
//! 这个模块只有一件事,别再往里加第二件:**接单成立的那一刻**在同一个临界区里拒绝并发、登记占用、
|
||||
//! 发出逻辑回合开始事件;占用对象持有这一轮的终态出口——正常 / 失败 / 接单后的前置失败谁先写谁算,
|
||||
//! 都没写时由 `Drop` 补一条 `host-dropped`。
|
||||
//!
|
||||
//! 为什么回合边界不能继续镜像 Codex 原生回合:`turn/start` 之前的失败(连不上 app-server、配置未
|
||||
//! 就绪失败、历史注入失败)根本没有原生回合可以镜像,而它们同样是"这一轮已经成立"。设计见
|
||||
//! `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
accept_direct_thread_turn, complete_direct_thread_turn_if_reserved, direct_tool_call_now_ms,
|
||||
DirectTurnError, DirectTurnTerminal,
|
||||
};
|
||||
|
||||
/// 一次接单的占用。持有它就代表这一轮还没收口。
|
||||
///
|
||||
/// 生命周期由调用方决定:命令把整轮任务 spawn 出去时把它一起搬进任务,任务结束(正常或失败)
|
||||
/// 时它随任务一起 drop。**持有顺序要与单飞锁一致**:单飞锁先声明、占用后声明,drop 时占用先收尾,
|
||||
/// 新回合不可能插到中间。
|
||||
pub(crate) struct DirectTurnReservation {
|
||||
thread_id: String,
|
||||
token: String,
|
||||
user_item_id: Option<String>,
|
||||
}
|
||||
|
||||
impl DirectTurnReservation {
|
||||
/// 接单:登记占用并发出逻辑回合开始事件。
|
||||
///
|
||||
/// 失败表示这个 thread 已经有一条没收口的回合(并发接单),此时不改队列、不发事件。
|
||||
/// `client_turn_id` 是给界面看的回合身份(首页快照与进度回填按它匹配),与占用身份 `token`
|
||||
/// 是两件事:前者来自调用方,后者只活在这个进程里。
|
||||
///
|
||||
/// 拒单载荷里的两个身份都取**回合身份**:`existing` 是已在跑的那一轮的 `clientTurnId`
|
||||
/// (Thread Manager 回的就是它),`incoming` 是本次请求的 `clientTurnId`。同一轮重发时两者
|
||||
/// 相等,界面才走得到"同一轮消息仍在处理中"那条文案。
|
||||
pub(crate) fn accept(
|
||||
thread_id: &str,
|
||||
client_turn_id: &str,
|
||||
user_item_id: Option<&str>,
|
||||
) -> Result<Self, DirectTurnError> {
|
||||
let token = Uuid::new_v4().to_string();
|
||||
accept_direct_thread_turn(
|
||||
thread_id,
|
||||
&token,
|
||||
client_turn_id,
|
||||
user_item_id,
|
||||
direct_tool_call_now_ms(),
|
||||
)
|
||||
.map_err(|existing| DirectTurnError::TurnAlreadyRunning {
|
||||
existing_invocation_id: existing,
|
||||
incoming_invocation_id: client_turn_id.to_string(),
|
||||
})?;
|
||||
Ok(Self {
|
||||
thread_id: thread_id.to_string(),
|
||||
token,
|
||||
user_item_id: user_item_id.map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn thread_id(&self) -> &str {
|
||||
&self.thread_id
|
||||
}
|
||||
|
||||
/// 接单之后还没走到深层终态就失败的收口口:只有这一轮仍被自己占用时才写。
|
||||
///
|
||||
/// 深层(真正跑完这一轮的代码)已经写出终态时返回 `false`,兜底不覆盖真实结果。
|
||||
pub(crate) fn finish_if_unfinished(&self, terminal: DirectTurnTerminal) -> bool {
|
||||
complete_direct_thread_turn_if_reserved(
|
||||
&self.thread_id,
|
||||
&self.token,
|
||||
terminal.event(direct_tool_call_now_ms(), self.user_item_id.as_deref()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DirectTurnReservation {
|
||||
fn drop(&mut self) {
|
||||
// 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。
|
||||
// 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。
|
||||
let _ = self.finish_if_unfinished(DirectTurnTerminal::host_dropped());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::{
|
||||
consume_direct_thread, direct_thread_turn_is_active, subscribe_direct_thread,
|
||||
DirectThreadEvent, DirectTurnFailure, DirectTurnFailureKind,
|
||||
};
|
||||
|
||||
/// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。
|
||||
fn watch(thread_id: &str) -> String {
|
||||
let bootstrap = subscribe_direct_thread(thread_id);
|
||||
let _ = consume_direct_thread(&bootstrap.subscription_id);
|
||||
bootstrap.subscription_id
|
||||
}
|
||||
|
||||
fn pending(subscription_id: &str) -> Vec<DirectThreadEvent> {
|
||||
consume_direct_thread(subscription_id)
|
||||
.expect("consume")
|
||||
.events
|
||||
}
|
||||
|
||||
fn turn_completed_events(events: &[DirectThreadEvent]) -> Vec<&DirectThreadEvent> {
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn unique_thread(label: &str) -> String {
|
||||
format!("accept-test-{label}-{}", Uuid::new_v4())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_emits_a_logical_turn_started_and_holds_the_turn() {
|
||||
let thread = unique_thread("started");
|
||||
let subscription = watch(&thread);
|
||||
|
||||
let reservation =
|
||||
DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept");
|
||||
|
||||
let events = pending(&subscription);
|
||||
assert_eq!(events.len(), 1, "{events:?}");
|
||||
match &events[0] {
|
||||
DirectThreadEvent::TurnStarted { user_item_id, .. } => {
|
||||
assert_eq!(user_item_id.as_deref(), Some("u-1"));
|
||||
}
|
||||
other => panic!("expected turn.started, got {other:?}"),
|
||||
}
|
||||
assert!(direct_thread_turn_is_active(&thread));
|
||||
drop(reservation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_accept_is_rejected_while_the_turn_is_open() {
|
||||
let thread = unique_thread("busy");
|
||||
let subscription = watch(&thread);
|
||||
let reservation =
|
||||
DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept");
|
||||
// 先取走第一条接单自己的开始事件,之后的"空"才只说明被拒的这一次没写东西。
|
||||
assert_eq!(pending(&subscription).len(), 1);
|
||||
|
||||
let rejected = DirectTurnReservation::accept(&thread, "turn-2", Some("u-2"));
|
||||
|
||||
assert!(matches!(
|
||||
rejected,
|
||||
Err(DirectTurnError::TurnAlreadyRunning { .. })
|
||||
));
|
||||
let events = pending(&subscription);
|
||||
assert_eq!(events.len(), 0, "被拒的接单不许产生事件:{events:?}");
|
||||
drop(reservation);
|
||||
}
|
||||
|
||||
/// 拒单载荷里的两个身份都是**回合身份**:撞的是同一轮时两者相等,界面才走得到"同一轮消息仍在
|
||||
/// 处理中";撞的是另一轮时两者不等,界面才敢提示"另一条回合在运行"。占用 token 只活在本进程,
|
||||
/// 一旦漏进载荷,这两个分支就都判不出来(UUID 永远不等于界面的 `clientTurnId`)。
|
||||
#[test]
|
||||
fn accept_conflict_reports_client_turn_ids_not_reservation_tokens() {
|
||||
let thread = unique_thread("same-turn-conflict");
|
||||
let reservation =
|
||||
DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept");
|
||||
|
||||
let same_turn = match DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")) {
|
||||
Ok(_) => panic!("同一 thread 的第二条回合必须被拒"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let DirectTurnError::TurnAlreadyRunning {
|
||||
existing_invocation_id,
|
||||
incoming_invocation_id,
|
||||
} = &same_turn
|
||||
else {
|
||||
panic!("expected a concurrency rejection, got {same_turn:?}");
|
||||
};
|
||||
assert_eq!(existing_invocation_id, "turn-1");
|
||||
assert_eq!(incoming_invocation_id, "turn-1");
|
||||
assert!(
|
||||
same_turn.to_string().contains("同一轮消息仍在处理中"),
|
||||
"{same_turn}"
|
||||
);
|
||||
|
||||
let other_turn = match DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")) {
|
||||
Ok(_) => panic!("同一 thread 的第二条回合必须被拒"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(matches!(
|
||||
&other_turn,
|
||||
DirectTurnError::TurnAlreadyRunning {
|
||||
existing_invocation_id,
|
||||
incoming_invocation_id,
|
||||
} if existing_invocation_id == "turn-1" && incoming_invocation_id == "turn-2"
|
||||
));
|
||||
assert!(
|
||||
other_turn.to_string().contains("另一条 Direct 客户端回合"),
|
||||
"{other_turn}"
|
||||
);
|
||||
drop(reservation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_without_a_terminal_writes_a_host_dropped_terminal() {
|
||||
let thread = unique_thread("drop");
|
||||
let subscription = watch(&thread);
|
||||
let reservation =
|
||||
DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept");
|
||||
assert!(reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()));
|
||||
assert!(!direct_thread_turn_is_active(&thread));
|
||||
|
||||
// 显式收口之后 Drop 不再补第二条:兜底只负责"没人写过"的那一种。
|
||||
drop(reservation);
|
||||
|
||||
let events = pending(&subscription);
|
||||
let completed = turn_completed_events(&events);
|
||||
assert_eq!(completed.len(), 1, "{events:?}");
|
||||
match completed[0] {
|
||||
DirectThreadEvent::TurnCompleted {
|
||||
user_item_id,
|
||||
failure: Some(failure),
|
||||
..
|
||||
} => {
|
||||
assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped);
|
||||
assert_eq!(user_item_id.as_deref(), Some("u-1"));
|
||||
}
|
||||
other => panic!("expected a failed terminal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_deep_terminal_wins_and_the_fallback_stays_silent() {
|
||||
let thread = unique_thread("deep");
|
||||
let subscription = watch(&thread);
|
||||
let reservation =
|
||||
DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept");
|
||||
|
||||
// 深层收口:真正跑完这一轮的代码算出来的终态。
|
||||
let deep = DirectThreadEvent::turn_completed_failed(
|
||||
DirectTurnFailure::new(
|
||||
DirectTurnFailureKind::Timeout,
|
||||
"等待模型回执超时".to_string(),
|
||||
),
|
||||
2_000,
|
||||
)
|
||||
.with_user_item_id(Some("u-1"));
|
||||
crate::agent::complete_direct_thread_turn(&thread, deep);
|
||||
|
||||
assert!(
|
||||
!reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()),
|
||||
"深层已收口时兜底不许再写"
|
||||
);
|
||||
drop(reservation);
|
||||
|
||||
let events = pending(&subscription);
|
||||
let completed = turn_completed_events(&events);
|
||||
assert_eq!(completed.len(), 1, "一轮只许有一条终态:{events:?}");
|
||||
match completed[0] {
|
||||
DirectThreadEvent::TurnCompleted { failure, .. } => {
|
||||
assert_eq!(
|
||||
failure.as_ref().map(|f| f.kind),
|
||||
Some(DirectTurnFailureKind::Timeout)
|
||||
);
|
||||
}
|
||||
other => panic!("expected a terminal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_thread_can_be_accepted_again_after_the_turn_is_settled() {
|
||||
let thread = unique_thread("again");
|
||||
let first = DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept");
|
||||
drop(first);
|
||||
|
||||
let second =
|
||||
DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")).expect("second accept");
|
||||
|
||||
assert!(direct_thread_turn_is_active(&thread));
|
||||
drop(second);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
//! 失败终态的宿主侧策略:把"这一轮为什么失败"翻译成可下发的 `failure` 载荷,并在宿主自己
|
||||
//! 提前收场时补一条失败终态。
|
||||
//!
|
||||
//! 这个模块只有三件事,别再往里加第四件:
|
||||
//! 1. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么;
|
||||
//! 2. [`DirectTurnTerminal::event`]:把终态投影成 `turn.completed` 事件。
|
||||
//!
|
||||
//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 `direct_turn_accept.rs` 的接单占用对象里:
|
||||
//! 这个模块只负责"什么算失败、原因怎么写"。
|
||||
//!
|
||||
//! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs`(`DirectTurnFailure`);
|
||||
//! 载荷的 `kind` 与 `message` 由 [`DirectTurnError`] 投影而来(`kind` 的取值表见
|
||||
//! [`DirectTurnError::wire_kind`]);这里只负责"什么算失败、原因怎么写、什么时候兜底",
|
||||
//! 不碰事件队列的搬运规则,也不自己认 `LlmError`。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::{
|
||||
redact_agent_runtime_error, DirectThreadEvent, DirectTurnError, DirectTurnFailure,
|
||||
DirectTurnFailureKind,
|
||||
};
|
||||
|
||||
/// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于
|
||||
/// 把整段上游报文塞进事件队列。
|
||||
const DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS: usize = 600;
|
||||
|
||||
/// 宿主任务提前结束(panic / future 被丢弃 / 终态之前的早退)时的分类与文案。
|
||||
const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str =
|
||||
"陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。";
|
||||
|
||||
/// 一轮的终态:写进事件的 `status` 与(失败时的)载荷。**状态由载荷反推**,不由收尾阶段推。
|
||||
pub(crate) struct DirectTurnTerminal {
|
||||
pub(crate) status: String,
|
||||
pub(crate) failure: Option<DirectTurnFailure>,
|
||||
}
|
||||
|
||||
impl DirectTurnTerminal {
|
||||
/// 终态事件:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。
|
||||
pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> DirectThreadEvent {
|
||||
let event = match self.failure {
|
||||
Some(failure) => DirectThreadEvent::turn_completed_failed(failure, completed_at),
|
||||
None => DirectThreadEvent::turn_completed(self.status, completed_at),
|
||||
};
|
||||
event.with_user_item_id(user_item_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 拿这一轮的**事实**判定终态。判据按优先级:
|
||||
/// 1. `host_failure`:宿主自己观察 / 判定的失败(执行通道断开、等待超时、app-server 单方面中断…),
|
||||
/// 原因就用宿主当场写下的那句——它比交付报告更接近现场,报告只说明"收束到哪一步";
|
||||
/// 2. `collect_outcome` 是错误:真失败(模型 / 传输 / 历史落盘)。模型自报失败也走这一档:
|
||||
/// 原生 `turn/completed.status="failed"` 的 `error` 由调用点投影成 [`DirectTurnError`] 再进来;
|
||||
/// 3. `session_status` 已经判成 `failed`、而拿到的只是一份交付报告:原因用那份报告兜底——收尾
|
||||
/// 阶段的账本读不出来时只有它可用。
|
||||
///
|
||||
/// **有载荷就一定是 `failed`,没载荷就用收尾阶段的 `session_status`。** 这条反推关系是这个模块存在
|
||||
/// 的理由:`session_status` 是宿主收尾时按 ledger 阶段推的,收尾本身会把阶段推成 `Interrupted`,
|
||||
/// 于是"模型已经判失败"的一轮会被写成 `status="interrupted"` 且不带载荷——界面只剩"本轮已结束",
|
||||
/// 用户看不到任何原因(连接/上游断开时就是这个现象)。事实判失败就必须报失败。
|
||||
///
|
||||
/// 载荷的 `kind` 与 `message` 在这一个出口从 typed 错误投影:`kind` 决定界面语气,`message` 是脱敏
|
||||
/// 截断后的原因文本;Rust 侧没有第二个地方再解析它。
|
||||
pub(crate) fn direct_turn_terminal(
|
||||
session_status: &str,
|
||||
collect_outcome: Result<&str, DirectTurnError>,
|
||||
host_failure: Option<&DirectTurnError>,
|
||||
history_root: &Path,
|
||||
) -> DirectTurnTerminal {
|
||||
let failure = match (host_failure, collect_outcome) {
|
||||
(Some(failure), _) => Some(failure.clone()),
|
||||
(None, Err(error)) => Some(error.clone()),
|
||||
// 账本读不出来时没有 typed 原因可用:报告文本就是这一轮唯一的收口依据,按未分类失败发出去,
|
||||
// 不能让界面停在"已结束、没原因"。
|
||||
(None, Ok(report)) if session_status == "failed" => {
|
||||
Some(DirectTurnError::TurnFailedUnclassified {
|
||||
detail: report.to_string(),
|
||||
})
|
||||
}
|
||||
(None, Ok(_)) => None,
|
||||
};
|
||||
match failure {
|
||||
Some(failure) => DirectTurnTerminal::failed(history_root, &failure),
|
||||
None => DirectTurnTerminal {
|
||||
status: session_status.to_string(),
|
||||
failure: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
impl DirectTurnTerminal {
|
||||
/// 一次失败终态:`kind` 与 `message` 只在这一个出口从 typed 错误投影。
|
||||
pub(crate) fn failed(history_root: &Path, failure: &DirectTurnError) -> Self {
|
||||
Self {
|
||||
status: "failed".to_string(),
|
||||
failure: Some(DirectTurnFailure::new(
|
||||
failure
|
||||
.wire_kind()
|
||||
.unwrap_or(DirectTurnFailureKind::ModelFailed),
|
||||
redact_agent_runtime_error(
|
||||
history_root,
|
||||
&failure.to_string(),
|
||||
DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS,
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 宿主任务提前结束(panic / future 被丢弃 / 取消)的兜底终态。
|
||||
///
|
||||
/// 这类收场说不出原因,只给分类;能说清原因的一律走 [`Self::failed`]。
|
||||
pub(crate) fn host_dropped() -> Self {
|
||||
Self {
|
||||
status: "failed".to_string(),
|
||||
failure: Some(DirectTurnFailure::new(
|
||||
DirectTurnFailureKind::HostDropped,
|
||||
DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::{consume_direct_thread, subscribe_direct_thread};
|
||||
use platform_llm::LlmError;
|
||||
|
||||
fn history_root() -> std::path::PathBuf {
|
||||
std::path::PathBuf::from("/tmp/direct-turn-failure-test")
|
||||
}
|
||||
|
||||
/// 正常收场:不带载荷,`status` 就用收尾阶段推出来的那个。
|
||||
#[test]
|
||||
fn non_failure_terminals_keep_the_session_status() {
|
||||
for status in ["completed", "interrupted", "aborted"] {
|
||||
let terminal = direct_turn_terminal(status, Ok("报告不重要"), None, &history_root());
|
||||
assert!(terminal.failure.is_none(), "{status} 不该带失败载荷");
|
||||
assert_eq!(terminal.status, status);
|
||||
}
|
||||
}
|
||||
|
||||
/// 拿得到错误:分类与原因都取自错误。
|
||||
#[test]
|
||||
fn collect_error_becomes_a_failure_terminal() {
|
||||
let error = DirectTurnError::from_model_call(&LlmError::Transport(
|
||||
"DirectProject 收尾历史失败:写入 project.jsonl 失败".into(),
|
||||
));
|
||||
let terminal = direct_turn_terminal("completed", Err(error), None, &history_root());
|
||||
let failure = terminal
|
||||
.failure
|
||||
.expect("transport error must fail the turn");
|
||||
assert_eq!(terminal.status, "failed");
|
||||
assert_eq!(failure.kind, DirectTurnFailureKind::TransportFailed);
|
||||
assert!(failure.message.contains("收尾历史失败"));
|
||||
}
|
||||
|
||||
/// **收尾阶段的中断不能把已经失败的一轮讲成"已结束"。** 模型自报失败在调用点被投影成 typed
|
||||
/// 错误(原因带 `codex-app-server-error:<kind>` 前缀),宿主收尾自己又把 ledger 阶段推成
|
||||
/// `Interrupted`(`session_status` 因此是 `interrupted`):事实就是失败、原因就是那份投影,
|
||||
/// 必须原样发出去——否则界面只剩"本轮已结束",用户看不到任何东西。
|
||||
#[test]
|
||||
fn projected_native_failure_outranks_the_interrupted_session_status() {
|
||||
let error = DirectTurnError::from_model_call(&LlmError::InvalidRequest(
|
||||
"codex-app-server-error:context-window-exceeded".into(),
|
||||
));
|
||||
let terminal = direct_turn_terminal("interrupted", Err(error), None, &history_root());
|
||||
let failure = terminal.failure.expect("native failure must fail the turn");
|
||||
assert_eq!(terminal.status, "failed");
|
||||
assert_eq!(failure.kind, DirectTurnFailureKind::RequestRejected);
|
||||
assert_eq!(
|
||||
failure.message,
|
||||
"codex-app-server-error:context-window-exceeded"
|
||||
);
|
||||
}
|
||||
|
||||
/// 收尾阶段的账本读不出来(`session_status` 只能是 `failed`)时没有错误可用:用交付报告兜底,
|
||||
/// 但照样要带载荷发出去,不能让界面停在"已结束、没原因"。
|
||||
#[test]
|
||||
fn unreadable_session_ledger_still_reports_a_payload() {
|
||||
let terminal = direct_turn_terminal("failed", Ok("报告"), None, &history_root());
|
||||
assert_eq!(terminal.status, "failed");
|
||||
let failure = terminal
|
||||
.failure
|
||||
.expect("unreadable ledger must fail the turn");
|
||||
assert_eq!(failure.kind, DirectTurnFailureKind::ModelFailed);
|
||||
assert_eq!(failure.message, "报告");
|
||||
}
|
||||
|
||||
/// 宿主自己记下的失败排在最前面:它比交付报告更接近现场。
|
||||
#[test]
|
||||
fn host_recorded_failure_outranks_every_other_source() {
|
||||
let diagnostic = "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL);\
|
||||
stderrClass=nonempty;stderrBytes=1000";
|
||||
let host_failure = DirectTurnError::TransportClosed {
|
||||
diagnostic: diagnostic.to_string(),
|
||||
};
|
||||
let terminal = direct_turn_terminal(
|
||||
"interrupted",
|
||||
Ok("执行连接已结束,正在核对自有子进程与在途操作。"),
|
||||
Some(&host_failure),
|
||||
&history_root(),
|
||||
);
|
||||
let failure = terminal.failure.expect("host fact must fail the turn");
|
||||
assert_eq!(terminal.status, "failed");
|
||||
assert_eq!(failure.kind, DirectTurnFailureKind::TransportFailed);
|
||||
assert!(failure.message.contains("SIGKILL"));
|
||||
assert!(!failure.message.contains("正在核对自有子进程"));
|
||||
|
||||
// 即使同时拿到了错误,宿主亲眼看到的事实仍然是第一顺位。
|
||||
let error = DirectTurnError::from_model_call(&LlmError::Transport(
|
||||
"DirectProject 收尾历史失败".into(),
|
||||
));
|
||||
let host_failure = DirectTurnError::TurnInterrupted {
|
||||
detail: "本轮模型执行被中断".into(),
|
||||
};
|
||||
let terminal = direct_turn_terminal(
|
||||
"interrupted",
|
||||
Err(error),
|
||||
Some(&host_failure),
|
||||
&history_root(),
|
||||
);
|
||||
let failure = terminal.failure.expect("host fact must fail the turn");
|
||||
assert_eq!(failure.kind, DirectTurnFailureKind::TurnInterrupted);
|
||||
assert!(failure.message.contains("本轮模型执行被中断"));
|
||||
}
|
||||
|
||||
/// 终态事件的形状:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。
|
||||
#[test]
|
||||
fn terminal_event_carries_the_payload_and_the_opening_identity() {
|
||||
let error = DirectTurnError::from_model_call(&LlmError::Upstream {
|
||||
status_code: 502,
|
||||
message: "上游 502".into(),
|
||||
});
|
||||
let failing = direct_turn_terminal("interrupted", Err(error), None, &history_root());
|
||||
let event = failing.event(2_000, Some("direct-codex:turn-1:user"));
|
||||
assert_eq!(
|
||||
event.failure().map(|failure| failure.kind),
|
||||
Some(DirectTurnFailureKind::ModelFailed)
|
||||
);
|
||||
assert_eq!(event.user_item_id(), Some("direct-codex:turn-1:user"));
|
||||
assert_eq!(event.at(), Some(2_000));
|
||||
|
||||
let quiet = direct_turn_terminal("completed", Ok("本轮交付已完成"), None, &history_root());
|
||||
let event = quiet.event(3_000, None);
|
||||
assert!(event.failure().is_none());
|
||||
assert!(matches!(
|
||||
event,
|
||||
DirectThreadEvent::TurnCompleted { ref status, .. } if status == "completed"
|
||||
));
|
||||
}
|
||||
|
||||
/// 兜底终态:说不出原因的那一种只给分类,不冒充真实原因。
|
||||
#[test]
|
||||
fn host_dropped_terminal_only_carries_the_classification() {
|
||||
let terminal = DirectTurnTerminal::host_dropped();
|
||||
assert_eq!(terminal.status, "failed");
|
||||
let failure = terminal.failure.expect("host-dropped must fail the turn");
|
||||
assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped);
|
||||
assert!(!failure.message.trim().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -677,160 +677,14 @@ fn agent_runtime_action_receipt_safe_detail_with_owner(
|
||||
"revisionAdvanceCount": value.get("revisionAdvanceCount").and_then(serde_json::Value::as_u64).unwrap_or(0),
|
||||
})).ok();
|
||||
}
|
||||
if observation.tool == "ui.workflow.run" {
|
||||
let value = serde_json::from_str::<serde_json::Value>(
|
||||
observation.detail.as_deref().unwrap_or_default(),
|
||||
)
|
||||
.ok()?;
|
||||
let operation = value.get("operation")?.as_str()?;
|
||||
let project_id = value.get("projectId")?.as_str()?;
|
||||
let source_asset_id = value.get("sourceAssetId")?.as_str()?;
|
||||
let completed = value.get("completed")?.as_bool()?;
|
||||
let revision_advance_count = value.get("revisionAdvanceCount")?.as_u64()?;
|
||||
if project_id.is_empty()
|
||||
|| source_asset_id.is_empty()
|
||||
|| !matches!(
|
||||
operation,
|
||||
"discover" | "prepare" | "recognize" | "status" | "finalize"
|
||||
)
|
||||
|| project_id.chars().any(char::is_control)
|
||||
|| source_asset_id.chars().any(char::is_control)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if operation == "discover" {
|
||||
let discovered_pages = value.get("discoveredPages")?.as_array()?;
|
||||
if discovered_pages.is_empty() || discovered_pages.len() > 32 {
|
||||
return None;
|
||||
}
|
||||
let safe_pages = discovered_pages
|
||||
.iter()
|
||||
.map(|page| {
|
||||
let page_id = page.get("pageId")?.as_str()?;
|
||||
let title = page.get("title")?.as_str()?;
|
||||
let description = page.get("description")?.as_str()?;
|
||||
let application_path = page.get("applicationPath")?.as_str()?;
|
||||
let required_design_asset_path =
|
||||
page.get("requiredDesignAssetPath")?.as_str()?;
|
||||
let discovered_from = page.get("discoveredFrom")?.as_str()?;
|
||||
if page_id.is_empty()
|
||||
|| title.is_empty()
|
||||
|| description.chars().any(char::is_control)
|
||||
|| application_path.is_empty()
|
||||
|| !application_path.starts_with("game/")
|
||||
|| required_design_asset_path.is_empty()
|
||||
|| discovered_from.is_empty()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(serde_json::json!({
|
||||
"pageId": agent_runtime_action_receipt_safe_text(root, page_id, 80, None)?,
|
||||
"title": agent_runtime_action_receipt_safe_text(root, title, 120, None)?,
|
||||
"description": agent_runtime_action_receipt_safe_text(root, description, 400, None)?,
|
||||
"applicationPath": agent_runtime_action_receipt_safe_text(root, application_path, 240, None)?,
|
||||
"requiredDesignAssetPath": agent_runtime_action_receipt_safe_text(root, required_design_asset_path, 240, None)?,
|
||||
"discoveredFrom": agent_runtime_action_receipt_safe_text(root, discovered_from, 240, None)?,
|
||||
}))
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
return serde_json::to_string(&serde_json::json!({
|
||||
"operation": operation,
|
||||
"projectId": agent_runtime_action_receipt_safe_text(root, project_id, 160, None)?,
|
||||
"sourceAssetId": agent_runtime_action_receipt_safe_text(root, source_asset_id, 160, None)?,
|
||||
"completed": completed,
|
||||
"revisionAdvanceCount": revision_advance_count,
|
||||
"pages": [],
|
||||
"discoveredPages": safe_pages,
|
||||
"finalStageRoute": null,
|
||||
}))
|
||||
.ok();
|
||||
}
|
||||
let pages = value.get("pages")?.as_array()?;
|
||||
if pages.is_empty() || pages.len() > 32 {
|
||||
return None;
|
||||
}
|
||||
let mut safe_pages = Vec::with_capacity(pages.len());
|
||||
for page in pages {
|
||||
let page_id = page.get("pageId")?.as_str()?;
|
||||
let title = page.get("title")?.as_str()?;
|
||||
let design_asset_id = page.get("designAssetId")?.as_str()?;
|
||||
let ui_asset_id = page.get("uiAssetId")?.as_str()?;
|
||||
let revision = page.get("uiStateRevision")?.as_u64()?;
|
||||
let stage = page.get("stage")?.as_str()?;
|
||||
let marker = page.get("applicationMarker")?.as_str()?;
|
||||
let blockers = page.get("blockers")?.as_array()?;
|
||||
if page_id.is_empty()
|
||||
|| title.is_empty()
|
||||
|| design_asset_id.is_empty()
|
||||
|| ui_asset_id.is_empty()
|
||||
|| revision > 9_007_199_254_740_991
|
||||
|| marker.is_empty()
|
||||
|| !matches!(
|
||||
stage,
|
||||
"reference-ready"
|
||||
| "structure-ready"
|
||||
| "binding-ready"
|
||||
| "application-ready"
|
||||
| "completed"
|
||||
)
|
||||
|| blockers.len() > 32
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let safe_blockers = blockers
|
||||
.iter()
|
||||
.map(|blocker| {
|
||||
let blocker = blocker.as_str()?;
|
||||
agent_runtime_action_receipt_safe_text(root, blocker, 240, None)
|
||||
.map(serde_json::Value::String)
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
safe_pages.push(serde_json::json!({
|
||||
"pageId": agent_runtime_action_receipt_safe_text(root, page_id, 80, None)?,
|
||||
"title": agent_runtime_action_receipt_safe_text(root, title, 120, None)?,
|
||||
"designAssetId": agent_runtime_action_receipt_safe_text(root, design_asset_id, 160, None)?,
|
||||
"uiAssetId": agent_runtime_action_receipt_safe_text(root, ui_asset_id, 160, None)?,
|
||||
"uiStateRevision": revision,
|
||||
"stage": stage,
|
||||
"blockers": safe_blockers,
|
||||
"applicationMarker": agent_runtime_action_receipt_safe_text(root, marker, 240, None)?,
|
||||
}));
|
||||
}
|
||||
let final_stage_route = if completed {
|
||||
let route = value.get("finalStageRoute")?;
|
||||
let resource_id = route.get("resourceId")?.as_str()?;
|
||||
let initial_step = route.get("initialStep")?.as_str()?;
|
||||
let render_mode = route.get("renderMode")?.as_str()?;
|
||||
if resource_id.is_empty()
|
||||
|| initial_step != "asset-separation"
|
||||
|| render_mode != "final-preview"
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(serde_json::json!({
|
||||
"resourceId": agent_runtime_action_receipt_safe_text(root, resource_id, 160, None)?,
|
||||
"initialStep": initial_step,
|
||||
"renderMode": render_mode,
|
||||
}))
|
||||
} else {
|
||||
if !value
|
||||
.get("finalStageRoute")
|
||||
.is_some_and(serde_json::Value::is_null)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
None
|
||||
};
|
||||
return serde_json::to_string(&serde_json::json!({
|
||||
"operation": operation,
|
||||
"projectId": agent_runtime_action_receipt_safe_text(root, project_id, 160, None)?,
|
||||
"sourceAssetId": agent_runtime_action_receipt_safe_text(root, source_asset_id, 160, None)?,
|
||||
"completed": completed,
|
||||
"revisionAdvanceCount": revision_advance_count,
|
||||
"pages": safe_pages,
|
||||
"finalStageRoute": final_stage_route,
|
||||
}))
|
||||
.ok();
|
||||
if matches!(
|
||||
observation.tool.as_str(),
|
||||
"ui-design-doc.from-images" | "ui-design-doc.run-workflow"
|
||||
) {
|
||||
return agent_runtime_ui_design_doc_safe_detail(root, observation.detail.as_deref());
|
||||
}
|
||||
if observation.tool == "ui-design-doc.into-js" {
|
||||
return agent_runtime_ui_design_code_safe_detail(root, observation.detail.as_deref());
|
||||
}
|
||||
if observation.tool != "project.patchset" {
|
||||
return None;
|
||||
@@ -1175,6 +1029,184 @@ pub(in crate::agent) fn agent_runtime_action_receipt_safe_text(
|
||||
Some(sanitized)
|
||||
}
|
||||
|
||||
/// UI 设计文档回执里身份类字段的长度上限:素材 id / 文档 id / 生成文件名都远小于这个值。
|
||||
const AGENT_RUNTIME_UI_DESIGN_SAFE_ID_MAX_CHARS: usize = 96;
|
||||
/// 文档相对路径的长度上限;同时要求必须落在 `ui/` 下,避免把项目别处的路径带进回执。
|
||||
const AGENT_RUNTIME_UI_DESIGN_SAFE_RELATIVE_PATH_MAX_CHARS: usize = 160;
|
||||
/// 单条回填说明的长度上限与总量上限;超出总量的部分不逐条外传,改用总数表达。
|
||||
const AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_MAX_CHARS: usize = 96;
|
||||
const AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_CHAR_BUDGET: usize = 240;
|
||||
/// 一份文档最多四张设计图,逐张身份也按同一上限放行。
|
||||
const AGENT_RUNTIME_UI_DESIGN_SAFE_IMAGE_ID_LIMIT: usize = 4;
|
||||
|
||||
/// UI 设计文档链路(新建文档 / 跑工作流)的回执明细:只放身份、相对路径、计数与回填说明。
|
||||
/// 计数与身份必须完全合格;体积较大的清单(设计图身份、回填说明)在回执长度上限内能放多少放多少,
|
||||
/// 放不下的部分用总数补齐,绝不把原始明细整体外传。
|
||||
fn agent_runtime_ui_design_doc_safe_detail(root: &Path, detail: Option<&str>) -> Option<String> {
|
||||
let detail = serde_json::from_str::<serde_json::Value>(detail.unwrap_or_default()).ok()?;
|
||||
let mut safe = serde_json::Map::new();
|
||||
safe.insert(
|
||||
"assetId".to_string(),
|
||||
agent_runtime_action_receipt_identity_text(
|
||||
root,
|
||||
detail.get("assetId")?.as_str()?,
|
||||
AGENT_RUNTIME_UI_DESIGN_SAFE_ID_MAX_CHARS,
|
||||
"assetId",
|
||||
)
|
||||
.ok()?
|
||||
.into(),
|
||||
);
|
||||
safe.insert(
|
||||
"relativePath".to_string(),
|
||||
agent_runtime_ui_design_safe_relative_path(root, detail.get("relativePath")?.as_str()?)?
|
||||
.into(),
|
||||
);
|
||||
for key in [
|
||||
"revision",
|
||||
"revisionAdvanceCount",
|
||||
"recognizedTreeCount",
|
||||
"boundNodeCount",
|
||||
"problematicNodeCount",
|
||||
] {
|
||||
if let Some(value) = detail.get(key) {
|
||||
safe.insert(key.to_string(), value.as_u64()?.into());
|
||||
}
|
||||
}
|
||||
if let Some(value) = detail.get("recoveredFromCheckpoint") {
|
||||
safe.insert(
|
||||
"recoveredFromCheckpoint".to_string(),
|
||||
value.as_bool()?.into(),
|
||||
);
|
||||
}
|
||||
if !agent_runtime_action_receipt_detail_fits(&safe) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(image_ids) = detail.get("imageIds") {
|
||||
let image_ids = image_ids.as_array()?;
|
||||
if image_ids.is_empty() || image_ids.len() > AGENT_RUNTIME_UI_DESIGN_SAFE_IMAGE_ID_LIMIT {
|
||||
return None;
|
||||
}
|
||||
let image_ids = image_ids
|
||||
.iter()
|
||||
.map(|value| {
|
||||
agent_runtime_action_receipt_identity_text(
|
||||
root,
|
||||
value.as_str()?,
|
||||
AGENT_RUNTIME_UI_DESIGN_SAFE_ID_MAX_CHARS,
|
||||
"imageIds",
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
agent_runtime_action_receipt_insert_while_fits(
|
||||
&mut safe,
|
||||
"imageIds",
|
||||
serde_json::json!(image_ids),
|
||||
);
|
||||
}
|
||||
if let Some(errors) = detail.get("backfillErrors") {
|
||||
let errors = errors.as_array()?;
|
||||
let mut safe_errors = Vec::new();
|
||||
for value in errors {
|
||||
// 回填说明是自由文本:绝对路径按既有口径脱敏成占位符,控制字符或超长则整条明细失败关闭。
|
||||
let raw = value.as_str()?.trim();
|
||||
let error = agent_runtime_action_receipt_safe_text(
|
||||
root,
|
||||
raw,
|
||||
AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_MAX_CHARS,
|
||||
None,
|
||||
)?;
|
||||
if sanitize_agent_runtime_text(&error, AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_MAX_CHARS)
|
||||
!= error
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut candidate = safe_errors.clone();
|
||||
candidate.push(serde_json::Value::String(error));
|
||||
let mut probe = safe.clone();
|
||||
probe.insert("backfillErrors".to_string(), serde_json::json!(candidate));
|
||||
probe.insert(
|
||||
"backfillErrorCount".to_string(),
|
||||
serde_json::json!(errors.len()),
|
||||
);
|
||||
if !agent_runtime_action_receipt_detail_fits(&probe) {
|
||||
break;
|
||||
}
|
||||
safe_errors = candidate;
|
||||
}
|
||||
if !safe_errors.is_empty()
|
||||
&& serde_json::to_string(&serde_json::json!(safe_errors))
|
||||
.map(|text| text.chars().count())
|
||||
.unwrap_or(0)
|
||||
<= AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_CHAR_BUDGET
|
||||
{
|
||||
safe.insert("backfillErrors".to_string(), serde_json::json!(safe_errors));
|
||||
}
|
||||
safe.insert(
|
||||
"backfillErrorCount".to_string(),
|
||||
serde_json::json!(errors.len()),
|
||||
);
|
||||
}
|
||||
serde_json::to_string(&serde_json::Value::Object(safe)).ok()
|
||||
}
|
||||
|
||||
/// 代码生成回执:相对路径与计数;导出名清单只用来核对数量,不逐项外传。
|
||||
fn agent_runtime_ui_design_code_safe_detail(root: &Path, detail: Option<&str>) -> Option<String> {
|
||||
let detail = serde_json::from_str::<serde_json::Value>(detail.unwrap_or_default()).ok()?;
|
||||
let relative_path =
|
||||
agent_runtime_ui_design_safe_relative_path(root, detail.get("relativePath")?.as_str()?)?;
|
||||
let tree_count = detail.get("treeCount")?.as_u64()?;
|
||||
let node_count = detail.get("nodeCount")?.as_u64()?;
|
||||
if usize::try_from(tree_count).ok()? != detail.get("treeExports")?.as_array()?.len() {
|
||||
return None;
|
||||
}
|
||||
let safe = serde_json::to_string(&serde_json::json!({
|
||||
"relativePath": relative_path,
|
||||
"treeCount": tree_count,
|
||||
"nodeCount": node_count,
|
||||
}))
|
||||
.ok()?;
|
||||
(sanitize_agent_runtime_text(&safe, AGENT_RUNTIME_ACTION_RECEIPT_SAFE_DETAIL_MAX_CHARS) == safe)
|
||||
.then_some(safe)
|
||||
}
|
||||
|
||||
/// `ui/` 下的项目相对路径:绝对路径、反斜杠、上跳与超长一律拒绝。
|
||||
fn agent_runtime_ui_design_safe_relative_path(root: &Path, value: &str) -> Option<String> {
|
||||
let path = normalize_relative_path(value).ok()?;
|
||||
if !path.starts_with("ui/") {
|
||||
return None;
|
||||
}
|
||||
agent_runtime_action_receipt_identity_text(
|
||||
root,
|
||||
&path,
|
||||
AGENT_RUNTIME_UI_DESIGN_SAFE_RELATIVE_PATH_MAX_CHARS,
|
||||
"relativePath",
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn agent_runtime_action_receipt_detail_fits(
|
||||
detail: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> bool {
|
||||
serde_json::to_string(&serde_json::Value::Object(detail.clone())).is_ok_and(|text| {
|
||||
sanitize_agent_runtime_text(&text, AGENT_RUNTIME_ACTION_RECEIPT_SAFE_DETAIL_MAX_CHARS)
|
||||
== text
|
||||
})
|
||||
}
|
||||
|
||||
fn agent_runtime_action_receipt_insert_while_fits(
|
||||
detail: &mut serde_json::Map<String, serde_json::Value>,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) {
|
||||
let mut candidate = detail.clone();
|
||||
candidate.insert(key.to_string(), value);
|
||||
if agent_runtime_action_receipt_detail_fits(&candidate) {
|
||||
*detail = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn agent_runtime_public_action_input_summary(
|
||||
root: &Path,
|
||||
tool: &str,
|
||||
@@ -1210,12 +1242,14 @@ pub(in crate::agent) fn agent_runtime_public_action_input_summary(
|
||||
| "preview.validate"
|
||||
| "image.inspect"
|
||||
| "canvas.asset_generate"
|
||||
| "ui.workflow.run"
|
||||
| "agent.message"
|
||||
| "agent.delegate"
|
||||
| "agent.schedule_ready"
|
||||
| "agent.action_history"
|
||||
| "agent.run_status"
|
||||
| "ui-design-doc.from-images"
|
||||
| "ui-design-doc.run-workflow"
|
||||
| "ui-design-doc.into-js"
|
||||
);
|
||||
if public_shape_only {
|
||||
return agent_runtime_action_receipt_safe_text(root, input_summary, 320, None);
|
||||
@@ -1749,8 +1783,82 @@ pub(crate) fn agent_runtime_tool_action_input_summary(
|
||||
text(&["agentId", "agent_id", "targetAgentId", "target_agent_id"]),
|
||||
text(&["delegationId", "delegation_id"])
|
||||
),
|
||||
// UI 设计文档三兄弟:入参只有设计图引用或目标文档 id,直接给出可读摘要,
|
||||
// 不再退化成"只报哈希"——否则模型看不到自己这次到底传了什么。
|
||||
"ui-design-doc.from-images" => ui_design_doc_images_input_summary(input),
|
||||
"ui-design-doc.run-workflow" | "ui-design-doc.into-js" => format!(
|
||||
"designDocAssetId={}",
|
||||
text(&["designDocAssetId", "design_doc_asset_id", "assetId", "asset_id"])
|
||||
),
|
||||
_ => String::new(),
|
||||
};
|
||||
let summary = redact_agent_runtime_project_paths(root, &summary, 320);
|
||||
(!summary.trim().is_empty()).then_some(summary)
|
||||
}
|
||||
|
||||
/// 新建文档工具的设计图入参摘要:逐张给出身份(素材 id 或相对路径),绝对路径按既有口径降级成拒绝标记。
|
||||
fn ui_design_doc_images_input_summary(input: &serde_json::Value) -> String {
|
||||
let Some(images) = input.get("images").and_then(serde_json::Value::as_array) else {
|
||||
return String::new();
|
||||
};
|
||||
let described = images
|
||||
.iter()
|
||||
.map(|image| {
|
||||
if let Some(asset_id) = image.get("assetId").and_then(serde_json::Value::as_str) {
|
||||
return format!("assetId={asset_id}");
|
||||
}
|
||||
match image.get("path").and_then(serde_json::Value::as_str) {
|
||||
Some(path) if Path::new(path).is_absolute() => {
|
||||
"[absolute path rejected]".to_string()
|
||||
}
|
||||
Some(path) => format!("path={path}"),
|
||||
None => "input".to_string(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("images={} · {described}", images.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ui_design_doc_public_input_summary_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ui_design_doc_input_summaries_are_readable_not_hashed() {
|
||||
let root = Path::new("/nonexistent-ui-design-doc-input-summary-project");
|
||||
for (tool, summary) in [
|
||||
(
|
||||
"ui-design-doc.from-images",
|
||||
"images=2 · assetId=image-1, path=assets/two.png",
|
||||
),
|
||||
(
|
||||
"ui-design-doc.run-workflow",
|
||||
"designDocAssetId=generated-ui-design-1",
|
||||
),
|
||||
(
|
||||
"ui-design-doc.into-js",
|
||||
"designDocAssetId=generated-ui-design-1",
|
||||
),
|
||||
] {
|
||||
let public = agent_runtime_public_action_input_summary(root, tool, Some(summary))
|
||||
.expect("input summary");
|
||||
assert_eq!(public, summary, "{tool} 的入参不能被压成摘要哈希");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_images_input_summary_lists_each_image_reference() {
|
||||
let input = serde_json::json!({
|
||||
"images": [
|
||||
{"assetId": "image-1"},
|
||||
{"path": "assets/two.png"},
|
||||
{"path": "/tmp/host-only.png"},
|
||||
]
|
||||
});
|
||||
assert_eq!(
|
||||
ui_design_doc_images_input_summary(&input),
|
||||
"images=3 · assetId=image-1, path=assets/two.png, [absolute path rejected]"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,16 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
|
||||
observe_agent_runtime_account_asset_library(root, &action.input).await
|
||||
}
|
||||
"canvas.asset_import" => observe_agent_runtime_asset_import(root, &action.input).await,
|
||||
"ui-design-doc.from-images" => {
|
||||
observe_agent_runtime_ui_design_doc_from_images(root, &action.input).await
|
||||
}
|
||||
"ui-design-doc.run-workflow" => {
|
||||
observe_agent_runtime_ui_design_doc_run_workflow(root, agent_id, run_id, &action.input)
|
||||
.await
|
||||
}
|
||||
"ui-design-doc.into-js" => {
|
||||
observe_agent_runtime_ui_design_doc_into_js(root, &action.input).await
|
||||
}
|
||||
"project.index" => observe_agent_runtime_project_snapshot_with_lock(
|
||||
root,
|
||||
agent_id,
|
||||
@@ -436,9 +446,6 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
|
||||
)
|
||||
.await
|
||||
}
|
||||
"ui.workflow.run" => {
|
||||
observe_agent_runtime_ui_workflow(root, agent_id, run_id, task, &action.input).await
|
||||
}
|
||||
"blackboard.write" => {
|
||||
observe_agent_runtime_blackboard_write(root, agent_id, run_id, &action.input)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user