mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 14:39:02 +00:00
fix: codex and similar processes keep dying on pty, solved by refactoring process spawning (#14257)
* exec: clean up PTY resources on timeout and exit * cli: harden resume cleanup and watchdog stalled runs * cli: productionize PTY and resume reliability paths * docs: add PTY process supervision architecture plan * docs: rewrite PTY supervision plan as pre-rewrite baseline * docs: switch PTY supervision plan to one-go execution * docs: add one-line root cause to PTY supervision plan * docs: add OS contracts and test matrix to PTY supervision plan * docs: define process-supervisor package placement and scope * docs: tie supervisor plan to existing CI lanes * docs: place PTY supervisor plan under src/process * refactor(process): route exec and cli runs through supervisor * docs(process): refresh PTY supervision plan * wip * fix(process): harden supervisor timeout and PTY termination * fix(process): harden supervisor adapters env and wait handling * ci: avoid failing formal conformance on comment permissions * test(ui): fix cron request mock argument typing * fix(ui): remove leftover conflict marker * fix: supervise PTY processes (#14257) (openclaw#14257) (thanks @onutc)
This commit is contained in:
152
src/agents/bash-tools.process.supervisor.test.ts
Normal file
152
src/agents/bash-tools.process.supervisor.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProcessSession } from "./bash-process-registry.js";
|
||||
import {
|
||||
addSession,
|
||||
getFinishedSession,
|
||||
getSession,
|
||||
resetProcessRegistryForTests,
|
||||
} from "./bash-process-registry.js";
|
||||
import { createProcessTool } from "./bash-tools.process.js";
|
||||
|
||||
const { supervisorMock } = vi.hoisted(() => ({
|
||||
supervisorMock: {
|
||||
spawn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
cancelScope: vi.fn(),
|
||||
reconcileOrphans: vi.fn(),
|
||||
getRecord: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { killProcessTreeMock } = vi.hoisted(() => ({
|
||||
killProcessTreeMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../process/supervisor/index.js", () => ({
|
||||
getProcessSupervisor: () => supervisorMock,
|
||||
}));
|
||||
|
||||
vi.mock("../process/kill-tree.js", () => ({
|
||||
killProcessTree: (...args: unknown[]) => killProcessTreeMock(...args),
|
||||
}));
|
||||
|
||||
function createBackgroundSession(id: string, pid?: number): ProcessSession {
|
||||
return {
|
||||
id,
|
||||
command: "sleep 999",
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
maxOutputChars: 10_000,
|
||||
pendingMaxOutputChars: 30_000,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
pid,
|
||||
exited: false,
|
||||
exitCode: undefined,
|
||||
exitSignal: undefined,
|
||||
truncated: false,
|
||||
backgrounded: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("process tool supervisor cancellation", () => {
|
||||
beforeEach(() => {
|
||||
supervisorMock.spawn.mockReset();
|
||||
supervisorMock.cancel.mockReset();
|
||||
supervisorMock.cancelScope.mockReset();
|
||||
supervisorMock.reconcileOrphans.mockReset();
|
||||
supervisorMock.getRecord.mockReset();
|
||||
killProcessTreeMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetProcessRegistryForTests();
|
||||
});
|
||||
|
||||
it("routes kill through supervisor when run is managed", async () => {
|
||||
supervisorMock.getRecord.mockReturnValue({
|
||||
runId: "sess",
|
||||
state: "running",
|
||||
});
|
||||
addSession(createBackgroundSession("sess"));
|
||||
const processTool = createProcessTool();
|
||||
|
||||
const result = await processTool.execute("toolcall", {
|
||||
action: "kill",
|
||||
sessionId: "sess",
|
||||
});
|
||||
|
||||
expect(supervisorMock.cancel).toHaveBeenCalledWith("sess", "manual-cancel");
|
||||
expect(getSession("sess")).toBeDefined();
|
||||
expect(getSession("sess")?.exited).toBe(false);
|
||||
expect(result.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "Termination requested for session sess.",
|
||||
});
|
||||
});
|
||||
|
||||
it("remove drops running session immediately when cancellation is requested", async () => {
|
||||
supervisorMock.getRecord.mockReturnValue({
|
||||
runId: "sess",
|
||||
state: "running",
|
||||
});
|
||||
addSession(createBackgroundSession("sess"));
|
||||
const processTool = createProcessTool();
|
||||
|
||||
const result = await processTool.execute("toolcall", {
|
||||
action: "remove",
|
||||
sessionId: "sess",
|
||||
});
|
||||
|
||||
expect(supervisorMock.cancel).toHaveBeenCalledWith("sess", "manual-cancel");
|
||||
expect(getSession("sess")).toBeUndefined();
|
||||
expect(getFinishedSession("sess")).toBeUndefined();
|
||||
expect(result.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "Removed session sess (termination requested).",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to process-tree kill when supervisor record is missing", async () => {
|
||||
supervisorMock.getRecord.mockReturnValue(undefined);
|
||||
addSession(createBackgroundSession("sess-fallback", 4242));
|
||||
const processTool = createProcessTool();
|
||||
|
||||
const result = await processTool.execute("toolcall", {
|
||||
action: "kill",
|
||||
sessionId: "sess-fallback",
|
||||
});
|
||||
|
||||
expect(killProcessTreeMock).toHaveBeenCalledWith(4242);
|
||||
expect(getSession("sess-fallback")).toBeUndefined();
|
||||
expect(getFinishedSession("sess-fallback")).toBeDefined();
|
||||
expect(result.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "Killed session sess-fallback.",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails remove when no supervisor record and no pid is available", async () => {
|
||||
supervisorMock.getRecord.mockReturnValue(undefined);
|
||||
addSession(createBackgroundSession("sess-no-pid"));
|
||||
const processTool = createProcessTool();
|
||||
|
||||
const result = await processTool.execute("toolcall", {
|
||||
action: "remove",
|
||||
sessionId: "sess-no-pid",
|
||||
});
|
||||
|
||||
expect(killProcessTreeMock).not.toHaveBeenCalled();
|
||||
expect(getSession("sess-no-pid")).toBeDefined();
|
||||
expect(result.details).toMatchObject({ status: "failed" });
|
||||
expect(result.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "Unable to remove session sess-no-pid: no active supervisor run or process id.",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user