Skip to content

Commit 897faeb

Browse files
authored
Fix agent task workspace change normalization (#1790)
* Fix agent task workspace change normalization * Harden runner workspace evidence and uploads * Centralize agent task cleanup into one signal-safe coordinator The executor previously carried four independent cleanup sites for the private runtime source root and the runner workspace seed snapshot: mid-run awaits, an inline catch-path duplicate, a partial exit hook, and signal handlers that cleaned only one of the two resources and exited 128 for every signal. Replace them with a single idempotent module-level coordinator that claims both roots exactly once and chains repeat invocations onto the in-flight cleanup instead of racing it. The top-level normal and failure paths route through one finally; SIGINT/SIGTERM/SIGHUP await the same coordinator before exiting with the conventional 128+signum code; the process exit hook keeps a bounded synchronous best-effort fallback for abrupt exits. Error reporting and redaction are preserved, and stderr messages are now redacted while the private root is still known. A deterministic interruption test starts the executor in a child process, pauses it immediately after seed snapshot creation through a narrowly scoped NODE_ENV=test marker-file hook that is inert in production, delivers each signal, and asserts the temp seed snapshot directory is removed, the conventional exit code is set, and no runner workspace content survives. A normal run in the same harness proves routine cleanup still passes.
1 parent cc0fd5e commit 897faeb

15 files changed

Lines changed: 716 additions & 70 deletions

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

Lines changed: 117 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { rmSync } from "node:fs"
22
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"
3-
import { join, relative, resolve } from "node:path"
3+
import { isAbsolute, join, relative, resolve } from "node:path"
44
import { pathToFileURL } from "node:url"
55
import { spawn } from "node:child_process"
6-
import { materializeExternalNativePackage, materializeRuntimeSources, normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy, validateRuntimeSourceModel } from "./materialize-external-native-package.mjs"
6+
import { canonicalExternalNativeAgentIdentity, materializeExternalNativePackage, materializeRuntimeSources, normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy, sha256BytesV1, validateRuntimeSourceModel } from "./materialize-external-native-package.mjs"
77
import { readNativeResult } from "./native-result-file.mjs"
88
import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson, sanitizeRuntimeSourceText, sanitizeRuntimeSourceValue } from "./runtime-source-sanitizer.mjs"
99
import { publishRunnerWorkspace } from "./runner-workspace-publisher.mjs"
10+
import { createRunnerWorkspaceSeedSnapshot, RUNNER_WORKSPACE_SEED_EXCLUDES } from "./runner-workspace-seed-snapshot.mjs"
1011

1112
const requestPath = process.env.AGENT_TASK_REQUEST_PATH || ".codebox/agent-task-request.json"
1213
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
@@ -18,6 +19,42 @@ const MAX_OUTPUT_CHARS = 8192
1819
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)
1920
let privateRuntimeSourceRoot = ""
2021
let privateRuntimeSourceRootForSanitization = ""
22+
let runnerWorkspaceSeedSnapshot
23+
24+
const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }
25+
let materializedSourceCleanup
26+
function claimMaterializedSourcePaths() {
27+
const paths = []
28+
if (runnerWorkspaceSeedSnapshot) {
29+
paths.push(runnerWorkspaceSeedSnapshot.source)
30+
runnerWorkspaceSeedSnapshot = undefined
31+
}
32+
if (privateRuntimeSourceRoot) {
33+
paths.push(privateRuntimeSourceRoot)
34+
privateRuntimeSourceRoot = ""
35+
}
36+
return paths
37+
}
38+
// Single idempotent cleanup coordinator for the private runtime source
39+
// materialization root and the runner workspace seed snapshot. Every
40+
// completion path (normal, failure, signal) awaits this coordinator; repeat
41+
// invocations chain onto the in-flight cleanup instead of racing it.
42+
function cleanupMaterializedSources() {
43+
materializedSourceCleanup = (materializedSourceCleanup ?? Promise.resolve())
44+
.then(() => Promise.all(claimMaterializedSourcePaths().map((path) => rm(path, { recursive: true, force: true }))))
45+
return materializedSourceCleanup
46+
}
47+
process.once("exit", () => {
48+
// Bounded synchronous best-effort fallback for abrupt exits; on every
49+
// awaited path the coordinator has already claimed these roots.
50+
for (const path of claimMaterializedSourcePaths()) {
51+
try { rmSync(path, { recursive: true, force: true, maxRetries: 0 }) } catch { /* best effort */ }
52+
}
53+
})
54+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
55+
process.once(signal, () => { cleanupMaterializedSources().finally(() => process.exit(SIGNAL_EXIT_CODES[signal])) })
56+
}
57+
2158
function redact(value) {
2259
if (typeof value === "string") return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value)
2360
if (Array.isArray(value)) return value.map(redact)
@@ -197,6 +234,55 @@ function requiredArtifacts(declarations) {
197234
})))
198235
}
199236

