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
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import base64
import json
import logging
from collections.abc import Awaitable, Callable
from datetime import timedelta
@@ -43,6 +44,7 @@ from cloud.llm_providers import LlmProviderResolutionError, LlmProviderService
from cloud.planner_config import build_cloud_planner_client
from cloud.provider_secrets import ProviderSecretConfigurationError
from cloud.repository import (
AssignmentProgressSnapshot,
DeviceEnrollmentConflictError,
HostEnrollmentConflictError,
)
@@ -79,8 +81,11 @@ def create_internal_router(
if planner_token_reservation_ceiling <= 0:
raise ValueError("planner_token_reservation_ceiling must be greater than zero")
if planner_token_reservation_ttl_seconds <= 0:
raise ValueError("planner_token_reservation_ttl_seconds must be greater than zero")
raise ValueError(
"planner_token_reservation_ttl_seconds must be greater than zero"
)
router = APIRouter(prefix=version_prefix, tags=["host-agent"])
def authorize_host(request: Request, host_id: str) -> None:
principal = auth_provider.authenticate(request)
if principal is None:
@@ -303,6 +308,14 @@ def create_internal_router(
)
now = utc_now()
lease_expires_at = now + timedelta(seconds=lease_duration_seconds)
progress_snapshot: AssignmentProgressSnapshot | None = None
if payload.progress is not None:
progress_snapshot = AssignmentProgressSnapshot(
step_index=payload.progress.step_index,
step_status=payload.progress.step_status,
summary=payload.progress.summary[:500],
updated_at=now,
)
renewal_status = pool.store.renew_lease(
task_id=task_id,
attempt=payload.attempt,
@@ -310,6 +323,7 @@ def create_internal_router(
host_id=host_id,
lease_expires_at=lease_expires_at,
now=now,
progress=progress_snapshot,
)
if renewal_status == "not_found":
raise HTTPException(
@@ -407,7 +421,9 @@ def create_internal_router(
resolved_provider = resolved.profile.provider_type
resolved_model = resolved.profile.model
else:
raise LlmProviderResolutionError("no database Provider resolver configured")
raise LlmProviderResolutionError(
"no database Provider resolver configured"
)
except (LlmProviderResolutionError, ProviderSecretConfigurationError) as exc:
logger.info(
"planner-decision request failed",
@@ -429,7 +445,8 @@ def create_internal_router(
task_id=payload.task_id,
attempt=payload.attempt,
created_at=now,
expires_at=now + timedelta(seconds=planner_token_reservation_ttl_seconds),
expires_at=now
+ timedelta(seconds=planner_token_reservation_ttl_seconds),
)
except TokenBudgetExceededError as exc:
return JSONResponse(
@@ -460,7 +477,11 @@ def create_internal_router(
content=PlannerDecisionError(detail=str(exc)).model_dump(),
)
usage = decision.usage
if reservation is not None and usage is not None and usage.total_tokens is not None:
if (
reservation is not None
and usage is not None
and usage.total_tokens is not None
):
pool.store.settle_host_token_reservation(
reservation_id=reservation.id,
event_id=uuid4().hex,
@@ -471,6 +492,17 @@ def create_internal_router(
total_tokens=usage.total_tokens,
occurred_at=utc_now(),
)
if payload.task_id and payload.attempt:
pool.store.record_planner_decision(
host_id=host_id,
task_id=payload.task_id,
attempt=payload.attempt,
system_prompt=payload.system_prompt,
user_prompt=payload.user_prompt,
tool_name=decision.tool_name,
arguments_json=json.dumps(decision.arguments),
now=utc_now(),
)
logger.info(
"planner-decision request resolved",
extra={
@@ -504,7 +536,9 @@ def _validate_assignment_identity(
)
def _validate_planner_context(pool, *, host_id: str, payload: PlannerDecisionRequest) -> None:
def _validate_planner_context(
pool, *, host_id: str, payload: PlannerDecisionRequest
) -> None:
context_values = (payload.task_id, payload.attempt, payload.lease_id)
if not any(value is not None for value in context_values):
return