Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion cmd/abctl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1150,7 +1150,9 @@ Layered on top of all of them:
| `C` | any session-view pane (not the picker) | open the registered-plugin catalog. Was `P` until the pipeline took that letter |
| `r` | catalog | refresh the catalog from `/v1/plugins` |
| `A` | any session-view pane (not the picker) | open the per-agent cost breakdown — what each coding agent has spent today. Capital `A` because lowercase `a` cycles the spend drawer's axis. Refetches on every press, then **refuses below two agents** and says which one it found: a one-row breakdown restates a total already on screen. Not in the footer for that reason; the `?` overlay names it |
| `Esc` | agents | back to the pane `A` was pressed on |
| `↑↓` / `jk` | agents | move the cursor |
| `↵` | agents | scope the usage pane to the agent under the cursor, and leave. Pressing it again **on the agent already scoped clears the scope** — there is no "all agents" row, so one key goes both ways and the footer's label flips to say which. The usage pane is the only surface that honours it: sessions and events carry no agent, the spend band and its drawer fetch separately and keep showing every agent, and `abctl cost --agent` is a separate process |
| `Esc` | agents | back to the pane `A` was pressed on, leaving the scope as it is |
| `e` | pipeline | edit pipeline subtree in `$EDITOR` |
| `y` | edit/diff | apply the edit |
| `N` | edit/diff | abort the edit |
Expand All @@ -1159,6 +1161,19 @@ Layered on top of all of them:
| `Esc` | edit/{waiting,rollback} | background the watch; result lands as a footer flash |
| `q` / `Ctrl+C` | any | quit (closes the key-help overlay first, if open) |

**The picker also opens itself at startup**, once per connection, when two or more
agents have been seen in the window — the same two-agent rule `A` applies, so a
one-agent proxy goes straight to the sessions pane and says nothing. Nothing is
remembered between runs: a second agent appearing is exactly when the picker
becomes worth showing, so a remembered dismissal would go stale then.

While a scope is active, two things on the usage pane change and both say so:
`[b]` disappears from the footer, because the scope needs `group=agent` on the
wire and there is no second axis left to break down by; and the latency metric
reports that it is unavailable per agent rather than plotting zeroes. Response
times are recorded per bucket across every agent that shared it, so
`/v1/usage` carries nothing that could attribute them to one.

## Settings

abctl remembers the events-table column selection, the sort order, and the active
Expand Down
96 changes: 8 additions & 88 deletions cmd/abctl/cmd_cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,12 @@ Flags:
}

