Skip to content

Commit 77cd44b

Browse files
committed
fix(daemon): decouple codex usage sync from cc statusline
1 parent 3fa04f0 commit 77cd44b

5 files changed

Lines changed: 312 additions & 144 deletions

File tree

cmd/daemon/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@ func main() {
148148
}
149149
}
150150

151+
codexUsageSyncService := daemon.NewCodexUsageSyncService(cfg)
152+
if err := codexUsageSyncService.Start(ctx); err != nil {
153+
slog.Error("Failed to start Codex usage sync service", slog.Any("err", err))
154+
} else {
155+
slog.Info("Codex usage sync service started")
156+
defer codexUsageSyncService.Stop()
157+
}
158+
151159
// Create processor instance
152160
processor := daemon.NewSocketHandler(&cfg, pubsub)
153161

daemon/cc_info_timer.go

Lines changed: 1 addition & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,6 @@ type CCInfoTimerService struct {
5555
// Anthropic rate limit cache
5656
rateLimitCache *anthropicRateLimitCache
5757

58-
// Codex rate limit cache
59-
codexRateLimitCache *codexRateLimitCache
60-
6158
// User profile cache (permanent for daemon lifetime)
6259
userLogin string
6360
userLoginFetched bool
@@ -70,8 +67,7 @@ func NewCCInfoTimerService(config *model.ShellTimeConfig) *CCInfoTimerService {
7067
cache: make(map[CCInfoTimeRange]CCInfoCache),
7168
activeRanges: make(map[CCInfoTimeRange]bool),
7269
gitCache: make(map[string]*GitCacheEntry),
73-
rateLimitCache: &anthropicRateLimitCache{},
74-
codexRateLimitCache: &codexRateLimitCache{},
70+
rateLimitCache: &anthropicRateLimitCache{},
7571
stopChan: make(chan struct{}),
7672
}
7773
}
@@ -156,11 +152,6 @@ func (s *CCInfoTimerService) stopTimer() {
156152
s.rateLimitCache.fetchedAt = time.Time{}
157153
s.rateLimitCache.lastAttemptAt = time.Time{}
158154
s.rateLimitCache.mu.Unlock()
159-
s.codexRateLimitCache.mu.Lock()
160-
s.codexRateLimitCache.usage = nil
161-
s.codexRateLimitCache.fetchedAt = time.Time{}
162-
s.codexRateLimitCache.lastAttemptAt = time.Time{}
163-
s.codexRateLimitCache.mu.Unlock()
164155

165156
slog.Info("CC info timer stopped due to inactivity")
166157
}
@@ -180,7 +171,6 @@ func (s *CCInfoTimerService) timerLoop() {
180171
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
181172
defer cancel()
182173
s.fetchRateLimit(ctx)
183-
s.fetchCodexRateLimit(ctx)
184174
}()
185175
go s.fetchUserProfile(context.Background())
186176

@@ -204,7 +194,6 @@ func (s *CCInfoTimerService) timerLoop() {
204194
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
205195
defer cancel()
206196
s.fetchRateLimit(ctx)
207-
s.fetchCodexRateLimit(ctx)
208197
}()
209198

210199
case <-s.stopChan:
@@ -562,138 +551,6 @@ func (s *CCInfoTimerService) GetCachedRateLimitError() string {
562551
return s.rateLimitCache.lastError
563552
}
564553

