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.
Files changed (57) hide show
  1. {hyperloop-0.1.1 → hyperloop-0.3.0}/CHANGELOG.md +6 -0
  2. {hyperloop-0.1.1 → hyperloop-0.3.0}/PKG-INFO +16 -15
  3. {hyperloop-0.1.1 → hyperloop-0.3.0}/README.md +15 -14
  4. {hyperloop-0.1.1 → hyperloop-0.3.0}/pyproject.toml +4 -1
  5. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/adapters/local.py +32 -10
  6. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/cli.py +33 -9
  7. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/loop.py +48 -26
  8. hyperloop-0.3.0/tests/test_e2e.py +591 -0
  9. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_local_runtime.py +3 -3
  10. {hyperloop-0.1.1 → hyperloop-0.3.0}/uv.lock +1 -1
  11. {hyperloop-0.1.1 → hyperloop-0.3.0}/.github/workflows/ci.yaml +0 -0
  12. {hyperloop-0.1.1 → hyperloop-0.3.0}/.github/workflows/release.yaml +0 -0
  13. {hyperloop-0.1.1 → hyperloop-0.3.0}/.gitignore +0 -0
  14. {hyperloop-0.1.1 → hyperloop-0.3.0}/.pre-commit-config.yaml +0 -0
  15. {hyperloop-0.1.1 → hyperloop-0.3.0}/.python-version +0 -0
  16. {hyperloop-0.1.1 → hyperloop-0.3.0}/CLAUDE.md +0 -0
  17. {hyperloop-0.1.1 → hyperloop-0.3.0}/base/implementer.yaml +0 -0
  18. {hyperloop-0.1.1 → hyperloop-0.3.0}/base/pm.yaml +0 -0
  19. {hyperloop-0.1.1 → hyperloop-0.3.0}/base/process-improver.yaml +0 -0
  20. {hyperloop-0.1.1 → hyperloop-0.3.0}/base/process.yaml +0 -0
  21. {hyperloop-0.1.1 → hyperloop-0.3.0}/base/rebase-resolver.yaml +0 -0
  22. {hyperloop-0.1.1 → hyperloop-0.3.0}/base/verifier.yaml +0 -0
  23. {hyperloop-0.1.1 → hyperloop-0.3.0}/specs/spec.md +0 -0
  24. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/__init__.py +0 -0
  25. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/__main__.py +0 -0
  26. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/adapters/__init__.py +0 -0
  27. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/adapters/git_state.py +0 -0
  28. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/compose.py +0 -0
  29. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/config.py +0 -0
  30. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/domain/__init__.py +0 -0
  31. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/domain/decide.py +0 -0
  32. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/domain/deps.py +0 -0
  33. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/domain/model.py +0 -0
  34. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/domain/pipeline.py +0 -0
  35. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/ports/__init__.py +0 -0
  36. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/ports/pr.py +0 -0
  37. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/ports/runtime.py +0 -0
  38. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/ports/state.py +0 -0
  39. {hyperloop-0.1.1 → hyperloop-0.3.0}/src/hyperloop/pr.py +0 -0
  40. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/__init__.py +0 -0
  41. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/fakes/__init__.py +0 -0
  42. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/fakes/pr.py +0 -0
  43. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/fakes/runtime.py +0 -0
  44. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/fakes/state.py +0 -0
  45. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_cli.py +0 -0
  46. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_compose.py +0 -0
  47. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_config.py +0 -0
  48. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_decide.py +0 -0
  49. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_deps.py +0 -0
  50. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_fakes.py +0 -0
  51. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_git_state.py +0 -0
  52. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_loop.py +0 -0
  53. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_model.py +0 -0
  54. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_pipeline.py +0 -0
  55. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_pr.py +0 -0
  56. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_smoke.py +0 -0
  57. {hyperloop-0.1.1 → hyperloop-0.3.0}/tests/test_state_contract.py +0 -0
@@ -2,6 +2,12 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v0.3.0 (2026-04-15)
6
+
7
+
8
+ ## v0.2.0 (2026-04-15)
9
+
10
+
5
11
  ## v0.1.1 (2026-04-15)
6
12
 
7
13
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperloop
3
- Version: 0.1.1
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 PRs.
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 source:
74
- uv run hyperloop run --repo owner/repo --branch main
73
+ # From inside the repo
74
+ uv run hyperloop run
75
75
 
76
- # Or if installed:
77
- hyperloop run --repo owner/repo --branch main
78
- ```
76
+ # Or point to a repo elsewhere
77
+ uv run hyperloop run --path ~/code/my-project
79
78
 
80
- 4. See what it would do without executing:
79
+ # With GitHub PR support (draft PRs, lgtm gates, squash-merge)
80
+ uv run hyperloop run --repo owner/repo
81
81
 
82
- ```bash
83
- hyperloop run --repo owner/repo --dry-run
82
+ # Dry run (show config, don't execute)
83
+ uv run hyperloop run --dry-run
84
84
  ```
85
85
 
86
- The orchestrator reads your specs, has the PM create tasks in `specs/tasks/`, then walks each task through the default pipeline: implement, verify, merge.
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 just run from the repo directory:
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
- The repo is inferred from your git remote. All settings have sensible defaults.
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 PRs.
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 source:
59
- uv run hyperloop run --repo owner/repo --branch main
58
+ # From inside the repo
59
+ uv run hyperloop run
60
60
 
61
- # Or if installed:
62
- hyperloop run --repo owner/repo --branch main
63
- ```
61
+ # Or point to a repo elsewhere
62
+ uv run hyperloop run --path ~/code/my-project
64
63
 
65
- 4. See what it would do without executing:
64
+ # With GitHub PR support (draft PRs, lgtm gates, squash-merge)
65
+ uv run hyperloop run --repo owner/repo
66
66
 
67
- ```bash
68
- hyperloop run --repo owner/repo --dry-run
67
+ # Dry run (show config, don't execute)
68
+ uv run hyperloop run --dry-run
69
69
  ```
70
70
 
71
- The orchestrator reads your specs, has the PM create tasks in `specs/tasks/`, then walks each task through the default pipeline: implement, verify, merge.
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 just run from the repo directory:
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
- The repo is inferred from your git remote. All settings have sensible defaults.
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.1.1"
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
 
@@ -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
- config_path = config_file or Path(".hyperloop.yaml")
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
- console.print(_config_table(cfg))
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 is set (required for actual run)
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
- "[bold red]Error:[/bold red] No repo specified. "
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 PRs for tasks at the merge-pr action step.
345
+ """Merge branches for tasks at the merge-pr action step.
344
346
 
345
- Rebases branch first; on conflict transitions to NEEDS_REBASE.
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
- # Step 1: Rebase onto base branch
363
- if not self._pr_manager.rebase_branch(branch, "main"):
364
- logger.warning("Rebase conflict for task %s, marking NEEDS_REBASE", task.id)
365
- self._state.transition_task(
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
- # Step 2: Squash-merge the PR
371
- if not self._pr_manager.merge(task.pr, task.id, task.spec_ref):
372
- logger.warning("Merge conflict for task %s, marking NEEDS_REBASE", task.id)
373
- self._state.transition_task(
374
- task.id, TaskStatus.NEEDS_REBASE, phase=Phase("merge-pr")
375
- )
376
- continue
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
- # Merge succeeded — mark task complete
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)
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 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