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