fix(worker): single version oracle — never rank plugin cache dirs by mtime

The 2026-07-22 restart storm: claude-mem had four resolvers that ranked
plugin cache dirs by directory mtime (worker-utils
cacheWorkerScriptCandidates, the inline node L() and Codex Windows
resolvers in hook-shell-template, and the POSIX `ls -dt` hook resolver),
while the staleness detector compared the live worker against the
marketplace package.json. When Claude Code stamped the superseded
13.11.0 cache dir with .orphaned_at, its mtime became newest, so every
recycle respawned 13.11.0 while hooks running 13.12.0 kept demanding a
restart — 485 respawns in ~2 hours, each spawning SDK and Chroma
children, until the host exhausted its process table.

The fix is an invariant, not a mitigation: one deterministic version
oracle shared by detection and respawn.

- resolveWorkerScript() ranks candidates by version descending (numeric
  major.minor.patch, release ahead of prerelease at the same base,
  reverse-lexical tiebreak) and skips .orphaned_at-stamped dirs; stable
  sort keeps cache → marketplace → cwd precedence on ties.
- ensureWorkerRunning() resolves once and feeds the same result to both
  checkVersionMatch(port, expectedVersion) and the lazy-spawn script, so
  a mismatch can only ever resolve toward the version the respawn will
  actually produce — the loop is structurally impossible.
- checkVersionMatch now takes the caller's expected version;
  getInstalledPluginVersion (the second oracle) is deleted.
- All three generated bootstrap resolvers (mcp node launcher, Codex
  Windows launcher, POSIX hook prelude) embed the identical ordering;
  the POSIX variant uses a zero-padded sort key and balanced (pattern)
  case forms (unmatched parens are a parser error inside $(...)).
- scripts/build-hooks.js gains --write-shell-templates, the regeneration
  mode its hand-edit tripwire error message points at.
- Regression coverage: comparator/selection unit tests plus a functional
  shell-matrix test that rebuilds the exact storm layout (orphaned old
  dir with newest mtime) and asserts the new version wins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-07-22 22:30:35 -07:00
parent 0280d9d85c
commit 7d3f1879eb
15 changed files with 820 additions and 522 deletions
+34 -12
View File
@@ -159,15 +159,25 @@ async function verifyShellTemplateCanonical() {
const manifest = shellTemplateManifest(buildShellCommand, buildCodexWindowsCommand);
// The regeneration mode the mismatch errors point at: after an intentional
// generator change, rewrite the committed launcher strings from the same
// manifest the verifier checks, so the two can never drift.
const writeMode = process.argv.includes('--write-shell-templates');
for (const [filePath, spec] of Object.entries(manifest)) {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
let dirty = false;
if (spec.kind === 'mcp') {
const actual = parsed.mcpServers?.['mcp-search']?.args?.[1] ?? '';
if (actual !== spec.command) {
throw new Error(
`Hand-edited shell string detected in ${filePath} (mcp-search). It no longer matches src/build/hook-shell-template.ts. ` +
`Update the generator (and this manifest) instead of hand-editing the launcher.`
);
if (!writeMode) {
throw new Error(
`Hand-edited shell string detected in ${filePath} (mcp-search). It no longer matches src/build/hook-shell-template.ts. ` +
`Regenerate via \`node scripts/build-hooks.js --write-shell-templates\` after an intentional generator change.`
);
}
parsed.mcpServers['mcp-search'].args[1] = spec.command;
dirty = true;
}
} else {
for (const [dottedPath, expected] of Object.entries(spec.commands)) {
@@ -175,22 +185,34 @@ async function verifyShellTemplateCanonical() {
const expectedCommand = typeof expected === 'string' ? expected : expected.command;
const actual = entry?.command ?? null;
if (actual !== expectedCommand) {
throw new Error(
`Hand-edited shell string detected in ${filePath} (${dottedPath}). It no longer matches src/build/hook-shell-template.ts. ` +
`Regenerate via the canonical generator instead of hand-editing the command.`
);
if (!writeMode || !entry) {
throw new Error(
`Hand-edited shell string detected in ${filePath} (${dottedPath}). It no longer matches src/build/hook-shell-template.ts. ` +
`Regenerate via \`node scripts/build-hooks.js --write-shell-templates\` after an intentional generator change.`
);
}
entry.command = expectedCommand;
dirty = true;
}
if (typeof expected !== 'string') {
const actualWindows = entry?.commandWindows ?? null;
if (actualWindows !== expected.commandWindows) {
throw new Error(
`Hand-edited Windows shell string detected in ${filePath} (${dottedPath}). It no longer matches src/build/hook-shell-template.ts. ` +
`Regenerate via the canonical generator instead of hand-editing commandWindows.`
);
if (!writeMode || !entry) {
throw new Error(
`Hand-edited Windows shell string detected in ${filePath} (${dottedPath}). It no longer matches src/build/hook-shell-template.ts. ` +
`Regenerate via \`node scripts/build-hooks.js --write-shell-templates\` after an intentional generator change.`
);
}
entry.commandWindows = expected.commandWindows;
dirty = true;
}
}
}
}
if (dirty) {
fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2) + '\n');
console.log(` ✏️ Regenerated shell templates in ${filePath}`);
}
}
// Rule C safety net (bun-runner.js fixBrokenScriptPath) must stay documented.