Files
agentic-mobile-control/openspec/changes/cloud-runtime/tasks.md
T

12 KiB

1. Package scaffolding

  • 1.1 Create the cloud/ package (__init__.py, pool.py, scheduler.py, dispatch.py, plugins.py, store.py, config.py) and the cloud/sdk/ sub-package (__init__.py, api.py, client.py, models.py)
  • 1.2 Add cloud* to [tool.setuptools.packages.find].include in pyproject.toml
  • 1.3 Promote httpx from [dependency-groups].dev to [project].dependencies in pyproject.toml (needed at runtime by cloud/sdk/client.py)
  • 1.4 Implement cloud/config.py: CloudConfig dataclass with sync_interval_seconds, stale_after_seconds, max_queue_depth, default_assignment_strategy, api_version_prefix ("/v1"), db_path (cloud/cloud.sqlite3), each with a conservative documented default
  • 1.5 Extend the project's smoke test (that imports every package) to import cloud and cloud.sdk

2. Device pool data model and store (capability: device-pool)

  • 2.1 Implement cloud/pool.py's data types: HostRegistration{host_id, address, last_seen_at}, PooledDevice{device_id, host_id, driver_type, status, capability_tags, synced_at}
  • 2.2 Implement cloud/store.py's CloudStore(db_path) with schema creation for host_registrations and pooled_devices tables (connect-per-call sqlite3 pattern, following storage/task_metadata.py/workflow/store.py)
  • 2.3 Implement CloudStore.upsert_host(host_id, address, last_seen_at) and CloudStore.replace_host_devices(host_id, devices: list[PooledDevice]) (atomic replace of one host's device rows per sync)
  • 2.4 Implement CloudStore.list_hosts(), CloudStore.list_devices(), CloudStore.get_device(device_id)
  • 2.5 Write unit tests for CloudStore: host upsert + device replace round-trip; a second sync for the same host fully replaces (not appends to) its device rows; devices from two different hosts coexist without collision

3. DevicePool aggregation and staleness (capability: device-pool)

  • 3.1 Implement DevicePool(store: CloudStore, config: CloudConfig) with sync_host_devices(host_id, snapshot: list[core.models.Device]), converting each Device into a PooledDevice and calling CloudStore.upsert_host()/replace_host_devices()
  • 3.2 Implement DevicePool.list_devices() -> list[PooledDevice] and DevicePool.get_device(device_id) -> PooledDevice | None, both computing per-host staleness lazily at call time (now - last_seen_at > config.stale_after_seconds implies status unreachable, overriding the last-synced status) rather than via a background thread
  • 3.3 Implement DevicePool.list_hosts() -> list[HostRegistration]
  • 3.4 Write unit tests: new host sync creates a HostRegistration + PooledDevices; re-sync updates last-seen and replaces devices; a host whose last-seen exceeds the staleness threshold reports all its devices unreachable on the next list_devices()/get_device() call; a host that resyncs after being stale immediately stops being reported unreachable; lookup for an unknown device_id returns None; empty pool returns an empty list

4. TaskScheduler queue and assignment (capability: task-scheduler)

  • 4.1 Implement cloud/scheduler.py's data types: TaskConstraints{driver_type: str | None, capability_tags: list[str]}, ScheduledTask{id, goal: str | None, workflow_definition_id: str | None, constraints, status, assigned_device_id, assigned_host_id, created_at}
  • 4.2 Add scheduled_tasks table + CloudStore.enqueue_task(), CloudStore.list_queued_tasks(), CloudStore.update_task(), CloudStore.get_task(task_id) to cloud/store.py
  • 4.3 Implement AssignmentStrategy protocol/ABC (select(task, candidates: list[PooledDevice]) -> PooledDevice | None) and a registry dict[str, AssignmentStrategy]
  • 4.4 Implement the default fifo_match strategy: return the first candidate in candidates (oldest-synced-first is not required; caller already filters to idle+constraint-matching) or None if candidates is empty
  • 4.5 Implement TaskScheduler(pool: DevicePool, store: CloudStore, config: CloudConfig) with submit(goal=None, workflow_definition_id=None, constraints=None) -> str (returns task id), raising a clear error if config.max_queue_depth queued tasks already exist
  • 4.6 Implement TaskScheduler.assign() -> list[Assignment]: for each queued task (oldest first), filter pool.list_devices() to idle devices matching constraints.driver_type/capability_tags, call the configured AssignmentStrategy, and on a match transition the task to assigned recording device_id/host_id; leave unmatched tasks queued
  • 4.7 Raise a clear configuration error if config.default_assignment_strategy names a strategy not present in the registry
  • 4.8 Write unit tests: submission enqueues with status queued; queue-depth-limit rejection; assignment picks a matching idle device via fifo_match; assignment leaves a task queued when no device matches; two tasks queued in order are assigned in submission order when only one device is available; unregistered strategy name raises at configuration/first-assign time

5. TaskDispatcher composition (capability: task-scheduler)

  • 5.1 Implement cloud/dispatch.py's Assignment{task_id, device_id, host_id, goal, workflow_definition_id} and RemoteDispatchNotSupportedError
  • 5.2 Implement TaskDispatcher(local_host_id: str, task_runner_factory, workflow_runner_factory, store: CloudStore) with dispatch(assignment: Assignment) -> None
  • 5.3 Implement the goal-based dispatch path: construct core.models.Task(goal=assignment.goal, device_id=assignment.device_id), call the existing runtime.task.TaskRunner(...).run(task) (import only, no edits to runtime/), and update the ScheduledTask's status to done/failed from task.status/task.failure_reason
  • 5.4 Implement the workflow-based dispatch path: load the referenced WorkflowDefinition and call the existing workflow.runner.WorkflowRunner(...).run(definition, device_id=assignment.device_id) (import only, no edits to workflow/), mapping the resulting WorkflowRun.status to the ScheduledTask's status
  • 5.5 Implement the remote-assignment guard: if assignment.host_id != local_host_id, raise RemoteDispatchNotSupportedError before constructing any Task/WorkflowDefinition, leaving the ScheduledTask status unchanged at assigned
  • 5.6 Write unit tests: local goal-based dispatch runs a stubbed TaskRunner and updates status to done/failed correctly; local workflow-based dispatch runs a stubbed WorkflowRunner and updates status correctly; remote-host assignment raises RemoteDispatchNotSupportedError and leaves status as assigned

6. Plugin manifest and registry (capability: plugin-system)

  • 6.1 Implement cloud/plugins.py's PluginManifest{name, version, entry_point_kind: Literal["driver", "tool", "skill"], target} with validation (all fields required, entry_point_kind restricted to the three literals)
  • 6.2 Add a plugins table + CloudStore.save_plugin(), CloudStore.list_plugins(), CloudStore.get_plugin(name) to cloud/store.py, storing wired: bool alongside each manifest
  • 6.3 Implement PluginRegistry(store: CloudStore) with register(manifest: PluginManifest) -> PluginManifest: reject unknown entry_point_kind, reject duplicate name, persist via CloudStore.save_plugin()
  • 6.4 Implement driver-kind wiring: resolve manifest.target (dotted module:attribute string) to a DriverFactoryBuilder callable via importlib, and call driver_registry.register_driver_type(manifest.name, builder) if importable; raise a clear, named error if the driver-registry function is not importable in the running environment
  • 6.5 Implement tool-/skill-kind handling: store the manifest with wired=False and do not attempt any further resolution or registration
  • 6.6 Implement PluginRegistry.discover_entry_points() -> list[PluginManifest] using importlib.metadata.entry_points(group="device_agent_runtime.plugins"), resolving each entry point and registering the resulting manifest
  • 6.7 Implement PluginRegistry.discover_manifest_files(scan_path) -> list[PluginManifest] globbing plugin.json under scan_path, parsing and registering each; on parse/validation failure, record the file path + error and continue (never abort the scan)
  • 6.8 Implement PluginRegistry.discover() -> DiscoveryResult{registered: list[PluginManifest], errors: list[str]} combining both discovery sources
  • 6.9 Write unit tests: valid manifest registers successfully; unrecognized entry_point_kind rejected; duplicate name rejected; driver-kind manifest registers into a fake driver_registry.register_driver_type; driver-kind manifest raises a named error when that function is not importable; tool-/skill-kind manifests register with wired=False and touch no other registry; entry-point discovery registers a fake installed plugin; manifest-file discovery registers a valid file and skips + records a malformed one without aborting

7. Platform SDK REST API (capability: platform-sdk)

  • 7.1 Implement cloud/sdk/models.py: Pydantic request/response models for task submission, task status, device listing, host listing, plugin listing, and plugin registration
  • 7.2 Implement AuthProvider protocol (authenticate(request) -> Principal | None) and NullAuthProvider (always returns an anonymous Principal) in cloud/sdk/api.py or a small cloud/sdk/auth.py
  • 7.3 Implement create_cloud_router(*, pool: DevicePool, scheduler: TaskScheduler, plugin_registry: PluginRegistry, auth_provider: AuthProvider = NullAuthProvider(), version_prefix: str = "/v1") -> APIRouter in cloud/sdk/api.py, mirroring api/console.py's create_console_router shape
  • 7.4 Implement POST {prefix}/tasks (submit via TaskScheduler.submit()), GET {prefix}/tasks/{task_id} (status via CloudStore.get_task(), 404 on unknown id)
  • 7.5 Implement GET {prefix}/devices (via DevicePool.list_devices()) and GET {prefix}/hosts (via DevicePool.list_hosts())
  • 7.6 Implement GET {prefix}/plugins (via PluginRegistry/CloudStore.list_plugins()) and POST {prefix}/plugins (via PluginRegistry.register(), returning a validation/conflict error response on failure)
  • 7.7 Wire every route through auth_provider.authenticate(request), returning an authorization error response when it returns None
  • 7.8 Write unit tests using FastAPI's TestClient: submit-then-status round trip; unknown task id returns 404; device/host listing reflects pool state; plugin listing/registration round trip; a custom rejecting AuthProvider causes every route to return an authorization error while the default NullAuthProvider allows all of the above through unchanged

8. Python SDK client (capability: platform-sdk)

  • 8.1 Implement cloud/sdk/client.py's CloudClient(base_url, *, http_client=None) using httpx, with submit_task(), get_task_status(), list_devices(), list_hosts(), list_plugins(), register_plugin() methods matching cloud/sdk/api.py's routes
  • 8.2 Write unit tests for CloudClient against a live TestClient-backed instance of the router from task 7.3: submit + status round trip via the client returns the same result as calling the routes directly

9. Composition safety checks and full-suite validation

  • 9.1 Confirm no existing file under driver//core/, device/, runtime/, tools/, workflow/, agents/, storage/, api/console.py, or api/mcp.py is modified by this change (composition via import only, per design.md's D1/D5)
  • 9.2 Write a test that runs a real (non-mocked, stub-driver-backed) runtime.task.TaskRunner instance inside TaskDispatcher.dispatch()'s goal-based path, guarding against silent drift in agent-runtime's public run(task) -> Task contract this change composes over
  • 9.3 Run the full test suite (pytest) and confirm every existing test in tests/ passes unmodified, with only new tests/test_device_pool.py, tests/test_task_scheduler.py, tests/test_task_dispatcher.py, tests/test_plugin_registry.py, tests/test_cloud_sdk_api.py, tests/test_cloud_client.py-style files added