fix(backend): thread cached input tokens through the session usage rollup (#2521)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
This commit is contained in:
nova28
2026-08-13 05:56:07 +08:00
committed by GitHub
parent 11f1b20134
commit 47a878498b
14 changed files with 786 additions and 29 deletions
@@ -69,6 +69,13 @@ func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Lo
return nil, nil, nil, fmt.Errorf("office repo: %w", err)
}
cleanups = append(cleanups, officeCleanup)
// Must run after office.Provide succeeds: it reconciles task_sessions
// against office's office_cost_events ledger, and construction order is
// what guarantees that table exists now - see the doc comment on
// sqlite.Repository.BackfillSessionTokensCachedIn.
if err := backfillSessionCachedTokens(ctx, taskRepoImpl, log); err != nil {
return nil, nil, nil, err
}
terminalRepoImpl, err := terminalrepo.NewWithDB(writer, reader, log)
if err != nil {
return nil, nil, nil, fmt.Errorf("terminal repo: %w", err)
@@ -121,6 +128,30 @@ func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Lo
return pool, repos, cleanups, nil
}
// sessionCachedTokenBackfiller is the narrow slice of *sqlite.Repository
// (internal/task/repository/sqlite) this composition root needs, defined
// locally so this file doesn't have to import that subpackage just for one
// call. See sqlite.Repository.BackfillSessionTokensCachedIn's doc comment
// for why this call lives here rather than inside the task repository's own
// runMigrations(): it reconciles task_sessions against the office
// repository's office_cost_events ledger, and only this composition root
// knows both repositories have finished initializing.
type sessionCachedTokenBackfiller interface {
BackfillSessionTokensCachedIn(ctx context.Context) (int64, error)
}
// backfillSessionCachedTokens triggers the task repository's cached-token
// reconciliation and logs how many task_sessions rows it reconciled.
// Extracted out of provideRepositories to keep it within the funlen limit.
func backfillSessionCachedTokens(ctx context.Context, taskRepo sessionCachedTokenBackfiller, log *logger.Logger) error {
affected, err := taskRepo.BackfillSessionTokensCachedIn(ctx)
if err != nil {
return err
}
log.Info("reconciled task_sessions.tokens_cached_in against office_cost_events", zap.Int64("rows", affected))
return nil
}
// supportRepositorySet groups the lighter-weight support repositories
// that share a common (writer, reader) wire-up pattern.
type supportRepositorySet struct {
@@ -0,0 +1,93 @@
package backendapp
import (
"context"
"testing"
"time"
"github.com/kandev/kandev/internal/common/config"
)
// TestProvideRepositoriesBackfillsSessionCachedTokens is the composition-level
// regression test for moving task_sessions.tokens_cached_in's backfill
// trigger out of the task repository's own runMigrations() and into this
// package (see the "PR Fixup" plan section on carlosflorencio's PR #2521
// architecture comment). Neither repository's own package tests can see the
// other's construction order, so only a test that boots the real repository
// graph through provideRepositories can prove the backfill still actually
// runs — a regression here would silently resurrect the original card's bug
// (task_sessions.tokens_cached_in drifting from the office_cost_events
// ledger) without any single-package test failing.
func TestProvideRepositoriesBackfillsSessionCachedTokens(t *testing.T) {
ctx := context.Background()
cfg := &config.Config{
HomeDir: t.TempDir(),
Database: config.DatabaseConfig{Driver: "sqlite"},
}
log := newTestLogger()
// First boot: creates the schema (both repositories' tables, including
// office_cost_events) against a real on-disk SQLite file. Then seed a
// drifted task_sessions row directly - bypassing IncrementTaskSessionUsage
// - the same way a missed live increment would leave one, plus a ledger
// row establishing the true value the next boot's backfill must recover.
pool, _, cleanups, err := provideRepositories(ctx, cfg, log, "test")
if err != nil {
t.Fatalf("provideRepositories (first boot): %v", err)
}
writer := pool.Writer()
now := time.Now().UTC()
if _, err := writer.Exec(writer.Rebind(
`INSERT INTO workspaces (id, name, created_at, updated_at) VALUES (?, 'test workspace', ?, ?)`),
"ws-cached-boot", now, now); err != nil {
t.Fatalf("seed workspace: %v", err)
}
if _, err := writer.Exec(writer.Rebind(
`INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) VALUES (?, ?, 'test', ?, ?)`),
"task-cached-boot", "ws-cached-boot", now, now); err != nil {
t.Fatalf("seed task: %v", err)
}
if _, err := writer.Exec(writer.Rebind(
`INSERT INTO task_sessions (id, task_id, started_at, updated_at, tokens_cached_in) VALUES (?, ?, ?, ?, ?)`),
"sess-cached-boot", "task-cached-boot", now, now, 2_000); err != nil {
t.Fatalf("seed drifted session: %v", err)
}
if _, err := writer.Exec(writer.Rebind(
`INSERT INTO office_cost_events (id, session_id, tokens_cached_in, occurred_at, created_at) VALUES (?, ?, ?, ?, ?)`),
"cost-cached-boot", "sess-cached-boot", 98_805_109, now, now); err != nil {
t.Fatalf("seed ledger row: %v", err)
}
for i := len(cleanups) - 1; i >= 0; i-- {
if cleanups[i] != nil {
_ = cleanups[i]()
}
}
// Second boot against the same on-disk DB (same cfg.HomeDir, so the same
// kandev.db file) simulates a restart. The composition-level backfill
// call in provideRepositories must reconcile the drifted row above.
pool2, _, cleanups2, err := provideRepositories(ctx, cfg, log, "test")
if err != nil {
t.Fatalf("provideRepositories (second boot): %v", err)
}
t.Cleanup(func() {
for i := len(cleanups2) - 1; i >= 0; i-- {
if cleanups2[i] != nil {
_ = cleanups2[i]()
}
}
})
var got int64
writer2 := pool2.Writer()
if err := writer2.QueryRowx(writer2.Rebind(
`SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-cached-boot",
).Scan(&got); err != nil {
t.Fatalf("read row after second boot: %v", err)
}
if got != 98_805_109 {
t.Errorf("tokens_cached_in after reboot = %d, want 98805109 (the drifted 2000 must be "+
"reconciled against the office_cost_events ledger by the composition-level backfill "+
"call in provideRepositories, after office.Provide succeeds)", got)
}
}
@@ -279,6 +279,13 @@ func (r *Repository) createCostTables() error {
CREATE INDEX IF NOT EXISTS idx_office_cost_agent ON office_cost_events(agent_profile_id);
CREATE INDEX IF NOT EXISTS idx_office_cost_occurred ON office_cost_events(occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_office_cost_task ON office_cost_events(task_id);
-- Indexes the join column internal/task/repository/sqlite's
-- BackfillSessionTokensCachedIn correlates task_sessions against. Lives
-- here (not in the task repository) because it indexes a table this
-- repository owns. Without it that correlated subquery falls back to a
-- full table scan per task_sessions row - measured at 76.72s at a modest
-- 4,000 sessions / 80,000 events versus 0.17s indexed.
CREATE INDEX IF NOT EXISTS idx_office_cost_events_session_id ON office_cost_events(session_id);
CREATE TABLE IF NOT EXISTS office_budget_policies (
id TEXT PRIMARY KEY,
@@ -35,6 +35,25 @@ func seedTasksTable(t *testing.T, repo *sqlite.Repository, taskID, workspaceID s
}
}
// TestCreateCostTables_IndexesSessionID guards the boot-time cost of
// internal/task/repository/sqlite's BackfillSessionTokensCachedIn, which
// correlates task_sessions against office_cost_events on session_id. Without
// this index that correlated subquery falls back to a full table scan per
// task_sessions row (a measured ~450x slowdown at 4,000 sessions / 80,000
// events - 76.72s vs 0.17s). The index lives here, not in the task
// repository, because it indexes a table this repository owns.
func TestCreateCostTables_IndexesSessionID(t *testing.T) {
repo := newTestRepo(t)
var name string
err := repo.ReaderDB().QueryRow(
`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'office_cost_events' AND sql LIKE '%session_id%'`,
).Scan(&name)
if err != nil {
t.Fatalf("expected an index on office_cost_events(session_id) to exist after repo init, got: %v", err)
}
}
func TestCostEvent_CreateAndList(t *testing.T) {
repo := newTestRepo(t)
ctx := context.Background()
@@ -534,9 +534,9 @@ func (s *Service) tryPostStartFallback(
// normalize the model id and look up pricing. On miss the row
// records cost_subcents=0 with estimated=true.
//
// After insert the session totals (tokens_in / tokens_out / cost_subcents)
// are incremented on task_sessions, and any applicable budget policy is
// evaluated. Estimated rows count toward budget totals at face value.
// After insert the session totals (tokens_in / tokens_cached_in / tokens_out /
// cost_subcents) are incremented on task_sessions, and any applicable budget
// policy is evaluated. Estimated rows count toward budget totals at face value.
func (s *Service) handlePromptUsage(ctx context.Context, event *bus.Event) error {
data, err := decodeEventData[PromptUsageData](event)
if err != nil || data.TaskID == "" || data.SessionID == "" {
@@ -549,6 +549,7 @@ func (s *Service) handlePromptUsage(ctx context.Context, event *bus.Event) error
costSubcents, estimated := s.resolveCostForUsage(ctx, *data)
provider := resolveProvider(*data)
tokensCachedIn := data.Usage.CachedReadTokens + data.Usage.CachedWriteTokens
costEvent := &models.CostEvent{
SessionID: data.SessionID,
@@ -558,7 +559,7 @@ func (s *Service) handlePromptUsage(ctx context.Context, event *bus.Event) error
Model: data.Model,
Provider: provider,
TokensIn: data.Usage.InputTokens,
TokensCachedIn: data.Usage.CachedReadTokens + data.Usage.CachedWriteTokens,
TokensCachedIn: tokensCachedIn,
TokensOut: data.Usage.OutputTokens,
CostSubcents: costSubcents,
Estimated: estimated,
@@ -570,7 +571,7 @@ func (s *Service) handlePromptUsage(ctx context.Context, event *bus.Event) error
s.incrementSessionUsageTotals(
ctx, data.SessionID,
data.Usage.InputTokens, data.Usage.OutputTokens, costSubcents,
data.Usage.InputTokens, tokensCachedIn, data.Usage.OutputTokens, costSubcents,
)
if fields.WorkspaceID != "" {
@@ -652,13 +653,13 @@ func providerFromCLI(cli string) string {
}
func (s *Service) incrementSessionUsageTotals(
ctx context.Context, sessionID string, tokensIn, tokensOut, costSubcents int64,
ctx context.Context, sessionID string, tokensIn, tokensCachedIn, tokensOut, costSubcents int64,
) {
if s.sessionUsageWriter == nil || sessionID == "" {
return
}
if err := s.sessionUsageWriter.IncrementTaskSessionUsage(
ctx, sessionID, tokensIn, tokensOut, costSubcents,
ctx, sessionID, tokensIn, tokensCachedIn, tokensOut, costSubcents,
); err != nil {
s.logger.Warn("increment task_session usage failed",
zap.String("session_id", sessionID), zap.Error(err))
@@ -0,0 +1,120 @@
package service_test
import (
"context"
"testing"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
)
// fakeSessionUsageWriter accumulates the deltas handlePromptUsage sends to
// the task-session rollup, mirroring what the real SQLite repo's
// IncrementTaskSessionUsage does with UPDATE ... SET x = x + ?.
type fakeSessionUsageWriter struct {
tokensIn int64
tokensCachedIn int64
tokensOut int64
costSubcents int64
}
func (f *fakeSessionUsageWriter) IncrementTaskSessionUsage(
_ context.Context, _ string, tokensIn, tokensCachedIn, tokensOut, costSubcents int64,
) error {
f.tokensIn += tokensIn
f.tokensCachedIn += tokensCachedIn
f.tokensOut += tokensOut
f.costSubcents += costSubcents
return nil
}
// TestPromptUsage_RollupReconcilesWithCostLedger asserts the actual
// invariant this rollup exists to maintain: task_sessions' cumulative
// totals must equal the sum of the office_cost_events rows they were
// derived from. Before the fix, tokensCachedIn silently never reaches the
// writer even though every event row carries it, which is exactly what let
// task_sessions.tokens_in under-report cache-heavy sessions by orders of
// magnitude in production.
func TestPromptUsage_RollupReconcilesWithCostLedger(t *testing.T) {
svc, eb := newTestServiceWithBus(t)
ctx := context.Background()
writer := &fakeSessionUsageWriter{}
svc.SetSessionUsageWriter(writer)
createTestAgent(t, svc, "ws-1", "worker-reconcile")
svc.ExecSQL(t, `INSERT INTO tasks (
id, workspace_id, project_id, title, created_at, updated_at
) VALUES (
'task-reconcile', 'ws-1', 'project-1', 'Reconcile task', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)`)
setTestTaskAssignee(t, svc, "task-reconcile", "worker-reconcile")
const sessionID = "session-reconcile-1"
// Cache-heavy shape mirroring the reported bug: cached read tokens
// dominate input tokens by several orders of magnitude.
usages := []map[string]interface{}{
{
"input_tokens": 100,
"cached_read_tokens": 50_000_000,
"cached_write_tokens": 1_000_000,
"output_tokens": 200,
},
{
"input_tokens": 10,
"cached_read_tokens": 2_000,
"cached_write_tokens": 0,
"output_tokens": 5,
},
}
for _, usage := range usages {
event := bus.NewEvent(events.SessionPromptUsageUpdated, "test", map[string]interface{}{
"task_id": "task-reconcile",
"session_id": sessionID,
"agent_id": "claude-acp",
"model": "default",
"usage": usage,
})
if err := eb.Publish(ctx, events.BuildSessionPromptUsageSubject(sessionID), event); err != nil {
t.Fatalf("publish prompt usage: %v", err)
}
}
costs, err := svc.ListCostEvents(ctx, "ws-1")
if err != nil {
t.Fatalf("list costs: %v", err)
}
var wantIn, wantCachedIn, wantOut, wantCost int64
var ledgerRows int
for _, c := range costs {
if c.SessionID != sessionID {
continue
}
ledgerRows++
wantIn += c.TokensIn
wantCachedIn += c.TokensCachedIn
wantOut += c.TokensOut
wantCost += c.CostSubcents
}
if ledgerRows != len(usages) {
t.Fatalf("ledger rows for session = %d, want %d", ledgerRows, len(usages))
}
if wantCachedIn == 0 {
t.Fatal("test setup bug: ledger recorded zero cached tokens, reconciliation would be vacuous")
}
if writer.tokensIn != wantIn {
t.Errorf("rollup tokens_in = %d, want %d (sum of office_cost_events.tokens_in)", writer.tokensIn, wantIn)
}
if writer.tokensCachedIn != wantCachedIn {
t.Errorf("rollup tokens_cached_in = %d, want %d (sum of office_cost_events.tokens_cached_in)",
writer.tokensCachedIn, wantCachedIn)
}
if writer.tokensOut != wantOut {
t.Errorf("rollup tokens_out = %d, want %d (sum of office_cost_events.tokens_out)", writer.tokensOut, wantOut)
}
if writer.costSubcents != wantCost {
t.Errorf("rollup cost_subcents = %d, want %d (sum of office_cost_events.cost_subcents)", writer.costSubcents, wantCost)
}
}
@@ -154,5 +154,5 @@ type PricingLookup interface {
// task_sessions when a cost event lands. Implemented by the task repo.
type SessionUsageWriter interface {
IncrementTaskSessionUsage(ctx context.Context, sessionID string,
tokensIn, tokensOut, costSubcents int64) error
tokensIn, tokensCachedIn, tokensOut, costSubcents int64) error
}
@@ -2,6 +2,7 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
@@ -39,6 +40,84 @@ func (r *Repository) migrateSessionsAddCostColumns() {
r.migrate.Apply("task_sessions.cost_subcents", `ALTER TABLE task_sessions ADD COLUMN cost_subcents INTEGER NOT NULL DEFAULT 0`)
r.migrate.Apply("task_sessions.tokens_in", `ALTER TABLE task_sessions ADD COLUMN tokens_in INTEGER NOT NULL DEFAULT 0`)
r.migrate.Apply("task_sessions.tokens_out", `ALTER TABLE task_sessions ADD COLUMN tokens_out INTEGER NOT NULL DEFAULT 0`)
// BIGINT, not INTEGER: office_cost_events.tokens_cached_in routinely
// accumulates well past int4's 2,147,483,647 ceiling over a long-running
// session (the reported bug measured up to 98,805,109 on one already-
// completed task). SQLite's INTEGER is 64-bit regardless, but on Postgres
// INTEGER is int4 - an overflowing session would abort the single
// multi-column UPDATE in IncrementTaskSessionUsage and the single
// table-wide UPDATE in BackfillSessionTokensCachedIn, silently taking
// tokens_in/tokens_out/cost_subcents down with it for that session.
r.migrate.Apply("task_sessions.tokens_cached_in", `ALTER TABLE task_sessions ADD COLUMN tokens_cached_in BIGINT NOT NULL DEFAULT 0`)
}
// BackfillSessionTokensCachedIn recomputes task_sessions.tokens_cached_in from
// the office_cost_events ledger. The rollup is purely derived from the ledger,
// so recomputing from it is restoring data, not inventing it.
//
// Deliberately NOT called from runMigrations(): office_cost_events is owned and
// created by internal/office/repository/sqlite, a separate repository that
// shares this database but initializes independently, and on a fresh boot the
// task repository's own migrations run first (internal/backendapp/storage.go).
// An earlier version of this method guarded on the ledger table's existence to
// tolerate that ordering from inside runMigrations(); that guard, and the table
// existence it checked, both went away in favor of the caller in
// internal/backendapp/storage.go, which only invokes this after office.Provide
// has already succeeded — construction order guarantees office_cost_events
// exists by then, so no guard is needed here. This keeps the task repository
// from having to know office's schema/table-existence details, and lets the
// office_cost_events(session_id) index it depends on live with the table it
// indexes (internal/office/repository/sqlite/base.go createCostTables).
//
// Deliberately unconditional per session it touches (assignment, not
// increment, so it is idempotent across replays) rather than gated on
// "already nonzero": the rollup can go out of sync with the ledger without
// erroring — a live IncrementTaskSessionUsage call is a best-effort
// UPDATE ... WHERE id = ? that silently matches zero rows if the session row
// doesn't exist yet (see the "missing row" case in
// TestIncrementTaskSessionUsage_UnknownSessionNoError), and
// event_subscribers.go only logs a Warn when the rollup write fails while the
// ledger insert has already committed. A "skip if nonzero" guard would make
// exactly that drift permanent instead of self-healing it on the next boot.
// (If the ledger for a session was deleted separately from the session row
// itself - see the two-transaction delete in workspace_deletion.go - this
// recompute correctly zeroes that session's tokens_cached_in, since an empty
// ledger sums to zero; the rollup's only contract is to equal the ledger sum.)
//
// The WHERE clause below is a boot-cost optimization, not a correctness gate:
// it restricts the write to sessions that either have ledger rows (need a
// possible recompute) or already hold a nonzero value (need a possible
// zeroing, per the paragraph above). Every row it excludes has
// tokens_cached_in = 0 AND no ledger rows, so the unconditional
// COALESCE(NULL, 0) = 0 this statement would otherwise compute for it is
// already what the row holds — excluding it is a provable no-op, never a
// skip of a row that needs correcting. This runs unconditionally on every
// boot (no run-once tracking - see MigrateLogger.Apply, which this method
// deliberately does not use so a real failure propagates instead of being
// swallowed as a Warn), so office_cost_events(session_id) must already be
// indexed: an unindexed correlated subquery here is quadratic in
// (sessions x ledger rows), measured at 76.72s at a modest 4,000 sessions /
// 80,000 events versus 0.17s indexed.
//
// Returns the number of task_sessions rows the WHERE clause matched, so a
// caller can log it for boot-time observability; not otherwise used for
// correctness.
func (r *Repository) BackfillSessionTokensCachedIn(ctx context.Context) (int64, error) {
result, err := r.db.ExecContext(ctx, `
UPDATE task_sessions
SET tokens_cached_in = COALESCE(
(SELECT SUM(e.tokens_cached_in) FROM office_cost_events e
WHERE e.session_id = task_sessions.id), 0)
WHERE EXISTS (SELECT 1 FROM office_cost_events e WHERE e.session_id = task_sessions.id)
OR tokens_cached_in <> 0`)
if err != nil {
return 0, fmt.Errorf("backfill task_sessions.tokens_cached_in: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("backfill task_sessions.tokens_cached_in: rows affected: %w", err)
}
return affected, nil
}
// runMigrations applies idempotent ALTER TABLE migrations for schema evolution.
@@ -154,6 +233,10 @@ func (r *Repository) runMigrations() error {
// task_sessions rebuilds above so it repairs legacy DBs whose schema can no
// longer trigger a rebuild (see migrateSessionsAddCostColumns).
r.migrateSessionsAddCostColumns()
// BackfillSessionTokensCachedIn is deliberately NOT called here - see its
// doc comment. It runs from internal/backendapp/storage.go, after both
// this repository and the office repository (which owns office_cost_events)
// have finished initializing.
// Office task extensions - net-new columns on existing main tables.
// Idempotent ALTERs; main upgrades pick them up at first boot.
@@ -191,9 +274,9 @@ func (r *Repository) runMigrations() error {
// Office session cost tracking extensions are declared in
// initSessionWorktreeSchema's CREATE TABLE (cost_subcents, tokens_in,
// tokens_out). task_sessions.agent_profile_id existed on main as
// NOT NULL; migrateSessionsRemoveAgentExecutionID rebuilds the table
// with the column nullable and the cost columns added.
// tokens_cached_in, tokens_out). task_sessions.agent_profile_id existed
// on main as NOT NULL; migrateSessionsRemoveAgentExecutionID rebuilds the
// table with the column nullable and the cost columns added.
r.migrate.Apply("workflows.is_system", `ALTER TABLE workflows ADD COLUMN is_system INTEGER DEFAULT 0`)
@@ -631,6 +714,7 @@ func (r *Repository) migrateSessionsRemoveAgentExecutionID() error {
task_environment_id TEXT DEFAULT '',
cost_subcents INTEGER NOT NULL DEFAULT 0,
tokens_in INTEGER NOT NULL DEFAULT 0,
tokens_cached_in BIGINT NOT NULL DEFAULT 0,
tokens_out INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
)`,
@@ -641,7 +725,7 @@ func (r *Repository) migrateSessionsRemoveAgentExecutionID() error {
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, is_passthrough, review_status,
COALESCE(base_commit_sha, ''), COALESCE(task_environment_id, ''),
0, 0, 0
0, 0, 0, 0
FROM task_sessions`,
`DROP TABLE task_sessions`,
`ALTER TABLE task_sessions_new RENAME TO task_sessions`,
@@ -701,6 +701,7 @@ const sessionWorktreeSchemaDDL = `
task_environment_id TEXT DEFAULT '',
cost_subcents INTEGER NOT NULL DEFAULT 0,
tokens_in INTEGER NOT NULL DEFAULT 0,
tokens_cached_in BIGINT NOT NULL DEFAULT 0,
tokens_out INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
@@ -1447,24 +1447,27 @@ func (r *Repository) GetLastAgentMessage(ctx context.Context, sessionID string)
}
// IncrementTaskSessionUsage adds the given deltas to the cumulative
// tokens / cost columns on task_sessions. Used by the office cost
// subscriber after a cost event lands so the per-session totals stay
// in sync without re-summing office_cost_events. The model + DTO
// tokens / cost columns on task_sessions, including cached input tokens
// (tokens_cached_in mirrors office_cost_events.tokens_cached_in and is kept
// separate from tokens_in because it is priced differently). Used by the
// office cost subscriber after a cost event lands so the per-session totals
// stay in sync without re-summing office_cost_events. The model + DTO
// don't surface these columns yet (DB-only per the office-costs
// wedge); the cost explorer follow-up will expose them.
func (r *Repository) IncrementTaskSessionUsage(
ctx context.Context, sessionID string, tokensIn, tokensOut, costSubcents int64,
ctx context.Context, sessionID string, tokensIn, tokensCachedIn, tokensOut, costSubcents int64,
) error {
if sessionID == "" {
return nil
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions
SET tokens_in = COALESCE(tokens_in, 0) + ?,
tokens_out = COALESCE(tokens_out, 0) + ?,
cost_subcents = COALESCE(cost_subcents, 0) + ?
SET tokens_in = COALESCE(tokens_in, 0) + ?,
tokens_cached_in = COALESCE(tokens_cached_in, 0) + ?,
tokens_out = COALESCE(tokens_out, 0) + ?,
cost_subcents = COALESCE(cost_subcents, 0) + ?
WHERE id = ?
`), tokensIn, tokensOut, costSubcents, sessionID)
`), tokensIn, tokensCachedIn, tokensOut, costSubcents, sessionID)
return err
}
@@ -985,10 +985,10 @@ func TestIncrementTaskSessionUsage_AccumulatesAcrossCalls(t *testing.T) {
ctx := context.Background()
seedForMsgTest(t, repo, "task-usage", "sess-usage", "turn-usage")
if err := repo.IncrementTaskSessionUsage(ctx, "sess-usage", 100, 200, 50); err != nil {
if err := repo.IncrementTaskSessionUsage(ctx, "sess-usage", 100, 0, 200, 50); err != nil {
t.Fatalf("first increment: %v", err)
}
if err := repo.IncrementTaskSessionUsage(ctx, "sess-usage", 10, 20, 5); err != nil {
if err := repo.IncrementTaskSessionUsage(ctx, "sess-usage", 10, 0, 20, 5); err != nil {
t.Fatalf("second increment: %v", err)
}
@@ -1008,7 +1008,7 @@ func TestIncrementTaskSessionUsage_AccumulatesAcrossCalls(t *testing.T) {
// missing row (subscriber may race against session creation).
func TestIncrementTaskSessionUsage_UnknownSessionNoError(t *testing.T) {
repo := newRepoForSessionTests(t)
if err := repo.IncrementTaskSessionUsage(context.Background(), "no-such", 1, 2, 3); err != nil {
if err := repo.IncrementTaskSessionUsage(context.Background(), "no-such", 1, 1, 2, 3); err != nil {
t.Errorf("expected no error for unknown session, got %v", err)
}
}
@@ -1017,7 +1017,7 @@ func TestIncrementTaskSessionUsage_UnknownSessionNoError(t *testing.T) {
// orchestrator publishing a usage event before SessionID is set.
func TestIncrementTaskSessionUsage_EmptySessionIDNoOp(t *testing.T) {
repo := newRepoForSessionTests(t)
if err := repo.IncrementTaskSessionUsage(context.Background(), "", 1, 2, 3); err != nil {
if err := repo.IncrementTaskSessionUsage(context.Background(), "", 1, 1, 2, 3); err != nil {
t.Errorf("empty session id should be a no-op, got %v", err)
}
}
@@ -1083,19 +1083,19 @@ func TestMigrateSessionsAddCostColumns_BackfillsLegacySchema(t *testing.T) {
seedForMsgTest(t, repo, "task-mig", "sess-mig", "turn-mig")
// Precondition: this is the reported bug on a legacy schema.
if err := repo.IncrementTaskSessionUsage(ctx, "sess-mig", 1, 2, 3); err == nil {
if err := repo.IncrementTaskSessionUsage(ctx, "sess-mig", 1, 1, 2, 3); err == nil {
t.Fatal("expected missing-column error before backfill")
}
repo.migrateSessionsAddCostColumns()
if err := repo.IncrementTaskSessionUsage(ctx, "sess-mig", 1, 2, 3); err != nil {
if err := repo.IncrementTaskSessionUsage(ctx, "sess-mig", 1, 1, 2, 3); err != nil {
t.Fatalf("IncrementTaskSessionUsage after backfill: %v", err)
}
// Idempotent: a second pass over a table that already has the columns is a no-op.
repo.migrateSessionsAddCostColumns()
if err := repo.IncrementTaskSessionUsage(ctx, "sess-mig", 10, 20, 30); err != nil {
if err := repo.IncrementTaskSessionUsage(ctx, "sess-mig", 10, 10, 20, 30); err != nil {
t.Fatalf("IncrementTaskSessionUsage after second pass: %v", err)
}
}
@@ -0,0 +1,163 @@
package sqlite
import (
"context"
"fmt"
"testing"
"time"
"github.com/kandev/kandev/internal/testutil"
)
// The cached-token rollup (BackfillSessionTokensCachedIn,
// IncrementTaskSessionUsage) is dialect-sensitive in a way the SQLite tests in
// session_usage_test.go and session_test.go cannot exercise: SQLite's INTEGER
// is 64-bit, so a column declared INTEGER never overflows there. On Postgres,
// INTEGER is int4 (max 2,147,483,647), and office_cost_events.tokens_cached_in
// routinely accumulates well past that for a single long-running session (the
// reported bug measured up to 98,805,109 on one already-completed task).
// Without this file the type of the new column was never exercised against
// Postgres at all.
//
// Skips unless KANDEV_TEST_POSTGRES_DSN is set.
func seedPostgresTaskSession(t *testing.T, repo *Repository, taskID, sessionID string) {
t.Helper()
now := time.Now().UTC()
if _, err := repo.db.Exec(repo.db.Rebind(`
INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
VALUES (?, '', 'test task', ?, ?)
`), taskID, now, now); err != nil {
t.Fatalf("seed task %s: %v", taskID, err)
}
if _, err := repo.db.Exec(repo.db.Rebind(`
INSERT INTO task_sessions (id, task_id, started_at, updated_at)
VALUES (?, ?, ?, ?)
`), sessionID, taskID, now, now); err != nil {
t.Fatalf("seed session %s: %v", sessionID, err)
}
}
// TestPostgresBackfillSessionTokensCachedIn_SumsLedger is the Postgres
// counterpart to TestBackfillSessionTokensCachedIn_SumsLedger: proves the
// correlated-subquery UPDATE and its CREATE INDEX IF NOT EXISTS both work
// against a real Postgres instance, not just SQLite.
func TestPostgresBackfillSessionTokensCachedIn_SumsLedger(t *testing.T) {
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
repo, err := NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("init postgres schema: %v", err)
}
seedPostgresTaskSession(t, repo, "task-backfill-pg", "sess-backfill-pg")
createOfficeCostEventsTable(t, repo)
insertOfficeCostEvent(t, repo, "cost-1", "sess-backfill-pg", 98_805_109)
insertOfficeCostEvent(t, repo, "cost-2", "sess-backfill-pg", 1_194_891)
insertOfficeCostEvent(t, repo, "cost-other", "sess-other-pg", 555)
if _, err := repo.BackfillSessionTokensCachedIn(context.Background()); err != nil {
t.Fatalf("backfill: %v", err)
}
var got int64
if err := repo.ro.QueryRowx(repo.ro.Rebind(
`SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-backfill-pg",
).Scan(&got); err != nil {
t.Fatalf("read row: %v", err)
}
if got != 100_000_000 {
t.Errorf("tokens_cached_in = %d, want 100000000 (sum of office_cost_events for this session)", got)
}
}
// TestPostgresBackfillSessionTokensCachedIn_HandlesValuesBeyondInt32Range is
// the regression test for F8: task_sessions.tokens_cached_in was declared
// INTEGER (int4 on Postgres, max 2,147,483,647). Because the backfill is a
// single UPDATE across the whole task_sessions table, one session whose
// ledger sum exceeds the int4 ceiling made the entire statement fail with
// "integer out of range" — swallowed to a Warn by MigrateLogger.Apply, so the
// self-healing recompute silently stopped working for every session, not just
// the overflowing one. Fails on an INTEGER column, passes once the column is
// BIGINT.
func TestPostgresBackfillSessionTokensCachedIn_HandlesValuesBeyondInt32Range(t *testing.T) {
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
repo, err := NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("init postgres schema: %v", err)
}
seedPostgresTaskSession(t, repo, "task-overflow-pg", "sess-overflow-pg")
seedPostgresTaskSession(t, repo, "task-normal-pg", "sess-normal-pg")
createOfficeCostEventsTable(t, repo)
// 200 events of 14,000,000 cached tokens each = 2,800,000,000, well past
// the int32 ceiling of 2,147,483,647 - the shape the card's own live-instance
// measurement (98,805,109 on a single completed task) extrapolates to over
// a long-running session.
const perEvent = int64(14_000_000)
const eventCount = 200
const wantOverflow = perEvent * eventCount
for i := 0; i < eventCount; i++ {
insertOfficeCostEvent(t, repo, fmt.Sprintf("cost-overflow-%d", i), "sess-overflow-pg", perEvent)
}
// A normal-sized session must still backfill correctly in the same pass -
// the whole point of F8 is that one overflowing row previously took the
// entire UPDATE down with it.
insertOfficeCostEvent(t, repo, "cost-normal", "sess-normal-pg", 1_000)
if _, err := repo.BackfillSessionTokensCachedIn(context.Background()); err != nil {
t.Fatalf("backfill must not error on a ledger sum beyond int32 range: %v", err)
}
var gotOverflow, gotNormal int64
if err := repo.ro.QueryRowx(repo.ro.Rebind(
`SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-overflow-pg",
).Scan(&gotOverflow); err != nil {
t.Fatalf("read overflow row: %v", err)
}
if gotOverflow != wantOverflow {
t.Errorf("tokens_cached_in = %d, want %d (sum beyond int32 range must survive the backfill)",
gotOverflow, wantOverflow)
}
if err := repo.ro.QueryRowx(repo.ro.Rebind(
`SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-normal-pg",
).Scan(&gotNormal); err != nil {
t.Fatalf("read normal row: %v", err)
}
if gotNormal != 1_000 {
t.Errorf("tokens_cached_in for the non-overflowing session = %d, want 1000 "+
"(a sibling session's overflow must not abort the whole backfill statement)", gotNormal)
}
}
// TestPostgresIncrementTaskSessionUsage_AccumulatesValuesBeyondInt32Range
// covers F8's other failure mode: IncrementTaskSessionUsage sets tokens_in,
// tokens_cached_in, tokens_out and cost_subcents in one UPDATE. On an INTEGER
// column that UPDATE fails once tokens_cached_in crosses the int32 ceiling,
// silently stopping tokens_in/tokens_out/cost_subcents from updating too -
// columns the card states reconcile correctly today. Fails on an INTEGER
// column, passes once the column is BIGINT.
func TestPostgresIncrementTaskSessionUsage_AccumulatesValuesBeyondInt32Range(t *testing.T) {
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
repo, err := NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("init postgres schema: %v", err)
}
ctx := context.Background()
seedPostgresTaskSession(t, repo, "task-inc-overflow-pg", "sess-inc-overflow-pg")
const beyondInt32 = int64(2_800_000_000)
if err := repo.IncrementTaskSessionUsage(ctx, "sess-inc-overflow-pg", 100, beyondInt32, 200, 50); err != nil {
t.Fatalf("IncrementTaskSessionUsage must not error on a cached-token delta beyond int32 range: %v", err)
}
var tokensIn, tokensCachedIn, tokensOut, costSubcents int64
if err := repo.ro.QueryRowx(repo.ro.Rebind(
`SELECT tokens_in, tokens_cached_in, tokens_out, cost_subcents FROM task_sessions WHERE id = ?`),
"sess-inc-overflow-pg").Scan(&tokensIn, &tokensCachedIn, &tokensOut, &costSubcents); err != nil {
t.Fatalf("read row: %v", err)
}
if tokensIn != 100 || tokensCachedIn != beyondInt32 || tokensOut != 200 || costSubcents != 50 {
t.Errorf("totals = (%d,%d,%d,%d), want (100,%d,200,50) - a cached-token delta beyond int32 "+
"range must not roll back tokens_in/tokens_out/cost_subcents in the same statement",
tokensIn, tokensCachedIn, tokensOut, costSubcents, beyondInt32)
}
}
@@ -0,0 +1,235 @@
package sqlite
import (
"context"
"testing"
"time"
)
// TestIncrementTaskSessionUsage_AccumulatesCachedInAcrossCalls confirms
// tokens_cached_in compounds the same way tokens_in/tokens_out/cost_subcents
// already do. Before the fix this column didn't exist and the parameter
// couldn't be threaded at all.
func TestIncrementTaskSessionUsage_AccumulatesCachedInAcrossCalls(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedForMsgTest(t, repo, "task-cached-usage", "sess-cached-usage", "turn-cached-usage")
if err := repo.IncrementTaskSessionUsage(ctx, "sess-cached-usage", 100, 50_000_000, 200, 50); err != nil {
t.Fatalf("first increment: %v", err)
}
if err := repo.IncrementTaskSessionUsage(ctx, "sess-cached-usage", 10, 2_000, 20, 5); err != nil {
t.Fatalf("second increment: %v", err)
}
var tokensIn, tokensCachedIn, tokensOut, costSubcents int64
err := repo.ro.QueryRowx(repo.ro.Rebind(
`SELECT tokens_in, tokens_cached_in, tokens_out, cost_subcents FROM task_sessions WHERE id = ?`),
"sess-cached-usage").Scan(&tokensIn, &tokensCachedIn, &tokensOut, &costSubcents)
if err != nil {
t.Fatalf("read row: %v", err)
}
if tokensIn != 110 || tokensCachedIn != 50_002_000 || tokensOut != 220 || costSubcents != 55 {
t.Errorf("totals = (%d,%d,%d,%d), want (110,50002000,220,55)",
tokensIn, tokensCachedIn, tokensOut, costSubcents)
}
}
// createOfficeCostEventsTable creates a minimal standalone copy of
// office_cost_events (owned by internal/office/repository/sqlite) so this
// package's migration tests can exercise the guarded backfill without
// importing the office package (which would create an import cycle back
// into internal/task). Column set mirrors office/repository/sqlite/base.go
// createCostTables.
func createOfficeCostEventsTable(t *testing.T, repo *Repository) {
t.Helper()
if _, err := repo.db.Exec(`
CREATE TABLE IF NOT EXISTS office_cost_events (
id TEXT PRIMARY KEY,
session_id TEXT DEFAULT '',
task_id TEXT DEFAULT '',
agent_profile_id TEXT DEFAULT '',
project_id TEXT DEFAULT '',
model TEXT DEFAULT '',
provider TEXT DEFAULT '',
tokens_in INTEGER DEFAULT 0,
tokens_cached_in INTEGER DEFAULT 0,
tokens_out INTEGER DEFAULT 0,
cost_subcents INTEGER NOT NULL DEFAULT 0,
estimated INTEGER NOT NULL DEFAULT 0,
occurred_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL
)`); err != nil {
t.Fatalf("create office_cost_events: %v", err)
}
}
func insertOfficeCostEvent(t *testing.T, repo *Repository, id, sessionID string, tokensCachedIn int64) {
t.Helper()
now := time.Now().UTC()
_, err := repo.db.Exec(repo.db.Rebind(`
INSERT INTO office_cost_events (id, session_id, tokens_cached_in, occurred_at, created_at)
VALUES (?, ?, ?, ?, ?)
`), id, sessionID, tokensCachedIn, now, now)
if err != nil {
t.Fatalf("insert office_cost_event %s: %v", id, err)
}
}
// TestBackfillSessionTokensCachedIn_SumsLedger proves the backfill invariant
// directly: task_sessions.tokens_cached_in must equal the sum of
// office_cost_events.tokens_cached_in for that session. This is the
// reconciliation the card asked for, exercised at the SQL layer.
func TestBackfillSessionTokensCachedIn_SumsLedger(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedForMsgTest(t, repo, "task-backfill", "sess-backfill", "turn-backfill")
createOfficeCostEventsTable(t, repo)
insertOfficeCostEvent(t, repo, "cost-1", "sess-backfill", 98_805_109)
insertOfficeCostEvent(t, repo, "cost-2", "sess-backfill", 1_194_891)
// A row for a different session must not leak into this session's total.
insertOfficeCostEvent(t, repo, "cost-other", "sess-other", 555)
if _, err := repo.BackfillSessionTokensCachedIn(ctx); err != nil {
t.Fatalf("backfill: %v", err)
}
var got int64
if err := repo.ro.QueryRowx(repo.ro.Rebind(
`SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-backfill",
).Scan(&got); err != nil {
t.Fatalf("read row: %v", err)
}
if got != 100_000_000 {
t.Errorf("tokens_cached_in = %d, want 100000000 (sum of office_cost_events for this session)", got)
}
// Idempotent: re-running against an unchanged ledger must not
// double-count the already-correct total.
if _, err := repo.BackfillSessionTokensCachedIn(ctx); err != nil {
t.Fatalf("second backfill: %v", err)
}
if err := repo.ro.QueryRowx(repo.ro.Rebind(
`SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-backfill",
).Scan(&got); err != nil {
t.Fatalf("read row after second pass: %v", err)
}
if got != 100_000_000 {
t.Errorf("tokens_cached_in after second backfill pass = %d, want unchanged 100000000", got)
}
}
// TestBackfillSessionTokensCachedIn_CorrectsDriftedRollup covers the case an
// unconditional recompute exists to repair: a session whose rollup went out
// of sync with the ledger without ever erroring. IncrementTaskSessionUsage
// is a best-effort `UPDATE ... WHERE id = ?` that silently matches zero rows
// if the session row doesn't exist yet (see
// TestIncrementTaskSessionUsage_UnknownSessionNoError), and the office
// subscriber only logs a Warn when that write fails while the ledger insert
// has already committed (event_subscribers.go handlePromptUsage). A backfill
// gated on "already nonzero" would treat this drifted-but-nonzero row as
// already correct and skip it forever; the unconditional recompute must
// instead overwrite it with the true ledger sum.
func TestBackfillSessionTokensCachedIn_CorrectsDriftedRollup(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedForMsgTest(t, repo, "task-drifted", "sess-drifted", "turn-drifted")
createOfficeCostEventsTable(t, repo)
insertOfficeCostEvent(t, repo, "cost-1", "sess-drifted", 98_805_109)
insertOfficeCostEvent(t, repo, "cost-2", "sess-drifted", 1_194_891)
// Simulate the drift directly: the rollup already holds a nonzero but
// wrong value, as it would after a missed increment.
if err := repo.IncrementTaskSessionUsage(ctx, "sess-drifted", 0, 2_000, 0, 0); err != nil {
t.Fatalf("seed drifted rollup: %v", err)
}
var before int64
if err := repo.ro.QueryRowx(repo.ro.Rebind(
`SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-drifted",
).Scan(&before); err != nil {
t.Fatalf("read row before backfill: %v", err)
}
if before != 2_000 {
t.Fatalf("test setup bug: rollup = %d, want the drifted seed value 2000", before)
}
if _, err := repo.BackfillSessionTokensCachedIn(ctx); err != nil {
t.Fatalf("backfill: %v", err)
}
var got int64
if err := repo.ro.QueryRowx(repo.ro.Rebind(
`SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-drifted",
).Scan(&got); err != nil {
t.Fatalf("read row after backfill: %v", err)
}
if got != 100_000_000 {
t.Errorf("tokens_cached_in after backfill = %d, want 100000000 (the drifted 2000 must be "+
"overwritten with the full ledger sum, not skipped as already-nonzero)", got)
}
}
// TestBackfillSessionTokensCachedIn_ZeroesRollupWithNoMatchingLedgerRows pins
// the behaviour when the ledger table exists but has no rows for a session
// whose rollup is already nonzero (e.g. the ledger rows for that session were
// deleted separately from the session itself - see workspace_deletion.go,
// which deletes office_cost_events before the task-side workspace in two
// separate transactions). The unconditional recompute is a COALESCE(...,0),
// so it deliberately zeroes tokens_cached_in in this case rather than leaving
// the stale value: the rollup is supposed to always equal the ledger sum for
// that session, and an empty ledger sums to zero.
func TestBackfillSessionTokensCachedIn_ZeroesRollupWithNoMatchingLedgerRows(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedForMsgTest(t, repo, "task-orphaned", "sess-orphaned", "turn-orphaned")
createOfficeCostEventsTable(t, repo)
// Rollup already holds a nonzero value, but no office_cost_events rows
// exist for this session (simulates the ledger having been deleted).
if err := repo.IncrementTaskSessionUsage(ctx, "sess-orphaned", 0, 12_345, 0, 0); err != nil {
t.Fatalf("seed rollup: %v", err)
}
if _, err := repo.BackfillSessionTokensCachedIn(ctx); err != nil {
t.Fatalf("backfill: %v", err)
}
var got int64
if err := repo.ro.QueryRowx(repo.ro.Rebind(
`SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-orphaned",
).Scan(&got); err != nil {
t.Fatalf("read row: %v", err)
}
if got != 0 {
t.Errorf("tokens_cached_in = %d, want 0 (no matching ledger rows sums to zero)", got)
}
}
// TestBackfillSessionTokensCachedIn_SkipsAlreadyCorrectUntouchedSessions pins
// the boot-cost optimization: a session with no office_cost_events rows and a
// tokens_cached_in already at its default of 0 must be excluded by the
// UPDATE's WHERE clause, not just happen to compute the same value. Proven
// via the reported affected-row count (matches SQL UPDATE semantics on both
// SQLite and Postgres: rows matched by WHERE, not rows whose value changed),
// comparing a session the WHERE clause must match (has ledger rows) against
// one it must not (no ledger rows, untouched default).
func TestBackfillSessionTokensCachedIn_SkipsAlreadyCorrectUntouchedSessions(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedForMsgTest(t, repo, "task-untouched", "sess-untouched", "turn-untouched")
seedForMsgTest(t, repo, "task-with-ledger", "sess-with-ledger", "turn-with-ledger")
createOfficeCostEventsTable(t, repo)
insertOfficeCostEvent(t, repo, "cost-1", "sess-with-ledger", 500)
// sess-untouched: no office_cost_events rows, tokens_cached_in stays at
// its untouched default of 0.
affected, err := repo.BackfillSessionTokensCachedIn(ctx)
if err != nil {
t.Fatalf("backfill: %v", err)
}
if affected != 1 {
t.Errorf("rows affected = %d, want 1 (only sess-with-ledger should match the WHERE clause; "+
"sess-untouched has no ledger rows and was already 0)", affected)
}
}
+2 -2
View File
@@ -68,7 +68,7 @@ Multiple policies can apply to the same scope (e.g. monthly + total).
### `TaskSession` additions
`cost_subcents`, `tokens_in`, `tokens_out` incrementally updated as cost events arrive, providing quick per-session totals without scanning `office_cost_events`.
`cost_subcents`, `tokens_in`, `tokens_out`, `tokens_cached_in` incrementally updated as cost events arrive, providing quick per-session totals without scanning `office_cost_events`. `tokens_cached_in` is DB-only today (no model/DTO/API/web reader) — it exists so the rollup reconciles column-for-column against the ledger; a future cost-explorer surface will expose it.
### Per-agent budget
@@ -187,7 +187,7 @@ There is no per-field permission model. Conformance tests should assert that cos
- `office_cost_events` rows (full history, never trimmed). Disk-runner rows keyed by `(session_id, provider_event_id)` survive re-ingestion without duplicating; the wire-side `estimated=true` rows for codex are deleted once the matching aggregate row lands.
- `office_budget_policies` rows.
- `TaskSession.cost_subcents` / `tokens_in` / `tokens_out` running totals, kept in sync with `office_cost_events` so per-session totals are correct without a re-scan.
- `TaskSession.cost_subcents` / `tokens_in` / `tokens_out` / `tokens_cached_in` running totals, kept in sync with `office_cost_events` so per-session totals are correct without a re-scan.
- Per-agent `budget_monthly_cents` stored on the agent instance row.
- Per-model pricing overrides stored in workspace settings.
- The on-disk `models.dev` cache at `<data-dir>/cache/models-dev.json`. Recovery on next boot: the in-memory pricing map is empty until first query; queries fall back to the on-disk file when the background refresh has not yet completed.