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.
Files changed (61) hide show
  1. {hyperloop-0.3.0 → hyperloop-0.4.0}/CHANGELOG.md +3 -0
  2. {hyperloop-0.3.0 → hyperloop-0.4.0}/PKG-INFO +1 -1
  3. {hyperloop-0.3.0 → hyperloop-0.4.0}/pyproject.toml +1 -1
  4. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/adapters/git_state.py +6 -0
  5. hyperloop-0.4.0/src/hyperloop/adapters/serial.py +50 -0
  6. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/cli.py +66 -0
  7. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/loop.py +147 -12
  8. hyperloop-0.4.0/src/hyperloop/ports/serial.py +24 -0
  9. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/ports/state.py +4 -0
  10. hyperloop-0.4.0/tests/fakes/serial.py +47 -0
  11. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/fakes/state.py +10 -0
  12. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_e2e.py +6 -0
  13. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_loop.py +141 -0
  14. hyperloop-0.4.0/tests/test_serial_agents.py +454 -0
  15. {hyperloop-0.3.0 → hyperloop-0.4.0}/uv.lock +1 -1
  16. {hyperloop-0.3.0 → hyperloop-0.4.0}/.github/workflows/ci.yaml +0 -0
  17. {hyperloop-0.3.0 → hyperloop-0.4.0}/.github/workflows/release.yaml +0 -0
  18. {hyperloop-0.3.0 → hyperloop-0.4.0}/.gitignore +0 -0
  19. {hyperloop-0.3.0 → hyperloop-0.4.0}/.pre-commit-config.yaml +0 -0
  20. {hyperloop-0.3.0 → hyperloop-0.4.0}/.python-version +0 -0
  21. {hyperloop-0.3.0 → hyperloop-0.4.0}/CLAUDE.md +0 -0
  22. {hyperloop-0.3.0 → hyperloop-0.4.0}/README.md +0 -0
  23. {hyperloop-0.3.0 → hyperloop-0.4.0}/base/implementer.yaml +0 -0
  24. {hyperloop-0.3.0 → hyperloop-0.4.0}/base/pm.yaml +0 -0
  25. {hyperloop-0.3.0 → hyperloop-0.4.0}/base/process-improver.yaml +0 -0
  26. {hyperloop-0.3.0 → hyperloop-0.4.0}/base/process.yaml +0 -0
  27. {hyperloop-0.3.0 → hyperloop-0.4.0}/base/rebase-resolver.yaml +0 -0
  28. {hyperloop-0.3.0 → hyperloop-0.4.0}/base/verifier.yaml +0 -0
  29. {hyperloop-0.3.0 → hyperloop-0.4.0}/specs/spec.md +0 -0
  30. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/__init__.py +0 -0
  31. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/__main__.py +0 -0
  32. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/adapters/__init__.py +0 -0
  33. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/adapters/local.py +0 -0
  34. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/compose.py +0 -0
  35. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/config.py +0 -0
  36. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/domain/__init__.py +0 -0
  37. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/domain/decide.py +0 -0
  38. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/domain/deps.py +0 -0
  39. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/domain/model.py +0 -0
  40. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/domain/pipeline.py +0 -0
  41. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/ports/__init__.py +0 -0
  42. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/ports/pr.py +0 -0
  43. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/ports/runtime.py +0 -0
  44. {hyperloop-0.3.0 → hyperloop-0.4.0}/src/hyperloop/pr.py +0 -0
  45. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/__init__.py +0 -0
  46. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/fakes/__init__.py +0 -0
  47. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/fakes/pr.py +0 -0
  48. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/fakes/runtime.py +0 -0
  49. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_cli.py +0 -0
  50. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_compose.py +0 -0
  51. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_config.py +0 -0
  52. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_decide.py +0 -0
  53. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_deps.py +0 -0
  54. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_fakes.py +0 -0
  55. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_git_state.py +0 -0
  56. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_local_runtime.py +0 -0
  57. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_model.py +0 -0
  58. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_pipeline.py +0 -0
  59. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_pr.py +0 -0
  60. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_smoke.py +0 -0
  61. {hyperloop-0.3.0 → hyperloop-0.4.0}/tests/test_state_contract.py +0 -0
@@ -2,6 +2,9 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v0.4.0 (2026-04-15)
6
+
7
+
5
8
  ## v0.3.0 (2026-04-15)
6
9
 
7
10
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperloop
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Orchestrator that walks tasks through composable process pipelines using AI agents
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: pyyaml>=6.0.3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hyperloop"
3
- version = "0.3.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"
@@ -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 stubs for process-improver/intake, poll gates, merge PRs, decide what to
5
- spawn, update state, spawn workers, and check convergence.
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 (stub)
57
- 4. Intake (stub)
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 _ in range(max_cycles):
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 (stub) --------------------------------------
235
+ # ---- 3. Process-improver ------------------------------------------------
214
236
  if had_failures_this_cycle:
215
- logger.info("process-improver: stub — would run on trunk with this cycle's findings")
237
+ self._run_process_improver(reaped_results)
216
238
 
217
- # ---- 4. Intake (stub) ------------------------------------------------
218
- logger.debug("intake: stub — would run if configured and new specs exist")
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
- return "all tasks complete"
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