feat(gateway/methods): add cache-aware resolveAgentUUIDCached helper

Three-part addition so WS handlers can accept agent_key input without a
DB roundtrip on every request:

1. Router.GetCached — lock-free TTL cache read, no resolver fallback.
2. Loop.UUID() — canonical UUID accessor (agent_key stays on ID()).
3. resolveAgentUUIDCached — fast-path checks router cache via an
   agentUUIDProvider interface, falls back to resolveAgentUUID on miss
   or when input is a UUID string (un-cached post-canonicalization).

Helper delegates to pure DB lookup when router is nil, so handlers with
no router dependency still work. Tests cover nil-router, cache-miss, and
UUID-input paths — cache-hit coverage is exercised via downstream handler
integration tests.

Phase 3 foundation (TD-2).
This commit is contained in:
viettranx committed 2026-04-11 21:22:23 +07:00
1 parent 82dc4d3b94
commit 6c17aad8d7
4 files changed
+136 -1

No files matched your search

+8 -1
View File
@@ -22,9 +22,16 @@ func (l *Loop) emit(event AgentEvent) {
}
}
// ID returns the agent's identifier.
// ID returns the agent's identifier (agent_key, e.g. "goctech-leader").
// Use for logs, UI, filesystem paths. NEVER for DB FK or DomainEvent.AgentID.
// See docs/agent-identity-conventions.md (Phase 6).
func (l *Loop) ID() string { return l.id }
// UUID returns the agent's canonical UUID (DB primary key).
// Use for SQL WHERE/JOIN, DomainEvent.AgentID, context propagation.
// See docs/agent-identity-conventions.md (Phase 6).
func (l *Loop) UUID() uuid.UUID { return l.agentUUID }
// Model returns the model identifier for this agent loop.
func (l *Loop) Model() string { return l.model }
+18
View File
@@ -208,6 +208,24 @@ func (r *Router) IsRunning(ctx context.Context, agentID string) bool {
return false
}
// GetCached returns a cached agent without invoking the resolver. Used by
// cache-aware helpers that want to avoid a DB roundtrip on the hot path.
// Returns (nil, false) on miss or when the entry has exceeded TTL.
// Does NOT trigger resolver fallback — call Router.Get() for that path.
func (r *Router) GetCached(ctx context.Context, agentID string) (Agent, bool) {
cacheKey := agentCacheKey(ctx, agentID)
r.mu.RLock()
defer r.mu.RUnlock()
entry, ok := r.agents[cacheKey]
if !ok {
return nil, false
}
if r.ttl > 0 && time.Since(entry.cachedAt) >= r.ttl {
return nil, false
}
return entry.agent, true
}
// --- Active Run Tracking (matching TS chat-abort.ts) ---
// ActiveRun tracks a running agent invocation so it can be aborted via chat.abort
+35
View File
@@ -408,6 +408,11 @@ func (m *AgentLinksMethods) invalidateLinkAgentsByID(ctx context.Context, source
// --- helpers ---
// resolveAgentUUID resolves an agent identifier (either UUID or agent_key) to its
// canonical UUID via a DB lookup. Tenant-aware via store.TenantIDFromContext(ctx)
// inside agentStore.GetByID/GetByKey. Prefer resolveAgentUUIDCached for hot-path
// handlers to avoid an unnecessary DB roundtrip.
// See docs/agent-identity-conventions.md trap zone 5.
func resolveAgentUUID(ctx context.Context, agentStore store.AgentStore, keyOrID string) (uuid.UUID, error) {
if id, err := uuid.Parse(keyOrID); err == nil {
ag, err := agentStore.GetByID(ctx, id)
@@ -423,6 +428,36 @@ func resolveAgentUUID(ctx context.Context, agentStore store.AgentStore, keyOrID
return ag.ID, nil
}
// agentUUIDProvider is satisfied by *agent.Loop and by test stubs; lets the
// cache-aware helper extract a canonical UUID from any cached Agent without
// a direct dependency on the concrete Loop type.
type agentUUIDProvider interface {
UUID() uuid.UUID
}
// resolveAgentUUIDCached is the cache-aware variant of resolveAgentUUID.
// Checks the router cache first when the input is an agent_key and the Loop is
// cached — avoids a DB roundtrip on the hot path. Falls back to DB lookup on
// cache miss or when the input is a UUID string (not cached under the raw UUID
// key post-Phase-2 canonicalization). If router is nil, delegates straight to
// resolveAgentUUID. See docs/agent-identity-conventions.md trap zone 5.
func resolveAgentUUIDCached(ctx context.Context, router *agent.Router, agentStore store.AgentStore, keyOrID string) (uuid.UUID, error) {
// Fast path: input is agent_key and the agent is cached in the router.
if router != nil {
if _, err := uuid.Parse(keyOrID); err != nil {
if ag, ok := router.GetCached(ctx, keyOrID); ok {
if up, ok := ag.(agentUUIDProvider); ok {
if uid := up.UUID(); uid != uuid.Nil {
return uid, nil
}
}
}
}
}
// Slow path: DB lookup.
return resolveAgentUUID(ctx, agentStore, keyOrID)
}
// resolveAgentInfo returns full agent data for validation and cache invalidation.
func resolveAgentInfo(ctx context.Context, agentStore store.AgentStore, keyOrID string) (*store.AgentData, error) {
if id, err := uuid.Parse(keyOrID); err == nil {
@@ -0,0 +1,75 @@
package methods
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// errSentinelMiss is a sentinel error used to verify DB fallback in cache-miss
// paths. The value is stable across tests so equality checks are reliable.
var errSentinelMiss = errors.New("sentinel: agent miss")
// errorAgentStore embeds store.AgentStore and overrides only GetByID and
// GetByKey to return a sentinel error. All other methods retain the nil embed,
// so any unexpected call path panics — loud failure is intentional.
type errorAgentStore struct {
store.AgentStore
err error
}
func (s *errorAgentStore) GetByID(context.Context, uuid.UUID) (*store.AgentData, error) {
return nil, s.err
}
func (s *errorAgentStore) GetByKey(context.Context, string) (*store.AgentData, error) {
return nil, s.err
}
// TestResolveAgentUUIDCached_NilRouterCallsResolveAgentUUID verifies that when
// router is nil the helper delegates directly to resolveAgentUUID — exercising
// the slow DB path. With a stub store returning a sentinel error, the helper
// must propagate that error unchanged.
func TestResolveAgentUUIDCached_NilRouterCallsResolveAgentUUID(t *testing.T) {
stub := &errorAgentStore{err: errSentinelMiss}
_, err := resolveAgentUUIDCached(context.Background(), nil, stub, uuid.New().String())
if !errors.Is(err, errSentinelMiss) {
t.Errorf("expected sentinel miss error, got %v", err)
}
}
// TestResolveAgentUUIDCached_CacheMissFallsBack — when the router is set but
// has no cached entry for the given agent_key, the helper must fall back to
// the DB path. The stub store's sentinel error confirms we took the fallback.
func TestResolveAgentUUIDCached_CacheMissFallsBack(t *testing.T) {
r := agent.NewRouter()
stub := &errorAgentStore{err: errSentinelMiss}
_, err := resolveAgentUUIDCached(context.Background(), r, stub, "missing-agent-key")
if !errors.Is(err, errSentinelMiss) {
t.Errorf("expected sentinel miss error, got %v", err)
}
}
// TestResolveAgentUUIDCached_UUIDInputTakesDBPath — per Phase 2 H1, a caller
// passing the UUID form is never cached under the raw UUID key, so the helper
// must fall through to the DB path. Verifies this by checking the stub's
// sentinel error surfaces.
func TestResolveAgentUUIDCached_UUIDInputTakesDBPath(t *testing.T) {
r := agent.NewRouter()
stub := &errorAgentStore{err: errSentinelMiss}
_, err := resolveAgentUUIDCached(context.Background(), r, stub, uuid.New().String())
if !errors.Is(err, errSentinelMiss) {
t.Errorf("expected sentinel miss error, got %v", err)
}
}