From 954e3afe56db01ceeec794caf223923396e2e753 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:57:38 +0100 Subject: [PATCH] M-12: the switch's settings, read again at every reading (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings gain switch_auto, switch_at_pct, room_below_pct, warn_at_pct, usage_minutes and usage_stale_minutes with the manifest's defaults and bounds, a value outside its bound keeping the default. The settings file is read again at every reading, each changed value written in the audit under settings, and the usage timer follows usage_minutes. A room or warning above switch_at_pct is found as an invalid pair naming both values. Builds: design 39 bus-and-switch §33, §24 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/main.go | 22 ++- .../cmd/claude-licence-manager/manager.go | 67 ++------ .../claude-licence-manager/manager_test.go | 7 +- .../cmd/claude-licence-manager/settings.go | 147 ++++++++++++++++++ .../claude-licence-manager/settings_test.go | 99 ++++++++++++ 5 files changed, 281 insertions(+), 61 deletions(-) create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/settings.go create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/settings_test.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index ec5e2015e..f48ffa1bb 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -86,12 +86,13 @@ func manager() (*Manager, error) { } return a, json.Unmarshal(raw, &a) }, - Reported: heard.Of, - Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) }, - Now: time.Now, - Log: say, - Holder: fmt.Sprintf("%s:%d", node, os.Getpid()), - Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")), + Reported: heard.Of, + Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) }, + Now: time.Now, + Log: say, + Holder: fmt.Sprintf("%s:%d", node, os.Getpid()), + Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")), + SettingsFile: os.Getenv("MESH_LICENCE_SETTINGS"), } return built, nil } @@ -196,7 +197,12 @@ func daemon() { }() refresh := time.NewTicker(time.Minute) - usage := time.NewTicker(5 * time.Minute) + // Every usage_minutes, read again at every reading (§24, §33): the manager's one timer of its own readings. + every := Defaults.UsageEvery + if m, err := manager(); err == nil { + every = m.settings().UsageEvery + } + usage := time.NewTimer(every) for { select { case <-refresh.C: @@ -216,7 +222,9 @@ func daemon() { if err := m.ReadUsage(context.Background()); err != nil { say("reading usage failed: %v", err) } + every = m.settings().UsageEvery } + usage.Reset(every) } } } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 420afb5b3..040a9ecc9 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -23,6 +23,7 @@ import ( "regexp" "sort" "strings" + "sync" "time" ) @@ -123,49 +124,6 @@ func (m *Manager) moved(ctx context.Context, b Binding, from string, c Cause) er return err } -// Settings are the manager's own, declared with defaults (design 39 §8). -type Settings struct { - Cadence time.Duration // rotate a grant once older than this - Floor time.Duration // refresh in any case with less than this left - FailuresToNotify int - Cooldown time.Duration // at most one notification per licence in this window - RefreshWarn time.Duration // warn this long before a refresh token itself expires - // UsageStale is how old a licence's latest successful reading may be for its room to be known (§25, §33). - UsageStale time.Duration -} - -// Defaults are the settings a fresh mesh runs with. -var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, - RefreshWarn: 72 * time.Hour, UsageStale: 15 * time.Minute} - -// SettingsFrom reads the settings file, keeping a default for anything absent or not positive. -func SettingsFrom(path string) Settings { - s := Defaults - raw, err := os.ReadFile(path) - if err != nil { - return s - } - var f map[string]float64 - if json.Unmarshal(raw, &f) != nil { - return s - } - set := func(k string, unit time.Duration, into *time.Duration) { - if v := f[k]; v > 0 { - *into = time.Duration(v * float64(unit)) - } - } - set("cadence_minutes", time.Minute, &s.Cadence) - set("floor_minutes", time.Minute, &s.Floor) - set("cooldown_hours", time.Hour, &s.Cooldown) - if v := f["refresh_warn_days"]; v > 0 { - s.RefreshWarn = time.Duration(v * float64(24*time.Hour)) - } - if v := f["failures_to_notify"]; v > 0 { - s.FailuresToNotify = int(v) - } - return s -} - // GrantAnswer is what a node's `claude_code_grant` answers: a login sealed to the key given, or nothing. type GrantAnswer struct { Sealed *SealedBox `json:"sealed"` @@ -193,8 +151,12 @@ type Manager struct { Now func() time.Time Log func(format string, args ...any) // Holder names this process in a lease, so a second run is told apart. - Holder string - Settings Settings + Holder string + // Settings are those in force, read again from SettingsFile at every reading (settings.go); an empty SettingsFile + // keeps them. + Settings Settings + SettingsFile string + settingsMu sync.RWMutex } const leaseFor = 2 * time.Minute @@ -595,7 +557,7 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil } now := m.Now() - if !force && !Due(*l, now, m.Settings) { + if !force && !Due(*l, now, m.settings()) { return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil } g, err := m.grantOf(ctx, *l) @@ -610,7 +572,7 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri if err := m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures}); err != nil { return nil, err } - m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify) + m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.settings().FailuresToNotify) if err := m.Store.SaveLicence(ctx, *l); err != nil { return nil, err } @@ -622,7 +584,7 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri if l.RefreshExpiresAt != 0 { left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24), - left < m.Settings.RefreshWarn) + left < m.settings().RefreshWarn) } if err := m.Store.SaveLicence(ctx, *l); err != nil { return nil, err @@ -654,7 +616,7 @@ func (m *Manager) notify(l *Licence, why string, when bool) { return } now := m.Now().UnixMilli() - if l.NotifiedAt != 0 && now-l.NotifiedAt < m.Settings.Cooldown.Milliseconds() { + if l.NotifiedAt != 0 && now-l.NotifiedAt < m.settings().Cooldown.Milliseconds() { return } l.NotifiedAt = now @@ -669,7 +631,7 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) { } var out []map[string]any for _, l := range all { - if Due(l, m.Now(), m.Settings) { + if Due(l, m.Now(), m.settings()) { r, err := m.Rotate(ctx, l.Name, false) if err != nil { return out, err @@ -682,6 +644,9 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) { // ReadUsage reads and records each subscription's usage (ADR 0054); the event names the licence and numbers. func (m *Manager) ReadUsage(ctx context.Context) error { + if err := m.ReadSettings(ctx); err != nil { + return err + } all, err := m.Store.Licences(ctx) if err != nil { return err @@ -748,7 +713,7 @@ func (m *Manager) Current(ctx context.Context, consumer string) (map[string]any, return nil, err } out := map[string]any{"licence": l.Name, "key": KeyFor(l.Name), "kind": l.Kind, "generation": b.Generation, - "health": Health(*l, m.Now(), m.Settings)} + "health": Health(*l, m.Now(), m.settings())} if l.AccessExpiresAt != 0 { out["accessExpiresAt"] = time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339) } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index 648f740cc..c0d645616 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -263,11 +263,12 @@ func TestTwoRefreshRunsStartedTogetherRotateAGrantOnce(t *testing.T) { ctx := context.Background() _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) mm.now = t0.Add(5 * time.Hour) - second := *mm.m - second.Holder = "another run" + second := &Manager{Store: mm.m.Store, Vendor: mm.m.Vendor, Crypt: mm.m.Crypt, Grants: mm.m.Grants, Keys: mm.m.Keys, + AskGrant: mm.m.AskGrant, Emit: mm.m.Emit, Now: mm.m.Now, Log: mm.m.Log, Holder: "another run", + Settings: mm.m.Settings} var wg sync.WaitGroup results := make([]map[string]any, 2) - for i, m := range []*Manager{mm.m, &second} { + for i, m := range []*Manager{mm.m, second} { wg.Add(1) go func() { defer wg.Done(); results[i], _ = m.Rotate(ctx, licence1, false) }() } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go b/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go new file mode 100644 index 000000000..171d2b892 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go @@ -0,0 +1,147 @@ +package main + +// The manager's settings (design 39 §8, design 39 bus-and-switch §33): today's five and the six of the switch, each +// declared in the manifest's `config` with its default and bound, set through mesh-config and written into the +// settings file. They are read again at every reading, so a change takes effect at the next one, and each change of a +// value read is written in the audit under `settings`. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "reflect" + "time" +) + +// Settings are the manager's own, declared with defaults. +type Settings struct { + Cadence time.Duration // rotate a grant once older than this + Floor time.Duration // refresh in any case with less than this left + FailuresToNotify int + Cooldown time.Duration // at most one notification per licence in this window + RefreshWarn time.Duration // warn this long before a refresh token itself expires + // SwitchAuto is whether a reading at its threshold moves the licence's consumers (§26). + SwitchAuto bool + // SwitchAt, RoomBelow and WarnAt are percentages: where a licence's consumers move, what a target's windows must + // both be below, and where a licence with consumers is warned about. + SwitchAt, RoomBelow, WarnAt float64 + // UsageEvery is how often each subscription's usage is read. + UsageEvery time.Duration + // UsageStale is how old a licence's latest successful reading may be for its room to be known (§25). + UsageStale time.Duration +} + +// Defaults are the settings a fresh mesh runs with. +var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, + RefreshWarn: 72 * time.Hour, SwitchAuto: true, SwitchAt: 99, RoomBelow: 90, WarnAt: 90, UsageEvery: 5 * time.Minute, + UsageStale: 15 * time.Minute} + +// SettingsFrom reads the settings file. Today's five keep a default for anything absent or not positive; the six of +// the switch keep a default for anything absent or outside the bound the manifest declares (§33). +func SettingsFrom(path string) Settings { + s := Defaults + raw, err := os.ReadFile(path) + if err != nil { + return s + } + var f map[string]any + if json.Unmarshal(raw, &f) != nil { + return s + } + num := func(k string) (float64, bool) { v, ok := f[k].(float64); return v, ok } + set := func(k string, unit time.Duration, into *time.Duration) { + if v, ok := num(k); ok && v > 0 { + *into = time.Duration(v * float64(unit)) + } + } + set("cadence_minutes", time.Minute, &s.Cadence) + set("floor_minutes", time.Minute, &s.Floor) + set("cooldown_hours", time.Hour, &s.Cooldown) + set("refresh_warn_days", 24*time.Hour, &s.RefreshWarn) + if v, ok := num("failures_to_notify"); ok && v > 0 { + s.FailuresToNotify = int(v) + } + if v, ok := f["switch_auto"].(bool); ok { + s.SwitchAuto = v + } + bounded := func(k string, lo, hi float64) (float64, bool) { + v, ok := num(k) + return v, ok && v >= lo && v <= hi + } + for _, p := range []struct { + key string + into *float64 + }{{"switch_at_pct", &s.SwitchAt}, {"room_below_pct", &s.RoomBelow}, {"warn_at_pct", &s.WarnAt}} { + if v, ok := bounded(p.key, 1, 100); ok { + *p.into = v + } + } + if v, ok := bounded("usage_minutes", 1, 60); ok { + s.UsageEvery = time.Duration(v * float64(time.Minute)) + } + if v, ok := bounded("usage_stale_minutes", 5, 180); ok { + s.UsageStale = time.Duration(v * float64(time.Minute)) + } + return s +} + +// Values are the settings by their names in the manifest's `config`, in its units. +func (s Settings) Values() map[string]any { + return map[string]any{"cadence_minutes": s.Cadence.Minutes(), "floor_minutes": s.Floor.Minutes(), + "failures_to_notify": float64(s.FailuresToNotify), "cooldown_hours": s.Cooldown.Hours(), + "refresh_warn_days": s.RefreshWarn.Hours() / 24, "switch_auto": s.SwitchAuto, "switch_at_pct": s.SwitchAt, + "room_below_pct": s.RoomBelow, "warn_at_pct": s.WarnAt, "usage_minutes": s.UsageEvery.Minutes(), + "usage_stale_minutes": s.UsageStale.Minutes()} +} + +// Invalid is a setting above switch_at_pct, which moves no consumer until it is fixed (§33): the setting and both +// values. +type Invalid struct { + Setting string + Value float64 + SwitchAt float64 +} + +// Invalid is every setting of the pairs §33 names that is above switch_at_pct: room_below_pct, then warn_at_pct. +func (s Settings) Invalid() []Invalid { + var out []Invalid + if s.RoomBelow > s.SwitchAt { + out = append(out, Invalid{Setting: "room_below_pct", Value: s.RoomBelow, SwitchAt: s.SwitchAt}) + } + if s.WarnAt > s.SwitchAt { + out = append(out, Invalid{Setting: "warn_at_pct", Value: s.WarnAt, SwitchAt: s.SwitchAt}) + } + return out +} + +// settings are those in force. +func (m *Manager) settings() Settings { + m.settingsMu.RLock() + defer m.settingsMu.RUnlock() + return m.Settings +} + +// ReadSettings reads the settings file again, at every reading (§33): each value that changed is written in the audit +// under `settings`, its value before and now, and the settings read are those in force from then on. +func (m *Manager) ReadSettings(ctx context.Context) error { + if m.SettingsFile == "" { + return nil + } + next := SettingsFrom(m.SettingsFile) + m.settingsMu.Lock() + before := m.Settings + m.Settings = next + m.settingsMu.Unlock() + was, now := before.Values(), next.Values() + changed := map[string]any{} + for k, v := range now { + if !reflect.DeepEqual(was[k], v) { + changed[k] = map[string]any{"from": was[k], "to": v} + } + } + if len(changed) == 0 { + return nil + } + return m.record(ctx, "settings", fmt.Sprintf("%d setting(s) changed: %v", len(changed), changed), changed) +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/settings_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/settings_test.go new file mode 100644 index 000000000..d642e76eb --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/settings_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// design 39 bus-and-switch §33/AC1: each setting's default is the manifest's, a value at its bound is taken, and one +// outside its bound or absent keeps the default. +func TestEachSettingsDefaultAndBound(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Config map[string]struct { + Default any `json:"default"` + Minimum *float64 `json:"minimum"` + Maximum *float64 `json:"maximum"` + Trusted bool `json:"trusted"` + } `json:"config"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + defaults := Defaults.Values() + if len(m.Config) != len(defaults) { + t.Fatalf("the manifest declares %d settings, the code %d", len(m.Config), len(defaults)) + } + file := filepath.Join(t.TempDir(), "settings.json") + read := func(k string, v any) any { + b, _ := json.Marshal(map[string]any{k: v}) + _ = os.WriteFile(file, b, 0o600) + return SettingsFrom(file).Values()[k] + } + for k, c := range m.Config { + if !c.Trusted || c.Default != defaults[k] { + t.Errorf("%s: the manifest's default %v (trusted %v), the code's %v", k, c.Default, c.Trusted, defaults[k]) + } + if c.Maximum == nil { + continue + } + lo, hi := *c.Minimum, *c.Maximum + for v, want := range map[float64]any{lo: lo, hi: hi, lo - 1: defaults[k], hi + 1: defaults[k]} { + if got := read(k, v); got != want { + t.Errorf("%s set to %v reads %v, want %v", k, v, got, want) + } + } + } + if read("switch_auto", false) != false || read("switch_at_pct", "high") != 99.0 { + t.Error("switch_auto off, or a value of the wrong type, is not read as written") + } +} + +// design 39 bus-and-switch §33: room_below_pct or warn_at_pct above switch_at_pct is an invalid pair naming both +// values; equal is valid. +func TestAnInvalidPairIsFound(t *testing.T) { + s := Defaults + if len(s.Invalid()) != 0 { + t.Fatal("the defaults are invalid") + } + s.SwitchAt, s.RoomBelow, s.WarnAt = 80, 85, 80 + got := s.Invalid() + if len(got) != 1 || got[0] != (Invalid{Setting: "room_below_pct", Value: 85, SwitchAt: 80}) { + t.Fatalf("invalid %+v", got) + } + s.WarnAt = 95 + if got := s.Invalid(); len(got) != 2 || got[1].Setting != "warn_at_pct" { + t.Fatalf("invalid %+v", got) + } +} + +// design 39 bus-and-switch §33: the settings are read again at every reading, and each change of a value read is +// written once in the audit under settings. +func TestASettingChangedIsReadAtTheNextReadingAndAudited(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + mm.m.SettingsFile = filepath.Join(t.TempDir(), "settings.json") + _ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 99}`), 0o600) + if err := mm.m.ReadUsage(ctx); err != nil || len(mm.audit.under("settings")) != 0 { + t.Fatalf("an unchanged file audited %v (%v)", mm.audit.under("settings"), err) + } + _ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 95, "usage_minutes": 10}`), 0o600) + _ = mm.m.ReadUsage(ctx) + _ = mm.m.ReadUsage(ctx) + if s := mm.m.settings(); s.SwitchAt != 95 || s.UsageEvery.Minutes() != 10 { + t.Fatalf("in force %+v", s) + } + got := mm.audit.under("settings") + if len(got) != 1 { + t.Fatalf("audited %v", got) + } + if c, _ := got[0]["switch_at_pct"].(map[string]any); c["from"] != 99.0 || c["to"] != 95.0 { + t.Fatalf("audited %v", got[0]) + } +}