#4855 · Incomplete skill caches survive failed installation
Verdict: REPRODUCED · Root-cause confidence: high for persistent incomplete-cache handling. The reported lazy-disk trigger, EIO, and exact last-used error were not reproduced.
1. TL;DR
The daemon downloads required skills into a content-addressed cache before staging them for an agent. A nonempty cache directory without a completion marker or content directory blocks the downloaded replacement. The installer handles the resulting rename collision as success without checking whether the resident tree is complete. Repeated staging requests then either fail with missing-content errors or stage incomplete data. An actually empty directory is replaced successfully on this Linux filesystem; that exact reported state alone did not reproduce the failure.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| A partial cache can permanently prevent required-skill staging. | Verified | Two sequential attempts on the same unfinished or marker-only tree both fail with ENOENT for content. Three regression cases fail in both clean trusted checkouts. |
| Collision handling accepts an existing tree without validating completion. | Verified | The installation catch ignores EEXIST/ENOTEMPTY, deletes the completed temporary tree, touches the resident directory, and returns its content path. |
| An empty hash directory by itself causes the described persistent failure. | Not reproduced on this filesystem | The empty-directory case passes twice, with only one fetch. POSIX rename replaces the empty destination directory. |
| The failure specifically occurs while writing the last-used marker. | Unverified | The reproduced failures instead occur during content lstat or leave the completion marker missing. The writable resident directory accepts the last-used write. |
| Lazy restoration causes transient EIO and creates the initial partial directory. | Unverified | No lazy-restoration hardware or filesystem was available. Fixtures deliberately seed invalid cache state; no cause of that state is asserted. |
| The installer does not retry general rename failures in the same request. | Source verified; not experimentally reproduced | The noncollision branch removes the temporary tree and rethrows. Later requests attempt another fetch; source alone does not show a permanent EIO latch. |
3. Environment
- Trusted public repository get-bb/bb, pinned origin/main commit
09ff18a73bd0a8037f43c6ed86c28eb02228089c. - Linux x86_64, Node v22.19.0, pnpm 9.15.0, Vitest 4.1.1.
- Two separate detached worktrees. Each received a frozen install and a Turbo host-daemon build. Only the agent-authored regression additions changed the source tree before reproducing.
- No running application, network listeners, provider processes, or real user runtime data. Each test uses a new mkdtemp directory removed by the existing test cleanup. No ports were needed.
4. Minimal reproduction
- Clone the trusted repository and pin the recorded base:
git clone https://github.com/get-bb/bb.git bb-repro cd bb-repro git checkout --detach 09ff18a73bd0a8037f43c6ed86c28eb02228089c pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build --filter=@bb/host-daemon
- Copy the full runnable test owner shown below into
apps/host-daemon/src/injected-skills.test.ts, replacing that file. The inline file contains only repository tests plus the agent-authored regression additions. No production code is changed. - Run the focused cases:
pnpm exec turbo run test --filter=@bb/host-daemon -- src/injected-skills.test.ts -t 'repairs a stored tree|reuses a complete tree installed'
Expected: all five selected cases pass. Each invalid resident tree is replaced with validated downloaded content; its next use succeeds without another fetch. A complete concurrent installation is preserved.
Actual (both checkouts, exit 1):
Tests 3 failed | 2 passed | 15 skipped (20)
The unfinished-directory and marker-only cases both produce two ENOENT errors for the same content path, proving failure persists across requests. The markerless partial-content case can stage incomplete files, but never installs the completion marker. The empty-directory and healthy concurrent-winner cases pass.
Paths in published logs are normalized to $CHECKOUT_1, $CHECKOUT_2, and $ISOLATED_DATA_DIR; all remaining test output is preserved.
Full runnable test owner
import {
access,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
symlink,
utimes,
writeFile,
} from "node:fs/promises";
import fs from "node:fs/promises";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentRuntimeSkillRoot } from "@bb/agent-runtime";
import type {
HostDaemonInjectedSkillSource,
HostDaemonSkillTree,
} from "@bb/host-daemon-contract";
import {
cleanupInjectedSkillStagingDirs,
ensureDataDirSkillsRootPath,
MAX_SKILL_STORE_TREES,
stageInjectedSkillSources,
} from "./injected-skills.js";
interface WriteSkillArgs {
body?: string;
name: string;
rootPath: string;
}
interface StageSourceArgs {
dataDir: string;
skillRootPath: string;
skillName: string;
}
interface CapturedWarning {
context: object;
message: string;
}
const tempDirs: string[] = [];
async function makeTempDir(): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), "bb-host-skills-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
);
});
function requireSkillRoot(
roots: readonly AgentRuntimeSkillRoot[],
): AgentRuntimeSkillRoot {
const [root, ...rest] = roots;
if (root === undefined || rest.length > 0) {
throw new Error(
`Expected exactly one staged skill root, got ${roots.length}`,
);
}
return root;
}
async function writeSkill(args: WriteSkillArgs): Promise<string> {
const skillRootPath = path.join(args.rootPath, args.name);
await mkdir(path.join(skillRootPath, "references"), { recursive: true });
await writeFile(
path.join(skillRootPath, "SKILL.md"),
[
"---",
`name: ${args.name}`,
`description: Use ${args.name} when host staging tests run.`,
"---",
"",
args.body ?? "# Skill",
"",
].join("\n"),
"utf8",
);
await writeFile(
path.join(skillRootPath, "references", "notes.md"),
"supporting notes\n",
"utf8",
);
return skillRootPath;
}
function createDataDirSource(
args: StageSourceArgs,
): HostDaemonInjectedSkillSource {
return {
kind: "workspace-path",
sourceType: "project",
name: args.skillName,
description: `Use ${args.skillName} when host staging tests run.`,
sourceRootPath: args.skillRootPath,
skillFilePath: path.join(args.skillRootPath, "SKILL.md"),
};
}
function createTreePayload(
name: string,
token = "tree bytes",
): HostDaemonSkillTree {
const entries = [
{
path: "SKILL.md",
mode: 0o644,
contentBase64: Buffer.from(
`---\nname: ${name}\ndescription: Use ${name} in pull tests.\n---\n\n${token}\n`,
).toString("base64"),
},
{
path: "scripts/run.sh",
mode: 0o755,
contentBase64: Buffer.from("#!/bin/sh\necho synced\n").toString("base64"),
},
];
entries.sort((left, right) =>
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
);
const hash = createHash("sha256");
hash.update("bb-skill-tree-v1");
for (const entry of entries) {
const bytes = Buffer.from(entry.contentBase64, "base64");
hash.update("\0file\0");
hash.update(entry.path);
hash.update("\0");
hash.update(entry.mode.toString(8));
hash.update("\0");
hash.update(String(bytes.length));
hash.update("\0");
hash.update(bytes);
}
return { treeHash: hash.digest("hex"), entries };
}
async function seedStoredTree(
dataDir: string,
tree: HostDaemonSkillTree,
): Promise<string> {
const treeRootPath = path.join(
dataDir,
"runtime",
"skill-store",
tree.treeHash,
);
const contentRootPath = path.join(treeRootPath, "content");
await Promise.all(
tree.entries.map(async (entry) => {
const destinationPath = path.join(contentRootPath, entry.path);
await mkdir(path.dirname(destinationPath), { recursive: true });
await writeFile(
destinationPath,
Buffer.from(entry.contentBase64, "base64"),
{ mode: entry.mode },
);
}),
);
await Promise.all([
writeFile(path.join(treeRootPath, ".complete"), "complete\n"),
writeFile(path.join(treeRootPath, ".last-used"), ""),
]);
return treeRootPath;
}
function createTreeSource(
name: string,
treeHash: string,
): HostDaemonInjectedSkillSource {
return {
kind: "tree",
sourceType: "data-dir",
name,
description: `Use ${name} in pull tests.`,
treeHash,
entryPath: "SKILL.md",
};
}
describe("data-dir skills root", () => {
it("creates the global skills root so the watcher can subscribe", async () => {
const dataDir = await makeTempDir();
const skillsRootPath = await ensureDataDirSkillsRootPath(dataDir);
expect(skillsRootPath).toBe(path.join(dataDir, "skills"));
await expect(
lstat(skillsRootPath).then((stats) => stats.isDirectory()),
).resolves.toBe(true);
});
});
describe("injected skill staging", () => {
it("pulls a missing tree, stages identical bytes and modes, and reuses the store", async () => {
const dataDir = await makeTempDir();
const payload = createTreePayload("synced-skill");
const fetchSkillTree = vi.fn(async () => payload);
const source = createTreeSource("synced-skill", payload.treeHash);
const first = await stageInjectedSkillSources({
dataDir,
fetchSkillTree,
injectedSkillSources: [source],
});
await stageInjectedSkillSources({
dataDir,
fetchSkillTree,
injectedSkillSources: [source],
});
const stagedScript = path.join(
requireSkillRoot(first.skillRoots).path,
"synced-skill",
"scripts",
"run.sh",
);
await expect(readFile(stagedScript, "utf8")).resolves.toBe(
"#!/bin/sh\necho synced\n",
);
if (process.platform !== "win32") {
await expect(
lstat(stagedScript).then((stat) => stat.mode & 0o777),
).resolves.toBe(0o755);
}
expect(fetchSkillTree).toHaveBeenCalledTimes(1);
expect(fetchSkillTree).toHaveBeenCalledWith(payload.treeHash);
});
it.each([
{ state: "empty directory", files: [] },
{ state: "unfinished directory", files: ["unfinished"] },
{ state: "missing content directory", files: [".complete"] },
{ state: "missing completion marker", files: ["content/SKILL.md"] },
])("repairs a stored tree with $state before staging", async ({ files }) => {
const dataDir = await makeTempDir();
const payload = createTreePayload("repaired-skill");
const treeRootPath = path.join(
dataDir,
"runtime",
"skill-store",
payload.treeHash,
);
await mkdir(treeRootPath, { recursive: true });
for (const file of files) {
const filePath = path.join(treeRootPath, file);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, "unfinished\n");
}
const fetchSkillTree = vi.fn(async () => payload);
const stages = [];
for (let attempt = 0; attempt < 2; attempt += 1) {
stages.push(
await stageInjectedSkillSources({
dataDir,
fetchSkillTree,
injectedSkillSources: [
createTreeSource("repaired-skill", payload.treeHash),
],
}).then(
(stage) => stage,
(error: unknown) => {
if (error instanceof Error) return error;
throw error;
},
),
);
}
expect(
stages
.filter((stage) => stage instanceof Error)
.map((error) => error.message),
).toEqual([]);
await expect(
readFile(path.join(treeRootPath, ".complete"), "utf8"),
).resolves.toBe("complete\n");
for (const stage of stages) {
if (stage instanceof Error) throw stage;
await expect(
readFile(
path.join(
requireSkillRoot(stage.skillRoots).path,
"repaired-skill",
"scripts",
"run.sh",
),
"utf8",
),
).resolves.toBe("#!/bin/sh\necho synced\n");
}
expect(fetchSkillTree).toHaveBeenCalledTimes(1);
});
it("reuses a complete tree installed while the fetch is pending", async () => {
const dataDir = await makeTempDir();
const payload = createTreePayload("concurrent-install");
const staged = await stageInjectedSkillSources({
dataDir,
fetchSkillTree: async () => {
const treeRootPath = await seedStoredTree(dataDir, payload);
await writeFile(path.join(treeRootPath, "resident"), "keep\n");
return payload;
},
injectedSkillSources: [
createTreeSource("concurrent-install", payload.treeHash),
],
});
await expect(
readFile(
path.join(
requireSkillRoot(staged.skillRoots).path,
"concurrent-install",
"scripts",
"run.sh",
),
"utf8",
),
).resolves.toBe("#!/bin/sh\necho synced\n");
await expect(
readFile(
path.join(
dataDir,
"runtime",
"skill-store",
payload.treeHash,
"resident",
),
"utf8",
),
).resolves.toBe("keep\n");
});
it("surfaces a failed required tree pull instead of silently skipping it", async () => {
const dataDir = await makeTempDir();
const payload = createTreePayload("failed-pull");
const warnings: CapturedWarning[] = [];
const failure = new Error("server unavailable");
await expect(
stageInjectedSkillSources({
dataDir,
fetchSkillTree: async () => Promise.reject(failure),
injectedSkillSources: [
createTreeSource("failed-pull", payload.treeHash),
],
logger: {
debug: () => undefined,
warn: (context, message) => warnings.push({ context, message }),
},
}),
).rejects.toThrow("server unavailable");
expect(warnings).toEqual([
expect.objectContaining({
message: "Failed to pull required injected skill tree",
}),
]);
});
it("rejects fetched tree content that does not match its declared hash", async () => {
const dataDir = await makeTempDir();
const payload = createTreePayload("tampered-tree");
const tampered = {
...payload,
entries: payload.entries.map((entry, index) =>
index === 0
? {
...entry,
contentBase64: Buffer.from("tampered\n").toString("base64"),
}
: entry,
),
};
await expect(
stageInjectedSkillSources({
dataDir,
fetchSkillTree: async () => tampered,
injectedSkillSources: [
createTreeSource("tampered-tree", payload.treeHash),
],
}),
).rejects.toThrow("Fetched skill tree content hash mismatch");
});
it("verifies Unicode paths with locale-independent code-point ordering", async () => {
const dataDir = await makeTempDir();
const entries = ["ä", "z", "A", "a"].map((entryPath) => ({
path: entryPath,
mode: 0o644,
contentBase64: Buffer.from(entryPath).toString("base64"),
}));
entries.push({
path: "SKILL.md",
mode: 0o644,
contentBase64: Buffer.from(
"---\nname: unicode-paths\ndescription: Use Unicode paths in tests.\n---\n",
).toString("base64"),
});
const hash = createHash("sha256");
hash.update("bb-skill-tree-v1");
for (const entry of [...entries].sort((left, right) =>
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
)) {
const bytes = Buffer.from(entry.contentBase64, "base64");
hash.update("\0file\0");
hash.update(entry.path);
hash.update("\0");
hash.update(entry.mode.toString(8));
hash.update("\0");
hash.update(String(bytes.length));
hash.update("\0");
hash.update(bytes);
}
const payload = { treeHash: hash.digest("hex"), entries };
await expect(
stageInjectedSkillSources({
dataDir,
fetchSkillTree: async () => payload,
injectedSkillSources: [
createTreeSource("unicode-paths", payload.treeHash),
],
}),
).resolves.toMatchObject({ skillRoots: expect.any(Array) });
});
it("garbage-collects the least-recently-used trees beyond the store cap", async () => {
const dataDir = await makeTempDir();
const residents = Array.from(
{ length: MAX_SKILL_STORE_TREES },
(_, index) => {
const name = `gc-skill-${index}`;
return { name, payload: createTreePayload(name, `token-${index}`) };
},
);
const residentRoots = await Promise.all(
residents.map(({ payload }) => seedStoredTree(dataDir, payload)),
);
const oldestRoot = residentRoots[0];
if (!oldestRoot) throw new Error("Expected an oldest resident tree");
const oldest = new Date(1);
await utimes(path.join(oldestRoot, ".last-used"), oldest, oldest);
const name = "gc-newcomer";
const payload = createTreePayload(name, "newcomer-token");
await stageInjectedSkillSources({
dataDir,
fetchSkillTree: async () => payload,
injectedSkillSources: [createTreeSource(name, payload.treeHash)],
});
const entries = await readdir(
path.join(dataDir, "runtime", "skill-store"),
{
withFileTypes: true,
},
);
expect(entries.filter((entry) => entry.isDirectory()).length).toBe(
MAX_SKILL_STORE_TREES,
);
await expect(lstat(oldestRoot)).rejects.toMatchObject({ code: "ENOENT" });
await expect(
lstat(path.join(dataDir, "runtime", "skill-store", payload.treeHash)),
).resolves.toBeDefined();
});
it("exempts in-flight tree hashes from garbage collection", async () => {
const dataDir = await makeTempDir();
const storeRoot = path.join(dataDir, "runtime", "skill-store");
const residents = Array.from(
{ length: MAX_SKILL_STORE_TREES },
(_, index) => {
const name = index === 0 ? "protected-skill" : `resident-${index}`;
return {
name,
payload: createTreePayload(name, `resident-${index}`),
};
},
);
await Promise.all(
residents.map(({ payload }) => seedStoredTree(dataDir, payload)),
);
const protectedTree = residents[0];
if (!protectedTree) throw new Error("Expected a protected tree");
const protectedRoot = path.join(storeRoot, protectedTree.payload.treeHash);
const protectedContentRoot = path.join(protectedRoot, "content");
let resumeCollection = (): void => undefined;
const collectionBlocked = new Promise<void>((resolve) => {
resumeCollection = resolve;
});
let reportCollectionBlocked = (): void => undefined;
const collectionReached = new Promise<void>((resolve) => {
reportCollectionBlocked = resolve;
});
const originalLstat = fs.lstat.bind(fs);
const lstatSpy = vi
.spyOn(fs, "lstat")
.mockImplementation(async (targetPath) => {
if (String(targetPath) === protectedContentRoot) {
reportCollectionBlocked();
await collectionBlocked;
}
return originalLstat(targetPath);
});
const protectedStage = stageInjectedSkillSources({
dataDir,
fetchSkillTree: async () => protectedTree.payload,
injectedSkillSources: [
createTreeSource(protectedTree.name, protectedTree.payload.treeHash),
],
});
try {
await collectionReached;
const oldest = new Date(1);
await utimes(path.join(protectedRoot, ".last-used"), oldest, oldest);
const newcomer = createTreePayload("newcomer");
await stageInjectedSkillSources({
dataDir,
fetchSkillTree: async () => newcomer,
injectedSkillSources: [createTreeSource("newcomer", newcomer.treeHash)],
});
resumeCollection();
await expect(protectedStage).resolves.toMatchObject({
skillRoots: expect.any(Array),
});
await expect(lstat(protectedRoot)).resolves.toBeDefined();
} finally {
resumeCollection();
await protectedStage.catch(() => undefined);
lstatSpy.mockRestore();
}
});
it("stages a workspace-path skill source into the shared catalog", async () => {
const dataDir = await makeTempDir();
const skillRootPath = await writeSkill({
rootPath: path.join(dataDir, "source-skills"),
name: "release-notes",
});
const staged = await stageInjectedSkillSources({
dataDir,
injectedSkillSources: [
createDataDirSource({
dataDir,
skillName: "release-notes",
skillRootPath,
}),
],
});
const root = requireSkillRoot(staged.skillRoots);
const stageRootPath = path.join(
dataDir,
"runtime",
"global-skills",
staged.catalogHash,
);
expect(root).toEqual({
id: `global-skills:${staged.catalogHash}`,
path: path.join(stageRootPath, "skills"),
skills: [
{
description: "Use release-notes when host staging tests run.",
name: "release-notes",
},
],
});
await expect(
readFile(path.join(root.path, "release-notes", "SKILL.md"), "utf8"),
).resolves.toContain("name: release-notes");
await expect(
readFile(
path.join(root.path, "release-notes", "references", "notes.md"),
"utf8",
),
).resolves.toBe("supporting notes\n");
await expect(
readdir(stageRootPath).then((entries) => entries.sort()),
).resolves.toEqual(["catalog.json", "skills"]);
await expect(
readFile(path.join(stageRootPath, "catalog.json"), "utf8").then(
(content) => JSON.parse(content),
),
).resolves.toMatchObject({
catalogHash: staged.catalogHash,
skills: [
{
name: "release-notes",
sourceRootPath: skillRootPath,
sourceType: "project",
},
],
});
});
it("stages a read-only shared host path for every provider", async () => {
const dataDir = await makeTempDir();
const sourceRoot = await makeTempDir();
const skillRootPath = await writeSkill({
rootPath: sourceRoot,
name: "shared-review",
});
const staged = await stageInjectedSkillSources({
dataDir,
injectedSkillSources: [
{
kind: "host-path",
sourceType: "shared-user",
name: "shared-review",
description: "Use shared-review when host staging tests run.",
sourceRootPath: skillRootPath,
skillFilePath: path.join(skillRootPath, "SKILL.md"),
},
],
});
const root = requireSkillRoot(staged.skillRoots);
expect(root.skills.map((skill) => skill.name)).toEqual(["shared-review"]);
await expect(
readFile(path.join(root.path, "shared-review", "SKILL.md"), "utf8"),
).resolves.toContain("name: shared-review");
});
it("changes the catalog hash when skill content changes", async () => {
const dataDir = await makeTempDir();
const sourceRootPath = path.join(dataDir, "source-skills");
const skillRootPath = await writeSkill({
body: "first body",
rootPath: sourceRootPath,
name: "release-notes",
});
const source = createDataDirSource({
dataDir,
skillName: "release-notes",
skillRootPath,
});
const first = await stageInjectedSkillSources({
dataDir,
injectedSkillSources: [source],
});
await writeFile(
path.join(skillRootPath, "SKILL.md"),
[
"---",
"name: release-notes",
"description: Use release-notes when host staging tests run.",
"---",
"",
"second body",
"",
].join("\n"),
"utf8",
);
const second = await stageInjectedSkillSources({
dataDir,
injectedSkillSources: [source],
});
expect(second.catalogHash).not.toBe(first.catalogHash);
});
it("stages the same catalog concurrently without sharing temp directories", async () => {
const dataDir = await makeTempDir();
const skillRootPath = await writeSkill({
rootPath: path.join(dataDir, "source-skills"),
name: "release-notes",
});
const source = createDataDirSource({
dataDir,
skillName: "release-notes",
skillRootPath,
});
const fixedTime = vi.spyOn(Date, "now").mockReturnValue(1_781_053_873_372);
try {
const staged = await Promise.all(
Array.from({ length: 12 }, () =>
stageInjectedSkillSources({
dataDir,
injectedSkillSources: [source],
}),
),
);
const catalogHashes = new Set(staged.map((entry) => entry.catalogHash));
expect(catalogHashes.size).toBe(1);
const firstStaged = staged[0];
if (!firstStaged) {
throw new Error("Expected staged skill catalogs");
}
for (const entry of staged) {
expect(requireSkillRoot(entry.skillRoots).path).toBe(
path.join(
dataDir,
"runtime",
"global-skills",
entry.catalogHash,
"skills",
),
);
}
await expect(
readFile(
path.join(
dataDir,
"runtime",
"global-skills",
firstStaged.catalogHash,
"skills",
"release-notes",
"SKILL.md",
),
"utf8",
),
).resolves.toContain("Use release-notes");
} finally {
fixedTime.mockRestore();
}
});
it("skips symlinked files during staging", async () => {
const dataDir = await makeTempDir();
const outsideDir = await makeTempDir();
const skillRootPath = await writeSkill({
rootPath: path.join(dataDir, "source-skills"),
name: "release-notes",
});
await writeFile(path.join(outsideDir, "escape.md"), "escape\n", "utf8");
await symlink(
path.join(outsideDir, "escape.md"),
path.join(skillRootPath, "references", "escape.md"),
);
const warnings: CapturedWarning[] = [];
const staged = await stageInjectedSkillSources({
dataDir,
injectedSkillSources: [
createDataDirSource({
dataDir,
skillName: "release-notes",
skillRootPath,
}),
],
logger: {
debug: () => undefined,
warn: (context, message) => {
warnings.push({ context, message });
},
},
});
expect(staged.skillRoots).toEqual([]);
expect(warnings).toEqual([
expect.objectContaining({
message: "Skipping injected skill during staging",
}),
]);
});
});
describe("cleanupInjectedSkillStagingDirs", () => {
async function makeStagingRoot(): Promise<{
dataDir: string;
stagingRootPath: string;
}> {
const dataDir = await makeTempDir();
const stagingRootPath = path.join(dataDir, "runtime", "global-skills");
await mkdir(stagingRootPath, { recursive: true });
return { dataDir, stagingRootPath };
}
async function exists(targetPath: string): Promise<boolean> {
try {
await access(targetPath);
return true;
} catch {
return false;
}
}
it("keeps fresh .tmp- dirs that may belong to an in-flight staging", async () => {
const { dataDir, stagingRootPath } = await makeStagingRoot();
const inFlightTempPath = path.join(stagingRootPath, ".tmp-hash-123-456");
await mkdir(path.join(inFlightTempPath, "skills"), { recursive: true });
await cleanupInjectedSkillStagingDirs({ dataDir, keepCatalogHashes: [] });
expect(await exists(inFlightTempPath)).toBe(true);
});
it("removes stale .tmp- dirs left behind by crashed stagings", async () => {
const { dataDir, stagingRootPath } = await makeStagingRoot();
const staleTempPath = path.join(stagingRootPath, ".tmp-hash-789-101");
await mkdir(staleTempPath, { recursive: true });
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
await utimes(staleTempPath, twoHoursAgo, twoHoursAgo);
await cleanupInjectedSkillStagingDirs({ dataDir, keepCatalogHashes: [] });
expect(await exists(staleTempPath)).toBe(false);
});
it("removes unkept catalog dirs while keeping kept ones", async () => {
const { dataDir, stagingRootPath } = await makeStagingRoot();
const keptPath = path.join(stagingRootPath, "hash-keep");
const unkeptPath = path.join(stagingRootPath, "hash-drop");
await mkdir(keptPath, { recursive: true });
await mkdir(unkeptPath, { recursive: true });
await cleanupInjectedSkillStagingDirs({
dataDir,
keepCatalogHashes: ["hash-keep"],
});
expect(await exists(keptPath)).toBe(true);
expect(await exists(unkeptPath)).toBe(false);
});
});
5. Root cause
Cache lookup, lines 725–754, checks the completion marker and content path. ENOENT falls through to a fresh fetch. That is not sufficient: the existing invalid destination is still present when installation attempts its rename.
Installation, lines 668–723, correctly validates the downloaded hash and builds a complete temporary tree, but treats any EEXIST/ENOTEMPTY in its broad write/rename try block as proof of success. It never verifies the destination before returning it. A partial nonempty destination therefore survives every fetch and blocks each replacement.
await fs.chmod(destinationPath, file.mode);
}
await fs.writeFile(path.join(tempRootPath, STORE_LAST_USED_MARKER), "");
await fs.writeFile(
path.join(tempRootPath, STORE_COMPLETE_MARKER),
"complete\n",
);
await fs.rename(tempRootPath, treeRootPath);
} catch (error) {
if (
isFsErrorWithCode(error, "EEXIST") ||
isFsErrorWithCode(error, "ENOTEMPTY")
) {
await fs.rm(tempRootPath, { recursive: true, force: true });
} else {
await fs.rm(tempRootPath, { recursive: true, force: true });
throw error;
}
}
await touchStoredTree(treeRootPath);
await gcSkillStore(args.dataDir);
return path.join(treeRootPath, STORE_CONTENT_DIR);
}
export async function ensureStoredSkillTree(args: {
dataDir: string;
fetchSkillTree: FetchSkillTree;
treeHash: string;
}): Promise<string> {
const key = `${args.dataDir}\0${args.treeHash}`;
const pending = pendingSkillTreePulls.get(key);
if (pending) {
return pending;
}
const pull = runInSerialLane(skillStoreQueues, args.dataDir, async () => {
const treeRootPath = resolveStoredTreeRootPath(args.dataDir, args.treeHash);
try {
await fs.access(path.join(treeRootPath, STORE_COMPLETE_MARKER));
await fs.access(path.join(treeRootPath, STORE_CONTENT_DIR));
await touchStoredTree(treeRootPath);
await gcSkillStore(args.dataDir);
return path.join(treeRootPath, STORE_CONTENT_DIR);
} catch (error) {
if (!isFsErrorWithCode(error, "ENOENT")) {
throw error;
}
}
return writeFetchedTreeToStore({
dataDir: args.dataDir,
tree: await args.fetchSkillTree(args.treeHash),
treeHash: args.treeHash,
});
}).finally(() => pendingSkillTreePulls.delete(key));
pendingSkillTreePulls.set(key, pull);
Required-tree staging, lines 780–813, reads the returned content path and rethrows errors after logging a required-tree failure. These tests exercise that production boundary, not a fake installer. They do not launch a full thread/provider session; the claim that every thread is affected requires all those threads to request the same broken required tree.
6. Proposed fix
Keep installation within the existing per-data-directory serial lane. Catch only destination-collision errors from rename, then revalidate the resident completion marker and content directory. Preserve a complete winner; otherwise remove the incomplete destination and retry installing the already validated temporary tree once. Always remove the temporary tree on exit. Apply the same completeness predicate to the cache fast path. This preserves the cache layout and does not change a public protocol.
Do not add EIO retries or startup sweeping based only on this reproduction. Transient underlying storage failures and cross-process filesystem races remain explicit limits, not verified permanent-latch causes.
7. Verification
The same agent repeated the test in a second clean trusted checkout, at the exact same base SHA, after a separate frozen install and Turbo build. Before applying the agent-authored test-only patch the checkout was clean. No production edits were present in either reproduction.
pnpm exec turbo run test --filter=@bb/host-daemon -- src/injected-skills.test.ts -t 'repairs a stored tree|reuses a complete tree installed' First run: exit 1, 3 failed / 2 passed / 15 skipped. Second clean run: exit 1, 3 failed / 2 passed / 15 skipped.
The second run supports the final verdict and limitations. No report correction was necessary. This is a repeated reproduction by one agent, not an independent review. The report narrows the original broad claim: nonempty incomplete residents are confirmed; empty residents and the actual disk-restoration trigger are not.
8. Related issues and pull requests
The issue timeline had no cross-referenced pull requests, and an open-PR search for the trusted numeric issue returned none. No linked branch was fetched or executed. No other issue was independently verified as the same root cause.
9. Appendix
The complete test and both sanitized logs are embedded in this HTML. Raw artifacts remain local; the report repository prohibits publishing separate tests, patches, or logs.
First reproduction log
• turbo 2.10.12
• Packages in scope: @bb/host-daemon
• Running test in 1 packages
• Remote caching disabled, using shared worktree cache
//:ensure-native-modules: cache bypass, force executing 48fa1b1908f484d5
@bb/templates:generate:plugin-scaffold: cache miss, executing 248a5fe638e83374
@bb/plugin-build:generate: cache miss, executing cc5f8e238f0f5074
@bb/templates:generate:templates: cache miss, executing 0cbc79d1559fcb55
@bb/templates:generate:plugin-scaffold:
@bb/templates:generate:plugin-scaffold: > @bb/templates@0.0.1 generate:plugin-scaffold $CHECKOUT_1/packages/templates
@bb/templates:generate:plugin-scaffold: > node ./scripts/generate-plugin-scaffold.mjs
@bb/templates:generate:plugin-scaffold:
@bb/plugin-build:generate:
@bb/plugin-build:generate: > @bb/plugin-build@0.0.1 generate $CHECKOUT_1/packages/plugin-build
@bb/plugin-build:generate: > node ./scripts/generate-plugin-theme.mjs && node ./scripts/generate-runtime-export-manifest.mjs
@bb/plugin-build:generate:
@bb/templates:generate:templates:
@bb/templates:generate:templates: > @bb/templates@0.0.1 generate:templates $CHECKOUT_1/packages/templates
@bb/templates:generate:templates: > node ./scripts/generate-templates.mjs
@bb/templates:generate:templates:
//:ensure-native-modules:
//:ensure-native-modules: > bb@ ensure-native-modules $CHECKOUT_1
//:ensure-native-modules: > node scripts/ensure-native-modules.mjs
//:ensure-native-modules:
@bb/plugin-build:generate: plugin-theme.generated.ts up to date
@get-bb/plugin-sdk:build: cache miss, executing 934e4c91c889451f
@bb/plugin-build:generate: wrote $CHECKOUT_1/packages/plugin-build/src/generated/runtime-export-manifest.generated.ts (react@19.2.4)
@get-bb/plugin-sdk:build:
@get-bb/plugin-sdk:build: > @get-bb/plugin-sdk@0.6.16 build $CHECKOUT_1/packages/plugin-sdk
@get-bb/plugin-sdk:build: > node scripts/build-runtime.mjs
@get-bb/plugin-sdk:build:
@get-bb/plugin-sdk:build: Built 19 @get-bb/plugin-sdk runtime entries.
@bb/host-daemon:test: cache miss, executing 2031a0ccc6c1b4aa
@bb/host-daemon:test:
@bb/host-daemon:test: > @bb/host-daemon@0.0.1 test $CHECKOUT_1/apps/host-daemon
@bb/host-daemon:test: > vitest run --config vitest.config.ts "src/injected-skills.test.ts" "-t" "repairs a stored tree|reuses a complete tree installed"
@bb/host-daemon:test:
@bb/host-daemon:test:
@bb/host-daemon:test: RUN v4.1.1 $CHECKOUT_1/apps/host-daemon
@bb/host-daemon:test:
@bb/host-daemon:test: ❯ |@bb/host-daemon| src/injected-skills.test.ts (20 tests | 3 failed | 15 skipped) 96ms
@bb/host-daemon:test: ↓ creates the global skills root so the watcher can subscribe
@bb/host-daemon:test: ↓ pulls a missing tree, stages identical bytes and modes, and reuses the store
@bb/host-daemon:test: ✓ repairs a stored tree with 'empty directory' before staging 24ms
@bb/host-daemon:test: × repairs a stored tree with 'unfinished directory' before staging 20ms
@bb/host-daemon:test: × repairs a stored tree with 'missing content directory' before staging 14ms
@bb/host-daemon:test: × repairs a stored tree with 'missing completion marker' before staging 20ms
@bb/host-daemon:test: ✓ reuses a complete tree installed while the fetch is pending 17ms
@bb/host-daemon:test: ↓ surfaces a failed required tree pull instead of silently skipping it
@bb/host-daemon:test: ↓ rejects fetched tree content that does not match its declared hash
@bb/host-daemon:test: ↓ verifies Unicode paths with locale-independent code-point ordering
@bb/host-daemon:test: ↓ garbage-collects the least-recently-used trees beyond the store cap
@bb/host-daemon:test: ↓ exempts in-flight tree hashes from garbage collection
@bb/host-daemon:test: ↓ stages a workspace-path skill source into the shared catalog
@bb/host-daemon:test: ↓ stages a read-only shared host path for every provider
@bb/host-daemon:test: ↓ changes the catalog hash when skill content changes
@bb/host-daemon:test: ↓ stages the same catalog concurrently without sharing temp directories
@bb/host-daemon:test: ↓ skips symlinked files during staging
@bb/host-daemon:test: ↓ keeps fresh .tmp- dirs that may belong to an in-flight staging
@bb/host-daemon:test: ↓ removes stale .tmp- dirs left behind by crashed stagings
@bb/host-daemon:test: ↓ removes unkept catalog dirs while keeping kept ones
@bb/host-daemon:test:
@bb/host-daemon:test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯
@bb/host-daemon:test:
@bb/host-daemon:test: FAIL |@bb/host-daemon| src/injected-skills.test.ts > injected skill staging > repairs a stored tree with 'unfinished directory' before staging
@bb/host-daemon:test: AssertionError: expected [ …(2) ] to deeply equal []
@bb/host-daemon:test:
@bb/host-daemon:test: - Expected
@bb/host-daemon:test: + Received
@bb/host-daemon:test:
@bb/host-daemon:test: - []
@bb/host-daemon:test: + [
@bb/host-daemon:test: + "ENOENT: no such file or directory, lstat '$ISOLATED_DATA_DIR/runtime/skill-store/313eb49b3ef9c67ac694be944ba0fdc8d2b226341a4675981f616c0a7049c874/content'",
@bb/host-daemon:test: + "ENOENT: no such file or directory, lstat '$ISOLATED_DATA_DIR/runtime/skill-store/313eb49b3ef9c67ac694be944ba0fdc8d2b226341a4675981f616c0a7049c874/content'",
@bb/host-daemon:test: + ]
@bb/host-daemon:test:
@bb/host-daemon:test: ❯ src/injected-skills.test.ts:282:7
@bb/host-daemon:test: 280| .filter((stage) => stage instanceof Error)
@bb/host-daemon:test: 281| .map((error) => error.message),
@bb/host-daemon:test: 282| ).toEqual([]);
@bb/host-daemon:test: | ^
@bb/host-daemon:test: 283| await expect(
@bb/host-daemon:test: 284| readFile(path.join(treeRootPath, ".complete"), "utf8"),
@bb/host-daemon:test:
@bb/host-daemon:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯
@bb/host-daemon:test:
@bb/host-daemon:test: FAIL |@bb/host-daemon| src/injected-skills.test.ts > injected skill staging > repairs a stored tree with 'missing content directory' before staging
@bb/host-daemon:test: AssertionError: expected [ …(2) ] to deeply equal []
@bb/host-daemon:test:
@bb/host-daemon:test: - Expected
@bb/host-daemon:test: + Received
@bb/host-daemon:test:
@bb/host-daemon:test: - []
@bb/host-daemon:test: + [
@bb/host-daemon:test: + "ENOENT: no such file or directory, lstat '$ISOLATED_DATA_DIR/runtime/skill-store/313eb49b3ef9c67ac694be944ba0fdc8d2b226341a4675981f616c0a7049c874/content'",
@bb/host-daemon:test: + "ENOENT: no such file or directory, lstat '$ISOLATED_DATA_DIR/runtime/skill-store/313eb49b3ef9c67ac694be944ba0fdc8d2b226341a4675981f616c0a7049c874/content'",
@bb/host-daemon:test: + ]
@bb/host-daemon:test:
@bb/host-daemon:test: ❯ src/injected-skills.test.ts:282:7
@bb/host-daemon:test: 280| .filter((stage) => stage instanceof Error)
@bb/host-daemon:test: 281| .map((error) => error.message),
@bb/host-daemon:test: 282| ).toEqual([]);
@bb/host-daemon:test: | ^
@bb/host-daemon:test: 283| await expect(
@bb/host-daemon:test: 284| readFile(path.join(treeRootPath, ".complete"), "utf8"),
@bb/host-daemon:test:
@bb/host-daemon:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯
@bb/host-daemon:test:
@bb/host-daemon:test: FAIL |@bb/host-daemon| src/injected-skills.test.ts > injected skill staging > repairs a stored tree with 'missing completion marker' before staging
@bb/host-daemon:test: AssertionError: promise rejected "Error: ENOENT: no such file or directory,… { …(4) }" instead of resolving
@bb/host-daemon:test: ❯ src/injected-skills.test.ts:285:6
@bb/host-daemon:test: 283| await expect(
@bb/host-daemon:test: 284| readFile(path.join(treeRootPath, ".complete"), "utf8"),
@bb/host-daemon:test: 285| ).resolves.toBe("complete\n");
@bb/host-daemon:test: | ^
@bb/host-daemon:test: 286| for (const stage of stages) {
@bb/host-daemon:test: 287| if (stage instanceof Error) throw stage;
@bb/host-daemon:test:
@bb/host-daemon:test: Caused by: Error: ENOENT: no such file or directory, open '$ISOLATED_DATA_DIR/runtime/skill-store/313eb49b3ef9c67ac694be944ba0fdc8d2b226341a4675981f616c0a7049c874/.complete'
@bb/host-daemon:test: ❯ src/injected-skills.test.ts:283:5
@bb/host-daemon:test:
@bb/host-daemon:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
@bb/host-daemon:test: Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'open', path: '$ISOLATED_DATA_DIR/runtime/skill-store/313eb49b3ef9c67ac694be944ba0fdc8d2b226341a4675981f616c0a7049c874/.complete' }
@bb/host-daemon:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/3]⎯
@bb/host-daemon:test:
@bb/host-daemon:test:
@bb/host-daemon:test: Test Files 1 failed (1)
@bb/host-daemon:test: Tests 3 failed | 2 passed | 15 skipped (20)
@bb/host-daemon:test: Start at 16:47:43
@bb/host-daemon:test: Duration 553ms (transform 135ms, setup 0ms, import 266ms, tests 96ms, environment 0ms)
@bb/host-daemon:test:
@bb/host-daemon:test: ELIFECYCLE Test failed. See above for more details.
@bb/host-daemon#test: ERROR command ($CHECKOUT_1/apps/host-daemon) /usr/local/bin/pnpm run test src/injected-skills.test.ts -t repairs a stored tree|reuses a complete tree installed exited (1)
Tasks: 5 successful, 6 total
Cached: 0 cached, 6 total
Time: 2.99s
Failed: @bb/host-daemon#test
ERROR run failed: command exited (1)
Second clean reproduction log
• turbo 2.10.12
• Packages in scope: @bb/host-daemon
• Running test in 1 packages
• Remote caching disabled, using shared worktree cache
//:ensure-native-modules: cache bypass, force executing 48fa1b1908f484d5
@bb/templates:generate:plugin-scaffold: cache hit, replaying logs 248a5fe638e83374
@bb/templates:generate:plugin-scaffold:
@bb/templates:generate:plugin-scaffold: > @bb/templates@0.0.1 generate:plugin-scaffold $CHECKOUT_1/packages/templates
@bb/templates:generate:plugin-scaffold: > node ./scripts/generate-plugin-scaffold.mjs
@bb/templates:generate:plugin-scaffold:
@bb/templates:generate:templates: cache hit, replaying logs 0cbc79d1559fcb55
@bb/templates:generate:templates:
@bb/templates:generate:templates: > @bb/templates@0.0.1 generate:templates $CHECKOUT_1/packages/templates
@bb/templates:generate:templates: > node ./scripts/generate-templates.mjs
@bb/templates:generate:templates:
@bb/plugin-build:generate: cache hit, replaying logs cc5f8e238f0f5074
@bb/plugin-build:generate:
@bb/plugin-build:generate: > @bb/plugin-build@0.0.1 generate $CHECKOUT_1/packages/plugin-build
@bb/plugin-build:generate: > node ./scripts/generate-plugin-theme.mjs && node ./scripts/generate-runtime-export-manifest.mjs
@bb/plugin-build:generate:
@bb/plugin-build:generate: plugin-theme.generated.ts up to date
@bb/plugin-build:generate: wrote $CHECKOUT_1/packages/plugin-build/src/generated/runtime-export-manifest.generated.ts (react@19.2.4)
@get-bb/plugin-sdk:build: cache hit, replaying logs 934e4c91c889451f
@get-bb/plugin-sdk:build:
@get-bb/plugin-sdk:build: > @get-bb/plugin-sdk@0.6.16 build $CHECKOUT_1/packages/plugin-sdk
@get-bb/plugin-sdk:build: > node scripts/build-runtime.mjs
@get-bb/plugin-sdk:build:
@get-bb/plugin-sdk:build: Built 19 @get-bb/plugin-sdk runtime entries.
//:ensure-native-modules:
//:ensure-native-modules: > bb@ ensure-native-modules $CHECKOUT_2
//:ensure-native-modules: > node scripts/ensure-native-modules.mjs
//:ensure-native-modules:
//:ensure-native-modules: [ensure-native-modules] Detached hardlinked better-sqlite3 binary before verification
@bb/host-daemon:test: cache miss, executing 2031a0ccc6c1b4aa
@bb/host-daemon:test:
@bb/host-daemon:test: > @bb/host-daemon@0.0.1 test $CHECKOUT_2/apps/host-daemon
@bb/host-daemon:test: > vitest run --config vitest.config.ts "src/injected-skills.test.ts" "-t" "repairs a stored tree|reuses a complete tree installed"
@bb/host-daemon:test:
@bb/host-daemon:test:
@bb/host-daemon:test: RUN v4.1.1 $CHECKOUT_2/apps/host-daemon
@bb/host-daemon:test:
@bb/host-daemon:test: ❯ |@bb/host-daemon| src/injected-skills.test.ts (20 tests | 3 failed | 15 skipped) 96ms
@bb/host-daemon:test: ↓ creates the global skills root so the watcher can subscribe
@bb/host-daemon:test: ↓ pulls a missing tree, stages identical bytes and modes, and reuses the store
@bb/host-daemon:test: ✓ repairs a stored tree with 'empty directory' before staging 23ms
@bb/host-daemon:test: × repairs a stored tree with 'unfinished directory' before staging 20ms
@bb/host-daemon:test: × repairs a stored tree with 'missing content directory' before staging 14ms
@bb/host-daemon:test: × repairs a stored tree with 'missing completion marker' before staging 20ms
@bb/host-daemon:test: ✓ reuses a complete tree installed while the fetch is pending 17ms
@bb/host-daemon:test: ↓ surfaces a failed required tree pull instead of silently skipping it
@bb/host-daemon:test: ↓ rejects fetched tree content that does not match its declared hash
@bb/host-daemon:test: ↓ verifies Unicode paths with locale-independent code-point ordering
@bb/host-daemon:test: ↓ garbage-collects the least-recently-used trees beyond the store cap
@bb/host-daemon:test: ↓ exempts in-flight tree hashes from garbage collection
@bb/host-daemon:test: ↓ stages a workspace-path skill source into the shared catalog
@bb/host-daemon:test: ↓ stages a read-only shared host path for every provider
@bb/host-daemon:test: ↓ changes the catalog hash when skill content changes
@bb/host-daemon:test: ↓ stages the same catalog concurrently without sharing temp directories
@bb/host-daemon:test: ↓ skips symlinked files during staging
@bb/host-daemon:test: ↓ keeps fresh .tmp- dirs that may belong to an in-flight staging
@bb/host-daemon:test: ↓ removes stale .tmp- dirs left behind by crashed stagings
@bb/host-daemon:test: ↓ removes unkept catalog dirs while keeping kept ones
@bb/host-daemon:test:
@bb/host-daemon:test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯
@bb/host-daemon:test:
@bb/host-daemon:test: FAIL |@bb/host-daemon| src/injected-skills.test.ts > injected skill staging > repairs a stored tree with 'unfinished directory' before staging
@bb/host-daemon:test: AssertionError: expected [ …(2) ] to deeply equal []
@bb/host-daemon:test:
@bb/host-daemon:test: - Expected
@bb/host-daemon:test: + Received
@bb/host-daemon:test:
@bb/host-daemon:test: - []
@bb/host-daemon:test: + [
@bb/host-daemon:test: + "ENOENT: no such file or directory, lstat '$ISOLATED_DATA_DIR/runtime/skill-store/313eb49b3ef9c67ac694be944ba0fdc8d2b226341a4675981f616c0a7049c874/content'",
@bb/host-daemon:test: + "ENOENT: no such file or directory, lstat '$ISOLATED_DATA_DIR/runtime/skill-store/313eb49b3ef9c67ac694be944ba0fdc8d2b226341a4675981f616c0a7049c874/content'",
@bb/host-daemon:test: + ]
@bb/host-daemon:test:
@bb/host-daemon:test: ❯ src/injected-skills.test.ts:282:7
@bb/host-daemon:test: 280| .filter((stage) => stage instanceof Error)
@bb/host-daemon:test: 281| .map((error) => error.message),
@bb/host-daemon:test: 282| ).toEqual([]);
@bb/host-daemon:test: | ^
@bb/host-daemon:test: 283| await expect(
@bb/host-daemon:test: 284| readFile(path.join(treeRootPath, ".complete"), "utf8"),
@bb/host-daemon:test:
@bb/host-daemon:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯
@bb/host-daemon:test:
@bb/host-daemon:test: FAIL |@bb/host-daemon| src/injected-skills.test.ts > injected skill staging > repairs a stored tree with 'missing content directory' before staging
@bb/host-daemon:test: AssertionError: expected [ …(2) ] to deeply equal []
@bb/host-daemon:test:
@bb/host-daemon:test: - Expected
@bb/host-daemon:test: + Received
@bb/host-daemon:test:
@bb/host-daemon:test: - []
@bb/host-daemon:test: + [
@bb/host-daemon:test: + "ENOENT: no such file or directory, lstat '$ISOLATED_DATA_DIR/runtime/skill-store/313eb49b3ef9c67ac694be944ba0fdc8d2b226341a4675981f616c0a7049c874/content'",
@bb/host-daemon:test: + "ENOENT: no such file or directory, lstat '$ISOLATED_DATA_DIR/runtime/skill-store/313eb49b3ef9c67ac694be944ba0fdc8d2b226341a4675981f616c0a7049c874/content'",
@bb/host-daemon:test: + ]
@bb/host-daemon:test:
@bb/host-daemon:test: ❯ src/injected-skills.test.ts:282:7
@bb/host-daemon:test: 280| .filter((stage) => stage instanceof Error)
@bb/host-daemon:test: 281| .map((error) => error.message),
@bb/host-daemon:test: 282| ).toEqual([]);
@bb/host-daemon:test: | ^
@bb/host-daemon:test: 283| await expect(
@bb/host-daemon:test: 284| readFile(path.join(treeRootPath, ".complete"), "utf8"),
@bb/host-daemon:test:
@bb/host-daemon:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯
@bb/host-daemon:test:
@bb/host-daemon:test: FAIL |@bb/host-daemon| src/injected-skills.test.ts > injected skill staging > repairs a stored tree with 'missing completion marker' before staging
@bb/host-daemon:test: AssertionError: promise rejected "Error: ENOENT: no such file or directory,… { …(4) }" instead of resolving
@bb/host-daemon:test: ❯ src/injected-skills.test.ts:285:6
@bb/host-daemon:test: 283| await expect(
@bb/host-daemon:test: 284| readFile(path.join(treeRootPath, ".complete"), "utf8"),
@bb/host-daemon:test: 285| ).resolves.toBe("complete\n");
@bb/host-daemon:test: | ^
@bb/host-daemon:test: 286| for (const stage of stages) {
@bb/host-daemon:test: 287| if (stage instanceof Error) throw stage;
@bb/host-daemon:test:
@bb/host-daemon:test: Caused by: Error: ENOENT: no such file or directory, open '$ISOLATED_DATA_DIR/runtime/skill-store/313eb49b3ef9c67ac694be944ba0fdc8d2b226341a4675981f616c0a7049c874/.complete'
@bb/host-daemon:test: ❯ src/injected-skills.test.ts:283:5
@bb/host-daemon:test:
@bb/host-daemon:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
@bb/host-daemon:test: Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'open', path: '$ISOLATED_DATA_DIR/runtime/skill-store/313eb49b3ef9c67ac694be944ba0fdc8d2b226341a4675981f616c0a7049c874/.complete' }
@bb/host-daemon:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/3]⎯
@bb/host-daemon:test:
@bb/host-daemon:test:
@bb/host-daemon:test: Test Files 1 failed (1)
@bb/host-daemon:test: Tests 3 failed | 2 passed | 15 skipped (20)
@bb/host-daemon:test: Start at 16:48:03
@bb/host-daemon:test: Duration 555ms (transform 137ms, setup 0ms, import 266ms, tests 96ms, environment 0ms)
@bb/host-daemon:test:
@bb/host-daemon:test: ELIFECYCLE Test failed. See above for more details.
@bb/host-daemon#test: ERROR command ($CHECKOUT_2/apps/host-daemon) /usr/local/bin/pnpm run test src/injected-skills.test.ts -t repairs a stored tree|reuses a complete tree installed exited (1)
Tasks: 5 successful, 6 total
Cached: 4 cached, 6 total
Time: 2.13s
Failed: @bb/host-daemon#test
ERROR run failed: command exited (1)
Investigation commands: trusted origin/main fetch, detached worktree creation, frozen install, Turbo host-daemon build, formatting of the authored test, focused Turbo test in each checkout, source inspection, issue/type/field/label reads, timeline read, open-PR search. There were no live-process or visual checks.
Trust boundary: issue data was used only as claims to test. Suggestions and workaround instructions were not executed, and external issue links were not fetched. All executed code came from the recorded trusted base or the agent-authored tests.
> AGENT GENERATED