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:
2026-07-14 12:47:49 +08:00
co-authored by Claude Opus 4.6
parent c049c3c1b1
commit ec261d57c2
59 changed files with 3801 additions and 122 deletions
+198
View File
@@ -82,6 +82,9 @@ def test_cloud_repository_exposes_crud_and_atomic_lease_operations() -> None:
"record_task_result",
"reap_expired_leases",
"list_task_attempts",
"record_planner_decision",
"prune_planner_decision_log",
"list_planner_decisions",
"health_check",
"close",
} <= members
@@ -1553,3 +1556,198 @@ def test_task_lifecycle_logs_structured_identifiers(
assert "secret-image" not in repr(events)
finally:
database.close()
def test_record_planner_decision_assigns_incrementing_step_index(
database_url: str,
) -> None:
database = CloudDatabase(database_url)
task_id = _unique_id("planner-task")
other_task_id = _unique_id("planner-task-other")
host_id = _unique_id("planner-host")
now = datetime(2026, 7, 14, 0, 0, tzinfo=UTC)
try:
step1 = database.repository.record_planner_decision(
host_id=host_id,
task_id=task_id,
attempt=1,
system_prompt="system",
user_prompt="prompt-1",
tool_name="tap",
arguments_json='{"x": 1}',
now=now,
)
step2 = database.repository.record_planner_decision(
host_id=host_id,
task_id=task_id,
attempt=1,
system_prompt="system",
user_prompt="prompt-2",
tool_name="swipe",
arguments_json='{"y": 2}',
now=now + timedelta(seconds=1),
)
step3 = database.repository.record_planner_decision(
host_id=host_id,
task_id=task_id,
attempt=1,
system_prompt="system",
user_prompt="prompt-3",
tool_name="wait",
arguments_json="{}",
now=now + timedelta(seconds=2),
)
assert [step1, step2, step3] == [1, 2, 3]
# Different (task_id, attempt) gets its own counter starting at 1.
other_step = database.repository.record_planner_decision(
host_id=host_id,
task_id=other_task_id,
attempt=1,
system_prompt="system",
user_prompt="other",
tool_name="tap",
arguments_json="{}",
now=now,
)
assert other_step == 1
# Different attempt on the same task also gets its own counter.
attempt2_step = database.repository.record_planner_decision(
host_id=host_id,
task_id=task_id,
attempt=2,
system_prompt="system",
user_prompt="retry",
tool_name="tap",
arguments_json="{}",
now=now,
)
assert attempt2_step == 1
# Verify stored rows.
decisions = database.repository.list_planner_decisions(
task_id=task_id, attempt=1
)
assert len(decisions) == 3
assert [d.step_index for d in decisions] == [1, 2, 3]
assert [d.user_prompt for d in decisions] == [
"prompt-1",
"prompt-2",
"prompt-3",
]
assert [d.tool_name for d in decisions] == ["tap", "swipe", "wait"]
assert decisions[0].arguments_json == '{"x": 1}'
finally:
database.close()
def test_prune_planner_decision_log_deletes_only_old_terminal_tasks(
database_url: str,
) -> None:
database = CloudDatabase(database_url)
old_terminal_task = _unique_id("old-terminal")
in_flight_task = _unique_id("in-flight")
recent_terminal_task = _unique_id("recent-terminal")
host_id = _unique_id("prune-host")
now = datetime(2026, 7, 14, 12, 0, tzinfo=UTC)
try:
with Session(database.engine) as session, session.begin():
# Old terminal task (completed > window ago).
session.add(
TaskAttemptRow(
task_id=old_terminal_task,
attempt=1,
lease_id="lease-old",
host_id=host_id,
device_id="device-old",
status="done",
lease_expires_at=(now - timedelta(days=10)).isoformat(),
created_at=(now - timedelta(days=11)).isoformat(),
completed_at=(now - timedelta(days=10)).isoformat(),
failure_reason=None,
result_json=None,
)
)
# In-flight task (also old, but NOT terminal).
session.add(
TaskAttemptRow(
task_id=in_flight_task,
attempt=1,
lease_id="lease-flight",
host_id=host_id,
device_id="device-flight",
status="dispatched",
lease_expires_at=(now - timedelta(days=10)).isoformat(),
created_at=(now - timedelta(days=11)).isoformat(),
completed_at=None,
failure_reason=None,
result_json=None,
)
)
# Recently terminal task (within window).
session.add(
TaskAttemptRow(
task_id=recent_terminal_task,
attempt=1,
lease_id="lease-recent",
host_id=host_id,
device_id="device-recent",
status="done",
lease_expires_at=(now - timedelta(hours=1)).isoformat(),
created_at=(now - timedelta(hours=2)).isoformat(),
completed_at=(now - timedelta(hours=1)).isoformat(),
failure_reason=None,
result_json=None,
)
)
# Seed decision log rows for all three tasks.
for task_id in [old_terminal_task, in_flight_task, recent_terminal_task]:
database.repository.record_planner_decision(
host_id=host_id,
task_id=task_id,
attempt=1,
system_prompt="s",
user_prompt="u",
tool_name="tap",
arguments_json="{}",
now=now - timedelta(days=11),
)
# 7-day window.
deleted = database.repository.prune_planner_decision_log(
now=now,
prune_after_terminal_seconds=7 * 86_400,
)
assert deleted == 1
# Old terminal task's rows are gone.
assert (
database.repository.list_planner_decisions(
task_id=old_terminal_task, attempt=1
)
== []
)
# In-flight and recent terminal rows survive.
assert (
len(
database.repository.list_planner_decisions(
task_id=in_flight_task, attempt=1
)
)
== 1
)
assert (
len(
database.repository.list_planner_decisions(
task_id=recent_terminal_task, attempt=1
)
)
== 1
)
finally:
database.close()