diff --git a/scripts/code-queue-runner-skills-contract-test.ts b/scripts/code-queue-runner-skills-contract-test.ts index 70122335..c15735f5 100644 --- a/scripts/code-queue-runner-skills-contract-test.ts +++ b/scripts/code-queue-runner-skills-contract-test.ts @@ -88,22 +88,22 @@ mkdirSync(fixtureSource, { recursive: true }); createSkillSet(fixtureSource, ["docs-spec", "cli-spec"]); symlinkSync(fixtureSource, fixtureSymlinkTarget, "dir"); -const sourceFallback = collectSkillAvailability({ +const missingTargetWithSource = 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); +assertCondition(missingTargetWithSource.ok === false, "source exists target missing must keep runner unavailable until target is projected", missingTargetWithSource); +assertCondition(missingTargetWithSource.runnerUsable === false, "source must not be passed as the runner skills path when target is missing", missingTargetWithSource); +assertCondition(missingTargetWithSource.contractOk === false, "missing target must mark target projection contract degraded", missingTargetWithSource); +assertCondition(missingTargetWithSource.degraded === true, "missing target should remain degraded for host rollout", missingTargetWithSource); +assertCondition(missingTargetWithSource.blocker === "skills-target-missing", "missing target should preserve target missing degraded reason", missingTargetWithSource); +assertCondition(missingTargetWithSource.degradedReason === "skills-target-missing", "missing target should expose bounded degraded reason", missingTargetWithSource); +assertCondition(missingTargetWithSource.resolvedPath === fixtureMissingTarget, "missing target should keep runner path at the expected target", missingTargetWithSource); +assertCondition(missingTargetWithSource.resolvedPathSource === "missing", "missing target should not expose source fallback resolution", missingTargetWithSource); +assertCondition(missingTargetWithSource.skillCount === 0 && missingTargetWithSource.sourceSkillCount === 2 && missingTargetWithSource.targetSkillCount === 0, "missing target should expose bounded source and target counts", missingTargetWithSource); +assertCondition(asRecord(missingTargetWithSource.resolution, "missingTargetWithSource.resolution").runnerEnvValue === fixtureMissingTarget, "missing target should not pass source path to runner env", missingTargetWithSource.resolution); +assertCondition(asRecord(missingTargetWithSource.resolution, "missingTargetWithSource.resolution").hostRolloutRequired === true, "missing target should require host rollout repair", missingTargetWithSource.resolution); const symlinkOk = collectSkillAvailability({ source: fixtureSource, @@ -139,14 +139,14 @@ const missing = collectSkillAvailability({ target: "/path/that/does/not/exist/for-code-queue-skills-test", requiredSkills: ["docs-spec", "cli-spec"], }); -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.ok === false, "approved source must not keep missing-target runner usable"); +assertCondition(missing.runnerUsable === false, "missing target with approved source should expose runner unavailable"); 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.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.resolvedPath === "/path/that/does/not/exist/for-code-queue-skills-test", "missing target should keep the configured target path", missing); +assertCondition(missing.resolvedPathSource === "missing", "missing target should not expose source fallback", missing); assertCondition(missing.valuesPrinted === false, "missing report must also declare valuesPrinted=false"); const typoTarget = collectSkillAvailability({ @@ -203,8 +203,8 @@ 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.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(runtimePreflight.includes("skills.contractOk && ports.codex.ok"), "runtime preflight ok must depend on the read-only target projection contract"); +assertCondition(indexSource.includes("skills.contractOk === true"), "dev-ready must gate on structured target projection contract"); assertCondition(indexSource.includes("resolvedRunnerSkillsPath"), "runtime must pass resolved skills path to code agents"); assertCondition(indexSource.includes("runnerSkillsBlocker"), "scheduler must check skills before starting code agents"); assertCondition(indexSource.includes("task_blocked_by_runner_skills"), "scheduler must emit structured runner skills blockers"); @@ -278,7 +278,7 @@ const skillsPreflightTransport = { }), }; const defaultPreflightSummary = asRecord(codexPrPreflightQueryForTest(["--remote"], skillsPreflightTransport), "default preflight summary"); -assertCondition(defaultPreflightSummary.failureKind !== "runner-skills-blocker", "source fallback should not classify as runner skills blocker", defaultPreflightSummary); +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(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); @@ -288,11 +288,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 keep source fallback out of runner blocker classification", preflightSummary); +assertCondition(preflightSummary.failureKind === "runner-skills-blocker", "full preflight should classify missing target as runner blocker", 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(preflightSkills.resolvedPath === "/path/that/does/not/exist/for-code-queue-skills-test", "full preflight must keep resolved path at the target", preflightSkills); +assertCondition(preflightSkills.resolvedPathSource === "missing", "full preflight must not 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); @@ -403,7 +403,7 @@ process.stdout.write(`${JSON.stringify({ "skill availability report exposes source, target, requiredSkills, missingSkills, version fingerprint/mtime, degraded/blocker and valuesPrinted=false", "skills sync dry-run reports source, target, counts, version fingerprint/mtime, missing skills, permission failures, instructions and no-copy actions", "scheduler blocks runner startup with structured infra-blocked output when required skills are unavailable", - "runtime-preflight, dev-ready, health and PR preflight use the same structured skill and sync reports", + "runtime-preflight, dev-ready, health and PR preflight require the target projection instead of source fallback", "default health/preflight summaries expose bounded skills lifecycle evidence and --full expansion", "misspelled skills paths are rejected with forbidden-skills-path-configured before generic missing/unapproved path blockers", ], diff --git a/src/components/microservices/code-queue/src/index.ts b/src/components/microservices/code-queue/src/index.ts index d4c97c81..9a5cc9d7 100644 --- a/src/components/microservices/code-queue/src/index.ts +++ b/src/components/microservices/code-queue/src/index.ts @@ -2478,12 +2478,12 @@ function collectSkillsSyncPreflight(): JsonValue { } function resolvedRunnerSkillsPath(): string { - return currentSkillAvailability().resolution.runnerEnvValue; + return config.skillsPath; } function runnerSkillsBlocker(): Record | null { const skills = currentSkillAvailability(); - if (skills.runnerUsable) return null; + if (skills.contractOk) return null; const pathSpelling = { expectedTarget: skills.pathSpelling.expectedTarget, forbiddenPathChecked: skills.pathSpelling.forbiddenPathChecked, @@ -2565,7 +2565,7 @@ function collectDevReady(): JsonValue { const sshSharedReady = existsSync("/root/.ssh") && sshKeyProbe.ok && sshKeyProbe.output.trim().length > 0; const skills = collectSkillsStatus() as Record; const skillsSync = collectSkillsSyncPreflight() as Record; - const skillsReady = skills.ok === true || skills.runnerUsable === true; + const skillsReady = skills.contractOk === 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 = { diff --git a/src/components/microservices/code-queue/src/runtime-preflight.ts b/src/components/microservices/code-queue/src/runtime-preflight.ts index d9246705..2057ad50 100644 --- a/src/components/microservices/code-queue/src/runtime-preflight.ts +++ b/src/components/microservices/code-queue/src/runtime-preflight.ts @@ -656,7 +656,7 @@ export function collectRuntimePreflight(options: RuntimePreflightOptions = {}): }; const pullRequestDelivery = collectPullRequestDeliveryPreflight(options, checkedAt); return { - ok: skills.runnerUsable && ports.codex.ok && ports.opencode.ok && pullRequestDelivery.ok, + ok: skills.contractOk && ports.codex.ok && ports.opencode.ok && pullRequestDelivery.ok, checkedAt, cwd: process.cwd(), pid: process.pid, diff --git a/src/components/microservices/code-queue/src/skill-availability.ts b/src/components/microservices/code-queue/src/skill-availability.ts index 5a9e0d44..f095eac5 100644 --- a/src/components/microservices/code-queue/src/skill-availability.ts +++ b/src/components/microservices/code-queue/src/skill-availability.ts @@ -438,41 +438,33 @@ export function collectSkillAvailability(options: SkillAvailabilityOptions): Ski 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 targetHasRequiredSkills = reportHasRequiredSkills(targetProbe); 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 + const resolutionSource: SkillAvailabilityReport["resolvedPathSource"] = forbiddenPathConfigured || !targetReady ? "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; + : targetSymlinkToSource ? "target-symlink" : "target"; + const resolvedPath = target; + const selectedReport = targetProbe; + const runnerUsable = !forbiddenPathConfigured && targetReady && !forbiddenPathExists; + const contractOk = runnerUsable; const blocker = forbiddenPathConfigured ? "forbidden-skills-path-configured" + : forbiddenPathExists + ? "forbidden-skills-path-present" : missingBoth ? "skills-source-and-target-missing" : !runnerUsable ? targetBlocker ?? sourceBlocker ?? "required-skills-missing" - : contractOk - ? null - : targetBlocker ?? (forbiddenPathExists ? "forbidden-skills-path-present" : null); + : null; const degradedReason = contractOk ? null : blocker; - const ok = runnerUsable; + const ok = contractOk; const missingSkills = selectedReport.missingSkills; - const skills = resolutionSource === "source-fallback" ? sourceProbe.skills : targetProbe.skills; + const skills = targetProbe.skills; return { ok, runnerUsable, @@ -497,7 +489,7 @@ export function collectSkillAvailability(options: SkillAvailabilityOptions): Ski passesToRunnerEnv: true, targetBlocker, degradedReason, - hostRolloutRequired: runnerUsable && !contractOk, + hostRolloutRequired: sourceHasRequiredSkills && !contractOk, }, mountPoint: selectedReport.mountPoint, exists: targetProbe.exists, @@ -549,8 +541,8 @@ export function collectSkillAvailability(options: SkillAvailabilityOptions): Ski }, repairHint: contractOk ? null - : runnerUsable - ? `Runner can use ${resolvedPath}; restore the read-only ${defaultSource} -> ${expectedTarget} projection so host rollout no longer reports ${degradedReason ?? "skills contract degraded"}.` + : sourceHasRequiredSkills + ? `Source ${defaultSource} has the required skills; restore the read-only ${defaultSource} -> ${expectedTarget} projection before starting runners.` : `Mount ${defaultSource} read-only at ${expectedTarget}, set UNIDESK_SKILLS_PATH=${expectedTarget}, and remove any forbidden skills path spelling.`, error: selectedReport.error, valuesPrinted: false,