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:
@@ -1,10 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from cloud.auth import BearerCredential, ConfiguredBearerAuthProvider
|
||||
from cloud.config import CloudConfig
|
||||
from cloud.db_models import TaskAttemptRow
|
||||
from cloud.internal_api.api import create_internal_router
|
||||
from cloud.pool import DevicePool
|
||||
from cloud.store import CloudStore
|
||||
@@ -49,7 +53,7 @@ class _FakeToolCallingClient:
|
||||
|
||||
def _build_client(
|
||||
tmp_path, *, fake_client: _FakeToolCallingClient
|
||||
) -> tuple[TestClient, _FakeToolCallingClient]:
|
||||
) -> tuple[TestClient, _FakeToolCallingClient, DevicePool]:
|
||||
pool = DevicePool(
|
||||
CloudStore(tmp_path / "internal.sqlite3"),
|
||||
CloudConfig(stale_after_seconds=60),
|
||||
@@ -68,7 +72,7 @@ def _build_client(
|
||||
planner_client_factory=lambda: fake_client,
|
||||
)
|
||||
)
|
||||
return TestClient(app), fake_client
|
||||
return TestClient(app), fake_client, pool
|
||||
|
||||
|
||||
def _decision_payload(**overrides: object) -> dict[str, object]:
|
||||
@@ -94,7 +98,7 @@ def test_authenticated_host_resolves_planner_decision(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -113,7 +117,7 @@ def test_planner_decision_decodes_screenshot_base64(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -129,7 +133,7 @@ def test_invalid_screenshot_base64_is_rejected(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -145,7 +149,7 @@ def test_unauthenticated_request_is_rejected(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -160,7 +164,7 @@ def test_foreign_host_token_cannot_request_another_hosts_decision(tmp_path) -> N
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -176,7 +180,7 @@ def test_path_and_payload_host_id_mismatch_is_rejected(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -190,7 +194,7 @@ def test_path_and_payload_host_id_mismatch_is_rejected(tmp_path) -> None:
|
||||
|
||||
def test_provider_failure_returns_structured_error_without_crashing(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(error="anthropic timed out")
|
||||
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
|
||||
client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -203,3 +207,153 @@ def test_provider_failure_returns_structured_error_without_crashing(tmp_path) ->
|
||||
"code": "planner_unavailable",
|
||||
"detail": "anthropic timed out",
|
||||
}
|
||||
|
||||
|
||||
def _seed_attempt(pool: DevicePool, task_id: str, host_id: str = "host-a") -> None:
|
||||
"""Insert a task_attempts row so _validate_planner_context passes."""
|
||||
now = datetime.now(tz=UTC)
|
||||
with Session(pool.store.engine) as session, session.begin():
|
||||
session.add(
|
||||
TaskAttemptRow(
|
||||
task_id=task_id,
|
||||
attempt=1,
|
||||
lease_id="lease-x",
|
||||
host_id=host_id,
|
||||
device_id="device-x",
|
||||
status="dispatched",
|
||||
lease_expires_at=(now + timedelta(minutes=5)).isoformat(),
|
||||
created_at=now.isoformat(),
|
||||
completed_at=None,
|
||||
failure_reason=None,
|
||||
result_json=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_successful_decision_is_persisted_with_correct_fields(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={"x": 10, "y": 20})
|
||||
)
|
||||
client, fake_client, pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
_seed_attempt(pool, "task-log-1")
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
headers={"Authorization": "Bearer token-a"},
|
||||
json=_decision_payload(
|
||||
task_id="task-log-1",
|
||||
attempt=1,
|
||||
lease_id="lease-x",
|
||||
system_prompt="you are a test planner",
|
||||
user_prompt="tap the button now",
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
decisions = pool.store.list_planner_decisions(task_id="task-log-1", attempt=1)
|
||||
assert len(decisions) == 1
|
||||
row = decisions[0]
|
||||
assert row.step_index == 1
|
||||
assert row.system_prompt == "you are a test planner"
|
||||
assert row.user_prompt == "tap the button now"
|
||||
assert row.tool_name == "tap"
|
||||
assert '"x": 10' in row.arguments_json
|
||||
assert '"y": 20' in row.arguments_json
|
||||
|
||||
|
||||
def test_failed_decision_persists_nothing(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(error="model is down")
|
||||
client, fake_client, pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
_seed_attempt(pool, "task-log-fail")
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
headers={"Authorization": "Bearer token-a"},
|
||||
json=_decision_payload(
|
||||
task_id="task-log-fail",
|
||||
attempt=1,
|
||||
lease_id="lease-x",
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
assert pool.store.list_planner_decisions(task_id="task-log-fail", attempt=1) == []
|
||||
|
||||
|
||||
def test_screenshot_bytes_are_never_persisted_to_decision_log(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client, pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
_seed_attempt(pool, "task-log-screenshot")
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
headers={"Authorization": "Bearer token-a"},
|
||||
json=_decision_payload(
|
||||
task_id="task-log-screenshot",
|
||||
attempt=1,
|
||||
lease_id="lease-x",
|
||||
screenshot_base64="aGVsbG8gd29ybGQ=",
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
decisions = pool.store.list_planner_decisions(
|
||||
task_id="task-log-screenshot", attempt=1
|
||||
)
|
||||
assert len(decisions) == 1
|
||||
# The row has no screenshot column at all — verify the raw row text
|
||||
# does not contain the screenshot bytes.
|
||||
row_repr = repr(decisions[0])
|
||||
assert "hello world" not in row_repr
|
||||
assert "aGVsbG8" not in row_repr
|
||||
|
||||
|
||||
def test_decision_without_task_context_is_not_persisted(tmp_path) -> None:
|
||||
"""When task_id/attempt are absent, the log insert is skipped."""
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client, pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
headers={"Authorization": "Bearer token-a"},
|
||||
json=_decision_payload(), # no task_id / attempt / lease_id
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
# No task_id to query by — verify no rows exist at all via direct SQL.
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from cloud.db_models import PlannerDecisionLogRow
|
||||
|
||||
with Session(pool.store.engine) as session:
|
||||
count = session.scalar(select(func.count()).select_from(PlannerDecisionLogRow))
|
||||
assert count == 0
|
||||
|
||||
|
||||
def test_multiple_decisions_get_incrementing_step_index(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
decision=ToolCallDecision(tool_name="tap", arguments={})
|
||||
)
|
||||
client, fake_client, pool = _build_client(tmp_path, fake_client=fake_client)
|
||||
_seed_attempt(pool, "task-log-multi")
|
||||
|
||||
for i in range(3):
|
||||
response = client.post(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
headers={"Authorization": "Bearer token-a"},
|
||||
json=_decision_payload(
|
||||
task_id="task-log-multi",
|
||||
attempt=1,
|
||||
lease_id="lease-x",
|
||||
user_prompt=f"step {i}",
|
||||
),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
decisions = pool.store.list_planner_decisions(task_id="task-log-multi", attempt=1)
|
||||
assert [d.step_index for d in decisions] == [1, 2, 3]
|
||||
assert [d.user_prompt for d in decisions] == ["step 0", "step 1", "step 2"]
|
||||
|
||||
Reference in New Issue
Block a user