Skip to content

feat(serve): expose the background agents the supervisor is running - #10954

Open
yiliang114 wants to merge 31 commits into
mainfrom
feat/daemon-background-agents
Open

feat(serve): expose the background agents the supervisor is running#10954
yiliang114 wants to merge 31 commits into
mainfrom
feat/daemon-background-agents

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

Stack position 4/4. Parent: #10949. Adds GET /background-agents to qwen serve: the sessions the Agent View supervisor is running, with what each one is doing.

{
  "agents": [
    {
      "sessionId": "0f8e1c42-…",
      "name": "release audit",
      "state": "needs input",
      "cwd": "/w/app",
      "pid": 777,
      "startedAt": "2026-09-04T11:58:00.000Z"
    }
  ]
}

Why it's needed

The daemon has its own idea of what a session is, and it is not the one a background agent lives in. standalone-session-service tracks conversations the daemon itself hosts, in its own memory; a session started with qwen --bg is owned by the supervisor and recorded in the roster under ~/.qwen/. Nothing in the daemon reads that roster — rg 'listLiveSessions|session-registry' packages/cli/src/serve returns nothing across 94,136 lines — so every daemon client, the Web Shell included, is blind to background agents.

The rows come from managedSessionRows, the same function qwen sessions ps renders (#10942), so the CLI and anything built on this route cannot describe one session two different ways. That includes the part that is easy to get wrong: a failed session is labelled failed, not folded into the roster's completed display group. The roster UI can afford that fold because it also paints an icon tone; an HTTP client has nothing else to carry the difference.

Two decisions worth reviewing:

  • Read-only. Answering or stopping a background agent goes through the supervisor's own socket, which the CLI already does (feat(cli): see, answer and stop a background session #10949). Routing those through the daemon would put a second writer on state the supervisor owns, for no gain.
  • An unreadable store is a 503, not an empty list. A client that cannot tell "no agents" from "cannot look" would show an empty page to someone whose agent is waiting for an answer — the same failure the CLI half reports on stderr.

What is deliberately not here

The Web Shell panel. This PR is the first half of docs/plans/2026-09-04-background-agent-surfaces.md §3; the second half is a panel that renders these rows and answers a waiting agent.

I did not write it, and the reason is worth stating rather than hiding: packages/web-shell is 172 component files with Playwright visual tests, its sidebar sessions come through DaemonSessionProvider, and the machine this was written on can neither build the Web Shell nor run Playwright. A panel written blind into that pipeline is the kind of change a maintainer ends up rewriting. The route is the part that can be built and tested honestly from here; the panel wants someone who can see it.

Reviewer Test Plan

How to verify

cd packages/cli && npx vitest run src/serve/routes/background-agents.test.ts --coverage.enabled=false → 6 tests. They cover a running agent reported with its pid and ISO start time, a failed session labelled failed rather than completed, absent pid/startedAt omitted rather than invented (an unparseable createdAt must not become 1970), an empty list, the 503 on an unreadable store, and an untrusted workspace listing nothing.

eslint and prettier --check are clean on the changed files.

End to end, on a build of this stack:

  1. qwen serve in one terminal, qwen --bg "audit the release" in another.
  2. curl -s localhost:<port>/background-agents | jq lists the agent, initially working.
  3. Let it stop to ask something; the same call reports needs input.
  4. qwen sessions stop <id>, then confirm the route reports it stopped.

Evidence (Before & After)

Before: no daemon route reported background agents; they existed only in the CLI.

After: the JSON above. It is the shape the unit tests pin — not a live capture, since this machine cannot run qwen serve against a real supervisor.

Tested on

OS Status
🍏 macOS N/A
🪟 Windows N/A
🐧 Linux ⚠️

Unit tests only, on Linux. npx tsc --noEmit and npm run build were not run here; CI covers both. The four end-to-end steps above are unverified.

Environment (optional)

Linux, vitest only.

Risk & Scope

  • Main risk or tradeoff: a new public route on the daemon, which is a surface that has to be kept. It is read-only, has no parameters, and returns a projection of existing state, so the compatibility burden is small — but the field names are now a contract.
  • Not validated / out of scope: the Web Shell panel (above). No authentication decision is made here beyond honouring the untrusted-workspace hook the other routes use. Daemon-owned sessions (scheduled tasks, channel workers) are still not in the roster and so still not listed — that question is left open in the design doc.
  • Breaking changes / migration notes: none; new route only.

Linked Issues

Implements §3 step 1 of the design in #10951. Builds on #10942, #10943, #10949.

中文说明

这个 PR 做了什么

栈位置 4/4。父 PR:#10949。为 qwen serve 新增 GET /background-agents:列出 Agent View supervisor 正在运行的 session,以及每一个正在做什么。

为什么需要

daemon 对「session 是什么」有自己的一套理解,而后台 agent 并不活在那套里。standalone-session-service 追踪的是 daemon 自己托管的会话,存在它自己的内存中;而 qwen --bg 启动的 session 由 supervisor 拥有,记录在 ~/.qwen/ 下的 roster 里。daemon 中没有任何代码读取那个 roster —— rg 'listLiveSessions|session-registry' packages/cli/src/serve 在 94,136 行中零命中 —— 因此包括 Web Shell 在内的每一个 daemon 客户端,对后台 agent 都是盲的。

数据行来自 managedSessionRows,与 qwen sessions ps 渲染的是同一个函数(#10942),因此 CLI 与基于本路由构建的任何东西,都不会对同一个 session 给出两种说法。这包括最容易出错的那一处:失败的 session 标注为 failed,而不是被折叠进 roster 的 completed 展示分组。roster UI 之所以负担得起那次折叠,是因为它还会绘制图标色调;HTTP 客户端没有第二条通道来承载这个差别。

两处判断值得评审:

  • 只读。 回答或停止一个后台 agent 走 supervisor 自己的 socket,CLI 已经这么做了(feat(cli): see, answer and stop a background session #10949)。把这些经由 daemon 转发,等于在 supervisor 拥有的状态上再放一个写入方,且毫无收益。
  • 存储读不出来时返回 503,而不是空列表。 无法区分「没有 agent」与「看不了」的客户端,会给一个正在等待回答的人展示一个空页面 —— 与 CLI 一侧在 stderr 上报告的是同一类失败。

刻意没有包含的部分

Web Shell 面板。 本 PR 是 docs/plans/2026-09-04-background-agent-surfaces.md §3 的前半;后半是一个渲染这些行、并能回答等待中 agent 的面板。

我没有写它,理由值得明说而不是藏着:packages/web-shell 有 172 个组件文件并带 Playwright 视觉测试,其侧边栏的 session 来自 DaemonSessionProvider,而撰写本 PR 的机器既不能构建 Web Shell 也不能运行 Playwright。在那条数据管线里盲写一个面板,属于维护者最终要重写的那类改动。路由是从这里能够诚实地构建与测试的部分;面板需要一个看得见它的人。

评审者测试计划

如何验证

cd packages/cli && npx vitest run src/serve/routes/background-agents.test.ts --coverage.enabled=false → 6 个测试。覆盖:运行中的 agent 连同 pid 与 ISO 起始时间被报告;失败的 session 标注为 failed 而非 completed;缺失的 pid/startedAt 被省略而不是臆造(无法解析的 createdAt 不得变成 1970);空列表;存储不可读时的 503;不受信任的工作区不列出任何内容。

改动文件的 eslintprettier --check 干净。

端到端,在本栈的构建产物上:

  1. 一个终端跑 qwen serve,另一个跑 qwen --bg "audit the release"
  2. curl -s localhost:<port>/background-agents | jq 列出该 agent,初始为 working
  3. 让它停下来提问;同一次调用报告 needs input
  4. qwen sessions stop <id>,然后确认路由报告它已停止。

证据(前后对比)

之前:没有任何 daemon 路由报告后台 agent;它们只存在于 CLI 中。

之后:上面那段 JSON。它是单测钉住的形状 —— 不是实时截取,因为本机无法针对真实 supervisor 运行 qwen serve

测试环境

操作系统 状态
🍏 macOS N/A
🪟 Windows N/A
🐧 Linux ⚠️

仅 Linux 上的单元测试。npx tsc --noEmitnpm run build 未在此运行;CI 覆盖两者。上述四条端到端步骤未经验证。

运行环境(可选)

Linux,仅 vitest。

风险与范围

  • 主要风险或取舍: daemon 上新增了一个公开路由,而这是需要长期维护的表面。它只读、无参数、返回既有状态的投影,因此兼容负担很小 —— 但字段名从此成为契约。
  • 未验证 / 范围之外: Web Shell 面板(见上)。除沿用其他路由使用的 untrusted-workspace 钩子外,本 PR 不做任何鉴权决定。daemon 自己拥有的 session(定时任务、channel worker)仍不在 roster 中,因此仍不会被列出 —— 那个问题在设计文档中被留作开放问题。
  • 破坏性变更 / 迁移说明: 无;仅新增路由。

关联 Issue

实现 #10951 中设计的 §3 第 1 步。构建于 #10942#10943#10949 之上。

`packages/cli/src/agent-view/` is 11,004 production lines with no
consumer: the supervisor, the PTY workers and the session lifecycle all
merged (#7799, #7800, #7801/#9986), while the two PRs that would give
them an entry point are still open (#7802 commands, #7803 roster TUI,
both since 2026-07-27). Nothing calls the subsystem, so nothing that
subsystem knows can reach a user.

This gives it its first one, at the smallest surface that carries real
information: `qwen sessions ps` now lists managed sessions beside the
interactive ones it always listed.

The command walked only the live-process registry, which a supervisor's
worker never writes — so a background session waiting for an answer was
invisible to every listing. `managed-rows.ts` merges both sources into
one row shape, deduplicating by session id (a managed worker can also
register, and would otherwise be listed twice: once as `interactive`,
once with its real state). Managed rows come first, because a session
waiting on input is the usual reason to run this.

Three decisions worth naming:

- Rows are labelled by task state, not by the roster's display group.
  The group folds `ready`, `stopped` and `failed` into one `completed`
  bucket, which the roster UI can afford because it also paints an icon
  tone; a one-line table has no second channel, and printing "completed"
  beside a session that failed is a lie the user cannot see through.
- The name comes from `deriveAgentViewPresentation`, so this listing and
  the roster cannot drift into describing one session two ways. Its
  `Untitled session` placeholder is the single override — the roster can
  afford identical rows because a user arrows onto one; here the session
  id is the only thing that tells two apart. The placeholder is now
  exported rather than duplicated as a literal.
- A supervisor store that cannot be read degrades to the registry half
  and says so on stderr. Silently omitting a session that needs input is
  the failure this command exists to prevent, and stderr keeps `--json`
  stdout parseable.

The table gains a STATE column; `--json` gains a `managed` discriminator
on both row kinds, and carries each registry record through verbatim
rather than looking it back up by session id — two records can share one
(a stale writer, a restored transcript), which would emit one twice and
drop the other.
The find-simplifications survey uses `packages/cli/src/agent-view` as its
worked example for "everything says dead, git says five days old": a
feature mid-wiring, so drop it silently — do not file it, do not mention
it.

The deletion verdict was right. The silence was not. Measured again on
2026-09-04, one month after that example was written: 11,004 production
lines, still zero external references, up ~8,000 in four weeks. Its five
merged PRs shipped the supervisor, the PTY workers and the lifecycle;
the two that would give it an entry point have been open since
2026-07-27, the second at +31,699 lines across 104 files and
conflicting. The gate kept firing on the directory's recency and the
survey kept dropping it, so nothing ever said out loud that a
five-figure subsystem had no caller.

"Young" is not the fact the gate is reaching for — "still being wired
up" is, and for a subsystem the two come apart. The gate now says to
date the wiring rather than the code when it fires on a subsystem, and
names the outcome: recent commits inside, no external reference, and an
open stalled wiring PR is neither a deletion nor a drop. It is a
different finding with a different owner, owed to that PR stack.
The Agent View supervisor, its PTY workers and its session lifecycle are
all merged, and `dispatchAgentViewSession` records everything a
supervisor needs to spawn a session. None of it could run: the entry was
missing two wires.

The first is why nothing worked end to end. The supervisor spawns itself
as `qwen --internal-agent-view-supervisor` (`supervisor-runner.ts:107`)
and nothing parsed that flag — the CLI's parser runs `.strict()`, so the
process spawned to be a supervisor exited on an unknown argument instead
of serving. Every path that dispatches a session waited on a supervisor
that could never start. The entry now recognizes the flag and serves.

The second is the user-facing half: `qwen --bg "<prompt>"` ensures a
supervisor, records a session for it, prints the id and returns. The
session shows up in `qwen sessions ps`, which learned to list managed
sessions in the parent commit.

Both are handled before the argv parser and behind a raw-argv scan, so an
ordinary launch pays one `Array.includes` and loads none of it. `--bg`
needs a prompt and a directory; routing it through the interactive
startup path — auth, theme, extensions — would buy nothing and cost all
of it. The flag is still declared in the option tables so `--help` lists
it and the strict parser knows it.

Two details worth the reviewer's attention:

- The prompt is read as the default command's positional query, and the
  flags that consume the token after them are derived from the CLI's own
  option tables rather than listed by hand. A hand-written list would go
  stale the first time someone adds an option, and the cost of missing
  one is a flag's value silently becoming part of the prompt.
- The scan stops at `--`. `qwen -p x -- --bg` passes `--bg` as the user's
  own data, and hijacking that launch into a dispatch would be a bug with
  no way for the user to work around it.

The internal flag moves to its own module so the entry can recognize it
without importing the supervisor runtime on every launch.
`qwen sessions ps` can report that a background session is waiting for
input. Until now that was a dead end: there was no way to read the
question, no way to answer it, and no way to end the session short of
finding its pid and killing it. Surfacing a state the user cannot act on
is worse than not surfacing it, and the previous two commits in this
stack created exactly that gap.

Three subcommands close it, each a thin call to a supervisor operation
that already ships:

    qwen sessions peek   <session>
    qwen sessions answer <session> "<text>"
    qwen sessions stop   <session>

Each takes a session id or any unique prefix — the supervisor already
resolves prefixes, and refuses an ambiguous one rather than guessing, so
the short id `--bg` prints is enough to type.

Decisions worth reviewing:

- **They connect to a running supervisor and never start one.** Starting
  a supervisor to ask it about sessions it cannot have would turn
  "nothing is running" into a spawned process and an empty answer. With
  none running, all three say so and point at `--bg`.
- **`peek`'s state line comes from `deriveAgentViewPresentation`**, the
  same source as the roster and `sessions ps`, so three surfaces cannot
  describe one session three different ways.
- **The answer hint is printed only when the session is actually
  waiting.** Answering a working session queues a prompt instead, which
  is a different operation; offering it there would mislead.
- **Everything the session wrote is sanitized before it reaches the
  terminal.** `waitingFor`, `summary` and `lastResult` are a model's own
  words relayed from another process — the same untrusted input
  `sessions ps` already sanitizes for escape sequences and bidi
  overrides.
- **A supervisor error is repeated, not reinterpreted.** It already words
  an unknown id, an ambiguous prefix and a session that is not managed;
  only the stack is dropped.

The decisions live in `managed-control.ts` with no I/O of their own — the
supervisor connection is injected — so all of the above is tested without
a supervisor, a socket or a filesystem.
The PR describes an invariant it never checked: a background worker is
an ordinary interactive session, so it registers itself in the
live-process registry under whatever `--session-id` it was spawned with,
and `qwen sessions ps` deduplicates the registry against the roster by
session id. If the two ever stopped agreeing, every background session
would be listed twice — once as `interactive`, once with its real state
— and nothing would have failed.

Verified while tracing that path: `--session-id` does become the
session's own id (`config.ts:2031-2066`), so the invariant holds today.
This pins the seam that would silently break it.
The daemon has its own idea of what a session is, and it is not the one
a background agent lives in: `standalone-session-service` tracks
conversations the daemon itself hosts, in its own memory, while a
session started with `qwen --bg` is owned by the Agent View supervisor.
Nothing in the daemon reads the supervisor's roster — `rg
'listLiveSessions|session-registry' packages/cli/src/serve` is empty
across 94,136 lines — so every daemon client, the Web Shell included, is
blind to background agents.

`GET /background-agents` reports them. The rows come from
`managedSessionRows`, the same function `qwen sessions ps` renders, so
the CLI and anything built on this route cannot describe one session two
different ways — including the part that is easy to get wrong: a failed
session is labelled `failed`, not folded into the roster's `completed`
display group, because a caller with no icon tone has nothing else to
carry the difference.

Read-only by design. Answering or stopping a background agent goes
through the supervisor's own socket, which the CLI already does; routing
those through the daemon would put a second writer on state the
supervisor owns.

An unreadable store is a 503, not an empty list: a client that cannot
tell "no agents" from "cannot look" would show an empty page to someone
whose agent is waiting for an answer.

This is the first half of the surface described in
docs/plans/2026-09-04-background-agent-surfaces.md §3. The second half —
a Web Shell panel that renders these rows and answers a waiting agent —
is not in this PR.
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — this is the cleanest-written description in the stack, and the two decisions you flagged for review are the right two to flag.

Template looks good ✓ — every required heading is present, including the Chinese section.

Problem: this is a feat, not a bugfix, so I'm not asking for a reproduction. The gap itself is credible: managedSessionRows exists and is the same projection qwen sessions ps renders, so reusing it here is the right call and the CLI/HTTP surfaces can't drift. Two evidence-trail problems though, and they matter because they're the only justification a reviewer has:

  • docs/plans/2026-09-04-background-agent-surfaces.md does not exist — not at 85c5e36, and a repo-wide code search for the filename returns 0 hits. It's cited twice in the PR body and in the committed doc comment at the top of background-agents.ts, where it's offered as the authority for "why the roster is the authority rather than the daemon's own model". A committed pointer to a file that isn't in the repo is a dead end for the next reader.
  • ## Linked Issues says "Implements §3 step 1 of the design in docs: record where a background agent shows up, and who owns it #10951", but docs: record where a background agent shows up, and who owns it #10951 is an open PR (docs: record where a background agent shows up, and who owns it), not an issue. So the design lives in neither place a reviewer can read it.

Direction: aligned. A read-only daemon surface for supervisor-owned sessions is a sensible half of the split, and declining to write the Web Shell panel blind rather than shipping something a maintainer has to rewrite was the right judgement — saying so out loud in the description is appreciated. One genuine scope question, raised as a question and not a block:

listAgentViewSessionSnapshots() is called with no options, so it resolves through Storage.getGlobalQwenDir() to ~/.qwen/jobs and ~/.qwen/daemon/roster.json. That's user-global, spanning every project. But it's mounted on a workspace-scoped daemon, and unlike its closest sibling it takes no boundWorkspaceregisterGoalsRoutes gets both boundWorkspace: primaryBoundWorkspace and bridge.listWorkspaceSessions(boundWorkspace), so /goals is workspace-scoped by construction. Is the global listing intended? Per row it exposes another project's absolute cwd, plus a name that deriveTitle will fill from launch.initialPrompt whenever there's no roster display name and no activity summary. If global is the intent, that's worth stating in the route's doc comment and in the ownership classification; if not, it needs a workspace filter.

Size: not applicable. 92 production lines (background-agents.ts 87, server.ts 5), 140 test lines, 1 doc line. No core-infrastructure paths — packages/cli/src/serve/** isn't in the core list, and the change stays inside packages/cli. You're admin on the repo, so the two-tier gate doesn't apply regardless. Well under every threshold.

Approach: minimal and well-targeted — one new file, one import, one registration, no drive-by edits, no unrelated churn. The reuse of managedSessionRows is exactly right. One deviation from the house pattern that I'll detail in the code review: the trust guard is re-implemented as two injectable deps instead of importing the shared sendUntrustedWorkspaceResponse from ../workspace-route-runtime.js the way goals.ts does — and the call site passes {}, which turns out not to be a cosmetic difference.

Risk: no Stage 1e high-risk path match. But there is a CI blind spot worth naming now, because the description leans on it:

npx tsc --noEmit and npm run build were not run here; CI covers both.

CI covers neither. .github/workflows/ci.yml triggers pull_request on main and release/** only, and this PR's base is feat/agent-view-session-control. The sole pull_request-event run on 85c5e36 is tui-parity. So lint, typecheck and the 6 unit tests you cite have no automated signal on this PR at all — they'll only get one when the stack retargets to main. That doesn't invalidate your local results, but it does mean nothing here is independently confirmed, and "CI covers both" shouldn't be load-bearing in the description.

Moving on to code review. 🔍

中文说明

感谢贡献 —— 这是整个 stack 里描述写得最清楚的一篇,你主动标出来待评审的两个判断,也正是该标出来的两个。

模板完整 ✓ —— 所有必需小标题都在,含中文部分。

问题: 这是一个 feat 而非 bugfix,所以我不要求复现。缺口本身可信:managedSessionRows 确实存在,且与 qwen sessions ps 渲染的是同一个投影,因此这里复用它是对的,CLI 与 HTTP 两个表面不会漂移。但有两处证据链问题,而且要紧 —— 因为它们是评审者唯一能依据的东西:

  • docs/plans/2026-09-04-background-agent-surfaces.md 不存在 —— 在 85c5e36 上不存在,按文件名做全仓代码搜索也是 0 命中。它在 PR 描述中被引用两次,并且写在 background-agents.ts 顶部已提交的文档注释里,作为「为什么以 roster 为权威、而非 daemon 自己的模型」的依据。一个提交进仓库、却指向仓库里不存在的文件的指针,对后来的读者是死路。
  • ## Linked Issues 写「实现 docs: record where a background agent shows up, and who owns it #10951 中设计的 §3 第 1 步」,但 docs: record where a background agent shows up, and who owns it #10951 是一个未合并的 PRdocs: record where a background agent shows up, and who owns it),不是 issue。也就是说这份设计在评审者能读到的两个地方都不存在。

方向: 对齐。为 supervisor 拥有的 session 提供一个只读的 daemon 表面,是这个拆分中合理的一半;而没有盲写 Web Shell 面板、宁可交给看得见它的人,是正确的判断 —— 在描述里明说这一点也值得肯定。有一个真实的范围问题,以提问方式提出,不作为阻塞:

listAgentViewSessionSnapshots() 调用时没有传 options,因此经 Storage.getGlobalQwenDir() 解析到 ~/.qwen/jobs~/.qwen/daemon/roster.json。这是用户全局的,横跨所有项目。但它挂载在一个工作区级的 daemon 上,而且与它最接近的同类路由不同,它不接收 boundWorkspace —— registerGoalsRoutes 同时拿到 boundWorkspace: primaryBoundWorkspacebridge.listWorkspaceSessions(boundWorkspace),所以 /goals 在构造上就是工作区级的。全局列举是有意为之吗?逐行看,它会暴露另一个项目的绝对 cwd,以及一个 name —— 当既没有 roster 显示名也没有活动摘要时,deriveTitle 会直接用 launch.initialPrompt 填充它。如果全局是有意的,值得在路由的文档注释与归属分类中写明;如果不是,则需要加工作区过滤。

规模: 不适用。92 行生产代码(background-agents.ts 87 行、server.ts 5 行)、140 行测试、1 行文档。未触及核心基础设施路径 —— packages/cli/src/serve/** 不在核心清单内,改动也始终留在 packages/cli 内。你是仓库 admin,两层门禁本就不适用。远低于所有阈值。

方案: 精简且目标明确 —— 一个新文件、一处 import、一次注册,没有顺手改动,没有无关噪音。复用 managedSessionRows 完全正确。有一处偏离本仓既有模式,我会在代码审查中展开:信任守卫被重新实现为两个可注入的 dep,而没有像 goals.ts 那样从 ../workspace-route-runtime.js 导入共享的 sendUntrustedWorkspaceResponse —— 而调用点传的是 {},事实证明这不是一个表面差异。

风险: 未命中 Stage 1e 高风险路径。但有一个 CI 盲区值得现在就点明,因为描述依赖它:

npx tsc --noEmitnpm run build 未在此运行;CI 覆盖两者。

CI 两者都不覆盖。.github/workflows/ci.ymlpull_request 触发只针对 mainrelease/**,而本 PR 的 base 是 feat/agent-view-session-control85c5e36 上唯一的 pull_request 事件运行是 tui-parity。因此 lint、typecheck 与你提到的 6 个单测,在本 PR 上完全没有任何自动化信号 —— 只有当整个 stack 重新以 main 为 base 时才会有。这不否定你本地的结果,但意味着这里没有任何东西被独立确认过,「CI 覆盖两者」不应成为描述中的承重句。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 85c5e36aa88a729edd905a98b21dde5be9b5e2bc · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

The route itself is well built — small, pure projection, no new state, and the reuse of managedSessionRows is the right seam. Two things block it, both at the same call site, and both are invisible to the suite.

1. The untrusted-workspace guard never fires in production (blocking)

background-agents.ts guards with an optional dep:

if (deps.isWorkspaceTrusted?.() === false) {
  deps.sendUntrustedWorkspaceResponse?.(res);
  return;
}

and server.ts:2241 registers it as:

registerBackgroundAgentRoutes(app, {});

So deps.isWorkspaceTrusted is undefined, undefined?.() is undefined, and undefined === false is false. The guard is unreachable. isPrimaryWorkspaceTrusted is defined at server.ts:971 and handed to 25+ sibling registrations in this same file — including the closest analogue, registerGoalsRoutes at server.ts:3091, which receives isWorkspaceTrusted: isPrimaryWorkspaceTrusted alongside boundWorkspace: primaryBoundWorkspace. This one registration is the only read-only listing that opts out.

There is no global gate that would make the per-route dep redundant. The only app.use middleware are CORS, the host allowlist, trace-id capture, authenticate (token auth — a different boundary) and the rate limiter; requireTrustedWorkspaceRuntime is invoked per-route (server.ts:2444).

Failure scenario: qwen serve on an untrusted primary workspace. GET /goals → 403 untrusted_workspace. GET /background-agents200, with the user-global roster: every project's absolute cwd, and a name that deriveTitle fills from launch.initialPrompt when there's no display name or activity summary.

The PR body says this route makes "no authentication decision … beyond honouring the untrusted-workspace hook the other routes use." As wired it does not honour it — it never receives it.

Why the suite doesn't catch it: the untrusted test injects both deps itself, so it pins a switch no production caller ever sets — green, and proving nothing about the shipped daemon. It also asserts { error: 'untrusted' }, while the real shared helper (workspace-route-runtime.ts:307) returns { error: 'Workspace is not trusted.', code: 'untrusted_workspace' }, so the test doesn't pin the real contract either.

2. The responder is optional, so fixing #1 the obvious way hangs the request (blocking)

If you wire only the trust dep — registerBackgroundAgentRoutes(app, { isWorkspaceTrusted: isPrimaryWorkspaceTrusted }) — the guard fires, deps.sendUntrustedWorkspaceResponse?.(res) is a no-op, and the handler returns without ending the response. Express never replies; the request hangs until the client or socket times out. goals.ts cannot hang for the same reason it cannot fail open: it imports sendUntrustedWorkspaceResponse rather than accepting it as a dep.

Both findings have one fix: drop sendUntrustedWorkspaceResponse from RegisterBackgroundAgentRoutesDeps, import it from ../workspace-route-runtime.js as goals.ts does, and pass isWorkspaceTrusted: isPrimaryWorkspaceTrusted at the call site. Then the test can assert the real 403 body without stubbing the responder.

3. "An unreadable store is a 503" holds for one of several unreadable stores

This is the second decision you asked for review on, so here's the precise reach. listAgentViewSessionSnapshots has two reads with different failure semantics:

  • listAgentViewSessionStatesfs.readdir(jobsDir): ENOENT → [], any other error rethrows → 503 ✓
  • readAgentViewRosterreadJsonRecord, which by explicit design "fail[s] soft on any read or parse error" and returns undefinednormalizeRoster yields sessions: []never throws

Per-session state.json reads soft-fail the same way and are then .filter(Boolean)-ed out. So an unreadable or corrupt roster.json, or an unreadable individual state.json, returns 200 with silently degraded or missing rows — a session whose state file can't be read just disappears from the list. That is exactly the "cannot tell no-agents from cannot-look" confusion the decision was meant to prevent, one layer down.

The test can't see this because it stubs the entire lister (async () => { throw … }), which pins the route's try/catch but not what the real store does. Worth either narrowing the claim to "an unlistable jobs directory is a 503", or deciding that a silently-vanished session should also be a 503.

Smaller things

  • The doc comment at the top of background-agents.ts points at docs/plans/2026-09-04-background-agent-surfaces.md, which is 404 at this commit and 0-hit repo-wide. Land the doc or drop the pointer — as committed it's the stated authority for the design and it doesn't exist.
  • state: string widens what managedSessionRows actually returns (SessionRowState, a 6-member union). Since the description rightly notes "the field names are now a contract", typing it — Exclude<SessionRowState, 'interactive'> is exactly the reachable set — would make that contract compile-checked instead of JSDoc-pinned.
  • The name JSDoc lists three sources but omits the fourth: managedSessionRows substitutes snapshot.state.sessionId when the derived title is Untitled session or empty.

Verified, and correct

Worth recording what I checked and found sound, since none of it is covered by CI here:

  • startedAt cannot throw. managedSessionRows does Number.isNaN(createdAt) ? undefined : createdAt, so new Date(row.startedAt).toISOString() never sees an invalid value. The "must not become 1970" claim holds, and holds in the right layer — the route doesn't have to defend it.
  • pid is never invented. workerPid ?? hostPid, undefined propagated through the spread. No pid: 0.
  • failed really is distinct from completed. TASK_STATE maps failed → 'failed'; the completed fold is a roster display-group concern that never reaches SessionRowState. Verified, not taken on faith.
  • Fresh install → 200 [], not 503. ENOENT on jobsDir returns early. Right split.
  • No /background-agents path collision in server.ts.
  • express (^5.2.1, dependency) and supertest (^7.2.2, devDependency) are both available to packages/cli, so the test's imports resolve.

Test evidence

This run carried no test execution of its own — the review is static, and per the gate's rules I did not build or run any PR-derived code. The evidence below is the PR's own CI, read through the API on 85c5e36.

The headline is what is absent: Qwen Code CI never ran on this PR. ci.yml triggers pull_request on main and release/** only, and this PR's base is feat/agent-view-session-control. So there is no lint, no typecheck, and no unit-test signal — the 6 tests, eslint and prettier --check results in the description are the author's local claim, not independently re-run or confirmed here. Everything green below is either TUI snapshot parity (unrelated to a serve route) or bot orchestration.

Not verified, with reasons:

  • Unit tests (background-agents.test.ts, 6 cases) — not verified: no CI job runs them on a non-main base, and I do not execute PR code.
  • npx tsc --noEmit / npm run build — not verified: the description defers both to CI, and CI does not cover this PR. Note the two blocking findings are type-visible: registerBackgroundAgentRoutes(app, {}) typechecks fine, which is precisely why a compiler won't catch the dead guard.
  • The four end-to-end steps (qwen serve + qwen --bg + curl) — not verified; the description already marks them unverified.
  • The 503 and untrusted-workspace behaviours against the real store — not verified, and per findings 1 and 3 the mocks cannot verify them.
Check Conclusion
Qwen Code CI — lint / typecheck / unit tests did not run (base is not main or release/**)
OpenTUI no-flicker gate (tui-parity) success
TUI parity snapshots (ink vs opentui) (tui-parity) success
authorize · assign · label · delay-automatic-review success (bot orchestration)
verify · tmux-testing · resolve-pr · precheck-pr · review-config · publish-verify · publish-tmux · publish-resolution · ack-review-request skipped
triage · review-pr in progress (this run)

No check on 85c5e36 is red, so there is no failing-job log to quote. The absence of a lint/typecheck/unit lane is a consequence of the stacked base, not a failure — but it does mean findings 1 and 2 above are the only review this code has had.

Sandboxed verification would settle this: @qwen-code /verify — that /background-agents returns 403 for an untrusted primary workspace is not observable from the diff (the guard is inert as wired), and the PR's own suite passes unchanged with registerBackgroundAgentRoutes(app, {}) because the untrusted test injects both deps itself. A load-bearing A/B against the base build is also the only thing that would show whether the 503 reaches a real unreadable roster.json or only an unlistable jobs/ directory (finding 3). You have write access, so @qwen-code /tmux is available too if you want the four end-to-end steps driven against a real supervisor.

中文说明

代码审查

路由本身写得不错 —— 小、纯投影、不引入新状态,复用 managedSessionRows 是正确的切分点。有两处阻塞项,都在同一个调用点,而且测试套件都看不见。

1. 生产环境中「不受信任工作区」守卫永远不会触发(阻塞)

background-agents.ts 用一个可选 dep 做守卫,而 server.ts:2241 的注册是 registerBackgroundAgentRoutes(app, {});。于是 deps.isWorkspaceTrustedundefinedundefined?.()undefinedundefined === falsefalse —— 守卫不可达。isPrimaryWorkspaceTrusted 定义在 server.ts:971,并被同一文件中 25 处以上的同类注册接收,包括最接近的 registerGoalsRoutesserver.ts:3091),它同时拿到 isWorkspaceTrusted: isPrimaryWorkspaceTrustedboundWorkspace: primaryBoundWorkspace。这一处注册是唯一退出该机制的只读列举路由。

不存在能让这个 per-route dep 变得多余的全局门禁:app.use 只有 CORS、host 白名单、trace-id 捕获、authenticate(token 鉴权,属于另一条边界)与限流;requireTrustedWorkspaceRuntime 是逐路由调用的(server.ts:2444)。

失败场景: 在不受信任的主工作区上运行 qwen serveGET /goals → 403 untrusted_workspaceGET /background-agents200,返回用户全局 roster:每个项目的绝对 cwd,以及一个 name —— 当既无显示名也无活动摘要时,deriveTitle 会用 launch.initialPrompt 填充它。

PR 描述称本路由「除沿用其他路由使用的 untrusted-workspace 钩子外,不做任何鉴权决定」。按实际接线,它并没有沿用 —— 它根本没有收到这个钩子。

为什么测试没发现: 那个不受信任的用例自己注入了两个 dep,因此它钉住的是一个生产调用方从不设置的开关 —— 绿色,但对实际 daemon 什么也没证明。它断言的是 { error: 'untrusted' },而真正的共享 helper(workspace-route-runtime.ts:307)返回 { error: 'Workspace is not trusted.', code: 'untrusted_workspace' },所以它也没有钉住真实契约。

2. 响应器是可选的,因此按显而易见的方式修 #1 会让请求挂住(阻塞)

如果只接上信任 dep,守卫会触发,deps.sendUntrustedWorkspaceResponse?.(res) 是空操作,然后 handler 直接 return —— 没有结束响应。Express 永不回复,请求会挂到客户端或 socket 超时。goals.ts 既不会挂住也不会失效开放,原因相同:它是 import 这个 helper,而不是把它当作 dep 接收。

两个问题是同一个修法:从 RegisterBackgroundAgentRoutesDeps 中去掉 sendUntrustedWorkspaceResponse,像 goals.ts 那样从 ../workspace-route-runtime.js 导入它,并在调用点传入 isWorkspaceTrusted: isPrimaryWorkspaceTrusted。之后测试无需 stub 响应器即可断言真实的 403 响应体。

3.「存储读不出来时返回 503」只在若干种「读不出来」中的一种成立

这是你要求评审的第二个判断,所以给出精确的覆盖范围。listAgentViewSessionSnapshots 有两次读取,失败语义不同:listAgentViewSessionStatesfs.readdir(jobsDir) 在 ENOENT 时返回 []其他错误一律抛出 → 503 ✓;而 readAgentViewRosterreadJsonRecord,后者按明确设计「对任何读取或解析错误都软失败」并返回 undefinednormalizeRoster 得到 sessions: []永不抛出。逐个 session 的 state.json 读取同样软失败,随后被 .filter(Boolean) 静默丢弃。因此 roster.json 不可读或损坏、或某个 state.json 不可读时,返回的是 200 加上静默降级或缺失的行 —— 一个状态文件读不出来的 session 会直接从列表里消失。这正是该判断本想避免的「分不清没有 agent 与看不了」,只是发生在下一层。

测试看不到这一点,因为它 stub 了整个 lister(async () => { throw … }),钉住的是路由的 try/catch,而不是真实存储的行为。建议要么把说法收窄为「jobs 目录无法列举时返回 503」,要么明确决定「静默消失的 session」也应返回 503。

更小的问题

  • background-agents.ts 顶部的文档注释指向 docs/plans/2026-09-04-background-agent-surfaces.md,该文件在此 commit 上是 404,全仓搜索 0 命中。要么把文档提交进来,要么去掉这个指针 —— 按现状,它是已提交代码里声明的设计权威,却并不存在。
  • state: string 放宽了 managedSessionRows 实际返回的类型(SessionRowState,6 个成员的联合类型)。既然描述正确地指出「字段名从此成为契约」,那么给它一个类型 —— Exclude<SessionRowState, 'interactive'> 恰好就是可达集合 —— 能让这个契约由编译器检查,而不是只靠 JSDoc 钉住。
  • name 的 JSDoc 列了三个来源,漏了第四个:当派生标题为 Untitled session 或空时,managedSessionRows 会用 snapshot.state.sessionId 替代。

已核实且正确的部分

由于这里没有任何 CI 覆盖,值得记录我核对过并确认无误的部分:

  • startedAt 不会抛异常。 managedSessionRows 做了 Number.isNaN(createdAt) ? undefined : createdAt,所以 new Date(row.startedAt).toISOString() 永远不会遇到非法值。「不得变成 1970」这一条成立,而且成立在正确的层 —— 路由无需自己防御。
  • pid 不会被臆造。 workerPid ?? hostPidundefined 通过展开正确传递,不会出现 pid: 0
  • failed 确实区别于 completed TASK_STATEfailed 映射为 'failed'completed 的折叠属于 roster 的显示分组,永远到不了 SessionRowState。这是核实过的,不是采信说法。
  • 全新安装 → 200 [],而非 503。 jobsDir 的 ENOENT 会提前返回。这个划分是对的。
  • server.ts 中不存在 /background-agents 路径冲突。
  • express^5.2.1,dependency)与 supertest^7.2.2,devDependency)对 packages/cli 都可用,测试的 import 能解析。

测试证据

本次运行没有执行任何测试 —— 审查是静态的,且按门禁规则我未构建或运行任何源自本 PR 的代码。下面的证据来自本 PR 自己的 CI,通过 API 在 85c5e36 上读取。

要点在于缺失的部分:Qwen Code CI 从未在本 PR 上运行。 ci.ymlpull_request 只针对 mainrelease/** 触发,而本 PR 的 base 是 feat/agent-view-session-control。因此没有 lint、没有 typecheck、没有单测信号 —— 描述中提到的 6 个测试、eslintprettier --check 结果,是作者本地的声明,本次未独立重跑、也未在此确认。下面所有绿色项,要么是与 serve 路由无关的 TUI 快照对比,要么是机器人编排任务。

未验证项及原因:

  • 单元测试(background-agents.test.ts,6 个用例) —— 未验证:非 main base 上没有任何 CI 作业会运行它们,而我不执行 PR 代码。
  • npx tsc --noEmit / npm run build —— 未验证:描述把两者都交给 CI,而 CI 不覆盖本 PR。注意上述两个阻塞项是类型层面可见的:registerBackgroundAgentRoutes(app, {}) 能通过类型检查,而这恰恰是编译器抓不到这个失效守卫的原因。
  • 四条端到端步骤qwen serve + qwen --bg + curl)—— 未验证;描述本身已标注未验证。
  • 针对真实存储的 503 与不受信任工作区行为 —— 未验证,且按发现 1 与 3,mock 也无法验证它们。

CI 结果表见上方英文区(此处不重复)。85c5e36 上没有红色检查,因此没有失败作业日志可引用。缺少 lint/typecheck/单测通道是 stack base 导致的后果,不是一次失败 —— 但这确实意味着上面的发现 1 与 2 是这段代码迄今唯一得到过的审查。

沙箱验证可以定案:@qwen-code /verify —— 「不受信任的主工作区下 /background-agents 返回 403」这一点从 diff 中无法观察(守卫按现状接线是失效的),而且即使保持 registerBackgroundAgentRoutes(app, {}) 不变,本 PR 的套件也照样通过,因为不受信任的那个用例自己注入了两个 dep。针对 base 构建的承重 A/B 也是唯一能说明 503 究竟是覆盖了真实不可读的 roster.json、还是只覆盖了不可列举的 jobs/ 目录(发现 3)的手段。你有 write 权限,因此如果想让那四条端到端步骤跑在真实 supervisor 上,@qwen-code /tmux 也可用。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 85c5e36aa88a729edd905a98b21dde5be9b5e2bc · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 2/5 — the route is good work and the fix is about four lines, but a trust guard that is inert in production and has a green test asserting it is the one shape I can't wave through.

Stepping back: my own independent proposal for this — before I read the diff — was a read-only GET under serve/routes/, reusing the existing session-row projection so the CLI and HTTP surfaces can't drift, importing the shared untrusted-workspace responder, passing isWorkspaceTrusted: isPrimaryWorkspaceTrusted like every sibling registration, and scoping the listing to boundWorkspace unless global was a stated decision. The PR matches that on structure and on reuse, which is the part that's actually hard to get right, and diverges on exactly the last three items. So this isn't a disagreement about approach. I'd have built it the same way.

What I'd thank you for in six months: managedSessionRows as the single projection is the right call and the reason this route can't drift from qwen sessions ps. Ninety-two production lines with no churn, no drive-by refactor, no speculative configurability. Declining to write the Web Shell panel blind, and saying why in the description instead of shipping something a maintainer quietly rewrites — that's the judgement I'd want from anyone stacking PRs. And the failed vs completed distinction is a real catch that the roster UI's icon tone was covering for.

What I'd curse: the dead guard. Not because the omission is subtle — registerBackgroundAgentRoutes(app, {}) is one line — but because it looks handled. There's a test named "refuses to list anything for an untrusted workspace", it passes, and it passes by injecting both deps itself. That's the worst shape a security-adjacent defect can take: the next three reviewers see the test name, see green, and move on. It's also why "the field names are now a contract" rings a little hollow in the description — the contract includes a 403 that the shipped daemon never returns, and the test asserts a 403 body the real helper never produces.

Am I being a pushover here? I don't think so. Both blockers are mechanical, both are evidenced against sibling code in the same file at the same commit, and neither requires any judgement call about intent. The goals.ts comparison isn't a style preference — it's the same directory, the same optional-isWorkspaceTrusted shape, and the one difference (imported responder vs. injected responder) is precisely what separates "fails closed" from "fails open, then hangs".

Two things I want to be fair about, because they cut the other way:

  • Nothing here is confirmed by CI, and that's not your fault. The stacked base means Qwen Code CI never fires. I reviewed statically and did not run your code, so findings 1 and 2 are reading, not measurement. I'm confident in them — the undefined === false path is not ambiguous — but the honest framing is that this PR has had one review pass and no automated one.
  • The scope question in Stage 1 is a question, not a finding. Global-vs-workspace listing may well be deliberate; qwen sessions ps is global too. It just needs to be a decision, stated in the route's ownership classification, rather than a consequence of calling listAgentViewSessionSnapshots() with no arguments. Note the interaction though: while the guard is inert, an untrusted workspace's daemon serves that global list. Fix the guard and the sharpest edge of the scope question goes away, which is a reason to do the guard first and reconsider the filter after.

One line worth saying once for the whole stack rather than four times: none of #10942 / #10943 / #10949 / #10954 gets lint, typecheck or unit-test CI until it retargets to main. Each is individually small and each is individually unverified by automation. That's a property of the stacking strategy, not of this PR, but it compounds — a defect like the dead guard is exactly what a green local suite plus no CI lane lets through, four times over.

Requesting changes on findings 1 and 2. Both are the same edit: import sendUntrustedWorkspaceResponse from ../workspace-route-runtime.js instead of accepting it as a dep, drop it from RegisterBackgroundAgentRoutesDeps, and pass isWorkspaceTrusted: isPrimaryWorkspaceTrusted at server.ts:2241. Then let the untrusted test assert the real 403 body rather than stubbing the responder — that single change turns the test from one that pins a switch nobody sets into one that would have caught this. Findings 3–6 are yours to take or leave; #3 is the one I'd actually think about, since it's a claim the description asks reviewers to endorse.

Happy to re-run and approve once the guard is wired — the route underneath it is ready.

中文说明

信心度:2/5 —— 路由本身是不错的工作,修复大约四行;但一个在生产环境中失效、同时还有一个绿色测试为其背书的信任守卫,是我无法放行的那种形态。

退一步看:在读 diff 之前,我自己的独立方案是 —— 在 serve/routes/ 下加一个只读 GET,复用既有的 session-row 投影以使 CLI 与 HTTP 两个表面不会漂移,导入共享的 untrusted-workspace 响应器,像每一处同类注册那样传入 isWorkspaceTrusted: isPrimaryWorkspaceTrusted,并且除非「全局」是一个明确决定,否则把列举范围限定在 boundWorkspace。本 PR 在结构与复用上与此一致 —— 而那正是真正难做对的部分 —— 分歧恰好在最后三项。所以这不是方案层面的分歧;换作我,也会这样构建。

六个月后我会感谢你的地方:以 managedSessionRows 作为唯一投影是正确的选择,也是本路由不会与 qwen sessions ps 漂移的原因。92 行生产代码,没有噪音,没有顺手重构,没有投机性的可配置性。没有盲写 Web Shell 面板,并在描述里说明理由、而不是交付一个维护者要悄悄重写的东西 —— 这正是我在别人做 stack PR 时期望看到的判断。而 failedcompleted 的区分是一个真实的发现,此前是 roster UI 的图标色调在替它遮掩。

我会抱怨的地方:那个失效的守卫。不是因为遗漏很隐蔽 —— registerBackgroundAgentRoutes(app, {}) 就一行 —— 而是因为它看起来已经处理了。有一个名为「refuses to list anything for an untrusted workspace」的测试,它通过了,而它通过的方式是自己注入两个 dep。这是一个安全相关缺陷所能采取的最糟形态:接下来三位评审者看到测试名、看到绿色,然后放行。这也正是为什么描述里那句「字段名从此成为契约」显得有些空 —— 契约里包含一个实际 daemon 永远不会返回的 403,而测试断言的 403 响应体是真正的 helper 永远不会产生的。

我在这里是在当软柿子吗?我不认为。两个阻塞项都是机械性的,都能在同一 commit 的同类代码上找到依据,且都不需要对意图做任何判断。与 goals.ts 的对比不是风格偏好 —— 同一目录、同样的可选 isWorkspaceTrusted 形态,而唯一差异(导入响应器 vs 注入响应器)恰恰就是「失效关闭」与「失效开放、然后挂住」之间的那条界线。

有两点我要公平地说,因为它们指向另一面:

  • 这里没有任何东西被 CI 确认,而这不怪你。 stack 的 base 意味着 Qwen Code CI 从不触发。我做的是静态审查,没有运行你的代码,所以发现 1 与 2 是阅读结论、不是测量结论。我对它们有信心 —— undefined === false 这条路径没有歧义 —— 但诚实的表述是:本 PR 只经过了一次审查,且没有任何自动化审查。
  • Stage 1 里的范围问题是一个提问,不是一条发现。 全局还是工作区级列举,很可能就是有意为之;qwen sessions ps 也是全局的。它只需要成为一个决定、写进路由的归属分类里,而不是调用 listAgentViewSessionSnapshots() 时不传参数所带来的后果。但请注意二者的相互作用:在守卫失效期间,一个不受信任工作区的 daemon 会提供那份全局列表。修好守卫,范围问题最锋利的那一面就消失了 —— 这也是先做守卫、之后再重新考虑过滤的理由。

有一句话值得对整个 stack 说一次,而不是说四次:#10942 / #10943 / #10949 / #10954 中任何一个,在重新以 main 为 base 之前都拿不到 lint、typecheck 或单测 CI。它们各自都很小,也各自都没有被自动化验证过。这是 stack 策略的属性,不是本 PR 的属性,但它会累积 —— 像失效守卫这样的缺陷,正是「本地绿色套件 + 没有 CI 通道」会放过去的东西,而且会放过去四次。

基于发现 1 与 2 请求修改。两者是同一处编辑:从 ../workspace-route-runtime.js 导入 sendUntrustedWorkspaceResponse,而不是把它当作 dep 接收;从 RegisterBackgroundAgentRoutesDeps 中去掉它;并在 server.ts:2241 传入 isWorkspaceTrusted: isPrimaryWorkspaceTrusted。然后让那个不受信任的测试断言真实的 403 响应体,而不是 stub 响应器 —— 单这一处改动,就能把测试从「钉住一个无人设置的开关」变成「本可以抓住这个问题」。发现 3–6 由你决定取舍;#3 是我会真正去想的一条,因为它是描述请评审者背书的一个论断。

守卫接好之后,我很乐意重跑并批准 —— 它下面的路由已经准备好了。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 85c5e36aa88a729edd905a98b21dde5be9b5e2bc · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yiliang114 — requesting changes on the two blocking findings from my review above. Both are the same edit at the same call site.

1. The untrusted-workspace guard is inert as wired. server.ts:2241 registers the route with {}, so deps.isWorkspaceTrusted?.() === false evaluates undefined === false and never fires. Every sibling read-only listing in this file receives isWorkspaceTrusted: isPrimaryWorkspaceTrustedregisterGoalsRoutes at server.ts:3091 is the closest analogue. There is no global trust middleware that would make the per-route dep redundant. Net effect: on an untrusted primary workspace, GET /goals returns 403 while GET /background-agents returns 200 with the user-global roster — other projects' absolute cwd, and a name that deriveTitle fills from launch.initialPrompt when there's no display name or activity summary.

2. The responder is an optional dep, so the obvious fix for #1 hangs the request. Wiring only isWorkspaceTrusted makes the handler call deps.sendUntrustedWorkspaceResponse?.(res) (a no-op) and return without ending the response — Express never replies. goals.ts can neither fail open nor hang because it imports the helper.

The fix, for both: import sendUntrustedWorkspaceResponse from ../workspace-route-runtime.js, drop it from RegisterBackgroundAgentRoutesDeps, and pass isWorkspaceTrusted: isPrimaryWorkspaceTrusted at the registration. Then let the untrusted test assert the real 403 body ({ error: 'Workspace is not trusted.', code: 'untrusted_workspace' }) instead of stubbing the responder with { error: 'untrusted' } — today it pins a switch no production caller sets, and a body the real helper never produces.

Findings 3–6 (the narrower real reach of the 503, the dangling docs/plans/2026-09-04-background-agent-surfaces.md reference in committed code, state: string widening SessionRowState, the incomplete name JSDoc) are non-blocking — take or leave, though #3 is a claim the description asks reviewers to endorse.

The route underneath all this is good work and the reuse of managedSessionRows is the right seam. Happy to re-run and approve once the guard is wired.

Note for the record: Qwen Code CI does not run on this PR (ci.yml triggers pull_request on main / release/** only, and the base here is feat/agent-view-session-control), so the lint, typecheck and unit-test results in the description are unconfirmed by automation. This review was static; no PR-derived code was built or executed.

中文说明

@yiliang114 —— 基于上方审查中的两条阻塞发现请求修改。两者是同一调用点上的同一处编辑。

1. 不受信任工作区守卫按现状接线是失效的。 server.ts:2241{} 注册该路由,因此 deps.isWorkspaceTrusted?.() === false 实际求值为 undefined === false,永不触发。本文件中每一处同类只读列举都接收 isWorkspaceTrusted: isPrimaryWorkspaceTrusted —— server.ts:3091registerGoalsRoutes 是最接近的对照。不存在能让这个 per-route dep 变得多余的全局信任中间件。最终效果是:在不受信任的主工作区上,GET /goals 返回 403,而 GET /background-agents 返回 200 并给出用户全局 roster —— 其他项目的绝对 cwd,以及一个 name(当既无显示名也无活动摘要时,deriveTitle 会用 launch.initialPrompt 填充它)。

2. 响应器是可选 dep,因此 #1 的显而易见修法会让请求挂住。 只接上 isWorkspaceTrusted 会让 handler 调用 deps.sendUntrustedWorkspaceResponse?.(res)(空操作)然后 return没有结束响应 —— Express 永不回复。goals.ts 既不会失效开放也不会挂住,因为它是 import 这个 helper。

两者的修法:../workspace-route-runtime.js 导入 sendUntrustedWorkspaceResponse,从 RegisterBackgroundAgentRoutesDeps 中去掉它,并在注册处传入 isWorkspaceTrusted: isPrimaryWorkspaceTrusted。然后让不受信任的测试断言真实的 403 响应体({ error: 'Workspace is not trusted.', code: 'untrusted_workspace' }),而不是用 { error: 'untrusted' } stub 响应器 —— 现状是它钉住了一个生产调用方从不设置的开关,以及一个真实 helper 永不产生的响应体。

发现 3–6(503 的真实覆盖范围更窄、已提交代码中悬空的 docs/plans/2026-09-04-background-agent-surfaces.md 引用、state: string 放宽了 SessionRowStatename 的 JSDoc 不完整)不阻塞 —— 取舍由你,不过 #3 是描述请评审者背书的一个论断。

这一切之下的路由是不错的工作,复用 managedSessionRows 是正确的切分点。守卫接好之后,我很乐意重跑并批准。

留档说明:Qwen Code CI 不在本 PR 上运行(ci.ymlpull_request 只针对 main / release/** 触发,而此处 base 是 feat/agent-view-session-control),因此描述中的 lint、typecheck 与单测结果未获自动化确认。本次审查为静态审查;未构建或执行任何源自本 PR 的代码。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 85c5e36aa88a729edd905a98b21dde5be9b5e2bc

Comment thread packages/cli/src/serve/server.ts Outdated
| ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `serve/fs/` | `WorkspaceFileSystem` factory plus `policy.ts` (size/trust/binary checks), `paths.ts` (canonicalize, resolveWithin, symlink rejection), `audit.ts`, and typed `FsError` values. |
| `serve/routes/workspace-file-read.ts`, `workspace-file-write.ts` | HTTP handlers for `GET /file`, `GET /file/bytes`, `POST /file/write`, and `POST /file/edit`. |
| `serve/routes/background-agents.ts` | `GET /background-agents` — the sessions the Agent View supervisor is running, read from the roster rather than from the daemon's own session model. Read-only: acting on one goes through the supervisor's socket, which the CLI does. |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-4: This row says the listing is "read from the roster", but roster membership neither includes nor excludes rows: the code readdirs the supervisor's session-state files (the jobs directory via listAgentViewSessionStates) and only joins roster entries for display titles. A session whose roster entry was removed — the adoption-failure rollback (supervisor-process.ts:789) or disconnected-adoption path (supervisor-process.ts:2964) — is still listed here; a maintainer following this doc would delete a roster entry to take a stale session off the surface and see it persist, and a client reconciling route rows against roster contents sees a mismatch where the code behaves as designed.

Witness:

probe against the real store through the production wiring:
X (state file, no roster entry — the exact post-rollback state) listed: true
Y (state + roster) listed: true
Z (roster entry, no state file) listed: false
Suggested change
| `serve/routes/background-agents.ts` | `GET /background-agents` — the sessions the Agent View supervisor is running, read from the roster rather than from the daemon's own session model. Read-only: acting on one goes through the supervisor's socket, which the CLI does. |
| `serve/routes/background-agents.ts` | `GET /background-agents` — the sessions known to the Agent View supervisor, including stopped and failed ones, read from the supervisor's session store (the roster supplies display names only) rather than from the daemon's own session model. Read-only: acting on one goes through the supervisor's socket, which the CLI does. |
中文说明

该行写「从 roster 读取」,但 roster 成员关系既不会包含、也不会排除任何行:代码实际是 readdir supervisor 的会话状态文件(经 listAgentViewSessionStates 读取 jobs 目录),仅连接(join)roster 条目以取得显示标题。一个被删除了 roster 条目的会话 —— 接管失败回滚(supervisor-process.ts:789)或断连接管路径(supervisor-process.ts:2964)—— 仍会出现在此列表中;照此文档操作的维护者删除 roster 条目、想把过期会话从该表面移除时,会发现它依然存在;将路由返回行与 roster 内容对账的客户端,会在代码按设计运行时看到不一致。

证据:通过生产接线对真实存储的探针显示——X(有状态文件、无 roster 条目,即回滚后的确切状态)被列出:true;Y(状态 + roster)被列出:true;Z(仅有 roster 条目、无状态文件)被列出:false。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred for this round - the fix budget went to the Critical gate wiring (R1-1/R1-6), the 503 envelope (R1-2) and the guard coverage (R1-5). The proposed rewording is accepted: the listing is read from the supervisor's session store, the roster supplies display names only. Planned as a follow-up on this branch; leaving this thread open until it lands.

Comment thread packages/cli/src/serve/routes/background-agents.test.ts
Comment on lines +37 to +38
/** `working`, `needs input`, `ready`, `stopped` or `failed`. */
state: string;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-3: The state contract is carried only in this prose enumeration over a bare string, although the closed label set already exists as SessionRowState in managed-rows.ts and this route only projects managedSessionRows output. Adding a value to AgentViewTaskState forces a TASK_STATE update (exhaustive Record), which compiles and immediately flows new labels into this route's responses, while state: string and the five-label comment silently drift — clients switching on the documented labels meet a value they cannot match, and no type check anywhere can catch it because the contract lives in a comment.

Witness:

witness: not run — closest capability was a mutant probe adding a sixth AgentViewTaskState and running tsc; unnecessary because the Record<AgentViewTaskState, SessionRowState> exhaustiveness (managed-rows.ts:79-85) and the bare state: string declaration here are compile-time facts read directly off the definitions.
Suggested change
/** `working`, `needs input`, `ready`, `stopped` or `failed`. */
state: string;
/** Task state label produced by `managedSessionRows`. */
state: Exclude<SessionRowState, 'interactive'>;

(SessionRowState comes from ../../commands/sessions/managed-rows.js — extend the existing import.)

Fix constraint: 'interactive' is assigned only by registryRow (managed-rows.ts:140), reached only via mergeSessionRows, which this route never calls — the Exclude must not be widened if registry rows are ever mixed in.

中文说明

状态契约仅由这段文字枚举承载、类型却是裸 string,而闭合的标签集合在 managed-rows.ts 中已以 SessionRowState 存在,且本路由只是投影 managedSessionRows 的输出。给 AgentViewTaskState 新增值会强制更新 TASK_STATE(穷举 Record),编译通过后新标签立即流入本路由的响应,而 state: string 与五值注释悄悄漂移 —— 按文档标签做 switch 的客户端会遇到无法匹配的值,且由于契约存在于注释中,任何类型检查都无法捕获。

证据:未运行 —— 最接近的能力是添加第六个 AgentViewTaskState 并运行 tsc 的突变探针;无需运行,因为 Record<AgentViewTaskState, SessionRowState> 的穷举性(managed-rows.ts:79-85)与此处裸 state: string 声明都是可直接从定义读出的编译期事实。

修复:用既有联合类型为字段定型 —— Exclude<SessionRowState, 'interactive'>,并扩展现有 import 引入 SessionRowState;文档注释改为指向类型而非重新枚举。

修复约束:'interactive' 仅由 registryRowmanaged-rows.ts:140)赋值、且只经由 mergeSessionRows 到达,而本路由从不调用它 —— 若将来混入 registry 行,不得放宽该 Exclude

— qwen3.8-max via Qwen Code /review (v0.23.0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred for this round - the fix budget went to the Critical gate wiring (R1-1/R1-6), the 503 envelope (R1-2) and the guard coverage (R1-5). The proposed Exclude<SessionRowState, 'interactive'> typing (imported from managed-rows.js, without widening the Exclude if registry rows are ever mixed in) is accepted and planned as a follow-up on this branch; leaving this thread open until it lands.

Comment thread packages/cli/src/serve/routes/background-agents.ts Outdated
Comment thread packages/cli/src/serve/routes/background-agents.ts
yiliang114 and others added 14 commits September 4, 2026 06:04
The registration passed `{}`, so `deps.isWorkspaceTrusted?.() === false`
never fired: an untrusted primary workspace got 403 from every sibling
route but a full 200 listing here — sessionIds, names, cwds and pids.
Wire the same `isPrimaryWorkspaceTrusted` closure the sibling routes
use, and answer the refusal with `sendUntrustedWorkspaceResponse`
hard-imported from `workspace-route-runtime.js`, as every other route
does: the injected `(res: unknown) => void` responder dep could not
take the canonical helper under `strictFunctionTypes` in the first
place, and it was the only such injected dep across these routes.

A server-level test now builds `createServeApp` with an authoritative
untrusted primary workspace and expects 403 `untrusted_workspace`;
with the deps dropped from the registration it answers 200 and goes
red. The route-level refusal test registers the predicate alone and
asserts the canonical envelope.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq
The machine key sat under `error` and the human detail under
`message` — the only such shape among the daemon's 503s. A client
classifying failures by `body.code` got `undefined` from exactly this
route. Put the human message under `error` and the machine key under
`code`, as 36 of the 47 sibling serve 503 sites do, and keep the raw
error under `message`.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq
`pid` and `startedAt` come from independent sources in
`managedSessionRows`, so a store can spoil one without the other. The
existing tests only covered both present and both absent, which let a
condition-swap between the two omission guards survive; add the mixed
case — a live worker with an unusable stamp keeps `pid` and drops
`startedAt` — and verify it goes red under the swap.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq
managedSessionRows mapped every snapshot the store returns, but the
store also holds unmanaged tombstones, mid-removal snapshots and
mid-adoption snapshots; an adopting snapshot reuses the id of a live
registered session, so the merge replaced a registry row that knows a
live pid with a pid-less ghost. Filter to ownership === 'managed',
the shape the supervisor's own listing uses.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq
The supervisor store is durable and nothing reaps it when no supervisor
runs, so after a crash or a reboot a managed row could carry a pid that
is dead or recycled to an unrelated process, and acting on it would
signal the wrong target. Check both recorded pids with core's isPidAlive
(worker first, then the PTY host, matching the supervisor's own idiom)
and print `-` when neither lives.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq
The reason text is foreign input, and a preserved LF would forge extra
lines out of the single stderr note the sibling test pins. Feed a
message carrying all four classes and assert none survive, so a
downgrade to plain sanitizeTerminalText (which keeps LF and TAB) fails.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq
The .name example prints session-generated text with jq -r; like the
adjacent .cwd example, it must say that the value is rendered raw and
to sanitize it when untrusted.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq
Both intercepts ran before resolveBootstrapRoute and before the
external Guard token scrub. Move them below both: only the default
route reaches them (version keeps its base-parity win, subcommand
launches fall through to the parser instead of becoming prompts),
and the serve-only credential is deleted before either intercept can
spawn the supervisor that would inherit it. The supervisor scan also
stops at `--` now, matching the `--bg` scan and the file's other
flag scans, so a supervisor flag passed as a prompt word can no
longer hijack the launch into serving the shared socket.

Adds entry-level runCliEntry tests for both intercepts; each goes
red when its intercept block (or the scrub ordering, or the `--`
cut) is removed.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq
readBackgroundPrompt skipped any other flag and tried to model which
flags take values, but the derivation was weaker than the parser it
mirrored: array-typed options consume N tokens while the scan
consumed one, inline-registered options like --sandbox-session-id sit
in no option table, and nothing stopped a value flag from swallowing
a flag-shaped next token. Each shape silently dispatched a wrong
prompt to an unattended session. Since --bg forwards no flags to the
worker anyway, decline the launch and name the flag instead.

The error paths now write through the house writeStderrLine, the
test captures it (the old no-op mock would have silently broken the
stderr assertions on any switch), the word-join test asserts both
joins instead of comma-operator-no-op'ing the first, and the
reason-not-a-stack test pins the exact one-line output so a switch
to error.stack goes red.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq
sanitizeTerminalText deliberately preserves TAB and LF for multi-line
render sites, so clean()'s CR-only strip was a no-op that let
session-written text (a model's own waitingFor/summary/lastResult)
forge continuation lines in peek's one-line labelled output — e.g. a
fake `Answer it with:` hint at column 0. Drop TAB and LF at this
one-line call site, exactly like the sibling renderer ps.ts does, and
pin the LF case in the sanitization test.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq
Its closest siblings — the other prompt-carrying launch flags
--prompt and --prompt-interactive — both have rows there, and the
table is the reference for every way to hand the CLI a prompt at
launch. Note the experimental status and the `qwen sessions ps`
companion.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq
peek derived its title from {state, activity} only: the supervisor's
peek response carried no roster entry or launch record, so deriveTitle
fell through to the untitled placeholder while `sessions ps` named the
same session — a renamed session or one whose worker summary is a
generic filtered phrase. Carry rosterEntry and launch through the
supervisor's peek response (the store reads listAgentViewSessionSnapshots
already does, launch redacted like the snapshot join) and feed them into
deriveAgentViewPresentation without reordering its precedence chain.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq
deriveTitle falls back to the activity summary when neither a roster
name nor a launch prompt names the session, so a session adopted with
'Backgrounded from native session' showed that phrase as the title
line and again as Doing: two lines later. Skip the Doing: line when it
would repeat the printed title; a summary distinct from the title
still gets its line.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq
peek/answer/stop wrote failure output (exit code 1) to stdout, while
list, ps and --bg all route errors through writeStderrLine: an answer
against a stale id sent 'No Agent View session found for ...' into the
success channel of `qwen sessions answer "$id" "$reply" > last.log`.
Send failure lines to stderr and keep success lines on stdout, and add
a control-commands test file driving the real handlers to pin the
stream choice and the exit-code propagation.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed. Suggestions are inline.

3 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-4 docs row misstates the listing's data source — still standing from round 1, already reported (comment 3927603487)
  • dangling docs/plans/2026-09-04-background-agent-surfaces.md reference in the module comment — already reported (triage stage-2 comment 5529335539 and stage-3 review 5104767493)
  • name JSDoc omitting the session-id fallback as fourth source — already reported (triage stage-2 comment 5529335539)

Not explored to full depth (tool budget reached): "agent 6a": none — no check was cut short..

Not reviewed: reverse audit — stopped before round 5 by the review time budget.

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

本轮确认的 3 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未探索到全部深度(达到工具调用预算):"agent 6a"none — no check was cut short.

未审查:反向审计——评审时间预算不足,未能开始第 5 轮。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +2244 to +2246
registerBackgroundAgentRoutes(app, {
isWorkspaceTrusted: isPrimaryWorkspaceTrusted,
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-1: No server-level test exercises the trusted positive path through createServeApp for GET /background-agents, so the default listSnapshots fallback and the trusted side of the wired predicate are never executed. Every unit test injects listSnapshots, and the sole server-level test is refused at the trust gate before the store is read — so one-line mutations survive the whole suite green: wiring isWorkspaceTrusted: () => false gives trusted workspaces a permanent 403 on this route, and replacing the ?? listAgentViewSessionSnapshots default with a throwing function gives them a permanent 503.

Witness:

probe (scratch tree, QWEN_HOME isolated):
intact default-reader request → 200 with agents array (1 passed)
mutant ?? (async () => { throw … }) → probe "expected 503 to be 200" while existing suite 7 passed
mutant isWorkspaceTrusted: () => false → server test still "1 passed | 1176 skipped"

Add a trusted-path test beside the refusal test in server.test.ts: build createServeApp(tokenOpts, undefined, { bridge: fakeBridge(), primaryWorkspaceTrusted: true }), then the authenticated GET /background-agents asserting status 200 and Array.isArray(res.body.agents). The default reader touches the test machine's real supervisor store — ENOENT on the jobs dir returns [] (packages/cli/src/agent-view/supervisor-store.ts:341) but a machine with real sessions returns them — so assert status and shape only, not listing content. The new test is its own witness: it goes red if the trusted wiring breaks (constant-false predicate → 403, throwing default store → 503, registration removed → 404) — please remove the predicate from this registration, run it, and confirm it reds.

中文说明

没有服务端级测试经过 createServeAppGET /background-agents 的受信任正向路径,因此默认的 listSnapshots 回退与已接线谓词的受信任一侧从未被执行。所有单测都自行注入 listSnapshots,唯一的服务端级测试在信任门禁处就被拒绝、到不了存储读取 —— 于是单行变异可以在整套测试保持绿色的情况下存活:把接线改成 isWorkspaceTrusted: () => false 会让受信任工作区在该路由上永久 403;把 ?? listAgentViewSessionSnapshots 默认值换成一个抛错函数则会让它永久 503。

证据:探针(scratch tree,QWEN_HOME 隔离)——完整代码下走默认读取器的请求 → 200 且返回 agents 数组(1 passed);变异体 ?? (async () => { throw … }) → 探针 expected 503 to be 200,而既有套件仍 7 passed;变异体 isWorkspaceTrusted: () => false → 服务端测试仍 1 passed | 1176 skipped

请在 server.test.ts 的拒绝用例旁补一个受信任路径测试:以 createServeApp(tokenOpts, undefined, { bridge: fakeBridge(), primaryWorkspaceTrusted: true }) 构建,随后带鉴权请求 GET /background-agents,断言状态 200 且 Array.isArray(res.body.agents)。默认读取器会触碰测试机真实的 supervisor 存储 —— jobs 目录 ENOENT 时返回 []packages/cli/src/agent-view/supervisor-store.ts:341),但本机若真有 session 则会返回它们 —— 因此只断言状态与形状,不断言列表内容。新测试自身即验收见证:受信任接线被破坏时它会变红(谓词恒 false → 403、默认存储抛错 → 503、注册被移除 → 404)—— 请移除本注册处的谓词后运行确认其变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

function appWith(listSnapshots: () => Promise<AgentViewSessionSnapshot[]>) {
const app = express();
registerBackgroundAgentRoutes(app, {
listSnapshots: listSnapshots as never,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-2: The injected listSnapshots mock is cast as never here and again in the untrusted-workspace test below, erasing the compile-time check against the dep's declared contract typeof listAgentViewSessionSnapshots — and the cast is unnecessary. If listAgentViewSessionSnapshots's signature drifts (a required StoreOptions argument, or a changed snapshot return type), these unit tests still compile and pass because the cast hides the mismatch, while the production wiring — server.ts registers the route with no listSnapshots override, so the route invokes the real default store — breaks, and the test seam can no longer be caught disagreeing with the declared contract.

Witness:

probe (scratch tree):
both casts removed → tsc --noEmit exit 0; vitest 7 passed (casts unnecessary)
simulated drift, no cast → TS2322 at the injection line (exit 2)
identical drift with `as never` → tsc exit 0
Suggested change
listSnapshots: listSnapshots as never,
listSnapshots,

Delete the second cast in the untrusted-workspace test the same way (listSnapshots: async () => [snapshot()] type-checks directly against the dep type).

中文说明

注入的 listSnapshots mock 在此处被转换为 as never,下方不受信任工作区的测试里也有同样一处,抹掉了针对 dep 声明契约 typeof listAgentViewSessionSnapshots 的编译期检查 —— 而该转换并无必要。若 listAgentViewSessionSnapshots 的签名漂移(新增必填的 StoreOptions 参数,或快照返回类型变化),这些单测仍能编译并通过,因为转换掩盖了不匹配;而生产接线 —— server.ts 注册该路由时未覆盖 listSnapshots,路由会调用真实的默认存储 —— 将会损坏,且测试接缝与声明契约的不一致将再也无法被发现。

证据:探针(scratch tree)——删除两处转换 → tsc --noEmit exit 0、vitest 7 passed(转换本不必要);模拟签名漂移且不加转换 → 注入行报 TS2322(exit 2);同样的漂移加 as never → tsc exit 0。

请同样删除不受信任工作区测试中的第二处转换(listSnapshots: async () => [snapshot()] 可直接通过 dep 类型检查;修法见上方英文 suggestion 块)。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +159 to +162
registerBackgroundAgentRoutes(app, {
listSnapshots: (async () => [snapshot()]) as never,
isWorkspaceTrusted: () => false,
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-3: This untrusted-workspace test injects a listSnapshots mock but never asserts it is not invoked, so the gate's ordering before the store read is unpinned. A refactor relocating the trust check below the store read passes the entire suite, while in production an untrusted daemon now reads the user's agent-view store before refusing — and when that store is unreadable it answers 503 background_agents_unavailable instead of 403 untrusted_workspace, so clients classifying by body.code misread a trust refusal as a store outage.

Witness:

probe (scratch tree):
INTACT → status 403, code "untrusted_workspace", listSnapshotsCalls 0
MUTANT (gate after store read) → status 503, code "background_agents_unavailable", listSnapshotsCalls 1
  (all 7 existing route tests and the server-level test still green)

Make the injected mock a spy and pin the ordering: const listSnapshots = vi.fn(async () => [snapshot()]); injected here, then expect(listSnapshots).not.toHaveBeenCalled(); after the 403 assertions. That assertion is the witness — move the gate below the store read and the spy is called, so the test goes red; please apply that mutation and confirm it.

中文说明

这个不受信任工作区的测试注入了 listSnapshots mock,却从未断言它未被调用,因此「门禁先于存储读取」的顺序没有被钉住。若重构把信任检查挪到存储读取之后,整套测试依然通过;而生产中,不受信任的 daemon 会先读取用户的 agent-view 存储再拒绝 —— 当该存储不可读时,它会返回 503 background_agents_unavailable 而非 403 untrusted_workspace,按 body.code 分类的客户端会把一次信任拒绝误判为存储故障。

证据:探针(scratch tree)——完整代码 → 状态 403、code untrusted_workspace、listSnapshotsCalls 0;变异体(门禁移到存储读取之后)→ 状态 503、code background_agents_unavailable、listSnapshotsCalls 1(既有 7 个路由测试与服务端级测试仍全部为绿)。

请把注入的 mock 改成 spy 以钉住顺序:此处注入 const listSnapshots = vi.fn(async () => [snapshot()]);,然后在 403 断言之后加 expect(listSnapshots).not.toHaveBeenCalled();。该断言即验收见证 —— 把门禁移到存储读取之后,spy 就会被调用,测试随之变红;请施加该变异并确认。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +38 to +39
/** `working`, `needs input`, `ready`, `stopped` or `failed`. */
state: string;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-3: Still standing from round 1 (deferred then, with the proposed fix accepted for a later round): the state contract is carried only in this prose enumeration over a bare string, although the closed label set already exists as SessionRowState in managed-rows.ts and this route only projects managedSessionRows output. A state label added to or renamed in SessionRowState ships without a compile error here; HTTP clients classifying by the documented five labels silently receive a sixth. The description states "the field names are now a contract" — a prose enumeration over string cannot enforce it.

Witness:

not run — carried-over finding confirmed in round 1; the nearest capability (a type-level probe) is not re-run because the budget tail forbids re-verifying prior-round confirmations; still-stands re-ruled by direct read at HEAD (background-agents.ts:38 still declares state: string)

Type the field as the reachable set, importing the union from managed-rows.js:

import type { SessionRowState } from '../../commands/sessions/managed-rows.js';

  /** `working`, `needs input`, `ready`, `stopped` or `failed`. */
  state: Exclude<SessionRowState, 'interactive'>;
中文说明

第 1 轮遗留、至今仍成立(当时作者推迟处理,并认可所提修法留待后续轮次):state 的契约仅由这段基于裸 string 的文字枚举承载,而闭合的标签集合在 managed-rows.ts 中已以 SessionRowState 存在,且本路由只投影 managedSessionRows 的输出。若 SessionRowState 新增或重命名某个状态标签,此处不会报任何编译错误;按文档中五个标签分类的 HTTP 客户端会悄无声息地收到第六种。描述中写道「字段名从此成为契约」—— 基于 string 的文字枚举无法强制这一契约。

证据:未运行 —— 承接自第 1 轮的已确认发现;最近的手段(类型级探针)未重跑,因为预算尾段禁止对前轮已确认项重新验证;经 HEAD 直接阅读重新判定为仍然成立(background-agents.ts:38 仍声明 state: string)。

请把该字段类型改为可达集合,并从 managed-rows.js 导入该联合类型(代码见上方英文块)。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Review finding 2 on this PR: `SessionRow.state` was documented as "what
the STATE column can say" and went straight into `--json`, so the
machine contract was pinned to display copy. `'needs input'` carries a
space, and the documented recipe was
`jq -r 'select(.state == "needs input")'` — reword the column later and
every script breaks silently, with no type error anywhere to warn.

The stable enum was already in hand. The row now carries
`presentation.taskState` (`running` | `waiting` | `ready` | `stopped` |
`failed`) and `ps.ts` maps it to English at the one place that renders a
table.

This also removes an asymmetry the reviewer named: `state` was a *kind*
discriminator for registry rows (`'interactive'`) and a *task* state for
managed ones — one field with two meanings, which is why `managed` had
to be added beside it. A registry row now has no `taskState` at all,
which is the truth: it knows a process is alive and nothing more.
Follows the parent commit: `SessionRow` now carries
`presentation.taskState` instead of the string the `qwen sessions ps`
table prints, so this route's `state: string` would have emitted a
display label — `"needs input"`, space and all — as a JSON field that
clients script against.

It now emits `taskState` with the presentation layer's own tokens
(`running` | `waiting` | `ready` | `stopped` | `failed`), typed as
`AgentViewTaskState` rather than widened to `string`, which was a
separate non-blocking finding on this PR.

A row whose `taskState` is somehow unset is skipped rather than emitted
with the field missing. It cannot happen today — `managedSessionRows`
only maps snapshots the supervisor owns — but an agent reported with no
state at all is worse than one not reported, because the caller would
have to invent a state to render it.
CI caught what a vitest-only run cannot: the previous commit annotated a
test case with `AgentViewTaskState` but never imported it, so
`tsc --build` failed with TS2304 and took the TUI gates down with it on
every branch in the stack. The import edit had targeted an import block
that had since gained another symbol, so the anchor never matched and
the change was silently dropped.

Verified the same way it should have been the first time: every file
that names the type now imports it, and nothing anywhere still refers to
the removed `SessionRowState` or `row.state`.
@yiliang114
yiliang114 changed the base branch from feat/agent-view-session-control to main September 4, 2026 03:39
@yiliang114 yiliang114 closed this Sep 4, 2026
@yiliang114 yiliang114 reopened this Sep 4, 2026
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Retargeted this PR from feat/agent-view-session-control to main, so the cumulative tree gets a real CI run.

Why. .github/workflows/ci.yml triggers on pull_request: branches: [main, 'release/**']. With the base set to another feature branch, this PR ran only the TUI parity / OpenTUI gates and the bot jobs — 13 checks, zero unit tests and no Lint & Static. Every stage-3 review on this stack said the same thing ("no compiler, linter or test runner has touched this branch anywhere"), and that was structurally true, not an oversight.

Changing the base alone does not re-trigger anything — GitHub emits pull_request: edited, which on: pull_request does not listen for — so this PR was closed and reopened to start a full run.

What this changes for reviewers. The diff is now the whole stack (+2499/-66, 30 files) instead of this layer alone (+309/-0, 5 files). It shrinks by itself as the lower PRs land, and each layer is still readable on its own PR:

The merge is clean against main (MERGEABLE). No commits were rewritten and nothing was force-pushed, so existing review anchors are intact.

Also, for anyone re-reading the stage-2/3 findings on this PR: all three were fixed on 2026-09-04, after that review was written.

  • The inert trust guard — registerBackgroundAgentRoutes(app, {}) meant deps.isWorkspaceTrusted?.() === false was never true. Now wired with the same isPrimaryWorkspaceTrusted closure the sibling registrations use, and the injected (res: unknown) => void responder dep was dropped in favour of hard-importing sendUntrustedWorkspaceResponse, as every other route does. server.test.ts builds createServeApp with an authoritative untrusted primary workspace and expects 403 untrusted_workspace.
  • The 503 body now uses the daemon's code: envelope rather than error:.
  • The JSON field reports a stable taskState token instead of the wording qwen sessions ps prints in its table.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review was cancelled before a review could be posted. Nothing failed and nothing is retried automatically: the run was cancelled — by an operator, an upstream event, or the job exceeding its execution time limit. If you still want a review of this PR, request one with @qwen-code /review. See workflow logs.

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 2416476, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 12 scenario(s).

Qwen Code · serve A/B

CI caught this one: two cases in this file asserted `pid: 777`, and both
started failing when the parent branch's pid-liveness check reached here
through the stack merge. `managedSessionRows` now verifies a recorded
worker pid before reporting it, so 777 — a dead process on the runner —
is correctly dropped, and the row arrives with no pid at all.

The fixture uses this process's own pid instead. That keeps the real
liveness path under test rather than stubbing the check away, which is
the point of the check: a recorded pid can be dead or recycled onto an
unrelated process, and reporting it either way would be the bug.

The complementary case — a dead pid being dropped — is already covered
in `managed-rows.test.ts`, so it is not duplicated here.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R3-16 dangling docs/plans/2026-09-04-background-agent-surfaces.md reference in the route header — already reported (triage stage-2 comment 5529335539 and stage-3 review 5104767493)

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

Convergence: round 3 posted 24 inline comment(s), 22 of them reported for the first time; the previous round posted 4 (3 new). The rate of new findings is not falling. Batching the remaining fixes and verifying them before the next push, or dropping this PR's reviews to --severity-floor critical, keeps the loop from re-deriving the same set. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

收敛情况:第 3 轮发布了 24 条行内评论,其中 22 条是首次提出;上一轮发布了 4 条(其中 3 条首次提出)。新发现的产出速度没有下降。把剩余修复攒成一批、验证后再推送,或将本 PR 的评审降到 --severity-floor critical,可以避免循环反复推导同一组发现。(仅为观察——本轮评审未因此扣留任何内容。)

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment thread packages/cli/src/cli.ts
const { readBackgroundPrompt, runBackgroundDispatch } = await import(
'./agent-view/background-entry.js'
);
const read = readBackgroundPrompt(rawArgv);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R3-5: [certifies-falsely] [new-surface] The --bg entry intercept feeds the unnormalized rawArgv to readBackgroundPrompt, while every other decision in runCliEntry consumes normalizeServeFastPathArgv(rawArgv). In the launch shape that normalization exists for — the first argv token is the CLI entry path (the yargs/Electron special case, modeled identically by the strip in config.ts) — the path token does not start with -, so it is collected as a prompt word: ['/x/dist/qwen-cli/cli.js', '--bg', 'audit the release'] dispatches a background session whose prompt is '/x/dist/qwen-cli/cli.js audit the release' — the session runs the wrong task, presented as a normal start. The same corruption hits ['.../dist/cli.js', '--bg', '--', 'data'], where the prompt becomes the bare path.

Witness:

probe (real runCliEntry, scratch tree):
runCliEntry(['--bg', 'audit the release'])
 -> runBackgroundDispatch('audit the release')   [control]
runCliEntry(['/repo/dist/cli.js', '--bg', 'audit the release'])
 -> runBackgroundDispatch('/repo/dist/cli.js audit the release')
runCliEntry(['/repo/dist/qwen-cli/cli.js', '--bg', '--', 'data'])
 -> runBackgroundDispatch('/repo/dist/qwen-cli/cli.js')
fix flip (readBackgroundPrompt(argv)): all three dispatch the clean prompt;
the 8 existing 'Agent View entry intercepts' tests stay green
Suggested change
const read = readBackgroundPrompt(rawArgv);
const read = readBackgroundPrompt(argv);

The strip must keep matching exactly the three entry suffixes in packages/cli/src/utils/serve-fast-path-argv.ts:9-15 (/dist/qwen-cli/cli.js, /dist/cli.js, /dist/cli/cli.js); reusing the normalized argv already in scope satisfies that. In packages/cli/src/cli.test.ts (describe('Agent View entry intercepts')), please add a case asserting runCliEntry(['/x/dist/cli.js', '--bg', 'audit']) calls runBackgroundDispatch with 'audit', then remove the fix and confirm the test reds (with rawArgv the dispatch receives '/x/dist/cli.js audit').

中文说明

--bg 入口拦截把未归一化的 rawArgv 传给 readBackgroundPrompt,而 runCliEntry 中所有其他决策都消费 normalizeServeFastPathArgv(rawArgv)。在归一化为它而存在的启动形态下 —— argv 首 token 是 CLI 入口路径(yargs/Electron 特例,config.ts 中有同样的剥离)—— 路径 token 不以 - 开头,因此被当作提示词收集:['/x/dist/qwen-cli/cli.js', '--bg', 'audit the release'] 会派发一个提示词为 '/x/dist/qwen-cli/cli.js audit the release' 的后台会话 —— 会话执行的是错误的任务,表面上却像正常启动。['.../dist/cli.js', '--bg', '--', 'data'] 同样损坏,提示词变成裸路径。

证据:探针(真实 runCliEntry,scratch tree)——对照用例派发干净提示词;两种入口路径形态分别派发被路径污染的提示词与裸路径;改为 readBackgroundPrompt(argv) 后三者都派发干净提示词,既有 8 个入口拦截测试保持绿色。

修法见上方 suggestion 块。剥离逻辑必须继续精确匹配 serve-fast-path-argv.ts:9-15 的三个入口后缀;复用作用域内已归一化的 argv 即可满足。请在 cli.test.tsAgent View entry intercepts 中补一个用例:断言 runCliEntry(['/x/dist/cli.js', '--bg', 'audit'])'audit' 调用 runBackgroundDispatch,然后移除修复、确认该测试变红(rawArgv 下派发的是 '/x/dist/cli.js audit')。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed at 2416476. cli.ts:582 passes rawArgv to readBackgroundPrompt, while every other decision in runCliEntry consumes the normalized argv from normalizeServeFastPathArgv(rawArgv): resolveBootstrapRoute(argv), firstPositionalArg(argv) at cli.ts:573, and tryRunServeFastPath(argv).

The entry-path launch shape is not hypothetical - it is the reason two strips exist. normalizeServeFastPathArgv drops a first token ending in /dist/qwen-cli/cli.js, /dist/cli.js or /dist/cli/cli.js (utils/serve-fast-path-argv.ts:14), and parseArguments carries the identical strip commented "hack: if the first argument is the CLI entry point, remove it" (config/config.ts:574). In that shape the path token does not start with -, so the word loop in readBackgroundPrompt (agent-view/background-entry.ts:81-88) collects it and dispatches /x/dist/cli.js audit the release as the prompt.

Real, and the fix is the suggested one-liner: readBackgroundPrompt(argv). Caveat for whoever takes it - the strip is suffix-based, so a spawned child whose argv[0] is a TypeScript entrypoint (the DEV/tsx shape buildCurrentQwenCliArgv produces, agent-view/current-cli-argv.ts:18) is not covered by it. No current caller spawns --bg that way, so that is a caveat rather than a second bug.

No code lands this round - the PR is +2508/-66, over this sweep's 1500-addition scope fuse - so the fix is queued for a maintainer decision. Leaving the thread unresolved.

records: readonly SessionRegistryRecord[],
managed: readonly SessionRow[],
): SessionRow[] {
const managedIds = new Set(managed.map((row) => row.sessionId));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R3-10: [certifies-falsely] [new-surface] mergeSessionRows dedups registry records against managed rows by exact string equality on sessionId, but the two sources canonically carry different spellings of the same id: the managed side is forced to the lowercased store directory name (sanitizeSessionId lowercases at supervisor-store.ts:550; normalizeSessionState sets state.sessionId to path.basename(paths.sessionDir)), while the registry record keeps the raw spelling (registerSession writes fields.sessionId verbatim, and the worker registers config.getSessionId() after the supervisor respawns it with --resume=<raw spelling>). Mixed-case ids are legal (INTERNAL_SESSION_ID_REGEX is case-insensitive). So a managed session whose id contains uppercase is listed twice — one managed row with its real state, one interactive row — and --json emits two objects: exactly the double listing this function's own doc comment exists to prevent. The trigger is currently latent (the detach path that preserves the raw spelling is not wired to a user command yet, and --bg generates lowercase UUIDs), but the defect is in this diff's new merge code and wakes the day a mixed-case id reaches it.

Witness:

probe (scratch tree, vitest): registry record sessionId
'ABCDEF12-3456-1234-89AB-CDEF12345678' + managed snapshot lowercased
'abcdef12-...' on PR code -> expected [ ... ] to have a length of 1 but got 2
(managed row + interactive row); with case-insensitive dedup the probe
passes (1 row) and all 18 existing managed-rows tests stay green

Canonicalize both sides at comparison time with the store's own sanitizer — never rewrite either stored spelling:

const managedIds = new Set(managed.map((row) => sanitizeSessionId(row.sessionId)));
// ...
.filter((record) => !managedIds.has(sanitizeSessionId(record.sessionId)))

The two spellings coexist by design — parseAdoptParams at packages/cli/src/agent-view/supervisor-process.ts:4182-4186: "The store lowercases directory names… The raw spelling is kept separately: the native session store is case-sensitive, so --resume must use it" — so the fix must canonicalize at comparison time only. In ps.test.ts, please add a test pairing managedSnapshot() (state.sessionId 'managed-1') with record({ sessionId: 'Managed-1' }) asserting exactly one row (the managed one), then restore the plain Set/has comparison and confirm it reds (two rows).

中文说明

mergeSessionRowssessionId 的精确字符串相等来对注册表记录与 managed 行去重,但这两个来源对同一个 id 规范地保存着不同写法:managed 一侧被强制为小写的存储目录名(sanitizeSessionIdsupervisor-store.ts:550 转小写;normalizeSessionStatestate.sessionId 设为 path.basename(paths.sessionDir)),而注册表记录保留原始写法(registerSession 原样写入 fields.sessionId,supervisor 以 --resume=<原始写法> 重启 worker 后,worker 用 config.getSessionId() 注册)。混合大小写的 id 是合法的(INTERNAL_SESSION_ID_REGEX 大小写不敏感)。因此 id 含大写字母的 managed 会话会被列两次 —— 一行带真实状态的 managed 行、一行 interactive 行 —— --json 会输出两个对象:正是本函数自身注释声明要防止的重复列出。触发路径目前是潜伏的(保留原始写法的 detach 路径尚未接到用户命令,--bg 生成小写 UUID),但缺陷在本 diff 新增的合并代码里,一旦出现混合大小写 id 就会发作。

证据:探针(scratch tree,vitest)——注册表记录 ABCDEF12-… 与小写 managed 快照 abcdef12-… 在 PR 代码上得到 2 行(期望 1);改为大小写不敏感去重后探针通过(1 行),既有 18 个 managed-rows 测试保持绿色。

修法:比较时用存储自己的归一化函数对两侧归一(绝不改写任一存储写法),代码见上方英文块。两种写法是设计上共存的 —— supervisor-process.ts:4182-4186parseAdoptParams 注释:「存储把目录名转小写……原始写法另行保留:原生会话存储大小写敏感,--resume 必须用它」—— 因此只能在比较时归一。请在 ps.test.ts 补一个用例:managedSnapshot()state.sessionId'managed-1')配 record({ sessionId: 'Managed-1' }),断言恰好一行(managed 行);恢复普通 Set/has 比较后确认其变红(两行)。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed at 2416476. mergeSessionRows dedups by exact string equality - new Set(managed.map((row) => row.sessionId)) at managed-rows.ts:179 and the !managedIds.has(record.sessionId) filter just below it - while the two sides canonically carry different spellings of the same id. sanitizeSessionId lowercases (agent-view/supervisor-store.ts:550), and parseAdoptParams deliberately keeps the raw spelling beside the sanitized one for --resume (agent-view/supervisor-process.ts:4187, :4191, :4205), with the comment above them stating that the store lowercases directory names while the native session store is case-sensitive.

Reachability at head, because it sets priority: the trigger is latent, not live. --bg dispatch generates randomUUID() (agent-view/supervisor-dispatch.ts:45), which is lowercase. The only path that can put a mixed-case id into the store while a registry record keeps the raw spelling is adoption, and adoption's only in-repo caller detachCurrentSessionToAgentView (agent-view/managed-detach.ts:32) is imported solely by managed-detach.test.ts - no user command reaches it. So nothing double-lists today.

It is still a real defect in this diff's new merge code, and it activates the day detach/adoption gets wired. The fix is comparison-time canonicalization only (sanitizeSessionId on both sides), never rewriting a stored spelling. Queued for a maintainer decision - fix here, or land it with the PR that wires detach. No code lands this round - the PR is +2508/-66, over this sweep's 1500-addition scope fuse - so the fix is queued for a maintainer decision. Leaving the thread unresolved.


Experimental. Runs a prompt as a background session and returns immediately, printing the session id.

The session is owned by a supervisor process that outlives the shell you started it from, so closing that terminal does not stop the work. `qwen sessions ps` lists it, and says whether it is `working` or has stopped to ask you something. It is a full Qwen Code session, so — when `agents.crossSessionMessaging` is on — it also appears in another session's `list_agents` and can be addressed with `send_message` (see [Messaging Another Running Session](#6-messaging-another-running-session)).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R3-24: This sends users to section 6, which promises each session appears in list_agents and can be addressed with send_message — but for a managed session the addressable name and the name qwen sessions ps --json records come from different derivations: send_message addresses by the registry name (deriveSessionName(cwd, sessionId)<dir-basename>-<2-hex>; resolvePeerTarget matches record.name exactly), while the managed row's name is the presentation title (roster name / launch prompt / activity summary). The jq example at line 851 extracts exactly that managed title, implying it is an actionable address — and mergeSessionRows' dedup hides the registry row from ps, so no ps surface reveals the real one. A user who follows the documented flow (ps --json | jq …send_message to=<title>) gets a not-found refusal.

Witness:

probe (real deriveSessionName, managedSessionRows, toPeerSessionInfo,
resolvePeerTarget):
ps --json managed-row .name = "release audit"
registry name (list_agents address) = "app-d2"
send_message to="release audit" -> none
send_message to="app-d2"        -> one

State here (or in section 6) that a background session's messaging address is the name list_agents itself shows — derived from the session's working directory, not the title sessions ps prints — or include the registry name in managed ps --json rows.

中文说明

此处把用户引向第 6 节,该节承诺每个会话都会出现在 list_agents 中并可用 send_message 寻址 —— 但对 managed 会话,可寻址的名字与 qwen sessions ps --json 记录的名字来自不同派生:send_message 按注册表名寻址(deriveSessionName(cwd, sessionId)<目录名>-<2位十六进制>resolvePeerTargetrecord.name 精确匹配),而 managed 行的 name 是展示标题(roster 名 / 启动提示词 / 活动摘要)。第 851 行的 jq 示例提取的恰是那个 managed 标题,暗示它是可用的地址 —— 而 mergeSessionRows 的去重又把注册表行从 ps 中隐藏,因此 ps 的任何表面都看不到真实地址。按文档流程操作的用户(ps --json | jq …send_message to=<标题>)会得到「找不到」的拒绝。

证据:探针(真实 deriveSessionNamemanagedSessionRowstoPeerSessionInforesolvePeerTarget)——ps --json managed 行 .name = "release audit";注册表名(list_agents 地址)"app-d2"send_message to="release audit" → 无匹配;send_message to="app-d2" → 命中。

请在此处(或第 6 节)说明:后台会话的消息地址是 list_agents 自身显示的名字 —— 由会话工作目录派生,而不是 sessions ps 打印的标题;或在 managed 的 ps --json 行中加入注册表名。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +779 to +780
**managed** session is an Agent View session owned by a supervisor: it
writes no registry record, so it used to be invisible here. Managed

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R3-1: This (and the module header this diff adds at packages/cli/src/commands/sessions/managed-rows.ts:14) states managed sessions write no live-process registry record — contradicting the design issue this PR implements and the diff's own dedup. Issue #10951 records the measured fact that a --bg session "is launched as a full interactive session… so it registers in the live-process registry", and its fact-check re-verified registerSession is called unconditionally at startInteractiveUI.tsx:421; both hold at HEAD (buildNativeWorkerArgv launches --session-id <id> --prompt-interactive=<prompt>, and nothing on that path suppresses registration). A running --bg session was therefore already visible to the old registry-walking ps as an interactive row — what this change adds is its semantic state, not its visibility. mergeSessionRows' own comment acknowledges managed workers "can also write a registry record", and the test 'lists a session once when it is both managed and registered' pins the dedup that follows.

Witness:

witness: not run — proving the 'already visible to the old ps' half by
execution needs an A/B driving a real qwen --bg session (supervisor + PTY
worker + model-backed interactive startup) in both trees; the contradiction
is pinned by the quoted lines, including the diff's own dedup rationale

Reword both sites to the measured fact, e.g.: "A managed session is an Agent View session owned by a supervisor. Its worker launches as a full interactive session, so it can also write a live-process registry record; before this listing the registry could only show it as an interactive row with no state." The rewording must not assert or imply managed sessions never register — managed-rows.test.ts 'lists a session once when it is both managed and registered' and the unconditional registerSession at packages/cli/src/ui/startInteractiveUI.tsx:421 are the pins that invites deleting the dedup if it does.

中文说明

此处(以及本 diff 在 packages/cli/src/commands/sessions/managed-rows.ts:14 新增的模块头)声称 managed 会话不写活进程注册表记录 —— 与本 PR 声明实现的设计 issue 及 diff 自身的去重相矛盾。Issue #10951 记录了实测事实:--bg 会话「以完整交互式会话启动……因此会注册进活进程注册表」,其事实核查复核了 registerSessionstartInteractiveUI.tsx:421 无条件调用;两者在 HEAD 均成立(buildNativeWorkerArgv--session-id <id> --prompt-interactive=<prompt> 启动,该路径上没有任何豁免)。因此运行中的 --bg 会话本来就能被旧的、遍历注册表的 ps 看到(作为 interactive 行)—— 本改动增加的是它的语义状态,而不是它的可见性。mergeSessionRows 自己的注释承认 managed worker「也可能写注册表记录」,测试 'lists a session once when it is both managed and registered' 钉住了随之而来的去重。

证据:未运行 —— 要通过执行证明「旧 ps 本可见」这一半,需要在两棵树里 A/B 驱动一个真实 qwen --bg 会话(supervisor + PTY worker + 有模型支撑的交互式启动);该矛盾由上述引文钉住,包括 diff 自身的去重理由。

请把两处改为实测事实,例如:「managed 会话是由 supervisor 拥有的 Agent View 会话。它的 worker 以完整交互式会话启动,因此也可能写活进程注册表记录;在本列表出现之前,注册表只能把它显示为没有状态的 interactive 行。」措辞不得声称或暗示 managed 会话从不注册 —— managed-rows.test.ts 的 'lists a session once when it is both managed and registered' 与 startInteractiveUI.tsx:421 的无条件 registerSession 正是相应的钉扎;若措辞失实,会诱使维护者删掉去重。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +808 to +809
An interactive session is emitted as its whole registry record, plus
`managed: false`:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R3-3: The new JSON contract says the interactive row is the "whole registry record" and its field list omits ipcPath, which is emitted, while the deliberate stripping of the credential field ipcToken goes unmentioned — ps.ts emits { ...row.record, ipcToken: undefined, managed: false }, and SessionRegistryRecord carries optional ipcPath/ipcToken. Any session with cross-session messaging enabled therefore emits an undocumented ipcPath field in --json, and a reader auditing what ps --json spills into logs/pipelines cannot tell from this section — which presents itself as the contract to script against — that the inbox token is deliberately excluded.

Witness:

probe (drove the real handler with a record carrying both fields):
{ "ipcPath": "/tmp/qwen-inbox/sess-1.sock", "hasIpcToken": false }

Add ipcPath to the field list with a note that it appears when the session has a messaging socket, and say ipcToken is deliberately omitted.

中文说明

新的 JSON 契约说交互行是「整个注册表记录」,但字段列表遗漏了实际会输出的 ipcPath,同时刻意剥离凭据字段 ipcToken 一事未被提及 —— ps.ts 输出 { ...row.record, ipcToken: undefined, managed: false },而 SessionRegistryRecord 带有可选的 ipcPath/ipcToken。因此任何启用跨会话消息的会话都会在 --json 中输出未文档化的 ipcPath 字段;审计 ps --json 会向日志/管道泄漏什么的读者,无法从这一自称「脚本契约」的小节看出收件箱 token 是被刻意排除的。

证据:探针(以同时携带两个字段的记录驱动真实 handler)——{ "ipcPath": "/tmp/qwen-inbox/sess-1.sock", "hasIpcToken": false }

请把 ipcPath 加入字段列表并注明它在会话具有消息 socket 时出现,并说明 ipcToken 被刻意省略。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment thread packages/cli/src/commands/sessions/ps.test.ts
Comment on lines +154 to +156
const reason = error instanceof Error ? error.message : String(error);
writeStderrLine(
`Managed sessions could not be listed: ${sanitize(reason)}`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R3-15: The store-failure guard announces itself through writeStderrLine, which throws on a closed or broken stderr — so the exact failure the guard catches can still take the whole listing down. Run qwen sessions ps with stderr unavailable (2>&-, or piped to a reader that has exited) while the supervisor store is unreadable — the EACCES case the sibling test enacts: listAgentViewSessionSnapshots() rejects, the catch runs, and process.stderr.write throws (stdioHelpers.ts documents that writeStderrLine throws on EPIPE or a closed fd). The throw escapes readManagedRows, Promise.all rejects, handlePs aborts, and the registry rows listLiveSessions already returned are never emitted — the command fails wholesale instead of degrading to the registry half that the guard's own comment promises ("must not take the command down — the registry half still answers the question").

Witness:

probe (store EACCES + throwing stderr): the handler rejects —
'expected Error: write EPIPE { code: EPIPE } to be undefined' —
and the registry row is never emitted;
with the note on writeStderrLineSafe the probe passes and the
interactive row is emitted

Emit the note with writeStderrLineSafe from the same stdioHelpers.js module — the note is incidental and there is definitionally nowhere to report once stderr is gone; stdioHelpers.ts:51-58 ("Use it only where the write is incidental to the work at hand and failing it would destroy something real") is the criterion, and this site meets it, so prefer it over an ad-hoc try/catch. In ps.test.ts, please extend the existing store-failure tests with the stdioHelpers.js mock's writeStderrLine throwing, then assert run() resolves and stdout still contains the interactive row ('app-ab'); with plain writeStderrLine that assertion goes red.

中文说明

存储失败守卫通过 writeStderrLine 通告自身,而它在 stderr 关闭或损坏时会抛出 —— 于是守卫捕获的那个失败本身仍可能拖垮整个列表。在 supervisor 存储不可读时运行 qwen sessions ps 且 stderr 不可用(2>&-,或管道指向已退出的读者)—— 正是兄弟测试模拟的 EACCES 场景:listAgentViewSessionSnapshots() 拒绝,catch 执行,process.stderr.write 抛出(stdioHelpers.ts 记载 writeStderrLine 在 EPIPE 或 fd 关闭时抛出)。抛出逃出 readManagedRowsPromise.all 拒绝,handlePs 中止,listLiveSessions 已经返回的注册表行永远不会被输出 —— 命令整体失败,而不是按守卫自身注释承诺的那样降级到注册表一半(「不能拖垮命令 —— 注册表一半仍能回答问题」)。

证据:探针(存储 EACCES + stderr 抛出)——handler 拒绝:expected Error: write EPIPE { code: EPIPE } to be undefined,注册表行从未输出;改用 writeStderrLineSafe 输出该提示后,探针通过且交互行正常输出。

修法:用同一 stdioHelpers.js 模块的 writeStderrLineSafe 输出该提示 —— 该提示是附带信息,且 stderr 已不存在时按定义无处可报;stdioHelpers.ts:51-58(「仅当写入对工作本身是附带、且写失败会毁掉真实成果时使用」)即判断标准,本处满足,优于临时 try/catch。请在 ps.test.ts 扩展现有存储失败测试:令 stdioHelpers.js mock 的 writeStderrLine 抛出,断言 run() 正常 resolve 且 stdout 仍含交互行('app-ab');使用普通 writeStderrLine 时该断言变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +199 to +200
if (rows.length === 0) {
writeStdoutLine('No other Qwen Code sessions are running.');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R3-12: The reworded empty-state message asserts that no Qwen Code sessions at all are running — beyond what the command checked — in two distinct states. (a) The supervisor store is unreadable: readManagedRows swallows the error and returns [], so the definitive message prints while stderr carries the failure note; a stdout-only consumer (qwen sessions ps | grep, a script) gets an affirmative absence claim although a managed session sitting on an unanswered question may exist — the exact "cannot tell no-agents from cannot-look" confusion the route half of this PR says it rejects. (b) Headless (qwen -p) runs are invisible to ps by design — they write no live-process registry record (only the two interactive-UI starters call registerSession: startInteractiveUI.tsx:421, start-opentui-ui.tsx:417-418), a blind spot documented in this file's own header — yet the pre-change wording's accurate "interactive" qualifier was dropped, so a user with a concurrent headless job is told nothing is running and may relaunch it. Both existing store-failure tests seed a registry record, so neither path is tested.

Witness:

probe (store EACCES + empty registry): stdout
['No other Qwen Code sessions are running.'] while stderr carries
'Managed sessions could not be listed: EACCES…'; threading the store
failure into the empty state flips the probe and keeps all 26 ps tests green
headless trigger verified by trace: the only registerSession call sites are
the two interactive-UI starters

Track the degradation (e.g. have readManagedRows return { rows, failed } or a flag) and print a qualified message such as 'No interactive or managed Qwen Code sessions could be listed.' when the managed read failed; keep the stderr note — it must stay on stderr only, per the documented contract "stderr keeps --json stdout parseable" (ps.ts:147-152), pinned by the test 'keeps --json stdout parseable when the store fails'. Please add a test with listLiveSessions.mockResolvedValue([]) and listAgentViewSessionSnapshots.mockRejectedValue(new Error('broken')), asserting stdout does not contain 'are running'; removing the qualification must make it red.

中文说明

改写后的空状态消息断言「没有任何 Qwen Code 会话在运行」—— 超出该命令实际检查的范围 —— 且有两种不同的情形。(a) supervisor 存储不可读:readManagedRows 吞掉错误并返回 [],于是这句确定性消息照常打印,而失败提示在 stderr;只读 stdout 的消费者(qwen sessions ps | grep、脚本)会得到肯定的「不存在」结论,尽管可能正有一个 managed 会话在等待回答 —— 正是本 PR 路由那一半声称拒绝的「分不清没有 agent 与看不了」。(b) 无头(qwen -p)运行按设计对 ps 不可见 —— 它们不写活进程注册表记录(只有两个交互式 UI 启动器调用 registerSessionstartInteractiveUI.tsx:421start-opentui-ui.tsx:417-418),这一盲点就记录在本文件自己的头部 —— 但改动前措辞里准确的「interactive」限定词被丢掉了,于是有并发无头任务的用户会被告知什么都没在运行,并可能重新启动它。既有的两个存储失败测试都预置了注册表记录,因此两条路径都没有测试。

证据:探针(存储 EACCES + 空注册表)——stdout 为 ['No other Qwen Code sessions are running.'],stderr 为 'Managed sessions could not be listed: EACCES…';把存储失败状态接入空状态判断后探针翻转,26 个 ps 测试保持绿色。无头触发由追踪核实:registerSession 的调用点只有两个交互式 UI 启动器。

修法:追踪降级状态(例如让 readManagedRows 返回 { rows, failed } 或一个标志),当 managed 读取失败时打印限定措辞,如 'No interactive or managed Qwen Code sessions could be listed.';stderr 提示保留 —— 且必须只留在 stderr,按文档契约「stderr 保证 --json 的 stdout 可解析」(ps.ts:147-152),由测试 'keeps --json stdout parseable when the store fails' 钉住。请补一个测试:listLiveSessions.mockResolvedValue([])listAgentViewSessionSnapshots.mockRejectedValue(new Error('broken')),断言 stdout 不含 'are running';移除限定措辞后该断言应变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +2244 to +2246
registerBackgroundAgentRoutes(app, {
isWorkspaceTrusted: isPrimaryWorkspaceTrusted,
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-1: Still standing from round 2 — no server-level test exercises the trusted positive path through createServeApp for GET /background-agents, so the default listSnapshots fallback and the trusted side of the wired predicate are never executed. Every unit test injects listSnapshots, and the sole server-level test is refused at the trust gate before the store is read — so one-line mutations survive the whole suite green: wiring isWorkspaceTrusted: () => false gives trusted workspaces a permanent 403 on this route, and replacing the ?? listAgentViewSessionSnapshots default with a throwing function gives them a permanent 503.

Witness:

probe (scratch tree, QWEN_HOME isolated):
intact default-reader request -> 200 with agents array (1 passed)
mutant ?? (async () => { throw … }) -> probe 'expected 503 to be 200'
while existing suite 7 passed
mutant isWorkspaceTrusted: () => false -> server test still '1 passed | 1176 skipped'

Add a trusted-path test beside the refusal test in server.test.ts: build createServeApp(tokenOpts, undefined, { bridge: fakeBridge(), primaryWorkspaceTrusted: true }), then the authenticated GET /background-agents asserting status 200 and Array.isArray(res.body.agents). The default reader touches the test machine's real supervisor store — ENOENT on the jobs dir returns [] (supervisor-store.ts:341) but a machine with real sessions returns them — so assert status and shape only, not listing content. The new test is its own witness: it goes red if the trusted wiring breaks (constant-false predicate → 403, throwing default store → 503, registration removed → 404) — please remove the predicate from this registration, run it, and confirm it reds.

中文说明

第 2 轮遗留、仍然成立 —— 没有服务端级测试经过 createServeAppGET /background-agents 的受信任正向路径,因此默认的 listSnapshots 回退与已接线谓词的受信任一侧从未被执行。所有单测都自行注入 listSnapshots,唯一的服务端级测试在信任门禁处就被拒绝、到不了存储读取 —— 于是单行变异可以在整套测试保持绿色的情况下存活:把接线改成 isWorkspaceTrusted: () => false 会让受信任工作区在该路由上永久 403;把 ?? listAgentViewSessionSnapshots 默认值换成抛错函数会让它永久 503。

证据:探针(scratch tree,QWEN_HOME 隔离)——完整代码下走默认读取器的请求 → 200 且返回 agents 数组(1 passed);变异体 ?? (async () => { throw … }) → 探针 expected 503 to be 200,既有套件仍 7 passed;变异体 isWorkspaceTrusted: () => false → 服务端测试仍 1 passed | 1176 skipped

请在 server.test.ts 的拒绝用例旁补一个受信任路径测试:以 createServeApp(tokenOpts, undefined, { bridge: fakeBridge(), primaryWorkspaceTrusted: true }) 构建,随后带鉴权请求 GET /background-agents,断言状态 200 且 Array.isArray(res.body.agents)。默认读取器会触碰测试机真实的 supervisor 存储 —— jobs 目录 ENOENT 时返回 []supervisor-store.ts:341),但本机若真有 session 则会返回它们 —— 因此只断言状态与形状,不断言列表内容。新测试自身即验收见证:受信任接线被破坏时它会变红(谓词恒 false → 403、默认存储抛错 → 503、注册被移除 → 404)—— 请移除本注册处的谓词后运行确认其变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

function appWith(listSnapshots: () => Promise<AgentViewSessionSnapshot[]>) {
const app = express();
registerBackgroundAgentRoutes(app, {
listSnapshots: listSnapshots as never,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-2: Still standing from round 2 — the injected listSnapshots mock is cast as never here and again in the untrusted-workspace test below, erasing the compile-time check against the dep's declared contract typeof listAgentViewSessionSnapshots — and the cast is unnecessary. If listAgentViewSessionSnapshots's signature drifts (a required StoreOptions argument, or a changed snapshot return type), these unit tests still compile and pass because the cast hides the mismatch, while the production wiring — server.ts registers the route with no listSnapshots override, so the route invokes the real default store — breaks, and the test seam can no longer be caught disagreeing with the declared contract.

Witness:

probe (scratch tree):
both casts removed -> tsc --noEmit exit 0; vitest 7 passed (casts unnecessary)
simulated drift, no cast -> TS2322 at the injection line (exit 2)
identical drift with `as never` -> tsc exit 0
Suggested change
listSnapshots: listSnapshots as never,
listSnapshots,

Delete the second cast in the untrusted-workspace test the same way (listSnapshots: async () => [snapshot()] type-checks directly against the dep type).

中文说明

第 2 轮遗留、仍然成立 —— 注入的 listSnapshots mock 在此处被转换为 as never,下方不受信任工作区的测试里也有同样一处,抹掉了针对 dep 声明契约 typeof listAgentViewSessionSnapshots 的编译期检查 —— 而该转换并无必要。若 listAgentViewSessionSnapshots 的签名漂移(新增必填的 StoreOptions 参数,或快照返回类型变化),这些单测仍能编译并通过,因为转换掩盖了不匹配;而生产接线 —— server.ts 注册该路由时未覆盖 listSnapshots,路由会调用真实的默认存储 —— 将会损坏,且测试接缝与声明契约的不一致将再也无法被发现。

证据:探针(scratch tree)——删除两处转换 → tsc --noEmit exit 0、vitest 7 passed(转换本不必要);模拟签名漂移且不加转换 → 注入行报 TS2322(exit 2);同样的漂移加 as never → tsc exit 0。

修法见上方英文 suggestion 块;请同样删除不受信任工作区测试中的第二处转换(listSnapshots: async () => [snapshot()] 可直接通过 dep 类型检查)。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants