diff --git a/package-lock.json b/package-lock.json index 4744338..ad4fc0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "headlesscode", - "version": "1.9.7", + "version": "1.9.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "headlesscode", - "version": "1.9.7", + "version": "1.9.8", "license": "Apache-2.0", "dependencies": { "@aws-sdk/client-s3": "^3.1142.0", diff --git a/package.json b/package.json index 40ae385..140575d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "headlesscode", - "version": "1.9.7", + "version": "1.9.8", "description": "Coding-agent harness with CLI and HTTP interfaces, parallel Git worktree orchestration, and bounded recursive self-improvement (RSI).", "license": "Apache-2.0", "repository": { diff --git a/src/rsi/__tests__/operations-monitor.test.ts b/src/rsi/__tests__/operations-monitor.test.ts index b6f5cf9..ea0650d 100644 --- a/src/rsi/__tests__/operations-monitor.test.ts +++ b/src/rsi/__tests__/operations-monitor.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict" import { EventEmitter } from "node:events" +import { syncBuiltinESMExports } from "node:module" +import fsPromises from "node:fs/promises" import test from "node:test" import { spawn, type ChildProcess } from "node:child_process" import * as fs from "node:fs/promises" @@ -204,6 +206,62 @@ test("monitor writes an atomic heartbeat and refuses a concurrent process lock", await fs.rm(stateDirectory, { recursive: true, force: true }) }) +test("monitor lock files project only process identity and scope fields", async () => { + const stateDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "headlesscode-rsi-monitor-lock-projection-")) + await fs.chmod(stateDirectory, 0o700) + const fixture = fixturePool() + const sentinel = "postgres://monitor-lock-secret:sentinel-password@127.0.0.1/private?token=sentinel-token" + Object.assign(fixture.pool, { connectionString: sentinel, privatePoolOption: "must-not-serialize" }) + const injectedOptions = Object.assign({ + ...statusOptions, pool: fixture.pool, stateDirectory, pollMs: 1000, stallMs: 2000, maxRuntimeMs: 10_000, + }, { connectionString: sentinel, additionalRuntimeOption: "must-not-serialize" }) as RsiMonitorOptions + const originalOpen = fsPromises.open + const captured: Record = {} + const patchedOpen: typeof fsPromises.open = async (...args) => { + const handle = await originalOpen(...args) + const filename = String(args[0]) + if (!filename.endsWith("/monitor.lock") && !filename.endsWith("/monitor.lock.bootstrap")) return handle + return new Proxy(handle, { + get(target, property) { + if (property === "writeFile") return async (...writeArgs: Parameters) => { + await Reflect.apply(handle.writeFile.bind(handle), handle, writeArgs) + captured[path.basename(filename)] = await fs.readFile(filename, "utf8") + return undefined + } + const value = Reflect.get(target, property, target) + return typeof value === "function" ? value.bind(target) : value + }, + }) + } + const controller = new AbortController() + try { + fsPromises.open = patchedOpen + syncBuiltinESMExports() + const exitCode = await runRsiMonitor({ ...injectedOptions, signal: controller.signal, stdout: () => controller.abort() }) + assert.equal(exitCode, 0) + const persistent = JSON.parse(captured["monitor.lock"] ?? "null") as Record | null + const bootstrap = JSON.parse(captured["monitor.lock.bootstrap"] ?? "null") as Record | null + assert.ok(persistent, "read the persistent lock bytes after the file write") + assert.ok(bootstrap, "capture the bootstrap lock bytes during its write") + const allowedPersistent = ["schemaVersion", "pid", "startTicks", "bootId", "queueName", "campaignId", "startedAt"] + const allowedBootstrap = ["schemaVersion", "pid", "startTicks", "bootId", "queueName", "campaignId", "nonce"] + assert.deepEqual(Object.keys(persistent).sort(), allowedPersistent.sort()) + assert.deepEqual(Object.keys(bootstrap).sort(), allowedBootstrap.sort()) + assert.equal(persistent.queueName, statusOptions.queueName) + assert.equal(persistent.campaignId, statusOptions.campaignId) + assert.equal(bootstrap.queueName, statusOptions.queueName) + assert.equal(bootstrap.campaignId, statusOptions.campaignId) + assert.ok(!captured["monitor.lock"].includes(sentinel)) + assert.ok(!captured["monitor.lock.bootstrap"].includes(sentinel)) + assert.ok(!captured["monitor.lock"].includes("must-not-serialize")) + assert.ok(!captured["monitor.lock.bootstrap"].includes("must-not-serialize")) + } finally { + fsPromises.open = originalOpen + syncBuiltinESMExports() + await fs.rm(stateDirectory, { recursive: true, force: true }) + } +}) + test("monitor rejects a pre-existing lock and malformed history instead of taking ownership", async () => { const stateDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "headlesscode-rsi-monitor-lock-")) await fs.chmod(stateDirectory, 0o700) diff --git a/src/rsi/benchmark/__tests__/runner.test.ts b/src/rsi/benchmark/__tests__/runner.test.ts index b68a553..a23c0b2 100644 --- a/src/rsi/benchmark/__tests__/runner.test.ts +++ b/src/rsi/benchmark/__tests__/runner.test.ts @@ -1,7 +1,6 @@ import assert from "node:assert/strict" import { execFileSync } from "node:child_process" import * as fs from "node:fs/promises" -import * as os from "node:os" import * as path from "node:path" import { FileRsiArtifactStore } from "../../artifact-store.js" import { freezeBenchmarkProtocol, loadBenchmark, runBenchmark } from "../runner.js" @@ -11,7 +10,9 @@ import { OpenShellBenchmarkAgent } from "../openshell-agent.js" const fixtureRoot = path.resolve("fixtures/rsi-benchmark/smoke") const loaded = await loadBenchmark(fixtureRoot) -const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rsi-benchmark-runner-test-")) +const scratchRoot = path.resolve(".headlesscode/scratch") +await fs.mkdir(scratchRoot, { recursive: true }) +const tempRoot = await fs.mkdtemp(path.join(scratchRoot, "rsi-benchmark-runner-test-")) const outputRoot = path.join(tempRoot, "results") const harnessCommit = "a".repeat(40) let agentCalls = 0 @@ -19,6 +20,106 @@ let fakeCodingGuestActive = false let lastCodingGuestWorkspace = "" let activeSplit: "development" | "confirmation" = "development" const confirmationTexts = new Set(loaded.manifest.tasks.filter((task) => task.split === "confirmation").map((task) => task.task)) +const ownedFakeAgentFixtures = new Set() +const MAX_FIXTURE_REMOVE_ATTEMPTS = 5 +const fixtureGitEnv = { + ...process.env, + GIT_CONFIG_COUNT: "3", + GIT_CONFIG_KEY_0: "gc.auto", + GIT_CONFIG_VALUE_0: "0", + GIT_CONFIG_KEY_1: "maintenance.auto", + GIT_CONFIG_VALUE_1: "false", + GIT_CONFIG_KEY_2: "maintenance.autoDetach", + GIT_CONFIG_VALUE_2: "false", +} +const fixtureGitLocalConfigEnv = { ...fixtureGitEnv, GIT_CONFIG_COUNT: "0" } + +async function createOwnedFakeAgentFixture(): Promise { + const parent = path.join(tempRoot, "fake-agent-fixtures") + await fs.mkdir(parent, { recursive: true }) + const candidate = await fs.mkdtemp(path.join(parent, "rsi-benchmark-fake-agent-")) + ownedFakeAgentFixtures.add(candidate) + return candidate +} + +async function removeOwnedFakeAgentFixture( + candidate: string, + options: { + remove?: (target: string) => Promise + wait?: (milliseconds: number) => Promise + } = {}, +): Promise { + if (!ownedFakeAgentFixtures.has(candidate)) throw new Error("refusing to remove an unowned fake-agent fixture") + const remove = options.remove ?? (async (target: string) => fs.rm(target, { recursive: true, force: true })) + const wait = options.wait ?? ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds))) + let lastError: NodeJS.ErrnoException | undefined + let attempts = 0 + for (let attempt = 1; attempt <= MAX_FIXTURE_REMOVE_ATTEMPTS; attempt += 1) { + attempts = attempt + try { + await remove(candidate) + ownedFakeAgentFixtures.delete(candidate) + return + } catch (error) { + lastError = error as NodeJS.ErrnoException + if (lastError.code !== "ENOTEMPTY" || attempt === MAX_FIXTURE_REMOVE_ATTEMPTS) break + await wait(attempt * 20) + } + } + const failure = new Error(`could not remove owned fake-agent fixture after ${attempts} attempt${attempts === 1 ? "" : "s"}`, { cause: lastError }) as NodeJS.ErrnoException + failure.code = lastError?.code + throw failure +} + +async function removeRemainingOwnedFakeAgentFixtures(): Promise { + for (const fixture of ownedFakeAgentFixtures) await removeOwnedFakeAgentFixture(fixture) +} + +async function assertFixtureCleanupContract(): Promise { + const transient = await createOwnedFakeAgentFixture() + let attempts = 0 + await removeOwnedFakeAgentFixture(transient, { + remove: async () => { + attempts += 1 + if (attempts < 3) throw Object.assign(new Error("simulated concurrent Git metadata write"), { code: "ENOTEMPTY" }) + await fs.rm(transient, { recursive: true, force: true }) + }, + wait: async () => undefined, + }) + assert.equal(attempts, 3, "owned fixture cleanup retries transient ENOTEMPTY and then succeeds") + assert.equal(ownedFakeAgentFixtures.has(transient), false, "successful cleanup releases fixture ownership") + + const persistent = await createOwnedFakeAgentFixture() + attempts = 0 + await assert.rejects(removeOwnedFakeAgentFixture(persistent, { + remove: async () => { + attempts += 1 + throw Object.assign(new Error("simulated persistent Git metadata write"), { code: "ENOTEMPTY" }) + }, + wait: async () => undefined, + }), (error: NodeJS.ErrnoException) => error.code === "ENOTEMPTY") + assert.equal(attempts, MAX_FIXTURE_REMOVE_ATTEMPTS, "persistent ENOTEMPTY stops at the configured retry bound") + assert.equal(ownedFakeAgentFixtures.has(persistent), true, "failed cleanup retains ownership for final teardown") + await removeOwnedFakeAgentFixture(persistent) + + const unexpected = await createOwnedFakeAgentFixture() + attempts = 0 + await assert.rejects(removeOwnedFakeAgentFixture(unexpected, { + remove: async () => { + attempts += 1 + throw Object.assign(new Error("simulated unexpected cleanup failure"), { code: "EACCES" }) + }, + wait: async () => undefined, + }), (error: NodeJS.ErrnoException) => error.code === "EACCES") + assert.equal(attempts, 1, "non-ENOTEMPTY cleanup errors are not retried") + await removeOwnedFakeAgentFixture(unexpected) + + let removedUnowned = false + await assert.rejects(removeOwnedFakeAgentFixture(path.join(tempRoot, "not-owned"), { + remove: async () => { removedUnowned = true }, + }), /unowned fake-agent fixture/) + assert.equal(removedUnowned, false, "unowned paths are never passed to the remover") +} const agent: BenchmarkAgent = { provider: "fake-model", @@ -36,24 +137,38 @@ const agent: BenchmarkAgent = { } const task = requestTask const oracle = JSON.parse(await fs.readFile(path.join(fixtureRoot, task.knownGoodPath!), "utf8")) as { files: Record } - const candidate = await fs.mkdtemp(path.join(os.tmpdir(), "rsi-benchmark-fake-agent-")) - await fs.rm(candidate, { recursive: true, force: true }) - execFileSync("git", ["clone", "--quiet", "--no-hardlinks", "--shared", request.workspace, candidate], { stdio: ["ignore", "pipe", "pipe"] }) + const candidate = await createOwnedFakeAgentFixture() + let operationError: unknown try { - execFileSync("git", ["config", "user.name", "RSI Benchmark Test"], { cwd: candidate }) - execFileSync("git", ["config", "user.email", "rsi-benchmark-test@invalid.example"], { cwd: candidate }) + execFileSync("git", ["clone", "--quiet", "--no-hardlinks", "--shared", "--config", "gc.auto=0", "--config", "maintenance.auto=false", request.workspace, candidate], { stdio: ["ignore", "pipe", "pipe"], env: fixtureGitEnv }) + assert.equal(execFileSync("git", ["config", "--local", "--get", "gc.auto"], { cwd: candidate, encoding: "utf8", env: fixtureGitLocalConfigEnv }).trim(), "0") + assert.equal(execFileSync("git", ["config", "--local", "--get", "maintenance.auto"], { cwd: candidate, encoding: "utf8", env: fixtureGitLocalConfigEnv }).trim(), "false") + execFileSync("git", ["config", "gc.auto", "0"], { cwd: candidate, env: fixtureGitEnv }) + execFileSync("git", ["config", "maintenance.auto", "false"], { cwd: candidate, env: fixtureGitEnv }) + execFileSync("git", ["config", "maintenance.autoDetach", "false"], { cwd: candidate, env: fixtureGitEnv }) + execFileSync("git", ["config", "user.name", "RSI Benchmark Test"], { cwd: candidate, env: fixtureGitEnv }) + execFileSync("git", ["config", "user.email", "rsi-benchmark-test@invalid.example"], { cwd: candidate, env: fixtureGitEnv }) for (const [relative, content] of Object.entries(oracle.files)) { const target = path.join(candidate, relative) await fs.mkdir(path.dirname(target), { recursive: true }) await fs.writeFile(target, content) } - execFileSync("git", ["add", "--all"], { cwd: candidate, stdio: "ignore" }) - execFileSync("git", ["-c", "core.hooksPath=/dev/null", "commit", "--quiet", "-m", "fake model patch"], { cwd: candidate, stdio: "ignore" }) - const base = execFileSync("git", ["rev-parse", "HEAD~1"], { cwd: candidate, encoding: "utf8" }).trim() - const patch = execFileSync("git", ["diff", "--binary", "--no-ext-diff", `${base}...HEAD`], { cwd: candidate, encoding: "buffer" }) + execFileSync("git", ["add", "--all"], { cwd: candidate, stdio: "ignore", env: fixtureGitEnv }) + execFileSync("git", ["-c", "core.hooksPath=/dev/null", "commit", "--quiet", "-m", "fake model patch"], { cwd: candidate, stdio: "ignore", env: fixtureGitEnv }) + const base = execFileSync("git", ["rev-parse", "HEAD~1"], { cwd: candidate, encoding: "utf8", env: fixtureGitEnv }).trim() + const patch = execFileSync("git", ["diff", "--binary", "--no-ext-diff", `${base}...HEAD`], { cwd: candidate, encoding: "buffer", env: fixtureGitEnv }) return { validity: "valid", patch, stdout: "fake model completed", stderr: "", calls: 1, tokens: 22, wallTimeMs: 25, stopReason: "fake-complete" } + } catch (error) { + operationError = error + throw error } finally { - await fs.rm(candidate, { recursive: true, force: true }) + try { + await removeOwnedFakeAgentFixture(candidate) + } catch (cleanupError) { + fakeCodingGuestActive = false + if (operationError !== undefined) throw new AggregateError([operationError, cleanupError], "fake-agent operation and fixture cleanup both failed") + throw cleanupError + } fakeCodingGuestActive = false } }, @@ -73,6 +188,7 @@ const verifier: BenchmarkVerifier = { } try { + await assertFixtureCleanupContract() assert.equal(loaded.manifest.tasks.length, 32) assert.equal(loaded.manifest.tasks.filter((task) => task.split === "development").length, 16) assert.equal(loaded.manifest.tasks.filter((task) => task.split === "confirmation").length, 16) @@ -195,7 +311,11 @@ try { }), /does not match requested commit/) assert.equal(imageIdentityReads, 1, "mismatched identity must come from the deterministic resolver without Docker") assert.deepEqual(await fs.readdir(path.join(identityRoot, "attempts")).catch(() => []), [], "wrong image identity must abort without a scored attempt") - console.log("All 18 RSI benchmark runner integration assertions passed") + console.log("All RSI benchmark runner integration assertions passed") } finally { - await fs.rm(tempRoot, { recursive: true, force: true }) + try { + await removeRemainingOwnedFakeAgentFixtures() + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }) + } } diff --git a/src/rsi/operations-monitor.ts b/src/rsi/operations-monitor.ts index 9a13565..687efce 100644 --- a/src/rsi/operations-monitor.ts +++ b/src/rsi/operations-monitor.ts @@ -473,8 +473,8 @@ async function acquireMonitorLock(directory: string, scope: { queueName: string; const lockPath = path.join(directory, "monitor.lock") const bootstrapPath = path.join(directory, "monitor.lock.bootstrap") const identity = await monitorProcessIdentity() - const contents = `${JSON.stringify({ schemaVersion: 1, ...identity, ...scope, startedAt: new Date().toISOString() })}\n` - const bootstrapContents = `${JSON.stringify({ schemaVersion: 1, ...identity, ...scope, nonce: randomUUID() })}\n` + const contents = `${JSON.stringify({ schemaVersion: 1, pid: identity.pid, startTicks: identity.startTicks, bootId: identity.bootId, queueName: scope.queueName, campaignId: scope.campaignId, startedAt: new Date().toISOString() })}\n` + const bootstrapContents = `${JSON.stringify({ schemaVersion: 1, pid: identity.pid, startTicks: identity.startTicks, bootId: identity.bootId, queueName: scope.queueName, campaignId: scope.campaignId, nonce: randomUUID() })}\n` let bootstrap try { bootstrap = await fs.open(bootstrapPath, "wx", 0o600) } catch (error) {