Skip to content

feat: サブエージェント応答と中間テキストのSlack送信対応(トランスクリプト・カーソル方式)#13

Open
suzuki-toshihir0 wants to merge 8 commits into
mainfrom
feat/transcript-cursor-flush
Open

feat: サブエージェント応答と中間テキストのSlack送信対応(トランスクリプト・カーソル方式)#13
suzuki-toshihir0 wants to merge 8 commits into
mainfrom
feat/transcript-cursor-flush

Conversation

@suzuki-toshihir0

Copy link
Copy Markdown
Owner

Summary

  • エージェントチーム使用時にClaudeの応答がSlackに届かない問題を修正
  • トランスクリプト・カーソル方式を導入: 全hookイベントでJSONLログから未送信のassistantテキストをフラッシュ
  • SubagentStop / Notification(elicitation_dialog) / SessionEnd イベントの処理を追加

根本原因と対策

欠落パターン 原因 対策
AskUserQuestion後のテキスト Stop未発火 flush_transcriptで回収
Interrupted後のテキスト Stop未発火 次のUserPromptSubmitのflushで回収
中間テキスト(Agent呼び出し前後) last_assistant_messageは最後のみ transcriptの全assistantエントリを読む
サブエージェント応答 SubagentStop未処理 SubagentStopハンドラ追加

設計ポイント

  • at-least-once保証: CursorLockGuard::commit() を送信成功後のみ呼ぶ → 失敗時は次回再送
  • 並行安全: fs2::lock_exclusive() (flock) でセッションごとにフラッシュを直列化
  • 重複送信なし: フラッシュは type="assistant" のみ、SubagentStopの応答はメイントランスクリプトでは type="user" の tool_result

Test plan

  • ユニットテスト: cargo test --bin aloud-code(42テスト)
  • 統合テスト: cargo test --test integration_test(9テスト)
  • cargo clippy -- -D warnings 警告なし
  • cargo fmt --check フォーマット済み

🤖 Generated with Claude Code

トランスクリプト・カーソル方式を導入し、エージェントチーム使用時に
欠落していたClaudeの応答をSlackに確実に送信する。

## 変更内容

- `src/transcript.rs`(新規): JSONL読み取り・カーソル方式でassistantテキスト抽出
- `src/config.rs`: CursorLockGuard追加(fs2によるflock排他制御)、deactivate時にcursor/lockファイルも削除
- `src/formatter.rs`: format_subagent_message / format_notification_message 追加
- `src/hook.rs`: 全イベント共通のflush_transcript実装、SubagentStop/Notification/SessionEndハンドラ追加
- `hooks/hooks.json`: SubagentStop・Notification(elicitation_dialog)・SessionEndイベント登録追加
- `Cargo.toml`: fs2 = "0.4" 追加

## 解決した問題

- SubagentStop未処理(サブエージェント応答が届かない)
- AskUserQuestion/Interruptedでのassistantテキスト欠落(Stop未発火)
- 1ターン内の中間テキスト(Agentツール呼び出し前後のテキスト)欠落

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 3, 2026 03:54

Copilot AI 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.

Pull request overview

エージェントチーム利用時にサブエージェント応答や中間テキストがSlackへ届かない問題を、トランスクリプトJSONLをカーソルで追跡して未送信分をフラッシュ送信する方式で解消するPRです。

Changes:

  • トランスクリプトJSONLから「未送信のassistantテキスト」をカーソル以降で読み出してSlackへフラッシュ送信する仕組みを追加
  • SubagentStop / Notification(elicitation_dialog) / SessionEnd のhook対応とSlack整形を追加
  • カーソル永続化・排他(fs2 flock)と、統合テスト/ユニットテストを追加

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/integration_test.rs 新hook/フラッシュ動作の統合テストを追加・Stopテストをトランスクリプト方式へ更新
src/transcript.rs JSONLトランスクリプトからassistant textを抽出し、カーソル更新値を返す読み取りロジックを追加
src/main.rs transcriptモジュールを追加
src/hook.rs 全イベント共通のトランスクリプトフラッシュ、SubagentStop/Notification/SessionEndの処理を追加
src/formatter.rs サブエージェント/通知用のSlack payload整形関数を追加
src/config.rs セッションごとのカーソル・ロック機構と、deactivate時の追加ファイル削除を実装
hooks/hooks.json SubagentStop / Notification(elicitation_dialog) / SessionEnd のhook定義を追加
Cargo.toml fs2依存を追加
Cargo.lock fs2および依存のロック更新
Comments suppressed due to low confidence (4)

