Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
dbe9950
feat(cli): list managed Agent View sessions in qwen sessions ps
yiliang114 Sep 3, 2026
8879852
docs(skills): stop the recency gate from silencing a stalled wiring
yiliang114 Sep 3, 2026
9b14578
fix(cli): keep non-owned supervisor snapshots out of sessions ps
yiliang114 Sep 3, 2026
94610b3
fix(cli): verify managed session pids before listing them
yiliang114 Sep 3, 2026
3e3dac5
test(cli): pin LF and TAB stripping on the ps store-failure note
yiliang114 Sep 3, 2026
4d84fbc
docs(commands): warn that the jq -r example renders session text raw
yiliang114 Sep 3, 2026
6b2c711
fix(cli): keep the sessions ps JSON contract off display wording
yiliang114 Sep 4, 2026
cd68e10
fix(cli): import the task-state type the row test annotates
yiliang114 Sep 4, 2026
853d57b
fix(cli): hold managed session rows to the registry's identity standard
yiliang114 Sep 4, 2026
d02752f
test(cli): give ps.test.ts the identity helpers its subject now calls
yiliang114 Sep 4, 2026
558bd25
fix(cli): correct sessions ps comment claims and pin every STATE label
yiliang114 Sep 4, 2026
32295e3
chore: merge main into feat/agent-view-first-consumer
yiliang114 Sep 4, 2026
b5cf6d9
fix(cli): finish sessions ps review cleanup
yiliang114 Sep 4, 2026
eb7642b
fix(cli): guard sessions ps managed pids by boot id like the registry
yiliang114 Sep 4, 2026
79ca2fc
fix(cli): report the resumable session id spelling in sessions ps
yiliang114 Sep 4, 2026
0783984
fix(cli): carry a deduped record's spelling and pid onto a degraded m…
yiliang114 Sep 5, 2026
075b47d
Merge main into feat/agent-view-first-consumer
yiliang114 Sep 5, 2026
294cbe2
fix(cli): refuse managed pids from a worker file written on another OS
yiliang114 Sep 5, 2026
3b772f1
Merge origin/main (00fe690482) into feat/agent-view-first-consumer
yiliang114 Sep 6, 2026
7c3e095
Merge origin/main into feat/agent-view-first-consumer
yiliang114 Sep 7, 2026
9d891f9
docs: normalize the sessions table padding prettier asks for
yiliang114 Sep 7, 2026
662923e
Merge origin/main into feat/agent-view-first-consumer
yiliang114 Sep 9, 2026
38e77c9
fix(cli): validate resumeSessionId before it becomes a row identity
yiliang114 Sep 19, 2026
10d09db
Merge remote-tracking branch 'origin/main' into pr-10942-sweep
yiliang114 Sep 19, 2026
83b98c1
fix(cli): import process-liveness and session-registry symbols from t…
yiliang114 Sep 19, 2026
8a95fe3
test(cli): intercept the deep process-liveness module in the sessions…
yiliang114 Sep 19, 2026
16c5251
fix(cli): carry the deduped record's name and cwd, and gate resumeSes…
yiliang114 Sep 19, 2026
9a0ca98
docs(skills): key the stalled-wiring probe on the directory basename
yiliang114 Sep 19, 2026
794fcba
Merge origin/main into feat/agent-view-first-consumer
yiliang114 Sep 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .qwen/skills/find-simplifications/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ the issue comment.
| Several strong candidates in one run | File them all; land at most one |
| Report-only territory | File with evidence, marked report-only. Never a PR |
| Path or symbol younger than ~90 days | Drop **silently** — unwired new feature, not rot |
| A young **subsystem** unwired, its wiring PR open and stale | Not a deletion. Report the stall to that PR stack |
| A never-called migrator, validator, guard, or dropped wire-up | Not cleanup. It may be a defect → `/bugfix` or `/review` |
| Any consumer cannot be named | Drop |
| Correct but tiny (one dead import, a typo) | Reject on the ledger: below both skills' intake bar |
Expand Down
53 changes: 47 additions & 6 deletions .qwen/skills/find-simplifications/references/survey.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,33 @@ feature someone is still wiring up. (90 days is a heuristic, not a measured
threshold; widen it for a large subsystem.) Proposing deletion of something
that landed last week is the fastest way to lose a reviewer for good.

But "young" is not the fact the gate is reaching for — "still being wired
up" is, and the two come apart. A directory can keep growing for months
while its consumer never lands, and then every re-run of this survey dates
it young again and drops it again, which is how a subsystem gets to five
figures with nothing calling it. So when the gate fires on a **subsystem**
(not a single symbol), date the wiring rather than the code:

```bash
# has anything outside it ever referenced it? The two spellings differ on
# purpose: `<dir>` is the full repo-relative path in the glob, while the
# pattern is only its last segment, because an import specifier spells
# '../agent-view/x.js' and never 'packages/cli/src/agent-view/'. Classify
# the hits — a bare segment also reaches same-named directories elsewhere,
# and cross-package consumers spell the package export subpath, so § 4's
# corpus grep stays the authoritative consumer check.
"$RG" -l '/<basename>/' --glob '!<dir>/**' packages
# and are the PRs that would wire it still open?
gh pr list --repo QwenLM/qwen-code --search '<feature> in:title' --state all
```

