From b57b573085edf5a76cda5ba16d381eff07756396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Albert=20Lie=20=EC=9D=B4=EC=98=81=EB=8D=95?= Date: Mon, 16 Mar 2026 16:38:20 -0400 Subject: [PATCH] fix(continuous-learning-v2): add lazy-start observer logic (#508) * feat(continuous-learning-v2): add lazy-start observer logic Auto-starts observer when observer.enabled: true in config and no .observer.pid exists. Co-Authored-By: Claude Opus 4.6 * fix(continuous-learning-v2): address PR review concerns - Use flock for atomic check-then-act to prevent race conditions - Check both project-scoped AND global PID files before starting - Support CLV2_CONFIG override for config file path - Check disabled file in lazy-start logic - Use double-check pattern after acquiring lock Co-Authored-By: Claude Opus 4.6 * fix(observe.sh): address PR review comments - Add stale PID cleanup via _CHECK_OBSERVER_RUNNING function - Add macOS fallback using lockfile when flock unavailable - Fix CLV2_CONFIG override: use EFFECTIVE_CONFIG for both check and read - Use proper Python context manager (with open() as f) - Deduplicate signaled PIDs to avoid duplicate USR1 signals Co-Authored-By: Claude Opus 4.6 * fix(observe.sh): wrap macOS lockfile fallback in subshell with trap - Wrap lockfile block in subshell so exit 0 only terminates that block - Add trap for EXIT to clean up lock file on script interruption - Add -l 30 (30 second expiry) to prevent permanent lock file stuck Co-Authored-By: Claude Opus 4.6 * fix(observe.sh): address remaining PR review comments - Validate PID is a positive integer before kill calls to prevent signaling invalid targets (e.g. -1 could signal all processes) - Pass config path via env var instead of interpolating shell variable into Python -c string to prevent injection/breakage on special paths - Check CLV2_CONFIG-derived directory for disabled file so disable guard respects the same config source as lazy-start Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../continuous-learning-v2/hooks/observe.sh | 106 +++++++++++++++++- 1 file changed, 103 insertions(+), 3 deletions(-) diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index 7e524f29..14a862be 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -83,10 +83,13 @@ fi CONFIG_DIR="${HOME}/.claude/homunculus" -# Skip if disabled +# Skip if disabled (check both default and CLV2_CONFIG-derived locations) if [ -f "$CONFIG_DIR/disabled" ]; then exit 0 fi +if [ -n "${CLV2_CONFIG:-}" ] && [ -f "$(dirname "$CLV2_CONFIG")/disabled" ]; then + exit 0 +fi # Prevent observe.sh from firing on non-human sessions to avoid: # - ECC observing its own Haiku observer sessions (self-loop) @@ -275,12 +278,109 @@ if parsed["output"] is not None: print(json.dumps(observation)) ' >> "$OBSERVATIONS_FILE" -# Signal observer if running (check both project-scoped and global observer) +# Lazy-start observer if enabled but not running (first-time setup) +# Use flock for atomic check-then-act to prevent race conditions +# Fallback for macOS (no flock): use lockfile or skip +LAZY_START_LOCK="${PROJECT_DIR}/.observer-start.lock" +_CHECK_OBSERVER_RUNNING() { + local pid_file="$1" + if [ -f "$pid_file" ]; then + local pid + pid=$(cat "$pid_file" 2>/dev/null) + # Validate PID is a positive integer (>1) to prevent signaling invalid targets + case "$pid" in + ''|*[!0-9]*|0|1) + rm -f "$pid_file" 2>/dev/null || true + return 1 + ;; + esac + if kill -0 "$pid" 2>/dev/null; then + return 0 # Process is alive + fi + # Stale PID file - remove it + rm -f "$pid_file" 2>/dev/null || true + fi + return 1 # No PID file or process dead +} + +if [ -f "${CONFIG_DIR}/disabled" ]; then + OBSERVER_ENABLED=false +else + OBSERVER_ENABLED=false + CONFIG_FILE="${SKILL_ROOT}/config.json" + # Allow CLV2_CONFIG override + if [ -n "${CLV2_CONFIG:-}" ]; then + CONFIG_FILE="$CLV2_CONFIG" + fi + # Use effective config path for both existence check and reading + EFFECTIVE_CONFIG="$CONFIG_FILE" + if [ -f "$EFFECTIVE_CONFIG" ] && [ -n "$PYTHON_CMD" ]; then + _enabled=$(CLV2_CONFIG_PATH="$EFFECTIVE_CONFIG" "$PYTHON_CMD" -c " +import json, os +with open(os.environ['CLV2_CONFIG_PATH']) as f: + cfg = json.load(f) +print(str(cfg.get('observer', {}).get('enabled', False)).lower()) +" 2>/dev/null || echo "false") + if [ "$_enabled" = "true" ]; then + OBSERVER_ENABLED=true + fi + fi +fi + +# Check both project-scoped AND global PID files (with stale PID recovery) +if [ "$OBSERVER_ENABLED" = "true" ]; then + # Clean up stale PID files first + _CHECK_OBSERVER_RUNNING "${PROJECT_DIR}/.observer.pid" || true + _CHECK_OBSERVER_RUNNING "${CONFIG_DIR}/.observer.pid" || true + + # Check if observer is now running after cleanup + if [ ! -f "${PROJECT_DIR}/.observer.pid" ] && [ ! -f "${CONFIG_DIR}/.observer.pid" ]; then + # Use flock if available (Linux), fallback for macOS + if command -v flock >/dev/null 2>&1; then + ( + flock -n 9 || exit 0 + # Double-check PID files after acquiring lock + _CHECK_OBSERVER_RUNNING "${PROJECT_DIR}/.observer.pid" || true + _CHECK_OBSERVER_RUNNING "${CONFIG_DIR}/.observer.pid" || true + if [ ! -f "${PROJECT_DIR}/.observer.pid" ] && [ ! -f "${CONFIG_DIR}/.observer.pid" ]; then + nohup "${SKILL_ROOT}/agents/start-observer.sh" start >/dev/null 2>&1 & + fi + ) 9>"$LAZY_START_LOCK" + else + # macOS fallback: use lockfile if available, otherwise skip + if command -v lockfile >/dev/null 2>&1; then + # Use subshell to isolate exit and add trap for cleanup + ( + trap 'rm -f "$LAZY_START_LOCK" 2>/dev/null || true' EXIT + lockfile -r 1 -l 30 "$LAZY_START_LOCK" 2>/dev/null || exit 0 + _CHECK_OBSERVER_RUNNING "${PROJECT_DIR}/.observer.pid" || true + _CHECK_OBSERVER_RUNNING "${CONFIG_DIR}/.observer.pid" || true + if [ ! -f "${PROJECT_DIR}/.observer.pid" ] && [ ! -f "${CONFIG_DIR}/.observer.pid" ]; then + nohup "${SKILL_ROOT}/agents/start-observer.sh" start >/dev/null 2>&1 & + fi + rm -f "$LAZY_START_LOCK" 2>/dev/null || true + ) + fi + fi + fi +fi + +# Signal observer if running (check both project-scoped and global observer, deduplicate) +signaled_pids=" " for pid_file in "${PROJECT_DIR}/.observer.pid" "${CONFIG_DIR}/.observer.pid"; do if [ -f "$pid_file" ]; then - observer_pid=$(cat "$pid_file") + observer_pid=$(cat "$pid_file" 2>/dev/null || true) + # Validate PID is a positive integer (>1) + case "$observer_pid" in + ''|*[!0-9]*|0|1) rm -f "$pid_file" 2>/dev/null || true; continue ;; + esac + # Deduplicate: skip if already signaled this pass + case "$signaled_pids" in + *" $observer_pid "*) continue ;; + esac if kill -0 "$observer_pid" 2>/dev/null; then kill -USR1 "$observer_pid" 2>/dev/null || true + signaled_pids="${signaled_pids}${observer_pid} " fi fi done