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
+107 -1
View File
@@ -4,7 +4,8 @@ import json
import logging
from dataclasses import asdict
from datetime import datetime, timedelta
from typing import Any
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from sqlalchemy import Engine, delete, func, select
from sqlalchemy.exc import IntegrityError
@@ -14,6 +15,7 @@ from cloud.db_models import (
Base,
DeviceEnrollmentRow,
HostRow,
PlannerDecisionLogRow,
PluginRow,
PooledDeviceRow,
ScheduledTaskRow,
@@ -32,6 +34,9 @@ from cloud.db_models import (
from cloud.observability import current_correlation_id
from core.models import utc_now
if TYPE_CHECKING:
from cloud.repository import AssignmentProgressSnapshot
logger = logging.getLogger(__name__)
@@ -1486,6 +1491,7 @@ class SQLAlchemyCloudRepository:
host_id: str,
lease_expires_at: datetime,
now: datetime,
progress: AssignmentProgressSnapshot | None = None,
) -> str:
with self._sessions.begin() as session:
task = session.get(
@@ -1525,6 +1531,11 @@ class SQLAlchemyCloudRepository:
task.lease_expires_at = renewed_until
task.updated_at = _iso(now)
attempt_row.lease_expires_at = renewed_until
if progress is not None:
task.progress_step_index = progress.step_index
task.progress_step_status = progress.step_status
task.progress_summary = progress.summary
task.progress_updated_at = _iso(progress.updated_at)
_log_task_lifecycle("renewed", task)
return "renewed"
@@ -1590,6 +1601,10 @@ class SQLAlchemyCloudRepository:
task.failure_reason = failure_reason
task.result_json = result_json
task.updated_at = completed_at_iso
task.progress_step_index = None
task.progress_step_status = None
task.progress_summary = None
task.progress_updated_at = None
attempt_row.status = status
attempt_row.completed_at = completed_at_iso
attempt_row.failure_reason = failure_reason
@@ -1663,6 +1678,93 @@ class SQLAlchemyCloudRepository:
).all()
return [_task_attempt_from_row(row) for row in rows]
def record_planner_decision(
self,
*,
host_id: str,
task_id: str,
attempt: int,
system_prompt: str,
user_prompt: str,
tool_name: str,
arguments_json: str,
now: datetime,
) -> int:
with self._sessions.begin() as session:
current_max = session.scalars(
select(func.coalesce(func.max(PlannerDecisionLogRow.step_index), 0))
.where(PlannerDecisionLogRow.task_id == task_id)
.where(PlannerDecisionLogRow.attempt == attempt)
).one()
next_step = current_max + 1
session.add(
PlannerDecisionLogRow(
id=uuid4().hex,
host_id=host_id,
task_id=task_id,
attempt=attempt,
step_index=next_step,
system_prompt=system_prompt,
user_prompt=user_prompt,
tool_name=tool_name,
arguments_json=arguments_json,
created_at=_iso(now),
)
)
session.flush()
return next_step
def prune_planner_decision_log(
self,
*,
now: datetime,
prune_after_terminal_seconds: int,
) -> int:
cutoff_iso = _iso(now - timedelta(seconds=prune_after_terminal_seconds))
with self._sessions.begin() as session:
terminal_task_ids = select(TaskAttemptRow.task_id).where(
TaskAttemptRow.status.in_(("done", "failed")),
TaskAttemptRow.completed_at.is_not(None),
TaskAttemptRow.completed_at <= cutoff_iso,
)
result = session.execute(
delete(PlannerDecisionLogRow).where(
PlannerDecisionLogRow.task_id.in_(terminal_task_ids)
)
)
return result.rowcount or 0
def list_planner_decisions(
self,
*,
task_id: str,
attempt: int,
) -> list[Any]:
from cloud.repository import PlannerDecisionRecord
with self._sessions() as session:
rows = session.scalars(
select(PlannerDecisionLogRow)
.where(PlannerDecisionLogRow.task_id == task_id)
.where(PlannerDecisionLogRow.attempt == attempt)
.order_by(PlannerDecisionLogRow.step_index)
).all()
return [
PlannerDecisionRecord(
id=row.id,
host_id=row.host_id,
task_id=row.task_id,
attempt=row.attempt,
step_index=row.step_index,
system_prompt=row.system_prompt,
user_prompt=row.user_prompt,
tool_name=row.tool_name,
arguments_json=row.arguments_json,
created_at=_parse_dt(row.created_at), # type: ignore[arg-type]
)
for row in rows
]
def health_check(self) -> None:
with self._sessions() as session:
session.execute(select(1))
@@ -1778,6 +1880,10 @@ def _task_from_row(row: ScheduledTaskRow) -> Any:
failure_reason=row.failure_reason,
updated_at=_parse_dt(row.updated_at),
created_at=_parse_dt(row.created_at) or utc_now(),
progress_step_index=row.progress_step_index,
progress_step_status=row.progress_step_status,
progress_summary=row.progress_summary,
progress_updated_at=_parse_dt(row.progress_updated_at),
)