Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/node/orpc/agentSkillsDiagnosticsCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test";

import {
AgentSkillTransientDiscoveryError,
type DiscoverAgentSkillsDiagnosticsResult,
} from "@/node/services/agentSkills/agentSkillsService";
import {
getAgentSkillsDiscoveryCacheKey,
loadAgentSkillsDiagnosticsWithFallback,
} from "./agentSkillsDiagnosticsCache";

describe("agentSkillsDiagnosticsCache", () => {
test("reuses cached diagnostics after a transient discovery failure", async () => {
const cache = new Map<string, DiscoverAgentSkillsDiagnosticsResult>();
const cacheKey = getAgentSkillsDiscoveryCacheKey({
workspaceId: "workspace-1",
disableWorkspaceAgents: true,
});

const freshDiagnostics = {
skills: [
{
name: "pull-requests",
description: "PR workflow",
scope: "project" as const,
},
],
invalidSkills: [],
};

const seeded = await loadAgentSkillsDiagnosticsWithFallback({
cache,
cacheKey,
discover: () => Promise.resolve(freshDiagnostics),
});
expect(seeded).toBe(freshDiagnostics);

const fallback = await loadAgentSkillsDiagnosticsWithFallback({
cache,
cacheKey,
discover: () =>
Promise.reject(
new AgentSkillTransientDiscoveryError("SSH connection to host is in backoff")
),
});

expect(fallback).toBe(freshDiagnostics);
});

test("does not hide non-transient discovery failures", async () => {
const cache = new Map<string, DiscoverAgentSkillsDiagnosticsResult>();
const cacheKey = getAgentSkillsDiscoveryCacheKey({ projectPath: "/repo" });

await loadAgentSkillsDiagnosticsWithFallback({
cache,
cacheKey,
discover: () => Promise.resolve({ skills: [], invalidSkills: [] }),
});

let caught: unknown;
try {
await loadAgentSkillsDiagnosticsWithFallback({
cache,
cacheKey,
discover: () => Promise.reject(new Error("SKILL.md has invalid frontmatter")),
});
} catch (error) {
caught = error;
}

expect(caught).toBeInstanceOf(Error);
if (!(caught instanceof Error)) {
throw new Error("expected an error to be thrown");
}
expect(caught.message).toContain("invalid frontmatter");
});

test("rethrows transient discovery errors when no cache exists", async () => {
const cache = new Map<string, DiscoverAgentSkillsDiagnosticsResult>();
const cacheKey = getAgentSkillsDiscoveryCacheKey({ projectPath: "/repo" });

let caught: unknown;
try {
await loadAgentSkillsDiagnosticsWithFallback({
cache,
cacheKey,
discover: () =>
Promise.reject(new AgentSkillTransientDiscoveryError("Connection timed out")),
});
} catch (error) {
caught = error;
}

expect(caught).toBeInstanceOf(AgentSkillTransientDiscoveryError);
});
});
50 changes: 50 additions & 0 deletions src/node/orpc/agentSkillsDiagnosticsCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
AgentSkillTransientDiscoveryError,
type DiscoverAgentSkillsDiagnosticsResult,
} from "@/node/services/agentSkills/agentSkillsService";
import { log } from "@/node/services/log";

export interface AgentSkillsDiscoveryCacheInput {
projectPath?: string;
workspaceId?: string;
disableWorkspaceAgents?: boolean;
}

export function getAgentSkillsDiscoveryCacheKey(input: AgentSkillsDiscoveryCacheInput): string {
const disableWorkspaceAgents = input.disableWorkspaceAgents === true ? "1" : "0";

if (input.workspaceId) {
return `workspace:${input.workspaceId}:disableWorkspaceAgents:${disableWorkspaceAgents}`;
}

if (input.projectPath) {
return `project:${input.projectPath}:disableWorkspaceAgents:${disableWorkspaceAgents}`;
}

throw new Error("Either projectPath or workspaceId must be provided");
}

export async function loadAgentSkillsDiagnosticsWithFallback(args: {
cache: Map<string, DiscoverAgentSkillsDiagnosticsResult>;
cacheKey: string;
discover: () => Promise<DiscoverAgentSkillsDiagnosticsResult>;
}): Promise<DiscoverAgentSkillsDiagnosticsResult> {
try {
const diagnostics = await args.discover();
args.cache.set(args.cacheKey, diagnostics);
return diagnostics;
} catch (error) {
if (error instanceof AgentSkillTransientDiscoveryError) {
const cached = args.cache.get(args.cacheKey);
// During SSH hiccups we prefer a stale-but-correct snapshot over surfacing false invalid-skill diagnostics.
if (cached) {
log.warn(
`Agent skill diagnostics discovery transiently failed for ${args.cacheKey}; using cached result: ${error.message}`
);
return cached;
}
}

throw error;
}
}
16 changes: 15 additions & 1 deletion src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,13 @@ import {
import {
discoverAgentSkills,
discoverAgentSkillsDiagnostics,
type DiscoverAgentSkillsDiagnosticsResult,
readAgentSkill,
} from "@/node/services/agentSkills/agentSkillsService";
import {
getAgentSkillsDiscoveryCacheKey,
loadAgentSkillsDiagnosticsWithFallback,
} from "./agentSkillsDiagnosticsCache";
import {
discoverAgentDefinitions,
readAgentDefinition,
Expand Down Expand Up @@ -107,6 +112,8 @@ async function resolveAgentDiscoveryContext(
return { runtime, discoveryPath: input.projectPath! };
}

const agentSkillsDiagnosticsCache = new Map<string, DiscoverAgentSkillsDiagnosticsResult>();

function isErrnoWithCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
Expand Down Expand Up @@ -839,6 +846,8 @@ export const router = (authToken?: string) => {
await context.aiService.waitForInit(input.workspaceId);
}
const { runtime, discoveryPath } = await resolveAgentDiscoveryContext(context, input);
// Keep list resilient on first-load transient SSH hiccups by using non-diagnostic
// discovery, which skips only the affected skill instead of failing the whole request.
return discoverAgentSkills(runtime, discoveryPath);
}),
listDiagnostics: t
Expand All @@ -850,7 +859,12 @@ export const router = (authToken?: string) => {
await context.aiService.waitForInit(input.workspaceId);
}
const { runtime, discoveryPath } = await resolveAgentDiscoveryContext(context, input);
return discoverAgentSkillsDiagnostics(runtime, discoveryPath);
const cacheKey = getAgentSkillsDiscoveryCacheKey(input);
return loadAgentSkillsDiagnosticsWithFallback({
cache: agentSkillsDiagnosticsCache,
cacheKey,
discover: () => discoverAgentSkillsDiagnostics(runtime, discoveryPath),
});
}),
get: t
.input(schemas.agentSkills.get.input)
Expand Down
102 changes: 101 additions & 1 deletion src/node/services/agentSkills/agentSkillsService.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";

