Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions tests/smoke/claude-companion.smoke.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<subdir>/.
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/<subdir>/.
await waitForProcessExit(ev.pid);
} finally {
cleanup(dataDir);
rmSync(cwd, { recursive: true, force: true });
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions tests/smoke/gemini-companion.smoke.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading