Repository navigation
fix(memory_tree, e2e tests ): deterministic query_topic ordering + robust CEF cleanup - #1751
Conversation
The `tests/agent_retrieval_e2e.rs::orchestrator_query_topic_tool_returns_alice_phoenix_hits`
integration test flaked intermittently in CI with:
fetch_leaves should hydrate exactly the requested chunk
left: 0
right: 1
Two sources of nondeterminism in `query_topic`:
1. `by_node: HashMap<String, RetrievalHit>` (`retrieval/topic.rs:92`) was
converted to `Vec` via `.into_values().collect()`. HashMap iteration
order is randomised per process (Rust's SipHash), so the post-dedup
order varied between runs.
2. The downstream `sort_by(score DESC, time_range_end DESC)` is stable,
so when many hits tied on both keys — common with the inert embedder
used in retrieval tests, which yields score=0.0 for every hit — the
surviving order was whatever HashMap handed it.
The test picks the first `node_kind == "leaf"` hit and asserts
`fetch_leaves` hydrates exactly one chunk. With the HashMap-random
order, different leaves got picked on different runs; some choices
landed on entries whose downstream behaviour differed.
Fixes:
- `HashMap` → `BTreeMap<String, RetrievalHit>` so the post-dedup
iteration order is a deterministic function of `node_id`.
- Add `a.node_id ASC` as a final tie-break in the post-filter sort
so two hits matching on `(score, time_range_end)` still produce a
deterministic order across runs.
Together these make the test pick the same leaf on every run.
Verified locally: the previously-flaky test passed 5/5 consecutive
runs after the fix. Production retrieval behaviour is unchanged for
non-tied results — only the tie-break order shifts from
HashMap-random to `node_id`-alphabetic, which is strictly more useful
(stable display ordering, reproducible behaviour for debugging).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughDeterministic query ordering: query_topic deduplicates with a BTreeMap and adds node_id as a final tie-break. E2E script: cleanup() reaps the CEF process tree and tolerates transient workspace deletion errors. Tests: workspace env mutations are serialized with a process-wide ENV_LOCK held for EnvGuard lifetime. ChangesDeterministic Memory Query Results
E2E CEF Cleanup
Serialized Test Workspace Environment
🎯 3 (Moderate) | ⏱️ ~20 minutes
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Comment |
The Linux E2E job (`E2E (Linux / Appium Chromium)`) failed
intermittently with `Process completed with exit code 1` despite the
WDIO smoke spec reporting `1 passed, 1 total`. The actual failure was
in the cleanup trap of `app/scripts/e2e-run-session.sh`:
[runner] Stopping CEF app (pid 21110)...
rm: cannot remove '/tmp/tmp.xZBAn58lfq/users/local/cef/Default':
Directory not empty
##[error]Process completed with exit code 1.
Root cause: CEF spawns helper child processes (zygote, GPU process,
renderers) that the parent does not reap on `SIGTERM`. The cleanup
trap called `kill $APP_PID; wait $APP_PID; rm -rf $CREATED_TEMP_WORKSPACE`,
but the helpers kept writing into `<workspace>/users/local/cef/Default/`
after `wait` returned on the parent, so `rm -rf` raced them and
exited non-zero.
`set -e` was active inside the trap, so a non-zero `rm` exit
propagated to the job — manifesting as a green spec followed by a
red job. macOS/Windows runners aren't affected (different CEF
lifetime semantics + `rm` tolerance).
Two-part fix:
1. Reap the CEF process tree: `pkill -TERM -P "$APP_PID"` before
killing the parent, brief sleep for handles to release, then
`pkill -KILL -P "$APP_PID"` as a final guard.
2. Defense in depth: `rm -rf "$CREATED_TEMP_WORKSPACE" || true`.
The workspace is a per-run `mktemp` under `/tmp` — leftover files
are collected by CI's tmp-cleanup pass. The job must not fail on
cleanup race when the test itself passed.
The script's `set -euo pipefail` semantics are preserved everywhere
else; only the workspace `rm` was changed to tolerate the documented
race.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/scripts/e2e-run-session.sh`:
- Around line 72-78: The current sequence uses pkill -KILL -P "$APP_PID" after
killing the parent, but children may be reparented to PID 1 and escape that
sweep; modify the block around APP_PID to capture the current child PIDs before
killing the parent (e.g., use pgrep -P "$APP_PID" or readarray to snapshot
children into a variable/array), then proceed with pkill -TERM -P
"$APP_PID"/kill "$APP_PID" as before, and finally iterate the saved child PID
list to send SIGKILL to each PID directly (checking for non-empty values and
ignoring failures), replacing the final pkill -KILL -P "$APP_PID" with direct
kills of the captured PIDs so reparenting won't prevent cleanup.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: b72e65ce-4411-42f6-8746-bdeaadb4734f
📒 Files selected for processing (1)
app/scripts/e2e-run-session.sh
…_e2e CI under cargo-llvm-cov surfaced a deeper flake than the HashMap one that 54bcdf3 fixed: fetch_leaves should not error: Failed to initialize memory_tree schema Caused by: 0: disk I/O error 1: Error code 1802: Error invoking fstat to get file metadata Root cause: tests in this file each set the process-wide env var `OPENHUMAN_WORKSPACE` to their own `TempDir` so the tool wrappers (which resolve config via `load_config_with_timeout`) see the right workspace. `cargo test` runs integration-test binaries multi-threaded by default (`test-threads = num_cpus`), so two tests racing on the env var produced two distinct failure modes: 1. Older flake: A reads B's (empty) workspace → `fetch_leaves` returns 0 hits while expecting 1. 2. New flake (under `cargo-llvm-cov`): A keeps reading from B's workspace, then B finishes, B's `TempDir` drops and unlinks the directory, and A's next `with_connection` opens a connection to a path whose files are mid-disappearance. SQLite's `fstat` on that file errors with `SQLITE_IOERR_FSTAT` (code 1802). The `EnvGuard` in this file already restored the previous value on drop, but a guard local to one test does nothing for sibling tests that overwrite the env var in parallel. Fix: add a process-wide `static ENV_LOCK: Mutex<()>` and have `set_workspace_env` acquire it. The `EnvGuard` now holds the lock guard as a final field, so the drop order is: Drop::drop runs (restores env var) → key field drops → prev field drops → _lock field drops (releases mutex) i.e. the env var is restored *before* the lock is released, so the next waiting test always observes a clean value. `unwrap_or_else(|p| p.into_inner())` keeps the lock usable after a poisoning panic so one failing test doesn't cascade. Verified locally: full integration-test binary (`cargo test --test agent_retrieval_e2e`, default parallelism) passes 5/5 consecutive runs after this change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeRabbit (PR tinyhumansai#1751) flagged a real bug in 4191e3d's cleanup logic: the final `pkill -KILL -P "$APP_PID"` was dead code on Linux because by the time it ran the parent had already exited via `kill $APP_PID` + `wait $APP_PID`. The kernel reparents orphaned children to PID 1 the instant their parent dies, so `pkill -P "$APP_PID"` no longer matches any process — `man pkill`: "Only match processes whose parent process ID is listed." Fix per CodeRabbit's suggestion: capture the direct children of `$APP_PID` into `CHILD_PIDS` *before* killing the parent, then after the grace period iterate the captured list and send SIGKILL to each PID directly. Reparenting can't hide them — we're matching by PID, not by PPID. `pgrep -P "$APP_PID"` covers direct children only. CEF's helper topology (zygote, GPU, renderers) is one level deep from the parent, so this captures the set we actually need to reap. Deeper descendants are not currently a known issue with the WDIO smoke spec; if they show up we can recurse via `pgrep -P` per captured PID. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…bust CEF cleanup (tinyhumansai#1751) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…bust CEF cleanup (tinyhumansai#1751)\n\n
Summary
tests/agent_retrieval_e2e.rs::orchestrator_query_topic_tool_returns_alice_phoenix_hitsflaked becausequery_topicsorted its results into aHashMap-randomised order. ReplaceHashMapwithBTreeMap+ addnode_idASC tertiary tie-break.app/scripts/e2e-run-session.shfailed onrm -rf $tmpbecause CEF helper children kept writing to the temp workspace after the parent process died. Reap the whole CEF process tree before cleanup; tolerate residualrmerrors that race with helper teardown.Together these turn two intermittent CI flakes that have been blocking other PRs (e.g. #1734's
Rust Core Coveragejob) into deterministic green.Problem
Rust integration test
Test failure in CI:
Two sources of nondeterminism in
memory/tree/retrieval/topic.rs:by_node: HashMap<String, RetrievalHit>(topic.rs:92) —.into_values().collect()returned items in HashMap iteration order, which Rust randomises per process (SipHash).sort_by(score DESC, time_range_end DESC)doesn't disambiguate further. The test uses an inert embedder (cfg.memory_tree.embedding_endpoint = None), so every hit hasscore = 0.0and many sharetime_range_end. The surviving order was whatever HashMap handed in.The test picked the first
node_kind == "leaf"hit and assertedfetch_leaveshydrated exactly one chunk. With nondeterministic ordering, different leaves got picked on different runs.WDIO E2E (Linux job)
Pattern: smoke spec reports
1 passed, then the job dies withexit 1. Log:app/scripts/e2e-run-session.shcleanup trap didkill $APP_PID; wait $APP_PID; rm -rf $tmp— but CEF spawns zygote / GPU / renderer helpers that the parent doesn't reap on SIGTERM. They kept writing into<tmp>/users/local/cef/Default/afterwaitreturned, sorm -rfraced them.set -ewas active in the trap, so the non-zerormexit failed the whole job. Linux-only; macOS/Windows runners don't trigger it.Solution
src/openhuman/memory/tree/retrieval/topic.rs— 2 changesHashMap→BTreeMap<String, RetrievalHit>soby_node.into_values()returns hits in deterministicnode_idorder.a.node_id.cmp(&b.node_id)as the finalthen_within the post-filtersort_by. Belt-and-suspenders for any future path that bypassesby_nodebut still needs full-tie determinism.Pure ordering shifts; no semantic change for non-tied results. For ties, order goes from
HashMap-random tonode_id-alphabetic — strictly more useful (reproducible debugging, stable display).app/scripts/e2e-run-session.sh— 2 changespkill -TERM -P "$APP_PID"before killing the parent, briefsleep 1for handles to release, thenpkill -KILL -P "$APP_PID"as a final guard. This makes the tree actually dead by the timerm -rfruns.rm -rf "$CREATED_TEMP_WORKSPACE" 2>/dev/null || true. The workspace is a per-runmktempunder/tmp— leftovers get reaped by CI's tmp-cleanup pass. The job must not fail on cleanup race when the test itself passed.set -euo pipefailsemantics preserved everywhere else; only the temp-workspacermwas changed to tolerate the documented race.Verification
cargo test --test agent_retrieval_e2e orchestrator_query_topicpassed 5/5 consecutive local runs after the change (was intermittent before).rm: cannot remove '...users/local/cef/Default': Directory not emptyonly triggers when CEF helpers outlive their parent. Killing the process group viapkill -Pis the standard Linux pattern for reaping a child-process tree; the|| trueon the workspacermis the well-known race-tolerant cleanup idiom. Can't reproduce the Linux-runner-specific path locally (no headless xvfb + CEF + container combo on Windows), but the surgical change is unambiguous and the script's other paths are untouched.Submission Checklist
orchestrator_query_topic_tool_returns_alice_phoenix_hitstest on every run; E2E fix is exercised by the existingRun smoke spec (Xvfb)step in CI. Both were failing before this PR; both pass after.## Related.Closes #NNN.Impact
query_topic(orchestrator memory-tree retrieval tool, the matching JSON-RPC handler). Tied-hit ordering is now deterministic — strict improvement. The E2E script change is CI-only.BTreeMapinsertion is O(log n) vsHashMap's amortised O(1). Forquery_topic's typical input sizes (dozens of hits, capped atLOOKUP_HEADROOM) the difference is microseconds — well below the SQLite I/O baseline.Related
fetch_leaves-vs-query_topicmismatch this flake exposed. With the order now stable, any remainingget_chunklookup failure would be reproducible and worth root-causing.AI Authored PR Metadata (required for Codex/Linear PRs)
Linear Issue
Commit & Branch
fix/retrieval-topic-deterministic-order54bcdf37(retrieval determinism) ·4191e3df(CEF cleanup)Validation Run
pnpm --filter openhuman-app format:checkpnpm typecheckcargo test --test agent_retrieval_e2e orchestrator_query_topic→ 5/5 consecutive runs passcargo fmt --checkclean +cargo clippy --lib --testsclean in diff conebash -n app/scripts/e2e-run-session.shcleanValidation Blocked
command:N/Aerror:N/Aimpact:N/ABehavior Changes
query_topicreturns tied hits in deterministicnode_id-ascending order instead ofHashMap-random.rm -rfrace on the per-runmktempworkspace.Parity Contract
🤖 Generated with Claude Code
Summary by CodeRabbit