AI budget: allowance + capped pool + the sidecar's honest exhaustion gate (HELD for review) (#879)
* bot+cloud: AI budget machinery — allowance, pool, exhaustion gate * cloud+bot: AI budget — allowance, fleet pool, exhaustion gate (phase 3 machinery) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
13 files changed,
+339
−13
cloud.go+17 −0cloud_budget.go+116 −0cloud_budget_test.go+94 −0cloud_db.go+27 −0cloud_usage.go+4 −4internal/bot/appbuild.go+1 −1internal/bot/appbuild_test.go+5 −5internal/bot/brain.go+10 −0internal/bot/brain_test.go+3 −3internal/bot/meter.go+22 −0internal/bot/meter_test.go+33 −0internal/bot/runtime.go+2 −0internal/bot/task.go+5 −0
diff --git a/cloud.go b/cloud.go| index 9e75bb8..f147f4e 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -94,6 +94,12 @@ type cloudServer struct { |
| // sidecar usage reports can flow (#857). Empty = the usage pipe is off. |
| usageURL string |
| pool poolConfig // free-pool gauge (pool.go); maxShare 0 = off |
| + // The AI budget (cloud_budget.go, phase 3): per-model EUR rates, the |
| + // per-space monthly allowance, and the fleet-wide monthly pool. All three |
| + // unset = measure-only (every flush answers "ok"). |
| + aiRates map[string]aiRates |
| + aiAllowanceEUR float64 // LCHAT_CLOUD_AI_ALLOWANCE_EUR |
| + aiPoolEUR float64 // LCHAT_CLOUD_AI_POOL_EUR |
| } |
| func (s *cloudServer) handler() http.Handler { |
| @@ -539,6 +545,17 @@ func runCloud(args []string) { |
| if s.usageURL != "" { |
| log.Printf("cloud: usage pipe on — meter tokens push %s", s.usageURL) |
| } |
| + aiRatesCfg, err := parseAIRates(strings.TrimSpace(os.Getenv("LCHAT_CLOUD_AI_RATES"))) |
| + if err != nil { |
| + die("cloud: %v", err) |
| + } |
| + s.aiRates = aiRatesCfg |
| + s.aiAllowanceEUR = envFloat("LCHAT_CLOUD_AI_ALLOWANCE_EUR", 0) |
| + s.aiPoolEUR = envFloat("LCHAT_CLOUD_AI_POOL_EUR", 0) |
| + if len(s.aiRates) > 0 && s.aiAllowanceEUR > 0 { |
| + log.Printf("cloud: AI budget on — allowance €%.2f/space/month, pool €%.2f/month (%d models priced)", |
| + s.aiAllowanceEUR, s.aiPoolEUR, len(s.aiRates)) |
| + } |
| if *createBudget > 0 { |
| log.Printf("cloud: creation budget on (%d/day fleet-wide)", *createBudget) |
| } |
diff --git a/cloud_budget.go b/cloud_budget.go| new file mode 100644 |
| index 0000000..1584d14 |
| --- /dev/null |
| +++ b/cloud_budget.go |
| @@ -0,0 +1,116 @@ |
| +// cloud_budget.go — phase 3 of docs/ai-provisioning.md: the allowance and |
| +// pool math that turns the usage ledger (#857) into a budget answer. Money |
| +// only ever appears here as CONFIG (rates, allowance, pool — env, set by the |
| +// operator) and as arithmetic over the ledger's quantities; no payment |
| +// machinery lives in this file. The forwarding of over-allowance usage to |
| +// Stripe Billing Meters is deliberately NOT built yet — it lands with #859's |
| +// AI add-on product, after the rates and allowance below are reviewed and |
| +// published in docs/tier-economics.md. |
| +// |
| +// The budget model, per the locked decisions (Paul 2026-07-14): |
| +// - Every space gets a small included monthly allowance (in EUR of model |
| +// cost, computed from per-model rates over the four token counters). |
| +// - A fleet-wide monthly pool bounds the aggregate subsidy: however many |
| +// free spaces opt in, the total included spend has a ceiling — the |
| +// creates_log circuit-breaker pattern, applied to tokens. |
| +// - Exhausted (space over allowance, or pool drained) means the sidecar |
| +// drops to the deterministic command floor with one honest notice |
| +// (internal/bot/meter.go); nothing breaks, nothing is deleted. |
| +// - Unconfigured = measure-only: every report answers "ok", exactly the |
| +// phase-1 behavior. BYO-key and self-hosted sidecars never report at all. |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "log" |
| + "time" |
| +) |
| + |
| +// aiRates prices one model's tokens, EUR per million, one rate per counter — |
| +// cache reads and writes price differently from fresh input, which is why the |
| +// ledger keeps all four dimensions distinct. |
| +type aiRates struct { |
| + In float64 `json:"in"` |
| + Out float64 `json:"out"` |
| + CacheWrite float64 `json:"cw"` |
| + CacheRead float64 `json:"cr"` |
| +} |
| + |
| +// parseAIRates reads LCHAT_CLOUD_AI_RATES — a JSON object keyed by model id, |
| +// e.g. {"claude-sonnet-5":{"in":3,"out":15,"cw":3.75,"cr":0.3}}. Rates are |
| +// EUR per million tokens. Empty = no pricing = the budget never bites. |
| +func parseAIRates(v string) (map[string]aiRates, error) { |
| + if v == "" { |
| + return nil, nil |
| + } |
| + rates := map[string]aiRates{} |
| + if err := json.Unmarshal([]byte(v), &rates); err != nil { |
| + return nil, fmt.Errorf("bad LCHAT_CLOUD_AI_RATES: %v", err) |
| + } |
| + for model, r := range rates { |
| + if r.In < 0 || r.Out < 0 || r.CacheWrite < 0 || r.CacheRead < 0 { |
| + return nil, fmt.Errorf("negative rate for %q", model) |
| + } |
| + } |
| + return rates, nil |
| +} |
| + |
| +// budgetMonth is the allowance window — a UTC calendar month, matching the |
| +// ledger's day grain. |
| +func budgetMonth() string { return time.Now().UTC().Format("2006-01") } |
| + |
| +// usageCostEUR prices ledger rows against the rate book. Unknown models and |
| +// non-token dimensions cost zero — activity counters (charter_runs) are |
| +// measurement, not money, and a model missing from the rate book is an |
| +// operator gap worth logging, not a reason to guess a price. |
| +func usageCostEUR(rows []botUsageRow, rates map[string]aiRates) float64 { |
| + var eur float64 |
| + for _, row := range rows { |
| + r, ok := rates[row.Model] |
| + if !ok { |
| + if row.Model != "" { |
| + log.Printf("cloud: budget: no rate for model %q — its tokens price as 0 (add it to LCHAT_CLOUD_AI_RATES)", row.Model) |
| + } |
| + continue |
| + } |
| + perM := map[string]float64{ |
| + "llm_input_tokens": r.In, |
| + "llm_output_tokens": r.Out, |
| + "llm_cache_write_tokens": r.CacheWrite, |
| + "llm_cache_read_tokens": r.CacheRead, |
| + }[row.Dimension] |
| + eur += float64(row.Quantity) / 1e6 * perM |
| + } |
| + return eur |
| +} |
| + |
| +// budgetState answers one space's state for a flush response: "ok" or |
| +// "exhausted". Ordered cheap-to-dear: no config short-circuits before any |
| +// ledger read, the per-space allowance before the fleet-wide sum. |
| +func (s *cloudServer) budgetState(spaceID int64) string { |
| + if len(s.aiRates) == 0 || s.aiAllowanceEUR <= 0 { |
| + return "ok" // measure-only until the operator prices the budget |
| + } |
| + month := budgetMonth() |
| + rows, err := s.db.usageForMonth(spaceID, month) |
| + if err != nil { |
| + log.Printf("cloud: budget: space %d month read: %v", spaceID, err) |
| + return "ok" // a ledger blip must never silence a space's bot |
| + } |
| + if usageCostEUR(rows, s.aiRates) >= s.aiAllowanceEUR { |
| + return "exhausted" |
| + } |
| + if s.aiPoolEUR > 0 { |
| + fleet, err := s.db.fleetUsageForMonth(month) |
| + if err != nil { |
| + log.Printf("cloud: budget: fleet month read: %v", err) |
| + return "ok" |
| + } |
| + if usageCostEUR(fleet, s.aiRates) >= s.aiPoolEUR { |
| + log.Printf("cloud: budget: the fleet AI pool for %s is spent — subsidized AI paused fleet-wide", month) |
| + return "exhausted" |
| + } |
| + } |
| + return "ok" |
| +} |
diff --git a/cloud_budget_test.go b/cloud_budget_test.go| new file mode 100644 |
| index 0000000..4c038d0 |
| --- /dev/null |
| +++ b/cloud_budget_test.go |
| @@ -0,0 +1,94 @@ |
| +package main |
| + |
| +import ( |
| + "net/http" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func testRates() map[string]aiRates { |
| + return map[string]aiRates{ |
| + // Round numbers so the arithmetic below reads at a glance: |
| + // €10/M in, €50/M out, €12.50/M cache-write, €1/M cache-read. |
| + "claude-sonnet-5": {In: 10, Out: 50, CacheWrite: 12.5, CacheRead: 1}, |
| + } |
| +} |
| + |
| +func TestParseAIRates(t *testing.T) { |
| + rates, err := parseAIRates(`{"claude-sonnet-5":{"in":3,"out":15,"cw":3.75,"cr":0.3}}`) |
| + if err != nil || rates["claude-sonnet-5"].Out != 15 { |
| + t.Fatalf("parse: %v %+v", err, rates) |
| + } |
| + if r, err := parseAIRates(""); err != nil || r != nil { |
| + t.Fatalf("empty must be off: %v %v", r, err) |
| + } |
| + if _, err := parseAIRates(`{"m":{"in":-1}}`); err == nil { |
| + t.Fatal("negative rates must be refused") |
| + } |
| + if _, err := parseAIRates(`nonsense`); err == nil { |
| + t.Fatal("malformed JSON must be refused") |
| + } |
| +} |
| + |
| +func TestUsageCostEUR(t *testing.T) { |
| + rows := []botUsageRow{ |
| + {Model: "claude-sonnet-5", Dimension: "llm_input_tokens", Quantity: 1_000_000}, // €10 |
| + {Model: "claude-sonnet-5", Dimension: "llm_output_tokens", Quantity: 200_000}, // €10 |
| + {Model: "claude-sonnet-5", Dimension: "llm_cache_read_tokens", Quantity: 3_000_000}, // €3 |
| + {Model: "claude-sonnet-5", Dimension: "llm_requests", Quantity: 42}, // request counts are free |
| + {Dimension: "charter_runs", Quantity: 7}, // activity, not money |
| + {Model: "unpriced-model", Dimension: "llm_output_tokens", Quantity: 1_000_000}, // no rate → 0 + a log line |
| + } |
| + if got := usageCostEUR(rows, testRates()); got != 23 { |
| + t.Fatalf("cost = %v, want 23", got) |
| + } |
| +} |
| + |
| +// The state machine end to end through the ingest handler: under allowance is |
| +// ok, over allowance is exhausted, and a drained fleet pool exhausts a space |
| +// that is itself under its allowance. |
| +func TestBudgetStateThroughIngest(t *testing.T) { |
| + db := openTestCloudDB(t) |
| + a := insertSpaceRow(t, db, "a.11ch.at", 1000) |
| + b := insertSpaceRow(t, db, "b.11ch.at", 1000) |
| + for id, tok := range map[int64]string{a: "tok-a", b: "tok-b"} { |
| + if err := db.setMeterTokenHash(id, hashToken(tok)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + s := &cloudServer{db: db, aiRates: testRates(), aiAllowanceEUR: 5, aiPoolEUR: 12} |
| + day := time.Now().UTC().Format("2006-01-02") |
| + |
| + report := func(tok, reportID string, outTokens int64) map[string]any { |
| + t.Helper() |
| + code, body := ingestUsageReport(t, s, tok, reportID, []botUsageRow{ |
| + {Day: day, Model: "claude-sonnet-5", Dimension: "llm_output_tokens", Quantity: outTokens}}) |
| + if code != http.StatusOK { |
| + t.Fatalf("ingest: %d %v", code, body) |
| + } |
| + return body["budget"].(map[string]any) |
| + } |
| + |
| + // €2 of output: comfortably inside the €5 allowance. |
| + if st := report("tok-a", "aaaaaaaaaaaaaaa1", 40_000); st["state"] != "ok" { |
| + t.Fatalf("under allowance: %v", st) |
| + } |
| + // Another €4 → €6 total: over the per-space allowance. |
| + if st := report("tok-a", "aaaaaaaaaaaaaaa2", 80_000); st["state"] != "exhausted" { |
| + t.Fatalf("over allowance: %v", st) |
| + } |
| + // Space B is fresh (€4 < €5 allowance)… but the fleet pool (€12) is now |
| + // €6 + €4 = €10; one more €4 pushes past it and B exhausts too. |
| + if st := report("tok-b", "bbbbbbbbbbbbbbb1", 80_000); st["state"] != "ok" { |
| + t.Fatalf("pool not yet drained: %v", st) |
| + } |
| + if st := report("tok-b", "bbbbbbbbbbbbbbb2", 80_000); st["state"] != "exhausted" { |
| + t.Fatalf("pool drained must exhaust: %v", st) |
| + } |
| + |
| + // Unconfigured stays measure-only whatever the ledger says. |
| + s2 := &cloudServer{db: db} |
| + if st := s2.budgetState(a); st != "ok" { |
| + t.Fatalf("unconfigured: %q", st) |
| + } |
| +} |
diff --git a/cloud_db.go b/cloud_db.go| index 1f93c36..7eadfc7 100644 |
| --- a/cloud_db.go |
| +++ b/cloud_db.go |
| @@ -174,6 +174,29 @@ func (db *cloudDB) pruneUsageReports(cutoff int64) error { |
| return err |
| } |
| +// usageForMonth reads one space's ledger rows for a UTC month ("2006-01") — |
| +// the budget's allowance window (cloud_budget.go). |
| +func (db *cloudDB) usageForMonth(spaceID int64, month string) ([]botUsageRow, error) { |
| + rows, err := db.sql.Query( |
| + `SELECT day, model, dimension, quantity FROM usage WHERE space_id = ? AND day LIKE ?`, |
| + spaceID, month+"-%") |
| + if err != nil { |
| + return nil, err |
| + } |
| + return scanUsageRows(rows) |
| +} |
| + |
| +// fleetUsageForMonth sums every space's rows for a month — the pool's view. |
| +func (db *cloudDB) fleetUsageForMonth(month string) ([]botUsageRow, error) { |
| + rows, err := db.sql.Query( |
| + `SELECT day, model, dimension, SUM(quantity) FROM usage WHERE day LIKE ? |
| + GROUP BY day, model, dimension`, month+"-%") |
| + if err != nil { |
| + return nil, err |
| + } |
| + return scanUsageRows(rows) |
| +} |
| + |
| // usageFor reads a space's ledger rows (tests + the future usage surface). |
| func (db *cloudDB) usageFor(spaceID int64) ([]botUsageRow, error) { |
| rows, err := db.sql.Query( |
| @@ -181,6 +204,10 @@ func (db *cloudDB) usageFor(spaceID int64) ([]botUsageRow, error) { |
| if err != nil { |
| return nil, err |
| } |
| + return scanUsageRows(rows) |
| +} |
| + |
| +func scanUsageRows(rows *sql.Rows) ([]botUsageRow, error) { |
| defer rows.Close() |
| var out []botUsageRow |
| for rows.Next() { |
diff --git a/cloud_usage.go b/cloud_usage.go| index 351ee0e..aaf9984 100644 |
| --- a/cloud_usage.go |
| +++ b/cloud_usage.go |
| @@ -47,10 +47,10 @@ func (s *cloudServer) usageHandler(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "ledger error"}) |
| return |
| } |
| - // Budget is phase 3 (docs/ai-provisioning.md): allowance and pool math |
| - // land here; until then the meter measures and everything is within |
| - // budget by definition. |
| - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "budget": map[string]any{"state": "ok"}}) |
| + // The budget answer rides the flush response back to the sidecar — no |
| + // extra channel. Unconfigured, budgetState is a constant "ok" and this is |
| + // exactly phase 1's behavior (cloud_budget.go). |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "budget": map[string]any{"state": s.budgetState(spaceID)}}) |
| } |
| // aiOptInHandler provisions a space's bot sidecar when its admin switches AI |
diff --git a/internal/bot/appbuild.go b/internal/bot/appbuild.go| index 50661ac..9a620e2 100644 |
| --- a/internal/bot/appbuild.go |
| +++ b/internal/bot/appbuild.go |
| @@ -682,7 +682,7 @@ func (r *Runtime) runAppBuild(tc *ThreadCtx, req appBuildReq, requester, request |
| // generateApp is the dedicated build call (+ regenerate-on-validation-failure |
| // loop). Returns the validated source and its event types. |
| func (r *Runtime) generateApp(req appBuildReq, prevSource string) (string, []string, error) { |
| - if r.ai == nil || r.appGuide == "" { |
| + if r.ai == nil || r.appGuide == "" || r.budgetExhausted() { |
| return "", nil, fmt.Errorf("app building isn't available right now.") |
| } |
| system := []anthBlock{ |
diff --git a/internal/bot/appbuild_test.go b/internal/bot/appbuild_test.go| index 1bb324a..86ee5c1 100644 |
| --- a/internal/bot/appbuild_test.go |
| +++ b/internal/bot/appbuild_test.go |
| @@ -127,11 +127,11 @@ func TestParseAppBuildReq(t *testing.T) { |
| t.Fatalf("good req rejected: %v", err) |
| } |
| bad := []map[string]any{ |
| - {"name": "Habits", "title": "T", "glyph": "🌱", "spec": "s"}, // uppercase name |
| - {"name": "9habits", "title": "T", "glyph": "🌱", "spec": "s"}, // starts with digit |
| - {"name": "habits", "title": "", "glyph": "🌱", "spec": "s"}, // empty title |
| - {"name": "habits", "title": "T", "glyph": "", "spec": "s"}, // empty glyph |
| - {"name": "habits", "title": "T", "glyph": "🌱", "spec": ""}, // empty spec |
| + {"name": "Habits", "title": "T", "glyph": "🌱", "spec": "s"}, // uppercase name |
| + {"name": "9habits", "title": "T", "glyph": "🌱", "spec": "s"}, // starts with digit |
| + {"name": "habits", "title": "", "glyph": "🌱", "spec": "s"}, // empty title |
| + {"name": "habits", "title": "T", "glyph": "", "spec": "s"}, // empty glyph |
| + {"name": "habits", "title": "T", "glyph": "🌱", "spec": ""}, // empty spec |
| {"name": "habits", "title": "T", "glyph": "🌱", "spec": "s", "app_id": "NO"}, // bad app_id |
| } |
| for i, in := range bad { |
diff --git a/internal/bot/brain.go b/internal/bot/brain.go| index 84478ff..3913f4e 100644 |
| --- a/internal/bot/brain.go |
| +++ b/internal/bot/brain.go |
| @@ -459,6 +459,16 @@ func (r *Runtime) modelReply(tc *ThreadCtx) (Reply, bool) { |
| if r.ai == nil { |
| return Reply{}, false |
| } |
| + if r.budgetExhausted() { |
| + // The space's AI budget is spent (phase 3, docs/ai-provisioning.md): |
| + // explain once, honestly, then keep to the command floor. Per the |
| + // conduct rule (knowledge.md §20) the notice describes where the |
| + // admin acts — never a payment link, never a repeated upsell. |
| + if r.noteBudgetExhausted() { |
| + return Reply{Text: "I've used this space's included AI for now, so I'm sticking to plain commands — try /help for what still works. An admin can bring the AI back from the Admin app."}, true |
| + } |
| + return Reply{}, false |
| + } |
| // Best-effort "…is typing", re-beaten for the whole generation: a single |
| // beat's 15s expiry lapses under a long thinking run. |
| stop := make(chan struct{}) |
diff --git a/internal/bot/brain_test.go b/internal/bot/brain_test.go| index 383a703..b635ec1 100644 |
| --- a/internal/bot/brain_test.go |
| +++ b/internal/bot/brain_test.go |
| @@ -126,9 +126,9 @@ func TestActionsFromToolUseAppCommand(t *testing.T) { |
| t.Fatalf("chip: %+v", got) |
| } |
| bad := actionsFromToolUse([]anthBlock{ |
| - {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "remind me at 4"}}, // no slash |
| - {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "/todo @here buy milk"}}, // group side effect |
| - {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "/todo a\nb"}}, // multi-line |
| + {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "remind me at 4"}}, // no slash |
| + {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "/todo @here buy milk"}}, // group side effect |
| + {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "/todo a\nb"}}, // multi-line |
| {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "/" + strings.Repeat("x", 300)}}, // too long |
| }) |
| if len(bad) != 0 { |
diff --git a/internal/bot/meter.go b/internal/bot/meter.go| index a6610e7..01752d1 100644 |
| --- a/internal/bot/meter.go |
| +++ b/internal/bot/meter.go |
| @@ -188,5 +188,27 @@ func (r *Runtime) flushUsage() { |
| r.pendingUsage = nil |
| if resp.Budget.State != "" { |
| r.budgetState.Store(resp.Budget.State) |
| + if resp.Budget.State != "exhausted" { |
| + // Back within budget (a new month, a raised allowance): re-arm |
| + // the one-time notice for the next exhaustion. |
| + r.budgetNotified.Store(false) |
| + } |
| } |
| } |
| + |
| +// budgetExhausted is phase 3's enforcement gate (docs/ai-provisioning.md): |
| +// when the cloud's last flush response said this space's AI budget is spent, |
| +// every model path stands down and the deterministic command floor keeps |
| +// answering — the same degrade shape as ai_enabled off, plus one honest |
| +// notice. Unmetered, "ok", or never-flushed all read as within budget. |
| +func (r *Runtime) budgetExhausted() bool { |
| + v, _ := r.budgetState.Load().(string) |
| + return v == "exhausted" |
| +} |
| + |
| +// noteBudgetExhausted returns true exactly once per exhaustion (re-armed when |
| +// the budget recovers), so the bot explains itself without repeating. Process |
| +// memory only: a restart repeats the notice at most once more. |
| +func (r *Runtime) noteBudgetExhausted() bool { |
| + return r.budgetNotified.CompareAndSwap(false, true) |
| +} |
diff --git a/internal/bot/meter_test.go b/internal/bot/meter_test.go| index 515e312..7458eb6 100644 |
| --- a/internal/bot/meter_test.go |
| +++ b/internal/bot/meter_test.go |
| @@ -188,3 +188,36 @@ func TestFlushUsageOutboxRetriesVerbatim(t *testing.T) { |
| t.Fatalf("budget state not stored: %v", r.budgetState.Load()) |
| } |
| } |
| + |
| +// Phase 3's sidecar half: an "exhausted" budget state gates the model paths |
| +// (one honest notice, then silence) and recovery re-arms the notice. |
| +func TestBudgetExhaustionGate(t *testing.T) { |
| + r := &Runtime{Session: &Session{Name: "Eleven"}} |
| + if r.budgetExhausted() { |
| + t.Fatal("never-flushed must read as within budget") |
| + } |
| + r.budgetState.Store("exhausted") |
| + if !r.budgetExhausted() { |
| + t.Fatal("exhausted state not read") |
| + } |
| + if !r.noteBudgetExhausted() { |
| + t.Fatal("first notice must fire") |
| + } |
| + if r.noteBudgetExhausted() { |
| + t.Fatal("the notice is once-per-exhaustion") |
| + } |
| + // A task run refuses without burning tokens (ai is set, budget is not). |
| + r.ai = &anthropicClient{} |
| + if _, _, err := r.execTaskOnce("p", &TaskSpec{Prompt: "x"}, false); err == nil { |
| + t.Fatal("exhausted budget must refuse task runs") |
| + } |
| + // Recovery (simulated flush storing a non-exhausted state) re-arms. |
| + r.budgetState.Store("ok") |
| + r.budgetNotified.Store(false) |
| + if r.budgetExhausted() { |
| + t.Fatal("ok state must clear the gate") |
| + } |
| + if !r.noteBudgetExhausted() { |
| + t.Fatal("notice must re-arm after recovery") |
| + } |
| +} |
diff --git a/internal/bot/runtime.go b/internal/bot/runtime.go| index 7030cfc..dffb6cc 100644 |
| --- a/internal/bot/runtime.go |
| +++ b/internal/bot/runtime.go |
| @@ -59,6 +59,8 @@ type Runtime struct { |
| // failed flushes so the ledger can dedupe a replay whose first ack was |
| // lost. Touched only on the meterLoop goroutine. |
| pendingUsage *usageReport |
| + // budgetNotified arms the once-per-exhaustion notice (meter.go). |
| + budgetNotified atomic.Bool |
| // budgetState is the space's AI budget as of the last usage flush ("ok", |
| // "unmetered", …). Inert today — phase 3 (docs/ai-provisioning.md) gates |
| // the model path on it. |
diff --git a/internal/bot/task.go b/internal/bot/task.go| index a7707be..8feaa93 100644 |
| --- a/internal/bot/task.go |
| +++ b/internal/bot/task.go |
| @@ -174,6 +174,11 @@ func (r *Runtime) execTaskOnce(persona string, t *TaskSpec, think bool) (res tas |
| // misconfiguration the owner should see once per firing, in the log. |
| return res, "", fmt.Errorf("no model available (ANTHROPIC_API_KEY unset)") |
| } |
| + if r.budgetExhausted() { |
| + // Budget-spent runs ride the charter failure machinery (backoff, one |
| + // notice per outage) instead of burning tokens the space doesn't have. |
| + return res, "", fmt.Errorf("the space's included AI is used up for now — runs resume when the budget does") |
| + } |
| model := firstOf(t.Model, r.model, defaultModel) |
| maxCalls := t.MaxToolCalls |
| if maxCalls == 0 { |