From 3de833d7842559ed8f418c61d589dad6247361af Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 7 Sep 2026 13:36:16 +0800 Subject: [PATCH 1/7] fix(serve): report child-owned session turns as active work --- docs/design/2026-08-06-active-work-health.md | 10 ++++++---- ...2026-08-13-active-work-background-shell.md | 10 +++++----- docs/developers/qwen-serve-protocol.md | 2 +- packages/acp-bridge/src/bridgeTypes.ts | 11 ++++------ .../src/acp-integration/session/Session.ts | 20 +++++++++++++++++++ 5 files changed, 36 insertions(+), 17 deletions(-) diff --git a/docs/design/2026-08-06-active-work-health.md b/docs/design/2026-08-06-active-work-health.md index 6375f75d338..76ba829f140 100644 --- a/docs/design/2026-08-06-active-work-health.md +++ b/docs/design/2026-08-06-active-work-health.md @@ -8,7 +8,7 @@ `GET /health?deep=1` gains three fields: `activeWork`, `activeWorkReporting`, and `activeWorkStaleMs`. -`activeWork` is true while any managed workspace has an accepted-but-unsettled prompt, a running background Agent, an Agent terminal notification that is queued, awaiting acceptance, or being processed by its parent continuation, or Session-managed background shell work. Shell work covers a running registry entry and the terminal notification until its parent continuation settles. It deliberately does **not** cover Monitors, workflows, cron, or external processes the shell registry can no longer track. +`activeWork` is true while any managed workspace has an accepted-but-unsettled prompt, a running background Agent, an Agent terminal notification that is queued, awaiting acceptance, or being processed by its parent continuation, Session-managed background shell or workflow work, or a child-owned Session turn. The aggregate `session` hold covers goal and cron processing, history mutation, and queued or running Monitor continuations. Running Monitors, follow-up suggestions, and external processes the shell registry can no longer track remain outside the field. It is also **Session-scoped, not channel-scoped**. Channel-level work with no Session attached yet — a spawn in flight, a pending restore, MCP discovery or authentication — is not counted, so `activeWork` can read false while the daemon's own `hasNoChannelWork` is simultaneously refusing to reclaim that channel. The two answer different questions and are allowed to disagree: this field describes work owned by Sessions, and widening it to cover channel setup would change what the boolean means for every existing reader. A controller that needs "is this daemon reclaimable" must combine the three-term rule below with a graceful-shutdown handshake, not read more into this one field than it claims. @@ -16,7 +16,7 @@ Restart policy stays with the external controller. The daemon publishes facts; i ## Why holds, and why full snapshots -Each Session reports a set of named **holds**, each carrying a category (`agent`, `notification`, or `shell`). Two properties follow, and both are the point: +Each Session reports a set of named **holds**, each carrying a category (`agent`, `notification`, `shell`, `session`, or `workflow`). Two properties follow, and both are the point: **Holds are derived, never maintained.** `Session.collectActiveWorkHolds()` reads the owners of the work — the background-task registry's unfinalized set, the background-shell registry's running entries, the notification queue, and the in-flight acceptance and continuation state — on every call. There is no acquire/release ledger kept alongside the work, because a ledger can miss a release, and a leaked hold would pin its Session forever while every snapshot faithfully republished the leak. @@ -24,6 +24,8 @@ The agent category uses `BackgroundTaskRegistry.hasUnfinalizedTasks()`'s predica Shells use one aggregate hold, `{ "category": "shell", "id": "background-shells" }`, regardless of the number of running shells. The task registry and `/tasks` surface remain the detailed roster; active-work only needs the bounded retention fact. The aggregate also prevents an unbounded shell roster from exceeding the protocol's per-Session hold limit. +Child-owned turns that are not foreground prompts use one aggregate hold, `{ "category": "session", "id": "session:active-turn" }`. This keeps the conditional-close predicate aligned with the work its drain waits for without exposing feature-specific protocol categories. + **Reports are complete snapshots at channel scope, not per-Session transitions.** One message per ACP channel carries every Session the child owns and every hold it holds: ```json @@ -55,7 +57,7 @@ Per Session the daemon holds one of: Never-reported and gone-quiet are the same state on purpose. A snapshot older than the grading window (`intervalMs × 3`) is not a report that the Session is idle, it is the absence of one — a background Agent could have started at any point since — so it stops counting as evidence. -**Unknown is a reason to ask, not a reason to skip.** The two consumers read it differently, and they have to: the health surface reports unknown as busy (a controller must never mistake "nobody told me" for "nothing is running"), while automatic cleanup treats it as a candidate and goes on to the conditional close below. Only _known_ work — daemon-owned, or a fresh report of held work — blocks the attempt outright. Skipping on unknown instead would look safe and in fact be the worse failure: nothing would ever resolve it, so a Session on a channel that went quiet would be retained forever with no path out. Asking costs one bounded round trip and still retains on any non-answer, and the child can answer authoritatively under its close gate whether or not its snapshots are arriving. Incomplete coverage is different and does skip: a negotiated child that omits `shell` can truthfully answer according to its older predicate while missing a running shell, so its answer cannot authorize automatic destruction. +**Unknown is a reason to ask, not a reason to skip.** The two consumers read it differently, and they have to: the health surface reports unknown as busy (a controller must never mistake "nobody told me" for "nothing is running"), while automatic cleanup treats it as a candidate and goes on to the conditional close below. Only _known_ work — daemon-owned, or a fresh report of held work — blocks the attempt outright. Skipping on unknown instead would look safe and in fact be the worse failure: nothing would ever resolve it, so a Session on a channel that went quiet would be retained forever with no path out. Asking costs one bounded round trip and still retains on any non-answer, and the child can answer authoritatively under its close gate whether or not its snapshots are arriving. Incomplete coverage is different and does skip: a negotiated child that omits `shell` or `session` can truthfully answer according to its older predicate while missing work in that category, so its answer cannot authorize automatic destruction. Reclaiming a channel that has stopped answering entirely is still not this mechanism's job; see below. @@ -66,7 +68,7 @@ qwen/control/session/close { sessionId, onlyIfUnheld: true } → { sessionId, closed: true } | { sessionId, closed: false, holds: [...] } ``` -The child evaluates it under its own close gate, before anything destructive runs. It rejects known holds immediately, drains any turn that was already active when the gate closed, then evaluates the unfiltered collector again. The second read matters because an already-running out-of-scope turn such as cron can register a background shell while it drains. With the gate still held no new turn can start after that final read, so a hold cannot appear between final authorization and teardown **on the child side**. If either read finds holds, the gate is released and they are handed back. The daemon adopts the returned hold set only when it stays within the same 1,024-hold per-Session bound as snapshots; an oversized refusal still retains the Session but does not replace the last valid cache. +The child evaluates it under its own close gate, before anything destructive runs. It rejects known holds immediately, drains any turn that was already active when the gate closed, then evaluates the unfiltered collector again. With the gate still held no new turn can start after that final read, so a hold cannot appear between final authorization and teardown **on the child side**. If either read finds holds, the gate is released and they are handed back. The daemon adopts the returned hold set only when it stays within the same 1,024-hold per-Session bound as snapshots; an oversized refusal still retains the Session but does not replace the last valid cache. The daemon side needs its own cover, because the round trip is an await of up to ten seconds. A Session with a conditional close outstanding is marked in-flight, and every admission path — attach, prompt, rewind — refuses it exactly as it refuses one that is already closing. Without that, a prompt accepted during the round trip is lost when the teardown it raced completes; the previous synchronous guard-then-teardown sequence got this for free, and splitting it is what created the need to say so explicitly. diff --git a/docs/design/2026-08-13-active-work-background-shell.md b/docs/design/2026-08-13-active-work-background-shell.md index 51924d52b94..e6855adea7d 100644 --- a/docs/design/2026-08-13-active-work-background-shell.md +++ b/docs/design/2026-08-13-active-work-background-shell.md @@ -18,12 +18,12 @@ The Session collector remains an unfiltered statement of local truth. Category n ## Negotiation and compatibility -The protocol version remains v1. The daemon initialize request advertises `agent`, `notification`, and `shell`; the child answers with the intersection it supports. A request with no `categories` is the pre-negotiation v1 baseline, `agent` and `notification`. +The protocol version remains v1. The daemon initialize request advertises every current category; the child answers with the intersection it supports. A request with no `categories` is the pre-negotiation v1 baseline, `agent` and `notification`. | Peers | Reporting result | Ordinary automatic cleanup | | ------------------------------------------ | ---------------------------------------- | ----------------------------------------------------- | -| new daemon + new child | `full`; shell hold crosses the wire | existing conditional-close flow | -| new daemon + old v1 child | `partial`; `shell` is missing | disabled for that Session | +| new daemon + new child | `full`; current holds cross the wire | existing conditional-close flow | +| new daemon + old v1 child | `partial`; newer categories are missing | disabled for that Session | | old v1 daemon + new child | wire contains only the legacy categories | local conditional close still rejects a running shell | | daemon + child with no active-work support | `none` | historical legacy cleanup | @@ -37,13 +37,13 @@ At shell completion, the registry invokes the notification callback before publi `Session.isIdle()` consumes the same unfiltered collector. Workspace reload therefore skips a Session while a background shell or its terminal continuation is active. -Conditional close reads the unfiltered collector once before disturbing active turns and again after those turns drain, while the Session close gate remains held. The final read closes the window where an already-running, otherwise out-of-scope cron or automatic turn registers a shell during drain; the new shell refuses ordinary teardown without adding cron itself to `activeWork`. +Conditional close reads the unfiltered collector once before disturbing active turns and again after those turns drain, while the Session close gate remains held. Child-owned cron, goal, history-mutation, and Monitor-continuation work is now represented by the aggregate `session` category, so it refuses ordinary teardown before the drain begins. ## Boundaries This change tracks the logical lifecycle owned by `BackgroundShellRegistry`; it does not use PID probes or sidecars to reconstruct process liveness. `task_stop` follows the registry's terminal status and does not promise an additional OS-level exit confirmation. A promoted or externally detached process that the registry no longer tracks is outside the signal. -Long-running development servers consequently keep `activeWork: true`. This is the intended retention fact, not shell-stall detection or a restart lease. Monitor, workflow, cron, and follow-up work remain out of scope, and the public health shape, persistence formats, shell admission policy, heartbeat behavior, and watchdog behavior do not change. +Long-running development servers consequently keep `activeWork: true`. This is the intended retention fact, not shell-stall detection or a restart lease. Running Monitors, follow-up work, and external processes remain out of scope, and the public health shape, persistence formats, shell admission policy, heartbeat behavior, and watchdog behavior do not change. ## Verification diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 90544fbf787..bd49eae5cbb 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -636,7 +636,7 @@ Pass `?deep=1` (also accepts `?deep=true` or bare `?deep`) for a daemon-wide pro } ``` -`sessions`, `pendingPermissions`, and `activePrompts` are sums. `activeWork` is true when any runtime has an accepted but unsettled prompt (including a FIFO-waiting prompt), a running background Agent, a queued/in-progress Agent terminal notification, or Session-managed background shell work. Shell work remains active while the shell registry reports a running entry and while its terminal notification is queued or driving the parent continuation; any number of shells contributes one bounded aggregate hold. Monitors, workflows, cron jobs, follow-up suggestions, and external processes the shell registry can no longer track remain outside the field. It is session-scoped: channel-level work with no session attached yet — a spawn in flight, a pending restore, MCP discovery or authentication — is not counted, so `activeWork` may read false while the daemon still declines to reclaim that channel. Do not read this field as "the daemon is reclaimable"; it describes session-owned work only. `activeWorkReporting` says how much of that boolean is actually vouched for: `full` when every live session is covered by a fresh report from a child that reports all required categories, `none` when no session negotiated reporting, and `partial` for anything between — including a stale snapshot or a negotiated child that omits a required category. A snapshot older than three report intervals stops counting as coverage: it is not a report that the session is idle, so the session goes back to reading as retained, exactly as if the child had never reported. Ordinary automatic cleanup is also disabled for a negotiated-but-incomplete child; a child that does not understand `shell` cannot safely authorize conditional close according to the complete current predicate. Completely unsupported historical children retain legacy cleanup behavior, and explicit close, kill, shutdown, and channel exit remain force operations. `activeWorkStaleMs` is the age of the oldest snapshot the boolean rests on **among the covered sessions**, and is `0` when no session is covered; it is diagnostic, because freshness is already graded into `activeWorkReporting` by the daemon (only the daemon knows each channel's negotiated cadence). The grade is computed once over every managed runtime rather than per runtime and then combined — a runtime with no sessions is vacuously complete, and treating that as evidence would let an empty workspace vouch for another workspace's unreported sessions. `lastActivityAt` is the latest non-null workspace activity time and `idleSinceMs` is derived from that same snapshot. `channelAlive` means at least one managed workspace channel is live; it does not mean every workspace is healthy. `connectedClients` and the optional `rateLimitHits` remain daemon-wide counters rather than per-workspace sums. +`sessions`, `pendingPermissions`, and `activePrompts` are sums. `activeWork` is true when any runtime has an accepted but unsettled prompt (including a FIFO-waiting prompt), a running background Agent, a queued/in-progress Agent terminal notification, Session-managed background shell or workflow work, or a child-owned Session turn. The aggregate `session` hold covers goal and cron processing, history mutation, and queued or running Monitor continuations; foreground prompts remain daemon-owned. Running Monitors, follow-up suggestions, and external processes the shell registry can no longer track remain outside the field. It is session-scoped: channel-level work with no session attached yet — a spawn in flight, a pending restore, MCP discovery or authentication — is not counted, so `activeWork` may read false while the daemon still declines to reclaim that channel. Do not read this field as "the daemon is reclaimable"; it describes session-owned work only. `activeWorkReporting` says how much of that boolean is actually vouched for: `full` when every live session is covered by a fresh report from a child that reports all required categories, `none` when no session negotiated reporting, and `partial` for anything between — including a stale snapshot or a negotiated child that omits a required category. A snapshot older than three report intervals stops counting as coverage: it is not a report that the session is idle, so the session goes back to reading as retained, exactly as if the child had never reported. Ordinary automatic cleanup is also disabled for a negotiated-but-incomplete child; a child that does not understand `shell` or `session` cannot safely authorize conditional close according to the complete current predicate. Completely unsupported historical children retain legacy cleanup behavior, and explicit close, kill, shutdown, and channel exit remain force operations. `activeWorkStaleMs` is the age of the oldest snapshot the boolean rests on **among the covered sessions**, and is `0` when no session is covered; it is diagnostic, because freshness is already graded into `activeWorkReporting` by the daemon (only the daemon knows each channel's negotiated cadence). The grade is computed once over every managed runtime rather than per runtime and then combined — a runtime with no sessions is vacuously complete, and treating that as evidence would let an empty workspace vouch for another workspace's unreported sessions. `lastActivityAt` is the latest non-null workspace activity time and `idleSinceMs` is derived from that same snapshot. `channelAlive` means at least one managed workspace channel is live; it does not mean every workspace is healthy. `connectedClients` and the optional `rateLimitHits` remain daemon-wide counters rather than per-workspace sums. Restart controllers should treat the daemon as busy when: diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 084f15e8979..6b4ed18043e 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -376,14 +376,14 @@ export const ACTIVE_WORK_MAX_SESSION_HOLDS = 1024; export const WORKTREE_MCP_DEFER_META_KEY = 'qwen.session.deferMcpDiscovery'; /** - * Work categories a child reports holds for. Monitors and cron remain outside - * `activeWork`'s declared scope. The category travels on every + * Work categories a child reports holds for. The category travels on every * hold so peers can negotiate coverage explicitly when the scope widens. */ export type ActiveWorkHoldCategory = | 'agent' | 'notification' | 'shell' + | 'session' | 'workflow'; /** Categories understood by active-work v1 before category negotiation was @@ -395,6 +395,7 @@ export const ACTIVE_WORK_HOLD_CATEGORIES: readonly ActiveWorkHoldCategory[] = [ 'agent', 'notification', 'shell', + 'session', 'workflow', ]; @@ -2467,11 +2468,7 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { /** Number of sessions with an active prompt. */ readonly activePromptCount: number; - /** - * Whether an accepted prompt, a running background Agent, an Agent terminal - * notification, or Session-managed background shell work is unsettled. - * Monitors, workflows, and cron are deliberately outside this. - */ + /** Whether daemon-owned or child-reported Session work is unsettled. */ readonly activeWork: boolean; /** diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 6a7df1bea22..88f6f96864e 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -2533,6 +2533,7 @@ export class Session implements SessionContext { if (!turn) return; this.goalProcessing = true; + this.#activeWorkChanged(); this.activeGoalTurn = turn; const parts = buildGoalContinuationParts(turn); let result: PromptResponse | undefined; @@ -2575,6 +2576,7 @@ export class Session implements SessionContext { await this.#emitGoalEndTurn(result); if (this.activeGoalTurn === turn) this.activeGoalTurn = undefined; this.goalProcessing = false; + this.#activeWorkChanged(); void this.#drainCronQueue(); void this.#drainNotificationQueue(); void this.#drainGoalQueue(); @@ -4055,6 +4057,20 @@ export class Session implements SessionContext { holds.push({ category: 'workflow', id: task.runId }); } } + if ( + this.historyMutationActive || + this.goalProcessing || + this.cronProcessing || + this.cronAbortController || + this.cronCompletion || + this.notificationQueue.some((item) => item.kind === 'monitor') || + (this.notificationCompletion && + this.currentAgentNotificationTaskId === null && + this.currentWorkflowNotificationTaskId === null && + !this.currentShellNotificationActive) + ) { + holds.push({ category: 'session', id: 'session:active-turn' }); + } return holds; } @@ -4097,12 +4113,14 @@ export class Session implements SessionContext { }); } this.historyMutationActive = true; + this.#activeWorkChanged(); let released = false; return () => { if (released) return; released = true; this.historyMutationActive = false; if (this.disposed) return; + this.#activeWorkChanged(); void this.#drainCronQueue(); void this.#drainNotificationQueue(); }; @@ -8909,6 +8927,7 @@ export class Session implements SessionContext { } if (this.#deferAutomaticQueueDrainUntilTurnsSettle()) return; this.cronProcessing = true; + this.#activeWorkChanged(); let resolveCompletion!: () => void; this.cronCompletion = new Promise((resolve) => { @@ -8927,6 +8946,7 @@ export class Session implements SessionContext { this.cronProcessing = false; resolveCompletion(); this.cronCompletion = null; + this.#activeWorkChanged(); void this.#drainGoalQueue(); void this.#drainNotificationQueue(); From 3dd860aafba52463dba5c52fe34fea8a6347067d Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 7 Sep 2026 13:57:28 +0800 Subject: [PATCH 2/7] fix(web-shell): show session active work --- .../session-active-work-live-state.md | 33 +++++++++++++++ docs/developers/qwen-serve-protocol.md | 5 ++- packages/acp-bridge/src/bridge.ts | 20 +++++++++ packages/acp-bridge/src/bridgeTypes.ts | 3 ++ packages/cli/src/serve/acp-http/dispatch.ts | 3 ++ .../standalone-session-service.ts | 3 ++ packages/cli/src/serve/routes/session.ts | 3 ++ packages/sdk-typescript/src/daemon/types.ts | 4 ++ .../sidebar/SessionDetailsTooltip.tsx | 10 +++-- .../sidebar/WebShellSidebar.module.css | 5 +++ .../components/sidebar/WebShellSidebar.tsx | 42 ++++++++++++++++--- packages/web-shell/client/i18n.tsx | 4 ++ .../session-catalog/session-catalog-store.ts | 4 ++ 13 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 docs/design/web-shell/session-active-work-live-state.md diff --git a/docs/design/web-shell/session-active-work-live-state.md b/docs/design/web-shell/session-active-work-live-state.md new file mode 100644 index 00000000000..5872a9a1ba3 --- /dev/null +++ b/docs/design/web-shell/session-active-work-live-state.md @@ -0,0 +1,33 @@ +# Session active-work live state + +## Problem + +The workspace session snapshot exposes only foreground prompt activity. Once a +prompt launches background work and settles, the sidebar cannot distinguish the +still-working Session from an idle one even though the bridge already tracks +per-session active-work holds. + +## Contract + +Add one optional `activeWorkState` field to session summaries and workspace +live-state rows: + +- `active`: daemon-owned work exists or a fresh child snapshot contains a hold; +- `idle`: a fresh child snapshot covers every required category and is empty; +- `unknown`: reporting was negotiated but is stale or incomplete; +- `unsupported`: the child did not negotiate active-work reporting. + +`hasActivePrompt` keeps its running-foreground-turn meaning. The Web Shell +renders `activeWorkState: active` separately when no foreground prompt is +running; this state can represent queued prompt work as well as background +work. + +The field is optional for compatibility with older daemons. It uses the +bridge's existing hold cache, capability negotiation, and freshness window, so +the live-state request remains an in-memory read with no ACP round trip. + +## Scope + +This change exposes known liveness and does not add task persistence, route +rebinding, or cross-runtime recovery. Those require a reproduced routing loss, +not only an idle-looking UI. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index bd49eae5cbb..e645ef0f1df 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -2498,7 +2498,7 @@ Additional fields may appear on each session when `view=organized`: } ``` -Trusted active lists include live daemon overlay fields such as `clientCount` and `hasActivePrompt`. Untrusted-secondary and archived lists are storage-only: live overlay fields remain absent or false, and archived entries set `isArchived` to `true`. Empty array (not 404) when no sessions exist — a session-picker UI shouldn't error just because the workspace is idle. +Trusted active lists include live daemon overlay fields such as `clientCount`, `hasActivePrompt`, and `activeWorkState`. Untrusted-secondary and archived lists are storage-only: live overlay fields remain absent or false, and archived entries set `isArchived` to `true`. Empty array (not 404) when no sessions exist — a session-picker UI shouldn't error just because the workspace is idle. ### `GET /workspaces/:workspace/sessions/live-state` @@ -2518,6 +2518,7 @@ Response: "sessionId": "session-123", "clientCount": 1, "hasActivePrompt": true, + "activeWorkState": "active", "isWaitingForPermission": false, "isWaitingForUserQuestion": false, "updatedAt": "2026-08-18T08:12:30.123Z" @@ -2526,7 +2527,7 @@ Response: } ``` -`v` is the response schema version. Every successful response includes `Cache-Control: no-store`. `sessions` is the complete, unpaginated, unordered set of sessions currently live in the selected runtime; an empty live runtime returns `200` with `sessions: []`. `clientCount`, `hasActivePrompt`, `isWaitingForPermission`, and `isWaitingForUserQuestion` are required wire fields, and missing optional bridge values project to `0` or `false`. Static catalog fields such as display name, creation time, organization, and source metadata are deliberately excluded and remain owned by the full catalog. An absent live-state row only clears a known catalog row's volatile fields; it never deletes a persisted catalog row. +`v` is the response schema version. Every successful response includes `Cache-Control: no-store`. `sessions` is the complete, unpaginated, unordered set of sessions currently live in the selected runtime; an empty live runtime returns `200` with `sessions: []`. `clientCount`, `hasActivePrompt`, `isWaitingForPermission`, and `isWaitingForUserQuestion` are required wire fields, and missing optional bridge values project to `0` or `false`. `activeWorkState` is wire-additive and absent on older daemons: `active` means the daemon owns unsettled work or the child sent a fresh non-empty hold snapshot; `idle` is emitted only for a fresh empty snapshot covering every required category; `unknown` means negotiated reporting is stale or incomplete; and `unsupported` means the child did not negotiate reporting. It does not change `hasActivePrompt`: a background shell, cron turn, or pending terminal notification is active work without becoming a foreground prompt. Static catalog fields such as display name, creation time, organization, and source metadata are deliberately excluded and remain owned by the full catalog. An absent live-state row only clears a known catalog row's volatile fields; it never deletes a persisted catalog row. `updatedAt` is an optional daemon-observed activity watermark, present when a prompt that reached the running state has published a formal terminal in the current bridge. It advances exactly once per such terminal — success, error, cancellation, and deadline alike — is written before the terminal event is published, and is strictly increasing per live session even when two terminals land in one wall-clock millisecond or the wall clock moves backward; a forward clock jump therefore persists until wall time catches up. It is never earlier than the session's `createdAt`: the first advance floors at creation time, so a wall-clock rollback between creation and the first terminal cannot key a row behind the `createdAt` it was already listed at. Prompt admission, queue waits, streamed updates, queue-only cancellation, heartbeats, and interaction waits never advance it. Clients use it to refresh the recency of a catalog row they already hold instead of reloading the full catalog after a completed turn. It is not a persistence acknowledgement: the recorder writes turn results asynchronously, so the value proves only that the daemon observed a running attempt settle. It is absent before the first running terminal in a bridge generation — including for a session restored from disk — so absence is not a support probe, and it disappears when a daemon restart or workspace runtime replacement installs a new bridge. When both a live and a persisted summary exist for one session, full catalog responses report the later valid timestamp, so `GET /session/:id/status`, which returns the bridge summary directly without that merge, may report an earlier value than a list response. diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 2ee3ce3fef6..7e3a7dd4353 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -3132,6 +3132,25 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); } + function entryActiveWorkState( + entry: SessionEntry, + ): NonNullable { + if (entryHasLocalWork(entry) || childReportsHeldWork(entry)) { + return 'active'; + } + const capability = channelInfoForEntry(entry)?.activeWork; + if (!capability) return 'unsupported'; + if ( + childWorkIsUnknown(entry) || + ACTIVE_WORK_HOLD_CATEGORIES.some( + (category) => !capability.categories.includes(category), + ) + ) { + return 'unknown'; + } + return 'idle'; + } + /** * The guards every automatic teardown shares, whichever policy decided it * was time to look. Each caller adds its own policy on top (the reaper its @@ -4204,6 +4223,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ...(entry.sourceId !== undefined ? { sourceId: entry.sourceId } : {}), clientCount: entry.clientIds.size, hasActivePrompt: entry.promptActive || entry.goalTurnActive === true, + activeWorkState: entryActiveWorkState(entry), isWaitingForPermission, isWaitingForUserQuestion, pendingInteractionCount: entry.pendingInteractions.size, diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 6b4ed18043e..9c9d787ac67 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -814,6 +814,9 @@ export interface BridgeSessionSummary { sourceId?: string; clientCount: number; hasActivePrompt: boolean; + /** Per-session active-work observation. `idle` is emitted only from a + * fresh snapshot that covers every negotiated hold category. */ + activeWorkState?: 'active' | 'idle' | 'unknown' | 'unsupported'; /** True while a non-question permission request awaits a response. */ isWaitingForPermission?: boolean; /** True while an ask_user_question request awaits a response. */ diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 23a45f8abfa..5f89e32ecdc 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -2343,6 +2343,9 @@ export class AcpDispatcher { ...(s.sourceId !== undefined ? { sourceId: s.sourceId } : {}), clientCount: s.clientCount, hasActivePrompt: s.hasActivePrompt, + ...(s.activeWorkState !== undefined + ? { activeWorkState: s.activeWorkState } + : {}), isArchived: s.isArchived === true, ...(s.isPinned !== undefined ? { isPinned: s.isPinned } : {}), ...(s.pinnedAt !== undefined ? { pinnedAt: s.pinnedAt } : {}), diff --git a/packages/cli/src/serve/conversations/standalone-session-service.ts b/packages/cli/src/serve/conversations/standalone-session-service.ts index 74b2d90eb28..e2f43f14ff6 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.ts @@ -423,6 +423,9 @@ function mergeLiveStandaloneSummary( updatedAt: laterTimestamp(live.updatedAt, persisted.updatedAt), clientCount: live.clientCount, hasActivePrompt: live.hasActivePrompt, + ...(live.activeWorkState !== undefined + ? { activeWorkState: live.activeWorkState } + : {}), ...(live.isWaitingForPermission !== undefined ? { isWaitingForPermission: live.isWaitingForPermission } : {}), diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index a5f205b0dc0..55f97560a56 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -7715,6 +7715,9 @@ export function registerSessionRoutes( sessionId: session.sessionId, clientCount: session.clientCount, hasActivePrompt: session.hasActivePrompt, + ...(session.activeWorkState !== undefined + ? { activeWorkState: session.activeWorkState } + : {}), isWaitingForPermission: session.isWaitingForPermission ?? false, isWaitingForUserQuestion: session.isWaitingForUserQuestion ?? false, // Bridge-local activity watermark, absent until a running prompt in diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index c57461ee3f1..0738cf5087f 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1342,6 +1342,8 @@ export interface DaemonSessionSummary { sourceId?: string; clientCount?: number; hasActivePrompt?: boolean; + /** Per-session active-work observation from the owning runtime. */ + activeWorkState?: 'active' | 'idle' | 'unknown' | 'unsupported'; isWaitingForPermission?: boolean; isWaitingForUserQuestion?: boolean; pendingInteractionCount?: number; @@ -1566,6 +1568,8 @@ export interface DaemonSessionLiveState { sessionId: string; clientCount: number; hasActivePrompt: boolean; + /** Absent when talking to an older daemon. */ + activeWorkState?: 'active' | 'idle' | 'unknown' | 'unsupported'; isWaitingForPermission: boolean; isWaitingForUserQuestion: boolean; /** diff --git a/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx b/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx index 7d229453f9d..18657ba86b7 100644 --- a/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx +++ b/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx @@ -73,9 +73,13 @@ export function SessionDetailsTooltip({ ); const status = session.hasActivePrompt ? t('sidebar.running') - : completedUnread - ? t('sidebar.completedUnread') - : t('sidebar.clients', { count: session.clientCount ?? 0 }); + : session.activeWorkState === 'active' + ? t('sidebar.activeWork') + : session.activeWorkState === 'unknown' + ? t('sidebar.activityUnknown') + : completedUnread + ? t('sidebar.completedUnread') + : t('sidebar.clients', { count: session.clientCount ?? 0 }); useEffect(() => { return () => { diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css index 626c31029c2..82db375f58f 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css @@ -1044,6 +1044,11 @@ color: var(--success-color, #3fb950); } +.sessionStatusUnknown { + color: var(--muted-foreground); + font-size: 11px; +} + .sessionSourceIcon { width: 18px; height: 18px; diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 6e71fa8edf0..3ec99d69704 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -1987,6 +1987,7 @@ export function WebShellSidebar({ sessionActionItems.has('archive') && !isCurrentSession(session) && !session.hasActivePrompt && + session.activeWorkState !== 'active' && canMutateSessionArchive(session), [canMutateSessionArchive, isCurrentSession, sessionActionItems], ); @@ -2280,7 +2281,11 @@ export function WebShellSidebar({ }, []); const hasRunningSession = useMemo( - () => sessions.some((session) => session.hasActivePrompt), + () => + sessions.some( + (session) => + session.hasActivePrompt || session.activeWorkState === 'active', + ), [sessions], ); const statusSessions = useMemo(() => { @@ -4294,9 +4299,13 @@ export function WebShellSidebar({ } const isCurrent = isCurrentSession(session); + const sessionWorkActive = + !session.hasActivePrompt && session.activeWorkState === 'active'; + const activityUnknown = + !session.hasActivePrompt && session.activeWorkState === 'unknown'; // Archiving closes the live session daemon-side, which would end the - // running turn; keep the action visible but inert while it runs. - const running = Boolean(session.hasActivePrompt); + // running work; keep the action visible but inert while it runs. + const running = Boolean(session.hasActivePrompt || sessionWorkActive); const needsUserInput = !session.isWaitingForPermission && session.isWaitingForUserQuestion; const attention = session.isWaitingForPermission @@ -4337,7 +4346,7 @@ export function WebShellSidebar({ styles.sessionRow, isCurrent && styles.currentSession, session.isPinned && styles.pinnedSession, - session.hasActivePrompt && styles.runningSession, + running && styles.runningSession, busy && styles.busySession, )} onMouseEnter={(event) => @@ -4390,6 +4399,23 @@ export function WebShellSidebar({ data-web-shell-session-running aria-hidden="true" /> + ) : sessionWorkActive && + !scheduledTaskIcon && + !completedUnread ? ( + {isEditing && canRenameSession(session) ? ( @@ -4451,10 +4477,14 @@ export function WebShellSidebar({ {attention.short} )} - {session.hasActivePrompt ? ( + {session.hasActivePrompt || sessionWorkActive ? ( ) : !attention && gitIcon ? ( {gitIcon} diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index b3f26f8c85b..d9d17e137cd 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1635,6 +1635,8 @@ const EN: Messages = { `Delete "${v?.name ?? ''}"? This cannot be undone.`, 'sidebar.clients': (v) => `${v?.count ?? 0} client(s)`, 'sidebar.running': 'Running', + 'sidebar.activeWork': 'Active work', + 'sidebar.activityUnknown': 'Background activity unknown', 'sidebar.waitingForApproval': 'Waiting for approval', 'sidebar.waitingForApprovalShort': 'Approval', 'sidebar.sessionPr': (v) => `Pull Request #${v?.number ?? ''}`, @@ -5118,6 +5120,8 @@ const ZH: Messages = { `确定删除“${v?.name ?? ''}”吗?删除后不可恢复。`, 'sidebar.clients': (v) => `${v?.count ?? 0} 个客户端`, 'sidebar.running': '运行中', + 'sidebar.activeWork': '有活动任务', + 'sidebar.activityUnknown': '后台活动状态未知', 'sidebar.waitingForApproval': '等待批准', 'sidebar.waitingForApprovalShort': '待批准', 'sidebar.sessionPr': (v) => `合并请求 #${v?.number ?? ''}`, diff --git a/packages/web-shell/client/session-catalog/session-catalog-store.ts b/packages/web-shell/client/session-catalog/session-catalog-store.ts index 252b7a261a3..4ba34fdd99b 100644 --- a/packages/web-shell/client/session-catalog/session-catalog-store.ts +++ b/packages/web-shell/client/session-catalog/session-catalog-store.ts @@ -171,6 +171,7 @@ function liveSessionSnapshotsEqual( !prior || prior.clientCount !== session.clientCount || prior.hasActivePrompt !== session.hasActivePrompt || + prior.activeWorkState !== session.activeWorkState || prior.isWaitingForPermission !== session.isWaitingForPermission || prior.isWaitingForUserQuestion !== session.isWaitingForUserQuestion || prior.updatedAt !== session.updatedAt @@ -728,6 +729,7 @@ export class SessionCatalogStore { const live = liveById.get(session.sessionId); const clientCount = live?.clientCount ?? 0; const hasActivePrompt = live?.hasActivePrompt ?? false; + const activeWorkState = live?.activeWorkState; const isWaitingForPermission = live?.isWaitingForPermission ?? false; const isWaitingForUserQuestion = live?.isWaitingForUserQuestion ?? false; @@ -750,6 +752,7 @@ export class SessionCatalogStore { if ( session.clientCount === clientCount && session.hasActivePrompt === hasActivePrompt && + session.activeWorkState === activeWorkState && session.isWaitingForPermission === isWaitingForPermission && session.isWaitingForUserQuestion === isWaitingForUserQuestion && session.updatedAt === updatedAt @@ -762,6 +765,7 @@ export class SessionCatalogStore { ...session, clientCount, hasActivePrompt, + activeWorkState, isWaitingForPermission, isWaitingForUserQuestion, ...(updatedAt !== undefined ? { updatedAt } : {}), From c926de9df9f2118556a785ca0b09067d256d99c7 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 7 Sep 2026 14:05:17 +0800 Subject: [PATCH 3/7] fix(web-shell): gate todo spinner on live work --- .../session-active-work-live-state.md | 5 +++ packages/web-shell/client/App.test.tsx | 17 ++++---- packages/web-shell/client/App.tsx | 12 +++++- .../client/components/panels/TodoPanel.tsx | 4 +- .../session-catalog/session-catalog-hooks.ts | 42 +++++++++++++++---- 5 files changed, 60 insertions(+), 20 deletions(-) diff --git a/docs/design/web-shell/session-active-work-live-state.md b/docs/design/web-shell/session-active-work-live-state.md index 5872a9a1ba3..c63612a099f 100644 --- a/docs/design/web-shell/session-active-work-live-state.md +++ b/docs/design/web-shell/session-active-work-live-state.md @@ -22,6 +22,11 @@ renders `activeWorkState: active` separately when no foreground prompt is running; this state can represent queued prompt work as well as background work. +The floating Todo panel animates an `in_progress` item only while the local +stream, daemon foreground state, or per-session active-work state confirms +that execution is live. A persisted `in_progress` value without live activity +keeps its static status glyph instead of implying that work is still running. + The field is optional for compatibility with older daemons. It uses the bridge's existing hold cache, capability negotiation, and freshness window, so the live-state request remains an in-memory read with no ACP round trip. diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index c40db1195bd..c84f091b528 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -243,7 +243,7 @@ const { mockReleaseDetachedWebTerminal, mockReleaseWebTerminal, mockUseWorkspaceSessionLiveState, - mockUseDaemonActivePromptBridge, + mockUseDaemonSessionActivityBridge, } = vi.hoisted(() => { const connection: MockConnection = { status: 'connected', @@ -678,7 +678,7 @@ const { mockReleaseWebTerminal: vi.fn(), mockReleaseDetachedWebTerminal: vi.fn(), mockUseWorkspaceSessionLiveState: vi.fn(() => new Map()), - mockUseDaemonActivePromptBridge: vi.fn(), + mockUseDaemonSessionActivityBridge: vi.fn(), }; }); @@ -1515,7 +1515,7 @@ vi.mock('./session-catalog/session-catalog-hooks', () => ({ hasActivePrompt: testState.sessionHasActivePrompt, authoritative: true, }), - useDaemonActivePromptBridge: mockUseDaemonActivePromptBridge, + useDaemonSessionActivityBridge: mockUseDaemonSessionActivityBridge, // The Workspaces overview panel's per-row session counts; inert here. useSessionCatalogQuery: () => ({ page: undefined, @@ -8418,10 +8418,11 @@ beforeEach(() => { workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }], }; mockUseWorkspaceSessionLiveState.mockClear(); - mockUseDaemonActivePromptBridge.mockReset(); - mockUseDaemonActivePromptBridge.mockImplementation( - () => testState.sessionHasActivePrompt, - ); + mockUseDaemonSessionActivityBridge.mockReset(); + mockUseDaemonSessionActivityBridge.mockImplementation(() => ({ + hasActivePrompt: testState.sessionHasActivePrompt, + activeWorkState: undefined, + })); mockWorkspace.status = 'connected'; mockWorkspace.refreshCapabilities.mockReset(); mockWorkspace.refreshCapabilities.mockResolvedValue( @@ -10053,7 +10054,7 @@ describe('App conversation indicator keep-alive (#9487)', () => { renderApp({ sidebar: false }); await flush(); - expect(mockUseDaemonActivePromptBridge).toHaveBeenCalledWith( + expect(mockUseDaemonSessionActivityBridge).toHaveBeenCalledWith( mockWorkspace.client, '/tmp/live', 'session-1', diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 3040502491d..ed687f4eb76 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -110,7 +110,7 @@ import { import { useVoiceWorkspaceSettings } from './voice/use-voice-workspace-settings'; import { useSessionCatalogController, - useDaemonActivePromptBridge, + useDaemonSessionActivityBridge, } from './session-catalog/session-catalog-hooks'; import { loadSessionCatalogOnce, @@ -3264,7 +3264,10 @@ export function App({ ? trustedLiveWorkspaces[0]?.cwd : undefined : connection.workspaceCwd; - const sessionHasActivePrompt = useDaemonActivePromptBridge( + const { + hasActivePrompt: sessionHasActivePrompt, + activeWorkState: sessionActiveWorkState, + } = useDaemonSessionActivityBridge( workspace.client, activePromptWorkspaceCwd, connection.sessionId, @@ -17656,6 +17659,11 @@ export function App({ void; } @@ -28,6 +29,7 @@ export const TodoPanel = memo(function TodoPanel({ todos, title, statusItems = [], + hasLiveActivity = true, onOpen, }: TodoPanelProps) { const { t } = useI18n(); @@ -138,7 +140,7 @@ export const TodoPanel = memo(function TodoPanel({ className={`${styles.item} ${getStatusClass(todo.status)}`} >