Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import {
Expand Down Expand Up @@ -8760,8 +8760,8 @@ export class AgentSession {
/** Typed handlers for host requests arriving from the IPython kernel comm bridge. */
private _createKernelHostHandlers(): HostRequestHandlers {
const handlers: HostRequestHandlers = {
"rlm.run": createRlmRunHostHandler(async ({ prompt, kwargs, cellSourceCode }) => ({
...(await this.runRlmChild(prompt, kwargs, cellSourceCode)),
"rlm.run": createRlmRunHostHandler(async ({ prompt, kwargs, cellSourceCode }, signal) => ({
...(await this.runRlmChild(prompt, kwargs, cellSourceCode, signal)),
})),
"rlm.find_models": createRlmFindModelsHostHandler((query, limit) => this.findRlmModels(query, limit)),
"rlm.list_subagents": createRlmListSubagentsHostHandler(() => this.listRlmSubagents()),
Expand Down Expand Up @@ -9685,7 +9685,9 @@ export class AgentSession {
prompt: string,
kwargs: Record<string, unknown> = {},
spawnCode?: string,
signal?: AbortSignal,
): Promise<RlmSpawnHandle> {
signal?.throwIfAborted();
const { name: rawName, model: rawModel, ...unsupported } = kwargs;
const unsupportedKwargs = Object.keys(unsupported);
if (unsupportedKwargs.length > 0) {
Expand All @@ -9712,12 +9714,19 @@ export class AgentSession {
} finally {
if (requestedSessionName) this._pendingRlmSubagentSessionNames.delete(requestedSessionName);
}
signal?.throwIfAborted();
if (this._disposed || this._disposing) throw new Error("Cannot spawn a subagent after its parent was disposed");

const childSessionDir = this._createChildRlmSessionDir();
const childNodeId = basename(childSessionDir);
const sessionName = requestedSessionName ?? createDefaultRlmSubagentSessionName(prompt, childNodeId);
if (!requestedSessionName) await this._assertRlmSubagentSessionNameAvailable(sessionName);
try {
if (!requestedSessionName) await this._assertRlmSubagentSessionNameAvailable(sessionName);
signal?.throwIfAborted();
} catch (error) {
rmSync(childSessionDir, { recursive: true, force: true });
throw error;
}
const startedAt = Date.now();
const parentAssistantForUsage = this._findLastAssistantMessage();
const label = rlmChildLabel(prompt);
Expand All @@ -9741,6 +9750,15 @@ export class AgentSession {
if (run.status === "cancelled") throw new Error(run.error ?? "RLM child cancelled");
};
this._activeRlmChildRuns.set(run.id, run);
const abortFromHost = () => {
const reason = signal?.reason;
this._cancelRlmChildRun(run, reason instanceof Error ? reason.message : "IPython kernel host request aborted");
};
if (signal?.aborted) {
abortFromHost();
} else {
signal?.addEventListener("abort", abortFromHost, { once: true });
}
const emitChildUpdate = () => {
const childModel = childSession?.model ?? modelSelection.model;
this._emit({
Expand Down Expand Up @@ -9995,6 +10013,7 @@ export class AgentSession {
}
}
} finally {
signal?.removeEventListener("abort", abortFromHost);
if (run.detachedDeletion && childRuntime) {
try {
await this._deleteRlmSubagentSession(run.id, childRuntime.session);
Expand Down Expand Up @@ -10036,8 +10055,9 @@ export class AgentSession {
prompt: string,
kwargs: Record<string, unknown> = {},
spawnCode?: string,
signal?: AbortSignal,
): Promise<RlmSpawnHandle> {
return this._startRlmChildRun(prompt, kwargs, spawnCode);
return this._startRlmChildRun(prompt, kwargs, spawnCode, signal);
}

// =========================================================================
Expand Down
28 changes: 23 additions & 5 deletions packages/coding-agent/src/core/kernel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ export const HOST_COMM_TARGET = "host.request";
* This legacy unary compatibility alias remains the dispatcher and registration
* contract while context-aware handlers are staged separately below.
*/
export type HostRequestHandler = (payload: Record<string, unknown>) => Promise<Record<string, unknown>>;
export type HostRequestHandler = (
payload: Record<string, unknown>,
signal?: AbortSignal,
) => Promise<Record<string, unknown>>;

/**
* Per-call authority supplied by the host-request dispatcher.
Expand Down Expand Up @@ -616,6 +619,7 @@ export class KernelManager {
// attribute their spawning program.
private lastCellCode?: string;
private readonly inFlightHostRequests = new Set<Promise<void>>();
private hostRequestController = new AbortController();
private state: "idle" | "starting" | "running" | "shutdown" = "idle";
/** Memoized so concurrent callers all await the same in-flight startup. */
private startPromise?: Promise<void>;
Expand Down Expand Up @@ -658,6 +662,9 @@ export class KernelManager {

private async doStart(startOptions: KernelStartOptions): Promise<void> {
if (this.state !== "idle") return;
if (this.hostRequestController.signal.aborted) {
this.hostRequestController = new AbortController();
}
this.state = "starting";
installSignalHandlersOnce();
// Tracked from the moment startup begins so session cleanup and signal
Expand Down Expand Up @@ -1300,9 +1307,10 @@ export class KernelManager {
}
this.handledHostRequestCommIds.add(commId);

const signal = this.hostRequestController.signal;
const task = (async () => {
try {
const result = await this.handleHostRequest(data);
const result = await this.handleHostRequest(data, signal);
try {
await this.sendCommMessage(commId, { status: "ok", ...result });
} catch (replyError) {
Expand All @@ -1327,7 +1335,7 @@ export class KernelManager {
});
}

private async handleHostRequest(data: unknown): Promise<Record<string, unknown>> {
private async handleHostRequest(data: unknown, signal: AbortSignal): Promise<Record<string, unknown>> {
if (!isRecord(data)) {
throw new Error("host request payload must be an object");
}
Expand All @@ -1343,7 +1351,7 @@ export class KernelManager {
// the in-flight execution; detached spawns (asyncio.create_task) fire after
// the scheduling cell goes idle, so fall back to that last cell's source.
const cellSourceCode = this.activeExecution?.code ?? this.lastCellCode;
return handler({ ...data, cellSourceCode });
return handler({ ...data, cellSourceCode }, signal);
}

private async sendCommMessage(commId: string, data: Record<string, unknown>): Promise<void> {
Expand All @@ -1362,6 +1370,7 @@ export class KernelManager {
}

private cleanupResources(killSignal: NodeJS.Signals = "SIGTERM"): void {
this.abortHostRequests("IPython kernel stopped");
this.clearSnapshotTimer();
this.lateSentAgentMessageHandlers.clear();
if (this.forkedLivenessTimer) {
Expand Down Expand Up @@ -1401,6 +1410,12 @@ export class KernelManager {
this.startPromise = undefined;
}

private abortHostRequests(message: string): void {
if (!this.hostRequestController.signal.aborted) {
this.hostRequestController.abort(new Error(message));
}
}

private async waitForHostRequestsToSettle(tasks: Promise<void>[], timeoutMs: number): Promise<void> {
let timeout: ReturnType<typeof globalThis.setTimeout> | undefined;
const timeoutPromise = new Promise<"timeout">((resolve) => {
Expand Down Expand Up @@ -1429,6 +1444,7 @@ export class KernelManager {
}
// Best-effort final flush (bounded) before teardown — used by signal handlers
// so a SIGINT/SIGTERM exit doesn't lose work the debounced snapshot hasn't saved.
this.abortHostRequests("IPython kernel shut down");
if (opts.snapshot) {
await this.flushSnapshotForDispose();
}
Expand Down Expand Up @@ -1469,6 +1485,7 @@ export class KernelManager {
}

async kill(): Promise<void> {
this.abortHostRequests("IPython kernel killed");
this.state = "shutdown";
liveKernels.delete(this);
this.cleanupResources("SIGKILL");
Expand Down Expand Up @@ -1575,12 +1592,12 @@ export class KernelManager {
/** Graceful cleanup. Waits briefly for in-flight host request handlers before closing sockets. */
dispose(): Promise<void> {
return (async () => {
this.abortHostRequests("IPython kernel disposed");
// Final namespace flush while the kernel is still live (session end / reload).
await this.flushSnapshotForDispose();
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
this.state = "shutdown";
liveKernels.delete(this);
const inFlightHostRequests = [...this.inFlightHostRequests];
// TODO: plumb AbortSignal through AgentSession.prompt so disposal can cancel long-running child loops.
try {
if (inFlightHostRequests.length > 0) {
await this.waitForHostRequestsToSettle(inFlightHostRequests, HOST_REQUEST_DISPOSE_TIMEOUT_MS);
Expand All @@ -1593,6 +1610,7 @@ export class KernelManager {

/** Synchronous best-effort cleanup. Safe to call from `process.on('exit')`. */
disposeSync(): void {
this.abortHostRequests("IPython kernel disposed");
this.state = "shutdown";
liveKernels.delete(this);
// TODO: replace this best-effort hard-exit path if Node exposes an awaitable process-exit cleanup hook.
Expand Down
17 changes: 10 additions & 7 deletions packages/coding-agent/src/core/rlm-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export interface RlmFindModelsResult {
models: RlmModelMatch[];
}

export type RlmRunHandler = (request: RlmRunRequest) => Promise<Record<string, unknown>>;
export type RlmRunHandler = (request: RlmRunRequest, signal?: AbortSignal) => Promise<Record<string, unknown>>;
export type RlmListSubagentsHandler = () => RlmListSubagentsResult | Promise<RlmListSubagentsResult>;
export type RlmDeleteSubagentHandler = (target: string) => Promise<RlmDeleteSubagentResult>;
export type RlmFindModelsHandler = (query: string, limit: number) => RlmFindModelsResult | Promise<RlmFindModelsResult>;
Expand Down Expand Up @@ -150,17 +150,20 @@ export function findRlmModelMatches(query: string, models: Model<Api>[], limit:

/** Adapt an RlmRunHandler into the typed "rlm.run" handler for the kernel host bridge. */
export function createRlmRunHostHandler(handler: RlmRunHandler): HostRequestHandler {
return async (payload) => {
return async (payload, signal) => {
if (typeof payload.prompt !== "string") {
throw new Error("rlm.run prompt must be a string");
}
const kwargs = isRecord(payload.kwargs) ? payload.kwargs : {};
const cellSourceCode = typeof payload.cellSourceCode === "string" ? payload.cellSourceCode : undefined;
const result = await handler({
prompt: payload.prompt,
kwargs,
cellSourceCode,
});
const result = await handler(
{
prompt: payload.prompt,
kwargs,
cellSourceCode,
},
signal,
);
return result as unknown as Record<string, unknown>;
};
}
Expand Down
Loading