tests/integration_test.rs:453

  • セッションONのtoggle呼び出し結果を検証していないため、toggleが失敗しても後続のフラッシュ送信テストが実行されてしまいます。run_hook("toggle", ...)Outputを受け取り、成功をassertしてください。
    // セッションON
    let toggle_on = json!({
        "session_id": "flush-session",
        "hook_event_name": "UserPromptSubmit",
        "prompt": "/aloud-code:on"
    });
    env.run_hook("toggle", &toggle_on.to_string()).await;

tests/integration_test.rs:313

  • セッションONのtoggle呼び出し結果を検証していないため、toggleが失敗していてもテストが後続のアサーションまで進んで原因が分かりにくくなります。run_hook("toggle", ...)の戻り値Outputを受けてstatus.success()をassertしてください。
    // セッションON
    let toggle_on = json!({
        "session_id": "subagent-session",
        "hook_event_name": "UserPromptSubmit",
        "prompt": "/aloud-code:on"
    });
    env.run_hook("toggle", &toggle_on.to_string()).await;

tests/integration_test.rs:361

  • セッションONのtoggle呼び出し結果を検証していないため、toggleが失敗してもテストが進んでしまいます。run_hook("toggle", ...)Outputを受けてstatus.success()をassertしてください。
    // セッションON
    let toggle_on = json!({
        "session_id": "notif-session",
        "hook_event_name": "UserPromptSubmit",
        "prompt": "/aloud-code:on"
    });
    env.run_hook("toggle", &toggle_on.to_string()).await;

tests/integration_test.rs:410

  • セッションONのtoggle呼び出し結果を検証していないため、toggle失敗時にテストの失敗理由が不明瞭になります。run_hook("toggle", ...)の結果をassert!(output.status.success(), ...)で確認してください。
    // セッションON
    let toggle_on = json!({
        "session_id": "end-session",
        "hook_event_name": "UserPromptSubmit",
        "prompt": "/aloud-code:on"
    });
    env.run_hook("toggle", &toggle_on.to_string()).await;


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/transcript.rs
Comment on lines +32 to +52
last_complete_pos += n as u64;

