Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 10 additions & 33 deletions src/orchestrator/__tests__/muse-executor.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"

Expand Down Expand Up @@ -337,45 +341,18 @@ async function testInlineMusePromptUsesIgnoredScratchAndCleansUp(): Promise<void
}
}

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] ?? "" }
}

// State-based stop check: kill() errnos lie under sandbox init
// semantics (EPERM for a group of unreaped zombies; kill(0) success
// for a zombie that exists but runs nothing). The group is settled
// when the recorded pid is gone or no member is still running.
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
}

// State-based stop check: kill() errnos lie under sandbox group-signal
// policy (persistent EPERM) and init semantics (kill(0) success for a
// zombie that exists but runs nothing). The group is settled when the
// recorded pid is gone or no member is still running.
function workerGroupSettled(pgid: number, pid: number): boolean {
try {
process.kill(pid, 0)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ESRCH") return true
}
if (process.platform !== "linux") return false
return !groupHasLiveMember(pgid)
return processGroupGone(pgid)
}

// True when the stop was delivered or already settled; false when the
Expand Down
6 changes: 5 additions & 1 deletion src/orchestrator/__tests__/package-worker-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as path from "node:path"
import { execFileSync, spawn, spawnSync } from "node:child_process"
import { fileURLToPath } from "node:url"
import { setTimeout as delay } from "node:timers/promises"
import { processGroupGone } from "../../testing/process-groups.js"

const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..")
const scratchRoot = path.join(packageRoot, ".headlesscode", "scratch")
Expand Down Expand Up @@ -149,7 +150,10 @@ try {
assert.equal(timeoutCompletion.exitCode, 125, "acknowledgment timeout is recorded as a startup failure after teardown")
const timeoutPgid = Number(timeoutCompletion.pgid)
assert.ok(Number.isSafeInteger(timeoutPgid) && timeoutPgid > 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")
Expand Down
54 changes: 54 additions & 0 deletions src/testing/process-groups.ts
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 7 additions & 1 deletion src/watcher/__tests__/claims.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const api = claimApi()
let now = new Date("2026-10-11T12:00:00.000Z")
Expand Down Expand Up @@ -328,7 +334,7 @@ async function testWatcherClaimSurvivesUntilConfirmedCompletion(): Promise<void>
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")
}
Expand Down
Loading