feat: checkpoint device agent runtime milestones

This commit is contained in:
2026-07-06 17:24:03 +08:00
parent 2d4251e98e
commit 5658735bca
153 changed files with 8060 additions and 65 deletions
@@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: Host registration and heartbeat sync
The system SHALL provide a `DevicePool` that tracks a `HostRegistration` (host id, address, last-seen timestamp) for each host process that registers itself, and SHALL update a host's last-seen timestamp whenever that host pushes a device snapshot via `sync_host_devices(host_id, snapshot)`.
#### Scenario: New host registers and syncs devices
- **WHEN** a previously-unknown `host_id` calls `sync_host_devices` with a list of devices
- **THEN** the pool creates a new `HostRegistration` for that host, records the current time as its last-seen timestamp, and stores each synced device as a `PooledDevice` owned by that host
#### Scenario: Known host re-syncs
- **WHEN** an already-registered `host_id` calls `sync_host_devices` again with an updated device snapshot
- **THEN** the pool updates that host's last-seen timestamp and replaces its previously-stored `PooledDevice` records with the new snapshot, without duplicating or losing devices from other hosts
### Requirement: Aggregated device listing across hosts
The system SHALL provide a way to list all `PooledDevice` records across every registered host, including each device's owning `host_id`, `driver_type`, status, and capability tags.
#### Scenario: Listing devices across multiple hosts
- **WHEN** two hosts have each synced a non-empty device snapshot
- **THEN** a caller listing pool devices sees devices from both hosts in one combined result, each tagged with its correct `host_id`
#### Scenario: No hosts registered
- **WHEN** a caller lists pool devices before any host has ever synced
- **THEN** the pool returns an empty list rather than raising an error
### Requirement: Stale host devices degrade to unreachable
The system SHALL mark all `PooledDevice`s belonging to a host `unreachable` once that host's last-seen timestamp exceeds a configured staleness threshold, computed at read time, without requiring any background process and without raising an error for the stale host's absence.
#### Scenario: Host misses its sync interval
- **WHEN** a host's last-seen timestamp is older than `config.stale_after_seconds` at the time of a `list_devices()`/`get_device()` call
- **THEN** every `PooledDevice` owned by that host is reported with status `unreachable`, regardless of the status value in its last-synced snapshot
#### Scenario: Host resumes syncing after being stale
- **WHEN** a host previously marked stale calls `sync_host_devices` again
- **THEN** its devices immediately stop being reported `unreachable` and reflect the statuses in the new snapshot
### Requirement: Device lookup by id across the pool
The system SHALL allow looking up a single `PooledDevice` by `device_id` regardless of which host owns it, returning a clear not-found result when no host has ever reported that device id.
#### Scenario: Lookup finds device on any host
- **WHEN** a caller requests a device by id that exists in some host's synced snapshot
- **THEN** the pool returns that `PooledDevice` including its owning `host_id`
#### Scenario: Lookup for unknown device id
- **WHEN** a caller requests a device by id that no host has ever synced
- **THEN** the pool returns a not-found result (e.g. `None`) rather than raising an unhandled exception
@@ -0,0 +1,63 @@
## ADDED Requirements
### Requirement: Versioned public API surface
The system SHALL expose the platform SDK's REST endpoints under a versioned URL prefix (`/v1/...`), distinct from the `mcp-tool-server` and `console-status-api`/`console-config-api` surfaces, so external integrators have a stable base path that will not silently change shape.
#### Scenario: Routes are mounted under the version prefix
- **WHEN** the platform SDK's router is mounted into an application
- **THEN** every route it exposes (task submission, status queries, device/host listing, plugin listing/registration) is reachable only under the `/v1/` prefix
### Requirement: Task submission and status via the SDK
The system SHALL allow an external integrator to submit a task (goal or workflow reference plus constraints) through the platform SDK's API, and to query that task's current status by id, backed by the `task-scheduler` capability.
#### Scenario: Submit a task via the API
- **WHEN** an integrator calls the task-submission endpoint with a valid goal and optional constraints
- **THEN** the API returns a task id that can be used to poll status, and the underlying `task-scheduler` records a new `queued` `ScheduledTask`
#### Scenario: Query status of a known task
- **WHEN** an integrator requests status for a task id that exists
- **THEN** the API returns that task's current status (`queued`, `assigned`, `dispatched`, `done`, or `failed`)
#### Scenario: Query status of an unknown task
- **WHEN** an integrator requests status for a task id that does not exist
- **THEN** the API returns a not-found response rather than an unhandled server error
### Requirement: Device and host visibility via the SDK
The system SHALL allow an external integrator to list devices and hosts known to the `device-pool` capability through the platform SDK's API.
#### Scenario: List devices across the pool
- **WHEN** an integrator calls the device-listing endpoint
- **THEN** the API returns every `PooledDevice` known to the pool, including owning host id and current (possibly `unreachable`) status
#### Scenario: List registered hosts
- **WHEN** an integrator calls the host-listing endpoint
- **THEN** the API returns every `HostRegistration` known to the pool, including last-seen timestamp
### Requirement: Plugin listing and registration via the SDK
The system SHALL allow an external integrator to list registered plugins and submit a new plugin manifest for registration through the platform SDK's API, backed by the `plugin-system` capability.
#### Scenario: List registered plugins
- **WHEN** an integrator calls the plugin-listing endpoint
- **THEN** the API returns every registered `PluginManifest`, including its `entry_point_kind` and whether it is wired to an execution path
#### Scenario: Register a new plugin manifest
- **WHEN** an integrator submits a valid plugin manifest to the plugin-registration endpoint
- **THEN** the API registers it via `plugin-system`'s `PluginRegistry` and returns the stored manifest, or a clear validation/conflict error if registration fails
### Requirement: Pluggable authentication hook with a safe default
The system SHALL evaluate every platform SDK route through a configurable `AuthProvider` hook, defaulting to a no-op provider that treats every caller as an anonymous, authenticated principal, so real authentication can be added later without changing route signatures.
#### Scenario: Default configuration allows anonymous access
- **WHEN** no `AuthProvider` is explicitly configured
- **THEN** every route accepts requests without rejecting them for lack of credentials
#### Scenario: Custom AuthProvider is honored
- **WHEN** a caller configures a custom `AuthProvider` that rejects a request
- **THEN** the platform SDK's routes return an authorization error for that request instead of proceeding, without any route's own handler code needing to change
### Requirement: Python SDK client mirrors the REST API
The system SHALL provide a Python client (`CloudClient`) exposing methods corresponding to each `/v1/...` route (submit task, get task status, list devices, list hosts, list plugins, register plugin), so integrators do not need to hand-construct HTTP requests.
#### Scenario: Client submits a task and retrieves status
- **WHEN** a caller uses `CloudClient` to submit a task and then fetch its status by the returned id
- **THEN** the client's methods produce the same result as calling the corresponding `/v1/...` endpoints directly over HTTP
@@ -0,0 +1,53 @@
## ADDED Requirements
### Requirement: Plugin manifest schema
The system SHALL define a `PluginManifest` schema with a unique `name`, a `version`, an `entry_point_kind` restricted to `driver`, `tool`, or `skill`, and a `target` (a dotted module:attribute reference to the plugin's implementation), and SHALL reject a manifest missing any required field or using an unrecognized `entry_point_kind`.
#### Scenario: Valid manifest accepted
- **WHEN** a manifest with all required fields and a recognized `entry_point_kind` is submitted for registration
- **THEN** the registry accepts it and stores it as a known plugin
#### Scenario: Manifest with unrecognized entry_point_kind rejected
- **WHEN** a manifest declares an `entry_point_kind` other than `driver`, `tool`, or `skill`
- **THEN** the registry rejects it with a clear validation error and does not register it
#### Scenario: Duplicate plugin name rejected
- **WHEN** a manifest is submitted whose `name` matches an already-registered plugin
- **THEN** the registry rejects the new registration with a clear conflict error rather than silently overwriting the existing entry
### Requirement: Plugin discovery via entry points and manifest files
The system SHALL discover plugin manifests both from installed Python packages declaring an entry point in the `device_agent_runtime.plugins` group and from local `plugin.json` files under a configured scan path, feeding both sources into the same validation-and-registration path.
#### Scenario: Discovery via installed entry point
- **WHEN** an installed package declares an entry point in the `device_agent_runtime.plugins` group resolving to a valid manifest
- **THEN** `PluginRegistry.discover()` finds and registers it
#### Scenario: Discovery via local manifest file
- **WHEN** a `plugin.json` file exists under the configured plugin scan path and parses into a valid manifest
- **THEN** `PluginRegistry.discover()` finds and registers it
#### Scenario: Malformed manifest file is skipped, not fatal
- **WHEN** a `plugin.json` file under the scan path fails to parse or fails schema validation
- **THEN** `PluginRegistry.discover()` skips that file, records it as a discovery error, and continues discovering remaining plugins rather than aborting the whole scan
### Requirement: Driver-kind plugins register into the driver registry extension point
The system SHALL, for a manifest with `entry_point_kind == "driver"`, resolve its `target` to a driver-factory builder and register it under the manifest's `name` as a new `driver_type` in the existing driver-registry extension point, without requiring any edit to the `driver` package's own files.
#### Scenario: Driver plugin registered successfully
- **WHEN** a valid `driver`-kind manifest is registered and its `target` resolves to a callable driver-factory builder
- **THEN** the manifest's `name` becomes usable as a `driver_type` value by any caller building a driver factory, with no change to existing driver-registry code
#### Scenario: Driver registry extension point unavailable
- **WHEN** a `driver`-kind manifest is registered but the driver-registry's registration function is not importable in the running environment
- **THEN** the registry raises a clear, explicit error naming the missing integration point, rather than silently accepting the manifest without wiring it
### Requirement: Tool and skill plugin manifests are accepted but explicitly marked unwired
The system SHALL accept and store `tool`- and `skill`-kind plugin manifests (listable like any other registered plugin) but SHALL report them as not wired to any execution path, rather than implying they are active.
#### Scenario: Tool-kind manifest registered
- **WHEN** a valid `tool`-kind manifest is registered
- **THEN** the registry stores it and it appears in a plugin listing with a `wired: false` indicator, and no tool dispatch path is modified as a result
#### Scenario: Skill-kind manifest registered
- **WHEN** a valid `skill`-kind manifest is registered
- **THEN** the registry stores it and it appears in a plugin listing with a `wired: false` indicator, and no skill store or execution path is modified as a result
@@ -0,0 +1,56 @@
## ADDED Requirements
### Requirement: Task submission enqueues a scheduled task
The system SHALL allow a caller to submit a task (a goal string, or a reference to a `WorkflowDefinition`, plus optional device constraints: `driver_type`, required capability tags) and SHALL enqueue it as a `ScheduledTask` with status `queued`, returning a stable task id the caller can poll.
#### Scenario: Successful submission
- **WHEN** a caller submits a task with a goal and no constraints
- **THEN** the scheduler creates a `ScheduledTask` with status `queued`, assigns it a unique id, and returns that id to the caller without blocking for a device to become available
#### Scenario: Queue depth limit reached
- **WHEN** a caller submits a task while the queue already holds `config.max_queue_depth` queued tasks
- **THEN** the scheduler rejects the submission with a clear error rather than accepting an unbounded backlog
### Requirement: Assignment matches a queued task to an idle, constraint-matching device
The system SHALL assign a queued `ScheduledTask` to an idle `PooledDevice` (as reported by the `device-pool` capability) whose `driver_type` and capability tags satisfy the task's constraints, using a named, registrable `AssignmentStrategy`.
#### Scenario: Matching idle device available
- **WHEN** `assign()` runs and at least one idle `PooledDevice` matches the head-of-queue task's constraints
- **THEN** the scheduler selects one such device via the configured `AssignmentStrategy`, transitions the task to status `assigned`, and records the chosen `device_id`/`host_id`
#### Scenario: No matching device available
- **WHEN** `assign()` runs and no idle `PooledDevice` matches the head-of-queue task's constraints
- **THEN** the task remains `queued` (not failed), and `assign()` returns without error, ready to be retried on a later call
#### Scenario: Unknown assignment strategy configured
- **WHEN** `TaskScheduler` is configured with an `AssignmentStrategy` name that is not registered
- **THEN** the scheduler raises a clear configuration error at startup/first-assign rather than silently falling back to a default strategy
### Requirement: Assignment strategies are pluggable by name
The system SHALL provide an `AssignmentStrategy` registry mapping a strategy name to an implementation, with a default `fifo_match` strategy (oldest-queued matching task first, first matching idle device), and SHALL allow a new strategy to be added by registering a name without modifying `TaskScheduler`'s control flow.
#### Scenario: Default FIFO strategy orders by submission time
- **WHEN** two tasks with satisfiable, overlapping constraints are queued in order A then B, and one matching idle device exists
- **THEN** the default `fifo_match` strategy assigns the device to task A, leaving task B queued
#### Scenario: Adding a new strategy requires no scheduler edit
- **WHEN** a new `AssignmentStrategy` implementation is registered under a new name
- **THEN** `TaskScheduler` can be configured to use it by name alone, with no change to `scheduler.py`'s assignment control flow
### Requirement: Local dispatch executes an assignment via existing runners
The system SHALL provide a `TaskDispatcher` that, for an assignment whose device is owned by the local process's own host, executes the assigned task by composing the existing `agent-runtime` task-execution entry point (for a goal-based submission) or the `workflow-orchestration` workflow-execution entry point (for a workflow-based submission), without reimplementing planning/execution/retry logic.
#### Scenario: Dispatching a goal-based assignment
- **WHEN** `TaskDispatcher.dispatch()` is called with an assignment for a goal-based `ScheduledTask` whose device is local
- **THEN** the dispatcher constructs and runs a `Task` through the existing task-execution entry point, and updates the `ScheduledTask`'s status to `done` or `failed` based on the resulting task's outcome
#### Scenario: Dispatching a workflow-based assignment
- **WHEN** `TaskDispatcher.dispatch()` is called with an assignment referencing a `WorkflowDefinition` whose device is local
- **THEN** the dispatcher runs the definition through the existing workflow-execution entry point and updates the `ScheduledTask`'s status based on the resulting workflow run's outcome
### Requirement: Remote assignments are rejected explicitly, not silently ignored
The system SHALL raise a distinct, typed error when `TaskDispatcher.dispatch()` is called for an assignment whose device is owned by a host other than the dispatching process's own host, rather than attempting execution or silently no-op'ing.
#### Scenario: Assignment targets a remote host's device
- **WHEN** `TaskDispatcher.dispatch()` is called with an assignment whose `host_id` does not match the local process's own host id
- **THEN** the dispatcher raises a `RemoteDispatchNotSupportedError` and leaves the `ScheduledTask`'s status unchanged from `assigned`