feat: Task表示のレビュー・修正

This commit is contained in:
Keisuke Hirata 2026-05-04 17:28:39 +09:00
parent cb7da11de7
commit f33eba3fe6
8 changed files with 278 additions and 10 deletions

1
Cargo.lock generated
View File

@ -3620,6 +3620,7 @@ dependencies = [
"session-store",
"tokio",
"toml",
"tools",
"unicode-width",
"uuid",
]

View File

@ -6,6 +6,7 @@
- Pod: 空応答ターン (Submit 後 AI 応答ゼロで Pause/Cancel) を自動巻き戻し → [tickets/pod-empty-turn-rollback.md](tickets/pod-empty-turn-rollback.md)
- Pod: 任意ターンからの Fork複数ターン巻き戻しを汎用化 → [tickets/pod-session-fork.md](tickets/pod-session-fork.md)
- Pod: 子→親の TurnEnded/Errored callback を親由来ターンのみに絞る → [tickets/pod-parent-turn-callback.md](tickets/pod-parent-turn-callback.md)
- Pod: セッションログをバックエンドにした Pod 単位の永続化 → [tickets/pod-persistent-state.md](tickets/pod-persistent-state.md)
- llm-worker のエラー耐性
- ストリーム途中失敗時の継続 → [tickets/llm-worker-stream-continuation.md](tickets/llm-worker-stream-continuation.md)
- ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md)

View File

@ -17,3 +17,6 @@ manifest = { workspace = true }
session-store = { workspace = true }
pod-registry = { workspace = true }
serde = { workspace = true, features = ["derive"] }
[dev-dependencies]
tools = { workspace = true }

View File

@ -315,3 +315,116 @@ mod tests {
);
}
}
/// Cross-crate contract tests. The TUI deliberately re-implements a
/// stripped-down mirror of `tools::TaskStore` instead of depending on
/// the real one (see `tickets/tui-task-display.md`). That decoupling
/// means a format change on the tools side — a renamed field on
/// `TaskEntry`, a different fence syntax in `render_snapshot`, a new
/// JSON wrapper — would silently leave the TUI parsing nothing instead
/// of failing loudly.
///
/// These tests pull `tools` in as a dev-dependency so the contract is
/// exercised at CI time. If they fail, either the format genuinely
/// changed (update both sides) or the TUI mirror has drifted (re-sync
/// it).
#[cfg(test)]
mod cross_format_contract {
use super::*;
use tools::task::{TaskStatus as ToolsTaskStatus, TaskStore as ToolsTaskStore};
/// Mirrors the envelope `Pod::try_pre_run_compact` wraps the raw
/// snapshot text in. Hand-rolled here so the test fails loudly if
/// the prose around the JSON fence ever shifts.
fn wrap_pod_style(snapshot_text: &str) -> String {
format!(
"[Session TaskStore snapshot]\n\n{snapshot_text}\n\n\
This is the complete session task list preserved across compaction. \
The following TaskList tool result presents the same state through the tool lane."
)
}
fn tools_status_label(s: ToolsTaskStatus) -> &'static str {
match s {
ToolsTaskStatus::Pending => "pending",
ToolsTaskStatus::Inprogress => "inprogress",
ToolsTaskStatus::Completed => "completed",
ToolsTaskStatus::Deleted => "deleted",
}
}
fn tui_status_label(s: TaskStatus) -> &'static str {
match s {
TaskStatus::Pending => "pending",
TaskStatus::Inprogress => "inprogress",
TaskStatus::Completed => "completed",
TaskStatus::Deleted => "deleted",
}
}
#[test]
fn tools_snapshot_text_round_trips_into_tui_store() {
let upstream = ToolsTaskStore::new();
upstream.create("first".into(), "first desc".into());
upstream.create("second".into(), "second desc with\nnewline".into());
upstream
.update(1, Some(ToolsTaskStatus::Inprogress), None, None)
.expect("update 1");
upstream
.update(2, Some(ToolsTaskStatus::Completed), None, None)
.expect("update 2");
let envelope = wrap_pod_style(&upstream.snapshot_text());
let mut downstream = TaskStore::new();
downstream.apply_system_message_text(&envelope);
let upstream_tasks = upstream.list();
let downstream_tasks = downstream.tasks();
assert_eq!(
downstream_tasks.len(),
upstream_tasks.len(),
"TUI parsed wrong number of tasks — `tools::render_snapshot` shape may have shifted"
);
for (u, d) in upstream_tasks.iter().zip(downstream_tasks.iter()) {
assert_eq!(d.taskid, u.taskid);
assert_eq!(d.subject, u.subject);
assert_eq!(d.description, u.description);
assert_eq!(tui_status_label(d.status), tools_status_label(u.status));
}
}
#[test]
fn tools_taskentry_field_shape_deserializes_into_tui_taskentry() {
// A single `tools::TaskEntry` round-tripped through JSON. Field
// renames like `taskid` → `task_id` or status case changes on
// the tools side would surface here as a serde failure or a
// wrong-status assertion.
let upstream = ToolsTaskStore::new();
let created = upstream.create("subj".into(), "desc".into());
let json = serde_json::to_string(&created).expect("serialize tools::TaskEntry");
let parsed: TaskEntry =
serde_json::from_str(&json).expect("deserialize into tui::task::TaskEntry");
assert_eq!(parsed.taskid, created.taskid);
assert_eq!(parsed.subject, created.subject);
assert_eq!(parsed.description, created.description);
assert_eq!(tui_status_label(parsed.status), "pending");
}
#[test]
fn empty_tools_store_snapshot_is_recognised_by_tui() {
// Edge case: a freshly initialised TaskStore still produces a
// valid snapshot envelope. The TUI must parse it as "zero
// tasks", not silently fall through to no-op.
let upstream = ToolsTaskStore::new();
let envelope = wrap_pod_style(&upstream.snapshot_text());
// Seed the TUI store with stale state to confirm replacement.
let mut downstream = TaskStore::new();
downstream.apply_tool_call("TaskCreate", r#"{"subject":"stale","description":""}"#);
assert_eq!(downstream.tasks().len(), 1);
downstream.apply_system_message_text(&envelope);
assert!(downstream.is_empty());
}
}

