fix(coding-agent): scoped python kernel cleanup to sessions

scoped retained-kernel ownership to agent sessions and cleaned it up on session disposal

cleaned up warmed python owners on session startup failure and rejected new direct and tool-based python starts during disposal, including async hook, preflight, and warmup races
This commit is contained in:
Carl
2026-04-12 13:29:17 +02:00
parent f96422d911
commit 379064a0c9
18 changed files with 3593 additions and 1156 deletions
+316 -301
View File
@@ -175,6 +175,7 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
if (!this.session) {
throw new ToolError("Python tool requires a session when not using proxy executor");
}
const session = this.session;
const { cells, timeout: rawTimeout = 30, cwd, reset } = params;
// Clamp to reasonable range: 1s - 600s (10 min)
@@ -182,7 +183,10 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
const timeoutMs = timeoutSec * 1000;
const deadlineMs = Date.now() + timeoutMs;
const timeoutSignal = AbortSignal.timeout(Math.max(0, deadlineMs - Date.now()));
const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
const sessionAbortController = new AbortController();
const combinedSignal = signal
? AbortSignal.any([signal, timeoutSignal, sessionAbortController.signal])
: AbortSignal.any([timeoutSignal, sessionAbortController.signal]);
let outputSink: OutputSink | undefined;
let outputSummary: OutputSummary | undefined;
let outputDumped = false;
@@ -193,311 +197,322 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
return outputSummary;
};
try {
if (signal?.aborted) {
throw new ToolAbortError();
}
const commandCwd = cwd ? resolveToCwd(cwd, this.session.cwd) : this.session.cwd;
let cwdStat: fs.Stats;
const execution = (async (): Promise<AgentToolResult<PythonToolDetails | undefined>> => {
try {
cwdStat = await Bun.file(commandCwd).stat();
} catch {
throw new ToolError(`Working directory does not exist: ${commandCwd}`);
}
if (!cwdStat.isDirectory()) {
throw new ToolError(`Working directory is not a directory: ${commandCwd}`);
}
const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES * 2);
const jsonOutputs: unknown[] = [];
const images: ImageContent[] = [];
const statusEvents: PythonStatusEvent[] = [];
const cellResults: PythonCellResult[] = cells.map((cell, index) => ({
index,
title: cell.title,
code: cell.code,
output: "",
status: "pending",
}));
const cellOutputs: string[] = [];
const appendTail = (text: string) => {
tailBuffer.append(text);
};
const buildUpdateDetails = (): PythonToolDetails => {
const details: PythonToolDetails = {
cells: cellResults.map(cell => ({
...cell,
statusEvents: cell.statusEvents ? [...cell.statusEvents] : undefined,
})),
};
if (jsonOutputs.length > 0) {
details.jsonOutputs = jsonOutputs;
if (signal?.aborted) {
throw new ToolAbortError();
}
if (images.length > 0) {
details.images = images;
}
if (statusEvents.length > 0) {
details.statusEvents = statusEvents;
}
return details;
};
session.assertPythonExecutionAllowed?.();
const pushUpdate = () => {
if (!onUpdate) return;
const tailText = tailBuffer.text();
onUpdate({
content: [{ type: "text", text: tailText }],
details: buildUpdateDetails(),
});
};
const sessionFile = this.session.getSessionFile?.() ?? undefined;
const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("python")) ?? {};
outputSink = new OutputSink({
artifactPath,
artifactId,
onChunk: chunk => {
appendTail(chunk);
pushUpdate();
},
});
const sessionId = sessionFile ? `session:${sessionFile}:cwd:${commandCwd}` : `cwd:${commandCwd}`;
if (getPreludeDocs().length === 0) {
const warmup = await warmPythonEnvironment(
commandCwd,
sessionId,
this.session.settings.get("python.sharedGateway"),
sessionFile ?? undefined,
);
if (!warmup.ok) {
throw new ToolError(warmup.reason ?? "Python prelude helpers unavailable");
}
}
const baseExecutorOptions: Omit<PythonExecutorOptions, "reset"> = {
cwd: commandCwd,
deadlineMs,
signal: combinedSignal,
sessionId,
kernelMode: this.session.settings.get("python.kernelMode"),
useSharedGateway: this.session.settings.get("python.sharedGateway"),
sessionFile: sessionFile ?? undefined,
};
for (let i = 0; i < cells.length; i++) {
const cell = cells[i];
const isFirstCell = i === 0;
const cellResult = cellResults[i];
cellResult.status = "running";
cellResult.output = "";
cellResult.statusEvents = undefined;
cellResult.exitCode = undefined;
cellResult.durationMs = undefined;
pushUpdate();
const executorOptions: PythonExecutorOptions = {
...baseExecutorOptions,
reset: isFirstCell ? reset : false,
onChunk: chunk => {
outputSink!.push(chunk);
},
};
const startTime = Date.now();
const result = await executePython(cell.code, executorOptions);
const durationMs = Date.now() - startTime;
const cellStatusEvents: PythonStatusEvent[] = [];
let cellHasMarkdown = false;
for (const output of result.displayOutputs) {
if (output.type === "json") {
jsonOutputs.push(output.data);
}
if (output.type === "image") {
images.push({ type: "image", data: output.data, mimeType: output.mimeType });
}
if (output.type === "status") {
statusEvents.push(output.event);
cellStatusEvents.push(output.event);
}
if (output.type === "markdown") {
cellHasMarkdown = true;
}
}
const cellOutput = result.output.trim();
cellResult.output = cellOutput;
cellResult.exitCode = result.exitCode;
cellResult.durationMs = durationMs;
cellResult.statusEvents = cellStatusEvents.length > 0 ? cellStatusEvents : undefined;
cellResult.hasMarkdown = cellHasMarkdown || undefined;
let combinedCellOutput = "";
if (cells.length > 1) {
const cellHeader = `[${i + 1}/${cells.length}]`;
const cellTitle = cell.title ? ` ${cell.title}` : "";
if (cellOutput) {
combinedCellOutput = `${cellHeader}${cellTitle}\n${cellOutput}`;
} else {
combinedCellOutput = `${cellHeader}${cellTitle} (ok)`;
}
cellOutputs.push(combinedCellOutput);
} else if (cellOutput) {
combinedCellOutput = cellOutput;
cellOutputs.push(combinedCellOutput);
}
if (combinedCellOutput) {
const prefix = cellOutputs.length > 1 ? "\n\n" : "";
appendTail(`${prefix}${combinedCellOutput}`);
}
if (result.cancelled) {
cellResult.status = "error";
pushUpdate();
const errorMsg = result.output || "Command aborted";
const combinedOutput = cellOutputs.join("\n\n");
const outputText =
cells.length > 1
? `${combinedOutput}\n\nCell ${i + 1} aborted: ${errorMsg}`
: combinedOutput || errorMsg;
const rawSummary = (await finalizeOutput()) ?? {
output: "",
truncated: false,
totalLines: 0,
totalBytes: 0,
outputLines: 0,
outputBytes: 0,
};
const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0;
const outputBytes = Buffer.byteLength(combinedOutput, "utf-8");
const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines);
const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes);
const summaryForMeta: OutputSummary = {
output: combinedOutput,
truncated: rawSummary.truncated,
totalLines: outputLines + missingLines,
totalBytes: outputBytes + missingBytes,
outputLines,
outputBytes,
artifactId: rawSummary.artifactId,
};
const details: PythonToolDetails = {
cells: cellResults,
jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
images: images.length > 0 ? images : undefined,
statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
isError: true,
};
return toolResult(details)
.text(outputText)
.truncationFromSummary(summaryForMeta, { direction: "tail" })
.done();
}
if (result.exitCode !== 0 && result.exitCode !== undefined) {
cellResult.status = "error";
pushUpdate();
const combinedOutput = cellOutputs.join("\n\n");
const outputText =
cells.length > 1
? `${combinedOutput}\n\nCell ${i + 1} failed (exit code ${result.exitCode}). Earlier cells succeeded—their state persists. Fix only cell ${i + 1}.`
: combinedOutput
? `${combinedOutput}\n\nCommand exited with code ${result.exitCode}`
: `Command exited with code ${result.exitCode}`;
const rawSummary = (await finalizeOutput()) ?? {
output: "",
truncated: false,
totalLines: 0,
totalBytes: 0,
outputLines: 0,
outputBytes: 0,
};
const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0;
const outputBytes = Buffer.byteLength(combinedOutput, "utf-8");
const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines);
const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes);
const summaryForMeta: OutputSummary = {
output: combinedOutput,
truncated: rawSummary.truncated,
totalLines: outputLines + missingLines,
totalBytes: outputBytes + missingBytes,
outputLines,
outputBytes,
artifactId: rawSummary.artifactId,
};
const details: PythonToolDetails = {
cells: cellResults,
jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
images: images.length > 0 ? images : undefined,
statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
isError: true,
};
return toolResult(details)
.text(outputText)
.truncationFromSummary(summaryForMeta, { direction: "tail" })
.done();
}
cellResult.status = "complete";
pushUpdate();
}
const combinedOutput = cellOutputs.join("\n\n");
const outputText =
combinedOutput || (jsonOutputs.length > 0 || images.length > 0 ? "(no text output)" : "(no output)");
const rawSummary = (await finalizeOutput()) ?? {
output: "",
truncated: false,
totalLines: 0,
totalBytes: 0,
outputLines: 0,
outputBytes: 0,
};
const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0;
const outputBytes = Buffer.byteLength(combinedOutput, "utf-8");
const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines);
const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes);
const summaryForMeta: OutputSummary = {
output: combinedOutput,
truncated: rawSummary.truncated,
totalLines: outputLines + missingLines,
totalBytes: outputBytes + missingBytes,
outputLines,
outputBytes,
artifactId: rawSummary.artifactId,
};
const details: PythonToolDetails = {
cells: cellResults,
jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
images: images.length > 0 ? images : undefined,
statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
};
const resultBuilder = toolResult(details)
.text(outputText)
.truncationFromSummary(summaryForMeta, { direction: "tail" });
return resultBuilder.done();
} finally {
if (!outputDumped) {
const commandCwd = cwd ? resolveToCwd(cwd, session.cwd) : session.cwd;
let cwdStat: fs.Stats;
try {
await finalizeOutput();
} catch {}
cwdStat = await Bun.file(commandCwd).stat();
} catch {
throw new ToolError(`Working directory does not exist: ${commandCwd}`);
}
if (!cwdStat.isDirectory()) {
throw new ToolError(`Working directory is not a directory: ${commandCwd}`);
}
const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES * 2);
const jsonOutputs: unknown[] = [];
const images: ImageContent[] = [];
const statusEvents: PythonStatusEvent[] = [];
const cellResults: PythonCellResult[] = cells.map((cell, index) => ({
index,
title: cell.title,
code: cell.code,
output: "",
status: "pending",
}));
const cellOutputs: string[] = [];
const appendTail = (text: string) => {
tailBuffer.append(text);
};
const buildUpdateDetails = (): PythonToolDetails => {
const details: PythonToolDetails = {
cells: cellResults.map(cell => ({
...cell,
statusEvents: cell.statusEvents ? [...cell.statusEvents] : undefined,
})),
};
if (jsonOutputs.length > 0) {
details.jsonOutputs = jsonOutputs;
}
if (images.length > 0) {
details.images = images;
}
if (statusEvents.length > 0) {
details.statusEvents = statusEvents;
}
return details;
};
const pushUpdate = () => {
if (!onUpdate) return;
const tailText = tailBuffer.text();
onUpdate({
content: [{ type: "text", text: tailText }],
details: buildUpdateDetails(),
});
};
const sessionFile = session.getSessionFile?.() ?? undefined;
const kernelOwnerId = session.getPythonKernelOwnerId?.() ?? undefined;
const { path: artifactPath, id: artifactId } = (await session.allocateOutputArtifact?.("python")) ?? {};
session.assertPythonExecutionAllowed?.();
outputSink = new OutputSink({
artifactPath,
artifactId,
onChunk: chunk => {
appendTail(chunk);
pushUpdate();
},
});
const sessionId = sessionFile ? `session:${sessionFile}:cwd:${commandCwd}` : `cwd:${commandCwd}`;
if (getPreludeDocs().length === 0) {
const warmup = await warmPythonEnvironment(
commandCwd,
sessionId,
session.settings.get("python.sharedGateway"),
sessionFile ?? undefined,
kernelOwnerId,
combinedSignal,
);
if (!warmup.ok) {
if (combinedSignal.aborted) throw new ToolAbortError();
throw new ToolError(warmup.reason ?? "Python prelude helpers unavailable");
}
session.assertPythonExecutionAllowed?.();
}
const baseExecutorOptions = {
cwd: commandCwd,
deadlineMs,
signal: combinedSignal,
sessionId,
kernelMode: session.settings.get("python.kernelMode"),
useSharedGateway: session.settings.get("python.sharedGateway"),
sessionFile: sessionFile ?? undefined,
kernelOwnerId,
};
for (let i = 0; i < cells.length; i++) {
const cell = cells[i];
const isFirstCell = i === 0;
const cellResult = cellResults[i];
cellResult.status = "running";
cellResult.output = "";
cellResult.statusEvents = undefined;
cellResult.exitCode = undefined;
cellResult.durationMs = undefined;
pushUpdate();
const executorOptions: PythonExecutorOptions = {
...baseExecutorOptions,
reset: isFirstCell ? reset : false,
onChunk: chunk => {
outputSink!.push(chunk);
},
};
const startTime = Date.now();
const result = await executePython(cell.code, executorOptions);
const durationMs = Date.now() - startTime;
const cellStatusEvents: PythonStatusEvent[] = [];
let cellHasMarkdown = false;
for (const output of result.displayOutputs) {
if (output.type === "json") {
jsonOutputs.push(output.data);
}
if (output.type === "image") {
images.push({ type: "image", data: output.data, mimeType: output.mimeType });
}
if (output.type === "status") {
statusEvents.push(output.event);
cellStatusEvents.push(output.event);
}
if (output.type === "markdown") {
cellHasMarkdown = true;
}
}
const cellOutput = result.output.trim();
cellResult.output = cellOutput;
cellResult.exitCode = result.exitCode;
cellResult.durationMs = durationMs;
cellResult.statusEvents = cellStatusEvents.length > 0 ? cellStatusEvents : undefined;
cellResult.hasMarkdown = cellHasMarkdown || undefined;
let combinedCellOutput = "";
if (cells.length > 1) {
const cellHeader = `[${i + 1}/${cells.length}]`;
const cellTitle = cell.title ? ` ${cell.title}` : "";
if (cellOutput) {
combinedCellOutput = `${cellHeader}${cellTitle}\n${cellOutput}`;
} else {
combinedCellOutput = `${cellHeader}${cellTitle} (ok)`;
}
cellOutputs.push(combinedCellOutput);
} else if (cellOutput) {
combinedCellOutput = cellOutput;
cellOutputs.push(combinedCellOutput);
}
if (combinedCellOutput) {
const prefix = cellOutputs.length > 1 ? "\n\n" : "";
appendTail(`${prefix}${combinedCellOutput}`);
}
if (result.cancelled) {
cellResult.status = "error";
pushUpdate();
const errorMsg = result.output || "Command aborted";
const combinedOutput = cellOutputs.join("\n\n");
const outputText =
cells.length > 1
? `${combinedOutput}\n\nCell ${i + 1} aborted: ${errorMsg}`
: combinedOutput || errorMsg;
const rawSummary = (await finalizeOutput()) ?? {
output: "",
truncated: false,
totalLines: 0,
totalBytes: 0,
outputLines: 0,
outputBytes: 0,
};
const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0;
const outputBytes = Buffer.byteLength(combinedOutput, "utf-8");
const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines);
const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes);
const summaryForMeta: OutputSummary = {
output: combinedOutput,
truncated: rawSummary.truncated,
totalLines: outputLines + missingLines,
totalBytes: outputBytes + missingBytes,
outputLines,
outputBytes,
artifactId: rawSummary.artifactId,
};
const details: PythonToolDetails = {
cells: cellResults,
jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
images: images.length > 0 ? images : undefined,
statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
isError: true,
};
return toolResult(details)
.text(outputText)
.truncationFromSummary(summaryForMeta, { direction: "tail" })
.done();
}
if (result.exitCode !== 0 && result.exitCode !== undefined) {
cellResult.status = "error";
pushUpdate();
const combinedOutput = cellOutputs.join("\n\n");
const outputText =
cells.length > 1
? `${combinedOutput}\n\nCell ${i + 1} failed (exit code ${result.exitCode}). Earlier cells succeeded—their state persists. Fix only cell ${i + 1}.`
: combinedOutput
? `${combinedOutput}\n\nCommand exited with code ${result.exitCode}`
: `Command exited with code ${result.exitCode}`;
const rawSummary = (await finalizeOutput()) ?? {
output: "",
truncated: false,
totalLines: 0,
totalBytes: 0,
outputLines: 0,
outputBytes: 0,
};
const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0;
const outputBytes = Buffer.byteLength(combinedOutput, "utf-8");
const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines);
const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes);
const summaryForMeta: OutputSummary = {
output: combinedOutput,
truncated: rawSummary.truncated,
totalLines: outputLines + missingLines,
totalBytes: outputBytes + missingBytes,
outputLines,
outputBytes,
artifactId: rawSummary.artifactId,
};
const details: PythonToolDetails = {
cells: cellResults,
jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
images: images.length > 0 ? images : undefined,
statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
isError: true,
};
return toolResult(details)
.text(outputText)
.truncationFromSummary(summaryForMeta, { direction: "tail" })
.done();
}
cellResult.status = "complete";
pushUpdate();
}
const combinedOutput = cellOutputs.join("\n\n");
const outputText =
combinedOutput || (jsonOutputs.length > 0 || images.length > 0 ? "(no text output)" : "(no output)");
const rawSummary = (await finalizeOutput()) ?? {
output: "",
truncated: false,
totalLines: 0,
totalBytes: 0,
outputLines: 0,
outputBytes: 0,
};
const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0;
const outputBytes = Buffer.byteLength(combinedOutput, "utf-8");
const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines);
const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes);
const summaryForMeta: OutputSummary = {
output: combinedOutput,
truncated: rawSummary.truncated,
totalLines: outputLines + missingLines,
totalBytes: outputBytes + missingBytes,
outputLines,
outputBytes,
artifactId: rawSummary.artifactId,
};
const details: PythonToolDetails = {
cells: cellResults,
jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
images: images.length > 0 ? images : undefined,
statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
};
const resultBuilder = toolResult(details)
.text(outputText)
.truncationFromSummary(summaryForMeta, { direction: "tail" });
return resultBuilder.done();
} finally {
if (!outputDumped) {
try {
await finalizeOutput();
} catch {}
}
}
}
})();
return await (session.trackPythonExecution?.(execution, sessionAbortController) ?? execution);
}
}