feat(web-shell): show background shell task runtime details (#14831)

* feat(web-shell): show background shell task runtime details

* fix(build): budget shared shell task runtime labels

* test(serve): sync shell runtime telemetry route counts

---------

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-10-20 16:18:17 +08:00
1 parent a1fb0306ae
commit dc8b523d39
36 files changed
+1753 -75

No files matched your search

@@ -0,0 +1,118 @@
# Shell Task Detail Runtime
[English](2026-10-19-shell-task-detail-runtime.md) | [简体中文](2026-10-19-shell-task-detail-runtime.zh-CN.md)
Status: implemented on `feat/web-shell-shell-task-detail-info`, pending review.
## Problem
The web-shell shell-task panel shows the command, status, elapsed time, pid,
working directory, and output-file path. For a long-running task that is not
enough: the user cannot see any output, cannot tell whether the process is
still producing anything, and does not know when it started. The output file
lives under the session temp directory, so the existing workspace file routes
cannot read it.
## Goals
- Show the tail of the captured stdout/stderr inside the panel.
- Show the output file's size and last-write time, so "empty" and "still
producing" are distinguishable.
- Show resident memory for the shell's process tree.
- Show start/end wall-clock times, and make command/path fields copyable.
- Give the panel tab a readable title derived from the command, still
recognizable after truncation.
## Non-goals
- CPU utilization. The two platforms report it with different meanings
(lifetime average on Linux, a decaying one-minute average on macOS), so a
single number would be misleading without extra sampling state.
- Separating stdout from stderr. The captured file is one merged stream today.
- A general process monitor (child tree browsing, port probing, sampling
history).
## Data flow
The tasks list stays a cheap snapshot. A new read-only endpoint fetches the
detail on demand:
1. The web-shell panel calls `GET /session/:id/tasks/:taskId/runtime`.
2. The daemon forwards to the session process through a dedicated status
method.
3. The session looks up its own `BackgroundShellRegistry` entry by id, so the
caller never supplies a path. It then reads the output file's stat and tail,
and samples the process tree rooted at the registered pid.
4. The response carries only: `memoryBytes`, `outputBytes`,
`outputModifiedAt`, `outputTail`, `outputTailTruncated`, and `outputError`.
The panel polls every 3 seconds while the task is running and fetches again
when it becomes terminal. Each effect lifetime bounds its own concurrent reads,
so an old request cannot block the final read or StrictMode replay. Failed
requests offer a retry button; terminal tasks do not poll continuously. After
the user presses Stop, the panel stays running with Stop disabled and polls
the source session task status. Only a terminal status reported by the backend
changes the panel to the corresponding outcome and triggers a final output
read; acceptance of a Stop request alone does not indicate completion. Transient status-read failures are retried. Unknown or
evicted tasks return `runtime: null`.
ACP exposes the same capability as `_qwen/session/task_runtime`, with
`sessionId` and `taskId` parameters and connection ownership checks. Both REST
and ACP use the live session owner runtime. Restored cross-session tabs bind
all task reads to the original session.
## Decisions
- **On-demand, not part of the task list.** Output reads and `ps` sampling are
more expensive than a snapshot, and only an open panel needs them.
- **Server resolves the path.** The route accepts a task id, not a file path,
and the session resolves it from its own registry.
- **RSS, not "memory usage".** The value is the resident set of the process
tree. Shared pages are counted once per process, swapped-out pages are not
counted, and a container sandbox can make the host-visible pid an outer
wrapper. The label therefore says "resident memory", not "memory usage".
- **Tail is capped at the existing 8 KiB notification limit.** The panel and
the completion notification read the same bytes through the same helper.
- **Memory is sampled only while running.** A finished task has no live pid to
read; showing a stale number would be worse than showing none.
- **Partial failure preserves successful output.** A metadata read failure
leaves a successful tail visible with a hint. Unavailable tasks, read errors,
running empty output and terminal empty output have distinct labels. Clock
formatting follows the app language in the browser local time zone.
- **Copy is local.** Copy buttons write to the browser clipboard and do not
touch the daemon.
## Limits and risks
- On a sandboxed (container) shell the pid may describe a wrapper process, so
the memory figure can be much lower than the real workload. The value is
hidden when the registry has no pid.
- The output tail is a snapshot. Bytes written between polls are not shown
until the next tick.
- The pid can be reused between the status read and the OS probe. The window is
small, and the number is informational only.
## Validation
- Unit tests cover the `ps` table parser, tree summation, cycle handling, and
the SDK client call.
- Regression tests cover terminal transitions and StrictMode async loading,
failure retry, polling cleanup, partial failures, empty output, app language
and copy feedback. Short tab titles survive refresh and reopening.
- Route tests cover REST owner-runtime dispatch with no unknown-session
fallback, ACP parameter and connection ownership checks, and telemetry
catalog parity.
- Cross-session restoration tests confirm runtime reads use the source session.
Stop regression tests click the actual button, covering panels whose task
prop stays running, transient status-read failures and late stale responses.
The final output and terminal state must match the backend.
- Manual verification: run a background shell, open its panel, confirm output,
size/mtime, resident memory, start time, and copy actions; stop the shell and
confirm the final tail and exit code remain visible.
## Acceptance
- A running shell shows its output tail, output size and last-write time, and
resident memory without opening the output file manually.
- A finished shell shows the final tail, exit code, and end time.
- An unknown or non-shell task returns no runtime detail rather than an error.
@@ -0,0 +1,74 @@
# 后台 Shell 任务详情
[English](2026-10-19-shell-task-detail-runtime.md) | [简体中文](2026-10-19-shell-task-detail-runtime.zh-CN.md)
状态:已在 `feat/web-shell-shell-task-detail-info` 上实现,待评审。
## 问题
web-shell 的 shell 任务面板目前只显示命令、状态、运行时长、PID、工作目录和输出文件路径。对长时间运行的任
务来说这不够:看不到任何输出,无法判断进程是否还在产出,也不知道它是从什么时候开始跑的。输出文件位于
session 临时目录,现有的工作区文件路由读不到。
## 目标
- 在面板内显示被捕获的 stdout/stderr 尾部。
- 显示输出文件大小和最后写入时间,让“还没有输出”和“一直在产出”可以区分。
- 显示 shell 进程树的常驻内存。
- 显示开始/结束的本地时刻,并让命令和路径可以复制。
- 让面板 tab 的标题从命令派生,截断后仍能辨认。
## 非目标
- CPU 占用。两个平台的口径不同(Linux 是生命周期平均,macOS 是约一分钟的衰减平均),不额外维护采样状态
的话,单个数字会误导。
- 拆分 stdout 与 stderr。当前捕获的就是一个合并流。
- 通用进程监控(子进程树浏览、端口探测、采样历史)。
## 数据流
任务列表仍然只做轻量快照。详情由一个新的只读接口按需获取:
1. web-shell 面板请求 `GET /session/:id/tasks/:taskId/runtime`。
2. daemon 通过一个独立的状态方法转发到 session 进程。
3. session 用任务 id 在自己的 `BackgroundShellRegistry` 里查条目,调用方不提供路径。随后读取输出文件的
stat 和尾部,并对注册的 pid 做进程树采样。
4. 响应只包含 `memoryBytes`、`outputBytes`、`outputModifiedAt`、`outputTail`、
`outputTailTruncated`、`outputError`。
任务运行中面板每 3 秒轮询一次,进入终态后重新获取一次。每次 effect 生命周期独立限制并发,避免旧的在途请求阻止终态读取或 StrictMode 重放。请求失败显示重试按钮,终态不持续轮询。用户点击停止后,面板保持运行状态并禁用停止按钮,同时轮询原会话的任务状态;只有后端返回终态后,才显示对应结果并重新读取最终输出;停止请求被接受本身不代表任务已结束。状态查询临时失败会继续重试。未知或已被淘汰的任务返回 `runtime: null`。
ACP 同样通过 `_qwen/session/task_runtime` 提供该能力,参数为 `sessionId` 和 `taskId`,且要求连接拥有该会话。REST 与 ACP 都使用存活会话的所属 runtime。跨会话标签页恢复时,所有任务请求都绑定到原会话。
## 决策
- **按需获取,不并入任务列表。** 读输出和跑 `ps` 比快照贵,而且只有打开的面板需要。
- **路径由服务端解析。** 路由接收任务 id 而不是文件路径,session 从自己的 registry 里取路径。
- **叫常驻内存,不叫内存占用。** 数值是进程树的 RSS。共享页按进程各计一次,换出的页不计,容器沙箱下
daemon 看到的 pid 可能只是外层包装进程。因此标签写“常驻内存”,不写“内存占用”。
- **尾部沿用完成通知的 8 KiB 上限。** 面板和完成通知通过同一个 helper 读同一段字节。
- **只在运行中采样内存。** 结束的任务没有存活的 pid,显示旧数字比不显示更糟。
- **部分失败不隐藏成功输出。** 文件信息读取失败时仍展示已读到的尾部并附加提示;任务已不可用、读取失败、运行中暂无输出和终态无输出使用不同文案。时间格式遵循应用语言,时区仍为浏览器本地时区。
- **复制在本地完成。** 复制按钮只写浏览器剪贴板,不经过 daemon。
## 限制与风险
- 沙箱(容器)里的 shell,pid 可能描述的是包装进程,内存数字会明显低于真实负载。registry 没有 pid 时
该指标不显示。
- 输出尾部是一个快照。两次轮询之间写入的字节要等到下一次轮询才可见。
- 在读取状态和跑系统探针之间,pid 可能被复用。窗口很小,而且这个数字只用于参考。
## 验证
- 单测覆盖 `ps` 表格解析、进程树求和、环处理,以及 SDK client 调用。
- 回归测试覆盖终态与 StrictMode 的异步加载、失败重试、轮询清理、部分失败、空输出、应用语言及复制状态;标题在刷新和重复打开后保持短格式。
- 路由测试覆盖 REST 的会话所属 runtime、未知会话不回退,以及 ACP 的参数和连接归属校验;telemetry 目录与路由双向匹配。
- 跨会话恢复测试确认 runtime 请求使用原会话。停止回归测试通过实际点击按钮,覆盖属性仍为运行中的面板、状态查询临时失败及晚到的旧响应,确保最终输出与真实终态一致。
- 手工验证:跑一个后台 shell,打开面板确认输出、大小/最后写入、常驻内存、开始时间和复制动作;停止后
确认最终尾部和退出码仍然可见。
## 验收
- 运行中的 shell 无需手动打开输出文件,就能看到输出尾部、输出大小与最后写入时间、常驻内存。
- 结束的 shell 能看到最终尾部、退出码和结束时间。
- 未知任务或非 shell 任务返回空详情,而不是报错。
+1 -1
View File
@@ -123,7 +123,7 @@ operations when they share ownership and an SDK family.
| Workspace runtime status | [`GET /workspace/mcp`](./qwen-serve-protocol.md#get-workspacemcp) · [`GET /workspace/skills`](./qwen-serve-protocol.md#get-workspaceskills) · [`GET /workspace/providers`](./qwen-serve-protocol.md#get-workspaceproviders) · [`GET /workspace/env`](./qwen-serve-protocol.md#get-workspaceenv) · [`GET /workspace/preflight`](./qwen-serve-protocol.md#get-workspacepreflight) | `workspace_mcp`, `workspace_skills`, `workspace_providers`, `workspace_env`, `workspace_preflight`; legacy-primary | `DaemonClient.workspaceMcp`, `workspaceSkills`, `workspaceProviders`, `workspaceEnv`, `workspacePreflight` |
| File mutation | [`POST /file/write`](./qwen-serve-protocol.md#post-filewrite) · [`POST /file/edit`](./qwen-serve-protocol.md#post-fileedit) | `workspace_file_write`; legacy-primary | `DaemonClient.writeWorkspaceFile`, `DaemonClient.editWorkspaceFile` |
| Directory mutation | [`POST /workspaces/:workspace/directory/create`](./qwen-serve-protocol.md#post-workspacesworkspacedirectorycreate) · [`POST /workspaces/:workspace/directory/delete`](./qwen-serve-protocol.md#post-workspacesworkspacedirectorydelete) | `workspace_qualified_rest_core` plus `workspace_directory_create` or `workspace_directory_delete`; selected-runtime, trusted local only | `WorkspaceDaemonClient.createWorkspaceDirectory`, `WorkspaceDaemonClient.deleteWorkspaceDirectory` |
| Session inspection and tasks | [`GET /session/:id/supported-commands`](./qwen-serve-protocol.md#get-sessionidsupported-commands) · [`GET /session/:id/tasks`](./qwen-serve-protocol.md#get-sessionidtasks) · [`POST /session/:id/tasks/:taskId/workflow-action`](./qwen-serve-protocol.md#post-sessionidtaskstaskidworkflow-action) · [`GET /session/:id/lsp`](./qwen-serve-protocol.md#get-sessionidlsp) · [`GET /session/:id/resources`](./qwen-serve-protocol.md#get-sessionidresources) | `session_supported_commands`, `session_tasks`, `session_lsp`, `session_resources`; live-session-owner | `DaemonClient.sessionSupportedCommands`, `sessionTasks`, `sessionWorkflowTaskAction`, `sessionLspStatus`, `sessionResources` |
| Session inspection and tasks | [`GET /session/:id/supported-commands`](./qwen-serve-protocol.md#get-sessionidsupported-commands) · [`GET /session/:id/tasks`](./qwen-serve-protocol.md#get-sessionidtasks) · [`GET /session/:id/tasks/:taskId/runtime`](./qwen-serve-protocol.md#get-sessionidtaskstaskidruntime) · [`POST /session/:id/tasks/:taskId/workflow-action`](./qwen-serve-protocol.md#post-sessionidtaskstaskidworkflow-action) · [`GET /session/:id/lsp`](./qwen-serve-protocol.md#get-sessionidlsp) · [`GET /session/:id/resources`](./qwen-serve-protocol.md#get-sessionidresources) | `session_supported_commands`, `session_tasks`, `session_lsp`, `session_resources`; live-session-owner | `DaemonClient.sessionSupportedCommands`, `sessionTasks`, `sessionTaskRuntime`, `sessionWorkflowTaskAction`, `sessionLspStatus`, `sessionResources` |
| Workspace-qualified history | [`GET /workspaces/:workspace/session/:id/transcript`](./qwen-serve-protocol.md#get-workspacesworkspacesessionidtranscript) · [`GET /workspaces/:workspace/session/:id/export`](./qwen-serve-protocol.md#get-workspacesworkspacesessionidexport) · [`GET /workspaces/:workspace/session/:id/archive/export`](./qwen-serve-protocol.md#get-workspacesworkspacesessionidarchiveexport) | `workspace_persisted_transcript`, `workspace_session_export`, `workspace_archived_session_export`; persisted-workspace | `WorkspaceDaemonClient.getSessionTranscriptPage`, `exportSession`, `exportArchivedSession` |
| Worktree recovery | [`POST /session/:id/worktree-reset`](./qwen-serve-protocol.md#post-sessionidworktree-reset) | `session_worktree_reset_v1`; live-session-owner | `DaemonClient.resetWorktreeSession` |
| Persisted session catalog | [`POST /sessions/catalog`](./qwen-serve-protocol.md#post-sessionscatalog) · [`GET /workspace/:id/session-info`](./qwen-serve-protocol.md#get-workspaceidsession-info-and-get-workspacesworkspacesession-info) · [`GET /workspaces/:workspace/session-info`](./qwen-serve-protocol.md#get-workspaceidsession-info-and-get-workspacesworkspacesession-info) · [`GET /workspace/:id/sessions`](./qwen-serve-protocol.md#get-workspaceidsessions-and-get-workspacesworkspacesessions) · [`GET /workspaces/:workspace/sessions`](./qwen-serve-protocol.md#get-workspaceidsessions-and-get-workspacesworkspacesessions) · [`GET /workspaces/:workspace/sessions/live-state`](./qwen-serve-protocol.md#get-workspacesworkspacesessionslive-state) | `session_info`, `session_list`, `workspace_session_live_state`; persisted-workspace; `session_catalog_batch`; persisted-workspace per member | `DaemonClient.getStandaloneSession`, `listWorkspaceSessions`, `listSessionsCatalog`, `getWorkspaceSessionLiveState` |
+37
View File
@@ -2491,6 +2491,43 @@ top-level session). Agents launched by the top-level session omit
`parentAgentId` and `parentName`; clients should treat all three fields as
optional and fall back to a flat list when they are absent.
### `GET /session/:id/tasks/:taskId/runtime`
On-demand detail for one shell task. `GET /session/:id/tasks` carries lifecycle
facts only; this route reads the captured output file and probes the process
tree, so an open detail panel asks for it separately.
The ACP method is `_qwen/session/task_runtime`, with `sessionId` and `taskId`
parameters. Both transports resolve the live session owner; ACP also requires
the connection to own the session.
The path's `taskId` is resolved against the session's own shell registry;
callers never pass a file path.
```json
{
"v": 1,
"sessionId": "<sid>",
"taskId": "bg_0123abcd",
"runtime": {
"memoryBytes": 104857600,
"outputBytes": 65536,
"outputModifiedAt": 1700000000000,
"outputTail": "ready in 120ms\n",
"outputTailTruncated": true
}
}
```
`runtime` is `null` when the task is unknown, has been evicted, or is not a
shell task. `memoryBytes` is the resident set of the shell's process tree,
sampled only while the task is running; shared pages are counted once per
process, and a container sandbox may expose only the wrapper process.
`outputTail` is capped at the same 8 KiB limit used by the completion
notification, and `outputTailTruncated` marks a tail that starts mid-file.
`outputError` is set when the file cannot be read; fields from a part that did
succeed may still be present.
### `POST /session/:id/tasks/:taskId/workflow-action`
Controls a workflow run, or starts a new one. The ACP method is
+16
View File
@@ -4557,6 +4557,14 @@ describe('createAcpSessionBridge', () => {
tasks: [],
};
}
if (method === 'qwen/status/session/task_runtime') {
return {
v: 1,
sessionId: params['sessionId'],
taskId: params['taskId'],
runtime: { outputTail: 'ready' },
};
}
if (method === 'qwen/status/session/agent_trace') {
return {
v: 1,
@@ -4643,6 +4651,13 @@ describe('createAcpSessionBridge', () => {
sessionId: session.sessionId,
tasks: [],
});
await expect(
bridge.getSessionTaskRuntime(session.sessionId, 'bg_1'),
).resolves.toMatchObject({
sessionId: session.sessionId,
taskId: 'bg_1',
runtime: { outputTail: 'ready' },
});
await expect(
bridge.getSessionAgentsStatus(session.sessionId),
).resolves.toMatchObject({
@@ -4681,6 +4696,7 @@ describe('createAcpSessionBridge', () => {
'qwen/status/session/context',
'qwen/status/session/supported_commands',
'qwen/status/session/tasks',
'qwen/status/session/task_runtime',
'qwen/status/session/agents',
'qwen/status/session/agent_trace',
'qwen/status/session/lsp',
+11
View File
@@ -63,6 +63,7 @@ import type {
ServeSessionResourcesStatus,
ServeSessionSavedWorkflowStatus,
ServeSessionSupportedCommandsStatus,
ServeSessionTaskRuntimeStatus,
ServeSessionTasksStatus,
ServeSessionWorkflowTaskStatus,
ServeWorkflowActionInput,
@@ -2565,6 +2566,16 @@ export interface AcpSessionBridge extends WorkspaceEventBridge {
opts?: { includeWorkflows?: boolean },
): Promise<ServeSessionTasksStatus>;
/**
* Read on-demand runtime detail for one shell task: the process-tree RSS,
* the captured-output size/mtime, and the output tail. Read-only; returns
* `runtime: null` for an unknown/evicted task or a non-shell task.
*/
getSessionTaskRuntime(
sessionId: string,
taskId: string,
): Promise<ServeSessionTaskRuntimeStatus>;
/** Read persisted and live subagents for a live session. */
getSessionAgentsStatus(sessionId: string): Promise<ServeSessionAgentsStatus>;
@@ -93,6 +93,7 @@ import {
type ServeSessionLspStatus,
type ServeSessionResourcesStatus,
type ServeSessionSavedWorkflowStatus,
type ServeSessionTaskRuntimeStatus,
type ServeSessionTasksStatus,
type ServeSessionWorkflowTaskStatus,
type ServeWorkspaceMcpResourcesStatus,
@@ -13923,6 +13924,14 @@ export function createSessionControlPlane(
);
},
async getSessionTaskRuntime(sessionId, taskId) {
return requestSessionStatus<ServeSessionTaskRuntimeStatus>(
sessionId,
SERVE_STATUS_EXT_METHODS.sessionTaskRuntime,
{ taskId },
);
},
async getSessionAgentsStatus(sessionId) {
return requestSessionStatus<ServeSessionAgentsStatus>(
sessionId,
+42
View File
@@ -141,6 +141,7 @@ export const SERVE_STATUS_EXT_METHODS = {
sessionContextUsage: 'qwen/status/session/context_usage',
sessionSupportedCommands: 'qwen/status/session/supported_commands',
sessionTasks: 'qwen/status/session/tasks',
sessionTaskRuntime: 'qwen/status/session/task_runtime',
sessionAgents: 'qwen/status/session/agents',
sessionAgentTrace: 'qwen/status/session/agent_trace',
sessionStats: 'qwen/status/session/stats',
@@ -1143,6 +1144,47 @@ export interface ServeSessionTasksStatus {
tasks: ServeSessionTaskStatus[];
}
/**
* Point-in-time detail for one running or retained shell task.
*
* The tasks list carries lifecycle facts only; this surface carries the
* things that need a filesystem read or an OS probe and are therefore fetched
* on demand by an open detail panel.
*/
export interface ServeSessionShellTaskRuntime {
/**
* Resident set size of the shell's process tree, in bytes. Absent when the
* pid is unknown, the process already exited, or the platform probe failed.
* Shared pages are counted once per process, so this is an upper bound for
* "how much this task holds".
*/
memoryBytes?: number;
/** Size of the captured stdout/stderr file, in bytes. */
outputBytes?: number;
/** Epoch ms of the output file's last write. */
outputModifiedAt?: number;
/**
* Tail of the captured stdout/stderr, sanitized for display. Absent when
* the file is missing or empty.
*/
outputTail?: string;
/** True when `outputTail` starts mid-file (the head was dropped). */
outputTailTruncated?: boolean;
/**
* Read failure message. Fields from a part that did succeed may still be
* present (the stat and the tail read fail independently).
*/
outputError?: string;
}
export interface ServeSessionTaskRuntimeStatus {
v: typeof STATUS_SCHEMA_VERSION;
sessionId: string;
taskId: string;
/** Null when the task is unknown/evicted or is not a shell task. */
runtime: ServeSessionShellTaskRuntime | null;
}
export interface ServeSessionAgentsStatus {
v: typeof STATUS_SCHEMA_VERSION;
sessionId: string;
@@ -17604,6 +17604,71 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});
it('reads capped shell-task runtime output from the owning session registry', async () => {
const sessionId = '11111111-1111-1111-1111-111111111111';
const innerConfig = await setupSessionMocks(sessionId);
const dir = await realFsPromises.mkdtemp(
path.join(os.tmpdir(), 'qwen-runtime-detail-'),
);
const outputFile = path.join(dir, 'task.output');
const output = 'x'.repeat(9000) + '\nfinal output';
await realFsPromises.writeFile(outputFile, output);
const get = vi.fn((id: string) =>
id === 'bg_test' ? { status: 'completed', outputFile } : undefined,
);
Object.assign(innerConfig, {
getBackgroundShellRegistry: vi.fn().mockReturnValue({ get }),
});
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
try {
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
const detail = await agent.extMethod(
SERVE_STATUS_EXT_METHODS.sessionTaskRuntime,
{ sessionId, taskId: 'bg_test' },
);
expect(detail).toMatchObject({
v: 1,
sessionId,
taskId: 'bg_test',
runtime: {
outputBytes: Buffer.byteLength(output),
outputModifiedAt: expect.any(Number),
outputTail: expect.stringContaining('final output'),
outputTailTruncated: true,
},
});
expect(detail['runtime']).not.toHaveProperty('memoryBytes');
await expect(
agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionTaskRuntime, {
sessionId,
taskId: 'other',
}),
).resolves.toMatchObject({ runtime: null });
await expect(
agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionTaskRuntime, {
sessionId,
taskId: '',
}),
).rejects.toThrow('Invalid or missing sessionId/taskId');
expect(get).toHaveBeenCalledWith('bg_test');
expect(get).toHaveBeenCalledWith('other');
} finally {
mockConnectionState.resolve();
await agentPromise;
await realFsPromises.rm(dir, { recursive: true, force: true });
}
});
it('cancels running shell tasks', async () => {
const sessionId = '11111111-1111-1111-1111-111111111111';
const innerConfig = await setupSessionMocks(sessionId);
@@ -243,6 +243,8 @@ import {
SessionExecutionEngineError,
type SessionExecutionEngine,
} from '@qwen-code/qwen-code-core/services/session-execution-engine.js';
import { readBackgroundShellOutputTail } from '@qwen-code/qwen-code-core/services/backgroundShellRegistry.js';
import { sampleProcessTreeRssBytes } from '@qwen-code/qwen-code-core/utils/process-resources.js';
import type { Stats } from 'node:fs';
import { realpathSync } from 'node:fs';
import * as fs from 'node:fs/promises';
@@ -425,6 +427,8 @@ import {
type ServeSessionResourcesStatus,
type ServeSessionSavedWorkflowDetail,
type ServeSessionSavedWorkflowStatus,
type ServeSessionShellTaskRuntime,
type ServeSessionTaskRuntimeStatus,
type ServeSessionTasksStatus,
type ServeStatus,
type ServeStatusCell,
@@ -9231,6 +9235,66 @@ class QwenAgent implements Agent {
);
}
/**
* On-demand detail for one shell task. Kept separate from the tasks list
* because both parts cost more than a snapshot read: the output tail reads
* the captured file, and the memory number spawns a platform process probe.
* Only an open detail panel asks for it, and only while it is visible.
*/
private async buildSessionTaskRuntimeStatus(
sessionId: string,
taskId: string,
): Promise<ServeSessionTaskRuntimeStatus> {
const session = this.sessionOrThrow(sessionId);
const entry = session.getConfig().getBackgroundShellRegistry().get(taskId);
if (!entry) {
return {
v: STATUS_SCHEMA_VERSION,
sessionId,
taskId,
runtime: null,
};
}
const runtime: ServeSessionShellTaskRuntime = {};
if (entry.status === 'running' && entry.pid !== undefined) {
const memoryBytes = await sampleProcessTreeRssBytes(entry.pid);
if (memoryBytes !== undefined) runtime.memoryBytes = memoryBytes;
}
try {
const stat = await fs.lstat(entry.outputFile);
if (stat.isFile()) {
runtime.outputBytes = stat.size;
runtime.outputModifiedAt = stat.mtimeMs;
}
} catch (error) {
// A missing file is the normal state before the first write; anything
// else is worth surfacing instead of pretending there is no output.
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
runtime.outputError =
error instanceof Error ? error.message : String(error);
}
}
const tail = readBackgroundShellOutputTail(entry.outputFile);
if (tail) {
if ('error' in tail) {
runtime.outputError ??= tail.error;
} else {
runtime.outputTail = tail.text;
if (tail.truncated) runtime.outputTailTruncated = true;
}
}
return {
v: STATUS_SCHEMA_VERSION,
sessionId,
taskId,
runtime,
};
}
private async buildSessionAgentsStatus(
sessionId: string,
): Promise<ServeSessionAgentsStatus> {
@@ -10195,6 +10259,25 @@ class QwenAgent implements Agent {
this.canUseWorkflowControls(session.getConfig()),
)) as unknown as Record<string, unknown>;
}
case SERVE_STATUS_EXT_METHODS.sessionTaskRuntime: {
const sessionId = params['sessionId'];
const taskId = params['taskId'];
if (
typeof sessionId !== 'string' ||
sessionId.length === 0 ||
typeof taskId !== 'string' ||
taskId.length === 0
) {
throw RequestError.invalidParams(
undefined,
'Invalid or missing sessionId/taskId',
);
}
return (await this.buildSessionTaskRuntimeStatus(
sessionId,
taskId,
)) as unknown as Record<string, unknown>;
}
case SERVE_STATUS_EXT_METHODS.sessionAgents: {
const sessionId = params['sessionId'];
if (typeof sessionId !== 'string' || sessionId.length === 0) {
@@ -310,6 +310,7 @@ const SSH_METHODS = new Set([
'session/detach',
'session/context_usage',
'session/tasks',
'session/task_runtime',
'session/agents',
'session/agent_trace',
'session/attachments',
@@ -379,6 +380,7 @@ const ALL_QWEN_VENDOR_METHODS: readonly string[] = [
`${QWEN_METHOD_NS}session/detach`,
`${QWEN_METHOD_NS}session/context_usage`,
`${QWEN_METHOD_NS}session/tasks`,
`${QWEN_METHOD_NS}session/task_runtime`,
`${QWEN_METHOD_NS}session/agents`,
`${QWEN_METHOD_NS}session/agent_trace`,
`${QWEN_METHOD_NS}session/attachments`,
@@ -4127,6 +4129,26 @@ export class AcpDispatcher {
return;
}
case `${QWEN_METHOD_NS}session/task_runtime`: {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;
const taskId = params['taskId'];
if (typeof taskId !== 'string' || taskId.length === 0) {
if (id !== undefined) {
conn.sendConn(
error(id, RPC.INVALID_PARAMS, '`taskId` is required'),
);
}
return;
}
const result = await this.bridge.getSessionTaskRuntime(
sessionId,
taskId,
);
this.replyConn(conn, id, result as unknown);
return;
}
case `${QWEN_METHOD_NS}session/agents`: {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;
@@ -582,6 +582,14 @@ class FakeBridge {
this.lastSessionTasksOptions = opts;
return { sessionId, tasks: [] };
}
async getSessionTaskRuntime(sessionId: string, taskId: string) {
return {
v: 1 as const,
sessionId,
taskId,
runtime: { outputTail: 'ready' },
};
}
async getSessionAgentsStatus(sessionId: string) {
return { sessionId, tasks: [] };
}
@@ -9281,6 +9289,55 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
});
});
it.each([
{ sessionId: 'sess-1', taskId: 'bg_1', owned: true, valid: true },
{ sessionId: 'sess-1', taskId: '', owned: true, valid: false },
{ sessionId: 'sess-1', taskId: 123, owned: true, valid: false },
{
sessionId: 'other-session',
taskId: 'bg_1',
owned: false,
valid: false,
},
])(
'_qwen/session/task_runtime validates ownership and task id: $sessionId / $taskId',
async ({ sessionId, taskId, owned, valid }) => {
const read = vi.spyOn(bridge, 'getSessionTaskRuntime');
const connId = await initialize();
const streamRes = openStream(connId);
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 99,
method: 'session/new',
params: {},
});
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 58,
method: '_qwen/session/task_runtime',
params: { sessionId, taskId },
});
const frames = await takeFrames(await streamRes, 2);
if (valid) {
expect(frames[1]).toMatchObject({
result: { sessionId, taskId, runtime: { outputTail: 'ready' } },
});
expect(read).toHaveBeenCalledWith(sessionId, taskId);
} else {
expect(frames[1]).toMatchObject({
error: {
code: -32602,
message: expect.stringContaining(owned ? 'taskId' : 'not owned'),
},
});
expect(read).not.toHaveBeenCalled();
}
read.mockRestore();
},
);
it('_qwen/session/tasks/cancel forwards the task and trusted client context', async () => {
const connId = await initialize();
const streamRes = openStream(connId);
+19
View File
@@ -7051,6 +7051,25 @@ export function registerSessionRoutes(
),
);
app.get(
'/session/:id/tasks/:taskId/runtime',
withOwnerReadSession(
'GET /session/:id/tasks/:taskId/runtime',
async (req, res, sessionId, runtime) => {
const taskId = req.params['taskId'];
if (!taskId) {
res.status(400).json({
error: '`taskId` route parameter is required',
});
return;
}
res
.status(200)
.json(await runtime.bridge.getSessionTaskRuntime(sessionId, taskId));
},
),
);
app.get(
'/session/:id/agents',
withOwnerReadSession(
+40 -7
View File
@@ -2699,6 +2699,9 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
sessionTasksOptions.push(opts);
return sessionTasksImpl(sessionId, opts);
},
async getSessionTaskRuntime(sessionId, taskId) {
return { v: 1, sessionId, taskId, runtime: { outputTail: 'ready' } };
},
async getSessionAgentsStatus(sessionId) {
sessionAgentsCalls.push(sessionId);
return sessionAgentsImpl(sessionId);
@@ -11452,13 +11455,17 @@ describe('createServeApp', () => {
it('dispatches read-only session snapshots through the live owner runtime', async () => {
const primaryBridge = fakeBridge();
const secondaryBridge = fakeBridge({
summaryImpl: (sessionId) => ({
sessionId,
workspaceCwd: WS_DIFFERENT,
createdAt: '2026-05-17T12:00:00.000Z',
clientCount: 1,
hasActivePrompt: false,
}),
summaryImpl: (sessionId) => {
if (sessionId !== 's-secondary')
throw new SessionNotFoundError(sessionId);
return {
sessionId,
workspaceCwd: WS_DIFFERENT,
createdAt: '2026-05-17T12:00:00.000Z',
clientCount: 1,
hasActivePrompt: false,
};
},
sessionContextImpl: async (sessionId) => ({
v: 1,
sessionId,
@@ -11497,6 +11504,14 @@ describe('createServeApp', () => {
bridge: secondaryBridge,
}),
]);
const primaryRuntimeRead = vi.spyOn(
primaryBridge,
'getSessionTaskRuntime',
);
const secondaryRuntimeRead = vi.spyOn(
secondaryBridge,
'getSessionTaskRuntime',
);
const app = createServeApp(
{ ...baseOpts, workspace: WS_BOUND },
undefined,
@@ -11510,6 +11525,24 @@ describe('createServeApp', () => {
.get('/session/s-secondary/resources')
.set('Host', `127.0.0.1:${baseOpts.port}`);
const runtimeRes = await request(app)
.get('/session/s-secondary/tasks/bg_1/runtime')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(runtimeRes.status).toBe(200);
expect(runtimeRes.body).toEqual({
v: 1,
sessionId: 's-secondary',
taskId: 'bg_1',
runtime: { outputTail: 'ready' },
});
expect(secondaryRuntimeRead).toHaveBeenCalledWith('s-secondary', 'bg_1');
expect(primaryRuntimeRead).not.toHaveBeenCalled();
const missingRes = await request(app)
.get('/session/missing/tasks/bg_1/runtime')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(missingRes.status).toBe(404);
expect(secondaryRuntimeRead).toHaveBeenCalledTimes(1);
expect(primaryRuntimeRead).not.toHaveBeenCalled();
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
sessionId: 's-secondary',
@@ -104,7 +104,7 @@ describe('legacy session telemetry route drift guard', () => {
.map(({ method, path }) => `${method} ${path}`)
.sort();
expect(registered).toHaveLength(82);
expect(registered).toHaveLength(83);
expect(registered).toEqual(catalog);
});
});
@@ -1141,19 +1141,19 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => {
});
describe('legacy session telemetry route catalog', () => {
// Message group reads and deletion resolve the live session owner in the
// handler, preserving the two pre-resolved routes.
it('contains 82 unique routes with the audited 80/2 attribution split', () => {
// Shell runtime and message group routes resolve the live session owner
// in the handler, preserving the two pre-resolved routes.
it('contains 83 unique routes with the audited 81/2 attribution split', () => {
const keys = legacySessionTelemetryRoutes.map(
({ method, path }) => `${method} ${path}`,
);
expect(keys).toHaveLength(82);
expect(new Set(keys).size).toBe(82);
expect(keys).toHaveLength(83);
expect(new Set(keys).size).toBe(83);
expect(
legacySessionTelemetryRoutes.filter(
({ attribution }) => attribution === 'handler_resolved',
),
).toHaveLength(80);
).toHaveLength(81);
expect(
legacySessionTelemetryRoutes.filter(
({ attribution }) => attribution === 'pre_resolved',
@@ -151,6 +151,12 @@ export const legacySessionTelemetryRoutes = [
attribution: 'handler_resolved',
route: 'GET /session/:id/tasks',
},
{
method: 'GET',
path: '/session/:id/tasks/:taskId/runtime',
attribution: 'handler_resolved',
route: 'GET /session/:id/tasks/:taskId/runtime',
},
{
method: 'GET',
path: '/session/:id/agents',
@@ -54,12 +54,25 @@ function stripOutputControlChars(text: string): string {
return out;
}
type OutputTailResult =
export type BackgroundShellOutputTail =
| { text: string; truncated: boolean }
| { error: string }
| undefined;
function readOutputTail(outputFile: string): OutputTailResult {
/**
* Reads the last {@link MAX_NOTIFICATION_OUTPUT_TAIL_BYTES} bytes of a
* background shell's captured stdout/stderr.
*
* The completion notification uses this to show the model what the shell
* printed; the daemon's shell-task detail route reads the same tail so the
* user sees the same bytes. `undefined` means no displayable text (an empty
* or non-regular file, or a tail containing only stripped control characters
* and trailing whitespace). `{ error }` means the read failed, including a
* missing file.
*/
export function readBackgroundShellOutputTail(
outputFile: string,
): BackgroundShellOutputTail {
let fd: number | undefined;
try {
// O_NOFOLLOW (or the compensating identity check where the flag does
@@ -537,7 +550,7 @@ export class BackgroundShellRegistry {
`<result>${escapeXml(stripDisplayControlChars(entry.error))}</result>`,
);
}
const outputTail = readOutputTail(entry.outputFile);
const outputTail = readBackgroundShellOutputTail(entry.outputFile);
if (outputTail) {
if ('error' in outputTail) {
xmlParts.push(`<output-tail error="unreadable" />`);
@@ -0,0 +1,66 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import {
parseProcessTable,
sampleProcessTreeRssBytes,
sumProcessTreeRssBytes,
} from './process-resources.js';
describe('parseProcessTable', () => {
it('parses pid/ppid/rss triplets and ignores other lines', () => {
const rows = parseProcessTable(`
1 0 1024
2 1 2048
not a row
3 1 4096
`);
expect(rows).toEqual([
{ pid: 1, ppid: 0, rss: 1024 },
{ pid: 2, ppid: 1, rss: 2048 },
{ pid: 3, ppid: 1, rss: 4096 },
]);
});
});
describe('sumProcessTreeRssBytes', () => {
it('sums the root and every descendant, ignoring unrelated rows', () => {
const rows = parseProcessTable('1 0 100\n2 1 200\n3 2 300\n4 9 400\n');
expect(sumProcessTreeRssBytes(rows, 1, 1024)).toBe(
(100 + 200 + 300) * 1024,
);
});
it('returns undefined when the root is absent', () => {
expect(
sumProcessTreeRssBytes(parseProcessTable('2 1 10\n'), 1, 1024),
).toBeUndefined();
});
it('terminates on a parent cycle', () => {
expect(
sumProcessTreeRssBytes(
[
{ pid: 1, ppid: 2, rss: 10 },
{ pid: 2, ppid: 1, rss: 20 },
],
1,
1,
),
).toBe(30);
});
});
describe('sampleProcessTreeRssBytes', () => {
it.runIf(process.platform !== 'win32')(
'samples the current process tree',
async () => {
const bytes = await sampleProcessTreeRssBytes(process.pid);
expect(bytes).toBeGreaterThan(0);
},
);
});
@@ -0,0 +1,138 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { execFile } from 'node:child_process';
/**
* Resident-set sampling for a process tree, used by the daemon's shell-task
* detail surface.
*
* Two deliberate compromises:
* - RSS overcounts shared pages (dynamic libraries, shared memory are counted
* once per process) and undercounts swapped-out pages. It answers "roughly
* how much is this task holding", not "what does the system charge it".
* - The pid is sampled only while the registry still says `running`. A process
* that exits between the status read and the sample can have its pid reused
* before `ps` runs; the window is small, and a stale number is preferable to
* blocking the read path on a start-time probe.
*/
const SAMPLE_TIMEOUT_MS = 2_000;
const SAMPLE_MAX_BUFFER = 4 * 1024 * 1024;
/** `ps` reports RSS in 1024-byte units on both macOS and Linux. */
const PS_RSS_UNIT_BYTES = 1024;
/** `Win32_Process.WorkingSetSize` is already in bytes. */
const WINDOWS_RSS_UNIT_BYTES = 1;
export interface ProcessTableRow {
pid: number;
ppid: number;
/** Resident set size in the unit the platform reports. */
rss: number;
}
/** Parses `pid ppid rss` triplets, one per line, ignoring anything else. */
export function parseProcessTable(output: string): ProcessTableRow[] {
const rows: ProcessTableRow[] = [];
for (const line of output.split(/\r?\n/)) {
const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s*$/.exec(line);
if (!match) continue;
rows.push({
pid: Number(match[1]),
ppid: Number(match[2]),
rss: Number(match[3]),
});
}
return rows;
}
/**
* Sums RSS over the tree rooted at `rootPid` (the root included).
* Returns `undefined` when the root is absent, which is the truthful answer
* for a process that already exited.
*/
export function sumProcessTreeRssBytes(
rows: readonly ProcessTableRow[],
rootPid: number,
rssUnitBytes: number,
): number | undefined {
if (!Number.isInteger(rootPid) || rootPid <= 0) return undefined;
const rssByPid = new Map<number, number>();
const childrenByPid = new Map<number, number[]>();
for (const row of rows) {
rssByPid.set(row.pid, row.rss);
const siblings = childrenByPid.get(row.ppid);
if (siblings) siblings.push(row.pid);
else childrenByPid.set(row.ppid, [row.pid]);
}
if (!rssByPid.has(rootPid)) return undefined;
let total = 0;
const pending = [rootPid];
const visited = new Set<number>();
while (pending.length > 0) {
const pid = pending.pop()!;
if (visited.has(pid)) continue;
visited.add(pid);
total += (rssByPid.get(pid) ?? 0) * rssUnitBytes;
const children = childrenByPid.get(pid);
if (children) pending.push(...children);
}
return total;
}
function run(file: string, args: readonly string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
file,
[...args],
{
timeout: SAMPLE_TIMEOUT_MS,
maxBuffer: SAMPLE_MAX_BUFFER,
windowsHide: true,
},
(error, stdout) => {
if (error) reject(error);
else resolve(stdout);
},
);
});
}
/**
* Samples resident memory for the process tree rooted at `rootPid`, in bytes.
* Returns `undefined` when the tree cannot be sampled (root gone, `ps`
* unavailable, timeout). Never throws.
*/
export async function sampleProcessTreeRssBytes(
rootPid: number,
): Promise<number | undefined> {
if (!Number.isInteger(rootPid) || rootPid <= 0) return undefined;
try {
if (process.platform === 'win32') {
const stdout = await run('powershell.exe', [
'-NoProfile',
'-NonInteractive',
'-Command',
'Get-CimInstance Win32_Process | ForEach-Object { "{0} {1} {2}" -f $_.ProcessId, $_.ParentProcessId, $_.WorkingSetSize }',
]);
return sumProcessTreeRssBytes(
parseProcessTable(stdout),
rootPid,
WINDOWS_RSS_UNIT_BYTES,
);
}
const stdout = await run('ps', ['-axo', 'pid=,ppid=,rss=']);
return sumProcessTreeRssBytes(
parseProcessTable(stdout),
rootPid,
PS_RSS_UNIT_BYTES,
);
} catch {
return undefined;
}
}
@@ -97,6 +97,7 @@ import type {
DaemonStatusReport,
DaemonUpdateStatus,
DaemonStatusReportDetail,
DaemonSessionTaskRuntimeStatus,
DaemonSessionTaskWithWorkflowStatus,
DaemonSessionTasksStatus,
DaemonSessionWorkflowTaskStatus,
@@ -3934,6 +3935,26 @@ export class DaemonClient {
);
}
async sessionTaskRuntime(
sessionId: string,
taskId: string,
clientId?: string,
): Promise<DaemonSessionTaskRuntimeStatus> {
return await this.fetchWithTimeout(
`${this.baseUrl}/session/${urlEncode(sessionId)}/tasks/${urlEncode(taskId)}/runtime`,
{ headers: this.headers({}, clientId) },
async (res) => {
if (!res.ok) {
throw await this.failOnError(
res,
'GET /session/:id/tasks/:taskId/runtime',
);
}
return (await res.json()) as DaemonSessionTaskRuntimeStatus;
},
);
}
async sessionAgents(
sessionId: string,
clientId?: string,
@@ -65,6 +65,7 @@ import type {
DaemonSession,
DaemonSessionStatsStatus,
DaemonSessionSupportedCommandsStatus,
DaemonSessionTaskRuntimeStatus,
DaemonSessionTaskWithWorkflowStatus,
DaemonSessionTasksStatus,
DaemonSessionWorkflowTaskStatus,
@@ -1218,6 +1219,14 @@ export class DaemonSessionClient {
return this.client.sessionTasks(this.sessionId, this.clientId);
}
taskRuntime(taskId: string): Promise<DaemonSessionTaskRuntimeStatus> {
return this.client.sessionTaskRuntime(
this.sessionId,
taskId,
this.clientId,
);
}
agents(signal?: AbortSignal): Promise<DaemonSessionAgentsStatus> {
return this.client.sessionAgents(this.sessionId, this.clientId, signal);
}
@@ -695,9 +695,11 @@ export type {
DaemonPendingUserQuestionInteraction,
DaemonSessionState,
DaemonSessionSummary,
DaemonSessionShellTaskRuntime,
DaemonSessionShellTaskStatus,
DaemonSessionSupportedCommandsStatus,
DaemonSessionTaskLifecycleStatus,
DaemonSessionTaskRuntimeStatus,
DaemonSessionTaskStatus,
DaemonSessionTaskWithWorkflowStatus,
DaemonSessionTasksStatus,
@@ -3576,6 +3576,28 @@ export interface DaemonSessionTasksStatus {
tasks: DaemonSessionTaskStatus[];
}
/**
* On-demand detail for one shell task. `memoryBytes` is the resident memory
* of the shell's process tree (shared pages counted once per process);
* `outputTail` is the last few KB of the captured stdout/stderr.
*/
export interface DaemonSessionShellTaskRuntime {
memoryBytes?: number;
outputBytes?: number;
outputModifiedAt?: number;
outputTail?: string;
outputTailTruncated?: boolean;
outputError?: string;
}
export interface DaemonSessionTaskRuntimeStatus {
v: 1;
sessionId: string;
taskId: string;
/** Null when the task is unknown/evicted or is not a shell task. */
runtime: DaemonSessionShellTaskRuntime | null;
}
export interface DaemonSessionAgentsStatus {
v: 1;
sessionId: string;
+2
View File
@@ -275,8 +275,10 @@ export {
type DaemonSessionSubscribeOptions,
type DaemonSessionState,
type DaemonSessionSummary,
type DaemonSessionShellTaskRuntime,
type DaemonSessionSupportedCommandsStatus,
type DaemonSessionTaskLifecycleStatus,
type DaemonSessionTaskRuntimeStatus,
type DaemonSessionTaskStatus,
type DaemonSessionTaskWithWorkflowStatus,
type DaemonSessionTasksStatus,
@@ -36,6 +36,7 @@ import type {
DaemonSessionResourcesStatus,
DaemonSessionOrganizationResult,
DaemonSessionSupportedCommandsStatus,
DaemonSessionTaskRuntimeStatus,
DaemonSessionTasksStatus,
DaemonWorkspaceEnvStatus,
DaemonWorkspaceMcpStatus,
@@ -2714,6 +2715,18 @@ describe('DaemonClient', () => {
now: 1_700_000_000_000,
tasks: [],
};
const runtime: DaemonSessionTaskRuntimeStatus = {
v: 1,
sessionId: 'with/slash',
taskId: 'bg_1',
runtime: {
memoryBytes: 2048,
outputBytes: 4096,
outputModifiedAt: 1_700_000_000_000,
outputTail: 'ready',
outputTailTruncated: false,
},
};
const lsp: DaemonSessionLspStatus = {
v: 1,
sessionId: 'with/slash',
@@ -2766,6 +2779,9 @@ describe('DaemonClient', () => {
) {
return jsonResponse(200, tasks);
}
if (req.url.endsWith('/session/with%2Fslash/tasks/bg_1/runtime')) {
return jsonResponse(200, runtime);
}
if (req.url.endsWith('/session/with%2Fslash/lsp')) {
return jsonResponse(200, lsp);
}
@@ -2785,6 +2801,9 @@ describe('DaemonClient', () => {
await expect(
client.sessionTasks('with/slash', 'client-1'),
).resolves.toEqual(tasks);
await expect(
client.sessionTaskRuntime('with/slash', 'bg_1', 'client-1'),
).resolves.toEqual(runtime);
await expect(
client.sessionWorkflowTasks('with/slash', 'client-1'),
).resolves.toEqual(tasks);
@@ -2798,6 +2817,7 @@ describe('DaemonClient', () => {
['GET', 'http://daemon/session/with%2Fslash/context'],
['GET', 'http://daemon/session/with%2Fslash/supported-commands'],
['GET', 'http://daemon/session/with%2Fslash/tasks'],
['GET', 'http://daemon/session/with%2Fslash/tasks/bg_1/runtime'],
[
'GET',
'http://daemon/session/with%2Fslash/tasks?includeWorkflows=true',
@@ -2812,6 +2832,7 @@ describe('DaemonClient', () => {
'client-1',
'client-1',
'client-1',
'client-1',
]);
});
+34 -7
View File
@@ -498,6 +498,12 @@ const {
hasMore: false,
}),
getWorkspaceSessionLiveState: vi.fn(),
sessionTaskRuntime: vi.fn().mockResolvedValue({
v: 1,
sessionId: 'nested-session',
taskId: 'shell-1',
runtime: { outputTail: 'restored output' },
}),
sessionTasks: vi.fn().mockResolvedValue({
v: 1,
sessionId: 'session-1',
@@ -621,6 +627,7 @@ const {
listSources: vi
.fn<() => Promise<SessionSourcesResult>>()
.mockResolvedValue({ revision: 0, sources: [] }),
getTaskRuntime: vi.fn(),
getTasks: vi.fn().mockResolvedValue({
v: 1,
sessionId: 'session-1',
@@ -2672,7 +2679,11 @@ vi.doMock('./components/messages/AskUserQuestion', async () => {
});
vi.doMock('./components/messages/TasksStatusMessage', async () => {
const React = await import('react');
const actual = await vi.importActual<
typeof import('./components/messages/TasksStatusMessage')
>('./components/messages/TasksStatusMessage');
return {
shellTaskPanelTitle: actual.shellTaskPanelTitle,
TasksStatusMessage: (props: {
planTodos?: Array<{ id: string }>;
agentTools?: Array<{ callId: string }>;
@@ -7497,6 +7508,19 @@ describe('task activity key', () => {
expect(mockWorkspace.client.sessionTasks).toHaveBeenLastCalledWith(
'nested-session',
);
const restored = testState.latestArtifactPanelProps?.tabs.find(
(tab) => tab.kind === 'shell',
);
expect(restored?.kind).toBe('shell');
if (restored?.kind !== 'shell')
throw new Error('Shell tab was not restored');
expect(restored.title).toBe('npm');
await restored.sessionActions!.getTaskRuntime('shell-1');
expect(mockWorkspace.client.sessionTaskRuntime).toHaveBeenCalledWith(
'nested-session',
'shell-1',
);
expect(mockSessionActions.getTaskRuntime).not.toHaveBeenCalled();
});
it('does not restart current-session task restoration on transcript updates', async () => {
@@ -18775,12 +18799,12 @@ describe('App session callbacks', () => {
status: 'running',
startTime: 1_000,
runtimeMs: 5_000,
command: 'npm run dev',
command: '/opt/bin/node server.js',
cwd: '/tmp/project',
pid: 42,
};
testState.backgroundTasks = [shell];
const { container } = renderApp();
const { container, rerender } = renderApp();
act(() => {
testState.latestChatEditorProps?.onChatWidthModeChange?.('wide');
@@ -18796,7 +18820,7 @@ describe('App session callbacks', () => {
container.querySelectorAll<HTMLButtonElement>(
'[data-testid="environment-panel"] ul button',
),
).find((button) => button.textContent?.includes('npm run dev'));
).find((button) => button.textContent?.includes('/opt/bin/node server.js'));
act(() => shellButton?.click());
@@ -18805,11 +18829,14 @@ describe('App session callbacks', () => {
'[data-testid="environment-panel"]:not([hidden])',
),
).not.toBeNull();
expect(
container.querySelector('button[title="npm run dev"]'),
).not.toBeNull();
expect(container.querySelector('button[title="node"]')).not.toBeNull();
testState.backgroundTasks = [{ ...shell, runtimeMs: 8000 }];
rerender();
expect(container.querySelector('button[title="node"]')).not.toBeNull();
act(() => shellButton?.click());
expect(container.querySelector('button[title="node"]')).not.toBeNull();
expect(container.textContent).toContain('/tmp/project');
expect(testState.latestBackgroundTasksRefreshTrigger).toBe(2);
expect(testState.latestBackgroundTasksRefreshTrigger).toBe(3);
});
it('restores the environment panel open state per session', () => {
+19 -5
View File
@@ -451,6 +451,7 @@ import { QueuedPromptDisplay } from './components/QueuedPromptDisplay';
import { useQueuedPrompts } from './hooks/useQueuedPrompts';
import { useNewSessionSuggestion } from './hooks/useNewSessionSuggestion';
import {
shellTaskPanelTitle,
TasksStatusMessage,
type SerializedTasksMessage,
} from './components/messages/TasksStatusMessage';
@@ -6469,6 +6470,11 @@ export function App({
...sessionActions,
getTasks: () =>
workspace.client.sessionTasks(tab.sourceSessionId),
getTaskRuntime: (taskId) =>
workspace.client.sessionTaskRuntime(
tab.sourceSessionId,
taskId,
),
cancelTask: (taskId, kind) =>
workspace.client.sessionTaskCancel(
tab.sourceSessionId,
@@ -6489,7 +6495,7 @@ export function App({
: {
id: tab.id,
kind: 'shell',
title: tab.title,
title: shellTaskPanelTitle(task.command),
task,
sessionId: tab.sourceSessionId,
sessionActions: crossSessionActions,
@@ -7176,7 +7182,15 @@ export function App({
}
if (!task || task.kind !== tab.kind) return undefined;
const { taskId: _taskId, ...rest } = tab;
return { ...rest, task, sessionActions } as ArtifactPanelTab;
return {
...rest,
title:
task.kind === 'shell'
? shellTaskPanelTitle(task.command)
: tab.title,
task,
sessionActions,
} as ArtifactPanelTab;
}
case 'side_task':
return tab.sessionId ? tab : undefined;
@@ -7361,7 +7375,7 @@ export function App({
? `shell:${sourceSessionId}:${task.id}`
: `shell:${task.id}`,
kind: 'shell',
title: task.command,
title: shellTaskPanelTitle(task.command),
task,
...(sourceSessionId ? { sessionId: sourceSessionId } : {}),
...(sourceSessionActions
@@ -7375,7 +7389,7 @@ export function App({
const mergedTask = mergeShellTaskSnapshot(item.task, task);
return {
...tab,
title: mergedTask.command,
title: shellTaskPanelTitle(mergedTask.command),
task: mergedTask,
};
})
@@ -8545,7 +8559,7 @@ export function App({
changed = true;
return {
...tab,
title: mergedTask.command,
title: shellTaskPanelTitle(mergedTask.command),
task: mergedTask,
};
});
@@ -49,6 +49,7 @@ const {
mockActions: {
cancelTask: vi.fn(),
getTasks: vi.fn(),
getTaskRuntime: vi.fn(),
},
mockWorkspaceActions: {
readFileBytes: vi.fn(),
@@ -453,6 +454,10 @@ afterEach(() => {
}
mockActions.cancelTask.mockReset();
mockActions.getTasks.mockReset();
mockActions.getTaskRuntime.mockReset();
// The shell detail effect fetches once on mount. Keep it pending so the
// synchronous assertions never race a post-render state update.
mockActions.getTaskRuntime.mockImplementation(() => new Promise(() => {}));
mockWorkspaceActions.readFileBytes.mockReset();
mockWorkspaceActions.readWorkspaceFile.mockReset();
mockWorkspaceActions.stat.mockReset();
@@ -4233,6 +4238,7 @@ describe('ArtifactPanel shell tab', () => {
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
mockActions.getTaskRuntime.mockImplementation(() => new Promise(() => {}));
act(() => {
root.render(shellPanel(task));
@@ -0,0 +1,333 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, StrictMode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type {
DaemonSessionShellTaskStatus,
DaemonSessionTaskRuntimeStatus,
} from '@qwen-code/sdk/daemon';
import { I18nProvider } from '../../i18n';
const actions = vi.hoisted(() => ({
getTaskRuntime: vi.fn<() => Promise<DaemonSessionTaskRuntimeStatus>>(),
cancelTask: vi.fn(),
getTasks: vi.fn(),
}));
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
useActions: () => actions,
}));
const { ShellTaskDetail } = await import('./TasksStatusMessage');
const task: DaemonSessionShellTaskStatus = {
kind: 'shell',
id: 'bg_1',
label: 'server',
description: 'server',
command: '/opt/bin/node server.js',
cwd: '/project',
status: 'running',
startTime: 1000,
runtimeMs: 2000,
pid: 42,
};
const result = (outputTail: string): DaemonSessionTaskRuntimeStatus => ({
v: 1,
sessionId: 'session-1',
taskId: task.id,
runtime: { outputTail },
});
let root: Root | undefined;
let container: HTMLDivElement;
function render(next = task, strict = false, language: 'en' | 'zh-CN' = 'en') {
if (!root) {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
}
const panel = (
<I18nProvider language={language}>
<ShellTaskDetail task={next} />
</I18nProvider>
);
act(() => root!.render(strict ? <StrictMode>{panel}</StrictMode> : panel));
}
async function settle() {
await act(async () => {});
}
afterEach(() => {
if (root) act(() => root!.unmount());
root = undefined;
container?.remove();
actions.getTaskRuntime.mockReset();
actions.getTasks.mockReset();
actions.cancelTask.mockReset();
vi.clearAllMocks();
vi.useRealTimers();
});
describe('ShellTaskDetail runtime', () => {
it.each(['completed', 'cancelled'] as const)(
'reads final output when %s interrupts a pending sample',
async (status) => {
let finish!: (value: DaemonSessionTaskRuntimeStatus) => void;
actions.getTaskRuntime
.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
)
.mockResolvedValueOnce(result('FINAL OUTPUT'));
render();
render({ ...task, status, endTime: 3000 });
await settle();
expect(actions.getTaskRuntime).toHaveBeenCalledTimes(2);
expect(container.textContent).toContain('FINAL OUTPUT');
await act(async () => finish(result('OLD OUTPUT')));
expect(container.textContent).toContain('FINAL OUTPUT');
expect(container.textContent).not.toContain('OLD OUTPUT');
},
);
it.each(['cancelled', 'completed', 'failed'] as const)(
'waits for the actual %s state after Stop before reading the final output',
async (status) => {
vi.useFakeTimers();
let stopped = false;
const terminalTask = { ...task, status, endTime: 4000, runtimeMs: 3000 };
actions.cancelTask.mockResolvedValue({
cancelled: true,
status: 'running',
});
actions.getTasks.mockImplementation(async () => ({
v: 1,
sessionId: 'session-1',
now: 4000,
tasks: [stopped ? terminalTask : task],
}));
actions.getTaskRuntime.mockImplementation(async () =>
result(stopped ? 'FINAL OUTPUT' : 'BEFORE STOP'),
);
render();
await settle();
const stop = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'Stop',
);
await act(async () => stop!.click());
expect(actions.cancelTask).toHaveBeenCalledTimes(1);
expect(container.querySelector('[data-status="running"]')).not.toBeNull();
expect(stop!.disabled).toBe(true);
expect(container.textContent).not.toContain('FINAL OUTPUT');
// The restored panel's task prop can stay running; its owner read is authoritative.
stopped = true;
await act(async () => {
vi.advanceTimersByTime(3000);
});
expect(
container.querySelector(`[data-status="${status}"]`),
).not.toBeNull();
expect(container.textContent).toContain('FINAL OUTPUT');
expect(actions.getTasks).toHaveBeenCalledWith({ silent: true });
expect(stop!.isConnected).toBe(false);
const reads = actions.getTaskRuntime.mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(30000);
});
expect(actions.getTaskRuntime).toHaveBeenCalledTimes(reads);
expect(vi.getTimerCount()).toBe(0);
},
);
it('retries a failed stopping-status read without losing the final output', async () => {
vi.useFakeTimers();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
actions.cancelTask.mockResolvedValue({
cancelled: true,
status: 'running',
});
actions.getTasks
.mockRejectedValueOnce(new Error('temporary failure'))
.mockResolvedValueOnce({
tasks: [{ ...task, status: 'cancelled', endTime: 4000 }],
});
actions.getTaskRuntime
.mockResolvedValueOnce(result('BEFORE STOP'))
.mockResolvedValueOnce(result('BEFORE STOP'))
.mockResolvedValue(result('FINAL OUTPUT'));
render();
await settle();
await act(async () =>
Array.from(container.querySelectorAll('button'))
.find((button) => button.textContent === 'Stop')!
.click(),
);
expect(container.querySelector('[data-status="running"]')).not.toBeNull();
await act(async () => {
vi.advanceTimersByTime(3000);
});
expect(actions.getTasks).toHaveBeenCalledTimes(2);
expect(container.querySelector('[data-status="cancelled"]')).not.toBeNull();
expect(container.textContent).toContain('FINAL OUTPUT');
warn.mockRestore();
});
it('ignores a stale stopping-status read after an authoritative completion', async () => {
let finish!: (value: { tasks: DaemonSessionShellTaskStatus[] }) => void;
actions.cancelTask.mockResolvedValue({
cancelled: true,
status: 'running',
});
actions.getTasks.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
actions.getTaskRuntime
.mockResolvedValueOnce(result('BEFORE STOP'))
.mockResolvedValue(result('FINAL OUTPUT'));
render();
await settle();
await act(async () =>
Array.from(container.querySelectorAll('button'))
.find((button) => button.textContent === 'Stop')!
.click(),
);
render({ ...task, status: 'completed', endTime: 4000 });
await settle();
await act(async () =>
finish({ tasks: [{ ...task, status: 'cancelled', endTime: 3999 }] }),
);
expect(container.querySelector('[data-status="completed"]')).not.toBeNull();
expect(container.textContent).toContain('FINAL OUTPUT');
});
it('loads a completed task through StrictMode replay', async () => {
actions.getTaskRuntime.mockResolvedValue(result('STRICT OUTPUT'));
render({ ...task, status: 'completed' }, true);
await settle();
expect(container.textContent).toContain('STRICT OUTPUT');
});
it('retries a failed terminal read and shows the recovered output', async () => {
actions.getTaskRuntime
.mockRejectedValueOnce(new Error('network'))
.mockResolvedValueOnce(result('RECOVERED OUTPUT'));
render({ ...task, status: 'completed' });
await settle();
expect(container.textContent).toContain('Output unavailable');
const retry = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'Try again',
);
expect(retry).toBeDefined();
await act(async () => retry!.click());
expect(actions.getTaskRuntime).toHaveBeenCalledTimes(2);
expect(container.textContent).toContain('RECOVERED OUTPUT');
expect(container.textContent).not.toContain('Try again');
});
it('polls without overlapping reads and stops on unmount', async () => {
vi.useFakeTimers();
let finish!: (value: DaemonSessionTaskRuntimeStatus) => void;
actions.getTaskRuntime
.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
)
.mockResolvedValue(result('NEW OUTPUT'));
render();
await act(async () => {
vi.advanceTimersByTime(6000);
});
expect(actions.getTaskRuntime).toHaveBeenCalledTimes(1);
await act(async () => finish(result('FIRST OUTPUT')));
await act(async () => {
vi.advanceTimersByTime(3000);
});
expect(container.textContent).toContain('NEW OUTPUT');
act(() => root!.unmount());
root = undefined;
await act(async () => {
vi.advanceTimersByTime(6000);
});
expect(actions.getTaskRuntime).toHaveBeenCalledTimes(2);
});
it('keeps output visible when metadata fails, with size, tail and memory hints', async () => {
actions.getTaskRuntime.mockResolvedValue({
...result('AVAILABLE OUTPUT'),
runtime: {
outputTail: 'AVAILABLE OUTPUT',
outputError: 'stat failed',
outputTailTruncated: true,
outputBytes: 2048,
memoryBytes: 1048576,
},
});
render();
await settle();
expect(container.textContent).toContain('AVAILABLE OUTPUT');
expect(container.textContent).toContain('file details could not be read');
expect(container.textContent).toContain('Showing the end only');
expect(container.textContent).toContain('2.0 KB');
expect(container.textContent).toContain('1.0 MB');
render({ ...task, status: 'completed' });
await settle();
expect(container.textContent).not.toContain('Resident memory');
});
it.each([
{ runtime: {}, status: 'running', text: 'No output yet' },
{ runtime: {}, status: 'completed', text: 'No output' },
{
runtime: null,
status: 'completed',
text: 'Task details are no longer available',
},
{
runtime: { outputError: 'denied' },
status: 'completed',
text: 'Output unavailable',
},
] as const)(
'distinguishes $status / $text',
async ({ runtime, status, text }) => {
actions.getTaskRuntime.mockResolvedValue({ ...result(''), runtime });
render({ ...task, status });
await settle();
expect(container.textContent).toContain(text);
if (status === 'completed')
expect(container.textContent).not.toContain('No output yet');
},
);
it('uses the selected UI language for clock timestamps', async () => {
const clock = vi
.spyOn(Date.prototype, 'toLocaleTimeString')
.mockReturnValue('CLOCK');
actions.getTaskRuntime.mockResolvedValue(result(''));
render({ ...task, startTime: Date.now() }, false, 'zh-CN');
await settle();
expect(clock).toHaveBeenCalledWith('zh-CN');
expect(container.textContent).toContain('CLOCK');
clock.mockRestore();
});
it('copies the full command and cleans up copy feedback on unmount', async () => {
vi.useFakeTimers();
actions.getTaskRuntime.mockResolvedValue(result(''));
render({ ...task, status: 'completed' });
await settle();
await act(async () =>
container
.querySelector<HTMLButtonElement>('[aria-label="Copy Command"]')!
.click(),
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(task.command);
expect(container.querySelector('svg.lucide-check')).not.toBeNull();
act(() => root!.unmount());
root = undefined;
expect(vi.getTimerCount()).toBe(0);
});
});
@@ -267,6 +267,32 @@
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
}
.monitorInlineMetrics {
align-items: baseline;
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
}
.monitorInlineMetric {
align-items: baseline;
display: inline-flex;
gap: 6px;
white-space: nowrap;
}
.monitorInlineLabel {
color: var(--muted-foreground);
font-size: 11px;
}
.monitorInlineValue {
color: var(--foreground);
font-size: 13px;
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.monitorMetric {
background: var(--muted);
border: 1px solid var(--border);
@@ -314,6 +340,53 @@
padding: 8px 10px;
}
.monitorSectionHeader {
align-items: center;
display: flex;
flex-wrap: nowrap;
gap: 6px;
}
.shellSectionMeta {
color: var(--muted-foreground);
font-size: 11px;
}
.shellOutputLabel {
padding-top: 8px;
}
.shellOutput {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--foreground);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
margin: 0;
max-height: 240px;
overflow: auto;
overflow-wrap: anywhere;
padding: 10px;
white-space: pre-wrap;
}
.shellOutputHint {
color: var(--muted-foreground);
font-size: 11px;
}
.shellCopyButton {
color: var(--muted-foreground);
flex: 0 0 auto;
}
.shellCopyButton:hover,
.shellCopyButton:focus-visible {
color: var(--foreground);
}
.shellError {
background: color-mix(in srgb, var(--error-color) 7%, transparent);
border-radius: 6px;
@@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
DaemonSessionMonitorTaskStatus,
DaemonSessionShellTaskRuntime,
DaemonSessionShellTaskStatus,
DaemonSessionTaskWithWorkflowStatus,
DaemonSessionWorkflowTasksStatus,
} from '@qwen-code/sdk/daemon';
import { CheckIcon, CopyIcon } from 'lucide-react';
import { isSessionDisconnectedError } from '../../utils/sessionErrors';
import {
computeAgentTreeInfo,
@@ -19,7 +21,9 @@ import {
type DaemonSessionActions,
} from '@qwen-code/web-shell/daemon-react-sdk';
import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown';
import { useCopiedFlash } from '../../hooks/useCopiedFlash';
import { useI18n } from '../../i18n';
import { writeClipboardText } from '../../utils/clipboard';
import { formatRuntime } from '../../utils/formatRuntime';
import { formatContextTokens } from '../../utils/formatTokenCount';
import { createSentinelSerializer } from '../../utils/sentinelMessage';
@@ -34,6 +38,7 @@ import {
sanitizeControlChars,
} from './toolFormatting';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import styles from './TasksStatusMessage.module.css';
const ACTIVE_EVENT = 'web-shell:tasks-panel-active';
@@ -1317,12 +1322,21 @@ export function ShellTaskDetail({
task: DaemonSessionShellTaskStatus;
actions?: DaemonSessionActions;
}) {
const { t } = useI18n();
const { t, language } = useI18n();
const contextActions = useActions();
const actions = providedActions ?? contextActions;
const [currentTask, setCurrentTask] = useState(task);
const [busy, setBusy] = useState(false);
const [stopRequested, setStopRequested] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [runtime, setRuntime] = useState<DaemonSessionShellTaskRuntime | null>(
null,
);
const [runtimeLoaded, setRuntimeLoaded] = useState(false);
const [runtimeFailed, setRuntimeFailed] = useState(false);
const [runtimeLoading, setRuntimeLoading] = useState(false);
const [runtimeRefreshKey, setRuntimeRefreshKey] = useState(0);
const lastTaskIdRef = useRef(currentTask.id);
useEffect(() => {
setCurrentTask((current) =>
@@ -1336,10 +1350,79 @@ export function ShellTaskDetail({
useEffect(() => {
setActionError(null);
setStopRequested(false);
}, [task.id, task.status]);
useEffect(() => {
let cancelled = false;
let pending = false;
const taskIdChanged = lastTaskIdRef.current !== currentTask.id;
lastTaskIdRef.current = currentTask.id;
if (taskIdChanged) {
setRuntime(null);
setRuntimeLoaded(false);
setRuntimeFailed(false);
}
const load = async () => {
if (pending) return;
pending = true;
setRuntimeLoading(true);
try {
if (stopRequested && currentTask.status === 'running') {
try {
const snapshot = await actions.getTasks({ silent: true });
if (cancelled) return;
const updatedTask = snapshot.tasks.find(
(candidate): candidate is DaemonSessionShellTaskStatus =>
candidate.kind === 'shell' && candidate.id === currentTask.id,
);
if (updatedTask && updatedTask.status !== 'running') {
setCurrentTask(updatedTask);
return;
}
} catch (error: unknown) {
if (cancelled) return;
console.warn(
'[web-shell] failed to refresh stopping shell task:',
error,
);
}
}
const result = await actions.getTaskRuntime(currentTask.id);
if (cancelled) return;
setRuntime(result.runtime);
setRuntimeFailed(false);
setRuntimeLoaded(true);
} catch {
if (cancelled) return;
setRuntimeFailed(true);
setRuntimeLoaded(true);
} finally {
pending = false;
if (!cancelled) setRuntimeLoading(false);
}
};
void load();
if (currentTask.status !== 'running') {
return () => {
cancelled = true;
};
}
const timer = window.setInterval(load, 3000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [
actions,
currentTask.id,
currentTask.status,
runtimeRefreshKey,
stopRequested,
]);
const handleCancel = useCallback(async () => {
if (busy || currentTask.status !== 'running') return;
if (busy || stopRequested || currentTask.status !== 'running') return;
setActionError(null);
setBusy(true);
try {
@@ -1348,33 +1431,14 @@ export function ShellTaskDetail({
setActionError(t('tasks.alreadyStopped'));
return;
}
setCurrentTask({
...currentTask,
status: 'cancelled',
endTime: Date.now(),
});
try {
const snapshot = await actions.getTasks();
const updatedTask = snapshot.tasks.find(
(candidate): candidate is DaemonSessionShellTaskStatus =>
candidate.kind === 'shell' && candidate.id === currentTask.id,
);
if (updatedTask && updatedTask.status !== 'running') {
setCurrentTask(updatedTask);
}
} catch (error: unknown) {
console.warn(
'[web-shell] failed to refresh stopped shell task:',
error,
);
}
setStopRequested(true);
} catch (error: unknown) {
console.warn('[web-shell] failed to cancel shell task:', error);
setActionError(t('tasks.cancelFailed'));
} finally {
setBusy(false);
}
}, [actions, busy, currentTask, t]);
}, [actions, busy, currentTask, stopRequested, t]);
return (
<div className={styles.monitorDetail}>
@@ -1395,48 +1459,99 @@ export function ShellTaskDetail({
<button
type="button"
className={styles.monitorStopButton}
disabled={busy}
disabled={busy || stopRequested}
onClick={() => void handleCancel()}
>
{busy ? t('common.loading') : t('tasks.action.stop')}
{busy || stopRequested
? t('common.loading')
: t('tasks.action.stop')}
</button>
)}
</div>
</div>
<pre className={styles.monitorCommand}>{currentTask.command}</pre>
<div className={styles.monitorCommandSection}>
<div className={styles.monitorSectionHeader}>
<div className={styles.monitorSectionLabel}>
{t('tasks.detail.command')}
</div>
<CopyValueButton
value={currentTask.command}
label={t('tasks.detail.command')}
/>
</div>
<pre className={styles.monitorCommand}>{currentTask.command}</pre>
</div>
{actionError && (
<div className={styles.monitorActionError}>{actionError}</div>
)}
<div className={styles.monitorMetrics}>
<MonitorMetric
label={t('tasks.detail.runtime')}
<div className={styles.monitorInlineMetrics}>
<InlineMetric
label={t('tasks.detail.startedAt')}
value={formatClockTime(currentTask.startTime, language)}
/>
<InlineMetric
label={t('tasks.detail.endedAt')}
value={
currentTask.endTime !== undefined
? formatClockTime(currentTask.endTime, language)
: '—'
}
/>
<InlineMetric
label={t('tasks.detail.totalDuration')}
value={formatRuntime(currentTask.runtimeMs)}
/>
{currentTask.pid !== undefined && (
<MonitorMetric
label={t('tasks.detail.pid')}
value={String(currentTask.pid)}
/>
)}
{currentTask.exitCode !== undefined && (
<MonitorMetric
label={t('tasks.detail.exitCode')}
value={String(currentTask.exitCode)}
/>
)}
</div>
{(currentTask.pid !== undefined ||
currentTask.exitCode !== undefined) && (
<div className={styles.monitorInlineMetrics}>
{currentTask.pid !== undefined && (
<InlineMetric
label={t('tasks.detail.pid')}
value={String(currentTask.pid)}
/>
)}
{currentTask.exitCode !== undefined && (
<InlineMetric
label={t('tasks.detail.exitCode')}
value={String(currentTask.exitCode)}
/>
)}
</div>
)}
{currentTask.status === 'running' &&
runtime?.memoryBytes !== undefined && (
<div className={styles.monitorInlineMetrics}>
<InlineMetric
label={t('tasks.detail.residentMemory')}
value={formatBytes(runtime.memoryBytes)}
/>
</div>
)}
</div>
<div className={styles.shellFields}>
<div className={styles.monitorCommandSection}>
<div className={styles.monitorSectionLabel}>
{t('tasks.detail.workingDir')}
<div className={styles.monitorSectionHeader}>
<div className={styles.monitorSectionLabel}>
{t('tasks.detail.workingDir')}
</div>
<CopyValueButton
value={currentTask.cwd}
label={t('tasks.detail.workingDir')}
/>
</div>
<div className={styles.shellFieldValue}>{currentTask.cwd}</div>
</div>
{currentTask.outputFile && (
<div className={styles.monitorCommandSection}>
<div className={styles.monitorSectionLabel}>
{t('tasks.detail.outputFile')}
<div className={styles.monitorSectionHeader}>
<div className={styles.monitorSectionLabel}>
{t('tasks.detail.outputFile')}
</div>
<CopyValueButton
value={currentTask.outputFile}
label={t('tasks.detail.outputFile')}
/>
</div>
<div className={styles.shellFieldValue}>
{currentTask.outputFile}
@@ -1444,6 +1559,75 @@ export function ShellTaskDetail({
</div>
)}
</div>
{runtimeLoaded && (
<div className={styles.monitorCommandSection}>
<div
className={`${styles.monitorSectionLabel} ${styles.shellOutputLabel}`}
>
{t('tasks.detail.output')}
{runtime?.outputBytes !== undefined && (
<span className={styles.shellSectionMeta}>
{t('tasks.detail.outputSize', {
size: formatBytes(runtime.outputBytes),
})}
{runtime.outputModifiedAt !== undefined && (
<>
{' · '}
{t('tasks.detail.lastWrite')}{' '}
{formatClockTime(runtime.outputModifiedAt, language)}
</>
)}
</span>
)}
</div>
{runtime?.outputTail ? (
<>
<pre className={styles.shellOutput}>{runtime.outputTail}</pre>
{runtime.outputTailTruncated && (
<div className={styles.shellOutputHint}>
{t('tasks.detail.outputTruncated')}
</div>
)}
</>
) : runtimeFailed || runtime?.outputError ? (
<div className={styles.shellFieldValue}>
{t('tasks.detail.outputUnavailable')}
</div>
) : runtime === null ? (
<div className={styles.shellFieldValue}>
{t('tasks.detail.runtimeUnavailable')}
</div>
) : (
<div className={styles.shellFieldValue}>
{t(
currentTask.status === 'running'
? 'tasks.detail.outputEmpty'
: 'tasks.detail.outputNone',
)}
</div>
)}
{runtime?.outputTail && runtime.outputError && (
<div className={styles.shellOutputHint}>
{t('tasks.detail.outputPartial')}
</div>
)}
{(runtimeFailed || runtime?.outputError) && (
<div role="status">
{runtimeFailed &&
runtime?.outputTail &&
t('tasks.detail.outputRefreshFailed')}
<Button
variant="outline"
size="xs"
disabled={runtimeLoading}
onClick={() => setRuntimeRefreshKey((key) => key + 1)}
>
{t(runtimeLoading ? 'common.loading' : 'common.retry')}
</Button>
</div>
)}
</div>
)}
{currentTask.error && (
<div className={`${styles.monitorError} ${styles.shellError}`}>
<div className={styles.monitorSectionLabel}>
@@ -1456,6 +1640,85 @@ export function ShellTaskDetail({
);
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '—';
if (bytes < 1024) return `${bytes} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
}
function formatClockTime(timestamp: number, language: string): string {
const date = new Date(timestamp);
if (!Number.isFinite(timestamp)) return '—';
return date.toDateString() === new Date().toDateString()
? date.toLocaleTimeString(language)
: date.toLocaleString(language);
}
/**
* Tab title for a shell task. The full command is unreadable once the tab
* truncates it, so use the executable's basename (`/opt/homebrew/.../mariadbd`
* becomes `mariadbd`) and fall back to the first line.
*/
export function shellTaskPanelTitle(command: string): string {
const firstLine = command.split(/\r?\n/, 1)[0]?.trim() ?? '';
const tokens = firstLine.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
let index = 0;
while (
index < tokens.length &&
/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index]!)
) {
index++;
}
const executable = tokens[index]?.replace(/^['"]|['"]$/g, '');
if (!executable) return firstLine || command;
return executable.split('/').filter(Boolean).pop() ?? executable;
}
function CopyValueButton({ value, label }: { value: string; label: string }) {
const { t } = useI18n();
const [copied, flash] = useCopiedFlash(1500);
const handleCopy = useCallback(async () => {
try {
await writeClipboardText(value);
flash();
} catch {
// Clipboard access can be denied outside a secure context.
}
}, [value, flash]);
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
className={styles.shellCopyButton}
title={t('common.copy')}
aria-label={`${t('common.copy')} ${label}`}
onClick={() => void handleCopy()}
>
{copied ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
<span className="sr-only" role="status">
{copied ? t('copy.toClipboard', { label }) : ''}
</span>
</Button>
);
}
function InlineMetric({ label, value }: { label: string; value: string }) {
return (
<span className={styles.monitorInlineMetric}>
<span className={styles.monitorInlineLabel}>{label}</span>
<span className={styles.monitorInlineValue}>{value}</span>
</span>
);
}
function MonitorMetric({ label, value }: { label: string; value: string }) {
return (
<div className={styles.monitorMetric}>
@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { shellTaskPanelTitle } from './TasksStatusMessage';
describe('shellTaskPanelTitle', () => {
it('uses the executable basename instead of the full command', () => {
expect(
shellTaskPanelTitle(
'/opt/homebrew/opt/[email protected]/bin/mariadbd --datadir=/tmp/fg12d-db',
),
).toBe('mariadbd');
});
it('skips leading environment assignments', () => {
expect(shellTaskPanelTitle('FOO=1 BAR=2 node server.js')).toBe('node');
});
it('returns an empty string for an empty command', () => {
expect(shellTaskPanelTitle('')).toBe('');
});
it('keeps a quoted executable together', () => {
expect(shellTaskPanelTitle('"my script.sh" --flag')).toBe('my script.sh');
});
it('skips quoted environment assignments', () => {
expect(shellTaskPanelTitle('FOO="a b" node server.js')).toBe('node');
});
});
@@ -3087,6 +3087,17 @@ export function createDaemonSessionActions({
}
},
async getTaskRuntime(taskId) {
const session = sessionRef.current;
if (!session) throw new Error('Daemon session is not connected');
// Polled by an open detail panel; a transient failure is rendered
// inline there, so this must not raise a notice on every tick.
return await withActionTimeout(
session.taskRuntime(taskId),
'Get task runtime timed out',
);
},
async getWorkflowTasks(opts) {
const session = sessionRef.current;
if (!session) throw new Error('Daemon session is not connected');
@@ -35,6 +35,7 @@ import type {
DaemonSession,
DaemonSessionSummary,
DaemonSessionSupportedCommandsStatus,
DaemonSessionTaskRuntimeStatus,
DaemonSessionTaskWithWorkflowStatus,
DaemonSessionTasksStatus,
DaemonSessionWorkflowTaskStatus,
@@ -702,6 +703,7 @@ export interface DaemonSessionActions {
): Promise<DaemonRemovePendingPromptResult>;
sendShellCommand(command: string): Promise<DaemonShellCommandResult>;
getTasks(opts?: GetTasksActionOptions): Promise<DaemonSessionTasksStatus>;
getTaskRuntime(taskId: string): Promise<DaemonSessionTaskRuntimeStatus>;
getWorkflowTasks(
opts?: GetTasksActionOptions,
): Promise<DaemonSessionWorkflowTasksStatus>;
+29
View File
@@ -3882,6 +3882,21 @@ const EN: Messages = {
'tasks.shortcut.cancelConfirm': 'Esc cancel',
'tasks.detail.workingDir': 'Working dir',
'tasks.detail.outputFile': 'Output file',
'tasks.detail.startedAt': 'Started',
'tasks.detail.endedAt': 'Ended',
'tasks.detail.totalDuration': 'Total duration',
'tasks.detail.residentMemory': 'Resident memory',
'tasks.detail.output': 'Output',
'tasks.detail.outputSize': (v) => `: ${v?.size ?? ''}`,
'tasks.detail.outputEmpty': 'No output yet',
'tasks.detail.outputNone': 'No output',
'tasks.detail.runtimeUnavailable': 'Task details are no longer available',
'tasks.detail.outputPartial':
'Output is available, but file details could not be read',
'tasks.detail.outputRefreshFailed': 'Could not refresh output. ',
'tasks.detail.outputTruncated': 'Showing the end only',
'tasks.detail.outputUnavailable': 'Output unavailable',
'tasks.detail.lastWrite': 'Last write',
'tasks.detail.command': 'Command',
'tasks.detail.events': (v) => `${v?.count ?? 0} events`,
'tasks.detail.exit': (v) => `exit ${v?.exitCode ?? ''}`,
@@ -8053,6 +8068,20 @@ const ZH: Messages = {
'tasks.shortcut.cancelConfirm': 'Esc 取消',
'tasks.detail.workingDir': '工作目录',
'tasks.detail.outputFile': '输出文件',
'tasks.detail.startedAt': '开始时间',
'tasks.detail.endedAt': '结束时间',
'tasks.detail.totalDuration': '总时长',
'tasks.detail.residentMemory': '常驻内存',
'tasks.detail.output': '输出',
'tasks.detail.outputSize': (v) => `:${v?.size ?? ''}`,
'tasks.detail.outputEmpty': '暂无输出',
'tasks.detail.outputNone': '无输出',
'tasks.detail.runtimeUnavailable': '任务详情已不可用',
'tasks.detail.outputPartial': '输出可用,但无法读取文件信息',
'tasks.detail.outputRefreshFailed': '无法刷新输出。',
'tasks.detail.outputTruncated': '仅显示末尾',
'tasks.detail.outputUnavailable': '无法读取输出',
'tasks.detail.lastWrite': '最后写入',
'tasks.detail.command': '命令',
'tasks.detail.events': (v) => `${v?.count ?? 0} 个事件`,
'tasks.detail.exit': (v) => `退出码 ${v?.exitCode ?? ''}`,