View File

@ -66,25 +66,30 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
let input_render = app.input.render(input_content_width);
let input_height = input_area_height(&input_render, area.height);
let mini_view_h = task_mini_view_height(&app.task_store);
// One blank row separates the history tail from the mini-view so
// the latest message doesn't visually crash into the task summary.
// Folds away with the mini-view when there are no tasks.
let mini_view_gap = if mini_view_h > 0 { 1 } else { 0 };
let chunks = Layout::vertical([
Constraint::Min(0), // history view
Constraint::Length(mini_view_h), // task mini-view (0 when empty)
Constraint::Length(1), // separator
Constraint::Length(1), // status
Constraint::Length(input_height), // input area
Constraint::Min(0), // history view
Constraint::Length(mini_view_gap), // gap above mini-view
Constraint::Length(mini_view_h), // task mini-view (0 when empty)
Constraint::Length(1), // separator
Constraint::Length(1), // status
Constraint::Length(input_height), // input area
])
.split(area);
draw_history(frame, app, chunks[0]);
if mini_view_h > 0 {
draw_task_mini_view(frame, &app.task_store, chunks[1]);
draw_task_mini_view(frame, &app.task_store, chunks[2]);
}
draw_separator(frame, chunks[2]);
draw_status(frame, app, chunks[3]);
draw_input(frame, &input_render, chunks[4]);
draw_separator(frame, chunks[3]);
draw_status(frame, app, chunks[4]);
draw_input(frame, &input_render, chunks[5]);
if let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) {
draw_completion_popup(frame, state, chunks[4]);
draw_completion_popup(frame, state, chunks[5]);
}
}

View File

