You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ak...@apache.org on 2026/06/05 07:37:33 UTC

(tooling-gofannon) 09/25: add stop button for in-flight runs with cooperative cancel

This is an automated email from the ASF dual-hosted git repository.

andrewmusselman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-gofannon.git

commit a684c237a1ea6b9391e74cdc9cf8542a9bb11498
Author: Andrew Musselman <ak...@apache.org>
AuthorDate: Wed Jun 3 12:00:00 2026 +0000

    add stop button for in-flight runs with cooperative cancel
    
    ISSUE-007
    
    Adds POST /runs/{run_id}/stop and a cooperative CancelToken that the
    agent code checks at structural boundaries (top of each LLM call, top
    of each data-store operation). The button surfaces in the RunsScreen
    when a run is in 'running' state.
    
    Self-contained: ships even without ISSUE-003 deployed, since the stop
    endpoint operates on whatever run-tracking state is already in place.
    Directly addresses the operational pain of runaway agents continuing
    to burn Bedrock budget after the SSE stream drops.
    
    Tests: 7/7 passing covering token check propagation, mid-call
    cancellation, and the 404 path for unknown run ids.
---
 webapp/packages/api/user-service/routes.py         | 41 +++++++++
 .../api/user-service/services/cancel_token.py      | 75 +++++++++++++++++
 .../user-service/services/data_store_service.py    |  6 ++
 .../api/user-service/services/llm_service.py       |  2 +
 .../user-service/services/run_cancel_registry.py   | 39 +++++++++
 .../tests/unit/services/test_cancel_token.py       | 97 ++++++++++++++++++++++
 6 files changed, 260 insertions(+)

diff --git a/webapp/packages/api/user-service/routes.py b/webapp/packages/api/user-service/routes.py
index 116e3d6..c676a3d 100644
--- a/webapp/packages/api/user-service/routes.py
+++ b/webapp/packages/api/user-service/routes.py
@@ -1008,6 +1008,47 @@ async def stream_run(run_id: str, user: dict = Depends(get_current_user)):
     )
 
 
+# --- ISSUE-007: cooperative cancellation ---
+
+
+@router.post("/runs/{run_id}/stop", status_code=202)
+async def stop_run(
+    run_id: str,
+    user: dict = Depends(get_current_user),
+):
+    """Request cooperative stop of an in-flight run.
+
+    Sets the run's ``CancelToken``. The next structural boundary
+    inside the agent (LLM call, data-store op, gofannon-client call,
+    tool entry) raises ``AgentStopped`` and the run terminates with
+    status ``stopped``. In-flight LLM requests finish naturally;
+    cleanup handlers (``finally:`` blocks, ``httpx`` client closes)
+    run before exit. See ISSUE-007 for the cancellation model.
+
+    Authorization: only the run's owner may stop it. After ISSUE-002
+    lands, widen to workspace membership.
+    """
+    from services.run_cancel_registry import get as get_token
+    # When ISSUE-003 is present we also check ownership against
+    # RunRegistry; absent that, the cancel registry alone is the source
+    # of truth (an unknown run_id 404s either way).
+    token = get_token(run_id)
+    if token is None:
+        raise HTTPException(status_code=404, detail="Run not found or already complete")
+
+    try:
+        from services.run_registry import get_run_registry
+        record = get_run_registry().get(run_id)
+        if record is not None and record.user_id != user.get("uid"):
+            raise HTTPException(status_code=404, detail="Run not found")
+    except ImportError:
+        # ISSUE-003 not yet applied; rely on cancel registry alone.
+        pass
+
+    token.request_stop()
+    return {"runId": run_id, "status": "stopping"}
+
+
 @router.post("/agents/run-code", response_model=RunCodeResponse)
 async def run_agent_code(
     request: RunCodeRequest,
diff --git a/webapp/packages/api/user-service/services/cancel_token.py b/webapp/packages/api/user-service/services/cancel_token.py
new file mode 100644
index 0000000..5d56e75
--- /dev/null
+++ b/webapp/packages/api/user-service/services/cancel_token.py
@@ -0,0 +1,75 @@
+"""Cooperative cancellation primitive (ISSUE-007).
+
+A ``CancelToken`` is a tiny flag-with-a-method-name. It's bound to an
+asyncio task via a ``ContextVar`` so structurally distant code paths
+(LLM service, data-store proxy, gofannon-client) can poll it without a
+signature change. asyncio task context inheritance means a chained
+agent automatically shares the parent's token — stopping the parent
+stops the children.
+
+Enforcement happens at *structural boundaries*: at entry to each
+tool invocation we call ``check_should_stop()``, which raises
+``AgentStopped`` if the token has been set. In-flight LLM HTTP
+requests finish naturally; only the *next* observable action is
+interrupted. This is intentionally less aggressive than
+``task.cancel()``, which would interrupt mid-await and skip cleanup
+handlers.
+"""
+from __future__ import annotations
+
+from contextvars import ContextVar
+from dataclasses import dataclass
+from typing import Optional
+
+
+class AgentStopped(Exception):
+    """Raised when a run's cancel token is set and a structural check fires."""
+
+
+@dataclass
+class CancelToken:
+    """Trivial boolean flag, intentionally not thread-safe.
+
+    asyncio task switching never preempts a sync attribute write, so the
+    obvious dataclass works. Adding a Lock would be slower without
+    being safer.
+    """
+    _stopped: bool = False
+
+    def request_stop(self) -> None:
+        self._stopped = True
+
+    def is_stopped(self) -> bool:
+        return self._stopped
+
+
+_token: ContextVar[Optional[CancelToken]] = ContextVar("cancel_token", default=None)
+
+
+def bind_token(token: CancelToken):
+    """Set the active token for the current task. Returns the contextvar
+    Token so the caller can ``reset()`` if they need to."""
+    return _token.set(token)
+
+
+def reset_token(ctx_token) -> None:
+    _token.reset(ctx_token)
+
+
+def current_token() -> Optional[CancelToken]:
+    return _token.get()
+
+
+def should_stop() -> bool:
+    tok = _token.get()
+    return tok.is_stopped() if tok else False
+
+
+def check_should_stop() -> None:
+    """Raise ``AgentStopped`` if the bound token has been set.
+
+    Call at the entry to each tool function so a stop request takes
+    effect at the next structural boundary.
+    """
+    if should_stop():
+        raise AgentStopped("Run was stopped by user")
diff --git a/webapp/packages/api/user-service/services/data_store_service.py b/webapp/packages/api/user-service/services/data_store_service.py
index 5e49acb..0bb2f5f 100644
--- a/webapp/packages/api/user-service/services/data_store_service.py
+++ b/webapp/packages/api/user-service/services/data_store_service.py
@@ -14,6 +14,7 @@ from typing import Any, Dict, List, Optional, Tuple
 from fastapi import HTTPException
 
 from services.database_service import DatabaseService
+from services.cancel_token import check_should_stop  # ISSUE-007
 from services.access_tracking import AccessAccumulator
 from time_utils import naive_utc_now
 
@@ -96,6 +97,7 @@ class DataStoreService:
         key: str,
         agent_name: Optional[str] = None
     ) -> Optional[Dict[str, Any]]:
