Skip to content
Draft
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
61 changes: 61 additions & 0 deletions plugins/codex/hooks/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 >/dev/null 2>&1 &
echo $! > "$INDEX_PIDFILE"
}

# Kill a process and its entire process group to avoid orphans
_kill_tree() {
local pid="$1"
Expand Down
5 changes: 2 additions & 3 deletions plugins/codex/hooks/stop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions tests/test_codex_stop_hook_worker.py
Original file line number Diff line number Diff line change
@@ -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()