quota: z.ai credit-bucket back-pressure + resource gate + usage accounting (Redmine 490+491)

The loop now consults quota and host state before every dispatch and
DEFERS gated work with a logged reason instead of letting turns die at
the provider (the 2026-08-28 19:00 quota-wall failure mode, replayed as
a test). Adds internal/quota: 5h/weekly credit buckets (provider poll
when z.ai ships an endpoint - fake-server tested - else locally
estimated from the documented credit formula), TZ-aware peak window
(default 01:00-05:00 America/Chicago weekdays, matching the documented
z.ai peak Mon-Fri 14:00-18:00 Singapore), block/defer thresholds, a
read-only load/mem/disk/IO-PSI monitor, an optional redis shared-state
hop (stdlib RESP2 mini-client) so all instances of an account
coordinate, per-class token+credit accounting in loop.jsonl, and
`harness quota status|probe|gate`. Config: [quota] + [resources]
sections; README runbook covers the redis container and deploy-time
cgroup enforcement.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
2026-08-29 05:37:15 -05:00
parent 9dbb20489c
commit fc518c475e
21 changed files with 2904 additions and 20 deletions
+59 -3
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
@@ -19,6 +20,7 @@ const (
evCommit = "commit" // REPORT committed to gitea (optional step)
evRefresh = "refresh" // dedup marker advanced to post-writeback updated_on
evError = "error" // a step failed; loop continues
evDefer = "defer" // dispatch deferred by the quota/resource gate (throttle, not rejection)
)
// loopEvent is one line of the append-only loop log. It doubles as the
@@ -31,11 +33,18 @@ type loopEvent struct {
UpdatedOn string `json:"updated_on,omitempty"`
Subject string `json:"subject,omitempty"`
Model string `json:"model,omitempty"`
Class string `json:"class,omitempty"` // usage accounting key
ReportPath string `json:"report_path,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
StatusFrom string `json:"status_from,omitempty"`
StatusTo string `json:"status_to,omitempty"`
Detail string `json:"detail,omitempty"` // human context; never secrets
// Per-turn usage accounting (report events): tokens + estimated z.ai
// credits. Feeds the per-instance usage tables / Discourse reports.
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
TotalTokens int `json:"total_tokens,omitempty"`
Credits float64 `json:"credits,omitempty"`
Detail string `json:"detail,omitempty"` // human context; never secrets
}
// loopState is the append-only loop log + dedup index: stateDir/loop.jsonl,
@@ -47,7 +56,18 @@ type loopState struct {
f *os.File
path string
seen map[string]string
readOnly bool // dry-run: dedup checks work, writes are refused
usage map[string]usageAcc // class -> per-class totals (report events)
readOnly bool // dry-run: dedup checks work, writes are refused
}
// usageAcc accumulates per-class turn accounting; it is rebuilt from the
// JSONL at open so usage reporting survives restarts.
type usageAcc struct {
Turns int `json:"turns"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
Credits float64 `json:"credits"`
}
func openLoopState(stateDir string) (*loopState, error) {
@@ -59,7 +79,7 @@ func openLoopState(stateDir string) (*loopState, error) {
if err != nil {
return nil, fmt.Errorf("open loop log: %w", err)
}
s := &loopState{path: path, f: f, seen: make(map[string]string)}
s := &loopState{path: path, f: f, seen: make(map[string]string), usage: make(map[string]usageAcc)}
if err := s.loadIndex(); err != nil {
f.Close()
return nil, err
@@ -73,6 +93,7 @@ func openLoopStateReadOnly(stateDir string) (*loopState, error) {
s := &loopState{
path: filepath.Join(stateDir, "loop.jsonl"),
seen: make(map[string]string),
usage: make(map[string]usageAcc),
readOnly: true,
}
if err := s.loadIndex(); err != nil {
@@ -100,6 +121,9 @@ func (s *loopState) loadIndex() error {
if ev.Type == evDispatch || ev.Type == evRefresh {
s.seen[ev.TaskID] = ev.UpdatedOn
}
if ev.Type == evReport {
s.addUsage(ev.Class, ev)
}
}
return sc.Err()
}
@@ -131,9 +155,41 @@ func (s *loopState) log(ev loopEvent) error {
if ev.Type == evDispatch || ev.Type == evRefresh {
s.seen[ev.TaskID] = ev.UpdatedOn
}
if ev.Type == evReport {
s.addUsage(ev.Class, ev)
}
return nil
}
func (s *loopState) addUsage(class string, ev loopEvent) {
if class == "" {
class = "(unknown)"
}
acc := s.usage[class]
acc.Turns++
acc.PromptTokens += ev.PromptTokens
acc.CompletionTokens += ev.CompletionTokens
acc.TotalTokens += ev.TotalTokens
acc.Credits += ev.Credits
s.usage[class] = acc
}
// usageRows snapshots the per-class accounting (sorted by class).
func (s *loopState) usageRows() []usageRow {
s.mu.Lock()
defer s.mu.Unlock()
rows := make([]usageRow, 0, len(s.usage))
for class, acc := range s.usage {
rows = append(rows, usageRow{
Class: class, Turns: acc.Turns,
PromptTokens: acc.PromptTokens, CompletionTokens: acc.CompletionTokens,
TotalTokens: acc.TotalTokens, Credits: acc.Credits,
})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].Class < rows[j].Class })
return rows
}
func (s *loopState) Close() error {
s.mu.Lock()
defer s.mu.Unlock()