Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/forge_loop/brainstormer.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@
from __future__ import annotations

import json
import time
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
from typing import Any

from pydantic import BaseModel, ConfigDict, Field, ValidationError

Expand Down Expand Up @@ -239,6 +241,8 @@ class Brainstormer:
sdk_fn: Injection point for ``run_brainstormer_sdk`` (tests stub
this to avoid network + SDK install).
timeout_s: Hard cap on the SDK session.
provider: Agent provider for the default backend. ``claude`` uses
the Claude SDK shim; ``codex`` uses ``codex exec``.
"""

repo_path: Path = Path(".")
Expand All @@ -248,6 +252,7 @@ class Brainstormer:
sdk_fn: Callable[..., Any] | None = None
timeout_s: int = 300
model: str | None = None
provider: str = "claude"

def run(self, vision: ProductVision) -> BrainstormReport:
"""Entry point — see module docstring."""
Expand Down Expand Up @@ -320,6 +325,23 @@ def _render_prompt(self, vision: ProductVision, backlog: Any) -> str:
)

def _default_sdk_fn(self) -> Callable[..., Any]:
if self.provider == "codex":
return self._codex_sdk_fn
if self.provider != "claude":
raise ValueError(f"unknown brainstormer provider: {self.provider!r}")
from forge_loop._brainstormer_sdk import run_brainstormer_sdk

return run_brainstormer_sdk

def _codex_sdk_fn(self, prompt: str, *, cwd: Path, timeout_s: int, model: str | None = None) -> Any:
from forge_loop.agent_backend import run_codex_exec

log_dir = Path(cwd) / "docs" / "ops" / "loop-runner-logs"
log_path = log_dir / f"brainstormer-{int(time.time())}.jsonl"
return run_codex_exec(
prompt=prompt,
cwd=Path(cwd),
log_path=log_path,
timeout_s=timeout_s,
model=model or None,
)
37 changes: 32 additions & 5 deletions src/forge_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,15 @@ def _cmd_init(args: SimpleNamespace) -> int:
return 0


def _brainstormer_factory(repo_path: Path, owner: str, repo: str) -> Any:
def _brainstormer_factory(
repo_path: Path,
owner: str,
repo: str,
*,
provider: str = "claude",
model: str | None = None,
timeout_s: int = 300,
) -> Any:
"""Construct the default Brainstormer. Tests monkeypatch this.

