chore: let admins interrupt runaway agent runs (#195)
Some checks are pending
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Typecheck (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
CI / Docker build smoke (push) Waiting to run
CI / Triage ledger up to date (push) Waiting to run
CI / ui bun.lock in sync (push) Waiting to run

* fix: let admins interrupt runaway agents (#1730)

Add a workspace-wide admin control that cancels every active run while preserving thread history.

(cherry picked from commit 09eaf94c3e612db969daa000b909805963fe1de9)

* chore: reconcile triage ledger

Signed-off-by: Adam Moussa <adam@seahavenind.com>

* fix: change ui import from refactor

Signed-off-by: Adam Moussa <adam@seahavenind.com>

---------

Signed-off-by: Adam Moussa <adam@seahavenind.com>
Co-authored-by: Johannes du Plessis <johannes@langchain.dev>
This commit is contained in:
Adam Moussa 2026-07-16 16:05:03 -04:00 • committed by GitHub
parent 0f6eaaea78
commit a534a2e247
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 246 additions and 2 deletions

View file

@ -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,

View file

@ -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:

View file

@ -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"}

View file

@ -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/<domain>/, 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 |

View file

@ -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

View file

@ -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<AgentThread>(
`/admin/threads/${encodeURIComponent(threadId)}/cancel`,
{
method: "POST",
}
),
deleteThread: (threadId: string) =>
agentsRequest<void>(`/threads/${encodeURIComponent(threadId)}`, {
method: "DELETE",

View file

@ -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()

View file

@ -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() {
<TriggerReviewSection />
<RunningAgentsSection />
<SettingsSection title="Evals">
<Link
to="/admin/evals"
@ -89,6 +95,97 @@ function AdminPage() {
)
}
function RunningAgentsSection() {
const threads = useThreadsPage({
all: true,
status: "running",
limit: 50,
})
const cancel = useAdminCancelAgentThread()
const [message, setMessage] = useState<string | null>(null)
return (
<SettingsSection
title="Running agents"
description="Workspace-wide active threads. Killing a thread requests interruption of all pending and running runs without deleting its history."
>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{threads.data?.items.length ?? 0} running
</span>
<Button
size="sm"
variant="outline"
onClick={() => void threads.refetch()}
disabled={threads.isFetching}
>
{threads.isFetching ? "Refreshing…" : "Refresh"}
</Button>
</div>
{threads.isLoading ? (
<Skeleton className="h-20" />
) : threads.data?.items.length ? (
<div className="flex flex-col">
{threads.data.items.map((thread) => {
const isCancelling =
cancel.isPending && cancel.variables === thread.id
return (
<div
key={thread.id}
className="flex items-center justify-between gap-3 border-b border-border py-2 last:border-b-0"
>
<Link
to="/agents/$threadId"
params={{ threadId: thread.id }}
className="min-w-0 flex-1 hover:underline"
>
<p className="truncate text-xs font-medium text-foreground">
{thread.title}
</p>
<p className="truncate font-mono text-[11px] text-muted-foreground">
{thread.repoFullName || "no repo"} · {thread.id}
</p>
</Link>
<Button
size="sm"
variant="destructive"
disabled={cancel.isPending}
onClick={() => {
setMessage(null)
cancel.mutate(thread.id, {
onSuccess: () =>
setMessage(`Interruption requested for ${thread.title}.`),
onError: (error: Error) => setMessage(error.message),
})
}}
>
{isCancelling ? "Killing…" : "Kill"}
</Button>
</div>
)
})}
</div>
) : (
<p className="text-xs text-muted-foreground">No running agents.</p>
)}
{threads.error && (
<p className="text-xs text-destructive">{threads.error.message}</p>
)}
{message && (
<p
className={`text-xs ${cancel.isError ? "text-destructive" : "text-muted-foreground"}`}
>
{message}
</p>
)}
</div>
</SettingsSection>
)
}
const PR_URL_RE = /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/
function TriggerReviewSection() {