feat: checkpoint device agent runtime milestones
This commit is contained in:
@@ -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`
|
||||
Reference in New Issue
Block a user