Skip to content

Commit f295d2c

Browse files
committed
Normalize native agent task failures
1 parent 9bddc16 commit f295d2c

3 files changed

Lines changed: 130 additions & 10 deletions

File tree

.github/scripts/run-agent-task/execute-native-agent-task.mjs

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { rmSync } from "node:fs"
22
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"
3-
import { join, resolve } from "node:path"
3+
import { join, relative, resolve } from "node:path"
44
import { spawn } from "node:child_process"
55
import { materializeExternalNativePackage, materializeRuntimeSources, normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy, validateRuntimeSourceModel } from "./materialize-external-native-package.mjs"
66
import { readNativeResult } from "./native-result-file.mjs"
@@ -14,6 +14,8 @@ const outputPath = process.env.GITHUB_OUTPUT
1414
const MAX_CAPTURE_BYTES = 32768
1515
const MAX_OUTPUT_CHARS = 8192
1616
const secretValues = ["OPENAI_API_KEY", "MODEL_PROVIDER_SECRET_1", "MODEL_PROVIDER_SECRET_2", "MODEL_PROVIDER_SECRET_3", "MODEL_PROVIDER_SECRET_4", "MODEL_PROVIDER_SECRET_5", "GITHUB_TOKEN", "GH_TOKEN", "ACCESS_TOKEN", "EXTERNAL_PACKAGE_SOURCE_POLICY"].map((name) => process.env[name]).filter(Boolean)
17+
let privateRuntimeSourceRoot = ""
18+
let privateRuntimeSourceRootForSanitization = ""
1719
function redact(value) {
1820
if (typeof value === "string") return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value)
1921
if (Array.isArray(value)) return value.map(redact)
@@ -173,6 +175,45 @@ function projections(value, runtimeResult) {
173175
return output
174176
}
175177

