hyperloop 0.2.0__tar.gz → 0.4.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.2.0 → hyperloop-0.4.0}/CHANGELOG.md +6 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/PKG-INFO +1 -1
- {hyperloop-0.2.0 → hyperloop-0.4.0}/pyproject.toml +4 -1
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/adapters/git_state.py +6 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/adapters/local.py +32 -10
- hyperloop-0.4.0/src/hyperloop/adapters/serial.py +50 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/cli.py +83 -1
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/loop.py +155 -14
- hyperloop-0.4.0/src/hyperloop/ports/serial.py +24 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/ports/state.py +4 -0
- hyperloop-0.4.0/tests/fakes/serial.py +47 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/fakes/state.py +10 -0
- hyperloop-0.4.0/tests/test_e2e.py +597 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_local_runtime.py +3 -3
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_loop.py +141 -0
- hyperloop-0.4.0/tests/test_serial_agents.py +454 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/uv.lock +1 -1
- {hyperloop-0.2.0 → hyperloop-0.4.0}/.github/workflows/ci.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/.github/workflows/release.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/.gitignore +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/.pre-commit-config.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/.python-version +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/CLAUDE.md +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/README.md +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/base/implementer.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/base/pm.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/base/process-improver.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/base/process.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/base/rebase-resolver.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/base/verifier.yaml +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/specs/spec.md +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/__init__.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/__main__.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/adapters/__init__.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/compose.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/config.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/domain/__init__.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/domain/decide.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/domain/deps.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/domain/model.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/domain/pipeline.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/ports/__init__.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/ports/pr.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/ports/runtime.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/pr.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/__init__.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/fakes/__init__.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/fakes/pr.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/fakes/runtime.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_cli.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_compose.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_config.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_decide.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_deps.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_fakes.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_git_state.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_model.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_pipeline.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_pr.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_smoke.py +0 -0
- {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_state_contract.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hyperloop"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.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"
|
|
@@ -200,6 +200,12 @@ class GitStateStore:
|
|
|
200
200
|
"""Record a last-run marker."""
|
|
201
201
|
self._epochs[key] = value
|
|
202
202
|
|
|
203
|
+
def list_files(self, pattern: str) -> list[str]:
|
|
204
|
+
"""List file paths matching a glob pattern relative to the repo root."""
|
|
205
|
+
return sorted(
|
|
206
|
+
str(p.relative_to(self._repo)) for p in self._repo.glob(pattern) if p.is_file()
|
|
207
|
+
)
|
|
208
|
+
|
|
203
209
|
def read_file(self, path: str) -> str | None:
|
|
204
210
|
"""Read a file from the repo. Returns None if it does not exist."""
|
|
205
211
|
file_path = self._repo / path
|
|
@@ -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
|
|
60
|
-
|
|
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=
|
|
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
|
|
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
|
|
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
|
-
|
|
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=
|
|
294
|
+
env=_clean_git_env(),
|
|
273
295
|
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""SubprocessSerialRunner — runs serial agents via CLI subprocess on trunk.
|
|
2
|
+
|
|
3
|
+
Used for PM intake and process-improver. Blocks until the agent completes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SubprocessSerialRunner:
|
|
15
|
+
"""Run a serial agent as a subprocess in the repo directory."""
|
|
16
|
+
|
|
17
|
+
_DEFAULT_CMD = "claude --dangerously-skip-permissions"
|
|
18
|
+
|
|
19
|
+
def __init__(self, repo_path: str, command: str = _DEFAULT_CMD) -> None:
|
|
20
|
+
self._repo_path = repo_path
|
|
21
|
+
self._command = command
|
|
22
|
+
|
|
23
|
+
def run(self, role: str, prompt: str) -> bool:
|
|
24
|
+
"""Execute a serial agent with the given prompt. Blocks until complete."""
|
|
25
|
+
logger.info("Running serial agent: %s", role)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
result = subprocess.run(
|
|
29
|
+
self._command.split(),
|
|
30
|
+
input=prompt,
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
cwd=self._repo_path,
|
|
34
|
+
timeout=600,
|
|
35
|
+
)
|
|
36
|
+
if result.returncode != 0:
|
|
37
|
+
logger.warning(
|
|
38
|
+
"Serial agent %s failed (exit %d): %s",
|
|
39
|
+
role,
|
|
40
|
+
result.returncode,
|
|
41
|
+
result.stderr[:500],
|
|
42
|
+
)
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
logger.info("Serial agent %s completed successfully", role)
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
except subprocess.TimeoutExpired:
|
|
49
|
+
logger.warning("Serial agent %s timed out after 600s", role)
|
|
50
|
+
return False
|
|
@@ -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(
|
|
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
|
|
|
@@ -50,6 +66,37 @@ def _config_table(cfg: Config) -> Table:
|
|
|
50
66
|
return table
|
|
51
67
|
|
|
52
68
|
|
|
69
|
+
def _make_composer(state: object) -> object | None:
|
|
70
|
+
"""Construct a PromptComposer using the base/ directory.
|
|
71
|
+
|
|
72
|
+
Tries two locations:
|
|
73
|
+
1. Development: relative to this file (src/hyperloop/../../base)
|
|
74
|
+
2. Installed package: via importlib.resources
|
|
75
|
+
|
|
76
|
+
Returns None if base/ directory cannot be found.
|
|
77
|
+
"""
|
|
78
|
+
from hyperloop.compose import PromptComposer
|
|
79
|
+
|
|
80
|
+
# Development path: relative to this source file
|
|
81
|
+
dev_base = Path(__file__).resolve().parent.parent.parent / "base"
|
|
82
|
+
if dev_base.is_dir():
|
|
83
|
+
return PromptComposer(base_dir=dev_base, state=state) # type: ignore[arg-type]
|
|
84
|
+
|
|
85
|
+
# Installed package: try importlib.resources
|
|
86
|
+
try:
|
|
87
|
+
import importlib.resources as pkg_resources
|
|
88
|
+
|
|
89
|
+
ref = pkg_resources.files("hyperloop").joinpath("../../base")
|
|
90
|
+
base_path = Path(str(ref))
|
|
91
|
+
if base_path.is_dir():
|
|
92
|
+
return PromptComposer(base_dir=base_path, state=state) # type: ignore[arg-type]
|
|
93
|
+
except (ImportError, TypeError, FileNotFoundError):
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
console.print("[dim]Warning: base/ directory not found — prompts will be empty.[/dim]")
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
53
100
|
@app.command()
|
|
54
101
|
def run(
|
|
55
102
|
path: Path = typer.Option(
|
|
@@ -127,6 +174,7 @@ def run(
|
|
|
127
174
|
# 5. Construct runtime and state store, run loop
|
|
128
175
|
from hyperloop.adapters.git_state import GitStateStore
|
|
129
176
|
from hyperloop.adapters.local import LocalRuntime
|
|
177
|
+
from hyperloop.adapters.serial import SubprocessSerialRunner
|
|
130
178
|
from hyperloop.domain.model import ActionStep, LoopStep, Process, RoleStep
|
|
131
179
|
from hyperloop.loop import Orchestrator
|
|
132
180
|
|
|
@@ -147,6 +195,36 @@ def run(
|
|
|
147
195
|
|
|
148
196
|
state = GitStateStore(repo_path, specs_dir=cfg.specs_dir)
|
|
149
197
|
runtime = LocalRuntime(repo_path=str(repo_path))
|
|
198
|
+
serial_runner = SubprocessSerialRunner(repo_path=str(repo_path))
|
|
199
|
+
|
|
200
|
+
# Resolve base/ directory for agent prompt definitions
|
|
201
|
+
composer = _make_composer(state)
|
|
202
|
+
|
|
203
|
+
def _on_cycle(summary: dict[str, object]) -> None:
|
|
204
|
+
"""Print a rich status line after each orchestrator cycle."""
|
|
205
|
+
cycle = summary.get("cycle", "?")
|
|
206
|
+
tasks = summary.get("tasks", {})
|
|
207
|
+
workers = summary.get("workers", 0)
|
|
208
|
+
halt = summary.get("halt_reason")
|
|
209
|
+
|
|
210
|
+
if isinstance(tasks, dict):
|
|
211
|
+
total = tasks.get("total", 0)
|
|
212
|
+
done = tasks.get("complete", 0)
|
|
213
|
+
in_prog = tasks.get("in_progress", 0)
|
|
214
|
+
failed = tasks.get("failed", 0)
|
|
215
|
+
task_str = (
|
|
216
|
+
f"[dim]{total} total[/dim] "
|
|
217
|
+
f"[green]{done} done[/green] "
|
|
218
|
+
f"[yellow]{in_prog} active[/yellow] "
|
|
219
|
+
f"[red]{failed} failed[/red]"
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
task_str = "[dim]unknown[/dim]"
|
|
223
|
+
|
|
224
|
+
status = f"[bold]cycle {cycle}[/bold] tasks: {task_str} workers: {workers}"
|
|
225
|
+
if halt:
|
|
226
|
+
status += f" [bold]{halt}[/bold]"
|
|
227
|
+
console.print(status)
|
|
150
228
|
|
|
151
229
|
orchestrator = Orchestrator(
|
|
152
230
|
state=state,
|
|
@@ -154,6 +232,10 @@ def run(
|
|
|
154
232
|
process=default_process,
|
|
155
233
|
max_workers=cfg.max_workers,
|
|
156
234
|
max_rounds=cfg.max_rounds,
|
|
235
|
+
composer=composer,
|
|
236
|
+
serial_runner=serial_runner,
|
|
237
|
+
poll_interval=cfg.poll_interval,
|
|
238
|
+
on_cycle=_on_cycle,
|
|
157
239
|
)
|
|
158
240
|
|
|
159
241
|
# 6. Recover and run
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
"""Orchestrator loop — wires decide, pipeline, state store, and runtime.
|
|
2
2
|
|
|
3
3
|
Runs the serial section from the spec: reap finished workers, check for halt,
|
|
4
|
-
run
|
|
5
|
-
|
|
4
|
+
run process-improver/intake, poll gates, merge PRs, decide what to spawn,
|
|
5
|
+
update state, spawn workers, and check convergence.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import logging
|
|
11
|
+
import time
|
|
11
12
|
from typing import TYPE_CHECKING
|
|
12
13
|
|
|
13
14
|
from hyperloop.domain.decide import decide
|
|
@@ -37,10 +38,13 @@ from hyperloop.domain.pipeline import (
|
|
|
37
38
|
)
|
|
38
39
|
|
|
39
40
|
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Callable
|
|
42
|
+
|
|
40
43
|
from hyperloop.compose import PromptComposer
|
|
41
44
|
from hyperloop.domain.model import PipelineStep, Process, Task, WorkerResult
|
|
42
45
|
from hyperloop.ports.pr import PRPort
|
|
43
46
|
from hyperloop.ports.runtime import Runtime
|
|
47
|
+
from hyperloop.ports.serial import SerialRunner
|
|
44
48
|
from hyperloop.ports.state import StateStore
|
|
45
49
|
|
|
46
50
|
logger = logging.getLogger(__name__)
|
|
@@ -53,8 +57,8 @@ class Orchestrator:
|
|
|
53
57
|
Each cycle follows the spec's serial section order:
|
|
54
58
|
1. Reap finished workers
|
|
55
59
|
2. Halt if any task failed
|
|
56
|
-
3. Process-improver (
|
|
57
|
-
4. Intake (
|
|
60
|
+
3. Process-improver (serial on trunk)
|
|
61
|
+
4. Intake (serial on trunk)
|
|
58
62
|
5. Poll gates
|
|
59
63
|
6. Merge ready PRs
|
|
60
64
|
7. Decide what to spawn (via decide())
|
|
@@ -71,6 +75,10 @@ class Orchestrator:
|
|
|
71
75
|
max_rounds: int = 50,
|
|
72
76
|
pr_manager: PRPort | None = None,
|
|
73
77
|
composer: PromptComposer | None = None,
|
|
78
|
+
repo_path: str | None = None,
|
|
79
|
+
poll_interval: float = 30.0,
|
|
80
|
+
on_cycle: Callable[[dict[str, object]], None] | None = None,
|
|
81
|
+
serial_runner: SerialRunner | None = None,
|
|
74
82
|
) -> None:
|
|
75
83
|
self._state = state
|
|
76
84
|
self._runtime = runtime
|
|
@@ -79,16 +87,22 @@ class Orchestrator:
|
|
|
79
87
|
self._max_rounds = max_rounds
|
|
80
88
|
self._pr_manager = pr_manager
|
|
81
89
|
self._composer = composer
|
|
90
|
+
self._repo_path = repo_path
|
|
91
|
+
self._poll_interval = poll_interval
|
|
92
|
+
self._on_cycle = on_cycle
|
|
93
|
+
self._serial_runner = serial_runner
|
|
82
94
|
|
|
83
95
|
# Active worker tracking: task_id -> (handle, pipeline_position)
|
|
84
96
|
self._workers: dict[str, tuple[WorkerHandle, PipelinePosition]] = {}
|
|
85
97
|
|
|
86
98
|
def run_loop(self, max_cycles: int = 1000) -> str:
|
|
87
99
|
"""Run the orchestrator loop until halt or max_cycles. Returns halt reason."""
|
|
88
|
-
for
|
|
89
|
-
reason = self.run_cycle()
|
|
100
|
+
for cycle_num in range(max_cycles):
|
|
101
|
+
reason = self.run_cycle(cycle_num=cycle_num + 1)
|
|
90
102
|
if reason is not None:
|
|
91
103
|
return reason
|
|
104
|
+
if self._poll_interval > 0:
|
|
105
|
+
time.sleep(self._poll_interval)
|
|
92
106
|
return "max_cycles exhausted"
|
|
93
107
|
|
|
94
108
|
def recover(self) -> None:
|
|
@@ -121,7 +135,7 @@ class Orchestrator:
|
|
|
121
135
|
task.phase,
|
|
122
136
|
)
|
|
123
137
|
|
|
124
|
-
def run_cycle(self) -> str | None:
|
|
138
|
+
def run_cycle(self, cycle_num: int = 0) -> str | None:
|
|
125
139
|
"""Run one serial section cycle.
|
|
126
140
|
|
|
127
141
|
Returns a halt reason string if the loop should stop, or None to continue.
|
|
@@ -131,6 +145,16 @@ class Orchestrator:
|
|
|
131
145
|
# Cache world snapshot once per cycle — augmented with worker state
|
|
132
146
|
world = self._build_world()
|
|
133
147
|
|
|
148
|
+
# ---- 0. Early exit on zero tasks -------------------------------------
|
|
149
|
+
if not world.tasks and not self._workers:
|
|
150
|
+
# Try intake first — it may create tasks from new specs
|
|
151
|
+
self._run_intake()
|
|
152
|
+
world = self._build_world()
|
|
153
|
+
if not world.tasks and not self._workers:
|
|
154
|
+
reason = "no tasks found — nothing to do"
|
|
155
|
+
self._notify_cycle(cycle_num, world, reason=reason)
|
|
156
|
+
return reason
|
|
157
|
+
|
|
134
158
|
# ---- 1. Reap finished workers ----------------------------------------
|
|
135
159
|
reaped_results: dict[str, WorkerResult] = {}
|
|
136
160
|
had_failures_this_cycle = False
|
|
@@ -208,12 +232,12 @@ class Orchestrator:
|
|
|
208
232
|
self._state.commit("orchestrator: halt")
|
|
209
233
|
return halt_reason
|
|
210
234
|
|
|
211
|
-
# ---- 3. Process-improver
|
|
235
|
+
# ---- 3. Process-improver ------------------------------------------------
|
|
212
236
|
if had_failures_this_cycle:
|
|
213
|
-
|
|
237
|
+
self._run_process_improver(reaped_results)
|
|
214
238
|
|
|
215
|
-
# ---- 4. Intake
|
|
216
|
-
|
|
239
|
+
# ---- 4. Intake ----------------------------------------------------------
|
|
240
|
+
self._run_intake()
|
|
217
241
|
|
|
218
242
|
# ---- 5. Poll gates ---------------------------------------------------
|
|
219
243
|
self._poll_gates(executor, to_spawn)
|
|
@@ -275,14 +299,18 @@ class Orchestrator:
|
|
|
275
299
|
# ---- Check convergence -----------------------------------------------
|
|
276
300
|
all_tasks = self._state.get_world().tasks
|
|
277
301
|
if not all_tasks:
|
|
302
|
+
self._notify_cycle(cycle_num, world)
|
|
278
303
|
return None
|
|
279
304
|
|
|
280
305
|
all_complete = all(t.status == TaskStatus.COMPLETE for t in all_tasks.values())
|
|
281
306
|
no_workers = len(self._workers) == 0
|
|
282
307
|
|
|
283
308
|
if all_complete and no_workers:
|
|
284
|
-
|
|
309
|
+
reason = "all tasks complete"
|
|
310
|
+
self._notify_cycle(cycle_num, world, reason=reason)
|
|
311
|
+
return reason
|
|
285
312
|
|
|
313
|
+
self._notify_cycle(cycle_num, world)
|
|
286
314
|
return None
|
|
287
315
|
|
|
288
316
|
# -----------------------------------------------------------------------
|
|
@@ -382,9 +410,13 @@ class Orchestrator:
|
|
|
382
410
|
"""Merge worker branch into base branch locally (no PR)."""
|
|
383
411
|
import subprocess
|
|
384
412
|
|
|
413
|
+
git_cmd = ["git"]
|
|
414
|
+
if self._repo_path is not None:
|
|
415
|
+
git_cmd = ["git", "-C", self._repo_path]
|
|
416
|
+
|
|
385
417
|
try:
|
|
386
418
|
subprocess.run(
|
|
387
|
-
[
|
|
419
|
+
[*git_cmd, "merge", branch, "--no-edit", "-m", f"merge: {task_id}"],
|
|
388
420
|
check=True,
|
|
389
421
|
capture_output=True,
|
|
390
422
|
)
|
|
@@ -393,9 +425,87 @@ class Orchestrator:
|
|
|
393
425
|
logger.info("Local merge of %s into base branch", task_id)
|
|
394
426
|
except subprocess.CalledProcessError:
|
|
395
427
|
logger.warning("Local merge conflict for task %s, marking NEEDS_REBASE", task_id)
|
|
396
|
-
subprocess.run([
|
|
428
|
+
subprocess.run([*git_cmd, "merge", "--abort"], capture_output=True, check=False)
|
|
397
429
|
self._state.transition_task(task_id, TaskStatus.NEEDS_REBASE, phase=Phase("merge-pr"))
|
|
398
430
|
|
|
431
|
+
# -----------------------------------------------------------------------
|
|
432
|
+
# Serial agents (PM intake + process-improver)
|
|
433
|
+
# -----------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
def _unprocessed_specs(self) -> list[str]:
|
|
436
|
+
"""Return spec file paths that have no corresponding task.
|
|
437
|
+
|
|
438
|
+
Scans specs/*.md (excluding subdirectories like tasks/, reviews/,
|
|
439
|
+
prompts/) and checks whether any existing task references each spec
|
|
440
|
+
via its spec_ref field.
|
|
441
|
+
"""
|
|
442
|
+
all_specs = self._state.list_files("specs/*.md")
|
|
443
|
+
world = self._state.get_world()
|
|
444
|
+
covered_refs = {task.spec_ref for task in world.tasks.values()}
|
|
445
|
+
return [s for s in all_specs if s not in covered_refs]
|
|
446
|
+
|
|
447
|
+
def _collect_cycle_findings(self, reaped_results: dict[str, WorkerResult]) -> str:
|
|
448
|
+
"""Collect findings from all failed results this cycle into a single string."""
|
|
449
|
+
sections: list[str] = []
|
|
450
|
+
for task_id, result in reaped_results.items():
|
|
451
|
+
if result.verdict in (Verdict.FAIL, Verdict.ERROR, Verdict.TIMEOUT):
|
|
452
|
+
sections.append(f"### {task_id}\n{result.detail}")
|
|
453
|
+
return "\n\n".join(sections)
|
|
454
|
+
|
|
455
|
+
def _run_intake(self) -> None:
|
|
456
|
+
"""Run PM intake if there are unprocessed specs and a serial runner + composer."""
|
|
457
|
+
if self._serial_runner is None or self._composer is None:
|
|
458
|
+
logger.debug("intake: no serial_runner or composer — skipping")
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
unprocessed = self._unprocessed_specs()
|
|
462
|
+
if not unprocessed:
|
|
463
|
+
logger.debug("intake: no unprocessed specs — skipping")
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
logger.info("intake: %d unprocessed spec(s), running PM", len(unprocessed))
|
|
467
|
+
|
|
468
|
+
spec_list = "\n".join(f"- {s}" for s in unprocessed)
|
|
469
|
+
prompt = self._composer.compose(
|
|
470
|
+
role="pm",
|
|
471
|
+
task_id="intake",
|
|
472
|
+
spec_ref="specs/",
|
|
473
|
+
findings="",
|
|
474
|
+
)
|
|
475
|
+
prompt += f"\n## Specs to Process\n{spec_list}\n"
|
|
476
|
+
|
|
477
|
+
success = self._serial_runner.run("pm", prompt)
|
|
478
|
+
if success:
|
|
479
|
+
logger.info("intake: PM completed successfully")
|
|
480
|
+
else:
|
|
481
|
+
logger.warning("intake: PM agent failed")
|
|
482
|
+
|
|
483
|
+
def _run_process_improver(self, reaped_results: dict[str, WorkerResult]) -> None:
|
|
484
|
+
"""Run process-improver with findings from failed results this cycle."""
|
|
485
|
+
if self._serial_runner is None or self._composer is None:
|
|
486
|
+
logger.info("process-improver: no serial_runner or composer — skipping")
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
findings_text = self._collect_cycle_findings(reaped_results)
|
|
490
|
+
if not findings_text:
|
|
491
|
+
logger.debug("process-improver: no failure findings this cycle — skipping")
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
logger.info("process-improver: running with this cycle's findings")
|
|
495
|
+
|
|
496
|
+
prompt = self._composer.compose(
|
|
497
|
+
role="process-improver",
|
|
498
|
+
task_id="process-improvement",
|
|
499
|
+
spec_ref="specs/prompts",
|
|
500
|
+
findings=findings_text,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
success = self._serial_runner.run("process-improver", prompt)
|
|
504
|
+
if success:
|
|
505
|
+
logger.info("process-improver: completed successfully")
|
|
506
|
+
else:
|
|
507
|
+
logger.warning("process-improver: agent failed")
|
|
508
|
+
|
|
399
509
|
# -----------------------------------------------------------------------
|
|
400
510
|
# World building
|
|
401
511
|
# -----------------------------------------------------------------------
|
|
@@ -417,6 +527,37 @@ class Orchestrator:
|
|
|
417
527
|
epoch=base_world.epoch,
|
|
418
528
|
)
|
|
419
529
|
|
|
530
|
+
# -----------------------------------------------------------------------
|
|
531
|
+
# Cycle notification
|
|
532
|
+
# -----------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
def _notify_cycle(
|
|
535
|
+
self,
|
|
536
|
+
cycle_num: int,
|
|
537
|
+
world: World,
|
|
538
|
+
reason: str | None = None,
|
|
539
|
+
) -> None:
|
|
540
|
+
"""Invoke the on_cycle callback with a summary of this cycle."""
|
|
541
|
+
if self._on_cycle is None:
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
tasks = world.tasks
|
|
545
|
+
summary: dict[str, object] = {
|
|
546
|
+
"cycle": cycle_num,
|
|
547
|
+
"tasks": {
|
|
548
|
+
"total": len(tasks),
|
|
549
|
+
"not_started": sum(1 for t in tasks.values() if t.status == TaskStatus.NOT_STARTED),
|
|
550
|
+
"in_progress": sum(1 for t in tasks.values() if t.status == TaskStatus.IN_PROGRESS),
|
|
551
|
+
"complete": sum(1 for t in tasks.values() if t.status == TaskStatus.COMPLETE),
|
|
552
|
+
"failed": sum(1 for t in tasks.values() if t.status == TaskStatus.FAILED),
|
|
553
|
+
},
|
|
554
|
+
"workers": len(self._workers),
|
|
555
|
+
}
|
|
556
|
+
if reason is not None:
|
|
557
|
+
summary["halt_reason"] = reason
|
|
558
|
+
|
|
559
|
+
self._on_cycle(summary)
|
|
560
|
+
|
|
420
561
|
# -----------------------------------------------------------------------
|
|
421
562
|
# Prompt composition
|
|
422
563
|
# -----------------------------------------------------------------------
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""SerialRunner port — interface for running serial agents on trunk.
|
|
2
|
+
|
|
3
|
+
PM and process-improver run serially on trunk during the orchestrator's
|
|
4
|
+
serial section. They are not pipeline workers — they block the loop
|
|
5
|
+
until they complete.
|
|
6
|
+
|
|
7
|
+
Implementations: SubprocessSerialRunner (CLI subprocess),
|
|
8
|
+
FakeSerialRunner (in-memory for tests).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SerialRunner(Protocol):
|
|
17
|
+
"""Run an agent serially on trunk. Blocks until complete."""
|
|
18
|
+
|
|
19
|
+
def run(self, role: str, prompt: str) -> bool:
|
|
20
|
+
"""Execute a serial agent with the given prompt.
|
|
21
|
+
|
|
22
|
+
Returns True on success, False on failure.
|
|
23
|
+
"""
|
|
24
|
+
...
|
|
@@ -52,6 +52,10 @@ class StateStore(Protocol):
|
|
|
52
52
|
"""Record a last-run marker."""
|
|
53
53
|
...
|
|
54
54
|
|
|
55
|
+
def list_files(self, pattern: str) -> list[str]:
|
|
56
|
+
"""List file paths matching a glob pattern relative to the repo root."""
|
|
57
|
+
...
|
|
58
|
+
|
|
55
59
|
def read_file(self, path: str) -> str | None:
|
|
56
60
|
"""Read a file from trunk. Returns None if the file does not exist."""
|
|
57
61
|
...
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""FakeSerialRunner — in-memory implementation of the SerialRunner port.
|
|
2
|
+
|
|
3
|
+
Records invocations for test assertions. Optionally runs a callback
|
|
4
|
+
to simulate side effects (e.g. creating task files in the state store).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class SerialRunRecord:
|
|
18
|
+
"""Record of a serial agent invocation."""
|
|
19
|
+
|
|
20
|
+
role: str
|
|
21
|
+
prompt: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FakeSerialRunner:
|
|
25
|
+
"""In-memory implementation of the SerialRunner port."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self.runs: list[SerialRunRecord] = []
|
|
29
|
+
self._callbacks: dict[str, Callable[[str], bool]] = {}
|
|
30
|
+
self._default_success: bool = True
|
|
31
|
+
|
|
32
|
+
def set_callback(self, role: str, callback: Callable[[str], bool]) -> None:
|
|
33
|
+
"""Register a callback for a role. Called with the prompt, returns success."""
|
|
34
|
+
self._callbacks[role] = callback
|
|
35
|
+
|
|
36
|
+
def set_default_success(self, success: bool) -> None:
|
|
37
|
+
"""Set the default return value when no callback is registered."""
|
|
38
|
+
self._default_success = success
|
|
39
|
+
|
|
40
|
+
# -- SerialRunner protocol -------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def run(self, role: str, prompt: str) -> bool:
|
|
43
|
+
"""Record the invocation and optionally execute a callback."""
|
|
44
|
+
self.runs.append(SerialRunRecord(role=role, prompt=prompt))
|
|
45
|
+
if role in self._callbacks:
|
|
46
|
+
return self._callbacks[role](prompt)
|
|
47
|
+
return self._default_success
|