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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user