Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bb26857
feat: bill local tool execution time in chat agent runtime
jaaydenh Aug 14, 2026
a88b8a4
fix(coderd/x/chatd): bill interrupted tool batches with live completi…
jaaydenh Aug 18, 2026
589bfce
fix(coderd/x/chatd): track batch billing per occurrence and per dispa…
jaaydenh Aug 18, 2026
9ff5f50
fix(coderd/x/chatd): snapshot interrupt billing atomically per call o…
jaaydenh Aug 18, 2026
68dee09
fix(coderd/x/chatd): stamp tool completions by occurrence and reuse t…
jaaydenh Aug 18, 2026
2f6e743
fix(coderd/x/chatd): correlate interrupt billing by unresolved call p…
jaaydenh Aug 18, 2026
45f962c
docs(docs/ai-coder): reformat agent runtime section to one sentence p…
jaaydenh Aug 18, 2026
167d9f8
fix(coderd/x/chatd): keep interrupted billing stable across task retries
jaaydenh Aug 18, 2026
9fd2445
fix(coderd/x/chatd): carry the interrupt episode snapshot across task…
jaaydenh Aug 18, 2026
dd4384d
fix(coderd/x/chatd): bill windows defined by calls without tool call IDs
jaaydenh Aug 18, 2026
3041968
docs(coderd/usage): clarify that workspace tools bill regardless of a…
jaaydenh Aug 18, 2026
8d4760a
fix(coderd/x/chatd): seed tool batch billing only once dispatch is gu…
jaaydenh Aug 18, 2026
ca3ae1a
fix(coderd/x/chatd): capture the interrupt episode snapshot before th…
jaaydenh Aug 18, 2026
458498e
Merge remote-tracking branch 'origin/main' into agent-runtime-bgdy
jaaydenh Aug 19, 2026
d2d9a23
fix(coderd/x/chatd): bill serial tool calls from their actual start
jaaydenh Aug 19, 2026
da26bcd
docs(docs/ai-coder): revert usage-data-reporting changes
jaaydenh Aug 19, 2026
3871561
style(coderd): trim agent runtime comments
jaaydenh Aug 19, 2026
43749fe
Merge branch 'main' into jaayden/codagt-928-track-local-tool-executio…
jaaydenh Aug 19, 2026
a2d1778
docs(coderd/x/chatd): document tool runtime billing
jaaydenh Aug 19, 2026
fd35fd8
chore: cleanup for comments
jaaydenh Aug 20, 2026
82dcbcf
chore: simplifications
jaaydenh Aug 20, 2026
d52fd1a
refactor(coderd/x/chatd): simplify tool runtime billing
jaaydenh Aug 20, 2026
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
22 changes: 12 additions & 10 deletions coderd/database/querier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11109,13 +11109,13 @@ func TestGetTotalChatMessageRuntimeMsInRange(t *testing.T) {
LastModelConfigID: mc.ID,
})

