diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index 5793cb81..03a93d6d 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -152,6 +152,7 @@ from .team_settings import ( from .thread_api import ( ThreadMessageBody, ThreadResolveBody, + admin_cancel_dashboard_thread, cancel_dashboard_thread, delete_dashboard_thread, get_dashboard_thread, @@ -1684,6 +1685,14 @@ async def api_cancel_thread( return await cancel_dashboard_thread(thread_id, session["sub"], email=session.get("email")) +@router.post("/admin/threads/{thread_id}/cancel") +async def admin_cancel_thread( + thread_id: str, + _admin: dict[str, Any] = _ADMIN_DEP, +) -> dict[str, Any]: + return await admin_cancel_dashboard_thread(thread_id) + + @router.delete("/threads/{thread_id}") async def api_delete_thread( thread_id: str, diff --git a/agent/dashboard/thread_api.py b/agent/dashboard/thread_api.py index a6bf0af6..cc3f3015 100644 --- a/agent/dashboard/thread_api.py +++ b/agent/dashboard/thread_api.py @@ -1401,6 +1401,32 @@ async def cancel_dashboard_thread( ) +async def admin_cancel_dashboard_thread(thread_id: str) -> dict[str, Any]: + client = langgraph_client() + try: + thread = await client.threads.get(thread_id) + except Exception as exc: # noqa: BLE001 + raise HTTPException(404, "thread not found") from exc + + metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {} + try: + await client.runs.cancel_many(thread_id=thread_id, status="all", action="interrupt") + except Exception as exc: # noqa: BLE001 + logger.exception("Failed to cancel active runs for thread %s", thread_id) + raise HTTPException(502, "failed to request thread cancellation") from exc + + await client.threads.update( + thread_id=thread_id, + metadata={"latest_run_status": "interrupted", "updated_at_ms": _now_ms()}, + ) + updated_thread = await client.threads.get(thread_id) + return _thread_summary( + updated_thread + if isinstance(updated_thread, dict) + else {"thread_id": thread_id, "metadata": metadata} + ) + + async def delete_dashboard_thread(thread_id: str, login: str, *, email: str | None = None) -> None: client = langgraph_client() try: diff --git a/docs/upstream-sync/triage.jsonl b/docs/upstream-sync/triage.jsonl index 7e3c9430..f3b75842 100644 --- a/docs/upstream-sync/triage.jsonl +++ b/docs/upstream-sync/triage.jsonl @@ -96,7 +96,7 @@ {"sha": "ddbe457b", "pr": 1727, "subject": "fix: restore GPT-5.5 as default model (#1727)", "disposition": "deferred", "reason": "restores GPT-5.5 default in options/team_settings; fork picker is Bedrock/Fireworks-only — rides the #1708 OpenAI-models product decision (27b0ddeb)", "branch": "model-picker", "local_sha": null, "updated": "2026-07-16T18:46:38Z"} {"sha": "1ea03a43", "pr": 1729, "subject": "feat: include Cargo in sandbox image (#1729)", "disposition": "landed", "reason": "2-line Dockerfile add (Cargo); adopt with #1728", "branch": "chore/upstream-easy-picks", "local_sha": null, "updated": "2026-07-16T19:15:42Z"} {"sha": "5136079d", "pr": 1725, "subject": "chore: disable todos for GPT-5.6 Sol (#1725)", "disposition": "wont-merge", "reason": "superseded — #1733 (136d28e6) rewrites the same todo-exclusion block to a global default-off with env opt-in; per-model GPT-5.6 Sol list moot (fork picker has no OpenAI models)", "branch": "", "local_sha": null, "updated": "2026-07-16T18:46:21Z"} -{"sha": "09eaf94c", "pr": 1730, "subject": "fix: let admins interrupt runaway agents (#1730)", "disposition": "deferred", "reason": "admin interrupt for runaway agents (dashboard route + UI); useful ops control; UI half on post-reorg ui/src/features — remap to ui/src/components/agents", "branch": "dashboard-ui", "local_sha": null, "updated": "2026-07-16T18:47:07Z"} +{"sha": "09eaf94c", "pr": 1730, "subject": "fix: let admins interrupt runaway agents (#1730)", "disposition": "landed", "reason": "admin interrupt for runaway agents (dashboard route + UI); useful ops control; UI half on post-reorg ui/src/features — remap to ui/src/components/agents", "branch": "feat/admin-thread-interrupt", "local_sha": null, "updated": "2026-07-16T19:36:07Z"} {"sha": "30832d29", "pr": 1731, "subject": "fix: preserve OpenAI Responses tool history (#1731)", "disposition": "deferred", "reason": "deletes SanitizeOpenAIResponsesMiddleware in favor of replay-history preservation in utils/model.py; supersedes deferred #1718 (35659177) — triage the pair together against fork-diverged middleware + model.py", "branch": "openai-sanitize", "local_sha": null, "updated": "2026-07-16T18:46:38Z"} {"sha": "1ea0e600", "pr": 1736, "subject": "fix: bind cached GitHub tokens to users (#1736)", "disposition": "deferred", "reason": "FLAG-HUMAN: security fix — binds per-thread cached GitHub tokens to a user principal (closes cross-user token-reuse leak). Fork has github_token.py but not webhooks/common.py — hand-map; auth surface: GPT-4.1 cross-review + /sh-security-review on landing. Priority pick.", "branch": "github-token-binding", "local_sha": null, "updated": "2026-07-16T18:46:37Z"} {"sha": "3fcb27ce", "pr": 1737, "subject": "fix: bound reviewer diff fetching (#1737)", "disposition": "deferred", "reason": "bounds reviewer diff fetching + new fetch_review_diff tool; reviewer.py fork-diverged — reconcile like #1713 (71e3b818)", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-16T18:46:52Z"} diff --git a/docs/upstream-sync/triage.md b/docs/upstream-sync/triage.md index d697a413..ed0d7bc8 100644 --- a/docs/upstream-sync/triage.md +++ b/docs/upstream-sync/triage.md @@ -67,6 +67,7 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro | `22e024cb` | #1704 | fix: link issue PRs and prompt repo conventions (#1704) | Landed | issue/PR linking + repo-convention prompt; clean but prompt-conflict risk vs #113 | chore/upstream-easy-picks | | `129ddcf9` | #1728 | chore: include ripgrep in sandbox image (#1728) | Landed | 1-line Dockerfile add (ripgrep); dev image lacks it; trivial pick | chore/upstream-easy-picks | | `1ea03a43` | #1729 | feat: include Cargo in sandbox image (#1729) | Landed | 2-line Dockerfile add (Cargo); adopt with #1728 | chore/upstream-easy-picks | +| `09eaf94c` | #1730 | fix: let admins interrupt runaway agents (#1730) | Landed | admin interrupt for runaway agents (dashboard route + UI); useful ops control; UI half on post-reorg ui/src/features — remap to ui/src/components/agents | feat/admin-thread-interrupt | | `c69459ad` | #1751 | fix: prefer LangSmith tools for trace links (#1751) | Landed | 1-line prompt: prefer LangSmith tools for trace links; trivial but edits fork-customized prompt.py | chore/upstream-easy-picks | | `5cb2e2bb` | #1750 | fix: add trace link to error banner (#1750) | Landed | adds trace link to error banner (13 lines); remap AgentThreadView.tsx path (fork: ui/src/components/agents/) | chore/upstream-easy-picks | | `c3292d82` | #1611 | bake sfw binary into sandbox image | Won't merge | already in dev | | @@ -114,7 +115,6 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro | `8356eb34` | #1726 | refactor: organize repository by domain (#1726) | Deferred | FLAG-HUMAN: 298-file structural reorg (tests//, ui/src/features/); chain head — every later upstream commit is written against this layout. Fork policy defers structural refactors (CLAUDE.md); adopting is a dedicated merge exercise. Until then, later picks need path remapping. | domain-reorg | | `83abea26` | #1724 | fix: accept natural-language Slack plan approvals (#1724) | Deferred | natural-language Slack plan approvals; touches fork-diverged plan-mode + Slack webhook stack (#130); post-reorg test paths need remap | plan-approval | | `ddbe457b` | #1727 | fix: restore GPT-5.5 as default model (#1727) | Deferred | restores GPT-5.5 default in options/team_settings; fork picker is Bedrock/Fireworks-only — rides the #1708 OpenAI-models product decision (27b0ddeb) | model-picker | -| `09eaf94c` | #1730 | fix: let admins interrupt runaway agents (#1730) | Deferred | admin interrupt for runaway agents (dashboard route + UI); useful ops control; UI half on post-reorg ui/src/features — remap to ui/src/components/agents | dashboard-ui | | `30832d29` | #1731 | fix: preserve OpenAI Responses tool history (#1731) | Deferred | deletes SanitizeOpenAIResponsesMiddleware in favor of replay-history preservation in utils/model.py; supersedes deferred #1718 (35659177) — triage the pair together against fork-diverged middleware + model.py | openai-sanitize | | `1ea0e600` | #1736 | fix: bind cached GitHub tokens to users (#1736) | Deferred | FLAG-HUMAN: security fix — binds per-thread cached GitHub tokens to a user principal (closes cross-user token-reuse leak). Fork has github_token.py but not webhooks/common.py — hand-map; auth surface: GPT-4.1 cross-review + /sh-security-review on landing. Priority pick. | github-token-binding | | `3fcb27ce` | #1737 | fix: bound reviewer diff fetching (#1737) | Deferred | bounds reviewer diff fetching + new fetch_review_diff tool; reviewer.py fork-diverged — reconcile like #1713 (71e3b818) | reviewer-misc | diff --git a/tests/test_dashboard_thread_api.py b/tests/test_dashboard_thread_api.py index 067391e7..d409f291 100644 --- a/tests/test_dashboard_thread_api.py +++ b/tests/test_dashboard_thread_api.py @@ -1508,3 +1508,94 @@ async def test_options_gates_stale_fable_default_when_disabled() -> None: assert payload["default_agent_subagent_model"] != _FABLE assert payload["default_agent_model"] in model_ids assert payload["default_agent_subagent_model"] in model_ids + + +async def test_admin_cancel_dashboard_thread_interrupts_all_active_runs(monkeypatch) -> None: + calls: list[tuple[str, dict[str, object]]] = [] + thread = { + "thread_id": "thread-1", + "status": "busy", + "metadata": { + "title": "Runaway thread", + "latest_run_status": "running", + "updated_at_ms": 1, + }, + } + + class FakeThreads: + async def get(self, thread_id: str) -> dict[str, object]: + assert thread_id == "thread-1" + return thread + + async def update(self, **kwargs: object) -> None: + calls.append(("update", kwargs)) + metadata = kwargs["metadata"] + assert isinstance(metadata, dict) + thread["metadata"].update(metadata) + + class FakeRuns: + async def cancel_many(self, **kwargs: object) -> None: + calls.append(("cancel_many", kwargs)) + + class FakeClient: + threads = FakeThreads() + runs = FakeRuns() + + monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient()) + + result = await thread_api.admin_cancel_dashboard_thread("thread-1") + + assert calls[0] == ( + "cancel_many", + {"thread_id": "thread-1", "status": "all", "action": "interrupt"}, + ) + assert calls[1][0] == "update" + assert thread["metadata"]["latest_run_status"] == "interrupted" + assert result["id"] == "thread-1" + + +async def test_admin_cancel_dashboard_thread_does_not_update_on_cancel_failure(monkeypatch) -> None: + updated = False + + class FakeThreads: + async def get(self, thread_id: str) -> dict[str, object]: + return {"thread_id": thread_id, "status": "busy", "metadata": {}} + + async def update(self, **kwargs: object) -> None: + nonlocal updated + updated = True + + class FakeRuns: + async def cancel_many(self, **kwargs: object) -> None: + raise RuntimeError("runtime unavailable") + + class FakeClient: + threads = FakeThreads() + runs = FakeRuns() + + monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient()) + + with pytest.raises(HTTPException) as exc_info: + await thread_api.admin_cancel_dashboard_thread("thread-1") + + assert exc_info.value.status_code == 502 + assert updated is False + + +async def test_admin_cancel_thread_route_delegates_without_owner_identity(monkeypatch) -> None: + cancel = AsyncMock(return_value={"id": "thread-1", "status": "interrupted"}) + monkeypatch.setattr(routes, "admin_cancel_dashboard_thread", cancel) + + result = await routes.admin_cancel_thread("thread-1", _admin={"sub": "admin"}) + + assert result == {"id": "thread-1", "status": "interrupted"} + cancel.assert_awaited_once_with("thread-1") + + +def test_admin_cancel_thread_dependency_rejects_non_admin(monkeypatch) -> None: + monkeypatch.setenv("CONFIGURED_ADMINS", "admin") + + with pytest.raises(HTTPException) as exc_info: + routes._require_admin({"sub": "not-admin", "email": "user@example.com"}) + + assert exc_info.value.status_code == 403 diff --git a/ui/src/lib/agents/api.ts b/ui/src/lib/agents/api.ts index bd93beef..bb0dc0f6 100644 --- a/ui/src/lib/agents/api.ts +++ b/ui/src/lib/agents/api.ts @@ -110,6 +110,7 @@ export interface WorkflowApprovalsPayload { export interface ThreadsPageParams { limit?: number offset?: number + all?: boolean resolved?: boolean viewed?: boolean source?: string @@ -211,6 +212,7 @@ function buildThreadsPageQuery(params: ThreadsPageParams): string { const search = new URLSearchParams() if (params.limit != null) search.set("limit", String(params.limit)) if (params.offset != null) search.set("offset", String(params.offset)) + if (params.all != null) search.set("all", String(params.all)) if (params.resolved != null) search.set("resolved", String(params.resolved)) if (params.viewed != null) search.set("viewed", String(params.viewed)) if (params.source) search.set("source", params.source) @@ -293,6 +295,13 @@ export const agentsApi = { method: "POST", } ), + adminCancelThread: (threadId: string) => + agentsRequest( + `/admin/threads/${encodeURIComponent(threadId)}/cancel`, + { + method: "POST", + } + ), deleteThread: (threadId: string) => agentsRequest(`/threads/${encodeURIComponent(threadId)}`, { method: "DELETE", diff --git a/ui/src/lib/agents/queries.ts b/ui/src/lib/agents/queries.ts index 02b426f4..a5a50202 100644 --- a/ui/src/lib/agents/queries.ts +++ b/ui/src/lib/agents/queries.ts @@ -284,6 +284,18 @@ export function useCancelAgentThread(threadId: string) { }) } +export function useAdminCancelAgentThread() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (threadId: string) => agentsApi.adminCancelThread(threadId), + onSuccess: (thread) => { + queryClient.setQueryData(agentThreadKeys.detail(thread.id), thread) + invalidateAgentThreadLists(queryClient) + }, + }) +} + export function useDeleteAgentThread() { const queryClient = useQueryClient() const navigate = useNavigate() diff --git a/ui/src/routes/admin.tsx b/ui/src/routes/admin.tsx index 15b4ece6..caf03c3c 100644 --- a/ui/src/routes/admin.tsx +++ b/ui/src/routes/admin.tsx @@ -24,6 +24,10 @@ import { import { Skeleton } from "@/components/ui/skeleton" import { Switch } from "@/components/ui/switch" import { api } from "@/lib/api" +import { + useAdminCancelAgentThread, + useThreadsPage, +} from "@/lib/agents/queries" import { RequireLogin } from "@/lib/auth-redirect" import { useSession } from "@/lib/session" @@ -62,6 +66,8 @@ function AdminPage() { + + (null) + + return ( + +
+
+ + {threads.data?.items.length ?? 0} running + + +
+ + {threads.isLoading ? ( + + ) : threads.data?.items.length ? ( +
+ {threads.data.items.map((thread) => { + const isCancelling = + cancel.isPending && cancel.variables === thread.id + return ( +
+ +

+ {thread.title} +

+

+ {thread.repoFullName || "no repo"} · {thread.id} +

+ + +
+ ) + })} +
+ ) : ( +

No running agents.

+ )} + + {threads.error && ( +

{threads.error.message}

+ )} + {message && ( +

+ {message} +

+ )} +
+
+ ) +} + const PR_URL_RE = /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/ function TriggerReviewSection() {