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.
This commit is contained in:
can1357
2026-07-31 20:13:23 +02:00
parent dea8cf04a7
commit ccc9b900cf
12 changed files with 429 additions and 44 deletions
+55 -15
View File
@@ -13,6 +13,8 @@ import {
getRemainingTimeoutMs,
isCancellationError,
isTimedOutCancellation,
resolveOwnerScopedSessionKey,
type SessionOwners,
waitForPromiseWithCancellation,
} from "../executor-base";
import type { JsStatusEvent } from "../js/shared/types";
@@ -154,8 +156,12 @@ interface PythonSession {
hasFallbackOwner: boolean;
}
interface StartingPythonSession extends SessionOwners {
promise: Promise<PythonSession>;
}
const sessions = new Map<string, PythonSession>();
const startingSessions = new Map<string, Promise<PythonSession>>();
const startingSessions = new Map<string, StartingPythonSession>();
const resettingSessions = new Map<string, Promise<void>>();
function normalizeSessionCwd(cwd: string): string {
@@ -252,10 +258,10 @@ async function acquireSession(
}
const starting = startingSessions.get(sessionKey);
if (starting) {
const session = await starting;
attachSessionOwner(session, sessionId, options.kernelOwnerId);
return session;
attachSessionOwner(starting, sessionId, options.kernelOwnerId);
return await starting.promise;
}
let startingSession!: StartingPythonSession;
const startup = (async () => {
const kernel = await startKernel(cwd, options);
const session: PythonSession = {
@@ -264,19 +270,28 @@ async function acquireSession(
cwd,
kernel,
generation: 0,
ownerIds: new Set(),
hasFallbackOwner: false,
ownerIds: new Set(startingSession.ownerIds),
hasFallbackOwner: startingSession.hasFallbackOwner,
};
sessions.set(sessionKey, session);
// Publish only while this startup still owns the key: owner disposal or
// a concurrent dispose-all may have already reaped the starting record,
// and publishing here would resurrect a kernel that was just torn down.
if (startingSessions.get(sessionKey) === startingSession) {
sessions.set(sessionKey, session);
}
return session;
})();
startingSessions.set(sessionKey, startup);
startingSession = {
ownerIds: new Set(),
hasFallbackOwner: false,
promise: startup,
};
attachSessionOwner(startingSession, sessionId, options.kernelOwnerId);
startingSessions.set(sessionKey, startingSession);
try {
const session = await startup;
attachSessionOwner(session, sessionId, options.kernelOwnerId);
return session;
return await startup;
} finally {
if (startingSessions.get(sessionKey) === startup) startingSessions.delete(sessionKey);
if (startingSessions.get(sessionKey) === startingSession) startingSessions.delete(sessionKey);
}
}
@@ -370,7 +385,8 @@ async function acquireLiveSessionKernel(
}
async function resetSession(sessionKey: string): Promise<void> {
const existing = sessions.get(sessionKey) ?? (await startingSessions.get(sessionKey)?.catch(() => undefined));
const existing =
sessions.get(sessionKey) ?? (await startingSessions.get(sessionKey)?.promise.catch(() => undefined));
if (!existing) return;
existing.generation += 1;
sessions.delete(sessionKey);
@@ -382,7 +398,7 @@ async function resetSession(sessionKey: string): Promise<void> {
// ---------------------------------------------------------------------------
export async function disposeAllKernelSessions(): Promise<void> {
const pending = [...startingSessions.values()];
const pending = [...startingSessions.values()].map(starting => starting.promise);
startingSessions.clear();
const started = await Promise.allSettled(pending);
const all = [...sessions.entries()];
@@ -422,10 +438,28 @@ export async function disposeKernelSessionsByOwner(ownerId: string): Promise<voi
}
session.ownerIds.delete(ownerId);
}
const startingToShutdown: StartingPythonSession[] = [];
for (const [sessionKey, starting] of [...startingSessions.entries()]) {
if (sessions.has(sessionKey) || !starting.ownerIds.has(ownerId)) continue;
if (starting.ownerIds.size === 1) {
startingSessions.delete(sessionKey);
startingToShutdown.push(starting);
continue;
}
starting.ownerIds.delete(ownerId);
}
for (const session of toShutdown) {
session.generation += 1;
if (sessions.get(session.sessionKey) === session) sessions.delete(session.sessionKey);
}
const started = await Promise.allSettled(startingToShutdown.map(starting => starting.promise));
for (const result of started) {
if (result.status !== "fulfilled") continue;
const session = result.value;
session.generation += 1;
if (sessions.get(session.sessionKey) === session) sessions.delete(session.sessionKey);
toShutdown.push(session);
}
const results = await Promise.allSettled(toShutdown.map(session => shutdownInvalidatedSession(session)));
for (let i = 0; i < toShutdown.length; i += 1) {
const session = toShutdown[i];
@@ -503,7 +537,13 @@ async function executePerCall(code: string, cwd: string, options: PythonExecutor
async function executeOnSession(code: string, cwd: string, options: PythonExecutorOptions): Promise<PythonResult> {
const sessionId = options.sessionId ?? `session:${cwd}`;
const sessionKey = buildSessionKey(sessionId, cwd, options.interpreter);
const sessionKey = resolveOwnerScopedSessionKey({
baseKey: buildSessionKey(sessionId, cwd, options.interpreter),
ownerId: options.kernelOwnerId,
reset: options.reset === true,
hasSession: key => sessions.has(key) || startingSessions.has(key),
getOwners: key => sessions.get(key) ?? startingSessions.get(key),
});
if (options.bridge && !options.bridgeSessionId) {
options.bridgeSessionId = sessionId;
}