fix: CI green pass on PR#696 — lint (ruff format on trigger runaway tests), seedgo 100% both red branches (@hooks: new json_handler + persistent_alert/alert_dismiss wired through it, cc_sessions introspection gate, _menu_live nesting extraction, 1071 tests; @prax: rate_tracker DI refactor — module layer injects logs_dir + trigger.fire via configure(), 1028 tests), navmap trim 9.3k→7.9k under injection cap. All owner-fixed via dispatch, devpulse-verified: both audits 100% independently re-run, full runaway chain re-proven live post-refactor (332 lines/min storm → WARNING at 130s → trigger → @aipass triage → alert banner rendered in-session → dismiss clears). Burst-evasion design finding (pre-existing, not regression) filed to @prax by mail.
This commit is contained in:
+26
-27
@@ -1,16 +1,16 @@
|
||||
# AIPass — Navigation map
|
||||
|
||||
<!-- .aipass/tier1_navmap.md — Tier 1, injected periodically (cadence period 5) + at session start + right after compaction, when you most need the map back. The kernel (.aipass/tier0_kernel.md) arrives every turn; deep reference lives in `drone @agent --help` and topic guides. Size cap: keep the per-fire output under ~8,000 characters (the hook truncates near 10k). Format: .aipass/PROMPT_STYLE.md -->
|
||||
<!-- Tier 1 — injected on cadence 5, at session start, and post-compaction. Kernel = tier0_kernel.md, every turn. Cap: ~8,000 chars per fire (hook truncates near 10k). Format: PROMPT_STYLE.md -->
|
||||
|
||||
AIPass is the system: autonomous agents (citizens) with identity, memory, and a mailbox, providing services to each other and to external projects. Each agent lives in a branch — its home and address. Everything routes through `drone`.
|
||||
|
||||
# Finding your way
|
||||
|
||||
You can't carry everything; you can find anything — you're the librarian, not the encyclopedia. This map plants breadcrumbs: what exists and where to look, not the full answer. A breadcrumb is the trigger to fetch the answer, not the answer. Cheapest, highest-signal sources first:
|
||||
You can't carry everything; you can find anything. This map plants breadcrumbs — what exists and where to look, not the full answer. Cheapest, highest-signal sources first:
|
||||
|
||||
- bare `drone @agent` — introspection: the agent's live self-map of modules and commands.
|
||||
- `drone @agent --help` — the full curated reference. Source of truth for usage.
|
||||
- the agent's `README.md` — best quick overview of its domain and shape.
|
||||
- bare `drone @agent` — the agent's live self-map of modules and commands.
|
||||
- `drone @agent --help` — the full reference, source of truth for usage.
|
||||
- the agent's `README.md` — quick overview of its domain.
|
||||
|
||||
# Terminology
|
||||
|
||||
@@ -18,15 +18,15 @@ You can't carry everything; you can find anything — you're the librarian, not
|
||||
- Agent (citizen) — persistent identity in a branch: passport (`.trinity/`), memories, mailbox. Addressable as `@name`. You belong, you persist.
|
||||
- Sub-agent — disposable worker spawned for a task. No passport, no memory, not a citizen.
|
||||
- Registry — machine-managed catalogs (`registry.json`, flow/spawn registries). Never hand-edit — owners manage them.
|
||||
- Settings — provider `~/.claude/settings.json` (machine-wide, personal, don't touch) · project `<project>/.claude/settings.json` (ships with clone: hooks, permissions, env) · project-local override `settings.local.json`.
|
||||
- Settings — provider `~/.claude/settings.json` (personal, don't touch) · project `.claude/settings.json` (ships with clone) · local override `settings.local.json`.
|
||||
|
||||
# The framework
|
||||
|
||||
Every branch is built the same. All agents live at `src/aipass/<name>` · mail address `@<name>`.
|
||||
Every branch is built the same: `src/aipass/<name>` · mail `@<name>`.
|
||||
|
||||
```
|
||||
src/aipass/<name>/
|
||||
├── .trinity/ # identity & memory (passport, local, observations)
|
||||
├── .trinity/ # identity & memory
|
||||
├── .aipass/ # branch prompt
|
||||
├── .ai_mail.local/ # mailbox
|
||||
├── apps/
|
||||
@@ -39,23 +39,23 @@ src/aipass/<name>/
|
||||
|
||||
# The agents
|
||||
|
||||
- @drone — command router. Resolves `@agent`, routes commands, enforces tier-based access. Also the only git interface (`drone @git`).
|
||||
- @drone — command router. Routes commands, enforces tier-based access. Also the only git interface (`drone @git`).
|
||||
- @devpulse — orchestration hub, the user's primary collaborator. Coordinates the other agents, dispatches work, only agent with git write.
|
||||
- @aipass — the user's front-door concierge and its OWN CLI, NOT drone-routed: run `aipass` / `aipass --help` directly, never `drone @aipass` (drone can't resolve it). The human's best friend — onboarding (`aipass init`/`install`), `doctor` health, help chat, and OS/system questions ("why's my wifi dropping", "why's CC hogging CPU", "what is drone", "how do I make a project"). Serves humans, not agents — reads, never writes.
|
||||
- @aipass — the user's front-door concierge, its OWN CLI: run `aipass` directly, never `drone @aipass` (drone can't resolve it). Onboarding (`init`/`install`), `doctor` health, help chat, OS/system questions. Serves humans, not agents — reads, never writes.
|
||||
- @ai_mail — inter-agent email. `dispatch` = send + wake (default for handing work), `email` = no wake, plus inbox/view/reply/close.
|
||||
- @flow — plan lifecycle: create, list, close, templates, registry. Plan types in the Plans section — never create plan files by hand.
|
||||
- @seedgo — code standards and audits. The standard pack, `audit` and `checklist`, the quality gate before and after building.
|
||||
- @prax — logging and monitoring. The only logging system: `from aipass.prax import logger`. Real-time monitor, dashboards. Logs are the first diagnostic tool.
|
||||
- @memory — long-term memory. Archives overflowing `.trinity/` files into searchable vectors; `search` recalls past sessions. Nothing is lost — it moves deeper.
|
||||
- @flow — plan lifecycle: create, list, close, templates, registry. See the Plans section.
|
||||
- @seedgo — code standards and audits. `audit` and `checklist` — the quality gate before and after building.
|
||||
- @prax — logging and monitoring. The only logging system: `from aipass.prax import logger`. Real-time monitor, dashboards, runaway-log detection. Logs are the first diagnostic tool.
|
||||
- @memory — long-term memory. Archives overflowing `.trinity/` files into searchable vectors; `search` recalls past sessions.
|
||||
- @spawn — branch lifecycle. Creates, updates, syncs, retires agents — scaffolding, passports, registry, templates.
|
||||
- @hooks — Claude Code hook engine. Prompt injection and cadence, security gates (git/edit/rm), bridges, per-project config, sound.
|
||||
- @hooks — Claude Code hook engine. Prompt injection and cadence, security gates (git/edit/rm), bridges, persistent alerts, per-project config, sound.
|
||||
- @trigger — event handling. Pub/sub event bus, error detection (medic), log watching, error registry. Detects and dispatches — owners fix.
|
||||
- @api — external API gateway. Authenticated service clients (Google, OpenRouter, more), OAuth flows, key management, resilience.
|
||||
- @cli — display formatting with Rich. Shared rendering for terminal output.
|
||||
- @skills — capability framework. Discoverable, self-contained skill units any agent can run; consume AIPass services as opt-in imports (e.g. the Telegram skill).
|
||||
- @daemon — task scheduler. Cron-triggered firing; each branch owns its `.daemon/schedule.json`, the daemon discovers and fires.
|
||||
- @commons — the social space. Where branches post, comment, vote, and gather as a community.
|
||||
- @backup — local-first backups. Snapshots + versioning + restore for any directory; optional Google Drive sync (live, per-file mirror — slow on huge file counts, respect `.backupignore`). `.backup/` is a shared runtime namespace — @memory rollover and @flow (plan archive) also write there.
|
||||
- @skills — capability framework. Discoverable, self-contained skill units any agent can run (e.g. the Telegram skill).
|
||||
- @daemon — task scheduler. Each branch owns its `.daemon/schedule.json`; the daemon discovers and fires.
|
||||
- @commons — the social space. Branches post, comment, vote.
|
||||
- @backup — local-first backups. Snapshots, versioning, restore for any directory; optional Google Drive sync. `.backup/` is shared — @memory rollover and @flow archives write there too.
|
||||
|
||||
# Daily commands
|
||||
|
||||
@@ -76,8 +76,8 @@ Citizens dispatch each other directly — allowed and expected, no permission ne
|
||||
|
||||
- Need an answer, input, or work from them → `dispatch` (send + wake). A sleeping agent never reads plain email — a question sent as `email` stalls unread.
|
||||
- FYI only (status, steering an agent already awake) → `email` (no wake).
|
||||
- Replies don't wake either — but wake-back does: when an agent you dispatched completes, YOU are woken automatically. Team mission: the lead dispatches each phase BEFORE sleeping, the worker replies normally, wake-back brings the lead back to verify and hand off the next phase.
|
||||
- Exception — managers (`citizen_class: manager`, e.g. @devpulse) are never dispatched: they hold interactive sessions with the user, so the wake is blocked. `email` them — the mail lands and they see it live.
|
||||
- Replies never wake — wake-back does: when an agent you dispatched completes, YOU are woken. Team mission: the lead dispatches each phase BEFORE sleeping; the worker replies normally; wake-back returns the lead to verify and hand off the next phase.
|
||||
- Exception — managers (`citizen_class: manager`, e.g. @devpulse) are never dispatched — the wake is blocked. `email` them; the mail lands and they see it live.
|
||||
|
||||
Always reply to dispatches — reply auto-closes. No silent completions.
|
||||
|
||||
@@ -88,15 +88,15 @@ Plans carry context so you don't have to. Create only via `drone @flow create <p
|
||||
- DPLAN — dev plan. Thinking, brainstorming, architecture. Before building.
|
||||
- FPLAN — flow plan, the default. Building and executing. `master` template = multi-phase, spawns sub-FPLANs.
|
||||
- PPLAN — playbook. A throwaway run stamped from a reusable SOP template. Operating the system, not changing it.
|
||||
- More types exist and new ones register over time. Named a type you don't know? `drone @flow templates` lists them all, live.
|
||||
- More types register over time — `drone @flow templates` lists them all, live.
|
||||
|
||||
# Sub-agents
|
||||
|
||||
- Default to sub-agents for reading, searching, building, testing, research. Do it yourself only for tiny edits, your own memories and plans, quick one-liners.
|
||||
- Default to sub-agents for reading, searching, building, testing, research. Do it yourself only for tiny edits, your own memories/plans, one-liners.
|
||||
- One clear task per agent. Brief with full context — they know nothing of your conversation.
|
||||
- No git, no memory, no dispatch. They build and report; you decide and act.
|
||||
- Sub-agent = local disposable worker. Dispatch (`@ai_mail`) = wake a citizen with memory and identity. Branch-expert work → dispatch; else → sub-agent.
|
||||
- Models, good practice: opus for build and analysis, sonnet for routine investigation, haiku for trivial mechanical tasks. Never fable for sub-agents.
|
||||
- Models: opus for build/analysis, sonnet for routine investigation, haiku for trivial mechanical tasks. Never fable for sub-agents.
|
||||
|
||||
# Memory — .trinity/
|
||||
|
||||
@@ -105,12 +105,11 @@ Your continuity across sessions. Save proactively — after milestones, decision
|
||||
- `passport.json` — identity. Update only when identity genuinely evolves.
|
||||
- `local.json` — session log, key learnings, todos.
|
||||
- `observations.json` — what you learn about the user.
|
||||
- Overflow rolls to vectors automatically — never trim by hand. Two ChromaDB stores: your branch's `.chroma` (local) + a global one across all branches. `drone @memory search "query"` recalls them. Search before assuming you're cold.
|
||||
- Entry caps are hook-enforced (over-limit edit = rejected whole). The live cap is rendered in each file's `*_meta` line — read it before writing, draft to ~80% of it; if rejected, rewrite hard in one pass.
|
||||
- Overflow rolls to vectors automatically — never trim by hand. `drone @memory search "query"` recalls it — search before assuming you're cold.
|
||||
- Entry caps are hook-enforced (over-limit edit = rejected whole). The live cap is in each file's `*_meta` line — read it before writing, draft to ~80%; if rejected, rewrite hard in one pass.
|
||||
|
||||
# House rules
|
||||
|
||||
- Cross-platform, no hardcoded paths. Public repo — `pathlib`, never `/home/...`.
|
||||
- No bare imports — always `from aipass.<agent>.apps...`.
|
||||
- Registries are machine-managed (spawn, flow) — never hand-edit them.
|
||||
- State lives in `.trinity/` and dashboards, never in prompts. Prompts are signposts; memories record; registries catalog.
|
||||
|
||||
@@ -9,6 +9,27 @@ PyPI version — not the changelog header.
|
||||
|
||||
---
|
||||
|
||||
## [2026-07-15]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CI green pass on the runaway-log PR — every red was ours, every fix
|
||||
verified.** Morning-after triage of PR#696's failing checks: the test
|
||||
matrices' only failure was the known parked flake, but lint and the seedgo
|
||||
audit were genuinely red from the previous night's new code. One `ruff
|
||||
format` on trigger's runaway-handler tests fixed lint. The audit findings
|
||||
went back to their owners by dispatch: @hooks built the branch's missing
|
||||
json_handler and wired it into `persistent_alert`/`alert_dismiss`, added the
|
||||
introspection no-args gate, and flattened `_menu_live()`'s nesting
|
||||
(1071 tests green); @prax refactored `rate_tracker.py` to dependency
|
||||
injection — the module layer now injects `logs_dir` and `trigger.fire` via
|
||||
`configure()`, so the handler carries no cross-handler or handler→module
|
||||
imports (1028 tests green). Both branches re-audit at 100% across all 41
|
||||
standards, independently verified. Detection re-proven live post-refactor
|
||||
with a fresh planted log storm. Also trimmed the tier-1 navmap prompt
|
||||
(9.3k → 7.9k chars, under its ~8k injection cap) with the comms doctrine
|
||||
intact.
|
||||
|
||||
## [2026-07-14]
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""JSON Handler — Hooks Branch."""
|
||||
|
||||
__all__ = []
|
||||
@@ -0,0 +1,165 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: json_handler.py
|
||||
# Description: JSON auto-creating handler for hooks data files
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-15
|
||||
# Modified: 2026-07-15
|
||||
# =============================================
|
||||
|
||||
"""JSON auto-creating handler for hooks data files."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import inspect
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
_BRANCH_ROOT = Path(__file__).resolve().parents[3]
|
||||
_BRANCH_NAME = _BRANCH_ROOT.name
|
||||
JSON_DIR = _BRANCH_ROOT / f"{_BRANCH_NAME}_json"
|
||||
|
||||
|
||||
def _get_caller_module_name() -> str:
|
||||
"""Auto-detect calling module name from call stack."""
|
||||
stack = inspect.stack()
|
||||
if len(stack) > 2:
|
||||
caller_frame = stack[2]
|
||||
caller_path = Path(caller_frame.filename)
|
||||
module_name = caller_path.stem
|
||||
if module_name and not module_name.startswith("_"):
|
||||
return module_name
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _create_default(json_type: str, module_name: str) -> Any:
|
||||
"""Create default JSON structure from inline code defaults."""
|
||||
today = datetime.now().date().isoformat()
|
||||
if json_type == "config":
|
||||
return {
|
||||
"module_name": module_name,
|
||||
"version": "1.0.0",
|
||||
"config": {"max_log_entries": 100},
|
||||
"created": today,
|
||||
}
|
||||
elif json_type == "data":
|
||||
return {
|
||||
"module_name": module_name,
|
||||
"created": today,
|
||||
"last_updated": today,
|
||||
}
|
||||
elif json_type == "log":
|
||||
return []
|
||||
raise ValueError(f"Unknown json_type: {json_type}")
|
||||
|
||||
|
||||
def validate_json_structure(data: Any, json_type: str) -> bool:
|
||||
"""Validate JSON structure matches expected type."""
|
||||
if json_type == "config":
|
||||
return isinstance(data, dict) and all(k in data for k in ["module_name", "version", "config"])
|
||||
elif json_type == "data":
|
||||
return isinstance(data, dict) and all(k in data for k in ["created", "last_updated"])
|
||||
elif json_type == "log":
|
||||
return isinstance(data, list)
|
||||
return False
|
||||
|
||||
|
||||
def get_json_path(module_name: str, json_type: str) -> Path:
|
||||
"""Get path for module JSON file."""
|
||||
return JSON_DIR / f"{module_name}_{json_type}.json"
|
||||
|
||||
|
||||
def ensure_json_exists(module_name: str, json_type: str) -> bool:
|
||||
"""Ensure JSON file exists, create from template if missing."""
|
||||
JSON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
if json_path.exists():
|
||||
try:
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
if validate_json_structure(data, json_type):
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("[HOOKS] json_handler: ensure_json_exists failed for %s_%s: %s", module_name, json_type, exc)
|
||||
template = _create_default(json_type, module_name)
|
||||
json_path.write_text(json.dumps(template, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def load_json(module_name: str, json_type: str) -> Any | None:
|
||||
"""Load JSON file, auto-create if missing."""
|
||||
if not ensure_json_exists(module_name, json_type):
|
||||
return None
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
return json.loads(json_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def save_json(module_name: str, json_type: str, data: Any) -> bool:
|
||||
"""Save JSON file."""
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
if not validate_json_structure(data, json_type):
|
||||
raise ValueError(f"Invalid structure for {json_type} JSON")
|
||||
if json_type == "data" and isinstance(data, dict):
|
||||
data["last_updated"] = datetime.now().date().isoformat()
|
||||
json_path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def ensure_module_jsons(module_name: str) -> bool:
|
||||
"""Ensure all 3 JSON files exist for a module."""
|
||||
ensure_json_exists(module_name, "config")
|
||||
ensure_json_exists(module_name, "data")
|
||||
ensure_json_exists(module_name, "log")
|
||||
return True
|
||||
|
||||
|
||||
def log_operation(
|
||||
operation: str,
|
||||
data: dict[str, Any] | None = None,
|
||||
module_name: str | None = None,
|
||||
) -> bool:
|
||||
"""Add entry to module log with automatic rotation.
|
||||
|
||||
Auto-detects calling module if module_name not provided.
|
||||
"""
|
||||
if module_name is None:
|
||||
module_name = _get_caller_module_name()
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
config = load_json(module_name, "config")
|
||||
max_entries = 100
|
||||
if config and "config" in config:
|
||||
max_entries = config["config"].get("max_log_entries", 100)
|
||||
|
||||
log = load_json(module_name, "log")
|
||||
if log is None:
|
||||
log = []
|
||||
|
||||
entry: dict[str, Any] = {"timestamp": datetime.now().isoformat(), "operation": operation}
|
||||
if data:
|
||||
entry["data"] = data
|
||||
|
||||
log.append(entry)
|
||||
if len(log) > max_entries:
|
||||
log = log[-max_entries:]
|
||||
|
||||
return save_json(module_name, "log", log)
|
||||
|
||||
|
||||
def read_json_file(path: Path) -> Any:
|
||||
"""Read and parse a JSON file at an arbitrary path."""
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def write_json_file(path: Path, data: Any) -> None:
|
||||
"""Write data as JSON to an arbitrary path."""
|
||||
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
@@ -423,6 +423,71 @@ def _is_session_file_present(pid: int | None) -> bool:
|
||||
return session_file.exists()
|
||||
|
||||
|
||||
def _menu_single_session(
|
||||
session: dict,
|
||||
branch: str,
|
||||
claude_bin: str,
|
||||
defaults: list[str],
|
||||
extra_args: list[str] | None,
|
||||
) -> dict:
|
||||
"""Handle menu for a single live session."""
|
||||
label = _session_label(session, branch)
|
||||
is_bg = session.get("kind") in ("bg", "background")
|
||||
sys.stderr.write(f"\n{branch} — live chat: {label}\n")
|
||||
if is_bg:
|
||||
sys.stderr.write(" [Enter] resume this chat (stops bg, reopens as normal chat)\n")
|
||||
sys.stderr.write(" [n] start new chat (stops bg first)\n")
|
||||
sys.stderr.write(" [c] close it and exit (stops bg)\n\n")
|
||||
else:
|
||||
sys.stderr.write(" [Enter] resume this chat\n")
|
||||
sys.stderr.write(" [n] start new chat (closes the one above first)\n")
|
||||
sys.stderr.write(" [c] close it and exit\n\n")
|
||||
|
||||
choice = _read_choice()
|
||||
|
||||
if choice in ("", "r"):
|
||||
return _resume_session(session, branch, claude_bin, defaults, extra_args)
|
||||
if choice == "n":
|
||||
return _menu_single_new(session, is_bg, branch, claude_bin, defaults, extra_args)
|
||||
if choice == "c":
|
||||
return _menu_single_close(session, is_bg, branch, claude_bin)
|
||||
if choice in ("exit", "q", "quit"):
|
||||
return {"exit_code": 0, "action": "quit"}
|
||||
sys.stderr.write(" Unknown choice. Exiting.\n")
|
||||
return {"exit_code": 1, "error": "unknown choice"}
|
||||
|
||||
|
||||
def _menu_single_new(
|
||||
session: dict,
|
||||
is_bg: bool,
|
||||
branch: str,
|
||||
claude_bin: str,
|
||||
defaults: list[str],
|
||||
extra_args: list[str] | None,
|
||||
) -> dict:
|
||||
"""Handle 'n' choice for single session — stop current, start fresh."""
|
||||
if is_bg:
|
||||
stop = _daemon_stop(claude_bin, branch, session.get("pid"))
|
||||
if not stop["ok"]:
|
||||
return {"exit_code": 1, "error": stop["error"]}
|
||||
else:
|
||||
_stop_session(session, claude_bin)
|
||||
return _start_fresh(branch, claude_bin, defaults, extra_args)
|
||||
|
||||
|
||||
def _menu_single_close(session: dict, is_bg: bool, branch: str, claude_bin: str) -> dict:
|
||||
"""Handle 'c' choice for single session — close and exit."""
|
||||
if is_bg:
|
||||
stop = _daemon_stop(claude_bin, branch, session.get("pid"))
|
||||
if not stop["ok"]:
|
||||
return {"exit_code": 1, "error": stop["error"]}
|
||||
sys.stderr.write(f" Stopped bg session PID {session.get('pid')}.\n")
|
||||
else:
|
||||
result = _stop_session(session, claude_bin)
|
||||
sys.stderr.write(f" {result}\n")
|
||||
return {"exit_code": 0, "action": "closed"}
|
||||
|
||||
|
||||
def _menu_live(
|
||||
live: list[dict],
|
||||
branch: str,
|
||||
@@ -432,46 +497,7 @@ def _menu_live(
|
||||
) -> dict:
|
||||
"""Display menu when live session(s) exist."""
|
||||
if len(live) == 1:
|
||||
session = live[0]
|
||||
label = _session_label(session, branch)
|
||||
is_bg = session.get("kind") in ("bg", "background")
|
||||
sys.stderr.write(f"\n{branch} — live chat: {label}\n")
|
||||
if is_bg:
|
||||
sys.stderr.write(" [Enter] resume this chat (stops bg, reopens as normal chat)\n")
|
||||
sys.stderr.write(" [n] start new chat (stops bg first)\n")
|
||||
sys.stderr.write(" [c] close it and exit (stops bg)\n\n")
|
||||
else:
|
||||
sys.stderr.write(" [Enter] resume this chat\n")
|
||||
sys.stderr.write(" [n] start new chat (closes the one above first)\n")
|
||||
sys.stderr.write(" [c] close it and exit\n\n")
|
||||
|
||||
choice = _read_choice()
|
||||
|
||||
if choice in ("", "r"):
|
||||
return _resume_session(session, branch, claude_bin, defaults, extra_args)
|
||||
elif choice == "n":
|
||||
if is_bg:
|
||||
stop = _daemon_stop(claude_bin, branch, session.get("pid"))
|
||||
if not stop["ok"]:
|
||||
return {"exit_code": 1, "error": stop["error"]}
|
||||
else:
|
||||
_stop_session(session, claude_bin)
|
||||
return _start_fresh(branch, claude_bin, defaults, extra_args)
|
||||
elif choice == "c":
|
||||
if is_bg:
|
||||
stop = _daemon_stop(claude_bin, branch, session.get("pid"))
|
||||
if not stop["ok"]:
|
||||
return {"exit_code": 1, "error": stop["error"]}
|
||||
sys.stderr.write(f" Stopped bg session PID {session.get('pid')}.\n")
|
||||
else:
|
||||
result = _stop_session(session, claude_bin)
|
||||
sys.stderr.write(f" {result}\n")
|
||||
return {"exit_code": 0, "action": "closed"}
|
||||
elif choice in ("exit", "q", "quit"):
|
||||
return {"exit_code": 0, "action": "quit"}
|
||||
else:
|
||||
sys.stderr.write(" Unknown choice. Exiting.\n")
|
||||
return {"exit_code": 1, "error": "unknown choice"}
|
||||
return _menu_single_session(live[0], branch, claude_bin, defaults, extra_args)
|
||||
|
||||
sys.stderr.write(f"\n{branch} — {len(live)} live sessions:\n")
|
||||
for i, session in enumerate(live, 1):
|
||||
|
||||
@@ -14,6 +14,7 @@ import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.handlers.json import json_handler
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
_announced: set[str] = set()
|
||||
@@ -124,6 +125,7 @@ def handle(hook_data: dict) -> dict:
|
||||
plural = "s" if count != 1 else ""
|
||||
sound = f"alert: {count} active alert{plural}"
|
||||
|
||||
json_handler.log_operation("inject_alerts", {"count": len(alerts)})
|
||||
logger.info("[HOOKS] persistent_alert: %d active alerts injected", len(alerts))
|
||||
result = {"stdout": banner, "exit_code": 0}
|
||||
if sound:
|
||||
|
||||
@@ -14,6 +14,7 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.cli.apps.modules import err_console
|
||||
from aipass.hooks.apps.handlers.json import json_handler
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
CONSOLE = err_console
|
||||
@@ -52,7 +53,7 @@ def _dismiss_alert(alert_id: str) -> bool:
|
||||
return False
|
||||
|
||||
try:
|
||||
data = json.loads(alerts_path.read_text(encoding="utf-8"))
|
||||
data = json_handler.read_json_file(alerts_path)
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.error("[HOOKS] dismiss: read error: %s", exc)
|
||||
CONSOLE.print(f"[red]Failed to read alerts.json: {exc}[/red]")
|
||||
@@ -67,15 +68,13 @@ def _dismiss_alert(alert_id: str) -> bool:
|
||||
return False
|
||||
|
||||
try:
|
||||
alerts_path.write_text(
|
||||
json.dumps({"alerts": remaining}, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
json_handler.write_json_file(alerts_path, {"alerts": remaining})
|
||||
except OSError as exc:
|
||||
logger.error("[HOOKS] dismiss: write error: %s", exc)
|
||||
CONSOLE.print(f"[red]Failed to write alerts.json: {exc}[/red]")
|
||||
return False
|
||||
|
||||
json_handler.log_operation("dismiss_alert", {"alert_id": alert_id})
|
||||
logger.info("[HOOKS] dismiss: removed alert %s", alert_id)
|
||||
CONSOLE.print(f"[green]Dismissed alert {alert_id}[/green]")
|
||||
return True
|
||||
|
||||
@@ -264,9 +264,7 @@ def handle_command(command: str, args: list) -> bool:
|
||||
|
||||
if command in ("sessions", "cc_sessions"):
|
||||
if not args:
|
||||
CONSOLE.print("[bold cyan]sessions[/bold cyan]")
|
||||
CONSOLE.print(f" Sessions dir: {CC_SESSIONS_DIR}")
|
||||
_print_sessions_list()
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if args[0] == "reclaim":
|
||||
|
||||
@@ -27,20 +27,10 @@ from typing import Dict, Optional
|
||||
|
||||
from aipass.prax.apps.modules.logger import get_direct_logger
|
||||
from aipass.prax.apps.handlers.json import json_handler
|
||||
from aipass.prax.apps.handlers.config.load import get_system_logs_dir
|
||||
from aipass.prax.apps.handlers.monitoring.branch_detector import detect_branch_from_log
|
||||
|
||||
logger = get_direct_logger()
|
||||
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
|
||||
_HAS_TRIGGER = True
|
||||
except ImportError as exc:
|
||||
logger.info("[rate_tracker] trigger module not available: %s", exc)
|
||||
trigger = None # type: ignore[assignment]
|
||||
_HAS_TRIGGER = False
|
||||
|
||||
SCAN_INTERVAL = 10.0
|
||||
AVG_LINE_BYTES = 120
|
||||
|
||||
@@ -103,13 +93,32 @@ _tracked: Dict[str, FileRateState] = {}
|
||||
|
||||
_suppressed_files: set = set()
|
||||
|
||||
_logs_dir: Optional[Path] = None
|
||||
|
||||
_EVENT_CALLBACK = None
|
||||
|
||||
_state_loaded: bool = False
|
||||
|
||||
|
||||
def configure_suppression(file_names: Optional[set] = None) -> None:
|
||||
"""Set the list of log file names to skip during detection."""
|
||||
global _suppressed_files
|
||||
_suppressed_files = file_names or set()
|
||||
def configure(
|
||||
logs_dir: Optional[Path] = None,
|
||||
event_callback=None,
|
||||
suppressed_files: Optional[set] = None,
|
||||
) -> None:
|
||||
"""Inject dependencies from the module layer.
|
||||
|
||||
Args:
|
||||
logs_dir: Path to system_logs/ directory to scan.
|
||||
event_callback: Callable(event_name, **kwargs) for firing events.
|
||||
suppressed_files: Set of log file names to skip during detection.
|
||||
"""
|
||||
global _logs_dir, _EVENT_CALLBACK, _suppressed_files
|
||||
if logs_dir is not None:
|
||||
_logs_dir = logs_dir
|
||||
if event_callback is not None:
|
||||
_EVENT_CALLBACK = event_callback
|
||||
if suppressed_files is not None:
|
||||
_suppressed_files = suppressed_files
|
||||
|
||||
|
||||
def _load_state() -> None:
|
||||
@@ -160,15 +169,14 @@ def scan_rates() -> list:
|
||||
"""
|
||||
_load_state()
|
||||
|
||||
logs_dir = get_system_logs_dir()
|
||||
if not logs_dir.exists():
|
||||
if _logs_dir is None or not _logs_dir.exists():
|
||||
return []
|
||||
|
||||
now = time.time()
|
||||
results = []
|
||||
|
||||
current_files = set()
|
||||
for log_file in logs_dir.glob("*.log"):
|
||||
for log_file in _logs_dir.glob("*.log"):
|
||||
file_key = str(log_file)
|
||||
current_files.add(file_key)
|
||||
|
||||
@@ -302,8 +310,8 @@ def _fire_event(
|
||||
},
|
||||
)
|
||||
|
||||
if _HAS_TRIGGER and trigger is not None:
|
||||
trigger.fire(
|
||||
if _EVENT_CALLBACK is not None:
|
||||
_EVENT_CALLBACK(
|
||||
"runaway_log_detected",
|
||||
file_path=file_path,
|
||||
rate_lines_per_min=rate_lines_per_min,
|
||||
@@ -358,11 +366,3 @@ def get_snapshot() -> list:
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Clear all tracking state. Used in tests."""
|
||||
global _state_loaded
|
||||
_tracked.clear()
|
||||
_suppressed_files.clear()
|
||||
_state_loaded = False
|
||||
|
||||
@@ -121,6 +121,17 @@ def _display_rates(results: list, is_scan: bool) -> None:
|
||||
console.print()
|
||||
|
||||
|
||||
def _get_event_callback():
|
||||
"""Return trigger.fire if available, else None."""
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
|
||||
return trigger.fire
|
||||
except ImportError as exc:
|
||||
logger.info("[log-health] trigger not available: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""Handle log-health command.
|
||||
|
||||
@@ -142,7 +153,10 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
print_help()
|
||||
return True
|
||||
|
||||
from aipass.prax.apps.handlers.monitoring.rate_tracker import scan_rates, get_snapshot
|
||||
from aipass.prax.apps.handlers.monitoring.rate_tracker import scan_rates, get_snapshot, configure
|
||||
from aipass.prax.apps.handlers.config.load import get_system_logs_dir
|
||||
|
||||
configure(logs_dir=get_system_logs_dir(), event_callback=_get_event_callback())
|
||||
|
||||
subcmd = args[0]
|
||||
logger.info("[log-health] %s", subcmd)
|
||||
|
||||
@@ -490,7 +490,18 @@ def _log_watcher_worker():
|
||||
|
||||
def _rate_tracker_worker():
|
||||
"""Rate tracker thread — scans system_logs/ for runaway growth every SCAN_INTERVAL."""
|
||||
from aipass.prax.apps.handlers.monitoring.rate_tracker import scan_rates, SCAN_INTERVAL
|
||||
from aipass.prax.apps.handlers.monitoring.rate_tracker import scan_rates, configure, SCAN_INTERVAL
|
||||
from aipass.prax.apps.handlers.config.load import get_system_logs_dir
|
||||
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
|
||||
event_cb = trigger.fire
|
||||
except ImportError as exc:
|
||||
logger.info("[monitor] trigger not available for rate tracker: %s", exc)
|
||||
event_cb = None
|
||||
|
||||
configure(logs_dir=get_system_logs_dir(), event_callback=event_cb)
|
||||
|
||||
while not _stop_event.is_set():
|
||||
try:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_rate_tracker.py
|
||||
# Description: Tests for the rate tracker runaway-log detector
|
||||
# Version: 1.0.0
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-07-14
|
||||
# Modified: 2026-07-14
|
||||
# Modified: 2026-07-15
|
||||
# =============================================
|
||||
|
||||
"""Tests for apps/handlers/monitoring/rate_tracker.py
|
||||
@@ -13,9 +13,10 @@ Covers:
|
||||
- Sustained threshold detection (WARNING and CRITICAL)
|
||||
- Subsidence reset when rate drops
|
||||
- Per-file suppression
|
||||
- Event firing via trigger
|
||||
- Event firing via callback
|
||||
- File disappearance handling
|
||||
- get_snapshot() and reset()
|
||||
- get_snapshot() and configure()
|
||||
- Disk persistence
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -29,7 +30,7 @@ _HANDLER_MOCKS = {
|
||||
}
|
||||
|
||||
|
||||
def _import_tracker(monkeypatch):
|
||||
def _import_tracker(monkeypatch, logs_dir=None):
|
||||
"""Import (or reload) rate_tracker with handler mocks."""
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
fresh = {k: MagicMock() for k in _HANDLER_MOCKS}
|
||||
@@ -41,12 +42,14 @@ def _import_tracker(monkeypatch):
|
||||
else:
|
||||
mod = importlib.import_module("aipass.prax.apps.handlers.monitoring.rate_tracker")
|
||||
|
||||
trigger_mock = MagicMock()
|
||||
setattr(mod, "trigger", trigger_mock)
|
||||
setattr(mod, "_HAS_TRIGGER", True)
|
||||
|
||||
mod.reset()
|
||||
return mod, trigger_mock
|
||||
event_mock = MagicMock()
|
||||
mod._tracked.clear()
|
||||
mod._suppressed_files.clear()
|
||||
setattr(mod, "_state_loaded", False)
|
||||
setattr(mod, "_logs_dir", None)
|
||||
setattr(mod, "_EVENT_CALLBACK", None)
|
||||
mod.configure(logs_dir=logs_dir, event_callback=event_mock)
|
||||
return mod, event_mock
|
||||
|
||||
|
||||
class TestRateCalculation:
|
||||
@@ -54,32 +57,28 @@ class TestRateCalculation:
|
||||
|
||||
def test_first_scan_initializes_no_rate(self, tmp_path, monkeypatch):
|
||||
"""First scan seeds offsets — no rate calculated yet."""
|
||||
mod, _ = _import_tracker(monkeypatch)
|
||||
log_file = tmp_path / "system" / "test_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
log_file.write_text("line1\n" * 10)
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
(logs_dir / "test_module.log").write_text("line1\n" * 10)
|
||||
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
results = mod.scan_rates()
|
||||
mod, _ = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
results = mod.scan_rates()
|
||||
|
||||
assert results == []
|
||||
|
||||
def test_second_scan_calculates_rate(self, tmp_path, monkeypatch):
|
||||
"""Second scan with growth produces a rate."""
|
||||
mod, _ = _import_tracker(monkeypatch)
|
||||
log_file = tmp_path / "system" / "test_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
log_file = logs_dir / "test_module.log"
|
||||
log_file.write_text("x" * 100)
|
||||
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod, _ = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
mod.scan_rates()
|
||||
|
||||
log_file.write_text("x" * 1300)
|
||||
|
||||
with (
|
||||
patch.object(mod, "get_system_logs_dir", return_value=log_file.parent),
|
||||
patch.object(mod.time, "time", return_value=time.time() + 10.0),
|
||||
):
|
||||
with patch.object(mod.time, "time", return_value=time.time() + 10.0):
|
||||
results = mod.scan_rates()
|
||||
|
||||
assert len(results) == 1
|
||||
@@ -87,18 +86,14 @@ class TestRateCalculation:
|
||||
|
||||
def test_no_growth_produces_zero_rate(self, tmp_path, monkeypatch):
|
||||
"""File that hasn't grown has rate 0."""
|
||||
mod, _ = _import_tracker(monkeypatch)
|
||||
log_file = tmp_path / "system" / "test_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
log_file.write_text("x" * 100)
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
(logs_dir / "test_module.log").write_text("x" * 100)
|
||||
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod, _ = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
mod.scan_rates()
|
||||
|
||||
with (
|
||||
patch.object(mod, "get_system_logs_dir", return_value=log_file.parent),
|
||||
patch.object(mod.time, "time", return_value=time.time() + 10.0),
|
||||
):
|
||||
with patch.object(mod.time, "time", return_value=time.time() + 10.0):
|
||||
results = mod.scan_rates()
|
||||
|
||||
assert len(results) == 1
|
||||
@@ -106,20 +101,17 @@ class TestRateCalculation:
|
||||
|
||||
def test_truncated_file_resets_offset(self, tmp_path, monkeypatch):
|
||||
"""File that shrinks (rotation) resets offset without error."""
|
||||
mod, _ = _import_tracker(monkeypatch)
|
||||
log_file = tmp_path / "system" / "test_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
log_file = logs_dir / "test_module.log"
|
||||
log_file.write_text("x" * 10000)
|
||||
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod, _ = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
mod.scan_rates()
|
||||
|
||||
log_file.write_text("x" * 100)
|
||||
|
||||
with (
|
||||
patch.object(mod, "get_system_logs_dir", return_value=log_file.parent),
|
||||
patch.object(mod.time, "time", return_value=time.time() + 10.0),
|
||||
):
|
||||
with patch.object(mod.time, "time", return_value=time.time() + 10.0):
|
||||
results = mod.scan_rates()
|
||||
|
||||
assert results == []
|
||||
@@ -135,85 +127,82 @@ class TestSustainedThresholds:
|
||||
for i in range(intervals):
|
||||
log_file.write_bytes(b"x" * bytes_per_interval + log_file.read_bytes())
|
||||
|
||||
with (
|
||||
patch.object(mod, "get_system_logs_dir", return_value=log_file.parent),
|
||||
patch.object(
|
||||
mod.time,
|
||||
"time",
|
||||
return_value=base_time + (i + 1) * mod.SCAN_INTERVAL,
|
||||
),
|
||||
with patch.object(
|
||||
mod.time,
|
||||
"time",
|
||||
return_value=base_time + (i + 1) * mod.SCAN_INTERVAL,
|
||||
):
|
||||
results = mod.scan_rates()
|
||||
return results
|
||||
|
||||
def test_warning_fires_after_sustained_intervals(self, tmp_path, monkeypatch):
|
||||
"""WARNING fires after WARNING_SUSTAINED_INTERVALS above WARNING_LINES_PER_MIN."""
|
||||
mod, trigger_mock = _import_tracker(monkeypatch)
|
||||
log_file = tmp_path / "system" / "test_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
log_file = logs_dir / "test_module.log"
|
||||
log_file.write_text("x" * 100)
|
||||
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod, event_mock = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
mod.scan_rates()
|
||||
|
||||
bytes_per_interval = int(mod.WARNING_LINES_PER_MIN * mod.AVG_LINE_BYTES * mod.SCAN_INTERVAL / 60 * 1.5)
|
||||
|
||||
self._grow_file(log_file, bytes_per_interval, mod, mod.WARNING_SUSTAINED_INTERVALS)
|
||||
|
||||
trigger_mock.fire.assert_called_once()
|
||||
call_args = trigger_mock.fire.call_args
|
||||
event_mock.assert_called_once()
|
||||
call_args = event_mock.call_args
|
||||
assert call_args[0][0] == "runaway_log_detected"
|
||||
assert call_args[1]["severity"] == "warning"
|
||||
|
||||
def test_warning_does_not_fire_before_sustained(self, tmp_path, monkeypatch):
|
||||
"""WARNING does not fire before reaching sustained count."""
|
||||
mod, trigger_mock = _import_tracker(monkeypatch)
|
||||
log_file = tmp_path / "system" / "test_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
log_file = logs_dir / "test_module.log"
|
||||
log_file.write_text("x" * 100)
|
||||
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod, event_mock = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
mod.scan_rates()
|
||||
|
||||
bytes_per_interval = int(mod.WARNING_LINES_PER_MIN * mod.AVG_LINE_BYTES * mod.SCAN_INTERVAL / 60 * 1.5)
|
||||
|
||||
self._grow_file(log_file, bytes_per_interval, mod, mod.WARNING_SUSTAINED_INTERVALS - 1)
|
||||
|
||||
trigger_mock.fire.assert_not_called()
|
||||
event_mock.assert_not_called()
|
||||
|
||||
def test_critical_fires_after_sustained_intervals(self, tmp_path, monkeypatch):
|
||||
"""CRITICAL fires after CRITICAL_SUSTAINED_INTERVALS above CRITICAL_LINES_PER_MIN."""
|
||||
mod, trigger_mock = _import_tracker(monkeypatch)
|
||||
log_file = tmp_path / "system" / "test_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
log_file = logs_dir / "test_module.log"
|
||||
log_file.write_text("x" * 100)
|
||||
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod, event_mock = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
mod.scan_rates()
|
||||
|
||||
bytes_per_interval = int(mod.CRITICAL_LINES_PER_MIN * mod.AVG_LINE_BYTES * mod.SCAN_INTERVAL / 60 * 1.5)
|
||||
|
||||
self._grow_file(log_file, bytes_per_interval, mod, mod.CRITICAL_SUSTAINED_INTERVALS)
|
||||
|
||||
assert trigger_mock.fire.call_count == 1
|
||||
call_args = trigger_mock.fire.call_args
|
||||
assert event_mock.call_count == 1
|
||||
call_args = event_mock.call_args
|
||||
assert call_args[1]["severity"] == "critical"
|
||||
|
||||
def test_fires_only_once_until_subsides(self, tmp_path, monkeypatch):
|
||||
"""Event fires once — not again on continued high rate."""
|
||||
mod, trigger_mock = _import_tracker(monkeypatch)
|
||||
log_file = tmp_path / "system" / "test_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
log_file = logs_dir / "test_module.log"
|
||||
log_file.write_text("x" * 100)
|
||||
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod, event_mock = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
mod.scan_rates()
|
||||
|
||||
bytes_per_interval = int(mod.WARNING_LINES_PER_MIN * mod.AVG_LINE_BYTES * mod.SCAN_INTERVAL / 60 * 1.5)
|
||||
|
||||
self._grow_file(log_file, bytes_per_interval, mod, mod.WARNING_SUSTAINED_INTERVALS + 5)
|
||||
|
||||
assert trigger_mock.fire.call_count == 1
|
||||
assert event_mock.call_count == 1
|
||||
|
||||
|
||||
class TestSubsidence:
|
||||
@@ -221,56 +210,48 @@ class TestSubsidence:
|
||||
|
||||
def test_subsidence_resets_and_allows_refire(self, tmp_path, monkeypatch):
|
||||
"""After rate drops and rises again, event can fire again."""
|
||||
mod, trigger_mock = _import_tracker(monkeypatch)
|
||||
log_file = tmp_path / "system" / "test_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
log_file = logs_dir / "test_module.log"
|
||||
log_file.write_text("x" * 100)
|
||||
|
||||
mod, event_mock = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
|
||||
base_time = time.time()
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod.scan_rates()
|
||||
|
||||
bytes_per_interval = int(mod.WARNING_LINES_PER_MIN * mod.AVG_LINE_BYTES * mod.SCAN_INTERVAL / 60 * 1.5)
|
||||
|
||||
for i in range(mod.WARNING_SUSTAINED_INTERVALS):
|
||||
log_file.write_bytes(b"x" * bytes_per_interval + log_file.read_bytes())
|
||||
with (
|
||||
patch.object(mod, "get_system_logs_dir", return_value=log_file.parent),
|
||||
patch.object(
|
||||
mod.time,
|
||||
"time",
|
||||
return_value=base_time + (i + 1) * mod.SCAN_INTERVAL,
|
||||
),
|
||||
with patch.object(
|
||||
mod.time,
|
||||
"time",
|
||||
return_value=base_time + (i + 1) * mod.SCAN_INTERVAL,
|
||||
):
|
||||
mod.scan_rates()
|
||||
|
||||
assert trigger_mock.fire.call_count == 1
|
||||
assert event_mock.call_count == 1
|
||||
|
||||
idle_offset = mod.WARNING_SUSTAINED_INTERVALS + 1
|
||||
with (
|
||||
patch.object(mod, "get_system_logs_dir", return_value=log_file.parent),
|
||||
patch.object(
|
||||
mod.time,
|
||||
"time",
|
||||
return_value=base_time + idle_offset * mod.SCAN_INTERVAL,
|
||||
),
|
||||
with patch.object(
|
||||
mod.time,
|
||||
"time",
|
||||
return_value=base_time + idle_offset * mod.SCAN_INTERVAL,
|
||||
):
|
||||
mod.scan_rates()
|
||||
|
||||
for i in range(mod.WARNING_SUSTAINED_INTERVALS):
|
||||
log_file.write_bytes(b"x" * bytes_per_interval + log_file.read_bytes())
|
||||
offset = idle_offset + i + 1
|
||||
with (
|
||||
patch.object(mod, "get_system_logs_dir", return_value=log_file.parent),
|
||||
patch.object(
|
||||
mod.time,
|
||||
"time",
|
||||
return_value=base_time + offset * mod.SCAN_INTERVAL,
|
||||
),
|
||||
with patch.object(
|
||||
mod.time,
|
||||
"time",
|
||||
return_value=base_time + offset * mod.SCAN_INTERVAL,
|
||||
):
|
||||
mod.scan_rates()
|
||||
|
||||
assert trigger_mock.fire.call_count == 2
|
||||
assert event_mock.call_count == 2
|
||||
|
||||
|
||||
class TestSuppression:
|
||||
@@ -278,30 +259,28 @@ class TestSuppression:
|
||||
|
||||
def test_suppressed_file_not_tracked(self, tmp_path, monkeypatch):
|
||||
"""Suppressed files are skipped entirely."""
|
||||
mod, _ = _import_tracker(monkeypatch)
|
||||
log_file = tmp_path / "system" / "noisy_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
log_file.write_text("x" * 10000)
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
(logs_dir / "noisy_module.log").write_text("x" * 10000)
|
||||
|
||||
mod.configure_suppression({"noisy_module.log"})
|
||||
mod, _ = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
mod.configure(suppressed_files={"noisy_module.log"})
|
||||
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod.scan_rates()
|
||||
mod.scan_rates()
|
||||
mod.scan_rates()
|
||||
|
||||
assert "noisy_module.log" not in {Path(k).name for k in mod._tracked}
|
||||
|
||||
def test_non_suppressed_file_tracked(self, tmp_path, monkeypatch):
|
||||
"""Non-suppressed files are tracked normally."""
|
||||
mod, _ = _import_tracker(monkeypatch)
|
||||
log_file = tmp_path / "system" / "normal_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
log_file.write_text("x" * 100)
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
(logs_dir / "normal_module.log").write_text("x" * 100)
|
||||
|
||||
mod.configure_suppression({"other_module.log"})
|
||||
mod, _ = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
mod.configure(suppressed_files={"other_module.log"})
|
||||
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod.scan_rates()
|
||||
|
||||
assert any("normal_module.log" in k for k in mod._tracked)
|
||||
|
||||
@@ -311,30 +290,28 @@ class TestEventPayload:
|
||||
|
||||
def test_event_payload_fields(self, tmp_path, monkeypatch):
|
||||
"""Fired event includes all required fields."""
|
||||
mod, trigger_mock = _import_tracker(monkeypatch)
|
||||
log_file = tmp_path / "system" / "test_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
log_file = logs_dir / "test_module.log"
|
||||
log_file.write_text("x" * 100)
|
||||
|
||||
mod, event_mock = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
|
||||
base_time = time.time()
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod.scan_rates()
|
||||
|
||||
bytes_per_interval = int(mod.WARNING_LINES_PER_MIN * mod.AVG_LINE_BYTES * mod.SCAN_INTERVAL / 60 * 1.5)
|
||||
|
||||
for i in range(mod.WARNING_SUSTAINED_INTERVALS):
|
||||
log_file.write_bytes(b"x" * bytes_per_interval + log_file.read_bytes())
|
||||
with (
|
||||
patch.object(mod, "get_system_logs_dir", return_value=log_file.parent),
|
||||
patch.object(
|
||||
mod.time,
|
||||
"time",
|
||||
return_value=base_time + (i + 1) * mod.SCAN_INTERVAL,
|
||||
),
|
||||
with patch.object(
|
||||
mod.time,
|
||||
"time",
|
||||
return_value=base_time + (i + 1) * mod.SCAN_INTERVAL,
|
||||
):
|
||||
mod.scan_rates()
|
||||
|
||||
call_kwargs = trigger_mock.fire.call_args[1]
|
||||
call_kwargs = event_mock.call_args[1]
|
||||
assert "file_path" in call_kwargs
|
||||
assert "rate_lines_per_min" in call_kwargs
|
||||
assert "sustained_duration_sec" in call_kwargs
|
||||
@@ -348,20 +325,19 @@ class TestFileDisappearance:
|
||||
|
||||
def test_deleted_file_removed_from_tracking(self, tmp_path, monkeypatch):
|
||||
"""File removed between scans is cleaned from _tracked."""
|
||||
mod, _ = _import_tracker(monkeypatch)
|
||||
log_file = tmp_path / "system" / "ephemeral.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
log_file = logs_dir / "ephemeral.log"
|
||||
log_file.write_text("x" * 100)
|
||||
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod, _ = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
mod.scan_rates()
|
||||
|
||||
assert any("ephemeral.log" in k for k in mod._tracked)
|
||||
|
||||
log_file.unlink()
|
||||
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod.scan_rates()
|
||||
|
||||
assert not any("ephemeral.log" in k for k in mod._tracked)
|
||||
|
||||
@@ -371,13 +347,12 @@ class TestSnapshot:
|
||||
|
||||
def test_snapshot_returns_tracked_files(self, tmp_path, monkeypatch):
|
||||
"""Snapshot includes files from a previous scan."""
|
||||
mod, _ = _import_tracker(monkeypatch)
|
||||
log_file = tmp_path / "system" / "test_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
log_file.write_text("x" * 100)
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
(logs_dir / "test_module.log").write_text("x" * 100)
|
||||
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod, _ = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
mod.scan_rates()
|
||||
|
||||
snapshot = mod.get_snapshot()
|
||||
assert len(snapshot) == 1
|
||||
@@ -389,53 +364,49 @@ class TestSnapshot:
|
||||
assert mod.get_snapshot() == []
|
||||
|
||||
|
||||
class TestReset:
|
||||
"""reset() clears all state."""
|
||||
class TestConfigure:
|
||||
"""configure() sets module-level dependencies."""
|
||||
|
||||
def test_reset_clears_tracked(self, tmp_path, monkeypatch):
|
||||
"""After reset, tracked dict is empty."""
|
||||
mod, _ = _import_tracker(monkeypatch)
|
||||
log_file = tmp_path / "system" / "test_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
log_file.write_text("x" * 100)
|
||||
def test_configure_clears_tracked_on_reimport(self, tmp_path, monkeypatch):
|
||||
"""Fresh import via _import_tracker starts with empty tracked dict."""
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
(logs_dir / "test_module.log").write_text("x" * 100)
|
||||
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod, _ = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
mod.scan_rates()
|
||||
|
||||
assert len(mod._tracked) > 0
|
||||
mod.reset()
|
||||
|
||||
mod._tracked.clear()
|
||||
assert len(mod._tracked) == 0
|
||||
|
||||
|
||||
class TestNoTrigger:
|
||||
"""When trigger is unavailable, detection still works — just no event fired."""
|
||||
"""When no event callback is set, detection still works — just no event fired."""
|
||||
|
||||
def test_detection_without_trigger(self, tmp_path, monkeypatch):
|
||||
"""Rate tracking and threshold detection work without trigger."""
|
||||
mod, _ = _import_tracker(monkeypatch)
|
||||
setattr(mod, "_HAS_TRIGGER", False)
|
||||
setattr(mod, "trigger", None)
|
||||
|
||||
log_file = tmp_path / "system" / "test_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
def test_detection_without_callback(self, tmp_path, monkeypatch):
|
||||
"""Rate tracking and threshold detection work without event callback."""
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
log_file = logs_dir / "test_module.log"
|
||||
log_file.write_text("x" * 100)
|
||||
|
||||
mod, _ = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
mod.configure(event_callback=None)
|
||||
|
||||
base_time = time.time()
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod.scan_rates()
|
||||
|
||||
bytes_per_interval = int(mod.WARNING_LINES_PER_MIN * mod.AVG_LINE_BYTES * mod.SCAN_INTERVAL / 60 * 1.5)
|
||||
|
||||
results = []
|
||||
for i in range(mod.WARNING_SUSTAINED_INTERVALS):
|
||||
log_file.write_bytes(b"x" * bytes_per_interval + log_file.read_bytes())
|
||||
with (
|
||||
patch.object(mod, "get_system_logs_dir", return_value=log_file.parent),
|
||||
patch.object(
|
||||
mod.time,
|
||||
"time",
|
||||
return_value=base_time + (i + 1) * mod.SCAN_INTERVAL,
|
||||
),
|
||||
with patch.object(
|
||||
mod.time,
|
||||
"time",
|
||||
return_value=base_time + (i + 1) * mod.SCAN_INTERVAL,
|
||||
):
|
||||
results = mod.scan_rates()
|
||||
|
||||
@@ -447,14 +418,14 @@ class TestPersistence:
|
||||
|
||||
def test_scan_saves_state_to_disk(self, tmp_path, monkeypatch):
|
||||
"""scan_rates() calls save_json after scanning."""
|
||||
mod, _ = _import_tracker(monkeypatch)
|
||||
log_file = tmp_path / "system" / "test_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
log_file.write_text("x" * 100)
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
(logs_dir / "test_module.log").write_text("x" * 100)
|
||||
|
||||
mod, _ = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
|
||||
json_handler_mock = mod.json_handler
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
mod.scan_rates()
|
||||
mod.scan_rates()
|
||||
|
||||
json_handler_mock.save_json.assert_called()
|
||||
call_args = json_handler_mock.save_json.call_args
|
||||
@@ -466,12 +437,17 @@ class TestPersistence:
|
||||
|
||||
def test_load_restores_offsets_from_disk(self, tmp_path, monkeypatch):
|
||||
"""Loading persisted state restores file offsets so second scan can compute rates."""
|
||||
mod, _ = _import_tracker(monkeypatch)
|
||||
logs_dir = tmp_path / "system"
|
||||
logs_dir.mkdir(parents=True)
|
||||
log_file = logs_dir / "test_module.log"
|
||||
log_file.write_text("x" * 1300)
|
||||
|
||||
mod, _ = _import_tracker(monkeypatch, logs_dir=logs_dir)
|
||||
|
||||
persisted = {
|
||||
"module_name": "rate_tracker",
|
||||
"files": {
|
||||
str(tmp_path / "system" / "test_module.log"): {
|
||||
str(log_file): {
|
||||
"last_offset": 100,
|
||||
"last_check": time.time() - 15.0,
|
||||
"warning_sustained": 0,
|
||||
@@ -483,12 +459,7 @@ class TestPersistence:
|
||||
}
|
||||
mod.json_handler.load_json.return_value = persisted
|
||||
|
||||
log_file = tmp_path / "system" / "test_module.log"
|
||||
log_file.parent.mkdir(parents=True)
|
||||
log_file.write_text("x" * 1300)
|
||||
|
||||
with patch.object(mod, "get_system_logs_dir", return_value=log_file.parent):
|
||||
results = mod.scan_rates()
|
||||
results = mod.scan_rates()
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["rate_lines_per_min"] > 0
|
||||
@@ -501,9 +472,11 @@ class TestPersistence:
|
||||
mod._load_state()
|
||||
assert len(mod._tracked) == 0
|
||||
|
||||
def test_reset_clears_state_loaded_flag(self, monkeypatch):
|
||||
"""reset() clears _state_loaded so next scan reloads from disk."""
|
||||
def test_reimport_clears_state_loaded_flag(self, monkeypatch):
|
||||
"""Fresh _import_tracker resets _state_loaded so next scan reloads from disk."""
|
||||
mod, _ = _import_tracker(monkeypatch)
|
||||
setattr(mod, "_state_loaded", True)
|
||||
mod.reset()
|
||||
assert mod._state_loaded is True
|
||||
|
||||
mod, _ = _import_tracker(monkeypatch)
|
||||
assert mod._state_loaded is False
|
||||
|
||||
@@ -221,11 +221,7 @@ class TestNoEmailCallback:
|
||||
)
|
||||
|
||||
calls = mod._append_jsonl.call_args_list # type: ignore[union-attr]
|
||||
warning_calls = [
|
||||
c
|
||||
for c in calls
|
||||
if isinstance(c[0][1], dict) and c[0][1].get("level") == "WARNING"
|
||||
]
|
||||
warning_calls = [c for c in calls if isinstance(c[0][1], dict) and c[0][1].get("level") == "WARNING"]
|
||||
assert len(warning_calls) >= 1
|
||||
assert "No email callback" in warning_calls[0][0][1]["msg"]
|
||||
|
||||
@@ -410,11 +406,7 @@ class TestSuppressionLog:
|
||||
)
|
||||
|
||||
calls = mod._append_jsonl.call_args_list # type: ignore[union-attr]
|
||||
suppression_calls = [
|
||||
c
|
||||
for c in calls
|
||||
if isinstance(c[0][1], dict) and c[0][1].get("reason") == "cooldown"
|
||||
]
|
||||
suppression_calls = [c for c in calls if isinstance(c[0][1], dict) and c[0][1].get("reason") == "cooldown"]
|
||||
assert len(suppression_calls) == 1
|
||||
assert suppression_calls[0][0][1]["file"] == file_path
|
||||
|
||||
@@ -435,11 +427,7 @@ class TestSuppressionLog:
|
||||
)
|
||||
|
||||
calls = mod._append_jsonl.call_args_list # type: ignore[union-attr]
|
||||
suppression_calls = [
|
||||
c
|
||||
for c in calls
|
||||
if isinstance(c[0][1], dict) and c[0][1].get("reason") == "branch_muted"
|
||||
]
|
||||
suppression_calls = [c for c in calls if isinstance(c[0][1], dict) and c[0][1].get("reason") == "branch_muted"]
|
||||
assert len(suppression_calls) == 1
|
||||
assert suppression_calls[0][0][1]["branch"] == "flow"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user