test(paperclip-runner): stabilize the assigned-skills ACPX runtime host test (#13453)

Release sealed skill homes before test cleanup and reuse one prepared sandbox across the assigned-skills test opens. Preserve the current skill-refresh and prompt assertions without increasing test timeouts.

Co-Authored-By: Devin Foley <devinfoley@users.noreply.github.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
authored and GitHub committed 2026-09-24 11:11:02 -07:00
1 parent 2914501c08
commit e006c18f20
1 file changed
+57 -2
@@ -20,7 +20,10 @@ import type {
VerifiedAcpxInstallation,
} from "./installation-integrity.js";
import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
import { prepareAcpxRuntimeSandbox } from "./runtime-sandbox.js";
import {
prepareAcpxRuntimeSandbox,
type AcpxRuntimeSandbox,
} from "./runtime-sandbox.js";
import {
AcpxRuntimeHost,
type AcpxRuntimeHostDependencies,
@@ -30,6 +33,15 @@ import {
} from "./runtime-host.js";
const temporaryDirectories: string[] = [];
// A published skill snapshot is sealed read-only by `protectStagedTree`
// (runtime-context-materializer.ts:125, :133). Only
// `releaseMaterializedNativeRuntimeSkills` restores write permission before
// removal. `hostFixture` records every sandbox's skills home here as soon as
// the sandbox exists, before the materialize step that seals it and before
// any later step in the same open() call can fail or stall past this file's
// per-test timeout. `afterEach` releases every recorded home first, so a
// forced-open directory removal never has to unlink inside a sealed tree.
const materializedSkillsHomes: string[] = [];
const admissionControllers: AbortController[] = [];
const pendingAdmissionOpenings = new Set<Promise<void>>();
const pendingAdmissionCleanups = new Set<Promise<void>>();
@@ -167,6 +179,14 @@ afterEach(async () => {
}
await Promise.all([...pendingAdmissionOpenings]);
await Promise.all([...pendingAdmissionCleanups]);
// Release every sealed skills tree before the plain `rm` below. `rm` does
// not restore write permission, so a tree still sealed at this point would
// otherwise fail with EACCES and hide the real test failure.
await Promise.all(
materializedSkillsHomes
.splice(0)
.map((skillsHome) => releaseMaterializedNativeRuntimeSkills(skillsHome)),
);
await Promise.all(
temporaryDirectories
.splice(0)
@@ -281,7 +301,12 @@ describe("ACPX runtime host", () => {
const providerStartTurn = vi.fn(() => runtimeTurn());
let assigned = true;
let expectedReference = "ASSIGNED_SKILL_MARKER";
// Prepare the real sandbox once. Each reopen still exercises the host's
// real skill refresh, including changed references and removed assignments,
// without repeating unrelated durable directory and file writes. Sandbox
// preparation itself also has dedicated runtime-sandbox coverage.
const dependencies = fixture.dependencies({
reuseSandbox: true,
openRuntime: async (options) => {
skillsHome = join(options.launchEnvironment.CLAUDE_CONFIG_DIR!, "skills");
expect(await readdir(skillsHome)).toEqual(assigned ? ["assigned"] : []);
@@ -1392,6 +1417,16 @@ describe("ACPX runtime host", () => {
agentRuntimePackageJsonPath: null,
openCommand,
}),
// This test builds its own dependency object instead of
// `fixture.dependencies()`, so it must record the skills home
// itself. Command admission starts after the sandbox exists and
// Claude's skills are already materialized and sealed, and abort
// can land right there — before this test's own cleanup runs.
prepareSandbox: async (sandboxInput) => {
const sandbox = await prepareAcpxRuntimeSandbox(sandboxInput);
materializedSkillsHomes.push(join(sandbox.agentHomeDirectory, "skills"));
return sandbox;
},
openRuntime,
retainAdmissionCleanup: trackAdmissionCleanup,
reportRetainedCleanupFailure: vi.fn(),
@@ -1705,8 +1740,15 @@ async function hostFixture() {
input: Pick<AcpxRuntimeHostDependencies, "openRuntime"> &
Partial<
Pick<AcpxRuntimeHostDependencies, "reportRetainedCleanupFailure">
>,
> & {
// Opt-in only. When true, `prepareSandbox` runs the real
// preparation once, then returns that same sandbox for every
// later open in the test. Every other test omits this flag, so
// the file still proves that a reopen re-prepares the sandbox.
reuseSandbox?: boolean;
},
): AcpxRuntimeHostDependencies {
let reusedSandbox: AcpxRuntimeSandbox | null = null;
return {
verifyInstallation: async (profile) =>
({
@@ -1716,6 +1758,19 @@ async function hostFixture() {
openCommand: async () => command,
}) satisfies VerifiedAcpxInstallation,
openRuntime: input.openRuntime,
// Record the skills home the instant the sandbox exists, ahead of
// the materialize call that seals it. A test that overrides
// `prepareSandbox` for a non-Claude agent replaces this wrapper, but
// those agents never materialize skills, so nothing is lost.
prepareSandbox: async (sandboxInput) => {
if (input.reuseSandbox && reusedSandbox) return reusedSandbox;
const sandbox = await prepareAcpxRuntimeSandbox(sandboxInput);
if (sandboxInput.agent === "claude") {
materializedSkillsHomes.push(join(sandbox.agentHomeDirectory, "skills"));
}
if (input.reuseSandbox) reusedSandbox = sandbox;
return sandbox;
},
retainAdmissionCleanup: trackAdmissionCleanup,
reportRetainedCleanupFailure:
input.reportRetainedCleanupFailure ?? vi.fn(),