Merge pull request #585 from AIOSAI/fixprax-cap-monitor-agent-events-per-bat

fix(prax): cap monitor agent events per batch to prevent doom-scrolling
This commit is contained in:
AIPass
2026-05-15 14:38:21 -07:00
committed by GitHub
4 changed files with 76 additions and 8 deletions
+5 -2
View File
@@ -51,6 +51,7 @@ drone @git commit "msg" --all # Stage ALL repo changes and commit
drone @git commit "msg" f1 f2 # Stage only f1 f2, then commit
drone @git checkout dev # Switch to dev branch
drone @git checkout main # Switch to main branch
drone @git pr "desc" # Push current branch and create PR to main
drone @git dev-pr "desc" # Push dev and create PR to main
drone @git merge <PR#> # Merge a PR and sync local main
drone @git delete-branch <name> # Delete a remote branch (not main/dev)
@@ -59,7 +60,7 @@ drone @git sync # Pull latest (branch-aware: main or dev)
drone @git sync --autostash # Sync with autostash for dirty trees
drone @git smart-sync # Fetch + detect divergence + rebase
drone @git unlock --force # Force-release the PR lock
drone @git system-pr "desc" # DEPRECATED — use dev-pr instead
drone @git system-pr "desc" # DEPRECATED — returns error message
drone @git fix # Auto-fix stuck rebase / detached HEAD
drone @git fix --dry-run # Detect issues without fixing
@@ -216,7 +217,7 @@ Auth centralized via `verify_git_access()` in `apps/plugins/devpulse_ops/auth.py
| Tier | Who | Commands |
|------|-----|----------|
| **Global** | All branches | `status`, `diff`, `log`, `lock`, `branches`, `issue`, `run`, `workflow` |
| **Owner** | `devpulse` only | `commit`, `checkout`, `dev-pr`, `delete-branch`, `sync`, `unlock`, `system-pr`, `merge`, `smart-sync`, `fix` |
| **Owner** | `devpulse` only | `pr`, `commit`, `checkout`, `dev-pr`, `delete-branch`, `sync`, `unlock`, `system-pr`, `merge`, `smart-sync`, `fix` |
- Auth is checked once at the top of `git_module.handle_command()` before any handler is called
- Unauthorized commands return a clear "Access denied" message with the caller's tier
@@ -227,6 +228,8 @@ All work happens on `dev`. Only devpulse has write access. Agents build and repo
**Flow:** work on dev → stack changes → `drone @git dev-pr "desc"` → merge PR → `drone @git sync` (realigns dev from main)
**`pr` vs `dev-pr`:** `pr` works from any branch — on main it auto-creates a temp branch from the description slug (`main:<slug>`), on other branches it pushes directly. Does NOT use `-u` so main's upstream tracking stays on `origin/main`. `dev-pr` is specific to the dev→main workflow.
Enforcement layers:
- `git_gate.py` PreToolUse hook blocks ALL raw git/gh commands
- Drone tier system restricts write commands to devpulse only
+6 -3
View File
@@ -545,7 +545,10 @@ def get_help(command: str | None = None) -> str:
"git workflow [args] — Passthrough to gh workflow CLI [global]\n Examples: list, view <name>, run <name>\n"
)
if command == "pr":
return "git pr <description> — Push current branch and create PR to main [owner]\n"
return (
"git pr <description> — Push current branch and create PR to main [owner]\n"
" On main: creates temp branch from slug. Otherwise: pushes branch directly. No -u flag.\n"
)
if command == "status":
return "git status [--all] — Show git status filtered to your branch (--all for repo-wide) [global]\n"
if command == "diff":
@@ -650,7 +653,7 @@ def get_introspective() -> str:
" - commit_handler.py (commit_changes — selective files, --all, or pre-staged)\n"
" - checkout_handler.py (checkout_branch — main/dev only)\n"
" - sync_handler.py (sync_main — safe main synchronization)\n"
" - dev_pr_handler.py (create_dev_pr — push dev, PR to main)\n"
" - dev_pr_handler.py (create_branch_pr, create_dev_pr — PR to main)\n"
" - branches_handler.py (list_remote_branches)\n"
" - delete_branch_handler.py (delete_remote_branch — protected: main/dev)\n"
" - pr_handler.py (create_pr — DEPRECATED, kept for reference)\n"
@@ -665,7 +668,7 @@ def get_introspective() -> str:
" gh passthrough:\n"
" - issue, run, workflow → subprocess gh <cmd> [args]\n"
"\n"
"Access Tiers: global (status, diff, log, lock, branches, issue, run, workflow) | owner (commit, checkout, dev-pr, delete-branch, sync, unlock, system-pr, merge, smart-sync, fix)\n"
"Access Tiers: global (status, diff, log, lock, branches, issue, run, workflow) | owner (pr, commit, checkout, dev-pr, delete-branch, sync, unlock, system-pr, merge, smart-sync, fix)\n"
)
@@ -535,11 +535,15 @@ class MonitoringFileHandler(FileSystemEventHandler):
# CLAUDE CODE AGENT ACTIVITY PARSING (JSONL sessions)
# =========================================================================
_MAX_AGENT_EVENTS_PER_BATCH = 3
def _parse_agent_activity(self, file_path, branch):
"""Parse Claude Code session JSONL to show agent actions.
Returns True if an event was emitted (or deduped), False on failure.
Iterates forward through new lines, emitting all distinct actions.
Iterates forward through all new lines (for model extraction), but only
emits the last few distinct actions to prevent doom-scrolling when large
batches accumulate between polls or after compaction resets.
"""
try:
path_key = str(file_path)
@@ -560,6 +564,9 @@ class MonitoringFileHandler(FileSystemEventHandler):
if not lines:
return True
pending_actions = []
last_seen = self._last_agent_action.get(path_key)
for line in lines:
try:
entry = _json.loads(line)
@@ -575,10 +582,12 @@ class MonitoringFileHandler(FileSystemEventHandler):
if not action_text:
continue
if self._last_agent_action.get(path_key) == action_text:
if action_text == last_seen:
continue
self._last_agent_action[path_key] = action_text
last_seen = action_text
pending_actions.append(action_text)
for action_text in pending_actions[-self._MAX_AGENT_EVENTS_PER_BATCH :]:
tagged_branch = self._tag_branch_with_model(path_key, branch)
evt = MonitoringEvent(
priority=1,
@@ -591,6 +600,9 @@ class MonitoringFileHandler(FileSystemEventHandler):
if self._event_queue:
self._event_queue.enqueue(evt)
if pending_actions:
self._last_agent_action[path_key] = pending_actions[-1]
return True
except Exception as e:
@@ -1763,6 +1763,56 @@ class TestParseAgentActivity:
result = handler._parse_agent_activity(mock_path, "PRAX")
assert result is True
def test_batch_cap_limits_emitted_events(self):
"""Should only emit last N events when batch exceeds cap."""
mod, _, _, _, queue = _import_filesystem_handler()
handler = _make_handler(mod, queue=queue)
mock_path = MagicMock()
mock_path.__str__ = MagicMock(return_value="/fake/claude.jsonl")
mock_path.name = "claude.jsonl"
entries = []
tools = ["Read", "Edit", "Write", "Grep", "Glob", "Bash"]
for i, tool in enumerate(tools):
entries.append(
{
"type": "assistant",
"message": {"content": [{"type": "tool_use", "name": tool, "input": {"file_path": f"f{i}.py"}}]},
}
)
data = "\n".join(json.dumps(e) for e in entries) + "\n"
mock_path.stat.return_value.st_size = len(data)
with patch("builtins.open", _mopen(read_data=data)):
result = handler._parse_agent_activity(mock_path, "PRAX")
assert result is True
cap = mod.MonitoringFileHandler._MAX_AGENT_EVENTS_PER_BATCH
assert queue.enqueue.call_count == cap
def test_batch_cap_emits_tail_not_head(self):
"""Should emit the last actions in a large batch, not the first."""
mod, mock_eq, _, _, queue = _import_filesystem_handler()
handler = _make_handler(mod, queue=queue)
mock_path = MagicMock()
mock_path.__str__ = MagicMock(return_value="/fake/claude.jsonl")
mock_path.name = "claude.jsonl"
entries = []
for i in range(6):
entries.append(
{
"type": "assistant",
"message": {"content": [{"type": "tool_use", "name": f"Tool{i}", "input": {}}]},
}
)
data = "\n".join(json.dumps(e) for e in entries) + "\n"
mock_path.stat.return_value.st_size = len(data)
with patch("builtins.open", _mopen(read_data=data)):
handler._parse_agent_activity(mock_path, "PRAX")
cap = mod.MonitoringFileHandler._MAX_AGENT_EVENTS_PER_BATCH
assert queue.enqueue.call_count == cap
last_call_kwargs = mock_eq.MonitoringEvent.call_args_list[-1][1]
assert "Tool5" in last_call_kwargs["message"]
first_call_kwargs = mock_eq.MonitoringEvent.call_args_list[-cap][1]
assert "Tool3" in first_call_kwargs["message"]
# =============================================
# CHECK COMMAND INDICATOR TESTS