diff --git a/.yoi/tickets/00001KVF0ZJM5/artifacts/.gitkeep b/.yoi/tickets/00001KVF0ZJM5/artifacts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.yoi/tickets/00001KVF0ZJM5/artifacts/relations.json b/.yoi/tickets/00001KVF0ZJM5/artifacts/relations.json new file mode 100644 index 00000000..f1eb9c48 --- /dev/null +++ b/.yoi/tickets/00001KVF0ZJM5/artifacts/relations.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "relations": [ + { + "ticket_id": "00001KVF0ZJM5", + "kind": "related", + "target": "00001KVDETSN6", + "note": "Implements live startup latency improvement after dashboard content-ready measurement exposed Pod probe bottleneck.", + "author": "yoi ticket", + "at": "2026-06-19T04:19:09Z" + }, + { + "ticket_id": "00001KVF0ZJM5", + "kind": "related", + "target": "00001KVDQH839", + "note": "Uses shell/live startup measurements added by the E2E launch-path work.", + "author": "yoi ticket", + "at": "2026-06-19T04:19:09Z" + } + ] +} diff --git a/.yoi/tickets/00001KVF0ZJM5/item.md b/.yoi/tickets/00001KVF0ZJM5/item.md new file mode 100644 index 00000000..95bc905e --- /dev/null +++ b/.yoi/tickets/00001KVF0ZJM5/item.md @@ -0,0 +1,43 @@ +--- +title: 'Panel startup で Pod status probe を重複実行せず初回一覧表示を高速化する' +state: 'closed' +created_at: '2026-06-19T04:07:17Z' +updated_at: '2026-06-19T04:19:09Z' +assignee: null +readiness: 'implementation_ready' +risk_flags: ['panel', 'startup-latency', 'pod-status-probe', 'live-path', 'performance'] +--- + +## Background + +Live workspace で `yoi panel` を起動すると、first frame は約 50ms で出る一方、実際の Ticket / Pod rows が表示されるまで約 8 秒かかっている。実測 breakdown では `pod_metadata_status_probe.initial`、`companion.presence`、`orchestrator.presence` がそれぞれ約 2.5 秒かかり、同じ Pod metadata / live status scan が初回 dashboard render 前に直列で重複実行されている。 + +この Ticket では初回一覧表示前の重複 Pod status probe をなくし、live Pod summary の重い session log scan を避け、ユーザー目線の「一覧が表示されるまで」を短縮する。 + +## Requirements + +- `load_multi_pod_snapshot` で初回 `load_pod_list` の結果を Companion / Orchestrator presence 判定に再利用する。 +- 初回 render 前に `load_exact_companion_pod_presence` / `load_exact_pod_presence` 相当の追加 full probe を直列実行しない。 +- Live status probe は session log 全読みの preview/summary 作成を初回 path で行わない。 + - stored metadata summary を優先して使う。 + - live-only row は minimal live summary でよい。 +- Companion / Orchestrator spawn/restore が必要な場合の reload は維持する。 +- Existing Panel behavior を壊さない。 + - Companion / Orchestrator live status 表示 + - Queue action + - Pod rows open/attach + - E2E dashboard readiness +- Live workspace に近い例外的計測で、rows 表示までの時間が改善していることを確認する。 + +## Acceptance criteria + +- Panel startup source breakdown で `companion.presence` / `orchestrator.presence` が追加 full Pod probe として秒単位で出ない。 +- Live workspace 計測で first non-empty rows 表示が従来約 8 秒から明確に短縮する。 +- `cargo test -p yoi-e2e --features e2e --test panel` が通る。 +- `cargo check -p yoi-e2e -p yoi -p tui --features tui/e2e-test` が通る。 +- `cargo fmt --check` / `git diff --check` / `target/debug/yoi ticket doctor` が通る。 + +## Related work + +- `00001KVDETSN6` — Panel startup latency をユーザー目線の dashboard content ready 基準で計測・改善する。 +- `00001KVDQH839` — Panel E2E に shell Enter 起動経路の dashboard readiness 計測を追加する。 diff --git a/.yoi/tickets/00001KVF0ZJM5/resolution.md b/.yoi/tickets/00001KVF0ZJM5/resolution.md new file mode 100644 index 00000000..11557047 --- /dev/null +++ b/.yoi/tickets/00001KVF0ZJM5/resolution.md @@ -0,0 +1,22 @@ +Implemented and validated. + +Changes: +- Reused the initial `load_pod_list` result for Companion and Orchestrator presence in `load_multi_pod_snapshot`, removing two duplicate full Pod status probes before the first dashboard rows render. +- Renamed the E2E source timings to `companion.presence.from_initial_list` and `orchestrator.presence.from_initial_list` so regressions show whether the initial list is reused. +- Changed Pod list startup summarization to avoid reading active session logs while building initial Pod rows. Stored metadata now uses a cheap active-segment marker and live-only rows keep existing minimal live/pending summaries. +- Preserved spawn/restore behavior after Companion/Orchestrator lifecycle changes; if lifecycle changes require reload, the existing reload path remains. + +Live-path measurement in the current workspace: +- Before this fix: first non-empty Panel rows appeared at about 7967ms; `pod_metadata_status_probe.initial`, `companion.presence`, and `orchestrator.presence` were each about 2.5s. +- After removing duplicate probes only: first non-empty rows appeared at about 2964ms; duplicate presence probes dropped to 0ms but initial Pod metadata/status probe was still about 2386ms. +- After also removing session-log reads from the startup Pod summary path: first non-empty rows appeared at about 754ms; `pod_metadata_status_probe.initial` was about 138ms; total dashboard source breakdown was about 649ms. + +Validation: +- cargo test -p tui pod_list --lib +- cargo test -p yoi-e2e --features e2e --test panel +- cargo check -p yoi-e2e -p yoi -p tui --features tui/e2e-test +- cargo build -p yoi +- cargo fmt --check +- git diff --check +- target/debug/yoi ticket doctor +- nix build .#yoi --no-link diff --git a/.yoi/tickets/00001KVF0ZJM5/thread.md b/.yoi/tickets/00001KVF0ZJM5/thread.md new file mode 100644 index 00000000..44684d31 --- /dev/null +++ b/.yoi/tickets/00001KVF0ZJM5/thread.md @@ -0,0 +1,46 @@ + + +## 作成 + +LocalTicketBackend によって作成されました。 + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Implemented and validated. + +Changes: +- Reused the initial `load_pod_list` result for Companion and Orchestrator presence in `load_multi_pod_snapshot`, removing two duplicate full Pod status probes before the first dashboard rows render. +- Renamed the E2E source timings to `companion.presence.from_initial_list` and `orchestrator.presence.from_initial_list` so regressions show whether the initial list is reused. +- Changed Pod list startup summarization to avoid reading active session logs while building initial Pod rows. Stored metadata now uses a cheap active-segment marker and live-only rows keep existing minimal live/pending summaries. +- Preserved spawn/restore behavior after Companion/Orchestrator lifecycle changes; if lifecycle changes require reload, the existing reload path remains. + +Live-path measurement in the current workspace: +- Before this fix: first non-empty Panel rows appeared at about 7967ms; `pod_metadata_status_probe.initial`, `companion.presence`, and `orchestrator.presence` were each about 2.5s. +- After removing duplicate probes only: first non-empty rows appeared at about 2964ms; duplicate presence probes dropped to 0ms but initial Pod metadata/status probe was still about 2386ms. +- After also removing session-log reads from the startup Pod summary path: first non-empty rows appeared at about 754ms; `pod_metadata_status_probe.initial` was about 138ms; total dashboard source breakdown was about 649ms. + +Validation: +- cargo test -p tui pod_list --lib +- cargo test -p yoi-e2e --features e2e --test panel +- cargo check -p yoi-e2e -p yoi -p tui --features tui/e2e-test +- cargo build -p yoi +- cargo fmt --check +- git diff --check +- target/debug/yoi ticket doctor +- nix build .#yoi --no-link + + +--- diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index d0ca36d5..55990976 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -2503,10 +2503,10 @@ async fn load_multi_pod_snapshot( #[cfg(feature = "e2e-test")] let source_started = Instant::now(); - let companion_presence = load_exact_companion_pod_presence(&companion_pod_name).await?; + let companion_presence = companion_pod_presence(&companion_pod_name, &list); #[cfg(feature = "e2e-test")] source_timings.push(PanelE2eSourceTiming { - source: "companion.presence", + source: "companion.presence.from_initial_list", elapsed_ms: source_started.elapsed().as_millis(), }); @@ -2557,12 +2557,12 @@ async fn load_multi_pod_snapshot( let orchestrator_presence = match &config { TicketConfigAvailability::Absent | TicketConfigAvailability::Unusable(_) => None, TicketConfigAvailability::Usable => { - Some(load_exact_pod_presence(&orchestrator_pod_name).await?) + Some(orchestrator_pod_presence(&orchestrator_pod_name, &list)) } }; #[cfg(feature = "e2e-test")] source_timings.push(PanelE2eSourceTiming { - source: "orchestrator.presence", + source: "orchestrator.presence.from_initial_list", elapsed_ms: source_started.elapsed().as_millis(), }); @@ -3375,18 +3375,6 @@ fn existing_ticket_claim_notice( } } -async fn load_exact_companion_pod_presence( - pod_name: &str, -) -> Result { - let list = load_pod_list(Some(pod_name.to_string()), usize::MAX).await?; - Ok(companion_pod_presence(pod_name, &list)) -} - -async fn load_exact_pod_presence(pod_name: &str) -> Result { - let list = load_pod_list(Some(pod_name.to_string()), usize::MAX).await?; - Ok(orchestrator_pod_presence(pod_name, &list)) -} - async fn load_pod_list( selected_name: Option, max_entries: usize, diff --git a/crates/tui/src/pod_list.rs b/crates/tui/src/pod_list.rs index d9907942..bc0210a1 100644 --- a/crates/tui/src/pod_list.rs +++ b/crates/tui/src/pod_list.rs @@ -7,9 +7,7 @@ use client::PodClient; use pod_registry::{LockFileGuard, default_registry_path}; use pod_store::{PodActiveSegmentRef, PodMetadata, PodMetadataStore}; use protocol::{Event, PodStatus}; -use session_store::{ - FsStore, LogEntry, LoggedContentPart, LoggedItem, SegmentId, SessionId, Store, -}; +use session_store::{FsStore, SegmentId, SessionId}; #[derive(Debug, Clone)] pub(crate) struct PodList { @@ -27,14 +25,6 @@ impl PodList { ) -> Self { let mut entries_by_name: BTreeMap = BTreeMap::new(); - for live_info in live { - let name = live_info.pod_name.clone(); - entries_by_name - .entry(name.clone()) - .or_insert_with(|| PodListEntry::new(name, source)) - .merge_live(live_info); - } - for stored_info in stored { let name = stored_info.pod_name.clone(); entries_by_name @@ -43,6 +33,14 @@ impl PodList { .merge_stored(stored_info); } + for live_info in live { + let name = live_info.pod_name.clone(); + entries_by_name + .entry(name.clone()) + .or_insert_with(|| PodListEntry::new(name, source)) + .merge_live(live_info); + } + let mut entries: Vec = entries_by_name.into_values().collect(); for entry in &mut entries { entry.finalize(); @@ -358,7 +356,7 @@ pub(crate) async fn read_reachable_live_pod_infos( } async fn probe_reachable_live_pod_infos( - store: &FsStore, + _store: &FsStore, records: Vec, ) -> Result, io::Error> { let mut handles = Vec::with_capacity(records.len()); @@ -371,10 +369,9 @@ async fn probe_reachable_live_pod_infos( let result = handle .await .map_err(|e| io::Error::other(format!("live status probe task failed: {e}")))?; - let Ok(mut record) = result else { + let Ok(record) = result else { continue; }; - record.summary = summarize_live_pod(store, &record); reachable.push(record); } Ok(reachable) @@ -462,109 +459,22 @@ struct SegmentSummary { preview: Option, } -fn summarize_live_pod(store: &FsStore, live: &LivePodInfo) -> PodEntrySummary { - let Some(segment_id) = live.segment_id else { - return PodEntrySummary::default(); - }; - let session_id = store.lookup_session_of(segment_id).ok().flatten(); - let Some(session_id) = session_id else { - return PodEntrySummary { - active_session_id: None, - active_segment_id: Some(segment_id), - updated_at: 0, - preview: None, - }; - }; - let summary = summarize_segment(store, session_id, segment_id); - PodEntrySummary { - active_session_id: Some(session_id), - active_segment_id: Some(segment_id), - updated_at: summary.updated_at, - preview: summary.preview, - } -} - -fn summarize_metadata(store: &FsStore, active: Option<&PodActiveSegmentRef>) -> SegmentSummary { +fn summarize_metadata(_store: &FsStore, active: Option<&PodActiveSegmentRef>) -> SegmentSummary { let Some(active) = active else { return SegmentSummary { updated_at: 0, preview: None, }; }; - let Some(segment_id) = active.segment_id else { - return SegmentSummary { + match active.segment_id { + Some(segment_id) => SegmentSummary { + updated_at: 0, + preview: Some(format!("active segment {segment_id}")), + }, + None => SegmentSummary { updated_at: 0, preview: Some("[pending segment]".to_string()), - }; - }; - summarize_segment(store, active.session_id, segment_id) -} - -fn summarize_segment( - store: &FsStore, - session_id: SessionId, - segment_id: SegmentId, -) -> SegmentSummary { - match store.read_all(session_id, segment_id) { - Ok(entries) => SegmentSummary { - updated_at: last_entry_ts(&entries).unwrap_or(0), - preview: last_message_preview(&entries).or_else(|| Some("[empty]".to_string())), }, - Err(_) => SegmentSummary { - updated_at: 0, - preview: Some("[corrupt segment]".to_string()), - }, - } -} - -fn last_entry_ts(entries: &[LogEntry]) -> Option { - entries.iter().map(log_entry_ts).max() -} - -fn log_entry_ts(entry: &LogEntry) -> u64 { - match entry { - LogEntry::SegmentStart { ts, .. } - | LogEntry::Invoke { ts, .. } - | LogEntry::UserInput { ts, .. } - | LogEntry::AssistantItem { ts, .. } - | LogEntry::ToolResult { ts, .. } - | LogEntry::SystemItem { ts, .. } - | LogEntry::TurnEnd { ts, .. } - | LogEntry::RunCompleted { ts, .. } - | LogEntry::RunErrored { ts, .. } - | LogEntry::ConfigChanged { ts, .. } - | LogEntry::LlmUsage { ts, .. } - | LogEntry::Extension { ts, .. } => *ts, - } -} - -fn last_message_preview(entries: &[LogEntry]) -> Option { - for entry in entries.iter().rev() { - match entry { - LogEntry::UserInput { segments, .. } => { - let text = protocol::Segment::flatten_to_text(segments); - if !text.is_empty() { - return Some(format!("user: {}", trim_one_line(&text, 60))); - } - } - LogEntry::AssistantItem { item, .. } => { - if let Some(text) = first_text_logged(item) { - return Some(format!("assistant: {}", trim_one_line(&text, 60))); - } - } - _ => {} - } - } - None -} - -fn first_text_logged(item: &LoggedItem) -> Option { - match item { - LoggedItem::Message { content, .. } => content.iter().find_map(|p| match p { - LoggedContentPart::Text { text } => Some(text.clone()), - _ => None, - }), - _ => None, } } @@ -652,7 +562,7 @@ mod tests { use pod_store::FsPodStore; use pod_store::{PodActiveSegmentRef, PodMetadataStore}; use protocol::stream::JsonLineWriter; - use session_store::{new_segment_id, new_session_id}; + use session_store::{LogEntry, Store, new_segment_id, new_session_id}; use tempfile::tempdir; use tokio::net::UnixListener; use tokio::sync::Barrier; @@ -660,44 +570,35 @@ mod tests { const SOURCE: PodVisibilitySource = PodVisibilitySource::ResumePicker; #[test] - fn pod_list_rows_are_sorted_by_active_segment_timestamp() { + fn stored_metadata_summary_uses_segment_marker_without_reading_session_log() { let dir = tempdir().unwrap(); let store = FsStore::new(dir.path()).unwrap(); - let earlier_session = new_session_id(); - let later_session = new_session_id(); - let earlier_segment = new_segment_id(); - let later_segment = new_segment_id(); + let session = new_session_id(); + let segment = new_segment_id(); - append_start(&store, earlier_session, earlier_segment, 10); + append_start(&store, session, segment, 10); append_user( &store, - earlier_session, - earlier_segment, + session, + segment, 100, - "old pod update", + "session log text should not be scanned", ); - append_start(&store, later_session, later_segment, 20); - append_user(&store, later_session, later_segment, 200, "new pod update"); - let entries = PodList::from_sources( + let entry = single_entry(PodList::from_sources( SOURCE, - vec![ - metadata_info(&store, "older", earlier_session, earlier_segment), - metadata_info(&store, "newer", later_session, later_segment), - ], + vec![metadata_info(&store, "stored", session, segment)], vec![], None, 10, - ) - .entries; + )); - assert_eq!(entries[0].name, "newer"); - assert_eq!(entries[0].summary.updated_at, 200); + assert_eq!(entry.name, "stored"); + assert_eq!(entry.summary.updated_at, 0); assert_eq!( - entries[0].summary.preview.as_deref(), - Some("user: new pod update") + entry.summary.preview.as_deref(), + Some(format!("active segment {segment}").as_str()) ); - assert_eq!(entries[1].name, "older"); } #[test]