mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(cli): list managed Agent View sessions in qwen sessions ps (#10942)
* feat(cli): list managed Agent View sessions in qwen sessions ps `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. * docs(skills): stop the recency gate from silencing a stalled wiring 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. * fix(cli): keep non-owned supervisor snapshots out of sessions ps 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 <[email protected]> Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq * fix(cli): verify managed session pids before listing them 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 <[email protected]> Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq * test(cli): pin LF and TAB stripping on the ps store-failure note 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 <[email protected]> Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq * docs(commands): warn that the jq -r example renders session text raw 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 <[email protected]> Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq * fix(cli): keep the sessions ps JSON contract off display wording 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. * fix(cli): import the task-state type the row test annotates 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`. * 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. * test(cli): give ps.test.ts the identity helpers its subject now calls853d57btaught `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. * fix(cli): correct sessions ps comment claims and pin every STATE label - 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 <[email protected]> Patrol-Run: qwen-pr-closeout/jmtmourmvi4 * fix(cli): finish sessions ps review cleanup Co-authored-by: Qwen-Coder <[email protected]> * fix(cli): guard sessions ps managed pids by boot id like the registry 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 <[email protected]> Patrol-Run: qwen-pr-closeout/jmtnaadh9j8 * fix(cli): report the resumable session id spelling in sessions ps 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 <[email protected]> Patrol-Run: qwen-pr-closeout/jmtnaadh9j8 * fix(cli): carry a deduped record's spelling and pid onto a degraded managed 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 <[email protected]> Patrol-Run: qwen-pr-closeout/jmtnn5c4cjw * fix(cli): refuse managed pids from a worker file written on another OS 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. * docs: normalize the sessions table padding prettier asks for The merge commit7c3e0958resolved the Session Management table by hand and kept main's column width, which was sized for the longer "List interactive sessions" wording. Prettier re-pads the column to the longest remaining cell, so the Lint & Static job failed at Run Prettier with `Code style issues found in 1 file`. Whitespace only: the five changed lines are the table's padding, no cell text changes. Verified with prettier 3.6.1 --check (the version this branch's lockfile pins). Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-conflict/jmtrcr08gpe * fix(cli): validate resumeSessionId before it becomes a row identity normalizeLaunch passed resumeSessionId through the raw spread unvalidated while this PR promoted it to the row's reported sessionId and the managedIds dedupe key. A non-string spelling crashed ps via sanitizeSessionId outside every guard; an empty spelling canonicalized to the catch-all key and could delete a stranger's registry row. Run it through stringValue like every other text field, and let the consumer fall back on the empty spelling too. * fix(cli): import process-liveness and session-registry symbols from their core modules The core package root is banned as a value-import specifier in packages/cli/src (eslint.config.js, #10908); the two files added by this PR were not grandfathered, so Lint & Static failed with 2 no-restricted-imports errors. Import paths only, no behavior change. managed-rows.test.ts mocked the barrel specifier, so it stopped intercepting the deep import and 7 cases leaked the real pid; the mock specifier follows the import. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmu8068335i * test(cli): intercept the deep process-liveness module in the sessions ps suite The previous commit moved managed-rows.ts off the core package root, so this suite's barrel-only vi.mock stopped intercepting isSameProcess / readLocalBootId / readPidNamespaceId, and 2 managed-row cases fell through to the real liveness probe (pid rendered as '-'). Register the same fakes under the specifier managed-rows.ts actually imports. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmu8068335i * fix(cli): carry the deduped record's name and cwd, and gate resumeSessionId on identity * docs(skills): key the stalled-wiring probe on the directory basename * chore: remove unrelated stalled-wiring guidance Keep the sessions listing change focused on its user-facing contract instead of coupling it to an unrelated review-process policy. * fix(cli): narrow managed session listing * fix(cli): preserve sessions ps output on stderr failures * test(cli): make the sessions ps guard-order case able to fail The case named for the output-error guard asserted from inside the listAgentViewSessionStates mock. readManagedSessions catches everything the store throws, so that AssertionError rode out on the caught rejection and was swallowed: deleting ignoreBrokenPipe() from handlePs left the file at 24/24 green. Record the call order in the two mocks and assert it from the test body instead. Measured: guard deleted -> 1 failed / 23 passed with `expected [ 'store' ] to deeply equal [ 'guard', 'store' ]`; guard present -> 24 passed. No production code changed. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmudwd45z01 --------- Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
010941d202
commit
78faaec8a1
3 files changed
+176
-29
No files matched your search
@@ -761,11 +761,13 @@ qwen sessions list --json | jq .
|
||||
|
||||
#### `qwen sessions ps`
|
||||
|
||||
Lists the Qwen Code sessions registered 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.
|
||||
Lists the Qwen Code session records visible on this machine.
|
||||
`sessions list` walks saved transcripts ("what have I worked on"). This
|
||||
command shows both the live-process registry and Agent View records marked
|
||||
`managed`. Records left behind by a killed session are swept from the registry
|
||||
as they are found. A managed record is persistent bookkeeping, not evidence
|
||||
that its worker is still alive. A one-shot `qwen -p` run never registers and
|
||||
is not shown.
|
||||
|
||||
**Flags:**
|
||||
|
||||
@@ -785,22 +787,32 @@ 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. An Agent View record marked
|
||||
`managed` uses `managed` for KIND and `-` for PID and AGE. It remains a
|
||||
separate row when the same session also has a live registry record. See
|
||||
[Cross-Session Protocol](./cross-session-protocol.md) for the record
|
||||
format and for how to register a program of your own.
|
||||
|
||||
**JSON output (`--json`):**
|
||||
|
||||
Outputs JSON Lines on stdout, newest session first. Each line is a JSON
|
||||
object with fields:
|
||||
Outputs JSON Lines on stdout with managed records first. Registry records
|
||||
keep their existing shape:
|
||||
|
||||
```
|
||||
schemaVersion, pid, procStart, pidNs, sessionId, cwd, name, startedAt,
|
||||
qwenVersion, kind, ipcPath (when peer messaging is available)
|
||||
```
|
||||
|
||||
A managed record contains only source-backed data:
|
||||
|
||||
```
|
||||
sessionId, cwd, managed: true
|
||||
```
|
||||
|
||||
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 store cannot be read, registry records still print and the
|
||||
reason is written to stderr.
|
||||
|
||||
JSON output is raw data: field values are emitted exactly as recorded,
|
||||
with no terminal sanitization. Treat them as data, and sanitize before
|
||||
@@ -812,7 +824,7 @@ rendering them in a terminal.
|
||||
# Show the other live sessions
|
||||
qwen sessions ps
|
||||
|
||||
# Which directories are busy right now?
|
||||
# Which directories have a visible session record?
|
||||
# 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
|
||||
|
||||
@@ -9,6 +9,8 @@ import stringWidth from 'string-width';
|
||||
import type { SessionRegistryRecord } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const listLiveSessions = vi.fn();
|
||||
const listAgentViewSessionStates = vi.fn();
|
||||
const ignoreBrokenPipe = vi.fn();
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
listLiveSessions: (...args: unknown[]) => listLiveSessions(...args),
|
||||
@@ -22,10 +24,16 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
kind === undefined || kind.length === 0 ? 'tui' : kind,
|
||||
}));
|
||||
|
||||
vi.mock('../../agent-view/supervisor-store.js', () => ({
|
||||
listAgentViewSessionStates: (...args: unknown[]) =>
|
||||
listAgentViewSessionStates(...args),
|
||||
}));
|
||||
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
|
||||
vi.mock('../../utils/stdioHelpers.js', () => ({
|
||||
ignoreBrokenPipe,
|
||||
writeStdoutLine: (line: string) => stdout.push(line),
|
||||
writeStderrLine: (line: string) => stderr.push(line),
|
||||
}));
|
||||
@@ -50,6 +58,17 @@ function record(
|
||||
};
|
||||
}
|
||||
|
||||
function managedState(
|
||||
over: { ownership?: string; sessionId?: string; cwd?: string } = {},
|
||||
): Record<string, unknown> {
|
||||
const {
|
||||
ownership = 'managed',
|
||||
sessionId = 'managed-1',
|
||||
cwd = '/w/svc',
|
||||
} = over;
|
||||
return { ownership, sessionId, activeCwd: cwd };
|
||||
}
|
||||
|
||||
async function run(argv: Record<string, unknown>): Promise<void> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (psCommand.handler as any)(argv);
|
||||
@@ -59,6 +78,9 @@ beforeEach(() => {
|
||||
stdout.length = 0;
|
||||
stderr.length = 0;
|
||||
listLiveSessions.mockReset();
|
||||
listAgentViewSessionStates.mockReset();
|
||||
ignoreBrokenPipe.mockReset();
|
||||
listAgentViewSessionStates.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -155,7 +177,31 @@ describe('qwen sessions ps', () => {
|
||||
it('says so plainly when nothing is registered', async () => {
|
||||
listLiveSessions.mockResolvedValue([]);
|
||||
await run({ json: false });
|
||||
expect(stdout).toEqual(['No Qwen Code sessions are registered right now.']);
|
||||
expect(stdout).toEqual([
|
||||
'No Qwen Code sessions are registered or managed right now.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists managed records without claiming process liveness', async () => {
|
||||
listLiveSessions.mockResolvedValue([]);
|
||||
listAgentViewSessionStates.mockResolvedValue([
|
||||
managedState(),
|
||||
managedState({ ownership: 'adopting', sessionId: 'adopting-1' }),
|
||||
]);
|
||||
await run({ json: false });
|
||||
|
||||
expect(stdout).toEqual([
|
||||
'NAME'.padEnd(NAME_COL) +
|
||||
'KIND'.padEnd(KIND_COL) +
|
||||
'PID'.padEnd(PID_COL) +
|
||||
'AGE'.padEnd(AGE_COL) +
|
||||
'DIRECTORY',
|
||||
'managed-1'.padEnd(NAME_COL) +
|
||||
'managed'.padEnd(KIND_COL) +
|
||||
'-'.padEnd(PID_COL) +
|
||||
'-'.padEnd(AGE_COL) +
|
||||
'/w/svc',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits one JSON object per line with no header', async () => {
|
||||
@@ -167,6 +213,41 @@ describe('qwen sessions ps', () => {
|
||||
expect(JSON.parse(stdout[1]).pid).toBe(7);
|
||||
});
|
||||
|
||||
it('installs the output error guard before reading the managed store', async () => {
|
||||
// Both mocks only record that they ran; every expectation is raised
|
||||
// afterwards, in the test body. An expectation thrown from inside a
|
||||
// mock implementation rides out on the rejection that
|
||||
// `readManagedSessions` deliberately catches, so it is swallowed
|
||||
// along with the error and the case passes with the guard deleted.
|
||||
const rec = record();
|
||||
const order: string[] = [];
|
||||
ignoreBrokenPipe.mockImplementation(() => {
|
||||
order.push('guard');
|
||||
});
|
||||
listLiveSessions.mockResolvedValue([rec]);
|
||||
listAgentViewSessionStates.mockImplementation(() => {
|
||||
order.push('store');
|
||||
return Promise.reject(new Error('broken store'));
|
||||
});
|
||||
|
||||
await run({ json: true });
|
||||
|
||||
expect(order).toEqual(['guard', 'store']);
|
||||
expect(stdout).toEqual([JSON.stringify(rec)]);
|
||||
});
|
||||
|
||||
it('keeps registry JSON available when the supervisor store cannot be read', async () => {
|
||||
const rec = record();
|
||||
listLiveSessions.mockResolvedValue([rec]);
|
||||
listAgentViewSessionStates.mockRejectedValue(new Error('broken\n\tstore'));
|
||||
await run({ json: true });
|
||||
|
||||
expect(stdout).toEqual([JSON.stringify(rec)]);
|
||||
expect(stderr).toEqual([
|
||||
'Managed sessions could not be listed: brokenstore',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits each record as one whole line of JSON Lines', async () => {
|
||||
// JSON Lines is line-delimited by definition: a pretty-printed record
|
||||
// still round-trips through JSON.parse but breaks every consumer that
|
||||
@@ -184,6 +265,18 @@ describe('qwen sessions ps', () => {
|
||||
expect(stdout[0]).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('keeps managed and registry records separate in JSON', async () => {
|
||||
const rec = record({ sessionId: 'managed-1' });
|
||||
listLiveSessions.mockResolvedValue([rec]);
|
||||
listAgentViewSessionStates.mockResolvedValue([managedState()]);
|
||||
await run({ json: true });
|
||||
|
||||
expect(stdout.map((line) => JSON.parse(line))).toEqual([
|
||||
{ sessionId: 'managed-1', cwd: '/w/svc', managed: true },
|
||||
rec,
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries the kind into the JSON output, unfiltered', async () => {
|
||||
// This is the surface an aggregator reads to tell a user's own
|
||||
// terminals from sessions something else is driving, so the field has
|
||||
|
||||
@@ -5,17 +5,11 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* `qwen sessions ps` — list the Qwen Code sessions running right now.
|
||||
* `qwen sessions ps` — list registered and managed Qwen Code sessions.
|
||||
*
|
||||
* The sibling `qwen sessions list` walks saved transcripts; this walks the
|
||||
* live-process registry, so the two answer different questions: "what have
|
||||
* I worked on" versus "what is running on this machine at this moment".
|
||||
*
|
||||
* KIND says what registered each one — an interactive terminal, a
|
||||
* daemon-managed session, a program that is not Qwen Code at all. It is a
|
||||
* self-report, like NAME and DIRECTORY: everything here was written by
|
||||
* the process it describes. What does not appear at all is a one-shot
|
||||
* `qwen -p` run, which never registers.
|
||||
* The sibling `qwen sessions list` walks saved transcripts. This command shows
|
||||
* live registry records and Agent View records marked `managed`. The latter are
|
||||
* persistent records, not a liveness claim.
|
||||
*/
|
||||
|
||||
import type { CommandModule, Argv } from 'yargs';
|
||||
@@ -25,11 +19,16 @@ import {
|
||||
type SessionRegistryRecord,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import stringWidth from 'string-width';
|
||||
import { listAgentViewSessionStates } from '../../agent-view/supervisor-store.js';
|
||||
import {
|
||||
sanitizeTerminalText,
|
||||
truncateToWidth,
|
||||
} from '../../ui/utils/textUtils.js';
|
||||
import { writeStdoutLine } from '../../utils/stdioHelpers.js';
|
||||
import {
|
||||
ignoreBrokenPipe,
|
||||
writeStderrLine,
|
||||
writeStdoutLine,
|
||||
} from '../../utils/stdioHelpers.js';
|
||||
|
||||
/** Fixed column widths for the human-readable table (exported for tests). */
|
||||
export const NAME_COL = 22;
|
||||
@@ -42,6 +41,11 @@ interface PsArgs {
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface ManagedSession {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a record field for terminal output.
|
||||
*
|
||||
@@ -79,7 +83,11 @@ export function formatAge(ms: number): string {
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
function outputHuman(records: SessionRegistryRecord[], now: number): void {
|
||||
function outputHuman(
|
||||
records: SessionRegistryRecord[],
|
||||
managed: ManagedSession[],
|
||||
now: number,
|
||||
): void {
|
||||
writeStdoutLine(
|
||||
padDisplay('NAME', NAME_COL) +
|
||||
padDisplay('KIND', KIND_COL) +
|
||||
@@ -87,6 +95,18 @@ function outputHuman(records: SessionRegistryRecord[], now: number): void {
|
||||
padDisplay('AGE', AGE_COL) +
|
||||
'DIRECTORY',
|
||||
);
|
||||
for (const session of managed) {
|
||||
writeStdoutLine(
|
||||
padDisplay(
|
||||
truncateToWidth(sanitize(session.sessionId), NAME_COL - 2),
|
||||
NAME_COL,
|
||||
) +
|
||||
padDisplay('managed', KIND_COL) +
|
||||
padDisplay('-', PID_COL) +
|
||||
padDisplay('-', AGE_COL) +
|
||||
sanitize(session.cwd),
|
||||
);
|
||||
}
|
||||
for (const record of records) {
|
||||
writeStdoutLine(
|
||||
padDisplay(
|
||||
@@ -109,13 +129,33 @@ function outputHuman(records: SessionRegistryRecord[], now: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function readManagedSessions(): Promise<ManagedSession[]> {
|
||||
try {
|
||||
return (await listAgentViewSessionStates())
|
||||
.filter((state) => state.ownership === 'managed')
|
||||
.map(({ sessionId, activeCwd }) => ({ sessionId, cwd: activeCwd }));
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
writeStderrLine(
|
||||
`Managed sessions could not be listed: ${sanitize(reason)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePs(argv: PsArgs): Promise<void> {
|
||||
ignoreBrokenPipe();
|
||||
// listLiveSessions reports "cannot look" as "no peers" rather than
|
||||
// throwing, so there is no failure path to surface here.
|
||||
const records = await listLiveSessions();
|
||||
const now = Date.now();
|
||||
const [records, managed] = await Promise.all([
|
||||
listLiveSessions(),
|
||||
readManagedSessions(),
|
||||
]);
|
||||
|
||||
if (argv.json) {
|
||||
for (const session of managed) {
|
||||
writeStdoutLine(JSON.stringify({ ...session, managed: true }));
|
||||
}
|
||||
for (const record of records) {
|
||||
// Deliberately raw: field values are emitted exactly as recorded,
|
||||
// with none of the table path's terminal sanitization. That keeps
|
||||
@@ -130,17 +170,19 @@ async function handlePs(argv: PsArgs): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (records.length === 0) {
|
||||
writeStdoutLine('No Qwen Code sessions are registered right now.');
|
||||
if (records.length === 0 && managed.length === 0) {
|
||||
writeStdoutLine(
|
||||
'No Qwen Code sessions are registered or managed right now.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
outputHuman(records, now);
|
||||
outputHuman(records, managed, Date.now());
|
||||
}
|
||||
|
||||
export const psCommand: CommandModule<unknown, PsArgs> = {
|
||||
command: 'ps',
|
||||
describe: 'List Qwen Code sessions running right now',
|
||||
describe: 'List registered and managed Qwen Code sessions',
|
||||
builder: (yargs: Argv) =>
|
||||
yargs.option('json', {
|
||||
type: 'boolean',
|
||||
|
||||
Reference in new issue
Block a user