From 613041245b0604dcd4a57407bb72a0d1bcb1233a Mon Sep 17 00:00:00 2001 From: Jay Shen Date: Tue, 1 Sep 2026 18:48:30 +0800 Subject: [PATCH] feat: close production-path local acceptance --- .github/workflows/quality.yml | 4 +- README.md | 41 +- package.json | 5 +- src/server/automation/service.ts | 54 +- src/server/runs/agent-session.ts | 6 +- src/server/runs/evidence.ts | 21 + src/server/runs/orchestrator.ts | 75 +- src/server/runs/types.ts | 1 + src/server/runs/workspace.ts | 10 + tests/acceptance/closure.ts | 818 +++++++++++++++++++++ tests/acceptance/local-model-protocol.ts | 526 +++++++++++++ tests/closure2-merge-queue.test.ts | 11 +- tests/closure6-acceptance-layering.test.ts | 249 +++++++ tests/closure6-production-pi.test.ts | 495 +++++++++++++ tests/phase3-api.test.ts | 14 +- tests/phase4.test.ts | 3 + tests/phase6.test.ts | 110 ++- 17 files changed, 2401 insertions(+), 42 deletions(-) create mode 100644 tests/acceptance/closure.ts create mode 100644 tests/acceptance/local-model-protocol.ts create mode 100644 tests/closure6-acceptance-layering.test.ts create mode 100644 tests/closure6-production-pi.test.ts 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, {