Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-07
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
## Why

A Stop hook died mid-session with:

```
Plugin directory does not exist: <runtime>/plugins/cache/thedotmack/claude-mem/13.13.1
(claude-mem@thedotmack — run /plugin to reinstall)
```

The plugin was not missing. Its payload has been on disk since 2026-08-04; only
the runtime's `plugins/cache` symlink was gone, recreated seconds later
(observed 2026-08-07: `installed_plugins.json` written 10:10:57Z, the symlink
recreated at 10:11:07Z, hooks firing in between).

`linkPluginCache()` replaces each entry with `rm()` followed by `symlink()`.
Between those two awaits the path does not exist. Re-materializing a runtime
happens while sessions are live, so any hook that resolves a plugin installPath
in that window fails — with the exact error the function's own doc comment says
it exists to prevent.

The window is not theoretical. A reader polling the path across 300 swaps
observes **441** ENOENTs against the old implementation and **0** against the
new one.

## What Changes

Stage the replacement symlink beside the target and `rename()` it over.
`rename(2)` within one directory is atomic, so a concurrent reader sees either
the old entry or the new one, never neither.

`rename()` refuses to clobber a real directory, so the previous
remove-then-create is kept as a fallback for that one case: Claude's lazy empty
`cache/` copy on a first materialization, before any session can read it.

On failure the staged link is cleaned up and the existing entry is left in
place, rather than removed.

## Impact

- `src/lib/runtime-materializer.ts` — `linkPluginCache()` only.
- No behavior change in the steady state: the resulting symlinks are identical.
- New coverage in `src/lib/runtime-materializer.plugin-cache.test.ts`, including
a race assertion that fails against the old implementation. That test cannot
false-fail: an atomic rename has no window in which the entry is absent.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## ADDED Requirements

### Requirement: Plugin cache links are swapped atomically
Runtimes are re-materialized while agent sessions are live, and those sessions
resolve plugin install paths under `<runtime>/plugins/cache`. Replacing a
managed plugin entry SHALL NOT leave its path absent at any instant.

#### Scenario: A reader never observes a missing entry
- **WHEN** `linkPluginCache` replaces an entry that is already a symlink
- **THEN** a concurrent reader polling that path observes either the previous
link or the new one
- **AND** never observes ENOENT.

#### Scenario: Claude's lazy empty directory is still replaced
- **WHEN** the target entry is a real directory rather than a symlink
- **THEN** it is removed and replaced with the symlink
- **AND** this fallback applies only on a first materialization, before a
session can read the path.

#### Scenario: A failed swap preserves the existing entry
- **WHEN** staging or renaming the replacement fails
- **THEN** the existing entry is left in place
- **AND** no staging entry is left behind in the plugins directory.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## Definition of Done

This change is complete only when **all** of the following are true:

- Every checkbox below is checked.
- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff.
- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline.

## Handoff

