Skip to content

fix(notifications): stop announcing aborted, failed or stray idles as completed - #234

Merged
yuga-hashimoto merged 1 commit into
mainfrom
fix-premature-completion-notification
Aug 10, 2026
Merged

fix(notifications): stop announcing aborted, failed or stray idles as completed#234
yuga-hashimoto merged 1 commit into
mainfrom
fix-premature-completion-notification

Conversation

@yuga-hashimoto

@yuga-hashimoto yuga-hashimoto commented Aug 9, 2026

Copy link
Copy Markdown
Owner

完了通知が「作業の完了」以外のidleでも飛んでいたケースを修正する。

  • エラー終了: SessionError の後の trailing idle では完了通知を出さない (mute し、idle 消費で解除)
  • 停止ボタン/中断送信: abort 時に mute し、kill されたランの idle を完了として通知しない
  • サブエージェント判定: セッション取得に失敗したら通知しない (fail closed)
  • Claude CLI がターン途中で死亡: bare idle ではなく SessionError + idle を発行し、チャットにもエラーとして表示

mute は one-shot で、trailing idle か新しいラン (markSessionRunning / busy status) で解除される。

Tests: RuntimeActivityRepositoryTest (+4), ChatViewModelTest (+1), ClaudeStreamJsonParserTest (+1). testDebugUnitTest / lintDebug / detekt / spotlessCheck 通過済み。

Pre-PR review

判定: APPROVE

ブロッカー

  • なし

提案(非ブロッキング)

  • RuntimeActivityRepository.ktvar notifymutableState.update{} のラムダ内で副作用として書き換えている。update の CAS リトライで最後に実行されたラムダの値が採用されるため結果は正しいが、慣習として外部変数への副作用は読みにくい。mutedSessionIds を退出前のスナップショットとして別途比較する構成にすると追跡性が上がる(振る舞いは等価)。
  • ClaudeCodeRuntime.kt — リーダーJobの終了処理で、プロセスが mid-turn で終了し、かつ直後に同じ sessionId で新プロセスが start されたレアケースでは、旧Jobが発する SessionError が新ランのUIに一時的に表示されうる。mute機構で後続idle通知は正しく抑止されるため通知面は無害だが、error メッセージが一瞬出うる。実害は小さい。
  • ChatViewModel.kt — 中断送信時に onSessionAborted を呼ぶが、Claudeバックエンドでは明示的なabortなしに新プロンプトがstdin追記される。Claudeが旧ターンを完了してから新ターンを処理する限り二重idleとなり最初がmute・二つ目が通知される設計は正しい。CLIが単一idleにマージするフローはないと想定するが、ドッグフーディングで要観察。

チェック済み項目

  • 正しさ: mute はone-shot設計(SessionIdle ハンドラと markSessionRunning/SessionStatusChanged でクリア)。error→idle ペア、abort、mid-turn exit の各ケースで通知が正しく抑止され、次回正常ランで再通知されることを確認。turnFinished フラグは @volatile、beginTurn()→parseResult のライフサイクルが正確。stop() の cancel は CancellationException フィルタと !isActive ガードで確実に早期リターン。
  • 隣接挙動の回帰: 旧コードは正常完了でも parser idle + リーダーJob末端の idle の二重発火だった。新コードは正常完了時に末端idleを発火しないため改善。abort 時の handleSessionIdle 副作用が走らなくなるが onSuccess で補完済み。
  • i18n: claude_error_process_exited が values + ar/es/fr/ja/pt-rBR/ru/zh-rCN の8ロケール全てに存在し %1$s も一致。
  • Compose/状態: mutedSessionIds は不変フィールドで copy() 経由のみ更新。DI は AndCodeApp で onSessionAborted を正しく接続。ChatViewModel の新パラメータはデフォルト値あり。
  • 並行性: markSessionAborted/markSessionRunning は MutableStateFlow.update でアトミック。リーダーJobと stop() の競合は synchronized + !isActive ガードで整序。
  • リソース漏れ: なし。
  • セキュリティ: シークレットなし。
  • テスト: RuntimeActivityRepositoryTest +4, ChatViewModelTest +1(abort発火), ClaudeStreamJsonParserTest +1(turnFinished リセット)。
  • 慣習: processExited は ClaudeMessages interface + Default + AndroidClaudeMessages の既存パターンに準拠。

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 3 issue(s) in this PR.

  • ✅ Successfully posted inline: 2 comment(s)
  • ⏭️ Skipped (overlap with history): 1 comment(s)

}
refreshContextUsage(targetSessionId)
}
if (interrupting) onSessionAborted(targetSessionId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · low]
onSessionAborted(markSessionAborted) が currentBackend.sendMessage の成功を待たずに呼ばれています。sendMessage が例外を投げた場合、この runCatching ブロックの onFailure でエラー表示になりますが、その時点で session は既に mutedSessionIds に追加され activeSessionIds からも削除されています。そのため、中断されるはずだった従来の実行が(sendMessage 失敗で実際には中断されず)継続し、後で完了しても SessionIdle 通知が抑制されたままになります。sendMessage 成功後に呼び出すか、失敗時に mute 状態を巻き戻す処理を検討してください。

