Conversation
…n-mercato#898) The test-env entrypoints decided both "reuse this instance" and "kill this instance" from liveness signals that never identified whose instance it was: `kill -0 $pid` only asks whether some process holds that number, and an HTTP 200 only asks whether something answers on that port. The descriptor is gitignored and only ever rewritten, so it outlives reboots — after which its PID number is routinely recycled onto an unrelated process and its port is routinely held by another worktree's instance. Two consequences, both reachable without anything exotic. `teardown_stale()` killed on `startedByThisRepo`, which `write_descriptor()` hard-writes as the literal true on every boot and so could never be false, and which it never paired with `status` — so a descriptor `test-env-down.sh` had marked stopped still nominated its PID for `kill` and then `kill -9`. And `try_reuse()` could attach to another checkout's server on the recorded port, testing a different branch's build while reporting it as verified — the worst failure mode available to a QA gate, because it passes and leaves evidence claiming it checked. Both guards now prove identity from signals that name this worktree. `is_our_server()` reads the live process's argv and requires both `packages/cezar/dist/index.js` and `--repo $REPO_ROOT`, anchored at end-of-argv or a space so the main checkout cannot claim a worktree's server. `try_reuse()` additionally requires the health payload's `repoRoot` to be this checkout, compared through realpath because the server derives its root from git while the script derives it from its own path. `test-env-down.sh` carries the same guard and clears `app.pid` on stop, so a cleanly stopped descriptor nominates nothing at all. Where `ps` is unavailable the guards fail closed: reuse is declined and the kill is skipped, which costs a reboot rather than risking a wrong signal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
🤖 Labels could not be applied: this PR comes from a fork and its author has read access, so
|
|
🤖 The |
|
🤖 |
|
🤖 🔍 Code Review🎯 SummaryThis PR replaces the test-env launcher's liveness-based decisions with identity-based ones. The diff is confined to two generated shell entrypoints under Two details raise the quality of the fix above the issue's own prescription and deserve calling out, because both are places where a plausible implementation would have been silently wrong:
✅ Verdict: APPROVENo blockers and no majors. One minor finding (an inaccurate code comment) and one nit, both listed below; neither affects behavior. The local 🧪 Validation GateRun against the PR head
On the A further eight failures seen on the first run were an artifact of the local 📌 FindingsMinor1. The comment reads "it is what the reuse and teardown guards compare against". Neither guard reads the descriptor field: This is not a nit about wording — it misdescribes the security property. A future reader could reasonably conclude that tampering with Fix: state what the field actually is — an informational record for descriptor consumers and for debugging — and note explicitly that the guards deliberately derive their own value instead of trusting the file. Cite Nit2. The file's history header cites 💥 Breaking Changes
Graceful degradation ( 🧪 Test CoverageCoverage is the strongest part of this PR, and was verified rather than taken on trust.
No further cases are required. One gap is worth naming rather than fixing: none of this runs on Linux locally, and |
Review finding on open-mercato#900. The comment claimed the field is what the reuse and teardown guards compare against, which it is not: both derive $REPO_ROOT themselves at runtime and never read the descriptor back. The distinction is the security property, not wording. The descriptor is the artifact that goes stale, so comparing it against itself would be a no-op — the same script at the same path always writes the same value. A reader who trusted the old comment could "simplify" the guards into reading the field and reintroduce exactly the bug this fixes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
🤖 Both review findings are fixed in
The change is comment-only in Verdict is unchanged: approve, pending a maintainer's review since self-approval is not permitted. |
|
🤖 CI has not run and will not run without a maintainer. Both workflow runs for this branch ( Local gate, run against
The follow-up commit This is evidence, not a substitute for branch protection. The required checks still gate the merge, and one platform-dependent input in this change is genuinely worth seeing green on CI rather than on a developer's Mac: 🏷️ Labels could not be applied at any point in this run (fork PR, read access), |
|
🤖 What a maintainer needs to do next: apply the labels listed in the rationale comment, click Approve and run workflows so CI actually executes, and submit the human review this PR still requires. |
Closes #898
🎯 Goal
Make the test-env launcher act only on the instance this worktree actually started, so a QA run can no longer
kill -9an unrelated process or silently verify a different branch's build.🔍 Problem
.ai/scripts/test-env-up.shand.ai/scripts/test-env-down.shdecided both "reuse this instance" and "kill this instance" from liveness signals that never proved the process was theirs:kill -0 $pidplus an HTTP 200 on the recorded URL..ai/qa/test-env.jsonis gitignored and never removed —downonly rewrote one field — so it outlives reboots and days of process churn, after which the recorded PID number very plausibly belongs to something else. Two failure modes followed, worst first: akill -9of an innocent process, which needs only PID recycling and no second coincidence; and a silent false-green QA run, where the recorded port has meanwhile been taken by another worktree's instance and the run tests a different branch's build while reporting "verified" — the worst possible outcome for a gate whose whole job is to be trustworthy, because it passes and leaves evidence claiming it checked.🔍 Root Cause
The descriptor recorded where (a URL) and what number (a PID), never who.
try_reuse()requiredstatus: running,kill -0 $pid,curlOK on the health path and on/, TTL freshness, and no source newer thanstartedAt— every one of which a foreign process satisfies, becausekill -0only asks whether some process currently holds that number and the two HTTP probes only ask whether something answers on that port.teardown_stale()killed wheneverstartedByThisRepowas true, andwrite_descriptor()writes that field as the literaltrueon every boot, so it could never be false; it also never consultedstatus, so a descriptor left behind bytest-env-down.sh(which rewrotestatustostoppedbut keptapp.pid) still nominated that PID forkilland thenkill -9.test-env-down.shcarried the identical pattern.The identity signal already existed and was simply unused:
GET /api/v1/healthreturnsrepoRoot, the checkout path of the answering instance (packages/cezar/src/server/server.ts:1476), andstart_app()launchesnode packages/cezar/dist/index.js … --repo "$REPO_ROOT", a per-worktree argv fingerprint.What Changed
.ai/scripts/test-env-up.sh— newis_our_server()reads the live process's argv viaps -ww -o command= -pand requires bothpackages/cezar/dist/index.jsand--repo $REPO_ROOT. The match is anchored at end-of-argv (or a space) deliberately: an unanchored match would let the main checkout claim a worktree's server, since<root>is a prefix of<root>/.ai/cezar/worktrees/<id>. Newhealth_repo_root_matches()parsesrepoRootout of the health payload and compares it throughrealpath— the server derives its root from git (getRepoInfo) while the script derives it from its own path, and the two can disagree on symlinked components; it degrades to a basename comparison for theCEZ_REMOTE-trimmed form, and treats an unreadable or empty answer as a mismatch, never a pass.try_reuse()— now also requires the recorded PID to be this worktree's server and the recorded port to be answered by this checkout. Both rejections log a greppable reason in the same shape as the existingsource changed since boot (…)line.teardown_stale()— gates the kill onis_our_serverand onstatus = runninginstead of onstartedByThisRepo. A PID that is not ours is logged and left alone.write_descriptor()— recordsapp.repoRoot, which checkout this instance belongs to, for descriptor consumers and debugging. The guards deliberately do not read it back: the descriptor is the artifact that goes stale, so each derives$REPO_ROOTitself and compares against that — comparing the file against itself would be a no-op. ItsstartCommandstring also regains the--repoflag the process has always carried in argv but the descriptor omitted..ai/scripts/test-env-down.sh— the same guard before signalling (duplicated on purpose: these are deliberately standalone entrypoints), plusapp.pid: nullalongsidestatus: "stopped", so a cleanly stopped descriptor nominates no PID at all. That is defence in depth, not a substitute — a crashed run still leavesstatus: runningwith a dead PID, which is what the identity guard covers.Where
psis unavailable the guards fail closed: reuse is declined and the kill is skipped, costing a reboot rather than risking a wrong signal.🧪 Tests
npm run test:unit— pass;npm run build— pass;npm run test:package— pass;npm run typecheck— pass.npm test— 6084 passed / 6 failed. Every one of the 6 reproduces on a clean tree with no changes at all (directional-usage,agents-section,todoswatch, twogit-changescases) or is flaky under full-suite load (automations-gatepasses in isolation). None is in the changed area. A further 8 failures seen initially were an artifact ofTMPDIRpointing inside the git repo, which breaks tests asserting "outside a git repository".packages/cezar/test/unit/test-env-launcher.test.ts, all red-green proven — they fail on the unfixed scripts and pass on the fixed ones:TEST_ENV_REUSED=0rather than being adopted;TEST_ENV_REUSED=1), so the guards do not defeat the build cache.psjoins the fixture's symlinked PATH (without it every guard fails closed and the tests would pass vacuously), and the fake server answers/api/v1/healthechoing its own--repo(the real server reportsrepoRootthere; the fixture previously only special-cased/api/health). The survival assertions useChildProcess.exitCode/signalCoderather thankill -0, which succeeds on a zombie — the first draft of the recycled-PID test passed against the unfixed script for exactly that reason.💥 Breaking Changes
None.
BACKWARD_COMPATIBILITY.mdprotects the HTTP API surface and the published package; this touches only the generated.ai/scripts/test-env entrypoints and their unit test..ai/qa/test-env.jsongains one additive field (app.repoRoot) and correctsstartCommand— the file is gitignored, per-worktree, and rewritten on every boot, so no consumer needs a migration. Neither descriptor consumer (.ai/scripts/e2e.sh,packages/web/e2e/agent-browser.ts) readsapp.pidorapp.startCommand.Notes for the reviewer
app.healthPath(/api/health→/api/v1/health) and the hard-codedplatform: "wsl2". Both already landed onmaininbbd77e9b(feat(history): progressively load long sessions #739), after the issue's analysis was written, so they are not in this diff. Only thestartCommandomission remained from that family..ai/qa/test-env.lockhas the samekill -0-only shape for stale-owner recovery, but its consequence is only waiting on a foreign PID for up to 300 s rather than signalling it. Worth a follow-up, not worth widening this fix.