hyperloop 0.5.0__tar.gz → 0.6.1__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 (62) hide show
  1. {hyperloop-0.5.0 → hyperloop-0.6.1}/CHANGELOG.md +6 -0
  2. {hyperloop-0.5.0 → hyperloop-0.6.1}/PKG-INFO +1 -1
  3. {hyperloop-0.5.0 → hyperloop-0.6.1}/pyproject.toml +1 -1
  4. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/adapters/git_state.py +6 -0
  5. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/cli.py +1 -0
  6. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/loop.py +82 -4
  7. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/ports/state.py +4 -0
  8. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/test_loop.py +234 -0
  9. {hyperloop-0.5.0 → hyperloop-0.6.1}/.github/workflows/ci.yaml +0 -0
  10. {hyperloop-0.5.0 → hyperloop-0.6.1}/.github/workflows/release.yaml +0 -0
  11. {hyperloop-0.5.0 → hyperloop-0.6.1}/.gitignore +0 -0
  12. {hyperloop-0.5.0 → hyperloop-0.6.1}/.pre-commit-config.yaml +0 -0
  13. {hyperloop-0.5.0 → hyperloop-0.6.1}/.python-version +0 -0
  14. {hyperloop-0.5.0 → hyperloop-0.6.1}/CLAUDE.md +0 -0
  15. {hyperloop-0.5.0 → hyperloop-0.6.1}/README.md +0 -0
  16. {hyperloop-0.5.0 → hyperloop-0.6.1}/base/implementer.yaml +0 -0
  17. {hyperloop-0.5.0 → hyperloop-0.6.1}/base/kustomization.yaml +0 -0
  18. {hyperloop-0.5.0 → hyperloop-0.6.1}/base/pm.yaml +0 -0
  19. {hyperloop-0.5.0 → hyperloop-0.6.1}/base/process-improver.yaml +0 -0
  20. {hyperloop-0.5.0 → hyperloop-0.6.1}/base/process.yaml +0 -0
  21. {hyperloop-0.5.0 → hyperloop-0.6.1}/base/rebase-resolver.yaml +0 -0
  22. {hyperloop-0.5.0 → hyperloop-0.6.1}/base/verifier.yaml +0 -0
  23. {hyperloop-0.5.0 → hyperloop-0.6.1}/specs/spec.md +0 -0
  24. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/__init__.py +0 -0
  25. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/__main__.py +0 -0
  26. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/adapters/__init__.py +0 -0
  27. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/adapters/local.py +0 -0
  28. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/adapters/serial.py +0 -0
  29. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/compose.py +0 -0
  30. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/config.py +0 -0
  31. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/domain/__init__.py +0 -0
  32. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/domain/decide.py +0 -0
  33. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/domain/deps.py +0 -0
  34. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/domain/model.py +0 -0
  35. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/domain/pipeline.py +0 -0
  36. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/ports/__init__.py +0 -0
  37. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/ports/pr.py +0 -0
  38. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/ports/runtime.py +0 -0
  39. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/ports/serial.py +0 -0
  40. {hyperloop-0.5.0 → hyperloop-0.6.1}/src/hyperloop/pr.py +0 -0
  41. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/__init__.py +0 -0
  42. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/fakes/__init__.py +0 -0
  43. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/fakes/pr.py +0 -0
  44. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/fakes/runtime.py +0 -0
  45. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/fakes/serial.py +0 -0
  46. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/fakes/state.py +0 -0
  47. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/test_cli.py +0 -0
  48. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/test_compose.py +0 -0
  49. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/test_config.py +0 -0
  50. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/test_decide.py +0 -0
  51. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/test_deps.py +0 -0
  52. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/test_e2e.py +0 -0
  53. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/test_fakes.py +0 -0
  54. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/test_git_state.py +0 -0
  55. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/test_local_runtime.py +0 -0
  56. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/test_model.py +0 -0
  57. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/test_pipeline.py +0 -0
  58. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/test_pr.py +0 -0
  59. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/test_serial_agents.py +0 -0
  60. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/test_smoke.py +0 -0
  61. {hyperloop-0.5.0 → hyperloop-0.6.1}/tests/test_state_contract.py +0 -0
  62. {hyperloop-0.5.0 → hyperloop-0.6.1}/uv.lock +0 -0
