diff --git a/tests/smoke/claude-companion.smoke.test.mjs b/tests/smoke/claude-companion.smoke.test.mjs index 41b258eb..4c55ac0e 100644 --- a/tests/smoke/claude-companion.smoke.test.mjs +++ b/tests/smoke/claude-companion.smoke.test.mjs @@ -1759,11 +1759,11 @@ 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 { cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); @@ -1965,6 +1965,10 @@ 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 { cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); @@ -2042,6 +2046,9 @@ 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 { cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); @@ -2108,6 +2115,9 @@ 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 { cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); @@ -2296,7 +2306,9 @@ 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 { rmSync(dataDir, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true }); @@ -2552,6 +2564,7 @@ process.exit(0); (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); @@ -4619,6 +4632,12 @@ 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 { cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); @@ -5227,6 +5246,9 @@ 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 { cleanup(dataDir); rmSync(cwd, { recursive: true, force: true }); @@ -5259,6 +5281,7 @@ test("background api_key source-bearing review without approval fails before pro (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); 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);