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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user