- Handoff: change=`agent-claude-atomic-plugin-cache-symlink-2026-08-07-12-19`; branch=`agent/<your-name>/<branch-slug>`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`.
- Copy prompt: Continue `agent-claude-atomic-plugin-cache-symlink-2026-08-07-12-19` on branch `agent/<your-name>/<branch-slug>`. Work inside the existing sandbox, review `openspec/changes/agent-claude-atomic-plugin-cache-symlink-2026-08-07-12-19/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`.

## 1. Specification

- [ ] 1.1 Finalize proposal scope and acceptance criteria for `agent-claude-atomic-plugin-cache-symlink-2026-08-07-12-19`.
- [ ] 1.2 Define normative requirements in `specs/atomic-plugin-cache-symlink/spec.md`.

## 2. Implementation

- [ ] 2.1 Implement scoped behavior changes.
- [ ] 2.2 Add/update focused regression coverage.

## 3. Verification

- [ ] 3.1 Run targeted project verification commands.
- [ ] 3.2 Run `openspec validate agent-claude-atomic-plugin-cache-symlink-2026-08-07-12-19 --type change --strict`.
- [ ] 3.3 Run `openspec validate --specs`.

## 4. Cleanup (mandatory; run before claiming completion)

- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation.
- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff.
- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch).
111 changes: 111 additions & 0 deletions src/lib/runtime-materializer.plugin-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import { mkdtemp, mkdir, writeFile, rm, lstat, readlink, readdir, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { linkPluginCache } from "./runtime-materializer";

/**
* Re-materializing a runtime happens while sessions are live, so replacing the
* plugin-cache symlink has to be atomic. The rm-then-symlink it used to do left
* the path absent for a moment, and a hook firing in that window failed with
* "Plugin directory does not exist … run /plugin to reinstall" — the very error
* linkPluginCache exists to prevent.
*/
describe("linkPluginCache — atomic swap", () => {
let src: string;
let tgt: string;

beforeEach(async () => {
src = await mkdtemp(join(tmpdir(), "cue-plugsrc-"));
tgt = await mkdtemp(join(tmpdir(), "cue-plugtgt-"));
const verDir = join(src, "plugins", "cache", "thedotmack", "claude-mem", "13.13.1");
await mkdir(verDir, { recursive: true });
await writeFile(join(verDir, "hooks.json"), "{}");
await mkdir(join(src, "plugins", "marketplaces"), { recursive: true });
await writeFile(join(src, "plugins", "known_marketplaces.json"), "{}");
});

afterEach(async () => {
await rm(src, { recursive: true, force: true });
await rm(tgt, { recursive: true, force: true });
});

test("re-linking over an existing symlink keeps the version dir resolvable", async () => {
await linkPluginCache(tgt, src);
// Second pass is the one that used to open the window: the target is
// already a symlink, so rm would unlink it before symlink recreated it.
await linkPluginCache(tgt, src);

const cacheLink = join(tgt, "plugins", "cache");
expect((await lstat(cacheLink)).isSymbolicLink()).toBe(true);
expect(await readlink(cacheLink)).toBe(join(src, "plugins", "cache"));
const hooks = join(cacheLink, "thedotmack", "claude-mem", "13.13.1", "hooks.json");
expect((await stat(hooks)).isFile()).toBe(true);
});

test("leaves no staging entries behind", async () => {
await linkPluginCache(tgt, src);
await linkPluginCache(tgt, src);

const entries = await readdir(join(tgt, "plugins"));
expect(entries.filter((e) => e.includes(".cue-tmp-"))).toEqual([]);
expect(entries.sort()).toEqual(["cache", "known_marketplaces.json", "marketplaces"]);
});

test("still replaces Claude's lazy empty directory", async () => {
// rename() refuses to clobber a real directory, so this exercises the
// fallback path — first materialization, before any session reads it.
await mkdir(join(tgt, "plugins", "cache"), { recursive: true });

await linkPluginCache(tgt, src);

const cacheLink = join(tgt, "plugins", "cache");
expect((await lstat(cacheLink)).isSymbolicLink()).toBe(true);
expect(await readlink(cacheLink)).toBe(join(src, "plugins", "cache"));
});

test("the target path is never absent while it is being replaced", async () => {
// The actual invariant, and the only assertion here that fails against a
// rm-then-symlink implementation: a reader polling the path across many
// swaps must never see ENOENT. Cannot false-fail — an atomic rename has no
// window in which the entry is missing.
await linkPluginCache(tgt, src);
const cacheLink = join(tgt, "plugins", "cache");

let polling = true;
let misses = 0;
let polls = 0;
const reader = (async () => {
while (polling) {
polls++;
try {
await lstat(cacheLink);
} catch {
misses++;
}
}
})();

for (let i = 0; i < 300; i++) await linkPluginCache(tgt, src);
polling = false;
await reader;

expect(polls).toBeGreaterThan(0);
expect(misses).toBe(0);
});

test("a source entry that disappears mid-run leaves the old link in place", async () => {
await linkPluginCache(tgt, src);
const cacheLink = join(tgt, "plugins", "cache");
const before = await readlink(cacheLink);

// symlink() itself doesn't require the source to exist, so the link stays
// valid-shaped either way; what must not happen is the target vanishing.
await rm(join(src, "plugins", "marketplaces"), { recursive: true, force: true });
await linkPluginCache(tgt, src);

expect((await lstat(cacheLink)).isSymbolicLink()).toBe(true);
expect(await readlink(cacheLink)).toBe(before);
});
});
35 changes: 29 additions & 6 deletions src/lib/runtime-materializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1196,13 +1196,36 @@ export async function linkPluginCache(targetDir: string, sourceDir: string): Pro
}
const targetPath = join(pluginsDir, name);
// Replace whatever's there (Claude's lazy/empty copy or a stale symlink)
// with a symlink to the real, already-downloaded tree.
try {
await rm(targetPath, { recursive: true, force: true });
} catch { /* nothing to remove */ }
// with a symlink to the real, already-downloaded tree — atomically.
//
// A plain rm-then-symlink leaves the path absent for a moment, and
// re-materializing happens while sessions are live. A hook firing in that
// window sees exactly the error this function exists to prevent:
// "Plugin directory does not exist … run /plugin to reinstall". Observed
// 2026-08-07, a Stop hook against claude-mem@thedotmack. So stage the new
// link beside the target and rename() it over: same directory, so the swap
// is atomic and a concurrent reader sees the old entry or the new one,
// never neither.
const stagePath = `${targetPath}.cue-tmp-${process.pid}`;
try {
await symlink(sourcePath, targetPath);
} catch { /* race or permission — skip silently */ }
await rm(stagePath, { recursive: true, force: true });
await symlink(sourcePath, stagePath);
try {
await rename(stagePath, targetPath);
} catch {
// rename refuses to clobber a real directory, and POSIX has no atomic
// way to replace a directory with a symlink — so this branch keeps the
// old window. It is the first materialization, when the target is
// still Claude's lazy empty copy; every later pass finds a symlink and
// takes the atomic path above, which is the one that was failing hooks.
await rm(targetPath, { recursive: true, force: true });
await rename(stagePath, targetPath);
}
} catch {
// Race or permission — leave the existing entry in place rather than
// removing it, and don't leak the staged link.
await rm(stagePath, { recursive: true, force: true }).catch(() => {});
}
}
}

Expand Down
Loading