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.
Files changed (57) hide show
  1. {hyperloop-0.2.0 → hyperloop-0.3.0}/CHANGELOG.md +3 -0
  2. {hyperloop-0.2.0 → hyperloop-0.3.0}/PKG-INFO +1 -1
  3. {hyperloop-0.2.0 → hyperloop-0.3.0}/pyproject.toml +4 -1
  4. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/adapters/local.py +32 -10
  5. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/cli.py +17 -1
  6. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/loop.py +8 -2
  7. hyperloop-0.3.0/tests/test_e2e.py +591 -0
  8. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_local_runtime.py +3 -3
  9. {hyperloop-0.2.0 → hyperloop-0.3.0}/uv.lock +1 -1
  10. {hyperloop-0.2.0 → hyperloop-0.3.0}/.github/workflows/ci.yaml +0 -0
  11. {hyperloop-0.2.0 → hyperloop-0.3.0}/.github/workflows/release.yaml +0 -0
  12. {hyperloop-0.2.0 → hyperloop-0.3.0}/.gitignore +0 -0
  13. {hyperloop-0.2.0 → hyperloop-0.3.0}/.pre-commit-config.yaml +0 -0
  14. {hyperloop-0.2.0 → hyperloop-0.3.0}/.python-version +0 -0
  15. {hyperloop-0.2.0 → hyperloop-0.3.0}/CLAUDE.md +0 -0
  16. {hyperloop-0.2.0 → hyperloop-0.3.0}/README.md +0 -0
  17. {hyperloop-0.2.0 → hyperloop-0.3.0}/base/implementer.yaml +0 -0
  18. {hyperloop-0.2.0 → hyperloop-0.3.0}/base/pm.yaml +0 -0
  19. {hyperloop-0.2.0 → hyperloop-0.3.0}/base/process-improver.yaml +0 -0
  20. {hyperloop-0.2.0 → hyperloop-0.3.0}/base/process.yaml +0 -0
  21. {hyperloop-0.2.0 → hyperloop-0.3.0}/base/rebase-resolver.yaml +0 -0
  22. {hyperloop-0.2.0 → hyperloop-0.3.0}/base/verifier.yaml +0 -0
  23. {hyperloop-0.2.0 → hyperloop-0.3.0}/specs/spec.md +0 -0
  24. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/__init__.py +0 -0
  25. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/__main__.py +0 -0
  26. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/adapters/__init__.py +0 -0
  27. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/adapters/git_state.py +0 -0
  28. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/compose.py +0 -0
  29. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/config.py +0 -0
  30. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/domain/__init__.py +0 -0
  31. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/domain/decide.py +0 -0
  32. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/domain/deps.py +0 -0
  33. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/domain/model.py +0 -0
  34. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/domain/pipeline.py +0 -0
  35. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/ports/__init__.py +0 -0
  36. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/ports/pr.py +0 -0
  37. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/ports/runtime.py +0 -0
  38. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/ports/state.py +0 -0
  39. {hyperloop-0.2.0 → hyperloop-0.3.0}/src/hyperloop/pr.py +0 -0
  40. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/__init__.py +0 -0
  41. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/fakes/__init__.py +0 -0
  42. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/fakes/pr.py +0 -0
  43. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/fakes/runtime.py +0 -0
  44. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/fakes/state.py +0 -0
  45. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_cli.py +0 -0
  46. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_compose.py +0 -0
  47. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_config.py +0 -0
  48. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_decide.py +0 -0
  49. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_deps.py +0 -0
  50. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_fakes.py +0 -0
  51. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_git_state.py +0 -0
  52. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_loop.py +0 -0
  53. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_model.py +0 -0
  54. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_pipeline.py +0 -0
  55. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_pr.py +0 -0
  56. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_smoke.py +0 -0
  57. {hyperloop-0.2.0 → hyperloop-0.3.0}/tests/test_state_contract.py +0 -0
@@ -2,6 +2,9 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v0.3.0 (2026-04-15)
6
+
7
+
5
8
  ## v0.2.0 (2026-04-15)
6
9
 
7
10
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperloop
3
- Version: 0.2.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hyperloop"
3
- version = "0.2.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 on a new branch
60
- subprocess.run(
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=_clean_git_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 and branch, return WorkerResult."""
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 and delete the branch."""
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
- # Delete the branch
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=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() -> None:
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
- ["git", "merge", branch, "--no-edit", "-m", f"merge: {task_id}"],
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(["git", "merge", "--abort"], capture_output=True, check=False)
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 test_cleans_up_branch_after_reap(self, tmp_path: Path) -> None:
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 deleted
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)
@@ -61,7 +61,7 @@ wheels = [
61
61
 
62
62
  [[package]]
63
63
  name = "hyperloop"
64
- version = "0.1.0"
64
+ version = "0.1.1"
65
65
  source = { editable = "." }
66
66
  dependencies = [
67
67
  { name = "pyyaml" },
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