Skip to content

agentHost: add generic agent turn hang telemetry - #329884

Merged
roblourens merged 2 commits into
mainfrom
roblou/agent-turn-hang-telemetry
Aug 10, 2026
Merged

agentHost: add generic agent turn hang telemetry#329884
roblourens merged 2 commits into
mainfrom
roblou/agent-turn-hang-telemetry

Conversation

@roblourens

@roblourens roblourens commented Aug 10, 2026

Copy link
Copy Markdown
Member

Why

Today a hung agent turn is only detectable as the absence of telemetry — an agentHost.userMessageSent with no matching agentHost.turnCompleted. Absences don't show up on dashboards, which is why several session-hang bugs shipped undetected. This adds a positive signal.

The motivating case: a session config change caused the Agent Host to destroy its Copilot SDK session and immediately resume the same session ID; a runtime race then dropped the resumed session's native callback registration. session.send() returned normally, but no user.message was persisted, no model turn ever started, and no error or terminal event was ever emitted. turnStarted fired; turnCompleted never did. The UI sat on "Working…" forever. This change surfaces exactly that as hangReason: 'noProgress' / hadAnyProgress: false.

What

AgentHostTurnTracker arms a disposableTimeout watchdog per in-flight turn, debounced by activity — any turn-scoped protocol action re-arms it. On expiry it classifies the quiet state and emits agentHost.turnHung.

The threshold is 5 * 60 * 1000, matching the existing TOOL_CALL_STALL_THRESHOLD_MS so the two hang signals line up on dashboards.

Expected vs unexpected

Rather than excluding legitimate waits, all four states are reported and tagged, with a derived isExpected boolean so queries can split the populations with a single predicate. Silence is only interpretable with the reason attached.

hangReason isExpected meaning
noProgress false turn started, zero activity ever observed, never completed — the lost-turn signature above
stalledAfterProgress false streamed, then went silent with nothing outstanding to explain it
waitingOnUser true a request that blocks on a human is outstanding — confirmation, tool auth, or elicitation
runningTool true a tool call is in flight; covers long builds, client-executed tools, and subagents (whose progress is reported on their own chat channel)

This is documented on the AgentHostTurnHangReason type and in _deriveHangReason. A blocker outranks an in-flight tool call, since a tool awaiting confirmation is both and the human is the real reason for the silence.

Client tool execution is deliberately not a user blocker. The protocol defines SessionInputRequestKind.ToolClientExecution as delegated running work rather than a prompt, and the session reducer's awaitsUser predicate excludes it for the same reason; counting it would report every long-running client tool as waiting on a human. It is represented by the in-flight tool set instead, yielding runningTool.

Bounded volume

Each reason fires at most once per turn, and the watchdog re-arms for at most 6 quiet windows. Re-arming exists so a turn that moves from waitingOnUser to genuinely stuck still gets reported; the cap keeps live timers and event volume bounded for a permanently dead turn.

Recovery event

agentHost.hungTurnCompleted fires when a previously-hung turn later completes, carrying hangReportCount, totalTimeMs and timeAfterHangMs — so permanent hangs can be separated from merely slow ones. Modelled on the existing stalledToolCallCompleted.

Leak fix

_turnTimings previously kept entries for turns that never completed, forever. Turn tracking is now also cleared on channel teardown (clearChannelTelemetry, renamed from clearToolCallTelemetry since it now clears both trackers) and on truncation, closing that gap for the whole class of lost-turn bugs.

Decision: kept toolCallStalled alongside, not subsumed

agentHost.toolCallStalled carries tool identity (toolId, toolSourceKind) and only covers tool-specific blockers; the new event is turn-scoped. Subsuming it would mean either losing that tool detail or bloating the generic event with fields that are null for most hangs — and it would break existing queries. Keeping both also keeps this diff smaller and lower-risk.

Reviewer notes

A review pass caught four issues; three are fixed here, one is deliberately left alone:

  1. turnUnblocked didn't restart the quiet clock — a user answering a confirmation at 4:59 would have been misreported as an unexplained stall one second later. Both blocking and unblocking now reset the quiet period and re-arm.
  2. Synthesized ChatToolCallReady bypasses _dispatchActionForSession, so it never reached the watchdog. Now fed explicitly.
  3. Truncation left ghost turns whose watchdogs could later report a hang for a turn no longer in state. Now cleared via clearTurnsExcept.
  4. Client tool execution was misclassified as waitingOnUser (review feedback) — now excluded from user blockers, matching the protocol's awaitsUser semantics.
  5. A denied tool confirmation leaked its in-flight entry (review feedback) — a denial is terminal and no completion follows, so a later hang reported runningTool for a tool that would never run. Cleared on both the client and agent dispatch paths.
  6. Not addressed (pre-existing): subagent turns never call turnTracker.turnStarted, so the existing turnCompleted(subagent.chatUri, …) calls are already dead code today. Wiring that up would start emitting agentHost.turnCompleted for subagent turns and visibly shift turn-count volume on existing dashboards — out of scope here. Subagent hangs are still caught via runningTool on the parent turn.

