From 5dc2e1501f02bd1f01a3cbebf807ef47ebff01c3 Mon Sep 17 00:00:00 2001 From: jinye Date: Sat, 4 Jul 2026 04:19:02 +0800 Subject: [PATCH] feat(serve): Add runtime.activity fields to daemon status API (#6270) * feat(serve): add runtime.activity fields to daemon status API Add activePrompts, lastActivityAt, and idleSinceMs to the GET /daemon/status runtime section. These fields already exist on the bridge (and are exposed via GET /health?deep=1) but were missing from the richer status endpoint that operators use for troubleshooting. The idleSinceMs value is computed from a cached lastActivityAt read (same pattern as the health handler) to ensure consistency within a single response. * feat(serve): add MCP server health summary to workspace status Extract serversConnected, serversErrored, and serversDisabled counts from the MCP servers array into the workspace.mcp.summary object. Operators can see MCP fleet health at a glance without expanding the full JSON. * fix(serve): guard activity fields against undefined bridge getters Add ?? null / ?? 0 fallbacks for lastActivityAt and activePromptCount to prevent RangeError when a test fake bridge omits these properties. --- docs/developers/qwen-serve-protocol.md | 7 +++ packages/cli/src/serve/daemon-status.test.ts | 63 ++++++++++++++++++++ packages/cli/src/serve/daemon-status.ts | 38 ++++++++++++ packages/cli/src/serve/run-qwen-serve.ts | 7 ++- 4 files changed, 114 insertions(+), 1 deletion(-) diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 41232bb651..00c7d198fe 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -326,6 +326,11 @@ Response shape: "inbound": { "count": 0, "totalBytes": 0, "maxBytes": 0 }, "outbound": { "count": 0, "totalBytes": 0, "maxBytes": 0 } } + }, + "activity": { + "activePrompts": 0, + "lastActivityAt": null, + "idleSinceMs": null } } } @@ -346,6 +351,8 @@ mounted, `/daemon/status` may report `daemon_runtime_starting`; if the async runtime mount fails, it reports `daemon_runtime_failed` while non-status runtime routes return `503`. +`runtime.activity` reports daemon-wide prompt activity. `activePrompts` counts sessions with an in-flight prompt. `lastActivityAt` is the ISO 8601 timestamp of the last prompt start/end or session spawn; `null` when the daemon has never processed any activity since boot. `idleSinceMs` is computed from `lastActivityAt` at response generation time. + `runtime.channel.live` reports the ACP bridge channel inside the daemon. It is not the channel-adapter worker. Daemon-managed channels use `runtime.channelWorker`, whose `state` is one of `disabled`, `starting`, diff --git a/packages/cli/src/serve/daemon-status.test.ts b/packages/cli/src/serve/daemon-status.test.ts index 90fcf79365..1a1648c40a 100644 --- a/packages/cli/src/serve/daemon-status.test.ts +++ b/packages/cli/src/serve/daemon-status.test.ts @@ -307,6 +307,37 @@ describe('buildDaemonStatusResponse', () => { }); }); + it('summarizes MCP server health in workspace.mcp.summary', async () => { + const response = await buildDaemonStatusResponse( + 'full', + makeOptions({ + mcpStatus: { + v: 1, + workspaceCwd: BASE_WORKSPACE, + initialized: true, + servers: [ + { name: 'a', mcpStatus: 'connected', disabled: false }, + { name: 'b', mcpStatus: 'connected', disabled: false }, + { + name: 'c', + mcpStatus: 'disconnected', + status: 'error', + disabled: false, + }, + { name: 'd', disabled: true }, + ], + }, + }), + ); + const mcpSummary = response.full?.workspace?.['mcp']?.summary; + expect(mcpSummary).toMatchObject({ + serversCount: 4, + serversConnected: 2, + serversErrored: 1, + serversDisabled: 1, + }); + }); + it('marks a timed-out full workspace section unavailable', async () => { vi.useFakeTimers(); @@ -399,6 +430,34 @@ describe('buildDaemonStatusResponse', () => { expect(response.runtime).not.toHaveProperty('perf'); }); + + it('includes activity fields in runtime', async () => { + vi.useFakeTimers({ now: 1719990005000 }); + const response = await buildDaemonStatusResponse( + 'summary', + makeOptions({ + activePromptCount: 3, + lastActivityAt: 1719990000000, + }), + ); + expect(response.runtime.activity).toEqual({ + activePrompts: 3, + lastActivityAt: '2024-07-03T07:00:00.000Z', + idleSinceMs: 5000, + }); + }); + + it('reports null activity when daemon has never been active', async () => { + const response = await buildDaemonStatusResponse( + 'summary', + makeOptions({ activePromptCount: 0, lastActivityAt: null }), + ); + expect(response.runtime.activity).toEqual({ + activePrompts: 0, + lastActivityAt: null, + idleSinceMs: null, + }); + }); }); interface MakeOptionsInput { @@ -418,6 +477,8 @@ interface MakeOptionsInput { outbound: { count: number; totalBytes: number; maxBytes: number }; }; }; + activePromptCount?: number; + lastActivityAt?: number | null; } function makeOptions(input: MakeOptionsInput = {}): BuildDaemonStatusOptions { @@ -431,6 +492,8 @@ function makeOptions(input: MakeOptionsInput = {}): BuildDaemonStatusOptions { getDaemonStatusSnapshot: () => input.bridgeSnapshot ?? BASE_BRIDGE_SNAPSHOT, getWorkspaceToolsStatus: async () => input.toolsStatus ?? okStatus({ tools: [] }), + activePromptCount: input.activePromptCount ?? 0, + lastActivityAt: input.lastActivityAt ?? null, } as unknown as AcpSessionBridge; const workspace = { getWorkspaceMcpStatus: async () => diff --git a/packages/cli/src/serve/daemon-status.ts b/packages/cli/src/serve/daemon-status.ts index b7098e7a74..37ba8a1661 100644 --- a/packages/cli/src/serve/daemon-status.ts +++ b/packages/cli/src/serve/daemon-status.ts @@ -170,6 +170,11 @@ interface DaemonStatusRuntime { rejectedSinceStart: Record; }; perf?: DaemonPerfSnapshot; + activity: { + activePrompts: number; + lastActivityAt: string | null; + idleSinceMs: number | null; + }; process: NodeJS.MemoryUsage; } @@ -239,6 +244,7 @@ export async function buildDaemonStatusResponse( input: BuildDaemonStatusOptions, ): Promise { const bridgeSnapshot = input.bridge.getDaemonStatusSnapshot(); + const lastActivity = input.bridge.lastActivityAt ?? null; const acpSnapshot = input.acpHandle?.registry.getSnapshot(); const rateLimitHits = input.rateLimiter?.getHitCounts() ?? zeroRateHits(); const channelWorker = input.getChannelWorkerSnapshot?.() ?? { @@ -336,6 +342,12 @@ export async function buildDaemonStatusResponse( rejectedSinceStart: rateLimitHits, }, ...(input.getPerfSnapshot ? { perf: input.getPerfSnapshot() } : {}), + activity: { + activePrompts: input.bridge.activePromptCount ?? 0, + lastActivityAt: + lastActivity !== null ? new Date(lastActivity).toISOString() : null, + idleSinceMs: lastActivity !== null ? Date.now() - lastActivity : null, + }, process: process.memoryUsage(), }, ...(full ? { full } : {}), @@ -669,9 +681,35 @@ function summarizeStatusData(data: unknown): SectionSummary { } } + summarizeMcpServers(data, summary); + return summary; } +function summarizeMcpServers( + data: StatusRecord, + summary: SectionSummary, +): void { + const servers = data['servers']; + if (!Array.isArray(servers)) return; + let connected = 0; + let errored = 0; + let disabled = 0; + for (const server of servers) { + if (!isRecord(server)) continue; + if (server['disabled'] === true) { + disabled++; + } else if (server['status'] === 'error') { + errored++; + } else if (server['mcpStatus'] === 'connected') { + connected++; + } + } + summary['serversConnected'] = connected; + summary['serversErrored'] = errored; + summary['serversDisabled'] = disabled; +} + function collectStatuses(data: unknown): string[] { const statuses: string[] = []; visitStatusContainers(data, (record) => { diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 4a8510bdfa..d10631fe2f 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -1140,6 +1140,11 @@ function createBootstrapServeApp(input: { read: 0, }, }, + activity: { + activePrompts: 0, + lastActivityAt: null, + idleSinceMs: null, + }, process: process.memoryUsage(), }, ...(detail.detail === 'full' @@ -1240,7 +1245,7 @@ function isCorsPreflightRequest(req: Request): boolean { Boolean(req.headers.origin) && Boolean( req.headers['access-control-request-method'] || - req.headers['access-control-request-headers'], + req.headers['access-control-request-headers'], ) ); }