From bf01dd924f9a0c9d375fca647d9f44e41ac2bba3 Mon Sep 17 00:00:00 2001 From: Abhishek Mishra Date: Tue, 25 Aug 2026 15:48:35 +0530 Subject: [PATCH] feat(cli): agent-crew cancel to cancel a stuck build Adds the CLI half of the build-cancel flow (CLI redesign Tier 3). New command 'smallestai agent-crew cancel ' with --agent-id and --yes, backed by AtomsAPIClient.cancel_agent_build, which POSTs /atoms/v1/sdk/agents/{agentId}/builds/{buildId}/cancel. A build that is already terminal or deploying returns 409, surfaced as a clear message. Frees an agent's build queue when a build hangs in QUEUED/BUILDING. Gated on the platform cancel endpoint (atoms-platform #3409) shipping. No version bump yet; release once the endpoint is live. --- src/smallestai/cli/agent_crew.py | 36 ++++++++++++++++++++++++++++++++ src/smallestai/cli/lib/atoms.py | 35 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/smallestai/cli/agent_crew.py b/src/smallestai/cli/agent_crew.py index 8c2a8fa7..968d8b65 100644 --- a/src/smallestai/cli/agent_crew.py +++ b/src/smallestai/cli/agent_crew.py @@ -584,6 +584,42 @@ async def async_build_logs(build_id: str | None, agent_id_arg: Optional[str] = N console.print(f"[red]Error streaming build logs: {e}[/red]") raise typer.Exit(1) + @app.command("cancel") + def cancel_build( + build_id: str = typer.Argument(..., help="Build ID to cancel."), + agent_id: Optional[str] = typer.Option( + None, "--agent-id", help="Agent id (defaults to the linked project agent)." + ), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), + ): + """Cancel a build that is stuck in QUEUED or BUILDING. + + Frees the agent's build queue when a build hangs. A build that has already + finished, failed, or is deploying can't be cancelled. + """ + if not yes and sys.stdin.isatty(): + typer.confirm(f"Cancel build {build_id}?", abort=True) + asyncio.run(async_cancel_build(build_id, agent_id)) + + async def async_cancel_build(build_id: str, agent_id_arg: Optional[str] = None): + agent_id = _resolve_agent_id(agent_id_arg) + + credentials = auth_client.get_credentials() + if not credentials or not credentials.get("access_token"): + console.print("[red]Error: You must be logged in first. Run 'smallestai auth login'[/red]") + raise typer.Exit(1) + access_token = credentials["access_token"] + + try: + message = await atoms_client.cancel_agent_build( + agent_id=agent_id, build_id=build_id, api_key=access_token + ) + except Exception as e: + console.print(f"[red]Could not cancel build {build_id[:12]}...: {e}[/red]") + raise typer.Exit(1) + + console.print(f"[bold green]✓ {message}[/bold green] [dim]({build_id[:12]}...)[/dim]") + @app.command() def doctor( agent_id: Optional[str] = typer.Option( diff --git a/src/smallestai/cli/lib/atoms.py b/src/smallestai/cli/lib/atoms.py index 32bc0866..1b06c7b8 100644 --- a/src/smallestai/cli/lib/atoms.py +++ b/src/smallestai/cli/lib/atoms.py @@ -306,6 +306,41 @@ async def update_agent_build( return update_build_response.data + async def cancel_agent_build( + self, + agent_id: str, + build_id: str, + api_key: str, + ) -> str: + """Cancel a build stuck in QUEUED/BUILDING. Returns the server's message. + + Raises with the API's reason when the build can't be cancelled (e.g. it + is already terminal or deploying, which the backend returns as HTTP 409). + """ + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.base_url}/atoms/v1/sdk/agents/{agent_id}/builds/{build_id}/cancel", + headers={ + "Authorization": f"Bearer {api_key}", + }, + ) + + body = None + try: + body = response.json() + except Exception: + pass + + if response.status_code >= 400: + errors = body.get("errors") if isinstance(body, dict) else None + if isinstance(errors, list): + errors = "; ".join(str(e) for e in errors) + raise Exception(errors or f"cancel failed (HTTP {response.status_code}).") + + data = body.get("data") if isinstance(body, dict) else None + message = data.get("message") if isinstance(data, dict) else None + return message or "Build cancelled" + async def _stream_sse(self, url: str, access_token: str): """Open an SSE stream and yield each `data:` frame as a parsed dict.