mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user