fix: resolve code queue runner skills path

Resolve Code Queue runner skills through approved source fallback while preserving hostPath contract diagnostics.
This commit is contained in:
Lyon
2026-05-23 22:50:31 +08:00
committed by GitHub
parent 12fdc9e238
commit 026a718a24
11 changed files with 308 additions and 76 deletions
+2 -2
View File
@@ -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 请求必须失败。
@@ -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);
+26 -4
View File
@@ -6078,7 +6078,12 @@ function compactSkillsStatus(value: unknown): Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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",
+14
View File
@@ -626,7 +626,12 @@ function compactSkillAvailability(value: unknown): Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | null {
"writable",
"readonly",
"mountPoint",
"symlink",
"realPath",
"skillCount",
"requiredSkills",
"missingSkills",
@@ -677,6 +689,8 @@ function compactSkillSync(value: unknown): Record<string, unknown> | null {
"writable",
"readonly",
"mountPoint",
"symlink",
"realPath",
"skillCount",
"requiredSkills",
"missingSkills",
@@ -9,7 +9,7 @@ import { extractRecord, extractString, terminalStatus, textInput, withCodeAgentG
import { classifyRunnerError, runnerErrorClassificationJson } from "../runner-error-classifier";
export interface CodexPortContext {
config: Pick<RuntimeConfig, "approvalPolicy" | "codexHome" | "defaultWorkdir" | "sandbox" | "sourceCodexConfig" | "turnNoActivityTimeoutMs">;
config: Pick<RuntimeConfig, "approvalPolicy" | "codexHome" | "defaultWorkdir" | "resolvedRunnerSkillsPath" | "sandbox" | "sourceCodexConfig" | "turnNoActivityTimeoutMs">;
activeRuns: Map<string, ActiveRun>;
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];
@@ -10,7 +10,7 @@ import { codeAgentGitConfigEntries, deepseekChatModel, extractRecord, minimaxM27
import { classifyRunnerError, runnerErrorClassificationJson } from "../runner-error-classifier";
export interface OpenCodePortContext {
config: Pick<RuntimeConfig, "deepseekApiBase" | "deepseekApiKey" | "deepseekModel" | "defaultWorkdir" | "minimaxApiBase" | "minimaxApiKey" | "minimaxModel" | "turnNoActivityTimeoutMs">;
config: Pick<RuntimeConfig, "deepseekApiBase" | "deepseekApiKey" | "deepseekModel" | "defaultWorkdir" | "minimaxApiBase" | "minimaxApiKey" | "minimaxModel" | "resolvedRunnerSkillsPath" | "turnNoActivityTimeoutMs">;
activeRuns: Map<string, ActiveRun>;
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(),
});
}
@@ -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<string, JsonValue>;
const skillsSync = collectSkillsSyncPreflight() as Record<string, JsonValue>;
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,
@@ -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 {
@@ -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,
@@ -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,
};
}
@@ -124,6 +124,7 @@ export interface RuntimeConfig {
executionProviderIds: string[];
remoteCodexEnvKeys: string[];
skillsPath: string;
resolvedRunnerSkillsPath: () => string;
codexHome: string;
opencodeXdgDir: string;
sourceCodexConfig: string;