diff --git a/plugins/codex/hooks/common.sh b/plugins/codex/hooks/common.sh index 1b3fa9ae..1884f112 100755 --- a/plugins/codex/hooks/common.sh +++ b/plugins/codex/hooks/common.sh @@ -159,6 +159,22 @@ run_maintenance() { # --- Index process cleanup --- INDEX_PIDFILE="$MEMSEARCH_DIR/.index.pid" +INDEX_LOCKDIR="$MEMSEARCH_DIR/.index.lock" + +index_is_running() { + if [ -f "$INDEX_PIDFILE" ]; then + local pid + pid=$(cat "$INDEX_PIDFILE" 2>/dev/null || true) + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + return 0 + fi + rm -f "$INDEX_PIDFILE" + fi + + local orphans + orphans=$(pgrep -f "memsearch index $MEMORY_DIR" 2>/dev/null || true) + [ -n "$orphans" ] +} # Kill any previously spawned background index processes for this project. # Also sweeps orphaned milvus_lite processes. @@ -200,6 +216,51 @@ kill_orphaned_index() { WATCH_PIDFILE="$MEMSEARCH_DIR/.watch.pid" +watch_is_running() { + if [ -f "$WATCH_PIDFILE" ]; then + local pid + pid=$(cat "$WATCH_PIDFILE" 2>/dev/null || true) + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + return 0 + fi + rm -f "$WATCH_PIDFILE" + fi + + local orphans + orphans=$(pgrep -f "memsearch watch $MEMORY_DIR" 2>/dev/null || true) + [ -n "$orphans" ] +} + +start_background_index() { + if [ "${MEMSEARCH_NO_WATCH:-}" = "1" ]; then + return 0 + fi + if ! memsearch_available; then + return 0 + fi + ensure_memory_dir + + # The watch process is responsible for indexing new stop-hook memories. + # Only fall back to a one-at-a-time background index when no watch is alive. + if watch_is_running; then + return 0 + fi + if index_is_running; then + return 0 + fi + + if [ -d "$INDEX_LOCKDIR" ]; then + rmdir "$INDEX_LOCKDIR" 2>/dev/null || return 0 + fi + mkdir "$INDEX_LOCKDIR" 2>/dev/null || return 0 + + ( + trap 'rm -rf "$INDEX_LOCKDIR" "$INDEX_PIDFILE" 2>/dev/null || true' EXIT + run_memsearch index "$MEMORY_DIR" >/dev/null + ) /dev/null 2>&1 & + echo $! > "$INDEX_PIDFILE" +} + # Kill a process and its entire process group to avoid orphans _kill_tree() { local pid="$1" diff --git a/plugins/codex/hooks/stop.sh b/plugins/codex/hooks/stop.sh index dcc90c15..c7b7650f 100755 --- a/plugins/codex/hooks/stop.sh +++ b/plugins/codex/hooks/stop.sh @@ -197,8 +197,7 @@ ${CONTENT}" local _uri _uri="${MILVUS_URI:-$(_memsearch config get milvus.uri 2>/dev/null || echo "")}" if [[ "$_uri" == http* ]] || [[ "$_uri" == tcp* ]]; then - kill_orphaned_index - run_memsearch index "$MEMORY_DIR" >/dev/null + start_background_index fi run_maintenance @@ -339,7 +338,7 @@ if [ ${#CONTENT} -gt "$MAX_CONTENT_CHARS" ]; then CONTENT="$(printf '%s' "$CONTENT" | _truncate_chars "$MAX_CONTENT_CHARS")...(truncated)" fi -WORK_FILE="$(mktemp "${TMPDIR:-/tmp}/memsearch-stop.XXXXXX.json")" +WORK_FILE="$(mktemp "${TMPDIR:-/tmp}/memsearch-stop.XXXXXX")" python3 - "$WORK_FILE" "$NOW" "$MEMORY_FILE" "$SESSION_ID" "$TRANSCRIPT_PATH" "$CONTENT" "$USER_QUESTION" "$LAST_MSG" <<'PY' from pathlib import Path import json diff --git a/tests/test_codex_stop_hook_worker.py b/tests/test_codex_stop_hook_worker.py new file mode 100644 index 00000000..8c6a2ccb --- /dev/null +++ b/tests/test_codex_stop_hook_worker.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import os +import signal +import subprocess +from contextlib import suppress +from pathlib import Path + +COMMON = Path("plugins/codex/hooks/common.sh") +STOP = Path("plugins/codex/hooks/stop.sh") + + +def _write_fake_memsearch(fake_bin: Path) -> None: + fake_memsearch = fake_bin / "memsearch" + fake_memsearch.write_text( + """#!/usr/bin/env bash +set -euo pipefail +if [ "${1:-}" = "config" ] && [ "${2:-}" = "get" ]; then + case "${3:-}" in + milvus.uri) echo "http://localhost:19530" ;; + *) echo "" ;; + esac + exit 0 +fi +if [ "${1:-}" = "index" ]; then + echo "$$ $*" >> "$MEMSEARCH_FAKE_INDEX_LOG" + sleep 5 + exit 0 +fi +exit 0 +""", + encoding="utf-8", + ) + fake_memsearch.chmod(0o755) + + +def _run_common_function(tmp_path: Path, function_body: str, log_file: Path) -> subprocess.CompletedProcess[str]: + project = tmp_path / "project" + project.mkdir() + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_fake_memsearch(fake_bin) + + script = f""" +set -euo pipefail +SCRIPT_DIR="{Path("plugins/codex/hooks").resolve()}" +source "{COMMON.resolve()}" +{function_body} +""" + env = { + **os.environ, + "HOME": str(tmp_path / "home"), + "MEMSEARCH_PROJECT_DIR": str(project), + "MEMSEARCH_SKIP_HOOK_STDIN": "1", + "MEMSEARCH_FAKE_INDEX_LOG": str(log_file), + "MILVUS_URI": "http://localhost:19530", + "PATH": f"{fake_bin}:/usr/bin:/bin:/usr/sbin:/sbin", + } + return subprocess.run(["bash", "-c", script], check=True, capture_output=True, text=True, env=env) + + +def test_stop_hook_uses_suffix_safe_mktemp_template() -> None: + source = STOP.read_text(encoding="utf-8") + + assert "memsearch-stop.XXXXXX.json" not in source + assert 'mktemp "${TMPDIR:-/tmp}/memsearch-stop.XXXXXX"' in source + + +def test_background_index_is_singleton_when_watch_is_missing(tmp_path: Path) -> None: + log_file = tmp_path / "index.log" + result = _run_common_function( + tmp_path, + """ +start_background_index +start_background_index +for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -s "$MEMSEARCH_FAKE_INDEX_LOG" ] && break + sleep 0.1 +done +cat "$INDEX_PIDFILE" +""", + log_file, + ) + + pid = int(result.stdout.strip()) + try: + lines = log_file.read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + finally: + with suppress(ProcessLookupError): + os.kill(pid, signal.SIGTERM) + + +def test_background_index_skips_when_watch_pid_is_alive(tmp_path: Path) -> None: + log_file = tmp_path / "index.log" + _run_common_function( + tmp_path, + """ +ensure_memory_dir +echo "$$" > "$WATCH_PIDFILE" +start_background_index +sleep 0.2 +""", + log_file, + ) + + assert not log_file.exists()