From 5c9331e8485d87515c121a07eb6dee4fa5edd5f3 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 24 Jun 2026 21:04:13 +0900 Subject: [PATCH 01/11] ticket: accept queued ui and cleanup tasks --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVWPVHFJ/item.md | 4 +- .yoi/tickets/00001KVWPVHFJ/thread.md | 93 +++++++++++++++++++ .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVWPW3KX/item.md | 4 +- .yoi/tickets/00001KVWPW3KX/thread.md | 85 +++++++++++++++++ 6 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 .yoi/tickets/00001KVWPVHFJ/artifacts/orchestration-plan.jsonl create mode 100644 .yoi/tickets/00001KVWPW3KX/artifacts/orchestration-plan.jsonl diff --git a/.yoi/tickets/00001KVWPVHFJ/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVWPVHFJ/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..2a962664 --- /dev/null +++ b/.yoi/tickets/00001KVWPVHFJ/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260624-120242-1","ticket_id":"00001KVWPVHFJ","kind":"accepted_plan","accepted_plan":{"summary":"Ticket `00001KVWPVHFJ` は implementation_ready。専用 worktree `/home/hare/Projects/yoi/.worktree/00001KVWPVHFJ-storage-cleanup-cli` と branch `work/00001KVWPVHFJ-storage-cleanup-cli` で、safe Pod/session storage cleanup CLI を実装する。destructive operations は dry-run/force/live refusal/path-safety を厳守する。","branch":"work/00001KVWPVHFJ-storage-cleanup-cli","worktree":"/home/hare/Projects/yoi/.worktree/00001KVWPVHFJ-storage-cleanup-cli","role_plan":"Orchestrator: routing acceptance, worktree creation, final integration/validation/cleanup. Coder: CLI/storage implementation in dedicated child worktree. Reviewer: read-only review focusing on path safety, live Pod refusal, dry-run/force semantics, and session history preservation."},"author":"yoi-orchestrator","at":"2026-06-24T12:02:42Z"} diff --git a/.yoi/tickets/00001KVWPVHFJ/item.md b/.yoi/tickets/00001KVWPVHFJ/item.md index 5e9de6b1..8ef911d6 100644 --- a/.yoi/tickets/00001KVWPVHFJ/item.md +++ b/.yoi/tickets/00001KVWPVHFJ/item.md @@ -1,8 +1,8 @@ --- title: 'Pod/session storage cleanup CLI を追加する' -state: 'queued' +state: 'inprogress' created_at: '2026-06-24T11:39:41Z' -updated_at: '2026-06-24T12:01:42Z' +updated_at: '2026-06-24T12:04:07Z' assignee: null readiness: 'implementation_ready' risk_flags: ['pod-lifecycle', 'persistence', 'destructive-operation', 'cli-ux', 'session-history', 'authority-boundary'] diff --git a/.yoi/tickets/00001KVWPVHFJ/thread.md b/.yoi/tickets/00001KVWPVHFJ/thread.md index 6fac5d58..a9778587 100644 --- a/.yoi/tickets/00001KVWPVHFJ/thread.md +++ b/.yoi/tickets/00001KVWPVHFJ/thread.md @@ -13,4 +13,97 @@ LocalTicketBackend によって作成されました。 Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Dashboard Queue により人間が Orchestrator routing を許可した queued Ticket として確認した。 +- Ticket body は `yoi pod delete`, `yoi pod prune`, `yoi session prune --unreferenced` の command spelling、dry-run/force semantics、live Pod refusal、session history preservation、explicit age threshold、validation を具体的に列挙している。 +- `TicketRelationQuery` は blocking relation 0 件、`TicketOrchestrationPlanQuery` は既存 plan 0 件だった。 +- risk flags は pod-lifecycle / persistence / destructive-operation / cli-ux / session-history / authority-boundary だが、destructive operations の safety rails と escalation conditions が明記されている。risk は reviewer focus として扱えばよく、planning return 理由にはならない。 +- 同時 queued Ticket `00001KVWPW3KX` は TUI Console rendering で code surface が別。conflict risk は低く、別 worktree/branch で並列開始可能。 + +Evidence checked: +- Ticket body / thread: `item.md`, `thread.md`。thread は create と ready->queued のみで未解決 blocker は記録されていない。 +- Relations / orchestration plan: relation 0 件、routing 前 plan 0 件。accepted plan `orch-plan-20260624-120242-1` を記録済み。 +- Code map: Grep で `crates/yoi/src/main.rs`, `crates/yoi/src/session_cli.rs`, `crates/pod-store/src/lib.rs`, `crates/session-store`, `crates/pod/src/entrypoint.rs`, `crates/pod/src/discovery.rs` 周辺を確認。 +- Workspace state: `/home/hare/Projects/yoi/.worktree/orchestration` は clean。active inprogress Ticket は 0 件。 + +IntentPacket: + +Intent: +- Pod/session storage を手動削除せずに安全に整理できる公式 CLI を追加し、Pod metadata delete / Pod prune / unreferenced session prune を dry-run-first、force-required、live-safe に実装する。 + +Binding decisions / invariants: +- No silent restore bypass。same-name fresh start は、ユーザーが明示的に stopped/restorable Pod metadata を削除した結果としてのみ発生する。 +- `pod delete` は session logs/history を削除しない。 +- live/reachable Pod metadata は削除しない。live 判定が不確実なら安全側に拒否する。 +- old cleanup に暗黙 threshold を持たせない。`--older-than` など明示 criteria が必要。 +- destructive deletion は `--force` 必須。`--dry-run` / default report を重視する。 +- Pod metadata authority は `pod-store`、session log authority は `session-store` のまま。 +- legacy top-level resume flags / bare Pod-name inference は再導入しない。 +- Panel/TUI の broad Pod manager 化は non-goal。 + +Requirements / acceptance criteria: +- `yoi pod delete [--force] [--dry-run]` で stopped/restorable Pod metadata を削除できる。 +- live/reachable Pod delete/prune は拒否され理由を出す。 +- `yoi pod prune --older-than [--force] [--dry-run]` は explicit threshold なしに old 判定削除しない。 +- `yoi session prune --unreferenced [--older-than ] [--force] [--dry-run]` は Pod metadata active pointer から参照されない session/segment を report/prune できる。 +- delete/prune output は deleted/would delete/kept/refused reason を bounded に示す。 +- focused tests が stopped Pod delete, live refusal, session preservation, unreferenced prune dry-run/force, threshold requirement, CLI parsing/help を cover する。 + +Implementation latitude: +- product CLI 側で management subcommands を捕捉するか、runtime entrypoint 側に安全に追加するかは coder が code map を見て判断してよい。 +- 必要なら shared cleanup module や `session-store` delete API を追加してよい。path safety tests を伴うこと。 +- Orphan detection は初期実装では active `PodMetadata.active.session_id` references を authority としてよい。lineage-aware retention は referenced sessions を削除しない限り follow-up に分けてよい。 +- Output は human-readable でよい。JSON は自然なら追加してよいが必須ではない。 + +Escalate if: +- live Pod detection を安全に拒否できるほど reliable にできない。 +- orphan detection が session lineage semantics の変更を必要とする。 +- Pod delete の副作用として sessions を削除する必要が出る。 +- storage migration / compatibility fallback が必要になる。 +- command design が existing `yoi pod` runtime entrypoint usage と衝突する。 +- cleanup が Panel role-session/Ticket claims, worktrees, branches, Ticket state を mutate しようとする。 + +Validation: +- `cargo fmt --check` +- focused `cargo test` for `yoi`, `pod-store`, `session-store`, affected Pod/discovery code +- `cargo check -p yoi -p pod -p pod-store -p session-store` +- `target/debug/yoi ticket doctor` または `yoi ticket doctor` +- `git diff --check` + +Current code map: +- Primary: `crates/yoi/src/main.rs`, `crates/yoi/src/session_cli.rs`, `crates/pod-store/src/lib.rs`, `crates/session-store/src/fs_store.rs`, `crates/session-store/src/lib.rs`, `crates/pod/src/entrypoint.rs`, `crates/pod/src/discovery.rs`。 +- Avoid: Panel/TUI manager UI, scheduler/stop semantics changes, Ticket/worktree/branch cleanup operations。 + +Critical risks / reviewer focus: +- accidental deletion of session history from `pod delete`。 +- live/reachable Pod metadata deletion。 +- unsafe path deletion or broad directory removal。 +- force/dry-run semantics bypass。 +- ambiguous age parsing/default threshold。 +- breaking `yoi pod` runtime entrypoint spawn/restore behavior。 + +Next action: +- `queued -> inprogress` を記録してから worktree-workflow で dedicated worktree を作成し、Coder/Reviewer sibling loop に進める。 + +--- + + + +## State changed + +Orchestrator acceptance: queued -> inprogress + +- 直前確認で `TicketShow` は state `queued`、blocking relation は 0 件、accepted plan `orch-plan-20260624-120242-1` を確認した。 +- 同時 queued Ticket `00001KVWPW3KX` は disjoint code surface のため別 worktree/branch で並列開始可能と判断した。 +- routing decision と IntentPacket は Ticket thread に記録済み。 +- これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVWPVHFJ-storage-cleanup-cli` を作成し、multi-agent-workflow に接続する。 + --- diff --git a/.yoi/tickets/00001KVWPW3KX/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVWPW3KX/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..00980e0a --- /dev/null +++ b/.yoi/tickets/00001KVWPW3KX/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260624-120242-1","ticket_id":"00001KVWPW3KX","kind":"accepted_plan","accepted_plan":{"summary":"Ticket `00001KVWPW3KX` は implementation_ready。専用 worktree `/home/hare/Projects/yoi/.worktree/00001KVWPW3KX-thinking-group` と branch `work/00001KVWPW3KX-thinking-group` で、TUI Console の render-time Thinking block aggregation を実装する。protocol/history/persistence は変更せず、TUI rendering tests と focused validation を行う。","branch":"work/00001KVWPW3KX-thinking-group","worktree":"/home/hare/Projects/yoi/.worktree/00001KVWPW3KX-thinking-group","role_plan":"Orchestrator: routing acceptance, worktree creation, final integration/validation/cleanup. Coder: TUI implementation in dedicated child worktree. Reviewer: read-only review focusing on render-only aggregation, selection behavior, no protocol/history changes."},"author":"yoi-orchestrator","at":"2026-06-24T12:02:42Z"} diff --git a/.yoi/tickets/00001KVWPW3KX/item.md b/.yoi/tickets/00001KVWPW3KX/item.md index 0eaa7bce..8a10b31e 100644 --- a/.yoi/tickets/00001KVWPW3KX/item.md +++ b/.yoi/tickets/00001KVWPW3KX/item.md @@ -1,8 +1,8 @@ --- title: 'TUI Console: 連続した Thinking block を一つの表示グループにまとめる' -state: 'queued' +state: 'inprogress' created_at: '2026-06-24T11:39:59Z' -updated_at: '2026-06-24T12:01:41Z' +updated_at: '2026-06-24T12:04:07Z' assignee: null readiness: 'implementation_ready' risk_flags: ['tui-rendering', 'reasoning-display', 'block-aggregation', 'text-selection'] diff --git a/.yoi/tickets/00001KVWPW3KX/thread.md b/.yoi/tickets/00001KVWPW3KX/thread.md index c3bfd368..f9772067 100644 --- a/.yoi/tickets/00001KVWPW3KX/thread.md +++ b/.yoi/tickets/00001KVWPW3KX/thread.md @@ -29,4 +29,89 @@ LocalTicketBackend によって作成されました。 Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Dashboard Queue により人間が Orchestrator routing を許可した queued Ticket として確認した。 +- Ticket body は TUI Console の連続 Thinking block を render-time で一つの表示 group にまとめる目的、non-goals、binding invariants、validation を具体的に列挙している。 +- `TicketRelationQuery` は blocking relation 0 件、`TicketOrchestrationPlanQuery` は既存 plan 0 件だった。 +- risk flags は TUI rendering / reasoning-display / block-aggregation / text-selection だが、protocol / pod / persistence 変更をしない、source block sequence を保持する、turn boundary / non-Thinking block を跨がない、selection behavior を壊さない、という invariant が明示されている。risk は reviewer focus として扱えばよく、planning return 理由にはならない。 +- 同時 queued Ticket `00001KVWPVHFJ` は Pod/session storage cleanup CLI で code surface が別。conflict risk は低く、別 worktree/branch で並列開始可能。 + +Evidence checked: +- Ticket body / thread: `item.md`, `thread.md`。thread は create、planning->ready、ready->queued のみで未解決 blocker は記録されていない。 +- Relations / orchestration plan: relation 0 件、routing 前 plan 0 件。accepted plan `orch-plan-20260624-120242-1` を記録済み。 +- Code map: Grep で `ThinkingStart` / `ThinkingDelta` / `ThinkingStop` / `thinking` 周辺を確認し、primary files `crates/tui/src/block.rs`, `crates/tui/src/app.rs`, `crates/tui/src/ui.rs`, `crates/tui/src/tool.rs` が妥当。 +- Workspace state: `/home/hare/Projects/yoi/.worktree/orchestration` は clean。active inprogress Ticket は 0 件。 + +IntentPacket: + +Intent: +- TUI Console の transcript rendering で、assistant turn 内に連続する `Thinking` blocks を一つの表示 group として描画し、reasoning が細切れに見える UX を改善する。 + +Binding decisions / invariants: +- これは render-time aggregation。history / protocol / persistence / block sequence は変更しない。 +- turn boundary を跨いで group 化しない。 +- non-Thinking block を跨いで group 化しない。 +- streaming / incomplete thinking を隠さない。 +- Thinking を selectable/copyable transcript text に変える UX 変更はしない。 +- Dashboard / Panel / web UI は対象外。 + +Requirements / acceptance criteria: +- 連続 Thinking blocks が Console 表示で一つのまとまりとして見える。 +- 既存の `Read` aggregation / tool rendering と同様に、rendering helper が consumed count 等で `compute_history` の走査を壊さない。 +- 単独 Thinking block の表示は regress しない。 +- Detail mode / collapsed mode / streaming state が破綻しない。 +- focused unit tests で consecutive Thinking, non-Thinking separator, turn boundary, incomplete/streaming case を確認する。 + +Implementation latitude: +- `render_thinking` を単体 renderer として残し、必要なら aggregate renderer/helper を追加してよい。 +- Header 文言、elapsed 表示、detail mode の separator/index/blank line は既存 UX に沿って実装判断してよい。 +- `compute_history` の構造に合わせ、最小変更で実装してよい。 + +Escalate if: +- protocol / pod / persistence 層の変更が必要になる。 +- Thinking を text selection/copy target にする必要が出る。 +- Provider-specific reasoning metadata の意味づけ変更が必要になる。 +- Console 以外の UI surface に範囲が広がる。 +- broad TUI rendering rewrite が必要になる。 + +Validation: +- `cargo test -p tui` または focused `cargo test -p tui thinking` / rendering tests。 +- `cargo fmt --check`。 +- `git diff --check`。 +- 必要に応じて `cargo check -p tui`。 + +Current code map: +- Primary: `crates/tui/src/block.rs`, `crates/tui/src/app.rs`, `crates/tui/src/ui.rs`, `crates/tui/src/tool.rs`。 +- Avoid: protocol/history/persistence, Dashboard/Panel/web UI, broad TUI rewrite。 + +Critical risks / reviewer focus: +- source blocks/history を変更してしまうこと。 +- non-Thinking block や turn boundary を跨いだ誤 grouping。 +- streaming/incomplete thinking state の欠落。 +- text selection/copy semantics の accidental change。 + +Next action: +- `queued -> inprogress` を記録してから worktree-workflow で dedicated worktree を作成し、Coder/Reviewer sibling loop に進める。 + +--- + + + +## State changed + +Orchestrator acceptance: queued -> inprogress + +- 直前確認で `TicketShow` は state `queued`、blocking relation は 0 件、accepted plan `orch-plan-20260624-120242-1` を確認した。 +- 同時 queued Ticket `00001KVWPVHFJ` は disjoint code surface のため別 worktree/branch で並列開始可能と判断した。 +- routing decision と IntentPacket は Ticket thread に記録済み。 +- これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVWPW3KX-thinking-group` を作成し、multi-agent-workflow に接続する。 + --- From bca8ba6ed9e14b6e56bda427f3cb5f88f22cf66a Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 24 Jun 2026 21:05:18 +0900 Subject: [PATCH 02/11] ticket: record ui and cleanup coder starts --- .yoi/tickets/00001KVWPVHFJ/item.md | 2 +- .yoi/tickets/00001KVWPVHFJ/thread.md | 13 +++++++++++++ .yoi/tickets/00001KVWPW3KX/item.md | 2 +- .yoi/tickets/00001KVWPW3KX/thread.md | 13 +++++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVWPVHFJ/item.md b/.yoi/tickets/00001KVWPVHFJ/item.md index 8ef911d6..1441911f 100644 --- a/.yoi/tickets/00001KVWPVHFJ/item.md +++ b/.yoi/tickets/00001KVWPVHFJ/item.md @@ -2,7 +2,7 @@ title: 'Pod/session storage cleanup CLI を追加する' state: 'inprogress' created_at: '2026-06-24T11:39:41Z' -updated_at: '2026-06-24T12:04:07Z' +updated_at: '2026-06-24T12:05:13Z' assignee: null readiness: 'implementation_ready' risk_flags: ['pod-lifecycle', 'persistence', 'destructive-operation', 'cli-ux', 'session-history', 'authority-boundary'] diff --git a/.yoi/tickets/00001KVWPVHFJ/thread.md b/.yoi/tickets/00001KVWPVHFJ/thread.md index a9778587..cf2de667 100644 --- a/.yoi/tickets/00001KVWPVHFJ/thread.md +++ b/.yoi/tickets/00001KVWPVHFJ/thread.md @@ -107,3 +107,16 @@ Orchestrator acceptance: queued -> inprogress - これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVWPVHFJ-storage-cleanup-cli` を作成し、multi-agent-workflow に接続する。 --- + + + +## Implementation report + +Implementation orchestration update: + +- worktree-workflow に従い、implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVWPVHFJ-storage-cleanup-cli` と branch `work/00001KVWPVHFJ-storage-cleanup-cli` を作成した。 +- child worktree では tracked `.yoi` project records を見える状態にし、`.yoi/memory`、local/runtime/log/lock/secret-like `.yoi` paths を sparse-checkout で除外する方針にした。 +- Coder Pod `yoi-coder-00001KVWPVHFJ-cleanup-cli` を sibling として起動した。write scope は child worktree に限定し、runtime workspace root `/home/hare/Projects/yoi` は startup 要件を満たすため non-recursive read のみにした。 +- Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。 + +--- diff --git a/.yoi/tickets/00001KVWPW3KX/item.md b/.yoi/tickets/00001KVWPW3KX/item.md index 8a10b31e..57176b71 100644 --- a/.yoi/tickets/00001KVWPW3KX/item.md +++ b/.yoi/tickets/00001KVWPW3KX/item.md @@ -2,7 +2,7 @@ title: 'TUI Console: 連続した Thinking block を一つの表示グループにまとめる' state: 'inprogress' created_at: '2026-06-24T11:39:59Z' -updated_at: '2026-06-24T12:04:07Z' +updated_at: '2026-06-24T12:05:13Z' assignee: null readiness: 'implementation_ready' risk_flags: ['tui-rendering', 'reasoning-display', 'block-aggregation', 'text-selection'] diff --git a/.yoi/tickets/00001KVWPW3KX/thread.md b/.yoi/tickets/00001KVWPW3KX/thread.md index f9772067..5124897d 100644 --- a/.yoi/tickets/00001KVWPW3KX/thread.md +++ b/.yoi/tickets/00001KVWPW3KX/thread.md @@ -115,3 +115,16 @@ Orchestrator acceptance: queued -> inprogress - これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVWPW3KX-thinking-group` を作成し、multi-agent-workflow に接続する。 --- + + + +## Implementation report + +Implementation orchestration update: + +- worktree-workflow に従い、implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVWPW3KX-thinking-group` と branch `work/00001KVWPW3KX-thinking-group` を作成した。 +- child worktree では tracked `.yoi` project records を見える状態にし、`.yoi/memory`、local/runtime/log/lock/secret-like `.yoi` paths を sparse-checkout で除外する方針にした。 +- Coder Pod `yoi-coder-00001KVWPW3KX-thinking-group` を sibling として起動した。write scope は child worktree に限定し、runtime workspace root `/home/hare/Projects/yoi` は startup 要件を満たすため non-recursive read のみにした。 +- Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。 + +--- From 0b2ce6ca1f7ae44bc43bbf5afb440e35e69c705b Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 24 Jun 2026 21:14:06 +0900 Subject: [PATCH 03/11] tui: group consecutive thinking blocks --- crates/tui/src/dashboard/tests.rs | 5 + crates/tui/src/ui.rs | 417 +++++++++++++++++++++++++++++- 2 files changed, 419 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/dashboard/tests.rs b/crates/tui/src/dashboard/tests.rs index 57111d42..af3dc038 100644 --- a/crates/tui/src/dashboard/tests.rs +++ b/crates/tui/src/dashboard/tests.rs @@ -2766,6 +2766,11 @@ fn dashboard_ticket_intake_finish_success_clears_composer_and_reports_pod() { pod_name: "intake-pod".to_string(), socket_path: PathBuf::from("/tmp/intake.sock"), }, + acceptance_evidence: client::ticket_role::TicketRoleLaunchAcceptanceEvidence { + pod_name: "intake-pod".to_string(), + accepted_run_segments: 0, + event: client::ticket_role::TicketRoleLaunchAcceptanceEvent::UserMessage, + }, pre_run_warnings: vec![], }, peer_registration: IntakePeerRegistrationStatus::Registered { diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index a59df3ad..d28e0fe1 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -363,6 +363,13 @@ pub fn compute_history(app: &App, width: u16) -> HistoryLayout { previous_selectable = false; continue; } + if matches!(block, Block::Thinking(_)) { + let out = render_thinking_aggregate(&app.blocks, i, width, app.mode); + logical.extend(out.lines.into_iter().map(|line| (line, false))); + i += out.consumed.max(1); + previous_selectable = false; + continue; + } let mut block_lines = Vec::new(); render_block_into(&mut block_lines, block, width, app.mode); logical.extend( @@ -1226,11 +1233,181 @@ fn count_visual_rows(text: &str, width: u16) -> usize { total.max(1) } -fn render_thinking(lines: &mut Vec>, t: &ThinkingBlock, width: u16, mode: Mode) { +struct ThinkingRenderOutput { + lines: Vec>, + /// How many blocks were consumed from `blocks[start..]`. Always >= 1. + consumed: usize, +} + +fn render_thinking_aggregate( + blocks: &[Block], + start: usize, + width: u16, + mode: Mode, +) -> ThinkingRenderOutput { + let Some(Block::Thinking(_)) = blocks.get(start) else { + return ThinkingRenderOutput { + lines: Vec::new(), + consumed: 1, + }; + }; + + let mut end = start + 1; + while matches!(blocks.get(end), Some(Block::Thinking(_))) { + end += 1; + } + + let group: Vec<&ThinkingBlock> = blocks[start..end] + .iter() + .filter_map(|block| match block { + Block::Thinking(thinking) => Some(thinking), + _ => None, + }) + .collect(); + + let mut lines = Vec::new(); + if let [single] = group.as_slice() { + render_thinking(&mut lines, single, width, mode); + } else { + render_thinking_group(&mut lines, &group, width, mode); + } + + ThinkingRenderOutput { + lines, + consumed: end - start, + } +} + +fn render_thinking_group( + lines: &mut Vec>, + group: &[&ThinkingBlock], + width: u16, + mode: Mode, +) { + let header = thinking_group_header(group); + if matches!(mode, Mode::Overview) { + push_overview_line(lines, &header, width, MessageKind::Thinking, ""); + return; + } + let header_style = kind_style(MessageKind::Thinking); let body_style = Style::default().fg(Color::DarkGray); + lines.push(Line::from(Span::styled(header, header_style))); - let header = match &t.state { + match mode { + Mode::Detail => { + let item_style = body_style.add_modifier(Modifier::ITALIC); + for (idx, thinking) in group.iter().enumerate() { + lines.push(Line::from(vec![ + Span::styled(" ", body_style), + Span::styled( + format!("[{}] {}", idx + 1, thinking_header(thinking)), + item_style, + ), + ])); + for raw in thinking.text.lines() { + lines.push(Line::from(vec![ + Span::styled(" ", body_style), + Span::styled(raw.to_owned(), body_style), + ])); + } + } + } + Mode::Normal => { + let preview = thinking_group_preview(group); + if !preview.is_empty() { + let budget = width.saturating_sub(2) as usize; + let truncated = truncate_with_ellipsis(&preview, budget); + lines.push(Line::from(vec![ + Span::styled(" ", body_style), + Span::styled(truncated, body_style), + ])); + } + } + Mode::Overview => unreachable!("handled above"), + } +} + +fn thinking_group_header(group: &[&ThinkingBlock]) -> String { + let count = group.len(); + let block_count = format!("{count} block{}", plural_suffix(count)); + let streaming = group + .iter() + .filter(|thinking| matches!(thinking.state, ThinkingState::Streaming { .. })) + .count(); + let incomplete = group + .iter() + .filter(|thinking| matches!(thinking.state, ThinkingState::Incomplete { .. })) + .count(); + let finished = count.saturating_sub(streaming + incomplete); + + if streaming > 0 { + let mut parts = vec![block_count]; + if streaming > 1 { + parts.push(format!("{streaming} live")); + } + if incomplete > 0 { + parts.push(format!("{incomplete} interrupted")); + } + let elapsed = group + .iter() + .rev() + .find_map(|thinking| match &thinking.state { + ThinkingState::Streaming { started_at } => Some(started_at.elapsed().as_secs()), + _ => None, + }) + .map(fmt_elapsed); + match elapsed { + Some(elapsed) => format!("Thinking... ({}, {elapsed})", parts.join(", ")), + None => format!("Thinking... ({})", parts.join(", ")), + } + } else if incomplete > 0 { + let mut parts = vec![block_count]; + if finished > 0 { + parts.push(format!("{finished} finished")); + } + parts.push(format!("{incomplete} interrupted")); + format!("Thoughts interrupted ({})", parts.join(", ")) + } else { + format!("Thoughts — {block_count}") + } +} + +fn thinking_group_preview(group: &[&ThinkingBlock]) -> String { + if let Some(preview) = group + .iter() + .rev() + .find_map(|thinking| match thinking.state { + ThinkingState::Streaming { .. } => { + non_empty_preview(trailing_line_preview(&thinking.text)) + } + _ => None, + }) + { + return preview; + } + + group + .iter() + .find_map(|thinking| match thinking.state { + ThinkingState::Streaming { .. } => { + non_empty_preview(trailing_line_preview(&thinking.text)) + } + _ => non_empty_preview(first_line_preview(&thinking.text)), + }) + .unwrap_or_default() +} + +fn non_empty_preview(preview: String) -> Option { + if preview.is_empty() { + None + } else { + Some(preview) + } +} + +fn thinking_header(t: &ThinkingBlock) -> String { + match &t.state { ThinkingState::Streaming { started_at } => { let secs = started_at.elapsed().as_secs(); format!("Thinking... ({})", fmt_elapsed(secs)) @@ -1243,7 +1420,18 @@ fn render_thinking(lines: &mut Vec>, t: &ThinkingBlock, width: u16 Some(s) => format!("Thinking interrupted ({})", fmt_elapsed(*s)), None => "Thinking interrupted".to_owned(), }, - }; + } +} + +fn plural_suffix(count: usize) -> &'static str { + if count == 1 { "" } else { "s" } +} + +fn render_thinking(lines: &mut Vec>, t: &ThinkingBlock, width: u16, mode: Mode) { + let header_style = kind_style(MessageKind::Thinking); + let body_style = Style::default().fg(Color::DarkGray); + + let header = thinking_header(t); if matches!(mode, Mode::Overview) { push_overview_line(lines, &header, width, MessageKind::Thinking, ""); @@ -1812,6 +2000,229 @@ mod tests { ); } + fn row_texts(app: &App) -> Vec { + compute_history(app, 80) + .rows + .into_iter() + .map(|row| row.text) + .collect() + } + + fn finished_thinking(text: &str) -> Block { + Block::Thinking(ThinkingBlock { + text: text.to_string(), + state: ThinkingState::Finished { + elapsed_secs: Some(2), + }, + }) + } + + fn incomplete_thinking(text: &str) -> Block { + Block::Thinking(ThinkingBlock { + text: text.to_string(), + state: ThinkingState::Incomplete { + elapsed_secs: Some(4), + }, + }) + } + + fn streaming_thinking(text: &str) -> Block { + Block::Thinking(ThinkingBlock { + text: text.to_string(), + state: ThinkingState::Streaming { + started_at: Instant::now() - Duration::from_secs(3), + }, + }) + } + + #[test] + fn consecutive_thinking_blocks_render_as_one_normal_group() { + let mut app = App::new("pod".to_string()); + app.mode = Mode::Normal; + app.blocks = vec![finished_thinking("alpha"), finished_thinking("beta")]; + + let rows = row_texts(&app); + + assert!(rows.iter().any(|text| text == "Thoughts — 2 blocks")); + assert_eq!( + rows.iter() + .filter(|text| text.starts_with("Thought")) + .count(), + 1 + ); + assert!(rows.iter().any(|text| text == " alpha")); + } + + #[test] + fn thinking_group_detail_keeps_each_body_readable() { + let mut app = App::new("pod".to_string()); + app.mode = Mode::Detail; + app.blocks = vec![ + finished_thinking("alpha line 1\nalpha line 2"), + finished_thinking("beta line"), + ]; + + let rows = row_texts(&app); + + assert!(rows.iter().any(|text| text == "Thoughts — 2 blocks")); + assert!(rows.iter().any(|text| text == " [1] Thought for 2s")); + assert!(rows.iter().any(|text| text == " alpha line 1")); + assert!(rows.iter().any(|text| text == " alpha line 2")); + assert!(rows.iter().any(|text| text == " [2] Thought for 2s")); + assert!(rows.iter().any(|text| text == " beta line")); + } + + #[test] + fn non_thinking_separator_breaks_thinking_group() { + let mut app = App::new("pod".to_string()); + app.mode = Mode::Normal; + app.blocks = vec![ + finished_thinking("alpha"), + Block::AssistantText { + text: "assistant separator".to_string(), + }, + finished_thinking("beta"), + ]; + + let rows = row_texts(&app); + + assert_eq!( + rows.iter() + .filter(|text| text.as_str() == "Thought for 2s") + .count(), + 2 + ); + assert!(!rows.iter().any(|text| text == "Thoughts — 2 blocks")); + } + + #[test] + fn turn_header_breaks_thinking_group() { + let mut app = App::new("pod".to_string()); + app.mode = Mode::Normal; + app.blocks = vec![ + Block::TurnHeader { turn: 1 }, + finished_thinking("alpha"), + Block::TurnHeader { turn: 2 }, + finished_thinking("beta"), + ]; + + let rows = row_texts(&app); + + assert_eq!( + rows.iter() + .filter(|text| text.as_str() == "Thought for 2s") + .count(), + 2 + ); + assert!(!rows.iter().any(|text| text == "Thoughts — 2 blocks")); + } + + #[test] + fn thinking_group_preserves_streaming_and_incomplete_state_visibility() { + let mut app = App::new("pod".to_string()); + app.mode = Mode::Normal; + app.blocks = vec![ + finished_thinking("finished"), + incomplete_thinking("interrupted"), + streaming_thinking("live first\nlive tail"), + ]; + + let layout = compute_history(&app, 80); + let rows: Vec<_> = layout.rows.iter().map(|row| row.text.as_str()).collect(); + + assert!(rows.iter().any(|text| { + text.starts_with("Thinking...") + && text.contains("3 blocks") + && text.contains("interrupted") + })); + assert!(rows.iter().any(|text| *text == " live tail")); + assert!(layout.rows.iter().all(|row| !row.selectable)); + } + + #[test] + fn single_thinking_block_rendering_stays_unchanged() { + let mut app = App::new("pod".to_string()); + app.mode = Mode::Normal; + app.blocks = vec![Block::Thinking(ThinkingBlock { + text: "private reasoning".to_string(), + state: ThinkingState::Finished { elapsed_secs: None }, + })]; + + let rows = row_texts(&app); + + assert!(rows.iter().any(|text| text == "Thought")); + assert!(rows.iter().any(|text| text == " private reasoning")); + assert!(!rows.iter().any(|text| text.starts_with("Thoughts —"))); + } + + #[test] + fn single_tool_block_rendering_stays_unchanged() { + use crate::block::{ToolCallBlock, ToolCallState}; + + let mut app = App::new("pod".to_string()); + app.mode = Mode::Normal; + app.blocks = vec![Block::ToolCall(ToolCallBlock { + id: "bash-1".to_string(), + name: "Bash".to_string(), + args_stream: r#"{"command":"echo hi"}"#.to_string(), + arguments: Some(r#"{"command":"echo hi"}"#.to_string()), + state: ToolCallState::Done { + summary: "hi".to_string(), + output: None, + }, + edit_snapshot: None, + })]; + + let rows = row_texts(&app); + + assert!(rows.iter().any(|text| text == "Bash — done")); + assert!(rows.iter().any(|text| text == " hi")); + } + + #[test] + fn read_tool_aggregation_still_consumes_consecutive_tool_blocks() { + use crate::block::{ToolCallBlock, ToolCallState}; + + let mut app = App::new("pod".to_string()); + app.mode = Mode::Normal; + app.blocks = vec![ + Block::ToolCall(ToolCallBlock { + id: "read-1".to_string(), + name: "Read".to_string(), + args_stream: String::new(), + arguments: Some(r#"{"file_path":"/tmp/a"}"#.to_string()), + state: ToolCallState::Done { + summary: "read".to_string(), + output: None, + }, + edit_snapshot: None, + }), + Block::ToolCall(ToolCallBlock { + id: "read-2".to_string(), + name: "Read".to_string(), + args_stream: String::new(), + arguments: Some(r#"{"file_path":"/tmp/b"}"#.to_string()), + state: ToolCallState::Done { + summary: "read".to_string(), + output: None, + }, + edit_snapshot: None, + }), + ]; + + let rows = row_texts(&app); + + assert!(rows.iter().any(|text| text == "Read — 2 files read")); + assert_eq!( + rows.iter() + .filter(|text| text.starts_with("Read —")) + .count(), + 1 + ); + assert!(rows.iter().any(|text| text == " /tmp/a")); + assert!(rows.iter().any(|text| text == " /tmp/b")); + } + #[test] fn history_rows_mark_text_items_selectable_and_non_text_unselectable() { let mut app = App::new("pod".to_string()); From 7ee2b78bbbc5193371a074b6dbe890f9bcad2b67 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 24 Jun 2026 21:14:43 +0900 Subject: [PATCH 04/11] ticket: report thinking grouping implementation --- .yoi/tickets/00001KVWPW3KX/item.md | 2 +- .yoi/tickets/00001KVWPW3KX/thread.md | 41 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVWPW3KX/item.md b/.yoi/tickets/00001KVWPW3KX/item.md index 8a10b31e..6ad34e57 100644 --- a/.yoi/tickets/00001KVWPW3KX/item.md +++ b/.yoi/tickets/00001KVWPW3KX/item.md @@ -2,7 +2,7 @@ title: 'TUI Console: 連続した Thinking block を一つの表示グループにまとめる' state: 'inprogress' created_at: '2026-06-24T11:39:59Z' -updated_at: '2026-06-24T12:04:07Z' +updated_at: '2026-06-24T12:14:38Z' assignee: null readiness: 'implementation_ready' risk_flags: ['tui-rendering', 'reasoning-display', 'block-aggregation', 'text-selection'] diff --git a/.yoi/tickets/00001KVWPW3KX/thread.md b/.yoi/tickets/00001KVWPW3KX/thread.md index f9772067..1be72e0e 100644 --- a/.yoi/tickets/00001KVWPW3KX/thread.md +++ b/.yoi/tickets/00001KVWPW3KX/thread.md @@ -114,4 +114,45 @@ Orchestrator acceptance: queued -> inprogress - routing decision と IntentPacket は Ticket thread に記録済み。 - これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVWPW3KX-thinking-group` を作成し、multi-agent-workflow に接続する。 +--- + + + +## Implementation report + +実装報告: Ticket `00001KVWPW3KX` / TUI Console: 連続した Thinking block を一つの表示グループにまとめる + +変更内容: +- `crates/tui/src/ui.rs` + - `compute_history` の render-time 処理で、連続する `Block::Thinking` だけをまとめて消費する `render_thinking_aggregate` を追加しました。 + - 単一の Thinking block は既存 `render_thinking` へ委譲し、単体表示の互換性を維持しました。 + - 複数 Thinking group は `Normal` / `Overview` では 1 つのヘッダーとして表示し、`Detail` では各 Thinking block の状態ヘッダーと本文を下位項目として読めるようにしました。 + - streaming 中の group は `Thinking...` と最新 streaming tail preview を表示し、incomplete が含まれる場合はヘッダーに `interrupted` を含めるようにしました。 + - group 行は `compute_history` 側で selectable=false のまま扱い、Thinking を選択・コピー可能な transcript text にしていません。 + - 連続 Thinking、非 Thinking separator、turn boundary、streaming/incomplete、単一 Thinking、tool 表示回帰の単体テストを追加しました。 +- `crates/tui/src/dashboard/tests.rs` + - `cargo test -p tui` の validation を通すため、既存 dashboard test fixture の `TicketRoleLaunchResult` に現行必須 field `acceptance_evidence` を補いました。 + +保持した不変条件: +- history / session / protocol / reasoning persistence / source block sequence は変更していません。 +- group 化は Console TUI の render-time のみです。 +- `Block::Thinking` が連続している場合だけ group 化し、非 Thinking block や `TurnHeader` を跨ぎません。 +- streaming/incomplete 状態はヘッダーと preview/detail で見えるままにしています。 + +検証結果: +- `cargo test -p tui thinking -- --nocapture`: PASS(6 tests) +- `cargo test -p tui tool -- --nocapture`: PASS(2 tests) +- `cargo test -p tui`: PASS(382 tests) +- `cargo fmt --check`: PASS +- `cargo check -p tui`: PASS +- `git diff --check`: PASS + +実装 commit: +- `0b2ce6ca1f7ae44bc43bbf5afb440e35e69c705b` (`tui: group consecutive thinking blocks`) + +残リスク: +- 実端末での目視確認は行っていません。テストは `compute_history` の行テキスト・selectable 属性を中心に確認しています。 +- group header の文言は既存 Console 表示に合わせて英語にしています。将来 UI 文言を整理する場合は別途調整してください。 + + --- From 89910a1a29fd88d56e662b560a507bec982d82a0 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 24 Jun 2026 21:18:56 +0900 Subject: [PATCH 05/11] ticket: approve thinking grouping implementation --- .yoi/tickets/00001KVWPW3KX/item.md | 2 +- .yoi/tickets/00001KVWPW3KX/thread.md | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVWPW3KX/item.md b/.yoi/tickets/00001KVWPW3KX/item.md index 57176b71..fac82d65 100644 --- a/.yoi/tickets/00001KVWPW3KX/item.md +++ b/.yoi/tickets/00001KVWPW3KX/item.md @@ -2,7 +2,7 @@ title: 'TUI Console: 連続した Thinking block を一つの表示グループにまとめる' state: 'inprogress' created_at: '2026-06-24T11:39:59Z' -updated_at: '2026-06-24T12:05:13Z' +updated_at: '2026-06-24T12:18:51Z' assignee: null readiness: 'implementation_ready' risk_flags: ['tui-rendering', 'reasoning-display', 'block-aggregation', 'text-selection'] diff --git a/.yoi/tickets/00001KVWPW3KX/thread.md b/.yoi/tickets/00001KVWPW3KX/thread.md index 5124897d..a4d14f37 100644 --- a/.yoi/tickets/00001KVWPW3KX/thread.md +++ b/.yoi/tickets/00001KVWPW3KX/thread.md @@ -128,3 +128,28 @@ Implementation orchestration update: - Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。 --- + + + +## Review: approve + +Review result: approve + +確認内容: +- child worktree の `.yoi/tickets/00001KVWPW3KX/item.md` / `thread.md` / implementation report / `5c9331e8..HEAD` diff を read-only 確認。 +- 変更範囲は `crates/tui/src/ui.rs` の Console history rendering と、既存 dashboard test fixture の必須 field 追加に限定されている。 +- `compute_history` で `Block::Thinking` のみを render-time に連続消費し、source block sequence / history / protocol / persistence は変更していない。 +- 単独 Thinking は既存 `render_thinking` に委譲され、複数 Thinking のみ `render_thinking_group` で集約される。 +- `TurnHeader` や `AssistantText` など non-Thinking を跨ぐ grouping は起きない構造で、focused tests も追加されている。 +- streaming / incomplete は group header と preview/detail に反映され、未完了 Thinking を finalized text と混同する変更はない。 +- group 行は `selectable=false` で追加されており、Thinking を copyable transcript text にする変更はない。 +- Read tool aggregation / single ToolCall rendering の回帰確認テストも追加されている。 + +Validation: +- reviewer: `git diff --check 5c9331e8..HEAD`: PASS +- cargo 系は read-only/no-write 指示に合わせて再実行せず、implementation report の結果を確認した。 + +Non-blocking note: +- 実端末での目視確認は未実施だが、`compute_history` の行生成・selectable 属性を中心にテストされており、Ticket acceptance には十分。 + +--- From d2ec53358587721b8472a406b01c3e3b92423dad Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 24 Jun 2026 21:20:15 +0900 Subject: [PATCH 06/11] ticket: mark thinking grouping done --- .yoi/tickets/00001KVWPW3KX/item.md | 4 +- .yoi/tickets/00001KVWPW3KX/thread.md | 56 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVWPW3KX/item.md b/.yoi/tickets/00001KVWPW3KX/item.md index fac82d65..903970b3 100644 --- a/.yoi/tickets/00001KVWPW3KX/item.md +++ b/.yoi/tickets/00001KVWPW3KX/item.md @@ -1,8 +1,8 @@ --- title: 'TUI Console: 連続した Thinking block を一つの表示グループにまとめる' -state: 'inprogress' +state: 'done' created_at: '2026-06-24T11:39:59Z' -updated_at: '2026-06-24T12:18:51Z' +updated_at: '2026-06-24T12:20:08Z' assignee: null readiness: 'implementation_ready' risk_flags: ['tui-rendering', 'reasoning-display', 'block-aggregation', 'text-selection'] diff --git a/.yoi/tickets/00001KVWPW3KX/thread.md b/.yoi/tickets/00001KVWPW3KX/thread.md index a4d14f37..abf25d90 100644 --- a/.yoi/tickets/00001KVWPW3KX/thread.md +++ b/.yoi/tickets/00001KVWPW3KX/thread.md @@ -153,3 +153,59 @@ Non-blocking note: - 実端末での目視確認は未実施だが、`compute_history` の行生成・selectable 属性を中心にテストされており、Ticket acceptance には十分。 --- + + + +## Implementation report + +Integration outcome: + +- Coder `yoi-coder-00001KVWPW3KX-thinking-group` が implementation branch `work/00001KVWPW3KX-thinking-group` に実装を commit した。 + - implementation commit: `0b2ce6ca tui: group consecutive thinking blocks` + - child ticket report commit: `7ee2b78b ticket: report thinking grouping implementation` +- Reviewer `yoi-reviewer-00001KVWPW3KX-thinking-group` は read-only review で `approve`。render-time aggregation のみで history/protocol/persistence を変更していないこと、turn/non-Thinking boundary、streaming/incomplete visibility、selectable=false、tool rendering regression tests を確認した。 +- Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration` で branch `work/00001KVWPW3KX-thinking-group` を merge 済み。 + - merge commit: `b52b7c96 merge: 00001KVWPW3KX thinking grouping` +- merge 時に Ticket item/thread の append conflict が発生したため、orchestration 側の Ticket record を保持して merge し、この integration outcome に実装・review・validation evidence を集約して記録した。 + +Implemented behavior: +- `crates/tui/src/ui.rs` で `compute_history` の render-time 処理に連続 `Block::Thinking` aggregation を追加。 +- 単一 Thinking は既存 `render_thinking` に委譲。 +- 複数 Thinking group は Normal/Overview で一つの header、Detail で各 Thinking body を読める形にした。 +- streaming / incomplete state は header / preview / detail に残し、group rows は selectable=false を維持。 +- `crates/tui/src/dashboard/tests.rs` は current `TicketRoleLaunchResult.acceptance_evidence` 必須 field に追随。 + +Validation in Orchestrator worktree: +- `cargo test -p tui`: success +- `cargo fmt --check`: success +- `cargo check -p tui`: success +- `git diff --check`: success + +Notes: +- 実端末での目視確認は未実施。focused tests と full `cargo test -p tui` で `compute_history` rows/selectable/tool regression を確認済み。 +- dependency/package change は無いため `nix build .#yoi` は実施していない。 + +Next action: +- Mark Ticket done after this integration/validation evidence. +- Then stop related child Pods and remove only the child implementation worktree/branch. + +--- + + + +## State changed + +Implementation is merged into the orchestration branch and validated. + +Evidence: +- merge commit: `b52b7c96 merge: 00001KVWPW3KX thinking grouping` +- reviewer result: approve +- validation in `/home/hare/Projects/yoi/.worktree/orchestration` succeeded: + - `cargo test -p tui` + - `cargo fmt --check` + - `cargo check -p tui` + - `git diff --check` + +Closure is not performed here; this state records implementation completion after merge/validation. + +--- From 80d6861aba03619b4f1111c9e5cbb1cf53a39064 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 24 Jun 2026 21:22:19 +0900 Subject: [PATCH 07/11] feat: add pod and session cleanup CLI --- Cargo.lock | 1 + crates/session-store/src/fs_store.rs | 84 ++++ crates/yoi/Cargo.toml | 1 + crates/yoi/src/main.rs | 52 ++- crates/yoi/src/pod_cleanup_cli.rs | 624 +++++++++++++++++++++++++++ crates/yoi/src/session_cli.rs | 355 ++++++++++++++- 6 files changed, 1114 insertions(+), 3 deletions(-) create mode 100644 crates/yoi/src/pod_cleanup_cli.rs diff --git a/Cargo.lock b/Cargo.lock index 775ed1c0..4205c9b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6010,6 +6010,7 @@ dependencies = [ "manifest", "memory", "pod", + "pod-store", "project-record", "serde", "serde_json", diff --git a/crates/session-store/src/fs_store.rs b/crates/session-store/src/fs_store.rs index f9e04343..1339e7c0 100644 --- a/crates/session-store/src/fs_store.rs +++ b/crates/session-store/src/fs_store.rs @@ -22,6 +22,7 @@ use crate::{SegmentId, SessionId}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +use std::time::SystemTime; /// Filesystem-backed JSONL store. /// @@ -41,6 +42,50 @@ impl FsStore { Ok(Self { root }) } + /// Return the filesystem root used by this store. + pub fn root_dir(&self) -> &Path { + &self.root + } + + /// Return the latest filesystem mtime under a Session directory. + /// + /// Missing Sessions return `Ok(None)`. This is intentionally Session-scoped + /// so cleanup callers can apply age thresholds without reaching around the + /// Session store's directory authority. + pub fn session_modified_at( + &self, + session_id: SessionId, + ) -> Result, StoreError> { + let session_dir = self.session_dir(session_id); + let dir_metadata = match fs::metadata(&session_dir) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let mut latest = Some(dir_metadata.modified()?); + for entry in fs::read_dir(&session_dir)? { + let entry = entry?; + let modified = entry.metadata()?.modified()?; + if latest.map(|current| modified > current).unwrap_or(true) { + latest = Some(modified); + } + } + Ok(latest) + } + + /// Delete an entire Session directory owned by this Session store. + /// + /// Returns `Ok(true)` when a Session directory was removed and `Ok(false)` + /// when it was already absent. + pub fn delete_session(&self, session_id: SessionId) -> Result { + let session_dir = self.session_dir(session_id); + match fs::remove_dir_all(&session_dir) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } + } + fn session_dir(&self, session_id: SessionId) -> PathBuf { self.root.join(session_id.to_string()) } @@ -220,3 +265,42 @@ impl Store for FsStore { self.append_line(&self.trace_path(session_id, segment_id), &line) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{new_segment_id, new_session_id}; + + #[test] + fn delete_session_removes_session_directory_only() { + let tmp = tempfile::TempDir::new().unwrap(); + let store = FsStore::new(tmp.path()).unwrap(); + let keep_session = new_session_id(); + let keep_segment = new_segment_id(); + let delete_session = new_session_id(); + let delete_segment = new_segment_id(); + store + .create_segment(keep_session, keep_segment, &[]) + .unwrap(); + store + .create_segment(delete_session, delete_segment, &[]) + .unwrap(); + + assert!(store.delete_session(delete_session).unwrap()); + assert!(!store.exists(delete_session, delete_segment).unwrap()); + assert!(store.exists(keep_session, keep_segment).unwrap()); + assert!(!store.delete_session(delete_session).unwrap()); + } + + #[test] + fn session_modified_at_is_store_scoped() { + let tmp = tempfile::TempDir::new().unwrap(); + let store = FsStore::new(tmp.path()).unwrap(); + let session_id = new_session_id(); + let segment_id = new_segment_id(); + + assert!(store.session_modified_at(session_id).unwrap().is_none()); + store.create_segment(session_id, segment_id, &[]).unwrap(); + assert!(store.session_modified_at(session_id).unwrap().is_some()); + } +} diff --git a/crates/yoi/Cargo.toml b/crates/yoi/Cargo.toml index 0121f6a2..e4d23ec5 100644 --- a/crates/yoi/Cargo.toml +++ b/crates/yoi/Cargo.toml @@ -15,6 +15,7 @@ client = { workspace = true } memory = { workspace = true } manifest = { workspace = true } pod = { workspace = true } +pod-store = { workspace = true } session-store = { workspace = true } session-analytics = { workspace = true } ticket = { workspace = true } diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 048628b4..3625681a 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -2,6 +2,7 @@ mod mcp_cli; mod memory_lint; mod objective_cli; mod plugin_cli; +mod pod_cleanup_cli; mod session_cli; mod ticket_cli; @@ -25,6 +26,7 @@ enum Mode { Plugin(plugin_cli::PluginCliCommand), Objective(objective_cli::ObjectiveCli), Session(session_cli::SessionCli), + PodCleanup(pod_cleanup_cli::PodCleanupCli), Ticket(ticket_cli::TicketCli), WorkspaceHelp, WorkspaceServe(Vec), @@ -117,6 +119,7 @@ async fn main() -> ExitCode { print!("{}", output.stdout); match output.status { session_cli::SessionCliStatus::Success => ExitCode::SUCCESS, + session_cli::SessionCliStatus::Failure => ExitCode::FAILURE, } } Err(e) => { @@ -124,6 +127,19 @@ async fn main() -> ExitCode { ExitCode::FAILURE } }, + Mode::PodCleanup(cli) => match pod_cleanup_cli::run(cli).await { + Ok(output) => { + print!("{}", output.stdout); + match output.status { + pod_cleanup_cli::PodCleanupCliStatus::Success => ExitCode::SUCCESS, + pod_cleanup_cli::PodCleanupCliStatus::Failure => ExitCode::FAILURE, + } + } + Err(e) => { + eprintln!("yoi pod: {e}"); + ExitCode::FAILURE + } + }, Mode::Ticket(cli) => match ticket_cli::run(cli) { Ok(output) => { print!("{}", output.stdout); @@ -188,7 +204,14 @@ fn parse_args_slice(args: &[String]) -> Result { match args[0].as_str() { "--help" | "-h" => return Ok(Mode::Help), "resume" => return parse_resume_args(&args[1..]), - "pod" => return Ok(Mode::PodRuntime(args[1..].to_vec())), + "pod" => { + if let Some(cli) = pod_cleanup_cli::parse_pod_management_args(&args[1..]) + .map_err(|e| ParseError(e.to_string()))? + { + return Ok(Mode::PodCleanup(cli)); + } + return Ok(Mode::PodRuntime(args[1..].to_vec())); + } "objective" => { let objective_cli = objective_cli::parse_objective_args(&args[1..]) .map_err(|e| ParseError(e.to_string()))?; @@ -878,7 +901,7 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace ] [--all]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace ] [--all]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi pod delete [--force] [--dry-run]\n yoi pod prune --older-than [--force] [--dry-run]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi session prune --unreferenced [--older-than ] [--force] [--dry-run]\n yoi ticket [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" ); } @@ -978,6 +1001,31 @@ mod tests { } } + #[test] + fn parse_pod_delete_uses_cleanup_mode() { + match parse_args_from(["pod", "delete", "agent", "--dry-run"]).unwrap() { + Mode::PodCleanup(pod_cleanup_cli::PodCleanupCli::Delete(options)) => { + assert_eq!(options.name, "agent"); + assert!(options.dry_run); + assert!(!options.force); + } + _ => panic!("expected Pod cleanup delete mode"), + } + } + + #[test] + fn parse_pod_prune_uses_cleanup_mode() { + match parse_args_from(["pod", "prune", "--older-than", "30d"]).unwrap() { + Mode::PodCleanup(pod_cleanup_cli::PodCleanupCli::Prune(options)) => { + assert_eq!( + options.older_than, + std::time::Duration::from_secs(30 * 24 * 60 * 60) + ); + } + _ => panic!("expected Pod cleanup prune mode"), + } + } + #[test] fn parse_ticket_subcommand_uses_ticket_mode() { match parse_args_from(["ticket", "doctor"]).unwrap() { diff --git a/crates/yoi/src/pod_cleanup_cli.rs b/crates/yoi/src/pod_cleanup_cli.rs new file mode 100644 index 00000000..61f782fc --- /dev/null +++ b/crates/yoi/src/pod_cleanup_cli.rs @@ -0,0 +1,624 @@ +use std::fmt; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use manifest::paths; +use pod_store::{FsPodStore, PodMetadata, PodMetadataStore, validate_pod_name}; + +const MAX_REPORT_ITEMS: usize = 50; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PodCleanupCli { + Help, + Delete(PodDeleteOptions), + Prune(PodPruneOptions), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PodDeleteOptions { + pub name: String, + pub force: bool, + pub dry_run: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PodPruneOptions { + pub older_than: Duration, + pub force: bool, + pub dry_run: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PodCleanupCliOutput { + pub stdout: String, + pub status: PodCleanupCliStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PodCleanupCliStatus { + Success, + Failure, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PodCleanupCliError(String); + +impl fmt::Display for PodCleanupCliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for PodCleanupCliError {} + +pub fn parse_pod_management_args( + args: &[String], +) -> Result, PodCleanupCliError> { + let Some((subcommand, rest)) = args.split_first() else { + return Ok(None); + }; + match subcommand.as_str() { + "delete" => parse_delete_args(rest).map(PodCleanupCli::Delete).map(Some), + "prune" => parse_prune_args(rest).map(PodCleanupCli::Prune).map(Some), + "help" => Ok(Some(PodCleanupCli::Help)), + "--help" | "-h" => Ok(Some(PodCleanupCli::Help)), + _ => Ok(None), + } +} + +fn parse_delete_args(args: &[String]) -> Result { + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + return Err(PodCleanupCliError(delete_help_text().to_string())); + } + let mut name = None; + let mut force = false; + let mut dry_run = false; + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--force" => force = true, + "--dry-run" => dry_run = true, + "--" => { + for positional in iter { + set_name(&mut name, positional)?; + } + break; + } + value if value.starts_with('-') => { + return Err(PodCleanupCliError(format!( + "unknown yoi pod delete option `{value}`" + ))); + } + positional => set_name(&mut name, positional)?, + } + } + let name = name + .ok_or_else(|| PodCleanupCliError("yoi pod delete requires an explicit Pod name".into()))?; + validate_pod_name(&name).map_err(|e| PodCleanupCliError(e.to_string()))?; + Ok(PodDeleteOptions { + name, + force, + dry_run, + }) +} + +fn set_name(name: &mut Option, value: &str) -> Result<(), PodCleanupCliError> { + if name.is_some() { + return Err(PodCleanupCliError( + "yoi pod delete accepts exactly one Pod name".into(), + )); + } + *name = Some(value.to_string()); + Ok(()) +} + +fn parse_prune_args(args: &[String]) -> Result { + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + return Err(PodCleanupCliError(prune_help_text().to_string())); + } + let mut older_than = None; + let mut force = false; + let mut dry_run = false; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == "--force" { + force = true; + index += 1; + } else if arg == "--dry-run" { + dry_run = true; + index += 1; + } else if arg == "--older-than" { + let value = args.get(index + 1).ok_or_else(|| { + PodCleanupCliError("--older-than requires a duration value".into()) + })?; + if value.starts_with('-') { + return Err(PodCleanupCliError( + "--older-than requires a duration value".into(), + )); + } + older_than = Some(parse_duration(value)?); + index += 2; + } else if let Some(value) = arg.strip_prefix("--older-than=") { + if value.is_empty() { + return Err(PodCleanupCliError( + "--older-than requires a duration value".into(), + )); + } + older_than = Some(parse_duration(value)?); + index += 1; + } else if arg.starts_with('-') { + return Err(PodCleanupCliError(format!( + "unknown yoi pod prune option `{arg}`" + ))); + } else { + return Err(PodCleanupCliError(format!( + "yoi pod prune does not accept positional argument `{arg}`" + ))); + } + } + let older_than = older_than.ok_or_else(|| { + PodCleanupCliError("yoi pod prune requires --older-than ".into()) + })?; + Ok(PodPruneOptions { + older_than, + force, + dry_run, + }) +} + +pub fn parse_duration(value: &str) -> Result { + let split = value + .find(|ch: char| !ch.is_ascii_digit()) + .unwrap_or(value.len()); + let (amount, unit) = value.split_at(split); + if amount.is_empty() || unit.is_empty() { + return Err(PodCleanupCliError(format!( + "duration `{value}` must use an explicit unit: s, m, h, d, or w" + ))); + } + let amount = amount + .parse::() + .map_err(|_| PodCleanupCliError(format!("invalid duration amount `{value}`")))?; + if amount == 0 { + return Err(PodCleanupCliError( + "duration must be greater than zero".into(), + )); + } + let seconds = match unit { + "s" | "sec" | "secs" | "second" | "seconds" => amount, + "m" | "min" | "mins" | "minute" | "minutes" => amount.saturating_mul(60), + "h" | "hr" | "hrs" | "hour" | "hours" => amount.saturating_mul(60 * 60), + "d" | "day" | "days" => amount.saturating_mul(60 * 60 * 24), + "w" | "week" | "weeks" => amount.saturating_mul(60 * 60 * 24 * 7), + _ => { + return Err(PodCleanupCliError(format!( + "unknown duration unit `{unit}` in `{value}`" + ))); + } + }; + Ok(Duration::from_secs(seconds)) +} + +pub async fn run(cli: PodCleanupCli) -> Result { + let data_dir = paths::data_dir() + .ok_or_else(|| PodCleanupCliError("failed to resolve Yoi data directory".into()))?; + let runtime_dir = paths::runtime_dir() + .ok_or_else(|| PodCleanupCliError("failed to resolve Yoi runtime directory".into()))?; + run_with_roots(cli, data_dir, runtime_dir).await +} + +pub async fn run_with_roots( + cli: PodCleanupCli, + data_dir: PathBuf, + runtime_dir: PathBuf, +) -> Result { + match cli { + PodCleanupCli::Help => Ok(PodCleanupCliOutput { + stdout: help_text().to_string(), + status: PodCleanupCliStatus::Success, + }), + PodCleanupCli::Delete(options) => run_delete(options, data_dir, runtime_dir).await, + PodCleanupCli::Prune(options) => run_prune(options, data_dir, runtime_dir).await, + } +} + +async fn run_delete( + options: PodDeleteOptions, + data_dir: PathBuf, + runtime_dir: PathBuf, +) -> Result { + let store = FsPodStore::new(data_dir.join("pods")).map_err(to_error)?; + let metadata = store.read_by_name(&options.name).map_err(to_error)?; + let Some(metadata) = metadata else { + return Ok(PodCleanupCliOutput { + stdout: format!( + "yoi pod delete\nstatus: refused\npod: {}\nreason: pod metadata is missing\n", + options.name + ), + status: PodCleanupCliStatus::Failure, + }); + }; + + let probe = probe_pod_liveness(&runtime_dir, &options.name).await; + if let Some(reason) = probe.refusal_reason() { + return Ok(PodCleanupCliOutput { + stdout: format!( + "yoi pod delete\nstatus: refused\npod: {}\nreason: {}\nsocket: {}\n", + options.name, + reason, + probe.socket_path.display() + ), + status: PodCleanupCliStatus::Failure, + }); + } + + let delete = options.force && !options.dry_run; + let mut stdout = String::new(); + stdout.push_str("yoi pod delete\n"); + stdout.push_str(if delete { + "mode: force\n" + } else { + "mode: dry-run\n" + }); + stdout.push_str(&format!("pod: {}\n", options.name)); + describe_metadata(&mut stdout, &metadata); + if delete { + store.delete_by_name(&options.name).map_err(to_error)?; + stdout.push_str("deleted: pod metadata\n"); + stdout.push_str("preserved: session logs/history\n"); + } else { + stdout.push_str("would_delete: pod metadata\n"); + stdout.push_str("would_preserve: session logs/history\n"); + stdout + .push_str("note: pass --force to delete metadata; --dry-run keeps report-only mode\n"); + } + Ok(PodCleanupCliOutput { + stdout, + status: PodCleanupCliStatus::Success, + }) +} + +async fn run_prune( + options: PodPruneOptions, + data_dir: PathBuf, + runtime_dir: PathBuf, +) -> Result { + let store = FsPodStore::new(data_dir.join("pods")).map_err(to_error)?; + let names = store.list_names().map_err(to_error)?; + let cutoff = SystemTime::now() + .checked_sub(options.older_than) + .ok_or_else(|| PodCleanupCliError("--older-than duration is too large".into()))?; + let delete = options.force && !options.dry_run; + let mut stdout = String::new(); + stdout.push_str("yoi pod prune\n"); + stdout.push_str(if delete { + "mode: force\n" + } else { + "mode: dry-run\n" + }); + stdout.push_str(&format!("older_than: {:?}\n", options.older_than)); + + let mut deleted = 0usize; + let mut would_delete = 0usize; + let mut kept = 0usize; + let mut refused = 0usize; + for (index, name) in names.iter().enumerate() { + let metadata = store.read_by_name(name).map_err(to_error)?; + let Some(metadata) = metadata else { + kept += 1; + push_item_line(&mut stdout, index, "kept", name, "metadata disappeared"); + continue; + }; + let modified = metadata_modified_at(store.root_dir().as_deref(), name).map_err(to_error)?; + let Some(modified) = modified else { + refused += 1; + push_item_line( + &mut stdout, + index, + "refused", + name, + "metadata mtime is unavailable", + ); + continue; + }; + if modified > cutoff { + kept += 1; + push_item_line( + &mut stdout, + index, + "kept", + name, + "metadata is newer than threshold", + ); + continue; + } + let probe = probe_pod_liveness(&runtime_dir, name).await; + if let Some(reason) = probe.refusal_reason() { + refused += 1; + push_item_line(&mut stdout, index, "refused", name, &reason); + continue; + } + if delete { + store.delete_by_name(name).map_err(to_error)?; + deleted += 1; + push_item_line( + &mut stdout, + index, + "deleted", + name, + "old pod metadata; session logs/history preserved", + ); + } else { + would_delete += 1; + let reason = metadata + .active + .as_ref() + .map(|active| format!("old metadata; active_session={}", active.session_id)) + .unwrap_or_else(|| "old metadata; no active session".to_string()); + push_item_line(&mut stdout, index, "would_delete", name, &reason); + } + } + stdout.push_str(&format!( + "summary: deleted={deleted} would_delete={would_delete} kept={kept} refused={refused}\n" + )); + if !delete { + stdout + .push_str("note: pass --force to delete metadata; --dry-run keeps report-only mode\n"); + } + Ok(PodCleanupCliOutput { + stdout, + status: if refused > 0 { + PodCleanupCliStatus::Failure + } else { + PodCleanupCliStatus::Success + }, + }) +} + +fn describe_metadata(stdout: &mut String, metadata: &PodMetadata) { + match metadata.active.as_ref() { + Some(active) => stdout.push_str(&format!( + "active_session: {}\nactive_segment: {}\n", + active.session_id, + active + .segment_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "".to_string()) + )), + None => stdout.push_str("active_session: \n"), + } +} + +fn metadata_modified_at( + root: Option<&Path>, + pod_name: &str, +) -> Result, io::Error> { + let Some(root) = root else { + return Ok(None); + }; + let path = root.join(pod_name).join("metadata.json"); + match std::fs::metadata(path) { + Ok(metadata) => metadata.modified().map(Some), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } +} + +fn push_item_line(stdout: &mut String, index: usize, action: &str, name: &str, reason: &str) { + if index < MAX_REPORT_ITEMS { + stdout.push_str(&format!("{action}: {name} ({reason})\n")); + } else if index == MAX_REPORT_ITEMS { + stdout.push_str("... additional items omitted from bounded report ...\n"); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LivenessProbe { + socket_path: PathBuf, + result: LivenessResult, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum LivenessResult { + NotReachable, + Reachable, + Uncertain(String), +} + +impl LivenessProbe { + fn refusal_reason(&self) -> Option { + match &self.result { + LivenessResult::NotReachable => None, + LivenessResult::Reachable => Some("pod is live/reachable".into()), + LivenessResult::Uncertain(reason) => Some(format!( + "pod liveness is uncertain; refusing destructive metadata cleanup ({reason})" + )), + } + } +} + +async fn probe_pod_liveness(runtime_dir: &Path, pod_name: &str) -> LivenessProbe { + let socket_path = runtime_dir.join(pod_name).join("sock"); + let result = probe_socket(&socket_path).await; + LivenessProbe { + socket_path, + result, + } +} + +#[cfg(unix)] +async fn probe_socket(socket_path: &Path) -> LivenessResult { + use std::os::unix::net::UnixStream; + + let path = socket_path.to_path_buf(); + match tokio::task::spawn_blocking(move || UnixStream::connect(path)).await { + Ok(Ok(_stream)) => LivenessResult::Reachable, + Ok(Err(error)) if is_not_live_socket_error(&error) => LivenessResult::NotReachable, + Ok(Err(error)) => LivenessResult::Uncertain(error.to_string()), + Err(error) => LivenessResult::Uncertain(error.to_string()), + } +} + +#[cfg(unix)] +fn is_not_live_socket_error(error: &io::Error) -> bool { + matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused + ) +} + +#[cfg(not(unix))] +async fn probe_socket(_socket_path: &Path) -> LivenessResult { + LivenessResult::Uncertain("Unix socket probing is unavailable on this platform".into()) +} + +fn to_error(error: E) -> PodCleanupCliError { + PodCleanupCliError(error.to_string()) +} + +pub fn help_text() -> &'static str { + "yoi pod\n\nUsage:\n yoi pod delete [--force] [--dry-run]\n yoi pod prune --older-than [--force] [--dry-run]\n yoi pod [POD_OPTIONS]\n\nDescription:\n delete/prune are safe Pod metadata cleanup commands. `pod delete` removes only name-keyed Pod metadata and never removes session logs/history. Live or uncertain Pod liveness is refused. Without --force the command reports only.\n\nDuration units: s, m, h, d, w\n\nOptions:\n --force Perform deletion after safety checks\n --dry-run Report only, even with --force\n --older-than Required explicit age threshold for prune\n -h, --help Print help\n" +} + +fn delete_help_text() -> &'static str { + "usage: yoi pod delete [--force] [--dry-run]" +} + +fn prune_help_text() -> &'static str { + "usage: yoi pod prune --older-than [--force] [--dry-run]" +} + +#[cfg(test)] +mod tests { + use super::*; + use pod_store::PodActiveSegmentRef; + use session_store::{Store, new_segment_id, new_session_id}; + + fn string_args(args: &[&str]) -> Vec { + args.iter().map(|arg| arg.to_string()).collect() + } + + #[test] + fn parse_pod_delete_command() { + let cli = + parse_pod_management_args(&string_args(&["delete", "agent", "--force", "--dry-run"])) + .unwrap() + .unwrap(); + assert_eq!( + cli, + PodCleanupCli::Delete(PodDeleteOptions { + name: "agent".into(), + force: true, + dry_run: true, + }) + ); + } + + #[test] + fn parse_pod_prune_requires_explicit_threshold() { + let err = parse_pod_management_args(&string_args(&["prune"])).unwrap_err(); + assert!(err.to_string().contains("--older-than")); + } + + #[test] + fn parse_duration_requires_units() { + let err = parse_duration("30").unwrap_err(); + assert!(err.to_string().contains("explicit unit")); + assert_eq!(parse_duration("2d").unwrap(), Duration::from_secs(172_800)); + } + + #[tokio::test] + async fn stopped_pod_delete_force_removes_only_metadata() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let runtime_dir = tmp.path().join("run"); + let pod_store = FsPodStore::new(data_dir.join("pods")).unwrap(); + let session_store = session_store::FsStore::new(data_dir.join("sessions")).unwrap(); + let session_id = new_session_id(); + let segment_id = new_segment_id(); + session_store + .create_segment(session_id, segment_id, &[]) + .unwrap(); + pod_store + .write(&PodMetadata::new( + "agent", + Some(PodActiveSegmentRef::active_segment(session_id, segment_id)), + )) + .unwrap(); + + let output = run_with_roots( + PodCleanupCli::Delete(PodDeleteOptions { + name: "agent".into(), + force: true, + dry_run: false, + }), + data_dir.clone(), + runtime_dir, + ) + .await + .unwrap(); + + assert_eq!(output.status, PodCleanupCliStatus::Success); + assert!(output.stdout.contains("deleted: pod metadata")); + assert!(pod_store.read_by_name("agent").unwrap().is_none()); + assert!(session_store.exists(session_id, segment_id).unwrap()); + } + + #[tokio::test] + async fn pod_delete_without_force_reports_dry_run() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let runtime_dir = tmp.path().join("run"); + let pod_store = FsPodStore::new(data_dir.join("pods")).unwrap(); + pod_store.write(&PodMetadata::new("agent", None)).unwrap(); + + let output = run_with_roots( + PodCleanupCli::Delete(PodDeleteOptions { + name: "agent".into(), + force: false, + dry_run: false, + }), + data_dir, + runtime_dir, + ) + .await + .unwrap(); + + assert_eq!(output.status, PodCleanupCliStatus::Success); + assert!(output.stdout.contains("mode: dry-run")); + assert!(pod_store.read_by_name("agent").unwrap().is_some()); + } + + #[cfg(unix)] + #[tokio::test] + async fn live_pod_delete_is_refused() { + use std::os::unix::net::UnixListener; + + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let runtime_dir = tmp.path().join("run"); + let pod_store = FsPodStore::new(data_dir.join("pods")).unwrap(); + pod_store.write(&PodMetadata::new("agent", None)).unwrap(); + std::fs::create_dir_all(runtime_dir.join("agent")).unwrap(); + let listener = UnixListener::bind(runtime_dir.join("agent/sock")).unwrap(); + + let output = run_with_roots( + PodCleanupCli::Delete(PodDeleteOptions { + name: "agent".into(), + force: true, + dry_run: false, + }), + data_dir, + runtime_dir, + ) + .await + .unwrap(); + + drop(listener); + assert_eq!(output.status, PodCleanupCliStatus::Failure); + assert!(output.stdout.contains("status: refused")); + assert!(pod_store.read_by_name("agent").unwrap().is_some()); + } +} diff --git a/crates/yoi/src/session_cli.rs b/crates/yoi/src/session_cli.rs index ab3d8e6c..29641254 100644 --- a/crates/yoi/src/session_cli.rs +++ b/crates/yoi/src/session_cli.rs @@ -1,10 +1,21 @@ +use std::collections::BTreeSet; use std::fmt; use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +use manifest::paths; +use pod_store::{FsPodStore, PodMetadataStore}; +use session_store::{FsStore, SessionId, Store}; + +use crate::pod_cleanup_cli::parse_duration; + +const MAX_REPORT_ITEMS: usize = 50; #[derive(Debug, Clone, PartialEq, Eq)] pub enum SessionCli { Help, Analyze(SessionAnalyzeOptions), + Prune(SessionPruneOptions), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -13,6 +24,14 @@ pub struct SessionAnalyzeOptions { pub json: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionPruneOptions { + pub unreferenced: bool, + pub older_than: Option, + pub force: bool, + pub dry_run: bool, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionCliOutput { pub stdout: String, @@ -22,6 +41,7 @@ pub struct SessionCliOutput { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionCliStatus { Success, + Failure, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -41,6 +61,7 @@ pub fn parse_session_args(args: &[String]) -> Result parse_analyze_args(&args[1..]).map(SessionCli::Analyze), + "prune" => parse_prune_args(&args[1..]).map(SessionCli::Prune), other => Err(SessionCliError(format!( "unknown yoi session command `{other}`" ))), @@ -79,6 +100,65 @@ fn parse_analyze_args(args: &[String]) -> Result Result { + let mut unreferenced = false; + let mut older_than = None; + let mut force = false; + let mut dry_run = false; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == "--unreferenced" { + unreferenced = true; + index += 1; + } else if arg == "--force" { + force = true; + index += 1; + } else if arg == "--dry-run" { + dry_run = true; + index += 1; + } else if arg == "--older-than" { + let value = args + .get(index + 1) + .ok_or_else(|| SessionCliError("--older-than requires a duration value".into()))?; + if value.starts_with('-') { + return Err(SessionCliError( + "--older-than requires a duration value".into(), + )); + } + older_than = Some(parse_duration(value).map_err(|e| SessionCliError(e.to_string()))?); + index += 2; + } else if let Some(value) = arg.strip_prefix("--older-than=") { + if value.is_empty() { + return Err(SessionCliError( + "--older-than requires a duration value".into(), + )); + } + older_than = Some(parse_duration(value).map_err(|e| SessionCliError(e.to_string()))?); + index += 1; + } else if arg.starts_with('-') { + return Err(SessionCliError(format!( + "unknown yoi session prune option `{arg}`" + ))); + } else { + return Err(SessionCliError(format!( + "yoi session prune does not accept positional argument `{arg}`" + ))); + } + } + if !unreferenced { + return Err(SessionCliError( + "yoi session prune requires --unreferenced".into(), + )); + } + Ok(SessionPruneOptions { + unreferenced, + older_than, + force, + dry_run, + }) +} + fn set_path(path: &mut Option, value: &str) -> Result<(), SessionCliError> { if path.is_some() { return Err(SessionCliError( @@ -105,16 +185,181 @@ pub fn run(cli: SessionCli) -> Result { status: SessionCliStatus::Success, }) } + SessionCli::Prune(options) => { + let data_dir = paths::data_dir() + .ok_or_else(|| SessionCliError("failed to resolve Yoi data directory".into()))?; + run_prune_with_roots(options, data_dir) + } } } +pub fn run_prune_with_roots( + options: SessionPruneOptions, + data_dir: PathBuf, +) -> Result { + if !options.unreferenced { + return Err(SessionCliError( + "yoi session prune requires --unreferenced".into(), + )); + } + let session_store = FsStore::new(data_dir.join("sessions")).map_err(to_error)?; + let pod_store = FsPodStore::new(data_dir.join("pods")).map_err(to_error)?; + let referenced_sessions = referenced_sessions(&pod_store)?; + let cutoff = options + .older_than + .map(|older_than| { + SystemTime::now() + .checked_sub(older_than) + .ok_or_else(|| SessionCliError("--older-than duration is too large".into())) + }) + .transpose()?; + let delete = options.force && !options.dry_run; + + let mut deleted = 0usize; + let mut would_delete = 0usize; + let mut kept_referenced = 0usize; + let mut kept_newer = 0usize; + let mut refused = 0usize; + let mut stdout = String::new(); + stdout.push_str("yoi session prune\n"); + stdout.push_str(if delete { + "mode: force\n" + } else { + "mode: dry-run\n" + }); + stdout.push_str("scope: unreferenced sessions\n"); + if let Some(older_than) = options.older_than { + stdout.push_str(&format!("older_than: {older_than:?}\n")); + } + + let sessions = session_store.list_sessions().map_err(to_error)?; + for (index, session_id) in sessions.iter().enumerate() { + if referenced_sessions.contains(session_id) { + kept_referenced += 1; + push_item_line( + &mut stdout, + index, + "kept", + *session_id, + "referenced by pod metadata", + ); + continue; + } + if let Some(cutoff) = cutoff { + let modified = session_store + .session_modified_at(*session_id) + .map_err(to_error)?; + match modified { + Some(modified) if modified > cutoff => { + kept_newer += 1; + push_item_line( + &mut stdout, + index, + "kept", + *session_id, + "newer than threshold", + ); + continue; + } + Some(_) => {} + None => { + refused += 1; + push_item_line( + &mut stdout, + index, + "refused", + *session_id, + "session mtime is unavailable", + ); + continue; + } + } + } + if delete { + session_store + .delete_session(*session_id) + .map_err(to_error)?; + deleted += 1; + push_item_line( + &mut stdout, + index, + "deleted", + *session_id, + "unreferenced session", + ); + } else { + would_delete += 1; + push_item_line( + &mut stdout, + index, + "would_delete", + *session_id, + "unreferenced session", + ); + } + } + stdout.push_str(&format!( + "summary: deleted={deleted} would_delete={would_delete} kept_referenced={kept_referenced} kept_newer={kept_newer} refused={refused}\n" + )); + if !delete { + stdout + .push_str("note: pass --force to delete sessions; --dry-run keeps report-only mode\n"); + } + Ok(SessionCliOutput { + stdout, + status: if refused > 0 { + SessionCliStatus::Failure + } else { + SessionCliStatus::Success + }, + }) +} + +fn referenced_sessions(pod_store: &FsPodStore) -> Result, SessionCliError> { + let mut sessions = BTreeSet::new(); + for name in pod_store.list_names().map_err(to_error)? { + let metadata = pod_store + .read_by_name(&name) + .map_err(to_error)? + .ok_or_else(|| { + SessionCliError(format!( + "pod metadata for `{name}` disappeared while checking references" + )) + })?; + if let Some(active) = metadata.active { + sessions.insert(active.session_id); + } + } + Ok(sessions) +} + +fn push_item_line( + stdout: &mut String, + index: usize, + action: &str, + session_id: SessionId, + reason: &str, +) { + if index < MAX_REPORT_ITEMS { + stdout.push_str(&format!("{action}: {session_id} ({reason})\n")); + } else if index == MAX_REPORT_ITEMS { + stdout.push_str("... additional items omitted from bounded report ...\n"); + } +} + +fn to_error(error: E) -> SessionCliError { + SessionCliError(error.to_string()) +} + pub fn help_text() -> &'static str { - "yoi session\n\nUsage:\n yoi session analyze --json\n\nOptions:\n --json Emit a machine-readable JSON analytics report\n -h, --help Print help\n" + "yoi session\n\nUsage:\n yoi session analyze --json\n yoi session prune --unreferenced [--older-than ] [--force] [--dry-run]\n\nOptions:\n --json Emit a machine-readable JSON analytics report\n --unreferenced Prune only Sessions not referenced by Pod metadata\n --older-than Optional explicit age threshold for unreferenced cleanup (units: s, m, h, d, w)\n --force Perform deletion after safety checks\n --dry-run Report only, even with --force\n -h, --help Print help\n" } #[cfg(test)] mod tests { use super::*; + use pod_store::{PodActiveSegmentRef, PodMetadata}; + use session_store::{Store, new_segment_id, new_session_id}; use std::io::Write; #[test] @@ -134,6 +379,32 @@ mod tests { ); } + #[test] + fn parse_session_prune_unreferenced() { + let cli = parse_session_args(&[ + "prune".to_string(), + "--unreferenced".to_string(), + "--older-than=2w".to_string(), + "--dry-run".to_string(), + ]) + .unwrap(); + assert_eq!( + cli, + SessionCli::Prune(SessionPruneOptions { + unreferenced: true, + older_than: Some(Duration::from_secs(14 * 24 * 60 * 60)), + force: false, + dry_run: true, + }) + ); + } + + #[test] + fn session_prune_requires_unreferenced() { + let err = parse_session_args(&["prune".to_string()]).unwrap_err(); + assert!(err.to_string().contains("--unreferenced")); + } + #[test] fn run_session_analyze_outputs_json() { let mut fixture = tempfile::NamedTempFile::new().unwrap(); @@ -165,6 +436,88 @@ mod tests { ); } + #[test] + fn session_prune_unreferenced_preserves_active_pod_reference() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let session_store = FsStore::new(data_dir.join("sessions")).unwrap(); + let pod_store = FsPodStore::new(data_dir.join("pods")).unwrap(); + let referenced_session = new_session_id(); + let referenced_segment = new_segment_id(); + let orphan_session = new_session_id(); + let orphan_segment = new_segment_id(); + session_store + .create_segment(referenced_session, referenced_segment, &[]) + .unwrap(); + session_store + .create_segment(orphan_session, orphan_segment, &[]) + .unwrap(); + pod_store + .write(&PodMetadata::new( + "agent", + Some(PodActiveSegmentRef::active_segment( + referenced_session, + referenced_segment, + )), + )) + .unwrap(); + + let output = run_prune_with_roots( + SessionPruneOptions { + unreferenced: true, + older_than: None, + force: true, + dry_run: false, + }, + data_dir, + ) + .unwrap(); + + assert_eq!(output.status, SessionCliStatus::Success); + assert!(output.stdout.contains("deleted=1")); + assert!( + session_store + .exists(referenced_session, referenced_segment) + .unwrap() + ); + assert!( + !session_store + .exists(orphan_session, orphan_segment) + .unwrap() + ); + } + + #[test] + fn session_prune_without_force_is_dry_run() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let session_store = FsStore::new(data_dir.join("sessions")).unwrap(); + let orphan_session = new_session_id(); + let orphan_segment = new_segment_id(); + session_store + .create_segment(orphan_session, orphan_segment, &[]) + .unwrap(); + + let output = run_prune_with_roots( + SessionPruneOptions { + unreferenced: true, + older_than: None, + force: false, + dry_run: false, + }, + data_dir, + ) + .unwrap(); + + assert_eq!(output.status, SessionCliStatus::Success); + assert!(output.stdout.contains("mode: dry-run")); + assert!( + session_store + .exists(orphan_session, orphan_segment) + .unwrap() + ); + } + #[test] fn analyze_requires_json_for_initial_cli() { let err = parse_session_args(&["analyze".to_string(), "/tmp/session.jsonl".to_string()]) From 439f967cb81ec184844510387b58a838a9e8725e Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 24 Jun 2026 21:22:58 +0900 Subject: [PATCH 08/11] chore: record cleanup cli implementation report --- .yoi/tickets/00001KVWPVHFJ/item.md | 2 +- .yoi/tickets/00001KVWPVHFJ/thread.md | 35 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVWPVHFJ/item.md b/.yoi/tickets/00001KVWPVHFJ/item.md index 8ef911d6..840c26b6 100644 --- a/.yoi/tickets/00001KVWPVHFJ/item.md +++ b/.yoi/tickets/00001KVWPVHFJ/item.md @@ -2,7 +2,7 @@ title: 'Pod/session storage cleanup CLI を追加する' state: 'inprogress' created_at: '2026-06-24T11:39:41Z' -updated_at: '2026-06-24T12:04:07Z' +updated_at: '2026-06-24T12:22:48Z' assignee: null readiness: 'implementation_ready' risk_flags: ['pod-lifecycle', 'persistence', 'destructive-operation', 'cli-ux', 'session-history', 'authority-boundary'] diff --git a/.yoi/tickets/00001KVWPVHFJ/thread.md b/.yoi/tickets/00001KVWPVHFJ/thread.md index a9778587..70066a2d 100644 --- a/.yoi/tickets/00001KVWPVHFJ/thread.md +++ b/.yoi/tickets/00001KVWPVHFJ/thread.md @@ -106,4 +106,39 @@ Orchestrator acceptance: queued -> inprogress - routing decision と IntentPacket は Ticket thread に記録済み。 - これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVWPVHFJ-storage-cleanup-cli` を作成し、multi-agent-workflow に接続する。 +--- + + + +## Implementation report + +実装報告(Ticket 00001KVWPVHFJ) + +実装コミット: `80d6861a` (`feat: add pod and session cleanup CLI`) + +変更ファイル: +- `crates/yoi/src/main.rs`: `yoi pod delete/prune` と `yoi session prune` の CLI ルーティング・help・終了ステータス処理を追加。 +- `crates/yoi/src/pod_cleanup_cli.rs`: Pod メタデータ cleanup CLI を追加。`delete` は Pod メタデータのみ削除し、session logs/history は保持。`prune` は明示された `--older-than` のみで候補化。live/reachable または liveness 不確実時は拒否。`--force` がない場合および `--dry-run` は report-only。 +- `crates/yoi/src/session_cli.rs`: `session prune --unreferenced [--older-than ] [--force] [--dry-run]` を追加。Pod metadata の active session 参照を保護し、unreferenced Session のみ Session store API 経由で削除。 +- `crates/session-store/src/fs_store.rs`: Session store authority 内に `root_dir` / `session_modified_at` / `delete_session` を追加し、Session 単位削除と age 判定を store 側 API に寄せた。 +- `crates/yoi/Cargo.toml`, `Cargo.lock`: `yoi` から `pod-store` を直接利用する依存を追加。 + +検証結果: +- `cargo fmt --check`: 成功。 +- `cargo test -p yoi`: 成功(95 tests)。 +- `cargo test -p session-store --lib`: 成功(33 tests)。 +- `cargo test -p pod-store --lib`: 成功(6 tests)。 +- `cargo test -p pod discovery:: --lib`: 成功(8 tests)。 +- `cargo check -p yoi -p pod -p pod-store -p session-store`: 成功。 +- `cargo run -p yoi -- ticket doctor`: 成功(`doctor: ok`)。 +- `git diff --check`: 成功。 + +補足: +- 途中で `cargo test -p yoi -p pod-store -p session-store -p pod --lib` も実行したが、今回触れていない Pod prompt guidance の文言 assertion 2 件(`worktree status, diff, and test results`)で失敗したため、要求どおり `pod discovery::` に絞って再検証した。 + +残リスク: +- Pod liveness 判定は runtime socket への Unix socket connect に基づく。接続成功は live/reachable として拒否し、権限エラー等の不確実な結果も fail-safe で拒否する。socket が存在しない/接続拒否の stale socket は停止扱い。 +- `session prune --unreferenced` は現行 Pod metadata の `active.session_id` を authority として保護する。過去の非 active 参照は cleanup 対象になり得るため、設計どおり dry-run/report-first と `--force` 必須で運用する前提。 + + --- From 091ee764a40bb1e1ac3c7ce5e6ab0dc42c266d7a Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 24 Jun 2026 21:30:49 +0900 Subject: [PATCH 09/11] ticket: approve cleanup cli implementation --- .yoi/tickets/00001KVWPVHFJ/item.md | 2 +- .yoi/tickets/00001KVWPVHFJ/thread.md | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVWPVHFJ/item.md b/.yoi/tickets/00001KVWPVHFJ/item.md index 1441911f..20df9204 100644 --- a/.yoi/tickets/00001KVWPVHFJ/item.md +++ b/.yoi/tickets/00001KVWPVHFJ/item.md @@ -2,7 +2,7 @@ title: 'Pod/session storage cleanup CLI を追加する' state: 'inprogress' created_at: '2026-06-24T11:39:41Z' -updated_at: '2026-06-24T12:05:13Z' +updated_at: '2026-06-24T12:30:40Z' assignee: null readiness: 'implementation_ready' risk_flags: ['pod-lifecycle', 'persistence', 'destructive-operation', 'cli-ux', 'session-history', 'authority-boundary'] diff --git a/.yoi/tickets/00001KVWPVHFJ/thread.md b/.yoi/tickets/00001KVWPVHFJ/thread.md index cf2de667..0961d2ca 100644 --- a/.yoi/tickets/00001KVWPVHFJ/thread.md +++ b/.yoi/tickets/00001KVWPVHFJ/thread.md @@ -120,3 +120,29 @@ Implementation orchestration update: - Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。 --- + + + +## Review: approve + +Review result: approve + +確認内容: +- child worktree `/home/hare/Projects/yoi/.worktree/00001KVWPVHFJ-storage-cleanup-cli` のみを read-only 確認。 +- Ticket 要件・acceptance criteria・IntentPacket / implementation report、`5c9331e8..HEAD` の差分、主な実装箇所 `crates/yoi/src/main.rs`, `crates/yoi/src/pod_cleanup_cli.rs`, `crates/yoi/src/session_cli.rs`, `crates/pod-store/src/lib.rs`, `crates/session-store/src/fs_store.rs` を確認。 +- read-only 指示のため validation は再実行していない。 + +承認根拠: +- `yoi pod delete [--force] [--dry-run]`, `yoi pod prune --older-than ...`, `yoi session prune --unreferenced ...` の command spelling と product CLI routing が追加されている。 +- `pod delete` は `FsPodStore::delete_by_name` 経由で Pod metadata のみを削除し、session logs/history を削除しない。出力にも preserve が明示される。 +- live/reachable Pod は Unix socket connect 成功時に拒否され、権限エラー等の uncertain liveness も fail-safe で拒否される。`--force` でも live/uncertain は override しない。 +- destructive deletion は `--force && !--dry-run` の場合のみ実行され、通常時/dry-run は report-only になる。 +- `pod prune` は `--older-than` 必須で、暗黙の old threshold を持たない。 +- `session prune --unreferenced` は Pod metadata の active `session_id` を保護集合にしており、referenced session を削除対象から外す。削除は `SessionId` ベースの session-store API 経由で、path traversal 的な広範削除には見えない。 +- Panel/TUI/scheduler や legacy resume/bare Pod-name inference の scope creep は見当たらない。 +- Coder reported validation は Ticket 指定範囲を概ね満たしており、広い Pod lib test の失敗は既存 prompt assertion と説明され、対象 discovery test は再実行成功と記録されている。 + +Non-blocking note: +- dry-run/失敗 path でも既存 store constructor が root directory を初期作成し得る設計に見える。削除は発生しないため今回の blocker とは見なさないが、将来「dry-run は完全 no-write」と定義するなら follow-up で明確化するとよい。 + +--- From 28d53aadf27ab89f8fc5a54ec6eb12aadf57d8c4 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 24 Jun 2026 21:35:28 +0900 Subject: [PATCH 10/11] nix: update yoi cleanup cargo hash --- package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.nix b/package.nix index 312cd063..06fdc54f 100644 --- a/package.nix +++ b/package.nix @@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-rR8mrMDg1CCXb3Y5MnhUqSd0vAsI5OStFQCRIrvx8fg="; + cargoHash = "sha256-8mo2/IZMq3tfnv8fKRxJOdfb+T3NOheUmqT8TiR+Wag="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, From d5012d3e1618b732c0e9469866f7359e1db441de Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 24 Jun 2026 21:36:19 +0900 Subject: [PATCH 11/11] ticket: mark cleanup cli done --- .yoi/tickets/00001KVWPVHFJ/item.md | 4 +- .yoi/tickets/00001KVWPVHFJ/thread.md | 68 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVWPVHFJ/item.md b/.yoi/tickets/00001KVWPVHFJ/item.md index 20df9204..52414fec 100644 --- a/.yoi/tickets/00001KVWPVHFJ/item.md +++ b/.yoi/tickets/00001KVWPVHFJ/item.md @@ -1,8 +1,8 @@ --- title: 'Pod/session storage cleanup CLI を追加する' -state: 'inprogress' +state: 'done' created_at: '2026-06-24T11:39:41Z' -updated_at: '2026-06-24T12:30:40Z' +updated_at: '2026-06-24T12:36:12Z' assignee: null readiness: 'implementation_ready' risk_flags: ['pod-lifecycle', 'persistence', 'destructive-operation', 'cli-ux', 'session-history', 'authority-boundary'] diff --git a/.yoi/tickets/00001KVWPVHFJ/thread.md b/.yoi/tickets/00001KVWPVHFJ/thread.md index 0961d2ca..4b7edf56 100644 --- a/.yoi/tickets/00001KVWPVHFJ/thread.md +++ b/.yoi/tickets/00001KVWPVHFJ/thread.md @@ -146,3 +146,71 @@ Non-blocking note: - dry-run/失敗 path でも既存 store constructor が root directory を初期作成し得る設計に見える。削除は発生しないため今回の blocker とは見なさないが、将来「dry-run は完全 no-write」と定義するなら follow-up で明確化するとよい。 --- + + + +## Implementation report + +Integration outcome: + +- Coder `yoi-coder-00001KVWPVHFJ-cleanup-cli` が implementation branch `work/00001KVWPVHFJ-storage-cleanup-cli` に実装を commit した。 + - implementation commit: `80d6861a feat: add pod and session cleanup CLI` + - child ticket report commit: `439f967c chore: record cleanup cli implementation report` +- Reviewer `yoi-reviewer-00001KVWPVHFJ-cleanup-cli` は read-only review で `approve`。Pod metadata only delete、session preservation、live/uncertain refusal、force/dry-run semantics、explicit threshold、session active reference protection、path authority、scope creep なしを確認した。 +- Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration` で branch `work/00001KVWPVHFJ-storage-cleanup-cli` を merge 済み。 + - merge commit: `4fb75ec3 merge: 00001KVWPVHFJ storage cleanup cli` +- merge 時に Ticket item/thread の append conflict が発生したため、orchestration 側の Ticket record を保持して merge し、この integration outcome に実装・review・validation evidence を集約して記録した。 +- cleanup CLI 実装で Cargo dependencies / `Cargo.lock` が変わったため、Nix package cargoHash を更新した。 + - package hash commit: `28d53aad nix: update yoi cleanup cargo hash` + +Implemented behavior: +- `yoi pod delete [--force] [--dry-run]`: stopped/restorable Pod metadata のみ削除。sessions/history は削除しない。live/uncertain liveness は拒否。 +- `yoi pod prune --older-than [--force] [--dry-run]`: explicit threshold required。Pod metadata のみ prune。 +- `yoi session prune --unreferenced [--older-than ] [--force] [--dry-run]`: Pod metadata active session references を保護し、unreferenced session のみ対象。 +- `session-store` に session deletion / mtime support を追加。 + +Validation in Orchestrator worktree: +- `cargo fmt --check`: success +- `cargo test -p yoi`: success +- `cargo test -p session-store --lib`: success +- `cargo test -p pod-store --lib`: success +- `cargo test -p pod discovery:: --lib`: success +- `cargo check -p yoi -p pod -p pod-store -p session-store`: success +- `cargo run -p yoi -- ticket doctor`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success after updating `package.nix` cargoHash to `sha256-8mo2/IZMq3tfnv8fKRxJOdfb+T3NOheUmqT8TiR+Wag=` + +Notes: +- 初回 `nix build .#yoi --no-link` は cargoHash stale のため失敗し、hash 更新後に成功した。 +- Reviewer non-blocking note: dry-run/失敗 path でも既存 store constructor が root directory を初期作成し得る設計に見える。削除は起きないため blocker ではないが、将来「dry-run は完全 no-write」と定義するなら follow-up で明確化可能。 + +Next action: +- Mark Ticket done after this integration/validation evidence. +- Then stop related child Pods and remove only the child implementation worktree/branch. + +--- + + + +## State changed + +Implementation is merged into the orchestration branch and validated. + +Evidence: +- merge commit: `4fb75ec3 merge: 00001KVWPVHFJ storage cleanup cli` +- package hash commit: `28d53aad nix: update yoi cleanup cargo hash` +- reviewer result: approve +- validation in `/home/hare/Projects/yoi/.worktree/orchestration` succeeded: + - `cargo fmt --check` + - `cargo test -p yoi` + - `cargo test -p session-store --lib` + - `cargo test -p pod-store --lib` + - `cargo test -p pod discovery:: --lib` + - `cargo check -p yoi -p pod -p pod-store -p session-store` + - `cargo run -p yoi -- ticket doctor` + - `git diff --check` + - `nix build .#yoi --no-link` + +Closure is not performed here; this state records implementation completion after merge/validation. + +---