237+
async function testRuntimeFixtures(externalPackageSource) {
238+
if (process.env.NODE_ENV !== "test") return {}
239+
const packagePath = string(process.env.WP_CODEBOX_TEST_EXTERNAL_PACKAGE_PATH)
240+
const runtimeSourceInputs = string(process.env.WP_CODEBOX_TEST_RUNTIME_SOURCE_INPUTS)
241+
if (!packagePath && !runtimeSourceInputs) return {}
242+
const fixtures = {}
243+
if (packagePath) {
244+
const bytes = await readFile(packagePath)
245+
if (sha256BytesV1(bytes) !== externalPackageSource.digest) throw new Error("Test external package fixture digest does not match the request.")
246+
fixtures.materializedPackage = { bytes, descriptor: externalPackageSource, identity: canonicalExternalNativeAgentIdentity(bytes) }
247+
}
248+
if (runtimeSourceInputs) {
249+
try {
250+
fixtures.runtimeSourceInputs = JSON.parse(runtimeSourceInputs)
251+
} catch {
252+
throw new Error("WP_CODEBOX_TEST_RUNTIME_SOURCE_INPUTS must be valid JSON.")
253+
}
254+
}
255+
return fixtures
256+
}
257+
258+
// Test-only interruption hook: inert in production (requires NODE_ENV=test and
259+
// an explicit marker path). Publishes the seed snapshot location, then holds a
260+
// bounded window so a harness can deliver a termination signal.
261+
async function testPauseAfterSeedSnapshot(seedSnapshot) {
262+
if (process.env.NODE_ENV !== "test") return
263+
const markerPath = string(process.env.WP_CODEBOX_TEST_SEED_SNAPSHOT_PAUSE_FILE)
264+
if (!markerPath) return
265+
await writeFile(markerPath, `${JSON.stringify({ schema: "wp-codebox/test-seed-snapshot-pause/v1", seed_snapshot_source: seedSnapshot?.source ?? "" })}\n`)
266+
await new Promise((resolvePause) => setTimeout(resolvePause, 120_000))
267+
}
268+
269+
function runtimeSourceFixtureRoot(value) {
270+
const paths = []
271+
const collect = (entry) => {
272+
if (typeof entry === "string") {
273+
if (isAbsolute(entry)) paths.push(resolve(entry))
274+
return
275+
}
276+
if (Array.isArray(entry)) return entry.forEach(collect)
277+
if (entry && typeof entry === "object") Object.values(entry).forEach(collect)
278+
}
279+
collect(value)
280+
if (paths.length === 0) return ""
281+
282+
const shared = paths.map((path) => path.split("/").filter(Boolean)).reduce((prefix, parts) => prefix.filter((part, index) => parts[index] === part))
283+
return shared.length > 0 ? `/${shared.join("/")}` : ""
284+
}
285+
200286
function workflowPath(path) {
201287
const relativePath = relative(workspace, resolve(path))
202288
return relativePath && !relativePath.startsWith("..") ? relativePath.replaceAll("\\", "/") : ".codebox/agent-task-workflow-result.json"
@@ -261,23 +347,12 @@ const externalPackagePolicy = parseExternalPackageSourcePolicy(string(process.en
261347
const externalPackageSource = normalizeExternalPackageSource(request.external_package_source, externalPackagePolicy)
262348
const runtimeSources = normalizeRuntimeSources(request.runtime_sources ?? [], externalPackagePolicy)
263349
const requestedModel = validateRuntimeSourceModel(request.model, runtimeSources)
350+
const writablePaths = String(request.writable_paths || "").split(",").map((value) => value.trim()).filter(Boolean)
264351
const artifactsPath = join(workspace, ".codebox", "agent-task-artifacts")
265352
const runtimeInputPath = join(workspace, ".codebox", "native-agent-task-input.json")
266353
const resultPath = join(workspace, ".codebox", "agent-task-workflow-result.json")
267354
const controlledCodeboxPath = resolve(requestPath, "..")
268-
const nativeResultPath = join(controlledCodeboxPath, "native-agent-task-result.json")
269-
let cleaningPrivateRuntimeSources = false
270-
async function cleanupPrivateRuntimeSources() {
271-
if (cleaningPrivateRuntimeSources || !privateRuntimeSourceRoot) return
272-
cleaningPrivateRuntimeSources = true
273-
const root = privateRuntimeSourceRoot
274-
privateRuntimeSourceRoot = ""
275-
await rm(root, { recursive: true, force: true })
276-
}
277-
process.once("exit", () => { if (privateRuntimeSourceRoot) rmSync(privateRuntimeSourceRoot, { recursive: true, force: true }) })
278-
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
279-
process.once(signal, () => { cleanupPrivateRuntimeSources().finally(() => process.exit(128)) })
280-
}
355+
const nativeResultPath = join(controlledCodeboxPath, "native-agent-task-result.json")
281356
const runnerWorkspaceTools = [
282357
"workspace_read", "workspace_ls", "workspace_grep", "workspace_write", "workspace_edit", "workspace_apply_patch",
283358
"workspace_show", "workspace_git_status", "workspace_git_diff",
@@ -291,17 +366,21 @@ function runtimeMetadataForExecutionLocation(executionLocation) {
291366

292367
await mkdir(artifactsPath, { recursive: true })
293368

294-
const accessError = accessFailure(request)
369+
const accessError = accessFailure(request)
295370
if (accessError) {
296371
const error = new Error(accessError)
297372
error.code = "wp-codebox.agent-task.policy"
298-
throw error
299-
}
373+
throw error
374+
}
375+
376+
if (request.runner_workspace?.enabled) runnerWorkspaceSeedSnapshot = await createRunnerWorkspaceSeedSnapshot(workspace)
377+
await testPauseAfterSeedSnapshot(runnerWorkspaceSeedSnapshot)
300378

301-
const skipMaterialization = process.env.NODE_ENV === "test" && process.env.WP_CODEBOX_TEST_SKIP_MATERIALIZATION === "true"
379+
const testFixtures = await testRuntimeFixtures(externalPackageSource)
380+
const skipMaterialization = process.env.NODE_ENV === "test" && (process.env.WP_CODEBOX_TEST_SKIP_MATERIALIZATION === "true" || Boolean(testFixtures.materializedPackage || testFixtures.runtimeSourceInputs))
302381
const materializedPackage = request.run_agent && !request.dry_run && !skipMaterialization
303382
? await materializeExternalNativePackage(externalPackageSource, { policy: externalPackagePolicy })
304-
: undefined
383+
: testFixtures.materializedPackage
305384
const materializedRuntimeSources = request.run_agent && !request.dry_run && !skipMaterialization
306385
? await materializeRuntimeSources(runtimeSources, { policy: externalPackagePolicy, forbiddenRoots: [workspace, artifactsPath] })
307386
: undefined
@@ -311,7 +390,8 @@ await output("runtime_source_root", privateRuntimeSourceRoot)
311390
const runtimeSourceInputs = (materializedRuntimeSources?.lowered ?? []).reduce((input, lowered) => {
312391
for (const [key, entries] of Object.entries(lowered)) input[key] = [...(input[key] ?? []), ...entries]
313392
return input
314-
}, {})
393+
}, testFixtures.runtimeSourceInputs ?? {})
394+
const runtimeSourceOutputRoots = [privateRuntimeSourceRoot, runtimeSourceFixtureRoot(testFixtures.runtimeSourceInputs)]
315395
// Runtime source preparation must remain beside the private checkout, never in
316396
// the target artifact directory that is collected after the run.
317397
const privatePreparationRoot = privateRuntimeSourceRoot ? join(privateRuntimeSourceRoot, "prepared-runtime-sources") : ""
@@ -358,27 +438,27 @@ const taskInput = {
358438
model: requestedModel.name,
359439
runner_workspace: { ...record(request.runner_workspace), allowed_repos: request.access.allowed_repos },
360440
target_repo: request.target_repo,
361-
writable_paths: request.writable_paths,
362-
runner_workspace_policy: { allowed_repos: request.access.allowed_repos, writable_paths: request.writable_paths },
441+
writable_paths: writablePaths,
442+
runner_workspace_policy: { allowed_repos: request.access.allowed_repos, writable_paths: writablePaths },
363443
},
364444
artifact_declarations: request.artifacts?.declarations || [],
365445
required_artifacts: requiredArtifacts(request.artifacts?.declarations),
366446
output_projections: [],
367-
metadata: { workload: request.workload, ...(materializedPackage ? { imported_agent: materializedPackage.identity } : {}), runtime_sources: materializedRuntimeSources?.descriptors ?? [] },
447+
metadata: { workload: request.workload, ...(materializedPackage ? { imported_agent: materializedPackage.identity } : {}), runtime_sources: materializedRuntimeSources?.descriptors ?? [], ...(runnerWorkspaceSeedSnapshot ? { runner_workspace_seed: runnerWorkspaceSeedSnapshot.provenance } : {}) },
368448
},
369449
},
370-
// agent-task-run unwraps task_input before building the recipe. Keep the
371-
// runner seed on that canonical input rather than on the request envelope.
372-
workspaces: request.runner_workspace?.enabled ? [{ target: "/workspace", mode: "readwrite", sourceMode: "repo-backed", seed: { type: "directory", source: workspace, excludePaths: [".git/**", ".codebox/**", "node_modules/**", "vendor/**", "dist/**", "build/**", "coverage/**", ".cache/**"] } }] : [],
450+
// agent-task-run unwraps task_input before building the recipe. The
451+
// external snapshot prevents the recipe from ever mounting the checkout.
452+
workspaces: runnerWorkspaceSeedSnapshot ? [{ target: "/workspace", mode: "readwrite", sourceMode: "repo-backed", seed: { type: "directory", source: runnerWorkspaceSeedSnapshot.source, excludePaths: RUNNER_WORKSPACE_SEED_EXCLUDES } }] : [],
373453
},
374454
}
375455

