diff --git a/src/orchestrator/__tests__/muse-executor.test.ts b/src/orchestrator/__tests__/muse-executor.test.ts index bf9c1ca..c82471b 100644 --- a/src/orchestrator/__tests__/muse-executor.test.ts +++ b/src/orchestrator/__tests__/muse-executor.test.ts @@ -1,5 +1,4 @@ import assert from "node:assert/strict" -import { readdirSync, readFileSync } from "node:fs" import * as fs from "node:fs/promises" import * as path from "node:path" import { execFileSync, spawnSync } from "node:child_process" @@ -12,6 +11,11 @@ import { parseWatchArgs } from "../../watcher/cli.js" import { defaultSpawn } from "../../watcher/watch.js" import { watchIssues, type SpawnBatch } from "../../watcher/watch.js" import { loadState, saveState } from "../state.js" +import { + groupHasLiveMember, + processGroupGone, + procState, +} from "../../testing/process-groups.js" import { runReworkStep } from "../resume.js" import { defaultWatcherState, loadWatcherState, saveWatcherState } from "../../watcher/state.js" @@ -337,37 +341,10 @@ async function testInlineMusePromptUsesIgnoredScratchAndCleansUp(): Promise 1) - assert.throws(() => process.kill(-timeoutPgid, 0), { code: "ESRCH" }, "the gated process group is gone before timeout completion is recorded") + // Procfs-based: the eval sandbox denies group-signal probes + // (killpg EPERM), so an ESRCH assert cannot observe absence there. + const goneNote = "gated group gone before completion is recorded" + assert.ok(processGroupGone(timeoutPgid), goneNote) await assert.rejects(fs.access(timeoutStarted), "the worker wrapper remains gated until a matching acknowledgment is received") const jobWorkspace = path.join(scratch, "job-workspace") diff --git a/src/testing/process-groups.ts b/src/testing/process-groups.ts new file mode 100644 index 0000000..6fb98f8 --- /dev/null +++ b/src/testing/process-groups.ts @@ -0,0 +1,54 @@ +import { readdirSync, readFileSync } from "node:fs" + +// Shared process-group probes for tests. +// +// Background: the RSI eval sandbox denies group signaling (killpg +// persistently returns EPERM even for same-UID callers), while raw +// docker, host, and CI allow it. Tests that assert process-group +// state must therefore verify via /proc on Linux instead of trusting +// kill() errnos. See muse-executor and package-worker-runtime tests, +// which failed baselines on EPERM/ESRCH confusion before this module. + +export function procState(pid: number): { pgrp: number; state: string } | null { + let text: string + try { + text = readFileSync(`/proc/${pid}/stat`, "utf8") + } catch { + return null + } + const afterComm = text.slice(text.lastIndexOf(")") + 2).split(" ") + return { pgrp: Number(afterComm[2]), state: afterComm[0] ?? "" } +} + +// True when at least one member of pgid is still running (not zombie, +// not already reaped). Unreadable /proc conservatively counts as live. +export function groupHasLiveMember(pgid: number): boolean { + let entries: string[] + try { + entries = readdirSync("/proc") + } catch { + return true + } + for (const entry of entries) { + if (!/^[0-9]+$/.test(entry)) continue + const stat = procState(Number(entry)) + if (!stat || stat.pgrp !== pgid) continue + if (stat.state !== "Z" && stat.state !== "X") return true + } + return false +} + +// True when no member of pgid is still running. Uses procfs on Linux +// (immune to group-signal policy); falls back to the classic ESRCH +// probe elsewhere. +export function processGroupGone(pgid: number): boolean { + if (process.platform !== "linux") { + try { + process.kill(-pgid, 0) + return false + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ESRCH" + } + } + return !groupHasLiveMember(pgid) +} diff --git a/src/watcher/__tests__/claims.test.ts b/src/watcher/__tests__/claims.test.ts index b4559ec..44fcf72 100644 --- a/src/watcher/__tests__/claims.test.ts +++ b/src/watcher/__tests__/claims.test.ts @@ -108,6 +108,12 @@ function store( }) } +// ESRCH: no such group. EPERM: sandbox policy blocks group probes +// (an INT_MAX pgid cannot exist; either way it is undisturbable). +function absentGroupError(error: NodeJS.ErrnoException): boolean { + return error.code === "ESRCH" || error.code === "EPERM" +} + async function testClaimLifecycleAndExpiredQuarantine(): Promise { const api = claimApi() let now = new Date("2026-10-11T12:00:00.000Z") @@ -328,7 +334,7 @@ async function testWatcherClaimSurvivesUntilConfirmedCompletion(): Promise const worktree = path.join(repoRoot, ".worktrees", spec.name) await fs.mkdir(path.join(worktree, ".harness.done"), { recursive: true }) const absentPgid = 2_147_483_647 - assert.throws(() => process.kill(-absentPgid, 0), (error: NodeJS.ErrnoException) => error.code === "ESRCH") + assert.throws(() => process.kill(-absentPgid, 0), absentGroupError) await fs.writeFile(path.join(worktree, ".harness.pgid"), `${absentPgid}\n`) await fs.writeFile(path.join(worktree, ".harness.openshell-sandbox"), "stale-sandbox-id\n") }