Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions openhands/automation/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
status,
)
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select, update
from sqlalchemy import case, func, select, update
from sqlalchemy.engine import CursorResult
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
Expand Down Expand Up @@ -896,6 +896,14 @@ async def cancel_run(
completed_at=now,
error_detail="Cancelled by user",
status_detail=None,
# A cancelled run is terminal, so it can never open or continue
# its conversation afterwards: release its subject atomically in
# this same update, whether or not it ever recorded a sandbox.
# Runs without a subject keep NULL either way.
subject_released_at=case(
(AutomationRun.subject_key.is_not(None), now),
else_=AutomationRun.subject_released_at,
),
)
)
db_result: CursorResult = await session.execute(stmt) # type: ignore[assignment]
Expand All @@ -918,15 +926,11 @@ async def cancel_run(
)

# Clean up sandbox for runs that were RUNNING. Cancelling is explicit, so
# unlike `complete_run` the sandbox goes even when the run owns a subject
# -- but the subject is released with it, or the next event would pick this
# run and pay a lookup for a sandbox we just deleted. The key stays on the
# row as the record of what this run was about.
# unlike `complete_run` the sandbox goes even when the run owned a subject
# (already released by the terminal update above, so the next event will
# not pick this run). The key stays on the row as the record of what this
# run was about.
if run.sandbox_id:
if run.subject_key and run.subject_released_at is None:
run.subject_released_at = utcnow()
await session.commit()

from openhands.automation.config import get_settings

settings = get_settings()
Expand Down
62 changes: 62 additions & 0 deletions tests/test_cancel_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,68 @@ async def test_cancel_other_orgs_run_returns_403(async_client, async_session):
assert resp.status_code == 403


async def test_cancel_subject_run_without_sandbox_releases_subject(
async_client, async_session
):
"""Cancelling a subject run with no sandbox still releases the subject."""
_, run = await _create_automation_with_run(
async_session, status=AutomationRunStatus.RUNNING
)
run.subject_key = "team/C123/1755000000.000100"
await async_session.commit()

resp = await async_client.post(f"/api/automation/v1/runs/{run.id}/cancel")
assert resp.status_code == 200

await async_session.refresh(run)
assert run.status == AutomationRunStatus.CANCELLED
assert run.subject_released_at is not None
assert run.subject_key == "team/C123/1755000000.000100"


async def test_cancel_ordinary_run_touches_no_subject(async_client, async_session):
"""Cancelling a run without a subject leaves subject columns alone."""
_, run = await _create_automation_with_run(
async_session, status=AutomationRunStatus.RUNNING
)

resp = await async_client.post(f"/api/automation/v1/runs/{run.id}/cancel")
assert resp.status_code == 200

await async_session.refresh(run)
assert run.status == AutomationRunStatus.CANCELLED
assert run.subject_key is None
assert run.subject_released_at is None


async def test_cancelled_subject_no_longer_blocks_resubmission(
async_client, async_session
):
"""After cancel, a resubmitted event routes to a new run instead of being
folded into the cancelled run that still holds the subject."""
from openhands.automation.conversations import continue_conversation

automation, run = await _create_automation_with_run(
async_session, status=AutomationRunStatus.PENDING
)
run.subject_key = "team/C123/1755000000.000100"
await async_session.commit()

resp = await async_client.post(f"/api/automation/v1/runs/{run.id}/cancel")
assert resp.status_code == 200

result = await continue_conversation(
async_session,
org_id=TEST_ORG_ID,
source="slack",
subject_key="team/C123/1755000000.000100",
automation_id=automation.id,
event_key="Ev2",
event_payload={},
)
assert result.needs_run is True


async def test_cancel_same_org_other_users_run(async_client, async_session):
"""Cancelling a run owned by another member of the same org should succeed."""
automation = Automation(
Expand Down
Loading