Merge pull request '重构/对话错误类型化, 避免string-typed' (#474) from feat/fail-as-event into master
Project CI / AI game creator shell Rust crates (push) Successful in 1m29s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled

Reviewed-on: #474
This commit was merged in pull request #474.
This commit is contained in:
2026-09-24 19:42:49 +08:00
57 changed files with 6200 additions and 1581 deletions
+16
View File
@@ -190,6 +190,22 @@ _Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
_Avoid_: 进度通知、快照轮询、第二套历史
**逻辑回合**:
Thread Manager 拥有的一对回合边界(开始与结束),由接单动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。
_Avoid_: Codex 原生回合、原生日志、进程生命周期
**接单**:
把一条用户消息交给宿主开始执行的动作,成立即表示这一轮已经存在;此后结果只由运行态事件回答。
_Avoid_: 发送成功、命令调用、接口返回
**拒单**:
接单成立之前拒绝这次请求(并发、权限、目录、参数、工程准备未就绪),只回一条可展示原因,不产生回合事件,也不写用户条目。
_Avoid_: 回合失败、执行失败、失败事件
**在途回合**:
界面本地已经把这条用户消息发出去、宿主还没有对应回合开始事件的那一小段状态。
_Avoid_: 运行中回合、乐观锁、发送队列
**聊天投影**:
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
@@ -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=nonemptystderrBytes=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());
}
}
@@ -32,6 +32,8 @@ pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, messag
#[derive(Clone)]
pub(crate) struct DirectGameCreatorTurnUpdateEmitter {
project_path: String,
/// Thread Manager 的线程身份:进度只回填到"这一轮仍被占用"的那一格上。
thread_id: String,
turn_id: String,
sequence: Arc<AtomicU64>,
}
@@ -40,6 +42,7 @@ impl DirectGameCreatorTurnUpdateEmitter {
pub(crate) fn new(root: &Path, turn_id: String) -> Self {
Self {
project_path: root.to_string_lossy().into_owned(),
thread_id: crate::agent::direct_thread_id_for_project(root),
turn_id,
sequence: Arc::new(AtomicU64::new(0)),
}
@@ -136,8 +139,8 @@ impl DirectGameCreatorTurnUpdateEmitter {
.unwrap_or_default()
.as_millis()
.min(u64::MAX as u128) as u64;
update_direct_active_turn(
Path::new(&self.project_path),
update_direct_thread_active_turn(
&self.thread_id,
&self.turn_id,
status,
activity,
@@ -916,8 +916,16 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
.enable_all()
.build()
.map_err(|error| format!("创建 CLI runtime 失败:{error}"))?;
let reply_result = runtime
.block_on(async { run_direct_game_creator_turn_at(&project_path, &prompt).await });
let reply_result = runtime.block_on(async {
run_direct_game_creator_turn_at(&project_path, &prompt)
.await
// CLI 也是命令边界:typed 错误在这里序列化成一行给终端看的文本;可留痕的
// 调用级拒绝(宿主 / 环境事实)与 GUI 走同一份投影(同一份 `Display` 文案,
// 不另加 `详情:` 引用),差别只在 CLI 自己 await 整轮、拿到回复文本。
.map_err(|failure| {
direct_turn_error_boundary_text(&project_path, None, failure)
})
});
let shutdown_result = shutdown_game_creator_codex_app_servers();
let reply = reply_result?;
shutdown_result?;
@@ -5677,38 +5677,6 @@ pub(crate) async fn read_direct_project_conversation(
.map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) async fn read_agent_runtime_error_detail(
project_path: String,
detail_ref: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let relative = detail_ref.trim();
let Some(file_name) = relative.strip_prefix(".agent/runtime/errors/") else {
return Err("错误诊断引用不在项目错误目录内".to_string());
};
if file_name.is_empty()
|| file_name.contains(['/', '\\'])
|| file_name.contains("..")
|| !file_name.ends_with(".json")
{
return Err("错误诊断引用格式无效".to_string());
}
let path = root.join(relative);
prepare_game_creator_private_path_for_read(&path, false, "统一错误诊断")?;
let bytes = std::fs::read(&path).map_err(|error| format!("读取错误诊断失败:{error}"))?;
if bytes.len() > 16 * 1024 {
return Err("错误诊断超过读取上限".to_string());
}
let text = String::from_utf8(bytes).map_err(|_| "错误诊断不是 UTF-8 文本".to_string())?;
Ok(redact_agent_runtime_error(root, &text, 16 * 1024))
})
.await
.map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) fn list_game_creator_direct_active_turns(
) -> Result<Vec<DirectActiveTurnSnapshot>, String> {
@@ -2750,7 +2750,6 @@ fn main() {
archive_game_creator_agent_session,
read_local_conversation,
read_direct_project_conversation,
read_agent_runtime_error_detail,
list_game_creator_direct_active_turns,
subscribe_direct_project_thread,
consume_direct_project_thread,
@@ -888,10 +888,25 @@ function redactDirectFailureMarkers(value: string) {
.replace(/\[redacted sensitive context\]/gi, '[已隐藏敏感信息]');
}
function directCodexDiagnosticFailureDetail(message: string) {
/**
* 宿主收口文案(`direct-codex-failure:v1|v2 …`)解析出的可展示部分。
*
* 两版都要认:v1 是 `stage=… retryable=… summary=…`v2 在 `retryable` 前多一段 `code=…`
* (宿主现在发的是 v2)。只认 v1 时这一路永远命中不了,宿主的脱敏摘要等于白写。
*/
type DirectDiagnosticParts = {
stageLabel: string;
summary: string;
hint: string;
retryable: boolean;
};
function directCodexDiagnosticFailureParts(
message: string,
): DirectDiagnosticParts | null {
const trimmed = message.trim();
const match =
/^direct-codex-failure:v1 stage=(request|art-preparation|code-generation|browser-validation|version-registration) retryable=(true|false) summary=(.+?)(.+?)(?:|)$/u.exec(
/^direct-codex-failure:v[12] stage=(request|art-preparation|code-generation|browser-validation|version-registration)(?: code=[a-z0-9-]+)? retryable=(true|false) summary=(.+?)(.+?)(?:|)$/u.exec(
trimmed,
);
if (!match) {
@@ -925,17 +940,44 @@ function directCodexDiagnosticFailureDetail(message: string) {
'browser-validation': '真实试玩未通过',
'version-registration': '项目版本登记失败',
}[stage];
if (!stageLabel) {
if (!stageLabel || !summary || !hint) {
return null;
}
if (!summary || !hint) {
return null;
}
return `${stageLabel}${summary}${hint}${
retryable === 'true' ? '(可直接重试)' : ''
return { stageLabel, summary, hint, retryable: retryable === 'true' };
}
/** 收口文案的一句话:阶段标签只有"回合失败"才成立,拒单那边传 `null`(见下面的导出函数)。 */
function directDiagnosticSentence(
parts: DirectDiagnosticParts,
stageLabel: string | null,
) {
return `${stageLabel ? `${stageLabel}` : ''}${parts.summary}${parts.hint}${
parts.retryable ? '(可直接重试)' : ''
}`;
}
function directCodexDiagnosticFailureDetail(message: string) {
const parts = directCodexDiagnosticFailureParts(message);
return parts ? directDiagnosticSentence(parts, parts.stageLabel) : null;
}
/**
* **拒单**的可见文案:宿主收口文案里那段已脱敏的摘要与建议。
*
* 与 [`projectRuntimeVisibleError`] 的差别只有一处——**不带阶段标签**:阶段说的是"失败发生在交付的
* 哪一步",而拒单是"这一轮没有开始",阶段只会是默认值(`code-generation`),套上去会把没发生的
* 事讲成发生了。宿主的文案不是收口形状时退回同一份运行错误映射。
*/
export function projectRuntimeVisibleRejectionError(
message: string,
subject: string,
) {
const parts = directCodexDiagnosticFailureParts(message);
return parts
? `${subject}${directDiagnosticSentence(parts, null)}`
: projectRuntimeVisibleError(message, subject, true);
}
function directPlatformFailureDetail(message: string) {
const trimmed = message.trim();
if (!trimmed) {
@@ -1197,10 +1239,27 @@ export function projectRuntimeVisibleError(
if (
normalized.includes('落盘失败') ||
normalized.includes('持久化失败') ||
normalized.includes('写入失败')
normalized.includes('写入失败') ||
// 宿主"历史落盘"的事实句不带"失败"两个字:`TurnFailed` 的原因就是
// "DirectProject 收尾历史失败:未确认历史完整落盘"这一类。
normalized.includes('收尾历史失败') ||
normalized.includes('未确认历史完整落盘') ||
normalized.includes('写入本项目对话历史失败')
) {
return `${subject} 保存运行记录失败,请检查项目目录后重试`;
}
// 宿主 `Display` 的其余事实句:它们不含上面任何关键字,**不加模式就只会看到最后那句通用文案**。
// 这里只认宿主写死的句首短语,不回落原文——原文里带 `exitStatus=` / `stderrClass=` 这类内部字段
// `TransportClosed` 就是这种)。
if (visibleMessage.includes('执行通道已断开')) {
return `${subject} 服务连接已断开,请稍后重试`;
}
if (visibleMessage.includes('等待模型回合结束达到硬上限')) {
return `${subject} 响应超时,请稍后重试`;
}
if (visibleMessage.includes('宿主任务提前结束')) {
return `${subject} 本轮执行已中断,请重试`;
}
const containsInternalDiagnostics =
normalized.includes('agentllm.') ||
/(?:^|[\s:])kind=/.test(normalized) ||
@@ -92,15 +92,6 @@ export type DirectProjectChatViewProps = {
ref?: Ref<DirectProjectChatHandle>;
};
/** 首轮那条本地用户消息展示入口原话:附件与引用仍按 canonical content 进入回合。 */
function directInitialTurnText(content: readonly DirectCodexUserContentPart[]) {
return content
.filter((part) => part.type === 'input_text')
.map((part) => part.text)
.join('')
.trim();
}
export function DirectProjectChatView({
onRequestGamePublish,
onConfirmConfirmation,
@@ -122,7 +113,6 @@ export function DirectProjectChatView({
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
const [approvalNotice, setApprovalNotice] = useState('');
const chat = useDirectProjectChatController({
assets,
enabled: Boolean(projectPath),
ensureConversationReadAllowed,
ensureConversationWriteAllowed,
@@ -140,10 +130,10 @@ export function DirectProjectChatView({
composerNotice,
directEntries,
directTurnRunning,
directTurnStartedAt,
historyHasMore,
loadEarlierHistory,
localMessages,
pendingUserItemId,
queuedTurns,
startInitialTurn,
statusNotice,
@@ -161,19 +151,20 @@ export function DirectProjectChatView({
entries: directEntries,
localMessages,
turnRunning: directTurnRunning,
pendingUserItemId,
// 运行中回合的起点只在这里给:`turn.started.at` 是宿主的时间,条目上要等收口才盖。
turnStartedAt: directTurnStartedAt,
}),
[directEntries, localMessages, directTurnRunning, pendingUserItemId],
[directEntries, localMessages, directTurnRunning, directTurnStartedAt],
);
// 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮态。
// 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮态。
const turnStatus = useDirectProjectTurnStatus({
turnRunning: directTurnRunning,
turnBusy,
turns: directTurns,
});
// 状态条的起点只认**未结束**的最新一轮:`awaiting-start`(本地已发出、宿主还没确认)
// 也有用户发送时间,只读 `running` 会让卡片在模型首 token 之前根本不出现。
// 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮,`awaiting-start` 也只判最新一轮
// 状态条的起点只认**未结束**的最新一轮:只有它才拿得到本轮的 `turn.started.at`(运行中读实时值)。
// 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮。接单窗口里还没有这一轮的条目,
// 于是这里是 0,卡片只报"正在处理"、不读秒(宿主开始事件一到就开始读秒)
const latestTurn = directTurns.at(-1) ?? null;
const activeTurnStartedAt =
latestTurn && latestTurn.state !== 'finished' ? latestTurn.startedAt : 0;
@@ -202,8 +193,6 @@ export function DirectProjectChatView({
);
shouldFollowLatestRef.current = true;
startInitialTurn({
// 首轮那条用户消息按入口原话展示,引用/附件仍按 canonical content 发给运行时。
messageText: directInitialTurnText(initialTurn.content),
clientTurnId,
...(initialTurn.creationType
? { creationType: initialTurn.creationType }
@@ -27,7 +27,8 @@ export function DirectProjectConversation({
* 这一轮在飞吗:`DirectProjectTurnStatus.displayBusy`(本地命令在飞 ∪ 原生已确认在跑)。
*
* 只认原生 `turnRunning` 会让卡片在「命令已发出、`turn.started` 未到」的空窗里不出现——
* 模型首 token 之前那段(实测约十秒)界面就没有任何「正在处理」的交代。
* 模型首 token 之前那段界面就没有任何「正在处理」的交代。空窗期没有本轮条目,
* `activeTurnStartedAt` 还是 0,卡片只报"正在处理";宿主开始事件一到就开始读秒。
*/
turnInFlight: boolean;
activeTurnStartedAt: number;
@@ -20,15 +20,15 @@ import {
/**
* 一个完整回合的分区表现:用户发言、执行过程(工具/思考)与最终答复。
*
* 未结束的回合(`running` / `awaiting-start`)把执行过程平铺出来并隐藏终态文案,
* 未结束的回合(`running`)把执行过程平铺出来并隐藏终态文案,
* `finished` 才折叠进「执行过程」;这一层只做投影到表现的渲染,不拥有任何回合状态。
*
* 态的判据分两类,不要对调(态定义与真值表见
* 态的判据分两类,不要对调(态定义与真值表见
* `../../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`):
* - **否定式**(不要说它结束、不要折叠、不要显示终态文案)读 `state !== 'finished'`
* `awaiting-start` 时轮次确实还没结束,只是宿主还没确认
* `running` 时轮次确实还没结束。
* - **肯定式**(哪一段正文在流式、"正在处理"这类断言)读 `state === 'running'`
* `awaiting-start` 只说明本地已发出,不能据此断言宿主已经在跑
* 只有宿主开始事件到了才能这么说
*/
export function DirectProjectTurn({ turn }: { turn: DirectChatTurn }) {
const streamingKey =
@@ -66,13 +66,11 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) {
}
function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) {
// 否定式判据:未结束的回合不显示终态文案。`awaiting-start` 走这一条,所以"本地已发出、
// 原生还没认领"的窗口里不会再出现「本轮结束于 … 耗时 0.0秒」。
// 仍未修的另一半(A):`finished` 但没有可证明终态时间的回合,会被下面的 `Math.max` 兜底
// 量化成 0.0 秒,共两类——① 重进项目后读回来的历史回合(`turnEndedAt` 只活在本次会话里,
// 不会随 `project.jsonl` 持久化);② 本地已发出却一个原生事件都没产生的回合(发送失败)。
// 修法是只在 `turn.endedAt > 0` 时渲染终态文案、耗时改由 `turnTotalDurationMs()` 出(边界缺失
// 就隐藏),属于产品口径变化(宁可隐藏也不编),确认后单独改;改完把这半段注释删掉。
// 否定式判据:未结束的回合不显示终态文案`running` 走这一条)。
// `finished` 但没有可证明起点 / 终点的回合整条隐藏:重进项目后读回来的历史回合就是这样
// `turnStartedAt` / `turnEndedAt` 只活在本次会话里,不随 `project.jsonl` 持久化)。
// `Math.max` 只剩兜底时钟回拨的作用——本地乐观气泡已删,不再有"本地已发出却一个事件都没有"
// 的回合(发送失败的用户消息也不会进聊天区)。
if (turn.state !== 'finished' || !turn.startedAt) return null;
const endedAt = Math.max(turn.endedAt, turn.startedAt);
return (
@@ -214,7 +214,7 @@ export function formatClockTime(
/**
* 整轮耗时(毫秒)= 本轮起点 → 本轮终态。
*
* 起点是该轮实际用户消息的发送时间,缺失时用原生 `turn.started.at`;终点是明确的
* 起点是原生 `turn.started.at`(本轮唯一可证明的起点);终点是明确的
* `turn.completed.at`,运行中则是当前时刻(回合还在跑就持续增长,即使组内工具都结束了)。
* 两端任一缺失、非有限或倒序都返回 `null`:不伪造 `0.0秒`。
*/
@@ -224,7 +224,7 @@ export function turnTotalDurationMs({
running = false,
now = 0,
}: {
/** 本轮起点:用户实际发送时间优先,缺失时原生 `turn.started.at`0 = 未知。 */
/** 本轮起点:原生 `turn.started.at`0 = 未知(历史回合就是这一类,调用方须整条隐藏)。 */
startedAt: number | null | undefined;
/** 本轮明确终态时间(`turn.completed.at`);运行中忽略。 */
endedAt?: number | null;

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