修复 cc 回合"模型已回复却报宿主任务提前结束",并放行宿主 MCP 工具
Project CI / AI game creator shell Rust crates (push) Successful in 1m45s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / Backend tests (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 / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled

- thread_manager/dispatch.rs 在放行成功出口补写 DirectTurnTerminal::completed():cc 执行器没有 codex app-server 那条深层终态出口,否则占用对象兜底会把已拿到回复的回合收成 HostDropped
- direct_turn_failure.rs 新增 completed() 终态(status=completed、无失败载荷)与对应单测
- claude_code_cli.rs 在 cc 解析成功后把回复按 response_item/assistant 落进 .agent/conversations/project.jsonl(id=direct-codex:<clientTurnId>:assistant),落盘失败按回合失败收口
- agent-sidecar 按 mcp__<server> 前缀放行请求声明的 MCP 服务器,避免 dontAsk 模式下宿主工具被直接拒绝
- dispatch.rs 的 host_dropped / panic 诊断字段改用 tt=,不再被日志脱敏整行吃掉(原 turnToken= 命中凭据标记)
- pitfalls.md 记录三条根因、现行口径、诊断口径与 CDP 注入验收证据
This commit is contained in:
kdletters
2026-10-03 11:27:41 +08:00
parent d90f4fdfdc
commit 3f0895fa41
5 changed files with 71 additions and 4 deletions
@@ -56,6 +56,12 @@ function buildOptions(request, controller) {
if (request.sessionId) options.sessionId = request.sessionId;
if (request.mcpServers && typeof request.mcpServers === 'object') {
options.mcpServers = request.mcpServers;
// `dontAsk` 模式下没有显式放行的工具会被直接拒绝(真实案例:2026-10-03 cc 回合里模型想读
// 项目文件,`agc_read_project_context` 被拒,用户只拿到一句"没有权限")。宿主暴露的 MCP
// 本身就是 AGC 的工具边界(权限策略在宿主侧执行),这里按服务器前缀整体放行。
options.allowedTools = Object.keys(request.mcpServers).map(
(name) => `mcp__${name}`,
);
}
if (request.outputSchema) {
options.outputFormat = {
@@ -979,7 +979,27 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
direct_turn_trace("claude-parse-start");
let parsed = parse_direct_stream_result(stream.join("\n").as_bytes(), observer);
match &parsed {
Ok(text) => direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())),
Ok(text) => {
direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count()));
// 项目对话历史是这条对话的单一事实源:回复不落盘,UI 就看不到本轮结果。codex 路径由
// app-server 的 collect-history 负责写 assistant 条目,cc 没有那一步——只补终态会让
// 用户看到"回合结束但没有回复"。落盘失败按回合失败收口,不吞。
let item_id = match client_turn_id {
Some(client_turn_id) => format!("direct-codex:{client_turn_id}:assistant"),
None => format!("direct-codex:{}:assistant", uuid::Uuid::new_v4()),
};
let item = serde_json::json!({
"type": "message",
"role": "assistant",
"id": item_id,
"content": [{ "type": "output_text", "text": text }],
});
if let Err(error) = append_direct_project_history_item_at(root, &item) {
eprintln!("[agc-cc-direct] persist assistant failed: {error}");
direct_turn_trace("claude-parse-error");
return Err(format!("写入本项目对话历史失败:{error}"));
}
}
Err(error) => {
// 这一段必须走 stderr:AppData 日志的脱敏会把含 turnToken= 的整行替换掉,
// 收尾失败时看不到任何原因(真实案例:2026-10-02 18:19 模型已回复但回合被丢弃)。
@@ -35,6 +35,17 @@ pub(crate) struct DirectTurnTerminal {
}
impl DirectTurnTerminal {
/// 一次成功终态:只带 `status="completed"`,不带失败载荷。
///
/// 供给没有"深层终态出口"的执行器(cc / Claude Code sidecar)用:它们整轮成功返回后,
/// 线程仍被放行占用,必须由放行侧补写这条终态。
pub(crate) fn completed() -> Self {
Self {
status: "completed".to_string(),
failure: None,
}
}
/// 终态事件:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。
pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> ThreadEvent {
let event = match self.failure {
@@ -258,4 +269,15 @@ stderrClass=nonempty;stderrBytes=1000";
assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped);
assert!(!failure.message.trim().is_empty());
}
#[test]
fn completed_terminal_carries_no_failure_payload() {
let terminal = DirectTurnTerminal::completed();
assert_eq!(terminal.status, "completed");
assert!(terminal.failure.is_none());
assert!(matches!(
terminal.event(1_700_000_000_000, Some("item-1")),
ThreadEvent::TurnCompleted { ref status, .. } if status == "completed"
));
}
}
@@ -98,14 +98,17 @@ impl Drop for TurnReservation {
// 不必只依赖 AppData 的应用日志。
let failure = DirectTurnError::TurnFailed {
stage: DirectCodexFailureStage::CodeGeneration,
// 字段名用 `tt`(不是 `turnToken`):`turnToken=` 会命中应用日志的凭据标记,
// 整行被替换成 `<sensitive diagnostic details redacted>`,离线就只剩一个
// 说不出原因的 HostDropped。
detail: format!(
"DirectProject 宿主任务提前结束(panic、future 被丢弃或被取消),本轮未写下终态;threadId={} turnToken={}",
"DirectProject 宿主任务提前结束(panic、future 被丢弃或被取消),本轮未写下终态;threadId={} tt={}",
self.thread_id, self.token
),
};
let _ = record_direct_codex_failure(Path::new(&self.thread_id), &failure, None);
app_log!(
"agent.direct_turn.host_dropped threadId={} turnToken={} userItemId={} panicking={}",
"agent.direct_turn.host_dropped threadId={} tt={} userItemId={} panicking={}",
self.thread_id,
self.token,
self.user_item_id.as_deref().unwrap_or("-"),
@@ -149,7 +152,7 @@ fn ensure_direct_turn_panic_hook() {
.unwrap_or_else(|| "未知位置".to_string());
#[cfg(not(test))]
app_log!(
"agent.direct_turn.panic threadId={} turnToken={} location={} payload={}",
"agent.direct_turn.panic threadId={} tt={} location={} payload={}",
thread_id,
token,
location,
@@ -270,6 +273,11 @@ async fn run_dispatched_direct_turn(
Ok(_) => {
// 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。
emitter.emit("completed", Some("none"));
// cc(Claude Code sidecar)执行器没有 app-server 那条"深层终态出口":整轮成功返回后
// 线程仍被这条放行占用,不补写终态的话,占用对象 Drop 时的兜底会把一轮已经拿到回复的
// 回合收成 `HostDropped`(真实案例:2026-10-03 `claude-parse-done chars=176` 之后立刻
// `host_dropped panicking=false`)。codex 路径已写过终态,这里是空操作。
reservation.finish_if_unfinished(DirectTurnTerminal::completed());
}
Err(error) => {
// 放行之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。
@@ -6269,3 +6269,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **注意**:日志脱敏标记包含 `credential`、`x-api-key`、`bearer `、`token=`、`api_key`,命中即整行替换成 `<sensitive diagnostic details redacted>`。诊断行只能写 `source=`/`host=`/`auth=` 这类自查过的字段(第一版写成 `credential=bearer`,整行被吃掉过一次)。
- **验证**:`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell claude_code_cli::tests::` 11 passed,覆盖平台优先、自定义端点保留配置、未登录时不回落本机环境且报「先登录」、baseUrl `/v1` 归一化、主机名诊断、sidecar 环境隔离(`USERPROFILE`/`CLAUDE_CONFIG_DIR` 指向隔离目录且不带本机 `ANTHROPIC_*`);另用本地假 Anthropic 端点跑通真 sidecar:`REQ HEAD /api/hello` → `REQ POST /v1/messages?beta=true auth=bearer` → `result=PROBE_OK`。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`、[`【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`](../../technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md)。
## 2026-10-03 cc 回合"模型已回复却报宿主任务提前结束":缺终态 + 缺落盘 + 工具被拒
- **现象**:Router 渠道恢复后,cc 回合能拿到真实回复(日志 `stage=claude-parse-done chars=176/208/289`),但紧接着就是 `runtime-unclassified` +「DirectProject 宿主任务提前结束(panic、future 被丢弃或被取消)」,用户看到的仍是失败;项目对话历史里只有用户消息、没有助手消息;模型还会回一句「读项目文件的工具没有权限」。
- **根因 1(缺终态)**:终态 `turn.completed` 的"深层出口"只在 codex app-server 那条路径里(`DirectTurnTerminalContext::write` → `complete_turn`)。cc 执行器(`direct_game_creator_claude_code_chat_at`)整轮成功返回后没有任何人写终态,于是 `thread_manager::dispatch` 里占用对象的兜底把一轮已经拿到回复的回合收成 `HostDropped`。诊断行 `agent.direct_turn.host_dropped … panicking=false` 且**没有** `agent.direct_turn.panic` 行,就是这条(不是 panic,是"没写终态就结束")。
- **根因 2(缺落盘)**:assistant 回复只存在于 SDK 事件流里。codex 路径由 `finish_direct_project_collect_history` 写进 `.agent/conversations/project.jsonl`,cc 没有对应步骤,所以即使回合收口成功,UI 也读不到回复。
- **根因 3(工具被拒)**:sidecar 用 `permissionMode: 'dontAsk'` 且没有 `allowedTools`,宿主 MCP 工具(`mcp__agc__*`)一律被直接拒绝,模型只能回"没有权限"。
- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 解析成功后必须把回复按 `{"type":"message","role":"assistant","id":"direct-codex:<clientTurnId>:assistant","content":[{"type":"output_text","text":…}]}` 落进项目历史,落盘失败按回合失败收口;sidecar 按 `mcp__<server>` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。
- **诊断口径**:`agent.direct_turn.host_dropped` / `agent.direct_turn.panic` 里的令牌字段必须写 `tt=`,写 `turnToken=` 会命中脱敏标记,整行变成 `<sensitive diagnostic details redacted>`,离线只剩"说不出原因"的 HostDropped。
- **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs <expr>`):读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`<!doctype html>` / `<html lang="zh-CN">`)逐字一致;同一日志里不再出现新的 `host_dropped`。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`。