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:
@@ -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
|
||||
|
||||
@@ -78,11 +78,18 @@ class ClaimResponse(BaseModel):
|
||||
timed_out: bool = False
|
||||
|
||||
|
||||
class TaskProgressModel(BaseModel):
|
||||
step_index: int = Field(ge=0)
|
||||
step_status: Literal["running", "completed", "failed"]
|
||||
summary: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class LeaseRenewalRequest(BaseModel):
|
||||
host_id: str = Field(min_length=1)
|
||||
task_id: str = Field(min_length=1)
|
||||
attempt: int = Field(ge=1)
|
||||
lease_id: str = Field(min_length=1)
|
||||
progress: TaskProgressModel | None = None
|
||||
|
||||
|
||||
class LeaseRenewalResponse(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user