diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 67b8383f..6e212274 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -236,9 +236,9 @@ bun scripts/code-queue-pr-preflight-example.ts --repo pikasTech/unidesk --base m ### Runner Skill 可用性 -D601 Code Queue runner 的长期 skills source of truth 是宿主 `/home/ubuntu/.agents/skills`,生产和 dev Code Queue Pod 都必须只读挂载到容器内 `/root/.agents/skills`,并设置 `UNIDESK_SKILLS_PATH=/root/.agents/skills`。不要使用或传播任何拼错的 skills 路径;如果配置的 source 或 target 命中拼写错误路径,health/preflight 必须直接返回 `blocker=forbidden-skills-path-configured`,并在 `pathSpelling.forbiddenPathConfigured=true` 与 `pathSpelling.forbiddenPathRoles` 中标明是 source、target 或两者,而不是降级成普通 missing path。 +D601 Code Queue runner 的长期 skills source of truth 是宿主 `/home/ubuntu/.agents/skills`,生产和 dev Code Queue Pod 都必须只读挂载到容器内 `/root/.agents/skills`,并设置 `UNIDESK_SKILLS_PATH=/root/.agents/skills`。如果 target 暂缺但 approved source 存在且必需 skill 可读,runtime 必须把 `UNIDESK_SKILLS_PATH` 解析为可用 source 传给 runner,并在 health/preflight 中标记 `runnerUsable=true`、`contractOk=false`、`resolvedPathSource=source-fallback`、`degradedReason=skills-target-missing` 与 `resolution.hostRolloutRequired=true`;这表示任务执行能力可用,但 hostPath 投影仍需 host rollout 修复。不要使用或传播任何拼错的 skills 路径;如果配置的 source 或 target 命中拼写错误路径,health/preflight 必须直接返回 `blocker=forbidden-skills-path-configured`,并在 `pathSpelling.forbiddenPathConfigured=true` 与 `pathSpelling.forbiddenPathRoles` 中标明是 source、target 或两者,而不是降级成普通 missing path。 -执行面 `/health`、`/api/dev-ready` 和 `/api/runtime-preflight` 必须输出同一份只读 skill availability report。稳定字段包括 `source`、`target`、`requiredSkills`、`missingSkills`、`degraded`、`blocker`、`pathSpelling` 和 `valuesPrinted=false`。`requiredSkills` 至少覆盖 `docs-spec`、`cli-spec`、`frontend-design` 与 `playwright-cli`;如果目标目录缺失、不是只读挂载、必需 skill 缺失、拼写错误路径被配置或拼写错误路径已存在,报告必须显示 `ok=false` 和结构化 `blocker`,不能把 runner 能力缺口伪装成业务任务失败。 +执行面 `/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。 执行面还必须提供 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 请求必须失败。 diff --git a/scripts/code-queue-runner-skills-contract-test.ts b/scripts/code-queue-runner-skills-contract-test.ts index ee3f0560..04d61cfc 100644 --- a/scripts/code-queue-runner-skills-contract-test.ts +++ b/scripts/code-queue-runner-skills-contract-test.ts @@ -1,4 +1,6 @@ -import { readFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { collectSkillAvailability, collectSkillSyncPreflight } from "../src/components/microservices/code-queue/src/skill-availability"; import { codexPrPreflightQueryForTest } from "./src/code-queue"; import { summarizeMicroserviceObservation } from "./src/microservices"; @@ -18,6 +20,14 @@ function countOccurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } +function createSkillSet(root: string, skills: string[]): void { + for (const skill of skills) { + const dir = join(root, skill); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "SKILL.md"), `---\nname: ${skill}\n---\n# ${skill}\n`); + } +} + const productionManifest = readFileSync("src/components/microservices/k3sctl-adapter/k3s/code-queue.k8s.yaml", "utf8"); const devManifest = readFileSync("src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml", "utf8"); const runtimePreflight = readFileSync("src/components/microservices/code-queue/src/runtime-preflight.ts", "utf8"); @@ -58,6 +68,8 @@ const available = collectSkillAvailability({ }); assertCondition(available.source === "/home/ubuntu/.agents/skills", "skill report must expose source"); assertCondition(available.target === "/home/ubuntu/.agents/skills", "skill report must expose target"); +assertCondition(typeof available.resolvedPath === "string" && available.resolvedPath.length > 0, "skill report must expose resolved path", available); +assertCondition(asRecord(available.resolution, "available.resolution").passesToRunnerEnv === true, "skill report must expose runner env path resolution", available.resolution); assertCondition(Array.isArray(available.requiredSkills) && available.requiredSkills.includes("docs-spec"), "skill report must expose requiredSkills"); assertCondition(Array.isArray(available.missingSkills), "skill report must expose missingSkills"); assertCondition(available.valuesPrinted === false, "skill report must declare valuesPrinted=false"); @@ -65,15 +77,74 @@ assertCondition(asRecord(available.pathSpelling, "pathSpelling").forbiddenPathMu assertCondition(!JSON.stringify(available).includes(forbiddenPathLiteral), "skill report must not propagate misspelled path literal"); assertCondition(!JSON.stringify(available).includes("GH_TOKEN"), "skill report must not include secret environment names unrelated to skills"); +const tmpRoot = mkdtempSync(join(tmpdir(), "unidesk-codequeue-skills-")); +const fixtureSource = join(tmpRoot, "source"); +const fixtureMissingTarget = join(tmpRoot, "target-missing"); +const fixtureSymlinkTarget = join(tmpRoot, "target-symlink"); +const fixtureMissingSource = join(tmpRoot, "source-missing"); +mkdirSync(fixtureSource, { recursive: true }); +createSkillSet(fixtureSource, ["docs-spec", "cli-spec"]); +symlinkSync(fixtureSource, fixtureSymlinkTarget, "dir"); + +const sourceFallback = collectSkillAvailability({ + source: fixtureSource, + target: fixtureMissingTarget, + requiredSkills: ["docs-spec", "cli-spec"], +}); +assertCondition(sourceFallback.ok === true, "source exists target missing should keep runner usable", sourceFallback); +assertCondition(sourceFallback.runnerUsable === true, "source fallback should mark runner usable", sourceFallback); +assertCondition(sourceFallback.contractOk === false, "source fallback should still mark target projection contract degraded", sourceFallback); +assertCondition(sourceFallback.degraded === true, "source fallback should remain degraded for host rollout", sourceFallback); +assertCondition(sourceFallback.blocker === "skills-target-missing", "source fallback should preserve target missing degraded reason", sourceFallback); +assertCondition(sourceFallback.degradedReason === "skills-target-missing", "source fallback should expose bounded degraded reason", sourceFallback); +assertCondition(sourceFallback.resolvedPath === fixtureSource, "source fallback should resolve to source path", sourceFallback); +assertCondition(sourceFallback.resolvedPathSource === "source-fallback", "source fallback should expose resolved path source", sourceFallback); +assertCondition(sourceFallback.skillCount === 2 && sourceFallback.sourceSkillCount === 2 && sourceFallback.targetSkillCount === 0, "source fallback should expose bounded counts", sourceFallback); +assertCondition(asRecord(sourceFallback.resolution, "sourceFallback.resolution").runnerEnvValue === fixtureSource, "source fallback should pass resolved path to runner env", sourceFallback.resolution); +assertCondition(asRecord(sourceFallback.resolution, "sourceFallback.resolution").hostRolloutRequired === true, "source fallback should require host rollout repair", sourceFallback.resolution); + +const symlinkOk = collectSkillAvailability({ + source: fixtureSource, + target: fixtureSymlinkTarget, + requiredSkills: ["docs-spec", "cli-spec"], +}); +assertCondition(symlinkOk.ok === true && symlinkOk.contractOk === true, "target symlink to source should satisfy runner and contract", symlinkOk); +assertCondition(symlinkOk.resolvedPath === fixtureSymlinkTarget, "target symlink should keep target as runner path", symlinkOk); +assertCondition(symlinkOk.resolvedPathSource === "target-symlink", "target symlink should expose target-symlink source", symlinkOk); +assertCondition(symlinkOk.targetSymlink === true, "target symlink should be reported", symlinkOk); +assertCondition(asRecord(symlinkOk.resolution, "symlinkOk.resolution").hostRolloutRequired === false, "target symlink should not require rollout repair", symlinkOk.resolution); + +const missingBoth = collectSkillAvailability({ + source: fixtureMissingSource, + target: fixtureMissingTarget, + requiredSkills: ["docs-spec", "cli-spec"], +}); +assertCondition(missingBoth.ok === false && missingBoth.runnerUsable === false, "missing source and target should fail runner availability", missingBoth); +assertCondition(missingBoth.blocker === "skills-source-and-target-missing", "missing both should expose dedicated blocker", missingBoth); +assertCondition(missingBoth.resolvedPathSource === "missing", "missing both should expose missing resolution", missingBoth); + +const redactionProbe = collectSkillAvailability({ + source: fixtureSource, + target: fixtureMissingTarget, + requiredSkills: ["docs-spec", "cli-spec"], +}); +assertCondition(!JSON.stringify(redactionProbe).includes("ghp_"), "skill report must not include token-like values", redactionProbe); +assertCondition(!JSON.stringify(redactionProbe).includes("github_pat_"), "skill report must not include GitHub PAT-like values", redactionProbe); +rmSync(tmpRoot, { recursive: true, force: true }); + const missing = collectSkillAvailability({ source: "/home/ubuntu/.agents/skills", target: "/path/that/does/not/exist/for-code-queue-skills-test", requiredSkills: ["docs-spec", "cli-spec"], }); -assertCondition(missing.ok === false, "missing target should fail"); +assertCondition(missing.ok === true, "approved source should keep missing-target runner usable"); +assertCondition(missing.runnerUsable === true, "missing target with approved source should expose runner usable"); +assertCondition(missing.contractOk === false, "missing target with approved source should expose hostPath contract degraded"); assertCondition(missing.degraded === true, "missing target should be degraded"); assertCondition(missing.blocker === "skills-target-missing", "missing target should expose blocker", missing); -assertCondition(missing.missingSkills.includes("docs-spec") && missing.missingSkills.includes("cli-spec"), "missing target should list required missing skills", missing); +assertCondition(missing.targetMissingSkills.includes("docs-spec") && missing.targetMissingSkills.includes("cli-spec"), "missing target should list target missing skills", missing); +assertCondition(missing.resolvedPath === "/home/ubuntu/.agents/skills", "missing target should resolve to approved source", missing); +assertCondition(missing.resolvedPathSource === "source-fallback", "missing target should expose source fallback", missing); assertCondition(missing.valuesPrinted === false, "missing report must also declare valuesPrinted=false"); const typoTarget = collectSkillAvailability({ @@ -128,8 +199,9 @@ assertCondition(runtimePreflight.includes("skills: SkillAvailabilityReport"), "r assertCondition(runtimePreflight.includes("skillsSync: SkillSyncPreflightReport"), "runtime preflight type must include skills sync report"); assertCondition(runtimePreflight.includes("collectSkillAvailability"), "runtime preflight must collect skills availability"); assertCondition(runtimePreflight.includes("collectSkillSyncPreflight"), "runtime preflight must collect skills sync preflight"); -assertCondition(runtimePreflight.includes("skills.ok && skillsSync.ok && ports.codex.ok"), "runtime preflight ok must depend on skills and skillsSync"); -assertCondition(indexSource.includes("const skillsReady = skills.ok === true"), "dev-ready must gate on structured skills ok"); +assertCondition(runtimePreflight.includes("skills.runnerUsable && ports.codex.ok"), "runtime preflight ok must depend on runner usable skills without blocking on host rollout contract drift"); +assertCondition(indexSource.includes("skills.runnerUsable === true"), "dev-ready must gate on structured runner usable skills"); +assertCondition(indexSource.includes("resolvedRunnerSkillsPath"), "runtime must pass resolved skills path to code agents"); assertCondition(indexSource.includes("collectSkillsSyncPreflight"), "runtime index must expose skills sync preflight"); assertCondition(indexSource.includes("/api/skills-sync"), "runtime must expose a dry-run skills sync endpoint"); assertCondition(indexSource.includes("pass dryRun=1"), "skills sync endpoint must reject non-dry-run calls"); @@ -199,8 +271,8 @@ const skillsPreflightTransport = { }), }; const defaultPreflightSummary = asRecord(codexPrPreflightQueryForTest(["--remote"], skillsPreflightTransport), "default preflight summary"); -assertCondition(defaultPreflightSummary.failureKind === "runner-skills-blocker", "default preflight should classify missing skills as runner-skills-blocker", defaultPreflightSummary); -assertCondition(defaultPreflightSummary.degradedReason === "unapproved-target", "default preflight degraded reason should use the skills sync blocker first", defaultPreflightSummary); +assertCondition(defaultPreflightSummary.failureKind !== "runner-skills-blocker", "source fallback should not classify as runner skills blocker", defaultPreflightSummary); +assertCondition(asRecord(defaultPreflightSummary.skillsContract, "defaultPreflightSummary.skillsContract").hostRolloutRequired === true, "default preflight should expose host rollout blocker separately", defaultPreflightSummary); 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); @@ -209,9 +281,11 @@ const preflightSummary = asRecord(codexPrPreflightQueryForTest(["--remote", "--f const preflight = asRecord(preflightSummary.preflight, "preflight"); const preflightSkills = asRecord(preflight.skills, "preflight.skills"); const preflightSkillsSync = asRecord(preflight.skillsSync, "preflight.skillsSync"); -assertCondition(preflightSummary.failureKind === "runner-skills-blocker", "full preflight should classify missing skills as runner-skills-blocker", preflightSummary); -assertCondition(preflightSummary.degradedReason === "unapproved-target", "full preflight degraded reason should use the skills sync blocker first", preflightSummary); +assertCondition(preflightSummary.failureKind !== "runner-skills-blocker", "full preflight should keep source fallback out of runner blocker classification", preflightSummary); +assertCondition(asRecord(preflightSummary.skillsContract, "preflightSummary.skillsContract").degradedReason === "skills-target-missing", "full preflight should expose target missing as contract degraded reason", preflightSummary); assertCondition(preflightSkills.target === "/path/that/does/not/exist/for-code-queue-skills-test", "full preflight must show skills target", preflightSkills); +assertCondition(preflightSkills.resolvedPath === "/home/ubuntu/.agents/skills", "full preflight must show resolved source fallback path", preflightSkills); +assertCondition(preflightSkills.resolvedPathSource === "source-fallback", "full preflight must show source fallback resolution", preflightSkills); assertCondition(preflightSkillsSync.dryRun === true && preflightSkillsSync.mutation === false, "full preflight must show non-mutating skills sync dry-run", preflightSkillsSync); assertCondition(asRecord(preflightSkillsSync.counts, "preflight.skillsSync.counts").missingTargetSkills === 2, "full preflight must show missing target count", preflightSkillsSync); assertCondition(asRecord(preflightSkillsSync.plannedActions, "preflight.skillsSync.plannedActions").copy === false, "full preflight must show no copy action", preflightSkillsSync); diff --git a/scripts/src/code-queue.ts b/scripts/src/code-queue.ts index 51a2f6b9..b9a47a3c 100644 --- a/scripts/src/code-queue.ts +++ b/scripts/src/code-queue.ts @@ -6078,7 +6078,12 @@ function compactSkillsStatus(value: unknown): Record | null { if (record === null) return null; return { ok: record.ok ?? false, + runnerUsable: record.runnerUsable ?? record.ok ?? false, + contractOk: record.contractOk ?? record.ok ?? false, path: record.path ?? null, + resolvedPath: record.resolvedPath ?? record.path ?? null, + resolvedPathSource: record.resolvedPathSource ?? null, + resolution: record.resolution ?? null, source: record.source ?? null, target: record.target ?? null, mountPoint: record.mountPoint ?? null, @@ -6086,8 +6091,13 @@ function compactSkillsStatus(value: unknown): Record | null { available: record.available ?? false, degraded: record.degraded ?? true, blocker: record.blocker ?? null, + degradedReason: record.degradedReason ?? record.blocker ?? null, readonly: record.readonly ?? false, skillCount: record.skillCount ?? 0, + sourceSkillCount: record.sourceSkillCount ?? null, + targetSkillCount: record.targetSkillCount ?? null, + sourceMissingSkills: Array.isArray(record.sourceMissingSkills) ? record.sourceMissingSkills.map(String) : [], + targetMissingSkills: Array.isArray(record.targetMissingSkills) ? record.targetMissingSkills.map(String) : [], requiredSkills: Array.isArray(record.requiredSkills) ? record.requiredSkills : [], missingSkills: Array.isArray(record.missingSkills) ? record.missingSkills : [], valuesPrinted: record.valuesPrinted ?? false, @@ -6115,6 +6125,8 @@ function compactSkillPathReport(value: unknown): Record | null writable: record.writable ?? false, readonly: record.readonly ?? false, mountPoint: record.mountPoint ?? null, + symlink: record.symlink ?? false, + realPath: record.realPath ?? null, skillCount: record.skillCount ?? 0, requiredSkills: Array.isArray(record.requiredSkills) ? record.requiredSkills.map(String) : [], missingSkills: Array.isArray(record.missingSkills) ? record.missingSkills.map(String) : [], @@ -6729,6 +6741,7 @@ function compactPrPreflightCommanderView(record: Record, option capabilitySource: authBrokerCapability?.source ?? tokenCoverage?.credentialSource ?? null, }, }, + skillsContract: record.skillsContract ?? undefined, activeRunnerPrCapability: { scope: activeRunnerDevContainer.scope ?? "current-cli-process", tokenCandidatePresent: activeRunnerTokenCandidatePresent, @@ -6902,11 +6915,11 @@ function compactPrRuntimePreflight(preflight: Record, options: const risks = Array.isArray(pull.risks) ? pull.risks.map(String) : []; const ok = preflight.ok === true && tokenCoverage.ok === true; const githubTransient = githubTransientEvidence(pull); - const skillsBlocked = skills !== null && skills.ok !== true; - const skillsSyncBlocked = skillsSync !== null && skillsSync.ok !== true; + const skillsBlocked = skills !== null && skills.runnerUsable !== true && skills.ok !== true; + const skillsContractDegraded = skills !== null && skills.contractOk !== true; const failureKind = !tokenCoverage.ok ? "auth-missing" - : skillsBlocked || skillsSyncBlocked + : skillsBlocked ? "runner-skills-blocker" : githubTransient !== null ? "github-transient" @@ -6918,7 +6931,7 @@ function compactPrRuntimePreflight(preflight: Record, options: const degradedReason = failureKind === "auth-missing" ? "auth-broker-needed" : failureKind === "runner-skills-blocker" - ? typeof skillsSync?.blocker === "string" ? skillsSync.blocker : typeof skills?.blocker === "string" ? skills.blocker : "runner-skills-degraded" + ? typeof skills?.degradedReason === "string" ? skills.degradedReason : typeof skills?.blocker === "string" ? skills.blocker : typeof skillsSync?.blocker === "string" ? skillsSync.blocker : "runner-skills-degraded" : failureKind === "github-transient" ? "github-dns-api-transient" : failureKind === "git-remote-gap" @@ -6948,6 +6961,13 @@ function compactPrRuntimePreflight(preflight: Record, options: }, skills, skillsSync, + 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, + }, tokenCoverage, authBroker: authBrokerNeededStatus(tokenCoverage, authBrokerRuntime, systemGhBinary, unideskGhCli), authScopeSummary, @@ -7319,6 +7339,7 @@ function codeQueuePrPreflight(optionArgs: string[] = [], transport: CodeQueuePrP scopeBoundary: compact.scopeBoundary, activeRunnerDevContainer: compact.activeRunnerDevContainer, recommendedActions: compact.recommendedActions, + skillsContract: compact.skillsContract, upstream: { ok: response.ok, status: response.status }, controlPlane: { mode: "local-backend-core", @@ -7378,6 +7399,7 @@ export async function codexPrPreflightQueryAsync(optionArgs: string[], fetcher: scopeBoundary: compact.scopeBoundary, activeRunnerDevContainer: compact.activeRunnerDevContainer, recommendedActions: compact.recommendedActions, + skillsContract: compact.skillsContract, upstream: { ok: response.ok, status: response.status }, controlPlane: { mode: "remote-frontend", diff --git a/scripts/src/microservices.ts b/scripts/src/microservices.ts index b4220b70..b40df11a 100644 --- a/scripts/src/microservices.ts +++ b/scripts/src/microservices.ts @@ -626,7 +626,12 @@ function compactSkillAvailability(value: unknown): Record | nul if (skills === null) return null; return { ok: skills.ok ?? false, + runnerUsable: skills.runnerUsable ?? skills.ok ?? false, + contractOk: skills.contractOk ?? skills.ok ?? false, path: skills.path ?? null, + resolvedPath: skills.resolvedPath ?? skills.path ?? null, + resolvedPathSource: skills.resolvedPathSource ?? null, + resolution: skills.resolution ?? null, source: skills.source ?? null, target: skills.target ?? null, mountPoint: skills.mountPoint ?? null, @@ -634,8 +639,13 @@ function compactSkillAvailability(value: unknown): Record | nul available: skills.available ?? false, degraded: skills.degraded ?? true, blocker: skills.blocker ?? null, + degradedReason: skills.degradedReason ?? skills.blocker ?? null, readonly: skills.readonly ?? false, skillCount: skills.skillCount ?? 0, + sourceSkillCount: skills.sourceSkillCount ?? null, + targetSkillCount: skills.targetSkillCount ?? null, + sourceMissingSkills: Array.isArray(skills.sourceMissingSkills) ? skills.sourceMissingSkills.map(String) : [], + targetMissingSkills: Array.isArray(skills.targetMissingSkills) ? skills.targetMissingSkills.map(String) : [], requiredSkills: Array.isArray(skills.requiredSkills) ? skills.requiredSkills.map(String) : [], missingSkills: Array.isArray(skills.missingSkills) ? skills.missingSkills.map(String) : [], valuesPrinted: skills.valuesPrinted ?? false, @@ -663,6 +673,8 @@ function compactSkillSync(value: unknown): Record | null { "writable", "readonly", "mountPoint", + "symlink", + "realPath", "skillCount", "requiredSkills", "missingSkills", @@ -677,6 +689,8 @@ function compactSkillSync(value: unknown): Record | null { "writable", "readonly", "mountPoint", + "symlink", + "realPath", "skillCount", "requiredSkills", "missingSkills", diff --git a/src/components/microservices/code-queue/src/code-agent/codex.ts b/src/components/microservices/code-queue/src/code-agent/codex.ts index d10269c1..818a48e6 100644 --- a/src/components/microservices/code-queue/src/code-agent/codex.ts +++ b/src/components/microservices/code-queue/src/code-agent/codex.ts @@ -9,7 +9,7 @@ import { extractRecord, extractString, terminalStatus, textInput, withCodeAgentG import { classifyRunnerError, runnerErrorClassificationJson } from "../runner-error-classifier"; export interface CodexPortContext { - config: Pick; + config: Pick; activeRuns: Map; appendOutput: (task: QueueTask, channel: "system" | "assistant" | "reasoning" | "command" | "diff" | "tool" | "error", text: string, method?: string, itemId?: string, append?: boolean) => unknown; addEvent: (task: QueueTask, event: CodexEventSummary) => void; @@ -97,6 +97,7 @@ function codexAppServerEnv(task: QueueTask): NodeJS.ProcessEnv { ...process.env, CODEX_HOME: ctx().config.codexHome, CODEX_INTERNAL_ORIGINATOR_OVERRIDE: "unidesk_code_queue", + UNIDESK_SKILLS_PATH: ctx().config.resolvedRunnerSkillsPath(), }; if (!shouldRunCodexDirect()) return withCodeAgentGitConfigEnv(env); for (const key of codexProxyEnvKeys) delete env[key]; diff --git a/src/components/microservices/code-queue/src/code-agent/opencode.ts b/src/components/microservices/code-queue/src/code-agent/opencode.ts index 49f9e7c1..9c72f6c2 100644 --- a/src/components/microservices/code-queue/src/code-agent/opencode.ts +++ b/src/components/microservices/code-queue/src/code-agent/opencode.ts @@ -10,7 +10,7 @@ import { codeAgentGitConfigEntries, deepseekChatModel, extractRecord, minimaxM27 import { classifyRunnerError, runnerErrorClassificationJson } from "../runner-error-classifier"; export interface OpenCodePortContext { - config: Pick; + config: Pick; activeRuns: Map; addEvent: (task: QueueTask, event: CodexEventSummary) => void; appendOutput: (task: QueueTask, channel: "system" | "assistant" | "reasoning" | "command" | "diff" | "tool" | "error", text: string, method?: string, itemId?: string, append?: boolean) => unknown; @@ -140,6 +140,7 @@ function openCodeEnv(task: QueueTask): NodeJS.ProcessEnv { MINIMAX_API_BASE: ctx().config.minimaxApiBase, MINIMAX_MODEL: ctx().config.minimaxModel, OPENCODE_CONFIG_CONTENT: openCodeConfigContent(), + UNIDESK_SKILLS_PATH: ctx().config.resolvedRunnerSkillsPath(), }); } diff --git a/src/components/microservices/code-queue/src/index.ts b/src/components/microservices/code-queue/src/index.ts index cc2d1c1e..4f974e3d 100644 --- a/src/components/microservices/code-queue/src/index.ts +++ b/src/components/microservices/code-queue/src/index.ts @@ -402,6 +402,7 @@ function readConfig(): RuntimeConfig { executionProviderIds, remoteCodexEnvKeys: envList("CODE_QUEUE_REMOTE_CODEX_ENV_KEYS", ["OPENAI_API_KEY", "CRS_OAI_KEY", "OPENAI_BASE_URL", "OPENAI_API_BASE", "DEEPSEEK_API_KEY", "DEEPSEEK_API_BASE", "DEEPSEEK_MODEL", "MINIMAX_API_KEY", "MINIMAX_API_BASE", "MINIMAX_MODEL", "GH_TOKEN", "GITHUB_TOKEN", "GH_HOST", "GITHUB_API_URL", "GH_REPO"]), skillsPath: envString("UNIDESK_SKILLS_PATH", "/root/.agents/skills"), + resolvedRunnerSkillsPath, codexHome: envString("CODE_QUEUE_CODEX_HOME", "/var/lib/unidesk/code-queue/codex-home"), opencodeXdgDir: envString("CODE_QUEUE_OPENCODE_XDG_DIR", resolve(dataDir, "opencode-xdg")), sourceCodexConfig: envString("CODE_QUEUE_SOURCE_CODEX_CONFIG", "/root/.codex/config.toml"), @@ -2464,14 +2465,22 @@ function runProbe(command: string, args: string[], timeout = 3_000): { ok: boole return { ok: result.status === 0, output }; } +function currentSkillAvailability() { + return collectSkillAvailability({ target: config.skillsPath }); +} + function collectSkillsStatus(): JsonValue { - return skillAvailabilityJson(collectSkillAvailability({ target: config.skillsPath })); + return skillAvailabilityJson(currentSkillAvailability()); } function collectSkillsSyncPreflight(): JsonValue { return skillSyncPreflightJson(collectSkillSyncPreflight({ target: config.skillsPath })); } +function resolvedRunnerSkillsPath(): string { + return currentSkillAvailability().resolution.runnerEnvValue; +} + function collectDevReady(): JsonValue { const now = Date.now(); if (devReadyCache !== null && now - devReadyCache.checkedAtMs < 30_000) return devReadyCache.value; @@ -2519,9 +2528,9 @@ function collectDevReady(): JsonValue { const sshSharedReady = existsSync("/root/.ssh") && sshKeyProbe.ok && sshKeyProbe.output.trim().length > 0; const skills = collectSkillsStatus() as Record; const skillsSync = collectSkillsSyncPreflight() as Record; - const skillsReady = skills.ok === true; + const skillsReady = skills.ok === true || skills.runnerUsable === true; const runtimePreflight = runtimePreflightJson(collectRuntimePreflight({ includeRemote: false, includePushDryRun: false })); - const ok = missingTools.length === 0 && dockerProbe.ok && composeProbe.ok && workdirExists && dockerSocketExists && codexConfigReady && sshSharedReady && skillsReady && skillsSync.ok === true; + const ok = missingTools.length === 0 && dockerProbe.ok && composeProbe.ok && workdirExists && dockerSocketExists && codexConfigReady && sshSharedReady && skillsReady; const value: JsonValue = { ok, missingTools, diff --git a/src/components/microservices/code-queue/src/prompts.ts b/src/components/microservices/code-queue/src/prompts.ts index eb3aaa03..93f4a9ad 100644 --- a/src/components/microservices/code-queue/src/prompts.ts +++ b/src/components/microservices/code-queue/src/prompts.ts @@ -8,7 +8,7 @@ export const codeQueueEnvironmentHintTitle = "# Code Queue 运行环境提示"; export const codeQueueEnvironmentHint = [ codeQueueEnvironmentHintTitle, "如果当前 Code Queue Docker 容器缺少完成任务所需的环境、系统包或语言依赖,可以先在容器内临时安装以推进当前任务;同时必须把该依赖补到 `src/components/microservices/code-queue/Dockerfile`,让后续任务重建镜像后可直接使用。", - "任务可通过 `UNIDESK_SKILLS_PATH`(默认 `/root/.agents/skills`)读取注入的宿主 skills;若需要确认注入状态,查询 Code Queue `/api/dev-ready` 的 `devReady.skills`,缺失时报告该字段的修复建议,不要读取或输出宿主 token/auth 配置。", + "任务可通过 `UNIDESK_SKILLS_PATH` 读取注入的宿主 skills;默认目标是 `/root/.agents/skills`,如果该挂载暂缺但 approved source `/home/ubuntu/.agents/skills` 可读,runner 会把 `UNIDESK_SKILLS_PATH` 解析到可用 source 并在 `/api/dev-ready` 的 `devReady.skills.resolution` 中报告需要 host rollout 的合同退化,不要读取或输出宿主 token/auth 配置。", ].join("\n"); export function stripAutoReferenceHint(prompt: string): string { diff --git a/src/components/microservices/code-queue/src/runtime-preflight.ts b/src/components/microservices/code-queue/src/runtime-preflight.ts index b12a46b0..d9246705 100644 --- a/src/components/microservices/code-queue/src/runtime-preflight.ts +++ b/src/components/microservices/code-queue/src/runtime-preflight.ts @@ -656,7 +656,7 @@ export function collectRuntimePreflight(options: RuntimePreflightOptions = {}): }; const pullRequestDelivery = collectPullRequestDeliveryPreflight(options, checkedAt); return { - ok: skills.ok && skillsSync.ok && ports.codex.ok && ports.opencode.ok && pullRequestDelivery.ok, + ok: skills.runnerUsable && ports.codex.ok && ports.opencode.ok && pullRequestDelivery.ok, checkedAt, cwd: process.cwd(), pid: process.pid, diff --git a/src/components/microservices/code-queue/src/skill-availability.ts b/src/components/microservices/code-queue/src/skill-availability.ts index 4a862bd1..66c4b18a 100644 --- a/src/components/microservices/code-queue/src/skill-availability.ts +++ b/src/components/microservices/code-queue/src/skill-availability.ts @@ -1,4 +1,4 @@ -import { accessSync, constants, existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { accessSync, constants, existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; import { resolve } from "node:path"; import { spawnSync } from "node:child_process"; import type { JsonValue } from "./types"; @@ -11,18 +11,53 @@ export interface SkillAvailabilityOptions { export interface SkillAvailabilityReport { ok: boolean; + runnerUsable: boolean; + contractOk: boolean; available: boolean; degraded: boolean; blocker: string | null; + degradedReason: string | null; checkedAt: string; source: string; target: string; path: string; + resolvedPath: string; + resolvedPathSource: "target" | "target-symlink" | "source-fallback" | "missing"; + resolution: { + env: "UNIDESK_SKILLS_PATH"; + configuredTarget: string; + configuredSource: string; + resolvedPath: string; + resolvedPathSource: "target" | "target-symlink" | "source-fallback" | "missing"; + runnerEnvValue: string; + passesToRunnerEnv: true; + targetBlocker: string | null; + degradedReason: string | null; + hostRolloutRequired: boolean; + }; mountPoint: string | null; exists: boolean; directory: boolean; + readable: boolean; readonly: boolean; skillCount: number; + targetExists: boolean; + targetDirectory: boolean; + targetReadable: boolean; + targetReadonly: boolean; + targetSkillCount: number; + targetMissingSkills: string[]; + targetSymlink: boolean; + targetRealPath: string | null; + sourceExists: boolean; + sourceDirectory: boolean; + sourceReadable: boolean; + sourceReadonly: boolean; + sourceSkillCount: number; + sourceMissingSkills: string[]; + sourceMountPoint: string | null; + sourceSymlink: boolean; + sourceRealPath: string | null; requiredSkills: string[]; missingSkills: string[]; skills: Array<{ name: string; present: boolean; skillMdPresent: boolean; path: string }>; @@ -49,6 +84,8 @@ export interface SkillSyncPathReport { writable: boolean; readonly: boolean; mountPoint: string | null; + symlink: boolean; + realPath: string | null; skillCount: number; requiredSkills: string[]; missingSkills: string[]; @@ -159,6 +196,10 @@ function normalizedPathText(path: string): string { return path.replace(/\\/gu, "/").replace(/\/+$/u, ""); } +function normalizedResolvedPath(path: string | null): string | null { + return path === null ? null : normalizedPathText(path); +} + function isForbiddenSkillsPath(path: string): boolean { const normalized = normalizedPathText(path); return normalized === forbiddenRelativeSkillsPath @@ -235,6 +276,8 @@ function collectSyncPathReport(path: string, approved: boolean, requiredSkills: const exists = existsSync(path); const permissionFailures: SkillSyncPreflightReport["permissionFailures"] = []; let directory = false; + let symlink = false; + let realPath: string | null = null; let readable = false; let writable = false; let skillCount = 0; @@ -243,6 +286,13 @@ function collectSyncPathReport(path: string, approved: boolean, requiredSkills: if (exists) { try { + const lstat = lstatSync(path); + symlink = lstat.isSymbolicLink(); + try { + realPath = realpathSync(path); + } catch { + realPath = null; + } const stat = statSync(path); directory = stat.isDirectory(); } catch (probeError) { @@ -279,6 +329,8 @@ function collectSyncPathReport(path: string, approved: boolean, requiredSkills: writable, readonly: exists && directory && (mountInfo.readonly === true || !writable), mountPoint: mountInfo.mountPoint, + symlink, + realPath, skillCount, requiredSkills, missingSkills, @@ -289,73 +341,129 @@ function collectSyncPathReport(path: string, approved: boolean, requiredSkills: }; } +function targetBlockerFor(report: SkillSyncPathReport): string | null { + if (!report.exists) return "skills-target-missing"; + if (!report.directory) return "skills-target-not-directory"; + if (!report.readable) return "skills-target-permission-failed"; + if (!report.readonly) return "skills-target-not-readonly"; + if (report.missingSkills.length > 0) return "required-target-skills-missing"; + return null; +} + +function sourceBlockerFor(report: SkillSyncPathReport): string | null { + if (!report.exists) return "skills-source-missing"; + if (!report.directory) return "skills-source-not-directory"; + if (!report.readable) return "skills-source-permission-failed"; + if (report.missingSkills.length > 0) return "required-source-skills-missing"; + return null; +} + +function reportHasRequiredSkills(report: SkillSyncPathReport): boolean { + return report.exists && report.directory && report.readable && report.missingSkills.length === 0; +} + +function sameResolvedPath(left: string | null, right: string | null): boolean { + const normalizedLeft = normalizedResolvedPath(left); + const normalizedRight = normalizedResolvedPath(right); + return normalizedLeft !== null && normalizedRight !== null && normalizedLeft === normalizedRight; +} + export function collectSkillAvailability(options: SkillAvailabilityOptions): SkillAvailabilityReport { const target = options.target; const source = options.source ?? defaultSource; const requiredSkills = options.requiredSkills ?? defaultRequiredSkills; const checkedAt = new Date().toISOString(); - const mountInfo = mountInfoForPath(target); - const exists = existsSync(target); const forbiddenSourceConfigured = isForbiddenSkillsPath(source); const forbiddenTargetConfigured = isForbiddenSkillsPath(target); const forbiddenPathConfigured = forbiddenSourceConfigured || forbiddenTargetConfigured; const forbiddenPathExists = forbiddenTargets.some((path) => existsSync(path)); - let directory = false; - let skillCount = 0; - let readonly = mountInfo.readonly === true; - let error: string | null = null; - let skills: SkillAvailabilityReport["skills"] = requiredSkills.map((name) => ({ - name, - present: false, - skillMdPresent: false, - path: resolve(target, name), - })); - - if (exists) { - try { - const stat = statSync(target); - directory = stat.isDirectory(); - if (directory) { - const entries = readdirSync(target, { withFileTypes: true }); - skillCount = entries.filter((entry) => entry.isDirectory()).length; - skills = skillStatus(target, requiredSkills); - if (mountInfo.readonly === null) { - readonly = commandOk("sh", ["-lc", `test ! -w ${shellQuote(target)}`]); - } - } - } catch (probeError) { - error = probeError instanceof Error ? probeError.message : String(probeError); - } - } - - const missingSkills = skills.filter((skill) => !skill.present || !skill.skillMdPresent).map((skill) => skill.name); - const available = exists && directory; + const sourceProbe = collectSyncPathReport(source, source === defaultSource, requiredSkills).report; + const targetProbe = collectSyncPathReport(target, target === expectedTarget || normalizedPathText(target) === normalizedPathText(source), requiredSkills).report; + const targetSymlinkToSource = targetProbe.symlink && sameResolvedPath(targetProbe.realPath, sourceProbe.realPath); + const targetHasRequiredSkills = reportHasRequiredSkills(targetProbe); + const sourceHasRequiredSkills = reportHasRequiredSkills(sourceProbe); + const targetReadonlyOk = targetProbe.readonly || targetSymlinkToSource || normalizedPathText(target) === normalizedPathText(source); + const targetReady = targetHasRequiredSkills && targetReadonlyOk; + const targetBlocker = targetHasRequiredSkills && targetSymlinkToSource ? null : targetBlockerFor(targetProbe); + const sourceBlocker = sourceBlockerFor(sourceProbe); + const missingBoth = !targetProbe.exists && !sourceProbe.exists; + const resolutionSource: SkillAvailabilityReport["resolvedPathSource"] = forbiddenPathConfigured || missingBoth + ? "missing" + : targetReady + ? targetSymlinkToSource ? "target-symlink" : "target" + : sourceHasRequiredSkills + ? "source-fallback" + : "missing"; + const resolvedPath = resolutionSource === "target" || resolutionSource === "target-symlink" + ? target + : resolutionSource === "source-fallback" + ? source + : target; + const selectedReport = resolutionSource === "source-fallback" ? sourceProbe : targetProbe; + const runnerUsable = !forbiddenPathConfigured && resolutionSource !== "missing"; + const contractOk = runnerUsable && resolutionSource !== "source-fallback" && targetReady && !forbiddenPathExists; const blocker = forbiddenPathConfigured ? "forbidden-skills-path-configured" - : !available - ? "skills-target-missing" - : !readonly - ? "skills-target-not-readonly" - : missingSkills.length > 0 - ? "required-skills-missing" - : forbiddenPathExists - ? "forbidden-skills-path-present" - : null; - const ok = blocker === null; + : missingBoth + ? "skills-source-and-target-missing" + : !runnerUsable + ? targetBlocker ?? sourceBlocker ?? "required-skills-missing" + : contractOk + ? null + : targetBlocker ?? (forbiddenPathExists ? "forbidden-skills-path-present" : null); + const degradedReason = contractOk ? null : blocker; + const ok = runnerUsable; + const missingSkills = selectedReport.missingSkills; + const skills = resolutionSource === "source-fallback" ? sourceProbe.skills : targetProbe.skills; return { ok, - available, - degraded: !ok, + runnerUsable, + contractOk, + available: runnerUsable, + degraded: !contractOk, blocker, + degradedReason, checkedAt, source, target, - path: target, - mountPoint: mountInfo.mountPoint, - exists, - directory, - readonly, - skillCount, + path: resolvedPath, + resolvedPath, + resolvedPathSource: resolutionSource, + resolution: { + env: "UNIDESK_SKILLS_PATH", + configuredTarget: target, + configuredSource: source, + resolvedPath, + resolvedPathSource: resolutionSource, + runnerEnvValue: resolvedPath, + passesToRunnerEnv: true, + targetBlocker, + degradedReason, + hostRolloutRequired: runnerUsable && !contractOk, + }, + mountPoint: selectedReport.mountPoint, + exists: targetProbe.exists, + directory: targetProbe.directory, + readable: targetProbe.readable, + readonly: targetProbe.readonly, + skillCount: selectedReport.skillCount, + targetExists: targetProbe.exists, + targetDirectory: targetProbe.directory, + targetReadable: targetProbe.readable, + targetReadonly: targetProbe.readonly, + targetSkillCount: targetProbe.skillCount, + targetMissingSkills: targetProbe.missingSkills, + targetSymlink: targetProbe.symlink, + targetRealPath: targetProbe.realPath, + sourceExists: sourceProbe.exists, + sourceDirectory: sourceProbe.directory, + sourceReadable: sourceProbe.readable, + sourceReadonly: sourceProbe.readonly, + sourceSkillCount: sourceProbe.skillCount, + sourceMissingSkills: sourceProbe.missingSkills, + sourceMountPoint: sourceProbe.mountPoint, + sourceSymlink: sourceProbe.symlink, + sourceRealPath: sourceProbe.realPath, requiredSkills, missingSkills, skills, @@ -371,10 +479,12 @@ export function collectSkillAvailability(options: SkillAvailabilityOptions): Ski forbiddenPathMustNotBeUsed: true, }, expectedMount: `${defaultSource} mounted read-only to ${expectedTarget}`, - repairHint: ok + repairHint: contractOk ? null - : `Mount ${defaultSource} read-only at ${expectedTarget}, set UNIDESK_SKILLS_PATH=${expectedTarget}, and remove any forbidden skills path spelling.`, - error, + : runnerUsable + ? `Runner can use ${resolvedPath}; restore the read-only ${defaultSource} -> ${expectedTarget} projection so host rollout no longer reports ${degradedReason ?? "skills contract degraded"}.` + : `Mount ${defaultSource} read-only at ${expectedTarget}, set UNIDESK_SKILLS_PATH=${expectedTarget}, and remove any forbidden skills path spelling.`, + error: selectedReport.error, valuesPrinted: false, }; } diff --git a/src/components/microservices/code-queue/src/types.ts b/src/components/microservices/code-queue/src/types.ts index b89a2f41..b0b8292a 100644 --- a/src/components/microservices/code-queue/src/types.ts +++ b/src/components/microservices/code-queue/src/types.ts @@ -124,6 +124,7 @@ export interface RuntimeConfig { executionProviderIds: string[]; remoteCodexEnvKeys: string[]; skillsPath: string; + resolvedRunnerSkillsPath: () => string; codexHome: string; opencodeXdgDir: string; sourceCodexConfig: string;