Files
agentic-mobile-control/tests/test_cloud_task_progress_visibility.py
T
q792602257andClaude Opus 4.6 ec261d57c2 feat: surface task execution progress across Host Agent and Cloud
Host Agent now persists step-level execution detail locally (via a real
TaskMetadataStore/Timeline wired into TaskRunner) and reports a bounded
in-progress snapshot piggybacked on lease renewal. Cloud persists that
snapshot per active assignment and exposes it through the existing task
list/detail query path; Cloud Console renders it as a live badge. Host
Agent's local console gains authenticated, read-only task list and
detail/timeline pages (same-origin, server-rendered) with inlined
screenshots.

Also fixes a pre-existing gap in the shared Timeline: the actual
per-step LLM prompt is now recorded instead of the task goal, benefiting
both Runtime and Host Agent consoles. When a host uses the cloud planner
transport, each decide call's prompt and resulting tool decision are
durably logged in a new planner_decision_log table (with bounded
retention) and browsable from Cloud Console; direct-transport hosts
explicitly surface a "not reported" state.

Includes Alembic migrations 0008 (progress columns on scheduled_tasks)
and 0009 (planner_decision_log), bounded Host-Agent-local retention,
dual-backend repository parity, and Vitest + pytest coverage. Task 6.5
(manual end-to-end device verification) remains.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 12:47:49 +08:00

317 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 == "renewed"
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()