val currentBackend = backend ?: return
val sessionId = _uiState.value.sessionId ?: return
viewModelScope.launch {
onSessionAborted(sessionId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · medium]
abortSession の成否に関わらず onSessionAborted が先に呼ばれます。abortSession が失敗して実行が続いた場合でも、session は activeSessionIds から除去され mutedSessionIds に残るため、ドロワーのスピナーが早期に消え、かつ後続の完了通知(SessionIdle)も抑制されます。runCatching { ... }.onSuccess { onSessionAborted(sessionId) } のように成功時のみ通知するか、失敗時に markSessionRunning 相当で状態を復元することを検討してください。

// A CLI that exits before its result line would otherwise leave the chat spinning
// forever; that is a failure, not a completion. A finished turn already emitted its
// own idle, and repeating it on process exit would re-announce the old run.
if (!parser.turnFinished) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[other · low]
前回の実装ではプロセス終了時に常に events.tryEmit(OpenCodeEvent.SessionIdle(sessionId)) が送出されていましたが、今は parser.turnFinished == false のときだけ送出されます。turnFinishedbeginTurn()send 時)で false にリセットされるため、既存プロセス再利用中(ensureProcess が既存セッションを返すケース)に beginTurn と result 行の間に終了が起きる場合は問題ありませんが、一度 turnFinished == true になった直後(result 行受信後から次の send までの間)にプロセスが終了した場合、この分岐は何も送出しません。この期間では既に result 行で SessionIdle が送出済みなので問題ありません。ロジックは妥当です。

Comment on lines +388 to +394
if (!parser.turnFinished) {
val exitCode = runCatching { process.exitValue() }.getOrNull()
events.tryEmit(
OpenCodeEvent.SessionError(sessionId, messages.processExited(exitCode, streamFailure?.message)),
)
events.tryEmit(OpenCodeEvent.SessionIdle(sessionId))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · high]
ここで parser.turnFinishedtrue(result 行を受信後にプロセス終了)の場合、streamFailure が null 以外でも SessionError はまったく発行されません。しかし handleLine はパースされたイベントだけで SessionError を emit するため、turnFinished == trueis_error の result 行を受信した場合でも、ここではエラーも idle も送出されません。この場合、呼び出し側でチャットがスピナー状態のままになる可能性があります。parser.turnFinished に加えて streamFailure やエラーフラグも考慮して終了処理を判断すべきです。