565-
// fetchCodexRateLimit fetches Codex rate limit data if cache is stale.
566-
func (s *CCInfoTimerService) fetchCodexRateLimit(ctx context.Context) {
567-
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
568-
return
569-
}
570-
571-
// Check cache TTL under read lock
572-
s.codexRateLimitCache.mu.RLock()
573-
sinceLastFetch := time.Since(s.codexRateLimitCache.fetchedAt)
574-
sinceLastAttempt := time.Since(s.codexRateLimitCache.lastAttemptAt)
575-
s.codexRateLimitCache.mu.RUnlock()
576-
577-
if sinceLastFetch < codexUsageCacheTTL || sinceLastAttempt < codexUsageCacheTTL {
578-
return
579-
}
580-
581-
// Record attempt time
582-
s.codexRateLimitCache.mu.Lock()
583-
s.codexRateLimitCache.lastAttemptAt = time.Now()
584-
s.codexRateLimitCache.mu.Unlock()
585-
586-
auth, err := loadCodexAuth()
587-
if err != nil || auth == nil {
588-
slog.Debug("Failed to load Codex auth", slog.Any("err", err))
589-
s.codexRateLimitCache.mu.Lock()
590-
s.codexRateLimitCache.lastError = "auth"
591-
s.codexRateLimitCache.mu.Unlock()
592-
return
593-
}
594-
595-
usage, err := fetchCodexUsage(ctx, auth)
596-
if err != nil {
597-
slog.Warn("Failed to fetch Codex usage", slog.Any("err", err))
598-
s.codexRateLimitCache.mu.Lock()
599-
s.codexRateLimitCache.lastError = shortenCodexAPIError(err)
600-
s.codexRateLimitCache.mu.Unlock()
601-
return
602-
}
603-
604-
s.codexRateLimitCache.mu.Lock()
605-
s.codexRateLimitCache.usage = usage
606-
s.codexRateLimitCache.fetchedAt = time.Now()
607-
s.codexRateLimitCache.lastError = ""
608-
s.codexRateLimitCache.mu.Unlock()
609-
610-
// Send usage data to server (fire-and-forget)
611-
go func() {
612-
bgCtx, bgCancel := context.WithTimeout(context.Background(), 10*time.Second)
613-
defer bgCancel()
614-
s.sendCodexUsageToServer(bgCtx, usage)
615-
}()
616-
617-
slog.Debug("Codex rate limit updated",
618-
slog.String("plan", usage.Plan),
619-
slog.Int("windows", len(usage.Windows)))
620-
}
621-
622-
// sendCodexUsageToServer sends Codex usage data to the ShellTime server
623-
// for scheduling push notifications when rate limits reset.
624-
func (s *CCInfoTimerService) sendCodexUsageToServer(ctx context.Context, usage *CodexRateLimitData) {
625-
if s.config.Token == "" {
626-
return
627-
}
628-
629-
type usageWindow struct {
630-
LimitID string `json:"limit_id"`
631-
UsagePercentage float64 `json:"usage_percentage"`
632-
ResetsAt string `json:"resets_at"`
633-
WindowDurationMinutes int `json:"window_duration_minutes"`
634-
}
635-
type usagePayload struct {
636-
Plan string `json:"plan"`
637-
Windows []usageWindow `json:"windows"`
638-
}
639-
640-
windows := make([]usageWindow, len(usage.Windows))
641-
for i, w := range usage.Windows {
642-
windows[i] = usageWindow{
643-
LimitID: w.LimitID,
644-
UsagePercentage: w.UsagePercentage,
645-
ResetsAt: time.Unix(w.ResetAt, 0).UTC().Format(time.RFC3339),
646-
WindowDurationMinutes: w.WindowDurationMinutes,
647-
}
648-
}
649-
650-
payload := usagePayload{
651-
Plan: usage.Plan,
652-
Windows: windows,
653-
}
654-
655-
err := model.SendHTTPRequestJSON(model.HTTPRequestOptions[usagePayload, any]{
656-
Context: ctx,
657-
Endpoint: model.Endpoint{
658-
Token: s.config.Token,
659-
APIEndpoint: s.config.APIEndpoint,
660-
},
661-
Method: "POST",
662-
Path: "/api/v1/codex-usage",
663-
Payload: payload,
664-
Timeout: 5 * time.Second,
665-
})
666-
if err != nil {
667-
slog.Warn("Failed to send codex usage to server", slog.Any("err", err))
668-
}
669-
}
670-
671-
// GetCachedCodexRateLimit returns a copy of the cached Codex rate limit data, or nil if not available.
672-
func (s *CCInfoTimerService) GetCachedCodexRateLimit() *CodexRateLimitData {
673-
s.codexRateLimitCache.mu.RLock()
674-
defer s.codexRateLimitCache.mu.RUnlock()
675-
676-
if s.codexRateLimitCache.usage == nil {
677-
return nil
678-
}
679-
680-
// Return a copy
681-
copy := *s.codexRateLimitCache.usage
682-
windowsCopy := make([]CodexRateLimitWindow, len(copy.Windows))
683-
for i, w := range copy.Windows {
684-
windowsCopy[i] = w
685-
}
686-
copy.Windows = windowsCopy
687-
return &copy
688-
}
689-
690-
// GetCachedCodexRateLimitError returns the last error from Codex rate limit fetching, or empty string if none.
691-
func (s *CCInfoTimerService) GetCachedCodexRateLimitError() string {
692-
s.codexRateLimitCache.mu.RLock()
693-
defer s.codexRateLimitCache.mu.RUnlock()
694-
return s.codexRateLimitCache.lastError
695-
}
696-
697554
// shortenAPIError converts an Anthropic usage API error into a short string for statusline display.
698555
func shortenAPIError(err error) string {
699556
msg := err.Error()

daemon/codex_ratelimit.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import (
1313

1414
const codexUsageCacheTTL = 10 * time.Minute
1515

16+
var (
17+
loadCodexAuthFunc = loadCodexAuth
18+
fetchCodexUsageFunc = fetchCodexUsage
19+
)
20+
1621
// CodexRateLimitData holds the parsed rate limit data from the Codex API
1722
type CodexRateLimitData struct {
1823
Plan string

daemon/codex_usage_sync.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package daemon
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"sync"
8+
"time"
9+
10+
"github.com/malamtime/cli/model"
11+
)
12+
13+
var CodexUsageSyncInterval = 10 * time.Minute
14+
15+
// CodexUsageSyncService periodically fetches Codex usage and syncs it to the server.
16+
type CodexUsageSyncService struct {
17+
config model.ShellTimeConfig
18+
ticker *time.Ticker
19+
stopChan chan struct{}
20+
wg sync.WaitGroup
21+
}
22+
23+
// NewCodexUsageSyncService creates a new Codex usage sync service.
24+
func NewCodexUsageSyncService(config model.ShellTimeConfig) *CodexUsageSyncService {
25+
return &CodexUsageSyncService{
26+
config: config,
27+
stopChan: make(chan struct{}),
28+
}
29+
}
30+
31+
// Start begins the periodic Codex usage sync job.
32+
func (s *CodexUsageSyncService) Start(ctx context.Context) error {
33+
s.ticker = time.NewTicker(CodexUsageSyncInterval)
34+
s.wg.Add(1)
35+
36+
go func() {
37+
defer s.wg.Done()
38+
39+
s.sync()
40+
41+
for {
42+
select {
43+
case <-s.ticker.C:
44+
s.sync()
45+
case <-s.stopChan:
46+
return
47+
case <-ctx.Done():
48+
return
49+
}
50+
}
51+
}()
52+
53+
slog.Info("Codex usage sync service started", slog.Duration("interval", CodexUsageSyncInterval))
54+
return nil
55+
}
56+
57+
// Stop stops the Codex usage sync service.
58+
func (s *CodexUsageSyncService) Stop() {
59+
if s.ticker != nil {
60+
s.ticker.Stop()
61+
}
62+
close(s.stopChan)
63+
s.wg.Wait()
64+
slog.Info("Codex usage sync service stopped")
65+
}
66+
67+
func (s *CodexUsageSyncService) sync() {
68+
if s.config.Token == "" {
69+
return
70+
}
71+
72+
if err := syncCodexUsage(context.Background(), s.config); err != nil {
73+
slog.Warn("Failed to sync codex usage", slog.Any("err", err))
74+
}
75+
}
76+
77+
func syncCodexUsage(ctx context.Context, config model.ShellTimeConfig) error {
78+
if config.Token == "" {
79+
return nil
80+
}
81+
82+
runCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
83+
defer cancel()
84+
85+
auth, err := loadCodexAuthFunc()
86+
if err != nil || auth == nil {
87+
if err == nil && auth == nil {
88+
err = fmt.Errorf("codex auth unavailable")
89+
}
90+
return err
91+
}
92+
93+
usage, err := fetchCodexUsageFunc(runCtx, auth)
94+
if err != nil {
95+
return err
96+
}
97+
98+
return sendCodexUsageToServer(runCtx, config, usage)
99+
}
100+
101+
// sendCodexUsageToServer sends Codex usage data to the ShellTime server
102+
// for scheduling push notifications when rate limits reset.
103+
func sendCodexUsageToServer(ctx context.Context, config model.ShellTimeConfig, usage *CodexRateLimitData) error {
104+
if config.Token == "" {
105+
return nil
106+
}
107+
108+
type usageWindow struct {
109+
LimitID string `json:"limit_id"`
110+
UsagePercentage float64 `json:"usage_percentage"`
111+
ResetsAt string `json:"resets_at"`
112+
WindowDurationMinutes int `json:"window_duration_minutes"`
113+
}
114+
type usagePayload struct {
115+
Plan string `json:"plan"`
116+
Windows []usageWindow `json:"windows"`
117+
}
118+
119+
windows := make([]usageWindow, len(usage.Windows))
120+
for i, w := range usage.Windows {
121+
windows[i] = usageWindow{
122+
LimitID: w.LimitID,
123+
UsagePercentage: w.UsagePercentage,
124+
ResetsAt: time.Unix(w.ResetAt, 0).UTC().Format(time.RFC3339),
125+
WindowDurationMinutes: w.WindowDurationMinutes,
126+
}
127+
}
128+
129+
payload := usagePayload{
130+
Plan: usage.Plan,
131+
Windows: windows,
132+
}
133+
134+
return model.SendHTTPRequestJSON(model.HTTPRequestOptions[usagePayload, any]{
135+
Context: ctx,
136+
Endpoint: model.Endpoint{
137+
Token: config.Token,
138+
APIEndpoint: config.APIEndpoint,
139+
},
140+
Method: "POST",
141+
Path: "/api/v1/codex-usage",
142+
Payload: payload,
143+
Timeout: 5 * time.Second,
144+
})
145+
}

0 commit comments

Comments
 (0)