fix: speed up panel startup pod probes

This commit is contained in:
Keisuke Hirata 2026-06-19 13:19:49 +09:00
parent caf18dbaab
commit 69ab9f7c22
No known key found for this signature in database
7 changed files with 169 additions and 148 deletions

View File

@ -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"
}
]
}

View File

@ -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 計測を追加する。

View File

@ -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

View File

@ -0,0 +1,46 @@
<!-- event: create author: "yoi ticket" at: 2026-06-19T04:07:17Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: hare at: 2026-06-19T04:19:09Z from: inprogress to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-19T04:19:09Z status: 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
---

View File

@ -2503,10 +2503,10 @@ async fn load_multi_pod_snapshot(
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
let source_started = Instant::now(); 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")] #[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming { source_timings.push(PanelE2eSourceTiming {
source: "companion.presence", source: "companion.presence.from_initial_list",
elapsed_ms: source_started.elapsed().as_millis(), elapsed_ms: source_started.elapsed().as_millis(),
}); });
@ -2557,12 +2557,12 @@ async fn load_multi_pod_snapshot(
let orchestrator_presence = match &config { let orchestrator_presence = match &config {
TicketConfigAvailability::Absent | TicketConfigAvailability::Unusable(_) => None, TicketConfigAvailability::Absent | TicketConfigAvailability::Unusable(_) => None,
TicketConfigAvailability::Usable => { TicketConfigAvailability::Usable => {
Some(load_exact_pod_presence(&orchestrator_pod_name).await?) Some(orchestrator_pod_presence(&orchestrator_pod_name, &list))
} }
}; };
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming { source_timings.push(PanelE2eSourceTiming {
source: "orchestrator.presence", source: "orchestrator.presence.from_initial_list",
elapsed_ms: source_started.elapsed().as_millis(), 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<CompanionPodPresence, MultiPodError> {
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<OrchestratorPodPresence, MultiPodError> {
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( async fn load_pod_list(
selected_name: Option<String>, selected_name: Option<String>,
max_entries: usize, max_entries: usize,

View File

@ -7,9 +7,7 @@ use client::PodClient;
use pod_registry::{LockFileGuard, default_registry_path}; use pod_registry::{LockFileGuard, default_registry_path};
use pod_store::{PodActiveSegmentRef, PodMetadata, PodMetadataStore}; use pod_store::{PodActiveSegmentRef, PodMetadata, PodMetadataStore};
use protocol::{Event, PodStatus}; use protocol::{Event, PodStatus};
use session_store::{ use session_store::{FsStore, SegmentId, SessionId};
FsStore, LogEntry, LoggedContentPart, LoggedItem, SegmentId, SessionId, Store,
};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct PodList { pub(crate) struct PodList {
@ -27,14 +25,6 @@ impl PodList {
) -> Self { ) -> Self {
let mut entries_by_name: BTreeMap<String, PodListEntry> = BTreeMap::new(); let mut entries_by_name: BTreeMap<String, PodListEntry> = 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 { for stored_info in stored {
let name = stored_info.pod_name.clone(); let name = stored_info.pod_name.clone();
entries_by_name entries_by_name
@ -43,6 +33,14 @@ impl PodList {
.merge_stored(stored_info); .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<PodListEntry> = entries_by_name.into_values().collect(); let mut entries: Vec<PodListEntry> = entries_by_name.into_values().collect();
for entry in &mut entries { for entry in &mut entries {
entry.finalize(); entry.finalize();
@ -358,7 +356,7 @@ pub(crate) async fn read_reachable_live_pod_infos(
} }
async fn probe_reachable_live_pod_infos( async fn probe_reachable_live_pod_infos(
store: &FsStore, _store: &FsStore,
records: Vec<LivePodInfo>, records: Vec<LivePodInfo>,
) -> Result<Vec<LivePodInfo>, io::Error> { ) -> Result<Vec<LivePodInfo>, io::Error> {
let mut handles = Vec::with_capacity(records.len()); let mut handles = Vec::with_capacity(records.len());
@ -371,10 +369,9 @@ async fn probe_reachable_live_pod_infos(
let result = handle let result = handle
.await .await
.map_err(|e| io::Error::other(format!("live status probe task failed: {e}")))?; .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; continue;
}; };
record.summary = summarize_live_pod(store, &record);
reachable.push(record); reachable.push(record);
} }
Ok(reachable) Ok(reachable)
@ -462,109 +459,22 @@ struct SegmentSummary {
preview: Option<String>, preview: Option<String>,
} }
fn summarize_live_pod(store: &FsStore, live: &LivePodInfo) -> PodEntrySummary { fn summarize_metadata(_store: &FsStore, active: Option<&PodActiveSegmentRef>) -> SegmentSummary {
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 {
let Some(active) = active else { let Some(active) = active else {
return SegmentSummary { return SegmentSummary {
updated_at: 0, updated_at: 0,
preview: None, preview: None,
}; };
}; };
let Some(segment_id) = active.segment_id else { match active.segment_id {
return SegmentSummary { Some(segment_id) => SegmentSummary {
updated_at: 0,
preview: Some(format!("active segment {segment_id}")),
},
None => SegmentSummary {
updated_at: 0, updated_at: 0,
preview: Some("[pending segment]".to_string()), 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<u64> {
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<String> {
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<String> {
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::FsPodStore;
use pod_store::{PodActiveSegmentRef, PodMetadataStore}; use pod_store::{PodActiveSegmentRef, PodMetadataStore};
use protocol::stream::JsonLineWriter; 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 tempfile::tempdir;
use tokio::net::UnixListener; use tokio::net::UnixListener;
use tokio::sync::Barrier; use tokio::sync::Barrier;
@ -660,44 +570,35 @@ mod tests {
const SOURCE: PodVisibilitySource = PodVisibilitySource::ResumePicker; const SOURCE: PodVisibilitySource = PodVisibilitySource::ResumePicker;
#[test] #[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 dir = tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap(); let store = FsStore::new(dir.path()).unwrap();
let earlier_session = new_session_id(); let session = new_session_id();
let later_session = new_session_id(); let segment = new_segment_id();
let earlier_segment = new_segment_id();
let later_segment = new_segment_id();
append_start(&store, earlier_session, earlier_segment, 10); append_start(&store, session, segment, 10);
append_user( append_user(
&store, &store,
earlier_session, session,
earlier_segment, segment,
100, 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, SOURCE,
vec![ vec![metadata_info(&store, "stored", session, segment)],
metadata_info(&store, "older", earlier_session, earlier_segment),
metadata_info(&store, "newer", later_session, later_segment),
],
vec![], vec![],
None, None,
10, 10,
) ));
.entries;
assert_eq!(entries[0].name, "newer"); assert_eq!(entry.name, "stored");
assert_eq!(entries[0].summary.updated_at, 200); assert_eq!(entry.summary.updated_at, 0);
assert_eq!( assert_eq!(
entries[0].summary.preview.as_deref(), entry.summary.preview.as_deref(),
Some("user: new pod update") Some(format!("active segment {segment}").as_str())
); );
assert_eq!(entries[1].name, "older");
} }
#[test] #[test]