Skip to content

Fix: Plot a scoped agent's own latency on the usage pane - #1296

Merged
esnible merged 2 commits into
rossoctl:mainfrom
esnible:fix/1294-per-agent-latency
Oct 5, 2026
Merged

esnible merged 2 commits into
rossoctl:mainfrom
esnible:fix/1294-per-agent-latency

Conversation

@esnible

@esnible esnible commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Fixes #1294

Problem

When one agent is selected (A), the usage pane's latency metric always shows "latency is not available per agent". The pane narrows the group=agent series client-side, and that narrowing (narrowBucketsToAgent) zeroes latency, because a bucket's mean covers every agent that shared it.

That stopped being necessary with a704825. The aggregator now keeps a ring per recognised agent, and AgentSnapshot answers agent=<name> (with no session) from that ring, latency included. The spend drawer already uses agent=; the usage pane was never switched over.

Change

  • A second read for latency only. When scoped to one agent with no session, fetchUsage also asks /v1/usage?agent=<scope> and graftAgentLatency copies latMeanMs / latStdDevMs / latSamples onto the narrowed buckets.
  • The two reads must cover the same buckets. The server's fold groups minutes from the window's oldest, so at the 1h and 6h windows every bucket's time moves each minute, and a minute turning between the reads leaves none in common. A mismatch reads both again once; if the window moves again, it is reported as a latency error.
  • A failed latency read stays on the latency metric. It travels apart from the window read's error, so the count and cost metrics keep their chart, and the latency metric reports a failed read rather than latency the proxy does not keep. Each read has its own 5s timeout.
  • Why not serve the pane from agent= outright: that answer carries no whole-window ungroupedCostMicros and no window currencies, which the scoped "attributed to no agent" note (costUngroupedRow) needs. So counts, cost and that note stay on the narrowing, unchanged.
  • Answers that are not the agent's own are refused.
    • No agent echo means a server that ignored the parameter and answered for every agent. Grafting it would draw the whole window's response times under one agent's name.
    • An echo with requests but no latency sample means a v0.8.x server, which applied agent= by narrowing.
  • The unavailable message stays, and names its reason. It now appears only for Other, for an agent within a session, and for a proxy without per-agent rings.
  • Comments and docs: the stale "no per-agent latency on the wire" claim is corrected in core/cost/usage/scope.go and cmd/agentop/README.md.

[b] under a scope is unchanged: the per-agent ring could honour group, but that is a separate feature.

Testing

  • New cmd/agentop/tui/usage_agent_latency_test.go covers:
    • the graft, including two encodings of the same bucket instant;
    • a 5m-resolution window whose reads land a minute apart, which is reread, and one that keeps moving, which is reported;
    • a failed latency read, which leaves the count metrics intact;
    • refusal for a server that ignored agent= and for one that narrowed;
    • no agent= read for Other or within a session;
    • the plotted chart and its LATENCY summary cell;
    • each wording of the unavailable message.
  • Removing the agent-echo check fails the "server ignored agent=" case; removing the reread fails the moved-window case.
  • TestFetchUsage_ScopedAsksForTheAgentAxis now checks the narrowing read's query, since the latency read now comes after it.
  • go vet, gofmt -l, cmd/agentop/... and core/cost/usage all pass.
    • Exception: TestRunExec_BeforeFirstStartRunsAndSaysWhatIsLost fails locally only when SSL_CERT_FILE is set in the shell, and passes with it unset. It is unrelated to this change.
  • Ran fetchUsage against a live laptop proxy:
    • Scoped to claude-code: agentLatency=true at all three windows (10m, 1h, 6h).
    • Other: none, as expected.

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

