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>
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user