fix(core): automatically reload symlinked instruction targets

This commit is contained in:
NubeBuster
2026-10-07 19:11:53 +02:00
parent c99aab4603
commit ab59fbca48
5 changed files with 289 additions and 22 deletions
+13
View File
@@ -347,6 +347,19 @@ standalone adapters do this in the `errorCode` dimension, and use the bounded
labels `signal` and `no_exit_code` for `CommandTerminationError`. Only an actual
numeric exit is reported as `exitCode`.
## Symlinked instruction files
Rules and workflows can be symbolic links to regular files. Source paths and
fallback names use the link's name and location, not the target's. Broken,
circular, and non-file targets are not loaded.
When started, `UnifiedConfigFileWatcher` also watches the parent directories of
symlink targets and intermediate links. External edits, atomic saves, link
retargeting, and target deletion/recreation automatically refresh the affected
config types. Broken targets can recover while their parent directories exist
and are accessible. Unused target-directory watches are removed on rediscovery;
`stop()` closes all watches.
## Unknown model completion recovery
The AI SDK adapter maps unified `length` to `max-tokens` and missing or unrecognized reasons (including `other`) to `unknown`. Explicit `stop`, `tool-calls`, `content-filter`, and `error` retain their meanings. The agent also treats a stream without a finish event as unknown.
@@ -1,6 +1,8 @@
import {
mkdir,
mkdtemp,
realpath,
rename,
rm,
symlink,
unlink,
@@ -126,6 +128,132 @@ describe("UnifiedConfigFileWatcher", () => {
},
);
it.skipIf(process.platform === "win32")(
"automatically reloads symlink chains after edits, atomic saves, deletion, recreation, and retargeting",
async () => {
const tempRoot = await realpath(
await mkdtemp(join(tmpdir(), "core-config-symlink-watch-")),
);
tempRoots.push(tempRoot);
const profilesDir = join(tempRoot, "profiles");
const linksDir = join(tempRoot, "links");
const sourceDir = join(tempRoot, "source");
const alternateDir = join(tempRoot, "alternate");
const linkedDirectory = join(tempRoot, "nested", "linked-directory");
for (const dir of [profilesDir, linksDir, sourceDir, alternateDir]) {
await mkdir(dir);
}
await mkdir(join(tempRoot, "nested"));
await symlink(linksDir, linkedDirectory, "dir");
const sourcePath = join(sourceDir, "source.profile");
const alternatePath = join(alternateDir, "source.profile");
const intermediatePath = join(linksDir, "alias.profile");
const linkedPath = join(profilesDir, "linked.profile");
await writeFile(sourcePath, "name: Linked\n\nOriginal content.");
await writeFile(alternatePath, "name: Linked\n\nAlternate content.");
await symlink(
join("..", "source", "source.profile"),
intermediatePath,
"file",
);
// The intermediate link's relative target must resolve against its
// physical parent, not against this directory alias's lexical path.
await symlink(join(linkedDirectory, "alias.profile"), linkedPath, "file");
const watcher = new UnifiedConfigFileWatcher(
[
{
type: "profile" as const,
directories: [profilesDir],
includeFile: (fileName) => fileName.endsWith(".profile"),
parseFile: (context) => parseTestProfileConfig(context.content),
resolveId: (config) => config.name.toLowerCase(),
},
],
{ debounceMs: 10 },
);
const events: Array<
UnifiedConfigWatcherEvent<"profile", TestProfileConfig>
> = [];
const unsubscribe = watcher.subscribe((event) => events.push(event));
const watchedDirectories = () =>
new Set(
(
Reflect.get(watcher, "watchersByDirectory") as Map<string, unknown>
).keys(),
);
const waitForBody = async (body: string) => {
await waitForEvent(
events,
(event) => event.kind === "upsert" && event.record.item.body === body,
);
expect(watcher.getSnapshot("profile").get("linked")).toMatchObject({
filePath: linkedPath,
item: { body },
});
events.length = 0;
};
try {
await watcher.start();
expect(watchedDirectories()).toEqual(
new Set([profilesDir, linkedDirectory, sourceDir]),
);
events.length = 0;
await writeFile(sourcePath, "name: Linked\n\nEdited content.");
await waitForBody("Edited content.");
const savedPath = join(sourceDir, "saved.profile");
await writeFile(savedPath, "name: Linked\n\nAtomically saved content.");
await rename(savedPath, sourcePath);
await waitForBody("Atomically saved content.");
await writeFile(
sourcePath,
"name: Linked\n\nEdited after atomic save.",
);
await waitForBody("Edited after atomic save.");
await unlink(sourcePath);
await waitForEvent(
events,
(event) => event.kind === "remove" && event.id === "linked",
);
expect(watcher.getSnapshot("profile").size).toBe(0);
events.length = 0;
await writeFile(sourcePath, "name: Linked\n\nRecreated content.");
await waitForBody("Recreated content.");
await unlink(intermediatePath);
await symlink(alternatePath, intermediatePath, "file");
await waitForBody("Alternate content.");
expect(watchedDirectories()).toEqual(
new Set([profilesDir, linkedDirectory, alternateDir]),
);
await writeFile(
alternatePath,
"name: Linked\n\nEdited alternate content.",
);
await waitForBody("Edited alternate content.");
await unlink(linkedPath);
await symlink(sourcePath, linkedPath, "file");
await waitForBody("Recreated content.");
expect(watchedDirectories()).toEqual(new Set([profilesDir, sourceDir]));
await writeFile(sourcePath, "name: Linked\n\nEdited direct target.");
await waitForBody("Edited direct target.");
await unlink(linkedPath);
await waitForEvent(
events,
(event) => event.kind === "remove" && event.id === "linked",
);
expect(watchedDirectories()).toEqual(new Set([profilesDir]));
} finally {
watcher.stop();
unsubscribe();
expect(watchedDirectories().size).toBe(0);
}
},
15_000,
);
it("emits upsert and remove events with config type", async () => {
const tempRoot = await mkdtemp(
join(tmpdir(), "core-unified-config-watcher-"),
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import { type FSWatcher, watch } from "node:fs";
import { readdir, readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { readdir, readFile, readlink, realpath, stat } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
export interface UnifiedConfigFileContext<TType extends string = string> {
type: TType;
@@ -91,6 +91,32 @@ function isInaccessibleDirectoryError(error: unknown): boolean {
);
}
async function discoverSymlinkTargetDirectories(
filePath: string,
): Promise<Set<string>> {
const directories = new Set<string>();
const visitedPaths = new Set<string>();
let currentPath = resolve(filePath);
while (!visitedPaths.has(currentPath)) {
visitedPaths.add(currentPath);
const target = await readlink(currentPath).catch(() => undefined);
if (target === undefined) {
break;
}
// Relative link targets resolve from the physical parent directory,
// which can differ from dirname(currentPath) through directory aliases.
const parentPath = await realpath(dirname(currentPath)).catch(() =>
dirname(currentPath),
);
currentPath = resolve(parentPath, target);
// Watch parents rather than file inodes so atomic saves and target
// deletion/recreation keep working. Include intermediate links so
// retargeting a symlink chain triggers rediscovery too.
directories.add(dirname(currentPath));
}
return directories;
}
export class UnifiedConfigFileWatcher<
TType extends string = string,
TItem = unknown,
@@ -413,6 +439,22 @@ export class UnifiedConfigFileWatcher<
) {
continue;
}
const targetDirectories =
await discoverSymlinkTargetDirectories(filePath);
if (targetDirectories.size > 0) {
const targetStat = await stat(filePath).catch(() => undefined);
if (targetStat && !targetStat.isFile()) {
continue;
}
for (const targetDirectory of targetDirectories) {
discoveredDirectories.add(targetDirectory);
}
if (!targetStat) {
// Keep watching a broken link's target parent so recreating
// the file loads it again without a manual refresh.
continue;
}
}
try {
const content = await readFile(filePath, "utf8");
const context: UnifiedConfigFileContext<TType> = {
@@ -478,13 +520,9 @@ export class UnifiedConfigFileWatcher<
const candidates: UnifiedConfigFileCandidate[] = [];
for (const entry of entries) {
const filePath = join(directoryPath, entry.name);
const isFile =
entry.isFile() ||
(entry.isSymbolicLink() &&
(await stat(filePath)
.then((target) => target.isFile())
.catch(() => false)));
if (!isFile) {
// Retain broken links as candidates so loadDefinition can watch
// their targets without trying to parse them.
if (!entry.isFile() && !entry.isSymbolicLink()) {
continue;
}
candidates.push({
@@ -422,6 +422,92 @@ Escalation runbook`,
},
);
it.skipIf(process.platform === "win32").each(["rule", "workflow"] as const)(
"automatically reloads symlinked %s files when external targets change",
async (type) => {
const tempRoot = await mkdtemp(
join(tmpdir(), "core-instruction-symlink-watch-"),
);
tempRoots.push(tempRoot);
const configDir = join(tempRoot, "configs");
const externalDir = join(tempRoot, "external");
await mkdir(configDir);
await mkdir(externalDir);
const sourcePath = join(externalDir, "source.md");
const linkedPath = join(configDir, "linked.md");
await writeFile(sourcePath, "Original instructions.");
await symlink(sourcePath, linkedPath, "file");
const watcher = createUserInstructionConfigWatcher({
skills: { directories: [] },
rules: { directories: type === "rule" ? [configDir] : [] },
workflows: { directories: type === "workflow" ? [configDir] : [] },
debounceMs: 10,
});
const events: UserInstructionConfigWatcherEvent[] = [];
const unsubscribe = watcher.subscribe((event) => events.push(event));
try {
await watcher.start();
events.length = 0;
await writeFile(sourcePath, "Updated instructions.");
await waitForEvent(
events,
(event) =>
event.kind === "upsert" &&
event.record.type === type &&
event.record.item.instructions === "Updated instructions.",
);
expect(watcher.getSnapshot(type).get("linked")).toMatchObject({
filePath: linkedPath,
item: { instructions: "Updated instructions." },
});
} finally {
watcher.stop();
unsubscribe();
}
},
);
it.skipIf(process.platform === "win32")(
"loads an initially broken explicit AGENTS.md symlink when its target is created",
async () => {
const tempRoot = await mkdtemp(
join(tmpdir(), "core-agents-symlink-watch-"),
);
tempRoots.push(tempRoot);
const externalDir = join(tempRoot, "external");
await mkdir(externalDir);
const sourcePath = join(externalDir, "source.md");
const agentsPath = join(tempRoot, "AGENTS.md");
await symlink(sourcePath, agentsPath, "file");
const watcher = createUserInstructionConfigWatcher({
skills: { directories: [] },
rules: { directories: [agentsPath], workspacePath: tempRoot },
workflows: { directories: [] },
debounceMs: 10,
});
const events: UserInstructionConfigWatcherEvent[] = [];
const unsubscribe = watcher.subscribe((event) => events.push(event));
try {
await watcher.start();
expect(watcher.getSnapshot("rule").size).toBe(0);
await writeFile(sourcePath, "Created instructions.");
await waitForEvent(
events,
(event) => event.kind === "upsert" && event.record.type === "rule",
);
expect(
watcher.getSnapshot("rule").get("workspace agents.md"),
).toMatchObject({
filePath: agentsPath,
item: { instructions: "Created instructions." },
});
} finally {
watcher.stop();
unsubscribe();
}
},
);
it.skipIf(process.platform === "win32")(
"discovers skill directories through symlinks",
async () => {
@@ -1,4 +1,4 @@
import { readdir, readFile, realpath, stat } from "node:fs/promises";
import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
import {
basename,
dirname,
@@ -520,8 +520,14 @@ async function discoverRulesLikeFiles(
}
try {
const entryStat = await stat(directoryPath);
if (entryStat.isFile()) {
const entryStat = await lstat(directoryPath);
const isFile =
entryStat.isFile() ||
(entryStat.isSymbolicLink() &&
!(await stat(directoryPath)
.then((target) => target.isDirectory())
.catch(() => false)));
if (isFile) {
return [
{
directoryPath: dirname(directoryPath),
@@ -540,19 +546,15 @@ async function discoverRulesLikeFiles(
const entries = await readdir(directoryPath, { withFileTypes: true });
const candidates: UnifiedConfigFileCandidate[] = [];
for (const entry of entries) {
if (!isMarkdownFile(entry.name)) {
if (
!isMarkdownFile(entry.name) ||
(!entry.isFile() && !entry.isSymbolicLink())
) {
continue;
}
const filePath = join(directoryPath, entry.name);
const isFile =
entry.isFile() ||
(entry.isSymbolicLink() &&
(await stat(filePath)
.then((target) => target.isFile())
.catch(() => false)));
if (!isFile) {
continue;
}
// The watcher validates symlink targets and watches their parents,
// including broken links whose targets may be recreated later.
candidates.push({
directoryPath,
fileName: entry.name,