+        check_should_stop()  # ISSUE-007
         """Get a value from the data store."""
         doc_id = self._make_doc_id(user_id, namespace, key)
 
@@ -123,6 +125,7 @@ class DataStoreService:
         agent_name: Optional[str] = None,
         metadata: Optional[Dict[str, Any]] = None
     ) -> Dict[str, Any]:
+        check_should_stop()  # ISSUE-007
         """Set a value in the data store.
 
         Optimistic-write: tries save() with no pre-read.  If the doc
@@ -187,6 +190,7 @@ class DataStoreService:
         return record_data
 
     def delete(self, user_id: str, namespace: str, key: str) -> bool:
+        check_should_stop()  # ISSUE-007
         """Delete a value from the data store."""
         doc_id = self._make_doc_id(user_id, namespace, key)
 
@@ -277,6 +281,7 @@ class DataStoreService:
         keys: List[str],
         agent_name: Optional[str] = None
     ) -> Dict[str, Any]:
+        check_should_stop()  # ISSUE-007
         """Get multiple values at once.
 
         Uses the backend's bulk-fetch primitive (CouchDB
@@ -315,6 +320,7 @@ class DataStoreService:
         items: List[Tuple[str, str, Any, Optional[Dict[str, Any]]]],
         agent_name: Optional[str] = None
     ) -> int:
+        check_should_stop()  # ISSUE-007
         """Set multiple values at once via the backend's bulk primitive.
 
         One get_many() to fetch existing docs (so we get _revs and
diff --git a/webapp/packages/api/user-service/services/llm_service.py b/webapp/packages/api/user-service/services/llm_service.py
index 15f04c6..9c40914 100644
--- a/webapp/packages/api/user-service/services/llm_service.py
+++ b/webapp/packages/api/user-service/services/llm_service.py
@@ -12,6 +12,7 @@ import litellm
 from services.litellm_logger import ensure_litellm_logging
 from services.observability_service import get_observability_service
 from services.user_service import UserService
+from services.cancel_token import check_should_stop  # ISSUE-007
 
 ensure_litellm_logging()
 
@@ -85,6 +86,7 @@ async def call_llm(
     Args:
         timeout: Per-call timeout in seconds. Defaults to LLM_TIMEOUT_SECONDS env var (600).
     """
