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 .github/workflows/windows-smoke.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Windows smoke
name: Windows beta gate

on:
push:
Expand Down
5 changes: 1 addition & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,4 @@ examples/

# Local-only publishing and planning documents
.npmrc
MVP2_BETA_TEST_PLAN.md
MVP2_BETA_TEST_PLAN.zh-CN.md
OPTIMIZATION_PLAN.md
COMMAND_REFACTOR_*.md
CONTEXT_TEAM_REFACTOR_PLAN.md
10 changes: 9 additions & 1 deletion docs/12-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,15 @@ review policy v2 只允许 `clarification` 和用户明确要求跳过的整个

Windows CI 会在同一个 `windows-latest` runner 上分别从 CMD、PowerShell 和 Git
Bash 执行 smoke test。测试清空子进程 PATH 来模拟 Git/Bash 不可用,验证 Codex
初始化、solo 降级、Claude Code 初始化和两个 Node hooks 均能完成。
初始化、solo 降级、Claude Code 初始化和两个 Node hooks 均能完成。它还会让
PowerShell 以不允许删除的共享模式实际打开 V3 session evidence 文件,再运行一次
`context session spike`;原子替换必须在句柄释放后自行重试并成功。该 job 名为
`Windows beta gate`,应作为 Beta 发布的必需 CI 检查。

V3 Beta 还必须在目标项目执行 `mancode context beta --json`。该命令会拒绝
未激活或不兼容的 V3 authority、未安装的 V3 bootstrap、未完成的 repair journal、
缺失 checkout binding,以及没有五个平台真实双窗口与子进程传播证据的项目。session
evidence 文件不保存宿主 session key;只有在每个平台宿主中完成 spike 后才能通过。

---

Expand Down
32 changes: 31 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@
"prepublishOnly": "npm run lint && npm run typecheck && npm run build && npm test"
},
"dependencies": {
"commander": "^12.1.0"
"commander": "^12.1.0",
"js-tiktoken": "^1.0.21"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
Expand Down
148 changes: 147 additions & 1 deletion scripts/windows-smoke.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { spawnSync } from 'node:child_process';
import { spawn, spawnSync } from 'node:child_process';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
Expand Down Expand Up @@ -119,6 +119,8 @@ try {
JSON.stringify({ prompt: 'update README' }),
);

await assertV3SessionEvidenceRenameUnderOpenWindowsHandle();

console.log('Windows shell smoke passed.');
} finally {
await rm(root, { recursive: true, force: true });
Expand Down Expand Up @@ -183,6 +185,150 @@ function runHook(cwd, fileName, input = '') {
);
}

async function assertV3SessionEvidenceRenameUnderOpenWindowsHandle() {
if (process.platform !== 'win32') {
console.log('Skipping Windows open-file rename assertion outside Windows.');
return;
}
const project = await createProject('v3-open-file-rename-project');
const spikeEnv = {
...noToolPath,
MANCODE_SPIKE_HOST_SESSION_KEY: 'windows-smoke-host-a',
MANCODE_SPIKE_SECOND_WINDOW_HOST_SESSION_KEY: 'windows-smoke-host-b',
};
runCli(project, ['init', '--v3'], spikeEnv);
const spikeArgs = [
'context',
'session',
'spike',
'--platform',
'codex',
'--host-session-source',
'api',
];
runCli(project, spikeArgs, spikeEnv);
const evidenceTarget = path.join(
project,
'.mancode',
'local',
'evidence',
'platform-session',
'codex.json',
);
await assertReplaceAfterOpenWindowsHandle(evidenceTarget, () =>
runCli(project, spikeArgs, spikeEnv),
);
const evidence = await readFile(evidenceTarget, 'utf8');
assert(
evidence.includes('"platform": "codex"'),
'V3 session evidence was not atomically replaced after the Windows handle closed',
);

runCli(project, ['team', 'identity', 'create', '--name', 'Windows Smoke']);
const sessionResult = runCli(project, [
'context',
'session',
'new',
'--client',
'windows-smoke',
]);
const sessionId = JSON.parse(sessionResult.stdout).session.sessionId;
const workflowResult = runCli(project, [
'workflow',
'create',
'man',
'Exercise a locked Windows session authority replacement.',
'--session',
sessionId,
'--client',
'windows-smoke',
]);
const taskRef = JSON.parse(workflowResult.stdout).taskRef;
const sessionTarget = path.join(
project,
'.mancode',
'local',
'sessions',
`${sessionId}.json`,
);
await assertReplaceAfterOpenWindowsHandle(sessionTarget, () =>
runCli(project, [
'context',
'resume',
`${taskRef.namespace}:${taskRef.taskId}`,
'--session',
sessionId,
'--client',
'windows-smoke',
]),
);
const session = await readJson(sessionTarget);
assert(
session.activeTaskRef?.taskId === taskRef.taskId,
'V3 session authority was not atomically replaced after the Windows handle closed',
);
}

async function assertReplaceAfterOpenWindowsHandle(target, replace) {
const lock = spawn(
'powershell.exe',
[
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-Command',
'$path = [Environment]::GetEnvironmentVariable("MANCODE_RENAME_TARGET"); $stream = [System.IO.File]::Open($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read); Write-Output "locked"; Start-Sleep -Milliseconds 500; $stream.Dispose()',
],
{
env: { ...process.env, MANCODE_RENAME_TARGET: target },
stdio: ['ignore', 'pipe', 'pipe'],
},
);
const lockExit = waitForExit(lock);
try {
await waitForLock(lock);
replace();
} finally {
await lockExit;
}
}

function waitForLock(child) {
return new Promise((resolve, reject) => {
let stderr = '';
const timer = setTimeout(() => {
reject(new Error(`timed out waiting for Windows file lock: ${stderr}`));
}, 5_000);
child.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
child.stdout.on('data', (chunk) => {
if (String(chunk).includes('locked')) {
clearTimeout(timer);
resolve();
}
});
child.once('error', (error) => {
clearTimeout(timer);
reject(error);
});
child.once('exit', (code) => {
clearTimeout(timer);
reject(new Error(`Windows file lock exited early (${code}): ${stderr}`));
});
});
}

function waitForExit(child) {
return new Promise((resolve, reject) => {
child.once('error', reject);
child.once('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`Windows file lock failed with exit code ${code}`));
});
});
}

async function readJson(filePath) {
return JSON.parse(await readFile(filePath, 'utf8'));
}
Expand Down
Loading
Loading