178+
function workflowPath(path) {
179+
const relativePath = relative(workspace, resolve(path))
180+
return relativePath && !relativePath.startsWith("..") ? relativePath.replaceAll("\\", "/") : ".codebox/agent-task-workflow-result.json"
181+
}
182+
183+
function failureClassification(error) {
184+
const message = error instanceof Error ? error.message : String(error)
185+
const code = typeof error?.code === "string" && error.code ? error.code : ""
186+
if (code.includes(".policy")) return { code, classification: "policy" }
187+
if (code && !/materializ|fetch|download|archive|entrypoint|git failed|spawn git/i.test(message)) return { code, classification: "native-agent-task" }
188+
if (/policy|authorized|allowlisted|allowed_repos|ACCESS_TOKEN/i.test(message)) return { code: "wp-codebox.agent-task.policy", classification: "policy" }
189+
if (/materializ|fetch|download|archive|entrypoint|git failed|spawn git/i.test(message)) return { code: "wp-codebox.agent-task.materialization", classification: "materialization" }
190+
if (/approval|publication|pull request/i.test(message)) return { code: "wp-codebox.agent-task.approval", classification: "approval" }
191+
if (/projection/i.test(message)) return { code: "wp-codebox.agent-task.output-projection", classification: "output-projection" }
192+
return { code: "wp-codebox.agent-task.execution", classification: "execution" }
193+
}
194+
195+
async function writeNormalizedFailure(error, request = {}) {
196+
const resultPath = join(workspace, ".codebox", "agent-task-workflow-result.json")
197+
const failure = failureClassification(error)
198+
const message = bounded(error instanceof Error ? error.message : String(error), MAX_OUTPUT_CHARS)
199+
const accessError = failure.classification === "policy" && /allowed_repos|ACCESS_TOKEN|GitHub token|Caller repository|authorized/i.test(message)
200+
const result = {
201+
schema: "wp-codebox/agent-task-workflow-result/v1",
202+
run_id: `${record(request).workload?.id || "agent-task"}-${process.env.GITHUB_RUN_ID || "local"}`.replace(/[^A-Za-z0-9._-]+/g, "-"),
203+
status: "failed",
204+
success: false,
205+
request_path: workflowPath(requestPath),
206+
failure: { ...failure, message },
207+
...(accessError ? { access: { authorized: false, error: message } } : {}),
208+
}
209+
await mkdir(join(workspace, ".codebox"), { recursive: true })
210+
const sanitized = sanitizeRuntimeSourceValue(redact(result), privateRuntimeSourceRootForSanitization)
211+
assertNoRuntimeSourcePaths(sanitized, privateRuntimeSourceRootForSanitization)
212+
await writeFile(resultPath, `${JSON.stringify(sanitized, null, 2)}\n`)
213+
await output("job_status", "failed")
214+
await output("result_path", ".codebox/agent-task-workflow-result.json")
215+
}
216+
176217
async function redactArtifactFiles(directory) {
177218
const { readdir, stat } = await import("node:fs/promises")
178219
for (const entry of await readdir(directory, { withFileTypes: true })) {
@@ -189,6 +230,7 @@ async function redactArtifactFiles(directory) {
189230
}
190231
}
191232

233+
async function executeNativeAgentTask() {
192234
const request = JSON.parse(await readFile(requestPath, "utf8"))
193235
const verificationCommands = commandEntries(request.verification_commands, "verification_commands")
194236
const driftChecks = commandEntries(request.drift_checks, "drift_checks")
@@ -202,8 +244,6 @@ const runtimeInputPath = join(workspace, ".codebox", "native-agent-task-input.js
202244
const resultPath = join(workspace, ".codebox", "agent-task-workflow-result.json")
203245
const controlledCodeboxPath = resolve(requestPath, "..")
204246
const nativeResultPath = join(controlledCodeboxPath, "native-agent-task-result.json")
205-
let privateRuntimeSourceRoot = ""
206-
let privateRuntimeSourceRootForSanitization = ""
207247
let cleaningPrivateRuntimeSources = false
208248
async function cleanupPrivateRuntimeSources() {
209249
if (cleaningPrivateRuntimeSources || !privateRuntimeSourceRoot) return
@@ -232,11 +272,9 @@ await mkdir(artifactsPath, { recursive: true })
232272

233273
const accessError = accessFailure(request)
234274
if (accessError) {
235-
const result = { schema: "wp-codebox/agent-task-workflow-result/v1", run_id: runId, status: "failed", success: false, request_path: requestPath, access: { authorized: false, error: accessError } }
236-
await writeFile(resultPath, `${JSON.stringify(result, null, 2)}\n`)
237-
await output("job_status", "failed")
238-
process.exitCode = 1
239-
process.exit()
275+
const error = new Error(accessError)
276+
error.code = "wp-codebox.agent-task.policy"
277+
throw error
240278
}
241279

242280
const materializedPackage = request.run_agent && !request.dry_run
@@ -404,3 +442,20 @@ await output("declared_artifacts_json", result.artifacts.declarations)
404442
await output("result_path", ".codebox/agent-task-workflow-result.json")
405443

406444
if (!success) process.exitCode = 1
445+
}
446+
447+
try {
448+
await executeNativeAgentTask()
449+
} catch (error) {
450+
try {
451+
await writeNormalizedFailure(error)
452+
} finally {
453+
if (privateRuntimeSourceRoot) {
454+
const root = privateRuntimeSourceRoot
455+
privateRuntimeSourceRoot = ""
456+
await rm(root, { recursive: true, force: true })
457+
}
458+
}
459+
console.error(bounded(error instanceof Error ? error.message : String(error), MAX_OUTPUT_CHARS))
460+
process.exitCode = 1
461+
}

.github/scripts/run-agent-task/prepare-agent-task-upload.mjs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,12 @@ async function finalScan(directory) {
159159
}
160160
}
161161

162-
const request = JSON.parse(await readFile(requestPath, "utf8"))
162+
const parseJsonOrEmpty = (text) => {
163+
try { return JSON.parse(text) } catch { return {} }
164+
}
165+
const request = parseJsonOrEmpty(await readFile(requestPath, "utf8").catch(() => "{}"))
163166
const resultSource = join(workspace, ".codebox", "agent-task-workflow-result.json")
164-
const result = JSON.parse(await readFile(resultSource, "utf8").catch(() => "{}"))
167+
const result = parseJsonOrEmpty(await readFile(resultSource, "utf8").catch(() => "{}"))
165168
const declaredPaths = new Set(declaredArtifactPaths(result, declarations(request)))
166169

167170
await rm(uploadPath, { recursive: true, force: true })

tests/agent-task-reusable-workflow.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,68 @@ await assert.rejects(execFileAsync("node", [new URL("../.github/scripts/run-agen
291291
},
292292
}), /verification_commands\[0\]\.command/)
293293

294+
await writeFile(requestPath, "{\n")
295+
await assert.rejects(execFileAsync("node", [executeNativeAgentTask], {
296+
cwd: tmp,
297+
env: {
298+
...process.env,
299+
GITHUB_OUTPUT: outputPath,
300+
AGENT_TASK_REQUEST_PATH: requestPath,
301+
AGENT_TASK_WORKSPACE: tmp,
302+
WP_CODEBOX_WORKFLOW_ROOT: new URL("..", import.meta.url).pathname,
303+
EXTERNAL_PACKAGE_SOURCE_POLICY: '{"version":1,"repositories":{"automattic/example-agent-packages":["packages/example-agent.agent.json"]}}',
304+
},
305+
}))
306+
const malformedParseResult = JSON.parse(await readFile(resultPath, "utf8"))
307+
assert.equal(malformedParseResult.failure.classification, "execution")
308+
await execFileAsync("node", [new URL("../.github/scripts/run-agent-task/prepare-agent-task-upload.mjs", import.meta.url).pathname], {
309+
cwd: tmp,
310+
env: { ...process.env, AGENT_TASK_WORKSPACE: tmp, AGENT_TASK_REQUEST_PATH: requestPath },
311+
})
312+
assert.ok(await readFile(join(tmp, ".codebox", "agent-task-upload", ".codebox", "agent-task-workflow-result.json"), "utf8"))
313+
314+
// Every lifecycle failure publishes the same safe review envelope, including
315+
// failures before artifact materialization has created an artifact directory.
316+
const assertEarlyFailureUpload = async (name: string, environment: Record<string, string>, expectedClassification: string) => {
317+
const failureRoot = await mkdtemp(join(tmpdir(), `wp-codebox-agent-task-${name}-`))
318+
const failureCodebox = join(failureRoot, ".codebox")
319+
const failureRequestPath = join(failureCodebox, "agent-task-request.json")
320+
await mkdir(failureCodebox, { recursive: true })
321+
await writeFile(failureRequestPath, `${JSON.stringify({ ...request, run_agent: name === "materialization", dry_run: false }, null, 2)}\n`)
322+
await assert.rejects(execFileAsync(process.execPath, [executeNativeAgentTask], {
323+
cwd: failureRoot,
324+
env: {
325+
...process.env,
326+
GITHUB_OUTPUT: join(failureRoot, "github-output.txt"),
327+
AGENT_TASK_REQUEST_PATH: failureRequestPath,
328+
AGENT_TASK_WORKSPACE: failureRoot,
329+
WP_CODEBOX_WORKFLOW_ROOT: new URL("..", import.meta.url).pathname,
330+
EXTERNAL_PACKAGE_SOURCE_POLICY: '{"version":1,"repositories":{"automattic/example-agent-packages":["packages/example-agent.agent.json"]}}',
331+
GITHUB_TOKEN: "test-caller-token",
332+
...environment,
333+
},
334+
}))
335+
const failureResultPath = join(failureCodebox, "agent-task-workflow-result.json")
336+
const failureResult = JSON.parse(await readFile(failureResultPath, "utf8"))
337+
assert.equal(failureResult.status, "failed")
338+
assert.equal(failureResult.success, false)
339+
assert.equal(failureResult.failure.classification, expectedClassification)
340+
assert.equal(await readFile(join(failureCodebox, "agent-task-artifacts", "exclusions.json"), "utf8").catch(() => "missing"), "missing", "Early failures must not create source or artifact trees")
341+
await execFileAsync("node", [new URL("../.github/scripts/run-agent-task/prepare-agent-task-upload.mjs", import.meta.url).pathname], {
342+
cwd: failureRoot,
343+
env: { ...process.env, AGENT_TASK_WORKSPACE: failureRoot, AGENT_TASK_REQUEST_PATH: failureRequestPath, AGENT_TASK_UPLOAD_PATH: join(failureCodebox, "agent-task-upload") },
344+
})
345+
const uploadRoot = join(failureCodebox, "agent-task-upload", ".codebox")
346+
assert.deepEqual(JSON.parse(await readFile(join(uploadRoot, "agent-task-workflow-result.json"), "utf8")).failure, failureResult.failure)
347+
assert.ok(await readFile(join(uploadRoot, "agent-task-request.json"), "utf8"))
348+
assert.ok(await readFile(join(uploadRoot, "agent-task-artifacts", "exclusions.json"), "utf8"))
349+
assert.equal((await readdir(uploadRoot, { recursive: true })).some((path) => /prepared-|source-package|\.php$|\.m?js$/i.test(path)), false, "Failure uploads must exclude sources")
350+
}
351+
352+
await assertEarlyFailureUpload("source-policy", { EXTERNAL_PACKAGE_SOURCE_POLICY: "{}" }, "policy")
353+
await assertEarlyFailureUpload("materialization", { PATH: "" }, "materialization")
354+
await assertEarlyFailureUpload("approval", { GITHUB_TOKEN: "", EXPLICIT_ACCESS_TOKEN_CONFIGURED: "false" }, "policy")
355+
294356
// A serialized task request cannot stand in for a native run. Exercise the real
295357
// package-staging and canonical agents/chat harnesses instead of fabricating CLI
296358
// JSON or publication output in this workflow test.

0 commit comments

Comments
 (0)