给 Direct 回合的静默早退补上诊断留痕

TurnReservation 兜底收口时写应用日志,并用 thread::panicking 区分 panic 与 future 被丢弃/取消
新增 Direct 回合 panic hook:task-local 携带回合身份,panic 时记录位置与负载
两处均 cfg(not(test)),不污染用例与本机 AppData
This commit is contained in:
kdletters
2026-10-02 13:47:36 +08:00
parent e3b4127440
commit a59d068c1d
@@ -84,13 +84,80 @@ impl Drop for TurnReservation {
fn drop(&mut self) {
// 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。
// 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。
let _ = self.finish_if_unfinished(DirectTurnTerminal::host_dropped());
let finalized_by_guard = self.finish_if_unfinished(DirectTurnTerminal::host_dropped());
// 兜底一旦真的收口,就说明这一轮**从未写下终态**:深层既没成功也没失败地退出了。
// 这条必须留应用日志,否则离线只剩一个 `phase=working` 的账本,无从判断是哪一层
// 提前退出(真实案例:2026-10-02 连续两轮只留下 working 账本,errors/ 与
// application.log 全空)。测试态不写,避免用例污染本机 AppData。
#[cfg(not(test))]
if finalized_by_guard {
let _ = crate::append_application_log(
"error".to_string(),
"direct-turn".to_string(),
format!(
"agent.direct_turn.host_dropped threadId={} turnToken={} userItemId={} panicking={}",
self.thread_id,
self.token,
self.user_item_id.as_deref().unwrap_or("-"),
// true = 这一轮是被 panic 带走的;false = future 被丢弃或取消。
std::thread::panicking()
),
);
}
// 占用的释放就是"这个项目腾出了跑回合的位置":踢一脚,队里排着的消息不必等下一次用户动作。
// panic 也走这里,所以队列不会因为一个任务炸掉而永久停住。
kick_queue_dispatch(Path::new(&self.thread_id));
}
}
// 运行段经 task-local 携带回合身份,panic hook 据此把 panic 位置与负载写进应用日志。
// task-local 而非 thread-local:多线程 runtime 下 future 会跨 worker 迁移。
tokio::task_local! {
static DIRECT_TURN_PANIC_CONTEXT: Option<(String, String)>;
}
/// 兜底诊断:Direct 回合任务 panic 时,把位置与负载写进应用日志。
///
/// 没有它时,panic 只会让 `TurnReservation::drop` 把回合收成 `HostDropped`——那是"说不出原因"
/// 的分类,离线只留一个 `phase=working` 的账本,`.agent/runtime/errors/` 与 `application.log`
/// 全空,无法判断是哪一层退出的(真实案例:2026-10-02 连续两轮如此)。
fn ensure_direct_turn_panic_hook() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if let Ok(Some((thread_id, token))) = DIRECT_TURN_PANIC_CONTEXT.try_with(Clone::clone) {
let location = info
.location()
.map(|location| {
format!(
"{}:{}:{}",
location.file(),
location.line(),
location.column()
)
})
.unwrap_or_else(|| "未知位置".to_string());
#[cfg(not(test))]
let _ = crate::append_application_log(
"error".to_string(),
"direct-turn".to_string(),
format!(
"agent.direct_turn.panic threadId={} turnToken={} location={} payload={}",
thread_id,
token,
location,
info.payload_as_str().unwrap_or("未知 panic 负载")
),
);
#[cfg(test)]
let _ = (thread_id, token, location);
}
previous(info);
}));
});
}
/// 踢一脚:让队首那条待发消息有机会变成一轮真的回合。
///
/// 幂等,三个调用点:**入队之后**(队列空且没有回合在跑时,放行就是这一脚,不必再等一个调度周期)、
@@ -109,10 +176,12 @@ pub(crate) fn kick_queue_dispatch(root: &Path) {
crate::platform_session::current_platform_session_write_state().identity_generation;
let reservation = TurnReservation::resume(&thread_id, &dispatched);
let root = root.to_path_buf();
tauri::async_runtime::spawn(async move {
ensure_direct_turn_panic_hook();
let panic_context = Some((thread_id.clone(), dispatched.token.clone()));
tauri::async_runtime::spawn(DIRECT_TURN_PANIC_CONTEXT.scope(panic_context, async move {
run_dispatched_direct_turn(root, dispatched, reservation, release_identity_generation)
.await;
});
}));
}
/// 放行之后这一段:取这一轮的调用身份 → 落盘用户条目 → 下发 → 跑整轮。