SPB Git

spb/autoscholar

Public

Runner: deny background tool tasks (nohup + log polling instead); kill leftover workspace processes after each session

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Oct 30, 2026) parent 2f4359c Browse files

3 changed files +58 −0

modified services/research-runner/src/policy.test.ts +4 −0
@@ -58,6 +58,10 @@ describe("platform policy hook", () => {
58 expect(isDeny(await call("Bash", { command })), command).toBe(true); 58 expect(isDeny(await call("Bash", { command })), command).toBe(true);
59 } 59 }
60 expect(isDeny(await call("Bash", { command: "ls", dangerouslyDisableSandbox: true }))).toBe(true); 60 expect(isDeny(await call("Bash", { command: "ls", dangerouslyDisableSandbox: true }))).toBe(true);
61+ expect(isDeny(await call("Bash", { command: "python3 solve.py", run_in_background: true }))).toBe(true);
62+ expect(isDeny(await call("Bash", { command: "nohup python3 solve.py > results/solve.log 2>&1 &" }))).toBe(
63+ false,
64+ );
61 }); 65 });
62 it("allows ordinary research commands", async () => { 66 it("allows ordinary research commands", async () => {
63 for (const command of [ 67 for (const command of [
modified services/research-runner/src/policy.ts +6 −0
@@ -191,6 +191,12 @@ export function createPolicyHook(opts: PolicyOptions): HookCallback {
191 if (tool === "Bash" || tool === "PowerShell") { 191 if (tool === "Bash" || tool === "PowerShell") {
192 const cmd = String(ti.command ?? ""); 192 const cmd = String(ti.command ?? "");
193 if (ti.dangerouslyDisableSandbox === true) return deny(input, "sandbox bypass requested", ti); 193 if (ti.dangerouslyDisableSandbox === true) return deny(input, "sandbox bypass requested", ti);
194+ if (ti.run_in_background === true)
195+ return deny(
196+ input,
197+ "background tool tasks keep the stage open indefinitely; run long jobs with `nohup <cmd> > results/<name>.log 2>&1 &` (bounded with a timeout) and poll the log file instead",
198+ { command: cmd.slice(0, 200) },
199+ );
194 for (const p of DENIED_COMMAND_PATTERNS) 200 for (const p of DENIED_COMMAND_PATTERNS)
195 if (p.re.test(cmd)) return deny(input, p.reason, { command: cmd.slice(0, 300) }); 201 if (p.re.test(cmd)) return deny(input, p.reason, { command: cmd.slice(0, 300) });
196 if (cmd.length > 20_000) return deny(input, "command too long", {}); 202 if (cmd.length > 20_000) return deny(input, "command too long", {});
modified services/research-runner/src/runner.ts +48 −0
@@ -394,6 +394,7 @@ export class ResearchRunner {
394 clearInterval(cancelPoll); 394 clearInterval(cancelPoll);
395 } 395 }
396 result.denied = denied.n; 396 result.denied = denied.n;
397+ await this.killLeftoverProcesses(spec.key);
397 this.spent += result.costUsd; 398 this.spent += result.costUsd;
398 this.turns += result.turns; 399 this.turns += result.turns;
399 this.emit( 400 this.emit(
@@ -406,6 +407,53 @@ export class ResearchRunner {
406 return result; 407 return result;
407 } 408 }
408 409
410+ /** Kills processes (owned by this user) whose command line references this workspace — leftover solvers, servers, watchers. */
411+ private async killLeftoverProcesses(stage: StageKey): Promise<void> {
412+ try {
413+ const out = await new Promise<string>((resolve) => {
414+ const ps = spawn("/bin/ps", ["-axo", "pid=,command="], { stdio: ["ignore", "pipe", "ignore"] });
415+ let buf = "";
416+ ps.stdout.on("data", (d) => {
417+ buf += d.toString();
418+ });
419+ ps.on("close", () => resolve(buf));
420+ ps.on("error", () => resolve(""));
421+ });
422+ const mine = out
423+ .split("\n")
424+ .map((l) => l.trim())
425+ .filter((l) => l.includes(this.ws) && !/claude-agent-sdk|dist\/main\.js/.test(l))
426+ .map((l) => Number(l.split(/\s+/)[0]))
427+ .filter((pid) => Number.isInteger(pid) && pid !== process.pid);
428+ for (const pid of mine) {
429+ try {
430+ process.kill(pid, "SIGTERM");
431+ } catch {
432+ /* gone */
433+ }
434+ }
435+ if (mine.length) {
436+ await new Promise((r) => setTimeout(r, 3000));
437+ for (const pid of mine) {
438+ try {
439+ process.kill(pid, "SIGKILL");
440+ } catch {
441+ /* gone */
442+ }
443+ }
444+ this.emit(
445+ "process.cleanup",
446+ `terminated ${mine.length} leftover process(es) after the ${stage} session`,
447+ { pids: mine.slice(0, 20) },
448+ "warn",
449+ stage,
450+ );
451+ }
452+ } catch {
453+ /* best effort */
454+ }
455+ }
456+
409 private async stageContext(extra: Partial<StageContext> = {}): Promise<StageContext> { 457 private async stageContext(extra: Partial<StageContext> = {}): Promise<StageContext> {
410 return { 458 return {
411 filesPresent: await listFiles(this.ws), 459 filesPresent: await listFiles(this.ws),
412 460
Menu

Theme