insertMessage := func(chatID uuid.UUID, runtimeMs int64, createdAt time.Time, deleted bool) {
insertMessage := func(chatID uuid.UUID, role database.ChatMessageRole, runtimeMs int64, createdAt time.Time, deleted bool) {
t.Helper()
msg := dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chatID,
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: mc.ID, Valid: true},
Role: database.ChatMessageRoleAssistant,
Role: role,
RuntimeMs: sql.NullInt64{Int64: runtimeMs, Valid: true},
})
_, err := sqlDB.ExecContext(ctx, "UPDATE chat_messages SET created_at = $1, deleted = $2 WHERE id = $3", createdAt, deleted, msg.ID)
Expand All @@ -11124,22 +11124,24 @@ func TestGetTotalChatMessageRuntimeMsInRange(t *testing.T) {

// Counted: on the inclusive start boundary, in the middle (across two
// chats), soft-deleted, and just before the exclusive end boundary.
insertMessage(chat1.ID, 1, rangeStart, false)
insertMessage(chat2.ID, 2, rangeStart.Add(30*time.Minute), false)
insertMessage(chat1.ID, 4, rangeStart.Add(45*time.Minute), true)
insertMessage(chat1.ID, 8, rangeEnd.Add(-time.Second), false)
insertMessage(chat1.ID, database.ChatMessageRoleAssistant, 1, rangeStart, false)
insertMessage(chat2.ID, database.ChatMessageRoleAssistant, 2, rangeStart.Add(30*time.Minute), false)
insertMessage(chat1.ID, database.ChatMessageRoleAssistant, 4, rangeStart.Add(45*time.Minute), true)
insertMessage(chat1.ID, database.ChatMessageRoleAssistant, 8, rangeEnd.Add(-time.Second), false)
// Tool rows count because runtime totals are role-agnostic.
insertMessage(chat1.ID, database.ChatMessageRoleTool, 64, rangeStart.Add(20*time.Minute), false)
// Not counted: before the range, on the exclusive end boundary, and a
// NULL runtime (runtime 0 is stored as NULL).
insertMessage(chat1.ID, 16, rangeStart.Add(-time.Second), false)
insertMessage(chat1.ID, 32, rangeEnd, false)
insertMessage(chat1.ID, 0, rangeStart.Add(10*time.Minute), false)
insertMessage(chat1.ID, database.ChatMessageRoleAssistant, 16, rangeStart.Add(-time.Second), false)
insertMessage(chat1.ID, database.ChatMessageRoleAssistant, 32, rangeEnd, false)
insertMessage(chat1.ID, database.ChatMessageRoleAssistant, 0, rangeStart.Add(10*time.Minute), false)

total, err = db.GetTotalChatMessageRuntimeMsInRange(ctx, database.GetTotalChatMessageRuntimeMsInRangeParams{
StartTime: rangeStart,
EndTime: rangeEnd,
})
require.NoError(t, err)
require.EqualValues(t, 15, total)
require.EqualValues(t, 79, total)
}