if *agent != "" {
scoped, err := scopeToAgent(snap, *agent)
// KeepBuckets, not NarrowBuckets: on this path the command prints window totals, so it
// needs no per-bucket narrowing and pays for none. (Under --by it does read buckets —
// hence "on this path" rather than a claim about the command.) abctl's usage pane passes the other value
// because it renders a chart from the buckets themselves. See usage.BucketScope for why
// this is a parameter rather than a default.
scoped, err := usage.ScopeToAgent(snap, *agent, usage.KeepBuckets)
if err != nil {
fmt.Fprintf(stderr, "abctl cost: %v\n", err)
return 1
Expand All @@ -224,91 +229,6 @@ Flags:
return 0
}

// scopeToAgent narrows a group=agent snapshot to one agent's figures.
//
// IT REWRITES Totals AND HANDS BACK A SNAPSHOT, rather than rendering the agent itself, so the
// writers apply to it directly with no second implementation and no chance of the two drifting:
// the negative-total refusal, the coverage-gap disclosure and the incomplete-read admission each
// read one agent's numbers. Which fields do NOT survive the narrowing, and why, is stated at the
// narrowing itself below. A COPY, never the caller's snapshot mutated in place.
//
// The fold is usage.FoldSeriesAcrossWindow, the same one abctl's AGENTS pane uses, so the
// figure printed here and the row shown there cannot disagree.
//
// AN UNKNOWN AGENT IS AN ERROR THAT NAMES THE KNOWN ONES. The labels are User-Agents, so they
// are neither short nor guessable — "bob" is the obvious thing to try and is not what Bob
// sends. The set is already in hand, so withholding it would be a choice.
func scopeToAgent(snap *usage.Snapshot, agent string) (*usage.Snapshot, error) {
series := usage.FoldSeriesAcrossWindow(snap.Buckets)
counts, ok := series[agent]
if !ok {
known := make([]string, 0, len(series))
for label := range series {
known = append(known, label)
}
// Sorted so the same window reports the same order every run; a set printed in map
// order is a set a reader cannot diff against yesterday's.
sort.Strings(known)
if len(known) == 0 {
return nil, fmt.Errorf("no agent traffic in the %s window, so --agent %q matches nothing",
snap.Window, agent)
}
return nil, fmt.Errorf("no agent %q in the %s window; seen: %s",
agent, snap.Window, strings.Join(known, ", "))
}
scoped := *snap
scoped.Totals = counts
// EVERY WHOLE-WINDOW STATEMENT ABOUT WHERE THE TOTALS CAME FROM GOES WITH Totals, or it is
// printed beside one agent's figure while describing all of them. Replacing only Totals left
// `--agent <an agent nothing priced>` printing $0.00 — the window was priced, just not this
// agent's traffic — for exactly the agent the AGENTS pane prints "—" for, which breaks both
// writeCostSummary's "cost unavailable rather than $0.00" rule and this function's own claim
// that the figure here and the row there cannot disagree.
//
// Priced is RE-DERIVED with the producers' own rule rather than one invented here: both
// snapshot.go and sessionapi set it to Totals.PricedRequests > 0, so the narrowed snapshot is
// the one they would have emitted had this agent's traffic been the whole window.
scoped.Priced = counts.PricedRequests > 0
// The three by-model maps are DROPPED, not narrowed, because nothing here can narrow them: a
// bucket's series is keyed by agent and carries no per-model breakdown, so the only available
// readings are the window's maps — which describe other agents' traffic — or none. They are
// omitempty on the wire, and costIncompleteReasonLines already treats an absent map as
// nothing to say, which is its common case for a ledger-backed window anyway.
scoped.PricedBy = nil
scoped.UnpricedBy = nil
scoped.IncompleteBy = nil
// Degraded and DaysOutsideRetention STAY, and the asymmetry is the point: they describe the
// READ and the retention configuration, which are the same facts whichever agent is scoped
// to. Dropping them would hide a short sum behind a narrower question.
//
// SeriesOvershootMicros and SeriesAvoidedOvershootMicros stay too, and they are the two the
// "every" above has to account for rather than pass over. Both are defect reports about a
// breakdown — the series summed to MORE than the total — so they belong with Degraded rather
// than with the provenance maps. A correct producer never sends either on this path:
// residualOf leaves them nil unless the series overshoots, which cannot happen where the
// figures reconcile. Where one does arrive it is upstream's bug, and forwarding it says so;
// narrowing it to an agent would be inventing a per-agent overshoot nothing computed.

// Currencies IS CARRIED OVER, AND IT NO LONGER DESCRIBES Totals. Said out loud because it is
// the one field on this struct that the narrowing above invalidates, and the honest options are
// worse than keeping it.
//
// The field means "every unit the rows behind Totals were denominated in", and after this copy
// Totals is one agent while the list is the whole window. Narrowing it is not available:
// deciding which units THIS agent's traffic carries needs a cross-tabulation of agent against
// currency, and a folded per-agent Counts has already summed that axis away. Dropping it is
// worse than leaving it — this agent's own traffic may well be the mixed part, and an absent
// list reads as "single unit", so the surface would print a confident figure that is exactly
// the credits-plus-dollars sum this whole change exists to refuse.
//
// SO THE OVER-REFUSAL IS DELIBERATE, and it is the safe direction: a per-agent figure is
// withheld in a mixed window even when that agent billed in one unit. writeCostSummary says
// which of the two it is rather than letting the reader assume, because "no figure for this
// agent" and "no figure for this window" have different fixes.

return &scoped, nil
}

// costJSON is the --json shape: the window actually served plus the totals
// verbatim, the three maps that say where the totals came from, what they miss and which
// way any inexact figure in them is inexact, and the ledger's own admission when the read
Expand Down Expand Up @@ -375,7 +295,7 @@ type costJSON struct {
//
// EXCEPT UNDER --agent, WHERE IT DESCRIBES THE WINDOW AND Totals DESCRIBES ONE AGENT. The
// sentence above is the whole truth on every other path; on that one the two fields have
// different subjects and no third field says so. scopeToAgent explains why it cannot be
// different subjects and no third field says so. usage.ScopeToAgent explains why it cannot be
// narrowed — deciding one agent's units needs a cross-tabulation a folded Counts has already
// summed away — and the human surface prints a line saying whose mixture it is. This one does
// not, deliberately: the discrepancy is in the OVER-refusing direction, so a script that
Expand Down Expand Up @@ -717,7 +637,7 @@ func writeCostSummary(snap *usage.Snapshot, stdout io.Writer, agent string) {
strings.Join(snap.Currencies, " and "))
// WHOSE MIXTURE IT IS, on the --agent path. The list describes the WINDOW; Totals here
// describes one agent, and no client-side arithmetic can narrow the first to the second —
// see scopeToAgent for why the field is carried over anyway. Without this line a reader who
// see usage.ScopeToAgent for why the field is carried over anyway. Without this line a reader who
// asked about one agent reads the refusal as a statement about that agent's own traffic and
// goes looking for a second gateway it may never have called.
if agent != "" {
Expand Down
2 changes: 1 addition & 1 deletion cmd/abctl/cmd_cost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2582,7 +2582,7 @@ func TestRunCost_JSONOmitsCurrenciesWhenTheProducerDoesNotReportThem(t *testing.

// --agent on a mixed window says WHOSE mixture it is.
//
// scopeToAgent narrows Totals to one agent and carries Currencies over from the whole window, and
// usage.ScopeToAgent narrows Totals to one agent and carries Currencies over from the whole window, and
// no client-side arithmetic can narrow the second — a folded per-agent Counts has summed the
// currency axis away. So the refusal stands, deliberately over-refusing, and this line is what
// stops a reader taking it as a statement about the agent they asked about.
Expand Down
113 changes: 103 additions & 10 deletions cmd/abctl/tui/agents_pane.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,32 +121,51 @@ const agentsFetchTimeout = 5 * time.Second
// `abctl cost` documents.
const agentsWindow = usage.WindowToday

// agentsOpen says what the reply to a rows fetch is allowed to do with them.
//
// AN ENUM RATHER THAN A BOOL because there are three answers, not two, and the third differs
// from the second only in whether it may speak. `A` is owed an answer either way — a key that
// appears to do nothing is the defect agentsPaneRefusal exists to prevent. The startup gate is
// owed the opposite: nobody asked for it, so it enters or it stays quiet.
type agentsOpen int

const (
// agentsOpenNever is a background refresh: update the rows, enter nothing, say nothing.
agentsOpenNever agentsOpen = iota
// agentsOpenOnPress is an `A` press. It enters, or it flashes the reason it will not.
agentsOpenOnPress
// agentsOpenAtStartup is the gate run once per connection. It enters when
// agentsPaneApplies, and otherwise does nothing AND says nothing — see
// startupAgentsGateCmd.
agentsOpenAtStartup
)

// agentRowsLoadedMsg carries a fetched per-agent breakdown back to Update.
//
// NOT agentsLoadedMsg, which is TAKEN — by the Kubernetes namespace picker, whose
// Lister.ListAgents lists agent WORKLOADS. Same word, unrelated meaning; see paneAgents.
type agentRowsLoadedMsg struct {
rows []agentRow
err error
// open records that the `A` key asked for this, so the reply may enter the pane. A
// background refresh sets it false and only updates the table, which is why this is a
// field rather than inferred from the current pane: by the time a reply lands the reader
// may have moved.
open bool
// open records who asked, so the reply knows whether it may enter the pane and whether it
// may complain. A field rather than something inferred from the current pane: by the time a
// reply lands the reader may have moved.
open agentsOpen
// from is the pane the `A` press came from, captured AT PRESS TIME and carried here for
// exactly the reason the field above gives: by the time this reply lands the reader may have
// moved, so reading m.pane then records a caller the press never had. Only meaningful with
// open:true; a background refresh leaves it paneNone and enters nothing.
// moved, so reading m.pane then records a caller the press never had. Meaningless under
// agentsOpenNever, which enters nothing, and unread on the startup path — see that arm in
// Update for why it does not consult this field.
from paneID
}

// fetchAgentRowsCmd requests the per-agent breakdown off the render loop.
//
// group=agent AND NO AGENT FILTER, because /v1/usage has none: it reads window, resolution,
// group and session, and session is its only scoping parameter. The per-agent split therefore
// arrives as Bucket.Series and is folded here. That limit is also why this pane is read-only —
// there is no server-side agent scope to apply to any other pane.
func (m *model) fetchAgentRowsCmd(open bool, from paneID) tea.Cmd {
// arrives as Bucket.Series and is folded here, and the scope the pane sets is applied to the
// fetched snapshot rather than requested — see usage.ScopeToAgent.
func (m *model) fetchAgentRowsCmd(open agentsOpen, from paneID) tea.Cmd {
if m.client == nil {
return nil
}
Expand Down Expand Up @@ -181,6 +200,35 @@ func agentsColumns() []table.Column {
}
}

// startupAgentsGateCmd asks, once per connection, whether this proxy has enough agents on it to
// be worth a picker.
//
// ONE FETCH FROM initSessionView, which is the single place every entry point converges on:
// `--endpoint` mode's Init, the pod picker's portForwardReadyMsg, and `[l]`'s local endpoint all
// call it, and each replaces m.client first. Hooking it there rather than in Init is what makes
// the gate run again when the operator backs out to the pod picker and enters a DIFFERENT pod —
// a different proxy has different agents on it, and the answer from the previous one is not an
// answer about this one. The spend strip's chain is started from the same place for the same
// reason.
//
// NO MEMORY OF PREVIOUS ANSWERS, deliberately: the decision is a pure function of what the
// window currently shows. The Namespaces → Pods picker remembers nothing either, and a
// remembered dismissal would go stale exactly when it mattered — the moment a second agent
// appears is the moment the picker becomes worth showing.
//
// THE FIRST FRAME IS NOT BLOCKED. This returns a tea.Cmd like every other fetch, so the sessions
// pane paints and streams while the answer is in flight; entering AGENTS is something that
// happens a beat later, if it happens. A gate that waited would add its own latency to every
// startup, including the majority that it declines.
func (m *model) startupAgentsGateCmd() tea.Cmd {
// paneNone: this gate has no caller pane. It interrupts the sessions view before the
// operator has pressed anything, so there is no press-time pane to record — and the esc
// arm's paneNone fallback already lands on Sessions, which that arm documents as the one
// pane always defensible to land on. The reply handler does not read this field on the
// startup path at all; see the agentsOpenAtStartup case in Update for why not.
return m.fetchAgentRowsCmd(agentsOpenAtStartup, paneNone)
}

// newAgentsTable builds an empty per-agent breakdown table.
func newAgentsTable() table.Model {
t := table.New(
Expand Down Expand Up @@ -249,3 +297,48 @@ func (m *model) enterAgentsOrRefuse(from paneID) (entered bool, refusal string)
m.rebuildAgentsTable()
return true, ""
}

// selectedAgentLabel is the label of the row under the cursor, or "" when there is none.
//
// READ OFF m.agents BY CURSOR INDEX, not out of the rendered table cell: the cell is passed
// through sanitizeLabel, which is a display transform — a control character or a long label
// arrives on the wire and leaves that function altered, so scoping to what the cell says could
// scope to a string no agent ever sent. The two are kept in step by rebuildAgentsTable, which
// builds the rows from m.agents in order.
func (m *model) selectedAgentLabel() string {
i := m.agentsTbl.Cursor()
if i < 0 || i >= len(m.agents) {
return ""
}
return m.agents[i].label
}

// leaveAgentsPane returns to whichever pane opened the AGENTS pane.
//
// ONE EXIT FOR BOTH KEYS — esc backs out, Enter picks an agent and then backs out — so the two
// cannot drift on where the pane returns to. A key-opened surface owes its caller a way back;
// without an exit at all this pane was a dead end reachable only by `q`.
//
// THE FALLBACK IS SESSIONS, and for paneCatalog's stated reason rather than by imitation:
// Sessions is the one pane that is always a defensible place to land, while the enum's zero
// value is the Kubernetes namespace picker, which would look like the connection had gone away.
// The startup gate leans on this fallback deliberately — it records paneNone because it has no
// caller pane at all.
//
// RETURNING INTO USAGE RESTARTS ITS POLLING CHAIN. This pane holds no ticker of its own, but the
// usage pane's tick was dropped by its `m.pane != paneUsage` guard while this pane was up, so
// without the resume its 20s auto-refresh is silently dead. It matters more now than it did:
// Enter changes what the usage pane is showing, so landing back on a pane that never refetches
// would leave the new scope unapplied until the operator pressed something.
func (m *model) leaveAgentsPane() tea.Cmd {
if m.previousPane != paneNone {
m.pane = m.previousPane
m.previousPane = paneNone
} else {
m.pane = paneSessions
}
if m.pane == paneUsage {
return m.resumeUsagePolling()
}
return nil
}
Loading
Loading