fix: expose code queue runner skills contract

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