宿主:终态写点挪到整轮结束之后,解析失败也能落进终态
- Direct 回合的终态判定事实改成先固定上下文,写点留到解析与线程释放之后 - structured output 解析折进同一个收尾结果:解析失败不再"终态写完才失败",改走失败载荷 - 收尾结果拆成 `DirectTurnReport`(报告正文 + 解析结果),终态兜底文案仍取被解析的那份文本 - 新增 `DirectTurnTerminalContext::write` 作为唯一终态出口,占用解除与 `turn.completed` 一起走 - `direct_turn_terminal_write` 改成"报告 / 失败"两个入参,便于单测覆盖三种投影 - 补单测:解析失败投影成 `model-failed` 载荷,正常收尾不带失败载荷
This commit is contained in:
@@ -4091,6 +4091,8 @@ impl CodexAppServerConnection {
|
||||
}
|
||||
};
|
||||
let mut collect_result: Result<String, DirectTurnRunFailure> = collect.await;
|
||||
// Direct 回合的终态上下文:判定事实在这里固定,**写点**在整轮结束之后。
|
||||
let mut direct_terminal: Option<DirectTurnTerminalContext> = None;
|
||||
// 早退要先取得宿主收尾事实,但**只对真失败**:`RepairRequired`(封口复核要求继续当前返修
|
||||
// 批次)是控制流——这一轮还没结束,不能被这里中断成一次收束失败。它的产生点(封口复核)
|
||||
// 一定先收束适配器,所以它也落不进下面的 `is_settled` 判据。
|
||||
@@ -4142,43 +4144,63 @@ impl CodexAppServerConnection {
|
||||
.map(|(_, at)| *at)
|
||||
.unwrap_or_else(direct_tool_call_now_ms)
|
||||
};
|
||||
// 终态只有 `turn.completed` 一种事件:失败时同一个事件带 `failure` 载荷(原因由宿主
|
||||
// 脱敏 + 截断后写进去),其余(`completed` / `interrupted` / `aborted`)不带载荷。
|
||||
// 失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上长得一样,前端只能
|
||||
// 另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流讲不清一轮怎么结束。
|
||||
// 判定拿的是**事实**(模型终态 / 交付结果 / 宿主记下的失败),不是收尾阶段推出来的
|
||||
// `status`:收尾自己会把阶段推成 `Interrupted`,用它判就会把已经失败的回合讲成"已结束"。
|
||||
let turn_failure = approval_adapter
|
||||
.as_ref()
|
||||
.and_then(|adapter| adapter.turn_failure());
|
||||
// 收尾结果在这里投影成 typed 错误:载荷的 `kind` / `message` 都从这一份值出来;
|
||||
// `None` 表示这一轮不该写终态(返修要求是控制流,见 `direct_turn_terminal_write`)。
|
||||
let collect_outcome = direct_turn_terminal_write(&collect_result);
|
||||
if let Some(collect_outcome) = collect_outcome {
|
||||
let terminal = direct_turn_terminal(
|
||||
&status,
|
||||
collect_outcome,
|
||||
turn_failure.as_ref(),
|
||||
history_root,
|
||||
);
|
||||
// 终态走 Thread Manager 的深出口:解除这一轮的占用并写下 `turn.completed`。
|
||||
complete_direct_thread_turn(
|
||||
&direct_thread_id,
|
||||
terminal.event(completed_at, direct_turn_user_item_id.as_deref()),
|
||||
);
|
||||
}
|
||||
// 终态判定的**事实**在这里固定,写点留到整轮真正结束之后(见下面的
|
||||
// `turn_result`):终态只有 `turn.completed` 一种事件,失败时同一个事件带 `failure`
|
||||
// 载荷(原因由宿主脱敏 + 截断后写进去),其余(`completed` / `interrupted` /
|
||||
// `aborted`)不带载荷。失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上
|
||||
// 长得一样,前端只能另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流
|
||||
// 讲不清一轮怎么结束。判定拿的是**事实**(模型终态 / 交付结果 / 宿主记下的失败),
|
||||
// 不是收尾阶段推出来的 `status`:收尾自己会把阶段推成 `Interrupted`,用它判就会把
|
||||
// 已经失败的回合讲成"已结束"。
|
||||
direct_terminal = Some(DirectTurnTerminalContext {
|
||||
status,
|
||||
completed_at,
|
||||
host_failure: approval_adapter
|
||||
.as_ref()
|
||||
.and_then(|adapter| adapter.turn_failure()),
|
||||
thread_id: direct_thread_id.clone(),
|
||||
user_item_id: direct_turn_user_item_id.clone(),
|
||||
});
|
||||
}
|
||||
let text = collect_result?;
|
||||
guard.armed = false;
|
||||
self.inner.turns.lock().await.remove(&turn_id);
|
||||
let response = parse_game_creator_codex_app_server_text(&text, &thread_id, &request)?;
|
||||
if matches!(
|
||||
snapshot.request_kind.as_str(),
|
||||
"final-reply" | "steer-decision"
|
||||
) {
|
||||
// 收集成功就等于这一轮不再改动连接:解除守卫、注销回合(与改动前是同一刻)。
|
||||
// 收集失败时守卫保持 armed,自有连接交给 `CodexTurnGuard` 回收。
|
||||
if collect_result.is_ok() {
|
||||
guard.armed = false;
|
||||
self.inner.turns.lock().await.remove(&turn_id);
|
||||
}
|
||||
// 解析是这一轮的一部分,而且排在终态之前:structured output 非法同样是这一轮的失败,
|
||||
// 必须落进终态载荷。以前终态先写、再解析,于是这条 `Err` 谁都不接——终态已经是
|
||||
// `completed`,兜底的 `finish_if_unfinished` 变成空操作,用户看到的是"本轮结束、没有
|
||||
// 回复、没有任何解释"。
|
||||
let turn_result: Result<DirectTurnReport, DirectTurnRunFailure> = match collect_result {
|
||||
Ok(text) => match parse_game_creator_codex_app_server_text(&text, &thread_id, &request)
|
||||
{
|
||||
Ok(response) => Ok(DirectTurnReport { text, response }),
|
||||
Err(error) => Err(DirectTurnRunFailure::Failed(error)),
|
||||
},
|
||||
Err(failure) => Err(failure),
|
||||
};
|
||||
// 线程释放也排在终态之前:只有真的拿到响应才释放(与改动前一致)。
|
||||
if turn_result.is_ok()
|
||||
&& matches!(
|
||||
snapshot.request_kind.as_str(),
|
||||
"final-reply" | "steer-decision"
|
||||
)
|
||||
{
|
||||
self.release_thread(snapshot, &thread_id).await;
|
||||
}
|
||||
Ok(response)
|
||||
// 终态的**唯一**写点:解析与线程释放都定型之后才写,成功失败都从这里出去。
|
||||
// `RepairRequired`(封口复核要求继续当前返修批次)是控制流:这一轮还没结束,不写终态。
|
||||
if let Some(context) = direct_terminal.as_ref() {
|
||||
let (report, failure) = match &turn_result {
|
||||
Ok(report) => (Some(report.text.as_str()), None),
|
||||
Err(failure) => (None, Some(failure)),
|
||||
};
|
||||
if let Some(collect_outcome) = direct_turn_terminal_write(report, failure) {
|
||||
context.write(collect_outcome, history_root);
|
||||
}
|
||||
}
|
||||
turn_result.map(|report| report.response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4214,19 +4236,65 @@ impl DirectTurnRunFailure {
|
||||
|
||||
/// 这一轮要不要写终态、写什么内容。
|
||||
///
|
||||
/// - `Ok(text)`:正常终态(文本交给 `direct_turn_terminal` 判定);
|
||||
/// - `Err(Failed)`:失败终态,载荷从这条 typed 错误投影;
|
||||
/// - `Err(RepairRequired)`:**不写**——"继续当前返修批次"是控制流,这一轮还没结束。写成 `failed`
|
||||
/// 会让界面收到一条假失败,而且同一个逻辑回合稍后还会再写一条终态。
|
||||
fn direct_turn_terminal_write(
|
||||
collect_result: &Result<String, DirectTurnRunFailure>,
|
||||
) -> Option<Result<&str, DirectTurnError>> {
|
||||
match collect_result {
|
||||
Ok(report) => Some(Ok(report.as_str())),
|
||||
Err(DirectTurnRunFailure::RepairRequired { .. }) => None,
|
||||
Err(DirectTurnRunFailure::Failed(error)) => {
|
||||
/// - `report` 有值:正常终态(报告正文交给 `direct_turn_terminal` 兜底判定);
|
||||
/// - `failure` 是 `Failed`:失败终态,载荷从这条 typed 错误投影;
|
||||
/// - `failure` 是 `RepairRequired`:**不写**——"继续当前返修批次"是控制流,这一轮还没结束。写成
|
||||
/// `failed` 会让界面收到一条假失败,而且同一个逻辑回合稍后还会再写一条终态。
|
||||
fn direct_turn_terminal_write<'a>(
|
||||
report: Option<&'a str>,
|
||||
failure: Option<&DirectTurnRunFailure>,
|
||||
) -> Option<Result<&'a str, DirectTurnError>> {
|
||||
match failure {
|
||||
Some(DirectTurnRunFailure::Failed(error)) => {
|
||||
Some(Err(DirectTurnError::from_model_call(error)))
|
||||
}
|
||||
Some(DirectTurnRunFailure::RepairRequired { .. }) => None,
|
||||
None => report.map(Ok),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一轮真正结束时的收尾结果:终态要的**报告正文**和交给调用方的**解析结果**。
|
||||
///
|
||||
/// 两者一起带出来是刻意的:账本读不出来时终态兜底要用报告正文,而报告正文就是被解析的那份
|
||||
/// 文本;分开持有会让"写终态"重新跑到解析之前(正是这次要改掉的顺序)。
|
||||
struct DirectTurnReport {
|
||||
/// 可展示的回复 / 交付报告正文。
|
||||
text: String,
|
||||
/// 解析后的响应。
|
||||
response: platform_llm::LlmRunResponse,
|
||||
}
|
||||
|
||||
/// Direct 回合终态的上下文:判定所需的**事实**在收尾时固定,写点留到整轮结束之后。
|
||||
///
|
||||
/// 分两步是刻意的:终态必须在解析 / 线程释放都定型之后才写,否则"终态写完又失败"的回合在协议上
|
||||
/// 无解——前端只会看到一次没有解释的"已结束"。
|
||||
struct DirectTurnTerminalContext {
|
||||
/// 收尾阶段按 ledger 阶段推出来的 `status`,只作兜底(失败判定由事实决定)。
|
||||
status: String,
|
||||
/// 终态事件的宿主观测时刻。
|
||||
completed_at: u64,
|
||||
/// 宿主自己记下的失败(执行通道断开 / 等待超时 / app-server 单方面中断)。
|
||||
host_failure: Option<DirectTurnError>,
|
||||
/// 逻辑回合身份与开口的用户条目身份。
|
||||
thread_id: String,
|
||||
user_item_id: Option<String>,
|
||||
}
|
||||
|
||||
impl DirectTurnTerminalContext {
|
||||
/// 写下这一轮的终态。`collect_outcome` 是 [`direct_turn_terminal_write`] 的投影结果:
|
||||
/// `Ok(报告)` 正常结束,`Err(失败)` 带失败载荷。
|
||||
fn write(&self, collect_outcome: Result<&str, DirectTurnError>, history_root: &Path) {
|
||||
let terminal = direct_turn_terminal(
|
||||
&self.status,
|
||||
collect_outcome,
|
||||
self.host_failure.as_ref(),
|
||||
history_root,
|
||||
);
|
||||
// 终态走 Thread Manager 的深出口:解除这一轮的占用并写下 `turn.completed`。
|
||||
complete_direct_thread_turn(
|
||||
&self.thread_id,
|
||||
terminal.event(self.completed_at, self.user_item_id.as_deref()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6140,24 +6208,36 @@ mod tests {
|
||||
/// `validation-source-changed:` 那条 `InvalidRequest`,于是"继续返修"会被讲成一次用户可见的失败。
|
||||
#[test]
|
||||
fn terminal_write_skips_the_repair_request_and_projects_real_failures() {
|
||||
let repair: Result<String, DirectTurnRunFailure> =
|
||||
Err(DirectTurnRunFailure::RepairRequired {
|
||||
detail: "继续当前返修批次".into(),
|
||||
});
|
||||
let repair = DirectTurnRunFailure::RepairRequired {
|
||||
detail: "继续当前返修批次".into(),
|
||||
};
|
||||
assert!(
|
||||
direct_turn_terminal_write(&repair).is_none(),
|
||||
direct_turn_terminal_write(None, Some(&repair)).is_none(),
|
||||
"返修要求是控制流,不允许写终态"
|
||||
);
|
||||
|
||||
let failed: Result<String, DirectTurnRunFailure> = Err(DirectTurnRunFailure::Failed(
|
||||
platform_llm::LlmError::Transport("执行通道已断开".into()),
|
||||
let failed = DirectTurnRunFailure::Failed(platform_llm::LlmError::Transport(
|
||||
"执行通道已断开".into(),
|
||||
));
|
||||
let payload = direct_turn_terminal_write(&failed).expect("真失败必须写终态");
|
||||
let payload = direct_turn_terminal_write(None, Some(&failed)).expect("真失败必须写终态");
|
||||
let error = payload.expect_err("失败终态必须带载荷");
|
||||
assert_eq!(error.wire_kind(), Some("transport-failed"));
|
||||
|
||||
let report: Result<String, DirectTurnRunFailure> = Ok("本轮交付已完成".into());
|
||||
let payload = direct_turn_terminal_write(&report).expect("正常收尾要写终态");
|
||||
// 解析失败(structured output 非法)也走这条投影:以前解析排在终态**之后**,
|
||||
// 于是这条 Err 谁都不接——终态已经是 `completed`,兜底的 `finish_if_unfinished`
|
||||
// 变成空操作,用户只看到"本轮结束、没有回复、没有任何解释"。
|
||||
let parse_failed = DirectTurnRunFailure::Failed(platform_llm::LlmError::Deserialize(
|
||||
"Codex app-server structured output 不是严格 JSON".into(),
|
||||
));
|
||||
let payload =
|
||||
direct_turn_terminal_write(None, Some(&parse_failed)).expect("解析失败必须写终态");
|
||||
assert_eq!(
|
||||
payload.expect_err("解析失败必须带载荷").wire_kind(),
|
||||
Some("model-failed")
|
||||
);
|
||||
|
||||
let payload =
|
||||
direct_turn_terminal_write(Some("本轮交付已完成"), None).expect("正常收尾要写终态");
|
||||
assert_eq!(payload.expect("正常收尾不带失败载荷"), "本轮交付已完成");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user