diff --git a/apps/ade-cli/src/services/push/attentionItemBuilder.ts b/apps/ade-cli/src/services/push/attentionItemBuilder.ts index 23e04a935..0d7b86075 100644 --- a/apps/ade-cli/src/services/push/attentionItemBuilder.ts +++ b/apps/ade-cli/src/services/push/attentionItemBuilder.ts @@ -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; /** @@ -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. diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.ts b/apps/ade-cli/src/services/sync/rosterBuilder.ts index dbff00661..f8b23425d 100644 --- a/apps/ade-cli/src/services/sync/rosterBuilder.ts +++ b/apps/ade-cli/src/services/sync/rosterBuilder.ts @@ -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"; diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index df480f6b4..c4d218e10 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -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"); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 72d087812..5264eeb61 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -2506,7 +2506,7 @@ function buildLaneDomainService(runtime: AdeRuntime): OpaqueService { archive: async (args?: { laneId?: string }): Promise => { 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 { diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 8a283aed6..b92f4f534 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -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((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 diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index b5f2eae8b..42815d84d 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -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, @@ -1509,6 +1514,13 @@ type ClaudeRuntime = { * protect the long-lived query from idle cleanup and runtime eviction. */ liveBackgroundTaskIds: Set; + /** + * 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; /** True after this CLI process has emitted its first authoritative level. */ backgroundTasksLevelObserved: boolean; seenBackgroundTaskIds: Set; @@ -1568,6 +1580,7 @@ type ClaudeRuntime = { function resetClaudeProcessBackgroundLevel(runtime: ClaudeRuntime): void { runtime.liveBackgroundTaskIds.clear(); + runtime.backgroundTaskTypeById.clear(); runtime.backgroundTasksLevelObserved = false; } @@ -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 = []; + 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) { @@ -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); @@ -14354,11 +14449,14 @@ export function createAgentChatService(args: { tasks: unknown, ): void => { const nextIds = new Set(); + const nextTaskTypes = new Map(); 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)) { @@ -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(); }; @@ -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(), @@ -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[] = []; @@ -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 } : {}), @@ -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; diff --git a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts index f478af302..d4f494afa 100644 --- a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts +++ b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts @@ -629,6 +629,7 @@ export function createChatScheduledWorkScheduler( await updatePauseStatuses(sessionId); }, + async refreshGlobalPause(): Promise { await start(); await updatePauseStatuses(); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index e94945099..85b28f5c9 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -6030,7 +6030,7 @@ export function registerIpc({ .list({ includeArchived: true, includeStatus: false }) .then((lanes) => lanes.find((entry) => entry.id === arg.laneId) ?? null) .catch(() => null); - ctx.laneService.archive(arg); + await ctx.laneService.archive(arg); try { releaseLaneRuntimeResources(ctx, arg.laneId); } finally { @@ -6997,9 +6997,10 @@ export function registerIpc({ async (_event, arg: { sessionIds?: unknown }): Promise => { const ctx = ensureSessionContext(); if (!Array.isArray(arg?.sessionIds)) throw new Error("Session ids are required."); - ctx.sessionService.unsettleSessions( - arg.sessionIds.filter((sessionId): sessionId is string => typeof sessionId === "string"), + const unsettleIds = arg.sessionIds.filter( + (sessionId): sessionId is string => typeof sessionId === "string", ); + ctx.sessionService.unsettleSessions(unsettleIds); }, ); diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index e2ca65999..e71a58b72 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -4371,6 +4371,57 @@ describe("laneService delete teardown + cancellation + streaming", () => { return { db, service, repoRoot, worktreesDir, childPath }; } + it("stops the lane's runtime work before archiving it", async () => { + // Archive used to be a bare status write. Every caller releases the lane's + // port lease and proxy route the moment it returns, so the lane's dev + // servers and agents were still bound to those ports when the lease was + // handed back — and, filtered out of every surface, went on holding them + // indefinitely. + const events: any[] = []; + const fake = makeFakeServices(); + const { db, service } = await setupWithLane({ teardown: fake, events }); + + await service.archive({ laneId: "lane-child" }); + + expect(fake.calls).toContain("stop_chats"); + expect(fake.calls).toContain("stop_ptys"); + expect(fake.calls).toContain("stop_watchers"); + expect( + db.get<{ status: string }>("select status from lanes where id = ?", ["lane-child"])?.status, + ).toBe("archived"); + }); + + it("still archives when one teardown step throws, after attempting it", async () => { + // Each step is best-effort on purpose — a lane must not become + // un-archivable because one watcher refuses to stop. What must hold is + // that every stop is ATTEMPTED before the status write, and that a failure + // is logged rather than swallowed silently. + const events: any[] = []; + const fake = makeFakeServices(); + fake.ptyService.disposeForLane.mockImplementation(() => { + throw new Error("pty teardown exploded"); + }); + const { db, service } = await setupWithLane({ teardown: fake, events }); + + // Individual steps are best-effort, so the archive still completes — what + // must hold is that the stop was ATTEMPTED before the status write. + await service.archive({ laneId: "lane-child" }); + expect(fake.ptyService.disposeForLane).toHaveBeenCalledWith("lane-child"); + expect( + db.get<{ status: string }>("select status from lanes where id = ?", ["lane-child"])?.status, + ).toBe("archived"); + }); + + it("skips teardown for an already-archived lane", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + const { service } = await setupWithLane({ teardown: fake, events }); + await service.archive({ laneId: "lane-child" }); + const callsAfterFirst = [...fake.calls]; + await service.archive({ laneId: "lane-child" }); + expect(fake.calls).toEqual(callsAfterFirst); + }); + it("transfers shared proof ownership so the final owning lane can delete it", async () => { const events: any[] = []; const fake = makeFakeServices(); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index d15efc898..2ab793df8 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -3814,6 +3814,44 @@ export function createLaneService({ throw args.cause instanceof Error ? args.cause : new Error(originalMessage); } + /** + * Stop everything a lane is still running: chat sessions, PTY sessions, file + * watchers, and the rebase machinery watching it. + * + * Shared by `archive` and `archiveAndReclaim` so a step added for one cannot + * be forgotten by the other. `delete` runs the same steps through its own + * `runStep` progress reporting (the delete dialog shows each one), so it + * stays separate on purpose — keep the two in sync when adding a step. + * + * Best-effort per step: a lane must still archive when one watcher refuses + * to stop, and the caller's port-lease release is what actually needs the + * processes gone. + */ + const stopLaneRuntimeWork = async (laneId: string): Promise => { + const warn = (step: string, error: unknown): void => { + logger.warn("lane_runtime_teardown.step_failed", { + laneId, + step, + error: error instanceof Error ? error.message : String(error), + }); + }; + try { + teardownDeps?.autoRebaseService?.cancelForLane(laneId); + } catch (error) { warn("cancel_auto_rebase", error); } + try { + await teardownDeps?.rebaseSuggestionService?.dismiss({ laneId }); + } catch (error) { warn("dismiss_rebase_suggestion", error); } + try { + await teardownDeps?.agentChatService?.disposeForLane(laneId); + } catch (error) { warn("stop_chats", error); } + try { + teardownDeps?.ptyService?.disposeForLane(laneId); + } catch (error) { warn("stop_ptys", error); } + try { + teardownDeps?.fileWatcherService?.stopAllForWorkspace(laneId); + } catch (error) { warn("stop_watchers", error); } + }; + // Named so a few methods (branch-drift resolution) can delegate to sibling // methods instead of duplicating their transaction/rollback handling. const laneServiceApi = { @@ -6268,7 +6306,7 @@ export function createLaneService({ if (await hasSymlinkInManagedPath(packAdeDir, lanePackDir)) { throw new Error("ADE will not reclaim generated data through a symbolic link."); } - laneServiceApi.archive({ laneId: args.laneId }); + await laneServiceApi.archive({ laneId: args.laneId }); runtimeOpts?.onArchived?.(); db.run( `insert into local_lane_storage_state( @@ -6282,11 +6320,7 @@ export function createLaneService({ updated_at = excluded.updated_at`, [args.laneId, projectId, row.worktree_path, risk.reclaimableBytes, now], ); - teardownDeps?.autoRebaseService?.cancelForLane(args.laneId); - await teardownDeps?.rebaseSuggestionService?.dismiss({ laneId: args.laneId }); - await teardownDeps?.agentChatService?.disposeForLane(args.laneId); - teardownDeps?.ptyService?.disposeForLane(args.laneId); - teardownDeps?.fileWatcherService?.stopAllForWorkspace(args.laneId); + await stopLaneRuntimeWork(args.laneId); if (runtimeOpts?.teardownEnv) { try { await runtimeOpts.teardownEnv(); @@ -6417,7 +6451,23 @@ export function createLaneService({ } }, - archive({ laneId }: { laneId: string }): void { + /** + * Archive a lane, stopping its runtime work first. + * + * The stop is not cosmetic and its ORDER is the point. Every caller + * releases the lane's port lease and proxy route immediately after this + * resolves (`releaseLaneRuntimeResources`). Archive used to be a bare + * status write, so those resources were handed back while the lane's dev + * servers and agent processes were still bound to the ports — the lease + * could then be reassigned to another lane that could not bind, and the + * abandoned processes went on holding memory and ports indefinitely + * because the archived lane is filtered out of every surface that could + * have shown them to the user. + * + * Async for that reason, and the same teardown steps `delete` and + * `archiveAndReclaim` already run — one list, not three. + */ + async archive({ laneId }: { laneId: string }): Promise { const row = getLaneRow(laneId); if (!row) throw new Error(`Lane not found: ${laneId}`); if (row.lane_type === "primary") { @@ -6437,6 +6487,10 @@ export function createLaneService({ throw new Error("Cannot archive a lane that is part of a PR group. Remove from the group first."); } + // Before the status write, so a teardown failure leaves the lane visible + // and still owned rather than hidden with live processes behind it. + await stopLaneRuntimeWork(laneId); + const now = new Date().toISOString(); db.run("update lanes set status = 'archived', archived_at = ? where id = ? and project_id = ?", [now, laneId, projectId]); invalidateLanePathCaches(); diff --git a/apps/desktop/src/main/services/prs/prAsync.test.ts b/apps/desktop/src/main/services/prs/prAsync.test.ts index b251299b9..a586a2123 100644 --- a/apps/desktop/src/main/services/prs/prAsync.test.ts +++ b/apps/desktop/src/main/services/prs/prAsync.test.ts @@ -864,6 +864,7 @@ describe("prMergeAutoSettlementService", () => { settledAt: settledSessionIds.has("raw-shell") ? "2026-03-24T12:01:05.000Z" : null, }, ]), + get: vi.fn(() => null), settleSessionsWithOutcome, }) as any, emitEvent, @@ -911,6 +912,7 @@ describe("prMergeAutoSettlementService", () => { expect(settleSessionsWithOutcome).toHaveBeenCalledTimes(2); }); + it("does not re-settle after reactivation, but settles for a later PR", async () => { const db = createMemoryDb(); let settled = false; @@ -928,6 +930,7 @@ describe("prMergeAutoSettlementService", () => { archivedAt: null, settledAt: settled ? "2026-03-24T12:01:05.000Z" : null, }]), + get: vi.fn(() => null), settleSessionsWithOutcome, }) as any, emitEvent: vi.fn(), @@ -1009,6 +1012,7 @@ describe("prMergeAutoSettlementService", () => { archivedAt: null, settledAt: null, }]), + get: vi.fn(() => null), settleSessionsWithOutcome, }) as any, emitEvent: vi.fn(), @@ -1081,6 +1085,7 @@ describe("prMergeAutoSettlementService", () => { { laneId: "lane-1", id: "chat-owned", toolType: "codex-chat", archivedAt: null, settledAt: null }, { laneId: "lane-1", id: "chat-other", toolType: "codex-chat", archivedAt: null, settledAt: null }, ]), + get: vi.fn(() => null), settleSessionsWithOutcome, }) as any, emitEvent: vi.fn(), @@ -1124,6 +1129,7 @@ describe("prMergeAutoSettlementService", () => { archivedAt: null, settledAt: null, }]), + get: vi.fn(() => null), settleSessionsWithOutcome, }) as any, emitEvent, @@ -1187,6 +1193,7 @@ describe("prMergeAutoSettlementService", () => { archivedAt: null, settledAt: null, }]), + get: vi.fn(() => null), settleSessionsWithOutcome, }) as any, emitEvent: vi.fn(), diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 4268c1167..1374ae34f 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -10443,7 +10443,7 @@ export function createPrService({ continue; } try { - laneService.archive({ laneId }); + await laneService.archive({ laneId }); archivedLaneIds.push(laneId); } catch { skippedLaneIds.push(laneId); diff --git a/apps/desktop/src/main/services/sessions/chatSessionProjection.ts b/apps/desktop/src/main/services/sessions/chatSessionProjection.ts index 03d2e4add..bca547e1e 100644 --- a/apps/desktop/src/main/services/sessions/chatSessionProjection.ts +++ b/apps/desktop/src/main/services/sessions/chatSessionProjection.ts @@ -77,6 +77,7 @@ export function projectChatOntoSession( nextWakeAt: chat.nextWakeAt, chatActivityMode: chat.interactionMode === "plan" ? "planning" : null, activeBackgroundTaskCount: chat.activeBackgroundTaskCount ?? 0, + ...(chat.backgroundWork ? { backgroundWork: chat.backgroundWork } : {}), ...(chat.claudeTag !== undefined ? { claudeTag: chat.claudeTag } : {}), ...(chat.orchestrationRunId ? { diff --git a/apps/desktop/src/main/services/sessions/deleteTerminalSession.test.ts b/apps/desktop/src/main/services/sessions/deleteTerminalSession.test.ts index 62631ef02..32a4e1147 100644 --- a/apps/desktop/src/main/services/sessions/deleteTerminalSession.test.ts +++ b/apps/desktop/src/main/services/sessions/deleteTerminalSession.test.ts @@ -4,6 +4,9 @@ import { deleteTerminalSessionWithRuntimeCleanup } from "./deleteTerminalSession import type { createPtyService } from "../pty/ptyService"; import type { createSessionService } from "./sessionService"; + + + function makeSession(overrides: Partial = {}): TerminalSessionSummary { return { id: "session-1", @@ -91,3 +94,4 @@ describe("deleteTerminalSessionWithRuntimeCleanup", () => { })).toThrow("Use the chat delete flow instead."); }); }); + diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index 0e8a978c7..0e759306d 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -66,6 +66,8 @@ afterEach(async () => { }); describe("sessionService resume metadata", () => { + + it("reads terminal scrollback transparently from a compressed log", async () => { const projectRoot = makeProjectRoot("ade-session-service-gzip-"); const db = await openKvDb(path.join(projectRoot, ".ade", "ade.db"), createLogger() as any); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 93ea8d2f5..edec4d682 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -367,6 +367,7 @@ function normalizeSessionIds(sessionIds: string[]): string[] { export function createSessionService({ db }: { db: AdeDb }) { const changeListeners = new Set<(event: TerminalSessionChangedEvent) => void>(); + /** * Shared skeleton for the single-session lifecycle mutators: trim, existence * probe, run the update, broadcast. Keeps every SQL literal at its call site. @@ -1457,7 +1458,7 @@ export function createSessionService({ db }: { db: AdeDb }) { /** Clears a declared settle plus any `'settled'` override. */ unsettleSession(sessionId: string): boolean { - return mutateSessionMeta(sessionId, (id) => { + const changed = mutateSessionMeta(sessionId, (id) => { db.run( ` update terminal_sessions @@ -1469,6 +1470,7 @@ export function createSessionService({ db }: { db: AdeDb }) { [id], ); }); + return changed; }, /** Explicit settle override, cleared with `settled_at` on real activity. */ @@ -1754,7 +1756,7 @@ export function createSessionService({ db }: { db: AdeDb }) { }, clearTurnStartMarkers(sessionId: string): boolean { - return mutateSessionMeta(sessionId, (id) => { + const changed = mutateSessionMeta(sessionId, (id) => { db.run( ` update terminal_sessions @@ -1770,6 +1772,7 @@ export function createSessionService({ db }: { db: AdeDb }) { [id], ); }); + return changed; }, deleteSession(sessionId: string): boolean { diff --git a/apps/desktop/src/main/services/storage/storageInsightsService.test.ts b/apps/desktop/src/main/services/storage/storageInsightsService.test.ts index 2039ac4e4..5c936586b 100644 --- a/apps/desktop/src/main/services/storage/storageInsightsService.test.ts +++ b/apps/desktop/src/main/services/storage/storageInsightsService.test.ts @@ -1007,7 +1007,7 @@ describe("storageMaintenanceJournal", () => { ); }; insertLock("lane-locked", lockedPath, "lock-before-scan"); - const archive = vi.fn(); + const archive = vi.fn(async () => {}); const getReclaimRisk = vi.fn(async (laneId: string) => { if (laneId === "lane-raced") insertLock(laneId, racedPath, "lock-during-scan"); return { @@ -1070,7 +1070,7 @@ describe("storageMaintenanceJournal", () => { const worktreePath = path.join(projectRoot, ".ade", "worktrees", "lane-invalid-activity"); seedLane(db, { id: "lane-invalid-activity", name: "Invalid activity", worktreePath }); db.run("update lanes set created_at = 'not-a-date' where id = ?", ["lane-invalid-activity"]); - const archive = vi.fn(); + const archive = vi.fn(async () => {}); const service = createStorageInsightsService({ projectRoot, adeHome, @@ -1127,7 +1127,7 @@ describe("storageMaintenanceJournal", () => { throw new Error("lane scan failed"); }), getReclaimRisk: vi.fn(), - archive: vi.fn(), + archive: vi.fn(async () => {}), }, projectConfigService: { get: () => ({ @@ -1170,7 +1170,7 @@ describe("storageMaintenanceJournal", () => { worktreePath: retainedPath, archivedAt: "2026-07-01T00:00:00.000Z", }); - const archive = vi.fn(({ laneId }: { laneId: string }) => { + const archive = vi.fn(async ({ laneId }: { laneId: string }) => { db.run( "update lanes set status = 'archived', archived_at = ? where id = ?", [new Date().toISOString(), laneId], diff --git a/apps/desktop/src/main/services/storage/storageInsightsService.ts b/apps/desktop/src/main/services/storage/storageInsightsService.ts index 09fbffdc1..f426ced3d 100644 --- a/apps/desktop/src/main/services/storage/storageInsightsService.ts +++ b/apps/desktop/src/main/services/storage/storageInsightsService.ts @@ -78,7 +78,7 @@ type LaneLifecycleBackend = { activeWatcherCount: number; blockedReasons: Array<{ code: string }>; }>; - archive: (args: { laneId: string }) => void; + archive: (args: { laneId: string }) => Promise; }; type LaneCleanupConfigReader = { @@ -439,7 +439,9 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti if (!candidate.dueByAge && stillNeedsArchive <= 0) continue; try { if (hasActiveLaneWorktreeLock(candidate.laneId)) continue; - laneService.archive({ laneId: candidate.laneId }); + // Await before the lease release: the lane's processes have to be + // gone before their ports are handed back. + await laneService.archive({ laneId: candidate.laneId }); await options.releaseLaneRuntimeResources?.(candidate.laneId); archivedAutomatically += 1; if (stillNeedsArchive > 0) stillNeedsArchive -= 1; diff --git a/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx b/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx index bf8a8e976..43bafc4c7 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx @@ -92,7 +92,6 @@ function resetStore() { activeCount: 0, needsAttentionCount: 0, indicator: "none", - byLaneId: {}, }, } as any); } diff --git a/apps/desktop/src/renderer/components/app/TabNav.test.tsx b/apps/desktop/src/renderer/components/app/TabNav.test.tsx index 83ff16a59..e7934757f 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.test.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.test.tsx @@ -22,7 +22,6 @@ function resetStore() { activeCount: 0, needsAttentionCount: 0, indicator: "none", - byLaneId: {}, }, workViewByProject: {}, laneWorkViewByScope: {}, diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index c8f0c0df2..462ef60cc 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -176,7 +176,6 @@ function resetStore() { activeCount: 0, needsAttentionCount: 0, indicator: "none", - byLaneId: {}, }, closeProject: vi.fn(async () => undefined), openRepo: vi.fn(async () => ({ rootPath: "/Users/arul/ADE", name: "ADE" })), diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 60105a788..aa3e3b523 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -4471,6 +4471,19 @@ export function AgentChatPane({ () => selectedSubagentSnapshots.some((s) => s.background === true && s.status === "running"), [selectedSubagentSnapshots], ); + /** + * Subagents still RUNNING, which is what the toolbar badge counts. + * + * It used to count every tracked subagent, so a fleet that had finished an + * hour ago still wore a "7" — a number that only ever grew and told you + * nothing about now. A badge on a live-activity toggle has to answer "how + * much is happening", and once nothing is running the honest answer is to + * show no badge at all. + */ + const runningSubagentCount = useMemo( + () => selectedSubagentSnapshots.filter((s) => s.status === "running").length, + [selectedSubagentSnapshots], + ); // Auto-clear the subagent view when the underlying snapshot disappears // (e.g. session switch). Updating status is fine and stays in view. useEffect(() => { @@ -11695,8 +11708,8 @@ export function AgentChatPane({ : "Open agents, proof artifacts, and handoff controls for this chat.", effect: [ proofArtifactCount > 0 ? `${proofArtifactCount} artifact${proofArtifactCount === 1 ? "" : "s"}` : null, - selectedSubagentSnapshots.length > 0 - ? `${selectedSubagentSnapshots.length} subagent${selectedSubagentSnapshots.length === 1 ? "" : "s"}` + runningSubagentCount > 0 + ? `${runningSubagentCount} subagent${runningSubagentCount === 1 ? "" : "s"} running` : null, selectedScheduledWorkSnapshots.length > 0 ? `${selectedScheduledWorkSnapshots.length} scheduled` @@ -11737,9 +11750,9 @@ export function AgentChatPane({ {proofArtifactCount} - ) : selectedSubagentSnapshots.length > 0 ? ( + ) : runningSubagentCount > 0 ? ( - {selectedSubagentSnapshots.length} + {runningSubagentCount} ) : selectedScheduledWorkSnapshots.length > 0 ? ( diff --git a/apps/desktop/src/renderer/components/lanes/LaneAgentList.tsx b/apps/desktop/src/renderer/components/lanes/LaneAgentList.tsx index 76c6b83ac..0a6301606 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneAgentList.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneAgentList.tsx @@ -9,17 +9,26 @@ import type { LaneAgent, LaneAgentActivity } from "./laneAgents"; function activityColor(activity: LaneAgentActivity): string { switch (activity) { - case "working": return COLORS.success; + case "working": + case "monitoring": return COLORS.success; case "awaiting-input": return COLORS.warning; case "ended": return COLORS.danger; default: return COLORS.textDim; } } -/** Tiny live pulse — spins while working/awaiting, static dot otherwise. */ +const ACTIVITY_TITLE: Record = { + working: "Working", + monitoring: "Monitoring", + "awaiting-input": "Awaiting input", + idle: "Idle", + ended: "Ended", +}; + +/** Tiny live pulse — spins while live, static dot otherwise. */ function ActivityPulse({ activity }: { activity: LaneAgentActivity }): React.ReactElement { const color = activityColor(activity); - if (activity === "working" || activity === "awaiting-input") { + if (activity === "working" || activity === "monitoring" || activity === "awaiting-input") { return ( ); } @@ -38,7 +50,7 @@ function ActivityPulse({ activity }: { activity: LaneAgentActivity }): React.Rea ); } diff --git a/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts b/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts index db2031b84..b1df2a408 100644 --- a/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts +++ b/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts @@ -45,6 +45,61 @@ function cli(overrides: Partial): TerminalSessionSummary } describe("buildLaneAgents", () => { + it("keeps a resting agent live while its background work is", () => { + // The Lanes agent list read "idle" for the whole of a background fleet's + // run, because the turn had ended and nothing downstream looked at what the + // session still owned. + const [working, monitoring, quiet] = buildLaneAgents( + [ + chat({ + sessionId: "c-working", + status: "idle", + lastOutputPreview: "Turn finished", + activeBackgroundTaskCount: 2, + backgroundWork: { workingCount: 1, monitoringCount: 1 }, + }), + chat({ + sessionId: "c-monitoring", + status: "idle", + lastOutputPreview: "Turn finished", + activeBackgroundTaskCount: 1, + backgroundWork: { workingCount: 0, monitoringCount: 1 }, + }), + chat({ sessionId: "c-quiet", status: "idle", lastOutputPreview: "Turn finished" }), + ], + [], + ); + + // Live rows sort ahead of the genuinely idle one, working ahead of watching. + expect(working.sessionId).toBe("c-working"); + expect(working.activity).toBe("working"); + expect(monitoring.sessionId).toBe("c-monitoring"); + expect(monitoring.activity).toBe("monitoring"); + expect(quiet.activity).toBe("idle"); + + // The hint reports what is still running rather than the finished turn's + // stale preview. + expect(working.lastHint).toBe("2 background jobs still running"); + expect(monitoring.lastHint).toBe("1 monitor still running"); + expect(quiet.lastHint).toBe("Turn finished"); + }); + + it("counts a split-less CLI summary as working, and never over a live turn", () => { + // An older peer or a remote runtime mid-upgrade sends only the total. It + // must not be assumed passive, and a live turn's own output still wins. + const [resting] = buildLaneAgents([], [ + cli({ id: "t-resting", runtimeState: "waiting-input", activeBackgroundTaskCount: 3 }), + ]); + expect(resting.activity).toBe("working"); + expect(resting.lastHint).toBe("3 background jobs still running"); + + const [live] = buildLaneAgents([], [ + cli({ id: "t-live", runtimeState: "running", lastOutputPreview: "compiling", activeBackgroundTaskCount: 3 }), + ]); + expect(live.activity).toBe("working"); + expect(live.lastHint).toBe("compiling"); + }); + it("excludes plain shells", () => { const agents = buildLaneAgents( [], diff --git a/apps/desktop/src/renderer/components/lanes/laneAgents.ts b/apps/desktop/src/renderer/components/lanes/laneAgents.ts index 8bc2c65b6..e9304e23c 100644 --- a/apps/desktop/src/renderer/components/lanes/laneAgents.ts +++ b/apps/desktop/src/renderer/components/lanes/laneAgents.ts @@ -4,11 +4,22 @@ import type { TerminalSessionSummary, TerminalToolType, } from "../../../shared/types"; +import { + backgroundWorkFromSummary, + totalBackgroundWork, + type SessionBackgroundWork, +} from "../../../shared/sessionCanonicalState"; import { listSessionsCached } from "../../lib/sessionListCache"; import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; -/** Unified live state for an agent row, glanceable at a list level. */ -export type LaneAgentActivity = "working" | "awaiting-input" | "idle" | "ended"; +/** + * Unified live state for an agent row, glanceable at a list level. + * + * `monitoring` is a live state, not a calm one — it sorts and pulses with + * `working`. It exists so a lane whose agents are only watching CI reads + * differently from one where three agents are mid-build. + */ +export type LaneAgentActivity = "working" | "monitoring" | "awaiting-input" | "idle" | "ended"; export type LaneAgent = { /** Session id (chat or terminal) — used to open the agent in the Work tab. */ @@ -30,11 +41,25 @@ export type LaneAgent = { /** CLI tool types that are agents (not plain shells). */ const SHELL_TOOL_TYPES = new Set(["shell"]); +/** + * A session whose turn is over but whose background jobs are not is still a + * live agent. Without this the Lanes list showed nothing at all while a + * background fleet ran — the row read "idle" for the whole of it. + */ +function backgroundActivity(summary: { + backgroundWork?: SessionBackgroundWork; + activeBackgroundTaskCount?: number; +}): LaneAgentActivity | null { + const work = backgroundWorkFromSummary(summary); + if (totalBackgroundWork(work) <= 0) return null; + return (work?.workingCount ?? 0) > 0 ? "working" : "monitoring"; +} + function chatActivity(summary: AgentChatSessionSummary): LaneAgentActivity { if (summary.status === "ended") return "ended"; if (summary.awaitingInput) return "awaiting-input"; if (summary.status === "active") return "working"; - return "idle"; + return backgroundActivity(summary) ?? "idle"; } function cliActivity(summary: TerminalSessionSummary): LaneAgentActivity { @@ -45,10 +70,10 @@ function cliActivity(summary: TerminalSessionSummary): LaneAgentActivity { ) return "awaiting-input"; switch (summary.runtimeState) { case "running": return "working"; - case "waiting-input": return "idle"; + case "waiting-input": return backgroundActivity(summary) ?? "idle"; case "exited": case "killed": return "ended"; - default: return "idle"; + default: return backgroundActivity(summary) ?? "idle"; } } @@ -77,11 +102,40 @@ function chatAgentFrom(summary: AgentChatSessionSummary): LaneAgent { activity: chatActivity(summary), lastHint: summary.awaitingInput ? "Awaiting your input" - : summary.summary?.trim() || summary.lastOutputPreview?.trim() || null, + : backgroundHint(summary, summary.status === "active") + ?? summary.summary?.trim() + ?? summary.lastOutputPreview?.trim() + ?? null, lastActivityAt: summary.lastActivityAt ?? summary.startedAt, }; } +/** + * Background work is the more useful hint than a stale last-output preview: + * the preview describes the turn that already ended, the count describes what + * is still running. + * + * Callers pass `turnActive` explicitly rather than having this re-read a status + * field — chat and CLI summaries spell "a turn is running" differently + * (`status: "active"` vs `runtimeState: "running"`), and a single stringly-typed + * guard here would silently match neither for one of them. + */ +function backgroundHint( + summary: { + backgroundWork?: SessionBackgroundWork; + activeBackgroundTaskCount?: number; + }, + turnActive: boolean, +): string | null { + // A live turn's own output is the better story than a job count. + if (turnActive) return null; + const work = backgroundWorkFromSummary(summary); + const total = totalBackgroundWork(work); + if (total <= 0) return null; + const noun = (work?.workingCount ?? 0) > 0 ? "background job" : "monitor"; + return `${total} ${noun}${total === 1 ? "" : "s"} still running`; +} + function cliAgentFrom(summary: TerminalSessionSummary): LaneAgent { return { sessionId: summary.id, @@ -96,7 +150,10 @@ function cliAgentFrom(summary: TerminalSessionSummary): LaneAgent { || summary.attentionRequestedAt || summary.attentionSource === "provider_structured" ? "Awaiting your input" - : summary.summary?.trim() || summary.lastOutputPreview?.trim() || null, + : backgroundHint(summary, summary.runtimeState === "running") + ?? summary.summary?.trim() + ?? summary.lastOutputPreview?.trim() + ?? null, lastActivityAt: summary.endedAt ?? summary.startedAt, }; } @@ -132,9 +189,10 @@ export function buildLaneAgents( // most recently active first. const rank: Record = { working: 0, - "awaiting-input": 1, - idle: 2, - ended: 3, + monitoring: 1, + "awaiting-input": 2, + idle: 3, + ended: 4, }; return agents.sort((a, b) => { if (rank[a.activity] !== rank[b.activity]) return rank[a.activity] - rank[b.activity]; diff --git a/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx b/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx index a23b4e850..3a8303edf 100644 --- a/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx +++ b/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx @@ -53,7 +53,6 @@ function resetStore() { activeCount: 0, needsAttentionCount: 0, indicator: "none", - byLaneId: {}, }, workViewByProject: {}, laneWorkViewByScope: {}, diff --git a/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.test.tsx b/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.test.tsx index 3c638569f..4b0ccea1f 100644 --- a/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.test.tsx +++ b/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.test.tsx @@ -19,7 +19,6 @@ const summarizeTerminalAttention = vi.fn(() => ({ activeCount: 0, needsAttentionCount: 0, indicator: "none" as const, - byLaneId: {}, })); vi.mock("../lib/sessionListCache", () => ({ @@ -96,7 +95,6 @@ beforeEach(() => { activeCount: 0, needsAttentionCount: 2, indicator: "none" as const, - byLaneId: {}, }); Object.defineProperty(window, "ade", { configurable: true, diff --git a/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts b/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts index b41e0d0f8..e32e4ebd2 100644 --- a/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts +++ b/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts @@ -19,7 +19,6 @@ const EMPTY_TERMINAL_ATTENTION = { activeCount: 0, needsAttentionCount: 0, indicator: "none" as const, - byLaneId: {}, }; /** diff --git a/apps/desktop/src/renderer/lib/terminalAttention.test.ts b/apps/desktop/src/renderer/lib/terminalAttention.test.ts index f1bcafb3f..61245860c 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.test.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.test.ts @@ -6,6 +6,7 @@ import { sessionStatusBucket, sessionStatusDisplay, sessionStatusDot, + summarizeTerminalAttention, } from "./terminalAttention"; describe("terminalAttention", () => { @@ -301,6 +302,48 @@ describe("terminalAttention", () => { expect(presentation?.showsElapsed).toBe(true); }); + it("says Monitoring when watch loops are the only live work", () => { + const presentation = sessionStatusDisplay({ + status: "running", + runtimeState: "idle", + toolType: "claude-chat", + lastOutputPreview: "Foreground turn complete", + activeBackgroundTaskCount: 2, + backgroundWork: { workingCount: 0, monitoringCount: 2 }, + }); + + // "Monitoring" answers a different question than "Background work": + // a watch loop will not finish on its own, so the row is telling you it + // is safe to walk away. + expect(presentation).toMatchObject({ + label: "Monitoring ×2", + tone: "blue", + glyph: "monitoring", + }); + }); + + it("keeps a live turn's Planning label off a background-promoted row", () => { + const planningTurn = sessionStatusDisplay({ + status: "running", + runtimeState: "running", + toolType: "claude-chat", + lastOutputPreview: "thinking", + chatActivityMode: "planning", + }); + expect(planningTurn?.label).toBe("Planning"); + + // The turn is over; plan mode was how it ran, not what is happening now. + const backgroundOnly = sessionStatusDisplay({ + status: "running", + runtimeState: "idle", + toolType: "claude-chat", + lastOutputPreview: "Plan delivered", + chatActivityMode: "planning", + backgroundWork: { workingCount: 1, monitoringCount: 0 }, + }); + expect(backgroundOnly?.label).toBe("Background work"); + }); + it("shows Waiting only for an idle chat with a valid future wake", () => { const base = { status: "running" as const, @@ -353,3 +396,40 @@ describe("terminalAttention", () => { }); }); }); + +describe("summarizeTerminalAttention", () => { + const base = { + id: "s-1", + laneId: "lane-1", + status: "running" as const, + runtimeState: "idle" as const, + toolType: "claude-chat" as const, + lastOutputPreview: "Foreground turn complete", + startedAt: "2026-08-01T10:00:00.000Z", + }; + + it("counts a session whose only live work is in the background", () => { + // The Work-tab dot, the TopBar rollup and the dock badge all read this + // rollup. Before background work reached the canonical phase they showed + // nothing at all while agents were mid-run. + const quiet = summarizeTerminalAttention([base as never]); + expect(quiet.runningCount).toBe(0); + expect(quiet.indicator).toBe("none"); + + const busy = summarizeTerminalAttention([ + { ...base, backgroundWork: { workingCount: 2, monitoringCount: 0 }, activeBackgroundTaskCount: 2 } as never, + ]); + expect(busy.runningCount).toBe(1); + expect(busy.activeCount).toBe(1); + expect(busy.needsAttentionCount).toBe(0); + expect(busy.indicator).toBe("running-active"); + }); + + it("counts a monitoring-only session as running, not as needing you", () => { + const summary = summarizeTerminalAttention([ + { ...base, backgroundWork: { workingCount: 0, monitoringCount: 1 }, activeBackgroundTaskCount: 1 } as never, + ]); + expect(summary.runningCount).toBe(1); + expect(summary.needsAttentionCount).toBe(0); + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminalAttention.ts b/apps/desktop/src/renderer/lib/terminalAttention.ts index 12669231a..5ba68766d 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.ts @@ -1,9 +1,11 @@ import type { SessionSettleOverride, TerminalRuntimeState, TerminalSessionStatus, TerminalSessionSummary, TerminalToolType } from "../../shared/types"; import { + backgroundWorkFromSummary, canonicalSessionState, canonicalStatusBucket, isSessionFiledAsSnoozed, type CanonicalSessionState, + type SessionBackgroundWork, type SessionBadge, } from "../../shared/sessionCanonicalState"; import { @@ -20,19 +22,22 @@ export type SessionStatusFilter = "all" | "running" | "awaiting-input" | "ended" export type SessionStatusBucket = Exclude; export type SessionFilingBucket = SessionStatusBucket | "snoozed"; -export type LaneTerminalAttentionSummary = { - runningCount: number; - activeCount: number; - needsAttentionCount: number; - indicator: TerminalRunIndicatorState; -}; - +/** + * App-wide rollup for the Work tab indicator and the dock badge. + * + * Deliberately has NO per-lane breakdown. It used to carry a `byLaneId` map + * that nothing ever read: the Lanes tab gets its per-lane rollup from + * `laneListSnapshotService.summarizeLaneRuntime` in the main process, which is + * also what the synced lane list and mobile use. Keeping a second, unread + * derivation here meant two answers to "is this lane busy" that could disagree + * the moment either moved — so the unread one is gone rather than wired up, and + * the main-process rollup stays the single per-lane source. + */ export type TerminalAttentionSummary = { runningCount: number; activeCount: number; needsAttentionCount: number; indicator: TerminalRunIndicatorState; - byLaneId: Record; }; const OSC_REGEX = /\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g; @@ -143,6 +148,7 @@ type SessionCanonicalUiInput = { nextWakeAt?: string | null; chatActivityMode?: TerminalSessionSummary["chatActivityMode"]; activeBackgroundTaskCount?: number; + backgroundWork?: SessionBackgroundWork | null; nowMs?: number; }; @@ -168,6 +174,7 @@ export function canonicalInputFromSummary(session: TerminalSessionSummary): Sess nextWakeAt: session.nextWakeAt, chatActivityMode: session.chatActivityMode, activeBackgroundTaskCount: session.activeBackgroundTaskCount, + backgroundWork: backgroundWorkFromSummary(session), }; } @@ -185,6 +192,7 @@ export function sessionCanonicalUiState(session: SessionCanonicalUiInput): Canon settleOverride: session.settleOverride ?? null, attentionRequestedAt: session.attentionRequestedAt ?? null, lastTurnFailedAt: session.lastTurnFailedAt ?? null, + backgroundWork: backgroundWorkFromSummary(session), nowMs: session.nowMs, isChatTool: isChatToolType, }); @@ -220,10 +228,11 @@ export function sessionStatusDisplay( session: SessionCanonicalUiInput, overlay: SessionStatusOverlay = {}, ): SessionStatusPresentation | null { - const phase = sessionCanonicalUiState(session).phase; - return sessionStatusPresentation(phase, overlay, { + const state = sessionCanonicalUiState(session); + return sessionStatusPresentation(state.phase, overlay, { chatActivityMode: session.chatActivityMode, - activeBackgroundTaskCount: session.activeBackgroundTaskCount, + liveness: state.liveness, + backgroundWork: backgroundWorkFromSummary(session), nextWakeAt: session.nextWakeAt, nowMs: session.nowMs, }); @@ -298,7 +307,8 @@ export function sessionStatusDot( session: SessionCanonicalUiInput, overlay: SessionStatusOverlay = {}, ): SessionStatusDot { - const phase = sessionCanonicalUiState(session).phase; + const state = sessionCanonicalUiState(session); + const phase = state.phase; if (phase === "settled") { return { cls: "rounded-full border border-white/35 bg-transparent", @@ -306,7 +316,13 @@ export function sessionStatusDot( label: "Settled", }; } - const presentation = sessionStatusPresentation(phase, overlay); + // The dot's label is its tooltip, so it takes the same activity context as + // the full status slot — otherwise a monitoring row's dot reads "Working". + const presentation = sessionStatusPresentation(phase, overlay, { + chatActivityMode: session.chatActivityMode, + liveness: state.liveness, + backgroundWork: backgroundWorkFromSummary(session), + }); if (presentation) { return { cls: `rounded-full ${SESSION_TONE_DOT_CLASS[presentation.tone]}`, @@ -343,37 +359,25 @@ function legacySessionStatusDot(phase: CanonicalSessionPhase): SessionStatusDot * Rollup that feeds the Work tab indicator and dock badge. needsAttention is * the LOUD tier only (canonical needs_you) — a merely resting chat no longer * lights the tab amber; only a deterministic ask does. + * + * A session whose turn ended but whose background work is still live projects + * to `running` (see `canonicalSessionState`), so it counts here without any + * special case — which is the whole point of promoting background work into + * the phase rather than only into the row's label. */ export function summarizeTerminalAttention(sessions: TerminalSessionSummary[]): TerminalAttentionSummary { let runningCount = 0; let activeCount = 0; let needsAttentionCount = 0; - const byLane: Record = {}; for (const session of sessions) { const phase = sessionCanonicalUiState(canonicalInputFromSummary(session)).phase; const isLoud = phase === "needs_you"; const isWorking = phase === "starting" || phase === "running" || phase === "stale"; if (!isLoud && !isWorking) continue; - const lane = byLane[session.laneId] ?? { runningCount: 0, activeCount: 0, needsAttentionCount: 0 }; - lane.runningCount += 1; runningCount += 1; - if (isLoud) { - lane.needsAttentionCount += 1; - needsAttentionCount += 1; - } else { - lane.activeCount += 1; - activeCount += 1; - } - byLane[session.laneId] = lane; - } - - const byLaneId: Record = {}; - for (const [laneId, lane] of Object.entries(byLane)) { - byLaneId[laneId] = { - ...lane, - indicator: indicatorFromCounts(lane.runningCount, lane.needsAttentionCount) - }; + if (isLoud) needsAttentionCount += 1; + else activeCount += 1; } return { @@ -381,6 +385,5 @@ export function summarizeTerminalAttention(sessions: TerminalSessionSummary[]): activeCount, needsAttentionCount, indicator: indicatorFromCounts(runningCount, needsAttentionCount), - byLaneId }; } diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index 2f0013896..98c01e4c7 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -1001,7 +1001,6 @@ describe("appStore", () => { activeCount: 1, needsAttentionCount: 2, indicator: "running-needs-attention" as const, - byLaneId: {}, }; useAppStore.getState().setTerminalAttention(snapshot); expect(useAppStore.getState().terminalAttention).toEqual(snapshot); diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 54fe86745..12bc6bfb0 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -216,12 +216,6 @@ export type TerminalAttentionSnapshot = { activeCount: number; needsAttentionCount: number; indicator: TerminalAttentionIndicator; - byLaneId: Record; }; const EMPTY_TERMINAL_ATTENTION: TerminalAttentionSnapshot = { @@ -229,7 +223,6 @@ const EMPTY_TERMINAL_ATTENTION: TerminalAttentionSnapshot = { activeCount: 0, needsAttentionCount: 0, indicator: "none", - byLaneId: {} }; /** diff --git a/apps/desktop/src/shared/sessionCanonicalState.test.ts b/apps/desktop/src/shared/sessionCanonicalState.test.ts index 710f06faa..dcf1bf2ed 100644 --- a/apps/desktop/src/shared/sessionCanonicalState.test.ts +++ b/apps/desktop/src/shared/sessionCanonicalState.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from "vitest"; import { parseSessionSettleOverride } from "./types/sessions"; import { + backgroundWorkFromSummary, canonicalSessionState, + classifyBackgroundWorkKind, isSessionFiledAsSnoozed, isSessionSnoozed, isSessionSnoozeExpired, isWakingSessionError, resolveSessionWakeReason, + summarizeBackgroundWork, SESSION_STALE_AFTER_MS, type CanonicalSessionInputs, } from "./sessionCanonicalState"; @@ -290,3 +293,146 @@ describe("parseSessionSettleOverride: a typo must not silently clear a pin", () expect(parseSessionSettleOverride("Clear")).toBeNull(); }); }); + +describe("background work liveness", () => { + const working = { workingCount: 1, monitoringCount: 0 }; + const monitoring = { workingCount: 0, monitoringCount: 2 }; + + it("promotes a resting chat with live background work back to running", () => { + // Before this, a Claude chat whose turn ended while its background agents + // kept going read `ready`, so the Work dot, TopBar rollup and dock badge + // all showed nothing while the agents were mid-run. + const resting = state({ toolType: "claude-chat", runtimeState: "idle" }); + expect(resting.phase).toBe("ready"); + expect(resting.liveness).toBeNull(); + + const promoted = state({ toolType: "claude-chat", runtimeState: "idle", backgroundWork: working }); + expect(promoted.phase).toBe("running"); + expect(promoted.liveness).toBe("background"); + }); + + it("promotes a resting CLI session too, not just chats", () => { + const promoted = state({ toolType: "codex", runtimeState: "idle", backgroundWork: working }); + expect(promoted.phase).toBe("running"); + expect(promoted.liveness).toBe("background"); + }); + + it("reads monitoring only when watch loops are the sole live work", () => { + expect(state({ runtimeState: "idle", backgroundWork: monitoring }).liveness).toBe("monitoring"); + // One real job alongside monitors is still "working" — the loudest live + // commitment wins, never the quietest. + expect( + state({ runtimeState: "idle", backgroundWork: { workingCount: 1, monitoringCount: 3 } }).liveness, + ).toBe("background"); + }); + + it("marks a genuinely live turn as turn liveness", () => { + expect(state({ runtimeState: "running" }).liveness).toBe("turn"); + }); + + it("never lets lingering background work mask a failure", () => { + // A failed turn with an orphaned monitor still ticking must read Failed. + expect( + state({ toolType: "claude-chat", runtimeState: "idle", lastTurnFailedAt: new Date(NOW).toISOString(), backgroundWork: working }).phase, + ).toBe("failed"); + expect( + state({ status: "completed", exitCode: 1, backgroundWork: working }).phase, + ).toBe("failed"); + expect( + state({ status: "completed", runtimeState: "killed", backgroundWork: working }).phase, + ).toBe("failed"); + }); + + it("never lets background work mask a raised hand or a declared settle", () => { + expect(state({ pendingInputItemId: "i-1", runtimeState: "idle", backgroundWork: working }).phase).toBe("needs_you"); + const settledWithWork = state({ + runtimeState: "idle", + settledAt: new Date(NOW).toISOString(), + backgroundWork: working, + }); + expect(settledWithWork.phase).toBe("settled"); + // The phase holds — a declared settle is the user's call — but liveness + // still reports the live work, because settle does not stop it today and a + // settled row that silently owns a running shell is the same lie at the + // other end of the lifecycle. + expect(settledWithWork.liveness).toBe("background"); + }); + + it("leaves a silent session stale rather than claiming it is working", () => { + const silentSince = new Date(NOW - SESSION_STALE_AFTER_MS - 1_000).toISOString(); + expect(state({ lastActivityAt: silentSince, backgroundWork: working }).phase).toBe("stale"); + }); + + it("ignores an empty or absent background-work record", () => { + expect(state({ runtimeState: "idle", backgroundWork: null }).phase).toBe("idle"); + expect( + state({ runtimeState: "idle", backgroundWork: { workingCount: 0, monitoringCount: 0 } }).phase, + ).toBe("idle"); + }); +}); + +describe("classifyBackgroundWorkKind", () => { + it("treats every unrecognised task type as working", () => { + // The load-bearing property: an allowlist would silently drop a real + // subagent the first time an SDK renamed a task type. + expect(classifyBackgroundWorkKind("some_future_sdk_type")).toBe("working"); + expect(classifyBackgroundWorkKind(undefined)).toBe("working"); + expect(classifyBackgroundWorkKind(null)).toBe("working"); + expect(classifyBackgroundWorkKind(" ")).toBe("working"); + expect(classifyBackgroundWorkKind("subagent")).toBe("working"); + expect(classifyBackgroundWorkKind("local_workflow")).toBe("working"); + }); + + it("classifies only the known-passive types as monitoring", () => { + for (const taskType of ["monitor", "monitor_mcp"]) { + expect(classifyBackgroundWorkKind(taskType)).toBe("monitoring"); + } + expect(classifyBackgroundWorkKind("MONITOR")).toBe("monitoring"); + expect(classifyBackgroundWorkKind(" monitor_mcp ")).toBe("monitoring"); + }); + + it("counts a generic backgrounded shell as working, not monitoring", () => { + // These types mean "the agent backgrounded a command" — a `tail -f` and a + // 20-minute build arrive under the same one. Mixed is unknown, and unknown + // is working; labelling them monitoring told the user nothing was being + // produced while a build was running. + for (const taskType of ["local_bash", "shell", "background", "bash"]) { + expect(classifyBackgroundWorkKind(taskType)).toBe("working"); + } + }); + + it("drops inert types entirely", () => { + expect(classifyBackgroundWorkKind("plan")).toBe("inert"); + expect(classifyBackgroundWorkKind("dream")).toBe("inert"); + expect(summarizeBackgroundWork(["plan", "dream"])).toEqual({ workingCount: 0, monitoringCount: 0 }); + }); + + it("folds a mixed list into the two-state count", () => { + expect(summarizeBackgroundWork(["subagent", "monitor", "local_bash", "plan", null])).toEqual({ + workingCount: 3, + monitoringCount: 1, + }); + }); +}); + +describe("backgroundWorkFromSummary", () => { + it("prefers the explicit split when present", () => { + expect( + backgroundWorkFromSummary({ backgroundWork: { workingCount: 0, monitoringCount: 3 }, activeBackgroundTaskCount: 9 }), + ).toEqual({ workingCount: 0, monitoringCount: 3 }); + }); + + it("counts a split-less payload as working, never as passive", () => { + // An older peer or a remote runtime mid-upgrade sends only the total. + // Assuming those are monitors would under-report live work. + expect(backgroundWorkFromSummary({ activeBackgroundTaskCount: 2 })).toEqual({ + workingCount: 2, + monitoringCount: 0, + }); + }); + + it("returns null when nothing is live", () => { + expect(backgroundWorkFromSummary({})).toBeNull(); + expect(backgroundWorkFromSummary({ activeBackgroundTaskCount: 0 })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/shared/sessionCanonicalState.ts b/apps/desktop/src/shared/sessionCanonicalState.ts index a29a78aa0..9b0f6074a 100644 --- a/apps/desktop/src/shared/sessionCanonicalState.ts +++ b/apps/desktop/src/shared/sessionCanonicalState.ts @@ -40,12 +40,69 @@ export type SessionBadge = { label: string; }; +/** + * WHY a `running` phase is running. The phase alone cannot answer it, and the + * answer changes the copy: "Working 14s" is a live turn, "Background work ×2" + * is a turn that ended while its jobs kept going, and "Monitoring" is a watch + * loop that will never finish on its own. + * + * Deliberately not a phase. Filing, filtering, buckets, and the push wire all + * key off the phase, and every one of them wants these three treated + * identically: work is happening, nothing is asked of you. Splitting the phase + * would have forced a matching split into iOS's `AgentRunPhase`, the roster + * status, and `canonicalStatusBucket` for a distinction only the label cares + * about. + */ +export type SessionLiveness = "turn" | "background" | "monitoring"; + export type CanonicalSessionState = { phase: CanonicalSessionPhase; /** Non-null ONLY for attention states — capsules never render for calm ones. */ badge: SessionBadge | null; + /** Non-null ONLY for `running`. See `SessionLiveness`. */ + liveness: SessionLiveness | null; }; +/** + * Live work a session still owns after its foreground turn bookends — background + * shells, monitors, and subagent fleets that outlive the turn that spawned them. + * + * Split two ways because users need to tell "still building" apart from "just + * watching CI": a `monitoring` row is safe to walk away from, a `working` row + * is not. See `classifyBackgroundWorkKind` for how the split is decided. + */ +export type SessionBackgroundWork = { + /** Jobs doing real work. Anything unrecognised lands here — see the classifier. */ + workingCount: number; + /** Jobs that only watch: monitors, tails, polling loops. */ + monitoringCount: number; +}; + +export function totalBackgroundWork(work: SessionBackgroundWork | null | undefined): number { + if (!work) return 0; + return Math.max(0, work.workingCount) + Math.max(0, work.monitoringCount); +} + +/** + * Read a session summary's background work, tolerating a payload that carries + * only the older total. + * + * `activeBackgroundTaskCount` predates the working/monitoring split and is still + * the field the mobile roster and the push publisher read, so it stays the wire + * total. A summary that arrives with the total but no split (an older peer, a + * remote runtime mid-upgrade) is counted as WORKING — same denylist principle as + * `classifyBackgroundWorkKind`: unclassified is never assumed passive. + */ +export function backgroundWorkFromSummary(summary: { + backgroundWork?: SessionBackgroundWork | null; + activeBackgroundTaskCount?: number | null; +}): SessionBackgroundWork | null { + if (summary.backgroundWork) return summary.backgroundWork; + const total = summary.activeBackgroundTaskCount ?? 0; + if (!Number.isFinite(total) || total <= 0) return null; + return { workingCount: Math.trunc(total), monitoringCount: 0 }; +} + /** * A session still marked running that has produced no output for this long is * "stale" — running but silent. Distinct from the push relay's APNs TTLs @@ -60,6 +117,72 @@ const BADGE_BY_KIND: Record = { stale: { kind: "stale", label: "Stale" }, }; +/** + * Task types that only WATCH. A session whose ONLY live work is on this list + * reads "Monitoring" rather than "Working". + * + * ── Why a denylist, and why it must stay one ──────────────────────────────── + * + * The obvious shape is an allowlist of "these are real subagents". It is also + * the wrong one: provider agent-type names drift with every SDK release, so the + * first unfamiliar name silently drops a genuinely working agent out of the + * count and the row goes quiet while the agent is mid-run — the exact failure + * this whole state exists to prevent. + * + * The rule, therefore: classify only what we KNOW is passive, and treat every + * unrecognised type as working. A new SDK task type shows up as "Working", + * which is at worst slightly over-loud and at best exactly right. Adding a name + * here is a deliberate act with a known job behind it. + * + * ── Why the generic shell types are NOT here ──────────────────────────────── + * + * `local_bash` / `shell` / `background` / `bash` are how a provider reports + * "the agent backgrounded a command". That is a MIXED bag, not a passive one: a + * `tail -f` and a 20-minute `npm run build` arrive under the same type. Listing + * them here labelled every background build "Monitoring" — telling the user + * nothing was being produced while it was. Mixed is unknown, and by the rule + * above unknown is working. Only types whose whole job is to watch belong here. + */ +const MONITOR_TASK_TYPES: ReadonlySet = new Set([ + "monitor", + "monitor_mcp", +]); + +/** + * Types that hold no live work at all — they describe a turn's *thinking*, not + * a process. Counting them would put a "Working" pill on a session whose only + * outstanding item is a plan document. + */ +const INERT_TASK_TYPES: ReadonlySet = new Set(["plan", "dream"]); + +export type BackgroundWorkKind = "working" | "monitoring" | "inert"; + +/** + * The ONE classifier for live background work, shared by every runtime adapter + * so Claude's `local_bash` and Codex's monitor land in the same column. + */ +export function classifyBackgroundWorkKind(taskType: string | null | undefined): BackgroundWorkKind { + const normalized = typeof taskType === "string" ? taskType.trim().toLowerCase() : ""; + if (!normalized) return "working"; + if (INERT_TASK_TYPES.has(normalized)) return "inert"; + if (MONITOR_TASK_TYPES.has(normalized)) return "monitoring"; + return "working"; +} + +/** Fold a list of live task types into the two-state count. */ +export function summarizeBackgroundWork( + taskTypes: Iterable, +): SessionBackgroundWork { + let workingCount = 0; + let monitoringCount = 0; + for (const taskType of taskTypes) { + const kind = classifyBackgroundWorkKind(taskType); + if (kind === "working") workingCount += 1; + else if (kind === "monitoring") monitoringCount += 1; + } + return { workingCount, monitoringCount }; +} + export type CanonicalSessionInputs = { status: TerminalSessionStatus; runtimeState?: TerminalRuntimeState | null; @@ -89,6 +212,16 @@ export type CanonicalSessionInputs = { * so exitCode can't carry this). Cleared when the next turn starts. */ lastTurnFailedAt?: string | null; + /** + * Live background work owned by the session (monitors, background shells, + * subagent fleets still running after the turn bookends). + * + * In-memory and runtime-derived by design: after a restart nothing is live, + * because orphaned background work is not live work. There is deliberately no + * persisted column behind this — a resurrected "Working" pill on a session + * whose process died with the app is worse than no pill at all. + */ + backgroundWork?: SessionBackgroundWork | null; nowMs?: number; /** Chat sessions idle between turns are "ready", not running/ended. */ isChatTool?: (toolType: TerminalToolType | null | undefined) => boolean; @@ -113,12 +246,41 @@ function isSilentPast(lastActivityAt: string | null | undefined, nowMs: number, * 4. failed — non-zero exit / killed / chat turn death, * 5. stale — status running but silent ≥ SESSION_STALE_AFTER_MS, * 6. running, - * 7. resting states — ready (idle chat, quiet "your move"), idle, ended. + * 7. resting states — ready (idle chat, quiet "your move"), idle, ended, + * EXCEPT when the session still owns live background work, which promotes + * the row back to `running` (see `restingPhaseWithBackgroundWork`). + * + * Note where the background-work promotion sits: below failure, below stale. + * A stale "Working" pill must never mask a failed session — so a chat whose + * turn died reads Failed even while its orphaned monitor is still ticking, and + * a session silent past the stale threshold still reads Stale, because "nothing + * has happened in three hours" is the fact worth surfacing regardless of what + * claims to be alive. */ export function canonicalSessionState(args: CanonicalSessionInputs): CanonicalSessionState { const nowMs = args.nowMs ?? Date.now(); const chat = args.isChatTool?.(args.toolType) ?? false; + /** + * A resting session that still owns live background work is not resting. + * Returns the promoted `running` state, or the caller's resting state when + * nothing is live. + */ + const restingPhaseWithBackgroundWork = ( + resting: CanonicalSessionState, + ): CanonicalSessionState => { + const work = args.backgroundWork; + if (totalBackgroundWork(work) <= 0) return resting; + return { + phase: "running", + badge: null, + // Monitoring only when watch loops are the SOLE live work. One real job + // alongside three monitors is still "Working" — the honest summary of a + // session is its loudest live commitment, not its quietest. + liveness: (work?.workingCount ?? 0) > 0 ? "background" : "monitoring", + }; + }; + // 1. Deterministic attention beats everything — including the failure and // stale checks below (an agent explicitly asking is actionable regardless). if ( @@ -126,7 +288,7 @@ export function canonicalSessionState(args: CanonicalSessionInputs): CanonicalSe || args.attentionRequestedAt || args.attentionSource === "provider_structured" ) { - return { phase: "needs_you", badge: BADGE_BY_KIND.needs_you }; + return { phase: "needs_you", badge: BADGE_BY_KIND.needs_you, liveness: null }; } // 2. Declared settle (or a "settled" override). No timestamp math: activity @@ -139,7 +301,29 @@ export function canonicalSessionState(args: CanonicalSessionInputs): CanonicalSe const pinnedActive = args.settleOverride === "active"; const atRest = args.status !== "running" || args.runtimeState === "idle"; if (!pinnedActive && atRest && (args.settleOverride === "settled" || args.settledAt)) { - return { phase: "settled", badge: null }; + // The PHASE stays settled: a declared settle is a human judgment call, and + // re-lighting the row would let a stubborn monitor out-vote the user's + // explicit "this is done". + // + // But `liveness` still reports the truth, because settle does NOT stop + // background work today — archive is the only lifecycle path that stops + // processes. A settled session can therefore legitimately still own a live + // background shell, subagent, or Cursor cloud run, and a surface that wants + // to show "settled, but something is still running" must be able to. The + // phase alone would hide it, which is the exact failure this module exists + // to prevent — just at the other end of the lifecycle. + // + // Making settle stop that work is a separate change; it needs a synchronous + // lifecycle revision teardown can serialize against, not a wrapper around + // this write. See the settle-teardown design doc. + const settledWork = args.backgroundWork; + return { + phase: "settled", + badge: null, + liveness: totalBackgroundWork(settledWork) <= 0 + ? null + : (settledWork?.workingCount ?? 0) > 0 ? "background" : "monitoring", + }; } const ended = args.status !== "running"; @@ -148,20 +332,20 @@ export function canonicalSessionState(args: CanonicalSessionInputs): CanonicalSe // failure. Keep it badge-free and let the session row's red dot carry the // ended state. if (args.status === "disposed") { - return { phase: "stopped", badge: null }; + return { phase: "stopped", badge: null, liveness: null }; } // 4. Failure: a non-clean exit, an explicit "failed" persisted status // (spawn/setup failures that die before an exit code), or a killed // runtime — all deterministic "failed" signals a terminal-backed session // reports. if (typeof args.exitCode === "number" && args.exitCode !== 0) { - return { phase: "failed", badge: BADGE_BY_KIND.failed }; + return { phase: "failed", badge: BADGE_BY_KIND.failed, liveness: null }; } if (args.status === "failed") { - return { phase: "failed", badge: BADGE_BY_KIND.failed }; + return { phase: "failed", badge: BADGE_BY_KIND.failed, liveness: null }; } if (args.runtimeState === "killed") { - return { phase: "failed", badge: BADGE_BY_KIND.failed }; + return { phase: "failed", badge: BADGE_BY_KIND.failed, liveness: null }; } // Chats never "end" like PTYs — they rest between turns. A turn that died // on a runtime/API error is a real failure the row must carry (chats have @@ -170,39 +354,43 @@ export function canonicalSessionState(args: CanonicalSessionInputs): CanonicalSe // is genuinely over — ended, not perpetually "your move". if (chat) { if (args.lastTurnFailedAt) { - return { phase: "failed", badge: BADGE_BY_KIND.failed }; + return { phase: "failed", badge: BADGE_BY_KIND.failed, liveness: null }; } if (args.status === "detached") { - return { phase: "ended", badge: null }; + return { phase: "ended", badge: null, liveness: null }; } - return { phase: "ready", badge: null }; + return restingPhaseWithBackgroundWork({ phase: "ready", badge: null, liveness: null }); } // A clean process exit only says the CLI ended. Settlement is a lifecycle // declaration made by the user (or the lane PR-merge policy), never // inferred from process mechanics. There is no "derived clean-exit settle" // anywhere in ADE — if you find a comment claiming otherwise, it is stale. - return { phase: "ended", badge: null }; + return { phase: "ended", badge: null, liveness: null }; } // Chat rows keep status "running" even when a turn dies — surface the // persisted failure marker ahead of the calm running/ready states. if (chat && args.lastTurnFailedAt) { - return { phase: "failed", badge: BADGE_BY_KIND.failed }; + return { phase: "failed", badge: BADGE_BY_KIND.failed, liveness: null }; } // 6. Stale: running but silent past the threshold. if (isSilentPast(args.lastActivityAt, nowMs, SESSION_STALE_AFTER_MS)) { - return { phase: "stale", badge: BADGE_BY_KIND.stale }; + return { phase: "stale", badge: BADGE_BY_KIND.stale, liveness: null }; } // Idle chats between turns are ready (calm); idle agent CLIs at an // undetected prompt stay actionable via the caller's existing idle rules — // canonical keeps them "idle" (calm) because there is no deterministic ask. if (args.runtimeState === "idle") { - return chat ? { phase: "ready", badge: null } : { phase: "idle", badge: null }; + return restingPhaseWithBackgroundWork( + chat + ? { phase: "ready", badge: null, liveness: null } + : { phase: "idle", badge: null, liveness: null }, + ); } - return { phase: "running", badge: null }; + return { phase: "running", badge: null, liveness: "turn" }; } /** diff --git a/apps/desktop/src/shared/sessionStatusPresentation.ts b/apps/desktop/src/shared/sessionStatusPresentation.ts index a9f386bab..87fa782bb 100644 --- a/apps/desktop/src/shared/sessionStatusPresentation.ts +++ b/apps/desktop/src/shared/sessionStatusPresentation.ts @@ -1,4 +1,9 @@ -import type { CanonicalSessionPhase } from "./sessionCanonicalState"; +import type { + CanonicalSessionPhase, + SessionBackgroundWork, + SessionLiveness, +} from "./sessionCanonicalState"; +import { totalBackgroundWork } from "./sessionCanonicalState"; /** * The ONE presentation vocabulary for "how does a session's state look and @@ -50,6 +55,7 @@ export type SessionStatusTone = "blue" | "violet" | "amber" | "emerald" | "red" */ export type SessionStatusGlyph = | "working" + | "monitoring" | "planning" | "waiting" | "needs-you" @@ -129,11 +135,21 @@ export type SessionStatusOverlay = { export type SessionStatusActivityContext = { chatActivityMode?: "planning" | null; - activeBackgroundTaskCount?: number; + /** + * Why the session is running, from `canonicalSessionState`. Absent (or + * `"turn"`) means a live foreground turn and the plain "Working" copy. + */ + liveness?: SessionLiveness | null; + /** Live background work, for the "×N" suffix. */ + backgroundWork?: SessionBackgroundWork | null; nextWakeAt?: string | null; nowMs?: number; }; +function countSuffix(count: number): string { + return count > 1 ? ` ×${count}` : ""; +} + export function sessionStatusPresentation( phase: CanonicalSessionPhase, overlay: SessionStatusOverlay = {}, @@ -159,7 +175,11 @@ export function sessionStatusPresentation( return { label: "Woke", tone: "amber", glyph: "woke", showsElapsed: false, prominent: true }; } - if (phase === "running" && activity.chatActivityMode === "planning") { + // Planning is a property of a LIVE TURN. A resting session promoted back to + // `running` by its background work is not planning anything — its plan-mode + // flag is just the mode the finished turn ran in. + const liveness = activity.liveness ?? "turn"; + if (phase === "running" && liveness === "turn" && activity.chatActivityMode === "planning") { return { label: "Planning", tone: "violet", @@ -180,10 +200,23 @@ export function sessionStatusPresentation( // not in the session summary. It is a proxy: a job launched early in a long // turn reads ~0s at turn end. `showsElapsed` also re-enables the breathing // animation, which is intended — background work genuinely is a live state. - const backgroundJobCount = activity.activeBackgroundTaskCount ?? 0; - if ((phase === "ready" || phase === "idle") && backgroundJobCount > 0) { + if (phase === "running" && liveness !== "turn") { + const work = activity.backgroundWork; + // "Monitoring" earns its own word because it answers a different question. + // Background work might finish on its own; a watch loop will not, so the + // row is telling you it is safe to walk away — and, once the CI run it is + // watching lands, that it is yours to close. + if (liveness === "monitoring") { + return { + label: `Monitoring${countSuffix(work?.monitoringCount ?? 0)}`, + tone: "blue", + glyph: "monitoring", + showsElapsed: true, + prominent: false, + }; + } return { - label: backgroundJobCount > 1 ? `Background work ×${backgroundJobCount}` : "Background work", + label: `Background work${countSuffix(totalBackgroundWork(work))}`, tone: "blue", glyph: "working", showsElapsed: true, diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index db15915a0..e396360be 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -9,6 +9,7 @@ import type { FileDiff } from "./git"; import type { LaneLinearIssue, SessionLinearIssueLink } from "./lanes"; import type { OrchestrationContextItem, OrchestrationRole } from "./orchestration"; import type { AdeRecoveryErrorCode } from "./recovery"; +import type { SessionBackgroundWork } from "../sessionCanonicalState"; import type { SubagentCapability } from "../subagentCapabilities"; export type AgentChatProvider = "codex" | "claude" | "cursor" | "droid" | "opencode" | "pi" | (string & {}); @@ -1602,6 +1603,8 @@ export type AgentChatSessionSummary = { nextWakeAt: string | null; /** Authoritative provider-reported background tasks still running after the foreground turn. */ activeBackgroundTaskCount?: number; + /** The same live work split into working vs monitoring (`classifyBackgroundWorkKind`). */ + backgroundWork?: SessionBackgroundWork; /** True when this chat's durable schedules are paused. */ scheduledWorkPaused?: boolean; /** KV-backed durable schedules. This is the management source of truth. */ diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index 37ebccc96..b8aed2277 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -13,6 +13,7 @@ import type { } from "./chat"; import type { LaneLinearIssue } from "./lanes"; import type { OrchestrationRole } from "./orchestration"; +import type { SessionBackgroundWork } from "../sessionCanonicalState"; export type TerminalSessionStatus = "running" | "completed" | "failed" | "disposed" | "detached"; @@ -253,6 +254,12 @@ export type TerminalSessionSummary = { chatActivityMode?: "planning" | null; /** Authoritative provider-reported background tasks still running after the foreground turn. */ activeBackgroundTaskCount?: number; + /** + * The same live work split into working vs monitoring + * (`classifyBackgroundWorkKind`). `activeBackgroundTaskCount` stays the total + * so the mobile roster and push publisher keep reading one number. + */ + backgroundWork?: SessionBackgroundWork; /** First tag mirrored from the backing Claude SDK session pointer. */ claudeTag?: string | null; /** Owner session id for attached terminals, historically a parent chat id and now also a tracked CLI session id. */ diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 728592409..57e362067 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -546,9 +546,21 @@ a lane parented to primary would always show zero behind. current branch. When both the parent link and the resolved base ref are unchanged, reparent short-circuits without touching git so a redundant apply is a no-op rather than a stack rebase. -7. **Archive / reclaim / restore** — `archive` sets `archived_at` and - `status = 'archived'` but keeps the worktree and generated files on disk, - then emits a `lane-archived` lifecycle event. `archiveAndReclaim` is the +7. **Archive / reclaim / restore** — `archive` stops the lane's runtime work + (chats, PTYs, file watchers, auto-rebase) through the shared + `stopLaneRuntimeWork` helper, then sets `archived_at` and + `status = 'archived'` while keeping the worktree and generated files on + disk, then emits a `lane-archived` lifecycle event. The stop is `await`ed + and its ORDER is load-bearing: every caller releases the lane's port lease + and proxy route (`releaseLaneRuntimeResources`) the moment archive resolves, + and archive used to be a bare status write — so the lease and route went + back to the pool while the lane's dev servers and agents were still bound to + those ports, and the abandoned processes went on holding them indefinitely + because an archived lane is filtered out of every surface that could have + shown them. Individual stop steps are best-effort (one stuck watcher must not + make a lane un-archivable) and failures are logged as + `lane_runtime_teardown.step_failed`. `delete` runs the same steps through its + own `runStep` progress reporting, since the delete dialog shows each one. `archiveAndReclaim` is the separate, typed-confirmation path: it preserves the lane row, branch, chats, and metadata while stopping lane-owned processes and removing only the ADE-managed worktree and lane pack data. Before sizing or removing a diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index ef1d4837d..bffc77896 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -205,7 +205,18 @@ and in tests. presenting a false live/green agent. - `apps/desktop/src/main/services/sessions/settleTerminalSession.ts` — single settlement transaction shared by direct IPC and the ADE action - registry. Plain settle writes lifecycle state. `dismissPendingInput: true` + registry. Settle writes lifecycle state only — it deliberately does NOT stop + the session's background work. That was attempted and removed: teardown is + async, and `settled_at` is written and cleared from seven places, so a + teardown-then-write settle races real activity (a user starting a turn during + a provider stop call gets their background work stopped AND no settle), and + every guard tried against it either read a column that turn-start never + updates or had to be repeated at each of the settle entry points. Making + settle stop work needs a synchronous lifecycle revision that teardown can be + serialized against; it is not a wrapper around the existing write. Archive is + the one lifecycle path that does stop processes — see + `laneService.archive`, where the ordering is load-bearing. + `dismissPendingInput: true` first quiets an SDK chat through `agentChatService`, or clears a tracked CLI's explicit `ade chat ask` marker through `ptyService`; arbitrary native terminal prompts are rejected because ADE cannot answer them truthfully. @@ -278,6 +289,24 @@ Shared types and IPC: explicit/structured attention → declared settle at rest → stopped/failure/ clean exit → stale/running/resting. It is the source of the one-word row capsule, Work grouping, and the loud-vs-quiet attention split. + **Live background work promotes a resting session back to `running`.** A + session whose foreground turn ended while its background shells, monitors, or + subagent fleet kept going used to project to `ready`/`idle`, so the Work-tab + dot, the TopBar rollup, the dock badge and the Lanes agent list all showed + nothing while agents were mid-run — the "Background work" copy existed but + never reached the phase those surfaces derive from. The promotion also + returns `liveness` (`"turn" | "background" | "monitoring"`), which is what the + label reads; it is not a phase, because filing, buckets, the roster status and + iOS's `AgentRunPhase` all want the three treated identically. + `monitoring` is set only when watch loops are the SOLE live work — one real + job among three monitors still reads as working. + Classification is a **denylist** (`MONITOR_TASK_TYPES` / `INERT_TASK_TYPES`, + via `classifyBackgroundWorkKind`): unknown task types count as WORKING, + because an allowlist silently drops a real subagent the first time a provider + SDK renames a type. Liveness is in-memory and deliberately empty after a + restart — orphaned background work is not live work — and it sits BELOW + failure, stopped, settled and stale in the precedence order, so a lingering + "Working" can never mask a failed session. The **settle override** (`terminal_sessions.settle_override`, `null | "settled" | "active"`) is consulted at the declared-settle tier, i.e. `"settled"` behaves like a declared settle, and `"active"` is an explicit @@ -317,11 +346,13 @@ Shared types and IPC: and Activity surfaces and mirrored by iOS widgets/Activity drawer. Blue means work in flight, amber is reserved exclusively for `Needs you`, emerald is a clean unseen outcome, red is failure, and neutral is true but non-actionable. - An idle or ready session that still has live background jobs is named for what - it is — **Background work**, or **Background work ×N** — rather than reusing - the bare **Working** of a live turn, and it sets `showsElapsed`, so the row - carries a duration instead of an unfalsifiable claim that the model is still - thinking. + A session running only because of background work is named for what it is — + **Background work** / **Background work ×N**, or **Monitoring** / **Monitoring + ×N** when watch loops are all that is left — rather than reusing the bare + **Working** of a live turn, and it sets `showsElapsed`, so the row carries a + duration instead of an unfalsifiable claim that the model is still thinking. + Plan mode is a property of a live turn only: a background-promoted row never + reads **Planning**. It also owns the short working-duration formatter; renderer icon components map its dependency-free glyph ids to platform symbols. - `apps/desktop/src/renderer/lib/sessionSnooze.ts` — the desktop half of snooze