mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-19 16:28:28 +00:00
feat(core): write runtime.json sidecar for active sessions (#3714)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
* feat(core): write runtime.json sidecar for active sessions Port kimi-cli PR #2082 part 1 to qwen-code. On interactive session start, atomically write a small JSON sidecar at <projectDir>/chats/<sessionId>.runtime.json recording the (pid, session_id, work_dir, hostname, started_at, qwen_version) tuple. External tools (terminal multiplexers, IDE integrations, status daemons) can map a running PID to its session id and work dir without parsing argv. Write is best-effort: a read-only filesystem must not block UI startup. OS process title (was #3713) and dynamic OSC tab title (kimi #2083) remain out of scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(core): refresh runtime.json on same-PID session swap Config.startNewSession() reassigns this.sessionId in the same process, which is reached by /clear, /reset, /new and /resume. Previously the old <oldId>.runtime.json was left behind, falsely claiming the still- live PID for a session no longer being served, and no new sidecar was written for the incoming session. Centralize the swap by clearing the old sidecar and writing a fresh one for the new session id from inside startNewSession itself, so all same-PID transitions are covered. The refresh runs as a fire-and- forget best-effort; failures must not block the session switch. Mirrors the post-merge Codex P1 fix on kimi-cli PR #2082 (the source of the runtime.json sidecar pattern this PR ports). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(core): only refresh runtime.json when this process owns it Mirrors kimi-cli PR #2082 commit e237951f (Codex P1 r3158754463): a short-lived non-interactive invocation (qwen --prompt, ACP, etc.) that runs `/clear` would otherwise call `Config.startNewSession()`, delete a concurrent shell's runtime.json sidecar (same outgoing session id), and never write a replacement — leaving the shell discoverable to nobody. Add a `runtimeStatusEnabled` flag on Config, flipped on by the interactive UI bootstrap immediately after the first successful sidecar write, and gate the swap-time refresh in `startNewSession()` on it. Non-interactive entry points never reach the bootstrap, so they won't touch sibling sidecars. Kimi later reverted the equivalent `write only from shell mode` guard (commit 7083975a) in favor of writing from every long-lived mode, but qwen's wire point is already interactive-only, so the narrower guard is the right shape here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
9bd5a0180b
commit
464e4cf343
6 changed files with 613 additions and 0 deletions
|
|
@ -15,6 +15,7 @@ import {
|
|||
SessionService,
|
||||
type Config,
|
||||
createDebugLogger,
|
||||
writeRuntimeStatus,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { render } from 'ink';
|
||||
import dns from 'node:dns';
|
||||
|
|
@ -216,6 +217,28 @@ export async function startInteractiveUI(
|
|||
) {
|
||||
const version = await getCliVersion();
|
||||
setWindowTitle(basename(workspaceRoot), settings);
|
||||
|
||||
// Write a small runtime.json sidecar next to the chat log so external
|
||||
// tools (terminal multiplexers, IDE integrations, status daemons) can
|
||||
// map the running PID back to its session id and work directory.
|
||||
// Best-effort: a read-only filesystem must not prevent the UI from
|
||||
// starting up.
|
||||
try {
|
||||
const sessionId = config.getSessionId();
|
||||
const runtimeStatusPath = config.storage.getRuntimeStatusPath(sessionId);
|
||||
await writeRuntimeStatus(runtimeStatusPath, {
|
||||
sessionId,
|
||||
workDir: config.getTargetDir(),
|
||||
qwenVersion: version,
|
||||
});
|
||||
// Mark this process as the runtime.json owner so subsequent
|
||||
// session swaps (/clear, /resume, etc.) refresh the sidecar.
|
||||
// Non-interactive entry points never reach here, so they won't
|
||||
// trample a sibling shell's sidecar on the same session id.
|
||||
config.markRuntimeStatusEnabled();
|
||||
} catch {
|
||||
// ignored: best-effort, never block UI startup.
|
||||
}
|
||||
const restoreTerminalRedrawOptimizer =
|
||||
process.stdout.isTTY && !config.getScreenReader()
|
||||
? installTerminalRedrawOptimizer(process.stdout)
|
||||
|
|
|
|||
|
|
@ -116,6 +116,10 @@ import {
|
|||
import { DEFAULT_QWEN_EMBEDDING_MODEL } from './models.js';
|
||||
import { Storage } from './storage.js';
|
||||
import { ChatRecordingService } from '../services/chatRecordingService.js';
|
||||
import {
|
||||
clearRuntimeStatus,
|
||||
writeRuntimeStatus,
|
||||
} from '../utils/runtimeStatus.js';
|
||||
import {
|
||||
SessionService,
|
||||
type ResumedSessionData,
|
||||
|
|
@ -734,6 +738,7 @@ export class Config {
|
|||
private readonly overrideExtensions?: string[];
|
||||
|
||||
private readonly cliVersion?: string;
|
||||
private runtimeStatusEnabled = false;
|
||||
private readonly experimentalZedIntegration: boolean = false;
|
||||
private readonly cronEnabled: boolean = false;
|
||||
private readonly emitToolUseSummaries: boolean = true;
|
||||
|
|
@ -1442,6 +1447,7 @@ export class Config {
|
|||
// Best-effort — don't block session switch
|
||||
}
|
||||
|
||||
const previousSessionId = this.sessionId;
|
||||
this.sessionId = sessionId ?? randomUUID();
|
||||
this.sessionData = sessionData;
|
||||
setDebugLogSession(this);
|
||||
|
|
@ -1468,9 +1474,59 @@ export class Config {
|
|||
if (this.initialized) {
|
||||
logStartSession(this, new StartSessionEvent(this));
|
||||
}
|
||||
|
||||
// Refresh the runtime.json sidecar so external observers (terminal
|
||||
// multiplexers, IDE integrations, status daemons) see the new
|
||||
// session id rather than a stale claim against a still-live PID.
|
||||
// /clear, /reset, /new, and /resume all flow through this method,
|
||||
// so handling the swap centrally covers every same-PID session
|
||||
// transition. Best-effort: must never block /clear or /resume.
|
||||
//
|
||||
// Only refresh when THIS process established its own sidecar at
|
||||
// startup (interactive UI). A non-interactive `/clear` (e.g.
|
||||
// qwen --prompt-interactive) must not delete a sibling shell's
|
||||
// sidecar that happens to share the outgoing session id —
|
||||
// mirrors kimi-cli PR #2082's "write only when a session is
|
||||
// established for this process" rule.
|
||||
if (
|
||||
this.runtimeStatusEnabled &&
|
||||
previousSessionId !== this.sessionId
|
||||
) {
|
||||
const oldPath = this.storage.getRuntimeStatusPath(previousSessionId);
|
||||
const newPath = this.storage.getRuntimeStatusPath(this.sessionId);
|
||||
const cliVersion = this.cliVersion ?? null;
|
||||
const workDir = this.targetDir;
|
||||
const newSessionId = this.sessionId;
|
||||
void (async () => {
|
||||
try {
|
||||
await clearRuntimeStatus(oldPath);
|
||||
await writeRuntimeStatus(newPath, {
|
||||
sessionId: newSessionId,
|
||||
workDir,
|
||||
qwenVersion: cliVersion,
|
||||
});
|
||||
} catch {
|
||||
// ignored: best-effort cleanup
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks this Config as the owner of a runtime.json sidecar for the
|
||||
* current PID. Call once after the initial sidecar write succeeds
|
||||
* (typically from the interactive UI bootstrap). When set, subsequent
|
||||
* startNewSession() calls will refresh the sidecar on session swap;
|
||||
* when unset, startNewSession() leaves sibling sidecars alone so a
|
||||
* short-lived non-interactive process can't trample a concurrent
|
||||
* shell's sidecar that happens to share the outgoing session id.
|
||||
*/
|
||||
markRuntimeStatusEnabled(): void {
|
||||
this.runtimeStatusEnabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resumed session data if this session was resumed from a previous one.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -242,6 +242,18 @@ export class Storage {
|
|||
return path.join(this.getQwenDir(), 'commands');
|
||||
}
|
||||
|
||||
/**
|
||||
* Path to the runtime-status sidecar JSON for this session.
|
||||
*
|
||||
* Co-located with the per-session chat log under
|
||||
* `<projectDir>/chats/<sessionId>.runtime.json` so external observers
|
||||
* (terminal multiplexers, IDE integrations, status daemons) can scan
|
||||
* the same directory used for chat history to find live sessions.
|
||||
*/
|
||||
getRuntimeStatusPath(sessionId: string): string {
|
||||
return path.join(this.getProjectDir(), 'chats', `${sessionId}.runtime.json`);
|
||||
}
|
||||
|
||||
getProjectTempCheckpointsDir(): string {
|
||||
return path.join(this.getProjectTempDir(), 'checkpoints');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,6 +276,7 @@ export * from './utils/environmentContext.js';
|
|||
export * from './utils/errorParsing.js';
|
||||
export * from './utils/errors.js';
|
||||
export * from './utils/fileUtils.js';
|
||||
export * from './utils/runtimeStatus.js';
|
||||
export * from './utils/filesearch/fileSearch.js';
|
||||
export * from './utils/formatters.js';
|
||||
export * from './utils/generateContentResponseUtilities.js';
|
||||
|
|
|
|||
271
packages/core/src/utils/runtimeStatus.test.ts
Normal file
271
packages/core/src/utils/runtimeStatus.test.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { mkdtemp, readFile, rm, writeFile, readdir } from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
RUNTIME_STATUS_SCHEMA_VERSION,
|
||||
clearRuntimeStatus,
|
||||
readRuntimeStatus,
|
||||
writeRuntimeStatus,
|
||||
} from './runtimeStatus.js';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'qwen-runtime-status-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const targetPath = () => path.join(tmpDir, 'runtime.json');
|
||||
|
||||
describe('writeRuntimeStatus', () => {
|
||||
it('writes the expected fields', async () => {
|
||||
const written = await writeRuntimeStatus(targetPath(), {
|
||||
sessionId: '11111111-2222-3333-4444-555555555555',
|
||||
workDir: '/work/dir',
|
||||
pid: 4242,
|
||||
qwenVersion: '0.15.3',
|
||||
});
|
||||
expect(written).toBe(targetPath());
|
||||
|
||||
const data = JSON.parse(await readFile(targetPath(), 'utf-8'));
|
||||
expect(data.pid).toBe(4242);
|
||||
expect(data.session_id).toBe('11111111-2222-3333-4444-555555555555');
|
||||
expect(data.work_dir).toBe('/work/dir');
|
||||
expect(data.schema_version).toBe(RUNTIME_STATUS_SCHEMA_VERSION);
|
||||
expect(typeof data.hostname).toBe('string');
|
||||
expect(data.hostname.length).toBeGreaterThan(0);
|
||||
expect(typeof data.started_at).toBe('number');
|
||||
expect(data.qwen_version).toBe('0.15.3');
|
||||
});
|
||||
|
||||
it('defaults pid to process.pid and qwen_version to null', async () => {
|
||||
await writeRuntimeStatus(targetPath(), {
|
||||
sessionId: 'abc',
|
||||
workDir: '/w',
|
||||
});
|
||||
const data = JSON.parse(await readFile(targetPath(), 'utf-8'));
|
||||
expect(data.pid).toBe(process.pid);
|
||||
expect(data.qwen_version).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves no .tmp leftovers on success', async () => {
|
||||
await writeRuntimeStatus(targetPath(), {
|
||||
sessionId: 'abc',
|
||||
workDir: '/w',
|
||||
pid: 1,
|
||||
});
|
||||
const entries = await readdir(tmpDir);
|
||||
expect(entries.filter((e) => e.endsWith('.tmp'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('creates the parent directory on demand', async () => {
|
||||
const nested = path.join(tmpDir, 'a', 'b', 'runtime.json');
|
||||
await writeRuntimeStatus(nested, { sessionId: 'abc', workDir: '/w' });
|
||||
const data = JSON.parse(await readFile(nested, 'utf-8'));
|
||||
expect(data.session_id).toBe('abc');
|
||||
});
|
||||
|
||||
it('atomically overwrites the previous PID on resume', async () => {
|
||||
await writeRuntimeStatus(targetPath(), {
|
||||
sessionId: 'abc',
|
||||
workDir: '/w',
|
||||
pid: 1000,
|
||||
});
|
||||
const first = await readRuntimeStatus(targetPath());
|
||||
expect(first?.pid).toBe(1000);
|
||||
|
||||
await writeRuntimeStatus(targetPath(), {
|
||||
sessionId: 'abc',
|
||||
workDir: '/w',
|
||||
pid: 2000,
|
||||
});
|
||||
const second = await readRuntimeStatus(targetPath());
|
||||
expect(second?.pid).toBe(2000);
|
||||
});
|
||||
|
||||
it('preserves non-ASCII characters in path components and session ids', async () => {
|
||||
await writeRuntimeStatus(targetPath(), {
|
||||
sessionId: '中文-uuid-aaa',
|
||||
workDir: 'D:/项目/我的-app',
|
||||
pid: 7777,
|
||||
});
|
||||
const status = await readRuntimeStatus(targetPath());
|
||||
expect(status?.sessionId).toBe('中文-uuid-aaa');
|
||||
expect(status?.workDir).toBe('D:/项目/我的-app');
|
||||
const rawBytes = await readFile(targetPath());
|
||||
expect(rawBytes.includes(Buffer.from('中文', 'utf-8'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readRuntimeStatus', () => {
|
||||
it('round-trips a written record', async () => {
|
||||
await writeRuntimeStatus(targetPath(), {
|
||||
sessionId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
workDir: '/some/where',
|
||||
pid: 99,
|
||||
qwenVersion: '0.15.3',
|
||||
});
|
||||
const status = await readRuntimeStatus(targetPath());
|
||||
expect(status).not.toBeNull();
|
||||
expect(status!.pid).toBe(99);
|
||||
expect(status!.sessionId).toBe('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee');
|
||||
expect(status!.workDir).toBe('/some/where');
|
||||
expect(status!.schemaVersion).toBe(RUNTIME_STATUS_SCHEMA_VERSION);
|
||||
expect(status!.qwenVersion).toBe('0.15.3');
|
||||
});
|
||||
|
||||
it('returns null when the file is missing', async () => {
|
||||
expect(await readRuntimeStatus(targetPath())).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on malformed JSON', async () => {
|
||||
await writeFile(targetPath(), 'not-json', 'utf-8');
|
||||
expect(await readRuntimeStatus(targetPath())).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on an unknown schema version', async () => {
|
||||
await writeFile(
|
||||
targetPath(),
|
||||
JSON.stringify({
|
||||
schema_version: RUNTIME_STATUS_SCHEMA_VERSION + 99,
|
||||
pid: 1,
|
||||
session_id: 'x',
|
||||
work_dir: '/w',
|
||||
hostname: 'h',
|
||||
started_at: 0,
|
||||
qwen_version: null,
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
expect(await readRuntimeStatus(targetPath())).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when session_id has the wrong type', async () => {
|
||||
await writeFile(
|
||||
targetPath(),
|
||||
JSON.stringify({
|
||||
schema_version: RUNTIME_STATUS_SCHEMA_VERSION,
|
||||
pid: 1,
|
||||
session_id: null,
|
||||
work_dir: '/w',
|
||||
hostname: 'h',
|
||||
started_at: 0,
|
||||
qwen_version: null,
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
expect(await readRuntimeStatus(targetPath())).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when pid is a string', async () => {
|
||||
await writeFile(
|
||||
targetPath(),
|
||||
JSON.stringify({
|
||||
schema_version: RUNTIME_STATUS_SCHEMA_VERSION,
|
||||
pid: '1234',
|
||||
session_id: 'abc',
|
||||
work_dir: '/w',
|
||||
hostname: 'h',
|
||||
started_at: 0,
|
||||
qwen_version: null,
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
expect(await readRuntimeStatus(targetPath())).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when work_dir is an array', async () => {
|
||||
await writeFile(
|
||||
targetPath(),
|
||||
JSON.stringify({
|
||||
schema_version: RUNTIME_STATUS_SCHEMA_VERSION,
|
||||
pid: 1,
|
||||
session_id: 'abc',
|
||||
work_dir: ['/', 'w'],
|
||||
hostname: 'h',
|
||||
started_at: 0,
|
||||
qwen_version: null,
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
expect(await readRuntimeStatus(targetPath())).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on an array root payload', async () => {
|
||||
await writeFile(targetPath(), JSON.stringify([1, 2, 3]), 'utf-8');
|
||||
expect(await readRuntimeStatus(targetPath())).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on invalid UTF-8 bytes', async () => {
|
||||
// Truncated multi-byte sequence
|
||||
await writeFile(targetPath(), Buffer.from([0xff, 0xfe, 0x20, 0x67]));
|
||||
expect(await readRuntimeStatus(targetPath())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearRuntimeStatus', () => {
|
||||
it('removes an existing file', async () => {
|
||||
await writeRuntimeStatus(targetPath(), {
|
||||
sessionId: 'abc',
|
||||
workDir: '/w',
|
||||
pid: 1,
|
||||
});
|
||||
await clearRuntimeStatus(targetPath());
|
||||
expect(await readRuntimeStatus(targetPath())).toBeNull();
|
||||
});
|
||||
|
||||
it('is idempotent on a missing file', async () => {
|
||||
await clearRuntimeStatus(targetPath());
|
||||
await writeRuntimeStatus(targetPath(), {
|
||||
sessionId: 'abc',
|
||||
workDir: '/w',
|
||||
pid: 1,
|
||||
});
|
||||
await clearRuntimeStatus(targetPath());
|
||||
await clearRuntimeStatus(targetPath());
|
||||
});
|
||||
|
||||
it('does not throw on a non-existent directory', async () => {
|
||||
await clearRuntimeStatus(path.join(tmpDir, 'does-not-exist', 'r.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('same-PID session swap', () => {
|
||||
// Models the /clear, /reset, /new and /resume flow: same PID transitions
|
||||
// from session A to session B. The old sidecar must be removed before the
|
||||
// new one is written so external observers can't double-claim the PID.
|
||||
it('clears the old sidecar before writing the new one', async () => {
|
||||
const oldPath = path.join(tmpDir, 'session-a.runtime.json');
|
||||
const newPath = path.join(tmpDir, 'session-b.runtime.json');
|
||||
await writeRuntimeStatus(oldPath, {
|
||||
sessionId: 'session-a',
|
||||
workDir: '/w',
|
||||
pid: 4242,
|
||||
qwenVersion: '0.0.0-test',
|
||||
});
|
||||
expect(await readRuntimeStatus(oldPath)).not.toBeNull();
|
||||
|
||||
await clearRuntimeStatus(oldPath);
|
||||
await writeRuntimeStatus(newPath, {
|
||||
sessionId: 'session-b',
|
||||
workDir: '/w',
|
||||
pid: 4242,
|
||||
qwenVersion: '0.0.0-test',
|
||||
});
|
||||
|
||||
expect(await readRuntimeStatus(oldPath)).toBeNull();
|
||||
const after = await readRuntimeStatus(newPath);
|
||||
expect(after?.sessionId).toBe('session-b');
|
||||
expect(after?.pid).toBe(4242);
|
||||
});
|
||||
});
|
||||
250
packages/core/src/utils/runtimeStatus.ts
Normal file
250
packages/core/src/utils/runtimeStatus.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Runtime status sidecar for an active interactive Qwen Code session.
|
||||
*
|
||||
* This module writes a small JSON file alongside the session's chat log
|
||||
* while an interactive session is alive. It exists so that **external**
|
||||
* tools (terminal multiplexers, tab managers, IDE integrations,
|
||||
* observability daemons) can answer the question:
|
||||
*
|
||||
* "Which Qwen Code session is the running PID X serving?"
|
||||
*
|
||||
* The CLI does not embed the session id in `argv` for fresh
|
||||
* (non-resumed) sessions, and the OS process title can be truncated, so
|
||||
* a side-channel file that records the explicit
|
||||
* `(pid, session_id, work_dir, ...)` tuple is the most reliable
|
||||
* cross-platform signal.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - Written on session start (clean launch or resume); the resume case
|
||||
* atomically overwrites whatever the previous PID wrote.
|
||||
* - **Not** deleted on clean `/quit` or on crash. From an external
|
||||
* observer's standpoint the recorded PID no longer exists in either
|
||||
* case, so a liveness check is sufficient and an explicit cleanup
|
||||
* adds nothing.
|
||||
* - `clearRuntimeStatus` exists for the narrow case where the same PID
|
||||
* keeps running while no longer serving the recorded session
|
||||
* (e.g. a hypothetical future mode-switch). Not currently invoked.
|
||||
*
|
||||
* The file is written atomically (tmp-file + rename) and contains a
|
||||
* small, stable schema. External consumers should treat unknown fields
|
||||
* as forward-compatible additions.
|
||||
*/
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { isNodeError } from './errors.js';
|
||||
|
||||
export const RUNTIME_STATUS_SCHEMA_VERSION = 1;
|
||||
|
||||
/** Snapshot of a live Qwen Code session process for external observers. */
|
||||
export interface RuntimeStatus {
|
||||
schemaVersion: number;
|
||||
pid: number;
|
||||
sessionId: string;
|
||||
workDir: string;
|
||||
hostname: string;
|
||||
/** Epoch seconds (with sub-second precision). Matches kimi-cli's format. */
|
||||
startedAt: number;
|
||||
qwenVersion: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* On-disk JSON shape. Keys are snake_case to match the cross-tool
|
||||
* convention established by kimi-cli's `runtime.json`, so external
|
||||
* observers can use one parser for both ecosystems.
|
||||
*/
|
||||
interface RuntimeStatusOnDisk {
|
||||
schema_version: number;
|
||||
pid: number;
|
||||
session_id: string;
|
||||
work_dir: string;
|
||||
hostname: string;
|
||||
started_at: number;
|
||||
qwen_version: string | null;
|
||||
}
|
||||
|
||||
export interface WriteRuntimeStatusFields {
|
||||
sessionId: string;
|
||||
workDir: string;
|
||||
/** Defaults to `process.pid`. */
|
||||
pid?: number;
|
||||
/** Defaults to `null`. Pass the value of `getCliVersion()`. */
|
||||
qwenVersion?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically write the runtime status file at `filePath`.
|
||||
*
|
||||
* Writes via tmp-file + rename so an external observer never sees a
|
||||
* partially written file: it sees either the previous contents or the
|
||||
* fully committed new contents.
|
||||
*
|
||||
* The parent directory of `filePath` is created on demand. Exceptions
|
||||
* from the underlying I/O propagate to the caller; this function does
|
||||
* not log or swallow them. Callers that want best-effort semantics
|
||||
* should wrap the call in a try/catch. On failure no leftover `.tmp`
|
||||
* file is kept on disk.
|
||||
*/
|
||||
export async function writeRuntimeStatus(
|
||||
filePath: string,
|
||||
fields: WriteRuntimeStatusFields,
|
||||
): Promise<string> {
|
||||
const payload: RuntimeStatusOnDisk = {
|
||||
schema_version: RUNTIME_STATUS_SCHEMA_VERSION,
|
||||
pid: fields.pid ?? process.pid,
|
||||
session_id: fields.sessionId,
|
||||
work_dir: fields.workDir,
|
||||
hostname: os.hostname(),
|
||||
started_at: Date.now() / 1000,
|
||||
qwen_version: fields.qwenVersion ?? null,
|
||||
};
|
||||
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
|
||||
const tmpPath = `${filePath}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf-8');
|
||||
await renameWithRetry(tmpPath, filePath, 3, 50);
|
||||
} catch (err) {
|
||||
try {
|
||||
await fs.unlink(tmpPath);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the runtime status file at `filePath`, if present.
|
||||
*
|
||||
* Returns `null` if the file is missing, malformed (truncated UTF-8,
|
||||
* invalid JSON, non-object payload, wrong field types), or written by a
|
||||
* schema version this code does not understand. The function never
|
||||
* coerces null/array/object into a string just to satisfy the
|
||||
* dataclass.
|
||||
*
|
||||
* Note: a returned record only proves that *some* Qwen Code process
|
||||
* once claimed this session. The PID may already be dead (clean quit
|
||||
* or crash). Consumers must verify liveness themselves before treating
|
||||
* the record as a currently-running session.
|
||||
*/
|
||||
export async function readRuntimeStatus(
|
||||
filePath: string,
|
||||
): Promise<RuntimeStatus | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(filePath, 'utf-8');
|
||||
} catch (err) {
|
||||
if (isNodeError(err) && err.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
if (err instanceof Error && err.message.includes('utf-8')) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return null;
|
||||
}
|
||||
const obj = data as Record<string, unknown>;
|
||||
|
||||
// Schema gate first: an unknown schema_version is not our concern.
|
||||
if (obj['schema_version'] !== RUNTIME_STATUS_SCHEMA_VERSION) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const schemaVersion = obj['schema_version'];
|
||||
const pid = obj['pid'];
|
||||
const sessionId = obj['session_id'];
|
||||
const workDir = obj['work_dir'];
|
||||
const hostname = obj['hostname'];
|
||||
const startedAt = obj['started_at'];
|
||||
const qwenVersion = obj['qwen_version'];
|
||||
|
||||
if (!isFiniteIntegerNotBool(schemaVersion)) return null;
|
||||
if (!isFiniteIntegerNotBool(pid)) return null;
|
||||
if (typeof sessionId !== 'string') return null;
|
||||
if (typeof workDir !== 'string') return null;
|
||||
if (typeof hostname !== 'string') return null;
|
||||
if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) {
|
||||
return null;
|
||||
}
|
||||
if (qwenVersion !== null && typeof qwenVersion !== 'string') return null;
|
||||
|
||||
return {
|
||||
schemaVersion,
|
||||
pid,
|
||||
sessionId,
|
||||
workDir,
|
||||
hostname,
|
||||
startedAt,
|
||||
qwenVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the runtime status file at `filePath`, if present.
|
||||
*
|
||||
* Intentionally **not** called on `/quit` — when the qwen-code process
|
||||
* exits, an external observer's PID-liveness check already detects the
|
||||
* missing process, so a stale record is harmless. This helper exists
|
||||
* for the narrow case where the **same PID continues running** but
|
||||
* stops serving the recorded session.
|
||||
*
|
||||
* Safe to call multiple times and on paths that no longer exist;
|
||||
* `ENOENT` and other `OSError`-class failures are swallowed so cleanup
|
||||
* cannot disrupt the surrounding control flow.
|
||||
*/
|
||||
export async function clearRuntimeStatus(filePath: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch {
|
||||
// ignored: best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
function isFiniteIntegerNotBool(v: unknown): v is number {
|
||||
return (
|
||||
typeof v === 'number' &&
|
||||
Number.isInteger(v) &&
|
||||
Number.isFinite(v) &&
|
||||
typeof v !== 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
async function renameWithRetry(
|
||||
src: string,
|
||||
dest: string,
|
||||
retries: number,
|
||||
delayMs: number,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
await fs.rename(src, dest);
|
||||
return;
|
||||
} catch (err) {
|
||||
const retryable =
|
||||
isNodeError(err) && (err.code === 'EPERM' || err.code === 'EACCES');
|
||||
if (!retryable || attempt === retries) {
|
||||
throw err;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, delayMs * 2 ** attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue