hyperloop 0.1.0__py3-none-any.whl
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/__init__.py +0 -0
- hyperloop/__main__.py +5 -0
- hyperloop/adapters/__init__.py +0 -0
- hyperloop/adapters/git_state.py +254 -0
- hyperloop/adapters/local.py +273 -0
- hyperloop/cli.py +166 -0
- hyperloop/compose.py +126 -0
- hyperloop/config.py +161 -0
- hyperloop/domain/__init__.py +0 -0
- hyperloop/domain/decide.py +104 -0
- hyperloop/domain/deps.py +66 -0
- hyperloop/domain/model.py +221 -0
- hyperloop/domain/pipeline.py +306 -0
- hyperloop/loop.py +510 -0
- hyperloop/ports/__init__.py +0 -0
- hyperloop/ports/pr.py +32 -0
- hyperloop/ports/runtime.py +37 -0
- hyperloop/ports/state.py +61 -0
- hyperloop/pr.py +212 -0
- hyperloop-0.1.0.dist-info/METADATA +253 -0
- hyperloop-0.1.0.dist-info/RECORD +23 -0
- hyperloop-0.1.0.dist-info/WHEEL +4 -0
- hyperloop-0.1.0.dist-info/entry_points.txt +2 -0
hyperloop/loop.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""Orchestrator loop — wires decide, pipeline, state store, and runtime.
|
|
2
|
+
|
|
3
|
+
Runs the serial section from the spec: reap finished workers, check for halt,
|
|
4
|
+
run stubs for process-improver/intake, poll gates, merge PRs, decide what to
|
|
5
|
+
spawn, update state, spawn workers, and check convergence.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from hyperloop.domain.decide import decide
|
|
14
|
+
from hyperloop.domain.model import (
|
|
15
|
+
ActionStep,
|
|
16
|
+
GateStep,
|
|
17
|
+
Halt,
|
|
18
|
+
LoopStep,
|
|
19
|
+
Phase,
|
|
20
|
+
PipelinePosition,
|
|
21
|
+
ReapWorker,
|
|
22
|
+
RoleStep,
|
|
23
|
+
SpawnWorker,
|
|
24
|
+
TaskStatus,
|
|
25
|
+
Verdict,
|
|
26
|
+
WorkerHandle,
|
|
27
|
+
WorkerState,
|
|
28
|
+
World,
|
|
29
|
+
)
|
|
30
|
+
from hyperloop.domain.pipeline import (
|
|
31
|
+
PerformAction,
|
|
32
|
+
PipelineComplete,
|
|
33
|
+
PipelineExecutor,
|
|
34
|
+
PipelineFailed,
|
|
35
|
+
SpawnRole,
|
|
36
|
+
WaitForGate,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from hyperloop.compose import PromptComposer
|
|
41
|
+
from hyperloop.domain.model import PipelineStep, Process, Task, WorkerResult
|
|
42
|
+
from hyperloop.ports.pr import PRPort
|
|
43
|
+
from hyperloop.ports.runtime import Runtime
|
|
44
|
+
from hyperloop.ports.state import StateStore
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Orchestrator:
|
|
50
|
+
"""Main orchestrator loop — one serial section per cycle.
|
|
51
|
+
|
|
52
|
+
The orchestrator tracks active workers and their pipeline positions.
|
|
53
|
+
Each cycle follows the spec's serial section order:
|
|
54
|
+
1. Reap finished workers
|
|
55
|
+
2. Halt if any task failed
|
|
56
|
+
3. Process-improver (stub)
|
|
57
|
+
4. Intake (stub)
|
|
58
|
+
5. Poll gates
|
|
59
|
+
6. Merge ready PRs
|
|
60
|
+
7. Decide what to spawn (via decide())
|
|
61
|
+
8. Update state (transition tasks, commit)
|
|
62
|
+
9. Spawn workers
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
state: StateStore,
|
|
68
|
+
runtime: Runtime,
|
|
69
|
+
process: Process,
|
|
70
|
+
max_workers: int = 6,
|
|
71
|
+
max_rounds: int = 50,
|
|
72
|
+
pr_manager: PRPort | None = None,
|
|
73
|
+
composer: PromptComposer | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self._state = state
|
|
76
|
+
self._runtime = runtime
|
|
77
|
+
self._process = process
|
|
78
|
+
self._max_workers = max_workers
|
|
79
|
+
self._max_rounds = max_rounds
|
|
80
|
+
self._pr_manager = pr_manager
|
|
81
|
+
self._composer = composer
|
|
82
|
+
|
|
83
|
+
# Active worker tracking: task_id -> (handle, pipeline_position)
|
|
84
|
+
self._workers: dict[str, tuple[WorkerHandle, PipelinePosition]] = {}
|
|
85
|
+
|
|
86
|
+
def run_loop(self, max_cycles: int = 1000) -> str:
|
|
87
|
+
"""Run the orchestrator loop until halt or max_cycles. Returns halt reason."""
|
|
88
|
+
for _ in range(max_cycles):
|
|
89
|
+
reason = self.run_cycle()
|
|
90
|
+
if reason is not None:
|
|
91
|
+
return reason
|
|
92
|
+
return "max_cycles exhausted"
|
|
93
|
+
|
|
94
|
+
def recover(self) -> None:
|
|
95
|
+
"""Recover from a crash by reconciling persisted state with runtime.
|
|
96
|
+
|
|
97
|
+
Reads all tasks from the state store. For IN_PROGRESS tasks with no
|
|
98
|
+
active worker: checks for orphaned workers via the runtime and cancels
|
|
99
|
+
them, then adds the task to internal tracking for re-spawn.
|
|
100
|
+
"""
|
|
101
|
+
world = self._state.get_world()
|
|
102
|
+
|
|
103
|
+
for task in world.tasks.values():
|
|
104
|
+
if task.status != TaskStatus.IN_PROGRESS:
|
|
105
|
+
continue
|
|
106
|
+
if task.id in self._workers:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
branch = task.branch or f"worker/{task.id}"
|
|
110
|
+
|
|
111
|
+
# Check for orphaned workers left from a previous session
|
|
112
|
+
orphan = self._runtime.find_orphan(task.id, branch)
|
|
113
|
+
if orphan is not None:
|
|
114
|
+
self._runtime.cancel(orphan)
|
|
115
|
+
|
|
116
|
+
# We don't add to _workers here — instead leave it workerless
|
|
117
|
+
# so decide() will emit a SpawnWorker for it next cycle.
|
|
118
|
+
logger.info(
|
|
119
|
+
"recover: task %s is IN_PROGRESS at phase %s, will re-spawn",
|
|
120
|
+
task.id,
|
|
121
|
+
task.phase,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def run_cycle(self) -> str | None:
|
|
125
|
+
"""Run one serial section cycle.
|
|
126
|
+
|
|
127
|
+
Returns a halt reason string if the loop should stop, or None to continue.
|
|
128
|
+
"""
|
|
129
|
+
executor = PipelineExecutor(self._process.pipeline)
|
|
130
|
+
|
|
131
|
+
# Cache world snapshot once per cycle — augmented with worker state
|
|
132
|
+
world = self._build_world()
|
|
133
|
+
|
|
134
|
+
# ---- 1. Reap finished workers ----------------------------------------
|
|
135
|
+
reaped_results: dict[str, WorkerResult] = {}
|
|
136
|
+
had_failures_this_cycle = False
|
|
137
|
+
|
|
138
|
+
actions = decide(world, self._max_workers, self._max_rounds)
|
|
139
|
+
|
|
140
|
+
# Process ReapWorker actions from decide()
|
|
141
|
+
for action in actions:
|
|
142
|
+
if isinstance(action, ReapWorker):
|
|
143
|
+
task_id = action.task_id
|
|
144
|
+
if task_id in self._workers:
|
|
145
|
+
handle, _pos = self._workers[task_id]
|
|
146
|
+
result = self._runtime.reap(handle)
|
|
147
|
+
reaped_results[task_id] = result
|
|
148
|
+
|
|
149
|
+
# ---- 2. Process reaped results through pipeline ----------------------
|
|
150
|
+
to_spawn: list[tuple[str, str, PipelinePosition]] = []
|
|
151
|
+
halt_reason: str | None = None
|
|
152
|
+
|
|
153
|
+
for task_id, result in reaped_results.items():
|
|
154
|
+
_handle, position = self._workers.pop(task_id)
|
|
155
|
+
task = self._state.get_task(task_id)
|
|
156
|
+
|
|
157
|
+
pipe_action, new_pos = executor.next_action(position, result)
|
|
158
|
+
|
|
159
|
+
if isinstance(pipe_action, PipelineComplete):
|
|
160
|
+
self._state.transition_task(task_id, TaskStatus.COMPLETE, phase=None)
|
|
161
|
+
self._state.clear_findings(task_id)
|
|
162
|
+
|
|
163
|
+
elif isinstance(pipe_action, PipelineFailed):
|
|
164
|
+
self._state.transition_task(task_id, TaskStatus.FAILED, phase=None)
|
|
165
|
+
self._state.store_findings(task_id, result.detail)
|
|
166
|
+
halt_reason = f"task {task_id} pipeline failed: {pipe_action.reason}"
|
|
167
|
+
had_failures_this_cycle = True
|
|
168
|
+
|
|
169
|
+
elif isinstance(pipe_action, SpawnRole):
|
|
170
|
+
if result.verdict in (Verdict.FAIL, Verdict.ERROR, Verdict.TIMEOUT):
|
|
171
|
+
had_failures_this_cycle = True
|
|
172
|
+
new_round = task.round + 1
|
|
173
|
+
if new_round >= self._max_rounds:
|
|
174
|
+
self._state.transition_task(
|
|
175
|
+
task_id,
|
|
176
|
+
TaskStatus.FAILED,
|
|
177
|
+
phase=None,
|
|
178
|
+
round=new_round,
|
|
179
|
+
)
|
|
180
|
+
halt_reason = f"task {task_id} exceeded max_rounds ({self._max_rounds})"
|
|
181
|
+
continue
|
|
182
|
+
self._state.store_findings(task_id, result.detail)
|
|
183
|
+
self._state.transition_task(
|
|
184
|
+
task_id,
|
|
185
|
+
TaskStatus.IN_PROGRESS,
|
|
186
|
+
phase=Phase(pipe_action.role),
|
|
187
|
+
round=new_round,
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
self._state.transition_task(
|
|
191
|
+
task_id,
|
|
192
|
+
TaskStatus.IN_PROGRESS,
|
|
193
|
+
phase=Phase(pipe_action.role),
|
|
194
|
+
)
|
|
195
|
+
to_spawn.append((task_id, pipe_action.role, new_pos))
|
|
196
|
+
|
|
197
|
+
else:
|
|
198
|
+
# WaitForGate, PerformAction — record phase, don't spawn
|
|
199
|
+
phase_name = _phase_for_action(pipe_action)
|
|
200
|
+
self._state.transition_task(
|
|
201
|
+
task_id,
|
|
202
|
+
TaskStatus.IN_PROGRESS,
|
|
203
|
+
phase=Phase(phase_name) if phase_name else None,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# ---- 2b. Halt if any task failed -------------------------------------
|
|
207
|
+
if halt_reason is not None:
|
|
208
|
+
self._state.commit("orchestrator: halt")
|
|
209
|
+
return halt_reason
|
|
210
|
+
|
|
211
|
+
# ---- 3. Process-improver (stub) --------------------------------------
|
|
212
|
+
if had_failures_this_cycle:
|
|
213
|
+
logger.info("process-improver: stub — would run on trunk with this cycle's findings")
|
|
214
|
+
|
|
215
|
+
# ---- 4. Intake (stub) ------------------------------------------------
|
|
216
|
+
logger.debug("intake: stub — would run if configured and new specs exist")
|
|
217
|
+
|
|
218
|
+
# ---- 5. Poll gates ---------------------------------------------------
|
|
219
|
+
self._poll_gates(executor, to_spawn)
|
|
220
|
+
|
|
221
|
+
# ---- 6. Merge ready PRs ----------------------------------------------
|
|
222
|
+
self._merge_ready_prs()
|
|
223
|
+
|
|
224
|
+
# ---- 7. Decide what to spawn (via decide()) -------------------------
|
|
225
|
+
# Rebuild world after reaping + gates + merges (tasks may have changed)
|
|
226
|
+
world_after_reap = self._build_world()
|
|
227
|
+
spawn_actions = decide(world_after_reap, self._max_workers, self._max_rounds)
|
|
228
|
+
|
|
229
|
+
# Check for Halt from decide() (convergence or max_rounds)
|
|
230
|
+
for action in spawn_actions:
|
|
231
|
+
if isinstance(action, Halt):
|
|
232
|
+
self._state.commit("orchestrator: halt")
|
|
233
|
+
return action.reason
|
|
234
|
+
|
|
235
|
+
# Process SpawnWorker actions
|
|
236
|
+
already_spawning = {tid for tid, _role, _pos in to_spawn}
|
|
237
|
+
for action in spawn_actions:
|
|
238
|
+
if isinstance(action, SpawnWorker) and action.task_id not in already_spawning:
|
|
239
|
+
task = self._state.get_task(action.task_id)
|
|
240
|
+
|
|
241
|
+
if action.role == "rebase-resolver":
|
|
242
|
+
pos = executor.initial_position()
|
|
243
|
+
self._state.transition_task(
|
|
244
|
+
action.task_id,
|
|
245
|
+
TaskStatus.IN_PROGRESS,
|
|
246
|
+
phase=Phase("rebase-resolver"),
|
|
247
|
+
)
|
|
248
|
+
to_spawn.append((action.task_id, "rebase-resolver", pos))
|
|
249
|
+
elif task.status == TaskStatus.IN_PROGRESS and task.phase is not None:
|
|
250
|
+
pos = self._position_from_phase(executor, task)
|
|
251
|
+
to_spawn.append((action.task_id, str(task.phase), pos))
|
|
252
|
+
else:
|
|
253
|
+
pos = executor.initial_position()
|
|
254
|
+
pipe_action, pos = executor.next_action(pos, result=None)
|
|
255
|
+
if isinstance(pipe_action, SpawnRole):
|
|
256
|
+
self._state.transition_task(
|
|
257
|
+
action.task_id,
|
|
258
|
+
TaskStatus.IN_PROGRESS,
|
|
259
|
+
phase=Phase(pipe_action.role),
|
|
260
|
+
)
|
|
261
|
+
to_spawn.append((action.task_id, pipe_action.role, pos))
|
|
262
|
+
|
|
263
|
+
# ---- 8. Commit state -------------------------------------------------
|
|
264
|
+
if reaped_results or to_spawn:
|
|
265
|
+
self._state.commit("orchestrator: cycle update")
|
|
266
|
+
|
|
267
|
+
# ---- 9. Spawn workers ------------------------------------------------
|
|
268
|
+
for task_id, role, position in to_spawn:
|
|
269
|
+
task = self._state.get_task(task_id)
|
|
270
|
+
branch = task.branch or f"worker/{task_id}"
|
|
271
|
+
prompt = self._compose_prompt(task, role)
|
|
272
|
+
handle = self._runtime.spawn(task_id, role, prompt=prompt, branch=branch)
|
|
273
|
+
self._workers[task_id] = (handle, position)
|
|
274
|
+
|
|
275
|
+
# ---- Check convergence -----------------------------------------------
|
|
276
|
+
all_tasks = self._state.get_world().tasks
|
|
277
|
+
if not all_tasks:
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
all_complete = all(t.status == TaskStatus.COMPLETE for t in all_tasks.values())
|
|
281
|
+
no_workers = len(self._workers) == 0
|
|
282
|
+
|
|
283
|
+
if all_complete and no_workers:
|
|
284
|
+
return "all tasks complete"
|
|
285
|
+
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
# -----------------------------------------------------------------------
|
|
289
|
+
# Gate polling and merge
|
|
290
|
+
# -----------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def _poll_gates(
|
|
293
|
+
self,
|
|
294
|
+
executor: PipelineExecutor,
|
|
295
|
+
to_spawn: list[tuple[str, str, PipelinePosition]],
|
|
296
|
+
) -> None:
|
|
297
|
+
"""Poll gates for tasks at a gate step. If cleared, advance the task."""
|
|
298
|
+
if self._pr_manager is None:
|
|
299
|
+
logger.debug("gates: no PRManager — skipping gate polling")
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
all_tasks = self._state.get_world().tasks
|
|
303
|
+
for task in all_tasks.values():
|
|
304
|
+
if task.status != TaskStatus.IN_PROGRESS:
|
|
305
|
+
continue
|
|
306
|
+
if task.phase is None:
|
|
307
|
+
continue
|
|
308
|
+
if task.pr is None:
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
# Check if this phase corresponds to a gate step in the pipeline
|
|
312
|
+
pos = self._find_position_for_step(executor, str(task.phase))
|
|
313
|
+
if pos is None:
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
step = PipelineExecutor.resolve_step(executor.pipeline, pos.path)
|
|
317
|
+
if not isinstance(step, GateStep):
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
# Task is at a gate — poll it
|
|
321
|
+
cleared = self._pr_manager.check_gate(task.pr, step.gate)
|
|
322
|
+
if not cleared:
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
# Gate cleared — advance to next pipeline step
|
|
326
|
+
logger.info("Gate '%s' cleared for task %s", step.gate, task.id)
|
|
327
|
+
advanced = PipelineExecutor.advance_from(executor.pipeline, pos.path)
|
|
328
|
+
if advanced is not None:
|
|
329
|
+
next_action, new_pos = advanced
|
|
330
|
+
phase_name = _phase_for_pipe_action(next_action)
|
|
331
|
+
self._state.transition_task(
|
|
332
|
+
task.id,
|
|
333
|
+
TaskStatus.IN_PROGRESS,
|
|
334
|
+
phase=Phase(phase_name) if phase_name else None,
|
|
335
|
+
)
|
|
336
|
+
if isinstance(next_action, SpawnRole):
|
|
337
|
+
to_spawn.append((task.id, next_action.role, new_pos))
|
|
338
|
+
else:
|
|
339
|
+
self._state.transition_task(task.id, TaskStatus.COMPLETE, phase=None)
|
|
340
|
+
self._state.clear_findings(task.id)
|
|
341
|
+
|
|
342
|
+
def _merge_ready_prs(self) -> None:
|
|
343
|
+
"""Merge PRs for tasks at the merge-pr action step.
|
|
344
|
+
|
|
345
|
+
Rebases branch first; on conflict transitions to NEEDS_REBASE.
|
|
346
|
+
"""
|
|
347
|
+
if self._pr_manager is None:
|
|
348
|
+
logger.debug("merge: no PRManager — skipping merge")
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
all_tasks = self._state.get_world().tasks
|
|
352
|
+
for task in all_tasks.values():
|
|
353
|
+
if task.status != TaskStatus.IN_PROGRESS:
|
|
354
|
+
continue
|
|
355
|
+
if task.phase != Phase("merge-pr"):
|
|
356
|
+
continue
|
|
357
|
+
if task.pr is None:
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
branch = task.branch or f"worker/{task.id}"
|
|
361
|
+
|
|
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
|
|
369
|
+
|
|
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
|
|
377
|
+
|
|
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)
|
|
382
|
+
|
|
383
|
+
# -----------------------------------------------------------------------
|
|
384
|
+
# World building
|
|
385
|
+
# -----------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
def _build_world(self) -> World:
|
|
388
|
+
"""Build a World snapshot including current worker state from runtime."""
|
|
389
|
+
base_world = self._state.get_world()
|
|
390
|
+
workers: dict[str, WorkerState] = {}
|
|
391
|
+
for task_id, (handle, _pos) in self._workers.items():
|
|
392
|
+
poll_status = self._runtime.poll(handle)
|
|
393
|
+
workers[task_id] = WorkerState(
|
|
394
|
+
task_id=task_id,
|
|
395
|
+
role=handle.role,
|
|
396
|
+
status=poll_status,
|
|
397
|
+
)
|
|
398
|
+
return World(
|
|
399
|
+
tasks=base_world.tasks,
|
|
400
|
+
workers=workers,
|
|
401
|
+
epoch=base_world.epoch,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# -----------------------------------------------------------------------
|
|
405
|
+
# Prompt composition
|
|
406
|
+
# -----------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
def _compose_prompt(self, task: Task, role: str) -> str:
|
|
409
|
+
"""Compose a prompt for a worker using PromptComposer if available."""
|
|
410
|
+
if self._composer is None:
|
|
411
|
+
return ""
|
|
412
|
+
|
|
413
|
+
findings = self._state.get_findings(task.id)
|
|
414
|
+
return self._composer.compose(
|
|
415
|
+
role=role,
|
|
416
|
+
task_id=task.id,
|
|
417
|
+
spec_ref=task.spec_ref,
|
|
418
|
+
findings=findings,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# -----------------------------------------------------------------------
|
|
422
|
+
# Pipeline position helpers
|
|
423
|
+
# -----------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
def _position_from_phase(self, executor: PipelineExecutor, task: Task) -> PipelinePosition:
|
|
426
|
+
"""Determine pipeline position from a task's current phase."""
|
|
427
|
+
if task.phase is not None:
|
|
428
|
+
pos = self._find_position_for_role(executor, str(task.phase))
|
|
429
|
+
if pos is not None:
|
|
430
|
+
return pos
|
|
431
|
+
return executor.initial_position()
|
|
432
|
+
|
|
433
|
+
@staticmethod
|
|
434
|
+
def _find_position_for_role(executor: PipelineExecutor, role: str) -> PipelinePosition | None:
|
|
435
|
+
"""Walk the pipeline for a RoleStep matching the given role name."""
|
|
436
|
+
|
|
437
|
+
def _search(
|
|
438
|
+
steps: tuple[PipelineStep, ...], prefix: tuple[int, ...]
|
|
439
|
+
) -> PipelinePosition | None:
|
|
440
|
+
for i, step in enumerate(steps):
|
|
441
|
+
path = (*prefix, i)
|
|
442
|
+
if isinstance(step, RoleStep) and step.role == role:
|
|
443
|
+
return PipelinePosition(path=path)
|
|
444
|
+
if isinstance(step, LoopStep):
|
|
445
|
+
found = _search(step.steps, path)
|
|
446
|
+
if found is not None:
|
|
447
|
+
return found
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
return _search(executor.pipeline, ())
|
|
451
|
+
|
|
452
|
+
@staticmethod
|
|
453
|
+
def _find_position_for_step(
|
|
454
|
+
executor: PipelineExecutor, phase_name: str
|
|
455
|
+
) -> PipelinePosition | None:
|
|
456
|
+
"""Walk the pipeline for any step whose name matches phase_name.
|
|
457
|
+
|
|
458
|
+
Searches depth-first across RoleStep, GateStep, and ActionStep.
|
|
459
|
+
"""
|
|
460
|
+
|
|
461
|
+
def _step_name(step: PipelineStep) -> str | None:
|
|
462
|
+
if isinstance(step, RoleStep):
|
|
463
|
+
return step.role
|
|
464
|
+
if isinstance(step, GateStep):
|
|
465
|
+
return step.gate
|
|
466
|
+
if isinstance(step, ActionStep):
|
|
467
|
+
return step.action
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
def _search(
|
|
471
|
+
steps: tuple[PipelineStep, ...], prefix: tuple[int, ...]
|
|
472
|
+
) -> PipelinePosition | None:
|
|
473
|
+
for i, step in enumerate(steps):
|
|
474
|
+
path = (*prefix, i)
|
|
475
|
+
if _step_name(step) == phase_name:
|
|
476
|
+
return PipelinePosition(path=path)
|
|
477
|
+
if isinstance(step, LoopStep):
|
|
478
|
+
found = _search(step.steps, path)
|
|
479
|
+
if found is not None:
|
|
480
|
+
return found
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
return _search(executor.pipeline, ())
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# ---------------------------------------------------------------------------
|
|
487
|
+
# Module-level helpers
|
|
488
|
+
# ---------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _phase_for_action(action: object) -> str | None:
|
|
492
|
+
"""Extract a phase name from a pipeline action (WaitForGate or PerformAction)."""
|
|
493
|
+
if isinstance(action, WaitForGate):
|
|
494
|
+
return action.gate
|
|
495
|
+
if isinstance(action, PerformAction):
|
|
496
|
+
return action.action
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _phase_for_pipe_action(
|
|
501
|
+
action: SpawnRole | WaitForGate | PerformAction | PipelineComplete | PipelineFailed,
|
|
502
|
+
) -> str | None:
|
|
503
|
+
"""Extract a phase name from any pipeline action type."""
|
|
504
|
+
if isinstance(action, SpawnRole):
|
|
505
|
+
return action.role
|
|
506
|
+
if isinstance(action, WaitForGate):
|
|
507
|
+
return action.gate
|
|
508
|
+
if isinstance(action, PerformAction):
|
|
509
|
+
return action.action
|
|
510
|
+
return None
|
|
File without changes
|
hyperloop/ports/pr.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""PRManager port — interface for PR lifecycle operations.
|
|
2
|
+
|
|
3
|
+
Implementations: PRManager (gh CLI), FakePRManager (in-memory for tests).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PRPort(Protocol):
|
|
12
|
+
"""Interface for PR lifecycle: gate polling, rebase, and merge."""
|
|
13
|
+
|
|
14
|
+
def create_draft(self, task_id: str, branch: str, title: str, spec_ref: str) -> str:
|
|
15
|
+
"""Create a draft PR. Returns PR URL. Adds spec/task labels."""
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
def check_gate(self, pr_url: str, gate: str) -> bool:
|
|
19
|
+
"""Check if a gate signal is present. Returns True if gate is cleared."""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
def mark_ready(self, pr_url: str) -> None:
|
|
23
|
+
"""Mark a draft PR as ready for review."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
def merge(self, pr_url: str, task_id: str, spec_ref: str) -> bool:
|
|
27
|
+
"""Squash-merge a PR. Returns True on success, False on conflict."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
def rebase_branch(self, branch: str, base_branch: str) -> bool:
|
|
31
|
+
"""Rebase a branch onto base. Returns True if clean, False if conflicts."""
|
|
32
|
+
...
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Runtime port — interface for managing worker agent sessions.
|
|
2
|
+
|
|
3
|
+
Implementations: LocalRuntime (worktrees + CLI), AmbientRuntime (platform API).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, Literal, Protocol
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from hyperloop.domain.model import WorkerHandle, WorkerResult
|
|
12
|
+
|
|
13
|
+
WorkerPollStatus = Literal["running", "done", "failed"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Runtime(Protocol):
|
|
17
|
+
"""Spawn, poll, and collect results from worker agents."""
|
|
18
|
+
|
|
19
|
+
def spawn(self, task_id: str, role: str, prompt: str, branch: str) -> WorkerHandle:
|
|
20
|
+
"""Start a worker agent session on the given branch."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
def poll(self, handle: WorkerHandle) -> WorkerPollStatus:
|
|
24
|
+
"""Check worker status. Returns 'running', 'done', or 'failed'."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
def reap(self, handle: WorkerHandle) -> WorkerResult:
|
|
28
|
+
"""Collect the result from a finished worker and clean up."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def cancel(self, handle: WorkerHandle) -> None:
|
|
32
|
+
"""Terminate a running worker session."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def find_orphan(self, task_id: str, branch: str) -> WorkerHandle | None:
|
|
36
|
+
"""Find a worker left running from a previous orchestrator session (crash recovery)."""
|
|
37
|
+
...
|
hyperloop/ports/state.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""StateStore port — interface for persisting orchestrator state.
|
|
2
|
+
|
|
3
|
+
Implementations: GitStateStore (git commits), AmbientStateStore (platform API).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, Protocol
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from hyperloop.domain.model import Phase, Task, TaskStatus, World
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StateStore(Protocol):
|
|
15
|
+
"""Read and write orchestrator state (tasks, findings, epochs)."""
|
|
16
|
+
|
|
17
|
+
def get_world(self) -> World:
|
|
18
|
+
"""Return a complete snapshot of all tasks, workers, and the current epoch."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def get_task(self, task_id: str) -> Task:
|
|
22
|
+
"""Return a single task by ID."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def transition_task(
|
|
26
|
+
self,
|
|
27
|
+
task_id: str,
|
|
28
|
+
status: TaskStatus,
|
|
29
|
+
phase: Phase | None,
|
|
30
|
+
round: int | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Update a task's status, phase, and optionally round."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def store_findings(self, task_id: str, detail: str) -> None:
|
|
36
|
+
"""Append findings detail text to the task file's Findings section on trunk."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
def get_findings(self, task_id: str) -> str:
|
|
40
|
+
"""Return stored findings for a task. Empty string if none."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
def clear_findings(self, task_id: str) -> None:
|
|
44
|
+
"""Clear the findings section of a task file (on completion)."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
def get_epoch(self, key: str) -> str:
|
|
48
|
+
"""Return the content fingerprint for skip logic."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
def set_epoch(self, key: str, value: str) -> None:
|
|
52
|
+
"""Record a last-run marker."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def read_file(self, path: str) -> str | None:
|
|
56
|
+
"""Read a file from trunk. Returns None if the file does not exist."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
def commit(self, message: str) -> None:
|
|
60
|
+
"""Persist all pending state changes."""
|
|
61
|
+
...
|