diff --git a/codeframe/cli/app.py b/codeframe/cli/app.py index 66b91a2b..2ea85a6c 100644 --- a/codeframe/cli/app.py +++ b/codeframe/cli/app.py @@ -2724,6 +2724,14 @@ def work_resume( console.print(" Use 'codeframe blocker list' to see blockers") elif state.status == AgentStatus.FAILED: console.print("[red]Task execution failed[/red]") + + # Same rule as `work start`: anything short of COMPLETED exits 1, so + # `cf work resume && next_step` cannot proceed on a task that + # did not finish. Exiting 0 on FAILED made the shell read a failed + # run as success. BLOCKED is included for parity with start — it is + # equally "normal" there, and one predictable rule beats two. + if state.status in (AgentStatus.BLOCKED, AgentStatus.FAILED): + raise typer.Exit(1) else: console.print( "\n[yellow]Not executed.[/yellow] The run is RUNNING with no worker; " diff --git a/tests/ui/test_resume_starts_worker_1005.py b/tests/ui/test_resume_starts_worker_1005.py index 6ae57935..e27dc35b 100644 --- a/tests/ui/test_resume_starts_worker_1005.py +++ b/tests/ui/test_resume_starts_worker_1005.py @@ -281,3 +281,44 @@ def _boom(*a, **k): assert "ANTHROPIC_API_KEY is not set" in result.output, ( f"the real error was masked: {result.output!r}" ) + + +# --------------------------------------------------------------------------- +# Exit codes must match `work start` (#1005 follow-up review) +# --------------------------------------------------------------------------- + + +def _invoke_resume_with_status(ws, task_id, status, monkeypatch): + from typer.testing import CliRunner + + from codeframe.core.agent import AgentStatus + + class _State: + blocker = None + + _State.status = getattr(AgentStatus, status) + monkeypatch.setattr(runtime, "execute_agent", lambda *a, **k: _State()) + + from codeframe.cli.app import app + from codeframe.core import workspace as ws_module + + monkeypatch.setattr(ws_module, "get_workspace", lambda p: ws) + return CliRunner().invoke(app, ["work", "resume", task_id[:8]]) + + +@pytest.mark.parametrize("status,expected", [ + ("COMPLETED", 0), + ("FAILED", 1), + ("BLOCKED", 1), +]) +def test_cli_resume_exit_code_matches_work_start( + blocked, monkeypatch, status, expected +): + """Review finding: resume exited 0 even on FAILED, so + `cf work resume && next_step` proceeded on a failed run. `work start` + exits 1 for anything short of COMPLETED (app.py) — resume now does too.""" + _, ws, task_id, _ = blocked + + result = _invoke_resume_with_status(ws, task_id, status, monkeypatch) + + assert result.exit_code == expected, result.output