yoi/tickets/log-entry-singular-and-direct-commit.md

104 lines
8.5 KiB
Markdown

# Log writer を sync 化 + LogEntry を単数化
## 背景
`tickets/system-item-unify.md``Event::SystemItem` / `LogEntry::SystemItems` を導入し、 Notify / PodEvent / 各 ref 解決を 1 経路に統合した。 ただし実装の過程で 2 つの設計上の歪みが残った:
**1. `LogEntry::SystemItems { items: Vec<SystemItem> }` の複数形は実需要が無い**
- 既存 `LogEntry::AssistantItems { items: Vec<LoggedItem> }` / `ToolResults { items: Vec<LoggedItem> }` の形に揃えて plural にしたが、 これは旧 `save_delta` の turn 末尾バッチ commit 時代の名残
- 現在は worker の `on_history_append` callback が per-item で発火し、 drain task が 1 件ずつ classify+commit する形に移行済み。 `items: Vec<_>` の中身は常に 1 件
- `SystemItems` も同様で、 interceptor が drain した N 件をまとめているだけで「グルーピング」 の意味はない (同 drain 機会に来ただけ)
- wire 側では IPC server が `items.into_iter().map(...)` で per-item の `Event::SystemItem` に fan-out しており、 LogEntry 上のバッチと wire 上の単発の非対称が発生している
**2. `LogCommand::SystemItems` 追加が二重設計**
- `LogCommand` は元来「worker の sync callback (`on_history_append`) を async commit に橋渡しする」 ための小さな ferry。 `Item + Flush` の 2 variant だけだった
- system items に typed kind 情報を運ぶために `LogCommand::SystemItems(Vec<SystemItem>)` を増やしたが、 interceptor は async コンテキストなので sync→async の橋渡しは要らない。 単に「SystemItem を直接 commit する経路」 として LogCommand を流用しているだけで、 `LogCommand` の元来の責務 (sync→async ferry) を曖昧にしている
**3. そもそも log 周りで async が必要だった理由はほぼ無い**
log 周りで async になっている箇所を棚卸しすると:
| 箇所 | 現在 async | 本当に async でないとダメか |
|------|-----------|---------------------------|
| `Store::append` (FsStore) | tokio::fs | ❌ `std::fs::OpenOptions` + `writeln!` で良い (1 行 append、 < 1KB < 1ms) |
| `Store::read_all` (restore ) | tokio::fs::read_to_string | Pod 起動時 1 hot path じゃない |
| `Store::read_head_hash` | tokio::fs | 同上 |
| `session_head` mutex | `tokio::sync::AsyncMutex` | async は不要 sync mutex で十分 |
| `sink.publish` / `subscribe_with_snapshot` | sync (元から) | |
| `broadcast::send` | sync (元から) | |
つまり log subsystem async にしていた本当の理由は **tokio::fs を default 選択した点だけ** hash chain 維持のためでも性能のためでもない そして sync callback async commit を呼べないという mismatch を解消するために `LogCommand` ferry を作る必要があった
log writer sync にすればこの mismatch そのものが消え `LogCommand` / drain task / Flush バリアは丸ごと不要になる
## 方針
**A. log writer を sync に切り替える**
- `Store::append` / `read_all` / `read_head_hash` `std::fs` / `std::io` ベースの sync API に変更 (or sync 版を併設して段階移行)
- `session_head` `tokio::sync::AsyncMutex` から `parking_lot::Mutex` (or `std::sync::Mutex`) に変更
- `SessionLogWriter::append_entry()` sync 関数に disk write の間 caller thread ms 未満ブロックする (local fs append < 1KB 実害なし)
- Pod `commit_entry().await` `commit_entry()` (await 無し)
- worker `on_history_append` callback が直接 `writer.append_entry(classified_entry)` を呼ぶ
- interceptor `Arc<SessionLogWriter>` を持って直接呼ぶ
**B. `LogCommand` / drain task / Flush バリアの撤廃**
- `LogCommand` enum を削除
- `run_log_drain` 関数を削除
- mpsc channel (`log_cmd_tx` / `log_cmd_rx`) を削除
- `persist_turn` Flush バリアを削除 (sync 書き込みなので順序は call 順で自然に決まる)
- worker callback で直接 sync 書き込みするので `Worker::on_history_append` 経由のフローはそのまま (sync closure)
**C. LogEntry のアシスタント / ツール / システム系を単数バリアントに揃える**
- 新名 (write side):
- `LogEntry::AssistantItem { ts, item: LoggedItem }`
- `LogEntry::ToolResult { ts, item: LoggedItem }`
- `LogEntry::SystemItem { ts, item: SystemItem }`
- plural variant (`AssistantItems` / `ToolResults` / `HookInjectedItems` / `SystemItems`) **読み出し専用 (deserialize-only)** として残置 既存セッションログを開けることは保証する
- `collect_state` は新旧両方をフラットに `state.history` に展開
- 1 entry = 1 item になることで commit 経路が `LogEntry::<Singular> { item }` に直接 1:1 写像する
- wire 側の `Event::SystemItem` (per-item) log 上の `LogEntry::SystemItem` (per-item) が完全対称になり IPC server fan-out ロジックが消える
ハッシュチェーン長は entry 数に比例して長くなるが hash chain 自体は別チケットで廃止予定なので長さの悪化は受け入れる
## 要件
- `Store` trait の主要メソッド (`append` / `read_all` / `read_head_hash` / `create_session` ) sync API に統一する async 版を残す必要があるかは内部判断 (例: 起動時に大きい log を読む `read_all` だけ async を残す案もあり 必要に応じて選択)
- `SessionLogWriter` (Pod 側ラッパ) sync API に統一し hash chain 計算 + session_head 更新 + sink publish 1 つの sync 関数内で行う
- `session_head` sync mutex に変更
- `LogCommand` / `LogDrainHandle::run_log_drain` / `log_cmd_tx` / `log_cmd_rx` / Flush バリアを削除
- `PodInterceptor::on_prompt_submit` / `pending_history_appends` `Arc<SessionLogWriter>` を直接呼んで per-`SystemItem` commit 完了後に `Item::system_message` worker に返す
- worker `on_history_append` callback が直接 `writer.append_entry(...)` を呼ぶ (sync)
- `LogEntry` `AssistantItem` / `ToolResult` / `SystemItem` の単数 variant を追加し 新規書き込みはこれを使う
- plural variant read 専用として残し deserialize alias または独立 variant のどちらでも実装判断で良い `collect_state` で同じ形に reduce されること
- IPC server `LogEntry::SystemItem` 受信は 1 件の `Event::SystemItem` をそのまま送る単純な対応に
- `cargo test --workspace` pass する
## 完了条件
- `LogCommand` / drain task / Flush バリアがコードから消えている
- worker `on_history_append` callback が直接 sync writer を呼んでいる
- `PodInterceptor` writer を直接呼んで SystemItem commit している (mpsc 経由ではない)
- `Store` および `SessionLogWriter` の主要 commit API sync 関数
- `session_head` mutex 型が `parking_lot::Mutex` または `std::sync::Mutex`
- 新規セッションログに `assistant_item` / `tool_result` / `system_item` (snake_case wire tag) が単数 entry として書かれている
- `assistant_items` / `tool_results` / `hook_injected_items` / `system_items` を含むセッションログが読めて view 復元できる
- IPC server `LogEntry::SystemItem` を受けたら 1 件の `Event::SystemItem` fan-out 無しで送るシンプルなマッピングに戻っている
## 範囲外
- ハッシュチェーン自体の廃止 (entry hash, `prev_hash`, `HashedEntry`, `session_head` mutex の最終撤去) `tickets/persistence-semantics.md` の責務領域 本チケットはその直前段階として mutex sync 化するところまで
- Session / Segment 階層への rename (`tickets/persistence-semantics.md`)
- plural entry disk migration (ファイル書き換え)。 deserialize alias で読めるところまで
- `Event::SystemItem` payload の変更 (引き続き `serde_json::Value` `SystemItem` JSON )
## 関連
- 前提 `tickets/system-item-unify.md` (本チケットで完成した SystemItem 経路を 本チケットで単数化 + direct writer 化する後続)
- 関連 `tickets/pod-state-from-session-log.md` (per-item commit drain task 経路を確立した前段 本チケットでその drain task 自体を撤去する)
- 後続 `tickets/persistence-semantics.md` Entry hash の廃止 セクション 本チケットで sync 化したことが entry hash 廃止の足場になる