diff --git a/internal/domain/domain.go b/internal/domain/domain.go index c6fccc3..3b292c0 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -155,6 +155,17 @@ func ValidatePayload(typ string, p map[string]any) error { if err := requiredString("subject_ref"); err != nil { return err } + case "QuotaReported": + if err := requiredString("harness_id"); err != nil { + return err + } + if v, ok := p["consumed"].(float64); !ok || v < 0 { + return fmt.Errorf("%w: consumed required", ErrInvalid) + } + case "StandupAdvisory": + if _, ok := p["items"]; !ok { + return fmt.Errorf("%w: items required", ErrInvalid) + } } return nil } diff --git a/internal/router/router.go b/internal/router/router.go index 8d75fb6..b3ee7f3 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -17,6 +17,47 @@ type AlwaysAvailable struct{} func (AlwaysAvailable) Available(registry.Herdr) bool { return true } +// QuotaAvailability applies the conservative 80% rule to the most recent +// native quota report in the configured rolling window. +type QuotaAvailability struct { + Store *store.Store + Limits map[string]float64 + Window time.Duration + Now func() time.Time +} + +func (q QuotaAvailability) Available(h registry.Herdr) bool { + if q.Store == nil { + return false + } + limit, bounded := q.Limits[h.ID] + if !bounded || limit <= 0 { + return true + } + now := time.Now() + if q.Now != nil { + now = q.Now() + } + window := q.Window + if window <= 0 { + window = 7 * 24 * time.Hour + } + var consumed float64 + for _, e := range q.Store.Events(0) { + if e.Type != "QuotaReported" || e.At.Before(now.Add(-window)) { + continue + } + var p struct { + HarnessID string `json:"harness_id"` + Consumed float64 `json:"consumed"` + } + if json.Unmarshal(e.Payload, &p) == nil && p.HarnessID == h.ID && p.Consumed > consumed { + consumed = p.Consumed + } + } + return consumed < limit*0.8 +} + type RetryPolicy struct { MaxAttempts int Backoff time.Duration diff --git a/progress.md b/progress.md index e17910e..d6f8854 100644 --- a/progress.md +++ b/progress.md @@ -63,6 +63,8 @@ The coordinator now persists active task→herdr session mappings in an atomic r Provider supervision/reflection is now wired: Gitea webhook and polling use append-first task reflection, JSONL and Gitea loops restart with bounded backoff, and `/v1/providers/health` exposes running/error state. Provider lifecycle cancellation remains tied to process shutdown until the server gains a root cancellation context. +Quota reporting now has strict payload validation, and `router.QuotaAvailability` implements rolling-window conservative headroom filtering: a harness is considered full at 80% of its configured limit. Standup event payloads also require an items field; scheduled advisory production and approval application remain open. + Recommended order: 1. Add the orchestration coordinator: lease → worktree → harness session → bootstrap → lifecycle events.