feat(T2.1): implement activity result caching
- Add internal/cache package for deduplicating activity results - Implement ResultCache with MD5 hash-based cache keys - Support cache by activity type, task ID, input hash, model ID - Configurable max size with FIFO eviction policy - TTL support for automatic expiration - Persistence to JSON for recovery across runs - Query operations: by activity type, by task ID - Hit rate tracking and statistics - 13 cache tests, all passing Features: - ComputeHash() for input deduplication - Set/Get operations with TTL support - Invalidation by activity type or task ID - Cache stats with usage ratio - Full cache clear - Disk persistence with JSON storage - Hit rate calculation Performance: - Avoids redundant LLM calls - Reduces API costs - Faster workflow execution - Configurable eviction policies Test Coverage: - 13 cache tests (set/get, TTL, eviction, persistence) - Hit rate calculation verified - Invalidation tested - Multi-entry scenarios Next: T2.2 (Parallel task dispatch)
This commit is contained in:
Vendored
+319
@@ -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)
|
||||||
|
}
|
||||||
Vendored
+316
@@ -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"])
|
||||||
|
}
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
| ID | Scope | Status | Branch | Verification |
|
| 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.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.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 |
|
| 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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user