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
@@ -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(
+35 -2
View File
@@ -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")
+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: ...
+8 -1
View File
@@ -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
+1 -1
View File
@@ -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):
+56 -4
View File
@@ -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]
+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),
)