Repository navigation
fix(memory): only unlink serve.port files that still hold our own port - #163
KM-IA-Agency wants to merge 5 commits into
Conversation
Several `cce serve` processes can run for the same project (one per agent session, or two agents on one repo). Each start overwrites serve.port and the rendezvous file with its own port, but on graceful exit every server unlinked both files unconditionally. When an older server exited, the newer one kept running with no rendezvous: hooks found no port and silently dropped captured turns until the next server start. The cleanup now removes a port file only when it still contains this server's bound port, and adds a regression test with two servers on one project. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Concurrent publication between the ownership check and deletion can still cause cleanup to remove a newer server’s rendezvous.
Review effort: Balanced
Findings: 1
What changed in this PR
Protects memory-hook discovery when multiple cce serve processes share a project.
Changes:
- Records the bound port and checks file ownership before cleanup.
- Adds a two-server regression test for sequential shutdowns.
| File | Description |
|---|---|
tests/memory/test_hook_server_cleanup.py |
Tests preservation of a newer server’s port files. |
src/context_engine/memory/hook_server.py |
Adds ownership checks before deleting port files. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The ownership check and the unlink were not atomic across processes: a peer could publish its port after cleanup read this server's port and before it unlinked the file, losing the peer's rendezvous. Publication and compare-and-delete now run under the same cross-process lock on `<storage_base>/serve.port.lock` (fcntl on POSIX, msvcrt on Windows), bounded to 2 s. On timeout, cleanup leaves the files rather than risk deleting a peer's rendezvous. Adds a regression test that publishes from a peer between the read and the unlink; it fails without the lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Compare-and-delete still left survivors without a rendezvous when the most recently started server exited first: it legitimately owned the port files, so it removed them. A short-lived server next to a long-running one (a headless agent run beside an interactive session) hits this on every exit. Each server now registers itself in `<storage_base>/serve.ports/<port>` under the port-files lock. On exit, a server whose port is still published rewrites the files with the most recently registered peer that accepts a loopback connection (0.2 s probe), and only deletes them when no peer is alive. Entries of peers killed without cleanup are pruned on the way. Adds two regression tests (handover to the survivor, dead registry entry skipped); both fail without this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@fazleelahhee pushed |
Servers killed without cleanup (TerminateProcess on Windows, SIGKILL) never remove their registry entry, and the exit-time sweep stops at the first live peer, so the registry kept growing: 23 entries for 4 live servers after a few hours with an agent host that restarts its MCP servers. Each start now drops every entry whose port refuses a loopback connection, under the port-files lock, before registering itself. Adds a regression test that fails without the sweep. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Headless agent runs (`claude -p` workers) need the MCP tools but not live capture, and their host may kill the server without cleanup. Such a server used to publish its port to serve.port at start and leave it pointing at a dead port, cutting live capture for every other session of the project until the next server start. With CCE_NO_HOOK_SERVER set to 1/true/yes/on, `cce serve` skips the hook listener entirely and never touches serve.port or the registry. Unit test for the switch; checked end to end on Windows (serve.port and registry unchanged across start and a hard kill). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bundles the fixes proposed upstream and not yet merged there: serve.port ownership, handover and registry sweep, CCE_NO_HOOK_SERVER (PR elara-labs#163); PostToolUse tool_response (PR elara-labs#166); dotted .cceignore patterns and directory targets (PR elara-labs#167). Full suite on Windows / Python 3.13: 1153 passed, 2 failed (symlink tests, failing on upstream main too). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Problem
When several
cce serveprocesses run for the same project (one per agent session, or e.g. Claude Code and Codex on the same repo), each start overwritesserve.portand the rendezvous file~/.cce/projects/<name>/serve.portwith its own port. On graceful exit,_unlink_port_filesremoved both files unconditionally.So as soon as an older server exited, the newer one kept running with no rendezvous:
cce_hook.cmd/ the hook script found no port and silently dropped every captured turn until the next server start. In our setup (six concurrentcce serveon one repo), live memory capture was mostly dead for four days without any visible error.Fix
_unlink_port_filesnow removes a port file only when it still contains this server's bound port. The bound port is kept in a closure because the aiohttp app is frozen once the runner is set up.Unchanged: the single-server case (files are still removed on exit, the existing tests pass), tolerance of files already removed, and the storage == rendezvous layout.
Tests
test_cleanup_leaves_port_files_owned_by_another_server: two servers on one project; stopping the first must leave the second's port in both files, and stopping the second removes them.tests/memory/test_hook_server_cleanup.py: 4 passed with the fix; the new test fails without it (1 failed, 3 passed). Run locally on Windows / Python 3.13 with-n0; I have not run the full suite locally.Not covered
SIGKILL still bypasses
on_cleanup, so a killed server leaves a stale file. That is the existing #66 limit, handled by the socket-liveness probe in the hook script.Update: cross-process lock (review feedback)
The ownership check and the unlink were not atomic across processes: a peer could publish between them. Publication (
_publish_port) and the compare-and-delete now share one cross-process lock on<storage_base>/serve.port.lock(fcntl on POSIX, msvcrt on Windows), bounded to 2 s. On timeout, cleanup leaves the files rather than risk deleting a peer's rendezvous.New regression
test_peer_publishing_during_cleanup_keeps_its_rendezvous: a peer publishes between the read and the unlink. It passes with the lock and fails with the lock disabled.tests/memory/test_hook_server_cleanup.py+test_hooks.py: 33 passed locally (Windows / Python 3.13,-n0).Update: hand the rendezvous over to a live peer
Compare-and-delete still left survivors without a rendezvous when the most recently started server exited first: it legitimately owned the files, so it removed them. A short-lived server next to a long-running one (a headless agent run beside an interactive session) hits this on every exit.
Each server now registers itself in
<storage_base>/serve.ports/<port>under the same lock. On exit, a server whose port is still published rewrites the files with the most recently registered peer that accepts a loopback connection (0.2 s probe), and deletes them only when no peer is alive. Registry entries of peers killed without cleanup are pruned on the way.New regressions:
test_last_started_server_exiting_hands_rendezvous_to_live_peerandtest_dead_peer_in_registry_is_pruned_not_published; both fail without this commit (2 failed, 5 passed). With it:tests/memory/test_hook_server_cleanup.py7 passed,tests/memory369 passed,ruff checkclean (Windows / Python 3.13).Update: sweep dead registry entries at start
Servers killed without cleanup (TerminateProcess on Windows, SIGKILL) never remove their
serve.ports/entry, and the exit-time handover stops at the first live peer, so the registry kept growing (23 entries for 4 live servers after a few hours next to an agent host that restarts its MCP servers). Each start now drops every entry whose port refuses a loopback connection, under the same lock, before registering itself. New regressiontest_start_prunes_registry_entries_of_dead_peers(fails without the change).tests/memory: 370 passed, ruff clean.Known limit, unchanged by this PR: a server killed without cleanup while it owns
serve.portstill leaves a stale file until the next server start (the hook script's liveness probe covers it, as for #66).Update:
CCE_NO_HOOK_SERVER=1for headless agent runsThe known limit above (a server killed without cleanup while it owns
serve.port) is what headlessclaude -pworkers hit: their host kills the MCP server hard on exit. Those runs need the MCP tools, not live capture, socce servenow skips the hook listener whenCCE_NO_HOOK_SERVERis1/true/yes/on, and never touchesserve.portor the registry. Default unchanged. Unit test for the switch; end-to-end on Windows:serve.portand the registry unchanged across start and a hard kill.tests/test_cli_serve.py+tests/memory: 378 passed, ruff clean.🤖 Generated with Claude Code