From 125ee96b9d7c17727dff06afbad9502dcdea3848 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 26 Jun 2026 15:20:13 +0900 Subject: [PATCH 1/2] fix(test): barrier background-worker teardown against ENOTEMPTY race (#250) claude-companion smoke intermittently failed with ENOTEMPTY during temp cleanup: a detached --background worker was still writing into dataDir/state/.../jobs while the test's finally rmSync'd dataDir. Tests awaited only the job record (waitForJobRecord), never the worker process exit. Apply the deterministic waitForProcessExit barrier (mirroring #234) to every background-worker test that rmSyncs dataDir: capture launched.pid, then await waitForProcessExit(launchedPid) as the first finally statement, before cleanup. Cancellation tests use the tolerant .catch(() => {}). 9 tests covered; the waitForProcessExit helper and the one already-barriered test are unchanged. Validated: lint clean, baseline 138/138, 16x sequential stress 0 ENOTEMPTY (the flake was ~50%/run before the fix). Co-Authored-By: Claude Opus 4.8 --- tests/smoke/claude-companion.smoke.test.mjs | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/smoke/claude-companion.smoke.test.mjs b/tests/smoke/claude-companion.smoke.test.mjs index 41b258eb..3bb5fcea 100644 --- a/tests/smoke/claude-companion.smoke.test.mjs +++ b/tests/smoke/claude-companion.smoke.test.mjs @@ -1734,9 +1734,11 @@ test("T7.4 / §21.3.2: prompt sidecar is deleted after worker consumes it", asyn "--cwd", cwd, "--", "bg sidecar task"], { cwd } ); + let launchedPid = null; try { assert.equal(status, 0); const ev = JSON.parse(stdout); + launchedPid = ev.pid; const stateRoot = path.join(dataDir, "state"); // Poll until the record is terminal. const deadline = Date.now() + 10000; @@ -1765,6 +1767,7 @@ test("T7.4 / §21.3.2: prompt sidecar is deleted after worker consumes it", asyn // CI flakes with `ENOTEMPTY` on rmdir of state//. await new Promise((r) => setTimeout(r, 250)); } finally { + await waitForProcessExit(launchedPid); cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); } @@ -1853,9 +1856,11 @@ test("run --background: active job is visible as running and can be cancelled", "--cwd", cwd, "--", "long background task"], { cwd, env: { CLAUDE_MOCK_DELAY_MS: "5000" } }, ); + let launchedPid = null; try { assert.equal(status, 0, stderr); const launched = JSON.parse(stdout); + launchedPid = launched.pid; const deadline = Date.now() + CLAUDE_SMOKE_POLL_TIMEOUT_MS; let running = null; while (Date.now() < deadline && !running) { @@ -1966,6 +1971,7 @@ test("run --background: active job is visible as running and can be cancelled", } } } finally { + await waitForProcessExit(launchedPid).catch(() => {}); cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); } @@ -1986,9 +1992,11 @@ test("cancel: SIGTERM-trapping target classifies as cancelled, not completed (is "--cwd", cwd, "--", "long task"], { cwd, env: { CLAUDE_MOCK_DELAY_MS: "30000", CLAUDE_MOCK_TRAP_SIGTERM: "1" } }, ); + let launchedPid = null; try { assert.equal(status, 0, stderr); const launched = JSON.parse(stdout); + launchedPid = launched.pid; // Wait until the job is visible as running (mock has spawned, pid_info written). const runDeadline = Date.now() + CLAUDE_SMOKE_POLL_TIMEOUT_MS; let running = null; @@ -2043,6 +2051,7 @@ test("cancel: SIGTERM-trapping target classifies as cancelled, not completed (is assert.equal(terminal.status, "cancelled", `cancel-marker must force status=cancelled even when target trapped SIGTERM and exited 0; got ${JSON.stringify(terminal)}`); } finally { + await waitForProcessExit(launchedPid).catch(() => {}); cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); } @@ -2059,9 +2068,11 @@ test("cancel: ESRCH after ownership verification is already_dead, not signal_fai "--cwd", cwd, "--", "long task"], { cwd, env: { CLAUDE_MOCK_DELAY_MS: "30000" } }, ); + let launchedPid = null; try { assert.equal(status, 0, stderr); const launched = JSON.parse(stdout); + launchedPid = launched.pid; const runDeadline = Date.now() + CLAUDE_SMOKE_POLL_TIMEOUT_MS; let running = null; while (Date.now() < runDeadline && !running) { @@ -2109,6 +2120,7 @@ process.kill = (pid, signal) => { assert.equal(cancel.status, "already_dead"); assert.equal(cancel.pid, running.pid_info.pid); } finally { + await waitForProcessExit(launchedPid).catch(() => {}); cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); } @@ -2269,6 +2281,7 @@ test("continue --job --background reuses the parent Claude project cwd", async ( CLAUDE_MOCK_ENFORCE_PROJECT_SESSIONS: "1", CLAUDE_MOCK_PROJECT_SESSION_STORE: sessionStore, }; + let launchedPid = null; try { const runRes = runCompanion( ["run", "--mode=custom-review", "--foreground", @@ -2287,6 +2300,7 @@ test("continue --job --background reuses the parent Claude project cwd", async ( ); assert.equal(contRes.status, 0, contRes.stderr); const launched = JSON.parse(contRes.stdout); + launchedPid = launched.pid; const continued = await waitForJobRecord( dataDir, launched.job_id, @@ -2298,6 +2312,7 @@ test("continue --job --background reuses the parent Claude project cwd", async ( assert.equal(continued.runtime_diagnostics.child_cwd, parentRecord.runtime_diagnostics.child_cwd); await new Promise((resolve) => setTimeout(resolve, 250)); } finally { + await waitForProcessExit(launchedPid); rmSync(dataDir, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true }); } @@ -2529,6 +2544,7 @@ process.stdout.write(JSON.stringify({ process.exit(0); `); const env = { ANTHROPIC_API_KEY: "secret-test-value", CLAUDE_API_KEY: "" }; + let launchedPid = null; try { const parent = runCompanion([ "run", "--mode=custom-review", "--foreground", @@ -2546,6 +2562,7 @@ process.exit(0); ], { cwd, dataDir, env }); if (unapproved.status === 0 && unapproved.stdout.trim()) { const launched = JSON.parse(unapproved.stdout); + launchedPid = launched.pid; await waitForJobRecord( dataDir, launched.job_id, @@ -2567,6 +2584,7 @@ process.exit(0); assert.equal(promptSidecarExists, false, "unapproved background API continuation must not persist selected source prompt sidecar"); assert.doesNotMatch(unapproved.stdout, /secret-test-value|CLAUDE_CONTINUE_UNAPPROVED_BACKGROUND_API_SOURCE_SENTINEL/); } finally { + await waitForProcessExit(launchedPid); cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); rmSync(tmp, { recursive: true, force: true }); @@ -4596,9 +4614,11 @@ process.exit(0); "--model", "claude-haiku-4-5-20251001", "--cwd", cwd, "--", "review this change"], { cwd, env: { ANTHROPIC_API_KEY: "", CLAUDE_API_KEY: "" } }, ); + let launchedPid = null; try { assert.equal(status, 0, stderr || stdout); const launched = JSON.parse(stdout); + launchedPid = launched.pid; const cancelRes = spawnSync("node", [ COMPANION, "cancel", "--job", launched.job_id, "--cwd", cwd, @@ -4620,6 +4640,7 @@ process.exit(0); assert.equal(record.external_review.source_content_transmission, "not_sent"); assert.equal(existsSync(targetLaunchedPath), false); } finally { + await waitForProcessExit(launchedPid).catch(() => {}); cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); rmSync(tmp, { recursive: true, force: true }); @@ -5196,6 +5217,7 @@ process.exit(0); ["approval-request", ...commonOptions, "--", "review selected source"], { cwd, dataDir, env }, ); + let launchedPid = null; try { assert.equal(approval.status, 0, approval.stderr || approval.stdout); const request = JSON.parse(approval.stdout); @@ -5209,6 +5231,7 @@ process.exit(0); ); assert.equal(run.status, 0, run.stderr || run.stdout); const launched = JSON.parse(run.stdout); + launchedPid = launched.pid; const record = await waitForJobRecord( dataDir, launched.job_id, @@ -5228,6 +5251,7 @@ process.exit(0); assert.doesNotMatch(JSON.stringify(record), new RegExp(request.approval_token.value), "approved JobRecord must not persist the approval token"); } finally { + await waitForProcessExit(launchedPid); cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); rmSync(tmp, { recursive: true, force: true }); @@ -5250,9 +5274,11 @@ test("background api_key source-bearing review without approval fails before pro ["run", "--background", "--lifecycle-events", "jsonl", ...commonOptions, "--", "review selected source"], { cwd, dataDir, env: { ANTHROPIC_API_KEY: "secret-test-value", CLAUDE_API_KEY: "" } }, ); + let launchedPid = null; try { if (run.status === 0 && run.stdout.trim()) { const launched = JSON.parse(run.stdout); + launchedPid = launched.pid; await waitForJobRecord( dataDir, launched.job_id, @@ -5270,6 +5296,7 @@ test("background api_key source-bearing review without approval fails before pro assert.equal(existsSync(promptPath), false, "unapproved background API run must not persist selected source prompt sidecar"); assert.doesNotMatch(run.stdout, /secret-test-value|CLAUDE_UNAPPROVED_BACKGROUND_API_SOURCE_SENTINEL/); } finally { + await waitForProcessExit(launchedPid); cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); } From e458807b3b6643924462594c0c243f9ce63ba540 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 26 Jun 2026 17:15:57 +0900 Subject: [PATCH 2/2] fix(test): converge ENOTEMPTY teardown barriers to #242 form and extend to gemini (#250) The first cut of #250 used a finally-first barrier with .catch() on the cancel tests -- a variant that diverged from the proven #234/#242 pattern and swallowed the 5s fail-loud timeout. Rework to the exact #242 form: end-of-try placement, plain await (fail-loud), a 30s *_SMOKE_POLL_TIMEOUT_MS budget on the cancel/status-poll tests, and the redundant setTimeout(250) cushions replaced rather than duplicated. Extend the same barrier to the gemini companion smoke suite, which shares the identical detached-worker teardown race. The resulting barriers are byte-identical to the companion-smoke barriers currently carried by PR #242 (verified: git diff feat/234 shows zero changed waitForProcessExit lines), so #242 dedups them automatically on its next main merge. This makes #250 the single source for the companion-smoke barrier class and removes the duplicative barrier work from the concurrent-relays PR. Class coverage: claude (9 tests) and gemini (helper + 8 tests) fixed here; kimi already barriered on main; agy is foreground-only (rejects --background, no detached worker, no race); identity-resume has no background launches. Test-only. Validated: lint (incl. sync checks), claude 138/138, gemini 100/100, sequential stress 10x claude + 5x gemini = 0 ENOTEMPTY (flake reproduced ~50%/run unfixed). Co-Authored-By: Claude Opus 4.8 --- tests/smoke/claude-companion.smoke.test.mjs | 62 ++++++++++----------- tests/smoke/gemini-companion.smoke.test.mjs | 35 ++++++++++++ 2 files changed, 64 insertions(+), 33 deletions(-) diff --git a/tests/smoke/claude-companion.smoke.test.mjs b/tests/smoke/claude-companion.smoke.test.mjs index 3bb5fcea..4c55ac0e 100644 --- a/tests/smoke/claude-companion.smoke.test.mjs +++ b/tests/smoke/claude-companion.smoke.test.mjs @@ -1734,11 +1734,9 @@ test("T7.4 / §21.3.2: prompt sidecar is deleted after worker consumes it", asyn "--cwd", cwd, "--", "bg sidecar task"], { cwd } ); - let launchedPid = null; try { assert.equal(status, 0); const ev = JSON.parse(stdout); - launchedPid = ev.pid; const stateRoot = path.join(dataDir, "state"); // Poll until the record is terminal. const deadline = Date.now() + 10000; @@ -1761,13 +1759,12 @@ test("T7.4 / §21.3.2: prompt sidecar is deleted after worker consumes it", asyn // After the worker consumed the prompt, the sidecar must be gone. assert.equal(existsSync(path.join(jobDir, "prompt.txt")), false, "§21.3.1: prompt sidecar must be deleted after worker consumes it"); - // Settle: meta.json flips to terminal BEFORE upsertJob writes state.json - // and BEFORE writeSidecar emits stdout.log/stderr.log. Without this - // wait, the recursive cleanup races the worker's tail writes and Linux - // CI flakes with `ENOTEMPTY` on rmdir of state//. - await new Promise((r) => setTimeout(r, 250)); + // meta.json flips to terminal BEFORE upsertJob writes state.json and + // BEFORE writeSidecar emits stdout.log/stderr.log. Wait for the worker + // process to exit so cleanup() does not race those tail writes and flake + // with ENOTEMPTY on rmdir of state//. + await waitForProcessExit(ev.pid); } finally { - await waitForProcessExit(launchedPid); cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); } @@ -1856,11 +1853,9 @@ test("run --background: active job is visible as running and can be cancelled", "--cwd", cwd, "--", "long background task"], { cwd, env: { CLAUDE_MOCK_DELAY_MS: "5000" } }, ); - let launchedPid = null; try { assert.equal(status, 0, stderr); const launched = JSON.parse(stdout); - launchedPid = launched.pid; const deadline = Date.now() + CLAUDE_SMOKE_POLL_TIMEOUT_MS; let running = null; while (Date.now() < deadline && !running) { @@ -1970,8 +1965,11 @@ test("run --background: active job is visible as running and can be cancelled", "default status (no --all) must include cancelled jobs"); } } + // Barrier: wait for the worker process to fully exit before cleanup so the + // recursive rmSync does not race its post-terminal tail writes (lease + // release, sidecar removal) and flake with ENOTEMPTY. + await waitForProcessExit(launched.pid, CLAUDE_SMOKE_POLL_TIMEOUT_MS); } finally { - await waitForProcessExit(launchedPid).catch(() => {}); cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); } @@ -1992,11 +1990,9 @@ test("cancel: SIGTERM-trapping target classifies as cancelled, not completed (is "--cwd", cwd, "--", "long task"], { cwd, env: { CLAUDE_MOCK_DELAY_MS: "30000", CLAUDE_MOCK_TRAP_SIGTERM: "1" } }, ); - let launchedPid = null; try { assert.equal(status, 0, stderr); const launched = JSON.parse(stdout); - launchedPid = launched.pid; // Wait until the job is visible as running (mock has spawned, pid_info written). const runDeadline = Date.now() + CLAUDE_SMOKE_POLL_TIMEOUT_MS; let running = null; @@ -2050,8 +2046,10 @@ test("cancel: SIGTERM-trapping target classifies as cancelled, not completed (is assert.ok(terminal, `job did not finalize after cancel; last status seen=${lastStatusSeen}`); assert.equal(terminal.status, "cancelled", `cancel-marker must force status=cancelled even when target trapped SIGTERM and exited 0; got ${JSON.stringify(terminal)}`); + // Barrier: ensure the worker process is gone before cleanup so the + // recursive rmSync does not race its post-terminal tail writes (ENOTEMPTY). + await waitForProcessExit(launched.pid, CLAUDE_SMOKE_POLL_TIMEOUT_MS); } finally { - await waitForProcessExit(launchedPid).catch(() => {}); cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); } @@ -2068,11 +2066,9 @@ test("cancel: ESRCH after ownership verification is already_dead, not signal_fai "--cwd", cwd, "--", "long task"], { cwd, env: { CLAUDE_MOCK_DELAY_MS: "30000" } }, ); - let launchedPid = null; try { assert.equal(status, 0, stderr); const launched = JSON.parse(stdout); - launchedPid = launched.pid; const runDeadline = Date.now() + CLAUDE_SMOKE_POLL_TIMEOUT_MS; let running = null; while (Date.now() < runDeadline && !running) { @@ -2119,8 +2115,10 @@ process.kill = (pid, signal) => { assert.equal(cancelRes.status, 0, cancelRes.stderr); assert.equal(cancel.status, "already_dead"); assert.equal(cancel.pid, running.pid_info.pid); + // Barrier: ensure the worker process is gone before cleanup so the + // recursive rmSync does not race its post-terminal tail writes (ENOTEMPTY). + await waitForProcessExit(launched.pid, CLAUDE_SMOKE_POLL_TIMEOUT_MS); } finally { - await waitForProcessExit(launchedPid).catch(() => {}); cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); } @@ -2281,7 +2279,6 @@ test("continue --job --background reuses the parent Claude project cwd", async ( CLAUDE_MOCK_ENFORCE_PROJECT_SESSIONS: "1", CLAUDE_MOCK_PROJECT_SESSION_STORE: sessionStore, }; - let launchedPid = null; try { const runRes = runCompanion( ["run", "--mode=custom-review", "--foreground", @@ -2300,7 +2297,6 @@ test("continue --job --background reuses the parent Claude project cwd", async ( ); assert.equal(contRes.status, 0, contRes.stderr); const launched = JSON.parse(contRes.stdout); - launchedPid = launched.pid; const continued = await waitForJobRecord( dataDir, launched.job_id, @@ -2310,9 +2306,10 @@ test("continue --job --background reuses the parent Claude project cwd", async ( assert.equal(continued.status, "completed"); assert.equal(continued.parent_job_id, parent.job_id); assert.equal(continued.runtime_diagnostics.child_cwd, parentRecord.runtime_diagnostics.child_cwd); - await new Promise((resolve) => setTimeout(resolve, 250)); + // Wait for the background continue worker to exit before cleanup so the + // recursive rmSync does not race its tail writes and flake with ENOTEMPTY. + await waitForProcessExit(launched.pid); } finally { - await waitForProcessExit(launchedPid); rmSync(dataDir, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true }); } @@ -2544,7 +2541,6 @@ process.stdout.write(JSON.stringify({ process.exit(0); `); const env = { ANTHROPIC_API_KEY: "secret-test-value", CLAUDE_API_KEY: "" }; - let launchedPid = null; try { const parent = runCompanion([ "run", "--mode=custom-review", "--foreground", @@ -2562,13 +2558,13 @@ process.exit(0); ], { cwd, dataDir, env }); if (unapproved.status === 0 && unapproved.stdout.trim()) { const launched = JSON.parse(unapproved.stdout); - launchedPid = launched.pid; await waitForJobRecord( dataDir, launched.job_id, (candidate) => candidate.status === "completed" || candidate.status === "failed", "background unapproved API continuation did not finalize", ); + await waitForProcessExit(launched.pid); } assert.equal(unapproved.status, 2, unapproved.stderr || unapproved.stdout); const record = JSON.parse(unapproved.stdout); @@ -2584,7 +2580,6 @@ process.exit(0); assert.equal(promptSidecarExists, false, "unapproved background API continuation must not persist selected source prompt sidecar"); assert.doesNotMatch(unapproved.stdout, /secret-test-value|CLAUDE_CONTINUE_UNAPPROVED_BACKGROUND_API_SOURCE_SENTINEL/); } finally { - await waitForProcessExit(launchedPid); cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); rmSync(tmp, { recursive: true, force: true }); @@ -4614,11 +4609,9 @@ process.exit(0); "--model", "claude-haiku-4-5-20251001", "--cwd", cwd, "--", "review this change"], { cwd, env: { ANTHROPIC_API_KEY: "", CLAUDE_API_KEY: "" } }, ); - let launchedPid = null; try { assert.equal(status, 0, stderr || stdout); const launched = JSON.parse(stdout); - launchedPid = launched.pid; const cancelRes = spawnSync("node", [ COMPANION, "cancel", "--job", launched.job_id, "--cwd", cwd, @@ -4639,8 +4632,13 @@ process.exit(0); assert.equal(record.pid_info ?? null, null); assert.equal(record.external_review.source_content_transmission, "not_sent"); assert.equal(existsSync(targetLaunchedPath), false); + // The cancelled record is durable before the worker finishes its tail + // writes (lease release, sidecar removal, lifecycle flush). Wait for the + // worker process to exit so cleanup() does not race a live writer and + // flake with ENOTEMPTY — same barrier the sibling preflight-rejection + // test above uses. + await waitForProcessExit(launched.pid); } finally { - await waitForProcessExit(launchedPid).catch(() => {}); cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); rmSync(tmp, { recursive: true, force: true }); @@ -5217,7 +5215,6 @@ process.exit(0); ["approval-request", ...commonOptions, "--", "review selected source"], { cwd, dataDir, env }, ); - let launchedPid = null; try { assert.equal(approval.status, 0, approval.stderr || approval.stdout); const request = JSON.parse(approval.stdout); @@ -5231,7 +5228,6 @@ process.exit(0); ); assert.equal(run.status, 0, run.stderr || run.stdout); const launched = JSON.parse(run.stdout); - launchedPid = launched.pid; const record = await waitForJobRecord( dataDir, launched.job_id, @@ -5250,8 +5246,10 @@ process.exit(0); assert.equal(Object.hasOwn(record.review_metadata.audit_manifest, "approval_token"), false); assert.doesNotMatch(JSON.stringify(record), new RegExp(request.approval_token.value), "approved JobRecord must not persist the approval token"); + // Wait for the worker process to exit before cleanup so the recursive + // rmSync does not race its post-terminal tail writes (ENOTEMPTY). + await waitForProcessExit(launched.pid); } finally { - await waitForProcessExit(launchedPid); cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); rmSync(tmp, { recursive: true, force: true }); @@ -5274,17 +5272,16 @@ test("background api_key source-bearing review without approval fails before pro ["run", "--background", "--lifecycle-events", "jsonl", ...commonOptions, "--", "review selected source"], { cwd, dataDir, env: { ANTHROPIC_API_KEY: "secret-test-value", CLAUDE_API_KEY: "" } }, ); - let launchedPid = null; try { if (run.status === 0 && run.stdout.trim()) { const launched = JSON.parse(run.stdout); - launchedPid = launched.pid; await waitForJobRecord( dataDir, launched.job_id, (candidate) => candidate.status === "completed" || candidate.status === "failed", "background unapproved API run did not finalize", ); + await waitForProcessExit(launched.pid); } assert.equal(run.status, 2, run.stderr || run.stdout); const record = JSON.parse(run.stdout); @@ -5296,7 +5293,6 @@ test("background api_key source-bearing review without approval fails before pro assert.equal(existsSync(promptPath), false, "unapproved background API run must not persist selected source prompt sidecar"); assert.doesNotMatch(run.stdout, /secret-test-value|CLAUDE_UNAPPROVED_BACKGROUND_API_SOURCE_SENTINEL/); } finally { - await waitForProcessExit(launchedPid); cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); } diff --git a/tests/smoke/gemini-companion.smoke.test.mjs b/tests/smoke/gemini-companion.smoke.test.mjs index 361afad1..621876a6 100644 --- a/tests/smoke/gemini-companion.smoke.test.mjs +++ b/tests/smoke/gemini-companion.smoke.test.mjs @@ -83,6 +83,25 @@ function rmTree(target) { } } +// A background worker keeps writing into its data dir (lease release, sidecar +// removal, lifecycle jsonl flush) AFTER its terminal JobRecord is durable. +// Tests must wait for the worker PROCESS to exit before rmTree, or the +// recursive removal races those tail writes and flakes with ENOTEMPTY. +async function waitForProcessExit(pid, timeoutMs = 5000) { + if (!Number.isInteger(pid)) return; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch (error) { + if (error?.code === "ESRCH") return; + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.fail(`worker process ${pid} did not exit`); +} + function assertPreflightSafetyFields(result) { assert.equal(result.target_spawned, false); assert.equal(result.selected_scope_sent_to_provider, false); @@ -233,6 +252,8 @@ test("gemini custom-review background: launched event and terminal JobRecord", a disclosure: "Selected source content was sent to Gemini CLI for external review.", }); assert.equal("prompt" in meta, false, "full prompt must not appear on JobRecord"); + // Barrier: wait for the worker process to exit before cleanup (ENOTEMPTY race). + await waitForProcessExit(launched.pid); } finally { rmTree(dataDir); rmTree(cwd); @@ -478,6 +499,8 @@ test("gemini rescue background: active job appears in default status", async () if (!terminal) await new Promise((resolve) => setTimeout(resolve, 100)); } assert.ok(terminal, "background job did not finish before cleanup"); + // Barrier: wait for the worker process to exit before cleanup (ENOTEMPTY race). + await waitForProcessExit(launched.pid, GEMINI_SMOKE_POLL_TIMEOUT_MS); } finally { rmTree(dataDir); rmTree(cwd); @@ -553,6 +576,8 @@ test("gemini cancel: signals a running background job (issue #22 sub-task 1)", a assert.equal(cancel.pid, running.pid_info.pid); } } + // Barrier: wait for the worker process to exit before cleanup (ENOTEMPTY race). + await waitForProcessExit(launched.pid, GEMINI_SMOKE_POLL_TIMEOUT_MS); } finally { rmTree(dataDir); rmTree(cwd); @@ -921,6 +946,8 @@ test("gemini cancel: SIGTERM-trapping target classifies as cancelled, not comple assert.ok(terminal, "job did not finalize after cancel"); assert.equal(terminal.status, "cancelled", `cancel-marker must force status=cancelled even when target trapped SIGTERM; got ${JSON.stringify(terminal)}`); + // Barrier: wait for the worker process to exit before cleanup (ENOTEMPTY race). + await waitForProcessExit(launched.pid, GEMINI_SMOKE_POLL_TIMEOUT_MS); } finally { rmTree(dataDir); rmTree(cwd); @@ -986,6 +1013,8 @@ process.kill = (pid, signal) => { assert.equal(cancelRes.status, 0, cancelRes.stderr); assert.equal(cancel.status, "already_dead"); assert.equal(cancel.pid, running.pid_info.pid); + // Barrier: wait for the worker process to exit before cleanup (ENOTEMPTY race). + await waitForProcessExit(launched.pid, GEMINI_SMOKE_POLL_TIMEOUT_MS); } finally { rmTree(dataDir); rmTree(cwd); @@ -1350,6 +1379,8 @@ test("gemini continue background: launched event and resumed terminal JobRecord" assert.ok(fx, "worker never wrote stdout.log"); assert.equal(fx.t7_resume_id, prior.gemini_session_id); assert.equal("prompt" in meta, false, "full prompt must not appear on JobRecord"); + // Barrier: wait for the worker process to exit before cleanup (ENOTEMPTY race). + await waitForProcessExit(launched.pid); } finally { rmTree(first.dataDir); rmTree(cwd); @@ -1401,6 +1432,8 @@ test("gemini _run-worker refuses terminal JobRecord without overwriting it", asy assert.equal(after.status, "completed"); assert.match(after.result, /Mock Gemini response\./); assert.equal(after.gemini_session_id, GEMINI_SESSION_ID); + // Barrier: wait for the worker process to exit before cleanup (ENOTEMPTY race). + await waitForProcessExit(launched.pid); } finally { rmTree(dataDir); rmTree(cwd); @@ -3496,6 +3529,8 @@ test("gemini approval-request token unlocks matching background api_key source-b assert.equal(terminal.review_metadata.audit_manifest.source_send_approval_required, true); assert.equal(terminal.review_metadata.audit_manifest.source_send_approval_state, "approved"); assert.doesNotMatch(approval.stdout + launched.stdout + JSON.stringify(terminal), /secret-test-value/); + // Barrier: wait for the worker process to exit before cleanup (ENOTEMPTY race). + await waitForProcessExit(launchEvent.pid); } finally { rmTree(approval.dataDir); rmTree(cwd);