Recent commits inside the directory plus no external reference plus an open,
stalled wiring PR is **not** a deletion candidate and **not** a drop. It is a
different finding with a different owner: the wiring stalled. Report it to
that stack — the merged infrastructure has no entry point — and never as a
deletion PR. The code is wanted; the review that would connect it is what is
missing.

**3 — Published-surface escape.** Is the surface reachable from outside the
repo? SKILL.md § Territory is the authoritative list; keep the two in step.
Everything under `packages/core/src` is, via that package's `"./src/*"`
Expand Down Expand Up @@ -262,13 +289,27 @@ registry entry), not in `packages/core`, and `git log` puts it well past the
recency gate. Deletion is the component, its test, and its snapshot entry, in
one commit. This is what a filed candidate should look like.

**2. Everything says dead; `git` says five days old.** `packages/cli/src/agent-view`
is ~6,000 lines whose entry flag is passed to a spawned process but parsed
nowhere; every static signal calls it rot. Then:
**2. Everything says dead; `git` says five days old — and says it again a
month later.** `packages/cli/src/agent-view` was ~6,000 lines whose entry flag
is passed to a spawned process but parsed nowhere; every static signal called
it rot. Then:
`git log --follow --diff-filter=A --format=%ad --date=short -- packages/cli/src/agent-view
| tail -1` → `2026-08-01`, five days before HEAD. It is a feature mid-wiring.
**Drop silently.** Do not file it, do not mention it — a "should we delete
your new subsystem?" question costs more trust than the finding is worth.
| tail -1` → `2026-08-01`, five days before HEAD. A feature mid-wiring, so:
**do not file a deletion.** A "should we delete your new subsystem?" question
costs more trust than the finding is worth.

What this example originally got wrong was the second half — "do not mention
it". Measured again on 2026-09-04: **11,004 production lines, still zero
external references**, up ~8,000 in four weeks. The five merged PRs of its
Comment on lines +302 to +303

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R12-2: [certifies-falsely] [new-surface] The stalled-wiring rule this commit adds can never fire again for its own worked example, and the example's evidence sentence ("still zero external references") is falsified by the very commit that ships it.

The rule's second conjunct is "no external reference" (:162-163), and the block above it now works — the repaired probe (R10-1, fixed in 9a0ca988cc) keyed on the directory basename returns this PR's own consumers. So for packages/cli/src/agent-view the conjunction is permanently false post-merge, the mandated stall report is unreachable, and the only documented outcomes left are silent drops (:137 "drop silently", :193 "Any production consumer → drop") — reinstating the exact behaviour this hunk calls the bug, while the fact situation (11,118 production lines, no entry point, two open conflicting wiring PRs) still holds. The worked example simultaneously ships a measurement ("still zero external references") that is false in the tree that ships it.

Failure scenario: a post-merge survey reaches the recency gate on this subsystem, the probe returns ps.ts/managed-rows.ts as production consumers, the three-conjunct test fails, and the agent drops silently — nothing ever says out loud that a five-figure subsystem with no entry point exists, which :307-310 names as the failure the rule exists to stop.

Witness (executed on both arms):

BASE (c1c00cbaab): 0 genuine external references to packages/cli/src/agent-view
PR    (794fcbad45): "$RG" -l '/agent-view/' --glob '!packages/cli/src/agent-view/**' packages
  → 7 files, 4 genuine: ps.ts:41,47 + managed-rows.ts:43,47,52 (+ their tests)
entry-point sweep: no agent-view flag in config.ts/settingsSchema.ts
gh pr list: 7802 OPEN 2026-07-27, 7803 OPEN 2026-07-27 (both CONFLICTING)
production lines: 11,118

Suggested fix: restate the precondition as the fact the rule reaches for — an entry point, not any reference ("nothing outside it that gives a user a way in") — and add the partial-consumer branch (a read-only listing is a reference and still not an entry point). Date/qualify the example: "still zero external references as of 2026-09-04; qwen sessions ps (PR 10942) became the first out-of-directory consumer … still not an entry point".

The reworded gate must keep the deletion verdict dropped once a production consumer exists (:193) — only the stall report may survive the reference. Acceptance: N/A (instruction prose; no test can pin it) — the witness above is the acceptance check a future round can re-run.

中文说明

[Critical] R12-2:本次提交新增的"接线停滞"规则,对它自己引用的示例目录将永远无法再触发;而示例中的证据句("仍然零外部引用")恰恰被发布它的这个提交证伪。

规则的第二个合取项是"无外部引用"(:162-163),而其上方的探测块现在能正常工作了——修复后的探针(R10-1,由 9a0ca988cc 修复)按目录 basename 匹配,会返回本 PR 自己的消费者。因此对 packages/cli/src/agent-view 而言,合并之后该合取式永久为假,规则强制要求的停滞报告不可达,剩下的唯一文档化结局就是静默丢弃(:137 "drop silently"、:193 "Any production consumer → drop")——恰恰恢复了这一段称之为 bug 的行为,而事实状态(11,118 行生产代码、无入口、两个开放且冲突的接线 PR)依然成立。同时,示例发布了一句在发布它的代码树里为假的测量结论("仍然零外部引用")。

**故障场景:**合并后的一次巡检对该子系统走到时效门,探针返回 ps.ts/managed-rows.ts 作为生产消费者,三合取判定失败,代理静默丢弃——再也不会大声说出"存在一个五位数行数、没有入口的子系统",而这正是 :307-310 声明该规则要阻止的失败。

**证据(在两侧分支上实际执行):**见上方英文 witness 代码块(BASE 0 个真实外部引用;PR 侧 7 个文件、4 个真实引用;入口 flag 扫描为零;7802/7803 均 OPEN 且冲突;生产行数 11,118)。

**建议修复:**把前置条件改述为规则真正想说的事实——入口,而不是任何引用("外面没有任何东西能给用户一个进入方式"),并补充部分消费者分支(只读列表算引用,但仍不是入口)。给示例加上日期/限定:"截至 2026-09-04 仍零外部引用;qwen sessions ps(PR 10942)成为第一个目录外消费者……仍不是入口"。

改述后的判定必须保持:一旦存在生产消费者,删除结论仍为 drop(:193)——只有停滞报告可以越过引用存在。验收:N/A(指令文本,无测试可钉)——上方 witness 即未来轮次可重跑的验收检查。

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verified at head 794fcbad45: REAL on both halves — the conjunct self-disables for its own example, and the example's measurement sentence is false in the tree that ships it.

  • The rule: "Recent commits inside the directory plus no external reference plus an open, stalled wiring PR is not a deletion candidate and not a drop" (survey.md:162-163).
  • The probe above it is the basename-keyed one from 9a0ca988cc, which I confirmed is real and on this branch: git log --oneline -1 9a0ca988cc → "docs(skills): key the stalled-wiring probe on the directory basename". The probe is survey.md:150-155: "$RG" -l '/<basename>/' --glob '!<dir>/**' packages.
  • That pattern matches this PR's own imports on this head. /agent-view/ occurs at ps.ts:41 ('../../agent-view/supervisor-store.js'), ps.ts:47, managed-rows.ts:43, :47, :52, plus the two test files. So for packages/cli/src/agent-view the second conjunct is permanently false post-merge, the mandated stall report is unreachable, and the only outcomes the text leaves are :137 "drop silently" and :193 "Any production consumer → drop" — the behaviour this hunk calls the bug.
  • The falsified sentence is survey.md:302-303: "Measured again on 2026-09-04: 11,004 production lines, still zero external references". I did not re-measure the line count, so your 11,118 versus the doc's 11,004 stays unverified on my side; the zero-references half is what I checked, and it is false here.
  • The rest of the example still holds, which is why silent drop is the wrong outcome: gh pr list --repo QwenLM/qwen-code --search 'agent-view in:title' --state open returns feat(cli): Expose agent view commands #7802 "Expose agent view commands" and feat(cli): Add agent view roster UI #7803 "Add agent view roster UI" as of this round, both created 2026-07-27.
  • One narrowing in your favour rather than against: this PR's consumer is a read-only listing, not an entry point. packages/cli/src/commands/agent-view/ does not exist on this head, and the only out-of-directory importers of agent-view/ in packages/cli/src and packages/core/src are ps.ts, managed-rows.ts and ui/layouts/DefaultAppLayout.tsx (the in-TUI panel). So the fact situation the rule exists to report is unchanged — which is exactly why the conjunct should read "nothing outside it that gives a user a way in" rather than "no external reference", with the partial-consumer branch you describe.

No code this round: it is prose in a file this PR already touches (+53 lines), so it is the second-cheapest of the five, but the round is code-frozen at +1852/-75 pending the maintainer scope ruling, and rewording the gate changes what every future survey reports — that belongs in the ruling rather than in a sweep pass. Not resolving: as written the example ships a measurement the same commit falsifies.

stack shipped the supervisor, the PTY workers and the lifecycle; the two that
would give it an entry point (#7802 commands, #7803 roster TUI) have been
open since 2026-07-27, the second at +31,699 lines across 104 files and
conflicting. The recency gate kept firing and the survey kept dropping it, so
nothing ever said out loud that a five-figure subsystem had no caller.

The deletion verdict was right and stays right. The silence was not: this is
the stalled-wiring finding above, owed to that PR stack, not to a cleanup
list.

**3. The naive count is wrong in both directions.**
`eslint.legacy-filenames.mjs` lists 559 bare basenames. Checking "does a
Expand Down
71 changes: 56 additions & 15 deletions docs/users/features/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -715,11 +715,11 @@ These commands are run from the shell as `qwen <subcommand>` before starting an

### Session Management

| Command | Description | Usage Examples |
| --------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------- |
| `qwen sessions list` | List recent conversation sessions | `qwen sessions list`, `qwen sessions list --json --limit 50` |
| `qwen sessions ps` | List interactive sessions running right now | `qwen sessions ps`, `qwen sessions ps --json` |
| `qwen sessions controllers` | Manage trusted controller tokens | `qwen sessions controllers add --label <name>`, `qwen sessions controllers list` |
| Command | Description | Usage Examples |
| --------------------------- | ----------------------------------- | -------------------------------------------------------------------------------- |
| `qwen sessions list` | List recent conversation sessions | `qwen sessions list`, `qwen sessions list --json --limit 50` |
| `qwen sessions ps` | List the sessions running right now | `qwen sessions ps`, `qwen sessions ps --json` |
| `qwen sessions controllers` | Manage trusted controller tokens | `qwen sessions controllers add --label <name>`, `qwen sessions controllers list` |

#### `qwen sessions list`

Expand Down Expand Up @@ -761,11 +761,20 @@ qwen sessions list --json | jq .

#### `qwen sessions ps`

Lists the Qwen Code sessions registered on this machine right now.
Lists the Qwen Code sessions running on this machine right now.
`sessions list` walks saved transcripts ("what have I worked on"); this
walks the live-process registry ("what is running at this moment").
Records left behind by a killed session are swept as they are found. A
one-shot `qwen -p` run never registers, so it is never shown.
answers "what is running at this moment".

Two kinds of session are listed. An **interactive** session is one you
started in a terminal; it registers in the live-process registry, and
records left behind by a killed session are swept as they are found. A
**managed** session is an Agent View session owned by a supervisor: it
Comment thread
yiliang114 marked this conversation as resolved.
stores richer lifecycle state in the supervisor store, which the registry
alone cannot provide. A session present in both sources is listed once, as
managed. Managed sessions are listed first, because one of them may be
waiting for an answer.

Headless sessions (`qwen -p`) register nowhere and are not shown.

**Flags:**

Expand All @@ -775,7 +784,7 @@ one-shot `qwen -p` run never registers, so it is never shown.

**Human-readable output (default):**

A table with columns: NAME, KIND, PID, AGE, DIRECTORY.
A table with columns: NAME, KIND, PID, AGE, STATE, DIRECTORY.

KIND says what registered the session — `tui` for someone at a terminal,
`external` for a program that is not a Qwen Code session at all (a voice
Expand All @@ -785,22 +794,48 @@ program drives. Several `serve` or `headless` rows can share one PID: a
the daemon spawned it, `headless` when a client is driving it directly —
and each of them registers separately. It is a self-report, like NAME and DIRECTORY: every field
here was written by the process it describes, and nothing about what a
session is allowed to do depends on it. See
session is allowed to do depends on it. A managed session has no registry
record behind it, so nothing registered it and its KIND reads `managed`
rather than borrowing a word some registrant wrote. See
[Cross-Session Protocol](./cross-session-protocol.md) for the record
format and for how to register a program of your own.

STATE is `interactive` for a session you started yourself. For a managed
session it is what that session is actually doing — `needs input`,
`working`, `ready`, `stopped` or `failed`. Those are display labels; the
`--json` output carries stable tokens instead (see below). PID and AGE print `-` for a
managed session only when their own source value is unavailable: PID when
there is no live worker process, and AGE when the creation stamp is unusable.

**JSON output (`--json`):**

Outputs JSON Lines on stdout, newest session first. Each line is a JSON
object with fields:
Outputs JSON Lines on stdout, managed sessions first. Every line carries
a `managed` field saying which kind it is.

An interactive session is emitted as its whole registry record, plus
`managed: false`:

```
schemaVersion, pid, procStart, pidNs, sessionId, cwd, name, startedAt,
qwenVersion, kind, ipcPath (when peer messaging is available)
qwenVersion, kind, ipcPath (when peer messaging is available), managed
```

A managed session is emitted in the managed row shape, even when the same
session also has a registry record:

```
name, pid, startedAt, cwd, taskState, sessionId, managed
```

`taskState` is the machine-readable form of the STATE column, and is the
field to script against: `running`, `waiting`, `ready`, `stopped` or
`failed`. The column's wording can change; these tokens will not.

Nothing else is written to stdout — an empty listing prints nothing at
all — so `qwen sessions ps --json | jq .` is safe to script against.
all — so `qwen sessions ps --json | jq .` is safe to script against. If
the supervisor's own bookkeeping cannot be read, the interactive half is
still listed and the reason is reported on stderr, which leaves stdout
parseable.

JSON output is raw data: field values are emitted exactly as recorded,
with no terminal sanitization. Treat them as data, and sanitize before
Expand All @@ -816,6 +851,12 @@ qwen sessions ps
# Note: `jq -r` renders the raw recorded value in your terminal (see the
# raw-data note above); pipe through a sanitizer if the path is untrusted.
qwen sessions ps --json | jq -r .cwd

# Which background sessions are waiting on me?
# Note: `jq -r` renders the raw recorded value in your terminal (see the
# raw-data note above); a session name is session-generated text, so pipe
# through a sanitizer if it is untrusted.
qwen sessions ps --json | jq -r 'select(.taskState == "waiting") | .name'
```

