diff --git a/internal/cache/result_cache.go b/internal/cache/result_cache.go new file mode 100644 index 0000000..5c39a01 --- /dev/null +++ b/internal/cache/result_cache.go @@ -0,0 +1,319 @@ +package cache + +import ( + "crypto/md5" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// CacheKey represents a cache key for an activity result +type CacheKey struct { + ActivityType string // "implementer", "judge", "planner" + TaskID string + InputHash string // MD5 hash of input + ModelID string // LLM model used +} + +// String returns a string representation of the cache key +func (ck *CacheKey) String() string { + return fmt.Sprintf("%s:%s:%s:%s", ck.ActivityType, ck.TaskID, ck.InputHash, ck.ModelID) +} + +// CacheEntry represents a cached activity result +type CacheEntry struct { + Key CacheKey `json:"key"` + Result map[string]interface{} `json:"result"` + CreatedAt time.Time `json:"created_at"` + HitCount int `json:"hit_count"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// ResultCache caches activity results to avoid redundant computations +type ResultCache struct { + mu sync.RWMutex + basePath string + cache map[string]*CacheEntry + maxSize int + ttl time.Duration +} + +// NewResultCache creates a new result cache +func NewResultCache(basePath string, maxSize int, ttl time.Duration) *ResultCache { + return &ResultCache{ + basePath: basePath, + cache: make(map[string]*CacheEntry), + maxSize: maxSize, + ttl: ttl, + } +} + +// ComputeHash computes a hash of the input data +func ComputeHash(data interface{}) (string, error) { + jsonData, err := json.Marshal(data) + if err != nil { + return "", err + } + + hash := md5.Sum(jsonData) + return fmt.Sprintf("%x", hash), nil +} + +// Set stores a result in the cache +func (rc *ResultCache) Set(key *CacheKey, result map[string]interface{}) error { + if key == nil { + return fmt.Errorf("cache key cannot be nil") + } + + rc.mu.Lock() + defer rc.mu.Unlock() + + keyStr := key.String() + + entry := &CacheEntry{ + Key: *key, + Result: result, + CreatedAt: time.Now(), + Metadata: make(map[string]interface{}), + } + + // Check size limit + if len(rc.cache) >= rc.maxSize && rc.cache[keyStr] == nil { + // Evict oldest entry (simple FIFO) + var oldestKey string + var oldestTime time.Time + + for k, v := range rc.cache { + if oldestTime.IsZero() || v.CreatedAt.Before(oldestTime) { + oldestKey = k + oldestTime = v.CreatedAt + } + } + + if oldestKey != "" { + delete(rc.cache, oldestKey) + } + } + + rc.cache[keyStr] = entry + return rc.persistLocked(keyStr, entry) +} + +// Get retrieves a result from the cache +func (rc *ResultCache) Get(key *CacheKey) (map[string]interface{}, bool, error) { + if key == nil { + return nil, false, fmt.Errorf("cache key cannot be nil") + } + + rc.mu.Lock() + defer rc.mu.Unlock() + + keyStr := key.String() + entry, exists := rc.cache[keyStr] + + if !exists { + return nil, false, nil + } + + // Check TTL + if rc.ttl > 0 && time.Since(entry.CreatedAt) > rc.ttl { + delete(rc.cache, keyStr) + return nil, false, nil + } + + // Increment hit count + entry.HitCount++ + _ = rc.persistLocked(keyStr, entry) + + return entry.Result, true, nil +} + +// Invalidate removes a cache entry +func (rc *ResultCache) Invalidate(key *CacheKey) error { + if key == nil { + return fmt.Errorf("cache key cannot be nil") + } + + rc.mu.Lock() + defer rc.mu.Unlock() + + keyStr := key.String() + delete(rc.cache, keyStr) + + // Delete from disk + cacheFile := filepath.Join(rc.basePath, "cache", fmt.Sprintf("%s.json", keyStr)) + _ = os.Remove(cacheFile) + + return nil +} + +// Clear clears all cache entries +func (rc *ResultCache) Clear() error { + rc.mu.Lock() + defer rc.mu.Unlock() + + rc.cache = make(map[string]*CacheEntry) + + // Clear disk cache + cacheDir := filepath.Join(rc.basePath, "cache") + _ = os.RemoveAll(cacheDir) + + return nil +} + +// GetStats returns cache statistics +func (rc *ResultCache) GetStats() map[string]interface{} { + rc.mu.RLock() + defer rc.mu.RUnlock() + + totalHits := 0 + for _, entry := range rc.cache { + totalHits += entry.HitCount + } + + return map[string]interface{}{ + "size": len(rc.cache), + "max_size": rc.maxSize, + "total_hits": totalHits, + "usage_ratio": float64(len(rc.cache)) / float64(rc.maxSize), + } +} + +// GetSize returns the current cache size +func (rc *ResultCache) GetSize() int { + rc.mu.RLock() + defer rc.mu.RUnlock() + + return len(rc.cache) +} + +// persistLocked saves a cache entry to disk (must be called with lock held) +func (rc *ResultCache) persistLocked(keyStr string, entry *CacheEntry) error { + cacheDir := filepath.Join(rc.basePath, "cache") + + // Create directory if it doesn't exist + if err := os.MkdirAll(cacheDir, 0755); err != nil { + return err + } + + cacheFile := filepath.Join(cacheDir, fmt.Sprintf("%s.json", keyStr)) + + data, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return err + } + + return os.WriteFile(cacheFile, data, 0644) +} + +// Load loads cache from disk +func (rc *ResultCache) Load() error { + rc.mu.Lock() + defer rc.mu.Unlock() + + cacheDir := filepath.Join(rc.basePath, "cache") + entries, err := os.ReadDir(cacheDir) + if err != nil { + if os.IsNotExist(err) { + return nil // Cache doesn't exist yet + } + return err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + filePath := filepath.Join(cacheDir, entry.Name()) + data, err := os.ReadFile(filePath) + if err != nil { + continue + } + + var cacheEntry CacheEntry + if err := json.Unmarshal(data, &cacheEntry); err != nil { + continue + } + + // Skip expired entries + if rc.ttl > 0 && time.Since(cacheEntry.CreatedAt) > rc.ttl { + continue + } + + keyStr := cacheEntry.Key.String() + rc.cache[keyStr] = &cacheEntry + } + + return nil +} + +// InvalidateByActivity invalidates all cache entries for an activity type +func (rc *ResultCache) InvalidateByActivity(activityType string) error { + rc.mu.Lock() + defer rc.mu.Unlock() + + keysToDelete := make([]string, 0) + for keyStr, entry := range rc.cache { + if entry.Key.ActivityType == activityType { + keysToDelete = append(keysToDelete, keyStr) + } + } + + for _, keyStr := range keysToDelete { + delete(rc.cache, keyStr) + + // Delete from disk + cacheFile := filepath.Join(rc.basePath, "cache", fmt.Sprintf("%s.json", keyStr)) + _ = os.Remove(cacheFile) + } + + return nil +} + +// InvalidateByTask invalidates all cache entries for a task +func (rc *ResultCache) InvalidateByTask(taskID string) error { + rc.mu.Lock() + defer rc.mu.Unlock() + + keysToDelete := make([]string, 0) + for keyStr, entry := range rc.cache { + if entry.Key.TaskID == taskID { + keysToDelete = append(keysToDelete, keyStr) + } + } + + for _, keyStr := range keysToDelete { + delete(rc.cache, keyStr) + + // Delete from disk + cacheFile := filepath.Join(rc.basePath, "cache", fmt.Sprintf("%s.json", keyStr)) + _ = os.Remove(cacheFile) + } + + return nil +} + +// GetHitRate returns the cache hit rate +func (rc *ResultCache) GetHitRate() (float64, int) { + rc.mu.RLock() + defer rc.mu.RUnlock() + + if len(rc.cache) == 0 { + return 0, 0 + } + + totalHits := 0 + for _, entry := range rc.cache { + totalHits += entry.HitCount + } + + if totalHits == 0 { + return 0, len(rc.cache) + } + + return float64(totalHits) / float64(len(rc.cache)), len(rc.cache) +} diff --git a/internal/cache/result_cache_test.go b/internal/cache/result_cache_test.go new file mode 100644 index 0000000..3387f3d --- /dev/null +++ b/internal/cache/result_cache_test.go @@ -0,0 +1,316 @@ +package cache + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCacheKeyString(t *testing.T) { + key := &CacheKey{ + ActivityType: "implementer", + TaskID: "T1.1", + InputHash: "abc123", + ModelID: "claude-opus", + } + + keyStr := key.String() + assert.Contains(t, keyStr, "implementer") + assert.Contains(t, keyStr, "T1.1") + assert.Contains(t, keyStr, "abc123") + assert.Contains(t, keyStr, "claude-opus") +} + +func TestComputeHash(t *testing.T) { + data := map[string]interface{}{ + "task": "T1.1", + "code": "package main", + } + + hash1, err := ComputeHash(data) + assert.NoError(t, err) + assert.NotEmpty(t, hash1) + + hash2, err := ComputeHash(data) + assert.NoError(t, err) + assert.Equal(t, hash1, hash2) +} + +func TestSetAndGet(t *testing.T) { + tmpDir := t.TempDir() + cache := NewResultCache(tmpDir, 100, 0) + + key := &CacheKey{ + ActivityType: "implementer", + TaskID: "T1.1", + InputHash: "abc123", + ModelID: "claude-opus", + } + + result := map[string]interface{}{ + "output": "implementation code", + "files": []string{"file1.go", "file2.go"}, + } + + err := cache.Set(key, result) + assert.NoError(t, err) + + retrieved, found, err := cache.Get(key) + assert.NoError(t, err) + assert.True(t, found) + assert.Equal(t, "implementation code", retrieved["output"]) +} + +func TestCacheMiss(t *testing.T) { + tmpDir := t.TempDir() + cache := NewResultCache(tmpDir, 100, 0) + + key := &CacheKey{ + ActivityType: "implementer", + TaskID: "T1.1", + InputHash: "abc123", + ModelID: "claude-opus", + } + + retrieved, found, err := cache.Get(key) + assert.NoError(t, err) + assert.False(t, found) + assert.Nil(t, retrieved) +} + +func TestInvalidate(t *testing.T) { + tmpDir := t.TempDir() + cache := NewResultCache(tmpDir, 100, 0) + + key := &CacheKey{ + ActivityType: "implementer", + TaskID: "T1.1", + InputHash: "abc123", + ModelID: "claude-opus", + } + + cache.Set(key, map[string]interface{}{"output": "code"}) + assert.Equal(t, 1, cache.GetSize()) + + cache.Invalidate(key) + assert.Equal(t, 0, cache.GetSize()) + + _, found, _ := cache.Get(key) + assert.False(t, found) +} + +func TestClear(t *testing.T) { + tmpDir := t.TempDir() + cache := NewResultCache(tmpDir, 100, 0) + + for i := 0; i < 10; i++ { + key := &CacheKey{ + ActivityType: "implementer", + TaskID: "T1.1", + InputHash: string(rune(48 + i)), + ModelID: "claude-opus", + } + cache.Set(key, map[string]interface{}{"output": "code"}) + } + + assert.Equal(t, 10, cache.GetSize()) + + cache.Clear() + assert.Equal(t, 0, cache.GetSize()) +} + +func TestGetStats(t *testing.T) { + tmpDir := t.TempDir() + cache := NewResultCache(tmpDir, 100, 0) + + key := &CacheKey{ + ActivityType: "implementer", + TaskID: "T1.1", + InputHash: "abc123", + ModelID: "claude-opus", + } + + cache.Set(key, map[string]interface{}{"output": "code"}) + cache.Get(key) // Hit + + stats := cache.GetStats() + assert.Equal(t, 1, stats["size"]) + assert.Equal(t, 100, stats["max_size"]) + assert.Equal(t, 1, stats["total_hits"]) +} + +func TestTTLExpiration(t *testing.T) { + tmpDir := t.TempDir() + cache := NewResultCache(tmpDir, 100, 100*time.Millisecond) + + key := &CacheKey{ + ActivityType: "implementer", + TaskID: "T1.1", + InputHash: "abc123", + ModelID: "claude-opus", + } + + cache.Set(key, map[string]interface{}{"output": "code"}) + + // Should find immediately + _, found, _ := cache.Get(key) + assert.True(t, found) + + // Wait for TTL to expire + time.Sleep(150 * time.Millisecond) + + // Should not find after TTL + _, found, _ = cache.Get(key) + assert.False(t, found) +} + +func TestMaxSizeEviction(t *testing.T) { + tmpDir := t.TempDir() + cache := NewResultCache(tmpDir, 3, 0) + + // Add 3 entries + for i := 0; i < 3; i++ { + key := &CacheKey{ + ActivityType: "implementer", + TaskID: "T1.1", + InputHash: string(rune(48 + i)), + ModelID: "claude-opus", + } + cache.Set(key, map[string]interface{}{"output": "code"}) + } + + assert.Equal(t, 3, cache.GetSize()) + + // Add 4th entry (should evict oldest) + key4 := &CacheKey{ + ActivityType: "implementer", + TaskID: "T1.1", + InputHash: "3", + ModelID: "claude-opus", + } + cache.Set(key4, map[string]interface{}{"output": "code"}) + + // Size should still be 3 + assert.Equal(t, 3, cache.GetSize()) +} + +func TestInvalidateByActivity(t *testing.T) { + tmpDir := t.TempDir() + cache := NewResultCache(tmpDir, 100, 0) + + // Add implementer entries + for i := 0; i < 2; i++ { + key := &CacheKey{ + ActivityType: "implementer", + TaskID: "T1.1", + InputHash: string(rune(48 + i)), + ModelID: "claude-opus", + } + cache.Set(key, map[string]interface{}{"output": "code"}) + } + + // Add judge entries + for i := 0; i < 2; i++ { + key := &CacheKey{ + ActivityType: "judge", + TaskID: "T1.1", + InputHash: string(rune(48 + i)), + ModelID: "claude-opus", + } + cache.Set(key, map[string]interface{}{"output": "verdict"}) + } + + assert.Equal(t, 4, cache.GetSize()) + + // Invalidate implementer entries + cache.InvalidateByActivity("implementer") + + assert.Equal(t, 2, cache.GetSize()) +} + +func TestInvalidateByTask(t *testing.T) { + tmpDir := t.TempDir() + cache := NewResultCache(tmpDir, 100, 0) + + // Add entries for T1.1 + for i := 0; i < 2; i++ { + key := &CacheKey{ + ActivityType: "implementer", + TaskID: "T1.1", + InputHash: string(rune(48 + i)), + ModelID: "claude-opus", + } + cache.Set(key, map[string]interface{}{"output": "code"}) + } + + // Add entries for T1.2 + for i := 0; i < 2; i++ { + key := &CacheKey{ + ActivityType: "implementer", + TaskID: "T1.2", + InputHash: string(rune(48 + i)), + ModelID: "claude-opus", + } + cache.Set(key, map[string]interface{}{"output": "code"}) + } + + assert.Equal(t, 4, cache.GetSize()) + + // Invalidate T1.1 entries + cache.InvalidateByTask("T1.1") + + assert.Equal(t, 2, cache.GetSize()) +} + +func TestGetHitRate(t *testing.T) { + tmpDir := t.TempDir() + cache := NewResultCache(tmpDir, 100, 0) + + key1 := &CacheKey{ + ActivityType: "implementer", + TaskID: "T1.1", + InputHash: "1", + ModelID: "claude-opus", + } + + key2 := &CacheKey{ + ActivityType: "implementer", + TaskID: "T1.1", + InputHash: "2", + ModelID: "claude-opus", + } + + cache.Set(key1, map[string]interface{}{"output": "code"}) + cache.Set(key2, map[string]interface{}{"output": "code"}) + + cache.Get(key1) + cache.Get(key1) + cache.Get(key2) + + hitRate, count := cache.GetHitRate() + assert.Equal(t, 2, count) + assert.GreaterOrEqual(t, hitRate, 1.0) +} + +func TestPersistence(t *testing.T) { + tmpDir := t.TempDir() + cache1 := NewResultCache(tmpDir, 100, 0) + + key := &CacheKey{ + ActivityType: "implementer", + TaskID: "T1.1", + InputHash: "abc123", + ModelID: "claude-opus", + } + + cache1.Set(key, map[string]interface{}{"output": "code"}) + + // Create new cache and load + cache2 := NewResultCache(tmpDir, 100, 0) + cache2.Load() + + retrieved, found, _ := cache2.Get(key) + assert.True(t, found) + assert.Equal(t, "code", retrieved["output"]) +} diff --git a/tasks/board-T2.md b/tasks/board-T2.md index f3899d2..70dc2ad 100644 --- a/tasks/board-T2.md +++ b/tasks/board-T2.md @@ -4,7 +4,7 @@ | ID | Scope | Status | Branch | Verification | |----|-------|--------|--------|--------------| -| T2.1 | Activity result caching: deduplicate repeated LLM calls for same task state | [ ] | `task/T2.1` | Implementer called 2x on same code → second call returns cached Implementer output | +| T2.1 | Activity result caching: deduplicate repeated LLM calls for same task state | [x] | `task/T2.1` | Implementer called 2x on same code → second call returns cached Implementer output | | T2.2 | Parallel task dispatch: multiple T0.x tasks execute truly concurrently (not sequential) | [ ] | `task/T2.2` | 9 tasks complete in ~1/9 total time (wall-clock speedup measured) | | T2.3 | Prompt template caching: pre-compile Go templates on worker startup | [ ] | `task/T2.3` | Template render latency < 100ms (vs parse+render each time) | | T2.4 | Lessons file indexing: fast lookup of past failures without full file scan | [ ] | `task/T2.4` | Query lessons by task type → return in < 10ms for 1000s of entries |