Skip to content

Commit 2323895

Browse files
solomonneascursoragentclaude
authored
feat(run): preflight Cloudflare AI Gateway seats for required env vars (#394)
Preflight every child-launch path (direct worker, grok invalid-final fallback, orchestrator) for cloudflare-ai-gateway/ routes: roster doctor and dispatch fail before launching the child when CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_GATEWAY_ID are missing, classified as provider configuration (not a generic adapter error) in worker results, run.json, and the human summary. Names only, never values. Closes #394 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3f05f4e commit 2323895

8 files changed

Lines changed: 517 additions & 1 deletion

File tree

src/brigade/aboyeur.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,15 @@ def _run_orchestrator(
725725
ok=False,
726726
detail="codex-cloud seats are workers only; pick a local CLI for the orchestrator",
727727
)
728+
cloudflare_detail = agents.cloudflare_ai_gateway_preflight_detail(orchestrator.model)
729+
if cloudflare_detail is not None:
730+
return agents.AgentResult(
731+
text="",
732+
ok=False,
733+
detail=cloudflare_detail,
734+
failure_phase="preflight",
735+
failure_kind="provider-config",
736+
)
728737
kwargs: dict[str, object] = {
729738
"timeout": timeout_for(orchestrator, roster),
730739
"cwd": cwd,
@@ -1951,6 +1960,8 @@ def _run_payload(
19511960
payload["error"] = error
19521961
if failure_phase is not None or failure_kind is not None:
19531962
payload["failure_phase"] = failure_phase or "unknown"
1963+
if failure_kind is not None:
1964+
payload["failure_kind"] = failure_kind
19541965
failure_payload: dict[str, object] = {
19551966
"phase": failure_phase or "unknown",
19561967
"kind": failure_kind or "unknown",
@@ -2333,6 +2344,12 @@ def _payload(**kwargs: Any) -> dict[str, object]:
23332344
if output_dir is not None:
23342345
finished_at = datetime.now(timezone.utc)
23352346
_write_json(output_dir / "plan-attempts.json", {"attempts": plan_attempts or []})
2347+
failure_phase = "planning"
2348+
if isinstance(final_attempt, dict):
2349+
attempt_phase = final_attempt.get("failure_phase")
2350+
attempt_kind = final_attempt.get("failure_kind")
2351+
if attempt_phase == "preflight" and attempt_kind == "provider-config":
2352+
failure_phase = "preflight"
23362353
_write_json(
23372354
output_dir / "run.json",
23382355
_payload(
@@ -2346,7 +2363,7 @@ def _payload(**kwargs: Any) -> dict[str, object]:
23462363
finished_at=finished_at,
23472364
output_dir=output_dir,
23482365
error=str(exc),
2349-
failure_phase="planning",
2366+
failure_phase=failure_phase,
23502367
failure_kind=failure_kind,
23512368
failure_seat=roster.orchestrator,
23522369
code_graph=code_graph,

src/brigade/agents.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import os
1313
import re
1414
import unicodedata
15+
from collections.abc import Mapping
1516
from dataclasses import dataclass
1617
from pathlib import Path
1718
from typing import Callable, List
@@ -21,6 +22,8 @@
2122

2223
_OLLAMA_PREFIX = "ollama:"
2324
_CODEX_CLOUD_PREFIX = "codex-cloud:"
25+
_CLOUDFLARE_AI_GATEWAY_PREFIX = "cloudflare-ai-gateway/"
26+
_CLOUDFLARE_AI_GATEWAY_REQUIRED_ENV = ("CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_GATEWAY_ID")
2427
_NONRECOVERABLE_GROK_OUTPUT_FAILURES = frozenset(
2528
{
2629
"provider-error",
@@ -578,6 +581,50 @@ def secret_ref_targets(env: dict[str, str]) -> set[str]:
578581
return {key[: -len("_REF")] for key in env if key.endswith("_REF") and key != "_REF"}
579582

580583

584+
def is_cloudflare_ai_gateway_route(model: str | None) -> bool:
585+
"""Return True if the model route is a Cloudflare AI Gateway route.
586+
587+
The first path segment must be exactly ``cloudflare-ai-gateway``, not a
588+
substring such as ``cloudflare-ai-gateway-other``.
589+
"""
590+
591+
if not model:
592+
return False
593+
if not model.startswith(_CLOUDFLARE_AI_GATEWAY_PREFIX):
594+
return False
595+
return len(model) > len(_CLOUDFLARE_AI_GATEWAY_PREFIX)
596+
597+
598+
def missing_cloudflare_ai_gateway_env_vars(
599+
env: Mapping[str, str] | None = None,
600+
) -> list[str]:
601+
"""Return the required Cloudflare AI Gateway env vars that are missing.
602+
603+
Reads only variable names and presence; never reads or prints values.
604+
Empty strings count as missing.
605+
``env`` defaults to ``os.environ``.
606+
"""
607+
608+
if env is None:
609+
env = os.environ
610+
return [name for name in _CLOUDFLARE_AI_GATEWAY_REQUIRED_ENV if not env.get(name)]
611+
612+
613+
def cloudflare_ai_gateway_preflight_detail(model: str | None) -> str | None:
614+
"""Return a provider-config detail if the Cloudflare route lacks env.
615+
616+
Empty string values are treated as missing. Returns None when the route is
617+
not a Cloudflare AI Gateway route or when all required env vars are present.
618+
"""
619+
620+
if not is_cloudflare_ai_gateway_route(model):
621+
return None
622+
missing = missing_cloudflare_ai_gateway_env_vars()
623+
if not missing:
624+
return None
625+
return f"Cloudflare AI Gateway seat missing required env vars: {', '.join(missing)}; set them before running"
626+
627+
581628
_MIN_SCRUB_VALUE_LENGTH = 8
582629
_COMBINING_MARK_CATEGORIES = frozenset({"Mn", "Mc", "Me"})
583630

src/brigade/roster_cmd.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,22 @@ def doctor(target: Path, *, roster_path: Path | None = None) -> int:
195195
f"{agent.cli} does not support model pinning; drop model= or switch cli",
196196
)
197197
)
198+
# Endpoint-mode agents are intentionally exempt: their model is a
199+
# remote HTTP model name, not a local CLI route that needs Cloudflare
200+
# env vars (the cli=None branch above already continued past this).
201+
if agents.is_cloudflare_ai_gateway_route(agent.model):
202+
missing = agents.missing_cloudflare_ai_gateway_env_vars()
203+
label = f"agent: {name} cloudflare gateway"
204+
if missing:
205+
checks.append(
206+
(
207+
doctor_mod.FAIL,
208+
label,
209+
f"requires env vars: {', '.join(missing)}; set them before running",
210+
)
211+
)
212+
else:
213+
checks.append((doctor_mod.OK, label, "required env vars are set"))
198214
if agent.reasoning is not None:
199215
if agents.supports_reasoning(agent.cli):
200216
checks.append((doctor_mod.OK, f"agent: {name} reasoning", f"{agent.reasoning} via {agent.cli}"))

src/brigade/run_transport.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,26 @@ def _is_direct_grok_invalid_final(
7272
)
7373

7474

75+
def _cloudflare_preflight_failure(agent: Agent, assignment: Assignment) -> WorkerResult | None:
76+
"""Return a preflight failure if the agent's Cloudflare route lacks env.
77+
78+
Empty string values are treated as missing.
79+
"""
80+
81+
detail = agents.cloudflare_ai_gateway_preflight_detail(agent.model)
82+
if detail is None:
83+
return None
84+
return WorkerResult(
85+
worker=assignment.worker,
86+
task=assignment.task,
87+
text="",
88+
ok=False,
89+
detail=detail,
90+
failure_phase="preflight",
91+
failure_kind="provider-config",
92+
)
93+
94+
7595
def _worker_attempt(
7696
*,
7797
kind: str,
@@ -280,6 +300,9 @@ def run_one(assignment: Assignment, prior_results: list[WorkerResult]) -> Worker
280300
else f"{agent.cli} is not allowed by limits.allow_models"
281301
),
282302
)
303+
preflight = _cloudflare_preflight_failure(agent, assignment)
304+
if preflight is not None:
305+
return preflight
283306
prompt = build_prompt(
284307
agent,
285308
assignment,
@@ -549,6 +572,21 @@ def finish(
549572
return finish(missing_fallback, agent, attempts)
550573

551574
fallback_agent = roster.agents[fallback_name]
575+
fallback_cloudflare_detail = agents.cloudflare_ai_gateway_preflight_detail(fallback_agent.model)
576+
if fallback_cloudflare_detail is not None:
577+
# Route through finish() so the accumulated grok attempt history and
578+
# elapsed duration are preserved in the persisted WorkerResult.
579+
return finish(
580+
agents.AgentResult(
581+
text="",
582+
ok=False,
583+
detail=fallback_cloudflare_detail,
584+
failure_phase="preflight",
585+
failure_kind="provider-config",
586+
),
587+
fallback_agent,
588+
attempts,
589+
)
552590
fallback_prompt = build_prompt(
553591
fallback_agent,
554592
assignment,

tests/test_aboyeur.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3154,6 +3154,63 @@ def fake_run_agent(cli_ref, prompt, timeout=600.0, cwd=None, read_only=False):
31543154
}
31553155

31563156

3157+
def test_orchestrator_cloudflare_preflight_fails_before_plan_and_reaches_run_json(monkeypatch, tmp_path, capsys):
3158+
_CF_MODEL_ROUTE = "cloudflare-ai-gateway/openai/gpt-5.3-codex"
3159+
_FAKE_CF_ACCOUNT = "fake-account-id-for-test"
3160+
_FAKE_CF_GATEWAY = "fake-gateway-id-for-test"
3161+
3162+
roster = Roster(
3163+
orchestrator="chef",
3164+
agents={
3165+
"chef": Agent(
3166+
name="chef",
3167+
cli="codex",
3168+
role="plan",
3169+
model=_CF_MODEL_ROUTE,
3170+
),
3171+
"coder": Agent(name="coder", cli="codex", role="worker"),
3172+
},
3173+
max_workers=1,
3174+
)
3175+
3176+
def should_not_run(*args, **kwargs): # noqa: ARG001
3177+
raise AssertionError("agents.run_agent must not be called when Cloudflare orchestrator env is missing")
3178+
3179+
monkeypatch.setattr(aboyeur.agents, "run_agent", should_not_run)
3180+
monkeypatch.delenv("CLOUDFLARE_ACCOUNT_ID", raising=False)
3181+
monkeypatch.delenv("CLOUDFLARE_GATEWAY_ID", raising=False)
3182+
3183+
output_dir = tmp_path / "run"
3184+
rc = aboyeur.run(
3185+
"build feature",
3186+
roster,
3187+
output_dir=output_dir,
3188+
code_graph_enabled=False,
3189+
route_enabled=False,
3190+
)
3191+
3192+
assert rc == 2
3193+
err = capsys.readouterr().err
3194+
assert "orchestrator failed during plan" in err
3195+
assert "Cloudflare AI Gateway" in err
3196+
run_meta = json.loads((output_dir / "run.json").read_text())
3197+
assert run_meta["status"] == "failed"
3198+
assert run_meta["failure_phase"] == "preflight"
3199+
assert run_meta["failure_kind"] == "provider-config"
3200+
assert run_meta["failure"] == {
3201+
"phase": "preflight",
3202+
"kind": "provider-config",
3203+
"detail": (
3204+
"orchestrator failed during plan: "
3205+
"Cloudflare AI Gateway seat missing required env vars: "
3206+
"CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_GATEWAY_ID; set them before running"
3207+
),
3208+
"seat": "chef",
3209+
}
3210+
assert _FAKE_CF_ACCOUNT not in str(run_meta)
3211+
assert _FAKE_CF_GATEWAY not in str(run_meta)
3212+
3213+
31573214
def test_synthesis_failure_writes_artifact(monkeypatch, tmp_path, capsys):
31583215
calls = []
31593216

tests/test_agents.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1677,3 +1677,55 @@ def test_run_agent_env_ref_empty_value_is_typed_failure(monkeypatch):
16771677
assert not result.ok
16781678
assert result.failure_kind == "env-ref-missing"
16791679
assert "is not set or is empty" in result.detail
1680+
1681+
1682+
_CF_MODEL_ROUTE = "cloudflare-ai-gateway/openai/gpt-5.3-codex"
1683+
1684+
1685+
@pytest.mark.parametrize(
1686+
("route", "expected"),
1687+
[
1688+
(_CF_MODEL_ROUTE, True),
1689+
("cloudflare-ai-gateway-other/openai/gpt-5.3-codex", False),
1690+
("openai-cloudflare-ai-gateway/gpt-5.3-codex", False),
1691+
("foo/cloudflare-ai-gateway/openai/gpt-5.3-codex", False),
1692+
(None, False),
1693+
("", False),
1694+
],
1695+
)
1696+
def test_is_cloudflare_ai_gateway_route(route, expected):
1697+
assert agents.is_cloudflare_ai_gateway_route(route) is expected
1698+
1699+
1700+
def test_missing_cloudflare_ai_gateway_env_vars_both_missing():
1701+
assert agents.missing_cloudflare_ai_gateway_env_vars({}) == [
1702+
"CLOUDFLARE_ACCOUNT_ID",
1703+
"CLOUDFLARE_GATEWAY_ID",
1704+
]
1705+
1706+
1707+
@pytest.mark.parametrize(
1708+
("env", "expected_missing"),
1709+
[
1710+
({"CLOUDFLARE_ACCOUNT_ID": "fake-account-id-for-test"}, ["CLOUDFLARE_GATEWAY_ID"]),
1711+
({"CLOUDFLARE_GATEWAY_ID": "fake-gateway-id-for-test"}, ["CLOUDFLARE_ACCOUNT_ID"]),
1712+
(
1713+
{
1714+
"CLOUDFLARE_ACCOUNT_ID": "fake-account-id-for-test",
1715+
"CLOUDFLARE_GATEWAY_ID": "fake-gateway-id-for-test",
1716+
},
1717+
[],
1718+
),
1719+
],
1720+
)
1721+
def test_missing_cloudflare_ai_gateway_env_vars_partial(env, expected_missing):
1722+
assert agents.missing_cloudflare_ai_gateway_env_vars(env) == expected_missing
1723+
1724+
1725+
def test_missing_cloudflare_ai_gateway_env_vars_empty_string_counts_as_missing():
1726+
assert agents.missing_cloudflare_ai_gateway_env_vars(
1727+
{
1728+
"CLOUDFLARE_ACCOUNT_ID": "",
1729+
"CLOUDFLARE_GATEWAY_ID": "fake-gateway-id-for-test",
1730+
}
1731+
) == ["CLOUDFLARE_ACCOUNT_ID"]

tests/test_roster_cmd.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from pathlib import Path
22

3+
import pytest
4+
35
from brigade import agents
46
from brigade import cli
57
from brigade import model_inventory
@@ -416,6 +418,68 @@ def test_roster_doctor_fails_pin_on_ollama_ref(monkeypatch, tmp_target, capsys):
416418
assert "ollama names its model in the cli ref" in out
417419

418420

421+
_CF_MODEL_ROUTE = "cloudflare-ai-gateway/openai/gpt-5.3-codex"
422+
_FAKE_CF_ACCOUNT = "fake-account-id-for-test"
423+
_FAKE_CF_GATEWAY = "fake-gateway-id-for-test"
424+
425+
426+
def _write_cloudflare_gateway_roster(tmp_target) -> None:
427+
_write_roster(
428+
tmp_target,
429+
'orchestrator = "chef"\n'
430+
'[agents.chef]\ncli = "codex"\nmodel = "gpt-5.5"\nrole = "plan"\n'
431+
f'[agents.cf_worker]\ncli = "codex"\nmodel = "{_CF_MODEL_ROUTE}"\nrole = "worker"\n',
432+
)
433+
434+
435+
def _clear_cloudflare_gateway_env(monkeypatch) -> None:
436+
monkeypatch.delenv("CLOUDFLARE_ACCOUNT_ID", raising=False)
437+
monkeypatch.delenv("CLOUDFLARE_GATEWAY_ID", raising=False)
438+
439+
440+
def test_roster_doctor_ok_for_cloudflare_gateway_when_env_present(monkeypatch, tmp_target, capsys):
441+
_write_cloudflare_gateway_roster(tmp_target)
442+
_clear_cloudflare_gateway_env(monkeypatch)
443+
monkeypatch.setattr(agents.proc, "which", lambda cmd: "/x/" + cmd)
444+
monkeypatch.setenv("CLOUDFLARE_ACCOUNT_ID", _FAKE_CF_ACCOUNT)
445+
monkeypatch.setenv("CLOUDFLARE_GATEWAY_ID", _FAKE_CF_GATEWAY)
446+
447+
rc = roster_cmd.doctor(tmp_target)
448+
out = capsys.readouterr().out
449+
450+
assert rc == 0
451+
assert "[ok] agent: cf_worker cloudflare gateway" in out
452+
assert "required env vars are set" in out
453+
assert _FAKE_CF_ACCOUNT not in out
454+
assert _FAKE_CF_GATEWAY not in out
455+
456+
457+
@pytest.mark.parametrize(
458+
("env", "missing_vars"),
459+
[
460+
({}, ("CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_GATEWAY_ID")),
461+
({"CLOUDFLARE_ACCOUNT_ID": _FAKE_CF_ACCOUNT}, ("CLOUDFLARE_GATEWAY_ID",)),
462+
({"CLOUDFLARE_GATEWAY_ID": _FAKE_CF_GATEWAY}, ("CLOUDFLARE_ACCOUNT_ID",)),
463+
],
464+
)
465+
def test_roster_doctor_fails_cloudflare_gateway_when_env_missing(monkeypatch, tmp_target, capsys, env, missing_vars):
466+
_write_cloudflare_gateway_roster(tmp_target)
467+
_clear_cloudflare_gateway_env(monkeypatch)
468+
monkeypatch.setattr(agents.proc, "which", lambda cmd: "/x/" + cmd)
469+
for name, value in env.items():
470+
monkeypatch.setenv(name, value)
471+
472+
rc = roster_cmd.doctor(tmp_target)
473+
out = capsys.readouterr().out
474+
475+
assert rc == 1
476+
assert "[fail] agent: cf_worker cloudflare gateway" in out
477+
for var in missing_vars:
478+
assert var in out
479+
assert _FAKE_CF_ACCOUNT not in out
480+
assert _FAKE_CF_GATEWAY not in out
481+
482+
419483
def test_roster_doctor_endpoint_agent_skips_pin_check(tmp_target, capsys):
420484
_write_roster(
421485
tmp_target,

0 commit comments

Comments
 (0)