Skip to content
Closed
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
37 changes: 37 additions & 0 deletions docs/design/immediate-auto-update-relaunch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Immediate Automatic Update Relaunch

## Problem

Updating a global installation in place while the interactive CLI is running
removes content-hashed chunks that the old process may still import. Deferring
the install until the user exits avoids that corruption, but delays every
automatic update and reopens a session the user intentionally closed.

## Design

Reuse the existing update relaunch handoff:

1. The post-render update check discovers an installable update.
2. The UI exits with the update relaunch code after normal cleanup. If a model
or tool turn, prompt or automatic-notification queue, slash command, or
unsent draft is active, it waits for a safe idle boundary.
3. The supervisor rechecks and installs the update after the child is gone.
4. The stable launcher starts the updated CLI with the original options and
resumes the exact durable session without replaying the initial prompt. For
a session with no recorded messages, the handoff independently records
whether the initial prompt was already consumed: an unconsumed prompt still
runs, while an executed slash or shell command is not replayed.

The production wrapper supplies an explicit capability marker only after it
resolves a stable launcher, plus a private handoff file for the session ID.
Container and macOS sandboxes receive the same handoff. If no stable launcher
is available, or a conversation cannot be resumed because recording is
disabled or failed, keep the current manual guidance. Custom or manually
managed sandbox hosts and sessions started with `--worktree` remain manual.
Windows standalone installs download in the background but apply after exit
because the running executable is locked.

## Non-goals

