diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 8923e9d..ef764fb 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -38,8 +38,8 @@ jobs:
--build-arg "DEBIAN_SECURITY_MIRROR=${DEBIAN_SECURITY_MIRROR}"
--tag luowang:quality .
- - name: Phase 9 acceptance and quality matrix
- run: docker run --rm --init --ipc=host luowang:quality npm run test:acceptance
+ - name: Local acceptance and quality matrix (live is not run in CI)
+ run: docker run --rm --init --ipc=host luowang:quality npm run test:acceptance:local
- name: Build production image
run: >-
diff --git a/README.md b/README.md
index 7355db6..9c69c78 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,20 @@
# LuoWang
-罗网(LuoWang)是一个独立部署的 AI 场景测试 Harness。当前仓库已发布 v0.1.0,并已实现 Phase 0–9 的主要模块:安全配置控制台、唯一 GitHub 目标仓库索引、Main → Runner → Reviewer → Main Run、受控 Playwright MCP UI 执行、S3-compatible OSS 证据 Gateway、幂等归档和持久 FIFO 自动化队列,以及长期场景生命周期、三种场景维护模式、陌生项目初始化和运维控制台。
-
-v0.1.0 的 Phase 9 验收已使用隔离的本地样例仓库、样例 Web 应用和 test doubles 完成 34 个 AC 的自动化回归;这证明的是 local 基线,不是生产闭环的 live 联合验收。当前尚未完成真实 Provider + Pi Agent + Playwright MCP + 私有 OSS + GitHub + 非生产应用的联合证明,因此不能把 v0.1.0 或当前 `develop` 视为下一版本的发布验收已经通过。
+罗网(LuoWang)是一个独立部署的 AI 场景测试 Harness。当前仓库已完成 Phase 0–9 的主要模块,并已发布 v0.1.0:除了安全配置控制台、唯一 GitHub 目标仓库索引、Main → Runner → Reviewer → Main 的本地 Run、受控 Playwright MCP UI 执行、S3-compatible OSS 证据 Gateway、幂等归档和持久 FIFO 自动化队列,还支持长期场景生命周期、三种场景维护模式、陌生项目初始化,以及完整运维控制台。34 个原 MVP AC 已有本地 fixture/自动化回归;真实 GitHub、Provider、Pi、Playwright MCP、私有 OSS 和非生产应用的联合验收尚未完成,不能据此声明可发布。
## 本地启动
-优先使用 Docker Compose 复现固定的 Node、原生依赖和 Chromium 环境:
+优先使用下文的 Docker Compose 或固定 quality/runtime 镜像复现。原生运行需要 Node.js 24 和 npm;当 `better-sqlite3` 没有匹配的预编译包时,还需要 `python3`、`make` 和 `g++`。生产模式:
+
+```bash
+npm ci
+npm run build
+npm start
+```
+
+默认只监听 `127.0.0.1:3000`。打开 可查看控制台壳,健康检查地址为 。数据默认保存在 `/data`;本地开发可以设置 `LUOWANG_DATA_DIR` 到可写目录。执行 `npm run dev` 可以同时启动 Vite 和开发服务器。
+
+也可以使用 Docker Compose:
```bash
# 首次启动空数据卷前必须设置这两个值;不要把真实值提交到 Git。
@@ -19,25 +27,24 @@ docker compose down
Compose 将数据保存到 `luowang-data` 卷,并把宿主机端口绑定到 `127.0.0.1`。管理员密码只在空数据库首次启动时读取;主密钥只用于进程内派生 Secret Store 密钥,二者都不会写入 SQLite。
-如需直接在宿主机运行,需要 Node.js 24 和 npm 10 或更高版本。`better-sqlite3` 等依赖没有适用的预编译包时,`npm ci` 还需要系统已安装 `python3`、`make` 和 `g++`;缺少这些工具时请优先使用 Docker,不要跳过依赖安装或质量检查。
-
-```bash
-npm ci
-npm run build
-npm start
-```
-
-默认只监听 `127.0.0.1:3000`。打开 可查看控制台壳,健康检查地址为 。数据默认保存在 `/data`;本地开发可以设置 `LUOWANG_DATA_DIR` 到可写目录。执行 `npm run dev` 可以同时启动 Vite 和开发服务器。
-
## 验收状态
-当前命令:
+验收命令按证明范围严格分层:
```bash
-npm run test:acceptance
+# 无外部凭据;真实经过 Pi SDK Session 和本地模型协议服务。兼容别名 npm run test:acceptance 也只指向 local。
+npm run test:acceptance:local
+
+# 真实外部联合验收;缺少任一必需输入时列出 missing 名称并非零退出。
+npm run test:acceptance:live
+
+# 先执行公共质量与 local,再执行 live;live blocked/failed 时非零退出。
+npm run test:acceptance:release
```
-该命令当前只代表 **local acceptance**。它会运行公共质量命令,并使用临时 Git bare 仓库、Cynos 官网登录/注册样例应用、SQLite、队列、归档和 headless Chromium 回归 34 个 AC;报告保存在 `.cynos/acceptance//`。其中 Agent Run 证明注入 `FixtureSessionFactory`,浏览器证明直接使用 Playwright;它们不能代替真实 `createAgentSession()`、模型 custom tool 循环和 Playwright MCP 联合路径。正式的 local/live/release 分层命令将在生产闭环 Closure Phase 6 提供。
+`local` 使用临时 Git bare 仓库、样例应用、SQLite、队列、归档、headless Chromium 和本地可控模型协议服务;Agent 流程真实调用生产 `createAgentSession()`,但本地 test double 只能证明 `local.status=passed`。报告保存在 `.cynos/acceptance/-/`,分别记录 `local.status`、`live.status`、`release.status`、资源检查、逐 AC 证据和命令。CI 只运行 local,并明确不读取 live Secret。
+
+Closure 6 只提供 live 输入预检和诚实的 blocked 结果;真实 GitHub、Provider、Playwright MCP、私有 OSS、非生产应用和测试账号的联合流程在 Closure 7 执行。在它通过前,`release.status` 必须保持 blocked,不能用现有 GitHub 只读 smoke 或本地服务冒充 live passed。
当前可选的 live 路径只包含有限的 GitHub smoke,不覆盖真实 Provider、Pi Agent、Playwright MCP、私有 OSS 和非生产应用联合验收。它不会默认执行;如需运行,必须显式提供 `LUOWANG_ACCEPTANCE_LIVE=1`、`LUOWANG_SMOKE_REPOSITORY=https://github.com/cynos-ai/cynos-website` 和临时 `LUOWANG_SMOKE_GITHUB_TOKEN`。即使该有限 smoke 通过,也不能视为 live 或 release 证明完成。
diff --git a/package.json b/package.json
index 380eab6..b554b2f 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,10 @@
"lint": "eslint .",
"start": "node dist/server/main.js",
"test": "vitest run",
- "test:acceptance": "tsx tests/acceptance/phase9.ts",
+ "test:acceptance": "npm run test:acceptance:local",
+ "test:acceptance:local": "tsx tests/acceptance/closure.ts local",
+ "test:acceptance:live": "tsx tests/acceptance/closure.ts live",
+ "test:acceptance:release": "tsx tests/acceptance/closure.ts release",
"test:e2e": "npm run build && tsx tests/e2e/smoke.ts && tsx tests/e2e/phase8-ui-smoke.ts",
"test:e2e:github": "npm run build && tsx tests/e2e/phase2-github-smoke.ts",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.web.json",
diff --git a/src/server/automation/service.ts b/src/server/automation/service.ts
index dbb3f29..fbda7c5 100644
--- a/src/server/automation/service.ts
+++ b/src/server/automation/service.ts
@@ -95,6 +95,7 @@ class DefaultAutomationService implements AutomationService {
private activeQueueId: number | null = null;
private activeRunId: string | null = null;
private dispatching = false;
+ private dispatchScheduled = false;
private recovering = false;
constructor(
@@ -110,12 +111,8 @@ class DefaultAutomationService implements AutomationService {
async submitTestRequest(input: TestRequestInput): Promise {
const queued = this.options.queue.enqueue(input);
- const dispatched = await this.dispatchNext();
- const current = this.options.queue.get(queued.queueId) ?? queued;
- return {
- queue: current,
- run: dispatched?.queueId === queued.queueId ? dispatched.run : null,
- };
+ this.kickDispatch();
+ return { queue: queued, run: null };
}
listQueue(): TestRequestRecord[] {
@@ -164,10 +161,15 @@ class DefaultAutomationService implements AutomationService {
if (!this.options.runStore) return result;
const cutoff = this.now().getTime() - retentionDays * 24 * 60 * 60 * 1_000;
+ const current = await this.options.runs.current();
for (const run of this.options.runStore.list()) {
if (run.archiveStatus !== 'completed') continue;
const finishedAt = Date.parse(run.finishedAt);
if (Number.isNaN(finishedAt) || finishedAt > cutoff) continue;
+ if (current?.runId === run.runId || this.hasPendingRunOperations(run.runId)) {
+ result.skippedRunIds.push(run.runId);
+ continue;
+ }
try {
await this.workspaceStore.remove(run.runId, 'completed');
result.removedRunIds.push(run.runId);
@@ -201,6 +203,19 @@ class DefaultAutomationService implements AutomationService {
throw new AutomationServiceError('AUTOMATION_RUN_ACTIVE', '当前 Run 正在执行,不能清理');
}
const knownRun = await this.options.runs.get(runId);
+ const storedRun = this.options.runStore?.get(runId);
+ if (!storedRun || storedRun.archiveStatus !== 'completed') {
+ throw new AutomationServiceError(
+ 'AUTOMATION_CLEANUP_FAILED',
+ 'Run 归档尚未完成,必须保留 report、场景 patch 和 Issue 重试工件',
+ );
+ }
+ if (this.hasPendingRunOperations(runId)) {
+ throw new AutomationServiceError(
+ 'AUTOMATION_CLEANUP_FAILED',
+ 'Run 仍有关联的队列、归档或发布重试操作,不能清理',
+ );
+ }
let removed = false;
for (const placement of ['running', 'completed'] as const) {
if ((await this.workspaceStore.list(placement)).includes(runId)) {
@@ -218,6 +233,13 @@ class DefaultAutomationService implements AutomationService {
return this.options.state;
}
+ private hasPendingRunOperations(runId: string): boolean {
+ return this.options.queue
+ .list()
+ .filter((item) => item.runId === runId || queueRunId(item) === runId)
+ .some((item) => item.status !== 'completed' || item.archiveStatus !== 'completed');
+ }
+
private async dispatchNext(): Promise<{ queueId: number; run: RunSummary } | null> {
if (this.activeQueueId !== null || this.dispatching || this.recovering) return null;
this.dispatching = true;
@@ -246,9 +268,7 @@ class DefaultAutomationService implements AutomationService {
await this.failQueueItem(item.queueId, safeMessage(error));
this.activeQueueId = null;
this.activeRunId = null;
- Promise.resolve()
- .then(() => this.dispatchNext())
- .catch(() => undefined);
+ this.kickDispatch();
return null;
}
} finally {
@@ -290,10 +310,24 @@ class DefaultAutomationService implements AutomationService {
this.activeQueueId = null;
this.activeRunId = null;
}
- await this.dispatchNext();
+ this.kickDispatch();
}
}
+ private kickDispatch(): void {
+ if (this.dispatchScheduled) return;
+ this.dispatchScheduled = true;
+ setImmediate(() => {
+ this.dispatchScheduled = false;
+ this.dispatchNext().catch((error: unknown) => {
+ this.options.logger?.error(
+ { errorName: error instanceof Error ? error.name : 'UnknownError' },
+ 'background automation dispatch failed',
+ );
+ });
+ });
+ }
+
private async recoverQueueItem(item: TestRequestRecord): Promise {
if (item.status === 'waiting_archive') {
if (item.runId) {
diff --git a/src/server/runs/agent-session.ts b/src/server/runs/agent-session.ts
index 12d116a..b445c86 100644
--- a/src/server/runs/agent-session.ts
+++ b/src/server/runs/agent-session.ts
@@ -84,16 +84,20 @@ class PiAgentSessionFactory implements AgentSessionFactory {
class ManagedAgentSession implements AgentSession {
private disposed = false;
+ readonly sessionId: string;
constructor(
private readonly session: {
+ sessionId: string;
prompt(message: string): Promise;
dispose(): void;
extensionRunner: {
emit(event: SessionShutdownEvent): Promise;
};
},
- ) {}
+ ) {
+ this.sessionId = session.sessionId;
+ }
prompt(message: string): Promise {
if (this.disposed) return Promise.reject(new Error('Agent session 已释放'));
diff --git a/src/server/runs/evidence.ts b/src/server/runs/evidence.ts
index 3759441..bf2186e 100644
--- a/src/server/runs/evidence.ts
+++ b/src/server/runs/evidence.ts
@@ -20,6 +20,11 @@ export interface EvidenceUploadFailure {
message: string;
}
+export interface EvidenceCleanupResult {
+ deleted: string[];
+ failures: EvidenceUploadFailure[];
+}
+
export interface RunEvidenceStore {
list(): Promise;
upload(filename: string): Promise;
@@ -27,6 +32,7 @@ export interface RunEvidenceStore {
read(filename: string): Promise;
readUploaded?(filename: string): Promise;
cleanupLocal(): Promise;
+ cleanupUploaded(): Promise;
readFailureCount?: () => number;
recordReadFailure?: () => void;
reviewReadCount?: () => number;
@@ -156,6 +162,21 @@ class DefaultRunEvidenceStore implements RunEvidenceStore {
return this.workspace.removeEvidence();
}
+ async cleanupUploaded(): Promise {
+ const deleted: string[] = [];
+ const failures: EvidenceUploadFailure[] = [];
+ for (const [filename, reference] of this.references) {
+ try {
+ await this.oss.deleteObject(reference.objectKey);
+ this.references.delete(filename);
+ deleted.push(filename);
+ } catch (error) {
+ failures.push({ filename, message: safeMessage(error) });
+ }
+ }
+ return { deleted, failures };
+ }
+
readFailureCount(): number {
return this.readFailures;
}
diff --git a/src/server/runs/orchestrator.ts b/src/server/runs/orchestrator.ts
index b379538..c6209e9 100644
--- a/src/server/runs/orchestrator.ts
+++ b/src/server/runs/orchestrator.ts
@@ -359,7 +359,13 @@ class DefaultRunOrchestrator implements RunOrchestrator {
}
scenarioDecision = await this.prepareScenarioPatch(workspace, prepared.repository, context);
if (scenarioDecision === 'review') {
- await this.finishScenarioReviewRun(state, workspace, context);
+ const closure = await this.finishScenarioReviewRunner(
+ state,
+ workspace,
+ context,
+ evidenceStore,
+ );
+ await this.finishScenarioReviewRun(state, workspace, context, closure);
return;
}
let runnerCleanup: { uploaded: boolean; uploadFailed: boolean } = {
@@ -680,14 +686,60 @@ class DefaultRunOrchestrator implements RunOrchestrator {
return 'applied';
}
+ private async finishScenarioReviewRunner(
+ state: RunState,
+ workspace: RunWorkspace,
+ context: RunContext,
+ evidenceStore: RunEvidenceStore | undefined,
+ ): Promise {
+ try {
+ await this.finishRunner(state, workspace, context, evidenceStore);
+ } catch (error) {
+ this.addBlockingReason(context, `Runner 收尾失败:${safeMessage(error)}`);
+ await this.appendExecutionNotes(workspace, [
+ `Runner 收尾失败,特殊场景审核 Run 已阻塞:${safeMessage(error)}`,
+ ]).catch(() => undefined);
+ }
+ const testData = await this.finalizeTestData(workspace, context);
+ if (!evidenceStore) {
+ return { testDataMessage: testData.message, evidenceDeleted: 0, evidenceDeleteFailures: 0 };
+ }
+
+ const cleanup = await evidenceStore.cleanupUploaded();
+ if (cleanup.deleted.length > 0) {
+ await this.appendExecutionNotes(workspace, [
+ `特殊场景审核不保留执行 evidence;已删除 ${cleanup.deleted.length} 个已上传 OSS 对象。`,
+ ]);
+ }
+ for (const failure of cleanup.failures) {
+ this.addBlockingReason(context, `特殊场景审核 evidence 删除失败:${failure.filename}`);
+ }
+ if (cleanup.failures.length > 0) {
+ await this.appendExecutionNotes(
+ workspace,
+ cleanup.failures.map(
+ (failure) => `特殊场景审核 evidence 删除失败:${failure.filename}(${failure.message})`,
+ ),
+ );
+ }
+ context.evidence = [];
+ state.evidence = [];
+ return {
+ testDataMessage: testData.message,
+ evidenceDeleted: cleanup.deleted.length,
+ evidenceDeleteFailures: cleanup.failures.length,
+ };
+ }
+
private async finishScenarioReviewRun(
state: RunState,
workspace: RunWorkspace,
context: RunContext,
+ closure: ScenarioReviewClosure,
): Promise {
this.addBlockingReason(context, 'Run 等待场景变更人工审核,不等待 PR 合并');
const finishedAt = this.now().toISOString();
- const reportContent = buildScenarioReviewReport(state, context, finishedAt);
+ const reportContent = buildScenarioReviewReport(state, context, finishedAt, closure);
await workspace.writer('main-b').writeReport(reportContent);
const report = parseReportMarkdown(
reportContent,
@@ -976,7 +1028,10 @@ class DefaultRunOrchestrator implements RunOrchestrator {
return { uploaded, uploadFailed };
}
- private async finalizeTestData(workspace: RunWorkspace, context: RunContext): Promise {
+ private async finalizeTestData(
+ workspace: RunWorkspace,
+ context: RunContext,
+ ): Promise> {
const result = (this.options.testData ?? createTestDataManager()).finalize(context.runId);
const notes = [`测试数据最终核验:${result.message}`];
if (!result.ok) {
@@ -992,6 +1047,7 @@ class DefaultRunOrchestrator implements RunOrchestrator {
notes.push(...context.blockingReasons.map((reason) => `Harness 阻塞:${reason}`));
}
await this.appendExecutionNotes(workspace, notes);
+ return result;
}
private async cleanupRetainedEvidence(
@@ -1512,10 +1568,17 @@ async function readOptionalScenarioPatch(workspace: RunWorkspace): Promise;
dispose(): void | Promise;
}
diff --git a/src/server/runs/workspace.ts b/src/server/runs/workspace.ts
index 364bdae..6535843 100644
--- a/src/server/runs/workspace.ts
+++ b/src/server/runs/workspace.ts
@@ -170,9 +170,19 @@ export class RunWorkspace implements RunArtifactReader {
if (error instanceof RunWorkspaceError) throw error;
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
+ if (options.specialScenarioReview) await this.retainSpecialScenarioReviewArtifacts();
await rename(this.runningDirectory, this.completedDirectory);
}
+ private async retainSpecialScenarioReviewArtifacts(): Promise {
+ const retained = new Set([SCENARIO_PATCH_ARTIFACT_NAME, 'report.md']);
+ for (const entry of await readdir(this.runningDirectory)) {
+ if (!retained.has(entry)) {
+ await rm(resolve(this.runningDirectory, entry), { recursive: true, force: false });
+ }
+ }
+ }
+
async listEvidence(): Promise {
const files: RunEvidenceFile[] = [];
await this.walkEvidence(this.evidenceDirectory, '', files);
diff --git a/tests/acceptance/closure.ts b/tests/acceptance/closure.ts
new file mode 100644
index 0000000..5cbab80
--- /dev/null
+++ b/tests/acceptance/closure.ts
@@ -0,0 +1,818 @@
+import { execFile } from 'node:child_process';
+import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { promisify } from 'node:util';
+
+const execFileAsync = promisify(execFile);
+
+export const PUBLIC_QUALITY_SCRIPTS = [
+ 'format:check',
+ 'lint',
+ 'typecheck',
+ 'test',
+ 'build',
+ 'test:e2e',
+] as const;
+
+export const LIVE_INPUT_NAMES = [
+ 'LUOWANG_LIVE_REPOSITORY',
+ 'LUOWANG_LIVE_INITIAL_REF',
+ 'LUOWANG_LIVE_TARGET_ALLOWLIST',
+ 'LUOWANG_LIVE_GITHUB_TOKEN',
+ 'LUOWANG_ADMIN_PASSWORD',
+ 'LUOWANG_MASTER_KEY',
+ 'LUOWANG_LIVE_BASE_URL',
+ 'LUOWANG_LIVE_NON_PRODUCTION_CONFIRMED',
+ 'LUOWANG_LIVE_TEST_ACCOUNT_DEDICATED_CONFIRMED',
+ 'LUOWANG_LIVE_TEST_USERNAME',
+ 'LUOWANG_LIVE_TEST_PASSWORD',
+ 'LUOWANG_LIVE_PROVIDER',
+ 'LUOWANG_LIVE_PROVIDER_API_KEY',
+ 'LUOWANG_LIVE_MAIN_MODEL',
+ 'LUOWANG_LIVE_MAIN_THINKING',
+ 'LUOWANG_LIVE_RUNNER_MODEL',
+ 'LUOWANG_LIVE_RUNNER_THINKING',
+ 'LUOWANG_LIVE_REVIEWER_MODEL',
+ 'LUOWANG_LIVE_REVIEWER_THINKING',
+ 'LUOWANG_LIVE_REVIEWER_VISION_CONFIRMED',
+ 'LUOWANG_LIVE_OSS_ENDPOINT',
+ 'LUOWANG_LIVE_OSS_REGION',
+ 'LUOWANG_LIVE_OSS_BUCKET',
+ 'LUOWANG_LIVE_OSS_PREFIX',
+ 'LUOWANG_LIVE_OSS_ACCESS_KEY_ID',
+ 'LUOWANG_LIVE_OSS_ACCESS_KEY_SECRET',
+ 'LUOWANG_LIVE_OSS_PRIVATE_CONFIRMED',
+ 'LUOWANG_LIVE_PASSED_CASE',
+ 'LUOWANG_LIVE_FAILED_CASE_1',
+ 'LUOWANG_LIVE_FAILED_CASE_2',
+ 'LUOWANG_LIVE_BLOCKED_CASE',
+ 'LUOWANG_LIVE_RESET_PROCEDURE',
+ 'LUOWANG_LIVE_DELETION_PROCEDURE',
+ 'LUOWANG_LIVE_ABSENCE_VERIFICATION',
+ 'LUOWANG_LIVE_NETWORK_APPROVED',
+ 'LUOWANG_LIVE_COST_APPROVED',
+ 'LUOWANG_LIVE_RELEASE_AUTHORIZED',
+ 'LUOWANG_LIVE_CREDENTIAL_DISPOSITION',
+] as const;
+
+const LIVE_THINKING_INPUTS = new Set([
+ 'LUOWANG_LIVE_MAIN_THINKING',
+ 'LUOWANG_LIVE_RUNNER_THINKING',
+ 'LUOWANG_LIVE_REVIEWER_THINKING',
+]);
+const VALID_THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
+
+const LIVE_TRUE_CONFIRMATIONS = new Set([
+ 'LUOWANG_LIVE_NON_PRODUCTION_CONFIRMED',
+ 'LUOWANG_LIVE_TEST_ACCOUNT_DEDICATED_CONFIRMED',
+ 'LUOWANG_LIVE_REVIEWER_VISION_CONFIRMED',
+ 'LUOWANG_LIVE_OSS_PRIVATE_CONFIRMED',
+ 'LUOWANG_LIVE_NETWORK_APPROVED',
+ 'LUOWANG_LIVE_COST_APPROVED',
+ 'LUOWANG_LIVE_RELEASE_AUTHORIZED',
+]);
+
+type LayerStatus = 'passed' | 'failed' | 'blocked' | 'not_run';
+type AcceptanceMode = 'local' | 'live' | 'release';
+
+interface LayerResult {
+ status: LayerStatus;
+ message: string;
+ missing?: string[];
+}
+
+interface CommandResult {
+ command: string;
+ status: 'passed' | 'failed';
+ durationMs: number;
+ summary: string;
+}
+
+export interface ClosureProofStatuses {
+ doc: LayerStatus;
+ instr01: LayerStatus;
+ instr02: LayerStatus;
+ instr03: LayerStatus;
+ merge01: LayerStatus;
+ target01: LayerStatus;
+ merge02: LayerStatus;
+ data01: LayerStatus;
+ data02: LayerStatus;
+ active01: LayerStatus;
+ history01: LayerStatus;
+ ordinaryPi: LayerStatus;
+ directInitialization: LayerStatus;
+ scenarioReview: LayerStatus;
+ finalRevision: LayerStatus;
+ invalidTool: LayerStatus;
+ mergeConflict: LayerStatus;
+ indexerRecovery: LayerStatus;
+ archiveRetry: LayerStatus;
+ processRestart: LayerStatus;
+ queueRecovery: LayerStatus;
+ publicQuality: LayerStatus;
+ acceptanceLayering: LayerStatus;
+ acMapping: LayerStatus;
+}
+
+interface AcceptanceReport {
+ schema: 'luowang.production-acceptance.v1';
+ mode: AcceptanceMode;
+ startedAt: string;
+ finishedAt: string;
+ local: LayerResult;
+ live: LayerResult;
+ release: LayerResult;
+ resourceChecks: Array<{
+ id: string;
+ status: LayerStatus;
+ evidence: string[];
+ }>;
+ acEvidence: Array<{
+ ac: string;
+ status: LayerStatus;
+ evidence: string[];
+ }>;
+ commands: CommandResult[];
+ proofs: ClosureProofStatuses;
+}
+
+export function missingLiveInputs(environment: NodeJS.ProcessEnv): string[] {
+ return LIVE_INPUT_NAMES.filter((name) => {
+ const value = environment[name]?.trim();
+ if (!value) return true;
+ const normalized = value.toLocaleLowerCase();
+ if (LIVE_TRUE_CONFIRMATIONS.has(name)) return normalized !== 'true';
+ return LIVE_THINKING_INPUTS.has(name) && !VALID_THINKING_LEVELS.has(normalized);
+ });
+}
+
+export function createLayeredReport(input: {
+ mode: AcceptanceMode;
+ startedAt: string;
+ local: LayerResult;
+ live: LayerResult;
+ commands?: CommandResult[];
+ proofs: ClosureProofStatuses;
+}): AcceptanceReport {
+ const releasePassed = input.local.status === 'passed' && input.live.status === 'passed';
+ const piStatus = aggregateStatuses([
+ input.proofs.instr01,
+ input.proofs.instr02,
+ input.proofs.ordinaryPi,
+ input.proofs.directInitialization,
+ input.proofs.scenarioReview,
+ input.proofs.finalRevision,
+ input.proofs.invalidTool,
+ ]);
+ const releaseStatus: LayerStatus = releasePassed
+ ? 'passed'
+ : input.local.status === 'failed' || input.live.status === 'failed'
+ ? 'failed'
+ : 'blocked';
+ return {
+ schema: 'luowang.production-acceptance.v1',
+ mode: input.mode,
+ startedAt: input.startedAt,
+ finishedAt: new Date().toISOString(),
+ local: input.local,
+ live: input.live,
+ release: {
+ status: releaseStatus,
+ message: releasePassed
+ ? '公共质量、local 和 live 联合验收均通过。'
+ : 'release 只有在 local 与 live 均 passed 时才能通过。',
+ },
+ resourceChecks: [
+ {
+ id: 'built-in-role-instruction-id-hash-isolation',
+ status: aggregateStatuses([
+ input.proofs.instr01,
+ input.proofs.instr02,
+ input.proofs.instr03,
+ ]),
+ evidence: [
+ 'tests/closure1-role-instructions.test.ts',
+ 'tests/closure6-production-pi.test.ts: integrated Session metadata assertions',
+ ],
+ },
+ {
+ id: 'pi-sdk-ordinary-four-session',
+ status: input.proofs.ordinaryPi,
+ evidence: ['tests/closure6-production-pi.test.ts: ordinary production Pi Run'],
+ },
+ {
+ id: 'fifo-first-branch-six-session-pi-initialization',
+ status: input.proofs.directInitialization,
+ evidence: [
+ 'tests/closure6-production-pi.test.ts: manual-merge-source → FIFO → initial-create → resolved target → one six-Session Pi Run',
+ ],
+ },
+ {
+ id: 'scenario-review-three-session-special-finalize',
+ status: input.proofs.scenarioReview,
+ evidence: ['tests/closure6-production-pi.test.ts: review-required initialization'],
+ },
+ {
+ id: 'final-revision-without-rerun',
+ status: input.proofs.finalRevision,
+ evidence: ['tests/closure6-production-pi.test.ts: final patch revision'],
+ },
+ {
+ id: 'invalid-tool-fail-closed',
+ status: input.proofs.invalidTool,
+ evidence: ['tests/closure6-production-pi.test.ts: invalid tool request'],
+ },
+ {
+ id: 'merge-conflict-cleanup',
+ status: input.proofs.mergeConflict,
+ evidence: ['tests/phase2.test.ts: conflicted merge cleanup'],
+ },
+ {
+ id: 'indexer-atomic-recovery',
+ status: input.proofs.indexerRecovery,
+ evidence: ['tests/phase2.test.ts: valid cache retained on invalid input'],
+ },
+ {
+ id: 'archive-retry',
+ status: input.proofs.archiveRetry,
+ evidence: ['tests/phase5.test.ts: failed Issue item retry'],
+ },
+ {
+ id: 'process-restart-recovery',
+ status: input.proofs.processRestart,
+ evidence: ['tests/phase6.test.ts: orphaned Run becomes interrupted'],
+ },
+ {
+ id: 'fifo-queue-recovery',
+ status: input.proofs.queueRecovery,
+ evidence: ['tests/phase6.test.ts: queued/running/waiting archive restart recovery'],
+ },
+ {
+ id: 'live-external-resources',
+ status: input.live.status,
+ evidence: ['Closure 7 live runner and operator-provided resources'],
+ },
+ ],
+ acEvidence: [
+ {
+ ac: 'AC-CLOSURE-DOC-01',
+ status: input.proofs.doc,
+ evidence: ['README.md', 'tests/closure6-acceptance-layering.test.ts: documentation'],
+ },
+ {
+ ac: 'AC-CLOSURE-INSTR-01',
+ status: input.proofs.instr01,
+ evidence: ['tests/closure1-role-instructions.test.ts: allowlist and ambient isolation'],
+ },
+ {
+ ac: 'AC-CLOSURE-INSTR-02',
+ status: input.proofs.instr02,
+ evidence: [
+ 'tests/closure6-production-pi.test.ts: integrated Session metadata and prompt layering',
+ ],
+ },
+ {
+ ac: 'AC-CLOSURE-INSTR-03',
+ status: input.proofs.instr03,
+ evidence: ['resources/agent-roles/*.md', 'tests/closure1-role-instructions.test.ts'],
+ },
+ {
+ ac: 'AC-CLOSURE-MERGE-01',
+ status: input.proofs.merge01,
+ evidence: ['tests/closure2-merge-queue.test.ts: FIFO prepared merge publication'],
+ },
+ {
+ ac: 'AC-CLOSURE-TARGET-01',
+ status: input.proofs.target01,
+ evidence: ['tests/closure2-merge-queue.test.ts: fixed remote scenario target'],
+ },
+ {
+ ac: 'AC-CLOSURE-MERGE-02',
+ status: input.proofs.merge02,
+ evidence: ['tests/closure2-merge-queue.test.ts: crash and Git ref recovery'],
+ },
+ {
+ ac: 'AC-CLOSURE-DATA-01',
+ status: input.proofs.data01,
+ evidence: ['tests/closure3-test-data.test.ts: controlled cleanup verification'],
+ },
+ {
+ ac: 'AC-CLOSURE-DATA-02',
+ status: input.proofs.data02,
+ evidence: ['tests/closure3-test-data.test.ts: rejected and pending cleanup boundaries'],
+ },
+ {
+ ac: 'AC-CLOSURE-ACTIVE-01',
+ status: input.proofs.active01,
+ evidence: ['tests/closure4-progress.test.ts'],
+ },
+ {
+ ac: 'AC-CLOSURE-HISTORY-01',
+ status: input.proofs.history01,
+ evidence: ['tests/closure5-history.test.ts'],
+ },
+ {
+ ac: 'AC-CLOSURE-PI-01',
+ status: piStatus,
+ evidence: [
+ 'tests/closure6-production-pi.test.ts',
+ 'tests/acceptance/local-model-protocol.ts',
+ 'src/server/runs/agent-session.ts:createPiAgentSessionFactory',
+ ],
+ },
+ {
+ ac: 'AC-CLOSURE-ACCEPT-01',
+ status: aggregateStatuses([input.proofs.publicQuality, input.proofs.acceptanceLayering]),
+ evidence: [
+ 'package.json:test:acceptance:local/live/release',
+ 'tests/closure6-acceptance-layering.test.ts',
+ ...PUBLIC_QUALITY_SCRIPTS.map((script) => `npm run ${script}`),
+ ],
+ },
+ {
+ ac: 'AC-CLOSURE-ACCEPT-02',
+ status: input.proofs.acMapping,
+ evidence: [
+ 'report.resourceChecks[] contains behavior-specific evidence',
+ 'report.acEvidence[] contains one entry per Closure 6 AC',
+ ],
+ },
+ ],
+ commands: input.commands ?? [],
+ proofs: input.proofs,
+ };
+}
+
+function aggregateStatuses(statuses: LayerStatus[]): LayerStatus {
+ if (statuses.every((status) => status === 'passed')) return 'passed';
+ if (statuses.some((status) => status === 'failed')) return 'failed';
+ if (statuses.some((status) => status === 'blocked')) return 'blocked';
+ return 'not_run';
+}
+
+function emptyProofStatuses(status: LayerStatus): ClosureProofStatuses {
+ return {
+ doc: status,
+ instr01: status,
+ instr02: status,
+ instr03: status,
+ merge01: status,
+ target01: status,
+ merge02: status,
+ data01: status,
+ data02: status,
+ active01: status,
+ history01: status,
+ ordinaryPi: status,
+ directInitialization: status,
+ scenarioReview: status,
+ finalRevision: status,
+ invalidTool: status,
+ mergeConflict: status,
+ indexerRecovery: status,
+ archiveRetry: status,
+ processRestart: status,
+ queueRecovery: status,
+ publicQuality: status,
+ acceptanceLayering: status,
+ acMapping: status,
+ };
+}
+
+async function runLocal(artifactDirectory: string): Promise {
+ const startedAt = new Date().toISOString();
+ const phase9Directory = join(artifactDirectory, 'phase9-local');
+ await mkdir(phase9Directory, { recursive: true });
+ await mkdir(join(phase9Directory, 'isolated-home'), { recursive: true });
+ const environment = localOnlyEnvironment(phase9Directory);
+ const commands: CommandResult[] = [];
+ const proofs = emptyProofStatuses('not_run');
+ const npmCli = process.env.npm_execpath;
+ if (npmCli) {
+ for (const script of PUBLIC_QUALITY_SCRIPTS) {
+ const quality = await runAcceptanceCommand(
+ `npm run ${script}`,
+ [npmCli, 'run', script],
+ environment,
+ );
+ commands.push(quality.command);
+ }
+ } else {
+ commands.push({
+ command: 'public quality commands',
+ status: 'failed',
+ durationMs: 0,
+ summary: 'npm_execpath is unavailable; public quality commands were not run.',
+ });
+ }
+ proofs.publicQuality = commands.every((command) => command.status === 'passed')
+ ? 'passed'
+ : 'failed';
+ const phase9 = await runAcceptanceCommand(
+ 'tsx tests/acceptance/phase9.ts',
+ ['--import', 'tsx', 'tests/acceptance/phase9.ts'],
+ environment,
+ async () => {
+ const report = JSON.parse(await readFile(join(phase9Directory, 'report.json'), 'utf8')) as {
+ status?: unknown;
+ };
+ if (report.status !== 'passed') throw new Error('Phase 9 local report 未通过');
+ },
+ );
+ commands.push(phase9.command);
+ const definitions: Array<{
+ key: keyof ClosureProofStatuses;
+ label: string;
+ file: string;
+ pattern: string;
+ requires?: string[];
+ }> = [
+ {
+ key: 'doc',
+ label: 'AC-CLOSURE-DOC-01 truthful Docker and acceptance documentation',
+ file: 'tests/closure6-acceptance-layering.test.ts',
+ pattern: 'documents Docker priority',
+ },
+ {
+ key: 'instr01',
+ label: 'AC-CLOSURE-INSTR-01 fixed role allowlist and ambient isolation',
+ file: 'tests/closure1-role-instructions.test.ts',
+ pattern: 'loads only|ignores ambient|fails closed',
+ },
+ {
+ key: 'instr02',
+ label: 'AC-CLOSURE-INSTR-02 prompt and tool isolation in production Pi',
+ file: 'tests/closure6-production-pi.test.ts',
+ pattern: 'ordinary four-session|unfamiliar-project direct initialization',
+ requires: ['tests/closure1-role-instructions.test.ts'],
+ },
+ {
+ key: 'instr03',
+ label: 'AC-CLOSURE-INSTR-03 role method content boundaries',
+ file: 'tests/closure6-acceptance-layering.test.ts',
+ pattern: 'validates integrated role instruction method content',
+ requires: ['resources/agent-roles/common.md'],
+ },
+ {
+ key: 'merge01',
+ label: 'AC-CLOSURE-MERGE-01 prepared FIFO merge publication',
+ file: 'tests/closure2-merge-queue.test.ts',
+ pattern: 'creates a missing scenario branch|prepares and recovers|uses one FIFO',
+ },
+ {
+ key: 'target01',
+ label: 'AC-CLOSURE-TARGET-01 fixed remote scenario target',
+ file: 'tests/closure2-merge-queue.test.ts',
+ pattern: 'keeps only automatic|creates no Run|uses one FIFO',
+ },
+ {
+ key: 'merge02',
+ label: 'AC-CLOSURE-MERGE-02 crash and internal ref recovery',
+ file: 'tests/closure2-merge-queue.test.ts',
+ pattern:
+ 'does not regenerate|recovers push-before|keeps an already resolved|start-before-link|crash-gap',
+ },
+ {
+ key: 'data01',
+ label: 'AC-CLOSURE-DATA-01 controlled cleanup verification',
+ file: 'tests/closure3-test-data.test.ts',
+ pattern: 'captures a real|trusted cleanup adapter|zero-data Run',
+ },
+ {
+ key: 'data02',
+ label: 'AC-CLOSURE-DATA-02 rejected and pending cleanup boundaries',
+ file: 'tests/closure3-test-data.test.ts',
+ pattern: 'still exists|rejects operations|pending and Reviewer-rejected',
+ },
+ {
+ key: 'active01',
+ label: 'AC-CLOSURE-ACTIVE-01 live scenario progress',
+ file: 'tests/closure4-progress.test.ts',
+ pattern: 'updates current|rejects undeclared|explicit 0/0|preserves the last|caps activities',
+ },
+ {
+ key: 'history01',
+ label: 'AC-CLOSURE-HISTORY-01 bounded history queries',
+ file: 'tests/closure5-history.test.ts',
+ pattern: 'queries completed|matches Issue|enforces read-before|distinguishes successful',
+ },
+ {
+ key: 'ordinaryPi',
+ label: 'Pi ordinary four-session Run',
+ file: 'tests/closure6-production-pi.test.ts',
+ pattern: 'ordinary four-session',
+ },
+ {
+ key: 'directInitialization',
+ label: 'FIFO first-branch six-session Pi initialization',
+ file: 'tests/closure6-production-pi.test.ts',
+ pattern: 'creates the first scenario branch through FIFO',
+ },
+ {
+ key: 'scenarioReview',
+ label: 'Pi three-session scenario review and Archiver PR',
+ file: 'tests/closure6-production-pi.test.ts',
+ pattern: 'review-required initialization',
+ },
+ {
+ key: 'finalRevision',
+ label: 'Pi final revision without rerun',
+ file: 'tests/closure6-production-pi.test.ts',
+ pattern: 'finalization revisions',
+ },
+ {
+ key: 'invalidTool',
+ label: 'Pi invalid tool fail-closed',
+ file: 'tests/closure6-production-pi.test.ts',
+ pattern: 'outside the production allowlist',
+ },
+ {
+ key: 'mergeConflict',
+ label: 'merge conflict cleanup',
+ file: 'tests/phase2.test.ts',
+ pattern: 'conflicted merge',
+ },
+ {
+ key: 'indexerRecovery',
+ label: 'Indexer atomic cache recovery',
+ file: 'tests/phase2.test.ts',
+ pattern: 'atomically indexes valid scenes',
+ },
+ {
+ key: 'archiveRetry',
+ label: 'archive failed-item retry',
+ file: 'tests/phase5.test.ts',
+ pattern: 'creates multiple confirmed Bug Issues',
+ },
+ {
+ key: 'processRestart',
+ label: 'orphaned Run restart recovery',
+ file: 'tests/phase6.test.ts',
+ pattern: 'orphaned running directory',
+ },
+ {
+ key: 'queueRecovery',
+ label: 'FIFO queue process restart recovery',
+ file: 'tests/phase6.test.ts',
+ pattern: 'recovers queued, running and waiting-archive',
+ },
+ {
+ key: 'acceptanceLayering',
+ label: 'AC-CLOSURE-ACCEPT-01 command and status layering',
+ file: 'tests/closure6-acceptance-layering.test.ts',
+ pattern: 'lists every|builds local|redacts credential|keeps release blocked|exposes separate',
+ },
+ {
+ key: 'acMapping',
+ label: 'AC-CLOSURE-ACCEPT-02 independent status mapping',
+ file: 'tests/closure6-acceptance-layering.test.ts',
+ pattern: 'derives each AC status',
+ },
+ ];
+ for (const definition of definitions) {
+ const requiredPaths = [definition.file, ...(definition.requires ?? [])];
+ const available = await Promise.all(
+ requiredPaths.map((path) =>
+ access(join(process.cwd(), path)).then(
+ () => true,
+ () => false,
+ ),
+ ),
+ );
+ if (available.some((item) => !item)) {
+ proofs[definition.key] = 'blocked';
+ commands.push({
+ command: `vitest: ${definition.label}`,
+ status: 'failed',
+ durationMs: 0,
+ summary:
+ 'Required Closure proof is not present on this standalone branch; deferred to aggregate validation.',
+ });
+ continue;
+ }
+ const proof = await runAcceptanceCommand(
+ `vitest: ${definition.label}`,
+ [
+ join(process.cwd(), 'node_modules', 'vitest', 'vitest.mjs'),
+ 'run',
+ definition.file,
+ '-t',
+ definition.pattern,
+ ],
+ environment,
+ );
+ proofs[definition.key] = proof.status;
+ commands.push(proof.command);
+ }
+ const passed =
+ phase9.status === 'passed' && Object.values(proofs).every((status) => status === 'passed');
+ const local: LayerResult = passed
+ ? {
+ status: 'passed',
+ message: '本地质量、fixture、真实 Pi SDK Session 和逐 AC 专项证明通过。',
+ }
+ : {
+ status: 'failed',
+ message: '一个或多个 local 质量或逐 AC 专项证明失败。',
+ };
+ return createLayeredReport({
+ mode: 'local',
+ startedAt,
+ local,
+ live: {
+ status: 'blocked',
+ message: 'local 命令不会读取或运行真实 GitHub、Provider、MCP、OSS 和测试账号。',
+ missing: missingLiveInputs(process.env),
+ },
+ commands,
+ proofs,
+ });
+}
+
+async function runLive(): Promise {
+ const startedAt = new Date().toISOString();
+ const missing = missingLiveInputs(process.env);
+ const live: LayerResult =
+ missing.length > 0
+ ? {
+ status: 'blocked',
+ message: `live 输入不完整;缺少 ${missing.length} 项。`,
+ missing,
+ }
+ : {
+ status: 'blocked',
+ message:
+ '输入预检通过,但真实联合验收由 Closure 7 live runner 执行;本阶段不伪报 passed。',
+ };
+ return createLayeredReport({
+ mode: 'live',
+ startedAt,
+ local: { status: 'not_run', message: 'live 命令不把未运行的 local 写成 passed。' },
+ live,
+ proofs: emptyProofStatuses('not_run'),
+ });
+}
+
+async function runRelease(artifactDirectory: string): Promise {
+ const startedAt = new Date().toISOString();
+ const localReport = await runLocal(join(artifactDirectory, 'local'));
+ const liveReport = await runLive();
+ return createLayeredReport({
+ mode: 'release',
+ startedAt,
+ local: localReport.local,
+ live: liveReport.live,
+ commands: localReport.commands,
+ proofs: localReport.proofs,
+ });
+}
+
+async function runAcceptanceCommand(
+ label: string,
+ args: string[],
+ environment: NodeJS.ProcessEnv,
+ verify?: () => Promise,
+): Promise<{ status: 'passed' | 'failed'; command: CommandResult }> {
+ const startedAt = Date.now();
+ try {
+ const result = await execFileAsync(process.execPath, args, {
+ cwd: process.cwd(),
+ env: environment,
+ encoding: 'utf8',
+ maxBuffer: 16 * 1024 * 1024,
+ });
+ await verify?.();
+ return {
+ status: 'passed',
+ command: {
+ command: label,
+ status: 'passed',
+ durationMs: Date.now() - startedAt,
+ summary: summarize(`${result.stdout}\n${result.stderr}`),
+ },
+ };
+ } catch (error) {
+ return {
+ status: 'failed',
+ command: {
+ command: label,
+ status: 'failed',
+ durationMs: Date.now() - startedAt,
+ summary: safeError(error),
+ },
+ };
+ }
+}
+
+export function localOnlyEnvironment(phase9Directory: string): NodeJS.ProcessEnv {
+ const environment: NodeJS.ProcessEnv = {
+ PATH: process.env.PATH,
+ HOME: join(phase9Directory, 'isolated-home'),
+ TMPDIR: process.env.TMPDIR,
+ TEMP: process.env.TEMP,
+ TMP: process.env.TMP,
+ LANG: process.env.LANG,
+ LC_ALL: process.env.LC_ALL,
+ CI: process.env.CI,
+ NODE_ENV: 'test',
+ PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH,
+ NPM_CONFIG_REGISTRY: process.env.NPM_CONFIG_REGISTRY,
+ npm_config_registry: process.env.npm_config_registry,
+ LUOWANG_ACCEPTANCE_ARTIFACT_DIR: phase9Directory,
+ };
+ if (process.platform === 'win32') {
+ environment.SystemRoot = process.env.SystemRoot;
+ environment.ComSpec = process.env.ComSpec;
+ environment.PATHEXT = process.env.PATHEXT;
+ }
+ return Object.fromEntries(
+ Object.entries(environment).filter(
+ (entry): entry is [string, string] => entry[1] !== undefined,
+ ),
+ );
+}
+
+async function writeReport(directory: string, report: AcceptanceReport): Promise {
+ await mkdir(directory, { recursive: true });
+ await writeFile(join(directory, 'report.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
+ await writeFile(join(directory, 'report.md'), renderMarkdown(report), 'utf8');
+}
+
+function renderMarkdown(report: AcceptanceReport): string {
+ const resources = report.resourceChecks
+ .map((check) => `| ${check.id} | ${check.status} | ${check.evidence.join('
')} |`)
+ .join('\n');
+ const ac = report.acEvidence
+ .map((item) => `| ${item.ac} | ${item.status} | ${item.evidence.join('
')} |`)
+ .join('\n');
+ return `# LuoWang production acceptance\n\n- Mode: **${report.mode}**\n- local.status: **${report.local.status}**\n- live.status: **${report.live.status}**\n- release.status: **${report.release.status}**\n\n## Resource checks\n\n| Check | Status | Evidence |\n|---|---|---|\n${resources}\n\n## AC evidence\n\n| AC | Status | Evidence |\n|---|---|---|\n${ac}\n\n本地 test double 只能证明 local;live blocked 时 release 必须 blocked。\n`;
+}
+
+function summarize(value: string): string {
+ return redactAcceptanceText(value)
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .slice(-4)
+ .join(' | ')
+ .slice(0, 1000);
+}
+
+function safeError(error: unknown): string {
+ return redactAcceptanceText(error instanceof Error ? error.message : String(error)).slice(
+ 0,
+ 1000,
+ );
+}
+
+export function redactAcceptanceText(value: string): string {
+ return value
+ .replace(
+ /((?:["']?)(?:authorization|password|passwd|token|secret|cookie|api[_-]?key)["']?\s*[=:]\s*["']?)(?:bearer\s+)?[^"'\s,}]+/gi,
+ '$1[REDACTED]',
+ )
+ .replace(/\b(?:github_pat_|gh[opsur]_|sk-)[A-Za-z0-9_-]+\b/g, '[REDACTED]')
+ .replace(/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED]')
+ .replace(/https?:\/\/[^\s/@:]+:[^\s/@]+@/g, 'https://[REDACTED]@');
+}
+
+async function main(): Promise {
+ const mode = (process.argv[2] ?? 'local') as AcceptanceMode;
+ if (!['local', 'live', 'release'].includes(mode)) {
+ throw new Error(`未知验收模式:${mode}`);
+ }
+ const directory =
+ process.env.LUOWANG_ACCEPTANCE_ARTIFACT_DIR ??
+ join(
+ process.cwd(),
+ '.cynos',
+ 'acceptance',
+ `${new Date().toISOString().replace(/[:.]/g, '-')}-${mode}`,
+ );
+ const report =
+ mode === 'local'
+ ? await runLocal(directory)
+ : mode === 'live'
+ ? await runLive()
+ : await runRelease(directory);
+ await writeReport(directory, report);
+ process.stdout.write(
+ `Acceptance ${mode}: local=${report.local.status}, live=${report.live.status}, release=${report.release.status}; report=${join(directory, 'report.json')}\n`,
+ );
+ if (report.live.missing?.length) {
+ process.stderr.write(
+ `Missing live inputs:\n${report.live.missing.map((name) => `- ${name}`).join('\n')}\n`,
+ );
+ }
+ if (
+ (mode === 'local' && report.local.status !== 'passed') ||
+ (mode === 'live' && report.live.status !== 'passed') ||
+ (mode === 'release' && report.release.status !== 'passed')
+ ) {
+ process.exitCode = 1;
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
diff --git a/tests/acceptance/local-model-protocol.ts b/tests/acceptance/local-model-protocol.ts
new file mode 100644
index 0000000..a388c5b
--- /dev/null
+++ b/tests/acceptance/local-model-protocol.ts
@@ -0,0 +1,526 @@
+import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
+import { mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { ModelRuntime } from '@earendil-works/pi-coding-agent';
+
+import { createPiAgentSessionFactory } from '../../src/server/runs/agent-session.js';
+import type { PiModel, ProviderAdapter } from '../../src/server/runs/provider.js';
+import type {
+ AgentRole,
+ AgentSession,
+ AgentSessionFactory,
+ AgentSessionInput,
+} from '../../src/server/runs/types.js';
+
+export type LocalModelBehavior =
+ 'normal' | 'revise-final-patch' | 'invalid-tool' | 'special-cleanup';
+
+export interface LocalPiSessionRecord {
+ id: string;
+ role: AgentRole;
+ model: string;
+ thinking: string;
+ tools: string[];
+ systemPrompt: string;
+ sessionKind: string | null;
+ roleInstructionVersions: Array<{
+ id: string;
+ formatVersion: string;
+ applicationVersion: string;
+ sha256: string;
+ }>;
+ prompts: string[];
+ disposed: boolean;
+}
+
+export interface LocalModelProtocol {
+ sessions: LocalPiSessionRecord[];
+ requestCount: number;
+ sessionFactory: AgentSessionFactory;
+ close(): Promise;
+}
+
+interface ChatRequest {
+ messages?: Array<{
+ role?: string;
+ content?: unknown;
+ tool_calls?: Array<{ function?: { name?: string } }>;
+ }>;
+ tools?: Array<{ function?: { name?: string } }>;
+}
+
+interface NextTool {
+ name: string;
+ arguments: Record;
+}
+
+export async function startLocalModelProtocol(
+ behavior: LocalModelBehavior = 'normal',
+): Promise {
+ const directory = await mkdtemp(join(tmpdir(), 'luowang-local-model-'));
+ const server = createServer((request, response) => {
+ void handleRequest(request, response, behavior, protocolState);
+ });
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', () => resolve());
+ });
+ const address = server.address();
+ if (!address || typeof address === 'string') throw new Error('本地模型协议服务启动失败');
+ const modelsPath = join(directory, 'models.json');
+ await writeFile(
+ modelsPath,
+ JSON.stringify({
+ providers: {
+ 'luowang-local': {
+ baseUrl: `http://127.0.0.1:${address.port}/v1`,
+ api: 'openai-completions',
+ apiKey: 'local-protocol-placeholder',
+ models: [
+ {
+ id: 'deterministic-tool-model',
+ name: 'Deterministic local tool model',
+ reasoning: false,
+ input: ['text'],
+ contextWindow: 128000,
+ maxTokens: 4096,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ compat: {
+ supportsDeveloperRole: false,
+ supportsUsageInStreaming: false,
+ maxTokensField: 'max_tokens',
+ },
+ },
+ ],
+ },
+ },
+ }),
+ 'utf8',
+ );
+ const runtime = await ModelRuntime.create({
+ modelsPath,
+ authPath: join(directory, 'auth.json'),
+ modelsStorePath: join(directory, 'models-store.json'),
+ refreshOnCreate: false,
+ allowModelNetwork: false,
+ });
+ const model = runtime.getModel('luowang-local', 'deterministic-tool-model');
+ if (!model) throw new Error('本地模型未注册');
+ const provider = new LocalProvider(runtime, model);
+ const productionFactory = createPiAgentSessionFactory({ provider });
+ const sessions: LocalPiSessionRecord[] = [];
+ const recordingFactory = new RecordingProductionFactory(productionFactory, sessions);
+ const protocolState = { requestCount: 0 };
+
+ return {
+ sessions,
+ get requestCount() {
+ return protocolState.requestCount;
+ },
+ sessionFactory: recordingFactory,
+ close: async () => {
+ await new Promise((resolve, reject) =>
+ server.close((error) => (error ? reject(error) : resolve())),
+ );
+ await rm(directory, { recursive: true, force: true });
+ },
+ };
+}
+
+class LocalProvider implements ProviderAdapter {
+ constructor(
+ private readonly runtime: ModelRuntime,
+ private readonly model: PiModel,
+ ) {}
+
+ async getRuntime(): Promise {
+ return this.runtime;
+ }
+
+ async resolveModel(): Promise {
+ return this.model;
+ }
+
+ async listModels() {
+ return [
+ {
+ provider: this.model.provider,
+ id: this.model.id,
+ name: this.model.name,
+ reasoning: this.model.reasoning,
+ input: [...this.model.input],
+ thinkingLevels: ['off' as const],
+ available: true,
+ },
+ ];
+ }
+
+ async checkConnectivity() {
+ return {
+ status: 'ok' as const,
+ message: '本地模型协议可用',
+ checkedAt: new Date().toISOString(),
+ latencyMs: 0,
+ };
+ }
+}
+
+class RecordingProductionFactory implements AgentSessionFactory {
+ constructor(
+ private readonly delegate: AgentSessionFactory,
+ private readonly records: LocalPiSessionRecord[],
+ ) {}
+
+ async create(input: AgentSessionInput): Promise {
+ const session = await this.delegate.create(input);
+ const instructionInput = input as AgentSessionInput & {
+ sessionKind?: string;
+ roleInstructionVersions?: LocalPiSessionRecord['roleInstructionVersions'];
+ };
+ const record: LocalPiSessionRecord = {
+ id: session.sessionId ?? `pi-session-${this.records.length + 1}`,
+ role: input.role,
+ model: input.config.model,
+ thinking: input.config.thinking,
+ tools: input.customTools.map((tool) => tool.name),
+ systemPrompt: input.systemPrompt,
+ sessionKind: instructionInput.sessionKind ?? null,
+ roleInstructionVersions:
+ instructionInput.roleInstructionVersions?.map((item) => ({
+ ...item,
+ })) ?? [],
+ prompts: [],
+ disposed: false,
+ };
+ this.records.push(record);
+ return {
+ sessionId: record.id,
+ prompt: async (message) => {
+ record.prompts.push(message);
+ await session.prompt(message);
+ },
+ dispose: async () => {
+ await session.dispose();
+ record.disposed = true;
+ },
+ };
+ }
+}
+
+async function handleRequest(
+ request: IncomingMessage,
+ response: ServerResponse,
+ behavior: LocalModelBehavior,
+ state: { requestCount: number },
+): Promise {
+ if (request.method !== 'POST' || request.url !== '/v1/chat/completions') {
+ response.writeHead(404).end();
+ return;
+ }
+ state.requestCount += 1;
+ const body = JSON.parse(await readBody(request)) as ChatRequest;
+ const toolNames = (body.tools ?? [])
+ .map((tool) => tool.function?.name)
+ .filter((name): name is string => Boolean(name));
+ const systemPrompt = messageText(body.messages?.find((message) => message.role === 'system'));
+ const userPrompt = messageText(
+ [...(body.messages ?? [])].reverse().find((message) => message.role === 'user'),
+ );
+ const called = (body.messages ?? []).flatMap(
+ (message) => message.tool_calls?.map((tool) => tool.function?.name ?? '') ?? [],
+ );
+ const next = nextTool(toolNames, called, `${systemPrompt}\n${userPrompt}`, behavior);
+ response.writeHead(200, {
+ 'content-type': 'text/event-stream; charset=utf-8',
+ 'cache-control': 'no-cache',
+ connection: 'keep-alive',
+ });
+ if (next) {
+ const id = `call-${state.requestCount}`;
+ sendEvent(response, {
+ id: `chat-${state.requestCount}`,
+ object: 'chat.completion.chunk',
+ created: 1,
+ model: 'deterministic-tool-model',
+ choices: [
+ {
+ index: 0,
+ delta: {
+ role: 'assistant',
+ tool_calls: [
+ {
+ index: 0,
+ id,
+ type: 'function',
+ function: { name: next.name, arguments: JSON.stringify(next.arguments) },
+ },
+ ],
+ },
+ finish_reason: null,
+ },
+ ],
+ });
+ sendEvent(response, {
+ id: `chat-${state.requestCount}`,
+ object: 'chat.completion.chunk',
+ created: 1,
+ model: 'deterministic-tool-model',
+ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
+ });
+ } else {
+ sendEvent(response, {
+ id: `chat-${state.requestCount}`,
+ object: 'chat.completion.chunk',
+ created: 1,
+ model: 'deterministic-tool-model',
+ choices: [
+ {
+ index: 0,
+ delta: { role: 'assistant', content: '本阶段受控工具调用已完成。' },
+ finish_reason: null,
+ },
+ ],
+ });
+ sendEvent(response, {
+ id: `chat-${state.requestCount}`,
+ object: 'chat.completion.chunk',
+ created: 1,
+ model: 'deterministic-tool-model',
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
+ });
+ }
+ response.end('data: [DONE]\n\n');
+}
+
+function nextTool(
+ available: string[],
+ called: string[],
+ prompt: string,
+ behavior: LocalModelBehavior,
+): NextTool | null {
+ if (behavior === 'invalid-tool') {
+ if (called.length === 0) {
+ return { name: 'write_outside_allowlist', arguments: { path: '/tmp/forbidden' } };
+ }
+ return null;
+ }
+ const has = (name: string) => available.includes(name);
+ const count = (name: string) => called.filter((calledName) => calledName === name).length;
+ const nextUnreadArtifact = (names: string[]): NextTool | null => {
+ const index = count('read_run_artifact');
+ return index < names.length ? readArtifact(names[index] as string) : null;
+ };
+ const initialization = /"initialization"\s*:\s*true/i.test(prompt);
+ const candidateMain =
+ has('write_scenario_patch') &&
+ has('read_run_artifact') &&
+ !has('write_plan') &&
+ !has('write_report');
+ const finalMain = has('write_report');
+
+ if (has('write_plan')) {
+ if (count('get_run_context') === 0) return tool('get_run_context');
+ if (count('list_target_files') === 0) return tool('list_target_files');
+ if (count('write_plan') === 0) {
+ return tool('write_plan', {
+ content: initialization
+ ? '# 初始化计划\n\n候选场景 `ONBOARD-SMOKE-001`:验证陌生项目核心入口。\n'
+ : '# 测试计划\n\n无需场景测试:本次仅验证固定 target 的生产 Pi 工件流转。\n',
+ });
+ }
+ return null;
+ }
+
+ if (candidateMain) {
+ const unreadArtifact = nextUnreadArtifact(['plan.md', 'execution.md', 'draft-report.md']);
+ if (unreadArtifact) return unreadArtifact;
+ if (count('write_scenario_patch') === 0) {
+ return tool('write_scenario_patch', { content: scenarioAddPatch('ONBOARD-SMOKE-001') });
+ }
+ return null;
+ }
+
+ if (has('write_execution') && has('write_draft_report')) {
+ const unreadArtifact = nextUnreadArtifact(['plan.md']);
+ if (unreadArtifact) return unreadArtifact;
+ if (
+ behavior === 'special-cleanup' &&
+ has('register_test_data') &&
+ count('register_test_data') === 0
+ ) {
+ const context = parseRunContext(prompt);
+ return tool('register_test_data', {
+ id: `luowang-${context.runId}-special-review-data`,
+ description: 'special review cleanup fixture',
+ });
+ }
+ if (count('get_run_context') === 0) return tool('get_run_context');
+ if (count('list_working_scenarios') === 0) return tool('list_working_scenarios');
+ if (has('begin_scenario_execution') && count('begin_scenario_execution') === 0) {
+ return tool('begin_scenario_execution', {
+ scenarioIds: initialization ? ['ONBOARD-SMOKE-001'] : [],
+ });
+ }
+ if (initialization && has('start_scenario') && count('start_scenario') === 0) {
+ return tool('start_scenario', { scenarioId: 'ONBOARD-SMOKE-001' });
+ }
+ if (initialization && has('finish_scenario') && count('finish_scenario') === 0) {
+ return tool('finish_scenario', { scenarioId: 'ONBOARD-SMOKE-001' });
+ }
+ if (count('run_fixture_command') === 0) {
+ return tool('run_fixture_command', { command: 'node --version' });
+ }
+ if (count('write_execution') === 0) {
+ return tool('write_execution', {
+ content: initialization
+ ? '# 执行记录\n\n候选场景 ONBOARD-SMOKE-001 已通过受控命令验证。\n'
+ : '# 执行记录\n\n固定 target 的受控命令执行成功;无需产品场景。\n',
+ });
+ }
+ if (count('write_draft_report') === 0) {
+ return tool('write_draft_report', {
+ content: initialization
+ ? '# 草稿报告\n\nONBOARD-SMOKE-001 passed。\n'
+ : '# 草稿报告\n\n无需场景测试,工件流转通过。\n',
+ });
+ }
+ return null;
+ }
+
+ if (has('write_review')) {
+ const unreadArtifact = nextUnreadArtifact(['plan.md', 'execution.md', 'draft-report.md']);
+ if (unreadArtifact) return unreadArtifact;
+ if (count('write_review') === 0) {
+ return tool('write_review', {
+ content: initialization
+ ? '# 独立审核\n\n已独立确认候选场景 ONBOARD-SMOKE-001 的执行证据。\n'
+ : '# 独立审核\n\n已独立确认无需场景测试的依据和执行工件。\n',
+ });
+ }
+ return null;
+ }
+
+ if (finalMain) {
+ const unreadArtifact = nextUnreadArtifact([
+ 'plan.md',
+ 'execution.md',
+ 'draft-report.md',
+ 'review.md',
+ ]);
+ if (unreadArtifact) return unreadArtifact;
+ if (
+ initialization &&
+ behavior === 'revise-final-patch' &&
+ has('write_scenario_patch') &&
+ count('write_scenario_patch') === 0
+ ) {
+ return tool('write_scenario_patch', { content: scenarioAddPatch('ONBOARD-REVISED-001') });
+ }
+ if (count('write_report') === 0) {
+ return tool('write_report', { content: reportFromPrompt(prompt, initialization) });
+ }
+ return null;
+ }
+
+ return null;
+}
+
+function tool(name: string, arguments_: Record = {}): NextTool {
+ return { name, arguments: arguments_ };
+}
+
+function readArtifact(name: string): NextTool {
+ return { name: 'read_run_artifact', arguments: { name } };
+}
+
+function reportFromPrompt(prompt: string, initialization: boolean): string {
+ const context = parseRunContext(prompt);
+ const included = context.includedCommits.length
+ ? `\n${context.includedCommits.map((commit) => ` - ${commit}`).join('\n')}`
+ : ' []';
+ const scenarioResults = initialization
+ ? '\n - id: ONBOARD-SMOKE-001\n result: passed'
+ : ' []';
+ return `---
+run_id: ${context.runId}
+trigger: ${context.trigger}
+base_commit: ${context.baseCommit ?? 'null'}
+target_commit: ${context.targetCommit}
+included_commits:${included}
+result: passed
+started_at: 2026-09-01T00:00:00Z
+finished_at: 2026-09-01T00:01:00Z
+scenario_results:${scenarioResults}
+confirmed_bugs: []
+---
+
+# 最终报告
+
+${initialization ? '候选场景 ONBOARD-SMOKE-001 已执行并经 Reviewer 审核。' : '无需场景测试:Reviewer 已独立确认。'}
+`;
+}
+
+function parseRunContext(prompt: string): {
+ runId: string;
+ trigger: string;
+ baseCommit: string | null;
+ targetCommit: string;
+ includedCommits: string[];
+} {
+ const dynamic = prompt.match(/动态 Run 上下文:\s*(\{[\s\S]*\})\s*$/);
+ const fixed = prompt.match(/固定 Run 上下文:\s*([\s\S]*?)\s*\n\s*(?:必须|请|先)/);
+ const serialized = dynamic?.[1] ?? fixed?.[1];
+ if (!serialized) throw new Error('本地模型无法读取固定 Run 上下文');
+ return JSON.parse(serialized) as ReturnType;
+}
+
+function scenarioAddPatch(id: string): string {
+ const path = `docs/scenario-testing/scenarios/${id}.md`;
+ const lines = [
+ '---',
+ `id: ${id}`,
+ `name: ${id} 场景`,
+ 'description: 陌生项目核心入口可用。',
+ 'status: approved',
+ 'tags:',
+ ' - core',
+ '---',
+ '',
+ '## 期望',
+ '',
+ '陌生项目核心入口可用。',
+ ];
+ return `diff --git a/${path} b/${path}
+new file mode 100644
+index 0000000..1111111
+--- /dev/null
++++ b/${path}
+@@ -0,0 +1,${lines.length} @@
+${lines.map((line) => `+${line}`).join('\n')}
+`;
+}
+
+function messageText(message: ChatRequest['messages'] extends Array ? T : never): string {
+ if (!message) return '';
+ if (typeof message.content === 'string') return message.content;
+ if (Array.isArray(message.content)) {
+ return message.content
+ .map((item) =>
+ typeof item === 'object' && item && 'text' in item ? String(item.text ?? '') : '',
+ )
+ .join('\n');
+ }
+ return '';
+}
+
+function sendEvent(response: ServerResponse, value: unknown): void {
+ response.write(`data: ${JSON.stringify(value)}\n\n`);
+}
+
+async function readBody(request: IncomingMessage): Promise {
+ const chunks: Buffer[] = [];
+ for await (const chunk of request) chunks.push(Buffer.from(chunk));
+ return Buffer.concat(chunks).toString('utf8');
+}
diff --git a/tests/closure2-merge-queue.test.ts b/tests/closure2-merge-queue.test.ts
index 0198b4b..b4deb80 100644
--- a/tests/closure2-merge-queue.test.ts
+++ b/tests/closure2-merge-queue.test.ts
@@ -564,7 +564,12 @@ describe('Closure 2 automation integration', () => {
sourceRef: 'main',
confirmed: true,
});
- await waitFor(() => automation.getQueue(merge.queue.queueId)?.status === 'failed');
+ await waitFor(
+ () =>
+ automation.getQueue(automatic.queue.queueId)?.status === 'failed' &&
+ automation.getQueue(current.queue.queueId)?.status === 'failed' &&
+ automation.getQueue(merge.queue.queueId)?.status === 'failed',
+ );
assert.equal(automation.getQueue(automatic.queue.queueId)?.status, 'failed');
assert.equal(automation.getQueue(current.queue.queueId)?.status, 'failed');
@@ -598,7 +603,9 @@ describe('Closure 2 automation integration', () => {
confirmed: true,
initialization: true,
});
- assert.equal(fake.started.length, 1);
+ assert.equal(submission.queue.status, 'queued');
+ assert.equal(submission.run, null);
+ await waitFor(() => fake.started.length === 1);
assert.equal(fake.started[0]?.targetCommit, fixture.mainHead);
assert.equal(fake.started[0]?.initialization, true);
assert.match(fake.started[0]?.runId ?? '', /^[0-9A-HJKMNP-TV-Z]{26}$/);
diff --git a/tests/closure6-acceptance-layering.test.ts b/tests/closure6-acceptance-layering.test.ts
new file mode 100644
index 0000000..95f2bd7
--- /dev/null
+++ b/tests/closure6-acceptance-layering.test.ts
@@ -0,0 +1,249 @@
+import { strict as assert } from 'node:assert';
+import { readFile } from 'node:fs/promises';
+
+import { describe, it } from 'vitest';
+
+import {
+ LIVE_INPUT_NAMES,
+ createLayeredReport,
+ localOnlyEnvironment,
+ missingLiveInputs,
+ PUBLIC_QUALITY_SCRIPTS,
+ redactAcceptanceText,
+ type ClosureProofStatuses,
+} from './acceptance/closure.js';
+
+describe('Closure 6 acceptance status layering', () => {
+ it('lists every missing live input by name without exposing configured values', () => {
+ const environment: NodeJS.ProcessEnv = {
+ LUOWANG_LIVE_REPOSITORY: 'https://github.com/example/private-target',
+ LUOWANG_LIVE_GITHUB_TOKEN: 'canary-live-secret-value',
+ };
+ const missing = missingLiveInputs(environment);
+ assert.equal(missing.includes('LUOWANG_LIVE_REPOSITORY'), false);
+ assert.equal(missing.includes('LUOWANG_LIVE_GITHUB_TOKEN'), false);
+ assert.deepEqual(
+ missing,
+ LIVE_INPUT_NAMES.filter(
+ (name) => name !== 'LUOWANG_LIVE_REPOSITORY' && name !== 'LUOWANG_LIVE_GITHUB_TOKEN',
+ ),
+ );
+ assert.doesNotMatch(JSON.stringify(missing), /canary-live-secret-value/);
+ });
+
+ it('treats explicit safety confirmations as missing unless they equal true', () => {
+ const environment = Object.fromEntries(LIVE_INPUT_NAMES.map((name) => [name, 'provided']));
+ environment.LUOWANG_LIVE_NON_PRODUCTION_CONFIRMED = 'false';
+ environment.LUOWANG_LIVE_REVIEWER_VISION_CONFIRMED = 'TRUE';
+ environment.LUOWANG_LIVE_MAIN_THINKING = 'unsupported';
+ environment.LUOWANG_LIVE_RUNNER_THINKING = 'high';
+ const missing = missingLiveInputs(environment);
+ assert.equal(missing.includes('LUOWANG_LIVE_NON_PRODUCTION_CONFIRMED'), true);
+ assert.equal(missing.includes('LUOWANG_LIVE_REVIEWER_VISION_CONFIRMED'), false);
+ assert.equal(missing.includes('LUOWANG_LIVE_MAIN_THINKING'), true);
+ assert.equal(missing.includes('LUOWANG_LIVE_RUNNER_THINKING'), false);
+ assert.equal(missing.includes('LUOWANG_LIVE_OSS_PRIVATE_CONFIRMED'), true);
+ });
+
+ it('builds local subprocess environment from a non-secret allowlist', () => {
+ const original = {
+ github: process.env.GITHUB_TOKEN,
+ aws: process.env.AWS_SECRET_ACCESS_KEY,
+ master: process.env.LUOWANG_MASTER_KEY,
+ };
+ process.env.GITHUB_TOKEN = 'canary-github-value';
+ process.env.AWS_SECRET_ACCESS_KEY = 'canary-aws-value';
+ process.env.LUOWANG_MASTER_KEY = 'canary-master-value';
+ try {
+ const environment = localOnlyEnvironment('/tmp/luowang-closure6-environment');
+ assert.equal(environment.GITHUB_TOKEN, undefined);
+ assert.equal(environment.AWS_SECRET_ACCESS_KEY, undefined);
+ assert.equal(environment.LUOWANG_MASTER_KEY, undefined);
+ assert.equal(environment.NODE_ENV, 'test');
+ assert.equal(environment.HOME, '/tmp/luowang-closure6-environment/isolated-home');
+ assert.doesNotMatch(JSON.stringify(environment), /canary-(?:github|aws|master)-value/);
+ } finally {
+ restoreEnvironment('GITHUB_TOKEN', original.github);
+ restoreEnvironment('AWS_SECRET_ACCESS_KEY', original.aws);
+ restoreEnvironment('LUOWANG_MASTER_KEY', original.master);
+ }
+ });
+
+ it('redacts credential-shaped command output before it can enter reports', () => {
+ const output = redactAcceptanceText(
+ `Authorization: Bearer canary-bearer token=canary-token {"token":"canary-json-token","apiKey":"canary-json-key","password":"canary-json-password"} github_pat_1234567890abcdef AKIA1234567890ABCDEF https://user:pass@example.test/path`,
+ );
+ assert.doesNotMatch(output, /canary-|github_pat_|AKIA123|user:pass/);
+ assert.match(output, /REDACTED/);
+ });
+
+ it('keeps release blocked when local passes but live is blocked', () => {
+ const report = createLayeredReport({
+ mode: 'local',
+ startedAt: '2026-09-01T00:00:00.000Z',
+ local: { status: 'passed', message: 'local passed' },
+ live: {
+ status: 'blocked',
+ message: 'live inputs missing',
+ missing: ['LUOWANG_LIVE_PROVIDER_API_KEY'],
+ },
+ proofs: proofStatuses(),
+ });
+ assert.equal(report.local.status, 'passed');
+ assert.equal(report.live.status, 'blocked');
+ assert.equal(report.release.status, 'blocked');
+ assert.equal(report.acEvidence.length, 14);
+ assert.equal(new Set(report.acEvidence.map((item) => item.ac)).size, 14);
+ assert.equal(
+ report.resourceChecks.every((item) => item.evidence.length > 0),
+ true,
+ );
+ });
+
+ it('derives each AC status from its corresponding proof instead of the local aggregate', () => {
+ const report = createLayeredReport({
+ mode: 'local',
+ startedAt: '2026-09-01T00:00:00.000Z',
+ local: { status: 'passed', message: 'aggregate local status' },
+ live: { status: 'blocked', message: 'live blocked' },
+ proofs: proofStatuses({
+ merge01: 'blocked',
+ data02: 'failed',
+ history01: 'not_run',
+ ordinaryPi: 'failed',
+ acceptanceLayering: 'passed',
+ acMapping: 'blocked',
+ }),
+ });
+ const statuses = Object.fromEntries(report.acEvidence.map((item) => [item.ac, item.status]));
+ assert.equal(statuses['AC-CLOSURE-MERGE-01'], 'blocked');
+ assert.equal(statuses['AC-CLOSURE-DATA-02'], 'failed');
+ assert.equal(statuses['AC-CLOSURE-HISTORY-01'], 'not_run');
+ assert.equal(statuses['AC-CLOSURE-PI-01'], 'failed');
+ assert.equal(statuses['AC-CLOSURE-ACCEPT-01'], 'passed');
+ assert.equal(statuses['AC-CLOSURE-ACCEPT-02'], 'blocked');
+ assert.equal(statuses['AC-CLOSURE-TARGET-01'], 'passed');
+ assert.equal(
+ report.resourceChecks.find((item) => item.id === 'pi-sdk-ordinary-four-session')?.status,
+ 'failed',
+ );
+ assert.equal(report.resourceChecks.at(-1)?.status, 'blocked');
+ });
+
+ it('documents Docker priority, native build dependencies, and honest acceptance boundaries', async () => {
+ const readme = await readFile('README.md', 'utf8');
+ assert.match(readme, /优先使用.*Docker/s);
+ assert.match(readme, /python3.*make.*g\+\+/s);
+ assert.match(readme, /test:acceptance:local/);
+ assert.match(readme, /test:acceptance:live/);
+ assert.match(readme, /test:acceptance:release/);
+ assert.match(readme, /local.*只能证明.*local\.status=passed/s);
+ assert.match(readme, /release\.status.*blocked/);
+ });
+
+ it('validates integrated role instruction method content when Closure 1 is present', async () => {
+ const files = [
+ 'common.md',
+ 'main-planning.md',
+ 'runner-execution.md',
+ 'reviewer-audit.md',
+ 'main-finalization.md',
+ 'scenario-initialization.md',
+ ];
+ let contents: string[];
+ try {
+ contents = await Promise.all(
+ files.map((file) => readFile(`resources/agent-roles/${file}`, 'utf8')),
+ );
+ } catch {
+ return;
+ }
+ for (const [index, content] of contents.entries()) {
+ assert.match(content, new RegExp(`luowang-role-id: ${files[index]?.replace(/\.md$/, '')}`));
+ for (const heading of ['目标', '硬边界', '顺序', '输出契约', '失败规则', '反模式']) {
+ assert.match(content, new RegExp(`## ${heading}`));
+ }
+ }
+ const all = contents.join('\n');
+ assert.match(all, /不得用当前实现反推正确期望/);
+ assert.match(all, /证据优先级/);
+ assert.match(all, /Runner 报告是待审核假设/);
+ assert.match(all, /清理声明不是独立核验事实/);
+ assert.match(all, /不影响验证目标的偏差可以记录后继续/);
+ assert.match(all, /blocked > failed > passed/);
+ });
+
+ it('requires every public quality command before local or release acceptance can pass', () => {
+ assert.deepEqual(PUBLIC_QUALITY_SCRIPTS, [
+ 'format:check',
+ 'lint',
+ 'typecheck',
+ 'test',
+ 'build',
+ 'test:e2e',
+ ]);
+ const report = createLayeredReport({
+ mode: 'release',
+ startedAt: '2026-09-01T00:00:00.000Z',
+ local: { status: 'failed', message: 'quality failed' },
+ live: { status: 'passed', message: 'live passed' },
+ proofs: proofStatuses({ publicQuality: 'failed' }),
+ });
+ assert.equal(
+ report.acEvidence.find((item) => item.ac === 'AC-CLOSURE-ACCEPT-01')?.status,
+ 'failed',
+ );
+ assert.equal(report.release.status, 'failed');
+ });
+
+ it('exposes separate package commands and keeps CI on local only', async () => {
+ const packageJson = JSON.parse(await readFile('package.json', 'utf8')) as {
+ scripts: Record;
+ };
+ assert.equal(packageJson.scripts['test:acceptance'], 'npm run test:acceptance:local');
+ assert.match(packageJson.scripts['test:acceptance:local'] ?? '', /closure\.ts local/);
+ assert.match(packageJson.scripts['test:acceptance:live'] ?? '', /closure\.ts live/);
+ assert.match(packageJson.scripts['test:acceptance:release'] ?? '', /closure\.ts release/);
+ const workflow = await readFile('.github/workflows/quality.yml', 'utf8');
+ assert.match(workflow, /timeout-minutes:\s*60/);
+ assert.match(workflow, /npm run test:acceptance:local/);
+ assert.doesNotMatch(workflow, /npm run test:acceptance:(?:live|release)/);
+ const dockerignore = await readFile('.dockerignore', 'utf8');
+ assert.match(dockerignore, /!\.github\/workflows\/quality\.yml/);
+ });
+});
+
+function proofStatuses(overrides: Partial = {}): ClosureProofStatuses {
+ return {
+ doc: 'passed',
+ instr01: 'passed',
+ instr02: 'passed',
+ instr03: 'passed',
+ merge01: 'passed',
+ target01: 'passed',
+ merge02: 'passed',
+ data01: 'passed',
+ data02: 'passed',
+ active01: 'passed',
+ history01: 'passed',
+ ordinaryPi: 'passed',
+ directInitialization: 'passed',
+ scenarioReview: 'passed',
+ finalRevision: 'passed',
+ invalidTool: 'passed',
+ mergeConflict: 'passed',
+ indexerRecovery: 'passed',
+ archiveRetry: 'passed',
+ processRestart: 'passed',
+ queueRecovery: 'passed',
+ publicQuality: 'passed',
+ acceptanceLayering: 'passed',
+ acMapping: 'passed',
+ ...overrides,
+ };
+}
+
+function restoreEnvironment(name: string, value: string | undefined): void {
+ if (value === undefined) delete process.env[name];
+ else process.env[name] = value;
+}
diff --git a/tests/closure6-production-pi.test.ts b/tests/closure6-production-pi.test.ts
new file mode 100644
index 0000000..e4f5fce
--- /dev/null
+++ b/tests/closure6-production-pi.test.ts
@@ -0,0 +1,495 @@
+import { strict as assert } from 'node:assert';
+import { execFile } from 'node:child_process';
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { promisify } from 'node:util';
+
+import pino from 'pino';
+import { afterEach, describe, it } from 'vitest';
+
+import { createAutomationService } from '../src/server/automation/service.js';
+import { loadConfig } from '../src/server/config.js';
+import { createConfigurationStore } from '../src/server/configuration.js';
+import { initializeDatabase } from '../src/server/db/migrate.js';
+import { createRepositoryService } from '../src/server/repository/service.js';
+import type { RepositoryService } from '../src/server/repository/service.js';
+import { createRunArchiver, type RunArchiver } from '../src/server/runs/archiver.js';
+import { createControlledCommandRunner } from '../src/server/runs/command-runner.js';
+import { createRunOrchestrator } from '../src/server/runs/orchestrator.js';
+import type { ProviderAdapter } from '../src/server/runs/provider.js';
+import { createRunStore, type RunStore } from '../src/server/runs/store.js';
+import { createTestDataManager } from '../src/server/runs/test-data.js';
+import { createSecretStore } from '../src/server/security/secret-store.js';
+import {
+ startLocalModelProtocol,
+ type LocalModelBehavior,
+ type LocalModelProtocol,
+} from './acceptance/local-model-protocol.js';
+
+const execFileAsync = promisify(execFile);
+const cleanup: Array<() => Promise> = [];
+
+afterEach(async () => {
+ while (cleanup.length > 0) await cleanup.pop()?.();
+});
+
+describe('Closure 6 local production Pi path', () => {
+ it('runs an ordinary four-session Run through createAgentSession and custom tool loops', async () => {
+ const context = await createContext('review-all', 'normal');
+ const result = await context.orchestrator.run({
+ request: '验证固定 target 的生产 Pi Session 工件交接',
+ trigger: 'manual',
+ });
+
+ assert.equal(result.status, 'completed', JSON.stringify(result));
+ assert.equal(result.result, 'passed');
+ assert.deepEqual(Object.keys(result.artifacts).sort(), [
+ 'draft-report.md',
+ 'execution.md',
+ 'plan.md',
+ 'report.md',
+ 'review.md',
+ ]);
+ assertSessionSequence(context.model, ['main-a', 'runner', 'reviewer', 'main-b']);
+ assert.ok(context.model.requestCount > context.model.sessions.length);
+ assert.equal(context.model.sessions[0]?.model, context.model.sessions[3]?.model);
+ assert.notDeepEqual(context.model.sessions[0]?.tools, context.model.sessions[3]?.tools);
+ assert.match(result.artifacts['report.md'] ?? '', /Reviewer 已独立确认/);
+ });
+
+ it('creates the first scenario branch through FIFO before one six-Session production Pi initialization Run', async () => {
+ const context = await createContext('autonomous', 'normal', false);
+ let releasePreparation: () => void = () => undefined;
+ const preparationGate = new Promise((resolve) => {
+ releasePreparation = resolve;
+ });
+ let markPreparationStarted: () => void = () => undefined;
+ const preparationStarted = new Promise((resolve) => {
+ markPreparationStarted = resolve;
+ });
+ const repository = new Proxy(context.repository, {
+ get(target, property, receiver) {
+ if (property === 'prepareMergeRequest') {
+ return async (...args: Parameters) => {
+ markPreparationStarted();
+ await preparationGate;
+ return target.prepareMergeRequest(...args);
+ };
+ }
+ const value = Reflect.get(target, property, receiver) as unknown;
+ return typeof value === 'function' ? value.bind(target) : value;
+ },
+ }) as RepositoryService;
+ const automation = createAutomationService({
+ database: context.database.sqlite,
+ configuration: context.configuration,
+ repository,
+ runs: context.orchestrator,
+ archiver: noopArchiver(),
+ runStore: context.runStore,
+ reportDir: context.reportDir,
+ logger: pino({ level: 'silent' }),
+ });
+
+ const submission = await automation.submitTestRequest({
+ request: '从 main 首次创建场景分支并初始化',
+ trigger: 'manual',
+ requestKind: 'manual-merge-source',
+ sourceRef: 'main',
+ confirmed: true,
+ initialization: true,
+ });
+ assert.equal(submission.queue.status, 'queued');
+ assert.equal(submission.run, null);
+ await preparationStarted;
+ assert.equal(
+ await (await context.repository.getRepository()).remoteBranchHead('scenario-testing'),
+ null,
+ );
+
+ releasePreparation();
+ await waitFor(() => automation.getQueue(submission.queue.queueId)?.runId !== null);
+ const running = automation.getQueue(submission.queue.queueId);
+ assert.ok(running?.runId);
+ assert.equal(running?.initialization, true);
+ assert.equal(running?.preparedMergeMode, 'initial-create');
+ assert.equal(running?.preparedMergeCommit, running?.resolvedTargetCommit);
+ const result = await context.orchestrator.wait(running?.runId as string);
+ assert.equal(result?.status, 'completed', JSON.stringify(result));
+ assert.equal(result?.result, 'passed');
+ assert.equal(result?.targetCommit, running?.resolvedTargetCommit);
+ assertSessionSequence(context.model, [
+ 'main-a',
+ 'runner',
+ 'main-a',
+ 'runner',
+ 'reviewer',
+ 'main-b',
+ ]);
+ await waitForAsync(
+ async () =>
+ automation.getQueue(submission.queue.queueId)?.status === 'completed' &&
+ (await repository.readMergeRequestRef(submission.queue.queueId)) === null,
+ );
+ });
+
+ it('runs unfamiliar-project direct initialization through six isolated production Pi Sessions', async () => {
+ const context = await createContext('autonomous', 'normal');
+ const result = await context.orchestrator.run({
+ request: '初始化陌生项目并直接新增一个高价值场景',
+ trigger: 'manual',
+ initialization: true,
+ });
+
+ assert.equal(result.status, 'completed', JSON.stringify(result));
+ assert.equal(result.result, 'passed');
+ assertSessionSequence(context.model, [
+ 'main-a',
+ 'runner',
+ 'main-a',
+ 'runner',
+ 'reviewer',
+ 'main-b',
+ ]);
+ assert.equal(new Set(context.model.sessions.map((session) => session.id)).size, 6);
+ assert.deepEqual(Object.keys(result.artifacts).sort(), [
+ 'draft-report.md',
+ 'execution.md',
+ 'plan.md',
+ 'report.md',
+ 'review.md',
+ 'scenario-changes.patch',
+ ]);
+ assert.match(result.artifacts['scenario-changes.patch'] ?? '', /ONBOARD-SMOKE-001/);
+ assert.equal(context.model.sessions.filter((session) => session.role === 'main-a').length, 2);
+ assert.equal(context.model.sessions.filter((session) => session.role === 'runner').length, 2);
+ });
+
+ it('stops review-required initialization after three Sessions and selectively finalizes two artifacts', async () => {
+ const context = await createContext('review-all', 'special-cleanup');
+ const result = await context.orchestrator.run({
+ request: '初始化陌生项目但场景变更必须人工审核',
+ trigger: 'manual',
+ initialization: true,
+ });
+
+ assert.equal(result.status, 'completed', JSON.stringify(result));
+ assert.equal(result.result, 'blocked');
+ assertSessionSequence(context.model, ['main-a', 'runner', 'main-a']);
+ assert.deepEqual(Object.keys(result.artifacts).sort(), ['report.md', 'scenario-changes.patch']);
+ assert.match(result.artifacts['report.md'] ?? '', /等待场景变更人工审核/);
+ assert.equal(
+ context.model.sessions.some((session) => session.role === 'reviewer'),
+ false,
+ );
+ assert.equal(
+ context.model.sessions.some((session) => session.role === 'main-b'),
+ false,
+ );
+ assert.equal(context.specialCleanupCalls(), 1);
+ assert.match(result.artifacts['report.md'] ?? '', /测试数据:全部登记测试数据均已独立核验清理/);
+ assert.match(result.artifacts['report.md'] ?? '', /特殊归档仅保留/);
+ assert.doesNotMatch(result.artifacts['report.md'] ?? '', /测试数据残留|清理失败/);
+
+ const publicationModes: string[] = [];
+ const archiveRepository = {
+ validateScenarioPatch: (target: string, patch: string) =>
+ context.repository.validateScenarioPatch(target, patch),
+ publishScenarioChanges: async (
+ _runId: string,
+ _patch: string,
+ mode: 'direct' | 'pull-request',
+ ) => {
+ publicationModes.push(mode);
+ return {
+ status: 'pull_request' as const,
+ commitSha: 'f'.repeat(40),
+ scenarioBranchHead: result.targetCommit,
+ scenarioPrUrl: 'https://github.com/example/target/pull/17',
+ };
+ },
+ } as unknown as RepositoryService;
+ const archiver = createRunArchiver({
+ database: context.database.sqlite,
+ reportDir: context.reportDir,
+ repository: archiveRepository,
+ runStore: context.runStore,
+ logger: pino({ level: 'silent' }),
+ });
+ const archived = await archiver.archive(result.runId);
+ assert.equal(archived.status, 'completed', JSON.stringify(archived));
+ assert.equal(archived.scenarioStatus, 'pull_request');
+ assert.equal(archived.scenarioPrUrl, 'https://github.com/example/target/pull/17');
+ assert.deepEqual(publicationModes, ['pull-request']);
+ });
+
+ it('keeps finalization revisions blocked when no new Runner Session re-executes them', async () => {
+ const context = await createContext('autonomous', 'revise-final-patch');
+ const result = await context.orchestrator.run({
+ request: '初始化后按 Reviewer 意见修订候选场景但不重跑',
+ trigger: 'manual',
+ initialization: true,
+ });
+
+ assert.equal(result.status, 'completed', JSON.stringify(result));
+ assert.equal(result.result, 'blocked');
+ assertSessionSequence(context.model, [
+ 'main-a',
+ 'runner',
+ 'main-a',
+ 'runner',
+ 'reviewer',
+ 'main-b',
+ ]);
+ assert.match(result.artifacts['scenario-changes.patch'] ?? '', /ONBOARD-REVISED-001/);
+ assert.match(result.artifacts['report.md'] ?? '', /修订内容未重新执行/);
+ assert.equal(context.model.sessions.filter((session) => session.role === 'runner').length, 2);
+ });
+
+ it('fails closed when the local model requests a tool outside the production allowlist', async () => {
+ const context = await createContext('review-all', 'invalid-tool');
+ const result = await context.orchestrator.run({
+ request: '模型越权工具调用必须失败',
+ trigger: 'manual',
+ });
+
+ assert.equal(result.status, 'failed');
+ assert.equal(result.result, null);
+ assert.deepEqual(result.artifacts, {});
+ assert.equal(context.model.sessions.length, 1);
+ assert.equal(context.model.sessions[0]?.disposed, true);
+ });
+});
+
+interface ProductionContext {
+ orchestrator: ReturnType;
+ model: LocalModelProtocol;
+ database: ReturnType;
+ reportDir: string;
+ repository: ReturnType;
+ configuration: ReturnType;
+ runStore: RunStore;
+ specialCleanupCalls(): number;
+}
+
+async function createContext(
+ scenarioMode: 'autonomous' | 'review-all',
+ behavior: LocalModelBehavior,
+ withScenarioBranch = true,
+): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'luowang-closure6-'));
+ cleanup.push(async () => rm(root, { recursive: true, force: true }));
+ const remote = join(root, 'remote.git');
+ const source = join(root, 'source');
+ const clone = join(root, 'clone');
+ const dataDir = join(root, 'data');
+ const reportDir = join(dataDir, 'report');
+ await mkdir(source, { recursive: true });
+ await git(['init', '--bare', remote], root);
+ await git(['init', '--initial-branch=main'], source);
+ await git(['config', 'user.name', 'LuoWang Closure 6'], source);
+ await git(['config', 'user.email', 'luowang-closure6@example.test'], source);
+ await writeFile(join(source, 'README.md'), '# Local Pi target\n', 'utf8');
+ await git(['add', 'README.md'], source);
+ await git(['commit', '-m', 'fixture: initialize target'], source);
+ await git(['remote', 'add', 'origin', remote], source);
+ await git(['push', '-u', 'origin', 'main'], source);
+ if (withScenarioBranch) {
+ await git(['checkout', '-b', 'scenario-testing'], source);
+ await git(['push', '-u', 'origin', 'scenario-testing'], source);
+ }
+
+ const config = loadConfig({
+ NODE_ENV: 'test',
+ LUOWANG_DATA_DIR: dataDir,
+ LUOWANG_REPO_DIR: clone,
+ LUOWANG_REPORT_DIR: reportDir,
+ LUOWANG_ADMIN_PASSWORD: 'closure6-local-admin-password!',
+ LUOWANG_MASTER_KEY: 'closure6-local-master-key',
+ });
+ const database = initializeDatabase(config);
+ cleanup.push(async () => database.close());
+ const configuration = createConfigurationStore(database.sqlite, {
+ repoDir: config.repoDir,
+ reportDir: config.reportDir,
+ });
+ configuration.updateRepository({
+ repository: remote,
+ scenarioBranch: 'scenario-testing',
+ scenarioMode,
+ baseUrl: 'http://127.0.0.1:4173',
+ });
+ configuration.updateHarness({
+ provider: 'luowang-local',
+ agents: {
+ main: { model: 'deterministic-tool-model', thinking: 'off' },
+ runner: { model: 'deterministic-tool-model', thinking: 'off' },
+ reviewer: { model: 'deterministic-tool-model', thinking: 'off' },
+ },
+ mcp: { enabled: false },
+ });
+ const secretStore = createSecretStore(database.sqlite, config.masterKey);
+ secretStore.set('testUsername', 'local-synthetic-user');
+ secretStore.set('testPassword', 'local-synthetic-password');
+ const repository = createRepositoryService(database.sqlite, configuration, secretStore, {
+ repoDir: config.repoDir,
+ allowLocalRepository: true,
+ });
+ const runStore = createRunStore(database.sqlite);
+ const model = await startLocalModelProtocol(behavior);
+ cleanup.push(() => model.close());
+ let specialCleanupCalls = 0;
+ const testData = createTestDataManager({
+ cleanupAdapter: {
+ id: 'closure6-special-cleanup',
+ cleanupAndVerify: async () => {
+ specialCleanupCalls += 1;
+ return { absent: true, content: 'not found', statusCode: 404 };
+ },
+ },
+ });
+ const orchestrator = createRunOrchestrator({
+ configuration,
+ repository,
+ reportDir,
+ secretStore,
+ provider: {} as ProviderAdapter,
+ sessions: model.sessionFactory,
+ commandRunner: createControlledCommandRunner(process.env),
+ testData,
+ runStore,
+ logger: pino({ level: 'silent' }),
+ browser: disabledBrowser(),
+ });
+ return {
+ orchestrator,
+ model,
+ database,
+ reportDir,
+ repository,
+ configuration,
+ runStore,
+ specialCleanupCalls: () => specialCleanupCalls,
+ };
+}
+
+function assertSessionSequence(model: LocalModelProtocol, expected: string[]): void {
+ assert.deepEqual(
+ model.sessions.map((session) => session.role),
+ expected,
+ );
+ assert.equal(
+ model.sessions.every((session) => session.disposed),
+ true,
+ );
+ assert.equal(
+ model.sessions.every((session) => session.prompts.length === 1),
+ true,
+ );
+ assert.equal(new Set(model.sessions.map((session) => session.id)).size, expected.length);
+ if (model.sessions.some((session) => session.roleInstructionVersions.length > 0)) {
+ const roleIds: Record = {
+ 'main-planning': 'main-planning',
+ 'runner-execution': 'runner-execution',
+ 'reviewer-audit': 'reviewer-audit',
+ 'main-finalization': 'main-finalization',
+ };
+ for (const session of model.sessions) {
+ assert.ok(session.sessionKind, 'integrated production Session must expose its session kind');
+ const ids = session.roleInstructionVersions.map((version) => version.id);
+ const initialization = session.prompts.some((prompt) =>
+ /"initialization"\s*:\s*true/.test(prompt),
+ );
+ const expectsInitialization =
+ initialization &&
+ (session.sessionKind === 'main-planning' || session.sessionKind === 'main-finalization');
+ const expectedIds = [
+ 'common',
+ roleIds[session.sessionKind as string] as string,
+ ...(expectsInitialization ? ['scenario-initialization'] : []),
+ ];
+ assert.deepEqual(ids, expectedIds);
+ assert.doesNotMatch(session.systemPrompt, /"initialization"\s*:/);
+ assert.equal(
+ session.roleInstructionVersions.every(
+ (version) =>
+ version.formatVersion !== '' &&
+ version.applicationVersion !== '' &&
+ /^[0-9a-f]{64}$/.test(version.sha256),
+ ),
+ true,
+ );
+ for (const roleId of [
+ 'common',
+ 'main-planning',
+ 'runner-execution',
+ 'reviewer-audit',
+ 'main-finalization',
+ 'scenario-initialization',
+ ]) {
+ assert.equal(
+ session.systemPrompt.includes(`luowang-role-id: ${roleId};`),
+ expectedIds.includes(roleId),
+ `${session.sessionKind} system prompt role marker mismatch: ${roleId}`,
+ );
+ }
+ }
+ }
+}
+
+function noopArchiver(): RunArchiver {
+ return {
+ archive: async (runId) => ({
+ runId,
+ status: 'completed',
+ reportStatus: 'published',
+ reportCommitSha: null,
+ issues: [],
+ progressed: true,
+ archiveStatus: 'completed',
+ errorMessage: null,
+ indexerTriggered: false,
+ }),
+ scan: async () => [],
+ retry: async (runId) => noopArchiver().archive(runId),
+ };
+}
+
+async function waitFor(predicate: () => boolean): Promise {
+ return waitForAsync(async () => predicate());
+}
+
+async function waitForAsync(predicate: () => Promise): Promise {
+ for (let attempt = 0; attempt < 400; attempt += 1) {
+ if (await predicate()) return;
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+ throw new Error('condition was not reached');
+}
+
+function disabledBrowser() {
+ return {
+ isEnabled: () => false,
+ serverDefinition: () => ({
+ command: 'node',
+ args: [],
+ env: {},
+ lifecycle: 'lazy' as const,
+ directTools: false as const,
+ excludeTools: [],
+ requestTimeoutMs: 30_000,
+ }),
+ extension: () => ({ name: 'disabled-browser', hidden: true, factory: async () => undefined }),
+ checkConnectivity: async () => ({
+ status: 'not_configured' as const,
+ message: '本地验收不需要浏览器',
+ checkedAt: null,
+ latencyMs: null,
+ }),
+ };
+}
+
+function git(args: string[], cwd: string) {
+ return execFileAsync('git', args, { cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
+}
diff --git a/tests/phase3-api.test.ts b/tests/phase3-api.test.ts
index 55cc7bf..249c00a 100644
--- a/tests/phase3-api.test.ts
+++ b/tests/phase3-api.test.ts
@@ -85,8 +85,10 @@ describe('Phase 3 Run API', () => {
payload: { request: '验证测试项目', trigger: 'api' },
});
assert.equal(start.statusCode, 202);
- assert.equal(start.json().runId, '01K00000000000000000000001');
- assert.equal(started.length, 1);
+ assert.equal(start.json().status, 'queued');
+ assert.equal(start.json().run, null);
+ assert.equal(typeof start.json().queueId, 'number');
+ await waitFor(() => started.length === 1);
assert.equal(started[0]?.request, '验证测试项目');
assert.equal(started[0]?.trigger, 'api');
assert.equal(started[0]?.targetCommit, 'a'.repeat(40));
@@ -221,6 +223,14 @@ function emptyDetail(): RunDetail {
return { ...emptySummary(), artifacts: {} };
}
+async function waitFor(predicate: () => boolean): Promise {
+ for (let attempt = 0; attempt < 200; attempt += 1) {
+ if (predicate()) return;
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+ throw new Error('condition was not reached');
+}
+
function firstCookie(value: string | string[] | undefined): string {
const first = Array.isArray(value) ? value[0] : value;
return first?.split(';', 1)[0] ?? '';
diff --git a/tests/phase4.test.ts b/tests/phase4.test.ts
index cd09b04..5e3650e 100644
--- a/tests/phase4.test.ts
+++ b/tests/phase4.test.ts
@@ -226,6 +226,9 @@ describe('Phase 4 browser and evidence boundaries', () => {
),
false,
);
+ const uploadedCleanup = await store.cleanupUploaded();
+ assert.deepEqual(uploadedCleanup, { deleted: ['login.png'], failures: [] });
+ assert.deepEqual(await store.list(), []);
});
});
diff --git a/tests/phase6.test.ts b/tests/phase6.test.ts
index 6a4f3fd..4a1f329 100644
--- a/tests/phase6.test.ts
+++ b/tests/phase6.test.ts
@@ -274,8 +274,11 @@ describe('Phase 6 persistent automation', () => {
trigger: 'manual',
requestKind: 'manual-current-head',
});
- assert.equal(first.run?.runId, runId(1));
+ assert.equal(first.queue.status, 'queued');
+ assert.equal(first.run, null);
+ assert.equal(second.queue.status, 'queued');
assert.equal(second.run, null);
+ await waitFor(() => fakeRuns.started.length === 1);
assert.deepEqual(
fakeRuns.started.map((item) => item.request),
['第一项'],
@@ -442,8 +445,9 @@ describe('Phase 6 persistent automation', () => {
const oldRunId = runId(5);
const recentRunId = runId(6);
const failedRunId = runId(7);
+ const pendingQueueRunId = runId(24);
await Promise.all(
- [oldRunId, recentRunId, failedRunId].map((id) =>
+ [oldRunId, recentRunId, failedRunId, pendingQueueRunId].map((id) =>
mkdir(join(context.config.reportDir, 'completed', id), { recursive: true }),
),
);
@@ -464,8 +468,22 @@ describe('Phase 6 persistent automation', () => {
archiveStatus: 'failed',
finishedAt: '2026-08-28T00:00:00.000Z',
},
+ {
+ runId: pendingQueueRunId,
+ archiveStatus: 'completed',
+ finishedAt: '2026-08-28T00:00:00.000Z',
+ },
],
} as unknown as RunStore;
+ const queue = createTestRequestQueue(context.database.sqlite);
+ const pending = queue.enqueue({
+ request: 'retention must preserve queue recovery artifacts',
+ trigger: 'manual',
+ requestKind: 'manual-current-head',
+ });
+ queue.claimNext();
+ queue.markResolved(pending.queueId, 'c'.repeat(40));
+ queue.markStarted(pending.queueId, pendingQueueRunId);
const automation = createAutomationService({
database: context.database.sqlite,
configuration,
@@ -473,17 +491,101 @@ describe('Phase 6 persistent automation', () => {
runs: createRestartFakeRuns().runs,
archiver: createNoopArchiver(),
runStore,
+ queue,
reportDir: context.config.reportDir,
now: () => new Date('2026-08-30T00:00:00.000Z'),
});
const cleanupResult = await automation.cleanupRetention();
- assert.deepEqual(cleanupResult, { removedRunIds: [oldRunId], skippedRunIds: [] });
+ assert.deepEqual(cleanupResult, {
+ removedRunIds: [oldRunId],
+ skippedRunIds: [pendingQueueRunId],
+ });
const remaining = await new RunWorkspaceStore(context.config.reportDir).list('completed');
- assert.deepEqual(remaining, [failedRunId, recentRunId].sort());
+ assert.deepEqual(remaining, [failedRunId, pendingQueueRunId, recentRunId].sort());
await assert.rejects(access(join(context.config.reportDir, 'completed', oldRunId)));
});
+ it('refuses manual cleanup until archive and every associated queue operation are complete', async () => {
+ const context = await createDatabaseContext();
+ const configuration = createConfigurationStore(context.database.sqlite, {
+ repoDir: context.config.repoDir,
+ reportDir: context.config.reportDir,
+ });
+ const pendingArchiveRunId = runId(20);
+ const pendingQueueRunId = runId(21);
+ const terminalFailedRunId = runId(22);
+ const cleanableRunId = runId(23);
+ await Promise.all(
+ [pendingArchiveRunId, pendingQueueRunId, terminalFailedRunId, cleanableRunId].map((id) =>
+ mkdir(join(context.config.reportDir, 'completed', id), { recursive: true }),
+ ),
+ );
+ const records = new Map([
+ [pendingArchiveRunId, { runId: pendingArchiveRunId, archiveStatus: 'failed' }],
+ [pendingQueueRunId, { runId: pendingQueueRunId, archiveStatus: 'completed' }],
+ [terminalFailedRunId, { runId: terminalFailedRunId, archiveStatus: 'completed' }],
+ [cleanableRunId, { runId: cleanableRunId, archiveStatus: 'completed' }],
+ ]);
+ const runStore = {
+ get: (id: string) => records.get(id) ?? null,
+ list: () => [...records.values()],
+ } as unknown as RunStore;
+ const queue = createTestRequestQueue(context.database.sqlite);
+ const queued = queue.enqueue({
+ request: 'still waiting for archive',
+ trigger: 'manual',
+ requestKind: 'manual-current-head',
+ });
+ queue.claimNext();
+ queue.markResolved(queued.queueId, 'a'.repeat(40));
+ queue.markStarted(queued.queueId, pendingQueueRunId);
+ const terminal = queue.enqueue({
+ request: 'terminal queue status must still be completed',
+ trigger: 'manual',
+ requestKind: 'manual-current-head',
+ });
+ queue.claimNext();
+ queue.markResolved(terminal.queueId, 'b'.repeat(40));
+ queue.markStarted(terminal.queueId, terminalFailedRunId);
+ queue.complete(terminal.queueId, {
+ runId: terminalFailedRunId,
+ archiveStatus: 'completed',
+ progressed: false,
+ });
+ context.database.sqlite
+ .prepare("UPDATE test_request_queue SET status = 'failed' WHERE queue_id = ?")
+ .run(terminal.queueId);
+ const automation = createAutomationService({
+ database: context.database.sqlite,
+ configuration,
+ repository: {} as RepositoryService,
+ runs: createRestartFakeRuns().runs,
+ archiver: createNoopArchiver(),
+ runStore,
+ queue,
+ reportDir: context.config.reportDir,
+ });
+
+ await assert.rejects(
+ () => automation.cleanupRun(pendingArchiveRunId),
+ /归档尚未完成.*保留 report、场景 patch 和 Issue 重试工件/,
+ );
+ await assert.rejects(
+ () => automation.cleanupRun(pendingQueueRunId),
+ /仍有关联的队列.*重试操作/,
+ );
+ await assert.rejects(
+ () => automation.cleanupRun(terminalFailedRunId),
+ /仍有关联的队列.*重试操作/,
+ );
+ await automation.cleanupRun(cleanableRunId);
+ await assert.rejects(access(join(context.config.reportDir, 'completed', cleanableRunId)));
+ await access(join(context.config.reportDir, 'completed', pendingArchiveRunId));
+ await access(join(context.config.reportDir, 'completed', pendingQueueRunId));
+ await access(join(context.config.reportDir, 'completed', terminalFailedRunId));
+ });
+
it('marks an orphaned running directory interrupted without restoring an Agent session', async () => {
const context = await createDatabaseContext();
const configuration = createConfigurationStore(context.database.sqlite, {