Compare commits

..

1 Commits

Author SHA1 Message Date
1453db2440 fix: integrate token tracker with orchestrator (#634)
Some checks failed
Architecture Lint / Linter Tests (pull_request) Successful in 29s
PR Checklist / pr-checklist (pull_request) Failing after 3m50s
Smoke Test / smoke (pull_request) Failing after 16s
Validate Config / YAML Lint (pull_request) Failing after 12s
Validate Config / JSON Validate (pull_request) Successful in 10s
Validate Config / Python Syntax & Import Check (pull_request) Failing after 1m2s
Validate Config / Shell Script Lint (pull_request) Failing after 27s
Validate Config / Cron Syntax Check (pull_request) Successful in 7s
Validate Config / Deploy Script Dry Run (pull_request) Successful in 6s
Validate Config / Playbook Schema Validation (pull_request) Successful in 11s
Architecture Lint / Lint Repository (pull_request) Has been cancelled
Validate Config / Python Test Suite (pull_request) Has been cancelled
- Add log_token_usage() to record input/output tokens per task
- Add Huey SIGNAL_COMPLETE hook for automatic logging
- Auto-detect pipeline name from task function name
- Append to ~/.hermes/token_usage.jsonl for downstream analysis

Related: #622 (token budget tracker)
2026-04-15 01:12:08 +00:00
2 changed files with 48 additions and 468 deletions

View File

@@ -1,6 +1,53 @@
"""Sovereign orchestration — Huey replaces 3,843 lines of homebrew."""
from huey import SqliteHuey, crontab
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from huey import SqliteHuey, signals
huey = SqliteHuey(filename=str(Path.home() / ".hermes" / "orchestration.db"))
# === Token Tracking ===
TOKEN_LOG = Path.home() / ".hermes" / "token_usage.jsonl"
def log_token_usage(task_name, result):
"""Log token usage from a completed pipeline task.
Reads input_tokens/output_tokens from the agent result dict.
Auto-detects pipeline name from task context.
Appends to JSONL for downstream analysis.
"""
if not isinstance(result, dict):
return
input_tokens = result.get("input_tokens", 0)
output_tokens = result.get("output_tokens", 0)
if input_tokens == 0 and output_tokens == 0:
return
# Auto-detect pipeline name from task function name
pipeline = task_name.replace("_task", "").replace("_", "-")
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"pipeline": pipeline,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"task": task_name,
}
TOKEN_LOG.parent.mkdir(parents=True, exist_ok=True)
with open(TOKEN_LOG, "a") as f:
f.write(json.dumps(entry) + "\n")
@huey.signal(signals.SIGNAL_COMPLETE)
def on_task_complete(signal, task, task_value=None, **kwargs):
"""Huey hook: log token usage after each pipeline task completes."""
task_name = getattr(task, "name", "unknown")
log_token_usage(task_name, task_value)

View File

