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
@@ -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;