fix(code-queue): preflight runner skills availability

This commit is contained in:
Codex
2026-05-22 15:24:00 +00:00
parent b9185e0379
commit 62c4da6b58
8 changed files with 340 additions and 68 deletions
@@ -134,6 +134,7 @@ import {
readOaTraceStepsForTask,
} from "./oa-events";
import { collectRuntimePreflight, runtimePreflightJson } from "./runtime-preflight";
import { collectSkillAvailability, skillAvailabilityJson } from "./skill-availability";
import { configureSelfTests, runJudgeInfraSelfTest, runQueueClaimMoveSelfTest, runQueueOrderingSelfTest, runReferenceInjectionSelfTest, runTracePortSelfTest, runTraceSummaryContractSelfTest } from "./self-tests";
import {
codexToolLifecycleStartedBeforeIn,
@@ -2420,72 +2421,8 @@ function runProbe(command: string, args: string[], timeout = 3_000): { ok: boole
return { ok: result.status === 0, output };
}
function decodeMountInfoPath(value: string): string {
return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) => String.fromCharCode(Number.parseInt(octal, 8)));
}
function mountInfoForPath(path: string): { mountPoint: string | null; readonly: boolean | null } {
try {
const target = resolve(path);
let best: { mountPoint: string; readonly: boolean } | null = null;
for (const line of readFileSync("/proc/self/mountinfo", "utf8").split(/\r?\n/u)) {
if (line.trim().length === 0) continue;
const fields = line.split(" ");
const mountPoint = decodeMountInfoPath(fields[4] ?? "");
const options = (fields[5] ?? "").split(",");
if (mountPoint.length === 0) continue;
const matches = target === mountPoint || target.startsWith(mountPoint.endsWith("/") ? mountPoint : `${mountPoint}/`);
if (!matches) continue;
if (best === null || mountPoint.length > best.mountPoint.length) best = { mountPoint, readonly: options.includes("ro") };
}
return best ?? { mountPoint: null, readonly: null };
} catch {
return { mountPoint: null, readonly: null };
}
}
function collectSkillsStatus(): JsonValue {
const path = config.skillsPath;
const exists = existsSync(path);
const mountInfo = mountInfoForPath(path);
let directory = false;
let skillCount = 0;
let cliSpecAvailable = false;
let readonly = mountInfo.readonly === true;
let error: string | null = null;
if (exists) {
try {
const stat = statSync(path);
directory = stat.isDirectory();
if (directory) {
const entries = readdirSync(path, { withFileTypes: true });
skillCount = entries.filter((entry) => entry.isDirectory()).length;
cliSpecAvailable = existsSync(resolve(path, "cli-spec", "SKILL.md"));
if (mountInfo.readonly === null) {
const writeProbe = runProbe("sh", ["-lc", `test ! -w ${shellQuote(path)}`], 2_000);
readonly = writeProbe.ok;
}
}
} catch (probeError) {
error = probeError instanceof Error ? probeError.message : String(probeError);
}
}
const available = exists && directory;
return {
path,
mountPoint: mountInfo.mountPoint,
exists,
directory,
available,
readonly,
skillCount,
cliSpecAvailable,
expectedMount: "host ~/.agents/skills mounted read-only to UNIDESK_SKILLS_PATH",
repairHint: available && readonly && cliSpecAvailable
? null
: "DEV code-queue should mount /home/ubuntu/.agents/skills read-only at /root/.agents/skills and set UNIDESK_SKILLS_PATH=/root/.agents/skills.",
error,
} as unknown as JsonValue;
return skillAvailabilityJson(collectSkillAvailability({ target: config.skillsPath }));
}
function collectDevReady(): JsonValue {
@@ -2534,7 +2471,7 @@ function collectDevReady(): JsonValue {
const githubKnownHostProbe = runProbe("ssh-keygen", ["-F", "github.com", "-f", "/root/.ssh/known_hosts"]);
const sshSharedReady = existsSync("/root/.ssh") && sshKeyProbe.ok && sshKeyProbe.output.trim().length > 0;
const skills = collectSkillsStatus() as Record<string, JsonValue>;
const skillsReady = skills.available === true && skills.readonly === true && skills.cliSpecAvailable === true;
const skillsReady = skills.ok === true;
const runtimePreflight = runtimePreflightJson(collectRuntimePreflight({ includeRemote: false, includePushDryRun: false }));
const ok = missingTools.length === 0 && dockerProbe.ok && composeProbe.ok && workdirExists && dockerSocketExists && codexConfigReady && sshSharedReady && skillsReady;
const value: JsonValue = {
@@ -2,6 +2,7 @@
import { spawnSync } from "node:child_process";
import type { JsonValue } from "./types";
import { collectSkillAvailability, type SkillAvailabilityReport } from "./skill-availability";
export type RuntimePreflightAgentPort = "codex" | "opencode";
@@ -51,6 +52,7 @@ export interface RuntimePreflightReport {
version: string;
};
path: string;
skills: SkillAvailabilityReport;
ports: Record<RuntimePreflightAgentPort, RuntimePreflightPortStatus>;
pullRequestDelivery: PullRequestDeliveryPreflight;
}
@@ -645,13 +647,14 @@ function opencodeStatus(path: string, checkedAt: string): RuntimePreflightPortSt
export function collectRuntimePreflight(options: RuntimePreflightOptions = {}): RuntimePreflightReport {
const checkedAt = new Date().toISOString();
const path = process.env.PATH ?? "";
const skills = collectSkillAvailability({ target: process.env.UNIDESK_SKILLS_PATH || "/root/.agents/skills" });
const ports = {
codex: codexStatus(path, checkedAt),
opencode: opencodeStatus(path, checkedAt),
};
const pullRequestDelivery = collectPullRequestDeliveryPreflight(options, checkedAt);
return {
ok: ports.codex.ok && ports.opencode.ok && pullRequestDelivery.ok,
ok: skills.ok && ports.codex.ok && ports.opencode.ok && pullRequestDelivery.ok,
checkedAt,
cwd: process.cwd(),
pid: process.pid,
@@ -661,6 +664,7 @@ export function collectRuntimePreflight(options: RuntimePreflightOptions = {}):
version: process.version,
},
path,
skills,
ports,
pullRequestDelivery,
};
@@ -0,0 +1,188 @@
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { spawnSync } from "node:child_process";
import type { JsonValue } from "./types";
export interface SkillAvailabilityOptions {
target: string;
source?: string;
requiredSkills?: string[];
}
export interface SkillAvailabilityReport {
ok: boolean;
available: boolean;
degraded: boolean;
blocker: string | null;
checkedAt: string;
source: string;
target: string;
path: string;
mountPoint: string | null;
exists: boolean;
directory: boolean;
readonly: boolean;
skillCount: number;
requiredSkills: string[];
missingSkills: string[];
skills: Array<{ name: string; present: boolean; skillMdPresent: boolean; path: string }>;
pathSpelling: {
expectedTarget: string;
forbiddenPathChecked: true;
forbiddenPathExists: boolean;
forbiddenPathMustNotBeUsed: true;
};
expectedMount: string;
repairHint: string | null;
error: string | null;
valuesPrinted: false;
}
const defaultRequiredSkills = ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"];
const defaultSource = "/home/ubuntu/.agents/skills";
const expectedTarget = "/root/.agents/skills";
const forbiddenSkillsDirName = [".ag", "nets"].join("");
const forbiddenTargets = [`/root/${forbiddenSkillsDirName}/skills`, `/home/ubuntu/${forbiddenSkillsDirName}/skills`];
const skillDirectoryAliases: Record<string, string[]> = {
"playwright-cli": ["playwright-cli", "playwright"],
};
function shellQuote(value: string): string {
return `'${value.replace(/'/gu, "'\\''")}'`;
}
function commandOk(command: string, args: string[], timeoutMs = 2000): boolean {
const result = spawnSync(command, args, {
encoding: "utf8",
timeout: timeoutMs,
maxBuffer: 64 * 1024,
shell: false,
});
return result.error === undefined && result.status === 0;
}
function decodeMountInfoPath(value: string): string {
return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) => String.fromCharCode(Number.parseInt(octal, 8)));
}
function mountInfoForPath(path: string): { mountPoint: string | null; readonly: boolean | null } {
try {
const target = resolve(path);
let best: { mountPoint: string; readonly: boolean } | null = null;
for (const line of readFileSync("/proc/self/mountinfo", "utf8").split(/\r?\n/u)) {
if (line.trim().length === 0) continue;
const fields = line.split(" ");
const mountPoint = decodeMountInfoPath(fields[4] ?? "");
const options = (fields[5] ?? "").split(",");
if (mountPoint.length === 0) continue;
const matches = target === mountPoint || target.startsWith(mountPoint.endsWith("/") ? mountPoint : `${mountPoint}/`);
if (!matches) continue;
if (best === null || mountPoint.length > best.mountPoint.length) best = { mountPoint, readonly: options.includes("ro") };
}
return best ?? { mountPoint: null, readonly: null };
} catch {
return { mountPoint: null, readonly: null };
}
}
function candidateDirs(name: string): string[] {
return skillDirectoryAliases[name] ?? [name];
}
function skillStatus(target: string, requiredSkills: string[]): SkillAvailabilityReport["skills"] {
return requiredSkills.map((name) => {
const candidates = candidateDirs(name).map((candidate) => resolve(target, candidate));
const existingPath = candidates.find((candidate) => existsSync(candidate) && statSync(candidate).isDirectory());
const path = existingPath ?? candidates[0] ?? resolve(target, name);
return {
name,
present: existingPath !== undefined,
skillMdPresent: existsSync(resolve(path, "SKILL.md")),
path,
};
});
}
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 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 blocker = !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;
return {
ok,
available,
degraded: !ok,
blocker,
checkedAt,
source,
target,
path: target,
mountPoint: mountInfo.mountPoint,
exists,
directory,
readonly,
skillCount,
requiredSkills,
missingSkills,
skills,
pathSpelling: {
expectedTarget,
forbiddenPathChecked: true,
forbiddenPathExists,
forbiddenPathMustNotBeUsed: true,
},
expectedMount: `${source} mounted read-only to ${target}`,
repairHint: ok
? null
: `Mount ${source} read-only at ${target}, set UNIDESK_SKILLS_PATH=${target}, and remove any forbidden skills path spelling.`,
error,
valuesPrinted: false,
};
}
export function skillAvailabilityJson(report: SkillAvailabilityReport): JsonValue {
return report as unknown as JsonValue;
}
@@ -80,6 +80,8 @@ spec:
value: "/var/lib/unidesk/code-queue/opencode-xdg"
- name: CODE_QUEUE_SOURCE_CODEX_CONFIG
value: "/root/.codex/config.toml"
- name: UNIDESK_SKILLS_PATH
value: "/root/.agents/skills"
- name: CODE_QUEUE_DEFAULT_MODEL
value: "gpt-5.5"
- name: CODE_QUEUE_MODELS
@@ -177,6 +179,9 @@ spec:
- name: codex-auth
mountPath: /root/.codex/auth.json
readOnly: true
- name: skills-dir
mountPath: /root/.agents/skills
readOnly: true
- name: ssh-dir
mountPath: /root/.ssh
readOnly: true
@@ -232,6 +237,10 @@ spec:
hostPath:
path: /home/ubuntu/.codex/auth.json
type: File
- name: skills-dir
hostPath:
path: /home/ubuntu/.agents/skills
type: Directory
- name: ssh-dir
hostPath:
path: /home/ubuntu/.ssh
@@ -317,6 +326,8 @@ spec:
value: "/var/lib/unidesk/code-queue/opencode-xdg"
- name: CODE_QUEUE_SOURCE_CODEX_CONFIG
value: "/root/.codex/config.toml"
- name: UNIDESK_SKILLS_PATH
value: "/root/.agents/skills"
- name: CODE_QUEUE_DEFAULT_MODEL
value: "gpt-5.5"
- name: CODE_QUEUE_MODELS
@@ -414,6 +425,9 @@ spec:
- name: codex-auth
mountPath: /root/.codex/auth.json
readOnly: true
- name: skills-dir
mountPath: /root/.agents/skills
readOnly: true
- name: ssh-dir
mountPath: /root/.ssh
readOnly: true
@@ -469,6 +483,10 @@ spec:
hostPath:
path: /home/ubuntu/.codex/auth.json
type: File
- name: skills-dir
hostPath:
path: /home/ubuntu/.agents/skills
type: Directory
- name: ssh-dir
hostPath:
path: /home/ubuntu/.ssh
@@ -1013,6 +1031,8 @@ spec:
value: "/var/lib/unidesk/code-queue/opencode-xdg"
- name: CODE_QUEUE_SOURCE_CODEX_CONFIG
value: "/root/.codex/config.toml"
- name: UNIDESK_SKILLS_PATH
value: "/root/.agents/skills"
- name: CODE_QUEUE_DEFAULT_MODEL
value: "gpt-5.5"
- name: CODE_QUEUE_MODELS
@@ -1110,6 +1130,9 @@ spec:
- name: codex-auth
mountPath: /root/.codex/auth.json
readOnly: true
- name: skills-dir
mountPath: /root/.agents/skills
readOnly: true
- name: ssh-dir
mountPath: /root/.ssh
readOnly: true
@@ -1165,6 +1188,10 @@ spec:
hostPath:
path: /home/ubuntu/.codex/auth.json
type: File
- name: skills-dir
hostPath:
path: /home/ubuntu/.agents/skills
type: Directory
- name: ssh-dir
hostPath:
path: /home/ubuntu/.ssh