Track plugin metrics for background unified exec commands (#38276)

## Why

Unified exec can yield while a command is still running. Plugin measurement
collection must remain active until that background command exits, including
when its item completion arrives after the turn has completed.

## What changed

- Keep the plugin metrics sidecar with the stored process and let either the
  exit watcher or a poll that observes completion finalize it exactly once.
- Retain completed turn analytics state while tool items are pending so late
  command completion events can be emitted without duplicating the turn event.

## Testing

- Verify that a background command completed after its turn emits a command
  execution event and does not emit a second turn event.

GitOrigin-RevId: ecf715b3e047aa29ca9a417d12d955257fed8557
This commit is contained in:
Kyle Brown
2026-08-13 00:04:57 +00:00
committed by copyberry
parent cbb7e82a8b
commit 96e8afbfb8
8 changed files with 158 additions and 8 deletions
@@ -5062,6 +5062,86 @@ async fn turn_event_counts_completed_tool_items() {
assert_eq!(payload["event_params"]["image_generation_count"], json!(1));
}
#[tokio::test]
async fn completed_background_tool_item_emits_after_turn_event() {
let mut reducer = AnalyticsReducer::default();
let mut out = Vec::new();
ingest_turn_prerequisites(
&mut reducer,
&mut out,
/*include_initialize*/ true,
/*include_resolved_config*/ true,
/*include_started*/ true,
/*include_token_usage*/ false,
)
.await;
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted(
ItemStartedNotification {
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
started_at_ms: 998,
item: sample_command_execution_item(
CommandExecutionStatus::InProgress,
/*exit_code*/ None,
/*duration_ms*/ None,
),
},
))),
&mut out,
)
.await;
reducer
.ingest(
AnalyticsFact::Notification(Box::new(sample_turn_completed_notification(
"thread-2",
"turn-2",
AppServerTurnStatus::Completed,
/*codex_error_info*/ None,
))),
&mut out,
)
.await;
assert_eq!(
out.iter()
.filter(|event| matches!(event, TrackEventRequest::TurnEvent(_)))
.count(),
1
);
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted(
ItemCompletedNotification {
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
completed_at_ms: 1_000,
item: sample_command_execution_item(
CommandExecutionStatus::Completed,
Some(0),
Some(1),
),
},
))),
&mut out,
)
.await;
assert_eq!(
out.iter()
.filter(|event| matches!(event, TrackEventRequest::TurnEvent(_)))
.count(),
1
);
assert!(
out.iter()
.any(|event| matches!(event, TrackEventRequest::CommandExecution(_)))
);
}
#[tokio::test]
async fn item_completed_without_turn_state_does_not_create_turn_state() {
let mut reducer = AnalyticsReducer::default();
+25 -1
View File
@@ -424,6 +424,7 @@ struct TurnState {
steer_count: usize,
tool_counts: TurnToolCounts,
resource_skill_invocations: HashSet<String>,
turn_event_emitted: bool,
}
#[derive(Clone, Hash, Eq, PartialEq)]
@@ -1757,6 +1758,14 @@ impl AnalyticsReducer {
);
}
self.item_review_summaries.remove(&key);
if self
.turns
.get(&notification.turn_id)
.is_some_and(|turn_state| turn_state.turn_event_emitted)
&& !self.has_pending_tool_items_for_turn(&notification.turn_id)
{
self.turns.remove(&notification.turn_id);
}
}
ServerNotification::ItemGuardianApprovalReviewStarted(notification) => {
let _ = notification;
@@ -2128,6 +2137,9 @@ impl AnalyticsReducer {
let Some(turn_state) = self.turns.get(turn_id) else {
return;
};
if turn_state.turn_event_emitted {
return;
}
if turn_state.thread_id.is_none()
|| turn_state.num_input_images.is_none()
|| turn_state.resolved_config.is_none()
@@ -2179,7 +2191,19 @@ impl AnalyticsReducer {
input.repo_hash = accepted_line_repo_hash_for_cwd(cwd.as_path()).await;
out.extend(accepted_line_fingerprint_event_requests(input));
}
self.turns.remove(turn_id);
if self.has_pending_tool_items_for_turn(turn_id) {
if let Some(turn_state) = self.turns.get_mut(turn_id) {
turn_state.turn_event_emitted = true;
}
} else {
self.turns.remove(turn_id);
}
}
fn has_pending_tool_items_for_turn(&self, turn_id: &str) -> bool {
self.tool_items_started_at_ms
.keys()
.any(|key| key.turn_id == turn_id)
}
/// Resolve the parent connection lazily when a subagent fact arrives first.