@ -0,0 +1,84 @@
# Pod: セッションログをバックエンドにした Pod 単位の永続化
## 背景
現在の永続化の主軸は session-store の append-only JSONL ログで、`SessionId` 単位に会話履歴・設定・scope snapshot・usage・拡張 payload を復元できる。一方で Pod 単位のランタイム状態は `<runtime_dir>/{pod_name}/` 配下の `status.json` / `history.json` / `spawned_pods.json` などに write-through されているが、runtime dir は再起動で消えてよい領域であり、Pod プロセスの寿命を超える復元ソースとしては扱えない。
特に spawned Pod の管理情報は `SpawnedPodRegistry` のコメントにもある通り、現状は runtime dir への write-through のみで、再起動した spawner が子 Pod 一覧を rebuild する future work になっている。
このチケットでは、既存の session-store を物理バックエンドとして利用しつつ、Pod 名をキーにした永続状態を追加し、Pod 単位で「最後にどの session を保持していたか」「spawned children をどう復元するか」を扱えるようにする。
## 方針
- session log は引き続き会話状態の唯一の復元ソースにする。
- `history.json` や runtime dir の snapshot を永続正本にはしない。
- LLM context に載せる新規 input は、既存方針通り先に worker history / session log に commit されている必要がある。
- Pod 単位の永続化は「Pod identity → session / child registry などへの参照」を保存する薄いメタデータ層として設計する。
- 会話本文を二重保存しない。
- active session だけでなく、compaction / fork / resume によってその Pod が辿ってきた過去 session を順序付きで保持する。これは UI の履歴表示、直近以前への復元、active session 変更の監査に使う。
- session-store の `Store` trait を拡張するか、隣接 trait / module を追加して、FsStore 以外の backend でも同じ形で実装できるようにする。
- FsStore のデフォルト layout は `<data_dir>/pods/` 配下など、`sessions/` と同じ data_dir 管理下に置く。
- runtime dir (`<runtime_dir>/{pod_name}/`) は引き続き socket / pid / status など一時状態専用。
- Pod lifecycle 上の write point を明確にする。
- Pod 作成時: pod name と allocated session id を記録。
- first run で `SessionStart` が materialize された後: active session / head を更新できる状態にする。
- compaction / fork / resume で active session が変わる場合: Pod state も同時に更新。
- `SpawnPod` / callback / `StopPod` による child registry 変更時: runtime dir だけでなく persistent Pod state にも write-through。
- 復元時は Pod state から active session を解決し、その session log を `restore_from_manifest` 相当の経路で復元する。
- session id を明示した resume は既存通り session を直接指定できる。
- Pod 名 resume は Pod state → active session → session restore の順に解決する。
- live writer 衝突は既存の pod-registry / session_id collision check を維持する。
## データ粒度の考え方
- ユーザー視点の会話継続単位と、内部の append-only log 単位を分けて扱う。
- ユーザー視点: Pod / thread / conversation のような安定 ID。compaction しても同じ会話として継続する。
- 内部 log 視点: session segment / revision / epoch のような履歴再構築単位。compaction や fork で新しい log root が必要なら新 ID になる。
- 現状の `SessionId` は内部 log 単位の性質が強い。compaction は履歴を要約済み prefix に置き換えて新しい append-only chain を始めるため、低レベルには「新 session」として扱うのは自然。ただし UX / データモデル上は「同じ Pod conversation の新 revision」と見せる。
- 将来 DB backend を追加する場合も、`Conversation/PodState` と `SessionSegment` を分ける形に寄せる。
- `pod_state.active_session_id` は現在 append 先の segment を指す。
- `pod_state.session_history[]` は Pod 視点で active だった segment の順序付き履歴。
- compaction / fork の構造的 lineage は session log の `SessionOrigin` または DB の relation として保持し、Pod state は「この Pod がどれを active にしたか」の操作履歴に留める。
## 要件
- Pod 名をキーに、少なくとも以下を永続化できること:
- active `SessionId`
- ordered session history: その Pod が active として保持してきた `SessionId` の時系列リスト
- 各 entry には最低限 `session_id` と遷移理由new / resume / compact / fork など)を持たせる
- compaction / fork の構造的な出自は session log の `SessionOrigin` を正本とし、Pod state 側は Pod 視点の active session 遷移履歴として扱う
- Pod manifest / scope 復元に必要な参照または snapshot の扱い(既存 session log の `pod.scope` snapshot と責務を重複させない)
- spawned children の registrypod name, socket path, delegated scope, callback address, child session id が必要なら含める)
- `SpawnedPodRegistry` が runtime dir の `spawned_pods.json` だけでなく、Pod 永続状態から初期化できること。
- `ListPods` / `SendToPod` / `ReadPodOutput` / `StopPod` は、復元後の spawner でも永続化された child registry を基に動作できること。
- ただし `ReadPodOutput` の read cursor は session-lifetime / in-memory のままでよい。永続化対象にしない。
- Pod の compaction により active session id が変わった場合、Pod 永続状態と pod-registry の session id が整合すること。
- 既存の `--session <UUID>` resume は壊さない。
- 新しい Pod 名単位 resume / attach の入口を決めること。
- 例: `pod --pod-state <name>` ではなく、既存 `pod.name` と manifest cascade から同名 Pod state を探す形など。
- CLI / TUI の最小導線を本チケット内で確定する。
## 完了条件
- `session-store` に Pod 単位メタデータを扱う backend API と FsStore 実装がある。
- Pod state が active session と ordered session history を保持し、new / resume / compaction / fork の遷移が順序付きで記録される。
- 新規 Pod 起動、resume、compaction、spawn / stop の各タイミングで Pod 永続状態が更新される。
- Pod プロセス再起動後、Pod 名から active session を復元し、会話を継続できる。
- spawner Pod の再起動後、永続化された spawned children 一覧から `ListPods` が復元され、到達可能な child に対して comm tools が使える。
- runtime dir は引き続き一時状態として扱われ、永続正本に依存しない。
- live writer の二重起動は既存 pod-registry / session lock と同等以上に防止される。
## 範囲外
- 会話履歴そのものの保存形式変更。
- session log の DB 化や remote backend 実装。
- Pod state の自動 GC / retention policy。
- TUI 上の高度な Pod 一覧 UI。最小限の resume / attach 導線を超える UX は別チケット。
- `ReadPodOutput` cursor の永続化。
## 関連
- `crates/session-store/`: 既存の session append-only backend。
- `crates/pod/src/runtime/dir.rs`: runtime dir の `history.json` / `spawned_pods.json`
- `crates/pod/src/spawn/registry.rs`: spawned children registry。現状は write-through のみで復元未実装。
- `tickets/pod-session-fork.md`: active session 切り替え設計との整合が必要。

