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