# perf: tool-store の save() がツール呼び出しごとにリスト全体を再 stringify する（#1507）

## User Prompt

> あと、これに書かれていた他の課題残っていたらかいぜんしていきたいし、この2000msってのもどうにかスピードアップしたいね

#1501（取り下げ済み）に「この issue の主題ではない」として併記されていたもの。主因ではないという
評価はこちらの計測でも同じだが、直す価値はあるので #1506 から分離して #1507 として起票した。

## 診断

`createSessionStore` の `save()` は、セッションのリスト **全体** を毎回 `JSON.stringify` して
ファイルに書き直す。これが `recordToolCallStart`（PreToolUse）と `recordToolCallEnd`
（PostToolUse）の**両方**から呼ばれるので、**1 ツール呼び出しにつき 2 回**走る。

リストは `TOOLCALLS_LIMIT = 200` 件、各 `toolOutput` は `TOOL_OUTPUT_CAP = 20,000` 文字まで
保持するので、活発なセッションでは上限に張り付く。

計測（macOS / Apple Silicon、現実的なエスケープ入りデータ）:

| JSON サイズ | `JSON.stringify` |
|---|---|
| 0.65 MB | 0.8 ms |
| 3.39 MB | 3.8 ms |
| 6.13 MB | 8.0 ms |

`stringify` は同期なのでイベントループを止める。加えて数 MB のディスク書き込みが 2 回。

（`save()` は `void` されており `stringify` は `await fs.mkdir` の後に走るので、`/api/hook` の
`res.json` を直接ブロックしてはいない。止めるのはイベントループのほう。#1501 の記述はこの点だけ
不正確だった。）

## 修正

`SAVE_COALESCE_MS = 50` の窓に入った save は 1 回の write を共有する。

- **速いツールは Pre と Post が同じ窓に入る**ので、書き直しが 2 回から 1 回になる
- 遅いツールは窓の外に出て従来どおり 2 回書くが、その頻度なら元から問題にならない
- 窓の代償は「その間に kill されたら失うもの」= 表示専用の履歴の最後の 1 変更

あわせて、**1 セッションの write を直列化**した。`writeFile` 2 本が同じパスを同時に truncate して
書き戻すと、短いほうが後に終わったときどちらのバージョンでもないファイルが残る。これは今回の
コアレスで起こりにくくはなるが、元からあった穴なので塞ぐ。

`owned` への追加は**同期のまま**にしてある。`get()` がこれを見て「ディスクのほうが新しいかも」を
判断しており、save から write までの間にそれが真になってはいけない。

`save()` が返す Promise の契約（await したら書き終わっている）は維持した。既存 spec がそれに
依存している。

## 計測（本物のモジュール、速いツール100回分の Pre/Post を再現）

| ツールの応答時間 | save 呼び出し | 実際の write | 変更前 | シリアライズ量 |
|---|---|---|---|---|
| 即時 | 200 | 3 | 200 | 5.6 MB / 374 MB |
| 5 ms | 200 | 12 | 200 | 24.8 MB / 413 MB |
| 30 ms | 200 | 51 | 200 | 108 MB / 425 MB |
| 200 ms | 200 | 101 | 200 | 216 MB / 428 MB |

遅いツールでも半減するのは、ある呼び出しの Post と次の呼び出しの Pre が同じ窓に入るため。

## 検証

- コアレスされること / 1 回の write が両方の変更を含むこと / 窓の外なら書き直すこと /
  同一セッションの write が同時に走らないこと（`tool-store.spec.ts`）
- `yarn format` / `lint` / `typecheck` / `build` / `test`
