Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/design/web-shell/session-active-work-live-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 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 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.

## 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.
5 changes: 3 additions & 2 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -2539,7 +2539,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`

Expand All @@ -2559,6 +2559,7 @@ Response:
"sessionId": "session-123",
"clientCount": 1,
"hasActivePrompt": true,
"activeWorkState": "active",
"isWaitingForPermission": false,
"isWaitingForUserQuestion": false,
"updatedAt": "2026-08-18T08:12:30.123Z"
Expand All @@ -2567,7 +2568,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.

Expand Down
45 changes: 45 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,12 +436,18 @@ describe('createAcpSessionBridge', () => {
// than idle, and graded `partial` — the channel did negotiate, it just
// has not spoken yet, which is not the same as `none`.
expect(bridge.activeWork).toBe(true);
expect(bridge.getSessionSummary(session.sessionId).activeWorkState).toBe(
'unknown',
);
expect(reportingGrade(bridge)).toBe('partial');

await sendActiveWorkSnapshot(handle, 1, [
{ sessionId: session.sessionId, holds: [] },
]);
expect(bridge.activeWork).toBe(false);
expect(bridge.getSessionSummary(session.sessionId).activeWorkState).toBe(
'idle',
);
expect(reportingGrade(bridge)).toBe('full');

// Prompts are a daemon-owned fact: no child report is involved.
Expand All @@ -450,6 +456,9 @@ describe('createAcpSessionBridge', () => {
prompt: [{ type: 'text', text: 'start background work' }],
});
expect(bridge.activeWork).toBe(true);
expect(bridge.getSessionSummary(session.sessionId).activeWorkState).toBe(
'active',
);
prompt.resolve({ stopReason: 'end_turn' });
await running;
expect(bridge.activeWork).toBe(false);
Expand All @@ -465,6 +474,9 @@ describe('createAcpSessionBridge', () => {
// Unsupported must not behave like unknown: an older child would
// otherwise pin every session as permanently busy and unreapable.
expect(bridge.activeWork).toBe(false);
expect(bridge.getSessionSummary(session.sessionId).activeWorkState).toBe(
'unsupported',
);
expect(reportingGrade(bridge)).toBe('none');
expect(bridge.activeWorkCoverage.oldestCoveredReportAt).toBeNull();

Expand All @@ -474,6 +486,39 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});

it('applies a snapshot received before session registration', async () => {
const newSessionStarted = deferred<void>();
const releaseNewSession = deferred<void>();
const handle = makeChannel({
initializeImpl: () => activeWorkInitializeResponse(),
newSessionImpl: async () => {
newSessionStarted.resolve();
await releaseNewSession.promise;
return { sessionId: 'registering' };
},
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const spawning = bridge.spawnOrAttach({ workspaceCwd: WS_A });
await newSessionStarted.promise;
await sendActiveWorkSnapshot(handle, 1, [
{ sessionId: 'registering', holds: [agentHold('a1')] },
]);
releaseNewSession.resolve();
const session = await spawning;

expect(bridge.getSessionSummary(session.sessionId).activeWorkState).toBe(
'active',
);
await sendActiveWorkSnapshot(handle, 2, [
{ sessionId: session.sessionId, holds: [] },
]);
expect(bridge.getSessionSummary(session.sessionId).activeWorkState).toBe(
'idle',
);

await bridge.shutdown();
});

it('retains sessions reported by a negotiated but incomplete child', async () => {
let conditionalCloseCalls = 0;
let forcedCloseCalls = 0;
Expand Down
35 changes: 33 additions & 2 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,11 @@ interface ChannelInfo {
categories: readonly ActiveWorkHoldCategory[];
/** Highest snapshot sequence applied; guards against reordering only. */
seq: number;
/** Latest report, retained for Sessions registered after it arrived. */
snapshot?: {
receivedAt: number;
sessions: Map<string, Map<string, ActiveWorkHoldCategory>>;
};
};
channelLiveness?: ChannelLivenessMonitor;
handshakeComplete: boolean;
Expand Down Expand Up @@ -3162,6 +3167,25 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
);
}

function entryActiveWorkState(
entry: SessionEntry,
): NonNullable<BridgeSessionSummary['activeWorkState']> {
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
Expand Down Expand Up @@ -3573,6 +3597,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
for (const hold of session.holds) holds.set(hold.id, hold.category);
reported.set(session.sessionId, holds);
}
info.activeWork.snapshot = { receivedAt: now, sessions: reported };
// Iterate what the channel owns rather than what the snapshot named: a
// Session the child did not mention holds nothing on the child side.
// Because reports are complete, silence about a Session this channel owns
Expand Down Expand Up @@ -4234,6 +4259,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
...(entry.sourceId !== undefined ? { sourceId: entry.sourceId } : {}),
clientCount: entry.clientIds.size,
hasActivePrompt: hasInFlightPromptActivity(entry),
activeWorkState: entryActiveWorkState(entry),
isWaitingForPermission,
isWaitingForUserQuestion,
pendingInteractionCount: entry.pendingInteractions.size,
Expand Down Expand Up @@ -7027,6 +7053,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
branch?: { name: string; baseBranch: string };
} = {},
): SessionEntry => {
const childSnapshot = ci.activeWork?.snapshot;
const reportedChildHolds = childSnapshot?.sessions.get(sessionId);
const entry: SessionEntry = {
sessionId,
workspaceCwd,
Expand Down Expand Up @@ -7084,8 +7112,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
attachRefs: new Map(),
spawnOwnerWantedKill: false,
promptActive: false,
childHolds: null,
childHoldsAt: null,
childHolds: reportedChildHolds ?? null,
childHoldsAt:
childSnapshot && reportedChildHolds !== undefined
? childSnapshot.receivedAt
: null,
activeWorkCloseInFlight: false,
activeWorkCloseFailures: 0,
activeWorkCloseRetryAt: null,
Expand Down
11 changes: 6 additions & 5 deletions packages/acp-bridge/src/bridgeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2210,14 +2210,15 @@ export class BridgeClient implements Client {
if (method === ACTIVE_WORK_NOTIFICATION_METHOD) {
const snapshot = parseActiveWorkSnapshot(params);
if (snapshot) {
// Sessions the child claims but this channel does not own are dropped
// rather than rejecting the whole snapshot: the rest of it is still
// usable, and a channel must never influence another channel's state.
// Retain rows while a Session is registering so the bridge can apply
// a report that races the newSession response.
this.onActiveWork?.({
v: ACTIVE_WORK_HEARTBEAT_VERSION,
seq: snapshot.seq,
sessions: snapshot.sessions.filter((session) =>
this.ownsSession(session.sessionId),
sessions: snapshot.sessions.filter(
(session) =>
this.ownsSession(session.sessionId) ||
this.hasSessionSpawnInFlight(),
),
});
}
Expand Down
3 changes: 3 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/serve/acp-http/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
: {}),
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/serve/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8490,6 +8490,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
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk-typescript/src/daemon/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,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;
Expand Down Expand Up @@ -1597,6 +1599,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;
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@ describe('public SDK entry — typed daemon event surface (#4217)', () => {
sessionId: string;
clientCount: number;
hasActivePrompt: boolean;
activeWorkState?: 'active' | 'idle' | 'unknown' | 'unsupported';
isWaitingForPermission: boolean;
isWaitingForUserQuestion: boolean;
updatedAt?: string;
Expand Down
17 changes: 9 additions & 8 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ const {
mockReleaseDetachedWebTerminal,
mockReleaseWebTerminal,
mockUseWorkspaceSessionLiveState,
mockUseDaemonActivePromptBridge,
mockUseDaemonSessionActivityBridge,
} = vi.hoisted(() => {
const connection: MockConnection = {
status: 'connected',
Expand Down Expand Up @@ -717,7 +717,7 @@ const {
mockReleaseWebTerminal: vi.fn(),
mockReleaseDetachedWebTerminal: vi.fn(),
mockUseWorkspaceSessionLiveState: vi.fn(() => new Map()),
mockUseDaemonActivePromptBridge: vi.fn(),
mockUseDaemonSessionActivityBridge: vi.fn(),
};
});

Expand Down Expand Up @@ -1614,7 +1614,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,
Expand Down Expand Up @@ -9381,10 +9381,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(
Expand Down Expand Up @@ -11022,7 +11023,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',
Expand Down
Loading
Loading