宿主:接单之后的落盘失败不再从命令返回 Err

- `chat_with_game_creator_direct_codex_typed` 在接单后的历史追加写失败时仍写失败终态,但返回 `Ok(())`:命令的 `Err` 只表示拒单,同一个失败不该从事件与横幅两条通道下发,前端也不该把已经开始的回合读成没开始
- 不继续起整轮:`project.jsonl` 是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口用户消息的助手回复
- 补 Rust 用例 `a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting`:借历史追加写的测试注入钉住恰好一条失败终态、不带拒单收口文案、占用已释放
- 补前端用例:落盘失败的说明只来自事件且恰好一条,忙态放掉,下一条能直接发出去
This commit is contained in:
2026-09-24 16:51:44 +08:00
parent ab970b9fdb
commit cd5feac5fe
2 changed files with 142 additions and 1 deletions
@@ -114,6 +114,11 @@ async fn chat_with_game_creator_direct_codex_typed(
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,
@@ -122,7 +127,7 @@ async fn chat_with_game_creator_direct_codex_typed(
),
};
reservation.finish_if_unfinished(DirectTurnTerminal::failed(root, &failure));
return Err(failure);
return Ok(());
}
let capture = crate::analytics::gui::capture_writer_context();
let root = root.to_path_buf();
@@ -257,4 +262,81 @@ mod tests {
);
assert_eq!(user_item_id.as_deref(), Some("direct-codex:turn-1:user"));
}
/// 接单之后的落盘失败:**只走占用对象的失败终态**,命令返回 `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());
}
}
@@ -650,6 +650,65 @@ export function registerChatComposerControlTests() {
).toHaveLength(0);
});
it('keeps the composer moving when the host fails the turn right after accepting it', async () => {
// 落盘失败发生在**接单之后**:终态事件先写进队列,命令随后返回 `Ok`(不再用 `Err` 下发同一个
// 失败)。这里钉住这条时序的界面结果:说明只来自事件、恰好一条,忙态被放掉,下一条还能发。
const seen: string[] = [];
let harness: ReturnType<typeof createProjectChatRuntimeHarness> | null =
null;
const { surface } = await openDirectCodexSurface(
{
chat_with_game_creator_direct_codex: (
args: Record<string, unknown> | undefined,
) => {
const text = directTurnInputText(args);
seen.push(text);
if (text === '落盘失败的那条') {
const userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`;
harness?.emitDirectThreadEvents(
{ type: 'turn.started', at: 5_000, userItemId },
{
type: 'turn.completed',
status: 'failed',
failure: {
kind: 'environment-not-ready',
message: '写入本项目对话历史失败:项目对话历史追加写失败',
},
at: 5_100,
userItemId,
},
);
}
return Promise.resolve(null);
},
},
(directHarness) => {
harness = directHarness;
},
);
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '落盘失败的那条');
const conversation = await within(surface).findByLabelText('陶泥儿消息');
// 说明来自 `turn.completed.failure`,经同一份可见文案映射;命令返回 `Ok` 不再写第二条。
await waitFor(() => {
expect(
within(conversation).getAllByText(
'陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试',
),
).toHaveLength(1);
});
// 忙态已放掉、也没卡成忙碌:下一条能直接发出去。
await waitFor(() => {
expect(
within(conversation).queryAllByText('陶泥儿正在处理'),
).toHaveLength(0);
});
await submitDirectTurn(surface, composer, '后面这条');
await waitFor(() => {
expect(seen).toEqual(['落盘失败的那条', '后面这条']);
});
});
it('keeps the next queued turn busy when the write gate refuses the running one', async () => {
const pending: Array<{ resolve: (value: string) => void }> = [];
const deferredPolicies: Array<(value: unknown) => void> = [];