376456
await writeFile(executionInputPath, `${JSON.stringify(taskInput, null, 2)}\n`)
377457

378458
let execution = { code: 0, stdout: "", stderr: "", stdout_truncated: false, stderr_truncated: false }
379-
if (request.run_agent && !request.dry_run) {
380-
execution = await command("node", [codeboxCliPath, "agent-task-run", "--input-file", executionInputPath, "--result-file", nativeResultPath], workspace, agentEnvironment())
381-
}
459+
if (request.run_agent && !request.dry_run) {
460+
execution = await command("node", [codeboxCliPath, "agent-task-run", "--input-file", executionInputPath, "--result-file", nativeResultPath], workspace, agentEnvironment())
461+
}
382462

383463
// Public package bytes are embedded in the runtime recipe and consumed only by
384464
// the Playground bootstrap before the agent's tools are resolved.
@@ -387,7 +467,7 @@ const nativeRuntimeResult = request.run_agent && !request.dry_run
387467
? await readNativeResult(nativeResultPath, controlledCodeboxPath, secretValues, redact)
388468
: {}
389469
await rm(nativeResultPath, { force: true })
390-
const runtimeResult = sanitizeRuntimeSourceValue(nativeRuntimeResult, privateRuntimeSourceRootForSanitization)
470+
let runtimeResult = sanitizeRuntimeSourceValue(nativeRuntimeResult, privateRuntimeSourceRootForSanitization)
391471
assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization)
392472
let workspaceApply = { status: "no-op", changedFiles: [] }
393473
let runnerWorkspaceCore = null
@@ -398,12 +478,13 @@ if (execution.code === 0 && runtimeResult.success === true && request.runner_wor
398478
const publicCore = await import(pathToFileURL(join(codeboxRoot, "packages/runtime-core/dist/public.js")).href)
399479
const refs = publicCore.normalizePublicArtifactRefDTOs(runtimeResult)
400480
.filter((ref) => ref.kind === "codebox-patch" || ref.kind === "codebox-changed-files")
401-
workspaceApply = await runnerWorkspaceCore.applyRunnerWorkspacePatch({ artifactRoot: artifactsPath, artifactRefs: refs, workspaceRoot: workspace, writablePaths: String(request.writable_paths || "").split(",").map((value) => value.trim()).filter(Boolean) })
481+
workspaceApply = await runnerWorkspaceCore.applyRunnerWorkspacePatch({ artifactRoot: artifactsPath, artifactRefs: refs, workspaceRoot: workspace, writablePaths })
402482
} catch (error) {
403483
downstreamFailure = { stage: "apply", message: bounded(error instanceof Error ? error.message : String(error), MAX_OUTPUT_CHARS) }
404484
}
405485
}
406-
await cleanupPrivateRuntimeSources()
486+
runtimeResult = sanitizeRuntimeSourceValue(runtimeResult, runtimeSourceOutputRoots)
487+
privateRuntimeSourceRootForSanitization = runtimeSourceOutputRoots
407488
assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization)
408489

409490
await redactArtifactFiles(artifactsPath)
@@ -511,15 +592,9 @@ if (!success) process.exitCode = 1
511592
try {
512593
await executeNativeAgentTask()
513594
} catch (error) {
514-
try {
515-
await writeNormalizedFailure(error)
516-
} finally {
517-
if (privateRuntimeSourceRoot) {
518-
const root = privateRuntimeSourceRoot
519-
privateRuntimeSourceRoot = ""
520-
await rm(root, { recursive: true, force: true })
521-
}
522-
}
595+
await writeNormalizedFailure(error)
523596
console.error(bounded(error instanceof Error ? error.message : String(error), MAX_OUTPUT_CHARS))
524597
process.exitCode = 1
598+
} finally {
599+
await cleanupMaterializedSources()
525600
}

0 commit comments

Comments
 (0)