import { describe, expect, test } from "bun:test";
import { describe, expect, spyOn, test } from "bun:test";

import { SkillNameSchema } from "@/common/orpc/schemas";
import { LocalRuntime } from "@/node/runtime/LocalRuntime";
import { DisposableTempDir } from "@/node/services/tempDir";
import {
AgentSkillTransientDiscoveryError,
discoverAgentSkills,
discoverAgentSkillsDiagnostics,
readAgentSkill,
Expand Down Expand Up @@ -225,6 +226,105 @@ describe("agentSkillsService", () => {
).toContain("must match directory name");
});

test("discoverAgentSkillsDiagnostics rethrows transient stat errors", async () => {
using project = new DisposableTempDir("agent-skills-transient-stat");
using global = new DisposableTempDir("agent-skills-global");

const projectSkillsRoot = path.join(project.path, ".mux", "skills");
const globalSkillsRoot = global.path;

await writeSkill(projectSkillsRoot, "foo", "valid");

const roots = { projectRoot: projectSkillsRoot, globalRoot: globalSkillsRoot };
const runtime = new LocalRuntime(project.path);
const originalStat = runtime.stat.bind(runtime);

spyOn(runtime, "stat").mockImplementation(
async (targetPath: string, abortSignal?: AbortSignal) => {
if (targetPath.endsWith(path.join("foo", "SKILL.md"))) {
throw new Error(
"SSH connection to test-host is in backoff for 2s. Last error: connection timed out"
);
}

return originalStat(targetPath, abortSignal);
}
);

let caught: unknown;
try {
await discoverAgentSkillsDiagnostics(runtime, project.path, { roots });
} catch (error) {
caught = error;
}

expect(caught).toBeInstanceOf(AgentSkillTransientDiscoveryError);
});

test("discoverAgentSkillsDiagnostics rethrows transient read errors", async () => {
using project = new DisposableTempDir("agent-skills-transient-read");
using global = new DisposableTempDir("agent-skills-global");

const projectSkillsRoot = path.join(project.path, ".mux", "skills");
const globalSkillsRoot = global.path;

await writeSkill(projectSkillsRoot, "foo", "valid");

const roots = { projectRoot: projectSkillsRoot, globalRoot: globalSkillsRoot };
const runtime = new LocalRuntime(project.path);
const originalReadFile = runtime.readFile.bind(runtime);

spyOn(runtime, "readFile").mockImplementation(
(targetPath: string, abortSignal?: AbortSignal) => {
if (targetPath.endsWith(path.join("foo", "SKILL.md"))) {
throw new Error("kex_exchange_identification: Connection closed by remote host");
}

return originalReadFile(targetPath, abortSignal);
}
);

let caught: unknown;
try {
await discoverAgentSkillsDiagnostics(runtime, project.path, { roots });
} catch (error) {
caught = error;
}

expect(caught).toBeInstanceOf(AgentSkillTransientDiscoveryError);
});

test("discoverAgentSkills skips transient skill I/O failures and returns other skills", async () => {
using project = new DisposableTempDir("agent-skills-transient-discovery");
using global = new DisposableTempDir("agent-skills-global");

const projectSkillsRoot = path.join(project.path, ".mux", "skills");
const globalSkillsRoot = global.path;

await writeSkill(projectSkillsRoot, "foo", "valid");
await writeSkill(projectSkillsRoot, "bar", "also valid");

const roots = { projectRoot: projectSkillsRoot, globalRoot: globalSkillsRoot };
const runtime = new LocalRuntime(project.path);
const originalStat = runtime.stat.bind(runtime);

spyOn(runtime, "stat").mockImplementation(
async (targetPath: string, abortSignal?: AbortSignal) => {
if (targetPath.endsWith(path.join("foo", "SKILL.md"))) {
throw new Error(
"SSH connection to test-host is in backoff for 2s. Last error: connection timed out"
);
}

return originalStat(targetPath, abortSignal);
}
);

const skills = await discoverAgentSkills(runtime, project.path, { roots });

expect(skills.map((skill) => skill.name)).toEqual(["bar", "init", "mux-docs"]);
});

test("discovers symlinked skill directories", async () => {
using project = new DisposableTempDir("agent-skills-symlink");
using skillSource = new DisposableTempDir("agent-skills-source");
Expand Down
Loading
Loading