Activity is gated to turn-scoped actions (hasKey(action, { turnId: true })). Session-scoped actions like MCP server state changes can arrive while a turn is genuinely stuck and would otherwise mask the hang.

Testing

11 new tests in agentHostTurnHangTelemetry.test.ts using runWithFakedTimers, driven through AgentSideEffects so the real wiring is exercised rather than the tracker in isolation:

  • fires noProgress after the threshold for a turn that starts and is never heard from again (the motivating case)
  • fires stalledAfterProgress after streaming stops
  • does not fire across 10 windows of elapsed time while deltas keep arriving (debounce)
  • tags a confirmation-blocked turn waitingOnUser, and a silent long-running tool runningTool
  • keeps watching after the user answers, then reports the real stall
  • paired recovery event; no recovery event for a turn that never hung
  • no reports after cancellation, session teardown, or truncation (timer disposal)

npm run typecheck-client clean, eslint clean, hygiene clean, and all 4404 src/vs/platform/agentHost tests pass — including the two existing telemetry suites, which are unmodified.

(Written by Copilot)

A hung agent turn was previously only detectable as the *absence* of
`agentHost.turnCompleted` after `agentHost.userMessageSent`, which does
not surface on dashboards. Add a positive signal.

`AgentHostTurnTracker` now arms a `disposableTimeout` watchdog per
in-flight turn, debounced by any turn-scoped activity. On expiry it
classifies the quiet state and reports `agentHost.turnHung`:

  unexpected: `noProgress` (nothing was ever observed for the turn - the
              signature of a lost turn), `stalledAfterProgress`
  expected:   `waitingOnUser` (an input request is outstanding),
              `runningTool` (a tool call is in flight, which also covers
              subagents)

Each reason is reported at most once per turn and the watchdog re-arms
for at most six quiet windows, so a permanently dead turn cannot produce
unbounded events or timers. A paired `agentHost.hungTurnCompleted` fires
when a previously hung turn later completes, so permanent hangs can be
separated from merely slow ones.

`AgentSideEffects` feeds the watchdog: turn-scoped protocol actions as
activity, tool call start/complete as in-flight state, and session input
requests as blockers. Turn tracking is also cleared on channel teardown
and on truncation, closing a pre-existing leak where entries for turns
that never completed lived forever.

The existing `agentHost.toolCallStalled` telemetry is kept as-is: it is a
more specialized signal (it carries tool identity and only covers tool
blockers), and keeping it avoids breaking existing queries.

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings August 10, 2026 03:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds positive telemetry for agent turns that become inactive, including hang classification and recovery reporting.

Changes:

  • Adds debounced per-turn hang watchdogs and cleanup.
  • Adds hang/recovery telemetry events and wiring.
  • Adds integration coverage for hang scenarios.
Show a summary per file
File Description
agentHostTurnHangTelemetry.test.ts Tests watchdog behavior and cleanup.
agentSideEffects.ts Wires activity, blockers, tools, and teardown.
agentService.ts Uses expanded telemetry cleanup.
agentHostTurnTracker.ts Implements hang tracking and classification.
agentHostTelemetryReporter.ts Defines and emits new telemetry events.

Review details

Tip

Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread src/vs/platform/agentHost/node/agentHostTurnTracker.ts
Comment thread src/vs/platform/agentHost/node/agentHostTurnTracker.ts
Two fixes from PR review, both cases where the watchdog would misstate
what a turn was waiting for.

Client tool execution is not a wait on the user. The protocol defines
`SessionInputRequestKind.ToolClientExecution` as delegated running work
rather than a prompt — the call has already cleared its confirmation
gate and is simply executing elsewhere — and the session reducer's
`awaitsUser` predicate excludes it for exactly this reason. Recording it
as a user blocker classified every long-running client tool as
`waitingOnUser` with `blockedOn: toolClientExecution`. Outstanding
requests are still tracked for bookkeeping, but only user-blocking kinds
drive `waitingOnUser` and `blockedOn`; client execution is represented by
the in-flight tool set and so reports `runningTool`. That predicate is
mirrored rather than imported because the reducer file is generated.

A denied tool confirmation is terminal. The reducer moves the call to
`cancelled` and ignores any later completion for it, so the tool call id
stayed in the turn's in-flight set forever and a subsequent hang was
reported as an expected `runningTool` instead of `stalledAfterProgress`.
Denials now clear the entry on both the client (`handleAction`) and
agent (`_dispatchActionForSession`) paths, since the action arrives on
both.

Both new tests were verified to fail against the pre-fix code.

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@roblourens
roblourens marked this pull request as ready for review August 10, 2026 04:26
@roblourens
roblourens enabled auto-merge (squash) August 10, 2026 04:26
@roblourens
roblourens merged commit 7172dbd into main Aug 10, 2026
29 checks passed
@roblourens
roblourens deleted the roblou/agent-turn-hang-telemetry branch August 10, 2026 04:49
@vs-code-engineering vs-code-engineering Bot added this to the 1.133.0 milestone Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants