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 12e7d2f2a..81828085f 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -686,11 +686,26 @@ func (m *Manager) ReadUsageOf(ctx context.Context, licence string, dry bool) ([] if l.Kind != "subscription" || l.Generation == 0 || (licence != "" && l.Name != licence) { continue } - g, err := m.grantOf(ctx, l) + // The reading takes the licence's grant under its lease, so it never reads a grant a refresh is replacing (§4, + // §16); a grant the vault does not answer is a failed reading like a vendor's refusal (§31). + key := "licence." + KeyFor(l.Name) + leased, err := m.Store.Lease(ctx, key, m.Holder, leaseFor) if err != nil { return out, err } - raw, err := m.Vendor.Usage(ctx, g.AccessToken) + if !leased { + out = append(out, Read{Licence: l.Name, Error: "another run holds its lease", Switches: []Switch{}}) + continue + } + unlease := func() { _ = m.Store.Unlease(ctx, key, m.Holder) } + if fresh, err := m.Store.Licence(ctx, l.Name); err == nil && fresh != nil { + l = *fresh + } + var raw map[string]any + g, err := m.grantOf(ctx, l) + if err == nil { + raw, err = m.Vendor.Usage(ctx, g.AccessToken) + } g = FullGrant{} if err == nil && raw == nil { err = errors.New("the usage endpoint answered nothing") @@ -703,7 +718,9 @@ func (m *Manager) ReadUsageOf(ctx context.Context, licence string, dry bool) ([] } kept.ReadError = err.Error() l.Usage = &kept - if err := m.Store.SaveLicence(ctx, l); err != nil { + err := m.Store.SaveLicence(ctx, l) + unlease() + if err != nil { return out, err } m.Log("reading the usage of %s failed: %v", l.Name, err) @@ -712,13 +729,16 @@ func (m *Manager) ReadUsageOf(ctx context.Context, licence string, dry bool) ([] } reading := FlattenUsage(raw) if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil { + unlease() return out, err } // The latest reading beside its licence, so licences answers from one key (§2). latest := reading latest.ReadAt = m.Now().UTC().Format(time.RFC3339Nano) l.Usage = &latest - if err := m.Store.SaveLicence(ctx, l); err != nil { + err = m.Store.SaveLicence(ctx, l) + unlease() + if err != nil { return out, err } _ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct, 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 9f8f5b5b8..aeafc0005 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 @@ -64,6 +64,29 @@ func TestAFailedReadingKeepsThePercentages(t *testing.T) { if n := len(mm.usage.entries); n != 1 { t.Fatalf("a failed reading appended: %d entries", n) } + // §16, §31: a grant the vault does not answer is a failed reading, written and gone past, never the end of the round. + mm.vendor.usageErr = nil + mm.grants.mu.Lock() + delete(mm.grants.held, SecretName(KeyFor(licence1), PartGrant)) + mm.grants.mu.Unlock() + if err := mm.m.ReadUsage(ctx); err != nil { + t.Fatalf("a grant the vault did not answer ended the round: %v", err) + } + l, _ = mm.store.Licence(ctx, licence1) + if !strings.Contains(l.Usage.ReadError, "cannot be read from the vault") || *l.Usage.WeeklyPct != 97 { + t.Fatalf("after the vault's refusal the reading is %+v", l.Usage) + } + // §4, §16: a reading is taken under the licence's lease; with another run holding it, nothing is read or written. + if ok, _ := mm.store.Lease(ctx, "licence."+KeyFor(licence1), "another run", time.Minute); !ok { + t.Fatal("the lease was not taken") + } + reads, err := mm.m.ReadUsageOf(ctx, licence1, true) + if err != nil || len(reads) != 1 || reads[0].Error != "another run holds its lease" { + t.Fatalf("a reading under another's lease answered %+v, %v", reads, err) + } + if again, _ := mm.store.Licence(ctx, licence1); again.Usage.ReadError != l.Usage.ReadError { + t.Fatalf("a reading under another's lease wrote %+v", again.Usage) + } } // design 39 bus-and-switch §25/AC1, §48: a table of readings, ages and healths and the room each gives.