mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
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
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:
parent
0f6eaaea78
commit
a534a2e247
8 changed files with 246 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue