Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed stopping or deleting an agent whose tree holds finished intermediate subagents: the walk no longer re-visits subtrees exponentially (which could freeze the worker on deep trees), and one cancel press reliably reaches every running descendant.
166 changes: 74 additions & 92 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9861,41 +9861,31 @@ export class AgentSession {
childId: string,
isExternallyRunning: () => boolean = () => false,
): Promise<"deleted" | "not_found" | "running"> {
const isRunning = (): boolean => {
const status = this._activeRlmChildRuns.get(childId)?.status;
return status === "queued" || status === "running" || isExternallyRunning();
};
if (isRunning()) {
return "running";
}
const subagent = [...(await this.listRlmSubagents()).subagents, ...this._rlmChildCleanupFailures.values()].find(
(entry) => entry.rlm_child_id === childId,
);
if (!subagent) {
for (const run of this._activeRlmChildRuns.values()) {
const result = await run.session?.deleteInactiveRlmSubagent(childId, isExternallyRunning);
if (result && result !== "not_found") {
return result;
}
}
for (const { session: retained } of this._rlmChildSessions.values()) {
const result = await retained.deleteInactiveRlmSubagent(childId, isExternallyRunning);
if (result !== "not_found") {
return result;
}
for (const owner of this._rlmSubtreeSessions()) {
const isRunning = (): boolean => {
const status = owner._activeRlmChildRuns.get(childId)?.status;
return status === "queued" || status === "running" || isExternallyRunning();
};
if (isRunning()) {
return "running";
}
return "not_found";
}
if (isRunning()) {
return "running";
}
const result = await this._trackRlmSubagentDeletion(subagent, () => {
const subagent = [
...(await owner.listRlmSubagents()).subagents,
...owner._rlmChildCleanupFailures.values(),
].find((entry) => entry.rlm_child_id === childId);
if (!subagent) continue;
if (isRunning()) {
return Promise.resolve({ subagent, outcome: "skipped_running" });
return "running";
}
return this._deleteResolvedRlmSubagent(subagent);
});
return result.outcome === "skipped_running" ? "running" : "deleted";
const result = await owner._trackRlmSubagentDeletion(subagent, () => {
if (isRunning()) {
return Promise.resolve({ subagent, outcome: "skipped_running" });
}
return owner._deleteResolvedRlmSubagent(subagent);
});
return result.outcome === "skipped_running" ? "running" : "deleted";
}
return "not_found";
}

async deleteRlmSubagent(target: string): Promise<RlmDeleteSubagentResult> {
Expand Down Expand Up @@ -10320,18 +10310,11 @@ export class AgentSession {

/** True when any direct or nested subagent is still running or queued. */
hasRunningRlmChildren(): boolean {
for (const run of this._activeRlmChildRuns.values()) {
if (run.status === "running" || run.status === "queued") {
return true;
}
if (run.session?.hasRunningRlmChildren()) {
return true;
}
}
// A finished direct child can still have a running nested subagent.
for (const { session } of this._rlmChildSessions.values()) {
if (session.hasRunningRlmChildren()) {
return true;
for (const session of this._rlmSubtreeSessions()) {
for (const run of session._activeRlmChildRuns.values()) {
if (run.status === "running" || run.status === "queued") {
return true;
}
}
}
return false;
Expand Down Expand Up @@ -10407,20 +10390,11 @@ export class AgentSession {

// Inline (non-daemon) mode only; daemon clients attach to the child session directly.
getRlmChildSession(childId: string): AgentSession | undefined {
const direct = this._activeRlmChildRuns.get(childId)?.session ?? this._rlmChildSessions.get(childId)?.session;
if (direct) {
return direct;
}
for (const candidate of this._activeRlmChildRuns.values()) {
const nested = candidate.session?.getRlmChildSession(childId);
if (nested) {
return nested;
}
}
for (const { session: retained } of this._rlmChildSessions.values()) {
const nested = retained.getRlmChildSession(childId);
if (nested) {
return nested;
for (const session of this._rlmSubtreeSessions()) {
const direct =
session._activeRlmChildRuns.get(childId)?.session ?? session._rlmChildSessions.get(childId)?.session;
if (direct) {
return direct;
}
}
return undefined;
Expand All @@ -10433,50 +10407,58 @@ export class AgentSession {
* was suppressed; false when the id is unknown or the run already settled.
*/
cancelRlmChildRun(childId: string, reason = "Cancelled by user"): boolean {
const run = this._activeRlmChildRuns.get(childId);
if (run) {
if (run.status !== "running" && run.status !== "queued" && !run.settled) {
if (this._sessionInputPumpSuspended) this._abandonRlmRunForQuiescence(run);
else run.suppressTerminalNotice = true;
return true;
}
// Cancel AND descend: the abort cascade only reaches active runs, never
// running work retained under a settled descendant.
const cancelled = this._cancelRlmChildRun(run, reason);
const descendantsCancelled = run.session?.cancelRunningRlmDescendants(reason) ?? false;
return cancelled || descendantsCancelled;
}
const retainedTarget = this._rlmChildSessions.get(childId)?.session;
if (retainedTarget?.cancelRunningRlmDescendants(reason)) {
return true;
}
for (const candidate of this._activeRlmChildRuns.values()) {
if (candidate.session?.cancelRlmChildRun(childId, reason)) {
return true;
for (const session of this._rlmSubtreeSessions()) {
const run = session._activeRlmChildRuns.get(childId);
if (run) {
if (run.status !== "running" && run.status !== "queued" && !run.settled) {
if (session._sessionInputPumpSuspended) session._abandonRlmRunForQuiescence(run);
else run.suppressTerminalNotice = true;
return true;
}
// The abort cascade never reaches running work retained under a settled descendant.
const cancelled = session._cancelRlmChildRun(run, reason);
const descendantsCancelled = run.session?.cancelRunningRlmDescendants(reason) ?? false;
if (cancelled || descendantsCancelled) {
return true;
}
}
}
for (const { session: retained } of this._rlmChildSessions.values()) {
if (retained.cancelRlmChildRun(childId, reason)) {
// A fruitless match keeps walking: child ids are only mkdir-unique among
// siblings, so a colliding live run elsewhere must stay reachable.
if (session._rlmChildSessions.get(childId)?.session.cancelRunningRlmDescendants(reason)) {
return true;
}
}
return false;
}

/** Cancel every running or queued run in this session's subtree; cancel AND descend at every node. */
cancelRunningRlmDescendants(reason = "Cancelled by user"): boolean {
let cancelled = false;
for (const run of this._activeRlmChildRuns.values()) {
if (run.status === "running" || run.status === "queued") {
if (this._cancelRlmChildRun(run, reason)) cancelled = true;
// A done child sits in BOTH maps until passivation; the visited set keeps that dual membership from doubling the walk.
private *_rlmSubtreeSessions(): Generator<AgentSession> {
const visited = new Set<AgentSession>([this]);
const stack: AgentSession[] = [this];
while (stack.length > 0) {
const session = stack.pop()!;
yield session;
for (const run of session._activeRlmChildRuns.values()) {
if (run.session && !visited.has(run.session)) {
visited.add(run.session);
stack.push(run.session);
}
}
if (run.session?.cancelRunningRlmDescendants(reason)) {
cancelled = true;
for (const { session: retained } of session._rlmChildSessions.values()) {
if (!visited.has(retained)) {
visited.add(retained);
stack.push(retained);
}
}
}
for (const { session } of this._rlmChildSessions.values()) {
if (session.cancelRunningRlmDescendants(reason)) {
cancelled = true;
}

/** Cancel every running or queued run in this session's subtree. */
cancelRunningRlmDescendants(reason = "Cancelled by user"): boolean {
let cancelled = false;
for (const session of this._rlmSubtreeSessions()) {
for (const run of session._activeRlmChildRuns.values()) {
if (session._cancelRlmChildRun(run, reason)) cancelled = true;
}
}
return cancelled;
Expand Down
92 changes: 92 additions & 0 deletions packages/coding-agent/test/agent-session-recursion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3144,6 +3144,98 @@ describe("AgentSession rlm recursion", () => {
expect(root.cancelRlmChildRun(childId)).toBe(false);
});

it("keeps a colliding child id reachable past a finished retained match", async () => {
const root = createSession({ rlmSessionDir: join(tempDir, "collide-root") });
const finished = createSession({ rlmSessionDir: join(tempDir, "collide-finished") });
const otherParent = createSession({ rlmSessionDir: join(tempDir, "collide-other") });
const rootMaps = root as unknown as { _rlmChildSessions: Map<string, { session: AgentSession }> };
// Child ids are only mkdir-unique among siblings: "sub-dup" exists twice.
rootMaps._rlmChildSessions.set("sub-dup", { session: finished });
rootMaps._rlmChildSessions.set("other-parent", { session: otherParent });
const abort = vi.fn();
const collidingRun = {
id: "sub-dup",
status: "running",
settled: false,
abort,
publication: { reject: vi.fn() },
emitUpdate: vi.fn(),
};
(otherParent as unknown as { _activeRlmChildRuns: Map<string, typeof collidingRun> })._activeRlmChildRuns.set(
"sub-dup",
collidingRun,
);

expect(root.cancelRlmChildRun("sub-dup")).toBe(true);
expect(collidingRun.status).toBe("cancelled");
expect(abort).toHaveBeenCalled();
});

it("cancels a deep dual-membership chain in one visit per session", async () => {
const levels = 20;
const sessions = Array.from({ length: levels + 1 }, (_, level) =>
createSession({ rlmSessionDir: join(tempDir, `chain-${level}`) }),
);
let cancelPrimitiveCalls = 0;
let runMapIterations = 0;
for (const [level, session] of sessions.entries()) {
const target = session as unknown as {
_activeRlmChildRuns: Map<string, unknown>;
_rlmChildSessions: Map<string, { session: AgentSession }>;
_cancelRlmChildRun(run: unknown, reason: string): boolean;
};
const original = target._cancelRlmChildRun.bind(session);
target._cancelRlmChildRun = (run, reason) => {
cancelPrimitiveCalls++;
return original(run, reason);
};
const originalValues = target._activeRlmChildRuns.values.bind(target._activeRlmChildRuns);
target._activeRlmChildRuns.values = () => {
runMapIterations++;
return originalValues();
};
if (level === 0) continue;
// A finished intermediate lives in BOTH parent maps until passivation.
const parent = sessions[level - 1] as unknown as {
_activeRlmChildRuns: Map<string, unknown>;
_rlmChildSessions: Map<string, { session: AgentSession }>;
};
parent._activeRlmChildRuns.set(`chain-${level}`, {
id: `chain-${level}`,
status: "done",
settled: true,
session,
abort: vi.fn(),
publication: { reject: vi.fn() },
emitUpdate: vi.fn(),
});
parent._rlmChildSessions.set(`chain-${level}`, { session });
}
const leafAbort = vi.fn();
const leafRun = {
id: "leaf-run",
status: "running",
settled: false,
abort: leafAbort,
publication: { reject: vi.fn() },
emitUpdate: vi.fn(),
};
(sessions[levels] as unknown as { _activeRlmChildRuns: Map<string, typeof leafRun> })._activeRlmChildRuns.set(
"leaf-run",
leafRun,
);

expect(sessions[0]!.hasRunningRlmChildren()).toBe(true);
expect(runMapIterations).toBeLessThanOrEqual(3 * (levels + 1));

expect(sessions[0]!.cancelRunningRlmDescendants()).toBe(true);
expect(leafRun.status).toBe("cancelled");
expect(leafAbort).toHaveBeenCalled();
// One visit per session, not 2^depth.
expect(cancelPrimitiveCalls).toBeLessThanOrEqual(levels + 1);
expect(sessions[0]!.hasRunningRlmChildren()).toBe(false);
});

it("stops live descendants when the targeted child run already settled", async () => {
const root = createSession({
streamFn: (_model, context) => {
Expand Down
Loading