Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,6 @@ 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';
Expand Down Expand Up @@ -308,6 +307,7 @@ export {
detectRuntime,
getOrCreateSharedDispatcher,
} from './utils/runtimeFetchOptions.js';
export * from './utils/runtimeStatus.js';
export * from './utils/schemaValidator.js';
export * from './utils/shell-utils.js';
export * from './utils/subagentGenerator.js';
Expand Down
160 changes: 160 additions & 0 deletions packages/core/src/utils/runtimeStatus.config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Integration coverage for the runtime.json sidecar wiring through
* Config.startNewSession(). The unit tests in runtimeStatus.test.ts
* exercise the module in isolation; this file pins the contract that
* /clear, /reset, /new and /resume — all of which flow through
* startNewSession() — actually drive the sidecar swap, and only when
* the interactive UI bootstrap has flipped runtimeStatusEnabled on.
*/

import { mkdtemp, readdir, rm } 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 { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import { readRuntimeStatus, writeRuntimeStatus } from './runtimeStatus.js';

let tmpDir: string;
let runtimeDir: string;
let prevRuntimeEnv: string | undefined;

beforeEach(async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'qwen-rt-cfg-'));
runtimeDir = path.join(tmpDir, 'runtime');
prevRuntimeEnv = process.env['QWEN_RUNTIME_DIR'];
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
});

afterEach(async () => {
if (prevRuntimeEnv === undefined) {
delete process.env['QWEN_RUNTIME_DIR'];
} else {
process.env['QWEN_RUNTIME_DIR'] = prevRuntimeEnv;
}
await rm(tmpDir, { recursive: true, force: true });
});

function makeConfig(sessionId: string): Config {
return new Config({
sessionId,
cwd: tmpDir,
targetDir: tmpDir,
debugMode: false,
model: 'test-model',
usageStatisticsEnabled: false,
bareMode: true,
cliVersion: '0.0.0-test',
});
}

// The IIFE in startNewSession is fire-and-forget. Poll the filesystem
// briefly instead of guessing a fixed sleep — keeps the test fast on
// happy paths and resilient on slow CI.
async function waitFor<T>(
predicate: () => Promise<T | null>,
timeoutMs = 1000,
): Promise<T | null> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const value = await predicate();
if (value !== null) return value;
await new Promise((r) => setTimeout(r, 25));
}
return null;
}

describe('Config.startNewSession runtime.json swap', () => {
it('leaves sibling sidecars alone when this process did not bootstrap one', async () => {
const sessionA = 'aaaaaaaa-1111-2222-3333-aaaaaaaaaaaa';
const sessionB = 'bbbbbbbb-1111-2222-3333-bbbbbbbbbbbb';
const config = makeConfig(sessionA);

// Pretend a *different* process owns this session id and wrote its
// own sidecar (e.g. a long-lived shell). A non-interactive `/clear`
// in our process must not delete it.
const aPath = config.storage.getRuntimeStatusPath(sessionA);
await writeRuntimeStatus(aPath, {
sessionId: sessionA,
workDir: tmpDir,
qwenVersion: '0.0.0-test',
});

config.startNewSession(sessionB);
// Drain microtasks + any in-flight I/O the IIFE could have queued.
await new Promise((r) => setTimeout(r, 100));

expect(await readRuntimeStatus(aPath)).not.toBeNull();
const bPath = config.storage.getRuntimeStatusPath(sessionB);
expect(await readRuntimeStatus(bPath)).toBeNull();
});

it('clears the old sidecar and writes a new one when this process owns it', async () => {
const sessionA = 'aaaaaaaa-1111-2222-3333-aaaaaaaaaaaa';
const sessionB = 'bbbbbbbb-1111-2222-3333-bbbbbbbbbbbb';
const config = makeConfig(sessionA);

// Mimic what startInteractiveUI() does on launch: write the initial
// sidecar, then mark this Config as the owner.
const aPath = config.storage.getRuntimeStatusPath(sessionA);
await writeRuntimeStatus(aPath, {
sessionId: sessionA,
workDir: tmpDir,
qwenVersion: '0.0.0-test',
});
config.markRuntimeStatusEnabled();

config.startNewSession(sessionB);

const bPath = config.storage.getRuntimeStatusPath(sessionB);
const after = await waitFor(() => readRuntimeStatus(bPath));
expect(after).not.toBeNull();
expect(after!.sessionId).toBe(sessionB);
expect(after!.pid).toBe(process.pid);

expect(await readRuntimeStatus(aPath)).toBeNull();
});

it('skips the swap when the session id does not change', async () => {
const sessionA = 'aaaaaaaa-1111-2222-3333-aaaaaaaaaaaa';
const config = makeConfig(sessionA);

const aPath = config.storage.getRuntimeStatusPath(sessionA);
await writeRuntimeStatus(aPath, {
sessionId: sessionA,
workDir: tmpDir,
qwenVersion: '0.0.0-test',
});
config.markRuntimeStatusEnabled();

const before = await readRuntimeStatus(aPath);

// Pass the same id back in — startNewSession should be a no-op for
// the sidecar so we don't churn the file (and lose started_at).
config.startNewSession(sessionA);
await new Promise((r) => setTimeout(r, 100));

const after = await readRuntimeStatus(aPath);
expect(after?.startedAt).toBe(before?.startedAt);

// No stray sidecars created in the chats/ dir.
const chatsDir = path.dirname(aPath);
const entries = await readdir(chatsDir);
expect(entries.filter((e) => e.endsWith('.runtime.json'))).toEqual([
`${sessionA}.runtime.json`,
]);
});
});

describe('Storage.getRuntimeStatusPath', () => {
it('co-locates the sidecar under <projectDir>/chats/', () => {
const storage = new Storage(tmpDir);
const p = storage.getRuntimeStatusPath('abc-123');
expect(p.endsWith(path.join('chats', 'abc-123.runtime.json'))).toBe(true);
expect(p.startsWith(storage.getProjectDir())).toBe(true);
});
});
21 changes: 5 additions & 16 deletions packages/core/src/utils/runtimeStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,7 @@ export async function readRuntimeStatus(
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;
}
} catch {
return null;
}

Expand Down Expand Up @@ -177,8 +171,8 @@ export async function readRuntimeStatus(
const startedAt = obj['started_at'];
const qwenVersion = obj['qwen_version'];

if (!isFiniteIntegerNotBool(schemaVersion)) return null;
if (!isFiniteIntegerNotBool(pid)) return null;
if (!isFiniteInteger(schemaVersion)) return null;
if (!isFiniteInteger(pid)) return null;
if (typeof sessionId !== 'string') return null;
if (typeof workDir !== 'string') return null;
if (typeof hostname !== 'string') return null;
Expand Down Expand Up @@ -219,13 +213,8 @@ export async function clearRuntimeStatus(filePath: string): Promise<void> {
}
}

function isFiniteIntegerNotBool(v: unknown): v is number {
return (
typeof v === 'number' &&
Number.isInteger(v) &&
Number.isFinite(v) &&
typeof v !== 'boolean'
);
function isFiniteInteger(v: unknown): v is number {
return typeof v === 'number' && Number.isInteger(v);
}

async function renameWithRetry(
Expand Down
Loading