feat(system): S85: Trigger self-healing live + drone error handler + watchdog v1.1 snapshot + STATUS

Fix 6 bugs from DPLAN-0111 audit + cleanup:
- Bug #3: memory_threshold_exceeded.py handler→modules import path
- Bug #5: startup catch-up fires error_detected (Medic v2) not error_logged
- Bug #7: circuit_breaker_record_error at detection time, not dispatch time
- Bug #8: add encoding=utf-8 to 5 open() calls missing it
- Bug #9: core.py _fire_to_handlers copies data dict before mutation
- Cleanup: local prompt 12→14 events, remove stale branch prefixes
  (backup, The_Commons, aipass_os, aipass_business), add spawn/devpulse

370 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AIOSAI
2026-04-10 13:54:29 -07:00
co-authored by Claude Opus 4.6
parent f564996a8f
commit 0c9a57f912
7 changed files with 18 additions and 14 deletions
@@ -6,7 +6,7 @@ Event bus and error dispatch for AIPass. I detect errors, fingerprint them, gate
## Architecture
```
trigger.py (entry point — auto-discovers modules/)
├── core.py → Event bus: fire/on/off/status (12 events, 12 handlers)
├── core.py → Event bus: fire/on/off/status (14 events, 14 handlers)
├── errors.py → Error registry CLI: list/detail/suppress/resolve/stats/circuit-breaker
├── medic.py → Medic toggle: on/off/status/mute/unmute
├── log_events.py → Centralized log watcher (system_logs/)
@@ -350,7 +350,7 @@ def _write_suppression_log(reason: str, branch: str, module: str, message: str)
try:
suppressed_log = TRIGGER_ROOT / "logs" / "medic_suppressed.log"
suppressed_log.parent.mkdir(parents=True, exist_ok=True)
with open(suppressed_log, 'a') as f:
with open(suppressed_log, 'a', encoding='utf-8') as f:
f.write(
f"{datetime.now().isoformat()} | "
f"{reason} - {branch}: {module} - {message[:100]}\n"
@@ -364,7 +364,7 @@ def _write_rate_log(reason: str, detail: str) -> None:
try:
rate_log = TRIGGER_ROOT / "logs" / "rate_limited.log"
rate_log.parent.mkdir(parents=True, exist_ok=True)
with open(rate_log, 'a') as f:
with open(rate_log, 'a', encoding='utf-8') as f:
f.write(f"{datetime.now().isoformat()} | {reason}: {detail}\n")
except Exception as exc:
_log_warning(f"rate log write failed ({reason}): {exc}")
@@ -445,6 +445,10 @@ def handle_error_detected(
_write_suppression_log(f"First occurrence (count={count}) - waiting for pattern", branch, module, message)
return
# Record error for circuit breaker at detection time (not dispatch time)
if _REGISTRY_DISPATCH_AVAILABLE:
circuit_breaker_record_error()
# Callback must be set by module layer before events fire
if _send_email is None:
return
@@ -531,9 +535,8 @@ def handle_error_detected(
# Record dispatch for tracking
if _REGISTRY_DISPATCH_AVAILABLE and fingerprint:
# Medic v2: per-fingerprint dispatch tracking + circuit breaker
# Medic v2: per-fingerprint dispatch tracking
registry_record_dispatch(fingerprint)
circuit_breaker_record_error()
else:
# Legacy: per-branch rate limiting
_record_dispatch(recipient)
@@ -170,7 +170,7 @@ def _log_suppression(reason: str, branch: str, source_module: str, message: str)
"""
try:
SUPPRESSED_LOG.parent.mkdir(parents=True, exist_ok=True)
with open(SUPPRESSED_LOG, 'a') as f:
with open(SUPPRESSED_LOG, 'a', encoding='utf-8') as f:
f.write(
f"{datetime.now().isoformat()} | "
f"{reason} - suppressed dispatch for {branch}: "
@@ -134,9 +134,9 @@ def handle_memory_threshold_exceeded(
if not branch or not file_name or line_count is None:
return
# Import AI_Mail delivery
# Import AI_Mail delivery (modules-level API, not handler-level)
try:
from aipass.ai_mail.apps.handlers.email.delivery import deliver_email_to_branch
from aipass.ai_mail.apps.modules.email import deliver_email_to_branch
except ImportError:
return
@@ -56,7 +56,7 @@ def _load_trigger_data() -> Dict[str, Any]:
"""Load trigger_data.json with error_catchup section."""
try:
if TRIGGER_DATA_FILE.exists():
with open(TRIGGER_DATA_FILE, 'r') as f:
with open(TRIGGER_DATA_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
if 'error_catchup' not in data:
data['error_catchup'] = {
@@ -103,7 +103,7 @@ def _log_suppression(reason: str) -> None:
"""
try:
SUPPRESSED_LOG.parent.mkdir(parents=True, exist_ok=True)
with open(SUPPRESSED_LOG, 'a') as f:
with open(SUPPRESSED_LOG, 'a', encoding='utf-8') as f:
f.write(f"{datetime.now().isoformat()} | error_catchup: {reason}\n")
except Exception as exc:
_log_warning(f"log suppression write failed: {exc}")
@@ -349,7 +349,7 @@ def _run_error_catchup(fire_event: Optional[Callable[..., None]] = None) -> None
if errors and fire_event is not None:
for error in errors:
fire_event('error_logged', **error)
fire_event('error_detected', **error)
hash_list = list(processed_hashes)
max_h = catchup.get('max_hashes', MAX_HASHES)
@@ -112,9 +112,9 @@ SYSTEM_LOGS_DIR = AIPASS_PKG_ROOT.parent.parent / "system_logs"
# Known branch prefixes that appear in system_logs filenames (<prefix>_<module>.log).
# Sorted longest-first so longer prefixes match before shorter ones.
_SYSTEM_LOGS_BRANCH_PREFIXES: list = sorted([
'ai_mail', 'api', 'backup', 'cli', 'drone', 'flow',
'prax', 'trigger', 'seedgo', 'memory', 'The_Commons',
'aipass_os', 'aipass_business',
'ai_mail', 'api', 'cli', 'drone', 'flow',
'prax', 'trigger', 'seedgo', 'memory',
'spawn', 'devpulse',
], key=len, reverse=True)
# Event fire callback (set by module, avoids handler importing from modules)
+1
View File
@@ -96,6 +96,7 @@ class Trigger:
def _fire_to_handlers(cls, event: str, data: dict) -> None:
"""Fire a single event to its registered handlers."""
handlers = cls._handlers.get(event, [])
data = dict(data) # Copy to avoid mutating caller's dict
data['fire_event'] = cls.fire
for handler in handlers:
try: