feat: report agent runtime hours usage in entitlements (#27985)

Populate `FeatureAgentRuntimeHours.Actual` on every entitlements refresh
for licenses that grant the feature. A new
`GetTotalUsageHBAgentRuntimeV1` query sums `runtime_ms` over the
license's usage period, reading `usage_events` directly:
`hb_agent_runtime_v1` is exactly one row per hourly bucket
deployment-wide with `created_at` at the bucket start, enforced by the
unique partial index introduced in #27983.

The measurement reuses the shared `measureUsage` policy from #27984
through a new `AgentRuntimeMsFn` closure (usage publisher subject):
failures publish the stable
`LicenseAgentRuntimeUsageUnavailableErrorText` and log the cause. Usage
is floored to whole hours, matching the unit of the
`agent_runtime_hours_*` claims, and at most one warning is emitted per
refresh: reaching the allocation supersedes the advisory soft limit. The
dashboard renders the soft-limit advisory muted without a sales link and
treats the runtime usage-unavailable text as a diagnostic.

**Precise usage.** `Feature.ActualMs` (JSON `actual_ms`), set only for
`agent_runtime_hours`, carries the exact stored milliseconds backing the
floored `Actual` so clients can render fractional hours (e.g. `10.3`).
It has the same freshness as `Actual`; the whole-hour warning thresholds
are unchanged.

**Unlimited licenses.** A license minted with the unlimited (`-1`)
allocation decodes to an enabled feature with a nil `Limit` (#27984), so
the warning write-back now guards the allocation dereference: no
thresholds can exist for an unlimited license, so no runtime hours
warning is ever emitted, while `Actual` is still measured and published.
`Feature.Compare` is unchanged; for usage-period features the
issued-at/end dates decide first, so a metered feature outranks an
unlimited one only on an exact timestamp tie, an edge pinned by a
`TestFeatureComparison` case and documented on
`decodeAgentRuntimeHours`.

**Grandfathered premium licenses.** Premium licenses without
`agent_runtime_hours_*` claims are now granted the feature disabled with
a zero limit over the license term, identical to an explicit
`allocation: 0`: usage is measured and published for every Premium
deployment, and chatd's pooled admission (#27902) caps concurrent
agentic chats until a license with a positive allocation is added. The
default carries a fixed early `UsagePeriod.IssuedAt` (2026-08-01, the
same mechanism as the managed-agents default) so any license actually
carrying the claims outranks it in the `AddFeature` merge regardless of
the licenses' relative issue dates; the constant must stay earlier than
the earliest legitimately issued claim-bearing license. Zero allocations
(explicit or grandfathered) emit no deployment-wide warning banner:
those deployments are steered by the in-page upgrade CTA and the
concurrency cap. Enterprise licenses are unchanged.

Part 3 of a 3-PR stack splitting up #27796 (see there for review
history). Stack: #27983 → #27984 → this PR.

Closes CODAGT-852.
This commit is contained in:
Jaayden Halko
2026-08-18 12:40:33 +07:00
committed by GitHub
parent 30dc7ebd71
commit fa8ffe4eda
24 changed files with 1532 additions and 77 deletions
+155
View File
@@ -121,6 +121,15 @@ func Entitlements(
EndDate: endTime,
})
},
AgentRuntimeMsFn: func(ctx context.Context, startTime time.Time, endTime time.Time) (int64, error) {
// Bounds and bucket semantics are documented on the query.
//
// nolint:gocritic // Reading usage events requires the usage publisher subject.
return db.GetTotalUsageHBAgentRuntimeV1(dbauthz.AsUsagePublisher(ctx), database.GetTotalUsageHBAgentRuntimeV1Params{
StartTime: startTime,
EndTime: endTime,
})
},
})
if err != nil {
return entitlements, err
@@ -140,6 +149,9 @@ type FeatureArguments struct {
// state of the world, but a count between two points in time determined by
// the licenses.
ManagedAgentCountFn ManagedAgentCountFn
// AgentRuntimeMsFn is queried with two points in time determined by the
// licenses, like the managed agent count above.
AgentRuntimeMsFn AgentRuntimeMsFn
// UserCountingMode selects the count that FeatureUserLimit candidates
// from AI Governance addon licenses are evaluated against. Under
// UserCountingModeWorkspaceCapable they use WorkspaceCapableUserCountFn's
@@ -173,6 +185,10 @@ const (
type ManagedAgentCountFn func(ctx context.Context, from time.Time, to time.Time) (int64, error)
// AgentRuntimeMsFn returns the total Coder Agent runtime in milliseconds
// recorded between from (inclusive) and to (exclusive).
type AgentRuntimeMsFn = ManagedAgentCountFn
type WorkspaceCapableUserCountFn func(ctx context.Context) (int64, error)
// userLimitCandidate is one license's FeatureUserLimit terms: its seat limit,
@@ -465,6 +481,37 @@ func LicensesEntitlements(
End: defaultManagedAgentsEnd,
},
})
// Premium licenses without agent_runtime_hours_* claims are
// grandfathered into a zero-hour allocation: the feature is
// granted disabled with a zero limit, which measures and
// publishes usage (see the measureAgentRuntimeMs call below)
// and caps concurrent agentic chats the same as an explicit
// zero allocation.
var (
// A fixed issue time that predates any license issued with
// agent_runtime_hours_* claims, so a license that actually
// carries those claims outranks this default in
// Feature.Compare (IssuedAt-first for usage period features)
// regardless of the licenses' relative issue dates. This
// must remain earlier than the earliest legitimately issued
// claim-bearing license.
defaultAgentRuntimeHoursIssuedAt = time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
defaultAgentRuntimeHoursLimit int64
)
entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, codersdk.Feature{
Enabled: false,
Entitlement: entitlement,
Limit: &defaultAgentRuntimeHoursLimit,
UsagePeriod: &codersdk.UsagePeriod{
IssuedAt: defaultAgentRuntimeHoursIssuedAt,
// The license term, matching a license with an explicit
// zero allocation, so measured usage covers the current
// term.
Start: usagePeriodStart,
End: usagePeriodEnd,
},
})
}
// TODO: Remove this tracking once AI Bridge is enforced as an add-on license.
@@ -736,6 +783,44 @@ func LicensesEntitlements(
}
}
// Usage is measured even for a zero allocation, which reports the
// feature disabled: see decodeAgentRuntimeHours. Premium licenses
// without agent runtime hour claims grant the same disabled zero-limit
// feature (see the grandfather default above), so every premium
// deployment reports usage here. Reported usage can trail real usage;
// the sources of staleness and loss are documented on the
// enterprise/coderd/usage.AgentRuntime* constants.
runtimeHours := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
if entitlements.HasLicense && runtimeHours.UsagePeriod != nil {
runtimeMs, ok, err := measureAgentRuntimeMs(ctx, &entitlements,
featureArguments.Logger, featureArguments.AgentRuntimeMsFn, *runtimeHours.UsagePeriod)
if err != nil {
return entitlements, err
}
if ok {
actualHours := agentRuntimeMsToHours(runtimeMs)
runtimeHours.Actual = &actualHours
// ActualMs carries the exact stored milliseconds so clients can
// render fractional hours. Negative input clamps to 0, mirroring
// agentRuntimeMsToHours, since AgentRuntimeMsFn is a
// caller-supplied seam.
actualMs := max(runtimeMs, 0)
runtimeHours.ActualMs = &actualMs
// Written back directly rather than through AddFeature:
// AddFeature only replaces the existing entry when the new one
// strictly outranks it, so setting Actual on an otherwise
// identical feature would be dropped as a tie.
entitlements.Features[codersdk.FeatureAgentRuntimeHours] = runtimeHours
// A nil Limit means the license grants unlimited runtime
// hours: no thresholds can exist, so no warnings.
if runtimeHours.Limit != nil {
entitlements.Warnings = appendAgentRuntimeHoursWarning(
entitlements.Warnings, actualHours, *runtimeHours.Limit, runtimeHours.SoftLimit)
}
}
}
if entitlements.HasLicense {
userLimit := entitlements.Features[codersdk.FeatureUserLimit]
// The enforced count and its meaning come from the selected
@@ -865,6 +950,63 @@ func LicensesEntitlements(
return entitlements, nil
}
// measureAgentRuntimeMs runs fn over the feature's usage period. A nil fn
// or a failure with a dead context fails the whole call; any other failure
// logs the cause and publishes the stable unavailable text instead. It
// returns the measured milliseconds and true only on success.
func measureAgentRuntimeMs(
ctx context.Context,
entitlements *codersdk.Entitlements,
logger slog.Logger,
fn AgentRuntimeMsFn,
usagePeriod codersdk.UsagePeriod,
) (int64, bool, error) {
if fn == nil {
return 0, false, xerrors.New("developer error: no closure provided to measure agent runtime usage")
}
value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
switch {
case err != nil && ctx.Err() != nil:
// Do not classify cancellation by error shape instead of ctx.Err():
// Postgres raises SQLSTATE 57014 (query_canceled) for
// statement_timeout kills as well as client cancels, and aborting on
// those would fail every entitlements refresh on a deployment whose
// statement_timeout is shorter than a usage query.
return 0, false, xerrors.Errorf("get agent runtime: %w", err)
case err != nil:
logger.Error(ctx, "get agent runtime for entitlements", slog.Error(err))
entitlements.Errors = append(entitlements.Errors, codersdk.LicenseAgentRuntimeUsageUnavailableErrorText)
return 0, false, nil
}
return value, true, nil
}
// appendAgentRuntimeHoursWarning appends at most one warning: reaching the
// allocation supersedes the advisory soft limit, so the dashboard banner
// never stacks both messages.
func appendAgentRuntimeHoursWarning(warnings []string, actualHours int64, allocation int64, softLimit *int64) []string {
// A zero allocation (explicit or the grandfathered premium default) has
// no thresholds to warn about: those deployments are steered by the
// in-page upgrade CTA and the concurrent chat cap, not a
// deployment-wide banner.
if allocation <= 0 {
return warnings
}
switch {
case actualHours >= allocation:
return append(warnings, fmt.Sprintf(
codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText,
actualHours, allocation))
case softLimit != nil && actualHours >= *softLimit:
return append(warnings, fmt.Sprintf(
codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText,
actualHours, allocation, *softLimit))
}
return warnings
}
func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string {
if limit <= 0 {
return warnings
@@ -948,6 +1090,19 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
}
}
// agentRuntimeMsToHours floors milliseconds of Coder Agent runtime to whole
// hours, the unit shared by the agent_runtime_hours_* claims and the
// feature's limits. Flooring keeps the rendered value and the whole-hour
// warning thresholds in agreement. Negative input (not producible by the
// production query, but AgentRuntimeMsFn is a caller-supplied seam) clamps
// to 0.
func agentRuntimeMsToHours(ms int64) int64 {
if ms <= 0 {
return 0
}
return ms / int64(time.Hour/time.Millisecond)
}
// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours
// feature from its claims. granted is false when there is no usable
// allocation claim; per-claim validity rules live on the Claim* constants
@@ -1,10 +1,15 @@
package license
import (
"fmt"
"math"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
)
func TestNextLicenseValidityPeriod(t *testing.T) {
@@ -138,3 +143,80 @@ func permutations[T any](arr []T) [][]T {
helper(arr, 0)
return res
}
func TestAgentRuntimeMsToHours(t *testing.T) {
t.Parallel()
const hourMs = int64(60 * 60 * 1000)
testCases := []struct {
name string
ms int64
want int64
}{
{"Zero", 0, 0},
// Any runtime below an hour floors to zero.
{"OneMillisecond", 1, 0},
{"JustUnderAnHour", hourMs - 1, 0},
{"ExactlyOneHour", hourMs, 1},
{"JustOverAnHour", hourMs + 1, 1},
{"JustUnderTwoHours", 2*hourMs - 1, 1},
{"ExactlyTwoHours", 2 * hourMs, 2},
// A realistic month of continuous runtime.
{"Large", 720 * hourMs, 720},
// Pins the divisor as milliseconds per hour.
{"MaxInt64", math.MaxInt64, math.MaxInt64 / hourMs},
// Negative input is not expected from the production query, which
// coalesces NULL to 0, but it must never produce a negative hour
// count that would compare oddly against the license limits.
{"Negative", -1, 0},
{"NegativeHour", -hourMs, 0},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, agentRuntimeMsToHours(tc.ms))
})
}
}
// TestAppendAgentRuntimeHoursWarning pins the warning arithmetic: thresholds
// are "reached" (>=), and reaching the allocation supersedes the advisory
// soft limit so at most one warning is appended.
func TestAppendAgentRuntimeHoursWarning(t *testing.T) {
t.Parallel()
softLimit := ptr.Ref[int64](80)
softWarning := func(actual int64) []string {
return []string{fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, actual, 100, 80)}
}
allocationWarning := func(actual int64) []string {
return []string{fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText, actual, 100)}
}
testCases := []struct {
name string
actual int64
allocation int64
softLimit *int64
want []string
}{
{"ZeroAllocation", 50, 0, softLimit, nil},
{"NegativeAllocation", 50, -1, softLimit, nil},
{"BelowSoftLimit", 79, 100, softLimit, nil},
{"AtSoftLimit", 80, 100, softLimit, softWarning(80)},
{"BetweenSoftLimitAndAllocation", 99, 100, softLimit, softWarning(99)},
{"AtAllocationSupersedesSoftLimit", 100, 100, softLimit, allocationWarning(100)},
{"OverAllocation", 150, 100, softLimit, allocationWarning(150)},
{"NoSoftLimitBelowAllocation", 99, 100, nil, nil},
{"NoSoftLimitAtAllocation", 100, 100, nil, allocationWarning(100)},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, appendAgentRuntimeHoursWarning(nil, tc.actual, tc.allocation, tc.softLimit))
})
}
}
File diff suppressed because it is too large Load Diff
+17
View File
@@ -2,6 +2,7 @@ package coderd_test
import (
"context"
"fmt"
"net/http"
"testing"
"time"
@@ -155,6 +156,22 @@ func TestPostLicense(t *testing.T) {
require.NotNil(t, feature.HardLimit)
require.EqualValues(t, 120, *feature.HardLimit)
require.NotNil(t, feature.UsagePeriod)
// Actual is read from usage_events, which has no runtime events in
// this deployment. It is reported in whole hours, matching the unit
// of the claims above, with the precise milliseconds in ActualMs.
require.NotNil(t, feature.Actual)
require.EqualValues(t, 0, *feature.Actual)
require.NotNil(t, feature.ActualMs)
require.EqualValues(t, 0, *feature.ActualMs)
require.Empty(t, entitlements.Errors)
// Zero usage is below both thresholds, so no runtime warning
// fires. Unrelated warnings from this bare license are ignored.
// The negatives are built from the exported constants so a reword
// cannot silently disarm this guard.
require.NotContains(t, entitlements.Warnings,
fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, 0, 100, 80))
require.NotContains(t, entitlements.Warnings,
fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText, 0, 100))
})
t.Run("Unauthorized", func(t *testing.T) {