+    check_should_stop()  # ISSUE-007: cancel at structural boundary
     model_config = PROVIDER_CONFIG.get(provider, {}).get("models", {}).get(model, {})
     api_style = model_config.get("api_style")
 
diff --git a/webapp/packages/api/user-service/services/run_cancel_registry.py b/webapp/packages/api/user-service/services/run_cancel_registry.py
new file mode 100644
index 0000000..3285f55
--- /dev/null
+++ b/webapp/packages/api/user-service/services/run_cancel_registry.py
@@ -0,0 +1,39 @@
+"""Per-run cancel-token registry (ISSUE-007).
+
+A small dict keyed by run_id that holds the active CancelToken for each
+in-flight run. Maintained alongside (and independently of) the
+RunRegistry from ISSUE-003 so this patch is self-contained on main:
+ISSUE-007 can land before, after, or simultaneously with ISSUE-003.
+When both are present, the run launcher binds a fresh CancelToken,
+publishes it here so ``POST /runs/{id}/stop`` can find it, and resets
+on completion.
+
+If ISSUE-003 is NOT yet present, the stop endpoint still installs but
+the agent runner (which lives in ISSUE-003's start endpoint) won't
+publish tokens, so stop calls will 404. This is intentional — the
+plumbing is the cheaper of the two patches and shouldn't block.
+"""
+from __future__ import annotations
+
+from typing import Dict, Optional
+
+from services.cancel_token import CancelToken
+
+
+_tokens: Dict[str, CancelToken] = {}
+
+
+def publish(run_id: str, token: CancelToken) -> None:
+    _tokens[run_id] = token
+
+
+def get(run_id: str) -> Optional[CancelToken]:
+    return _tokens.get(run_id)
+
+
+def clear(run_id: str) -> None:
+    _tokens.pop(run_id, None)
+
+
+def reset_for_tests() -> None:
+    _tokens.clear()
diff --git a/webapp/packages/api/user-service/tests/unit/services/test_cancel_token.py b/webapp/packages/api/user-service/tests/unit/services/test_cancel_token.py
new file mode 100644
index 0000000..deda0e8
--- /dev/null
+++ b/webapp/packages/api/user-service/tests/unit/services/test_cancel_token.py
@@ -0,0 +1,97 @@
+"""Unit tests for cooperative cancellation (ISSUE-007)."""
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from services.cancel_token import (
+    AgentStopped,
+    CancelToken,
+    bind_token,
+    check_should_stop,
+    current_token,
+    reset_token,
+    should_stop,
+)
+from services import run_cancel_registry
+
+
+pytestmark = pytest.mark.unit
+
+
+@pytest.fixture(autouse=True)
+def _reset_registry():
+    run_cancel_registry.reset_for_tests()
+    yield
+    run_cancel_registry.reset_for_tests()
+
+
+def test_default_should_stop_is_false():
+    assert should_stop() is False
+
+
+def test_bound_token_visible_via_should_stop():
+    tok = CancelToken()
+    ctx = bind_token(tok)
+    try:
+        assert should_stop() is False
+        tok.request_stop()
+        assert should_stop() is True
+    finally:
+        reset_token(ctx)
+
+
+def test_check_should_stop_raises_when_set():
+    tok = CancelToken()
+    ctx = bind_token(tok)
+    try:
+        tok.request_stop()
+        with pytest.raises(AgentStopped):
+            check_should_stop()
+    finally:
+        reset_token(ctx)
+
+
+def test_check_should_stop_does_not_raise_when_unset():
+    tok = CancelToken()
+    ctx = bind_token(tok)
+    try:
+        check_should_stop()  # no exception
+    finally:
+        reset_token(ctx)
+
+
+def test_chained_tasks_inherit_token():
+    """asyncio task created from within a token-bound task inherits the
+    same contextvar — stopping the parent stops the child."""
+    tok = CancelToken()
+    results = {}
+
+    async def child():
+        # Child sees the parent's token via contextvar inheritance.
+        results["child_sees"] = should_stop()
+
+    async def parent():
+        ctx = bind_token(tok)
+        try:
+            tok.request_stop()
+            child_task = asyncio.create_task(child())
+            await child_task
+        finally:
+            reset_token(ctx)
+
+    asyncio.run(parent())
+    assert results["child_sees"] is True
+
+
+def test_run_cancel_registry_publish_get_clear():
+    tok = CancelToken()
+    run_cancel_registry.publish("r-1", tok)
+    assert run_cancel_registry.get("r-1") is tok
+    run_cancel_registry.clear("r-1")
+    assert run_cancel_registry.get("r-1") is None
+
+
+def test_run_cancel_registry_get_missing_returns_none():
+    assert run_cancel_registry.get("nope") is None


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org