View File

@ -78,3 +78,8 @@
- TUI 側受信パス: `crates/tui/src/app.rs``Event::ToolCallDone` / `Event::SystemMessage` / `Event::History`)、`crates/tui/src/ui.rs`、`crates/tui/src/block.rs``ToolCallBlock` / `SystemMessage`
- 設計指針: `AGENTS.md`「LLM コンテキストの加工原則」history からの決定的再構成は許容変換)
- 関連チケット: `tickets/session-todo.md`Task ツール本体)、`tickets/session-todo-reminder.md`LLM 側ナッジ)
## Review
- 状態: Approve
- レビュー詳細: [./tui-task-display.review.md](./tui-task-display.review.md)
- 日付: 2026-05-04

View File

@ -0,0 +1,56 @@
# Review: TUI に Task ストア状態を表示する
## 前提・要件の確認
### TUI 側の TaskStore 取り回し
- `App` に最新 `TaskStore` を持つ: `crates/tui/src/app.rs:84` `pub task_store: TaskStore` で保持。型は TUI 内 mirror`crates/tui/src/task.rs`)で `tools` 依存なし。要件通り。
- `ToolCallDone``TaskCreate` / `TaskUpdate` を反映: `crates/tui/src/app.rs:569-586`、name は当該ブロックから取得し `task_store.apply_tool_call(name, &arguments)` を呼ぶ。完了タイミングのみで部分引数は混入しない。要件通り。
- 部分引数streaming 中)を適用しない: `Event::ToolCallArgsDelta``app.rs:561-568`)は `args_stream.push_str` のみで `task_store` は触らない。要件通り。
- `SystemMessage` 観測時、`[Session TaskStore snapshot]` 始まりなら snapshot を適用: `crates/tui/src/app.rs:383-386``push_history_item` の "system" ブランチ)から `task_store.apply_system_message_text(&text)` を呼ぶ。`Event::SystemMessage` は `app.rs:494-497``push_history_item` を経由するため live 経路もカバー。要件通り。
- 初回 history replay でも同じ経路: `restore_history``task_store = TaskStore::new()` でリセット後(`app.rs:857-858`)、`push_history_item` を順に通すため自動復元される。要件通り。
- 不正 JSON や未知フィールドを黙って無視: `apply_tool_call` の各 match arm で `if let Ok(...)` ガード、`apply_system_message_text` も `parse_snapshot_text``Option` を返す。`crates/tui/src/task.rs:213-220` のテスト `malformed_arguments_are_silently_ignored` で挙動確認済み。要件通り。
### ミニビュー
- 配置 `history / mini-view / separator / status / input`: `crates/tui/src/ui.rs:70-77``Layout::vertical` で確認。要件通り。
- 0 件時は領域ごと畳む: `task_mini_view_height``is_empty()` で 0 を返し、`Constraint::Length(0)` で行ごと消える(`ui.rs:99-106`)。要件通り。
- 最大 3 件 active を表示 + サマリ 1 行: `MINI_VIEW_MAX_ACTIVE = 3``ui.rs:94`)と `mini_view_summary_line` で実装。subject の改行は `entry.subject.lines().next()` で先頭行のみ(`ui.rs:141`)。要件通り。
- 件数サマリ: pending / inprogress / completed / deleted の内訳を1行で表示`ui.rs:153-167`)。要件通り。
### サイドペイン
- トグルキー `Ctrl-T`: `crates/tui/src/main.rs:436-439`。要件通り。
- 横分割は history 領域内のみ: `draw_history` 内で `Layout::horizontal` 分割し、status / input / separator は外側 `chunks` の全幅を維持(`ui.rs:326-334`)。要件通り。
- 全件pending / inprogress / completed / deleted+ description を含めて列挙: `draw_task_side_pane``ui.rs:386-460`)。各エントリは `taskid` + status mark + subject + description を表示。要件通り。
- スクロール対応: `task_pane_scroll``App` に保持、`PageUp` / `PageDown` がペイン open 時に優先(`main.rs:471-489`)。最大値で clamp`ui.rs:449-452`)。要件通り。
### レイアウトと一貫性
- ミニビューとサイドペインは同じ `App::task_store` を参照する単一情報源(`ui.rs:81, 399`)。要件通り。
- 既存の completion popup は `chunks[4]` (input rect) を使うため、ミニビューが挟まっても座標計算に影響なし(`ui.rs:86-88, 191-246`)。
- スクロール / status line は `chunks[0]``inner` を使うため、サイドペインが開いてもスクロール計算は分割後の history rect に追従(`ui.rs:347-350`)。
- 副作用無し。
### 完了条件
すべて満たしている。`cargo test -p tui` 71 件合格、`cargo build --workspace` クリーン。
## アーキテクチャ・スコープ
- **`tools` 依存を持ち込まない方針** が一貫して守られている。TUI 内 `task.rs` mirror は `tools::TaskStore` 全機能ではなく TUI が必要とする最小サブセット(`apply_tool_call` / `apply_system_message_text` / `tasks()` / `counts()`)に絞っている。`feedback_llm_worker_scope` / 「TUI は表示専門レイヤ」の方針に整合。
- `serde``cargo add` で追加(`crates/tui/Cargo.toml:19`。memory の `feedback_cargo_add` 遵守。
- protocol 表面は無変更。`Event::SystemMessage` / `Event::ToolCallDone` / `Event::History` の既存経路にすべて乗っている。チケットの「protocol 表面を増やさない」目標を達成。
- LLM コンテキスト加工原則: history からの純粋な再構成変換であり context への割り込みではない。整合済み。
- ファイル分割: 新規 `task.rs` モジュール180行 + テスト)として切り出されており、`app.rs` / `ui.rs` への変更も既存パターンcache.rs / scroll.rs と同じ位置付け)に整合。コードベースを歪めていない。
## 指摘事項
### Non-blocking / Follow-up
- **snapshot text フォーマット契約の自動検出機構が無い**: `crates/tui/src/task.rs:168-179` `parse_snapshot_text``crates/tools/src/task.rs:393-404` `parse_compact_snapshot_text` と byte-for-byte 同一の手書きクローン。`tools::render_snapshot` のフォーマットフェンス記号・改行配置・ヘッダ文字列が変わると、TUI は無言で snapshot 検出を失敗するだけで CI からは見えない。Doc コメント(`task.rs:8-17`)と TUI 側ユニットテストの手書き fixture`wrap_snapshot` ヘルパ)で「契約に乗っているつもり」を表明しているが、`tools::render_snapshot` の出力をそのまま食わせる意味でのリンク確認は無い。
- 落としどころ案: `crates/tools``dev-dependencies` だけに入れ、`#[cfg(test)]` で `tools::render_snapshot(...)` の実出力を `tui::task::TaskStore::apply_system_message_text` に流すクロステストを 1 本足す。本番ビルドの依存は増やさず、契約破綻だけ拾える。今のスコープ外でも構わない。
- **History replay 経路に「retained TaskCreate → 末尾 snapshot で上書き」シナリオの App テストが無い**: `tools` 側には `trailing_snapshot_supersedes_pre_compact_taskcreates_in_retained``crates/tools/src/task.rs:584-628`があるが、TUI の `App::handle_pod_event(Event::History { ... })` 経由で同じ並びを通す App レベルテストは未追加。実装上は `push_history_item` を順に呼ぶだけなので動くはずで、`history_replay_reconstructs_task_store``app.rs:1404-1448`)と `snapshot_text_replaces_state_and_advances_next_id``task.rs:251-284`)の組み合わせで間接的にカバーはされている。リグレッション保護として 1 本欲しい程度の話。
- **狭い端末でのトグル無反応**: `task_side_pane_width``ui.rs:373-384`)は `area_width < 60` なら 0 を返すため、`task_pane_open == true` でも視覚的に何も起きない。Ctrl-T が "効いていない" ように見えるリスクがある。レアケースなので非ブロッキング。
### Nits
- `crates/tui/src/task.rs:165-166``TaskSnapshot``tasks` フィールドのみだが、本家 `tools::TaskSnapshot` には他フィールドが追加される可能性がある。`#[serde(deny_unknown_fields)]` を付けないことで forward-compat を保っている(適切)が、その意図を 1 行コメントしておくと将来読み手に親切。
- `task_status_mark``ui.rs:172-184`)の戻り値タプルに型エイリアスを入れると、サイドペイン側でも mini-view 側でも使われている共有意図がコードから読みやすい。今のままで実害は無い。
## 判断
**Approve** — チケットの前提・方針・要件・完了条件をすべて満たしており、設計の意図(`tools` 非依存・protocol 不変・history からの純粋再構成も忠実に実装されている。tools 側との snapshot フォーマット契約はテキスト一致でしか担保されていないが、これはチケットが意識的に選んだトレードオフであり、必要であれば dev-dependency でクロステストを 1 本足す程度のフォローアップで十分。コードベースを歪める箇所は無い。