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).
This commit is contained in:
@@ -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/<repo> 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/<work_item_id>/06-adr/ (at least 1 file)
|
||||
OR: docs/work-items/<work_item_id>/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/<work_item_id>/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/<repo> 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,
|
||||
|
||||
Reference in New Issue
Block a user