hyperloop 0.1.1__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.1.1 → hyperloop-0.3.0}/CHANGELOG.md +6 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/PKG-INFO +16 -15
- {hyperloop-0.1.1 → hyperloop-0.3.0}/README.md +15 -14
- {hyperloop-0.1.1 → hyperloop-0.3.0}/pyproject.toml +4 -1
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/adapters/local.py +32 -10
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/cli.py +33 -9
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/loop.py +48 -26
- hyperloop-0.3.0/tests/test_e2e.py +591 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_local_runtime.py +3 -3
- {hyperloop-0.1.1 → hyperloop-0.3.0}/uv.lock +1 -1
- {hyperloop-0.1.1 → hyperloop-0.3.0}/.github/workflows/ci.yaml +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/.github/workflows/release.yaml +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/.gitignore +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/.pre-commit-config.yaml +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/.python-version +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/CLAUDE.md +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/base/implementer.yaml +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/base/pm.yaml +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/base/process-improver.yaml +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/base/process.yaml +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/base/rebase-resolver.yaml +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/base/verifier.yaml +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/specs/spec.md +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/__init__.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/__main__.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/adapters/__init__.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/adapters/git_state.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/compose.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/config.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/domain/__init__.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/domain/decide.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/domain/deps.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/domain/model.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/domain/pipeline.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/ports/__init__.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/ports/pr.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/ports/runtime.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/ports/state.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/pr.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/__init__.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/fakes/__init__.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/fakes/pr.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/fakes/runtime.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/fakes/state.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_cli.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_compose.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_config.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_decide.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_deps.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_fakes.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_git_state.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_loop.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_model.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_pipeline.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_pr.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_smoke.py +0 -0
- {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_state_contract.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyperloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Orchestrator that walks tasks through composable process pipelines using AI agents
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Requires-Dist: pyyaml>=6.0.3
|
|
@@ -15,14 +15,14 @@ Description-Content-Type: text/markdown
|
|
|
15
15
|
|
|
16
16
|
# hyperloop
|
|
17
17
|
|
|
18
|
-
Walks tasks through composable process pipelines using AI agents. You write specs, it creates tasks, implements them, verifies the work, and merges
|
|
18
|
+
Walks tasks through composable process pipelines using AI agents. You write specs, it creates tasks, implements them, verifies the work, and merges the results.
|
|
19
19
|
|
|
20
20
|
## Prerequisites
|
|
21
21
|
|
|
22
22
|
- Python 3.12+
|
|
23
23
|
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
|
24
|
-
- `gh` CLI (authenticated, for PR management)
|
|
25
24
|
- `git`
|
|
25
|
+
- `gh` CLI (optional, for GitHub PR operations)
|
|
26
26
|
|
|
27
27
|
## Install
|
|
28
28
|
|
|
@@ -67,23 +67,23 @@ Implement JWT-based authentication for the API.
|
|
|
67
67
|
- JWTs expire after 24 hours
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
3. Run:
|
|
70
|
+
3. Run against the local repo:
|
|
71
71
|
|
|
72
72
|
```bash
|
|
73
|
-
# From
|
|
74
|
-
uv run hyperloop run
|
|
73
|
+
# From inside the repo
|
|
74
|
+
uv run hyperloop run
|
|
75
75
|
|
|
76
|
-
# Or
|
|
77
|
-
hyperloop run --
|
|
78
|
-
```
|
|
76
|
+
# Or point to a repo elsewhere
|
|
77
|
+
uv run hyperloop run --path ~/code/my-project
|
|
79
78
|
|
|
80
|
-
|
|
79
|
+
# With GitHub PR support (draft PRs, lgtm gates, squash-merge)
|
|
80
|
+
uv run hyperloop run --repo owner/repo
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
# Dry run (show config, don't execute)
|
|
83
|
+
uv run hyperloop run --dry-run
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
When `--repo` is not set, completed work is merged locally into the base branch via `git merge`. When `--repo` is set, the orchestrator creates draft PRs, polls for `lgtm` labels on gates, and squash-merges via GitHub.
|
|
87
87
|
|
|
88
88
|
## Configuration
|
|
89
89
|
|
|
@@ -101,13 +101,14 @@ merge:
|
|
|
101
101
|
strategy: squash
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
Then
|
|
104
|
+
Then run from the repo directory (or use `--path`):
|
|
105
105
|
|
|
106
106
|
```bash
|
|
107
107
|
hyperloop run
|
|
108
|
+
hyperloop run --path ~/code/my-project
|
|
108
109
|
```
|
|
109
110
|
|
|
110
|
-
|
|
111
|
+
All settings have sensible defaults. `--repo` is only needed for GitHub PR operations.
|
|
111
112
|
|
|
112
113
|
## Customizing Agent Behavior
|
|
113
114
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# hyperloop
|
|
2
2
|
|
|
3
|
-
Walks tasks through composable process pipelines using AI agents. You write specs, it creates tasks, implements them, verifies the work, and merges
|
|
3
|
+
Walks tasks through composable process pipelines using AI agents. You write specs, it creates tasks, implements them, verifies the work, and merges the results.
|
|
4
4
|
|
|
5
5
|
## Prerequisites
|
|
6
6
|
|
|
7
7
|
- Python 3.12+
|
|
8
8
|
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
|
9
|
-
- `gh` CLI (authenticated, for PR management)
|
|
10
9
|
- `git`
|
|
10
|
+
- `gh` CLI (optional, for GitHub PR operations)
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
@@ -52,23 +52,23 @@ Implement JWT-based authentication for the API.
|
|
|
52
52
|
- JWTs expire after 24 hours
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
3. Run:
|
|
55
|
+
3. Run against the local repo:
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
|
-
# From
|
|
59
|
-
uv run hyperloop run
|
|
58
|
+
# From inside the repo
|
|
59
|
+
uv run hyperloop run
|
|
60
60
|
|
|
61
|
-
# Or
|
|
62
|
-
hyperloop run --
|
|
63
|
-
```
|
|
61
|
+
# Or point to a repo elsewhere
|
|
62
|
+
uv run hyperloop run --path ~/code/my-project
|
|
64
63
|
|
|
65
|
-
|
|
64
|
+
# With GitHub PR support (draft PRs, lgtm gates, squash-merge)
|
|
65
|
+
uv run hyperloop run --repo owner/repo
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
# Dry run (show config, don't execute)
|
|
68
|
+
uv run hyperloop run --dry-run
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
When `--repo` is not set, completed work is merged locally into the base branch via `git merge`. When `--repo` is set, the orchestrator creates draft PRs, polls for `lgtm` labels on gates, and squash-merges via GitHub.
|
|
72
72
|
|
|
73
73
|
## Configuration
|
|
74
74
|
|
|
@@ -86,13 +86,14 @@ merge:
|
|
|
86
86
|
strategy: squash
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
-
Then
|
|
89
|
+
Then run from the repo directory (or use `--path`):
|
|
90
90
|
|
|
91
91
|
```bash
|
|
92
92
|
hyperloop run
|
|
93
|
+
hyperloop run --path ~/code/my-project
|
|
93
94
|
```
|
|
94
95
|
|
|
95
|
-
|
|
96
|
+
All settings have sensible defaults. `--repo` is only needed for GitHub PR operations.
|
|
96
97
|
|
|
97
98
|
## Customizing Agent Behavior
|
|
98
99
|
|
|
@@ -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
|
|
|
@@ -52,6 +68,10 @@ def _config_table(cfg: Config) -> Table:
|
|
|
52
68
|
|
|
53
69
|
@app.command()
|
|
54
70
|
def run(
|
|
71
|
+
path: Path = typer.Option(
|
|
72
|
+
Path.cwd(),
|
|
73
|
+
help="Path to the target repo. Default: current directory.",
|
|
74
|
+
),
|
|
55
75
|
repo: str | None = typer.Option(
|
|
56
76
|
None,
|
|
57
77
|
help="GitHub repo (owner/repo). Inferred from git remote if not set.",
|
|
@@ -64,7 +84,7 @@ def run(
|
|
|
64
84
|
None,
|
|
65
85
|
"--config",
|
|
66
86
|
"-c",
|
|
67
|
-
help="Config file path. Default: .hyperloop.yaml",
|
|
87
|
+
help="Config file path. Default: .hyperloop.yaml in the target repo.",
|
|
68
88
|
),
|
|
69
89
|
max_workers: int | None = typer.Option(
|
|
70
90
|
None,
|
|
@@ -78,7 +98,8 @@ def run(
|
|
|
78
98
|
) -> None:
|
|
79
99
|
"""Run the orchestrator loop."""
|
|
80
100
|
# 1. Load config (file + CLI overrides)
|
|
81
|
-
|
|
101
|
+
repo_path = path.resolve()
|
|
102
|
+
config_path = config_file or (repo_path / ".hyperloop.yaml")
|
|
82
103
|
try:
|
|
83
104
|
cfg = load_config(
|
|
84
105
|
config_path,
|
|
@@ -99,7 +120,9 @@ def run(
|
|
|
99
120
|
style="blue",
|
|
100
121
|
)
|
|
101
122
|
)
|
|
102
|
-
|
|
123
|
+
table = _config_table(cfg)
|
|
124
|
+
table.add_row("path", str(repo_path))
|
|
125
|
+
console.print(table)
|
|
103
126
|
console.print()
|
|
104
127
|
|
|
105
128
|
# 3. Dry run — show config and exit
|
|
@@ -107,13 +130,15 @@ def run(
|
|
|
107
130
|
console.print("[bold yellow]Dry run[/bold yellow] -- exiting without executing.")
|
|
108
131
|
return
|
|
109
132
|
|
|
110
|
-
# 4. Validate repo
|
|
133
|
+
# 4. Validate repo path exists and is a git repo
|
|
134
|
+
if not (repo_path / ".git").exists():
|
|
135
|
+
console.print(f"[bold red]Error:[/bold red] {repo_path} is not a git repository.")
|
|
136
|
+
raise typer.Exit(code=1)
|
|
137
|
+
|
|
111
138
|
if cfg.repo is None:
|
|
112
139
|
console.print(
|
|
113
|
-
"[
|
|
114
|
-
"Use --repo owner/repo or set target.repo in .hyperloop.yaml"
|
|
140
|
+
"[dim]No --repo set. PR operations (draft, merge, gate) will be skipped.[/dim]"
|
|
115
141
|
)
|
|
116
|
-
raise typer.Exit(code=1)
|
|
117
142
|
|
|
118
143
|
# 5. Construct runtime and state store, run loop
|
|
119
144
|
from hyperloop.adapters.git_state import GitStateStore
|
|
@@ -136,7 +161,6 @@ def run(
|
|
|
136
161
|
),
|
|
137
162
|
)
|
|
138
163
|
|
|
139
|
-
repo_path = Path.cwd()
|
|
140
164
|
state = GitStateStore(repo_path, specs_dir=cfg.specs_dir)
|
|
141
165
|
runtime = LocalRuntime(repo_path=str(repo_path))
|
|
142
166
|
|
|
@@ -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]] = {}
|
|
@@ -340,45 +342,65 @@ class Orchestrator:
|
|
|
340
342
|
self._state.clear_findings(task.id)
|
|
341
343
|
|
|
342
344
|
def _merge_ready_prs(self) -> None:
|
|
343
|
-
"""Merge
|
|
345
|
+
"""Merge branches for tasks at the merge-pr action step.
|
|
344
346
|
|
|
345
|
-
|
|
347
|
+
With a PRManager: rebase + squash-merge the PR.
|
|
348
|
+
Without a PRManager: local git merge of worker branch into base branch.
|
|
349
|
+
On conflict, transitions to NEEDS_REBASE.
|
|
346
350
|
"""
|
|
347
|
-
if self._pr_manager is None:
|
|
348
|
-
logger.debug("merge: no PRManager — skipping merge")
|
|
349
|
-
return
|
|
350
|
-
|
|
351
351
|
all_tasks = self._state.get_world().tasks
|
|
352
352
|
for task in all_tasks.values():
|
|
353
353
|
if task.status != TaskStatus.IN_PROGRESS:
|
|
354
354
|
continue
|
|
355
355
|
if task.phase != Phase("merge-pr"):
|
|
356
356
|
continue
|
|
357
|
-
if task.pr is None:
|
|
358
|
-
continue
|
|
359
357
|
|
|
360
358
|
branch = task.branch or f"worker/{task.id}"
|
|
361
359
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
self.
|
|
366
|
-
task.id, TaskStatus.NEEDS_REBASE, phase=Phase("merge-pr")
|
|
367
|
-
)
|
|
368
|
-
continue
|
|
360
|
+
if self._pr_manager is not None and task.pr is not None:
|
|
361
|
+
self._merge_via_pr(task.id, task.pr, task.spec_ref, branch)
|
|
362
|
+
else:
|
|
363
|
+
self._merge_local(task.id, branch)
|
|
369
364
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
365
|
+
def _merge_via_pr(self, task_id: str, pr_url: str, spec_ref: str, branch: str) -> None:
|
|
366
|
+
"""Merge via GitHub PR: rebase, then squash-merge."""
|
|
367
|
+
assert self._pr_manager is not None
|
|
368
|
+
|
|
369
|
+
if not self._pr_manager.rebase_branch(branch, "main"):
|
|
370
|
+
logger.warning("Rebase conflict for task %s, marking NEEDS_REBASE", task_id)
|
|
371
|
+
self._state.transition_task(task_id, TaskStatus.NEEDS_REBASE, phase=Phase("merge-pr"))
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
if not self._pr_manager.merge(pr_url, task_id, spec_ref):
|
|
375
|
+
logger.warning("Merge conflict for task %s, marking NEEDS_REBASE", task_id)
|
|
376
|
+
self._state.transition_task(task_id, TaskStatus.NEEDS_REBASE, phase=Phase("merge-pr"))
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
self._state.transition_task(task_id, TaskStatus.COMPLETE, phase=None)
|
|
380
|
+
self._state.clear_findings(task_id)
|
|
381
|
+
logger.info("Merged PR for task %s", task_id)
|
|
382
|
+
|
|
383
|
+
def _merge_local(self, task_id: str, branch: str) -> None:
|
|
384
|
+
"""Merge worker branch into base branch locally (no PR)."""
|
|
385
|
+
import subprocess
|
|
377
386
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
self.
|
|
381
|
-
|
|
387
|
+
git_cmd = ["git"]
|
|
388
|
+
if self._repo_path is not None:
|
|
389
|
+
git_cmd = ["git", "-C", self._repo_path]
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
subprocess.run(
|
|
393
|
+
[*git_cmd, "merge", branch, "--no-edit", "-m", f"merge: {task_id}"],
|
|
394
|
+
check=True,
|
|
395
|
+
capture_output=True,
|
|
396
|
+
)
|
|
397
|
+
self._state.transition_task(task_id, TaskStatus.COMPLETE, phase=None)
|
|
398
|
+
self._state.clear_findings(task_id)
|
|
399
|
+
logger.info("Local merge of %s into base branch", task_id)
|
|
400
|
+
except subprocess.CalledProcessError:
|
|
401
|
+
logger.warning("Local merge conflict for task %s, marking NEEDS_REBASE", task_id)
|
|
402
|
+
subprocess.run([*git_cmd, "merge", "--abort"], capture_output=True, check=False)
|
|
403
|
+
self._state.transition_task(task_id, TaskStatus.NEEDS_REBASE, phase=Phase("merge-pr"))
|
|
382
404
|
|
|
383
405
|
# -----------------------------------------------------------------------
|
|
384
406
|
# World building
|
|
@@ -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
|