diff --git a/server/src/capabilities.ts b/server/src/capabilities.ts index 1a0707c07f..9c7a81a621 100644 --- a/server/src/capabilities.ts +++ b/server/src/capabilities.ts @@ -266,11 +266,15 @@ export class CapabilityDiscovery { this.formatsService = new FormatsService(); } - async discoverCapabilities(agent: Agent, auth?: SdkAuth): Promise { + async discoverCapabilities(agent: Agent, auth?: SdkAuth, forceRefresh = false): Promise { // Skip cache when auth is provided — manual owner-triggered refresh // wants fresh data and may previously have cached an unauthed // discovery_error result. Periodic crawls (no auth) keep the cache. - if (!auth) { + // `forceRefresh` covers the same manual-refresh intent for agents with + // no saved auth (e.g. the "Recheck Status" button on an unowned/public + // agent) — without it, a fresh probe would still be shadowed by a stale + // unauthed cache entry for up to CACHE_TTL_MS. + if (!auth && !forceRefresh) { const cached = this.cache.get(agent.url); if (cached && Date.now() - new Date(cached.last_discovered).getTime() < this.CACHE_TTL_MS) { return cached; @@ -304,7 +308,7 @@ export class CapabilityDiscovery { profile.standard_operations = this.analyzeSalesCapabilities(tools); } if (CapabilityDiscovery.CREATIVE_TOOLS.some(t => toolNames.has(t))) { - profile.creative_capabilities = await this.analyzeCreativeCapabilities(agent, tools, auth); + profile.creative_capabilities = await this.analyzeCreativeCapabilities(agent, tools, auth, forceRefresh); } if (CapabilityDiscovery.SIGNALS_TOOLS.some(t => toolNames.has(t))) { profile.signals_capabilities = this.analyzeSignalsCapabilities(tools); @@ -487,14 +491,14 @@ export class CapabilityDiscovery { }; } - private async analyzeCreativeCapabilities(agent: Agent, tools: ToolCapability[], auth?: SdkAuth): Promise { + private async analyzeCreativeCapabilities(agent: Agent, tools: ToolCapability[], auth?: SdkAuth, forceRefresh = false): Promise { const toolNames = new Set(tools.map((t) => t.name.toLowerCase())); const hasFormatTool = toolNames.has("list_creative_formats"); let formats: string[] = []; if (hasFormatTool) { try { - const formatsProfile = await this.formatsService.getFormatsForAgent(agent, auth); + const formatsProfile = await this.formatsService.getFormatsForAgent(agent, auth, forceRefresh); formats = formatsProfile.formats.map(f => f.name); } catch (error: any) { logger.debug({ url: agent.url, error: error.message }, 'Format discovery failed'); diff --git a/server/src/crawler.ts b/server/src/crawler.ts index 093204096f..f74b285991 100644 --- a/server/src/crawler.ts +++ b/server/src/crawler.ts @@ -1073,8 +1073,13 @@ export class CrawlerService { ...(known?.health_check_url ? { health_check_url: known.health_check_url } : {}), }; + // `forceRefresh: true` — this method backs the manual "Recheck Status" + // action, which must always hit the live endpoint. Without it, an agent + // with no saved owner auth would still read the shared 15-minute + // capability/health cache and keep reporting a fixed-then-redeployed + // issue as unresolved (#5777). const profile = await Promise.race([ - this.capabilityDiscovery.discoverCapabilities(agent, auth), + this.capabilityDiscovery.discoverCapabilities(agent, auth, true), new Promise((_, reject) => setTimeout(() => reject(new Error('Probe timeout')), PROBE_TIMEOUT_MS) ), @@ -1086,7 +1091,7 @@ export class CrawlerService { const [health, stats] = await Promise.all([ Promise.race([ - this.healthChecker.checkHealth(agentForHealth, auth), + this.healthChecker.checkHealth(agentForHealth, auth, true), new Promise((_, reject) => setTimeout(() => reject(new Error('Health timeout')), PROBE_TIMEOUT_MS) ), @@ -1096,7 +1101,7 @@ export class CrawlerService { error: err instanceof Error ? err.message : 'health check failed', })), Promise.race([ - this.healthChecker.getStats(agentForHealth, auth), + this.healthChecker.getStats(agentForHealth, auth, true), new Promise((_, reject) => setTimeout(() => reject(new Error('Stats timeout')), PROBE_TIMEOUT_MS) ), diff --git a/server/src/formats.ts b/server/src/formats.ts index 25e73de51f..06502d4319 100644 --- a/server/src/formats.ts +++ b/server/src/formats.ts @@ -19,8 +19,8 @@ export class FormatsService { private authedClients: WeakMap> = new WeakMap(); private readonly CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes - async getFormatsForAgent(agent: Agent, auth?: SdkAuth): Promise { - if (!auth) { + async getFormatsForAgent(agent: Agent, auth?: SdkAuth, forceRefresh = false): Promise { + if (!auth && !forceRefresh) { const cached = this.cache.get(agent.url); if (cached && Date.now() - new Date(cached.last_fetched).getTime() < this.CACHE_TTL_MS) { return cached; diff --git a/server/src/health.ts b/server/src/health.ts index 7829d5c121..37b8695920 100644 --- a/server/src/health.ts +++ b/server/src/health.ts @@ -181,10 +181,13 @@ export class HealthChecker { this.formatsService = new FormatsService(); } - async checkHealth(agent: Agent, auth?: SdkAuth): Promise { + async checkHealth(agent: Agent, auth?: SdkAuth, forceRefresh = false): Promise { // Skip cache when auth is provided — manual owner-triggered refresh // wants fresh data. Periodic crawls (no auth) keep the cache. - if (!auth) { + // `forceRefresh` covers the same intent for a manual refresh of an + // agent with no saved auth, which would otherwise still read the + // stale unauthed cache entry. + if (!auth && !forceRefresh) { const cached = this.healthCache.get(agent.url); if (cached) return cached; } @@ -353,18 +356,18 @@ export class HealthChecker { } } - async getStats(agent: Agent, auth?: SdkAuth): Promise { - if (!auth) { + async getStats(agent: Agent, auth?: SdkAuth, forceRefresh = false): Promise { + if (!auth && !forceRefresh) { const cached = this.statsCache.get(agent.url); if (cached) return cached; } - const stats = await this.fetchStats(agent, auth); + const stats = await this.fetchStats(agent, auth, forceRefresh); if (!auth) this.statsCache.set(agent.url, stats); return stats; } - private async fetchStats(agent: Agent, auth?: SdkAuth): Promise { + private async fetchStats(agent: Agent, auth?: SdkAuth, forceRefresh = false): Promise { const stats: AgentStats = {}; try { @@ -381,7 +384,7 @@ export class HealthChecker { } else if (agent.type === "creative") { // For creative agents, get format count from FormatsService try { - const formatsProfile = await this.formatsService.getFormatsForAgent(agent, auth); + const formatsProfile = await this.formatsService.getFormatsForAgent(agent, auth, forceRefresh); if (formatsProfile.formats && formatsProfile.formats.length > 0) { stats.creative_formats = formatsProfile.formats.length; } diff --git a/server/tests/unit/capability-discovery-auth-classification.test.ts b/server/tests/unit/capability-discovery-auth-classification.test.ts index 7772b2d0e4..3771744d67 100644 --- a/server/tests/unit/capability-discovery-auth-classification.test.ts +++ b/server/tests/unit/capability-discovery-auth-classification.test.ts @@ -64,4 +64,33 @@ describe('CapabilityDiscovery auth classification', () => { expect(profile.discovery_error).toBe('Unauthorized'); expect(profile.oauth_required).toBe(false); }); + + it('serves a cached unauthed profile within the TTL by default', async () => { + getAgentInfoMock.mockResolvedValueOnce({ tools: [{ name: 'list_creative_formats' }] }); + + const discovery = new CapabilityDiscovery(); + const first = await discovery.discoverCapabilities(AGENT); + expect(first.discovered_tools).toHaveLength(1); + + const callsAfterFirst = getAgentInfoMock.mock.calls.length; + + // Second call should hit the cache — getAgentInfo must not be called again. + const second = await discovery.discoverCapabilities(AGENT); + expect(getAgentInfoMock.mock.calls.length).toBe(callsAfterFirst); + expect(second).toBe(first); + }); + + it('bypasses the unauthed cache when forceRefresh is set (manual "Recheck Status")', async () => { + getAgentInfoMock.mockResolvedValueOnce({ tools: [{ name: 'list_creative_formats' }] }); + + const discovery = new CapabilityDiscovery(); + await discovery.discoverCapabilities(AGENT); + const callsAfterFirst = getAgentInfoMock.mock.calls.length; + + getAgentInfoMock.mockResolvedValueOnce({ tools: [] }); + const refreshed = await discovery.discoverCapabilities(AGENT, undefined, true); + + expect(getAgentInfoMock.mock.calls.length).toBe(callsAfterFirst + 1); + expect(refreshed.discovered_tools).toEqual([]); + }); });