Compare commits

...

9 Commits

Author SHA1 Message Date
k88936 3467042000 Merge branch 'master' into feat/fail-as-event
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
2026-09-22 20:19:23 +08:00
k88936 80b15b24ae appSurface 用例:失败只经终态事件收口,界面不再停在"还在处理"
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- 新增「closes the turn from the host failure payload instead of leaving it running」:宿主发过 turn.started 之后以 failure 载荷收场并让命令失败,断言失败文案来自事件载荷(经同一份可见文案映射)、"陶泥儿正在处理"消失、终止钮消失、输入盒回到「发送」
- 同一条用例反向断言命令返回的错误原文不进聊天:那条通道只负责运行错误横幅
- 变异校验:让 reducer 不落失败说明条目时该用例变红(1 failed),恢复后绿
2026-09-22 17:40:17 +08:00
k88936 b432556ee3 reducer 用例:失败终态落说明条目、文案与横幅同源、重放不重复
- 新增「失败终态(turn.completed 带 failure 载荷)」六条用例:失败照样收口并冻结终点、说明条目按本轮开口身份派生、本轮开口条目拿到边界
- 可见文案与运行错误横幅共用同一份映射(点名 codex-app-server-error:context-window-exceeded)
- 重复 / 迟到的失败终态不追加第二条说明、不抬高冻结终点、不复活运行态
- 身份不匹配的失败终态不动正在跑的这一轮;空原因不落说明条目但终态照样收口
- 没有身份时用事件时间派生说明身份,两轮失败不会合并成一条
2026-09-22 17:40:17 +08:00
k88936 258645f182 前端失败说明改由事件驱动:turn.completed.failure 落成本轮说明条目,命令返回只留横幅
- 新增 conversation/directTurnFailure.ts:失败说明条目的展示身份(本轮开口身份 + :failure)与可见文案(复用 projectRuntimeVisibleError)两条口径集中一处
- directThreadChat 的 turn.completed 分支读 failure 载荷:非空原因先落成本轮最后一条说明条目,再走同一个收口函数;失败不再是第二套生命周期
- useDirectProjectChatController 的失败分支不再写聊天气泡:聊天文案唯一来源是事件,命令返回只保留运行错误横幅(含详情 long detail)与诊断留痕
- 数据流、时序与投影注释同步:标注失败说明来自事件、本地通道只剩终止说明与壳层 announce
2026-09-22 17:40:17 +08:00
k88936 5cf4a018b3 宿主终态接线:失败写进 turn.completed 的 failure 载荷,并在 turn.started 之后武装兜底守卫
- codex_app_server 的 DirectProject 终态改用 direct_turn_failure 判定:失败走 turn_completed_failed(原因脱敏 + 截断后写进同一个事件),其余仍走 turn_completed(status)
- 失败判定两个来源:collect_result 是 Err 时用错误本身当原因;collect_result 是交付报告但 status 已判成 failed 时用那份报告当原因
- turn.started 进入队列后立即武装 DirectTurnFailureGuard,写完终态 disarm:panic、回合 future 被丢弃、终态之前的早退都会补一条 host-dropped 失败终态,前端不会停在"还在跑"
- 定向 `cargo test direct_`(438 passed,含 wire / manager / 失败策略模块)
2026-09-22 17:40:17 +08:00
k88936 5d9223c32e 失败终态策略独立成模块:分类、原因脱敏与 Drop 兜底守卫
- 新增 agent/direct_turn_failure.rs:LlmError → 稳定分类(timeout / model-failed / transport-failed / request-rejected)、判定"终态是不是失败"并给出脱敏截断后的原因、DirectTurnFailureGuard(turn.started 之后武装、写完终态 disarm,Drop 时补 host-dropped 失败终态)
- 守卫兜底覆盖 panic / future 被丢弃 / 终态之前的早退;kill -9 与 turn.started 之前的早退写进模块注释,明确不为它们补路径
- agent.rs 注册模块并再导出
- 5 条用例:错误分类映射、只有 failed 终态带载荷、原因脱敏 + 按字符截断、armed 后 Drop 补终态、disarm 后不再产出事件
2026-09-22 17:40:17 +08:00
k88936 d32c99c927 事件协议:turn.completed 增加可选 failure 载荷,失败终态与正常终态同权入锚点
- direct_thread_wire 新增 DirectTurnFailure{kind,message} 类型,给 TurnCompleted 增可选 failure 字段,并补 turn_completed_failed 构造器与 failure 读取器
- with_user_item_id 显式带上 failure:原先把 TurnCompleted 写成 `..` 会静默吞掉失败载荷,身份与原因必须一起流转
- 新增 wire 用例:失败终态带载荷、正常终态不带且回写不补 null、缺载荷的 failed 事件仍可反序列化
- direct_thread_manager 增回归用例:turn.completed(status=failed) 必须顶替更早的 turn.started 成为 lifecycle_anchor,重放不会把已收口的回合看成"还在跑"
- 重新生成 ts-rs 绑定(新增 DirectTurnFailure.ts、DirectThreadEvent.ts 增 failure 字段)并按 prettier 格式化
2026-09-22 17:40:17 +08:00
k88936 d36f5842b6 文档:失败回合终态定为 turn.completed 带 failure 载荷,宿主 Drop 守卫兜底
- ADR【DirectProject对话历史单一事实源】补三条决策:终态事件只有 turn.completed,失败时 status="failed" 必须带 failure{kind,message};宿主 Drop 守卫在 turn.started 之后武装、写完终态即解除;失败原因只走事件这条通道,聊天说明的展示位保留、数据来源换成事件
- 同 ADR「影响」补两条已知边界(进程被强杀时没有 Drop、turn.started 之前的早退不产回合也不补终态)与「可见文案映射规则不变」的口径
- 技术方案【DirectProject Codex原始历史与异常恢复】同步线上形状:turn.completed 增加可选 failure,并写明失败终态与正常终态同权顶替 lifecycle_anchor
- decision-log 记本次决策、明确不做项、影响范围与验证方式
2026-09-22 17:40:17 +08:00
k88936 981a6b0021 Revert:撤掉前端「命令返回就收口」的兜底,改由宿主 turn.failed 事件收口
- 撤销 a35956f3e 的前端实现:directThreadChat 的 commandClosedTurnUserItemId / stopDirectThreadTurn、subscription 的 stopCommandTurn、controller 失败分支的调用,以及随附的两处用例
- 原因:失败语义改由宿主事件(turn.failed)表达,前端不再自造第二条「结束」判定路径,也不再在失败路径上补本地消息
2026-09-22 17:40:16 +08:00
17 changed files with 760 additions and 167 deletions
@@ -34,6 +34,7 @@ mod direct_thread_wire;
mod direct_tool_bridge; mod direct_tool_bridge;
mod direct_tool_calls; mod direct_tool_calls;
mod direct_tools_mcp; mod direct_tools_mcp;
mod direct_turn_failure;
mod direct_turn_metrics; mod direct_turn_metrics;
mod direct_turn_stream; mod direct_turn_stream;
mod direct_validation; mod direct_validation;
@@ -72,6 +73,7 @@ pub(crate) use direct_thread_wire::*;
pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*; pub(crate) use direct_tool_calls::*;
pub(crate) use direct_tools_mcp::*; pub(crate) use direct_tools_mcp::*;
pub(crate) use direct_turn_failure::*;
pub(crate) use direct_turn_metrics::*; pub(crate) use direct_turn_metrics::*;
pub(crate) use direct_turn_stream::*; pub(crate) use direct_turn_stream::*;
pub(crate) use direct_validation::DirectValidationConfig; pub(crate) use direct_validation::DirectValidationConfig;
@@ -3548,12 +3548,20 @@ impl CodexAppServerConnection {
let direct_turn_user_item_id = direct_persisted_user_item let direct_turn_user_item_id = direct_persisted_user_item
.as_ref() .as_ref()
.and_then(direct_thread_item_identity); .and_then(direct_thread_item_identity);
// 回合终态兜底:`turn.started` 进队列之后就武装,写完终态即解除。宿主在这两者之间任何
// 提前收场(panic、future 被丢弃、以后新增的早退)都由它补一条失败终态,否则前端只能
// 永远停在"还在跑"。
let mut direct_turn_failure_guard: Option<DirectTurnFailureGuard> = None;
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
append_direct_thread_event( append_direct_thread_event(
&direct_thread_id, &direct_thread_id,
DirectThreadEvent::turn_started(direct_turn_started_at_ms) DirectThreadEvent::turn_started(direct_turn_started_at_ms)
.with_user_item_id(direct_turn_user_item_id.as_deref()), .with_user_item_id(direct_turn_user_item_id.as_deref()),
); );
direct_turn_failure_guard = Some(DirectTurnFailureGuard::arm(
direct_thread_id.clone(),
direct_turn_user_item_id.clone(),
));
if let Some(user_item) = direct_persisted_user_item.as_ref() { if let Some(user_item) = direct_persisted_user_item.as_ref() {
if let Some(entry_item) = direct_thread_event_item(history_root, user_item) { if let Some(entry_item) = direct_thread_event_item(history_root, user_item) {
// 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份 // 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份
@@ -4061,11 +4069,30 @@ impl CodexAppServerConnection {
.map(|(_, at)| *at) .map(|(_, at)| *at)
.unwrap_or_else(direct_tool_call_now_ms) .unwrap_or_else(direct_tool_call_now_ms)
}; };
append_direct_thread_event( // 终态只有 `turn.completed` 一种事件:失败时同一个事件带 `failure` 载荷(原因由宿主
&direct_thread_id, // 脱敏 + 截断后写进去),其余(`completed` / `interrupted` / `aborted`)不带载荷。
DirectThreadEvent::turn_completed(status, completed_at) // 失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上长得一样,前端只能
.with_user_item_id(direct_turn_user_item_id.as_deref()), // 另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流讲不清一轮怎么结束。
let failure = direct_turn_failure(
&status,
collect_result.as_ref().map(String::as_str),
history_root,
); );
match failure {
Some(failure) => append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::turn_completed_failed(failure, completed_at)
.with_user_item_id(direct_turn_user_item_id.as_deref()),
),
None => append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::turn_completed(status, completed_at)
.with_user_item_id(direct_turn_user_item_id.as_deref()),
),
};
if let Some(guard) = direct_turn_failure_guard.as_mut() {
guard.disarm();
}
} }
let text = collect_result?; let text = collect_result?;
guard.armed = false; guard.armed = false;
@@ -603,6 +603,30 @@ 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("host-dropped", "回合宿主任务提前结束"),
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 == "host-dropped")
&& *at == Some(FIXED_AT_MS)
));
}
/// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**, /// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**,
/// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。 /// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。
#[test] #[test]
@@ -211,6 +211,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 {
/// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` /
/// `host-dropped`。
pub(crate) kind: String,
/// 脱敏 + 截断后的失败原因。
pub(crate) message: String,
}
impl DirectTurnFailure {
pub(crate) fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
Self {
kind: kind.into(),
message: message.into(),
}
}
}
/// Thread Manager 下发的运行态事件。 /// Thread Manager 下发的运行态事件。
/// ///
/// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: /// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`:
@@ -250,7 +274,13 @@ pub(crate) enum DirectThreadEvent {
}, },
#[serde(rename = "turn.completed")] #[serde(rename = "turn.completed")]
TurnCompleted { TurnCompleted {
/// 终态语义:`completed` / `interrupted` / `aborted` 是正常收场;`failed` 是**失败**
/// 此时必须带 `failure` 载荷。
status: String, status: String,
/// 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
failure: Option<DirectTurnFailure>,
/// 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 /// 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional, as = "Option<f64>")] #[ts(optional, as = "Option<f64>")]
@@ -301,11 +331,30 @@ impl DirectThreadEvent {
pub(crate) fn turn_completed(status: String, at: u64) -> Self { pub(crate) fn turn_completed(status: String, at: u64) -> Self {
Self::TurnCompleted { Self::TurnCompleted {
status, status,
failure: None,
at: Some(at), at: Some(at),
user_item_id: None, 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。 /// 附上本轮开口用户条目的 canonical itemId。
/// ///
/// 只在构造之后补一次身份,避免 `turn.started` / `turn.completed` 的既有调用点(含各处兜底 /// 只在构造之后补一次身份,避免 `turn.started` / `turn.completed` 的既有调用点(含各处兜底
@@ -317,8 +366,14 @@ impl DirectThreadEvent {
.map(str::to_string); .map(str::to_string);
match self { match self {
Self::TurnStarted { at, .. } => Self::TurnStarted { at, user_item_id }, Self::TurnStarted { at, .. } => Self::TurnStarted { at, user_item_id },
Self::TurnCompleted { status, at, .. } => Self::TurnCompleted { Self::TurnCompleted {
status, status,
failure,
at,
..
} => Self::TurnCompleted {
status,
failure,
at, at,
user_item_id, user_item_id,
}, },
@@ -1351,4 +1406,56 @@ mod tests {
); );
assert_eq!(item_event.user_item_id(), None); 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("model-failed", "上游返回 500:模型服务暂不可用"),
4_000,
)
.with_user_item_id(Some("direct-codex:turn-1:user"));
assert_eq!(
failed.failure(),
Some(&DirectTurnFailure::new(
"model-failed",
"上游返回 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,274 @@
//! 失败终态的宿主侧策略:把"这一轮为什么失败"翻译成可下发的 `failure` 载荷,并在宿主自己
//! 提前收场时补一条失败终态。
//!
//! 这个模块只有三件事,别再往里加第四件:
//! 1. [`direct_turn_failure_kind`]:把 `LlmError` 归到稳定分类(只给界面选语气);
//! 2. [`direct_turn_failure`]:判定这一轮的终态是不是失败,是的话给出脱敏后的原因;
//! 3. [`DirectTurnFailureGuard`]`turn.started` 之后武装、写完终态解除的 Drop 兜底。
//!
//! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs``DirectTurnFailure`);
//! 这里只负责"什么算失败、原因怎么写、什么时候兜底",不碰事件队列的搬运规则。
use std::path::Path;
use platform_llm::LlmError;
use super::{
append_direct_thread_event, direct_tool_call_now_ms, redact_agent_runtime_error,
DirectThreadEvent, DirectTurnFailure,
};
/// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于
/// 把整段上游报文塞进事件队列。
const DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS: usize = 600;
/// 宿主任务提前结束(panic / future 被丢弃 / 终态之前的早退)时的分类与文案。
const DIRECT_TURN_FAILURE_HOST_DROPPED_KIND: &str = "host-dropped";
const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str =
"陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。";
/// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected`。
///
/// 分类只影响界面语气,前端不得拿它做流程分支(流程判据只有"收到终态事件"这一条)。
fn direct_turn_failure_kind(error: &LlmError) -> &'static str {
match error {
LlmError::Timeout { .. } => "timeout",
LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => "request-rejected",
LlmError::Connectivity { .. } | LlmError::Transport(_) | LlmError::StreamUnavailable => {
"transport-failed"
}
LlmError::Upstream { .. } | LlmError::EmptyResponse | LlmError::Deserialize(_) => {
"model-failed"
}
}
}
/// 这一轮的终态是不是「失败」?是的话给出失败载荷(原因已脱敏并截断)。
///
/// 失败有两个来源,都必须进 `turn.completed(status="failed")` 的 `failure` 载荷:
/// - `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取;
/// - `collect_result` 是交付报告、但 `status` 已经判成 `failed`:宿主收束了一个失败的回合,
/// 原因用那份报告本身(它本来就是给用户看的失败说明)。
///
/// 其余终态(`completed` / `interrupted` / `aborted`)都不是失败,返回 `None`,事件不带载荷。
pub(crate) fn direct_turn_failure(
status: &str,
collect_result: Result<&str, &LlmError>,
history_root: &Path,
) -> Option<DirectTurnFailure> {
let (kind, message) = match collect_result {
Err(error) => (
direct_turn_failure_kind(error).to_string(),
error.to_string(),
),
Ok(report) if status == "failed" => ("model-failed".to_string(), report.to_string()),
Ok(_) => return None,
};
Some(DirectTurnFailure::new(
kind,
redact_agent_runtime_error(
history_root,
&message,
DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS,
),
))
}
/// 回合终态兜底守卫:`turn.started` 发出去之后,这一轮在宿主侧只剩两条收场路径——正常路径
/// 写完终态事件(然后 [`Self::disarm`]),或者这个守卫的 `Drop`。
///
/// 兜底覆盖三种"走不到终态"的情况:panic 展开、future 被丢弃(任务 / 进程取消),以及今后在
/// 终态事件之前新增的 `?` 早退。它们都再也没有机会补终态事件,前端只能永远停在"还在跑";
/// 这里在 Drop 里补一条 `status="failed"` + `host-dropped` 的终态,让前端拿到收口依据。
///
/// 与 `CodexTurnGuard` / `CodexTurnStartGuard` 是**三件事**,不要合并:那两个守卫管的是
/// app-server 连接与 `turn/start` 请求的回收,Drop 里不产出任何事件。
///
/// 已知边界(不为它加路径):宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,前端仍会停在
/// 运行态;`turn.started` 之前的早退根本不武装这个守卫——没有开始就没有"未收口的回合"。
pub(crate) struct DirectTurnFailureGuard {
thread_id: String,
user_item_id: Option<String>,
armed: bool,
}
impl DirectTurnFailureGuard {
/// 武装:调用点必须是 `turn.started` **已经**进入队列之后。
pub(crate) fn arm(thread_id: String, user_item_id: Option<String>) -> Self {
Self {
thread_id,
user_item_id,
armed: true,
}
}
/// 解除:终态事件(正常或失败)已经写完,兜底不再需要。
pub(crate) fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for DirectTurnFailureGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
append_direct_thread_event(
&self.thread_id,
DirectThreadEvent::turn_completed_failed(
DirectTurnFailure::new(
DIRECT_TURN_FAILURE_HOST_DROPPED_KIND,
DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE,
),
direct_tool_call_now_ms(),
)
.with_user_item_id(self.user_item_id.as_deref()),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::{consume_direct_thread, subscribe_direct_thread};
fn history_root() -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/direct-turn-failure-test")
}
#[test]
fn llm_error_variants_map_to_stable_kinds() {
assert_eq!(
direct_turn_failure_kind(&LlmError::Timeout { attempts: 3 }),
"timeout"
);
assert_eq!(
direct_turn_failure_kind(&LlmError::InvalidConfig("missing key".into())),
"request-rejected"
);
assert_eq!(
direct_turn_failure_kind(&LlmError::InvalidRequest("bad payload".into())),
"request-rejected"
);
assert_eq!(
direct_turn_failure_kind(&LlmError::Connectivity {
attempts: 2,
message: "reset".into(),
}),
"transport-failed"
);
assert_eq!(
direct_turn_failure_kind(&LlmError::Transport("stream closed".into())),
"transport-failed"
);
assert_eq!(
direct_turn_failure_kind(&LlmError::StreamUnavailable),
"transport-failed"
);
assert_eq!(
direct_turn_failure_kind(&LlmError::Upstream {
status_code: 500,
message: "boom".into(),
}),
"model-failed"
);
assert_eq!(
direct_turn_failure_kind(&LlmError::EmptyResponse),
"model-failed"
);
assert_eq!(
direct_turn_failure_kind(&LlmError::Deserialize("bad json".into())),
"model-failed"
);
}
#[test]
fn only_failed_terminals_carry_a_failure_payload() {
// 正常终态:无论交付报告写了什么都不是失败。
assert_eq!(
direct_turn_failure("completed", Ok("本轮交付已完成"), &history_root()),
None
);
assert_eq!(
direct_turn_failure("interrupted", Ok("本轮已被终止"), &history_root()),
None
);
assert_eq!(
direct_turn_failure("aborted", Ok("已结束这一轮占用"), &history_root()),
None
);
// 失败且拿得到错误:分类取自错误,原因取自错误文本。
let error =
LlmError::Transport("DirectProject 收尾历史失败:写入 project.jsonl 失败".into());
let failure = direct_turn_failure("failed", Err(&error), &history_root())
.expect("transport error must produce a failure payload");
assert_eq!(failure.kind, "transport-failed");
assert!(failure.message.contains("收尾历史失败"));
// 失败但拿到的是交付报告:宿主已经收束了这一轮,报告本身就是失败说明。
let failure = direct_turn_failure(
"failed",
Ok("宿主尚未确认交付完成;请核对未完成项。"),
&history_root(),
)
.expect("failed status must produce a failure payload");
assert_eq!(failure.kind, "model-failed");
assert_eq!(failure.message, "宿主尚未确认交付完成;请核对未完成项。");
}
#[test]
fn failure_message_is_redacted_and_truncated() {
let root = history_root();
let with_path = format!("落盘失败:{} 不可写", root.display());
let failure = direct_turn_failure("failed", Ok(&with_path), &history_root())
.expect("failed status must produce a failure payload");
assert!(!failure.message.contains("/tmp/direct-turn-failure-test"));
assert!(failure.message.contains("$PROJECT_ROOT"));
let long = "x".repeat(4_000);
let failure = direct_turn_failure("failed", Ok(&long), &history_root())
.expect("failed status must produce a failure payload");
// 按字符截断,最多再多一个省略号标记。
assert!(failure.message.chars().count() <= DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS + 1);
assert!(failure.message.ends_with('…'));
}
/// 兜底:守卫武装后没被解除就 Drop,必须补一条失败终态(panic / future 被丢弃走的就是这条)。
#[test]
fn armed_guard_appends_host_dropped_terminal_on_drop() {
let thread_id = "test-thread-failure-guard-armed";
let subscription = subscribe_direct_thread(thread_id);
let guard = DirectTurnFailureGuard::arm(
thread_id.to_string(),
Some("direct-codex:turn-1:user".to_string()),
);
drop(guard);
let events = consume_direct_thread(&subscription.subscription_id)
.expect("consume guard terminal")
.events;
assert!(matches!(
events.as_slice(),
[DirectThreadEvent::TurnCompleted { status, failure, user_item_id, .. }]
if status == "failed"
&& failure.as_ref().is_some_and(|failure| failure.kind == "host-dropped")
&& user_item_id.as_deref() == Some("direct-codex:turn-1:user")
));
}
/// 解除之后就闭嘴:正常写完终态的回合不得再多出一条兜底终态。
#[test]
fn disarmed_guard_appends_nothing() {
let thread_id = "test-thread-failure-guard-disarmed";
let subscription = subscribe_direct_thread(thread_id);
let mut guard = DirectTurnFailureGuard::arm(thread_id.to_string(), None);
guard.disarm();
drop(guard);
assert!(consume_direct_thread(&subscription.subscription_id)
.expect("consume disarmed guard")
.events
.is_empty());
}
}
@@ -104,7 +104,7 @@ export type DirectProjectChatControllerProps = {
* 传输层(三条通道,前端各拉各的) * 传输层(三条通道,前端各拉各的)
* A 运行态:notify → invoke consume_direct_project_thread → events[](实时) * A 运行态:notify → invoke consume_direct_project_thread → events[](实时)
* B 历史: invoke read_direct_project_history_slice → items[](分页,文件尾反向扫描) * B 历史: invoke read_direct_project_history_slice → items[](分页,文件尾反向扫描)
* C 本地: 前端自己造(乐观用户气泡、忙态、失败 / 终止说明) * C 本地: 前端自己造(乐观用户气泡、忙态、终止说明)
* ▼ * ▼
* 前端 * 前端
* useDirectThreadChatSubscription reducerA + B 进同一份 stateturnRunning / history / live * useDirectThreadChatSubscription reducerA + B 进同一份 stateturnRunning / history / live
@@ -121,6 +121,8 @@ export type DirectProjectChatControllerProps = {
* 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。 * 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。
* - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是原生回合 * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是原生回合
* 活跃与否的**唯一**判据;可回收事件被回收后靠 `lifecycle_anchor` 保住最新一条生命周期事件。 * 活跃与否的**唯一**判据;可回收事件被回收后靠 `lifecycle_anchor` 保住最新一条生命周期事件。
* 失败也走这条流:`turn.completed.failure` 自己带脱敏后的原因,reducer 把它落成本轮说明条目;
* 命令返回那条通道只提供横幅与诊断,不再写聊天文案。
* - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份 * - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份
* `direct-codex:{clientTurnId}:user`),所以两边按**身份**合并,不按时间戳猜。 * `direct-codex:{clientTurnId}:user`),所以两边按**身份**合并,不按时间戳猜。
* *
@@ -131,7 +133,8 @@ export type DirectProjectChatControllerProps = {
* 3. notify → consume → `turn.started`reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。 * 3. notify → consume → `turn.started`reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。
* 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。 * 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。
* 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。
* 6. `turn.completed``live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上 * 6. `turn.completed``live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上
* 带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。
* 7. 命令收尾(`finally`):刷新清单 → `endTurnCommand()` 清掉忙态与在途身份 → 出队下一轮。 * 7. 命令收尾(`finally`):刷新清单 → `endTurnCommand()` 清掉忙态与在途身份 → 出队下一轮。
* **顺序是契约**:出队会同步开始下一轮并设上它自己的忙态,所以清忙态必须早于出队; * **顺序是契约**:出队会同步开始下一轮并设上它自己的忙态,所以清忙态必须早于出队;
* 权限被拒那种「本轮从未发出但要继续出队」的情况,也只标记 `queueAdvance`、由这里统一收口。 * 权限被拒那种「本轮从未发出但要继续出队」的情况,也只标记 `queueAdvance`、由这里统一收口。
@@ -592,13 +595,6 @@ export function useDirectProjectChatController({
}); });
return; return;
} }
// 真失败:这条命令返回就说明这一轮在宿主那边已经收场,但终态事件可能永远不来
// app-server 崩了、任务被中止、panic 都只留下一条开着的 `turn.started`)。
// 按本轮身份放掉原生忙态,否则界面会一直显示「正在处理」、输入盒一直排队。
// 主动终止与「正在跑的是另一轮」不走这里:前者宿主必然补终态,后者不是这一轮。
directThread.stopCommandTurn(
directCodexConversationMessageId(input.clientTurnId, 'user'),
);
void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID);
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
let persistedDetail = ''; let persistedDetail = '';
@@ -621,14 +617,10 @@ export function useDirectProjectChatController({
true, true,
); );
if (projectPathRef.current !== nextProjectPath) return; if (projectPathRef.current !== nextProjectPath) return;
// 聊天里的失败说明不再由这里写:宿主已经把脱敏后的原因放进了
// `turn.completed.failure`reducer 会把它落成本轮最后一条条目(唯一来源)。这里只保留
// 运行错误横幅(含 `详情:` 那份长 detail)与诊断留痕,两条通道不再各写一份文案。
onRuntimeError(visibleMessage); onRuntimeError(visibleMessage);
appendLocalMessage({
role: 'assistant',
text: visibleMessage,
runtimeOwned: true,
messageId: `direct-codex:${input.clientTurnId}:failure`,
updatedAt: Date.now(),
});
} }
} }
@@ -19,7 +19,6 @@ import {
mergeDirectHistoryItems, mergeDirectHistoryItems,
resolveDirectThreadBootstrap, resolveDirectThreadBootstrap,
selectDirectChatEntries, selectDirectChatEntries,
stopDirectThreadTurn,
} from '../conversation/directThreadChat'; } from '../conversation/directThreadChat';
import type { DirectThreadConsumeResult } from '../generated/DirectThreadConsumeResult'; import type { DirectThreadConsumeResult } from '../generated/DirectThreadConsumeResult';
import type { DirectThreadItem } from '../generated/DirectThreadItem'; import type { DirectThreadItem } from '../generated/DirectThreadItem';
@@ -41,11 +40,6 @@ export type DirectThreadChatSubscription = {
mergeHistoryItems: (items: readonly DirectThreadItem[]) => void; mergeHistoryItems: (items: readonly DirectThreadItem[]) => void;
/** 终止成功(`released`)时手动放掉回合占用:订阅可能要等下一个事件才知道。 */ /** 终止成功(`released`)时手动放掉回合占用:订阅可能要等下一个事件才知道。 */
markTurnStopped: () => void; markTurnStopped: () => void;
/**
* 本地命令失败收场时按身份放掉这一轮:宿主的终态事件可能永远不会来(进程崩了 /
* 任务被中止),不能一直挂在 `turn.started` 上显示「正在处理」。
*/
stopCommandTurn: (userItemId: string) => void;
}; };
/** /**
@@ -191,13 +185,6 @@ export function useDirectThreadChatSubscription({
[], [],
); );
const stopCommandTurn = useMemo(
() => (userItemId: string) => {
setState((current) => stopDirectThreadTurn(current, userItemId));
},
[],
);
const entries = useMemo(() => selectDirectChatEntries(state), [state]); const entries = useMemo(() => selectDirectChatEntries(state), [state]);
return { return {
@@ -207,6 +194,5 @@ export function useDirectThreadChatSubscription({
anchorGateRef, anchorGateRef,
mergeHistoryItems, mergeHistoryItems,
markTurnStopped, markTurnStopped,
stopCommandTurn,
}; };
} }
@@ -5,6 +5,10 @@
* 运行态独有条目。这里不做可见性判断(那是投影的事):DirectProject 同一时刻只有一个回合在跑, * 运行态独有条目。这里不做可见性判断(那是投影的事):DirectProject 同一时刻只有一个回合在跑,
* `turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔;回合身份只用原生生命周期 * `turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔;回合身份只用原生生命周期
* 事件自带的 canonical user identity`userItemId`)做展示边界关联,不新建回合注册表。 * 事件自带的 canonical user identity`userItemId`)做展示边界关联,不新建回合注册表。
*
* 失败(`turn.completed.status === "failed"`)不是第二套生命周期:终态还是同一个事件,只是带了
* `failure` 载荷。这里把载荷落成本轮最后一条说明条目再走同一个收口函数——失败文案的唯一来源
* 就是事件流,命令返回只服务运行错误横幅与诊断。
*/ */
import type { GameCreatorDirectToolCall } from '../../../../app/types'; import type { GameCreatorDirectToolCall } from '../../../../app/types';
@@ -14,6 +18,10 @@ import type { DirectThreadHistorySlice } from '../generated/DirectThreadHistoryS
import type { DirectThreadItem } from '../generated/DirectThreadItem'; import type { DirectThreadItem } from '../generated/DirectThreadItem';
import type { DirectThreadSubscriptionBootstrap } from '../generated/DirectThreadSubscriptionBootstrap'; import type { DirectThreadSubscriptionBootstrap } from '../generated/DirectThreadSubscriptionBootstrap';
import { projectDirectThreadItem } from './directThreadItemProjection'; import { projectDirectThreadItem } from './directThreadItemProjection';
import {
directTurnFailureItemId,
directTurnFailureNoticeText,
} from './directTurnFailure';
export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool';
@@ -65,16 +73,6 @@ export type DirectThreadChatState = {
* 也不用时间戳近似。空串 = 原生没给身份(旧事件),此时不猜历史归属。 * 也不用时间戳近似。空串 = 原生没给身份(旧事件),此时不猜历史归属。
*/ */
turnUserItemId: string; turnUserItemId: string;
/**
* 「本地命令已经返回、宿主却一直没给终态」的那一轮身份(见 `stopDirectThreadTurn`)。
*
* `turn.started` 与 `turn.completed` 是原生回合唯一的开闭配对,但**进程崩了、任务被
* 中止、panic** 这类收场不会补终态事件,只留一条永远开着的 `turn.started`:界面上就
* 一直显示「正在处理」,输入盒也一直忙。本地那一条命令(`chat_with_game_creator_direct_codex`
* 返回时说到底就是"这一轮在宿主那边已经收场",这条身份就是它的记录:同身份的
* `turn.started` 迟到 / 重放回来不再复活这一轮,避免收口之后又被拉回运行态。
*/
commandClosedTurnUserItemId: string;
/** 历史切片条目,保持文件顺序。 */ /** 历史切片条目,保持文件顺序。 */
history: DirectChatEntry[]; history: DirectChatEntry[];
/** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */
@@ -87,40 +85,11 @@ export function emptyDirectThreadChatState(): DirectThreadChatState {
turnStartedAt: 0, turnStartedAt: 0,
turnEndedAt: 0, turnEndedAt: 0,
turnUserItemId: '', turnUserItemId: '',
commandClosedTurnUserItemId: '',
history: [], history: [],
live: [], live: [],
}; };
} }
/**
* 本地命令失败收场:这一轮命令已经返回,宿主却还在事件流里挂着 `turn.started`。
*
* 只放掉"是否在跑",**不写终态时间**——命令返回不等于我们知道这一轮真正的结束时刻,
* 编一个只会让耗时变成假数。收口后同身份的 `turn.started` 迟到 / 重放回来不再复活,
* 免得刚修好的"还在处理"又被拉起来。宿主随后真发来 `turn.completed` 时照旧正常收口。
*
* 身份不同的轮次不动:宿主同时只允许一条回合,但"正在跑的是另一轮"`another-turn-running`
* 这种拒绝也要能原样报给用户,不能顺手把别人那轮抹掉。空身份(宿主没能落上身份)时按
* 本轮处理,否则这条兜底永远盖不住协议早期失败。
*/
export function stopDirectThreadTurn(
state: DirectThreadChatState,
userItemId: string,
): DirectThreadChatState {
if (state.turnUserItemId !== '' && state.turnUserItemId !== userItemId) {
return state;
}
if (!state.turnRunning && state.commandClosedTurnUserItemId === userItemId) {
return state;
}
return {
...state,
turnRunning: false,
commandClosedTurnUserItemId: userItemId,
};
}
/** 时间戳合法性:缺失 / 0 / 非有限都算没有这个边界,不用它计任何耗时。 */ /** 时间戳合法性:缺失 / 0 / 非有限都算没有这个边界,不用它计任何耗时。 */
function validBoundaryAt(value: number | null | undefined): number { function validBoundaryAt(value: number | null | undefined): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 return typeof value === 'number' && Number.isFinite(value) && value > 0
@@ -341,14 +310,6 @@ export function reduceDirectThreadEvent(
// 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时 // 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时
// 清空而不是继承上一轮,避免上一轮迟到的终态按身份匹配到这一轮。 // 清空而不是继承上一轮,避免上一轮迟到的终态按身份匹配到这一轮。
const turnUserItemId = readDirectThreadEventUserItemId(event); const turnUserItemId = readDirectThreadEventUserItemId(event);
// 本地命令已经收过场的那一轮:迟到的 `turn.started` 不得把它拉回运行态(见
// `commandClosedTurnUserItemId`)。身份按 clientTurnId 唯一,只挡它自己那一轮。
if (
turnUserItemId !== '' &&
turnUserItemId === state.commandClosedTurnUserItemId
) {
return state;
}
return { return {
...state, ...state,
turnRunning: true, turnRunning: true,
@@ -370,20 +331,23 @@ export function reduceDirectThreadEvent(
return state; return state;
} }
// 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。 // 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。
// 例外是"本地命令兜底收口"的那一轮(`commandClosedTurnUserItemId` 命中且还没有终态 if (!state.turnRunning && state.live.length === 0) {
// 时间):那次收口本来就没写时间,宿主这份迟到的终态要拿来补上真正的结束时刻。
const lateTerminalForCommandClosedTurn =
eventUserItemId !== '' &&
eventUserItemId === state.commandClosedTurnUserItemId &&
state.turnEndedAt <= 0;
if (
!state.turnRunning &&
state.live.length === 0 &&
!lateTerminalForCommandClosedTurn
) {
return state; return state;
} }
return finishDirectThreadTurn(state, eventAt); // 失败终态带 `failure` 载荷:先把它落成本轮最后一条说明条目,再和正常终态走同一个收口
// 函数。载荷在、原因非空才算一条说明;空原因不补一条空气泡(终态照样收口)。
const failure = event.failure;
const withNotice =
failure && failure.message.trim()
? upsertLiveEntry(state, {
itemId: directTurnFailureItemId(eventUserItemId, eventAt),
kind: 'message',
role: 'assistant',
text: directTurnFailureNoticeText(failure.message),
at: eventAt,
})
: state;
return finishDirectThreadTurn(withNotice, eventAt);
} }
case 'item.delta': case 'item.delta':
return appendLiveText(state, event); return appendLiveText(state, event);
@@ -426,7 +390,8 @@ export function reduceDirectThreadEvent(
/** /**
* 回合收口:把运行态条目并入历史、清空运行态,并固定本轮终态时间。 * 回合收口:把运行态条目并入历史、清空运行态,并固定本轮终态时间。
* *
* `endedAt` 只接受明确的终态时间`turn.completed.at`,或宿主终止收口时观测到的时刻): * `endedAt` 只接受明确的终态时间`turn.completed.at`(正常与失败同源),或宿主终止收口时
* 观测到的时刻。
* 缺失就是缺失,宁可不显示总耗时,也不用最后一条工具 / 正文的时间顶替。 * 缺失就是缺失,宁可不显示总耗时,也不用最后一条工具 / 正文的时间顶替。
* 已经冻结的终态时间不会被后来的调用抬高;开始时间只记原生值,用户实际发送时间的优先级 * 已经冻结的终态时间不会被后来的调用抬高;开始时间只记原生值,用户实际发送时间的优先级
* 由投影层决定(条目上的 `at` 才是气泡时间)。 * 由投影层决定(条目上的 `at` 才是气泡时间)。
@@ -0,0 +1,46 @@
/**
* 失败终态的展示口径:`turn.completed.failure` 载荷怎么变成聊天里那条说明。
*
* 只放两条规则,别在这里做事件归并(那是 `directThreadChat.ts` 的事):
* 1. 说明条目的展示身份怎么派生;
* 2. 事件里的原始原因怎么变成用户可见文案。
*
* 为什么值得单独一个文件:这两条是**跨侧约定**——身份要和前端自己造的说明(终止 / announce)
* 区分开又保持可预期,文案映射要和运行错误横幅用同一份规则。把它们散在 reducer 里,读代码的人
* 只能靠猜"这条失败说明是从哪冒出来的"。
*/
import { projectRuntimeVisibleError } from '../../../../features/agent-runtime';
/** 没有本轮开口条目身份时的兜底展示身份前缀(正常路径不会用到)。 */
const DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID = 'direct-thread-turn-failure';
/**
* 失败说明条目的**展示身份**:本轮开口用户条目的 canonical identity + `:failure` 后缀。
*
* 它是派生身份,不是原生条目身份——宿主只在 `turn.completed.failure` 里给原因,不额外造条目。
* 用本轮身份派生可以保证"同一轮只有一条说明、跨轮不会合并",也不会和原生 itemId 撞车。
* 身份不可证明(本轮没有落盘用户条目)时退化成与事件时间绑定的固定形状:它随事件固定、
* 重放不变,又不会让两轮失败互相覆盖。
*/
export function directTurnFailureItemId(
userItemId: string | null | undefined,
at: number,
): string {
const identity = typeof userItemId === 'string' ? userItemId.trim() : '';
if (identity) return `${identity}:failure`;
return at > 0
? `${DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID}:${at}`
: DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID;
}
/**
* 失败原因的可见文案:与运行错误横幅共用同一份映射(`projectRuntimeVisibleError`)。
*
* 事件里的 `message` 是宿主已脱敏 + 截断的原始原因,这里只做"给人看"的那一步,不再另开文案
* 规则,也不在这里判断"这算不算失败"(那由事件载荷的有没有决定)。
*/
export function directTurnFailureNoticeText(message: string): string {
const raw = typeof message === 'string' ? message.trim() : '';
return projectRuntimeVisibleError(raw, '陶泥儿智能创作', true);
}
@@ -239,7 +239,10 @@ function newTurn(key: string): DirectChatTurnEntries {
* 条目 + 运行期本地消息 → 回合列表。 * 条目 + 运行期本地消息 → 回合列表。
* *
* 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息 * 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息
* 失败 / 终止说明)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。 * (终止说明、壳层 `announce`)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。
*
* 失败说明不在这条本地通道里:它是宿主 `turn.completed.failure` 载荷落成的普通条目,来源与
* 顺序都归 reducer。
*/ */
export function buildDirectChatTurns({ export function buildDirectChatTurns({
entries, entries,
@@ -2,6 +2,7 @@
import type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; import type { DirectThreadDeltaKind } from './DirectThreadDeltaKind';
import type { DirectThreadItem } from './DirectThreadItem'; import type { DirectThreadItem } from './DirectThreadItem';
import type { DirectThreadRequestKind } from './DirectThreadRequestKind'; import type { DirectThreadRequestKind } from './DirectThreadRequestKind';
import type { DirectTurnFailure } from './DirectTurnFailure';
/** /**
* Thread Manager 下发的运行态事件。 * Thread Manager 下发的运行态事件。
@@ -41,7 +42,15 @@ export type DirectThreadEvent =
} }
| { | {
type: 'turn.completed'; type: 'turn.completed';
/**
* 终态语义:`completed` / `interrupted` / `aborted` 是正常收场;`failed` 是**失败**
* 此时必须带 `failure` 载荷。
*/
status: string; status: string;
/**
* 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。
*/
failure?: DirectTurnFailure;
/** /**
* 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 * 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。
*/ */
@@ -0,0 +1,19 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。
*
* `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的
* 可展示原因——失败原因只走这一条通道,前端不再从命令返回或另一条 IPC 里另造文案。
*/
export type DirectTurnFailure = {
/**
* 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` /
* `host-dropped`。
*/
kind: string;
/**
* 脱敏 + 截断后的失败原因。
*/
message: string;
};
@@ -504,7 +504,7 @@ export function registerChatComposerControlTests() {
}); });
}); });
it('stops claiming the turn is running when a failed send left turn.started open', async () => { it('closes the turn from the host failure payload instead of leaving it running', async () => {
let harness: ReturnType<typeof createProjectChatRuntimeHarness> | null = let harness: ReturnType<typeof createProjectChatRuntimeHarness> | null =
null; null;
const { surface } = await openDirectCodexSurface( const { surface } = await openDirectCodexSurface(
@@ -512,13 +512,23 @@ export function registerChatComposerControlTests() {
chat_with_game_creator_direct_codex: ( chat_with_game_creator_direct_codex: (
args: Record<string, unknown> | undefined, args: Record<string, unknown> | undefined,
) => { ) => {
// 宿主先认领了这一轮(turn.started),随后崩掉:没有终态事件,命令以失败返回。 // 宿主先认领了这一轮(turn.started),随后失败收场:失败原因由终态事件自己带出来,
harness?.emitDirectThreadEvents({ // 命令也以失败返回(真实宿主是先 append 终态、再把错误抛回前端)。
type: 'turn.started', const userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`;
at: 5_000, harness?.emitDirectThreadEvents(
userItemId: `direct-codex:${String(args?.clientTurnId ?? '')}:user`, { type: 'turn.started', at: 5_000, userItemId },
}); {
throw new Error('模拟宿主崩溃:turn.started 之后没有终态事件'); type: 'turn.completed',
status: 'failed',
failure: {
kind: 'request-rejected',
message: 'codex-app-server-error:context-window-exceeded',
},
at: 6_000,
userItemId,
},
);
throw new Error('模拟宿主失败:错误只走终态事件,命令只回报失败');
}, },
}, },
(directHarness) => { (directHarness) => {
@@ -526,20 +536,28 @@ export function registerChatComposerControlTests() {
}, },
); );
const composer = within(surface).getByLabelText('陶泥儿对话内容'); const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '崩掉的那条'); await submitDirectTurn(surface, composer, '超限的那条');
// 聊天里的失败说明来自事件载荷(经同一份可见文案映射),不是命令返回的错误文本。
await waitFor(() => { await waitFor(() => {
expect( expect(
within(surface).getAllByText('陶泥儿智能创作 执行失败,请稍后重试') within(surface).getAllByText(
.length, '陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试',
).length,
).toBeGreaterThan(0); ).toBeGreaterThan(0);
}); });
// 命令已经收场:卡片和输入区都不能再声称"还在处理"。 // 终态已经到了:卡片和输入区都不能再声称"还在处理"。
expect(within(surface).queryAllByText('陶泥儿正在处理')).toHaveLength(0); expect(within(surface).queryAllByText('陶泥儿正在处理')).toHaveLength(0);
expect(within(surface).queryByRole('button', { name: '终止' })).toBeNull(); expect(within(surface).queryByRole('button', { name: '终止' })).toBeNull();
expect( expect(
within(surface).getByRole('button', { name: '发送' }), within(surface).getByRole('button', { name: '发送' }),
).not.toBeNull(); ).not.toBeNull();
// 命令返回那条通道只负责横幅:它不写第二条聊天文案。
expect(
within(surface).queryAllByText(
'模拟宿主失败:错误只走终态事件,命令只回报失败',
),
).toHaveLength(0);
}); });
it('keeps the next queued turn busy when the write gate refuses the running one', async () => { it('keeps the next queued turn busy when the write gate refuses the running one', async () => {
@@ -11,7 +11,6 @@ import {
reduceDirectThreadEvents, reduceDirectThreadEvents,
resolveDirectThreadBootstrap, resolveDirectThreadBootstrap,
selectDirectChatEntries, selectDirectChatEntries,
stopDirectThreadTurn,
} from '../src/view/project-development/chat/conversation/directThreadChat'; } from '../src/view/project-development/chat/conversation/directThreadChat';
import type { DirectThreadItem } from '../src/view/project-development/chat/conversation/directThreadItemProjection'; import type { DirectThreadItem } from '../src/view/project-development/chat/conversation/directThreadItemProjection';
import type { DirectThreadEvent } from '../src/view/project-development/chat/generated/DirectThreadEvent'; import type { DirectThreadEvent } from '../src/view/project-development/chat/generated/DirectThreadEvent';
@@ -177,54 +176,6 @@ describe('DirectProject 聊天 reducer', () => {
expect(selectDirectChatEntries(done)).toHaveLength(2); expect(selectDirectChatEntries(done)).toHaveLength(2);
}); });
it('本地命令兜底收口后,迟到的同名 turn.started 不复活这一轮', () => {
const identity = 'direct-codex:client-turn-1:user';
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event(withUserItemId({ type: 'turn.started', at: 1_000 }, identity)),
event({ type: 'item.completed', item: messageItem() }),
]);
expect(running.turnRunning).toBe(true);
const stopped = stopDirectThreadTurn(running, identity);
expect(stopped.turnRunning).toBe(false);
// 兜底收口不写终态时间:命令返回不等于知道这一轮真正的结束时刻。
expect(stopped.turnEndedAt).toBe(0);
// 同一轮迟到的 turn.started 不再把它拉回运行态,运行态条目也没丢。
const revived = reduceDirectThreadEvents(stopped, [
event(withUserItemId({ type: 'turn.started', at: 2_000 }, identity)),
]);
expect(revived.turnRunning).toBe(false);
expect(selectDirectChatEntries(revived)).toHaveLength(1);
// 宿主随后补上的真终态照旧收口,并把真正的结束时间补上。
const late = reduceDirectThreadEvents(revived, [
event(
withUserItemId(
{ type: 'turn.completed', status: 'failed', at: 3_000 },
identity,
),
),
]);
expect(late.turnRunning).toBe(false);
expect(late.turnEndedAt).toBe(3_000);
});
it('本地命令兜底收口不碰身份不同的那轮', () => {
const other = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event(
withUserItemId(
{ type: 'turn.started', at: 1_000 },
'direct-codex:client-turn-9:user',
),
),
]);
expect(
stopDirectThreadTurn(other, 'direct-codex:client-turn-1:user')
.turnRunning,
).toBe(true);
});
it('历史切片搬运层不合并,合并发生在前端投影', () => { it('历史切片搬运层不合并,合并发生在前端投影', () => {
const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
toolStarted(), toolStarted(),
@@ -333,6 +284,165 @@ describe('DirectProject 聊天 reducer', () => {
expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}');
}); });
describe('失败终态(turn.completed 带 failure 载荷)', () => {
it('失败也是终态:收口本轮、冻结终点,并把原因落成本轮最后一条说明', () => {
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 1_000_000 }),
'direct-codex:turn-1:user',
),
event({ type: 'item.started', at: 1_000_100, item: toolStarted() }),
withUserItemId(
event({
type: 'turn.completed',
status: 'failed',
failure: {
kind: 'transport-failed',
message: 'DirectProject 收尾历史失败:未确认历史完整落盘',
},
at: 1_000_900,
}),
'direct-codex:turn-1:user',
),
]);
expect(failed.turnRunning).toBe(false);
expect(failed.turnEndedAt).toBe(1_000_900);
expect(failed.live).toHaveLength(0);
const notice = failed.history.at(-1);
expect(notice?.itemId).toBe('direct-codex:turn-1:user:failure');
expect(notice?.role).toBe('assistant');
expect(notice?.text).toBe('陶泥儿智能创作 执行失败,请稍后重试');
// 本轮开口条目照样按身份拿到边界(失败与正常终态同源)。
expect(selectDirectChatEntries(failed)[0]?.turnEndedAt).toBe(1_000_900);
expect(selectDirectChatEntries(failed)[0]?.turnStartedAt).toBe(1_000_000);
});
it('失败说明的可见文案与运行错误横幅共用同一份映射', () => {
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 1_000 }),
'direct-codex:turn-1:user',
),
event({
type: 'turn.completed',
status: 'failed',
failure: {
kind: 'request-rejected',
message: 'codex-app-server-error:context-window-exceeded',
},
at: 2_000,
}),
]);
expect(failed.history.at(-1)?.text).toBe(
'陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试',
);
});
it('重复 / 迟到的失败终态不追加第二条说明,也不抬高冻结终点或复活运行态', () => {
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 1_000_000 }),
'direct-codex:turn-1:user',
),
withUserItemId(
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'model-failed', message: '模型服务暂不可用' },
at: 1_000_900,
}),
'direct-codex:turn-1:user',
),
]);
const replayed = reduceDirectThreadEvents(failed, [
withUserItemId(
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'model-failed', message: '模型服务暂不可用' },
at: 9_900_000,
}),
'direct-codex:turn-1:user',
),
]);
expect(replayed.turnRunning).toBe(false);
expect(replayed.turnEndedAt).toBe(1_000_900);
expect(
replayed.history.filter((entry) => entry.itemId.endsWith(':failure')),
).toHaveLength(1);
});
it('身份不匹配的失败终态不动正在跑的这一轮', () => {
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 2_000_000 }),
'direct-codex:turn-2:user',
),
]);
const untouched = reduceDirectThreadEvents(running, [
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'model-failed', message: '上一轮的失败' },
at: 2_000_900,
userItemId: 'direct-codex:turn-1:user',
}),
]);
expect(untouched.turnRunning).toBe(true);
expect(untouched.turnEndedAt).toBe(0);
expect(untouched.history).toHaveLength(0);
expect(untouched.live).toHaveLength(0);
});
it('空原因不落说明条目,但终态照样收口', () => {
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 1_000 }),
'direct-codex:turn-1:user',
),
withUserItemId(
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'host-dropped', message: ' ' },
at: 2_000,
}),
'direct-codex:turn-1:user',
),
]);
expect(failed.turnRunning).toBe(false);
expect(failed.turnEndedAt).toBe(2_000);
expect(failed.history).toHaveLength(0);
});
it('没有身份时用事件时间派生说明身份,两轮失败不会合并成一条', () => {
const first = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000 }),
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'model-failed', message: '第一轮失败' },
at: 2_000,
}),
]);
const second = reduceDirectThreadEvents(first, [
event({ type: 'turn.started', at: 3_000 }),
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'model-failed', message: '第二轮失败' },
at: 4_000,
}),
]);
expect(second.history.map((entry) => entry.itemId)).toEqual([
'direct-thread-turn-failure:2000',
'direct-thread-turn-failure:4000',
]);
});
});
describe('事件级计时边界', () => { describe('事件级计时边界', () => {
/** 工具的开始 / 完成只读事件级 `at`,条目 `item.at` 不作起止。 */ /** 工具的开始 / 完成只读事件级 `at`,条目 `item.at` 不作起止。 */
it('工具耗时只读事件级 at,不把 item.at 当开始或完成', () => { it('工具耗时只读事件级 at,不把 item.at 当开始或完成', () => {
@@ -24,6 +24,8 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(
- 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。 - 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。
- 前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复。 - 前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复。
- 活动回合的唯一判据是「出现过 `turn.started` 且未出现 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 - 活动回合的唯一判据是「出现过 `turn.started` 且未出现 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。
- 终态事件只有 `turn.completed` 一种,它同时承载三种语义:`status !== "failed"` 是正常结束 / 中断 / 终止,`status === "failed"` 是**失败**,且必须再带 `failure { kind, message }``message` 已脱敏截断)。失败原因只走这一条通道:前端不再从命令返回或另一条 IPC 里另造失败文案,聊天里那条失败说明仍落在同一个展示位上(本轮最后一条助手气泡、只在运行期显示),只是数据来源换成事件载荷;命令返回只用于运行错误横幅与诊断留痕。
- 宿主侧兜底:`turn.started` 发出之后才武装 Drop 守卫,正常写完终态即解除;panic、future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态,避免前端永远停在"还在跑"。已知边界见「影响」一节。
- 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 - 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。
- `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不需要为了取这批事件再补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。唯一例外是回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,前端却要等回执才知道自己的 `subscriptionId`,这段窗口内的通知只能记成欠账,回执到达后立刻补一次 `consume` 取回,否则该回合的尾部事件会卡在队列里等一个可能永不出现的下一次通知。 - `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不需要为了取这批事件再补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。唯一例外是回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,前端却要等回执才知道自己的 `subscriptionId`,这段窗口内的通知只能记成欠账,回执到达后立刻补一次 `consume` 取回,否则该回合的尾部事件会卡在队列里等一个可能永不出现的下一次通知。
- 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。
@@ -50,7 +52,9 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(
- 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。 - 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。
- 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。 - 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。
- 回合结束语义务必由 `turn.completed` 判定;缺少该事件的残留回合不得被渲染成运行中。 - 回合结束语义务必由 `turn.completed` 判定(失败时同一事件带 `failure` 载荷,不新增事件类型);缺少该事件的残留回合不得被渲染成运行中。
- 两条已知边界,都**不**在本次补路径:① 宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,前端仍会停在运行态,那要靠前端自己的"命令已返回却没有任何终态事件"判据;② `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、历史注入参数构建失败等)不产生回合、也不补终态事件——它们不会留下永远开着的回合,出问题时只有运行错误横幅解释。
- 失败原因里的 `message` 是宿主侧脱敏 + 截断后的可展示文本,前端仍按既有口径做一次可见文案映射(`projectRuntimeVisibleError`),映射规则不因这次改动改变。
- 「活动回合的唯一判据」约束的是**原生回合**:界面上的「本地已发出、原生还没认领」是投影的展示态(`DirectChatTurn.state = 'awaiting-start'`),由本地在途用户条目身份派生,不构成第二套原生生命周期,也不参与 `turnRunning` 的判定。 - 「活动回合的唯一判据」约束的是**原生回合**:界面上的「本地已发出、原生还没认领」是投影的展示态(`DirectChatTurn.state = 'awaiting-start'`),由本地在途用户条目身份派生,不构成第二套原生生命周期,也不参与 `turnRunning` 的判定。
- 三层数据流、变量归属与一次发送的时序写在代码里:`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表在 `apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts``DirectChatTurnState`。改判据时同步这两处与对应测试。 - 三层数据流、变量归属与一次发送的时序写在代码里:`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表在 `apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts``DirectChatTurnState`。改判据时同步这两处与对应测试。
- 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl` - 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`
@@ -9419,11 +9419,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 边界(A 仍未修):`DirectProjectTurnUsage``Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。为什么会有这两类、修法与要产品确认的口径都写在代码里(`DirectProjectTurn.tsx``DirectProjectTurnUsage` 注释与 `directTurnPresentation.ts``DirectChatTurnState` 注释),改完删掉那段注释。 - 边界(A 仍未修):`DirectProjectTurnUsage``Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。为什么会有这两类、修法与要产品确认的口径都写在代码里(`DirectProjectTurn.tsx``DirectProjectTurnUsage` 注释与 `directTurnPresentation.ts``DirectChatTurnState` 注释),改完删掉那段注释。
- 验证:`tests/directProjectTurn.test.tsx`(新增 3 条渲染契约:`awaiting-start``running` 不显示终态文案且不折叠、`finished` 有终态时显示结束时间与耗时);`tests/appSurface/chat-composer.suite.ts` 新增 `does not report a finished turn while the host has not acknowledged the send yet`(invoke 挂起、无任何原生事件时断言不出现「本轮结束于」);变异验证:把 `state !== 'finished'` 退回 `state === 'running'` 后渲染契约用例变红,恢复即绿。定向 vitest、`appSurface.test.ts`203 passed / 13 skipped)、`tsc`、ESLint、Prettier、`check:encoding``check:doc-index``git diff --check` 通过。真实客户端观感未复核。 - 验证:`tests/directProjectTurn.test.tsx`(新增 3 条渲染契约:`awaiting-start``running` 不显示终态文案且不折叠、`finished` 有终态时显示结束时间与耗时);`tests/appSurface/chat-composer.suite.ts` 新增 `does not report a finished turn while the host has not acknowledged the send yet`(invoke 挂起、无任何原生事件时断言不出现「本轮结束于」);变异验证:把 `state !== 'finished'` 退回 `state === 'running'` 后渲染契约用例变红,恢复即绿。定向 vitest、`appSurface.test.ts`203 passed / 13 skipped)、`tsc`、ESLint、Prettier、`check:encoding``check:doc-index``git diff --check` 通过。真实客户端观感未复核。
## 2026-09-22 宿主崩掉不再留下永远开着的回合:本地命令失败时按身份兜底收口 ## 2026-09-22 失败回合的终态:`turn.completed``failure` 载荷 + 宿主 Drop 守卫兜底
- 背景:`turn.started` / `turn.completed` 是原生回合唯一的开闭配对,界面上的「正在处理」卡片与输入盒忙态都读 reducer 的 `turnRunning`。但 app-server 崩了、回合任务被中止或 panic 时没人补终态事件,事件流里就留一条永远开着的 `turn.started`:界面一直显示「陶泥儿正在处理」、输入盒一直排队(用户现场反馈) - 背景:宿主崩在 `turn.started` 之后时没有任何终态事件,前端 `turnRunning` 永远为真,界面停在「陶泥儿正在处理」;同时失败在事件流里与正常结束同形(`turn.completed(status="failed")`,前端根本不读 `status`),失败文案只能从命令返回那条通道另造,同一次失败因此有两条通道、两份文案,而"这一轮结束了没有"只有事件说了算
- 决策(本地命令返回即这一轮在宿主那边收场):`chat_with_game_creator_direct_codex` 以真失败返回时,controller 按本轮身份调用 `stopDirectThreadTurn`,只放掉「是否在跑」,**不写终态时间**——命令返回不等于知道这一轮真正的结束时刻,编一个只会让耗时变成假数。用户主动终止与「正在跑的是另一轮」两条不适用:前者宿主必然补终态,后者不是这一轮(不能顺手抹掉别人的回合) - 决策(协议形状:复用,不新增事件类型):终态事件仍只有 `turn.completed``status !== "failed"` 表示正常结束 / 中断 / 终止,不带载荷;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }` —— `kind` 为稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `host-dropped`,只给界面选语气),`message` 为宿主脱敏 + 截断后的可展示原因。"是不是失败"只看两件事:`collect_result` 是 Err 就用错误本身当原因;`collect_result` 是交付报告但状态已判成 `failed` 就用那份报告当原因
- 决策(身份作用域 + 不复活):`stopDirectThreadTurn` 只在 reducer 里的运行身份相同或为空时生效;收口记进 `commandClosedTurnUserItemId`,同身份迟到的 `turn.started` 不再把这一轮拉回运行态(迟到的 `turn.completed` 例外放行,仍要拿它补上真正的结束时间)。身份按 clientTurnId 唯一,所以这条记忆只挡它自己那一轮 - 决策(兜底覆盖全部收场路径):`turn.started` 进入队列之后武装 Drop 守卫,正常写完终态即解除;panic、回合 future 被丢弃、终态之前的早退由守卫补一条 `host-dropped` 失败终态。Thread Manager 不改一行:`turn.completed` 本来就是 `lifecycle_anchor` 成员,失败终态天然顶替更早的 `turn.started`,重放不会把已收口的回合看成"还在跑"
- 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,controller/useDirectThreadChatSubscription.ts,controller/useDirectProjectChatController.ts}``apps/ai-game-creator-shell/tests/{directThreadChat.test.ts,appSurface/chat-composer.suite.ts}` - 决策(失败文案只有一条通道):聊天里那条失败说明仍落在原来的展示位(本轮最后一条助手气泡、只在运行期显示、不写进 `project.jsonl`),数据来源换成事件载荷;命令返回只保留运行错误横幅(含 `read_agent_runtime_error_detail` 的长 detail)与诊断留痕,不再写聊天气泡。可见文案映射仍走既有 `projectRuntimeVisibleError` 规则,只是执行点从 controller 移到 reducer
- 验证:reducer 新增 2 条用例(兜底收口后同名 `turn.started` 不复活且真终态仍能补上结束时间;身份不同的回合不动),appSurface 新增 `stops claiming the turn is running when a failed send left turn.started open`;变异验证:拿掉 controller 里的兜底收口调用后该用例变红(界面仍显示「陶泥儿正在处理」),恢复即绿 - 明确不做:不为 `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、缺稳定 `clientTurnId`、历史注入参数构建失败)补事件或兜底路径 —— 它们不产生回合、也不会留下永远开着的回合;不为进程被强杀(`kill -9`)补前端判据
- 边界(未做):根因仍在宿主侧——要在进程内保证开闭配对,应由 Rust 在回合函数退出(含 panic / 任务中止)时补一条终态事件(drop 守卫);本次只做到前端不再跟着说谎。另:兜底收口的回合没有终态时间,仍会落进「`finished` 但拿不到终态时间」那个已知缺口(终态文案要不要藏,见 `DirectProjectTurn.tsx``DirectChatTurnState` 注释里的 A 项) - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_thread_wire.rs,direct_turn_failure.rs,direct_thread_manager.rs,codex_app_server/mod.rs}``apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,conversation/directTurnFailure.ts,controller/useDirectProjectChatController.ts}`、生成绑定与两侧用例;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md``docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`
- 验证:见本条决策对应的提交记录(Rust 定向测试、reducer 与 appSurface 用例、`cargo test export_bindings` 后的生成绑定、`npm run check:encoding``git diff --check`)。
@@ -113,7 +113,7 @@ Thread 内所有公开事件共用一个单调递增 seq,但 **seq 只是 Thre
```ts ```ts
type DirectThreadEvent = type DirectThreadEvent =
| { type: 'turn.started' } | { type: 'turn.started' }
| { type: 'turn.completed'; status: string } | { type: 'turn.completed'; status: string; failure?: { kind: string; message: string } }
| { type: 'item.started'; item: DirectThreadItem } | { type: 'item.started'; item: DirectThreadItem }
| { type: 'item.completed'; item: DirectThreadItem } | { type: 'item.completed'; item: DirectThreadItem }
| { type: 'item.delta'; itemId: string; kind: 'message' | 'reasoning'; delta: string } | { type: 'item.delta'; itemId: string; kind: 'message' | 'reasoning'; delta: string }
@@ -122,12 +122,18 @@ type DirectThreadEvent =
进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started``item.delta``item.completed`、approval/request/resolved 事件,以及 `turn.started``turn.completed` 生命周期事件。前端按事件顺序 reduce,只用一个 reducer。 进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started``item.delta``item.completed`、approval/request/resolved 事件,以及 `turn.started``turn.completed` 生命周期事件。前端按事件顺序 reduce,只用一个 reducer。
**事件不带回合身份。** DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;条目、增量、请求与生命周期锚点都不带 turn id。前端 state 里只有一个 `turnRunning` 布尔,历史条目也不记录回合身份。 **事件不带回合身份。** DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`(失败时另带可选的 `failure`;条目、增量、请求与生命周期锚点都不带 turn id。前端 state 里只有一个 `turnRunning` 布尔,历史条目也不记录回合身份。
**终态只有 `turn.completed` 一种,失败靠 `failure` 载荷区分。** `status !== "failed"` 表示正常结束 / 中断 / 终止,事件不带 `failure``status === "failed"` 是失败终态,**必须**带 `failure { kind, message }``kind` 是稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `host-dropped`,只给界面选语气),`message` 是脱敏截断后的失败原因。失败原因只走这一条通道——前端不再从命令返回或另一条 IPC 里另造失败文案;`status="failed"` 却没有载荷视为协议违规。
宿主的异常收场同样靠这条事件:`turn.started` 进入队列之后武装一个 Drop 守卫,正常写完终态即解除;panic、回合 future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态。两条已知边界——宿主进程被强杀(`kill -9`)时没有任何 `Drop` 执行;`turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`)本身不产生回合——都不产出终态事件,也不假装有回合可收,前端在这两种情况下仍按"命令已返回"的既有语义收尾。
一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。 一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。
前端 reducer 的活动回合判定只有一条:事件序列中出现 `turn.started` 且其后没有 `turn.completed` 时才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 里没有这样的序列,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。 前端 reducer 的活动回合判定只有一条:事件序列中出现 `turn.started` 且其后没有 `turn.completed` 时才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 里没有这样的序列,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。
失败终态与正常终态同权:`turn.completed`(无论 `status`)都顶替更早的 `turn.started` 成为队列锚点,重放时新订阅既不会把已收口的回合看成"还在跑",也不会看到已经过期的失败原因。
生命周期锚点独立于 replay 队列保存:`turn.started` / `turn.completed` 事件即使已被队列前缀回收,`subscribe` 仍必须把最新的一条作为 bootstrap 事件返回。因此进程内任意时刻新建订阅,都能判定最新回合是运行中还是已结束,不依赖"未完成 item 恰好还在队列里"。 生命周期锚点独立于 replay 队列保存:`turn.started` / `turn.completed` 事件即使已被队列前缀回收,`subscribe` 仍必须把最新的一条作为 bootstrap 事件返回。因此进程内任意时刻新建订阅,都能判定最新回合是运行中还是已结束,不依赖"未完成 item 恰好还在队列里"。
`item.started``item.completed` 必须携带与历史切片同形的**脱敏原始条目**(经同一套挑字段、脱敏、截断、路径归一),不得只给 item 类型或空 payload。前端不得依赖"按 `itemId` 单点取快照"补齐正文:Rust 不提供 `getItemSnapshot(itemId)`,未完成条目的正文随事件下发,已完成条目一律通过历史读取。 `item.started``item.completed` 必须携带与历史切片同形的**脱敏原始条目**(经同一套挑字段、脱敏、截断、路径归一),不得只给 item 类型或空 payload。前端不得依赖"按 `itemId` 单点取快照"补齐正文:Rust 不提供 `getItemSnapshot(itemId)`,未完成条目的正文随事件下发,已完成条目一律通过历史读取。