fix(claude): detect interrupted turns reliably - #41
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthrough该变更将 Codex rollout 观察器替换为统一的 transcript observer。它新增 Claude Code transcript 解析与 session 状态轮询,并把 Hook 转发、配置同步和前端事件处理扩展到 Changes转录观察器迁移
Estimated code review effort: 4 (Complex) | ~75 minutes Sequence Diagram(s)sequenceDiagram
participant HookServer
participant TranscriptObserver
participant TranscriptFile
participant FrontendState
HookServer->>TranscriptObserver: handle_hook_event(event, transcript_path)
TranscriptObserver->>TranscriptFile: 轮询并解析新增 transcript 或 session 状态
TranscriptFile-->>TranscriptObserver: Completed 或 Interrupted
TranscriptObserver->>FrontendState: 发出 Stop 或 TurnInterrupted
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src-tauri/src/hook_server/transcript_observer.rs`:
- Around line 248-275: The observe loop in transcript_observer::observe is
holding the entries lock while running poll_entry and emit_event, which blocks
hook processing. Update the entries.lock() section to only collect a snapshot of
active entries to poll, then release the lock before any file IO, Claude session
reads, or event emission. After poll_entry returns, reacquire the lock only for
mark_terminal/write-back if needed, and keep emit_event outside the locked
scope.
- Around line 337-345: 在 WatchEntry 中新增 session_lookup_at: Option<Instant>,并定义
CLAUDE_SESSION_LOOKUP_BACKOFF 为 5 秒;更新 poll_claude_session 中对
claude_session_path 为空时的查找逻辑,仅在从未查找或距离上次查找已超过退避间隔时调用
find_claude_session_path,并记录本次查找时间,避免每轮轮询重复扫描会话目录。
In `@src-tauri/src/hook_server/transcript_observer/claude_code.rs`:
- Around line 115-152: Remove the directory-level `.take(128)` from
`find_session_path` so all session entries are scanned. Keep the existing cheap
filename and PID validation first, then limit only the expensive `canonicalize`
and `read_session_file` processing to `MAX_INSPECTED_SESSION_FILES` candidates,
defining and using that constant to enforce the cap.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 5f1126f9-cf36-41f0-bf36-191925160c77
📒 Files selected for processing (12)
src-tauri/src/hook_installer.rssrc-tauri/src/hook_installer/claude_code.rssrc-tauri/src/hook_server.rssrc-tauri/src/hook_server/rollout_observer.rssrc-tauri/src/hook_server/transcript_observer.rssrc-tauri/src/hook_server/transcript_observer/claude_code.rssrc-tauri/src/hook_server/transcript_observer/codex.rssrc-tauri/src/lib.rssrc/agents/claude-code.tssrc/agents/registry.test.tssrc/live-status.test.tssrc/reaction-controller.test.ts
💤 Files with no reviewable changes (1)
- src-tauri/src/hook_server/rollout_observer.rs
| fn poll_claude_session(entry: &mut WatchEntry, now: Instant) -> Option<TranscriptTerminal> { | ||
| if entry.agent != hook_installer::CLAUDE_CODE { | ||
| return None; | ||
| } | ||
| if entry.claude_session_path.is_none() { | ||
| entry.claude_session_path = find_claude_session_path(&entry.session_id); | ||
| } | ||
| let activity = | ||
| claude_code::session_activity(entry.claude_session_path.as_deref()?, &entry.session_id)?; |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win
claude_session_path 为 None 时每轮都会重新扫描会话目录。
第 341-343 行在 claude_session_path 为 None 时调用 find_claude_session_path。该函数会 read_dir 会话目录,最多处理 128 个条目,并对每个条目执行 canonicalize 与文件读取。如果会话文件始终不存在(例如 Claude 未写入该会话,或 session_id 不匹配),查找会一直失败,于是每 200 毫秒都会重复整次扫描,并且每个 Claude 条目各扫描一次。
建议记录上次查找时间,只在退避间隔之后重试,避免持续的目录扫描。
♻️ 建议的退避实现
fn poll_claude_session(entry: &mut WatchEntry, now: Instant) -> Option<TranscriptTerminal> {
if entry.agent != hook_installer::CLAUDE_CODE {
return None;
}
if entry.claude_session_path.is_none() {
+ // 仅在退避间隔到期后重试,避免每轮轮询都扫描会话目录。
+ if entry
+ .session_lookup_at
+ .is_some_and(|last| now.duration_since(last) < CLAUDE_SESSION_LOOKUP_BACKOFF)
+ {
+ return None;
+ }
+ entry.session_lookup_at = Some(now);
entry.claude_session_path = find_claude_session_path(&entry.session_id);
}需要在 WatchEntry 中新增 session_lookup_at: Option<Instant> 字段,并新增常量:
const CLAUDE_SESSION_LOOKUP_BACKOFF: Duration = Duration::from_secs(5);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/src/hook_server/transcript_observer.rs` around lines 337 - 345, 在
WatchEntry 中新增 session_lookup_at: Option<Instant>,并定义
CLAUDE_SESSION_LOOKUP_BACKOFF 为 5 秒;更新 poll_claude_session 中对
claude_session_path 为空时的查找逻辑,仅在从未查找或距离上次查找已超过退避间隔时调用
find_claude_session_path,并记录本次查找时间,避免每轮轮询重复扫描会话目录。
| pub(super) fn find_session_path(config_root: &Path, session_id: &str) -> Option<PathBuf> { | ||
| let sessions_root = config_root.join("sessions").canonicalize().ok()?; | ||
| let mut latest: Option<(u64, PathBuf)> = None; | ||
| for item in fs::read_dir(&sessions_root).ok()?.take(128) { | ||
| let Ok(item) = item else { continue }; | ||
| let path = item.path(); | ||
| let Some(file_name) = path.file_name() else { | ||
| continue; | ||
| }; | ||
| let file_name = file_name.to_string_lossy(); | ||
| let Some(pid) = file_name.strip_suffix(".json") else { | ||
| continue; | ||
| }; | ||
| if pid.is_empty() || !pid.bytes().all(|byte| byte.is_ascii_digit()) { | ||
| continue; | ||
| } | ||
| let Ok(canonical) = path.canonicalize() else { | ||
| continue; | ||
| }; | ||
| if !canonical.starts_with(&sessions_root) { | ||
| continue; | ||
| } | ||
| let Some(state) = read_session_file(&canonical) else { | ||
| continue; | ||
| }; | ||
| if state.session_id != session_id { | ||
| continue; | ||
| } | ||
| let updated_at = state.status_updated_at.max(state.updated_at); | ||
| if latest | ||
| .as_ref() | ||
| .is_none_or(|(latest_at, _)| updated_at > *latest_at) | ||
| { | ||
| latest = Some((updated_at, canonical)); | ||
| } | ||
| } | ||
| latest.map(|(_, path)| path) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
take(128) 限制的是目录项总数,会漏掉目标会话文件。
第 118 行对 read_dir 的迭代器调用 .take(128),因此只检查会话目录中的前 128 个条目。read_dir 的返回顺序未定义,不保证按名称或修改时间排序。Claude Code 的 sessions 目录按进程 ID 命名,长期使用会累积文件。当条目数超过 128 时,目标会话文件可能排在被截断的部分,find_session_path 返回 None,本 PR 的 Claude idle 中断兜底检测对该会话静默失效。
建议先用廉价的文件名过滤扫描全部条目,只对通过过滤的条目执行 canonicalize 与文件读取,并对这些昂贵操作单独计数限流。
🐛 建议的修复
pub(super) fn find_session_path(config_root: &Path, session_id: &str) -> Option<PathBuf> {
let sessions_root = config_root.join("sessions").canonicalize().ok()?;
let mut latest: Option<(u64, PathBuf)> = None;
- for item in fs::read_dir(&sessions_root).ok()?.take(128) {
+ let mut inspected = 0usize;
+ for item in fs::read_dir(&sessions_root).ok()? {
let Ok(item) = item else { continue };
let path = item.path();
let Some(file_name) = path.file_name() else {
continue;
};
let file_name = file_name.to_string_lossy();
let Some(pid) = file_name.strip_suffix(".json") else {
continue;
};
if pid.is_empty() || !pid.bytes().all(|byte| byte.is_ascii_digit()) {
continue;
}
+ // 仅对通过文件名过滤的条目限流,避免昂贵的 canonicalize 与读取无上限增长。
+ inspected += 1;
+ if inspected > MAX_INSPECTED_SESSION_FILES {
+ break;
+ }
let Ok(canonical) = path.canonicalize() else {
continue;
};新增常量:
const MAX_INSPECTED_SESSION_FILES: usize = 128;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/src/hook_server/transcript_observer/claude_code.rs` around lines
115 - 152, Remove the directory-level `.take(128)` from `find_session_path` so
all session entries are scanned. Keep the existing cheap filename and PID
validation first, then limit only the expensive `canonicalize` and
`read_session_file` processing to `MAX_INSPECTED_SESSION_FILES` candidates,
defining and using that constant to enforce the cap.
|
PR Title: fix(claude): detect interrupted turns reliably Commit: 本次 PR 修复了 transcript observer 在持有共享锁期间执行文件 I/O(轮询 transcript)导致的锁竞争问题。核心改动包括:
总体评估:设计合理,解决了 observer 线程长时间持有 Mutex 阻塞 hook handler 的问题。版本号检查覆盖了所有修改路径(新建 entry、重新激活、terminal 标记、PostCompact 手动停用), |
Summary
Problem
Claude Code does not expose one reliable terminal signal for every ESC path. Interrupting during an active response may append a Request interrupted by user record, but pressing Enter and immediately pressing ESC can return the session to idle without writing an interruption record or emitting a usable terminal hook. Agent Cat therefore left the pet and status bubble in the running state.
Implementation
The backend now keeps agent-specific transcript parsers behind a shared observer. Claude transcripts recognize explicit interruption markers and turn-duration completion records. When no transcript terminal record is available, the observer locates the matching numeric PID file under the configured Claude sessions directory, validates its exact sessionId, and watches active states transition to idle.
An idle transition is held for a 2.5-second grace period so a normal Stop or StopFailure hook can win before the fallback emits TurnInterrupted. Explicit transcript interruption remains fast. StopFailure also terminates observer state, and observer registration no longer depends on a transcript file already existing.
The Claude configuration directory continues to honor CLAUDE_CONFIG_DIR, and session files are bounded and validated before reading.
Verification
Manual verification
Summary by CodeRabbit
新功能
错误修复
测试