Kept as a module-level callable so ``monkeypatch.setattr(cli,
Expand All @@ -645,7 +653,14 @@ def _brainstormer_factory(repo_path: Path, owner: str, repo: str) -> Any:
"""
from forge_loop.brainstormer import Brainstormer

return Brainstormer(repo_path=repo_path, owner=owner, repo=repo)
return Brainstormer(
repo_path=repo_path,
owner=owner,
repo=repo,
provider=provider,
model=model,
timeout_s=timeout_s,
)


def _gh_client_factory() -> Any:
Expand All @@ -669,25 +684,30 @@ def _cmd_brainstorm(args: SimpleNamespace) -> int:
import yaml

from forge_loop.brainstormer import (
Brainstormer,
BrainstormReport,
ProposedEpic,
ProposedTicket,
)
from forge_loop.gh_client import MockGhClient
from forge_loop.product_vision import MissingVisionError, discover

# 1. Resolve repo path + GitHub coordinates from the existing config
# accessor — same pattern as ``_cmd_init`` / ``_cmd_run``.
repo_path = Path.cwd()
owner = ""
repo_name = ""
provider = "claude"
model: str | None = None
timeout_s = 300
try:
cfg = load()
repo_path = Path(cfg.repo).resolve() if getattr(cfg, "repo", None) else repo_path
gh_repo = getattr(cfg, "github_repo", "") or ""
if "/" in gh_repo:
owner, repo_name = gh_repo.split("/", 1)
po_cfg = getattr(cfg, "po", None)
provider = getattr(po_cfg, "provider", provider)
model = getattr(po_cfg, "model", model)
timeout_s = getattr(po_cfg, "timeout_s", timeout_s)
except Exception: # noqa: BLE001 — config-independent: vision discovery still runs
pass

Expand All @@ -703,7 +723,14 @@ def _cmd_brainstorm(args: SimpleNamespace) -> int:

# 3. Run the brainstormer. Tests monkeypatch ``cli._brainstormer_factory``
# to inject a stub that skips the real SDK session.
brainstormer = _brainstormer_factory(repo_path, owner, repo_name)
brainstormer = _brainstormer_factory(
repo_path,
owner,
repo_name,
provider=provider,
model=model,
timeout_s=timeout_s,
)
try:
report: BrainstormReport = brainstormer.run(vision)
except Exception as exc: # noqa: BLE001 — propagate as runtime error to operator
Expand Down
16 changes: 15 additions & 1 deletion src/forge_loop/runner/tick.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
_maybe_deploy_drift_halt,
)
from forge_loop.state import append_event, consolidate_sprint, write_state
from forge_loop.stuck_sweep import SweepReport, sweep as _stuck_sweep
from forge_loop.stuck_sweep import SweepReport
from forge_loop.stuck_sweep import sweep as _stuck_sweep
from forge_loop.worker import WorkerOutcome


Expand Down Expand Up @@ -70,6 +71,9 @@ def _issue_number_from_pr(pr: dict[str, Any]) -> int | None:


def _blocking_pr_repairs(cfg: Config) -> list[tuple[dict[str, Any], dict[str, Any], str]]:
from forge_loop.axis import matches_axes, parse_filter_env

axis_filter = parse_filter_env()
repairs: list[tuple[dict[str, Any], dict[str, Any], str]] = []
for pr in prs_by_label("critic:blocking", cfg.parallel, repo=cfg.github_repo):
issue_num = _issue_number_from_pr(pr)
Expand All @@ -91,6 +95,16 @@ def _blocking_pr_repairs(cfg: Config) -> list[tuple[dict[str, Any], dict[str, An
reason="issue_fetch_failed",
)
continue
if axis_filter and not matches_axes(issue.get("labels") or [], axis_filter):
append_event(
cfg.events_file,
"repair_pr_skipped",
pr=pr.get("url"),
issue=issue_num,
reason="axis_filter_mismatch",
axes=axis_filter,
)
continue
repairs.append((issue, pr, pr_review_context(pr["number"], repo=cfg.github_repo)))
return repairs

Expand Down
36 changes: 35 additions & 1 deletion tests/test_brainstormer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
import pytest

from forge_loop.brainstormer import (
BrainstormReport,
Brainstormer,
BrainstormReport,
ProposedEpic,
ProposedTicket,
_parse_sdk_payload,
Expand Down Expand Up @@ -135,6 +135,40 @@ def test_happy_path_two_epics_three_tickets() -> None:
assert all(isinstance(t, ProposedTicket) for t in report.proposed_tickets)


def test_codex_provider_uses_codex_backend(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
payload = {"proposed_epics": [], "proposed_tickets": []}
calls: dict[str, object] = {}

def fake_codex(**kwargs):
from forge_loop.agent_backend import AgentRunResult

calls.update(kwargs)
return AgentRunResult(
provider="codex",
log_path=kwargs["log_path"],
last_message=json.dumps(payload),
duration_s=0.01,
)

from forge_loop import agent_backend

monkeypatch.setattr(agent_backend, "run_codex_exec", fake_codex)

report = Brainstormer(
repo_path=tmp_path,
provider="codex",
model="gpt-5-codex",
timeout_s=17,
).run(_vision())

assert report.proposed_epics == []
assert report.proposed_tickets == []
assert calls["cwd"] == tmp_path
assert calls["timeout_s"] == 17
assert calls["model"] == "gpt-5-codex"
assert str(calls["log_path"]).endswith(".jsonl")


# ---------------------------------------------------------------------------
# Filters
# ---------------------------------------------------------------------------
Expand Down
35 changes: 32 additions & 3 deletions tests/test_cli_brainstorm.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@

from __future__ import annotations

import os
from pathlib import Path
from typing import Any

Expand All @@ -24,11 +23,9 @@
from typer.testing import CliRunner

from forge_loop import cli
from forge_loop import settings as _fl_settings
from forge_loop.brainstormer import BrainstormReport, ProposedEpic, ProposedTicket
from forge_loop.gh_client import GhError, MockGhClient


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -150,6 +147,38 @@ def test_brainstorm_dry_run_prints_yaml(
assert not any(c[0] == "create_issue" for c in gh.calls)


def test_brainstorm_factory_receives_po_provider_config(
runner: CliRunner, cwd_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
captured: dict[str, Any] = {}
report = BrainstormReport(proposed_epics=[], proposed_tickets=[])

def factory(*args: Any, **kwargs: Any) -> _StubBrainstormer:
captured["args"] = args
captured["kwargs"] = kwargs
return _StubBrainstormer(report)

from types import SimpleNamespace as _NS

monkeypatch.setattr(
cli,
"load",
lambda: _NS(
repo=cwd_repo,
github_repo="acme/widgets",
po=_NS(provider="codex", model="gpt-5-codex", timeout_s=123),
),
)
monkeypatch.setattr(cli, "_brainstormer_factory", factory)

result = runner.invoke(cli.app, ["brainstorm"])

assert result.exit_code == 0, result.stdout + result.stderr
assert captured["kwargs"]["provider"] == "codex"
assert captured["kwargs"]["model"] == "gpt-5-codex"
assert captured["kwargs"]["timeout_s"] == 123


# ---------------------------------------------------------------------------
# --apply path
# ---------------------------------------------------------------------------
Expand Down
29 changes: 28 additions & 1 deletion tests/test_dispatch_axis_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
parse_filter_env,
)


READY = "loop:ready"


Expand Down Expand Up @@ -131,3 +130,31 @@ def test_unknown_axis_filter_produces_empty_result_not_error() -> None:
queue = [_make(1, READY, "axis:dispatch")]
picked = filter_issues_by_axes(queue, ["nonexistent"])
assert picked == []


def test_blocked_pr_repair_respects_axis_filter(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
from forge_loop.config import Config
from forge_loop.runner import tick as tick_mod

cfg = Config(repo=tmp_path, github_repo="acme/widgets")
monkeypatch.setenv(AXIS_FILTER_ENV, "dispatch")
monkeypatch.setattr(
tick_mod,
"prs_by_label",
lambda *_a, **_k: [{"number": 10, "url": "https://github.com/acme/widgets/pull/10", "headRefName": "loop/99-old"}],
)
monkeypatch.setattr(
tick_mod,
"fetch_issue",
lambda *_a, **_k: _make(99, READY, "axis:docs"),
)
monkeypatch.setattr(
tick_mod,
"pr_review_context",
lambda *_a, **_k: "should not be called",
)

repairs = tick_mod._blocking_pr_repairs(cfg)

assert repairs == []
assert "axis_filter_mismatch" in cfg.events_file.read_text()
Loading