Under an agent scope the usage pane's latency metric always said
"latency is not available per agent" (rossoctl#1294). The pane narrows the
group=agent series client-side, and that narrowing zeroes latency: a
bucket's mean covers every agent that shared it.

Since a704825 the server keeps a ring per recognised agent and answers
agent=<name> from it, latency included. The pane now makes that read
too, when scoped to one agent with no session, and copies only the
latency onto its narrowed buckets, matched by bucket time. Counts, cost,
the whole-window residual note and the window's units still come from
the narrowing, since the agent= answer carries neither of the last two.

The latency is not taken from a server that ignored agent= (it answered
for every agent), nor from one that applied it by narrowing (v0.8.x:
requests, no samples). There, and under Other or within a session, the
pane still says latency is unavailable, and now names which.

Fixes rossoctl#1294

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Ed Snible <snible@us.ibm.com>
@esnible
esnible requested a review from a team as a code owner October 5, 2026 19:19
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 6 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: d2056bdd-6b92-4e0c-9bd8-db59b1fd95ff
📥 Commits

Reviewing files that changed from the base of the PR and between efca3a3 and e6e8adc.

📒 Files selected for processing (6)
  • cmd/agentop/README.md
  • cmd/agentop/tui/agents_scope_test.go
  • cmd/agentop/tui/app.go
  • cmd/agentop/tui/usage_agent_latency_test.go
  • cmd/agentop/tui/usage_pane.go
  • core/cost/usage/scope.go
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Review follow-up on rossoctl#1296.

- The two reads must cover the same buckets. The server's fold groups
  minutes from the window's oldest, so at 1h and 6h every bucket's time
  moves each minute, and a minute turning between the reads left no
  bucket time in common: the graft copied nothing, still reported
  success, and the pane said "no latency samples in this window" until
  the next poll. The reads are now compared bucket for bucket; a
  mismatch reads both again once, and is reported as a latency error if
  the window moves again.
- A failed latency read no longer takes down the pane. It travels apart
  from the window read's error and is shown only on the latency metric,
  as a failed read rather than as latency the proxy does not keep. Each
  read has its own 5s timeout.
- README: the server keeps per-agent latency only for a recognised agent
  across all sessions; "both say so" no longer fits.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Ed Snible <snible@us.ibm.com>

@esnible esnible left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Summary

Re-reviewed at e6e8adc. The new commit fixes all three issues from the first pass:

  • Window moving between the two reads: graftAgentLatency now requires both reads to cover identical bucket times. When they don't, it reports errUsageWindowMoved, and fetchUsage reads both again, once.
  • A failed latency read blanking the pane: the failure now goes on latencyErr and shows only on the latency metric, and each read gets its own usageReadTimeout.
  • README: the paragraph now gives the right reason.

What I checked:

  • The cmd/agentop/tui suite passes locally, including the three new tests. go vet and gofmt -l are clean.
  • Making sameBucketTimes compare only lengths fails both new reread tests, so they guard the fix.
  • The per-agent rings are allocated at the same NumBuckets with the same window clamp, and an absent ring still emits zeroed buckets at the right times. So the strict match fails only when a minute really did turn.

One nit inline; otherwise this is ready.

Author: esnible (MEMBER — maintainer)
Areas reviewed: Go (agentop TUI, core/cost/usage), Docs
Agent/IDE config (.claude/.vscode): none
Commits: 2 commits, all signed-off: yes
CI status: pending on e6e8adc at review time

Comment on lines +244 to +245
// them not. The server's fold groups minutes from the window's oldest, so at the 1h and 6h
// resolutions every bucket's time moves each minute and none would line up. That is

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

nit: 1h and 6h are the windows; their resolutions are 5m and 30m. Maybe "so at the 1h and 6h windows every bucket's time moves each minute".

@rubambiza rubambiza added the ready-for-ai-review Request automated AI code review from clawgenti label Oct 5, 2026

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

Grafts the scoped agent's own latency onto the narrowed usage buckets from a second agent= read, refusing answers that are not the agent's own and rereading both once when the window moves between the reads; a failed latency read stays off the other metrics. The graft, both refusals, the reread paths, and the unavailable-note wordings are all covered by tests — one nit inline, nothing blocking.

  • nit — cmd/agentop/tui/usage_pane.go:244: "at the 1h and 6h resolutions" — 1h and 6h are the windows; their resolutions are 5m and 30m.

Reviewed by clawgenti using the github-pr-review skill

// come from.
//
// THE TWO READS MUST COVER THE SAME BUCKETS, and a minute turning between them is enough to make
// them not. The server's fold groups minutes from the window's oldest, so at the 1h and 6h

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.

nit: "at the 1h and 6h resolutions" — 1h and 6h are the windows; their resolutions are 5m and 30m. Perhaps "so at the 1h and 6h windows every bucket's time moves each minute", as the PR description has it.

@mrsabath mrsabath left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A real fix for #1294: a scoped agent's latency was reported unavailable because ScopeToAgent zeroes it, when the server does keep per-agent response times in its own ring. The fix reads them from a second agent= call and grafts only the latency.

The CI picture, because the check list looks alarming

Eight checks show fail with 19m–64m durations. None is a real failure. Every one is cancelled with an empty steps array, an empty runner_name, and started_at == created_at, and the run annotation reads "The job was not acquired by Runner of type hosted even after multiple attempts". GitHub never assigned a hosted runner — the long durations are pure queue wait, then reaping. The incident hit both ubuntu-latest and macos-latest across runs 37366607521 and 37366608488, and it spread to three more jobs while I was reviewing (Go CI (authbridge cortex), Shell Script Lint, proxy-init iptables rules were pending at the start and have since been reaped the same way).

What matters: the two jobs that cover this PR's code both passed — Go CI (authbridge agentop) (2m35s) and Go CI (core) (1m19s) — as did every job that got a runner at all (both CodeQL, Trivy, Bandit, Dependency Review, Action Pinning). This PR touches only cmd/agentop/** and core/cost/usage/scope.go; nothing it changes is in scope for Hadolint, YAML Lint, Shell Script Lint or proxy-init in the first place. gh run rerun --failed on both run IDs should clear it.

What I verified

The echo guard is load-bearing, and the whole chain checks out. graftAgentLatency refuses when own.Agent != scope. That is not defensive boilerplate: GetUsageWindowForAgent's own doc notes that a server predating agent= ignores it and leaves Snapshot.Agent empty, so without the check an old proxy would hand back every agent's latency to be plotted under one agent's name — exactly the failure the narrowing exists to prevent. On the server side /v1/usage?agent= routes through sessionapi.ringSnapshot → usage.AgentSnapshot (scope.go:179), and all three producers (AgentSnapshot, NarrowToAgent, the ledger path) set Snapshot.Agent. So the guard passes on every real path and fails only in the old-server case it was written for.

sameBucketTimes is correct for a subtle reason. It compares with .Equal rather than == because two times decoded from the same non-local offset carry distinct *time.Location pointers and compare unequal under == however equal the instants are. The fixture deliberately writes one instant two ways (2026-09-29T10:00:00Z against 2026-09-29T06:00:00-04:00) to prove the graft matches on the instant rather than the representation. Weakening sameBucketTimes to compare only lengths fails both reread tests, which is the right negative control.

The reread is bounded and correctly prioritised. On errUsageWindowMoved it rereads both once, and keeps the second pass only if again.err == nil — so a latency problem cannot take down the counts and cost the first read already answered. Each read gets its own usageReadTimeout, so a slow window read cannot spend the latency read's budget.

The three unavailable-reason branches earn their keep. Separating Other / within-a-session / old-proxy matters because the remedies differ — the first two are views to leave, the third is a proxy to upgrade — and a failed read gets its own line rather than being miscategorised as "not available per agent". Eight new tests cover the graft, both refusals, both reread paths, each wording, and the failure isolation. No t.Skip, no secrets, no new dependencies.

One nit inline, the same one @clawgenti and your own review already flagged.

A correction to my own process, for the record: I initially suspected the new AgentSnapshot reference in scope.go's comment was stale, because my first fetch of that file did not surface the symbol. It exists, fifteen lines below the comment that names it. The reference is accurate.

Areas reviewed: Go (agentop TUI, core/cost/usage), docs, tests, CI failure triage, security
Commits: 2, both signed off, imperative subjects at 56/72 chars
CI: 8 checks failed to hosted-runner starvation with zero steps executed; the agentop and core Go jobs covering this code passed

One disclosure on method: I did not run the suite — no clone of this repo on my side — so the execution evidence is the passing Go CI (authbridge agentop) job plus your reported local run. I verified the logic, the client/server agent= chain, and the CI failure classification directly against source and the Actions API at e6e8adc.

Nothing blocking. LGTM.

// come from.
//
// THE TWO READS MUST COVER THE SAME BUCKETS, and a minute turning between them is enough to make
// them not. The server's fold groups minutes from the window's oldest, so at the 1h and 6h

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit, and not an original one — @clawgenti flagged it and so did your own review, so this is just the third vote for fixing it while you are in here.

"at the 1h and 6h resolutions": 1h and 6h are windows, not resolutions. usageState.window() returns (window, resolution) as two distinct values from the usageWindows table, so the two are explicitly different concepts in this very file.

The reasoning the sentence carries is right and worth keeping — it is the justification for why a strict bucket-time match is necessary rather than paranoid, since at those windows the fold regroups from the window's oldest minute and every bucket time shifts each minute. Only the noun is wrong. "at the 1h and 6h windows" would do it.

@esnible
esnible merged commit 535b451 into rossoctl:main Oct 5, 2026
19 of 28 checks passed
@esnible
esnible deleted the fix/1294-per-agent-latency branch October 5, 2026 22:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-ai-review Request automated AI code review from clawgenti

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

latency usage graphs not available per-agent

4 participants