## 6. Messaging Another Running Session
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/agent-view/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ import type {
AgentViewSessionStateFile,
} from './protocol.js';

/**
* Title shown for a session that has produced nothing to name it by.
*
* Exported because a caller that cannot afford an unidentifiable row —
* a one-line listing, where there is nothing to arrow onto — has to be
* able to recognize the placeholder and substitute the session id.
*/
export const AGENT_VIEW_UNTITLED_TITLE = 'Untitled session';

export type AgentViewTaskState =
| 'running'
| 'waiting'
Expand Down Expand Up @@ -326,7 +335,7 @@ function deriveTitle(
cleanText(rosterEntry?.displayName) ??
cleanRuntimeSummary(activity?.summary) ??
cleanText(launch?.initialPrompt) ??
'Untitled session'
AGENT_VIEW_UNTITLED_TITLE
);
}

Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/agent-view/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/

import * as path from 'node:path';

export const AGENT_VIEW_PROTOCOL_VERSION = 1;

export type AgentViewOwnership =
Expand Down Expand Up @@ -115,6 +117,28 @@ export interface AgentViewWorkerFile {
schemaVersion: 1;
hostPid?: number;
workerPid?: number;
/**
* Process start tokens for the pids above, and the PID namespace they
* were recorded in.
*
* A pid number does not identify a process. Nothing reaps this store
* while no supervisor runs, so a crash or a reboot leaves recorded pids
* behind that the OS later recycles to unrelated processes; and a
* shared `~/.qwen` — an NFS home, a devcontainer with the home mounted
* — puts another machine's pids in front of a reader who would resolve
* them in its own namespace. A reader pairs these with `isSameProcess`
* to answer the question the live-session registry already answers for
* its own records.
*
* All three are optional on purpose: this is a durable
* `schemaVersion: 1` record, and a file written before these fields
* existed must stay readable. A reader that finds them absent degrades
* to a bare liveness check — the same rule `isSameProcess` applies on a
* platform that has no start token.
*/
hostProcStart?: string | null;
workerProcStart?: string | null;
pidNs?: number | null;
endpoint?: string;
hostEndpoint?: string;
hostAuthToken?: string;
Expand Down Expand Up @@ -232,3 +256,25 @@ export type AgentViewWorkerAnswerOutcome =
| 'modify_with_editor'
| 'restore_previous'
| 'cancel';

/**
* The directory name a session id is filed under.
*
* This lives with the on-disk shapes rather than with the store because
* it *is* one: it defines the identity two readers must agree on. The
* store files a session under this name and reports it back as the
* session's id, while the live-session registry keeps the raw spelling
* the worker registered with — adoption deliberately keeps both, because
* the native session store is case-sensitive. Anything that joins the two
* sources has to canonicalize through this one function, or a mixed-case
* session is two sessions to whichever half is comparing raw strings.
*/
export function sanitizeSessionId(sessionId: string): string {
const safe = path
.basename(sessionId.replace(/\\/g, '/'))
.toLowerCase()
.replace(/^\.+/g, '_')
// eslint-disable-next-line no-control-regex
.replace(/[<>:"|?*\x00-\x1F]/g, '_');
return safe || '_';
}
50 changes: 42 additions & 8 deletions packages/cli/src/agent-view/supervisor-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import {
DEFAULT_AGENT_VIEW_ATTACH_LEASE_TTL_MS,
} from './attach-lease.js';
import type { AgentViewAttachLease } from './attach-lease.js';
import {
readPidNamespaceId,
readProcStartToken,
} from '@qwen-code/qwen-code-core/utils/process-liveness.js';
import { AGENT_VIEW_PROTOCOL_VERSION } from './protocol.js';
import type {
AgentViewActivityFile,
Expand Down Expand Up @@ -80,6 +84,40 @@ import {
getAgentViewActivityInputState,
} from './presentation.js';

/**
* The pid fields of a worker file, with the identity that makes them
* trustworthy to a later reader.
*
* Recording the numbers alone is not enough. Nothing reaps this store
* while no supervisor runs — `clearAgentViewWorkerPids` does not get to
* run after a SIGKILL or a reboot — so the durable file keeps naming pids
* the OS is free to hand to unrelated processes, and a `~/.qwen` shared
* between machines or namespaces offers pids that mean nothing here. A
* reader pairing these tokens with `isSameProcess` gets the same evidence
* standard the live-session registry already holds its own records to,
* instead of a bare `kill(pid, 0)`.
*
* Reading the token can fail (the process exits between spawn and this
* call, `/proc` is unreadable, the platform has none); `null` then records
* "no identity available", which readers degrade to a liveness check
* rather than treating as a mismatch.
*/
function workerPidIdentity(host: { pid: number; workerPid: number }): {
hostPid: number;
workerPid: number;
hostProcStart: string | null;
workerProcStart: string | null;
pidNs: number | null;
} {
return {
hostPid: host.pid,
workerPid: host.workerPid,
hostProcStart: readProcStartToken(host.pid),
workerProcStart: readProcStartToken(host.workerPid),
pidNs: readPidNamespaceId(),
};
}

function resolveSessionCwd(cwd: string): string {
try {
return fs.realpathSync(cwd);
Expand Down Expand Up @@ -470,8 +508,7 @@ class AgentViewSupervisorProcessHandler
result.sessionId,
{
schemaVersion: 1,
hostPid: host.pid,
workerPid: host.workerPid,
...workerPidIdentity(host),
...(host.hostId ? { hostId: host.hostId } : {}),
...(host.endpoint ? { hostEndpoint: host.endpoint } : {}),
...(host.authToken ? { hostAuthToken: host.authToken } : {}),
Expand Down Expand Up @@ -703,8 +740,7 @@ class AgentViewSupervisorProcessHandler
adoption.sessionId,
{
schemaVersion: 1,
hostPid: host.pid,
workerPid: host.workerPid,
...workerPidIdentity(host),
...(host.hostId ? { hostId: host.hostId } : {}),
...(host.endpoint ? { hostEndpoint: host.endpoint } : {}),
...(host.authToken ? { hostAuthToken: host.authToken } : {}),
Expand Down Expand Up @@ -2322,8 +2358,7 @@ class WorkerRegistry {
sessionId,
{
schemaVersion: 1,
hostPid: host.pid,
workerPid: host.workerPid,
...workerPidIdentity(host),
...(host.hostId ? { hostId: host.hostId } : {}),
hostEndpoint: worker.hostEndpoint,
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
Comment on lines 2360 to 2363

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R7-2: [certifies-falsely] [new-surface] (still standing) 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 own 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.

Re-ruled at head 9d891f90: still stands. No code landed against it (every source file this PR touches is byte-identical to the head this entry was filed at — git diff 3b772f1e60..HEAD --unified=0 reports 0 hunks for supervisor-process.ts), and a territory auditor this round independently re-derived it by walking all four workerPidIdentity call sites against the provenance of each site's host: :508, :740 and :2556 are local launchPtyHostForSupervisor handles, and only :2358 stamps a connectAgentViewPtyHostProcess handle whose pid/workerPid come from the host's self-report over the socket (pty-host-process.ts:186-197, status.pid/status.workerPid) — numbers assigned by the host's PID namespace, not the reconnecting supervisor's.

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.

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 — there is 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.

The witness recorded on the original thread still stands, and the mis-stamping is permanently stable rather than transient:

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).
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 this site only):
         launcher's identity preserved (pidNs = 4026531837); row = no pid
Declared model: the real container topology was not exercised - the machine
cannot create a PID namespace.

Omitting the identity spread at this site suffices, because writeAgentViewWorker merges {...existing, ...worker} (supervisor-store.ts:517-533): the original launcher's identity — valid in its namespace, describing the same live processes — survives and the namespace guard keeps working, along with the pids the merge already holds. Alternatively, have the host's status reply report its own namespace id and tokens (evaluated inside the host) 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. And the pids themselves must still be written: supervisor-process.ts:505-507 records that they are persisted right after spawn, before any store I/O, because a crash before the ready wait must not leave an unsignalable orphan host holding the deterministic session socket. 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.

中文说明

[Critical] R7-2:[certifies-falsely]new-surface重连写入点会用「已在运行的 PTY host 通过 socket 自报的 pid」重新盖章 worker 文件的 pid 身份,而 workerPidIdentity 盖的是重连方 supervisor 自己的 readPidNamespaceId(),并从它自己的 /proc 读取这些编号的 readProcStartToken —— 为它从未在自己命名空间里验证过存活的 pid 背书 —— 且 merge 式写入会覆盖启动方原本正确的外来 pidNs

在本轮 head 9d891f90 重新裁定:依然存在。没有代码修复落地(本 PR 触及的每个源文件与该条目提出时的 head 逐字节相同 —— git diff 3b772f1e60..HEAD --unified=0supervisor-process.ts 报告 0 个 hunk),且本轮一个区域审计 agent 通过把四个 workerPidIdentity 调用点与各自 host 的来源逐一对照,独立重新推导出该问题::508:740:2556 是本地 launchPtyHostForSupervisor 句柄,只有 :2358 盖的是 connectAgentViewPtyHostProcess 句柄,其 pid/workerPid 来自 host 通过 socket 的自报(pty-host-process.ts:186-197,status.pid/status.workerPid)—— 这些编号由 host 的 PID 命名空间分配,而非重连方 supervisor 的命名空间。

三个 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:pid 1 与 pid 2 的错误 token 完全相同,因为内核线程共享 starttime)。

在此处省略身份 spread 即可,因为 writeAgentViewWorker{...existing, ...worker} 合并(supervisor-store.ts:517-533):启动方原本的身份(在其命名空间内有效、描述同一批存活进程)以及合并中已有的 pid 都会保留,命名空间守卫继续有效。或者让 host 的 status 应答报告自己的命名空间 id 与 token(在 host 内部求值),仅当其与 readPidNamespaceId() 一致时才盖身份。

修复不得用显式 null 覆盖已记录的有效 token:normalizeWorker 会把缺失的 token 字段规范化为 null(supervisor-store.ts:891-893),所以「省略字段」而非「写 null」才是保留原身份的形态。pid 本身仍必须写入:supervisor-process.ts:505-507 记录了它们在 spawn 之后、任何存储 I/O 之前就持久化,因为在 ready 等待之前崩溃不能留下一个持有确定性会话 socket 却无法被信号触达的孤儿 host。请补一个 supervisor-process.test.tsreconnectSessionHost 用例,断言重连后的 worker 文件保留原写入方的 pidNs/hostProcStart/workerProcStart(或不含身份字段),而不是重连进程自己的;然后在同一位置恢复无条件的 ...workerPidIdentity(host) 并确认该测试变红。

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verdict at head 662923e5b0 (packages/cli/src/agent-view/supervisor-process.ts:2363): REAL - still stands. (The write site is :2356-2370 at this head; the workerPidIdentity spread is :2361.)

I read the chain at this head rather than re-running the previous round's reasoning. Evidence, with the lines I actually opened:

  • workerPidIdentity stamps tokens read from the caller's /proc and the caller's own namespace: supervisor-process.ts:105-119 - hostProcStart: readProcStartToken(host.pid), workerProcStart: readProcStartToken(host.workerPid), pidNs: readPidNamespaceId().
  • On the reconnect path those two pids are self-reported over the socket by an already-running PTY host: reconnectSessionHostLocked calls connectAgentViewPtyHostProcess (supervisor-process.ts:2348-2354), which builds the handle purely from waitForPtyHost's status reply (pty-host-process.ts:170-199; the values are read at :1012-1027, including pid: Number.isInteger(result['pid']) ? Number(result['pid']) : process.pid). Nothing verifies them live in the reconnecting supervisor's namespace.
  • The write is a merge that replaces the launcher's identity: writeAgentViewWorker does {...existing, ...worker} (supervisor-store.ts:517-533), and the literal at supervisor-process.ts:2357-2369 sets pidNs (via workerPidIdentity) and platform: process.platform explicitly, so a foreign pidNs the launcher recorded is overwritten with ours.
  • The three spawn sites are not equivalent, which is why only reconnect is exposed: there host.pid is child.pid from a local spawn (pty-host-process.ts:159), writes at supervisor-process.ts:511, :743, :2559.
  • The consumer this PR adds trusts exactly those fields: liveWorkerPid gates on worker.platform (managed-rows.ts:213), worker.pidNs (:216-220) and the boot prefix of workerProcStart/hostProcStart (:230-235) to decide whether to print a pid a user or a script may kill.

Not fixed this round, on purpose. This PR is at +1628/-75, past the +1500-addition scope fuse this closeout pass runs under, so this round is reply-only: no code, no commit, no push. The remedy below needs the owning maintainer's decision (part of it is a store-schema or identity-model call, not a local edit), so the thread stays open.

Proposed fix shape (proposal only, not implemented):
on reconnect do not re-stamp identity - carry pidNs, platform and the two tokens forward from the worker file already read at :2341, and refresh only what the socket actually proves (hostEndpoint, hostId, recentOutputBytes). Alternatively verify the self-reported pids against this namespace's /proc before writing them, and skip the write when they do not resolve here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Re-verified at head 8a95fe3e15: REAL, still stands. The write site is supervisor-process.ts:2356-2370.

  • It still spreads ...workerPidIdentity(host) (:2361) and sets platform: process.platform (:2368). On the reconnect path host.pid / host.workerPid come from connectAgentViewPtyHostProcess (:2348-2354), i.e. values an already-running PTY host self-reported over its socket; nothing resolves them against the reconnecting supervisor's /proc or namespace.
  • writeAgentViewWorker merges { ...existing, ...worker } (supervisor-store.ts:526-531), so the pidNs and start tokens the launcher recorded are overwritten by the self-reported ones.
  • The consumer this PR adds trusts exactly those fields to decide whether to print a pid a user or a script may kill: liveWorkerPid gates on worker.platform (managed-rows.ts:216), worker.pidNs (:218-224) and the boot prefix of the recorded tokens (:237-238).

Only this file's import line changed since the previous verdict (83b98c1989, core subpath imports); the write site itself is unchanged.

Code-frozen this round: the PR is at +1723/-75, past this closeout pass's 1500-line scope fuse, so no code was changed and nothing was pushed. Left UNRESOLVED for the owning maintainer's decision (no /triage while Criticals stand).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R7-2: [certifies-falsely] [regression] (still standing at 8a95fe3) The reconnect write site spreads ...workerPidIdentity(host) over pids an already-running PTY host self-reported, and workerPidIdentity (:105-118) stamps readPidNamespaceId() and /proc start tokens read from the RECONNECTING supervisor's own environment — so a launcher-recorded foreign pidNs is overwritten with a locally-valid one, and the cross-namespace guard this same diff adds is fed identity vouched for by the wrong machine. The three spawn sites (:511/:743/:2559) consume a locally-observed child handle and are correct; this site is the only one consuming connectAgentViewPtyHostProcess's self-reported handle.

Witness:

witness: not run — requires a live PTY-host reconnect across PID namespaces;
mechanism re-traced by read at head 8a95fe3e15 (supervisor-process.ts:2349-2370:
the reconnect spreads ...workerPidIdentity(host) over the self-reported handle;
workerPidIdentity :105-118 stamps readPidNamespaceId() and tokens from the
reconnecting supervisor's own /proc). Round-5 auditor confirmed all four stamp
sites: only :2361 consumes a self-reported handle.

Fix: on the reconnect path, preserve the existing worker file's identity fields (or re-derive them from the host's own reports) instead of re-stamping from the reconnecting process.

The spawn paths must keep stamping identity at write time (supervisor-process.ts:503-506 persists pids right after spawn, before any store I/O) — only this site consumes a self-reported handle. A reconnect test asserting the persisted pidNs/tokens are the file's prior values (not the reconnecting process's) must go red when the spread is restored.

中文说明

[Critical] R7-2(在 8a95fe3 仍未解决):重连写入点把 ...workerPidIdentity(host) 展开覆盖在一个已在运行的 PTY host 自报的 pid 上,而 workerPidIdentity(:105-118)写入的是发起重连的 supervisor 自己环境里读到的 readPidNamespaceId()/proc 启动令牌——于是启动方记录的"外部" pidNs 被改写成一个本地有效值,本次 diff 新增的跨命名空间守卫拿到的是由错误机器背书的身份。三个 spawn 写入点(:511/:743/:2559)消费的是本地观察到的子进程句柄,是正确的;只有这个点消费 connectAgentViewPtyHostProcess 的自报句柄。

证据:witness: not run——需要一次跨 PID 命名空间的真实 PTY host 重连;机制已在 head 8a95fe3 逐行重读确认(supervisor-process.ts:2349-2370:重连把 ...workerPidIdentity(host) 展开到自报句柄上;workerPidIdentity :105-118 从发起重连的 supervisor 自己的 /proc 读取命名空间与令牌)。第 5 轮审计确认了全部四个写入点:只有 :2361 消费自报句柄。

修复:重连路径应保留 worker 文件里已有的身份字段(或根据 host 自己的上报重新推导),而不是用重连进程的信息重新写入。

spawn 路径必须继续在写入时记录身份(supervisor-process.ts:503-506 要求 spawn 后立即持久化 pid、先于任何 store I/O)——只有此点消费自报句柄。需要一个重连测试,断言持久化的 pidNs/令牌仍是文件中原有的值(而非重连进程的),恢复该展开时测试必须变红。

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Still REAL at 9a0ca988cc; the write site is unchanged (supervisor-process.ts:2356-2372, spread at :2361, platform: process.platform at :2368). This round I worked through your minimal fix and it is not sufficient on its own, which is why it stays open.

"Omit the identity spread" is not a one-line deletion — the spread is also what writes hostPid/workerPid (:112-114). Enumerating the shapes:

  • omit it entirely: writeAgentViewWorker merges {...existing, ...worker} (supervisor-store.ts:526-531), so prior pids and prior identity both survive. Correct when the reconnect reaches the same host; when the socket reports different pids, the file keeps vouching for pids the socket just contradicted and persists nothing new.
  • write the self-reported pids without the tokens: the file's prior tokens now vouch for different pids, so isSameProcess(newPid, oldToken) refuses a legitimately live local session. Fail-closed, but it blanks real pids on every reconnect that changes a pid.
  • write the pids and null the tokens: normalizeWorker keeps the nulls, recordBootId becomes null (managed-rows.ts:236) and every reconnected session drops into the bare-liveness fall-through — the exact failure the guards exist to prevent.
  • have the host report its own pidNs and start tokens in its status reply and stamp only on a match with readPidNamespaceId(): the only shape that is both fail-closed and non-degrading, and a wire-protocol addition to pty-host-process.ts.

platform at :2368 has the same defect and has to move with whichever shape is chosen. Routing: UNRESOLVED — the correct fix is a protocol change, beyond one thread's ceiling here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R7-2: [certifies-falsely] [regression] Still standing at 794fcba (:2361): the reconnect write site re-stamps pid identity with self-reported pids (from the worker's own reconnect frame, worker.hostIdentity.pid/workerPid) paired with the reconnecting supervisor's own readPidNamespaceId()/readProcStartToken() results — vouching for pids it never verified.

The author's reply on the original thread stands: "a trust-boundary decision rather than a patch, so it stays unresolved". Neither fix commit touched this site; the other three workerPidIdentity sites stamp pids from freshly spawned local handles and were re-traced clean this round.

Witness (this round): all four write sites re-walked at HEAD (chunk-1 round-4 layer receipt): ':511/:743/:2559 stamp pids from just-spawned local handles; the fourth site's self-reported pids are R7-2, already on the ledger'.

Fix direction, witness and constraint are in the original thread (refuse to re-stamp on reconnect, or verify the reported pids first; reconnect itself must stay non-fatal per :2374-2376).

中文说明

[Critical] R7-2:在 794fcba 仍然存在(:2361):重连写入点用自报的 pid(来自 worker 自己的重连帧 worker.hostIdentity.pid/workerPid)配上重连方 supervisor 自己读到的 readPidNamespaceId()/readProcStartToken() 结果重新盖章 pid 身份——为它从未验证过的 pid 作保。

原作者在原始线程的回复仍然有效:"这是信任边界决策而非补丁,因此保持未解决"。两个修复提交都没有触及该写入点;其余三个 workerPidIdentity 写入点用的是刚 spawn 的本地句柄中的 pid,本轮已重新追踪确认无问题。

**证据(本轮):**四个写入点在 HEAD 全部重走(chunk-1 第 4 轮层收据):':511/:743/:2559 使用刚 spawn 的本地句柄;第四个点的自报 pid 即 R7-2,已在台账'。

修复方向、验收与约束见原始线程(重连时拒绝重新盖章,或先验证所报 pid;按 :2374-2376,重连本身必须保持非致命)。

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

Comment thread
yiliang114 marked this conversation as resolved.
...(worker.hostAuthToken
Expand Down Expand Up @@ -2521,8 +2556,7 @@ class WorkerRegistry {
sessionId,
{
schemaVersion: 1,
hostPid: host.pid,
workerPid: host.workerPid,
...workerPidIdentity(host),
...(host.hostId ? { hostId: host.hostId } : {}),
...(host.endpoint ? { hostEndpoint: host.endpoint } : {}),
...(host.authToken ? { hostAuthToken: host.authToken } : {}),
Expand Down
Loading
Loading