hyperloop 0.5.0__tar.gz → 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {hyperloop-0.5.0 → hyperloop-0.6.0}/CHANGELOG.md +3 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/PKG-INFO +1 -1
- {hyperloop-0.5.0 → hyperloop-0.6.0}/pyproject.toml +1 -1
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/adapters/git_state.py +6 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/cli.py +1 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/loop.py +79 -1
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/ports/state.py +4 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/test_loop.py +234 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/.github/workflows/ci.yaml +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/.github/workflows/release.yaml +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/.gitignore +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/.pre-commit-config.yaml +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/.python-version +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/CLAUDE.md +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/README.md +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/base/implementer.yaml +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/base/kustomization.yaml +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/base/pm.yaml +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/base/process-improver.yaml +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/base/process.yaml +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/base/rebase-resolver.yaml +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/base/verifier.yaml +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/specs/spec.md +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/__init__.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/__main__.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/adapters/__init__.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/adapters/local.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/adapters/serial.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/compose.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/config.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/domain/__init__.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/domain/decide.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/domain/deps.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/domain/model.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/domain/pipeline.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/ports/__init__.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/ports/pr.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/ports/runtime.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/ports/serial.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/src/hyperloop/pr.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/__init__.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/fakes/__init__.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/fakes/pr.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/fakes/runtime.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/fakes/serial.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/fakes/state.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/test_cli.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/test_compose.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/test_config.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/test_decide.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/test_deps.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/test_e2e.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/test_fakes.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/test_git_state.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/test_local_runtime.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/test_model.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/test_pipeline.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/test_pr.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/test_serial_agents.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/test_smoke.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/tests/test_state_contract.py +0 -0
- {hyperloop-0.5.0 → hyperloop-0.6.0}/uv.lock +0 -0
|
@@ -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")
|
|
@@ -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
|
# -----------------------------------------------------------------------
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|