// forever; that is a failure, not a completion. A finished turn already emitted its
// own idle, and repeating it on process exit would re-announce the old run.
if (!parser.turnFinished) {
val exitCode = runCatching { process.exitValue() }.getOrNull()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[maintainability · low]
runCatching { process.exitValue() }exitCode == 0 を「正常終了」として扱わず、!parser.turnFinished だけでエラーを判定しています。正常終了コード 0 かつ未完了ターン(例: リザルト行未受信のまま終了)でも「エラー」扱いで問題ないなら、exitCode を条件に含めるかコメントで意図を明示することを推奨します。また詳細メッセージが null のときは exit code が使われますが、メッセージの生成は ClaudeMessages 側に委ねられており、ここでは判断が分かりにくいです。

detail: String?,
): String {
val cause = detail ?: exitCode?.let { "exit code $it" } ?: "process exited"
return context.getString(R.string.claude_error_process_exited, cause)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[other · low]
exit code や process exited という英語文字列が翻訳済みリソースの %1$s にそのまま埋め込まれます。多言語対応のアプリのため、日本語などの表示時にも英語が混在してしまいます。文言全体をリソース側に寄せる(例: exitCode を個別フォーマット引数として渡す)ことを検討してください。

Comment on lines +48 to +50
@Volatile
var turnFinished: Boolean = false
private set

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[maintainability · low]
Parsed.turnFinished フィールド(42 行目)とこの @Volatile プロパティ turnFinished でターン完了状態が二重管理されています。どちらも parseResult 内で true に設定しており、将来の変更で不整合が生じやすくなっています。Parsed.turnFinished を廃止して本プロパティに一本化するか、プロパティを Parsed から導出する形にするなど、単一の情報源に整理することをお勧めします。

Comment on lines +48 to +50
@Volatile
var turnFinished: Boolean = false
private set

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[maintainability · low]
Parsed.turnFinished フィールド(42 行目)とこの @Volatile プロパティ turnFinished でターン完了状態が二重管理されています。どちらも parseResult 内で true に設定しており、将来の変更で不整合が生じやすくなっています。Parsed.turnFinished を廃止して本プロパティに一本化するか、プロパティを Parsed から導出する形にして、単一の情報源に整理することをお勧めします。

Comment on lines +52 to +54
fun beginTurn() {
turnFinished = false
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · medium]
beginTurn()turnFinished を無条件に false へリセットしますが、send() が書き込み失敗(broken pipe 等)で失敗したケースでは、send()onFailureSessionError/SessionIdle を送出した後も turnFinished == false のままになります。すると reader ジョブのプロセス終了処理(ClaudeCodeRuntimeif (!parser.turnFinished) 分岐)でも同じ SessionError/SessionIdle が再送出され、エラー/完了イベントが重複します。送信失敗パスで turnFinished を true にする、あるいはターン完了イベントの送出元を一箇所に集約し、二重送出を防いでください。

Comment on lines +52 to +54
fun beginTurn() {
turnFinished = false
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · medium]
beginTurn()turnFinished を無条件に false へリセットしますが、ClaudeCodeRuntime.send() が書き込み失敗(broken pipe 等)で失敗した場合、onFailureSessionError/SessionIdle を送出した後に turnFinished が false のまま残ります。すると reader ジョブのプロセス終了処理(ClaudeCodeRuntimeif (!parser.turnFinished) 分岐)でも同じ SessionError/SessionIdle が再送出され、エラー/完了イベントが重複するリスクがあります。送信失敗パスでも turnFinished を true にするか、ターン終了イベントの送出元を一箇所に集約して二重送出を防いでください。

Comment on lines +835 to 836
if (interrupting) onSessionAborted(targetSessionId)
currentBackend.sendMessage(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · high]
割り込み送信時に onSessionAborted を呼んでいますが、これは abortSession を呼ぶ前の段階です。markSessionAbortedmutedSessionIds に session を追加し、以降の SessionIdle イベントによる完了通知を永久に抑制します。もし sendMessage(割り込み)が後のコルーチン内で失敗して abortSession が実行されない/失敗する場合、このセッションの完了通知が以後送られなくなります。onSessionAborted は実際に割り込み(abortSession/sendMessage)が成功した後に呼ぶか、失敗時に mutedSessionIds から除外するリカバリ処理を入れてください。

Comment on lines +1357 to 1358
onSessionAborted(sessionId)
runCatching { currentBackend.abortSession(sessionId) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · high]
onSessionAborted(sessionId)runCatching { currentBackend.abortSession(sessionId) } の直前(実行前)に呼ばれています。abortSession が失敗して .onFailure でエラー表示になるケースでも、mutedSessionIds に session が残ったままとなり、その後の実際の SessionIdle イベントによる完了通知が抑制されます。markSessionAbortedabortSession の成功後(.onSuccess 内)に呼ぶように変更してください。

@yuga-hashimoto
yuga-hashimoto force-pushed the fix-premature-completion-notification branch from 660b760 to 4ded110 Compare August 10, 2026 01:54
Comment on lines +181 to +184
current.copy(
activeSessionIds = current.activeSessionIds - sessionId,
mutedSessionIds = current.mutedSessionIds + sessionId,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · medium]
markSessionAbortedSessionError ハンドラ(settledSessionIds + sessionIdmutedSessionIds + sessionId を同時に設定)と異なり、settledSessionIds を更新していません。abort 後に遅延して届く MessagePartUpdated / MessagePartDelta / MessageUpdated は settled 判定(sessionId in current.settledSessionIds)に該当しないため、abort 済みセッションが activeSessionIds に再追加され、ドロワーの「実行中」表示が復活してしまいます。SessionError と同様に settledSessionIds も同時に設定することをお勧めします(markSessionRunning が次の run 開始時に解除するため、再開は妨げません)。

Suggestion:

Suggested change
current.copy(
activeSessionIds = current.activeSessionIds - sessionId,
mutedSessionIds = current.mutedSessionIds + sessionId,
)
current.copy(
activeSessionIds = current.activeSessionIds - sessionId,
settledSessionIds = current.settledSessionIds + sessionId,
mutedSessionIds = current.mutedSessionIds + sessionId,
)

runCatching { target.session(event.sessionId).parentId != null }
.getOrDefault(false)
if (!isSubagent) {
if (!notify) return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · low]
mute の解除は SessionIdle / SessionStatusChanged / markSessionRunning の3箇所のみです。しかし実行中に新メッセージを送る割り込みフローでは、markSessionAborted は呼ばれる一方で isRunning が遷移しないため markSessionRunning は再呼び出しされず、ローカルランタイムは SessionStatusChanged も emit しません。そのため、割り込まれたターンが trailing SessionIdle を emit しないケースでは mute が次の run の完了通知まで残り、この returnonSessionIdle を飲み込むことになります(テスト a muted session notifies again once a new run startsSessionStatusChanged で解除する前提ですが、ローカルランタイムではこのイベントは発生しません)。次の run 開始時に必ず mute が解除されること(例: 割り込み時は trailing idle のみを対象にした一回限りの mute にする、あるいは run 開始パスで明示的に解除する)を確認・テストで担保してください。

Comment on lines +407 to +409
events.tryEmit(
OpenCodeEvent.SessionError(sessionId, messages.processExited(exitCode, streamFailure?.message)),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · low]
sessions マップからの削除(synchronized ブロック)の後に、ロック外で SessionError/SessionIdle を送出しています。reader のクリーンアップ処理中に別スレッドの send() が同じ sessionId で新しいプロセスを作成した場合、古いプロセスの失敗イベントが新しい実行に誤って紐づき、誤ったエラー表示や早期の idle 通知を引き起こす可能性があります。イベント送出を削除と同一の同期ブロック内で行うか、送出直前に sessions[sessionId]?.process === process を再確認してから送出することを推奨します。

Comment on lines +407 to +409
events.tryEmit(
OpenCodeEvent.SessionError(sessionId, messages.processExited(exitCode, streamFailure?.message)),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · low]
sessions マップからの削除(synchronized ブロック)の後に、ロック外で SessionError/SessionIdle を送出しています。reader のクリーンアップ処理中に別スレッドの send() が同じ sessionId で新しいプロセスを作成した場合、古いプロセスの失敗イベントが新しい実行に誤って紐づき、誤ったエラー表示や早期の idle 通知を引き起こす可能性があります。イベント送出を削除と同一の同期ブロック内で行うか、送出直前に sessions[sessionId]?.process === process を再確認してから送出することを推奨します。

… completed

The completion notification fired on every SessionIdle, including the
idle that trails an abort, an error, a mid-turn CLI death, and subagent
idles whose parent lookup failed. Mute the notification for a run that
was aborted or errored until its trailing idle is consumed, fail closed
when the subagent check cannot resolve the session, and surface a
mid-turn Claude process exit as an error instead of a bare idle.
@yuga-hashimoto
yuga-hashimoto force-pushed the fix-premature-completion-notification branch from 4ded110 to 16d716e Compare August 10, 2026 02:09
Comment on lines +203 to +204
activeSessionIds = current.activeSessionIds - sessionId,
mutedSessionIds = current.mutedSessionIds + sessionId,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[bug · medium]
markSessionAborted は呼び出し元(ChatViewModel.abort / 割り込み送信)で abortSessionsendMessage の成否に関わらず先に実行されます。abort が失敗したりランタイムが中断を無視して run が実際には継続している場合でも、ここで即座に activeSessionIds から除去され、drawer は停止状態を表示する一方、後続の SessionIdle が muted 扱いとなり完了通知も抑止されます。実際に run の終了が確認できてから muted へ移す(または abort 失敗時に状態を戻す)など、実行終了を確定させてから状態を変更する設計を検討してください。

activeSessionIds = current.activeSessionIds - event.sessionId,
completedSessionIds = current.completedSessionIds + event.sessionId,
settledSessionIds = current.settledSessionIds + event.sessionId,
mutedSessionIds = current.mutedSessionIds - event.sessionId,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[maintainability · low]
mutedSessionIds のエントリは、同じセッションが以後 SessionIdle / SessionStatusChanged / markSessionRunning でクリアされるまで残り続けます。abort 直後に接続が切れるなど、以後そのセッションのイベントが一切来ないケースではエントリが残り続け、ランタイム切断時のリセットでも activeSessionIdspermissions しか消えないため累積します。さらに、古い muted エントリが残った状態で同じセッションが(markSessionRunning を伴わないイベント起点で)再実行されると、本来通知すべき完了の SessionIdle が誤って抑止されるリスクがあります。切断時・ランタイム切替時や新規 run 開始時に muted を解除するなど、ライフサイクルを明確にすることを推奨します。

@yuga-hashimoto
yuga-hashimoto merged commit c082a2d into main Aug 10, 2026
7 checks passed
@yuga-hashimoto
yuga-hashimoto deleted the fix-premature-completion-notification branch August 10, 2026 02:22
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