diff --git a/modules/claude-licence-manager/README.md b/modules/claude-licence-manager/README.md index 1148eef35..6f7a2a6cb 100644 --- a/modules/claude-licence-manager/README.md +++ b/modules/claude-licence-manager/README.md @@ -55,10 +55,10 @@ reads alone and answers every act with "the carry-over has not ended"; a failed ## Switching on usage Every `usage_minutes` the manager reads each subscription's usage with its access token from the vault: the session, -weekly and sonnet windows, each with its reset time. A licence's room is 100 less the higher of its session and weekly -percentages, known only when it was read within `usage_stale_minutes` and its refresh is healthy. When a reading puts -a licence with consumers at or above `switch_at_pct`, every consumer bound to it moves, in one act, to the licence -with the most room whose windows are both below `room_below_pct`: one `binding.changed` per consumer, one +weekly and sonnet windows, each with its reset time. A licence's room is 100 less its weekly percentage alone (the session +window takes no part in room, the switch, the warning or the conditions: the vendor retired the session limit), known only when it was read within `usage_stale_minutes` and its refresh is healthy. When a reading puts +a licence with consumers at or above `switch_at_pct` in its weekly window, every consumer bound to it moves, in one act, to the licence +with the most room whose weekly window is below `room_below_pct`: one `binding.changed` per consumer, one `licence.switched`, and one `operator-channel.tell`. A consumer is never moved back by the rule. Its conditions are said on the operator channel's condition subject, the router deciding where they go: diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go index 59ddae278..4e53daaa0 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go @@ -227,16 +227,16 @@ func (m *Manager) unread(l Licence, s Settings) (string, bool) { func (m *Manager) roomWords(o Licence, s Settings) string { room, known := Room(o, m.Now(), s) switch { - case known && (*o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow): - return fmt.Sprintf("has %g %% room, a window not below %g %%", room, s.RoomBelow) + case known && *o.Usage.WeeklyPct >= s.RoomBelow: + return fmt.Sprintf("has %g %% room, its weekly window not below %g %%", room, s.RoomBelow) case known: return fmt.Sprintf("has %g %% room", room) case o.Kind != "subscription": return "is an API key, whose room is never known" case o.Usage == nil || o.Usage.ReadAt == "": return "has no reading" - case o.Usage.SessionPct == nil || o.Usage.WeeklyPct == nil: - return "has no session or weekly window in its reading" + case o.Usage.WeeklyPct == nil: + return "has no weekly window in its reading" case o.Failures >= s.FailuresToNotify || (o.RefreshExpiresAt != 0 && o.RefreshExpiresAt <= m.Now().UnixMilli()): return "does not refresh" } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go index 6fbe66679..1fab35649 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go @@ -140,7 +140,7 @@ func TestNoRoomIsSaidOnceAndClearedByRoom(t *testing.T) { ctx := context.Background() mm := fourOnA(t) _, said := saying(mm) - _ = mm.store.SaveLicence(ctx, sub("b", read(95, 20))) + _ = mm.store.SaveLicence(ctx, sub("b", read(20, 95))) _ = mm.store.SaveLicence(ctx, sub("c", read(40, 99))) for range 2 { _ = mm.m.SayConditions(ctx, false) @@ -158,7 +158,7 @@ func TestNoRoomIsSaidOnceAndClearedByRoom(t *testing.T) { if c.Severity != occ.SeverityUrgent || c.Headline != "No licence has room for 4 machines" || c.Needs != "add a licence, or wait for a's weekly window to reset at 2026-11-14T09:00:00Z" || !strings.Contains(c.Explanation, "a is at 99 % of its weekly window, with ace, g14, laptop, server bound to it") || - !strings.Contains(c.Explanation, "b has 5 % room, a window not below 90 %") || + !strings.Contains(c.Explanation, "b has 5 % room, its weekly window not below 90 %") || c.Resolved != "ace, g14, laptop, server have a licence with room again" { t.Fatalf("no-room %+v", c) } @@ -178,7 +178,7 @@ func TestNearLimitIsSaidOncePerCrossing(t *testing.T) { key := "licence.a.near-limit" var last oc.Condition for _, w := range []float64{80, 89, 91, 91, 95} { - _ = mm.store.SaveLicence(ctx, sub("a", read(10, w))) + _ = mm.store.SaveLicence(ctx, sub("a", read(95, w))) // a session at 95 % takes no part (C4) _ = mm.m.SayConditions(ctx, false) for _, s := range *said { if s.Key == key { @@ -191,8 +191,8 @@ func TestNearLimitIsSaidOncePerCrossing(t *testing.T) { !strings.Contains(last.Explanation, "which resets at 2026-11-14T09:00:00Z") { t.Fatalf("said %v, last %+v", of(*said, key), last) } - _ = mm.store.SaveLicence(ctx, sub("b", read(95, 0))) - _ = mm.store.SaveLicence(ctx, sub("c", read(95, 0))) + _ = mm.store.SaveLicence(ctx, sub("b", read(0, 95))) + _ = mm.store.SaveLicence(ctx, sub("c", read(0, 95))) _ = mm.m.SayConditions(ctx, false) if c := (*said)[len(*said)-1]; c.Key != key || c.Needs != "no other licence has room" { t.Fatalf("with no target said %+v", c) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/room.go b/modules/claude-licence-manager/cmd/claude-licence-manager/room.go index caa24142f..587448141 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/room.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/room.go @@ -1,18 +1,15 @@ package main -// A licence's room (design 39 bus-and-switch §25, §48): 100 less the higher of its session and weekly percentages in -// its latest reading, known only for a subscription read successfully within usage_stale_minutes, whose refresh has not +// A licence's room (design 39 bus-and-switch §25, §48, C4): 100 less its weekly percentage in its latest reading, the +// session window taking no part (the vendor retired the session limit), known only for a subscription read successfully within usage_stale_minutes, whose refresh has not // failed failures_to_notify times in a row and whose refresh token has not expired. An API key's room is never known, // so it is never a target. -import ( - "math" - "time" -) +import "time" // Room is a licence's room at now, and whether it is known. func Room(l Licence, now time.Time, s Settings) (float64, bool) { - if l.Kind != "subscription" || l.Usage == nil || l.Usage.SessionPct == nil || l.Usage.WeeklyPct == nil { + if l.Kind != "subscription" || l.Usage == nil || l.Usage.WeeklyPct == nil { return 0, false } read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt) @@ -22,5 +19,5 @@ func Room(l Licence, now time.Time, s Settings) (float64, bool) { if l.Failures >= s.FailuresToNotify || (l.RefreshExpiresAt != 0 && l.RefreshExpiresAt <= now.UnixMilli()) { return 0, false } - return 100 - math.Max(*l.Usage.SessionPct, *l.Usage.WeeklyPct), true + return 100 - *l.Usage.WeeklyPct, true } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go index aeafc0005..de14c71da 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go @@ -89,7 +89,7 @@ func TestAFailedReadingKeepsThePercentages(t *testing.T) { } } -// design 39 bus-and-switch §25/AC1, §48: a table of readings, ages and healths and the room each gives. +// design 39 bus-and-switch §25/AC1, §48, C4: a table of readings, ages and healths and the room each gives. func TestTheRoomOfEachLicence(t *testing.T) { now := t0 pct := func(v float64) *float64 { return &v } @@ -102,8 +102,10 @@ func TestTheRoomOfEachLicence(t *testing.T) { room float64 known bool }{ - {"the higher window counts", Licence{Kind: "subscription", Usage: read(41, 97, time.Minute)}, 3, true}, - {"the session can be the higher", Licence{Kind: "subscription", Usage: read(80, 20, time.Minute)}, 20, true}, + {"the weekly window counts", Licence{Kind: "subscription", Usage: read(41, 97, time.Minute)}, 3, true}, + {"the session takes no part (C4)", Licence{Kind: "subscription", Usage: read(80, 20, time.Minute)}, 80, true}, + {"no session window, weekly 33 (C4)", Licence{Kind: "subscription", Usage: &UsageReading{WeeklyPct: pct(33), + ReadAt: now.Format(time.RFC3339Nano)}}, 67, true}, {"read at the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 15*time.Minute)}, 90, true}, {"read before the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 16*time.Minute)}, 0, false}, {"a failure since keeps the room", Licence{Kind: "subscription", Usage: func() *UsageReading { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go b/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go index 13027e4be..45bbc8ae6 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go @@ -1,9 +1,9 @@ package main // The switch rule (design 39 bus-and-switch §26, §27, §28, §32): after a reading of licence L, when switch_auto is on and -// L, with one or more consumers bound to it, is at or above switch_at_pct in its session or weekly window, every -// consumer bound to L moves, in one act, to the other licence with known room whose windows are both below -// room_below_pct and that has the most room, the first by name on a tie. The act is said as one licence.switched, a +// L, with one or more consumers bound to it, is at or above switch_at_pct in its weekly window (C4: the session window takes +// no part), every consumer bound to L moves, in one act, to the other licence with known room whose weekly window is +// below room_below_pct and that has the most room, the first by name on a tie. The act is said as one licence.switched, a // binding.changed per consumer, one audit entry switched, and told to the operator. A consumer is only ever moved off // a licence at its threshold, so none flaps between two. @@ -61,24 +61,17 @@ func consumersOf(licence string, bindings []Binding) []string { return consumers } -// windowAt is l's window at or above pct, the higher of the two, the session window on a tie (B36): its name, -// percentage and reset; ok false when neither is. +// windowAt is l's weekly window when it is at or above pct (C4: the session window takes no part): its name, +// percentage and reset; ok false when it is not. func windowAt(u *UsageReading, pct float64) (window string, at float64, resetsAt string, ok bool) { - if u == nil { + if u == nil || u.WeeklyPct == nil || *u.WeeklyPct < pct { return "", 0, "", false } - session, weekly := u.SessionPct, u.WeeklyPct - switch { - case session != nil && *session >= pct && (weekly == nil || *session >= *weekly): - return "session", *session, u.SessionResetsAt, true - case weekly != nil && *weekly >= pct: - return "weekly", *weekly, u.WeeklyResetsAt, true - } - return "", 0, "", false + return "weekly", *u.WeeklyPct, u.WeeklyResetsAt, true } -// targetFor is the licence §26 moves l's consumers to: among the others with known room whose windows are both below -// room_below_pct, the one with the most room, the first by name on a tie; empty when none has room. +// targetFor is the licence §26 moves l's consumers to: among the others with known room whose weekly window is below +// room_below_pct (C4), the one with the most room, the first by name on a tie; empty when none has room. func (m *Manager) targetFor(l Licence, licences []Licence, s Settings) (string, float64) { others := append([]Licence(nil), licences...) sort.Slice(others, func(i, j int) bool { return others[i].Name < others[j].Name }) @@ -88,7 +81,7 @@ func (m *Manager) targetFor(l Licence, licences []Licence, s Settings) (string, continue } room, known := Room(o, m.Now(), s) - if !known || *o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow || room <= best { + if !known || *o.Usage.WeeklyPct >= s.RoomBelow || room <= best { continue } best, to = room, o.Name diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/switching_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/switching_test.go index 3dfa8e8da..40757860a 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/switching_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/switching_test.go @@ -14,11 +14,18 @@ func read(session, weekly float64) *UsageReading { WeeklyResetsAt: "2026-11-14T09:00:00Z", ReadAt: t0.UTC().Format(time.RFC3339Nano)} } +// weekly is a reading with no session window, as the vendor answers since the session limit was retired (C4). +func weekly(pct float64) *UsageReading { + u := read(0, pct) + u.SessionPct, u.SessionResetsAt = nil, "" + return u +} + func sub(name string, u *UsageReading) Licence { return Licence{Name: name, Kind: "subscription", Generation: 1, Usage: u} } -// design 39 bus-and-switch §26/AC1, §25, §48: a table of licences, readings and bindings and the moves each gives. +// design 39 bus-and-switch §26/AC1, §25, §48, C4: a table of licences, readings and bindings and the moves each gives. func TestTheRuleMovesEachTable(t *testing.T) { mm := newMesh(t) on := func(consumers ...string) []Binding { @@ -41,12 +48,15 @@ func TestTheRuleMovesEachTable(t *testing.T) { {"no consumer", []Licence{sub("a", read(99, 99)), sub("b", read(0, 0))}, nil, "", "", false, 0}, {"the weekly window at 99", []Licence{sub("a", read(40, 99)), sub("b", read(10, 30)), sub("c", read(5, 20))}, on("x", "y", "z", "w"), "c", "weekly", false, 4}, - {"the session window at 100", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))}, on("x"), "b", "session", false, 1}, - {"a tie goes to the first by name", []Licence{sub("a", read(99, 0)), sub("c", read(10, 10)), sub("b", read(10, 10))}, - on("x"), "b", "session", false, 1}, - {"a target's windows both below room_below_pct", []Licence{sub("a", read(99, 0)), sub("b", read(0, 90)), - sub("c", read(89, 89))}, on("x"), "c", "session", false, 1}, - {"no other licence has room", []Licence{sub("a", read(99, 0)), sub("b", read(95, 0)), + {"the session window at 100 moves nothing (C4)", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))}, + on("x"), "", "", false, 0}, + {"no session window, weekly 99, to weekly 33 (C4)", []Licence{sub("a", weekly(99)), sub("b", weekly(33))}, + on("x", "y"), "b", "weekly", false, 2}, + {"a tie goes to the first by name", []Licence{sub("a", read(0, 99)), sub("c", read(10, 10)), sub("b", read(10, 10))}, + on("x"), "b", "weekly", false, 1}, + {"a target's weekly below room_below_pct, its session not counted (C4)", []Licence{sub("a", read(0, 99)), + sub("b", read(0, 90)), sub("c", read(95, 89))}, on("x"), "c", "weekly", false, 1}, + {"no other licence has room", []Licence{sub("a", read(0, 99)), sub("b", read(0, 95)), {Name: "k", Kind: "api-key", Generation: 1}}, on("x"), "", "", true, 0}, {"an unread licence is no target", []Licence{sub("a", read(0, 99)), sub("b", nil)}, on("x"), "", "", true, 0}, } { @@ -65,7 +75,7 @@ func TestTheRuleMovesEachTable(t *testing.T) { func fourOnA(t *testing.T) *miniMesh { mm := newMesh(t) ctx := context.Background() - for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 20)), sub("c", read(40, 10))} { + for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 30)), sub("c", read(40, 40))} { _ = mm.store.SaveLicence(ctx, l) } for _, c := range []string{"ace", "g14", "laptop", "server"} { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go index f9c750658..a9606cd64 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go @@ -211,13 +211,13 @@ func fillTodaysRows(t *testing.T, conn *pgx.Conn) { type vendor struct { mu sync.Mutex calls int - logins map[string][2]string // refresh token: the next access token and the account - usage map[string][2]float64 // access token: session and weekly percentages + logins map[string][2]string // refresh token: the next access token and the account + usage map[string]float64 // access token: the weekly percentage; the session window is answered empty (C4) server *httptest.Server } func newVendor(t *testing.T) *vendor { - v := &vendor{logins: map[string][2]string{}, usage: map[string][2]float64{}} + v := &vendor{logins: map[string][2]string{}, usage: map[string]float64{}} v.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { v.mu.Lock() defer v.mu.Unlock() @@ -240,16 +240,17 @@ func newVendor(t *testing.T) *vendor { return } resets := time.Now().Add(3 * time.Hour).UTC().Format(time.RFC3339) - _ = json.NewEncoder(w).Encode(map[string]any{"five_hour": map[string]any{"utilization": u[0], "resets_at": resets}, - "seven_day": map[string]any{"utilization": u[1], "resets_at": resets}}) + // As the vendor answers since it retired the session limit: the session window with no utilization (C4). + _ = json.NewEncoder(w).Encode(map[string]any{"five_hour": map[string]any{"utilization": nil, "resets_at": nil}, + "seven_day": map[string]any{"utilization": u, "resets_at": resets}}) })) t.Cleanup(v.server.Close) return v } -func (v *vendor) set(access string, session, weekly float64) { +func (v *vendor) set(access string, weekly float64) { v.mu.Lock() - v.usage[access] = [2]float64{session, weekly} + v.usage[access] = weekly v.mu.Unlock() } @@ -1065,8 +1066,8 @@ func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) { m.mu.Lock() carried := m.count("binding.changed") m.mu.Unlock() - m.vendor.set("at-b", 10, 20) - m.vendor.set("at-a", 99, 50) + m.vendor.set("at-b", 20) + m.vendor.set("at-a", 99) p.must("licences_read_usage", map[string]any{"licence": licenceB}) p.must("licences_read_usage", map[string]any{"licence": licenceA}) m.until("the four consumers moved", func() bool { return m.count("binding.changed") >= carried+4 }) @@ -1098,8 +1099,8 @@ func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) { // Every licence at its threshold: the consumers have nowhere to go. noRoom := "licence." + KeyFor(licenceB) + ".no-room" - m.vendor.set("at-a", 99, 50) - m.vendor.set("at-b", 99, 60) + m.vendor.set("at-a", 99) + m.vendor.set("at-b", 99) p.must("licences_read_usage", map[string]any{"licence": licenceA}) p.must("licences_read_usage", map[string]any{"licence": licenceB}) m.until("no-room is raised", func() bool { return len(m.conditionsOf(noRoom)) > 0 }) @@ -1110,7 +1111,7 @@ func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) { t.Errorf("no-room said %+v", r) } // A reading with room: the next reading of the full licence moves its consumers, and no-room is cleared. - m.vendor.set("at-a", 5, 30) + m.vendor.set("at-a", 30) p.must("licences_read_usage", map[string]any{"licence": licenceA}) p.must("licences_read_usage", map[string]any{"licence": licenceB}) m.until("no-room is cleared", func() bool { @@ -1159,8 +1160,8 @@ func TestANewLoginIsAdoptedListedAndATarget(t *testing.T) { } // The full licence is read first: nothing has room yet, so its consumer stays. - m.vendor.set("at-old", 99, 40) - m.vendor.set("at-team", 10, 5) + m.vendor.set("at-old", 99) + m.vendor.set("at-team", 5) p.must("licences_read_usage", map[string]any{"licence": licenceA}) p.must("licences_read_usage", map[string]any{"licence": "team@example.org"}) var listed []map[string]any diff --git a/modules/claude-licence-manager/module.json b/modules/claude-licence-manager/module.json index 4a1a86e16..5d6fe9d52 100644 --- a/modules/claude-licence-manager/module.json +++ b/modules/claude-licence-manager/module.json @@ -186,13 +186,13 @@ }, "switch_auto": { "type": "boolean", - "description": "Whether the manager moves every consumer off a licence whose session or weekly usage reaches switch_at_pct, to the licence with the most room; off, it still raises its no-room and near-limit conditions.", + "description": "Whether the manager moves every consumer off a licence whose weekly usage reaches switch_at_pct, to the licence with the most room; off, it still raises its no-room and near-limit conditions.", "trusted": true, "default": true }, "switch_at_pct": { "type": "number", - "description": "The session or weekly usage of a licence, in percent, at which its consumers are moved to the licence with the most room.", + "description": "The weekly usage of a licence, in percent, at which its consumers are moved to the licence with the most room.", "minimum": 1, "maximum": 100, "trusted": true, @@ -200,7 +200,7 @@ }, "room_below_pct": { "type": "number", - "description": "What both the session and the weekly usage of a licence must be below, in percent, for it to be a target of a switch.", + "description": "What the weekly usage of a licence must be below, in percent, for it to be a target of a switch.", "minimum": 1, "maximum": 100, "trusted": true, @@ -208,7 +208,7 @@ }, "warn_at_pct": { "type": "number", - "description": "The session or weekly usage of a licence with consumers, in percent, at which it is warned about once.", + "description": "The weekly usage of a licence with consumers, in percent, at which it is warned about once.", "minimum": 1, "maximum": 100, "trusted": true,