mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
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 任务返回空详情,而不是报错。
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 ?? ''}`,
|
||||
|
||||
Reference in new issue
Block a user