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
19 changes: 10 additions & 9 deletions apps/ade-cli/src/services/push/attentionItemBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,13 @@ export type AgentRunState = {
metaResolved: boolean;
/**
* Live background-task ids for this run, tracked from the `background_task`
* flavour of `scheduled_work_update`. Claude spawns background subagents that
* keep working after the foreground turn bookends, and the desktop sidebar
* already treats that as Working (`sessionStatusPresentation.ts` overrides
* ready/idle when `activeBackgroundTaskCount > 0`). This set is the
* publisher's copy of the same fact — both derive from agentChatService's
* live background-task level — so Activity cannot publish "is done" over a
* session that is demonstrably still working.
* flavour of `scheduled_work_update`. Agents spawn background work that keeps
* running after the foreground turn bookends, and desktop already treats that
* as Working (`sessionCanonicalState.ts` promotes a resting session with live
* background work back to the `running` phase). This set is the publisher's
* copy of the same fact — both derive from agentChatService's live
* background-task level — so Activity cannot publish "is done" over a session
* that is demonstrably still working.
*/
backgroundTaskIds: Set<string>;
/**
Expand Down Expand Up @@ -160,8 +160,9 @@ export function agentAttentionPhase(run: AgentRunState): AttentionPhase {
if (run.phase === "waiting_for_approval" || run.phase === "waiting_for_input") return "needs_you";
// Belt and braces over the `deferredTerminalPhase` state machine in
// `onChatEvent`: whatever route left the run at a quiet phase, a session with
// live background subagents is Working. This mirrors the sidebar override in
// apps/desktop/src/shared/sessionStatusPresentation.ts — the two surfaces
// live background subagents is Working. This mirrors the phase promotion in
// apps/desktop/src/shared/sessionCanonicalState.ts, where live background
// work lifts a resting session back to `running` — the two surfaces
// disagreeing about the same session is exactly the bug this guards.
// `failed` is deliberately not overridden: a failure needs the user now, and
// burying it under "working" would cost them the signal.
Expand Down
18 changes: 12 additions & 6 deletions apps/ade-cli/src/services/sync/rosterBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,12 +366,18 @@ function diskChatStatus(row: TerminalSessionRow, sidecarAwaiting: boolean): Sync
function liveChatStatus(live: RosterLiveSession): SyncRosterChatStatus {
if (live.awaitingInput) return "awaiting";
if (live.status === "active") return "running";
// Claude's background subagents keep working after the foreground turn ends,
// and agentChatService reports the chat `idle` for the whole of it. The
// desktop sidebar overrides that to Working
// (`sessionStatusPresentation.ts` — `activeBackgroundTaskCount > 0`); the
// roster has to agree, or Activity maps the session idle → stale → Done and
// reports a live agent as finished.
// Background work keeps running after the foreground turn ends, and
// agentChatService reports the chat `idle` for the whole of it. Desktop
// promotes that back to the `running` phase
// (`sessionCanonicalState.ts` — `backgroundWork`); the roster has to agree,
// or Activity maps the session idle → stale → Done and reports a live agent
// as finished.
//
// The count is cross-runtime, not Claude-only: it now also covers Codex
// background subagents and Cursor cloud runs, so this branch fires for them
// too without any change here. The phone deliberately reads the TOTAL rather
// than the working/monitoring split — a roster row has one status, and
// "something is still running" is the fact it needs.
if ((live.activeBackgroundTaskCount ?? 0) > 0) return "running";
if (live.status === "idle") return "idle";
return "ended";
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/services/adeActions/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1696,6 +1696,9 @@ describe("runtime session actions", () => {

// The user-driven single-row unsettle (desktop row menu on a remote-bound
// project, `ade code`'s /session unsettle) survives under a cto-gated name.
// Still synchronous: resuming the scheduled work settle paused is driven by
// sessionService's onSettleCleared hook at the column write, not by each
// caller — so the action itself stays a plain lifecycle call.
expect(sessionActions.unsettleSession({ sessionId: "session-1" }))
.toEqual({ ok: true, sessionId: "session-1" });
expect(unsettleSession).toHaveBeenCalledWith("session-1");
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2506,7 +2506,7 @@ function buildLaneDomainService(runtime: AdeRuntime): OpaqueService {
archive: async (args?: { laneId?: string }): Promise<void> => {
const laneId = requireNonEmptyString(args?.laneId, "laneId");
const lane = await findLaneForArchive(laneId);
runtime.laneService.archive({ laneId });
await runtime.laneService.archive({ laneId });
try {
releaseLaneRuntimeResources(runtime, laneId);
} finally {
Expand Down
68 changes: 68 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11777,6 +11777,74 @@ describe("createAgentChatService", () => {
await expect(sendPromise).resolves.toBeUndefined();
});

it("splits the background level into working and monitoring counts on the session summary", async () => {
// The classifier is a DENYLIST: only a type whose whole job is to watch
// (`monitor`) reads as monitoring. A generic backgrounded shell, a real
// subagent, and anything unrecognised all count as working — an allowlist
// would silently drop a real subagent the first time the SDK renamed a
// task type, which is the exact failure this state exists to prevent.
const events: AgentChatEventEnvelope[] = [];
let streamCall = 0;
let warmupComplete = false;
let turnDone: (() => void) | null = null;
const turnDonePromise = new Promise<void>((resolve) => { turnDone = resolve; });
const send = vi.fn().mockResolvedValue(undefined);
const setPermissionMode = vi.fn().mockResolvedValue(undefined);
const stream = vi.fn(() => (async function* () {
streamCall += 1;
if (streamCall === 1) {
yield { type: "system", subtype: "init", session_id: "sdk-bgsplit-1", slash_commands: [] };
warmupComplete = true;
yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } };
return;
}
yield {
type: "system",
subtype: "background_tasks_changed",
tasks: [
{ task_id: "watch-ci", task_type: "monitor", description: "Watch CI" },
{ task_id: "run-build", task_type: "local_bash", description: "npm run build" },
{ task_id: "build-it", task_type: "local_agent", description: "Implement the feature" },
{ task_id: "who-knows", task_type: "some_future_sdk_type", description: "Unrecognised" },
],
};
await turnDonePromise;
// The jobs finish on their own; the level is the authoritative drain.
yield { type: "system", subtype: "background_tasks_changed", tasks: [] };
yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } };
})());
vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({
send, stream, close: vi.fn(), sessionId: "sdk-bgsplit-1", setPermissionMode,
} as any);
const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) });
const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" });
await vi.waitFor(() => { expect(warmupComplete).toBe(true); });
const sendPromise = service.sendMessage({ sessionId: session.id, text: "kick off background work" });

await waitForEvent(events, (e): e is AgentChatEventEnvelope =>
e.event.type === "scheduled_work_update"
&& (e.event as any).id === "background:watch-ci"
&& (e.event as any).status === "running");

const live = await service.getSessionSummary(session.id);
// Total stays the single number the mobile roster and push publisher read.
expect(live?.activeBackgroundTaskCount).toBe(4);
// Unknown types — and a generic backgrounded build — land in `working`,
// never in the quiet column.
expect(live?.backgroundWork).toEqual({ workingCount: 3, monitoringCount: 1 });


turnDone!();
await expect(sendPromise).resolves.toBeUndefined();

// Background work outlives the turn by design, so the turn ending does not
// drain it — only the SDK's own empty level does. Once drained, the record
// is omitted entirely rather than riding along as a zero on every read.
const drained = await service.getSessionSummary(session.id);
expect(drained?.activeBackgroundTaskCount).toBe(0);
expect(drained?.backgroundWork).toBeUndefined();
});

it("uses the SDK background level to distinguish background and foreground local_bash tasks", async () => {
// `local_bash` is the implementation kind for both foreground and
// background Bash. Only the SDK's authoritative membership level makes
Expand Down
136 changes: 133 additions & 3 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,11 @@ import {
type CodexSkillsListResponse,
} from "../skills/agentSkillRuntimeService";
import { parseAgentChatTranscript } from "../../../shared/chatTranscript";
import {
summarizeBackgroundWork,
totalBackgroundWork,
type SessionBackgroundWork,
} from "../../../shared/sessionCanonicalState";
import {
isBackgroundShellCommand,
isNonAgentTaskRun,
Expand Down Expand Up @@ -1509,6 +1514,13 @@ type ClaudeRuntime = {
* protect the long-lived query from idle cleanup and runtime eviction.
*/
liveBackgroundTaskIds: Set<string>;
/**
* Raw SDK `task_type` per live background task, kept beside the level set so
* `runtimeBackgroundWork` can split working from monitoring without going
* through `activeSubagents` — which does not hold an entry for every plain
* background shell, and would silently classify those as unknown.
*/
backgroundTaskTypeById: Map<string, string>;
/** True after this CLI process has emitted its first authoritative level. */
backgroundTasksLevelObserved: boolean;
seenBackgroundTaskIds: Set<string>;
Expand Down Expand Up @@ -1568,6 +1580,7 @@ type ClaudeRuntime = {

function resetClaudeProcessBackgroundLevel(runtime: ClaudeRuntime): void {
runtime.liveBackgroundTaskIds.clear();
runtime.backgroundTaskTypeById.clear();
runtime.backgroundTasksLevelObserved = false;
}

Expand Down Expand Up @@ -2131,6 +2144,87 @@ function hasLivePendingInput(managed: ManagedChatSession | null | undefined): bo
return false;
}

// Frozen because it is returned by reference to every caller with no live work;
// a single mutation would otherwise follow every session in the process.
const NO_BACKGROUND_WORK: SessionBackgroundWork = Object.freeze({
workingCount: 0,
monitoringCount: 0,
});

/**
* Live work a chat session still owns after its foreground turn bookends,
* classified working vs monitoring for `canonicalSessionState`.
*
* ── Scope, stated honestly ──────────────────────────────────────────────────
*
* This reads RESIDENT runtime state only. It is therefore in-memory and empty
* after a restart, which is the intended contract: orphaned background work is
* not live work, and a persisted count would resurrect a "Working" pill over a
* process that died with the app.
*
* What it does NOT see, and cannot without new tracking:
* • processes an agent detached with `nohup`/`setsid`/`disown` — they leave
* ADE's process tree entirely,
* • long-lived processes started inside a user-owned terminal pane, which are
* the user's to manage and deliberately out of scope,
* • opencode / droid / pi work — those harnesses expose no background-task,
* subagent, or remote-run level to track at all, so they contribute zero
* here. That is a checked fact per harness, not a default: the switch below
* is exhaustive over `ChatRuntime["kind"]`, so a newly landed harness fails
* to compile until someone decides which column it belongs in.
*/
function runtimeBackgroundWork(runtime: ChatRuntime | null): SessionBackgroundWork {
if (!runtime) return NO_BACKGROUND_WORK;
switch (runtime.kind) {
case "claude": {
// The level set is authoritative for what is still live; the per-task
// types recorded alongside it decide which column each lands in.
return summarizeBackgroundWork(
[...runtime.liveBackgroundTaskIds].map(
(taskId) => runtime.backgroundTaskTypeById.get(taskId) ?? null,
),
);
}
case "codex": {
// Codex reports no task_type, so a backgrounded subagent is a real agent
// doing real work — the unknown-is-working default is also the right one.
const backgroundTypes: Array<string | null> = [];
for (const subagent of runtime.activeSubagents.values()) {
if (subagent.background) backgroundTypes.push(null);
}
return summarizeBackgroundWork(backgroundTypes);
}
case "cursor": {
// A cloud run keeps executing on Cursor's infrastructure after the local
// turn ends — the clearest case of work outliving its turn ADE has.
return summarizeBackgroundWork(new Array(runtime.cloudRuns.size).fill(null));
}
// ── Harnesses with no background-work surface ───────────────────────────
//
// Listed individually rather than swept up by a `default`, so the
// exhaustiveness check below turns "a new harness landed" into a compile
// error instead of a silent zero. Pi (#1054/#1055) is the case that proved
// the point: its runtime carries only turn-scoped state — activeTurnId,
// busy, pendingSteers, activeCompactionId, lease — with no subagent,
// background-task, or remote-run tracking of any kind, and it is absent
// from `SUBAGENT_CAPABILITIES` so `resolveSubagentCapability` already
// degrades it to the no-op descriptor. Zero here is a verified fact about
// Pi, not an unexamined default.
case "opencode":
case "droid":
case "pi":
return NO_BACKGROUND_WORK;
default: {
// A new harness must state whether it owns work that outlives a turn.
// Getting this wrong in the silent direction is the exact bug this whole
// module exists to fix, so the decision is compulsory.
const exhaustive: never = runtime;
void exhaustive;
return NO_BACKGROUND_WORK;
}
}
}

function hasRuntimeActiveWorkload(runtime: ChatRuntime | null): boolean {
if (!runtime) return false;
switch (runtime.kind) {
Expand Down Expand Up @@ -14333,6 +14427,7 @@ export function createAgentChatService(args: {
if (terminal) {
runtime.seenBackgroundTaskIds.delete(args.taskId);
runtime.liveBackgroundTaskIds.delete(args.taskId);
runtime.backgroundTaskTypeById.delete(args.taskId);
runtime.backgroundTaskTitleById.delete(args.taskId);
} else {
runtime.seenBackgroundTaskIds.add(args.taskId);
Expand All @@ -14354,11 +14449,14 @@ export function createAgentChatService(args: {
tasks: unknown,
): void => {
const nextIds = new Set<string>();
const nextTaskTypes = new Map<string, string>();
for (const rawTask of Array.isArray(tasks) ? tasks : []) {
const task = asRecord(rawTask);
const taskId = compactString(task?.task_id);
if (!task || !taskId) continue;
nextIds.add(taskId);
const rawLevelTaskType = compactString(task.task_type);
if (rawLevelTaskType) nextTaskTypes.set(taskId, rawLevelTaskType);

const description = compactString(task.description) ?? "Background work";
if (isClaudeAgentBackgroundTaskType(task.task_type)) {
Expand Down Expand Up @@ -14424,6 +14522,10 @@ export function createAgentChatService(args: {

runtime.liveBackgroundTaskIds.clear();
for (const taskId of nextIds) runtime.liveBackgroundTaskIds.add(taskId);
runtime.backgroundTaskTypeById.clear();
for (const [taskId, taskType] of nextTaskTypes) {
runtime.backgroundTaskTypeById.set(taskId, taskType);
}
runtime.backgroundTasksLevelObserved = true;
managed.lastActivityTimestamp = Date.now();
};
Expand Down Expand Up @@ -29120,6 +29222,7 @@ export function createAgentChatService(args: {
taskTodos: { seeded: false, byId: new Map() },
emittedTextByAssistantMessage: new Map(),
liveBackgroundTaskIds: new Set(),
backgroundTaskTypeById: new Map(),
backgroundTasksLevelObserved: false,
seenBackgroundTaskIds: new Set(),
stoppingBackgroundTaskIds: new Map(),
Expand Down Expand Up @@ -38683,9 +38786,8 @@ export function createAgentChatService(args: {
const claudeTag = provider === "claude"
? getClaudeSessionPointerForChat(row.id)?.tags[0] ?? null
: undefined;
const activeBackgroundTaskCount = liveManaged?.runtime?.kind === "claude"
? liveManaged.runtime.liveBackgroundTaskIds.size
: 0;
const backgroundWork = runtimeBackgroundWork(liveManaged?.runtime ?? null);
const activeBackgroundTaskCount = totalBackgroundWork(backgroundWork);
let nextWakeAt: string | null = null;
let scheduledWorkPaused = false;
let scheduledWork: AgentChatScheduledWorkItem[] = [];
Expand Down Expand Up @@ -38802,6 +38904,10 @@ export function createAgentChatService(args: {
...(provider === "claude" ? { claudeTag } : {}),
nextWakeAt,
activeBackgroundTaskCount,
// Omitted when nothing is live, like every other optional field here: a
// zero record carries no information and would ride along on every
// summary read for every session.
...(activeBackgroundTaskCount > 0 ? { backgroundWork } : {}),
scheduledWorkPaused,
scheduledWork,
...(sessionHasPendingInput ? { awaitingInput: true } : {}),
Expand Down Expand Up @@ -39194,6 +39300,30 @@ export function createAgentChatService(args: {
await scheduledWorkScheduler?.refreshGlobalPause();
};

/**
* Stop the background work a session still owns, WITHOUT closing the session
* or interrupting a turn the user is watching.
*
* This is the runtime half of settle teardown. Settle used to be a pure
* column write: the row went quiet and every monitor, background shell and
* subagent it had spawned kept running — burning tokens, holding ports, and
* (via scheduled work) waking the thread hours later.
*
* Two rules shape what it touches:
*
* • An ACTIVE foreground turn is left alone. Its subagents belong to work
* the user can see happening, and killing them because the row was filed
* would be worse than the leak. A settled session that is still streaming
* un-settles on its own activity anyway.
* • Children stop before parents. Stopping only the parent leaves the fleet
* running and untracked, which is how a "stopped" agent keeps spending.
*
* Deliberately returns no count. An earlier version reported "how much was
* stopped" and could not keep the number honest: `closeOpenClaudeBackgroundTasks`
* (reached through `stopActiveClaudeSubagents`) closes a shell it failed to
* stop, which silently inflated any before/after measurement. The number had
* no consumer, so it is gone rather than approximated.
*/
const hasActiveWorkloads = (): boolean => {
for (const managed of managedSessions.values()) {
if (managed.closed || managed.deleted) continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,7 @@ export function createChatScheduledWorkScheduler(
await updatePauseStatuses(sessionId);
},


async refreshGlobalPause(): Promise<void> {
await start();
await updatePauseStatuses();
Expand Down
Loading
Loading