hyperloop 0.3.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.3.0 → hyperloop-0.4.0}/CHANGELOG.md +3 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/PKG-INFO +1 -1
- {hyperloop-0.3.0 → hyperloop-0.4.0}/pyproject.toml +1 -1
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/adapters/git_state.py +6 -0
- hyperloop-0.4.0/src/hyperloop/adapters/serial.py +50 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/cli.py +66 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/loop.py +147 -12
- hyperloop-0.4.0/src/hyperloop/ports/serial.py +24 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/ports/state.py +4 -0
- hyperloop-0.4.0/tests/fakes/serial.py +47 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/fakes/state.py +10 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_e2e.py +6 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_loop.py +141 -0
- hyperloop-0.4.0/tests/test_serial_agents.py +454 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/uv.lock +1 -1
- {hyperloop-0.3.0 → hyperloop-0.4.0}/.github/workflows/ci.yaml +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/.github/workflows/release.yaml +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/.gitignore +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/.pre-commit-config.yaml +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/.python-version +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/CLAUDE.md +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/README.md +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/base/implementer.yaml +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/base/pm.yaml +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/base/process-improver.yaml +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/base/process.yaml +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/base/rebase-resolver.yaml +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/base/verifier.yaml +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/specs/spec.md +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/__init__.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/__main__.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/adapters/__init__.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/adapters/local.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/compose.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/config.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/domain/__init__.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/domain/decide.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/domain/deps.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/domain/model.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/domain/pipeline.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/ports/__init__.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/ports/pr.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/ports/runtime.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/pr.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/__init__.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/fakes/__init__.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/fakes/pr.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/fakes/runtime.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_cli.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_compose.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_config.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_decide.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_deps.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_fakes.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_git_state.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_local_runtime.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_model.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_pipeline.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_pr.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_smoke.py +0 -0
- {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_state_contract.py +0 -0
|
@@ -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
|
|
@@ -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
|
|
@@ -66,6 +66,37 @@ def _config_table(cfg: Config) -> Table:
|
|
|
66
66
|
return table
|
|
67
67
|
|
|
68
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
|
+
|
|
69
100
|
@app.command()
|
|
70
101
|
def run(
|
|
71
102
|
path: Path = typer.Option(
|
|
@@ -143,6 +174,7 @@ def run(
|
|
|
143
174
|
# 5. Construct runtime and state store, run loop
|
|
144
175
|
from hyperloop.adapters.git_state import GitStateStore
|
|
145
176
|
from hyperloop.adapters.local import LocalRuntime
|
|
177
|
+
from hyperloop.adapters.serial import SubprocessSerialRunner
|
|
146
178
|
from hyperloop.domain.model import ActionStep, LoopStep, Process, RoleStep
|
|
147
179
|
from hyperloop.loop import Orchestrator
|
|
148
180
|
|
|
@@ -163,6 +195,36 @@ def run(
|
|
|
163
195
|
|
|
164
196
|
state = GitStateStore(repo_path, specs_dir=cfg.specs_dir)
|
|
165
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)
|
|
166
228
|
|
|
167
229
|
orchestrator = Orchestrator(
|
|
168
230
|
state=state,
|
|
@@ -170,6 +232,10 @@ def run(
|
|
|
170
232
|
process=default_process,
|
|
171
233
|
max_workers=cfg.max_workers,
|
|
172
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,
|
|
173
239
|
)
|
|
174
240
|
|
|
175
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())
|
|
@@ -72,6 +76,9 @@ class Orchestrator:
|
|
|
72
76
|
pr_manager: PRPort | None = None,
|
|
73
77
|
composer: PromptComposer | None = None,
|
|
74
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,
|
|
75
82
|
) -> None:
|
|
76
83
|
self._state = state
|
|
77
84
|
self._runtime = runtime
|
|
@@ -81,16 +88,21 @@ class Orchestrator:
|
|
|
81
88
|
self._pr_manager = pr_manager
|
|
82
89
|
self._composer = composer
|
|
83
90
|
self._repo_path = repo_path
|
|
91
|
+
self._poll_interval = poll_interval
|
|
92
|
+
self._on_cycle = on_cycle
|
|
93
|
+
self._serial_runner = serial_runner
|
|
84
94
|
|
|
85
95
|
# Active worker tracking: task_id -> (handle, pipeline_position)
|
|
86
96
|
self._workers: dict[str, tuple[WorkerHandle, PipelinePosition]] = {}
|
|
87
97
|
|
|
88
98
|
def run_loop(self, max_cycles: int = 1000) -> str:
|
|
89
99
|
"""Run the orchestrator loop until halt or max_cycles. Returns halt reason."""
|
|
90
|
-
for
|
|
91
|
-
reason = self.run_cycle()
|
|
100
|
+
for cycle_num in range(max_cycles):
|
|
101
|
+
reason = self.run_cycle(cycle_num=cycle_num + 1)
|
|
92
102
|
if reason is not None:
|
|
93
103
|
return reason
|
|
104
|
+
if self._poll_interval > 0:
|
|
105
|
+
time.sleep(self._poll_interval)
|
|
94
106
|
return "max_cycles exhausted"
|
|
95
107
|
|
|
96
108
|
def recover(self) -> None:
|
|
@@ -123,7 +135,7 @@ class Orchestrator:
|
|
|
123
135
|
task.phase,
|
|
124
136
|
)
|
|
125
137
|
|
|
126
|
-
def run_cycle(self) -> str | None:
|
|
138
|
+
def run_cycle(self, cycle_num: int = 0) -> str | None:
|
|
127
139
|
"""Run one serial section cycle.
|
|
128
140
|
|
|
129
141
|
Returns a halt reason string if the loop should stop, or None to continue.
|
|
@@ -133,6 +145,16 @@ class Orchestrator:
|
|
|
133
145
|
# Cache world snapshot once per cycle — augmented with worker state
|
|
134
146
|
world = self._build_world()
|
|
135
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
|
+
|
|
136
158
|
# ---- 1. Reap finished workers ----------------------------------------
|
|
137
159
|
reaped_results: dict[str, WorkerResult] = {}
|
|
138
160
|
had_failures_this_cycle = False
|
|
@@ -210,12 +232,12 @@ class Orchestrator:
|
|
|
210
232
|
self._state.commit("orchestrator: halt")
|
|
211
233
|
return halt_reason
|
|
212
234
|
|
|
213
|
-
# ---- 3. Process-improver
|
|
235
|
+
# ---- 3. Process-improver ------------------------------------------------
|
|
214
236
|
if had_failures_this_cycle:
|
|
215
|
-
|
|
237
|
+
self._run_process_improver(reaped_results)
|
|
216
238
|
|
|
217
|
-
# ---- 4. Intake
|
|
218
|
-
|
|
239
|
+
# ---- 4. Intake ----------------------------------------------------------
|
|
240
|
+
self._run_intake()
|
|
219
241
|
|
|
220
242
|
# ---- 5. Poll gates ---------------------------------------------------
|
|
221
243
|
self._poll_gates(executor, to_spawn)
|
|
@@ -277,14 +299,18 @@ class Orchestrator:
|
|
|
277
299
|
# ---- Check convergence -----------------------------------------------
|
|
278
300
|
all_tasks = self._state.get_world().tasks
|
|
279
301
|
if not all_tasks:
|
|
302
|
+
self._notify_cycle(cycle_num, world)
|
|
280
303
|
return None
|
|
281
304
|
|
|
282
305
|
all_complete = all(t.status == TaskStatus.COMPLETE for t in all_tasks.values())
|
|
283
306
|
no_workers = len(self._workers) == 0
|
|
284
307
|
|
|
285
308
|
if all_complete and no_workers:
|
|
286
|
-
|
|
309
|
+
reason = "all tasks complete"
|
|
310
|
+
self._notify_cycle(cycle_num, world, reason=reason)
|
|
311
|
+
return reason
|
|
287
312
|
|
|
313
|
+
self._notify_cycle(cycle_num, world)
|
|
288
314
|
return None
|
|
289
315
|
|
|
290
316
|
# -----------------------------------------------------------------------
|
|
@@ -402,6 +428,84 @@ class Orchestrator:
|
|
|
402
428
|
subprocess.run([*git_cmd, "merge", "--abort"], capture_output=True, check=False)
|
|
403
429
|
self._state.transition_task(task_id, TaskStatus.NEEDS_REBASE, phase=Phase("merge-pr"))
|
|
404
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
|
+
|
|
405
509
|
# -----------------------------------------------------------------------
|
|
406
510
|
# World building
|
|
407
511
|
# -----------------------------------------------------------------------
|
|
@@ -423,6 +527,37 @@ class Orchestrator:
|
|
|
423
527
|
epoch=base_world.epoch,
|
|
424
528
|
)
|
|
425
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
|
+
|
|
426
561
|
# -----------------------------------------------------------------------
|
|
427
562
|
# Prompt composition
|
|
428
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
|
|
@@ -104,6 +104,16 @@ class InMemoryStateStore:
|
|
|
104
104
|
"""Record a last-run marker."""
|
|
105
105
|
self._epochs[key] = value
|
|
106
106
|
|
|
107
|
+
def list_files(self, pattern: str) -> list[str]:
|
|
108
|
+
"""List file paths matching a glob pattern against in-memory files.
|
|
109
|
+
|
|
110
|
+
Uses PurePosixPath.match to respect directory boundaries (``*`` does
|
|
111
|
+
not cross ``/``), consistent with pathlib.Path.glob behaviour.
|
|
112
|
+
"""
|
|
113
|
+
from pathlib import PurePosixPath
|
|
114
|
+
|
|
115
|
+
return sorted(p for p in self._files if PurePosixPath(p).match(pattern))
|
|
116
|
+
|
|
107
117
|
def read_file(self, path: str) -> str | None:
|
|
108
118
|
"""Read a file from the in-memory filesystem. Returns None if not found."""
|
|
109
119
|
return self._files.get(path)
|
|
@@ -255,6 +255,7 @@ class TestSingleTaskCompletesE2E:
|
|
|
255
255
|
process=E2E_PIPELINE,
|
|
256
256
|
max_workers=2,
|
|
257
257
|
max_rounds=10,
|
|
258
|
+
poll_interval=0,
|
|
258
259
|
)
|
|
259
260
|
|
|
260
261
|
reason = orch.run_loop(max_cycles=MAX_CYCLES)
|
|
@@ -322,6 +323,7 @@ fi
|
|
|
322
323
|
process=E2E_PIPELINE,
|
|
323
324
|
max_workers=2,
|
|
324
325
|
max_rounds=10,
|
|
326
|
+
poll_interval=0,
|
|
325
327
|
)
|
|
326
328
|
|
|
327
329
|
reason = orch.run_loop(max_cycles=MAX_CYCLES)
|
|
@@ -363,6 +365,7 @@ class TestTwoTasksRunInParallelE2E:
|
|
|
363
365
|
process=E2E_PIPELINE,
|
|
364
366
|
max_workers=4,
|
|
365
367
|
max_rounds=10,
|
|
368
|
+
poll_interval=0,
|
|
366
369
|
)
|
|
367
370
|
|
|
368
371
|
reason = orch.run_loop(max_cycles=MAX_CYCLES)
|
|
@@ -411,6 +414,7 @@ class TestLocalMergeLandsCodeOnBaseBranch:
|
|
|
411
414
|
max_workers=2,
|
|
412
415
|
max_rounds=10,
|
|
413
416
|
repo_path=str(repo),
|
|
417
|
+
poll_interval=0,
|
|
414
418
|
)
|
|
415
419
|
|
|
416
420
|
reason = orch.run_loop(max_cycles=MAX_CYCLES)
|
|
@@ -485,6 +489,7 @@ class TestPRFlowWithFakePRManager:
|
|
|
485
489
|
max_workers=2,
|
|
486
490
|
max_rounds=10,
|
|
487
491
|
pr_manager=pr_manager,
|
|
492
|
+
poll_interval=0,
|
|
488
493
|
)
|
|
489
494
|
|
|
490
495
|
reason = orch.run_loop(max_cycles=MAX_CYCLES)
|
|
@@ -560,6 +565,7 @@ class TestGateBlocksUntilLgtm:
|
|
|
560
565
|
max_workers=2,
|
|
561
566
|
max_rounds=10,
|
|
562
567
|
pr_manager=pr_manager,
|
|
568
|
+
poll_interval=0,
|
|
563
569
|
)
|
|
564
570
|
|
|
565
571
|
# Run enough cycles for implementer + verifier to complete, reaching the gate
|