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:
@@ -38,6 +38,8 @@ class CloudControlConfig:
|
||||
trust_proxy_headers: bool = False
|
||||
planner_token_reservation_ceiling: int = 4096
|
||||
planner_token_reservation_ttl_seconds: int = 300
|
||||
planner_decision_log_prune_interval_seconds: float = 3600.0
|
||||
planner_decision_log_retention_days: int = 7
|
||||
|
||||
|
||||
def load_control_config(
|
||||
@@ -127,6 +129,16 @@ def load_control_config(
|
||||
"CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS",
|
||||
300,
|
||||
),
|
||||
planner_decision_log_prune_interval_seconds=_positive_float(
|
||||
values,
|
||||
"CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS",
|
||||
3600.0,
|
||||
),
|
||||
planner_decision_log_retention_days=_positive_int(
|
||||
values,
|
||||
"CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS",
|
||||
7,
|
||||
),
|
||||
)
|
||||
validate_control_config(config)
|
||||
return config
|
||||
@@ -142,9 +154,7 @@ def validate_control_config(config: CloudControlConfig) -> None:
|
||||
"CLOUD_USER_SESSION_ABSOLUTE_SECONDS must be at least the idle TTL"
|
||||
)
|
||||
if config.environment == "production" and not config.session_cookie_secure:
|
||||
raise CloudConfigurationError(
|
||||
"production requires secure user session cookies"
|
||||
)
|
||||
raise CloudConfigurationError("production requires secure user session cookies")
|
||||
|
||||
|
||||
def _positive_float(
|
||||
|
||||
@@ -106,6 +106,10 @@ class ScheduledTaskRow(Base):
|
||||
)
|
||||
lease_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
lease_expires_at: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
progress_step_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
progress_step_status: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
progress_summary: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
progress_updated_at: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
result_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
updated_at: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
@@ -128,6 +132,31 @@ class TaskAttemptRow(Base):
|
||||
result_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class PlannerDecisionLogRow(Base):
|
||||
__tablename__ = "planner_decision_log"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_planner_decision_log_task_attempt",
|
||||
"task_id",
|
||||
"attempt",
|
||||
"step_index",
|
||||
unique=True,
|
||||
),
|
||||
Index("ix_planner_decision_log_host_created", "host_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
host_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||
task_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||
attempt: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
step_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
user_prompt: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
tool_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
arguments_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
|
||||
class PluginRow(Base):
|
||||
__tablename__ = "plugins"
|
||||
|
||||
@@ -141,7 +170,9 @@ class PluginRow(Base):
|
||||
class UserRow(Base):
|
||||
__tablename__ = "cloud_users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("username_normalized", name="uq_cloud_users_username_normalized"),
|
||||
UniqueConstraint(
|
||||
"username_normalized", name="uq_cloud_users_username_normalized"
|
||||
),
|
||||
Index("ix_cloud_users_enabled_role", "enabled", "role"),
|
||||
)
|
||||
|
||||
@@ -151,7 +182,9 @@ class UserRow(Base):
|
||||
display_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
role: Mapped[str] = mapped_column(String, nullable=False)
|
||||
enabled: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default=text("1"))
|
||||
enabled: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=1, server_default=text("1")
|
||||
)
|
||||
must_change_password: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Add nullable per-assignment progress snapshot columns to scheduled_tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0008_task_progress_columns"
|
||||
down_revision = "0007_llm_provider_management"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"scheduled_tasks",
|
||||
sa.Column("progress_step_index", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduled_tasks",
|
||||
sa.Column("progress_step_status", sa.String(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduled_tasks",
|
||||
sa.Column("progress_summary", sa.String(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduled_tasks",
|
||||
sa.Column("progress_updated_at", sa.String(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("scheduled_tasks", "progress_updated_at")
|
||||
op.drop_column("scheduled_tasks", "progress_summary")
|
||||
op.drop_column("scheduled_tasks", "progress_step_status")
|
||||
op.drop_column("scheduled_tasks", "progress_step_index")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Add planner_decision_log table for D7/D8 full LLM interaction history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0009_planner_decision_log"
|
||||
down_revision = "0008_task_progress_columns"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"planner_decision_log",
|
||||
sa.Column("id", sa.String(), primary_key=True),
|
||||
sa.Column("host_id", sa.String(), nullable=False),
|
||||
sa.Column("task_id", sa.String(), nullable=False),
|
||||
sa.Column("attempt", sa.Integer(), nullable=False),
|
||||
sa.Column("step_index", sa.Integer(), nullable=False),
|
||||
sa.Column("system_prompt", sa.Text(), nullable=False),
|
||||
sa.Column("user_prompt", sa.Text(), nullable=False),
|
||||
sa.Column("tool_name", sa.String(), nullable=False),
|
||||
sa.Column("arguments_json", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.String(), nullable=False),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_planner_decision_log_task_attempt",
|
||||
"planner_decision_log",
|
||||
["task_id", "attempt", "step_index"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_planner_decision_log_host_created",
|
||||
"planner_decision_log",
|
||||
["host_id", "created_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_planner_decision_log_host_created", table_name="planner_decision_log"
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_planner_decision_log_task_attempt", table_name="planner_decision_log"
|
||||
)
|
||||
op.drop_table("planner_decision_log")
|
||||
@@ -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: ...
|
||||
|
||||
@@ -53,6 +53,10 @@ class ScheduledTask:
|
||||
failure_reason: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
progress_step_index: int | None = None
|
||||
progress_step_status: str | None = None
|
||||
progress_summary: str | None = None
|
||||
progress_updated_at: datetime | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -199,7 +203,10 @@ class TaskScheduler:
|
||||
def _matches(device: "PooledDevice", constraints: TaskConstraints) -> bool:
|
||||
if constraints.target_host_id and device.host_id != constraints.target_host_id:
|
||||
return False
|
||||
if constraints.target_device_id and device.device_id != constraints.target_device_id:
|
||||
if (
|
||||
constraints.target_device_id
|
||||
and device.device_id != constraints.target_device_id
|
||||
):
|
||||
return False
|
||||
if constraints.driver_type and device.driver_type != constraints.driver_type:
|
||||
return False
|
||||
|
||||
@@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext
|
||||
from cloud.database import create_database_engine, normalize_database_url
|
||||
|
||||
|
||||
HEAD_REVISION = "0007_llm_provider_management"
|
||||
HEAD_REVISION = "0009_planner_decision_log"
|
||||
|
||||
|
||||
class SchemaVersionError(RuntimeError):
|
||||
|
||||
@@ -10,6 +10,7 @@ authentication can be added later without changing route signatures.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Callable, Literal
|
||||
|
||||
from cloud.auth import (
|
||||
@@ -32,6 +33,8 @@ from cloud.sdk.models import (
|
||||
TaskAttemptResponse,
|
||||
TaskListItem,
|
||||
TaskListResponse,
|
||||
TaskPlannerDecisionItem,
|
||||
TaskPlannerDecisionListResponse,
|
||||
TaskStatusResponse,
|
||||
TaskSubmissionRequest,
|
||||
TaskSubmissionResponse,
|
||||
@@ -144,14 +147,16 @@ def create_cloud_router(
|
||||
failure_reason=task.failure_reason,
|
||||
target_host_id=task.constraints.target_host_id,
|
||||
target_device_id=task.constraints.target_device_id,
|
||||
progress_step_index=task.progress_step_index,
|
||||
progress_step_status=task.progress_step_status,
|
||||
progress_summary=task.progress_summary,
|
||||
progress_updated_at=task.progress_updated_at,
|
||||
)
|
||||
|
||||
@router.get("/tasks", response_model=TaskListResponse)
|
||||
def list_tasks(
|
||||
request: Request,
|
||||
status_filter: Literal[
|
||||
"queued", "assigned", "dispatched", "done", "failed"
|
||||
]
|
||||
status_filter: Literal["queued", "assigned", "dispatched", "done", "failed"]
|
||||
| None = Query(default=None, alias="status"),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
@@ -177,6 +182,10 @@ def create_cloud_router(
|
||||
target_host_id=task.constraints.target_host_id,
|
||||
target_device_id=task.constraints.target_device_id,
|
||||
created_at=task.created_at,
|
||||
progress_step_index=task.progress_step_index,
|
||||
progress_step_status=task.progress_step_status,
|
||||
progress_summary=task.progress_summary,
|
||||
progress_updated_at=task.progress_updated_at,
|
||||
)
|
||||
for task in tasks
|
||||
],
|
||||
@@ -218,6 +227,47 @@ def create_cloud_router(
|
||||
for attempt in attempts
|
||||
]
|
||||
|
||||
@router.get(
|
||||
"/tasks/{task_id}/planner-decisions",
|
||||
response_model=TaskPlannerDecisionListResponse,
|
||||
)
|
||||
def list_task_planner_decisions(
|
||||
task_id: str,
|
||||
request: Request,
|
||||
attempt: int = Query(ge=0),
|
||||
) -> TaskPlannerDecisionListResponse:
|
||||
_authorize(request, TASKS_READ_SCOPE)
|
||||
task = scheduler.store.get_task(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"task {task_id!r} not found",
|
||||
)
|
||||
records = scheduler.store.list_planner_decisions(
|
||||
task_id=task_id,
|
||||
attempt=attempt,
|
||||
)
|
||||
items: list[TaskPlannerDecisionItem] = []
|
||||
for rec in records:
|
||||
try:
|
||||
parsed_args = (
|
||||
json.loads(rec.arguments_json) if rec.arguments_json else {}
|
||||
)
|
||||
except Exception:
|
||||
parsed_args = {}
|
||||
items.append(
|
||||
TaskPlannerDecisionItem(
|
||||
step_index=rec.step_index,
|
||||
attempt=rec.attempt,
|
||||
system_prompt=rec.system_prompt,
|
||||
user_prompt=rec.user_prompt,
|
||||
tool_name=rec.tool_name,
|
||||
arguments=parsed_args,
|
||||
created_at=rec.created_at,
|
||||
)
|
||||
)
|
||||
return TaskPlannerDecisionListResponse(items=items)
|
||||
|
||||
@router.get("/devices", response_model=list[DeviceResponse])
|
||||
def list_devices(request: Request) -> list[DeviceResponse]:
|
||||
_authorize(request, POOL_READ_SCOPE)
|
||||
@@ -332,7 +382,9 @@ def _validate_task_target(pool, constraints) -> None:
|
||||
raise ValueError("target_device_id requires target_host_id")
|
||||
if constraints.target_host_id is None:
|
||||
return
|
||||
if not any(host.host_id == constraints.target_host_id for host in pool.list_hosts()):
|
||||
if not any(
|
||||
host.host_id == constraints.target_host_id for host in pool.list_hosts()
|
||||
):
|
||||
raise ValueError(f"target host {constraints.target_host_id!r} is not known")
|
||||
if constraints.target_device_id is not None and not any(
|
||||
device.host_id == constraints.target_host_id
|
||||
|
||||
@@ -37,6 +37,10 @@ class TaskStatusResponse(BaseModel):
|
||||
failure_reason: str | None = None
|
||||
target_host_id: str | None = None
|
||||
target_device_id: str | None = None
|
||||
progress_step_index: int | None = None
|
||||
progress_step_status: str | None = None
|
||||
progress_summary: str | None = None
|
||||
progress_updated_at: datetime | None = None
|
||||
|
||||
|
||||
class TaskListItem(BaseModel):
|
||||
@@ -51,6 +55,10 @@ class TaskListItem(BaseModel):
|
||||
target_host_id: str | None = None
|
||||
target_device_id: str | None = None
|
||||
created_at: datetime
|
||||
progress_step_index: int | None = None
|
||||
progress_step_status: str | None = None
|
||||
progress_summary: str | None = None
|
||||
progress_updated_at: datetime | None = None
|
||||
|
||||
|
||||
class TaskListResponse(BaseModel):
|
||||
@@ -263,3 +271,17 @@ class LlmProviderSettingsResponse(BaseModel):
|
||||
class LlmProviderProfileListResponse(BaseModel):
|
||||
settings: LlmProviderSettingsResponse
|
||||
items: list[LlmProviderProfileResponse]
|
||||
|
||||
|
||||
class TaskPlannerDecisionItem(BaseModel):
|
||||
step_index: int
|
||||
attempt: int
|
||||
system_prompt: str
|
||||
user_prompt: str
|
||||
tool_name: str
|
||||
arguments: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TaskPlannerDecisionListResponse(BaseModel):
|
||||
items: list[TaskPlannerDecisionItem]
|
||||
|
||||
@@ -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