@@ -1,467 +0,0 @@
"""Tests for the Quality Gate modules.
Tests for:
- ci_automation_gate.py: linting, function length, auto-fix, counters
- task_gate.py: pre/post task gate logic, lane checking, filter tags
Refs: #629
"""
import json
import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock
# Add scripts/ to path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from ci_automation_gate import QualityGate
# ===========================================================================
# CI AUTOMATION GATE TESTS
# ===========================================================================
# -- helpers ---------------------------------------------------------------
def _write_file(dirpath, relpath, content):
"""Write a file in a temp directory and return its Path."""
p = Path(dirpath) / relpath
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content)
return p
def _run_gate_on_file(dirpath, relpath, content, fix=False):
"""Write a file, run QualityGate on it, return the gate instance."""
p = _write_file(dirpath, relpath, content)
gate = QualityGate(fix=fix)
gate.check_file(p)
return gate
# -- trailing whitespace ---------------------------------------------------
def test_trailing_whitespace_warns():
"""Lines with trailing whitespace should produce a warning."""
with tempfile.TemporaryDirectory() as tmp:
gate = _run_gate_on_file(tmp, "test.py", "x = 1 \ny = 2\n")
assert gate.warnings >= 1, "Expected warning for trailing whitespace"
def test_trailing_whitespace_fixes():
"""With fix=True, trailing whitespace should be removed."""
with tempfile.TemporaryDirectory() as tmp:
p = _write_file(tmp, "test.py", "x = 1 \ny = 2\n")
gate = QualityGate(fix=True)
gate.check_file(p)
fixed = p.read_text()
assert "x = 1 \n" not in fixed, "Trailing whitespace should be removed"
assert fixed == "x = 1\ny = 2\n"
def test_clean_file_no_warnings():
"""A clean file should produce no warnings."""
with tempfile.TemporaryDirectory() as tmp:
gate = _run_gate_on_file(tmp, "test.py", "x = 1\ny = 2\n")
assert gate.warnings == 0
assert gate.failures == 0
# -- missing final newline -------------------------------------------------
def test_missing_final_newline_warns():
"""File without trailing newline should warn."""
with tempfile.TemporaryDirectory() as tmp:
gate = _run_gate_on_file(tmp, "test.py", "x = 1")
assert gate.warnings >= 1, "Expected warning for missing final newline"
def test_missing_final_newline_fixed():
"""With fix=True, missing final newline should be added."""
with tempfile.TemporaryDirectory() as tmp:
p = _write_file(tmp, "test.py", "x = 1")
gate = QualityGate(fix=True)
gate.check_file(p)
fixed = p.read_text()
assert fixed.endswith("\n"), "Fixed file should end with newline"
# -- function length (JS/TS) -----------------------------------------------
def test_short_function_passes():
"""A short JS function should not warn or fail."""
with tempfile.TemporaryDirectory() as tmp:
code = "function hello() {\n return 1;\n}\n"
gate = _run_gate_on_file(tmp, "test.js", code)
assert gate.failures == 0
assert gate.warnings == 0
def test_medium_function_warns():
"""JS function over 20 lines should warn."""
body = "\n".join(f" console.log({i});" for i in range(22))
code = f"function big() {{\n{body}\n}}\n"
with tempfile.TemporaryDirectory() as tmp:
gate = _run_gate_on_file(tmp, "test.js", code)
assert gate.warnings >= 1, "Expected warning for function over 20 lines"
def test_long_function_fails():
"""JS function over 50 lines should fail."""
body = "\n".join(f" console.log({i});" for i in range(52))
code = f"function huge() {{\n{body}\n}}\n"
with tempfile.TemporaryDirectory() as tmp:
gate = _run_gate_on_file(tmp, "test.js", code)
assert gate.failures >= 1, "Expected failure for function over 50 lines"
def test_python_function_length_not_checked():
"""Python functions should not be checked by the JS regex."""
body = "\n".join(f" print({i})" for i in range(60))
code = f"def huge():\n{body}\n"
with tempfile.TemporaryDirectory() as tmp:
gate = _run_gate_on_file(tmp, "test.py", code)
assert gate.failures == 0, "Python functions should not trigger JS length check"
# -- file type filtering ---------------------------------------------------
def test_non_code_file_skipped():
"""Non-code files (.md, .json, .txt) should be skipped."""
with tempfile.TemporaryDirectory() as tmp:
gate = _run_gate_on_file(tmp, "README.md", "# Title \ntrailing ws\n")
assert gate.warnings == 0, "Markdown files should be skipped"
assert gate.failures == 0
def test_typescript_checked():
"""TypeScript files should be checked."""
with tempfile.TemporaryDirectory() as tmp:
gate = _run_gate_on_file(tmp, "test.ts", "x = 1 \n")
assert gate.warnings >= 1, "TypeScript files should be checked"
# -- directory traversal ---------------------------------------------------
def test_run_scans_directory():
"""Gate.run() should scan all files in a directory tree."""
with tempfile.TemporaryDirectory() as tmp:
_write_file(tmp, "clean.py", "x = 1\n")
_write_file(tmp, "dirty.js", "x = 1 \n")
_write_file(tmp, "sub/nested.ts", "y = 2 \n")
gate = QualityGate()
gate.run(tmp)
assert gate.warnings >= 2, "Should find trailing whitespace in both dirty files"
def test_run_skips_node_modules():
"""Gate.run() should skip node_modules directories."""
with tempfile.TemporaryDirectory() as tmp:
_write_file(tmp, "clean.py", "x = 1\n")
_write_file(tmp, "node_modules/pkg/index.js", "x = 1 \n")
gate = QualityGate()
gate.run(tmp)
assert gate.warnings == 0, "node_modules should be skipped"
def test_run_skips_git_dir():
"""Gate.run() should skip .git directories."""
with tempfile.TemporaryDirectory() as tmp:
_write_file(tmp, "clean.py", "x = 1\n")
_write_file(tmp, ".git/hooks/pre-commit", "x = 1 \n")
gate = QualityGate()
gate.run(tmp)
assert gate.warnings == 0, ".git should be skipped"
# -- exit code -------------------------------------------------------------
def test_failures_cause_exit_code_1():
"""Gate with failures should exit with code 1."""
import subprocess
with tempfile.TemporaryDirectory() as tmp:
body = "\n".join(f" console.log({i});" for i in range(52))
_write_file(tmp, "huge.js", f"function f() {{\n{body}\n}}\n")
r = subprocess.run(
[sys.executable, str(Path(__file__).resolve().parent.parent / "scripts" / "ci_automation_gate.py"), tmp],
capture_output=True, text=True
)
assert r.returncode == 1, f"Expected exit 1, got {r.returncode}"
def test_clean_directory_exits_0():
"""Gate on clean directory should exit 0."""
import subprocess
with tempfile.TemporaryDirectory() as tmp:
_write_file(tmp, "clean.py", "x = 1\ny = 2\n")
r = subprocess.run(
[sys.executable, str(Path(__file__).resolve().parent.parent / "scripts" / "ci_automation_gate.py"), tmp],
capture_output=True, text=True
)
assert r.returncode == 0, f"Expected exit 0, got {r.returncode}"
# ===========================================================================
# TASK GATE TESTS
# ===========================================================================
# Import task_gate functions directly — test the pure logic
from task_gate import check_agent_lane, FILTER_TAGS, AGENT_USERNAMES
# -- filter tags -----------------------------------------------------------
def test_epic_tag_filtered():
"""Issues with [EPIC] tag should be filtered."""
title = "[EPIC] Build the thing"
for tag in FILTER_TAGS:
tag_clean = tag.upper().replace("[", "").replace("]", "")
if tag_clean in title.upper():
return # Found
assert False, "EPIC tag should be detected by FILTER_TAGS"
def test_permanent_tag_filtered():
"""Issues with [DO NOT CLOSE] tag should be filtered."""
title = "[DO NOT CLOSE] Keep this open forever"
title_upper = title.upper()
matched = any(
tag.upper().replace("[", "").replace("]", "") in title_upper
for tag in FILTER_TAGS
)
assert matched, "[DO NOT CLOSE] should be filtered"
def test_normal_title_not_filtered():
"""Normal issue titles should not be filtered."""
title = "Fix the login bug in auth.py"
title_upper = title.upper()
matched = any(
tag.upper().replace("[", "").replace("]", "") in title_upper
for tag in FILTER_TAGS
)
assert not matched, "Normal title should not be filtered"
def test_morning_report_filtered():
"""[MORNING REPORT] issues should be filtered."""
title = "[MORNING REPORT] Fleet status 2026-04-13"
title_upper = title.upper()
matched = any(
tag.upper().replace("[", "").replace("]", "") in title_upper
for tag in FILTER_TAGS
)
assert matched, "[MORNING REPORT] should be filtered"
# -- agent lane checker ----------------------------------------------------
def test_lane_check_no_config():
"""With no lane config, lane check should pass."""
ok, msg = check_agent_lane("groq", "Fix bug", [], {})
assert ok
assert "No lane config" in msg
def test_lane_check_agent_not_in_config():
"""Agent not in lane config should pass."""
lanes = {"ezra": ["docs"]}
ok, msg = check_agent_lane("groq", "Fix bug", [], lanes)
assert ok
assert "No lanes defined" in msg
def test_lane_check_agent_in_config():
"""Agent in lane config should return their lanes."""
lanes = {"groq": ["code", "infra"]}
ok, msg = check_agent_lane("groq", "Fix bug", [], lanes)
assert ok
assert "groq" in msg
assert "code" in msg
# -- agent usernames -------------------------------------------------------
def test_known_agents_in_usernames():
"""Core agent usernames should be registered."""
assert "groq" in AGENT_USERNAMES
assert "ezra" in AGENT_USERNAMES
assert "bezalel" in AGENT_USERNAMES
assert "timmy" in AGENT_USERNAMES
assert "codex-agent" in AGENT_USERNAMES
# -- pre-task gate (mocked API) -------------------------------------------
def test_pre_task_gate_issue_not_found():
"""Pre-task gate should fail if issue doesn't exist."""
from task_gate import pre_task_gate
with patch("task_gate.gitea_get", return_value=None):
passed, msgs = pre_task_gate("timmy-config", 99999, "groq")
assert not passed
assert any("not found" in m for m in msgs)
def test_pre_task_gate_filter_tag_blocks():
"""Pre-task gate should block filtered issues."""
from task_gate import pre_task_gate
mock_issue = {
"title": "[EPIC] Big thing",
"assignees": [],
"labels": [],
}
def mock_gitea_get(path):
if "issues/100" in path:
return mock_issue
if "branches" in path:
return []
if "pulls" in path:
return []
return None
with patch("task_gate.gitea_get", side_effect=mock_gitea_get):
passed, msgs = pre_task_gate("timmy-config", 100, "groq")
assert not passed
assert any("filter" in m.lower() for m in msgs)
def test_pre_task_gate_assigned_agent_blocks():
"""Pre-task gate should block issues assigned to other agents."""
from task_gate import pre_task_gate
mock_issue = {
"title": "Fix bug",
"assignees": [{"login": "ezra"}],
"labels": [],
}
def mock_gitea_get(path):
if "issues/100" in path:
return mock_issue
if "branches" in path:
return []
if "pulls" in path:
return []
return None
with patch("task_gate.gitea_get", side_effect=mock_gitea_get):
passed, msgs = pre_task_gate("timmy-config", 100, "groq")
assert not passed
assert any("Already assigned" in m for m in msgs)
def test_pre_task_gate_existing_pr_blocks():
"""Pre-task gate should block issues with existing PRs."""
from task_gate import pre_task_gate
mock_issue = {
"title": "Fix bug",
"assignees": [],
"labels": [],
}
mock_prs = [{"number": 50, "title": "Fix for #100", "body": "Closes #100"}]
def mock_gitea_get(path):
if "issues/100" in path:
return mock_issue
if "branches" in path:
return []
if "pulls" in path:
return mock_prs
return None
with patch("task_gate.gitea_get", side_effect=mock_gitea_get):
passed, msgs = pre_task_gate("timmy-config", 100, "groq")
assert not passed
assert any("Open PR" in m for m in msgs)
def test_pre_task_gate_clean_passes():
"""Pre-task gate should pass for clean issues."""
from task_gate import pre_task_gate
def mock_gitea_get(path):
if "issues/100" in path:
return {"title": "Fix bug", "assignees": [], "labels": []}
if "branches" in path:
return []
if "pulls" in path:
return []
return None
with patch("task_gate.gitea_get", side_effect=mock_gitea_get):
passed, msgs = pre_task_gate("timmy-config", 100, "groq")
assert passed
# -- post-task gate (mocked API) ------------------------------------------
def test_post_task_gate_missing_branch():
"""Post-task gate should fail if branch doesn't exist."""
from task_gate import post_task_gate
with patch("task_gate.gitea_get", return_value=None):
passed, msgs = post_task_gate("timmy-config", 100, "groq", "groq/fix-100")
assert not passed
assert any("does not exist" in m for m in msgs)
def test_post_task_gate_no_agent_prefix_warns():
"""Post-task gate should warn if branch doesn't start with agent name."""
from task_gate import post_task_gate
def mock_gitea_get(path):
if "branches/fix-100" in path:
return {"name": "fix-100"}
if "compare" in path:
return {"commits": [{"id": "abc"}], "diff_files": ["file.py"]}
if "pulls" in path:
return []
return None
with patch("task_gate.gitea_get", side_effect=mock_gitea_get):
passed, msgs = post_task_gate("timmy-config", 100, "groq", "fix-100")
assert passed # Warning, not failure
assert any("doesn't start with agent" in m or "convention" in m for m in msgs)
def test_post_task_gate_no_commits_fails():
"""Post-task gate should fail if branch has no commits ahead of main."""
from task_gate import post_task_gate
def mock_gitea_get(path):
if "branches/" in path:
return {"name": "groq/fix-100"}
if "compare" in path:
return {"commits": [], "diff_files": []}
if "pulls" in path:
return []
return None
with patch("task_gate.gitea_get", side_effect=mock_gitea_get):
passed, msgs = post_task_gate("timmy-config", 100, "groq", "groq/fix-100")
assert not passed
assert any("no commits" in m.lower() for m in msgs)
# ===========================================================================
# INTEGRATION: gate on real script files
# ===========================================================================
def test_ci_gate_on_actual_task_gate():
"""Run QualityGate on task_gate.py itself — should pass."""
gate_path = Path(__file__).resolve().parent.parent / "scripts" / "task_gate.py"
if gate_path.exists():
gate = QualityGate()
gate.check_file(gate_path)
assert gate.failures == 0, f"task_gate.py should pass quality gate, got {gate.failures} failures"
def test_ci_gate_on_actual_ci_automation_gate():
"""Run QualityGate on ci_automation_gate.py itself — should pass."""
gate_path = Path(__file__).resolve().parent.parent / "scripts" / "ci_automation_gate.py"
if gate_path.exists():
gate = QualityGate()
gate.check_file(gate_path)
assert gate.failures == 0, f"ci_automation_gate.py should pass quality gate, got {gate.failures} failures"