/** Run ownership lease tests (I-002). */ import * as assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { after, before, describe, it } from "node:test"; import { acquireOwnership, assertOwnership, heldOwnershipKeys, OwnershipError, ownershipHeld, ownershipRecord, processStartOf, releaseOwnership, } from "../src/run-ownership.ts"; import { initState, startRun } from "../src/state.ts"; let tmpRoot: string; function setupRun(name: string): { workdir: string; runId: string } { const workdir = path.join(tmpRoot, name); fs.mkdirSync(workdir, { recursive: true }); initState(workdir); const { run } = startRun(workdir, { topic: "owner-tests", skill: "plan-small", requestText: "t" }); return { workdir, runId: run.run_id }; } function ownerPath(workdir: string, runId: string): string { return path.join(workdir, ".git", "pi_plans", "runs", runId, "owner.json"); } function writeRawOwner(workdir: string, runId: string, record: Record): void { fs.writeFileSync(ownerPath(workdir, runId), JSON.stringify(record), "utf8"); } function selfRecord(overrides: Record): Record { return { schema: 1, host: os.hostname(), pid: process.pid, pidStart: processStartOf(process.pid), sessionId: null, processToken: "tok-" + Math.random().toString(36).slice(2), generation: 1, acquiredAt: "2026-09-07T00:00:00Z", ...overrides, }; } before(() => { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-own-")); }); after(() => { fs.rmSync(tmpRoot, { recursive: true, force: true }); }); describe("ownership lease", () => { it("acquires exclusively, is idempotent per process, and validates", () => { const { workdir, runId } = setupRun("basic"); const first = acquireOwnership(workdir, runId); assert.equal(first.generation, 1); assert.ok(fs.existsSync(ownerPath(workdir, runId))); const again = acquireOwnership(workdir, runId); assert.equal(again.processToken, first.processToken); assert.equal(ownershipHeld(workdir, runId), true); assertOwnership(workdir, runId, { processToken: first.processToken, generation: first.generation }); assert.deepEqual(heldOwnershipKeys().slice(-1), [`${workdir}::${runId}`]); }); it("release only honors the matching token", () => { const { workdir, runId } = setupRun("release"); const owner = acquireOwnership(workdir, runId); assert.equal(releaseOwnership(workdir, runId, "wrong-token"), false); assert.ok(fs.existsSync(ownerPath(workdir, runId))); assert.equal(releaseOwnership(workdir, runId, owner.processToken), true); assert.equal(fs.existsSync(ownerPath(workdir, runId)), false); // Fresh acquire after release starts a new epoch file. const next = acquireOwnership(workdir, runId); assert.equal(next.generation, 1); }); it("refuses to steal from a live owner", () => { const { workdir, runId } = setupRun("live"); const child = spawn("sleep", ["30"], { stdio: "ignore" }); try { const start = processStartOf(child.pid!); assert.ok(start); writeRawOwner(workdir, runId, selfRecord({ pid: child.pid, pidStart: start })); assert.throws( () => acquireOwnership(workdir, runId), (error: unknown) => error instanceof OwnershipError && /actively owned/.test(error.message), ); } finally { child.kill("SIGKILL"); } }); it("takes over a dead owner with generation+1", async () => { const { workdir, runId } = setupRun("dead"); const child = spawn("sleep", ["0.05"], { stdio: "ignore" }); const childPid = child.pid!; const start = processStartOf(childPid); assert.ok(start); // Wait until the child is fully reaped (zombies still answer kill(0)). await new Promise((resolve) => child.on("exit", () => resolve())); writeRawOwner(workdir, runId, selfRecord({ pid: childPid, pidStart: start, generation: 3 })); const taken = acquireOwnership(workdir, runId); assert.equal(taken.generation, 4); const record = ownershipRecord(workdir, runId); assert.equal(record?.pid, process.pid); }); it("treats a reused pid (start-time mismatch) as a dead owner", () => { const { workdir, runId } = setupRun("reuse"); writeRawOwner(workdir, runId, selfRecord({ pidStart: "bogus start time", generation: 2 })); const taken = acquireOwnership(workdir, runId); assert.equal(taken.generation, 3); }); it("refuses foreign hosts and corrupt records", () => { const { workdir, runId } = setupRun("foreign"); writeRawOwner(workdir, runId, selfRecord({ host: "another-host" })); assert.throws( () => acquireOwnership(workdir, runId), (error: unknown) => error instanceof OwnershipError && /cross-host/.test(error.message), ); fs.rmSync(ownerPath(workdir, runId)); fs.writeFileSync(ownerPath(workdir, runId), "{ broken", "utf8"); assert.throws( () => acquireOwnership(workdir, runId), (error: unknown) => error instanceof OwnershipError && /corrupt/.test(error.message), ); }); it("assertOwnership fails after a takeover", () => { const { workdir, runId } = setupRun("assert"); const owner = acquireOwnership(workdir, runId); // Simulate a takeover by another process: write a new record directly. const child = spawn("sleep", ["0.01"], { stdio: "ignore" }); child.on("exit", () => {}); writeRawOwner(workdir, runId, selfRecord({ pid: child.pid, pidStart: "bogus-start", processToken: "someone-else", generation: owner.generation + 1, })); assert.throws( () => assertOwnership(workdir, runId, { processToken: owner.processToken, generation: owner.generation }), OwnershipError, ); }); it("same live process re-acquires after an in-memory reset (reload)", () => { const { workdir, runId } = setupRun("reload"); const first = acquireOwnership(workdir, runId); // Simulate extension reload: the on-disk record stays, memory is gone. // Drop our memory by releasing knowledge only — the file remains. // (Direct approach: fabricate the file as if we still hold it.) fs.writeFileSync(ownerPath(workdir, runId), JSON.stringify(first), "utf8"); // The module still holds it in memory; use a fresh child process to // prove adoption works across processes with the same pid semantics. const record = ownershipRecord(workdir, runId); assert.equal(record?.processToken, first.processToken); assert.equal(ownershipHeld(workdir, runId), true); }); });