@@ -2,6 +2,12 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v0.6.1 (2026-04-15)
6
+
7
+
8
+ ## v0.6.0 (2026-04-15)
9
+
10
+
5
11
  ## v0.5.0 (2026-04-15)
6
12
 
7
13
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperloop
3
- Version: 0.5.0
3
+ Version: 0.6.1
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.5.0"
3
+ version = "0.6.1"
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"
@@ -213,6 +213,12 @@ class GitStateStore:
213
213
  return None
214
214
  return file_path.read_text()
215
215
 
216
+ def set_task_pr(self, task_id: str, pr_url: str) -> None:
217
+ """Set the PR URL on a task file."""
218
+ fm, body = self._read_task_file(task_id)
219
+ fm["pr"] = pr_url
220
+ self._write_task_file(task_id, fm, body)
221
+
216
222
  def commit(self, message: str) -> None:
217
223
  """Stage all changes and create a git commit."""
218
224
  self._git("add", "-A")
@@ -228,6 +228,7 @@ def run(
228
228
  serial_runner=serial_runner,
229
229
  poll_interval=cfg.poll_interval,
230
230
  on_cycle=_on_cycle,
231
+ max_rebase_attempts=cfg.max_rebase_attempts,
231
232
  )
232
233
 
233
234
  # 6. Recover and run
@@ -79,6 +79,7 @@ class Orchestrator:
79
79
  poll_interval: float = 30.0,
80
80
  on_cycle: Callable[[dict[str, object]], None] | None = None,
81
81
  serial_runner: SerialRunner | None = None,
82
+ max_rebase_attempts: int = 3,
82
83
  ) -> None:
83
84
  self._state = state
84
85
  self._runtime = runtime
@@ -91,9 +92,12 @@ class Orchestrator:
91
92
  self._poll_interval = poll_interval
92
93
  self._on_cycle = on_cycle
93
94
  self._serial_runner = serial_runner
95
+ self._max_rebase_attempts = max_rebase_attempts
94
96
 
95
97
  # Active worker tracking: task_id -> (handle, pipeline_position)
96
98
  self._workers: dict[str, tuple[WorkerHandle, PipelinePosition]] = {}
99
+ # Consecutive rebase failure count per task
100
+ self._rebase_attempts: dict[str, int] = {}
97
101
 
98
102
  def run_loop(self, max_cycles: int = 1000) -> str:
99
103
  """Run the orchestrator loop until halt or max_cycles. Returns halt reason."""
@@ -181,6 +185,9 @@ class Orchestrator:
181
185
  pipe_action, new_pos = executor.next_action(position, result)
182
186
 
183
187
  if isinstance(pipe_action, PipelineComplete):
188
+ # Mark PR ready if reviews passed and pipeline is done
189
+ if self._pr_manager is not None and task.pr is not None:
190
+ self._pr_manager.mark_ready(task.pr)
184
191
  self._state.transition_task(task_id, TaskStatus.COMPLETE, phase=None)
185
192
  self._state.clear_findings(task_id)
186
193
 
@@ -211,6 +218,14 @@ class Orchestrator:
211
218
  round=new_round,
212
219
  )
213
220
  else:
