Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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.
Original file line number Diff line number Diff line change
Expand Up @@ -2528,7 +2528,9 @@ 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}`;
// Busy descendants must be legible on a collapsed group, not dimmed away.
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
28 changes: 22 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,28 @@ 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);
}
// The parent's own hasRunningRlmChildren snapshot goes stale between its
// flushes; the live descendant rows are the truth the indicator shows.
// Iterative (the nesting loop gives every row at most one parent, so this is
// a forest): a deep child chain must not overflow the stack on view open.
Comment thread
snimu marked this conversation as resolved.
Resolved
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 +895,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 +1027,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
8 changes: 5 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,22 @@ export function classifyAgentStatus(input: AgentStatusInput): AgentRosterStatus
return input.busy ? "running" : "idle";
}

// Residency and shutdown-safety busy: delegated child work counts. The section
// classifier below deliberately does not use it - Running means the session itself.
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.
Resolved
});
}

Expand Down
8 changes: 4 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,8 @@ 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)) {
// A running bash, in-flight turn, or running child means live work to preserve.
if (state.runtime.session.isBashRunning || hasLiveSessionWork(state)) {
return false;
}
return this.isEmptyDraftContent(state);
Expand Down Expand Up @@ -6974,7 +6974,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
10 changes: 6 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,17 @@ 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 session's own turn or any running
// RLM child. Recovery and draft-discard gates use this; the display activity
// axis deliberately does not (delegated work is the badge's signal there).
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");
});
});
32 changes: 32 additions & 0 deletions packages/coding-agent/test/agents-view-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,38 @@ describe("AgentsViewMode", () => {
}
});

it("renders a collapsed group's busy-subagent badge legibly instead of dimmed", () => {
const parent = summary({ id: "parent", activeSessionId: "parent", sessionId: "parent-session" });
const busyChild = summary({
id: "busy-child",
activeSessionId: "busy-child",
sessionId: "busy-child-session",
sessionFile: "/tmp/busy-child.jsonl",
runtimeKind: "subagent",
parentActiveSessionId: "parent",
activity: "working",
isSessionActive: true,
isStreaming: true,
});
const idleChild = { ...busyChild, activity: "idle" as const, isSessionActive: false, isStreaming: false };
const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, {});

try {
const busyRows = buildAgentsViewRows([parent, busyChild]);
const busySummaryRow = busyRows.find((row) => row.kind === "subagent-summary");
expect(busySummaryRow).toMatchObject({ section: "idle", title: "1 subagent running" });
Reflect.set(view, "rows", busyRows);
expect(invoke("renderRow", view, busySummaryRow, 160)).toContain(theme.fg("success", "▸ 1 subagent running"));

const idleRows = buildAgentsViewRows([parent, idleChild]);
const idleSummaryRow = idleRows.find((row) => row.kind === "subagent-summary");
Reflect.set(view, "rows", idleRows);
expect(invoke("renderRow", view, idleSummaryRow, 160)).toContain(theme.fg("dim", "▸ 1 subagent"));
} finally {
stopThemeWatcher();
}
});

it("dims a paused-only heartbeat badge and keeps active badges in the error color", () => {
const job = (status: "active" | "paused") => ({
job: {
Expand Down
Loading
Loading