fix(evals): finish process group cancellation after CLI exit

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-24 10:23:49 -07:00
1 parent 5201836602
commit 4781487215
3 files changed
+40 -2

No files matched your search

@@ -510,3 +510,7 @@ Campaign reports verify the retained request binding and the digest of every
request receipt before reducing a cell's allocation. Missing admission, request,
or receipt evidence preserves known ledger charges and holds the rest of the
full allocation.
On cancellation, the controller keeps the scheduled process-group SIGKILL until
any remaining owned descendants receive it, even if the CLI exits first. A
forced stop still leaves cleanup unverified and retains the full allocation.
@@ -11,9 +11,17 @@ import { summarizeControlledCampaign } from './report.mjs';
const childScript = options => `
import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';
import {spawn} from 'node:child_process';
import {once} from 'node:events';
import {readFile,writeFile} from 'node:fs/promises';
const args=new Map();for(let i=2;i<process.argv.length;i+=2)args.set(process.argv[i],process.argv[i+1]);
const request=JSON.parse(await readFile(args.get('--request'),'utf8'));
if(${Boolean(options.resistantDescendant)}) {
const descendant=spawn(process.execPath,['-e',"process.on('SIGTERM',()=>{});process.stdout.write('ready');setInterval(()=>{},1000)"],{stdio:['ignore','pipe','ignore']});
await once(descendant.stdout,'data');
await writeFile(request.session.workingDirectory+'/descendant.pid',String(descendant.pid));
}
const descriptorRaw=await readFile(args.get('--gateway-config'),'utf8'),descriptor=JSON.parse(descriptorRaw);
for(const key of ['OPENAI_API_KEY','ANTHROPIC_API_KEY','BRAINTRUST_API_KEY','GH_TOKEN','NODE_OPTIONS','PYTHONPATH'])assert.equal(process.env[key],undefined);
const statuses=[];
@@ -250,3 +258,22 @@ test('staged CLI relative imports survive a symlinked evidence directory', async
assert.equal(result.code, 0);
assert.equal((await summarizeControlledCampaign(f.manifest)).incompleteAttempts, 0);
});
test('cancellation still kills an owned resistant descendant after the CLI exits', async t => {
const f = await fixture(t, { requests: 1, waitForever: true, resistantDescendant: true });
f.manifest.attempts[0].timeoutMs = 15000; await f.save();
const abort = new AbortController(); let ready;
const started = new Promise(resolve => { ready = resolve; });
const running = f.run(0, async () => { ready(); return success(); }, abort.signal);
await started;
const pid = Number(await readFile(join(f.requests[0].request.session.workingDirectory, 'descendant.pid'), 'utf8'));
t.after(() => { try { process.kill(pid, 'SIGKILL'); } catch {} });
abort.abort();
const result = await running;
assert.equal(result.report.child.stopped, 'controller_cancelled');
const absent = () => { try { process.kill(pid, 0); return false; } catch (error) { return error.code === 'ESRCH'; } };
for (let retry = 0; retry < 100 && !absent(); retry++) await new Promise(resolve => setTimeout(resolve, 10));
assert.equal(absent(), true);
assert.equal(result.report.cleanupVerified, false);
assert.equal((await summarizeControlledCampaign(f.manifest)).committedUsd, '0.014000000');
});
@@ -20,14 +20,16 @@ async function runChild(input) {
const child = spawn(process.execPath, ['--import', input.loader, input.cli, '--request', input.requestPath, '--output', input.artifactPath, '--gateway-config', input.descriptorPath], {
cwd: input.cwd, env: input.environment, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
});
let stopped = null, force;
let stopped = null, force, escalation;
const output = { stdout: [], stderr: [] }, sizes = { stdout: 0, stderr: 0 };
const stop = reason => {
if (stopped) return;
stopped = reason;
void input.closeGateway().catch(() => {});
try { process.kill(-child.pid, 'SIGTERM'); } catch {}
force = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 5000);
escalation = new Promise(resolve => {
force = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} finally { resolve(); } }, 5000);
});
};
for (const stream of ['stdout', 'stderr']) child[stream].on('data', bytes => {
const available = Math.max(0, 1_048_576 - sizes[stream]);
@@ -40,6 +42,11 @@ async function runChild(input) {
if (input.signal?.aborted) onAbort();
try {
const [code, signal] = await once(child, 'exit');
if (stopped) {
let groupPresent = true;
try { process.kill(-child.pid, 0); } catch (error) { if (error.code === 'ESRCH') groupPresent = false; }
if (groupPresent) await escalation;
}
return { code, signal, pid: child.pid, stopped, stdout: Buffer.concat(output.stdout).toString(), stderr: Buffer.concat(output.stderr).toString() };
} finally {
clearTimeout(timeout); clearTimeout(force); input.signal?.removeEventListener('abort', onAbort);