Files
agentic-mobile-control/tests/test_cloud_task_progress_visibility.py
T
q792602257 19c6669800 feat(cloud): add durable cancellation support to task repository
- Add nullable cancel_requested_at column (migration 0012)
- Widen ScheduledTaskStatus/TerminalTaskStatus to include cancelled
- Add CancellationRequestStatus + request_task_cancellation() to
  CloudRepository protocol and SQLAlchemy implementation
- renew_lease() now returns LeaseRenewalResult, surfacing whether
  cancellation is pending, instead of a bare status string
- reap_expired_leases() resolves pending-cancellation tasks to
  cancelled instead of requeuing/failing them
- record_task_result() accepts cancelled and clears
  cancel_requested_at on any terminal write

Note: internal_api/api.py's renew_assignment route still compares
renew_lease()'s return value against a bare string; it will be
updated in the next task (Internal Host<->Cloud protocol) to consume
LeaseRenewalResult and populate the new cancel_requested wire field.
2026-07-15 18:08:48 +08:00

318 lines
10 KiB
Python

"""Repository contract tests for cloud task progress columns (task 3.6).
Separate from ``test_cloud_repository_contract.py``; covers the new
``progress_*`` fields added by migration 0008.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from cloud.database import CloudDatabase
from cloud.pool import PooledDevice
from cloud.repository import AssignmentProgressSnapshot
from cloud.scheduler import ScheduledTask, TaskConstraints
def _device(device_id: str, host_id: str) -> PooledDevice:
return PooledDevice(
device_id=device_id,
host_id=host_id,
driver_type="wda",
status="idle",
capability_tags=["ios"],
synced_at=datetime(2026, 7, 12, tzinfo=UTC),
)
def _task(task_id: str, *, created_at: datetime | None = None) -> ScheduledTask:
return ScheduledTask(
id=task_id,
goal="test progress",
workflow_definition_id=None,
constraints=TaskConstraints(),
created_at=created_at or datetime(2026, 7, 12, tzinfo=UTC),
)
def _setup_assigned_and_claimed(
database: CloudDatabase, host_id: str, device_id: str, task_id: str
):
"""Enqueue, assign, and claim a task. Returns LeasedAssignment."""
now = datetime(2026, 7, 12, 5, 0, tzinfo=UTC)
database.repository.upsert_host(host_id, address=None, last_seen_at=now)
database.repository.replace_host_devices(host_id, [_device(device_id, host_id)])
database.repository.enqueue_task(_task(task_id, created_at=now))
database.repository.assign_task(
task_id=task_id,
host_id=host_id,
device_id=device_id,
lease_id="lease-1",
lease_expires_at=now + timedelta(minutes=10),
now=now,
)
assignment = database.repository.claim_assignment(
host_id=host_id,
now=now + timedelta(seconds=1),
)
assert assignment is not None
return assignment, now
# --------------------------------------------------------------------------- #
# renew_lease writes progress on success
# --------------------------------------------------------------------------- #
def test_renew_lease_writes_progress_on_success(tmp_path) -> None:
database = CloudDatabase(f"sqlite:///{(tmp_path / 'progress.sqlite3').as_posix()}")
host_id = "progress-host"
device_id = "progress-device"
task_id = "progress-task"
try:
assignment, now = _setup_assigned_and_claimed(
database, host_id, device_id, task_id
)
progress = AssignmentProgressSnapshot(
step_index=2,
step_status="running",
summary="executing step 2",
updated_at=now + timedelta(seconds=5),
)
result = database.repository.renew_lease(
task_id=task_id,
attempt=assignment.attempt,
lease_id=assignment.lease_id,
host_id=host_id,
lease_expires_at=now + timedelta(minutes=15),
now=now + timedelta(seconds=5),
progress=progress,
)
assert result.status == "renewed"
assert result.cancel_requested is False
task = database.repository.get_task(task_id)
assert task is not None
assert task.progress_step_index == 2
assert task.progress_step_status == "running"
assert task.progress_summary == "executing step 2"
assert task.progress_updated_at is not None
finally:
database.close()
# --------------------------------------------------------------------------- #
# renew_lease without progress leaves previous untouched
# --------------------------------------------------------------------------- #
def test_renew_lease_without_progress_leaves_previous_untouched(tmp_path) -> None:
database = CloudDatabase(f"sqlite:///{(tmp_path / 'progress2.sqlite3').as_posix()}")
host_id = "retain-host"
device_id = "retain-device"
task_id = "retain-task"
try:
assignment, now = _setup_assigned_and_claimed(
database, host_id, device_id, task_id
)
# First renew WITH progress.
progress = AssignmentProgressSnapshot(
step_index=1,
step_status="completed",
summary="step 1 done",
updated_at=now + timedelta(seconds=2),
)
database.repository.renew_lease(
task_id=task_id,
attempt=assignment.attempt,
lease_id=assignment.lease_id,
host_id=host_id,
lease_expires_at=now + timedelta(minutes=15),
now=now + timedelta(seconds=2),
progress=progress,
)
# Second renew WITHOUT progress (None).
database.repository.renew_lease(
task_id=task_id,
attempt=assignment.attempt,
lease_id=assignment.lease_id,
host_id=host_id,
lease_expires_at=now + timedelta(minutes=20),
now=now + timedelta(seconds=4),
progress=None,
)
task = database.repository.get_task(task_id)
assert task is not None
# Previous values must be retained, not cleared.
assert task.progress_step_index == 1
assert task.progress_step_status == "completed"
assert task.progress_summary == "step 1 done"
finally:
database.close()
# --------------------------------------------------------------------------- #
# record_task_result clears progress on terminal
# --------------------------------------------------------------------------- #
def test_record_task_result_clears_progress_on_terminal(tmp_path) -> None:
database = CloudDatabase(f"sqlite:///{(tmp_path / 'clear.sqlite3').as_posix()}")
host_id = "clear-host"
device_id = "clear-device"
task_id = "clear-task"
try:
assignment, now = _setup_assigned_and_claimed(
database, host_id, device_id, task_id
)
# Write progress first.
progress = AssignmentProgressSnapshot(
step_index=3,
step_status="running",
summary="almost done",
updated_at=now + timedelta(seconds=3),
)
database.repository.renew_lease(
task_id=task_id,
attempt=assignment.attempt,
lease_id=assignment.lease_id,
host_id=host_id,
lease_expires_at=now + timedelta(minutes=15),
now=now + timedelta(seconds=3),
progress=progress,
)
task = database.repository.get_task(task_id)
assert task is not None
assert task.progress_step_index == 3
# Record terminal result.
result = database.repository.record_task_result(
task_id=task_id,
attempt=assignment.attempt,
lease_id=assignment.lease_id,
host_id=host_id,
status="done",
failure_reason=None,
terminal_result={"output": "success"},
completed_at=now + timedelta(seconds=10),
)
assert result == "recorded"
task = database.repository.get_task(task_id)
assert task is not None
assert task.status == "done"
assert task.progress_step_index is None
assert task.progress_step_status is None
assert task.progress_summary is None
assert task.progress_updated_at is None
finally:
database.close()
# --------------------------------------------------------------------------- #
# Task list/detail response includes progress fields
# --------------------------------------------------------------------------- #
def test_list_tasks_includes_progress_fields(tmp_path) -> None:
database = CloudDatabase(f"sqlite:///{(tmp_path / 'list.sqlite3').as_posix()}")
host_id = "list-host"
device_id = "list-device"
task_id = "list-task"
try:
assignment, now = _setup_assigned_and_claimed(
database, host_id, device_id, task_id
)
progress = AssignmentProgressSnapshot(
step_index=1,
step_status="running",
summary="running step 1",
updated_at=now + timedelta(seconds=5),
)
database.repository.renew_lease(
task_id=task_id,
attempt=assignment.attempt,
lease_id=assignment.lease_id,
host_id=host_id,
lease_expires_at=now + timedelta(minutes=15),
now=now + timedelta(seconds=5),
progress=progress,
)
tasks = database.repository.list_tasks()
matching = [t for t in tasks if t.id == task_id]
assert len(matching) == 1
task = matching[0]
assert task.progress_step_index == 1
assert task.progress_step_status == "running"
assert task.progress_summary == "running step 1"
assert task.progress_updated_at is not None
finally:
database.close()
def test_get_task_includes_progress_fields(tmp_path) -> None:
database = CloudDatabase(f"sqlite:///{(tmp_path / 'get.sqlite3').as_posix()}")
host_id = "get-host"
device_id = "get-device"
task_id = "get-task"
try:
assignment, now = _setup_assigned_and_claimed(
database, host_id, device_id, task_id
)
progress = AssignmentProgressSnapshot(
step_index=5,
step_status="failed",
summary="step 5 failed",
updated_at=now + timedelta(seconds=8),
)
database.repository.renew_lease(
task_id=task_id,
attempt=assignment.attempt,
lease_id=assignment.lease_id,
host_id=host_id,
lease_expires_at=now + timedelta(minutes=15),
now=now + timedelta(seconds=8),
progress=progress,
)
task = database.repository.get_task(task_id)
assert task is not None
assert task.progress_step_index == 5
assert task.progress_step_status == "failed"
assert task.progress_summary == "step 5 failed"
finally:
database.close()
def test_progress_fields_default_null_before_any_renewal(tmp_path) -> None:
database = CloudDatabase(f"sqlite:///{(tmp_path / 'null.sqlite3').as_posix()}")
host_id = "null-host"
device_id = "null-device"
task_id = "null-task"
try:
_setup_assigned_and_claimed(database, host_id, device_id, task_id)
task = database.repository.get_task(task_id)
assert task is not None
assert task.progress_step_index is None
assert task.progress_step_status is None
assert task.progress_summary is None
assert task.progress_updated_at is None
finally:
database.close()