Skip to content

fix(claude): detect interrupted turns reliably - #41

Merged
LRainner merged 2 commits into
masterfrom
codex/claude-interrupt-status
Aug 5, 2026
Merged

fix(claude): detect interrupted turns reliably#41
LRainner merged 2 commits into
masterfrom
codex/claude-interrupt-status

Conversation

@LRainner

@LRainner LRainner commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Summary

  • generalize the Codex rollout observer into an agent-aware transcript observer
  • detect Claude Code interruption markers and structured normal-completion records
  • use Claude session activity as a fallback for immediate ESC interruptions that do not reach the transcript
  • propagate TurnInterrupted through the Claude adapter and live-status controllers

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

  • cargo test --all-targets --locked: 64 passed
  • cargo clippy --all-targets --locked -- -D warnings
  • cargo fmt --all -- --check
  • npm test: 70 passed
  • npm run build
  • git diff --check

Manual verification

  • interrupt an active Claude response with ESC: the bubble changes to interrupted
  • submit a prompt and immediately press ESC: Agent Cat detects the idle fallback and changes to interrupted
  • allow a prompt to finish normally: Stop wins during the grace period and remains a normal completion

Summary by CodeRabbit

  • 新功能

    • 支持同时监测 Codex 与 Claude Code 的会话记录。
    • 新增回合完成与中断状态识别,并同步更新实时状态。
    • Claude Code 支持无回合标识的中断事件处理。
  • 错误修复

    • 优化会话记录路径校验与配置路径解析,提升异常场景下的稳定性。
  • 测试

    • 增加完成、中断、增量读取及会话状态恢复相关测试。

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0518fdcd-c10a-4dbb-a20d-e41661f765f6

📥 Commits

Reviewing files that changed from the base of the PR and between ea6f567 and 457d621.

📒 Files selected for processing (1)
  • src-tauri/src/hook_server/transcript_observer.rs

📝 Walkthrough

Walkthrough

该变更将 Codex rollout 观察器替换为统一的 transcript observer。它新增 Claude Code transcript 解析与 session 状态轮询,并把 Hook 转发、配置同步和前端事件处理扩展到 TurnInterrupted

Changes

转录观察器迁移

Layer / File(s) Summary
配置路径与 Hook 接线
src-tauri/src/hook_installer.rs, src-tauri/src/hook_installer/claude_code.rs, src-tauri/src/hook_server.rs, src-tauri/src/lib.rs
新增 Claude Code 配置根路径解析。Hook 服务器改为接入 transcript_observer。配置同步同时处理 Codex 和 Claude Code。
Codex 与 Claude Code transcript 解析
src-tauri/src/hook_server/transcript_observer/codex.rs, src-tauri/src/hook_server/transcript_observer/claude_code.rs
新增终止事件识别、路径校验、Claude session 查找和活动状态分类。
观察器生命周期与增量轮询
src-tauri/src/hook_server/transcript_observer.rs
新增观察项管理、Hook 事件处理、transcript 增量读取、Claude idle 判断、终止历史限制和事件发射。测试覆盖增量读取、完成检测、历史淘汰和 idle 状态。
前端事件与状态验证
src/agents/claude-code.ts, src/agents/registry.test.ts, src/live-status.test.ts, src/reaction-controller.test.ts
Claude Code 事件白名单新增 TurnInterrupted。测试覆盖无 turnId 时的中断保持、旧事件过滤和新提示恢复。

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
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.35% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 标题准确概括了本次 PR 对 Claude 中断回合检测可靠性的主要改进。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 9aad9c3 and ea6f567.

📒 Files selected for processing (12)
  • src-tauri/src/hook_installer.rs
  • src-tauri/src/hook_installer/claude_code.rs
  • src-tauri/src/hook_server.rs
  • src-tauri/src/hook_server/rollout_observer.rs
  • src-tauri/src/hook_server/transcript_observer.rs
  • src-tauri/src/hook_server/transcript_observer/claude_code.rs
  • src-tauri/src/hook_server/transcript_observer/codex.rs
  • src-tauri/src/lib.rs
  • src/agents/claude-code.ts
  • src/agents/registry.test.ts
  • src/live-status.test.ts
  • src/reaction-controller.test.ts
💤 Files with no reviewable changes (1)
  • src-tauri/src/hook_server/rollout_observer.rs

Comment thread src-tauri/src/hook_server/transcript_observer.rs Outdated
Comment on lines +337 to +345
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)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

claude_session_pathNone 时每轮都会重新扫描会话目录。

第 341-343 行在 claude_session_pathNone 时调用 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,并记录本次查找时间,避免每轮轮询重复扫描会话目录。

Comment on lines +115 to +152
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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

@monkeyscan

monkeyscan Bot commented Aug 5, 2026

Copy link
Copy Markdown

PR Title: fix(claude): detect interrupted turns reliably

Commit: 457d621

本次 PR 修复了 transcript observer 在持有共享锁期间执行文件 I/O(轮询 transcript)导致的锁竞争问题。核心改动包括:

  1. 引入 revision 版本号机制:为 WatchEntry 增加 u64 版本字段,使用全局 AtomicU64 计数器分配,每次修改 entry 时通过 touch() 递增版本。
  2. 锁外轮询(poll outside lock):observe 循环先在持有锁时快速克隆所有 active entry 的快照(包含 watch_key、revision、entry clone),然后释放锁,对每个快照独立执行 poll_entry(可能涉及文件 I/O 或 Claude session 检测),最后再获取锁通过 commit_poll_result 提交轮询结果。
  3. 乐观并发控制:commit_poll_result 检查 entry 是否仍 active 且 revision 未变;若期间被 hook 事件修改过,则丢弃本次轮询状态,避免脏写。
  4. 状态回写:apply_poll_state 将快照中更新的 offset、partial_line、discarding_line、claude_session_path、idle_observed_at 写回原 entry。
  5. 新增单元测试验证 revision 不匹配时正确丢弃轮询结果。

总体评估:设计合理,解决了 observer 线程长时间持有 Mutex 阻塞 hook handler 的问题。版本号检查覆盖了所有修改路径(新建 entry、重新激活、terminal 标记、PostCompact 手动停用),Relaxed ordering 在 Mutex 保护下是安全的。未发现引入的正确性、并发安全或数据完整性缺陷。

@LRainner
LRainner merged commit 5493c2b into master Aug 5, 2026
8 checks passed
@LRainner
LRainner deleted the codex/claude-interrupt-status branch August 5, 2026 14:10
@monkeyscan monkeyscan Bot mentioned this pull request Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant