diff --git a/docs/agent-skill-sharing-implementation-checklist.md b/docs/agent-skill-sharing-implementation-checklist.md index 9b5ad78f8ba..dad0204199a 100644 --- a/docs/agent-skill-sharing-implementation-checklist.md +++ b/docs/agent-skill-sharing-implementation-checklist.md @@ -1316,8 +1316,10 @@ platform gates below. The shared transaction core now serializes two simultaneous installs into one installed and one unchanged result, one receipt, and no staging residue. The host request service also serializes simultaneous download-grant and trusted-local ingress requests with the same - guarantees; contention across separate desktop, headless, CLI, SSH, and recovery processes - remains open. + guarantees. A real multi-process harness proves an on-disk destination lock returns a + retryable busy result without residue, then converges to unchanged after the owning process + commits and releases; the release gate runs it on macOS, native Windows, and Ubuntu 20.04. + Named desktop/headless/CLI/SSH/recovery process combinations remain open. - [ ] Test permission and read-only failures, disk exhaustion, cancellation, process termination, and host disconnect. Simulated `EACCES` and `ENOSPC` preservation plus lost-response retry are covered; physical failure and termination journeys remain open. diff --git a/src/main/skills/skill-process-contention-child.test.ts b/src/main/skills/skill-process-contention-child.test.ts new file mode 100644 index 00000000000..977e18fb2c0 --- /dev/null +++ b/src/main/skills/skill-process-contention-child.test.ts @@ -0,0 +1,56 @@ +import { stat, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { describe, it } from 'vitest' +import { installLocalSkillPackage } from './skill-install-transaction' + +const CHILD = process.env.ORCA_SKILL_CONTENTION_CHILD === '1' + +async function waitForRelease(path: string): Promise { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + if (await stat(path).catch(() => null)) { + return + } + await new Promise((resolve) => setTimeout(resolve, 20)) + } + throw new Error('skill-contention-release-timeout') +} + +describe.runIf(CHILD)('skill process contention child', () => { + it('runs one transaction process', async () => { + const root = process.env.ORCA_SKILL_CONTENTION_ROOT + const archivePath = process.env.ORCA_SKILL_CONTENTION_ARCHIVE + const resultPath = process.env.ORCA_SKILL_CONTENTION_RESULT + const role = process.env.ORCA_SKILL_CONTENTION_ROLE + if (!root || !archivePath || !resultPath || !role) { + throw new Error('missing-skill-contention-input') + } + const result = await installLocalSkillPackage( + { + operationId: `contention-${role}`, + archivePath, + destinationRoot: join(root, 'skills'), + stateDirectory: join(root, 'state'), + scope: 'global', + destinationIdentity: 'global:contention-test', + hostIdentity: 'contention-test', + lockTimeoutMs: role === 'blocked' ? 1 : undefined + }, + { + onJournalTransition: async (phase, boundary) => { + if (role !== 'holder' || phase !== 'prepared' || boundary !== 'before') { + return + } + const readyPath = process.env.ORCA_SKILL_CONTENTION_READY + const releasePath = process.env.ORCA_SKILL_CONTENTION_RELEASE + if (!readyPath || !releasePath) { + throw new Error('missing-skill-contention-coordination') + } + await writeFile(readyPath, `${process.pid}\n`) + await waitForRelease(releasePath) + } + } + ) + await writeFile(resultPath, JSON.stringify(result)) + }) +}) diff --git a/src/main/skills/skill-process-contention.integration.test.ts b/src/main/skills/skill-process-contention.integration.test.ts new file mode 100644 index 00000000000..26688019d60 --- /dev/null +++ b/src/main/skills/skill-process-contention.integration.test.ts @@ -0,0 +1,171 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { createRequire } from 'node:module' +import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { createSkillPackageArchive } from './skill-package-creation' + +const RUN_REAL_PROCESS = process.env.ORCA_REAL_PROCESS_SKILL_TEST === '1' +const require = createRequire(import.meta.url) +const vitestBin = join(dirname(require.resolve('vitest/package.json')), 'vitest.mjs') +const childTest = resolve('src/main/skills/skill-process-contention-child.test.ts') +const roots: string[] = [] +const children: ChildProcess[] = [] + +function childOutput(child: ChildProcess): { value(): string } { + let output = '' + const append = (chunk: Buffer): void => { + output = `${output}${chunk.toString('utf8')}`.slice(-8_192) + } + child.stdout?.on('data', append) + child.stderr?.on('data', append) + return { value: () => output } +} + +async function waitForFile(path: string, child: ChildProcess, output: () => string): Promise { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + if (await stat(path).catch(() => null)) { + return + } + if (child.exitCode !== null) { + throw new Error(`skill-contention-child-exited-${child.exitCode}: ${output()}`) + } + await new Promise((resolveWait) => setTimeout(resolveWait, 20)) + } + throw new Error(`skill-contention-marker-timeout: ${output()}`) +} + +function startChild(input: { + root: string + archivePath: string + role: 'holder' | 'blocked' | 'retry' + readyPath: string + releasePath: string + resultPath: string +}): { child: ChildProcess; output: () => string } { + const child = spawn( + process.execPath, + [ + vitestBin, + 'run', + '--config', + resolve('config/vitest.config.ts'), + '--pool=forks', + '--maxWorkers=1', + '--no-file-parallelism', + childTest + ], + { + cwd: process.cwd(), + env: { + ...process.env, + ORCA_REAL_PROCESS_SKILL_TEST: '0', + ORCA_SKILL_CONTENTION_CHILD: '1', + ORCA_SKILL_CONTENTION_ROOT: input.root, + ORCA_SKILL_CONTENTION_ARCHIVE: input.archivePath, + ORCA_SKILL_CONTENTION_ROLE: input.role, + ORCA_SKILL_CONTENTION_READY: input.readyPath, + ORCA_SKILL_CONTENTION_RELEASE: input.releasePath, + ORCA_SKILL_CONTENTION_RESULT: input.resultPath + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + } + ) + children.push(child) + return { child, output: childOutput(child).value } +} + +async function waitForExit(child: ChildProcess, output: () => string): Promise { + const exit = + child.exitCode !== null || child.signalCode !== null + ? { code: child.exitCode, signal: child.signalCode } + : await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolveExit) => + child.once('exit', (code, signal) => resolveExit({ code, signal })) + ) + if (exit.code !== 0) { + throw new Error(`skill-contention-child-failed-${exit.code ?? exit.signal}: ${output()}`) + } +} + +afterEach(async () => { + for (const child of children.splice(0)) { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + } + } + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe.runIf(RUN_REAL_PROCESS)('skill multi-process contention', () => { + it('fails busy without residue, then converges after the owner releases', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-process-contention-')) + roots.push(root) + const source = join(root, 'source') + await mkdir(source) + await writeFile( + join(source, 'SKILL.md'), + '---\nname: contention-skill\ndescription: Contention\n---\n\n# Contention\n' + ) + const archive = await createSkillPackageArchive({ + sourceDirectory: source, + archivePath: join(root, 'package.tar.gz'), + packageId: 'package-contention', + versionId: 'version_1' + }) + const readyPath = join(root, 'holder-ready') + const releasePath = join(root, 'holder-release') + const holderResultPath = join(root, 'holder-result.json') + const blockedResultPath = join(root, 'blocked-result.json') + const retryResultPath = join(root, 'retry-result.json') + const holder = startChild({ + root, + archivePath: archive.archivePath, + role: 'holder', + readyPath, + releasePath, + resultPath: holderResultPath + }) + await waitForFile(readyPath, holder.child, holder.output) + + const blocked = startChild({ + root, + archivePath: archive.archivePath, + role: 'blocked', + readyPath, + releasePath, + resultPath: blockedResultPath + }) + await waitForExit(blocked.child, blocked.output) + expect(JSON.parse(await readFile(blockedResultPath, 'utf8'))).toMatchObject({ + status: 'failed', + errorCategory: 'skill-install-busy', + failure: { retryable: true } + }) + + await writeFile(releasePath, 'release\n') + await waitForExit(holder.child, holder.output) + expect(JSON.parse(await readFile(holderResultPath, 'utf8'))).toMatchObject({ + status: 'installed' + }) + + const retry = startChild({ + root, + archivePath: archive.archivePath, + role: 'retry', + readyPath, + releasePath, + resultPath: retryResultPath + }) + await waitForExit(retry.child, retry.output) + expect(JSON.parse(await readFile(retryResultPath, 'utf8'))).toMatchObject({ + status: 'unchanged' + }) + expect(await readdir(join(root, 'state', 'receipts'))).toHaveLength(1) + expect((await readdir(join(root, 'skills'))).filter((name) => name.includes('.orca-'))).toEqual( + [] + ) + }, 30_000) +})