From 91e13cd180e5874e44d9fba2b38d1cc64f530cdc Mon Sep 17 00:00:00 2001 From: AgentRun Codex Date: Fri, 17 Jul 2026 16:18:53 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=B0=86=E7=BC=BA=E5=A4=B1=E8=BE=85?= =?UTF-8?q?=E5=8A=A9=20skill=20=E9=99=8D=E7=BA=A7=E4=B8=BA=E5=91=8A?= =?UTF-8?q?=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/aipods/artificer.yaml | 2 +- src/runner/resource-bundle.ts | 54 +++++++++++++------ src/runner/run-once.ts | 2 +- .../cases/50-hwlab-manual-dispatch.ts | 44 ++++++++++----- .../cases/81-artificer-nonrecursive-prompt.ts | 4 +- src/selftest/cases/90-runner-image-tools.ts | 8 ++- 6 files changed, 81 insertions(+), 33 deletions(-) diff --git a/config/aipods/artificer.yaml b/config/aipods/artificer.yaml index 62ee2c7..f713cc3 100644 --- a/config/aipods/artificer.yaml +++ b/config/aipods/artificer.yaml @@ -144,7 +144,7 @@ spec: - name: git-spec - name: unidesk-trans - name: unidesk-gh - - name: unidesk-code-queue + - name: unidesk-agentrun - name: unidesk-cicd - name: unidesk-subagent - name: unidesk-decision diff --git a/src/runner/resource-bundle.ts b/src/runner/resource-bundle.ts index b7e70aa..35f8012 100644 --- a/src/runner/resource-bundle.ts +++ b/src/runner/resource-bundle.ts @@ -45,6 +45,17 @@ interface MaterializedSkillRef { sourceBundle: JsonRecord | null; } +interface ResourceSkillWarning extends JsonRecord { + code: "required-skill-unavailable"; + severity: "warning"; + scope: "resource-bundle.requiredSkills"; + message: string; + missingNames: string[]; + materializedNames: string[]; + blocking: false; + valuesPrinted: false; +} + interface GitCheckout { repoUrl: string; fetchRepoUrl: string; @@ -242,6 +253,7 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl tools: tools.event, skillDirs: skills.event, requiredSkills: requiredSkills.event, + warnings: requiredSkills.warnings, promptRefs: prompts.event, initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillCount: skills.items.length, valuesPrinted: false }, valuesPrinted: false, @@ -969,45 +981,53 @@ async function discoverGitBundleSkills(overlayRoot: string, bundles: Materialize }; } -function materializeRequiredSkills(refs: NonNullable, skills: MaterializedSkillRef[]): { items: MaterializedSkillRef[]; event: JsonRecord } { - if (refs.length === 0) return { items: [], event: { count: 0, materializedCount: 0, names: [], items: [], valuesPrinted: false } }; +function materializeRequiredSkills(refs: NonNullable, skills: MaterializedSkillRef[]): { items: MaterializedSkillRef[]; event: JsonRecord; warnings: JsonRecord } { + if (refs.length === 0) { + return { + items: [], + event: { count: 0, materializedCount: 0, missingCount: 0, names: [], materializedNames: [], missingNames: [], items: [], valuesPrinted: false }, + warnings: { count: 0, items: [], valuesPrinted: false }, + }; + } const byName = new Map(skills.map((skill) => [skill.name, skill])); const items: MaterializedSkillRef[] = []; const eventItems: JsonRecord[] = []; - const missing: JsonRecord[] = []; const missingNames: string[] = []; for (const ref of refs) { const skill = byName.get(ref.name); if (!skill) { - const item = { name: ref.name, path: `.agents/skills/${ref.name}/SKILL.md`, status: "missing", valuesPrinted: false }; + const item = { name: ref.name, path: `.agents/skills/${ref.name}/SKILL.md`, status: "missing", warning: "required-skill-unavailable", severity: "warning", blocking: false, valuesPrinted: false }; missingNames.push(ref.name); - missing.push(item); eventItems.push(item); continue; } items.push(skill); eventItems.push({ name: skill.name, path: skill.path, aggregateAs: skill.aggregateAs, status: "materialized", manifestSha256: skill.manifestSha256, manifestBytes: skill.manifestBytes, sourceBundle: skill.sourceBundle, summary: skill.summary, valuesPrinted: false }); } - if (missing.length > 0) { - throw new AgentRunError("required-skill-unavailable", `required resource skill ${missingNames.join(", ")} is not materialized`, { - httpStatus: 400, - details: { - required: refs.map((ref) => ref.name), - missing, - available: skills.map((skill) => ({ name: skill.name, path: skill.path, manifestSha256: skill.manifestSha256, manifestBytes: skill.manifestBytes, sourceBundle: skill.sourceBundle, valuesPrinted: false })), - valuesPrinted: false, - }, - }); - } + const materializedNames = items.map((item) => item.name); + const warning: ResourceSkillWarning | null = missingNames.length > 0 ? { + code: "required-skill-unavailable", + severity: "warning", + scope: "resource-bundle.requiredSkills", + message: `resource skill ${missingNames.join(", ")} is not materialized; continuing with available skills`, + missingNames, + materializedNames, + blocking: false, + valuesPrinted: false, + } : null; return { items, event: { count: refs.length, materializedCount: items.length, - names: items.map((item) => item.name), + missingCount: missingNames.length, + names: materializedNames, + materializedNames, + missingNames, items: eventItems, valuesPrinted: false, }, + warnings: { count: warning ? 1 : 0, items: warning ? [warning] : [], valuesPrinted: false }, }; } diff --git a/src/runner/run-once.ts b/src/runner/run-once.ts index cd66c10..763b28d 100644 --- a/src/runner/run-once.ts +++ b/src/runner/run-once.ts @@ -205,7 +205,7 @@ function requiresBundledWorkReadyTools(run: RunRecord): boolean { const toolCredentials = run.executionPolicy.secretScope.toolCredentials ?? []; if (toolCredentials.some((item) => item.tool === "unidesk-ssh" || item.tool === "github")) return true; const requiredSkills = run.resourceBundleRef?.requiredSkills ?? []; - return requiredSkills.some((item) => item.name === "unidesk-trans" || item.name === "unidesk-gh" || item.name === "unidesk-code-queue" || item.name === "unidesk-cicd" || item.name === "dad-dev"); + return requiredSkills.some((item) => item.name === "unidesk-trans" || item.name === "unidesk-gh" || item.name === "unidesk-agentrun" || item.name === "unidesk-cicd" || item.name === "dad-dev"); } function withResourceAssembly(options: RunnerOnceOptions, resourceEnv: NodeJS.ProcessEnv | undefined, initialPrompt: InitialPromptAssembly | undefined): RunnerOnceOptions { diff --git a/src/selftest/cases/50-hwlab-manual-dispatch.ts b/src/selftest/cases/50-hwlab-manual-dispatch.ts index e06cfe6..010ab06 100644 --- a/src/selftest/cases/50-hwlab-manual-dispatch.ts +++ b/src/selftest/cases/50-hwlab-manual-dispatch.ts @@ -232,19 +232,39 @@ process.exit(1); assert.equal(missingPromptResult.terminalStatus, "blocked"); assert.equal(missingPromptResult.failureKind, "prompt-unavailable"); - const missingSkillRun = await createHwlabRun(client, context, { ...bundle, bundles: [{ name: "hwlab-tools", subpath: "tools", targetPath: "tools" }], requiredSkills: [{ name: "dad-dev" }] }, "hwlab-session-missing-skill", "missing required skill", "hwlab-command-missing-skill"); + const missingSkillRun = await createHwlabRun(client, context, { ...bundle, requiredSkills: [{ name: "dad-dev" }, { name: "unavailable-skill" }] }, "hwlab-session-missing-skill", "continue with available required skill", "hwlab-command-missing-skill"); const missingSkillResult = await runOnce({ managerUrl: server.baseUrl, runId: missingSkillRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-missing-skill") }, oneShot: true }) as JsonRecord; - assert.equal(missingSkillResult.terminalStatus, "blocked"); - assert.equal(missingSkillResult.failureKind, "required-skill-unavailable"); + assert.equal(missingSkillResult.terminalStatus, "completed"); + assert.equal(missingSkillResult.failureKind, null); + const missingSkillEvents = await client.get(`/api/v1/runs/${missingSkillRun.runId}/events?afterSeq=0&limit=100`) as { items?: Array<{ type?: string; payload?: JsonRecord }> }; + const missingSkillEvent = (missingSkillEvents.items ?? []).find((event) => event.type === "backend_status" && event.payload?.phase === "resource-bundle-materialized")?.payload as JsonRecord; + assert.equal((((missingSkillEvent.warnings as JsonRecord).items as JsonRecord[])[0]?.severity), "warning"); + assert.deepEqual((((missingSkillEvent.requiredSkills as JsonRecord).missingNames)), ["unavailable-skill"]); const missingSkillEnvelope = await client.get(`/api/v1/runs/${missingSkillRun.runId}/commands/${missingSkillRun.commandId}/result`) as JsonRecord; - assert.equal(missingSkillEnvelope.terminalStatus, "blocked"); - assert.equal(missingSkillEnvelope.terminalSource, "terminal_status-event"); - assert.equal(missingSkillEnvelope.failureKind, "required-skill-unavailable"); - assert.deepEqual((((missingSkillEnvelope.resourceBundleRef as JsonRecord).requiredSkills as JsonRecord).names), ["dad-dev"]); - const missingSkillBlocker = missingSkillEnvelope.blocker as JsonRecord; - assert.equal(missingSkillBlocker.failureKind, "required-skill-unavailable"); - assert.deepEqual(((missingSkillBlocker.details as JsonRecord).required as string[]), ["dad-dev"]); - assert.deepEqual((((missingSkillBlocker.details as JsonRecord).missing as JsonRecord[]).map((item) => item.name)), ["dad-dev"]); + assert.equal(missingSkillEnvelope.terminalStatus, "completed"); + assert.equal(missingSkillEnvelope.reply, "fake codex stdio reply"); + assert.equal(missingSkillEnvelope.failureKind, null); + assert.deepEqual((((missingSkillEnvelope.resourceBundleRef as JsonRecord).requiredSkills as JsonRecord).names), ["dad-dev", "unavailable-skill"]); + const missingSkillMaterialized = ((missingSkillEnvelope.resourceBundleRef as JsonRecord).materialized as JsonRecord); + const missingRequiredSkills = missingSkillMaterialized.requiredSkills as JsonRecord; + assert.deepEqual(missingRequiredSkills.materializedNames, ["dad-dev"]); + assert.deepEqual(missingRequiredSkills.missingNames, ["unavailable-skill"]); + assert.equal(missingRequiredSkills.materializedCount, 1); + assert.equal(missingRequiredSkills.missingCount, 1); + const missingSkillWarnings = missingSkillMaterialized.warnings as JsonRecord; + assert.equal(missingSkillWarnings.count, 1); + assert.deepEqual((missingSkillWarnings.items as JsonRecord[])[0], { + code: "required-skill-unavailable", + severity: "warning", + scope: "resource-bundle.requiredSkills", + message: "resource skill unavailable-skill is not materialized; continuing with available skills", + missingNames: ["unavailable-skill"], + materializedNames: ["dad-dev"], + blocking: false, + valuesPrinted: false, + }); + assert.equal(((missingRequiredSkills.items as JsonRecord[]).find((item) => item.name === "dad-dev"))?.status, "materialized"); + assert.equal(((missingRequiredSkills.items as JsonRecord[]).find((item) => item.name === "unavailable-skill"))?.severity, "warning"); assertNoSecretLeak(missingSkillEnvelope); const resumed = await createHwlabRun(client, context, bundle, "hwlab-session-resume", "hello resumed", "hwlab-command-session-resumed"); @@ -443,7 +463,7 @@ process.exit(1); const runningResult = await running; assert.equal(runningResult.terminalStatus, "cancelled"); - return { name: "hwlab-manual-dispatch", tests: ["runner-job-idempotency", "pending-cancel", "result-envelope", "session-ref-resume", "resource-gitbundle-materialization", "gitbundle-ref-resolution", "gitbundle-tools-path", "gitbundle-skill-dir-assembly", "resource-prompt-required-blocker", "resource-required-skill-blocker", "same-run-runner-multiturn", "running-steer", "running-steer-user-message-target-trace", "backend-retry-replays-durable-steers", "backend-replay-barrier-fast-terminal", "backend-replay-rpc-failure-retries", "rebuilt-runner-resumes-acknowledged-turn", "no-event-watchdog-after-tool", "tool-output-hard-timeout", "running-cancel"] }; + return { name: "hwlab-manual-dispatch", tests: ["runner-job-idempotency", "pending-cancel", "result-envelope", "session-ref-resume", "resource-gitbundle-materialization", "gitbundle-ref-resolution", "gitbundle-tools-path", "gitbundle-skill-dir-assembly", "resource-prompt-required-blocker", "resource-required-skill-warning", "same-run-runner-multiturn", "running-steer", "running-steer-user-message-target-trace", "backend-retry-replays-durable-steers", "backend-replay-barrier-fast-terminal", "backend-replay-rpc-failure-retries", "rebuilt-runner-resumes-acknowledged-turn", "no-event-watchdog-after-tool", "tool-output-hard-timeout", "running-cancel"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } diff --git a/src/selftest/cases/81-artificer-nonrecursive-prompt.ts b/src/selftest/cases/81-artificer-nonrecursive-prompt.ts index 8e35e28..a114852 100644 --- a/src/selftest/cases/81-artificer-nonrecursive-prompt.ts +++ b/src/selftest/cases/81-artificer-nonrecursive-prompt.ts @@ -41,6 +41,8 @@ const selfTest: SelfTestCase = async (context) => { assert.match(effectivePrompt, /runner 容器本地执行冒充 Target 原入口验证/u); assert.match(effectivePrompt, /不得把 NC01、目标绝对路径或第二条执行路径硬编码/u); assert.ok(requiredSkills.some((item) => item.name === "unidesk-trans"), "Artificer must require the unidesk-trans skill"); + assert.ok(requiredSkills.some((item) => item.name === "unidesk-agentrun"), "Artificer must require the canonical unidesk-agentrun skill"); + assert.equal(requiredSkills.some((item) => item.name === "unidesk-code-queue"), false, "Artificer must not retain the retired unidesk-code-queue skill name"); assert.ok(bundles.some((item) => item.name === "agentrun-runner-tools" && item.targetPath === "tools"), "Artificer must materialize the bundled trans executable"); const unideskSsh = toolCredentials.find((item) => item.tool === "unidesk-ssh" && item.purpose === "ssh-passthrough"); assert.ok(unideskSsh, "Artificer must bind the unidesk-ssh route credential"); @@ -49,7 +51,7 @@ const selfTest: SelfTestCase = async (context) => { assert.equal(effectivePrompt.endsWith(userPrompt), true, "rendered prompt must preserve the complete user business prompt"); assert.equal(effectivePrompt.split(userPrompt).length - 1, 1, "rendered prompt must include the user business prompt exactly once"); - return { name: "artificer-nonrecursive-prompt", tests: ["artificer-yaml-nonrecursive-prompt-render", "artificer-target-trans-prompt-render", "artificer-trans-skill-tool-and-secretref-bound", "artificer-user-prompt-preserved"] }; + return { name: "artificer-nonrecursive-prompt", tests: ["artificer-yaml-nonrecursive-prompt-render", "artificer-target-trans-prompt-render", "artificer-canonical-agentrun-skill-render", "artificer-trans-skill-tool-and-secretref-bound", "artificer-user-prompt-preserved"] }; }; export default selfTest; diff --git a/src/selftest/cases/90-runner-image-tools.ts b/src/selftest/cases/90-runner-image-tools.ts index 3109ebd..247ef89 100644 --- a/src/selftest/cases/90-runner-image-tools.ts +++ b/src/selftest/cases/90-runner-image-tools.ts @@ -77,6 +77,12 @@ const selfTest: SelfTestCase = async (context) => { const imageSmoke = await smokeImageWorkReadyCapabilities(smokeEnv); assert.equal(((imageSmoke.smoke as { ok?: unknown }).ok), true); assert.equal(imageSmoke.valuesPrinted, false); + const missingImageToolBin = path.join(context.tmp, "missing-work-ready-bin"); + await mkdir(missingImageToolBin, { recursive: true }); + await assert.rejects( + () => smokeImageWorkReadyCapabilities({ PATH: missingImageToolBin, AGENTRUN_SELFTEST_WORK_READY_BIN_PATH: " " }), + (error) => error instanceof Error && /runner image is not work-ready; missing required image tools/u.test(error.message), + ); const bundleSmoke = await smokeBundledWorkReadyCapabilities(smokeEnv); assert.equal(((bundleSmoke.smoke as { ok?: unknown }).ok), true); assert.equal(bundleSmoke.valuesPrinted, false); @@ -88,7 +94,7 @@ const selfTest: SelfTestCase = async (context) => { assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111"), true); assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr:self-test"), false); - return { name: "90-runner-image-tools", tests: ["runner image installs required CLI tools", "runner image build verifies work-ready tools", "gitbundle tran delegates to the primary UniDesk workspace", "runner apply-patch helper is bundled", "runner agentrun-git helper is bundled", "work-ready smoke runs without printing secrets", "aipod imageRef validates env image source identity"] }; + return { name: "90-runner-image-tools", tests: ["runner image installs required CLI tools", "runner image build verifies work-ready tools", "gitbundle tran delegates to the primary UniDesk workspace", "runner apply-patch helper is bundled", "runner agentrun-git helper is bundled", "work-ready smoke runs without printing secrets", "runner image missing tools fail closed", "aipod imageRef validates env image source identity"] }; }; async function createFakeToolBin(dir: string): Promise {