221
+ # Advancing forward on PASS — create draft PR if needed
222
+ if self._pr_manager is not None and task.pr is None:
223
+ branch = task.branch or f"worker/{task_id}"
224
+ pr_url = self._pr_manager.create_draft(
225
+ task_id, branch, task.title, task.spec_ref
226
+ )
227
+ self._state.set_task_pr(task_id, pr_url)
228
+
214
229
  self._state.transition_task(
215
230
  task_id,
216
231
  TaskStatus.IN_PROGRESS,
@@ -389,19 +404,62 @@ class Orchestrator:
389
404
  self._merge_local(task.id, branch)
390
405
 
391
406
  def _merge_via_pr(self, task_id: str, pr_url: str, spec_ref: str, branch: str) -> None:
392
- """Merge via GitHub PR: rebase, then squash-merge."""
407
+ """Merge via GitHub PR: mark ready, rebase, then squash-merge."""
393
408
  assert self._pr_manager is not None
394
409
 
410
+ # Mark the draft PR as ready before merging
411
+ self._pr_manager.mark_ready(pr_url)
412
+
395
413
  if not self._pr_manager.rebase_branch(branch, "main"):
396
414
  logger.warning("Rebase conflict for task %s, marking NEEDS_REBASE", task_id)
415
+ self._rebase_attempts[task_id] = self._rebase_attempts.get(task_id, 0) + 1
416
+ if self._rebase_attempts[task_id] >= self._max_rebase_attempts:
417
+ logger.warning(
418
+ "Task %s exceeded max_rebase_attempts (%d), looping back",
419
+ task_id,
420
+ self._max_rebase_attempts,
421
+ )
422
+ self._rebase_attempts.pop(task_id, None)
423
+ task = self._state.get_task(task_id)
424
+ self._state.store_findings(
425
+ task_id, f"Rebase conflict after {self._max_rebase_attempts} attempts"
426
+ )
427
+ self._state.transition_task(
428
+ task_id,
429
+ TaskStatus.IN_PROGRESS,
430
+ phase=Phase("implementer"),
431
+ round=task.round + 1,
432
+ )
433
+ return
397
434
  self._state.transition_task(task_id, TaskStatus.NEEDS_REBASE, phase=Phase("merge-pr"))
398
435
  return
399
436
 
400
437
  if not self._pr_manager.merge(pr_url, task_id, spec_ref):
401
438
  logger.warning("Merge conflict for task %s, marking NEEDS_REBASE", task_id)
439
+ self._rebase_attempts[task_id] = self._rebase_attempts.get(task_id, 0) + 1
440
+ if self._rebase_attempts[task_id] >= self._max_rebase_attempts:
441
+ logger.warning(
442
+ "Task %s exceeded max_rebase_attempts (%d), looping back",
443
+ task_id,
444
+ self._max_rebase_attempts,
445
+ )
446
+ self._rebase_attempts.pop(task_id, None)
447
+ task = self._state.get_task(task_id)
448
+ self._state.store_findings(
449
+ task_id, f"Merge conflict after {self._max_rebase_attempts} attempts"
450
+ )
451
+ self._state.transition_task(
452
+ task_id,
453
+ TaskStatus.IN_PROGRESS,
454
+ phase=Phase("implementer"),
455
+ round=task.round + 1,
456
+ )
457
+ return
402
458
  self._state.transition_task(task_id, TaskStatus.NEEDS_REBASE, phase=Phase("merge-pr"))
403
459
  return
404
460
 
461
+ # Merge succeeded — reset counter
462
+ self._rebase_attempts.pop(task_id, None)
405
463
  self._state.transition_task(task_id, TaskStatus.COMPLETE, phase=None)
406
464
  self._state.clear_findings(task_id)
407
465
  logger.info("Merged PR for task %s", task_id)
@@ -420,12 +478,32 @@ class Orchestrator:
420
478
  check=True,
421
479
  capture_output=True,
422
480
  )
481
+ self._rebase_attempts.pop(task_id, None)
423
482
  self._state.transition_task(task_id, TaskStatus.COMPLETE, phase=None)
424
483
  self._state.clear_findings(task_id)
425
484
  logger.info("Local merge of %s into base branch", task_id)
426
485
  except subprocess.CalledProcessError:
427
486
  logger.warning("Local merge conflict for task %s, marking NEEDS_REBASE", task_id)
428
487
  subprocess.run([*git_cmd, "merge", "--abort"], capture_output=True, check=False)
488
+ self._rebase_attempts[task_id] = self._rebase_attempts.get(task_id, 0) + 1
489
+ if self._rebase_attempts[task_id] >= self._max_rebase_attempts:
490
+ logger.warning(
491
+ "Task %s exceeded max_rebase_attempts (%d), looping back",
492
+ task_id,
493
+ self._max_rebase_attempts,
494
+ )
495
+ self._rebase_attempts.pop(task_id, None)
496
+ task = self._state.get_task(task_id)
497
+ self._state.store_findings(
498
+ task_id, f"Merge conflict after {self._max_rebase_attempts} attempts"
499
+ )
500
+ self._state.transition_task(
501
+ task_id,
502
+ TaskStatus.IN_PROGRESS,
503
+ phase=Phase("implementer"),
504
+ round=task.round + 1,
505
+ )
506
+ return
429
507
  self._state.transition_task(task_id, TaskStatus.NEEDS_REBASE, phase=Phase("merge-pr"))
