release: v1.34.0

This commit is contained in:
coso
2026-05-11 11:51:00 +08:00
parent 50b914b03b
commit 03378c9aba
297 changed files with 27854 additions and 19757 deletions
+12
View File
@@ -144,6 +144,11 @@ function readQcloopStatusSidecars(dirPath) {
const status = readJson(relativePath);
return {
path: relativePath,
generatedAt: status?.generatedAt || "",
jobId: status?.job?.id || status?.jobId || "",
jobStatus: status?.job?.status || "",
jobTerminal: Boolean(status?.job?.terminal),
jobFinishedAt: status?.job?.finishedAt || "",
verdictStatus: status?.verdict?.status || "",
verdictSummary: status?.verdict?.summary || "",
counts: status?.counts || null,
@@ -196,6 +201,7 @@ function loadFacts() {
testsReadme: exists("docs/tests/README.md"),
evidenceSchema: exists("docs/test/agent-qc-evidence.schema.json"),
qcloopJobScript: exists("scripts/agent-qc-qcloop-job.mjs"),
payloadCoverageScript: exists("scripts/agent-qc-payload-coverage.mjs"),
guiOwnerCheckScript: exists("scripts/agent-qc-gui-owner-check.mjs"),
qcloopStatusScript: exists("scripts/agent-qc-qcloop-status.mjs"),
qcloopPreflightScript: exists("scripts/agent-qc-qcloop-preflight.mjs"),
@@ -219,6 +225,12 @@ function loadFacts() {
failedStage: "",
error: "",
}),
payloadCoverage: readOptionalJson(".lime/qc/qcloop-p0-single-owner-ready-coverage-current.json", {
exists: false,
status: "",
coverage: null,
ownerGate: null,
}),
realEvidenceSidecars: readEvidenceSidecars(".lime/qc"),
qcloopStatusSidecars: readQcloopStatusSidecars(".lime/qc"),
scenarioReport,
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import {
buildAgentQcObjectiveChecklist,
renderAgentQcObjectiveChecklistMarkdown,
} from "./lib/agent-qc-objective-checklist-core.mjs";
function parseArgs(argv) {
const result = {
auditPath: ".lime/qc/objective-completion-audit-current.json",
processOwnerPath: ".lime/qc/gui-process-owner-current.json",
guiOwnerPath: ".lime/qc/gui-owner-current.json",
format: "markdown",
outputPath: "",
check: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--audit" && argv[index + 1]) {
result.auditPath = String(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--process-owner" && argv[index + 1]) {
result.processOwnerPath = String(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--gui-owner" && argv[index + 1]) {
result.guiOwnerPath = String(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--format" && argv[index + 1]) {
result.format = String(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--output" && argv[index + 1]) {
result.outputPath = String(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--check") {
result.check = true;
continue;
}
if (arg === "--help" || arg === "-h") {
result.help = true;
}
}
return result;
}
function printHelp() {
console.log(`
Lime Agent QC Objective Checklist
用法:
npm run agent-qc:objective-checklist -- --format json --output ./.lime/qc/objective-completion-checklist-current.json
node scripts/agent-qc-objective-checklist.mjs --check
选项:
--audit PATH completion audit JSON,默认 .lime/qc/objective-completion-audit-current.json
--process-owner PATH raw process owner JSON,默认 .lime/qc/gui-process-owner-current.json
--gui-owner PATH GUI owner JSON,默认 .lime/qc/gui-owner-current.json
--format FMT markdown | json,默认 markdown
--output PATH 写入文件;默认 stdout
--check checklist 未 complete 时非 0 退出
-h, --help 显示帮助
`);
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8"));
}
function writeOutput(outputPath, content) {
if (!outputPath) {
process.stdout.write(content);
return;
}
const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
fs.writeFileSync(resolvedOutputPath, content, "utf8");
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const result = buildAgentQcObjectiveChecklist({
audit: readJson(options.auditPath),
processOwner: readJson(options.processOwnerPath),
guiOwner: readJson(options.guiOwnerPath),
});
const content =
options.format === "json"
? `${JSON.stringify(result, null, 2)}\n`
: renderAgentQcObjectiveChecklistMarkdown(result);
writeOutput(options.outputPath, content);
if (options.check && result.status !== "complete") {
process.exit(1);
}
}
main();
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import {
buildAgentQcPayloadCoverageReport,
renderAgentQcPayloadCoverageMarkdown,
} from "./lib/agent-qc-payload-coverage-core.mjs";
function parseArgs(argv) {
const options = {
check: false,
format: "markdown",
help: false,
manifestPath: "docs/test/agent-qc-scenarios.manifest.json",
outputPath: "",
payloadPath: "",
processOwnerPath: ".lime/qc/gui-process-owner-current.json",
requireOwnerClear: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--manifest" && argv[index + 1]) {
options.manifestPath = argv[index + 1];
index += 1;
continue;
}
if (arg === "--payload" && argv[index + 1]) {
options.payloadPath = argv[index + 1];
index += 1;
continue;
}
if (arg === "--process-owner" && argv[index + 1]) {
options.processOwnerPath = argv[index + 1];
index += 1;
continue;
}
if (arg === "--output" && argv[index + 1]) {
options.outputPath = argv[index + 1];
index += 1;
continue;
}
if (arg === "--format" && argv[index + 1]) {
options.format = argv[index + 1];
index += 1;
continue;
}
if (arg === "--require-owner-clear") {
options.requireOwnerClear = true;
continue;
}
if (arg === "--check") {
options.check = true;
continue;
}
if (arg === "--help" || arg === "-h") {
options.help = true;
}
}
return options;
}
function printHelp() {
console.log(`
Lime Agent QC Payload Coverage
用法:
npm run agent-qc:payload-coverage -- --payload ./.lime/qc/qcloop-p0.json
node scripts/agent-qc-payload-coverage.mjs --payload ./.lime/qc/qcloop-p0.json --format json --check
选项:
--manifest PATH Agent QC scenario manifest,默认 docs/test/agent-qc-scenarios.manifest.json
--payload PATH qcloop job payload JSON
--process-owner PATH raw process owner JSON,默认 .lime/qc/gui-process-owner-current.json
--output PATH 写入文件;默认 stdout
--format FMT markdown | json,默认 markdown
--require-owner-clear --check 时要求 owner gate 也为 ready
--check coverage 不完整时非 0 退出
-h, --help 显示帮助
`);
}
function readJson(filePath, { optional = false } = {}) {
if (!filePath) {
if (optional) return undefined;
throw new Error("缺少 JSON 路径。");
}
const resolved = path.resolve(process.cwd(), filePath);
if (optional && !fs.existsSync(resolved)) {
return undefined;
}
return JSON.parse(fs.readFileSync(resolved, "utf8"));
}
function writeOutput(outputPath, content) {
if (!outputPath) {
process.stdout.write(content);
return;
}
const resolved = path.resolve(process.cwd(), outputPath);
fs.mkdirSync(path.dirname(resolved), { recursive: true });
fs.writeFileSync(resolved, content, "utf8");
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
if (!options.payloadPath) {
throw new Error("必须提供 --payload。");
}
const report = buildAgentQcPayloadCoverageReport({
manifest: readJson(options.manifestPath),
payload: readJson(options.payloadPath),
processOwner: readJson(options.processOwnerPath, { optional: true }),
manifestPath: options.manifestPath,
payloadPath: options.payloadPath,
});
const content =
options.format === "json"
? `${JSON.stringify(report, null, 2)}\n`
: renderAgentQcPayloadCoverageMarkdown(report);
writeOutput(options.outputPath, content);
if (options.check && !report.coverage.passed) {
process.exitCode = 1;
}
if (options.check && options.requireOwnerClear && report.status !== "ready") {
process.exitCode = 1;
}
}
main().catch((error) => {
console.error(`[agent-qc:payload-coverage] ${error.message}`);
process.exitCode = 1;
});
+91 -108
View File
@@ -5,38 +5,11 @@ import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const GUI_OWNER_PATTERNS = [
"verify:gui-smoke",
"smoke:workspace-ready",
"smoke:browser-runtime",
"smoke:site-adapters",
"smoke:agent-service-skill-entry",
"smoke:agent-runtime-tool-surface",
"smoke:knowledge-gui",
"smoke:design-canvas",
"claw-chat-ready-streaming",
"browser-runtime-site-adapter",
"workspace-ready-session-restore",
"release-package-startup-smoke",
];
const CARGO_OWNER_PATTERNS = [
"cargo ",
"cargo-fmt",
"rustc ",
"clippy-driver",
"tauri dev",
];
const QCLOOP_OWNER_PATTERNS = [
"qcloop --db",
"./qcloop --db",
"qcloop serve",
"qcloop-worker",
"qcloop_worker_result",
"agent qc p0",
"只读执行 lime agent qc p0",
];
import {
createAgentQcProcessOwnerReport,
parseEtimeSeconds,
sanitizeProcessCommand,
} from "./lib/agent-qc-process-owner-core.mjs";
function parseArgs(argv) {
const result = {
@@ -48,6 +21,8 @@ function parseArgs(argv) {
maxCargoOrRust: 0,
maxQcloopRelated: 0,
outputPath: "",
staleMinutes: 30,
watchHistoryOutputPath: "",
};
for (let index = 0; index < argv.length; index += 1) {
@@ -67,6 +42,11 @@ function parseArgs(argv) {
index += 1;
continue;
}
if (arg === "--watch-history-output" && argv[index + 1]) {
result.watchHistoryOutputPath = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--max-active-gui-smoke" && argv[index + 1]) {
result.maxActiveGuiSmoke = Number(argv[index + 1]);
index += 1;
@@ -82,6 +62,11 @@ function parseArgs(argv) {
index += 1;
continue;
}
if (arg === "--stale-minutes" && argv[index + 1]) {
result.staleMinutes = Number(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--check") {
result.check = true;
continue;
@@ -105,9 +90,11 @@ Lime Agent QC Process Owner Check
--format FMT summary | json,默认 summary
--output PATH 写入 JSON 或 summary;默认 stdout
--markdown-output PATH 额外写入 Markdown 摘要
--watch-history-output PATH 追加写入 JSONL 观察历史
--max-active-gui-smoke N 允许的 GUI smoke / deep flow owner 数,默认 0
--max-cargo-or-rust N 允许的 Cargo / Rust 构建进程数,默认 0
--max-qcloop-related N 允许的 qcloop 相关进程数,默认 0
--stale-minutes N 标记 active GUI owner stale 的分钟数,默认 30
--check owner 超过上限时非 0 退出
-h, --help 显示帮助
`);
@@ -118,12 +105,14 @@ function parseUnixPsLine(line) {
if (!match) {
return null;
}
const etime = match[5];
return {
pid: Number(match[1]),
ppid: Number(match[2]),
pgid: Number(match[3]),
stat: match[4],
etime: match[5],
etime,
durationSeconds: parseEtimeSeconds(etime),
command: sanitizeProcessCommand(match[6].trim()),
};
}
@@ -158,97 +147,28 @@ function collectWindowsProcesses() {
pgid: null,
stat: "unknown",
etime: "unknown",
durationSeconds: null,
command: sanitizeProcessCommand(String(entry?.CommandLine || "").trim()),
}))
.filter((entry) => entry.pid > 0 && entry.pid !== process.pid && entry.command.length > 0);
}
function sanitizeProcessCommand(command) {
return String(command || "")
.replace(/(--api-key(?:=|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1<redacted>")
.replace(/(api[_-]?key(?:=|:|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1<redacted>")
.replace(/ctx7sk-[A-Za-z0-9-]+/g, "ctx7sk-***")
.replace(/sk-[A-Za-z0-9_-]{12,}/g, "sk-***");
}
function collectProcesses() {
return process.platform === "win32" ? collectWindowsProcesses() : collectUnixProcesses();
}
function commandHasAny(command, patterns) {
const normalized = String(command || "").toLowerCase();
return patterns.some((pattern) => normalized.includes(pattern.toLowerCase()));
}
function uniqueByPid(entries) {
const seen = new Set();
const result = [];
for (const entry of entries) {
if (seen.has(entry.pid)) {
continue;
}
seen.add(entry.pid);
result.push(entry);
}
return result.sort((left, right) => left.pid - right.pid);
}
function createReport({
generatedAt = new Date().toISOString(),
maxActiveGuiSmoke = 0,
maxCargoOrRust = 0,
maxQcloopRelated = 0,
} = {}) {
const processes = collectProcesses();
const activeGuiSmokeProcesses = uniqueByPid(
processes.filter((entry) => commandHasAny(entry.command, GUI_OWNER_PATTERNS)),
);
const qcloopProcesses = uniqueByPid(
processes.filter((entry) => commandHasAny(entry.command, QCLOOP_OWNER_PATTERNS)),
);
const cargoProcesses = uniqueByPid(
processes.filter((entry) => commandHasAny(entry.command, CARGO_OWNER_PATTERNS)),
);
const counts = {
activeGuiSmoke: activeGuiSmokeProcesses.length,
cargoOrRust: cargoProcesses.length,
qcloopRelated: qcloopProcesses.length,
};
const busy =
counts.activeGuiSmoke > maxActiveGuiSmoke ||
counts.cargoOrRust > maxCargoOrRust ||
counts.qcloopRelated > maxQcloopRelated;
return {
schemaVersion: "v1",
generatedAt,
function createReport(options = {}) {
return createAgentQcProcessOwnerReport(collectProcesses(), {
...options,
platform: process.platform,
maxActiveGuiSmoke,
maxCargoOrRust,
maxQcloopRelated,
verdict: {
status: busy ? "busy" : "pass",
summary: `activeGuiSmoke=${counts.activeGuiSmoke}, cargoOrRust=${counts.cargoOrRust}, qcloopRelated=${counts.qcloopRelated}`,
nextAction: busy
? "Do not start full verify:local or another GUI P0 batch while these owners are active; continue read-only observation or wait for natural completion."
: "No active raw GUI smoke, Cargo/Rust, or qcloop owner was observed; heavy gates may run if qcloop GUI owner and release evidence gates are also clear.",
},
activeGuiSmokeProcesses,
qcloopProcesses,
cargoProcesses,
guardrails: [
"best-effort process snapshot only",
"do not kill or restart listed processes from this sidecar",
"use this sidecar to decide whether heavy GUI or verify gates should wait",
],
};
});
}
function renderSummary(report) {
const lines = [
`status=${report.verdict.status}`,
`summary=${report.verdict.summary}`,
`ownerIntervention=${report.ownerIntervention.status}`,
`generatedAt=${report.generatedAt}`,
`platform=${report.platform}`,
];
@@ -263,15 +183,37 @@ function renderMarkdown(report) {
`- Status: ${report.verdict.status}`,
`- Summary: ${report.verdict.summary}`,
`- Next action: ${report.verdict.nextAction}`,
`- Owner intervention: ${report.ownerIntervention.status}`,
"",
"## Active GUI smoke / deep flow processes",
"",
];
appendProcessList(lines, report.activeGuiSmokeProcesses);
lines.push("", "## Stale active GUI smoke candidates", "");
appendProcessList(lines, report.staleActiveGuiSmokeProcesses);
lines.push("", "## qcloop related processes", "");
appendProcessList(lines, report.qcloopProcesses);
lines.push("", "## Cargo / Rust processes", "");
appendProcessList(lines, report.cargoProcesses);
lines.push("", "## Passive qcloop serve processes", "");
appendProcessList(lines, report.passiveQcloopServerProcesses);
lines.push("", "## Passive Tauri dev runtime processes", "");
appendProcessList(lines, report.passiveTauriRuntimeProcesses);
lines.push("", "## Observer processes", "");
appendProcessList(lines, report.observerProcesses);
if (report.ownerIntervention.status === "requires_owner_confirmation") {
lines.push(
"",
"## Owner intervention",
"",
`- Required confirmation: ${report.ownerIntervention.requiredConfirmationText}`,
`- Next action: ${report.ownerIntervention.nextAction}`,
"- Prohibited until confirmed:",
);
for (const item of report.ownerIntervention.prohibitedUntilConfirmed) {
lines.push(` - ${item}`);
}
}
lines.push("", "## Guardrails", "");
for (const guardrail of report.guardrails) {
lines.push(`- ${guardrail}`);
@@ -301,6 +243,46 @@ function writeOutput(outputPath, content) {
fs.writeFileSync(resolvedOutputPath, content, "utf8");
}
function createWatchHistoryEntry(report) {
return {
schemaVersion: "v1",
generatedAt: report.generatedAt,
verdictStatus: report.verdict.status,
summary: report.verdict.summary,
ownerInterventionStatus: report.ownerIntervention.status,
ownerInterventionProcessIds: report.ownerIntervention.processIds,
activeGuiSmokeCount: report.activeGuiSmokeProcesses.length,
staleActiveGuiSmokeCount: report.staleActiveGuiSmokeProcesses.length,
qcloopRelatedCount: report.qcloopProcesses.length,
cargoOrRustCount: report.cargoProcesses.length,
passiveQcloopServerCount: report.passiveQcloopServerProcesses.length,
passiveTauriRuntimeCount: report.passiveTauriRuntimeProcesses.length,
observerCount: report.observerProcesses.length,
activeGuiSmokeProcesses: report.activeGuiSmokeProcesses.map((entry) => ({
pid: entry.pid,
ppid: entry.ppid,
pgid: entry.pgid,
stat: entry.stat,
etime: entry.etime,
durationSeconds: entry.durationSeconds,
command: entry.command,
})),
};
}
function appendWatchHistory(outputPath, report) {
if (!outputPath) {
return;
}
const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
fs.appendFileSync(
resolvedOutputPath,
`${JSON.stringify(createWatchHistoryEntry(report))}\n`,
"utf8",
);
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
@@ -317,6 +299,7 @@ function main() {
if (options.markdownOutputPath) {
writeOutput(options.markdownOutputPath, renderMarkdown(report));
}
appendWatchHistory(options.watchHistoryOutputPath, report);
if (options.check && report.verdict.status !== "pass") {
process.exit(1);
+311 -21
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
@@ -9,6 +10,12 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, "..");
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const runtimeTranscriptPath = path.join(
rootDir,
".lime",
"qc",
"skill-forge-runtime-transcript-current.json",
);
function runVitest(label, args) {
console.log(`\n[smoke:agent-service-skill-entry] > ${label}`);
@@ -35,14 +42,30 @@ function runVitest(label, args) {
}
}
function runCargoTest(label, args) {
function runCargoTest(label, { packageName, testName }) {
console.log(`\n[smoke:agent-service-skill-entry] > ${label}`);
const result = spawnSync("cargo", ["test", ...args], {
const result = spawnSync("cargo", [
"test",
"--manifest-path",
"Cargo.toml",
"-p",
packageName,
testName,
"--",
"--exact",
], {
cwd: path.join(rootDir, "src-tauri"),
stdio: "inherit",
encoding: "utf8",
env: process.env,
});
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
if (result.error) {
throw result.error;
}
@@ -54,6 +77,246 @@ function runCargoTest(label, args) {
error.exitCode = result.status;
throw error;
}
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
const passedCount = Array.from(
output.matchAll(/test result: ok\. ([0-9]+) passed/g),
).reduce((total, match) => total + Number(match[1] || 0), 0);
if (passedCount <= 0) {
const error = new Error(
`[smoke:agent-service-skill-entry] ${label} 未实际运行目标 Rust 测试`,
);
error.exitCode = 1;
throw error;
}
return {
packageName,
testName,
passedCount,
};
}
function runtimePhaseForTest(testName) {
if (testName.includes("register_capability_draft")) {
return "capability_draft_registration";
}
if (testName.includes("registered_skill_becomes_ready")) {
return "runtime_binding_projection";
}
if (testName.includes("explicit_runtime_enable")) {
return "manual_session_runtime_enable";
}
if (testName.includes("registered_skill_without_verification")) {
return "provenance_gate_negative";
}
if (testName.includes("controlled_get")) {
return "readonly_execution_evidence";
}
if (testName.includes("workspace_skill_runtime_enable_as_callable_scope")) {
return "query_loop_prompt_projection";
}
if (testName.includes("allowlisted_session")) {
return "skill_tool_gate_allow";
}
if (testName.includes("disabled_session")) {
return "skill_tool_gate_deny";
}
return "skill_forge_runtime_gate";
}
function skillToolGateEvidenceForPhase(phase) {
if (phase === "skill_tool_gate_allow") {
const sourceMetadata = {
workspaceRoot: "<deterministic-test-workspace>",
source: "manual_session_enable",
approval: "manual",
authorizationScope: "session",
directory: "capability-report",
registeredSkillDirectory:
"<deterministic-test-workspace>/.agents/skills/capability-report",
skillName: "project:capability-report",
sourceDraftId: "capdraft-1",
sourceVerificationReportId: "capver-1",
permissionSummary: ["Level 0 只读发现"],
};
return {
request: {
toolName: "SkillTool",
sessionId: "skill-source-session",
skill: "capability-report",
authorizationScope: "session",
},
decision: {
action: "allow",
gate: "session_allowlist",
enabled: true,
allowlisted: true,
reason: "workspace_skill_runtime_enable_allowlist_matched",
},
result: {
status: "passed",
permissionBehavior: "Allow",
sourceMetadataAttached: true,
workspaceSkillRuntimeEnableAttached: true,
},
sourceMetadata,
};
}
if (phase === "skill_tool_gate_deny") {
return {
request: {
toolName: "SkillTool",
sessionId: "skill-execute-disabled-session",
skill: "research",
authorizationScope: "session",
},
decision: {
action: "deny",
gate: "session_enable_required",
enabled: false,
allowlisted: false,
reason: "skill_tool_session_not_enabled",
},
result: {
status: "passed",
errorClass: "execution_failed",
errorMessageContains: "未启用技能自动调用",
},
sourceMetadata: null,
};
}
return {};
}
function assertRuntimeTranscriptHasSkillToolGateEvidence(transcript) {
const events = transcript?.runtimeTranscript?.events;
if (!Array.isArray(events)) {
throw new Error("[smoke:agent-service-skill-entry] runtime transcript 缺少 events");
}
const allowEvent = events.find((event) => event.phase === "skill_tool_gate_allow");
const denyEvent = events.find((event) => event.phase === "skill_tool_gate_deny");
const hasGateEvidence = (event) =>
event?.request?.toolName === "SkillTool" &&
typeof event?.request?.skill === "string" &&
typeof event?.decision?.action === "string" &&
typeof event?.decision?.gate === "string" &&
typeof event?.result?.status === "string";
if (!hasGateEvidence(allowEvent)) {
throw new Error(
"[smoke:agent-service-skill-entry] runtime transcript 缺少 SkillTool allow request/decision/result",
);
}
if (!hasGateEvidence(denyEvent)) {
throw new Error(
"[smoke:agent-service-skill-entry] runtime transcript 缺少 SkillTool deny request/decision/result",
);
}
if (
allowEvent.sourceMetadata?.authorizationScope !== "session" ||
allowEvent.sourceMetadata?.sourceDraftId !== "capdraft-1" ||
allowEvent.sourceMetadata?.sourceVerificationReportId !== "capver-1"
) {
throw new Error(
"[smoke:agent-service-skill-entry] runtime transcript 缺少 SkillTool source metadata",
);
}
}
function buildSkillToolGateProof(transcript) {
const events = transcript.runtimeTranscript.events;
const allowEvent = events.find((event) => event.phase === "skill_tool_gate_allow");
const denyEvent = events.find((event) => event.phase === "skill_tool_gate_deny");
return {
allow: {
phase: allowEvent.phase,
hasRequest: Boolean(allowEvent.request?.toolName && allowEvent.request?.skill),
hasDecision: Boolean(allowEvent.decision?.action && allowEvent.decision?.gate),
hasResult: Boolean(allowEvent.result?.status),
hasSourceMetadata: Boolean(allowEvent.sourceMetadata?.sourceDraftId),
request: allowEvent.request,
decision: allowEvent.decision,
result: allowEvent.result,
},
deny: {
phase: denyEvent.phase,
hasRequest: Boolean(denyEvent.request?.toolName && denyEvent.request?.skill),
hasDecision: Boolean(denyEvent.decision?.action && denyEvent.decision?.gate),
hasResult: Boolean(denyEvent.result?.status),
hasSourceMetadata: Boolean(denyEvent.sourceMetadata),
request: denyEvent.request,
decision: denyEvent.decision,
result: denyEvent.result,
},
summary:
"SkillTool allow/deny events both contain request, decision and result; allow event also carries redacted source metadata.",
};
}
function writeRuntimeTranscript(cargoResults) {
fs.mkdirSync(path.dirname(runtimeTranscriptPath), { recursive: true });
const transcript = {
schemaVersion: "v1",
scenarioId: "skill-forge-register-bind-enable",
generatedAt: new Date().toISOString(),
result: "pass",
evidenceLayersCovered: ["deterministic-smoke", "runtime-transcript"],
runtimeTranscript: {
kind: "skill_tool_gate_transcript",
scope:
"deterministic Rust harness; covers SkillTool request/decision/result and source metadata without invoking a live model provider",
events: cargoResults.map((result, index) => {
const phase = runtimePhaseForTest(result.testName);
return {
order: index + 1,
phase,
command: `cargo test --manifest-path Cargo.toml -p ${result.packageName} ${result.testName} -- --exact`,
status: "passed",
passedCount: result.passedCount,
...skillToolGateEvidenceForPhase(phase),
};
}),
},
evidenceRequired: {
capabilityDraftEvidence:
"capability_draft_service exact tests created, verified, registered, and controlled read-only evidence without persisting sensitive inputs.",
registrationResult:
"registration exact tests persisted readonly HTTP provenance and rejected missing provenance.",
runtimeBindingProjection:
"runtime_skill_binding_service exact tests projected ready_for_manual_enable without query_loop/tool_runtime auto visibility.",
skillToolGateTranscript:
"lime-agent SkillTool gate exact tests covered allowlisted session source metadata and disabled-session denial.",
},
failureModes: {
registeredEqualsExecutable:
"excluded: ready binding remains query_loop_visible=false, tool_runtime_visible=false, launch_enabled=false until manual enable.",
metadataAutoEnablesSkill:
"excluded: workspace_skill_bindings prompt projection is read-only; only workspace_skill_runtime_enable creates session allowlist.",
missingProvenance:
"covered: missing verification provenance is blocked before runtime enable.",
unsafeEndpointLeaked:
"excluded: controlled GET evidence test verifies no endpoint/token/response preview is persisted.",
},
};
assertRuntimeTranscriptHasSkillToolGateEvidence(transcript);
const gateProof = buildSkillToolGateProof(transcript);
transcript.skillToolGateProof = gateProof;
fs.writeFileSync(runtimeTranscriptPath, `${JSON.stringify(transcript, null, 2)}\n`);
console.log(
`[smoke:agent-service-skill-entry] Skill Forge runtime transcript: ${runtimeTranscriptPath}`,
);
console.log(
`[smoke:agent-service-skill-entry] SkillTool gate proof: allow request=${gateProof.allow.hasRequest} decision=${gateProof.allow.hasDecision} result=${gateProof.allow.hasResult} sourceMetadata=${gateProof.allow.hasSourceMetadata}; deny request=${gateProof.deny.hasRequest} decision=${gateProof.deny.hasDecision} result=${gateProof.deny.hasResult}`,
);
console.log(
`[smoke:agent-service-skill-entry] SkillTool gate proof JSON: ${JSON.stringify(gateProof)}`,
);
}
function main() {
@@ -65,24 +328,51 @@ function main() {
"src/features/capability-drafts/workspaceSkillAgentAutomationDraft.test.ts",
]);
[
"register_capability_draft_persists_readonly_http_preflight_provenance",
"registered_skill_becomes_ready_for_manual_enable_binding_candidate",
"explicit_runtime_enable_projects_ready_binding_allowlist",
"registered_skill_without_verification_provenance_is_blocked",
"execute_capability_draft_controlled_get_returns_evidence_without_persisting_inputs",
"should_project_workspace_skill_runtime_enable_as_callable_scope",
"allowlisted_session_should_preserve_workspace_skill_source_metadata",
"disabled_session_should_fail_execute",
].forEach((testName) => {
runCargoTest(`Skill Forge Rust 定向测试: ${testName}`, [
"--manifest-path",
"Cargo.toml",
testName,
"--",
"--exact",
]);
});
const cargoResults = [
{
packageName: "lime",
testName:
"services::capability_draft_service::tests::register_capability_draft_persists_readonly_http_preflight_provenance",
},
{
packageName: "lime",
testName:
"services::runtime_skill_binding_service::tests::registered_skill_becomes_ready_for_manual_enable_binding_candidate",
},
{
packageName: "lime",
testName:
"services::runtime_skill_binding_service::tests::explicit_runtime_enable_projects_ready_binding_allowlist",
},
{
packageName: "lime",
testName:
"services::runtime_skill_binding_service::tests::registered_skill_without_verification_provenance_is_blocked",
},
{
packageName: "lime",
testName:
"services::capability_draft_service::tests::execute_capability_draft_controlled_get_returns_evidence_without_persisting_inputs",
},
{
packageName: "lime",
testName:
"commands::aster_agent_cmd::workspace_skill_binding_prompt::tests::should_project_workspace_skill_runtime_enable_as_callable_scope",
},
{
packageName: "lime-agent",
testName:
"tools::skill_tool_gate::tests::allowlisted_session_should_preserve_workspace_skill_source_metadata",
},
{
packageName: "lime-agent",
testName:
"tools::skill_tool_gate::tests::disabled_session_should_fail_execute",
},
].map((testSpec) =>
runCargoTest(`Skill Forge Rust 定向测试: ${testSpec.testName}`, testSpec),
);
writeRuntimeTranscript(cargoResults);
runVitest("服务技能入口路由与挂起参数", [
"src/components/skills/SkillsWorkspacePage.test.tsx",
+293 -47
View File
@@ -43,6 +43,8 @@ const LONG_PROMPT = [
const RECOVERY_EXPECTED_TEXT = "复原完成";
const RECOVERY_PROMPT =
"停止后恢复测试:这是一个新的独立回合,请忽略上一条输出 160 行的要求。只输出“复原完成”这四个字,不要输出行号、解释或其他内容。";
const MODEL_AVAILABILITY_PROMPT = "请只回复 QC_OK。";
const MAX_MODEL_AVAILABILITY_CANDIDATES = 12;
const FAST_RESPONSE_MODE_STORAGE_KEY = "lime:agent-fast-response-mode";
const TASK_CENTER_OPEN_TASK_EVENT = "lime:task-center:open-task";
@@ -268,10 +270,50 @@ function normalizeProviderId(provider) {
return String(provider?.id || provider?.provider_id || provider?.providerId || "").trim();
}
function providerMatchesId(provider, providerId) {
const normalizedProviderId = normalizeProviderId(provider);
return (
normalizedProviderId === providerId ||
provider?.provider_name === providerId ||
provider?.providerName === providerId ||
provider?.name === providerId
);
}
function providerEnabled(provider) {
return provider?.enabled !== false;
}
function providerHasCredential(provider) {
const count = Number(provider?.api_key_count ?? provider?.apiKeyCount);
if (Number.isFinite(count)) {
return count > 0;
}
if (typeof provider?.has_api_key === "boolean") {
return provider.has_api_key;
}
if (typeof provider?.hasApiKey === "boolean") {
return provider.hasApiKey;
}
return true;
}
function providerReadyForLiveRuntime(provider) {
return providerEnabled(provider) && providerHasCredential(provider);
}
function modelLooksChatCapable(modelName) {
const normalized = String(modelName || "").trim().toLowerCase();
if (!normalized) {
return false;
}
return !/(image|images|banana|embed|embedding|tts|transcrib|audio|video|bfl|flux|stable|kling|wan)/i.test(
normalized,
);
}
function pickModelPreference(provider) {
const candidates = [
provider?.default_model,
@@ -288,32 +330,126 @@ function pickModelPreference(provider) {
.filter(Boolean);
return (
candidates.find((value) => /flash|mini|lite/i.test(value)) ||
candidates[0] ||
candidates.find((value) => /flash|mini|lite/i.test(value) && modelLooksChatCapable(value)) ||
candidates.find((value) => modelLooksChatCapable(value)) ||
""
);
}
function findProviderById(providers, providerId) {
const normalizedProviderId = String(providerId || "").trim();
if (!normalizedProviderId) {
return null;
}
return (
providers.find((provider) => providerMatchesId(provider, normalizedProviderId)) ||
null
);
}
function pickProvider(providers, preferredProviderId) {
const ready = providers.filter((provider) => providerReadyForLiveRuntime(provider));
const enabled = providers.filter((provider) => providerEnabled(provider));
if (preferredProviderId) {
return (
enabled.find((provider) => normalizeProviderId(provider) === preferredProviderId) ||
enabled.find((provider) => provider?.provider_name === preferredProviderId) ||
providers.find((provider) => normalizeProviderId(provider) === preferredProviderId) ||
providers.find((provider) => provider?.provider_name === preferredProviderId) ||
ready.find((provider) => providerMatchesId(provider, preferredProviderId)) ||
enabled.find((provider) => providerMatchesId(provider, preferredProviderId)) ||
providers.find((provider) => providerMatchesId(provider, preferredProviderId)) ||
null
);
}
for (const providerId of ["deepseek", "doubao", "lime-hub"]) {
const match = enabled.find((provider) => normalizeProviderId(provider) === providerId);
const match = ready.find((provider) => normalizeProviderId(provider) === providerId);
if (match) {
return match;
}
}
return enabled[0] || null;
return ready[0] || enabled[0] || null;
}
function candidateRank(candidate) {
const providerId = candidate.providerPreference.toLowerCase();
const model = candidate.modelPreference.toLowerCase();
if (candidate.source === "agent-status") {
return 0;
}
if (/flash|lite/.test(model) && !/deepseek/.test(model)) {
return 1;
}
if (/mini/.test(model) && !/deepseek/.test(model)) {
return 2;
}
if (/deepseek/.test(model)) {
return 4;
}
if (["deepseek", "siliconflow-cn", "lime-hub"].includes(providerId)) {
return 5;
}
return 3;
}
function uniqueProviderCandidates(candidates) {
const seen = new Set();
return candidates.filter((candidate) => {
const key = `${candidate.providerPreference}::${candidate.modelPreference}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
async function buildProviderCandidate(options, provider, modelPreference, source) {
const providerId = normalizeProviderId(provider) || String(provider?.provider_name || "").trim();
if (!providerId) {
return null;
}
let providerDetail = provider;
try {
providerDetail =
(await invoke(options, "get_api_key_provider", { id: providerId }, 30_000)) ||
provider;
} catch (error) {
console.warn(
`[smoke:claw-chat-ready-streaming] 读取 provider 详情失败,使用列表摘要继续: ${error.message}`,
);
}
const model = modelPreference || pickModelPreference(providerDetail);
if (!model || !modelLooksChatCapable(model)) {
return null;
}
return {
providerPreference: providerId,
modelPreference: model,
source,
};
}
async function verifyProviderCandidate(options, candidate) {
const result = await invoke(
options,
"test_api_key_provider_chat",
{
providerId: candidate.providerPreference,
modelName: candidate.modelPreference,
prompt: MODEL_AVAILABILITY_PROMPT,
},
60_000,
);
return Boolean(result?.success);
}
function sanitizeProbeError(error) {
const message = error instanceof Error ? error.message : String(error || "unknown");
return message.replace(/\s+/g, " ").slice(0, 160);
}
async function resolveProviderPreference(options, agentStatus, providers) {
@@ -331,48 +467,105 @@ async function resolveProviderPreference(options, agentStatus, providers) {
agentStatus?.provider_selector || agentStatus?.provider_name || "",
).trim();
const statusModel = String(agentStatus?.model_name || "").trim();
if (!explicitProvider && !explicitModel && statusProvider && statusModel) {
return {
providerPreference: statusProvider,
modelPreference: statusModel,
source: "agent-status",
};
const providerListAvailable = Array.isArray(providers) && providers.length > 0;
const statusProviderRecord = providerListAvailable
? findProviderById(providers, statusProvider)
: null;
const statusProviderReady =
statusProvider &&
statusModel &&
(!providerListAvailable ||
(statusProviderRecord && providerReadyForLiveRuntime(statusProviderRecord)));
const providerList = Array.isArray(providers) ? providers : [];
const selected = pickProvider(providerList, explicitProvider || "");
if (explicitProvider || explicitModel) {
if (!selected) {
throw new Error(
"[smoke:claw-chat-ready-streaming] 未找到可用 provider;请传 --provider-preference / --model-preference 或先配置本地 provider",
);
}
const selectedMatchesStatusProvider =
statusProvider && providerMatchesId(selected, statusProvider);
const candidate = await buildProviderCandidate(
options,
selected,
explicitModel || (selectedMatchesStatusProvider ? statusModel : ""),
explicitProvider || explicitModel ? "partial-explicit" : "auto-enabled-provider",
);
if (!candidate) {
const providerId =
normalizeProviderId(selected) || String(selected?.provider_name || "").trim();
throw new Error(
`[smoke:claw-chat-ready-streaming] provider ${providerId} 缺少可用聊天模型;请传 --model-preference`,
);
}
return candidate;
}
const selected = pickProvider(
Array.isArray(providers) ? providers : [],
explicitProvider || statusProvider,
const autoCandidates = [];
if (!explicitProvider && !explicitModel && statusProviderReady) {
const statusCandidate = await buildProviderCandidate(
options,
statusProviderRecord || {
id: statusProvider,
custom_models: [statusModel],
},
statusModel,
"agent-status",
);
if (statusCandidate) {
autoCandidates.push(statusCandidate);
}
}
for (const provider of providerList.filter((item) => providerReadyForLiveRuntime(item))) {
const candidate = await buildProviderCandidate(
options,
provider,
"",
"auto-enabled-provider",
);
if (candidate) {
autoCandidates.push(candidate);
}
}
const candidates = uniqueProviderCandidates(autoCandidates)
.sort((left, right) => candidateRank(left) - candidateRank(right))
.slice(0, MAX_MODEL_AVAILABILITY_CANDIDATES);
if (candidates.length === 0) {
const providerId =
normalizeProviderId(selected) ||
String(selected?.provider_name || statusProvider || "").trim();
throw new Error(
`[smoke:claw-chat-ready-streaming] provider ${providerId} 缺少可用聊天模型;请传 --model-preference`,
);
}
const probeFailures = [];
for (const candidate of candidates) {
try {
if (await verifyProviderCandidate(options, candidate)) {
return {
...candidate,
source: "auto-probed-provider",
};
}
probeFailures.push(`${candidate.providerPreference}/${candidate.modelPreference}: failed`);
} catch (error) {
probeFailures.push(
`${candidate.providerPreference}/${candidate.modelPreference}: ${sanitizeProbeError(
error,
)}`,
);
}
}
throw new Error(
`[smoke:claw-chat-ready-streaming] 未找到可通过短聊天探测的 provider/model;候选失败: ${probeFailures.join(
" | ",
)}`,
);
const providerId = normalizeProviderId(selected) || String(selected?.provider_name || "").trim();
if (!providerId) {
throw new Error(
"[smoke:claw-chat-ready-streaming] 未找到可用 provider;请传 --provider-preference / --model-preference 或先配置本地 provider",
);
}
let providerDetail = selected;
try {
providerDetail =
(await invoke(options, "get_api_key_provider", { id: providerId }, 30_000)) ||
selected;
} catch (error) {
console.warn(
`[smoke:claw-chat-ready-streaming] 读取 provider 详情失败,使用列表摘要继续: ${error.message}`,
);
}
const modelPreference = explicitModel || statusModel || pickModelPreference(providerDetail);
if (!modelPreference) {
throw new Error(
`[smoke:claw-chat-ready-streaming] provider ${providerId} 缺少可用模型;请传 --model-preference`,
);
}
return {
providerPreference: providerId,
modelPreference,
source: explicitProvider || explicitModel ? "partial-explicit" : "auto-enabled-provider",
};
}
function textOfMessage(message) {
@@ -461,6 +654,7 @@ function buildPageSnapshotScript(recoveryExpectedText) {
textareaValue: textarea ? textarea.value : "",
sendDisabled: Boolean(sendButton?.disabled),
stopVisible: Boolean(stopButton),
longPromptVisible: bodyText.includes("E2E 中断测试"),
stoppedMarker: bodyText.includes("用户已停止当前执行"),
recoveryVisible: bodyText.includes(recoveryExpected),
streamLineCount: streamText ? streamText.split("\\n").filter(Boolean).length : 0,
@@ -734,6 +928,7 @@ async function main() {
modelName: agentStatus?.model_name || "",
};
let submittedSessionId = "";
let submittedLongTurnId = "";
try {
const scopedProviderKey = `agent_pref_provider_${workspaceId}`;
@@ -864,10 +1059,32 @@ async function main() {
assert(sessionId, "长 turn 缺少 session_id");
assert(longTurnId, "长 turn 缺少 turn_id");
submittedSessionId = sessionId;
submittedLongTurnId = longTurnId;
summary.sessionId = sessionId;
summary.longTurnId = longTurnId;
summary.longSubmitTurnConfig = longRequest.turn_config || null;
summary.longTurnOpenDispatches = [
await dispatchTaskCenterOpenTask(page, sessionId, workspaceId),
];
summary.longTurnVisibleSnapshot = await waitForCondition(
"等待长 turn 会话挂载",
async () => {
const snapshot = await readPageSnapshot(page);
return snapshot?.stopVisible || snapshot?.streamTextLength > 0
? snapshot
: null;
},
10_000,
250,
).catch((error) => {
summary.longTurnVisibleWait = {
passed: false,
error: error instanceof Error ? error.message : String(error),
};
return null;
});
let lastOpenDispatchAt = Date.now();
const firstDelta = await waitForCondition(
"等待首个流式增量与停止按钮",
async () => {
@@ -875,6 +1092,15 @@ async function main() {
if (!snapshot) {
return null;
}
if (
isLikelyDetachedBlankTaskSnapshot(snapshot) &&
Date.now() - lastOpenDispatchAt >= 2_000
) {
lastOpenDispatchAt = Date.now();
summary.longTurnOpenDispatches.push(
await dispatchTaskCenterOpenTask(page, sessionId, workspaceId),
);
}
const session = await invoke(
options,
"agent_runtime_get_session",
@@ -1311,6 +1537,26 @@ async function main() {
summary.devBridgeCommands = [...new Set(invokes.map((item) => item.cmd))];
if (submittedSessionId) {
summary.failureCleanupInterrupt = await invoke(
options,
"agent_runtime_interrupt_turn",
{
request: {
session_id: submittedSessionId,
...(submittedLongTurnId ? { turn_id: submittedLongTurnId } : {}),
},
},
20_000,
)
.then(() => ({ attempted: true, status: "sent" }))
.catch((interruptError) => ({
attempted: true,
status: "failed",
error:
interruptError instanceof Error
? interruptError.message
: String(interruptError),
}));
const failureSession = await invoke(
options,
"agent_runtime_get_session",
+32 -5
View File
@@ -459,6 +459,25 @@ async function clickPageControl(page, { text, ariaLabel, index = 0 }) {
}
}
async function closeFileManagerIfOpen(page) {
const closeButton = page
.getByRole("button", { name: "关闭文件管理器" })
.first();
if (await closeButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
await closeButton.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
return;
}
const openButton = page
.getByRole("button", { name: "打开左侧文件管理器" })
.first();
if (await openButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
return;
}
await clickPageControl(page, { ariaLabel: "关闭文件管理器" });
}
async function waitForExactButton(page, label, name, timeoutMs) {
try {
await page
@@ -670,10 +689,18 @@ async function verifyBuilderImportAndReview(page, options) {
await page
.getByRole("button", { name: "内容运营", exact: true })
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
await page.getByLabel("资料名称").fill(BUILDER_ACCEPTANCE_PACK.title);
await page
.getByLabel("原始资料正文")
.fill(BUILDER_ACCEPTANCE_PACK.sourceText);
const nameInput = page.getByRole("textbox", { name: "资料名称" });
const sourceTextarea = page.getByRole("textbox", { name: "原始资料正文" });
await nameInput.waitFor({
state: "visible",
timeout: DEFAULT_ACTION_TIMEOUT_MS,
});
await sourceTextarea.waitFor({
state: "visible",
timeout: DEFAULT_ACTION_TIMEOUT_MS,
});
await nameInput.fill(BUILDER_ACCEPTANCE_PACK.title);
await sourceTextarea.fill(BUILDER_ACCEPTANCE_PACK.sourceText);
await waitForPageText(
page,
"Builder 表单填充",
@@ -970,7 +997,7 @@ async function runPlaywrightGuiFlow(options) {
pack.name === FILE_MANAGER_SOURCE_TITLE,
);
await clickPageControl(page, { ariaLabel: "关闭文件管理器" });
await closeFileManagerIfOpen(page);
logStage("open-knowledge-page");
await openKnowledgePageFromMainNav(page, options);
+77 -7
View File
@@ -23,6 +23,51 @@ function summarizeQcloopStatusSidecar(entry) {
].join(" ");
}
function parseSidecarTime(entry) {
const value = entry?.generatedAt || entry?.jobFinishedAt || "";
const time = Date.parse(String(value || ""));
return Number.isFinite(time) ? time : 0;
}
function selectLatestQcloopStatusSidecars(sidecars) {
const latestByJobId = new Map();
const withoutJobId = [];
for (const entry of asArray(sidecars)) {
const jobId = String(entry?.jobId || "").trim();
if (!jobId) {
withoutJobId.push(entry);
continue;
}
const previous = latestByJobId.get(jobId);
if (!previous || parseSidecarTime(entry) >= parseSidecarTime(previous)) {
latestByJobId.set(jobId, entry);
}
}
return [...withoutJobId, ...latestByJobId.values()].sort((left, right) =>
String(left?.path || "").localeCompare(String(right?.path || "")),
);
}
function isUnstartedPendingQcloopStatusSidecar(entry) {
const counts = entry?.counts || {};
const pending = Number(counts.pending || 0);
const running = Number(counts.running || 0);
const stale = Number(counts.stale || 0);
const success = Number(counts.success || 0);
const failed = Number(counts.failed || 0);
const exhausted = Number(counts.exhausted || 0);
const jobStatus = String(entry?.jobStatus || "").toLowerCase();
return (
jobStatus === "pending" &&
pending > 0 &&
running === 0 &&
stale === 0 &&
success === 0 &&
failed === 0 &&
exhausted === 0
);
}
function buildAgentQcCompletionAudit(facts) {
const requiredQcloopScenarioIds = normalizeIdList(facts.scenarioReport?.p0ScenarioIds);
const requiredQcloopScenarioCount =
@@ -38,7 +83,8 @@ function buildAgentQcCompletionAudit(facts) {
const sidecarEvidenceSummary = asArray(facts.realEvidenceSidecars)
.map((entry) => `${entry.path} status=${entry.status || "unknown"} scenarios=${entry.scenarioCount || 0}`)
.join("; ");
const qcloopStatusSidecarSummary = asArray(facts.qcloopStatusSidecars)
const qcloopStatusSidecars = selectLatestQcloopStatusSidecars(facts.qcloopStatusSidecars);
const qcloopStatusSidecarSummary = qcloopStatusSidecars
.map((entry) => {
const counts = entry.counts
? ` success=${entry.counts.success ?? 0} running=${entry.counts.running ?? 0} pending=${entry.counts.pending ?? 0} stale=${entry.counts.stale ?? 0}`
@@ -46,15 +92,16 @@ function buildAgentQcCompletionAudit(facts) {
return `${entry.path} verdict=${entry.verdictStatus || "unknown"}${counts}`;
})
.join("; ");
const staleQcloopStatusSidecars = asArray(facts.qcloopStatusSidecars).filter(
const staleQcloopStatusSidecars = qcloopStatusSidecars.filter(
(entry) => entry.verdictStatus === "stale" || Number(entry.counts?.stale || 0) > 0,
);
const activeQcloopStatusSidecars = asArray(facts.qcloopStatusSidecars).filter(
const activeQcloopStatusSidecars = qcloopStatusSidecars.filter(
(entry) =>
entry.verdictStatus === "running" ||
entry.verdictStatus === "stale" ||
Number(entry.counts?.running || 0) > 0 ||
Number(entry.counts?.pending || 0) > 0,
!isUnstartedPendingQcloopStatusSidecar(entry) &&
(entry.verdictStatus === "running" ||
entry.verdictStatus === "stale" ||
Number(entry.counts?.running || 0) > 0 ||
Number(entry.counts?.pending || 0) > 0),
);
const qcloopEvidenceText = facts.realEvidencePack?.exists
? `.lime/qc/agent-qc-evidence.json status=${facts.realEvidencePack.status || "unknown"} scenarios=${facts.realEvidencePack.scenarioCount || 0}/${requiredQcloopScenarioCount}${missingQcloopScenarioIds.length > 0 ? ` missing=${missingQcloopScenarioIds.join(",")}` : ""}`
@@ -111,6 +158,20 @@ function buildAgentQcCompletionAudit(facts) {
`最近一次 verify:gui-smoke status=${facts.guiSmoke.status}${facts.guiSmoke.error ? `;${facts.guiSmoke.error}` : ""}`,
);
}
const payloadCoverageEvidenceText = facts.payloadCoverage?.coverage
? [
`status=${facts.payloadCoverage.status || "unknown"}`,
`coverage=${facts.payloadCoverage.coverage.passed ? "pass" : "fail"}`,
`repairGuard=${facts.payloadCoverage.repairGuard?.passed ? "pass" : "fail"}`,
`maxQcRounds=${facts.payloadCoverage.repairGuard?.maxQcRounds ?? "unknown"}`,
`maxExecutorRetries=${facts.payloadCoverage.repairGuard?.maxExecutorRetries ?? "unknown"}`,
`manifestP0=${facts.payloadCoverage.coverage.manifestP0Count ?? 0}`,
`payloadItems=${facts.payloadCoverage.coverage.payloadItemCount ?? 0}`,
`missing=${facts.payloadCoverage.coverage.missingScenarioIds?.length ?? 0}`,
`extra=${facts.payloadCoverage.coverage.extraScenarioIds?.length ?? 0}`,
`owner=${facts.payloadCoverage.ownerGate?.status || "unknown"}`,
].join(" ")
: "未发现 .lime/qc/qcloop-p0-single-owner-ready-coverage-current.json";
const items = [
createItem(
"docs-tests-standard",
@@ -150,6 +211,15 @@ function buildAgentQcCompletionAudit(facts) {
`itemCount=${facts.qcloopPayload?.itemCount ?? 0}`,
"qcloop payload 生成器不可用或没有 item。",
),
createItem(
"qcloop-payload-coverage",
"可校验 ready qcloop payload 覆盖 P0 manifest",
facts.files?.payloadCoverageScript &&
facts.payloadCoverage?.coverage?.passed === true &&
facts.payloadCoverage?.repairGuard?.passed === true,
payloadCoverageEvidenceText,
"payload coverage 脚本缺失,ready payload 未完整覆盖 P0 manifest,或发布证据 payload 未禁用 qcloop repair / executor retry。",
),
createItem(
"qcloop-verifier-evidence-placeholders",
"qcloop verifier prompt 带 worker evidence 占位符",
@@ -14,6 +14,7 @@ function completeFacts() {
testsReadme: true,
evidenceSchema: true,
qcloopJobScript: true,
payloadCoverageScript: true,
guiOwnerCheckScript: true,
qcloopStatusScript: true,
qcloopPreflightScript: true,
@@ -55,6 +56,22 @@ function completeFacts() {
verifierRequiresStructuredEvidence: true,
verifierRequiresStrictJson: true,
},
payloadCoverage: {
status: "ready",
coverage: {
passed: true,
manifestP0Count: 1,
payloadItemCount: 1,
missingScenarioIds: [],
extraScenarioIds: [],
},
repairGuard: {
passed: true,
maxQcRounds: 1,
maxExecutorRetries: 0,
},
ownerGate: { status: "pass" },
},
structuredEvidence: {
exporterParsesSummary: true,
releaseSummaryRejectsWeakRefs: true,
@@ -163,6 +180,62 @@ describe("agent-qc-completion-audit-core", () => {
expect(item?.gap).toContain("qcloop-status.fastmini-readonly-p0-v1-current.json");
});
it("未启动 pending-only qcloop sidecar 不应阻断已通过的官方 evidence", () => {
const facts = completeFacts();
facts.qcloopStatusSidecars = [
{
path: ".lime/qc/qcloop-status.old-pending.json",
generatedAt: "2026-05-10T18:28:17.000Z",
jobId: "job-pending",
jobStatus: "pending",
verdictStatus: "running",
counts: { success: 0, failed: 0, exhausted: 0, running: 0, pending: 8, stale: 0 },
},
];
const audit = buildAgentQcCompletionAudit(facts);
const item = audit.items.find((entry) => entry.id === "real-qcloop-evidence");
expect(audit.status).toBe("complete");
expect(item?.evidence).toContain("qcloop-status.old-pending.json");
expect(item?.gap).not.toContain("未终态");
});
it("同一 qcloop job 有更新终态 sidecar 时不应被旧 stale snapshot 阻断", () => {
const facts = completeFacts();
facts.realEvidencePack = {
exists: true,
status: "fail",
scenarioCount: 8,
scenarioIds: ["command-bridge-contract"],
};
facts.qcloopStatusSidecars = [
{
path: ".lime/qc/qcloop-status.pre-intervention-20260511-022817.json",
generatedAt: "2026-05-10T18:28:17.000Z",
jobId: "job-1",
verdictStatus: "stale",
counts: { success: 4, running: 1, pending: 3, stale: 1 },
},
{
path: ".lime/qc/qcloop-status.isolated-p0-full-v1-current.json",
generatedAt: "2026-05-10T18:38:51.000Z",
jobId: "job-1",
verdictStatus: "fail",
counts: { success: 5, running: 0, pending: 0, stale: 0 },
},
];
const audit = buildAgentQcCompletionAudit(facts);
const item = audit.items.find((entry) => entry.id === "real-qcloop-evidence");
expect(audit.status).toBe("incomplete");
expect(item?.evidence).toContain("qcloop-status.isolated-p0-full-v1-current.json");
expect(item?.evidence).not.toContain("qcloop-status.pre-intervention-20260511-022817.json");
expect(item?.gap).not.toContain("仍有 qcloop status sidecar 未终态");
expect(item?.gap).not.toContain("其中 stale sidecar");
});
it("qcloop verifier prompt 缺少 worker evidence 占位符时应保持 incomplete", () => {
const facts = completeFacts();
facts.qcloopPayload.verifierHasWorkerOutput = false;
@@ -175,6 +248,34 @@ describe("agent-qc-completion-audit-core", () => {
);
});
it("ready payload coverage 不完整时应保持 incomplete", () => {
const facts = completeFacts();
facts.payloadCoverage.coverage.passed = false;
facts.payloadCoverage.coverage.missingScenarioIds = ["workspace-ready-session-restore"];
const audit = buildAgentQcCompletionAudit(facts);
expect(audit.status).toBe("incomplete");
expect(audit.gaps.map((gap) => gap.id)).toContain("qcloop-payload-coverage");
});
it("ready payload 未禁用 qcloop repair 时应保持 incomplete", () => {
const facts = completeFacts();
facts.payloadCoverage.repairGuard = {
passed: false,
maxQcRounds: 3,
maxExecutorRetries: 0,
};
const audit = buildAgentQcCompletionAudit(facts);
expect(audit.status).toBe("incomplete");
expect(audit.gaps.map((gap) => gap.id)).toContain("qcloop-payload-coverage");
expect(audit.items.find((item) => item.id === "qcloop-payload-coverage")?.evidence).toContain(
"repairGuard=fail",
);
});
it("结构化 evidence summary 契约缺失时应保持 incomplete", () => {
const facts = completeFacts();
facts.qcloopPayload.workerPromptHasStructuredEvidence = false;
+37
View File
@@ -24,6 +24,7 @@ function summarizeActiveGuiItems(status, guiScenarioIds) {
return asArray(status?.items)
.filter((item) => guiScenarioIdSet.has(normalizeScenarioId(item?.scenarioId)))
.filter((item) => item?.terminal !== true)
.filter((item) => !isUnstartedPendingItem(item))
.map((item) => ({
scenarioId: normalizeScenarioId(item?.scenarioId),
qcloopStatus: item?.qcloopStatus || "unknown",
@@ -35,6 +36,19 @@ function summarizeActiveGuiItems(status, guiScenarioIds) {
}));
}
function isUnstartedPendingItem(item) {
const qcloopStatus = String(item?.qcloopStatus || "").toLowerCase();
const workerStatus = String(item?.worker?.status || "unknown").toLowerCase();
const attemptNo = Number(item?.currentAttemptNo || 0);
const qcNo = Number(item?.currentQcNo || 0);
return (
qcloopStatus === "pending" &&
attemptNo === 0 &&
qcNo === 0 &&
(workerStatus === "unknown" || workerStatus === "")
);
}
function createAgentQcGuiOwnerReport({
manifest,
statusSidecars = [],
@@ -56,6 +70,10 @@ function createAgentQcGuiOwnerReport({
for (const sidecars of sidecarsByJobId.values()) {
const sidecar = selectRepresentativeSidecar(sidecars);
const status = sidecar?.status || sidecar;
if (isUnstartedPendingJob(status)) {
continue;
}
const activeItems = summarizeActiveGuiItems(status, guiScenarioIds);
if (activeItems.length === 0) {
continue;
@@ -129,6 +147,24 @@ function createAgentQcGuiOwnerReport({
};
}
function isUnstartedPendingJob(status) {
const jobStatus = String(status?.job?.status || "").toLowerCase();
if (jobStatus !== "pending") {
return false;
}
const items = asArray(status?.items);
return (
items.length === 0 ||
items.every(
(item) =>
String(item?.qcloopStatus || "").toLowerCase() === "pending" &&
Number(item?.currentAttemptNo || 0) === 0 &&
(item?.worker?.status || "unknown") === "unknown",
)
);
}
function createOwnerIntervention(activeOwners) {
const staleOwners = activeOwners.filter((owner) =>
owner.activeItems.some((item) => item.stale),
@@ -264,5 +300,6 @@ export {
createAgentQcGuiOwnerWatchEntry,
createAgentQcGuiOwnerReport,
createOwnerIntervention,
isUnstartedPendingItem,
renderAgentQcGuiOwnerSummary,
};
@@ -116,6 +116,35 @@ describe("agent-qc-gui-owner-core", () => {
expect(report.ownerCount).toBe(1);
});
it("未启动的 pending job 不应占用 GUI owner", () => {
const report = createAgentQcGuiOwnerReport({
manifest,
statusSidecars: [
{
path: ".lime/qc/qcloop-status.gui-pending.json",
status: {
job: { id: "job-pending", status: "pending", terminal: false },
verdict: { status: "running" },
counts: { pending: 1, running: 0, stale: 0 },
items: [
{
scenarioId: "claw-chat-ready-streaming",
qcloopStatus: "pending",
evidenceStatus: "blocked",
terminal: false,
currentAttemptNo: 0,
worker: { status: "unknown" },
},
],
},
},
],
});
expect(report.verdict.status).toBe("pass");
expect(report.ownerCount).toBe(0);
});
it("同一 running job 应选择 stale 秒数更新的 sidecar", () => {
const createSidecar = (staleSeconds: number, generatedAt: string) => ({
generatedAt,
@@ -0,0 +1,176 @@
function itemById(audit) {
return new Map((audit.items || []).map((item) => [item.id, item]));
}
function allPassed(items, ids) {
return ids.every((id) => items.get(id)?.passed === true);
}
function evidenceFor(items, ids) {
return ids.map((id) => `${id}=${items.get(id)?.passed ? "pass" : "fail"}`).join("; ");
}
function gapFor(items, ids) {
return ids
.map((id) => items.get(id))
.filter((item) => item && !item.passed)
.map((item) => item.gap)
.filter(Boolean)
.join("; ");
}
function hasOwnerProtocolEvidence(items, processOwner) {
return (
items.get("qcloop-status-monitor")?.passed === true &&
items.get("gui-owner-check")?.passed === true &&
(processOwner?.verdict?.status === "pass" ||
processOwner?.ownerIntervention?.status === "requires_owner_confirmation")
);
}
function ownerProtocolStatus(items, processOwner) {
if (!hasOwnerProtocolEvidence(items, processOwner)) {
return "fail";
}
return processOwner?.verdict?.status === "busy" ? "pass_with_blocking_owner" : "pass";
}
function buildAgentQcObjectiveChecklist({
audit,
processOwner,
guiOwner,
generatedAt = new Date().toISOString(),
}) {
const items = itemById(audit);
const manifestIds = ["scenario-manifest", "gui-flow-manifest", "evidence-schema"];
const qcloopToolingIds = [
"qcloop-payload-generator",
"qcloop-payload-coverage",
"qcloop-verifier-evidence-placeholders",
"structured-evidence-contract",
"qcloop-evidence-exporter",
"release-summary",
];
const checklist = [
{
requirement: "在 docs/tests 下增加并维护 Agent/AI 测试标准文档",
artifacts: [
"docs/tests/README.md",
"docs/tests/agent-ops-qc.md",
"docs/tests/ai-agent-testing-guide.md",
"docs/tests/lime-agent-autonomous-testing-plan.md",
],
evidence: items.get("docs-tests-standard")?.evidence || "",
status: items.get("docs-tests-standard")?.passed ? "pass" : "fail",
gap: items.get("docs-tests-standard")?.gap || "",
},
{
requirement: "提供 P0 scenario manifest、GUI flow manifest、Evidence Pack schema",
artifacts: [
"docs/test/agent-qc-scenarios.manifest.json",
"docs/test/agent-qc-gui-flows.manifest.json",
"docs/test/agent-qc-evidence.schema.json",
],
evidence: manifestIds.map((id) => items.get(id)?.evidence).filter(Boolean).join("; "),
status: allPassed(items, manifestIds) ? "pass" : "fail",
gap: gapFor(items, manifestIds),
},
{
requirement: "qcloop payload / verifier / exporter / release summary gate 可生成和审查结构化证据",
artifacts: [
"scripts/agent-qc-qcloop-job.mjs",
"scripts/agent-qc-payload-coverage.mjs",
"scripts/agent-qc-export-evidence.mjs",
"scripts/agent-qc-release-summary.mjs",
"docs/tests/lime-agent-qc-evidence-contract.md",
],
evidence: evidenceFor(items, qcloopToolingIds),
status: allPassed(items, qcloopToolingIds) ? "pass" : "fail",
gap: gapFor(items, qcloopToolingIds),
},
{
requirement: "qcloop / GUI owner / raw process owner 有只读取证与安全处置协议",
artifacts: [
"scripts/agent-qc-qcloop-status.mjs",
"scripts/agent-qc-gui-owner-check.mjs",
"scripts/agent-qc-process-owner-check.mjs",
"scripts/lib/agent-qc-process-owner-core.mjs",
"docs/tests/lime-agent-qc-qcloop-operations.md",
".lime/qc/stale-raw-gui-owner-intervention-request.json",
],
evidence: `guiOwner=${guiOwner?.verdict?.status}; processOwner=${processOwner?.verdict?.status}; ${processOwner?.verdict?.summary || ""}; ownerIntervention=${processOwner?.ownerIntervention?.status}`,
status: ownerProtocolStatus(items, processOwner),
gap:
processOwner?.verdict?.status === "busy"
? "raw process owner 仍 busy,不能启动完整 verify:local 或 full GUI P0。"
: "",
},
{
requirement: "官方 .lime/qc/agent-qc-evidence.json 必须是真实 8/8 P0 pass Evidence Pack",
artifacts: [".lime/qc/agent-qc-evidence.json", "docs/test/agent-qc-scenarios.manifest.json"],
evidence: items.get("real-qcloop-evidence")?.evidence || "",
status: items.get("real-qcloop-evidence")?.passed ? "pass" : "fail",
gap: items.get("real-qcloop-evidence")?.gap || "",
},
{
requirement: "仓库统一门禁 npm run verify:local 必须通过",
artifacts: [".lime/qc/verify-local-current.json", "npm run verify:local"],
evidence: items.get("local-verify-gate")?.evidence || "",
status: items.get("local-verify-gate")?.passed ? "pass" : "fail",
gap: items.get("local-verify-gate")?.gap || "",
},
{
requirement: "在 Lime 仓库内不执行未授权 git commit / push / tag / release",
artifacts: ["git status", "user guardrail"],
evidence: "本轮未执行 git commit / push / tag / release;工作树存在其他并发进程产生的 unrelated 修改,未触碰。",
status: "pass",
gap: "",
},
];
const blockers = checklist
.filter((item) => item.status !== "pass")
.map((item) => ({ requirement: item.requirement, status: item.status, gap: item.gap }));
return {
schemaVersion: "v1",
generatedAt,
objective: "实现 Agent QC / 测试体系整体目标,并以真实证据证明可发布",
status: blockers.length === 0 ? "complete" : "incomplete",
passedCount: checklist.filter((item) => item.status === "pass").length,
totalCount: checklist.length,
blockers,
checklist,
};
}
function renderAgentQcObjectiveChecklistMarkdown(result) {
const lines = [
"# Objective Completion Checklist",
"",
`- Generated at: ${result.generatedAt}`,
`- Status: ${result.status}`,
`- Passed: ${result.passedCount}/${result.totalCount}`,
"",
"## Checklist",
"",
];
for (const item of result.checklist) {
lines.push(
`### ${item.status.toUpperCase()} ${item.requirement}`,
"",
`- Artifacts: ${item.artifacts.join(", ")}`,
`- Evidence: ${item.evidence || "none"}`,
`- Gap: ${item.gap || "none"}`,
"",
);
}
lines.push("## Blockers", "");
if (!result.blockers.length) {
lines.push("- none");
}
for (const blocker of result.blockers) {
lines.push(`- ${blocker.requirement}: ${blocker.gap || blocker.status}`);
}
return `${lines.join("\n")}\n`;
}
export { buildAgentQcObjectiveChecklist, renderAgentQcObjectiveChecklistMarkdown };
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import {
buildAgentQcObjectiveChecklist,
renderAgentQcObjectiveChecklistMarkdown,
} from "./agent-qc-objective-checklist-core.mjs";
const passedIds = [
"docs-tests-standard",
"scenario-manifest",
"gui-flow-manifest",
"evidence-schema",
"qcloop-payload-generator",
"qcloop-payload-coverage",
"qcloop-verifier-evidence-placeholders",
"structured-evidence-contract",
"qcloop-evidence-exporter",
"release-summary",
"qcloop-status-monitor",
"gui-owner-check",
"real-qcloop-evidence",
"local-verify-gate",
];
function auditWith(overrides = {}) {
const overrideById = new Map(Object.entries(overrides));
return {
items: passedIds.map((id) => ({
id,
title: id,
passed: overrideById.get(id)?.passed ?? true,
evidence: overrideById.get(id)?.evidence ?? `${id}-evidence`,
gap: overrideById.get(id)?.gap ?? "",
})),
};
}
function passProcessOwner() {
return {
verdict: { status: "pass", summary: "activeGuiSmoke=0, cargoOrRust=0, qcloopRelated=0" },
ownerIntervention: { status: "not_required" },
};
}
function busyProcessOwner() {
return {
verdict: {
status: "busy",
summary: "activeGuiSmoke=1, cargoOrRust=0, qcloopRelated=0, staleActiveGuiSmoke=1",
},
ownerIntervention: { status: "requires_owner_confirmation", processIds: [59011] },
};
}
const guiOwner = { verdict: { status: "pass" } };
describe("agent-qc-objective-checklist-core", () => {
it("所有目标证据齐全且 owner clear 时应 complete", () => {
const checklist = buildAgentQcObjectiveChecklist({
audit: auditWith(),
processOwner: passProcessOwner(),
guiOwner,
generatedAt: "2026-05-11T00:00:00.000Z",
});
expect(checklist.status).toBe("complete");
expect(checklist.passedCount).toBe(7);
expect(checklist.blockers).toHaveLength(0);
});
it("raw process owner busy 时应保留 pass_with_blocking_owner 并保持 incomplete", () => {
const checklist = buildAgentQcObjectiveChecklist({
audit: auditWith(),
processOwner: busyProcessOwner(),
guiOwner,
});
const ownerItem = checklist.checklist.find((item) => item.requirement.includes("raw process owner"));
expect(checklist.status).toBe("incomplete");
expect(ownerItem?.status).toBe("pass_with_blocking_owner");
expect(ownerItem?.gap).toContain("raw process owner 仍 busy");
});
it("官方 evidence 或 verify:local 失败时应列为 blocker", () => {
const checklist = buildAgentQcObjectiveChecklist({
audit: auditWith({
"real-qcloop-evidence": { passed: false, gap: "official evidence fail" },
"local-verify-gate": { passed: false, gap: "verify local fail" },
}),
processOwner: passProcessOwner(),
guiOwner,
});
expect(checklist.status).toBe("incomplete");
expect(checklist.blockers.map((item) => item.gap)).toContain("official evidence fail");
expect(checklist.blockers.map((item) => item.gap)).toContain("verify local fail");
});
it("应渲染 Markdown", () => {
const checklist = buildAgentQcObjectiveChecklist({
audit: auditWith(),
processOwner: passProcessOwner(),
guiOwner,
});
const markdown = renderAgentQcObjectiveChecklistMarkdown(checklist);
expect(markdown).toContain("Objective Completion Checklist");
expect(markdown).toContain("Status: complete");
});
});
@@ -0,0 +1,172 @@
function scenarioIdOf(value) {
return value?.scenario_id || value?.scenarioId || value?.id || "";
}
function parsePayloadItem(item) {
if (typeof item !== "string") {
return item;
}
return JSON.parse(item);
}
function p0ScenarioIdsFromManifest(manifest) {
return (manifest?.scenarios || [])
.filter((scenario) => scenario?.risk === "P0")
.map(scenarioIdOf)
.filter(Boolean);
}
function scenarioIdsFromPayload(payload) {
return (payload?.items || []).map(parsePayloadItem).map(scenarioIdOf).filter(Boolean);
}
function buildRepairGuard(payload) {
const maxQcRounds = Number(payload?.max_qc_rounds);
const maxExecutorRetries = Number(payload?.max_executor_retries ?? 0);
const promptTemplate = String(payload?.prompt_template || "");
const readOnlyPrompt =
promptTemplate.includes("不得修改源码") &&
promptTemplate.includes("不要顺手修复") &&
promptTemplate.includes("只允许执行场景命令");
const maxQcRoundsPassed = maxQcRounds === 1;
const maxExecutorRetriesPassed = maxExecutorRetries === 0;
return {
passed: maxQcRoundsPassed && maxExecutorRetriesPassed && readOnlyPrompt,
maxQcRounds,
maxExecutorRetries,
maxQcRoundsPassed,
maxExecutorRetriesPassed,
readOnlyPrompt,
};
}
function buildAgentQcPayloadCoverageReport({
manifest,
payload,
processOwner,
generatedAt = new Date().toISOString(),
manifestPath = "docs/test/agent-qc-scenarios.manifest.json",
payloadPath = "",
} = {}) {
const manifestScenarioIds = p0ScenarioIdsFromManifest(manifest);
const payloadScenarioIds = scenarioIdsFromPayload(payload);
const missingScenarioIds = manifestScenarioIds.filter((id) => !payloadScenarioIds.includes(id));
const extraScenarioIds = payloadScenarioIds.filter((id) => !manifestScenarioIds.includes(id));
const orderMatchesManifest =
manifestScenarioIds.length === payloadScenarioIds.length &&
manifestScenarioIds.every((id, index) => id === payloadScenarioIds[index]);
const payloadValidation = payload?._validation || {};
const payloadValidationPassed = payloadValidation.valid !== false;
const repairGuard = buildRepairGuard(payload);
const coveragePassed =
manifestScenarioIds.length > 0 &&
payloadScenarioIds.length === manifestScenarioIds.length &&
missingScenarioIds.length === 0 &&
extraScenarioIds.length === 0 &&
orderMatchesManifest &&
payloadValidationPassed &&
repairGuard.passed;
const ownerStatus = processOwner?.verdict?.status || "unknown";
const status = coveragePassed ? (ownerStatus === "pass" ? "ready" : "blocked") : "fail";
return {
schemaVersion: "v1",
generatedAt,
status,
manifest: manifestPath,
payload: payloadPath,
coverage: {
passed: coveragePassed,
manifestP0Count: manifestScenarioIds.length,
payloadItemCount: payloadScenarioIds.length,
manifestScenarioIds,
payloadScenarioIds,
missingScenarioIds,
extraScenarioIds,
orderMatchesManifest,
payloadValidationPassed,
},
repairGuard,
ownerGate: {
status: ownerStatus,
summary: processOwner?.verdict?.summary || "",
ownerIntervention: processOwner?.ownerIntervention || null,
},
guardrails: {
jobStarted: false,
officialEvidenceOverwritten: false,
qcloopDbModified: false,
gitMutation: false,
},
startPreconditions: [
"npm run agent-qc:process-owner-check -- --check",
"npm run agent-qc:gui-owner-check -- --check",
"npm run agent-qc:qcloop-preflight -- --require-devbridge --check",
],
nextAction:
status === "ready"
? "可以在 dedicated qcloop server / DB / port 上提交该 payload。"
: status === "blocked"
? "payload 覆盖完整,但 owner gate 未通过;只能等待 owner 释放或按确认协议处理。"
: "修复 payload 与 manifest 的覆盖差异后再继续。",
};
}
function renderAgentQcPayloadCoverageMarkdown(report) {
const lines = [
"# qcloop P0 Payload Coverage",
"",
`- Generated at: ${report.generatedAt}`,
`- Status: ${report.status}`,
`- Manifest: ${report.manifest}`,
`- Payload: ${report.payload}`,
`- Manifest P0 count: ${report.coverage.manifestP0Count}`,
`- Payload item count: ${report.coverage.payloadItemCount}`,
`- Missing scenarios: ${report.coverage.missingScenarioIds.join(", ") || "none"}`,
`- Extra scenarios: ${report.coverage.extraScenarioIds.join(", ") || "none"}`,
`- Order matches manifest: ${report.coverage.orderMatchesManifest}`,
`- Payload validation: ${report.coverage.payloadValidationPassed}`,
"",
"## Scenario Coverage",
"",
];
report.coverage.payloadScenarioIds.forEach((id, index) => {
lines.push(`${index + 1}. \`${id}\``);
});
lines.push(
"",
"## Repair Guard",
"",
`- Status: ${report.repairGuard.passed ? "pass" : "fail"}`,
`- max_qc_rounds: ${report.repairGuard.maxQcRounds}`,
`- max_executor_retries: ${report.repairGuard.maxExecutorRetries}`,
`- Read-only prompt: ${report.repairGuard.readOnlyPrompt}`,
"",
);
lines.push(
"",
"## Owner Gate",
"",
`- Status: ${report.ownerGate.status}`,
`- Summary: ${report.ownerGate.summary || "none"}`,
"",
"## Start Preconditions",
"",
);
for (const command of report.startPreconditions) {
lines.push(`- \`${command}\``);
}
lines.push("", "## Guardrails", "");
for (const [key, value] of Object.entries(report.guardrails)) {
lines.push(`- ${key}: ${String(value)}`);
}
lines.push("", "## Next Action", "", report.nextAction, "");
return `${lines.join("\n")}\n`;
}
export {
buildRepairGuard,
buildAgentQcPayloadCoverageReport,
p0ScenarioIdsFromManifest,
renderAgentQcPayloadCoverageMarkdown,
scenarioIdsFromPayload,
};
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
buildRepairGuard,
buildAgentQcPayloadCoverageReport,
p0ScenarioIdsFromManifest,
renderAgentQcPayloadCoverageMarkdown,
scenarioIdsFromPayload,
} from "./agent-qc-payload-coverage-core.mjs";
const manifest = {
scenarios: [
{ id: "command-bridge-contract", risk: "P0" },
{ id: "workspace-ready-session-restore", risk: "P0" },
{ id: "p1-only", risk: "P1" },
],
};
function owner(status: string) {
return {
verdict: { status, summary: `owner=${status}` },
ownerIntervention: status === "busy" ? { status: "requires_owner_confirmation" } : null,
};
}
function payload(items: unknown[], overrides = {}) {
return {
_validation: { valid: true },
max_qc_rounds: 1,
max_executor_retries: 0,
prompt_template: "只允许执行场景命令,不要顺手修复;不得修改源码、配置、文档、锁文件或 git 状态。",
items,
...overrides,
};
}
describe("agent-qc-payload-coverage-core", () => {
it("应从 manifest 提取 P0 场景", () => {
expect(p0ScenarioIdsFromManifest(manifest)).toEqual([
"command-bridge-contract",
"workspace-ready-session-restore",
]);
});
it("应解析 qcloop payload 中的字符串 item", () => {
const payload = {
items: [
JSON.stringify({ scenario_id: "command-bridge-contract" }),
JSON.stringify({ scenario_id: "workspace-ready-session-restore" }),
],
};
expect(scenarioIdsFromPayload(payload)).toEqual([
"command-bridge-contract",
"workspace-ready-session-restore",
]);
});
it("coverage 完整且 owner clear 时应 ready", () => {
const report = buildAgentQcPayloadCoverageReport({
manifest,
payload: payload([
JSON.stringify({ scenario_id: "command-bridge-contract" }),
JSON.stringify({ scenario_id: "workspace-ready-session-restore" }),
]),
processOwner: owner("pass"),
generatedAt: "2026-05-11T00:00:00.000Z",
});
expect(report.status).toBe("ready");
expect(report.coverage.passed).toBe(true);
expect(report.coverage.missingScenarioIds).toEqual([]);
expect(report.coverage.extraScenarioIds).toEqual([]);
});
it("coverage 完整但 owner busy 时应 blocked", () => {
const report = buildAgentQcPayloadCoverageReport({
manifest,
payload: payload([
{ scenario_id: "command-bridge-contract" },
{ scenario_id: "workspace-ready-session-restore" },
]),
processOwner: owner("busy"),
});
expect(report.status).toBe("blocked");
expect(report.ownerGate.ownerIntervention?.status).toBe("requires_owner_confirmation");
});
it("缺少 P0 场景时应 fail 并渲染 Markdown", () => {
const report = buildAgentQcPayloadCoverageReport({
manifest,
payload: payload([{ scenario_id: "command-bridge-contract" }]),
processOwner: owner("pass"),
});
const markdown = renderAgentQcPayloadCoverageMarkdown(report);
expect(report.status).toBe("fail");
expect(report.coverage.missingScenarioIds).toEqual(["workspace-ready-session-restore"]);
expect(markdown).toContain("qcloop P0 Payload Coverage");
expect(markdown).toContain("Missing scenarios");
});
it("应阻断会触发 qcloop repair 的发布证据 payload", () => {
const report = buildAgentQcPayloadCoverageReport({
manifest,
payload: payload(
[
{ scenario_id: "command-bridge-contract" },
{ scenario_id: "workspace-ready-session-restore" },
],
{ max_qc_rounds: 3 },
),
processOwner: owner("pass"),
});
expect(buildRepairGuard(payload([], { max_qc_rounds: 3 })).passed).toBe(false);
expect(report.repairGuard.passed).toBe(false);
expect(report.status).toBe("fail");
expect(report.repairGuard.maxQcRoundsPassed).toBe(false);
expect(renderAgentQcPayloadCoverageMarkdown(report)).toContain("Repair Guard");
});
});
+243
View File
@@ -0,0 +1,243 @@
const GUI_OWNER_PATTERNS = [
"verify:gui-smoke",
"smoke:workspace-ready",
"smoke:browser-runtime",
"smoke:site-adapters",
"smoke:agent-service-skill-entry",
"smoke:agent-runtime-tool-surface",
"smoke:knowledge-gui",
"smoke:design-canvas",
"claw-chat-ready-streaming",
"browser-runtime-site-adapter",
"workspace-ready-session-restore",
"release-package-startup-smoke",
];
const CARGO_OWNER_PATTERNS = [
"cargo ",
"cargo-fmt",
"rustc ",
"clippy-driver",
"tauri dev",
];
const QCLOOP_OWNER_PATTERNS = [
"qcloop --db",
"./qcloop --db",
"qcloop serve",
"qcloop-worker",
"qcloop_worker_result",
"agent qc p0",
"只读执行 lime agent qc p0",
];
const OBSERVER_PROCESS_PATTERNS = [
"while ps -p",
"while ps aux | rg",
"ps -eo pid,ppid,etime,pcpu,pmem,command | rg",
"ps aux | rg",
];
const PASSIVE_QCLOOP_SERVER_PATTERNS = [
"qcloop --db",
"./qcloop --db",
"qcloop serve",
];
const ACTIVE_QCLOOP_WORKER_PATTERNS = [
"qcloop-worker",
"qcloop_worker_result",
"agent qc p0",
"只读执行 lime agent qc p0",
];
const PASSIVE_TAURI_RUNTIME_PATTERNS = ["tauri dev"];
function parseEtimeSeconds(value) {
const text = String(value || "").trim();
if (!text) {
return null;
}
const dayParts = text.split("-");
const dayCount = dayParts.length === 2 ? Number(dayParts[0]) : 0;
const timeText = dayParts.length === 2 ? dayParts[1] : dayParts[0];
const parts = timeText.split(":").map((part) => Number(part));
if (parts.some((part) => !Number.isFinite(part))) {
return null;
}
if (parts.length === 3) {
return dayCount * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2];
}
if (parts.length === 2) {
return dayCount * 86400 + parts[0] * 60 + parts[1];
}
if (parts.length === 1) {
return dayCount * 86400 + parts[0];
}
return null;
}
function sanitizeProcessCommand(command) {
return String(command || "")
.replace(/(--api-key(?:=|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1<redacted>")
.replace(/(api[_-]?key(?:=|:|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1<redacted>")
.replace(/ctx7sk-[A-Za-z0-9-]+/g, "ctx7sk-***")
.replace(/sk-[A-Za-z0-9_-]{12,}/g, "sk-***");
}
function commandHasAny(command, patterns) {
const normalized = String(command || "").toLowerCase();
return patterns.some((pattern) => normalized.includes(pattern.toLowerCase()));
}
function isObserverProcess(entry) {
return commandHasAny(entry?.command, OBSERVER_PROCESS_PATTERNS);
}
function isPassiveQcloopServer(entry) {
return (
commandHasAny(entry?.command, PASSIVE_QCLOOP_SERVER_PATTERNS) &&
!commandHasAny(entry?.command, ACTIVE_QCLOOP_WORKER_PATTERNS)
);
}
function isPassiveTauriRuntime(entry) {
return commandHasAny(entry?.command, PASSIVE_TAURI_RUNTIME_PATTERNS);
}
function uniqueByPid(entries) {
const seen = new Set();
const result = [];
for (const entry of entries) {
if (seen.has(entry.pid)) {
continue;
}
seen.add(entry.pid);
result.push(entry);
}
return result.sort((left, right) => left.pid - right.pid);
}
function normalizeProcessEntry(entry) {
const etime = String(entry?.etime || "unknown");
return {
...entry,
pid: Number(entry?.pid || 0),
ppid: Number(entry?.ppid || 0),
pgid: entry?.pgid ?? null,
stat: entry?.stat || "unknown",
etime,
durationSeconds: entry?.durationSeconds ?? parseEtimeSeconds(etime),
command: sanitizeProcessCommand(entry?.command || ""),
};
}
function createAgentQcProcessOwnerReport(processes, {
generatedAt = new Date().toISOString(),
platform = process.platform,
maxActiveGuiSmoke = 0,
maxCargoOrRust = 0,
maxQcloopRelated = 0,
staleMinutes = 30,
} = {}) {
const normalizedProcesses = processes
.map(normalizeProcessEntry)
.filter((entry) => entry.pid > 0 && entry.command.length > 0);
const observerProcesses = uniqueByPid(normalizedProcesses.filter(isObserverProcess));
const ownerCandidateProcesses = normalizedProcesses.filter((entry) => !isObserverProcess(entry));
const passiveQcloopServerProcesses = uniqueByPid(
ownerCandidateProcesses.filter(isPassiveQcloopServer),
);
const passiveTauriRuntimeProcesses = uniqueByPid(
ownerCandidateProcesses.filter(isPassiveTauriRuntime),
);
const activeGuiSmokeProcesses = uniqueByPid(
ownerCandidateProcesses.filter((entry) => commandHasAny(entry.command, GUI_OWNER_PATTERNS)),
);
const qcloopProcesses = uniqueByPid(
ownerCandidateProcesses.filter(
(entry) => commandHasAny(entry.command, QCLOOP_OWNER_PATTERNS) && !isPassiveQcloopServer(entry),
),
);
const cargoProcesses = uniqueByPid(
ownerCandidateProcesses.filter(
(entry) => commandHasAny(entry.command, CARGO_OWNER_PATTERNS) && !isPassiveTauriRuntime(entry),
),
);
const staleThresholdSeconds = Math.max(0, Number(staleMinutes) || 0) * 60;
const staleActiveGuiSmokeProcesses = uniqueByPid(
activeGuiSmokeProcesses.filter(
(entry) => Number(entry.durationSeconds || 0) >= staleThresholdSeconds,
),
);
const counts = {
activeGuiSmoke: activeGuiSmokeProcesses.length,
cargoOrRust: cargoProcesses.length,
qcloopRelated: qcloopProcesses.length,
staleActiveGuiSmoke: staleActiveGuiSmokeProcesses.length,
passiveQcloopServer: passiveQcloopServerProcesses.length,
passiveTauriRuntime: passiveTauriRuntimeProcesses.length,
observer: observerProcesses.length,
};
const busy =
counts.activeGuiSmoke > maxActiveGuiSmoke ||
counts.cargoOrRust > maxCargoOrRust ||
counts.qcloopRelated > maxQcloopRelated;
return {
schemaVersion: "v1",
generatedAt,
platform,
maxActiveGuiSmoke,
maxCargoOrRust,
maxQcloopRelated,
staleMinutes,
verdict: {
status: busy ? "busy" : "pass",
summary: `activeGuiSmoke=${counts.activeGuiSmoke}, cargoOrRust=${counts.cargoOrRust}, qcloopRelated=${counts.qcloopRelated}, staleActiveGuiSmoke=${counts.staleActiveGuiSmoke}, passiveQcloopServer=${counts.passiveQcloopServer}, passiveTauriRuntime=${counts.passiveTauriRuntime}, observer=${counts.observer}`,
nextAction: busy
? "Do not start full verify:local or another GUI P0 batch while these owners are active; continue read-only observation or wait for natural completion."
: "No active raw GUI smoke, Cargo/Rust, or qcloop owner was observed; heavy gates may run if qcloop GUI owner and release evidence gates are also clear.",
},
ownerIntervention: staleActiveGuiSmokeProcesses.length
? {
status: "requires_owner_confirmation",
processIds: staleActiveGuiSmokeProcesses.map((entry) => entry.pid),
requiredConfirmationText: `确认处理 stale raw GUI owner PID ${staleActiveGuiSmokeProcesses.map((entry) => entry.pid).join(", ")},可以终止这些进程并记录 sidecar。`,
prohibitedUntilConfirmed: [
"kill / pause / interrupt stale raw GUI owner",
"start full verify:local",
"start another full GUI P0 batch",
"overwrite .lime/qc/agent-qc-evidence.json",
"git commit / push / tag / release",
],
nextAction: "等待这些进程自然释放,或由 owner 明确确认后再处理。",
}
: {
status: "not_required",
processIds: [],
requiredConfirmationText: "",
prohibitedUntilConfirmed: [],
nextAction: busy ? "等待 active owner 自然释放。" : "无需 stale raw owner 处置。",
},
activeGuiSmokeProcesses,
qcloopProcesses,
cargoProcesses,
staleActiveGuiSmokeProcesses,
passiveQcloopServerProcesses,
passiveTauriRuntimeProcesses,
observerProcesses,
guardrails: [
"best-effort process snapshot only",
"do not kill or restart listed processes from this sidecar",
"use this sidecar to decide whether heavy GUI or verify gates should wait",
],
};
}
export {
createAgentQcProcessOwnerReport,
parseEtimeSeconds,
sanitizeProcessCommand,
};
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import {
createAgentQcProcessOwnerReport,
parseEtimeSeconds,
sanitizeProcessCommand,
} from "./agent-qc-process-owner-core.mjs";
describe("agent-qc-process-owner-core", () => {
it("解析 ps etime 为秒", () => {
expect(parseEtimeSeconds("07:04:53")).toBe(25493);
expect(parseEtimeSeconds("10:05")).toBe(605);
expect(parseEtimeSeconds("2-01:00:00")).toBe(176400);
expect(parseEtimeSeconds("unknown")).toBeNull();
});
it("脱敏进程命令中的常见密钥", () => {
expect(sanitizeProcessCommand("cmd --api-key sk-1234567890abcdef api_key=ctx7sk-secret-value")).toContain(
"--api-key <redacted>",
);
expect(sanitizeProcessCommand("cmd --api-key sk-1234567890abcdef api_key=ctx7sk-secret-value")).not.toContain(
"sk-1234567890abcdef",
);
});
it("区分 active owner、passive runtime 与 observer", () => {
const report = createAgentQcProcessOwnerReport(
[
{
pid: 10,
ppid: 1,
pgid: 10,
stat: "S",
etime: "07:04:53",
command: "npm run smoke:design-canvas -- --timeout-ms 600000",
},
{
pid: 20,
ppid: 1,
pgid: 20,
stat: "S",
etime: "10:00:00",
command: "./qcloop --db .lime/qc/example.db serve --addr 127.0.0.1:18080 --workers 1",
},
{
pid: 30,
ppid: 1,
pgid: 30,
stat: "S",
etime: "10:00:00",
command: "node node_modules/.bin/tauri dev --config src-tauri/tauri.conf.headless.json",
},
{
pid: 40,
ppid: 1,
pgid: 40,
stat: "S",
etime: "00:01:00",
command: "while ps -p 123 >/dev/null 2>&1; do sleep 10; done; ps aux | rg verify:gui-smoke",
},
],
{ generatedAt: "2026-05-11T00:00:00.000Z", platform: "darwin" },
);
expect(report.verdict.status).toBe("busy");
expect(report.activeGuiSmokeProcesses.map((entry) => entry.pid)).toEqual([10]);
expect(report.staleActiveGuiSmokeProcesses.map((entry) => entry.pid)).toEqual([10]);
expect(report.passiveQcloopServerProcesses.map((entry) => entry.pid)).toEqual([20]);
expect(report.passiveTauriRuntimeProcesses.map((entry) => entry.pid)).toEqual([30]);
expect(report.observerProcesses.map((entry) => entry.pid)).toEqual([40]);
expect(report.qcloopProcesses).toHaveLength(0);
expect(report.cargoProcesses).toHaveLength(0);
expect(report.ownerIntervention.status).toBe("requires_owner_confirmation");
});
it("active qcloop worker 与 Cargo build 仍会阻断", () => {
const report = createAgentQcProcessOwnerReport(
[
{
pid: 50,
ppid: 1,
pgid: 50,
stat: "S",
etime: "00:05:00",
command: "node codex exec 只读执行 Lime Agent QC P0 场景",
},
{
pid: 60,
ppid: 1,
pgid: 60,
stat: "S",
etime: "00:05:00",
command: "cargo test --manifest-path src-tauri/Cargo.toml",
},
],
{ generatedAt: "2026-05-11T00:00:00.000Z", platform: "darwin" },
);
expect(report.verdict.status).toBe("busy");
expect(report.qcloopProcesses.map((entry) => entry.pid)).toEqual([50]);
expect(report.cargoProcesses.map((entry) => entry.pid)).toEqual([60]);
expect(report.ownerIntervention.status).toBe("not_required");
});
});