v0.31.2
[0.31.2] - 2026-09-06
Highlight: 「同じ出来事が二度届く」「片方の面だけ答えが違う」を潰した patch リリース。完了通知は module graph をまたぐと 2 通送られることがあり、プロンプトに答えた直後の poller 再開は直前ターンの応答をもう 1 行保存していた。ファイルパスのクリックはチャット面だけが存在確認をしていて、History は読めないタブを開いていた。あわせて files API が不在のテキストファイルに 500 を返していたのを 404 に直した。
Changed
- chore(catalog): claude / codex のスラッシュコマンド attestation を再取得する (catalog-reconcile 2026-09-06):
catalog:refresh --writeで claude/design/skill-doctorの 2 件をカタログへ追加し(新規 locale キーはskill-doctorの 1 件、/designは command-code と意味が異なるためフラットキーdescriptions.designをdesign.command-code(出荷済み文言を据え置き)/design.claude(docs 由来)へ人手で分割)、attestation を claude 2.1.251 → 2.1.261(docs 表 112 行 = active 107 + removed 3 + alias 2、/ultraplan込みで 108 件、observedAt 2026-09-06)、codex 0.151.0 → 0.153.4(slash_command.rs@ rust-v0.153.4 は rust-v0.151.0 とバイト同一、59 variant → active 57、observedAt 2026-09-06)へ採り直した。集合の削除はゼロ、除外判断(exclusions.json 4 件)は不変
Fixed
-
fix(polling): プロンプト応答後の poller 再開が直前ターンの応答をもう 1 行保存する問題を修正 (#2230):
stopPollingByKey()は毎回 response hash(#1268 の内容 dedup)を消しており、startPolling()の restart もその経路を通るため、再開直後の tick がまだ前ターンの完成画面を見ていると同じ応答がchat_messagesに 2 行入る。実運用でこの状態になるのは/sendではなく/respond/ Auto-Yes の経路 —/sendは submit を read-back 検証し claude はスラッシュコマンド込みで全入力を❯ …として transcript に echo するのでstartPolling()の時点で抽出アンカーは前ターンから外れている。一方 ターン完了後にエージェント自身が出すダイアログ(2026-09-01 実測: claude が作業完了後にTeach auto mode about your environment?を表示。detectPromptはmultiple_choiceと判定する)は poller の prompt 経路に入り、non-TUI ツールはそこで自分の chain を 止めて hash を消す。答えるとダイアログが消えて画面は前ターンの完成画面に戻り、/respondのstartPolling()→ 最初の tick で A が再保存される(実 SQLite +実checkForResponseの統合テストで再現、2 行)。対策は B(ターン境界の証拠を要求する)を poller core の中で:checkForResponse()が自分の tick の中から呼んだstopPolling()は response hash を即座に消さず、checkForResponse()の戻り値が出た時点でsettleSelfStop()が裁定する — 「記録あり + full-screen TUI 以外」=プロンプトで一時停止(pausedOnPromptに印、hash は保持)、「記録あり + opencode/copilot」=返答保存済みでターン終了(従来どおり全消し)、「記録なし」=セッション消失(従来どおり全消し)。startPolling()は印のある key なら resume(hash を持ち越し、印を消費)、それ以外(走っている chain の restart =/send、外部 stop 後)は従来どおり新サイクルで hash を捨てる。#1268 の要件は不変: 次ターンで同一内容の応答が来ても/sendの restart が hash を捨てるので保存される(統合テストで byte 同一の応答を 2 ターン続けて保存できることを確認。変異注入: 「全 restart で hash を持ち越す」に変えるとこの 2 件が赤、対策を外すと再保存の 3 件が赤)。prompt hash は一時停止でも従来どおり消す(答えが効かず同じダイアログが残っていたら新しいカードを出す必要がある)。明示stopPolling()/stopAllPolling()は一時停止を終わらせて hash を捨て、worktree ID 移行は一時停止中 key の hash と印を新 ID へ移す。残課題(scope 外): response poller が一度も見ないうちに Auto-Yes が答えたダイアログ(chain は走ったまま → restart が新サイクル扱い)は呼び出し側の証拠(resume フラグ or 新規 user 行)が無いと区別できない。付随:tests/integration/response-poller-singleton-db-2223.test.tsが #2317 Phase D のprobeGeometryDelegation(実 tmux 子プロセス)を fake timers で待てず develop で赤だったので stub を追加(tmux/geometry-delegationを mock)。 -
fix(history): History のファイルパスクリックにもチャット面と同じ存在確認を入れる (#2352): History 列(
ConversationPairCard)で存在しないファイルや worktree 外の絶対パスをクリックすると、トーストが出ずに読めないタブがそのまま増えていた。#2345 が正規化を両面に入れた一方、#2274 のHEAD /api/worktrees/:id/files/:pathプローブはChatTranscript.tsxの module-private 関数のままで、History からは呼べなかった(本番:3000/commandagent-developで 2026-09-06 実測:/private/tmp/commandagent-verify-epic.mdのクリックがパネル本体のGET …/files//private/tmp/…を撃ちfile-tab-…が開く)。プローブをsrc/lib/chat/chat-file-probe.tsへ切り出し(probeChatFilePath(worktreeId, path, fetchImpl?)/classifyChatFileProbeResponse()/chatFileProbeUrl()。fetchは注入可能で既定は呼び出し時に解決したグローバルfetch)、ChatTranscriptとHistoryPaneの両方がこれを import する。HistoryPane.handleFilePathClickは正規化 → プローブ →'missing'ならトースト(開かない)/それ以外ならonFilePathClick(target)の順になり、チャット面と同じ本文に同じ答えを返す。404 / 400 / 403 → 'missing'、それ以外(5xx・fetch 例外)→'unknown'(パネルを開いて自身にエラーを報告させる)の写像は変えていない(chat-file-probe-2352.test.tsが表として固定。500 を'missing'に広げる変異で赤くなることを確認済み)。トースト文言キーはchatTranscript.filePathMissing→conversation.filePathMissingへ移動(両面で共有する裸パス button のラベルconversation.openFileと同じ名前空間。英日の表示テキストは不変)。変異注入:HistoryPaneからプローブを外すとHistoryPane-file-probe-2352.test.tsxの 15 件中 10 件が赤になる。 -
fix(push): 完了通知 dedup の
lastSentをglobalThisに載せ、module graph をまたいでも同じ完了を 2 通送らない (#2228):src/lib/push/notification-dedup.tsのlastSent(content hash + 30 秒窓の dedup 記録)が素のモジュールスコープnew Map()だったため、next startで custom server graph(dist/server/…)と Next route graph(.next/server/chunks/…)が同モジュールを別々に評価すると(#2220 で実測)graph ごとに別の記録を持ち、graph A の poller が通知した完了を、poller の停止・再開で owner になった graph B が再検出すると双方の dedup を通過して再送しうる(#2223 の根本原因分析で発見。同時 2 本の poller は #2223 で消えたが、ownership が graph をまたいで移るこの残存経路は塞がれていなかった)。同じファイルのwaitingSent(__waitingPushDedup、#1790)や__promptDedupSkips(#1736)/__terminalSnapshotVersions(#2220)/__chatTurnProgressState(#2199)/__responsePollerCoordinator(#2223)と同じ形でdeclare global { var __notificationDedupLastSent }+globalThis.__notificationDedupLastSent ?? (globalThis.__notificationDedupLastSent = new Map())にした。変更は共有スコープだけで、dedup キー(worktreeId:kind)・content hash(sha256)・窓(DEFAULT_DEDUP_WINDOW_MS = 30_000)・shouldSendNotification()/resetNotificationDedup()のシグネチャは無変更(Issue 本文のclearNotificationDedup()は実在せず、resetNotificationDedup()がそれに当たる)。vi.resetModules()で本物の 2 つ目の module instance を作る横断テスト(tests/unit/lib/push/notification-dedup-cross-graph-2228.test.ts、5 件)で「1 つ目で記録した完了が 2 つ目から抑止される」「後から作られた instance にも見える」「窓経過後は従来どおり再送」「別 hash は送る」「片方の reset がもう片方にも効く」を固定し、素のモジュールスコープへ戻す変異注入で 3 件が赤になることを実測した。globalThisはvi.resetModules()後も残るため、テストは前後で slot を明示的にdeleteする。 -
fix(api): 不在のテキストファイルを GET したとき 500 ではなく 404
FILE_NOT_FOUNDを返す (#2349):GET /api/worktrees/:id/files/<path>は 4 分岐(?download=1/ 画像 / 動画・PDF / テキスト)を持ち、前 3 者はそれぞれENOENTを捕まえて 404FILE_NOT_FOUNDを返していたが、テキスト分岐だけは入口のstat(Issue #469 のLast-Modified用)がどのtryにも囲まれておらず、ファイルが無いと最外周のcatchに落ちて 500INTERNAL_ERROR/ "Failed to read file" になっていた(実測 2026-09-06、本番:3000:no/such/file-xyz.png→ 404、no/such/file-xyz.md/.txt→ 500。worktree 外の絶対パスが Next.js の 308 正規化で相対パスとして解決され不在になる場合も同じ)。?startLine&endLineの行範囲モードも同じstatを先に通るため 500 だった。このstatを他 3 分岐と同じ形(code === 'ENOENT'→FILE_NOT_FOUND、それ以外はthrow err)で囲んだ。ENOENT 以外(EACCES など)は従来どおり最外周へ流して 500 のままで、logger.error('error-reading-file:')も残る。HEAD(#2274 で追加、チャット面の存在プローブが使う)は既にENOENT/ENOTDIR/ENAMETOOLONG→ 404 を自前で持っていたので無変更。実在ファイルの 200 応答(content/totalBytes/Last-Modified/Cache-Control)とIf-Modified-Sinceの 304、行範囲の部分応答、画像・動画・PDF・download の 404 は実 temp worktree に対する unit test で固定した(tests/unit/app/api/worktrees/files-text-not-found-2349.test.ts。変異注入 4 種 — ENOENT 分岐削除/FILE_NOT_FOUND→INTERNAL_ERROR/全エラー握り潰し/修正前コード — がそれぞれ赤になることを実測)。なお Issue が想定した UI 経路とは食い違いがある: チャット面(ChatTranscript)のprobeChatFilePathは GET ではなく HEAD を撃つので本番でも既に 404 → トーストになっており、History(HistoryPane.handleFilePathClick)は存在確認を一切せずそのままタブを開く。UAT(#2345 TC-03)で「トーストが出ずタブが開く」と観測されたのは History 経由で、そこで捕まえた 500 はプローブではなくファイルパネル本体の GET である。したがって本修正でファイルパネルには 404 が届くようになるが、History でトーストを出すには HistoryPane 側にプローブを足す別 Issue が必要(本 Issue の scope 外)。