hyperloop 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {hyperloop-0.2.0 → hyperloop-0.3.0}/CHANGELOG.md +3 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/PKG-INFO +1 -1
- {hyperloop-0.2.0 → hyperloop-0.3.0}/pyproject.toml +4 -1
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/adapters/local.py +32 -10
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/cli.py +17 -1
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/loop.py +8 -2
- hyperloop-0.3.0/tests/test_e2e.py +591 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_local_runtime.py +3 -3
- {hyperloop-0.2.0 → hyperloop-0.3.0}/uv.lock +1 -1
- {hyperloop-0.2.0 → hyperloop-0.3.0}/.github/workflows/ci.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/.github/workflows/release.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/.gitignore +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/.pre-commit-config.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/.python-version +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/CLAUDE.md +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/README.md +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/base/implementer.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/base/pm.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/base/process-improver.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/base/process.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/base/rebase-resolver.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/base/verifier.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/specs/spec.md +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/__init__.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/__main__.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/adapters/__init__.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/adapters/git_state.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/compose.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/config.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/domain/__init__.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/domain/decide.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/domain/deps.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/domain/model.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/domain/pipeline.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/ports/__init__.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/ports/pr.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/ports/runtime.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/ports/state.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/pr.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/__init__.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/fakes/__init__.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/fakes/pr.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/fakes/runtime.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/fakes/state.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_cli.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_compose.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_config.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_decide.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_deps.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_fakes.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_git_state.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_loop.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_model.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_pipeline.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_pr.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_smoke.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_state_contract.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hyperloop"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Orchestrator that walks tasks through composable process pipelines using AI agents"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -58,6 +58,9 @@ reportMissingTypeStubs = false
|
|
|
58
58
|
|
|
59
59
|
[tool.pytest.ini_options]
|
|
60
60
|
testpaths = ["tests"]
|
|
61
|
+
markers = [
|
|
62
|
+
"slow: end-to-end integration tests (real git repos, subprocesses)",
|
|
63
|
+
]
|
|
61
64
|
|
|
62
65
|
[tool.semantic_release]
|
|
63
66
|
commit_parser = "conventional"
|
|
@@ -56,8 +56,10 @@ class LocalRuntime:
|
|
|
56
56
|
# Ensure the parent directory exists
|
|
57
57
|
os.makedirs(self._worktree_base, exist_ok=True)
|
|
58
58
|
|
|
59
|
-
# Create the git worktree
|
|
60
|
-
|
|
59
|
+
# Create the git worktree — reuse existing branch if it survived
|
|
60
|
+
# a previous pipeline step, otherwise create a new one.
|
|
61
|
+
env = _clean_git_env()
|
|
62
|
+
result = subprocess.run(
|
|
61
63
|
[
|
|
62
64
|
"git",
|
|
63
65
|
"-C",
|
|
@@ -65,14 +67,29 @@ class LocalRuntime:
|
|
|
65
67
|
"worktree",
|
|
66
68
|
"add",
|
|
67
69
|
worktree_path,
|
|
68
|
-
"-b",
|
|
69
70
|
branch,
|
|
70
|
-
"HEAD",
|
|
71
71
|
],
|
|
72
|
-
check=True,
|
|
73
72
|
capture_output=True,
|
|
74
|
-
env=
|
|
73
|
+
env=env,
|
|
75
74
|
)
|
|
75
|
+
if result.returncode != 0:
|
|
76
|
+
# Branch doesn't exist yet — create it
|
|
77
|
+
subprocess.run(
|
|
78
|
+
[
|
|
79
|
+
"git",
|
|
80
|
+
"-C",
|
|
81
|
+
self._repo_path,
|
|
82
|
+
"worktree",
|
|
83
|
+
"add",
|
|
84
|
+
worktree_path,
|
|
85
|
+
"-b",
|
|
86
|
+
branch,
|
|
87
|
+
"HEAD",
|
|
88
|
+
],
|
|
89
|
+
check=True,
|
|
90
|
+
capture_output=True,
|
|
91
|
+
env=env,
|
|
92
|
+
)
|
|
76
93
|
|
|
77
94
|
# Write the prompt file
|
|
78
95
|
prompt_path = os.path.join(worktree_path, "prompt.md")
|
|
@@ -120,7 +137,10 @@ class LocalRuntime:
|
|
|
120
137
|
return "failed"
|
|
121
138
|
|
|
122
139
|
def reap(self, handle: WorkerHandle) -> WorkerResult:
|
|
123
|
-
"""Read the result file, clean up worktree
|
|
140
|
+
"""Read the result file, clean up worktree, return WorkerResult.
|
|
141
|
+
|
|
142
|
+
Preserves the branch so later pipeline steps (e.g. merge-pr) can use it.
|
|
143
|
+
"""
|
|
124
144
|
task_id = handle.task_id
|
|
125
145
|
worktree_path = self._worktrees.get(task_id)
|
|
126
146
|
|
|
@@ -152,6 +172,7 @@ class LocalRuntime:
|
|
|
152
172
|
|
|
153
173
|
branch = self._get_worktree_branch(worktree_path)
|
|
154
174
|
self._cleanup_worktree(task_id, worktree_path, branch)
|
|
175
|
+
self._delete_branch(branch)
|
|
155
176
|
|
|
156
177
|
def find_orphan(self, task_id: str, branch: str) -> WorkerHandle | None:
|
|
157
178
|
"""Check if a worktree exists for the given branch. Return a handle if so."""
|
|
@@ -229,7 +250,7 @@ class LocalRuntime:
|
|
|
229
250
|
return None
|
|
230
251
|
|
|
231
252
|
def _cleanup_worktree(self, task_id: str, worktree_path: str, branch: str | None) -> None:
|
|
232
|
-
"""Remove the worktree directory
|
|
253
|
+
"""Remove the worktree directory. Preserves the branch for later pipeline steps."""
|
|
233
254
|
# Remove from internal tracking
|
|
234
255
|
self._processes.pop(task_id, None)
|
|
235
256
|
self._worktrees.pop(task_id, None)
|
|
@@ -264,10 +285,11 @@ class LocalRuntime:
|
|
|
264
285
|
env=env,
|
|
265
286
|
)
|
|
266
287
|
|
|
267
|
-
|
|
288
|
+
def _delete_branch(self, branch: str | None) -> None:
|
|
289
|
+
"""Delete a branch from the repo (best-effort, used by cancel)."""
|
|
268
290
|
if branch:
|
|
269
291
|
subprocess.run(
|
|
270
292
|
["git", "-C", self._repo_path, "branch", "-D", branch],
|
|
271
293
|
capture_output=True,
|
|
272
|
-
env=
|
|
294
|
+
env=_clean_git_env(),
|
|
273
295
|
)
|
|
@@ -7,6 +7,7 @@ the orchestrator, and runs the loop with rich status output.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import sys
|
|
10
|
+
from importlib.metadata import version as pkg_version
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
|
|
12
13
|
import typer
|
|
@@ -23,8 +24,23 @@ app = typer.Typer(
|
|
|
23
24
|
console = Console()
|
|
24
25
|
|
|
25
26
|
|
|
27
|
+
def _version_callback(value: bool) -> None:
|
|
28
|
+
if value:
|
|
29
|
+
console.print(f"hyperloop {pkg_version('hyperloop')}")
|
|
30
|
+
raise typer.Exit()
|
|
31
|
+
|
|
32
|
+
|
|
26
33
|
@app.callback()
|
|
27
|
-
def main(
|
|
34
|
+
def main(
|
|
35
|
+
version: bool = typer.Option(
|
|
36
|
+
False,
|
|
37
|
+
"--version",
|
|
38
|
+
"-V",
|
|
39
|
+
help="Show version and exit.",
|
|
40
|
+
callback=_version_callback,
|
|
41
|
+
is_eager=True,
|
|
42
|
+
),
|
|
43
|
+
) -> None:
|
|
28
44
|
"""AI agent orchestrator for composable process pipelines."""
|
|
29
45
|
|
|
30
46
|
|
|
@@ -71,6 +71,7 @@ class Orchestrator:
|
|
|
71
71
|
max_rounds: int = 50,
|
|
72
72
|
pr_manager: PRPort | None = None,
|
|
73
73
|
composer: PromptComposer | None = None,
|
|
74
|
+
repo_path: str | None = None,
|
|
74
75
|
) -> None:
|
|
75
76
|
self._state = state
|
|
76
77
|
self._runtime = runtime
|
|
@@ -79,6 +80,7 @@ class Orchestrator:
|
|
|
79
80
|
self._max_rounds = max_rounds
|
|
80
81
|
self._pr_manager = pr_manager
|
|
81
82
|
self._composer = composer
|
|
83
|
+
self._repo_path = repo_path
|
|
82
84
|
|
|
83
85
|
# Active worker tracking: task_id -> (handle, pipeline_position)
|
|
84
86
|
self._workers: dict[str, tuple[WorkerHandle, PipelinePosition]] = {}
|
|
@@ -382,9 +384,13 @@ class Orchestrator:
|
|
|
382
384
|
"""Merge worker branch into base branch locally (no PR)."""
|
|
383
385
|
import subprocess
|
|
384
386
|
|
|
387
|
+
git_cmd = ["git"]
|
|
388
|
+
if self._repo_path is not None:
|
|
389
|
+
git_cmd = ["git", "-C", self._repo_path]
|
|
390
|
+
|
|
385
391
|
try:
|
|
386
392
|
subprocess.run(
|
|
387
|
-
[
|
|
393
|
+
[*git_cmd, "merge", branch, "--no-edit", "-m", f"merge: {task_id}"],
|
|
388
394
|
check=True,
|
|
389
395
|
capture_output=True,
|
|
390
396
|
)
|
|
@@ -393,7 +399,7 @@ class Orchestrator:
|
|
|
393
399
|
logger.info("Local merge of %s into base branch", task_id)
|
|
394
400
|
except subprocess.CalledProcessError:
|
|
395
401
|
logger.warning("Local merge conflict for task %s, marking NEEDS_REBASE", task_id)
|
|
396
|
-
subprocess.run([
|
|
402
|
+
subprocess.run([*git_cmd, "merge", "--abort"], capture_output=True, check=False)
|
|
397
403
|
self._state.transition_task(task_id, TaskStatus.NEEDS_REBASE, phase=Phase("merge-pr"))
|
|
398
404
|
|
|
399
405
|
# -----------------------------------------------------------------------
|
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
"""End-to-end integration tests for the hyperloop orchestrator.
|
|
2
|
+
|
|
3
|
+
Wires real GitStateStore + LocalRuntime + Orchestrator against a real git repo
|
|
4
|
+
with a deterministic fake "agent" shell script. No mocks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
from textwrap import dedent
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from hyperloop.adapters.git_state import GitStateStore
|
|
17
|
+
from hyperloop.adapters.local import LocalRuntime
|
|
18
|
+
from hyperloop.domain.model import (
|
|
19
|
+
ActionStep,
|
|
20
|
+
GateStep,
|
|
21
|
+
LoopStep,
|
|
22
|
+
Process,
|
|
23
|
+
RoleStep,
|
|
24
|
+
TaskStatus,
|
|
25
|
+
)
|
|
26
|
+
from hyperloop.loop import Orchestrator
|
|
27
|
+
from tests.fakes.pr import FakePRManager
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Helpers
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _clean_git_env() -> dict[str, str]:
|
|
38
|
+
"""Strip GIT_* env vars so tests work inside pre-commit hooks."""
|
|
39
|
+
env = os.environ.copy()
|
|
40
|
+
for key in list(env):
|
|
41
|
+
if key.startswith("GIT_"):
|
|
42
|
+
del env[key]
|
|
43
|
+
return env
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _git(repo: Path, *args: str) -> str:
|
|
47
|
+
"""Run a git command in `repo` and return stdout."""
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
["git", "-C", str(repo), *args],
|
|
50
|
+
check=True,
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
env=_clean_git_env(),
|
|
54
|
+
)
|
|
55
|
+
return result.stdout.strip()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _init_repo(path: Path) -> None:
|
|
59
|
+
"""Create a git repo with user config and an initial commit."""
|
|
60
|
+
env = _clean_git_env()
|
|
61
|
+
subprocess.run(["git", "init", str(path)], check=True, capture_output=True, env=env)
|
|
62
|
+
subprocess.run(
|
|
63
|
+
["git", "-C", str(path), "config", "user.email", "test@test.com"],
|
|
64
|
+
check=True,
|
|
65
|
+
capture_output=True,
|
|
66
|
+
env=env,
|
|
67
|
+
)
|
|
68
|
+
subprocess.run(
|
|
69
|
+
["git", "-C", str(path), "config", "user.name", "Test"],
|
|
70
|
+
check=True,
|
|
71
|
+
capture_output=True,
|
|
72
|
+
env=env,
|
|
73
|
+
)
|
|
74
|
+
subprocess.run(
|
|
75
|
+
["git", "-C", str(path), "commit", "--allow-empty", "-m", "init"],
|
|
76
|
+
check=True,
|
|
77
|
+
capture_output=True,
|
|
78
|
+
env=env,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _write_task_file(repo: Path, task_id: str, content: str) -> None:
|
|
83
|
+
"""Write a task file into the repo's specs/tasks directory."""
|
|
84
|
+
tasks_dir = repo / "specs" / "tasks"
|
|
85
|
+
tasks_dir.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
(tasks_dir / f"{task_id}.md").write_text(content)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _write_spec_file(repo: Path, name: str, content: str) -> None:
|
|
90
|
+
"""Write a spec file into the repo's specs directory."""
|
|
91
|
+
specs_dir = repo / "specs"
|
|
92
|
+
specs_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
(specs_dir / name).write_text(content)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _commit_all(repo: Path, message: str) -> None:
|
|
97
|
+
"""Stage and commit all changes in the repo."""
|
|
98
|
+
_git(repo, "add", "-A")
|
|
99
|
+
_git(repo, "commit", "-m", message)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _make_agent_script(tmp_path: Path, name: str, body: str) -> str:
|
|
103
|
+
"""Create an executable shell script and return its absolute path."""
|
|
104
|
+
script_path = tmp_path / name
|
|
105
|
+
script_path.write_text(body)
|
|
106
|
+
script_path.chmod(0o755)
|
|
107
|
+
return str(script_path)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# Pipeline used by basic e2e tests: LoopStep(implementer, verifier).
|
|
111
|
+
# No merge-pr step — keeps tests focused on the orchestrator loop logic.
|
|
112
|
+
E2E_PIPELINE = Process(
|
|
113
|
+
name="e2e",
|
|
114
|
+
intake=(),
|
|
115
|
+
pipeline=(
|
|
116
|
+
LoopStep(
|
|
117
|
+
steps=(
|
|
118
|
+
RoleStep(role="implementer", on_pass=None, on_fail=None),
|
|
119
|
+
RoleStep(role="verifier", on_pass=None, on_fail=None),
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
TASK_CONTENT = dedent("""\
|
|
127
|
+
---
|
|
128
|
+
id: {task_id}
|
|
129
|
+
title: Implement example feature
|
|
130
|
+
spec_ref: specs/example.md
|
|
131
|
+
status: not-started
|
|
132
|
+
phase: null
|
|
133
|
+
deps: []
|
|
134
|
+
round: 0
|
|
135
|
+
branch: null
|
|
136
|
+
pr: null
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Spec
|
|
140
|
+
Build the example feature.
|
|
141
|
+
|
|
142
|
+
## Findings
|
|
143
|
+
""")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Pipeline with merge-pr step: LoopStep(implementer, verifier), then merge-pr.
|
|
147
|
+
E2E_MERGE_PIPELINE = Process(
|
|
148
|
+
name="e2e-merge",
|
|
149
|
+
intake=(),
|
|
150
|
+
pipeline=(
|
|
151
|
+
LoopStep(
|
|
152
|
+
steps=(
|
|
153
|
+
RoleStep(role="implementer", on_pass=None, on_fail=None),
|
|
154
|
+
RoleStep(role="verifier", on_pass=None, on_fail=None),
|
|
155
|
+
),
|
|
156
|
+
),
|
|
157
|
+
ActionStep(action="merge-pr"),
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Pipeline with gate: LoopStep(implementer) -> gate(human-pr-approval) -> merge-pr.
|
|
162
|
+
E2E_GATE_PIPELINE = Process(
|
|
163
|
+
name="e2e-gate",
|
|
164
|
+
intake=(),
|
|
165
|
+
pipeline=(
|
|
166
|
+
LoopStep(
|
|
167
|
+
steps=(
|
|
168
|
+
RoleStep(role="implementer", on_pass=None, on_fail=None),
|
|
169
|
+
RoleStep(role="verifier", on_pass=None, on_fail=None),
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
GateStep(gate="human-pr-approval"),
|
|
173
|
+
ActionStep(action="merge-pr"),
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# Minimal shell script that writes a passing .worker-result.json.
|
|
179
|
+
# Avoids git operations to keep execution time minimal (~10ms vs ~600ms).
|
|
180
|
+
PASS_AGENT_BODY = """\
|
|
181
|
+
#!/bin/bash
|
|
182
|
+
set -e
|
|
183
|
+
cat > /dev/null
|
|
184
|
+
cat > .worker-result.json <<'RESULT'
|
|
185
|
+
{"verdict": "pass", "findings": 0, "detail": "implemented"}
|
|
186
|
+
RESULT
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
# Agent script that creates a file, commits it, and reports pass.
|
|
190
|
+
# Used by merge tests to verify code lands on the base branch.
|
|
191
|
+
COMMIT_AGENT_BODY = """\
|
|
192
|
+
#!/bin/bash
|
|
193
|
+
set -e
|
|
194
|
+
cat > /dev/null
|
|
195
|
+
|
|
196
|
+
# Strip GIT_* env vars (may interfere when running inside pre-commit)
|
|
197
|
+
for var in $(env | grep '^GIT_' | cut -d= -f1); do
|
|
198
|
+
unset "$var"
|
|
199
|
+
done
|
|
200
|
+
|
|
201
|
+
# Only create the file if it doesn't already exist (idempotent across roles)
|
|
202
|
+
if [ ! -f feature.txt ]; then
|
|
203
|
+
echo "hello from agent" > feature.txt
|
|
204
|
+
git add feature.txt
|
|
205
|
+
git commit -m "feat: add feature.txt"
|
|
206
|
+
fi
|
|
207
|
+
|
|
208
|
+
cat > .worker-result.json <<'RESULT'
|
|
209
|
+
{"verdict": "pass", "findings": 0, "detail": "implemented"}
|
|
210
|
+
RESULT
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# Tests
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
# Each orchestrator cycle takes ~10ms (reads task files, polls subprocesses,
|
|
219
|
+
# calls decide). Worker subprocesses take ~50-200ms. max_cycles must be high
|
|
220
|
+
# enough to cover the wall-clock time of subprocess execution. 500 cycles
|
|
221
|
+
# gives a ~5s budget which is more than sufficient.
|
|
222
|
+
MAX_CYCLES = 500
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@pytest.mark.slow
|
|
226
|
+
class TestSingleTaskCompletesE2E:
|
|
227
|
+
"""One task, deterministic agent always passes.
|
|
228
|
+
|
|
229
|
+
Task goes from not-started -> implementer -> verifier -> complete.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
def test_single_task_completes_e2e(self, tmp_path: Path) -> None:
|
|
233
|
+
repo = tmp_path / "repo"
|
|
234
|
+
repo.mkdir()
|
|
235
|
+
_init_repo(repo)
|
|
236
|
+
|
|
237
|
+
# Seed the repo with a spec and a task
|
|
238
|
+
_write_spec_file(repo, "example.md", "# Example\nBuild a widget.\n")
|
|
239
|
+
_write_task_file(repo, "task-001", TASK_CONTENT.format(task_id="task-001"))
|
|
240
|
+
_commit_all(repo, "chore: seed spec and task")
|
|
241
|
+
|
|
242
|
+
# Create the agent script
|
|
243
|
+
agent_script = _make_agent_script(tmp_path, "pass-agent.sh", PASS_AGENT_BODY)
|
|
244
|
+
|
|
245
|
+
# Wire up real adapters
|
|
246
|
+
state = GitStateStore(repo_path=repo)
|
|
247
|
+
runtime = LocalRuntime(
|
|
248
|
+
repo_path=str(repo),
|
|
249
|
+
worktree_base=str(tmp_path / "worktrees"),
|
|
250
|
+
command=f"bash {agent_script}",
|
|
251
|
+
)
|
|
252
|
+
orch = Orchestrator(
|
|
253
|
+
state=state,
|
|
254
|
+
runtime=runtime,
|
|
255
|
+
process=E2E_PIPELINE,
|
|
256
|
+
max_workers=2,
|
|
257
|
+
max_rounds=10,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
reason = orch.run_loop(max_cycles=MAX_CYCLES)
|
|
261
|
+
|
|
262
|
+
assert "all tasks complete" in reason.lower()
|
|
263
|
+
|
|
264
|
+
task = state.get_task("task-001")
|
|
265
|
+
assert task.status == TaskStatus.COMPLETE
|
|
266
|
+
|
|
267
|
+
# Verify the orchestrator committed state changes to trunk
|
|
268
|
+
log = _git(repo, "log", "--oneline")
|
|
269
|
+
assert "orchestrator" in log.lower()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@pytest.mark.slow
|
|
273
|
+
class TestFailedVerificationRetriesE2E:
|
|
274
|
+
"""Verifier agent script returns fail on the first call, then pass on the second.
|
|
275
|
+
|
|
276
|
+
Uses a counter file in tmp_path to track invocations.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
def test_failed_verification_retries_e2e(self, tmp_path: Path) -> None:
|
|
280
|
+
repo = tmp_path / "repo"
|
|
281
|
+
repo.mkdir()
|
|
282
|
+
_init_repo(repo)
|
|
283
|
+
|
|
284
|
+
_write_spec_file(repo, "example.md", "# Example\nBuild a widget.\n")
|
|
285
|
+
_write_task_file(repo, "task-001", TASK_CONTENT.format(task_id="task-001"))
|
|
286
|
+
_commit_all(repo, "chore: seed spec and task")
|
|
287
|
+
|
|
288
|
+
counter_file = tmp_path / "verifier-counter"
|
|
289
|
+
|
|
290
|
+
# Agent script that fails on the first invocation (counter file absent),
|
|
291
|
+
# then passes on all subsequent invocations.
|
|
292
|
+
retry_agent_body = f"""\
|
|
293
|
+
#!/bin/bash
|
|
294
|
+
set -e
|
|
295
|
+
cat > /dev/null
|
|
296
|
+
|
|
297
|
+
COUNTER_FILE="{counter_file}"
|
|
298
|
+
|
|
299
|
+
if [ ! -f "$COUNTER_FILE" ]; then
|
|
300
|
+
echo "1" > "$COUNTER_FILE"
|
|
301
|
+
cat > .worker-result.json <<'RESULT'
|
|
302
|
+
{{"verdict": "fail", "findings": 1, "detail": "tests failed on first run"}}
|
|
303
|
+
RESULT
|
|
304
|
+
else
|
|
305
|
+
cat > .worker-result.json <<'RESULT'
|
|
306
|
+
{{"verdict": "pass", "findings": 0, "detail": "all good"}}
|
|
307
|
+
RESULT
|
|
308
|
+
fi
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
agent_script = _make_agent_script(tmp_path, "retry-agent.sh", retry_agent_body)
|
|
312
|
+
|
|
313
|
+
state = GitStateStore(repo_path=repo)
|
|
314
|
+
runtime = LocalRuntime(
|
|
315
|
+
repo_path=str(repo),
|
|
316
|
+
worktree_base=str(tmp_path / "worktrees"),
|
|
317
|
+
command=f"bash {agent_script}",
|
|
318
|
+
)
|
|
319
|
+
orch = Orchestrator(
|
|
320
|
+
state=state,
|
|
321
|
+
runtime=runtime,
|
|
322
|
+
process=E2E_PIPELINE,
|
|
323
|
+
max_workers=2,
|
|
324
|
+
max_rounds=10,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
reason = orch.run_loop(max_cycles=MAX_CYCLES)
|
|
328
|
+
|
|
329
|
+
assert "all tasks complete" in reason.lower()
|
|
330
|
+
|
|
331
|
+
task = state.get_task("task-001")
|
|
332
|
+
assert task.status == TaskStatus.COMPLETE
|
|
333
|
+
|
|
334
|
+
# Verify the fail path ran at least once
|
|
335
|
+
assert counter_file.exists()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@pytest.mark.slow
|
|
339
|
+
class TestTwoTasksRunInParallelE2E:
|
|
340
|
+
"""Two independent tasks, both complete. Verify both worker branches are created."""
|
|
341
|
+
|
|
342
|
+
def test_two_tasks_run_in_parallel_e2e(self, tmp_path: Path) -> None:
|
|
343
|
+
repo = tmp_path / "repo"
|
|
344
|
+
repo.mkdir()
|
|
345
|
+
_init_repo(repo)
|
|
346
|
+
|
|
347
|
+
_write_spec_file(repo, "example.md", "# Example\nBuild widgets.\n")
|
|
348
|
+
_write_task_file(repo, "task-001", TASK_CONTENT.format(task_id="task-001"))
|
|
349
|
+
_write_task_file(repo, "task-002", TASK_CONTENT.format(task_id="task-002"))
|
|
350
|
+
_commit_all(repo, "chore: seed spec and tasks")
|
|
351
|
+
|
|
352
|
+
agent_script = _make_agent_script(tmp_path, "pass-agent.sh", PASS_AGENT_BODY)
|
|
353
|
+
|
|
354
|
+
state = GitStateStore(repo_path=repo)
|
|
355
|
+
runtime = LocalRuntime(
|
|
356
|
+
repo_path=str(repo),
|
|
357
|
+
worktree_base=str(tmp_path / "worktrees"),
|
|
358
|
+
command=f"bash {agent_script}",
|
|
359
|
+
)
|
|
360
|
+
orch = Orchestrator(
|
|
361
|
+
state=state,
|
|
362
|
+
runtime=runtime,
|
|
363
|
+
process=E2E_PIPELINE,
|
|
364
|
+
max_workers=4,
|
|
365
|
+
max_rounds=10,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
reason = orch.run_loop(max_cycles=MAX_CYCLES)
|
|
369
|
+
|
|
370
|
+
assert "all tasks complete" in reason.lower()
|
|
371
|
+
|
|
372
|
+
task1 = state.get_task("task-001")
|
|
373
|
+
task2 = state.get_task("task-002")
|
|
374
|
+
assert task1.status == TaskStatus.COMPLETE
|
|
375
|
+
assert task2.status == TaskStatus.COMPLETE
|
|
376
|
+
|
|
377
|
+
# Verify orchestrator committed state changes
|
|
378
|
+
log = _git(repo, "log", "--oneline")
|
|
379
|
+
assert "orchestrator" in log.lower()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@pytest.mark.slow
|
|
383
|
+
class TestLocalMergeLandsCodeOnBaseBranch:
|
|
384
|
+
"""Full pipeline with merge-pr step, no PRManager (local merge mode).
|
|
385
|
+
|
|
386
|
+
Verifies that code committed by the agent on the worker branch is merged
|
|
387
|
+
into the base branch when the pipeline completes.
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
def test_local_merge_lands_code_on_base_branch(self, tmp_path: Path) -> None:
|
|
391
|
+
repo = tmp_path / "repo"
|
|
392
|
+
repo.mkdir()
|
|
393
|
+
_init_repo(repo)
|
|
394
|
+
|
|
395
|
+
_write_spec_file(repo, "example.md", "# Example\nBuild a widget.\n")
|
|
396
|
+
_write_task_file(repo, "task-001", TASK_CONTENT.format(task_id="task-001"))
|
|
397
|
+
_commit_all(repo, "chore: seed spec and task")
|
|
398
|
+
|
|
399
|
+
agent_script = _make_agent_script(tmp_path, "commit-agent.sh", COMMIT_AGENT_BODY)
|
|
400
|
+
|
|
401
|
+
state = GitStateStore(repo_path=repo)
|
|
402
|
+
runtime = LocalRuntime(
|
|
403
|
+
repo_path=str(repo),
|
|
404
|
+
worktree_base=str(tmp_path / "worktrees"),
|
|
405
|
+
command=f"bash {agent_script}",
|
|
406
|
+
)
|
|
407
|
+
orch = Orchestrator(
|
|
408
|
+
state=state,
|
|
409
|
+
runtime=runtime,
|
|
410
|
+
process=E2E_MERGE_PIPELINE,
|
|
411
|
+
max_workers=2,
|
|
412
|
+
max_rounds=10,
|
|
413
|
+
repo_path=str(repo),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
reason = orch.run_loop(max_cycles=MAX_CYCLES)
|
|
417
|
+
|
|
418
|
+
assert "all tasks complete" in reason.lower()
|
|
419
|
+
|
|
420
|
+
task = state.get_task("task-001")
|
|
421
|
+
assert task.status == TaskStatus.COMPLETE
|
|
422
|
+
|
|
423
|
+
# Verify the agent's file exists on the base branch
|
|
424
|
+
assert (repo / "feature.txt").exists()
|
|
425
|
+
assert (repo / "feature.txt").read_text().strip() == "hello from agent"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@pytest.mark.slow
|
|
429
|
+
class TestPRFlowWithFakePRManager:
|
|
430
|
+
"""Full pipeline with FakePRManager wired into the Orchestrator.
|
|
431
|
+
|
|
432
|
+
Proves the PR path works end-to-end: implementer -> verifier -> merge-pr
|
|
433
|
+
with real GitStateStore + LocalRuntime + FakePRManager.
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
def test_pr_flow_with_fake_pr_manager(self, tmp_path: Path) -> None:
|
|
437
|
+
repo = tmp_path / "repo"
|
|
438
|
+
repo.mkdir()
|
|
439
|
+
_init_repo(repo)
|
|
440
|
+
|
|
441
|
+
_write_spec_file(repo, "example.md", "# Example\nBuild a widget.\n")
|
|
442
|
+
# Task needs a pr field set so _merge_via_pr is used instead of _merge_local
|
|
443
|
+
task_with_pr = dedent("""\
|
|
444
|
+
---
|
|
445
|
+
id: task-001
|
|
446
|
+
title: Implement example feature
|
|
447
|
+
spec_ref: specs/example.md
|
|
448
|
+
status: not-started
|
|
449
|
+
phase: null
|
|
450
|
+
deps: []
|
|
451
|
+
round: 0
|
|
452
|
+
branch: null
|
|
453
|
+
pr: https://github.com/test/repo/pull/1
|
|
454
|
+
---
|
|
455
|
+
|
|
456
|
+
## Spec
|
|
457
|
+
Build the example feature.
|
|
458
|
+
|
|
459
|
+
## Findings
|
|
460
|
+
""")
|
|
461
|
+
_write_task_file(repo, "task-001", task_with_pr)
|
|
462
|
+
_commit_all(repo, "chore: seed spec and task")
|
|
463
|
+
|
|
464
|
+
agent_script = _make_agent_script(tmp_path, "pass-agent.sh", PASS_AGENT_BODY)
|
|
465
|
+
|
|
466
|
+
pr_manager = FakePRManager(repo="test/repo")
|
|
467
|
+
# Pre-create the PR in the fake so it exists when merge is called
|
|
468
|
+
pr_url = pr_manager.create_draft(
|
|
469
|
+
task_id="task-001",
|
|
470
|
+
branch="worker/task-001",
|
|
471
|
+
title="Implement example feature",
|
|
472
|
+
spec_ref="specs/example.md",
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
state = GitStateStore(repo_path=repo)
|
|
476
|
+
runtime = LocalRuntime(
|
|
477
|
+
repo_path=str(repo),
|
|
478
|
+
worktree_base=str(tmp_path / "worktrees"),
|
|
479
|
+
command=f"bash {agent_script}",
|
|
480
|
+
)
|
|
481
|
+
orch = Orchestrator(
|
|
482
|
+
state=state,
|
|
483
|
+
runtime=runtime,
|
|
484
|
+
process=E2E_MERGE_PIPELINE,
|
|
485
|
+
max_workers=2,
|
|
486
|
+
max_rounds=10,
|
|
487
|
+
pr_manager=pr_manager,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
reason = orch.run_loop(max_cycles=MAX_CYCLES)
|
|
491
|
+
|
|
492
|
+
assert "all tasks complete" in reason.lower()
|
|
493
|
+
|
|
494
|
+
task = state.get_task("task-001")
|
|
495
|
+
assert task.status == TaskStatus.COMPLETE
|
|
496
|
+
|
|
497
|
+
# Verify FakePRManager received the merge call
|
|
498
|
+
assert len(pr_manager.merged) == 1
|
|
499
|
+
assert pr_manager.rebased == [("worker/task-001", "main")]
|
|
500
|
+
|
|
501
|
+
# Verify the PR was actually merged in the fake
|
|
502
|
+
assert pr_manager._prs[pr_url].merged
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@pytest.mark.slow
|
|
506
|
+
class TestGateBlocksUntilLgtm:
|
|
507
|
+
"""Pipeline with gate: implementer -> verifier -> gate(human-pr-approval) -> merge-pr.
|
|
508
|
+
|
|
509
|
+
Task reaches the gate and stalls. Test adds the lgtm signal to FakePRManager.
|
|
510
|
+
Next cycle clears the gate. Task proceeds to merge and completes.
|
|
511
|
+
"""
|
|
512
|
+
|
|
513
|
+
def test_gate_blocks_until_lgtm(self, tmp_path: Path) -> None:
|
|
514
|
+
repo = tmp_path / "repo"
|
|
515
|
+
repo.mkdir()
|
|
516
|
+
_init_repo(repo)
|
|
517
|
+
|
|
518
|
+
_write_spec_file(repo, "example.md", "# Example\nBuild a widget.\n")
|
|
519
|
+
task_with_pr = dedent("""\
|
|
520
|
+
---
|
|
521
|
+
id: task-001
|
|
522
|
+
title: Implement example feature
|
|
523
|
+
spec_ref: specs/example.md
|
|
524
|
+
status: not-started
|
|
525
|
+
phase: null
|
|
526
|
+
deps: []
|
|
527
|
+
round: 0
|
|
528
|
+
branch: null
|
|
529
|
+
pr: https://github.com/test/repo/pull/1
|
|
530
|
+
---
|
|
531
|
+
|
|
532
|
+
## Spec
|
|
533
|
+
Build the example feature.
|
|
534
|
+
|
|
535
|
+
## Findings
|
|
536
|
+
""")
|
|
537
|
+
_write_task_file(repo, "task-001", task_with_pr)
|
|
538
|
+
_commit_all(repo, "chore: seed spec and task")
|
|
539
|
+
|
|
540
|
+
agent_script = _make_agent_script(tmp_path, "pass-agent.sh", PASS_AGENT_BODY)
|
|
541
|
+
|
|
542
|
+
pr_manager = FakePRManager(repo="test/repo")
|
|
543
|
+
pr_url = pr_manager.create_draft(
|
|
544
|
+
task_id="task-001",
|
|
545
|
+
branch="worker/task-001",
|
|
546
|
+
title="Implement example feature",
|
|
547
|
+
spec_ref="specs/example.md",
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
state = GitStateStore(repo_path=repo)
|
|
551
|
+
runtime = LocalRuntime(
|
|
552
|
+
repo_path=str(repo),
|
|
553
|
+
worktree_base=str(tmp_path / "worktrees"),
|
|
554
|
+
command=f"bash {agent_script}",
|
|
555
|
+
)
|
|
556
|
+
orch = Orchestrator(
|
|
557
|
+
state=state,
|
|
558
|
+
runtime=runtime,
|
|
559
|
+
process=E2E_GATE_PIPELINE,
|
|
560
|
+
max_workers=2,
|
|
561
|
+
max_rounds=10,
|
|
562
|
+
pr_manager=pr_manager,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
# Run enough cycles for implementer + verifier to complete, reaching the gate
|
|
566
|
+
for _ in range(MAX_CYCLES):
|
|
567
|
+
result = orch.run_cycle()
|
|
568
|
+
if result is not None:
|
|
569
|
+
break
|
|
570
|
+
task = state.get_task("task-001")
|
|
571
|
+
if task.phase is not None and str(task.phase) == "human-pr-approval":
|
|
572
|
+
break
|
|
573
|
+
|
|
574
|
+
# Task should be at the gate
|
|
575
|
+
task = state.get_task("task-001")
|
|
576
|
+
assert task.status == TaskStatus.IN_PROGRESS
|
|
577
|
+
assert str(task.phase) == "human-pr-approval"
|
|
578
|
+
|
|
579
|
+
# Simulate human approval: add lgtm label
|
|
580
|
+
pr_manager.add_label(pr_url, "lgtm")
|
|
581
|
+
|
|
582
|
+
# Run more cycles — gate should clear, merge should succeed
|
|
583
|
+
reason = orch.run_loop(max_cycles=MAX_CYCLES)
|
|
584
|
+
|
|
585
|
+
assert "all tasks complete" in reason.lower()
|
|
586
|
+
|
|
587
|
+
task = state.get_task("task-001")
|
|
588
|
+
assert task.status == TaskStatus.COMPLETE
|
|
589
|
+
|
|
590
|
+
# Verify the PR was merged
|
|
591
|
+
assert len(pr_manager.merged) == 1
|
|
@@ -229,7 +229,7 @@ class TestReap:
|
|
|
229
229
|
worktree_path = tmp_path / "worktrees" / "workers" / "task-022"
|
|
230
230
|
assert not worktree_path.exists()
|
|
231
231
|
|
|
232
|
-
def
|
|
232
|
+
def test_preserves_branch_after_reap(self, tmp_path: Path) -> None:
|
|
233
233
|
_init_repo(tmp_path)
|
|
234
234
|
rt = LocalRuntime(repo_path=str(tmp_path), command=PASS_COMMAND)
|
|
235
235
|
|
|
@@ -243,7 +243,7 @@ class TestReap:
|
|
|
243
243
|
|
|
244
244
|
rt.reap(handle)
|
|
245
245
|
|
|
246
|
-
# Branch should be
|
|
246
|
+
# Branch should be preserved for later pipeline steps (e.g. merge-pr)
|
|
247
247
|
result = subprocess.run(
|
|
248
248
|
["git", "-C", str(tmp_path), "branch", "--list", "worker/task-023"],
|
|
249
249
|
check=True,
|
|
@@ -251,7 +251,7 @@ class TestReap:
|
|
|
251
251
|
text=True,
|
|
252
252
|
env=_clean_git_env(),
|
|
253
253
|
)
|
|
254
|
-
assert result.stdout.strip()
|
|
254
|
+
assert "worker/task-023" in result.stdout.strip()
|
|
255
255
|
|
|
256
256
|
def test_returns_error_result_when_no_result_file(self, tmp_path: Path) -> None:
|
|
257
257
|
_init_repo(tmp_path)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|