430
508
 
431
509
  # -----------------------------------------------------------------------
@@ -469,10 +547,10 @@ class Orchestrator:
469
547
  prompt = self._composer.compose(
470
548
  role="pm",
471
549
  task_id="intake",
472
- spec_ref="specs/",
550
+ spec_ref="",
473
551
  findings="",
474
552
  )
475
- prompt += f"\n## Specs to Process\n{spec_list}\n"
553
+ prompt += f"\n\n## Specs to Process\n\n{spec_list}\n"
476
554
 
477
555
  success = self._serial_runner.run("pm", prompt)
478
556
  if success:
@@ -496,7 +574,7 @@ class Orchestrator:
496
574
  prompt = self._composer.compose(
497
575
  role="process-improver",
498
576
  task_id="process-improvement",
499
- spec_ref="specs/prompts",
577
+ spec_ref="",
500
578
  findings=findings_text,
501
579
  )
502
580
 
@@ -60,6 +60,10 @@ class StateStore(Protocol):
60
60
  """Read a file from trunk. Returns None if the file does not exist."""
61
61
  ...
62
62
 
63
+ def set_task_pr(self, task_id: str, pr_url: str) -> None:
64
+ """Set the PR URL on a task."""
65
+ ...
66
+
63
67
  def commit(self, message: str) -> None:
64
68
  """Persist all pending state changes."""
65
69
  ...
@@ -81,6 +81,7 @@ def _make_orchestrator(
81
81
  composer: PromptComposer | None = None,
82
82
  poll_interval: float = 0,
83
83
  on_cycle: Callable[[dict[str, object]], None] | None = None,
84
+ max_rebase_attempts: int = 3,
84
85
  ) -> Orchestrator:
