feat(memory): auto-process memory pool + rollover on session-start/pre-compact (TDPLAN-0005)

This commit is contained in:
AIOSAI
2026-06-06 20:28:19 -07:00
parent 8efb204486
commit 1ef1e2e89c
41 changed files with 1575 additions and 2430 deletions
+33
View File
@@ -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": {
+9 -7
View File
@@ -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]