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
+120 -13
View File
@@ -22,7 +22,11 @@ if TYPE_CHECKING:
TokenUsageEvent,
UserSubmissionPolicy,
)
from cloud.llm_providers import LlmProviderProfile, LlmProviderSettings, ProviderType
from cloud.llm_providers import (
LlmProviderProfile,
LlmProviderSettings,
ProviderType,
)
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
@@ -99,6 +103,39 @@ class LeasedAssignment:
workflow_definition_id: str | None
@dataclass(frozen=True)
class AssignmentProgressSnapshot:
"""Latest in-progress step snapshot for an active assignment.
Mirrors the wire ``TaskProgressModel`` fields; carried through the
repository layer without depending on Pydantic models.
``None`` on the ``renew_lease`` parameter means "no progress reported
for this renewal" (leave any previously stored progress untouched).
"""
step_index: int
step_status: str
summary: str
updated_at: datetime
@dataclass(frozen=True)
class PlannerDecisionRecord:
"""One persisted planner decision row (D7/D8)."""
id: str
host_id: str
task_id: str
attempt: int
step_index: int
system_prompt: str
user_prompt: str
tool_name: str
arguments_json: str
created_at: datetime
class CloudRepository(Protocol):
"""Persistence port for cloud state and atomic scheduling operations."""
@@ -286,13 +323,17 @@ class CloudRepository(Protocol):
block_duration: timedelta,
) -> LoginThrottle: ...
def clear_login_throttle(self, username_normalized: str, client_bucket: str) -> None: ...
def clear_login_throttle(
self, username_normalized: str, client_bucket: str
) -> None: ...
def record_auth_audit(self, event: AuthAuditEvent) -> None: ...
def cleanup_auth_state(self, *, now: datetime, limit: int) -> int: ...
def get_user_submission_policy(self, user_id: str) -> UserSubmissionPolicy | None: ...
def get_user_submission_policy(
self, user_id: str
) -> UserSubmissionPolicy | None: ...
def upsert_user_submission_policy(
self,
@@ -305,7 +346,9 @@ class CloudRepository(Protocol):
updated_at: datetime,
) -> UserSubmissionPolicy: ...
def get_host_governance_policy(self, host_id: str) -> HostGovernancePolicy | None: ...
def get_host_governance_policy(
self, host_id: str
) -> HostGovernancePolicy | None: ...
def upsert_host_governance_policy(
self,
@@ -321,32 +364,58 @@ class CloudRepository(Protocol):
def count_active_tasks_for_host(self, host_id: str) -> int: ...
def reserve_host_token_budget(
self, *, reservation_id: str, host_id: str, usage_day: str,
reserved_tokens: int, task_id: str | None, attempt: int | None,
created_at: datetime, expires_at: datetime,
self,
*,
reservation_id: str,
host_id: str,
usage_day: str,
reserved_tokens: int,
task_id: str | None,
attempt: int | None,
created_at: datetime,
expires_at: datetime,
) -> TokenReservation | None: ...
def settle_host_token_reservation(
self, *, reservation_id: str, event_id: str, provider: str, model: str,
input_tokens: int | None, output_tokens: int | None, total_tokens: int,
self,
*,
reservation_id: str,
event_id: str,
provider: str,
model: str,
input_tokens: int | None,
output_tokens: int | None,
total_tokens: int,
occurred_at: datetime,
) -> TokenUsageEvent | None: ...
def cleanup_expired_token_reservations(self, *, now: datetime, limit: int) -> int: ...
def cleanup_expired_token_reservations(
self, *, now: datetime, limit: int
) -> int: ...
def get_host_token_usage_summary(
self, *, host_id: str, usage_day: str, now: datetime,
self,
*,
host_id: str,
usage_day: str,
now: datetime,
) -> TokenUsageSummary: ...
def list_host_token_usage_events(
self, *, host_id: str, limit: int, offset: int,
self,
*,
host_id: str,
limit: int,
offset: int,
) -> list[TokenUsageEvent]: ...
def get_llm_provider_settings(self) -> LlmProviderSettings: ...
def list_llm_provider_profiles(self) -> list[LlmProviderProfile]: ...
def get_llm_provider_profile(self, profile_id: str) -> LlmProviderProfile | None: ...
def get_llm_provider_profile(
self, profile_id: str
) -> LlmProviderProfile | None: ...
def create_llm_provider_profile(
self, profile: LlmProviderProfile
@@ -413,6 +482,7 @@ class CloudRepository(Protocol):
host_id: str,
lease_expires_at: datetime,
now: datetime,
progress: AssignmentProgressSnapshot | None = None,
) -> LeaseRenewalStatus: ...
def record_task_result(
@@ -437,6 +507,43 @@ class CloudRepository(Protocol):
def list_task_attempts(self, task_id: str) -> list[TaskAttemptRecord]: ...
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:
"""Insert one planner-decision log row, returning the assigned step_index.
``step_index`` is assigned as ``max(step_index) + 1`` scoped to
``(task_id, attempt)`` within the same transaction.
"""
def prune_planner_decision_log(
self,
*,
now: datetime,
prune_after_terminal_seconds: int,
) -> int:
"""Delete decision-log rows for tasks terminal more than the window ago.
Returns the count of deleted rows.
"""
def list_planner_decisions(
self,
*,
task_id: str,
attempt: int,
) -> list[PlannerDecisionRecord]:
"""Return all decision-log rows for a (task_id, attempt), ordered by step."""
def health_check(self) -> None: ...
def close(self) -> None: ...