Skip to content

Commit 76ecad0

Browse files
committed
fix(state): plugin calls + spend rate + active task persistence + sane latency
Five user-visible bugs from the latest cockpit screenshot, all in state.rs and overview.rs: 1. emu / lich / naga / gorgon plugin calls stuck at 0 despite events arriving and display_value updating. Typed-variant handlers (Event::EmuContextUpdate, Event::LichReview, Event::NagaSpecCheck, etc.) set display_value but never bumped calls — they relied on the generic post-apply block which only set last_event. Generic block now bumps calls too. Skipped for Event::Unknown to avoid double-counting (apply_unknown's per-plugin branches already bump). 2. Spend rate $0.00/hr despite Spent $0.02. Computed inline in synthesize_health: spent_session_usd / max(uptime_hours, 0.001). 3. Active task / File / Risk / Phase / Age went blank between task lifecycle iterations because TaskCompleted cleared session.active_task_id. Stop clearing — keep the most-recent task visible, otherwise the box bounces "T-104..." → "(idle)" every ~2 seconds. 4. RUNTIME "Tasks active" and SYSTEM HEALTH "Active tasks" disagreed (2 vs 0). RUNTIME used the wire's runtime.metrics heartbeat; SYSTEM HEALTH derived from app.tasks. Now both derive from app.tasks via synthesize_health. SYSTEM HEALTH filter also includes Queued (was Running/Waiting* only). 5. P95 1486ms / P99 2271ms / Avg latency 100ms (capped) / Event Loop 100ms (capped) — interarrival times include the loop's sleeps between scenarios. Filter to bursts only (delta <= 100ms) so the metric reflects burst-mode throughput, not producer pacing. When no burst-deltas in the window, keep prior value (stable display). Tests: 80/0 lib green; release build clean. Two existing tests adjusted: unknown_runtime_metrics_populates_runtime_state now expects ongoing_tasks=0 (synthesize_health overrides wire value from app.tasks).
1 parent 22bce81 commit 76ecad0

2 files changed

Lines changed: 69 additions & 18 deletions

File tree

inspector/src/state.rs

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,7 +1040,12 @@ impl AppState {
10401040
self.health.memory_pct =
10411041
(self.events.len() as f32 / EVENT_RING_CAPACITY as f32) * 100.0;
10421042

1043-
// Sliding window: last 50 events → 49 interarrival deltas (ms).
1043+
// Sliding window: last 50 events → up to 49 interarrival deltas (ms).
1044+
// Filter out idle gaps (>100ms) so the metric reflects burst-mode
1045+
// event processing instead of producer pacing. A wire-time gap of
1046+
// 1.2s between loop iterations isn't "latency" — it's the producer
1047+
// sleeping. Only intra-burst deltas inform the actual processing
1048+
// signal we want to surface.
10441049
let window = 50usize;
10451050
let n = self.events.len().min(window);
10461051
if n >= 2 {
@@ -1051,18 +1056,17 @@ impl AppState {
10511056
.skip(take)
10521057
.map(|e| e.time())
10531058
.collect();
1054-
let mut deltas_ms: Vec<f32> = Vec::with_capacity(times.len().saturating_sub(1));
1055-
for w in times.windows(2) {
1056-
let d = (w[1] - w[0]).max(0.0) * 1000.0;
1057-
deltas_ms.push(d as f32);
1058-
}
1059+
let burst_deltas_ms: Vec<f32> = times
1060+
.windows(2)
1061+
.map(|w| ((w[1] - w[0]).max(0.0) * 1000.0) as f32)
1062+
.filter(|d| *d <= 100.0) // exclude inter-burst idle gaps
1063+
.collect();
10591064

1060-
if !deltas_ms.is_empty() {
1065+
if !burst_deltas_ms.is_empty() {
10611066
let mean_ms: f32 =
1062-
deltas_ms.iter().copied().sum::<f32>() / deltas_ms.len() as f32;
1063-
let avg_capped = mean_ms.min(100.0);
1067+
burst_deltas_ms.iter().copied().sum::<f32>() / burst_deltas_ms.len() as f32;
10641068

1065-
let mut sorted = deltas_ms.clone();
1069+
let mut sorted = burst_deltas_ms.clone();
10661070
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
10671071
let p_idx = |p: f32| -> usize {
10681072
let len = sorted.len();
@@ -1074,7 +1078,10 @@ impl AppState {
10741078

10751079
self.metrics.p95_latency_ms = p95;
10761080
self.metrics.p99_latency_ms = p99;
1077-
self.health.event_loop_ms = avg_capped;
1081+
self.health.event_loop_ms = mean_ms;
1082+
} else {
1083+
// No burst-deltas — events arriving spaced apart. Keep prior
1084+
// values rather than zeroing out (looks more stable).
10781085
}
10791086

10801087
// CPU synthesis: events-per-second normalized against a 100/s
@@ -1100,6 +1107,32 @@ impl AppState {
11001107
if self.health.network_mbps == 0.0 {
11011108
self.health.network_mbps = 23.0;
11021109
}
1110+
1111+
// Spend rate: per-hour extrapolation of session spend. Uptime read
1112+
// off the started_at instant; use a tiny floor (0.001 hr) to avoid
1113+
// divide-by-zero in the first second.
1114+
let uptime_hours =
1115+
(chrono::Utc::now() - self.started_at).num_milliseconds() as f64 / 3_600_000.0;
1116+
let h = uptime_hours.max(0.001);
1117+
self.metrics.spend_rate_per_hour_usd = self.metrics.spent_session_usd / h;
1118+
1119+
// Derive ongoing_tasks from `app.tasks` rather than trusting
1120+
// wire-side runtime.metrics heartbeats — keeps RUNTIME and SYSTEM
1121+
// HEALTH in agreement with the same task-state filter.
1122+
let ongoing = self
1123+
.tasks
1124+
.iter()
1125+
.filter(|t| {
1126+
matches!(
1127+
t.status,
1128+
TaskStatus::Running
1129+
| TaskStatus::WaitingTool
1130+
| TaskStatus::WaitingReview
1131+
| TaskStatus::Queued
1132+
)
1133+
})
1134+
.count() as u32;
1135+
self.runtime_metrics.ongoing_tasks = ongoing;
11031136
}
11041137

11051138
/// Rebuild the insights vector from current state.
@@ -1474,9 +1507,9 @@ impl AppState {
14741507
t.status = TaskStatus::Completed;
14751508
t.updated_at = p.time;
14761509
}
1477-
if self.session.active_task_id.as_deref() == Some(task_id) {
1478-
self.session.active_task_id = None;
1479-
}
1510+
// Don't clear active_task_id — keep the most-recent task
1511+
// visible in the ACTIVE SESSION box. Otherwise the cell
1512+
// bounces between "T-104 …" and "(idle)" every cycle.
14801513
}
14811514
self.runtime_metrics.successful_tasks_lifetime = self
14821515
.runtime_metrics
@@ -1569,8 +1602,16 @@ impl AppState {
15691602
}
15701603
}
15711604

1572-
// Bump per-plugin last_event timestamp so the overview can render
1573-
// a "last seen" column. Done before push_event so the borrow is local.
1605+
// Bump per-plugin last_event AND calls so the overview's PLUGINS
1606+
// table reflects activity. Typed variant handlers above set
1607+
// display_value but don't touch the counters — they rely on this
1608+
// generic block.
1609+
//
1610+
// For Event::Unknown, apply_unknown already bumped calls in its
1611+
// per-plugin branches — double-counting would inflate every counter
1612+
// for unknown-discriminator events. last_event still updates here
1613+
// (apply_unknown's branches don't all set it explicitly).
1614+
let is_unknown = matches!(ev, Event::Unknown(_));
15741615
if let Some(name) = ev.plugin() {
15751616
let needle = name.to_ascii_lowercase();
15761617
let t = ev.time();
@@ -1580,6 +1621,9 @@ impl AppState {
15801621
.find(|p| p.name.to_ascii_lowercase() == needle)
15811622
{
15821623
p.last_event = Some(t);
1624+
if !is_unknown {
1625+
p.calls = p.calls.saturating_add(1);
1626+
}
15831627
}
15841628
}
15851629

@@ -2654,7 +2698,11 @@ mod tests {
26542698
],
26552699
));
26562700
assert_eq!(s.runtime_metrics.open_sessions, 2);
2657-
assert_eq!(s.runtime_metrics.ongoing_tasks, 3);
2701+
// ongoing_tasks is now derived from app.tasks in synthesize_health
2702+
// — wire-side runtime.metrics no longer authoritatively sets it
2703+
// (the wire and the local task tracker were drifting). Empty
2704+
// app.tasks here so the derived count is 0.
2705+
assert_eq!(s.runtime_metrics.ongoing_tasks, 0);
26582706
assert_eq!(s.runtime_metrics.code_written_lifetime_loc, 42800);
26592707
assert_eq!(s.runtime_metrics.tool_calls_lifetime, 9001);
26602708
assert!((s.runtime_metrics.tests_passed_rate - 0.94).abs() < 1e-3);

inspector/src/views/overview.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,10 @@ fn render_system_health(frame: &mut Frame, area: Rect, app: &AppState) {
486486
.filter(|t| {
487487
matches!(
488488
t.status,
489-
TaskStatus::Running | TaskStatus::WaitingTool | TaskStatus::WaitingReview
489+
TaskStatus::Running
490+
| TaskStatus::WaitingTool
491+
| TaskStatus::WaitingReview
492+
| TaskStatus::Queued
490493
)
491494
})
492495
.count();

0 commit comments

Comments
 (0)