Skip to content

v0.31.2

Choose a tag to compare

@Kewton Kewton released this 06 Sep 05:57
· 21 commits to main since this release
03e5d95

[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 を捕まえて 404 FILE_NOT_FOUND を返していたが、テキスト分岐だけは入口の stat(Issue #469 の Last-Modified 用)がどの try にも囲まれておらず、ファイルが無いと最外周の catch に落ちて 500 INTERNAL_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 外)。