Claude Code Direct 回合补 sidecar 静默与超时收口诊断
Project CI / AI game creator shell Rust crates (push) Successful in 1m29s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m39s
Project CI / Backend tests (push) Failing after 3m28s
Project CI / Frontend tests (push) Successful in 1m51s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell 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 2/2 (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled

- claude_code_cli:sidecar stdout 静默 30 秒就写一条 agent.direct_codex.claude_sidecar_idle,记录已静默秒数
- claude_code_cli:超时收口先杀 sidecar 进程树,再给 stderr 一个 5 秒排空窗口,避免回合永远不肯收口
- claude_code_cli:超时详情过滤掉宿主主动断流后 sidecar 自己抛的 EPIPE 收尾噪声
- claude_code_cli:sidecar 返回 error 事件时同样杀进程树,不再留下孤儿 Claude Code 进程
This commit is contained in:
kdletters
2026-10-02 14:34:52 +08:00
parent 72165c3891
commit 5dc595db58
@@ -17,6 +17,18 @@ const CLAUDE_NODE_ENV: &str = "GENARRATIVE_CLAUDE_NODE";
const CLAUDE_CODE_OUTPUT_MAX_BYTES: usize = 8 * 1024 * 1024;
const CLAUDE_CODE_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024;
const CLAUDE_CODE_MODEL_MAX_CHARS: usize = 200;
/// 超时收口时给 sidecar stderr 的排空窗口。
///
/// sidecar 自己还活着时 stderr 永远等不到 EOF(Claude Code CLI 正在网关侧重试),
/// 没有上限就会让超时分支自己挂住:这一轮既写不出终态,也留不下任何诊断
/// (2026-10-02 实测:回合 4 分钟不结束、应用日志一行不写)。
const CLAUDE_CODE_STDERR_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
/// sidecar stdout 静默多久就写一条"还没收到事件"的应用日志。
///
/// Claude Code CLI 在网关侧失败时会自己退避重试,这段时间**一行事件都没有**;
/// 不留痕的话用户与离线诊断只能看到一个卡住的回合(真实案例:ECONNRESET 重试,
/// 每轮 ~60 秒,共 10 轮)。
const CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT: Duration = Duration::from_secs(30);
struct ActiveClaudeCodeTurn {
client_turn_id: String,
@@ -216,11 +228,26 @@ async fn run_sidecar_turn(
let mut lines = BufReader::new(stdout).lines();
let mut events = Vec::new();
let mut terminal = None;
while let Some(line) = lines
.next_line()
.await
.map_err(|error| format!("读取 Claude Agent SDK sidecar 输出失败:{error}"))?
{
let mut idle_rounds = 0_u64;
loop {
let line =
match tokio::time::timeout(CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT, lines.next_line())
.await
{
Ok(Ok(Some(line))) => line,
Ok(Ok(None)) => break,
Ok(Err(error)) => {
return Err(format!("读取 Claude Agent SDK sidecar 输出失败:{error}"));
}
Err(_) => {
idle_rounds += 1;
app_log!(
"agent.direct_codex.claude_sidecar_idle idleSeconds={}",
CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT.as_secs() * idle_rounds
);
continue;
}
};
if line.trim().is_empty() {
continue;
}
@@ -237,6 +264,7 @@ async fn run_sidecar_turn(
break;
}
Some("error") => {
kill_claude_code_process_tree(pid);
return Err(value
.get("message")
.and_then(serde_json::Value::as_str)
@@ -260,13 +288,19 @@ async fn run_sidecar_turn(
{
Ok(result) => result?,
Err(_) => {
let detail = stderr_task
.await
.ok()
.and_then(Result::ok)
.and_then(|bytes| String::from_utf8(bytes).ok())
// 先杀进程树:不杀的话 sidecar(连同它守护的 Claude Code CLI)会一直活着,
// stderr 等不到 EOF,这一轮就永远出不了终态。
kill_claude_code_process_tree(pid);
let stderr_detail =
match tokio::time::timeout(CLAUDE_CODE_STDERR_DRAIN_TIMEOUT, stderr_task).await {
Ok(Ok(Ok(bytes))) => String::from_utf8(bytes).ok(),
_ => None,
}
.map(|value| value.trim().chars().take(512).collect::<String>())
.filter(|value| !value.is_empty())
// 断开 stdout 之后 sidecar 自己会抛 EPIPE;那是收尾噪声,不是失败原因。
.filter(|value| !value.contains("EPIPE"));
let detail = stderr_detail
.map(|value| format!(":{value}"))
.unwrap_or_default();
return Err(format!("Claude Agent SDK sidecar 回合超时{detail}"));