feat(memory): auto-process memory pool + rollover on session-start/pre-compact (TDPLAN-0005)
This commit is contained in:
@@ -344,6 +344,19 @@
|
||||
"pattern": "prax logger import",
|
||||
"reason": "Systemd service \u2014 uses print() to journald, not prax logger. Importing prax adds unnecessary dependency for a standalone daemon."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/events/memory_pool.py",
|
||||
"standard": "silent_catch",
|
||||
"pattern": "_log_warning except",
|
||||
"reason": "Meta-logging helper: _log_warning() writes directly to file. Its own except block cannot log \u2014 you cannot log a failure to log."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/events/memory_pool.py",
|
||||
"standard": "error_handling",
|
||||
"lines": [43],
|
||||
"pattern": "except Exception: pass",
|
||||
"reason": "Meta-logging helper _log_warning() \u2014 cannot log a failure to log. Same pattern as silent_catch bypass."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/events/pr_status_sync.py",
|
||||
"standard": "silent_catch",
|
||||
@@ -505,6 +518,26 @@
|
||||
"standard": "cli_flags",
|
||||
"file": "apps/config.py",
|
||||
"reason": "Config module \u2014 not a CLI entry point. --version is in apps/trigger.py."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/events/memory_pool.py",
|
||||
"standard": "trigger",
|
||||
"lines": [37],
|
||||
"pattern": "prax logger docstring mention",
|
||||
"reason": "Docstring mentions 'prax logger' to explain WHY this file uses file-based logging instead. Not an import — same pattern as bulletin_created.py, pr_status_sync.py, etc."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_memory_pool_handler.py",
|
||||
"standard": "architecture",
|
||||
"pattern": "3-layer structure",
|
||||
"reason": "Test file \u2014 tests/ is the standard location for unit tests, not part of the apps/modules/handlers source tree."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_memory_pool_handler.py",
|
||||
"standard": "encapsulation",
|
||||
"lines": [40],
|
||||
"pattern": "handler imported directly",
|
||||
"reason": "Test helper _import_module() must import the handler module directly to test it. All trigger test files follow this pattern."
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -74,7 +74,7 @@ result = report_error(
|
||||
|
||||
## Events
|
||||
|
||||
14 events registered via `handlers/events/registry.py` on first `Trigger.fire()`. All fire through the event bus.
|
||||
15 events registered via `handlers/events/registry.py` on first `Trigger.fire()`. All fire through the event bus.
|
||||
|
||||
| Event | Handler | Trigger | Action |
|
||||
|-------|---------|---------|--------|
|
||||
@@ -92,6 +92,7 @@ result = report_error(
|
||||
| `cli_header_displayed` | `cli.py` | CLI displays headers | Registration hook |
|
||||
| `pr_created` | `pr_status_sync.py` | PR opened on GitHub | Runs `drone @prax status sync` (fire-and-forget) |
|
||||
| `pr_merged` | `pr_status_sync.py` | PR merged on GitHub | Runs `drone @prax status sync` (fire-and-forget) |
|
||||
| `memory_pool_auto_processed` | `memory_pool.py` | Hook engine runs `auto_process()` | Logs result; on failure fires `error_detected` for Medic dispatch |
|
||||
|
||||
## Medic
|
||||
|
||||
@@ -147,7 +148,7 @@ trigger/
|
||||
│ ├── json/
|
||||
│ │ └── json_handler.py # JSON structure logging
|
||||
│ ├── events/
|
||||
│ │ ├── registry.py # Auto-registers all 14 event handlers
|
||||
│ │ ├── registry.py # Auto-registers all 15 event handlers
|
||||
│ │ ├── startup.py # Startup catch-up scan
|
||||
│ │ ├── error_detected.py # 8-gate Medic dispatch
|
||||
│ │ ├── error_logged.py # Monitor-only (no dispatch)
|
||||
@@ -158,10 +159,11 @@ trigger/
|
||||
│ │ ├── memory_template_updated.py
|
||||
│ │ ├── memory.py # memory_saved placeholder
|
||||
│ │ ├── cli.py # cli_header_displayed hook
|
||||
│ │ └── pr_status_sync.py # PR → prax status sync
|
||||
│ │ ├── pr_status_sync.py # PR → prax status sync
|
||||
│ │ └── memory_pool.py # Pool auto-process observability
|
||||
│ └── watchers/
|
||||
│ └── log_watcher.py # System log watcher (system_logs/ dir)
|
||||
├── tests/ # 551 tests across 18 modules
|
||||
├── tests/ # 563 tests across 19 modules
|
||||
├── trigger_json/ # Runtime state files
|
||||
│ ├── trigger_config.json # Medic state, muted branches
|
||||
│ ├── error_registry.json # All tracked errors
|
||||
@@ -189,13 +191,13 @@ trigger/
|
||||
|
||||
## Testing
|
||||
|
||||
551 tests across 18 test modules, all passing. Coverage: 76/76 public functions (100%).
|
||||
575 tests across 19 test modules, all passing. Coverage: 76/76 public functions (100%).
|
||||
|
||||
```bash
|
||||
cd src/aipass/trigger && pytest # Run all tests
|
||||
```
|
||||
|
||||
Test files: `test_core`, `test_errors`, `test_medic`, `test_error_registry`, `test_error_reporter`, `test_medic_state`, `test_log_watcher`, `test_watchers_log_watcher`, `test_branch_log_events`, `test_log_events`, `test_json_handler`, `test_pr_status_sync`, `test_error_detected`, `test_event_handlers`, `test_log_watcher_service`, `test_plan_file_handler`, `test_startup_handler`, `test_trigger_entry`
|
||||
Test files: `test_core`, `test_errors`, `test_medic`, `test_error_registry`, `test_error_reporter`, `test_medic_state`, `test_log_watcher`, `test_watchers_log_watcher`, `test_branch_log_events`, `test_log_events`, `test_json_handler`, `test_pr_status_sync`, `test_error_detected`, `test_event_handlers`, `test_log_watcher_service`, `test_plan_file_handler`, `test_startup_handler`, `test_trigger_entry`, `test_memory_pool_handler`
|
||||
|
||||
## Compliance
|
||||
|
||||
@@ -203,7 +205,7 @@ Seedgo: 100% (34/34 standards). Zero type errors. All categories at 100%.
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2026-05-16*
|
||||
*Last Updated: 2026-06-06*
|
||||
|
||||
---
|
||||
[← Back to AIPass](../../../README.md)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: memory_pool.py
|
||||
# Description: Memory pool auto-process event handler — observability for pool processing
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-06
|
||||
# Modified: 2026-06-06
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Memory Pool Auto-Processed Event Handler
|
||||
|
||||
Handles memory_pool_auto_processed events fired by the hook engine after
|
||||
calling @memory's auto_process() entry point. Makes pool processing visible
|
||||
in AIPass's event/error tracking (not just buried in engine.jsonl).
|
||||
|
||||
On success: logs the result for monitoring.
|
||||
On failure: fires error_detected so the error enters the Medic dispatch pipeline.
|
||||
|
||||
Event data expected:
|
||||
- success: bool — overall result from auto_process()
|
||||
- branch: str — branch that triggered the processing (or "__global__")
|
||||
- pool: dict — {status, files_processed, total_chunks}
|
||||
- rollover: dict — {status, triggers, processed}
|
||||
- error: str | None — error message if success=False
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from aipass.trigger.apps.config import TRIGGER_ROOT
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
_HANDLER_LOG = TRIGGER_ROOT / "logs" / "memory_pool_handler.log"
|
||||
|
||||
|
||||
def _log_warning(message: str) -> None:
|
||||
"""Log warning to file (event handlers cannot import prax logger — causes recursion)."""
|
||||
try:
|
||||
_HANDLER_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
with open(_HANDLER_LOG, "a", encoding="utf-8") as f:
|
||||
f.write(f"{ts} | WARNING | {message}\n")
|
||||
except Exception:
|
||||
pass # Meta-logging: cannot log a failure to log
|
||||
|
||||
|
||||
def handle_memory_pool_auto_processed(
|
||||
success: bool | None = None,
|
||||
branch: str | None = None,
|
||||
pool: dict | None = None,
|
||||
rollover: dict | None = None,
|
||||
error: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Handle memory_pool_auto_processed event.
|
||||
|
||||
On success: logs pool/rollover stats for monitoring.
|
||||
On failure: fires error_detected to enter the Medic dispatch pipeline.
|
||||
|
||||
Args:
|
||||
success: Overall result from auto_process()
|
||||
branch: Branch that triggered processing
|
||||
pool: Pool processing result dict
|
||||
rollover: Rollover result dict
|
||||
error: Error message if success=False
|
||||
**kwargs: Additional event data (may include fire_event callback)
|
||||
"""
|
||||
pool = pool or {}
|
||||
rollover = rollover or {}
|
||||
files_processed = pool.get("files_processed", 0)
|
||||
total_chunks = pool.get("total_chunks", 0)
|
||||
|
||||
if success:
|
||||
json_handler.log_operation(
|
||||
"memory_pool_auto_processed",
|
||||
{
|
||||
"success": True,
|
||||
"files_processed": files_processed,
|
||||
"total_chunks": total_chunks,
|
||||
"pool_status": pool.get("status", "unknown"),
|
||||
"rollover_status": rollover.get("status", "unknown"),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
error_msg = error or "memory pool auto-process failed (no detail)"
|
||||
_log_warning(f"auto-process failure: {error_msg}")
|
||||
|
||||
json_handler.log_operation(
|
||||
"memory_pool_auto_processed",
|
||||
{
|
||||
"success": False,
|
||||
"error": error_msg,
|
||||
},
|
||||
)
|
||||
|
||||
fire_event = kwargs.get("fire_event")
|
||||
if fire_event is not None:
|
||||
fire_event(
|
||||
"error_detected",
|
||||
branch=branch or "memory",
|
||||
error_type="MemoryPoolAutoProcessError",
|
||||
message=error_msg,
|
||||
source_file="auto_process.py",
|
||||
)
|
||||
@@ -64,6 +64,7 @@ def setup_handlers():
|
||||
from .bulletin_created import handle_bulletin_created
|
||||
from .memory_template_updated import handle_memory_template_updated
|
||||
from .pr_status_sync import handle_pr_created, handle_pr_merged
|
||||
from .memory_pool import handle_memory_pool_auto_processed
|
||||
|
||||
trigger.on("startup", handle_startup)
|
||||
trigger.on("cli_header_displayed", handle_cli_header_displayed)
|
||||
@@ -76,5 +77,6 @@ def setup_handlers():
|
||||
trigger.on("memory_template_updated", handle_memory_template_updated)
|
||||
trigger.on("pr_created", handle_pr_created)
|
||||
trigger.on("pr_merged", handle_pr_merged)
|
||||
trigger.on("memory_pool_auto_processed", handle_memory_pool_auto_processed)
|
||||
|
||||
json_handler.log_operation("handlers_registered", {"success": True})
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_memory_pool_handler.py
|
||||
# Description: Tests for memory_pool_auto_processed event handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-06
|
||||
# Modified: 2026-06-06
|
||||
# =============================================
|
||||
|
||||
"""Tests for memory_pool event handler."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_infrastructure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Mock heavy infrastructure imports."""
|
||||
import sys
|
||||
|
||||
from aipass.trigger.apps.config import atomic_write_json
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.TRIGGER_ROOT = tmp_path
|
||||
mock_config.atomic_write_json = atomic_write_json
|
||||
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config)
|
||||
|
||||
mock_json_handler = MagicMock()
|
||||
mock_json_handler.log_operation = MagicMock(return_value=True)
|
||||
json_pkg = MagicMock()
|
||||
json_pkg.json_handler = mock_json_handler
|
||||
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json", json_pkg)
|
||||
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json.json_handler", mock_json_handler)
|
||||
|
||||
monkeypatch.delitem(sys.modules, "aipass.trigger.apps.handlers.events.memory_pool", raising=False)
|
||||
|
||||
|
||||
def _import_module():
|
||||
"""Import fresh after mocking."""
|
||||
import aipass.trigger.apps.handlers.events.memory_pool as m
|
||||
|
||||
return m
|
||||
|
||||
|
||||
class TestHandleMemoryPoolAutoProcessedSuccess:
|
||||
"""Tests for successful auto-process events."""
|
||||
|
||||
def test_logs_success(self) -> None:
|
||||
"""Logs pool stats via json_handler on success."""
|
||||
mod = _import_module()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_memory_pool_auto_processed(
|
||||
success=True,
|
||||
branch="memory",
|
||||
pool={"status": "success", "files_processed": 3, "total_chunks": 42},
|
||||
rollover={"status": "skipped", "triggers": 0, "processed": 0},
|
||||
)
|
||||
|
||||
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
|
||||
"memory_pool_auto_processed",
|
||||
{
|
||||
"success": True,
|
||||
"files_processed": 3,
|
||||
"total_chunks": 42,
|
||||
"pool_status": "success",
|
||||
"rollover_status": "skipped",
|
||||
},
|
||||
)
|
||||
|
||||
def test_success_does_not_fire_error(self) -> None:
|
||||
"""Success path does not fire error_detected."""
|
||||
mod = _import_module()
|
||||
fire_event = MagicMock()
|
||||
|
||||
mod.handle_memory_pool_auto_processed(
|
||||
success=True,
|
||||
pool={"status": "success", "files_processed": 0, "total_chunks": 0},
|
||||
rollover={"status": "skipped"},
|
||||
fire_event=fire_event,
|
||||
)
|
||||
|
||||
fire_event.assert_not_called()
|
||||
|
||||
def test_none_defaults(self) -> None:
|
||||
"""Handles all-None parameters gracefully."""
|
||||
mod = _import_module()
|
||||
mod.handle_memory_pool_auto_processed(success=True)
|
||||
|
||||
def test_empty_pool_noop(self) -> None:
|
||||
"""Zero files processed logs correctly."""
|
||||
mod = _import_module()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_memory_pool_auto_processed(
|
||||
success=True,
|
||||
pool={"status": "success", "files_processed": 0, "total_chunks": 0},
|
||||
)
|
||||
|
||||
call_args = json_handler.log_operation.call_args[0] # type: ignore[union-attr]
|
||||
assert call_args[1]["files_processed"] == 0
|
||||
assert call_args[1]["total_chunks"] == 0
|
||||
|
||||
|
||||
class TestHandleMemoryPoolAutoProcessedFailure:
|
||||
"""Tests for failed auto-process events."""
|
||||
|
||||
def test_fires_error_detected_on_failure(self) -> None:
|
||||
"""Fires error_detected through the event bus on failure."""
|
||||
mod = _import_module()
|
||||
fire_event = MagicMock()
|
||||
|
||||
mod.handle_memory_pool_auto_processed(
|
||||
success=False,
|
||||
branch="memory",
|
||||
error="ChromaDB connection refused",
|
||||
fire_event=fire_event,
|
||||
)
|
||||
|
||||
fire_event.assert_called_once_with(
|
||||
"error_detected",
|
||||
branch="memory",
|
||||
error_type="MemoryPoolAutoProcessError",
|
||||
message="ChromaDB connection refused",
|
||||
source_file="auto_process.py",
|
||||
)
|
||||
|
||||
def test_logs_failure(self) -> None:
|
||||
"""Logs failure via json_handler."""
|
||||
mod = _import_module()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_memory_pool_auto_processed(
|
||||
success=False,
|
||||
error="fastembed subprocess crashed",
|
||||
)
|
||||
|
||||
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
|
||||
"memory_pool_auto_processed",
|
||||
{
|
||||
"success": False,
|
||||
"error": "fastembed subprocess crashed",
|
||||
},
|
||||
)
|
||||
|
||||
def test_failure_default_error_message(self) -> None:
|
||||
"""Uses default error message when none provided."""
|
||||
mod = _import_module()
|
||||
fire_event = MagicMock()
|
||||
|
||||
mod.handle_memory_pool_auto_processed(
|
||||
success=False,
|
||||
fire_event=fire_event,
|
||||
)
|
||||
|
||||
call_kwargs = fire_event.call_args[1]
|
||||
assert "no detail" in call_kwargs["message"]
|
||||
|
||||
def test_failure_default_branch(self) -> None:
|
||||
"""Defaults branch to 'memory' when not provided."""
|
||||
mod = _import_module()
|
||||
fire_event = MagicMock()
|
||||
|
||||
mod.handle_memory_pool_auto_processed(
|
||||
success=False,
|
||||
error="test error",
|
||||
fire_event=fire_event,
|
||||
)
|
||||
|
||||
assert fire_event.call_args[1]["branch"] == "memory"
|
||||
|
||||
def test_failure_without_fire_event(self) -> None:
|
||||
"""Handles failure gracefully when fire_event callback not available."""
|
||||
mod = _import_module()
|
||||
mod.handle_memory_pool_auto_processed(
|
||||
success=False,
|
||||
error="something broke",
|
||||
)
|
||||
|
||||
def test_writes_handler_log_on_failure(self, tmp_path: Path) -> None:
|
||||
"""Writes to handler log file on failure."""
|
||||
mod = _import_module()
|
||||
|
||||
mod.handle_memory_pool_auto_processed(
|
||||
success=False,
|
||||
error="pool write failed",
|
||||
)
|
||||
|
||||
log_file = tmp_path / "logs" / "memory_pool_handler.log"
|
||||
assert log_file.exists()
|
||||
content = log_file.read_text()
|
||||
assert "pool write failed" in content
|
||||
|
||||
|
||||
class TestEventRegistration:
|
||||
"""Tests for event registration in the event system."""
|
||||
|
||||
def test_event_registered_and_discoverable(self) -> None:
|
||||
"""memory_pool_auto_processed is registered in the handler registry."""
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
mock_trigger = MagicMock()
|
||||
mock_trigger.on = MagicMock()
|
||||
|
||||
sys.modules.pop("aipass.trigger.apps.handlers.events.registry", None)
|
||||
sys.modules.pop("aipass.trigger.apps.modules.core", None)
|
||||
|
||||
core_mod = MagicMock()
|
||||
core_mod.trigger = mock_trigger
|
||||
sys.modules["aipass.trigger.apps.modules.core"] = core_mod
|
||||
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.deliver_email_to_branch = MagicMock(return_value=(True, None))
|
||||
sys.modules["aipass.ai_mail.apps.modules.email_send"] = mock_mail
|
||||
|
||||
from aipass.trigger.apps.handlers.events.registry import setup_handlers
|
||||
|
||||
setup_handlers()
|
||||
|
||||
registered_events = [call[0][0] for call in mock_trigger.on.call_args_list]
|
||||
assert "memory_pool_auto_processed" in registered_events
|
||||
|
||||
def test_fires_once_per_invocation(self) -> None:
|
||||
"""Handler executes once per event fire (not per-turn)."""
|
||||
mod = _import_module()
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
||||
|
||||
mod.handle_memory_pool_auto_processed(
|
||||
success=True,
|
||||
pool={"status": "success", "files_processed": 1, "total_chunks": 10},
|
||||
)
|
||||
|
||||
assert json_handler.log_operation.call_count == 1 # type: ignore[union-attr]
|
||||
Reference in New Issue
Block a user