From 4380d3a550714d6049e60640ce4bbff3a8810f15 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 25 Aug 2026 09:17:17 +0000 Subject: [PATCH 1/2] =?UTF-8?q?auto(openhands):=20[openhands=5Fautopilot]?= =?UTF-8?q?=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BD=D0=BE=D0=BC=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=82=D0=BE=D1=80=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=20=D0=BA=D0=BE=D0=BD=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=B0=20(todo/ruff=20=E2=86=92=20=D0=BE=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D1=8C=20=E2=86=92=20ContourService)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...825T090600Z-sandbox-openhands-autopilot.md | 8 + ...825T090600Z-sandbox-openhands-autopilot.md | 56 +++++ scripts/openhands_autopilot.py | 232 ++++++++++++++++++ tests/test_openhands_autopilot.py | 103 ++++++++ 4 files changed, 399 insertions(+) create mode 100644 coordination/claims/autopilot--20260825T090600Z-sandbox-openhands-autopilot.md create mode 100644 coordination/sessions/20260825T090600Z-sandbox-openhands-autopilot.md create mode 100644 scripts/openhands_autopilot.py create mode 100644 tests/test_openhands_autopilot.py diff --git a/coordination/claims/autopilot--20260825T090600Z-sandbox-openhands-autopilot.md b/coordination/claims/autopilot--20260825T090600Z-sandbox-openhands-autopilot.md new file mode 100644 index 000000000..157c75d64 --- /dev/null +++ b/coordination/claims/autopilot--20260825T090600Z-sandbox-openhands-autopilot.md @@ -0,0 +1,8 @@ +# Claim: openhands-autopilot +- Session: `20260825T090600Z-sandbox-openhands-autopilot` +- Status: `DONE` +- Agent: `OpenHands sandbox agent` +- Machine: `openhands-workspace` +- Started UTC: `2026-08-25T09:06:00Z` +- Expected files: `scripts/openhands_autopilot.py`, `tests/test_openhands_autopilot.py`, `coordination/sessions/20260825T090600Z-sandbox-openhands-autopilot.md` +- Goal: `Автономный генератор задач OpenHands-контура (TODO/ruff → очередь → ContourService).` diff --git a/coordination/sessions/20260825T090600Z-sandbox-openhands-autopilot.md b/coordination/sessions/20260825T090600Z-sandbox-openhands-autopilot.md new file mode 100644 index 000000000..89878f2a8 --- /dev/null +++ b/coordination/sessions/20260825T090600Z-sandbox-openhands-autopilot.md @@ -0,0 +1,56 @@ +--- +session_id: "20260825T090600Z-sandbox-openhands-autopilot" +status: "ACTIVE" +agent: "OpenHands sandbox agent" +machine: "openhands-workspace" +started_utc: "2026-08-25T09:06:00Z" +updated_utc: "2026-08-25T09:06:00Z" +branch: "auto/openhands/autopilot-20260825" +base_commit: "ad5bda0" +claim: "coordination/claims/autopilot--20260825T090600Z-sandbox-openhands-autopilot.md" +--- + +## Цель + +Автономный генератор задач для OpenHands-контура: скан ассортимента проекта +(TODO/FIXME, ruff) → упорядоченная очередь → `ContourService.submit/run_task`. + +## Scope + +- Разрешённые компоненты/файлы: `scripts/openhands_autopilot.py`, + `tests/test_openhands_autopilot.py`, координация. +- Явно вне scope: protected-файлы, правки контура `aios_core/openhands/`. + +## План + +1. Коллекторы `todo`/`ruff` — чистые функции, детерминированный порядок. +2. CLI: `--plan` (по умолчанию, безопасно) / `--run` (исполнение через Cloud-клиент). +3. Тесты (fake-клиент), pytest + ruff. + +## Текущий шаг (виден другим агентам) + +- Текущий шаг: DONE — автопилот реализован и проверен. +- Обновлено UTC: 2026-08-25T09:20:00Z + +## Ход работы и решения + +- Коллекторы `todo` (TODO/FIXME по файлам) и `ruff` (через `shutil.which`, + fallback `python -m ruff`) выдают `TaskDraft`; детерминированный порядок + ruff → todo. Подача `submit_queue` пропускает дубликаты по заголовку и + соблюдает `--max-tasks`. +- CLI: по умолчанию безопасный `--plan`; `--run` строит реальный + `OpenHandsClient` (env `OPENHANDS_API_KEY`) и последовательно исполняет задачи. +- `sys.path`-правка как в `selfguard.py` для прямого запуска скрипта. + +## Изменённые файлы + +- `scripts/openhands_autopilot.py` — автопилот. +- `tests/test_openhands_autopilot.py` — 10 тестов (fake-клиент). +- координация — журнал/claim. + +## Проверки + +- `[PASS]` `python -m pytest tests/test_openhands_autopilot.py -q` — 10/10. +- `[PASS]` `python -m pytest tests/test_openhands_service.py -q` — интеграция clean. +- `[PASS]` `python -m pytest tests/test_openhands_*.py -q` — весь набор контура. +- `[PASS]` `python -m ruff check` обоих файлов. diff --git a/scripts/openhands_autopilot.py b/scripts/openhands_autopilot.py new file mode 100644 index 000000000..d27453fe7 --- /dev/null +++ b/scripts/openhands_autopilot.py @@ -0,0 +1,232 @@ +"""Автономный генератор задач OpenHands-контура по всему проекту. + +Сканирует репозиторий коллекторами (`todo`: TODO/FIXME-маркеры, `ruff`: +lint-замечания), формирует упорядоченную очередь и подаёт её в +``ContourService``. По умолчанию работает в безопасном режиме ``--plan`` +(печать очереди, без обращений к Cloud); исполнение — только с флагом +``--run`` и наличием ``OPENHANDS_API_KEY``. + +Детальное описание lifecycle — ``docs/AIOS_AGENT_ARCHITECTURE.md`` и +``docs/TASK_LIFECYCLE.md``. +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +if __name__ == "__main__": + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from aios_core.openhands import ContourService, Gate, OpenHandsClient + +RUFF_SCOPE = ("aios_core", "octopus_core", "scripts") +MARKER_PREFIXES = ("TODO:", "TODO(", "FIXME:", "FIXME(") + +# Токены, подвергаемые исключению при обходе корня. +_SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", ".ruff_cache"} + + +@dataclass +class TaskDraft: + """Черновик задачи очереди.""" + + collector: str + title: str + description: str + + def as_dict(self) -> dict[str, str]: + return {"collector": self.collector, "title": self.title, "description": self.description} + + +@dataclass +class AutopilotResult: + """Сводка исполнения.""" + + submitted: list[str] = field(default_factory=list) + skipped_duplicates: list[str] = field(default_factory=list) + executed: dict[str, str] = field(default_factory=dict) + + +def _iter_source_files(root: Path) -> list[Path]: + """Все ``*.py``-файлы, кроме служебных каталогов; детерминированный порядок.""" + files = [ + p + for p in root.rglob("*.py") + if not any(part in _SKIP_DIRS for part in p.parts) + ] + return sorted(files, key=lambda p: p.relative_to(root).as_posix()) + + +def collect_todo(root: Path, *, max_files: int = 50, max_lines: int = 5) -> list[TaskDraft]: + """Сгруппировать маркеры TODO/FIXME по файлам — одна задача на файл.""" + drafts: list[TaskDraft] = [] + for path in _iter_source_files(root): + try: + text = path.read_text(encoding="utf-8") + except OSError: + continue + hits = [ + f"L{lineno}: {line.strip()}" + for lineno, line in enumerate(text.splitlines(), 1) + if any(prefix in line for prefix in MARKER_PREFIXES) + ] + if not hits: + continue + rel = path.relative_to(root).as_posix() + sample = "\n".join(hits[:max_lines]) + drafts.append( + TaskDraft( + collector="todo", + title=f"TODO/FIXME: {rel}", + description=f"Устранить маркеры TODO/FIXME в `{rel}`:\n{sample}", + ) + ) + if len(drafts) >= max_files: + break + return drafts + + +def parse_ruff_output(text: str) -> dict[str, list[str]]: + """Разобрать output ruff ``path:l:c: CODE msg`` в {path: [сообщения]}.""" + grouped: dict[str, list[str]] = {} + for line in text.splitlines(): + parts = line.split(":", 3) + if len(parts) < 4: + continue + path, msg = parts[0], parts[3].strip() + grouped.setdefault(path, []).append(msg) + return grouped + + +def collect_ruff( + root: Path, + *, + scope: tuple[str, ...] = RUFF_SCOPE, + max_files: int = 50, + max_msgs: int = 5, +) -> list[TaskDraft]: + """Запустить ``ruff check`` (output-format=txt) и сгруппировать по файлам.""" + targets = [s for s in scope if (root / s).exists()] + if not targets: + return [] + binary = shutil.which("ruff") + cmd = [binary] if binary else [sys.executable, "-m", "ruff"] + try: + proc = subprocess.run( + [*cmd, "check", "--output-format=txt", "--no-fix", *targets], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError: + return [] + grouped = parse_ruff_output(proc.stdout) + drafts: list[TaskDraft] = [] + for rel in sorted(grouped): + msgs = grouped[rel] + sample = "\n".join(f"{rel}: {m}" for m in msgs[:max_msgs]) + drafts.append( + TaskDraft( + collector="ruff", + title=f"Ruff-замечания: {rel}", + description=f"Устранить замечания ruff в `{rel}`:\n{sample}", + ) + ) + if len(drafts) >= max_files: + break + return drafts + + +def collect_queue( + root: Path, + *, + collectors: tuple[str, ...] = ("ruff", "todo"), + max_per_collector: int = 50, +) -> list[TaskDraft]: + """Собрать очередь из выбранных коллекторов в фиксированном порядке.""" + queue: list[TaskDraft] = [] + for name in collectors: + if name == "ruff": + queue.extend(collect_ruff(root, max_files=max_per_collector)) + elif name == "todo": + queue.extend(collect_todo(root, max_files=max_per_collector)) + else: + raise ValueError(f"неизвестный коллектор: {name}") + return queue + + +def submit_queue( + service: ContourService, + drafts: list[TaskDraft], + *, + gates: frozenset[Gate] | None = None, + max_tasks: int | None = None, +) -> AutopilotResult: + """Подать черновики в сервис, пропуская дубликаты по заголовку.""" + known = {entry.task.name for entry in service._tasks.values()} + result = AutopilotResult() + for draft in drafts: + if max_tasks is not None and len(result.submitted) >= max_tasks: + break + if draft.title in known: + result.skipped_duplicates.append(draft.title) + continue + task_id = service.submit(draft.title, draft.description, required_gates=gates) + known.add(draft.title) + result.submitted.append(task_id) + return result + + +def _build_service(args: argparse.Namespace) -> ContourService: + """Реальный Cloud-клиент; ошибка авторизации выводится по-человечески.""" + + return ContourService( + client=OpenHandsClient(), + base_branch=args.base_branch, + repository=args.repository or None, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=".", help="корень репозитория") + parser.add_argument("--plan", action="store_true", default=False, help="только печать очереди") + parser.add_argument("--run", action="store_true", help="исполнить очередь через Cloud") + parser.add_argument("--max-tasks", type=int, help="лимит подач") + parser.add_argument("--collectors", default="ruff,todo", help="ruff,todo (порядок)") + parser.add_argument("--repository", default="", help="owner/repo для Cloud-разговоров") + parser.add_argument("--base-branch", default="main") + args = parser.parse_args(argv) + + collectors = tuple(name.strip() for name in args.collectors.split(",")) + root = Path(args.root).resolve() + queue = collect_queue(root, collectors=collectors) + + if not queue: + print("🔎 Очередь пуста: замечаний нет.") + return 0 + + if not args.run: + for i, draft in enumerate(queue, 1): + print(f"{i:>3}. [{draft.collector}] {draft.title}") + print(f"💡 Исполнение: `python {Path(__file__).absolute()} --run --repository owner/repo`") + return 0 + + service = _build_service(args) + result = submit_queue(service, queue, max_tasks=args.max_tasks) + print(f"📥 Подано: {len(result.submitted)}, пропущено дубликатов: {len(result.skipped_duplicates)}") + for task_id in result.submitted: + run = service.run_task(task_id) + result.executed[task_id] = str(run.status) + print(f"{'✅' if run.status == 'completed' else '⚠️'} {task_id}: {run.status}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_openhands_autopilot.py b/tests/test_openhands_autopilot.py new file mode 100644 index 000000000..2b55bbf7b --- /dev/null +++ b/tests/test_openhands_autopilot.py @@ -0,0 +1,103 @@ +"""Тесты автопилота контура: коллекторы очереди и подача в ContourService. + +Без моков: реальные файловые сканеры по tmp-tree, реальный lifecycle +через FakeClient (см. ``tests/test_openhands_runner``), сеть не используется. +""" + +import argparse + +import pytest + +from aios_core.openhands import ContourService, ContourStore +from aios_core.orchestrator import TaskStatus +from scripts.openhands_autopilot import ( + AutopilotResult, + TaskDraft, + collect_todo, + main, + parse_ruff_output, + submit_queue, +) +from tests.test_openhands_runner import FakeClient + + +@pytest.fixture +def service(tmp_path): + return ContourService( + client=FakeClient(), + github=None, + store=ContourStore(state_dir=tmp_path / "state"), + ) + + +class TestCollectTodo: + def test_groups_markers_by_file(self, tmp_path): + (tmp_path / "a.py").write_text("# TODO: досыпать\nx = 1\n# FIXME: починить\n") + drafts = collect_todo(tmp_path) + assert [d.title for d in drafts] == ["TODO/FIXME: a.py"] + assert "L1" in drafts[0].description and "L3" in drafts[0].description + + def test_skips_service_dirs(self, tmp_path): + (tmp_path / ".git").mkdir() + (tmp_path / ".git" / "x.py").write_text("# TODO: нет\n") + assert collect_todo(tmp_path) == [] + + def test_deterministic_order(self, tmp_path): + for name in ("b.py", "a.py", "c.py"): + (tmp_path / name).write_text("# FIXME: разберём\n") + assert [d.title for d in collect_todo(tmp_path)] == [f"TODO/FIXME: {n}" for n in ("a.py", "b.py", "c.py")] + + +class TestParseRuff: + def test_grouped_by_path(self): + text = "aios_core/x.py:1:5: F401 unused\naios_core/x.py:2:1: E501 long\nscripts/y.py:9:3: W291 ws\n" + grouped = parse_ruff_output(text) + assert grouped["aios_core/x.py"] == ["F401 unused", "E501 long"] + assert grouped["scripts/y.py"] == ["W291 ws"] + + def test_ignores_non_matching_lines(self): + assert parse_ruff_output("all done?\n") == {} + + +class TestSubmitQueue: + def test_dedups_against_existing_titles(self, service): + service.submit("TODO/FIXME: a.py", "описание") + drafts = [TaskDraft("todo", "TODO/FIXME: a.py", "описание"), TaskDraft("todo", "TODO/FIXME: b.py", "op")] + res = submit_queue(service, drafts) + assert res.skipped_duplicates == ["TODO/FIXME: a.py"] + assert len(res.submitted) == 1 + + def test_max_tasks_cap(self, service): + drafts = [TaskDraft("todo", f"t-{i}", "op") for i in range(5)] + res = submit_queue(service, drafts, max_tasks=2) + assert len(res.submitted) == 2 + + def test_run_lifecycle_with_fake_client(self, service): + res = submit_queue( + service, + [TaskDraft("todo", "TODO/FIXME: a.py", "описание")], + ) + (task_id,) = res.submitted + run = service.run_task(task_id) + res2 = AutopilotResult(submitted=[task_id]) + res2.executed[task_id] = str(run.status) + assert res2.executed[task_id] == str(TaskStatus.COMPLETED) + + +class TestMain: + def test_plan_prints_queue_and_exits_zero(self, tmp_path, capsys): + (tmp_path / "x.py").write_text("# TODO: 1\n") + assert main(["--plan", "--root", str(tmp_path)]) == 0 + out = capsys.readouterr().out + assert "TODO/FIXME: x.py" in out + + +class TestBuildService: + def test_service_factory_accepts_repo_and_branch(self, monkeypatch): + from scripts import openhands_autopilot as ap + + fake = FakeClient() + monkeypatch.setattr(ap, "OpenHandsClient", lambda: fake) + args = argparse.Namespace(repository="owner/repo", base_branch="dev") + svc = ap._build_service(args) + assert svc.repository == "owner/repo" and svc.base_branch == "dev" From b5370f5496f102c0aaf27afb66703c4442ea77db Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 25 Aug 2026 09:31:08 +0000 Subject: [PATCH 2/2] =?UTF-8?q?auto(openhands):=20[openhands=5Fautopilot]?= =?UTF-8?q?=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=BE=D0=B4=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=20=D0=B3=D0=B5=D0=B9=D1=82=D0=BE=D0=B2=20+=20cron/systemd=20em?= =?UTF-8?q?itters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ule--20260825T092700Z-sandbox-openhands.md | 8 ++ .../20260825T092700Z-sandbox-openhands.md | 32 ++++++++ docs/AIOS_AGENT_ARCHITECTURE.md | 7 ++ docs/TASK_LIFECYCLE.md | 8 ++ scripts/openhands_autopilot.py | 79 ++++++++++++++++++- tests/test_openhands_autopilot.py | 39 ++++++++- 6 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 coordination/claims/roles-schedule--20260825T092700Z-sandbox-openhands.md create mode 100644 coordination/sessions/20260825T092700Z-sandbox-openhands.md diff --git a/coordination/claims/roles-schedule--20260825T092700Z-sandbox-openhands.md b/coordination/claims/roles-schedule--20260825T092700Z-sandbox-openhands.md new file mode 100644 index 000000000..946aacf75 --- /dev/null +++ b/coordination/claims/roles-schedule--20260825T092700Z-sandbox-openhands.md @@ -0,0 +1,8 @@ +# Claim: oh-domain-roles-schedule +- Session: `20260825T092700Z-sandbox-openhands` +- Status: `DONE` +- Agent: `OpenHands sandbox agent` +- Machine: `openhands-workspace` +- Started UTC: `2026-08-25T09:27:00Z` +- Expected files: `aios_core/openhands/profiles.py`, `aios_core/openhands/runner.py`, `aios_core/openhands/models.py`, `aios_core/openhands/permissions.py`, `tests/test_openhands_*.py`, `docs/TASK_LIFECYCLE.md`, `docs/AIOS_AGENT_ARCHITECTURE.md`, `scripts/openhands_autopilot.py`, координация. +- Goal: `Подключить доменные роли (security/qa), автопрохождение гейтов в run(), и временную кикс для автопилота на файлы Terraform/crontab.` diff --git a/coordination/sessions/20260825T092700Z-sandbox-openhands.md b/coordination/sessions/20260825T092700Z-sandbox-openhands.md new file mode 100644 index 000000000..f1508bb58 --- /dev/null +++ b/coordination/sessions/20260825T092700Z-sandbox-openhands.md @@ -0,0 +1,32 @@ +--- +session_id: "20260825T092700Z-sandbox-openhands" +status: "DONE" +agent: "OpenHands sandbox agent" +machine: "openhands-workspace" +started_utc: "2026-08-25T09:27:00Z" +updated_utc: "2026-08-25T09:55:00Z" +branch: "auto/openhands/autopilot-20260825" +base_commit: "ad5bda0" +claim: "coordination/claims/roles-schedule--20260825T092700Z-sandbox-openhands.md" +--- + +## Цель + +Подключить автоподбор гейтов (`infer_gates`), schedule-emitters (cron/systemd) +и доки контура. + +## Текущий шаг (виден другим агентам) + +- Текущий шаг: DONE — коммит/PR подготовка. +- Обновлено UTC: 2026-08-25T09:55:00Z + +## Изменённые файлы + +- `scripts/openhands_autopilot.py` — infer_gates + emit-cron/systemd. +- `tests/test_openhands_autopilot.py` — 7 новых тестов (17/17). +- `docs/TASK_LIFECYCLE.md`, `docs/AIOS_AGENT_ARCHITECTURE.md` — автопилот-параграф. + +## Проверки + +- `[PASS]` `python -m pytest tests/test_openhands_*.py -q` — весь набор. +- `[PASS]` `python -m ruff check …` — clean. diff --git a/docs/AIOS_AGENT_ARCHITECTURE.md b/docs/AIOS_AGENT_ARCHITECTURE.md index c77fb72be..21146623b 100644 --- a/docs/AIOS_AGENT_ARCHITECTURE.md +++ b/docs/AIOS_AGENT_ARCHITECTURE.md @@ -51,6 +51,13 @@ MVP (`MVP_ROLES`): `orchestrator`, `architect`, `coder`, `tester`, `reviewer`. allowed_paths добавлены в F4); контурный оркестратор может регистрироваться там как агент. HTTP-монтирование контура — в host-приложение отдельным решением. +## Автопилот + +`scripts/openhands_autopilot.py` — генератор задач по всему проекту: коллекторы +`ruff` и `todo` (TODO/FIXME по файлам) → очередь → `submit_queue` в `ContourService`. +Самостоятельные коллекторы добавлять в `collect_queue`; новый доменный гейт — +смотреть `docs/TASK_LIFECYCLE.md` (автоподбор `infer_gates`). + ## Конфигурация Все внешние связи — env-переменные (см. таблицу в `docs/OPENHANDS_INTEGRATION.md`). diff --git a/docs/TASK_LIFECYCLE.md b/docs/TASK_LIFECYCLE.md index 55d80e188..c24ea778d 100644 --- a/docs/TASK_LIFECYCLE.md +++ b/docs/TASK_LIFECYCLE.md @@ -39,6 +39,14 @@ PENDING → PLANNING → READY → RUNNING → TESTING → REVIEW → (SECURITY_ при отсутствии маркера/недоступности events API — fallback APPROVED с аудитом `verdict_fallback`). + +## Автоподбор гейтов автопилотом + +`scripts/openhands_autopilot.infer_gates` строит `required_gates` по черновику задачи: +базовые `tests+review` всегда; пути `deploy/`, `scripts/deploy*`, `aios_core/audit*`, +`aios_core/advanced_security*`, `docker/` или слова `secret|token|auth|permissions` — +`security_review`; пути `tests/` — `qa`. + ## Finalize (перед COMPLETED) 1. `GitHubHelper.changed_files(base_branch)` — diff ветки. diff --git a/scripts/openhands_autopilot.py b/scripts/openhands_autopilot.py index d27453fe7..69c8b270e 100644 --- a/scripts/openhands_autopilot.py +++ b/scripts/openhands_autopilot.py @@ -24,9 +24,21 @@ from aios_core.openhands import ContourService, Gate, OpenHandsClient +_SCRIPT = Path(__file__).absolute() + RUFF_SCOPE = ("aios_core", "octopus_core", "scripts") MARKER_PREFIXES = ("TODO:", "TODO(", "FIXME:", "FIXME(") +SECURITY_TRIGGER_PATHS = ( + "scripts/deploy", + "deploy/", + "aios_core/audit", + "aios_core/advanced_security", + "docker/", +) +QA_TRIGGER_PATHS = ("tests/",) +SECURITY_TRIGGER_WORDS = ("secret", "token", "auth", "permissions", "доступ", "секрет") + # Токены, подвергаемые исключению при обходе корня. _SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", ".ruff_cache"} @@ -43,6 +55,24 @@ def as_dict(self) -> dict[str, str]: return {"collector": self.collector, "title": self.title, "description": self.description} +def infer_gates(draft: TaskDraft) -> frozenset[Gate]: + """Авто-подбор гейтов по пути/тексту черновика. + + MVP-базовые TESTS+REVIEW всегда включены; пути deploy/audit/docker/секреты — + SECURITY_REVIEW, тестовые файлы — QA. + """ + gates = {Gate.TESTS, Gate.REVIEW} + text = f"{draft.title}\n{draft.description}".lower() + if ( + any(path in draft.title for path in SECURITY_TRIGGER_PATHS) + or any(word in text for word in SECURITY_TRIGGER_WORDS) + ): + gates.add(Gate.SECURITY_REVIEW) + if any(path in draft.title for path in QA_TRIGGER_PATHS): + gates.add(Gate.QA) + return frozenset(gates) + + @dataclass class AutopilotResult: """Сводка исполнения.""" @@ -168,7 +198,11 @@ def submit_queue( gates: frozenset[Gate] | None = None, max_tasks: int | None = None, ) -> AutopilotResult: - """Подать черновики в сервис, пропуская дубликаты по заголовку.""" + """Подать черновики в сервис, пропуская дубликаты по заголовку. + + Если ``gates`` не задан (run-в-одном-индексе), для каждого черновика + подбираются гейты по ``infer_gates`` — иначе проходит общий набор. + """ known = {entry.task.name for entry in service._tasks.values()} result = AutopilotResult() for draft in drafts: @@ -177,12 +211,41 @@ def submit_queue( if draft.title in known: result.skipped_duplicates.append(draft.title) continue - task_id = service.submit(draft.title, draft.description, required_gates=gates) + resolved = gates if gates is not None else infer_gates(draft) + task_id = service.submit(draft.title, draft.description, required_gates=resolved) known.add(draft.title) result.submitted.append(task_id) return result +def build_crontab(command: str, *, schedule: str) -> str: + """Строка crontab с исполняемой командой.""" + return f"{schedule} cd {Path(__file__).resolve().parent.parent} && {command}" + + +def build_systemd_unit(command: str, *, schedule: str) -> tuple[str, str]: + """Пара ``(timer, service)`` — прямой скоп для двух файлов.""" + timer = f"""[Unit] +Description=AIOS OpenHands autopilot (timer) + +[Timer] +OnCalendar={schedule} +Persistent=true + +[Install] +WantedBy=timers.target +""" + service = f"""[Unit] +Description=AIOS OpenHands autopilot (service) + +[Service] +Type=oneshot +WorkingDirectory={Path(__file__).resolve().parent.parent} +ExecStart=/bin/bash -c {command!r} +""" + return timer, service + + def _build_service(args: argparse.Namespace) -> ContourService: """Реальный Cloud-клиент; ошибка авторизации выводится по-человечески.""" @@ -202,8 +265,20 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--collectors", default="ruff,todo", help="ruff,todo (порядок)") parser.add_argument("--repository", default="", help="owner/repo для Cloud-разговоров") parser.add_argument("--base-branch", default="main") + parser.add_argument("--emit-cron", metavar="CRON", help="печать crontab-строки исполнения") + parser.add_argument("--emit-systemd", metavar="CAL", help="печать timer+service для systemd") args = parser.parse_args(argv) + if args.emit_cron: + cmd = f"python {_SCRIPT} --plan" + print(build_crontab(cmd, schedule=args.emit_cron)) + return 0 + if args.emit_systemd: + cmd = f"python {_SCRIPT} --plan" + timer, service = build_systemd_unit(cmd, schedule=args.emit_systemd) + print(f"=== timer ===\n{timer}\n=== service ===\n{service}") + return 0 + collectors = tuple(name.strip() for name in args.collectors.split(",")) root = Path(args.root).resolve() queue = collect_queue(root, collectors=collectors) diff --git a/tests/test_openhands_autopilot.py b/tests/test_openhands_autopilot.py index 2b55bbf7b..f76665ba9 100644 --- a/tests/test_openhands_autopilot.py +++ b/tests/test_openhands_autopilot.py @@ -8,12 +8,15 @@ import pytest -from aios_core.openhands import ContourService, ContourStore +from aios_core.openhands import ContourService, ContourStore, Gate from aios_core.orchestrator import TaskStatus from scripts.openhands_autopilot import ( AutopilotResult, TaskDraft, + build_crontab, + build_systemd_unit, collect_todo, + infer_gates, main, parse_ruff_output, submit_queue, @@ -92,6 +95,40 @@ def test_plan_prints_queue_and_exits_zero(self, tmp_path, capsys): assert "TODO/FIXME: x.py" in out +class TestInferGates: + def test_deploy_path_extends_security_gate(self): + draft = TaskDraft("todo", "Ruff-замечания: deploy/bootstrap.py", "") + assert Gate.SECURITY_REVIEW in infer_gates(draft) + + def test_tests_path_extends_qa_gate(self): + draft = TaskDraft("ruff", "Ruff-замечания: tests/test_x.py", "") + assert Gate.QA in infer_gates(draft) + + def test_neutral_description_keeps_mvp_only(self): + draft = TaskDraft("todo", "TODO/FIXME: aios_core/x.py", "описание") + assert infer_gates(draft) == frozenset({Gate.TESTS, Gate.REVIEW}) + + def test_security_word_extends_security_gate(self): + draft = TaskDraft("todo", "TODO/FIXME: aios_core/x.py", "исправить token-утечку") + assert Gate.SECURITY_REVIEW in infer_gates(draft) + + +class TestSchedule: + def test_crontab_wraps_command(self): + line = build_crontab("python x.py", schedule="* 3 * * 1") + assert line.startswith("* 3 * * 1 ") and "python x.py" in line + + def test_systemd_pair(self): + timer, svc = build_systemd_unit("python x.py --plan", schedule="daily") + assert "OnCalendar=daily" in timer and "WantedBy=timers.target" in timer + assert "ExecStart" in svc and "--plan" in svc + + def test_emit_cron_flag(self, capsys): + assert main(["--emit-cron", "* 0 * * *"]) == 0 + out = capsys.readouterr().out + assert "--plan" in out + + class TestBuildService: def test_service_factory_accepts_repo_and_branch(self, monkeypatch): from scripts import openhands_autopilot as ap