fix(cli): hold managed session rows to the registry's identity standard

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.
This commit is contained in:
yiliang114 committed 2026-09-04 15:20:38 +09:00
1 parent cd68e10375
commit 853d57bba9
5 files changed
+281 -31

No files matched your search

+46
View File
@@ -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 =
@@ -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;
@@ -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 || '_';
}
@@ -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';
import { AGENT_VIEW_PROTOCOL_VERSION } from './protocol.js';
import type {
AgentViewActivityFile,
@@ -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);
@@ -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 } : {}),
@@ -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 } : {}),
@@ -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,
...(worker.hostAuthToken
@@ -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 } : {}),
+13 -10
View File
@@ -8,6 +8,7 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import { atomicWriteFile, Storage } from '@qwen-code/qwen-code-core';
import { sanitizeSessionId } from './protocol.js';
import type {
AgentViewActivityFile,
AgentViewLaunchFile,
@@ -19,6 +20,11 @@ import type {
AgentViewWorkerFile,
} from './protocol.js';
// Re-exported from its old home: the sanitizer moved to `protocol.js` so
// the pure row-merging module can canonicalize ids without importing this
// filesystem store, and every existing importer keeps working.
export { sanitizeSessionId };
type JsonRecord = Record<string, unknown>;
export interface AgentViewStorePaths {
@@ -547,16 +553,6 @@ export async function writeAgentViewSupervisor(
});
}
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 || '_';
}
function compareRosterEntries(
left: AgentViewRosterEntry,
right: AgentViewRosterEntry,
@@ -888,6 +884,13 @@ function normalizeWorker(
schemaVersion: 1,
hostPid: numberValue(raw['hostPid']),
workerPid: numberValue(raw['workerPid']),
// `null` rather than `undefined` for an absent token: it is the value
// `isSameProcess` reads as "no identity recorded, fall back to a bare
// liveness check", so a pre-identity worker file keeps its old
// behaviour instead of being treated as a mismatch.
hostProcStart: stringValue(raw['hostProcStart']) ?? null,
workerProcStart: stringValue(raw['workerProcStart']) ?? null,
pidNs: numberValue(raw['pidNs']) ?? null,
endpoint: stringValue(raw['endpoint']),
hostEndpoint: stringValue(raw['hostEndpoint']),
hostAuthToken: stringValue(raw['hostAuthToken']),
@@ -15,9 +15,27 @@ import type { SessionRegistryRecord } from '@qwen-code/qwen-code-core';
import type { AgentViewTaskState } from '../../agent-view/presentation.js';
const isPidAlive = vi.fn((pid: number) => pid > 0);
/** The start token the OS would report for a pid right now. */
const currentProcStart = vi.fn((pid: number): string | null => `start-${pid}`);
const pidNamespaceId = vi.fn((): number | null => null);
vi.mock('@qwen-code/qwen-code-core', () => ({
isPidAlive: (...args: unknown[]) => isPidAlive(...(args as [number])),
readProcStartToken: (...args: unknown[]) =>
currentProcStart(...(args as [number])),
readPidNamespaceId: () => pidNamespaceId(),
// Mirrors the real degradation contract rather than stubbing a verdict,
// so a test that records no token exercises the same fall-through to a
// bare liveness check that a pre-identity worker file gets in
// production: `procStart == null` and an unreadable current token both
// mean "no identity available", never "mismatch".
isSameProcess: (pid: number, procStart?: string | null) => {
if (!isPidAlive(pid)) return false;
if (procStart == null) return true;
const current = currentProcStart(pid);
if (current === null) return true;
return current === procStart;
},
}));
const { managedSessionRows, mergeSessionRows } = await import(
@@ -29,6 +47,10 @@ const NOW = Date.parse('2026-09-04T12:00:00Z');
beforeEach(() => {
isPidAlive.mockReset();
isPidAlive.mockImplementation((pid: number) => pid > 0);
currentProcStart.mockReset();
currentProcStart.mockImplementation((pid: number) => `start-${pid}`);
pidNamespaceId.mockReset();
pidNamespaceId.mockImplementation(() => null);
});
function state(
@@ -164,6 +186,79 @@ describe('managedSessionRows', () => {
expect(row.pid).toBe(100);
});
it('drops a live pid the recorded identity does not vouch for', () => {
// The half of the blocker liveness cannot see. After a SIGKILL or a
// reboot nothing clears the durable worker file, and the OS is free to
// hand 200 to something unrelated: `kill(200, 0)` then says "alive"
// about a process that has nothing to do with this session, and a
// script reading `--json` would kill a stranger.
const [row] = managedSessionRows(
[
snapshot({
worker: workerFile({
workerPid: 200,
workerProcStart: 'start-200-before-the-reboot',
}),
}),
],
NOW,
);
expect(isPidAlive(200)).toBe(true);
expect(row.pid).toBeUndefined();
});
it('refuses pids from a worker file written in another PID namespace', () => {
// One `~/.qwen` shared across machines or namespaces — an NFS home, a
// devcontainer with the home mounted — needs no recycling to break
// this: the foreign file's pids get probed here, where low numbers
// routinely belong to live local processes.
pidNamespaceId.mockImplementation(() => 4026531999);
const [row] = managedSessionRows(
[
snapshot({
worker: workerFile({
workerPid: 200,
workerProcStart: 'start-200',
pidNs: 4026531837,
}),
}),
],
NOW,
);
expect(row.pid).toBeUndefined();
});
it('still reports a pid when either side’s namespace is unreadable', () => {
// The guard fires only on a known disagreement. `/proc` being
// unreadable must not blank the pid of a worker that is really there.
pidNamespaceId.mockImplementation(() => null);
const [row] = managedSessionRows(
[
snapshot({
worker: workerFile({
workerPid: 200,
workerProcStart: 'start-200',
pidNs: 4026531837,
}),
}),
],
NOW,
);
expect(row.pid).toBe(200);
});
it('keeps reporting pids from a worker file written before identity existed', () => {
// `AgentViewWorkerFile` is a durable schemaVersion 1 record. A file
// written by an older supervisor carries no token, and that must
// degrade to the liveness check this command already did — not blank
// the pid of every session that predates the field.
const [row] = managedSessionRows(
[snapshot({ worker: workerFile({ hostPid: 100, workerPid: 200 }) })],
NOW,
);
expect(row.pid).toBe(200);
});
it('leaves the pid unset when no process is recorded, rather than reporting 0', () => {
// A managed session that has not spawned a worker yet, or whose
// worker has exited, has no pid to point at. Zero is a real pid.
@@ -261,6 +356,22 @@ describe('mergeSessionRows', () => {
expect(rows[0].taskState).toBe('running');
});
it('lists a mixed-case session once, though the two sources spell it differently', () => {
// The store files a session under a sanitized, lowercased directory
// name and reports that as the id; the registry keeps the raw spelling
// the worker registered with, which adoption preserves on purpose
// because the native session store is case-sensitive. A raw string
// comparison lets this record through and the table lists one session
// twice — once with its real state, once as `interactive`.
const rows = mergeSessionRows(
[record({ sessionId: 'Managed-1', name: 'app-ab' })],
managedSessionRows([snapshot({ state: state() })], NOW),
);
expect(rows).toHaveLength(1);
expect(rows[0].managed).toBe(true);
expect(rows[0].sessionId).toBe('managed-1');
});
it('lets a live registry record survive the adopting window', () => {
// Adoption reuses the live session's id before ownership flips to
// managed. Until it flips, the registry record is the only row that
@@ -15,15 +15,29 @@
*
* This module turns both sources into one row shape. It is deliberately
* pure: the readers stay in the command, so the merge and the labelling
* are testable without a filesystem or a supervisor. The one probe it
* makes — `isPidAlive`, which decides whether a recorded pid may still
* be printed — is an input the tests control like any other.
* are testable without a filesystem or a supervisor. The probes it makes
* — the process-identity checks that decide whether a recorded pid may
* still be printed — are inputs the tests control like any other.
*
* Both sources describe the same sessions, and both had their own idea of
* what a session's identity is. That is the bug class this module has to
* keep closed:
*
* - the store files a session under a sanitized (lowercased) directory
* name and reports that as the id, while the registry keeps the raw
* spelling the worker registered with, so the merge canonicalizes
* through `sanitizeSessionId` before comparing;
* - the registry verifies a pid with a recorded start token plus
* namespace and boot-id guards, so a managed pid is held to the same
* standard rather than to a bare liveness probe.
*/
import {
isPidAlive,
isSameProcess,
readPidNamespaceId,
type SessionRegistryRecord,
} from '@qwen-code/qwen-code-core';
import { sanitizeSessionId } from '../../agent-view/protocol.js';
import type {
AgentViewSessionSnapshot,
AgentViewWorkerFile,
@@ -130,17 +144,49 @@ export function managedSessionRows(
*
* The worker is the process doing the work; the host only owns the PTY —
* report whichever lives, worker first, matching the supervisor's own
* two-pid liveness idiom. The store is durable and nothing reaps it when
* no supervisor runs, so a crash or a reboot leaves recorded pids behind
* that are dead, or recycled to an unrelated process; printing one would
* point a `kill` at the wrong target. When neither lives, the row prints
* `-`, exactly like a session that never had a worker.
* two-pid liveness idiom.
*
* Liveness alone is not enough to print a number a user or a script may
* `kill`. The store is durable and nothing reaps it while no supervisor
* runs, so a crash or a reboot leaves recorded pids behind; once the OS
* recycles one, a bare `kill(pid, 0)` says "alive" about a process that
* has nothing to do with this session. A `~/.qwen` shared between
* machines or PID namespaces — an NFS home, a devcontainer with the home
* mounted — reaches the same end without any recycling, because a foreign
* file's pids get probed here, where low numbers routinely belong to live
* local processes. So each candidate is checked with `isSameProcess`
* against the start token the supervisor recorded beside it, which is the
* contract the registry rows in this very table already answer to.
*
* Degradation is deliberate in two places. A worker file written before
* those tokens existed carries none, and `isSameProcess` reads that as
* "no identity recorded" and falls through to a liveness check — the
* behaviour this command had before, rather than a blanked pid on every
* pre-existing session. And the namespace guard only fires when both
* sides are known and disagree; an unreadable namespace on either side
* must not blank a real worker's pid.
*
* When nothing qualifies the row prints `-`, exactly like a session that
* never had a worker.
*/
function liveWorkerPid(
worker: AgentViewWorkerFile | undefined,
): number | undefined {
for (const pid of [worker?.workerPid, worker?.hostPid]) {
if (pid !== undefined && isPidAlive(pid)) return pid;
if (!worker) return undefined;
const ownNamespace = readPidNamespaceId();
if (
worker.pidNs != null &&
ownNamespace != null &&
worker.pidNs !== ownNamespace
) {
return undefined;
}
const candidates = [
[worker.workerPid, worker.workerProcStart],
[worker.hostPid, worker.hostProcStart],
] as const;
for (const [pid, procStart] of candidates) {
if (pid !== undefined && isSameProcess(pid, procStart)) return pid;
}
return undefined;
}
@@ -176,11 +222,21 @@ export function mergeSessionRows(
records: readonly SessionRegistryRecord[],
managed: readonly SessionRow[],
): SessionRow[] {
const managedIds = new Set(managed.map((row) => row.sessionId));
// Canonicalized on both sides rather than compared raw. The two
// sources spell one id differently: the store reports the sanitized
// directory name it files the 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. Comparing
// the spellings would let any managed session whose id contains an
// uppercase letter through this filter, and the table would list it
// twice: once with its real state, once as `interactive`.
const managedIds = new Set(
managed.map((row) => sanitizeSessionId(row.sessionId)),
);
return [
...managed,
...records
.filter((record) => !managedIds.has(record.sessionId))
.filter((record) => !managedIds.has(sanitizeSessionId(record.sessionId)))
.map(registryRow),
];
}