85
86
  return Orchestrator(
86
87
  state=state,
@@ -92,6 +93,7 @@ def _make_orchestrator(
92
93
  composer=composer,
93
94
  poll_interval=poll_interval,
94
95
  on_cycle=on_cycle,
96
+ max_rebase_attempts=max_rebase_attempts,
95
97
  )
96
98
 
97
99
 
@@ -1035,3 +1037,235 @@ class TestPromptComposition:
1035
1037
 
1036
1038
  task = state.get_task("task-001")
1037
1039
  assert task.status == TaskStatus.IN_PROGRESS
1040
+
1041
+
1042
+ class TestPRLifecycle:
1043
+ """PR lifecycle: draft created at first review step, marked ready on completion."""
1044
+
1045
+ MERGE_PROCESS = Process(
1046
+ name="merge-process",
1047
+ intake=(),
1048
+ pipeline=(
1049
+ LoopStep(
1050
+ steps=(
1051
+ RoleStep(role="implementer", on_pass=None, on_fail=None),
1052
+ RoleStep(role="verifier", on_pass=None, on_fail=None),
1053
+ ),
1054
+ ),
1055
+ ActionStep(action="merge-pr"),
1056
+ ),
1057
+ )
1058
+
1059
+ def test_draft_pr_created_when_task_advances_to_verifier(self) -> None:
1060
+ """A draft PR is created when a task advances from implementer to verifier."""
1061
+ state = InMemoryStateStore()
1062
+ runtime = InMemoryRuntime()
1063
+ pr_mgr = FakePRManager(repo="org/repo")
1064
+
1065
+ state.add_task(_task(id="task-001", branch="worker/task-001"))
1066
+
1067
+ orch = _make_orchestrator(state, runtime, process=self.MERGE_PROCESS, pr_manager=pr_mgr)
1068
+
1069
+ # Cycle 1: spawn implementer
1070
+ orch.run_cycle()
1071
+ assert state.get_task("task-001").phase == Phase("implementer")
1072
+ assert state.get_task("task-001").pr is None
1073
+
1074
+ # Implementer passes
1075
+ runtime.set_poll_status("task-001", "done")
1076
+ runtime.set_result("task-001", PASS_RESULT)
1077
+
1078
+ # Cycle 2: reap implementer, advance to verifier -> draft PR created
1079
+ orch.run_cycle()
1080
+ task = state.get_task("task-001")
1081
+ assert task.phase == Phase("verifier")
1082
+ assert task.pr is not None
1083
+ assert "github.com" in task.pr
1084
+
1085
+ # The PR should be a draft
1086
+ assert pr_mgr.is_draft(task.pr)
1087
+
1088
+ def test_mark_ready_called_when_task_completes_pipeline(self) -> None:
1089
+ """mark_ready is called when a task reaches the merge step."""
1090
+ state = InMemoryStateStore()
1091
+ runtime = InMemoryRuntime()
1092
+ pr_mgr = FakePRManager(repo="org/repo")
1093
+
1094
+ state.add_task(
1095
+ _task(
1096
+ id="task-001",
1097
+ status=TaskStatus.IN_PROGRESS,
1098
+ phase=Phase("merge-pr"),
1099
+ branch="worker/task-001",
1100
+ )
1101
+ )
1102
+ # Create and set PR on the task
1103
+ pr_url = pr_mgr.create_draft(
1104
+ "task-001", "worker/task-001", "Task task-001", "specs/task-001.md"
1105
+ )
1106
+ state.set_task_pr("task-001", pr_url)
1107
+
1108
+ orch = _make_orchestrator(state, runtime, process=self.MERGE_PROCESS, pr_manager=pr_mgr)
1109
+ orch.run_cycle()
1110
+
1111
+ # mark_ready should have been called before merge
1112
+ assert pr_url in pr_mgr.marked_ready
1113
+ # And the task should be complete (merged)
1114
+ assert state.get_task("task-001").status == TaskStatus.COMPLETE
1115
+
1116
+ def test_no_pr_created_when_pr_manager_is_none(self) -> None:
1117
+ """No PR is created when pr_manager is not set."""
1118
+ state = InMemoryStateStore()
1119
+ runtime = InMemoryRuntime()
1120
+
1121
+ state.add_task(_task(id="task-001", branch="worker/task-001"))
1122
+
1123
+ orch = _make_orchestrator(state, runtime, process=self.MERGE_PROCESS, pr_manager=None)
1124
+
1125
+ # Cycle 1: spawn implementer
1126
+ orch.run_cycle()
1127
+
1128
+ # Implementer passes
1129
+ runtime.set_poll_status("task-001", "done")
1130
+ runtime.set_result("task-001", PASS_RESULT)
1131
+
1132
+ # Cycle 2: advance to verifier — no PR should be created
1133
+ orch.run_cycle()
1134
+ task = state.get_task("task-001")
1135
+ assert task.phase == Phase("verifier")
1136
+ assert task.pr is None
1137
+
1138
+ def test_draft_pr_not_recreated_on_loop_back(self) -> None:
1139
+ """When a task loops back (verifier fails), the existing PR is reused."""
1140
+ state = InMemoryStateStore()
1141
+ runtime = InMemoryRuntime()
1142
+ pr_mgr = FakePRManager(repo="org/repo")
1143
+
1144
+ state.add_task(_task(id="task-001", branch="worker/task-001"))
1145
+
1146
+ orch = _make_orchestrator(state, runtime, process=self.MERGE_PROCESS, pr_manager=pr_mgr)
1147
+
1148
+ # Cycle 1: spawn implementer
1149
+ orch.run_cycle()
1150
+
1151
+ # Implementer passes -> advance to verifier, create draft
1152
+ runtime.set_poll_status("task-001", "done")
1153
+ runtime.set_result("task-001", PASS_RESULT)
1154
+ orch.run_cycle()
1155
+
1156
+ first_pr = state.get_task("task-001").pr
1157
+ assert first_pr is not None
1158
+
1159
+ # Verifier fails -> loops back to implementer
1160
+ runtime.set_poll_status("task-001", "done")
1161
+ runtime.set_result("task-001", FAIL_RESULT)
1162
+ orch.run_cycle()
1163
+
1164
+ # Implementer passes again -> advance to verifier again
1165
+ runtime.set_poll_status("task-001", "done")
1166
+ runtime.set_result("task-001", PASS_RESULT)
1167
+ orch.run_cycle()
1168
+
1169
+ # PR should be the same (not recreated)
1170
+ assert state.get_task("task-001").pr == first_pr
1171
+
1172
+
1173
+ class TestMaxRebaseAttempts:
1174
+ """max_rebase_attempts: after N consecutive rebase failures, task loops back."""
1175
+
1176
+ MERGE_PROCESS = Process(
1177
+ name="merge-process",
1178
+ intake=(),
1179
+ pipeline=(
1180
+ LoopStep(
1181
+ steps=(
1182
+ RoleStep(role="implementer", on_pass=None, on_fail=None),
1183
+ RoleStep(role="verifier", on_pass=None, on_fail=None),
1184
+ ),
1185
+ ),
1186
+ ActionStep(action="merge-pr"),
1187
+ ),
1188
+ )
1189
+
1190
+ def test_rebase_failure_exceeds_max_attempts_loops_task_back(self) -> None:
1191
+ """After max_rebase_attempts consecutive rebase failures, task loops back."""
1192
+ state = InMemoryStateStore()
1193
+ runtime = InMemoryRuntime()
1194
+ pr_mgr = FakePRManager(repo="org/repo")
1195
+
1196
+ pr_url = pr_mgr.create_draft("task-001", "worker/task-001", "Widget", "specs/task-001.md")
1197
+ pr_mgr.set_rebase_fails("worker/task-001")
1198
+
1199
+ state.add_task(
1200
+ _task(
1201
+ id="task-001",
1202
+ status=TaskStatus.IN_PROGRESS,
1203
+ phase=Phase("merge-pr"),
1204
+ branch="worker/task-001",
1205
+ )
1206
+ )
1207
+ state.set_task_pr("task-001", pr_url)
1208
+
1209
+ orch = _make_orchestrator(
1210
+ state,
1211
+ runtime,
1212
+ process=self.MERGE_PROCESS,
1213
+ pr_manager=pr_mgr,
1214
+ max_rebase_attempts=3,
1215
+ )
1216
+
1217
+ # First two failures: task stays in NEEDS_REBASE / rebase-resolver cycle
1218
+ for _ in range(2):
1219
+ orch.run_cycle()
1220
+ task = state.get_task("task-001")
1221
+ # Should be NEEDS_REBASE or spawning rebase-resolver
1222
+ assert task.status in (TaskStatus.NEEDS_REBASE, TaskStatus.IN_PROGRESS)
1223
+
1224
+ # If rebase-resolver was spawned, let it complete and return to merge-pr
1225
+ if task.phase == Phase("rebase-resolver"):
1226
+ runtime.set_poll_status("task-001", "done")
1227
+ runtime.set_result("task-001", PASS_RESULT)
1228
+ orch.run_cycle()
1229
+ # Manually transition back to merge-pr for next attempt
1230
+ state.transition_task("task-001", TaskStatus.IN_PROGRESS, phase=Phase("merge-pr"))
1231
+
1232
+ # Third failure: should exceed max_rebase_attempts -> loop back
1233
+ orch.run_cycle()
1234
+ task = state.get_task("task-001")
1235
+ assert task.status == TaskStatus.IN_PROGRESS
1236
+ assert task.round == 1 # round incremented
1237
+ assert task.phase == Phase("implementer") # looped back to start
1238
+
1239
+ def test_successful_merge_resets_rebase_counter(self) -> None:
1240
+ """A successful merge resets the rebase attempt counter for a task."""
1241
+ state = InMemoryStateStore()
1242
+ runtime = InMemoryRuntime()
1243
+ pr_mgr = FakePRManager(repo="org/repo")
1244
+
1245
+ pr_url = pr_mgr.create_draft("task-001", "worker/task-001", "Widget", "specs/task-001.md")
1246
+
1247
+ state.add_task(
1248
+ _task(
1249
+ id="task-001",
1250
+ status=TaskStatus.IN_PROGRESS,
1251
+ phase=Phase("merge-pr"),
1252
+ branch="worker/task-001",
1253
+ )
1254
+ )
1255
+ state.set_task_pr("task-001", pr_url)
1256
+
1257
+ orch = _make_orchestrator(
1258
+ state,
1259
+ runtime,
1260
+ process=self.MERGE_PROCESS,
1261
+ pr_manager=pr_mgr,
1262
+ max_rebase_attempts=3,
1263
+ )
1264
+
1265
+ # Merge succeeds
1266
+ orch.run_cycle()
1267
+ task = state.get_task("task-001")
1268
+ assert task.status == TaskStatus.COMPLETE
1269
+
1270
+ # The counter should be reset (no external way to verify directly,
1271
+ # but the task completed successfully — that's the behavior we want)
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