From 1ebe8afc23cedc4edf03bb803626e873645816d8 Mon Sep 17 00:00:00 2001 From: Dev Agent Date: Tue, 2 Jun 2026 21:12:06 +0300 Subject: [PATCH 1/8] feat(worktree): git worktree per task to isolate shared /repos (ORCH-2 / S-4) - add src/git_worktree.py: ensure/remove/get_worktree_path - config: worktrees_dir=/repos/_wt - launcher: agent runs in per-branch worktree; task-file + commit/push in worktree; no shared checkout - qg/checks: read artifacts + run make test from worktree (branch arg, backward-compatible) - webhooks/plane: pass branch into QG dispatch; review fallback from worktree - webhooks/gitea: keep read-only branch --contains in main clone (documented) - tests: test_git_worktree.py (isolation) + update test_launcher write-task-file - docs: ARCHITECTURE worktree section + BUGFIXES_2026-06-02_ORCH2 Preserves B-1/B-2/S-1/S-5 fixes (paths now point at worktree). --- docs/ARCHITECTURE.md | 40 ++++++-- docs/BUGFIXES_2026-06-02_ORCH2.md | 81 ++++++++++++++++ src/agents/launcher.py | 72 +++++++------- src/config.py | 1 + src/git_worktree.py | 107 +++++++++++++++++++++ src/qg/checks.py | 57 ++++++----- src/webhooks/gitea.py | 4 +- src/webhooks/plane.py | 9 +- tests/test_git_worktree.py | 152 ++++++++++++++++++++++++++++++ tests/test_launcher.py | 40 +++++--- 10 files changed, 474 insertions(+), 89 deletions(-) create mode 100644 docs/BUGFIXES_2026-06-02_ORCH2.md create mode 100644 src/git_worktree.py create mode 100644 tests/test_git_worktree.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 696b659..7aec12f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -39,7 +39,7 @@ STAGE_TRANSITIONS = { |-------|---------------| | check_analysis_approved | Filesystem: 4 файла + :approved: comment в Plane | | check_architecture_done | Filesystem: ADR dir или infra-requirements.md | -| check_tests_local | Оркестратор сам гоняет `make test` в `/repos/` (judge по exit-code). Заменил check_ci_green: Gitea CI не сконфигурирован. | +| check_tests_local | Оркестратор сам гоняет `make test` в **worktree задачи** `/repos/_wt//` (judge по exit-code). Заменил check_ci_green: Gitea CI не сконфигурирован. Worktree-изоляция → безопасно при параллельных задачах (ORCH-2 / S-4). | | check_reviewer_verdict | Filesystem: читает `verdict: APPROVED\|REQUEST_CHANGES` из YAML-frontmatter `12-review.md` (только машиночитаемое поле, не подстроки в тексте) | | check_tests_passed | Filesystem: test-report.md содержит "PASS" | | check_ci_green | (legacy) Gitea API: GET /commits/{branch}/status — больше не используется как QG развития | @@ -188,7 +188,7 @@ services: Каждый агент — Claude CLI с: - **System prompt**: `.openclaw/agents/{role}.md` (в репозитории) -- **Task file**: `.task-{suffix}.md` — генерируется orchestrator **прямой записью в смонтированный volume `/repos//`** (B-1, без docker). В `.gitignore` репозитория проекта (рантайм-артефакт, не коммитится). +- **Task file**: `.task-{suffix}.md` — генерируется orchestrator **прямой записью в worktree задачи** `/repos/_wt///` (B-1, без docker; ORCH-2 — в изолированную рабочую копию, не в shared `/repos/`). В `.gitignore` репозитория проекта (рантайм-артефакт, не коммитится). - **Tools**: Read, Write, Edit, Bash - **Output**: `--print` mode (весь вывод в stdout после завершения) @@ -201,13 +201,39 @@ services: | tester | test-report.md, e2e results | 10-25 мин | | deployer | merge PR + SSH deploy-hook + smoke | 5-10 мин | +## Изоляция через git worktree (ORCH-2 / S-4) + +Каждая задача (= одна git-ветка) работает в **изолированной git worktree**, а не в общем +`/repos/`. Это убирает гонки `git checkout`, когда две задачи активны одновременно. + +``` +/repos/ ← основной clone (fetch / управление worktree, read-only запросы) +/repos/_wt// ← worktree конкретной задачи (рабочая копия агента) +``` + +Модуль `src/git_worktree.py`: +- `get_worktree_path(repo, branch)` — путь worktree (не создаёт). +- `ensure_worktree(repo, branch)` — создаёт (или переиспользует) worktree на нужной ветке; + для новой ветки создаёт её от `origin/main`. Возвращает путь. +- `remove_worktree(repo, branch)` — опциональная очистка при `done`. + +Где используется worktree: +- **launcher**: агент запускается с `cd ` (без `git checkout` в cmd); task-файл + пишется в worktree; commit/push в `_monitor_agent` идут в worktree. +- **qg/checks**: чтение артефактов агента (`check_analysis_complete`, `check_architecture_done`, + `check_tests_passed`, `check_reviewer_verdict`) и `check_tests_local` (`make test`) — из worktree. + Артефакт-функции принимают опциональный `branch`; без него падают на shared `/repos/` + (обратная совместимость). +- **webhooks/gitea**: `git branch -r --contains ` оставлен в основном clone — это + **read-only** запрос (нет checkout/мутации), гонок не создаёт. + +> Один branch может быть checked out только в одной worktree одновременно — +> это и есть нужное свойство: одна задача = одна ветка = одна worktree. + ## Известные ограничения -- **Shared `/repos` checkout (гонки при параллельных задачах).** Все агенты и - `check_tests_local` делают `git checkout` в одном `/repos/`. При двух - одновременно активных задачах checkout одной перетрёт рабочую копию другой. - Пока приемлемо (задачи идут последовательно). **Исправление — git worktree per task/branch - (запланировано отдельной задачей S-4).** +- ~~Shared `/repos` checkout (гонки при параллельных задачах).~~ **РЕШЕНО (ORCH-2 / S-4):** + git worktree per task/branch — см. раздел «Изоляция через git worktree» ниже. - **In-process daemon-потоки.** Агенты запускаются в daemon-потоках uvicorn. При рестарте uvicorn запущенные агенты осиротевают → ловит orphan-recovery (M-1). Целевая архитектура — очередь задач (F-2b, отдельно). diff --git a/docs/BUGFIXES_2026-06-02_ORCH2.md b/docs/BUGFIXES_2026-06-02_ORCH2.md new file mode 100644 index 0000000..6034d57 --- /dev/null +++ b/docs/BUGFIXES_2026-06-02_ORCH2.md @@ -0,0 +1,81 @@ +# ORCH-2 / S-4 — git worktree per task (изоляция shared /repos) + +**Дата:** 2026-06-02 +**Ветка:** `feature/ORCH-2-worktree` +**Источник:** `AUDIT_2026-06-02.md` (SERIOUS S-4), `DEV_TASK_ORCH2_WORKTREE.md` +**Исполнитель:** Dev (Opus 4.8 Tokenator) + +## Проблема (S-4) + +Все git-операции (`launcher.launch` cmd, `_monitor_agent` commit/push, `check_tests_local`) +делали `git checkout ` в одном общем `/repos/`. При двух активных задачах +checkout одной перетирал рабочую копию другой → гонки (на ET-009 это дало «два коллектора» +и путаницу веток). + +## Решение + +**git worktree per branch.** Каждая задача (ветка) работает в изолированной рабочей копии: + +``` +/repos/ ← основной clone (fetch / worktree mgmt / read-only) +/repos/_wt// ← worktree задачи (рабочая копия агента) +``` + +## Изменения + +| Файл | Что | +|------|-----| +| `src/config.py` | + `worktrees_dir: str = "/repos/_wt"` | +| `src/git_worktree.py` (новый) | `_safe`, `get_worktree_path`, `ensure_worktree`, `remove_worktree` | +| `src/agents/launcher.py` | `launch()`: ветка резолвится заранее → `ensure_worktree`; cmd = `cd ` без `git checkout`; `_write_task_file(repo, branch, ...)` пишет в worktree; `_monitor_agent` commit/push в worktree (checkout убран); чтение `01-questions.md`/`10-conflict.md` из worktree; QG-диспетчер прокидывает `branch` | +| `src/qg/checks.py` | `_repo_path(repo, branch)` helper (worktree если есть, иначе shared); артефакт-чеки получили опциональный `branch`; `check_tests_local` → `ensure_worktree` + `make test` в worktree (TODO про S-4 удалён) | +| `src/webhooks/plane.py` | QG-диспетчер прокидывает `branch`; review-файл fallback читается из worktree | +| `src/webhooks/gitea.py` | `git branch -r --contains ` — подтверждено read-only, оставлено в main clone (+ комментарий) | +| `tests/test_git_worktree.py` (новый) | покрытие `_safe`/`get_worktree_path`/`ensure_worktree`/`remove_worktree` + изоляция двух веток (реальные локальные git-репо в tmp, без сети) | +| `tests/test_launcher.py` | `TestWriteTaskFile` обновлён под новую сигнатуру (запись в worktree) | +| `docs/ARCHITECTURE.md` | раздел «Изоляция через git worktree»; убран пункт про shared-checkout гонки | + +## Совместимость с прежними фиксами + +- **B-1** (запись task-файла без docker, прямой `open()`): сохранена — теперь путь = worktree. +- **B-2** (Popen stdout → файл, monitor `proc.wait()` без зомби): не тронут. +- **S-5** (`check_reviewer_verdict` — только YAML-frontmatter): не тронут, добавлен лишь worktree-путь. +- **S-1** (`check_tests_local` — свой `make test` вместо Gitea CI): сохранён, тесты теперь в worktree. + +Обратная совместимость QG-диспетчеризации: артефакт-чеки принимают `branch` опционально +(default `None` → shared `/repos/`), поэтому существующие 2-арг вызовы/тесты не сломаны. + +## Проверка + +```bash +# Тесты (в контейнере через образ — хостовый .venv сломан): +IMG=$(docker inspect orchestrator --format '{{.Config.Image}}') +docker run --rm -v /home/slin/repos/orchestrator:/code -w /code --entrypoint python3 $IMG -m pytest tests/ -q +# → 37 passed, 9 failed (pre-existing test_webhooks 401/signature — НЕ относятся к ORCH-2, +# идентичны baseline на main). + +# test_git_worktree.py изолированно → 9 passed. +``` + +### Тест изоляции (в работающем контейнере) + +```bash +docker exec orchestrator python3 -c " +import sys; sys.path.insert(0,'/app') +from src.git_worktree import ensure_worktree +import subprocess +p1 = ensure_worktree('enduro-trails','feature/wt-test-A') +p2 = ensure_worktree('enduro-trails','feature/wt-test-B') +b1 = subprocess.run(['git','-C',p1,'branch','--show-current'],capture_output=True,text=True).stdout.strip() +b2 = subprocess.run(['git','-C',p2,'branch','--show-current'],capture_output=True,text=True).stdout.strip() +assert p1!=p2 and b1!=b2, 'NOT ISOLATED' +print('ISOLATION OK', p1, p2, b1, b2) +" +``` + +(Результат прогона на сервере — см. ниже / в отчёте Стрим.) + +## Ограничения / заметки + +- Очередь задач (ORCH-1 / F-2b) **не** входит в эту задачу. +- `remove_worktree` существует, но автоматический вызов при `done` не подключён (опционально, отдельным шагом). diff --git a/src/agents/launcher.py b/src/agents/launcher.py index 47d6574..b129412 100644 --- a/src/agents/launcher.py +++ b/src/agents/launcher.py @@ -6,6 +6,7 @@ import signal from ..config import settings from ..db import get_db, get_task_by_repo_branch, update_task_stage from ..stages import get_next_stage, get_qg_for_stage, get_agent_for_stage +from ..git_worktree import ensure_worktree, get_worktree_path from ..qg.checks import QG_CHECKS from ..notifications import notify_stage_change, notify_qg_failure, notify_agent_started, notify_agent_finished, notify_approve_requested from ..plane_sync import notify_stage_change as plane_notify_stage, add_comment as plane_add_comment @@ -71,15 +72,22 @@ class AgentLauncher: if not config: raise ValueError(f"Unknown agent: {agent}") - # Container-local path (repos mounted at /repos) + # Main clone lives at /repos/; the agent works in an isolated worktree + # (ORCH-2 / S-4) so concurrent tasks never fight over a shared checkout. local_repo_path = os.path.join(settings.repos_dir, repo) - if not os.path.isdir(local_repo_path): raise FileNotFoundError(f"Repo not found: {local_repo_path}") - # Write task file if content provided (B-1: direct write to mounted /repos, no docker) + # Determine branch (needed before we touch the worktree / task file). + _br_row = get_db().execute("SELECT branch FROM tasks WHERE id=?", (task_id,)).fetchone() if task_id else None + agent_branch = _br_row[0] if _br_row else "main" + + # Ensure the per-branch worktree exists and is on the right branch. + work_path = ensure_worktree(repo, agent_branch) + + # Write task file if content provided (B-1: direct write; now into the worktree). if task_content: - self._write_task_file(repo, config["task_file"], task_content) + self._write_task_file(repo, agent_branch, config["task_file"], task_content) # Record run in DB conn = get_db() @@ -99,15 +107,13 @@ class AgentLauncher: system_prompt = config["system_prompt"] allowed_tools = config["allowed_tools"] - # Determine branch for checkout - _br_row = get_db().execute("SELECT branch FROM tasks WHERE id=?", (task_id,)).fetchone() if task_id else None - agent_branch = _br_row[0] if _br_row else "main" - model = config.get("model", "") model_flag = f"--model {model} " if model else "" + # No git fetch/checkout here: ensure_worktree() already put the worktree on + # the right branch. The agent simply runs inside its isolated work_path. cmd = ( - f'cd {local_repo_path} && git fetch origin 2>/dev/null; git checkout {agent_branch} 2>/dev/null || git checkout -b {agent_branch} origin/{agent_branch} 2>/dev/null; ' + f'cd {work_path} && ' f'{self.CLAUDE_BIN} --print ' f'{model_flag}' f'"$(cat {task_file})" ' @@ -219,8 +225,10 @@ class AgentLauncher: notify_agent_finished(run_id, agent, exit_code, task_id=_task_id, duration_s=_duration_s) - # Commit and push any changes - repo_path = os.path.join(settings.repos_dir, repo) + # Commit and push any changes — in the per-branch worktree (ORCH-2 / S-4), + # NOT in the shared /repos/. The worktree is already on `branch` + # (ensure_worktree did the checkout), so no checkout is needed here. + repo_path = get_worktree_path(repo, branch) try: git_env = { **os.environ, @@ -230,20 +238,6 @@ class AgentLauncher: "GIT_COMMITTER_NAME": "claude-bot", "GIT_COMMITTER_EMAIL": "claude-bot@mva154.local", } - # Checkout feature branch before committing - subprocess.run( - ["git", "-C", repo_path, "fetch", "origin"], - capture_output=True, text=True, timeout=30, env=git_env - ) - checkout_result = subprocess.run( - ["git", "-C", repo_path, "checkout", branch], - capture_output=True, text=True, timeout=30, env=git_env - ) - if checkout_result.returncode != 0: - subprocess.run( - ["git", "-C", repo_path, "checkout", "-b", branch, f"origin/{branch}"], - capture_output=True, text=True, timeout=30, env=git_env - ) result = subprocess.run( ["git", "-C", repo_path, "status", "--porcelain"], capture_output=True, text=True, timeout=10, env=git_env @@ -351,7 +345,7 @@ class AgentLauncher: if agent == "analyst" and qg_name == "check_analysis_approved" and work_item_id: files_check = QG_CHECKS.get("check_analysis_complete") if files_check: - files_ok, _ = files_check(repo, work_item_id) + files_ok, _ = files_check(repo, work_item_id, branch) if files_ok: # Full artifacts ready -> In Review from ..plane_sync import set_issue_in_review @@ -364,10 +358,10 @@ class AgentLauncher: notify_approve_requested(task_id) logger.info(f"Task {task_id}: analyst finished, requested :approved: in Plane") else: - # Check if questions file exists + # Check if questions file exists (in the task worktree) import os as _os questions_path = _os.path.join( - settings.repos_dir, repo, + get_worktree_path(repo, branch), f"docs/work-items/{work_item_id}/01-questions.md" ) if _os.path.isfile(questions_path): @@ -392,11 +386,14 @@ class AgentLauncher: ) return elif qg_name in ("check_ci_green", "check_tests_local"): + # (repo, branch) signature — already worktree-aware. passed, reason = check_fn(repo, branch) elif qg_name == "check_tests_passed": - passed, reason = check_fn(repo, work_item_id or "") + # Artifact check — pass branch so it reads from the worktree. + passed, reason = check_fn(repo, work_item_id or "", branch) else: - passed, reason = check_fn(repo, work_item_id or "") + # Other artifact checks (check_architecture_done, etc.) — worktree-aware. + passed, reason = check_fn(repo, work_item_id or "", branch) if not passed: logger.info(f"Task {task_id}: QG '{qg_name}' not passed after {agent}: {reason}") @@ -461,7 +458,7 @@ class AgentLauncher: if agent == "architect" and qg_name == "check_architecture_done" and not passed: import os as _os conflict_path = _os.path.join( - settings.repos_dir, repo, + get_worktree_path(repo, branch), f"docs/work-items/{work_item_id}/10-conflict.md" ) if _os.path.isfile(conflict_path): @@ -578,15 +575,16 @@ class AgentLauncher: logger.error(f"Auto-merge failed for {branch}: {e}") return False - def _write_task_file(self, repo: str, task_file: str, content: str): - """Write task file directly to the mounted repo volume (/repos). + def _write_task_file(self, repo: str, branch: str, task_file: str, content: str): + """Write task file directly into the task's worktree. - B-1 fix: no docker. The repos directory is mounted RW at settings.repos_dir - (/repos inside the container), so write straight to /repos//. + B-1 fix: no docker (direct open()). ORCH-2/S-4: the target is the per-branch + worktree (/repos/_wt//), not the shared /repos/, so the + agent reads the task ZADANIE from its own isolated working copy. Raise on failure instead of silently swallowing errors. """ - container_repo_path = os.path.join(settings.repos_dir, repo) # /repos/ - full_path = os.path.join(container_repo_path, task_file) + work_path = get_worktree_path(repo, branch) # /repos/_wt// + full_path = os.path.join(work_path, task_file) try: with open(full_path, "w", encoding="utf-8") as f: f.write(content) diff --git a/src/config.py b/src/config.py index 1d00d2c..07c1fed 100644 --- a/src/config.py +++ b/src/config.py @@ -20,6 +20,7 @@ class Settings(BaseSettings): claude_bin: str = "/opt/claude-code/bin/claude.exe" repos_dir: str = "/repos" host_repos_dir: str = "/home/slin/repos" + worktrees_dir: str = "/repos/_wt" # ORCH-2 / S-4: isolated worktree per task/branch # DB db_path: str = "/app/data/orchestrator.db" diff --git a/src/git_worktree.py b/src/git_worktree.py new file mode 100644 index 0000000..1721907 --- /dev/null +++ b/src/git_worktree.py @@ -0,0 +1,107 @@ +"""Git worktree management — isolated working copy per task/branch (ORCH-2 / S-4). + +Background +---------- +Previously every git operation (checkout/commit/push/test) ran in the single shared +clone ``/repos/``. With two active tasks a ``git checkout`` of one branch would +overwrite the working copy of the other -> races (see AUDIT S-4 / ET-009 "two collectors"). + +Solution +-------- +Each task (branch) gets an isolated git worktree:: + + /repos/ <- main clone (fetch / worktree management) + /repos/_wt// <- worktree for one task/branch (agent works here) + +A branch can only be checked out in ONE worktree at a time, which is exactly the +property we want: one task = one branch = one worktree. +""" +import os +import re +import subprocess +import logging +from .config import settings + +logger = logging.getLogger("orchestrator.git_worktree") + + +def _safe(branch: str) -> str: + """Filesystem-safe branch name for use in a path component.""" + return re.sub(r"[^A-Za-z0-9._-]", "_", branch) + + +def get_worktree_path(repo: str, branch: str) -> str: + """Path of the worktree for (repo, branch). Does NOT create it.""" + return os.path.join(settings.worktrees_dir, repo, _safe(branch)) + + +def _main_repo(repo: str) -> str: + return os.path.join(settings.repos_dir, repo) + + +def ensure_worktree(repo: str, branch: str) -> str: + """Create (or reuse) an isolated worktree for ``branch``. Returns its path. + + Main clone stays at ``/repos/``. Worktree lives at + ``/repos/_wt//``. + + - If the worktree already exists, it is fetched + fast-aligned to the branch + (and to ``origin/`` when that remote branch exists). + - If the branch exists (locally or on origin) it is checked out into a fresh + worktree; otherwise a new branch is created from ``origin/main``. + """ + main_repo = _main_repo(repo) + wt = get_worktree_path(repo, branch) + + if not os.path.isdir(main_repo): + raise FileNotFoundError(f"Main repo not found: {main_repo}") + + # Always refresh refs in the main clone first. + subprocess.run(["git", "-C", main_repo, "fetch", "origin"], + capture_output=True, timeout=60) + + # Reuse existing worktree (.git may be a dir or a file pointer for worktrees). + if os.path.isdir(os.path.join(wt, ".git")) or os.path.isfile(os.path.join(wt, ".git")): + subprocess.run(["git", "-C", wt, "fetch", "origin"], capture_output=True, timeout=60) + subprocess.run(["git", "-C", wt, "checkout", branch], capture_output=True, timeout=30) + # Align to remote only if the remote branch exists (avoid wiping local-only work). + rb = subprocess.run( + ["git", "-C", wt, "rev-parse", "--verify", "--quiet", f"origin/{branch}"], + capture_output=True, + ) + if rb.returncode == 0: + subprocess.run(["git", "-C", wt, "reset", "--hard", f"origin/{branch}"], + capture_output=True, timeout=30) + logger.info(f"Worktree reused: {wt} (branch {branch})") + return wt + + os.makedirs(os.path.dirname(wt), exist_ok=True) + + # Try to attach an existing branch (local or remote-tracking) to the new worktree. + r = subprocess.run(["git", "-C", main_repo, "worktree", "add", wt, branch], + capture_output=True, text=True, timeout=60) + if r.returncode != 0: + # Branch doesn't exist yet — create it from origin/main. + r2 = subprocess.run( + ["git", "-C", main_repo, "worktree", "add", "-b", branch, wt, "origin/main"], + capture_output=True, text=True, timeout=60, + ) + if r2.returncode != 0: + raise RuntimeError( + f"git worktree add failed for {repo}:{branch}: " + f"{r.stderr.strip()} | {r2.stderr.strip()}" + ) + logger.info(f"Worktree ready: {wt} (branch {branch})") + return wt + + +def remove_worktree(repo: str, branch: str): + """Remove the worktree for (repo, branch) — optional cleanup when a task is done.""" + main_repo = _main_repo(repo) + wt = get_worktree_path(repo, branch) + subprocess.run(["git", "-C", main_repo, "worktree", "remove", "--force", wt], + capture_output=True, timeout=30) + # Prune dangling administrative entries. + subprocess.run(["git", "-C", main_repo, "worktree", "prune"], + capture_output=True, timeout=30) + logger.info(f"Worktree removed: {wt}") diff --git a/src/qg/checks.py b/src/qg/checks.py index fdca16e..7750b9e 100644 --- a/src/qg/checks.py +++ b/src/qg/checks.py @@ -7,12 +7,28 @@ from ..config import settings logger = logging.getLogger("orchestrator.qg") +from ..git_worktree import get_worktree_path, ensure_worktree + + +def _repo_path(repo: str, branch: str | None = None) -> str: + """Resolve the working path to read agent artifacts from. + + ORCH-2 / S-4: artifacts now live in the per-branch worktree. When a branch is + given and its worktree exists on disk, read from there; otherwise fall back to + the shared /repos/ clone (keeps backward-compat for 2-arg callers/tests). + """ + if branch: + wt = get_worktree_path(repo, branch) + if os.path.isdir(wt): + return wt + return os.path.join(settings.repos_dir, repo) + # Shared httpx client config GITEA_HEADERS = {"Authorization": f"token {settings.gitea_token}"} GITEA_BASE = f"{settings.gitea_url}/api/v1" -def check_analysis_complete(repo: str, work_item_id: str) -> tuple[bool, str]: +def check_analysis_complete(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]: """ Check if analysis artifacts exist in the repo branch. Required files: @@ -28,7 +44,7 @@ def check_analysis_complete(repo: str, work_item_id: str) -> tuple[bool, str]: f"docs/work-items/{work_item_id}/04-test-plan.yaml", ] - repo_path = os.path.join(settings.repos_dir, repo) + repo_path = _repo_path(repo, branch) missing = [] for f in required_files: @@ -41,13 +57,13 @@ def check_analysis_complete(repo: str, work_item_id: str) -> tuple[bool, str]: return True, "All analysis artifacts present" -def check_architecture_done(repo: str, work_item_id: str) -> tuple[bool, str]: +def check_architecture_done(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]: """ Check if architecture artifacts exist. Required: docs/work-items//06-adr/ (at least 1 file) OR: docs/work-items//07-infra-requirements.md """ - repo_path = os.path.join(settings.repos_dir, repo) + repo_path = _repo_path(repo, branch) adr_dir = os.path.join(repo_path, f"docs/work-items/{work_item_id}/06-adr") infra_file = os.path.join(repo_path, f"docs/work-items/{work_item_id}/07-infra-requirements.md") @@ -119,12 +135,12 @@ def check_review_approved(repo: str, pr_number: int) -> tuple[bool, str]: return False, f"API error: {e}" -def check_tests_passed(repo: str, work_item_id: str) -> tuple[bool, str]: +def check_tests_passed(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]: """ Check if test report exists and contains PASS indicator. File: docs/work-items//13-test-report.md """ - repo_path = os.path.join(settings.repos_dir, repo) + repo_path = _repo_path(repo, branch) report_path = os.path.join(repo_path, f"docs/work-items/{work_item_id}/13-test-report.md") if not os.path.isfile(report_path): @@ -141,7 +157,7 @@ def check_tests_passed(repo: str, work_item_id: str) -> tuple[bool, str]: -def check_analysis_approved(repo: str, work_item_id: str) -> tuple[bool, str]: +def check_analysis_approved(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]: """ Check if analysis is complete AND approved by stakeholder. Requirements: @@ -152,7 +168,7 @@ def check_analysis_approved(repo: str, work_item_id: str) -> tuple[bool, str]: so the approval check verifies file completeness as a safety gate. """ # First check files - files_ok, files_reason = check_analysis_complete(repo, work_item_id) + files_ok, files_reason = check_analysis_complete(repo, work_item_id, branch) if not files_ok: return False, files_reason @@ -187,7 +203,7 @@ def check_analysis_approved(repo: str, work_item_id: str) -> tuple[bool, str]: -def check_reviewer_verdict(repo: str, work_item_id: str) -> tuple[bool, str]: +def check_reviewer_verdict(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]: """ Check reviewer agent verdict from 12-review.md (S-5 fix). @@ -198,7 +214,7 @@ def check_reviewer_verdict(repo: str, work_item_id: str) -> tuple[bool, str]: (False, ...) -> verdict: REQUEST_CHANGES, missing verdict, or no frontmatter """ import yaml - repo_path = os.path.join(settings.repos_dir, repo) + repo_path = _repo_path(repo, branch) review_path = os.path.join(repo_path, f"docs/work-items/{work_item_id}/12-review.md") if not os.path.isfile(review_path): @@ -229,26 +245,15 @@ def check_reviewer_verdict(repo: str, work_item_id: str) -> tuple[bool, str]: def check_tests_local(repo: str, branch: str) -> tuple[bool, str]: """ - S-1 fix: run the project test suite locally in /repos/ and judge by exit - code, instead of depending on Gitea CI (which is not configured -> always false). + S-1 fix: run the project test suite locally and judge by exit code, instead of + depending on Gitea CI (which is not configured -> always false). - Checks out `branch` in the shared /repos checkout and runs `make test`. - NOTE (known limitation): the shared /repos checkout means this is not safe for - concurrent active tasks. git-worktree-per-task is a separate task (S-4). + ORCH-2 / S-4: tests run inside the per-branch worktree (ensure_worktree), so this + is safe for concurrent active tasks — no shared /repos checkout race. """ import subprocess - repo_path = os.path.join(settings.repos_dir, repo) try: - subprocess.run( - ["git", "-C", repo_path, "fetch", "origin"], - capture_output=True, timeout=30, - ) - co = subprocess.run( - ["git", "-C", repo_path, "checkout", branch], - capture_output=True, text=True, timeout=30, - ) - if co.returncode != 0: - return False, f"Cannot checkout branch '{branch}': {co.stderr.strip()[-200:]}" + repo_path = ensure_worktree(repo, branch) r = subprocess.run( ["make", "test"], cwd=repo_path, capture_output=True, text=True, timeout=600, diff --git a/src/webhooks/gitea.py b/src/webhooks/gitea.py index d0d3ef3..78e19d1 100644 --- a/src/webhooks/gitea.py +++ b/src/webhooks/gitea.py @@ -146,7 +146,9 @@ async def handle_ci_status(payload: dict): if not branch: sha = payload.get("sha", "") repo_name = payload.get("repository", {}).get("name", settings.default_repo) - # Try to find task by checking git branch containing this SHA + # Try to find task by checking git branch containing this SHA. + # ORCH-2 / S-4: this is a READ-ONLY query of remote-tracking refs in the main + # clone (no checkout / no mutation), so it is safe to keep on /repos/. try: result = subprocess.run( ["git", "-C", os.path.join(settings.repos_dir, repo_name), diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index 639b750..07ee6c5 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -304,7 +304,8 @@ async def _try_advance_stage( # Determine args based on QG function if qg_name in ("check_analysis_approved", "check_analysis_complete", "check_architecture_done", "check_tests_passed", "check_reviewer_verdict"): - passed, reason = qg_func(repo, work_item_id) + # ORCH-2 / S-4: pass branch so artifacts are read from the task worktree. + passed, reason = qg_func(repo, work_item_id, branch) elif qg_name in ("check_ci_green", "check_tests_local"): passed, reason = qg_func(repo, branch) elif qg_name == "check_review_approved": @@ -327,8 +328,10 @@ async def _try_advance_stage( else: # No open PR but review file exists — check file-based import os - _review_path = os.path.join(_s.repos_dir, repo, f"docs/work-items/{work_item_id}/12-review.md") - _review_path2 = os.path.join(_s.repos_dir, repo, f"docs/work-items/{work_item_id}/09-review.md") + from ..git_worktree import get_worktree_path as _gwp + _wt = _gwp(repo, branch) if os.path.isdir(_gwp(repo, branch)) else os.path.join(_s.repos_dir, repo) + _review_path = os.path.join(_wt, f"docs/work-items/{work_item_id}/12-review.md") + _review_path2 = os.path.join(_wt, f"docs/work-items/{work_item_id}/09-review.md") if os.path.isfile(_review_path) or os.path.isfile(_review_path2): passed, reason = True, "Review file exists (file-based approval)" else: diff --git a/tests/test_git_worktree.py b/tests/test_git_worktree.py new file mode 100644 index 0000000..66d5d1e --- /dev/null +++ b/tests/test_git_worktree.py @@ -0,0 +1,152 @@ +"""Tests for src/git_worktree (ORCH-2 / S-4): isolated worktree per task/branch. + +Uses real local git repos in tmp (a bare 'origin' + a working main clone) so that +`git fetch origin`, `git worktree add`, branch creation from origin/main, reuse and +removal are all exercised without network access. +""" +import os +import subprocess +import tempfile + +import pytest + +# Env must be set before importing app modules (same convention as the other suites). +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_wt.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ["ORCH_GITEA_TOKEN"] = "test-token" +os.environ["ORCH_PLANE_API_TOKEN"] = "test-token" + +from src import git_worktree +from src.git_worktree import ( + _safe, + get_worktree_path, + ensure_worktree, + remove_worktree, +) + + +def _git(cwd, *args): + return subprocess.run(["git", "-C", cwd, *args], capture_output=True, text=True) + + +@pytest.fixture +def repos(tmp_path, monkeypatch): + """Build a bare 'origin' with main + a feature branch, plus a main clone at repos_dir/. + + Returns the repo name. settings.repos_dir / worktrees_dir are pointed at tmp. + """ + repo = "enduro-trails" + repos_dir = tmp_path / "repos" + wt_dir = tmp_path / "repos" / "_wt" + repos_dir.mkdir(parents=True) + + monkeypatch.setattr(git_worktree.settings, "repos_dir", str(repos_dir)) + monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(wt_dir)) + + # Bare origin + origin = tmp_path / "origin.git" + subprocess.run(["git", "init", "--bare", "-b", "main", str(origin)], capture_output=True) + + # Seed repo + seed = tmp_path / "seed" + seed.mkdir() + _git(str(seed), "init", "-b", "main") + _git(str(seed), "config", "user.email", "t@t") + _git(str(seed), "config", "user.name", "t") + (seed / "README.md").write_text("# seed\n") + _git(str(seed), "add", ".") + _git(str(seed), "commit", "-m", "init") + _git(str(seed), "remote", "add", "origin", str(origin)) + _git(str(seed), "push", "origin", "main") + # An existing feature branch on origin + _git(str(seed), "checkout", "-b", "feature/existing") + (seed / "f.txt").write_text("feature\n") + _git(str(seed), "add", ".") + _git(str(seed), "commit", "-m", "feat") + _git(str(seed), "push", "origin", "feature/existing") + + # Main clone at repos_dir/ + main_clone = repos_dir / repo + subprocess.run(["git", "clone", str(origin), str(main_clone)], capture_output=True) + _git(str(main_clone), "config", "user.email", "t@t") + _git(str(main_clone), "config", "user.name", "t") + return repo + + +# --------------------------------------------------------------------------- +# _safe / get_worktree_path +# --------------------------------------------------------------------------- +class TestSafeAndPath: + def test_safe_replaces_slashes_and_specials(self): + assert _safe("feature/ET-001-x") == "feature_ET-001-x" + assert _safe("a b/c:d") == "a_b_c_d" + assert _safe("keep.dots-and_underscores") == "keep.dots-and_underscores" + + def test_get_worktree_path(self, monkeypatch): + monkeypatch.setattr(git_worktree.settings, "worktrees_dir", "/repos/_wt") + assert get_worktree_path("repo", "feature/x") == "/repos/_wt/repo/feature_x" + + +# --------------------------------------------------------------------------- +# ensure_worktree +# --------------------------------------------------------------------------- +class TestEnsureWorktree: + def test_missing_main_repo_raises(self, tmp_path, monkeypatch): + monkeypatch.setattr(git_worktree.settings, "repos_dir", str(tmp_path / "nope")) + monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(tmp_path / "_wt")) + with pytest.raises(FileNotFoundError): + ensure_worktree("enduro-trails", "main") + + def test_creates_worktree_for_existing_branch(self, repos): + wt = ensure_worktree(repos, "feature/existing") + assert os.path.isdir(wt) + assert wt == get_worktree_path(repos, "feature/existing") + # On the right branch + cur = _git(wt, "branch", "--show-current").stdout.strip() + assert cur == "feature/existing" + # Feature file from that branch is present (proves correct checkout) + assert os.path.isfile(os.path.join(wt, "f.txt")) + + def test_creates_new_branch_from_origin_main(self, repos): + wt = ensure_worktree(repos, "feature/brand-new") + assert os.path.isdir(wt) + cur = _git(wt, "branch", "--show-current").stdout.strip() + assert cur == "feature/brand-new" + # Based on main -> README present, no feature file + assert os.path.isfile(os.path.join(wt, "README.md")) + assert not os.path.isfile(os.path.join(wt, "f.txt")) + + def test_reuse_returns_same_path(self, repos): + wt1 = ensure_worktree(repos, "feature/existing") + wt2 = ensure_worktree(repos, "feature/existing") + assert wt1 == wt2 + assert os.path.isdir(wt2) + + def test_two_branches_are_isolated(self, repos): + a = ensure_worktree(repos, "feature/wt-A") + b = ensure_worktree(repos, "feature/wt-B") + assert a != b + ba = _git(a, "branch", "--show-current").stdout.strip() + bb = _git(b, "branch", "--show-current").stdout.strip() + assert ba == "feature/wt-A" + assert bb == "feature/wt-B" + # Writing in A must not affect B + with open(os.path.join(a, "only-a.txt"), "w") as f: + f.write("a") + assert not os.path.isfile(os.path.join(b, "only-a.txt")) + + +# --------------------------------------------------------------------------- +# remove_worktree +# --------------------------------------------------------------------------- +class TestRemoveWorktree: + def test_remove_deletes_worktree_dir(self, repos): + wt = ensure_worktree(repos, "feature/to-remove") + assert os.path.isdir(wt) + remove_worktree(repos, "feature/to-remove") + assert not os.path.isdir(wt) + + def test_remove_nonexistent_is_noop(self, repos): + # Should not raise even if the worktree was never created. + remove_worktree(repos, "feature/never-made") diff --git a/tests/test_launcher.py b/tests/test_launcher.py index f9cd376..8f05dda 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -26,24 +26,34 @@ from src.qg.checks import check_reviewer_verdict # B-1: _write_task_file # --------------------------------------------------------------------------- class TestWriteTaskFile: - def test_writes_to_repos_volume_path(self, tmp_path, monkeypatch): - """Task file is written to //, content matches.""" - monkeypatch.setattr("src.agents.launcher.settings.repos_dir", str(tmp_path)) - repo_dir = tmp_path / "enduro-trails" - repo_dir.mkdir() + """B-1 fix preserved + ORCH-2/S-4: task file now lands in the per-branch worktree. + + _write_task_file(repo, branch, task_file, content) writes to + /// with a plain open() (no docker). + """ + + def _wt_dir(self, tmp_path, repo, branch): + from src.git_worktree import _safe + d = tmp_path / "_wt" / repo / _safe(branch) + d.mkdir(parents=True) + return d + + def test_writes_to_worktree_path(self, tmp_path, monkeypatch): + """Task file is written to the worktree path, content matches (B-1 + S-4).""" + monkeypatch.setattr("src.git_worktree.settings.worktrees_dir", str(tmp_path / "_wt")) + wt = self._wt_dir(tmp_path, "enduro-trails", "feature/ET-001-x") launcher = AgentLauncher() - launcher._write_task_file("enduro-trails", ".task-dev.md", "hello-content") + launcher._write_task_file("enduro-trails", "feature/ET-001-x", ".task-dev.md", "hello-content") - written = repo_dir / ".task-dev.md" + written = wt / ".task-dev.md" assert written.is_file() assert written.read_text() == "hello-content" def test_does_not_use_docker(self, tmp_path, monkeypatch): """No subprocess/docker call: if subprocess.run were used it would error here.""" - monkeypatch.setattr("src.agents.launcher.settings.repos_dir", str(tmp_path)) - repo_dir = tmp_path / "enduro-trails" - repo_dir.mkdir() + monkeypatch.setattr("src.git_worktree.settings.worktrees_dir", str(tmp_path / "_wt")) + self._wt_dir(tmp_path, "enduro-trails", "main") called = {"run": False} @@ -54,17 +64,17 @@ class TestWriteTaskFile: monkeypatch.setattr("src.agents.launcher.subprocess.run", _fail_run) launcher = AgentLauncher() - launcher._write_task_file("enduro-trails", ".task.md", "x") + launcher._write_task_file("enduro-trails", "main", ".task.md", "x") assert called["run"] is False def test_raises_on_write_failure(self, tmp_path, monkeypatch): - """If the target dir does not exist, raise RuntimeError (do not fail silently).""" - monkeypatch.setattr("src.agents.launcher.settings.repos_dir", str(tmp_path)) - # repo dir intentionally NOT created -> open() raises OSError + """If the target worktree dir does not exist, raise RuntimeError (no silent fail).""" + monkeypatch.setattr("src.git_worktree.settings.worktrees_dir", str(tmp_path / "_wt")) + # worktree dir intentionally NOT created -> open() raises OSError launcher = AgentLauncher() with pytest.raises(RuntimeError): - launcher._write_task_file("nonexistent-repo", ".task.md", "x") + launcher._write_task_file("nonexistent-repo", "main", ".task.md", "x") # --------------------------------------------------------------------------- From 36d5f25f2a214cd630c25fe64a7b49687c4b566d Mon Sep 17 00:00:00 2001 From: Dev Agent Date: Tue, 2 Jun 2026 22:30:42 +0300 Subject: [PATCH 2/8] feat(projects): add project registry (Plane id -> repo/prefix mapping) ORCH-6: src/projects.py introduces ProjectConfig + resolvers (get_project_by_plane_id/by_repo, known_plane_project_ids) keyed by Plane project uuid. Source: ORCH_PROJECTS_JSON env (config.projects_json), with a built-in default registry (enduro-trails + orchestrator) and robust parsing (malformed JSON/entries fall back to default). --- src/config.py | 5 ++ src/projects.py | 127 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 src/projects.py diff --git a/src/config.py b/src/config.py index 07c1fed..d2d7d2c 100644 --- a/src/config.py +++ b/src/config.py @@ -16,6 +16,11 @@ class Settings(BaseSettings): gitea_owner: str = "admin" default_repo: str = "enduro-trails" + # ORCH-6: multi-repo project registry. JSON array of + # {plane_project_id, repo, work_item_prefix, name}. + # Empty -> built-in default registry in src/projects.py. + projects_json: str = "" + # Claude CLI claude_bin: str = "/opt/claude-code/bin/claude.exe" repos_dir: str = "/repos" diff --git a/src/projects.py b/src/projects.py new file mode 100644 index 0000000..3d9f11a --- /dev/null +++ b/src/projects.py @@ -0,0 +1,127 @@ +"""ORCH-6: Project registry — map Plane project id -> repo / work-item prefix. + +Root cause of the 2026-06-02 incident: the Plane webhook listened to the whole +workspace and hardcoded ``repo = settings.default_repo`` (enduro-trails). Every +issue from any project was funneled into one repo with one prefix (ET). + +This module introduces a small registry keyed by the Plane project uuid so the +orchestrator can: + * filter webhooks by project (ignore unknown projects), + * resolve the gitea repo + work-item prefix for a known project, + * route Plane sync (state/comment) into the issue's own project. + +Source of truth: ``settings.projects_json`` (a JSON array set via the +``ORCH_PROJECTS_JSON`` env var). If unset/empty/invalid, a built-in default +registry is used so the system works out of the box. +""" + +import json +import logging +from dataclasses import dataclass + +from .config import settings + +logger = logging.getLogger("orchestrator.projects") + + +@dataclass(frozen=True) +class ProjectConfig: + plane_project_id: str # uuid of the Plane project (registry key) + repo: str # gitea repo name (== folder under /repos) + work_item_prefix: str # ET / ORCH + name: str # human-readable label + + +# Built-in default registry (used when ORCH_PROJECTS_JSON is empty/invalid). +# Keep enduro-trails first so existing behaviour is the safe default. +_DEFAULT_PROJECTS = [ + ProjectConfig( + plane_project_id="7a79f0a9-5278-49cd-9007-9a338f238f9c", + repo="enduro-trails", + work_item_prefix="ET", + name="enduro-trails", + ), + ProjectConfig( + plane_project_id="8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a", + repo="orchestrator", + work_item_prefix="ORCH", + name="orchestrator", + ), +] + + +def _parse_projects_json(raw: str) -> list[ProjectConfig] | None: + """Parse ORCH_PROJECTS_JSON. Returns None if empty/invalid (-> use default).""" + if not raw or not raw.strip(): + return None + try: + data = json.loads(raw) + except (ValueError, TypeError) as e: + logger.error(f"ORCH_PROJECTS_JSON is not valid JSON, falling back to default: {e}") + return None + if not isinstance(data, list): + logger.error("ORCH_PROJECTS_JSON must be a JSON array, falling back to default") + return None + + parsed: list[ProjectConfig] = [] + for i, item in enumerate(data): + if not isinstance(item, dict): + logger.error(f"ORCH_PROJECTS_JSON[{i}] is not an object, skipping") + continue + try: + parsed.append( + ProjectConfig( + plane_project_id=str(item["plane_project_id"]), + repo=str(item["repo"]), + work_item_prefix=str(item["work_item_prefix"]), + name=str(item.get("name", item["repo"])), + ) + ) + except KeyError as e: + logger.error(f"ORCH_PROJECTS_JSON[{i}] missing required key {e}, skipping") + continue + if not parsed: + logger.error("ORCH_PROJECTS_JSON produced no valid entries, falling back to default") + return None + return parsed + + +def _load_projects() -> list[ProjectConfig]: + parsed = _parse_projects_json(getattr(settings, "projects_json", "") or "") + if parsed is not None: + logger.info(f"Project registry loaded from ORCH_PROJECTS_JSON: {len(parsed)} project(s)") + return parsed + return list(_DEFAULT_PROJECTS) + + +# Module-level registry, built once at import. +PROJECTS: list[ProjectConfig] = _load_projects() +_BY_PLANE_ID: dict[str, ProjectConfig] = {p.plane_project_id: p for p in PROJECTS} +_BY_REPO: dict[str, ProjectConfig] = {p.repo: p for p in PROJECTS} + + +def get_project_by_plane_id(plane_project_id: str) -> ProjectConfig | None: + """Resolve project config by Plane project uuid. None if unknown.""" + if not plane_project_id: + return None + return _BY_PLANE_ID.get(plane_project_id) + + +def get_project_by_repo(repo: str) -> ProjectConfig | None: + """Resolve project config by gitea repo name. None if unknown.""" + if not repo: + return None + return _BY_REPO.get(repo) + + +def known_plane_project_ids() -> set[str]: + """Set of Plane project ids the orchestrator is configured to handle.""" + return set(_BY_PLANE_ID.keys()) + + +def reload_projects() -> None: + """Rebuild the registry from current settings (used by tests).""" + global PROJECTS, _BY_PLANE_ID, _BY_REPO + PROJECTS = _load_projects() + _BY_PLANE_ID = {p.plane_project_id: p for p in PROJECTS} + _BY_REPO = {p.repo: p for p in PROJECTS} From 0797f958dc6ad486e767ca3665769e391dc52a74 Mon Sep 17 00:00:00 2001 From: Dev Agent Date: Tue, 2 Jun 2026 22:30:42 +0300 Subject: [PATCH 3/8] feat(db): per-project work-item prefix in get_next_work_item_id ORCH-6: get_next_work_item_id(repo, prefix="ET") numbers per (repo, prefix) so orchestrator issues number ORCH-001 independently of the ET sequence. Default prefix stays ET for backward compatibility. --- src/db.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/db.py b/src/db.py index 1a4fb50..693b954 100644 --- a/src/db.py +++ b/src/db.py @@ -79,21 +79,29 @@ def update_task_stage(task_id: int, stage: str): conn.close() -def get_next_work_item_id(repo: str) -> str: - """Generate next work item ID (e.g., ET-003).""" +def get_next_work_item_id(repo: str, prefix: str = "ET") -> str: + """Generate next work item ID (e.g., ET-003 / ORCH-001). + + ORCH-6: numbering is per (repo, prefix). The prefix comes from the project + registry (proj.work_item_prefix), so orchestrator issues number ORCH-001, + ORCH-002 independently of the ET sequence in enduro-trails. Default prefix + stays "ET" for backward compatibility with existing callers. + """ conn = get_db() row = conn.execute( - "SELECT work_item_id FROM tasks WHERE repo = ? AND work_item_id IS NOT NULL ORDER BY id DESC LIMIT 1", - (repo,), + "SELECT work_item_id FROM tasks " + "WHERE repo = ? AND work_item_id LIKE ? AND work_item_id IS NOT NULL " + "ORDER BY id DESC LIMIT 1", + (repo, f"{prefix}-%"), ).fetchone() conn.close() if row and row["work_item_id"]: - # Parse ET-003 -> 3, increment - prefix, num = row["work_item_id"].rsplit("-", 1) + # Parse -003 -> 3, increment (keep the existing prefix). + existing_prefix, num = row["work_item_id"].rsplit("-", 1) + prefix = existing_prefix next_num = int(num) + 1 else: - prefix = "ET" next_num = 1 return f"{prefix}-{next_num:03d}" From a87c6330031217a18fed66bee480fe12c54c9821 Mon Sep 17 00:00:00 2001 From: Dev Agent Date: Tue, 2 Jun 2026 22:30:42 +0300 Subject: [PATCH 4/8] refactor(plane_sync): parameterize project_id (backward compatible) ORCH-6: sync functions resolve the issue PROJECT_ID via the registry (get_project_by_repo) and accept project_id; default stays enduro so existing ET callers keep working. --- src/plane_sync.py | 89 +++++++++++++++++++++++++++++++++-------------- src/qg/checks.py | 8 +++-- 2 files changed, 68 insertions(+), 29 deletions(-) diff --git a/src/plane_sync.py b/src/plane_sync.py index 6c44510..762cbbb 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -11,6 +11,35 @@ PLANE_HEADERS = {"X-API-Key": settings.plane_api_token} WORKSPACE = settings.plane_workspace_slug PROJECT_ID = settings.plane_project_id or "7a79f0a9-5278-49cd-9007-9a338f238f9c" + +def _resolve_project_id(work_item_id: str = None, project_id: str = None) -> str: + """ORCH-6: resolve the Plane project id for a sync call. + + Priority: + 1. explicit project_id arg (caller already knows the project), + 2. project derived from the task's repo in the DB (by work_item_id), + 3. legacy default PROJECT_ID (enduro) for backward compatibility. + """ + if project_id: + return project_id + if work_item_id: + try: + from .db import get_db + from .projects import get_project_by_repo + conn = get_db() + row = conn.execute( + "SELECT repo FROM tasks WHERE work_item_id = ? ORDER BY id DESC LIMIT 1", + (work_item_id,), + ).fetchone() + conn.close() + if row and row[0]: + proj = get_project_by_repo(row[0]) + if proj: + return proj.plane_project_id + except Exception as e: + logger.debug(f"_resolve_project_id fallback for {work_item_id}: {e}") + return PROJECT_ID + # Plane state IDs PLANE_STATES = { "backlog": "113b24f6-cce8-4be9-9a22-a359b9cf0122", @@ -36,8 +65,9 @@ STAGE_TO_STATE = { } -def find_issue_id(work_item_id: str) -> str | None: +def find_issue_id(work_item_id: str, project_id: str = None) -> str | None: """Find Plane issue UUID by work_item_id (e.g. 'ET-002').""" + project_id = _resolve_project_id(work_item_id, project_id) # Primary: lookup from DB (plane_issue_id column) try: from .db import get_db @@ -52,7 +82,7 @@ def find_issue_id(work_item_id: str) -> str | None: logger.debug(f"DB lookup failed for {work_item_id}: {e}") # Fallback: search via Plane API - url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/" + url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/" try: # First try search by work_item_id resp = httpx.get(url, headers=PLANE_HEADERS, params={"search": work_item_id}, timeout=10) @@ -83,18 +113,19 @@ def find_issue_id(work_item_id: str) -> str | None: return None -def update_issue_state(work_item_id: str, stage: str): +def update_issue_state(work_item_id: str, stage: str, project_id: str = None): """Update Plane issue state based on orchestrator stage.""" state_id = STAGE_TO_STATE.get(stage) if not state_id: return - issue_id = find_issue_id(work_item_id) + project_id = _resolve_project_id(work_item_id, project_id) + issue_id = find_issue_id(work_item_id, project_id) if not issue_id: logger.warning(f"Issue not found in Plane for {work_item_id}") return - url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/" + url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/" try: resp = httpx.patch(url, headers=PLANE_HEADERS, json={"state": state_id}, timeout=10) resp.raise_for_status() @@ -103,14 +134,15 @@ def update_issue_state(work_item_id: str, stage: str): logger.error(f"Failed to update Plane state for {work_item_id}: {e}") -def add_comment(work_item_id: str, text: str): +def add_comment(work_item_id: str, text: str, project_id: str = None): """Add a comment to Plane issue.""" - issue_id = find_issue_id(work_item_id) + project_id = _resolve_project_id(work_item_id, project_id) + issue_id = find_issue_id(work_item_id, project_id) if not issue_id: logger.warning(f"Issue not found in Plane for {work_item_id}, skipping comment") return - url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/comments/" + url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/comments/" html = f"

{text}

" try: resp = httpx.post(url, headers=PLANE_HEADERS, json={"comment_html": html}, timeout=10) @@ -121,33 +153,34 @@ def add_comment(work_item_id: str, text: str): -def set_issue_needs_input(work_item_id: str): +def set_issue_needs_input(work_item_id: str, project_id: str = None): """Set issue to 'Needs Input' state — waiting for stakeholder response.""" - _set_issue_state_direct(work_item_id, PLANE_STATES["needs_input"]) + _set_issue_state_direct(work_item_id, PLANE_STATES["needs_input"], project_id) -def set_issue_in_review(work_item_id: str): +def set_issue_in_review(work_item_id: str, project_id: str = None): """Set issue to 'In Review' state — waiting for :approved: or :rejected:.""" - _set_issue_state_direct(work_item_id, PLANE_STATES["in_review"]) + _set_issue_state_direct(work_item_id, PLANE_STATES["in_review"], project_id) -def set_issue_blocked(work_item_id: str): +def set_issue_blocked(work_item_id: str, project_id: str = None): """Set issue to 'Blocked' state — manual intervention needed.""" - _set_issue_state_direct(work_item_id, PLANE_STATES["blocked"]) + _set_issue_state_direct(work_item_id, PLANE_STATES["blocked"], project_id) -def set_issue_in_progress(work_item_id: str): +def set_issue_in_progress(work_item_id: str, project_id: str = None): """Set issue to 'In Progress' state — agent working.""" - _set_issue_state_direct(work_item_id, PLANE_STATES["in_progress"]) + _set_issue_state_direct(work_item_id, PLANE_STATES["in_progress"], project_id) -def _set_issue_state_direct(work_item_id: str, state_id: str): +def _set_issue_state_direct(work_item_id: str, state_id: str, project_id: str = None): """Set issue state directly by state_id.""" - issue_id = find_issue_id(work_item_id) + project_id = _resolve_project_id(work_item_id, project_id) + issue_id = find_issue_id(work_item_id, project_id) if not issue_id: logger.warning(f"Issue not found in Plane for {work_item_id}") return - url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/" + url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/" try: resp = httpx.patch(url, headers=PLANE_HEADERS, json={"state": state_id}, timeout=10) resp.raise_for_status() @@ -156,9 +189,10 @@ def _set_issue_state_direct(work_item_id: str, state_id: str): logger.error(f"Failed to update Plane state for {work_item_id}: {e}") -def notify_stage_change(work_item_id: str, old_stage: str, new_stage: str, agent: str = None): +def notify_stage_change(work_item_id: str, old_stage: str, new_stage: str, agent: str = None, project_id: str = None): """Notify Plane about stage transition with links.""" - update_issue_state(work_item_id, new_stage) + project_id = _resolve_project_id(work_item_id, project_id) + update_issue_state(work_item_id, new_stage, project_id) msg = f"🔄 Stage: {old_stage} → {new_stage}" if agent: @@ -193,15 +227,16 @@ def notify_stage_change(work_item_id: str, old_stage: str, new_stage: str, agent except Exception: pass - add_comment(work_item_id, msg) + add_comment(work_item_id, msg, project_id) -def notify_qg_failure(work_item_id: str, stage: str, check: str, reason: str): +def notify_qg_failure(work_item_id: str, stage: str, check: str, reason: str, project_id: str = None): """Notify Plane about QG failure.""" - add_comment(work_item_id, f"⚠️ QG failed at {stage}: {check} — {reason}") + add_comment(work_item_id, f"⚠️ QG failed at {stage}: {check} — {reason}", project_id) -def notify_done(work_item_id: str): +def notify_done(work_item_id: str, project_id: str = None): """Mark issue as Done in Plane.""" - update_issue_state(work_item_id, "done") - add_comment(work_item_id, "✅ Task completed! PR merged and deployed.") + project_id = _resolve_project_id(work_item_id, project_id) + update_issue_state(work_item_id, "done", project_id) + add_comment(work_item_id, "✅ Task completed! PR merged and deployed.", project_id) diff --git a/src/qg/checks.py b/src/qg/checks.py index 7750b9e..01665b1 100644 --- a/src/qg/checks.py +++ b/src/qg/checks.py @@ -175,11 +175,15 @@ def check_analysis_approved(repo: str, work_item_id: str, branch: str | None = N # Check for :approved: comment via Plane API try: from ..plane_sync import find_issue_id, PLANE_BASE, PLANE_HEADERS, WORKSPACE, PROJECT_ID - issue_id = find_issue_id(work_item_id) + from ..projects import get_project_by_repo + # ORCH-6: verify approval in the issue's own Plane project. + _proj = get_project_by_repo(repo) + _pid = _proj.plane_project_id if _proj else PROJECT_ID + issue_id = find_issue_id(work_item_id, _pid) if not issue_id: return False, "Cannot find Plane issue to verify approval" - url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/comments/" + url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{_pid}/issues/{issue_id}/comments/" resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10) resp.raise_for_status() comments = resp.json() From 171f4eb304e2e7df4821be930e7d8097190297fe Mon Sep 17 00:00:00 2001 From: Dev Agent Date: Tue, 2 Jun 2026 22:30:42 +0300 Subject: [PATCH 5/8] fix(webhooks/plane): filter by project + resolve repo/prefix from registry ORCH-6 / incident 2026-06-02: ignore work items from unknown Plane projects (status=ignored) instead of funneling everything into default_repo. Resolve repo, work-item prefix and Plane sync project from the registry by data.project. --- src/webhooks/plane.py | 54 +++++++++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index 07ee6c5..177ce6e 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -24,6 +24,11 @@ from ..plane_sync import ( notify_qg_failure as plane_notify_qg, notify_done as plane_notify_done, ) +from ..projects import ( + get_project_by_plane_id, + get_project_by_repo, + known_plane_project_ids, +) logger = logging.getLogger("orchestrator.webhooks.plane") @@ -68,15 +73,26 @@ async def plane_webhook(request: Request): action = payload.get("action", "") data = payload.get("data", {}) + # ORCH-6: filter by Plane project. Ignore issues from unknown/unconfigured + # projects so a webhook on the whole workspace cannot funnel everything into + # the default repo (root cause of the 2026-06-02 incident). + project_id = data.get("project") or data.get("project_id") or "" + if project_id not in known_plane_project_ids(): + logger.info( + f"Plane webhook: ignoring event '{event}' from unknown project " + f"'{project_id}' (known: {len(known_plane_project_ids())})" + ) + return {"status": "ignored", "reason": "unknown project"} + if (event == "work_item.created") or (event == "issue" and action == "created"): - await handle_work_item_created(data) + await handle_work_item_created(data, project_id) elif (event == "comment.created") or (event == "issue_comment" and action == "created"): - await handle_comment(data) + await handle_comment(data, project_id) return {"status": "accepted"} -async def handle_work_item_created(data: dict): +async def handle_work_item_created(data: dict, project_id: str = ""): """ New work item created in Plane. QG-0: validate title, description, priority. @@ -88,7 +104,17 @@ async def handle_work_item_created(data: dict): description = data.get("description_stripped", data.get("description", "")) priority = data.get("priority", {}) priority_name = priority if isinstance(priority, str) else priority.get("name", "") - repo = settings.default_repo + + # ORCH-6: resolve repo / prefix / Plane project from the registry instead of + # the single hardcoded default_repo. + if not project_id: + project_id = data.get("project") or data.get("project_id") or "" + proj = get_project_by_plane_id(project_id) + if not proj: + logger.warning(f"handle_work_item_created: unknown project '{project_id}', ignoring {plane_id}") + return + repo = proj.repo + plane_project_id = proj.plane_project_id # QG-0 validation errors = [] @@ -102,17 +128,17 @@ async def handle_work_item_created(data: dict): if errors: # QG-0 failed error_text = "\u26a0\ufe0f QG-0 failed:\n" + "\n".join(f"\u2022 {e}" for e in errors) - from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE, PROJECT_ID, PLANE_STATES + from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE, PLANE_STATES import httpx as _httpx - # Post comment - url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{plane_id}/comments/" + # Post comment (ORCH-6: route to the issue's own project) + url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{plane_project_id}/issues/{plane_id}/comments/" try: _httpx.post(url, headers=PLANE_HEADERS, json={"comment_html": f"

{error_text}

"}, timeout=10) except Exception: pass # Set blocked - url2 = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{plane_id}/" + url2 = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{plane_project_id}/issues/{plane_id}/" try: _httpx.patch(url2, headers=PLANE_HEADERS, json={"state": PLANE_STATES["blocked"]}, timeout=10) @@ -122,7 +148,7 @@ async def handle_work_item_created(data: dict): return # Generate work item ID - work_item_id = get_next_work_item_id(repo) + work_item_id = get_next_work_item_id(repo, proj.work_item_prefix) # Create slug from name slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:30] @@ -169,7 +195,7 @@ async def handle_work_item_created(data: dict): logger.error(f"Failed to launch analyst for {work_item_id}: {e}") -async def handle_comment(data: dict): +async def handle_comment(data: dict, project_id: str = ""): """ Handle comment event — check for :approved: or :rejected:. Advance or rollback stage accordingly. @@ -237,11 +263,15 @@ async def handle_comment(data: dict): if not issue_id: issue_id = plane_id if issue_id: - from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE, PROJECT_ID + from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE + from ..plane_sync import PROJECT_ID as _DEFAULT_PROJECT_ID + # ORCH-6: route to this task's own Plane project (resolved from repo). + _proj = get_project_by_repo(repo) + _pid = _proj.plane_project_id if _proj else (project_id or _DEFAULT_PROJECT_ID) import httpx as _httpx try: _resp = _httpx.get( - f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/", + f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{_pid}/issues/{issue_id}/", headers=PLANE_HEADERS, timeout=10 ) if _resp.status_code == 200: From a6f6a43c1c09e7249a6f04f7447c08dee4a95dd6 Mon Sep 17 00:00:00 2001 From: Dev Agent Date: Tue, 2 Jun 2026 22:30:42 +0300 Subject: [PATCH 6/8] fix(webhooks/gitea): ignore pushes/events for repos outside the registry ORCH-6: get_project_by_repo None -> ignored, so events for unknown repos do not trigger the pipeline. --- src/webhooks/gitea.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/webhooks/gitea.py b/src/webhooks/gitea.py index 78e19d1..33c318c 100644 --- a/src/webhooks/gitea.py +++ b/src/webhooks/gitea.py @@ -16,6 +16,7 @@ from ..qg.checks import check_ci_green, check_review_approved from ..notifications import notify_stage_change, notify_qg_failure, notify_error from ..agents.launcher import launcher from ..plane_sync import notify_stage_change as plane_notify_stage +from ..projects import get_project_by_repo logger = logging.getLogger("orchestrator.webhooks.gitea") @@ -84,6 +85,11 @@ async def handle_push(payload: dict): repo_name = payload.get("repository", {}).get("name", settings.default_repo) + # ORCH-6: ignore pushes to repos outside the project registry. + if not get_project_by_repo(repo_name): + logger.info(f"Gitea push: ignoring unknown repo '{repo_name}'") + return + task = get_task_by_repo_branch(repo_name, branch) if not task: logger.debug(f"Push to '{branch}' — no matching task found") @@ -167,6 +173,12 @@ async def handle_ci_status(payload: dict): return repo_name = payload.get("repository", {}).get("name", settings.default_repo) + + # ORCH-6: ignore CI status for repos outside the project registry. + if not get_project_by_repo(repo_name): + logger.info(f"Gitea CI status: ignoring unknown repo '{repo_name}'") + return + task = get_task_by_repo_branch(repo_name, branch) if not task: return @@ -221,6 +233,11 @@ async def handle_pr(payload: dict): if not head_branch: return + # ORCH-6: ignore PR events for repos outside the project registry. + if not get_project_by_repo(repo_name): + logger.info(f"Gitea PR: ignoring unknown repo '{repo_name}'") + return + task = get_task_by_repo_branch(repo_name, head_branch) if not task: logger.debug(f"PR event for branch '{head_branch}' — no matching task") From c1f35a2047b7a27bd624b3060ad6de0d932ace88 Mon Sep 17 00:00:00 2001 From: Dev Agent Date: Tue, 2 Jun 2026 22:30:51 +0300 Subject: [PATCH 7/8] test(projects,webhook): cover registry resolvers + project filter ORCH-6: test_projects.py covers resolvers and ORCH_PROJECTS_JSON parsing (valid/malformed/fallback). test_plane_webhook.py covers the webhook project filter via TestClient (unknown->ignored, orchestrator->orchestrator repo, enduro->enduro-trails, independent ORCH/ET prefixes); launcher mocked. test_webhooks.py: register proj-1 so existing ET fixtures pass. --- tests/test_plane_webhook.py | 180 ++++++++++++++++++++++++++++++++++++ tests/test_projects.py | 177 +++++++++++++++++++++++++++++++++++ tests/test_webhooks.py | 6 ++ 3 files changed, 363 insertions(+) create mode 100644 tests/test_plane_webhook.py create mode 100644 tests/test_projects.py diff --git a/tests/test_plane_webhook.py b/tests/test_plane_webhook.py new file mode 100644 index 0000000..c213376 --- /dev/null +++ b/tests/test_plane_webhook.py @@ -0,0 +1,180 @@ +"""ORCH-6: Plane webhook project-filter + repo-resolution tests. + +Verifies the core of the 2026-06-02 incident fix: + * webhook from an UNKNOWN Plane project -> {"status": "ignored"} and no task + * webhook from the orchestrator project -> task created with repo=orchestrator + * webhook from the enduro project -> task created with repo=enduro-trails + +launcher.launch is mocked so no real agents are spawned. Gitea branch/doc +creation is mocked (network). FastAPI TestClient drives the real endpoint. + +This module configures its own registry via monkeypatch + reload_projects so it +is independent of ORCH_PROJECTS_JSON set by other test modules. +""" + +import os +import tempfile + +import pytest + +# Test DB / disable signature checks (same convention as test_webhooks.py). +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_plane.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ.setdefault("ORCH_PLANE_WEBHOOK_SECRET", "") +os.environ.setdefault("ORCH_GITEA_WEBHOOK_SECRET", "") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +from unittest.mock import patch, AsyncMock # noqa: E402 + +from fastapi.testclient import TestClient # noqa: E402 + +from src.main import app # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import projects as P # noqa: E402 +from src.projects import reload_projects # noqa: E402 + +ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a" +ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" +UNKNOWN_PLANE_ID = "deadbeef-0000-0000-0000-000000000000" + +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def setup(monkeypatch): + """Fresh DB + a known two-project registry for each test.""" + # settings.db_path is resolved once at import; force it to our isolated DB so + # this suite is independent of whichever test module imported config first. + monkeypatch.setattr(P.settings, "db_path", _test_db) + import src.db as _db + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + + # The webhook signature secret may be baked into the runtime env; this suite + # focuses on the project filter, so bypass signature verification. + monkeypatch.setattr("src.webhooks.plane.verify_plane_signature", lambda body, sig: True) + + registry_json = ( + f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",' + f' "work_item_prefix": "ET", "name": "enduro-trails"}},' + f' {{"plane_project_id": "{ORCH_PLANE_ID}", "repo": "orchestrator",' + f' "work_item_prefix": "ORCH", "name": "orchestrator"}}]' + ) + monkeypatch.setattr(P.settings, "projects_json", registry_json) + reload_projects() + + yield + + reload_projects() # restore from env + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _post_created(plane_project_id, plane_id="wi-1", name="A valid work item title"): + return client.post( + "/webhook/plane", + json={ + "event": "work_item.created", + "data": { + "id": plane_id, + "name": name, + "description_stripped": "This is a sufficiently long description.", + "project": plane_project_id, + }, + }, + ) + + +# --------------------------------------------------------------------------- +# Filter: unknown project is ignored, no side effects +# --------------------------------------------------------------------------- + +@patch("src.webhooks.plane.launcher") +@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) +@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) +def test_unknown_project_ignored(mock_branch, mock_docs, mock_launcher): + resp = _post_created(UNKNOWN_PLANE_ID, plane_id="ignore-me") + assert resp.status_code == 200 + assert resp.json()["status"] == "ignored" + assert resp.json().get("reason") == "unknown project" + + # No task, no branch, no agent. + conn = get_db() + task = conn.execute("SELECT * FROM tasks WHERE plane_id='ignore-me'").fetchone() + conn.close() + assert task is None + mock_branch.assert_not_called() + mock_launcher.launch.assert_not_called() + + +# --------------------------------------------------------------------------- +# orchestrator project -> repo=orchestrator, prefix ORCH +# --------------------------------------------------------------------------- + +@patch("src.webhooks.plane.launcher") +@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) +@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) +def test_orchestrator_project_routes_to_orchestrator_repo(mock_branch, mock_docs, mock_launcher): + mock_launcher.launch.return_value = 1 + resp = _post_created(ORCH_PLANE_ID, plane_id="orch-1") + assert resp.status_code == 200 + assert resp.json()["status"] == "accepted" + + conn = get_db() + task = conn.execute("SELECT * FROM tasks WHERE plane_id='orch-1'").fetchone() + conn.close() + assert task is not None + assert task["repo"] == "orchestrator" + assert task["work_item_id"].startswith("ORCH-") + assert task["stage"] == "analysis" + # Branch created against the orchestrator repo. + args = mock_branch.call_args.args + assert args[0] == "orchestrator" + + +# --------------------------------------------------------------------------- +# enduro project -> repo=enduro-trails, prefix ET +# --------------------------------------------------------------------------- + +@patch("src.webhooks.plane.launcher") +@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) +@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) +def test_enduro_project_routes_to_enduro_repo(mock_branch, mock_docs, mock_launcher): + mock_launcher.launch.return_value = 1 + resp = _post_created(ENDURO_PLANE_ID, plane_id="et-1") + assert resp.status_code == 200 + assert resp.json()["status"] == "accepted" + + conn = get_db() + task = conn.execute("SELECT * FROM tasks WHERE plane_id='et-1'").fetchone() + conn.close() + assert task is not None + assert task["repo"] == "enduro-trails" + assert task["work_item_id"].startswith("ET-") + args = mock_branch.call_args.args + assert args[0] == "enduro-trails" + + +# --------------------------------------------------------------------------- +# prefixes are independent per repo (ORCH-001 vs ET-001 in parallel) +# --------------------------------------------------------------------------- + +@patch("src.webhooks.plane.launcher") +@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) +@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) +def test_prefixes_independent_per_project(mock_branch, mock_docs, mock_launcher): + mock_launcher.launch.return_value = 1 + _post_created(ORCH_PLANE_ID, plane_id="o1", name="Orchestrator item one") + _post_created(ENDURO_PLANE_ID, plane_id="e1", name="Enduro item one") + _post_created(ORCH_PLANE_ID, plane_id="o2", name="Orchestrator item two") + + conn = get_db() + rows = {r["plane_id"]: r["work_item_id"] for r in + conn.execute("SELECT plane_id, work_item_id FROM tasks").fetchall()} + conn.close() + assert rows["o1"] == "ORCH-001" + assert rows["o2"] == "ORCH-002" + assert rows["e1"] == "ET-001" diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..4b6b6ce --- /dev/null +++ b/tests/test_projects.py @@ -0,0 +1,177 @@ +"""ORCH-6: tests for the project registry (src/projects.py). + +Covers resolvers (by plane_id, by repo, unknown -> None, known ids) against the +built-in default registry, plus ORCH_PROJECTS_JSON parsing (valid + malformed +-> default fallback). + +The pure parser ``_parse_projects_json`` is tested directly so we don't mutate +the module-global registry. Resolver tests run against the default registry; if +another test (e.g. test_webhooks) set ORCH_PROJECTS_JSON in the env, we restore +the default via monkeypatch + reload_projects to keep this file order-independent. +""" + +import pytest + +from src import projects as P +from src.projects import ( + ProjectConfig, + get_project_by_plane_id, + get_project_by_repo, + known_plane_project_ids, + reload_projects, + _parse_projects_json, + _DEFAULT_PROJECTS, +) + +# Known ids from the default registry / task spec. +ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" +ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a" + + +@pytest.fixture +def default_registry(monkeypatch): + """Force the default (built-in) registry regardless of ORCH_PROJECTS_JSON + that other test modules may have set in the process env.""" + monkeypatch.setattr(P.settings, "projects_json", "") + reload_projects() + yield + # Restore from current settings (whatever env says) after the test. + reload_projects() + + +# --------------------------------------------------------------------------- +# Resolvers +# --------------------------------------------------------------------------- + +def test_get_project_by_plane_id_orchestrator(default_registry): + proj = get_project_by_plane_id(ORCH_PLANE_ID) + assert proj is not None + assert proj.repo == "orchestrator" + assert proj.work_item_prefix == "ORCH" + assert proj.plane_project_id == ORCH_PLANE_ID + + +def test_get_project_by_plane_id_enduro(default_registry): + proj = get_project_by_plane_id(ENDURO_PLANE_ID) + assert proj is not None + assert proj.repo == "enduro-trails" + assert proj.work_item_prefix == "ET" + + +def test_get_project_by_plane_id_unknown_returns_none(default_registry): + assert get_project_by_plane_id("00000000-0000-0000-0000-000000000000") is None + + +def test_get_project_by_plane_id_empty_returns_none(default_registry): + assert get_project_by_plane_id("") is None + assert get_project_by_plane_id(None) is None + + +def test_get_project_by_repo(default_registry): + assert get_project_by_repo("enduro-trails").work_item_prefix == "ET" + assert get_project_by_repo("orchestrator").work_item_prefix == "ORCH" + + +def test_get_project_by_repo_unknown_returns_none(default_registry): + assert get_project_by_repo("does-not-exist") is None + assert get_project_by_repo("") is None + assert get_project_by_repo(None) is None + + +def test_known_plane_project_ids(default_registry): + ids = known_plane_project_ids() + assert isinstance(ids, set) + assert ENDURO_PLANE_ID in ids + assert ORCH_PLANE_ID in ids + assert len(ids) == len(_DEFAULT_PROJECTS) + + +# --------------------------------------------------------------------------- +# ORCH_PROJECTS_JSON parsing (pure function, no global mutation) +# --------------------------------------------------------------------------- + +def test_parse_empty_returns_none(): + assert _parse_projects_json("") is None + assert _parse_projects_json(" ") is None + assert _parse_projects_json(None) is None + + +def test_parse_valid_json(): + raw = ( + '[{"plane_project_id": "p-1", "repo": "repo-a", ' + '"work_item_prefix": "AAA", "name": "Alpha"}]' + ) + parsed = _parse_projects_json(raw) + assert parsed is not None + assert len(parsed) == 1 + assert isinstance(parsed[0], ProjectConfig) + assert parsed[0].plane_project_id == "p-1" + assert parsed[0].repo == "repo-a" + assert parsed[0].work_item_prefix == "AAA" + assert parsed[0].name == "Alpha" + + +def test_parse_valid_json_multiple(): + raw = ( + '[{"plane_project_id": "p-1", "repo": "repo-a", "work_item_prefix": "A"},' + ' {"plane_project_id": "p-2", "repo": "repo-b", "work_item_prefix": "B"}]' + ) + parsed = _parse_projects_json(raw) + assert len(parsed) == 2 + # name defaults to repo when omitted + assert parsed[0].name == "repo-a" + assert parsed[1].repo == "repo-b" + + +def test_parse_malformed_json_returns_none(): + assert _parse_projects_json("{not valid json") is None + assert _parse_projects_json("[}") is None + + +def test_parse_not_an_array_returns_none(): + # A JSON object (not array) is invalid -> fallback. + assert _parse_projects_json('{"plane_project_id": "p-1"}') is None + + +def test_parse_skips_bad_entries_keeps_good(): + raw = ( + '[{"repo": "missing-id"},' # missing required key -> skipped + ' {"plane_project_id": "p-2", "repo": "repo-b", "work_item_prefix": "B"}]' + ) + parsed = _parse_projects_json(raw) + assert parsed is not None + assert len(parsed) == 1 + assert parsed[0].plane_project_id == "p-2" + + +def test_parse_all_bad_entries_returns_none(): + # No valid entries -> None (fallback to default). + assert _parse_projects_json('[{"repo": "no-id"}, "not-an-object"]') is None + + +def test_reload_from_custom_json(monkeypatch): + """End-to-end: set settings.projects_json, reload, resolvers reflect it.""" + custom = ( + '[{"plane_project_id": "custom-uuid", "repo": "custom-repo", ' + '"work_item_prefix": "CUS", "name": "Custom"}]' + ) + monkeypatch.setattr(P.settings, "projects_json", custom) + reload_projects() + try: + assert get_project_by_plane_id("custom-uuid").repo == "custom-repo" + assert get_project_by_repo("custom-repo").work_item_prefix == "CUS" + assert known_plane_project_ids() == {"custom-uuid"} + # The built-in defaults must NOT be present when JSON overrides. + assert get_project_by_plane_id(ENDURO_PLANE_ID) is None + finally: + reload_projects() + + +def test_reload_invalid_json_falls_back_to_default(monkeypatch): + monkeypatch.setattr(P.settings, "projects_json", "{garbage") + reload_projects() + try: + assert get_project_by_plane_id(ENDURO_PLANE_ID) is not None + assert get_project_by_plane_id(ORCH_PLANE_ID) is not None + finally: + reload_projects() diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 0c93649..074b9ae 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -14,6 +14,12 @@ os.environ["ORCH_GITEA_TOKEN"] = "test-token" os.environ["ORCH_PLANE_API_TOKEN"] = "test-token" os.environ["ORCH_GITEA_OWNER"] = "admin" os.environ["ORCH_DEFAULT_REPO"] = "enduro-trails" +# ORCH-6: register the test project so the project filter lets these fixtures +# through. proj-1 maps to enduro-trails/ET, preserving the ET-001/ET-002 asserts. +os.environ["ORCH_PROJECTS_JSON"] = ( + '[{"plane_project_id": "proj-1", "repo": "enduro-trails", ' + '"work_item_prefix": "ET", "name": "enduro-trails"}]' +) from fastapi.testclient import TestClient from src.main import app From ca81f383302d300503355b7900f828e7a16d32e6 Mon Sep 17 00:00:00 2001 From: Dev Agent Date: Tue, 2 Jun 2026 22:30:51 +0300 Subject: [PATCH 8/8] docs: document multi-repo registry + ORCH-6 bugfix and incident ORCH-6: ARCHITECTURE.md gets a project-registry section; README explains how to add a project via ORCH_PROJECTS_JSON; BUGFIXES_2026-06-03.md records the fix and links the 2026-06-02 webhook autorun incident. --- README.md | 41 +++++++++- docs/ARCHITECTURE.md | 32 +++++++- docs/BUGFIXES_2026-06-03.md | 82 ++++++++++++++++++++ docs/INCIDENT_2026-06-02_webhook_autorun.txt | 7 ++ 4 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 docs/BUGFIXES_2026-06-03.md create mode 100644 docs/INCIDENT_2026-06-02_webhook_autorun.txt diff --git a/README.md b/README.md index fd3a97e..c516245 100644 --- a/README.md +++ b/README.md @@ -101,12 +101,51 @@ uvicorn src.main:app --reload --port 8500 | `ORCH_GITEA_TOKEN` | Gitea API token | — | | `ORCH_GITEA_WEBHOOK_SECRET` | Gitea webhook secret | — | | `ORCH_GITEA_OWNER` | Gitea repo owner | `admin` | -| `ORCH_DEFAULT_REPO` | Default repository | `enduro-trails` | +| `ORCH_DEFAULT_REPO` | Default repository (fallback) | `enduro-trails` | +| `ORCH_PROJECTS_JSON` | Multi-repo реестр (JSON-массив, ORCH-6) | `""` → дефолт в `src/projects.py` | | `ORCH_CLAUDE_BIN` | Путь к Claude CLI | `/opt/claude-code/bin/claude.exe` | | `ORCH_REPOS_DIR` | Repos dir (container) | `/repos` | | `ORCH_HOST_REPOS_DIR` | Repos dir (host) | `/home/slin/repos` | | `ORCH_DB_PATH` | SQLite path | `/app/data/orchestrator.db` | +## Multi-repo: реестр проектов (ORCH-6) + +Оркестратор обслуживает несколько репозиториев через реестр проектов +(`src/projects.py`), ключ = **Plane project id**. Plane-webhook фильтрует события +по проекту (неизвестный проект → `ignored`) и резолвит `repo` / `work_item_prefix` / +Plane-проект из маппинга. + +По умолчанию (если `ORCH_PROJECTS_JSON` пуст) зарегистрированы два проекта: + +| Проект | Plane project id | repo | prefix | +|--------|------------------|------|--------| +| enduro-trails | `7a79f0a9-5278-49cd-9007-9a338f238f9c` | `enduro-trails` | `ET` | +| orchestrator | `8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a` | `orchestrator` | `ORCH` | + +### Как добавить новый проект + +1. Убедись, что gitea-репо уже клонировано в `/repos/` (авто-clone — отдельно). +2. Узнай Plane project uuid (из URL проекта в Plane или через Plane API). +3. Добавь запись в `ORCH_PROJECTS_JSON` в `.env` (JSON-массив). **Важно:** если + задаёшь `ORCH_PROJECTS_JSON`, он полностью заменяет дефолт — перечисли **все** + нужные проекты (включая enduro-trails и orchestrator): + + ```bash + ORCH_PROJECTS_JSON='[ + {"plane_project_id":"7a79f0a9-5278-49cd-9007-9a338f238f9c","repo":"enduro-trails","work_item_prefix":"ET","name":"enduro-trails"}, + {"plane_project_id":"8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a","repo":"orchestrator","work_item_prefix":"ORCH","name":"orchestrator"}, + {"plane_project_id":"<новый-uuid>","repo":"<новый-repo>","work_item_prefix":"","name":"<имя>"} + ]' + ``` + +4. Пересобери: `docker compose up -d --build`. +5. Проверь резолв: + ```bash + docker exec orchestrator python3 -c "from src.projects import get_project_by_plane_id as g; print(g('<новый-uuid>'))" + ``` + +Поля `name` опционально (по умолчанию = `repo`). Подробности — `docs/ARCHITECTURE.md`. + ## Ключевые механизмы ### Auto-advance diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7aec12f..73593b9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -9,9 +9,39 @@ Orchestrator — event-driven FastAPI сервис, который управл ### 1. Webhook Receivers #### Plane Webhook (`src/webhooks/plane.py`) -- Принимает `work_item.created` — создаёт задачу в DB, запускает analyst +- **Фильтр по проекту (ORCH-6):** извлекает `data.project` (Plane project uuid) и игнорирует событие, если проект не в реестре (`known_plane_project_ids()`) → ответ `{"status":"ignored","reason":"unknown project"}`. Это предотвращает инцидент 2026-06-02 (webhook на весь workspace без фильтра). +- Принимает `work_item.created` — резолвит repo/prefix/Plane-проект из реестра по `project`, создаёт задачу в DB, запускает analyst - Принимает `work_item.updated` — синхронизация статусов +#### Реестр проектов (`src/projects.py`, multi-repo, ORCH-6) +Маппинг **Plane project id → (repo, work_item_prefix, name)**. Позволяет одному +оркестратору обслуживать несколько репозиториев, не путая их. + +```python +@dataclass(frozen=True) +class ProjectConfig: + plane_project_id: str # uuid Plane-проекта (ключ реестра) + repo: str # имя gitea-репо (= папка в /repos) + work_item_prefix: str # ET / ORCH + name: str # человекочитаемое +``` + +Резолверы: +- `get_project_by_plane_id(uuid) -> ProjectConfig | None` — для фильтра/резолва в plane-webhook. +- `get_project_by_repo(repo) -> ProjectConfig | None` — когда известен только repo (gitea-webhook, plane_sync). +- `known_plane_project_ids() -> set[str]` — множество разрешённых проектов (фильтр). + +**Источник конфигурации:** env `ORCH_PROJECTS_JSON` (JSON-массив `ProjectConfig`). +Если пусто/битый JSON — используется встроенный дефолт-реестр (enduro-trails + orchestrator), +чтобы система работала из коробки. Парсинг устойчив: битые записи пропускаются, +полностью невалидный JSON → fallback на дефолт. + +Следствия multi-repo: +- **repo per project:** `repo = get_project_by_plane_id(project_id).repo` вместо хардкода `default_repo`. +- **prefix per project:** `get_next_work_item_id(repo, prefix)` нумерует независимо — `ORCH-001` vs `ET-010` (`src/db.py`). +- **plane_sync в правильный проект:** state/comment пишутся в Plane-проект самой задачи (резолв по repo через `get_project_by_repo`), а не в единственный хардкоженный `PROJECT_ID` (обратная совместимость сохранена дефолтом на enduro). +- **gitea-webhook:** push в repo вне реестра → `ignored` (не триггерит конвейер). + #### Gitea Webhook (`src/webhooks/gitea.py`) - **push** — проверяет наличие артефактов (docs/, src/), продвигает стадию - **pull_request\*** (wildcard) — обрабатывает review approved/rejected, PR merge diff --git a/docs/BUGFIXES_2026-06-03.md b/docs/BUGFIXES_2026-06-03.md new file mode 100644 index 0000000..87d227d --- /dev/null +++ b/docs/BUGFIXES_2026-06-03.md @@ -0,0 +1,82 @@ +# BUGFIXES / CHANGES — 2026-06-03 + +## ORCH-6 — Multi-repo: фильтр проекта + маппинг repo per project + +**Тип:** root-fix инцидента + новая возможность (multi-repo) +**Ветка:** `feature/ORCH-6-multirepo` +**Plane:** ORCH-6 (project `8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a`) +**Связанный инцидент:** [`INCIDENT_2026-06-02_webhook_autorun.txt`](./INCIDENT_2026-06-02_webhook_autorun.txt) + +### Контекст инцидента + +При создании задач ORCH-1..7 в Plane (проект `orchestrator`) Plane-webhook +(id `93f0c342-a614-4248-9d0f-c107276f5620`) сработал на каждую задачу и запустил +конвейер — но **всё ушло в репо `enduro-trails`**, потому что `plane.py:91` +хардкодил `repo = settings.default_repo`. Webhook слушал **весь workspace без +фильтра по проекту**, наплодив мусорные ET-010..016. + +Митигация на время фикса: Plane-webhook **деактивирован** (`is_active=false`). + +### Root cause + +1. Нет фильтра по Plane-проекту — любая issue из любого проекта попадала в конвейер. +2. `repo` хардкожен на единственный `default_repo` (enduro-trails). +3. `work_item_prefix` всегда `ET` (db.py). +4. `plane_sync` ходил в единственный хардкоженный `PROJECT_ID` (enduro). + +### Что сделано + +| Файл | Изменение | +|------|-----------| +| `src/projects.py` (новый) | Реестр проектов: `ProjectConfig` + дефолт-список (enduro-trails + orchestrator) + резолверы `get_project_by_plane_id` / `get_project_by_repo` / `known_plane_project_ids`. Источник переопределения — `ORCH_PROJECTS_JSON`; устойчивый парсинг (битый JSON / битые записи → fallback на дефолт). | +| `src/config.py` | Добавлен `projects_json: str = ""` (env `ORCH_PROJECTS_JSON`). | +| `src/webhooks/plane.py` | **Фильтр по проекту**: `data.project` не в реестре → `{"status":"ignored","reason":"unknown project"}`. Резолв `repo`/`prefix`/Plane-проекта из реестра. Plane-sync для задачи идёт в её собственный проект. | +| `src/db.py` | `get_next_work_item_id(repo, prefix="ET")` — нумерация per (repo, prefix); `ORCH-001` независимо от `ET-010`. Дефолт `ET` сохранён для обратной совместимости. | +| `src/plane_sync.py` | `_resolve_project_id` + параметризация `project_id` (дефолт на enduro → обратная совместимость существующих вызовов). | +| `src/webhooks/gitea.py` | Неизвестный repo (`get_project_by_repo` → None) → `ignored` в 3 хэндлерах. | + +### Тесты + +- `tests/test_projects.py` (16 тестов): резолверы (by plane_id, by repo, unknown→None, + known_plane_project_ids), парсинг `ORCH_PROJECTS_JSON` (валидный / битый JSON / не массив / + битые записи → skip / all-bad → fallback), reload с кастомным JSON. +- `tests/test_plane_webhook.py` (4 теста, FastAPI TestClient, `launcher.launch` замокан): + unknown project → `ignored` + нет task/branch/agent; orchestrator-проект → `repo=orchestrator`, + `ORCH-*`; enduro-проект → `repo=enduro-trails`, `ET-*`; независимые префиксы (`ORCH-001`/`ORCH-002` + параллельно с `ET-001`). + +**Прогон (в контейнере, образ `orchestrator-orchestrator`):** `57 passed`. 9 падений в +`tests/test_webhooks.py` — **pre-existing** (webhook signature 401 / TypeError, не связаны с ORCH-6, +не трогались). + +```bash +IMG=$(docker inspect orchestrator --format '{{.Config.Image}}') +docker run --rm -v /home/slin/repos/orchestrator:/code -w /code --entrypoint python3 $IMG -m pytest tests/ -q +``` + +### Проверка резолва (offline, в работающем контейнере) + +```bash +docker exec orchestrator python3 -c " +from src.projects import get_project_by_plane_id, known_plane_project_ids +o = get_project_by_plane_id('8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a') +e = get_project_by_plane_id('7a79f0a9-5278-49cd-9007-9a338f238f9c') +assert o.repo=='orchestrator' and o.work_item_prefix=='ORCH' +assert e.repo=='enduro-trails' and e.work_item_prefix=='ET' +assert get_project_by_plane_id('00000000-0000-0000-0000-000000000000') is None +print('RESOLVE OK:', o.repo, e.repo, '| known:', len(known_plane_project_ids())) +" +``` + +### ⚠️ Важно + +- Plane-webhook **остаётся выключенным** (`is_active=false`). Включение — отдельный + шаг Стрим после ревью PR. +- `ORCH_PROJECTS_JSON` (если задан) **полностью заменяет** дефолт — перечислять все нужные проекты. +- Обратная совместимость `plane_sync` сохранена (дефолт project_id = enduro), ET-задачи не сломаны. + +### Re-enable webhook (после ревью, делает Стрим) + +```sql +UPDATE webhooks SET is_active=true WHERE id='93f0c342-a614-4248-9d0f-c107276f5620'; +``` diff --git a/docs/INCIDENT_2026-06-02_webhook_autorun.txt b/docs/INCIDENT_2026-06-02_webhook_autorun.txt new file mode 100644 index 0000000..659d6ab --- /dev/null +++ b/docs/INCIDENT_2026-06-02_webhook_autorun.txt @@ -0,0 +1,7 @@ +INCIDENT 2026-06-02: Plane webhook auto-triggered pipeline for ALL ORCH-1..7 tasks +- Plane webhook (id 93f0c342) fires on ANY issue creation in workspace, no project filter +- plane.py:91 hardcodes repo=settings.default_repo (enduro-trails) +- Result: ORCH-x tasks ran analyst/architect in WRONG repo (enduro-trails), created junk ET-010..016 +- MITIGATION: Plane webhook DEACTIVATED (is_active=false) until ORCH-6 adds project filter +- ROOT FIX = ORCH-6 (multi-repo): filter by plane_project_id + repo mapping per project +- To re-enable webhook after ORCH-6: UPDATE webhooks SET is_active=true WHERE id=93f0c342...