From 0c77991cfdaee330e43dc48175d1fbf1fb2a7354 Mon Sep 17 00:00:00 2001 From: MAJDOUB Khalid Date: Thu, 28 May 2026 22:47:18 +0200 Subject: [PATCH 1/2] feat: support codex provider for brainstormer --- src/forge_loop/brainstormer.py | 24 +++++++++++++++++++++- src/forge_loop/cli.py | 37 +++++++++++++++++++++++++++++----- tests/test_brainstormer.py | 36 ++++++++++++++++++++++++++++++++- tests/test_cli_brainstorm.py | 35 +++++++++++++++++++++++++++++--- 4 files changed, 122 insertions(+), 10 deletions(-) diff --git a/src/forge_loop/brainstormer.py b/src/forge_loop/brainstormer.py index e173dc8..e00b18e 100644 --- a/src/forge_loop/brainstormer.py +++ b/src/forge_loop/brainstormer.py @@ -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 @@ -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(".") @@ -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.""" @@ -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, + ) diff --git a/src/forge_loop/cli.py b/src/forge_loop/cli.py index 01d58fa..baf4589 100644 --- a/src/forge_loop/cli.py +++ b/src/forge_loop/cli.py @@ -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, @@ -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: @@ -669,12 +684,10 @@ 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 @@ -682,12 +695,19 @@ def _cmd_brainstorm(args: SimpleNamespace) -> int: 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 @@ -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 diff --git a/tests/test_brainstormer.py b/tests/test_brainstormer.py index c9cd7b1..6aceece 100644 --- a/tests/test_brainstormer.py +++ b/tests/test_brainstormer.py @@ -9,8 +9,8 @@ import pytest from forge_loop.brainstormer import ( - BrainstormReport, Brainstormer, + BrainstormReport, ProposedEpic, ProposedTicket, _parse_sdk_payload, @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/test_cli_brainstorm.py b/tests/test_cli_brainstorm.py index 28f65cf..a4dcc10 100644 --- a/tests/test_cli_brainstorm.py +++ b/tests/test_cli_brainstorm.py @@ -15,7 +15,6 @@ from __future__ import annotations -import os from pathlib import Path from typing import Any @@ -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 # --------------------------------------------------------------------------- @@ -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 # --------------------------------------------------------------------------- From 8a995f216bd20505b3058c476163709e67c90cc6 Mon Sep 17 00:00:00 2001 From: MAJDOUB Khalid Date: Thu, 28 May 2026 23:01:01 +0200 Subject: [PATCH 2/2] fix: respect axis filter for blocked PR repairs --- src/forge_loop/runner/tick.py | 16 +++++++++++++++- tests/test_dispatch_axis_filter.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/forge_loop/runner/tick.py b/src/forge_loop/runner/tick.py index 3e7ee38..83b952a 100644 --- a/src/forge_loop/runner/tick.py +++ b/src/forge_loop/runner/tick.py @@ -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 @@ -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) @@ -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 diff --git a/tests/test_dispatch_axis_filter.py b/tests/test_dispatch_axis_filter.py index 9962528..092c181 100644 --- a/tests/test_dispatch_axis_filter.py +++ b/tests/test_dispatch_axis_filter.py @@ -26,7 +26,6 @@ parse_filter_env, ) - READY = "loop:ready" @@ -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()