feat(host-agent): wire skill sync + inventory report into app lifecycle

Adds host_agent/skill_sync.py (HostAgentSkillSync) which constructs the
synced + local skill stores, the Cloud API sync client, and the runner,
then drives them on the host-agent lifecycle: incremental per-host pull
into the synced catalog, fork-on-revocation, and a best-effort local-
skill inventory report to the Cloud (design D7). Wired into
create_application + run_async start/stop. Host-agent suite green
(219 passed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 08:02:31 +08:00
co-authored by Claude Opus 4.6
parent a8ba2312fc
commit 0d944ec97d
5 changed files with 214 additions and 3 deletions
+10
View File
@@ -24,6 +24,7 @@ from host_agent.local_account import LocalAccountStore
from host_agent.policy_cache import HostPolicyCacheStore
from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor
from host_agent.retention import prune_task_history
from host_agent.skill_sync import HostAgentSkillSync
from host_agent.status import AgentStatusTracker
from host_agent.web.app import create_console_app
from host_agent.web.auth import SessionManager
@@ -42,6 +43,7 @@ class HostAgentApplication:
console_enrollment_client: HostAgentEnrollmentClient | None = None
dependency_supervisor: DependencySupervisor | None = None
instance_lock: InstanceLock | None = None
skill_sync: HostAgentSkillSync | None = None
def run(self) -> None:
asyncio.run(self.run_async())
@@ -60,6 +62,8 @@ class HostAgentApplication:
)
heartbeat_stop = asyncio.Event()
heartbeat_task = asyncio.create_task(self.heartbeat.run(heartbeat_stop))
if self.skill_sync is not None:
self.skill_sync.start()
console_task = (
asyncio.create_task(self.console_server.serve())
if self.console_server is not None
@@ -110,6 +114,8 @@ class HostAgentApplication:
finally:
if self.console_enrollment_client is not None:
self.console_enrollment_client.close()
if self.skill_sync is not None:
self.skill_sync.stop()
await self.client.aclose()
if self.instance_lock is not None:
self.instance_lock.release()
@@ -263,6 +269,9 @@ def create_application(
dependency_supervisor = DependencySupervisor.from_host_agent_config(
resolved_config
)
skill_sync: HostAgentSkillSync | None = None
if resolved_config.host_id and resolved_config.token:
skill_sync = HostAgentSkillSync(resolved_config)
return HostAgentApplication(
client=client,
heartbeat=heartbeat,
@@ -271,6 +280,7 @@ def create_application(
console_enrollment_client=console_enrollment_client,
dependency_supervisor=dependency_supervisor,
instance_lock=instance_lock,
skill_sync=skill_sync,
)
except BaseException:
instance_lock.release()
@@ -47,6 +47,7 @@ class HostAgentConfig:
task_artifact_dir: Path = Path("host_agent_data/history")
task_retention_max_count: int = 50
task_retention_max_age_days: int = 7
skill_sync_interval_seconds: float = 300.0
def load_host_agent_config(
@@ -156,6 +157,9 @@ def load_host_agent_config(
task_retention_max_age_days=_positive_int(
values, "HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS", 7
),
skill_sync_interval_seconds=_positive_float(
values, "HOST_AGENT_SKILL_SYNC_INTERVAL_SECONDS", 300.0
),
)
if config.max_retry_backoff_seconds < config.retry_backoff_seconds:
raise HostAgentConfigurationError(
@@ -0,0 +1,104 @@
"""Host-agent wiring for skill sync against the Cloud Control Plane.
Constructs the synced catalog store, the local skill store, the Cloud API
sync client, and the :class:`SkillSyncRunner`, then drives them on the host
agent's lifecycle: the runner pulls incremental per-host skill deltas into the
synced catalog (forking local overrides on revocation, design D9), and a
best-effort inventory of the agent's local skills is reported to the Cloud
(design D7). Lives in ``host_agent`` (not ``runtime``) for the same boundary
reasons as :mod:`host_agent.cloud_planner_client`.
"""
from __future__ import annotations
import logging
import threading
from host_agent.config import HostAgentConfig
log = logging.getLogger(__name__)
class HostAgentSkillSync:
"""Owns the skill stores, sync runner, and inventory reporting thread."""
def __init__(
self,
config: HostAgentConfig,
*,
poll_interval: float | None = None,
http_client=None,
) -> None:
from api.skill_sync import CloudApiSkillClient, SkillSyncRunner
from storage.local_skills import LocalSkillStore
from storage.skill_catalog import SkillCatalogStore
state_dir = config.identity_path.parent
self.config = config
self.synced_store = SkillCatalogStore(db_path=state_dir / "skills.sqlite3")
self.local_store = LocalSkillStore(db_path=state_dir / "local_skills.sqlite3")
self.client = CloudApiSkillClient(
config.control_plane_url,
host_token=config.token,
client=http_client,
)
self.runner = SkillSyncRunner(
store=self.synced_store,
client=self.client,
subscriptions=[config.host_id],
poll_interval=poll_interval or config.skill_sync_interval_seconds,
local_store=self.local_store,
)
self._inventory_stop = threading.Event()
self._inventory_thread: threading.Thread | None = None
def start(self) -> None:
"""Start the sync poll loop + periodic inventory reporting."""
self.runner.start_background()
self._inventory_stop.clear()
self._inventory_thread = threading.Thread(
target=self._report_inventory_forever, daemon=True
)
self._inventory_thread.start()
log.info("skill sync started for host %s", self.config.host_id)
def stop(self) -> None:
"""Stop the sync loop + inventory thread and close the HTTP client."""
self.runner.stop_background()
self._inventory_stop.set()
if self._inventory_thread is not None:
self._inventory_thread.join(timeout=5.0)
self._inventory_thread = None
self.client.close()
def report_inventory_once(self) -> None:
"""Report the current local-skill inventory to the Cloud (best-effort)."""
try:
inventory = [
{
"id": meta.id,
"name": meta.name,
"kind": meta.kind,
"origin": "local",
}
for meta in self.local_store.list_local()
]
for override in self.local_store.list_overrides():
inventory.append(
{
"id": override.metadata.id,
"name": override.metadata.name,
"kind": override.metadata.kind,
"origin": "cloud",
"locally_overridden": True,
}
)
self.client.report_inventory(self.config.host_id, inventory)
except Exception: # best-effort: never impair local operation
log.debug("skill inventory report failed", exc_info=True)
def _report_inventory_forever(self) -> None:
# Report once at startup, then on the sync cadence.
self.report_inventory_once()
interval = self.config.skill_sync_interval_seconds
while not self._inventory_stop.wait(interval):
self.report_inventory_once()