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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Albert Lie 이영덕
2026-03-16 16:38:20 -04:00
committed by GitHub
parent 01ed1b3b03
commit b57b573085

View File

@@ -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