func TestListUsageEventCreatedAtsByTypeSince(t *testing.T) {
Expand Down
14 changes: 9 additions & 5 deletions coderd/usage/usagetypes/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,15 @@ func (e HBAISeats) Fields() map[string]any {
}

// HBAgentRuntime is the event associated with hb_agent_runtime_v1. RuntimeMs
// is the total agent-loop runtime in milliseconds consumed by Coder Agents
// (chats) in one UTC hour. Each measured step spans model streaming (including
// provider-executed tools) and stream retries, and ends when the model stream
// finishes. Time spent executing local tools between steps, including
// sub-agents that bill their own model calls, is excluded.
// is total Coder Agent chat runtime in milliseconds for one UTC hour.
//
// Model steps bill provider streaming. Local tool batches bill the union of
// billed execution intervals, so parallel calls count once and serial calls
// count only from their own start.
//
// Excluded: sub-agent orchestration, client and external-agent work, user or
// idle waits, and retry backoff. Server-executed tools count even when their
// work runs in a connected workspace.
//
// This measures the new Coder Agents (the `chats` tables), not the deprecated
// Tasks counted by dc_managed_agents_v1.
Expand Down
12 changes: 7 additions & 5 deletions coderd/x/chatd/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,9 @@ The buffer exposes the following API:
- `AddPart(chat_id, history_version, generation_attempt, content)`: adds a message part to the buffer. Returns a predefined error if the episode is not found or the array is full.
- `GetParts(chat_id, history_version, generation_attempt)`: returns the message parts for an episode. Returns a predefined error if the episode is not found.
- `StartModelInvocation(chat_id, history_version, generation_attempt)`: stamps the instant the episode opens its provider stream. Returns a predefined error if the episode is not found or already closed. Episodes that never invoke a model, such as local tool execution batches, are never stamped.
- `ModelInvokedAt(chat_id, history_version, generation_attempt)`: returns the instant stamped by `StartModelInvocation`, or the zero time when the episode is unknown or never opened a provider stream. It must be read before `CloseEpisode`, because closed episodes are garbage collected and reading afterwards races the cleanup loop. The interrupt goroutine reads it just before closing the episode and uses the span between that instant and the interrupt as the interrupted attempt's billable runtime.
- `ModelInvokedAt(chat_id, history_version, generation_attempt)`: returns the `StartModelInvocation` stamp, or zero if none exists. Interrupt handling reads it before closing the episode.
- `RecordToolStart(chat_id, history_version, generation_attempt, call_index, started_at)` and `RecordToolCompletion(chat_id, history_version, generation_attempt, call_index, completed_at)`: record when each call occurrence starts and finishes. Calls are keyed by unresolved-call position so rejected gaps and duplicate IDs remain distinct. Queued and undispatched calls are absent because neither has started.
- `ToolCompletions(chat_id, history_version, generation_attempt)`: returns the started per-occurrence execution stamps. Interrupt handling reads them before closing the episode.
- `SubscribeToEpisode(chat_id, history_version, generation_attempt)`: returns a go channel that will receive all message parts for the episode. It spawns a goroutine that delivers parts to the channel. It's live until the episode is closed or until a subscriber requests that the channel be closed. Once the goroutine delivers all message parts for a closed episode, it closes the channel and exits. If the episode is already closed at the time of the call, the goroutine delivers all message parts for the episode, closes the channel, and exits. `SubscribeToEpisode` does not return an error if the episode is not found: it waits for it to be created instead.

Closed episodes are garbage collected after at least 15 seconds since they were closed and when they have no active subscribers. The message part buffer maintains a garbage collection goroutine.
Expand Down Expand Up @@ -930,10 +932,10 @@ The interrupt goroutine is responsible for handling interrupts. It is spawned wh

The goroutine does the following in order:

1. It fetches the generation attempt number from the database.
2. It closes the episode corresponding to its history version and generation attempt by calling the `CloseEpisode` method on the [Message part buffer](#message-part-buffer).
3. It reads the buffered parts for that episode by calling the `GetParts` method on the message part buffer.
4. It applies the `FinishInterruption(partial?)` transition on the core state machine. If there are no buffered parts for that episode, or the episode is not found, it passes `nil` as the `partial` argument.
1. Before its first database read, it reads the expected episode's billing stamps, closes it with `CloseEpisode`, reads its buffered parts, and retains the snapshot across task retries.
2. It loads the chat and verifies the generation attempt, resnapshotting the matching episode if the key differs.
3. It uses the snapshot's interrupt time to convert buffered parts and synthesize tool cancellation rows. Started, billable local tools contribute the union of their partial execution intervals; unstarted or excluded tools do not.
4. It applies the `FinishInterruption(partial?)` transition on the core state machine, passing `nil` when there are no partial messages.

#### Dynamic tools timeout goroutine

Expand Down
3 changes: 3 additions & 0 deletions coderd/x/chatd/attempt.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ type stepData struct {
ContextLimit sql.NullInt64
Runtime time.Duration

// BatchRuntime is the local-tool batch window. Model steps use Runtime.
BatchRuntime time.Duration

ToolCallCreatedAt map[string]time.Time
ToolResultCreatedAt map[string]time.Time
ReasoningStartedAt []time.Time
Expand Down
9 changes: 9 additions & 0 deletions coderd/x/chatd/chatd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6437,6 +6437,15 @@ func TestActiveServer_ToolExecutionAndPolicy(t *testing.T) {
require.False(t, result.ProviderExecuted)
}
}

messages := chatMessages(ctx, t, db, chat.ID)
billedToolRows := 0
for _, msg := range messages {
if msg.Role == database.ChatMessageRoleTool && msg.RuntimeMs.Valid {
billedToolRows++
}
}
require.LessOrEqual(t, billedToolRows, 1)
})
}

Expand Down
Loading
Loading