if let Ok(entry) = serde_json::from_str::<Value>(&line) {
if entry["type"] == "assistant" {
if let Some(content) = entry["message"]["content"].as_array() {
let parts: Vec<&str> = content
.iter()
.filter_map(|b| {
if b["type"] == "text" {
b["text"].as_str()
} else {
None
}
})
.collect();
let text = parts.join("\n");
if !text.is_empty() {
messages.push(text);
}
}
}

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

last_complete_pos(= 次回カーソル)がJSONパース成功前に進められているため、完全行でもJSONパースに失敗した場合にその行を永続的にスキップしてassistantテキストを取りこぼします。パース失敗時はカーソルを進めずにbreakして次回再試行する、またはエラーとして返して呼び出し側で扱えるようにしてください。

Suggested change
last_complete_pos += n as u64;
if let Ok(entry) = serde_json::from_str::<Value>(&line) {
if entry["type"] == "assistant" {
if let Some(content) = entry["message"]["content"].as_array() {
let parts: Vec<&str> = content
.iter()
.filter_map(|b| {
if b["type"] == "text" {
b["text"].as_str()
} else {
None
}
})
.collect();
let text = parts.join("\n");
if !text.is_empty() {
messages.push(text);
}
}
}
match serde_json::from_str::<Value>(&line) {
Ok(entry) => {
// JSONパースに成功した行のみ、カーソルを進める
last_complete_pos += n as u64;
if entry["type"] == "assistant" {
if let Some(content) = entry["message"]["content"].as_array() {
let parts: Vec<&str> = content
.iter()
.filter_map(|b| {
if b["type"] == "text" {
b["text"].as_str()
} else {
None
}
})
.collect();
let text = parts.join("\n");
if !text.is_empty() {
messages.push(text);
}
}
}
}
Err(_) => {
// JSONパースに失敗した場合はカーソルを進めずに終了し、
// 次回呼び出し時に同じ行から再試行できるようにする
break;
}

Copilot uses AI. Check for mistakes.
Comment thread src/config.rs
Comment on lines +109 to +112
for path in &paths {
match std::fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

deactivate().cursor.lock(ロック用ファイル)まで削除していますが、別プロセス/別フックがロックファイルを開いたままのタイミングだと、特にWindowsでは開いているファイルを削除できずPermissionDenied等でsession-endが失敗する可能性があります。ロックファイルは削除対象から外すか、削除失敗時は警告に留めて処理を継続するなど、プラットフォーム差分と並行実行を考慮した挙動にしてください。

Suggested change
for path in &paths {
match std::fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
for path in &paths {
let is_lock_file = path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.ends_with(".cursor.lock"))
.unwrap_or(false);
match std::fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) if is_lock_file && e.kind() == std::io::ErrorKind::PermissionDenied => {
eprintln!(
"warning: failed to remove lock file {:?}: {}",
path,
e
);
}

Copilot uses AI. Check for mistakes.
Comment thread src/config.rs
Comment on lines +35 to +51
impl CursorLockGuard {
/// ファイルロックを排他取得してカーソル値を返す
pub fn acquire(session_id: &str) -> Result<(Self, u64)> {
let dir = sessions_dir()?;
std::fs::create_dir_all(&dir)?;
let lock_path = dir.join(format!("{}.cursor.lock", session_id));
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)?;
lock_file.lock_exclusive()?;
let cursor = read_cursor_inner(session_id);
Ok((
CursorLockGuard {
lock_file,
session_id: session_id.to_string(),

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

session_idをそのままファイル名に埋め込んでjoin()しているため、../やパス区切り文字を含む入力でセッションディレクトリ外の任意パスを参照/作成できてしまいます(activate()/deactivate()/カーソル/ロック全てに影響)。session_idを安全な文字集合に制限する、エスケープ/ハッシュ化してファイル名にする等の対策を入れてください。

Suggested change
impl CursorLockGuard {
/// ファイルロックを排他取得してカーソル値を返す
pub fn acquire(session_id: &str) -> Result<(Self, u64)> {
let dir = sessions_dir()?;
std::fs::create_dir_all(&dir)?;
let lock_path = dir.join(format!("{}.cursor.lock", session_id));
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)?;
lock_file.lock_exclusive()?;
let cursor = read_cursor_inner(session_id);
Ok((
CursorLockGuard {
lock_file,
session_id: session_id.to_string(),
/// セッションIDをファイル名として安全に使用できる形式に正規化する
/// - ASCII英数字と `-` / `_` のみを許可し、それ以外は `_` に置換する
/// - すべて置換された場合は `_` を返す
fn sanitize_session_id(session_id: &str) -> String {
let mut sanitized: String = session_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
if sanitized.is_empty() {
sanitized.push('_');
}
sanitized
}
impl CursorLockGuard {
/// ファイルロックを排他取得してカーソル値を返す
pub fn acquire(session_id: &str) -> Result<(Self, u64)> {
let safe_session_id = sanitize_session_id(session_id);
let dir = sessions_dir()?;
std::fs::create_dir_all(&dir)?;
let lock_path = dir.join(format!("{}.cursor.lock", safe_session_id));
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)?;
lock_file.lock_exclusive()?;
let cursor = read_cursor_inner(&safe_session_id);
Ok((
CursorLockGuard {
lock_file,
session_id: safe_session_id,

Copilot uses AI. Check for mistakes.
suzuki-toshihir0 and others added 2 commits March 3, 2026 13:05
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
カーソルファイルが存在しない(初回有効化・再有効化後)場合は
トランスクリプトの末尾にカーソルを設定して過去メッセージを送信しない。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 3, 2026 04:11
ツール呼び出し直前にフラッシュすることで、Claudeのテキストが
リアルタイムに近いタイミングでSlackに届くようになる。
割り込み(Interrupted)時もツール呼び出しがあれば大半は送信済みになる。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (2)

src/config.rs:69

  • CursorLockGuard::commit()unlock() した後に CursorLockGuard がdropされるため、Drop 実装でもう一度 unlock() が呼ばれます(結果は捨てていますが、プラットフォームによっては二重unlockがエラーになり得ます)。commit(mut self, ..) にして内部フラグで「解放済み」を管理するか、commit 側でunlockせずDropに任せる等、unlockを1回にしてください。
    /// カーソルを新しい値に更新してロックを解放する(送信成功後に呼び出す)
    pub fn commit(self, new_cursor: u64) -> Result<()> {
        write_cursor_inner(&self.session_id, new_cursor)?;
        self.lock_file.unlock()?;
        Ok(())
    }
}

impl Drop for CursorLockGuard {
    fn drop(&mut self) {
        let _ = self.lock_file.unlock();
    }

src/config.rs:86

  • カーソル更新が std::fs::write() 一発だと、プロセス中断等で0バイト/途中書きのcursorファイルが残る可能性があります。その場合 read_cursor_inner()None 扱いになって初回フラッシュ扱い→履歴スキップや再送挙動が不安定になります。テンポラリに書いて rename するなど、原子的な書き込みにしておくのが安全です。
fn write_cursor_inner(session_id: &str, cursor: u64) -> Result<()> {
    let path = cursor_path(session_id)?;
    std::fs::write(path, cursor.to_string())?;
    Ok(())

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/hook.rs
Comment on lines +85 to +87
let file_size = std::fs::metadata(transcript_path)
.map(|m| m.len())
.unwrap_or(0);

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

初回フラッシュ(cursor=None)のとき metadata(transcript_path) が失敗すると unwrap_or(0) により cursor=0 をコミットしてしまい、後でトランスクリプトが作成/復旧した際に過去分を全送信する挙動になります(コメントの「過去メッセージは送信せず」と逆)。ここはメタデータ取得に失敗したら cursorファイルを作らずに return する、もしくは明示的にエラー扱いにして次回再試行するようにしてください。

Suggested change
let file_size = std::fs::metadata(transcript_path)
.map(|m| m.len())
.unwrap_or(0);
let file_size = match std::fs::metadata(transcript_path) {
Ok(m) => m.len(),
Err(_) => {
// メタデータ取得に失敗した場合はカーソルファイルを作成せずに終了し、
// 次回以降に再試行できるようにする
return Ok(());
}
};

Copilot uses AI. Check for mistakes.
suzuki-toshihir0 and others added 2 commits March 3, 2026 13:25
Stop hookはtranscript書き込み完了前に発火するため、
transcriptフラッシュではなくlast_assistant_messageを直接送信する。
Stop後にカーソルをファイル末尾に進め重複送信を防ぐ。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PreToolUseが遅延してロック取得した際、transcript上の最終テキストも
フラッシュしてしまい、Stopがlast_assistant_messageとして同じ最終
テキストを直接送信することで二重送信が発生していた。

StopでもPreToolUse同様にtranscript flushのみを使うよう統一する。
CursorLockGuardによりどちらが先にロックを取得しても整合が保たれる:
- PreToolUse先行: 最終テキストを含めてflush → Stop時は新規なし
- Stop先行: 最終テキストを含めてflush → PreToolUse時は新規なし

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 3, 2026 04:56

Copilot AI 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.

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (3)

src/hook.rs:88

  • 初回フラッシュ分岐で transcript_path の metadata 取得に失敗した場合でも unwrap_or(0) で cursor=0 を commit してしまうと、後でトランスクリプトが見えるようになった際に過去分を再送する状態になります。ここは metadata 取得失敗をエラー扱いにして cursor を作らない(次回リトライで再度初回扱いにする)方が安全です。
            let file_size = std::fs::metadata(transcript_path)
                .map(|m| m.len())
                .unwrap_or(0);
            lock.commit(file_size)?;

src/config.rs:120

  • deactivate() が cursor/lock ファイルをロック無しで削除しているため、別プロセスの flush_transcript がロック保持中/commit直前でも削除が走り得ます。その場合、flush 側が commit で cursor ファイルを再作成してしまい「SessionEnd後もcursorが残る」「終了後に再送が発生する」等の競合が起きます。deactivate も同じロック(CursorLockGuard)を取得してから削除するか、commit 時にセッション有効フラグを再確認するなど、同一セッションの削除と更新を直列化してください。
pub fn deactivate(session_id: &str) -> Result<()> {
    let dir = sessions_dir()?;
    let paths = [
        dir.join(session_id),
        dir.join(format!("{}.cursor", session_id)),
        dir.join(format!("{}.cursor.lock", session_id)),
    ];
    for path in &paths {
        match std::fs::remove_file(path) {
            Ok(()) => {}
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
            Err(e) => return Err(e.into()),
        }
    }
    Ok(())

tests/integration_test.rs:196

  • コメントが「フラッシュ済みセッションをシミュレート」となっていますが、cursor を 0 にすると「先頭から未フラッシュ」と同義になり意図と逆です。コメントを実態に合わせて修正するか、フラッシュ済みを表現したいなら transcript のファイルサイズ(末尾オフセット)を cursor に設定してください。
    // カーソルを0に設定(フラッシュ済みセッションをシミュレート)
    env.set_cursor("test-session-stop", 0);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/hook.rs
Comment on lines 157 to +160
"stop" => {
// transcript flush で全テキスト送信済み
// last_assistant_message は使わない(PreToolUseとの二重送信防止)
}

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

flush_transcript の失敗をログ出力して握りつぶしている一方で、Stop イベントでは last_assistant_message を送らない実装に変わっています。transcript_path が無い/読めない/壊れているケースで assistant 応答が一切Slackに届かない回帰になるので、Stop では flush 失敗時のフォールバック送信(従来の last_assistant_message 等)を入れるか、flush エラー時は処理を失敗としてリトライされるように扱ってください。

Copilot uses AI. Check for mistakes.
Comment thread src/config.rs
Comment on lines +39 to +46
let dir = sessions_dir()?;
std::fs::create_dir_all(&dir)?;
let lock_path = dir.join(format!("{}.cursor.lock", session_id));
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)?;

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

session_id をそのままファイル名に埋め込んで join(format!("{}.cursor.lock", session_id)) しているため、入力が ../ やパス区切りを含む場合に sessions_dir 外のパスへ到達できます(パストラバーサル)。セッションIDを安全な文字種にバリデート/正規化するか、ハッシュ化した固定長のファイル名を使うなどでファイルパス生成を安全にしてください。

Copilot uses AI. Check for mistakes.
Comment thread src/hook.rs Outdated
Comment on lines +88 to +89
lock.commit(file_size)?;
return Ok(());

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

カーソルファイルが存在しない場合に「ファイル末尾へcommitしてreturn」してしまうため、最初のフラッシュが Stop(または PreToolUse 等)だった場合、その時点までに書き込まれた assistant テキストが全て未送信のままスキップされます(/aloud-code:on直後の最初の応答が落ちる可能性)。cursor の初期化は flush 時ではなく有効化時に行う、または「Noneの場合はまず末尾に初期化→同一イベント内では読み取りを開始しない」前提を満たす設計に変更する、などで取りこぼしを防いでください。

Suggested change
lock.commit(file_size)?;
return Ok(());
// カーソルをファイル末尾に初期化するが、同一イベント内で
// 追記されたテキストはこの後の読み取りで送信する
lock.commit(file_size)?;
file_size

Copilot uses AI. Check for mistakes.
suzuki-toshihir0 and others added 2 commits March 3, 2026 16:11
Stop フックが発火した時点でtranscriptへの書き込みが未完了な場合、
flush で何も読めずメッセージが次のUserPromptSubmitまで遅延していた。

ファイルサイズが安定するまでポーリング(100ms間隔、最大1秒)してから
flushするよう変更する。これにより:
- レースコンディションが解消(書き込み完了後にflush)
- 書き込みが早く完了した場合は短時間で終わる(固定待機より効率的)
- PreToolUse + Stop の二重送信もなし(CursorLockによる整合)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PreToolUseフックを廃止し、Stopイベントでの二重送信リスクを根本的に解消する。

Stopの処理を以下の方式に変更:
- transcript flushを試みる(通常ケース)
- flush件数が0(= transcript書き込みのレースコンディション)の場合のみ
  last_assistant_messageにフォールバックしカーソルを末尾に進める

PreToolUseがなくなることでflush件数=0は「レースコンディションのみ」になり、
フォールバックが安全に機能する。中間テキストはターン終了時(Stop)にまとめて届く。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 3, 2026 07:20

Copilot AI 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.

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/config.rs:69

  • CursorLockGuard::commit(self, ...)unlock() した後に self が drop され、Drop 実装でも unlock() が呼ばれるため二重 unlock になります。fs2 の実装/OS によっては二重 unlock がエラーになり得てデバッグしづらいので、commit 後は Drop 側で unlock しない(例: committed フラグを持つ / lock_fileOption<File> にして take() する / commit では unlock せず Drop に任せる)いずれかに統一したいです。
    /// カーソルを新しい値に更新してロックを解放する(送信成功後に呼び出す)
    pub fn commit(self, new_cursor: u64) -> Result<()> {
        write_cursor_inner(&self.session_id, new_cursor)?;
        self.lock_file.unlock()?;
        Ok(())
    }
}

impl Drop for CursorLockGuard {
    fn drop(&mut self) {
        let _ = self.lock_file.unlock();
    }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/hook.rs
Comment on lines +161 to +182
let flushed = if let Some(transcript_path) = &input.transcript_path {
flush_transcript(session_id, transcript_path, &ctx, &sender)
.await
.unwrap_or(0)
} else {
0
};
if flushed == 0 {
let message = input.last_assistant_message.as_deref().unwrap_or("");
if !message.is_empty() {
let payload = formatter::format_assistant_message(message, &ctx);
sender.send(payload).await?;
}
// カーソルをファイル末尾に進め、次回UserPromptSubmitでの重複送信を防ぐ
if let Some(transcript_path) = &input.transcript_path {
let file_size = std::fs::metadata(transcript_path.as_str())
.map(|m| m.len())
.unwrap_or(0);
if let Ok((lock, _)) = config::CursorLockGuard::acquire(session_id) {
let _ = lock.commit(file_size);
}
}

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

Stop ハンドラで flush_transcript(...).await.unwrap_or(0) としているため、フラッシュ処理が送信失敗/IO失敗した場合でも flushed==0 扱いになりフォールバック経路に入ります。その後 commit(file_size) でカーソルを進めてしまうので、フラッシュ未送信分が次回以降に再送されず失われる可能性があります(at-least-once保証と矛盾)。Result を握りつぶさずに Err と Ok(0) を分岐し、Err の場合はカーソル更新を行わない(必要なら処理自体を失敗させて次回リトライ)ようにして下さい。

Copilot uses AI. Check for mistakes.
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.

2 participants