feat(cli): list managed Agent View sessions in qwen sessions ps - #10942
feat(cli): list managed Agent View sessions in qwen sessions ps#10942yiliang114 wants to merge 18 commits into
Conversation
`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.
|
Re-run at Template looks good ✓ Problem: the premise still holds, and I re-checked it at current What is still unverifiable is the user-facing half, for the same reason as last time: nothing dispatches a managed session until #7802 or #7803 lands, so there is no live capture to take. The Direction: my reservation from the last pass is unchanged, and I am recording it rather than repeating it as a blocker. This adds a reader to a subsystem that still has no writer, while changing the Size: not applicable — no core paths. Nothing here matches Approach: the shape is right and I would not change it — merge and labelling in a pure module, readers left in the command, and reusing
Risk: no elevated risk signals — none of the changed files match the revert-correlated path set. The real risk is in the merge logic, and it is a correctness defect rather than a path signal; see the code review. Moving on to code review. 🔍 中文说明在 模板完整 ✓ 问题: 前提依然成立,而且我是在当前 仍然无法核实的是用户可见的那一半,原因与上次相同:在 #7802 或 #7803 落地之前没有任何东西会调度出 managed session,因此没有实时截取可拿。描述中的 方向: 上一轮的保留意见没有变,我把它记录下来,而不是当作阻塞项重复一遍。本 PR 给一个仍然没有写入方的子系统加了读取方,同时改变了一个已在 #8969 发布的命令的 规模: 不适用 —— 未触及核心路径。没有任何文件命中 方案: 整体结构是对的,我不会去改它 —— 合并与标注放在纯函数模块、读取留在命令里、复用
风险: 无升级风险信号 —— 改动文件均未命中与 revert 相关的路径集合。真实风险在合并逻辑本身,属于正确性缺陷而非路径信号;见代码审查。 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewMy independent proposal first, written from the title and the "Why it's needed" section before reading the diff: read the agent-view snapshots inside The PR matches that shape and exceeds it on testability — the pure module is a better call than what I sketched, and the 30-odd new cases are real cases. I would not ask for that to change. It also now exceeds my proposal on pid honesty: I would have printed The previous pass's Critical is fixed and pinned. What follows are two defects in the code that landed after that fix. I verified both against 1. Critical —
|
| File | What changed |
|---|---|
packages/cli/src/commands/sessions/managed-rows.ts |
New pure module: row shape, the ownership filter, pid-identity resolution, and the merge. Both Criticals are here. |
packages/cli/src/commands/sessions/managed-rows.test.ts |
New, 591 lines. Thorough on pid identity; pins the degraded-merge outcome as correct. |
packages/cli/src/commands/sessions/ps.ts |
Second reader, STATE column, taskState-to-label map, stderr degradation. |
packages/cli/src/commands/sessions/ps.test.ts |
8 new cases for ordering, the JSON discriminator, placeholders and store failure. |
packages/cli/src/agent-view/protocol.ts |
Three new optional worker-file identity fields; sanitizeSessionId moved here. |
packages/cli/src/agent-view/supervisor-process.ts |
New workerPidIdentity helper, applied at four worker-file write sites. |
packages/cli/src/agent-view/supervisor-store.ts |
Normalizes the three new fields; re-exports the moved sanitizer. |
packages/cli/src/agent-view/presentation.ts |
Untitled session literal becomes an exported constant. Nothing else. |
docs/users/features/commands.md |
Both JSON row shapes, the STATE column, the degradation contract, a new jq example. |
.qwen/skills/find-simplifications/references/survey.md |
Unrelated: teaches the survey to report stalled wiring instead of dropping it. |
.qwen/skills/find-simplifications/SKILL.md |
Unrelated: one table row for the same rule. |
What I checked that is fine
Recorded because several of these are the kind of thing that looks wrong at a glance, and because the PR makes claims in comments that are worth holding to:
- The pid-identity mirroring claim is literally true.
liveWorkerPid's boot-id guard —recordBootId !== null && recordBootId !== ownBootId→ skip — is the same shape aslistLiveSessions' atsession-registry.ts:553-557, including firing when the local boot id is unreadable, which is the subtle part the registry's own comment explains. The namespace guard deliberately diverges (fires only when both sides are known) and says so; that is the right call here, because this declines to print a pid where the registry declines to list at all. - The imports resolve.
isSameProcess,readLocalBootId,readPidNamespaceIdandreadProcStartTokenall come frompackages/core/src/utils/process-liveness.ts, re-exported bypackages/core/src/index.ts:703.isSameProcess's real degradation contract matches both the module comment and the test mock. - The test mock is honest. It re-implements
isSameProcess's fall-through rules instead of stubbing a verdict, so a token-less case exercises the same path production does. That is better than most mocks of this shape. - "Both readers already sort newest first" is accurate.
listAgentViewSessionSnapshotsends insnapshots.sort((l, r) => r.state.updatedAt.localeCompare(l.state.updatedAt))(:387-389). - The
sanitizeSessionIdmove is safe. All three consumer sites still resolve: the store's own 11 internal uses,supervisor-process.ts:60via the re-export, and the new module importing fromprotocol.js. No cycle —protocol.tsonly gained anode:pathimport. deriveAgentViewPresentationis called correctly. The parameter is a union and only the input half carriesnow; passing field by field rather than spreading is right, and the comment explaining why is accurate.- Docs match the code. Both
--jsonrow shapes are documented, including that a session in both sources emits the row shape rather than the record. Thejq -r 'select(.taskState == "waiting")'example is correct — interactive rows have notaskState, soselectdrops them. - The stderr path is sanitized, and
stateLabelcan only ever return one of six fixed literals, so the new column cannot carry foreign text into the terminal.
Test evidence
Unattended CI run — I did not build or execute anything from this PR. Everything below is the PR's own CI, read from the check-runs API for 79ca2fc. CI has settled on this commit: all three pull_request workflow runs are completed, so nothing is pending and no result is a guess.
Final CI results for 79ca2fc (auto-updated by the triage finalize job after CI completed):
| Check | Conclusion |
|---|---|
web-shell E2E Smoke (ubuntu-latest, Node 22.x) |
❌ failure |
Classify PR |
✅ success |
Dependency CVE audit |
✅ success |
Desktop Shell (ubuntu-22.04) |
✅ success |
Desktop Shell (windows-2022) |
✅ success |
Integration Tests (no-AK, No Sandbox) |
✅ success |
Lint & Static (ubuntu-latest, Node 22.x) |
✅ success |
OpenTUI no-flicker gate |
✅ success |
Secret scan (TruffleHog) |
✅ success |
Test (ubuntu-latest, Node 22.x) |
✅ success |
TUI parity snapshots (ink vs opentui) |
✅ success |
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。
Skipped, so they are evidence of nothing either way: Test (macos-latest), Test (windows-latest), Integration Tests (CLI, No Sandbox).
The one red check is a stale base, not this diff. The failing step is Run transcript document browser gate, and its whole output is:
No test files found, exiting with code 1
filter: chat-transcript-document.test.ts
include: **/*.test.ts
That is a collection failure, not an assertion failure — vitest could not find the file. Three facts pin the cause:
- The job checks out the PR head only: the log records
ref: refs/pull/10942/headandexpected_sha: 79ca2fc..., not a merge commit against currentmain. - Both the gate step and its test file landed on
maintogether in74fe3a6(test(web-shell): render the Session Workflow cockpit in the visuals preview #11014, 2026-09-04 18:33Z) —git log --diff-filter=Aonintegration-tests/chat-transcript-document.test.tsreturns exactly that commit, andgit log -S'Run transcript document browser gate'on.github/workflows/ci.ymlreturns the same one. integration-tests/chat-transcript-document.test.tsdoes not exist at79ca2fc— the contents API 404s for that ref while returning the file forref=main.
So the workflow definition this run used (from the base) contains a step whose test file only exists on a base the branch has not merged. The PR touches nothing under integration-tests/, packages/web-shell/ or .github/workflows/. This also explains why the same check was ✅ on the earlier commit 8879852: #11014 had not landed yet. Merging or rebasing onto current main clears it — no code change needed, and it needs to happen before branch protection will let this through.
The typecheck gap the description discloses is closed by CI. The Tested-on section says npx tsc --noEmit and npm run build were not run locally, and that types and build "need CI or a second machine". They got it: the Install dependencies step runs npm ci, which fires prepare → node scripts/prepare.js → npm run build (prepare.js:29) → per-package node ../../scripts/build_package.js → execSync('tsc --build') (build_package.js:38), and QWEN_SKIP_PREPARE is not set anywhere in ci.yml. Both green Test (ubuntu-latest) and Lint & Static therefore compiled packages/cli with tsc before running. That caveat in the PR body can be retired. Note that Lint & Static itself is ESLint + Prettier + actionlint + shellcheck — no tsc --noEmit; the typecheck signal comes from the install-time build, not from that job's name.
Not verified, and why: the 364 tests passing figure is the author's claim from a vitest-only run on Linux — I did not re-run it, and per the round-5 review the full suite observed 28373 passed against it. No live terminal capture of the managed half exists, because nothing dispatches a managed session yet. And neither Critical above is reachable by the suite as written: finding 1's degraded shapes are either pinned as correct or merged without a pid assertion, and finding 2's trigger is never constructed.
Sandboxed verification would settle what CI cannot: @qwen-code /verify — the central claim is behavioural (a managed session appears in the rendered table, ahead of interactive rows, with its real state) and the After block is explicitly not a live capture. More specifically, an A/B against the base build is what would demonstrate the two findings: hand-write an ownership: "managed" state file sharing a session id with a live registry record, truncate its worker.json, and confirm the merged row loses pid 4242; then put "resumeSessionId": 123 in a launch.json and confirm qwen sessions ps exits non-zero with no interactive rows either. @qwen-code /tmux would cover the table surface itself. You have admin on this repo, so neither needs a sponsor.
中文说明
代码审查(要点)
先说我的独立方案:在 ps.ts 内部读 agent-view 快照,按 supervisor 自己的做法过滤 ownership === 'managed',映射成行,排在注册表行之前,按 session id 去重并优先保留知道真实 pid 的一侧,保留现有列,只在新行上加判别字段。约四十行,不新增模块。
本 PR 与之一致,并在可测试性上超过它(纯函数模块、30 多个真实用例),在 pid 诚实性上也超过它 —— 我原本只会用一个裸存活探测,它走了身份令牌的路,这是对的。我核实了这个主张,成立(见下方「核对过没问题」)。
上一轮的 Critical 已修复且已钉住:ownership === 'managed' 过滤到位;测试按我的要求补了一个遍历三种非 managed 值断言「不产生行」的用例,以及 lets a live registry record survive the adopting window 断言注册表行保住 pid 4242。--json 那条 Suggestion 也修了 —— taskState 承载稳定标识,展示措辞只留在渲染处的 TASK_STATE_LABEL。
以下两个缺陷出现在那次修复之后落地的代码里。两者我都对着 79ca2fc 逐行核实,没有只采信评审线程。
1. Critical —— managed 行是降级的一方时,mergeSessionRows 仍会丢弃注册表记录
去重是无条件的,managed 行永远赢。当 managed 行知道得更少时这个规则就错了,而存储自身的软失败行为让这种情况可达:readJsonRecord(supervisor-store.ts:570-583)在任何读取或解析错误下返回 undefined(注释明确写了「Fail soft on any read or parse error」)。于是一次瞬时 EMFILE/EIO 或一个被截断的文件,就会让一个仍然存活且被拥有的快照丢掉 launch 或 worker。
(a) session 会被列在一个无法用于 resume 的 id 下。 state.sessionId 是净化后的目录名(readAgentViewSessionState 返回 normalizeSessionState(raw, path.basename(paths.sessionDir)),目录名来自 sanitizeSessionId);可 resume 的原始拼写在 launch.resumeSessionId 里,收养之所以保留它,正因为原生 session 存储大小写敏感。launch 文件暂时不可读时,回退到小写 id,而合并又丢掉了仍携带真实拼写的记录 —— 一个可能正在等待输入的 session,被列在任何消费者都无法操作的 id 下。
测试不只是漏掉了它,而是把它断言成了正确行为:lists a mixed-case session once... 构造无 launch 文件的快照 + {sessionId: 'Managed-1'} 的记录,期望 rows[0].sessionId 为 'managed-1'。
(b) 注册表刚刚担保过的 pid 消失了。 worker 文件不可读时 liveWorkerPid(undefined) 返回 undefined,合并丢掉那条 pid 已被 listLiveSessions 用同一身份契约验证过的记录 —— 表格打印 -、--json 省略 pid。lists a session once when it is both managed and registered 正好合并了这个形态(无 worker 快照 + pid 4242 的记录),却只断言了长度、managed、taskState,从未断言 pid。
修复方向是「携带」而非「优先」:记录与 managed 行去重时,把降级行缺失的信息带到存活行上 —— 记录的原始 sessionId(当它净化后与行报告的 id 相撞时),以及行没有 pid 时记录已验证的 pid。两种拼写仍须折叠为恰好一行,launch 拼写在存在时仍须优先,收养窗口用例仍须为绿;混合大小写用例中那个有损断言需要翻转。
该问题已作为未解决的行内线程(R5-3)存在于本 commit,也是 PR 当前显示 CHANGES_REQUESTED 的原因。我是在确认它仍然成立,不是重新提一遍。
2. Critical —— resumeSessionId 是唯一没人校验的 launch 字段,而它会让整个列表崩掉
第 1 条的回退让 launch.resumeSessionId 第一次成为承重字段,而它恰好是 normalizeLaunch 唯一没检查的字段。normalizeLaunch(:793-818)对 sessionId、entrypoint、projectCwd、activeCwd、initialPrompt 都过了 stringValue()(非空字符串以外一律返回 undefined),然后 ...raw 展开 —— resumeSessionId 从未经过它。于是 "resumeSessionId": 123 会以「类型为 string、实际为 number」的形态活下来,而 protocol.ts:77 声明的就是 resumeSessionId?: string。
随后 mergeSessionRows 调用 sanitizeSessionId(row.sessionId),其第一步是 sessionId.replace(...) —— 对非字符串抛 TypeError。
这个抛出落在命令所有防护之外:readManagedRows 的 try/catch 包住的是 listAgentViewSessionSnapshots 与 managedSessionRows,而 mergeSessionRows 是在 handlePs 里 Promise.all 之后调用的,handler 自身也没有 catch。因此一个 managed session 的 launch 文件里一个类型不对的字段,会让 qwen sessions ps 整体失败 —— 包括 interactive 那一半,而这正是模块文档字符串声明绝不能发生的失败。
需要说清它为什么不是纯理论加固:normalizeLaunch 自己就把敌意 launch 文件当作范围内威胁(注释写着「a tampered launch.json cannot impersonate another session」,并为此校验了它消费的每个字段)。本 PR 新消费了一个该校验从未覆盖的字段。测试中没有任何用例构造非字符串 resumeSessionId,所有出现都是字面量 'Managed-1'。
一行修复:在 normalizeLaunch 中,与其他字段并列加上 resumeSessionId: stringValue(raw['resumeSessionId'])。
/review 第 5 轮把它记为 R4-1,随后以「fails-closed on new surface,未认证错误结果,merge base 既无该功能面也无该缺陷」为由延后。我不认同这个定性:merge base 上的 sessions ps 根本不读 supervisor 存储,任何 launch 文件都影响不到它。一个连 interactive 列表都一并抹掉的崩溃,是对今天行为的回归,而不是一个此前不存在的功能面上的 fail-closed 缺口。它很小也很便宜,我建议在同一个 PR 里修掉,而不是作为后续工作带着走。
核对过、确认没问题的部分
- pid 身份的「镜像」主张字面成立:
liveWorkerPid的 boot-id 守卫与session-registry.ts:553-557完全同形,包括在本地 boot id 不可读时也要生效这一微妙之处。namespace 守卫有意不同(仅在双方都已知时生效)并做了说明 —— 这里是对的,因为本模块是拒绝打印 pid,而注册表是拒绝列出。 - import 能解析:四个函数均来自
packages/core/src/utils/process-liveness.ts,由packages/core/src/index.ts:703重新导出;isSameProcess的真实降级契约与模块注释、测试 mock 一致。 - 测试 mock 是诚实的:它重实现了
isSameProcess的回退规则,而不是直接钉一个结论。 - 「两个读取方都已按最新在前排序」准确:
listAgentViewSessionSnapshots末尾按updatedAt降序排序(:387-389)。 sanitizeSessionId的搬迁安全:三处消费者全部仍可解析(存储内部 11 处、supervisor-process.ts:60经由再导出、新模块从protocol.js导入);无循环依赖。deriveAgentViewPresentation调用正确:参数是联合类型且只有 input 一侧带now,逐字段传递而非展开是对的。- 文档与代码一致:两种
--json行形态都写到了,包括「同时存在于两个来源的 session 输出行形态而非记录形态」;jq -r 'select(.taskState == "waiting")'示例正确(interactive 行没有taskState,会被select过滤掉)。 - stderr 路径已净化,且
stateLabel只可能返回六个固定字面量之一,新列不会把外部文本带进终端。
测试证据
无人值守 CI 运行 —— 我没有构建或执行本 PR 的任何代码。以下全部来自本 PR 自己的 CI,通过 check-runs API 读取 79ca2fc 的结果。CI 已跑完:三个 pull_request workflow run 均为 completed,因此没有 pending,也没有任何猜测。
(表格见上方英文部分,由 finalize 任务在 CI 结束后就地更新。)
skipped 因而不构成任何一方证据的:Test (macos-latest)、Test (windows-latest)、Integration Tests (CLI, No Sandbox)。
唯一的红检查是 base 过旧,不是本 diff。 失败步骤是 Run transcript document browser gate,其全部输出是一次收集失败而非断言失败:vitest 找不到 chat-transcript-document.test.ts。三条事实钉住了原因:(1) 该任务只检出 PR head(日志记录 ref: refs/pull/10942/head、expected_sha: 79ca2fc...),不是对当前 main 的合并提交;(2) 该闸门步骤与它的测试文件是一起在 74fe3a6(#11014,2026-09-04 18:33Z)落到 main 的 —— 对该测试文件的 --diff-filter=A 与对 ci.yml 的 -S 查询返回同一个 commit;(3) 该测试文件在 79ca2fc 上不存在(contents API 对该 ref 返回 404,对 ref=main 正常返回)。
也就是说:本次运行使用的工作流定义(来自 base)含有一个步骤,而该步骤所需的测试文件只存在于分支尚未合入的 base 上。本 PR 未触及 integration-tests/、packages/web-shell/、.github/workflows/ 下任何文件。这也解释了为什么同一个检查在更早的 8879852 上是 ✅ —— 那时 #11014 还没落地。合并或 rebase 到当前 main 即可清除,无需改代码;但在分支保护放行之前必须做。
描述中披露的类型检查缺口已被 CI 关闭。 Tested-on 一节说本地未运行 npx tsc --noEmit 与 npm run build,需要 CI 或另一台机器。它得到了:Install dependencies 步骤运行 npm ci,触发 prepare → node scripts/prepare.js → npm run build(prepare.js:29)→ 各 package 的 build_package.js → execSync('tsc --build')(build_package.js:38),而 ci.yml 中任何位置都没有设置 QWEN_SKIP_PREPARE。因此绿色的 Test (ubuntu-latest) 与 Lint & Static 在运行前都已用 tsc 编译过 packages/cli。PR 正文里那条注意事项可以撤掉了。需要注意:Lint & Static 本身跑的是 ESLint + Prettier + actionlint + shellcheck,并不含 tsc --noEmit;类型检查信号来自安装期的构建,而不是这个任务的名字。
未核实及原因:364 tests passing 是作者在 Linux 上仅跑 vitest 的声明,我没有重跑(第 5 轮评审观测到的完整套件为 28373 passed)。managed 那一半没有任何实时终端截取,因为目前没有任何东西会调度出 managed session。而上述两条 Critical 在现有测试下都不可达:第 1 条的降级形态要么被钉成正确、要么在合并时对 pid 无任何断言;第 2 条的触发条件从未被构造。
沙箱化验证可以了结 CI 无法了结的部分:@qwen-code /verify —— 核心主张是行为性的(managed session 出现在渲染出的表格中、排在 interactive 行之前、带真实状态),而 之后 代码块明确不是实时截取。更具体地说,针对 base 构建的 A/B 正是能证实上述两条发现的手段:手工写一个与存活注册表记录共用 session id 的 ownership: "managed" 状态文件,把它的 worker.json 截断,确认合并后的行丢掉 pid 4242;再在某个 launch.json 里写入 "resumeSessionId": 123,确认 qwen sessions ps 以非零退出且 interactive 行也一并消失。@qwen-code /tmux 可覆盖表格表面本身。你在本仓库有 admin 权限,两者都不需要他人代为触发。
— Qwen Code · qwen3.8-max-2026-09-02
Reviewed at 79ca2fc451cd6c0148d51eb49e363eff656865d9 · re-run with @qwen-code /triage
|
Confidence: 2/5 — the craft here is good and the last round's Critical is genuinely fixed and pinned; but the code that landed in response to review introduced two new correctness defects in the merge, one of which a test currently asserts is correct behaviour, and I cannot approve over that. Stepping back. Five commits ago I asked for one line — filter Which is why the two findings are worth being blunt about, because they are both children of the fixes rather than leftovers. Making the row honest about pids meant reading the worker file, and making the id resumable meant reading the launch file — and the store fails soft on both, returning The second one is smaller and I keep coming back to it because it contradicts the module's own stated purpose. The docstring says a store that cannot be read must not take the command down, and On the direction question I raised in Stage 1 — reader landing before writer, Two things that are not findings but would make the next pass faster. The PR body still does not mention the supervisor writer-side change — three new fields on a durable Verdict: should not merge as-is. I am not submitting a second Everything else in this diff I would keep exactly as written. 中文说明Confidence: 2/5 —— 工程质量是好的,上一轮的 Critical 也确实修好并被测试钉住了;但作为回应评审而落地的代码,在合并逻辑里引入了两个新的正确性缺陷,其中一个当前正被测试断言为正确行为。在这样的状态下我无法批准。 退一步看整体。五个 commit 之前我要求的只有一行 —— 过滤 也正因为如此,这两条发现值得直说,因为它们都是修复的产物,而不是遗留物。让行对 pid 诚实,意味着要读 worker 文件;让 id 可 resume,意味着要读 launch 文件 —— 而存储对这两者都是软失败的,按明确设计在任何读取或解析错误下返回 第二个更小,但我反复回到它,因为它与该模块自己声明的目的相矛盾。文档字符串说:读不出来的存储绝不能拖垮这个命令,而 关于我在 Stage 1 提出的方向问题 —— 读取方先于写入方落地、一个已发布命令的 两件不是发现、但能让下一轮更快的事。PR 正文仍未提到 supervisor 写入侧的改动 —— 一个持久化 结论:当前状态不应合并。 我不会再提交第二份 这个 diff 里的其他部分,我都会照原样保留。 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
@yiliang114 Requesting changes on one Critical finding — the full write-up is in the code-review comment above.
managedSessionRows maps every snapshot returned by listAgentViewSessionSnapshots to a row with managed: true, but that API returns all four AgentViewOwnership values (unmanaged, adopting, managed, removing), and every existing consumer on main filters before use (supervisor-process.ts:293, :299, :1187, :1435, :1439, :1606). Two consequences:
- Tombstones and teardowns get listed as live sessions —
supervisor-dispatch.ts:164leavesunmanaged/failed/exitedstate files behind on a failed dispatch, andgcUnmanagedSession(supervisor-process.ts:358) exists specifically torm -rfthem. - A live interactive session can disappear from the listing.
supervisor-process.ts:590adopts an existing session and reuses its id underownership: 'adopting';mergeSessionRowsthen drops that session's registry record, losing its realpidand its whole--jsonpayload, and shows it asreadywith PID-. That is a regression of behaviourqwen sessions pshas today.
No test can catch either case: both fixtures hardcode ownership: 'managed'.
Suggested fix, matching the established idiom:
snapshots.filter((snapshot) => snapshot.state.ownership === 'managed')plus one case per non-managed ownership value asserting it yields no row, and one asserting a registry record sharing a sessionId with an adopting snapshot survives the merge.
Also worth folding in (non-blocking): emit presentation.taskState in --json and keep the display wording (needs input) in the table only, so rewording the STATE column later cannot silently break jq scripts; and split the .qwen/skills/find-simplifications/ edits into their own PR — they are unmentioned in the body and aimed at a different reader.
Everything else here I would keep as written: the pure merge module, reusing deriveAgentViewPresentation so the listing and the roster cannot drift, the stderr degradation, and the sanitized failure reason are all the right calls. The direction question about sequencing this against #7802 is a maintainer's call, not a blocker.
CI had not settled at review time — Test (ubuntu-latest) and Lint & Static were still in flight and the macOS/Windows legs report skipped, so this diff has not been typechecked anywhere yet.
中文说明
基于一条 Critical 发现请求修改 —— 完整分析见上方的代码审查评论。
managedSessionRows 把 listAgentViewSessionSnapshots 返回的每一个快照都映射成 managed: true 的行,但该 API 会返回全部四种 AgentViewOwnership 值(unmanaged、adopting、managed、removing),而 main 上每一个现存消费者在使用前都会过滤(supervisor-process.ts:293、:299、:1187、:1435、:1439、:1606)。两个后果:
- 墓碑与拆除中的记录会被当作存活 session 列出 ——
supervisor-dispatch.ts:164在 dispatch 失败时会留下unmanaged/failed/exited的状态文件,而gcUnmanagedSession(supervisor-process.ts:358)的存在正是为了把它们rm -rf掉。 - 一个存活的 interactive session 可能从列表中消失。
supervisor-process.ts:590收养已存在的 session 时会在ownership: 'adopting'下复用其 id;随后mergeSessionRows会丢弃该 session 的注册表记录,丢掉真实pid与整条--json数据,并把它显示为ready、PID 为-。这是对qwen sessions ps今天已有行为的回归。
两种情况测试都抓不到:两个 fixture 都硬编码了 ownership: 'managed'。
建议的修复,沿用既有写法:
snapshots.filter((snapshot) => snapshot.state.ownership === 'managed')外加:为每个非 managed 的 ownership 值补一个「不产生行」的用例,以及一个「注册表记录与某个 adopting 快照共用 sessionId 时,合并后仍然存活」的用例。
另有两点建议一并处理(非阻塞):在 --json 中输出 presentation.taskState,把展示措辞(needs input)只留在表格里,这样日后改写 STATE 列文案不会静默破坏 jq 脚本;以及把 .qwen/skills/find-simplifications/ 的改动拆成独立 PR —— 正文没有提到它们,且面向不同的读者。
其余部分我都会照原样保留:纯函数合并模块、复用 deriveAgentViewPresentation 以避免列表与 roster 说法漂移、stderr 降级、以及净化后的失败原因,这些都是正确的判断。关于本 PR 与 #7802 时序安排的方向性问题,属于维护者的判断,不是阻塞项。
审查时 CI 尚未跑完 —— Test (ubuntu-latest) 与 Lint & Static 仍在进行,macOS/Windows 两个 leg 报告 skipped,因此这份 diff 目前在任何地方都还没有被类型检查过。
— Qwen Code · qwen3.8-max-2026-09-02
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
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Test Plan (not a blocker): 364 tests passing — this review observed 28158 passed.
Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:
packages/cli/src/commands/sessions/managed-rows.test.ts:72 — [review] D2-1 fixture builders (record/state/snapshot/workerFile) duplicated across the two adjacent test files; the ps.test.ts copy is untyped and would silently emit stale shape…packages/cli/src/commands/sessions/managed-rows.test.ts:43 — [probe] D2-2 projectCwd/originalCwd/activeCwd collapse to one fixture value and no test asserts row.cwd — the activeCwd source choice is unpinned (both mutants survive)packages/cli/src/commands/sessions/ps.ts:119 — [probe] D2-3 readManagedRows' catch over the synchronous mapping is untested — a narrowed try would crash instead of degrade with all tests greenpackages/cli/src/commands/sessions/ps.test.ts:89 — [probe] D2-4 managedSnapshot({state: ...}) partial-state overrides are silently clobbered by the trailing ...over spread (observed TypeError in the probe run)
中文说明
Test Plan(非阻断):364 tests passing — this review observed 28158 passed。
收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 4 条(原文未翻译,列表见上方英文部分)。
— 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.
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`.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
5 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- docs AGE
-contract re-derivation — already reported as R1-9 (inline thread at docs/users/features/commands.md) - module header interactive-only opening-line re-derivation — already reported as R1-6 (inline thread at packages/cli/src/commands/sessions/ps.ts)
- supervisor-listing comment-accuracy re-derivation — already reported as R2-2 (comment 3930057660 at packages/cli/src/commands/sessions/managed-rows.ts)
- AGE dash render-level test gap re-derivation — already reported as R1-3 (inline thread at packages/cli/src/commands/sessions/ps.ts)
- cwd source-choice unpinned re-derivation — already reported as D2-2 in the round-2 review body deferral list (review 5108278256)
Test Plan (not a blocker): 364 tests passing — this review observed 28159 passed.
Deferred under the convergence posture (round 3, not a blocker) — recorded, not requested in this round:
packages/cli/src/commands/sessions/managed-rows.ts:89 — [probe] managedSessionRows' now parameter is dead plumbing — it feeds only the discarded ageLabel (deferred under the code-age rule: unchanged since the previous round reviewed it)docs/users/features/commands.md:790 — [probe] --json field list omits ipcPath and "whole registry record" ignores the ipcToken strip (deferred under the code-age rule: unchanged since the previous round reviewed it)
Convergence: round 3 posted 11 inline comment(s), 2 of them reported for the first time; the previous round posted 9 (2 new). Findings keep coming back to the same files: packages/cli/src/commands/sessions/managed-rows.ts (findings in rounds 1, 2; 1 more now); packages/cli/src/commands/sessions/ps.ts (findings in round 1; 1 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. 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.)
中文说明
本轮确认的 5 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
Test Plan(非阻断):364 tests passing — this review observed 28159 passed。
收敛姿态下延后(第 3 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 3 轮发布了 11 条行内评论,其中 2 条是首次提出;上一轮发布了 9 条(其中 2 条首次提出)。发现反复回到同一批文件:packages/cli/src/commands/sessions/managed-rows.ts(第 1、2 轮已出过发现,本轮又有 1 条);packages/cli/src/commands/sessions/ps.ts(第 1 轮已出过发现,本轮又有 1 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,或将本 PR 的评审降到 --severity-floor critical,可以避免循环反复推导同一组发现。(仅为观察——本轮评审未因此扣留任何内容。)
— qwen3.8-max via Qwen Code /review (v0.23.0)
Both blockers on this listing are the same mistake in two places: the merge joins two sources that disagree about what identifies a session, and each half trusted its own spelling. Pids. `liveWorkerPid` gated durable recorded pids with a bare `isPidAlive`, and the worker file recorded no process identity at all. Nothing reaps that file while no supervisor runs — `clearAgentViewWorkerPids` does not get to run after a SIGKILL or a reboot — so once the OS recycles the number, `kill(pid, 0)` answers "alive" about an unrelated process and `qwen sessions ps` prints it next to a row still reading `working`; a script reading `--json` kills a stranger. A `~/.qwen` shared between machines or namespaces (an NFS home, a devcontainer with the home mounted) needs no recycling to reach the same place. The interactive rows in the very same table are verified with `isSameProcess` plus namespace and boot-id guards, so the two halves answered to different evidence standards. The supervisor now records the start token for each pid it writes, plus its own PID namespace, and the reader checks `isSameProcess` against them. All three fields are optional: `AgentViewWorkerFile` is a durable `schemaVersion: 1` record, and a file written before they existed carries none, which `isSameProcess` reads as "no identity recorded" and degrades to exactly the liveness check this command did before. The namespace guard fires only on a known disagreement, so an unreadable `/proc` on either side never blanks a real worker's pid. Ids. `mergeSessionRows` deduped with a case-sensitive `Set.has`, but the supervisor store reports the sanitized, lowercased directory name it files a session under while the registry keeps the raw spelling the worker registered with — adoption keeps both on purpose, because the native session store is case-sensitive. A managed session whose id contains an uppercase letter therefore slipped the filter and listed twice, once with its real state and once as `interactive`, with `--json` emitting two different spellings for one session. Both sides now canonicalize through `sanitizeSessionId`. That sanitizer moved from `supervisor-store` to `protocol`, which is where it belongs — it defines the identity two readers must agree on, not a detail of writing files — and it keeps the pure row module free of a dependency on the filesystem store. `supervisor-store` re-exports it, so no caller changes. Tests pin the two arms liveness cannot see: a live pid whose recorded token no longer matches, and a foreign-namespace worker file. Two more pin the degradations, since silently blanking every pre-identity pid would be the worse regression. The mock mirrors the real `isSameProcess` contract rather than stubbing a verdict, so the existing dead-pid cases still exercise the fall-through they were written for. Typecheck, lint and tests were not run locally; CI is the authority on this branch.
853d57b taught `managedSessionRows` to verify pids with `isSameProcess` and `readPidNamespaceId`, and updated the core mock in `managed-rows.test.ts` — but `ps.test.ts` mocks the same module and reaches the same code through `ps.ts`, and its factory still exported only `listLiveSessions` and `isPidAlive`. So `readPidNamespaceId()` was `undefined` at the call site, the TypeError propagated out of the snapshot mapping, and `ps.ts` caught it on the path meant for an unreadable supervisor store: every managed row vanished and the command reported a store failure. Four tests failed in CI with the managed half of the listing simply missing — `expected [ false ] to deeply equal [ true, false ]` — rather than with the type error that caused it. The mock now mirrors the real `isSameProcess` contract, the same way `managed-rows.test.ts` does, so the fixtures — which record no start token — keep degrading to a bare liveness check and `isPidAlive.mockReturnValue(false)` still means exactly "this pid is dead". Worth a follow-up, filed separately rather than widened into this commit: `ps.ts` treating a TypeError from row mapping as "the store could not be read" is why this surfaced as four confusing assertion failures instead of one stack trace. Typecheck, lint and tests were not run locally; CI is the authority.
- managed-rows.ts: the managedSessionRows rationale block said the supervisor's own listing "skips the same shapes", but SupervisorProcess.list() only skips unmanaged and removing snapshots and shows sessions mid-adoption. State the difference, and why this listing must keep filtering adopting snapshots (R2-2). - ps.test.ts: render the human table for managed sessions in working, idle, stopped and failed states and assert the STATE cell against the documented labels, so no swap among the four previously unpinned TASK_STATE_LABEL values can ship green (R3-1). Also fix the managedSnapshot helper, whose trailing spread shadowed the constructed state when a caller passed a partial state override. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-closeout/jmtmourmvi4
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
Closeout summary for the remaining deferred review threads:
Verification on the pushed diff: 51 focused session tests passed; related ESLint, Prettier, and |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
3 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- docs --json field list vs ipcPath/ipcToken wording — already reported in the round-3 review body deferral list (review 5109073154)
- activeCwd source-choice unpinned — already reported as D2-2 in the round-2 review body deferral list (review 5108278256)
- R1-2 duplicated createdAt parse — still standing at this commit; author explicitly deferred it as a follow-up with rationale in the closeout summary (comment 5540135921), thread at packages/cli/src/commands/sessions/managed-rows.ts
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.
4 Suggestion(s) were drafted inline past the resolved critical posting floor — the floor engaged early: the first-time-finding rate has not fallen for 2 consecutive round(s); the CLI moved them into the deferral list below (floor enforcement).
Deferred under the convergence posture (round 4, not a blocker) — the floor engaged early: the first-time-finding rate has not fallen for 2 consecutive round(s) — recorded, not requested in this round:
packages/cli/src/commands/sessions/managed-rows.test.ts:250 — [review] R4-2: This pre-identity degradation test runs only with an unreadable own pid namespace — the beforeEach default mocks pidNamespaceId -> null — so deleting the work…packages/cli/src/agent-view/supervisor-process.ts:511 — [review] R4-3: No test asserts that a written worker file actually records the new identity fields ( hostProcStart / workerProcStart / pidNs ) — the write side of the pid-identity cont…packages/cli/src/commands/sessions/managed-rows.ts:187 — [review] R4-4: The host fallback's identity check — worker.hostProcStart passed to isSameProcess in the second candidate — is pinned by no test: no test file in the package ever s…packages/cli/src/agent-view/supervisor-store.ts:891 — [review] R4-5: The read half of the new pid-identity contract — normalizeWorker materializing hostProcStart / workerProcStart / pidNs from the durable worker file — is pinned by no t…packages/cli/src/commands/sessions/ps.test.ts:365 — [probe] managed --json row shape pinned on 4 of its 7 fields; cwd unpinned along the JSON pathpackages/cli/src/commands/sessions/ps.test.ts:345 — [probe] handler-level mergeSessionRows dedupe wiring has no end-to-end pinpackages/cli/src/commands/sessions/ps.ts:159 — [probe] store-failure partial listing exits 0 — machine consumers cannot tell complete from partialdocs/users/features/commands.md:836 — [probe] new jq example breaks the ps/list_agents name equivalence for managed sessions
Convergence: round 4 posted 2 inline comment(s), 2 of them reported for the first time; the previous round posted 11 (2 new). Findings keep coming back to the same files: packages/cli/src/commands/sessions/managed-rows.ts (findings in rounds 1, 2, 3; 2 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 3 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
未审查:反向审计——在 5 轮的反审轮数上限内未收敛。
4 条 Suggestion 在已解析的 critical 发布下限之外被起草为行内评论——发布下限因首次发现速率连续 2 轮未下降而提前生效;CLI 已将其移入下方延后清单(下限强制执行)。
收敛姿态下延后(第 4 轮,非阻断)——发布下限因首次发现速率连续 2 轮未下降而提前生效——已记录,本轮不要求修改:共 8 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 4 轮发布了 2 条行内评论,其中 2 条是首次提出;上一轮发布了 11 条(其中 2 条首次提出)。发现反复回到同一批文件:packages/cli/src/commands/sessions/managed-rows.ts(第 1、2、3 轮已出过发现,本轮又有 2 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)
— qwen3.8-max via Qwen Code /review (v0.23.0)
liveWorkerPid verified recorded pids with the start token and the PID namespace, but omitted the registry's boot-id pre-guard. Two machines sharing one ~/.qwen both live in the initial PID namespace, whose inode is a kernel constant, so the namespace guard never fires between them; when the local boot id is unreadable — or the reader is non-Linux — isSameProcess degrades to a bare liveness check and a foreign worker file's pid, routinely a live unrelated local process, was printed. Read readLocalBootId() once and skip any candidate whose recorded token carries a boot prefix that differs from the local boot id, mirroring listLiveSessions: the guard fires on an unreadable local boot id as well, not only on a positive mismatch, and token-less pre-identity candidates keep the liveness fall-through. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-closeout/jmtnaadh9j8
The managed row emitted the sanitized (lowercased) store id as sessionId, and mergeSessionRows dropped the deduped registry record that was the only other carrier of the raw spelling. For any session whose id contains an uppercase letter — a supported class, e.g. arena-agent ids — a script piping the emitted id into `qwen --resume` hit the case-sensitive native store's exact-match lookup and failed. Emit launch.resumeSessionId when recorded, falling back to the store id for sessions created rather than adopted; the name fallback follows so the table shows the id scripts can act on. The dedupe comparison stays sanitized on both sides and store keying is untouched — only the reported value changes. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-closeout/jmtnaadh9j8
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- R5-1 managed-row cwd source choice unpinned (managed-rows.ts:150) — already reported as D2-2 in the round-2 review body deferral list (review 5108278256)
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Test Plan (not a blocker): 364 tests passing — this review observed 28373 passed.
Deferred under the convergence posture (round 5, not a blocker) — the floor engaged early: the first-time-finding rate has not fallen for 2 consecutive round(s) — recorded, not requested in this round; 1 Critical(s) among them are deferred by their axes — fails-closed on new surface, where no wrong result is certified and the merge base had neither the surface nor the defect — and remain follow-up work recorded in the findings artifact:
packages/cli/src/commands/sessions/managed-rows.ts:134 — [probe] Critical [fails-closed] [new-surface] R4-1: (fix-induced) unvalidated launch.resumeSessionId — a non-string value crashes mergeSessionRows outside readManagedRows' catch and t…packages/cli/src/commands/sessions/managed-rows.test.ts:238 — [probe] namespace guard worker.pidNs != null escape hatch unpinned — dropping the clause ships green and blanks pids of pidNs-less worker filespackages/cli/src/commands/sessions/managed-rows.test.ts:285 — [probe] token-less candidates under unreadable local boot id unpinned — a fail-closed early-return mutant ships greenpackages/cli/src/commands/sessions/managed-rows.test.ts:322 — [probe] same-boot starttime-mismatch rejection unpinned — a boot-match liveness-shortcut mutant ships greenpackages/cli/src/commands/sessions/managed-rows.test.ts:338 — [probe] namespace guard whole-row scope unpinned — a per-candidate-skip mutant ships greenpackages/cli/src/commands/sessions/managed-rows.test.ts:368 — [probe] name precedence (roster display name over activity summary) never tested with both present — branch-swap mutant ships greenpackages/cli/src/commands/sessions/managed-rows.test.ts:369 — [probe] deriveTitle initialPrompt rung pinned by zero tests — rung deletion ships green
Convergence: round 5 posted 1 inline comment(s), 1 of them reported for the first time; the previous round posted 2 (2 new). Findings keep coming back to the same files: packages/cli/src/commands/sessions/managed-rows.ts (findings in rounds 3, 4; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (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.
Test Plan(非阻断):364 tests passing — this review observed 28373 passed。
收敛姿态下延后(第 5 轮,非阻断)——发布下限因首次发现速率连续 2 轮未下降而提前生效——已记录,本轮不要求修改;其中 1 条 Critical 按其失败方向与对照基线延后——fails-closed 且 new-surface:未认证任何错误结果,且 merge base 既无该功能面也无该缺陷——作为后续工作记录在 findings 工件中:共 7 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 5 轮发布了 1 条行内评论,其中 1 条是首次提出;上一轮发布了 2 条(其中 2 条首次提出)。发现反复回到同一批文件:packages/cli/src/commands/sessions/managed-rows.ts(第 3、4 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)
— qwen3.8-max via Qwen Code /review (v0.23.0)
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 167 passed · 0 failed · 167 total Flakiness gate: ✅ 2 changed test file(s) x 5 identical rounds, no divergence 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:167 通过 · 0 失败 · 167 总计 抖动门:✅ 2 changed test file(s) x 5 identical rounds, no divergence Verification reportPR 10942 — deep verification reportVerdict: 中文摘要
1. ScopeCentral claim — Secondary claims
Out of scope by choice: everything in §7. 2. Environment and controls
3. Central claim — A/B tableOne identical fixture: one managed session (
40/40 assertions in 4. Secondary claimsS1 — pid identity (40/40,
Plus the untitled fallback: no roster/activity/launch → the row's name is the session id, and S2 — store failure (28/28, Id canonicalization (26/26, Docs census (33/33, 4b. Corrections to the descriptionNot requests to change code — the description states two things about the verified head that this round measured differently:
5. Findings (non-blocking)F1 — the canonicalized dedupe can silently drop live registry rows. Measured: two live registry records whose session ids differ only by case ( F2 — a managed row that wins the dedupe discards a live registry pid. Measured: managed session with no worker file + a live registry record for the same id → base prints the live pid ( F3 — two doc-precision gaps in the F4 — the identity guard inherits core's 10 ms token resolution. 6. Gates
Mutation matrix (witness
No survivors, so there is no coverage gap to report; M3 and M4 each kill alone and together, so the two identity guards are independently load-bearing rather than layered redundancy. No kill was invalidated by an import/compile error (checked per log). 7. Not covered
8. MethodologyOne paragraph: the round ran in the CI verify container on the merge-ref checkout; both arms executed the real CLI from TypeScript source via Flakiness gate logEvidence imagesHarness scripts and raw logs are in the workflow run artifacts (7-day retention). — Qwen Code · sandboxed verification |
…anaged row The store fails soft on any read or parse error, so a managed row can be degraded to the sanitized store id and no pid while the session is still live, and the registry record the merge dedupes against it may be the only other carrier of what it lost. Carry the record's raw spelling when the row fell back to the sanitized id, and its verified pid when the row has none, so `qwen sessions ps` lists a resumable id and a live pid instead of a sanitized id no consumer can resume and `-`. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-closeout/jmtnn5c4cjw
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
2 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- namespace guard worker.pidNs != null clause unpinned (managed-rows.test.ts:254) — already reported in the round-5 review body deferral list (review 5117986596)
- managed-row cwd source choice unpinned (managed-rows.ts:150) — already reported as D2-2 in the round-2 review body deferral list (review 5108278256)
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Test Plan (not a blocker): 364 tests passing — this review observed 28375 passed.
Deferred under the convergence posture (round 6, not a blocker) — recorded, not requested in this round; 1 Critical(s) among them are deferred by their axes — fails-closed on new surface, where no wrong result is certified and the merge base had neither the surface nor the defect — and remain follow-up work recorded in the findings artifact:
packages/cli/src/commands/sessions/managed-rows.ts:134 — [review] Critical [fails-closed] [new-surface] R4-1: (still standing) unvalidated launch.resumeSessionId — a non-string value crashes mergeSessionRows outside readManagedRows' catch a…packages/cli/src/commands/sessions/managed-rows.ts:288 — [probe] R6-2: carry updates sessionId but never row.name — a degraded merged row reports name != sessionId against the module's own invariantpackages/cli/src/commands/sessions/managed-rows.ts:283 — [probe] R6-3: spelling-carry guard infers degradation by string equality — an all-lowercase launch spelling is misclassified as degraded and overwritten by a colliding recordpackages/cli/src/commands/sessions/managed-rows.ts:286 — [probe] R6-4: pid ??= record.pid keys on the sanitized id alone — a sanitized-id collision hands an interloper record's pid to the managed rowpackages/cli/src/commands/sessions/managed-rows.test.ts:254 — [probe] R6-5: namespace guard accept path (both sides known and equal — the everyday Linux case) exercised by no test; a blanket-reject mutant ships greenpackages/cli/src/commands/sessions/managed-rows.test.ts:338 — [probe] R6-6: boot-guard rejection side for the host pid candidate pinned by no test; a worker-branch-only guard mutant ships greenpackages/cli/src/commands/sessions/managed-rows.test.ts:550 — [probe] R6-7: cross-session isolation guard (managed-rows.ts:279) pinned by no test; deletion ships green and leaks an unrelated record's pidpackages/cli/src/commands/sessions/managed-rows.test.ts:537 — [probe] R6-8: pid carry's independence from the spelling guard pinned by no test; narrowing the carry to the degradation branch ships greenpackages/cli/src/commands/sessions/managed-rows.test.ts:535 — [probe] R6-10: launch-spelling overwrite protection pinned by no test; an unconditional-overwrite mutant ships green
Convergence: round 6 posted 1 inline comment(s), 1 of them reported for the first time; the previous round posted 1 (1 new). Findings keep coming back to the same files: packages/cli/src/commands/sessions/managed-rows.ts (findings in round 5; 1 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)
Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (1 Critical(s)), the rate of first-time findings is not falling (this round 1, previous 1), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):
| standing Critical | attack surface | attacker-dependency | blast radius |
|---|---|---|---|
| (each standing Critical) | … | … | … |
Advisory only — it does not block this review.
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 2 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Test Plan(非阻断):364 tests passing — this review observed 28375 passed。
收敛姿态下延后(第 6 轮,非阻断)——已记录,本轮不要求修改;其中 1 条 Critical 按其失败方向与对照基线延后——fails-closed 且 new-surface:未认证任何错误结果,且 merge base 既无该功能面也无该缺陷——作为后续工作记录在 findings 工件中:共 9 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 6 轮发布了 1 条行内评论,其中 1 条是首次提出;上一轮发布了 1 条(其中 1 条首次提出)。发现反复回到同一批文件:packages/cli/src/commands/sessions/managed-rows.ts(第 5 轮已出过发现,本轮又有 1 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)
残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 1 条 Critical),首次发现的速率没有下降(本轮 1,上一轮 1),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。
— qwen3.8-max via Qwen Code /review (v0.23.0)
Picks up integration-tests/chat-transcript-document.test.ts, which the web-shell E2E Smoke gate runs by name. The branch was 24 commits behind main and did not have the file, so the gate failed with "No test files found" rather than on anything in this PR.
|
CI attribution for run 1. Test (ubuntu-latest, Node 22.x) — not PR-caused (shared-runner saturation). All suites were green: cli Load 251 with ~100 concurrent test processes on the shared ECS host — same saturation lineage as the recent Test-ubuntu failures on #10917. Every file this PR touches (incl. 2. Integration Tests (no-AK) — deterministic failure, flagged for maintainer. 184/185 passed; the single failure reproduces identically across all 3 attempts in ~19ms (an immediate error, not a timeout): The test itself comes from main (#10617) and the sibling ACP tests pass, but this PR's diff touches the worker-identity surface ( |
R6-1. The token-less fall-through in `liveWorkerPid` was written for one class of file — same-machine, written before the identity fields existed, where a liveness probe was the prior behaviour in the reader's own pid space. It does not transfer to a file written on another OS, and darwin and win32 produce exactly that: `readProcStartToken` and `readPidNamespaceId` return null there permanently, so the file carries no token and no namespace, clears the namespace and boot-id guards on those nulls, and reaches a bare `kill(pid, 0)` against a number that belongs to another OS's pid space. The shared-`~/.qwen` topology this module's header already names is the concrete shape: a macOS supervisor's home mounted into a Linux devcontainer. If any local process holds that number the row prints it in the table and in `--json`, and a script acting on it hits a stranger. That is the same "two halves of one table, two evidence standards" defect this listing was already being fixed for — `listLiveSessions` skips a foreign-namespace record outright — so it is the same fix, one class further out. Pre-platform files keep the documented degradation: `platformValue` defaults an absent `platform` to `process.platform` (supervisor-store.ts:1008-1011), so they read as same-platform. Both test fixtures pinned `platform: 'linux'`. Left alone that would not have tested the guard, it would have blanked every managed pid on the macOS runner and failed the suites there instead. Both now use `process.platform`, and the new case picks a platform that is definitely not the reader's, asserting the pid is dropped while `isPidAlive` still reports it live — so the case fails for the right reason and goes red if the guard is removed. Typecheck, lint and tests were not run locally; CI is the authority.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
10 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- R4-3/R4-5 writer-side pid-identity test gap (supervisor-process.ts:511, supervisor-store.ts:891) — already reported in the round-4 deferral list (review 5114958165)
- R6-5 namespace guard accept path untested (managed-rows.test.ts:254) — already reported in the round-6 deferral list (review 5119546341)
- namespace guard pidNs escape hatch unpinned (managed-rows.test.ts:238) — already reported in the round-5 deferral list (review 5117986596)
- D2-2 managed-row cwd source choice unpinned (managed-rows.ts:150) — already reported in the round-2 deferral list (review 5108278256)
- R6-10 spelling-preservation guard unpinned (managed-rows.test.ts:535) — already reported in the round-6 deferral list (review 5119546341)
- docs --json field list omits ipcPath / 'exactly as recorded' vs ipcToken strip (commands.md:790) — already reported in the round-3 deferral list (review 5109073154)
- R6-2 carryDedupedRecord never carries the record's name (managed-rows.ts:288) — already reported in the round-6 deferral list (review 5119546341)
- store-failure partial listing exits 0 (ps.ts:159) — already reported in the round-4 deferral list (review 5114958165)
- managedSessionRows now-parameter dead plumbing (managed-rows.ts:89) — already reported in the round-3 deferral list (review 5109073154)
- title precedence / initialPrompt rung unpinned (managed-rows.test.ts:368) — already reported in the round-5 deferral list (review 5117986596)
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Not explored to full depth (tool budget reached): "agent reverse-audit (round 5)": none of my assigned range went unread (diff lines 393-578 read in full); the managed-rows.test.ts case bodies below diff line 578 belong to another chunk and ….
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.
Test Plan (not a blocker): 364 tests passing — this review observed 28624 passed.
Deferred under the convergence posture (round 7, not a blocker) — recorded, not requested in this round:
packages/cli/src/commands/sessions/ps.ts:205 — [probe] D7-1 complete flag blind to the store's per-entry fail-soft dropspackages/cli/src/agent-view/protocol.ts:139 — [probe] D7-2 clearAgentViewWorkerPids orphans the new identity tokenspackages/cli/src/commands/sessions/managed-rows.ts:151 — [probe] D7-3 taskState has no liveness gate; stale working/needs-input survives an unclean deathpackages/cli/src/commands/sessions/ps.ts:159 — [probe] D7-4 fail-soft catch's stderr note destroys the listing when stderr is broken (fix: ignoreBrokenPipe)docs/users/features/commands.md:785 — [probe] D7-5 documented PID - rule contradicts the code in both directionspackages/cli/src/agent-view/supervisor-process.ts:97 — [probe] D7-6 supervisor's own pid gates ignore the identity contract this diff adds (class finding, 7 sites)packages/cli/src/agent-view/supervisor-store.ts:891 — [probe] D7-7 platformValue allowlist narrower than NodeJS.Platform defeats the new cross-OS guardpackages/cli/src/commands/sessions/managed-rows.ts:144 — [probe] D7-8 unreachable !presentation.title clause — dead branch in a new filepackages/cli/src/commands/sessions/managed-rows.ts:339 — [probe] D7-9 N:1 dedupe collapse makes one of two live same-id processes vanishpackages/cli/src/commands/sessions/managed-rows.ts:296 — [probe] D7-10 pid carry undoes a provenance refusal, misattributing a local interactive pidpackages/cli/src/commands/sessions/ps.ts:107 — [probe] D7-11 unreadable sessionState laundered into a confident 'failed' verdictpackages/cli/src/commands/sessions/ps.test.ts:470 — [probe] D7-12 new empty-listing test duplicates the pre-existing one; added mock line inertpackages/cli/src/commands/sessions/managed-rows.test.ts:578 — [probe] D7-13 dedupe→emitter seam unpinned: record===undefined never assertedpackages/cli/src/commands/sessions/ps.ts:197 — [probe] D7-14 deduped managed row drops record-only fields, falsifying the docs messaging promise (class finding)packages/cli/src/commands/sessions/managed-rows.ts:334 — [probe] D7-15 dedupe suppression key taken from an untrusted launch-file fieldpackages/cli/src/commands/sessions/ps.ts:44 — [probe] D7-16 STATE column squeezes the never-truncated DIRECTORY column below 80 columnsdocs/users/features/commands.md:804 — [probe] D7-17 managed-row JSON field list written unconditionally for optional fields.qwen/skills/find-simplifications/references/survey.md:151 — [probe] D7-18 new external-reference probe mis-measures on this repo's relative-import style
Convergence: round 7 posted 4 inline comment(s), 4 of them reported for the first time; the previous round posted 1 (1 new). Findings keep coming back to the same files: packages/cli/src/commands/sessions/managed-rows.ts (findings in round 6; 3 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)
Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (4 Critical(s)), the rate of first-time findings is not falling (this round 4, previous 1), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):
| standing Critical | attack surface | attacker-dependency | blast radius |
|---|---|---|---|
| (each standing Critical) | … | … | … |
Advisory only — it does not block this review.
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 10 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 5)":none of my assigned range went unread (diff lines 393-578 read in full); the managed-rows.test.ts case bodies below diff line 578 belong to another chunk and …。
未审查:反向审计——在 5 轮的反审轮数上限内未收敛。
Test Plan(非阻断):364 tests passing — this review observed 28624 passed。
收敛姿态下延后(第 7 轮,非阻断)——已记录,本轮不要求修改:共 18 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 7 轮发布了 4 条行内评论,其中 4 条是首次提出;上一轮发布了 1 条(其中 1 条首次提出)。发现反复回到同一批文件:packages/cli/src/commands/sessions/managed-rows.ts(第 6 轮已出过发现,本轮又有 3 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)
残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 4 条 Critical),首次发现的速率没有下降(本轮 4,上一轮 1),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。
— qwen3.8-max via Qwen Code /review (v0.23.0)
| return [ | ||
| ...managed.map((row) => carryDedupedRecord(row, records)), | ||
| ...records | ||
| .filter((record) => !managedIds.has(sanitizeSessionId(record.sessionId))) |
There was a problem hiding this comment.
[Critical] R7-1: [certifies-falsely] [new-surface] The merge dedupe treats canonical-id equality as session identity, but sanitizeSessionId is not injective over real session ids: uppercase-hex ids are representable (SESSION_FILE_PATTERN admits [0-9a-fA-F-]{32,36}), the native session store is case-sensitive, adoption accepts any non-empty raw id, and --session-id validates with a case-insensitive regex whose case-twin guard blocks only pre-existing twins. A registry record belonging to a DIFFERENT live session whose id merely canonicalizes onto a managed row's id is therefore silently dropped from the listing, its listLiveSessions-verified pid is carried onto the managed row via pid ??= record.pid, and a degraded managed row can even take the interloper's raw resumable spelling. Concretely: session Managed-1 is adopted (store directory managed-1, row emits Managed-1 from launch.resumeSessionId) while a distinct live interactive session holds id MANAGED-1 — the filter drops that record, so MANAGED-1 appears in neither the table nor --json while its process is alive (the silent omission this command exists to prevent), and its verified pid rides the managed row, so a --json consumer that kills by pid targets the wrong process. This is the class-level finding for the sanitize-collision family; it absorbs the pid-carry arm recorded as R6-4 in the round-6 deferral list.
Witness:
Probe against the real managed-rows.ts (scratch tree):
INTACT: F2-A rows = [{"sessionId":"Managed-1","pid":999,"managed":true}]
F2-A interloper listed? false | managed row pid: 999
F2-C degraded row = [{"sessionId":"MANAGED-1","pid":999,"managed":true}]
FIXED (exact raw-spelling match first; sanitized fallback only while the
row itself reports the sanitized form):
rows = [{"sessionId":"Managed-1","managed":true},
{"sessionId":"MANAGED-1","pid":999,"managed":false}]
interloper listed? true | managed row pid: undefined
same-session mixed-case dedupe unchanged; all 34 managed-rows tests green under the fix
Suggested fix (two cooperating sites, so no one-click block): in mergeSessionRows/carryDedupedRecord, dedupe a record by exact match against the managed row's known raw spelling (launch.resumeSessionId ?? state.sessionId — adoption always writes resumeSessionId, supervisor-process.ts:649), falling back to the sanitized comparison only when the row itself reports the sanitized form.
The fix must not regress to raw-string comparison in the fallback case — this module's own comment: "Comparing the spellings would let any managed session whose id contains an uppercase letter through this filter, and the table would list it twice", and the mixed-case same-session dedupe pinned at managed-rows.test.ts:526/:547 must keep working. Please add a managed-rows.test.ts case merging record({ sessionId: 'MANAGED-1', pid: 999 }) with a managed row reporting Managed-1 (store id managed-1), asserting two rows — the managed row without pid 999 and the interactive MANAGED-1 row with it — then remove the raw-spelling-first match and confirm that test goes red.
中文说明
R7-1:[certifies-falsely][new-surface] 合并去重把「规范化后 id 相等」当成了 session 同一性,但 sanitizeSessionId 对真实 session id 不是单射:大写十六进制 id 是合法可表示的(SESSION_FILE_PATTERN 接受 [0-9a-fA-F-]{32,36}),原生 session 存储大小写敏感,收养(adoption)接受任意非空原始 id,--session-id 用大小写不敏感的正则校验、其 case-twin 守卫只拦截已存在的孪生 id。因此,属于另一个存活 session、id 恰好规范化后与某个 managed 行相同的注册表记录会被静默丢弃:它经 listLiveSessions 验证过的 pid 通过 pid ??= record.pid 被搬到 managed 行上,降级行甚至会采用闯入者的原始可恢复拼写。具体场景:session Managed-1 被收养(存储目录 managed-1,行从 launch.resumeSessionId 输出 Managed-1),同时另一个存活的 interactive session 持有 id MANAGED-1 —— 过滤器丢弃后者的记录,于是 MANAGED-1 在表格和 --json 中都消失,而它的进程还活着(这正是本命令要防止的静默遗漏),它已验证的 pid 却出现在 managed 行上,按 pid 执行 kill 的 --json 消费者会命中错误的进程。本条是 sanitize 碰撞族的类级发现,吸收了第 6 轮延后清单中记录的 R6-4(pid 搬运分支)。
(证据见上方 probe:未修复时存活的 MANAGED-1 记录消失、其 pid 999 出现在 managed 行上;按建议修复后闯入者正常列出、managed 行不再携带其 pid,同 session 的混合大小写去重不受影响,34 个测试全绿。)
建议修复(涉及两处配合,不用一键 suggestion 块):在 mergeSessionRows/carryDedupedRecord 中,先用 managed 行已知的原始拼写(launch.resumeSessionId ?? state.sessionId —— 收养总会写入 resumeSessionId,supervisor-process.ts:649)做精确匹配去重,仅当行本身报告的是净化形式时才回退到净化比较。
修复不得在回退分支退回原始字符串比较 —— 本模块自己的注释写明:「比较拼写会让任何 id 含大写字母的 managed session 绕过过滤器,表格会把它列两次」;managed-rows.test.ts:526/:547 钉住的同 session 混合大小写去重必须继续通过。请补一个 managed-rows.test.ts 用例:把 record({ sessionId: 'MANAGED-1', pid: 999 }) 与报告 Managed-1(存储 id managed-1)的 managed 行合并,断言输出两行 —— managed 行不携带 pid 999,interactive 行 MANAGED-1 携带 —— 然后移除「原始拼写优先」的匹配并确认该测试变红。
— qwen3.8-max via Qwen Code /review (v0.23.0)
There was a problem hiding this comment.
Verified real at head 294cbe2cc9. Every link in the premise chain holds in the tree:
sanitizeSessionId(packages/cli/src/agent-view/protocol.ts:272-280) lowercases, so it is not injective;SESSION_FILE_PATTERN(packages/core/src/services/sessionService.ts:419) admits[0-9a-fA-F-]{32,36}, so both spellings are persistable in the native store.- Adoption accepts any non-empty raw id:
requireSessionId(supervisor-process.ts:4161-4167) checks onlytypeof/length, andparseAdoptParamsadds just astartsWith('-')guard (:4206) before storing the sanitized form as the key (:4211) and the raw spelling asresumeSessionId(:4223). - The
--session-idoccupancy check is case-insensitive but consults only already-persisted transcripts (findSessionIdIgnoringCase,config/config.ts:2052) and is skipped entirely whensessionIdGenerated(:2050) — so it blocks pre-existing twins only, exactly as described.
Consequence at this head: mergeSessionRows builds managedIds from sanitized row ids (managed-rows.ts:334) and drops every registry record whose sanitized id lands in that set (:339), so the interloper disappears from both the table and --json even though listLiveSessions verified it live. carryDedupedRecord then moves its pid onto the managed row unconditionally inside the match loop (pid ??= record.pid, :298), and when the managed row is degraded to the sanitized store id the :295 arm also hands the row the interloper's raw resumable spelling — an id that resumes the wrong session. There is no compensating gate in the caller: mergeSessionRows runs at ps.ts:174, outside readManagedRows' fail-soft catch.
Fix shape: match the exact raw spelling first and use the sanitized comparison only as a fallback while the managed row itself reports the sanitized form (both at :334/:339 and at :291/:295), and do not carry a pid or a spelling across a sanitize-only match. No code lands this round — the PR is +1582/-78, over this sweep's 1500-addition scope fuse, so this stays unresolved for a follow-up pass.
| ...workerPidIdentity(host), | ||
| ...(host.hostId ? { hostId: host.hostId } : {}), | ||
| hostEndpoint: worker.hostEndpoint, |
There was a problem hiding this comment.
[Critical] R7-2: [certifies-falsely] [new-surface] The reconnect write site re-stamps the worker file's pid identity with pids an already-running PTY host self-reported over its socket, while workerPidIdentity stamps the RECONNECTING supervisor's readPidNamespaceId() and reads readProcStartToken for those numbers from its own /proc — vouching for pids it never verified live in its own namespace, and the merge-style write overwrites the launcher's correct foreign pidNs. The three spawn sites are safe (a plain local spawn shares the writer's namespace); only reconnect reaches a host that may live in another namespace, which defeats the cross-namespace guard these fields exist for. Concretely: a devcontainer bind-mounts ~/.qwen; the container's supervisor spawns a session's detached PTY host (it survives its parent) and then dies (SIGKILL/OOM); supervisor handoff is socket-reachability only (no namespace check), so a host-side qwen spawns its own supervisor, reaches the orphan host through the shared socket, and this site rewrites the identity — pidNs becomes the host namespace and the tokens are read for low container pid numbers that on the host resolve to long-lived system daemons. qwen sessions ps on the host then passes every liveWorkerPid guard and prints that unrelated daemon's pid as the session's live worker pid — a number the docs invite the user to kill — while the real worker sits invisible in the container namespace.
Witness:
Probe driven end-to-end through the real handler (stub socket peer answering
the product's own status protocol; real reconnect → real write → real store →
real managedSessionRows. Declared model: the real container topology was not
exercised — this machine cannot create a PID namespace):
INTACT: before: worker.json pidNs = 4026531837 (launcher's, foreign); row = no pid
after handler.logs(): pidNs = 4026531836 (writer's own);
row = {"name":"Sess-C","pid":2}
pid 2 in THIS namespace is: kthreadd
(mis-stamped tokens for pid 1 and pid 2 came out identical — kernel
threads share a starttime — so the false vouch is permanently stable)
FIX ARM (identity spread removed at :2361 only):
launcher's identity preserved (pidNs = 4026531837); row = no pid
| ...workerPidIdentity(host), | |
| ...(host.hostId ? { hostId: host.hostId } : {}), | |
| hostEndpoint: worker.hostEndpoint, | |
| ...(host.hostId ? { hostId: host.hostId } : {}), | |
| hostEndpoint: worker.hostEndpoint, |
Omitting the spread suffices because writeAgentViewWorker merges {...existing, ...worker} (supervisor-store.ts:525-529), so the original launcher's identity — valid in its namespace, describing the same live processes — survives and the namespace guard keeps working; alternatively have the host's status reply report its own namespace id and stamp identity only when it matches readPidNamespaceId(). The fix must not write explicit nulls over a valid recorded token: normalizeWorker coerces absent token fields to null (supervisor-store.ts:891-893), so omission — not nulling — is the shape that preserves the original identity. Please add a supervisor-process.test.ts case for reconnectSessionHost asserting the post-reconnect worker file keeps the original writer's pidNs/hostProcStart/workerProcStart (or omits identity) rather than the reconnecting process's own, then restore the unconditional ...workerPidIdentity(host) at this site and confirm the test goes red.
中文说明
R7-2:[certifies-falsely][new-surface] 重连(reconnect)写入点会用「已在运行的 PTY host 通过 socket 自报的 pid」重新盖章 worker 文件的 pid 身份,而 workerPidIdentity 盖的是重连方 supervisor 自己的 readPidNamespaceId(),并从它自己的 /proc 读取这些编号的 readProcStartToken —— 为它从未在自己命名空间里验证过存活的 pid 背书,且 merge 式写入会覆盖启动方原本正确的外来 pidNs。三个 spawn 写入点是安全的(本地 spawn 的子进程与写入方共享命名空间);只有 reconnect 会连接到可能位于另一个命名空间的 host,从而破坏这些字段本要防御的跨命名空间守卫。具体场景:devcontainer 以 bind-mount 挂载 ~/.qwen;容器内 supervisor 启动了会话的 detached PTY host(host 比父进程长寿)后被 SIGKILL/OOM;supervisor 接管只看 socket 可达性(无命名空间检查),于是宿主机侧的 qwen 启动自己的 supervisor,通过共享 socket 连上孤儿 host,此写入点重写身份:pidNs 变成宿主命名空间,token 按容器内的小 pid 编号在宿主的 /proc 里读出 —— 那些编号在宿主上对应长寿的系统守护进程。宿主机上的 qwen sessions ps 随后通过 liveWorkerPid 的全部守卫,把这个无关守护进程的 pid 当作该会话的存活 worker pid 打印出来(文档正是邀请用户去 kill 这个数字),而真正的 worker 在容器命名空间里不可见。
(证据见上方 probe:通过真实 handler 端到端驱动。未修复时 worker.json 的 pidNs 被改写为重连方自己的命名空间、行输出 pid 2 = kthreadd,且错误盖章的 token 稳定复现;仅移除 :2361 的身份 spread 后,启动方身份得以保留、行不再输出 pid。声明:真实容器拓扑未能实际执行 —— 本机无法创建 PID 命名空间,外方 host 用遵循产品自身 status 协议的 stub socket 对端建模。)
采用上方 suggestion 块即可:省略该 spread 就够了,因为 writeAgentViewWorker 以 {...existing, ...worker} 合并(supervisor-store.ts:525-529),启动方原本的身份(在其命名空间内有效、描述同一批存活进程)会保留下来,命名空间守卫继续有效;或者让 host 的 status 应答报告自己的命名空间 id,仅当其与 readPidNamespaceId() 一致时才盖身份。修复不得用显式 null 覆盖已记录的有效 token:normalizeWorker 会把缺失的 token 字段规范化为 null(supervisor-store.ts:891-893),所以「省略字段」而非「写 null」才是保留原身份的形态。请补一个 supervisor-process.test.ts 的 reconnectSessionHost 用例,断言重连后的 worker 文件保留原写入方的 pidNs/hostProcStart/workerProcStart(或不含身份字段),而不是重连进程自己的;然后在同一位置恢复无条件的 ...workerPidIdentity(host) 并确认该测试变红。
— qwen3.8-max via Qwen Code /review (v0.23.0)
There was a problem hiding this comment.
Mechanism verified at head 294cbe2cc9. This one is a trust-boundary decision rather than a patch, so it stays unresolved.
What the code does:
workerPidIdentity(supervisor-process.ts:105-119) stampspidNs: readPidNamespaceId()— the writer's namespace — and readsreadProcStartTokenforhost.pid/host.workerPidout of the writer's own/proc.- At the reconnect site the write is
:2357-2370(identity spread at:2361), and those two pids are not the writer's children:connectAgentViewPtyHostProcess(pty-host-process.ts:171-197) takes them from the peer'sstatusreply (:1008,:1012-1025), which the host answers with its ownprocess.pid(:882-883). - The written object is a fresh literal that carries
hostEndpoint,hostAuthTokenandrecentOutputBytesforward from the file read at:2342but deliberately notpidNs, so the launcher's namespace stamp is replaced by the writer's. The only peer check before the write isexpectedHostId(:2353), which proves "same host process", not "same namespace". - The finding's scoping also holds: the other three write sites (
:511,:743,:2559) each follow a local spawn, so only reconnect can reach a host in a foreign namespace.
Decision owed: whether a reconnecting supervisor may write pid identity at all for a peer whose namespace it cannot observe. The candidate shapes have different costs and none is obviously right — (a) carry the old file's pidNs/procStart forward and refresh only endpoint/auth, which leaves a legitimate same-namespace reconnect with stale tokens and can blank a real pid; (b) skip the refresh unless the peer is provably local, which needs a notion of "local" the status protocol does not currently carry; (c) extend status so the host reports its own pidNs and compare before writing, which is a protocol change with an old-host compatibility question. Downstream also interacts with the row-admission question raised separately on managed-rows.ts:111.
No code lands this round regardless: the PR is over this sweep's 1500-addition scope fuse.
| now: number = Date.now(), | ||
| ): SessionRow[] { | ||
| return snapshots | ||
| .filter((snapshot) => snapshot.state.ownership === 'managed') |
There was a problem hiding this comment.
[Critical] R7-3: [certifies-falsely] [new-surface] The managed half of the listing admits rows with no machine or namespace identity gate — the only filter is ownership === 'managed' — while the interactive half of the SAME table explicitly refuses foreign records (record.pidNs !== ownNamespace plus boot-prefix skips in listLiveSessions). getAgentViewStorePaths keys the jobs directory on globalDir alone and listAgentViewSessionSnapshots returns every readable job dir, so in a shared ~/.qwen — an NFS home or a bind-mounted devcontainer home, the topology this diff's own protocol.ts comment and the registry's guard comments name — the command lists sessions running on another machine while the docs line this PR adds (commands.md:755) claims "running on this machine right now". The identity this diff added is spent solely on blanking the pid inside liveWorkerPid, never on row admission: a foreign managed session is printed with its real title, real cwd and STATE needs input, PID -, and --json emits taskState: "waiting" — so the diff's own jq example (select(.taskState == "waiting") | .name) hands a script the name of a session waiting on a machine it cannot reach, in a listing documented as this machine's. Pre-PR this command made no such claim (zero agent-view references at the merge base).
Witness:
Probe S1 — real ps on one fabricated shared home holding (a) a managed job
with a foreign pidNs (4026531837 vs local 4026531836) + foreign boot prefix
and (b) an interactive registry record from the same foreign namespace:
PR table: HOST session waitin… - 0s needs input /host/w/app
PR json : {"name":"HOST session waiting for an answer","cwd":"/host/w/app",
"taskState":"waiting","sessionId":"host-managed","managed":true}
PR : foreign interactive record NOT listed (registry guards refused it)
BASE : ps.ts agent-view references → 0 (the claim is new in this diff)
Fix-cost measurement: a strict known-foreign row gate flipped S1 to an empty
listing but broke 5 of this PR's own tests (each pins keep-the-row,
blank-the-pid) and is ineffective while the platformValue coercion stands
(see the deferred platform-allowlist entry) — a design change, not a one-liner.
Suggested fix: either gate the row the way the registry gates records — drop a snapshot whose worker.pidNs is known and differs from readPidNamespaceId(), or whose token boot prefix names another boot — keeping worker-less rows and rewording commands.md:755's "on this machine" contract for them; or state on the row and in the docs that a managed row's locality is unverified. The docs+marking arm is the only complete non-breaking option measured here.
The gate cannot rely on pidNs alone: two machines sharing one ~/.qwen both live in the initial PID namespace, whose inode is a kernel constant, so the boot prefix is the only cross-machine identity (managed-rows.ts:193-197) — and the fields are optional by design (protocol.ts:139; normalizeWorker defaults an absent value to null), so a pre-identity worker file and any worker-less managed session carry no machine identity and must not be dropped by a strict gate. Please add a managed-rows.test.ts case with a managed snapshot whose worker.pidNs differs from the reader's (and a second with a foreign boot prefix) asserting whichever contract is chosen — no row, or a row marked locality-unverified — red when that behaviour is removed, updating the five keep-row-blank-pid tests consistently with the chosen contract.
中文说明
R7-3:[certifies-falsely][new-surface] 列表的 managed 半边在收录行时没有任何机器/命名空间身份门 —— 唯一的过滤是 ownership === 'managed' —— 而同一张表的 interactive 半边明确拒绝外来记录(listLiveSessions 的 record.pidNs !== ownNamespace 与 boot 前缀跳过)。getAgentViewStorePaths 只按 globalDir 定位 jobs 目录,listAgentViewSessionSnapshots 返回每个可读的 job 目录,因此在共享 ~/.qwen(NFS 主目录、bind-mount 的 devcontainer 主目录 —— 本 diff 自己的 protocol.ts 注释与注册表守卫注释都点名了这一拓扑)下,命令会列出运行在另一台机器上的会话,而本 PR 新增的文档行(commands.md:755)声称「running on this machine right now」。本 diff 添加的身份信息只被用于在 liveWorkerPid 内部抹掉 pid,从不用于行的收录:外来 managed 会话带着真实标题、真实 cwd 和 STATE needs input、PID - 被打印,--json 输出 taskState: "waiting" —— 于是本 diff 自己给出的 jq 示例(select(.taskState == "waiting") | .name)会把一个「等待在够不到的机器上」的会话名交给脚本,而列表文档声称这是本机列表。PR 之前本命令没有这个声明(merge base 上 ps.ts 对 agent-view 零引用)。
(证据见上方 probe S1:同一外来命名空间的 interactive 注册表记录被注册表守卫正确拒绝,而外来 managed 会话被完整列出;BASE 臂确认该声明是本 diff 新增。修复成本实测:严格的「已知外来即拒收」行门能让 S1 变空,但会破坏本 PR 自己的 5 个测试 —— 它们都钉住「保留行、抹掉 pid」的契约 —— 且在 platformValue 强制转换存在时对部分平台无效(见延后的平台白名单条目):这是设计级改动,不是一行修复。)
建议修复:要么像注册表那样对行做门控 —— 丢弃 worker.pidNs 已知且与 readPidNamespaceId() 不同、或 token boot 前缀指向另一次启动的快照 —— 同时保留无 worker 的行并相应改写 commands.md:755 的「本机」契约;要么在行与文档中明确 managed 行的本机性未经验证。实测之下,「文档+标注」分支是唯一完整且不破坏现有测试的选项。
门控不能只靠 pidNs:共享一个 ~/.qwen 的两台机器都位于初始 PID 命名空间,其 inode 是内核常量,boot 前缀才是唯一的跨机器身份(managed-rows.ts:193-197);且这些字段按设计是可选的(protocol.ts:139;normalizeWorker 把缺失值规范为 null),因此身份字段出现之前的 worker 文件、以及任何没有 worker 文件的 managed 会话都不携带机器身份,严格门控不得丢弃它们。请补一个 managed-rows.test.ts 用例:managed 快照的 worker.pidNs 与读取方不同(外加一个 boot 前缀为外来的用例),断言所选契约 —— 不输出行,或输出「本机性未验证」的行 —— 移除该行为时变红,并按所选契约同步更新那 5 个「保留行、抹掉 pid」的测试。
— qwen3.8-max via Qwen Code /review (v0.23.0)
There was a problem hiding this comment.
Verified at head 294cbe2cc9, including the fix-cost tension — this is a product call, so it stays unresolved.
The asymmetry is real and the two halves of one table are gated differently:
- Managed admission is
ownership === 'managed'and nothing else (managed-rows.ts:111). No pidNs, boot-id or machine check anywhere inmanagedSessionRows. - The interactive half gates hard:
listLiveSessionsreturns early onrecord.pidNs !== ownNamespace(packages/core/src/services/session-registry.ts:541) and again on a foreign boot prefix (:553-557). - The store cannot supply the identity:
getAgentViewStorePathskeysjobsDironglobalDiralone (supervisor-store.ts:57-69) andlistAgentViewSessionSnapshots(:361) returns every readable job dir. - The namespace/boot-id identity this diff added is spent only inside
liveWorkerPid(managed-rows.ts:198-239, guards at:213and:218) — on blanking the pid, never on row admission. So a foreign managed session is listed with real title, real cwd and a live task state, withPIDas-. - The doc claim is new in this PR:
docs/users/features/commands.md:755"Lists the Qwen Code sessions running on this machine right now."git grepfor that phrase at merge basee3d26283e6returns nothing.
Confirmed the tension the probe measured. A strict known-foreign row gate contradicts behaviour this PR pins in its own tests: managed-rows.test.ts:236 (refuses pids from a worker file written in another PID namespace), :288, :311, :341 and :364 each construct a worker file with foreign identity and assert the row survives with pid undefined. That is five tests pinning keep-the-row/blank-the-pid, and it is the deliberate design stated in the liveWorkerPid docblock ("an unreadable namespace on either side must not blank a real worker's pid").
Decision owed: pick one and make the other half consistent — either the listing admits foreign managed sessions, in which case commands.md:755 has to be reworded and --json should carry the fact that a row is not local (the documented select(.taskState == "waiting") example currently hands a script an unreachable session), or it gates them out, in which case the degrade-don't-blank design and those five pinned tests change. Not settleable by a patch, and no code lands this round anyway (PR is over the sweep's 1500-addition scope fuse).
| // an id that cannot resume the session. Store keying stays | ||
| // untouched; only the reported value changes. | ||
| const sessionId = | ||
| snapshot.launch?.resumeSessionId ?? snapshot.state.sessionId; |
There was a problem hiding this comment.
[Critical] R4-1: [certifies-falsely] [new-surface] (still standing) launch.resumeSessionId is consumed here as a trusted string, but normalizeLaunch passes it through the ...raw spread unvalidated — the only launch text field this new consumer reads that escapes stringValue — so a well-formed-but-wrong-typed launch.json crashes qwen sessions ps in mergeSessionRows, OUTSIDE readManagedRows' fail-soft catch, or yields a silently wrong empty-id row. This entry was deferred in rounds 5-6 as fails-closed on new surface; the probe below shows the empty-string arm CERTIFIES FALSELY (exit 0, parseable stdout, a row no consumer can act on or identify), so the deferral gate is no longer met and the finding posts. A launch.json containing "resumeSessionId": 123 — tampering (the exact input normalizeLaunch's own comment says it defends against), a hand-edit, a backup restore or sync-tool corruption — makes the row take sessionId = 123 (?? does not fall back for non-nullish); handlePs then calls mergeSessionRows outside the try/catch, where sanitizeSessionId runs .replace on the number: the command exits 1 printing ZERO rows, including the already-fetched verified registry records, defeating the documented degrade-and-still-list contract. "resumeSessionId": "" is the non-crashing arm: exit 0, stdout parseable, "sessionId":"" and "name":"" presented as a normal listing entry. The product's own write path is clean (requireSessionId enforces a string); the trigger is an external writer of the durable file.
Witness:
Probe A2 — real qwen sessions ps --json, identical store except the one JSON
value, with a healthy live registry record present (produced by the real
registerSession in a live process):
CONTROL "resumeSessionId":"Managed-A" → EXIT=0, STDOUT 2 lines
PR CODE "resumeSessionId":123 → EXIT=1, STDOUT 0 lines
stderr: TypeError: sessionId.replace is not a function
at sanitizeSessionId (protocol.ts:274)
at mergeSessionRows (managed-rows.ts:334)
at handlePs (ps.ts:174)
<- the verified interactive record was fetched and never printed
PR CODE "resumeSessionId":"" → EXIT=0, STDOUT 2 lines, one being
{"name":"","startedAt":1788523080000,"cwd":"/w/app","taskState":"running","sessionId":"","managed":true}
FIX ARM (resumeSessionId: stringValue(raw['resumeSessionId']) in normalizeLaunch)
123 → EXIT=0, 2 lines, row falls back to the sanitized directory id
"" → EXIT=0, 2 lines, same fallback
Suggested fix (in normalizeLaunch, supervisor-store.ts — a different file, so a plain block rather than a one-click suggestion at this line):
return stripUndefined({
...raw,
schemaVersion: 1,
sessionId,
resumeSessionId: stringValue(raw['resumeSessionId']),
// ...the remaining fields unchanged
}) as AgentViewLaunchFile;stringValue is typeof value === 'string' && value.length > 0 ? value : undefined (supervisor-store.ts:1029) and is already applied to every other string field in normalizeLaunch; the fix must not change store keying, whose source of truth is the sanitized directory name (supervisor-store.ts:793-795). Please add a ps.test.ts case resolving listAgentViewSessionSnapshots with a managed snapshot whose launch.resumeSessionId is 123 as unknown as string plus one registry record, asserting the registry row still prints and the handler does not throw, and a managed-rows.test.ts case with the same fixture asserting row.sessionId equals state.sessionId — then remove the coercion and confirm both go red.
中文说明
R4-1:[certifies-falsely]new-surface此处把 launch.resumeSessionId 当作可信字符串消费,但 normalizeLaunch 让它经 ...raw 展开原样通过 —— 它是这个新消费者读取的启动文件文本字段中唯一逃过 stringValue 的 —— 因此一个格式合法但类型错误的 launch.json 会让 qwen sessions ps 在 mergeSessionRows 里崩溃,而该调用位于 readManagedRows 的 fail-soft catch 之外;或者产出一个静默错误的空 id 行。本条目在第 5、6 轮曾按「fails-closed + new-surface」延后;下方 probe 表明空字符串分支是认证错误结果(exit 0、stdout 可解析、一行任何消费者都无法使用甚至无法识别的数据),延后门槛不再满足,故本轮发布。launch.json 含 "resumeSessionId": 123 时 —— 篡改(normalizeLaunch 自己的注释写明它就是要防御这种输入)、手工编辑、备份恢复或同步工具损坏 —— 行会取 sessionId = 123(?? 对非 nullish 不回退);handlePs 在 try/catch 之外调用 mergeSessionRows,其中 sanitizeSessionId 对数字执行 .replace:命令以 exit 1 结束、零行输出,连已经取到并验证过的注册表记录也一起丢失,破坏了文档承诺的「降级也要列出」契约。"resumeSessionId": "" 是不崩溃的分支:exit 0、stdout 可解析、"sessionId":"" 与 "name":"" 被当作正常列表行输出。产品自身的写入路径是干净的(requireSessionId 强制字符串);触发者是持久文件的外部写入方。
(证据见上方 probe A2:对照臂两行输出;123 臂 exit 1、零行、TypeError 栈止于本 PR 新增的文件与新增行;空串臂输出一行空 id/空名的「正常」数据;在 normalizeLaunch 中加 stringValue 强制转换后,两种输入都回退到净化目录 id,exit 0、两行。)
建议修复(位于 supervisor-store.ts 的 normalizeLaunch,与本行不同文件,故用普通代码块而非一键 suggestion):在返回对象中加 resumeSessionId: stringValue(raw['resumeSessionId']),,其余字段不变。stringValue 即 typeof value === 'string' && value.length > 0 ? value : undefined(supervisor-store.ts:1029),normalizeLaunch 的其他所有字符串字段都已使用它;修复不得改动存储键控 —— 其唯一事实来源是净化后的目录名(supervisor-store.ts:793-795)。请补一个 ps.test.ts 用例:让 listAgentViewSessionSnapshots 返回一个 launch.resumeSessionId 为 123 as unknown as string 的 managed 快照外加一条注册表记录,断言注册表行仍然打印且 handler 不抛错;再补一个 managed-rows.test.ts 用例,同 fixture 断言 row.sessionId 等于 state.sessionId —— 然后移除该强制转换并确认两个测试变红。
— qwen3.8-max via Qwen Code /review (v0.23.0)
There was a problem hiding this comment.
Still standing at head 294cbe2cc9 — verified end to end by reading, and the crash really is outside the fail-soft boundary. Stays unresolved.
normalizeLaunch(supervisor-store.ts:789-813) spreads...rawand overridessessionId,argv,env,entrypoint,initialPrompt,projectCwd,activeCwd,includeDirectoriesandterminal.resumeSessionIdis not in that list — it is the one launch text field this PR's new consumer reads that escapes coercion.- The consumer takes it with
??(managed-rows.ts:134), so a non-nullish non-string (123) becomes the row'ssessionIdand never falls back tostate.sessionId. readManagedRows' try/catch (ps.ts:155-168) wraps onlymanagedSessionRows, which never callssanitizeSessionId.mergeSessionRowsis called atps.ts:174, and it reachessanitizeSessionId(row.sessionId)atmanaged-rows.ts:334→sessionId.replaceatprotocol.ts:273. Exit 1 with zero rows, including the registry recordslistLiveSessionsalready verified — the documented degrade-and-still-list contract is what breaks.- Empty-string arm confirmed too:
"" ?? xis"", andsanitizeSessionId("")returns'_'(protocol.ts:279), so the row is emitted at exit 0 withsessionId: ""and--jsonconsumers get an entry nothing can act on; as a side effect any record canonicalizing to'_'is also dropped by the:339filter.
The fix is one line and closes both arms: route resumeSessionId through stringValue in normalizeLaunch. That helper returns undefined for a non-string and for an empty string (supervisor-store.ts:1029-1031), and stripUndefined (:872-876) drops it, so ?? falls back to state.sessionId in both cases and the consumer needs no guard of its own.
No code lands this round: the PR is +1582/-78, over this sweep's 1500-addition scope fuse.



What this PR does
qwen sessions psnow lists managed Agent View sessions beside the interactive ones it always listed, and says what each one is doing.The command walked only the live-process registry, which cannot describe the richer lifecycle state kept by an Agent View supervisor. As a result, managed sessions without a live registry entry were invisible, while registered managed workers appeared only as
interactive. This merges both sources into one table: managed sessions first, each labelledneeds input,working,ready,stoppedorfailed, with interactive sessions below them labelledinteractive. A session that is both managed and registered is listed once, as managed.The merge and the labelling live in a new pure module (
managed-rows.ts) so they are testable without a filesystem or a running supervisor; the command keeps the readers.Why it's needed
packages/cli/src/agent-view/is 11,004 lines of production code with no consumer. The supervisor runtime (#7799), the PTY workers (#7800) and the session lifecycle (#7801, re-landed as #9986) are all merged; the two PRs that would give them an entry point — #7802 (commands) and #7803 (roster TUI) — have been open since 2026-07-27, the second at +31,699 lines across 104 files and currently conflicting. Nothing calls the subsystem, so nothing it knows can reach a user, and it has grown by roughly 8,000 lines in the four weeks since it was last measured.This gives it its first consumer, at the smallest surface that carries real information. It is deliberately not a new command: it neither competes with #7802/#7803 nor waits for them, and
sessions psis already the place a user asks "what is running right now".listAgentViewSessionSnapshotsandderiveAgentViewPresentationnow have a caller outside their own directory.Three decisions worth reviewing rather than skimming:
ready,stoppedandfailedinto onecompletedbucket, 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 has no way to see through.deriveAgentViewPresentation, so this listing and the roster cannot drift into describing one session two different ways. ItsUntitled sessionplaceholder is the single override — the roster can afford identical rows because a user arrows onto one, while here the session id is the only thing that tells two of them apart. The placeholder is now an exported constant rather than a literal duplicated across two files; that is the only change topresentation.ts.--jsonstdout parseable.Reviewer Test Plan
How to verify
Unit level, from
packages/cli:npx vitest run src/commands/sessions/ src/agent-view/ --coverage.enabled=false→ 17 files, 364 tests passing. 12 new cases inmanaged-rows.test.tscover the state labels (including that a failed session is never reported asready), the worker-then-host pid fallback, a missing pid staying absent rather than becoming0, the title precedence and its id fallback, an unparseablecreatedAt, the managed-first ordering, the dedupe, and two records sharing one session id. 8 new cases inps.test.tscover a managed session the registry cannot see, the ordering in the rendered table, themanageddiscriminator in--json, the-placeholders, and the three properties of a store failure: interactive rows still listed, stdout still parseable, and the reason sanitized before it reaches the terminal.eslintandprettier --checkare clean on every changed file.Behaviourally, with no supervisor running the output is unchanged except for the new
STATEcolumn readinginteractive, and--jsongainingmanaged: false. To see the managed half before #7802 lands a dispatcher, write a state file by hand under$QWEN_HOME/jobs/<id>/(sessionState: "needs_input",ownership: "managed") with a roster entry in$QWEN_HOME/daemon/roster.json, and runqwen sessions ps.Evidence (Before & After)
Before:
After, with one managed session waiting for an answer:
Both are the shapes the unit tests pin; the
Afterrow is not a live capture, because nothing dispatches a managed session until #7802 lands.Tested on
Unit tests only, on Linux.
npx tsc --noEmitandnpm run buildwere not run: the machine this was written on cannot complete either. Types and build need CI or a second machine.Environment (optional)
Linux, vitest only.
Risk & Scope
--jsonshape changes. Interactive rows gainmanaged: false— additive for ajqconsumer selecting fields, breaking for one comparing whole objects. The alternative (marking only managed rows, so absence means interactive) is less work for us and more work for every consumer, so the discriminator is on both kinds. The table also gains a column, which shiftsDIRECTORYright; the existing column-offset test is updated rather than removed.--json, see the risk above; the documented field lists are updated.Linked Issues
Gives a first consumer to the subsystem merged by #7799, #7800 and #7801/#9986. Related to #7802 and #7803, which it neither blocks nor depends on.
中文说明
这个 PR 做了什么
qwen sessions ps现在会把 Agent View 的 managed session 和它一直在列的 interactive session 一起列出,并说明每一个正在做什么。这个命令此前只走 live-process registry,无法描述 Agent View supervisor 保存的更丰富生命周期状态。因此,没有存活注册记录的 managed session 完全不可见;已经注册的 managed worker 也只会被标成
interactive。本 PR 把两个来源合并成一张表:managed 排在前面,各自标注needs input、working、ready、stopped或failed;interactive 排在下面,标注interactive。同时既是 managed 又已注册的 session 只列一次,按 managed 列。合并与标注逻辑放在一个新的纯函数模块(
managed-rows.ts)里,因此无需文件系统或运行中的 supervisor 即可测试;读取仍留在命令里。为什么需要
packages/cli/src/agent-view/有 11,004 行生产代码,却没有任何消费者。supervisor runtime(#7799)、PTY workers(#7800)、session lifecycle(#7801,由 #9986 重新落地)都已合并;而能给它们提供入口的两个 PR —— #7802(命令)和 #7803(roster TUI)—— 自 2026-07-27 起一直开着,后者 +31,699 行、104 个文件,且当前处于冲突状态。没有任何代码调用这个子系统,因此它掌握的信息也就无法抵达用户;距上次测量后的四周里,它又长了约 8,000 行。本 PR 以「能承载真实信息的最小表面」给了它第一个消费者。刻意不新增命令:既不与 #7802/#7803 抢位,也不等待它们,而
sessions ps本来就是用户问「现在有什么在跑」的地方。listAgentViewSessionSnapshots和deriveAgentViewPresentation现在终于有了目录外的调用者。有三处判断值得细看而非略过:
ready、stopped、failed折叠进同一个completed桶,roster UI 之所以负担得起,是因为它还会绘制图标色调。单行表格没有第二条通道,在一个失败的 session 旁边打上「completed」,是用户无从看穿的谎言。deriveAgentViewPresentation,因此本列表与 roster 不会对同一个 session 给出两种说法。它的Untitled session占位符是唯一的例外:roster 可以容忍完全相同的行,因为用户可以用方向键选中其中一行;而在这里,session id 是唯一能区分两者的东西。该占位符现已改为导出常量,而不是在两个文件里各写一份字面量 —— 这也是本 PR 对presentation.ts的唯一改动。--json的 stdout 保持可解析。评审者测试计划
如何验证
单测,在
packages/cli下:npx vitest run src/commands/sessions/ src/agent-view/ --coverage.enabled=false→ 17 个文件、364 个测试通过。managed-rows.test.ts中 12 个新用例覆盖状态标签(含「失败的 session 绝不会被报成ready」)、worker 优先其次 host 的 pid 回退、缺失 pid 保持缺失而不变成0、标题优先级及其 id 回退、无法解析的createdAt、managed 优先的排序、去重,以及两条记录共用同一个 session id 的情形。ps.test.ts中 8 个新用例覆盖注册表看不见的 managed session、渲染表格中的排序、--json里的managed判别字段、-占位符,以及存储读取失败的三条性质:interactive 行仍然列出、stdout 仍可解析、原因在抵达终端前已被净化。所有改动文件的
eslint与prettier --check均干净。行为上,在没有 supervisor 运行时,输出与此前一致,只多出
STATE列显示interactive,--json多出managed: false。若想在 #7802 落地调度器之前看到 managed 那一半,可手工在$QWEN_HOME/jobs/<id>/下写一个 state 文件(sessionState: "needs_input"、ownership: "managed"),并在$QWEN_HOME/daemon/roster.json里写一条 roster entry,然后运行qwen sessions ps。证据(前后对比)
之前:
之后,含一个正在等待回答的 managed session:
两者都是单测所钉住的形状;
之后那一行不是实时截取,因为在 #7802 落地前没有任何东西会调度出 managed session。测试环境
仅 Linux 上的单元测试。
npx tsc --noEmit与npm run build未运行:撰写本 PR 的机器无法完成其中任何一个。类型检查与构建需要 CI 或另一台机器。运行环境(可选)
Linux,仅 vitest。
风险与范围
--json的形状发生变化。interactive 行新增managed: false—— 对按字段取值的jq消费者是增量变更,对整对象比较的消费者则是破坏性变更。另一种做法(只标记 managed 行,以「缺失即 interactive」)对我们更省事、对每个消费者更费事,因此判别字段两种行都带。表格也多了一列,DIRECTORY会右移;现有的列偏移测试是被更新而非删除。--json见上述风险;文档中的字段列表已同步更新。关联 Issue
为 #7799、#7800 与 #7801/#9986 合并的子系统提供了第一个消费者。与 #7802、#7803 相关,但既不阻塞它们,也不依赖它们。