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,2 @@
- Fixed the agents view undercounting running subagents: the "N subagents running" indicator now counts busy descendants at any depth, stays visible on collapsed groups, and idle sessions with busy subagents sort above plain idle sessions.
- Changed the agents view Running section to mean the session's own work: sessions whose only activity is delegated to subagents now list as Idle with the running-subagents badge.
29 changes: 28 additions & 1 deletion packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10429,7 +10429,15 @@ export class AgentSession {
else run.suppressTerminalNotice = true;
return true;
}
return this._cancelRlmChildRun(run, reason);
// 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)) {
Expand All @@ -10444,6 +10452,25 @@ export class AgentSession {
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;
}
if (run.session?.cancelRunningRlmDescendants(reason)) {
cancelled = true;
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
for (const { session } of this._rlmChildSessions.values()) {
if (session.cancelRunningRlmDescendants(reason)) {
cancelled = true;
}
}
return cancelled;
}

private async _assertRlmSubagentSessionNameAvailable(name: string, ignorePendingReservation = false): Promise<void> {
const depth = this._rlmDepth + 1;
if (!ignorePendingReservation && this._pendingRlmSubagentSessionNames.has(name)) {
Expand Down
14 changes: 8 additions & 6 deletions packages/coding-agent/src/modes/agents-view/agents-view-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1949,7 +1949,7 @@ export class AgentsViewMode implements Component, Focusable {
}

private async killSubagent(pending: PendingKillSubagent, currentRow: AgentsViewRow): Promise<void> {
const running = currentRow.section === "running";
const running = hasLiveWork(currentRow);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const client = this.requireClient();
this.setStatusMessage(running ? "Stopping subagent..." : "Deleting subagent...");
try {
Expand Down Expand Up @@ -2015,7 +2015,7 @@ export class AgentsViewMode implements Component, Focusable {
this.showDeleteConfirmation();
return;
}
if (!isRunningSessionSummary(row.summary)) {
if (!hasLiveWork(row)) {
this.pendingDeleteAgent = {
identity,
activeSessionId,
Expand Down Expand Up @@ -2528,7 +2528,8 @@ export class AgentsViewMode implements Component, Focusable {
if (row.kind === "subagent-summary") {
const indent = " ".repeat(row.depth);
const hint = row.hasSpawnCode ? theme.fg("dim", ` · ${keyText("app.agents.program")} show program`) : "";
const label = `${theme.fg("dim", `${row.expanded ? "▾" : "▸"} ${row.title}`)}${hint}`;
const titleColor = row.runningSubagentCount > 0 ? ("success" as const) : ("dim" as const);
const label = `${theme.fg(titleColor, `${row.expanded ? "▾" : "▸"} ${row.title}`)}${hint}`;
const line = padLine(truncateToWidth(`${indent}${label}`, width, ""), width);
return selected ? `${SELECTED_ROW_MARKER}${line}` : line;
}
Expand Down Expand Up @@ -2558,7 +2559,7 @@ export class AgentsViewMode implements Component, Focusable {
const title = pendingDelete
? `${heartbeatWarning}${this.getPendingDeleteTitle()}`
: pendingKill
? `${heartbeatWarning}${keyText("app.agents.delete")} again to ${row.section === "running" ? "stop" : "delete"}`
? `${heartbeatWarning}${keyText("app.agents.delete")} again to ${hasLiveWork(row) ? "stop" : "delete"}`
: styleRowTitle(row);
// Keep stable model information ahead of the variable summary so narrow rows truncate the summary first.
const summaryText = !pendingDelete && !pendingKill ? row.summary.summary : undefined;
Expand Down Expand Up @@ -2820,8 +2821,9 @@ function rowHasSpawnCode(row: AgentsViewRow): boolean {
return typeof code === "string" && code.trim().length > 0;
}

function isRunningSessionSummary(summary: SessionSummary): boolean {
return summary.activity === "working";
// Destructive actions gate on live work anywhere in the subtree, never on the display section.
function hasLiveWork(row: AgentsViewRow): boolean {
return row.section === "running" || row.runningSubagentCount > 0 || row.summary.hasRunningRlmChildren === true;
}

// Explicit session names read bold so they stand out from fallback titles
Expand Down
25 changes: 19 additions & 6 deletions packages/coding-agent/src/modes/agents-view/agents-view-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,13 +715,25 @@ export function buildAgentsViewRows(
continue;
}
nestedRows.add(row);
if (row.section === "running") {
parent.runningSubagentCount += 1;
}
const siblings = childrenByParent.get(parent) ?? [];
siblings.push(row);
childrenByParent.set(parent, siblings);
}
// Busy-descendant tally from the live rows: iterative over the parent forest so deep chains cannot overflow.
const tallyOrder = baseRows.filter((row) => !nestedRows.has(row));
for (let index = 0; index < tallyOrder.length; index++) {
for (const child of childrenByParent.get(tallyOrder[index]!) ?? []) {
tallyOrder.push(child);
}
}
for (let index = tallyOrder.length - 1; index >= 0; index--) {
const row = tallyOrder[index]!;
let count = 0;
for (const child of childrenByParent.get(row) ?? []) {
count += (child.section === "running" ? 1 : 0) + child.runningSubagentCount;
}
row.runningSubagentCount = count;
}

const roots = baseRows.filter((row) => !nestedRows.has(row));
const flattened: AgentsViewRow[] = [];
Expand Down Expand Up @@ -880,6 +892,10 @@ function compareAgentsViewRows(a: AgentsViewRow, b: AgentsViewRow): number {
}
}
if (a.section !== "running") {
const busyDescendantsDiff = Number(b.runningSubagentCount > 0) - Number(a.runningSubagentCount > 0);
if (busyDescendantsDiff !== 0) {
return busyDescendantsDiff;
}
const activityDiff = getTimestamp(b.summary.lastActivityAt) - getTimestamp(a.summary.lastActivityAt);
if (activityDiff !== 0) {
return activityDiff;
Expand Down Expand Up @@ -1008,9 +1024,6 @@ function getSessionStatusLabel(summary: SessionSummary, heartbeat?: UnifiedSessi
if (summary.isBashRunning === true) {
return "running bash";
}
if (summary.hasRunningRlmChildren === true) {
return "subagents running";
}
if (summary.sessionActions.active) {
return summary.sessionActions.active.label ?? summary.sessionActions.active.kind.replace("_", " ");
}
Expand Down
7 changes: 4 additions & 3 deletions packages/coding-agent/src/modes/daemon/agent-roster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface AgentStatusInput {
resident: boolean;
/** Admitted child run whose session has not materialized yet. */
queuedChild: boolean;
/** Actively working: streaming, running tools/bash, or running children. */
/** Actively working: streaming or running tools/bash. */
busy: boolean;
}

Expand All @@ -19,20 +19,21 @@ export function classifyAgentStatus(input: AgentStatusInput): AgentRosterStatus
return input.busy ? "running" : "idle";
}

// Residency/shutdown-safety busy: delegated child work counts; the section classifier deliberately does not use it.
export function isSessionSummaryBusy(
summary: Pick<SessionSummary, "isSessionActive" | "hasRunningRlmChildren">,
): boolean {
return summary.isSessionActive || summary.hasRunningRlmChildren === true;
}

export function classifySessionRosterStatus(
summary: Pick<SessionSummary, "activeSessionId" | "activity" | "isSessionActive" | "hasRunningRlmChildren">,
summary: Pick<SessionSummary, "activeSessionId" | "activity" | "isSessionActive">,
queuedChild = false,
): AgentRosterStatus {
return classifyAgentStatus({
resident: !!summary.activeSessionId,
queuedChild,
busy: summary.activity === "working" || isSessionSummaryBusy(summary),
busy: summary.activity === "working" || summary.isSessionActive === true,
Comment thread
snimu marked this conversation as resolved.
});
}

Expand Down
7 changes: 3 additions & 4 deletions packages/coding-agent/src/modes/daemon/daemon-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,8 @@ import {
buildRlmChildSnapshots,
buildSessionList,
classifySessionRosterStatus,
hasLiveSessionWork,
inactiveLifecycleForSession,
isActiveSessionBusy,
type SessionSummary,
scheduledJobRegistrations,
summaryForActiveSession,
Expand Down Expand Up @@ -6048,8 +6048,7 @@ export class AgentDaemon {
if (state.runtime.metadata.kind === "subagent") {
return false;
}
// A running bash or in-flight turn means there is live work to preserve.
if (state.runtime.session.isBashRunning || isActiveSessionBusy(state)) {
if (state.runtime.session.isBashRunning || hasLiveSessionWork(state)) {
return false;
}
return this.isEmptyDraftContent(state);
Expand Down Expand Up @@ -6974,7 +6973,7 @@ export class AgentDaemon {
}
const session = state.runtime.session;
const busy =
busyOverride ?? (isActiveSessionBusy(state) || session.isRetrying || session.hasAcceptedPromptInFlight);
busyOverride ?? (hasLiveSessionWork(state) || session.isRetrying || session.hasAcceptedPromptInFlight);
try {
this.recoveryJournal.record({
activeSessionId: state.activeSessionId,
Expand Down
8 changes: 4 additions & 4 deletions packages/coding-agent/src/modes/daemon/daemon-session-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,15 +407,15 @@ function readMessageText(content: unknown): string {
.join("\n");
}

// Agent doing work, ignoring the classification verdict.
export function isActiveSessionBusy(activeSession: ActiveSessionState): boolean {
// Live work that dies with the worker; the display activity axis deliberately excludes delegated work.
export function hasLiveSessionWork(activeSession: ActiveSessionState): boolean {
const session = activeSession.runtime.session;
// Background subagents keep the parent "working" even after its own turn ends.
return session.isSessionActive || session.hasRunningRlmChildren();
}

export function activeActivityForSession(activeSession: ActiveSessionState): SessionActivity {
if (isActiveSessionBusy(activeSession)) {
// The session's own work only, ignoring the classification verdict.
if (activeSession.runtime.session.isSessionActive) {
return "working";
}
// A finished subagent is resident but never gets a summarizer verdict, so don't hold
Expand Down
7 changes: 7 additions & 0 deletions packages/coding-agent/test/agent-roster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,11 @@ describe("classifyAgentStatus", () => {
expect(classifySessionRosterStatus(summaryFor(false, false, true))).toBe("inactive");
expect(classifySubagentSnapshotStatus(childFor(false, false))).toBe("inactive");
});

it("keeps a session with only delegated child work out of running", () => {
const delegating: SessionSummary = { ...summaryFor(true, false, false), hasRunningRlmChildren: true };
expect(classifySessionRosterStatus(delegating)).toBe("idle");
const streaming: SessionSummary = { ...summaryFor(true, true, false), hasRunningRlmChildren: true };
expect(classifySessionRosterStatus(streaming)).toBe("running");
});
});
63 changes: 63 additions & 0 deletions packages/coding-agent/test/agent-session-recursion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3105,9 +3105,33 @@ describe("AgentSession rlm recursion", () => {
expect(root.cancelRlmChildRun("unknown-child")).toBe(false);
expect(run?.status).toBe("running");

// Running work retained under the LIVE child: the abort cascade only
// reaches active runs, so the cancel walk must descend here itself.
await waitFor(() => run?.session !== undefined);
const deepHost = createSession({ rlmSessionDir: join(tempDir, "deep-host") });
const deepAbort = vi.fn();
const deepRun = {
id: "deep-1",
status: "running",
settled: false,
abort: deepAbort,
publication: { reject: vi.fn() },
emitUpdate: vi.fn(),
};
(deepHost as unknown as { _activeRlmChildRuns: Map<string, typeof deepRun> })._activeRlmChildRuns.set(
"deep-1",
deepRun,
);
(run?.session as unknown as { _rlmChildSessions: Map<string, { session: AgentSession }> })._rlmChildSessions.set(
"deep-host",
{ session: deepHost },
);

expect(root.cancelRlmChildRun(childId)).toBe(true);
expect(run?.status).toBe("cancelled");
expect(run?.error).toBe("Cancelled by user");
expect(deepRun.status).toBe("cancelled");
expect(deepAbort).toHaveBeenCalled();
// The cancelled update is pushed at cancel time, before the (possibly
// stuck) child unwinds; viewers must not keep showing a running child.
expect(childStatuses[childStatuses.length - 1]).toBe("cancelled");
Expand All @@ -3120,6 +3144,45 @@ describe("AgentSession rlm recursion", () => {
expect(root.cancelRlmChildRun(childId)).toBe(false);
});

it("stops live descendants when the targeted child run already settled", async () => {
const root = createSession({
streamFn: (_model, context) => {
const stream = createAssistantMessageEventStream();
stream.push({
type: "done",
reason: "stop",
message: assistantMessage(`child answer: ${userText(context)}`),
});
return stream;
},
});

await root.runRlmChild("quick shard");
const runs = (root as unknown as InspectableRlmSession)._activeRlmChildRuns;
await waitFor(() => runs.size === 0);
const retained = (root as unknown as { _rlmChildSessions: Map<string, { session: AgentSession }> })
._rlmChildSessions;
expect(retained.size).toBe(1);
const [childId, { session: childSession }] = [...retained.entries()][0]!;
const abort = vi.fn();
const grandchild = {
id: "grandchild-1",
status: "running",
settled: false,
abort,
publication: { reject: vi.fn() },
emitUpdate: vi.fn(),
};
(childSession as unknown as { _activeRlmChildRuns: Map<string, typeof grandchild> })._activeRlmChildRuns.set(
"grandchild-1",
grandchild,
);

expect(root.cancelRlmChildRun(childId)).toBe(true);
expect(grandchild.status).toBe("cancelled");
expect(abort).toHaveBeenCalled();
});

it("reports a shared running outcome to concurrent inactive-delete callers", async () => {
let runningChecks = 0;
const isExternallyRunning = () => ++runningChecks >= 5;
Expand Down
Loading
Loading