This does not hot-swap modules in a running process or introduce versioned
installation directories.
243 changes: 234 additions & 9 deletions packages/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
rmSync,
writeFileSync,
} from 'node:fs';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { FatalError } from '@qwen-code/qwen-code-core';
Expand Down Expand Up @@ -394,11 +394,11 @@ describe('bootstrap import boundaries', () => {
);
writeFileSync(
path.join(oldDir, 'cli.js'),
`import { chmodSync, rmSync, writeFileSync } from 'node:fs';\nwriteFileSync(${JSON.stringify(binPath)}, ${JSON.stringify(`#!/bin/sh\nexec "${process.execPath}" "${path.join(newDir, 'entry.mjs')}" "$@"\n`)});\nchmodSync(${JSON.stringify(binPath)}, 0o755);\nrmSync(${JSON.stringify(oldDir)}, { recursive: true, force: true });\nprocess.exit(44);\n`,
`import { chmodSync, rmSync, writeFileSync } from 'node:fs';\nwriteFileSync(process.env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH, JSON.stringify({ sessionId: '123e4567-e89b-12d3-a456-426614174000' }));\nwriteFileSync(${JSON.stringify(binPath)}, ${JSON.stringify(`#!/bin/sh\nexec "${process.execPath}" "${path.join(newDir, 'entry.mjs')}" "$@"\n`)});\nchmodSync(${JSON.stringify(binPath)}, 0o755);\nrmSync(${JSON.stringify(oldDir)}, { recursive: true, force: true });\nprocess.exit(44);\n`,
);
writeFileSync(
path.join(newDir, 'cli.js'),
"process.stdout.write(`${JSON.stringify({ args: process.argv.slice(2), skip: process.env.QWEN_CODE_SKIP_UPDATE_CHECK_ONCE, hasLauncherPid: /^\\d+$/.test(process.env.QWEN_CODE_LAUNCHER_PID ?? ''), launcherPath: process.env.QWEN_CODE_LAUNCHER_PATH })}\\n`);\n",
"process.stdout.write(`${JSON.stringify({ args: process.argv.slice(2), skip: process.env.QWEN_CODE_SKIP_UPDATE_CHECK_ONCE, skipPrompt: process.env.QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE, hasLauncherPid: /^\\d+$/.test(process.env.QWEN_CODE_LAUNCHER_PID ?? ''), launcherPath: process.env.QWEN_CODE_LAUNCHER_PATH })}\\n`);\n",
);
writeFileSync(
binPath,
Expand All @@ -411,22 +411,247 @@ describe('bootstrap import boundaries', () => {
);
chmodSync(path.join(wrongDir, 'qwen'), 0o755);

const output = execFileSync(binPath, ['--prompt', 'a&b'], {
const output = execFileSync(
binPath,
[
'--prompt',
'a&b',
'--fork-session',
'false',
'-r=old-session',
'-c',
'false',
],
{
encoding: 'utf8',
env: {
...process.env,
PATH: `${wrongDir}${path.delimiter}${tempDir}${path.delimiter}${process.env['PATH'] ?? ''}`,
},
},
);

expect(JSON.parse(output)).toEqual({
args: [
'--prompt',
'a&b',
'--resume=123e4567-e89b-12d3-a456-426614174000',
],
skip: 'true',
skipPrompt: 'true',
hasLauncherPid: true,
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
rmSync(wrongDir, { recursive: true, force: true });
}
});

it('rejects an invalid resume session from the update handoff', () => {
const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-fresh-update-'));
const oldDir = path.join(tempDir, 'old');
const newDir = path.join(tempDir, 'new');
const binPath = path.join(tempDir, 'qwen');
try {
mkdirSync(oldDir);
mkdirSync(newDir);
copyFileSync(
'../../scripts/cli-entry.js',
path.join(oldDir, 'entry.mjs'),
);
copyFileSync(
'../../scripts/cli-entry.js',
path.join(newDir, 'entry.mjs'),
);
writeFileSync(
path.join(oldDir, 'cli.js'),
`import { chmodSync, writeFileSync } from 'node:fs';\nwriteFileSync(process.env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH, JSON.stringify({ sessionId: '--no-sandbox' }));\nwriteFileSync(${JSON.stringify(binPath)}, ${JSON.stringify(`#!/bin/sh\nexec "${process.execPath}" "${path.join(newDir, 'entry.mjs')}" "$@"\n`)});\nchmodSync(${JSON.stringify(binPath)}, 0o755);\nprocess.exit(44);\n`,
);
writeFileSync(
path.join(newDir, 'cli.js'),
'process.stdout.write(JSON.stringify({ args: process.argv.slice(2), skipPrompt: process.env.QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE }));\n',
);
writeFileSync(
binPath,
`#!/bin/sh\nexec "${process.execPath}" "${path.join(oldDir, 'entry.mjs')}" "$@"\n`,
);
chmodSync(binPath, 0o755);

const output = execFileSync(binPath, ['--prompt-interactive', 'hello'], {
encoding: 'utf8',
env: {
...process.env,
PATH: `${wrongDir}${path.delimiter}${tempDir}${path.delimiter}${process.env['PATH'] ?? ''}`,
PATH: `${tempDir}${path.delimiter}${process.env['PATH'] ?? ''}`,
},
});

expect(JSON.parse(output)).toEqual({
args: ['--prompt', 'a&b'],
skip: 'true',
hasLauncherPid: true,
args: ['--prompt-interactive', 'hello'],
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
rmSync(wrongDir, { recursive: true, force: true });
}
});

it('does not replay a consumed initial command in a fresh session', () => {
const tempDir = mkdtempSync(
path.join(tmpdir(), 'qwen-cli-command-update-'),
);
const oldDir = path.join(tempDir, 'old');
const newDir = path.join(tempDir, 'new');
const binPath = path.join(tempDir, 'qwen');
try {
mkdirSync(oldDir);
mkdirSync(newDir);
copyFileSync(
'../../scripts/cli-entry.js',
path.join(oldDir, 'entry.mjs'),
);
copyFileSync(
'../../scripts/cli-entry.js',
path.join(newDir, 'entry.mjs'),
);
writeFileSync(
path.join(oldDir, 'cli.js'),
`import { chmodSync, writeFileSync } from 'node:fs';\nwriteFileSync(process.env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH, JSON.stringify({ skipInitialPrompt: true }));\nwriteFileSync(${JSON.stringify(binPath)}, ${JSON.stringify(`#!/bin/sh\nexec "${process.execPath}" "${path.join(newDir, 'entry.mjs')}" "$@"\n`)});\nchmodSync(${JSON.stringify(binPath)}, 0o755);\nprocess.exit(44);\n`,
);
writeFileSync(
path.join(newDir, 'cli.js'),
'process.stdout.write(JSON.stringify({ args: process.argv.slice(2), skipPrompt: process.env.QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE }));\n',
);
writeFileSync(
binPath,
`#!/bin/sh\nexec "${process.execPath}" "${path.join(oldDir, 'entry.mjs')}" "$@"\n`,
);
chmodSync(binPath, 0o755);

const output = execFileSync(
binPath,
['--prompt-interactive', '/update'],
{
encoding: 'utf8',
env: {
...process.env,
PATH: `${tempDir}${path.delimiter}${process.env['PATH'] ?? ''}`,
},
},
);

expect(JSON.parse(output)).toEqual({
args: ['--prompt-interactive', '/update'],
skipPrompt: 'true',
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});

it('does not advertise update relaunch without a stable launcher', () => {
const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-no-launcher-'));
const entryPath = path.join(tempDir, 'entry.mjs');
try {
copyFileSync('../../scripts/cli-entry.js', entryPath);
writeFileSync(
path.join(tempDir, 'cli.js'),
'process.stdout.write(JSON.stringify({ supported: process.env.QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED, statePath: process.env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH }));\n',
);

const output = execFileSync(process.execPath, [entryPath], {
encoding: 'utf8',
env: { ...process.env, PATH: '' },
});

expect(JSON.parse(output)).toEqual({});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});

it('does not advertise update relaunch for startup worktrees', () => {
const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-worktree-'));
const entryPath = path.join(tempDir, 'entry.mjs');
const launcherPath = path.join(tempDir, 'qwen');
try {
copyFileSync('../../scripts/cli-entry.js', entryPath);
writeFileSync(
path.join(tempDir, 'cli.js'),
'process.stdout.write(JSON.stringify({ supported: process.env.QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED, statePath: process.env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH }));\n',
);
writeFileSync(launcherPath, '#!/bin/sh\n');
chmodSync(launcherPath, 0o755);

for (const worktreeArg of ['--worktree', '--worktree=feature']) {
const output = execFileSync(
process.execPath,
[entryPath, worktreeArg],
{
encoding: 'utf8',
env: {
...process.env,
QWEN_CODE_LAUNCHER_PATH: launcherPath,
QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED: 'true',
QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH: '/tmp/forged-state.json',
},
},
);

expect(JSON.parse(output)).toEqual({});
}
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});

it('preserves the host update handoff across a sandbox wrapper', () => {
const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-sandbox-'));
const entryPath = path.join(tempDir, 'entry.mjs');
const statePath = path.join(tempDir, 'state.json');
try {
copyFileSync('../../scripts/cli-entry.js', entryPath);
writeFileSync(
path.join(tempDir, 'cli.js'),
`import { writeFileSync } from 'node:fs';\nwriteFileSync(process.env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH, '{"skipInitialPrompt":true}');\nprocess.exit(43);\n`,
);

const result = spawnSync(process.execPath, [entryPath], {
env: {
...process.env,
SANDBOX: 'container',
QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED: 'true',
QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH: statePath,
},
});

expect(result.status).toBe(43);
expect(readFileSync(statePath, 'utf8')).toBe(
'{"skipInitialPrompt":true}',
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});

it('starts normally when the relaunch state directory is unavailable', () => {
const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-no-tmp-'));
const entryPath = path.join(tempDir, 'entry.mjs');
const launcherPath = path.join(tempDir, 'qwen');
try {
copyFileSync('../../scripts/cli-entry.js', entryPath);
writeFileSync(path.join(tempDir, 'cli.js'), 'process.exit(0);\n');
writeFileSync(launcherPath, '#!/bin/sh\n');
chmodSync(launcherPath, 0o755);

const result = spawnSync(process.execPath, [entryPath], {
env: {
...process.env,
TMPDIR: '/dev/null',
QWEN_CODE_LAUNCHER_PATH: launcherPath,
},
});

expect(result.status).toBe(0);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});

Expand Down
Loading
Loading