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
+51
View File
@@ -195,6 +195,57 @@ def test_schema_readiness_requires_head_revision(tmp_path) -> None:
require_current_schema(database_url)
def test_planner_decision_log_migration_upgrades_and_downgrades(tmp_path) -> None:
database_url = _database_url(tmp_path)
upgrade_database(database_url, "0008_task_progress_columns")
engine = create_engine(database_url)
try:
inspector = inspect(engine)
assert "planner_decision_log" not in inspector.get_table_names()
finally:
engine.dispose()
upgrade_database(database_url)
engine = create_engine(database_url)
try:
inspector = inspect(engine)
assert "planner_decision_log" in inspector.get_table_names()
columns = {col["name"] for col in inspector.get_columns("planner_decision_log")}
assert {
"id",
"host_id",
"task_id",
"attempt",
"step_index",
"system_prompt",
"user_prompt",
"tool_name",
"arguments_json",
"created_at",
} <= columns
index_names = {
idx["name"] for idx in inspector.get_indexes("planner_decision_log")
}
assert {
"ix_planner_decision_log_task_attempt",
"ix_planner_decision_log_host_created",
} <= index_names
assert current_revision(database_url) == HEAD_REVISION
finally:
engine.dispose()
downgrade_database(database_url, "0008_task_progress_columns")
engine = create_engine(database_url)
try:
inspector = inspect(engine)
assert "planner_decision_log" not in inspector.get_table_names()
assert current_revision(database_url) == "0008_task_progress_columns"
finally:
engine.dispose()
def _create_legacy_schema(connection) -> None:
connection.exec_driver_sql(
"create table host_registrations ("