Files
oh-my-pi/packages/coding-agent/src/session/eval-runner.ts
T
can1357 ccc9b900cf fix(eval): forked subagent kernel resets away from shared sessions
- Subagents inherit the parent's eval session id, so a child's
  reset: true destroyed the co-owned kernel and every sibling's
  interpreter state mid-session.
- resolveOwnerScopedSessionKey now routes a reset from a non-exclusive
  owner onto a deterministic per-owner fork key: the requester gets a
  fresh private kernel, co-owners keep the shared one, and the fork
  stays sticky for that owner until its teardown reaps it.
- Applied across Python, JavaScript, Ruby, and Julia executors; JS
  contexts gained an owner registry plus disposeVmContextsByOwner,
  wired into EvalRunner.disposeKernels and SDK session teardown.
- Covered by pure key-resolution contracts and an end-to-end JS test:
  co-owner reset forks, shared state survives, fork is sticky, and
  per-owner dispose reaps only the fork.
2026-07-31 20:13:23 +02:00

220 lines
7.8 KiB
TypeScript

import type { Agent } from "@oh-my-pi/pi-agent-core";
import { logger } from "@oh-my-pi/pi-utils";
import type { Settings } from "../config/settings";
import { disposeJuliaKernelSessionsByOwner } from "../eval/jl/executor";
import { disposeVmContextsByOwner } from "../eval/js/context-manager";
import { namespaceSessionId as namespacePythonSessionId } from "../eval/py";
import {
disposeKernelSessionsByOwner,
executePython as executePythonCommand,
type PythonResult,
} from "../eval/py/executor";
import { disposeRubyKernelSessionsByOwner } from "../eval/rb/executor";
import { defaultEvalSessionId } from "../eval/session-id";
import type { ExtensionRunner } from "../extensibility/extensions";
import { outputMeta } from "../tools/output-meta";
import type { PythonExecutionMessage } from "./messages";
import type { SessionManager } from "./session-manager";
/** Capabilities the eval runner borrows from its owning session. */
export interface EvalRunnerHost {
agent: Agent;
sessionManager: SessionManager;
settings: Settings;
extensionRunner(): ExtensionRunner | undefined;
isStreaming(): boolean;
appendSessionMessage(message: PythonExecutionMessage): void;
}
/** Owns user-initiated Python execution and retained eval-kernel lifecycle. */
export class EvalRunner {
readonly #host: EvalRunnerHost;
readonly #kernelOwnerId: string;
readonly #parentSessionId: string | undefined;
#abortControllers = new Set<AbortController>();
#pendingMessages: PythonExecutionMessage[] = [];
#activeExecutions = new Set<Promise<unknown>>();
#disposing = false;
constructor(host: EvalRunnerHost, options: { kernelOwnerId: string; parentSessionId: string | undefined }) {
this.#host = host;
this.#kernelOwnerId = options.kernelOwnerId;
this.#parentSessionId = options.parentSessionId;
}
/** Executes Python in the session's shared kernel. */
async executePython(
code: string,
onChunk?: (chunk: string) => void,
options?: { excludeFromContext?: boolean },
): Promise<PythonResult> {
const excludeFromContext = options?.excludeFromContext === true;
const cwd = this.#host.sessionManager.getCwd();
this.assertExecutionAllowed();
const abortController = new AbortController();
const execution = (async (): Promise<PythonResult> => {
const extensionRunner = this.#host.extensionRunner();
if (extensionRunner?.hasHandlers("user_python")) {
const hookResult = await extensionRunner.emitUserPython({
type: "user_python",
code,
excludeFromContext,
cwd,
});
this.assertExecutionAllowed();
if (hookResult?.result) {
this.recordPythonResult(code, hookResult.result, options);
return hookResult.result;
}
}
const sessionId =
this.getSessionId() ??
defaultEvalSessionId({
cwd,
getSessionFile: () => this.#host.sessionManager.getSessionFile() ?? null,
});
const result = await executePythonCommand(code, {
cwd,
sessionId: namespacePythonSessionId(sessionId),
kernelOwnerId: this.#kernelOwnerId,
kernelMode: this.#host.settings.get("python.kernelMode"),
interpreter: this.#host.settings.get("python.interpreter")?.trim() || undefined,
onChunk,
signal: abortController.signal,
});
this.recordPythonResult(code, result, options);
return result;
})();
return await this.trackExecution(execution, abortController);
}
/** Rejects new eval work once session disposal begins. */
assertExecutionAllowed(): void {
if (this.#disposing) throw new Error("Python execution is unavailable while session disposal is in progress");
}
/** Tracks externally started Python work so disposal can await and abort it. */
trackExecution<T>(execution: Promise<T>, abortController: AbortController): Promise<T> {
this.#abortControllers.add(abortController);
this.#activeExecutions.add(execution);
void execution.then(
() => {
this.#abortControllers.delete(abortController);
this.#activeExecutions.delete(execution);
},
() => {
this.#abortControllers.delete(abortController);
this.#activeExecutions.delete(execution);
},
);
return execution;
}
/** Records a Python execution result in session history. */
recordPythonResult(code: string, result: PythonResult, options?: { excludeFromContext?: boolean }): void {
const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
const message: PythonExecutionMessage = {
role: "pythonExecution",
code,
output: result.output,
exitCode: result.exitCode,
cancelled: result.cancelled,
truncated: result.truncated,
meta,
timestamp: Date.now(),
excludeFromContext: options?.excludeFromContext,
};
if (this.#host.isStreaming()) {
this.#pendingMessages.push(message);
} else {
this.#host.appendSessionMessage(message);
}
}
/** Cancels every running Python execution. */
abort(): void {
for (const abortController of this.#abortControllers) abortController.abort();
}
/** Whether a Python execution is currently running. */
get isRunning(): boolean {
return this.#abortControllers.size > 0;
}
/** Whether Python results are waiting for a safe persistence boundary. */
get hasPendingMessages(): boolean {
return this.#pendingMessages.length > 0;
}
/** Returns the stable owner shared by eval and session-owned tools. */
getKernelOwnerId(): string {
return this.#kernelOwnerId;
}
/** Returns the eval session shared with the Python backend. */
getSessionId(): string | null {
if (this.#parentSessionId !== undefined) return this.#parentSessionId;
return defaultEvalSessionId({
cwd: this.#host.sessionManager.getCwd(),
getSessionFile: () => this.#host.sessionManager.getSessionFile() ?? null,
});
}
/** Flushes deferred Python results into agent state and persistence. */
flushPending(): void {
if (this.#pendingMessages.length === 0) return;
for (const message of this.#pendingMessages) this.#host.appendSessionMessage(message);
this.#pendingMessages = [];
}
/** Prevents new Python executions before asynchronous disposal starts. */
beginDispose(): void {
this.#disposing = true;
}
/** Waits for active work and disposes every retained eval kernel owned by the session. */
async disposeKernels(): Promise<void> {
const settled = await this.#prepareExecutionsForDispose();
if (!settled) {
logger.warn("Detaching retained eval-kernel ownership during dispose while eval execution is still active");
}
const results = await Promise.allSettled([
disposeKernelSessionsByOwner(this.#kernelOwnerId),
disposeRubyKernelSessionsByOwner(this.#kernelOwnerId),
disposeJuliaKernelSessionsByOwner(this.#kernelOwnerId),
disposeVmContextsByOwner(this.#kernelOwnerId),
]);
const errors: unknown[] = [];
for (const result of results) if (result.status === "rejected") errors.push(result.reason);
if (errors.length > 0) throw new AggregateError(errors, "Failed to dispose one or more eval kernels");
}
async #waitForExecutionsToSettle(timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (this.#activeExecutions.size > 0) {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) return false;
const settled = await Promise.race([
Promise.allSettled(Array.from(this.#activeExecutions)).then(() => true),
Bun.sleep(remainingMs).then(() => false),
]);
if (!settled && this.#activeExecutions.size > 0) return false;
}
return true;
}
async #prepareExecutionsForDispose(): Promise<boolean> {
if (!(await this.#waitForExecutionsToSettle(3_000))) {
logger.warn("Aborting active Python execution during dispose before retained kernel cleanup");
this.abort();
if (!(await this.#waitForExecutionsToSettle(1_000))) {
logger.warn(
"Python execution is still active after dispose aborted all active runs; retained kernel ownership will still be detached",
);
return false;
}
}
return true;
}
}