From 6c17aad8d7b27664ec9527962d816dc34acbe5d2 Mon Sep 17 00:00:00 2001 From: viettranx Date: Sat, 11 Apr 2026 11:32:59 +0700 Subject: [PATCH] feat(gateway/methods): add cache-aware resolveAgentUUIDCached helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- internal/agent/loop_tracing.go | 9 ++- internal/agent/router.go | 18 +++++ internal/gateway/methods/agent_links.go | 35 +++++++++ .../methods/agent_resolve_cached_test.go | 75 +++++++++++++++++++ 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 internal/gateway/methods/agent_resolve_cached_test.go diff --git a/internal/agent/loop_tracing.go b/internal/agent/loop_tracing.go index 4aec88a6..e8174cee 100644 --- a/internal/agent/loop_tracing.go +++ b/internal/agent/loop_tracing.go @@ -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 } diff --git a/internal/agent/router.go b/internal/agent/router.go index ce6a517e..a103a192 100644 --- a/internal/agent/router.go +++ b/internal/agent/router.go @@ -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 diff --git a/internal/gateway/methods/agent_links.go b/internal/gateway/methods/agent_links.go index 290bf5fc..7a65c2d0 100644 --- a/internal/gateway/methods/agent_links.go +++ b/internal/gateway/methods/agent_links.go @@ -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 { diff --git a/internal/gateway/methods/agent_resolve_cached_test.go b/internal/gateway/methods/agent_resolve_cached_test.go new file mode 100644 index 00000000..547c0d5c --- /dev/null +++ b/internal/gateway/methods/agent_resolve_cached_test.go @@ -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) + } +}