diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 1d7e0b5f..f2e45a0f 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -242,6 +242,8 @@ D601 Code Queue runner 的长期 skills source of truth 是宿主 `/home/ubuntu/ 执行面 `/health`、`/api/dev-ready` 和 `/api/runtime-preflight` 必须输出同一份只读 skill availability report。稳定字段包括 `source`、`target`、`resolvedPath`、`resolvedPathSource`、`resolution`、`requiredSkills`、`missingSkills`、`sourceSkillCount`、`targetSkillCount`、`degraded`、`degradedReason`、`blocker`、`pathSpelling` 和 `valuesPrinted=false`。`requiredSkills` 至少覆盖 `docs-spec`、`cli-spec`、`frontend-design` 与 `playwright-cli`;如果 source 与 target 都缺失、必需 skill 均不可用、拼写错误路径被配置或拼写错误路径已存在,报告必须显示 `ok=false` 和结构化 `blocker`,不能把 runner 能力缺口伪装成业务任务失败。target symlink 到 approved source 属于可接受的兼容修复,必须显示 `resolvedPathSource=target-symlink` 和有界 skill count。 +`codex pr-preflight --remote` 默认视图必须保留同一份可操作 `skillsContract`,不能只给出模糊的 `skills-target-missing`。稳定字段包括 `source`、`target`、`resolvedPath`、`resolvedPathSource`、`requiredSkills`、`missingSkills`、`sourceSkillCount`、`targetSkillCount`、`sourceMissingSkills`、`targetMissingSkills`、`hostRolloutRequired`、`degradedReason`、`blocker`、`repairHint`、`pathSpelling` 和 `valuesPrinted=false`。当 `/api/runtime-preflight` 的旧 `skills` report 缺少 `resolution` 或 counts,但 `skillsSync` 已证明 approved source 可读且 target 未投影时,CLI 必须从 `skillsSync` 合成同一份 `skillsContract`,并显示 `hostRolloutRequired=true` 与修复指令。`--full` 或 `--raw` 可以展开完整 `skills`、`skillsSync`、tool 和 transport 观测;默认输出仍保持低噪声,只保留指挥官能直接判断和恢复的字段。 + 执行面还必须提供 dry-run skills sync/preflight 合同:稳定入口是 `GET /api/skills-sync?dryRun=1`,CLI 入口是 `bun scripts/cli.ts codex skills-sync --dry-run [--full]`。该合同只描述受控 hostPath 生命周期,不复制文件、不从任意路径静默加载、不重启服务、不 rollout Pod、不读取 Secret。默认输出保持紧凑,必须报告 source、target、expected env/mount、required skill 列表、source/target skill counts、missing source/target skills、permission failure count、plannedActions 和修复指令;逐 skill 细节、完整 permission failure 和原始报告只能通过 `--full` 显式展开。非 dry-run 请求必须失败。 受控生命周期是更新宿主 `/home/ubuntu/.agents/skills` 这一 approved source,然后让生产和 dev Code Queue Pod 通过 manifest 中的 read-only hostPath 挂载读取 `/root/.agents/skills`;provider dev container 还必须通过启动脚本把同一 source bind 到同一 target。在线热更新只能作为临时恢复手段,长期验收必须以 manifest/source-of-truth、runner container bind、dry-run sync contract、结构化 health/preflight 和合同测试为准。需要验证时优先运行: diff --git a/scripts/code-queue-runner-skills-contract-test.ts b/scripts/code-queue-runner-skills-contract-test.ts index b33ef1b6..d2d99596 100644 --- a/scripts/code-queue-runner-skills-contract-test.ts +++ b/scripts/code-queue-runner-skills-contract-test.ts @@ -405,6 +405,12 @@ const skillsPreflightTransport = { const defaultPreflightSummary = asRecord(codexPrPreflightQueryForTest(["--remote"], skillsPreflightTransport), "default preflight summary"); assertCondition(defaultPreflightSummary.failureKind === "runner-skills-blocker", "missing target should classify as runner skills blocker even when source exists", defaultPreflightSummary); assertCondition(asRecord(defaultPreflightSummary.skillsContract, "defaultPreflightSummary.skillsContract").hostRolloutRequired === true, "default preflight should expose host rollout blocker separately", defaultPreflightSummary); +assertCondition(asRecord(defaultPreflightSummary.skillsContract, "defaultPreflightSummary.skillsContract").source === missing.source, "default preflight should expose skills source in the bounded contract", defaultPreflightSummary.skillsContract); +assertCondition(asRecord(defaultPreflightSummary.skillsContract, "defaultPreflightSummary.skillsContract").target === missing.target, "default preflight should expose skills target in the bounded contract", defaultPreflightSummary.skillsContract); +assertCondition(Array.isArray(asRecord(defaultPreflightSummary.skillsContract, "defaultPreflightSummary.skillsContract").requiredSkills), "default preflight should expose requiredSkills in the bounded contract", defaultPreflightSummary.skillsContract); +assertCondition(Array.isArray(asRecord(defaultPreflightSummary.skillsContract, "defaultPreflightSummary.skillsContract").missingSkills), "default preflight should expose missingSkills in the bounded contract", defaultPreflightSummary.skillsContract); +assertCondition(asRecord(defaultPreflightSummary.skillsContract, "defaultPreflightSummary.skillsContract").repairHint !== null, "default preflight should expose repairHint in the bounded contract", defaultPreflightSummary.skillsContract); +assertCondition(asRecord(defaultPreflightSummary.skillsContract, "defaultPreflightSummary.skillsContract").valuesPrinted === false, "default preflight skills contract must declare valuesPrinted=false", defaultPreflightSummary.skillsContract); assertCondition(defaultPreflightSummary.preflight === undefined, "default PR preflight should omit detailed preflight internals", defaultPreflightSummary); assertCondition(asRecord(defaultPreflightSummary.disclosure, "defaultPreflightSummary.disclosure").fullDetailOmitted === true, "default PR preflight should disclose full detail omission", defaultPreflightSummary.disclosure); assertCondition(String(asRecord(defaultPreflightSummary.disclosure, "defaultPreflightSummary.disclosure").expandWith ?? "").includes("--full"), "default PR preflight should point to --full expansion", defaultPreflightSummary.disclosure); @@ -424,6 +430,190 @@ assertCondition(asRecord(preflightSkillsSync.plannedActions, "preflight.skillsSy assertCondition(preflightSkillsSync.valuesPrinted === false, "full preflight skills sync must declare valuesPrinted=false", preflightSkillsSync); assertCondition(!JSON.stringify(preflightSkillsSync).includes(forbiddenPathLiteral), "full preflight must not propagate misspelled path literal"); +const legacyRuntimeSkills = { + ok: false, + runnerUsable: false, + contractOk: false, + path: "/root/.agents/skills", + resolvedPath: "/root/.agents/skills", + resolvedPathSource: null, + resolution: null, + source: "/home/ubuntu/.agents/skills", + target: "/root/.agents/skills", + exists: false, + available: false, + degraded: true, + blocker: "skills-target-missing", + degradedReason: "skills-target-missing", + readonly: false, + skillCount: 0, + requiredSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"], + missingSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"], + valuesPrinted: false, + pathSpelling: { + expectedTarget: "/root/.agents/skills", + forbiddenPathChecked: true, + forbiddenPathExists: false, + forbiddenPathConfigured: false, + forbiddenPathRoles: [], + forbiddenPathMustNotBeUsed: true, + }, + repairHint: "Mount /home/ubuntu/.agents/skills read-only at /root/.agents/skills, set UNIDESK_SKILLS_PATH=/root/.agents/skills, and remove any forbidden skills path spelling.", +}; +const legacyRuntimeSkillsSync = { + ok: false, + degraded: true, + blocker: "skills-target-missing", + checkedAt: "2026-05-23T00:00:00.000Z", + mode: "dry-run", + dryRun: true, + mutation: false, + syncMode: "hostPath-read-only-projection", + source: { + path: "/home/ubuntu/.agents/skills", + approved: true, + exists: true, + directory: true, + readable: true, + writable: true, + readonly: false, + mountPoint: "/home/ubuntu", + symlink: false, + realPath: null, + skillCount: 49, + version: null, + requiredSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"], + missingSkills: [], + error: null, + }, + target: { + path: "/root/.agents/skills", + approved: true, + exists: false, + directory: false, + readable: false, + writable: false, + readonly: false, + mountPoint: "/", + symlink: false, + realPath: null, + skillCount: 0, + version: null, + requiredSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"], + missingSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"], + error: null, + }, + expected: { + source: "/home/ubuntu/.agents/skills", + target: "/root/.agents/skills", + env: "UNIDESK_SKILLS_PATH", + envValue: "/root/.agents/skills", + mount: "/home/ubuntu/.agents/skills mounted read-only to /root/.agents/skills", + requiredSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"], + }, + counts: { + sourceSkills: 49, + targetSkills: 0, + requiredSkills: 4, + missingSourceSkills: 0, + missingTargetSkills: 4, + }, + version: null, + missing: { + sourceSkills: [], + targetSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"], + }, + permissionFailures: [], + pathSpelling: { + expectedTarget: "/root/.agents/skills", + forbiddenPathChecked: true, + forbiddenPathExists: false, + forbiddenPathConfigured: false, + forbiddenPathRoles: [], + forbiddenPathMustNotBeUsed: true, + }, + plannedActions: { + copy: false, + writesSource: false, + writesTarget: false, + restartRequired: false, + readsSecrets: false, + copyFromArbitraryPath: false, + }, + commands: { + dryRun: "bun scripts/cli.ts codex skills-sync --dry-run", + full: "bun scripts/cli.ts codex skills-sync --dry-run --full", + health: "bun scripts/cli.ts microservice health code-queue", + runtimePreflight: "bun scripts/cli.ts codex pr-preflight --remote", + contractTest: "bun scripts/code-queue-runner-skills-contract-test.ts", + }, + valuesPrinted: false, +}; +const legacyRuntimePreflightTransport = { + config: null, + coreFetch: () => ({ + ok: true, + status: 200, + body: { + runtimePreflight: { + ok: false, + checkedAt: "2026-05-23T00:00:00.000Z", + cwd: "/workspace/unidesk", + pid: 601, + skills: legacyRuntimeSkills, + skillsSync: legacyRuntimeSkillsSync, + ports: {}, + pullRequestDelivery: { + ok: true, + checkedAt: "2026-05-23T00:00:00.000Z", + tools: {}, + unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true }, + authBroker: { ok: true, configured: true, source: "auth-broker" }, + credentials: { + ghTokenPresent: true, + githubTokenPresent: false, + ghHostsConfigPresent: false, + gitCredentialsPresent: false, + }, + git: { + insideWorktree: true, + branch: "code-queue/issue-68-runner-skills-lifecycle", + head: "abc1234", + originMaster: "def5678", + remoteOrigin: "git@github.com:pikasTech/unidesk.git", + home: "/root", + homeWritable: true, + knownHostsPresent: true, + privateKeyPresent: true, + }, + githubContext: { + host: "github.com", + apiBaseUrl: "https://api.github.com", + repo: "pikasTech/unidesk", + issueProbeNumber: 68, + }, + egress: { proxy: {} }, + remote: null, + limitations: [], + risks: [], + }, + }, + }, + }), +}; +const legacyDefaultPreflight = asRecord(codexPrPreflightQueryForTest(["--remote"], legacyRuntimePreflightTransport), "legacy default preflight"); +const legacySkillsContract = asRecord(legacyDefaultPreflight.skillsContract, "legacyDefaultPreflight.skillsContract"); +assertCondition(legacyDefaultPreflight.failureKind === "runner-skills-blocker", "legacy runtime shape should still classify missing target as runner skills blocker", legacyDefaultPreflight); +assertCondition(legacySkillsContract.source === "/home/ubuntu/.agents/skills", "legacy runtime shape should expose source path from skillsSync", legacySkillsContract); +assertCondition(legacySkillsContract.target === "/root/.agents/skills", "legacy runtime shape should expose target path from skillsSync", legacySkillsContract); +assertCondition(legacySkillsContract.hostRolloutRequired === true, "legacy runtime shape with source available and target missing should require host rollout", legacySkillsContract); +assertCondition(legacySkillsContract.degradedReason === "skills-target-missing", "legacy runtime shape should keep actionable degraded reason", legacySkillsContract); +assertCondition(Array.isArray(legacySkillsContract.requiredSkills) && legacySkillsContract.requiredSkills.includes("docs-spec"), "legacy runtime shape should expose requiredSkills", legacySkillsContract); +assertCondition(Array.isArray(legacySkillsContract.missingSkills) && legacySkillsContract.missingSkills.includes("docs-spec"), "legacy runtime shape should expose missingSkills", legacySkillsContract); +assertCondition(legacySkillsContract.sourceSkillCount === 49 && legacySkillsContract.targetSkillCount === 0, "legacy runtime shape should expose source/target skill counts", legacySkillsContract); +assertCondition(legacySkillsContract.repairHint !== null, "legacy runtime shape should expose repairHint", legacySkillsContract); +assertCondition(legacySkillsContract.valuesPrinted === false, "legacy runtime shape contract must declare valuesPrinted=false", legacySkillsContract); + const typoPreflightTransport = { config: null, coreFetch: () => ({ diff --git a/scripts/src/code-queue.ts b/scripts/src/code-queue.ts index 73f76420..c603b1b0 100644 --- a/scripts/src/code-queue.ts +++ b/scripts/src/code-queue.ts @@ -6382,6 +6382,118 @@ function compactSkillsSyncStatus(value: unknown, full = false): Record String(item ?? "")).filter((item) => item.length > 0))); +} + +function firstStringField(...values: unknown[]): string | null { + for (const value of values) { + if (typeof value === "string" && value.length > 0) return value; + } + return null; +} + +function firstNumberField(...values: unknown[]): number | null { + for (const value of values) { + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return null; +} + +function mergeStringArrays(...values: unknown[]): string[] { + return Array.from(new Set(values.flatMap((value) => compactStringArray(value)))); +} + +function compactPrPreflightSkillsContract( + skills: Record | null, + skillsSync: Record | null, + existing: Record | null = null, +): Record | null { + if (skills === null && skillsSync === null && existing === null) return null; + const resolution = asRecord(skills?.resolution); + const sourceReport = asRecord(skillsSync?.source); + const targetReport = asRecord(skillsSync?.target); + const expected = asRecord(skillsSync?.expected); + const counts = asRecord(skillsSync?.counts); + const missing = asRecord(skillsSync?.missing); + const existingResolution = asRecord(existing?.resolution); + const source = firstStringField(existing?.source, skills?.source, sourceReport?.path, expected?.source); + const target = firstStringField(existing?.target, skills?.target, targetReport?.path, expected?.target, skills?.path, skills?.resolvedPath); + const resolvedPath = firstStringField(existing?.resolvedPath, skills?.resolvedPath, skills?.path, resolution?.resolvedPath, target); + const sourceMissingSkills = mergeStringArrays(existing?.sourceMissingSkills, skills?.sourceMissingSkills, missing?.sourceSkills, sourceReport?.missingSkills); + const targetMissingSkills = mergeStringArrays(existing?.targetMissingSkills, skills?.targetMissingSkills, missing?.targetSkills, targetReport?.missingSkills); + const requiredSkills = mergeStringArrays(existing?.requiredSkills, skills?.requiredSkills, expected?.requiredSkills, sourceReport?.requiredSkills, targetReport?.requiredSkills); + const missingSkills = mergeStringArrays(existing?.missingSkills, skills?.missingSkills, targetMissingSkills); + const sourceSkillCount = firstNumberField(existing?.sourceSkillCount, skills?.sourceSkillCount, counts?.sourceSkills, sourceReport?.skillCount); + const targetSkillCount = firstNumberField(existing?.targetSkillCount, skills?.targetSkillCount, counts?.targetSkills, targetReport?.skillCount, skills?.skillCount); + const blocker = firstStringField(existing?.degradedReason, existing?.blocker, skills?.degradedReason, skills?.blocker, skillsSync?.blocker); + const contractOk = existing?.ok === true || skills?.contractOk === true || skills?.runnerUsable === true || skills?.ok === true && skillsSync?.ok !== false || skillsSync?.ok === true && skills === null; + const degraded = !contractOk; + const forbiddenPathBlocker = blocker === "forbidden-skills-path-configured" || blocker === "forbidden-skills-path-present"; + const sourceHasRequiredSkills = sourceMissingSkills.length === 0 + && (sourceReport?.exists === true && sourceReport?.readable === true || typeof sourceSkillCount === "number" && sourceSkillCount > 0); + const targetProjectionMissing = !forbiddenPathBlocker + && ( + blocker === "skills-target-missing" + || blocker === "skills-target-not-directory" + || blocker === "skills-target-permission-failed" + || blocker === "skills-target-not-readonly" + || blocker === "required-target-skills-missing" + || targetMissingSkills.length > 0 + || targetReport?.exists === false + || skills?.resolvedPathSource === "missing" + ); + const hostRolloutRequired = existing?.hostRolloutRequired === true + || existingResolution?.hostRolloutRequired === true + || resolution?.hostRolloutRequired === true + || degraded && sourceHasRequiredSkills && targetProjectionMissing; + const resolvedPathSource = firstStringField(existing?.resolvedPathSource, skills?.resolvedPathSource, resolution?.resolvedPathSource) + ?? (targetProjectionMissing ? "missing" : null); + const pathSpelling = compactObjectFields(asRecord(existing?.pathSpelling) ?? asRecord(skills?.pathSpelling) ?? asRecord(skillsSync?.pathSpelling), [ + "expectedTarget", + "forbiddenPathChecked", + "forbiddenPathExists", + "forbiddenPathConfigured", + "forbiddenPathRoles", + "forbiddenPathMustNotBeUsed", + ]); + const repairHint = firstStringField(existing?.repairHint, skills?.repairHint) + ?? (degraded + ? hostRolloutRequired && source !== null && target !== null + ? `Source ${source} has the required skills; restore the read-only ${source} -> ${target} projection before starting runners.` + : source !== null && target !== null + ? `Mount ${source} read-only at ${target}, set UNIDESK_SKILLS_PATH=${target}, and remove any forbidden skills path spelling.` + : "Restore the approved runner skills source/target projection before starting runners." + : null); + return { + ok: contractOk, + degraded, + source, + target, + resolvedPath, + resolvedPathSource, + hostRolloutRequired, + degradedReason: blocker, + blocker, + requiredSkills, + missingSkills, + sourceSkillCount, + targetSkillCount, + sourceMissingSkills, + targetMissingSkills, + sourceHasRequiredSkills, + targetProjected: !targetProjectionMissing && !degraded, + repairHint, + valuesPrinted: false, + ...(pathSpelling === null ? {} : { pathSpelling }), + note: degraded + ? hostRolloutRequired + ? "runner skills source is available, but the read-only target projection is missing and needs controlled host rollout repair" + : "runner skills target projection contract is degraded; use repairHint before starting runners" + : null, + }; +} + function codeQueueDevReady(): unknown { const response = unwrapCodexResponse(coreInternalFetch(codeQueueProxyPath("/api/dev-ready"))); const devReady = asRecord(response.body.devReady) ?? {}; @@ -6903,7 +7015,19 @@ function compactRecommendedActions(record: Record): Record, options: CodexPrPreflightOptions): Record { - if (options.full) return record; + const preflight = asRecord(record.preflight); + const normalizedSkillsContract = compactPrPreflightSkillsContract( + compactSkillsStatus(record.skills ?? preflight?.skills), + compactSkillsSyncStatus(record.skillsSync ?? preflight?.skillsSync, options.full), + asRecord(record.skillsContract) ?? asRecord(preflight?.skillsContract), + ); + if (options.full) { + return normalizedSkillsContract === null ? record : { + ...record, + skillsContract: normalizedSkillsContract, + ...(preflight === null ? {} : { preflight: { ...preflight, skillsContract: normalizedSkillsContract } }), + }; + } const tokenCoverage = prPreflightTokenCoverage(record); const authBroker = prPreflightAuthBroker(record); @@ -6969,7 +7093,7 @@ function compactPrPreflightCommanderView(record: Record, option capabilitySource: authBrokerCapability?.source ?? tokenCoverage?.credentialSource ?? null, }, }, - skillsContract: record.skillsContract ?? undefined, + skillsContract: normalizedSkillsContract ?? undefined, activeRunnerPrCapability: { scope: activeRunnerDevContainer.scope ?? "current-cli-process", tokenCandidatePresent: activeRunnerTokenCandidatePresent, @@ -7329,6 +7453,7 @@ function compactPrRuntimePreflight(preflight: Record, options: const githubTransient = githubTransientEvidence(pull); const skillsBlocked = skills !== null && skills.runnerUsable !== true && skills.ok !== true; const skillsContractDegraded = skills !== null && skills.contractOk !== true; + const skillsContract = compactPrPreflightSkillsContract(skills, skillsSync); const failureKind = !tokenCoverage.ok ? "auth-missing" : skillsBlocked @@ -7375,12 +7500,13 @@ function compactPrRuntimePreflight(preflight: Record, options: }, skills, skillsSync, - skillsContract: { + skillsContract: skillsContract ?? { ok: !skillsContractDegraded, degraded: skillsContractDegraded, hostRolloutRequired: asRecord(skills?.resolution)?.hostRolloutRequired === true, degradedReason: skills?.degradedReason ?? skills?.blocker ?? skillsSync?.blocker ?? null, - note: skillsContractDegraded ? "runner skills are usable through the resolved path, but the read-only target projection still needs host rollout repair" : null, + valuesPrinted: false, + note: skillsContractDegraded ? "runner skills target projection contract is degraded; use repairHint before starting runners" : null, }, tokenCoverage, authBroker: authBrokerNeededStatus(tokenCoverage, authBrokerRuntime, systemGhBinary, unideskGhCli),